summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorblackst0ne <blackst0ne.ru@gmail.com>2017-08-18 14:26:11 +1100
committerblackst0ne <blackst0ne.ru@gmail.com>2017-08-18 14:26:11 +1100
commit2047991363844b4f17216f94778b432ed198a412 (patch)
tree6b90710c44dae00178ad323d796beb59737f47f1
parent7a1c5ba7471b233c994f5b5b313085d0d1292b5d (diff)
parentf3203cbbc25586df622552153cc460d2f79f414e (diff)
downloadgitlab-ce-2047991363844b4f17216f94778b432ed198a412.tar.gz
Merge remote-tracking branch 'upstream/master'
-rw-r--r--.codeclimate.yml47
-rw-r--r--.eslintignore1
-rw-r--r--.eslintrc5
-rw-r--r--.flayignore2
-rw-r--r--.gitignore11
-rw-r--r--.gitlab-ci.yml605
-rw-r--r--.gitlab/issue_templates/Bug.md31
-rw-r--r--.gitlab/issue_templates/Feature Proposal.md43
-rw-r--r--.gitlab/merge_request_templates/Database Changes.md73
-rw-r--r--.haml-lint.yml18
-rw-r--r--.nvmrc1
-rw-r--r--.rspec2
-rw-r--r--.rubocop.yml566
-rw-r--r--.rubocop_todo.yml241
-rw-r--r--.scss-lint.yml85
-rw-r--r--CHANGELOG.md940
-rw-r--r--CONTRIBUTING.md266
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--GITLAB_SHELL_VERSION2
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--Gemfile115
-rw-r--r--Gemfile.lock429
-rw-r--r--PROCESS.md154
-rw-r--r--README.md2
-rw-r--r--VERSION2
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_canceled.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_created.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_failed.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_manual.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_not_found.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_pending.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_running.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_skipped.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_success.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/dev/favicon_status_warning.icobin0 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_canceled.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_created.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_failed.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_manual.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_not_found.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_pending.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_running.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_skipped.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_success.icobin5430 -> 4286 bytes
-rw-r--r--[-rwxr-xr-x]app/assets/images/ci_favicons/favicon_status_warning.icobin5430 -> 4286 bytes
-rw-r--r--app/assets/images/i2p-step.svg4
-rw-r--r--app/assets/images/new_nav.pngbin0 -> 14322 bytes
-rw-r--r--app/assets/images/new_repo.pngbin0 -> 19292 bytes
-rw-r--r--app/assets/images/old_nav.pngbin0 -> 25617 bytes
-rw-r--r--app/assets/images/old_repo.pngbin0 -> 20668 bytes
-rw-r--r--app/assets/javascripts/activities.js5
-rw-r--r--app/assets/javascripts/ajax_loading_spinner.js2
-rw-r--r--app/assets/javascripts/api.js244
-rw-r--r--app/assets/javascripts/autosave.js44
-rw-r--r--app/assets/javascripts/awards_handler.js813
-rw-r--r--app/assets/javascripts/behaviors/autosize.js23
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js94
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js11
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js119
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js161
-rw-r--r--app/assets/javascripts/behaviors/index.js2
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js4
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js9
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js1
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js114
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js22
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js32
-rw-r--r--app/assets/javascripts/blob/create_branch_dropdown.js88
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js13
-rw-r--r--app/assets/javascripts/blob/notebook/index.js3
-rw-r--r--app/assets/javascripts/blob/pdf/index.js12
-rw-r--r--app/assets/javascripts/blob/target_branch_dropdown.js152
-rw-r--r--app/assets/javascripts/blob/template_selector.js7
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js15
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js2
-rw-r--r--app/assets/javascripts/blob/viewer/index.js149
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js5
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js50
-rw-r--r--app/assets/javascripts/boards/components/board.js32
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js11
-rw-r--r--app/assets/javascripts/boards/components/board_card.js2
-rw-r--r--app/assets/javascripts/boards/components/board_list.js37
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js11
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js79
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js105
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js1
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/header.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js23
-rw-r--r--app/assets/javascripts/boards/components/modal/list.js2
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.js2
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js5
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.js3
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js7
-rw-r--r--app/assets/javascripts/boards/models/assignee.js12
-rw-r--r--app/assets/javascripts/boards/models/issue.js34
-rw-r--r--app/assets/javascripts/boards/models/list.js53
-rw-r--r--app/assets/javascripts/boards/models/user.js12
-rw-r--r--app/assets/javascripts/boards/services/board_service.js5
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js15
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js36
-rw-r--r--app/assets/javascripts/breakpoints.js77
-rw-r--r--app/assets/javascripts/build.js346
-rw-r--r--app/assets/javascripts/build_variables.js2
-rw-r--r--app/assets/javascripts/ci_status_icons.js34
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js97
-rw-r--r--app/assets/javascripts/comment_type_toggle.js5
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js48
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js170
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue101
-rw-r--r--app/assets/javascripts/commits.js37
-rw-r--r--app/assets/javascripts/commons/bootstrap.js2
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/jquery.js1
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/copy_as_gfm.js142
-rw-r--r--app/assets/javascripts/create_label.js2
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js193
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js13
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js21
-rw-r--r--app/assets/javascripts/cycle_analytics/default_event_objects.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue55
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue106
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue100
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue60
-rw-r--r--app/assets/javascripts/deploy_keys/eventhub.js3
-rw-r--r--app/assets/javascripts/deploy_keys/index.js21
-rw-r--r--app/assets/javascripts/deploy_keys/service/index.js34
-rw-r--r--app/assets/javascripts/deploy_keys/store/index.js9
-rw-r--r--app/assets/javascripts/diff.js14
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js34
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js17
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js23
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js31
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js33
-rw-r--r--app/assets/javascripts/dispatcher.js327
-rw-r--r--app/assets/javascripts/droplab/constants.js3
-rw-r--r--app/assets/javascripts/droplab/drop_down.js128
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js126
-rw-r--r--app/assets/javascripts/droplab/hook.js29
-rw-r--r--app/assets/javascripts/droplab/hook_button.js59
-rw-r--r--app/assets/javascripts/droplab/hook_input.js68
-rw-r--r--app/assets/javascripts/droplab/keyboard.js2
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js45
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js48
-rw-r--r--app/assets/javascripts/droplab/utils.js16
-rw-r--r--app/assets/javascripts/dropzone_input.js302
-rw-r--r--app/assets/javascripts/due_date_select.js13
-rw-r--r--app/assets/javascripts/emoji/index.js100
-rw-r--r--app/assets/javascripts/emoji/support/index.js10
-rw-r--r--app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js120
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js167
-rw-r--r--app/assets/javascripts/environments/components/environment.vue143
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue37
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue9
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue156
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue29
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue34
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue8
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue124
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue125
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js15
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js3
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js27
-rw-r--r--app/assets/javascripts/experimental_flags.js14
-rw-r--r--app/assets/javascripts/extensions/array.js11
-rw-r--r--app/assets/javascripts/files_comment_button.js194
-rw-r--r--app/assets/javascripts/filterable_list.js84
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js18
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js35
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js9
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js19
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js76
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js35
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js161
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js157
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js8
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js14
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js11
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js4
-rw-r--r--app/assets/javascripts/flash.js34
-rw-r--r--app/assets/javascripts/fly_out_nav.js177
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js603
-rw-r--r--app/assets/javascripts/gl_dropdown.js186
-rw-r--r--app/assets/javascripts/gl_field_error.js4
-rw-r--r--app/assets/javascripts/gl_field_errors.js21
-rw-r--r--app/assets/javascripts/gl_form.js23
-rw-r--r--app/assets/javascripts/gpg_badges.js17
-rw-r--r--app/assets/javascripts/graphs/graphs_bundle.js2
-rw-r--r--app/assets/javascripts/graphs/graphs_charts.js61
-rw-r--r--app/assets/javascripts/graphs/graphs_show.js21
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors.js1
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_graph.js5
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_util.js1
-rw-r--r--app/assets/javascripts/group_name.js40
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue27
-rw-r--r--app/assets/javascripts/groups/components/group_identicon.vue45
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue233
-rw-r--r--app/assets/javascripts/groups/components/groups.vue39
-rw-r--r--app/assets/javascripts/groups/event_hub.js3
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js87
-rw-r--r--app/assets/javascripts/groups/index.js193
-rw-r--r--app/assets/javascripts/groups/services/groups_service.js38
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js166
-rw-r--r--app/assets/javascripts/groups_select.js5
-rw-r--r--app/assets/javascripts/helpers/issuables_helper.js27
-rw-r--r--app/assets/javascripts/helpers/user_feature_helper.js11
-rw-r--r--app/assets/javascripts/how_to_merge.js12
-rw-r--r--app/assets/javascripts/importer_status.js2
-rw-r--r--app/assets/javascripts/init_changes_dropdown.js10
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js18
-rw-r--r--app/assets/javascripts/init_legacy_filters.js15
-rw-r--r--app/assets/javascripts/init_notes.js14
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js123
-rw-r--r--app/assets/javascripts/issuable.js188
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js38
-rw-r--r--app/assets/javascripts/issuable/issuable_bundle.js1
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js42
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js70
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/help_state.js25
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js12
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/time_tracker.js117
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js66
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js160
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js158
-rw-r--r--app/assets/javascripts/issuable_context.js12
-rw-r--r--app/assets/javascripts/issuable_form.js20
-rw-r--r--app/assets/javascripts/issuable_index.js171
-rw-r--r--app/assets/javascripts/issue.js127
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue270
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue101
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue79
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue56
-rw-r--r--app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue23
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue55
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue111
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue32
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue104
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue20
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue53
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js3
-rw-r--r--app/assets/javascripts/issue_show/index.js61
-rw-r--r--app/assets/javascripts/issue_show/issue_title.vue80
-rw-r--r--app/assets/javascripts/issue_show/mixins/animate.js13
-rw-r--r--app/assets/javascripts/issue_show/mixins/update.js10
-rw-r--r--app/assets/javascripts/issue_show/services/index.js27
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js37
-rw-r--r--app/assets/javascripts/issue_status_select.js4
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js163
-rw-r--r--app/assets/javascripts/jobs/components/header.vue83
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue31
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue161
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js60
-rw-r--r--app/assets/javascripts/jobs/job_details_mediator.js66
-rw-r--r--app/assets/javascripts/jobs/services/job_service.js14
-rw-r--r--app/assets/javascripts/jobs/stores/job_store.js11
-rw-r--r--app/assets/javascripts/label_manager.js6
-rw-r--r--app/assets/javascripts/labels.js6
-rw-r--r--app/assets/javascripts/labels_select.js36
-rw-r--r--app/assets/javascripts/landing.js37
-rw-r--r--app/assets/javascripts/layout_nav.js20
-rw-r--r--app/assets/javascripts/lazy_loader.js76
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js47
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js48
-rw-r--r--app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js116
-rw-r--r--app/assets/javascripts/lib/utils/cache.js19
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js40
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/datefix.js8
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js119
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js7
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js5
-rw-r--r--app/assets/javascripts/lib/utils/notify.js85
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js10
-rw-r--r--app/assets/javascripts/lib/utils/poll.js3
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js2
-rw-r--r--app/assets/javascripts/lib/utils/simple_poll.js15
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js23
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js9
-rw-r--r--app/assets/javascripts/lib/utils/type_utility.js17
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js3
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js28
-rw-r--r--app/assets/javascripts/line_highlighter.js31
-rw-r--r--app/assets/javascripts/locale/index.js70
-rw-r--r--app/assets/javascripts/main.js89
-rw-r--r--app/assets/javascripts/member_expiration_date.js4
-rw-r--r--app/assets/javascripts/members.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js18
-rw-r--r--app/assets/javascripts/merge_request.js37
-rw-r--r--app/assets/javascripts/merge_request_tabs.js78
-rw-r--r--app/assets/javascripts/merge_request_widget.js305
-rw-r--r--app/assets/javascripts/merge_request_widget/ci_bundle.js53
-rw-r--r--app/assets/javascripts/merged_buttons.js47
-rw-r--r--app/assets/javascripts/milestone.js183
-rw-r--r--app/assets/javascripts/milestone_select.js43
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring.vue157
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_column.vue296
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_deployment.vue136
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_flag.vue104
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_legends.vue144
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_row.vue41
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_state.vue112
-rw-r--r--app/assets/javascripts/monitoring/constants.js4
-rw-r--r--app/assets/javascripts/monitoring/event_hub.js3
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js46
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js14
-rw-r--r--app/assets/javascripts/monitoring/prometheus_graph.js408
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js19
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js61
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js40
-rw-r--r--app/assets/javascripts/namespace_select.js9
-rw-r--r--app/assets/javascripts/new_branch_form.js38
-rw-r--r--app/assets/javascripts/new_commit_form.js15
-rw-r--r--app/assets/javascripts/new_sidebar.js66
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue24
-rw-r--r--app/assets/javascripts/notes.js2357
-rw-r--r--app/assets/javascripts/notifications_form.js4
-rw-r--r--app/assets/javascripts/oauth_remember_me.js32
-rw-r--r--app/assets/javascripts/pager.js9
-rw-r--r--app/assets/javascripts/pdf/assets/img/bg.gifbin0 -> 58 bytes
-rw-r--r--app/assets/javascripts/pdf/index.vue73
-rw-r--r--app/assets/javascripts/pdf/page/index.vue68
-rw-r--r--app/assets/javascripts/performance_bar.js62
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue146
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue59
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js52
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js66
-rw-r--r--app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg1
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js45
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js12
-rw-r--r--app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js71
-rw-r--r--app/assets/javascripts/pipelines.js46
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue48
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue63
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue55
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue107
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue77
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue122
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue83
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.js52
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.vue54
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.js72
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.js56
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue211
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.js89
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.vue78
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.js33
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue51
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue59
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue297
-rw-r--r--app/assets/javascripts/pipelines/components/stage.js115
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue176
-rw-r--r--app/assets/javascripts/pipelines/components/status.js60
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.js98
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.vue95
-rw-r--r--app/assets/javascripts/pipelines/index.js22
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js103
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js70
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js59
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js278
-rw-r--r--app/assets/javascripts/pipelines/pipelines_bundle.js24
-rw-r--r--app/assets/javascripts/pipelines/pipelines_charts.js38
-rw-r--r--app/assets/javascripts/pipelines/pipelines_times.js27
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js19
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js4
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js11
-rw-r--r--app/assets/javascripts/preview_markdown.js48
-rw-r--r--app/assets/javascripts/profile/gl_crop.js3
-rw-r--r--app/assets/javascripts/profile/profile_bundle.js4
-rw-r--r--app/assets/javascripts/project.js45
-rw-r--r--app/assets/javascripts/project_edit.js9
-rw-r--r--app/assets/javascripts/project_find_file.js10
-rw-r--r--app/assets/javascripts/project_new.js71
-rw-r--r--app/assets/javascripts/project_select.js22
-rw-r--r--app/assets/javascripts/project_select_combo_button.js85
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js14
-rw-r--r--app/assets/javascripts/projects/project_new.js67
-rw-r--r--app/assets/javascripts/prometheus_metrics/constants.js5
-rw-r--r--app/assets/javascripts/prometheus_metrics/index.js6
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js109
-rw-r--r--app/assets/javascripts/protected_branches/index.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js51
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js106
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js40
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js114
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit_list.js28
-rw-r--r--app/assets/javascripts/protected_branches/protected_branches_bundle.js5
-rw-r--r--app/assets/javascripts/protected_tags/index.js11
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js10
-rw-r--r--app/assets/javascripts/raven/index.js20
-rw-r--r--app/assets/javascripts/raven/raven_config.js103
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js47
-rw-r--r--app/assets/javascripts/render_gfm.js4
-rw-r--r--app/assets/javascripts/repo/components/repo.vue70
-rw-r--r--app/assets/javascripts/repo/components/repo_commit_section.vue130
-rw-r--r--app/assets/javascripts/repo/components/repo_edit_button.vue58
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue117
-rw-r--r--app/assets/javascripts/repo/components/repo_file.vue107
-rw-r--r--app/assets/javascripts/repo/components/repo_file_buttons.vue69
-rw-r--r--app/assets/javascripts/repo/components/repo_file_options.vue25
-rw-r--r--app/assets/javascripts/repo/components/repo_loading_file.vue76
-rw-r--r--app/assets/javascripts/repo/components/repo_prev_directory.vue38
-rw-r--r--app/assets/javascripts/repo/components/repo_preview.vue52
-rw-r--r--app/assets/javascripts/repo/components/repo_sidebar.vue100
-rw-r--r--app/assets/javascripts/repo/components/repo_tab.vue63
-rw-r--r--app/assets/javascripts/repo/components/repo_tabs.vue36
-rw-r--r--app/assets/javascripts/repo/helpers/monaco_loader_helper.js25
-rw-r--r--app/assets/javascripts/repo/helpers/repo_helper.js271
-rw-r--r--app/assets/javascripts/repo/index.js78
-rw-r--r--app/assets/javascripts/repo/mixins/repo_mixin.js17
-rw-r--r--app/assets/javascripts/repo/monaco_loader.js13
-rw-r--r--app/assets/javascripts/repo/services/repo_service.js80
-rw-r--r--app/assets/javascripts/repo/stores/repo_store.js199
-rw-r--r--app/assets/javascripts/right_sidebar.js23
-rw-r--r--app/assets/javascripts/search.js36
-rw-r--r--app/assets/javascripts/settings_panels.js44
-rw-r--r--app/assets/javascripts/shortcuts.js20
-rw-r--r--app/assets/javascripts/shortcuts_blob.js6
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js2
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js19
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js4
-rw-r--r--app/assets/javascripts/shortcuts_network.js2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js59
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js87
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue82
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue47
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue45
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js98
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js44
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js53
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/event_hub.js8
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js28
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js41
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js38
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js56
-rw-r--r--app/assets/javascripts/sidebar_height_manager.js37
-rw-r--r--app/assets/javascripts/signin_tabs_memoizer.js78
-rw-r--r--app/assets/javascripts/single_file_diff.js151
-rw-r--r--app/assets/javascripts/smart_interval.js253
-rw-r--r--app/assets/javascripts/snippets_list.js13
-rw-r--r--app/assets/javascripts/star.js50
-rw-r--r--app/assets/javascripts/subbable_resource.js51
-rw-r--r--app/assets/javascripts/subscription.js74
-rw-r--r--app/assets/javascripts/subscription_select.js61
-rw-r--r--app/assets/javascripts/syntax_highlight.js27
-rw-r--r--app/assets/javascripts/task_list.js8
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js2
-rw-r--r--app/assets/javascripts/terminal/terminal_bundle.js12
-rw-r--r--app/assets/javascripts/test.js1
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/todos.js15
-rw-r--r--app/assets/javascripts/tree.js120
-rw-r--r--app/assets/javascripts/two_factor_auth.js13
-rw-r--r--app/assets/javascripts/u2f/authenticate.js18
-rw-r--r--app/assets/javascripts/u2f/error.js4
-rw-r--r--app/assets/javascripts/u2f/register.js20
-rw-r--r--app/assets/javascripts/ui_development_kit.js22
-rw-r--r--app/assets/javascripts/usage_ping.js5
-rw-r--r--app/assets/javascripts/user.js35
-rw-r--r--app/assets/javascripts/user_callout.js11
-rw-r--r--app/assets/javascripts/user_tabs.js175
-rw-r--r--app/assets/javascripts/username_validator.js216
-rw-r--r--app/assets/javascripts/users/activity_calendar.js233
-rw-r--r--app/assets/javascripts/users/calendar.js225
-rw-r--r--app/assets/javascripts/users/index.js19
-rw-r--r--app/assets/javascripts/users/user_tabs.js194
-rw-r--r--app/assets/javascripts/users/users_bundle.js1
-rw-r--r--app/assets/javascripts/users_select.js1080
-rw-r--r--app/assets/javascripts/version_check_image.js7
-rw-r--r--app/assets/javascripts/visibility_select.js40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js113
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js111
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js153
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js85
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js47
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js35
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js78
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js117
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js140
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js43
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js325
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js67
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/event_hub.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js260
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js142
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js37
-rw-r--r--app/assets/javascripts/vue_shared/ci_action_icons.js21
-rw-r--r--app/assets/javascripts/vue_shared/ci_status_icons.js43
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js157
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue166
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue159
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_icon.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue112
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue113
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.js115
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js255
-rw-r--r--app/assets/javascripts/vue_shared/components/popup_dialog.vue85
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.js135
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue145
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue45
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js13
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js18
-rw-r--r--app/assets/javascripts/vue_shared/translate.js42
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js21
-rw-r--r--app/assets/javascripts/webpack.js9
-rw-r--r--app/assets/javascripts/wikis.js98
-rw-r--r--app/assets/javascripts/zen_mode.js126
-rw-r--r--app/assets/stylesheets/application.scss2
-rw-r--r--app/assets/stylesheets/framework.scss86
-rw-r--r--app/assets/stylesheets/framework/animations.scss106
-rw-r--r--app/assets/stylesheets/framework/avatar.scss23
-rw-r--r--app/assets/stylesheets/framework/awards.scss23
-rw-r--r--app/assets/stylesheets/framework/blank.scss67
-rw-r--r--app/assets/stylesheets/framework/blocks.scss79
-rw-r--r--app/assets/stylesheets/framework/buttons.scss18
-rw-r--r--app/assets/stylesheets/framework/calendar.scss3
-rw-r--r--app/assets/stylesheets/framework/common.scss22
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss194
-rw-r--r--app/assets/stylesheets/framework/emojis.scss1
-rw-r--r--app/assets/stylesheets/framework/files.scss105
-rw-r--r--app/assets/stylesheets/framework/filters.scss137
-rw-r--r--app/assets/stylesheets/framework/flash.scss20
-rw-r--r--app/assets/stylesheets/framework/forms.scss14
-rw-r--r--app/assets/stylesheets/framework/gfm.scss4
-rw-r--r--app/assets/stylesheets/framework/header.scss77
-rw-r--r--app/assets/stylesheets/framework/highlight.scss4
-rw-r--r--app/assets/stylesheets/framework/icons.scss7
-rw-r--r--app/assets/stylesheets/framework/layout.scss26
-rw-r--r--app/assets/stylesheets/framework/lists.scss123
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss52
-rw-r--r--app/assets/stylesheets/framework/media_object.scss8
-rw-r--r--app/assets/stylesheets/framework/memory_graph.scss22
-rw-r--r--app/assets/stylesheets/framework/mixins.scss33
-rw-r--r--app/assets/stylesheets/framework/mobile.scss9
-rw-r--r--app/assets/stylesheets/framework/modal.scss6
-rw-r--r--app/assets/stylesheets/framework/nav.scss95
-rw-r--r--app/assets/stylesheets/framework/notes.scss14
-rw-r--r--app/assets/stylesheets/framework/page-header.scss4
-rw-r--r--app/assets/stylesheets/framework/panels.scss90
-rw-r--r--app/assets/stylesheets/framework/responsive-tables.scss137
-rw-r--r--app/assets/stylesheets/framework/selects.scss36
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss55
-rw-r--r--app/assets/stylesheets/framework/timeline.scss62
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap_variables.scss8
-rw-r--r--app/assets/stylesheets/framework/typography.scss73
-rw-r--r--app/assets/stylesheets/framework/variables.scss104
-rw-r--r--app/assets/stylesheets/framework/wells.scss20
-rw-r--r--app/assets/stylesheets/highlight/dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss5
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss5
-rw-r--r--app/assets/stylesheets/highlight/white.scss13
-rw-r--r--app/assets/stylesheets/mailers/devise.scss140
-rw-r--r--app/assets/stylesheets/new_nav.scss450
-rw-r--r--app/assets/stylesheets/new_sidebar.scss479
-rw-r--r--app/assets/stylesheets/pages/boards.scss196
-rw-r--r--app/assets/stylesheets/pages/builds.scss360
-rw-r--r--app/assets/stylesheets/pages/ci_projects.scss1
-rw-r--r--app/assets/stylesheets/pages/commits.scss86
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss255
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss89
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss6
-rw-r--r--app/assets/stylesheets/pages/diff.scss148
-rw-r--r--app/assets/stylesheets/pages/environments.scss163
-rw-r--r--app/assets/stylesheets/pages/events.scss1
-rw-r--r--app/assets/stylesheets/pages/groups.scss21
-rw-r--r--app/assets/stylesheets/pages/issuable.scss299
-rw-r--r--app/assets/stylesheets/pages/issues.scss108
-rw-r--r--app/assets/stylesheets/pages/issues/issue_count_badge.scss29
-rw-r--r--app/assets/stylesheets/pages/labels.scss9
-rw-r--r--app/assets/stylesheets/pages/login.scss2
-rw-r--r--app/assets/stylesheets/pages/members.scss203
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss427
-rw-r--r--app/assets/stylesheets/pages/milestone.scss6
-rw-r--r--app/assets/stylesheets/pages/note_form.scss165
-rw-r--r--app/assets/stylesheets/pages/notes.scss328
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss157
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss390
-rw-r--r--app/assets/stylesheets/pages/profile.scss64
-rw-r--r--app/assets/stylesheets/pages/projects.scss243
-rw-r--r--app/assets/stylesheets/pages/repo.scss324
-rw-r--r--app/assets/stylesheets/pages/runners.scss17
-rw-r--r--app/assets/stylesheets/pages/settings.scss170
-rw-r--r--app/assets/stylesheets/pages/status.scss175
-rw-r--r--app/assets/stylesheets/pages/todos.scss5
-rw-r--r--app/assets/stylesheets/pages/tree.scss113
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss8
-rw-r--r--app/assets/stylesheets/pages/wiki.scss32
-rw-r--r--app/assets/stylesheets/performance_bar.scss111
-rw-r--r--app/assets/stylesheets/print.scss10
-rw-r--r--app/assets/stylesheets/test.scss17
-rw-r--r--app/controllers/abuse_reports_controller.rb14
-rw-r--r--app/controllers/admin/appearances_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb76
-rw-r--r--app/controllers/admin/applications_controller.rb4
-rw-r--r--app/controllers/admin/builds_controller.rb25
-rw-r--r--app/controllers/admin/conversational_development_index_controller.rb5
-rw-r--r--app/controllers/admin/dashboard_controller.rb2
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb26
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/health_check_controller.rb7
-rw-r--r--app/controllers/admin/hook_logs_controller.rb29
-rw-r--r--app/controllers/admin/hooks_controller.rb46
-rw-r--r--app/controllers/admin/identities_controller.rb4
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/jobs_controller.rb25
-rw-r--r--app/controllers/admin/keys_controller.rb4
-rw-r--r--app/controllers/admin/labels_controller.rb2
-rw-r--r--app/controllers/admin/projects_controller.rb19
-rw-r--r--app/controllers/admin/runner_projects_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/controllers/admin/services_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb4
-rw-r--r--app/controllers/admin/users_controller.rb43
-rw-r--r--app/controllers/application_controller.rb94
-rw-r--r--app/controllers/autocomplete_controller.rb10
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb2
-rw-r--r--app/controllers/concerns/creates_commit.rb14
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb9
-rw-r--r--app/controllers/concerns/diff_for_path.rb13
-rw-r--r--app/controllers/concerns/hooks_execution.rb18
-rw-r--r--app/controllers/concerns/issuable_actions.rb25
-rw-r--r--app/controllers/concerns/issuable_collections.rb46
-rw-r--r--app/controllers/concerns/issues_action.rb2
-rw-r--r--app/controllers/concerns/lfs_request.rb10
-rw-r--r--app/controllers/concerns/markdown_preview.rb19
-rw-r--r--app/controllers/concerns/membership_actions.rb26
-rw-r--r--app/controllers/concerns/milestone_actions.rb55
-rw-r--r--app/controllers/concerns/notes_actions.rb200
-rw-r--r--app/controllers/concerns/renders_blob.rb32
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/repository_settings_redirect.rb2
-rw-r--r--app/controllers/concerns/requires_health_token.rb25
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb33
-rw-r--r--app/controllers/concerns/routable_actions.rb38
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/concerns/snippets_actions.rb4
-rw-r--r--app/controllers/concerns/spammable_actions.rb20
-rw-r--r--app/controllers/concerns/toggle_award_emoji.rb3
-rw-r--r--app/controllers/concerns/uploads_actions.rb27
-rw-r--r--app/controllers/concerns/with_performance_bar.rb17
-rw-r--r--app/controllers/dashboard/groups_controller.rb28
-rw-r--r--app/controllers/dashboard/labels_controller.rb11
-rw-r--r--app/controllers/dashboard/milestones_controller.rb4
-rw-r--r--app/controllers/dashboard/projects_controller.rb23
-rw-r--r--app/controllers/dashboard/snippets_controller.rb7
-rw-r--r--app/controllers/dashboard/todos_controller.rb27
-rw-r--r--app/controllers/dashboard_controller.rb10
-rw-r--r--app/controllers/explore/groups_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb13
-rw-r--r--app/controllers/explore/snippets_controller.rb2
-rw-r--r--app/controllers/groups/application_controller.rb30
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/labels_controller.rb4
-rw-r--r--app/controllers/groups/milestones_controller.rb82
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb24
-rw-r--r--app/controllers/groups/variables_controller.rb64
-rw-r--r--app/controllers/groups_controller.rb25
-rw-r--r--app/controllers/health_check_controller.rb2
-rw-r--r--app/controllers/health_controller.rb26
-rw-r--r--app/controllers/import/github_controller.rb8
-rw-r--r--app/controllers/import/gitlab_controller.rb2
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb10
-rw-r--r--app/controllers/invites_controller.rb2
-rw-r--r--app/controllers/jwt_controller.rb14
-rw-r--r--app/controllers/metrics_controller.rb21
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb4
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb35
-rw-r--r--app/controllers/passwords_controller.rb12
-rw-r--r--app/controllers/profiles/avatars_controller.rb5
-rw-r--r--app/controllers/profiles/chat_names_controller.rb2
-rw-r--r--app/controllers/profiles/emails_controller.rb9
-rw-r--r--app/controllers/profiles/gpg_keys_controller.rb47
-rw-r--r--app/controllers/profiles/keys_controller.rb2
-rw-r--r--app/controllers/profiles/notifications_controller.rb4
-rw-r--r--app/controllers/profiles/passwords_controller.rb24
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles/preferences_controller.rb6
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb15
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb58
-rw-r--r--app/controllers/projects/application_controller.rb87
-rw-r--r--app/controllers/projects/artifacts_controller.rb40
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/badges_controller.rb6
-rw-r--r--app/controllers/projects/blob_controller.rb85
-rw-r--r--app/controllers/projects/boards/issues_controller.rb5
-rw-r--r--app/controllers/projects/boards/lists_controller.rb4
-rw-r--r--app/controllers/projects/branches_controller.rb63
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb55
-rw-r--r--app/controllers/projects/builds_controller.rb103
-rw-r--r--app/controllers/projects/commit_controller.rb17
-rw-r--r--app/controllers/projects/commits_controller.rb50
-rw-r--r--app/controllers/projects/compare_controller.rb14
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb24
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb51
-rw-r--r--app/controllers/projects/deployments_controller.rb50
-rw-r--r--app/controllers/projects/discussions_controller.rb6
-rw-r--r--app/controllers/projects/environments_controller.rb35
-rw-r--r--app/controllers/projects/forks_controller.rb4
-rw-r--r--app/controllers/projects/git_http_client_controller.rb72
-rw-r--r--app/controllers/projects/git_http_controller.rb77
-rw-r--r--app/controllers/projects/graphs_controller.rb21
-rw-r--r--app/controllers/projects/group_links_controller.rb4
-rw-r--r--app/controllers/projects/hook_logs_controller.rb33
-rw-r--r--app/controllers/projects/hooks_controller.rb42
-rw-r--r--app/controllers/projects/imports_controller.rb21
-rw-r--r--app/controllers/projects/issues_controller.rb121
-rw-r--r--app/controllers/projects/jobs_controller.rb142
-rw-r--r--app/controllers/projects/labels_controller.rb35
-rw-r--r--app/controllers/projects/lfs_api_controller.rb4
-rw-r--r--app/controllers/projects/mattermosts_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb47
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb66
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb128
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb66
-rw-r--r--[-rwxr-xr-x]app/controllers/projects/merge_requests_controller.rb615
-rw-r--r--app/controllers/projects/milestones_controller.rb61
-rw-r--r--app/controllers/projects/network_controller.rb4
-rw-r--r--app/controllers/projects/notes_controller.rb181
-rw-r--r--app/controllers/projects/pages_controller.rb6
-rw-r--r--app/controllers/projects/pages_domains_controller.rb8
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb80
-rw-r--r--app/controllers/projects/pipelines_controller.rb90
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb8
-rw-r--r--app/controllers/projects/project_members_controller.rb23
-rw-r--r--app/controllers/projects/prometheus_controller.rb24
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb6
-rw-r--r--app/controllers/projects/protected_tags_controller.rb2
-rw-r--r--app/controllers/projects/raw_controller.rb2
-rw-r--r--app/controllers/projects/refs_controller.rb20
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb6
-rw-r--r--app/controllers/projects/registry/tags_controller.rb6
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/runner_projects_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb6
-rw-r--r--app/controllers/projects/services_controller.rb39
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb11
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb2
-rw-r--r--app/controllers/projects/settings/members_controller.rb27
-rw-r--r--app/controllers/projects/snippets_controller.rb44
-rw-r--r--app/controllers/projects/tags_controller.rb14
-rw-r--r--app/controllers/projects/tree_controller.rb24
-rw-r--r--app/controllers/projects/triggers_controller.rb13
-rw-r--r--app/controllers/projects/uploads_controller.rb32
-rw-r--r--app/controllers/projects/variables_controller.rb47
-rw-r--r--app/controllers/projects/wikis_controller.rb31
-rw-r--r--app/controllers/projects_controller.rb101
-rw-r--r--app/controllers/registrations_controller.rb4
-rw-r--r--app/controllers/search_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb39
-rw-r--r--app/controllers/sherlock/application_controller.rb4
-rw-r--r--app/controllers/sherlock/transactions_controller.rb2
-rw-r--r--app/controllers/snippets/notes_controller.rb35
-rw-r--r--app/controllers/snippets_controller.rb86
-rw-r--r--app/controllers/unicorn_test_controller.rb14
-rw-r--r--app/controllers/uploads_controller.rb95
-rw-r--r--app/controllers/users_controller.rb37
-rw-r--r--app/finders/admin/projects_finder.rb33
-rw-r--r--app/finders/concerns/created_at_filter.rb8
-rw-r--r--app/finders/events_finder.rb63
-rw-r--r--app/finders/group_members_finder.rb6
-rw-r--r--app/finders/group_projects_finder.rb74
-rw-r--r--app/finders/groups_finder.rb34
-rw-r--r--app/finders/issuable_finder.rb53
-rw-r--r--app/finders/issues_finder.rb90
-rw-r--r--app/finders/labels_finder.rb7
-rw-r--r--app/finders/merge_requests_finder.rb1
-rw-r--r--app/finders/milestones_finder.rb60
-rw-r--r--app/finders/notes_finder.rb4
-rw-r--r--app/finders/pipeline_schedules_finder.rb22
-rw-r--r--app/finders/pipelines_finder.rb108
-rw-r--r--app/finders/projects_finder.rb76
-rw-r--r--app/finders/snippets_finder.rb102
-rw-r--r--app/finders/todos_finder.rb17
-rw-r--r--app/finders/users_finder.rb77
-rw-r--r--app/helpers/appearances_helper.rb2
-rw-r--r--app/helpers/application_helper.rb73
-rw-r--r--app/helpers/application_settings_helper.rb98
-rw-r--r--app/helpers/avatars_helper.rb20
-rw-r--r--app/helpers/award_emoji_helper.rb8
-rw-r--r--app/helpers/blame_helper.rb21
-rw-r--r--app/helpers/blob_helper.rb113
-rw-r--r--app/helpers/boards_helper.rb7
-rw-r--r--app/helpers/branches_helper.rb12
-rw-r--r--app/helpers/breadcrumbs_helper.rb25
-rw-r--r--app/helpers/broadcast_messages_helper.rb2
-rw-r--r--app/helpers/builds_helper.rb18
-rw-r--r--app/helpers/button_helper.rb20
-rw-r--r--app/helpers/ci_status_helper.rb53
-rw-r--r--app/helpers/commits_helper.rb70
-rw-r--r--app/helpers/compare_helper.rb3
-rw-r--r--app/helpers/conversational_development_index_helper.rb16
-rw-r--r--app/helpers/defer_script_tag_helper.rb6
-rw-r--r--app/helpers/diff_helper.rb94
-rw-r--r--app/helpers/dropdowns_helper.rb35
-rw-r--r--app/helpers/emails_helper.rb19
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/events_helper.rb41
-rw-r--r--app/helpers/explore_helper.rb2
-rw-r--r--app/helpers/external_wiki_helper.rb2
-rw-r--r--app/helpers/form_helper.rb32
-rw-r--r--app/helpers/gitlab_routing_helper.rb157
-rw-r--r--app/helpers/graph_helper.rb11
-rw-r--r--app/helpers/groups_helper.rb30
-rw-r--r--app/helpers/hooks_helper.rb17
-rw-r--r--app/helpers/icons_helper.rb8
-rw-r--r--app/helpers/import_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb188
-rw-r--r--app/helpers/issues_helper.rb28
-rw-r--r--app/helpers/labels_helper.rb30
-rw-r--r--app/helpers/lazy_image_tag_helper.rb24
-rw-r--r--app/helpers/markup_helper.rb34
-rw-r--r--app/helpers/merge_requests_helper.rb75
-rw-r--r--app/helpers/milestones_helper.rb65
-rw-r--r--app/helpers/milestones_routing_helper.rb17
-rw-r--r--app/helpers/nav_helper.rb42
-rw-r--r--app/helpers/notes_helper.rb104
-rw-r--r--app/helpers/notifications_helper.rb39
-rw-r--r--app/helpers/page_layout_helper.rb4
-rw-r--r--app/helpers/pagination_helper.rb21
-rw-r--r--app/helpers/performance_bar_helper.rb7
-rw-r--r--app/helpers/pipeline_schedules_helper.rb11
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/profiles_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb216
-rw-r--r--app/helpers/rss_helper.rb2
-rw-r--r--app/helpers/search_helper.rb50
-rw-r--r--app/helpers/selects_helper.rb10
-rw-r--r--app/helpers/snippets_helper.rb13
-rw-r--r--app/helpers/sorting_helper.rb10
-rw-r--r--app/helpers/storage_health_helper.rb37
-rw-r--r--app/helpers/submodule_helper.rb69
-rw-r--r--app/helpers/system_note_helper.rb5
-rw-r--r--app/helpers/tab_helper.rb3
-rw-r--r--app/helpers/tags_helper.rb2
-rw-r--r--app/helpers/todos_helper.rb29
-rw-r--r--app/helpers/tree_helper.rb6
-rw-r--r--app/helpers/triggers_helper.rb2
-rw-r--r--app/helpers/u2f_helper.rb2
-rw-r--r--app/helpers/users_helper.rb10
-rw-r--r--app/helpers/visibility_level_helper.rb10
-rw-r--r--app/helpers/webpack_helper.rb25
-rw-r--r--app/helpers/wiki_helper.rb6
-rw-r--r--app/mailers/base_mailer.rb6
-rw-r--r--app/mailers/devise_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb10
-rw-r--r--app/mailers/emails/members.rb4
-rw-r--r--app/mailers/emails/merge_requests.rb4
-rw-r--r--app/mailers/emails/notes.rb10
-rw-r--r--app/mailers/emails/profile.rb12
-rw-r--r--app/mailers/emails/projects.rb2
-rw-r--r--app/mailers/notify.rb3
-rw-r--r--app/models/ability.rb74
-rw-r--r--app/models/abuse_report.rb3
-rw-r--r--app/models/appearance.rb22
-rw-r--r--app/models/application_setting.rb108
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb8
-rw-r--r--app/models/blob.rb227
-rw-r--r--app/models/blob_viewer/auxiliary.rb18
-rw-r--r--app/models/blob_viewer/balsamiq.rb12
-rw-r--r--app/models/blob_viewer/base.rb99
-rw-r--r--app/models/blob_viewer/binary_stl.rb10
-rw-r--r--app/models/blob_viewer/cartfile.rb15
-rw-r--r--app/models/blob_viewer/changelog.rb16
-rw-r--r--app/models/blob_viewer/client_side.rb11
-rw-r--r--app/models/blob_viewer/composer_json.rb23
-rw-r--r--app/models/blob_viewer/contributing.rb10
-rw-r--r--app/models/blob_viewer/dependency_manager.rb43
-rw-r--r--app/models/blob_viewer/download.rb9
-rw-r--r--app/models/blob_viewer/empty.rb8
-rw-r--r--app/models/blob_viewer/gemfile.rb15
-rw-r--r--app/models/blob_viewer/gemspec.rb27
-rw-r--r--app/models/blob_viewer/gitlab_ci_yml.rb23
-rw-r--r--app/models/blob_viewer/godeps_json.rb15
-rw-r--r--app/models/blob_viewer/image.rb12
-rw-r--r--app/models/blob_viewer/license.rb20
-rw-r--r--app/models/blob_viewer/markup.rb11
-rw-r--r--app/models/blob_viewer/notebook.rb12
-rw-r--r--app/models/blob_viewer/package_json.rb23
-rw-r--r--app/models/blob_viewer/pdf.rb12
-rw-r--r--app/models/blob_viewer/podfile.rb15
-rw-r--r--app/models/blob_viewer/podspec.rb27
-rw-r--r--app/models/blob_viewer/podspec_json.rb9
-rw-r--r--app/models/blob_viewer/readme.rb20
-rw-r--r--app/models/blob_viewer/requirements_txt.rb15
-rw-r--r--app/models/blob_viewer/rich.rb11
-rw-r--r--app/models/blob_viewer/route_map.rb30
-rw-r--r--app/models/blob_viewer/server_side.rb26
-rw-r--r--app/models/blob_viewer/simple.rb11
-rw-r--r--app/models/blob_viewer/sketch.rb12
-rw-r--r--app/models/blob_viewer/static.rb14
-rw-r--r--app/models/blob_viewer/svg.rb12
-rw-r--r--app/models/blob_viewer/text.rb11
-rw-r--r--app/models/blob_viewer/text_stl.rb5
-rw-r--r--app/models/blob_viewer/video.rb12
-rw-r--r--app/models/blob_viewer/yarn_lock.rb15
-rw-r--r--app/models/board.rb6
-rw-r--r--app/models/broadcast_message.rb14
-rw-r--r--app/models/chat_team.rb9
-rw-r--r--app/models/ci/artifact_blob.rb35
-rw-r--r--app/models/ci/build.rb160
-rw-r--r--app/models/ci/group.rb40
-rw-r--r--app/models/ci/group_variable.rb13
-rw-r--r--app/models/ci/legacy_stage.rb64
-rw-r--r--app/models/ci/pipeline.rb98
-rw-r--r--app/models/ci/pipeline_schedule.rb64
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb8
-rw-r--r--app/models/ci/pipeline_variable.rb10
-rw-r--r--app/models/ci/runner.rb18
-rw-r--r--app/models/ci/stage.rb57
-rw-r--r--app/models/ci/trigger.rb7
-rw-r--r--app/models/ci/trigger_request.rb2
-rw-r--r--app/models/ci/trigger_schedule.rb41
-rw-r--r--app/models/ci/variable.rb17
-rw-r--r--app/models/commit.rb83
-rw-r--r--app/models/commit_status.rb37
-rw-r--r--app/models/concerns/avatarable.rb18
-rw-r--r--app/models/concerns/awardable.rb2
-rw-r--r--app/models/concerns/blob_like.rb48
-rw-r--r--app/models/concerns/cache_markdown_field.rb9
-rw-r--r--app/models/concerns/created_at_filterable.rb12
-rw-r--r--app/models/concerns/discussion_on_diff.rb9
-rw-r--r--app/models/concerns/each_batch.rb81
-rw-r--r--app/models/concerns/editable.rb11
-rw-r--r--app/models/concerns/feature_gate.rb7
-rw-r--r--app/models/concerns/has_status.rb25
-rw-r--r--app/models/concerns/has_variable.rb23
-rw-r--r--app/models/concerns/internal_id.rb3
-rw-r--r--app/models/concerns/issuable.rb65
-rw-r--r--app/models/concerns/mentionable.rb31
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb22
-rw-r--r--app/models/concerns/milestoneish.rb26
-rw-r--r--app/models/concerns/note_on_diff.rb10
-rw-r--r--app/models/concerns/noteable.rb7
-rw-r--r--app/models/concerns/participable.rb2
-rw-r--r--app/models/concerns/project_features_compatibility.rb2
-rw-r--r--app/models/concerns/protected_branch_access.rb24
-rw-r--r--app/models/concerns/protected_ref.rb39
-rw-r--r--app/models/concerns/referable.rb13
-rw-r--r--app/models/concerns/relative_positioning.rb16
-rw-r--r--app/models/concerns/routable.rb137
-rw-r--r--app/models/concerns/select_for_project_authorization.rb6
-rw-r--r--app/models/concerns/sha_attribute.rb20
-rw-r--r--app/models/concerns/sortable.rb12
-rw-r--r--app/models/concerns/spammable.rb8
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb102
-rw-r--r--app/models/concerns/storage/legacy_project.rb76
-rw-r--r--app/models/concerns/storage/legacy_project_wiki.rb9
-rw-r--r--app/models/concerns/storage/legacy_repository.rb7
-rw-r--r--app/models/concerns/subscribable.rb20
-rw-r--r--app/models/concerns/time_trackable.rb2
-rw-r--r--app/models/concerns/token_authenticatable.rb3
-rw-r--r--app/models/conversational_development_index/card.rb26
-rw-r--r--app/models/conversational_development_index/idea_to_production_step.rb19
-rw-r--r--app/models/conversational_development_index/metric.rb19
-rw-r--r--app/models/dashboard_milestone.rb4
-rw-r--r--app/models/deploy_key.rb2
-rw-r--r--app/models/deploy_keys_project.rb2
-rw-r--r--app/models/deployment.rb54
-rw-r--r--app/models/diff_discussion.rb11
-rw-r--r--app/models/diff_note.rb38
-rw-r--r--app/models/diff_viewer/added.rb8
-rw-r--r--app/models/diff_viewer/base.rb87
-rw-r--r--app/models/diff_viewer/client_side.rb10
-rw-r--r--app/models/diff_viewer/deleted.rb8
-rw-r--r--app/models/diff_viewer/image.rb12
-rw-r--r--app/models/diff_viewer/mode_changed.rb8
-rw-r--r--app/models/diff_viewer/no_preview.rb9
-rw-r--r--app/models/diff_viewer/not_diffable.rb9
-rw-r--r--app/models/diff_viewer/renamed.rb8
-rw-r--r--app/models/diff_viewer/rich.rb11
-rw-r--r--app/models/diff_viewer/server_side.rb26
-rw-r--r--app/models/diff_viewer/simple.rb11
-rw-r--r--app/models/diff_viewer/static.rb10
-rw-r--r--app/models/diff_viewer/text.rb15
-rw-r--r--app/models/discussion.rb9
-rw-r--r--app/models/environment.rb39
-rw-r--r--app/models/event.rb107
-rw-r--r--app/models/event_collection.rb98
-rw-r--r--app/models/event_for_migration.rb5
-rw-r--r--app/models/external_issue.rb5
-rw-r--r--app/models/forked_project_link.rb4
-rw-r--r--app/models/generic_commit_status.rb1
-rw-r--r--app/models/global_label.rb2
-rw-r--r--app/models/global_milestone.rb53
-rw-r--r--app/models/gpg_key.rb107
-rw-r--r--app/models/gpg_signature.rb25
-rw-r--r--app/models/group.rb88
-rw-r--r--app/models/group_milestone.rb4
-rw-r--r--app/models/hooks/project_hook.rb25
-rw-r--r--app/models/hooks/service_hook.rb3
-rw-r--r--app/models/hooks/system_hook.rb13
-rw-r--r--app/models/hooks/web_hook.rb54
-rw-r--r--app/models/hooks/web_hook_log.rb13
-rw-r--r--app/models/issue.rb77
-rw-r--r--app/models/issue_assignee.rb6
-rw-r--r--app/models/issue_collection.rb6
-rw-r--r--app/models/key.rb14
-rw-r--r--app/models/label.rb23
-rw-r--r--app/models/label_link.rb2
-rw-r--r--app/models/legacy_diff_discussion.rb18
-rw-r--r--app/models/legacy_diff_note.rb6
-rw-r--r--app/models/lfs_object.rb2
-rw-r--r--app/models/lfs_objects_project.rb3
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/member.rb43
-rw-r--r--app/models/members/group_member.rb8
-rw-r--r--app/models/members/project_member.rb8
-rw-r--r--app/models/merge_request.rb314
-rw-r--r--app/models/merge_request_diff.rb184
-rw-r--r--app/models/merge_request_diff_commit.rb38
-rw-r--r--app/models/merge_request_diff_file.rb21
-rw-r--r--app/models/merge_requests_closing_issues.rb6
-rw-r--r--app/models/milestone.rb121
-rw-r--r--app/models/namespace.rb168
-rw-r--r--app/models/network/commit.rb2
-rw-r--r--app/models/network/graph.rb4
-rw-r--r--app/models/note.rb69
-rw-r--r--app/models/notification_recipient.rb142
-rw-r--r--app/models/notification_setting.rb35
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/personal_access_token.rb13
-rw-r--r--app/models/project.rb611
-rw-r--r--app/models/project_authorization.rb6
-rw-r--r--app/models/project_feature.rb18
-rw-r--r--app/models/project_import_data.rb4
-rw-r--r--app/models/project_services/asana_service.rb3
-rw-r--r--app/models/project_services/assembla_service.rb2
-rw-r--r--app/models/project_services/bamboo_service.rb6
-rw-r--r--app/models/project_services/buildkite_service.rb4
-rw-r--r--app/models/project_services/campfire_service.rb4
-rw-r--r--app/models/project_services/chat_message/base_message.rb11
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb12
-rw-r--r--app/models/project_services/chat_notification_service.rb12
-rw-r--r--app/models/project_services/chat_slash_commands_service.rb52
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb6
-rw-r--r--app/models/project_services/deployment_service.rb4
-rw-r--r--app/models/project_services/drone_ci_service.rb6
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/external_wiki_service.rb2
-rw-r--r--app/models/project_services/flowdock_service.rb8
-rw-r--r--app/models/project_services/gemnasium_service.rb4
-rw-r--r--app/models/project_services/gitlab_issue_tracker_service.rb16
-rw-r--r--app/models/project_services/hipchat_service.rb6
-rw-r--r--app/models/project_services/irker_service.rb4
-rw-r--r--app/models/project_services/issue_tracker_service.rb21
-rw-r--r--app/models/project_services/jira_service.rb98
-rw-r--r--app/models/project_services/kubernetes_service.rb86
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb6
-rw-r--r--app/models/project_services/microsoft_teams_service.rb2
-rw-r--r--app/models/project_services/mock_ci_service.rb7
-rw-r--r--app/models/project_services/mock_monitoring_service.rb4
-rw-r--r--app/models/project_services/monitoring_service.rb7
-rw-r--r--app/models/project_services/pipelines_email_service.rb5
-rw-r--r--app/models/project_services/pivotaltracker_service.rb3
-rw-r--r--app/models/project_services/prometheus_service.rb66
-rw-r--r--app/models/project_services/pushover_service.rb8
-rw-r--r--app/models/project_services/slack_slash_commands_service.rb2
-rw-r--r--app/models/project_services/slash_commands_service.rb52
-rw-r--r--app/models/project_services/teamcity_service.rb8
-rw-r--r--app/models/project_statistics.rb2
-rw-r--r--app/models/project_team.rb17
-rw-r--r--app/models/project_wiki.rb47
-rw-r--r--app/models/protectable_dropdown.rb8
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/protected_branch/merge_access_level.rb10
-rw-r--r--app/models/protected_branch/push_access_level.rb18
-rw-r--r--app/models/protected_tag.rb6
-rw-r--r--app/models/push_event.rb126
-rw-r--r--app/models/push_event_payload.rb22
-rw-r--r--app/models/readme_blob.rb13
-rw-r--r--app/models/redirect_route.rb20
-rw-r--r--app/models/repository.rb321
-rw-r--r--app/models/route.rb57
-rw-r--r--app/models/sent_notification.rb6
-rw-r--r--app/models/service.rb14
-rw-r--r--app/models/snippet.rb52
-rw-r--r--app/models/snippet_blob.rb31
-rw-r--r--app/models/spam_log.rb3
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/system_note_metadata.rb6
-rw-r--r--app/models/todo.rb20
-rw-r--r--app/models/tree.rb5
-rw-r--r--app/models/upload.rb2
-rw-r--r--app/models/user.rb367
-rw-r--r--app/models/user_agent_detail.rb2
-rw-r--r--app/models/wiki_page.rb95
-rw-r--r--app/policies/base_policy.rb129
-rw-r--r--app/policies/ci/build_policy.rb14
-rw-r--r--app/policies/ci/pipeline_policy.rb15
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb14
-rw-r--r--app/policies/ci/runner_policy.rb15
-rw-r--r--app/policies/ci/trigger_policy.rb21
-rw-r--r--app/policies/commit_status_policy.rb6
-rw-r--r--app/policies/deploy_key_policy.rb11
-rw-r--r--app/policies/deployment_policy.rb4
-rw-r--r--app/policies/environment_policy.rb8
-rw-r--r--app/policies/external_issue_policy.rb4
-rw-r--r--app/policies/global_policy.rb60
-rw-r--r--app/policies/group_label_policy.rb4
-rw-r--r--app/policies/group_member_policy.rb29
-rw-r--r--app/policies/group_policy.rb95
-rw-r--r--app/policies/issuable_policy.rb19
-rw-r--r--app/policies/issue_policy.rb26
-rw-r--r--app/policies/namespace_policy.rb12
-rw-r--r--app/policies/nil_policy.rb3
-rw-r--r--app/policies/note_policy.rb31
-rw-r--r--app/policies/personal_snippet_policy.rb37
-rw-r--r--app/policies/project_label_policy.rb4
-rw-r--r--app/policies/project_member_policy.rb26
-rw-r--r--app/policies/project_policy.rb512
-rw-r--r--app/policies/project_snippet_policy.rb59
-rw-r--r--app/policies/user_policy.rb22
-rw-r--r--app/presenters/ci/group_variable_presenter.rb25
-rw-r--r--app/presenters/ci/variable_presenter.rb25
-rw-r--r--app/presenters/conversational_development_index/metric_presenter.rb144
-rw-r--r--app/presenters/merge_request_presenter.rb185
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb21
-rw-r--r--app/serializers/README.md325
-rw-r--r--app/serializers/analytics_build_entity.rb4
-rw-r--r--app/serializers/analytics_issue_entity.rb2
-rw-r--r--app/serializers/analytics_stage_entity.rb1
-rw-r--r--app/serializers/analytics_summary_entity.rb5
-rw-r--r--app/serializers/base_serializer.rb6
-rw-r--r--app/serializers/blob_entity.rb17
-rw-r--r--app/serializers/build_action_entity.rb13
-rw-r--r--app/serializers/build_artifact_entity.rb30
-rw-r--r--app/serializers/build_details_entity.rb46
-rw-r--r--app/serializers/build_entity.rb35
-rw-r--r--app/serializers/build_serializer.rb2
-rw-r--r--app/serializers/commit_entity.rb10
-rw-r--r--app/serializers/deploy_key_entity.rb21
-rw-r--r--app/serializers/deploy_key_serializer.rb3
-rw-r--r--app/serializers/deployment_entity.rb11
-rw-r--r--app/serializers/deployment_serializer.rb8
-rw-r--r--app/serializers/entity_date_helper.rb2
-rw-r--r--app/serializers/environment_entity.rb22
-rw-r--r--app/serializers/event_entity.rb4
-rw-r--r--app/serializers/group_entity.rb50
-rw-r--r--app/serializers/group_serializer.rb19
-rw-r--r--app/serializers/issuable_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb7
-rw-r--r--app/serializers/job_entity.rb51
-rw-r--r--app/serializers/job_group_entity.rb16
-rw-r--r--app/serializers/label_entity.rb4
-rw-r--r--app/serializers/label_serializer.rb7
-rw-r--r--app/serializers/merge_request_basic_entity.rb11
-rw-r--r--app/serializers/merge_request_basic_serializer.rb3
-rw-r--r--app/serializers/merge_request_create_entity.rb7
-rw-r--r--app/serializers/merge_request_create_serializer.rb3
-rw-r--r--app/serializers/merge_request_entity.rb166
-rw-r--r--app/serializers/merge_request_serializer.rb8
-rw-r--r--app/serializers/pipeline_details_entity.rb7
-rw-r--r--app/serializers/pipeline_entity.rb52
-rw-r--r--app/serializers/pipeline_serializer.rb16
-rw-r--r--app/serializers/project_entity.rb14
-rw-r--r--app/serializers/request_aware_entity.rb1
-rw-r--r--app/serializers/runner_entity.rb18
-rw-r--r--app/serializers/stage_entity.rb16
-rw-r--r--app/serializers/status_entity.rb12
-rw-r--r--app/serializers/submodule_entity.rb23
-rw-r--r--app/serializers/tree_entity.rb17
-rw-r--r--app/serializers/tree_root_entity.rb21
-rw-r--r--app/serializers/tree_serializer.rb3
-rw-r--r--app/serializers/user_entity.rb5
-rw-r--r--app/services/access_token_validation_service.rb24
-rw-r--r--app/services/akismet_service.rb4
-rw-r--r--app/services/audit_event_service.rb2
-rw-r--r--app/services/auth/container_registry_authentication_service.rb7
-rw-r--r--app/services/boards/create_service.rb18
-rw-r--r--app/services/boards/issues/list_service.rb17
-rw-r--r--app/services/boards/issues/move_service.rb4
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/chat_names/authorize_user_service.rb2
-rw-r--r--app/services/ci/create_pipeline_builds_service.rb51
-rw-r--r--app/services/ci/create_pipeline_schedule_service.rb13
-rw-r--r--app/services/ci/create_pipeline_service.rb100
-rw-r--r--app/services/ci/create_pipeline_stages_service.rb20
-rw-r--r--app/services/ci/create_trigger_request_service.rb20
-rw-r--r--app/services/ci/pipeline_trigger_service.rb44
-rw-r--r--app/services/ci/play_build_service.rb17
-rw-r--r--app/services/ci/process_pipeline_service.rb22
-rw-r--r--app/services/ci/register_job_service.rb40
-rw-r--r--app/services/ci/retry_build_service.rb17
-rw-r--r--app/services/ci/retry_pipeline_service.rb4
-rw-r--r--app/services/ci/stop_environments_service.rb14
-rw-r--r--app/services/commits/change_service.rb1
-rw-r--r--app/services/compare_service.rb6
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb6
-rw-r--r--app/services/create_deployment_service.rb78
-rw-r--r--app/services/delete_branch_service.rb16
-rw-r--r--app/services/delete_merged_branches_service.rb2
-rw-r--r--app/services/discussions/update_diff_position_service.rb41
-rw-r--r--app/services/emails/base_service.rb8
-rw-r--r--app/services/emails/create_service.rb7
-rw-r--r--app/services/emails/destroy_service.rb17
-rw-r--r--app/services/event_create_service.rb9
-rw-r--r--app/services/files/update_service.rb4
-rw-r--r--app/services/git_hooks_service.rb6
-rw-r--r--app/services/git_operation_service.rb5
-rw-r--r--app/services/git_push_service.rb60
-rw-r--r--app/services/git_tag_push_service.rb4
-rw-r--r--app/services/gravatar_service.rb21
-rw-r--r--app/services/groups/destroy_service.rb7
-rw-r--r--app/services/issuable/bulk_update_service.rb18
-rw-r--r--app/services/issuable_base_service.rb91
-rw-r--r--app/services/issues/base_service.rb30
-rw-r--r--app/services/issues/close_service.rb5
-rw-r--r--app/services/issues/create_service.rb12
-rw-r--r--app/services/issues/duplicate_service.rb24
-rw-r--r--app/services/issues/move_service.rb14
-rw-r--r--app/services/issues/reopen_service.rb7
-rw-r--r--app/services/issues/update_service.rb32
-rw-r--r--app/services/labels/promote_service.rb28
-rw-r--r--app/services/labels/transfer_service.rb22
-rw-r--r--app/services/members/authorized_destroy_service.rb35
-rw-r--r--app/services/members/create_service.rb22
-rw-r--r--app/services/members/destroy_service.rb2
-rw-r--r--app/services/merge_requests/assign_issues_service.rb4
-rw-r--r--app/services/merge_requests/base_service.rb11
-rw-r--r--app/services/merge_requests/build_service.rb2
-rw-r--r--app/services/merge_requests/close_service.rb1
-rw-r--r--app/services/merge_requests/conflicts/base_service.rb11
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb36
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb55
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb54
-rw-r--r--app/services/merge_requests/create_service.rb33
-rw-r--r--app/services/merge_requests/get_urls_service.rb4
-rw-r--r--app/services/merge_requests/merge_service.rb8
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/refresh_service.rb25
-rw-r--r--app/services/merge_requests/reopen_service.rb5
-rw-r--r--app/services/merge_requests/resolve_service.rb65
-rw-r--r--app/services/merge_requests/update_service.rb11
-rw-r--r--app/services/metrics_service.rb36
-rw-r--r--app/services/milestones/base_service.rb6
-rw-r--r--app/services/milestones/close_service.rb2
-rw-r--r--app/services/milestones/create_service.rb4
-rw-r--r--app/services/milestones/destroy_service.rb6
-rw-r--r--app/services/milestones/reopen_service.rb2
-rw-r--r--app/services/milestones/update_service.rb4
-rw-r--r--app/services/notes/build_service.rb18
-rw-r--r--app/services/notes/create_service.rb8
-rw-r--r--app/services/notes/diff_position_update_service.rb30
-rw-r--r--app/services/notes/quick_actions_service.rb36
-rw-r--r--app/services/notes/slash_commands_service.rb36
-rw-r--r--app/services/notification_recipient_service.rb463
-rw-r--r--app/services/notification_service.rb151
-rw-r--r--app/services/preview_markdown_service.rb45
-rw-r--r--app/services/projects/autocomplete_service.rb12
-rw-r--r--app/services/projects/create_from_template_service.rb15
-rw-r--r--app/services/projects/create_service.rb9
-rw-r--r--app/services/projects/destroy_service.rb90
-rw-r--r--app/services/projects/enable_deploy_key_service.rb5
-rw-r--r--app/services/projects/fork_service.rb6
-rw-r--r--app/services/projects/forks_count_service.rb30
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb36
-rw-r--r--app/services/projects/import_export/export_service.rb2
-rw-r--r--app/services/projects/import_service.rb12
-rw-r--r--app/services/projects/propagate_service_template.rb103
-rw-r--r--app/services/projects/transfer_service.rb122
-rw-r--r--app/services/projects/unlink_fork_service.rb8
-rw-r--r--app/services/projects/update_pages_configuration_service.rb2
-rw-r--r--app/services/projects/update_pages_service.rb27
-rw-r--r--app/services/projects/update_service.rb54
-rw-r--r--app/services/projects/upload_service.rb22
-rw-r--r--app/services/push_event_payload_service.rb120
-rw-r--r--app/services/quick_actions/interpret_service.rb572
-rw-r--r--app/services/search/snippet_service.rb2
-rw-r--r--app/services/search_service.rb2
-rw-r--r--app/services/slash_commands/interpret_service.rb352
-rw-r--r--app/services/submit_usage_ping_service.rb43
-rw-r--r--app/services/system_hooks_service.rb14
-rw-r--r--app/services/system_note_service.rb131
-rw-r--r--app/services/tags/create_service.rb4
-rw-r--r--app/services/test_hook_service.rb6
-rw-r--r--app/services/test_hooks/base_service.rb41
-rw-r--r--app/services/test_hooks/project_service.rb63
-rw-r--r--app/services/test_hooks/system_service.rb48
-rw-r--r--app/services/todo_service.rb22
-rw-r--r--app/services/upload_service.rb20
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/build_service.rb1
-rw-r--r--app/services/users/create_service.rb1
-rw-r--r--app/services/users/destroy_service.rb19
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb42
-rw-r--r--app/services/users/update_service.rb34
-rw-r--r--app/services/web_hook_service.rb133
-rw-r--r--app/services/wiki_pages/base_service.rb16
-rw-r--r--app/services/wiki_pages/update_service.rb2
-rw-r--r--app/uploaders/artifact_uploader.rb32
-rw-r--r--app/uploaders/file_mover.rb63
-rw-r--r--app/uploaders/file_uploader.rb19
-rw-r--r--app/uploaders/gitlab_uploader.rb51
-rw-r--r--app/uploaders/lfs_object_uploader.rb8
-rw-r--r--app/uploaders/personal_file_uploader.rb23
-rw-r--r--app/uploaders/records_uploads.rb7
-rw-r--r--app/validators/dynamic_path_validator.rb53
-rw-r--r--app/validators/namespace_validator.rb73
-rw-r--r--app/validators/project_path_validator.rb35
-rw-r--r--app/validators/variable_duplicates_validator.rb13
-rw-r--r--app/views/admin/application_settings/_form.html.haml112
-rw-r--r--app/views/admin/applications/_form.html.haml8
-rw-r--r--app/views/admin/applications/edit.html.haml1
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/applications/new.html.haml2
-rw-r--r--app/views/admin/applications/show.html.haml6
-rw-r--r--app/views/admin/background_jobs/_head.html.haml25
-rw-r--r--app/views/admin/background_jobs/show.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/edit.html.haml1
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml1
-rw-r--r--app/views/admin/builds/index.html.haml18
-rw-r--r--app/views/admin/conversational_development_index/_callout.html.haml13
-rw-r--r--app/views/admin/conversational_development_index/_card.html.haml25
-rw-r--r--app/views/admin/conversational_development_index/_disabled.html.haml9
-rw-r--r--app/views/admin/conversational_development_index/_no_data.html.haml7
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml35
-rw-r--r--app/views/admin/dashboard/_head.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml310
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml10
-rw-r--r--app/views/admin/deploy_keys/index.html.haml4
-rw-r--r--app/views/admin/deploy_keys/new.html.haml29
-rw-r--r--app/views/admin/groups/show.html.haml9
-rw-r--r--app/views/admin/health_check/_failing_storages.html.haml15
-rw-r--r--app/views/admin/health_check/show.html.haml49
-rw-r--r--app/views/admin/hook_logs/_index.html.haml37
-rw-r--r--app/views/admin/hook_logs/show.html.haml10
-rw-r--r--app/views/admin/hooks/_form.html.haml47
-rw-r--r--app/views/admin/hooks/edit.html.haml20
-rw-r--r--app/views/admin/hooks/index.html.haml61
-rw-r--r--app/views/admin/jobs/index.html.haml18
-rw-r--r--app/views/admin/logs/show.html.haml2
-rw-r--r--app/views/admin/monitoring/_head.html.haml29
-rw-r--r--app/views/admin/projects/_projects.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml10
-rw-r--r--app/views/admin/requests_profiles/index.html.haml4
-rw-r--r--app/views/admin/runners/_runner.html.haml17
-rw-r--r--app/views/admin/runners/index.html.haml70
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml15
-rw-r--r--app/views/admin/users/index.html.haml71
-rw-r--r--app/views/admin/users/projects.html.haml4
-rw-r--r--app/views/admin/users/show.html.haml27
-rw-r--r--app/views/award_emoji/_awards_block.html.haml4
-rw-r--r--app/views/ci/lints/show.html.haml2
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml16
-rw-r--r--app/views/ci/status/_graph_badge.html.haml20
-rw-r--r--app/views/ci/variables/_content.html.haml9
-rw-r--r--app/views/ci/variables/_form.html.haml19
-rw-r--r--app/views/ci/variables/_index.html.haml16
-rw-r--r--app/views/ci/variables/_show.html.haml9
-rw-r--r--app/views/ci/variables/_table.html.haml28
-rw-r--r--app/views/dashboard/_activities.html.haml5
-rw-r--r--app/views/dashboard/_groups_head.html.haml15
-rw-r--r--app/views/dashboard/_projects_head.html.haml10
-rw-r--r--app/views/dashboard/_snippets_head.html.haml9
-rw-r--r--app/views/dashboard/activity.html.haml13
-rw-r--r--app/views/dashboard/groups/_groups.html.haml13
-rw-r--r--app/views/dashboard/groups/index.html.haml6
-rw-r--r--app/views/dashboard/issues.atom.builder15
-rw-r--r--app/views/dashboard/issues.html.haml11
-rw-r--r--app/views/dashboard/merge_requests.html.haml9
-rw-r--r--app/views/dashboard/milestones/index.html.haml7
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml33
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml48
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml59
-rw-r--r--app/views/dashboard/projects/index.atom.builder15
-rw-r--r--app/views/dashboard/projects/index.html.haml23
-rw-r--r--app/views/dashboard/projects/starred.html.haml19
-rw-r--r--app/views/dashboard/snippets/index.html.haml1
-rw-r--r--app/views/dashboard/todos/_todo.html.haml6
-rw-r--r--app/views/dashboard/todos/index.html.haml1
-rw-r--r--app/views/devise/mailer/confirmation_instructions.html.haml31
-rw-r--r--app/views/devise/mailer/password_change.html.haml18
-rw-r--r--app/views/devise/mailer/reset_password_instructions.html.haml22
-rw-r--r--app/views/devise/mailer/unlock_instructions.html.haml17
-rw-r--r--app/views/devise/passwords/edit.html.haml4
-rw-r--r--app/views/devise/sessions/_new_base.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml6
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml5
-rw-r--r--app/views/devise/shared/_signin_box.html.haml4
-rw-r--r--app/views/devise/shared/_signup_box.html.haml4
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml4
-rw-r--r--app/views/devise/shared/_tabs_normal.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml5
-rw-r--r--app/views/discussions/_discussion.html.haml11
-rw-r--r--app/views/discussions/_jump_to_next.html.haml4
-rw-r--r--app/views/discussions/_new_issue_for_all_discussions.html.haml2
-rw-r--r--app/views/discussions/_new_issue_for_discussion.html.haml2
-rw-r--r--app/views/discussions/_notes.html.haml15
-rw-r--r--app/views/doorkeeper/applications/edit.html.haml1
-rw-r--r--app/views/doorkeeper/applications/index.html.haml5
-rw-r--r--app/views/doorkeeper/applications/show.html.haml2
-rw-r--r--app/views/errors/omniauth_error.html.haml21
-rw-r--r--app/views/events/_commit.html.haml4
-rw-r--r--app/views/events/_event_last_push.html.haml14
-rw-r--r--app/views/events/_event_push.atom.haml19
-rw-r--r--app/views/events/event/_push.html.haml19
-rw-r--r--app/views/explore/groups/index.html.haml10
-rw-r--r--app/views/explore/projects/index.html.haml1
-rw-r--r--app/views/explore/projects/starred.html.haml1
-rw-r--r--app/views/explore/projects/trending.html.haml1
-rw-r--r--app/views/explore/snippets/index.html.haml1
-rw-r--r--app/views/groups/_activities.html.haml3
-rw-r--r--app/views/groups/_head.html.haml3
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/_settings_head.html.haml5
-rw-r--r--app/views/groups/_shared_projects.html.haml1
-rw-r--r--app/views/groups/_show_nav.html.haml7
-rw-r--r--app/views/groups/edit.html.haml15
-rw-r--r--app/views/groups/group_members/index.html.haml2
-rw-r--r--app/views/groups/issues.atom.builder15
-rw-r--r--app/views/groups/issues.html.haml17
-rw-r--r--app/views/groups/labels/index.html.haml10
-rw-r--r--app/views/groups/labels/new.html.haml1
-rw-r--r--app/views/groups/merge_requests.html.haml9
-rw-r--r--app/views/groups/milestones/_form.html.haml27
-rw-r--r--app/views/groups/milestones/_milestone.html.haml3
-rw-r--r--app/views/groups/milestones/edit.html.haml7
-rw-r--r--app/views/groups/milestones/index.html.haml14
-rw-r--r--app/views/groups/milestones/new.html.haml39
-rw-r--r--app/views/groups/milestones/show.html.haml2
-rw-r--r--app/views/groups/new.html.haml3
-rw-r--r--app/views/groups/projects.html.haml4
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/groups/show.atom.builder15
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/groups/variables/show.html.haml1
-rw-r--r--app/views/help/_shortcuts.html.haml7
-rw-r--r--app/views/help/index.html.haml25
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml29
-rw-r--r--app/views/import/_githubish_status.html.haml2
-rw-r--r--app/views/import/base/create.js.haml4
-rw-r--r--app/views/import/bitbucket/status.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml5
-rw-r--r--app/views/import/fogbugz/status.html.haml2
-rw-r--r--app/views/import/gitlab/status.html.haml2
-rw-r--r--app/views/import/gitlab_projects/new.html.haml50
-rw-r--r--app/views/import/google_code/status.html.haml2
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/issues/_issue.atom.builder19
-rw-r--r--app/views/kaminari/gitlab/_without_count.html.haml8
-rw-r--r--app/views/layouts/_bootlint.haml7
-rw-r--r--app/views/layouts/_broadcast.html.haml3
-rw-r--r--app/views/layouts/_google_analytics.html.haml1
-rw-r--r--app/views/layouts/_head.html.haml19
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml17
-rw-r--r--app/views/layouts/_mailer.html.haml74
-rw-r--r--app/views/layouts/_page.html.haml25
-rw-r--r--app/views/layouts/_piwik.html.haml1
-rw-r--r--app/views/layouts/_search.html.haml4
-rw-r--r--app/views/layouts/admin.html.haml6
-rw-r--r--app/views/layouts/application.html.haml14
-rw-r--r--app/views/layouts/devise.html.haml1
-rw-r--r--app/views/layouts/devise_empty.html.haml1
-rw-r--r--app/views/layouts/devise_mailer.html.haml34
-rw-r--r--app/views/layouts/group.html.haml6
-rw-r--r--app/views/layouts/header/_default.html.haml23
-rw-r--r--app/views/layouts/header/_new.html.haml86
-rw-r--r--app/views/layouts/header/_new_dropdown.haml49
-rw-r--r--app/views/layouts/help.html.haml1
-rw-r--r--app/views/layouts/mailer.html.haml73
-rw-r--r--app/views/layouts/mailer/devise.html.haml21
-rw-r--r--app/views/layouts/nav/_admin.html.haml8
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml27
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml4
-rw-r--r--app/views/layouts/nav/_group.html.haml2
-rw-r--r--app/views/layouts/nav/_new_admin_sidebar.html.haml151
-rw-r--r--app/views/layouts/nav/_new_dashboard.html.haml33
-rw-r--r--app/views/layouts/nav/_new_explore.html.haml19
-rw-r--r--app/views/layouts/nav/_new_group_sidebar.html.haml90
-rw-r--r--app/views/layouts/nav/_new_profile_sidebar.html.haml85
-rw-r--r--app/views/layouts/nav/_new_project_sidebar.html.haml262
-rw-r--r--app/views/layouts/nav/_profile.html.haml10
-rw-r--r--app/views/layouts/nav/_project.html.haml45
-rw-r--r--app/views/layouts/oauth_error.html.haml127
-rw-r--r--app/views/layouts/profile.html.haml6
-rw-r--r--app/views/layouts/project.html.haml14
-rw-r--r--app/views/layouts/snippets.html.haml6
-rw-r--r--app/views/layouts/xml.atom.builder4
-rw-r--r--app/views/notify/_reassigned_issuable_email.html.haml10
-rw-r--r--app/views/notify/_reassigned_issuable_email.text.erb6
-rw-r--r--app/views/notify/closed_issue_email.text.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml2
-rw-r--r--app/views/notify/issue_moved_email.html.haml2
-rw-r--r--app/views/notify/issue_moved_email.text.erb2
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb2
-rw-r--r--app/views/notify/links/ci/builds/_build.html.haml2
-rw-r--r--app/views/notify/links/ci/builds/_build.text.erb2
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml2
-rw-r--r--app/views/notify/new_gpg_key_email.html.haml10
-rw-r--r--app/views/notify/new_gpg_key_email.text.erb7
-rw-r--r--app/views/notify/new_issue_email.html.haml4
-rw-r--r--app/views/notify/new_issue_email.text.erb4
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb4
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml6
-rw-r--r--app/views/notify/pipeline_success_email.html.haml6
-rw-r--r--app/views/notify/project_was_exported_email.html.haml2
-rw-r--r--app/views/notify/project_was_exported_email.text.erb2
-rw-r--r--app/views/notify/project_was_moved_email.html.haml2
-rw-r--r--app/views/notify/project_was_moved_email.text.erb2
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml11
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb7
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml11
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb7
-rw-r--r--app/views/notify/repository_push_email.html.haml34
-rw-r--r--app/views/notify/repository_push_email.text.haml20
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb2
-rw-r--r--app/views/peek/views/_host.html.haml2
-rw-r--r--app/views/peek/views/_mysql2.html.haml4
-rw-r--r--app/views/peek/views/_pg.html.haml4
-rw-r--r--app/views/peek/views/_rblineprof.html.haml7
-rw-r--r--app/views/peek/views/_sql.html.haml13
-rw-r--r--app/views/profiles/_event_table.html.haml3
-rw-r--r--app/views/profiles/accounts/_reset_token.html.haml11
-rw-r--r--app/views/profiles/accounts/show.html.haml92
-rw-r--r--app/views/profiles/audit_log.html.haml7
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml5
-rw-r--r--app/views/profiles/emails/index.html.haml5
-rw-r--r--app/views/profiles/gpg_keys/_email_with_badge.html.haml8
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml10
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml18
-rw-r--r--app/views/profiles/gpg_keys/_key_table.html.haml11
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml21
-rw-r--r--app/views/profiles/keys/index.html.haml5
-rw-r--r--app/views/profiles/keys/show.html.haml1
-rw-r--r--app/views/profiles/notifications/show.html.haml5
-rw-r--r--app/views/profiles/passwords/edit.html.haml5
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml13
-rw-r--r--app/views/profiles/preferences/show.html.haml35
-rw-r--r--app/views/profiles/show.html.haml107
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml170
-rw-r--r--app/views/projects/_activity.html.haml12
-rw-r--r--app/views/projects/_deletion_failed.html.haml6
-rw-r--r--app/views/projects/_export.html.haml41
-rw-r--r--app/views/projects/_files.html.haml9
-rw-r--r--app/views/projects/_find_file_link.html.haml4
-rw-r--r--app/views/projects/_flash_messages.html.haml8
-rw-r--r--app/views/projects/_head.html.haml15
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_last_commit.html.haml12
-rw-r--r--app/views/projects/_last_push.html.haml34
-rw-r--r--app/views/projects/_md_preview.html.haml17
-rw-r--r--app/views/projects/_merge_request_settings.html.haml7
-rw-r--r--app/views/projects/_project_templates.html.haml10
-rw-r--r--app/views/projects/_readme.html.haml21
-rw-r--r--app/views/projects/_visibility_select.html.haml4
-rw-r--r--app/views/projects/_wiki.html.haml5
-rw-r--r--app/views/projects/_zen.html.haml12
-rw-r--r--app/views/projects/activity.html.haml5
-rw-r--r--app/views/projects/artifacts/_tree_directory.html.haml6
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml11
-rw-r--r--app/views/projects/artifacts/browse.html.haml24
-rw-r--r--app/views/projects/artifacts/file.html.haml33
-rw-r--r--app/views/projects/blame/_age_map_legend.html.haml12
-rw-r--r--app/views/projects/blame/show.html.haml18
-rw-r--r--app/views/projects/blob/_auxiliary_viewer.html.haml5
-rw-r--r--app/views/projects/blob/_blob.html.haml37
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml37
-rw-r--r--app/views/projects/blob/_content.html.haml8
-rw-r--r--app/views/projects/blob/_download.html.haml7
-rw-r--r--app/views/projects/blob/_header.html.haml35
-rw-r--r--app/views/projects/blob/_header_content.html.haml10
-rw-r--r--app/views/projects/blob/_image.html.haml2
-rw-r--r--app/views/projects/blob/_markup.html.haml4
-rw-r--r--app/views/projects/blob/_new_dir.html.haml13
-rw-r--r--app/views/projects/blob/_notebook.html.haml5
-rw-r--r--app/views/projects/blob/_pdf.html.haml5
-rw-r--r--app/views/projects/blob/_remove.html.haml5
-rw-r--r--app/views/projects/blob/_render_error.html.haml7
-rw-r--r--app/views/projects/blob/_sketch.html.haml7
-rw-r--r--app/views/projects/blob/_stl.html.haml12
-rw-r--r--app/views/projects/blob/_svg.html.haml9
-rw-r--r--app/views/projects/blob/_text.html.haml2
-rw-r--r--app/views/projects/blob/_too_large.html.haml5
-rw-r--r--app/views/projects/blob/_upload.html.haml12
-rw-r--r--app/views/projects/blob/_viewer.html.haml20
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml12
-rw-r--r--app/views/projects/blob/edit.html.haml9
-rw-r--r--app/views/projects/blob/new.html.haml5
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/show.html.haml28
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_changelog.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_contributing.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml11
-rw-r--r--app/views/projects/blob/viewers/_download.html.haml7
-rw-r--r--app/views/projects/blob/viewers/_empty.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_image.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_license.html.haml8
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_loading_auxiliary.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_notebook.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_pdf.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_readme.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml7
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml12
-rw-r--r--app/views/projects/blob/viewers/_svg.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_text.html.haml1
-rw-r--r--app/views/projects/blob/viewers/_video.html.haml2
-rw-r--r--app/views/projects/boards/_show.html.haml12
-rw-r--r--app/views/projects/boards/components/_board.html.haml31
-rw-r--r--app/views/projects/boards/components/_sidebar.html.haml3
-rw-r--r--app/views/projects/boards/components/sidebar/_assignee.html.haml54
-rw-r--r--app/views/projects/boards/components/sidebar/_due_date.html.haml2
-rw-r--r--app/views/projects/boards/components/sidebar/_labels.html.haml4
-rw-r--r--app/views/projects/boards/components/sidebar/_milestone.html.haml5
-rw-r--r--app/views/projects/boards/components/sidebar/_notifications.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml42
-rw-r--r--app/views/projects/branches/_commit.html.haml4
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml34
-rw-r--r--app/views/projects/branches/index.html.haml16
-rw-r--r--app/views/projects/branches/new.html.haml22
-rw-r--r--app/views/projects/builds/_header.html.haml27
-rw-r--r--app/views/projects/builds/_sidebar.html.haml142
-rw-r--r--app/views/projects/builds/index.html.haml23
-rw-r--r--app/views/projects/builds/show.html.haml86
-rw-r--r--app/views/projects/buttons/_download.html.haml29
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml36
-rw-r--r--app/views/projects/buttons/_fork.html.haml14
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml10
-rw-r--r--app/views/projects/ci/builds/_build.html.haml22
-rw-r--r--app/views/projects/commit/_ajax_signature.html.haml2
-rw-r--r--app/views/projects/commit/_change.html.haml27
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml6
-rw-r--r--app/views/projects/commit/_commit_box.html.haml62
-rw-r--r--app/views/projects/commit/_invalid_signature_badge.html.haml9
-rw-r--r--app/views/projects/commit/_pipeline.html.haml52
-rw-r--r--app/views/projects/commit/_signature.html.haml5
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml18
-rw-r--r--app/views/projects/commit/_valid_signature_badge.html.haml32
-rw-r--r--app/views/projects/commit/branches.html.haml28
-rw-r--r--app/views/projects/commit/pipelines.html.haml2
-rw-r--r--app/views/projects/commit/show.html.haml14
-rw-r--r--app/views/projects/commits/_commit.atom.builder4
-rw-r--r--app/views/projects/commits/_commit.html.haml26
-rw-r--r--app/views/projects/commits/_commits.html.haml13
-rw-r--r--app/views/projects/commits/_head.html.haml32
-rw-r--r--app/views/projects/commits/_inline_commit.html.haml4
-rw-r--r--app/views/projects/commits/show.atom.builder15
-rw-r--r--app/views/projects/commits/show.html.haml67
-rw-r--r--app/views/projects/compare/_form.html.haml12
-rw-r--r--app/views/projects/compare/_ref_dropdown.html.haml5
-rw-r--r--app/views/projects/compare/index.html.haml8
-rw-r--r--app/views/projects/compare/show.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_empty_stage.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/_no_access.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml57
-rw-r--r--app/views/projects/deploy_keys/_deploy_key.html.haml30
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml2
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml35
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml10
-rw-r--r--app/views/projects/deploy_keys/new.html.haml5
-rw-r--r--app/views/projects/deployments/_actions.haml1
-rw-r--r--app/views/projects/deployments/_commit.html.haml31
-rw-r--r--app/views/projects/deployments/_deployment.html.haml36
-rw-r--r--app/views/projects/diffs/_collapsed.html.haml5
-rw-r--r--app/views/projects/diffs/_content.html.haml36
-rw-r--r--app/views/projects/diffs/_diffs.html.haml42
-rw-r--r--app/views/projects/diffs/_file.html.haml14
-rw-r--r--app/views/projects/diffs/_file_header.html.haml22
-rw-r--r--app/views/projects/diffs/_image.html.haml69
-rw-r--r--app/views/projects/diffs/_line.html.haml7
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml9
-rw-r--r--app/views/projects/diffs/_render_error.html.haml6
-rw-r--r--app/views/projects/diffs/_stats.html.haml66
-rw-r--r--app/views/projects/diffs/_text_file.html.haml4
-rw-r--r--app/views/projects/diffs/_viewer.html.haml16
-rw-r--r--app/views/projects/diffs/_warning.html.haml13
-rw-r--r--app/views/projects/diffs/viewers/_added.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_deleted.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_image.html.haml69
-rw-r--r--app/views/projects/diffs/viewers/_mode_changed.html.haml3
-rw-r--r--app/views/projects/diffs/viewers/_no_preview.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_not_diffable.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_renamed.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_text.html.haml8
-rw-r--r--app/views/projects/edit.html.haml432
-rw-r--r--app/views/projects/empty.html.haml7
-rw-r--r--app/views/projects/environments/_form.html.haml2
-rw-r--r--app/views/projects/environments/_stop.html.haml2
-rw-r--r--app/views/projects/environments/_terminal_button.html.haml2
-rw-r--r--app/views/projects/environments/index.html.haml5
-rw-r--r--app/views/projects/environments/metrics.html.haml77
-rw-r--r--app/views/projects/environments/new.html.haml1
-rw-r--r--app/views/projects/environments/show.html.haml22
-rw-r--r--app/views/projects/environments/terminal.html.haml7
-rw-r--r--app/views/projects/find_file/show.html.haml15
-rw-r--r--app/views/projects/forks/error.html.haml2
-rw-r--r--app/views/projects/forks/index.html.haml2
-rw-r--r--app/views/projects/forks/new.html.haml2
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml8
-rw-r--r--app/views/projects/graphs/charts.html.haml66
-rw-r--r--app/views/projects/graphs/show.html.haml32
-rw-r--r--app/views/projects/group_links/_index.html.haml55
-rw-r--r--app/views/projects/hook_logs/_index.html.haml37
-rw-r--r--app/views/projects/hook_logs/show.html.haml11
-rw-r--r--app/views/projects/hooks/_index.html.haml24
-rw-r--r--app/views/projects/hooks/edit.html.haml22
-rw-r--r--app/views/projects/imports/new.html.haml4
-rw-r--r--app/views/projects/imports/show.html.haml2
-rw-r--r--app/views/projects/issues/_discussion.html.haml6
-rw-r--r--app/views/projects/issues/_head.html.haml10
-rw-r--r--app/views/projects/issues/_issue.html.haml74
-rw-r--r--app/views/projects/issues/_issue_by_email.html.haml4
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml4
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml10
-rw-r--r--app/views/projects/issues/_new_branch.html.haml36
-rw-r--r--app/views/projects/issues/_related_branches.html.haml3
-rw-r--r--app/views/projects/issues/index.atom.builder15
-rw-r--r--app/views/projects/issues/index.html.haml27
-rw-r--r--app/views/projects/issues/new.html.haml1
-rw-r--r--app/views/projects/issues/show.html.haml78
-rw-r--r--app/views/projects/jobs/_header.html.haml31
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml101
-rw-r--r--app/views/projects/jobs/_table.html.haml (renamed from app/views/projects/builds/_table.html.haml)0
-rw-r--r--app/views/projects/jobs/_user.html.haml (renamed from app/views/projects/builds/_user.html.haml)0
-rw-r--r--app/views/projects/jobs/index.html.haml26
-rw-r--r--app/views/projects/jobs/show.html.haml100
-rw-r--r--app/views/projects/labels/edit.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml22
-rw-r--r--app/views/projects/labels/new.html.haml3
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml2
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml6
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml (renamed from app/views/projects/merge_requests/show/_commits.html.haml)0
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml8
-rw-r--r--app/views/projects/merge_requests/_head.html.haml6
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml55
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml80
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml13
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml40
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml5
-rw-r--r--app/views/projects/merge_requests/_new_compare.html.haml75
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml58
-rw-r--r--app/views/projects/merge_requests/_pipelines.html.haml4
-rw-r--r--app/views/projects/merge_requests/_show.html.haml121
-rw-r--r--app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml8
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml4
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml38
-rw-r--r--app/views/projects/merge_requests/creations/_diffs.html.haml (renamed from app/views/projects/merge_requests/_new_diffs.html.haml)0
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml68
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml52
-rw-r--r--app/views/projects/merge_requests/creations/branch_from.html.haml (renamed from app/views/projects/merge_requests/branch_from.html.haml)0
-rw-r--r--app/views/projects/merge_requests/creations/branch_to.html.haml (renamed from app/views/projects/merge_requests/branch_to.html.haml)0
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml7
-rw-r--r--app/views/projects/merge_requests/creations/update_branches.html.haml (renamed from app/views/projects/merge_requests/update_branches.html.haml)0
-rw-r--r--app/views/projects/merge_requests/diffs.html.haml1
-rw-r--r--app/views/projects/merge_requests/diffs/_diffs.html.haml5
-rw-r--r--app/views/projects/merge_requests/diffs/_versions.html.haml97
-rw-r--r--app/views/projects/merge_requests/dropdowns/_project.html.haml2
-rw-r--r--app/views/projects/merge_requests/index.html.haml28
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml4
-rw-r--r--app/views/projects/merge_requests/merge.js.haml14
-rw-r--r--app/views/projects/merge_requests/new.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml92
-rw-r--r--app/views/projects/merge_requests/show/_diffs.html.haml5
-rw-r--r--app/views/projects/merge_requests/show/_how_to_merge.html.haml63
-rw-r--r--app/views/projects/merge_requests/show/_mr_box.html.haml14
-rw-r--r--app/views/projects/merge_requests/show/_mr_title.html.haml35
-rw-r--r--app/views/projects/merge_requests/show/_pipelines.html.haml4
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml87
-rw-r--r--app/views/projects/merge_requests/widget/_closed.html.haml12
-rw-r--r--app/views/projects/merge_requests/widget/_commit_change_content.html.haml4
-rw-r--r--app/views/projects/merge_requests/widget/_heading.html.haml50
-rw-r--r--app/views/projects/merge_requests/widget/_locked.html.haml9
-rw-r--r--app/views/projects/merge_requests/widget/_merged.html.haml52
-rw-r--r--app/views/projects/merge_requests/widget/_merged_buttons.haml14
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml49
-rw-r--r--app/views/projects/merge_requests/widget/_show.html.haml40
-rw-r--r--app/views/projects/merge_requests/widget/open/_accept.html.haml50
-rw-r--r--app/views/projects/merge_requests/widget/open/_archived.html.haml4
-rw-r--r--app/views/projects/merge_requests/widget/open/_build_failed.html.haml6
-rw-r--r--app/views/projects/merge_requests/widget/open/_check.html.haml6
-rw-r--r--app/views/projects/merge_requests/widget/open/_conflicts.html.haml27
-rw-r--r--app/views/projects/merge_requests/widget/open/_manual.html.haml4
-rw-r--r--app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml33
-rw-r--r--app/views/projects/merge_requests/widget/open/_missing_branch.html.haml16
-rw-r--r--app/views/projects/merge_requests/widget/open/_not_allowed.html.haml6
-rw-r--r--app/views/projects/merge_requests/widget/open/_nothing.html.haml8
-rw-r--r--app/views/projects/merge_requests/widget/open/_reload.html.haml6
-rw-r--r--app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml6
-rw-r--r--app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml10
-rw-r--r--app/views/projects/merge_requests/widget/open/_wip.html.haml11
-rw-r--r--app/views/projects/milestones/_form.html.haml8
-rw-r--r--app/views/projects/milestones/_milestone.html.haml6
-rw-r--r--app/views/projects/milestones/index.html.haml9
-rw-r--r--app/views/projects/milestones/new.html.haml1
-rw-r--r--app/views/projects/milestones/show.html.haml11
-rw-r--r--app/views/projects/network/show.html.haml5
-rw-r--r--app/views/projects/new.html.haml166
-rw-r--r--app/views/projects/no_repo.html.haml16
-rw-r--r--app/views/projects/notes/_actions.html.haml48
-rw-r--r--app/views/projects/notes/_comment_button.html.haml30
-rw-r--r--app/views/projects/notes/_edit_form.html.haml14
-rw-r--r--app/views/projects/notes/_form.html.haml36
-rw-r--r--app/views/projects/notes/_hints.html.haml14
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml16
-rw-r--r--app/views/projects/notes/_note.html.haml102
-rw-r--r--app/views/projects/notes/_notes.html.haml8
-rw-r--r--app/views/projects/notes/_notes_with_form.html.haml26
-rw-r--r--app/views/projects/pages/_destroy.haml2
-rw-r--r--app/views/projects/pages/_disabled.html.haml4
-rw-r--r--app/views/projects/pages/_list.html.haml4
-rw-r--r--app/views/projects/pages/show.html.haml17
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml41
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml37
-rw-r--r--app/views/projects/pipeline_schedules/_table.html.haml12
-rw-r--r--app/views/projects/pipeline_schedules/_tabs.html.haml18
-rw-r--r--app/views/projects/pipeline_schedules/_variable_row.html.haml17
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml7
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml34
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml12
-rw-r--r--app/views/projects/pipelines/_graph.html.haml4
-rw-r--r--app/views/projects/pipelines/_head.html.haml14
-rw-r--r--app/views/projects/pipelines/_info.html.haml22
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml28
-rw-r--r--app/views/projects/pipelines/charts.html.haml10
-rw-r--r--app/views/projects/pipelines/charts/_build_times.haml27
-rw-r--r--app/views/projects/pipelines/charts/_builds.haml56
-rw-r--r--app/views/projects/pipelines/charts/_overall.haml22
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml10
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml36
-rw-r--r--app/views/projects/pipelines/index.html.haml4
-rw-r--r--app/views/projects/pipelines/new.html.haml14
-rw-r--r--app/views/projects/pipelines/show.html.haml6
-rw-r--r--app/views/projects/pipelines_settings/_badge.html.haml4
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml20
-rw-r--r--app/views/projects/project_members/_group_members.html.haml18
-rw-r--r--app/views/projects/project_members/_index.html.haml22
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml39
-rw-r--r--app/views/projects/project_members/_new_shared_group.html.haml20
-rw-r--r--app/views/projects/project_members/_shared_group_members.html.haml24
-rw-r--r--app/views/projects/project_members/_team.html.haml12
-rw-r--r--app/views/projects/project_members/import.html.haml4
-rw-r--r--app/views/projects/project_members/index.html.haml44
-rw-r--r--app/views/projects/protected_branches/_branches_list.html.haml30
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml53
-rw-r--r--app/views/projects/protected_branches/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_branches/_index.html.haml23
-rw-r--r--app/views/projects/protected_branches/_matching_branch.html.haml9
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml21
-rw-r--r--app/views/projects/protected_branches/_update_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml30
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml33
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml24
-rw-r--r--app/views/projects/protected_branches/shared/_matching_branch.html.haml10
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml24
-rw-r--r--app/views/projects/protected_branches/show.html.haml4
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml38
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/_index.html.haml20
-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.haml30
-rw-r--r--app/views/projects/protected_tags/_update_protected_tag.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml29
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml24
-rw-r--r--app/views/projects/protected_tags/shared/_matching_tag.html.haml10
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml22
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml30
-rw-r--r--app/views/projects/protected_tags/show.html.haml6
-rw-r--r--app/views/projects/registry/repositories/_image.html.haml16
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml72
-rw-r--r--app/views/projects/releases/edit.html.haml8
-rw-r--r--app/views/projects/remove_fork.js.haml2
-rw-r--r--app/views/projects/repositories/_feed.html.haml4
-rw-r--r--app/views/projects/runners/_runner.html.haml23
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml4
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml19
-rw-r--r--app/views/projects/runners/edit.html.haml2
-rw-r--r--app/views/projects/services/_form.html.haml30
-rw-r--r--app/views/projects/services/_index.html.haml6
-rw-r--r--app/views/projects/services/edit.html.haml5
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml2
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml45
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/projects/settings/_head.html.haml21
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml9
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml13
-rw-r--r--app/views/projects/settings/integrations/show.html.haml3
-rw-r--r--app/views/projects/settings/members/show.html.haml5
-rw-r--r--app/views/projects/settings/repository/show.html.haml15
-rw-r--r--app/views/projects/show.atom.builder15
-rw-r--r--app/views/projects/show.html.haml54
-rw-r--r--app/views/projects/snippets/_actions.html.haml16
-rw-r--r--app/views/projects/snippets/edit.html.haml2
-rw-r--r--app/views/projects/snippets/index.html.haml15
-rw-r--r--app/views/projects/snippets/new.html.haml2
-rw-r--r--app/views/projects/snippets/show.html.haml5
-rw-r--r--app/views/projects/stage/_graph.html.haml19
-rw-r--r--app/views/projects/stage/_in_stage_group.html.haml14
-rw-r--r--app/views/projects/tags/_tag.html.haml14
-rw-r--r--app/views/projects/tags/index.html.haml22
-rw-r--r--app/views/projects/tags/new.html.haml30
-rw-r--r--app/views/projects/tags/show.html.haml15
-rw-r--r--app/views/projects/transfer.js.haml2
-rw-r--r--app/views/projects/tree/_blob_item.html.haml2
-rw-r--r--app/views/projects/tree/_old_tree_content.html.haml24
-rw-r--r--app/views/projects/tree/_old_tree_header.html.haml70
-rw-r--r--app/views/projects/tree/_readme.html.haml17
-rw-r--r--app/views/projects/tree/_tree_commit_column.html.haml2
-rw-r--r--app/views/projects/tree/_tree_content.html.haml45
-rw-r--r--app/views/projects/tree/_tree_header.html.haml91
-rw-r--r--app/views/projects/tree/_tree_item.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml18
-rw-r--r--app/views/projects/triggers/_form.html.haml22
-rw-r--r--app/views/projects/triggers/_index.html.haml6
-rw-r--r--app/views/projects/triggers/_trigger.html.haml12
-rw-r--r--app/views/projects/update.js.haml2
-rw-r--r--app/views/projects/variables/_content.html.haml8
-rw-r--r--app/views/projects/variables/_form.html.haml10
-rw-r--r--app/views/projects/variables/_index.html.haml16
-rw-r--r--app/views/projects/variables/_table.html.haml25
-rw-r--r--app/views/projects/variables/show.html.haml10
-rw-r--r--app/views/projects/wikis/_form.html.haml27
-rw-r--r--app/views/projects/wikis/_main_links.html.haml4
-rw-r--r--app/views/projects/wikis/_new.html.haml39
-rw-r--r--app/views/projects/wikis/_pages_wiki_page.html.haml2
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml36
-rw-r--r--app/views/projects/wikis/_sidebar_wiki_page.html.haml2
-rw-r--r--app/views/projects/wikis/edit.html.haml57
-rw-r--r--app/views/projects/wikis/git_access.html.haml67
-rw-r--r--app/views/projects/wikis/history.html.haml69
-rw-r--r--app/views/projects/wikis/pages.html.haml2
-rw-r--r--app/views/projects/wikis/show.html.haml47
-rw-r--r--app/views/search/_category.html.haml77
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_issue.html.haml3
-rw-r--r--app/views/search/results/_merge_request.html.haml3
-rw-r--r--app/views/search/results/_milestone.html.haml3
-rw-r--r--app/views/search/results/_note.html.haml3
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/search/results/_snippet_title.html.haml2
-rw-r--r--app/views/search/results/_wiki_blob.html.haml2
-rw-r--r--app/views/search/show.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml10
-rw-r--r--app/views/shared/_branch_switcher.html.haml8
-rw-r--r--app/views/shared/_clone_panel.html.haml14
-rw-r--r--app/views/shared/_commit_message_container.html.haml2
-rw-r--r--app/views/shared/_commit_well.html.haml4
-rw-r--r--app/views/shared/_field.html.haml9
-rw-r--r--app/views/shared/_group_form.html.haml2
-rw-r--r--app/views/shared/_import_form.html.haml26
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml8
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/_label.html.haml47
-rw-r--r--app/views/shared/_label_row.html.haml6
-rw-r--r--app/views/shared/_logo_type.svg1
-rw-r--r--app/views/shared/_merge_requests.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml12
-rw-r--r--app/views/shared/_mr_head.html.haml2
-rw-r--r--app/views/shared/_new_commit_form.html.haml10
-rw-r--r--app/views/shared/_new_merge_request_checkbox.html.haml8
-rw-r--r--app/views/shared/_new_project_item_select.html.haml24
-rw-r--r--app/views/shared/_no_password.html.haml11
-rw-r--r--app/views/shared/_no_ssh.html.haml11
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml15
-rw-r--r--app/views/shared/_personal_access_tokens_table.html.haml19
-rw-r--r--app/views/shared/_ref_dropdown.html.haml7
-rw-r--r--app/views/shared/_ref_switcher.html.haml10
-rw-r--r--app/views/shared/_service_settings.html.haml12
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml8
-rw-r--r--app/views/shared/_sort_dropdown.html.haml4
-rw-r--r--app/views/shared/_target_switcher.html.haml20
-rw-r--r--app/views/shared/_user_callout.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml30
-rw-r--r--app/views/shared/empty_states/_issues.html.haml8
-rw-r--r--app/views/shared/empty_states/_labels.html.haml8
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml4
-rw-r--r--app/views/shared/empty_states/icons/_pipelines_empty.svg2
-rw-r--r--app/views/shared/errors/_graphic_422.svg1
-rw-r--r--app/views/shared/form_elements/_description.html.haml23
-rw-r--r--app/views/shared/groups/_dropdown.html.haml12
-rw-r--r--app/views/shared/hook_logs/_content.html.haml44
-rw-r--r--app/views/shared/hook_logs/_status_label.html.haml3
-rw-r--r--app/views/shared/icons/_abuse_reports.svg1
-rw-r--r--app/views/shared/icons/_access_tokens.svg1
-rw-r--r--app/views/shared/icons/_account.svg1
-rw-r--r--app/views/shared/icons/_add_new_group.svg8
-rw-r--r--app/views/shared/icons/_add_new_project.svg1
-rw-r--r--app/views/shared/icons/_add_new_user.svg9
-rw-r--r--app/views/shared/icons/_appearance.svg1
-rw-r--r--app/views/shared/icons/_applications.svg1
-rw-r--r--app/views/shared/icons/_authentication_log.svg1
-rw-r--r--app/views/shared/icons/_chat.svg1
-rw-r--r--app/views/shared/icons/_configure_server.svg8
-rw-r--r--app/views/shared/icons/_container_registry.svg1
-rw-r--r--app/views/shared/icons/_convdev_no_data.svg40
-rw-r--r--app/views/shared/icons/_convdev_no_index.svg67
-rw-r--r--app/views/shared/icons/_convdev_overview.svg64
-rw-r--r--app/views/shared/icons/_doc_text.svg1
-rw-r--r--app/views/shared/icons/_ellipsis_v.svg1
-rw-r--r--app/views/shared/icons/_emails.svg1
-rw-r--r--app/views/shared/icons/_express.svg6
-rw-r--r--app/views/shared/icons/_globe.svg1
-rw-r--r--app/views/shared/icons/_i2p_step_1.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_10.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_2.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_3.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_4.svg6
-rw-r--r--app/views/shared/icons/_i2p_step_5.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_6.svg15
-rw-r--r--app/views/shared/icons/_i2p_step_7.svg7
-rw-r--r--app/views/shared/icons/_i2p_step_8.svg4
-rw-r--r--app/views/shared/icons/_i2p_step_9.svg4
-rw-r--r--app/views/shared/icons/_icon_clone.svg3
-rw-r--r--app/views/shared/icons/_icon_empty_metrics.svg5
-rw-r--r--app/views/shared/icons/_icon_explore_groups_splash.svg1
-rw-r--r--app/views/shared/icons/_icon_history.svg1
-rw-r--r--app/views/shared/icons/_icon_status_notfound_borderless.svg1
-rwxr-xr-xapp/views/shared/icons/_icon_status_skipped.svg2
-rw-r--r--app/views/shared/icons/_icon_status_skipped_borderless.svg2
-rw-r--r--app/views/shared/icons/_issues.svg1
-rw-r--r--app/views/shared/icons/_issues.svg.erb4
-rw-r--r--app/views/shared/icons/_key.svg1
-rw-r--r--app/views/shared/icons/_key_2.svg1
-rw-r--r--app/views/shared/icons/_labels.svg1
-rw-r--r--app/views/shared/icons/_lock.svg1
-rw-r--r--app/views/shared/icons/_members.svg1
-rw-r--r--app/views/shared/icons/_messages.svg1
-rw-r--r--app/views/shared/icons/_monitoring.svg1
-rw-r--r--app/views/shared/icons/_mr_widget_empty_state.svg1
-rw-r--r--app/views/shared/icons/_notifications.svg1
-rw-r--r--app/views/shared/icons/_overview.svg1
-rw-r--r--app/views/shared/icons/_pipeline.svg1
-rw-r--r--app/views/shared/icons/_preferences.svg1
-rw-r--r--app/views/shared/icons/_profile.svg1
-rw-r--r--app/views/shared/icons/_project.svg1
-rw-r--r--app/views/shared/icons/_project.svg.erb3
-rw-r--r--app/views/shared/icons/_rails.svg6
-rw-r--r--app/views/shared/icons/_scroll_down.svg6
-rw-r--r--app/views/shared/icons/_scroll_down_hover_active.svg3
-rw-r--r--app/views/shared/icons/_scroll_up.svg4
-rw-r--r--app/views/shared/icons/_scroll_up_hover_active.svg3
-rw-r--r--app/views/shared/icons/_service_templates.svg1
-rw-r--r--app/views/shared/icons/_settings.svg1
-rw-r--r--app/views/shared/icons/_snippets.svg1
-rw-r--r--app/views/shared/icons/_spam_logs.svg1
-rw-r--r--app/views/shared/icons/_spring.svg6
-rw-r--r--app/views/shared/icons/_system_hooks.svg1
-rw-r--r--app/views/shared/icons/_wiki.svg1
-rw-r--r--app/views/shared/issuable/_assignees.html.haml14
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml53
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml14
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml49
-rw-r--r--app/views/shared/issuable/_filter.html.haml44
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml9
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml2
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml2
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml6
-rw-r--r--app/views/shared/issuable/_nav.html.haml10
-rw-r--r--app/views/shared/issuable/_participants.html.haml10
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml107
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml66
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml56
-rw-r--r--app/views/shared/issuable/_user_dropdown_item.html.haml12
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml10
-rw-r--r--app/views/shared/issuable/form/_description.html.haml15
-rw-r--r--app/views/shared/issuable/form/_issue_assignee.html.haml31
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml3
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml31
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml11
-rw-r--r--app/views/shared/issuable/form/_metadata_issue_assignee.html.haml11
-rw-r--r--app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml8
-rw-r--r--app/views/shared/members/_access_request_buttons.html.haml17
-rw-r--r--app/views/shared/members/_group.html.haml4
-rw-r--r--app/views/shared/members/_member.html.haml80
-rw-r--r--app/views/shared/members/_requests.html.haml8
-rw-r--r--app/views/shared/milestones/_issuable.html.haml21
-rw-r--r--app/views/shared/milestones/_issuables.html.haml6
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml14
-rw-r--r--app/views/shared/milestones/_milestone.html.haml43
-rw-r--r--app/views/shared/milestones/_participants_tab.html.haml4
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml16
-rw-r--r--app/views/shared/milestones/_tab_loading.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml33
-rw-r--r--app/views/shared/milestones/_top.html.haml64
-rw-r--r--app/views/shared/notes/_comment_button.html.haml30
-rw-r--r--app/views/shared/notes/_edit.html.haml1
-rw-r--r--app/views/shared/notes/_edit_form.html.haml14
-rw-r--r--app/views/shared/notes/_form.html.haml43
-rw-r--r--app/views/shared/notes/_hints.html.haml35
-rw-r--r--app/views/shared/notes/_note.html.haml65
-rw-r--r--app/views/shared/notes/_notes.html.haml8
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml25
-rw-r--r--app/views/shared/notifications/_button.html.haml4
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml17
-rw-r--r--app/views/shared/projects/_list.html.haml8
-rw-r--r--app/views/shared/projects/_project.html.haml8
-rw-r--r--app/views/shared/projects/blob/_branch_page_create.html.haml8
-rw-r--r--app/views/shared/projects/blob/_branch_page_default.html.haml10
-rw-r--r--app/views/shared/repo/_editable_mode.html.haml2
-rw-r--r--app/views/shared/repo/_repo.html.haml7
-rw-r--r--app/views/shared/snippets/_blob.html.haml31
-rw-r--r--app/views/shared/snippets/_form.html.haml9
-rw-r--r--app/views/shared/snippets/_header.html.haml10
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml182
-rw-r--r--app/views/shared/web_hooks/_test_button.html.haml12
-rw-r--r--app/views/snippets/_snippets.html.haml6
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml17
-rw-r--r--app/views/snippets/show.html.haml13
-rw-r--r--app/views/u2f/_register.html.haml4
-rw-r--r--app/views/users/_deletion_guidance.html.haml10
-rw-r--r--app/views/users/calendar.html.haml9
-rw-r--r--app/views/users/calendar_activities.html.haml8
-rw-r--r--app/views/users/show.atom.builder15
-rw-r--r--app/views/users/show.html.haml26
-rw-r--r--app/workers/background_migration_worker.rb39
-rw-r--r--app/workers/build_success_worker.rb11
-rw-r--r--app/workers/concerns/new_issuable.rb26
-rw-r--r--app/workers/create_gpg_signature_worker.rb12
-rw-r--r--app/workers/email_receiver_worker.rb2
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb2
-rw-r--r--app/workers/expire_job_cache_worker.rb27
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb27
-rw-r--r--app/workers/git_garbage_collect_worker.rb39
-rw-r--r--app/workers/gitlab_shell_worker.rb2
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb18
-rw-r--r--app/workers/invalid_gpg_signature_update_worker.rb12
-rw-r--r--app/workers/irker_worker.rb10
-rw-r--r--app/workers/merge_worker.rb6
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb39
-rw-r--r--app/workers/new_issue_worker.rb17
-rw-r--r--app/workers/new_merge_request_worker.rb17
-rw-r--r--app/workers/pages_worker.rb2
-rw-r--r--app/workers/pipeline_schedule_worker.rb22
-rw-r--r--app/workers/post_receive.rb46
-rw-r--r--app/workers/process_commit_worker.rb22
-rw-r--r--app/workers/project_cache_worker.rb6
-rw-r--r--app/workers/project_destroy_worker.rb9
-rw-r--r--app/workers/project_service_worker.rb2
-rw-r--r--app/workers/project_web_hook_worker.rb11
-rw-r--r--app/workers/propagate_service_template_worker.rb21
-rw-r--r--app/workers/prune_old_events_worker.rb8
-rw-r--r--app/workers/remove_old_web_hook_logs_worker.rb10
-rw-r--r--app/workers/repository_check/batch_worker.rb8
-rw-r--r--app/workers/repository_check/clear_worker.rb2
-rw-r--r--app/workers/repository_check/single_repository_worker.rb2
-rw-r--r--app/workers/repository_fork_worker.rb50
-rw-r--r--app/workers/repository_import_worker.rb46
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb2
-rw-r--r--app/workers/stuck_import_jobs_worker.rb50
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb34
-rw-r--r--app/workers/system_hook_worker.rb10
-rw-r--r--app/workers/trigger_schedule_worker.rb18
-rw-r--r--app/workers/update_user_activity_worker.rb4
-rw-r--r--app/workers/web_hook_worker.rb13
-rwxr-xr-xbin/changelog134
-rwxr-xr-xbin/ci/upgrade.rb3
-rwxr-xr-xbin/rspec-stackprof3
-rw-r--r--changelogs/unreleased/10085-stop-encoding-user-name.yml4
-rw-r--r--changelogs/unreleased/12673-fix_v3_project_hooks_build_events4
-rw-r--r--changelogs/unreleased/12892-reset-css-text-align-to-initial-for-rtl.md4
-rw-r--r--changelogs/unreleased/12910-personal-snippet-prep-2.yml4
-rw-r--r--changelogs/unreleased/13247-api_project_events_target_iid.yml4
-rw-r--r--changelogs/unreleased/13265-project_events_noteable_iid.yml4
-rw-r--r--changelogs/unreleased/13325-bugfix-silence-on-disabled-notifications.yml6
-rw-r--r--changelogs/unreleased/1440-db-backup-ssl-support.yml4
-rw-r--r--changelogs/unreleased/1827-prevent-concurrent-editing-wiki.yml4
-rw-r--r--changelogs/unreleased/19629-remove-inactive-tokens-list.yml4
-rw-r--r--changelogs/unreleased/20378-natural-sort-issue-numbers.yml4
-rw-r--r--changelogs/unreleased/20817-please-add-coordinator-url-to-admin-area-runner-page.yml4
-rw-r--r--changelogs/unreleased/21683-show-created-group-name-flash.yml4
-rw-r--r--changelogs/unreleased/21949-add-type-to-changelog.yml4
-rw-r--r--changelogs/unreleased/2246-uuid-is-nil-for-new-installation.yml4
-rw-r--r--changelogs/unreleased/22600-related-resources-uris-using-grape-source-helpers.yml4
-rw-r--r--changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml4
-rw-r--r--changelogs/unreleased/22826-ui-inconsistency-different-files-views-find-file-button-missing.yml4
-rw-r--r--changelogs/unreleased/23036-replace-dashboard-event-filters-spinach.yml4
-rw-r--r--changelogs/unreleased/23036-replace-dashboard-spinach.yml4
-rw-r--r--changelogs/unreleased/26208-animate-drodowns.yml4
-rw-r--r--changelogs/unreleased/26372-duplicate-issue-slash-command.yml4
-rw-r--r--changelogs/unreleased/26437-closed-by.yml4
-rw-r--r--changelogs/unreleased/26509-show-update-time.yml4
-rw-r--r--changelogs/unreleased/26585-remove-readme-view-caching.yml4
-rw-r--r--changelogs/unreleased/27376-bvl-load-pipelinestatus-in-batch.yml4
-rw-r--r--changelogs/unreleased/27616-fix-contributions-graph-utc-offset-mysql.yml4
-rw-r--r--changelogs/unreleased/27655-clear-emoji-search-after-selection.yml4
-rw-r--r--changelogs/unreleased/27729-improve-webpack-dev-environment.yml4
-rw-r--r--changelogs/unreleased/27827-cleanup-markdown.yml4
-rw-r--r--changelogs/unreleased/28017-separate-ce-params-on-api.yml4
-rw-r--r--changelogs/unreleased/28202_decrease_abc_threshold_step1.yml4
-rw-r--r--changelogs/unreleased/28202_decrease_abc_threshold_step2.yml4
-rw-r--r--changelogs/unreleased/28472-ignore-auto-generated-mails.yml4
-rw-r--r--changelogs/unreleased/28575-expand-collapse-look.yml4
-rw-r--r--changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml4
-rw-r--r--changelogs/unreleased/29289-project-destroy-clean-up-after-failure.yml4
-rw-r--r--changelogs/unreleased/29385-add_shrug_command.yml4
-rw-r--r--changelogs/unreleased/29505-allow-admins-sudo-to-blocked-users.yml4
-rw-r--r--changelogs/unreleased/29595-customize-experience-callout.yml4
-rw-r--r--changelogs/unreleased/29712-unnecessary-wait-for-ajax.yml4
-rw-r--r--changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml4
-rw-r--r--changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml5
-rw-r--r--changelogs/unreleased/29811-fix-line-number-alignment.yml4
-rw-r--r--changelogs/unreleased/29816-create-keyboard-shortcut-for-editing-wiki-page.yml4
-rw-r--r--changelogs/unreleased/29901-refactor-initialization-dropzone_input-js.yml4
-rw-r--r--changelogs/unreleased/29903-remove-user-is-admin-flag-from-api.yml4
-rw-r--r--changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml4
-rw-r--r--changelogs/unreleased/30305-oauth-token-push-code.yml4
-rw-r--r--changelogs/unreleased/30349-create-users-build-service.yml4
-rw-r--r--changelogs/unreleased/30466-click-x-to-remove-filter.yml4
-rw-r--r--changelogs/unreleased/30484-profile-dropdown-account-name.yml4
-rw-r--r--changelogs/unreleased/30634-protected-pipeline.yml5
-rw-r--r--changelogs/unreleased/30645-show-pipeline-events-description.yml4
-rw-r--r--changelogs/unreleased/30672-versioned-markdown-cache.yml4
-rw-r--r--changelogs/unreleased/30678-improve-dev-server-process.yml4
-rw-r--r--changelogs/unreleased/30973-fix-network-graph-ordering.yml4
-rw-r--r--changelogs/unreleased/31009-disable-test-settings-on-services-when-repository-is-empty.yml4
-rw-r--r--changelogs/unreleased/31129-jira-project-key-elim.yml4
-rw-r--r--changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml4
-rw-r--r--changelogs/unreleased/31193-ff-copy.yml4
-rw-r--r--changelogs/unreleased/31207-clean-locked-merge-requests.yml4
-rw-r--r--changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml4
-rw-r--r--changelogs/unreleased/31362_decrease_cyclomatic_complexity_threshold_step1.yml4
-rw-r--r--changelogs/unreleased/31533-usage-data-projects-stats.yml4
-rw-r--r--changelogs/unreleased/31571-don-t-let-webhooks-jobs-go-to-the-dead-jobs-queue.yml5
-rw-r--r--changelogs/unreleased/32340-correct-jobs-api-documentation4
-rw-r--r--changelogs/unreleased/32483-jira-error.yml4
-rw-r--r--changelogs/unreleased/32844-issuables-performance.yml4
-rw-r--r--changelogs/unreleased/33095-mr-widget-ui.yml4
-rw-r--r--changelogs/unreleased/33097-issue-tracker.yml4
-rw-r--r--changelogs/unreleased/33601-add-csrf-token-verification-to-api.yml4
-rw-r--r--changelogs/unreleased/33620-remove-events-from-notification_settings.yml4
-rw-r--r--changelogs/unreleased/33741-clarify-k8s-service-keys.yml5
-rw-r--r--changelogs/unreleased/33770-respect-blockquote-line-breaks.yml4
-rw-r--r--changelogs/unreleased/33874_confi.yml5
-rw-r--r--changelogs/unreleased/34027-add-icons-to-sidebar.yml4
-rw-r--r--changelogs/unreleased/34028-collapse-sidebar.yml4
-rw-r--r--changelogs/unreleased/34049-public-commits-should-not-require-authentication.yml4
-rw-r--r--changelogs/unreleased/34075-pipelines-count-mt.yml5
-rw-r--r--changelogs/unreleased/34110-memory-usage-notice-doesn-t-link-anywhere.yml4
-rw-r--r--changelogs/unreleased/34339-user_avatar-url-in-push-event-webhook-json-payload-is-relative-should-be-absolute.yml4
-rw-r--r--changelogs/unreleased/34361-lazy-load-images-on-the-frontend.yml4
-rw-r--r--changelogs/unreleased/34371-pipeline-schedule-vue-files.yml6
-rw-r--r--changelogs/unreleased/34519-extend-api-group-secret-variable.yml4
-rw-r--r--changelogs/unreleased/34527-make-edit-comment-button-always-available-outside-of-dropdown.yml4
-rw-r--r--changelogs/unreleased/34533-speed-up-group-project-authorizations.yml5
-rw-r--r--changelogs/unreleased/34534-update-vue-resource.yml4
-rw-r--r--changelogs/unreleased/34549-extract-devise-mappings-into-helper.yml4
-rw-r--r--changelogs/unreleased/34563-usage-ping-github.yml4
-rw-r--r--changelogs/unreleased/34643-fix-project-path-slugify.yml4
-rw-r--r--changelogs/unreleased/34764-rename-to-overview.yml4
-rw-r--r--changelogs/unreleased/34810-vue-pagination.yml4
-rw-r--r--changelogs/unreleased/34831-remove-coffee-rails-gem.yml4
-rw-r--r--changelogs/unreleased/34858-bump-scss-lint-to-0-54-0.yml4
-rw-r--r--changelogs/unreleased/34867-remove-net-ssh-gem.yml4
-rw-r--r--changelogs/unreleased/34869-bump-rubocop-to-0-49-1-and-rubocop-rspec-to-1-15-1.yml4
-rw-r--r--changelogs/unreleased/34921-global-dropdown-ui-improvement.yml4
-rw-r--r--changelogs/unreleased/34927-protect-manual-actions-on-tags.yml4
-rw-r--r--changelogs/unreleased/34978-remove-public-ci-favicon-ico.yml4
-rw-r--r--changelogs/unreleased/35044-projects-logo-are-not-centered-vertically-on-projects-page.yml4
-rw-r--r--changelogs/unreleased/35072-fix-pages-delete.yml5
-rw-r--r--changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml4
-rw-r--r--changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml4
-rw-r--r--changelogs/unreleased/35155-upgrade-fog-core-to-1-44-3-and-its-providers-to-the-latest.yml4
-rw-r--r--changelogs/unreleased/35163-url-in-commit-message-can-be-broken-in-blame.yml4
-rw-r--r--changelogs/unreleased/35164-cycle-analytics-firefox.yml4
-rw-r--r--changelogs/unreleased/35181-cannot-create-label-from-board-page.yml4
-rw-r--r--changelogs/unreleased/35191-prioritized-labels-for-non-member.yml4
-rw-r--r--changelogs/unreleased/35204-doc-api-ci-lint-typo.yml4
-rw-r--r--changelogs/unreleased/35225-transient-poll.yml4
-rw-r--r--changelogs/unreleased/35253-desc-protected-branches-for-non-member.yml4
-rw-r--r--changelogs/unreleased/35391-fix-star-i18n-in-js.yml4
-rw-r--r--changelogs/unreleased/35408-group-auto-avatars.yml4
-rw-r--r--changelogs/unreleased/35483-improve-mobile-sidebar.yml4
-rw-r--r--changelogs/unreleased/35659-rename-pipeline.yml4
-rw-r--r--changelogs/unreleased/35695-comment-appears-in-a-wrong-place-after-changing-diff-view-to-inline.yml4
-rw-r--r--changelogs/unreleased/35761-convdev-perc.yml4
-rw-r--r--changelogs/unreleased/35769-fix-ruby-2-4-compatibility.yml4
-rw-r--r--changelogs/unreleased/35815-webhook-log-encoding-error.yml4
-rw-r--r--changelogs/unreleased/36010-api-v4-allows-setting-a-branch-that-doesn-t-exist-as-the-default-one.yml4
-rw-r--r--changelogs/unreleased/36087-users-cannot-delete-their-account.yml5
-rw-r--r--changelogs/unreleased/36119-issuable-workers.yml4
-rw-r--r--changelogs/unreleased/36185-or-separator.yml4
-rw-r--r--changelogs/unreleased/36213-return-is_admin-in-users-api-when-current_user-is-admin.yml6
-rw-r--r--changelogs/unreleased/36385-pipeline-graph-dropdown.yml5
-rw-r--r--changelogs/unreleased/3686_make_tarball_download_url.yml4
-rw-r--r--changelogs/unreleased/5971-webhook-testing.yml4
-rw-r--r--changelogs/unreleased/6260-frontend-prevent-authored-votes.yml4
-rw-r--r--changelogs/unreleased/add-aria-to-icon.yml4
-rw-r--r--changelogs/unreleased/add-filtered-search-group-issues-ce.yml4
-rw-r--r--changelogs/unreleased/add-star-for-action-scope.yml4
-rw-r--r--changelogs/unreleased/add-username-to-activity-feed.yml4
-rw-r--r--changelogs/unreleased/add-vue-loader.yml4
-rw-r--r--changelogs/unreleased/add_index_on_ci_builds_user_id.yml4
-rw-r--r--changelogs/unreleased/appearances-caching-and-schema.yml4
-rw-r--r--changelogs/unreleased/artifacts-download-dropdown-menu-is-too-narrow.yml4
-rw-r--r--changelogs/unreleased/bb_save_trace.yml5
-rw-r--r--changelogs/unreleased/boards-done-add-tooltip.yml4
-rw-r--r--changelogs/unreleased/breadcrumbs-collapsed-title-width-fix.yml4
-rw-r--r--changelogs/unreleased/broadcast-messages-cache.yml4
-rw-r--r--changelogs/unreleased/bump-omniauth-ldap-gem-version-2-0-4.yml4
-rw-r--r--changelogs/unreleased/bump-omniauth-ldap-gem-version.yml4
-rw-r--r--changelogs/unreleased/bvl-add-all-settings-to-api.yml4
-rw-r--r--changelogs/unreleased/bvl-free-unused-names.yml5
-rw-r--r--changelogs/unreleased/bvl-nfs-circuitbreaker.yml4
-rw-r--r--changelogs/unreleased/bvl-rollback-renamed-system-namespace.yml4
-rw-r--r--changelogs/unreleased/commits-list-page-limit.yml5
-rw-r--r--changelogs/unreleased/diff-changed-files-dropdown.yml4
-rw-r--r--changelogs/unreleased/diff-discussion-buttons-spacing.yml4
-rw-r--r--changelogs/unreleased/disable-project-export.yml4
-rw-r--r--changelogs/unreleased/dm-fix-ghost-user-validation.yml4
-rw-r--r--changelogs/unreleased/dm-fix-position-tracer-for-hidden-lines.yml5
-rw-r--r--changelogs/unreleased/dm-large-push-performance.yml4
-rw-r--r--changelogs/unreleased/dm-link-discussion-to-outdated-diff.yml4
-rw-r--r--changelogs/unreleased/dm-sidekiq-5.yml4
-rw-r--r--changelogs/unreleased/dont-use-limit-offset-when-counting-projects.yml4
-rw-r--r--changelogs/unreleased/dz-cleanup-add-users.yml4
-rw-r--r--changelogs/unreleased/dz-fix-calendar-today.yml4
-rw-r--r--changelogs/unreleased/dz-refactor-admin-group-members.yml4
-rw-r--r--changelogs/unreleased/dz-refactor-create-members.yml4
-rw-r--r--changelogs/unreleased/dz-remove-repo-version.yml4
-rw-r--r--changelogs/unreleased/eager-load-project-creators-for-project-dashboards.yml4
-rw-r--r--changelogs/unreleased/empty-task-list-alignment.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-bang-format.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-declaration-order.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-import-path.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-property-spelling.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-space-after-comma.yml4
-rw-r--r--changelogs/unreleased/enable-scss-lint-unnecessary-parent-reference.yml4
-rw-r--r--changelogs/unreleased/ericy_ts-protected_branches_api.yml5
-rw-r--r--changelogs/unreleased/feature-backup-custom-path.yml4
-rw-r--r--changelogs/unreleased/feature-gb-auto-retry-failed-ci-job.yml4
-rw-r--r--changelogs/unreleased/feature-gpg-signed-commits.yml4
-rw-r--r--changelogs/unreleased/fix-500-error-when-rendering-avatar-for-deleted-project-creator.yml4
-rw-r--r--changelogs/unreleased/fix-btn-alignment.yml5
-rw-r--r--changelogs/unreleased/fix-edit-merge-request-button-case.yml5
-rw-r--r--changelogs/unreleased/fix-gb-handle-max-pages-artifacts-size-correctly.yml4
-rw-r--r--changelogs/unreleased/fix-import-symbolink-links.yml4
-rw-r--r--changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml4
-rw-r--r--changelogs/unreleased/fix-notify-post-receive.yml4
-rw-r--r--changelogs/unreleased/fix-replying-to-commit-comment-in-mr-from-fork.yml4
-rw-r--r--changelogs/unreleased/fix-thread-safe-gpgme-tmp-directory.yml4
-rw-r--r--changelogs/unreleased/fix-user-profile-tabs-showing-raw-json-instead.yml5
-rw-r--r--changelogs/unreleased/fix-web_hooks-index.yml4
-rw-r--r--changelogs/unreleased/fix_build_header_line_height.yml4
-rw-r--r--changelogs/unreleased/fix_cache_expiration_in_repository.yml4
-rw-r--r--changelogs/unreleased/fix_emoji_parser.yml4
-rw-r--r--changelogs/unreleased/fix_link_in_readme.yml4
-rw-r--r--changelogs/unreleased/fix_spaces_in_label_title.yml4
-rw-r--r--changelogs/unreleased/fixes-for-internal-auth-disabled.yml4
-rw-r--r--changelogs/unreleased/forks-count-cache.yml5
-rw-r--r--changelogs/unreleased/form-focus-previous-incorrect-form.yml4
-rw-r--r--changelogs/unreleased/github.yml4
-rw-r--r--changelogs/unreleased/gl-version-backup-file.yml4
-rw-r--r--changelogs/unreleased/group-assignee-dropdown-send-group-id.yml4
-rw-r--r--changelogs/unreleased/group-milestone-references-system-notes.yml4
-rw-r--r--changelogs/unreleased/group-new-issue.yml4
-rw-r--r--changelogs/unreleased/handle-reserved-words-for-oauth-usernames.yml4
-rw-r--r--changelogs/unreleased/issue_31790.yml4
-rw-r--r--changelogs/unreleased/issue_35580.yml4
-rw-r--r--changelogs/unreleased/memoize-user-personal-projects-count.yml4
-rw-r--r--changelogs/unreleased/merge-issuable-reopened-into-opened-state.yml4
-rw-r--r--changelogs/unreleased/metrics-graph-error-fix.yml4
-rw-r--r--changelogs/unreleased/migrate-events-into-a-new-format.yml4
-rw-r--r--changelogs/unreleased/milestone-not-showing-correctly-title.yml4
-rw-r--r--changelogs/unreleased/mk-fix-wiki-backup.yml4
-rw-r--r--changelogs/unreleased/more-mr-filters.yml4
-rw-r--r--changelogs/unreleased/move-search-labels.yml4
-rw-r--r--changelogs/unreleased/mr-branch-link-use-tree.yml4
-rw-r--r--changelogs/unreleased/mr-diff-size-overflow.yml4
-rw-r--r--changelogs/unreleased/mrchrisw-22740-merge-api.yml4
-rw-r--r--changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml4
-rw-r--r--changelogs/unreleased/optimise-pipelines-json.yml4
-rw-r--r--changelogs/unreleased/pagination-projects-explore.yml4
-rw-r--r--changelogs/unreleased/pass-before-script-as-is.yml4
-rw-r--r--changelogs/unreleased/pawel-add-sidekiq-metrics-endpoint-32145.yml4
-rw-r--r--changelogs/unreleased/pawel-add_more_variables_to_additional_metrics-35267.yml4
-rw-r--r--changelogs/unreleased/post-upload-pack-opt-out.yml4
-rw-r--r--changelogs/unreleased/query-users-by-extern-uid.yml4
-rw-r--r--changelogs/unreleased/rc-fix-branches-api-endpoint.yml5
-rw-r--r--changelogs/unreleased/rc-fix-commits-api.yml5
-rw-r--r--changelogs/unreleased/rc-fix-tags-api.yml5
-rw-r--r--changelogs/unreleased/related-branch-ci-status-icon-alignment.yml4
-rw-r--r--changelogs/unreleased/remove-double-newline-for-single-attachments.yml4
-rw-r--r--changelogs/unreleased/remove-nprogress-gleaning.yml4
-rw-r--r--changelogs/unreleased/remove-redundant-query-when-retrieving-recent-pushes.yml4
-rw-r--r--changelogs/unreleased/reorganise-issues-indexes-for-sorting.yml4
-rw-r--r--changelogs/unreleased/replace_header_mr_icon.yml4
-rw-r--r--changelogs/unreleased/replace_spinach_spec_browse_files.yml4
-rw-r--r--changelogs/unreleased/request-store-wrap.yml4
-rw-r--r--changelogs/unreleased/reset-new-branch-button.yml4
-rw-r--r--changelogs/unreleased/restrict-haml-javascript.yml4
-rw-r--r--changelogs/unreleased/right-sidebar-closed-default-mobile.yml4
-rw-r--r--changelogs/unreleased/rs-alphanumeric-ssh-params.yml5
-rw-r--r--changelogs/unreleased/seven-days-cycle-analytics.yml5
-rw-r--r--changelogs/unreleased/sh-bump-sidekiq-version.yml4
-rw-r--r--changelogs/unreleased/sh-optimize-duplicate-routable-full-path.yml4
-rw-r--r--changelogs/unreleased/sh-structured-logging.yml4
-rw-r--r--changelogs/unreleased/skip-oauth-authorization-for-trusted-applications.yml4
-rw-r--r--changelogs/unreleased/spec_for_schema.yml4
-rw-r--r--changelogs/unreleased/submodules-no-dotgit.yml4
-rw-r--r--changelogs/unreleased/tc-api-root-merge-requests.yml4
-rw-r--r--changelogs/unreleased/tc-issue-api-assignee.yml4
-rw-r--r--changelogs/unreleased/tc-make-user-master-project-by-admin.yml4
-rw-r--r--changelogs/unreleased/tc-no-todo-service-select.yml4
-rw-r--r--changelogs/unreleased/toggle-new-project-import-description.yml4
-rw-r--r--changelogs/unreleased/uassign_on_member_removing.yml4
-rw-r--r--changelogs/unreleased/use-a-specialized-class-for-querying-events.yml4
-rw-r--r--changelogs/unreleased/use-hashie-forbidden_attributes.yml4
-rw-r--r--changelogs/unreleased/user-activity-scroll-bar.yml4
-rw-r--r--changelogs/unreleased/wiki_title.yml4
-rw-r--r--changelogs/unreleased/winh-derive-project-name.yml4
-rw-r--r--changelogs/unreleased/zj-delete-mm-team.yml4
-rw-r--r--changelogs/unreleased/zj-dockerfiles.yml4
-rw-r--r--changelogs/unreleased/zj-pipeline-badge-improvements.yml4
-rw-r--r--changelogs/unreleased/zj-project-templates.yml4
-rw-r--r--changelogs/unreleased/zj-upgrade-grape.yml5
-rw-r--r--config/README.md130
-rw-r--r--config/application.rb54
-rw-r--r--config/database.yml.mysql1
-rw-r--r--config/database.yml.postgresql1
-rw-r--r--config/dependency_decisions.yml6
-rw-r--r--config/environments/production.rb4
-rw-r--r--config/environments/test.rb5
-rw-r--r--config/gitlab.yml.example152
-rw-r--r--config/initializers/0_acts_as_taggable.rb9
-rw-r--r--config/initializers/1_settings.rb71
-rw-r--r--config/initializers/5_backend.rb10
-rw-r--r--config/initializers/6_validations.rb16
-rw-r--r--config/initializers/7_prometheus_metrics.rb18
-rw-r--r--config/initializers/7_redis.rb11
-rw-r--r--config/initializers/8_gitaly.rb6
-rw-r--r--config/initializers/8_metrics.rb23
-rw-r--r--config/initializers/active_record_array_type_casting.rb20
-rw-r--r--config/initializers/active_record_data_types.rb81
-rw-r--r--config/initializers/active_record_locking.rb74
-rw-r--r--config/initializers/active_record_mysql_timestamp.rb30
-rw-r--r--config/initializers/active_record_preloader.rb15
-rw-r--r--config/initializers/active_record_query_trace.rb5
-rw-r--r--config/initializers/active_record_table_definition.rb34
-rw-r--r--config/initializers/acts_as_taggable.rb5
-rw-r--r--config/initializers/ar_monkey_patch.rb74
-rw-r--r--config/initializers/ar_speed_up_migration_checking.rb2
-rw-r--r--config/initializers/bootstrap_form.rb7
-rw-r--r--config/initializers/carrierwave.rb2
-rw-r--r--config/initializers/devise.rb12
-rw-r--r--config/initializers/doorkeeper.rb10
-rw-r--r--config/initializers/doorkeeper_openid_connect.rb4
-rw-r--r--config/initializers/fast_gettext.rb6
-rw-r--r--config/initializers/flipper.rb8
-rw-r--r--config/initializers/forbid_sidekiq_in_transactions.rb49
-rw-r--r--config/initializers/gettext_rails_i18n_patch.rb41
-rw-r--r--config/initializers/grape_route_helpers_fix.rb35
-rw-r--r--config/initializers/hamlit.rb4
-rw-r--r--config/initializers/lograge.rb24
-rw-r--r--config/initializers/mysql_set_length_for_binary_indexes.rb21
-rw-r--r--config/initializers/omniauth.rb15
-rw-r--r--config/initializers/peek.rb28
-rw-r--r--config/initializers/postgresql_cte.rb132
-rw-r--r--config/initializers/relative_naming_ci_namespace.rb4
-rw-r--r--config/initializers/rspec_profiling.rb10
-rw-r--r--config/initializers/rugged_use_gitlab_git_attributes.rb25
-rw-r--r--config/initializers/server_uptime.rb1
-rw-r--r--config/initializers/session_store.rb14
-rw-r--r--config/initializers/sidekiq.rb12
-rw-r--r--config/initializers/static_files.rb16
-rw-r--r--config/initializers/trusted_proxies.rb2
-rw-r--r--config/karma.config.js34
-rw-r--r--config/locales/de.yml256
-rw-r--r--config/locales/en.yml247
-rw-r--r--config/locales/es.yml255
-rw-r--r--config/mail_room.yml4
-rw-r--r--config/prometheus/additional_metrics.yml133
-rw-r--r--config/redis.cache.yml.example38
-rw-r--r--config/redis.queues.yml.example38
-rw-r--r--config/redis.shared_state.yml.example38
-rw-r--r--config/routes.rb24
-rw-r--r--config/routes/admin.rb30
-rw-r--r--config/routes/api.rb2
-rw-r--r--config/routes/dashboard.rb8
-rw-r--r--config/routes/git_http.rb8
-rw-r--r--config/routes/group.rb34
-rw-r--r--config/routes/legacy_builds.rb22
-rw-r--r--config/routes/profile.rb6
-rw-r--r--config/routes/project.rb183
-rw-r--r--config/routes/repository.rb11
-rw-r--r--config/routes/snippets.rb14
-rw-r--r--config/routes/test.rb2
-rw-r--r--config/routes/uploads.rb20
-rw-r--r--config/routes/user.rb28
-rw-r--r--config/sidekiq_queues.yml10
-rw-r--r--config/unicorn.rb.example4
-rw-r--r--config/webpack.config.js169
-rw-r--r--db/fixtures/development/04_project.rb4
-rw-r--r--db/fixtures/development/09_issues.rb2
-rw-r--r--db/fixtures/development/10_merge_requests.rb2
-rw-r--r--db/fixtures/development/11_keys.rb11
-rw-r--r--db/fixtures/development/14_pipelines.rb6
-rw-r--r--db/fixtures/development/17_cycle_analytics.rb11
-rw-r--r--db/fixtures/development/19_environments.rb2
-rw-r--r--db/fixtures/development/21_conversational_development_index_metrics.rb40
-rw-r--r--db/fixtures/production/010_settings.rb24
-rw-r--r--db/migrate/20160314114439_add_requested_at_to_members.rb1
-rw-r--r--db/migrate/20160415062917_create_personal_access_tokens.rb2
-rw-r--r--db/migrate/20160419122101_add_only_allow_merge_if_build_succeeds_to_projects.rb1
-rw-r--r--db/migrate/20160608195742_add_repository_storage_to_projects.rb1
-rw-r--r--db/migrate/20160610204157_add_deployments.rb1
-rw-r--r--db/migrate/20160610204158_add_environments.rb1
-rw-r--r--db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb3
-rw-r--r--db/migrate/20160705054938_add_protected_branches_push_access.rb1
-rw-r--r--db/migrate/20160705054952_add_protected_branches_merge_access.rb1
-rw-r--r--db/migrate/20160713205315_add_domain_blacklist_to_application_settings.rb1
-rw-r--r--db/migrate/20160715154212_add_request_access_enabled_to_projects.rb1
-rw-r--r--db/migrate/20160715204316_add_request_access_enabled_to_groups.rb1
-rw-r--r--db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb3
-rw-r--r--db/migrate/20160724205507_add_resolved_to_notes.rb1
-rw-r--r--db/migrate/20160727163552_create_user_agent_details.rb1
-rw-r--r--db/migrate/20160727191041_create_boards.rb1
-rw-r--r--db/migrate/20160727193336_create_lists.rb1
-rw-r--r--db/migrate/20160804142904_add_ci_config_file_to_project.rb11
-rw-r--r--db/migrate/20160805041956_add_deleted_at_to_namespaces.rb1
-rw-r--r--db/migrate/20160810142633_remove_redundant_indexes.rb2
-rw-r--r--db/migrate/20160817133006_add_koding_to_application_settings.rb1
-rw-r--r--db/migrate/20160824124900_add_table_issue_metrics.rb2
-rw-r--r--db/migrate/20160825052008_add_table_merge_request_metrics.rb2
-rw-r--r--db/migrate/20160829114652_add_markdown_cache_columns.rb2
-rw-r--r--db/migrate/20160831214002_create_project_features.rb1
-rw-r--r--db/migrate/20160831223750_remove_features_enabled_from_projects.rb1
-rw-r--r--db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb3
-rw-r--r--db/migrate/20160913162434_remove_projects_pushes_since_gc.rb1
-rw-r--r--db/migrate/20160915042921_create_merge_requests_closing_issues.rb1
-rw-r--r--db/migrate/20160919144305_add_type_to_labels.rb3
-rw-r--r--db/migrate/20161014173530_create_label_priorities.rb1
-rw-r--r--db/migrate/20161017125927_add_unique_index_to_labels.rb2
-rw-r--r--db/migrate/20161018124658_make_project_owners_masters.rb3
-rw-r--r--db/migrate/20161020075830_default_request_access_projects.rb2
-rw-r--r--db/migrate/20161103191444_add_sidekiq_throttling_to_application_settings.rb1
-rw-r--r--db/migrate/20161113184239_create_user_chat_names_table.rb2
-rw-r--r--db/migrate/20161124111402_add_routes_table.rb1
-rw-r--r--db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb7
-rw-r--r--db/migrate/20161128161412_add_html_emails_enabled_to_application_settings.rb1
-rw-r--r--db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb1
-rw-r--r--db/migrate/20161207231620_fixup_environment_name_uniqueness.rb32
-rw-r--r--db/migrate/20161207231626_add_environment_slug.rb8
-rw-r--r--db/migrate/20161221152132_add_last_used_at_to_key.rb1
-rw-r--r--db/migrate/20161223034646_create_timelogs_ce.rb1
-rw-r--r--db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb3
-rw-r--r--db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb1
-rw-r--r--db/migrate/20170120131253_create_chat_teams.rb1
-rw-r--r--db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb1
-rw-r--r--db/migrate/20170124193205_add_two_factor_columns_to_users.rb1
-rw-r--r--db/migrate/20170130221926_create_uploads.rb1
-rw-r--r--db/migrate/20170222111732_create_gpg_keys.rb19
-rw-r--r--db/migrate/20170222143317_drop_ci_projects.rb1
-rw-r--r--db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb1
-rw-r--r--db/migrate/20170301205639_remove_unused_ci_tables_and_columns.rb1
-rw-r--r--db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb1
-rw-r--r--db/migrate/20170309173138_create_protected_tags.rb1
-rw-r--r--db/migrate/20170314082049_create_system_note_metadata.rb1
-rw-r--r--db/migrate/20170315174634_revert_add_notified_of_own_activity_to_users.rb1
-rw-r--r--db/migrate/20170315194013_add_closed_at_to_issues.rb1
-rw-r--r--db/migrate/20170316163845_move_uploads_to_system_dir.rb59
-rw-r--r--db/migrate/20170317203554_index_routes_path_for_like.rb5
-rw-r--r--db/migrate/20170320173259_migrate_assignees.rb42
-rw-r--r--db/migrate/20170322013926_create_container_repository.rb1
-rw-r--r--db/migrate/20170327091750_add_created_at_index_to_deployments.rb15
-rw-r--r--db/migrate/20170329095907_create_ci_trigger_schedules.rb1
-rw-r--r--db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb8
-rw-r--r--db/migrate/20170413035209_add_preferred_language_to_users.rb16
-rw-r--r--db/migrate/20170425112128_create_pipeline_schedules_table.rb30
-rw-r--r--db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb23
-rw-r--r--db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb9
-rw-r--r--db/migrate/20170427103502_create_web_hook_logs.rb22
-rw-r--r--db/migrate/20170427215854_create_redirect_routes.rb15
-rw-r--r--db/migrate/20170428064307_add_column_delete_error_to_projects.rb7
-rw-r--r--db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb13
-rw-r--r--db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb2
-rw-r--r--db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb21
-rw-r--r--db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb21
-rw-r--r--db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb8
-rw-r--r--db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb15
-rw-r--r--db/migrate/20170503004426_add_retried_to_ci_build.rb9
-rw-r--r--db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb14
-rw-r--r--db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb14
-rw-r--r--db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb15
-rw-r--r--db/migrate/20170503114228_add_description_to_snippets.rb12
-rw-r--r--db/migrate/20170503140201_reschedule_project_authorizations.rb44
-rw-r--r--db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb123
-rw-r--r--db/migrate/20170503184421_add_index_to_redirect_routes.rb21
-rw-r--r--db/migrate/20170503185032_index_redirect_routes_path_for_like.rb28
-rw-r--r--db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb33
-rw-r--r--db/migrate/20170504182103_add_index_project_group_links_group_id.rb19
-rw-r--r--db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb19
-rw-r--r--db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb15
-rw-r--r--db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb23
-rw-r--r--db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb7
-rw-r--r--db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb12
-rw-r--r--db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb24
-rw-r--r--db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb18
-rw-r--r--db/migrate/20170511083824_rename_services_build_events_to_job_events.rb18
-rw-r--r--db/migrate/20170516153305_migrate_assignee_to_separate_table.rb83
-rw-r--r--db/migrate/20170516183131_add_indices_to_issue_assignees.rb41
-rw-r--r--db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb16
-rw-r--r--db/migrate/20170521184006_add_change_position_to_notes.rb13
-rw-r--r--db/migrate/20170523091700_add_rss_token_to_users.rb19
-rw-r--r--db/migrate/20170523121229_create_conversational_development_index_metrics.rb40
-rw-r--r--db/migrate/20170524125940_add_source_to_ci_pipeline.rb9
-rw-r--r--db/migrate/20170524161101_add_protected_to_ci_variables.rb15
-rw-r--r--db/migrate/20170525130346_create_group_variables_table.rb23
-rw-r--r--db/migrate/20170525130758_add_foreign_key_to_group_variables.rb15
-rw-r--r--db/migrate/20170525132202_create_pipeline_stages.rb26
-rw-r--r--db/migrate/20170525174156_create_feature_tables.rb26
-rw-r--r--db/migrate/20170526185602_add_stage_id_to_ci_builds.rb13
-rw-r--r--db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb205
-rw-r--r--db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb15
-rw-r--r--db/migrate/20170602154736_add_help_page_hide_commercial_content_to_application_settings.rb10
-rw-r--r--db/migrate/20170602154813_add_help_page_support_url_to_application_settings.rb9
-rw-r--r--db/migrate/20170603200744_add_email_provider_to_users.rb9
-rw-r--r--db/migrate/20170606154216_add_notification_setting_columns.rb26
-rw-r--r--db/migrate/20170608152747_prepare_events_table_for_push_events_migration.rb51
-rw-r--r--db/migrate/20170608152748_create_push_event_payloads_tables.rb46
-rw-r--r--db/migrate/20170608171156_create_merge_request_diff_files.rb22
-rw-r--r--db/migrate/20170613154149_create_gpg_signatures.rb23
-rw-r--r--db/migrate/20170614115405_merge_request_diff_file_limits_to_mysql.rb1
-rw-r--r--db/migrate/20170616133147_create_merge_request_diff_commits.rb20
-rw-r--r--db/migrate/20170619144837_add_index_for_head_pipeline_merge_request.rb15
-rw-r--r--db/migrate/20170620064728_create_ci_pipeline_schedule_variables.rb25
-rw-r--r--db/migrate/20170620065449_add_foreign_key_to_ci_pipeline_schedule_variables.rb15
-rw-r--r--db/migrate/20170622130029_correct_protected_branches_foreign_keys.rb40
-rw-r--r--db/migrate/20170622132212_add_foreign_key_for_merge_request_diffs.rb30
-rw-r--r--db/migrate/20170622135451_rename_duplicated_variable_key.rb38
-rw-r--r--db/migrate/20170622135628_add_environment_scope_to_ci_variables.rb15
-rw-r--r--db/migrate/20170622135728_add_unique_constraint_to_ci_variables.rb38
-rw-r--r--db/migrate/20170622162730_add_ref_fetched_to_merge_request.rb9
-rw-r--r--db/migrate/20170623080805_remove_ci_variables_project_id_index.rb19
-rw-r--r--db/migrate/20170629171610_rename_application_settings_signin_enabled_to_password_authentication_enabled.rb15
-rw-r--r--db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb35
-rw-r--r--db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb9
-rw-r--r--db/migrate/20170707183807_add_group_id_to_milestones.rb20
-rw-r--r--db/migrate/20170707184243_add_group_milestone_id_indexes.rb21
-rw-r--r--db/migrate/20170707184244_remove_wrong_versions_from_schema_versions.rb10
-rw-r--r--db/migrate/20170710083355_clean_stage_id_reference_migration.rb18
-rw-r--r--db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb45
-rw-r--r--db/migrate/20170717074009_move_system_upload_folder.rb70
-rw-r--r--db/migrate/20170717200542_add_trusted_column_to_oauth_applications.rb15
-rw-r--r--db/migrate/20170720130522_create_ci_pipeline_variables.rb20
-rw-r--r--db/migrate/20170720130749_add_foreign_key_to_ci_pipeline_variables.rb15
-rw-r--r--db/migrate/20170724214302_add_lower_path_index_to_redirect_routes.rb34
-rw-r--r--db/migrate/20170725145659_add_binary_to_merge_request_diff_files.rb9
-rw-r--r--db/migrate/20170727123534_add_index_on_events_project_id_id.rb37
-rw-r--r--db/migrate/20170731175128_add_percentages_to_conv_dev.rb32
-rw-r--r--db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb7
-rw-r--r--db/migrate/20170803130232_reorganise_issues_indexes_for_faster_sorting.rb43
-rw-r--r--db/migrate/20170809133343_add_broadcast_messages_index.rb21
-rw-r--r--db/migrate/20170809134534_add_broadcast_message_not_null_constraints.rb29
-rw-r--r--db/migrate/20170809142252_cleanup_appearances_schema.rb33
-rw-r--r--db/migrate/20170809161910_add_project_export_enabled_to_application_settings.rb14
-rw-r--r--db/migrate/markdown_cache_limits_to_mysql.rb13
-rw-r--r--db/migrate/merge_request_diff_file_limits_to_mysql.rb12
-rw-r--r--db/post_migrate/20161109150329_fix_project_records_with_invalid_visibility.rb22
-rw-r--r--db/post_migrate/20161221153951_rename_reserved_project_names.rb12
-rw-r--r--db/post_migrate/20170104150317_requeue_pending_delete_projects.rb12
-rw-r--r--db/post_migrate/20170106142508_fill_authorized_projects.rb4
-rw-r--r--db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb4
-rw-r--r--db/post_migrate/20170309171644_reset_relative_position_for_issue.rb7
-rw-r--r--db/post_migrate/20170313133418_rename_more_reserved_project_names.rb12
-rw-r--r--db/post_migrate/20170317162059_update_upload_paths_to_system.rb57
-rw-r--r--db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb14
-rw-r--r--db/post_migrate/20170406111121_clean_upload_symlinks.rb52
-rw-r--r--db/post_migrate/20170406142253_migrate_user_project_view.rb2
-rw-r--r--db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb62
-rw-r--r--db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb48
-rw-r--r--db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb33
-rw-r--r--db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb18
-rw-r--r--db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb47
-rw-r--r--db/post_migrate/20170503004427_update_retried_for_ci_build.rb68
-rw-r--r--db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb15
-rw-r--r--db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb27
-rw-r--r--db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb35
-rw-r--r--db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb18
-rw-r--r--db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb18
-rw-r--r--db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb39
-rw-r--r--db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb37
-rw-r--r--db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb50
-rw-r--r--db/post_migrate/20170518231126_fix_wrongly_renamed_routes.rb104
-rw-r--r--db/post_migrate/20170523083112_migrate_old_artifacts.rb72
-rw-r--r--db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb113
-rw-r--r--db/post_migrate/20170526185842_migrate_pipeline_stages.rb22
-rw-r--r--db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb15
-rw-r--r--db/post_migrate/20170526185901_remove_stage_id_index_from_builds.rb18
-rw-r--r--db/post_migrate/20170526185921_migrate_build_stage_reference.rb19
-rw-r--r--db/post_migrate/20170526190000_migrate_build_stage_reference_again.rb27
-rw-r--r--db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb15
-rw-r--r--db/post_migrate/20170606202615_move_appearance_to_system_dir.rb57
-rw-r--r--db/post_migrate/20170607121233_convert_custom_notification_settings_to_columns.rb55
-rw-r--r--db/post_migrate/20170609183112_remove_position_from_issuables.rb8
-rw-r--r--db/post_migrate/20170612071012_move_personal_snippets_files.rb91
-rw-r--r--db/post_migrate/20170613111224_clean_appearance_symlinks.rb52
-rw-r--r--db/post_migrate/20170621102400_add_stage_id_index_to_builds.rb17
-rw-r--r--db/post_migrate/20170627101016_schedule_event_migrations.rb40
-rw-r--r--db/post_migrate/20170628080858_migrate_stage_id_reference_in_background.rb33
-rw-r--r--db/post_migrate/20170629180131_cleanup_application_settings_signin_enabled_rename.rb15
-rw-r--r--db/post_migrate/20170703130158_schedule_merge_request_diff_migrations.rb33
-rw-r--r--db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb40
-rw-r--r--db/post_migrate/20170717150329_enqueue_migrate_system_uploads_to_new_folder.rb20
-rw-r--r--db/post_migrate/20170719150301_merge_issuable_reopened_into_opened_state.rb32
-rw-r--r--db/post_migrate/20170728101014_remove_events_from_notification_settings.rb9
-rw-r--r--db/post_migrate/20170803090603_calculate_conv_dev_index_percentages.rb30
-rw-r--r--db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb11
-rw-r--r--db/post_migrate/20170807190736_move_personal_snippet_files_into_correct_folder.rb29
-rw-r--r--db/post_migrate/20170815060945_remove_duplicate_mr_events.rb26
-rw-r--r--db/schema.rb440
-rw-r--r--doc/README.md80
-rw-r--r--doc/administration/auth/authentiq.md9
-rw-r--r--doc/administration/auth/ldap.md76
-rw-r--r--doc/administration/container_registry.md61
-rw-r--r--doc/administration/environment_variables.md5
-rw-r--r--doc/administration/gitaly/index.md156
-rw-r--r--doc/administration/high_availability/README.md18
-rw-r--r--doc/administration/high_availability/database.md11
-rw-r--r--doc/administration/high_availability/gitlab.md16
-rw-r--r--doc/administration/high_availability/load_balancer.md2
-rw-r--r--doc/administration/high_availability/nfs.md37
-rw-r--r--doc/administration/high_availability/redis.md17
-rw-r--r--doc/administration/high_availability/redis_source.md6
-rw-r--r--doc/administration/img/failing_storage.pngbin0 -> 48281 bytes
-rw-r--r--doc/administration/integration/terminal.md4
-rw-r--r--doc/administration/job_artifacts.md50
-rw-r--r--doc/administration/logs.md27
-rw-r--r--doc/administration/monitoring/ip_whitelist.md39
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar.pngbin0 -> 170256 bytes
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar_configuration_settings.pngbin0 -> 20385 bytes
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar_line_profiling.pngbin0 -> 161313 bytes
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar_sql_queries.pngbin0 -> 165124 bytes
-rw-r--r--doc/administration/monitoring/performance/performance_bar.md35
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md69
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_monitor_exporter.md2
-rw-r--r--doc/administration/monitoring/prometheus/index.md13
-rw-r--r--doc/administration/operations/cleaning_up_redis_sessions.md6
-rw-r--r--doc/administration/raketasks/github_import.md4
-rw-r--r--doc/administration/reply_by_email_postfix_setup.md14
-rw-r--r--doc/administration/repository_storage_paths.md77
-rw-r--r--doc/api/README.md148
-rw-r--r--doc/api/access_requests.md2
-rw-r--r--doc/api/award_emoji.md2
-rw-r--r--doc/api/boards.md2
-rw-r--r--doc/api/branches.md8
-rw-r--r--doc/api/broadcast_messages.md2
-rw-r--r--doc/api/build_variables.md121
-rw-r--r--doc/api/ci/lint.md4
-rw-r--r--doc/api/ci/runners.md2
-rw-r--r--doc/api/commits.md5
-rw-r--r--doc/api/deploy_key_multiple_projects.md2
-rw-r--r--doc/api/deploy_keys.md2
-rw-r--r--doc/api/enviroments.md138
-rw-r--r--doc/api/environments.md138
-rw-r--r--doc/api/events.md364
-rw-r--r--doc/api/features.md88
-rw-r--r--doc/api/group_level_variables.md125
-rw-r--r--doc/api/group_milestones.md120
-rw-r--r--doc/api/groups.md2
-rw-r--r--doc/api/issues.md200
-rw-r--r--doc/api/jobs.md4
-rw-r--r--doc/api/keys.md2
-rw-r--r--doc/api/labels.md2
-rw-r--r--doc/api/members.md2
-rw-r--r--doc/api/merge_requests.md109
-rw-r--r--doc/api/milestones.md6
-rw-r--r--doc/api/namespaces.md16
-rw-r--r--doc/api/notes.md11
-rw-r--r--doc/api/notification_settings.md2
-rw-r--r--doc/api/oauth2.md118
-rw-r--r--doc/api/pipeline_schedules.md273
-rw-r--r--doc/api/pipeline_triggers.md2
-rw-r--r--doc/api/pipelines.md8
-rw-r--r--doc/api/project_level_variables.md125
-rw-r--r--doc/api/project_snippets.md35
-rw-r--r--doc/api/projects.md464
-rw-r--r--doc/api/protected_branches.md145
-rw-r--r--doc/api/repositories.md2
-rw-r--r--doc/api/repository_files.md18
-rw-r--r--doc/api/repository_storage_health.md74
-rw-r--r--doc/api/services.md4
-rw-r--r--doc/api/session.md15
-rw-r--r--doc/api/settings.md12
-rw-r--r--doc/api/sidekiq_metrics.md2
-rw-r--r--doc/api/snippets.md67
-rw-r--r--doc/api/system_hooks.md2
-rw-r--r--doc/api/tags.md43
-rw-r--r--doc/api/templates/gitignores.md2
-rw-r--r--doc/api/templates/gitlab_ci_ymls.md2
-rw-r--r--doc/api/templates/licenses.md2
-rw-r--r--doc/api/todos.md2
-rw-r--r--doc/api/users.md199
-rw-r--r--doc/api/v3_to_v4.md8
-rw-r--r--doc/articles/artifactory_and_gitlab/index.md278
-rw-r--r--doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.pngbin0 -> 27877 bytes
-rw-r--r--doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gifbin0 -> 222162 bytes
-rw-r--r--doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gifbin0 -> 110971 bytes
-rw-r--r--doc/articles/how_to_configure_ldap_gitlab_ce/index.md267
-rw-r--r--doc/articles/how_to_install_git/index.md66
-rw-r--r--doc/articles/index.md109
-rw-r--r--doc/articles/numerous_undo_possibilities_in_git/img/branching.pngbin0 -> 26245 bytes
-rw-r--r--doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.pngbin0 -> 43609 bytes
-rw-r--r--doc/articles/numerous_undo_possibilities_in_git/img/revert.pngbin0 -> 28112 bytes
-rw-r--r--doc/articles/numerous_undo_possibilities_in_git/index.md497
-rw-r--r--doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.pngbin0 -> 37386 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/add-to-project.pngbin0 -> 21672 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/create-project-ui.pngbin0 -> 22290 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/gitlab-logs.pngbin0 -> 70858 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/gitlab-overview.pngbin0 -> 106432 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/gitlab-running.pngbin0 -> 107993 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/gitlab-scale.pngbin0 -> 36628 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/gitlab-settings.pngbin0 -> 111366 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/no-resources.pngbin0 -> 34669 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/openshift-infra-project.pngbin0 -> 95725 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/pods-overview.pngbin0 -> 106861 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/rc-name.pngbin0 -> 51390 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/running-pods.pngbin0 -> 29818 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/storage-volumes.pngbin0 -> 49584 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/img/web-console.pngbin0 -> 34774 bytes
-rw-r--r--doc/articles/openshift_and_gitlab/index.md510
-rw-r--r--doc/ci/README.md12
-rw-r--r--doc/ci/api/README.md2
-rw-r--r--doc/ci/api/builds.md2
-rw-r--r--doc/ci/api/runners.md2
-rw-r--r--doc/ci/autodeploy/img/auto_monitoring.pngbin0 -> 89206 bytes
-rw-r--r--doc/ci/autodeploy/index.md25
-rw-r--r--doc/ci/docker/using_docker_build.md18
-rw-r--r--doc/ci/docker/using_docker_images.md364
-rw-r--r--doc/ci/environments.md40
-rw-r--r--doc/ci/examples/README.md1
-rw-r--r--doc/ci/examples/code_climate.md34
-rw-r--r--doc/ci/examples/deployment/README.md2
-rw-r--r--doc/ci/examples/deployment/composer-npm-deploy.md6
-rw-r--r--doc/ci/examples/test-and-deploy-python-application-to-heroku.md4
-rw-r--r--doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md4
-rw-r--r--doc/ci/examples/test-scala-application.md2
-rw-r--r--doc/ci/img/environments_monitoring.pngbin0 -> 243491 bytes
-rw-r--r--doc/ci/img/pipelines_grouped.pngbin0 -> 12937 bytes
-rw-r--r--doc/ci/img/pipelines_index.pngbin0 -> 36299 bytes
-rw-r--r--doc/ci/img/pipelines_mini_graph.pngbin0 -> 15404 bytes
-rw-r--r--doc/ci/img/pipelines_mini_graph_simple.pngbin0 -> 1637 bytes
-rw-r--r--doc/ci/img/pipelines_mini_graph_sorting.pngbin0 -> 10742 bytes
-rw-r--r--doc/ci/img/prometheus_environment_detail_with_metrics.png (renamed from doc/user/project/integrations/img/prometheus_environment_detail_with_metrics.png)bin120479 -> 120479 bytes
-rw-r--r--doc/ci/pipelines.md155
-rw-r--r--doc/ci/quick_start/README.md6
-rw-r--r--doc/ci/runners/README.md214
-rw-r--r--doc/ci/runners/img/shared_runners_admin.pngbin0 -> 29192 bytes
-rw-r--r--doc/ci/runners/project_specific.pngbin30196 -> 0 bytes
-rw-r--r--doc/ci/runners/shared_runner.pngbin17797 -> 0 bytes
-rw-r--r--doc/ci/ssh_keys/README.md6
-rw-r--r--doc/ci/triggers/README.md190
-rw-r--r--doc/ci/triggers/img/trigger_schedule_create.pngbin34264 -> 0 bytes
-rw-r--r--doc/ci/triggers/img/trigger_schedule_edit.pngbin18524 -> 0 bytes
-rw-r--r--doc/ci/triggers/img/trigger_schedule_updated_next_run_at.pngbin21896 -> 0 bytes
-rw-r--r--doc/ci/triggers/img/triggers_page.pngbin110560 -> 20857 bytes
-rw-r--r--doc/ci/variables/README.md94
-rw-r--r--doc/ci/yaml/README.md168
-rw-r--r--doc/customization/libravatar.md4
-rw-r--r--doc/development/README.md17
-rw-r--r--doc/development/architecture.md14
-rw-r--r--doc/development/background_migrations.md259
-rw-r--r--doc/development/build_test_package.md39
-rw-r--r--doc/development/changelog.md54
-rw-r--r--doc/development/code_review.md100
-rw-r--r--doc/development/database_merge_request_checklist.md15
-rw-r--r--doc/development/doc_styleguide.md17
-rw-r--r--doc/development/fe_guide/droplab/droplab.md2
-rw-r--r--doc/development/fe_guide/droplab/plugins/input_setter.md2
-rw-r--r--doc/development/fe_guide/img/testing_triangle.pngbin0 -> 11836 bytes
-rw-r--r--doc/development/fe_guide/index.md14
-rw-r--r--doc/development/fe_guide/performance.md13
-rw-r--r--doc/development/fe_guide/style_guide_js.md638
-rw-r--r--doc/development/fe_guide/testing.md129
-rw-r--r--doc/development/fe_guide/vue.md122
-rw-r--r--doc/development/feature_flags.md21
-rw-r--r--doc/development/foreign_keys.md63
-rw-r--r--doc/development/gotchas.md29
-rw-r--r--doc/development/hash_indexes.md20
-rw-r--r--doc/development/i18n_guide.md256
-rw-r--r--doc/development/img/trigger_ss1.pngbin0 -> 106261 bytes
-rw-r--r--doc/development/img/trigger_ss2.pngbin0 -> 106671 bytes
-rw-r--r--doc/development/iterating_tables_in_batches.md37
-rw-r--r--doc/development/limit_ee_conflicts.md4
-rw-r--r--doc/development/migration_style_guide.md73
-rw-r--r--doc/development/ordering_table_columns.md127
-rw-r--r--doc/development/policies.md116
-rw-r--r--doc/development/polymorphic_associations.md146
-rw-r--r--doc/development/rake_tasks.md89
-rw-r--r--doc/development/serializing_data.md85
-rw-r--r--doc/development/sha1_as_binary.md36
-rw-r--r--doc/development/sidekiq_style_guide.md7
-rw-r--r--doc/development/single_table_inheritance.md18
-rw-r--r--doc/development/sql.md26
-rw-r--r--doc/development/testing.md84
-rw-r--r--doc/development/ux_guide/basics.md2
-rw-r--r--doc/development/ux_guide/copy.md8
-rw-r--r--doc/development/verifying_database_capabilities.md26
-rw-r--r--doc/development/what_requires_downtime.md2
-rw-r--r--doc/development/writing_documentation.md37
-rw-r--r--doc/downgrade_ee_to_ce/README.md13
-rw-r--r--doc/gitlab-basics/README.md4
-rw-r--r--doc/gitlab-basics/create-group.md50
-rw-r--r--doc/gitlab-basics/create-issue.md30
-rw-r--r--doc/gitlab-basics/create-project.md46
-rw-r--r--doc/gitlab-basics/img/create_new_group_sidebar.pngbin2682 -> 0 bytes
-rw-r--r--doc/gitlab-basics/img/create_new_project_button.pngbin6978 -> 3702 bytes
-rw-r--r--doc/install/README.md4
-rw-r--r--doc/install/database_mysql.md17
-rw-r--r--doc/install/google_cloud_platform/index.md4
-rw-r--r--doc/install/installation.md87
-rw-r--r--doc/install/kubernetes/gitlab_chart.md476
-rw-r--r--doc/install/kubernetes/gitlab_omnibus.md171
-rw-r--r--doc/install/kubernetes/gitlab_runner_chart.md231
-rw-r--r--doc/install/kubernetes/index.md46
-rw-r--r--doc/install/requirements.md97
-rw-r--r--doc/integration/README.md20
-rw-r--r--doc/integration/chat_commands.md15
-rw-r--r--doc/integration/external-issue-tracker.md9
-rw-r--r--doc/integration/github.md51
-rw-r--r--doc/integration/google.md15
-rw-r--r--doc/integration/img/enable_trello_powerup.pngbin0 -> 17905 bytes
-rw-r--r--doc/integration/img/trello_card_with_gitlab_powerup.pngbin0 -> 18667 bytes
-rw-r--r--doc/integration/oauth_provider.md3
-rw-r--r--doc/integration/saml.md3
-rw-r--r--doc/integration/slash_commands.md33
-rw-r--r--doc/integration/trello_power_up.md42
-rw-r--r--doc/intro/README.md2
-rw-r--r--doc/profile/README.md6
-rw-r--r--doc/raketasks/backup_restore.md25
-rw-r--r--doc/raketasks/user_management.md79
-rw-r--r--doc/system_hooks/system_hooks.md64
-rw-r--r--doc/topics/authentication/index.md2
-rw-r--r--doc/topics/git/index.md2
-rw-r--r--doc/university/README.md2
-rw-r--r--doc/university/glossary/README.md3
-rw-r--r--doc/university/high-availability/aws/README.md22
-rw-r--r--doc/university/process/README.md2
-rw-r--r--doc/update/8.10-to-8.11.md2
-rw-r--r--doc/update/8.11-to-8.12.md2
-rw-r--r--doc/update/8.12-to-8.13.md2
-rw-r--r--doc/update/8.13-to-8.14.md2
-rw-r--r--doc/update/8.17-to-9.0.md15
-rw-r--r--doc/update/8.9-to-8.10.md2
-rw-r--r--doc/update/9.0-to-9.1.md15
-rw-r--r--doc/update/9.1-to-9.2.md326
-rw-r--r--doc/update/9.2-to-9.3.md344
-rw-r--r--doc/update/9.3-to-9.4.md357
-rw-r--r--doc/update/9.4-to-9.5.md356
-rw-r--r--doc/update/README.md82
-rw-r--r--doc/update/patch_versions.md9
-rw-r--r--doc/update/upgrader.md2
-rw-r--r--doc/user/admin_area/monitoring/convdev.md29
-rw-r--r--doc/user/admin_area/monitoring/health_check.md132
-rw-r--r--doc/user/admin_area/monitoring/img/convdev_index.pngbin0 -> 31012 bytes
-rw-r--r--doc/user/admin_area/settings/usage_statistics.md88
-rw-r--r--doc/user/discussions/index.md4
-rw-r--r--doc/user/group/img/access_requests_management.png (renamed from doc/workflow/groups/access_requests_management.png)bin11186 -> 11186 bytes
-rw-r--r--doc/user/group/img/add_new_members.pngbin0 -> 67235 bytes
-rw-r--r--doc/user/group/img/create_new_group_info.png (renamed from doc/gitlab-basics/img/create_new_group_info.png)bin105173 -> 105173 bytes
-rw-r--r--doc/user/group/img/create_new_project_from_group.png (renamed from doc/gitlab-basics/img/create_new_project_from_group.png)bin3194 -> 3194 bytes
-rw-r--r--doc/user/group/img/group_settings.pngbin0 -> 28821 bytes
-rw-r--r--doc/user/group/img/groups.pngbin0 -> 202498 bytes
-rw-r--r--doc/user/group/img/membership_lock.pngbin0 -> 17333 bytes
-rw-r--r--doc/user/group/img/new_group_form.png (renamed from doc/workflow/groups/new_group_form.png)bin114515 -> 114515 bytes
-rw-r--r--doc/user/group/img/new_group_from_groups.pngbin0 -> 97271 bytes
-rw-r--r--doc/user/group/img/new_group_from_other_pages.pngbin0 -> 70899 bytes
-rw-r--r--doc/user/group/img/request_access_button.png (renamed from doc/workflow/groups/request_access_button.png)bin35917 -> 35917 bytes
-rw-r--r--doc/user/group/img/select_group_dropdown.png (renamed from doc/gitlab-basics/img/select_group_dropdown.png)bin3489 -> 3489 bytes
-rw-r--r--doc/user/group/img/share_with_group_lock.pngbin0 -> 18257 bytes
-rw-r--r--doc/user/group/img/transfer_project_to_other_group.pngbin0 -> 66460 bytes
-rw-r--r--doc/user/group/img/withdraw_access_request_button.png (renamed from doc/workflow/groups/withdraw_access_request_button.png)bin36413 -> 36413 bytes
-rw-r--r--doc/user/group/index.md221
-rw-r--r--doc/user/group/subgroups/index.md20
-rw-r--r--doc/user/img/gitlab_snippet.pngbin0 -> 34355 bytes
-rw-r--r--doc/user/index.md152
-rw-r--r--doc/user/markdown.md6
-rw-r--r--doc/user/permissions.md118
-rw-r--r--doc/user/profile/account/delete_account.md24
-rw-r--r--doc/user/profile/account/index.md5
-rw-r--r--doc/user/profile/account/two_factor_authentication.md55
-rw-r--r--doc/user/profile/img/personal_access_tokens.pngbin0 -> 18555 bytes
-rw-r--r--doc/user/profile/index.md47
-rw-r--r--doc/user/profile/personal_access_tokens.md60
-rw-r--r--doc/user/profile/preferences.md8
-rw-r--r--doc/user/project/container_registry.md28
-rw-r--r--doc/user/project/img/container_registry_panel.pngbin32310 -> 0 bytes
-rw-r--r--doc/user/project/img/issue_board.pngbin76461 -> 51439 bytes
-rw-r--r--doc/user/project/img/issue_board_add_list.pngbin23632 -> 17312 bytes
-rw-r--r--doc/user/project/img/issue_board_move_issue_card_list.pngbin0 -> 74826 bytes
-rw-r--r--doc/user/project/img/issue_board_welcome_message.pngbin120751 -> 26533 bytes
-rw-r--r--doc/user/project/img/issue_boards_add_issues_modal.pngbin177057 -> 29176 bytes
-rw-r--r--doc/user/project/img/project_settings_list.pngbin5919 -> 0 bytes
-rw-r--r--doc/user/project/img/protected_branches_delete.pngbin0 -> 21510 bytes
-rw-r--r--doc/user/project/import/bitbucket.md62
-rw-r--r--doc/user/project/import/fogbugz.md28
-rw-r--r--doc/user/project/import/gitea.md77
-rw-r--r--doc/user/project/import/github.md122
-rw-r--r--doc/user/project/import/gitlab_com.md20
-rw-r--r--doc/user/project/import/img/bitbucket_import_grant_access.png (renamed from doc/workflow/importing/img/bitbucket_import_grant_access.png)bin7248 -> 7248 bytes
-rw-r--r--doc/user/project/import/img/bitbucket_import_new_project.png (renamed from doc/workflow/importing/img/bitbucket_import_new_project.png)bin1316 -> 1316 bytes
-rw-r--r--doc/user/project/import/img/bitbucket_import_select_project.png (renamed from doc/workflow/importing/img/bitbucket_import_select_project.png)bin8688 -> 8688 bytes
-rw-r--r--doc/user/project/import/img/fogbugz_import_finished.png (renamed from doc/workflow/importing/fogbugz_importer/fogbugz_import_finished.png)bin17744 -> 17744 bytes
-rw-r--r--doc/user/project/import/img/fogbugz_import_login.png (renamed from doc/workflow/importing/fogbugz_importer/fogbugz_import_login.png)bin13751 -> 13751 bytes
-rw-r--r--doc/user/project/import/img/fogbugz_import_select_fogbogz.png (renamed from doc/workflow/importing/fogbugz_importer/fogbugz_import_select_fogbogz.png)bin12289 -> 12289 bytes
-rw-r--r--doc/user/project/import/img/fogbugz_import_select_project.png (renamed from doc/workflow/importing/fogbugz_importer/fogbugz_import_select_project.png)bin20905 -> 20905 bytes
-rw-r--r--doc/user/project/import/img/fogbugz_import_user_map.png (renamed from doc/workflow/importing/fogbugz_importer/fogbugz_import_user_map.png)bin51238 -> 51238 bytes
-rw-r--r--doc/user/project/import/img/gitlab_importer.png (renamed from doc/workflow/importing/gitlab_importer/importer.png)bin12864 -> 12864 bytes
-rw-r--r--doc/user/project/import/img/gitlab_new_project_page.png (renamed from doc/workflow/importing/gitlab_importer/new_project_page.png)bin21251 -> 21251 bytes
-rw-r--r--doc/user/project/import/img/import_projects_from_gitea_new_import.png (renamed from doc/workflow/importing/img/import_projects_from_gitea_new_import.png)bin15561 -> 15561 bytes
-rw-r--r--doc/user/project/import/img/import_projects_from_github_importer.png (renamed from doc/workflow/importing/img/import_projects_from_github_importer.png)bin17953 -> 17953 bytes
-rw-r--r--doc/user/project/import/img/import_projects_from_github_select_auth_method.png (renamed from doc/workflow/importing/img/import_projects_from_github_select_auth_method.png)bin17612 -> 17612 bytes
-rw-r--r--doc/user/project/import/img/import_projects_from_new_project_page.png (renamed from doc/workflow/importing/img/import_projects_from_new_project_page.png)bin36821 -> 36821 bytes
-rw-r--r--doc/user/project/import/index.md20
-rw-r--r--doc/user/project/import/svn.md183
-rw-r--r--doc/user/project/index.md111
-rw-r--r--doc/user/project/integrations/bugzilla.md13
-rw-r--r--doc/user/project/integrations/img/accessing_integrations.pngbin8941 -> 0 bytes
-rw-r--r--doc/user/project/integrations/img/jira_service_page.pngbin12228 -> 193364 bytes
-rw-r--r--doc/user/project/integrations/img/merge_request_performance.pngbin0 -> 60194 bytes
-rwxr-xr-xdoc/user/project/integrations/img/webhook_logs.pngbin0 -> 24066 bytes
-rw-r--r--doc/user/project/integrations/img/webhook_testing.pngbin0 -> 191267 bytes
-rw-r--r--doc/user/project/integrations/index.md8
-rw-r--r--doc/user/project/integrations/jira.md36
-rw-r--r--doc/user/project/integrations/kubernetes.md5
-rw-r--r--doc/user/project/integrations/project_services.md19
-rw-r--r--doc/user/project/integrations/prometheus.md88
-rw-r--r--doc/user/project/integrations/prometheus_library/cloudwatch.md25
-rw-r--r--doc/user/project/integrations/prometheus_library/haproxy.md20
-rw-r--r--doc/user/project/integrations/prometheus_library/kubernetes.md26
-rw-r--r--doc/user/project/integrations/prometheus_library/metrics.md24
-rw-r--r--doc/user/project/integrations/prometheus_library/nginx.md23
-rw-r--r--doc/user/project/integrations/prometheus_library/nginx_ingress.md25
-rw-r--r--doc/user/project/integrations/redmine.md13
-rw-r--r--doc/user/project/integrations/samples/cloudwatch.yml26
-rw-r--r--doc/user/project/integrations/samples/prometheus.yml38
-rw-r--r--doc/user/project/integrations/slack.md59
-rw-r--r--doc/user/project/integrations/slack_slash_commands.md25
-rw-r--r--doc/user/project/integrations/webhooks.md48
-rw-r--r--doc/user/project/issue_board.md75
-rw-r--r--doc/user/project/issues/closing_issues.md59
-rw-r--r--doc/user/project/issues/create_new_issue.md38
-rw-r--r--doc/user/project/issues/crosslinking_issues.md63
-rw-r--r--doc/user/project/issues/due_dates.md6
-rwxr-xr-xdoc/user/project/issues/img/button_close_issue.pngbin0 -> 15508 bytes
-rw-r--r--doc/user/project/issues/img/close_issue_from_board.gifbin0 -> 109533 bytes
-rwxr-xr-xdoc/user/project/issues/img/closing_and_related_issues.pngbin0 -> 6395 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_create.pngbin9659 -> 8185 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_index_page.pngbin9949 -> 8349 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_issue_page.pngbin16089 -> 14230 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_search_guest.pngbin10014 -> 8593 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_search_master.pngbin15332 -> 13228 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/confidential_issues_system_notes.pngbin3025 -> 2330 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/due_dates_create.pngbin7705 -> 6992 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/due_dates_edit_sidebar.pngbin2424 -> 1700 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/due_dates_issues_index_page.pngbin21402 -> 19302 bytes
-rwxr-xr-x[-rw-r--r--]doc/user/project/issues/img/due_dates_todos.pngbin5644 -> 4799 bytes
-rwxr-xr-xdoc/user/project/issues/img/issue_board.pngbin0 -> 58645 bytes
-rwxr-xr-xdoc/user/project/issues/img/issue_template.pngbin0 -> 28061 bytes
-rwxr-xr-xdoc/user/project/issues/img/issue_tracker.pngbin0 -> 37037 bytes
-rw-r--r--doc/user/project/issues/img/issues_main_view.pngbin0 -> 73751 bytes
-rw-r--r--doc/user/project/issues/img/issues_main_view_numbered.jpgbin0 -> 103249 bytes
-rwxr-xr-xdoc/user/project/issues/img/mention_in_issue.pngbin0 -> 3738 bytes
-rwxr-xr-xdoc/user/project/issues/img/mention_in_merge_request.pngbin0 -> 3944 bytes
-rwxr-xr-xdoc/user/project/issues/img/merge_request_closes_issue.pngbin0 -> 19423 bytes
-rwxr-xr-xdoc/user/project/issues/img/new_issue.pngbin0 -> 31727 bytes
-rwxr-xr-xdoc/user/project/issues/img/new_issue_from_issue_board.pngbin0 -> 137175 bytes
-rwxr-xr-xdoc/user/project/issues/img/new_issue_from_open_issue.pngbin0 -> 20628 bytes
-rwxr-xr-xdoc/user/project/issues/img/new_issue_from_projects_dashboard.pngbin0 -> 29865 bytes
-rwxr-xr-xdoc/user/project/issues/img/new_issue_from_tracker_list.pngbin0 -> 24345 bytes
-rw-r--r--doc/user/project/issues/index.md125
-rw-r--r--doc/user/project/issues/issues_functionalities.md172
-rw-r--r--doc/user/project/koding.md2
-rw-r--r--doc/user/project/members/img/access_requests_management.png (renamed from doc/workflow/add-user/img/access_requests_management.png)bin11018 -> 11018 bytes
-rw-r--r--doc/user/project/members/img/add_new_user_to_project_settings.png (renamed from doc/workflow/add-user/img/add_new_user_to_project_settings.png)bin11046 -> 11046 bytes
-rw-r--r--doc/user/project/members/img/add_user_email_accept.png (renamed from doc/workflow/add-user/img/add_user_email_accept.png)bin16890 -> 16890 bytes
-rw-r--r--doc/user/project/members/img/add_user_email_ready.png (renamed from doc/workflow/add-user/img/add_user_email_ready.png)bin28171 -> 28171 bytes
-rw-r--r--doc/user/project/members/img/add_user_email_search.png (renamed from doc/workflow/add-user/img/add_user_email_search.png)bin29628 -> 29628 bytes
-rw-r--r--doc/user/project/members/img/add_user_give_permissions.png (renamed from doc/workflow/add-user/img/add_user_give_permissions.png)bin36619 -> 36619 bytes
-rw-r--r--doc/user/project/members/img/add_user_import_members_from_another_project.png (renamed from doc/workflow/add-user/img/add_user_import_members_from_another_project.png)bin25343 -> 25343 bytes
-rw-r--r--doc/user/project/members/img/add_user_imported_members.png (renamed from doc/workflow/add-user/img/add_user_imported_members.png)bin25398 -> 25398 bytes
-rw-r--r--doc/user/project/members/img/add_user_list_members.png (renamed from doc/workflow/add-user/img/add_user_list_members.png)bin16916 -> 16916 bytes
-rw-r--r--doc/user/project/members/img/add_user_members_menu.png (renamed from doc/workflow/add-user/img/add_user_members_menu.png)bin28994 -> 28994 bytes
-rw-r--r--doc/user/project/members/img/add_user_search_people.png (renamed from doc/workflow/add-user/img/add_user_search_people.png)bin25368 -> 25368 bytes
-rw-r--r--doc/user/project/members/img/max_access_level.png (renamed from doc/workflow/groups/max_access_level.png)bin34718 -> 34718 bytes
-rw-r--r--doc/user/project/members/img/other_group_sees_shared_project.png (renamed from doc/workflow/groups/other_group_sees_shared_project.png)bin30182 -> 30182 bytes
-rw-r--r--doc/user/project/members/img/request_access_button.png (renamed from doc/workflow/add-user/img/request_access_button.png)bin25281 -> 25281 bytes
-rw-r--r--doc/user/project/members/img/share_project_with_groups.png (renamed from doc/workflow/groups/share_project_with_groups.png)bin30307 -> 30307 bytes
-rw-r--r--doc/user/project/members/img/withdraw_access_request_button.png (renamed from doc/workflow/add-user/img/withdraw_access_request_button.png)bin26135 -> 26135 bytes
-rw-r--r--doc/user/project/members/index.md116
-rw-r--r--doc/user/project/members/share_project_with_groups.md41
-rw-r--r--doc/user/project/merge_requests/index.md64
-rw-r--r--doc/user/project/milestones/img/progress.pngbin0 -> 23491 bytes
-rw-r--r--doc/user/project/milestones/index.md28
-rw-r--r--doc/user/project/new_ci_build_permissions_model.md12
-rw-r--r--doc/user/project/pages/getting_started_part_four.md5
-rw-r--r--doc/user/project/pages/getting_started_part_one.md26
-rw-r--r--doc/user/project/pages/getting_started_part_three.md5
-rw-r--r--doc/user/project/pages/getting_started_part_two.md7
-rw-r--r--doc/user/project/pages/introduction.md3
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedule_variables.pngbin0 -> 13478 bytes
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedules_list.pngbin0 -> 14665 bytes
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedules_new_form.pngbin0 -> 72501 bytes
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedules_ownership.pngbin0 -> 12043 bytes
-rw-r--r--doc/user/project/pipelines/job_artifacts.md30
-rw-r--r--doc/user/project/pipelines/schedules.md93
-rw-r--r--doc/user/project/pipelines/settings.md32
-rw-r--r--doc/user/project/protected_branches.md34
-rw-r--r--doc/user/project/quick_actions.md40
-rw-r--r--doc/user/project/repository/branches/img/delete_merged_branches.pngbin0 -> 42891 bytes
-rw-r--r--doc/user/project/repository/branches/index.md17
-rwxr-xr-xdoc/user/project/repository/img/compare_branches.pngbin0 -> 35999 bytes
-rwxr-xr-xdoc/user/project/repository/img/contributors_graph.pngbin0 -> 31670 bytes
-rwxr-xr-xdoc/user/project/repository/img/repo_graph.pngbin0 -> 52317 bytes
-rw-r--r--doc/user/project/repository/index.md156
-rw-r--r--doc/user/project/repository/reducing_the_repo_size_using_git.md81
-rw-r--r--doc/user/project/settings/import_export.md19
-rw-r--r--doc/user/project/slash_commands.md39
-rw-r--r--doc/user/project/wiki/img/wiki_create_home_page.pngbin0 -> 12422 bytes
-rw-r--r--doc/user/project/wiki/img/wiki_create_new_page.pngbin0 -> 38105 bytes
-rw-r--r--doc/user/project/wiki/img/wiki_create_new_page_modal.pngbin0 -> 13189 bytes
-rw-r--r--doc/user/project/wiki/img/wiki_page_history.pngbin0 -> 26478 bytes
-rw-r--r--doc/user/project/wiki/img/wiki_sidebar.pngbin0 -> 7440 bytes
-rw-r--r--doc/user/project/wiki/index.md97
-rw-r--r--doc/user/reserved_names.md109
-rw-r--r--doc/user/search/img/filter_issues_project.gifbin1430218 -> 0 bytes
-rw-r--r--doc/user/search/img/group_issues_filter.pngbin0 -> 45288 bytes
-rw-r--r--doc/user/search/img/issue_search_filter.pngbin0 -> 69559 bytes
-rw-r--r--doc/user/search/index.md16
-rw-r--r--doc/user/snippets.md12
-rw-r--r--doc/workflow/README.md11
-rw-r--r--doc/workflow/add-user/add-user.md115
-rw-r--r--doc/workflow/gitlab_flow.md13
-rw-r--r--doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.pngbin0 -> 32699 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.pngbin0 -> 24514 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.pngbin0 -> 10331 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.pngbin0 -> 112812 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.pngbin0 -> 9542 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.pngbin0 -> 14029 bytes
-rw-r--r--doc/workflow/gpg_signed_commits/index.md84
-rw-r--r--doc/workflow/groups.md96
-rw-r--r--doc/workflow/groups/add_member_to_group.pngbin35724 -> 0 bytes
-rw-r--r--doc/workflow/groups/group_dashboard.pngbin28155 -> 0 bytes
-rw-r--r--doc/workflow/groups/group_with_two_projects.pngbin34462 -> 0 bytes
-rw-r--r--doc/workflow/groups/new_group_button.pngbin49708 -> 0 bytes
-rw-r--r--doc/workflow/groups/override_access_level.pngbin40993 -> 0 bytes
-rw-r--r--doc/workflow/groups/project_members_via_group.pngbin39532 -> 0 bytes
-rw-r--r--doc/workflow/groups/transfer_project.pngbin43502 -> 0 bytes
-rw-r--r--doc/workflow/img/notification_global_settings.png (renamed from doc/workflow/notifications/settings.png)bin37542 -> 37542 bytes
-rw-r--r--doc/workflow/img/notification_group_settings.pngbin0 -> 171784 bytes
-rw-r--r--doc/workflow/img/notification_project_settings.pngbin0 -> 167548 bytes
-rw-r--r--doc/workflow/img/todos_icon.pngbin1341 -> 4910 bytes
-rw-r--r--doc/workflow/img/todos_index.pngbin63372 -> 98239 bytes
-rw-r--r--doc/workflow/importing/README.md18
-rw-r--r--doc/workflow/importing/import_projects_from_bitbucket.md63
-rw-r--r--doc/workflow/importing/import_projects_from_fogbugz.md30
-rw-r--r--doc/workflow/importing/import_projects_from_gitea.md78
-rw-r--r--doc/workflow/importing/import_projects_from_github.md123
-rw-r--r--doc/workflow/importing/import_projects_from_gitlab_com.md22
-rw-r--r--doc/workflow/importing/migrating_from_svn.md184
-rw-r--r--doc/workflow/lfs/lfs_administration.md2
-rw-r--r--doc/workflow/notifications.md10
-rw-r--r--doc/workflow/project_features.md44
-rw-r--r--doc/workflow/share_projects_with_other_groups.md33
-rw-r--r--doc/workflow/share_with_group.md14
-rw-r--r--doc/workflow/shortcuts.md3
-rw-r--r--doc/workflow/time_tracking.md8
-rw-r--r--features/dashboard/dashboard.feature70
-rw-r--r--features/dashboard/event_filters.feature58
-rw-r--r--features/dashboard/merge_requests.feature21
-rw-r--r--features/dashboard/new_project.feature30
-rw-r--r--features/dashboard/starred_projects.feature12
-rw-r--r--features/dashboard/todos.feature28
-rw-r--r--features/group/members.feature59
-rw-r--r--features/group/milestones.feature5
-rw-r--r--features/profile/active_tab.feature6
-rw-r--r--features/profile/notifications.feature15
-rw-r--r--features/profile/profile.feature2
-rw-r--r--features/project/active_tab.feature18
-rw-r--r--features/project/badges/build.feature27
-rw-r--r--features/project/builds/artifacts.feature5
-rw-r--r--features/project/builds/permissions.feature1
-rw-r--r--features/project/builds/summary.feature3
-rw-r--r--features/project/commits/revert.feature3
-rw-r--r--features/project/create.feature14
-rw-r--r--features/project/deploy_keys.feature6
-rw-r--r--features/project/hooks.feature37
-rw-r--r--features/project/issues/issues.feature3
-rw-r--r--features/project/merge_requests.feature9
-rw-r--r--features/project/merge_requests/accept.feature4
-rw-r--r--features/project/milestone.feature8
-rw-r--r--features/project/pages.feature11
-rw-r--r--features/project/project.feature1
-rw-r--r--features/project/service.feature26
-rw-r--r--features/project/snippets.feature1
-rw-r--r--features/project/source/browse_files.feature332
-rw-r--r--features/project/source/markdown_render.feature15
-rw-r--r--features/search.feature4
-rw-r--r--features/snippets/snippets.feature39
-rw-r--r--features/steps/dashboard/dashboard.rb83
-rw-r--r--features/steps/dashboard/event_filters.rb92
-rw-r--r--features/steps/dashboard/merge_requests.rb121
-rw-r--r--features/steps/dashboard/new_project.rb53
-rw-r--r--features/steps/dashboard/starred_projects.rb15
-rw-r--r--features/steps/dashboard/todos.rb191
-rw-r--r--features/steps/explore/projects.rb12
-rw-r--r--features/steps/group/members.rb4
-rw-r--r--features/steps/group/milestones.rb20
-rw-r--r--features/steps/groups.rb12
-rw-r--r--features/steps/profile/active_tab.rb4
-rw-r--r--features/steps/profile/emails.rb2
-rw-r--r--features/steps/profile/profile.rb2
-rw-r--r--features/steps/project/active_tab.rb6
-rw-r--r--features/steps/project/archived.rb2
-rw-r--r--features/steps/project/badges/build.rb32
-rw-r--r--features/steps/project/builds/artifacts.rb27
-rw-r--r--features/steps/project/builds/summary.rb2
-rw-r--r--features/steps/project/commits/commits.rb10
-rw-r--r--features/steps/project/commits/revert.rb3
-rw-r--r--features/steps/project/commits/user_lookup.rb4
-rw-r--r--features/steps/project/create.rb6
-rw-r--r--features/steps/project/deploy_keys.rb22
-rw-r--r--features/steps/project/fork.rb8
-rw-r--r--features/steps/project/forked_merge_requests.rb57
-rw-r--r--features/steps/project/graph.rb10
-rw-r--r--features/steps/project/hooks.rb75
-rw-r--r--features/steps/project/issues/award_emoji.rb6
-rw-r--r--features/steps/project/issues/issues.rb14
-rw-r--r--features/steps/project/issues/labels.rb2
-rw-r--r--features/steps/project/merge_requests.rb71
-rw-r--r--features/steps/project/merge_requests/acceptance.rb23
-rw-r--r--features/steps/project/merge_requests/revert.rb5
-rw-r--r--features/steps/project/network_graph.rb4
-rw-r--r--features/steps/project/pages.rb22
-rw-r--r--features/steps/project/project.rb20
-rw-r--r--features/steps/project/project_group_links.rb9
-rw-r--r--features/steps/project/project_milestone.rb3
-rw-r--r--features/steps/project/redirects.rb14
-rw-r--r--features/steps/project/services.rb77
-rw-r--r--features/steps/project/snippets.rb11
-rw-r--r--features/steps/project/source/browse_files.rb42
-rw-r--r--features/steps/project/source/markdown_render.rb85
-rw-r--r--features/steps/project/team_management.rb4
-rw-r--r--features/steps/project/wiki.rb14
-rw-r--r--features/steps/search.rb8
-rw-r--r--features/steps/shared/active_tab.rb4
-rw-r--r--features/steps/shared/builds.rb6
-rw-r--r--features/steps/shared/diff_note.rb6
-rw-r--r--features/steps/shared/issuable.rb8
-rw-r--r--features/steps/shared/markdown.rb8
-rw-r--r--features/steps/shared/note.rb23
-rw-r--r--features/steps/shared/paths.rb120
-rw-r--r--features/steps/shared/project.rb49
-rw-r--r--features/steps/shared/project_tab.rb4
-rw-r--r--features/steps/shared/snippet.rb63
-rw-r--r--features/steps/snippets/snippets.rb84
-rw-r--r--features/steps/user.rb2
-rw-r--r--features/support/capybara.rb4
-rw-r--r--features/support/env.rb10
-rw-r--r--fixtures/emojis/digests.json1791
-rw-r--r--lib/after_commit_queue.rb30
-rw-r--r--lib/api/access_requests.rb5
-rw-r--r--lib/api/api.rb22
-rw-r--r--lib/api/api_guard.rb35
-rw-r--r--lib/api/award_emoji.rb1
-rw-r--r--lib/api/branches.rb32
-rw-r--r--lib/api/broadcast_messages.rb1
-rw-r--r--lib/api/circuit_breakers.rb50
-rw-r--r--lib/api/commit_statuses.rb12
-rw-r--r--lib/api/commits.rb44
-rw-r--r--lib/api/deploy_keys.rb22
-rw-r--r--lib/api/entities.rb325
-rw-r--r--lib/api/environments.rb1
-rw-r--r--lib/api/events.rb86
-rw-r--r--lib/api/features.rb70
-rw-r--r--lib/api/files.rb23
-rw-r--r--lib/api/group_milestones.rb85
-rw-r--r--lib/api/group_variables.rb96
-rw-r--r--lib/api/groups.rb20
-rw-r--r--lib/api/helpers.rb102
-rw-r--r--lib/api/helpers/common_helpers.rb13
-rw-r--r--lib/api/helpers/internal_helpers.rb92
-rw-r--r--lib/api/helpers/members_helpers.rb4
-rw-r--r--lib/api/helpers/related_resources_helpers.rb28
-rw-r--r--lib/api/helpers/runner.rb3
-rw-r--r--lib/api/internal.rb84
-rw-r--r--lib/api/issues.rb38
-rw-r--r--lib/api/jobs.rb23
-rw-r--r--lib/api/labels.rb1
-rw-r--r--lib/api/members.rb1
-rw-r--r--lib/api/merge_requests.rb120
-rw-r--r--lib/api/milestone_responses.rb98
-rw-r--r--lib/api/milestones.rb154
-rw-r--r--lib/api/namespaces.rb2
-rw-r--r--lib/api/notes.rb7
-rw-r--r--lib/api/notification_settings.rb5
-rw-r--r--lib/api/pipeline_schedules.rb134
-rw-r--r--lib/api/pipelines.rb18
-rw-r--r--lib/api/project_hooks.rb3
-rw-r--r--lib/api/project_milestones.rb91
-rw-r--r--lib/api/project_snippets.rb22
-rw-r--r--lib/api/projects.rb158
-rw-r--r--lib/api/protected_branches.rb85
-rw-r--r--lib/api/repositories.rb2
-rw-r--r--lib/api/runner.rb18
-rw-r--r--lib/api/runners.rb4
-rw-r--r--lib/api/scope.rb23
-rw-r--r--lib/api/services.rb18
-rw-r--r--lib/api/settings.rb70
-rw-r--r--lib/api/snippets.rb23
-rw-r--r--lib/api/subscriptions.rb2
-rw-r--r--lib/api/system_hooks.rb1
-rw-r--r--lib/api/tags.rb30
-rw-r--r--lib/api/templates.rb6
-rw-r--r--lib/api/time_tracking_endpoints.rb2
-rw-r--r--lib/api/todos.rb6
-rw-r--r--lib/api/triggers.rb25
-rw-r--r--lib/api/users.rb148
-rw-r--r--lib/api/v3/branches.rb8
-rw-r--r--lib/api/v3/builds.rb22
-rw-r--r--lib/api/v3/commits.rb2
-rw-r--r--lib/api/v3/deploy_keys.rb1
-rw-r--r--lib/api/v3/entities.rb80
-rw-r--r--lib/api/v3/files.rb2
-rw-r--r--lib/api/v3/groups.rb10
-rw-r--r--lib/api/v3/helpers.rb30
-rw-r--r--lib/api/v3/issues.rb31
-rw-r--r--lib/api/v3/merge_requests.rb4
-rw-r--r--lib/api/v3/milestones.rb4
-rw-r--r--lib/api/v3/notes.rb10
-rw-r--r--lib/api/v3/pipelines.rb2
-rw-r--r--lib/api/v3/project_hooks.rb8
-rw-r--r--lib/api/v3/project_snippets.rb3
-rw-r--r--lib/api/v3/projects.rb12
-rw-r--r--lib/api/v3/repositories.rb2
-rw-r--r--lib/api/v3/services.rb6
-rw-r--r--lib/api/v3/settings.rb14
-rw-r--r--lib/api/v3/snippets.rb4
-rw-r--r--lib/api/v3/subscriptions.rb2
-rw-r--r--lib/api/v3/tags.rb4
-rw-r--r--lib/api/v3/templates.rb6
-rw-r--r--lib/api/v3/time_tracking_endpoints.rb2
-rw-r--r--lib/api/v3/todos.rb6
-rw-r--r--lib/api/v3/triggers.rb11
-rw-r--r--lib/api/v3/users.rb26
-rw-r--r--lib/api/variables.rb11
-rw-r--r--lib/backup/artifacts.rb2
-rw-r--r--lib/backup/manager.rb52
-rw-r--r--lib/backup/repository.rb82
-rw-r--r--lib/banzai/filter/abstract_reference_filter.rb100
-rw-r--r--lib/banzai/filter/ascii_doc_post_processing_filter.rb13
-rw-r--r--lib/banzai/filter/commit_range_reference_filter.rb2
-rw-r--r--lib/banzai/filter/commit_reference_filter.rb2
-rw-r--r--lib/banzai/filter/external_issue_reference_filter.rb8
-rw-r--r--lib/banzai/filter/external_link_filter.rb36
-rw-r--r--lib/banzai/filter/gollum_tags_filter.rb2
-rw-r--r--lib/banzai/filter/image_lazy_load_filter.rb16
-rw-r--r--lib/banzai/filter/image_link_filter.rb2
-rw-r--r--lib/banzai/filter/issue_reference_filter.rb34
-rw-r--r--lib/banzai/filter/label_reference_filter.rb3
-rw-r--r--lib/banzai/filter/merge_request_reference_filter.rb2
-rw-r--r--lib/banzai/filter/milestone_reference_filter.rb34
-rw-r--r--lib/banzai/filter/reference_filter.rb2
-rw-r--r--lib/banzai/filter/relative_link_filter.rb3
-rw-r--r--lib/banzai/filter/sanitization_filter.rb4
-rw-r--r--lib/banzai/filter/snippet_reference_filter.rb2
-rw-r--r--lib/banzai/filter/upload_link_filter.rb2
-rw-r--r--lib/banzai/filter/user_reference_filter.rb2
-rw-r--r--lib/banzai/object_renderer.rb2
-rw-r--r--lib/banzai/pipeline/ascii_doc_pipeline.rb14
-rw-r--r--lib/banzai/pipeline/base_pipeline.rb2
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb1
-rw-r--r--lib/banzai/pipeline/markup_pipeline.rb13
-rw-r--r--lib/banzai/reference_extractor.rb4
-rw-r--r--lib/banzai/reference_parser/base_parser.rb9
-rw-r--r--lib/banzai/reference_parser/commit_parser.rb2
-rw-r--r--lib/banzai/reference_parser/commit_range_parser.rb2
-rw-r--r--lib/banzai/reference_parser/external_issue_parser.rb10
-rw-r--r--lib/banzai/reference_parser/issue_parser.rb9
-rw-r--r--lib/banzai/reference_parser/label_parser.rb2
-rw-r--r--lib/banzai/reference_parser/merge_request_parser.rb7
-rw-r--r--lib/banzai/reference_parser/milestone_parser.rb2
-rw-r--r--lib/banzai/reference_parser/snippet_parser.rb4
-rw-r--r--lib/banzai/reference_parser/user_parser.rb6
-rw-r--r--lib/banzai/renderer.rb6
-rw-r--r--lib/bitbucket/collection.rb2
-rw-r--r--lib/bitbucket/representation/pull_request_comment.rb4
-rw-r--r--lib/ci/ansi2html.rb8
-rw-r--r--lib/ci/api/builds.rb14
-rw-r--r--lib/ci/api/entities.rb16
-rw-r--r--lib/ci/api/helpers.rb3
-rw-r--r--lib/ci/api/triggers.rb11
-rw-r--r--lib/ci/charts.rb34
-rw-r--r--lib/ci/gitlab_ci_yaml_processor.rb68
-rw-r--r--lib/constraints/group_url_constrainer.rb14
-rw-r--r--lib/constraints/project_url_constrainer.rb10
-rw-r--r--lib/constraints/user_url_constrainer.rb6
-rw-r--r--lib/container_registry/client.rb14
-rw-r--r--lib/declarative_policy.rb94
-rw-r--r--lib/declarative_policy/base.rb329
-rw-r--r--lib/declarative_policy/cache.rb35
-rw-r--r--lib/declarative_policy/condition.rb103
-rw-r--r--lib/declarative_policy/dsl.rb103
-rw-r--r--lib/declarative_policy/preferred_scope.rb28
-rw-r--r--lib/declarative_policy/rule.rb301
-rw-r--r--lib/declarative_policy/runner.rb183
-rw-r--r--lib/declarative_policy/step.rb86
-rw-r--r--lib/extracts_path.rb3
-rw-r--r--lib/feature.rb67
-rw-r--r--lib/file_size_validator.rb4
-rw-r--r--lib/file_streamer.rb16
-rw-r--r--lib/github/client.rb36
-rw-r--r--lib/github/import.rb69
-rw-r--r--lib/github/representation/branch.rb14
-rw-r--r--lib/github/representation/pull_request.rb54
-rw-r--r--lib/gitlab.rb4
-rw-r--r--lib/gitlab/access.rb6
-rw-r--r--lib/gitlab/allowable.rb4
-rw-r--r--lib/gitlab/asciidoc.rb19
-rw-r--r--lib/gitlab/auth.rb55
-rw-r--r--lib/gitlab/auth/ip_rate_limiter.rb12
-rw-r--r--lib/gitlab/auth/result.rb4
-rw-r--r--lib/gitlab/auth/unique_ips_limiter.rb2
-rw-r--r--lib/gitlab/background_migration.rb52
-rw-r--r--lib/gitlab/background_migration/.gitkeep0
-rw-r--r--lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb109
-rw-r--r--lib/gitlab/background_migration/migrate_build_stage_id_reference.rb19
-rw-r--r--lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb176
-rw-r--r--lib/gitlab/background_migration/migrate_system_uploads_to_new_folder.rb26
-rw-r--r--lib/gitlab/background_migration/move_personal_snippet_files.rb79
-rw-r--r--lib/gitlab/badge/build/metadata.rb28
-rw-r--r--lib/gitlab/badge/build/status.rb37
-rw-r--r--lib/gitlab/badge/build/template.rb47
-rw-r--r--lib/gitlab/badge/coverage/metadata.rb6
-rw-r--r--lib/gitlab/badge/metadata.rb2
-rw-r--r--lib/gitlab/badge/pipeline/metadata.rb27
-rw-r--r--lib/gitlab/badge/pipeline/status.rb37
-rw-r--r--lib/gitlab/badge/pipeline/template.rb48
-rw-r--r--lib/gitlab/bitbucket_import/importer.rb2
-rw-r--r--lib/gitlab/blame.rb2
-rw-r--r--lib/gitlab/cache/ci/project_pipeline_status.rb14
-rw-r--r--lib/gitlab/cache/request_cache.rb94
-rw-r--r--lib/gitlab/chat_commands/base_command.rb47
-rw-r--r--lib/gitlab/chat_commands/command.rb44
-rw-r--r--lib/gitlab/chat_commands/deploy.rb50
-rw-r--r--lib/gitlab/chat_commands/help.rb28
-rw-r--r--lib/gitlab/chat_commands/issue_command.rb13
-rw-r--r--lib/gitlab/chat_commands/issue_new.rb42
-rw-r--r--lib/gitlab/chat_commands/issue_search.rb23
-rw-r--r--lib/gitlab/chat_commands/issue_show.rb23
-rw-r--r--lib/gitlab/chat_commands/presenters/access.rb40
-rw-r--r--lib/gitlab/chat_commands/presenters/base.rb77
-rw-r--r--lib/gitlab/chat_commands/presenters/deploy.rb21
-rw-r--r--lib/gitlab/chat_commands/presenters/help.rb27
-rw-r--r--lib/gitlab/chat_commands/presenters/issue_base.rb43
-rw-r--r--lib/gitlab/chat_commands/presenters/issue_new.rb50
-rw-r--r--lib/gitlab/chat_commands/presenters/issue_search.rb47
-rw-r--r--lib/gitlab/chat_commands/presenters/issue_show.rb61
-rw-r--r--lib/gitlab/chat_commands/result.rb5
-rw-r--r--lib/gitlab/chat_name_token.rb14
-rw-r--r--lib/gitlab/checks/change_access.rb104
-rw-r--r--lib/gitlab/checks/force_push.rb19
-rw-r--r--lib/gitlab/ci/build/artifacts/metadata.rb2
-rw-r--r--lib/gitlab/ci/build/artifacts/metadata/entry.rb6
-rw-r--r--lib/gitlab/ci/build/image.rb11
-rw-r--r--lib/gitlab/ci/build/step.rb3
-rw-r--r--lib/gitlab/ci/config/entry/cache.rb14
-rw-r--r--lib/gitlab/ci/config/entry/image.rb30
-rw-r--r--lib/gitlab/ci/config/entry/job.rb10
-rw-r--r--lib/gitlab/ci/config/entry/legacy_validation_helpers.rb8
-rw-r--r--lib/gitlab/ci/config/entry/service.rb34
-rw-r--r--lib/gitlab/ci/config/entry/services.rb25
-rw-r--r--lib/gitlab/ci/config/entry/validators.rb8
-rw-r--r--lib/gitlab/ci/config/entry/variables.rb4
-rw-r--r--lib/gitlab/ci/cron_parser.rb21
-rw-r--r--lib/gitlab/ci/pipeline_duration.rb4
-rw-r--r--lib/gitlab/ci/stage/seed.rb49
-rw-r--r--lib/gitlab/ci/status/build/action.rb21
-rw-r--r--lib/gitlab/ci/status/build/cancelable.rb8
-rw-r--r--lib/gitlab/ci/status/build/common.rb4
-rw-r--r--lib/gitlab/ci/status/build/factory.rb3
-rw-r--r--lib/gitlab/ci/status/build/failed_allowed.rb4
-rw-r--r--lib/gitlab/ci/status/build/play.rb8
-rw-r--r--lib/gitlab/ci/status/build/retryable.rb8
-rw-r--r--lib/gitlab/ci/status/build/stop.rb8
-rw-r--r--lib/gitlab/ci/status/canceled.rb4
-rw-r--r--lib/gitlab/ci/status/created.rb4
-rw-r--r--lib/gitlab/ci/status/extended.rb12
-rw-r--r--lib/gitlab/ci/status/external/common.rb4
-rw-r--r--lib/gitlab/ci/status/failed.rb4
-rw-r--r--lib/gitlab/ci/status/group/common.rb21
-rw-r--r--lib/gitlab/ci/status/group/factory.rb13
-rw-r--r--lib/gitlab/ci/status/manual.rb4
-rw-r--r--lib/gitlab/ci/status/pending.rb4
-rw-r--r--lib/gitlab/ci/status/pipeline/blocked.rb8
-rw-r--r--lib/gitlab/ci/status/pipeline/common.rb4
-rw-r--r--lib/gitlab/ci/status/running.rb4
-rw-r--r--lib/gitlab/ci/status/skipped.rb4
-rw-r--r--lib/gitlab/ci/status/stage/common.rb5
-rw-r--r--lib/gitlab/ci/status/success.rb4
-rw-r--r--lib/gitlab/ci/status/success_warning.rb8
-rw-r--r--lib/gitlab/ci/trace/stream.rb58
-rw-r--r--lib/gitlab/ci_access.rb9
-rw-r--r--lib/gitlab/conflict/file.rb15
-rw-r--r--lib/gitlab/conflict/file_collection.rb42
-rw-r--r--lib/gitlab/contributions_calendar.rb56
-rw-r--r--lib/gitlab/current_settings.rb55
-rw-r--r--lib/gitlab/cycle_analytics/base_query.rb18
-rw-r--r--lib/gitlab/cycle_analytics/base_stage.rb2
-rw-r--r--lib/gitlab/cycle_analytics/code_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/issue_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/metrics_tables.rb4
-rw-r--r--lib/gitlab/cycle_analytics/permissions.rb2
-rw-r--r--lib/gitlab/cycle_analytics/plan_event_fetcher.rb37
-rw-r--r--lib/gitlab/cycle_analytics/plan_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/production_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/review_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/staging_stage.rb8
-rw-r--r--lib/gitlab/cycle_analytics/summary/base.rb2
-rw-r--r--lib/gitlab/cycle_analytics/summary/commit.rb4
-rw-r--r--lib/gitlab/cycle_analytics/summary/deploy.rb4
-rw-r--r--lib/gitlab/cycle_analytics/summary/issue.rb2
-rw-r--r--lib/gitlab/cycle_analytics/test_stage.rb8
-rw-r--r--lib/gitlab/daemon.rb62
-rw-r--r--lib/gitlab/data_builder/build.rb6
-rw-r--r--lib/gitlab/data_builder/pipeline.rb2
-rw-r--r--lib/gitlab/data_builder/push.rb8
-rw-r--r--lib/gitlab/data_builder/repository.rb35
-rw-r--r--lib/gitlab/data_builder/wiki_page.rb22
-rw-r--r--lib/gitlab/database.rb20
-rw-r--r--lib/gitlab/database/median.rb22
-rw-r--r--lib/gitlab/database/migration_helpers.rb150
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1.rb40
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb84
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb198
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb101
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb70
-rw-r--r--lib/gitlab/database/sha_attribute.rb34
-rw-r--r--lib/gitlab/dependency_linker.rb27
-rw-r--r--lib/gitlab/dependency_linker/base_linker.rb86
-rw-r--r--lib/gitlab/dependency_linker/cartfile_linker.rb14
-rw-r--r--lib/gitlab/dependency_linker/cocoapods.rb10
-rw-r--r--lib/gitlab/dependency_linker/composer_json_linker.rb18
-rw-r--r--lib/gitlab/dependency_linker/gemfile_linker.rb32
-rw-r--r--lib/gitlab/dependency_linker/gemspec_linker.rb18
-rw-r--r--lib/gitlab/dependency_linker/godeps_json_linker.rb26
-rw-r--r--lib/gitlab/dependency_linker/json_linker.rb44
-rw-r--r--lib/gitlab/dependency_linker/method_linker.rb39
-rw-r--r--lib/gitlab/dependency_linker/package_json_linker.rb44
-rw-r--r--lib/gitlab/dependency_linker/podfile_linker.rb15
-rw-r--r--lib/gitlab/dependency_linker/podspec_json_linker.rb32
-rw-r--r--lib/gitlab/dependency_linker/podspec_linker.rb24
-rw-r--r--lib/gitlab/dependency_linker/requirements_txt_linker.rb17
-rw-r--r--lib/gitlab/devise_failure.rb23
-rw-r--r--lib/gitlab/diff/diff_refs.rb10
-rw-r--r--lib/gitlab/diff/file.rb192
-rw-r--r--lib/gitlab/diff/file_collection/base.rb25
-rw-r--r--lib/gitlab/diff/file_collection/merge_request_diff.rb8
-rw-r--r--lib/gitlab/diff/highlight.rb19
-rw-r--r--lib/gitlab/diff/inline_diff_markdown_marker.rb17
-rw-r--r--lib/gitlab/diff/inline_diff_marker.rb130
-rw-r--r--lib/gitlab/diff/line.rb18
-rw-r--r--lib/gitlab/diff/line_mapper.rb6
-rw-r--r--lib/gitlab/diff/parallel_diff.rb20
-rw-r--r--lib/gitlab/diff/position.rb48
-rw-r--r--lib/gitlab/diff/position_tracer.rb218
-rw-r--r--lib/gitlab/downtime_check.rb4
-rw-r--r--lib/gitlab/ee_compat_check.rb46
-rw-r--r--lib/gitlab/email/attachment_uploader.rb2
-rw-r--r--lib/gitlab/email/handler/create_issue_handler.rb2
-rw-r--r--lib/gitlab/email/handler/create_note_handler.rb3
-rw-r--r--lib/gitlab/email/handler/unsubscribe_handler.rb2
-rw-r--r--lib/gitlab/email/html_parser.rb7
-rw-r--r--lib/gitlab/email/message/repository_push.rb19
-rw-r--r--lib/gitlab/email/receiver.rb31
-rw-r--r--lib/gitlab/emoji.rb2
-rw-r--r--lib/gitlab/encoding_helper.rb62
-rw-r--r--lib/gitlab/environment.rb7
-rw-r--r--lib/gitlab/etag_caching/middleware.rb9
-rw-r--r--lib/gitlab/etag_caching/router.rb40
-rw-r--r--lib/gitlab/etag_caching/store.rb14
-rw-r--r--lib/gitlab/exclusive_lease.rb35
-rw-r--r--lib/gitlab/fake_application_settings.rb27
-rw-r--r--lib/gitlab/file_detector.rb20
-rw-r--r--lib/gitlab/file_finder.rb32
-rw-r--r--lib/gitlab/fogbugz_import/importer.rb18
-rw-r--r--lib/gitlab/git.rb4
-rw-r--r--lib/gitlab/git/attributes.rb5
-rw-r--r--lib/gitlab/git/blame.rb25
-rw-r--r--lib/gitlab/git/blob.rb160
-rw-r--r--lib/gitlab/git/blob_snippet.rb2
-rw-r--r--lib/gitlab/git/branch.rb5
-rw-r--r--lib/gitlab/git/commit.rb259
-rw-r--r--lib/gitlab/git/commit_stats.rb6
-rw-r--r--lib/gitlab/git/compare.rb4
-rw-r--r--lib/gitlab/git/diff.rb244
-rw-r--r--lib/gitlab/git/diff_collection.rb103
-rw-r--r--lib/gitlab/git/encoding_helper.rb64
-rw-r--r--lib/gitlab/git/env.rb2
-rw-r--r--lib/gitlab/git/gitmodules_parser.rb79
-rw-r--r--lib/gitlab/git/hook.rb12
-rw-r--r--lib/gitlab/git/index.rb8
-rw-r--r--lib/gitlab/git/path_helper.rb2
-rw-r--r--lib/gitlab/git/popen.rb2
-rw-r--r--lib/gitlab/git/ref.rb12
-rw-r--r--lib/gitlab/git/repository.rb1128
-rw-r--r--lib/gitlab/git/rev_list.rb4
-rw-r--r--lib/gitlab/git/storage.rb22
-rw-r--r--lib/gitlab/git/storage/circuit_breaker.rb144
-rw-r--r--lib/gitlab/git/storage/forked_storage_check.rb55
-rw-r--r--lib/gitlab/git/storage/health.rb91
-rw-r--r--lib/gitlab/git/tag.rb6
-rw-r--r--lib/gitlab/git/tree.rb73
-rw-r--r--lib/gitlab/git/util.rb2
-rw-r--r--lib/gitlab/git_access.rb111
-rw-r--r--lib/gitlab/git_access_status.rb15
-rw-r--r--lib/gitlab/git_access_wiki.rb12
-rw-r--r--lib/gitlab/git_post_receive.rb33
-rw-r--r--lib/gitlab/git_ref_validator.rb2
-rw-r--r--lib/gitlab/gitaly_client.rb114
-rw-r--r--lib/gitlab/gitaly_client/blob_service.rb37
-rw-r--r--lib/gitlab/gitaly_client/commit.rb38
-rw-r--r--lib/gitlab/gitaly_client/commit_service.rb219
-rw-r--r--lib/gitlab/gitaly_client/diff.rb21
-rw-r--r--lib/gitlab/gitaly_client/diff_stitcher.rb34
-rw-r--r--lib/gitlab/gitaly_client/notification_service.rb20
-rw-r--r--lib/gitlab/gitaly_client/notifications.rb18
-rw-r--r--lib/gitlab/gitaly_client/ref.rb46
-rw-r--r--lib/gitlab/gitaly_client/ref_service.rb146
-rw-r--r--lib/gitlab/gitaly_client/repository_service.rb37
-rw-r--r--lib/gitlab/gitaly_client/util.rb3
-rw-r--r--lib/gitlab/github_import/base_formatter.rb4
-rw-r--r--lib/gitlab/github_import/client.rb2
-rw-r--r--lib/gitlab/github_import/importer.rb4
-rw-r--r--lib/gitlab/github_import/issue_formatter.rb2
-rw-r--r--lib/gitlab/github_import/wiki_formatter.rb4
-rw-r--r--lib/gitlab/gitlab_import/client.rb2
-rw-r--r--lib/gitlab/gl_repository.rb20
-rw-r--r--lib/gitlab/gon_helper.rb10
-rw-r--r--lib/gitlab/google_code_import/client.rb2
-rw-r--r--lib/gitlab/google_code_import/importer.rb32
-rw-r--r--lib/gitlab/gpg.rb80
-rw-r--r--lib/gitlab/gpg/commit.rb97
-rw-r--r--lib/gitlab/gpg/invalid_gpg_signature_updater.rb17
-rw-r--r--lib/gitlab/group_hierarchy.rb111
-rw-r--r--lib/gitlab/health_checks/base_abstract_check.rb6
-rw-r--r--lib/gitlab/health_checks/fs_shards_check.rb131
-rw-r--r--lib/gitlab/health_checks/prometheus_text_format.rb40
-rw-r--r--lib/gitlab/health_checks/redis/cache_check.rb31
-rw-r--r--lib/gitlab/health_checks/redis/queues_check.rb31
-rw-r--r--lib/gitlab/health_checks/redis/redis_check.rb27
-rw-r--r--lib/gitlab/health_checks/redis/shared_state_check.rb31
-rw-r--r--lib/gitlab/health_checks/redis_check.rb25
-rw-r--r--lib/gitlab/health_checks/simple_abstract_check.rb15
-rw-r--r--lib/gitlab/highlight.rb47
-rw-r--r--lib/gitlab/i18n.rb59
-rw-r--r--lib/gitlab/import_export.rb2
-rw-r--r--lib/gitlab/import_export/attributes_finder.rb1
-rw-r--r--lib/gitlab/import_export/file_importer.rb6
-rw-r--r--lib/gitlab/import_export/import_export.yml75
-rw-r--r--lib/gitlab/import_export/json_hash_builder.rb4
-rw-r--r--lib/gitlab/import_export/project_tree_restorer.rb25
-rw-r--r--lib/gitlab/import_export/reader.rb5
-rw-r--r--lib/gitlab/import_export/relation_factory.rb10
-rw-r--r--lib/gitlab/import_export/repo_restorer.rb2
-rw-r--r--lib/gitlab/import_export/uploads_saver.rb1
-rw-r--r--lib/gitlab/import_sources.rb2
-rw-r--r--lib/gitlab/issuable_metadata.rb36
-rw-r--r--lib/gitlab/job_waiter.rb2
-rw-r--r--lib/gitlab/key_fingerprint.rb71
-rw-r--r--lib/gitlab/kubernetes.rb55
-rw-r--r--lib/gitlab/lazy.rb2
-rw-r--r--lib/gitlab/ldap/access.rb4
-rw-r--r--lib/gitlab/ldap/adapter.rb2
-rw-r--r--lib/gitlab/ldap/auth_hash.rb2
-rw-r--r--lib/gitlab/ldap/authentication.rb2
-rw-r--r--lib/gitlab/ldap/config.rb62
-rw-r--r--lib/gitlab/ldap/person.rb4
-rw-r--r--lib/gitlab/ldap/user.rb19
-rw-r--r--lib/gitlab/lfs_token.rb8
-rw-r--r--lib/gitlab/mail_room.rb10
-rw-r--r--lib/gitlab/markdown/pipeline.rb2
-rw-r--r--lib/gitlab/metrics.rb154
-rw-r--r--lib/gitlab/metrics/base_sampler.rb63
-rw-r--r--lib/gitlab/metrics/influx_db.rb170
-rw-r--r--lib/gitlab/metrics/influx_sampler.rb101
-rw-r--r--lib/gitlab/metrics/null_metric.rb10
-rw-r--r--lib/gitlab/metrics/prometheus.rb57
-rw-r--r--lib/gitlab/metrics/requests_rack_middleware.rb40
-rw-r--r--lib/gitlab/metrics/sampler.rb133
-rw-r--r--lib/gitlab/metrics/sidekiq_metrics_exporter.rb39
-rw-r--r--lib/gitlab/metrics/system.rb8
-rw-r--r--lib/gitlab/metrics/unicorn_sampler.rb48
-rw-r--r--lib/gitlab/middleware/rails_queue_duration.rb2
-rw-r--r--lib/gitlab/middleware/webpack_proxy.rb2
-rw-r--r--lib/gitlab/o_auth/provider.rb6
-rw-r--r--lib/gitlab/o_auth/session.rb2
-rw-r--r--lib/gitlab/o_auth/user.rb38
-rw-r--r--lib/gitlab/other_markup.rb10
-rw-r--r--lib/gitlab/otp_key_rotator.rb87
-rw-r--r--lib/gitlab/path_regex.rb266
-rw-r--r--lib/gitlab/performance_bar.rb34
-rw-r--r--lib/gitlab/performance_bar/peek_query_tracker.rb46
-rw-r--r--lib/gitlab/project_authorizations/with_nested_groups.rb125
-rw-r--r--lib/gitlab/project_authorizations/without_nested_groups.rb35
-rw-r--r--lib/gitlab/project_search_results.rb20
-rw-r--r--lib/gitlab/project_template.rb47
-rw-r--r--lib/gitlab/prometheus.rb70
-rw-r--r--lib/gitlab/prometheus/additional_metrics_parser.rb34
-rw-r--r--lib/gitlab/prometheus/metric.rb16
-rw-r--r--lib/gitlab/prometheus/metric_group.rb14
-rw-r--r--lib/gitlab/prometheus/parsing_error.rb5
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb21
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb17
-rw-r--r--lib/gitlab/prometheus/queries/base_query.rb26
-rw-r--r--lib/gitlab/prometheus/queries/deployment_query.rb31
-rw-r--r--lib/gitlab/prometheus/queries/environment_query.rb25
-rw-r--r--lib/gitlab/prometheus/queries/matched_metrics_query.rb80
-rw-r--r--lib/gitlab/prometheus/queries/query_additional_metrics.rb86
-rw-r--r--lib/gitlab/prometheus_client.rb84
-rw-r--r--lib/gitlab/quick_actions/command_definition.rb89
-rw-r--r--lib/gitlab/quick_actions/dsl.rb159
-rw-r--r--lib/gitlab/quick_actions/extractor.rb144
-rw-r--r--lib/gitlab/quick_actions/substitution_definition.rb24
-rw-r--r--lib/gitlab/redis.rb102
-rw-r--r--lib/gitlab/redis/cache.rb31
-rw-r--r--lib/gitlab/redis/queues.rb32
-rw-r--r--lib/gitlab/redis/shared_state.rb31
-rw-r--r--lib/gitlab/redis/wrapper.rb140
-rw-r--r--lib/gitlab/reference_extractor.rb9
-rw-r--r--lib/gitlab/regex.rb106
-rw-r--r--lib/gitlab/repo_path.rb40
-rw-r--r--lib/gitlab/request_forgery_protection.rb39
-rw-r--r--lib/gitlab/route_map.rb12
-rw-r--r--lib/gitlab/routing.rb25
-rw-r--r--lib/gitlab/seeder.rb2
-rw-r--r--lib/gitlab/sentry.rb2
-rw-r--r--lib/gitlab/shell.rb110
-rw-r--r--lib/gitlab/sherlock/line_profiler.rb4
-rw-r--r--lib/gitlab/sherlock/query.rb8
-rw-r--r--lib/gitlab/sidekiq_status.rb9
-rw-r--r--lib/gitlab/slash_commands/base_command.rb47
-rw-r--r--lib/gitlab/slash_commands/command.rb44
-rw-r--r--lib/gitlab/slash_commands/command_definition.rb57
-rw-r--r--lib/gitlab/slash_commands/deploy.rb55
-rw-r--r--lib/gitlab/slash_commands/dsl.rb98
-rw-r--r--lib/gitlab/slash_commands/extractor.rb122
-rw-r--r--lib/gitlab/slash_commands/help.rb28
-rw-r--r--lib/gitlab/slash_commands/issue_command.rb13
-rw-r--r--lib/gitlab/slash_commands/issue_new.rb42
-rw-r--r--lib/gitlab/slash_commands/issue_search.rb23
-rw-r--r--lib/gitlab/slash_commands/issue_show.rb23
-rw-r--r--lib/gitlab/slash_commands/presenters/access.rb40
-rw-r--r--lib/gitlab/slash_commands/presenters/base.rb77
-rw-r--r--lib/gitlab/slash_commands/presenters/deploy.rb18
-rw-r--r--lib/gitlab/slash_commands/presenters/help.rb27
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_base.rb43
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_new.rb50
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_search.rb47
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_show.rb61
-rw-r--r--lib/gitlab/slash_commands/result.rb5
-rw-r--r--lib/gitlab/sql/glob.rb22
-rw-r--r--lib/gitlab/sql/recursive_cte.rb62
-rw-r--r--lib/gitlab/string_range_marker.rb102
-rw-r--r--lib/gitlab/string_regex_marker.rb13
-rw-r--r--lib/gitlab/untrusted_regexp.rb49
-rw-r--r--lib/gitlab/uploads_transfer.rb2
-rw-r--r--lib/gitlab/url_blocker.rb8
-rw-r--r--lib/gitlab/url_builder.rb23
-rw-r--r--lib/gitlab/url_sanitizer.rb6
-rw-r--r--lib/gitlab/usage_data.rb25
-rw-r--r--lib/gitlab/user_access.rb58
-rw-r--r--lib/gitlab/user_activities.rb6
-rw-r--r--lib/gitlab/utils.rb21
-rw-r--r--lib/gitlab/view/presenter/base.rb5
-rw-r--r--lib/gitlab/visibility_level.rb38
-rw-r--r--lib/gitlab/workhorse.rb80
-rw-r--r--lib/haml_lint/inline_javascript.rb16
-rw-r--r--lib/mattermost/client.rb10
-rw-r--r--lib/mattermost/session.rb26
-rw-r--r--lib/mattermost/team.rb7
-rw-r--r--lib/omni_auth/request_forgery_protection.rb21
-rw-r--r--lib/peek/rblineprof/custom_controller_helpers.rb122
-rw-r--r--lib/rouge/lexers/math.rb16
-rw-r--r--lib/rouge/lexers/plantuml.rb16
-rw-r--r--lib/rspec_flaky/example.rb46
-rw-r--r--lib/rspec_flaky/flaky_example.rb39
-rw-r--r--lib/rspec_flaky/listener.rb75
-rw-r--r--lib/static_model.rb2
-rwxr-xr-xlib/support/init.d/gitlab8
-rw-r--r--lib/support/init.d/gitlab.default.example4
-rw-r--r--lib/support/nginx/gitlab35
-rw-r--r--lib/support/nginx/gitlab-pages5
-rw-r--r--lib/support/nginx/gitlab-pages-ssl5
-rw-r--r--lib/support/nginx/gitlab-ssl39
-rw-r--r--lib/system_check.rb21
-rw-r--r--lib/system_check/app/active_users_check.rb17
-rw-r--r--lib/system_check/app/database_config_exists_check.rb25
-rw-r--r--lib/system_check/app/git_config_check.rb42
-rw-r--r--lib/system_check/app/git_version_check.rb29
-rw-r--r--lib/system_check/app/gitlab_config_exists_check.rb24
-rw-r--r--lib/system_check/app/gitlab_config_up_to_date_check.rb30
-rw-r--r--lib/system_check/app/init_script_exists_check.rb27
-rw-r--r--lib/system_check/app/init_script_up_to_date_check.rb43
-rw-r--r--lib/system_check/app/log_writable_check.rb28
-rw-r--r--lib/system_check/app/migrations_are_up_check.rb20
-rw-r--r--lib/system_check/app/orphaned_group_members_check.rb20
-rw-r--r--lib/system_check/app/projects_have_namespace_check.rb37
-rw-r--r--lib/system_check/app/redis_version_check.rb25
-rw-r--r--lib/system_check/app/ruby_version_check.rb27
-rw-r--r--lib/system_check/app/tmp_writable_check.rb28
-rw-r--r--lib/system_check/app/uploads_directory_exists_check.rb21
-rw-r--r--lib/system_check/app/uploads_path_permission_check.rb36
-rw-r--r--lib/system_check/app/uploads_path_tmp_permission_check.rb40
-rw-r--r--lib/system_check/base_check.rb129
-rw-r--r--lib/system_check/helpers.rb75
-rw-r--r--lib/system_check/simple_executor.rb101
-rw-r--r--lib/tasks/brakeman.rake2
-rw-r--r--lib/tasks/cache.rake4
-rw-r--r--lib/tasks/gemojione.rake3
-rw-r--r--lib/tasks/gettext.rake22
-rw-r--r--lib/tasks/gitlab/assets.rake1
-rw-r--r--lib/tasks/gitlab/check.rake493
-rw-r--r--lib/tasks/gitlab/db.rake1
-rw-r--r--lib/tasks/gitlab/gitaly.rake42
-rw-r--r--lib/tasks/gitlab/helpers.rake2
-rw-r--r--lib/tasks/gitlab/info.rake6
-rw-r--r--lib/tasks/gitlab/list_repos.rake2
-rw-r--r--lib/tasks/gitlab/shell.rake12
-rw-r--r--lib/tasks/gitlab/task_helpers.rb55
-rw-r--r--lib/tasks/gitlab/two_factor.rake16
-rw-r--r--lib/tasks/gitlab/update_templates.rake56
-rw-r--r--lib/tasks/gitlab/workhorse.rake6
-rw-r--r--lib/tasks/haml-lint.rake1
-rw-r--r--lib/tasks/import.rake5
-rw-r--r--lib/tasks/migrate/add_limits_mysql.rake4
-rw-r--r--lib/tasks/migrate/setup_postgresql.rake4
-rw-r--r--lib/tasks/spec.rake2
-rw-r--r--lib/tasks/tokens.rake10
-rw-r--r--lib/uploaded_file.rb2
-rw-r--r--locale/bg/gitlab.po1249
-rw-r--r--locale/bg/gitlab.po.time_stamp0
-rw-r--r--locale/de/gitlab.po303
-rw-r--r--locale/de/gitlab.po.time_stamp0
-rw-r--r--locale/en/gitlab.po1240
-rw-r--r--locale/en/gitlab.po.time_stamp0
-rw-r--r--locale/eo/gitlab.po1246
-rw-r--r--locale/eo/gitlab.po.time_stamp0
-rw-r--r--locale/es/gitlab.po1136
-rw-r--r--locale/es/gitlab.po.time_stamp0
-rw-r--r--locale/fr/gitlab.po1266
-rw-r--r--locale/fr/gitlab.po.time_stamp0
-rw-r--r--locale/gitlab.pot1137
-rw-r--r--locale/it/gitlab.po1254
-rw-r--r--locale/it/gitlab.po.time_stamp0
-rw-r--r--locale/ja/gitlab.po1195
-rw-r--r--locale/ja/gitlab.po.time_stamp0
-rw-r--r--locale/ko/gitlab.po1200
-rw-r--r--locale/ko/gitlab.po.time_stamp0
-rw-r--r--locale/pt_BR/gitlab.po1243
-rw-r--r--locale/pt_BR/gitlab.po.time_stamp0
-rw-r--r--locale/ru/gitlab.po1269
-rw-r--r--locale/ru/gitlab.po.time_stamp0
-rw-r--r--locale/uk/gitlab.po1264
-rw-r--r--locale/uk/gitlab.po.time_stamp0
-rw-r--r--locale/zh_CN/gitlab.po1175
-rw-r--r--locale/zh_CN/gitlab.po.time_stamp0
-rw-r--r--locale/zh_HK/gitlab.po1174
-rw-r--r--locale/zh_HK/gitlab.po.time_stamp0
-rw-r--r--locale/zh_TW/gitlab.po1188
-rw-r--r--locale/zh_TW/gitlab.po.time_stamp0
-rw-r--r--package.json35
-rw-r--r--public/ci/favicon.icobin5430 -> 0 bytes
-rw-r--r--qa/Dockerfile26
-rw-r--r--qa/Gemfile2
-rw-r--r--qa/Gemfile.lock10
-rw-r--r--qa/qa.rb10
-rw-r--r--qa/qa/page/dashboard/groups.rb22
-rw-r--r--qa/qa/page/group/show.rb11
-rw-r--r--qa/qa/page/main/groups.rb20
-rw-r--r--qa/qa/page/main/projects.rb16
-rw-r--r--qa/qa/runtime/release.rb2
-rw-r--r--qa/qa/scenario/gitlab/project/create.rb5
-rw-r--r--qa/qa/specs/config.rb45
-rw-r--r--qa/spec/spec_helper.rb1
-rw-r--r--rubocop/cop/active_record_dependent.rb26
-rw-r--r--rubocop/cop/active_record_serialize.rb18
-rw-r--r--rubocop/cop/in_batches.rb16
-rw-r--r--rubocop/cop/migration/add_column_with_default.rb34
-rw-r--r--rubocop/cop/migration/add_column_with_default_to_large_table.rb55
-rw-r--r--rubocop/cop/migration/add_timestamps.rb25
-rw-r--r--rubocop/cop/migration/datetime.rb36
-rw-r--r--rubocop/cop/migration/hash_index.rb51
-rw-r--r--rubocop/cop/migration/reversible_add_column_with_default.rb35
-rw-r--r--rubocop/cop/migration/safer_boolean_column.rb94
-rw-r--r--rubocop/cop/migration/timestamps.rb27
-rw-r--r--rubocop/cop/migration/update_column_in_batches.rb43
-rw-r--r--rubocop/cop/polymorphic_associations.rb23
-rw-r--r--rubocop/cop/project_path_helper.rb51
-rw-r--r--rubocop/cop/redirect_with_status.rb44
-rw-r--r--rubocop/cop/rspec/single_line_hook.rb38
-rw-r--r--rubocop/migration_helpers.rb5
-rw-r--r--rubocop/model_helpers.rb11
-rw-r--r--rubocop/rubocop.rb16
-rwxr-xr-xscripts/detect-new-flaky-examples21
-rwxr-xr-xscripts/gitaly-test-build19
-rwxr-xr-xscripts/gitaly-test-spawn7
-rwxr-xr-xscripts/lint-conflicts.sh5
-rwxr-xr-xscripts/lint-doc.sh1
-rwxr-xr-xscripts/merge-reports2
-rwxr-xr-xscripts/notify_slack.sh13
-rw-r--r--[-rwxr-xr-x]scripts/prepare_build.sh29
-rwxr-xr-xscripts/static-analysis41
-rwxr-xr-xscripts/trigger-build27
-rw-r--r--spec/bin/changelog_spec.rb98
-rw-r--r--spec/config/mail_room_spec.rb26
-rw-r--r--spec/controllers/abuse_reports_controller_spec.rb25
-rw-r--r--spec/controllers/admin/applications_controller_spec.rb11
-rw-r--r--spec/controllers/admin/dashboard_controller_spec.rb21
-rw-r--r--spec/controllers/admin/groups_controller_spec.rb11
-rw-r--r--spec/controllers/admin/health_check_controller_spec.rb25
-rw-r--r--spec/controllers/admin/hooks_controller_spec.rb28
-rw-r--r--spec/controllers/admin/identities_controller_spec.rb5
-rw-r--r--spec/controllers/admin/projects_controller_spec.rb14
-rw-r--r--spec/controllers/admin/services_controller_spec.rb38
-rw-r--r--spec/controllers/admin/users_controller_spec.rb23
-rw-r--r--spec/controllers/application_controller_spec.rb112
-rw-r--r--spec/controllers/autocomplete_controller_spec.rb176
-rw-r--r--spec/controllers/blob_controller_spec.rb67
-rw-r--r--spec/controllers/dashboard/labels_controller_spec.rb25
-rw-r--r--spec/controllers/dashboard/milestones_controller_spec.rb38
-rw-r--r--spec/controllers/dashboard/todos_controller_spec.rb34
-rw-r--r--spec/controllers/explore/projects_controller_spec.rb4
-rw-r--r--spec/controllers/groups/group_members_controller_spec.rb63
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb262
-rw-r--r--spec/controllers/groups/settings/ci_cd_controller_spec.rb20
-rw-r--r--spec/controllers/groups/variables_controller_spec.rb56
-rw-r--r--spec/controllers/groups_controller_spec.rb247
-rw-r--r--spec/controllers/health_check_controller_spec.rb75
-rw-r--r--spec/controllers/health_controller_spec.rb126
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb107
-rw-r--r--spec/controllers/import/fogbugz_controller_spec.rb4
-rw-r--r--spec/controllers/import/github_controller_spec.rb8
-rw-r--r--spec/controllers/import/gitlab_controller_spec.rb109
-rw-r--r--spec/controllers/import/google_code_controller_spec.rb4
-rw-r--r--spec/controllers/invites_controller_spec.rb2
-rw-r--r--spec/controllers/metrics_controller_spec.rb114
-rw-r--r--spec/controllers/notification_settings_controller_spec.rb27
-rw-r--r--spec/controllers/oauth/authorizations_controller_spec.rb55
-rw-r--r--spec/controllers/passwords_controller_spec.rb29
-rw-r--r--spec/controllers/profiles/accounts_controller_spec.rb2
-rw-r--r--spec/controllers/profiles/keys_controller_spec.rb2
-rw-r--r--spec/controllers/profiles/personal_access_tokens_controller_spec.rb60
-rw-r--r--spec/controllers/profiles/personal_access_tokens_spec.rb56
-rw-r--r--spec/controllers/profiles/preferences_controller_spec.rb5
-rw-r--r--spec/controllers/profiles_controller_spec.rb31
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb181
-rw-r--r--spec/controllers/projects/avatars_controller_spec.rb2
-rw-r--r--spec/controllers/projects/badges_controller_spec.rb28
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb84
-rw-r--r--spec/controllers/projects/boards/issues_controller_spec.rb4
-rw-r--r--spec/controllers/projects/boards/lists_controller_spec.rb4
-rw-r--r--spec/controllers/projects/boards_controller_spec.rb2
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb169
-rw-r--r--spec/controllers/projects/builds_controller_spec.rb104
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb23
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb16
-rw-r--r--spec/controllers/projects/deploy_keys_controller_spec.rb66
-rw-r--r--spec/controllers/projects/deployments_controller_spec.rb180
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb122
-rw-r--r--spec/controllers/projects/forks_controller_spec.rb16
-rw-r--r--spec/controllers/projects/graphs_controller_spec.rb33
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb13
-rw-r--r--spec/controllers/projects/hooks_controller_spec.rb21
-rw-r--r--spec/controllers/projects/imports_controller_spec.rb12
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb221
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb443
-rw-r--r--spec/controllers/projects/labels_controller_spec.rb80
-rw-r--r--spec/controllers/projects/mattermosts_controller_spec.rb10
-rw-r--r--spec/controllers/projects/merge_requests/conflicts_controller_spec.rb307
-rw-r--r--spec/controllers/projects/merge_requests/creations_controller_spec.rb120
-rw-r--r--spec/controllers/projects/merge_requests/diffs_controller_spec.rb160
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb836
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb39
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb105
-rw-r--r--spec/controllers/projects/pages_controller_spec.rb57
-rw-r--r--spec/controllers/projects/pages_domains_controller_spec.rb60
-rw-r--r--spec/controllers/projects/pipeline_schedules_controller_spec.rb398
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb114
-rw-r--r--spec/controllers/projects/project_members_controller_spec.rb92
-rw-r--r--spec/controllers/projects/prometheus_controller_spec.rb59
-rw-r--r--spec/controllers/projects/raw_controller_spec.rb8
-rw-r--r--spec/controllers/projects/registry/repositories_controller_spec.rb2
-rw-r--r--spec/controllers/projects/registry/tags_controller_spec.rb48
-rw-r--r--spec/controllers/projects/repositories_controller_spec.rb2
-rw-r--r--spec/controllers/projects/runners_controller_spec.rb2
-rw-r--r--spec/controllers/projects/services_controller_spec.rb112
-rw-r--r--spec/controllers/projects/settings/ci_cd_controller_spec.rb2
-rw-r--r--spec/controllers/projects/settings/integrations_controller_spec.rb2
-rw-r--r--spec/controllers/projects/settings/members_controller_spec.rb14
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb70
-rw-r--r--spec/controllers/projects/tags_controller_spec.rb8
-rw-r--r--spec/controllers/projects/todo_controller_spec.rb144
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb144
-rw-r--r--spec/controllers/projects/tree_controller_spec.rb14
-rw-r--r--spec/controllers/projects/uploads_controller_spec.rb2
-rw-r--r--spec/controllers/projects/variables_controller_spec.rb11
-rw-r--r--spec/controllers/projects_controller_spec.rb371
-rw-r--r--spec/controllers/registrations_controller_spec.rb6
-rw-r--r--spec/controllers/search_controller_spec.rb12
-rw-r--r--spec/controllers/sent_notifications_controller_spec.rb43
-rw-r--r--spec/controllers/sessions_controller_spec.rb59
-rw-r--r--spec/controllers/snippets/notes_controller_spec.rb196
-rw-r--r--spec/controllers/snippets_controller_spec.rb286
-rw-r--r--spec/controllers/uploads_controller_spec.rb165
-rw-r--r--spec/controllers/users_controller_spec.rb228
-rw-r--r--spec/db/production/settings.rb17
-rw-r--r--spec/db/production/settings_spec.rb58
-rw-r--r--spec/factories/application_settings.rb4
-rw-r--r--spec/factories/boards.rb2
-rw-r--r--spec/factories/ci/builds.rb39
-rw-r--r--spec/factories/ci/group_variables.rb12
-rw-r--r--spec/factories/ci/pipeline_schedule.rb29
-rw-r--r--spec/factories/ci/pipeline_schedule_variables.rb8
-rw-r--r--spec/factories/ci/pipeline_variable_variables.rb8
-rw-r--r--spec/factories/ci/pipelines.rb33
-rw-r--r--spec/factories/ci/runner_projects.rb4
-rw-r--r--spec/factories/ci/stages.rb8
-rw-r--r--spec/factories/ci/trigger_requests.rb4
-rw-r--r--spec/factories/ci/trigger_schedules.rb28
-rw-r--r--spec/factories/ci/triggers.rb7
-rw-r--r--spec/factories/ci/variables.rb6
-rw-r--r--spec/factories/commits.rb2
-rw-r--r--spec/factories/conversational_development_index_metrics.rb43
-rw-r--r--spec/factories/deploy_keys_projects.rb2
-rw-r--r--spec/factories/deployments.rb4
-rw-r--r--spec/factories/environments.rb14
-rw-r--r--spec/factories/events.rb18
-rw-r--r--spec/factories/file_uploader.rb20
-rw-r--r--spec/factories/file_uploaders.rb22
-rw-r--r--spec/factories/forked_project_links.rb8
-rw-r--r--spec/factories/gpg_keys.rb8
-rw-r--r--spec/factories/gpg_signature.rb11
-rw-r--r--spec/factories/group_members.rb6
-rw-r--r--spec/factories/groups.rb4
-rw-r--r--spec/factories/issues.rb8
-rw-r--r--spec/factories/keys.rb19
-rw-r--r--spec/factories/label_priorities.rb2
-rw-r--r--spec/factories/labels.rb2
-rw-r--r--spec/factories/lists.rb6
-rw-r--r--spec/factories/merge_requests.rb17
-rw-r--r--spec/factories/milestones.rb22
-rw-r--r--spec/factories/notes.rb6
-rw-r--r--spec/factories/notification_settings.rb3
-rw-r--r--spec/factories/personal_snippets.rb4
-rw-r--r--spec/factories/project_group_links.rb2
-rw-r--r--spec/factories/project_hooks.rb5
-rw-r--r--spec/factories/project_members.rb8
-rw-r--r--spec/factories/project_snippets.rb5
-rw-r--r--spec/factories/project_statistics.rb8
-rw-r--r--spec/factories/project_wikis.rb4
-rw-r--r--spec/factories/projects.rb198
-rw-r--r--spec/factories/protected_branches.rb54
-rw-r--r--spec/factories/protected_tags.rb36
-rw-r--r--spec/factories/releases.rb2
-rw-r--r--spec/factories/sent_notifications.rb2
-rw-r--r--spec/factories/services.rb25
-rw-r--r--spec/factories/snippets.rb8
-rw-r--r--spec/factories/subscriptions.rb2
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/factories/uploads.rb8
-rw-r--r--spec/factories/users.rb14
-rw-r--r--spec/factories/web_hook_log.rb14
-rw-r--r--spec/factories/wiki_directories.rb2
-rw-r--r--spec/factories_spec.rb14
-rw-r--r--spec/features/abuse_report_spec.rb6
-rw-r--r--spec/features/admin/admin_abuse_reports_spec.rb4
-rw-r--r--spec/features/admin/admin_active_tab_spec.rb2
-rw-r--r--spec/features/admin/admin_appearance_spec.rb14
-rw-r--r--spec/features/admin/admin_broadcast_messages_spec.rb4
-rw-r--r--spec/features/admin/admin_browse_spam_logs_spec.rb2
-rw-r--r--spec/features/admin/admin_browses_logs_spec.rb2
-rw-r--r--spec/features/admin/admin_builds_spec.rb18
-rw-r--r--spec/features/admin/admin_cohorts_spec.rb4
-rw-r--r--spec/features/admin/admin_conversational_development_index_spec.rb40
-rw-r--r--spec/features/admin/admin_deploy_keys_spec.rb67
-rw-r--r--spec/features/admin/admin_disables_git_access_protocol_spec.rb10
-rw-r--r--spec/features/admin/admin_disables_two_factor_spec.rb6
-rw-r--r--spec/features/admin/admin_groups_spec.rb11
-rw-r--r--spec/features/admin/admin_health_check_spec.rb26
-rw-r--r--spec/features/admin/admin_hook_logs_spec.rb40
-rw-r--r--spec/features/admin/admin_hooks_spec.rb56
-rw-r--r--spec/features/admin/admin_labels_spec.rb6
-rw-r--r--spec/features/admin/admin_manage_applications_spec.rb9
-rw-r--r--spec/features/admin/admin_projects_spec.rb33
-rw-r--r--spec/features/admin/admin_requests_profiles_spec.rb69
-rw-r--r--spec/features/admin/admin_runners_spec.rb77
-rw-r--r--spec/features/admin/admin_settings_spec.rb32
-rw-r--r--spec/features/admin/admin_system_info_spec.rb5
-rw-r--r--spec/features/admin/admin_users_impersonation_tokens_spec.rb22
-rw-r--r--spec/features/admin/admin_users_spec.rb33
-rw-r--r--spec/features/admin/admin_uses_repository_checks_spec.rb12
-rw-r--r--spec/features/atom/dashboard_issues_spec.rb25
-rw-r--r--spec/features/atom/dashboard_spec.rb15
-rw-r--r--spec/features/atom/issues_spec.rb49
-rw-r--r--spec/features/atom/users_spec.rb19
-rw-r--r--spec/features/auto_deploy_spec.rb17
-rw-r--r--spec/features/boards/add_issues_modal_spec.rb28
-rw-r--r--spec/features/boards/boards_spec.rb278
-rw-r--r--spec/features/boards/issue_ordering_spec.rb75
-rw-r--r--spec/features/boards/keyboard_shortcut_spec.rb12
-rw-r--r--spec/features/boards/modal_filter_spec.rb42
-rw-r--r--spec/features/boards/new_issue_spec.rb40
-rw-r--r--spec/features/boards/sidebar_spec.rb107
-rw-r--r--spec/features/boards/sub_group_project_spec.rb43
-rw-r--r--spec/features/calendar_spec.rb32
-rw-r--r--spec/features/ci_lint_spec.rb2
-rw-r--r--spec/features/commits_spec.rb137
-rw-r--r--spec/features/container_registry_spec.rb9
-rw-r--r--spec/features/copy_as_gfm_spec.rb57
-rw-r--r--spec/features/cycle_analytics_spec.rb91
-rw-r--r--spec/features/dashboard/active_tab_spec.rb4
-rw-r--r--spec/features/dashboard/activity_spec.rb155
-rw-r--r--spec/features/dashboard/archived_projects_spec.rb4
-rw-r--r--spec/features/dashboard/datetime_on_tooltips_spec.rb12
-rw-r--r--spec/features/dashboard/group_spec.rb6
-rw-r--r--spec/features/dashboard/groups_list_spec.rb126
-rw-r--r--spec/features/dashboard/help_spec.rb4
-rw-r--r--spec/features/dashboard/issuables_counter_spec.rb14
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb115
-rw-r--r--spec/features/dashboard/issues_spec.rb118
-rw-r--r--spec/features/dashboard/label_filter_spec.rb4
-rw-r--r--spec/features/dashboard/merge_requests_spec.rb126
-rw-r--r--spec/features/dashboard/milestone_filter_spec.rb54
-rw-r--r--spec/features/dashboard/milestone_tabs_spec.rb40
-rw-r--r--spec/features/dashboard/milestones_spec.rb29
-rw-r--r--spec/features/dashboard/project_member_activity_index_spec.rb20
-rw-r--r--spec/features/dashboard/projects_spec.rb102
-rw-r--r--spec/features/dashboard/shortcuts_spec.rb33
-rw-r--r--spec/features/dashboard/snippets_spec.rb53
-rw-r--r--spec/features/dashboard/todos/target_state_spec.rb65
-rw-r--r--spec/features/dashboard/todos/todos_filtering_spec.rb153
-rw-r--r--spec/features/dashboard/todos/todos_sorting_spec.rb99
-rw-r--r--spec/features/dashboard/todos/todos_spec.rb338
-rw-r--r--spec/features/dashboard/user_filters_projects_spec.rb4
-rw-r--r--spec/features/dashboard_issues_spec.rb73
-rw-r--r--spec/features/dashboard_milestones_spec.rb29
-rw-r--r--spec/features/discussion_comments/commit_spec.rb8
-rw-r--r--spec/features/discussion_comments/issue_spec.rb8
-rw-r--r--spec/features/discussion_comments/merge_request_spec.rb8
-rw-r--r--spec/features/discussion_comments/snippets_spec.rb8
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb51
-rw-r--r--spec/features/explore/groups_list_spec.rb38
-rw-r--r--spec/features/explore/new_menu_spec.rb173
-rw-r--r--spec/features/gitlab_flavored_markdown_spec.rb59
-rw-r--r--spec/features/global_search_spec.rb6
-rw-r--r--spec/features/group_variables_spec.rb78
-rw-r--r--spec/features/groups/activity_spec.rb14
-rw-r--r--spec/features/groups/empty_states_spec.rb8
-rw-r--r--spec/features/groups/group_name_toggle_spec.rb8
-rw-r--r--spec/features/groups/group_settings_spec.rb85
-rw-r--r--spec/features/groups/issues_spec.rb20
-rw-r--r--spec/features/groups/labels/edit_spec.rb4
-rw-r--r--spec/features/groups/labels/subscription_spec.rb51
-rw-r--r--spec/features/groups/members/last_owner_cannot_leave_group_spec.rb16
-rw-r--r--spec/features/groups/members/leave_group_spec.rb62
-rw-r--r--spec/features/groups/members/list_members_spec.rb42
-rw-r--r--spec/features/groups/members/list_spec.rb105
-rw-r--r--spec/features/groups/members/manage_access_requests_spec.rb47
-rw-r--r--spec/features/groups/members/manage_members.rb113
-rw-r--r--spec/features/groups/members/member_cannot_request_access_to_his_project_spec.rb16
-rw-r--r--spec/features/groups/members/member_leaves_group_spec.rb21
-rw-r--r--spec/features/groups/members/owner_manages_access_requests_spec.rb47
-rw-r--r--spec/features/groups/members/request_access_spec.rb78
-rw-r--r--spec/features/groups/members/sort_members_spec.rb98
-rw-r--r--spec/features/groups/members/sorting_spec.rb98
-rw-r--r--spec/features/groups/members/user_requests_access_spec.rb71
-rw-r--r--spec/features/groups/merge_requests_spec.rb4
-rw-r--r--spec/features/groups/milestone_spec.rb65
-rw-r--r--spec/features/groups/show_spec.rb13
-rw-r--r--spec/features/groups_spec.rb36
-rw-r--r--spec/features/help_pages_spec.rb41
-rw-r--r--spec/features/issuables/close_reopen_report_toggle_spec.rb116
-rw-r--r--spec/features/issuables/default_sort_order_spec.rb12
-rw-r--r--spec/features/issuables/issuable_list_spec.rb13
-rw-r--r--spec/features/issuables/markdown_references_spec.rb193
-rw-r--r--spec/features/issuables/user_sees_sidebar_spec.rb30
-rw-r--r--spec/features/issues/award_emoji_spec.rb36
-rw-r--r--spec/features/issues/award_spec.rb14
-rw-r--r--spec/features/issues/bulk_assignment_labels_spec.rb105
-rw-r--r--spec/features/issues/create_branch_merge_request_spec.rb93
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb32
-rw-r--r--spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb21
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb39
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb39
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb26
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb28
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb240
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb56
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb10
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb22
-rw-r--r--spec/features/issues/form_spec.rb181
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb30
-rw-r--r--spec/features/issues/group_label_sidebar_spec.rb12
-rw-r--r--spec/features/issues/issue_detail_spec.rb43
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb75
-rw-r--r--spec/features/issues/markdown_toolbar_spec.rb8
-rw-r--r--spec/features/issues/move_spec.rb19
-rw-r--r--spec/features/issues/new_branch_button_spec.rb62
-rw-r--r--spec/features/issues/note_polling_spec.rb135
-rw-r--r--spec/features/issues/notes_on_issues_spec.rb77
-rw-r--r--spec/features/issues/spam_issues_spec.rb8
-rw-r--r--spec/features/issues/todo_spec.rb16
-rw-r--r--spec/features/issues/update_issues_spec.rb54
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb103
-rw-r--r--spec/features/issues_spec.rb233
-rw-r--r--spec/features/login_spec.rb71
-rw-r--r--spec/features/markdown_spec.rb20
-rw-r--r--spec/features/merge_requests/assign_issues_spec.rb12
-rw-r--r--spec/features/merge_requests/award_spec.rb14
-rw-r--r--spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb18
-rw-r--r--spec/features/merge_requests/cherry_pick_spec.rb10
-rw-r--r--spec/features/merge_requests/closes_issues_spec.rb29
-rw-r--r--spec/features/merge_requests/conflicts_spec.rb55
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb48
-rw-r--r--spec/features/merge_requests/created_from_fork_spec.rb40
-rw-r--r--spec/features/merge_requests/deleted_source_branch_spec.rb16
-rw-r--r--spec/features/merge_requests/diff_notes_avatars_spec.rb36
-rw-r--r--spec/features/merge_requests/diff_notes_resolve_spec.rb14
-rw-r--r--spec/features/merge_requests/diffs_spec.rb50
-rw-r--r--spec/features/merge_requests/discussion_spec.rb55
-rw-r--r--spec/features/merge_requests/edit_mr_spec.rb13
-rw-r--r--spec/features/merge_requests/filter_by_labels_spec.rb16
-rw-r--r--spec/features/merge_requests/filter_by_milestone_spec.rb8
-rw-r--r--spec/features/merge_requests/filter_merge_requests_spec.rb118
-rw-r--r--spec/features/merge_requests/form_spec.rb50
-rw-r--r--spec/features/merge_requests/merge_commit_message_toggle_spec.rb27
-rw-r--r--spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb30
-rw-r--r--spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb46
-rw-r--r--spec/features/merge_requests/mini_pipeline_graph_spec.rb40
-rw-r--r--spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb54
-rw-r--r--spec/features/merge_requests/pipelines_spec.rb99
-rw-r--r--spec/features/merge_requests/reset_filters_spec.rb8
-rw-r--r--spec/features/merge_requests/target_branch_spec.rb18
-rw-r--r--spec/features/merge_requests/toggle_whitespace_changes_spec.rb6
-rw-r--r--spec/features/merge_requests/toggler_behavior_spec.rb8
-rw-r--r--spec/features/merge_requests/update_merge_requests_spec.rb35
-rw-r--r--spec/features/merge_requests/user_lists_merge_requests_spec.rb24
-rw-r--r--spec/features/merge_requests/user_posts_diff_notes_spec.rb26
-rw-r--r--spec/features/merge_requests/user_posts_notes_spec.rb22
-rw-r--r--spec/features/merge_requests/user_sees_system_notes_spec.rb10
-rw-r--r--spec/features/merge_requests/user_uses_slash_commands_spec.rb59
-rw-r--r--spec/features/merge_requests/versions_spec.rb89
-rw-r--r--spec/features/merge_requests/widget_deployments_spec.rb18
-rw-r--r--spec/features/merge_requests/widget_spec.rb126
-rw-r--r--spec/features/merge_requests/wip_message_spec.rb18
-rw-r--r--spec/features/milestone_spec.rb33
-rw-r--r--spec/features/milestones/milestones_spec.rb98
-rw-r--r--spec/features/milestones/show_spec.rb12
-rw-r--r--spec/features/oauth_login_spec.rb114
-rw-r--r--spec/features/participants_autocomplete_spec.rb13
-rw-r--r--spec/features/password_reset_spec.rb6
-rw-r--r--spec/features/profile_spec.rb30
-rw-r--r--spec/features/profiles/account_spec.rb64
-rw-r--r--spec/features/profiles/chat_names_spec.rb4
-rw-r--r--spec/features/profiles/gpg_keys_spec.rb58
-rw-r--r--spec/features/profiles/keys_spec.rb4
-rw-r--r--spec/features/profiles/oauth_applications_spec.rb4
-rw-r--r--spec/features/profiles/password_spec.rb82
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb22
-rw-r--r--spec/features/profiles/preferences_spec.rb32
-rw-r--r--spec/features/profiles/user_changes_notified_of_own_activity_spec.rb4
-rw-r--r--spec/features/profiles/user_visits_notifications_tab_spec.rb21
-rw-r--r--spec/features/projects/activity/rss_spec.rb12
-rw-r--r--spec/features/projects/artifacts/browse_spec.rb25
-rw-r--r--spec/features/projects/artifacts/download_spec.rb61
-rw-r--r--spec/features/projects/artifacts/file_spec.rb78
-rw-r--r--spec/features/projects/artifacts/raw_spec.rb25
-rw-r--r--spec/features/projects/badges/coverage_spec.rb9
-rw-r--r--spec/features/projects/badges/list_spec.rb18
-rw-r--r--spec/features/projects/badges/pipeline_badge_spec.rb70
-rw-r--r--spec/features/projects/blobs/blob_line_permalink_updater_spec.rb20
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb500
-rw-r--r--spec/features/projects/blobs/edit_spec.rb44
-rw-r--r--spec/features/projects/blobs/shortcuts_blob_spec.rb8
-rw-r--r--spec/features/projects/blobs/user_create_spec.rb94
-rw-r--r--spec/features/projects/branches/download_buttons_spec.rb12
-rw-r--r--spec/features/projects/branches/new_branch_ref_dropdown_spec.rb48
-rw-r--r--spec/features/projects/branches_spec.rb155
-rw-r--r--spec/features/projects/builds_spec.rb477
-rw-r--r--spec/features/projects/commit/builds_spec.rb6
-rw-r--r--spec/features/projects/commit/cherry_pick_spec.rb19
-rw-r--r--spec/features/projects/commit/mini_pipeline_graph_spec.rb12
-rw-r--r--spec/features/projects/commit/rss_spec.rb14
-rw-r--r--spec/features/projects/compare_spec.rb15
-rw-r--r--spec/features/projects/deploy_keys_spec.rb16
-rw-r--r--spec/features/projects/developer_views_empty_project_instructions_spec.rb20
-rw-r--r--spec/features/projects/diffs/diff_show_spec.rb137
-rw-r--r--spec/features/projects/edit_spec.rb6
-rw-r--r--spec/features/projects/environments/environment_metrics_spec.rb10
-rw-r--r--spec/features/projects/environments/environment_spec.rb79
-rw-r--r--spec/features/projects/environments/environments_spec.rb30
-rw-r--r--spec/features/projects/features_visibility_spec.rb137
-rw-r--r--spec/features/projects/files/browse_files_spec.rb23
-rw-r--r--spec/features/projects/files/creating_a_file_spec.rb13
-rw-r--r--spec/features/projects/files/dockerfile_dropdown_spec.rb12
-rw-r--r--spec/features/projects/files/download_buttons_spec.rb13
-rw-r--r--spec/features/projects/files/edit_file_soft_wrap_spec.rb8
-rw-r--r--spec/features/projects/files/editing_a_file_spec.rb8
-rw-r--r--spec/features/projects/files/files_sort_submodules_with_folders_spec.rb6
-rw-r--r--spec/features/projects/files/find_file_keyboard_spec.rb10
-rw-r--r--spec/features/projects/files/find_files_spec.rb17
-rw-r--r--spec/features/projects/files/gitignore_dropdown_spec.rb12
-rw-r--r--spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb12
-rw-r--r--spec/features/projects/files/project_owner_creates_license_file_spec.rb18
-rw-r--r--spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb14
-rw-r--r--spec/features/projects/files/template_type_dropdown_spec.rb14
-rw-r--r--spec/features/projects/files/undo_template_spec.rb12
-rw-r--r--spec/features/projects/gfm_autocomplete_load_spec.rb12
-rw-r--r--spec/features/projects/group_links_spec.rb21
-rw-r--r--spec/features/projects/guest_navigation_menu_spec.rb68
-rw-r--r--spec/features/projects/import_export/export_file_spec.rb8
-rw-r--r--spec/features/projects/import_export/import_file_spec.rb25
-rw-r--r--spec/features/projects/import_export/namespace_export_file_spec.rb10
-rw-r--r--spec/features/projects/import_export/test_project_export.tar.gzbin679892 -> 681481 bytes
-rw-r--r--spec/features/projects/issuable_counts_caching_spec.rb132
-rw-r--r--spec/features/projects/issuable_templates_spec.rb41
-rw-r--r--spec/features/projects/issues/list_spec.rb6
-rw-r--r--spec/features/projects/issues/rss_spec.rb17
-rw-r--r--spec/features/projects/jobs_spec.rb546
-rw-r--r--spec/features/projects/labels/issues_sorted_by_priority_spec.rb10
-rw-r--r--spec/features/projects/labels/subscription_spec.rb10
-rw-r--r--spec/features/projects/labels/update_prioritization_spec.rb40
-rw-r--r--spec/features/projects/main/download_buttons_spec.rb12
-rw-r--r--spec/features/projects/main/rss_spec.rb10
-rw-r--r--spec/features/projects/members/anonymous_user_sees_members_spec.rb8
-rw-r--r--spec/features/projects/members/group_links_spec.rb16
-rw-r--r--spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb6
-rw-r--r--spec/features/projects/members/group_member_cannot_request_access_to_his_group_project_spec.rb6
-rw-r--r--spec/features/projects/members/group_members_spec.rb14
-rw-r--r--spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb6
-rw-r--r--spec/features/projects/members/list_spec.rb6
-rw-r--r--spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb10
-rw-r--r--spec/features/projects/members/master_manages_access_requests_spec.rb12
-rw-r--r--spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb6
-rw-r--r--spec/features/projects/members/member_leaves_project_spec.rb8
-rw-r--r--spec/features/projects/members/owner_cannot_leave_project_spec.rb6
-rw-r--r--spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb6
-rw-r--r--spec/features/projects/members/sorting_spec.rb21
-rw-r--r--spec/features/projects/members/user_requests_access_spec.rb22
-rw-r--r--spec/features/projects/merge_request_button_spec.rb48
-rw-r--r--spec/features/projects/merge_requests/list_spec.rb14
-rw-r--r--spec/features/projects/milestones/milestone_spec.rb17
-rw-r--r--spec/features/projects/milestones/milestones_sorting_spec.rb8
-rw-r--r--spec/features/projects/milestones/new_spec.rb18
-rw-r--r--spec/features/projects/new_project_spec.rb93
-rw-r--r--spec/features/projects/no_password_spec.rb69
-rw-r--r--spec/features/projects/pages_spec.rb12
-rw-r--r--spec/features/projects/pipeline_schedules_spec.rb308
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb89
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb142
-rw-r--r--spec/features/projects/project_settings_spec.rb177
-rw-r--r--spec/features/projects/ref_switcher_spec.rb18
-rw-r--r--spec/features/projects/services/jira_service_spec.rb89
-rw-r--r--spec/features/projects/services/mattermost_slash_command_spec.rb28
-rw-r--r--spec/features/projects/services/slack_service_spec.rb6
-rw-r--r--spec/features/projects/services/slack_slash_command_spec.rb22
-rw-r--r--spec/features/projects/settings/integration_settings_spec.rb141
-rw-r--r--spec/features/projects/settings/merge_requests_settings_spec.rb21
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb10
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb95
-rw-r--r--spec/features/projects/settings/visibility_settings_spec.rb14
-rw-r--r--spec/features/projects/shortcuts_spec.rb6
-rw-r--r--spec/features/projects/show_project_spec.rb20
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb86
-rw-r--r--spec/features/projects/snippets/show_spec.rb144
-rw-r--r--spec/features/projects/snippets_spec.rb42
-rw-r--r--spec/features/projects/sub_group_issuables_spec.rb32
-rw-r--r--spec/features/projects/tags/download_buttons_spec.rb12
-rw-r--r--spec/features/projects/tree/rss_spec.rb10
-rw-r--r--spec/features/projects/user_browses_files_spec.rb188
-rw-r--r--spec/features/projects/user_create_dir_spec.rb71
-rw-r--r--spec/features/projects/user_creates_directory_spec.rb87
-rw-r--r--spec/features/projects/user_creates_files_spec.rb153
-rw-r--r--spec/features/projects/user_creates_project_spec.rb27
-rw-r--r--spec/features/projects/user_deletes_files_spec.rb68
-rw-r--r--spec/features/projects/user_edits_files_spec.rb138
-rw-r--r--spec/features/projects/user_replaces_files_spec.rb87
-rw-r--r--spec/features/projects/user_uploads_files_spec.rb82
-rw-r--r--spec/features/projects/view_on_env_spec.rb36
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb65
-rw-r--r--spec/features/projects/wiki/shortcuts_spec.rb8
-rw-r--r--spec/features/projects/wiki/user_creates_wiki_page_spec.rb75
-rw-r--r--spec/features/projects/wiki/user_git_access_wiki_page_spec.rb10
-rw-r--r--spec/features/projects/wiki/user_updates_wiki_page_spec.rb28
-rw-r--r--spec/features/projects/wiki/user_views_project_wiki_page_spec.rb13
-rw-r--r--spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb14
-rw-r--r--spec/features/projects_spec.rb72
-rw-r--r--spec/features/protected_branches/access_control_ce_spec.rb91
-rw-r--r--spec/features/protected_branches_spec.rb23
-rw-r--r--spec/features/protected_tags/access_control_ce_spec.rb46
-rw-r--r--spec/features/protected_tags_spec.rb22
-rw-r--r--spec/features/raven_js_spec.rb23
-rw-r--r--spec/features/reportable_note/commit_spec.rb33
-rw-r--r--spec/features/reportable_note/issue_spec.rb17
-rw-r--r--spec/features/reportable_note/merge_request_spec.rb26
-rw-r--r--spec/features/reportable_note/snippets_spec.rb22
-rw-r--r--spec/features/runners_spec.rb27
-rw-r--r--spec/features/search_spec.rb69
-rw-r--r--spec/features/security/admin_access_spec.rb2
-rw-r--r--spec/features/security/dashboard_access_spec.rb2
-rw-r--r--spec/features/security/group/internal_access_spec.rb3
-rw-r--r--spec/features/security/group/private_access_spec.rb3
-rw-r--r--spec/features/security/group/public_access_spec.rb3
-rw-r--r--spec/features/security/profile_access_spec.rb2
-rw-r--r--spec/features/security/project/internal_access_spec.rb143
-rw-r--r--spec/features/security/project/private_access_spec.rb145
-rw-r--r--spec/features/security/project/public_access_spec.rb143
-rw-r--r--spec/features/security/project/snippet/internal_access_spec.rb16
-rw-r--r--spec/features/security/project/snippet/private_access_spec.rb12
-rw-r--r--spec/features/security/project/snippet/public_access_spec.rb20
-rw-r--r--spec/features/signup_spec.rb10
-rw-r--r--spec/features/snippets/create_snippet_spec.rb34
-rw-r--r--spec/features/snippets/explore_spec.rb29
-rw-r--r--spec/features/snippets/internal_snippet_spec.rb23
-rw-r--r--spec/features/snippets/notes_on_personal_snippets_spec.rb124
-rw-r--r--spec/features/snippets/public_snippets_spec.rb3
-rw-r--r--spec/features/snippets/search_snippets_spec.rb6
-rw-r--r--spec/features/snippets/show_spec.rb138
-rw-r--r--spec/features/snippets/user_creates_snippet_spec.rb107
-rw-r--r--spec/features/snippets/user_deletes_snippet_spec.rb19
-rw-r--r--spec/features/snippets/user_edits_snippet_spec.rb58
-rw-r--r--spec/features/snippets/user_snippets_spec.rb4
-rw-r--r--spec/features/snippets_spec.rb4
-rw-r--r--spec/features/tags/master_creates_tag_spec.rb96
-rw-r--r--spec/features/tags/master_deletes_tag_spec.rb12
-rw-r--r--spec/features/tags/master_updates_tag_spec.rb23
-rw-r--r--spec/features/tags/master_views_tags_spec.rb26
-rw-r--r--spec/features/task_lists_spec.rb56
-rw-r--r--spec/features/todos/target_state_spec.rb65
-rw-r--r--spec/features/todos/todos_filtering_spec.rb153
-rw-r--r--spec/features/todos/todos_sorting_spec.rb97
-rw-r--r--spec/features/todos/todos_spec.rb301
-rw-r--r--spec/features/triggers_spec.rb95
-rw-r--r--spec/features/u2f_spec.rb60
-rw-r--r--spec/features/unsubscribe_links_spec.rb14
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_group_spec.rb6
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb6
-rw-r--r--spec/features/uploads/user_uploads_file_to_note_spec.rb82
-rw-r--r--spec/features/user_callout_spec.rb6
-rw-r--r--spec/features/user_can_display_performance_bar_spec.rb86
-rw-r--r--spec/features/users/projects_spec.rb10
-rw-r--r--spec/features/users/rss_spec.rb7
-rw-r--r--spec/features/users/snippets_spec.rb48
-rw-r--r--spec/features/users_spec.rb20
-rw-r--r--spec/features/variables_spec.rb64
-rw-r--r--spec/finders/access_requests_finder_spec.rb4
-rw-r--r--spec/finders/admin/projects_finder_spec.rb142
-rw-r--r--spec/finders/contributed_projects_finder_spec.rb8
-rw-r--r--spec/finders/environments_finder_spec.rb2
-rw-r--r--spec/finders/events_finder_spec.rb44
-rw-r--r--spec/finders/group_members_finder_spec.rb2
-rw-r--r--spec/finders/group_projects_finder_spec.rb10
-rw-r--r--spec/finders/groups_finder_spec.rb108
-rw-r--r--spec/finders/issues_finder_spec.rb196
-rw-r--r--spec/finders/joined_groups_finder_spec.rb2
-rw-r--r--spec/finders/labels_finder_spec.rb18
-rw-r--r--spec/finders/members_finder_spec.rb2
-rw-r--r--spec/finders/merge_requests_finder_spec.rb75
-rw-r--r--spec/finders/milestones_finder_spec.rb90
-rw-r--r--spec/finders/move_to_project_finder_spec.rb20
-rw-r--r--spec/finders/notes_finder_spec.rb15
-rw-r--r--spec/finders/personal_access_tokens_finder_spec.rb84
-rw-r--r--spec/finders/personal_projects_finder_spec.rb10
-rw-r--r--spec/finders/pipeline_schedules_finder_spec.rb41
-rw-r--r--spec/finders/pipelines_finder_spec.rb205
-rw-r--r--spec/finders/projects_finder_spec.rb27
-rw-r--r--spec/finders/snippets_finder_spec.rb129
-rw-r--r--spec/finders/todos_finder_spec.rb6
-rw-r--r--spec/finders/users_finder_spec.rb77
-rw-r--r--spec/fixtures/api/schemas/branch.json12
-rw-r--r--spec/fixtures/api/schemas/deployments.json58
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request.json100
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json15
-rw-r--r--spec/fixtures/api/schemas/issue.json18
-rw-r--r--spec/fixtures/api/schemas/list.json2
-rw-r--r--spec/fixtures/api/schemas/merge_request.json12
-rw-r--r--spec/fixtures/api/schemas/pipeline.json354
-rw-r--r--spec/fixtures/api/schemas/pipeline_schedule.json41
-rw-r--r--spec/fixtures/api/schemas/pipeline_schedules.json4
-rw-r--r--spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json58
-rw-r--r--spec/fixtures/api/schemas/public_api/v3/issues.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v3/merge_requests.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/branch.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/branches.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/comment.json21
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit/basic.json37
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit/detail.json16
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit_note.json19
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit_notes.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit_stats.json14
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commits.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issues.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/merge_requests.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/release.json12
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/tag.json21
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/tags.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/user/admin.json34
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/user/basic.json15
-rw-r--r--spec/fixtures/config/kubeconfig-without-ca.yml18
-rw-r--r--spec/fixtures/config/kubeconfig.yml19
-rw-r--r--spec/fixtures/config/redis_cache_config_with_env.yml2
-rw-r--r--spec/fixtures/config/redis_cache_new_format_host.yml29
-rw-r--r--spec/fixtures/config/redis_cache_new_format_socket.yml6
-rw-r--r--spec/fixtures/config/redis_cache_old_format_host.yml5
-rw-r--r--spec/fixtures/config/redis_cache_old_format_socket.yml3
-rw-r--r--spec/fixtures/config/redis_new_format_host.yml12
-rw-r--r--spec/fixtures/config/redis_queues_config_with_env.yml2
-rw-r--r--spec/fixtures/config/redis_queues_new_format_host.yml29
-rw-r--r--spec/fixtures/config/redis_queues_new_format_socket.yml6
-rw-r--r--spec/fixtures/config/redis_queues_old_format_host.yml5
-rw-r--r--spec/fixtures/config/redis_queues_old_format_socket.yml3
-rw-r--r--spec/fixtures/config/redis_shared_state_config_with_env.yml2
-rw-r--r--spec/fixtures/config/redis_shared_state_new_format_host.yml29
-rw-r--r--spec/fixtures/config/redis_shared_state_new_format_socket.yml6
-rw-r--r--spec/fixtures/config/redis_shared_state_old_format_host.yml5
-rw-r--r--spec/fixtures/config/redis_shared_state_old_format_socket.yml3
-rw-r--r--spec/fixtures/emails/forwarded_new_issue.eml25
-rw-r--r--spec/fixtures/emails/html_empty_link.eml26
-rw-r--r--spec/fixtures/encoding/Japanese.md42
-rw-r--r--spec/fixtures/markdown.md.erb25
-rw-r--r--spec/helpers/application_helper_spec.rb146
-rw-r--r--spec/helpers/auth_helper_spec.rb2
-rw-r--r--spec/helpers/avatars_helper_spec.rb89
-rw-r--r--spec/helpers/award_emoji_helper_spec.rb61
-rw-r--r--spec/helpers/blame_helper_spec.rb59
-rw-r--r--spec/helpers/blob_helper_spec.rb136
-rw-r--r--spec/helpers/broadcast_messages_helper_spec.rb4
-rw-r--r--spec/helpers/button_helper_spec.rb65
-rw-r--r--spec/helpers/ci_status_helper_spec.rb2
-rw-r--r--spec/helpers/commits_helper_spec.rb10
-rw-r--r--spec/helpers/defer_script_tag_helper_spec.rb13
-rw-r--r--spec/helpers/diff_helper_spec.rb108
-rw-r--r--spec/helpers/emails_helper_spec.rb2
-rw-r--r--spec/helpers/events_helper_spec.rb6
-rw-r--r--spec/helpers/form_helper_spec.rb12
-rw-r--r--spec/helpers/gitlab_routing_helper_spec.rb29
-rw-r--r--spec/helpers/groups_helper_spec.rb21
-rw-r--r--spec/helpers/hooks_helper_spec.rb20
-rw-r--r--spec/helpers/import_helper_spec.rb20
-rw-r--r--spec/helpers/issuables_helper_spec.rb154
-rw-r--r--spec/helpers/issues_helper_spec.rb16
-rw-r--r--spec/helpers/labels_helper_spec.rb12
-rw-r--r--spec/helpers/markup_helper_spec.rb28
-rw-r--r--spec/helpers/members_helper_spec.rb6
-rw-r--r--spec/helpers/merge_requests_helper_spec.rb162
-rw-r--r--spec/helpers/milestones_helper_spec.rb38
-rw-r--r--spec/helpers/milestones_routing_helper_spec.rb46
-rw-r--r--spec/helpers/namespaces_helper_spec.rb2
-rw-r--r--spec/helpers/notes_helper_spec.rb234
-rw-r--r--spec/helpers/notifications_helper_spec.rb6
-rw-r--r--spec/helpers/page_layout_helper_spec.rb6
-rw-r--r--spec/helpers/pagination_helper_spec.rb23
-rw-r--r--spec/helpers/preferences_helper_spec.rb16
-rw-r--r--spec/helpers/profiles_helper_spec.rb36
-rw-r--r--spec/helpers/projects_helper_spec.rb191
-rw-r--r--spec/helpers/rss_helper_spec.rb8
-rw-r--r--spec/helpers/search_helper_spec.rb36
-rw-r--r--spec/helpers/storage_health_helper_spec.rb20
-rw-r--r--spec/helpers/submodule_helper_spec.rb47
-rw-r--r--spec/helpers/todos_helper_spec.rb15
-rw-r--r--spec/helpers/u2f_helper_spec.rb49
-rw-r--r--spec/helpers/version_check_helper_spec.rb2
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb10
-rw-r--r--spec/initializers/6_validations_spec.rb23
-rw-r--r--spec/initializers/8_metrics_spec.rb13
-rw-r--r--spec/initializers/secret_token_spec.rb2
-rw-r--r--spec/initializers/settings_spec.rb53
-rw-r--r--spec/initializers/trusted_proxies_spec.rb2
-rw-r--r--spec/javascripts/abuse_reports_spec.js10
-rw-r--r--spec/javascripts/activities_spec.js6
-rw-r--r--spec/javascripts/ajax_loading_spinner_spec.js7
-rw-r--r--spec/javascripts/api_spec.js281
-rw-r--r--spec/javascripts/autosave_spec.js134
-rw-r--r--spec/javascripts/awards_handler_spec.js17
-rw-r--r--spec/javascripts/behaviors/autosize_spec.js2
-rw-r--r--spec/javascripts/behaviors/bind_in_out_spec.js12
-rw-r--r--spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js47
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js23
-rw-r--r--spec/javascripts/behaviors/requires_input_spec.js2
-rw-r--r--spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js51
-rw-r--r--spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js326
-rw-r--r--spec/javascripts/blob/blob_file_dropzone_spec.js42
-rw-r--r--spec/javascripts/blob/create_branch_dropdown_spec.js107
-rw-r--r--spec/javascripts/blob/pdf/index_spec.js6
-rw-r--r--spec/javascripts/blob/pdf/test.pdfbin11956 -> 0 bytes
-rw-r--r--spec/javascripts/blob/target_branch_dropdown_spec.js119
-rw-r--r--spec/javascripts/blob/viewer/index_spec.js184
-rw-r--r--spec/javascripts/boards/board_card_spec.js18
-rw-r--r--spec/javascripts/boards/board_list_spec.js1
-rw-r--r--spec/javascripts/boards/board_new_issue_spec.js208
-rw-r--r--spec/javascripts/boards/boards_store_spec.js19
-rw-r--r--spec/javascripts/boards/components/board_spec.js112
-rw-r--r--spec/javascripts/boards/issue_card_spec.js157
-rw-r--r--spec/javascripts/boards/issue_spec.js76
-rw-r--r--spec/javascripts/boards/list_spec.js62
-rw-r--r--spec/javascripts/boards/mock_data.js3
-rw-r--r--spec/javascripts/boards/modal_store_spec.js12
-rw-r--r--spec/javascripts/bootstrap_linked_tabs_spec.js23
-rw-r--r--spec/javascripts/breakpoints_spec.js15
-rw-r--r--spec/javascripts/build_spec.js314
-rw-r--r--spec/javascripts/ci_status_icon_spec.js44
-rw-r--r--spec/javascripts/close_reopen_report_toggle_spec.js270
-rw-r--r--spec/javascripts/commit/pipelines/mock_data.js89
-rw-r--r--spec/javascripts/commit/pipelines/pipelines_spec.js73
-rw-r--r--spec/javascripts/commits_spec.js45
-rw-r--r--spec/javascripts/copy_as_gfm_spec.js49
-rw-r--r--spec/javascripts/cycle_analytics/limit_warning_component_spec.js3
-rw-r--r--spec/javascripts/datetime_utility_spec.js35
-rw-r--r--spec/javascripts/deploy_keys/components/action_btn_spec.js70
-rw-r--r--spec/javascripts/deploy_keys/components/app_spec.js142
-rw-r--r--spec/javascripts/deploy_keys/components/key_spec.js105
-rw-r--r--spec/javascripts/deploy_keys/components/keys_panel_spec.js71
-rw-r--r--spec/javascripts/diff_comments_store_spec.js6
-rw-r--r--spec/javascripts/droplab/constants_spec.js6
-rw-r--r--spec/javascripts/droplab/drop_down_spec.js57
-rw-r--r--spec/javascripts/droplab/hook_spec.js8
-rw-r--r--spec/javascripts/droplab/plugins/ajax_filter_spec.js72
-rw-r--r--spec/javascripts/droplab/plugins/ajax_spec.js36
-rw-r--r--spec/javascripts/emoji_spec.js429
-rw-r--r--spec/javascripts/environments/environment_actions_spec.js21
-rw-r--r--spec/javascripts/environments/environment_monitoring_spec.js19
-rw-r--r--spec/javascripts/environments/environment_rollback_spec.js24
-rw-r--r--spec/javascripts/environments/environment_spec.js13
-rw-r--r--spec/javascripts/environments/environment_stop_spec.js19
-rw-r--r--spec/javascripts/environments/environment_table_spec.js2
-rw-r--r--spec/javascripts/environments/environment_terminal_button_spec.js9
-rw-r--r--spec/javascripts/environments/environments_store_spec.js30
-rw-r--r--spec/javascripts/environments/folder/environments_folder_view_spec.js4
-rw-r--r--spec/javascripts/extensions/array_spec.js22
-rw-r--r--spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js24
-rw-r--r--spec/javascripts/filtered_search/dropdown_user_spec.js49
-rw-r--r--spec/javascripts/filtered_search/dropdown_utils_spec.js153
-rw-r--r--spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js11
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js173
-rw-r--r--spec/javascripts/filtered_search/filtered_search_token_keys_spec.js3
-rw-r--r--spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js27
-rw-r--r--spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js462
-rw-r--r--spec/javascripts/filtered_search/recent_searches_root_spec.js31
-rw-r--r--spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js18
-rw-r--r--spec/javascripts/filtered_search/services/recent_searches_service_spec.js123
-rw-r--r--spec/javascripts/fixtures/abuse_reports.rb2
-rw-r--r--spec/javascripts/fixtures/balsamiq.rb18
-rw-r--r--spec/javascripts/fixtures/balsamiq_viewer.html.haml1
-rw-r--r--spec/javascripts/fixtures/blob.rb29
-rw-r--r--spec/javascripts/fixtures/boards.rb28
-rw-r--r--spec/javascripts/fixtures/branches.rb2
-rw-r--r--spec/javascripts/fixtures/builds.rb33
-rw-r--r--spec/javascripts/fixtures/dashboard.rb2
-rw-r--r--spec/javascripts/fixtures/deploy_keys.rb36
-rw-r--r--spec/javascripts/fixtures/environments.rb30
-rw-r--r--spec/javascripts/fixtures/environments/metrics.html.haml62
-rw-r--r--spec/javascripts/fixtures/graph.html.haml1
-rw-r--r--spec/javascripts/fixtures/issuable_filter.html.haml2
-rw-r--r--spec/javascripts/fixtures/issues.rb13
-rw-r--r--spec/javascripts/fixtures/jobs.rb33
-rw-r--r--spec/javascripts/fixtures/labels.rb56
-rw-r--r--spec/javascripts/fixtures/line_highlighter.html.haml2
-rw-r--r--spec/javascripts/fixtures/merge_requests.rb23
-rw-r--r--spec/javascripts/fixtures/merge_requests_diffs.rb57
-rw-r--r--spec/javascripts/fixtures/mini_dropdown_graph.html.haml6
-rw-r--r--spec/javascripts/fixtures/oauth_remember_me.html.haml5
-rw-r--r--spec/javascripts/fixtures/pdf.rb18
-rw-r--r--spec/javascripts/fixtures/pipelines.rb35
-rw-r--r--spec/javascripts/fixtures/pipelines_table.html.haml1
-rw-r--r--spec/javascripts/fixtures/project_branches.json5
-rw-r--r--spec/javascripts/fixtures/project_select_combo_button.html.haml6
-rw-r--r--spec/javascripts/fixtures/projects.rb2
-rw-r--r--spec/javascripts/fixtures/prometheus_service.rb30
-rw-r--r--spec/javascripts/fixtures/raw.rb8
-rw-r--r--spec/javascripts/fixtures/services.rb30
-rw-r--r--spec/javascripts/fixtures/snippet.rb27
-rw-r--r--spec/javascripts/fixtures/target_branch_dropdown.html.haml28
-rw-r--r--spec/javascripts/fixtures/todos.rb4
-rw-r--r--spec/javascripts/fixtures/u2f.rb4
-rw-r--r--spec/javascripts/fly_out_nav_spec.js331
-rw-r--r--spec/javascripts/gfm_auto_complete_spec.js22
-rw-r--r--spec/javascripts/gl_dropdown_spec.js57
-rw-r--r--spec/javascripts/gl_emoji_spec.js399
-rw-r--r--spec/javascripts/gl_field_errors_spec.js2
-rw-r--r--spec/javascripts/gl_form_spec.js28
-rw-r--r--spec/javascripts/gpg_badges_spec.js48
-rw-r--r--spec/javascripts/groups/group_identicon_spec.js60
-rw-r--r--spec/javascripts/groups/group_item_spec.js102
-rw-r--r--spec/javascripts/groups/groups_spec.js99
-rw-r--r--spec/javascripts/groups/mock_data.js114
-rw-r--r--spec/javascripts/header_spec.js4
-rw-r--r--spec/javascripts/helpers/class_spec_helper.js4
-rw-r--r--spec/javascripts/helpers/class_spec_helper_spec.js4
-rw-r--r--spec/javascripts/helpers/filtered_search_spec_helper.js17
-rw-r--r--spec/javascripts/helpers/user_mock_data_helper.js16
-rw-r--r--spec/javascripts/helpers/vue_resource_helper.js11
-rw-r--r--spec/javascripts/integrations/integration_settings_form_spec.js199
-rw-r--r--spec/javascripts/issuable_spec.js18
-rw-r--r--spec/javascripts/issuable_time_tracker_spec.js250
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js379
-rw-r--r--spec/javascripts/issue_show/components/description_spec.js129
-rw-r--r--spec/javascripts/issue_show/components/edit_actions_spec.js147
-rw-r--r--spec/javascripts/issue_show/components/edited_spec.js49
-rw-r--r--spec/javascripts/issue_show/components/fields/description_spec.js76
-rw-r--r--spec/javascripts/issue_show/components/fields/description_template_spec.js49
-rw-r--r--spec/javascripts/issue_show/components/fields/project_move_spec.js38
-rw-r--r--spec/javascripts/issue_show/components/fields/title_spec.js50
-rw-r--r--spec/javascripts/issue_show/components/form_spec.js68
-rw-r--r--spec/javascripts/issue_show/components/title_spec.js75
-rw-r--r--spec/javascripts/issue_show/helpers.js10
-rw-r--r--spec/javascripts/issue_show/issue_title_spec.js22
-rw-r--r--spec/javascripts/issue_show/mock_data.js32
-rw-r--r--spec/javascripts/issue_spec.js77
-rw-r--r--spec/javascripts/jobs/header_spec.js63
-rw-r--r--spec/javascripts/jobs/job_details_mediator_spec.js43
-rw-r--r--spec/javascripts/jobs/job_store_spec.js26
-rw-r--r--spec/javascripts/jobs/mock_data.js123
-rw-r--r--spec/javascripts/jobs/sidebar_detail_row_spec.js40
-rw-r--r--spec/javascripts/jobs/sidebar_details_block_spec.js111
-rw-r--r--spec/javascripts/labels_issue_sidebar_spec.js16
-rw-r--r--spec/javascripts/landing_spec.js160
-rw-r--r--spec/javascripts/lazy_loader_spec.js57
-rw-r--r--spec/javascripts/lib/utils/accessor_spec.js78
-rw-r--r--spec/javascripts/lib/utils/ajax_cache_spec.js198
-rw-r--r--spec/javascripts/lib/utils/cache_spec.js65
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js34
-rw-r--r--spec/javascripts/lib/utils/dom_utils_spec.js35
-rw-r--r--spec/javascripts/lib/utils/number_utility_spec.js9
-rw-r--r--spec/javascripts/lib/utils/poll_spec.js133
-rw-r--r--spec/javascripts/lib/utils/sticky_spec.js52
-rw-r--r--spec/javascripts/lib/utils/text_utility_spec.js2
-rw-r--r--spec/javascripts/lib/utils/users_cache_spec.js136
-rw-r--r--spec/javascripts/line_highlighter_spec.js10
-rw-r--r--spec/javascripts/merge_request_notes_spec.js99
-rw-r--r--spec/javascripts/merge_request_spec.js39
-rw-r--r--spec/javascripts/merge_request_tabs_spec.js197
-rw-r--r--spec/javascripts/merge_request_widget_spec.js199
-rw-r--r--spec/javascripts/merged_buttons_spec.js44
-rw-r--r--spec/javascripts/monitoring/mock_data.js4230
-rw-r--r--spec/javascripts/monitoring/monitoring_column_spec.js109
-rw-r--r--spec/javascripts/monitoring/monitoring_deployment_spec.js137
-rw-r--r--spec/javascripts/monitoring/monitoring_flag_spec.js76
-rw-r--r--spec/javascripts/monitoring/monitoring_legends_spec.js111
-rw-r--r--spec/javascripts/monitoring/monitoring_row_spec.js57
-rw-r--r--spec/javascripts/monitoring/monitoring_spec.js49
-rw-r--r--spec/javascripts/monitoring/monitoring_state_spec.js110
-rw-r--r--spec/javascripts/monitoring/monitoring_store_spec.js24
-rw-r--r--spec/javascripts/monitoring/prometheus_graph_spec.js98
-rw-r--r--spec/javascripts/monitoring/prometheus_mock_data.js1014
-rw-r--r--spec/javascripts/new_branch_spec.js2
-rw-r--r--spec/javascripts/notebook/cells/markdown_spec.js57
-rw-r--r--spec/javascripts/notes_spec.js699
-rw-r--r--spec/javascripts/oauth_remember_me_spec.js26
-rw-r--r--spec/javascripts/pager_spec.js2
-rw-r--r--spec/javascripts/pdf/index_spec.js61
-rw-r--r--spec/javascripts/pdf/page_spec.js57
-rw-r--r--spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js178
-rw-r--r--spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js106
-rw-r--r--spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js145
-rw-r--r--spec/javascripts/pipelines/async_button_spec.js44
-rw-r--r--spec/javascripts/pipelines/graph/action_component_spec.js42
-rw-r--r--spec/javascripts/pipelines/graph/dropdown_action_component_spec.js32
-rw-r--r--spec/javascripts/pipelines/graph/graph_component_spec.js55
-rw-r--r--spec/javascripts/pipelines/graph/job_component_spec.js121
-rw-r--r--spec/javascripts/pipelines/graph/job_name_component_spec.js27
-rw-r--r--spec/javascripts/pipelines/graph/mock_data.js232
-rw-r--r--spec/javascripts/pipelines/graph/stage_column_component_spec.js42
-rw-r--r--spec/javascripts/pipelines/header_component_spec.js63
-rw-r--r--spec/javascripts/pipelines/mock_data.js107
-rw-r--r--spec/javascripts/pipelines/nav_controls_spec.js2
-rw-r--r--spec/javascripts/pipelines/pipeline_details_mediator_spec.js41
-rw-r--r--spec/javascripts/pipelines/pipeline_store_spec.js27
-rw-r--r--spec/javascripts/pipelines/pipeline_url_spec.js9
-rw-r--r--spec/javascripts/pipelines/pipelines_actions_spec.js33
-rw-r--r--spec/javascripts/pipelines/pipelines_artifacts_spec.js2
-rw-r--r--spec/javascripts/pipelines/pipelines_spec.js11
-rw-r--r--spec/javascripts/pipelines/pipelines_table_row_spec.js161
-rw-r--r--spec/javascripts/pipelines/pipelines_table_spec.js67
-rw-r--r--spec/javascripts/pipelines/stage_spec.js156
-rw-r--r--spec/javascripts/pipelines/time_ago_spec.js2
-rw-r--r--spec/javascripts/pipelines_spec.js37
-rw-r--r--spec/javascripts/pretty_time_spec.js2
-rw-r--r--spec/javascripts/project_select_combo_button_spec.js105
-rw-r--r--spec/javascripts/project_title_spec.js87
-rw-r--r--spec/javascripts/projects/project_import_gitlab_project_spec.js25
-rw-r--r--spec/javascripts/projects/project_new_spec.js127
-rw-r--r--spec/javascripts/prometheus_metrics/mock_data.js41
-rw-r--r--spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js158
-rw-r--r--spec/javascripts/raven/index_spec.js44
-rw-r--r--spec/javascripts/raven/raven_config_spec.js254
-rw-r--r--spec/javascripts/repo/components/repo_commit_section_spec.js160
-rw-r--r--spec/javascripts/repo/components/repo_edit_button_spec.js51
-rw-r--r--spec/javascripts/repo/components/repo_editor_spec.js49
-rw-r--r--spec/javascripts/repo/components/repo_file_buttons_spec.js75
-rw-r--r--spec/javascripts/repo/components/repo_file_options_spec.js33
-rw-r--r--spec/javascripts/repo/components/repo_file_spec.js136
-rw-r--r--spec/javascripts/repo/components/repo_loading_file_spec.js79
-rw-r--r--spec/javascripts/repo/components/repo_prev_directory_spec.js43
-rw-r--r--spec/javascripts/repo/components/repo_preview_spec.js23
-rw-r--r--spec/javascripts/repo/components/repo_sidebar_spec.js111
-rw-r--r--spec/javascripts/repo/components/repo_tab_spec.js70
-rw-r--r--spec/javascripts/repo/components/repo_tabs_spec.js45
-rw-r--r--spec/javascripts/repo/monaco_loader_spec.js17
-rw-r--r--spec/javascripts/repo/services/repo_service_spec.js121
-rw-r--r--spec/javascripts/search_autocomplete_spec.js9
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js6
-rw-r--r--spec/javascripts/sidebar/assignee_title_spec.js105
-rw-r--r--spec/javascripts/sidebar/assignees_spec.js272
-rw-r--r--spec/javascripts/sidebar/confidential_edit_buttons_spec.js39
-rw-r--r--spec/javascripts/sidebar/confidential_edit_form_buttons_spec.js39
-rw-r--r--spec/javascripts/sidebar/confidential_issue_sidebar_spec.js65
-rw-r--r--spec/javascripts/sidebar/mock_data.js109
-rw-r--r--spec/javascripts/sidebar/sidebar_assignees_spec.js58
-rw-r--r--spec/javascripts/sidebar/sidebar_mediator_spec.js41
-rw-r--r--spec/javascripts/sidebar/sidebar_service_spec.js33
-rw-r--r--spec/javascripts/sidebar/sidebar_store_spec.js85
-rw-r--r--spec/javascripts/signin_tabs_memoizer_spec.js97
-rw-r--r--spec/javascripts/smart_interval_spec.js2
-rw-r--r--spec/javascripts/subbable_resource_spec.js63
-rw-r--r--spec/javascripts/syntax_highlight_spec.js2
-rw-r--r--spec/javascripts/test_bundle.js73
-rw-r--r--spec/javascripts/todos_spec.js6
-rw-r--r--spec/javascripts/u2f/authenticate_spec.js10
-rw-r--r--spec/javascripts/u2f/mock_u2f_device.js6
-rw-r--r--spec/javascripts/u2f/register_spec.js10
-rw-r--r--spec/javascripts/version_check_image_spec.js7
-rw-r--r--spec/javascripts/visibility_select_spec.js10
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js39
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js61
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js179
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js105
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js233
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js51
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js129
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js117
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js18
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js32
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js19
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js51
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js70
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js122
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js33
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js213
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js174
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js55
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js17
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js29
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js16
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js16
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js422
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js16
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js47
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js96
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js214
-rw-r--r--spec/javascripts/vue_mr_widget/mr_widget_options_spec.js361
-rw-r--r--spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js46
-rw-r--r--spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js65
-rw-r--r--spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js22
-rw-r--r--spec/javascripts/vue_shared/ci_action_icons_spec.js27
-rw-r--r--spec/javascripts/vue_shared/ci_status_icon_spec.js27
-rw-r--r--spec/javascripts/vue_shared/components/ci_badge_link_spec.js89
-rw-r--r--spec/javascripts/vue_shared/components/ci_icon_spec.js139
-rw-r--r--spec/javascripts/vue_shared/components/commit_spec.js20
-rw-r--r--spec/javascripts/vue_shared/components/header_ci_component_spec.js98
-rw-r--r--spec/javascripts/vue_shared/components/loading_icon_spec.js53
-rw-r--r--spec/javascripts/vue_shared/components/markdown/field_spec.js154
-rw-r--r--spec/javascripts/vue_shared/components/markdown/header_spec.js67
-rw-r--r--spec/javascripts/vue_shared/components/memory_graph_spec.js143
-rw-r--r--spec/javascripts/vue_shared/components/mock_data.js69
-rw-r--r--spec/javascripts/vue_shared/components/pipelines_table_row_spec.js87
-rw-r--r--spec/javascripts/vue_shared/components/pipelines_table_spec.js67
-rw-r--r--spec/javascripts/vue_shared/components/table_pagination_spec.js304
-rw-r--r--spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js56
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar_image_spec.js54
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar_link_spec.js50
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar_svg_spec.js29
-rw-r--r--spec/javascripts/vue_shared/directives/tooltip_spec.js63
-rw-r--r--spec/javascripts/vue_shared/translate_spec.js90
-rw-r--r--spec/javascripts/zen_mode_spec.js3
-rw-r--r--spec/lib/after_commit_queue_spec.rb15
-rw-r--r--spec/lib/api/helpers/pagination_spec.rb2
-rw-r--r--spec/lib/banzai/cross_project_reference_spec.rb6
-rw-r--r--spec/lib/banzai/filter/abstract_reference_filter_spec.rb47
-rw-r--r--spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb15
-rw-r--r--spec/lib/banzai/filter/autolink_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/blockquote_fence_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/commit_range_reference_filter_spec.rb50
-rw-r--r--spec/lib/banzai/filter/commit_reference_filter_spec.rb28
-rw-r--r--spec/lib/banzai/filter/emoji_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/external_issue_reference_filter_spec.rb19
-rw-r--r--spec/lib/banzai/filter/external_link_filter_spec.rb92
-rw-r--r--spec/lib/banzai/filter/gollum_tags_filter_spec.rb8
-rw-r--r--spec/lib/banzai/filter/html_entity_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/image_lazy_load_filter_spec.rb19
-rw-r--r--spec/lib/banzai/filter/image_link_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/inline_diff_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/issuable_state_filter_spec.rb16
-rw-r--r--spec/lib/banzai/filter/issue_reference_filter_spec.rb111
-rw-r--r--spec/lib/banzai/filter/label_reference_filter_spec.rb154
-rw-r--r--spec/lib/banzai/filter/markdown_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/math_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/merge_request_reference_filter_spec.rb47
-rw-r--r--spec/lib/banzai/filter/milestone_reference_filter_spec.rb260
-rw-r--r--spec/lib/banzai/filter/plantuml_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/redactor_filter_spec.rb38
-rw-r--r--spec/lib/banzai/filter/reference_filter_spec.rb8
-rw-r--r--spec/lib/banzai/filter/relative_link_filter_spec.rb54
-rw-r--r--spec/lib/banzai/filter/sanitization_filter_spec.rb22
-rw-r--r--spec/lib/banzai/filter/snippet_reference_filter_spec.rb44
-rw-r--r--spec/lib/banzai/filter/syntax_highlight_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/table_of_contents_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/upload_link_filter_spec.rb30
-rw-r--r--spec/lib/banzai/filter/user_reference_filter_spec.rb11
-rw-r--r--spec/lib/banzai/filter/video_link_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/wiki_link_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/yaml_front_matter_filter_spec.rb2
-rw-r--r--spec/lib/banzai/issuable_extractor_spec.rb15
-rw-r--r--spec/lib/banzai/note_renderer_spec.rb12
-rw-r--r--spec/lib/banzai/object_renderer_spec.rb4
-rw-r--r--spec/lib/banzai/pipeline/full_pipeline_spec.rb2
-rw-r--r--spec/lib/banzai/pipeline/gfm_pipeline_spec.rb90
-rw-r--r--spec/lib/banzai/pipeline/wiki_pipeline_spec.rb2
-rw-r--r--spec/lib/banzai/redactor_spec.rb24
-rw-r--r--spec/lib/banzai/reference_parser/base_parser_spec.rb148
-rw-r--r--spec/lib/banzai/reference_parser/commit_parser_spec.rb40
-rw-r--r--spec/lib/banzai/reference_parser/commit_range_parser_spec.rb32
-rw-r--r--spec/lib/banzai/reference_parser/external_issue_parser_spec.rb8
-rw-r--r--spec/lib/banzai/reference_parser/issue_parser_spec.rb26
-rw-r--r--spec/lib/banzai/reference_parser/label_parser_spec.rb8
-rw-r--r--spec/lib/banzai/reference_parser/merge_request_parser_spec.rb2
-rw-r--r--spec/lib/banzai/reference_parser/milestone_parser_spec.rb8
-rw-r--r--spec/lib/banzai/reference_parser/snippet_parser_spec.rb193
-rw-r--r--spec/lib/banzai/reference_parser/user_parser_spec.rb54
-rw-r--r--spec/lib/banzai/renderer_spec.rb2
-rw-r--r--spec/lib/bitbucket/paginator_spec.rb2
-rw-r--r--spec/lib/ci/ansi2html_spec.rb2
-rw-r--r--spec/lib/ci/charts_spec.rb14
-rw-r--r--spec/lib/ci/gitlab_ci_yaml_processor_spec.rb400
-rw-r--r--spec/lib/ci/mask_secret_spec.rb2
-rw-r--r--spec/lib/constraints/group_url_constrainer_spec.rb41
-rw-r--r--spec/lib/constraints/project_url_constrainer_spec.rb25
-rw-r--r--spec/lib/constraints/user_url_constrainer_spec.rb23
-rw-r--r--spec/lib/container_registry/blob_spec.rb12
-rw-r--r--spec/lib/container_registry/client_spec.rb39
-rw-r--r--spec/lib/container_registry/path_spec.rb12
-rw-r--r--spec/lib/container_registry/tag_spec.rb30
-rw-r--r--spec/lib/disable_email_interceptor_spec.rb6
-rw-r--r--spec/lib/event_filter_spec.rb22
-rw-r--r--spec/lib/expand_variables_spec.rb6
-rw-r--r--spec/lib/extracts_path_spec.rb19
-rw-r--r--spec/lib/feature_spec.rb26
-rw-r--r--spec/lib/file_size_validator_spec.rb8
-rw-r--r--spec/lib/git_ref_validator_spec.rb25
-rw-r--r--spec/lib/gitlab/allowable_spec.rb4
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb56
-rw-r--r--spec/lib/gitlab/auth/unique_ips_limiter_spec.rb6
-rw-r--r--spec/lib/gitlab/auth_spec.rb53
-rw-r--r--spec/lib/gitlab/backend/shell_spec.rb94
-rw-r--r--spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb199
-rw-r--r--spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb412
-rw-r--r--spec/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder_spec.rb19
-rw-r--r--spec/lib/gitlab/background_migration/move_personal_snippet_files_spec.rb72
-rw-r--r--spec/lib/gitlab/background_migration_spec.rb122
-rw-r--r--spec/lib/gitlab/backup/manager_spec.rb86
-rw-r--r--spec/lib/gitlab/backup/repository_spec.rb117
-rw-r--r--spec/lib/gitlab/badge/build/metadata_spec.rb27
-rw-r--r--spec/lib/gitlab/badge/build/status_spec.rb94
-rw-r--r--spec/lib/gitlab/badge/build/template_spec.rb82
-rw-r--r--spec/lib/gitlab/badge/coverage/metadata_spec.rb2
-rw-r--r--spec/lib/gitlab/badge/coverage/report_spec.rb2
-rw-r--r--spec/lib/gitlab/badge/pipeline/metadata_spec.rb27
-rw-r--r--spec/lib/gitlab/badge/pipeline/status_spec.rb97
-rw-r--r--spec/lib/gitlab/badge/pipeline/template_spec.rb82
-rw-r--r--spec/lib/gitlab/bitbucket_import/importer_spec.rb16
-rw-r--r--spec/lib/gitlab/bitbucket_import/project_creator_spec.rb6
-rw-r--r--spec/lib/gitlab/blame_spec.rb2
-rw-r--r--spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb86
-rw-r--r--spec/lib/gitlab/cache/request_cache_spec.rb133
-rw-r--r--spec/lib/gitlab/changes_list_spec.rb2
-rw-r--r--spec/lib/gitlab/chat_commands/command_spec.rb102
-rw-r--r--spec/lib/gitlab/chat_commands/deploy_spec.rb81
-rw-r--r--spec/lib/gitlab/chat_commands/issue_new_spec.rb78
-rw-r--r--spec/lib/gitlab/chat_commands/issue_search_spec.rb48
-rw-r--r--spec/lib/gitlab/chat_commands/issue_show_spec.rb59
-rw-r--r--spec/lib/gitlab/chat_commands/presenters/access_spec.rb49
-rw-r--r--spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb47
-rw-r--r--spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb17
-rw-r--r--spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb23
-rw-r--r--spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb52
-rw-r--r--spec/lib/gitlab/chat_name_token_spec.rb2
-rw-r--r--spec/lib/gitlab/checks/change_access_spec.rb116
-rw-r--r--spec/lib/gitlab/checks/force_push_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb12
-rw-r--r--spec/lib/gitlab/ci/build/credentials/factory_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/build/credentials/registry_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/build/image_spec.rb61
-rw-r--r--spec/lib/gitlab/ci/build/step_spec.rb49
-rw-r--r--spec/lib/gitlab/ci/config/entry/cache_spec.rb56
-rw-r--r--spec/lib/gitlab/ci/config/entry/environment_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/entry/global_spec.rb50
-rw-r--r--spec/lib/gitlab/ci/config/entry/image_spec.rb113
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb61
-rw-r--r--spec/lib/gitlab/ci/config/entry/jobs_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/config/entry/service_spec.rb119
-rw-r--r--spec/lib/gitlab/ci/config/entry/services_spec.rb43
-rw-r--r--spec/lib/gitlab/ci/config/entry/variables_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/cron_parser_spec.rb88
-rw-r--r--spec/lib/gitlab/ci/stage/seed_spec.rb57
-rw-r--r--spec/lib/gitlab/ci/status/build/action_spec.rb56
-rw-r--r--spec/lib/gitlab/ci/status/build/cancelable_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/status/build/common_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/status/build/factory_spec.rb67
-rw-r--r--spec/lib/gitlab/ci/status/build/play_spec.rb51
-rw-r--r--spec/lib/gitlab/ci/status/build/retryable_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/status/build/stop_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/status/extended_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/status/external/common_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/status/group/common_spec.rb20
-rw-r--r--spec/lib/gitlab/ci/status/group/factory_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/status/pipeline/common_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/status/stage/common_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/stage/factory_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/trace/stream_spec.rb93
-rw-r--r--spec/lib/gitlab/ci_access_spec.rb15
-rw-r--r--spec/lib/gitlab/closing_issue_extractor_spec.rb46
-rw-r--r--spec/lib/gitlab/color_schemes_spec.rb2
-rw-r--r--spec/lib/gitlab/conflict/file_collection_spec.rb4
-rw-r--r--spec/lib/gitlab/conflict/file_spec.rb30
-rw-r--r--spec/lib/gitlab/conflict/parser_spec.rb68
-rw-r--r--spec/lib/gitlab/contributions_calendar_spec.rb44
-rw-r--r--spec/lib/gitlab/current_settings_spec.rb59
-rw-r--r--spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb12
-rw-r--r--spec/lib/gitlab/cycle_analytics/permissions_spec.rb2
-rw-r--r--spec/lib/gitlab/cycle_analytics/shared_event_spec.rb2
-rw-r--r--spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb4
-rw-r--r--spec/lib/gitlab/daemon_spec.rb103
-rw-r--r--spec/lib/gitlab/data_builder/note_spec.rb4
-rw-r--r--spec/lib/gitlab/data_builder/push_spec.rb7
-rw-r--r--spec/lib/gitlab/data_builder/wiki_page_spec.rb21
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb367
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb280
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb289
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb160
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb76
-rw-r--r--spec/lib/gitlab/database/sha_attribute_spec.rb33
-rw-r--r--spec/lib/gitlab/database_spec.rb101
-rw-r--r--spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb74
-rw-r--r--spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb82
-rw-r--r--spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb60
-rw-r--r--spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb66
-rw-r--r--spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb84
-rw-r--r--spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb94
-rw-r--r--spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb53
-rw-r--r--spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb96
-rw-r--r--spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb69
-rw-r--r--spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb95
-rw-r--r--spec/lib/gitlab/dependency_linker_spec.rb85
-rw-r--r--spec/lib/gitlab/diff/diff_refs_spec.rb61
-rw-r--r--spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb12
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb339
-rw-r--r--spec/lib/gitlab/diff/highlight_spec.rb10
-rw-r--r--spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb14
-rw-r--r--spec/lib/gitlab/diff/inline_diff_marker_spec.rb20
-rw-r--r--spec/lib/gitlab/diff/inline_diff_spec.rb2
-rw-r--r--spec/lib/gitlab/diff/line_mapper_spec.rb2
-rw-r--r--spec/lib/gitlab/diff/parallel_diff_spec.rb2
-rw-r--r--spec/lib/gitlab/diff/parser_spec.rb6
-rw-r--r--spec/lib/gitlab/diff/position_spec.rb56
-rw-r--r--spec/lib/gitlab/diff/position_tracer_spec.rb325
-rw-r--r--spec/lib/gitlab/downtime_check_spec.rb32
-rw-r--r--spec/lib/gitlab/email/attachment_uploader_spec.rb2
-rw-r--r--spec/lib/gitlab/email/handler/create_issue_handler_spec.rb2
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb13
-rw-r--r--spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb4
-rw-r--r--spec/lib/gitlab/email/message/repository_push_spec.rb2
-rw-r--r--spec/lib/gitlab/email/receiver_spec.rb30
-rw-r--r--spec/lib/gitlab/email/reply_parser_spec.rb42
-rw-r--r--spec/lib/gitlab/encoding_helper_spec.rb135
-rw-r--r--spec/lib/gitlab/etag_caching/middleware_spec.rb56
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb57
-rw-r--r--spec/lib/gitlab/exclusive_lease_spec.rb15
-rw-r--r--spec/lib/gitlab/fake_application_settings_spec.rb32
-rw-r--r--spec/lib/gitlab/file_detector_spec.rb8
-rw-r--r--spec/lib/gitlab/file_finder_spec.rb21
-rw-r--r--spec/lib/gitlab/fogbugz_import/client_spec.rb2
-rw-r--r--spec/lib/gitlab/gfm/reference_rewriter_spec.rb8
-rw-r--r--spec/lib/gitlab/gfm/uploads_rewriter_spec.rb8
-rw-r--r--spec/lib/gitlab/git/attributes_spec.rb44
-rw-r--r--spec/lib/gitlab/git/blame_spec.rb99
-rw-r--r--spec/lib/gitlab/git/blob_spec.rb111
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb206
-rw-r--r--spec/lib/gitlab/git/compare_spec.rb4
-rw-r--r--spec/lib/gitlab/git/diff_collection_spec.rb91
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb94
-rw-r--r--spec/lib/gitlab/git/encoding_helper_spec.rb88
-rw-r--r--spec/lib/gitlab/git/gitmodules_parser_spec.rb28
-rw-r--r--spec/lib/gitlab/git/hook_spec.rb46
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb675
-rw-r--r--spec/lib/gitlab/git/rev_list_spec.rb6
-rw-r--r--spec/lib/gitlab/git/storage/circuit_breaker_spec.rb329
-rw-r--r--spec/lib/gitlab/git/storage/forked_storage_check_spec.rb58
-rw-r--r--spec/lib/gitlab/git/storage/health_spec.rb87
-rw-r--r--spec/lib/gitlab/git/tag_spec.rb38
-rw-r--r--spec/lib/gitlab/git/tree_spec.rb23
-rw-r--r--spec/lib/gitlab/git/util_spec.rb4
-rw-r--r--spec/lib/gitlab/git_access_spec.rb387
-rw-r--r--spec/lib/gitlab/git_access_wiki_spec.rb12
-rw-r--r--spec/lib/gitlab/git_ref_validator_spec.rb25
-rw-r--r--spec/lib/gitlab/git_spec.rb8
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb129
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_spec.rb58
-rw-r--r--spec/lib/gitlab/gitaly_client/diff_spec.rb30
-rw-r--r--spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb59
-rw-r--r--spec/lib/gitlab/gitaly_client/notification_service_spec.rb17
-rw-r--r--spec/lib/gitlab/gitaly_client/notifications_spec.rb16
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_service_spec.rb94
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_spec.rb41
-rw-r--r--spec/lib/gitlab/gitaly_client_spec.rb115
-rw-r--r--spec/lib/gitlab/github_import/branch_formatter_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/client_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/comment_formatter_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/issuable_formatter_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/issue_formatter_spec.rb14
-rw-r--r--spec/lib/gitlab/github_import/label_formatter_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/milestone_formatter_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/project_creator_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/pull_request_formatter_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/release_formatter_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/user_formatter_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/wiki_formatter_spec.rb6
-rw-r--r--spec/lib/gitlab/gitlab_import/client_spec.rb4
-rw-r--r--spec/lib/gitlab/gitlab_import/importer_spec.rb8
-rw-r--r--spec/lib/gitlab/gitlab_import/project_creator_spec.rb6
-rw-r--r--spec/lib/gitlab/gl_repository_spec.rb19
-rw-r--r--spec/lib/gitlab/google_code_import/client_spec.rb2
-rw-r--r--spec/lib/gitlab/google_code_import/importer_spec.rb6
-rw-r--r--spec/lib/gitlab/google_code_import/project_creator_spec.rb6
-rw-r--r--spec/lib/gitlab/gpg/commit_spec.rb128
-rw-r--r--spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb166
-rw-r--r--spec/lib/gitlab/gpg_spec.rb135
-rw-r--r--spec/lib/gitlab/graphs/commits_spec.rb4
-rw-r--r--spec/lib/gitlab/group_hierarchy_spec.rb77
-rw-r--r--spec/lib/gitlab/health_checks/db_check_spec.rb (renamed from spec/lib/gitlab/healthchecks/db_check_spec.rb)0
-rw-r--r--spec/lib/gitlab/health_checks/fs_shards_check_spec.rb200
-rw-r--r--spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb41
-rw-r--r--spec/lib/gitlab/health_checks/redis/cache_check_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/redis/queues_check_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/redis/redis_check_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/redis/shared_state_check_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/simple_check_shared.rb66
-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/highlight_spec.rb54
-rw-r--r--spec/lib/gitlab/i18n_spec.rb29
-rw-r--r--spec/lib/gitlab/identifier_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml48
-rw-r--r--spec/lib/gitlab/import_export/attribute_cleaner_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/attribute_configuration_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/avatar_restorer_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/avatar_saver_spec.rb6
-rw-r--r--spec/lib/gitlab/import_export/file_importer_spec.rb7
-rw-r--r--spec/lib/gitlab/import_export/fork_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/hash_util_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/import_export_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/members_mapper_spec.rb16
-rw-r--r--spec/lib/gitlab/import_export/merge_request_parser_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/model_configuration_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/project.json128
-rw-r--r--spec/lib/gitlab/import_export/project.light.json100
-rw-r--r--spec/lib/gitlab/import_export/project_tree_restorer_spec.rb62
-rw-r--r--spec/lib/gitlab/import_export/project_tree_saver_spec.rb33
-rw-r--r--spec/lib/gitlab/import_export/reader_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/relation_factory_spec.rb8
-rw-r--r--spec/lib/gitlab/import_export/repo_restorer_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/repo_saver_spec.rb6
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml96
-rw-r--r--spec/lib/gitlab/import_export/version_checker_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb27
-rw-r--r--spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb27
-rw-r--r--spec/lib/gitlab/import_sources_spec.rb2
-rw-r--r--spec/lib/gitlab/incoming_email_spec.rb2
-rw-r--r--spec/lib/gitlab/issuable_metadata_spec.rb59
-rw-r--r--spec/lib/gitlab/issuable_sorter_spec.rb44
-rw-r--r--spec/lib/gitlab/job_waiter_spec.rb10
-rw-r--r--spec/lib/gitlab/key_fingerprint_spec.rb82
-rw-r--r--spec/lib/gitlab/kubernetes_spec.rb34
-rw-r--r--spec/lib/gitlab/lazy_spec.rb2
-rw-r--r--spec/lib/gitlab/ldap/access_spec.rb14
-rw-r--r--spec/lib/gitlab/ldap/adapter_spec.rb24
-rw-r--r--spec/lib/gitlab/ldap/auth_hash_spec.rb16
-rw-r--r--spec/lib/gitlab/ldap/authentication_spec.rb14
-rw-r--r--spec/lib/gitlab/ldap/config_spec.rb256
-rw-r--r--spec/lib/gitlab/ldap/person_spec.rb6
-rw-r--r--spec/lib/gitlab/ldap/user_spec.rb50
-rw-r--r--spec/lib/gitlab/lfs_token_spec.rb2
-rw-r--r--spec/lib/gitlab/markup_helper_spec.rb14
-rw-r--r--spec/lib/gitlab/metrics/influx_sampler_spec.rb150
-rw-r--r--spec/lib/gitlab/metrics/instrumentation_spec.rb32
-rw-r--r--spec/lib/gitlab/metrics/rack_middleware_spec.rb12
-rw-r--r--spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb65
-rw-r--r--spec/lib/gitlab/metrics/sampler_spec.rb150
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb101
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb36
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_view_spec.rb8
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb18
-rw-r--r--spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb80
-rw-r--r--spec/lib/gitlab/metrics/transaction_spec.rb28
-rw-r--r--spec/lib/gitlab/metrics/unicorn_sampler_spec.rb108
-rw-r--r--spec/lib/gitlab/metrics_spec.rb243
-rw-r--r--spec/lib/gitlab/middleware/go_spec.rb2
-rw-r--r--spec/lib/gitlab/middleware/rails_queue_duration_spec.rb8
-rw-r--r--spec/lib/gitlab/o_auth/auth_hash_spec.rb16
-rw-r--r--spec/lib/gitlab/o_auth/provider_spec.rb42
-rw-r--r--spec/lib/gitlab/o_auth/user_spec.rb166
-rw-r--r--spec/lib/gitlab/optimistic_locking_spec.rb2
-rw-r--r--spec/lib/gitlab/other_markup_spec.rb4
-rw-r--r--spec/lib/gitlab/otp_key_rotator_spec.rb70
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb425
-rw-r--r--spec/lib/gitlab/performance_bar_spec.rb92
-rw-r--r--spec/lib/gitlab/polling_interval_spec.rb2
-rw-r--r--spec/lib/gitlab/popen_spec.rb15
-rw-r--r--spec/lib/gitlab/project_authorizations_spec.rb73
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb91
-rw-r--r--spec/lib/gitlab/project_template_spec.rb65
-rw-r--r--spec/lib/gitlab/project_transfer_spec.rb4
-rw-r--r--spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb246
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb20
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb17
-rw-r--r--spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb37
-rw-r--r--spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb134
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb221
-rw-r--r--spec/lib/gitlab/prometheus_spec.rb143
-rw-r--r--spec/lib/gitlab/quick_actions/command_definition_spec.rb225
-rw-r--r--spec/lib/gitlab/quick_actions/dsl_spec.rb123
-rw-r--r--spec/lib/gitlab/quick_actions/extractor_spec.rb260
-rw-r--r--spec/lib/gitlab/quick_actions/substitution_definition_spec.rb42
-rw-r--r--spec/lib/gitlab/redis/cache_spec.rb20
-rw-r--r--spec/lib/gitlab/redis/queues_spec.rb20
-rw-r--r--spec/lib/gitlab/redis/shared_state_spec.rb20
-rw-r--r--spec/lib/gitlab/redis/wrapper_spec.rb27
-rw-r--r--spec/lib/gitlab/redis_spec.rb211
-rw-r--r--spec/lib/gitlab/reference_extractor_spec.rb39
-rw-r--r--spec/lib/gitlab/regex_spec.rb43
-rw-r--r--spec/lib/gitlab/repo_path_spec.rb75
-rw-r--r--spec/lib/gitlab/request_context_spec.rb8
-rw-r--r--spec/lib/gitlab/request_forgery_protection_spec.rb89
-rw-r--r--spec/lib/gitlab/request_profiler_spec.rb27
-rw-r--r--spec/lib/gitlab/route_map_spec.rb39
-rw-r--r--spec/lib/gitlab/saml/user_spec.rb68
-rw-r--r--spec/lib/gitlab/search_results_spec.rb18
-rw-r--r--spec/lib/gitlab/serializer/pagination_spec.rb4
-rw-r--r--spec/lib/gitlab/shell_spec.rb299
-rw-r--r--spec/lib/gitlab/sherlock/collection_spec.rb2
-rw-r--r--spec/lib/gitlab/sherlock/file_sample_spec.rb6
-rw-r--r--spec/lib/gitlab/sherlock/line_profiler_spec.rb8
-rw-r--r--spec/lib/gitlab/sherlock/line_sample_spec.rb2
-rw-r--r--spec/lib/gitlab/sherlock/location_spec.rb2
-rw-r--r--spec/lib/gitlab/sherlock/middleware_spec.rb6
-rw-r--r--spec/lib/gitlab/sherlock/query_spec.rb6
-rw-r--r--spec/lib/gitlab/sherlock/transaction_spec.rb30
-rw-r--r--spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb4
-rw-r--r--spec/lib/gitlab/sidekiq_status/server_middleware_spec.rb4
-rw-r--r--spec/lib/gitlab/sidekiq_status_spec.rb12
-rw-r--r--spec/lib/gitlab/sidekiq_throttler_spec.rb4
-rw-r--r--spec/lib/gitlab/slash_commands/command_definition_spec.rb173
-rw-r--r--spec/lib/gitlab/slash_commands/command_spec.rb111
-rw-r--r--spec/lib/gitlab/slash_commands/deploy_spec.rb120
-rw-r--r--spec/lib/gitlab/slash_commands/dsl_spec.rb77
-rw-r--r--spec/lib/gitlab/slash_commands/extractor_spec.rb223
-rw-r--r--spec/lib/gitlab/slash_commands/issue_new_spec.rb78
-rw-r--r--spec/lib/gitlab/slash_commands/issue_search_spec.rb48
-rw-r--r--spec/lib/gitlab/slash_commands/issue_show_spec.rb59
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/access_spec.rb49
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb33
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb17
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb25
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb52
-rw-r--r--spec/lib/gitlab/sql/glob_spec.rb53
-rw-r--r--spec/lib/gitlab/sql/recursive_cte_spec.rb49
-rw-r--r--spec/lib/gitlab/sql/union_spec.rb4
-rw-r--r--spec/lib/gitlab/string_range_marker_spec.rb36
-rw-r--r--spec/lib/gitlab/string_regex_marker_spec.rb18
-rw-r--r--spec/lib/gitlab/template/gitignore_template_spec.rb2
-rw-r--r--spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb2
-rw-r--r--spec/lib/gitlab/template/issue_template_spec.rb45
-rw-r--r--spec/lib/gitlab/template/merge_request_template_spec.rb45
-rw-r--r--spec/lib/gitlab/untrusted_regexp_spec.rb98
-rw-r--r--spec/lib/gitlab/upgrader_spec.rb4
-rw-r--r--spec/lib/gitlab/uploads_transfer_spec.rb11
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb36
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb37
-rw-r--r--spec/lib/gitlab/url_sanitizer_spec.rb11
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb41
-rw-r--r--spec/lib/gitlab/user_access_spec.rb60
-rw-r--r--spec/lib/gitlab/user_activities_spec.rb34
-rw-r--r--spec/lib/gitlab/utils_spec.rb27
-rw-r--r--spec/lib/gitlab/version_info_spec.rb6
-rw-r--r--spec/lib/gitlab/view/presenter/base_spec.rb8
-rw-r--r--spec/lib/gitlab/view/presenter/delegated_spec.rb4
-rw-r--r--spec/lib/gitlab/visibility_level_spec.rb33
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb78
-rw-r--r--spec/lib/gitlab_spec.rb2
-rw-r--r--spec/lib/json_web_token/rsa_token_spec.rb12
-rw-r--r--spec/lib/json_web_token/token_spec.rb5
-rw-r--r--spec/lib/light_url_builder_spec.rb119
-rw-r--r--spec/lib/mattermost/command_spec.rb16
-rw-r--r--spec/lib/mattermost/session_spec.rb19
-rw-r--r--spec/lib/mattermost/team_spec.rb12
-rw-r--r--spec/lib/repository_cache_spec.rb6
-rw-r--r--spec/lib/rspec_flaky/example_spec.rb89
-rw-r--r--spec/lib/rspec_flaky/flaky_example_spec.rb104
-rw-r--r--spec/lib/rspec_flaky/listener_spec.rb178
-rw-r--r--spec/lib/system_check/simple_executor_spec.rb247
-rw-r--r--spec/lib/system_check_spec.rb36
-rw-r--r--spec/mailers/abuse_report_mailer_spec.rb4
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb2
-rw-r--r--spec/mailers/emails/profile_spec.rb176
-rw-r--r--spec/mailers/notify_spec.rb195
-rw-r--r--spec/migrations/README.md93
-rw-r--r--spec/migrations/active_record/schema_spec.rb (renamed from spec/migrations/schema_spec.rb)0
-rw-r--r--spec/migrations/add_foreign_key_to_merge_requests_spec.rb39
-rw-r--r--spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb33
-rw-r--r--spec/migrations/calculate_conv_dev_index_percentages_spec.rb41
-rw-r--r--spec/migrations/clean_appearance_symlinks_spec.rb46
-rw-r--r--spec/migrations/clean_stage_id_reference_migration_spec.rb34
-rw-r--r--spec/migrations/clean_upload_symlinks_spec.rb46
-rw-r--r--spec/migrations/cleanup_move_system_upload_folder_symlink_spec.rb35
-rw-r--r--spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb32
-rw-r--r--spec/migrations/convert_custom_notification_settings_to_columns_spec.rb118
-rw-r--r--spec/migrations/fill_authorized_projects_spec.rb18
-rw-r--r--spec/migrations/fix_wrongly_renamed_routes_spec.rb73
-rw-r--r--spec/migrations/migrate_build_events_to_pipeline_events_spec.rb74
-rw-r--r--spec/migrations/migrate_build_stage_reference_again_spec.rb62
-rw-r--r--spec/migrations/migrate_old_artifacts_spec.rb117
-rw-r--r--spec/migrations/migrate_pipeline_stages_spec.rb56
-rw-r--r--spec/migrations/migrate_process_commit_worker_jobs_spec.rb60
-rw-r--r--spec/migrations/migrate_stage_id_reference_in_background_spec.rb68
-rw-r--r--spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb4
-rw-r--r--spec/migrations/migrate_user_project_view_spec.rb9
-rw-r--r--spec/migrations/move_personal_snippets_files_spec.rb180
-rw-r--r--spec/migrations/move_system_upload_folder_spec.rb80
-rw-r--r--spec/migrations/move_uploads_to_system_dir_spec.rb68
-rw-r--r--spec/migrations/remove_duplicate_mr_events_spec.rb26
-rw-r--r--spec/migrations/rename_duplicated_variable_key_spec.rb34
-rw-r--r--spec/migrations/rename_more_reserved_project_names_spec.rb6
-rw-r--r--spec/migrations/rename_reserved_project_names_spec.rb6
-rw-r--r--spec/migrations/rename_users_with_renamed_namespace_spec.rb22
-rw-r--r--spec/migrations/schedule_merge_request_diff_migrations_spec.rb59
-rw-r--r--spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb66
-rw-r--r--spec/migrations/update_retried_for_ci_build_spec.rb17
-rw-r--r--spec/migrations/update_upload_paths_to_system_spec.rb53
-rw-r--r--spec/models/ability_spec.rb87
-rw-r--r--spec/models/abuse_report_spec.rb10
-rw-r--r--spec/models/appearance_spec.rb37
-rw-r--r--spec/models/application_setting_spec.rb238
-rw-r--r--spec/models/award_emoji_spec.rb2
-rw-r--r--spec/models/blob_spec.rb376
-rw-r--r--spec/models/blob_viewer/base_spec.rb149
-rw-r--r--spec/models/blob_viewer/changelog_spec.rb27
-rw-r--r--spec/models/blob_viewer/composer_json_spec.rb25
-rw-r--r--spec/models/blob_viewer/gemspec_spec.rb25
-rw-r--r--spec/models/blob_viewer/gitlab_ci_yml_spec.rb32
-rw-r--r--spec/models/blob_viewer/license_spec.rb34
-rw-r--r--spec/models/blob_viewer/package_json_spec.rb25
-rw-r--r--spec/models/blob_viewer/podspec_json_spec.rb25
-rw-r--r--spec/models/blob_viewer/podspec_spec.rb25
-rw-r--r--spec/models/blob_viewer/readme_spec.rb49
-rw-r--r--spec/models/blob_viewer/route_map_spec.rb38
-rw-r--r--spec/models/blob_viewer/server_side_spec.rb41
-rw-r--r--spec/models/broadcast_message_spec.rb41
-rw-r--r--spec/models/chat_name_spec.rb2
-rw-r--r--spec/models/chat_team_spec.rb2
-rw-r--r--spec/models/ci/artifact_blob_spec.rb44
-rw-r--r--spec/models/ci/build_spec.rb425
-rw-r--r--spec/models/ci/group_spec.rb44
-rw-r--r--spec/models/ci/group_variable_spec.rb31
-rw-r--r--spec/models/ci/legacy_stage_spec.rb273
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb137
-rw-r--r--spec/models/ci/pipeline_schedule_variable_spec.rb7
-rw-r--r--spec/models/ci/pipeline_spec.rb192
-rw-r--r--spec/models/ci/pipeline_variable_spec.rb8
-rw-r--r--spec/models/ci/runner_spec.rb42
-rw-r--r--spec/models/ci/stage_spec.rb229
-rw-r--r--spec/models/ci/trigger_schedule_spec.rb76
-rw-r--r--spec/models/ci/trigger_spec.rb5
-rw-r--r--spec/models/ci/variable_spec.rb48
-rw-r--r--spec/models/commit_range_spec.rb12
-rw-r--r--spec/models/commit_spec.rb91
-rw-r--r--spec/models/commit_status_spec.rb115
-rw-r--r--spec/models/compare_spec.rb2
-rw-r--r--spec/models/concerns/access_requestable_spec.rb12
-rw-r--r--spec/models/concerns/awardable_spec.rb4
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb6
-rw-r--r--spec/models/concerns/case_sensitivity_spec.rb150
-rw-r--r--spec/models/concerns/discussion_on_diff_spec.rb32
-rw-r--r--spec/models/concerns/each_batch_spec.rb53
-rw-r--r--spec/models/concerns/editable_spec.rb11
-rw-r--r--spec/models/concerns/feature_gate_spec.rb19
-rw-r--r--spec/models/concerns/has_status_spec.rb6
-rw-r--r--spec/models/concerns/has_variable_spec.rb43
-rw-r--r--spec/models/concerns/issuable_spec.rb186
-rw-r--r--spec/models/concerns/mentionable_spec.rb67
-rw-r--r--spec/models/concerns/milestoneish_spec.rb39
-rw-r--r--spec/models/concerns/noteable_spec.rb2
-rw-r--r--spec/models/concerns/participable_spec.rb2
-rw-r--r--spec/models/concerns/project_features_compatibility_spec.rb2
-rw-r--r--spec/models/concerns/reactive_caching_spec.rb27
-rw-r--r--spec/models/concerns/relative_positioning_spec.rb4
-rw-r--r--spec/models/concerns/resolvable_discussion_spec.rb14
-rw-r--r--spec/models/concerns/resolvable_note_spec.rb4
-rw-r--r--spec/models/concerns/routable_spec.rb197
-rw-r--r--spec/models/concerns/sha_attribute_spec.rb46
-rw-r--r--spec/models/concerns/spammable_spec.rb4
-rw-r--r--spec/models/concerns/strip_attribute_spec.rb2
-rw-r--r--spec/models/concerns/subscribable_spec.rb2
-rw-r--r--spec/models/concerns/token_authenticatable_spec.rb5
-rw-r--r--spec/models/concerns/uniquify_spec.rb2
-rw-r--r--spec/models/container_repository_spec.rb4
-rw-r--r--spec/models/conversational_development_index/metric_spec.rb11
-rw-r--r--spec/models/cycle_analytics/code_spec.rb2
-rw-r--r--spec/models/cycle_analytics/issue_spec.rb2
-rw-r--r--spec/models/cycle_analytics/plan_spec.rb2
-rw-r--r--spec/models/cycle_analytics/production_spec.rb2
-rw-r--r--spec/models/cycle_analytics/review_spec.rb2
-rw-r--r--spec/models/cycle_analytics/staging_spec.rb2
-rw-r--r--spec/models/cycle_analytics/test_spec.rb4
-rw-r--r--spec/models/deploy_key_spec.rb4
-rw-r--r--spec/models/deploy_keys_project_spec.rb6
-rw-r--r--spec/models/deployment_spec.rb79
-rw-r--r--spec/models/diff_discussion_spec.rb90
-rw-r--r--spec/models/diff_note_spec.rb85
-rw-r--r--spec/models/diff_viewer/base_spec.rb150
-rw-r--r--spec/models/diff_viewer/server_side_spec.rb36
-rw-r--r--spec/models/discussion_spec.rb2
-rw-r--r--spec/models/email_spec.rb2
-rw-r--r--spec/models/environment_spec.rb208
-rw-r--r--spec/models/event_collection_spec.rb51
-rw-r--r--spec/models/event_spec.rb94
-rw-r--r--spec/models/external_issue_spec.rb2
-rw-r--r--spec/models/forked_project_link_spec.rb80
-rw-r--r--spec/models/generic_commit_status_spec.rb13
-rw-r--r--spec/models/global_milestone_spec.rb24
-rw-r--r--spec/models/gpg_key_spec.rb155
-rw-r--r--spec/models/gpg_signature_spec.rb28
-rw-r--r--spec/models/group_label_spec.rb6
-rw-r--r--spec/models/group_milestone_spec.rb8
-rw-r--r--spec/models/group_spec.rb151
-rw-r--r--spec/models/guest_spec.rb18
-rw-r--r--spec/models/hooks/project_hook_spec.rb12
-rw-r--r--spec/models/hooks/service_hook_spec.rb39
-rw-r--r--spec/models/hooks/system_hook_spec.rb46
-rw-r--r--spec/models/hooks/web_hook_log_spec.rb30
-rw-r--r--spec/models/hooks/web_hook_spec.rb95
-rw-r--r--spec/models/identity_spec.rb2
-rw-r--r--spec/models/issue/metrics_spec.rb4
-rw-r--r--spec/models/issue_collection_spec.rb8
-rw-r--r--spec/models/issue_spec.rb160
-rw-r--r--spec/models/key_spec.rb38
-rw-r--r--spec/models/label_link_spec.rb2
-rw-r--r--spec/models/label_priority_spec.rb2
-rw-r--r--spec/models/label_spec.rb19
-rw-r--r--spec/models/legacy_diff_discussion_spec.rb24
-rw-r--r--spec/models/lfs_objects_project_spec.rb4
-rw-r--r--spec/models/list_spec.rb6
-rw-r--r--spec/models/member_spec.rb22
-rw-r--r--spec/models/members/group_member_spec.rb10
-rw-r--r--spec/models/members/project_member_spec.rb29
-rw-r--r--spec/models/merge_request/metrics_spec.rb2
-rw-r--r--spec/models/merge_request_diff_commit_spec.rb15
-rw-r--r--spec/models/merge_request_diff_file_spec.rb36
-rw-r--r--spec/models/merge_request_diff_spec.rb37
-rw-r--r--spec/models/merge_request_spec.rb474
-rw-r--r--spec/models/milestone_spec.rb144
-rw-r--r--spec/models/namespace_spec.rb136
-rw-r--r--spec/models/network/graph_spec.rb23
-rw-r--r--spec/models/note_spec.rb126
-rw-r--r--spec/models/notification_setting_spec.rb41
-rw-r--r--spec/models/pages_domain_spec.rb51
-rw-r--r--spec/models/personal_access_token_spec.rb22
-rw-r--r--spec/models/project_authorization_spec.rb10
-rw-r--r--spec/models/project_feature_spec.rb16
-rw-r--r--spec/models/project_group_link_spec.rb16
-rw-r--r--spec/models/project_label_spec.rb14
-rw-r--r--spec/models/project_services/asana_service_spec.rb8
-rw-r--r--spec/models/project_services/assembla_service_spec.rb4
-rw-r--r--spec/models/project_services/bamboo_service_spec.rb16
-rw-r--r--spec/models/project_services/bugzilla_service_spec.rb10
-rw-r--r--spec/models/project_services/buildkite_service_spec.rb12
-rw-r--r--spec/models/project_services/campfire_service_spec.rb23
-rw-r--r--spec/models/project_services/chat_message/issue_message_spec.rb4
-rw-r--r--spec/models/project_services/chat_message/merge_message_spec.rb4
-rw-r--r--spec/models/project_services/chat_message/note_message_spec.rb4
-rw-r--r--spec/models/project_services/chat_message/pipeline_message_spec.rb25
-rw-r--r--spec/models/project_services/chat_message/push_message_spec.rb6
-rw-r--r--spec/models/project_services/chat_message/wiki_page_message_spec.rb46
-rw-r--r--spec/models/project_services/chat_notification_service_spec.rb6
-rw-r--r--spec/models/project_services/custom_issue_tracker_service_spec.rb10
-rw-r--r--spec/models/project_services/drone_ci_service_spec.rb12
-rw-r--r--spec/models/project_services/emails_on_push_service_spec.rb8
-rw-r--r--spec/models/project_services/external_wiki_service_spec.rb16
-rw-r--r--spec/models/project_services/flowdock_service_spec.rb12
-rw-r--r--spec/models/project_services/gemnasium_service_spec.rb12
-rw-r--r--spec/models/project_services/gitlab_issue_tracker_service_spec.rb35
-rw-r--r--spec/models/project_services/hipchat_service_spec.rb14
-rw-r--r--spec/models/project_services/irker_service_spec.rb12
-rw-r--r--spec/models/project_services/issue_tracker_service_spec.rb2
-rw-r--r--spec/models/project_services/jira_service_spec.rb264
-rw-r--r--spec/models/project_services/kubernetes_service_spec.rb175
-rw-r--r--spec/models/project_services/mattermost_service_spec.rb2
-rw-r--r--spec/models/project_services/mattermost_slash_commands_service_spec.rb28
-rw-r--r--spec/models/project_services/microsoft_teams_service_spec.rb12
-rw-r--r--spec/models/project_services/pipeline_email_service_spec.rb175
-rw-r--r--spec/models/project_services/pipelines_email_service_spec.rb169
-rw-r--r--spec/models/project_services/pivotaltracker_service_spec.rb14
-rw-r--r--spec/models/project_services/prometheus_service_spec.rb51
-rw-r--r--spec/models/project_services/pushover_service_spec.rb12
-rw-r--r--spec/models/project_services/redmine_service_spec.rb14
-rw-r--r--spec/models/project_services/slack_service_spec.rb2
-rw-r--r--spec/models/project_services/slack_slash_commands_service_spec.rb4
-rw-r--r--spec/models/project_services/teamcity_service_spec.rb18
-rw-r--r--spec/models/project_snippet_spec.rb5
-rw-r--r--spec/models/project_spec.rb964
-rw-r--r--spec/models/project_statistics_spec.rb8
-rw-r--r--spec/models/project_team_spec.rb199
-rw-r--r--spec/models/project_wiki_spec.rb120
-rw-r--r--spec/models/protectable_dropdown_spec.rb9
-rw-r--r--spec/models/protected_branch/merge_access_level_spec.rb5
-rw-r--r--spec/models/protected_branch/push_access_level_spec.rb5
-rw-r--r--spec/models/protected_branch_spec.rb43
-rw-r--r--spec/models/protected_tag_spec.rb2
-rw-r--r--spec/models/push_event_payload_spec.rb16
-rw-r--r--spec/models/push_event_spec.rb202
-rw-r--r--spec/models/redirect_route_spec.rb35
-rw-r--r--spec/models/release_spec.rb2
-rw-r--r--spec/models/repository_spec.rb564
-rw-r--r--spec/models/route_spec.rb153
-rw-r--r--spec/models/sent_notification_spec.rb10
-rw-r--r--spec/models/service_spec.rb18
-rw-r--r--spec/models/snippet_blob_spec.rb47
-rw-r--r--spec/models/snippet_spec.rb65
-rw-r--r--spec/models/spam_log_spec.rb2
-rw-r--r--spec/models/subscription_spec.rb2
-rw-r--r--spec/models/system_note_metadata_spec.rb2
-rw-r--r--spec/models/timelog_spec.rb2
-rw-r--r--spec/models/todo_spec.rb48
-rw-r--r--spec/models/tree_spec.rb2
-rw-r--r--spec/models/trending_project_spec.rb10
-rw-r--r--spec/models/upload_spec.rb6
-rw-r--r--spec/models/user_agent_detail_spec.rb2
-rw-r--r--spec/models/user_spec.rb759
-rw-r--r--spec/models/wiki_directory_spec.rb6
-rw-r--r--spec/models/wiki_page_spec.rb87
-rw-r--r--spec/policies/base_policy_spec.rb8
-rw-r--r--spec/policies/ci/build_policy_spec.rb95
-rw-r--r--spec/policies/ci/pipeline_policy_spec.rb66
-rw-r--r--spec/policies/ci/trigger_policy_spec.rb18
-rw-r--r--spec/policies/deploy_key_policy_spec.rb56
-rw-r--r--spec/policies/environment_policy_spec.rb57
-rw-r--r--spec/policies/global_policy_spec.rb54
-rw-r--r--spec/policies/group_policy_spec.rb124
-rw-r--r--spec/policies/issue_policy_spec.rb248
-rw-r--r--spec/policies/issues_policy_spec.rb193
-rw-r--r--spec/policies/personal_snippet_policy_spec.rb141
-rw-r--r--spec/policies/project_policy_spec.rb175
-rw-r--r--spec/policies/project_snippet_policy_spec.rb132
-rw-r--r--spec/policies/user_policy_spec.rb14
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb10
-rw-r--r--spec/presenters/ci/group_variable_presenter_spec.rb63
-rw-r--r--spec/presenters/ci/pipeline_presenter_spec.rb2
-rw-r--r--spec/presenters/ci/variable_presenter_spec.rb63
-rw-r--r--spec/presenters/conversational_development_index/metric_presenter_spec.rb36
-rw-r--r--spec/presenters/merge_request_presenter_spec.rb403
-rw-r--r--spec/presenters/projects/settings/deploy_keys_presenter_spec.rb6
-rw-r--r--spec/requests/api/access_requests_spec.rb2
-rw-r--r--spec/requests/api/api_internal_helpers_spec.rb32
-rw-r--r--spec/requests/api/award_emoji_spec.rb6
-rw-r--r--spec/requests/api/boards_spec.rb4
-rw-r--r--spec/requests/api/branches_spec.rb519
-rw-r--r--spec/requests/api/circuit_breakers_spec.rb57
-rw-r--r--spec/requests/api/commit_statuses_spec.rb114
-rw-r--r--spec/requests/api/commits_spec.rb820
-rw-r--r--spec/requests/api/deploy_keys_spec.rb95
-rw-r--r--spec/requests/api/doorkeeper_access_spec.rb2
-rw-r--r--spec/requests/api/environments_spec.rb11
-rw-r--r--spec/requests/api/events_spec.rb200
-rw-r--r--spec/requests/api/features_spec.rb252
-rw-r--r--spec/requests/api/files_spec.rb43
-rw-r--r--spec/requests/api/group_milestones_spec.rb21
-rw-r--r--spec/requests/api/group_variables_spec.rb221
-rw-r--r--spec/requests/api/groups_spec.rb26
-rw-r--r--spec/requests/api/helpers_spec.rb99
-rw-r--r--spec/requests/api/internal_spec.rb324
-rw-r--r--spec/requests/api/issues_spec.rb181
-rw-r--r--spec/requests/api/jobs_spec.rb167
-rw-r--r--spec/requests/api/keys_spec.rb4
-rw-r--r--spec/requests/api/labels_spec.rb14
-rw-r--r--spec/requests/api/members_spec.rb2
-rw-r--r--spec/requests/api/merge_requests_spec.rb406
-rw-r--r--spec/requests/api/milestones_spec.rb351
-rw-r--r--spec/requests/api/namespaces_spec.rb35
-rw-r--r--spec/requests/api/notes_spec.rb24
-rw-r--r--spec/requests/api/notification_settings_spec.rb6
-rw-r--r--spec/requests/api/oauth_tokens_spec.rb2
-rw-r--r--spec/requests/api/pipeline_schedules_spec.rb299
-rw-r--r--spec/requests/api/pipelines_spec.rb259
-rw-r--r--spec/requests/api/project_hooks_spec.rb8
-rw-r--r--spec/requests/api/project_milestones_spec.rb25
-rw-r--r--spec/requests/api/project_snippets_spec.rb84
-rw-r--r--spec/requests/api/projects_spec.rb405
-rw-r--r--spec/requests/api/protected_branches_spec.rb232
-rw-r--r--spec/requests/api/runner_spec.rb111
-rw-r--r--spec/requests/api/runners_spec.rb34
-rw-r--r--spec/requests/api/services_spec.rb4
-rw-r--r--spec/requests/api/settings_spec.rb22
-rw-r--r--spec/requests/api/snippets_spec.rb84
-rw-r--r--spec/requests/api/system_hooks_spec.rb7
-rw-r--r--spec/requests/api/tags_spec.rb471
-rw-r--r--spec/requests/api/templates_spec.rb12
-rw-r--r--spec/requests/api/todos_spec.rb4
-rw-r--r--spec/requests/api/triggers_spec.rb23
-rw-r--r--spec/requests/api/users_spec.rb399
-rw-r--r--spec/requests/api/v3/award_emoji_spec.rb2
-rw-r--r--spec/requests/api/v3/boards_spec.rb4
-rw-r--r--spec/requests/api/v3/branches_spec.rb13
-rw-r--r--spec/requests/api/v3/commits_spec.rb5
-rw-r--r--spec/requests/api/v3/deploy_keys_spec.rb19
-rw-r--r--spec/requests/api/v3/environments_spec.rb2
-rw-r--r--spec/requests/api/v3/files_spec.rb10
-rw-r--r--spec/requests/api/v3/groups_spec.rb26
-rw-r--r--spec/requests/api/v3/issues_spec.rb51
-rw-r--r--spec/requests/api/v3/labels_spec.rb2
-rw-r--r--spec/requests/api/v3/members_spec.rb2
-rw-r--r--spec/requests/api/v3/merge_requests_spec.rb55
-rw-r--r--spec/requests/api/v3/milestones_spec.rb4
-rw-r--r--spec/requests/api/v3/notes_spec.rb12
-rw-r--r--spec/requests/api/v3/project_hooks_spec.rb10
-rw-r--r--spec/requests/api/v3/project_snippets_spec.rb36
-rw-r--r--spec/requests/api/v3/projects_spec.rb148
-rw-r--r--spec/requests/api/v3/runners_spec.rb16
-rw-r--r--spec/requests/api/v3/services_spec.rb2
-rw-r--r--spec/requests/api/v3/settings_spec.rb6
-rw-r--r--spec/requests/api/v3/snippets_spec.rb16
-rw-r--r--spec/requests/api/v3/system_hooks_spec.rb3
-rw-r--r--spec/requests/api/v3/todos_spec.rb4
-rw-r--r--spec/requests/api/v3/triggers_spec.rb7
-rw-r--r--spec/requests/api/v3/users_spec.rb84
-rw-r--r--spec/requests/api/variables_spec.rb24
-rw-r--r--spec/requests/api/version_spec.rb4
-rw-r--r--spec/requests/ci/api/builds_spec.rb106
-rw-r--r--spec/requests/ci/api/runners_spec.rb11
-rw-r--r--spec/requests/ci/api/triggers_spec.rb16
-rw-r--r--spec/requests/git_http_spec.rb767
-rw-r--r--spec/requests/jwt_controller_spec.rb38
-rw-r--r--spec/requests/lfs_http_spec.rb78
-rw-r--r--spec/requests/openid_connect_spec.rb6
-rw-r--r--spec/requests/projects/artifacts_controller_spec.rb117
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb30
-rw-r--r--spec/requests/request_profiler_spec.rb44
-rw-r--r--spec/routing/admin_routing_spec.rb26
-rw-r--r--spec/routing/environments_spec.rb4
-rw-r--r--spec/routing/notifications_routing_spec.rb14
-rw-r--r--spec/routing/project_routing_spec.rb142
-rw-r--r--spec/routing/routing_spec.rb45
-rw-r--r--spec/rubocop/cop/active_record_dependent_spec.rb33
-rw-r--r--spec/rubocop/cop/active_record_serialize_spec.rb33
-rw-r--r--spec/rubocop/cop/in_batches_spec.rb19
-rw-r--r--spec/rubocop/cop/migration/add_column_with_default_spec.rb41
-rw-r--r--spec/rubocop/cop/migration/add_column_with_default_to_large_table_spec.rb44
-rw-r--r--spec/rubocop/cop/migration/add_timestamps_spec.rb90
-rw-r--r--spec/rubocop/cop/migration/datetime_spec.rb90
-rw-r--r--spec/rubocop/cop/migration/hash_index_spec.rb53
-rw-r--r--spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb41
-rw-r--r--spec/rubocop/cop/migration/safer_boolean_column_spec.rb85
-rw-r--r--spec/rubocop/cop/migration/timestamps_spec.rb99
-rw-r--r--spec/rubocop/cop/migration/update_column_in_batches_spec.rb94
-rw-r--r--spec/rubocop/cop/polymorphic_associations_spec.rb33
-rw-r--r--spec/rubocop/cop/project_path_helper_spec.rb41
-rw-r--r--spec/rubocop/cop/redirect_with_status_spec.rb86
-rw-r--r--spec/rubocop/cop/rspec/single_line_hook_spec.rb66
-rw-r--r--spec/serializers/analytics_build_entity_spec.rb14
-rw-r--r--spec/serializers/analytics_generic_entity_spec.rb39
-rw-r--r--spec/serializers/analytics_issue_entity_spec.rb39
-rw-r--r--spec/serializers/analytics_issue_serializer_spec.rb4
-rw-r--r--spec/serializers/analytics_merge_request_serializer_spec.rb2
-rw-r--r--spec/serializers/analytics_summary_serializer_spec.rb2
-rw-r--r--spec/serializers/build_action_entity_spec.rb13
-rw-r--r--spec/serializers/build_artifact_entity_spec.rb22
-rw-r--r--spec/serializers/build_details_entity_spec.rb116
-rw-r--r--spec/serializers/build_entity_spec.rb53
-rw-r--r--spec/serializers/build_serializer_spec.rb2
-rw-r--r--spec/serializers/deploy_key_entity_spec.rb57
-rw-r--r--spec/serializers/deployment_entity_spec.rb18
-rw-r--r--spec/serializers/environment_serializer_spec.rb18
-rw-r--r--spec/serializers/event_entity_spec.rb13
-rw-r--r--spec/serializers/job_entity_spec.rb132
-rw-r--r--spec/serializers/label_serializer_spec.rb46
-rw-r--r--spec/serializers/merge_request_basic_serializer_spec.rb12
-rw-r--r--spec/serializers/merge_request_entity_spec.rb145
-rw-r--r--spec/serializers/merge_request_serializer_spec.rb37
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb140
-rw-r--r--spec/serializers/pipeline_entity_spec.rb62
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb48
-rw-r--r--spec/serializers/runner_entity_spec.rb23
-rw-r--r--spec/serializers/stage_entity_spec.rb21
-rw-r--r--spec/serializers/status_entity_spec.rb6
-rw-r--r--spec/serializers/user_entity_spec.rb6
-rw-r--r--spec/services/access_token_validation_service_spec.rb45
-rw-r--r--spec/services/after_branch_delete_service_spec.rb2
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb218
-rw-r--r--spec/services/boards/create_service_spec.rb11
-rw-r--r--spec/services/boards/issues/create_service_spec.rb4
-rw-r--r--spec/services/boards/issues/list_service_spec.rb15
-rw-r--r--spec/services/boards/issues/move_service_spec.rb6
-rw-r--r--spec/services/boards/list_service_spec.rb4
-rw-r--r--spec/services/boards/lists/create_service_spec.rb4
-rw-r--r--spec/services/boards/lists/destroy_service_spec.rb4
-rw-r--r--spec/services/boards/lists/generate_service_spec.rb4
-rw-r--r--spec/services/boards/lists/list_service_spec.rb33
-rw-r--r--spec/services/boards/lists/move_service_spec.rb4
-rw-r--r--spec/services/chat_names/authorize_user_service_spec.rb2
-rw-r--r--spec/services/chat_names/find_user_service_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb318
-rw-r--r--spec/services/ci/create_trigger_request_service_spec.rb28
-rw-r--r--spec/services/ci/pipeline_trigger_service_spec.rb83
-rw-r--r--spec/services/ci/play_build_service_spec.rb114
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb84
-rw-r--r--spec/services/ci/register_job_service_spec.rb8
-rw-r--r--spec/services/ci/retry_build_service_spec.rb37
-rw-r--r--spec/services/ci/retry_pipeline_service_spec.rb47
-rw-r--r--spec/services/ci/stop_environments_service_spec.rb18
-rw-r--r--spec/services/ci/update_build_queue_service_spec.rb10
-rw-r--r--spec/services/ci/update_runner_service_spec.rb2
-rw-r--r--spec/services/cohorts_service_spec.rb2
-rw-r--r--spec/services/compare_service_spec.rb2
-rw-r--r--spec/services/create_branch_service_spec.rb2
-rw-r--r--spec/services/create_deployment_service_spec.rb303
-rw-r--r--spec/services/create_release_service_spec.rb4
-rw-r--r--spec/services/create_snippet_service_spec.rb2
-rw-r--r--spec/services/delete_branch_service_spec.rb2
-rw-r--r--spec/services/delete_merged_branches_service_spec.rb51
-rw-r--r--spec/services/discussions/update_diff_position_service_spec.rb193
-rw-r--r--spec/services/emails/create_service_spec.rb21
-rw-r--r--spec/services/emails/destroy_service_spec.rb14
-rw-r--r--spec/services/event_create_service_spec.rb54
-rw-r--r--spec/services/files/update_service_spec.rb4
-rw-r--r--spec/services/git_hooks_service_spec.rb11
-rw-r--r--spec/services/git_push_service_spec.rb361
-rw-r--r--spec/services/git_tag_push_service_spec.rb20
-rw-r--r--spec/services/gravatar_service_spec.rb20
-rw-r--r--spec/services/groups/create_service_spec.rb10
-rw-r--r--spec/services/groups/destroy_service_spec.rb68
-rw-r--r--spec/services/groups/update_service_spec.rb8
-rw-r--r--spec/services/import_export_clean_up_service_spec.rb2
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb70
-rw-r--r--spec/services/issues/build_service_spec.rb4
-rw-r--r--spec/services/issues/close_service_spec.rb46
-rw-r--r--spec/services/issues/create_service_spec.rb116
-rw-r--r--spec/services/issues/duplicate_service_spec.rb80
-rw-r--r--spec/services/issues/move_service_spec.rb81
-rw-r--r--spec/services/issues/reopen_service_spec.rb11
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb28
-rw-r--r--spec/services/issues/update_service_spec.rb155
-rw-r--r--spec/services/labels/create_service_spec.rb40
-rw-r--r--spec/services/labels/find_or_create_service_spec.rb4
-rw-r--r--spec/services/labels/promote_service_spec.rb18
-rw-r--r--spec/services/labels/transfer_service_spec.rb6
-rw-r--r--spec/services/labels/update_service_spec.rb16
-rw-r--r--spec/services/members/approve_access_request_service_spec.rb4
-rw-r--r--spec/services/members/authorized_destroy_service_spec.rb33
-rw-r--r--spec/services/members/create_service_spec.rb24
-rw-r--r--spec/services/members/destroy_service_spec.rb8
-rw-r--r--spec/services/members/request_access_service_spec.rb8
-rw-r--r--spec/services/merge_requests/assign_issues_service_spec.rb12
-rw-r--r--spec/services/merge_requests/build_service_spec.rb18
-rw-r--r--spec/services/merge_requests/close_service_spec.rb8
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb80
-rw-r--r--spec/services/merge_requests/conflicts/resolve_service_spec.rb257
-rw-r--r--spec/services/merge_requests/create_from_issue_service_spec.rb74
-rw-r--r--spec/services/merge_requests/create_service_spec.rb159
-rw-r--r--spec/services/merge_requests/get_urls_service_spec.rb14
-rw-r--r--spec/services/merge_requests/merge_request_diff_cache_service_spec.rb6
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb81
-rw-r--r--spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb6
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb15
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb76
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb10
-rw-r--r--spec/services/merge_requests/resolve_service_spec.rb213
-rw-r--r--spec/services/merge_requests/resolved_discussion_notification_service.rb46
-rw-r--r--spec/services/merge_requests/resolved_discussion_notification_service_spec.rb46
-rw-r--r--spec/services/merge_requests/update_service_spec.rb168
-rw-r--r--spec/services/milestones/close_service_spec.rb6
-rw-r--r--spec/services/milestones/create_service_spec.rb6
-rw-r--r--spec/services/milestones/destroy_service_spec.rb51
-rw-r--r--spec/services/note_summary_spec.rb4
-rw-r--r--spec/services/notes/build_service_spec.rb76
-rw-r--r--spec/services/notes/create_service_spec.rb4
-rw-r--r--spec/services/notes/destroy_service_spec.rb4
-rw-r--r--spec/services/notes/diff_position_update_service_spec.rb175
-rw-r--r--spec/services/notes/post_process_service_spec.rb6
-rw-r--r--spec/services/notes/quick_actions_service_spec.rb252
-rw-r--r--spec/services/notes/slash_commands_service_spec.rb223
-rw-r--r--spec/services/notes/update_service_spec.rb4
-rw-r--r--spec/services/notification_service_spec.rb290
-rw-r--r--spec/services/pages_service_spec.rb16
-rw-r--r--spec/services/preview_markdown_service_spec.rb67
-rw-r--r--spec/services/projects/autocomplete_service_spec.rb33
-rw-r--r--spec/services/projects/create_from_template_service_spec.rb26
-rw-r--r--spec/services/projects/create_service_spec.rb12
-rw-r--r--spec/services/projects/destroy_service_spec.rb87
-rw-r--r--spec/services/projects/download_service_spec.rb4
-rw-r--r--spec/services/projects/enable_deploy_key_service_spec.rb14
-rw-r--r--spec/services/projects/fork_service_spec.rb18
-rw-r--r--spec/services/projects/forks_count_service_spec.rb40
-rw-r--r--spec/services/projects/housekeeping_service_spec.rb16
-rw-r--r--spec/services/projects/import_service_spec.rb48
-rw-r--r--spec/services/projects/participants_service_spec.rb9
-rw-r--r--spec/services/projects/propagate_service_template_spec.rb107
-rw-r--r--spec/services/projects/transfer_service_spec.rb102
-rw-r--r--spec/services/projects/unlink_fork_service_spec.rb20
-rw-r--r--spec/services/projects/update_pages_configuration_service_spec.rb4
-rw-r--r--spec/services/projects/update_pages_service_spec.rb72
-rw-r--r--spec/services/projects/update_service_spec.rb66
-rw-r--r--spec/services/projects/upload_service_spec.rb73
-rw-r--r--spec/services/protected_branches/create_service_spec.rb4
-rw-r--r--spec/services/protected_branches/update_service_spec.rb4
-rw-r--r--spec/services/protected_tags/create_service_spec.rb4
-rw-r--r--spec/services/protected_tags/update_service_spec.rb4
-rw-r--r--spec/services/push_event_payload_service_spec.rb218
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb1151
-rw-r--r--spec/services/repair_ldap_blocked_user_service_spec.rb4
-rw-r--r--spec/services/repository_archive_clean_up_service_spec.rb2
-rw-r--r--spec/services/search/global_service_spec.rb18
-rw-r--r--spec/services/search/group_service_spec.rb16
-rw-r--r--spec/services/search/snippet_service_spec.rb4
-rw-r--r--spec/services/search_service_spec.rb73
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb731
-rw-r--r--spec/services/spam_service_spec.rb8
-rw-r--r--spec/services/submit_usage_ping_service_spec.rb106
-rw-r--r--spec/services/system_hooks_service_spec.rb4
-rw-r--r--spec/services/system_note_service_spec.rb321
-rw-r--r--spec/services/tags/create_service_spec.rb14
-rw-r--r--spec/services/tags/destroy_service_spec.rb2
-rw-r--r--spec/services/test_hook_service_spec.rb14
-rw-r--r--spec/services/test_hooks/project_service_spec.rb188
-rw-r--r--spec/services/test_hooks/system_service_spec.rb82
-rw-r--r--spec/services/todo_service_spec.rb83
-rw-r--r--spec/services/update_release_service_spec.rb4
-rw-r--r--spec/services/update_snippet_service_spec.rb2
-rw-r--r--spec/services/upload_service_spec.rb73
-rw-r--r--spec/services/user_project_access_changed_service_spec.rb4
-rw-r--r--spec/services/users/activity_service_spec.rb10
-rw-r--r--spec/services/users/build_service_spec.rb2
-rw-r--r--spec/services/users/create_service_spec.rb2
-rw-r--r--spec/services/users/destroy_service_spec.rb18
-rw-r--r--spec/services/users/migrate_to_ghost_user_service_spec.rb35
-rw-r--r--spec/services/users/refresh_authorized_projects_service_spec.rb96
-rw-r--r--spec/services/users/update_service_spec.rb43
-rw-r--r--spec/services/web_hook_service_spec.rb154
-rw-r--r--spec/services/wiki_pages/create_service_spec.rb44
-rw-r--r--spec/services/wiki_pages/destroy_service_spec.rb23
-rw-r--r--spec/services/wiki_pages/update_service_spec.rb50
-rw-r--r--spec/sidekiq/cron/job_gem_dependency_spec.rb18
-rw-r--r--spec/simplecov_env.rb25
-rw-r--r--spec/spec_helper.rb92
-rw-r--r--spec/support/api/milestones_shared_examples.rb383
-rw-r--r--spec/support/api/schema_matcher.rb25
-rw-r--r--spec/support/api/scopes/read_user_shared_examples.rb79
-rw-r--r--spec/support/api/status_shared_examples.rb42
-rw-r--r--spec/support/api_helpers.rb18
-rw-r--r--spec/support/capybara.rb21
-rw-r--r--spec/support/capybara_helpers.rb5
-rw-r--r--spec/support/chat_slash_commands_shared_examples.rb4
-rw-r--r--spec/support/controllers/githubish_import_controller_shared_examples.rb120
-rw-r--r--spec/support/cycle_analytics_helpers.rb53
-rw-r--r--spec/support/db_cleaner.rb4
-rw-r--r--spec/support/devise_helpers.rb14
-rw-r--r--spec/support/dropzone_helper.rb59
-rw-r--r--spec/support/fake_migration_classes.rb11
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb91
-rw-r--r--spec/support/features/reportable_note_shared_examples.rb40
-rw-r--r--spec/support/features/rss_shared_examples.rb24
-rw-r--r--spec/support/filter_item_select_helper.rb19
-rw-r--r--spec/support/filtered_search_helpers.rb35
-rw-r--r--spec/support/forgery_protection.rb11
-rwxr-xr-xspec/support/generate-seed-repo-rb162
-rw-r--r--spec/support/git_http_helpers.rb22
-rw-r--r--spec/support/gitaly.rb9
-rw-r--r--spec/support/gitlab-git-test.git/HEAD1
-rw-r--r--spec/support/gitlab-git-test.git/README.md16
-rw-r--r--spec/support/gitlab-git-test.git/config7
-rw-r--r--spec/support/gitlab-git-test.git/objects/3e/20715310a699808282e772720b9c04a0696bccbin0 -> 566 bytes
-rw-r--r--spec/support/gitlab-git-test.git/objects/95/96bc54a6f0c0c98248fe97077eb5ccf48a98d02
-rw-r--r--spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idxbin0 -> 5496 bytes
-rw-r--r--spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.packbin0 -> 381502 bytes
-rw-r--r--spec/support/gitlab-git-test.git/packed-refs19
-rw-r--r--spec/support/gitlab-git-test.git/refs/heads/.gitkeep0
-rw-r--r--spec/support/gitlab-git-test.git/refs/tags/.gitkeep0
-rw-r--r--spec/support/gpg_helpers.rb204
-rw-r--r--spec/support/helpers/fake_blob_helpers.rb40
-rw-r--r--spec/support/helpers/key_generator_helper.rb41
-rw-r--r--spec/support/helpers/note_interaction_helpers.rb8
-rw-r--r--spec/support/import_export/export_file_helper.rb4
-rw-r--r--spec/support/import_export/import_export.yml8
-rw-r--r--spec/support/import_spec_helper.rb2
-rw-r--r--spec/support/issuable_shared_examples.rb38
-rw-r--r--spec/support/issuables_list_metadata_shared_examples.rb17
-rw-r--r--spec/support/issue_helpers.rb2
-rw-r--r--spec/support/issue_tracker_service_shared_example.rb8
-rw-r--r--spec/support/javascript_fixtures_helpers.rb2
-rw-r--r--spec/support/jira_service_helper.rb2
-rw-r--r--spec/support/json_response_helpers.rb2
-rw-r--r--spec/support/kubernetes_helpers.rb43
-rw-r--r--spec/support/login_helpers.rb76
-rw-r--r--spec/support/malicious_regexp_shared_examples.rb8
-rw-r--r--spec/support/markdown_feature.rb6
-rw-r--r--spec/support/matchers/access_matchers.rb4
-rw-r--r--spec/support/matchers/access_matchers_for_controller.rb108
-rw-r--r--spec/support/matchers/be_utf8.rb9
-rw-r--r--spec/support/matchers/execute_check.rb23
-rw-r--r--spec/support/matchers/gitaly_matchers.rb15
-rw-r--r--spec/support/matchers/gitlab_git_matchers.rb6
-rw-r--r--spec/support/matchers/have_gitlab_http_status.rb14
-rw-r--r--spec/support/matchers/markdown_matchers.rb6
-rw-r--r--spec/support/matchers/query_matcher.rb2
-rw-r--r--spec/support/mentionable_shared_examples.rb12
-rw-r--r--spec/support/merge_request_helpers.rb2
-rw-r--r--spec/support/migrations_helpers.rb68
-rw-r--r--spec/support/milestone_tabs_examples.rb84
-rw-r--r--spec/support/notify_shared_examples.rb5
-rwxr-xr-xspec/support/prepare-gitlab-git-test-for-commit17
-rw-r--r--spec/support/project_features_apply_to_issuables_shared_examples.rb4
-rw-r--r--spec/support/project_hook_data_shared_example.rb4
-rw-r--r--spec/support/prometheus/additional_metrics_shared_examples.rb152
-rw-r--r--spec/support/prometheus/metric_builders.rb27
-rw-r--r--spec/support/prometheus_helpers.rb97
-rw-r--r--spec/support/protected_tags/access_control_ce_shared_examples.rb47
-rw-r--r--spec/support/quick_actions_helpers.rb10
-rw-r--r--spec/support/rake_helpers.rb11
-rw-r--r--spec/support/reactive_caching_helpers.rb6
-rw-r--r--spec/support/redis/redis_shared_examples.rb214
-rw-r--r--spec/support/reference_parser_shared_examples.rb8
-rw-r--r--spec/support/repo_helpers.rb4
-rw-r--r--spec/support/routing_helpers.rb3
-rw-r--r--spec/support/seed_helper.rb2
-rw-r--r--spec/support/seed_repo.rb12
-rw-r--r--spec/support/services/issuable_create_service_shared_examples.rb52
-rw-r--r--spec/support/services/issuable_create_service_slash_commands_shared_examples.rb24
-rw-r--r--spec/support/services/issuable_update_service_shared_examples.rb52
-rw-r--r--spec/support/services/migrate_to_ghost_user_service_shared_examples.rb30
-rw-r--r--spec/support/services_shared_context.rb6
-rw-r--r--spec/support/shared_examples/features/issuable_sidebar_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/features/protected_branches_access_control_ce.rb91
-rw-r--r--spec/support/shared_examples/requests/api/status_shared_examples.rb42
-rw-r--r--spec/support/sidekiq.rb10
-rw-r--r--spec/support/slack_mattermost_notifications_shared_examples.rb66
-rw-r--r--spec/support/slash_commands_helpers.rb10
-rw-r--r--spec/support/snippets_shared_examples.rb2
-rw-r--r--spec/support/sorting_helper.rb18
-rw-r--r--spec/support/stored_repositories.rb12
-rw-r--r--spec/support/stub_configuration.rb52
-rw-r--r--spec/support/stub_env.rb32
-rw-r--r--spec/support/stub_feature_flags.rb8
-rw-r--r--spec/support/stub_gitlab_calls.rb38
-rw-r--r--spec/support/target_branch_helpers.rb16
-rw-r--r--spec/support/test_env.rb158
-rw-r--r--spec/support/time_tracking_shared_examples.rb23
-rw-r--r--spec/support/unique_ip_check_shared_examples.rb14
-rwxr-xr-xspec/support/unpack-gitlab-git-test38
-rw-r--r--spec/support/update_invalid_issuable.rb4
-rw-r--r--spec/support/updating_mentions_shared_examples.rb14
-rw-r--r--spec/support/user_activities_helpers.rb4
-rw-r--r--spec/support/wait_for_ajax.rb18
-rw-r--r--spec/support/wait_for_requests.rb37
-rw-r--r--spec/support/wait_for_vue_resource.rb7
-rw-r--r--spec/support/workhorse_helpers.rb2
-rw-r--r--spec/tasks/config_lint_spec.rb8
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb41
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb79
-rw-r--r--spec/tasks/gitlab/shell_rake_spec.rb7
-rw-r--r--spec/tasks/gitlab/task_helpers_spec.rb23
-rw-r--r--spec/tasks/gitlab/workhorse_rake_spec.rb20
-rw-r--r--spec/tasks/tokens_spec.rb6
-rw-r--r--spec/unicorn/unicorn_spec.rb98
-rw-r--r--spec/uploaders/artifact_uploader_spec.rb61
-rw-r--r--spec/uploaders/attachment_uploader_spec.rb11
-rw-r--r--spec/uploaders/avatar_uploader_spec.rb11
-rw-r--r--spec/uploaders/file_mover_spec.rb63
-rw-r--r--spec/uploaders/file_uploader_spec.rb12
-rw-r--r--spec/uploaders/gitlab_uploader_spec.rb71
-rw-r--r--spec/uploaders/lfs_object_uploader_spec.rb40
-rw-r--r--spec/uploaders/personal_file_uploader_spec.rb31
-rw-r--r--spec/uploaders/records_uploads_spec.rb9
-rw-r--r--spec/validators/dynamic_path_validator_spec.rb97
-rw-r--r--spec/views/admin/dashboard/index.html.haml_spec.rb2
-rw-r--r--spec/views/ci/status/_badge.html.haml_spec.rb7
-rw-r--r--spec/views/devise/shared/_signin_box.html.haml_spec.rb4
-rw-r--r--spec/views/help/index.html.haml_spec.rb2
-rw-r--r--spec/views/layouts/nav/_project.html.haml_spec.rb2
-rw-r--r--spec/views/notify/pipeline_failed_email.html.haml_spec.rb2
-rw-r--r--spec/views/notify/pipeline_success_email.html.haml_spec.rb2
-rw-r--r--spec/views/profiles/show.html.haml_spec.rb19
-rw-r--r--spec/views/projects/_home_panel.html.haml_spec.rb4
-rw-r--r--spec/views/projects/_last_commit.html.haml_spec.rb22
-rw-r--r--spec/views/projects/blob/_viewer.html.haml_spec.rb97
-rw-r--r--spec/views/projects/builds/_build.html.haml_spec.rb28
-rw-r--r--spec/views/projects/builds/show.html.haml_spec.rb293
-rw-r--r--spec/views/projects/commit/_commit_box.html.haml_spec.rb2
-rw-r--r--spec/views/projects/commit/show.html.haml_spec.rb46
-rw-r--r--spec/views/projects/commits/_commit.html.haml_spec.rb22
-rw-r--r--spec/views/projects/diffs/_viewer.html.haml_spec.rb71
-rw-r--r--spec/views/projects/edit.html.haml_spec.rb16
-rw-r--r--spec/views/projects/environments/terminal.html.haml_spec.rb32
-rw-r--r--spec/views/projects/imports/new.html.haml_spec.rb22
-rw-r--r--spec/views/projects/jobs/_build.html.haml_spec.rb28
-rw-r--r--spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb (renamed from spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb)0
-rw-r--r--spec/views/projects/jobs/show.html.haml_spec.rb214
-rw-r--r--spec/views/projects/merge_requests/_commits.html.haml_spec.rb7
-rw-r--r--spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb31
-rw-r--r--spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb31
-rw-r--r--spec/views/projects/notes/_form.html.haml_spec.rb36
-rw-r--r--spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb39
-rw-r--r--spec/views/projects/pipelines/_stage.html.haml_spec.rb7
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb67
-rw-r--r--spec/views/projects/registry/repositories/index.html.haml_spec.rb4
-rw-r--r--spec/views/projects/tags/index.html.haml_spec.rb20
-rw-r--r--spec/views/projects/tree/show.html.haml_spec.rb8
-rw-r--r--spec/views/shared/notes/_form.html.haml_spec.rb36
-rw-r--r--spec/views/shared/projects/_project.html.haml_spec.rb19
-rw-r--r--spec/workers/authorized_projects_worker_spec.rb2
-rw-r--r--spec/workers/background_migration_worker_spec.rb44
-rw-r--r--spec/workers/create_gpg_signature_worker_spec.rb39
-rw-r--r--spec/workers/delete_user_worker_spec.rb12
-rw-r--r--spec/workers/email_receiver_worker_spec.rb26
-rw-r--r--spec/workers/emails_on_push_worker_spec.rb12
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb10
-rw-r--r--spec/workers/expire_build_artifacts_worker_spec.rb8
-rw-r--r--spec/workers/expire_build_instance_artifacts_worker_spec.rb12
-rw-r--r--spec/workers/expire_job_cache_worker_spec.rb31
-rw-r--r--spec/workers/expire_pipeline_cache_worker_spec.rb8
-rw-r--r--spec/workers/git_garbage_collect_worker_spec.rb60
-rw-r--r--spec/workers/gitlab_usage_ping_worker_spec.rb20
-rw-r--r--spec/workers/group_destroy_worker_spec.rb4
-rw-r--r--spec/workers/invalid_gpg_signature_update_worker_spec.rb29
-rw-r--r--spec/workers/merge_worker_spec.rb13
-rw-r--r--spec/workers/namespaceless_project_destroy_worker_spec.rb79
-rw-r--r--spec/workers/new_issue_worker_spec.rb54
-rw-r--r--spec/workers/new_merge_request_worker_spec.rb56
-rw-r--r--spec/workers/new_note_worker_spec.rb4
-rw-r--r--spec/workers/pipeline_metrics_worker_spec.rb2
-rw-r--r--spec/workers/pipeline_notification_worker_spec.rb4
-rw-r--r--spec/workers/pipeline_process_worker_spec.rb (renamed from spec/workers/pipeline_proccess_worker_spec.rb)0
-rw-r--r--spec/workers/pipeline_schedule_worker_spec.rb65
-rw-r--r--spec/workers/post_receive_spec.rb83
-rw-r--r--spec/workers/process_commit_worker_spec.rb36
-rw-r--r--spec/workers/project_cache_worker_spec.rb34
-rw-r--r--spec/workers/project_destroy_worker_spec.rb22
-rw-r--r--spec/workers/propagate_service_template_worker_spec.rb29
-rw-r--r--spec/workers/prune_old_events_worker_spec.rb8
-rw-r--r--spec/workers/remove_expired_members_worker_spec.rb2
-rw-r--r--spec/workers/remove_old_web_hook_logs_worker_spec.rb18
-rw-r--r--spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb6
-rw-r--r--spec/workers/repository_check/batch_worker_spec.rb10
-rw-r--r--spec/workers/repository_check/clear_worker_spec.rb4
-rw-r--r--spec/workers/repository_fork_worker_spec.rb36
-rw-r--r--spec/workers/repository_import_worker_spec.rb27
-rw-r--r--spec/workers/schedule_update_user_activity_worker_spec.rb2
-rw-r--r--spec/workers/stuck_ci_jobs_worker_spec.rb12
-rw-r--r--spec/workers/stuck_import_jobs_worker_spec.rb34
-rw-r--r--spec/workers/stuck_merge_jobs_worker_spec.rb50
-rw-r--r--spec/workers/trigger_schedule_worker_spec.rb73
-rw-r--r--spec/workers/update_user_activity_worker_spec.rb4
-rw-r--r--tmp/prometheus_multiproc_dir/.gitkeep0
-rw-r--r--vendor/Dockerfile/Binary-alpine.Dockerfile14
-rw-r--r--vendor/Dockerfile/Binary-scratch.Dockerfile17
-rw-r--r--vendor/Dockerfile/Binary.Dockerfile11
-rw-r--r--vendor/Dockerfile/Golang-alpine.Dockerfile17
-rw-r--r--vendor/Dockerfile/Golang-scratch.Dockerfile20
-rw-r--r--vendor/Dockerfile/Golang.Dockerfile14
-rw-r--r--vendor/Dockerfile/Node-alpine.Dockerfile14
-rw-r--r--vendor/Dockerfile/Node.Dockerfile14
-rw-r--r--vendor/Dockerfile/OpenJDK-alpine.Dockerfile8
-rw-r--r--vendor/Dockerfile/OpenJDK.Dockerfile8
-rw-r--r--vendor/Dockerfile/Python-alpine.Dockerfile19
-rw-r--r--vendor/Dockerfile/Python.Dockerfile22
-rw-r--r--vendor/Dockerfile/Ruby-alpine.Dockerfile24
-rw-r--r--vendor/Dockerfile/Ruby.Dockerfile27
-rw-r--r--vendor/assets/javascripts/cropper.js2993
-rw-r--r--vendor/assets/javascripts/jquery.nicescroll.js3634
-rwxr-xr-xvendor/assets/javascripts/pdf.js9365
-rwxr-xr-xvendor/assets/javascripts/pdf.min.js6
-rwxr-xr-x[-rw-r--r--]vendor/assets/javascripts/pdf.worker.js305
-rwxr-xr-xvendor/assets/javascripts/pdf.worker.min.js19
-rw-r--r--vendor/assets/javascripts/pdflab.js12484
-rw-r--r--vendor/assets/javascripts/peek.js78
-rw-r--r--vendor/assets/javascripts/peek.performance_bar.js182
-rw-r--r--vendor/assets/javascripts/task_list.js258
-rw-r--r--vendor/gitignore/Global/Archives.gitignore5
-rw-r--r--vendor/gitignore/Global/JEnv.gitignore5
-rw-r--r--vendor/gitignore/Global/JetBrains.gitignore3
-rw-r--r--vendor/gitignore/Global/MicrosoftOffice.gitignore2
-rw-r--r--vendor/gitignore/Global/SublimeText.gitignore10
-rw-r--r--vendor/gitignore/Global/Vagrant.gitignore4
-rw-r--r--vendor/gitignore/Global/Vim.gitignore10
-rw-r--r--vendor/gitignore/Global/Windows.gitignore3
-rw-r--r--vendor/gitignore/Global/macOS.gitignore1
-rw-r--r--vendor/gitignore/Magento.gitignore27
-rw-r--r--vendor/gitignore/Python.gitignore12
-rw-r--r--vendor/gitignore/Qt.gitignore9
-rw-r--r--vendor/gitignore/SugarCRM.gitignore4
-rw-r--r--vendor/gitignore/UnrealEngine.gitignore5
-rw-r--r--vendor/gitignore/VisualStudio.gitignore7
-rw-r--r--vendor/gitlab-ci-yml/.gitlab-ci.yml4
-rw-r--r--vendor/gitlab-ci-yml/Crystal.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Django.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Docker.gitlab-ci.yml19
-rw-r--r--vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Nodejs.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml6
-rw-r--r--vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/Rust.gitlab-ci.yml2
-rw-r--r--vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml8
-rw-r--r--vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml6
-rw-r--r--vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml12
-rw-r--r--vendor/licenses.csv543
-rw-r--r--vendor/project_templates/express.tar.gzbin0 -> 4572 bytes
-rw-r--r--vendor/project_templates/rails.tar.gzbin0 -> 23749 bytes
-rw-r--r--vendor/project_templates/spring.tar.gzbin0 -> 49882 bytes
-rw-r--r--yarn.lock1318
6223 files changed, 226867 insertions, 88244 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml
new file mode 100644
index 00000000000..42afed54371
--- /dev/null
+++ b/.codeclimate.yml
@@ -0,0 +1,47 @@
+---
+engines:
+ brakeman:
+ enabled: true
+ bundler-audit:
+ enabled: true
+ duplication:
+ enabled: true
+ config:
+ languages:
+ - ruby
+ - javascript
+ exclude_paths:
+ - "lib/api/v3/*"
+ eslint:
+ enabled: true
+ rubocop:
+ enabled: true
+ratings:
+ paths:
+ - Gemfile.lock
+ - "**.erb"
+ - "**.haml"
+ - "**.rb"
+ - "**.rhtml"
+ - "**.slim"
+ - "**.inc"
+ - "**.js"
+ - "**.jsx"
+ - "**.module"
+exclude_paths:
+- config/
+- db/
+- features/
+- node_modules/
+- spec/
+- vendor/
+- .yarn-cache/
+- tmp/
+- builds/
+- coverage/
+- public/
+- shared/
+- webpack-report/
+- log/
+- backups/
+- coverage-javascript/
diff --git a/.eslintignore b/.eslintignore
index c742b08c005..1605e483e9e 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -7,3 +7,4 @@
/vendor/
karma.config.js
webpack.config.js
+/app/assets/javascripts/locale/**/*.js
diff --git a/.eslintrc b/.eslintrc
index aba8112c5a9..3e07edbccfe 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -11,6 +11,7 @@
"gon": false,
"localStorage": false
},
+ "parser": "babel-eslint",
"plugins": [
"filenames",
"import",
@@ -27,7 +28,9 @@
},
"rules": {
"filenames/match-regex": [2, "^[a-z0-9_]+$"],
+ "import/no-commonjs": "error",
"no-multiple-empty-lines": ["error", { "max": 1 }],
- "promise/catch-or-return": "error"
+ "promise/catch-or-return": "error",
+ "no-underscore-dangle": ["error", { "allow": ["__"]}]
}
}
diff --git a/.flayignore b/.flayignore
index 47597025115..b63ce4c4df0 100644
--- a/.flayignore
+++ b/.flayignore
@@ -3,3 +3,5 @@ lib/gitlab/sanitizers/svg/whitelist.rb
lib/gitlab/diff/position_tracer.rb
app/policies/project_policy.rb
app/models/concerns/relative_positioning.rb
+app/workers/stuck_merge_jobs_worker.rb
+lib/gitlab/redis/*.rb
diff --git a/.gitignore b/.gitignore
index dfc99a4ee48..3baf640a9c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
*.log
*.swp
+*.mo
+*.edit.po
.DS_Store
.bundle
.chef
@@ -16,8 +18,10 @@ eslint-report.html
.sass-cache/
/.secret
/.vagrant
+/.yarn-cache
/.byebug_history
/Vagrantfile
+/app/assets/javascripts/locale/**/app.js
/backups/*
/config/aws.yml
/config/database.yml
@@ -27,6 +31,9 @@ eslint-report.html
/config/initializers/smtp_settings.rb
/config/initializers/relative_url.rb
/config/resque.yml
+/config/redis.cache.yml
+/config/redis.queues.yml
+/config/redis.shared_state.yml
/config/unicorn.rb
/config/secrets.yml
/config/sidekiq.yml
@@ -45,6 +52,8 @@ eslint-report.html
/public/uploads.*
/public/uploads/
/shared/artifacts/
+/spec/javascripts/fixtures/blob/pdf/
+/spec/javascripts/fixtures/blob/balsamiq/
/rails_best_practices_output.html
/tags
/tmp/*
@@ -53,3 +62,5 @@ eslint-report.html
/shared/*
/.gitlab_workhorse_secret
/webpack-report/
+/locale/**/LC_MESSAGES
+/.rspec
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 50f1f65e4e8..df7244d5a2e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,9 +1,20 @@
-image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.7-phantomjs-2.1-node-7.1-postgresql-9.6"
+image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-phantomjs-2.1-node-7.1-postgresql-9.6"
-cache:
- key: "ruby-233"
+.default-cache: &default-cache
+ key: "ruby-233-with-yarn"
paths:
- - vendor/ruby
+ - vendor/ruby
+ - .yarn-cache/
+
+.push-cache: &push-cache
+ cache:
+ <<: *default-cache
+ policy: push
+
+.pull-cache: &pull-cache
+ cache:
+ <<: *default-cache
+ policy: pull
variables:
MYSQL_ALLOW_EMPTY_PASSWORD: "1"
@@ -16,41 +27,40 @@ variables:
GET_SOURCES_ATTEMPTS: "3"
KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json
KNAPSACK_SPINACH_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/spinach_report-master.json
+ FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/${CI_PROJECT_NAME}/report-master.json
before_script:
- bundle --version
- - . scripts/utils.sh
- - ./scripts/prepare_build.sh
+ - source scripts/utils.sh
+ - source scripts/prepare_build.sh
stages:
-- prepare
-- test
-- post-test
-- pages
+ - build
+ - prepare
+ - test
+ - post-test
+ - pages
# Predefined scopes
.dedicated-runner: &dedicated-runner
tags:
- gitlab-org
-.knapsack-state: &knapsack-state
+.tests-metadata-state: &tests-metadata-state
services: []
variables:
SETUP_DB: "false"
USE_BUNDLE_INSTALL: "false"
- KNAPSACK_S3_BUCKET: "gitlab-ce-cache"
- cache:
- key: "knapsack"
- paths:
- - knapsack/
+ TESTS_METADATA_S3_BUCKET: "gitlab-ce-cache"
artifacts:
expire_in: 31d
paths:
- knapsack/
+ - rspec_flaky/
.use-pg: &use-pg
services:
- - postgres:latest
+ - postgres:9.2
- redis:alpine
.use-mysql: &use-mysql
@@ -58,27 +68,44 @@ stages:
- mysql:latest
- redis:alpine
-.only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql
+.only-if-want-mysql: &only-if-want-mysql
only:
- /mysql/
+ - /-stable/
- master@gitlab-org/gitlab-ce
+ - master@gitlab-org/gitlab-ee
- master@gitlab/gitlabhq
+ - master@gitlab/gitlab-ee
- tags@gitlab-org/gitlab-ce
+ - tags@gitlab-org/gitlab-ee
- tags@gitlab/gitlabhq
- - //@gitlab-org/gitlab-ee
- - //@gitlab/gitlab-ee
+ - tags@gitlab/gitlab-ee
-.rspec-knapsack: &rspec-knapsack
- stage: test
+# Skip all jobs except the ones that begin with 'docs/'.
+# Used for commits including ONLY documentation changes.
+# https://docs.gitlab.com/ce/development/writing_documentation.html#testing
+.except-docs: &except-docs
+ except:
+ - /(^docs[\/-].*|.*-docs$)/
+
+.rspec-metadata: &rspec-metadata
<<: *dedicated-runner
+ <<: *pull-cache
+ stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
- export CI_NODE_INDEX=${JOB_NAME[-2]}
- export CI_NODE_TOTAL=${JOB_NAME[-1]}
- - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
+ - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
- export KNAPSACK_GENERATE_REPORT=true
+ - export ALL_FLAKY_RSPEC_REPORT_PATH=rspec_flaky/${CI_PROJECT_NAME}/all_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
+ - export NEW_FLAKY_RSPEC_REPORT_PATH=rspec_flaky/${CI_PROJECT_NAME}/new_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
+ - export FLAKY_RSPEC_GENERATE_REPORT=true
- export CACHE_CLASSES=true
- cp ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH}
+ - cp ${FLAKY_RSPEC_SUITE_REPORT_PATH} ${ALL_FLAKY_RSPEC_REPORT_PATH}
+ - '[[ -f $NEW_FLAKY_RSPEC_REPORT_PATH ]] || echo "{}" > ${NEW_FLAKY_RSPEC_REPORT_PATH}'
+ - scripts/gitaly-test-spawn
- knapsack rspec "--color --format documentation"
artifacts:
expire_in: 31d
@@ -86,25 +113,29 @@ stages:
paths:
- coverage/
- knapsack/
+ - rspec_flaky/
- tmp/capybara/
-.rspec-knapsack-pg: &rspec-knapsack-pg
- <<: *rspec-knapsack
+.rspec-metadata-pg: &rspec-metadata-pg
+ <<: *rspec-metadata
<<: *use-pg
+ <<: *except-docs
-.rspec-knapsack-mysql: &rspec-knapsack-mysql
- <<: *rspec-knapsack
+.rspec-metadata-mysql: &rspec-metadata-mysql
+ <<: *rspec-metadata
<<: *use-mysql
- <<: *only-master-and-ee-or-mysql
+ <<: *only-if-want-mysql
+ <<: *except-docs
-.spinach-knapsack: &spinach-knapsack
- stage: test
+.spinach-metadata: &spinach-metadata
<<: *dedicated-runner
+ <<: *pull-cache
+ stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
- export CI_NODE_INDEX=${JOB_NAME[-2]}
- export CI_NODE_TOTAL=${JOB_NAME[-1]}
- - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
+ - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
- export KNAPSACK_GENERATE_REPORT=true
- export CACHE_CLASSES=true
- cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH}
@@ -117,51 +148,122 @@ stages:
- knapsack/
- tmp/capybara/
-.spinach-knapsack-pg: &spinach-knapsack-pg
- <<: *spinach-knapsack
+.spinach-metadata-pg: &spinach-metadata-pg
+ <<: *spinach-metadata
<<: *use-pg
+ <<: *except-docs
-.spinach-knapsack-mysql: &spinach-knapsack-mysql
- <<: *spinach-knapsack
+.spinach-metadata-mysql: &spinach-metadata-mysql
+ <<: *spinach-metadata
<<: *use-mysql
- <<: *only-master-and-ee-or-mysql
+ <<: *only-if-want-mysql
+ <<: *except-docs
+
+.only-canonical-masters: &only-canonical-masters
+ only:
+ - master@gitlab-org/gitlab-ce
+ - master@gitlab-org/gitlab-ee
+ - master@gitlab/gitlabhq
+ - master@gitlab/gitlab-ee
+
+# Trigger a package build on omnibus-gitlab repository
+
+build-package:
+ image: ruby:2.3-alpine
+ before_script: []
+ services: []
+ variables:
+ SETUP_DB: "false"
+ USE_BUNDLE_INSTALL: "false"
+ stage: build
+ cache: {}
+ when: manual
+ script:
+ - scripts/trigger-build
+ only:
+ - //@gitlab-org/gitlab-ce
+ - //@gitlab-org/gitlab-ee
-# Prepare and merge knapsack tests
-knapsack:
- <<: *knapsack-state
+# Retrieve knapsack and rspec_flaky reports
+retrieve-tests-metadata:
+ <<: *tests-metadata-state
<<: *dedicated-runner
+ <<: *except-docs
stage: prepare
+ cache:
+ key: tests_metadata
+ policy: pull
script:
- mkdir -p knapsack/${CI_PROJECT_NAME}/
- - wget -O $KNAPSACK_RSPEC_SUITE_REPORT_PATH http://${KNAPSACK_S3_BUCKET}.s3.amazonaws.com/$KNAPSACK_RSPEC_SUITE_REPORT_PATH || rm $KNAPSACK_RSPEC_SUITE_REPORT_PATH
- - wget -O $KNAPSACK_SPINACH_SUITE_REPORT_PATH http://${KNAPSACK_S3_BUCKET}.s3.amazonaws.com/$KNAPSACK_SPINACH_SUITE_REPORT_PATH || rm $KNAPSACK_SPINACH_SUITE_REPORT_PATH
+ - wget -O $KNAPSACK_RSPEC_SUITE_REPORT_PATH http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/$KNAPSACK_RSPEC_SUITE_REPORT_PATH || rm $KNAPSACK_RSPEC_SUITE_REPORT_PATH
+ - wget -O $KNAPSACK_SPINACH_SUITE_REPORT_PATH http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/$KNAPSACK_SPINACH_SUITE_REPORT_PATH || rm $KNAPSACK_SPINACH_SUITE_REPORT_PATH
- '[[ -f $KNAPSACK_RSPEC_SUITE_REPORT_PATH ]] || echo "{}" > ${KNAPSACK_RSPEC_SUITE_REPORT_PATH}'
- '[[ -f $KNAPSACK_SPINACH_SUITE_REPORT_PATH ]] || echo "{}" > ${KNAPSACK_SPINACH_SUITE_REPORT_PATH}'
+ - mkdir -p rspec_flaky/${CI_PROJECT_NAME}/
+ - wget -O $FLAKY_RSPEC_SUITE_REPORT_PATH http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/$FLAKY_RSPEC_SUITE_REPORT_PATH || rm $FLAKY_RSPEC_SUITE_REPORT_PATH
+ - '[[ -f $FLAKY_RSPEC_SUITE_REPORT_PATH ]] || echo "{}" > ${FLAKY_RSPEC_SUITE_REPORT_PATH}'
-update-knapsack:
- <<: *knapsack-state
+update-tests-metadata:
+ <<: *tests-metadata-state
<<: *dedicated-runner
+ <<: *only-canonical-masters
stage: post-test
+ cache:
+ key: tests_metadata
+ paths:
+ - knapsack/
+ - rspec_flaky/
+ policy: push
script:
- - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec_pg_node_*.json
- - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach_pg_node_*.json
- - '[[ -z ${KNAPSACK_S3_BUCKET} ]] || scripts/sync-reports put $KNAPSACK_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH'
+ - retry gem install fog-aws mime-types
+ - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec-pg_node_*.json
+ - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach-pg_node_*.json
+ - scripts/merge-reports ${FLAKY_RSPEC_SUITE_REPORT_PATH} rspec_flaky/${CI_PROJECT_NAME}/all_node_*.json
+ - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH'
+ - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $FLAKY_RSPEC_SUITE_REPORT_PATH'
- rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json
+ - rm -f rspec_flaky/${CI_PROJECT_NAME}/all_node_*.json
+
+flaky-examples-check:
+ <<: *dedicated-runner
+ <<: *except-docs
+ image: ruby:2.3-alpine
+ services: []
+ before_script: []
+ cache: {}
+ variables:
+ SETUP_DB: "false"
+ USE_BUNDLE_INSTALL: "false"
+ NEW_FLAKY_SPECS_REPORT: rspec_flaky/${CI_PROJECT_NAME}/new_rspec_flaky_examples.json
+ stage: post-test
+ allow_failure: yes
only:
- - master@gitlab-org/gitlab-ce
- - master@gitlab-org/gitlab-ee
- - master@gitlab/gitlabhq
- - master@gitlab/gitlab-ee
+ - branches
+ except:
+ - master
+ artifacts:
+ expire_in: 30d
+ paths:
+ - rspec_flaky/
+ script:
+ - '[[ -f $NEW_FLAKY_SPECS_REPORT ]] || echo "{}" > ${NEW_FLAKY_SPECS_REPORT}'
+ - scripts/merge-reports $NEW_FLAKY_SPECS_REPORT rspec_flaky/${CI_PROJECT_NAME}/new_node_*.json
+ - scripts/detect-new-flaky-examples $NEW_FLAKY_SPECS_REPORT
setup-test-env:
<<: *use-pg
<<: *dedicated-runner
+ <<: *except-docs
stage: prepare
+ cache:
+ <<: *default-cache
script:
- node --version
- - yarn install --pure-lockfile
+ - yarn install --pure-lockfile --cache-folder .yarn-cache
+ - bundle exec rake gettext:po_to_json
- bundle exec rake gitlab:assets:compile
- bundle exec ruby -Ispec -e 'require "spec_helper" ; TestEnv.init'
+ - scripts/gitaly-test-build # Do not use 'bundle exec' here
artifacts:
expire_in: 7d
paths:
@@ -169,112 +271,129 @@ setup-test-env:
- public/assets
- tmp/tests
-rspec pg 0 20: *rspec-knapsack-pg
-rspec pg 1 20: *rspec-knapsack-pg
-rspec pg 2 20: *rspec-knapsack-pg
-rspec pg 3 20: *rspec-knapsack-pg
-rspec pg 4 20: *rspec-knapsack-pg
-rspec pg 5 20: *rspec-knapsack-pg
-rspec pg 6 20: *rspec-knapsack-pg
-rspec pg 7 20: *rspec-knapsack-pg
-rspec pg 8 20: *rspec-knapsack-pg
-rspec pg 9 20: *rspec-knapsack-pg
-rspec pg 10 20: *rspec-knapsack-pg
-rspec pg 11 20: *rspec-knapsack-pg
-rspec pg 12 20: *rspec-knapsack-pg
-rspec pg 13 20: *rspec-knapsack-pg
-rspec pg 14 20: *rspec-knapsack-pg
-rspec pg 15 20: *rspec-knapsack-pg
-rspec pg 16 20: *rspec-knapsack-pg
-rspec pg 17 20: *rspec-knapsack-pg
-rspec pg 18 20: *rspec-knapsack-pg
-rspec pg 19 20: *rspec-knapsack-pg
-
-rspec mysql 0 20: *rspec-knapsack-mysql
-rspec mysql 1 20: *rspec-knapsack-mysql
-rspec mysql 2 20: *rspec-knapsack-mysql
-rspec mysql 3 20: *rspec-knapsack-mysql
-rspec mysql 4 20: *rspec-knapsack-mysql
-rspec mysql 5 20: *rspec-knapsack-mysql
-rspec mysql 6 20: *rspec-knapsack-mysql
-rspec mysql 7 20: *rspec-knapsack-mysql
-rspec mysql 8 20: *rspec-knapsack-mysql
-rspec mysql 9 20: *rspec-knapsack-mysql
-rspec mysql 10 20: *rspec-knapsack-mysql
-rspec mysql 11 20: *rspec-knapsack-mysql
-rspec mysql 12 20: *rspec-knapsack-mysql
-rspec mysql 13 20: *rspec-knapsack-mysql
-rspec mysql 14 20: *rspec-knapsack-mysql
-rspec mysql 15 20: *rspec-knapsack-mysql
-rspec mysql 16 20: *rspec-knapsack-mysql
-rspec mysql 17 20: *rspec-knapsack-mysql
-rspec mysql 18 20: *rspec-knapsack-mysql
-rspec mysql 19 20: *rspec-knapsack-mysql
-
-spinach pg 0 10: *spinach-knapsack-pg
-spinach pg 1 10: *spinach-knapsack-pg
-spinach pg 2 10: *spinach-knapsack-pg
-spinach pg 3 10: *spinach-knapsack-pg
-spinach pg 4 10: *spinach-knapsack-pg
-spinach pg 5 10: *spinach-knapsack-pg
-spinach pg 6 10: *spinach-knapsack-pg
-spinach pg 7 10: *spinach-knapsack-pg
-spinach pg 8 10: *spinach-knapsack-pg
-spinach pg 9 10: *spinach-knapsack-pg
-
-spinach mysql 0 10: *spinach-knapsack-mysql
-spinach mysql 1 10: *spinach-knapsack-mysql
-spinach mysql 2 10: *spinach-knapsack-mysql
-spinach mysql 3 10: *spinach-knapsack-mysql
-spinach mysql 4 10: *spinach-knapsack-mysql
-spinach mysql 5 10: *spinach-knapsack-mysql
-spinach mysql 6 10: *spinach-knapsack-mysql
-spinach mysql 7 10: *spinach-knapsack-mysql
-spinach mysql 8 10: *spinach-knapsack-mysql
-spinach mysql 9 10: *spinach-knapsack-mysql
-
-# Other generic tests
+rspec-pg 0 25: *rspec-metadata-pg
+rspec-pg 1 25: *rspec-metadata-pg
+rspec-pg 2 25: *rspec-metadata-pg
+rspec-pg 3 25: *rspec-metadata-pg
+rspec-pg 4 25: *rspec-metadata-pg
+rspec-pg 5 25: *rspec-metadata-pg
+rspec-pg 6 25: *rspec-metadata-pg
+rspec-pg 7 25: *rspec-metadata-pg
+rspec-pg 8 25: *rspec-metadata-pg
+rspec-pg 9 25: *rspec-metadata-pg
+rspec-pg 10 25: *rspec-metadata-pg
+rspec-pg 11 25: *rspec-metadata-pg
+rspec-pg 12 25: *rspec-metadata-pg
+rspec-pg 13 25: *rspec-metadata-pg
+rspec-pg 14 25: *rspec-metadata-pg
+rspec-pg 15 25: *rspec-metadata-pg
+rspec-pg 16 25: *rspec-metadata-pg
+rspec-pg 17 25: *rspec-metadata-pg
+rspec-pg 18 25: *rspec-metadata-pg
+rspec-pg 19 25: *rspec-metadata-pg
+rspec-pg 20 25: *rspec-metadata-pg
+rspec-pg 21 25: *rspec-metadata-pg
+rspec-pg 22 25: *rspec-metadata-pg
+rspec-pg 23 25: *rspec-metadata-pg
+rspec-pg 24 25: *rspec-metadata-pg
+
+rspec-mysql 0 25: *rspec-metadata-mysql
+rspec-mysql 1 25: *rspec-metadata-mysql
+rspec-mysql 2 25: *rspec-metadata-mysql
+rspec-mysql 3 25: *rspec-metadata-mysql
+rspec-mysql 4 25: *rspec-metadata-mysql
+rspec-mysql 5 25: *rspec-metadata-mysql
+rspec-mysql 6 25: *rspec-metadata-mysql
+rspec-mysql 7 25: *rspec-metadata-mysql
+rspec-mysql 8 25: *rspec-metadata-mysql
+rspec-mysql 9 25: *rspec-metadata-mysql
+rspec-mysql 10 25: *rspec-metadata-mysql
+rspec-mysql 11 25: *rspec-metadata-mysql
+rspec-mysql 12 25: *rspec-metadata-mysql
+rspec-mysql 13 25: *rspec-metadata-mysql
+rspec-mysql 14 25: *rspec-metadata-mysql
+rspec-mysql 15 25: *rspec-metadata-mysql
+rspec-mysql 16 25: *rspec-metadata-mysql
+rspec-mysql 17 25: *rspec-metadata-mysql
+rspec-mysql 18 25: *rspec-metadata-mysql
+rspec-mysql 19 25: *rspec-metadata-mysql
+rspec-mysql 20 25: *rspec-metadata-mysql
+rspec-mysql 21 25: *rspec-metadata-mysql
+rspec-mysql 22 25: *rspec-metadata-mysql
+rspec-mysql 23 25: *rspec-metadata-mysql
+rspec-mysql 24 25: *rspec-metadata-mysql
+
+spinach-pg 0 5: *spinach-metadata-pg
+spinach-pg 1 5: *spinach-metadata-pg
+spinach-pg 2 5: *spinach-metadata-pg
+spinach-pg 3 5: *spinach-metadata-pg
+spinach-pg 4 5: *spinach-metadata-pg
+
+spinach-mysql 0 5: *spinach-metadata-mysql
+spinach-mysql 1 5: *spinach-metadata-mysql
+spinach-mysql 2 5: *spinach-metadata-mysql
+spinach-mysql 3 5: *spinach-metadata-mysql
+spinach-mysql 4 5: *spinach-metadata-mysql
+
+# Static analysis jobs
.ruby-static-analysis: &ruby-static-analysis
+ <<: *pull-cache
variables:
SIMPLECOV: "false"
SETUP_DB: "false"
- USE_BUNDLE_INSTALL: "true"
-.exec: &exec
+.rake-exec: &rake-exec
<<: *ruby-static-analysis
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
stage: test
script:
- - bundle exec $CI_JOB_NAME
+ - bundle exec rake $CI_JOB_NAME
-rubocop:
+static-analysis:
<<: *ruby-static-analysis
<<: *dedicated-runner
+ <<: *except-docs
stage: test
script:
- - bundle exec "rubocop --require rubocop-rspec"
-
-rake haml_lint: *exec
-rake scss_lint: *exec
-rake config_lint: *exec
-rake brakeman: *exec
-rake flay: *exec
-license_finder: *exec
-rake downtime_check:
- <<: *exec
+ - scripts/static-analysis
+
+# Documentation checks:
+# - Check validity of relative links
+# - Make sure cURL examples in API docs use the full switches
+docs lint:
+ <<: *dedicated-runner
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine"
+ stage: test
+ cache: {}
+ dependencies: []
+ before_script: []
+ script:
+ - scripts/lint-doc.sh
+ - mv doc/ /nanoc/content/
+ - cd /nanoc
+ # Build HTML from Markdown
+ - bundle exec nanoc
+ # Check the internal links
+ - bundle exec nanoc check internal_links
+
+downtime_check:
+ <<: *rake-exec
except:
- master
- tags
- /^[\d-]+-stable(-ee)?$/
+ - /(^docs[\/-].*|.*-docs$)/
-rake ee_compat_check:
- <<: *exec
+ee_compat_check:
+ <<: *rake-exec
only:
- branches@gitlab-org/gitlab-ce
except:
- master
- tags
- - /^[\d-]+-stable(-ee)?$/
+ - /^[\d-]+-stable(-ee)?/
allow_failure: yes
cache:
key: "ee_compat_check_repo"
@@ -287,42 +406,74 @@ rake ee_compat_check:
paths:
- ee_compat_check/patches/*.patch
+# DB migration, rollback, and seed jobs
.db-migrate-reset: &db-migrate-reset
- stage: test
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
+ stage: test
script:
- bundle exec rake db:migrate:reset
-rake pg db:migrate:reset:
+db:migrate:reset-pg:
<<: *db-migrate-reset
<<: *use-pg
-rake mysql db:migrate:reset:
+db:migrate:reset-mysql:
<<: *db-migrate-reset
<<: *use-mysql
-.db-rollback: &db-rollback
+.migration-paths: &migration-paths
+ <<: *dedicated-runner
+ <<: *only-canonical-masters
+ <<: *pull-cache
stage: test
+ variables:
+ SETUP_DB: "false"
+ script:
+ - git fetch origin v8.14.10
+ - git checkout -f FETCH_HEAD
+ - bundle install $BUNDLE_INSTALL_FLAGS
+ - cp config/gitlab.yml.example config/gitlab.yml
+ - bundle exec rake db:drop db:create db:schema:load db:seed_fu
+ - git checkout $CI_COMMIT_SHA
+ - bundle install $BUNDLE_INSTALL_FLAGS
+ - . scripts/prepare_build.sh
+ - bundle exec rake db:migrate
+
+migration:path-pg:
+ <<: *migration-paths
+ <<: *use-pg
+
+migration:path-mysql:
+ <<: *migration-paths
+ <<: *use-mysql
+
+.db-rollback: &db-rollback
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
+ stage: test
script:
- bundle exec rake db:rollback STEP=120
- bundle exec rake db:migrate
-rake pg db:rollback:
+db:rollback-pg:
<<: *db-rollback
<<: *use-pg
-rake mysql db:rollback:
+db:rollback-mysql:
<<: *db-rollback
<<: *use-mysql
.db-seed_fu: &db-seed_fu
- stage: test
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
+ stage: test
variables:
SIZE: "1"
SETUP_DB: "false"
- RAILS_ENV: "development"
script:
- git clone https://gitlab.com/gitlab-org/gitlab-test.git
/home/git/repositories/gitlab-org/gitlab-test.git
@@ -333,17 +484,20 @@ rake mysql db:rollback:
paths:
- log/development.log
-rake pg db:seed_fu:
+db:seed_fu-pg:
<<: *db-seed_fu
<<: *use-pg
-rake mysql db:seed_fu:
+db:seed_fu-mysql:
<<: *db-seed_fu
<<: *use-mysql
-rake gitlab:assets:compile:
- stage: test
+# Frontend-related jobs
+gitlab:assets:compile:
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
+ stage: test
dependencies: []
variables:
NODE_ENV: "production"
@@ -352,101 +506,66 @@ rake gitlab:assets:compile:
USE_DB: "false"
SKIP_STORAGE_VALIDATION: "true"
WEBPACK_REPORT: "true"
+ NO_COMPRESSION: "true"
script:
- - bundle exec rake yarn:install gitlab:assets:compile
+ - yarn install --pure-lockfile --production --cache-folder .yarn-cache
+ - bundle exec rake gettext:po_to_json
+ - bundle exec rake gitlab:assets:compile
artifacts:
name: webpack-report
expire_in: 31d
paths:
- - webpack-report/
+ - webpack-report/
-rake karma:
- cache:
- paths:
- - vendor/ruby
- stage: test
+karma:
<<: *use-pg
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
+ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.13-chrome-59.0-node-7.1-postgresql-9.6"
+ stage: test
variables:
BABEL_ENV: "coverage"
+ CHROME_LOG_FILE: "chrome_debug.log"
script:
+ - scripts/gitaly-test-spawn
+ - bundle exec rake gettext:po_to_json
- bundle exec rake karma
coverage: '/^Statements *: (\d+\.\d+%)/'
artifacts:
name: coverage-javascript
expire_in: 31d
+ when: always
paths:
+ - chrome_debug.log
- coverage-javascript/
-docs:check:apilint:
- image: "phusion/baseimage"
- stage: test
- <<: *dedicated-runner
- cache: {}
- dependencies: []
- before_script: []
- script:
- - scripts/lint-doc.sh
-
-docs:check:links:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine"
- stage: test
- <<: *dedicated-runner
- cache: {}
- dependencies: []
+codeclimate:
+ <<: *except-docs
+ <<: *pull-cache
before_script: []
- script:
- - mv doc/ /nanoc/content/
- - cd /nanoc
- # Build HTML from Markdown
- - bundle exec nanoc
- # Check the internal links
- - bundle exec nanoc check internal_links
-
-bundler:audit:
+ image: docker:latest
stage: test
- <<: *ruby-static-analysis
- <<: *dedicated-runner
- only:
- - master@gitlab-org/gitlab-ce
- - master@gitlab-org/gitlab-ee
- - master@gitlab/gitlabhq
- - master@gitlab/gitlab-ee
- script:
- - "bundle exec bundle-audit check --update --ignore CVE-2016-4658"
-
-.migration-paths: &migration-paths
- stage: test
- <<: *dedicated-runner
variables:
SETUP_DB: "false"
- only:
- - master@gitlab-org/gitlab-ce
- - master@gitlab-org/gitlab-ee
- - master@gitlab/gitlabhq
- - master@gitlab/gitlab-ee
+ DOCKER_DRIVER: overlay
+ services:
+ - docker:dind
script:
- - git fetch origin v8.14.10
- - git checkout -f FETCH_HEAD
- - bundle install $BUNDLE_INSTALL_FLAGS
- - bundle exec rake db:drop db:create db:schema:load db:seed_fu
- - git checkout $CI_COMMIT_SHA
- - bundle install $BUNDLE_INSTALL_FLAGS
- - . scripts/prepare_build.sh
- - bundle exec rake db:migrate
-
-migration pg paths:
- <<: *migration-paths
- <<: *use-pg
-
-migration mysql paths:
- <<: *migration-paths
- <<: *use-mysql
+ - cp .rubocop.yml .rubocop.yml.bak
+ - grep -v "rubocop-gitlab-security" .rubocop.yml.bak > .rubocop.yml
+ - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > raw_codeclimate.json
+ - cat raw_codeclimate.json | docker run -i stedolan/jq -c 'map({check_name,fingerprint,location})' > codeclimate.json
+ - mv .rubocop.yml.bak .rubocop.yml
+ artifacts:
+ paths: [codeclimate.json]
coverage:
+ <<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
stage: post-test
services: []
- <<: *dedicated-runner
variables:
SETUP_DB: "false"
USE_BUNDLE_INSTALL: "true"
@@ -460,16 +579,13 @@ coverage:
- coverage/index.html
- coverage/assets/
-lint:javascript:
- <<: *dedicated-runner
- stage: test
- before_script: []
- script:
- - yarn run eslint
-
lint:javascript:report:
<<: *dedicated-runner
+ <<: *except-docs
+ <<: *pull-cache
stage: post-test
+ dependencies:
+ - setup-test-env
before_script: []
script:
- find app/ spec/ -name '*.js' -exec sed --in-place 's|/\* eslint-disable .*\*/||' {} \; # run report over all files
@@ -480,49 +596,15 @@ lint:javascript:report:
paths:
- eslint-report.html
-# Trigger docs build
-# https://gitlab.com/gitlab-com/doc-gitlab-com/blob/master/README.md#deployment-process
-trigger_docs:
- stage: post-test
- image: "alpine"
- <<: *dedicated-runner
- before_script:
- - apk update && apk add curl
- variables:
- GIT_STRATEGY: "none"
- cache: {}
- artifacts: {}
- script:
- - "HTTP_STATUS=$(curl -X POST -F token=${DOCS_TRIGGER_TOKEN} -F ref=master -F variables[PROJECT]=${CI_PROJECT_NAME} --silent --output curl.log --write-out '%{http_code}' https://gitlab.com/api/v3/projects/1794617/trigger/builds)"
- - if [ "${HTTP_STATUS}" -ne "201" ]; then echo "Error ${HTTP_STATUS}"; cat curl.log; echo; exit 1; fi
- only:
- - master@gitlab-org/gitlab-ce
- - master@gitlab-org/gitlab-ee
-
-# Notify slack in the end
-notify:slack:
- stage: post-test
- <<: *dedicated-runner
- variables:
- SETUP_DB: "false"
- USE_BUNDLE_INSTALL: "false"
- script:
- - ./scripts/notify_slack.sh "#development" "Build on \`$CI_COMMIT_REF_NAME\` failed! Commit \`$(git log -1 --oneline)\` See <https://gitlab.com/gitlab-org/$(basename "$PWD")/commit/"$CI_COMMIT_SHA"/pipelines>"
- when: on_failure
- only:
- - master@gitlab-org/gitlab-ce
- - tags@gitlab-org/gitlab-ce
- - master@gitlab-org/gitlab-ee
- - tags@gitlab-org/gitlab-ee
-
pages:
+ <<: *dedicated-runner
+ <<: *pull-cache
before_script: []
stage: pages
- <<: *dedicated-runner
dependencies:
- coverage
- - rake karma
- - rake gitlab:assets:compile
+ - karma
+ - gitlab:assets:compile
- lint:javascript:report
script:
- mv public/ .public/
@@ -542,6 +624,7 @@ pages:
# rubygems.org in the future.
cache gems:
<<: *dedicated-runner
+ <<: *pull-cache
only:
- tags
variables:
@@ -554,3 +637,11 @@ cache gems:
only:
- master@gitlab-org/gitlab-ce
- master@gitlab-org/gitlab-ee
+
+gitlab_git_test:
+ <<: *pull-cache
+ <<: *except-docs
+ variables:
+ SETUP_DB: "false"
+ script:
+ - spec/support/prepare-gitlab-git-test-for-commit --check-for-changes
diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md
index 241d90a204b..aec734870d6 100644
--- a/.gitlab/issue_templates/Bug.md
+++ b/.gitlab/issue_templates/Bug.md
@@ -1,3 +1,24 @@
+Please read this!
+
+Before opening a new issue, make sure to search for keywords in the issues
+filtered by the "regression" or "bug" label.
+
+For the Community Edition issue tracker:
+
+- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=regression
+- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=bug
+
+For the Enterprise Edition issue tracker:
+
+- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=regression
+- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug
+
+and verify the issue you're about to submit isn't a duplicate.
+
+Please remove this notice if you're confident your issue isn't a duplicate.
+
+------
+
### Summary
(Summarize the bug encountered concisely)
@@ -6,6 +27,12 @@
(How one can reproduce the issue - this is very important)
+### Example Project
+
+(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report)
+
+(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version)
+
### What is the current *bug* behavior?
(What actually happens)
@@ -26,6 +53,7 @@ logs, and code as it's very hard to read otherwise.)
#### Results of GitLab environment info
<details>
+<summary>Expand for output related to GitLab environment info</summary>
<pre>
(For installations with omnibus-gitlab package run and paste the output of:
@@ -40,6 +68,7 @@ logs, and code as it's very hard to read otherwise.)
#### Results of GitLab application Check
<details>
+<summary>Expand for output related to the GitLab application check</summary>
<pre>
(For installations with omnibus-gitlab package run and paste the output of:
@@ -56,3 +85,5 @@ logs, and code as it's very hard to read otherwise.)
### Possible fixes
(If you can, link to the line of code that might be responsible for the problem)
+
+/label ~bug
diff --git a/.gitlab/issue_templates/Feature Proposal.md b/.gitlab/issue_templates/Feature Proposal.md
index 2636010e2fb..1278061a410 100644
--- a/.gitlab/issue_templates/Feature Proposal.md
+++ b/.gitlab/issue_templates/Feature Proposal.md
@@ -1,3 +1,22 @@
+Please read this!
+
+Before opening a new issue, make sure to search for keywords in the issues
+filtered by the "feature proposal" label:
+
+For the Community Edition issue tracker:
+
+- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=feature+proposal
+
+For the Enterprise Edition issue tracker:
+
+- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=feature+proposal
+
+and verify the issue you're about to submit isn't a duplicate.
+
+Please remove this notice if you're confident your issue isn't a duplicate.
+
+------
+
### Description
(Include problem, use cases, benefits, and/or goals)
@@ -8,10 +27,24 @@
### Documentation blurb
-(Write the start of the documentation of this feature here, include:
+#### Overview
+
+What is it?
+Why should someone use this feature?
+What is the underlying (business) problem?
+How do you use this feature?
+
+#### Use cases
+
+Who is this for? Provide one or more use cases.
+
+### Feature checklist
+
+Make sure these are completed before closing the issue,
+with a link to the relevant commit.
-1. Why should someone use it; what's the underlying problem.
-2. What is the solution.
-3. How does someone use this
+- [ ] [Feature assurance](https://about.gitlab.com/handbook/product/#feature-assurance)
+- [ ] Documentation
+- [ ] Added to [features.yml](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/features.yml)
-During implementation, this can then be copied and used as a starter for the documentation.)
+/label ~"feature proposal" \ No newline at end of file
diff --git a/.gitlab/merge_request_templates/Database Changes.md b/.gitlab/merge_request_templates/Database Changes.md
new file mode 100644
index 00000000000..2a5c8267872
--- /dev/null
+++ b/.gitlab/merge_request_templates/Database Changes.md
@@ -0,0 +1,73 @@
+Remove this section and replace it with a description of your MR. Also follow the
+checklist below and check off any tasks that are done. If a certain task can not
+be done you should explain so in the MR body. You are free to remove any
+sections that do not apply to your MR.
+
+When gathering statistics (e.g. the output of `EXPLAIN ANALYZE`) you should make
+sure your database has enough data. Having around 10 000 rows in the tables
+being queries should provide a reasonable estimate of how a query will behave.
+Also make sure that PostgreSQL uses the following settings:
+
+* `random_page_cost`: `1`
+* `work_mem`: `16MB`
+* `maintenance_work_mem`: at least `64MB`
+* `shared_buffers`: at least `256MB`
+
+If you have access to GitLab.com's staging environment you should also run your
+measurements there, and include the results in this MR.
+
+## Database Checklist
+
+When adding migrations:
+
+- [ ] Updated `db/schema.rb`
+- [ ] Added a `down` method so the migration can be reverted
+- [ ] Added the output of the migration(s) to the MR body
+- [ ] Added the execution time of the migration(s) to the MR body
+- [ ] Added tests for the migration in `spec/migrations` if necessary (e.g. when
+ migrating data)
+- [ ] Made sure the migration won't interfere with a running GitLab cluster,
+ for example by disabling transactions for long running migrations
+
+When adding or modifying queries to improve performance:
+
+- [ ] Included the raw SQL queries of the relevant queries
+- [ ] Included the output of `EXPLAIN ANALYZE` and execution timings of the
+ relevant queries
+- [ ] Added tests for the relevant changes
+
+When adding indexes:
+
+- [ ] Described the need for these indexes in the MR body
+- [ ] Made sure existing indexes can not be reused instead
+
+When adding foreign keys to existing tables:
+
+- [ ] Included a migration to remove orphaned rows in the source table
+- [ ] Removed any instances of `dependent: ...` that may no longer be necessary
+
+When adding tables:
+
+- [ ] Ordered columns based on their type sizes in descending order
+- [ ] Added foreign keys if necessary
+- [ ] Added indexes if necessary
+
+When removing columns, tables, indexes or other structures:
+
+- [ ] Removed these in a post-deployment migration
+- [ ] Made sure the application no longer uses (or ignores) these structures
+
+## General Checklist
+
+- [ ] [Changelog entry](https://docs.gitlab.com/ce/development/changelog.html) added, if necessary
+- [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)
+- [ ] API support added
+- [ ] Tests added for this feature/bug
+- Review
+ - [ ] Has been reviewed by UX
+ - [ ] Has been reviewed by Frontend
+ - [ ] Has been reviewed by Backend
+ - [ ] Has been reviewed by Database
+- [ ] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html)
+- [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides)
+- [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits)
diff --git a/.haml-lint.yml b/.haml-lint.yml
index 528f99d08d2..32c7de0fb78 100644
--- a/.haml-lint.yml
+++ b/.haml-lint.yml
@@ -35,9 +35,21 @@ linters:
HtmlAttributes:
enabled: true
+ IdNames:
+ enabled: false
+
ImplicitDiv:
enabled: true
+ InlineJavaScript:
+ enabled: true
+
+ InlineStyles:
+ enabled: false
+
+ InstanceVariables:
+ enabled: false
+
LeadingCommentSpace:
enabled: false
@@ -54,6 +66,9 @@ linters:
ObjectReferenceAttributes:
enabled: true
+ RepeatedId:
+ enabled: false
+
RuboCop:
enabled: false
# These cops are incredibly noisy when it comes to HAML templates, so we
@@ -101,3 +116,6 @@ linters:
UnnecessaryStringOutput:
enabled: true
+
+ ViewLength:
+ enabled: false
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000000..72906051c5c
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+7.5 \ No newline at end of file
diff --git a/.rspec b/.rspec
deleted file mode 100644
index 35f4d7441e0..00000000000
--- a/.rspec
+++ /dev/null
@@ -1,2 +0,0 @@
---color
---format Fuubar
diff --git a/.rubocop.yml b/.rubocop.yml
index e73500be2a9..23bb0fa8be8 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,11 +1,13 @@
require:
- rubocop-rspec
+ - rubocop-gitlab-security
- ./rubocop/rubocop
inherit_from: .rubocop_todo.yml
AllCops:
TargetRubyVersion: 2.3
+ TargetRailsVersion: 4.2
# Cop names are not d§splayed in offense messages by default. Change behavior
# by overriding DisplayCopNames, or by giving the -D/--display-cop-names
# option.
@@ -29,34 +31,241 @@ AllCops:
Bundler/OrderedGems:
Enabled: false
-# Style #######################################################################
+# Layout ######################################################################
# Check indentation of private/protected visibility modifiers.
-Style/AccessModifierIndentation:
- Enabled: true
-
-# Check the naming of accessor methods for get_/set_.
-Style/AccessorMethodName:
- Enabled: false
-
-# Use alias_method instead of alias.
-Style/Alias:
- EnforcedStyle: prefer_alias_method
+Layout/AccessModifierIndentation:
Enabled: true
# Align the elements of an array literal if they span more than one line.
-Style/AlignArray:
+Layout/AlignArray:
Enabled: true
# Align the elements of a hash literal if they span more than one line.
-Style/AlignHash:
+Layout/AlignHash:
Enabled: true
# Here we check if the parameters on a multi-line method call or
# definition are aligned.
-Style/AlignParameters:
+Layout/AlignParameters:
+ Enabled: false
+
+# Put end statement of multiline block on its own line.
+Layout/BlockEndNewline:
+ Enabled: true
+
+# Indentation of when in a case/when/[else/]end.
+Layout/CaseIndentation:
+ Enabled: true
+
+# Indentation of comments.
+Layout/CommentIndentation:
+ Enabled: true
+
+# Multi-line method chaining should be done with leading dots.
+Layout/DotPosition:
+ Enabled: true
+ EnforcedStyle: leading
+
+# Align elses and elsifs correctly.
+Layout/ElseAlignment:
+ Enabled: true
+
+# Add an empty line after magic comments to separate them from code.
+Layout/EmptyLineAfterMagicComment:
+ Enabled: false
+
+# Use empty lines between defs.
+Layout/EmptyLineBetweenDefs:
+ Enabled: true
+
+# Don't use several empty lines in a row.
+Layout/EmptyLines:
+ Enabled: true
+
+# Keep blank lines around access modifiers.
+Layout/EmptyLinesAroundAccessModifier:
+ Enabled: true
+
+# Keeps track of empty lines around block bodies.
+Layout/EmptyLinesAroundBlockBody:
+ Enabled: true
+
+# Keeps track of empty lines around class bodies.
+Layout/EmptyLinesAroundClassBody:
+ Enabled: true
+
+# Keeps track of empty lines around exception handling keywords.
+Layout/EmptyLinesAroundExceptionHandlingKeywords:
+ Enabled: false
+
+# Keeps track of empty lines around method bodies.
+Layout/EmptyLinesAroundMethodBody:
+ Enabled: true
+
+# Keeps track of empty lines around module bodies.
+Layout/EmptyLinesAroundModuleBody:
+ Enabled: true
+
+# Use Unix-style line endings.
+Layout/EndOfLine:
+ Enabled: true
+
+# Checks for a line break before the first parameter in a multi-line method
+# parameter definition.
+Layout/FirstMethodParameterLineBreak:
+ Enabled: true
+
+# Keep indentation straight.
+Layout/IndentationConsistency:
+ Enabled: true
+
+# Use 2 spaces for indentation.
+Layout/IndentationWidth:
+ Enabled: true
+
+# Checks the indentation of the first line of the right-hand-side of a
+# multi-line assignment.
+Layout/IndentAssignment:
+ Enabled: true
+
+# This cops checks the indentation of the here document bodies.
+Layout/IndentHeredoc:
+ Enabled: false
+
+# Comments should start with a space.
+Layout/LeadingCommentSpace:
+ Enabled: true
+
+# Checks that the closing brace in an array literal is either on the same line
+# as the last array element, or a new line.
+Layout/MultilineArrayBraceLayout:
+ Enabled: true
+ EnforcedStyle: symmetrical
+
+# Ensures newlines after multiline block do statements.
+Layout/MultilineBlockLayout:
+ Enabled: true
+
+# Checks that the closing brace in a hash literal is either on the same line as
+# the last hash element, or a new line.
+Layout/MultilineHashBraceLayout:
+ Enabled: true
+ EnforcedStyle: symmetrical
+
+# Checks that the closing brace in a method call is either on the same line as
+# the last method argument, or a new line.
+Layout/MultilineMethodCallBraceLayout:
+ Enabled: false
+ EnforcedStyle: symmetrical
+
+# Checks indentation of method calls with the dot operator that span more than
+# one line.
+Layout/MultilineMethodCallIndentation:
+ Enabled: false
+
+# Checks that the closing brace in a method definition is symmetrical with
+# respect to the opening brace and the method parameters.
+Layout/MultilineMethodDefinitionBraceLayout:
+ Enabled: false
+
+# Checks indentation of binary operations that span more than one line.
+Layout/MultilineOperationIndentation:
+ Enabled: true
+ EnforcedStyle: indented
+
+# Use spaces after colons.
+Layout/SpaceAfterColon:
+ Enabled: true
+
+# Use spaces after commas.
+Layout/SpaceAfterComma:
+ Enabled: true
+
+# Do not put a space between a method name and the opening parenthesis in a
+# method definition.
+Layout/SpaceAfterMethodName:
+ Enabled: true
+
+# Tracks redundant space after the ! operator.
+Layout/SpaceAfterNot:
+ Enabled: true
+
+# Use spaces after semicolons.
+Layout/SpaceAfterSemicolon:
+ Enabled: true
+
+# Use space around equals in parameter default
+Layout/SpaceAroundEqualsInParameterDefault:
+ Enabled: true
+
+# Use a space around keywords if appropriate.
+Layout/SpaceAroundKeyword:
+ Enabled: true
+
+# Use a single space around operators.
+Layout/SpaceAroundOperators:
+ Enabled: true
+
+# Checks that block braces have or don't have a space before the opening
+# brace depending on configuration.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: space, no_space
+Layout/SpaceBeforeBlockBraces:
+ Enabled: true
+
+# No spaces before commas.
+Layout/SpaceBeforeComma:
+ Enabled: true
+
+# Checks for missing space between code and a comment on the same line.
+Layout/SpaceBeforeComment:
+ Enabled: true
+
+# No spaces before semicolons.
+Layout/SpaceBeforeSemicolon:
+ Enabled: true
+
+# Checks for spaces inside square brackets.
+Layout/SpaceInsideBrackets:
+ Enabled: true
+
+# Use spaces inside hash literal braces - or don't.
+Layout/SpaceInsideHashLiteralBraces:
+ Enabled: true
+
+# No spaces inside range literals.
+Layout/SpaceInsideRangeLiteral:
+ Enabled: true
+
+# Checks for padding/surrounding spaces inside string interpolation.
+Layout/SpaceInsideStringInterpolation:
+ EnforcedStyle: no_space
+ Enabled: true
+
+# No hard tabs.
+Layout/Tab:
+ Enabled: true
+
+# Checks trailing blank lines and final newline.
+Layout/TrailingBlankLines:
+ Enabled: true
+
+# Avoid trailing whitespace.
+Layout/TrailingWhitespace:
+ Enabled: true
+
+# Style #######################################################################
+
+# Check the naming of accessor methods for get_/set_.
+Style/AccessorMethodName:
Enabled: false
+# Use alias_method instead of alias.
+Style/Alias:
+ EnforcedStyle: prefer_alias_method
+ Enabled: true
+
# Whether `and` and `or` are banned only in conditionals (conditionals)
# or completely (always).
Style/AndOr:
@@ -91,10 +300,6 @@ Style/BlockComments:
Style/BlockDelimiters:
Enabled: true
-# Put end statement of multiline block on its own line.
-Style/BlockEndNewline:
- Enabled: true
-
# This cop checks for braces around the last parameter in a method call
# if the last parameter is a hash.
Style/BracesAroundHashParameters:
@@ -104,10 +309,6 @@ Style/BracesAroundHashParameters:
Style/CaseEquality:
Enabled: false
-# Indentation of when in a case/when/[else/]end.
-Style/CaseIndentation:
- Enabled: true
-
# Checks for uses of character literals.
Style/CharacterLiteral:
Enabled: true
@@ -142,10 +343,6 @@ Style/ColonMethodCall:
Style/CommentAnnotation:
Enabled: false
-# Indentation of comments.
-Style/CommentIndentation:
- Enabled: true
-
# Check for `if` and `case` statements where each branch is used for
# assignment to the same variable when using the return of the
# condition can be used instead.
@@ -170,46 +367,10 @@ Style/Documentation:
Style/DoubleNegation:
Enabled: false
-# Align elses and elsifs correctly.
-Style/ElseAlignment:
- Enabled: true
-
-# Use empty lines between defs.
-Style/EmptyLineBetweenDefs:
- Enabled: true
-
-# Don't use several empty lines in a row.
-Style/EmptyLines:
- Enabled: true
-
-# Keep blank lines around access modifiers.
-Style/EmptyLinesAroundAccessModifier:
- Enabled: true
-
-# Keeps track of empty lines around block bodies.
-Style/EmptyLinesAroundBlockBody:
- Enabled: true
-
-# Keeps track of empty lines around class bodies.
-Style/EmptyLinesAroundClassBody:
- Enabled: true
-
-# Keeps track of empty lines around method bodies.
-Style/EmptyLinesAroundMethodBody:
- Enabled: true
-
-# Keeps track of empty lines around module bodies.
-Style/EmptyLinesAroundModuleBody:
- Enabled: true
-
# Avoid the use of END blocks.
Style/EndBlock:
Enabled: true
-# Use Unix-style line endings.
-Style/EndOfLine:
- Enabled: true
-
# Favor the use of Fixnum#even? && Fixnum#odd?
Style/EvenOdd:
Enabled: true
@@ -218,11 +379,6 @@ Style/EvenOdd:
Style/FileName:
Enabled: true
-# Checks for a line break before the first parameter in a multi-line method
-# parameter definition.
-Style/FirstMethodParameterLineBreak:
- Enabled: true
-
# Checks for flip flops.
Style/FlipFlop:
Enabled: true
@@ -231,6 +387,10 @@ Style/FlipFlop:
Style/For:
Enabled: true
+# Use a consistent style for format string tokens.
+Style/FormatStringToken:
+ Enabled: false
+
# Checks if there is a magic comment to enforce string literals
Style/FrozenStringLiteralComment:
Enabled: false
@@ -256,31 +416,19 @@ Style/IdenticalConditionalBranches:
Style/IfWithSemicolon:
Enabled: true
-# Checks the indentation of the first line of the right-hand-side of a
-# multi-line assignment.
-Style/IndentAssignment:
- Enabled: true
-
-# Keep indentation straight.
-Style/IndentationConsistency:
- Enabled: true
-
-# Use 2 spaces for indentation.
-Style/IndentationWidth:
- Enabled: true
-
# Use Kernel#loop for infinite loops.
Style/InfiniteLoop:
Enabled: true
+# Use the inverse method instead of `!.method`
+# if an inverse method is defined.
+Style/InverseMethods:
+ Enabled: false
+
# Use lambda.call(...) instead of lambda.(...).
Style/LambdaCall:
Enabled: true
-# Comments should start with a space.
-Style/LeadingCommentSpace:
- Enabled: true
-
# Checks if the method definitions have or don't have parentheses.
Style/MethodDefParentheses:
Enabled: true
@@ -293,55 +441,23 @@ Style/MethodName:
Style/ModuleFunction:
Enabled: false
-# Checks that the closing brace in an array literal is either on the same line
-# as the last array element, or a new line.
-Style/MultilineArrayBraceLayout:
- Enabled: true
- EnforcedStyle: symmetrical
-
# Avoid multi-line chains of blocks.
Style/MultilineBlockChain:
Enabled: true
-# Ensures newlines after multiline block do statements.
-Style/MultilineBlockLayout:
- Enabled: true
-
-# Checks that the closing brace in a hash literal is either on the same line as
-# the last hash element, or a new line.
-Style/MultilineHashBraceLayout:
- Enabled: true
- EnforcedStyle: symmetrical
-
# Do not use then for multi-line if/unless.
Style/MultilineIfThen:
Enabled: true
-# Checks that the closing brace in a method call is either on the same line as
-# the last method argument, or a new line.
-Style/MultilineMethodCallBraceLayout:
- Enabled: false
- EnforcedStyle: symmetrical
-
-# Checks indentation of method calls with the dot operator that span more than
-# one line.
-Style/MultilineMethodCallIndentation:
- Enabled: false
-
-# Checks that the closing brace in a method definition is symmetrical with
-# respect to the opening brace and the method parameters.
-Style/MultilineMethodDefinitionBraceLayout:
- Enabled: false
-
-# Checks indentation of binary operations that span more than one line.
-Style/MultilineOperationIndentation:
- Enabled: true
- EnforcedStyle: indented
-
# Avoid multi-line `? :` (the ternary operator), use if/unless instead.
Style/MultilineTernaryOperator:
Enabled: true
+# Avoid comparing a variable with multiple items in a conditional,
+# use Array#include? instead.
+Style/MultipleComparison:
+ Enabled: false
+
# This cop checks whether some constant value isn't a
# mutable literal (e.g. array or hash).
Style/MutableConstant:
@@ -390,6 +506,15 @@ Style/OpMethod:
Style/ParenthesesAroundCondition:
Enabled: true
+# This cop (by default) checks for uses of methods Hash#has_key? and
+# Hash#has_value? where it enforces Hash#key? and Hash#value?
+# It is configurable to enforce the inverse, using `verbose` method
+# names also.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: short, verbose
+Style/PreferredHashMethods:
+ Enabled: false
+
# Checks for an obsolete RuntimeException argument in raise/fail.
Style/RedundantException:
Enabled: true
@@ -407,68 +532,6 @@ Style/SignalException:
EnforcedStyle: only_raise
Enabled: true
-# Use spaces after colons.
-Style/SpaceAfterColon:
- Enabled: true
-
-# Use spaces after commas.
-Style/SpaceAfterComma:
- Enabled: true
-
-# Do not put a space between a method name and the opening parenthesis in a
-# method definition.
-Style/SpaceAfterMethodName:
- Enabled: true
-
-# Tracks redundant space after the ! operator.
-Style/SpaceAfterNot:
- Enabled: true
-
-# Use spaces after semicolons.
-Style/SpaceAfterSemicolon:
- Enabled: true
-
-# Use space around equals in parameter default
-Style/SpaceAroundEqualsInParameterDefault:
- Enabled: true
-
-# Use a space around keywords if appropriate.
-Style/SpaceAroundKeyword:
- Enabled: true
-
-# Use a single space around operators.
-Style/SpaceAroundOperators:
- Enabled: true
-
-# No spaces before commas.
-Style/SpaceBeforeComma:
- Enabled: true
-
-# Checks for missing space between code and a comment on the same line.
-Style/SpaceBeforeComment:
- Enabled: true
-
-# No spaces before semicolons.
-Style/SpaceBeforeSemicolon:
- Enabled: true
-
-# Checks for spaces inside square brackets.
-Style/SpaceInsideBrackets:
- Enabled: true
-
-# Use spaces inside hash literal braces - or don't.
-Style/SpaceInsideHashLiteralBraces:
- Enabled: true
-
-# No spaces inside range literals.
-Style/SpaceInsideRangeLiteral:
- Enabled: true
-
-# Checks for padding/surrounding spaces inside string interpolation.
-Style/SpaceInsideStringInterpolation:
- EnforcedStyle: no_space
- Enabled: true
-
# Check for the usage of parentheses around stabby lambda arguments.
Style/StabbyLambdaParentheses:
EnforcedStyle: require_parentheses
@@ -484,17 +547,19 @@ Style/StringMethods:
intern: to_sym
Enabled: true
-# No hard tabs.
-Style/Tab:
- Enabled: true
-
-# Checks trailing blank lines and final newline.
-Style/TrailingBlankLines:
- Enabled: true
+# Use %i or %I for arrays of symbols.
+Style/SymbolArray:
+ Enabled: false
# This cop checks for trailing comma in array and hash literals.
Style/TrailingCommaInLiteral:
- Enabled: false
+ Enabled: true
+ EnforcedStyleForMultiline: no_comma
+
+# This cop checks for trailing comma in argument lists.
+Style/TrailingCommaInArguments:
+ Enabled: true
+ EnforcedStyleForMultiline: no_comma
# Checks for %W when interpolation is not needed.
Style/UnneededCapitalW:
@@ -533,6 +598,10 @@ Style/WhileUntilModifier:
Style/WordArray:
Enabled: true
+# Do not use literals as the first operand of a comparison.
+Style/YodaCondition:
+ Enabled: false
+
# Use `proc` instead of `Proc.new`.
Style/Proc:
Enabled: true
@@ -543,7 +612,7 @@ Style/Proc:
# branches, and conditions.
Metrics/AbcSize:
Enabled: true
- Max: 57.08
+ Max: 56.96
# This cop checks if the length of a block exceeds some maximum value.
Metrics/BlockLength:
@@ -588,6 +657,11 @@ Metrics/PerceivedComplexity:
# Lint ########################################################################
+# Checks for ambiguous block association with method when param passed without
+# parentheses.
+Lint/AmbiguousBlockAssociation:
+ Enabled: false
+
# Checks for ambiguous operators in the first argument of a method invocation
# without parentheses.
Lint/AmbiguousOperator:
@@ -789,6 +863,10 @@ Lint/Void:
# Performance #################################################################
+# Use `caller(n..n)` instead of `caller`.
+Performance/Caller:
+ Enabled: false
+
# Use `casecmp` rather than `downcase ==`.
Performance/Casecmp:
Enabled: true
@@ -863,14 +941,23 @@ Rails/ActionFilter:
Enabled: true
EnforcedStyle: action
+# Check that models subclass ApplicationRecord.
+Rails/ApplicationRecord:
+ Enabled: false
+
+# Enforce using `blank?` and `present?`.
+Rails/Blank:
+ Enabled: false
+
# Checks the correct usage of date aware methods, such as `Date.today`,
# `Date.current`, etc.
Rails/Date:
Enabled: false
# Prefer delegate method for delegations.
+# Disabled per https://gitlab.com/gitlab-org/gitlab-ce/issues/35869
Rails/Delegate:
- Enabled: true
+ Enabled: false
# This cop checks dynamic `find_by_*` methods.
Rails/DynamicFindBy:
@@ -919,10 +1006,18 @@ Rails/OutputSafety:
Rails/PluralizationGrammar:
Enabled: true
+# Enforce using `blank?` and `present?`.
+Rails/Present:
+ Enabled: false
+
# Checks for `read_attribute(:attr)` and `write_attribute(:attr, val)`.
Rails/ReadWriteAttribute:
Enabled: false
+# Do not assign relative date to constants.
+Rails/RelativeDateConstant:
+ Enabled: false
+
# Checks the arguments of ActiveRecord scopes.
Rails/ScopeArgs:
Enabled: true
@@ -945,12 +1040,16 @@ RSpec/AnyInstance:
RSpec/BeEql:
Enabled: true
+# We don't enforce this as we use this technique in a few places.
+RSpec/BeforeAfterAll:
+ Enabled: false
+
# Check that the first argument to the top level describe is the tested class or
# module.
RSpec/DescribeClass:
Enabled: false
-# Use `described_class` for tested class / module.
+# Checks that the second argument to `describe` specifies a method.
RSpec/DescribeMethod:
Enabled: false
@@ -958,10 +1057,15 @@ RSpec/DescribeMethod:
RSpec/DescribeSymbol:
Enabled: true
-# Checks that the second argument to top level describe is the tested method
-# name.
+# Checks that tests use `described_class`.
RSpec/DescribedClass:
- Enabled: false
+ Enabled: true
+
+# Checks if an example group does not include any tests.
+RSpec/EmptyExampleGroup:
+ Enabled: true
+ CustomIncludeMethods:
+ - run_permission_checks
# Checks for long example.
RSpec/ExampleLength:
@@ -981,17 +1085,34 @@ RSpec/ExampleWording:
RSpec/ExpectActual:
Enabled: true
+# Checks for opportunities to use `expect { … }.to output`.
+RSpec/ExpectOutput:
+ Enabled: true
+
# Checks the file and folder naming of the spec file.
RSpec/FilePath:
- Enabled: false
- CustomTransform:
- RuboCop: rubocop
- RSpec: rspec
+ Enabled: true
+ IgnoreMethods: true
+ Exclude:
+ - 'qa/**/*'
+ - 'spec/javascripts/fixtures/*'
+ - 'spec/requests/api/v3/*'
# Checks if there are focused specs.
RSpec/Focus:
Enabled: true
+# Checks the arguments passed to `before`, `around`, and `after`.
+RSpec/HookArgument:
+ Enabled: true
+ EnforcedStyle: implicit
+
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: is_expected, should
+RSpec/ImplicitExpect:
+ Enabled: true
+ EnforcedStyle: is_expected
+
# Checks for the usage of instance variables.
RSpec/InstanceVariable:
Enabled: false
@@ -1037,6 +1158,13 @@ RSpec/NotToNot:
RSpec/RepeatedDescription:
Enabled: false
+# Ensure RSpec hook blocks are always multi-line.
+RSpec/SingleLineHook:
+ Enabled: true
+ Exclude:
+ - 'spec/factories/*'
+ - 'spec/requests/api/v3/*'
+
# Checks for stubbed test subjects.
RSpec/SubjectStub:
Enabled: false
@@ -1044,3 +1172,39 @@ RSpec/SubjectStub:
# Prefer using verifying doubles over normal doubles.
RSpec/VerifiedDoubles:
Enabled: false
+
+# GitlabSecurity ##############################################################
+
+GitlabSecurity/DeepMunge:
+ Enabled: true
+ Exclude:
+ - 'lib/**/*.rake'
+ - 'spec/**/*'
+
+GitlabSecurity/PublicSend:
+ Enabled: true
+ Exclude:
+ - 'config/**/*'
+ - 'db/**/*'
+ - 'features/**/*'
+ - 'lib/**/*.rake'
+ - 'qa/**/*'
+ - 'spec/**/*'
+
+GitlabSecurity/RedirectToParamsUpdate:
+ Enabled: true
+ Exclude:
+ - 'lib/**/*.rake'
+ - 'spec/**/*'
+
+GitlabSecurity/SqlInjection:
+ Enabled: true
+ Exclude:
+ - 'lib/**/*.rake'
+ - 'spec/**/*'
+
+GitlabSecurity/SystemCommandInjection:
+ Enabled: true
+ Exclude:
+ - 'lib/**/*.rake'
+ - 'spec/**/*'
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 38b22afdf82..4b4f14efea4 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,45 +1,71 @@
# This configuration was generated by
# `rubocop --auto-gen-config --exclude-limit 0`
-# on 2017-04-07 20:17:35 -0400 using RuboCop version 0.47.1.
+# on 2017-07-10 01:48:30 +0900 using RuboCop version 0.49.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: 54
-RSpec/BeforeAfterAll:
+# Offense count: 181
+# Cop supports --auto-correct.
+# Configuration parameters: AllowForAlignment, ForceEqualSignAlignment.
+Layout/ExtraSpacing:
Enabled: false
-# Offense count: 15
-# Configuration parameters: CustomIncludeMethods.
-RSpec/EmptyExampleGroup:
+# Offense count: 119
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
+# SupportedStyles: special_inside_parentheses, consistent, align_brackets
+Layout/IndentArray:
Enabled: false
-# Offense count: 233
-RSpec/EmptyLineAfterFinalLet:
+# Offense count: 208
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
+# SupportedStyles: special_inside_parentheses, consistent, align_braces
+Layout/IndentHash:
Enabled: false
-# Offense count: 167
-RSpec/EmptyLineAfterSubject:
+# Offense count: 8
+# Cop supports --auto-correct.
+# Configuration parameters: AllowForAlignment.
+Layout/SpaceBeforeFirstArg:
Enabled: false
-# Offense count: 3
-RSpec/ExpectOutput:
+# Offense count: 64
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+# SupportedStyles: require_no_space, require_space
+Layout/SpaceInLambdaLiteral:
Enabled: false
-# Offense count: 72
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: implicit, each, example
-RSpec/HookArgument:
+# Offense count: 256
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters.
+# SupportedStyles: space, no_space
+# SupportedStylesForEmptyBraces: space, no_space
+Layout/SpaceInsideBlockBraces:
Enabled: false
-# Offense count: 12
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: is_expected, should
-RSpec/ImplicitExpect:
+# Offense count: 135
+# Cop supports --auto-correct.
+Layout/SpaceInsideParens:
Enabled: false
-# Offense count: 11
+# Offense count: 14
+# Cop supports --auto-correct.
+Layout/SpaceInsidePercentLiteralDelimiters:
+ Enabled: false
+
+# Offense count: 272
+RSpec/EmptyLineAfterFinalLet:
+ Enabled: false
+
+# Offense count: 181
+RSpec/EmptyLineAfterSubject:
+ Enabled: false
+
+# Offense count: 9
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: it_behaves_like, it_should_behave_like
RSpec/ItBehavesLike:
@@ -49,19 +75,19 @@ RSpec/ItBehavesLike:
RSpec/IteratedExpectation:
Enabled: false
-# Offense count: 3
+# Offense count: 2
RSpec/OverwritingSetup:
Enabled: false
-# Offense count: 34
+# Offense count: 36
RSpec/RepeatedExample:
Enabled: false
-# Offense count: 43
+# Offense count: 86
RSpec/ScatteredLet:
Enabled: false
-# Offense count: 32
+# Offense count: 20
RSpec/ScatteredSetup:
Enabled: false
@@ -69,7 +95,7 @@ RSpec/ScatteredSetup:
RSpec/SharedContext:
Enabled: false
-# Offense count: 150
+# Offense count: 115
Rails/FilePath:
Enabled: false
@@ -79,97 +105,71 @@ Rails/FilePath:
Rails/ReversibleMigration:
Enabled: false
-# Offense count: 302
+# Offense count: 336
# Configuration parameters: Blacklist.
# Blacklist: decrement!, decrement_counter, increment!, increment_counter, toggle!, touch, update_all, update_attribute, update_column, update_columns, update_counters
Rails/SkipsModelValidations:
Enabled: false
-# Offense count: 7
+# Offense count: 11
# Cop supports --auto-correct.
Security/YAMLLoad:
Enabled: false
-# Offense count: 59
+# Offense count: 58
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: percent_q, bare_percent
Style/BarePercentLiterals:
Enabled: false
-# Offense count: 1403
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: leading, trailing
-Style/DotPosition:
- Enabled: false
-
-# Offense count: 5
+# Offense count: 6
# Cop supports --auto-correct.
Style/EachWithObject:
Enabled: false
-# Offense count: 28
+# Offense count: 31
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: empty, nil, both
Style/EmptyElse:
Enabled: false
-# Offense count: 4
+# Offense count: 9
# Cop supports --auto-correct.
Style/EmptyLiteral:
Enabled: false
-# Offense count: 59
+# Offense count: 78
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: compact, expanded
Style/EmptyMethod:
Enabled: false
-# Offense count: 214
+# Offense count: 23
# Cop supports --auto-correct.
-# Configuration parameters: AllowForAlignment, ForceEqualSignAlignment.
-Style/ExtraSpacing:
- Enabled: false
-
-# Offense count: 9
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: format, sprintf, percent
Style/FormatString:
Enabled: false
-# Offense count: 285
+# Offense count: 301
# Configuration parameters: MinBodyLength.
Style/GuardClause:
Enabled: false
-# Offense count: 16
+# Offense count: 18
Style/IfInsideElse:
Enabled: false
-# Offense count: 186
+# Offense count: 182
# Cop supports --auto-correct.
# Configuration parameters: MaxLineLength.
Style/IfUnlessModifier:
Enabled: false
-# 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: 160
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
-# SupportedStyles: special_inside_parentheses, consistent, align_braces
-Style/IndentHash:
- Enabled: false
-
-# Offense count: 50
+# Offense count: 52
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: line_count_dependent, lambda, literal
@@ -181,63 +181,63 @@ Style/Lambda:
Style/LineEndConcatenation:
Enabled: false
-# Offense count: 34
+# Offense count: 40
# Cop supports --auto-correct.
Style/MethodCallWithoutArgsParentheses:
Enabled: false
-# Offense count: 10
+# Offense count: 13
Style/MethodMissing:
Enabled: false
-# Offense count: 3
+# Offense count: 6
# Cop supports --auto-correct.
Style/MultilineIfModifier:
Enabled: false
-# Offense count: 24
+# Offense count: 26
# Cop supports --auto-correct.
Style/NestedParenthesizedCalls:
Enabled: false
-# Offense count: 18
+# Offense count: 20
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles.
# SupportedStyles: skip_modifier_ifs, always
Style/Next:
Enabled: false
-# Offense count: 37
+# Offense count: 45
# Cop supports --auto-correct.
# Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles.
# SupportedOctalStyles: zero_with_o, zero_only
Style/NumericLiteralPrefix:
Enabled: false
-# Offense count: 88
+# Offense count: 98
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
Enabled: false
-# Offense count: 36
+# Offense count: 42
# Cop supports --auto-correct.
Style/ParallelAssignment:
Enabled: false
-# Offense count: 570
+# Offense count: 800
# Cop supports --auto-correct.
# Configuration parameters: PreferredDelimiters.
Style/PercentLiteralDelimiters:
Enabled: false
-# Offense count: 14
+# Offense count: 15
# Cop supports --auto-correct.
Style/PerlBackrefs:
Enabled: false
-# Offense count: 83
+# Offense count: 105
# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist.
# NamePrefix: is_, has_, have_
# NamePrefixBlacklist: is_, has_, have_
@@ -245,54 +245,47 @@ Style/PerlBackrefs:
Style/PredicateName:
Enabled: false
-# Offense count: 45
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: short, verbose
-Style/PreferredHashMethods:
- Enabled: false
-
-# Offense count: 65
+# Offense count: 58
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: compact, exploded
Style/RaiseArgs:
Enabled: false
-# Offense count: 5
+# Offense count: 6
# Cop supports --auto-correct.
Style/RedundantBegin:
Enabled: false
-# Offense count: 32
+# Offense count: 37
# Cop supports --auto-correct.
Style/RedundantFreeze:
Enabled: false
-# Offense count: 15
+# Offense count: 14
# Cop supports --auto-correct.
# Configuration parameters: AllowMultipleReturnValues.
Style/RedundantReturn:
Enabled: false
-# Offense count: 382
+# Offense count: 406
# Cop supports --auto-correct.
Style/RedundantSelf:
Enabled: false
-# Offense count: 111
+# Offense count: 115
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes.
# SupportedStyles: slashes, percent_r, mixed
Style/RegexpLiteral:
Enabled: false
-# Offense count: 24
+# Offense count: 29
# Cop supports --auto-correct.
Style/RescueModifier:
Enabled: false
-# Offense count: 7
+# Offense count: 8
# Cop supports --auto-correct.
Style/SelfAssignment:
Enabled: false
@@ -303,108 +296,58 @@ Style/SelfAssignment:
Style/SingleLineMethods:
Enabled: false
-# Offense count: 168
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: space, no_space
-Style/SpaceBeforeBlockBraces:
- Enabled: false
-
-# Offense count: 8
-# Cop supports --auto-correct.
-# Configuration parameters: AllowForAlignment.
-Style/SpaceBeforeFirstArg:
- Enabled: false
-
-# Offense count: 46
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: require_no_space, require_space
-Style/SpaceInLambdaLiteral:
- Enabled: false
-
-# Offense count: 229
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters.
-# SupportedStyles: space, no_space
-# SupportedStylesForEmptyBraces: space, no_space
-Style/SpaceInsideBlockBraces:
- Enabled: false
-
-# Offense count: 116
-# Cop supports --auto-correct.
-Style/SpaceInsideParens:
- Enabled: false
-
-# Offense count: 12
-# Cop supports --auto-correct.
-Style/SpaceInsidePercentLiteralDelimiters:
- Enabled: false
-
-# Offense count: 57
+# Offense count: 64
# Cop supports --auto-correct.
# Configuration parameters: SupportedStyles.
# SupportedStyles: use_perl_names, use_english_names
Style/SpecialGlobalVars:
EnforcedStyle: use_perl_names
-# Offense count: 42
+# Offense count: 44
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiteralsInInterpolation:
Enabled: false
-# Offense count: 64
+# Offense count: 84
# Cop supports --auto-correct.
# Configuration parameters: IgnoredMethods.
# IgnoredMethods: respond_to, define_method
Style/SymbolProc:
Enabled: false
-# Offense count: 6
+# Offense count: 8
# 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: 53
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyleForMultiline, SupportedStylesForMultiline.
-# SupportedStylesForMultiline: comma, consistent_comma, no_comma
-Style/TrailingCommaInArguments:
- Enabled: false
-
-# Offense count: 18
+# Offense count: 17
# Cop supports --auto-correct.
# Configuration parameters: AllowNamedUnderscoreVariables.
Style/TrailingUnderscoreVariable:
Enabled: false
-# Offense count: 78
-# Cop supports --auto-correct.
-Style/TrailingWhitespace:
- Enabled: false
-
-# Offense count: 3
+# Offense count: 4
# Cop supports --auto-correct.
# Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, IgnoreClassMethods, Whitelist.
# Whitelist: to_ary, to_a, to_c, to_enum, to_h, to_hash, to_i, to_int, to_io, to_open, to_path, to_proc, to_r, to_regexp, to_str, to_s, to_sym
Style/TrivialAccessors:
Enabled: false
-# Offense count: 6
+# Offense count: 5
# Cop supports --auto-correct.
Style/UnlessElse:
Enabled: false
-# Offense count: 24
+# Offense count: 28
# Cop supports --auto-correct.
Style/UnneededInterpolation:
Enabled: false
-# Offense count: 8
+# Offense count: 11
# Cop supports --auto-correct.
Style/ZeroLengthPredicate:
Enabled: false
diff --git a/.scss-lint.yml b/.scss-lint.yml
index 83c68309fa8..73f8d27f78c 100644
--- a/.scss-lint.yml
+++ b/.scss-lint.yml
@@ -10,12 +10,12 @@ linters:
# Reports when you use improper spacing around ! (the "bang") in !default,
# !global, !important, and !optional flags.
BangFormat:
- enabled: false
-
+ enabled: true
+
# Whether or not to prefer `border: 0` over `border: none`.
BorderZero:
enabled: false
-
+
# Reports when you define a rule set using a selector with chained classes
# (a.k.a. adjoining classes).
ChainedClasses:
@@ -25,13 +25,13 @@ linters:
# (e.g. `color: green` is a color keyword)
ColorKeyword:
enabled: false
-
+
# Prefer color literals (keywords or hexadecimal codes) to be used only in
# variable declarations. They should be referred to via variables everywhere
# else.
ColorVariable:
enabled: true
-
+
# Which form of comments to prefer in CSS.
Comment:
enabled: false
@@ -39,14 +39,15 @@ linters:
# Reports @debug statements (which you probably left behind accidentally).
DebugStatement:
enabled: false
-
+
# Rule sets should be ordered as follows:
# - @extend declarations
# - @include declarations without inner @content
- # - properties, @include declarations with inner @content
+ # - properties
+ # - @include declarations with inner @content
# - nested rule sets.
DeclarationOrder:
- enabled: false
+ enabled: true
# `scss-lint:disable` control comments should be preceded by a comment
# explaining why these linters are being disabled for this file.
@@ -54,19 +55,19 @@ linters:
# more information.
DisableLinterReason:
enabled: true
-
+
# Reports when you define the same property twice in a single rule set.
DuplicateProperty:
- enabled: false
-
+ enabled: true
+
# Separate rule, function, and mixin declarations with empty lines.
EmptyLineBetweenBlocks:
enabled: true
-
+
# Reports when you have an empty rule set.
EmptyRule:
enabled: true
-
+
# Reports when you have an @extend directive.
ExtendDirective:
enabled: false
@@ -75,49 +76,49 @@ linters:
# when adding lines to the file, since SCM systems such as git won't
# think that you touched the last line.
FinalNewline:
- enabled: false
-
+ enabled: true
+
# HEX colors should use three-character values where possible.
HexLength:
enabled: false
-
+
# HEX color values should use lower-case colors to differentiate between
# letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`.
HexNotation:
enabled: true
-
+
# Avoid using ID selectors.
IdSelector:
enabled: false
-
+
# The basenames of @imported SCSS partials should not begin with an
# underscore and should not include the filename extension.
ImportPath:
- enabled: false
-
+ enabled: true
+
# Avoid using !important in properties. It is usually indicative of a
# misunderstanding of CSS specificity and can lead to brittle code.
ImportantRule:
enabled: false
-
+
# Indentation should always be done in increments of 2 spaces.
Indentation:
enabled: true
width: 2
-
+
# Don't write leading zeros for numeric values with a decimal point.
LeadingZero:
enabled: false
-
+
# Reports when you define the same selector twice in a single sheet.
MergeableSelector:
enabled: false
-
+
# Functions, mixins, variables, and placeholders should be declared
# with all lowercase letters and hyphens instead of underscores.
NameFormat:
enabled: false
-
+
# Avoid nesting selectors too deeply.
NestingDepth:
enabled: false
@@ -129,12 +130,12 @@ linters:
# Sort properties in a strict order.
PropertySortOrder:
enabled: false
-
+
# Reports when you use an unknown or disabled CSS property
# (ignoring vendor-prefixed properties).
PropertySpelling:
- enabled: false
-
+ enabled: true
+
# Configure which units are allowed for property values.
PropertyUnits:
enabled: false
@@ -144,25 +145,25 @@ linters:
# be declared with one colon.
PseudoElement:
enabled: true
-
+
# Avoid qualifying elements in selectors (also known as "tag-qualifying").
QualifyingElement:
enabled: false
-
+
# Don't write selectors with a depth of applicability greater than 3.
SelectorDepth:
enabled: false
-
+
# Selectors should always use hyphenated-lowercase, rather than camelCase or
# snake_case.
SelectorFormat:
enabled: false
convention: hyphenated_lowercase
-
+
# Prefer the shortest shorthand form possible for properties that support it.
Shorthand:
enabled: true
-
+
# Each property should have its own line, except in the special case of
# single line rulesets.
SingleLinePerProperty:
@@ -173,11 +174,15 @@ linters:
# individual selector occupy a single line.
SingleLinePerSelector:
enabled: true
-
+
# Commas in lists should be followed by a space.
SpaceAfterComma:
+ enabled: true
+
+ # Comment literals should be followed by a space.
+ SpaceAfterComment:
enabled: false
-
+
# Properties should be formatted with a single space separating the colon
# from the property's value.
SpaceAfterPropertyColon:
@@ -197,12 +202,12 @@ linters:
# colon.
SpaceAfterVariableName:
enabled: false
-
+
# Operators should be formatted with a single space on both sides of an
# infix operator.
SpaceAroundOperator:
enabled: true
-
+
# Opening braces should be preceded by a single space.
SpaceBeforeBrace:
enabled: true
@@ -210,7 +215,7 @@ linters:
# Parentheses should not be padded with spaces.
SpaceBetweenParens:
enabled: false
-
+
# Enforces that string literals should be written with a consistent form
# of quotes (single or double).
StringQuotes:
@@ -240,8 +245,8 @@ linters:
# Do not use parent selector references (&) when they would otherwise
# be unnecessary.
UnnecessaryParentReference:
- enabled: false
-
+ enabled: true
+
# URLs should be valid and not contain protocols or domain names.
UrlFormat:
enabled: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a47c43dd5d6..3ecedd44c89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,857 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 9.4.5 (2017-08-14)
+
+- Fix deletion of deploy keys linked to other projects. !13162
+- Allow any logged in users to read_users_list even if it's restricted. !13201
+- Make Delete Merged Branches handle wildcard protected branches correctly. !13251
+- Fix an order of operations for CI connection error message in merge request widget. !13252
+- Fix pipeline_schedules pages when active schedule has an abnormal state. !13286
+- Add missing validation error for username change with container registry tags. !13356
+- Fix destroy of case-insensitive conflicting redirects. !13357
+- Project pending delete no longer return 500 error in admins projects view. !13389
+- Fix search box losing focus when typing.
+- Use jQuery to control scroll behavior in job log for cross browser consistency.
+- Use project_ref_path to create the link to a branch to fix links that 404.
+- improve file upload/replace experience.
+- fix jump to next discussion button.
+- Fixes new issue button for failed job returning 404.
+- Fix links to group milestones from issue and merge request sidebar.
+- Fixed sign-in restrictions buttons not toggling active state.
+- Fix Mattermost integration.
+- Change project FK migration to skip existing FKs.
+
+## 9.4.4 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 9.4.3 (2017-07-31)
+
+- Fix Prometheus client PID reuse bug. !13130
+- Improve deploy environment chatops slash command. !13150
+- Fix asynchronous javascript paths when GitLab is installed under a relative URL. !13165
+- Fix LDAP authentication to Git repository or container registry.
+- Fixed new navigation breadcrumb title on help pages.
+- Ensure filesystem metrics test files are deleted.
+- Properly affixes nav bar in job view in microsoft edge.
+
+## 9.4.2 (2017-07-28)
+
+- Fix job merge request link to a forked source project. !12965
+- Improve redirect route query performance. !13062
+- Allow admin to read_users_list even if it's restricted. !13066
+- Fixes 500 error caused by pending delete projects in admin dashboard. !13067
+- Add instrumentation to MarkupHelper#link_to_gfm. !13069
+- Pending delete projects should not show in deploy keys. !13088
+- Fix sizing of custom header logo in new navigation.
+- Fix crash on /help/ui.
+- Fix creating merge request diffs when diff contains bytes that are invalid in UTF-8.
+- fix vertical alignment of New Project button.
+- Add LDAP SSL certificate verification option.
+- Fix vertical alignment in firefox and safari for pipeline mini graph.
+
+## 9.4.1 (2017-07-25)
+
+- Fix pipeline_schedules pages throwing error 500 (when ref is empty). !12983
+- Fix editing project with container images present. !13028
+- Fix some invalid entries in PO files. !13032
+- Fix cross site request protection when logging in as a regular user when LDAP is enabled. !13049
+- Fix bug causing metrics files to be truncated. !35420
+- Fix anonymous access to public projects in groups with pending invites.
+- Fixed issue boards sidebar close icon size.
+- Fixed duplicate new milestone buttons when new navigation is turned on.
+- Fix margins in the mini graph for pipeline in commits box.
+
+## 9.4.0 (2017-07-22)
+
+- Add blame view age mapping. !7198 (Jeff Stubler)
+- Add support for image and services configuration in .gitlab-ci.yml. !8578
+- Fix an email parsing bug where brackets would be inserted in emails from some Outlook clients. !9045 (jneen)
+- Use fa-chevron-down on dropdown arrows for consistency. !9659 (TM Lee)
+- Update the devise mail templates to match the design of the pipeline emails. !10483 (Alexis Reigel)
+- Handle renamed submodules in repository browser. !10798 (David Turner)
+- Display all current broadcast messages, not just the last one. !11113 (rickettm)
+- Fix CI/CD status in case there are only allowed to failed jobs in the pipeline. !11166
+- Omit trailing / leading hyphens in CI_COMMIT_REF_SLUG variable to make it usable as a hostname. !11218 (Stefan Hanreich)
+- Moved "Members in a project" menu entry and path locations. !11560
+- Additional Prometheus metrics support. !11712
+- Rename all reserved paths that could have been created. !11713
+- Move uploads from `uploads/system` to `uploads/-/system` to free up `system` as a group name. !11713
+- Fix offline runner detection. !11751 (Alessio Caiazza)
+- Use authorize_update_pipeline_schedule in PipelineSchedulesController. !11846
+- Rollback project repo move if there is an error in Projects::TransferService. !11877
+- Help landing page customizations. !11878 (Robin Bobbitt)
+- Fixes "sign in / Register" active state underline misalignment. !11890 (Frank Sierra)
+- Honor the "Remember me" parameter for OAuth-based login. !11963
+- Instruct user to use personal access token for Git over HTTP. !11986 (Robin Bobbitt)
+- Accept image for avatar in project API. !11988 (Ivan Chernov)
+- Supplement Simplified Chinese translation of Project Page & Repository Page. !11994 (Huang Tao)
+- Supplement Traditional Chinese in Hong Kong translation of Project Page & Repository Page. !11995 (Huang Tao)
+- Make the revision on the `/help` page clickable. !12016
+- Display issue state in issue links section of merge request widget. !12021
+- Enable support for webpack code-splitting by dynamically setting publicPath at runtime. !12032
+- Replace PhantomJS with headless Chrome for karma test suite. !12036
+- Prevent description change notes when toggling tasks. !12057 (Jared Deckard <jared.deckard@gmail.com>)
+- Update QA Dockerfile to lock Chrome browser version. !12071
+- Fix FIDO U2F for Opera browser. !12082 (Jakub Kramarz and Jonas Kalderstam)
+- Supplement Bulgarian translation of Project Page & Repository Page. !12083 (Lyubomir Vasilev)
+- Removes deleted_at and pending_delete occurrences in Project related queries. !12091
+- Provide hint to create a personal access token for Git over HTTP. !12105 (Robin Bobbitt)
+- Display own user id in account settings page. !12141 (Riccardo Padovani)
+- Accept image for avatar in user API. !12143 (Ivan Chernov)
+- Disable fork button on project limit. !12145 (Ivan Chernov)
+- Added "created_after" and "created_before" params to issuables. !12151 (Kyle Bishop @kybishop)
+- Supplement Portuguese Brazil translation of Project Page & Repository Page. !12156 (Huang Tao)
+- Add review apps to usage metrics. !12185
+- Adding French translations. !12200 (Erwan "Dremor" Georget)
+- Ensures default user limits when external user is unchecked. !12218
+- Provide KUBECONFIG from KubernetesService for runners. !12223
+- Filter archived project in API v3 only if param present. !12245 (Ivan Chernov)
+- Add explicit message when no runners on admin. !12266 (Takuya Noguchi)
+- Split pipelines as internal and external in the usage data. !12277
+- Fix API Scoping. !12300
+- Remove registry image delete button if user cant delete it. !12317 (Ivan Chernov)
+- Allow the feature flags to be enabled/disabled with more granularity. !12357
+- Allow to enable the performance bar per user or Feature group. !12362
+- Rename duplicated variables with the same key for projects. Add environment_scope column to variables and add unique constraint to make sure that no variables could be created with the same key within a project. !12363
+- Add variables to pipelines schedules. !12372
+- Add User#full_private_access? to check if user has access to all private groups & projects. !12373
+- Change milestone endpoint for groups. !12374 (Takuya Noguchi)
+- Improve performance of the pipeline charts page. !12378
+- Add option to run Gitaly on a remote server. !12381
+- #20628 Enable implicit grant in GitLab as OAuth Provider. !12384 (Mateusz Pytel)
+- Replace 'snippets/snippets.feature' spinach with rspec. !12385 (Alexander Randa @randaalex)
+- Add Simplified Chinese translations of Commits Page. !12405 (Huang Tao)
+- Add Traditional Chinese in HongKong translations of Commits Page. !12406 (Huang Tao)
+- Add Traditional Chinese in Taiwan translations of Commits Page. !12407 (Huang Tao)
+- Add Portuguese Brazil translations of Commits Page. !12408 (Huang Tao)
+- Add French translations of Commits Page. !12409 (Huang Tao)
+- Add Esperanto translations of Commits Page. !12410 (Huang Tao)
+- Add Bulgarian translations of Commits Page. !12411 (Huang Tao)
+- Remove bin/ci/upgrade.rb as not working all. !12414 (Takuya Noguchi)
+- Store merge request ref_fetched status in the database. !12424
+- Replace 'dashboard/merge_requests' spinach with rspec. !12440 (Alexander Randa (@randaalex))
+- Add Esperanto translations for Cycle Analytics, Project, and Repository pages. !12442 (Huang Tao)
+- Allow unauthenticated access to the /api/v4/users API. !12445
+- Drop GFM support for the title of Milestone/MergeRequest in template. !12451 (Takuya Noguchi)
+- Replace 'dashboard/todos' spinach with rspec. !12453 (Alexander Randa (@randaalex))
+- Cache open issue and merge request counts for project tabs to speed up project pages. !12457
+- Introduce cache policies for CI jobs. !12483
+- Improve support for external issue references. !12485
+- Fix errors caused by attempts to report already blocked or deleted users. !12502 (Horacio Bertorello)
+- Allow customize CI config path. !12509 (Keith Pope)
+- Supplement Traditional Chinese in Taiwan translation of Project Page & Repository Page. !12514 (Huang Tao)
+- Closes any open Autocomplete of the markdown editor when the form is closed. !12521
+- Inserts exact matches of name, username and email to the top of the search list. !12525
+- Use smaller min-width for dropdown-menu-nav only on mobile. !12528 (Takuya Noguchi)
+- Hide archived project labels from group issue tracker. !12547 (Horacio Bertorello)
+- Replace 'dashboard/new-project.feature' spinach with rspec. !12550 (Alexander Randa (@randaalex))
+- Remove group modal like remove project modal (requires typing + confirmation). !12569 (Diego Souza)
+- Add Italian translation of Cycle Analytics Page & Project Page & Repository Page. !12578 (Huang Tao)
+- Add Group secret variables. !12582
+- Update jobs page output to have a scrollable page. !12587
+- Add user projects API. !12596 (Ivan Chernov)
+- Allow creation of files and directories with spaces through Web UI. !12608
+- Improve members view on mobile. !12619
+- Fixed the chart legend not being set correctly. !12628
+- Add Italian translations of Commits Page. !12645 (Huang Tao)
+- Allow admins to disable all restricted visibility levels. !12649
+- Allow admins to retrieve user agent details for an issue or snippet. !12655
+- Update welcome page UX for new users. !12662
+- N+1 problems on milestone page. !12670 (Takuya Noguchi)
+- Upgrade GitLab Workhorse to v2.3.0. !12676
+- Remove option to disable Gitaly. !12677
+- Improve the performance of the project list API. !12679
+- Add creation time filters to user search API for admins. !12682
+- Add Japanese translations for Cycle Analytics & Project pages & Repository pages & Commits pages & Pipeline Charts. !12693 (Huang Tao)
+- Undo adding the /reassign quick action. !12701
+- Fix dashboard labels dropdown. !12708
+- Username and password are no longer stripped from import url on mirror update. !12725
+- Add Russian translations for Cycle Analytics & Project pages & Repository pages & Commits pages & Pipeline Charts. !12743 (Huang Tao)
+- Add Ukrainian translations for Cycle Analytics & Project pages & Repository pages & Commits pages & Pipeline Charts. !12744 (Huang Tao)
+- Prevent bad data being added to application settings when Redis is unavailable. !12750
+- Do not show pipeline schedule button for non-member. !12757 (Takuya Noguchi)
+- Return `is_admin` attribute in the GET /user endpoint for admins. !12811
+- Recover from renaming project that has container images. !12840
+- Exact matches of username and email are now on top of the user search. !12868
+- Use Ghost user for last_edited_by and merge_user when original user is deleted. !12933
+- Fix docker tag reference routing constraints. !12961
+- Optimize creation of commit API by using Repository#commit instead of Repository#commits.
+- Speed up used languages calculation on charts page.
+- Make loading new merge requests (those created after the 9.4 upgrade) faster.
+- Ensure participants for issues, merge requests, etc. are calculated correctly when sending notifications.
+- Handle nameless legacy jobs.
+- Bump Faraday and dependent OAuth2 gem version to support no_proxy variable.
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Render CI statuses with warnings in orange.
+- Document the Delete Merged Branches functionality.
+- Add wells to admin dashboard overview to fix spacing problems.
+- Removes hover style for nodes that are either links or buttons in the pipeline graph.
+- more visual contrast in pagination widget.
+- Deprecate Healthcheck Access Token in favor of IP whitelist.
+- Drop GFM support for issuable title on milestone for consistency and performance. (Takuya Noguchi)
+- fix left & right padding on sidebar.
+- Cleanup minor UX issues in the performance dashboard.
+- Remove two columned layout from project member settings.
+- Make font size of contextual sub menu items 14px.
+- Fix vertical space in job details sidebar.
+- Fix alignment of controls in mr issuable list.
+- Add wip message to new navigation preference section.
+- Add group members counting and plan related data on namespaces API.
+- Fix spacing on runner buttons.
+- Remove uploads/appearance symlink. A leftover from a previous migration.
+- Change order of monospace fonts to fix bug on some linux distros.
+- Limit commit & snippets comments width.
+- Fixed dashboard milestone tabs not loading.
+- Detect if file that appears to be text in the first 1024 bytes is actually binary afer loading all data.
+- Fix inconsistent display of the "Browse files" button in the commit list.
+- Implement diff viewers.
+- Fix 'New merge request' button for users who don't have push access to canonical project.
+- Fix issues with non-UTF8 filenames by always fixing the encoding of tree and blob paths.
+- Show group name instead of path on group page.
+- Don't check if MailRoom is running on Omnibus.
+- Limit OpenGraph image size to 64x64.
+- Don't show auxiliary blob viewer for README when there is no wiki.
+- Strip trailing whitespace in relative submodule URL.
+- Update /target_branch slash command description to be more consistent.
+- Remove unnecessary top padding on group MR index.
+- Added printing_merge_requst_link_enabled to the API. (David Turner <dturner@twosigma.com>)
+- Re-enable realtime for environments table.
+- Create responsive mobile view for pipelines table.
+- Adds realtime feature to job show view header and sidebar info. Updates UX.
+- Use color inputs for broadcast messages.
+- Center dropdown for mini graph.
+- Users can subscribe to group labels on the group labels page.
+- Add issuable-list class to shared mr/issue lists to fix new responsive layout design.
+- Rename "Slash commands" to "Quick actions" and deprecate "chat commands" in favor of "slash commands".
+- Don't mark empty MRs as merged on push to the target branch.
+- Improve issue rendering performance with lots of notes from other users.
+- Fixed overflow on mobile screens for the slash commands.
+- Fix an infinite loop when handling user-supplied regular expressions.
+- Fixed sidebar not collapsing on merge requests in mobile screens.
+- Speed up project removals by adding foreign keys with cascading deletes to various tables.
+- Fix mobile view of files view buttons.
+- Fixed dropdown filter input not focusing after transition.
+- Fixed GFM references not being included when updating issues inline.
+- Remove issues/merge requests drag n drop and sorting from milestone view.
+- Add native group milestones.
+- Fix API bug accepting wrong parameter to create merge request.
+- Clean up UI of issuable lists and make more responsive.
+- Improve the overall UX for the new monitoring dashboard.
+- Fixed the y_label not setting correctly for each graph on the monitoring dashboard.
+- Changed utilities imports from ~ to relative paths.
+- Remove unused space in sidebar todo toggle when not signed in.
+- Limit the width of the projects README text.
+- Add a simple mode to merge request API.
+- Make Project#ensure_repository force create a repo.
+- Use uploads/system directory for personal snippets.
+- Defer project destroys within a namespace in Groups::DestroyService#async_execute.
+- Log rescued exceptions to Sentry.
+- Remove remaining N+1 queries in merge requests API with emojis and labels.
+
+## 9.3.10 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 9.3.9 (2017-07-20)
+
+- Fix an infinite loop when handling user-supplied regular expressions.
+
+## 9.3.8 (2017-07-19)
+
+- Improve support for external issue references. !12485
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Use uploads/system directory for personal snippets.
+- Remove uploads/appearance symlink. A leftover from a previous migration.
+
+## 9.3.7 (2017-07-18)
+
+- Prevent bad data being added to application settings when Redis is unavailable. !12750
+- Return `is_admin` attribute in the GET /user endpoint for admins. !12811
+
+## 9.3.6 (2017-07-12)
+
+- Fix API Scoping. !12300
+- Username and password are no longer stripped from import url on mirror update. !12725
+- Fix issues with non-UTF8 filenames by always fixing the encoding of tree and blob paths.
+- Fixed GFM references not being included when updating issues inline.
+
+## 9.3.5 (2017-07-05)
+
+- Remove "Remove from board" button from backlog and closed list. !12430
+- Do not delete protected branches when deleting all merged branches. !12624
+- Set default for Remove source branch to false.
+- Prevent accidental deletion of protected MR source branch by repeating checks before actual deletion.
+- Expires full_path cache after a repository is renamed/transferred.
+
+## 9.3.4 (2017-07-03)
+
+- Update gitlab-shell to 5.1.1 !12615
+
+## 9.3.3 (2017-06-30)
+
+- Fix head pipeline stored in merge request for external pipelines. !12478
+- Bring back branches badge to main project page. !12548
+- Fix diff of requirements.txt file by not matching newlines as part of package names.
+- Perform housekeeping only when an import of a fresh project is completed.
+- Fixed issue boards closed list not showing all closed issues.
+- Fixed multi-line markdown tooltip buttons in issue edit form.
+
+## 9.3.2 (2017-06-27)
+
+- API: Fix optional arugments for POST :id/variables. !12474
+- Bump premailer-rails gem to 1.9.7 and its dependencies to prevent network retrieval of assets.
+
+## 9.3.1 (2017-06-26)
+
+- Fix reversed breadcrumb order for nested groups. !12322
+- Fix 500 when failing to create private group. !12394
+- Fix linking to line number on side-by-side diff creating empty discussion box.
+- Don't match tilde and exclamation mark as part of requirements.txt package name.
+- Perform project housekeeping after importing projects.
+- Fixed ctrl+enter not submit issue edit form.
+
+## 9.3.0 (2017-06-22)
+
+- Refactored gitlab:app:check into SystemCheck liberary and improve some checks. !9173
+- Add an ability to cancel attaching file and redesign attaching files UI. !9431 (blackst0ne)
+- Add Aliyun OSS as the backup storage provider. !9721 (Yuanfei Zhu)
+- Add suport for find_local_branches GRPC from Gitaly. !10059
+- Allow manual bypass of auto_sign_in_with_provider with a new param. !10187 (Maxime Besson)
+- Redirect to user's keys index instead of user's index after a key is deleted in the admin. !10227 (Cyril Jouve)
+- Changed Blame to Annotate in the UI to promote blameless culture. !10378 (Ilya Vassilevsky)
+- Implement ability to update deploy keys. !10383 (Alexander Randa)
+- Allow numeric values in gitlab-ci.yml. !10607 (blackst0ne)
+- Add a feature test for Unicode trace. !10736 (dosuken123)
+- Notes: Warning message should go away once resolved. !10823 (Jacopo Beschi @jacopo-beschi)
+- Project authorizations are calculated much faster when using PostgreSQL, and nested groups support for MySQL has been removed
+. !10885
+- Fix long urls in the title of commit. !10938 (Alexander Randa)
+- Update gem sidekiq-cron from 0.4.4 to 0.6.0 and rufus-scheduler from 3.1.10 to 3.4.0. !10976 (dosuken123)
+- Use relative paths for group/project/user avatars. !11001 (blackst0ne)
+- Enable cancelling non-HEAD pending pipelines by default for all projects. !11023
+- Implement web hook logging. !11027 (Alexander Randa)
+- Add indices for auto_canceled_by_id for ci_pipelines and ci_builds on PostgreSQL. !11034
+- Add post-deploy migration to clean up projects in `pending_delete` state. !11044
+- Limit User's trackable attributes, like `current_sign_in_at`, to update at most once/hour. !11053
+- Disallow multiple selections for Milestone dropdown. !11084
+- Link to commit author user page from pipelines. !11100
+- Fix the last coverage in trace log should be extracted. !11128 (dosuken123)
+- Remove redirect for old issue url containing id instead of iid. !11135 (blackst0ne)
+- Backported new SystemHook event: `repository_update`. !11140
+- Keep input data after creating a tag that already exists. !11155
+- Fix support for external CI services. !11176
+- Translate backend for Project & Repository pages. !11183
+- Fix LaTeX formatting for AsciiDoc wiki. !11212
+- Add foreign key for pipeline schedule owner. !11233
+- Print Go version in rake gitlab:env:info. !11241
+- Include the blob content when printing a blob page. !11247
+- Sync email address from specified omniauth provider. !11268 (Robin Bobbitt)
+- Disable reference prefixes in notes for Snippets. !11278
+- Rename build_events to job_events. !11287
+- Add API support for pipeline schedule. !11307 (dosuken123)
+- Use route.cache_key for project list cache key. !11325
+- Make environment table realtime. !11333
+- Cache npm modules between pipelines with yarn to speed up setup-test-env. !11343
+- Allow GitLab instance to start when InfluxDB hostname cannot be resolved. !11356
+- Add ConvDev Index page to admin area. !11377
+- Fix Git-over-HTTP error statuses and improve error messages. !11398
+- Renamed users 'Audit Log'' to 'Authentication Log'. !11400
+- Style people in issuable search bar. !11402
+- Change /builds in the URL to /-/jobs. Backward URLs were also added. !11407
+- Update password field label while editing service settings. !11431
+- Add an optional performance bar to view performance metrics for the current page. !11439
+- Update task_list to version 2.0.0. !11525 (Jared Deckard <jared.deckard@gmail.com>)
+- Avoid resource intensive login checks if password is not provided. !11537 (Horatiu Eugen Vlad)
+- Allow numeric pages domain. !11550
+- Exclude manual actions when checking if pipeline can be canceled. !11562
+- Add server uptime to System Info page in admin dashboard. !11590 (Justin Boltz)
+- Simplify testing and saving service integrations. !11599
+- Fixed handling of the `can_push` attribute in the v3 deploy_keys api. !11607 (Richard Clamp)
+- Improve user experience around slash commands in instant comments. !11612
+- Show current user immediately in issuable filters. !11630
+- Add extra context-sensitive functionality for the top right menu button. !11632
+- Reorder Issue action buttons in order of usability. !11642
+- Expose atom links with an RSS token instead of using the private token. !11647 (Alexis Reigel)
+- Respect merge, instead of push, permissions for protected actions. !11648
+- Job details page update real time. !11651
+- Improve performance of ProjectFinder used in /projects API endpoint. !11666
+- Remove redundant data-turbolink attributes from links. !11672 (blackst0ne)
+- Minimum postgresql version is now 9.2. !11677
+- Add protected variables which would only be passed to protected branches or protected tags. !11688
+- Introduce optimistic locking support via optional parameter last_commit_sha on File Update API. !11694 (electroma)
+- Add $CI_ENVIRONMENT_URL to predefined variables for pipelines. !11695
+- Simplify project repository settings page. !11698
+- Fix pipeline_schedules pages throwing error 500. !11706 (dosuken123)
+- Add performance deltas between app deployments on Merge Request widget. !11730
+- Add feature toggles and API endpoints for admins. !11747
+- Replace 'starred_projects.feature' spinach test with an rspec analog. !11752 (blackst0ne)
+- Introduce an Events API. !11755
+- Display Shared Runner status in Admin Dashboard. !11783 (Ivan Chernov)
+- Persist pipeline stages in the database. !11790
+- Revert the feature that would include the current user's username in the HTTP clone URL. !11792
+- Enable Gitaly by default in installations from source. !11796
+- Use zopfli compression for frontend assets. !11798
+- Add tag_list param to project api. !11799 (Ivan Chernov)
+- Add changelog for improved Registry description. !11816
+- Automatically adjust project settings to match changes in project visibility. !11831
+- Add slugify project path to CI enviroment variables. !11838 (Ivan Chernov)
+- Add all pipeline sources as special keywords to 'only' and 'except'. !11844 (Filip Krakowski)
+- Allow pulling of container images using personal access tokens. !11845
+- Expose import_status in Projects API. !11851 (Robin Bobbitt)
+- Allow admins to delete users from the admin users page. !11852
+- Allow users to be hard-deleted from the API. !11853
+- Fix hard-deleting users when they have authored issues. !11855
+- Fix missing optional path parameter in "Create project for user" API. !11868
+- Allow users to be hard-deleted from the admin panel. !11874
+- Add a Rake task to aid in rotating otp_key_base. !11881
+- Fix submodule link to then project under subgroup. !11906
+- Fix binary encoding error on MR diffs. !11929
+- Limit non-administrators to adding 100 members at a time to groups and projects. !11940
+- add bulgarian translation of cycle analytics page to I18N. !11958 (Lyubomir Vasilev)
+- Make backup task to continue on corrupt repositories. !11962
+- Fix incorrect ETag cache key when relative instance URL is used. !11964
+- Reinstate is_admin flag in users api when authenticated user is an admin. !12211 (rickettm)
+- Fix edit button for deploy keys available from other projects. !12301 (Alexander Randa)
+- Fix passing CI_ENVIRONMENT_NAME and CI_ENVIRONMENT_SLUG for CI_ENVIRONMENT_URL. !12344
+- Disable environment list refresh due to bug https://gitlab.com/gitlab-org/gitlab-ee/issues/2677. !12347
+- Standardize timeline note margins across different viewport sizes. !12364
+- Fix Ordered Task List Items. !31483 (Jared Deckard <jared.deckard@gmail.com>)
+- Upgrade dependency to Go 1.8.3. !31943
+- Add prometheus metrics on pipeline creation.
+- Fix etag route not being a match for environments.
+- Sort folder for environments.
+- Support descriptions for snippets.
+- Hide clone panel and file list when user is only a guest. (James Clark)
+- Don’t create comment on JIRA if it already exists for the entity.
+- Update Dashboard Groups UI with better support for subgroups.
+- Confirm Project forking behaviour via the API.
+- Add prometheus based metrics collection to gitlab webapp.
+- Fix: Wiki is not searchable with Guest permissions.
+- Center all empty states.
+- Remove 'New issue' button when issues search returns no results.
+- Add API URL to JIRA settings.
+- animate adding issue to boards.
+- Update session cookie key name to be unique to instance in development.
+- Single click on filter to open filtered search dropdown.
+- Makes header information of pipeline show page realtine.
+- Creates a mediator for pipeline details vue in order to mount several vue apps with the same data.
+- Scope issue/merge request recent searches to project.
+- Increase individual diff collapse limit to 100 KB, and render limit to 200 KB.
+- Fix Pipelines table empty state - only render empty state if we receive 0 pipelines.
+- Make New environment empty state btn lowercase.
+- Removes duplicate environment variable in documentation.
+- Change links in issuable meta to black.
+- Fix border-bottom for project activity tab.
+- Adds new icon for CI skipped status.
+- Create equal padding for emoji.
+- Use briefcase icon for company in profile page.
+- Remove overflow from comment form for confidential issues and vertically aligns confidential issue icon.
+- Keep trailing newline when resolving conflicts by picking sides.
+- Fix /unsubscribe slash command creating extra todos when you were already mentioned in an issue.
+- Fix math rendering on blob pages.
+- Allow group reporters to manage group labels.
+- Use pre-wrap for commit messages to keep lists indented.
+- Count badges depend on translucent color to better adjust to different background colors and permission badges now feature a pill shaped design similar to labels.
+- Allow reporters to promote project labels to group labels.
+- Enabled keyboard shortcuts on artifacts pages.
+- Perform filtered search when state tab is changed.
+- Remove duplication for sharing projects with groups in project settings.
+- Change order of commits ahead and behind on divergence graph for branch list view.
+- Creates CI Header component for Pipelines and Jobs details pages.
+- Invalidate cache for issue and MR counters more granularly.
+- disable blocked manual actions.
+- Load tree readme asynchronously.
+- Display extra info about files on .gitlab-ci.yml, .gitlab/route-map.yml and LICENSE blob pages.
+- Fix replying to a commit discussion displayed in the context of an MR.
+- Consistently use monospace font for commit SHAs and branch and tag names.
+- Consistently display last push event widget.
+- Don't copy empty elements that were not selected on purpose as GFM.
+- Copy as GFM even when parts of other elements are selected.
+- Autolink package names in Gemfile.
+- Resolve N+1 query issue with discussions.
+- Don't match email addresses or foo@bar as user references.
+- Fix title of discussion jump button at top of page.
+- Don't return nil for missing objects from parser cache.
+- Make .gitmodules parsing more resilient to syntax errors.
+- Add username parameter to gravatar URL.
+- Autolink package names in more dependency files.
+- Return nil when looking up config for unknown LDAP provider.
+- Add system note with link to diff comparison when MR discussion becomes outdated.
+- Don't wrap pasted code when it's already inside code tags.
+- Revert 'New file from interface on existing branch'.
+- Show last commit for current tree on tree page.
+- Add documentation about adding foreign keys.
+- add username field to push webhook. (David Turner)
+- Rename CI/CD Pipelines to Pipelines in the project settings.
+- Make environment tables responsive.
+- Expand/collapse backlog & closed lists in issue boards.
+- Fix GitHub importer performance on branch existence check.
+- Fix counter cache for acts as taggable.
+- Github - Fix token interpolation when cloning wiki repository.
+- Fix token interpolation when setting the Github remote.
+- Fix N+1 queries for non-members in comment threads.
+- Fix terminals support for Kubernetes Service.
+- Fix: A diff comment on a change at last line of a file shows as two comments in discussion.
+- Instrument MergeRequestDiff#load_commits.
+- Introduce source to Pipeline entity.
+- Fixed create new label form in issue form not working for sub-group projects.
+- Fixed style on unsubscribe page. (Gustav Ernberg)
+- Enables inline editing for an issues title & description.
+- Ask for an example project for bug reports.
+- Add summary lines for collapsed details in the bug report template.
+- Prevent commits from upstream repositories to be re-processed by forks.
+- Avoid repeated queries for pipeline builds on merge requests.
+- Preloads head pipeline for merge request collection.
+- Handle head pipeline when creating merge requests.
+- Migrate artifacts to a new path.
+- Rescue OpenSSL::SSL::SSLError in JiraService & IssueTrackerService.
+- Repository browser: handle in-repository submodule urls. (David Turner)
+- Prevent project transfers if a new group is not selected.
+- Allow 'no one' as an option for allowed to merge on a procted branch.
+- Reduce time spent waiting for certain Sidekiq jobs to complete.
+- Refactor ProjectsFinder#init_collection to produce more efficient queries for retrieving projects.
+- Remove unused code and uses underscore.
+- Restricts search projects dropdown to group projects when group is selected.
+- Properly handle container registry redirects to fix metadata stored on a S3 backend.
+- Fix LFS timeouts when trying to save large files.
+- Set artifact working directory to be in the destination store to prevent unnecessary I/O.
+- Strip trailing whitespaces in submodule URLs.
+- Make sure reCAPTCHA configuration is loaded when spam checks are initiated.
+- Fix up arrow not editing last discussion comment.
+- Added application readiness endpoints to the monitoring health check admin view.
+- Use wait_for_requests for both ajax and Vue requests.
+- Cleanup ci_variables schema and table.
+- Remove foreigh key on ci_trigger_schedules only if it exists.
+- Allow translation of Pipeline Schedules.
+
+## 9.2.10 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 9.2.9 (2017-07-20)
+
+- Fix an infinite loop when handling user-supplied regular expressions.
+
+## 9.2.8 (2017-07-19)
+
+- Improve support for external issue references. !12485
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Fix incorrect project authorizations.
+- Remove uploads/appearance symlink. A leftover from a previous migration.
+
+## 9.2.7 (2017-06-21)
+
+- Reinstate is_admin flag in users api when authenticated user is an admin. !12211 (rickettm)
+
+## 9.2.6 (2017-06-16)
+
+- Fix the last coverage in trace log should be extracted. !11128 (dosuken123)
+- Respect merge, instead of push, permissions for protected actions. !11648
+- Fix pipeline_schedules pages throwing error 500. !11706 (dosuken123)
+- Make backup task to continue on corrupt repositories. !11962
+- Fix incorrect ETag cache key when relative instance URL is used. !11964
+- Fix math rendering on blob pages.
+- Invalidate cache for issue and MR counters more granularly.
+- Fix terminals support for Kubernetes Service.
+- Fix LFS timeouts when trying to save large files.
+- Strip trailing whitespaces in submodule URLs.
+- Make sure reCAPTCHA configuration is loaded when spam checks are initiated.
+- Remove foreigh key on ci_trigger_schedules only if it exists.
+
+## 9.2.5 (2017-06-07)
+
+- No changes.
+
+## 9.2.4 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.2.3 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Escapes html content before appending it to the DOM.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
+## 9.2.2 (2017-05-25)
+
+- Fix issue where real time pipelines were not cached. !11615
+- Make all notes use equal padding.
+
+## 9.2.1 (2017-05-23)
+
+- Fix placement of note emoji on hover.
+- Fix migration for older PostgreSQL versions.
+
+## 9.2.0 (2017-05-22)
+
+- API: Filter merge requests by milestone and labels. (10924)
+- Reset New branch button when issue state changes. !5962 (winniehell)
+- Frontend prevent authored votes. !6260 (Barthc)
+- Change issues list in MR to natural sorting. !7110 (Jeff Stubler)
+- Add animations to all the dropdowns. !8419
+- Add update time to project lists. !8514 (Jeff Stubler)
+- Remove view fragment caching for project READMEs. !8838
+- API: Add parameters to allow filtering project pipelines. !9367 (dosuken123)
+- Database SSL support for backup script. !9715 (Guillaume Simon)
+- Fix UI inconsistency different files view (find file button missing). !9847 (TM Lee)
+- Display slash commands outcome when previewing Markdown. !10054 (Rares Sfirlogea)
+- Resolve "Add more tests for spec/controllers/projects/builds_controller_spec.rb". !10244 (dosuken123)
+- Add keyboard edit shotcut for wiki. !10245 (George Andrinopoulos)
+- Redirect old links after renaming a user/group/project. !10370
+- Add system note on description change of issue/merge request. !10392 (blackst0ne)
+- Improve validation of namespace & project paths. !10413
+- Add board_move slash command. !10433 (Alex Sanford)
+- Update all instances of the old loading icon. !10490 (Andrew Torres)
+- Implement protected manual actions. !10494
+- Implement search by extern_uid in Users API. !10509 (Robin Bobbitt)
+- add support for .vue templates. !10517
+- Only add newlines between multiple uploads. !10545
+- Added balsamiq file viewer. !10564
+- Remove unnecessary test helpers includes. !10567 (Jacopo Beschi @jacopo-beschi)
+- Add tooltip to header of Done board. !10574 (Andy Brown)
+- Fix redundant cache expiration in Repository. !10575 (blackst0ne)
+- Add hashie-forbidden_attributes gem. !10579 (Andy Brown)
+- Add spec for schema.rb. !10580 (blackst0ne)
+- Keep webpack-dev-server process functional across branch changes. !10581
+- Turns true value and false value database methods from instance to class methods. !10583
+- Improve text on todo list when the todo action comes from yourself. !10594 (Jacopo Beschi @jacopo-beschi)
+- Replace rake cache:clear:db with an automatic mechanism. !10597
+- Remove heading and trailing spaces from label's color and title. !10603 (blackst0ne)
+- Add webpack_bundle_tag helper to improve non-localhost GDK configurations. !10604
+- Added quick-update (fade-in) animation to newly rendered notes. !10623
+- Fix rendering emoji inside a string. !10647 (blackst0ne)
+- Dockerfiles templates are imported from gitlab.com/gitlab-org/Dockerfile. !10663
+- Add support for i18n on Cycle Analytics page. !10669
+- Allow OAuth clients to push code. !10677
+- Add configurable timeout for git fetch and clone operations. !10697
+- Move labels of search results from bottom to title. !10705 (dr)
+- Added build failures summary page for pipelines. !10719
+- Expand/collapse button -> Change to make it look like a toggle. !10720 (Jacopo Beschi @jacopo-beschi)
+- Decrease ABC threshold to 57.08. !10724 (Rydkin Maxim)
+- Removed target blank from the metrics action inside the environments list. !10726
+- Remove Repository#version method and tests. !10734
+- Refactor Admin::GroupsController#members_update method and add some specs. !10735
+- Refactor code that creates project/group members. !10735
+- Add Slack slash command api to services documentation and rearrange order and cases. !10757 (TM Lee)
+- Disable test settings on chat notification services when repository is empty. !10759
+- Add support for instantly updating comments. !10760
+- Show checkmark on current assignee in assignee dropdown. !10767
+- Remove pipeline controls for last deployment from Environment monitoring page. !10769
+- Pipeline view updates in near real time. !10777
+- Fetch pipeline status in batch from redis. !10785
+- Add username to activity atom feed. !10802 (winniehell)
+- Support Markdown previews for personal snippets. !10810
+- Implement ability to edit hooks. !10816 (Alexander Randa)
+- Allow admins to sudo to blocked users via the API. !10842
+- Don't display the is_admin flag in most API responses. !10846
+- Refactor add_users method for project and group. !10850
+- Pipeline schedules got a new and improved UI. !10853
+- Fix updating merge_when_build_succeeds via merge API endpoint. !10873
+- Add index on ci_builds.user_id. !10874 (blackst0ne)
+- Improves test settings for chat notification services for empty projects. !10886
+- Change Git commit command in Existing folder to git commit -m. !10900 (TM Lee)
+- Show group name on flash container when group is created from Admin area. !10905
+- Make markdown tables thinner. !10909 (blackst0ne)
+- Ensure namespace owner is Master of project upon creation. !10910
+- Updated CI status favicons to include the tanuki. !10923
+- Decrease Cyclomatic Complexity threshold to 16. !10928 (Rydkin Maxim)
+- Replace header merge request icon. !10932 (blackst0ne)
+- Fix error on CI/CD Settings page related to invalid pipeline trigger. !10948 (dosuken123)
+- rickettm Add repo parameter to gitaly:install and workhorse:install rake tasks. !10979 (M. Ricketts)
+- Generate and handle a gl_repository param to pass around components. !10992
+- Prevent 500 errors caused by testing the Prometheus service. !10994
+- Disable navigation to Project-level pages configuration when Pages disabled. !11008
+- Fix caching large snippet HTML content on MySQL databases. !11024
+- Hide external environment URL button on terminal page if URL is not defined. !11029
+- Always show the latest pipeline information in the commit box. !11038
+- Fix misaligned buttons in wiki pages. !11043
+- Colorize labels in search field. !11047
+- Sort the network graph both by commit date and topographically. !11057
+- Remove carriage returns from commit messages. !11077
+- Add tooltips to user contribution graph key. !11138
+- Add German translation for Cycle Analytics. !11161
+- Fix skipped manual actions problem when processing the pipeline. !11164
+- Fix cross referencing for private and internal projects. !11243
+- Add state to MR widget that prevent merges when branch changes after page load. !11316
+- Fixes the 500 when accessing customized appearance logos. !11479 (Alexis Reigel)
+- Implement Users::BuildService. !30349 (George Andrinopoulos)
+- Display comments for personal snippets.
+- Support comments for personal snippets.
+- Support uploaders for personal snippets comments.
+- Handle incoming emails from aliases correctly.
+- Re-rewrites pipeline graph in vue to support realtime data updates.
+- Add issues/:iid/closed_by api endpoint. (mhasbini)
+- Disallow merge requests from fork when source project have disabled merge requests. (mhasbini)
+- Improved UX on project members settings view.
+- Clear emoji search in awards menu after picking emoji.
+- Cleanup markdown spacing.
+- Separate CE params on Grape API.
+- Allow to create new branch and empty WIP merge request from issue page.
+- Prevent people from creating branches if they don't have persmission to push.
+- Redesign auth 422 page.
+- 29595 Update callout design.
+- Detect already enabled DeployKeys in EnableDeployKeyService.
+- Add transparent top-border to the hover state of done todos.
+- Refactor all CI vue badges to use the same vue component.
+- Update note edits in real-time.
+- Add button to delete filters from filtered search bar.
+- Added profile name to user dropdown.
+- Display GitLab Pages status in Admin Dashboard.
+- Fix label creation from issuable for subgroup projects.
+- Vertically align mini pipeline stage container.
+- prevent nav tabs from wrapping to new line.
+- Fix environments vue architecture to match documentation.
+- Enforce project features when searching blobs and wikis.
+- fix inline diff copy in firefox.
+- Note Ghost user and refer to user deletion documentation.
+- Expose project statistics on single requests via the API.
+- Job dropdown of pipeline mini graph updates in realtime when its opened.
+- Add default margin-top to user request table on project members page.
+- Add tooltips to note action buttons.
+- Remove `#` being added on commit sha in MR widget.
+- Remove spinner from loading comment.
+- Fixes an issue preventing screen readers from reading some icons.
+- Load milestone tabs asynchronously to increase initial load performance.
+- [BB Importer] Save the error trace and the whole raw document to debug problems easier.
+- Fixed branches dropdown rendering branch names as HTML.
+- Make Asciidoc & other markup go through pipeline to prevent XSS.
+- Validate URLs in markdown using URI to detect the host correctly.
+- Side-by-side view in commits correcly expands full window width.
+- Deploy keys load are loaded async.
+- Fixed spacing of discussion submit buttons.
+- Add hostname to usage ping.
+- Allow usage ping to be disabled completely in gitlab.yml.
+- Add artifact file page that uses the blob viewer.
+- Add breadcrumb, build header and pipelines submenu to artifacts browser.
+- Show Raw button as Download for binary files.
+- Add Source/Rendered switch to blobs for SVG, Markdown, Asciidoc and other text files that can be rendered.
+- Catch all URI errors in ExternalLinkFilter.
+- Allow commenting on older versions of the diff and comparisons between diff versions.
+- Paste a copied MR source branch name as code when pasted into a GFM form.
+- Fix commenting on an existing discussion on an unchanged line that is no longer in the diff.
+- Link to outdated diff in older MR version from outdated diff discussion.
+- Bump Sidekiq to 5.0.0.
+- Use blob viewers for snippets.
+- Add download button to project snippets.
+- Display video blobs in-line like images.
+- Gracefully handle failures for incoming emails which do not match on the To header, and have no References header.
+- Added title to award emoji buttons.
+- Fixed alignment of empty task list items.
+- Removed the target=_blank from the monitoring component to prevent opening a new tab.
+- Fix new admin integrations not taking effect on existing projects.
+- Prevent further repository corruption when resolving conflicts from a fork where both the fork and upstream projects require housekeeping.
+- Add missing project attributes to Import/Export.
+- Remove N+1 queries in processing MR references.
+- Fixed wrong method call on notify_post_receive. (Luigi Leoni)
+- Fixed search terms not correctly highlighting.
+- Refactored the anchor tag to remove the trailing space in the target branch.
+- Prevent user profile tabs to display raw json when going back and forward in browser history.
+- Add index to webhooks type column.
+- Change line-height on build-header so elements don't overlap. (Dino Maric)
+- Fix dead link to GDK on the README page. (Dino Maric)
+- Fixued preview shortcut focusing wrong preview tab.
+- Issue assignees are now removed without loading unnecessary data into memory.
+- Refactor backup/restore docs.
+- Fixed group issues assignee dropdown loading all users.
+- Fix for XSS in project import view caused by Hamlit filter usage.
+- Fixed avatar not display on issue boards when Gravatar is disabled.
+- Fixed create new label form in issue boards sidebar.
+- Add realtime descriptions to issue show pages.
+- Issue API change: assignee_id parameter and assignee object in a response have been deprecated.
+- Fixed bug where merge request JSON would be displayed.
+- Fixed Prometheus monitoring graphs not showing empty states in certain scenarios.
+- Removed the milestone references from the milestone views.
+- Show sizes correctly in merge requests when diffs overflow.
+- Fix notify_only_default_branch check for Slack service.
+- Make the `gitlab:gitlab_shell:check` task check that the repositories storage path are owned by the `root` group.
+- Optimise pipelines.json endpoint.
+- Pass docsUrl to pipeline schedules callout component.
+- Fixed alignment of CI icon in issues related branches.
+- Set the issuable sidebar to remain closed for mobile devices.
+- Sanitize submodule URLs before linking to them in the file tree view.
+- Upgrade Sidekiq to 4.2.10.
+- Cache Routable#full_path in RequestStore to reduce duplicate route loads.
+- Refactor snippets finder & dont return internal snippets for external users.
+- Fix snippets visibility for show action - external users can not see internal snippets.
+- Store retried in database for CI Builds.
+- repository browser: handle submodule urls that don't end with .git. (David Turner)
+- Fixed tags sort from defaulting to empty.
+- Do not show private groups on subgroups page if user doesn't have access to.
+- Make MR link in build sidebar bold.
+- Unassign all Issues and Merge Requests when member leaves a team.
+- Fix preemptive scroll bar on user activity calendar.
+- Pipeline chat notifications convert seconds to minutes and hours.
+
+## 9.1.10 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 9.1.9 (2017-07-20)
+
+- Fix an infinite loop when handling user-supplied regular expressions.
+
+## 9.1.8 (2017-07-19)
+
+- Improve support for external issue references. !12485
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Fix incorrect project authorizations.
+- Remove uploads/appearance symlink. A leftover from a previous migration.
+
+## 9.1.7 (2017-06-07)
+
+- No changes.
+
+## 9.1.6 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.1.5 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
+## 9.1.4 (2017-05-12)
+
+- Fix error on CI/CD Settings page related to invalid pipeline trigger. !10948 (dosuken123)
+- Sort the network graph both by commit date and topographically. !11057
+- Fix cross referencing for private and internal projects. !11243
+- Handle incoming emails from aliases correctly.
+- Gracefully handle failures for incoming emails which do not match on the To header, and have no References header.
+- Add missing project attributes to Import/Export.
+- Fixed search terms not correctly highlighting.
+- Fixed bug where merge request JSON would be displayed.
+
+## 9.1.3 (2017-05-05)
+
+- Do not show private groups on subgroups page if user doesn't have access to.
+- Enforce project features when searching blobs and wikis.
+- Fixed branches dropdown rendering branch names as HTML.
+- Make Asciidoc & other markup go through pipeline to prevent XSS.
+- Validate URLs in markdown using URI to detect the host correctly.
+- Fix for XSS in project import view caused by Hamlit filter usage.
+- Sanitize submodule URLs before linking to them in the file tree view.
+- Refactor snippets finder & dont return internal snippets for external users.
+- Fix snippets visibility for show action - external users can not see internal snippets.
+
+## 9.1.2 (2017-05-01)
+
+- Add index on ci_runners.contacted_at. !10876 (blackst0ne)
+- Fix pipeline events description for Slack and Mattermost integration. !10908
+- Fixed milestone sidebar showing incorrect number of MRs when collapsed. !10933
+- Fix ordering of commits in the network graph. !10936
+- Ensure the chat notifications service properly saves the "Notify only default branch" setting. !10959
+- Lazily sets UUID in ApplicationSetting for new installations.
+- Skip validation when creating internal (ghost, service desk) users.
+- Use GitLab Pages v0.4.1.
+
## 9.1.1 (2017-04-26)
- Add a transaction around move_issues_to_ghost_user. !10465
@@ -19,6 +870,7 @@ entry.
## 9.1.0 (2017-04-22)
+- Add Jupyter notebook rendering !10017
- Added merge requests empty state. !7342
- Add option to start a new resolvable discussion in an MR. !7527
- Hide form inputs for group member without editing rights. !7816
@@ -265,6 +1117,47 @@ entry.
- Only send chat notifications for the default branch.
- Don't fill in the default kubernetes namespace.
+## 9.0.13 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 9.0.12 (2017-07-20)
+
+- Fix an infinite loop when handling user-supplied regular expressions.
+
+## 9.0.11 (2017-07-19)
+
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Fix incorrect project authorizations.
+- Remove uploads/appearance symlink. A leftover from a previous migration.
+
+## 9.0.10 (2017-06-07)
+
+- No changes.
+
+## 9.0.9 (2017-06-02)
+
+- Fix visibility when referencing snippets.
+
+## 9.0.8 (2017-05-31)
+
+- Move uploads from 'public/uploads' to 'public/uploads/system'.
+- Restrict API X-Frame-Options to same origin.
+- Allow users autocomplete by author_id only for authenticated users.
+
+## 9.0.7 (2017-05-05)
+
+- Enforce project features when searching blobs and wikis.
+- Fixed branches dropdown rendering branch names as HTML.
+- Make Asciidoc & other markup go through pipeline to prevent XSS.
+- Validate URLs in markdown using URI to detect the host correctly.
+- Fix for XSS in project import view caused by Hamlit filter usage.
+- Sanitize submodule URLs before linking to them in the file tree view.
+- Refactor snippets finder & dont return internal snippets for external users.
+- Fix snippets visibility for show action - external users can not see internal snippets.
+- Do not show private groups on subgroups page if user doesn't have access to.
+
## 9.0.6 (2017-04-21)
- Bugfix: POST /projects/:id/hooks and PUT /projects/:id/hook/:hook_id no longer ignore the the job_events param in the V4 API. !10586
@@ -609,6 +1502,27 @@ entry.
- Change development tanuki favicon colors to match logo color order.
- API issues - support filtering by iids.
+## 8.17.8 (2017-08-09)
+
+- Remove hidden symlinks from project import files.
+- Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric character.
+
+## 8.17.7 (2017-07-19)
+
+- Renders 404 if given project is not readable by the user on Todos dashboard.
+- Fix incorrect project authorizations.
+
+## 8.17.6 (2017-05-05)
+
+- Enforce project features when searching blobs and wikis.
+- Fixed branches dropdown rendering branch names as HTML.
+- Make Asciidoc & other markup go through pipeline to prevent XSS.
+- Validate URLs in markdown using URI to detect the host correctly.
+- Fix for XSS in project import view caused by Hamlit filter usage.
+- Sanitize submodule URLs before linking to them in the file tree view.
+- Refactor snippets finder & dont return internal snippets for external users.
+- Fix snippets visibility for show action - external users can not see internal snippets.
+
## 8.17.5 (2017-04-05)
- Don’t show source project name when user does not have access.
@@ -845,8 +1759,6 @@ entry.
## 8.16.7 (2017-02-27)
-- No changes.
-- No changes.
- Fix MR changes tab size count when there are over 100 files in the diff.
## 8.16.6 (2017-02-17)
@@ -1060,6 +1972,14 @@ entry.
- Prevent the GitHub importer from assigning labels and comments to merge requests or issues belonging to other projects.
- Patch XSS vulnerability in RDOC support.
+## 8.15.5 (2017-01-20)
+
+- Ensure export files are removed after a namespace is deleted.
+- Don't allow project guests to subscribe to merge requests through the API. (Robert Schilling)
+- Prevent users from creating notes on resources they can't access.
+- Prevent users from deleting system deploy keys via the project deploy key API.
+- Upgrade omniauth gem to 1.3.2.
+
## 8.15.4 (2017-01-09)
- Make successful pipeline emails off for watchers. !8176
@@ -1342,6 +2262,14 @@ entry.
- Speed up group milestone index by passing group_id to IssuesFinder. !8363
- Ensure issuable state changes only fire webhooks once.
+## 8.14.7 (2017-01-21)
+
+- Ensure export files are removed after a namespace is deleted.
+- Don't allow project guests to subscribe to merge requests through the API. (Robert Schilling)
+- Prevent users from creating notes on resources they can't access.
+- Prevent users from deleting system deploy keys via the project deploy key API.
+- Upgrade omniauth gem to 1.3.2.
+
## 8.14.6 (2017-01-10)
- Update the gitlab-markup gem to the version 1.5.1. !8509
@@ -1624,6 +2552,14 @@ entry.
- Fix "Without projects" filter. !6611 (Ben Bodenmiller)
- Fix 404 when visit /projects page
+## 8.13.12 (2017-01-21)
+
+- Ensure export files are removed after a namespace is deleted.
+- Don't allow project guests to subscribe to merge requests through the API. (Robert Schilling)
+- Prevent users from creating notes on resources they can't access.
+- Prevent users from deleting system deploy keys via the project deploy key API.
+- Upgrade omniauth gem to 1.3.2.
+
## 8.13.11 (2017-01-10)
- Update the gitlab-markup gem to the version 1.5.1. !8509
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 73c8a77364b..12fb34b24be 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,27 +13,29 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
-- [Contributor license agreement](#contributor-license-agreement)
- [Contribute to GitLab](#contribute-to-gitlab)
- [Security vulnerability disclosure](#security-vulnerability-disclosure)
- [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests)
- [Helping others](#helping-others)
- [I want to contribute!](#i-want-to-contribute)
-- [Implement design & UI elements](#implement-design-ui-elements)
-- [Release retrospective and kickoff](#release-retrospective-and-kickoff)
- - [Retrospective](#retrospective)
- - [Kickoff](#kickoff)
+- [Workflow labels](#workflow-labels)
+ - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc)
+ - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc)
+ - [Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.)](#team-labels-ci-discussion-edge-platform-etc)
+ - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch)
+ - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests)
+- [Implement design & UI elements](#implement-design--ui-elements)
- [Issue tracker](#issue-tracker)
- - [Feature proposals](#feature-proposals)
- - [Issue tracker guidelines](#issue-tracker-guidelines)
- - [Issue weight](#issue-weight)
- - [Regression issues](#regression-issues)
- - [Technical debt](#technical-debt)
- - [Stewardship](#stewardship)
+ - [Issue triaging](#issue-triaging)
+ - [Feature proposals](#feature-proposals)
+ - [Issue tracker guidelines](#issue-tracker-guidelines)
+ - [Issue weight](#issue-weight)
+ - [Regression issues](#regression-issues)
+ - [Technical and UX debt](#technical-and-ux-debt)
+ - [Stewardship](#stewardship)
- [Merge requests](#merge-requests)
- - [Merge request guidelines](#merge-request-guidelines)
- - [Contribution acceptance criteria](#contribution-acceptance-criteria)
-- [Changes for Stable Releases](#changes-for-stable-releases)
+ - [Merge request guidelines](#merge-request-guidelines)
+ - [Contribution acceptance criteria](#contribution-acceptance-criteria)
- [Definition of done](#definition-of-done)
- [Style guides](#style-guides)
- [Code of conduct](#code-of-conduct)
@@ -47,6 +49,8 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._
Thank you for your interest in contributing to GitLab. This guide details how
to contribute to GitLab in a way that is efficient for everyone.
+Looking for something to work on? Look for the label [Accepting Merge Requests](#i-want-to-contribute).
+
GitLab comes into two flavors, GitLab Community Edition (CE) our free and open
source edition, and GitLab Enterprise Edition (EE) which is our commercial
edition. Throughout this guide you will see references to CE and EE for
@@ -103,34 +107,128 @@ contributing to GitLab.
## Workflow labels
-Labelling issues is described in the [GitLab Inc engineering workflow].
+To allow for asynchronous issue handling, we use [milestones][milestones-page]
+and [labels][labels-page]. Leads and product managers handle most of the
+scheduling into milestones. Labelling is a task for everyone.
-## Implement design & UI elements
+Most issues will have labels for at least one of the following:
-Please see the [UX Guide for GitLab].
+- Type: ~"feature proposal", ~bug, ~customer, etc.
+- Subject: ~wiki, ~"container registry", ~ldap, ~api, ~frontend, etc.
+- Team: ~CI, ~Discussion, ~Edge, ~Platform, etc.
+- Priority: ~Deliverable, ~Stretch
+
+All labels, their meaning and priority are defined on the
+[labels page][labels-page].
+
+If you come across an issue that has none of these, and you're allowed to set
+labels, you can _always_ add the team and type, and often also the subject.
+
+[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones
+[labels-page]: https://gitlab.com/gitlab-org/gitlab-ce/labels
+
+### Type labels (~"feature proposal", ~bug, ~customer, etc.)
+
+Type labels are very important. They define what kind of issue this is. Every
+issue should have one or more.
+
+Examples of type labels are ~"feature proposal", ~bug, ~customer, ~security,
+and ~"direction".
+
+A number of type labels have a priority assigned to them, which automatically
+makes them float to the top, depending on their importance.
+
+Type labels are always lowercase, and can have any color, besides blue (which is
+already reserved for subject labels).
+
+The descriptions on the [labels page][labels-page] explain what falls under each type label.
+
+### Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)
+
+Subject labels are labels that define what area or feature of GitLab this issue
+hits. They are not always necessary, but very convenient.
+
+If you are an expert in a particular area, it makes it easier to find issues to
+work on. You can also subscribe to those labels to receive an email each time an
+issue is labelled with a subject label corresponding to your expertise.
+
+Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api,
+~issues, ~"merge requests", ~labels, and ~"container registry".
+
+Subject labels are always all-lowercase.
+
+### Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.)
+
+Team labels specify what team is responsible for this issue.
+Assigning a team label makes sure issues get the attention of the appropriate
+people.
+
+The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge,
+~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX".
+
+The descriptions on the [labels page][labels-page] explain what falls under the
+responsibility of each team.
+
+Within those team labels, we also have the ~backend and ~frontend labels to
+indicate if an issue needs backend work, frontend work, or both.
+
+Team labels are always capitalized so that they show up as the first label for
+any issue.
-## Release retrospective and kickoff
+### Priority labels (~Deliverable and ~Stretch)
-### Retrospective
+Priority labels help us clearly communicate expectations of the work for the
+release. There are two levels of priority labels:
-After each release, we have a retrospective call where we discuss what went well,
-what went wrong, and what we can improve for the next release. The
-[retrospective notes] are public and you are invited to comment on them.
-If you're interested, you can even join the
-[retrospective call][retro-kickoff-call], on the first working day after the
-22nd at 6pm CET / 9am PST.
+- ~Deliverable: Issues that are expected to be delivered in the current
+ milestone.
+- ~Stretch: Issues that are a stretch goal for delivering in the current
+ milestone. If these issues are not done in the current release, they will
+ strongly be considered for the next release.
-### Kickoff
+### Label for community contributors (~"Accepting Merge Requests")
-Before working on the next release, we have a
-kickoff call to explain what we expect to ship in the next release. The
-[kickoff notes] are public and you are invited to comment on them.
-If you're interested, you can even join the [kickoff call][retro-kickoff-call],
-on the first working day after the 7th at 6pm CET / 9am PST..
+Issues that are beneficial to our users, 'nice to haves', that we currently do
+not have the capacity for or want to give the priority to, are labeled as
+~"Accepting Merge Requests", so the community can make a contribution.
-[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing
-[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing
-[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206
+Community contributors can submit merge requests for any issue they want, but
+the ~"Accepting Merge Requests" label has a special meaning. It points to
+changes that:
+
+1. We already agreed on,
+1. Are well-defined,
+1. Are likely to get accepted by a maintainer.
+
+We want to avoid a situation when a contributor picks an
+~"Accepting Merge Requests" issue and then their merge request gets closed,
+because we realize that it does not fit our vision, or we want to solve it in a
+different way.
+
+We add the ~"Accepting Merge Requests" label to:
+
+- Low priority ~bug issues (i.e. we do not add it to the bugs that we want to
+solve in the ~"Next Patch Release")
+- Small ~"feature proposal" that do not need ~UX / ~"Product work", or for which
+the ~UX / ~"Product work" is already done
+- Small ~"technical debt" issues
+
+After adding the ~"Accepting Merge Requests" label, we try to estimate the
+[weight](#issue-weight) of the issue. We use issue weight to let contributors
+know how difficult the issue is. Additionally:
+
+- We advertise [~"Accepting Merge Requests" issues with weight < 5][up-for-grabs]
+ as suitable for people that have never contributed to GitLab before on the
+ [Up For Grabs campaign](http://up-for-grabs.net)
+- We encourage people that have never contributed to any open source project to
+ look for [~"Accepting Merge Requests" issues with a weight of 1][firt-timers]
+
+[up-for-grabs]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests&scope=all&sort=weight_asc&state=opened
+[firt-timers]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=Accepting+Merge+Requests&scope=all&sort=upvotes_desc&state=opened&weight=1
+
+## Implement design & UI elements
+
+Please see the [UX Guide for GitLab].
## Issue tracker
@@ -154,6 +252,21 @@ If it happens that you know the solution to an existing bug, please first
open the issue in order to keep track of it and then open the relevant merge
request that potentially fixes it.
+### Issue triaging
+
+Our issue triage policies are [described in our handbook]. You are very welcome
+to help the GitLab team triage issues. We also organize [issue bash events] once
+every quarter.
+
+The most important thing is making sure valid issues receive feedback from the
+development team. Therefore the priority is mentioning developers that can help
+on those issues. Please select someone with relevant experience from the
+[GitLab team][team]. If there is nobody mentioned with that expertise look in
+the commit history for the affected files to find someone.
+
+[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/
+[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
+
### Feature proposals
To create a feature proposal for CE, open an issue on the
@@ -165,7 +278,7 @@ For feature proposals for EE, open an issue on the
In order to help track the feature proposals, we have created a
[`feature proposal`][fpl] label. For the time being, users that are not members
of the project cannot add labels. You can instead ask one of the [core team]
-members to add the label `feature proposal` to the issue or add the following
+members to add the label ~"feature proposal" to the issue or add the following
code snippet right after your description in a new line: `~"feature proposal"`.
Please keep feature proposals as small and simple as possible, complex ones
@@ -231,27 +344,29 @@ addressed.
[8.3 Regressions]: https://gitlab.com/gitlab-org/gitlab-ce/issues/4127
[update the notes]: https://gitlab.com/gitlab-org/release-tools/blob/master/doc/pro-tips.md#update-the-regression-issue
-### Technical debt
+### Technical and UX debt
-In order to track things that can be improved in GitLab's codebase, we created
-the ~"technical debt" label in [GitLab's issue tracker][ce-tracker].
+In order to track things that can be improved in GitLab's codebase,
+we use the ~"technical debt" label in [GitLab's issue tracker][ce-tracker].
+For user experience improvements, we use the ~"UX debt" label.
-This label should be added to issues that describe things that can be improved,
-shortcuts that have been taken, code that needs refactoring, features that need
-additional attention, and all other things that have been left behind due to
-high velocity of development.
+These labels should be added to issues that describe things that can be improved,
+shortcuts that have been taken, features that need additional attention, and all
+other things that have been left behind due to high velocity of development.
+For example, code that needs refactoring should use the ~"technical debt" label,
+user experience refinements should use the ~"UX debt" label.
Everyone can create an issue, though you may need to ask for adding a specific
label, if you do not have permissions to do it by yourself. Additional labels
-can be combined with the `technical debt` label, to make it easier to schedule
+can be combined with these labels, to make it easier to schedule
the improvements for a release.
-Issues tagged with the `technical debt` label have the same priority like issues
+Issues tagged with these labels have the same priority like issues
that describe a new feature to be introduced in GitLab, and should be scheduled
for a release by the appropriate person.
-Make sure to mention the merge request that the `technical debt` issue is
-associated with in the description of the issue.
+Make sure to mention the merge request that the ~"technical debt" issue or
+~"UX debt" issue is associated with in the description of the issue.
### Stewardship
@@ -327,13 +442,17 @@ request is as follows:
"Description" field.
1. If you are contributing documentation, choose `Documentation` from the
"Choose a template" menu and fill in the template.
+ 1. Mention the issue(s) your merge request solves, using the `Solves #XXX` or
+ `Closes #XXX` syntax to auto-close the issue(s) once the merge request will
+ be merged.
+1. If you're allowed to, set a relevant milestone and labels
1. If the MR changes the UI it should include *Before* and *After* screenshots
1. If the MR changes CSS classes please include the list of affected pages,
`grep css-class ./app -R`
-1. Link any relevant [issues][ce-tracker] in the merge request description and
- leave a comment on them with a link back to the MR
1. Be prepared to answer questions and incorporate feedback even if requests
for this arrive weeks or months after your MR submission
+ 1. If a discussion has been addressed, select the "Resolve discussion" button
+ beneath it to mark it resolved.
1. If your MR touches code that executes shell commands, reads or opens files or
handles paths to files on disk, make sure it adheres to the
[shell command guidelines](doc/development/shell_commands.md)
@@ -369,24 +488,6 @@ Please ensure that your merge request meets the contribution acceptance criteria
When having your code reviewed and when reviewing merge requests please take the
[code review guidelines](doc/development/code_review.md) into account.
-### Getting your merge request reviewed, approved, and merged
-
-There are a few rules to get your merge request accepted:
-
-1. Your merge request should only be **merged by a [maintainer][team]**.
- 1. If your merge request includes only backend changes [^1], it must be
- **approved by a [backend maintainer][team]**.
- 1. If your merge request includes only frontend changes [^1], it must be
- **approved by a [frontend maintainer][team]**.
- 1. If your merge request includes frontend and backend changes [^1], it must
- be **approved by a [frontend and a backend maintainer][team]**.
-1. To lower the amount of merge requests maintainers need to review, you can
- ask or assign any [reviewers][team] for a first review.
- 1. If you need some guidance (e.g. it's your first merge request), feel free
- to ask one of the [Merge request coaches][team].
- 1. The reviewer will assign the merge request to a maintainer once the
- reviewer is satisfied with the state of the merge request.
-
### Contribution acceptance criteria
1. The change is as small as possible
@@ -416,8 +517,7 @@ There are a few rules to get your merge request accepted:
1. If you need polling to support real-time features, please use
[polling with ETag caching][polling-etag].
1. Changes after submitting the merge request should be in separate commits
- (no squashing). If necessary, you will be asked to squash when the review is
- over, before merging.
+ (no squashing).
1. It conforms to the [style guides](#style-guides) and the following:
- If your change touches a line that does not follow the style, modify the
entire line to follow it. This prevents linting tools from generating warnings.
@@ -428,19 +528,6 @@ There are a few rules to get your merge request accepted:
See the instructions in that document for help if your MR fails the
"license-finder" test with a "Dependencies that need approval" error.
-## Changes for Stable Releases
-
-Sometimes certain changes have to be added to an existing stable release.
-Two examples are bug fixes and performance improvements. In these cases the
-corresponding merge request should be updated to have the following:
-
-1. A milestone indicating what release the merge request should be merged into.
-1. The label "Pick into Stable"
-
-This makes it easier for release managers to keep track of what still has to be
-merged and where changes have to be merged into.
-Like all merge requests the target should be master so all bugfixes are in master.
-
## Definition of done
If you contribute to GitLab please know that changes involve more than just
@@ -449,16 +536,16 @@ the feature you contribute through all of these steps.
1. Description explaining the relevancy (see following item)
1. Working and clean code that is commented where needed
-1. Unit and integration tests that pass on the CI server
+1. [Unit and system tests][testing] that pass on the CI server
1. Performance/scalability implications have been considered, addressed, and tested
-1. [Documented][doc-styleguide] in the /doc directory
-1. Changelog entry added
+1. [Documented][doc-styleguide] in the `/doc` directory
+1. [Changelog entry added][changelog], if necessary
1. Reviewed and any concerns are addressed
-1. Merged by the project lead
-1. Added to the release blog article
-1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant
+1. Merged by a project maintainer
+1. Added to the release blog article, if relevant
+1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/), if relevant
1. Community questions answered
-1. Answers to questions radiated (in docs/wiki/etc.)
+1. Answers to questions radiated (in docs/wiki/support etc.)
If you add a dependency in GitLab (such as an operating system package) please
consider updating the following and note the applicability of each in your
@@ -481,7 +568,7 @@ merge request:
- string literal quoting style **Option A**: single quoted by default
1. [Rails](https://github.com/bbatsov/rails-style-guide)
1. [Newlines styleguide][newlines-styleguide]
-1. [Testing](doc/development/testing.md)
+1. [Testing][testing]
1. [JavaScript styleguide][js-styleguide]
1. [SCSS styleguide][scss-styleguide]
1. [Shell commands](doc/development/shell_commands.md) created by GitLab
@@ -558,6 +645,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor
[license-finder-doc]: doc/development/licensing.md
[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues
[polling-etag]: https://docs.gitlab.com/ce/development/polling.html
+[testing]: doc/development/testing.md
[^1]: Please note that specs other than JavaScript specs are considered backend
code.
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index a918a2aa18d..c25c8e5b741 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.6.0
+0.30.0
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 1d0ba9ea182..8f0916f768f 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-0.4.0
+0.5.0
diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION
index 50e2274e6d3..11d9efa3d5a 100644
--- a/GITLAB_SHELL_VERSION
+++ b/GITLAB_SHELL_VERSION
@@ -1 +1 @@
-5.0.3
+5.8.0
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index 428b770e3e2..4a36342fcab 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-1.4.3
+3.0.0
diff --git a/Gemfile b/Gemfile
index 41c2dcfd76e..62d6b1e6446 100644
--- a/Gemfile
+++ b/Gemfile
@@ -12,12 +12,13 @@ gem 'sprockets', '~> 3.7.0'
gem 'default_value_for', '~> 3.0.0'
# Supported DBs
-gem 'mysql2', '~> 0.3.16', group: :mysql
+gem 'mysql2', '~> 0.4.5', group: :mysql
gem 'pg', '~> 0.18.2', group: :postgres
-gem 'rugged', '~> 0.25.1.1'
+gem 'rugged', '~> 0.26.0'
+gem 'grape-route-helpers', '~> 2.1.0'
-gem 'faraday', '~> 0.11.0'
+gem 'faraday', '~> 0.12'
# Authentication libraries
gem 'devise', '~> 4.2'
@@ -37,7 +38,7 @@ gem 'omniauth-saml', '~> 1.7.0'
gem 'omniauth-shibboleth', '~> 1.2.0'
gem 'omniauth-twitter', '~> 1.2.0'
gem 'omniauth_crowd', '~> 2.2.0'
-gem 'omniauth-authentiq', '~> 0.3.0'
+gem 'omniauth-authentiq', '~> 0.3.1'
gem 'rack-oauth2', '~> 1.2.1'
gem 'jwt', '~> 1.5.6'
@@ -57,10 +58,14 @@ gem 'validates_hostname', '~> 1.0.6'
# Browser detection
gem 'browser', '~> 2.2'
+# GPG
+gem 'gpgme'
+
# LDAP Auth
# GitLab fork with several improvements to original library. For full list of changes
# see https://github.com/intridea/omniauth-ldap/compare/master...gitlabhq:master
-gem 'gitlab_omniauth-ldap', '~> 1.2.1', require: 'omniauth-ldap'
+gem 'gitlab_omniauth-ldap', '~> 2.0.4', require: 'omniauth-ldap'
+gem 'net-ldap'
# Git Wiki
# Required manually in config/initializers/gollum.rb to control load order
@@ -71,7 +76,7 @@ gem 'gollum-rugged_adapter', '~> 0.4.4', require: false
gem 'github-linguist', '~> 4.7.0', require: 'linguist'
# API
-gem 'grape', '~> 0.19.0'
+gem 'grape', '~> 1.0'
gem 'grape-entity', '~> 0.6.0'
gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
@@ -79,24 +84,25 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
gem 'hashie-forbidden_attributes'
# Pagination
-gem 'kaminari', '~> 0.17.0'
+gem 'kaminari', '~> 1.0'
# HAML
gem 'hamlit', '~> 2.6.1'
# Files attachments
-gem 'carrierwave', '~> 0.11.0'
+gem 'carrierwave', '~> 1.1'
# Drag and Drop UI
gem 'dropzonejs-rails', '~> 0.7.1'
# for backups
-gem 'fog-aws', '~> 0.9'
-gem 'fog-core', '~> 1.40'
+gem 'fog-aws', '~> 1.4'
+gem 'fog-core', '~> 1.44'
gem 'fog-google', '~> 0.5'
gem 'fog-local', '~> 0.3'
gem 'fog-openstack', '~> 0.1'
gem 'fog-rackspace', '~> 0.1.1'
+gem 'fog-aliyun', '~> 0.1.0'
# for Google storage
gem 'google-api-client', '~> 0.8.6'
@@ -109,7 +115,7 @@ gem 'seed-fu', '~> 2.3.5'
# Markdown and HTML processing
gem 'html-pipeline', '~> 1.11.0'
-gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie'
+gem 'deckar01-task_list', '2.0.0'
gem 'gitlab-markup', '~> 1.5.1'
gem 'redcarpet', '~> 3.4'
gem 'RedCloth', '~> 4.3.2'
@@ -121,6 +127,7 @@ gem 'asciidoctor', '~> 1.5.2'
gem 'asciidoctor-plantuml', '0.0.7'
gem 'rouge', '~> 2.0'
gem 'truncato', '~> 0.7.8'
+gem 'bootstrap_form', '~> 2.7.0'
# See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s
# and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM
@@ -137,30 +144,31 @@ end
# State machine
gem 'state_machines-activerecord', '~> 0.4.0'
-# Run events after state machine commits
-gem 'after_commit_queue', '~> 1.3.0'
# Issue tags
gem 'acts-as-taggable-on', '~> 4.0'
# Background jobs
gem 'sidekiq', '~> 5.0'
-gem 'sidekiq-cron', '~> 0.4.4'
+gem 'sidekiq-cron', '~> 0.6.0'
gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
-gem 'rufus-scheduler', '~> 3.1.10'
+gem 'rufus-scheduler', '~> 3.4'
# HTTP requests
gem 'httparty', '~> 0.13.3'
# Colored output to console
-gem 'rainbow', '~> 2.1.0'
+gem 'rainbow', '~> 2.2'
# GitLab settings
gem 'settingslogic', '~> 2.0.9'
+# Linear-time regex library for untrusted regular expressions
+gem 're2', '~> 1.1.1'
+
# Misc
gem 'version_sorter', '~> 2.1.0'
@@ -222,7 +230,7 @@ gem 'ace-rails-ap', '~> 4.1.0'
gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding
-gem 'charlock_holmes', '~> 0.7.3'
+gem 'charlock_holmes', '~> 0.7.5'
# Faster JSON
gem 'oj', '~> 2.17.4'
@@ -235,7 +243,6 @@ gem 'webpack-rails', '~> 0.9.10'
gem 'rack-proxy', '~> 0.6.0'
gem 'sass-rails', '~> 5.0.6'
-gem 'coffee-rails', '~> 4.1.0'
gem 'uglifier', '~> 2.7.2'
gem 'addressable', '~> 2.3.8'
@@ -248,19 +255,40 @@ gem 'jquery-rails', '~> 4.1.0'
gem 'request_store', '~> 1.3'
gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
-gem 'net-ssh', '~> 3.0.1'
gem 'base32', '~> 0.3.0'
# Sentry integration
-gem 'sentry-raven', '~> 2.4.0'
-
-gem 'premailer-rails', '~> 1.9.0'
+gem 'sentry-raven', '~> 2.5.3'
+
+gem 'premailer-rails', '~> 1.9.7'
+
+# I18n
+gem 'ruby_parser', '~> 3.8', require: false
+gem 'rails-i18n', '~> 4.0.9'
+gem 'gettext_i18n_rails', '~> 1.8.0'
+gem 'gettext_i18n_rails_js', '~> 1.2.0'
+gem 'gettext', '~> 3.2.2', require: false, group: :development
+
+# Perf bar
+gem 'peek', '~> 1.0.1'
+gem 'peek-gc', '~> 0.0.2'
+gem 'peek-host', '~> 1.0.0'
+gem 'peek-mysql2', '~> 1.1.0', group: :mysql
+gem 'peek-performance_bar', '~> 1.3.0'
+gem 'peek-pg', '~> 1.3.0', group: :postgres
+gem 'peek-rblineprof', '~> 0.2.0'
+gem 'peek-redis', '~> 1.2.0'
+gem 'peek-sidekiq', '~> 1.0.3'
# Metrics
group :metrics do
gem 'allocations', '~> 1.0', require: false, platform: :mri
gem 'method_source', '~> 0.8', require: false
gem 'influxdb', '~> 0.2', require: false
+
+ # Prometheus
+ gem 'prometheus-client-mmap', '~>0.7.0.beta11'
+ gem 'raindrops', '~> 0.18'
end
group :development do
@@ -284,16 +312,17 @@ group :development, :test do
gem 'pry-rails', '~> 0.3.4'
gem 'awesome_print', '~> 1.2.0', require: false
- gem 'fuubar', '~> 2.0.0'
+ gem 'fuubar', '~> 2.2.0'
gem 'database_cleaner', '~> 1.5.0'
gem 'factory_girl_rails', '~> 4.7.0'
- gem 'rspec-rails', '~> 3.5.0'
+ gem 'rspec-rails', '~> 3.6.0'
gem 'rspec-retry', '~> 0.4.5'
gem 'spinach-rails', '~> 0.2.1'
gem 'spinach-rerun-reporter', '~> 0.0.2'
gem 'rspec_profiling', '~> 0.0.5'
gem 'rspec-set', '~> 0.1.3'
+ gem 'rspec-parameterized'
# Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826)
gem 'minitest', '~> 5.7.0'
@@ -309,10 +338,11 @@ group :development, :test do
gem 'spring-commands-rspec', '~> 1.0.4'
gem 'spring-commands-spinach', '~> 1.1.0'
- gem 'rubocop', '~> 0.47.1', 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 'rubocop', '~> 0.49.1', require: false
+ gem 'rubocop-rspec', '~> 1.15.1', require: false
+ gem 'rubocop-gitlab-security', '~> 0.0.6', require: false
+ gem 'scss_lint', '~> 0.54.0', require: false
+ gem 'haml_lint', '~> 0.26.0', require: false
gem 'simplecov', '~> 0.14.0', require: false
gem 'flay', '~> 2.8.0', require: false
gem 'bundler-audit', '~> 0.5.0', require: false
@@ -324,14 +354,14 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2'
- gem 'stackprof', '~> 0.2.10'
+ gem 'stackprof', '~> 0.2.10', require: false
end
group :test do
- gem 'shoulda-matchers', '~> 2.8.0', require: false
+ gem 'shoulda-matchers', '~> 3.1.2', require: false
gem 'email_spec', '~> 1.6.0'
gem 'json-schema', '~> 2.6.2'
- gem 'webmock', '~> 1.24.0'
+ gem 'webmock', '~> 2.3.2'
gem 'test_after_commit', '~> 1.1'
gem 'sham_rack', '~> 1.3.6'
gem 'timecop', '~> 0.8.0'
@@ -348,10 +378,10 @@ gem 'html2text'
gem 'ruby-prof', '~> 0.16.2'
# OAuth
-gem 'oauth2', '~> 1.3.0'
+gem 'oauth2', '~> 1.4'
# Soft deletion
-gem 'paranoia', '~> 2.2'
+gem 'paranoia', '~> 2.3.1'
# Health check
gem 'health_check', '~> 2.6.0'
@@ -360,7 +390,24 @@ gem 'health_check', '~> 2.6.0'
gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
+# SSH host key support
+gem 'net-ssh', '~> 4.1.0'
+
+# Required for ED25519 SSH host key support
+group :ed25519 do
+ gem 'rbnacl-libsodium'
+ gem 'rbnacl', '~> 3.2'
+ gem 'bcrypt_pbkdf', '~> 1.0'
+end
+
# Gitaly GRPC client
-gem 'gitaly', '~> 0.5.0'
+gem 'gitaly', '~> 0.27.0'
gem 'toml-rb', '~> 0.3.15', require: false
+
+# Feature toggles
+gem 'flipper', '~> 0.10.2'
+gem 'flipper-active_record', '~> 0.10.2'
+
+# Structured logging
+gem 'lograge', '~> 0.5'
diff --git a/Gemfile.lock b/Gemfile.lock
index 52707628748..ae1df783a80 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,6 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
RedCloth (4.3.2)
+ abstract_type (0.0.7)
ace-rails-ap (4.1.2)
actionmailer (4.2.8)
actionpack (= 4.2.8)
@@ -41,9 +42,10 @@ GEM
tzinfo (~> 1.1)
acts-as-taggable-on (4.0.0)
activerecord (>= 4.0)
+ adamantium (0.2.0)
+ ice_nine (~> 0.11.0)
+ memoizable (~> 0.4.0)
addressable (2.3.8)
- after_commit_queue (1.3.0)
- activerecord (>= 3.0)
akismet (2.0.0)
allocations (1.0.5)
arel (6.0.4)
@@ -56,6 +58,7 @@ GEM
asciidoctor-plantuml (0.0.7)
asciidoctor (~> 1.5)
ast (2.3.0)
+ atomic (1.1.99)
attr_encrypted (3.0.3)
encryptor (~> 3.0.0)
attr_required (1.0.0)
@@ -74,6 +77,7 @@ GEM
babosa (1.0.2)
base32 (0.3.2)
bcrypt (3.1.11)
+ bcrypt_pbkdf (1.0.0)
benchmark-ips (2.3.0)
better_errors (2.1.1)
coderay (>= 1.0.0)
@@ -85,6 +89,7 @@ GEM
bootstrap-sass (3.3.6)
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
+ bootstrap_form (2.7.0)
brakeman (3.6.1)
browser (2.2.0)
builder (3.2.3)
@@ -105,14 +110,12 @@ GEM
capybara-screenshot (1.0.14)
capybara (>= 1.0, < 3)
launchy
- carrierwave (0.11.2)
- activemodel (>= 3.2.0)
- activesupport (>= 3.2.0)
- json (>= 1.7)
+ carrierwave (1.1.0)
+ activemodel (>= 4.0.0)
+ activesupport (>= 4.0.0)
mime-types (>= 1.16)
- mimemagic (>= 0.3.0)
cause (0.1)
- charlock_holmes (0.7.3)
+ charlock_holmes (0.7.5)
chronic (0.10.2)
chronic_duration (0.10.6)
numerizer (~> 0.1.1)
@@ -122,20 +125,18 @@ GEM
coderay (1.1.1)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
- coffee-rails (4.1.1)
- coffee-script (>= 2.2.0)
- railties (>= 4.0.0, < 5.1.x)
- coffee-script (2.4.1)
- coffee-script-source
- execjs
- coffee-script-source (1.10.0)
colorize (0.7.7)
+ concord (0.1.5)
+ adamantium (~> 0.2.0)
+ equalizer (~> 0.0.9)
concurrent-ruby (1.0.5)
+ concurrent-ruby-ext (1.0.5)
+ concurrent-ruby (= 1.0.5)
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
creole (0.5.0)
- css_parser (1.4.1)
+ css_parser (1.5.0)
addressable
d3_rails (3.5.11)
railties (>= 3.1.0)
@@ -143,10 +144,8 @@ GEM
database_cleaner (1.5.3)
debug_inspector (0.0.2)
debugger-ruby_core_source (1.3.8)
- deckar01-task_list (1.0.6)
- activesupport (~> 4.0)
+ deckar01-task_list (2.0.0)
html-pipeline
- rack (~> 1.0)
default_value_for (3.0.2)
activerecord (>= 3.2.0, < 5.1)
descendants_tracker (0.0.4)
@@ -163,7 +162,7 @@ GEM
devise (~> 4.0)
railties
rotp (~> 2.0)
- diff-lcs (1.2.5)
+ diff-lcs (1.3)
diffy (3.1.0)
docile (1.1.5)
domain_name (0.5.20161021)
@@ -183,8 +182,10 @@ GEM
equalizer (0.0.11)
erubis (2.7.0)
escape_utils (1.1.1)
+ et-orbi (1.0.3)
+ tzinfo
eventmachine (1.0.8)
- excon (0.52.0)
+ excon (0.57.1)
execjs (2.6.0)
expression_parser (0.9.0)
extlib (0.9.16)
@@ -193,13 +194,14 @@ GEM
factory_girl_rails (4.7.0)
factory_girl (~> 4.7.0)
railties (>= 3.0.0)
- faraday (0.11.0)
+ faraday (0.12.1)
multipart-post (>= 1.2, < 3)
faraday_middleware (0.11.0.1)
faraday (>= 0.7.4, < 1.0)
faraday_middleware-multi_json (0.0.6)
faraday_middleware
multi_json
+ fast_gettext (1.4.0)
ffaker (2.4.0)
ffi (1.9.10)
flay (2.8.1)
@@ -207,29 +209,38 @@ GEM
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
+ flipper (0.10.2)
+ flipper-active_record (0.10.2)
+ activerecord (>= 3.2, < 6)
+ flipper (~> 0.10.2)
flowdock (0.7.1)
httparty (~> 0.7)
multi_json
- fog-aws (0.11.0)
+ fog-aliyun (0.1.0)
+ fog-core (~> 1.27)
+ fog-json (~> 1.0)
+ ipaddress (~> 0.8)
+ xml-simple (~> 1.1)
+ fog-aws (1.4.0)
fog-core (~> 1.38)
fog-json (~> 1.0)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
- fog-core (1.42.0)
+ fog-core (1.44.3)
builder
excon (~> 0.49)
formatador (~> 0.2)
- fog-google (0.5.0)
+ fog-google (0.5.3)
fog-core
fog-json
fog-xml
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
- fog-local (0.3.0)
+ fog-local (0.3.1)
fog-core (~> 1.27)
- fog-openstack (0.1.6)
- fog-core (>= 1.39)
+ fog-openstack (0.1.21)
+ fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
fog-rackspace (0.1.1)
@@ -237,24 +248,34 @@ GEM
fog-json (>= 1.0)
fog-xml (>= 0.1)
ipaddress (>= 0.8)
- fog-xml (0.1.2)
+ fog-xml (0.1.3)
fog-core
- nokogiri (~> 1.5, >= 1.5.11)
+ nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-rails (4.7.0.1)
railties (>= 3.2, < 5.1)
foreman (0.78.0)
thor (~> 0.19.1)
formatador (0.2.5)
- fuubar (2.0.0)
- rspec (~> 3.0)
+ fuubar (2.2.0)
+ rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
gemnasium-gitlab-service (0.2.6)
rugged (~> 0.21)
gemojione (3.0.1)
json
get_process_mem (0.2.0)
+ gettext (3.2.2)
+ locale (>= 2.0.5)
+ text (>= 1.3.0)
+ gettext_i18n_rails (1.8.0)
+ fast_gettext (>= 0.9.0)
+ gettext_i18n_rails_js (1.2.0)
+ gettext (>= 3.0.2)
+ gettext_i18n_rails (>= 0.7.1)
+ po_to_json (>= 1.0.0)
+ rails (>= 3.2.0)
gherkin-ruby (0.3.2)
- gitaly (0.5.0)
+ gitaly (0.27.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@@ -273,11 +294,11 @@ GEM
mime-types (>= 1.16, < 3)
posix-spawn (~> 0.3)
gitlab-markup (1.5.1)
- gitlab_omniauth-ldap (1.2.1)
- net-ldap (~> 0.9)
- omniauth (~> 1.0)
- pyu-ruby-sasl (~> 0.0.3.1)
- rubyntlm (~> 0.3)
+ gitlab_omniauth-ldap (2.0.4)
+ net-ldap (~> 0.16)
+ omniauth (~> 1.3)
+ pyu-ruby-sasl (>= 0.0.3.3, < 0.1)
+ rubyntlm (~> 0.5)
globalid (0.3.7)
activesupport (>= 4.1.0)
gollum-grit_adapter (1.0.1)
@@ -308,7 +329,7 @@ GEM
multi_json (~> 1.10)
retriable (~> 1.4)
signet (~> 0.6)
- google-protobuf (3.2.0.2)
+ google-protobuf (3.3.0)
googleauth (0.5.1)
faraday (~> 0.9)
jwt (~> 1.4)
@@ -317,35 +338,39 @@ GEM
multi_json (~> 1.11)
os (~> 0.9)
signet (~> 0.7)
- grape (0.19.1)
+ gpgme (2.0.13)
+ mini_portile2 (~> 2.1)
+ grape (1.0.0)
activesupport
builder
- hashie (>= 2.1.0)
- multi_json (>= 1.3.2)
- multi_xml (>= 0.5.2)
- mustermann-grape (~> 0.4.0)
+ mustermann-grape (~> 1.0.0)
rack (>= 1.3.0)
rack-accept
virtus (>= 1.0.0)
grape-entity (0.6.0)
activesupport
multi_json (>= 1.3.2)
- grpc (1.1.2)
+ grape-route-helpers (2.1.0)
+ activesupport
+ grape (>= 0.16.0)
+ rake
+ grpc (1.4.5)
google-protobuf (~> 3.1)
googleauth (~> 0.5.1)
haml (4.0.7)
tilt
- haml_lint (0.21.0)
- haml (~> 4.0)
+ haml_lint (0.26.0)
+ haml (>= 4.0, < 5.1)
+ rainbow
rake (>= 10, < 13)
- rubocop (>= 0.47.0)
+ rubocop (>= 0.49.0)
sysexits (~> 1.1)
hamlit (2.6.1)
temple (~> 0.7.6)
thor
tilt
- hashdiff (0.3.2)
- hashie (3.5.5)
+ hashdiff (0.3.4)
+ hashie (3.5.6)
hashie-forbidden_attributes (0.1.1)
hashie (>= 3.0)
health_check (2.6.0)
@@ -372,7 +397,7 @@ GEM
json (~> 1.8)
multi_xml (>= 0.5.2)
httpclient (2.8.2)
- i18n (0.8.1)
+ i18n (0.8.6)
ice_nine (0.11.2)
influxdb (0.2.3)
cause
@@ -396,9 +421,18 @@ GEM
json-schema (2.6.2)
addressable (~> 2.3.8)
jwt (1.5.6)
- kaminari (0.17.0)
- actionpack (>= 3.0.0)
- activesupport (>= 3.0.0)
+ kaminari (1.0.1)
+ activesupport (>= 4.1.0)
+ kaminari-actionview (= 1.0.1)
+ kaminari-activerecord (= 1.0.1)
+ kaminari-core (= 1.0.1)
+ kaminari-actionview (1.0.1)
+ actionview
+ kaminari-core (= 1.0.1)
+ kaminari-activerecord (1.0.1)
+ activerecord
+ kaminari-core (= 1.0.1)
+ kaminari-core (1.0.1)
kgio (2.10.0)
knapsack (1.11.0)
rake
@@ -424,38 +458,45 @@ GEM
licensee (8.7.0)
rugged (~> 0.24)
little-plugger (1.1.4)
- logging (2.1.0)
+ locale (2.1.2)
+ logging (2.2.2)
little-plugger (~> 1.1)
multi_json (~> 1.10)
+ lograge (0.5.1)
+ actionpack (>= 4, < 5.2)
+ activesupport (>= 4, < 5.2)
+ railties (>= 4, < 5.2)
loofah (2.0.3)
nokogiri (>= 1.5.9)
mail (2.6.5)
mime-types (>= 1.16, < 4)
mail_room (0.9.1)
memoist (0.15.0)
+ memoizable (0.4.2)
+ thread_safe (~> 0.3, >= 0.3.1)
method_source (0.8.2)
mime-types (2.99.3)
mimemagic (0.3.0)
mini_portile2 (2.1.0)
minitest (5.7.0)
+ mmap2 (2.2.7)
mousetrap-rails (1.4.6)
multi_json (1.12.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
- mustermann (0.4.0)
- tool (~> 0.2)
- mustermann-grape (0.4.0)
- mustermann (= 0.4.0)
- mysql2 (0.3.20)
- net-ldap (0.12.1)
- net-ssh (3.0.1)
+ mustermann (1.0.0)
+ mustermann-grape (1.0.0)
+ mustermann (~> 1.0.0)
+ mysql2 (0.4.5)
+ net-ldap (0.16.0)
+ net-ssh (4.1.0)
netrc (0.11.0)
nokogiri (1.6.8.1)
mini_portile2 (~> 2.1.0)
numerizer (0.1.1)
oauth (0.5.1)
- oauth2 (1.3.1)
- faraday (>= 0.8, < 0.12)
+ oauth2 (1.4.0)
+ faraday (>= 0.8, < 0.13)
jwt (~> 1.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
@@ -468,7 +509,7 @@ GEM
rack (>= 1.0, < 3)
omniauth-auth0 (1.4.1)
omniauth-oauth2 (~> 1.1)
- omniauth-authentiq (0.3.0)
+ omniauth-authentiq (0.3.1)
omniauth-oauth2 (~> 1.3, >= 1.3.1)
omniauth-azure-oauth2 (0.0.6)
jwt (~> 1.0)
@@ -487,11 +528,10 @@ GEM
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0)
omniauth-google-oauth2 (0.4.1)
- addressable (~> 2.3)
- jwt (~> 1.0)
+ jwt (~> 1.5.2)
multi_json (~> 1.3)
omniauth (>= 1.1.1)
- omniauth-oauth2 (~> 1.3.1)
+ omniauth-oauth2 (>= 1.3.1)
omniauth-kerberos (0.3.0)
omniauth-multipassword
timfel-krb5-auth (~> 0.8)
@@ -521,12 +561,45 @@ GEM
rubypants (~> 0.2)
orm_adapter (0.5.0)
os (0.9.6)
- paranoia (2.2.0)
- activerecord (>= 4.0, < 5.1)
+ parallel (1.12.0)
+ paranoia (2.3.1)
+ activerecord (>= 4.0, < 5.2)
parser (2.4.0.0)
ast (~> 2.2)
path_expander (1.0.1)
+ peek (1.0.1)
+ concurrent-ruby (>= 0.9.0)
+ concurrent-ruby-ext (>= 0.9.0)
+ railties (>= 4.0.0)
+ peek-gc (0.0.2)
+ peek
+ peek-host (1.0.0)
+ peek
+ peek-mysql2 (1.1.0)
+ atomic (>= 1.0.0)
+ mysql2
+ peek
+ peek-performance_bar (1.3.0)
+ peek (>= 0.1.0)
+ peek-pg (1.3.0)
+ concurrent-ruby
+ concurrent-ruby-ext
+ peek
+ pg
+ peek-rblineprof (0.2.0)
+ peek
+ rblineprof
+ peek-redis (1.2.0)
+ atomic (>= 1.0.0)
+ peek
+ redis
+ peek-sidekiq (1.0.3)
+ atomic (>= 1.0.0)
+ peek
+ sidekiq
pg (0.18.4)
+ po_to_json (1.0.1)
+ json (>= 1.6.0)
poltergeist (1.9.0)
capybara (~> 2.1)
cliver (~> 0.3.1)
@@ -534,12 +607,20 @@ GEM
websocket-driver (>= 0.2.0)
posix-spawn (0.3.11)
powerpack (0.1.1)
- premailer (1.8.6)
- css_parser (>= 1.3.6)
+ premailer (1.10.4)
+ addressable
+ css_parser (>= 1.4.10)
htmlentities (>= 4.0.0)
- premailer-rails (1.9.2)
+ premailer-rails (1.9.7)
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ procto (0.0.3)
+ prometheus-client-mmap (0.7.0.beta11)
+ mmap2 (~> 2.2, >= 2.2.7)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
@@ -550,7 +631,7 @@ GEM
pry-rails (0.3.5)
pry (>= 0.9.10)
pyu-ruby-sasl (0.0.3.3)
- rack (1.6.5)
+ rack (1.6.8)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (4.4.1)
@@ -587,18 +668,27 @@ GEM
rails-deprecated_sanitizer (>= 1.0.1)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
+ rails-i18n (4.0.9)
+ i18n (~> 0.7)
+ railties (~> 4.0)
railties (4.2.8)
actionpack (= 4.2.8)
activesupport (= 4.2.8)
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
- rainbow (2.1.0)
- raindrops (0.17.0)
- rake (10.5.0)
+ rainbow (2.2.2)
+ rake
+ raindrops (0.18.0)
+ rake (12.0.0)
rblineprof (0.3.6)
debugger-ruby_core_source (~> 1.3)
+ rbnacl (3.4.0)
+ ffi
+ rbnacl-libsodium (1.0.11)
+ rbnacl (>= 3.0.1)
rdoc (4.2.2)
json (~> 1.4)
+ re2 (1.1.1)
recaptcha (3.0.0)
json
recursive-open-struct (1.0.0)
@@ -632,47 +722,56 @@ GEM
retriable (1.4.1)
rinku (2.0.0)
rotp (2.1.2)
- rouge (2.0.7)
+ rouge (2.1.0)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
rqrcode (>= 0.4.2)
- rspec (3.5.0)
- rspec-core (~> 3.5.0)
- rspec-expectations (~> 3.5.0)
- rspec-mocks (~> 3.5.0)
- rspec-core (3.5.0)
- rspec-support (~> 3.5.0)
- rspec-expectations (3.5.0)
+ rspec (3.6.0)
+ rspec-core (~> 3.6.0)
+ rspec-expectations (~> 3.6.0)
+ rspec-mocks (~> 3.6.0)
+ rspec-core (3.6.0)
+ rspec-support (~> 3.6.0)
+ rspec-expectations (3.6.0)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.5.0)
- rspec-mocks (3.5.0)
+ rspec-support (~> 3.6.0)
+ rspec-mocks (3.6.0)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.5.0)
- rspec-rails (3.5.0)
+ rspec-support (~> 3.6.0)
+ rspec-parameterized (0.4.0)
+ binding_of_caller
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-rails (3.6.0)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
- rspec-core (~> 3.5.0)
- rspec-expectations (~> 3.5.0)
- rspec-mocks (~> 3.5.0)
- rspec-support (~> 3.5.0)
+ rspec-core (~> 3.6.0)
+ rspec-expectations (~> 3.6.0)
+ rspec-mocks (~> 3.6.0)
+ rspec-support (~> 3.6.0)
rspec-retry (0.4.5)
rspec-core
rspec-set (0.1.3)
- rspec-support (3.5.0)
+ rspec-support (3.6.0)
rspec_profiling (0.0.5)
activerecord
pg
rails
sqlite3
- rubocop (0.47.1)
+ rubocop (0.49.1)
+ parallel (~> 1.10)
parser (>= 2.3.3.1, < 3.0)
powerpack (~> 0.1)
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
- rubocop-rspec (1.15.0)
+ rubocop-gitlab-security (0.0.6)
+ rubocop (>= 0.47.1)
+ rubocop-rspec (1.15.1)
rubocop (>= 0.42.0)
ruby-fogbugz (0.2.1)
crack (~> 0.4)
@@ -680,13 +779,14 @@ GEM
ruby-progressbar (1.8.1)
ruby-saml (1.4.1)
nokogiri (>= 1.5.10)
- ruby_parser (3.8.4)
+ ruby_parser (3.9.0)
sexp_processor (~> 4.1)
- rubyntlm (0.5.2)
+ rubyntlm (0.6.2)
rubypants (0.2.0)
rubyzip (1.2.1)
- rufus-scheduler (3.1.10)
- rugged (0.25.1.1)
+ rufus-scheduler (3.4.0)
+ et-orbi (~> 1.0)
+ rugged (0.26.0)
safe_yaml (1.0.4)
sanitize (2.1.0)
nokogiri (>= 1.4.4)
@@ -700,31 +800,30 @@ GEM
sawyer (0.8.1)
addressable (>= 2.3.5, < 2.6)
faraday (~> 0.8, < 1.0)
- scss_lint (0.47.1)
- rake (>= 0.9, < 11)
- sass (~> 3.4.15)
+ scss_lint (0.54.0)
+ rake (>= 0.9, < 13)
+ sass (~> 3.4.20)
securecompare (1.0.0)
seed-fu (2.3.6)
activerecord (>= 3.1)
activesupport (>= 3.1)
select2-rails (3.5.9.3)
thor (~> 0.14)
- sentry-raven (2.4.0)
+ sentry-raven (2.5.3)
faraday (>= 0.7.6, < 1.0)
settingslogic (2.0.9)
- sexp_processor (4.8.0)
+ sexp_processor (4.9.0)
sham_rack (1.3.6)
rack
- shoulda-matchers (2.8.0)
- activesupport (>= 3.0.0)
- sidekiq (5.0.0)
+ shoulda-matchers (3.1.2)
+ activesupport (>= 4.0.0)
+ sidekiq (5.0.4)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
redis (~> 3.3, >= 3.3.3)
- sidekiq-cron (0.4.4)
- redis-namespace (>= 1.5.2)
- rufus-scheduler (>= 2.0.24)
+ sidekiq-cron (0.6.0)
+ rufus-scheduler (>= 3.3.0)
sidekiq (>= 4.2.1)
sidekiq-limit_fetch (3.4.0)
sidekiq (>= 4)
@@ -779,6 +878,7 @@ GEM
temple (0.7.7)
test_after_commit (1.1.0)
activerecord (>= 3.2)
+ text (1.3.1)
thin (1.7.0)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
@@ -790,11 +890,10 @@ GEM
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)
nokogiri (~> 1.6.1)
- tzinfo (1.2.2)
+ tzinfo (1.2.3)
thread_safe (~> 0.1)
u2f (0.2.1)
uglifier (2.7.2)
@@ -804,7 +903,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.7.2)
- unicode-display_width (1.1.3)
+ unicode-display_width (1.3.0)
unicorn (5.1.0)
kgio (~> 2.6)
raindrops (~> 0.7)
@@ -812,6 +911,14 @@ GEM
get_process_mem (~> 0)
unicorn (>= 4, < 6)
uniform_notifier (1.10.0)
+ unparser (0.2.6)
+ abstract_type (~> 0.0.7)
+ adamantium (~> 0.2.0)
+ concord (~> 0.1.5)
+ diff-lcs (~> 1.3)
+ equalizer (~> 0.0.9)
+ parser (>= 2.3.1.2, < 2.5)
+ procto (~> 0.0.2)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
activerecord (>= 3.0)
@@ -825,7 +932,7 @@ GEM
vmstat (2.3.0)
warden (1.2.6)
rack (>= 1.0)
- webmock (1.24.6)
+ webmock (2.3.2)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
@@ -851,7 +958,6 @@ DEPENDENCIES
activerecord_sane_schema_dumper (= 0.2)
acts-as-taggable-on (~> 4.0)
addressable (~> 2.3.8)
- after_commit_queue (~> 1.3.0)
akismet (~> 2.0)
allocations (~> 1.0)
asana (~> 0.6.0)
@@ -861,27 +967,28 @@ DEPENDENCIES
awesome_print (~> 1.2.0)
babosa (~> 1.0.2)
base32 (~> 0.3.0)
+ bcrypt_pbkdf (~> 1.0)
benchmark-ips (~> 2.3.0)
better_errors (~> 2.1.0)
binding_of_caller (~> 0.7.2)
bootstrap-sass (~> 3.3.0)
+ bootstrap_form (~> 2.7.0)
brakeman (~> 3.6.0)
browser (~> 2.2)
bullet (~> 5.5.0)
bundler-audit (~> 0.5.0)
capybara (~> 2.6.2)
capybara-screenshot (~> 1.0.0)
- carrierwave (~> 0.11.0)
- charlock_holmes (~> 0.7.3)
+ carrierwave (~> 1.1)
+ charlock_holmes (~> 0.7.5)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6)
- coffee-rails (~> 4.1.0)
concurrent-ruby (~> 1.0.5)
connection_pool (~> 2.0)
creole (~> 0.5.0)
d3_rails (~> 3.5.0)
database_cleaner (~> 1.5.0)
- deckar01-task_list (= 1.0.6)
+ deckar01-task_list (= 2.0.0)
default_value_for (~> 3.0.0)
devise (~> 4.2)
devise-two-factor (~> 3.0.0)
@@ -892,32 +999,40 @@ DEPENDENCIES
email_reply_trimmer (~> 0.1)
email_spec (~> 1.6.0)
factory_girl_rails (~> 4.7.0)
- faraday (~> 0.11.0)
+ faraday (~> 0.12)
ffaker (~> 2.4)
flay (~> 2.8.0)
- fog-aws (~> 0.9)
- fog-core (~> 1.40)
+ flipper (~> 0.10.2)
+ flipper-active_record (~> 0.10.2)
+ fog-aliyun (~> 0.1.0)
+ fog-aws (~> 1.4)
+ fog-core (~> 1.44)
fog-google (~> 0.5)
fog-local (~> 0.3)
fog-openstack (~> 0.1)
fog-rackspace (~> 0.1.1)
font-awesome-rails (~> 4.7)
foreman (~> 0.78.0)
- fuubar (~> 2.0.0)
+ fuubar (~> 2.2.0)
gemnasium-gitlab-service (~> 0.2)
gemojione (~> 3.0)
- gitaly (~> 0.5.0)
+ gettext (~> 3.2.2)
+ gettext_i18n_rails (~> 1.8.0)
+ gettext_i18n_rails_js (~> 1.2.0)
+ gitaly (~> 0.27.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
- gitlab_omniauth-ldap (~> 1.2.1)
+ gitlab_omniauth-ldap (~> 2.0.4)
gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.4)
gon (~> 6.1.0)
google-api-client (~> 0.8.6)
- grape (~> 0.19.0)
+ gpgme
+ grape (~> 1.0)
grape-entity (~> 0.6.0)
- haml_lint (~> 0.21.0)
+ grape-route-helpers (~> 2.1.0)
+ haml_lint (~> 0.26.0)
hamlit (~> 2.6.1)
hashie-forbidden_attributes
health_check (~> 2.6.0)
@@ -931,26 +1046,28 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
json-schema (~> 2.6.2)
jwt (~> 1.5.6)
- kaminari (~> 0.17.0)
+ kaminari (~> 1.0)
knapsack (~> 1.11.0)
kubeclient (~> 2.2.0)
letter_opener_web (~> 1.3.0)
license_finder (~> 2.1.0)
licensee (~> 8.7.0)
+ lograge (~> 0.5)
loofah (~> 2.0.3)
mail_room (~> 0.9.1)
method_source (~> 0.8)
minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6)
- mysql2 (~> 0.3.16)
- net-ssh (~> 3.0.1)
+ mysql2 (~> 0.4.5)
+ net-ldap
+ net-ssh (~> 4.1.0)
nokogiri (~> 1.6.7, >= 1.6.7.2)
- oauth2 (~> 1.3.0)
+ oauth2 (~> 1.4)
octokit (~> 4.6.2)
oj (~> 2.17.4)
omniauth (~> 1.4.2)
omniauth-auth0 (~> 1.4.1)
- omniauth-authentiq (~> 0.3.0)
+ omniauth-authentiq (~> 0.3.1)
omniauth-azure-oauth2 (~> 0.0.6)
omniauth-cas3 (~> 1.1.2)
omniauth-facebook (~> 4.0.0)
@@ -964,10 +1081,20 @@ DEPENDENCIES
omniauth-twitter (~> 1.2.0)
omniauth_crowd (~> 2.2.0)
org-ruby (~> 0.9.12)
- paranoia (~> 2.2)
+ paranoia (~> 2.3.1)
+ peek (~> 1.0.1)
+ peek-gc (~> 0.0.2)
+ peek-host (~> 1.0.0)
+ peek-mysql2 (~> 1.1.0)
+ peek-performance_bar (~> 1.3.0)
+ peek-pg (~> 1.3.0)
+ peek-rblineprof (~> 0.2.0)
+ peek-redis (~> 1.2.0)
+ peek-sidekiq (~> 1.0.3)
pg (~> 0.18.2)
poltergeist (~> 1.9.0)
- premailer-rails (~> 1.9.0)
+ premailer-rails (~> 1.9.7)
+ prometheus-client-mmap (~> 0.7.0.beta11)
pry-byebug (~> 3.4.1)
pry-rails (~> 0.3.4)
rack-attack (~> 4.4.1)
@@ -976,9 +1103,14 @@ DEPENDENCIES
rack-proxy (~> 0.6.0)
rails (= 4.2.8)
rails-deprecated_sanitizer (~> 1.0.3)
- rainbow (~> 2.1.0)
+ rails-i18n (~> 4.0.9)
+ rainbow (~> 2.2)
+ raindrops (~> 0.18)
rblineprof (~> 0.3.6)
+ rbnacl (~> 3.2)
+ rbnacl-libsodium
rdoc (~> 4.2)
+ re2 (~> 1.1.1)
recaptcha (~> 3.0)
redcarpet (~> 3.4)
redis (~> 3.2)
@@ -988,27 +1120,30 @@ DEPENDENCIES
responders (~> 2.0)
rouge (~> 2.0)
rqrcode-rails3 (~> 0.1.7)
- rspec-rails (~> 3.5.0)
+ rspec-parameterized
+ rspec-rails (~> 3.6.0)
rspec-retry (~> 0.4.5)
rspec-set (~> 0.1.3)
rspec_profiling (~> 0.0.5)
- rubocop (~> 0.47.1)
- rubocop-rspec (~> 1.15.0)
+ rubocop (~> 0.49.1)
+ rubocop-gitlab-security (~> 0.0.6)
+ rubocop-rspec (~> 1.15.1)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2)
- rufus-scheduler (~> 3.1.10)
- rugged (~> 0.25.1.1)
+ ruby_parser (~> 3.8)
+ rufus-scheduler (~> 3.4)
+ rugged (~> 0.26.0)
sanitize (~> 2.0)
sass-rails (~> 5.0.6)
- scss_lint (~> 0.47.0)
+ scss_lint (~> 0.54.0)
seed-fu (~> 2.3.5)
select2-rails (~> 3.5.9)
- sentry-raven (~> 2.4.0)
+ sentry-raven (~> 2.5.3)
settingslogic (~> 2.0.9)
sham_rack (~> 1.3.6)
- shoulda-matchers (~> 2.8.0)
+ shoulda-matchers (~> 3.1.2)
sidekiq (~> 5.0)
- sidekiq-cron (~> 0.4.4)
+ sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4)
simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1)
@@ -1036,9 +1171,9 @@ DEPENDENCIES
version_sorter (~> 2.1.0)
virtus (~> 1.0.1)
vmstat (~> 2.3.0)
- webmock (~> 1.24.0)
+ webmock (~> 2.3.2)
webpack-rails (~> 0.9.10)
wikicloth (= 0.8.1)
BUNDLED WITH
- 1.14.6
+ 1.15.3
diff --git a/PROCESS.md b/PROCESS.md
index fac3c22e09f..538e4389e00 100644
--- a/PROCESS.md
+++ b/PROCESS.md
@@ -1,35 +1,53 @@
-# GitLab Contributing Process
+## GitLab Core Team & GitLab Inc. Contribution Process
+
+---
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process)
+- [Common actions](#common-actions)
+ - [Merge request coaching](#merge-request-coaching)
+- [Assigning issues](#assigning-issues)
+- [Be kind](#be-kind)
+- [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd)
+ - [Between the 1st and the 7th](#between-the-1st-and-the-7th)
+ - [On the 7th](#on-the-7th)
+ - [After the 7th](#after-the-7th)
+- [Release retrospective and kickoff](#release-retrospective-and-kickoff)
+ - [Retrospective](#retrospective)
+ - [Kickoff](#kickoff)
+- [Copy & paste responses](#copy--paste-responses)
+ - [Improperly formatted issue](#improperly-formatted-issue)
+ - [Issue report for old version](#issue-report-for-old-version)
+ - [Support requests and configuration questions](#support-requests-and-configuration-questions)
+ - [Code format](#code-format)
+ - [Issue fixed in newer version](#issue-fixed-in-newer-version)
+ - [Improperly formatted merge request](#improperly-formatted-merge-request)
+ - [Inactivity close of an issue](#inactivity-close-of-an-issue)
+ - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request)
+ - [Accepting merge requests](#accepting-merge-requests)
+ - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests)
+ - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+---
## Purpose of describing the contributing process
-Below we describe the contributing process to GitLab for two reasons. So that
-contributors know what to expect from maintainers (possible responses, friendly
-treatment, etc.). And so that maintainers know what to expect from contributors
-(use the latest version, ensure that the issue is addressed, friendly treatment,
-etc.).
+Below we describe the contributing process to GitLab for two reasons:
+
+1. Contributors know what to expect from maintainers (possible responses, friendly
+ treatment, etc.)
+1. Maintainers know what to expect from contributors (use the latest version,
+ ensure that the issue is addressed, friendly treatment, etc.).
- [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/)
## Common actions
-### Issue triaging
-
-Our issue triage policies are [described in our handbook]. You are very welcome
-to help the GitLab team triage issues. We also organize [issue bash events] once
-every quarter.
-
-The most important thing is making sure valid issues receive feedback from the
-development team. Therefore the priority is mentioning developers that can help
-on those issues. Please select someone with relevant experience from
-[GitLab team][team]. If there is nobody mentioned with that expertise
-look in the commit history for the affected files to find someone. Avoid
-mentioning the lead developer, this is the person that is least likely to give a
-timely response. If the involvement of the lead developer is needed the other
-core team members will mention this person.
-
-[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/
-[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
-
### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get
@@ -37,12 +55,6 @@ their contributions accepted by meeting our [Definition of done][done].
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
-## Workflow labels
-
-Labelling issues is described in the [GitLab Inc engineering workflow].
-
-[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues
-
## Assigning issues
If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover.
@@ -107,6 +119,12 @@ only be left until after the freeze if:
are aware of it.
* It is in the correct milestone, with the ~Deliverable label.
+If a merge request is not ready, but the developers and Product Manager
+responsible for the feature think it is essential that it is in the release,
+they can [ask for an exception](#asking-for-an-exception) in advance. This is
+preferable to merging something that we are not confident in, but should still
+be a rare case: most features can be allowed to slip a release.
+
All Community Edition merge requests from GitLab team members merged on the
freeze date (the 7th) should have a corresponding Enterprise Edition merge
request, even if there are no conflicts. This is to reduce the size of the
@@ -116,11 +134,30 @@ information, see
### After the 7th
-Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release)
-and security issues will be cherry-picked into the stable branch.
-Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch.
-These fixes will be shipped in the next RC for that release if it is before the 22nd.
-If the fixes are are completed on or after the 22nd, they will be shipped in a patch for that release.
+Once the stable branch is frozen, the only MRs that can be cherry-picked into
+the stable branch are:
+
+* Fixes for [regressions](#regressions)
+* Fixes for security issues
+* New or updated translations (as long as they do not touch application code)
+
+During the feature freeze all merge requests that are meant to go into the upcoming
+release should have the correct milestone assigned _and_ have the label
+~"Pick into Stable" set, so that release managers can find and pick them.
+Merge requests without a milestone and this label will
+not be merged into any stable branches.
+
+Fixes marked like this will be shipped in the next RC for that release. Once
+the final RC has been prepared ready for release on the 22nd, further fixes
+marked ~"Pick into Stable" will go into a patch for that release.
+
+If a merge request is to be picked into more than one release it will also need
+the ~"Pick into Backports" label set to remind the release manager to change
+the milestone after cherry-picking. As before, it should still have the
+~"Pick into Stable" label and the milestone of the highest release it will be
+picked into.
+
+### Asking for an exception
If you think a merge request should go into an RC or patch even though it does not meet these requirements,
you can ask for an exception to be made. Exceptions require sign-off from 3 people besides the developer:
@@ -140,11 +177,48 @@ When in doubt, we err on the side of _not_ cherry-picking.
For example, it is likely that an exception will be made for a trivial 1-5 line performance improvement
(e.g. adding a database index or adding `includes` to a query), but not for a new feature, no matter how relatively small or thoroughly tested.
-During the feature freeze all merge requests that are meant to go into the upcoming
-release should have the correct milestone assigned _and_ have the label
-~"Pick into Stable" set, so that release managers can find and pick them.
-Merge requests without a milestone and this label will
-not be merged into any stable branches.
+All MRs which have had exceptions granted must be merged by the 15th.
+
+### Regressions
+
+A regression for a particular monthly release is a bug that exists in that
+release, but wasn't present in the release before. This includes bugs in
+features that were only added in that monthly release. Every regression **must**
+have the milestone of the release it was introduced in - if a regression doesn't
+have a milestone, it might be 'just' a bug!
+
+For instance, if 10.5.0 adds a feature, and that feature doesn't work correctly,
+then this is a regression in 10.5. If 10.5.1 then fixes that, but 10.5.3 somehow
+reintroduces the bug, then this bug is still a regression in 10.5.
+
+Because GitLab.com runs release candidates of new releases, a regression can be
+reported in a release before its 'official' release date on the 22nd of the
+month. When we say 'the most recent monthly release', this can refer to either
+the version currently running on GitLab.com, or the most recent version
+available in the package repositories.
+
+## Release retrospective and kickoff
+
+### Retrospective
+
+After each release, we have a retrospective call where we discuss what went well,
+what went wrong, and what we can improve for the next release. The
+[retrospective notes] are public and you are invited to comment on them.
+If you're interested, you can even join the
+[retrospective call][retro-kickoff-call], on the first working day after the
+22nd at 6pm CET / 9am PST.
+
+### Kickoff
+
+Before working on the next release, we have a
+kickoff call to explain what we expect to ship in the next release. The
+[kickoff notes] are public and you are invited to comment on them.
+If you're interested, you can even join the [kickoff call][retro-kickoff-call],
+on the first working day after the 7th at 6pm CET / 9am PST..
+
+[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing
+[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing
+[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206
## Copy & paste responses
diff --git a/README.md b/README.md
index 59de828e1ac..9309922ae39 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
## Test coverage
- [![Ruby coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) Ruby
-- [![JavaScript coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=rake+karma)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-javascript) JavaScript
+- [![JavaScript coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=karma)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-javascript) JavaScript
## Canonical source
diff --git a/VERSION b/VERSION
index 5c906509f70..027fe8dd2cf 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-9.2.0-pre
+9.5.0-pre
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico
new file mode 100644
index 00000000000..4af3582b60d
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_created.ico b/app/assets/images/ci_favicons/dev/favicon_status_created.ico
new file mode 100644
index 00000000000..13639da2e8a
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_created.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico
new file mode 100644
index 00000000000..5f0e711b104
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico
new file mode 100644
index 00000000000..8b1168a1267
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico
new file mode 100644
index 00000000000..ed19b69e1c5
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico
new file mode 100644
index 00000000000..5dfefd4cc5a
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_running.ico b/app/assets/images/ci_favicons/dev/favicon_status_running.ico
new file mode 100644
index 00000000000..a41539c0e3e
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_running.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico
new file mode 100644
index 00000000000..2c1ae552b93
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_success.ico b/app/assets/images/ci_favicons/dev/favicon_status_success.ico
new file mode 100644
index 00000000000..70f0ca61eca
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_success.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico
new file mode 100644
index 00000000000..db289e03eb1
--- /dev/null
+++ b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.ico b/app/assets/images/ci_favicons/favicon_status_canceled.ico
index 5a19458f2a2..23adcffff50 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_canceled.ico
+++ b/app/assets/images/ci_favicons/favicon_status_canceled.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_created.ico b/app/assets/images/ci_favicons/favicon_status_created.ico
index 4dca9640cb3..f9d93b390d8 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_created.ico
+++ b/app/assets/images/ci_favicons/favicon_status_created.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_failed.ico b/app/assets/images/ci_favicons/favicon_status_failed.ico
index c961ff9a69b..28a22ebf724 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_failed.ico
+++ b/app/assets/images/ci_favicons/favicon_status_failed.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_manual.ico b/app/assets/images/ci_favicons/favicon_status_manual.ico
index 5fbbc99ea7c..dbbf1abf30c 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_manual.ico
+++ b/app/assets/images/ci_favicons/favicon_status_manual.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.ico b/app/assets/images/ci_favicons/favicon_status_not_found.ico
index 21afa9c72e6..49b9b232dd1 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_not_found.ico
+++ b/app/assets/images/ci_favicons/favicon_status_not_found.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_pending.ico b/app/assets/images/ci_favicons/favicon_status_pending.ico
index 8be32dab85a..05962f3f148 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_pending.ico
+++ b/app/assets/images/ci_favicons/favicon_status_pending.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_running.ico b/app/assets/images/ci_favicons/favicon_status_running.ico
index f328ff1a5ed..7fa3d4d48d4 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_running.ico
+++ b/app/assets/images/ci_favicons/favicon_status_running.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.ico b/app/assets/images/ci_favicons/favicon_status_skipped.ico
index b4394e1b4af..b0c26b62068 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_skipped.ico
+++ b/app/assets/images/ci_favicons/favicon_status_skipped.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_success.ico b/app/assets/images/ci_favicons/favicon_status_success.ico
index 4f436c95242..b150960b5be 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_success.ico
+++ b/app/assets/images/ci_favicons/favicon_status_success.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_warning.ico b/app/assets/images/ci_favicons/favicon_status_warning.ico
index 805cc20cdec..7e71d71684d 100755..100644
--- a/app/assets/images/ci_favicons/favicon_status_warning.ico
+++ b/app/assets/images/ci_favicons/favicon_status_warning.ico
Binary files differ
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg
new file mode 100644
index 00000000000..8886092ed82
--- /dev/null
+++ b/app/assets/images/i2p-step.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120">
+ <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+</svg>
diff --git a/app/assets/images/new_nav.png b/app/assets/images/new_nav.png
new file mode 100644
index 00000000000..f98ca15d787
--- /dev/null
+++ b/app/assets/images/new_nav.png
Binary files differ
diff --git a/app/assets/images/new_repo.png b/app/assets/images/new_repo.png
new file mode 100644
index 00000000000..ed3af06ab1d
--- /dev/null
+++ b/app/assets/images/new_repo.png
Binary files differ
diff --git a/app/assets/images/old_nav.png b/app/assets/images/old_nav.png
new file mode 100644
index 00000000000..23fae7aa19e
--- /dev/null
+++ b/app/assets/images/old_nav.png
Binary files differ
diff --git a/app/assets/images/old_repo.png b/app/assets/images/old_repo.png
new file mode 100644
index 00000000000..c3c3b791ad9
--- /dev/null
+++ b/app/assets/images/old_repo.png
Binary files differ
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index d816df831eb..5d060165f4b 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -5,7 +5,8 @@ import Cookies from 'js-cookie';
class Activities {
constructor() {
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
+
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
this.toggleFilter(e.currentTarget);
@@ -19,7 +20,7 @@ class Activities {
reloadActivities() {
$('.content_list').html('');
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/ajax_loading_spinner.js b/app/assets/javascripts/ajax_loading_spinner.js
index 38a8317dbd7..8f5e2e545ec 100644
--- a/app/assets/javascripts/ajax_loading_spinner.js
+++ b/app/assets/javascripts/ajax_loading_spinner.js
@@ -10,7 +10,7 @@ class AjaxLoadingSpinner {
e.target.setAttribute('disabled', '');
const iconElement = e.target.querySelector('i');
// get first fa- icon
- const originalIcon = iconElement.className.match(/(fa-)([^\s]+)/g).first();
+ const originalIcon = iconElement.className.match(/(fa-)([^\s]+)/g)[0];
iconElement.dataset.icon = originalIcon;
AjaxLoadingSpinner.toggleLoadingIcon(iconElement);
$(e.target).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index e5f36c84987..56f91e95bb9 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,148 +1,190 @@
-/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */
-
-var Api = {
- groupsPath: "/api/:version/groups.json",
- groupPath: "/api/:version/groups/:id.json",
- namespacesPath: "/api/:version/namespaces.json",
- groupProjectsPath: "/api/:version/groups/:id/projects.json",
- projectsPath: "/api/:version/projects.json?simple=true",
- labelsPath: "/:namespace_path/:project_path/labels",
- licensePath: "/api/:version/templates/licenses/:key",
- gitignorePath: "/api/:version/templates/gitignores/:key",
- gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key",
- dockerfilePath: "/api/:version/templates/dockerfiles/:key",
- issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
- group: function(group_id, callback) {
- var url = Api.buildUrl(Api.groupPath)
- .replace(':id', group_id);
+import $ from 'jquery';
+
+const Api = {
+ groupsPath: '/api/:version/groups.json',
+ groupPath: '/api/:version/groups/:id.json',
+ namespacesPath: '/api/:version/namespaces.json',
+ groupProjectsPath: '/api/:version/groups/:id/projects.json',
+ projectsPath: '/api/:version/projects.json?simple=true',
+ labelsPath: '/:namespace_path/:project_path/labels',
+ licensePath: '/api/:version/templates/licenses/:key',
+ gitignorePath: '/api/:version/templates/gitignores/:key',
+ gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
+ dockerfilePath: '/api/:version/templates/dockerfiles/:key',
+ issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
+ usersPath: '/api/:version/users.json',
+ commitPath: '/api/:version/projects/:id/repository/commits',
+
+ group(groupId, callback) {
+ const url = Api.buildUrl(Api.groupPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(group) {
- return callback(group);
- });
+ url,
+ dataType: 'json',
+ })
+ .done(group => callback(group));
},
+
// Return groups list. Filtered by query
- groups: function(query, options, callback) {
- var url = Api.buildUrl(Api.groupsPath);
+ groups(query, options, callback) {
+ const url = Api.buildUrl(Api.groupsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
- per_page: 20
+ per_page: 20,
}, options),
- dataType: "json"
- }).done(function(groups) {
- return callback(groups);
- });
+ dataType: 'json',
+ })
+ .done(groups => callback(groups));
},
+
// Return namespaces list. Filtered by query
- namespaces: function(query, callback) {
- var url = Api.buildUrl(Api.namespacesPath);
+ namespaces(query, callback) {
+ const url = Api.buildUrl(Api.namespacesPath);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(namespaces) {
- return callback(namespaces);
- });
+ dataType: 'json',
+ }).done(namespaces => callback(namespaces));
},
+
// Return projects list. Filtered by query
- projects: function(query, options, callback) {
- var url = Api.buildUrl(Api.projectsPath);
+ projects(query, options, callback) {
+ const url = Api.buildUrl(Api.projectsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
per_page: 20,
- membership: true
+ membership: true,
}, options),
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
},
- newLabel: function(namespace_path, project_path, data, callback) {
- var url = Api.buildUrl(Api.labelsPath)
- .replace(':namespace_path', namespace_path)
- .replace(':project_path', project_path);
+
+ newLabel(namespacePath, projectPath, data, callback) {
+ const url = Api.buildUrl(Api.labelsPath)
+ .replace(':namespace_path', namespacePath)
+ .replace(':project_path', projectPath);
return $.ajax({
- url: url,
- type: "POST",
- data: { 'label': data },
- dataType: "json"
- }).done(function(label) {
- return callback(label);
- }).error(function(message) {
- return callback(message.responseJSON);
- });
+ url,
+ type: 'POST',
+ data: { label: data },
+ dataType: 'json',
+ })
+ .done(label => callback(label))
+ .fail(message => callback(message.responseJSON));
},
+
// Return group projects list. Filtered by query
- groupProjects: function(group_id, query, callback) {
- var url = Api.buildUrl(Api.groupProjectsPath)
- .replace(':id', group_id);
+ groupProjects(groupId, query, callback) {
+ const url = Api.buildUrl(Api.groupProjectsPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
+ },
+
+ commitMultiple(id, data, callback) {
+ const url = Api.buildUrl(Api.commitPath)
+ .replace(':id', id);
+ return $.ajax({
+ url,
+ type: 'POST',
+ contentType: 'application/json; charset=utf-8',
+ data: JSON.stringify(data),
+ dataType: 'json',
+ })
+ .done(commitData => callback(commitData))
+ .fail(message => callback(message.responseJSON));
},
+
// Return text for a specific license
- licenseText: function(key, data, callback) {
- var url = Api.buildUrl(Api.licensePath)
+ licenseText(key, data, callback) {
+ const url = Api.buildUrl(Api.licensePath)
.replace(':key', key);
return $.ajax({
- url: url,
- data: data
- }).done(function(license) {
- return callback(license);
- });
+ url,
+ data,
+ })
+ .done(license => callback(license));
},
- gitignoreText: function(key, callback) {
- var url = Api.buildUrl(Api.gitignorePath)
+
+ gitignoreText(key, callback) {
+ const url = Api.buildUrl(Api.gitignorePath)
.replace(':key', key);
- return $.get(url, function(gitignore) {
- return callback(gitignore);
- });
+ return $.get(url, gitignore => callback(gitignore));
},
- gitlabCiYml: function(key, callback) {
- var url = Api.buildUrl(Api.gitlabCiYmlPath)
+
+ gitlabCiYml(key, callback) {
+ const url = Api.buildUrl(Api.gitlabCiYmlPath)
.replace(':key', key);
- return $.get(url, function(file) {
- return callback(file);
- });
+ return $.get(url, file => callback(file));
},
- dockerfileYml: function(key, callback) {
- var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
+
+ dockerfileYml(key, callback) {
+ const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
$.get(url, callback);
},
- issueTemplate: function(namespacePath, projectPath, key, type, callback) {
- var url = Api.buildUrl(Api.issuableTemplatePath)
+
+ issueTemplate(namespacePath, projectPath, key, type, callback) {
+ const url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key)
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
$.ajax({
- url: url,
- dataType: 'json'
- }).done(function(file) {
- callback(null, file);
- }).error(callback);
+ url,
+ dataType: 'json',
+ })
+ .done(file => callback(null, file))
+ .fail(callback);
},
- buildUrl: function(url) {
+
+ users(query, options) {
+ const url = Api.buildUrl(this.usersPath);
+ return Api.wrapAjaxCall({
+ url,
+ data: Object.assign({
+ search: query,
+ per_page: 20,
+ }, options),
+ dataType: 'json',
+ });
+ },
+
+ buildUrl(url) {
+ let urlRoot = '';
if (gon.relative_url_root != null) {
- url = gon.relative_url_root + url;
+ urlRoot = gon.relative_url_root;
}
- return url.replace(':version', gon.api_version);
- }
+ return urlRoot + url.replace(':version', gon.api_version);
+ },
+
+ wrapAjaxCall(options) {
+ return new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.ajax(options) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${options.url}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ });
+ },
};
-window.Api = Api;
+export default Api;
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 8630b18a73f..cfab6c40b34 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,8 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */
+import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
function Autosave(field, key) {
this.field = field;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
if (key.join != null) {
key = key.join("/");
}
@@ -17,16 +20,12 @@ window.Autosave = (function() {
}
Autosave.prototype.restore = function() {
- var e, text;
- if (window.localStorage == null) {
- return;
- }
- try {
- text = window.localStorage.getItem(this.key);
- } catch (error) {
- e = error;
- return;
- }
+ var text;
+
+ if (!this.isLocalStorageAvailable) return;
+
+ text = window.localStorage.getItem(this.key);
+
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
@@ -35,27 +34,22 @@ window.Autosave = (function() {
Autosave.prototype.save = function() {
var text;
- if (window.localStorage == null) {
- return;
- }
text = this.field.val();
- if ((text != null ? text.length : void 0) > 0) {
- try {
- return window.localStorage.setItem(this.key, text);
- } catch (error) {}
- } else {
- return this.reset();
+
+ if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
+ return window.localStorage.setItem(this.key, text);
}
+
+ return this.reset();
};
Autosave.prototype.reset = function() {
- if (window.localStorage == null) {
- return;
- }
- try {
- return window.localStorage.removeItem(this.key);
- } catch (error) {}
+ if (!this.isLocalStorageAvailable) return;
+
+ return window.localStorage.removeItem(this.key);
};
return Autosave;
})();
+
+export default window.Autosave;
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index adb45b0606d..097f79a250a 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,12 +1,8 @@
+/* eslint-disable class-methods-use-this */
/* global Flash */
-
+import _ from 'underscore';
import Cookies from 'js-cookie';
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { glEmojiTag } from './behaviors/gl_emoji';
-import isEmojiNameValid from './behaviors/gl_emoji/is_emoji_name_valid';
-
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
const requestAnimationFrame = window.requestAnimationFrame ||
@@ -16,8 +12,6 @@ const requestAnimationFrame = window.requestAnimationFrame ||
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
-let categoryMap = null;
-
const categoryLabelMap = {
activity: 'Activity',
people: 'People',
@@ -29,186 +23,144 @@ const categoryLabelMap = {
flags: 'Flags',
};
-function buildCategoryMap() {
- return Object.keys(emojiMap).reduce((currentCategoryMap, emojiNameKey) => {
- const emojiInfo = emojiMap[emojiNameKey];
- if (currentCategoryMap[emojiInfo.category]) {
- currentCategoryMap[emojiInfo.category].push(emojiNameKey);
- }
-
- return currentCategoryMap;
- }, {
- activity: [],
- people: [],
- nature: [],
- food: [],
- travel: [],
- objects: [],
- symbols: [],
- flags: [],
- });
-}
-
-function renderCategory(name, emojiList, opts = {}) {
- return `
- <h5 class="emoji-menu-title">
- ${name}
- </h5>
- <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">
- ${glEmojiTag(emojiName, {
- sprite: true,
- })}
- </button>
- </li>
- `).join('\n')}
- </ul>
- `;
-}
+class AwardsHandler {
+ constructor(emoji) {
+ this.emoji = emoji;
+ this.eventListeners = [];
+ // If the user shows intent let's pre-build the menu
+ this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
+ const $menu = $('.emoji-menu');
+ if ($menu.length === 0) {
+ requestAnimationFrame(() => {
+ this.createEmojiMenu();
+ });
+ }
+ });
+ this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ this.showEmojiMenu($(e.currentTarget));
+ });
-function AwardsHandler() {
- this.eventListeners = [];
- this.aliases = emojiAliases;
- // If the user shows intent let's pre-build the menu
- this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
- const $menu = $('.emoji-menu');
- if ($menu.length === 0) {
- requestAnimationFrame(() => {
- this.createEmojiMenu();
- });
- }
- // Prebuild the categoryMap
- categoryMap = categoryMap || buildCategoryMap();
- });
- this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
- e.stopPropagation();
- e.preventDefault();
- this.showEmojiMenu($(e.currentTarget));
- });
-
- this.registerEventListener('on', $('html'), 'click', (e) => {
- const $target = $(e.target);
- if (!$target.closest('.emoji-menu-content').length) {
- $('.js-awards-block.current').removeClass('current');
- }
- if (!$target.closest('.emoji-menu').length) {
- if ($('.emoji-menu').is(':visible')) {
- $('.js-add-award.is-active').removeClass('is-active');
- $('.emoji-menu').removeClass('is-visible');
+ this.registerEventListener('on', $('html'), 'click', (e) => {
+ const $target = $(e.target);
+ if (!$target.closest('.emoji-menu-content').length) {
+ $('.js-awards-block.current').removeClass('current');
}
- }
- });
- this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- const $glEmojiElement = $target.find('gl-emoji');
- const $spriteIconElement = $target.find('.icon');
- const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
-
- $target.closest('.js-awards-block').addClass('current');
- this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
- });
-}
+ if (!$target.closest('.emoji-menu').length) {
+ if ($('.emoji-menu').is(':visible')) {
+ $('.js-add-award.is-active').removeClass('is-active');
+ $('.emoji-menu').removeClass('is-visible');
+ }
+ }
+ });
+ this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
+ e.preventDefault();
+ const $target = $(e.currentTarget);
+ const $glEmojiElement = $target.find('gl-emoji');
+ const $spriteIconElement = $target.find('.icon');
+ const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
+
+ $target.closest('.js-awards-block').addClass('current');
+ this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
+ });
+ }
-AwardsHandler.prototype.registerEventListener = function registerEventListener(method = 'on', element, ...args) {
- element[method].call(element, ...args);
- this.eventListeners.push({
- element,
- args,
- });
-};
+ registerEventListener(method = 'on', element, ...args) {
+ element[method].call(element, ...args);
+ this.eventListeners.push({
+ element,
+ args,
+ });
+ }
-AwardsHandler.prototype.showEmojiMenu = function showEmojiMenu($addBtn) {
- if ($addBtn.hasClass('js-note-emoji')) {
- $addBtn.closest('.note').find('.js-awards-block').addClass('current');
- } else {
- $addBtn.closest('.js-awards-block').addClass('current');
- }
-
- const $menu = $('.emoji-menu');
- const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
- const $userAuthored = this.isUserAuthored($addBtn);
- if ($menu.length) {
- if ($menu.is('.is-visible')) {
- $addBtn.removeClass('is-active');
- $menu.removeClass('is-visible');
- $('.js-emoji-menu-search').blur();
+ showEmojiMenu($addBtn) {
+ if ($addBtn.hasClass('js-note-emoji')) {
+ $addBtn.closest('.note').find('.js-awards-block').addClass('current');
} else {
- $addBtn.addClass('is-active');
- this.positionMenu($menu, $addBtn);
- $menu.addClass('is-visible');
- $('.js-emoji-menu-search').focus();
+ $addBtn.closest('.js-awards-block').addClass('current');
}
- } else {
- $addBtn.addClass('is-loading is-active');
- this.createEmojiMenu(() => {
- const $createdMenu = $('.emoji-menu');
- $addBtn.removeClass('is-loading');
- this.positionMenu($createdMenu, $addBtn);
- return setTimeout(() => {
- $createdMenu.addClass('is-visible');
- $('.js-emoji-menu-search').focus();
- }, 200);
- });
- }
- $thumbsBtn.toggleClass('disabled', $userAuthored);
-};
+ const $menu = $('.emoji-menu');
+ const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
+ const $userAuthored = this.isUserAuthored($addBtn);
+ if ($menu.length) {
+ if ($menu.is('.is-visible')) {
+ $addBtn.removeClass('is-active');
+ $menu.removeClass('is-visible');
+ $('.js-emoji-menu-search').blur();
+ } else {
+ $addBtn.addClass('is-active');
+ this.positionMenu($menu, $addBtn);
+ $menu.addClass('is-visible');
+ $('.js-emoji-menu-search').focus();
+ }
+ } else {
+ $addBtn.addClass('is-loading is-active');
+ this.createEmojiMenu(() => {
+ const $createdMenu = $('.emoji-menu');
+ $addBtn.removeClass('is-loading');
+ this.positionMenu($createdMenu, $addBtn);
+ return setTimeout(() => {
+ $createdMenu.addClass('is-visible');
+ $('.js-emoji-menu-search').focus();
+ }, 200);
+ });
+ }
-// Create the emoji menu with the first category of emojis.
-// Then render the remaining categories of emojis one by one to avoid jank.
-AwardsHandler.prototype.createEmojiMenu = function createEmojiMenu(callback) {
- if (this.isCreatingEmojiMenu) {
- return;
- }
- this.isCreatingEmojiMenu = true;
-
- // Render the first category
- categoryMap = categoryMap || buildCategoryMap();
- const categoryNameKey = Object.keys(categoryMap)[0];
- const emojisInCategory = categoryMap[categoryNameKey];
- const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
-
- // Render the frequently used
- const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
- let frequentlyUsedCatgegory = '';
- if (frequentlyUsedEmojis.length > 0) {
- frequentlyUsedCatgegory = renderCategory('Frequently used', frequentlyUsedEmojis, {
- menuListClass: 'frequent-emojis',
- });
+ $thumbsBtn.toggleClass('disabled', $userAuthored);
}
- const emojiMenuMarkup = `
- <div class="emoji-menu">
- <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
+ // Create the emoji menu with the first category of emojis.
+ // Then render the remaining categories of emojis one by one to avoid jank.
+ createEmojiMenu(callback) {
+ if (this.isCreatingEmojiMenu) {
+ return;
+ }
+ this.isCreatingEmojiMenu = true;
+
+ // Render the first category
+ const categoryMap = this.emoji.getEmojiCategoryMap();
+ const categoryNameKey = Object.keys(categoryMap)[0];
+ const emojisInCategory = categoryMap[categoryNameKey];
+ const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
+
+ // Render the frequently used
+ const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
+ let frequentlyUsedCatgegory = '';
+ if (frequentlyUsedEmojis.length > 0) {
+ frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, {
+ menuListClass: 'frequent-emojis',
+ });
+ }
+
+ const emojiMenuMarkup = `
+ <div class="emoji-menu">
+ <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
- <div class="emoji-menu-content">
- ${frequentlyUsedCatgegory}
- ${firstCategory}
+ <div class="emoji-menu-content">
+ ${frequentlyUsedCatgegory}
+ ${firstCategory}
+ </div>
</div>
- </div>
- `;
+ `;
- document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
+ document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
- this.addRemainingEmojiMenuCategories();
- this.setupSearch();
- if (callback) {
- callback();
+ this.addRemainingEmojiMenuCategories();
+ this.setupSearch();
+ if (callback) {
+ callback();
+ }
}
-};
-AwardsHandler
- .prototype
- .addRemainingEmojiMenuCategories = function addRemainingEmojiMenuCategories() {
+ addRemainingEmojiMenuCategories() {
if (this.isAddingRemainingEmojiMenuCategories) {
return;
}
this.isAddingRemainingEmojiMenuCategories = true;
- categoryMap = categoryMap || buildCategoryMap();
+ const categoryMap = this.emoji.getEmojiCategoryMap();
// Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive
@@ -220,7 +172,7 @@ AwardsHandler
promiseChain.then(() =>
new Promise((resolve) => {
const emojisInCategory = categoryMap[categoryNameKey];
- const categoryMarkup = renderCategory(
+ const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
@@ -243,179 +195,186 @@ AwardsHandler
emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>');
throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
});
- };
-
-AwardsHandler.prototype.positionMenu = function positionMenu($menu, $addBtn) {
- const position = $addBtn.data('position');
- // The menu could potentially be off-screen or in a hidden overflow element
- // So we position the element absolute in the body
- const css = {
- top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
- };
- if (position === 'right') {
- css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
- $menu.addClass('is-aligned-right');
- } else {
- css.left = `${$addBtn.offset().left}px`;
- $menu.removeClass('is-aligned-right');
- }
- return $menu.css(css);
-};
+ }
-AwardsHandler.prototype.addAward = function addAward(
- votesBlock,
- awardUrl,
- emoji,
- checkMutuality,
- callback,
-) {
- const normalizedEmoji = this.normalizeEmojiName(emoji);
- const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
- this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
- this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
- return typeof callback === 'function' ? callback() : undefined;
- });
- $('.emoji-menu').removeClass('is-visible');
- $('.js-add-award.is-active').removeClass('is-active');
-};
+ renderCategory(name, emojiList, opts = {}) {
+ return `
+ <h5 class="emoji-menu-title">
+ ${name}
+ </h5>
+ <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">
+ ${this.emoji.glEmojiTag(emojiName, {
+ sprite: true,
+ })}
+ </button>
+ </li>
+ `).join('\n')}
+ </ul>
+ `;
+ }
-AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
- votesBlock,
- emoji,
- checkForMutuality,
-) {
- if (checkForMutuality || checkForMutuality === null) {
- this.checkMutuality(votesBlock, emoji);
- }
- this.addEmojiToFrequentlyUsedList(emoji);
- const normalizedEmoji = this.normalizeEmojiName(emoji);
- const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
- if ($emojiButton.length > 0) {
- if (this.isActive($emojiButton)) {
- this.decrementCounter($emojiButton, normalizedEmoji);
+ positionMenu($menu, $addBtn) {
+ const position = $addBtn.data('position');
+ // The menu could potentially be off-screen or in a hidden overflow element
+ // So we position the element absolute in the body
+ const css = {
+ top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
+ };
+ if (position === 'right') {
+ css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
+ $menu.addClass('is-aligned-right');
} else {
- const counter = $emojiButton.find('.js-counter');
- counter.text(parseInt(counter.text(), 10) + 1);
- $emojiButton.addClass('active');
- this.addYouToUserList(votesBlock, normalizedEmoji);
- this.animateEmoji($emojiButton);
+ css.left = `${$addBtn.offset().left}px`;
+ $menu.removeClass('is-aligned-right');
}
- } else {
- votesBlock.removeClass('hidden');
- this.createEmoji(votesBlock, normalizedEmoji);
+ return $menu.css(css);
}
-};
-AwardsHandler.prototype.getVotesBlock = function getVotesBlock() {
- const currentBlock = $('.js-awards-block.current');
- let resultantVotesBlock = currentBlock;
- if (currentBlock.length === 0) {
- resultantVotesBlock = $('.js-awards-block').eq(0);
+ addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
+ const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
+ const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+ this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
+ this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
+ return typeof callback === 'function' ? callback() : undefined;
+ });
+ $('.emoji-menu').removeClass('is-visible');
+ $('.js-add-award.is-active').removeClass('is-active');
}
- return resultantVotesBlock;
-};
+ addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
+ if (checkForMutuality || checkForMutuality === null) {
+ this.checkMutuality(votesBlock, emoji);
+ }
+ this.addEmojiToFrequentlyUsedList(emoji);
+ const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
+ const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+ if ($emojiButton.length > 0) {
+ if (this.isActive($emojiButton)) {
+ this.decrementCounter($emojiButton, normalizedEmoji);
+ } else {
+ const counter = $emojiButton.find('.js-counter');
+ counter.text(parseInt(counter.text(), 10) + 1);
+ $emojiButton.addClass('active');
+ this.addYouToUserList(votesBlock, normalizedEmoji);
+ this.animateEmoji($emojiButton);
+ }
+ } else {
+ votesBlock.removeClass('hidden');
+ this.createEmoji(votesBlock, normalizedEmoji);
+ }
+ }
-AwardsHandler.prototype.getAwardUrl = function getAwardUrl() {
- return this.getVotesBlock().data('award-url');
-};
+ getVotesBlock() {
+ const currentBlock = $('.js-awards-block.current');
+ let resultantVotesBlock = currentBlock;
+ if (currentBlock.length === 0) {
+ resultantVotesBlock = $('.js-awards-block').eq(0);
+ }
-AwardsHandler.prototype.checkMutuality = function checkMutuality(votesBlock, emoji) {
- const awardUrl = this.getAwardUrl();
- if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
- const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
- const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent();
- const isAlreadyVoted = $emojiButton.hasClass('active');
- if (isAlreadyVoted) {
- this.addAward(votesBlock, awardUrl, mutualVote, false);
+ return resultantVotesBlock;
+ }
+
+ getAwardUrl() {
+ return this.getVotesBlock().data('award-url');
+ }
+
+ checkMutuality(votesBlock, emoji) {
+ const awardUrl = this.getAwardUrl();
+ if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
+ const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
+ const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent();
+ const isAlreadyVoted = $emojiButton.hasClass('active');
+ if (isAlreadyVoted) {
+ this.addAward(votesBlock, awardUrl, mutualVote, false);
+ }
}
}
-};
-AwardsHandler.prototype.isActive = function isActive($emojiButton) {
- return $emojiButton.hasClass('active');
-};
+ isActive($emojiButton) {
+ return $emojiButton.hasClass('active');
+ }
-AwardsHandler.prototype.isUserAuthored = function isUserAuthored($button) {
- return $button.hasClass('js-user-authored');
-};
+ isUserAuthored($button) {
+ return $button.hasClass('js-user-authored');
+ }
-AwardsHandler.prototype.decrementCounter = function decrementCounter($emojiButton, emoji) {
- const counter = $('.js-counter', $emojiButton);
- const counterNumber = parseInt(counter.text(), 10);
- if (counterNumber > 1) {
- counter.text(counterNumber - 1);
- this.removeYouFromUserList($emojiButton);
- } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
- $emojiButton.tooltip('destroy');
- counter.text('0');
- this.removeYouFromUserList($emojiButton);
- if ($emojiButton.parents('.note').length) {
+ decrementCounter($emojiButton, emoji) {
+ const counter = $('.js-counter', $emojiButton);
+ const counterNumber = parseInt(counter.text(), 10);
+ if (counterNumber > 1) {
+ counter.text(counterNumber - 1);
+ this.removeYouFromUserList($emojiButton);
+ } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
+ $emojiButton.tooltip('destroy');
+ counter.text('0');
+ this.removeYouFromUserList($emojiButton);
+ if ($emojiButton.parents('.note').length) {
+ this.removeEmoji($emojiButton);
+ }
+ } else {
this.removeEmoji($emojiButton);
}
- } else {
- this.removeEmoji($emojiButton);
+ return $emojiButton.removeClass('active');
}
- return $emojiButton.removeClass('active');
-};
-AwardsHandler.prototype.removeEmoji = function removeEmoji($emojiButton) {
- $emojiButton.tooltip('destroy');
- $emojiButton.remove();
- const $votesBlock = this.getVotesBlock();
- if ($votesBlock.find('.js-emoji-btn').length === 0) {
- $votesBlock.addClass('hidden');
+ removeEmoji($emojiButton) {
+ $emojiButton.tooltip('destroy');
+ $emojiButton.remove();
+ const $votesBlock = this.getVotesBlock();
+ if ($votesBlock.find('.js-emoji-btn').length === 0) {
+ $votesBlock.addClass('hidden');
+ }
}
-};
-AwardsHandler.prototype.getAwardTooltip = function getAwardTooltip($awardBlock) {
- return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
-};
-
-AwardsHandler.prototype.toSentence = function toSentence(list) {
- let sentence;
- if (list.length <= 2) {
- sentence = list.join(' and ');
- } else {
- sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
+ getAwardTooltip($awardBlock) {
+ return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
}
- return sentence;
-};
+ toSentence(list) {
+ let sentence;
+ if (list.length <= 2) {
+ sentence = list.join(' and ');
+ } else {
+ sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
+ }
-AwardsHandler.prototype.removeYouFromUserList = function removeYouFromUserList($emojiButton) {
- const awardBlock = $emojiButton;
- const originalTitle = this.getAwardTooltip(awardBlock);
- const authors = originalTitle.split(FROM_SENTENCE_REGEX);
- authors.splice(authors.indexOf('You'), 1);
- return awardBlock
- .closest('.js-emoji-btn')
- .removeData('title')
- .removeAttr('data-title')
- .removeAttr('data-original-title')
- .attr('title', this.toSentence(authors))
- .tooltip('fixTitle');
-};
+ return sentence;
+ }
-AwardsHandler.prototype.addYouToUserList = function addYouToUserList(votesBlock, emoji) {
- const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
- const origTitle = this.getAwardTooltip(awardBlock);
- let users = [];
- if (origTitle) {
- users = origTitle.trim().split(FROM_SENTENCE_REGEX);
- }
- users.unshift('You');
- return awardBlock
- .attr('title', this.toSentence(users))
- .tooltip('fixTitle');
-};
+ removeYouFromUserList($emojiButton) {
+ const awardBlock = $emojiButton;
+ const originalTitle = this.getAwardTooltip(awardBlock);
+ const authors = originalTitle.split(FROM_SENTENCE_REGEX);
+ authors.splice(authors.indexOf('You'), 1);
+ return awardBlock
+ .closest('.js-emoji-btn')
+ .removeData('title')
+ .removeAttr('data-title')
+ .removeAttr('data-original-title')
+ .attr('title', this.toSentence(authors))
+ .tooltip('fixTitle');
+ }
+
+ addYouToUserList(votesBlock, emoji) {
+ const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
+ const origTitle = this.getAwardTooltip(awardBlock);
+ let users = [];
+ if (origTitle) {
+ users = origTitle.trim().split(FROM_SENTENCE_REGEX);
+ }
+ users.unshift('You');
+ return awardBlock
+ .attr('title', this.toSentence(users))
+ .tooltip('fixTitle');
+ }
-AwardsHandler
- .prototype
- .createAwardButtonForVotesBlock = function createAwardButtonForVotesBlock(votesBlock, emojiName) {
+ createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
- ${glEmojiTag(emojiName)}
+ ${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
`;
@@ -424,144 +383,136 @@ AwardsHandler
this.animateEmoji($emojiButton);
$('.award-control').tooltip();
votesBlock.removeClass('current');
- };
-
-AwardsHandler.prototype.animateEmoji = function animateEmoji($emoji) {
- const className = 'pulse animated once short';
- $emoji.addClass(className);
+ }
- this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
- $(e.currentTarget).removeClass(className);
- });
-};
+ animateEmoji($emoji) {
+ const className = 'pulse animated once short';
+ $emoji.addClass(className);
-AwardsHandler.prototype.createEmoji = function createEmoji(votesBlock, emoji) {
- if ($('.emoji-menu').length) {
- this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
+ $(e.currentTarget).removeClass(className);
+ });
}
- this.createEmojiMenu(() => {
- this.createAwardButtonForVotesBlock(votesBlock, emoji);
- });
-};
-AwardsHandler.prototype.postEmoji = function postEmoji($emojiButton, awardUrl, emoji, callback) {
- if (this.isUserAuthored($emojiButton)) {
- this.userAuthored($emojiButton);
- } else {
- $.post(awardUrl, {
- name: emoji,
- }, (data) => {
- if (data.ok) {
- callback();
- }
- }).fail(() => new Flash('Something went wrong on our end.'));
+ createEmoji(votesBlock, emoji) {
+ if ($('.emoji-menu').length) {
+ this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ }
+ this.createEmojiMenu(() => {
+ this.createAwardButtonForVotesBlock(votesBlock, emoji);
+ });
}
-};
-AwardsHandler.prototype.findEmojiIcon = function findEmojiIcon(votesBlock, emoji) {
- return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
-};
+ postEmoji($emojiButton, awardUrl, emoji, callback) {
+ if (this.isUserAuthored($emojiButton)) {
+ this.userAuthored($emojiButton);
+ } else {
+ $.post(awardUrl, {
+ name: emoji,
+ }, (data) => {
+ if (data.ok) {
+ callback();
+ }
+ }).fail(() => new Flash('Something went wrong on our end.'));
+ }
+ }
-AwardsHandler.prototype.userAuthored = function userAuthored($emojiButton) {
- const oldTitle = this.getAwardTooltip($emojiButton);
- const newTitle = 'You cannot vote on your own issue, MR and note';
- gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show');
- // Restore tooltip back to award list
- return setTimeout(() => {
- $emojiButton.tooltip('hide');
- gl.utils.updateTooltipTitle($emojiButton, oldTitle);
- }, 2800);
-};
+ findEmojiIcon(votesBlock, emoji) {
+ return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
+ }
-AwardsHandler.prototype.scrollToAwards = function scrollToAwards() {
- const options = {
- scrollTop: $('.awards').offset().top - 110,
- };
- return $('body, html').animate(options, 200);
-};
+ userAuthored($emojiButton) {
+ const oldTitle = this.getAwardTooltip($emojiButton);
+ const newTitle = 'You cannot vote on your own issue, MR and note';
+ gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show');
+ // Restore tooltip back to award list
+ return setTimeout(() => {
+ $emojiButton.tooltip('hide');
+ gl.utils.updateTooltipTitle($emojiButton, oldTitle);
+ }, 2800);
+ }
-AwardsHandler.prototype.normalizeEmojiName = function normalizeEmojiName(emoji) {
- return Object.prototype.hasOwnProperty.call(this.aliases, emoji) ? this.aliases[emoji] : emoji;
-};
+ scrollToAwards() {
+ const options = {
+ scrollTop: $('.awards').offset().top - 110,
+ };
+ return $('body, html').animate(options, 200);
+ }
-AwardsHandler
- .prototype
- .addEmojiToFrequentlyUsedList = function addEmojiToFrequentlyUsedList(emoji) {
- if (isEmojiNameValid(emoji)) {
+ addEmojiToFrequentlyUsedList(emoji) {
+ if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
- };
-
-AwardsHandler.prototype.getFrequentlyUsedEmojis = function getFrequentlyUsedEmojis() {
- return this.frequentlyUsedEmojis || (() => {
- const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
- this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
- inputName => isEmojiNameValid(inputName),
- );
+ }
- return this.frequentlyUsedEmojis;
- })();
-};
+ getFrequentlyUsedEmojis() {
+ return this.frequentlyUsedEmojis || (() => {
+ const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
+ this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
+ inputName => this.emoji.isEmojiNameValid(inputName),
+ );
-AwardsHandler.prototype.setupSearch = function setupSearch() {
- const $search = $('.js-emoji-menu-search');
+ return this.frequentlyUsedEmojis;
+ })();
+ }
- this.registerEventListener('on', $search, 'input', (e) => {
- const term = $(e.target).val().trim();
- this.searchEmojis(term);
- });
+ setupSearch() {
+ const $search = $('.js-emoji-menu-search');
- const $menu = $('.emoji-menu');
- this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
- if (e.target === e.currentTarget) {
- // Clear the search
- this.searchEmojis('');
- }
- });
-};
+ this.registerEventListener('on', $search, 'input', (e) => {
+ const term = $(e.target).val().trim();
+ this.searchEmojis(term);
+ });
-AwardsHandler.prototype.searchEmojis = function searchEmojis(term) {
- const $search = $('.js-emoji-menu-search');
- $search.val(term);
-
- // Clean previous search results
- $('ul.emoji-menu-search, h5.emoji-search-title').remove();
- if (term.length > 0) {
- // Generate a search result block
- const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
- const foundEmojis = this.findMatchingEmojiElements(term).show();
- const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
- $('.emoji-menu-content ul, .emoji-menu-content h5').hide();
- $('.emoji-menu-content').append(h5).append(ul);
- } else {
- $('.emoji-menu-content').children().show();
+ const $menu = $('.emoji-menu');
+ this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
+ if (e.target === e.currentTarget) {
+ // Clear the search
+ this.searchEmojis('');
+ }
+ });
}
-};
-
-AwardsHandler.prototype.findMatchingEmojiElements = function findMatchingEmojiElements(term) {
- const safeTerm = term.toLowerCase();
- const namesMatchingAlias = [];
- Object.keys(emojiAliases).forEach((alias) => {
- if (alias.indexOf(safeTerm) >= 0) {
- namesMatchingAlias.push(emojiAliases[alias]);
+ searchEmojis(term) {
+ const $search = $('.js-emoji-menu-search');
+ $search.val(term);
+
+ // Clean previous search results
+ $('ul.emoji-menu-search, h5.emoji-search-title').remove();
+ if (term.length > 0) {
+ // Generate a search result block
+ const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
+ const foundEmojis = this.findMatchingEmojiElements(term).show();
+ const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
+ $('.emoji-menu-content ul, .emoji-menu-content h5').hide();
+ $('.emoji-menu-content').append(h5).append(ul);
+ } else {
+ $('.emoji-menu-content').children().show();
}
- });
- const $matchingElements = namesMatchingAlias.concat(safeTerm)
- .reduce(
- ($result, searchTerm) =>
- $result.add($(`.emoji-menu-list:not(.frequent-emojis) [data-name*="${searchTerm}"]`)),
- $([]),
- );
- return $matchingElements.closest('li').clone();
-};
+ }
-AwardsHandler.prototype.destroy = function destroy() {
- this.eventListeners.forEach((entry) => {
- entry.element.off.call(entry.element, ...entry.args);
- });
- $('.emoji-menu').remove();
-};
+ findMatchingEmojiElements(query) {
+ const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
+ const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
+ const $matchingElements = $emojiElements
+ .filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
+ return $matchingElements.closest('li').clone();
+ }
+
+ destroy() {
+ this.eventListeners.forEach((entry) => {
+ entry.element.off.call(entry.element, ...entry.args);
+ });
+ $('.emoji-menu').remove();
+ }
+}
-export default AwardsHandler;
+let awardsHandlerPromise = null;
+export default function loadAwardsHandler(reload = false) {
+ if (!awardsHandlerPromise || reload) {
+ awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
+ .then(Emoji => new AwardsHandler(Emoji));
+ }
+ return awardsHandlerPromise;
+}
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index 3bea460dcc6..e00af4b2fa8 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,23 +1,8 @@
import autosize from 'vendor/autosize';
-$(() => {
- const $fields = $('.js-autosize');
+document.addEventListener('DOMContentLoaded', () => {
+ const autosizeEls = document.querySelectorAll('.js-autosize');
- $fields.on('autosize:resized', function resized() {
- const $field = $(this);
- $field.data('height', $field.outerHeight());
- });
-
- $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');
+ autosize(autosizeEls);
+ autosize.update(autosizeEls);
});
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 19a607309e4..7e98e04303a 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,74 +1,9 @@
import installCustomElements from 'document-register-element';
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { getUnicodeSupportMap } from './gl_emoji/unicode_support_map';
-import { isEmojiUnicodeSupported } from './gl_emoji/is_emoji_unicode_supported';
+import isEmojiUnicodeSupported from '../emoji/support';
installCustomElements(window);
-const generatedUnicodeSupportMap = getUnicodeSupportMap();
-
-function emojiImageTag(name, src) {
- return `<img class="emoji" title=":${name}:" alt=":${name}:" src="${src}" width="20" height="20" align="absmiddle" />`;
-}
-
-function assembleFallbackImageSrc(inputName) {
- let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
- let emojiInfo = emojiMap[name];
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
- const fallbackImageSrc = `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${emojiInfo.digest}.png`;
-
- return fallbackImageSrc;
-}
-const glEmojiTagDefaults = {
- sprite: false,
- forceFallback: false,
-};
-function glEmojiTag(inputName, options) {
- const opts = Object.assign({}, glEmojiTagDefaults, options);
- let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
- let emojiInfo = emojiMap[name];
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
-
- const fallbackImageSrc = assembleFallbackImageSrc(name);
- const fallbackSpriteClass = `emoji-${name}`;
-
- const classList = [];
- if (opts.forceFallback && opts.sprite) {
- classList.push('emoji-icon');
- classList.push(fallbackSpriteClass);
- }
- const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
- const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
- let contents = emojiInfo.moji;
- if (opts.forceFallback && !opts.sprite) {
- contents = emojiImageTag(name, fallbackImageSrc);
- }
-
- return `
- <gl-emoji
- ${classAttribute}
- data-name="${name}"
- data-fallback-src="${fallbackImageSrc}"
- ${fallbackSpriteAttribute}
- data-unicode-version="${emojiInfo.unicodeVersion}"
- >
- ${contents}
- </gl-emoji>
- `;
-}
-
-function installGlEmojiElement() {
+export default function installGlEmojiElement() {
const GlEmojiElementProto = Object.create(HTMLElement.prototype);
GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
@@ -87,19 +22,28 @@ function installGlEmojiElement() {
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
if (
+ emojiUnicode &&
isEmojiUnicode &&
- !isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion)
+ !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
) {
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFalback) {
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
- } else if (hasImageFallback) {
- this.innerHTML = emojiImageTag(name, fallbackSrc);
} else {
- const src = assembleFallbackImageSrc(name);
- this.innerHTML = emojiImageTag(name, src);
+ import(/* webpackChunkName: 'emoji' */ '../emoji')
+ .then(({ emojiImageTag, emojiFallbackImageSrc }) => {
+ if (hasImageFallback) {
+ this.innerHTML = emojiImageTag(name, fallbackSrc);
+ } else {
+ const src = emojiFallbackImageSrc(name);
+ this.innerHTML = emojiImageTag(name, src);
+ }
+ })
+ .catch(() => {
+ // do nothing
+ });
}
}
};
@@ -108,9 +52,3 @@ function installGlEmojiElement() {
prototype: GlEmojiElementProto,
});
}
-
-export {
- installGlEmojiElement,
- glEmojiTag,
- emojiImageTag,
-};
diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js
deleted file mode 100644
index be4aeb32c46..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-
-function isEmojiNameValid(inputName) {
- const name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ?
- emojiAliases[inputName] : inputName;
-
- return name && emojiMap[name];
-}
-
-export default isEmojiNameValid;
diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
deleted file mode 100644
index 20ab2d7e827..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
+++ /dev/null
@@ -1,119 +0,0 @@
-// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
-const flagACodePoint = 127462; // parseInt('1F1E6', 16)
-const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
-function isFlagEmoji(emojiUnicode) {
- const cp = emojiUnicode.codePointAt(0);
- // Length 4 because flags are made of 2 characters which are surrogate pairs
- return emojiUnicode.length === 4 && cp >= flagACodePoint && cp <= flagZCodePoint;
-}
-
-// Chrome <57 renders keycaps oddly
-// See https://bugs.chromium.org/p/chromium/issues/detail?id=632294
-// Same issue on Windows also fixed in Chrome 57, http://i.imgur.com/rQF7woO.png
-function isKeycapEmoji(emojiUnicode) {
- return emojiUnicode.length === 3 && emojiUnicode[2] === '\u20E3';
-}
-
-// Check for a skin tone variation emoji which aren't always supported
-const tone1 = 127995;// parseInt('1F3FB', 16)
-const tone5 = 127999;// parseInt('1F3FF', 16)
-function isSkinToneComboEmoji(emojiUnicode) {
- return emojiUnicode.length > 2 && Array.from(emojiUnicode).some((char) => {
- const cp = char.codePointAt(0);
- return cp >= tone1 && cp <= tone5;
- });
-}
-
-// macOS supports most skin tone emoji's but
-// doesn't support the skin tone versions of horse racing
-const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
-function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
- return Array.from(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint &&
- isSkinToneComboEmoji(emojiUnicode);
-}
-
-// Check for `family_*`, `kiss_*`, `couple_*`
-// For ex. Windows 8.1 Firefox 51.0.1, doesn't support these
-const zwj = 8205; // parseInt('200D', 16)
-const personStartCodePoint = 128102; // parseInt('1F466', 16)
-const personEndCodePoint = 128105; // parseInt('1F469', 16)
-function isPersonZwjEmoji(emojiUnicode) {
- let hasPersonEmoji = false;
- let hasZwj = false;
- Array.from(emojiUnicode).forEach((character) => {
- const cp = character.codePointAt(0);
- if (cp === zwj) {
- hasZwj = true;
- } else if (cp >= personStartCodePoint && cp <= personEndCodePoint) {
- hasPersonEmoji = true;
- }
- });
-
- return hasPersonEmoji && hasZwj;
-}
-
-// Helper so we don't have to run `isFlagEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isFlagResult = isFlagEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.flag && isFlagResult) ||
- !isFlagResult
- );
-}
-
-// Helper so we don't have to run `isSkinToneComboEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) {
- const isSkinToneResult = isSkinToneComboEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.skinToneModifier && isSkinToneResult) ||
- !isSkinToneResult
- );
-}
-
-// Helper func so we don't have to run `isHorceRacingSkinToneComboEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isHorseRacingSkinToneResult = isHorceRacingSkinToneComboEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.horseRacing && isHorseRacingSkinToneResult) ||
- !isHorseRacingSkinToneResult
- );
-}
-
-// Helper so we don't have to run `isPersonZwjEmoji` twice
-// in `isEmojiUnicodeSupported` logic
-function checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode) {
- const isPersonZwjResult = isPersonZwjEmoji(emojiUnicode);
- return (
- (unicodeSupportMap.personZwj && isPersonZwjResult) ||
- !isPersonZwjResult
- );
-}
-
-// Takes in a support map and determines whether
-// the given unicode emoji is supported on the platform.
-//
-// Combines all the edge case tests into a one-stop shop method
-function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVersion) {
- const isOlderThanChrome57 = unicodeSupportMap.meta && unicodeSupportMap.meta.isChrome &&
- unicodeSupportMap.meta.chromeVersion < 57;
-
- // For comments about each scenario, see the comments above each individual respective function
- return unicodeSupportMap[unicodeVersion] &&
- !(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
- checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
- checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&
- checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) &&
- checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode);
-}
-
-export {
- isEmojiUnicodeSupported,
- isFlagEmoji,
- isKeycapEmoji,
- isSkinToneComboEmoji,
- isHorceRacingSkinToneComboEmoji,
- isPersonZwjEmoji,
-};
diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
deleted file mode 100644
index aa522e20c36..00000000000
--- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
+++ /dev/null
@@ -1,161 +0,0 @@
-const unicodeSupportTestMap = {
- // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
- // occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
- // woman, biking (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
- // sexZwj: '\u{1F6B4}\u{200D}\u{2640}',
- // family_mwgb
- // Windows 8.1, Firefox 51.0.1 does not support `family_`, `kiss_`, `couple_`
- personZwj: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
- // horse_racing_tone5
- // Special case that is not supported on macOS 10.12 even though `skinToneModifier` succeeds
- horseRacing: '\u{1F3C7}\u{1F3FF}',
- // US flag, http://emojipedia.org/flags/
- flag: '\u{1F1FA}\u{1F1F8}',
- // http://emojipedia.org/modifiers/
- skinToneModifier: [
- // spy_tone5
- '\u{1F575}\u{1F3FF}',
- // person_with_ball_tone5
- '\u{26F9}\u{1F3FF}',
- // angel_tone5
- '\u{1F47C}\u{1F3FF}',
- ],
- // rofl, http://emojipedia.org/unicode-9.0/
- '9.0': '\u{1F923}',
- // metal, http://emojipedia.org/unicode-8.0/
- '8.0': '\u{1F918}',
- // spy, http://emojipedia.org/unicode-7.0/
- '7.0': '\u{1F575}',
- // expressionless, http://emojipedia.org/unicode-6.1/
- 6.1: '\u{1F611}',
- // japanese_goblin, http://emojipedia.org/unicode-6.0/
- '6.0': '\u{1F47A}',
- // sailboat, http://emojipedia.org/unicode-5.2/
- 5.2: '\u{26F5}',
- // mahjong, http://emojipedia.org/unicode-5.1/
- 5.1: '\u{1F004}',
- // gear, http://emojipedia.org/unicode-4.1/
- 4.1: '\u{2699}',
- // zap, http://emojipedia.org/unicode-4.0/
- '4.0': '\u{26A1}',
- // recycle, http://emojipedia.org/unicode-3.2/
- 3.2: '\u{267B}',
- // information_source, http://emojipedia.org/unicode-3.0/
- '3.0': '\u{2139}',
- // heart, http://emojipedia.org/unicode-1.1/
- 1.1: '\u{2764}',
-};
-
-function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
- // `4 *` because RGBA
- const indexOffset = 4 * pixelOffset;
- const hasColor = imageDataArray[indexOffset + 0] ||
- imageDataArray[indexOffset + 1] ||
- imageDataArray[indexOffset + 2];
- const isVisible = imageDataArray[indexOffset + 3];
- // Check for some sort of color other than black
- if (hasColor && isVisible) {
- return true;
- }
- return false;
-}
-
-const chromeMatches = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
-const isChrome = chromeMatches && chromeMatches.length > 0;
-const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatches[1], 10);
-
-// We use 16px because mobile Safari (iOS 9.3) doesn't properly scale emojis :/
-// See 32px, https://i.imgur.com/htY6Zym.png
-// See 16px, https://i.imgur.com/FPPsIF8.png
-const fontSize = 16;
-function generateUnicodeSupportMap(testMap) {
- const testMapKeys = Object.keys(testMap);
- const numTestEntries = testMapKeys
- .reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
-
- const canvas = document.createElement('canvas');
- (window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas;
- const ctx = canvas.getContext('2d');
- canvas.width = (2 * fontSize);
- canvas.height = (numTestEntries * fontSize);
- ctx.fillStyle = '#000000';
- ctx.textBaseline = 'middle';
- ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
- // Write each emoji to the canvas vertically
- let writeIndex = 0;
- testMapKeys.forEach((testKey) => {
- const testEntry = testMap[testKey];
- [].concat(testEntry).forEach((emojiUnicode) => {
- ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
- writeIndex += 1;
- });
- });
-
- // Read from the canvas
- const resultMap = {};
- let readIndex = 0;
- testMapKeys.forEach((testKey) => {
- const testEntry = testMap[testKey];
- // This needs to be a `reduce` instead of `every` because we need to
- // keep the `readIndex` in sync from the writes by running all entries
- const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => {
- // Sample along the vertical-middle for a couple of characters
- const imageData = ctx.getImageData(
- 0,
- (readIndex * fontSize) + (fontSize / 2),
- 2 * fontSize,
- 1,
- ).data;
-
- let isValidEmoji = false;
- for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
- const isLookingAtFirstChar = currentPixel < fontSize;
- const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
- // Check for the emoji somewhere along the row
- if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
- isValidEmoji = true;
-
- // Check to see that nothing is rendered next to the first character
- // to ensure that the ZWJ sequence rendered as one piece
- } else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
- isValidEmoji = false;
- break;
- }
- }
-
- readIndex += 1;
- return isSatisfied && isValidEmoji;
- }, true);
-
- resultMap[testKey] = isTestSatisfied;
- });
-
- resultMap.meta = {
- isChrome,
- chromeVersion,
- };
-
- return resultMap;
-}
-
-function getUnicodeSupportMap() {
- let unicodeSupportMap;
- const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
- try {
- unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
- } catch (err) {
- // swallow
- }
- if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
- unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
- window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
- window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
- }
-
- return unicodeSupportMap;
-}
-
-export {
- getUnicodeSupportMap,
- generateUnicodeSupportMap,
-};
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 5b931e6cfa6..44b2c974b9e 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,7 +1,7 @@
import './autosize';
import './bind_in_out';
import './details_behavior';
-import { installGlEmojiElement } from './gl_emoji';
+import installGlEmojiElement from './gl_emoji';
import './quick_submit';
import './requires_input';
import './toggler_behavior';
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 3d162b24413..bc693616460 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -40,11 +40,11 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
e.preventDefault();
const $form = $(e.target).closest('form');
- const $submitButton = $form.find('input[type=submit], button[type=submit]');
+ const $submitButton = $form.find('input[type=submit], button[type=submit]').first();
if (!$submitButton.attr('disabled')) {
+ $submitButton.trigger('click', [e]);
$submitButton.disable();
- $form.submit();
}
});
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index b20d108aa25..035a7e5c431 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import '../commons/bootstrap';
// Requires Input behavior
@@ -48,7 +49,9 @@ function hideOrShowHelpBlock(form) {
$(() => {
const $form = $('form.js-requires-input');
- $form.requiresInput();
- hideOrShowHelpBlock($form);
- $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
+ if ($form) {
+ $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 77e92ff8caf..b70b0a9bbf8 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,4 +1,3 @@
-
// Toggle button. Show/hide content inside parent container.
// Button does not change visibility. If button has icon - it changes chevron style.
//
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
new file mode 100644
index 00000000000..c17877a276d
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -0,0 +1,114 @@
+import sqljs from 'sql.js';
+import { template as _template } from 'underscore';
+
+const PREVIEW_TEMPLATE = _template(`
+ <div class="panel panel-default">
+ <div class="panel-heading"><%- name %></div>
+ <div class="panel-body">
+ <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
+ </div>
+ </div>
+`);
+
+class BalsamiqViewer {
+ constructor(viewer) {
+ this.viewer = viewer;
+ }
+
+ loadFile(endpoint) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open('GET', endpoint, true);
+ xhr.responseType = 'arraybuffer';
+ xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject);
+ xhr.onerror = reject;
+
+ xhr.send();
+ });
+ }
+
+ fileLoaded(loadEvent, resolve, reject) {
+ if (loadEvent.target.status !== 200) return reject();
+
+ this.renderFile(loadEvent);
+
+ return resolve();
+ }
+
+ renderFile(loadEvent) {
+ const container = document.createElement('ul');
+
+ this.initDatabase(loadEvent.target.response);
+
+ const previews = this.getPreviews();
+ previews.forEach((preview) => {
+ const renderedPreview = this.renderPreview(preview);
+
+ container.appendChild(renderedPreview);
+ });
+
+ container.classList.add('list-inline');
+ container.classList.add('previews');
+
+ this.viewer.appendChild(container);
+ }
+
+ initDatabase(data) {
+ const previewBinary = new Uint8Array(data);
+
+ this.database = new sqljs.Database(previewBinary);
+ }
+
+ getPreviews() {
+ const thumbnails = this.database.exec('SELECT * FROM thumbnails');
+
+ return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
+ }
+
+ getResource(resourceID) {
+ const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
+
+ return resources[0];
+ }
+
+ renderPreview(preview) {
+ const previewElement = document.createElement('li');
+
+ previewElement.classList.add('preview');
+ previewElement.innerHTML = this.renderTemplate(preview);
+
+ return previewElement;
+ }
+
+ renderTemplate(preview) {
+ const resource = this.getResource(preview.resourceID);
+ const name = BalsamiqViewer.parseTitle(resource);
+ const image = preview.image;
+
+ const template = PREVIEW_TEMPLATE({
+ name,
+ image,
+ });
+
+ return template;
+ }
+
+ static parsePreview(preview) {
+ return JSON.parse(preview[1]);
+ }
+
+ /*
+ * resource = {
+ * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
+ * values: [['id', 'branchId', 'attributes', 'data']],
+ * }
+ *
+ * 'attributes' being a JSON string containing the `name` property.
+ */
+ static parseTitle(resource) {
+ return JSON.parse(resource.values[0][2]).name;
+ }
+}
+
+export default BalsamiqViewer;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
new file mode 100644
index 00000000000..8641a6fdae6
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -0,0 +1,22 @@
+/* global Flash */
+
+import BalsamiqViewer from './balsamiq/balsamiq_viewer';
+
+function onError() {
+ const flash = new window.Flash('Balsamiq file could not be loaded.');
+
+ return flash;
+}
+
+function loadBalsamiqFile() {
+ const viewer = document.getElementById('js-balsamiq-viewer');
+
+ if (!(viewer instanceof Element)) return;
+
+ const endpoint = viewer.dataset.endpoint;
+
+ const balsamiqViewer = new BalsamiqViewer(viewer);
+ balsamiqViewer.loadFile(endpoint).catch(onError);
+}
+
+$(loadBalsamiqFile);
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index 4568b86f298..26d3419a162 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,9 +1,24 @@
/* eslint-disable func-names, object-shorthand, prefer-arrow-callback */
/* global Dropzone */
+import '../lib/utils/url_utility';
+import { HIDDEN_CLASS } from '../lib/utils/constants';
+
+function toggleLoading($el, $icon, loading) {
+ if (loading) {
+ $el.disable();
+ $icon.removeClass(HIDDEN_CLASS);
+ } else {
+ $el.enable();
+ $icon.addClass(HIDDEN_CLASS);
+ }
+}
export default class BlobFileDropzone {
constructor(form, method) {
const formDropzone = form.find('.dropzone');
+ const submitButton = form.find('#submit-all');
+ const submitButtonLoadingIcon = submitButton.find('.js-loading-icon');
+ const dropzoneMessage = form.find('.dz-message');
Dropzone.autoDiscover = false;
const dropzone = formDropzone.dropzone({
@@ -26,16 +41,24 @@ export default class BlobFileDropzone {
},
init: function () {
this.on('addedfile', function () {
+ toggleLoading(submitButton, submitButtonLoadingIcon, false);
+ dropzoneMessage.addClass(HIDDEN_CLASS);
$('.dropzone-alerts').html('').hide();
});
+ this.on('removedfile', function () {
+ toggleLoading(submitButton, submitButtonLoadingIcon, false);
+ dropzoneMessage.removeClass(HIDDEN_CLASS);
+ });
this.on('success', function (header, response) {
- window.location.href = response.filePath;
+ $('#modal-upload-blob').modal('hide');
+ window.gl.utils.visitUrl(response.filePath);
});
this.on('maxfilesexceeded', function (file) {
+ dropzoneMessage.addClass(HIDDEN_CLASS);
this.removeFile(file);
});
this.on('sending', function (file, xhr, formData) {
- formData.append('branch_name', form.find('input[name="branch_name"]').val());
+ formData.append('branch_name', form.find('.js-branch-name').val());
formData.append('create_merge_request', form.find('.js-create-merge-request').val());
formData.append('commit_message', form.find('.js-commit-message').val());
});
@@ -48,14 +71,15 @@ export default class BlobFileDropzone {
},
});
- const submitButton = form.find('#submit-all')[0];
- submitButton.addEventListener('click', function (e) {
+ submitButton.on('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
alert('Please select a file');
+ return false;
}
+ toggleLoading(submitButton, submitButtonLoadingIcon, true);
dropzone[0].dropzone.processQueue();
return false;
});
diff --git a/app/assets/javascripts/blob/create_branch_dropdown.js b/app/assets/javascripts/blob/create_branch_dropdown.js
deleted file mode 100644
index 95517f51b1c..00000000000
--- a/app/assets/javascripts/blob/create_branch_dropdown.js
+++ /dev/null
@@ -1,88 +0,0 @@
-class CreateBranchDropdown {
- constructor(el, targetBranchDropdown) {
- this.targetBranchDropdown = targetBranchDropdown;
- this.el = el;
- this.dropdownBack = this.el.closest('.dropdown').querySelector('.dropdown-menu-back');
- this.cancelButton = this.el.querySelector('.js-cancel-branch-btn');
- this.newBranchField = this.el.querySelector('#new_branch_name');
- this.newBranchCreateButton = this.el.querySelector('.js-new-branch-btn');
-
- this.newBranchCreateButton.setAttribute('disabled', '');
-
- this.addBindings();
- this.cleanupWrapper = this.cleanup.bind(this);
- document.addEventListener('beforeunload', this.cleanupWrapper);
- }
-
- cleanup() {
- this.cleanBindings();
- document.removeEventListener('beforeunload', this.cleanupWrapper);
- }
-
- cleanBindings() {
- this.newBranchField.removeEventListener('keyup', this.enableBranchCreateButtonWrapper);
- this.newBranchField.removeEventListener('change', this.enableBranchCreateButtonWrapper);
- this.newBranchField.removeEventListener('keydown', this.handleNewBranchKeydownWrapper);
- this.dropdownBack.removeEventListener('click', this.resetFormWrapper);
- this.cancelButton.removeEventListener('click', this.handleCancelClickWrapper);
- this.newBranchCreateButton.removeEventListener('click', this.createBranchWrapper);
- }
-
- addBindings() {
- this.enableBranchCreateButtonWrapper = this.enableBranchCreateButton.bind(this);
- this.handleNewBranchKeydownWrapper = this.handleNewBranchKeydown.bind(this);
- this.resetFormWrapper = this.resetForm.bind(this);
- this.handleCancelClickWrapper = this.handleCancelClick.bind(this);
- this.createBranchWrapper = this.createBranch.bind(this);
-
- this.newBranchField.addEventListener('keyup', this.enableBranchCreateButtonWrapper);
- this.newBranchField.addEventListener('change', this.enableBranchCreateButtonWrapper);
- this.newBranchField.addEventListener('keydown', this.handleNewBranchKeydownWrapper);
- this.dropdownBack.addEventListener('click', this.resetFormWrapper);
- this.cancelButton.addEventListener('click', this.handleCancelClickWrapper);
- this.newBranchCreateButton.addEventListener('click', this.createBranchWrapper);
- }
-
- handleCancelClick(e) {
- e.preventDefault();
- e.stopPropagation();
-
- this.resetForm();
- this.dropdownBack.click();
- }
-
- handleNewBranchKeydown(e) {
- const keyCode = e.which;
- const ENTER_KEYCODE = 13;
- if (keyCode === ENTER_KEYCODE) {
- this.createBranch(e);
- }
- }
-
- enableBranchCreateButton() {
- if (this.newBranchField.value !== '') {
- this.newBranchCreateButton.removeAttribute('disabled');
- } else {
- this.newBranchCreateButton.setAttribute('disabled', '');
- }
- }
-
- resetForm() {
- this.newBranchField.value = '';
- this.enableBranchCreateButtonWrapper();
- }
-
- createBranch(e) {
- e.preventDefault();
-
- if (this.newBranchCreateButton.getAttribute('disabled') === '') {
- return;
- }
- const newBranchName = this.newBranchField.value;
- this.targetBranchDropdown.setNewBranch(newBranchName);
- this.resetForm();
- }
-}
-
-window.gl = window.gl || {};
-gl.CreateBranchDropdown = CreateBranchDropdown;
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 3062cd51ee3..a20c6ca7a21 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -99,7 +99,7 @@ export default class FileTemplateMediator {
});
}
- selectTemplateType(item, el, e) {
+ selectTemplateType(item, e) {
if (e) {
e.preventDefault();
}
@@ -117,6 +117,10 @@ export default class FileTemplateMediator {
this.cacheToggleText();
}
+ selectTemplateTypeOptions(options) {
+ this.selectTemplateType(options.selectedObj, options.e);
+ }
+
selectTemplateFile(selector, query, data) {
selector.renderLoading();
// in case undo menu is already already there
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index 31dd45fac89..5ae30990aea 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -1,5 +1,3 @@
-/* global Api */
-
export default class FileTemplateSelector {
constructor(mediator) {
this.mediator = mediator;
@@ -52,9 +50,16 @@ export default class FileTemplateSelector {
.removeClass('fa-spinner fa-spin');
}
- reportSelection(query, el, e, data) {
+ reportSelection(options) {
+ const { query, e, data } = options;
e.preventDefault();
return this.mediator.selectTemplateFile(this, query, data);
}
-}
+ reportSelectionName(options) {
+ const opts = options;
+ opts.query = options.selectedObj.name;
+
+ this.reportSelection(opts);
+ }
+}
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index 36fe8a7184f..27312d718b0 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -51,8 +51,9 @@ export default () => {
methods: {
loadFile() {
this.$http.get(el.dataset.endpoint)
+ .then(response => response.json())
.then((res) => {
- this.json = res.json();
+ this.json = res;
this.loading = false;
})
.catch((e) => {
diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js
index a74c2db9a61..0ed915c1ac9 100644
--- a/app/assets/javascripts/blob/pdf/index.js
+++ b/app/assets/javascripts/blob/pdf/index.js
@@ -1,11 +1,6 @@
/* eslint-disable no-new */
import Vue from 'vue';
-import PDFLab from 'vendor/pdflab';
-import workerSrc from 'vendor/pdf.worker';
-
-Vue.use(PDFLab, {
- workerSrc,
-});
+import pdfLab from '../../pdf/index.vue';
export default () => {
const el = document.getElementById('js-pdf-viewer');
@@ -20,6 +15,9 @@ export default () => {
pdf: el.dataset.endpoint,
};
},
+ components: {
+ pdfLab,
+ },
methods: {
onLoad() {
this.loading = false;
@@ -31,7 +29,7 @@ export default () => {
},
},
template: `
- <div class="container-fluid md prepend-top-default append-bottom-default">
+ <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default">
<div
class="text-center loading"
v-if="loading && !error">
diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js
deleted file mode 100644
index 216f069ef71..00000000000
--- a/app/assets/javascripts/blob/target_branch_dropdown.js
+++ /dev/null
@@ -1,152 +0,0 @@
-/* eslint-disable class-methods-use-this */
-const SELECT_ITEM_MSG = 'Select';
-
-class TargetBranchDropDown {
- constructor(dropdown) {
- this.dropdown = dropdown;
- this.$dropdown = $(dropdown);
- this.fieldName = this.dropdown.getAttribute('data-field-name');
- this.form = this.dropdown.closest('form');
- this.createDropdown();
- }
-
- static bootstrap() {
- const dropdowns = document.querySelectorAll('.js-project-branches-dropdown');
- [].forEach.call(dropdowns, dropdown => new TargetBranchDropDown(dropdown));
- }
-
- createDropdown() {
- const self = this;
- this.$dropdown.glDropdown({
- selectable: true,
- filterable: true,
- search: {
- fields: ['title'],
- },
- data: (term, callback) => $.ajax({
- url: self.dropdown.getAttribute('data-refs-url'),
- data: {
- ref: self.dropdown.getAttribute('data-ref'),
- show_all: true,
- },
- dataType: 'json',
- }).done(refs => callback(self.dropdownData(refs))),
- toggleLabel(item, el) {
- if (el.is('.is-active')) {
- return item.text;
- }
- return SELECT_ITEM_MSG;
- },
- clicked(item, el, e) {
- e.preventDefault();
- self.onClick.call(self);
- },
- fieldName: self.fieldName,
- });
- return new gl.CreateBranchDropdown(this.form.querySelector('.dropdown-new-branch'), this);
- }
-
- onClick() {
- this.enableSubmit();
- this.$dropdown.trigger('change.branch');
- }
-
- enableSubmit() {
- const submitBtn = this.form.querySelector('[type="submit"]');
- if (this.branchInput && this.branchInput.value) {
- submitBtn.removeAttribute('disabled');
- } else {
- submitBtn.setAttribute('disabled', '');
- }
- }
-
- dropdownData(refs) {
- const branchList = this.dropdownItems(refs);
- this.cachedRefs = refs;
- this.addDefaultBranch(branchList);
- this.addNewBranch(branchList);
- return { Branches: branchList };
- }
-
- dropdownItems(refs) {
- return refs.map(this.dropdownItem);
- }
-
- dropdownItem(ref) {
- return { id: ref, text: ref, title: ref };
- }
-
- addDefaultBranch(branchList) {
- // when no branch is selected do nothing
- if (!this.branchInput) {
- return;
- }
-
- const branchInputVal = this.branchInput.value;
- const currentBranchIndex = this.searchBranch(branchList, branchInputVal);
-
- if (currentBranchIndex === -1) {
- this.unshiftBranch(branchList, this.dropdownItem(branchInputVal));
- }
- }
-
- addNewBranch(branchList) {
- if (this.newBranch) {
- this.unshiftBranch(branchList, this.newBranch);
- }
- }
-
- searchBranch(branchList, branchName) {
- return _.findIndex(branchList, el => branchName === el.id);
- }
-
- unshiftBranch(branchList, branch) {
- const branchIndex = this.searchBranch(branchList, branch.id);
-
- if (branchIndex === -1) {
- branchList.unshift(branch);
- }
- }
-
- setNewBranch(newBranchName) {
- this.newBranch = this.dropdownItem(newBranchName);
- this.refreshData();
- this.selectBranch(this.searchBranch(this.glDropdown.fullData.Branches, newBranchName));
- }
-
- refreshData() {
- this.glDropdown.fullData = this.dropdownData(this.cachedRefs);
- this.clearFilter();
- }
-
- clearFilter() {
- // apply an empty filter in order to refresh the data
- this.glDropdown.filter.filter('');
- this.dropdown.closest('.dropdown').querySelector('.dropdown-page-one .dropdown-input-field').value = '';
- }
-
- selectBranch(index) {
- const branch = this.dropdown.closest('.dropdown').querySelectorAll('li a')[index];
-
- if (!branch.classList.contains('is-active')) {
- branch.click();
- } else {
- this.closeDropdown();
- }
- }
-
- closeDropdown() {
- this.dropdown.closest('.dropdown').querySelector('.dropdown-menu-close').click();
- }
-
- get branchInput() {
- return this.form.querySelector(`input[name="${this.fieldName}"]`);
- }
-
- get glDropdown() {
- return this.$dropdown.data('glDropdown');
- }
-}
-
-window.gl = window.gl || {};
-gl.TargetBranchDropDown = TargetBranchDropDown;
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index d7c1c32efbd..888883163c5 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
+ clicked: options => this.fetchFileTemplate(options),
text: item => item.name,
});
}
@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden');
}
- fetchFileTemplate(item, el, e) {
+ fetchFileTemplate(options) {
+ const { e } = options;
+ const item = options.selectedObj;
+
e.preventDefault();
return this.requestFile(item);
}
diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
index 935df07677c..9c41e429c8d 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index b4b4d09c315..45fb614fe00 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index aefae54ae71..a894953cc86 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index c8abd689ab4..b7c4da0f62e 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => {
+ clicked: (options) => {
+ const { e } = options;
+ const el = options.$el;
+ const query = options.selectedObj;
+
const data = {
project: this.$dropdown.data('project'),
fullname: this.$dropdown.data('fullname'),
};
- this.reportSelection(query.id, el, e, data);
+ this.reportSelection({
+ query: query.id,
+ el,
+ e,
+ data,
+ });
},
text: item => item.name,
});
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
index 56f23ef0568..a09381014a7 100644
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/type_selector.js
@@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
filterable: false,
selectable: true,
toggleLabel: item => item.name,
- clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e),
+ clicked: options => this.mediator.selectTemplateTypeOptions(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
new file mode 100644
index 00000000000..187fab084fd
--- /dev/null
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -0,0 +1,149 @@
+/* global Flash */
+export default class BlobViewer {
+ constructor() {
+ BlobViewer.initAuxiliaryViewer();
+
+ this.initMainViewers();
+ }
+
+ static initAuxiliaryViewer() {
+ const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]');
+ if (!auxiliaryViewer) return;
+
+ BlobViewer.loadViewer(auxiliaryViewer);
+ }
+
+ initMainViewers() {
+ this.$fileHolder = $('.file-holder');
+ if (!this.$fileHolder.length) return;
+
+ this.switcher = document.querySelector('.js-blob-viewer-switcher');
+ this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
+ this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
+
+ this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
+ this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
+
+ this.initBindings();
+
+ this.switchToInitialViewer();
+ }
+
+ switchToInitialViewer() {
+ const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)');
+ let initialViewerName = initialViewer.getAttribute('data-type');
+
+ if (this.switcher && location.hash.indexOf('#L') === 0) {
+ initialViewerName = 'simple';
+ }
+
+ this.switchToViewer(initialViewerName);
+ }
+
+ initBindings() {
+ if (this.switcherBtns.length) {
+ Array.from(this.switcherBtns)
+ .forEach((el) => {
+ el.addEventListener('click', this.switchViewHandler.bind(this));
+ });
+ }
+
+ if (this.copySourceBtn) {
+ this.copySourceBtn.addEventListener('click', () => {
+ if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur();
+
+ return this.switchToViewer('simple');
+ });
+ }
+ }
+
+ switchViewHandler(e) {
+ const target = e.currentTarget;
+
+ e.preventDefault();
+
+ this.switchToViewer(target.getAttribute('data-viewer'));
+ }
+
+ toggleCopyButtonState() {
+ if (!this.copySourceBtn) return;
+
+ if (this.simpleViewer.getAttribute('data-loaded')) {
+ this.copySourceBtn.setAttribute('title', 'Copy source to clipboard');
+ this.copySourceBtn.classList.remove('disabled');
+ } else if (this.activeViewer === this.simpleViewer) {
+ this.copySourceBtn.setAttribute('title', 'Wait for the source to load to copy it to the clipboard');
+ this.copySourceBtn.classList.add('disabled');
+ } else {
+ this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard');
+ this.copySourceBtn.classList.add('disabled');
+ }
+
+ $(this.copySourceBtn).tooltip('fixTitle');
+ }
+
+ switchToViewer(name) {
+ const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
+ if (this.activeViewer === newViewer) return;
+
+ const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active');
+ const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`);
+ const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`);
+
+ if (oldButton) {
+ oldButton.classList.remove('active');
+ }
+
+ if (newButton) {
+ newButton.classList.add('active');
+ newButton.blur();
+ }
+
+ if (oldViewer) {
+ oldViewer.classList.add('hidden');
+ }
+
+ newViewer.classList.remove('hidden');
+
+ this.activeViewer = newViewer;
+
+ this.toggleCopyButtonState();
+
+ BlobViewer.loadViewer(newViewer)
+ .then((viewer) => {
+ $(viewer).renderGFM();
+
+ this.$fileHolder.trigger('highlight:line');
+ gl.utils.handleLocationHash();
+
+ this.toggleCopyButtonState();
+ })
+ .catch(() => new Flash('Error loading viewer'));
+ }
+
+ static loadViewer(viewerParam) {
+ const viewer = viewerParam;
+ const url = viewer.getAttribute('data-url');
+
+ return new Promise((resolve, reject) => {
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ resolve(viewer);
+ return;
+ }
+
+ viewer.setAttribute('data-loading', 'true');
+
+ $.ajax({
+ url,
+ dataType: 'JSON',
+ })
+ .fail(reject)
+ .done((data) => {
+ viewer.innerHTML = data.html;
+ viewer.setAttribute('data-loaded', 'true');
+
+ resolve(viewer);
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 1c64ccf536f..b5500ac116f 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -8,6 +8,7 @@ import BlobFileDropzone from '../blob/blob_file_dropzone';
$(() => {
const editBlobForm = $('.js-edit-blob-form');
const uploadBlobForm = $('.js-upload-blob-form');
+ const deleteBlobForm = $('.js-delete-blob-form');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relative-url-root');
@@ -30,4 +31,8 @@ $(() => {
'.btn-upload-file',
);
}
+
+ if (deleteBlobForm.length) {
+ new NewCommitForm(deleteBlobForm);
+ }
});
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index b6dee8177d2..89c14180149 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -2,27 +2,27 @@
/* global BoardService */
/* global Flash */
+import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
-
-require('./models/issue');
-require('./models/label');
-require('./models/list');
-require('./models/milestone');
-require('./models/user');
-require('./stores/boards_store');
-require('./stores/modal_store');
-require('./services/board_service');
-require('./mixins/modal_mixins');
-require('./mixins/sortable_default_options');
-require('./filters/due_date_filters');
-require('./components/board');
-require('./components/board_sidebar');
-require('./components/new_list_dropdown');
-require('./components/modal/index');
-require('../vue_shared/vue_resource_interceptor');
+import './models/issue';
+import './models/label';
+import './models/list';
+import './models/milestone';
+import './models/assignee';
+import './stores/boards_store';
+import './stores/modal_store';
+import './services/board_service';
+import './mixins/modal_mixins';
+import './mixins/sortable_default_options';
+import './filters/due_date_filters';
+import './components/board';
+import './components/board_sidebar';
+import './components/new_list_dropdown';
+import './components/modal/index';
+import '../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
@@ -59,7 +59,8 @@ $(() => {
issueLinkBase: $boardApp.dataset.issueLinkBase,
rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: Store.detail
+ detailIssue: Store.detail,
+ defaultAvatar: $boardApp.dataset.defaultAvatar,
},
computed: {
detailIssueVisible () {
@@ -70,6 +71,7 @@ $(() => {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
this.filterManager = new FilteredSearchBoards(Store.filter, true);
+ this.filterManager.setup();
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens);
@@ -80,13 +82,16 @@ $(() => {
mounted () {
Store.disabled = this.disabled;
gl.boardService.all()
+ .then(response => response.json())
.then((resp) => {
- resp.json().forEach((board) => {
- const list = Store.addList(board);
+ resp.forEach((board) => {
+ const list = Store.addList(board, this.defaultAvatar);
if (list.type === 'closed') {
list.position = Infinity;
list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' };
+ } else if (list.type === 'backlog') {
+ list.position = -1;
}
});
@@ -94,7 +99,8 @@ $(() => {
Store.addBlankState();
this.loading = false;
- }).catch(() => new Flash('An error occurred. Please try again.'));
+ })
+ .catch(() => new Flash('An error occurred. Please try again.'));
},
methods: {
updateTokens() {
@@ -127,7 +133,7 @@ $(() => {
},
computed: {
disabled() {
- return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length;
+ return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
if (this.disabled) {
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 239eeacf2d7..adb7360327c 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,11 +1,10 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */
import Vue from 'vue';
+import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
-
-require('./board_delete');
-require('./board_list');
+import './board_delete';
const Store = gl.issueBoards.BoardsStore;
@@ -24,6 +23,10 @@ gl.issueBoards.Board = Vue.extend({
disabled: Boolean,
issueLinkBase: String,
rootPath: String,
+ boardId: {
+ type: String,
+ required: true,
+ },
},
data () {
return {
@@ -35,7 +38,10 @@ gl.issueBoards.Board = Vue.extend({
filter: {
handler() {
this.list.page = 1;
- this.list.getIssues(true);
+ this.list.getIssues(true)
+ .catch(() => {
+ // TODO: handle request error
+ });
},
deep: true,
},
@@ -77,7 +83,16 @@ gl.issueBoards.Board = Vue.extend({
methods: {
showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
- }
+ },
+ toggleExpanded(e) {
+ if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) {
+ this.list.isExpanded = !this.list.isExpanded;
+
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded);
+ }
+ }
+ },
},
mounted () {
this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -101,4 +116,11 @@ gl.issueBoards.Board = Vue.extend({
this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
},
+ created() {
+ if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
+ const isCollapsed = localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
+
+ this.list.isExpanded = !isCollapsed;
+ }
+ },
});
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js
index 3fc68457961..edfe7c326db 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.js
@@ -1,5 +1,6 @@
/* global ListLabel */
+import _ from 'underscore';
import Cookies from 'js-cookie';
const Store = gl.issueBoards.BoardsStore;
@@ -64,13 +65,17 @@ export default {
// Save the labels
gl.boardService.generateDefaultLists()
- .then((resp) => {
- resp.json().forEach((listObj) => {
+ .then(resp => resp.json())
+ .then((data) => {
+ data.forEach((listObj) => {
const list = Store.findList('title', listObj.title);
list.id = listObj.id;
list.label.id = listObj.label.id;
- list.getIssues();
+ list.getIssues()
+ .catch(() => {
+ // TODO: handle request error
+ });
});
})
.catch(() => {
diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js
index f591134c548..079fb6438b9 100644
--- a/app/assets/javascripts/boards/components/board_card.js
+++ b/app/assets/javascripts/boards/components/board_card.js
@@ -1,4 +1,4 @@
-require('./issue_card_inner');
+import './issue_card_inner';
const Store = gl.issueBoards.BoardsStore;
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
index b13386536bf..bebca17fb1e 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.js
@@ -2,6 +2,7 @@
import boardNewIssue from './board_new_issue';
import boardCard from './board_card';
import eventHub from '../eventhub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
const Store = gl.issueBoards.BoardsStore;
@@ -44,6 +45,7 @@ export default {
components: {
boardCard,
boardNewIssue,
+ loadingIcon,
},
methods: {
listHeight() {
@@ -55,6 +57,9 @@ export default {
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
@@ -90,7 +95,10 @@ export default {
if (this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length) {
this.list.page += 1;
- this.list.getIssues(false);
+ this.list.getIssues(false)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
@@ -103,6 +111,7 @@ export default {
},
created() {
eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -145,6 +154,7 @@ export default {
},
beforeDestroy() {
eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
template: `
@@ -153,14 +163,13 @@ export default {
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true">
- </i>
+ <loading-icon />
</div>
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
+ <transition name="slide-down">
+ <board-new-issue
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ </transition>
<ul
class="board-list"
v-show="!loading"
@@ -181,12 +190,12 @@ export default {
class="board-list-count text-center"
v-if="showCount"
data-id="-1">
- <i
- class="fa fa-spinner fa-spin"
- aria-label="Loading more issues"
- aria-hidden="true"
- v-show="list.loadingMore">
- </i>
+
+ <loading-icon
+ v-show="list.loadingMore"
+ label="Loading more issues"
+ />
+
<span v-if="list.issues.length === list.issuesSize">
Showing all issues
</span>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 0fa85b6fe14..4af8b0c7713 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -17,7 +17,7 @@ export default {
methods: {
submit(e) {
e.preventDefault();
- if (this.title.trim() === '') return;
+ if (this.title.trim() === '') return Promise.resolve();
this.error = false;
@@ -26,9 +26,13 @@ export default {
title: this.title,
labels,
subscribed: true,
+ assignees: [],
});
- this.list.newIssue(issue)
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
+ this.cancel();
+
+ return this.list.newIssue(issue)
.then(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
@@ -46,8 +50,6 @@ export default {
// Show error message
this.error = true;
});
-
- this.cancel();
},
cancel() {
this.title = '';
@@ -74,6 +76,7 @@ export default {
type="text"
v-model="title"
ref="input"
+ autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 004bac09f59..590b7be36e3 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -3,10 +3,13 @@
/* global MilestoneSelect */
/* global LabelsSelect */
/* global Sidebar */
+/* global Flash */
import Vue from 'vue';
-
-require('./sidebar/remove_issue');
+import eventHub from '../../sidebar/event_hub';
+import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
+import Assignees from '../../sidebar/components/assignees/assignees';
+import './sidebar/remove_issue';
const Store = gl.issueBoards.BoardsStore;
@@ -22,17 +25,30 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail,
issue: {},
list: {},
+ loadingAssignees: false,
};
},
computed: {
showSidebar () {
return Object.keys(this.issue).length;
- }
+ },
+ milestoneTitle() {
+ return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
+ },
+ canRemove() {
+ return !this.list.preset;
+ },
},
watch: {
detail: {
handler () {
if (this.issue.id !== this.detail.issue.id) {
+ $('.block.assignee')
+ .find('input:not(.js-vue)[name="issue[assignee_ids][]"]')
+ .each((i, el) => {
+ $(el).remove();
+ });
+
$('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('glDropdown').clearMenu();
});
@@ -40,22 +56,59 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
+
+ this.$nextTick(() => {
+ this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
+ });
},
deep: true
},
- issue () {
- if (this.showSidebar) {
- this.$nextTick(() => {
- $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
- $('.right-sidebar').getNiceScroll().resize();
- });
- }
- }
},
methods: {
closeSidebar () {
this.detail.issue = {};
- }
+ },
+ assignSelf () {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
+
+ this.addAssignee(this.currentUser);
+ this.saveAssignees();
+ },
+ removeAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
+ },
+ addAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
+ },
+ removeAllAssignees () {
+ gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
+ },
+ saveAssignees () {
+ this.loadingAssignees = true;
+
+ gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ .then(() => {
+ this.loadingAssignees = false;
+ })
+ .catch(() => {
+ this.loadingAssignees = false;
+ return new Flash('An error occurred while saving assignees');
+ });
+ },
+ },
+ created () {
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
mounted () {
new IssuableContext(this.currentUser);
@@ -67,5 +120,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
components: {
removeBtn: gl.issueBoards.RemoveIssueBtn,
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index fc154ee7b8b..d3de1830895 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
@@ -31,18 +32,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false,
},
},
+ data() {
+ return {
+ limitBeforeCounter: 3,
+ maxRender: 4,
+ maxCounter: 99,
+ };
+ },
+ components: {
+ userAvatarLink,
+ },
computed: {
- cardUrl() {
- return `${this.issueLinkBase}/${this.issue.id}`;
+ numberOverLimit() {
+ return this.issue.assignees.length - this.limitBeforeCounter;
+ },
+ assigneeCounterTooltip() {
+ return `${this.assigneeCounterLabel} more`;
},
- assigneeUrl() {
- return `${this.rootPath}${this.issue.assignee.username}`;
+ assigneeCounterLabel() {
+ if (this.numberOverLimit > this.maxCounter) {
+ return `${this.maxCounter}+`;
+ }
+
+ return `+${this.numberOverLimit}`;
},
- assigneeUrlTitle() {
- return `Assigned to ${this.issue.assignee.name}`;
+ shouldRenderCounter() {
+ if (this.issue.assignees.length <= this.maxRender) {
+ return false;
+ }
+
+ return this.issue.assignees.length > this.numberOverLimit;
},
- avatarUrlTitle() {
- return `Avatar for ${this.issue.assignee.name}`;
+ cardUrl() {
+ return `${this.issueLinkBase}/${this.issue.id}`;
},
issueId() {
return `#${this.issue.id}`;
@@ -52,10 +74,31 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
},
methods: {
- showLabel(label) {
- if (!this.list) return true;
+ isIndexLessThanlimit(index) {
+ return index < this.limitBeforeCounter;
+ },
+ shouldRenderAssignee(index) {
+ // Eg. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return index < this.maxRender;
+ }
- return !this.list.label || label.id !== this.list.label.id;
+ return index < this.limitBeforeCounter;
+ },
+ assigneeUrl(assignee) {
+ return `${this.rootPath}${assignee.username}`;
+ },
+ assigneeUrlTitle(assignee) {
+ return `Assigned to ${assignee.name}`;
+ },
+ avatarUrlTitle(assignee) {
+ return `Avatar for ${assignee.name}`;
+ },
+ showLabel(label) {
+ if (!this.list || !label) return true;
+ return true;
},
filterByLabel(label, e) {
if (!this.updateFilters) return;
@@ -105,25 +148,33 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }}
</span>
</h4>
- <a
- class="card-assignee has-tooltip js-no-trigger"
- :href="assigneeUrl"
- :title="assigneeUrlTitle"
- v-if="issue.assignee"
- data-container="body"
- >
- <img
- class="avatar avatar-inline s20 js-no-trigger"
- :src="issue.assignee.avatar"
- width="20"
- height="20"
- :alt="avatarUrlTitle"
+ <div class="card-assignee">
+ <user-avatar-link
+ v-for="(assignee, index) in issue.assignees"
+ :key="assignee.id"
+ v-if="shouldRenderAssignee(index)"
+ class="js-no-trigger"
+ :link-href="assigneeUrl(assignee)"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatar"
+ :tooltip-text="assigneeUrlTitle(assignee)"
+ tooltip-placement="bottom"
/>
- </a>
+ <span
+ class="avatar-counter has-tooltip"
+ :title="assigneeCounterTooltip"
+ v-if="shouldRenderCounter"
+ >
+ {{ assigneeCounterLabel }}
+ </span>
+ </div>
</div>
- <div class="card-footer" v-if="showLabelFooter">
+ <div
+ class="card-footer"
+ v-if="showLabelFooter"
+ >
<button
- class="label color-label has-tooltip js-no-trigger"
+ class="label color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
index b214b5a7199..56a0fde5a91 100644
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ b/app/assets/javascripts/boards/components/modal/filters.js
@@ -13,6 +13,7 @@ export default {
FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store);
+ this.filteredSearch.setup();
this.filteredSearch.removeTokens();
this.filteredSearch.handleInputPlaceholder();
this.filteredSearch.toggleClearSearchButton();
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index ccd270b27da..478a1335b2b 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -2,8 +2,7 @@
/* global Flash */
import Vue from 'vue';
-
-require('./lists_dropdown');
+import './lists_dropdown';
const ModalStore = gl.issueBoards.ModalStore;
@@ -27,7 +26,8 @@ gl.issueBoards.ModalFooter = Vue.extend({
},
methods: {
addIssues() {
- const list = this.modal.selectedList || this.state.lists[0];
+ const firstListIndex = 1;
+ const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js
index e2b3f9ae7e2..31f59d295bf 100644
--- a/app/assets/javascripts/boards/components/modal/header.js
+++ b/app/assets/javascripts/boards/components/modal/header.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import modalFilters from './filters';
-
-require('./tabs');
+import './tabs';
const ModalStore = gl.issueBoards.ModalStore;
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index fdab317dc23..96af69e7a36 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -1,12 +1,12 @@
/* global ListIssue */
import Vue from 'vue';
-import queryData from '../../utils/query_data';
-
-require('./header');
-require('./list');
-require('./footer');
-require('./empty_state');
+import queryData from '~/boards/utils/query_data';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import './header';
+import './list';
+import './footer';
+import './empty_state';
const ModalStore = gl.issueBoards.ModalStore;
@@ -88,9 +88,9 @@ gl.issueBoards.IssuesModal = Vue.extend({
return gl.boardService.getBacklog(queryData(this.filter.path, {
page: this.page,
per: this.perPage,
- })).then((res) => {
- const data = res.json();
-
+ }))
+ .then(resp => resp.json())
+ .then((data) => {
if (clearIssues) {
this.issues = [];
}
@@ -108,6 +108,8 @@ gl.issueBoards.IssuesModal = Vue.extend({
if (!this.issuesCount) {
this.issuesCount = data.size;
}
+ }).catch(() => {
+ // TODO: handle request error
});
},
},
@@ -135,6 +137,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
'modal-list': gl.issueBoards.ModalList,
'modal-footer': gl.issueBoards.ModalFooter,
'empty-state': gl.issueBoards.ModalEmptyState,
+ loadingIcon,
},
template: `
<div
@@ -159,7 +162,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
class="add-issues-list text-center"
v-if="loading || filterLoading">
<div class="add-issues-list-loading">
- <i class="fa fa-spinner fa-spin"></i>
+ <loading-icon />
</div>
</section>
<modal-footer></modal-footer>
diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js
index 363269c0d5d..b4a45feee4d 100644
--- a/app/assets/javascripts/boards/components/modal/list.js
+++ b/app/assets/javascripts/boards/components/modal/list.js
@@ -1,7 +1,7 @@
/* global ListIssue */
-/* global bp */
import Vue from 'vue';
+import bp from '../../../breakpoints';
const ModalStore = gl.issueBoards.ModalStore;
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
index 8cd15df90fa..4684ea76647 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
@@ -11,7 +11,7 @@ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
},
computed: {
selected() {
- return this.modal.selectedList || this.state.lists[0];
+ return this.modal.selectedList || this.state.lists[1];
},
},
destroyed() {
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 7e3bb79af1d..72bb9e10fbc 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var,
promise/catch-or-return */
+import _ from 'underscore';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -52,7 +53,9 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
- clicked (label, $el, e) {
+ clicked (options) {
+ const { e } = options;
+ const label = options.selectedObj;
e.preventDefault();
if (!Store.findList('title', label.title)) {
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
index 5597f128b80..6a900d4abd0 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
@@ -46,8 +46,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
},
template: `
<div
- class="block list"
- v-if="list.type !== 'closed'">
+ class="block list">
<button
class="btn btn-default btn-block"
type="button"
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 1264280284c..3f083655f95 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -2,7 +2,7 @@
import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager {
- constructor(store, updateUrl = false) {
+ constructor(store, updateUrl = false, cantEdit = []) {
super('boards');
this.store = store;
@@ -11,6 +11,7 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
+ this.cantEdit = cantEdit;
}
updateObject(path) {
@@ -40,4 +41,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Get the placeholder back if search is empty
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
+
+ canEdit(tokenName) {
+ return this.cantEdit.indexOf(tokenName) === -1;
+ }
}
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
new file mode 100644
index 00000000000..05dd449e4fd
--- /dev/null
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -0,0 +1,12 @@
+/* eslint-disable no-unused-vars */
+
+class ListAssignee {
+ constructor(user, defaultAvatar) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url || defaultAvatar;
+ }
+}
+
+window.ListAssignee = ListAssignee;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d6175069e37..6c2d8a3781b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -1,12 +1,12 @@
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */
/* global ListMilestone */
-/* global ListUser */
+/* global ListAssignee */
import Vue from 'vue';
class ListIssue {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.globalId = obj.id;
this.id = obj.iid;
this.title = obj.title;
@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date;
this.subscribed = obj.subscribed;
this.labels = [];
+ this.assignees = [];
this.selected = false;
- this.assignee = false;
this.position = obj.relative_position || Infinity;
- if (obj.assignee) {
- this.assignee = new ListUser(obj.assignee);
- }
-
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
}
@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
+
+ this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
}
addLabel (label) {
@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this));
}
+ addAssignee (assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(new ListAssignee(assignee));
+ }
+ }
+
+ findAssignee (findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee (removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees () {
+ this.assignees = [];
+ }
+
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -60,7 +78,7 @@ class ListIssue {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate,
- assignee_id: this.assignee ? this.assignee.id : null,
+ assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
label_ids: this.labels.map((label) => label.id)
}
};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index f2b79a88a4a..08f7c5ddcd2 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -6,25 +6,30 @@ import queryData from '../utils/query_data';
const PER_PAGE = 20;
class List {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
- this.preset = ['closed', 'blank'].indexOf(this.type) > -1;
+ this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1;
+ this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1;
+ this.isExpanded = true;
this.page = 1;
this.loading = true;
this.loadingMore = false;
this.issues = [];
this.issuesSize = 0;
+ this.defaultAvatar = defaultAvatar;
if (obj.label) {
this.label = new ListLabel(obj.label);
}
if (this.type !== 'blank' && this.id) {
- this.getIssues();
+ this.getIssues().catch(() => {
+ // TODO: handle request error
+ });
}
}
@@ -35,9 +40,8 @@ class List {
save () {
return gl.boardService.createList(this.label.id)
- .then((resp) => {
- const data = resp.json();
-
+ .then(resp => resp.json())
+ .then((data) => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
@@ -51,11 +55,17 @@ class List {
gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
- gl.boardService.destroyList(this.id);
+ gl.boardService.destroyList(this.id)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
update () {
- gl.boardService.updateList(this.id, this.position);
+ gl.boardService.updateList(this.id, this.position)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
nextPage () {
@@ -80,8 +90,8 @@ class List {
}
return gl.boardService.getIssuesForList(this.id, data)
- .then((resp) => {
- const data = resp.json();
+ .then(resp => resp.json())
+ .then((data) => {
this.loading = false;
this.issuesSize = data.size;
@@ -94,19 +104,24 @@ class List {
}
newIssue (issue) {
- this.addIssue(issue);
+ this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
- .then((resp) => {
- const data = resp.json();
+ .then(resp => resp.json())
+ .then((data) => {
issue.id = data.iid;
+
+ if (this.issuesSize > 1) {
+ const moveBeforeIid = this.issues[1].id;
+ gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
+ }
});
}
createIssues (data) {
data.forEach((issueObj) => {
- this.addIssue(new ListIssue(issueObj));
+ this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
@@ -145,11 +160,17 @@ class List {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
- gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid);
+ gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
- gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid);
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
findIssue (id) {
diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js
deleted file mode 100644
index 8e9de4d4cbb..00000000000
--- a/app/assets/javascripts/boards/models/user.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-class ListUser {
- constructor(user) {
- this.id = user.id;
- this.name = user.name;
- this.username = user.username;
- this.avatar = user.avatar_url;
- }
-}
-
-window.ListUser = ListUser;
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index db9bced2f89..3742507b236 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -23,11 +23,6 @@ class BoardService {
url: bulkUpdatePath,
},
});
-
- Vue.http.interceptors.push((request, next) => {
- request.headers['X-CSRF-Token'] = $.rails.csrfToken();
- next();
- });
}
all () {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ccb00099215..43928e602d6 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,6 +1,6 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */
/* global List */
-
+import _ from 'underscore';
import Cookies from 'js-cookie';
window.gl = window.gl || {};
@@ -22,19 +22,24 @@ gl.issueBoards.BoardsStore = {
create () {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
+ this.detail = { issue: {} };
},
- addList (listObj) {
- const list = new List(listObj);
+ addList (listObj, defaultAvatar) {
+ const list = new List(listObj, defaultAvatar);
this.state.lists.push(list);
return list;
},
new (listObj) {
const list = this.addList(listObj);
+ const backlogList = this.findList('type', 'backlog', 'backlog');
list
.save()
.then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
this.state.lists = _.sortBy(this.state.lists, 'position');
})
.catch(() => {
@@ -47,7 +52,7 @@ gl.issueBoards.BoardsStore = {
},
shouldAddBlankState () {
// Decide whether to add the blank state
- return !(this.state.lists.filter(list => list.type !== 'closed')[0]);
+ return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
@@ -100,7 +105,7 @@ gl.issueBoards.BoardsStore = {
issueTo.removeLabel(listFrom.label);
}
- if (listTo.type === 'closed') {
+ if (listTo.type === 'closed' && listFrom.type !== 'backlog') {
issueLists.forEach((list) => {
list.removeIssue(issue);
});
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
new file mode 100644
index 00000000000..af8bcdc1794
--- /dev/null
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -0,0 +1,36 @@
+const MODAL_SELECTOR = '#modal-delete-branch';
+
+class DeleteModal {
+ constructor() {
+ this.$modal = $(MODAL_SELECTOR);
+ this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`);
+ this.$branchName = $('.js-branch-name', this.$modal);
+ this.$confirmInput = $('.js-delete-branch-input', this.$modal);
+ this.$deleteBtn = $('.js-delete-branch', this.$modal);
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.$toggleBtns.on('click', this.setModalData.bind(this));
+ this.$confirmInput.on('input', this.setDeleteDisabled.bind(this));
+ }
+
+ setModalData(e) {
+ this.branchName = e.currentTarget.dataset.branchName || '';
+ this.deletePath = e.currentTarget.dataset.deletePath || '';
+ this.updateModal();
+ }
+
+ setDeleteDisabled(e) {
+ this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName);
+ }
+
+ updateModal() {
+ this.$branchName.text(this.branchName);
+ this.$confirmInput.val('');
+ this.$deleteBtn.attr('href', this.deletePath);
+ this.$deleteBtn.attr('disabled', true);
+ }
+}
+
+export default DeleteModal;
diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js
index 2c1f988d987..7951348d8b2 100644
--- a/app/assets/javascripts/breakpoints.js
+++ b/app/assets/javascripts/breakpoints.js
@@ -1,66 +1,19 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, quotes, no-shadow, prefer-arrow-callback, prefer-template, consistent-return, no-return-assign, new-parens, no-param-reassign, max-len */
+export const breakpoints = {
+ lg: 1200,
+ md: 992,
+ sm: 768,
+ xs: 0,
+};
-var Breakpoints = (function() {
- var BreakpointInstance, instance;
+const BreakpointInstance = {
+ windowWidth: () => window.innerWidth,
+ getBreakpointSize() {
+ const windowWidth = this.windowWidth();
- function Breakpoints() {}
+ const breakpoint = Object.keys(breakpoints).find(key => windowWidth > breakpoints[key]);
- instance = null;
+ return breakpoint;
+ },
+};
- BreakpointInstance = (function() {
- var BREAKPOINTS;
-
- BREAKPOINTS = ["xs", "sm", "md", "lg"];
-
- function BreakpointInstance() {
- this.setup();
- }
-
- BreakpointInstance.prototype.setup = function() {
- var allDeviceSelector, els;
- allDeviceSelector = BREAKPOINTS.map(function(breakpoint) {
- return ".device-" + breakpoint;
- });
- if ($(allDeviceSelector.join(",")).length) {
- return;
- }
- // Create all the elements
- els = $.map(BREAKPOINTS, function(breakpoint) {
- return "<div class='device-" + breakpoint + " visible-" + breakpoint + "'></div>";
- });
- return $("body").append(els.join(''));
- };
-
- BreakpointInstance.prototype.visibleDevice = function() {
- var allDeviceSelector;
- allDeviceSelector = BREAKPOINTS.map(function(breakpoint) {
- return ".device-" + breakpoint;
- });
- return $(allDeviceSelector.join(",")).filter(":visible");
- };
-
- BreakpointInstance.prototype.getBreakpointSize = function() {
- var $visibleDevice;
- $visibleDevice = this.visibleDevice;
- // TODO: Consider refactoring in light of turbolinks removal.
- // the page refreshed via turbolinks
- if (!$visibleDevice().length) {
- this.setup();
- }
- $visibleDevice = this.visibleDevice();
- return $visibleDevice.attr("class").split("visible-")[1];
- };
-
- return BreakpointInstance;
- })();
-
- Breakpoints.get = function() {
- return instance != null ? instance : instance = new BreakpointInstance;
- };
-
- return Breakpoints;
-})();
-
-$(() => { window.bp = Breakpoints.get(); });
-
-window.Breakpoints = Breakpoints;
+export default BreakpointInstance;
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 97f279e4be4..ae1a23132a7 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -1,47 +1,38 @@
/* eslint-disable func-names, wrap-iife, no-use-before-define,
consistent-return, prefer-rest-params */
-/* global Breakpoints */
-
+import _ from 'underscore';
+import bp from './breakpoints';
import { bytesToKiB } from './lib/utils/number_utils';
-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 () {
Build.timeout = null;
-
Build.state = null;
function Build(options) {
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.logBytes = 0;
+ this.hasBeenScrolled = false;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.updateDropdown = this.updateDropdown.bind(this);
+ this.getBuildTrace = this.getBuildTrace.bind(this);
- this.$body = $('body');
this.$buildTrace = $('#build-trace');
- this.$autoScrollContainer = $('.autoscroll-container');
- this.$autoScrollStatus = $('#autoscroll-status');
- this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
- this.$upBuildTrace = $('#up-build-trace');
- this.$downBuildTrace = $(DOWN_BUILD_TRACE);
- this.$scrollTopBtn = $('#scroll-top');
- this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
- this.$buildScroll = $('#js-build-scroll');
this.$truncatedInfo = $('.js-truncated-info');
+ this.$buildTraceOutput = $('.js-build-output');
+ this.$topBar = $('.js-top-bar');
+
+ // Scroll controllers
+ this.$scrollTopBtn = $('.js-scroll-up');
+ this.$scrollBottomBtn = $('.js-scroll-down');
clearTimeout(Build.timeout);
- // Init breakpoint checker
- this.bp = Breakpoints.get();
this.initSidebar();
this.populateJobs(this.buildStage);
@@ -56,54 +47,141 @@ window.Build = (function () {
.off('click', '.stage-item')
.on('click', '.stage-item', this.updateDropdown);
- this.$document.on('scroll', this.initScrollMonitor.bind(this));
+ // add event listeners to the scroll buttons
+ this.$scrollTopBtn
+ .off('click')
+ .on('click', this.scrollToTop.bind(this));
+
+ this.$scrollBottomBtn
+ .off('click')
+ .on('click', this.scrollToBottom.bind(this));
+
+ this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
$(window)
- .off('resize.build')
- .on('resize.build', this.sidebarOnResize.bind(this));
+ .off('scroll')
+ .on('scroll', () => {
+ const contentHeight = this.$buildTraceOutput.height();
+ if (contentHeight > this.windowSize) {
+ // means the user did not scroll, the content was updated.
+ this.windowSize = contentHeight;
+ } else {
+ // User scrolled
+ this.hasBeenScrolled = true;
+ this.toggleScrollAnimation(false);
+ }
- $('a', this.$buildScroll)
- .off('click.stepTrace')
- .on('click.stepTrace', this.stepTrace);
+ this.scrollThrottled();
+ });
+
+ $(window)
+ .off('resize.build')
+ .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
this.updateArtifactRemoveDate();
- this.initScrollButtonAffix();
- this.invokeBuildTrace();
+ this.initAffixTopArea();
+
+ this.getBuildTrace();
}
- 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);
+ Build.prototype.initAffixTopArea = function () {
+ /**
+ If the browser does not support position sticky, it returns the position as static.
+ If the browser does support sticky, then we allow the browser to handle it, if not
+ then we default back to Bootstraps affix
+ **/
+ if (this.$topBar.css('position') !== 'static') return;
+
+ const offsetTop = this.$buildTrace.offset().top;
+
+ this.$topBar.affix({
+ offset: {
+ top: offsetTop,
+ },
+ });
};
- Build.prototype.invokeBuildTrace = function () {
- return this.getBuildTrace();
+ Build.prototype.canScroll = function () {
+ return $(document).height() > $(window).height();
+ };
+
+ Build.prototype.toggleScroll = function () {
+ const currentPosition = $(document).scrollTop();
+ const scrollHeight = $(document).height();
+
+ const windowHeight = $(window).height();
+ if (this.canScroll()) {
+ if (currentPosition > 0 &&
+ (scrollHeight - currentPosition !== windowHeight)) {
+ // User is in the middle of the log
+
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (currentPosition === 0) {
+ // User is at Top of Build Log
+
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (scrollHeight - currentPosition === windowHeight) {
+ // User is at the bottom of the build log.
+
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ }
+ } else {
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ }
+ };
+
+ Build.prototype.scrollDown = function () {
+ $(document).scrollTop($(document).height());
+ };
+
+ Build.prototype.scrollToBottom = function () {
+ this.scrollDown();
+ this.hasBeenScrolled = true;
+ this.toggleScroll();
+ };
+
+ Build.prototype.scrollToTop = function () {
+ $(document).scrollTop(0);
+ this.hasBeenScrolled = true;
+ this.toggleScroll();
+ };
+
+ Build.prototype.toggleDisableButton = function ($button, disable) {
+ if (disable && $button.prop('disabled')) return;
+ $button.prop('disabled', disable);
+ };
+
+ Build.prototype.toggleScrollAnimation = function (toggle) {
+ this.$scrollBottomBtn.toggleClass('animate', toggle);
+ };
+
+ Build.prototype.initSidebar = function () {
+ this.$sidebar = $('.js-build-sidebar');
};
Build.prototype.getBuildTrace = function () {
return $.ajax({
url: `${this.pageUrl}/trace.json`,
- dataType: 'json',
- data: {
- state: this.state,
- },
- success: ((log) => {
- const $buildContainer = $('.js-build-output');
-
+ data: this.state,
+ })
+ .done((log) => {
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
if (log.state) {
this.state = log.state;
}
+ this.windowSize = this.$buildTraceOutput.height();
+
if (log.append) {
- $buildContainer.append(log.html);
+ this.$buildTraceOutput.append(log.html);
this.logBytes += log.size;
} else {
- $buildContainer.html(log.html);
+ this.$buildTraceOutput.html(log.html);
this.logBytes = log.size;
}
@@ -114,157 +192,62 @@ window.Build = (function () {
const size = bytesToKiB(this.logBytes);
$('.js-truncated-info-size').html(`${size}`);
this.$truncatedInfo.removeClass('hidden');
- this.initAffixTruncatedInfo();
} else {
this.$truncatedInfo.addClass('hidden');
}
- this.checkAutoscroll();
-
if (!log.complete) {
+ if (!this.hasBeenScrolled) {
+ this.toggleScrollAnimation(true);
+ } else {
+ this.toggleScrollAnimation(false);
+ }
+
Build.timeout = setTimeout(() => {
- this.invokeBuildTrace();
+ this.getBuildTrace();
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
+ this.toggleScrollAnimation(false);
}
if (log.status !== this.buildStatus) {
- let pageUrl = this.pageUrl;
-
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- gl.utils.visitUrl(pageUrl);
+ gl.utils.visitUrl(this.pageUrl);
}
- }),
- error: () => {
+ })
+ .fail(() => {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
- },
- });
- };
-
- 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
- // but never scrolled a page
- if (!this.$scrollTopBtn.is(':visible') &&
- !this.$scrollBottomBtn.is(':visible') &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- this.$scrollBottomBtn.show();
- }
- };
-
- Build.prototype.initScrollButtonAffix = function () {
- // Hide everything initially
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
- this.$autoScrollContainer.hide();
- };
-
- // Page scroll listener to detect if user has scrolling page
- // and handle following cases
- // 1) User is at Top of Build Log;
- // - Hide Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- // 2) User is at Bottom of Build Log;
- // - Show Top Arrow button
- // - Hide Bottom Arrow button
- // - Enable Autoscroll and show indicator (when build is running)
- // 3) User is somewhere in middle of Build Log;
- // - 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))) {
- // 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))) {
- this.$scrollBottomBtn.show();
- } else {
- this.$scrollBottomBtn.hide();
- }
-
- // Hide Autoscroll Status Indicator
- if (this.$scrollBottomBtn.is(':visible')) {
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else {
- 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))) {
- // User is at Top of Build Log
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.show();
-
- 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)))) {
- // 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.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // Build Log height is small
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
-
- // Hide Autoscroll Status Indicator
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- }
-
- 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',
- );
- }
+ })
+ .then(() => {
+ if (!this.hasBeenScrolled) {
+ this.scrollDown();
+ }
+ })
+ .then(() => this.toggleScroll());
};
Build.prototype.shouldHideSidebarForViewport = function () {
- const bootstrapBreakpoint = this.bp.getBreakpointSize();
+ const bootstrapBreakpoint = bp.getBreakpointSize();
return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
};
Build.prototype.toggleSidebar = function (shouldHide) {
const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ const $toggleButton = $('.js-sidebar-build-toggle-header');
- 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)
+ this.$sidebar
+ .toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
+
+ this.$topBar
+ .toggleClass('sidebar-expanded', shouldShow)
+ .toggleClass('sidebar-collapsed', shouldHide);
+
+ if (this.$sidebar.hasClass('right-sidebar-expanded')) {
+ $toggleButton.addClass('hidden');
+ } else {
+ $toggleButton.removeClass('hidden');
+ }
};
Build.prototype.sidebarOnResize = function () {
@@ -301,24 +284,5 @@ window.Build = (function () {
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function (e) {
- e.preventDefault();
-
- const $currentTarget = $(e.currentTarget);
- $.scrollTo($currentTarget.attr('href'), {
- offset: 0,
- });
- };
-
- Build.prototype.initAffixTruncatedInfo = function () {
- const offsetTop = this.$buildTrace.offset().top;
-
- this.$truncatedInfo.affix({
- offset: {
- top: offsetTop,
- },
- });
- };
-
return Build;
})();
diff --git a/app/assets/javascripts/build_variables.js b/app/assets/javascripts/build_variables.js
index 99082b412e2..c955a9ac2ea 100644
--- a/app/assets/javascripts/build_variables.js
+++ b/app/assets/javascripts/build_variables.js
@@ -2,7 +2,7 @@
$(function() {
$('.reveal-variables').off('click').on('click', function() {
- $('.js-build').toggle().niceScroll();
+ $('.js-build-variables').toggle();
$(this).hide();
});
});
diff --git a/app/assets/javascripts/ci_status_icons.js b/app/assets/javascripts/ci_status_icons.js
deleted file mode 100644
index f16616873b2..00000000000
--- a/app/assets/javascripts/ci_status_icons.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
-import CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
-import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
-import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
-import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
-import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
-import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
-import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
-import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
-
-const StatusIconEntityMap = {
- icon_status_canceled: CANCELED_SVG,
- icon_status_created: CREATED_SVG,
- icon_status_failed: FAILED_SVG,
- icon_status_manual: MANUAL_SVG,
- icon_status_pending: PENDING_SVG,
- icon_status_running: RUNNING_SVG,
- icon_status_skipped: SKIPPED_SVG,
- icon_status_success: SUCCESS_SVG,
- icon_status_warning: WARNING_SVG,
-};
-
-export {
- CANCELED_SVG,
- CREATED_SVG,
- FAILED_SVG,
- MANUAL_SVG,
- PENDING_SVG,
- RUNNING_SVG,
- SKIPPED_SVG,
- SUCCESS_SVG,
- WARNING_SVG,
- StatusIconEntityMap as default,
-};
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
new file mode 100644
index 00000000000..882d20671cc
--- /dev/null
+++ b/app/assets/javascripts/close_reopen_report_toggle.js
@@ -0,0 +1,97 @@
+import DropLab from './droplab/drop_lab';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
+
+class CloseReopenReportToggle {
+ constructor(opts = {}) {
+ this.dropdownTrigger = opts.dropdownTrigger;
+ this.dropdownList = opts.dropdownList;
+ this.button = opts.button;
+ }
+
+ initDroplab() {
+ this.reopenItem = this.dropdownList.querySelector('.reopen-item');
+ this.closeItem = this.dropdownList.querySelector('.close-item');
+
+ this.droplab = new DropLab();
+
+ const config = this.setConfig();
+
+ this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
+ }
+
+ updateButton(isClosed) {
+ this.toggleButtonType(isClosed);
+
+ this.button.blur();
+ }
+
+ toggleButtonType(isClosed) {
+ const [showItem, hideItem] = this.getButtonTypes(isClosed);
+
+ showItem.classList.remove('hidden');
+ showItem.classList.add('droplab-item-selected');
+
+ hideItem.classList.add('hidden');
+ hideItem.classList.remove('droplab-item-selected');
+
+ showItem.click();
+ }
+
+ getButtonTypes(isClosed) {
+ return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem];
+ }
+
+ setDisable(shouldDisable = true) {
+ if (shouldDisable) {
+ this.button.setAttribute('disabled', 'true');
+ this.dropdownTrigger.setAttribute('disabled', 'true');
+ } else {
+ this.button.removeAttribute('disabled');
+ this.dropdownTrigger.removeAttribute('disabled');
+ }
+ }
+
+ setConfig() {
+ const config = {
+ InputSetter: [
+ {
+ input: this.button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'data-value',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'title',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-button-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: this.dropdownTrigger,
+ valueAttribute: 'data-toggle-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-url',
+ inputAttribute: 'href',
+ },
+ {
+ input: this.button,
+ valueAttribute: 'data-method',
+ inputAttribute: 'data-method',
+ },
+ ],
+ };
+
+ return config;
+ }
+}
+
+export default CloseReopenReportToggle;
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
index df0ba86198c..c74184949df 100644
--- a/app/assets/javascripts/comment_type_toggle.js
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -1,5 +1,8 @@
import DropLab from './droplab/drop_lab';
-import InputSetter from './droplab/plugins/input_setter';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
class CommentTypeToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 86d99dd87da..687f09882a7 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,29 +1,43 @@
-/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
-import VueResource from 'vue-resource';
-import CommitPipelinesTable from './pipelines_table';
-
-Vue.use(VueResource);
+import commitPipelinesTable from './pipelines_table.vue';
/**
- * Commits View > Pipelines Tab > Pipelines Table.
- *
- * Renders Pipelines table in pipelines tab in the commits show view.
+ * Used in:
+ * - Commit details View > Pipelines Tab > Pipelines Table.
+ * - Merge Request details View > Pipelines Tab > Pipelines Table.
+ * - New Merge Request View > Pipelines Tab > Pipelines Table.
*/
-// export for use in merge_request_tabs.js (TODO: remove this hack)
+const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
+
+// export for use in merge_request_tabs.js (TODO: remove this hack when we understand how to load
+// vue.js in merge_request_tabs.js)
window.gl = window.gl || {};
window.gl.CommitPipelinesTable = CommitPipelinesTable;
-$(() => {
- gl.commits = gl.commits || {};
- gl.commits.pipelines = gl.commits.pipelines || {};
-
+document.addEventListener('DOMContentLoaded', () => {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
- gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable().$mount();
- pipelineTableViewEl.appendChild(gl.commits.pipelines.PipelinesTableBundle.$el);
+ if (pipelineTableViewEl) {
+ // Update MR and Commits tabs
+ pipelineTableViewEl.addEventListener('update-pipelines-count', (event) => {
+ if (event.detail.pipelines &&
+ event.detail.pipelines.count &&
+ event.detail.pipelines.count.all) {
+ const badge = document.querySelector('.js-pipelines-mr-count');
+
+ badge.textContent = event.detail.pipelines.count.all;
+ }
+ });
+
+ if (pipelineTableViewEl.dataset.disableInitialization === undefined) {
+ const table = new CommitPipelinesTable({
+ propsData: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
+ },
+ }).$mount();
+ pipelineTableViewEl.appendChild(table.$el);
+ }
}
});
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
deleted file mode 100644
index e704be8b53e..00000000000
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import Vue from 'vue';
-import Visibility from 'visibilityjs';
-import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
-import PipelinesService from '../../pipelines/services/pipelines_service';
-import PipelineStore from '../../pipelines/stores/pipelines_store';
-import eventHub from '../../pipelines/event_hub';
-import EmptyState from '../../pipelines/components/empty_state.vue';
-import ErrorState from '../../pipelines/components/error_state.vue';
-import '../../lib/utils/common_utils';
-import '../../vue_shared/vue_resource_interceptor';
-import Poll from '../../lib/utils/poll';
-
-/**
- *
- * Uses `pipelines-table-component` to render Pipelines table with an API call.
- * Endpoint is provided in HTML and passed as `endpoint`.
- * We need a store to store the received environemnts.
- * We need a service to communicate with the server.
- *
- * Necessary SVG in the table are provided as props. This should be refactored
- * as soon as we have Webpack and can load them directly into JS files.
- */
-
-export default Vue.component('pipelines-table', {
-
- components: {
- 'pipelines-table-component': PipelinesTableComponent,
- 'error-state': ErrorState,
- 'empty-state': EmptyState,
- },
-
- /**
- * Accesses the DOM to provide the needed data.
- * Returns the necessary props to render `pipelines-table-component` component.
- *
- * @return {Object}
- */
- data() {
- const store = new PipelineStore();
-
- return {
- endpoint: null,
- helpPagePath: null,
- store,
- state: store.state,
- isLoading: false,
- hasError: false,
- isMakingRequest: false,
- };
- },
-
- computed: {
- shouldRenderErrorState() {
- return this.hasError && !this.isLoading;
- },
-
- shouldRenderEmptyState() {
- return !this.state.pipelines.length &&
- !this.isLoading &&
- !this.hasError;
- },
-
- shouldRenderTable() {
- return !this.isLoading &&
- this.state.pipelines.length > 0 &&
- !this.hasError;
- },
- },
-
- /**
- * When the component is about to be mounted, tell the service to fetch the data
- *
- * A request to fetch the pipelines will be made.
- * In case of a successfull response we will store the data in the provided
- * store, in case of a failed response we need to warn the user.
- *
- */
- beforeMount() {
- const element = document.querySelector('#commit-pipeline-table-view');
-
- this.endpoint = element.dataset.endpoint;
- this.helpPagePath = element.dataset.helpPagePath;
- this.service = new PipelinesService(this.endpoint);
-
- this.poll = new Poll({
- resource: this.service,
- method: 'getPipelines',
- successCallback: this.successCallback,
- errorCallback: this.errorCallback,
- notificationCallback: this.setIsMakingRequest,
- });
-
- if (!Visibility.hidden()) {
- this.isLoading = true;
- this.poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
-
- eventHub.$on('refreshPipelines', this.fetchPipelines);
- },
-
- beforeDestroyed() {
- eventHub.$off('refreshPipelines');
- },
-
- destroyed() {
- this.poll.stop();
- },
-
- methods: {
- fetchPipelines() {
- this.isLoading = true;
-
- return this.service.getPipelines()
- .then(response => this.successCallback(response))
- .catch(() => this.errorCallback());
- },
-
- successCallback(resp) {
- const response = resp.json();
-
- // depending of the endpoint the response can either bring a `pipelines` key or not.
- const pipelines = response.pipelines || response;
- this.store.storePipelines(pipelines);
- this.isLoading = false;
- },
-
- errorCallback() {
- this.hasError = true;
- this.isLoading = false;
- },
-
- setIsMakingRequest(isMakingRequest) {
- this.isMakingRequest = isMakingRequest;
- },
- },
-
- template: `
- <div class="content-list pipelines">
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
-
- <empty-state
- v-if="shouldRenderEmptyState"
- :help-page-path="helpPagePath" />
-
- <error-state v-if="shouldRenderErrorState" />
-
- <div
- class="table-holder"
- v-if="shouldRenderTable">
- <pipelines-table-component
- :pipelines="state.pipelines"
- :service="service" />
- </div>
- </div>
- `,
-});
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
new file mode 100644
index 00000000000..dd751ec97a8
--- /dev/null
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -0,0 +1,101 @@
+<script>
+ import PipelinesService from '../../pipelines/services/pipelines_service';
+ import PipelineStore from '../../pipelines/stores/pipelines_store';
+ import pipelinesMixin from '../../pipelines/mixins/pipelines';
+
+ export default {
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ mixins: [
+ pipelinesMixin,
+ ],
+
+ data() {
+ const store = new PipelineStore();
+
+ return {
+ store,
+ state: store.state,
+ };
+ },
+
+ computed: {
+ /**
+ * Empty state is only rendered if after the first request we receive no pipelines.
+ *
+ * @return {Boolean}
+ */
+ shouldRenderEmptyState() {
+ return !this.state.pipelines.length &&
+ !this.isLoading &&
+ this.hasMadeRequest &&
+ !this.hasError;
+ },
+
+ shouldRenderTable() {
+ return !this.isLoading &&
+ this.state.pipelines.length > 0 &&
+ !this.hasError;
+ },
+ },
+ created() {
+ this.service = new PipelinesService(this.endpoint);
+ },
+ methods: {
+ successCallback(resp) {
+ return resp.json().then((response) => {
+ // depending of the endpoint the response can either bring a `pipelines` key or not.
+ const pipelines = response.pipelines || response;
+ this.setCommonData(pipelines);
+
+ const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
+ detail: {
+ pipelines: response,
+ },
+ });
+
+ // notifiy to update the count in tabs
+ if (this.$el.parentElement) {
+ this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
+ }
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="content-list pipelines">
+
+ <loading-icon
+ label="Loading pipelines"
+ size="3"
+ v-if="isLoading"
+ />
+
+ <empty-state
+ v-if="shouldRenderEmptyState"
+ :help-page-path="helpPagePath"
+ />
+
+ <error-state
+ v-if="shouldRenderErrorState"
+ />
+
+ <div
+ class="table-holder"
+ v-if="shouldRenderTable">
+ <pipelines-table-component
+ :pipelines="state.pipelines"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index e3f9eaaf39c..047544b1762 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -7,6 +7,8 @@ window.CommitsList = (function() {
CommitsList.timer = null;
CommitsList.init = function(limit) {
+ this.$contentList = $('.content_list');
+
$("body").on("click", ".day-commits-table li.commit", function(e) {
if (e.target.nodeName !== "A") {
location.href = $(this).attr("url");
@@ -14,9 +16,9 @@ window.CommitsList = (function() {
return false;
}
});
- Pager.init(limit, false, false, function() {
- gl.utils.localTimeAgo($('.js-timeago'));
- });
+
+ Pager.init(parseInt(limit, 10), false, false, this.processCommits);
+
this.content = $("#commits-list");
this.searchField = $("#commits-search");
this.lastSearch = this.searchField.val();
@@ -62,5 +64,34 @@ window.CommitsList = (function() {
});
};
+ // Prepare loaded data.
+ CommitsList.processCommits = (data) => {
+ let processedData = data;
+ const $processedData = $(processedData);
+ const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const lastShownDay = $commitsHeadersLast.data('day');
+ const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first();
+ const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day');
+ let commitsCount;
+
+ // If commits headers show the same date,
+ // remove the last header and change the previous one.
+ if (lastShownDay === loadedShownDayFirst) {
+ // Last shown commits count under the last commits header.
+ commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length;
+
+ // Remove duplicate of commits header.
+ processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`);
+
+ // Update commits count in the previous commits header.
+ commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
+ $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
+ }
+
+ gl.utils.localTimeAgo($processedData.find('.js-timeago'));
+
+ return processedData;
+ };
+
return CommitsList;
})();
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index 36bfe457be9..c11b7d5f340 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -3,11 +3,13 @@ import $ from 'jquery';
// bootstrap jQuery plugins
import 'bootstrap-sass/assets/javascripts/bootstrap/affix';
import 'bootstrap-sass/assets/javascripts/bootstrap/alert';
+import 'bootstrap-sass/assets/javascripts/bootstrap/button';
import 'bootstrap-sass/assets/javascripts/bootstrap/dropdown';
import 'bootstrap-sass/assets/javascripts/bootstrap/modal';
import 'bootstrap-sass/assets/javascripts/bootstrap/tab';
import 'bootstrap-sass/assets/javascripts/bootstrap/transition';
import 'bootstrap-sass/assets/javascripts/bootstrap/tooltip';
+import 'bootstrap-sass/assets/javascripts/bootstrap/popover';
// custom jQuery functions
$.fn.extend({
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index 7063f59d446..6db8b3afbef 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,3 +1,4 @@
+import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index b53f6284afc..b93e94a3c97 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -6,6 +6,5 @@ import 'vendor/jquery.endless-scroll';
import 'vendor/jquery.caret';
import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
-import 'vendor/jquery.nicescroll';
import 'vendor/jquery.waitforimages';
import 'select2/select2';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index cb054a2a197..bc3e741f524 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,5 +1,6 @@
// ECMAScript polyfills
import 'core-js/fn/array/find';
+import 'core-js/fn/array/find-index';
import 'core-js/fn/array/from';
import 'core-js/fn/array/includes';
import 'core-js/fn/object/assign';
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js
index 570799c030e..13ba4a57293 100644
--- a/app/assets/javascripts/copy_as_gfm.js
+++ b/app/assets/javascripts/copy_as_gfm.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
-
-require('./lib/utils/common_utils');
+import _ from 'underscore';
+import './lib/utils/common_utils';
+import { placeholderImage } from './lazy_loader';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
@@ -18,12 +19,12 @@ const gfmRules = {
},
},
TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el, text) {
+ 'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
- '.tooltip'(el, text) {
+ '.tooltip'(el) {
return '';
},
'a.gfm:not([data-link=true])'(el, text) {
@@ -39,15 +40,15 @@ const gfmRules = {
},
},
TableOfContentsFilter: {
- 'ul.section-nav'(el, text) {
+ 'ul.section-nav'(el) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
- 'img.emoji'(el, text) {
+ 'img.emoji'(el) {
return el.getAttribute('alt');
},
- 'gl-emoji'(el, text) {
+ 'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`;
},
},
@@ -56,14 +57,19 @@ const gfmRules = {
return text;
},
},
+ ImageLazyLoadFilter: {
+ 'img'(el, text) {
+ return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
+ },
+ },
VideoLinkFilter: {
- '.video-container'(el, text) {
+ '.video-container'(el) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
- 'video'(el, text) {
+ 'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
@@ -74,19 +80,19 @@ const gfmRules = {
'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`;
},
- 'span.katex-display span.katex-mathml'(el, text) {
+ 'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
- 'span.katex-mathml'(el, text) {
+ 'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
- 'span.katex-html'(el, text) {
+ 'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text.
return '';
},
@@ -95,7 +101,7 @@ const gfmRules = {
},
},
SanitizationFilter: {
- 'a[name]:not([href]):empty'(el, text) {
+ 'a[name]:not([href]):empty'(el) {
return el.outerHTML;
},
'dl'(el, text) {
@@ -143,7 +149,7 @@ const gfmRules = {
},
},
MarkdownFilter: {
- 'br'(el, text) {
+ 'br'(el) {
// Two spaces at the end of a line are turned into a BR
return ' ';
},
@@ -162,8 +168,10 @@ const gfmRules = {
'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
- 'img'(el, text) {
- return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
+ 'img'(el) {
+ const imageSrc = el.src;
+ const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || '');
+ return `![${el.getAttribute('alt')}](${imageUrl})`;
},
'a.anchor'(el, text) {
// Don't render a Markdown link for the anchor link inside a heading
@@ -222,10 +230,10 @@ const gfmRules = {
'sup'(el, text) {
return `^${text}`;
},
- 'hr'(el, text) {
+ 'hr'(el) {
return '-----';
},
- 'table'(el, text) {
+ 'table'(el) {
const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false;
@@ -233,11 +241,11 @@ const gfmRules = {
const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
- return theadText + tbodyText;
+ return [theadText, tbodyText].join('\n');
},
'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => {
- let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2;
+ let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = '';
let after = '';
@@ -262,10 +270,15 @@ const gfmRules = {
return before + middle + after;
});
- return `${text}|${cells.join('|')}|`;
+ const separatorRow = `|${cells.join('|')}|`;
+
+ return [text, separatorRow].join('\n');
},
- 'tr'(el, text) {
- const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim());
+ 'tr'(el) {
+ const cellEls = el.querySelectorAll('td, th');
+ if (cellEls.length === 0) return false;
+
+ const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`;
},
},
@@ -273,12 +286,12 @@ const gfmRules = {
class CopyAsGFM {
constructor() {
- $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
- $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
- $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this));
+ $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
+ $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
+ $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
}
- copyAsGFM(e, transformer) {
+ static copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
@@ -292,26 +305,59 @@ class CopyAsGFM {
e.stopPropagation();
clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el));
+ clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
}
- pasteGFM(e) {
+ static pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
+ const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return;
e.preventDefault();
- window.gl.utils.insertText(e.target, gfm);
+ window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
+ // If the text before the cursor contains an odd number of backticks,
+ // we are either inside an inline code span that starts with 1 backtick
+ // or a code block that starts with 3 backticks.
+ // This logic still holds when there are one or more _closed_ code spans
+ // or blocks that will have 2 or 6 backticks.
+ // This will break down when the actual code block contains an uneven
+ // number of backticks, but this is a rare edge case.
+ const backtickMatch = textBefore.match(/`/g);
+ const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;
+
+ if (insideCodeBlock) {
+ return text;
+ }
+
+ return gfm;
+ });
}
static transformGFMSelection(documentFragment) {
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return null;
+ const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmEls.length) {
+ case 0: {
+ return documentFragment;
+ }
+ case 1: {
+ return gfmEls[0];
+ }
+ default: {
+ const allGfmEl = document.createElement('div');
+
+ for (let i = 0; i < gfmEls.length; i += 1) {
+ const lineEl = gfmEls[i];
+ allGfmEl.appendChild(lineEl);
+ allGfmEl.appendChild(document.createTextNode('\n\n'));
+ }
- return documentFragment;
+ return allGfmEl;
+ }
+ }
}
static transformCodeSelection(documentFragment) {
@@ -343,7 +389,7 @@ class CopyAsGFM {
return codeEl;
}
- static nodeToGFM(node) {
+ static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
@@ -352,7 +398,9 @@ class CopyAsGFM {
return node.textContent;
}
- const text = this.innerGFM(node);
+ const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
+
+ const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text;
@@ -366,7 +414,17 @@ class CopyAsGFM {
if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
- const result = func(node, text);
+ let result;
+ if (func.length === 2) {
+ // if `func` takes 2 arguments, it depends on text.
+ // if there is no text, we don't need to generate GFM for this node.
+ if (text.length === 0) continue;
+
+ result = func(node, text);
+ } else {
+ result = func(node);
+ }
+
if (result === false) continue;
return result;
@@ -376,7 +434,7 @@ class CopyAsGFM {
return text;
}
- static innerGFM(parentNode) {
+ static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true);
@@ -386,13 +444,19 @@ class CopyAsGFM {
const node = nodes[i];
const clonedNode = clonedNodes[i];
- const text = this.nodeToGFM(node);
+ const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
- return clonedParentNode.innerText || clonedParentNode.textContent;
+ let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
+
+ if (!respectWhitespace) {
+ nodeText = nodeText.trim();
+ }
+
+ return nodeText;
}
}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 121d64db789..907b468e576 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,5 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */
-/* global Api */
+import Api from './api';
class CreateLabelDropdown {
constructor ($el, namespacePath, projectPath) {
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
new file mode 100644
index 00000000000..ff2f2c81971
--- /dev/null
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -0,0 +1,193 @@
+/* eslint-disable no-new */
+/* global Flash */
+import DropLab from './droplab/drop_lab';
+import ISetter from './droplab/plugins/input_setter';
+
+// Todo: Remove this when fixing issue in input_setter plugin
+const InputSetter = Object.assign({}, ISetter);
+
+const CREATE_MERGE_REQUEST = 'create-mr';
+const CREATE_BRANCH = 'create-branch';
+
+export default class CreateMergeRequestDropdown {
+ constructor(wrapperEl) {
+ this.wrapperEl = wrapperEl;
+ this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
+ this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
+ this.availableButton = this.wrapperEl.querySelector('.available');
+ this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
+ this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
+ this.unavailableButtonText = this.unavailableButton.querySelector('.text');
+
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
+ this.createMrPath = this.wrapperEl.dataset.createMrPath;
+ this.droplabInitialized = false;
+ this.isCreatingMergeRequest = false;
+ this.mergeRequestCreated = false;
+ this.isCreatingBranch = false;
+ this.branchCreated = false;
+
+ this.init();
+ }
+
+ init() {
+ this.checkAbilityToCreateBranch();
+ }
+
+ available() {
+ this.availableButton.classList.remove('hide');
+ this.unavailableButton.classList.add('hide');
+ }
+
+ unavailable() {
+ this.availableButton.classList.add('hide');
+ this.unavailableButton.classList.remove('hide');
+ }
+
+ enable() {
+ this.createMergeRequestButton.classList.remove('disabled');
+ this.createMergeRequestButton.removeAttribute('disabled');
+
+ this.dropdownToggle.classList.remove('disabled');
+ this.dropdownToggle.removeAttribute('disabled');
+ }
+
+ disable() {
+ this.createMergeRequestButton.classList.add('disabled');
+ this.createMergeRequestButton.setAttribute('disabled', 'disabled');
+
+ this.dropdownToggle.classList.add('disabled');
+ this.dropdownToggle.setAttribute('disabled', 'disabled');
+ }
+
+ hide() {
+ this.wrapperEl.classList.add('hide');
+ }
+
+ setUnavailableButtonState(isLoading = true) {
+ if (isLoading) {
+ this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
+ this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = 'Checking branch availability…';
+ } else {
+ this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
+ this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = 'New branch unavailable';
+ }
+ }
+
+ checkAbilityToCreateBranch() {
+ return $.ajax({
+ type: 'GET',
+ dataType: 'json',
+ url: this.canCreatePath,
+ beforeSend: () => this.setUnavailableButtonState(),
+ })
+ .done((data) => {
+ this.setUnavailableButtonState(false);
+
+ if (data.can_create_branch) {
+ this.available();
+ this.enable();
+
+ if (!this.droplabInitialized) {
+ this.droplabInitialized = true;
+ this.initDroplab();
+ this.bindEvents();
+ }
+ } else if (data.has_related_branch) {
+ this.hide();
+ }
+ }).fail(() => {
+ this.unavailable();
+ this.disable();
+ new Flash('Failed to check if a new branch can be created.');
+ });
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
+ this.getDroplabConfig());
+ }
+
+ getDroplabConfig() {
+ return {
+ InputSetter: [{
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ }, {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-text',
+ }],
+ };
+ }
+
+ bindEvents() {
+ this.createMergeRequestButton
+ .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ }
+
+ isBusy() {
+ return this.isCreatingMergeRequest ||
+ this.mergeRequestCreated ||
+ this.isCreatingBranch ||
+ this.branchCreated;
+ }
+
+ onClickCreateMergeRequestButton(e) {
+ let xhr = null;
+ e.preventDefault();
+
+ if (this.isBusy()) {
+ return;
+ }
+
+ if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
+ xhr = this.createMergeRequest();
+ } else if (e.target.dataset.action === CREATE_BRANCH) {
+ xhr = this.createBranch();
+ }
+
+ xhr.fail(() => {
+ this.isCreatingMergeRequest = false;
+ this.isCreatingBranch = false;
+ });
+
+ xhr.always(() => this.enable());
+
+ this.disable();
+ }
+
+ createMergeRequest() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createMrPath,
+ beforeSend: () => (this.isCreatingMergeRequest = true),
+ })
+ .done((data) => {
+ this.mergeRequestCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create Merge Request. Please try again.'));
+ }
+
+ createBranch() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createBranchPath,
+ beforeSend: () => (this.isCreatingBranch = true),
+ })
+ .done((data) => {
+ this.branchCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
+ }
+}
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
index abe48572347..8d3d34f836f 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
@@ -9,9 +9,9 @@ export default {
<span v-if="count === 50" class="events-info pull-right">
<i class="fa fa-warning has-tooltip"
aria-hidden="true"
- title="Limited to showing 50 events at most"
+ :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
data-placement="top"></i>
- Showing 50 events
+ {{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
`,
};
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
index 80bd2df6f42..7c32a38fbe7 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
@@ -28,11 +33,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
</div>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
index 20a43798fbe..5f4a0ac8590 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
index f33cac3da82..11fee5410d9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconCommit from '../svg/icon_commit.svg';
const global = window.gl || (window.gl = {});
@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
items: Array,
stage: Object,
},
-
+ components: {
+ userAvatarImage,
+ },
data() {
return { iconCommit };
},
-
template: `
<div>
<div class="events-description">
@@ -24,17 +26,18 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component">
- <img class="avatar" :src="commit.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="commit.author.avatarUrl"/>
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
</a>
</h5>
<span>
- First
+ {{ s__('FirstPushedBy|First') }}
<span class="commit-icon">${iconCommit}</span>
- <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
- pushed by
+ <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a>
+ {{ s__('FirstPushedBy|pushed by') }}
<a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
index 657f5385374..b7ba9360f70 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
index 8a801300647..f41a0d0e4ff 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
<template v-if="mergeRequest.state === 'closed'">
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
index 4a286379588..d7c906c9d39 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg';
const global = window.gl || (window.gl = {});
@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
data() {
return { iconBranch };
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -22,17 +26,18 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
- <img class="avatar" :src="build.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="build.author.avatarUrl"/>
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
index e306026429e..78cc97eea0b 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
@@ -29,9 +29,9 @@ global.cycleAnalytics.StageTestComponent = Vue.extend({
&middot;
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="issue-date">
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
index 77edcb76273..d5e6167b2a8 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
@@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
template: `
<span class="total-time">
<template v-if="Object.keys(time).length">
- <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
- <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
- <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
- <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
+ <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template>
+ <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template>
+ <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template>
+ <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template>
</template>
<template v-else>
--
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 48cab437e02..6583e471a48 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -2,19 +2,20 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
+import Translate from '../vue_shared/translate';
import LimitWarningComponent from './components/limit_warning_component';
+import './components/stage_code_component';
+import './components/stage_issue_component';
+import './components/stage_plan_component';
+import './components/stage_production_component';
+import './components/stage_review_component';
+import './components/stage_staging_component';
+import './components/stage_test_component';
+import './components/total_time_component';
+import './cycle_analytics_service';
+import './cycle_analytics_store';
-require('./components/stage_code_component');
-require('./components/stage_issue_component');
-require('./components/stage_plan_component');
-require('./components/stage_production_component');
-require('./components/stage_review_component');
-require('./components/stage_staging_component');
-require('./components/stage_test_component');
-require('./components/total_time_component');
-require('./cycle_analytics_service');
-require('./cycle_analytics_store');
-require('./default_event_objects');
+Vue.use(Translate);
$(() => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
@@ -91,7 +92,7 @@ $(() => {
});
},
selectDefaultStage() {
- const stage = this.state.stages.first();
+ const stage = this.state.stages[0];
this.selectStage(stage);
},
selectStage(stage) {
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
index 681d6eef565..6504d7db2f2 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
@@ -30,7 +30,7 @@ class CycleAnalyticsService {
startDate,
} = options;
- return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
+ return $.get(`${this.requestPath}/events/${stage.name}.json`, {
cycle_analytics: {
start_date: startDate,
},
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 6536a8fd7fa..991f8c1f6fd 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,19 +1,20 @@
/* eslint-disable no-param-reassign */
-require('../lib/utils/text_utility');
-const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
+import { __ } from '../locale';
+import '../lib/utils/text_utility';
+import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
const EMPTY_STAGE_TEXTS = {
- issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
- plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
- code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
- test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
- review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
- staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
- production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
+ issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'),
+ plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'),
+ code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'),
+ test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'),
+ review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'),
+ staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'),
+ production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'),
};
global.cycleAnalytics.CycleAnalyticsStore = {
@@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = {
});
newData.stages.forEach((item) => {
- const stageSlug = gl.text.dasherize(item.title.toLowerCase());
+ const stageSlug = gl.text.dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js
index cfaf9835bf8..57f9019d2f8 100644
--- a/app/assets/javascripts/cycle_analytics/default_event_objects.js
+++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
issue: {
created_at: '',
url: '',
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
new file mode 100644
index 00000000000..3f993213dd0
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -0,0 +1,55 @@
+<script>
+ import eventHub from '../eventhub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ btnCssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
+ },
+
+ components: {
+ loadingIcon,
+ },
+
+ methods: {
+ doAction() {
+ this.isLoading = true;
+
+ eventHub.$emit(`${this.type}.key`, this.deployKey);
+ },
+ },
+ computed: {
+ text() {
+ return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ class="btn btn-sm prepend-left-10"
+ :class="[{ disabled: isLoading }, btnCssClass]"
+ :disabled="isLoading"
+ @click="doAction">
+ {{ text }}
+ <loading-icon v-if="isLoading" />
+ </button>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
new file mode 100644
index 00000000000..a663e30dfd0
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -0,0 +1,106 @@
+<script>
+ /* global Flash */
+ import eventHub from '../eventhub';
+ import DeployKeysService from '../service';
+ import DeployKeysStore from '../store';
+ import keysPanel from './keys_panel.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ store: new DeployKeysStore(),
+ };
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ hasKeys() {
+ return Object.keys(this.keys).length;
+ },
+ keys() {
+ return this.store.keys;
+ },
+ },
+ components: {
+ keysPanel,
+ loadingIcon,
+ },
+ methods: {
+ fetchKeys() {
+ this.isLoading = true;
+
+ this.service.getKeys()
+ .then((data) => {
+ this.isLoading = false;
+ this.store.keys = data;
+ })
+ .catch(() => new Flash('Error getting deploy keys'));
+ },
+ enableKey(deployKey) {
+ this.service.enableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error enabling deploy key'));
+ },
+ disableKey(deployKey) {
+ // eslint-disable-next-line no-alert
+ if (confirm('You are going to remove this deploy key. Are you sure?')) {
+ this.service.disableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error removing deploy key'));
+ }
+ },
+ },
+ created() {
+ this.service = new DeployKeysService(this.endpoint);
+
+ eventHub.$on('enable.key', this.enableKey);
+ eventHub.$on('remove.key', this.disableKey);
+ eventHub.$on('disable.key', this.disableKey);
+ },
+ mounted() {
+ this.fetchKeys();
+ },
+ beforeDestroy() {
+ eventHub.$off('enable.key', this.enableKey);
+ eventHub.$off('remove.key', this.disableKey);
+ eventHub.$off('disable.key', this.disableKey);
+ },
+ };
+</script>
+
+<template>
+ <div class="append-bottom-default deploy-keys">
+ <loading-icon
+ v-if="isLoading && !hasKeys"
+ size="2"
+ label="Loading deploy keys"
+ />
+ <div v-else-if="hasKeys">
+ <keys-panel
+ title="Enabled deploy keys for this project"
+ :keys="keys.enabled_keys"
+ :store="store"
+ :endpoint="endpoint"
+ />
+ <keys-panel
+ title="Deploy keys from projects you have access to"
+ :keys="keys.available_project_keys"
+ :store="store"
+ :endpoint="endpoint"
+ />
+ <keys-panel
+ v-if="keys.public_keys.length"
+ title="Public deploy keys available to any project"
+ :keys="keys.public_keys"
+ :store="store"
+ :endpoint="endpoint"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
new file mode 100644
index 00000000000..904f7f64fa8
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -0,0 +1,100 @@
+<script>
+ import actionBtn from './action_btn.vue';
+
+ export default {
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ actionBtn,
+ },
+ computed: {
+ timeagoDate() {
+ return gl.utils.getTimeago().format(this.deployKey.created_at);
+ },
+ editDeployKeyPath() {
+ return `${this.endpoint}/${this.deployKey.id}/edit`;
+ },
+ },
+ methods: {
+ isEnabled(id) {
+ return this.store.findEnabledKey(id) !== undefined;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="pull-left append-right-10 hidden-xs">
+ <i
+ aria-hidden="true"
+ class="fa fa-key key-icon"
+ >
+ </i>
+ </div>
+ <div class="deploy-key-content key-list-item-info">
+ <strong class="title">
+ {{ deployKey.title }}
+ </strong>
+ <div class="description">
+ {{ deployKey.fingerprint }}
+ </div>
+ <div
+ v-if="deployKey.can_push"
+ class="write-access-allowed"
+ >
+ Write access allowed
+ </div>
+ </div>
+ <div class="deploy-key-content prepend-left-default deploy-key-projects">
+ <a
+ v-for="project in deployKey.projects"
+ class="label deploy-project-label"
+ :href="project.full_path"
+ >
+ {{ project.full_name }}
+ </a>
+ </div>
+ <div class="deploy-key-content">
+ <span class="key-created-at">
+ created {{ timeagoDate }}
+ </span>
+ <a
+ v-if="deployKey.can_edit"
+ class="btn btn-small"
+ :href="editDeployKeyPath"
+ >
+ Edit
+ </a>
+ <action-btn
+ v-if="!isEnabled(deployKey.id)"
+ :deploy-key="deployKey"
+ type="enable"
+ />
+ <action-btn
+ v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="remove"
+ />
+ <action-btn
+ v-else
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="disable"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
new file mode 100644
index 00000000000..9e6fb244af6
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -0,0 +1,60 @@
+<script>
+ import key from './key.vue';
+
+ export default {
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ showHelpBox: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ key,
+ },
+ };
+</script>
+
+<template>
+ <div class="deploy-keys-panel">
+ <h5>
+ {{ title }}
+ ({{ keys.length }})
+ </h5>
+ <ul class="well-list"
+ v-if="keys.length"
+ >
+ <li
+ v-for="deployKey in keys"
+ :key="deployKey.id">
+ <key
+ :deploy-key="deployKey"
+ :store="store"
+ :endpoint="endpoint"
+ />
+ </li>
+ </ul>
+ <div
+ class="settings-message text-center"
+ v-else-if="showHelpBox"
+ >
+ No deploy keys found. Create one with the form above.
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
new file mode 100644
index 00000000000..a5f232f950a
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import deployKeysApp from './components/app.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: document.getElementById('js-deploy-keys'),
+ data() {
+ return {
+ endpoint: this.$options.el.dataset.endpoint,
+ };
+ },
+ components: {
+ deployKeysApp,
+ },
+ render(createElement) {
+ return createElement('deploy-keys-app', {
+ props: {
+ endpoint: this.endpoint,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js
new file mode 100644
index 00000000000..fe6dbaa9498
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/service/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class DeployKeysService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, {
+ enable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/enable`,
+ },
+ disable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/disable`,
+ },
+ });
+ }
+
+ getKeys() {
+ return this.resource.get()
+ .then(response => response.json());
+ }
+
+ enableKey(id) {
+ return this.resource.enable({ id }, {});
+ }
+
+ disableKey(id) {
+ return this.resource.disable({ id }, {});
+ }
+}
diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js
new file mode 100644
index 00000000000..6210361af26
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/store/index.js
@@ -0,0 +1,9 @@
+export default class DeployKeysStore {
+ constructor() {
+ this.keys = {};
+ }
+
+ findEnabledKey(id) {
+ return this.keys.enabled_keys.find(key => key.id === id);
+ }
+}
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 5aa3eb46a69..6a008112203 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,6 +1,8 @@
/* eslint-disable class-methods-use-this */
-require('./lib/utils/url_utility');
+import './lib/utils/url_utility';
+import FilesCommentButton from './files_comment_button';
+import SingleFileDiff from './single_file_diff';
const UNFOLD_COUNT = 20;
let isBound = false;
@@ -8,8 +10,14 @@ let isBound = false;
class Diff {
constructor() {
const $diffFile = $('.files .diff-file');
- $diffFile.singleFileDiff();
- $diffFile.filesCommentButton();
+
+ $diffFile.each((index, file) => {
+ if (!$.data(file, 'singleFileDiff')) {
+ $.data(file, 'singleFileDiff', new SingleFileDiff(file));
+ }
+ });
+
+ FilesCommentButton.init($diffFile);
$diffFile.each((index, file) => new gl.ImageFile(file));
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index f3a688fbf2f..c37249c060a 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -3,6 +3,7 @@
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({
props: ['discussionId'],
@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
collapseIcon,
};
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div class="diff-comment-avatar-holders"
v-show="notesCount !== 0">
<div v-if="!isVisible">
- <img v-for="note in notesSubset"
- class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar"
- width="19"
- height="19"
- role="button"
- data-container="body"
- data-placement="top"
- data-html="true"
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image
+ v-for="note in notesSubset"
+ class="diff-comment-avatar js-diff-comment-avatar"
+ @click.native="clickedAvatar($event)"
+ :img-src="note.authorAvatar"
+ :tooltip-text="getTooltipText(note)"
:data-line-type="lineType"
- :title="note.authorName + ': ' + note.noteTruncated"
- :src="note.authorAvatar"
- @click="clickedAvatar($event)" />
+ :size="19"
+ data-html="true"
+ />
<span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body"
@@ -120,7 +123,7 @@ const DiffNoteAvatars = Vue.extend({
},
methods: {
clickedAvatar(e) {
- notes.addDiffNote(e);
+ notes.onAddDiffNote(e);
// Toggle the active state of the toggle all button
this.toggleDiscussionsToggleState();
@@ -136,9 +139,9 @@ const DiffNoteAvatars = Vue.extend({
const notesCount = this.notesCount;
$(this.$el).closest('.js-avatar-container')
- .toggleClass('js-no-comment-btn', notesCount > 0)
+ .toggleClass('no-comment-btn', notesCount > 0)
.nextUntil('.js-avatar-container')
- .toggleClass('js-no-comment-btn', notesCount > 0);
+ .toggleClass('no-comment-btn', notesCount > 0);
},
toggleDiscussionsToggleState() {
const $notesHolders = $(this.$el).closest('.code').find('.notes_holder');
@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
},
+ getTooltipText(note) {
+ return `${note.authorName}: ${note.noteTruncated}`;
+ },
},
});
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8a0fd3bb4a7..298f737a2bc 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({
};
},
computed: {
+ buttonText: function () {
+ if (this.discussionId) {
+ return 'Jump to next unresolved discussion';
+ } else {
+ return 'Jump to first unresolved discussion';
+ }
+ },
allResolved: function () {
return this.unresolvedDiscussionCount === 0;
},
@@ -87,7 +94,7 @@ const JumpToDiscussion = Vue.extend({
hasDiscussionsToJumpTo = false;
}
}
- } else if (activeTab !== 'notes') {
+ } else if (activeTab !== 'show') {
// If we are on the commits or builds tabs,
// there are no discussions to jump to.
hasDiscussionsToJumpTo = false;
@@ -96,12 +103,12 @@ const JumpToDiscussion = Vue.extend({
if (!hasDiscussionsToJumpTo) {
// If there are no discussions to jump to on the current page,
// switch to the notes tab and jump to the first disucssion there.
- window.mrTabs.activateTab('notes');
- activeTab = 'notes';
+ window.mrTabs.activateTab('show');
+ activeTab = 'show';
jumpToFirstDiscussion = true;
}
- if (activeTab === 'notes') {
+ if (activeTab === 'show') {
discussionsSelector = '.discussion[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
}
@@ -149,7 +156,7 @@ const JumpToDiscussion = Vue.extend({
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
- if (activeTab === 'notes') {
+ if (activeTab === 'show') {
$target = $target.closest('.note-discussion');
// If the next discussion is closed, toggle it open.
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index 92f6fd654b3..efb6ced9f46 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -1,4 +1,4 @@
-/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, no-new, max-len */
+/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
/* global Flash */
@@ -64,8 +64,6 @@ const ResolveBtn = Vue.extend({
});
},
resolve: function () {
- const errorFlashMsg = 'An error occurred when trying to resolve a comment. Please try again.';
-
if (!this.canResolve) return;
let promise;
@@ -79,23 +77,20 @@ const ResolveBtn = Vue.extend({
.resolve(this.noteId);
}
- promise.then((response) => {
- this.loading = false;
+ promise
+ .then(resp => resp.json())
+ .then((data) => {
+ this.loading = false;
- if (response.status === 200) {
- const data = response.json();
const resolved_by = data ? data.resolved_by : null;
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
- } else {
- new Flash(errorFlashMsg);
- }
+ gl.mrWidget.checkStatus();
- this.updateTooltip();
- }).catch(() => {
- new Flash(errorFlashMsg);
- });
+ this.updateTooltip();
+ })
+ .catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.'));
}
},
mounted: function () {
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index b6b47e2da6f..5decfc1dc01 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -2,19 +2,18 @@
/* global ResolveCount */
import Vue from 'vue';
-
-require('./models/discussion');
-require('./models/note');
-require('./stores/comments');
-require('./services/resolve');
-require('./mixins/discussion');
-require('./components/comment_resolve_btn');
-require('./components/jump_to_discussion');
-require('./components/resolve_btn');
-require('./components/resolve_count');
-require('./components/resolve_discussion_btn');
-require('./components/diff_note_avatars');
-require('./components/new_issue_for_discussion');
+import './models/discussion';
+import './models/note';
+import './stores/comments';
+import './services/resolve';
+import './mixins/discussion';
+import './components/comment_resolve_btn';
+import './components/jump_to_discussion';
+import './components/resolve_btn';
+import './components/resolve_count';
+import './components/resolve_discussion_btn';
+import './components/diff_note_avatars';
+import './components/new_issue_for_discussion';
$(() => {
const projectPath = document.querySelector('.merge-request').dataset.projectPath;
@@ -43,6 +42,10 @@ $(() => {
$components.each(function () {
const $this = $(this);
const noteId = $this.attr(':note-id');
+ const discussionId = $this.attr(':discussion-id');
+
+ if ($this.is('comment-and-resolve-btn') && !discussionId) return;
+
const tmp = Vue.extend({
template: $this.get(0).outerHTML
});
@@ -65,4 +68,6 @@ $(() => {
'resolve-count': ResolveCount
}
});
+
+ $(window).trigger('resize.nav');
});
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 4ea6ba8a73d..2f063f6fe1f 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -1,13 +1,8 @@
-/* eslint-disable class-methods-use-this, one-var, camelcase, no-new, comma-dangle, no-param-reassign, max-len */
/* global Flash */
/* global CommentsStore */
import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('../../vue_shared/vue_resource_interceptor');
-
-Vue.use(VueResource);
+import '../../vue_shared/vue_resource_interceptor';
window.gl = window.gl || {};
@@ -36,26 +31,22 @@ class ResolveServiceClass {
promise = this.resolveAll(mergeRequestId, discussionId);
}
- promise.then((response) => {
- discussion.loading = false;
-
- if (response.status === 200) {
- const data = response.json();
- const resolved_by = data ? data.resolved_by : null;
+ promise
+ .then(resp => resp.json())
+ .then((data) => {
+ discussion.loading = false;
+ const resolvedBy = data ? data.resolved_by : null;
if (isResolved) {
discussion.unResolveAllNotes();
} else {
- discussion.resolveAllNotes(resolved_by);
+ discussion.resolveAllNotes(resolvedBy);
}
+ gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
- } else {
- throw new Error('An error occurred when trying to resolve discussion.');
- }
- }).catch(() => {
- new Flash('An error occurred when trying to resolve a discussion. Please try again.');
- });
+ })
+ .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
resolveAll(mergeRequestId, discussionId) {
@@ -65,7 +56,7 @@ class ResolveServiceClass {
return this.discussionResource.save({
mergeRequestId,
- discussionId
+ discussionId,
}, {});
}
@@ -76,7 +67,7 @@ class ResolveServiceClass {
return this.discussionResource.delete({
mergeRequestId,
- discussionId
+ discussionId,
}, {});
}
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index b20673cd03c..a0ed5c23ffe 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,21 +1,17 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
-/* global UsernameValidator */
-/* global ActiveTabMemoizer */
+/* global ProjectSelect */
/* global ShortcutsNavigation */
-/* global Build */
-/* global Issuable */
+/* global IssuableIndex */
/* global ShortcutsIssuable */
-/* global ZenMode */
/* global Milestone */
/* global IssuableForm */
/* global LabelsSelect */
/* global MilestoneSelect */
-/* global MergedButtons */
/* global Commit */
+/* global CommitsList */
+/* global NewBranchForm */
/* global NotificationsForm */
-/* global TreeView */
/* global NotificationsDropdown */
-/* global UsersSelect */
/* global GroupAvatar */
/* global LineHighlighter */
/* global ProjectFork */
@@ -24,40 +20,67 @@
/* global Search */
/* global Admin */
/* global NamespaceSelects */
+/* global NewCommitForm */
+/* global NewBranchForm */
/* global Project */
/* global ProjectAvatar */
+/* global MergeRequest */
+/* global Compare */
/* global CompareAutocomplete */
+/* global ProjectFindFile */
/* global ProjectNew */
-/* global Star */
/* global ProjectShow */
+/* global ProjectImport */
/* global Labels */
/* global Shortcuts */
+/* global ShortcutsFindFile */
/* global Sidebar */
/* global ShortcutsWiki */
import Issue from './issue';
-
import BindInOut from './behaviors/bind_in_out';
+import DeleteModal from './branches/branches_delete_modal';
import Group from './group';
import GroupName from './group_name';
import GroupsList from './groups_list';
import ProjectsList from './projects_list';
+import setupProjectEdit from './project_edit';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
+import Landing from './landing';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
-import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki';
-
-const ShortcutsBlob = require('./shortcuts_blob');
+import Pipelines from './pipelines';
+import BlobViewer from './blob/viewer/index';
+import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
+import UsersSelect from './users_select';
+import RefSelectDropdown from './ref_select_dropdown';
+import GfmAutoComplete from './gfm_auto_complete';
+import ShortcutsBlob from './shortcuts_blob';
+import SigninTabsMemoizer from './signin_tabs_memoizer';
+import Star from './star';
+import Todos from './todos';
+import TreeView from './tree';
+import UsagePing from './usage_ping';
+import UsernameValidator from './username_validator';
+import VersionCheckImage from './version_check_image';
+import Wikis from './wikis';
+import ZenMode from './zen_mode';
+import initSettingsPanels from './settings_panels';
+import initExperimentalFlags from './experimental_flags';
+import OAuthRememberMe from './oauth_remember_me';
+import PerformanceBar from './performance_bar';
+import initNotes from './init_notes';
+import initLegacyFilters from './init_legacy_filters';
+import initIssuableSidebar from './init_issuable_sidebar';
+import GpgBadges from './gpg_badges';
+import UserFeatureHelper from './helpers/user_feature_helper';
+import initChangesDropdown from './init_changes_dropdown';
(function() {
var Dispatcher;
- $(function() {
- return new Dispatcher();
- });
-
Dispatcher = (function() {
function Dispatcher() {
this.initSearch();
@@ -71,9 +94,23 @@ const ShortcutsBlob = require('./shortcuts_blob');
if (!page) {
return false;
}
+
path = page.split(':');
shortcut_handler = null;
+ $('.js-gfm-input').each((i, el) => {
+ const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
+ const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
+ gfm.setup($(el), {
+ emojis: true,
+ members: enableGFM,
+ issues: enableGFM,
+ milestones: enableGFM,
+ mergeRequests: enableGFM,
+ labels: enableGFM,
+ });
+ });
+
function initBlob() {
new LineHighlighter();
@@ -101,33 +138,43 @@ const ShortcutsBlob = require('./shortcuts_blob');
.init();
}
+ const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search');
+
switch (page) {
+ case 'profiles:preferences:show':
+ initExperimentalFlags();
+ break;
case 'sessions:new':
new UsernameValidator();
- new ActiveTabMemoizer();
+ new SigninTabsMemoizer();
+ new OAuthRememberMe({ container: $(".omniauth-container") }).bindEvents();
break;
case 'projects:boards:show':
case 'projects:boards:index':
shortcut_handler = new ShortcutsNavigation();
- break;
- case 'projects:builds:show':
- new Build();
+ new UsersSelect();
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
- if (gl.FilteredSearchManager) {
- new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ if (filteredSearchEnabled) {
+ const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
- });
+ const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
+ IssuableIndex.init(pagePrefix);
+
shortcut_handler = new ShortcutsNavigation();
+ new UsersSelect();
break;
case 'projects:issues:show':
new Issue();
shortcut_handler = new ShortcutsIssuable();
new ZenMode();
+ initIssuableSidebar();
+ initNotes();
+ break;
+ case 'dashboard:milestones:index':
+ new ProjectSelect();
break;
case 'projects:milestones:show':
case 'groups:milestones:show':
@@ -135,8 +182,21 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Milestone();
new Sidebar();
break;
+ case 'dashboard:issues':
+ case 'dashboard:merge_requests':
+ case 'groups:merge_requests':
+ new ProjectSelect();
+ initLegacyFilters();
+ break;
+ case 'groups:issues':
+ if (filteredSearchEnabled) {
+ const filteredSearchManager = new gl.FilteredSearchManager('issues');
+ filteredSearchManager.setup();
+ }
+ new ProjectSelect();
+ break;
case 'dashboard:todos:index':
- new gl.Todos();
+ new Todos();
break;
case 'dashboard:projects:index':
case 'dashboard:projects:starred':
@@ -146,9 +206,16 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'admin:projects:index':
new ProjectsList();
break;
- case 'dashboard:groups:index':
case 'explore:groups:index':
new GroupsList();
+ const landingElement = document.querySelector('.js-explore-groups-landing');
+ if (!landingElement) break;
+ const exploreGroupsLanding = new Landing(
+ landingElement,
+ landingElement.querySelector('.dismiss-button'),
+ 'explore_groups_landing_dismissed',
+ );
+ exploreGroupsLanding.toggle();
break;
case 'projects:milestones:new':
case 'projects:milestones:edit':
@@ -158,55 +225,90 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'groups:milestones:update':
new ZenMode();
new gl.DueDateSelectors();
- new gl.GLForm($('.milestone-form'));
+ new gl.GLForm($('.milestone-form'), true);
break;
case 'projects:compare:show':
new gl.Diff();
+ initChangesDropdown();
+ break;
+ case 'projects:branches:new':
+ case 'projects:branches:create':
+ new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML));
break;
case 'projects:branches:index':
gl.AjaxLoadingSpinner.init();
+ new DeleteModal();
break;
case 'projects:issues:new':
case 'projects:issues:edit':
shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.issue-form'));
+ new gl.GLForm($('.issue-form'), true);
new IssuableForm($('.issue-form'));
new LabelsSelect();
new MilestoneSelect();
new gl.IssuableTemplateSelectors();
break;
- case 'projects:merge_requests:new':
- case 'projects:merge_requests:new_diffs':
+ case 'projects:merge_requests:creations:new':
+ const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
+ if (mrNewCompareNode) {
+ new Compare({
+ targetProjectUrl: mrNewCompareNode.dataset.targetProjectUrl,
+ sourceBranchUrl: mrNewCompareNode.dataset.sourceBranchUrl,
+ targetBranchUrl: mrNewCompareNode.dataset.targetBranchUrl,
+ });
+ } else {
+ const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit');
+ new MergeRequest({
+ action: mrNewSubmitNode.dataset.mrSubmitAction,
+ });
+ }
+ case 'projects:merge_requests:creations:diffs':
case 'projects:merge_requests:edit':
new gl.Diff();
shortcut_handler = new ShortcutsNavigation();
- new gl.GLForm($('.merge-request-form'));
+ new gl.GLForm($('.merge-request-form'), true);
new IssuableForm($('.merge-request-form'));
new LabelsSelect();
new MilestoneSelect();
new gl.IssuableTemplateSelectors();
+ new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
break;
case 'projects:tags:new':
new ZenMode();
- new gl.GLForm($('.tag-form'));
+ new gl.GLForm($('.tag-form'), true);
+ new RefSelectDropdown($('.js-branch-select'));
+ break;
+ case 'projects:snippets:show':
+ initNotes();
+ break;
+ case 'projects:snippets:new':
+ case 'projects:snippets:edit':
+ case 'projects:snippets:create':
+ case 'projects:snippets:update':
+ new gl.GLForm($('.snippet-form'), true);
+ break;
+ case 'snippets:new':
+ case 'snippets:edit':
+ case 'snippets:create':
+ case 'snippets:update':
+ new gl.GLForm($('.snippet-form'), false);
break;
case 'projects:releases:edit':
new ZenMode();
- new gl.GLForm($('.release-form'));
+ new gl.GLForm($('.release-form'), true);
break;
case 'projects:merge_requests:show':
new gl.Diff();
shortcut_handler = new ShortcutsIssuable(true);
new ZenMode();
- new MergedButtons();
- break;
- case 'projects:merge_requests:commits':
- new MergedButtons();
- break;
- case "projects:merge_requests:diffs":
- new gl.Diff();
- new ZenMode();
- new MergedButtons();
+
+ initIssuableSidebar();
+ initNotes();
+
+ const mrShowNode = document.querySelector('.merge-request');
+ window.mergeRequest = new MergeRequest({
+ action: mrShowNode.dataset.mrAction,
+ });
break;
case 'dashboard:activity':
new gl.Activities();
@@ -219,29 +321,54 @@ const ShortcutsBlob = require('./shortcuts_blob');
new MiniPipelineGraph({
container: '.js-commit-pipeline-graph',
}).bindEvents();
+ initNotes();
+ initChangesDropdown();
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
break;
case 'projects:commit:pipelines':
new MiniPipelineGraph({
container: '.js-commit-pipeline-graph',
}).bindEvents();
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
break;
- case 'projects:commits:show':
case 'projects:activity':
+ new gl.Activities();
shortcut_handler = new ShortcutsNavigation();
break;
+ case 'projects:commits:show':
+ CommitsList.init(document.querySelector('.js-project-commits-show').dataset.commitsLimit);
+ shortcut_handler = new ShortcutsNavigation();
+ GpgBadges.fetch();
+ break;
case 'projects:show':
shortcut_handler = new ShortcutsNavigation();
new NotificationsForm();
- if ($('#tree-slider').length) {
- new TreeView();
- }
+
+ if ($('#tree-slider').length) new TreeView();
+ if ($('.blob-viewer').length) new BlobViewer();
+ if ($('.project-show-activity').length) new gl.Activities();
+ $('#tree-slider').waitForImages(function() {
+ gl.utils.ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
+ });
+ break;
+ case 'projects:edit':
+ setupProjectEdit();
+ // Initialize expandable settings panels
+ initSettingsPanels();
+ break;
+ case 'projects:imports:show':
+ new ProjectImport();
+ break;
+ case 'projects:pipelines:new':
+ new NewBranchForm($('.js-new-pipeline-form'));
break;
case 'projects:pipelines:builds':
+ case 'projects:pipelines:failures':
case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
- new gl.Pipelines({
+ new Pipelines({
initTabs: true,
pipelineStatusUrl,
tabsOptions: {
@@ -265,7 +392,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.Members();
new UsersSelect();
break;
- case 'projects:members:show':
+ case 'projects:project_members:index':
new gl.MemberExpirationDate('.js-access-expiration-date-groups');
new GroupsSelect();
new gl.MemberExpirationDate();
@@ -286,25 +413,31 @@ const ShortcutsBlob = require('./shortcuts_blob');
break;
case 'projects:tree:show':
shortcut_handler = new ShortcutsNavigation();
+
+ if (UserFeatureHelper.isNewRepo()) break;
+
new TreeView();
- gl.TargetBranchDropDown.bootstrap();
+ new BlobViewer();
+ new NewCommitForm($('.js-create-dir-form'));
+ $('#tree-slider').waitForImages(function() {
+ gl.utils.ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
+ });
break;
case 'projects:find_file:show':
+ const findElement = document.querySelector('.js-file-finder');
+ const projectFindFile = new ProjectFindFile($(".file-finder-holder"), {
+ url: findElement.dataset.fileFindUrl,
+ treeUrl: findElement.dataset.findTreeUrl,
+ blobUrlTemplate: findElement.dataset.blobUrlTemplate,
+ });
+ new ShortcutsFindFile(projectFindFile);
shortcut_handler = true;
break;
- case 'projects:blob:new':
- gl.TargetBranchDropDown.bootstrap();
- break;
- case 'projects:blob:create':
- gl.TargetBranchDropDown.bootstrap();
- break;
case 'projects:blob:show':
- gl.TargetBranchDropDown.bootstrap();
+ if (UserFeatureHelper.isNewRepo()) break;
+ new BlobViewer();
initBlob();
break;
- case 'projects:blob:edit':
- gl.TargetBranchDropDown.bootstrap();
- break;
case 'projects:blame:show':
initBlob();
break;
@@ -314,10 +447,20 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:labels:edit':
new Labels();
break;
+ case 'groups:labels:index':
case 'projects:labels:index':
if ($('.prioritized-labels').length) {
new gl.LabelManager();
}
+ $('.label-subscription').each((i, el) => {
+ const $el = $(el);
+
+ if ($el.find('.dropdown-group-label').length) {
+ new gl.GroupLabelSubscription($el);
+ } else {
+ new gl.ProjectLabelSubscription($el);
+ }
+ });
break;
case 'projects:network:show':
// Ensure we don't create a particular shortcut handler here. This is
@@ -328,23 +471,25 @@ const ShortcutsBlob = require('./shortcuts_blob');
new ProjectFork();
break;
case 'projects:artifacts:browse':
+ new ShortcutsNavigation();
new BuildArtifacts();
break;
+ case 'projects:artifacts:file':
+ new ShortcutsNavigation();
+ new BlobViewer();
+ break;
case 'help:index':
- gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
+ VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
break;
case 'search:show':
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();
+ case 'projects:settings:repository:show':
+ // Initialize expandable settings panels
+ initSettingsPanels();
break;
- case 'projects:ci_cd:show':
+ case 'projects:settings:ci_cd:show':
+ case 'groups:settings:ci_cd:show':
new gl.ProjectVariables();
break;
case 'ci:lints:create':
@@ -354,8 +499,23 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'users:show':
new UserCallout();
break;
+ case 'admin:conversational_development_index:show':
+ new UserCallout();
+ break;
+ case 'snippets:show':
+ new LineHighlighter();
+ new BlobViewer();
+ initNotes();
+ break;
+ case 'import:fogbugz:new_user_map':
+ new UsersSelect();
+ break;
+ case 'profiles:personal_access_tokens:index':
+ case 'admin:impersonation_tokens:index':
+ new gl.DueDateSelectors();
+ break;
}
- switch (path.first()) {
+ switch (path[0]) {
case 'sessions':
case 'omniauth_callbacks':
if (!gon.u2f) break;
@@ -371,7 +531,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Admin();
switch (path[1]) {
case 'cohorts':
- new gl.UsagePing();
+ new UsagePing();
break;
case 'groups':
new UsersSelect();
@@ -423,15 +583,17 @@ const ShortcutsBlob = require('./shortcuts_blob');
new NotificationsDropdown();
break;
case 'wikis':
- new gl.Wikis();
+ new Wikis();
shortcut_handler = new ShortcutsWiki();
new ZenMode();
- new gl.GLForm($('.wiki-form'));
+ new gl.GLForm($('.wiki-form'), true);
break;
case 'snippets':
shortcut_handler = new ShortcutsNavigation();
if (path[2] === 'show') {
new ZenMode();
+ new LineHighlighter();
+ new BlobViewer();
}
break;
case 'labels':
@@ -448,11 +610,22 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'protected_branches':
shortcut_handler = new ShortcutsNavigation();
}
+ break;
+ case 'users':
+ const action = path[1];
+ import(/* webpackChunkName: 'user_profile' */ './users')
+ .then(user => user.default(action))
+ .catch(() => {});
+ break;
}
// If we haven't installed a custom shortcut handler, install the default one
if (!shortcut_handler) {
new Shortcuts();
}
+
+ if (document.querySelector('#peek')) {
+ new PerformanceBar({ container: '#peek' });
+ }
};
Dispatcher.prototype.initSearch = function() {
@@ -470,4 +643,8 @@ const ShortcutsBlob = require('./shortcuts_blob');
return Dispatcher;
})();
+
+ $(window).on('load', function() {
+ new Dispatcher();
+ });
}).call(window);
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
index 8883ed9aa14..868d47e91b3 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/droplab/constants.js
@@ -3,11 +3,14 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
+// Matches `{{anything}}` and `{{ everything }}`.
+const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
export {
DATA_TRIGGER,
DATA_DROPDOWN,
SELECTED_CLASS,
ACTIVE_CLASS,
+ TEMPLATE_REGEX,
IGNORE_CLASS,
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 1fb4d63923c..70cd337fb8a 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -1,44 +1,42 @@
-/* eslint-disable */
-
import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
-var DropDown = function(list) {
- this.currentIndex = 0;
- this.hidden = true;
- this.list = typeof list === 'string' ? document.querySelector(list) : list;
- this.items = [];
+class DropDown {
+ constructor(list) {
+ this.currentIndex = 0;
+ this.hidden = true;
+ this.list = typeof list === 'string' ? document.querySelector(list) : list;
+ this.items = [];
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.getItems();
- this.initTemplateString();
- this.addEvents();
+ this.getItems();
+ this.initTemplateString();
+ this.addEvents();
- this.initialState = list.innerHTML;
-};
+ this.initialState = list.innerHTML;
+ }
-Object.assign(DropDown.prototype, {
- getItems: function() {
+ getItems() {
this.items = [].slice.call(this.list.querySelectorAll('li'));
return this.items;
- },
+ }
- initTemplateString: function() {
- var items = this.items || this.getItems();
+ initTemplateString() {
+ const items = this.items || this.getItems();
- var templateString = '';
+ let templateString = '';
if (items.length > 0) templateString = items[items.length - 1].outerHTML;
this.templateString = templateString;
return this.templateString;
- },
+ }
- clickEvent: function(e) {
+ clickEvent(e) {
if (e.target.tagName === 'UL') return;
if (e.target.classList.contains(IGNORE_CLASS)) return;
- var selected = utils.closest(e.target, 'LI');
+ const selected = utils.closest(e.target, 'LI');
if (!selected) return;
this.addSelectedClass(selected);
@@ -46,95 +44,95 @@ Object.assign(DropDown.prototype, {
e.preventDefault();
this.hide();
- var listEvent = new CustomEvent('click.dl', {
+ const listEvent = new CustomEvent('click.dl', {
detail: {
list: this,
- selected: selected,
+ selected,
data: e.target.dataset,
},
});
this.list.dispatchEvent(listEvent);
- },
+ }
- addSelectedClass: function (selected) {
+ addSelectedClass(selected) {
this.removeSelectedClasses();
selected.classList.add(SELECTED_CLASS);
- },
+ }
- removeSelectedClasses: function () {
+ removeSelectedClasses() {
const items = this.items || this.getItems();
items.forEach(item => item.classList.remove(SELECTED_CLASS));
- },
+ }
- addEvents: function() {
- this.eventWrapper.clickEvent = this.clickEvent.bind(this)
+ addEvents() {
+ 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) {
+ setData(data) {
this.data = data;
this.render(data);
- },
+ }
- addData: function(data) {
+ addData(data) {
this.data = (this.data || []).concat(data);
this.render(this.data);
- },
+ }
- render: function(data) {
+ render(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');
+ renderChildren(data) {
+ const html = utils.template(this.templateString, data);
+ const template = document.createElement('div');
template.innerHTML = html;
- this.setImagesSrc(template);
+ DropDown.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() {
+ show() {
if (!this.hidden) return;
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
- },
+ }
- hide: function() {
+ hide() {
if (this.hidden) return;
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
- },
+ }
- toggle: function () {
- this.hidden ? this.show() : this.hide();
- },
+ toggle() {
+ if (this.hidden) return this.show();
- destroy: function() {
+ return this.hide();
+ }
+
+ destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
}
-});
+
+ static setImagesSrc(template) {
+ const images = [...template.querySelectorAll('img[data-src]')];
+
+ images.forEach((image) => {
+ const img = image;
+
+ img.src = img.getAttribute('data-src');
+ img.removeAttribute('data-src');
+ });
+ }
+}
export default DropDown;
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
index 6eb9f314af7..2a02ede72bf 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -1,99 +1,99 @@
-/* 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 = {};
+class DropLab {
+ constructor() {
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
- this.eventWrapper = {};
-};
+ this.eventWrapper = {};
+ }
-Object.assign(DropLab.prototype, {
- loadStatic: function(){
- var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
+ loadStatic() {
+ const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
this.addHooks(dropdownTriggers);
- },
+ }
- addData: function () {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_addData');
- },
+ addData(...args) {
+ this.applyArgs(args, 'processAddData');
+ }
- setData: function() {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_setData');
- },
+ setData(...args) {
+ this.applyArgs(args, 'processSetData');
+ }
- destroy: function() {
+ destroy() {
this.hooks.forEach(hook => hook.destroy());
this.hooks = [];
this.removeEvents();
- },
+ }
- applyArgs: function(args, methodName) {
- if (this.ready) return this[methodName].apply(this, args);
+ applyArgs(args, methodName) {
+ if (this.ready) return this[methodName](...args);
this.queuedData = this.queuedData || [];
this.queuedData.push(args);
- },
- _addData: function(trigger, data) {
- this._processData(trigger, data, 'addData');
- },
+ return this.ready;
+ }
+
+ processAddData(trigger, data) {
+ this.processData(trigger, data, 'addData');
+ }
- _setData: function(trigger, data) {
- this._processData(trigger, data, 'setData');
- },
+ processSetData(trigger, data) {
+ this.processData(trigger, data, 'setData');
+ }
- _processData: function(trigger, data, methodName) {
+ processData(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)
+ addEvents() {
+ this.eventWrapper.documentClicked = this.documentClicked.bind(this);
document.addEventListener('click', this.eventWrapper.documentClicked);
- },
+ }
- documentClicked: function(e) {
+ documentClicked(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;
+ if (utils.isDropDownParts(thisTag, this.hooks)) return;
+ if (utils.isDropDownParts(e.target, this.hooks)) return;
this.hooks.forEach(hook => hook.list.hide());
- },
+ }
- removeEvents: function(){
+ removeEvents() {
document.removeEventListener('click', this.eventWrapper.documentClicked);
- },
-
- changeHookList: function(trigger, list, plugins, config) {
- const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
+ }
+ changeHookList(trigger, list, plugins, config) {
+ const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
this.hooks.forEach((hook, i) => {
- hook.list.list.dataset.dropdownActive = false;
+ const aHook = hook;
+
+ aHook.list.list.dataset.dropdownActive = false;
- if (hook.trigger !== availableTrigger) return;
+ if (aHook.trigger !== availableTrigger) return;
- hook.destroy();
+ aHook.destroy();
this.hooks.splice(i, 1);
this.addHook(availableTrigger, list, plugins, config);
});
- },
+ }
- addHook: function(hook, list, plugins, config) {
+ addHook(hook, list, plugins, config) {
const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook;
let availableList;
@@ -111,18 +111,18 @@ Object.assign(DropLab.prototype, {
this.hooks.push(new HookObject(availableHook, availableList, plugins, config));
return this;
- },
+ }
- addHooks: function(hooks, plugins, config) {
+ addHooks(hooks, plugins, config) {
hooks.forEach(hook => this.addHook(hook, null, plugins, config));
return this;
- },
+ }
- setConfig: function(obj){
+ setConfig(obj) {
this.config = obj;
- },
+ }
- fireReady: function() {
+ fireReady() {
const readyEvent = new CustomEvent('ready.dl', {
detail: {
dropdown: this,
@@ -131,10 +131,14 @@ Object.assign(DropLab.prototype, {
document.dispatchEvent(readyEvent);
this.ready = true;
- },
+ }
- init: function (hook, list, plugins, config) {
- hook ? this.addHook(hook, list, plugins, config) : this.loadStatic();
+ init(hook, list, plugins, config) {
+ if (hook) {
+ this.addHook(hook, list, plugins, config);
+ } else {
+ this.loadStatic();
+ }
this.addEvents();
@@ -146,7 +150,7 @@ Object.assign(DropLab.prototype, {
this.queuedData = [];
return this;
- },
-});
+ }
+}
export default DropLab;
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
index 2f840083571..cf78165b0d8 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/droplab/hook.js
@@ -1,22 +1,15 @@
-/* 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,
-});
+class Hook {
+ constructor(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;
+ }
+}
export default Hook;
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
index be8aead1303..af45eba74e7 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -1,65 +1,58 @@
-/* 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';
+class HookButton extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.eventWrapper = {};
+ this.type = 'button';
+ this.event = 'click';
- this.addEvents();
- this.addPlugins();
-};
+ this.eventWrapper = {};
-HookButton.prototype = Object.create(Hook.prototype);
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookButton.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- clicked: function(e){
- var buttonEvent = new CustomEvent('click.dl', {
+ clicked(e) {
+ const buttonEvent = new CustomEvent('click.dl', {
detail: {
hook: this,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(buttonEvent);
this.list.toggle();
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.clicked = this.clicked.bind(this);
this.trigger.addEventListener('click', this.eventWrapper.clicked);
- },
+ }
- removeEvents: function(){
+ removeEvents() {
this.trigger.removeEventListener('click', this.eventWrapper.clicked);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
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
index 05082334045..19131a64f2c 100644
--- a/app/assets/javascripts/droplab/hook_input.js
+++ b/app/assets/javascripts/droplab/hook_input.js
@@ -1,25 +1,23 @@
-/* eslint-disable */
-
import Hook from './hook';
-var HookInput = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
+class HookInput extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.type = 'input';
- this.event = 'input';
+ this.type = 'input';
+ this.event = 'input';
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.addEvents();
- this.addPlugins();
-};
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookInput.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.mousedown = this.mousedown.bind(this);
this.eventWrapper.input = this.input.bind(this);
this.eventWrapper.keyup = this.keyup.bind(this);
@@ -29,19 +27,19 @@ Object.assign(HookInput.prototype, {
this.trigger.addEventListener('input', this.eventWrapper.input);
this.trigger.addEventListener('keyup', this.eventWrapper.keyup);
this.trigger.addEventListener('keydown', this.eventWrapper.keydown);
- },
+ }
- removeEvents: function() {
+ removeEvents() {
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;
+ input(e) {
+ if (this.hasRemovedEvents) return;
this.list.show();
@@ -51,12 +49,12 @@ Object.assign(HookInput.prototype, {
text: e.target.value,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(inputEvent);
- },
+ }
- mousedown: function(e) {
+ mousedown(e) {
if (this.hasRemovedEvents) return;
const mouseEvent = new CustomEvent('mousedown.dl', {
@@ -68,21 +66,21 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(mouseEvent);
- },
+ }
- keyup: function(e) {
+ keyup(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keyup.dl');
- },
+ }
- keydown: function(e) {
+ keydown(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keydown.dl');
- },
+ }
- keyEvent: function(e, eventName) {
+ keyEvent(e, eventName) {
this.list.show();
const keyEvent = new CustomEvent(eventName, {
@@ -96,17 +94,17 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(keyEvent);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
this.restoreInitialState();
this.removeEvents();
@@ -114,6 +112,6 @@ Object.assign(HookInput.prototype, {
this.list.destroy();
}
-});
+}
export default HookInput;
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
index 36740a430e1..02f1b805ce4 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -8,7 +8,7 @@ const Keyboard = function () {
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 itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0);
var listItems = [];
for(var i = 0; i < itemElements.length; i++) {
var listItem = itemElements[i];
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
index 12afe53ed76..267b53fa4f2 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -1,25 +1,8 @@
/* eslint-disable */
+import AjaxCache from '~/lib/utils/ajax_cache';
+
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]');
@@ -28,10 +11,19 @@ const Ajax = {
if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data);
},
+ preprocessing: function preprocessing(config, data) {
+ let results = data;
+
+ if (config.preprocessing && !data.preprocessed) {
+ results = config.preprocessing(data);
+ AjaxCache.override(config.endpoint, results);
+ }
+
+ return results;
+ },
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) {
@@ -48,14 +40,11 @@ const Ajax = {
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);
- }
+
+ return AjaxCache.retrieve(config.endpoint)
+ .then(self.preprocessing.bind(null, config))
+ .then((data) => self._loadData(data, config, self))
+ .catch(config.onError);
},
destroy: function() {
this.destroyed = true;
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
index cfd7e2ca189..1db20227a16 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import AjaxCache from '../../lib/utils/ajax_cache';
const AjaxFilter = {
init: function(hook) {
@@ -58,50 +59,27 @@ const AjaxFilter = {
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]);
- }
+ return AjaxCache.retrieve(url)
+ .then((data) => {
+ this._loadData(data, config);
+ if (config.onLoadingFinished) {
+ config.onLoadingFinished(data);
}
- };
- xhr.send();
- });
+ })
+ .catch(config.onError);
},
- _loadData: function _loadData(data, config, self) {
- const list = self.hook.list;
+ _loadData(data, config) {
+ const list = this.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;
+ dataLoadingTemplate.outerHTML = this.listTemplate;
}
}
- if (!self.destroyed) {
+ if (!this.destroyed) {
var hookListChildren = list.list.children;
var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
if (onlyDynamicList && data.length === 0) {
@@ -109,7 +87,7 @@ const AjaxFilter = {
}
list.setData.call(list, data);
}
- self.notLoading();
+ this.notLoading();
list.currentIndex = 0;
},
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
index c149a33a1e9..4da7344604e 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/droplab/utils.js
@@ -1,19 +1,19 @@
/* eslint-disable */
-import { DATA_TRIGGER, DATA_DROPDOWN } from './constants';
+import { template as _template } from 'underscore';
+import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } 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;
+ template(templateString, data) {
+ const template = _template(templateString, {
+ escape: TEMPLATE_REGEX,
+ });
+
+ return template(data);
},
camelize(str) {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index b70d242269d..6d19a6d9b3a 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,108 +1,165 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */
/* global Dropzone */
-
-require('./preview_markdown');
+import _ from 'underscore';
+import './preview_markdown';
window.DropzoneInput = (function() {
function DropzoneInput(form) {
- var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress;
- Dropzone.autoDiscover = false;
- alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
- alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
- divHover = "<div class=\"div-dropzone-hover\"></div>";
- divSpinner = "<div class=\"div-dropzone-spinner\"></div>";
- divAlert = "<div class=\"" + alertClass + "\"></div>";
- iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>";
- iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>";
- uploadProgress = $("<div class=\"div-dropzone-progress\"></div>");
- btnAlert = "<button type=\"button\"" + alertAttr + ">&times;</button>";
- project_uploads_path = window.project_uploads_path || null;
- max_file_size = gon.max_file_size || 10;
- form_textarea = $(form).find(".js-gfm-input");
- form_textarea.wrap("<div class=\"div-dropzone\"></div>");
- form_textarea.on('paste', (function(_this) {
+ const divHover = '<div class="div-dropzone-hover"></div>';
+ const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ const $attachButton = form.find('.button-attach-file');
+ const $attachingFileMessage = form.find('.attaching-file-message');
+ const $cancelButton = form.find('.button-cancel-uploading-files');
+ const $retryLink = form.find('.retry-uploading-link');
+ const $uploadProgress = form.find('.uploading-progress');
+ const $uploadingErrorContainer = form.find('.uploading-error-container');
+ const $uploadingErrorMessage = form.find('.uploading-error-message');
+ const $uploadingProgressContainer = form.find('.uploading-progress-container');
+ const uploadsPath = window.uploads_path || null;
+ const maxFileSize = gon.max_file_size || 10;
+ const formTextarea = form.find('.js-gfm-input');
+ let handlePaste;
+ let pasteText;
+ let addFileToForm;
+ let updateAttachingMessage;
+ let isImage;
+ let getFilename;
+ let uploadFile;
+
+ formTextarea.wrap('<div class="div-dropzone"></div>');
+ formTextarea.on('paste', (function(_this) {
return function(event) {
return handlePaste(event);
};
})(this));
- $mdArea = $(form_textarea).closest('.md-area');
- $(form).setupMarkdownPreview();
- form_dropzone = $(form).find('.div-dropzone');
- form_dropzone.parent().addClass("div-dropzone-wrapper");
- form_dropzone.append(divHover);
- form_dropzone.find(".div-dropzone-hover").append(iconPaperclip);
- form_dropzone.append(divSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(iconSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(uploadProgress);
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
- if (!project_uploads_path) return;
+ // Add dropzone area to the form.
+ const $mdArea = formTextarea.closest('.md-area');
+ form.setupMarkdownPreview();
+ const $formDropzone = form.find('.div-dropzone');
+ $formDropzone.parent().addClass('div-dropzone-wrapper');
+ $formDropzone.append(divHover);
+ $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
+
+ if (!uploadsPath) return;
- dropzone = form_dropzone.dropzone({
- url: project_uploads_path,
- dictDefaultMessage: "",
+ const dropzone = $formDropzone.dropzone({
+ url: uploadsPath,
+ dictDefaultMessage: '',
clickable: true,
- paramName: "file",
- maxFilesize: max_file_size,
+ paramName: 'file',
+ maxFilesize: maxFileSize,
uploadMultiple: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
previewContainer: false,
processing: function() {
- return $(".div-dropzone-alert").alert("close");
+ return $('.div-dropzone-alert').alert('close');
},
dragover: function() {
$mdArea.addClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0.7);
+ form.find('.div-dropzone-hover').css('opacity', 0.7);
},
dragleave: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
+ form.find('.div-dropzone-hover').css('opacity', 0);
},
drop: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
- form_textarea.focus();
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ formTextarea.focus();
},
success: function(header, response) {
const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
const shouldPad = processingFileCount >= 1;
pasteText(response.link.markdown, shouldPad);
+ // Show 'Attach a file' link only when all files have been uploaded.
+ if (!processingFileCount) $attachButton.removeClass('hide');
+ addFileToForm(response.link.url);
},
- error: function(temp) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed.");
- }
+ error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
+ // If 'error' event is fired by dropzone, the second parameter is error message.
+ // If the 'errorMessage' parameter is empty, the default error message is set.
+ // If the 'error' event is fired by backend (xhr) error response, the third parameter is
+ // xhr object (xhr.responseText is error message).
+ // On error we hide the 'Attach' and 'Cancel' buttons
+ // and show an error.
+
+ // If there's xhr error message, let's show it instead of dropzone's one.
+ const message = xhr ? xhr.responseText : errorMessage;
+
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ $attachButton.addClass('hide');
+ $cancelButton.addClass('hide');
},
totaluploadprogress: function(totalUploadProgress) {
- uploadProgress.text(Math.round(totalUploadProgress) + "%");
+ updateAttachingMessage(this.files, $attachingFileMessage);
+ $uploadProgress.text(Math.round(totalUploadProgress) + '%');
+ },
+ sending: function(file) {
+ // DOM elements already exist.
+ // Instead of dynamically generating them,
+ // we just either hide or show them.
+ $attachButton.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ $uploadingProgressContainer.removeClass('hide');
+ $cancelButton.removeClass('hide');
},
- sending: function() {
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
+ removedfile: function() {
+ $attachButton.removeClass('hide');
+ $cancelButton.addClass('hide');
+ $uploadingProgressContainer.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
},
queuecomplete: function() {
- uploadProgress.text("");
- $(".dz-preview").remove();
- $(".markdown-area").trigger("input");
- $(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
+ $('.dz-preview').remove();
+ $('.markdown-area').trigger('input');
+
+ $uploadingProgressContainer.addClass('hide');
+ $cancelButton.addClass('hide');
}
});
- child = $(dropzone[0]).children("textarea");
+
+ const child = $(dropzone[0]).children('textarea');
+
+ // removeAllFiles(true) stops uploading files (if any)
+ // and remove them from dropzone files queue.
+ $cancelButton.on('click', (e) => {
+ const target = e.target.closest('form').querySelector('.div-dropzone');
+
+ e.preventDefault();
+ e.stopPropagation();
+ Dropzone.forElement(target).removeAllFiles(true);
+ });
+
+ // If 'error' event is fired, we store a failed files,
+ // clear dropzone files queue, change status of failed files to undefined,
+ // and add that files to the dropzone files queue again.
+ // addFile() adds file to dropzone files queue and upload it.
+ $retryLink.on('click', (e) => {
+ const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone'));
+ const failedFiles = dropzoneInstance.files;
+
+ e.preventDefault();
+
+ // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment.
+ dropzoneInstance.removeAllFiles(true);
+
+ failedFiles.map((failedFile, i) => {
+ const file = failedFile;
+
+ if (file.status === Dropzone.ERROR) {
+ file.status = undefined;
+ file.accepted = undefined;
+ }
+
+ return dropzoneInstance.addFile(file);
+ });
+ });
+
handlePaste = function(event) {
var filename, image, pasteEvent, text;
pasteEvent = event.originalEvent;
@@ -110,25 +167,27 @@ window.DropzoneInput = (function() {
image = isImage(pasteEvent);
if (image) {
event.preventDefault();
- filename = getFilename(pasteEvent) || "image.png";
- text = "{{" + filename + "}}";
+ filename = getFilename(pasteEvent) || 'image.png';
+ text = `{{${filename}}}`;
pasteText(text);
return uploadFile(image.getAsFile(), filename);
}
}
};
+
isImage = function(data) {
var i, item;
i = 0;
while (i < data.clipboardData.items.length) {
item = data.clipboardData.items[i];
- if (item.type.indexOf("image") !== -1) {
+ if (item.type.indexOf('image') !== -1) {
return item;
}
i += 1;
}
return false;
};
+
pasteText = function(text, shouldPad) {
var afterSelection, beforeSelection, caretEnd, caretStart, textEnd;
var formattedText = text;
@@ -142,31 +201,67 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`;
- return form_textarea.trigger("input");
+ formTextarea.get(0).dispatchEvent(new Event('input'));
+ return formTextarea.trigger('input');
+ };
+
+ addFileToForm = function(path) {
+ $(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">');
};
+
getFilename = function(e) {
var value;
if (window.clipboardData && window.clipboardData.getData) {
- value = window.clipboardData.getData("Text");
+ value = window.clipboardData.getData('Text');
} else if (e.clipboardData && e.clipboardData.getData) {
- value = e.clipboardData.getData("text/plain");
+ value = e.clipboardData.getData('text/plain');
}
value = value.split("\r");
- return value.first();
+ return value[0];
+ };
+
+ const showSpinner = function(e) {
+ return $uploadingProgressContainer.removeClass('hide');
};
+
+ const closeSpinner = function() {
+ return $uploadingProgressContainer.addClass('hide');
+ };
+
+ const showError = function(message) {
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ };
+
+ const closeAlertMessage = function() {
+ return form.find('.div-dropzone-alert').alert('close');
+ };
+
+ const insertToTextArea = function(filename, url) {
+ return $(child).val(function(index, val) {
+ return val.replace(`{{${filename}}}`, url);
+ });
+ };
+
+ const appendToTextArea = function(url) {
+ return $(child).val(function(index, val) {
+ return val + url + "\n";
+ });
+ };
+
uploadFile = function(item, filename) {
var formData;
formData = new FormData();
- formData.append("file", item, filename);
+ formData.append('file', item, filename);
return $.ajax({
- url: project_uploads_path,
- type: "POST",
+ url: uploadsPath,
+ type: 'POST',
data: formData,
- dataType: "json",
+ dataType: 'json',
processData: false,
contentType: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
beforeSend: function() {
showSpinner();
@@ -183,44 +278,29 @@ window.DropzoneInput = (function() {
}
});
};
- insertToTextArea = function(filename, url) {
- return $(child).val(function(index, val) {
- return val.replace("{{" + filename + "}}", url);
- });
- };
- appendToTextArea = function(url) {
- return $(child).val(function(index, val) {
- return val + url + "\n";
- });
- };
- showSpinner = function(e) {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
- };
- closeSpinner = function() {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
- };
- showError = function(message) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- return $(".div-dropzone-alert").append(btnAlert + message);
+
+ updateAttachingMessage = (files, messageContainer) => {
+ let attachingMessage;
+ const filesCount = files.filter(function(file) {
+ return file.status === 'uploading' ||
+ file.status === 'queued';
+ }).length;
+
+ // Dinamycally change uploading files text depending on files number in
+ // dropzone files queue.
+ if (filesCount > 1) {
+ attachingMessage = 'Attaching ' + filesCount + ' files -';
+ } else {
+ attachingMessage = 'Attaching a file -';
}
+
+ messageContainer.text(attachingMessage);
};
- closeAlertMessage = function() {
- return form.find(".div-dropzone-alert").alert("close");
- };
- form.find(".markdown-selector").click(function(e) {
+
+ form.find('.markdown-selector').click(function(e) {
e.preventDefault();
$(this).closest('.gfm-form').find('.div-dropzone').click();
- form_textarea.focus();
+ formTextarea.focus();
});
}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index a8fc5b41fb4..ee71728184f 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,6 +1,8 @@
/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */
/* global dateFormat */
-/* global Pikaday */
+
+import Pikaday from 'pikaday';
+import DateFix from './lib/utils/datefix';
class DueDateSelect {
constructor({ $dropdown, $loading } = {}) {
@@ -43,14 +45,13 @@ class DueDateSelect {
initDatePicker() {
const $dueDateInput = $(`input[name='${this.fieldName}']`);
-
+ const dateFix = DateFix.dashedFix($dueDateInput.val());
const calendar = new Pikaday({
field: $dueDateInput.get(0),
theme: 'gitlab-theme',
format: 'yyyy-mm-dd',
onSelect: (dateText) => {
const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd');
-
$dueDateInput.val(formattedDate);
if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
@@ -62,7 +63,7 @@ class DueDateSelect {
}
});
- calendar.setDate(new Date($dueDateInput.val()));
+ calendar.setDate(dateFix);
this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar);
}
@@ -168,6 +169,7 @@ class DueDateSelectors {
initMilestoneDatePicker() {
$('.datepicker').each(function() {
const $datePicker = $(this);
+ const dateFix = DateFix.dashedFix($datePicker.val());
const calendar = new Pikaday({
field: $datePicker.get(0),
theme: 'gitlab-theme animate-picker',
@@ -177,7 +179,8 @@ class DueDateSelectors {
$datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
}
});
- calendar.setDate(new Date($datePicker.val()));
+
+ calendar.setDate(dateFix);
$datePicker.data('pikaday', calendar);
});
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
new file mode 100644
index 00000000000..dc7672560ea
--- /dev/null
+++ b/app/assets/javascripts/emoji/index.js
@@ -0,0 +1,100 @@
+import _ from 'underscore';
+import emojiMap from 'emojis/digests.json';
+import emojiAliases from 'emojis/aliases.json';
+
+export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+
+export function normalizeEmojiName(name) {
+ return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name;
+}
+
+export function isEmojiNameValid(name) {
+ return validEmojiNames.indexOf(name) >= 0;
+}
+
+export function filterEmojiNames(filter) {
+ const match = filter.toLowerCase();
+ return validEmojiNames.filter(name => name.indexOf(match) >= 0);
+}
+
+export function filterEmojiNamesByAlias(filter) {
+ return _.uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name)));
+}
+
+let emojiCategoryMap;
+export function getEmojiCategoryMap() {
+ if (!emojiCategoryMap) {
+ emojiCategoryMap = {
+ activity: [],
+ people: [],
+ nature: [],
+ food: [],
+ travel: [],
+ objects: [],
+ symbols: [],
+ flags: [],
+ };
+ Object.keys(emojiMap).forEach((name) => {
+ const emoji = emojiMap[name];
+ if (emojiCategoryMap[emoji.category]) {
+ emojiCategoryMap[emoji.category].push(name);
+ }
+ });
+ }
+ return emojiCategoryMap;
+}
+
+export function getEmojiInfo(query) {
+ let name = normalizeEmojiName(query);
+ let emojiInfo = emojiMap[name];
+
+ // Fallback to question mark for unknown emojis
+ if (!emojiInfo) {
+ name = 'grey_question';
+ emojiInfo = emojiMap[name];
+ }
+
+ return { ...emojiInfo, name };
+}
+
+export function emojiFallbackImageSrc(inputName) {
+ const { name, digest } = getEmojiInfo(inputName);
+ return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`;
+}
+
+export function emojiImageTag(name, src) {
+ return `<img class="emoji" title=":${name}:" alt=":${name}:" src="${src}" width="20" height="20" align="absmiddle" />`;
+}
+
+export function glEmojiTag(inputName, options) {
+ const opts = { sprite: false, forceFallback: false, ...options };
+ const { name, ...emojiInfo } = getEmojiInfo(inputName);
+
+ const fallbackImageSrc = emojiFallbackImageSrc(name);
+ const fallbackSpriteClass = `emoji-${name}`;
+
+ const classList = [];
+ if (opts.forceFallback && opts.sprite) {
+ classList.push('emoji-icon');
+ classList.push(fallbackSpriteClass);
+ }
+ const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
+ const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
+ let contents = emojiInfo.moji;
+ if (opts.forceFallback && !opts.sprite) {
+ contents = emojiImageTag(name, fallbackImageSrc);
+ }
+
+ return `
+ <gl-emoji
+ ${classAttribute}
+ data-name="${name}"
+ data-fallback-src="${fallbackImageSrc}"
+ ${fallbackSpriteAttribute}
+ data-unicode-version="${emojiInfo.unicodeVersion}"
+ title="${emojiInfo.description}"
+ >
+ ${contents}
+ </gl-emoji>
+ `;
+}
diff --git a/app/assets/javascripts/emoji/support/index.js b/app/assets/javascripts/emoji/support/index.js
new file mode 100644
index 00000000000..1f7852dd487
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/index.js
@@ -0,0 +1,10 @@
+import isEmojiUnicodeSupported from './is_emoji_unicode_supported';
+import getUnicodeSupportMap from './unicode_support_map';
+
+// cache browser support map between calls
+let browserUnicodeSupportMap;
+
+export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) {
+ browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap();
+ return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion);
+}
diff --git a/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
new file mode 100644
index 00000000000..3fd23efa9f8
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
@@ -0,0 +1,120 @@
+// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
+const flagACodePoint = 127462; // parseInt('1F1E6', 16)
+const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
+function isFlagEmoji(emojiUnicode) {
+ const cp = emojiUnicode.codePointAt(0);
+ // Length 4 because flags are made of 2 characters which are surrogate pairs
+ return emojiUnicode.length === 4 && cp >= flagACodePoint && cp <= flagZCodePoint;
+}
+
+// Chrome <57 renders keycaps oddly
+// See https://bugs.chromium.org/p/chromium/issues/detail?id=632294
+// Same issue on Windows also fixed in Chrome 57, http://i.imgur.com/rQF7woO.png
+function isKeycapEmoji(emojiUnicode) {
+ return emojiUnicode.length === 3 && emojiUnicode[2] === '\u20E3';
+}
+
+// Check for a skin tone variation emoji which aren't always supported
+const tone1 = 127995;// parseInt('1F3FB', 16)
+const tone5 = 127999;// parseInt('1F3FF', 16)
+function isSkinToneComboEmoji(emojiUnicode) {
+ return emojiUnicode.length > 2 && Array.from(emojiUnicode).some((char) => {
+ const cp = char.codePointAt(0);
+ return cp >= tone1 && cp <= tone5;
+ });
+}
+
+// macOS supports most skin tone emoji's but
+// doesn't support the skin tone versions of horse racing
+const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
+function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
+ const firstCharacter = Array.from(emojiUnicode)[0];
+ return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint &&
+ isSkinToneComboEmoji(emojiUnicode);
+}
+
+// Check for `family_*`, `kiss_*`, `couple_*`
+// For ex. Windows 8.1 Firefox 51.0.1, doesn't support these
+const zwj = 8205; // parseInt('200D', 16)
+const personStartCodePoint = 128102; // parseInt('1F466', 16)
+const personEndCodePoint = 128105; // parseInt('1F469', 16)
+function isPersonZwjEmoji(emojiUnicode) {
+ let hasPersonEmoji = false;
+ let hasZwj = false;
+ Array.from(emojiUnicode).forEach((character) => {
+ const cp = character.codePointAt(0);
+ if (cp === zwj) {
+ hasZwj = true;
+ } else if (cp >= personStartCodePoint && cp <= personEndCodePoint) {
+ hasPersonEmoji = true;
+ }
+ });
+
+ return hasPersonEmoji && hasZwj;
+}
+
+// Helper so we don't have to run `isFlagEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isFlagResult = isFlagEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.flag && isFlagResult) ||
+ !isFlagResult
+ );
+}
+
+// Helper so we don't have to run `isSkinToneComboEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) {
+ const isSkinToneResult = isSkinToneComboEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.skinToneModifier && isSkinToneResult) ||
+ !isSkinToneResult
+ );
+}
+
+// Helper func so we don't have to run `isHorceRacingSkinToneComboEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isHorseRacingSkinToneResult = isHorceRacingSkinToneComboEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.horseRacing && isHorseRacingSkinToneResult) ||
+ !isHorseRacingSkinToneResult
+ );
+}
+
+// Helper so we don't have to run `isPersonZwjEmoji` twice
+// in `isEmojiUnicodeSupported` logic
+function checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode) {
+ const isPersonZwjResult = isPersonZwjEmoji(emojiUnicode);
+ return (
+ (unicodeSupportMap.personZwj && isPersonZwjResult) ||
+ !isPersonZwjResult
+ );
+}
+
+// Takes in a support map and determines whether
+// the given unicode emoji is supported on the platform.
+//
+// Combines all the edge case tests into a one-stop shop method
+function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVersion) {
+ const isOlderThanChrome57 = unicodeSupportMap.meta && unicodeSupportMap.meta.isChrome &&
+ unicodeSupportMap.meta.chromeVersion < 57;
+
+ // For comments about each scenario, see the comments above each individual respective function
+ return unicodeSupportMap[unicodeVersion] &&
+ !(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
+ checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
+ checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&
+ checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) &&
+ checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode);
+}
+
+export {
+ isEmojiUnicodeSupported as default,
+ isFlagEmoji,
+ isKeycapEmoji,
+ isSkinToneComboEmoji,
+ isHorceRacingSkinToneComboEmoji,
+ isPersonZwjEmoji,
+};
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
new file mode 100644
index 00000000000..755381c2f95
--- /dev/null
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -0,0 +1,167 @@
+import AccessorUtilities from '../../lib/utils/accessor';
+
+const unicodeSupportTestMap = {
+ // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
+ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
+ // woman, biking (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
+ // sexZwj: '\u{1F6B4}\u{200D}\u{2640}',
+ // family_mwgb
+ // Windows 8.1, Firefox 51.0.1 does not support `family_`, `kiss_`, `couple_`
+ personZwj: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
+ // horse_racing_tone5
+ // Special case that is not supported on macOS 10.12 even though `skinToneModifier` succeeds
+ horseRacing: '\u{1F3C7}\u{1F3FF}',
+ // US flag, http://emojipedia.org/flags/
+ flag: '\u{1F1FA}\u{1F1F8}',
+ // http://emojipedia.org/modifiers/
+ skinToneModifier: [
+ // spy_tone5
+ '\u{1F575}\u{1F3FF}',
+ // person_with_ball_tone5
+ '\u{26F9}\u{1F3FF}',
+ // angel_tone5
+ '\u{1F47C}\u{1F3FF}',
+ ],
+ // rofl, http://emojipedia.org/unicode-9.0/
+ '9.0': '\u{1F923}',
+ // metal, http://emojipedia.org/unicode-8.0/
+ '8.0': '\u{1F918}',
+ // spy, http://emojipedia.org/unicode-7.0/
+ '7.0': '\u{1F575}',
+ // expressionless, http://emojipedia.org/unicode-6.1/
+ 6.1: '\u{1F611}',
+ // japanese_goblin, http://emojipedia.org/unicode-6.0/
+ '6.0': '\u{1F47A}',
+ // sailboat, http://emojipedia.org/unicode-5.2/
+ 5.2: '\u{26F5}',
+ // mahjong, http://emojipedia.org/unicode-5.1/
+ 5.1: '\u{1F004}',
+ // gear, http://emojipedia.org/unicode-4.1/
+ 4.1: '\u{2699}',
+ // zap, http://emojipedia.org/unicode-4.0/
+ '4.0': '\u{26A1}',
+ // recycle, http://emojipedia.org/unicode-3.2/
+ 3.2: '\u{267B}',
+ // information_source, http://emojipedia.org/unicode-3.0/
+ '3.0': '\u{2139}',
+ // heart, http://emojipedia.org/unicode-1.1/
+ 1.1: '\u{2764}',
+};
+
+function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
+ // `4 *` because RGBA
+ const indexOffset = 4 * pixelOffset;
+ const hasColor = imageDataArray[indexOffset + 0] ||
+ imageDataArray[indexOffset + 1] ||
+ imageDataArray[indexOffset + 2];
+ const isVisible = imageDataArray[indexOffset + 3];
+ // Check for some sort of color other than black
+ if (hasColor && isVisible) {
+ return true;
+ }
+ return false;
+}
+
+const chromeMatches = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
+const isChrome = chromeMatches && chromeMatches.length > 0;
+const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatches[1], 10);
+
+// We use 16px because mobile Safari (iOS 9.3) doesn't properly scale emojis :/
+// See 32px, https://i.imgur.com/htY6Zym.png
+// See 16px, https://i.imgur.com/FPPsIF8.png
+const fontSize = 16;
+function generateUnicodeSupportMap(testMap) {
+ const testMapKeys = Object.keys(testMap);
+ const numTestEntries = testMapKeys
+ .reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
+
+ const canvas = document.createElement('canvas');
+ (window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas;
+ const ctx = canvas.getContext('2d');
+ canvas.width = (2 * fontSize);
+ canvas.height = (numTestEntries * fontSize);
+ ctx.fillStyle = '#000000';
+ ctx.textBaseline = 'middle';
+ ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
+ // Write each emoji to the canvas vertically
+ let writeIndex = 0;
+ testMapKeys.forEach((testKey) => {
+ const testEntry = testMap[testKey];
+ [].concat(testEntry).forEach((emojiUnicode) => {
+ ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
+ writeIndex += 1;
+ });
+ });
+
+ // Read from the canvas
+ const resultMap = {};
+ let readIndex = 0;
+ testMapKeys.forEach((testKey) => {
+ const testEntry = testMap[testKey];
+ // This needs to be a `reduce` instead of `every` because we need to
+ // keep the `readIndex` in sync from the writes by running all entries
+ const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => {
+ // Sample along the vertical-middle for a couple of characters
+ const imageData = ctx.getImageData(
+ 0,
+ (readIndex * fontSize) + (fontSize / 2),
+ 2 * fontSize,
+ 1,
+ ).data;
+
+ let isValidEmoji = false;
+ for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
+ const isLookingAtFirstChar = currentPixel < fontSize;
+ const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
+ // Check for the emoji somewhere along the row
+ if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
+ isValidEmoji = true;
+
+ // Check to see that nothing is rendered next to the first character
+ // to ensure that the ZWJ sequence rendered as one piece
+ } else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
+ isValidEmoji = false;
+ break;
+ }
+ }
+
+ readIndex += 1;
+ return isSatisfied && isValidEmoji;
+ }, true);
+
+ resultMap[testKey] = isTestSatisfied;
+ });
+
+ resultMap.meta = {
+ isChrome,
+ chromeVersion,
+ };
+
+ return resultMap;
+}
+
+export default function getUnicodeSupportMap() {
+ let unicodeSupportMap;
+ let userAgentFromCache;
+
+ const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+
+ try {
+ unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
+ } catch (err) {
+ // swallow
+ }
+
+ if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
+ unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
+
+ if (isLocalStorageAvailable) {
+ window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
+ window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+ }
+ }
+
+ return unicodeSupportMap;
+}
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index f319d6ca0c8..91ed8c8467f 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -1,21 +1,28 @@
<script>
-
-/* eslint-disable no-new */
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from './environments_table.vue';
+import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils';
import eventHub from '../event_hub';
+import Poll from '../../lib/utils/poll';
+import environmentsMixin from '../mixins/environments_mixin';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore();
@@ -25,7 +32,6 @@ export default {
state: store.state,
visibility: 'available',
isLoading: false,
- isLoadingFolderContent: false,
cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment,
@@ -35,6 +41,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
+ isMakingRequest: false,
// Pagination Properties,
paginationInformation: {},
@@ -65,17 +72,42 @@ export default {
* Toggles loading property.
*/
created() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
this.service = new EnvironmentsService(this.endpoint);
- this.fetchEnvironments();
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
- eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
+ eventHub.$on('postAction', this.postAction);
},
- beforeDestroyed() {
- eventHub.$off('refreshEnvironments');
+ beforeDestroy() {
eventHub.$off('toggleFolder');
+ eventHub.$off('postAction');
},
methods: {
@@ -83,7 +115,7 @@ export default {
this.store.toggleFolder(folder);
if (!folder.isOpen) {
- this.fetchChildEnvironments(folder, folderUrl);
+ this.fetchChildEnvironments(folder, folderUrl, true);
}
},
@@ -102,43 +134,58 @@ export default {
fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true;
- return this.service.get(scope, pageNumber)
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occurred while fetching the environments.');
- });
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
},
- fetchChildEnvironments(folder, folderUrl) {
- this.isLoadingFolderContent = true;
+ fetchChildEnvironments(folder, folderUrl, showLoader = false) {
+ this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
this.service.getFolderContent(folderUrl)
.then(resp => resp.json())
- .then((response) => {
- this.store.setfolderContent(folder, response.environments);
- this.isLoadingFolderContent = false;
- })
+ .then(response => this.store.setfolderContent(folder, response.environments))
+ .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
.catch(() => {
- this.isLoadingFolderContent = false;
+ // eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
+ this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
+ });
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+
+ // We need to verify if any folder is open to also update it
+ const openFolders = this.store.getOpenFolders();
+ if (openFolders.length) {
+ openFolders.forEach((folder) => {
+ // TODO - Move this to the backend
+ const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
+
+ return this.fetchChildEnvironments(folder, folderUrl);
});
+ }
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
},
},
};
@@ -177,15 +224,12 @@ export default {
</div>
</div>
- <div class="content-list environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
-
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+ <div class="environments-container">
+ <loading-icon
+ label="Loading environments"
+ size="3"
+ v-if="isLoading"
+ />
<div
class="blank-state blank-state-no-icon"
@@ -205,7 +249,7 @@ export default {
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
- New Environment
+ New environment
</a>
</div>
@@ -217,8 +261,7 @@ export default {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
- :service="service"
- :is-loading-folder-content="isLoadingFolderContent" />
+ />
</div>
<table-pagination
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index e81c97260d7..e7495677e7c 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,9 +1,8 @@
<script>
-/* global Flash */
-/* eslint-disable no-new */
-
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
@@ -12,11 +11,14 @@ export default {
required: false,
default: () => [],
},
+ },
- service: {
- type: Object,
- required: true,
- },
+ directives: {
+ tooltip,
+ },
+
+ components: {
+ loadingIcon,
},
data() {
@@ -36,17 +38,7 @@ export default {
onClickAction(endpoint) {
this.isLoading = true;
- $(this.$refs.tooltip).tooltip('destroy');
-
- this.service.postAction(endpoint)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ eventHub.$emit('postAction', endpoint);
},
isActionDisabled(action) {
@@ -64,11 +56,11 @@ export default {
class="btn-group"
role="group">
<button
+ v-tooltip
type="button"
- class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip"
+ class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container"
data-container="body"
data-toggle="dropdown"
- ref="tooltip"
:title="title"
:aria-label="title"
:disabled="isLoading">
@@ -77,10 +69,7 @@ export default {
<i
class="fa fa-caret-down"
aria-hidden="true"/>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
+ <loading-icon v-if="isLoading" />
</span>
</button>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index eaeec2bc53c..6b749814ea4 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,4 +1,6 @@
<script>
+import tooltip from '../../vue_shared/directives/tooltip';
+
/**
* Renders the external url link in environments table.
*/
@@ -10,6 +12,10 @@ export default {
},
},
+ directives: {
+ tooltip,
+ },
+
computed: {
title() {
return 'Open';
@@ -19,7 +25,8 @@ export default {
</script>
<template>
<a
- class="btn external-url has-tooltip"
+ v-tooltip
+ class="btn external-url"
data-container="body"
target="_blank"
rel="noopener noreferrer nofollow"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 73679de6039..d8b1b2f1b92 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,5 +1,7 @@
<script>
import Timeago from 'timeago.js';
+import _ from 'underscore';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -7,7 +9,7 @@ import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
-import CommitComponent from '../../vue_shared/components/commit';
+import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
/**
@@ -19,6 +21,7 @@ const timeagoInstance = new Timeago();
export default {
components: {
+ userAvatarLink,
'commit-component': CommitComponent,
'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent,
@@ -46,11 +49,6 @@ export default {
required: false,
default: false,
},
-
- service: {
- type: Object,
- required: true,
- },
},
computed: {
@@ -64,7 +62,7 @@ export default {
hasLastDeploymentKey() {
if (this.model &&
this.model.last_deployment &&
- !this.$options.isObjectEmpty(this.model.last_deployment)) {
+ !_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
@@ -315,8 +313,8 @@ export default {
*/
deploymentHasUser() {
return this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user);
},
/**
@@ -327,8 +325,8 @@ export default {
*/
deploymentUser() {
if (this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user)) {
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)) {
return this.model.last_deployment.user;
}
return {};
@@ -343,8 +341,8 @@ export default {
*/
shouldRenderBuildName() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.deployable);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable);
},
/**
@@ -385,7 +383,7 @@ export default {
*/
shouldRenderDeploymentID() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined;
},
@@ -405,6 +403,14 @@ export default {
return '';
},
+ displayEnvironmentActions() {
+ return this.hasManualActions ||
+ this.externalURL ||
+ this.monitoringUrl ||
+ this.hasStopAction ||
+ this.canRetry;
+ },
+
/**
* Constructs folder URL based on the current location and the folder id.
*
@@ -415,21 +421,6 @@ export default {
},
},
- /**
- * Helper to verify if certain given object are empty.
- * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty
- * @param {Object} object
- * @returns {Bollean}
- */
- isObjectEmpty(object) {
- for (const key in object) { // eslint-disable-line
- if (hasOwnProperty.call(object, key)) {
- return false;
- }
- }
- return true;
- },
-
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
@@ -438,14 +429,21 @@ export default {
};
</script>
<template>
- <tr :class="{ 'js-child-row': model.isChildren }">
- <td>
+ <div
+ :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"
+ role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header"
+ role="rowheader">
+ Environment
+ </div>
<a
v-if="!model.isFolder"
- class="environment-name"
- :class="{ 'prepend-left-default': model.isChildren }"
+ class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- {{model.name}}
+ <span class="flex-truncate-child">{{model.name}}</span>
</a>
<span
v-else
@@ -478,40 +476,44 @@ export default {
{{model.size}}
</span>
</span>
- </td>
+ </div>
- <td class="deployment-column">
+ <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
<span v-if="shouldRenderDeploymentID">
{{deploymentInternalId}}
</span>
<span v-if="!model.isFolder && deploymentHasUser">
by
- <a
- :href="deploymentUser.web_url"
- class="js-deploy-user-container">
- <img
- class="avatar has-tooltip s20"
- :src="deploymentUser.avatar_url"
- :alt="userImageAltDescription"
- :title="deploymentUser.username" />
- </a>
+ <user-avatar-link
+ class="js-deploy-user-container"
+ :link-href="deploymentUser.web_url"
+ :img-src="deploymentUser.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="deploymentUser.username"
+ />
</span>
- </td>
+ </div>
- <td class="environments-build-cell">
+ <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
<a
v-if="shouldRenderBuildName"
- class="build-link"
+ class="build-link flex-truncate-parent"
:href="buildPath">
- {{buildName}}
+ <span class="flex-truncate-child">{{buildName}}</span>
</a>
- </td>
+ </div>
- <td>
+ <div class="table-section section-25" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Commit
+ </div>
<div
v-if="!model.isFolder && hasLastDeploymentKey"
- class="js-commit-component">
+ class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
@@ -520,55 +522,67 @@ export default {
:title="commitTitle"
:author="commitAuthor"/>
</div>
- <p
+ <div
v-if="!model.isFolder && !hasLastDeploymentKey"
- class="commit-title">
+ class="commit-title table-mobile-content">
No deployments yet
- </p>
- </td>
+ </div>
+ </div>
- <td>
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Updated
+ </div>
<span
v-if="!model.isFolder && canShowDate"
- class="environment-created-date-timeago">
+ class="environment-created-date-timeago table-mobile-content">
{{createdDate}}
</span>
- </td>
+ </div>
+
+ <div
+ v-if="!model.isFolder && displayEnvironmentActions"
+ class="table-section section-30 table-button-footer"
+ role="gridcell">
- <td class="environments-actions">
<div
- v-if="!model.isFolder"
- class="btn-group pull-right"
+ class="btn-group table-action-buttons"
role="group">
<actions-component
v-if="hasManualActions && canCreateDeployment"
- :service="service"
- :actions="manualActions"/>
+ :actions="manualActions"
+ />
<external-url-component
v-if="externalURL && canReadEnvironment"
- :external-url="externalURL"/>
+ :external-url="externalURL"
+ />
<monitoring-button-component
v-if="monitoringUrl && canReadEnvironment"
- :monitoring-url="monitoringUrl"/>
+ :monitoring-url="monitoringUrl"
+ />
<terminal-button-component
v-if="model && model.terminal_path"
- :terminal-path="model.terminal_path"/>
+ :terminal-path="model.terminal_path"
+ />
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
- :service="service"/>
+ />
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
- :service="service"/>
+ />
</div>
- </td>
- </tr>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 4b030a27900..1655561cdd3 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -2,6 +2,8 @@
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
+import tooltip from '../../vue_shared/directives/tooltip';
+
export default {
props: {
monitoringUrl: {
@@ -10,6 +12,10 @@ export default {
},
},
+ directives: {
+ tooltip,
+ },
+
computed: {
title() {
return 'Monitoring';
@@ -19,9 +25,9 @@ export default {
</script>
<template>
<a
- class="btn monitoring-url has-tooltip"
+ v-tooltip
+ class="btn monitoring-url hidden-xs hidden-sm"
data-container="body"
- target="_blank"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
:title="title"
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index f139f24036f..49dba38edfb 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -1,6 +1,4 @@
<script>
-/* global Flash */
-/* eslint-disable no-new */
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
@@ -8,6 +6,7 @@
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -20,11 +19,10 @@ export default {
type: Boolean,
default: true,
},
+ },
- service: {
- type: Object,
- required: true,
- },
+ components: {
+ loadingIcon,
},
data() {
@@ -37,17 +35,7 @@ export default {
onClick() {
this.isLoading = true;
- $(this.$el).tooltip('destroy');
-
- this.service.postAction(this.retryUrl)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ eventHub.$emit('postAction', this.retryUrl);
},
},
};
@@ -55,7 +43,7 @@ export default {
<template>
<button
type="button"
- class="btn"
+ class="btn hidden-xs hidden-sm"
@click="onClick"
:disabled="isLoading">
@@ -66,9 +54,6 @@ export default {
Rollback
</span>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index 11e9aff7b92..85f11d2071b 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,11 +1,11 @@
<script>
-/* global Flash */
-/* eslint-disable no-new, no-alert */
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
@@ -13,11 +13,10 @@ export default {
type: String,
default: '',
},
+ },
- service: {
- type: Object,
- required: true,
- },
+ directives: {
+ tooltip,
},
data() {
@@ -26,6 +25,10 @@ export default {
};
},
+ components: {
+ loadingIcon,
+ },
+
computed: {
title() {
return 'Stop';
@@ -34,20 +37,13 @@ export default {
methods: {
onClick() {
+ // eslint-disable-next-line no-alert
if (confirm('Are you sure you want to stop this environment?')) {
this.isLoading = true;
$(this.$el).tooltip('destroy');
- this.service.postAction(this.retryUrl)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshEnvironments');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.', 'alert');
- });
+ eventHub.$emit('postAction', this.stopUrl);
}
},
},
@@ -55,8 +51,9 @@ export default {
</script>
<template>
<button
+ v-tooltip
type="button"
- class="btn stop-env-link has-tooltip"
+ class="btn stop-env-link hidden-xs hidden-sm"
data-container="body"
@click="onClick"
:disabled="isLoading"
@@ -65,9 +62,6 @@ export default {
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index c8c1f17d4d8..2037bf618e3 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -4,6 +4,7 @@
* Used in environments table.
*/
import terminalIconSvg from 'icons/_icon_terminal.svg';
+import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
@@ -14,6 +15,10 @@ export default {
},
},
+ directives: {
+ tooltip,
+ },
+
data() {
return {
terminalIconSvg,
@@ -29,7 +34,8 @@ export default {
</script>
<template>
<a
- class="btn terminal-button has-tooltip"
+ v-tooltip
+ class="btn terminal-button hidden-xs hidden-sm"
data-container="body"
:title="title"
:aria-label="title"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 87f7cb4a536..175cc8f1f72 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -3,10 +3,12 @@
* Render environments table.
*/
import EnvironmentTableRowComponent from './environment_item.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
'environment-item': EnvironmentTableRowComponent,
+ loadingIcon,
},
props: {
@@ -27,17 +29,6 @@ export default {
required: false,
default: false,
},
-
- service: {
- type: Object,
- required: true,
- },
-
- isLoadingFolderContent: {
- type: Boolean,
- required: false,
- default: false,
- },
},
methods: {
@@ -48,70 +39,59 @@ export default {
};
</script>
<template>
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="environments-name">
- Environment
- </th>
- <th class="environments-deploy">
- Last deployment
- </th>
- <th class="environments-build">
- Job
- </th>
- <th class="environments-commit">
- Commit
- </th>
- <th class="environments-date">
- Updated
- </th>
- <th class="environments-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template
- v-for="model in environments"
- v-bind:model="model">
- <tr
- is="environment-item"
- :model="model"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- :service="service" />
+ <div class="ci-table" role="grid">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 environments-name" role="columnheader">
+ Environment
+ </div>
+ <div class="table-section section-10 environments-deploy" role="columnheader">
+ Deployment
+ </div>
+ <div class="table-section section-15 environments-build" role="columnheader">
+ Job
+ </div>
+ <div class="table-section section-25 environments-commit" role="columnheader">
+ Commit
+ </div>
+ <div class="table-section section-10 environments-date" role="columnheader">
+ Updated
+ </div>
+ </div>
+ <template
+ v-for="model in environments"
+ v-bind:model="model">
+ <div
+ is="environment-item"
+ :model="model"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <tr v-if="isLoadingFolderContent">
- <td colspan="6" class="text-center">
- <i
- class="fa fa-spin fa-spinner fa-2x"
- aria-hidden="true" />
- </td>
- </tr>
+ <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
+ <div v-if="model.isLoadingFolderContent">
+ <loading-icon size="2" />
+ </div>
- <template v-else>
- <tr
- is="environment-item"
- v-for="children in model.children"
- :model="children"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- :service="service" />
+ <template v-else>
+ <div
+ is="environment-item"
+ v-for="children in model.children"
+ :model="children"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <tr>
- <td
- colspan="6"
- class="text-center">
- <a
- :href="folderUrl(model)"
- class="btn btn-default">
- Show all
- </a>
- </td>
- </tr>
- </template>
+ <div>
+ <div class="text-center prepend-top-10">
+ <a
+ :href="folderUrl(model)"
+ class="btn btn-default">
+ Show all
+ </a>
+ </div>
+ </div>
</template>
</template>
- </tbody>
- </table>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index d27b2acfcdf..925503a01c4 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,19 +1,27 @@
<script>
-/* eslint-disable no-new */
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from '../components/environments_table.vue';
+import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import environmentsMixin from '../mixins/environments_mixin';
import '../../lib/utils/common_utils';
-import '../../vue_shared/vue_resource_interceptor';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore();
@@ -75,32 +83,39 @@ export default {
*/
created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
-
- const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
-
- this.service = new EnvironmentsService(endpoint);
-
- this.isLoading = true;
-
- return this.service.get()
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occurred while fetching the environments.', 'alert');
- });
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.service = new EnvironmentsService(this.endpoint);
+
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
+
+ eventHub.$on('postAction', this.postAction);
+ },
+
+ beforeDestroyed() {
+ eventHub.$off('postAction');
},
methods: {
@@ -115,6 +130,37 @@ export default {
gl.utils.visitUrl(param);
return param;
},
+
+ fetchEnvironments() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.isLoading = true;
+
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
},
};
</script>
@@ -153,13 +199,12 @@ export default {
</div>
<div class="environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
- </div>
+
+ <loading-icon
+ label="Loading environments"
+ v-if="isLoading"
+ size="3"
+ />
<div
class="table-holder"
@@ -169,7 +214,7 @@ export default {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
- :service="service"/>
+ />
<table-pagination
v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
new file mode 100644
index 00000000000..8f4066e3a6e
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -0,0 +1,15 @@
+export default {
+ methods: {
+ saveData(resp) {
+ const headers = resp.headers;
+ return resp.json().then((response) => {
+ this.isLoading = false;
+
+ this.store.storeAvailableCount(response.available_count);
+ this.store.storeStoppedCount(response.stopped_count);
+ this.store.storeEnvironments(response.environments);
+ this.store.setPagination(headers);
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 8adb53ea86d..03ab74b3338 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3;
}
- get(scope, page) {
+ get(options = {}) {
+ const { scope, page } = options;
return this.environments.get({ scope, page });
}
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 158e7922e3c..038c149be2d 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -35,14 +35,18 @@ export default class EnvironmentsStore {
*/
storeEnvironments(environments = []) {
const filteredEnvironments = environments.map((env) => {
+ const oldEnvironmentState = this.state.environments
+ .find(element => element.id === env.latest.id) || {};
+
let filtered = {};
if (env.size > 1) {
filtered = Object.assign({}, env, {
isFolder: true,
+ isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name,
- isOpen: false,
- children: [],
+ isOpen: oldEnvironmentState.isOpen || false,
+ children: oldEnvironmentState.children || [],
});
}
@@ -98,7 +102,7 @@ export default class EnvironmentsStore {
* @return {Array}
*/
toggleFolder(folder) {
- return this.updateFolder(folder, 'isOpen', !folder.isOpen);
+ return this.updateEnvironmentProp(folder, 'isOpen', !folder.isOpen);
}
/**
@@ -125,23 +129,23 @@ export default class EnvironmentsStore {
return updated;
});
- return this.updateFolder(folder, 'children', updatedEnvironments);
+ return this.updateEnvironmentProp(folder, 'children', updatedEnvironments);
}
/**
- * Given a folder a prop and a new value updates the correct folder.
+ * Given a environment, a prop and a new value updates the correct environment.
*
- * @param {Object} folder
+ * @param {Object} environment
* @param {String} prop
* @param {String|Boolean|Object|Array} newValue
* @return {Array}
*/
- updateFolder(folder, prop, newValue) {
+ updateEnvironmentProp(environment, prop, newValue) {
const environments = this.state.environments;
const updatedEnvironments = environments.map((env) => {
const updateEnv = Object.assign({}, env);
- if (env.isFolder && env.id === folder.id) {
+ if (env.id === environment.id) {
updateEnv[prop] = newValue;
}
@@ -149,8 +153,11 @@ export default class EnvironmentsStore {
});
this.state.environments = updatedEnvironments;
-
- return updatedEnvironments;
}
+ getOpenFolders() {
+ const environments = this.state.environments;
+
+ return environments.filter(env => env.isFolder && env.isOpen);
+ }
}
diff --git a/app/assets/javascripts/experimental_flags.js b/app/assets/javascripts/experimental_flags.js
new file mode 100644
index 00000000000..6ee65ca72f9
--- /dev/null
+++ b/app/assets/javascripts/experimental_flags.js
@@ -0,0 +1,14 @@
+import Cookies from 'js-cookie';
+
+export default () => {
+ $('.js-experiment-feature-toggle').on('change', (e) => {
+ const el = e.target;
+
+ Cookies.set(el.name, el.value, {
+ expires: 365 * 10,
+ });
+
+ document.body.scrollTop = 0;
+ window.location.reload();
+ });
+};
diff --git a/app/assets/javascripts/extensions/array.js b/app/assets/javascripts/extensions/array.js
deleted file mode 100644
index 027222f804d..00000000000
--- a/app/assets/javascripts/extensions/array.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// TODO: remove this
-
-// eslint-disable-next-line no-extend-native
-Array.prototype.first = function first() {
- return this[0];
-};
-
-// eslint-disable-next-line no-extend-native
-Array.prototype.last = function last() {
- return this[this.length - 1];
-};
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 59d6508fc02..d02e4cd5876 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -1,151 +1,73 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len, one-var, one-var-declaration-per-line, quotes, prefer-template, newline-per-chained-call, comma-dangle, new-cap, no-else-return, consistent-return */
-/* global FilesCommentButton */
/* global notes */
-let $commentButtonTemplate;
-var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
-window.FilesCommentButton = (function() {
- var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS;
-
- COMMENT_BUTTON_CLASS = '.add-diff-note';
-
- LINE_HOLDER_CLASS = '.line_holder';
-
- LINE_NUMBER_CLASS = 'diff-line-num';
-
- LINE_CONTENT_CLASS = 'line_content';
-
- UNFOLDABLE_LINE_CLASS = 'js-unfold';
-
- EMPTY_CELL_CLASS = 'empty-cell';
-
- OLD_LINE_CLASS = 'old_line';
-
- LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content";
-
- TEXT_FILE_SELECTOR = '.text-file';
-
- function FilesCommentButton(filesContainerElement) {
- this.render = bind(this.render, this);
- this.hideButton = bind(this.hideButton, this);
- this.isParallelView = notes.isParallelView();
- filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render)
- .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton);
- }
-
- FilesCommentButton.prototype.render = function(e) {
- var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button;
- $currentTarget = $(e.currentTarget);
-
- if ($currentTarget.hasClass('js-no-comment-btn')) return;
-
- lineContentElement = this.getLineContent($currentTarget);
- buttonParentElement = this.getButtonParent($currentTarget);
-
- if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return;
-
- $button = $(COMMENT_BUTTON_CLASS, buttonParentElement);
- buttonParentElement.addClass('is-over')
- .nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over');
-
- if ($button.length) {
- return;
+/* Developer beware! Do not add logic to showButton or hideButton
+ * that will force a reflow. Doing so will create a signficant performance
+ * bottleneck for pages with large diffs. For a comprehensive list of what
+ * causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a
+ */
+
+const LINE_NUMBER_CLASS = 'diff-line-num';
+const UNFOLDABLE_LINE_CLASS = 'js-unfold';
+const NO_COMMENT_CLASS = 'no-comment-btn';
+const EMPTY_CELL_CLASS = 'empty-cell';
+const OLD_LINE_CLASS = 'old_line';
+const LINE_COLUMN_CLASSES = `.${LINE_NUMBER_CLASS}, .line_content`;
+const DIFF_CONTAINER_SELECTOR = '.files';
+const DIFF_EXPANDED_CLASS = 'diff-expanded';
+
+export default {
+ init($diffFile) {
+ /* Caching is used only when the following members are *true*. This is because there are likely to be
+ * differently configured versions of diffs in the same session. However if these values are true, they
+ * will be true in all cases */
+
+ if (!this.userCanCreateNote) {
+ // data-can-create-note is an empty string when true, otherwise undefined
+ this.userCanCreateNote = $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('can-create-note') === '';
}
- 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'),
-
- // LegacyDiffNote
- lineCode: lineContentElement.attr('data-line-code'),
-
- // DiffNote
- position: lineContentElement.attr('data-position')
- }));
- };
-
- FilesCommentButton.prototype.hideButton = function(e) {
- var $currentTarget = $(e.currentTarget);
- var buttonParentElement = this.getButtonParent($currentTarget);
-
- buttonParentElement.removeClass('is-over')
- .nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over');
- };
-
- 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,
-
- // DiffNote
- 'data-position': buttonAttributes.position
- });
- };
-
- FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) {
- return hoveredElement.closest(TEXT_FILE_SELECTOR);
- };
-
- FilesCommentButton.prototype.getLineContent = function(hoveredElement) {
- if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) {
- return hoveredElement;
- }
- if (!this.isParallelView) {
- return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS);
- } else {
- return $(hoveredElement).next("." + LINE_CONTENT_CLASS);
+ if (typeof notes !== 'undefined' && !this.isParallelView) {
+ this.isParallelView = notes.isParallelView && notes.isParallelView();
}
- };
- FilesCommentButton.prototype.getButtonParent = function(hoveredElement) {
- if (!this.isParallelView) {
- if (hoveredElement.hasClass(OLD_LINE_CLASS)) {
- return hoveredElement;
- }
- return hoveredElement.parent().find("." + OLD_LINE_CLASS);
- } else {
- if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) {
- return hoveredElement;
- }
- return $(hoveredElement).prev("." + LINE_NUMBER_CLASS);
+ if (this.userCanCreateNote) {
+ $diffFile.on('mouseover', LINE_COLUMN_CLASSES, e => this.showButton(this.isParallelView, e))
+ .on('mouseleave', LINE_COLUMN_CLASSES, e => this.hideButton(this.isParallelView, e));
}
- };
+ },
- FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) {
- return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS);
- };
+ showButton(isParallelView, e) {
+ const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
- FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
- return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== '';
- };
+ if (!this.validateButtonParent(buttonParentElement)) return;
- return FilesCommentButton;
-})();
+ buttonParentElement.classList.add('is-over');
+ buttonParentElement.nextElementSibling.classList.add('is-over');
+ },
-$.fn.filesCommentButton = function() {
- $commentButtonTemplate = $('<button name="button" type="submit" class="add-diff-note js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>');
+ hideButton(isParallelView, e) {
+ const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
- if (!(this && (this.parent().data('can-create-note') != null))) {
- return;
- }
- return this.each(function() {
- if (!$.data(this, 'filesCommentButton')) {
- return $.data(this, 'filesCommentButton', new FilesCommentButton($(this)));
+ buttonParentElement.classList.remove('is-over');
+ buttonParentElement.nextElementSibling.classList.remove('is-over');
+ },
+
+ getButtonParent(hoveredElement, isParallelView) {
+ if (isParallelView) {
+ if (!hoveredElement.classList.contains(LINE_NUMBER_CLASS)) {
+ return hoveredElement.previousElementSibling;
+ }
+ } else if (!hoveredElement.classList.contains(OLD_LINE_CLASS)) {
+ return hoveredElement.parentNode.querySelector(`.${OLD_LINE_CLASS}`);
}
- });
+ return hoveredElement;
+ },
+
+ validateButtonParent(buttonParentElement) {
+ return !buttonParentElement.classList.contains(EMPTY_CELL_CLASS) &&
+ !buttonParentElement.classList.contains(UNFOLDABLE_LINE_CLASS) &&
+ !buttonParentElement.classList.contains(NO_COMMENT_CLASS) &&
+ !buttonParentElement.parentNode.classList.contains(DIFF_EXPANDED_CLASS);
+ },
};
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index aaaeb9bddb1..6d516a253bb 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+
/**
* Makes search request for content when user types a value in the search input.
* Updates the html content of the page with the received one.
@@ -8,39 +10,87 @@ export default class FilterableList {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
+ this.isBusy = false;
+ }
+
+ getFilterEndpoint() {
+ return `${this.filterForm.getAttribute('action')}?${$(this.filterForm).serialize()}`;
+ }
+
+ getPagePath() {
+ return this.getFilterEndpoint();
}
initSearch() {
- this.debounceFilter = _.debounce(this.filterResults.bind(this), 500);
+ // Wrap to prevent passing event arguments to .filterResults;
+ this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500);
- this.listFilterElement.removeEventListener('input', this.debounceFilter);
+ this.unbindEvents();
+ this.bindEvents();
+ }
+
+ onFilterInput() {
+ const $form = $(this.filterForm);
+ const queryData = {};
+ const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+
+ if (filterGroupsParam) {
+ queryData.filter_groups = filterGroupsParam;
+ }
+
+ this.filterResults(queryData);
+
+ if (this.setDefaultFilterOption) {
+ this.setDefaultFilterOption();
+ }
+ }
+
+ bindEvents() {
this.listFilterElement.addEventListener('input', this.debounceFilter);
}
- filterResults() {
- const form = this.filterForm;
- const filterUrl = `${form.getAttribute('action')}?${$(form).serialize()}`;
+ unbindEvents() {
+ this.listFilterElement.removeEventListener('input', this.debounceFilter);
+ }
+
+ filterResults(queryData) {
+ if (this.isBusy) {
+ return false;
+ }
$(this.listHolderElement).fadeTo(250, 0.5);
return $.ajax({
- url: form.getAttribute('action'),
- data: $(form).serialize(),
+ url: this.getFilterEndpoint(),
+ data: queryData,
type: 'GET',
dataType: 'json',
context: this,
- complete() {
- $(this.listHolderElement).fadeTo(250, 1);
+ complete: this.onFilterComplete,
+ beforeSend: () => {
+ this.isBusy = true;
},
- success(data) {
- this.listHolderElement.innerHTML = data.html;
-
- // Change url so if user reload a page - search results are saved
- return window.history.replaceState({
- page: filterUrl,
-
- }, document.title, filterUrl);
+ success: (response, textStatus, xhr) => {
+ this.onFilterSuccess(response, xhr, queryData);
},
});
}
+
+ onFilterSuccess(response, xhr, queryData) {
+ if (response.html) {
+ this.listHolderElement.innerHTML = response.html;
+ }
+
+ // Change url so if user reload a page - search results are saved
+ const currentPath = this.getPagePath(queryData);
+
+ return window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+ }
+
+ onFilterComplete() {
+ this.isBusy = false;
+ $(this.listHolderElement).fadeTo(250, 1);
+ }
}
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
index 9126422b335..c51d4b056af 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
@@ -8,13 +8,22 @@ export default {
type: Array,
required: true,
},
+ isLocalStorageAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ allowedKeys: {
+ type: Array,
+ required: true,
+ },
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(item);
+ = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
@@ -47,7 +56,12 @@ export default {
template: `
<div>
- <ul v-if="hasItems">
+ <div
+ v-if="!isLocalStorageAvailable"
+ class="dropdown-info-note">
+ This feature requires local storage to be enabled
+ </div>
+ <ul v-else-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 3e7a892756c..a81389ab088 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,16 +1,20 @@
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownHint extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
- super(droplab, dropdown, input, filter);
+ constructor(options = {}) {
+ const { input, tokenKeys } = options;
+ super(options);
this.config = {
Filter: {
template: 'hint',
- filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
+ filterFunction: gl.DropdownUtils.filterHint.bind(null, {
+ input,
+ allowedKeys: tokenKeys.getKeys(),
+ }),
},
};
+ this.tokenKeys = tokenKeys;
}
itemClicked(e) {
@@ -53,20 +57,13 @@ class DropdownHint extends gl.FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = [];
-
- [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
- const { icon, hint, tag, type } = dropdownMenu.dataset;
- if (icon && hint && tag) {
- dropdownData.push(
- Object.assign({
- icon: `fa-${icon}`,
- hint,
- tag: `&lt;${tag}&gt;`,
- }, type && { type }),
- );
- }
- });
+ const dropdownData = this.tokenKeys.get()
+ .map(tokenKey => ({
+ icon: `fa-${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `<${tokenKey.symbol}${tokenKey.key}>`,
+ type: tokenKey.type,
+ }));
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 982dc4b61be..0bc4b6f22a9 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -2,18 +2,19 @@
import Ajax from '~/droplab/plugins/ajax';
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter, endpoint, symbol) {
- super(droplab, dropdown, input, filter);
+ constructor(options = {}) {
+ const { input, endpoint, symbol, preprocessing } = options;
+ super(options);
this.symbol = symbol;
this.config = {
Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
+ preprocessing,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 74cec3d75fe..7246ccbb281 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,12 +1,13 @@
/* global Flash */
import AjaxFilter from '~/droplab/plugins/ajax_filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
+import { addClassIfElementExists } from '../lib/utils/dom_utils';
class DropdownUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
- super(droplab, dropdown, input, filter);
+ constructor(options = {}) {
+ const { tokenKeys } = options;
+ super(options);
this.config = {
AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
@@ -19,6 +20,9 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
+ },
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
@@ -26,6 +30,11 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
},
};
+ this.tokenKeys = tokenKeys;
+ }
+
+ hideCurrentUser() {
+ addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden');
}
itemClicked(e) {
@@ -44,7 +53,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
- const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
+ const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index bc7c1dffece..8d711e3213c 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import FilteredSearchContainer from './container';
class DropdownUtils {
@@ -50,21 +51,83 @@ class DropdownUtils {
return updatedItem;
}
- static filterHint(input, item) {
+ static mergeDuplicateLabels(dataMap, newLabel) {
+ const updatedMap = dataMap;
+ const key = newLabel.title;
+
+ const hasKeyProperty = Object.prototype.hasOwnProperty.call(updatedMap, key);
+
+ if (!hasKeyProperty) {
+ updatedMap[key] = newLabel;
+ } else {
+ const existing = updatedMap[key];
+
+ if (!existing.multipleColors) {
+ existing.multipleColors = [existing.color];
+ }
+
+ existing.multipleColors.push(newLabel.color);
+ }
+
+ return updatedMap;
+ }
+
+ static duplicateLabelColor(labelColors) {
+ const colors = labelColors;
+ const spacing = 100 / colors.length;
+
+ // Reduce the colors to 4
+ colors.length = Math.min(colors.length, 4);
+
+ const color = colors.map((c, i) => {
+ const percentFirst = Math.floor(spacing * i);
+ const percentSecond = Math.floor(spacing * (i + 1));
+ return `${c} ${percentFirst}%, ${c} ${percentSecond}%`;
+ }).join(', ');
+
+ return `linear-gradient(${color})`;
+ }
+
+ static duplicateLabelPreprocessing(data) {
+ const results = [];
+ const dataMap = {};
+
+ data.forEach(DropdownUtils.mergeDuplicateLabels.bind(null, dataMap));
+
+ Object.keys(dataMap)
+ .forEach((key) => {
+ const label = dataMap[key];
+
+ if (label.multipleColors) {
+ label.color = DropdownUtils.duplicateLabelColor(label.multipleColors);
+ label.text_color = '#000000';
+ }
+
+ results.push(label);
+ });
+
+ results.preprocessed = true;
+
+ return results;
+ }
+
+ static filterHint(config, item) {
+ const { input, allowedKeys } = config;
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input);
- const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
+ const { lastToken, tokens } =
+ gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
- } else if (!lastKey || searchInput.split('').last() === ' ') {
+ } else if (!lastKey || _.last(searchInput.split('')) === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
- const tokenName = split[0].split(' ').last();
+ const tokenName = _.last(split[0].split(' '));
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
@@ -100,10 +163,13 @@ class DropdownUtils {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
+ const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
- if (value && value.innerText) {
+ if (valueContainer && valueContainer.dataset.originalValue) {
+ valueText = valueContainer.dataset.originalValue;
+ } else if (value && value.innerText) {
valueText = value.innerText;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
index 856eb6590ee..132b6fe698a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
@@ -1,10 +1,10 @@
-require('./dropdown_hint');
-require('./dropdown_non_user');
-require('./dropdown_user');
-require('./dropdown_utils');
-require('./filtered_search_dropdown_manager');
-require('./filtered_search_dropdown');
-require('./filtered_search_manager');
-require('./filtered_search_token_keys');
-require('./filtered_search_tokenizer');
-require('./filtered_search_visual_tokens');
+import './dropdown_hint';
+import './dropdown_non_user';
+import './dropdown_user';
+import './dropdown_utils';
+import './filtered_search_token_keys';
+import './filtered_search_dropdown_manager';
+import './filtered_search_dropdown';
+import './filtered_search_manager';
+import './filtered_search_tokenizer';
+import './filtered_search_visual_tokens';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 4209ca0d6e2..9e9a9ef74be 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,7 +1,7 @@
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
class FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+ constructor({ droplab, dropdown, input, filter }) {
this.droplab = droplab;
this.hookId = input && input.id;
this.input = input;
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 49a6cd1ac77..dd1c067df87 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager {
- constructor(baseEndpoint = '', page) {
+ constructor(baseEndpoint = '', tokenizer, page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
- this.tokenizer = gl.FilteredSearchTokenizer;
+ this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
@@ -42,13 +42,20 @@ class FilteredSearchDropdownManager {
milestone: {
reference: null,
gl: 'DropdownNonUser',
- extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'],
+ extraArguments: {
+ endpoint: `${this.baseEndpoint}/milestones.json`,
+ symbol: '%',
+ },
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: 'DropdownNonUser',
- extraArguments: [`${this.baseEndpoint}/labels.json`, '~'],
+ extraArguments: {
+ endpoint: `${this.baseEndpoint}/labels.json`,
+ symbol: '~',
+ preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
+ },
element: this.container.querySelector('#js-dropdown-label'),
},
hint: {
@@ -97,12 +104,19 @@ class FilteredSearchDropdownManager {
let forceShowList = false;
if (!mappingKey.reference) {
- const dl = this.droplab;
- const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
- const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
+ const defaultArguments = {
+ droplab: this.droplab,
+ dropdown: element,
+ input: this.filteredSearchInput,
+ tokenKeys: this.filteredSearchTokenKeys,
+ filter: key,
+ };
+ const extraArguments = mappingKey.extraArguments || {};
+ const glArguments = Object.assign({}, defaultArguments, extraArguments);
// Passing glArguments to `new gl[glClass](<arguments>)`
- mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))();
+ mappingKey.reference =
+ new (Function.prototype.bind.apply(gl[glClass], [null, glArguments]))();
}
if (firstLoad) {
@@ -141,7 +155,8 @@ class FilteredSearchDropdownManager {
setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true);
- const { lastToken, searchToken } = this.tokenizer.processTokens(query);
+ const { lastToken, searchToken } =
+ this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
@@ -152,7 +167,7 @@ class FilteredSearchDropdownManager {
// Eg. token = 'label:'
const split = lastToken.split(':');
- const dropdownName = split[0].split(' ').last();
+ const dropdownName = _.last(split[0].split(' '));
this.loadDropdown(split.length > 1 ? dropdownName : '');
} else if (lastToken) {
// Token has been initialized into an object because it has a value
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 36af0674ac6..a31be2b0bc7 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,13 +1,13 @@
-/* global Flash */
-
import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
+import { addClassIfElementExists } from '../lib/utils/dom_utils';
class FilteredSearchManager {
constructor(page) {
+ this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
@@ -15,22 +15,36 @@ class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
- this.recentSearchesStore = new RecentSearchesStore();
- let recentSearchesKey = 'issue-recent-searches';
- if (page === 'merge_requests') {
- recentSearchesKey = 'merge-request-recent-searches';
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.filteredSearchTokenKeys.getKeys(),
+ });
+ this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
+ const fullPath = this.searchHistoryDropdownElement ?
+ this.searchHistoryDropdownElement.dataset.fullPath : 'project';
+ let recentSearchesPagePrefix = 'issue-recent-searches';
+ if (this.page === 'merge_requests') {
+ recentSearchesPagePrefix = 'merge-request-recent-searches';
}
+ const recentSearchesKey = `${fullPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
+ }
+ setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
- .catch(() => {
+ .catch((error) => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new Flash('An error occured while parsing recent searches');
+ new window.Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
.then((searches) => {
+ if (!searches) {
+ return;
+ }
+
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
@@ -41,12 +55,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
+ this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
- document.querySelector('.js-filtered-search-history-dropdown'),
+ this.searchHistoryDropdownElement,
);
this.recentSearchesRoot.init();
@@ -68,6 +82,41 @@ class FilteredSearchManager {
}
}
+ bindStateEvents() {
+ this.stateFilters = document.querySelector('.container-fluid .issues-state-filters');
+
+ if (this.stateFilters) {
+ this.searchStateWrapper = this.searchState.bind(this);
+
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .addEventListener('click', this.searchStateWrapper);
+
+ this.mergedState = this.stateFilters.querySelector('[data-state="merged"]');
+ if (this.mergedState) {
+ this.mergedState.addEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
+ unbindStateEvents() {
+ if (this.stateFilters) {
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .removeEventListener('click', this.searchStateWrapper);
+
+ if (this.mergedState) {
+ this.mergedState.removeEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
@@ -96,15 +145,15 @@ class FilteredSearchManager {
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.addEventListener('click', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
- document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.bindStateEvents();
}
unbindEvents() {
@@ -118,15 +167,15 @@ class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.removeEventListener('click', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
- document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.unbindStateEvents();
}
checkForBackspace(e) {
@@ -135,7 +184,9 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
- if (this.filteredSearchInput.value === '' && lastVisualToken) {
+ const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
+ const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
@@ -177,11 +228,7 @@ class FilteredSearchManager {
}
addInputContainerFocus() {
- const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
-
- if (inputContainer) {
- inputContainer.classList.add('focus');
- }
+ addClassIfElementExists(this.filteredSearchInput.closest('.filtered-search-box'), 'focus');
}
removeInputContainerFocus(e) {
@@ -196,23 +243,13 @@ class FilteredSearchManager {
}
}
- static selectToken(e) {
- const button = e.target.closest('.selectable');
- const removeButtonSelected = e.target.closest('.remove-token');
-
- if (!removeButtonSelected && button) {
- e.preventDefault();
- e.stopPropagation();
- gl.FilteredSearchVisualTokens.selectToken(button);
- }
- }
-
removeToken(e) {
const removeButtonSelected = e.target.closest('.remove-token');
if (removeButtonSelected) {
e.preventDefault();
- e.stopPropagation();
+ // Prevent editToken from being triggered after token is removed
+ e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
gl.FilteredSearchVisualTokens.selectToken(button, true);
@@ -234,8 +271,12 @@ class FilteredSearchManager {
editToken(e) {
const token = e.target.closest('.js-visual-token');
+ const sanitizedTokenName = token && token.querySelector('.name').textContent.trim();
+ const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
- if (token) {
+ if (token && canEdit) {
+ e.preventDefault();
+ e.stopPropagation();
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
@@ -313,7 +354,7 @@ class FilteredSearchManager {
handleInputVisualToken() {
const input = this.filteredSearchInput;
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(input.value);
+ = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
@@ -326,7 +367,7 @@ class FilteredSearchManager {
const fragments = searchToken.split(':');
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
- const tokenKey = inputValues.last();
+ const tokenKey = _.last(inputValues);
if (inputValues.length > 1) {
inputValues.pop();
@@ -385,7 +426,12 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
+ const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.value,
+ canEdit,
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -404,18 +450,27 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
+ const canEdit = this.canEdit && this.canEdit(sanitizedKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ sanitizedKey,
+ `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ canEdit,
+ );
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
+ const tokenName = 'assignee';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
+ const tokenName = 'author';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -432,15 +487,28 @@ class FilteredSearchManager {
}
}
- search() {
+ searchState(e) {
+ e.preventDefault();
+ const target = e.currentTarget;
+ // remove focus outline after click
+ target.blur();
+
+ const state = target.dataset && target.dataset.state;
+
+ if (state) {
+ this.search(state);
+ }
+ }
+
+ search(state = null) {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
- = this.tokenizer.processTokens(searchQuery);
- const currentState = gl.utils.getParameterByName('state') || 'opened';
+ = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
+ const currentState = state || gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
@@ -510,6 +578,11 @@ class FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
+
+ // eslint-disable-next-line class-methods-use-this
+ canEdit() {
+ return true;
+ }
}
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 1abad9d1b73..025d4d8795b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -3,21 +3,25 @@ const tokenKeys = [{
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'pencil',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'user',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
+ icon: 'clock-o',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
+ icon: 'tag',
}];
const alternativeTokenKeys = [{
@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys {
return tokenKeys;
}
+ static getKeys() {
+ return tokenKeys.map(i => i.key);
+ }
+
static getAlternatives() {
return alternativeTokenKeys;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index 2808e4b238a..f2e66503e5e 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -1,8 +1,7 @@
-require('./filtered_search_token_keys');
+import './filtered_search_token_keys';
class FilteredSearchTokenizer {
- static processTokens(input) {
- const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
+ static processTokens(input, allowedKeys) {
// Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 453ecccc6fc..243ee4d723a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,4 +1,7 @@
+import AjaxCache from '../lib/utils/ajax_cache';
+import '../flash'; /* global Flash */
import FilteredSearchContainer from './container';
+import UsersCache from '../lib/utils/users_cache';
class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
@@ -34,28 +37,124 @@ class FilteredSearchVisualTokens {
}
}
- static createVisualTokenElementHTML() {
+ static createVisualTokenElementHTML(canEdit = true) {
+ let removeTokenMarkup = '';
+ if (canEdit) {
+ removeTokenMarkup = `
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ `;
+ }
+
return `
<div class="selectable" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
- <div class="remove-token" role="button">
- <i class="fa fa-close"></i>
- </div>
+ ${removeTokenMarkup}
</div>
</div>
`;
}
- static addVisualTokenElement(name, value, isSearchTerm) {
+ static setTokenStyle(tokenContainer, backgroundColor, textColor) {
+ const token = tokenContainer;
+
+ // Labels with linear gradient should not override default background color
+ if (backgroundColor.indexOf('linear-gradient') === -1) {
+ token.style.backgroundColor = backgroundColor;
+ }
+
+ token.style.color = textColor;
+
+ if (textColor === '#FFFFFF') {
+ const removeToken = token.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+
+ return token;
+ }
+
+ static preprocessLabel(labelsEndpoint, labels) {
+ let processed = labels;
+
+ if (!labels.preprocessed) {
+ processed = gl.DropdownUtils.duplicateLabelPreprocessing(labels);
+ AjaxCache.override(labelsEndpoint, processed);
+ processed.preprocessed = true;
+ }
+
+ return processed;
+ }
+
+ static updateLabelTokenColor(tokenValueContainer, tokenValue) {
+ const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
+ const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
+ const labelsEndpoint = `${baseEndpoint}/labels.json`;
+
+ return AjaxCache.retrieve(labelsEndpoint)
+ .then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
+ .then((labels) => {
+ const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
+
+ if (!matchingLabel) {
+ return;
+ }
+
+ FilteredSearchVisualTokens
+ .setTokenStyle(tokenValueContainer, matchingLabel.color, matchingLabel.text_color);
+ })
+ .catch(() => new Flash('An error occurred while fetching label colors.'));
+ }
+
+ static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
+ if (tokenValue === 'none') {
+ return Promise.resolve();
+ }
+
+ const username = tokenValue.replace(/^@/, '');
+ return UsersCache.retrieve(username)
+ .then((user) => {
+ if (!user) {
+ return;
+ }
+
+ /* eslint-disable no-param-reassign */
+ tokenValueContainer.dataset.originalValue = tokenValue;
+ tokenValueElement.innerHTML = `
+ <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
+ ${user.name}
+ `;
+ /* eslint-enable no-param-reassign */
+ })
+ // ignore error and leave username in the search bar
+ .catch(() => { });
+ }
+
+ static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ const tokenValueContainer = parentElement.querySelector('.value-container');
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ tokenValueElement.innerText = tokenValue;
+
+ const tokenType = tokenName.toLowerCase();
+ if (tokenType === 'label') {
+ FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ } else if ((tokenType === 'author') || (tokenType === 'assignee')) {
+ FilteredSearchVisualTokens.updateUserTokenAppearance(
+ tokenValueContainer, tokenValueElement, tokenValue,
+ );
+ }
+ }
+
+ static addVisualTokenElement(name, value, isSearchTerm, canEdit) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
- li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
- li.querySelector('.value').innerText = value;
+ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
+ FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
}
@@ -74,24 +173,24 @@ class FilteredSearchVisualTokens {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
lastVisualToken.querySelector('.name').innerText = name;
- lastVisualToken.querySelector('.value').innerText = value;
+ FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
}
}
- static addFilterVisualToken(tokenName, tokenValue) {
+ static addFilterVisualToken(tokenName, tokenValue, canEdit) {
const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, false);
+ addVisualTokenElement(tokenName, tokenValue, false, canEdit);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, false);
+ addVisualTokenElement(previousTokenName, value, false, canEdit);
}
}
@@ -110,6 +209,12 @@ class FilteredSearchVisualTokens {
if (!lastVisualToken) return '';
+ const valueContainer = lastVisualToken.querySelector('.value-container');
+ const originalValue = valueContainer && valueContainer.dataset.originalValue;
+ if (originalValue) {
+ return originalValue;
+ }
+
const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name');
@@ -162,17 +267,28 @@ class FilteredSearchVisualTokens {
const inputLi = input.parentElement;
tokenContainer.replaceChild(inputLi, token);
- const name = token.querySelector('.name');
- const value = token.querySelector('.value');
+ const nameElement = token.querySelector('.name');
+ let value;
- if (token.classList.contains('filtered-search-token') && value) {
- FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
- input.value = value.innerText;
- } else {
- // token is a search term
- input.value = name.innerText;
+ if (token.classList.contains('filtered-search-token')) {
+ FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText);
+
+ const valueContainerElement = token.querySelector('.value-container');
+ value = valueContainerElement.dataset.originalValue;
+
+ if (!value) {
+ const valueElement = valueContainerElement.querySelector('.value');
+ value = valueElement.innerText;
+ }
}
+ // token is a search term
+ if (!value) {
+ value = nameElement.innerText;
+ }
+
+ input.value = value;
+
// Opens dropdown
const inputEvent = new Event('input');
input.dispatchEvent(inputEvent);
@@ -183,6 +299,9 @@ class FilteredSearchVisualTokens {
static moveInputToTheRight() {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
+
+ if (!input) return;
+
const inputLi = input.parentElement;
const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index 4e38409e12a..27e49d4fb96 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -29,12 +29,16 @@ class RecentSearchesRoot {
}
render() {
+ const state = this.store.state;
this.vm = new Vue({
el: this.wrapperElement,
- data: this.store.state,
+ data() { return state; },
template: `
<recent-searches-dropdown-content
- :items="recentSearches" />
+ :items="recentSearches"
+ :is-local-storage-available="isLocalStorageAvailable"
+ :allowed-keys="allowedKeys"
+ />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
index 3e402d5aed0..a056dea928d 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -1,9 +1,17 @@
+import RecentSearchesServiceError from './recent_searches_service_error';
+import AccessorUtilities from '../../lib/utils/accessor';
+
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
+ if (!RecentSearchesService.isAvailable()) {
+ const error = new RecentSearchesServiceError();
+ return Promise.reject(error);
+ }
+
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
@@ -19,8 +27,14 @@ class RecentSearchesService {
}
save(searches = []) {
+ if (!RecentSearchesService.isAvailable()) return;
+
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
+
+ static isAvailable() {
+ return AccessorUtilities.isLocalStorageAccessSafe();
+ }
}
export default RecentSearchesService;
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
new file mode 100644
index 00000000000..5917b223d63
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
@@ -0,0 +1,11 @@
+class RecentSearchesServiceError {
+ constructor(message) {
+ this.name = 'RecentSearchesServiceError';
+ this.message = message || 'Recent Searches Service is unavailable';
+ }
+}
+
+// Can't use `extends` for builtin prototypes and get true inheritance yet
+RecentSearchesServiceError.prototype = Error.prototype;
+
+export default RecentSearchesServiceError;
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index 066be69766a..aaa0c349d93 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -1,9 +1,11 @@
import _ from 'underscore';
class RecentSearchesStore {
- constructor(initialState = {}) {
+ constructor(initialState = {}, allowedKeys) {
this.state = Object.assign({
+ isLocalStorageAvailable: true,
recentSearches: [],
+ allowedKeys,
}, initialState);
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index eec30624ff2..ccff8f0ace7 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -7,8 +7,21 @@ window.Flash = (function() {
return $(this).fadeOut();
};
- function Flash(message, type, parent) {
- var flash, textDiv;
+ /**
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message
+ * @param {String} type Type of Flash, it can be `notice` or `alert` (default)
+ * @param {Object} parent Reference to Parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action link should point (default '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ */
+ function Flash(message, type, parent, actionConfig) {
+ var flash, textDiv, actionLink;
if (type == null) {
type = 'alert';
}
@@ -30,6 +43,23 @@ window.Flash = (function() {
text: message
});
textDiv.appendTo(flash);
+
+ if (actionConfig) {
+ const actionLinkConfig = {
+ class: 'flash-action',
+ href: actionConfig.href || '#',
+ text: actionConfig.title
+ };
+
+ if (!actionConfig.href) {
+ actionLinkConfig.role = 'button';
+ }
+
+ actionLink = $('<a/>', actionLinkConfig);
+
+ actionLink.appendTo(flash);
+ this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler);
+ }
if (this.flashContainer.parent().hasClass('content-wrapper')) {
textDiv.addClass('container-fluid container-limited');
}
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
new file mode 100644
index 00000000000..32cb42c8b10
--- /dev/null
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -0,0 +1,177 @@
+import Cookies from 'js-cookie';
+import bp from './breakpoints';
+
+const HIDE_INTERVAL_TIMEOUT = 300;
+const IS_OVER_CLASS = 'is-over';
+const IS_ABOVE_CLASS = 'is-above';
+const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out';
+let currentOpenMenu = null;
+let menuCornerLocs;
+let timeoutId;
+
+export const mousePos = [];
+
+export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
+
+export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
+
+let headerHeight = 50;
+
+export const getHeaderHeight = () => headerHeight;
+
+export const canShowActiveSubItems = (el) => {
+ const isHiddenByMedia = bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md';
+
+ if (el.classList.contains('active') && !isHiddenByMedia) {
+ return Cookies.get('sidebar_collapsed') === 'true';
+ }
+
+ return true;
+};
+
+export const canShowSubItems = () => bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg';
+
+export const getHideSubItemsInterval = () => {
+ if (!currentOpenMenu) return 0;
+
+ const currentMousePos = mousePos[mousePos.length - 1];
+ const prevMousePos = mousePos[0];
+ const currentMousePosY = currentMousePos.y;
+ const [menuTop, menuBottom] = menuCornerLocs;
+
+ if (currentMousePosY < menuTop.y ||
+ currentMousePosY > menuBottom.y) return 0;
+
+ if (slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) &&
+ slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop)) {
+ return HIDE_INTERVAL_TIMEOUT;
+ }
+
+ return 0;
+};
+
+export const calculateTop = (boundingRect, outerHeight) => {
+ const windowHeight = window.innerHeight;
+ const bottomOverflow = windowHeight - (boundingRect.top + outerHeight);
+
+ return bottomOverflow < 0 ? (boundingRect.top - outerHeight) + boundingRect.height :
+ boundingRect.top;
+};
+
+export const hideMenu = (el) => {
+ if (!el) return;
+
+ const parentEl = el.parentNode;
+
+ el.style.display = ''; // eslint-disable-line no-param-reassign
+ el.style.transform = ''; // eslint-disable-line no-param-reassign
+ el.classList.remove(IS_ABOVE_CLASS);
+ parentEl.classList.remove(IS_OVER_CLASS);
+ parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS);
+
+ setOpenMenu();
+};
+
+export const moveSubItemsToPosition = (el, subItems) => {
+ const boundingRect = el.getBoundingClientRect();
+ const top = calculateTop(boundingRect, subItems.offsetHeight);
+ const isAbove = top < boundingRect.top;
+
+ subItems.classList.add('fly-out-list');
+ subItems.style.transform = `translate3d(0, ${Math.floor(top) - headerHeight}px, 0)`; // eslint-disable-line no-param-reassign
+
+ const subItemsRect = subItems.getBoundingClientRect();
+
+ menuCornerLocs = [
+ {
+ x: subItemsRect.left, // left position of the sub items
+ y: subItemsRect.top, // top position of the sub items
+ },
+ {
+ x: subItemsRect.left, // left position of the sub items
+ y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items
+ },
+ ];
+
+ if (isAbove) {
+ subItems.classList.add(IS_ABOVE_CLASS);
+ }
+};
+
+export const showSubLevelItems = (el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ if (!canShowSubItems() || !canShowActiveSubItems(el)) return;
+
+ el.classList.add(IS_OVER_CLASS);
+
+ if (!subItems) return;
+
+ subItems.style.display = 'block';
+ el.classList.add(IS_SHOWING_FLY_OUT_CLASS);
+
+ setOpenMenu(subItems);
+ moveSubItemsToPosition(el, subItems);
+};
+
+export const mouseEnterTopItems = (el) => {
+ clearTimeout(timeoutId);
+
+ timeoutId = setTimeout(() => {
+ if (currentOpenMenu) hideMenu(currentOpenMenu);
+
+ showSubLevelItems(el);
+ }, getHideSubItemsInterval());
+};
+
+export const mouseLeaveTopItem = (el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ if (!canShowSubItems() || !canShowActiveSubItems(el) ||
+ (subItems && subItems === currentOpenMenu)) return;
+
+ el.classList.remove(IS_OVER_CLASS);
+};
+
+export const documentMouseMove = (e) => {
+ mousePos.push({
+ x: e.clientX,
+ y: e.clientY,
+ });
+
+ if (mousePos.length > 6) mousePos.shift();
+};
+
+export default () => {
+ const sidebar = document.querySelector('.sidebar-top-level-items');
+
+ if (!sidebar) return;
+
+ const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')];
+
+ sidebar.addEventListener('mouseleave', () => {
+ clearTimeout(timeoutId);
+
+ timeoutId = setTimeout(() => {
+ if (currentOpenMenu) hideMenu(currentOpenMenu);
+ }, getHideSubItemsInterval());
+ });
+
+ headerHeight = document.querySelector('.nav-sidebar').offsetTop;
+
+ items.forEach((el) => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ if (subItems) {
+ subItems.addEventListener('mouseleave', () => {
+ clearTimeout(timeoutId);
+ hideMenu(currentOpenMenu);
+ });
+ }
+
+ el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
+ el.addEventListener('mouseleave', e => mouseLeaveTopItem(e.currentTarget));
+ });
+
+ document.addEventListener('mousemove', documentMouseMove);
+};
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 687a462a0d4..5c624b79d45 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,169 +1,174 @@
-/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */
-
-import emojiMap from 'emojis/digests.json';
-import emojiAliases from 'emojis/aliases.json';
-import { glEmojiTag } from '~/behaviors/gl_emoji';
-import glRegexp from '~/lib/utils/regexp';
-
-// Creates the variables for setting up GFM auto-completion
-window.gl = window.gl || {};
+import _ from 'underscore';
+import glRegexp from './lib/utils/regexp';
+import AjaxCache from './lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
}
-window.gl.GfmAutoComplete = {
- dataSources: {},
- defaultLoadingData: ['loading'],
- cachedData: {},
- isLoadingData: {},
- atTypeMap: {
- ':': 'emojis',
- '@': 'members',
- '#': 'issues',
- '!': 'mergeRequests',
- '~': 'labels',
- '%': 'milestones',
- '/': 'commands'
- },
- // Emoji
- Emoji: {
- templateFunction: function(name) {
- return `<li>
- ${name} ${glEmojiTag(name)}
- </li>
- `;
- }
- },
- // Team Members
- Members: {
- template: '<li>${avatarTag} ${username} <small>${title}</small></li>'
- },
- Labels: {
- template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>'
- },
- // Issues and MergeRequests
- Issues: {
- template: '<li><small>${id}</small> ${title}</li>'
- },
- // Milestones
- Milestones: {
- template: '<li>${title}</li>'
- },
- Loading: {
- template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>'
- },
- DefaultOptions: {
- sorter: function(query, items, searchKey) {
- this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
- if (gl.GfmAutoComplete.isLoading(items)) {
- this.setting.highlightFirst = false;
- return items;
- }
- return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey);
- },
- filter: function(query, data, searchKey) {
- if (gl.GfmAutoComplete.isLoading(data)) {
- gl.GfmAutoComplete.fetchData(this.$inputor, this.at);
- return data;
- } else {
- return $.fn.atwho["default"].callbacks.filter(query, data, searchKey);
- }
- },
- beforeInsert: function(value) {
- if (value && !this.setting.skipSpecialCharacterTest) {
- var withoutAt = value.substring(1);
- if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"';
- }
- return value;
- },
- matcher: function (flag, subtext) {
- // The below is taken from At.js source
- // Tweaked to commands to start without a space only if char before is a non-word character
- // https://github.com/ichord/At.js
- var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar;
- atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
- atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
- subtext = subtext.split(/\s+/g).pop();
- flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
-
- _a = decodeURI("%C3%80");
- _y = decodeURI("%C3%BF");
-
- regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi');
-
- match = regexp.exec(subtext);
+class GfmAutoComplete {
+ constructor(dataSources) {
+ this.dataSources = dataSources || {};
+ this.cachedData = {};
+ this.isLoadingData = {};
+ }
- if (match) {
- return match[1];
- } else {
- return null;
- }
- }
- },
- setup: function(input) {
+ setup(input, enableMap = {
+ emojis: true,
+ members: true,
+ issues: true,
+ milestones: true,
+ mergeRequests: true,
+ labels: true,
+ }) {
// Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input');
+ this.enableMap = enableMap;
this.setupLifecycle();
- },
+ }
+
setupLifecycle() {
this.input.each((i, input) => {
const $input = $(input);
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
+ $input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
- // Needed for slash commands with suffixes (ex: /label ~)
+ // Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
+ $input.on('clear-commands-cache.atwho', () => this.clearCache());
});
- },
- setupAtWho: function($input) {
+ }
+
+ setupAtWho($input) {
+ if (this.enableMap.emojis) this.setupEmoji($input);
+ if (this.enableMap.members) this.setupMembers($input);
+ if (this.enableMap.issues) this.setupIssues($input);
+ if (this.enableMap.milestones) this.setupMilestones($input);
+ if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
+ if (this.enableMap.labels) this.setupLabels($input);
+
+ // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms
+ $input.filter('[data-supports-quick-actions="true"]').atwho({
+ at: '/',
+ alias: 'commands',
+ searchKey: 'search',
+ skipSpecialCharacterTest: true,
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '<li>/${name}';
+ if (value.aliases.length > 0) {
+ tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+ }
+ if (value.params.length > 0) {
+ tpl += ' <small><%- params.join(" ") %></small>';
+ }
+ if (value.description !== '') {
+ tpl += '<small class="description"><i><%- description %></i></small>';
+ }
+ tpl += '</li>';
+ return _.template(tpl)(value);
+ },
+ insertTpl(value) {
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '/${name} ';
+ let referencePrefix = null;
+ if (value.params.length > 0) {
+ referencePrefix = value.params[0][0];
+ if (/^[@%~]/.test(referencePrefix)) {
+ tpl += '<%- referencePrefix %>';
+ }
+ }
+ return _.template(tpl)({ referencePrefix });
+ },
+ suffix: '',
+ callbacks: {
+ ...this.getDefaultCallbacks(),
+ beforeSave(commands) {
+ if (GfmAutoComplete.isLoading(commands)) return commands;
+ return $.map(commands, (c) => {
+ let search = c.name;
+ if (c.aliases.length > 0) {
+ search = `${search} ${c.aliases.join(' ')}`;
+ }
+ return {
+ name: c.name,
+ aliases: c.aliases,
+ params: c.params,
+ description: c.description,
+ search,
+ };
+ });
+ },
+ matcher(flag, subtext) {
+ const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
+ const match = regexp.exec(subtext);
+ if (match) {
+ return match[1];
+ }
+ return null;
+ },
+ },
+ });
+ }
+
+ setupEmoji($input) {
// Emoji
$input.atwho({
at: ':',
- displayTpl: function(value) {
- return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value && value.name) {
+ tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: ':${name}:',
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
-
- matcher: (flag, subtext) => {
+ ...this.getDefaultCallbacks(),
+ matcher(flag, subtext) {
const relevantText = subtext.trim().split(/\s/).pop();
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(relevantText);
return match && match.length ? match[1] : null;
- }
- }
+ },
+ },
});
+ }
+
+ setupMembers($input) {
// Team Members
$input.atwho({
at: '@',
- displayTpl: function(value) {
- return value.username != null ? this.Members.template : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.username != null) {
+ tmpl = GfmAutoComplete.Members.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(members) {
- return $.map(members, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(members) {
+ return $.map(members, (m) => {
let title = '';
if (m.username == null) {
return m;
}
title = m.name;
if (m.count) {
- title += " (" + m.count + ")";
+ title += ` (${m.count})`;
}
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
@@ -174,226 +179,292 @@ window.gl.GfmAutoComplete = {
username: m.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
- search: sanitize(m.username + " " + m.name)
+ search: sanitize(`${m.username} ${m.name}`),
};
});
- }
- }
+ },
+ },
});
+ }
+
+ setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(issues) {
- return $.map(issues, function(i) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(issues) {
+ return $.map(issues, (i) => {
if (i.title == null) {
return i;
}
return {
id: i.iid,
title: sanitize(i.title),
- search: i.iid + " " + i.title
+ search: `${i.iid} ${i.title}`,
};
});
- }
- }
+ },
+ },
});
+ }
+
+ setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
searchKey: 'search',
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
- displayTpl: function(value) {
- return value.title != null ? this.Milestones.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Milestones.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- matcher: this.DefaultOptions.matcher,
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- beforeSave: function(milestones) {
- return $.map(milestones, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(milestones) {
+ return $.map(milestones, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: "" + m.title
+ search: m.title,
};
});
- }
- }
+ },
+ },
});
+ }
+
+ setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(merges) {
- return $.map(merges, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ return $.map(merges, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: m.iid + " " + m.title
+ search: `${m.iid} ${m.title}`,
};
});
- }
- }
+ },
+ },
});
+ }
+
+ setupLabels($input) {
$input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- return this.isLoading(value) ? this.Loading.template : this.Labels.template;
- }.bind(this),
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Labels.template;
+ if (GfmAutoComplete.isLoading(value)) {
+ tmpl = GfmAutoComplete.Loading.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
callbacks: {
- matcher: this.DefaultOptions.matcher,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- sorter: this.DefaultOptions.sorter,
- beforeSave: function(merges) {
- if (gl.GfmAutoComplete.isLoading(merges)) return merges;
- var sanitizeLabelTitle;
- sanitizeLabelTitle = function(title) {
- if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
- return "\"" + (sanitize(title)) + "\"";
- } else {
- return sanitize(title);
- }
- };
- return $.map(merges, function(m) {
- return {
- title: sanitize(m.title),
- color: m.color,
- search: "" + m.title
- };
- });
- }
- }
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ if (GfmAutoComplete.isLoading(merges)) return merges;
+ return $.map(merges, m => ({
+ title: sanitize(m.title),
+ color: m.color,
+ search: m.title,
+ }));
+ },
+ },
});
- // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
- $input.filter('[data-supports-slash-commands="true"]').atwho({
- at: '/',
- alias: 'commands',
- searchKey: 'search',
- skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- if (this.isLoading(value)) return this.Loading.template;
- var tpl = '<li>/${name}';
- if (value.aliases.length > 0) {
- tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
- }
- if (value.params.length > 0) {
- tpl += ' <small><%- params.join(" ") %></small>';
+ }
+
+ getDefaultCallbacks() {
+ const fetchData = this.fetchData.bind(this);
+
+ return {
+ sorter(query, items, searchKey) {
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
+ if (GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
+ return items;
}
- if (value.description !== '') {
- tpl += '<small class="description"><i><%- description %></i></small>';
+ return $.fn.atwho.default.callbacks.sorter(query, items, searchKey);
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
}
- tpl += '</li>';
- return _.template(tpl)(value);
- }.bind(this),
- insertTpl: function(value) {
- var tpl = "/${name} ";
- var reference_prefix = null;
- if (value.params.length > 0) {
- reference_prefix = value.params[0][0];
- if (/^[@%~]/.test(reference_prefix)) {
- tpl += '<%- reference_prefix %>';
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ },
+ beforeInsert(value) {
+ let resultantValue = value;
+ if (value && !this.setting.skipSpecialCharacterTest) {
+ const withoutAt = value.substring(1);
+ if (withoutAt && /[^\w\d]/.test(withoutAt)) {
+ resultantValue = `${value.charAt()}"${withoutAt}"`;
}
}
- return _.template(tpl)({ reference_prefix: reference_prefix });
+ return resultantValue;
},
- suffix: '',
- callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- beforeSave: function(commands) {
- if (gl.GfmAutoComplete.isLoading(commands)) return commands;
- return $.map(commands, function(c) {
- var search = c.name;
- if (c.aliases.length > 0) {
- search = search + " " + c.aliases.join(" ");
- }
- return {
- name: c.name,
- aliases: c.aliases,
- params: c.params,
- description: c.description,
- search: search
- };
- });
- },
- matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
- var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
- var match = regexp.exec(subtext);
- if (match) {
- return match[1];
- } else {
- return null;
- }
+ matcher(flag, subtext) {
+ // The below is taken from At.js source
+ // Tweaked to commands to start without a space only if char before is a non-word character
+ // https://github.com/ichord/At.js
+ const atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
+ const atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
+ const targetSubtext = subtext.split(/\s+/g).pop();
+ const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
+
+ const accentAChar = decodeURI('%C3%80');
+ const accentYChar = decodeURI('%C3%BF');
+
+ const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
+
+ const match = regexp.exec(targetSubtext);
+
+ if (match) {
+ return match[1];
}
- }
- });
- return;
- },
- fetchData: function($input, at) {
+ return null;
+ },
+ };
+ }
+
+ fetchData($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
- } else if (this.atTypeMap[at] === 'emojis') {
- this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
+ } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
+ import(/* webpackChunkName: 'emoji' */ './emoji')
+ .then(({ validEmojiNames, glEmojiTag }) => {
+ this.loadData($input, at, validEmojiNames);
+ GfmAutoComplete.glEmojiTag = glEmojiTag;
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
} else {
- $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => {
- this.loadData($input, at, data);
- }).fail(() => { this.isLoadingData[at] = false; });
+ AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ .then((data) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
}
- },
- loadData: function($input, at, data) {
+ }
+
+ loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
$input.atwho('load', at, data);
// This trigger at.js again
// otherwise we would be stuck with loading until the user types
return $input.trigger('keyup');
- },
- isLoading(data) {
- var dataToInspect = data;
+ }
+
+ clearCache() {
+ this.cachedData = {};
+ }
+
+ destroy() {
+ this.input.each((i, input) => {
+ const $input = $(input);
+ $input.atwho('destroy');
+ });
+ }
+
+ static isLoading(data) {
+ let dataToInspect = data;
if (data && data.length > 0) {
dataToInspect = data[0];
}
- var loadingState = this.defaultLoadingData[0];
+ const loadingState = GfmAutoComplete.defaultLoadingData[0];
return dataToInspect &&
(dataToInspect === loadingState || dataToInspect.name === loadingState);
}
+}
+
+GfmAutoComplete.defaultLoadingData = ['loading'];
+
+GfmAutoComplete.atTypeMap = {
+ ':': 'emojis',
+ '@': 'members',
+ '#': 'issues',
+ '!': 'mergeRequests',
+ '~': 'labels',
+ '%': 'milestones',
+ '/': 'commands',
+};
+
+// Emoji
+GfmAutoComplete.glEmojiTag = null;
+GfmAutoComplete.Emoji = {
+ templateFunction(name) {
+ // glEmojiTag helper is loaded on-demand in fetchData()
+ if (GfmAutoComplete.glEmojiTag) {
+ return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
+ }
+ return `<li>${name}</li>`;
+ },
+};
+// Team Members
+GfmAutoComplete.Members = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${avatarTag} ${username} <small>${title}</small></li>',
};
+GfmAutoComplete.Labels = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>',
+};
+// Issues and MergeRequests
+GfmAutoComplete.Issues = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><small>${id}</small> ${title}</li>',
+};
+// Milestones
+GfmAutoComplete.Milestones = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${title}</li>',
+};
+GfmAutoComplete.Loading = {
+ template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
+};
+
+export default GfmAutoComplete;
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a03f1202a6d..b62acfcd445 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,9 +1,53 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */
+/* eslint-disable func-names, no-underscore-dangle, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */
/* global fuzzaldrinPlus */
+import _ from 'underscore';
+import { isObject } from './lib/utils/type_utility';
-var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote,
- bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
+var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput;
+
+GitLabDropdownInput = (function() {
+ function GitLabDropdownInput(input, options) {
+ var $inputContainer, $clearButton;
+ var _this = this;
+ this.input = input;
+ this.options = options;
+ this.fieldName = this.options.fieldName || 'field-name';
+ $inputContainer = this.input.parent();
+ $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ $clearButton.on('click', (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input.val('').trigger('input').focus();
+ };
+ })(this));
+
+ this.input
+ .on('keydown', function (e) {
+ var keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .on('input', function(e) {
+ var val = e.currentTarget.value || _this.options.inputFieldName;
+ val = val.split(' ').join('-') // replaces space with dash
+ .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric
+ .replace(/(-)\1+/g, '-'); // replace repeated dashes
+ _this.cb(_this.options.fieldName, val, {}, true);
+ _this.input.closest('.dropdown')
+ .find('.dropdown-toggle-text')
+ .text(val);
+ });
+ }
+
+ GitLabDropdownInput.prototype.onInput = function(cb) {
+ this.cb = cb;
+ };
+
+ return GitLabDropdownInput;
+})();
GitLabDropdownFilter = (function() {
var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS;
@@ -95,7 +139,7 @@ GitLabDropdownFilter = (function() {
// { prop: 'def' }
// ]
// }
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
results = {};
for (key in data) {
group = data[key];
@@ -115,7 +159,7 @@ GitLabDropdownFilter = (function() {
} else {
elements = this.options.elements();
if (search_text) {
- return elements.each(function() {
+ elements.each(function() {
var $el, matches;
$el = $(this);
matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
@@ -128,8 +172,10 @@ GitLabDropdownFilter = (function() {
}
});
} else {
- return elements.show().removeClass('option-hidden');
+ elements.show().removeClass('option-hidden');
}
+
+ elements.parent().find('.dropdown-menu-empty-link').toggleClass('hidden', elements.is(':visible'));
}
};
@@ -189,7 +235,7 @@ GitLabDropdownRemote = (function() {
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
+ var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
LOADING_CLASS = "is-loading";
@@ -207,21 +253,24 @@ GitLabDropdown = (function() {
CURSOR_SELECT_SCROLL_PADDING = 5;
- FILTER_INPUT = '.dropdown-input .dropdown-input-field';
+ FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)';
+
+ NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
function GitLabDropdown(el1, options) {
var searchFields, selector, self;
this.el = el1;
this.options = options;
- this.updateLabel = bind(this.updateLabel, this);
- this.hidden = bind(this.hidden, this);
- this.opened = bind(this.opened, this);
- this.shouldPropagate = bind(this.shouldPropagate, this);
+ this.updateLabel = this.updateLabel.bind(this);
+ this.hidden = this.hidden.bind(this);
+ this.opened = this.opened.bind(this);
+ this.shouldPropagate = this.shouldPropagate.bind(this);
self = this;
selector = $(this.el).data("target");
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
// Set Defaults
this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
+ this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
this.highlight = !!this.options.highlight;
this.filterInputBlur = this.options.filterInputBlur != null
? this.options.filterInputBlur
@@ -249,16 +298,21 @@ GitLabDropdown = (function() {
return function(data) {
_this.fullData = data;
_this.parseData(_this.fullData);
- _this.focusTextInput();
+ _this.focusTextInput(true);
if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') {
return _this.filter.input.trigger('input');
}
};
// Remote data
- })(this)
+ })(this),
+ instance: this,
});
}
}
+ if (this.noFilterInput.length) {
+ this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options);
+ this.plainInput.onInput(this.addInput.bind(this));
+ }
// Init filterable
if (this.options.filterable) {
this.filter = new GitLabDropdownFilter(this.filterInput, {
@@ -269,6 +323,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote,
query: this.options.data,
keys: searchFields,
+ instance: this,
elements: (function(_this) {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
@@ -343,21 +398,26 @@ GitLabDropdown = (function() {
}
this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking;
- $el = $(this);
+ $el = $(e.currentTarget);
selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null;
- if (self.options.clicked) {
- self.options.clicked(selectedObj, $el, e, isMarking);
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
}
// Update label right after all modifications in dropdown has been done
- if (self.options.toggleLabel) {
- self.updateLabel(selectedObj, $el, self);
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
}
$el.trigger('blur');
- });
+ }.bind(this));
}
}
@@ -391,7 +451,7 @@ GitLabDropdown = (function() {
html = [this.noResults()];
} else {
// Handle array groups
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
html = [];
for (name in data) {
groupData = data[name];
@@ -439,15 +499,34 @@ GitLabDropdown = (function() {
}
};
+ GitLabDropdown.prototype.filteredFullData = function() {
+ return this.fullData.filter(r => typeof r === 'object'
+ && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
+ && !Object.prototype.hasOwnProperty.call(r, 'header')
+ );
+ };
+
GitLabDropdown.prototype.opened = function(e) {
var contentHtml;
this.resetRows();
this.addArrowKeyEvent();
+ const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
+ const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
+
// Makes indeterminate items effective
- if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
+ if (this.fullData && hasFilterBulkUpdate) {
this.parseData(this.fullData);
}
+
+ // Process the data to make sure rendered data
+ // matches the correct layout
+ const inputValue = this.filterInput.val();
+ if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
+ this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ }
+
contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") {
this.remote.execute();
@@ -584,7 +663,12 @@ GitLabDropdown = (function() {
var link = document.createElement('a');
link.href = url;
- link.innerHTML = text;
+
+ if (this.highlight) {
+ link.innerHTML = text;
+ } else {
+ link.textContent = text;
+ }
if (selected) {
link.className = 'is-active';
@@ -601,8 +685,8 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
- var occurrences;
- occurrences = fuzzaldrinPlus.match(text, term);
+ const occurrences = fuzzaldrinPlus.match(text, term);
+ const indexOf = [].indexOf;
return text.split('').map(function(character, i) {
if (indexOf.call(occurrences, i) !== -1) {
return "<b>" + character + "</b>";
@@ -698,18 +782,51 @@ GitLabDropdown = (function() {
return [selectedObject, isMarking];
};
- GitLabDropdown.prototype.focusTextInput = function() {
- if (this.options.filterable) { this.filterInput.focus(); }
+ GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) {
+ if (this.options.filterable) {
+ this.dropdown.one('transitionend', () => {
+ const initialScrollTop = $(window).scrollTop();
+
+ if (this.dropdown.is('.open')) {
+ this.filterInput.focus();
+ }
+
+ if ($(window).scrollTop() < initialScrollTop) {
+ $(window).scrollTop(initialScrollTop);
+ }
+ });
+
+ if (triggerFocus) {
+ // This triggers after a ajax request
+ // in case of slow requests, the dropdown transition could already be finished
+ this.dropdown.trigger('transitionend');
+ }
+ }
};
- GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) {
+ GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) {
var $input;
// Create hidden input for form
+ if (single) {
+ $('input[name="' + fieldName + '"]').remove();
+ }
+
$input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
- return this.dropdown.before($input);
+
+ if (this.options.multiSelect) {
+ Object.keys(selectedObject).forEach((attribute) => {
+ $input.attr(`data-${attribute}`, selectedObject[attribute]);
+ });
+ }
+
+ if (this.options.inputMeta) {
+ $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ }
+
+ this.dropdown.before($input).trigger('change');
};
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
@@ -829,7 +946,14 @@ GitLabDropdown = (function() {
if (instance == null) {
instance = null;
}
- return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
+
+ let toggleText = this.options.toggleLabel(selected, el, instance);
+ if (this.options.updateLabel) {
+ // Option to override the dropdown label text
+ toggleText = this.options.updateLabel;
+ }
+
+ return $(this.el).find(".dropdown-toggle-text").text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 76de249ac3b..0add7075254 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -65,6 +65,7 @@ class GlFieldError {
this.state = {
valid: false,
empty: true,
+ submitted: false,
};
this.initFieldValidation();
@@ -108,9 +109,10 @@ class GlFieldError {
const currentValue = this.accessCurrentValue();
this.state.valid = false;
this.state.empty = currentValue === '';
-
+ this.state.submitted = true;
this.renderValidity();
this.form.focusOnFirstInvalid.apply(this.form);
+
// For UX, wait til after first invalid submission to check each keyup
this.inputElement.off('keyup.fieldValidator')
.on('keyup.fieldValidator', this.updateValidity.bind(this));
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 636258ec555..4bef60264bb 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,6 +1,6 @@
/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-require('./gl_field_error');
+import './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
@@ -31,12 +31,25 @@ class GlFieldErrors {
* and prevents disabling of invalid submit button by application.js */
catchInvalidFormSubmit (event) {
- if (!event.currentTarget.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
+ const $form = $(event.currentTarget);
+
+ if (!$form.attr('novalidate')) {
+ if (!event.currentTarget.checkValidity()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
}
}
+ /* Public method for triggering validity updates manually */
+ updateFormValidityState() {
+ this.state.inputs.forEach((field) => {
+ if (field.state.submitted) {
+ field.updateValidity();
+ }
+ });
+ }
+
focusOnFirstInvalid () {
const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
firstInvalid.inputElement.focus();
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index ff10f19a4fe..4e8141b2956 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -3,11 +3,14 @@
/* global DropzoneInput */
/* global autosize */
+import GfmAutoComplete from './gfm_auto_complete';
+
window.gl = window.gl || {};
-function GLForm(form) {
+function GLForm(form, enableGFM = false) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
+ this.enableGFM = enableGFM;
// Before we start, we should clean up any previous data for this form
this.destroy();
// Setup the form
@@ -18,6 +21,9 @@ function GLForm(form) {
GLForm.prototype.destroy = function() {
// Clean form listeners
this.clearEventListeners();
+ if (this.autoComplete) {
+ this.autoComplete.destroy();
+ }
return this.form.data('gl-form', null);
};
@@ -30,13 +36,20 @@ GLForm.prototype.setupForm = function() {
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
-
- gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
+ this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
+ this.autoComplete.setup(this.form.find('.js-gfm-input'), {
+ emojis: true,
+ members: this.enableGFM,
+ issues: this.enableGFM,
+ milestones: this.enableGFM,
+ mergeRequests: this.enableGFM,
+ labels: this.enableGFM,
+ });
new DropzoneInput(this.form);
autosize(this.textarea);
- // form and textarea event listeners
- this.addEventListeners();
}
+ // form and textarea event listeners
+ this.addEventListeners();
gl.text.init(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
new file mode 100644
index 00000000000..7ac9dcd1112
--- /dev/null
+++ b/app/assets/javascripts/gpg_badges.js
@@ -0,0 +1,17 @@
+export default class GpgBadges {
+ static fetch() {
+ const badges = $('.js-loading-gpg-badge');
+ const form = $('.commits-search-form');
+
+ badges.html('<i class="fa fa-spinner fa-spin"></i>');
+
+ $.get({
+ url: form.data('signatures-path'),
+ data: form.serialize(),
+ }).done((response) => {
+ response.signatures.forEach((signature) => {
+ badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js
index a433c7ba8f0..534bc535bb6 100644
--- a/app/assets/javascripts/graphs/graphs_bundle.js
+++ b/app/assets/javascripts/graphs/graphs_bundle.js
@@ -1,6 +1,4 @@
import Chart from 'vendor/Chart';
-import ContributorsStatGraph from './stat_graph_contributors';
// export to global scope
window.Chart = Chart;
-window.ContributorsStatGraph = ContributorsStatGraph;
diff --git a/app/assets/javascripts/graphs/graphs_charts.js b/app/assets/javascripts/graphs/graphs_charts.js
new file mode 100644
index 00000000000..ec6eab34989
--- /dev/null
+++ b/app/assets/javascripts/graphs/graphs_charts.js
@@ -0,0 +1,61 @@
+import Chart from 'vendor/Chart';
+import _ from 'underscore';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML);
+
+ const responsiveChart = (selector, data) => {
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ pointHitDetectionRadius: 2,
+ maintainAspectRatio: false,
+ };
+ // get selector by context
+ const ctx = selector.get(0).getContext('2d');
+ // pointing parent container to make chart.js inherit its width
+ const container = $(selector).parent();
+ const generateChart = () => {
+ selector.attr('width', $(container).width());
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+ return new Chart(ctx).Bar(data, options);
+ };
+ // enabling auto-resizing
+ $(window).resize(generateChart);
+ return generateChart();
+ };
+
+ const chartData = data => ({
+ labels: Object.keys(data),
+ datasets: [{
+ fillColor: 'rgba(220,220,220,0.5)',
+ strokeColor: 'rgba(220,220,220,1)',
+ barStrokeWidth: 1,
+ barValueSpacing: 1,
+ barDatasetSpacing: 1,
+ data: _.values(data),
+ }],
+ });
+
+ const hourData = chartData(projectChartData.hour);
+ responsiveChart($('#hour-chart'), hourData);
+
+ const dayData = chartData(projectChartData.weekDays);
+ responsiveChart($('#weekday-chart'), dayData);
+
+ const monthData = chartData(projectChartData.month);
+ responsiveChart($('#month-chart'), monthData);
+
+ const data = projectChartData.languages;
+ const ctx = $('#languages-chart').get(0).getContext('2d');
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+ };
+
+ new Chart(ctx).Pie(data, options);
+});
diff --git a/app/assets/javascripts/graphs/graphs_show.js b/app/assets/javascripts/graphs/graphs_show.js
new file mode 100644
index 00000000000..36bad6db3e1
--- /dev/null
+++ b/app/assets/javascripts/graphs/graphs_show.js
@@ -0,0 +1,21 @@
+import ContributorsStatGraph from './stat_graph_contributors';
+
+document.addEventListener('DOMContentLoaded', () => {
+ $.ajax({
+ type: 'GET',
+ url: document.querySelector('.js-graphs-show').dataset.projectGraphPath,
+ dataType: 'json',
+ success(data) {
+ const graph = new ContributorsStatGraph();
+ graph.init(data);
+
+ $('#brush_change').change(() => {
+ graph.change_date_header();
+ graph.redraw_authors();
+ });
+
+ $('.stat-graph').fadeIn();
+ $('.loading-graph').hide();
+ },
+ });
+});
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js
index c6be4c9e8fe..cdc4fcf6573 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign, no-shadow */
+import _ from 'underscore';
import d3 from 'd3';
import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph';
import ContributorsStatGraphUtil from './stat_graph_contributors_util';
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
index 521bc77db66..f64b4638485 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
@@ -1,8 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */
-
+import _ from 'underscore';
import d3 from 'd3';
-const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
const hasProp = {}.hasOwnProperty;
@@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) {
function ContributorsMasterGraph(data1) {
this.data = data1;
- this.update_content = bind(this.update_content, this);
+ this.update_content = this.update_content.bind(this);
this.width = $('.content').width() - 70;
this.height = 200;
this.x = null;
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/graphs/stat_graph_contributors_util.js
index c583757f3f2..77135ad1f0e 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors_util.js
@@ -1,4 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, object-shorthand, no-var, one-var, camelcase, one-var-declaration-per-line, comma-dangle, no-param-reassign, no-return-assign, quotes, prefer-arrow-callback, wrap-iife, consistent-return, no-unused-vars, max-len, no-cond-assign, no-else-return, max-len */
+import _ from 'underscore';
export default {
parse_log: function(log) {
diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js
index 62675d7e67e..3e483b69fd2 100644
--- a/app/assets/javascripts/group_name.js
+++ b/app/assets/javascripts/group_name.js
@@ -1,16 +1,19 @@
-
+import Cookies from 'js-cookie';
import _ from 'underscore';
export default class GroupName {
constructor() {
- this.titleContainer = document.querySelector('.title-container');
- this.title = document.querySelector('.title');
- this.titleWidth = this.title.offsetWidth;
- this.groupTitle = document.querySelector('.group-title');
- this.groups = document.querySelectorAll('.group-path');
- this.toggle = null;
- this.isHidden = false;
- this.init();
+ this.titleContainer = document.querySelector('.js-title-container');
+ this.title = this.titleContainer.querySelector('.title');
+
+ if (this.title) {
+ this.titleWidth = this.title.offsetWidth;
+ this.groupTitle = this.titleContainer.querySelector('.group-title');
+ this.groups = this.titleContainer.querySelectorAll('.group-path');
+ this.toggle = null;
+ this.isHidden = false;
+ this.init();
+ }
}
init() {
@@ -33,29 +36,38 @@ export default class GroupName {
createToggle() {
this.toggle = document.createElement('button');
+ this.toggle.setAttribute('type', 'button');
this.toggle.className = 'text-expander group-name-toggle';
this.toggle.setAttribute('aria-label', 'Toggle full path');
- this.toggle.innerHTML = '...';
+ if (Cookies.get('new_nav') === 'true') {
+ this.toggle.innerHTML = '<i class="fa fa-ellipsis-h" aria-hidden="true"></i>';
+ } else {
+ this.toggle.innerHTML = '...';
+ }
this.toggle.addEventListener('click', this.toggleGroups.bind(this));
- this.titleContainer.insertBefore(this.toggle, this.title);
+ if (Cookies.get('new_nav') === 'true') {
+ this.title.insertBefore(this.toggle, this.groupTitle);
+ } else {
+ this.titleContainer.insertBefore(this.toggle, this.title);
+ }
this.toggleGroups();
}
showToggle() {
this.title.classList.add('wrap');
this.toggle.classList.remove('hidden');
- if (this.isHidden) this.groupTitle.classList.add('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.add('hidden');
}
hideToggle() {
this.title.classList.remove('wrap');
this.toggle.classList.add('hidden');
- if (this.isHidden) this.groupTitle.classList.remove('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.remove('hidden');
}
toggleGroups() {
this.isHidden = !this.isHidden;
- this.groupTitle.classList.toggle('is-hidden');
+ this.groupTitle.classList.toggle('hidden');
}
render() {
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
new file mode 100644
index 00000000000..7cc6c4b0359
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ props: {
+ groups: {
+ type: Object,
+ required: true,
+ },
+ baseGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="content-list group-list-tree">
+ <group-item
+ v-for="(group, index) in groups"
+ :key="index"
+ :group="group"
+ :base-group="baseGroup"
+ :collection="groups"
+ />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_identicon.vue b/app/assets/javascripts/groups/components/group_identicon.vue
new file mode 100644
index 00000000000..0edd820743f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_identicon.vue
@@ -0,0 +1,45 @@
+<script>
+export default {
+ props: {
+ entityId: {
+ type: Number,
+ required: true,
+ },
+ entityName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ /**
+ * This method is based on app/helpers/application_helper.rb#project_identicon
+ */
+ identiconStyles() {
+ const allowedColors = [
+ '#FFEBEE',
+ '#F3E5F5',
+ '#E8EAF6',
+ '#E3F2FD',
+ '#E0F2F1',
+ '#FBE9E7',
+ '#EEEEEE',
+ ];
+
+ const backgroundColor = allowedColors[this.entityId % 7];
+
+ return `background-color: ${backgroundColor}; color: #555;`;
+ },
+ identiconTitle() {
+ return this.entityName.charAt(0).toUpperCase();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="avatar s40 identicon"
+ :style="identiconStyles">
+ {{identiconTitle}}
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
new file mode 100644
index 00000000000..cb133cf7535
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -0,0 +1,233 @@
+<script>
+import eventHub from '../event_hub';
+import groupIdenticon from './group_identicon.vue';
+
+export default {
+ components: {
+ groupIdenticon,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ baseGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ collection: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ methods: {
+ onClickRowGroup(e) {
+ e.stopPropagation();
+
+ // Skip for buttons
+ if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
+ if (this.group.hasSubgroups) {
+ eventHub.$emit('toggleSubGroups', this.group);
+ } else {
+ window.location.href = this.group.groupPath;
+ }
+ }
+ },
+ onLeaveGroup(e) {
+ e.preventDefault();
+
+ // eslint-disable-next-line no-alert
+ if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
+ this.leaveGroup();
+ }
+ },
+ leaveGroup() {
+ eventHub.$emit('leaveGroup', this.group, this.collection);
+ },
+ },
+ computed: {
+ groupDomId() {
+ return `group-${this.group.id}`;
+ },
+ rowClass() {
+ return {
+ 'group-row': true,
+ 'is-open': this.group.isOpen,
+ 'has-subgroups': this.group.hasSubgroups,
+ 'no-description': !this.group.description,
+ };
+ },
+ visibilityIcon() {
+ return {
+ fa: true,
+ 'fa-globe': this.group.visibility === 'public',
+ 'fa-shield': this.group.visibility === 'internal',
+ 'fa-lock': this.group.visibility === 'private',
+ };
+ },
+ fullPath() {
+ let fullPath = '';
+
+ if (this.group.isOrphan) {
+ // check if current group is baseGroup
+ if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
+ // Remove baseGroup prefix from our current group.fullName. e.g:
+ // baseGroup.fullName: `level1`
+ // group.fullName: `level1 / level2 / level3`
+ // Result: `level2 / level3`
+ const gfn = this.group.fullName;
+ const bfn = this.baseGroup.fullName;
+ const length = bfn.length;
+ const start = gfn.indexOf(bfn);
+ const extraPrefixChars = 3;
+
+ fullPath = gfn.substr(start + length + extraPrefixChars);
+ } else {
+ fullPath = this.group.fullName;
+ }
+ } else {
+ fullPath = this.group.name;
+ }
+
+ return fullPath;
+ },
+ hasGroups() {
+ return Object.keys(this.group.subGroups).length > 0;
+ },
+ hasAvatar() {
+ return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ @click.stop="onClickRowGroup"
+ :id="groupDomId"
+ :class="rowClass"
+ >
+ <div
+ class="group-row-contents">
+ <div
+ class="controls">
+ <a
+ v-if="group.canEdit"
+ class="edit-group btn"
+ :href="group.editPath">
+ <i
+ class="fa fa-cogs"
+ aria-hidden="true"
+ >
+ </i>
+ </a>
+ <a
+ @click="onLeaveGroup"
+ :href="group.leavePath"
+ class="leave-group btn"
+ title="Leave this group">
+ <i
+ class="fa fa-sign-out"
+ aria-hidden="true"
+ >
+ </i>
+ </a>
+ </div>
+ <div
+ class="stats">
+ <span
+ class="number-projects">
+ <i
+ class="fa fa-bookmark"
+ aria-hidden="true"
+ >
+ </i>
+ {{group.numberProjects}}
+ </span>
+ <span
+ class="number-users">
+ <i
+ class="fa fa-users"
+ aria-hidden="true"
+ >
+ </i>
+ {{group.numberUsers}}
+ </span>
+ <span
+ class="group-visibility">
+ <i
+ :class="visibilityIcon"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </div>
+ <div
+ class="folder-toggle-wrap">
+ <span
+ class="folder-caret"
+ v-if="group.hasSubgroups">
+ <i
+ v-if="group.isOpen"
+ class="fa fa-caret-down"
+ aria-hidden="true"
+ >
+ </i>
+ <i
+ v-if="!group.isOpen"
+ class="fa fa-caret-right"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ <span class="folder-icon">
+ <i
+ v-if="group.isOpen"
+ class="fa fa-folder-open"
+ aria-hidden="true"
+ >
+ </i>
+ <i
+ v-if="!group.isOpen"
+ class="fa fa-folder"
+ aria-hidden="true">
+ </i>
+ </span>
+ </div>
+ <div
+ class="avatar-container s40 hidden-xs">
+ <a
+ :href="group.groupPath">
+ <img
+ v-if="hasAvatar"
+ class="avatar s40"
+ :src="group.avatarUrl"
+ />
+ <group-identicon
+ v-else
+ :entity-id=group.id
+ :entity-name="group.name"
+ />
+ </a>
+ </div>
+ <div
+ class="title">
+ <a
+ :href="group.groupPath">{{fullPath}}</a>
+ <template v-if="group.permissions.humanGroupAccess">
+ as
+ <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
+ </template>
+ </div>
+ <div
+ class="description">{{group.description}}</div>
+ </div>
+ <group-folder
+ v-if="group.isOpen && hasGroups"
+ :groups="group.subGroups"
+ :baseGroup="group"
+ />
+ </li>
+</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
new file mode 100644
index 00000000000..36a04d4202f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -0,0 +1,39 @@
+<script>
+import tablePagination from '~/vue_shared/components/table_pagination.vue';
+import eventHub from '../event_hub';
+
+export default {
+ props: {
+ groups: {
+ type: Object,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ tablePagination,
+ },
+ methods: {
+ change(page) {
+ const filterGroupsParam = gl.utils.getParameterByName('filter_groups');
+ const sortParam = gl.utils.getParameterByName('sort');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="groups-list-tree-container">
+ <group-folder
+ :groups="groups"
+ />
+ <table-pagination
+ :change="change"
+ :pageInfo="pageInfo"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/groups/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
new file mode 100644
index 00000000000..439a931ddad
--- /dev/null
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -0,0 +1,87 @@
+import FilterableList from '~/filterable_list';
+import eventHub from './event_hub';
+
+export default class GroupFilterableList extends FilterableList {
+ constructor({ form, filter, holder, filterEndpoint, pagePath }) {
+ super(form, filter, holder);
+ this.form = form;
+ this.filterEndpoint = filterEndpoint;
+ this.pagePath = pagePath;
+ this.$dropdown = $('.js-group-filter-dropdown-wrap');
+ }
+
+ getFilterEndpoint() {
+ return this.filterEndpoint;
+ }
+
+ getPagePath(queryData) {
+ const params = queryData ? $.param(queryData) : '';
+ const queryString = params ? `?${params}` : '';
+ return `${this.pagePath}${queryString}`;
+ }
+
+ bindEvents() {
+ super.bindEvents();
+
+ this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
+ this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
+
+ this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
+ this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
+ }
+
+ onFormSubmit(e) {
+ e.preventDefault();
+
+ const $form = $(this.form);
+ const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ const queryData = {};
+
+ if (filterGroupsParam) {
+ queryData.filter_groups = filterGroupsParam;
+ }
+
+ this.filterResults(queryData);
+ this.setDefaultFilterOption();
+ }
+
+ setDefaultFilterOption() {
+ const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+ this.$dropdown.find('.dropdown-label').text(defaultOption);
+ }
+
+ onOptionClick(e) {
+ e.preventDefault();
+
+ const queryData = {};
+ const sortParam = gl.utils.getParameterByName('sort', e.currentTarget.href);
+
+ if (sortParam) {
+ queryData.sort = sortParam;
+ }
+
+ this.filterResults(queryData);
+
+ // Active selected option
+ this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+
+ // Clear current value on search form
+ this.form.querySelector('[name="filter_groups"]').value = '';
+ }
+
+ onFilterSuccess(data, xhr, queryData) {
+ super.onFilterSuccess(data, xhr, queryData);
+
+ const paginationData = {
+ 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
+ 'X-Page': xhr.getResponseHeader('X-Page'),
+ 'X-Total': xhr.getResponseHeader('X-Total'),
+ 'X-Total-Pages': xhr.getResponseHeader('X-Total-Pages'),
+ 'X-Next-Page': xhr.getResponseHeader('X-Next-Page'),
+ 'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
+ };
+
+ eventHub.$emit('updateGroups', data);
+ eventHub.$emit('updatePagination', paginationData);
+ }
+}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
new file mode 100644
index 00000000000..00e1bd94c9c
--- /dev/null
+++ b/app/assets/javascripts/groups/index.js
@@ -0,0 +1,193 @@
+/* global Flash */
+
+import Vue from 'vue';
+import GroupFilterableList from './groups_filterable_list';
+import GroupsComponent from './components/groups.vue';
+import GroupFolder from './components/group_folder.vue';
+import GroupItem from './components/group_item.vue';
+import GroupsStore from './stores/groups_store';
+import GroupsService from './services/groups_service';
+import eventHub from './event_hub';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('dashboard-group-app');
+
+ // Don't do anything if element doesn't exist (No groups)
+ // This is for when the user enters directly to the page via URL
+ if (!el) {
+ return;
+ }
+
+ Vue.component('groups-component', GroupsComponent);
+ Vue.component('group-folder', GroupFolder);
+ Vue.component('group-item', GroupItem);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ this.store = new GroupsStore();
+ this.service = new GroupsService(el.dataset.endpoint);
+
+ return {
+ store: this.store,
+ isLoading: true,
+ state: this.store.state,
+ loading: true,
+ };
+ },
+ computed: {
+ isEmpty() {
+ return Object.keys(this.state.groups).length === 0;
+ },
+ },
+ methods: {
+ fetchGroups(parentGroup) {
+ let parentId = null;
+ let getGroups = null;
+ let page = null;
+ let sort = null;
+ let pageParam = null;
+ let sortParam = null;
+ let filterGroups = null;
+ let filterGroupsParam = null;
+
+ if (parentGroup) {
+ parentId = parentGroup.id;
+ } else {
+ this.isLoading = true;
+ }
+
+ pageParam = gl.utils.getParameterByName('page');
+ if (pageParam) {
+ page = pageParam;
+ }
+
+ filterGroupsParam = gl.utils.getParameterByName('filter_groups');
+ if (filterGroupsParam) {
+ filterGroups = filterGroupsParam;
+ }
+
+ sortParam = gl.utils.getParameterByName('sort');
+ if (sortParam) {
+ sort = sortParam;
+ }
+
+ getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
+ getGroups
+ .then(response => response.json())
+ .then((response) => {
+ this.isLoading = false;
+
+ this.updateGroups(response, parentGroup);
+ })
+ .catch(this.handleErrorResponse);
+
+ return getGroups;
+ },
+ fetchPage(page, filterGroups, sort) {
+ this.isLoading = true;
+
+ return this.service
+ .getGroups(null, page, filterGroups, sort)
+ .then((response) => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+
+ return response.json().then((data) => {
+ this.updateGroups(data);
+ this.updatePagination(response.headers);
+ });
+ })
+ .catch(this.handleErrorResponse);
+ },
+ toggleSubGroups(parentGroup = null) {
+ if (!parentGroup.isOpen) {
+ this.store.resetGroups(parentGroup);
+ this.fetchGroups(parentGroup);
+ }
+
+ this.store.toggleSubGroups(parentGroup);
+ },
+ leaveGroup(group, collection) {
+ this.service.leaveGroup(group.leavePath)
+ .then(resp => resp.json())
+ .then((response) => {
+ $.scrollTo(0);
+
+ this.store.removeGroup(group, collection);
+
+ // eslint-disable-next-line no-new
+ new Flash(response.notice, 'notice');
+ })
+ .catch((error) => {
+ let message = 'An error occurred. Please try again.';
+
+ if (error.status === 403) {
+ message = 'Failed to leave the group. Please make sure you are not the only owner';
+ }
+
+ // eslint-disable-next-line no-new
+ new Flash(message);
+ });
+ },
+ updateGroups(groups, parentGroup) {
+ this.store.setGroups(groups, parentGroup);
+ },
+ updatePagination(headers) {
+ this.store.storePagination(headers);
+ },
+ handleErrorResponse() {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred. Please try again.');
+ },
+ },
+ created() {
+ eventHub.$on('fetchPage', this.fetchPage);
+ eventHub.$on('toggleSubGroups', this.toggleSubGroups);
+ eventHub.$on('leaveGroup', this.leaveGroup);
+ eventHub.$on('updateGroups', this.updateGroups);
+ eventHub.$on('updatePagination', this.updatePagination);
+ },
+ beforeMount() {
+ let groupFilterList = null;
+ const form = document.querySelector('form#group-filter-form');
+ const filter = document.querySelector('.js-groups-list-filter');
+ const holder = document.querySelector('.js-groups-list-holder');
+
+ const opts = {
+ form,
+ filter,
+ holder,
+ filterEndpoint: el.dataset.endpoint,
+ pagePath: el.dataset.path,
+ };
+
+ groupFilterList = new GroupFilterableList(opts);
+ groupFilterList.initSearch();
+ },
+ mounted() {
+ this.fetchGroups()
+ .then((response) => {
+ this.updatePagination(response.headers);
+ this.isLoading = false;
+ })
+ .catch(this.handleErrorResponse);
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchPage', this.fetchPage);
+ eventHub.$off('toggleSubGroups', this.toggleSubGroups);
+ eventHub.$off('leaveGroup', this.leaveGroup);
+ eventHub.$off('updateGroups', this.updateGroups);
+ eventHub.$off('updatePagination', this.updatePagination);
+ },
+ });
+});
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/services/groups_service.js
new file mode 100644
index 00000000000..97e02fcb76d
--- /dev/null
+++ b/app/assets/javascripts/groups/services/groups_service.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class GroupsService {
+ constructor(endpoint) {
+ this.groups = Vue.resource(endpoint);
+ }
+
+ getGroups(parentId, page, filterGroups, sort) {
+ const data = {};
+
+ if (parentId) {
+ data.parent_id = parentId;
+ } else {
+ // Do not send the following param for sub groups
+ if (page) {
+ data.page = page;
+ }
+
+ if (filterGroups) {
+ data.filter_groups = filterGroups;
+ }
+
+ if (sort) {
+ data.sort = sort;
+ }
+ }
+
+ return this.groups.get(data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ leaveGroup(endpoint) {
+ return Vue.http.delete(endpoint);
+ }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
new file mode 100644
index 00000000000..6eab6083e8f
--- /dev/null
+++ b/app/assets/javascripts/groups/stores/groups_store.js
@@ -0,0 +1,166 @@
+import Vue from 'vue';
+
+export default class GroupsStore {
+ constructor() {
+ this.state = {};
+ this.state.groups = {};
+ this.state.pageInfo = {};
+ }
+
+ setGroups(rawGroups, parent) {
+ const parentGroup = parent;
+ const tree = this.buildTree(rawGroups, parentGroup);
+
+ if (parentGroup) {
+ parentGroup.subGroups = tree;
+ } else {
+ this.state.groups = tree;
+ }
+
+ return tree;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ resetGroups(parent) {
+ const parentGroup = parent;
+ parentGroup.subGroups = {};
+ }
+
+ storePagination(pagination = {}) {
+ let paginationInfo;
+
+ if (Object.keys(pagination).length) {
+ const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
+ paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = pagination;
+ }
+
+ this.state.pageInfo = paginationInfo;
+ }
+
+ buildTree(rawGroups, parentGroup) {
+ const groups = this.decorateGroups(rawGroups);
+ const tree = {};
+ const mappedGroups = {};
+ const orphans = [];
+
+ // Map groups to an object
+ groups.map((group) => {
+ mappedGroups[`id${group.id}`] = group;
+ mappedGroups[`id${group.id}`].subGroups = {};
+ return group;
+ });
+
+ Object.keys(mappedGroups).map((key) => {
+ const currentGroup = mappedGroups[key];
+ if (currentGroup.parentId) {
+ // If the group is not at the root level, add it to its parent array of subGroups.
+ const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
+ if (findParentGroup) {
+ mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
+ mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
+ } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
+ tree[`id${currentGroup.id}`] = currentGroup;
+ } else {
+ // No parent found. We save it for later processing
+ orphans.push(currentGroup);
+
+ // Add to tree to preserve original order
+ tree[`id${currentGroup.id}`] = currentGroup;
+ }
+ } else {
+ // If the group is at the top level, add it to first level elements array.
+ tree[`id${currentGroup.id}`] = currentGroup;
+ }
+
+ return key;
+ });
+
+ if (orphans.length) {
+ orphans.map((orphan) => {
+ let found = false;
+ const currentOrphan = orphan;
+
+ Object.keys(tree).map((key) => {
+ const group = tree[key];
+
+ if (
+ group &&
+ currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
+ // Make sure the currently selected orphan is not the same as the group
+ // we are checking here otherwise it will end up in an infinite loop
+ currentOrphan.id !== group.id
+ ) {
+ group.subGroups[currentOrphan.id] = currentOrphan;
+ group.isOpen = true;
+ currentOrphan.isOrphan = true;
+ found = true;
+
+ // Delete if group was put at the top level. If not the group will be displayed twice.
+ if (tree[`id${currentOrphan.id}`]) {
+ delete tree[`id${currentOrphan.id}`];
+ }
+ }
+
+ return key;
+ });
+
+ if (!found) {
+ currentOrphan.isOrphan = true;
+
+ tree[`id${currentOrphan.id}`] = currentOrphan;
+ }
+
+ return orphan;
+ });
+ }
+
+ return tree;
+ }
+
+ decorateGroups(rawGroups) {
+ this.groups = rawGroups.map(this.decorateGroup);
+ return this.groups;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ decorateGroup(rawGroup) {
+ return {
+ id: rawGroup.id,
+ fullName: rawGroup.full_name,
+ fullPath: rawGroup.full_path,
+ avatarUrl: rawGroup.avatar_url,
+ name: rawGroup.name,
+ hasSubgroups: rawGroup.has_subgroups,
+ canEdit: rawGroup.can_edit,
+ description: rawGroup.description,
+ webUrl: rawGroup.web_url,
+ groupPath: rawGroup.group_path,
+ parentId: rawGroup.parent_id,
+ visibility: rawGroup.visibility,
+ leavePath: rawGroup.leave_path,
+ editPath: rawGroup.edit_path,
+ isOpen: false,
+ isOrphan: false,
+ numberProjects: rawGroup.number_projects_with_delimiter,
+ numberUsers: rawGroup.number_users_with_delimiter,
+ permissions: {
+ humanGroupAccess: rawGroup.permissions.human_group_access,
+ },
+ subGroups: {},
+ };
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ removeGroup(group, collection) {
+ Vue.delete(collection, `id${group.id}`);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ toggleSubGroups(toggleGroup) {
+ const group = toggleGroup;
+ group.isOpen = !group.isOpen;
+ return group;
+ }
+}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index acfa4bd4c6b..4d629bc6326 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -3,7 +3,7 @@
prefer-arrow-callback, comma-dangle, consistent-return, yoda,
prefer-rest-params, prefer-spread, no-unused-vars, prefer-template,
promise/catch-or-return */
-/* global Api */
+import Api from './api';
var slice = [].slice;
@@ -111,8 +111,7 @@ window.GroupsSelect = (function() {
};
GroupsSelect.prototype.forceOverflow = function (e) {
- const itemHeight = this.dropdown.querySelector('.select2-result:first-child').clientHeight;
- this.dropdown.style.height = `${Math.floor(this.dropdown.scrollHeight - (itemHeight * 0.9))}px`;
+ this.dropdown.style.height = `${Math.floor(this.dropdown.scrollHeight)}px`;
};
GroupsSelect.PER_PAGE = 20;
diff --git a/app/assets/javascripts/helpers/issuables_helper.js b/app/assets/javascripts/helpers/issuables_helper.js
new file mode 100644
index 00000000000..52d0f7e43fc
--- /dev/null
+++ b/app/assets/javascripts/helpers/issuables_helper.js
@@ -0,0 +1,27 @@
+import CloseReopenReportToggle from '../close_reopen_report_toggle';
+
+function initCloseReopenReport() {
+ const container = document.querySelector('.js-issuable-close-dropdown');
+
+ if (!container) return undefined;
+
+ const dropdownTrigger = container.querySelector('.js-issuable-close-toggle');
+ const dropdownList = container.querySelector('.js-issuable-close-menu');
+ const button = container.querySelector('.js-issuable-close-button');
+
+ const closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ closeReopenReportToggle.initDroplab();
+
+ return closeReopenReportToggle;
+}
+
+const IssuablesHelper = {
+ initCloseReopenReport,
+};
+
+export default IssuablesHelper;
diff --git a/app/assets/javascripts/helpers/user_feature_helper.js b/app/assets/javascripts/helpers/user_feature_helper.js
new file mode 100644
index 00000000000..fcd8569819c
--- /dev/null
+++ b/app/assets/javascripts/helpers/user_feature_helper.js
@@ -0,0 +1,11 @@
+import Cookies from 'js-cookie';
+
+function isNewRepo() {
+ return Cookies.get('new_repo') === 'true';
+}
+
+const UserFeatureHelper = {
+ isNewRepo,
+};
+
+export default UserFeatureHelper;
diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js
new file mode 100644
index 00000000000..19f4a946f73
--- /dev/null
+++ b/app/assets/javascripts/how_to_merge.js
@@ -0,0 +1,12 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const modal = $('#modal_merge_info').modal({
+ modal: true,
+ show: false,
+ });
+ $('.how_to_merge_link').on('click', () => {
+ modal.show();
+ });
+ $('.modal-header .close').on('click', () => {
+ modal.hide();
+ });
+});
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 34e4a257ff9..5b4ca94ed30 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -56,6 +56,8 @@
if (job.import_status === 'finished') {
job_item.removeClass("active").addClass("success");
return status_field.html('<span><i class="fa fa-check"></i> done</span>');
+ } else if (job.import_status === 'scheduled') {
+ return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled");
} else if (job.import_status === 'started') {
return status_field.html("<i class='fa fa-spinner fa-spin'></i> started");
} else {
diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js
new file mode 100644
index 00000000000..f785ed29e6c
--- /dev/null
+++ b/app/assets/javascripts/init_changes_dropdown.js
@@ -0,0 +1,10 @@
+import stickyMonitor from './lib/utils/sticky';
+
+export default () => {
+ stickyMonitor(document.querySelector('.js-diff-files-changed'));
+
+ $('.js-diff-stats-dropdown').glDropdown({
+ filterable: true,
+ remoteFilter: false,
+ });
+};
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
new file mode 100644
index 00000000000..29e3d2ea94e
--- /dev/null
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+/* global MilestoneSelect */
+/* global LabelsSelect */
+/* global IssuableContext */
+/* global Sidebar */
+
+export default () => {
+ const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+
+ new MilestoneSelect({
+ full_path: sidebarOptions.fullPath,
+ });
+ new LabelsSelect();
+ new IssuableContext(sidebarOptions.currentUser);
+ gl.Subscription.bindAll('.subscription');
+ new gl.DueDateSelectors();
+ window.sidebar = new Sidebar();
+};
diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js
new file mode 100644
index 00000000000..1211c2c802c
--- /dev/null
+++ b/app/assets/javascripts/init_legacy_filters.js
@@ -0,0 +1,15 @@
+/* eslint-disable no-new */
+/* global LabelsSelect */
+/* global MilestoneSelect */
+/* global IssueStatusSelect */
+/* global SubscriptionSelect */
+
+import UsersSelect from './users_select';
+
+export default () => {
+ new UsersSelect();
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+};
diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js
new file mode 100644
index 00000000000..3a8b4360cb6
--- /dev/null
+++ b/app/assets/javascripts/init_notes.js
@@ -0,0 +1,14 @@
+/* global Notes */
+
+export default () => {
+ const dataEl = document.querySelector('.js-notes-data');
+ const {
+ notesUrl,
+ notesIds,
+ now,
+ diffView,
+ autocomplete,
+ } = JSON.parse(dataEl.innerHTML);
+
+ window.notes = new Notes(notesUrl, notesIds, now, diffView, autocomplete);
+};
diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js
new file mode 100644
index 00000000000..10fe6bac0e8
--- /dev/null
+++ b/app/assets/javascripts/integrations/index.js
@@ -0,0 +1,7 @@
+/* eslint-disable no-new */
+import IntegrationSettingsForm from './integration_settings_form';
+
+$(() => {
+ const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
new file mode 100644
index 00000000000..cf1e6a14725
--- /dev/null
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -0,0 +1,123 @@
+/* global Flash */
+
+export default class IntegrationSettingsForm {
+ constructor(formSelector) {
+ this.$form = $(formSelector);
+
+ // Form Metadata
+ this.canTestService = this.$form.data('can-test');
+ this.testEndPoint = this.$form.data('test-url');
+
+ // Form Child Elements
+ this.$serviceToggle = this.$form.find('#service_active');
+ this.$submitBtn = this.$form.find('button[type="submit"]');
+ this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner');
+ this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label');
+ }
+
+ init() {
+ // Initialize View
+ this.toggleServiceState(this.$serviceToggle.is(':checked'));
+
+ // Bind Event Listeners
+ this.$serviceToggle.on('change', e => this.handleServiceToggle(e));
+ this.$submitBtn.on('click', e => this.handleSettingsSave(e));
+ }
+
+ handleSettingsSave(e) {
+ // Check if Service is marked active, as if not marked active,
+ // We can skip testing it and directly go ahead to allow form to
+ // be submitted
+ if (!this.$serviceToggle.is(':checked')) {
+ return;
+ }
+
+ // Service was marked active so now we check;
+ // 1) If form contents are valid
+ // 2) If this service can be tested
+ // If both conditions are true, we override form submission
+ // and test the service using provided configuration.
+ if (this.$form.get(0).checkValidity() && this.canTestService) {
+ e.preventDefault();
+ this.testSettings(this.$form.serialize());
+ }
+ }
+
+ handleServiceToggle(e) {
+ this.toggleServiceState($(e.currentTarget).is(':checked'));
+ }
+
+ /**
+ * Change Form's validation enforcement based on service status (active/inactive)
+ */
+ toggleServiceState(serviceActive) {
+ this.toggleSubmitBtnLabel(serviceActive);
+ if (serviceActive) {
+ this.$form.removeAttr('novalidate');
+ } else if (!this.$form.attr('novalidate')) {
+ this.$form.attr('novalidate', 'novalidate');
+ }
+ }
+
+ /**
+ * Toggle Submit button label based on Integration status and ability to test service
+ */
+ toggleSubmitBtnLabel(serviceActive) {
+ let btnLabel = 'Save changes';
+
+ if (serviceActive && this.canTestService) {
+ btnLabel = 'Test settings and save changes';
+ }
+
+ this.$submitBtnLabel.text(btnLabel);
+ }
+
+ /**
+ * Toggle Submit button state based on provided boolean value of `saveTestActive`
+ * When enabled, it does two things, and reverts back when disabled
+ *
+ * 1. It shows load spinner on submit button
+ * 2. Makes submit button disabled
+ */
+ toggleSubmitBtnState(saveTestActive) {
+ if (saveTestActive) {
+ this.$submitBtn.disable();
+ this.$submitBtnLoader.removeClass('hidden');
+ } else {
+ this.$submitBtn.enable();
+ this.$submitBtnLoader.addClass('hidden');
+ }
+ }
+
+ /* eslint-disable promise/catch-or-return, no-new */
+ /**
+ * Test Integration config
+ */
+ testSettings(formData) {
+ this.toggleSubmitBtnState(true);
+ $.ajax({
+ type: 'PUT',
+ url: this.testEndPoint,
+ data: formData,
+ })
+ .done((res) => {
+ if (res.error) {
+ new Flash(`${res.message} ${res.service_response}`, null, null, {
+ title: 'Save anyway',
+ clickHandler: (e) => {
+ e.preventDefault();
+ this.$form.submit();
+ },
+ });
+ } else {
+ this.$form.submit();
+ }
+ })
+ .fail(() => {
+ new Flash('Something went wrong on our end.');
+ })
+ .always(() => {
+ this.toggleSubmitBtnState(false);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js
deleted file mode 100644
index 3bfce32768a..00000000000
--- a/app/assets/javascripts/issuable.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */
-/* global Issuable */
-
-((global) => {
- var issuable_created;
-
- issuable_created = false;
-
- global.Issuable = {
- init: function() {
- Issuable.initTemplates();
- Issuable.initSearch();
- Issuable.initChecks();
- Issuable.initResetFilters();
- Issuable.resetIncomingEmailToken();
- return Issuable.initLabelFilterRemove();
- },
- initTemplates: function() {
- return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
- },
- initSearch: function() {
- const $searchInput = $('#issuable_search');
-
- Issuable.initSearchState($searchInput);
-
- // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
- const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false);
-
- $searchInput.off('keyup').on('keyup', debouncedExecSearch);
-
- // ensures existing filters are preserved when manually submitted
- $('#issuable_search_form').on('submit', (e) => {
- e.preventDefault();
- debouncedExecSearch(e);
- });
- },
- initSearchState: function($searchInput) {
- const currentSearchVal = $searchInput.val();
-
- Issuable.searchState = {
- elem: $searchInput,
- current: currentSearchVal
- };
-
- Issuable.maybeFocusOnSearch();
- },
- accessSearchPristine: function(set) {
- // store reference to previous value to prevent search on non-mutating keyup
- const state = Issuable.searchState;
- const currentSearchVal = state.elem.val();
-
- if (set) {
- state.current = currentSearchVal;
- } else {
- return state.current === currentSearchVal;
- }
- },
- maybeFocusOnSearch: function() {
- const currentSearchVal = Issuable.searchState.current;
- if (currentSearchVal && currentSearchVal !== '') {
- const queryLength = currentSearchVal.length;
- const $searchInput = Issuable.searchState.elem;
-
- /* The following ensures that the cursor is initially placed at
- * the end of search input when focus is applied. It accounts
- * for differences in browser implementations of `setSelectionRange`
- * and cursor placement for elements in focus.
- */
- $searchInput.focus();
- if ($searchInput.setSelectionRange) {
- $searchInput.setSelectionRange(queryLength, queryLength);
- } else {
- $searchInput.val(currentSearchVal);
- }
- }
- },
- executeSearch: function(e) {
- const $search = $('#issuable_search');
- const $searchName = $search.attr('name');
- const $searchValue = $search.val();
- const $filtersForm = $('.js-filter-form');
- const $input = $(`input[name='${$searchName}']`, $filtersForm);
- const isPristine = Issuable.accessSearchPristine();
-
- if (isPristine) {
- return;
- }
-
- if (!$input.length) {
- $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`);
- } else {
- $input.val($searchValue);
- }
-
- Issuable.filterResults($filtersForm);
- },
- initLabelFilterRemove: function() {
- return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
- var $button;
- $button = $(this);
- // Remove the label input box
- $('input[name="label_name[]"]').filter(function() {
- return this.value === $button.data('label');
- }).remove();
- // Submit the form to get new data
- Issuable.filterResults($('.filter-form'));
- });
- },
- filterResults: (function(_this) {
- return function(form) {
- var formAction, formData, issuesUrl;
- formData = form.serializeArray();
- formData = formData.filter(function(data) {
- return data.value !== '';
- });
- formData = $.param(formData);
- formAction = form.attr('action');
- issuesUrl = formAction;
- issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&');
- issuesUrl += formData;
- return gl.utils.visitUrl(issuesUrl);
- };
- })(this),
- initResetFilters: function() {
- $('.reset-filters').on('click', function(e) {
- e.preventDefault();
- const target = e.target;
- const $form = $(target).parents('.js-filter-form');
- const baseIssuesUrl = target.href;
-
- $form.attr('action', baseIssuesUrl);
- gl.utils.visitUrl(baseIssuesUrl);
- });
- },
- initChecks: function() {
- this.issuableBulkActions = $('.bulk-update').data('bulkActions');
- $('.check_all_issues').off('click').on('click', function() {
- $('.selected_issue').prop('checked', this.checked);
- return Issuable.checkChanged();
- });
- return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this));
- },
- checkChanged: function() {
- const $checkedIssues = $('.selected_issue:checked');
- const $updateIssuesIds = $('#update_issuable_ids');
- const $issuesOtherFilters = $('.issues-other-filters');
- const $issuesBulkUpdate = $('.issues_bulk_update');
-
- this.issuableBulkActions.willUpdateLabels = false;
- this.issuableBulkActions.setOriginalDropdownData();
-
- if ($checkedIssues.length > 0) {
- const ids = $.map($checkedIssues, function(value) {
- return $(value).data('id');
- });
- $updateIssuesIds.val(ids);
- $issuesOtherFilters.hide();
- $issuesBulkUpdate.show();
- } else {
- $updateIssuesIds.val([]);
- $issuesBulkUpdate.hide();
- $issuesOtherFilters.show();
- }
- return true;
- },
-
- resetIncomingEmailToken: function() {
- $('.incoming-email-token-reset').on('click', function(e) {
- e.preventDefault();
-
- $.ajax({
- type: 'PUT',
- url: $('.incoming-email-token-reset').attr('href'),
- dataType: 'json',
- success: function(response) {
- $('#issue_email').val(response.new_issue_address).focus();
- },
- beforeSend: function() {
- $('.incoming-email-token-reset').text('resetting...');
- },
- complete: function() {
- $('.incoming-email-token-reset').text('reset it');
- }
- });
- });
- }
- };
-})(window);
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
new file mode 100644
index 00000000000..2203a56315e
--- /dev/null
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -0,0 +1,38 @@
+let instanceCount = 0;
+
+class AutoWidthDropdownSelect {
+ constructor(selectElement) {
+ this.$selectElement = $(selectElement);
+ this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`;
+ instanceCount += 1;
+ }
+
+ init() {
+ const dropdownClass = this.dropdownClass;
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ dropdownCss() {
+ let resultantWidth = 'auto';
+ const $dropdown = $(`.${dropdownClass}`);
+
+ // We have to look at the parent because
+ // `offsetParent` on a `display: none;` is `null`
+ const offsetParentWidth = $(this).parent().offsetParent().width();
+ // Reset any width to let it naturally flow
+ $dropdown.css('width', 'auto');
+ if ($dropdown.outerWidth(false) > offsetParentWidth) {
+ resultantWidth = offsetParentWidth;
+ }
+
+ return {
+ width: resultantWidth,
+ maxWidth: offsetParentWidth,
+ };
+ },
+ });
+
+ return this;
+ }
+}
+
+export default AutoWidthDropdownSelect;
diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js
deleted file mode 100644
index e927cc0077c..00000000000
--- a/app/assets/javascripts/issuable/issuable_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-require('./time_tracking/time_tracking_bundle');
diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
deleted file mode 100644
index aec13e78f42..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Vue from 'vue';
-import stopwatchSvg from 'icons/_icon_stopwatch.svg';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- Vue.component('time-tracking-collapsed-state', {
- name: 'time-tracking-collapsed-state',
- props: [
- 'showComparisonState',
- 'showSpentOnlyState',
- 'showEstimateOnlyState',
- 'showNoTimeTrackingState',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- methods: {
- abbreviateTime(timeStr) {
- return gl.utils.prettyTime.abbreviateTime(timeStr);
- },
- },
- template: `
- <div class='sidebar-collapsed-icon'>
- ${stopwatchSvg}
- <div class='time-tracking-collapsed-summary'>
- <div class='compare' v-if='showComparisonState'>
- <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='estimate-only' v-if='showEstimateOnlyState'>
- <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='spend-only' v-if='showSpentOnlyState'>
- <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
- </div>
- <div class='no-tracking' v-if='showNoTimeTrackingState'>
- <span class='no-value'>None</span>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
deleted file mode 100644
index c55e263f6f4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import Vue from 'vue';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- const prettyTime = gl.utils.prettyTime;
-
- Vue.component('time-tracking-comparison-pane', {
- name: 'time-tracking-comparison-pane',
- props: [
- 'timeSpent',
- 'timeEstimate',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- computed: {
- parsedRemaining() {
- const diffSeconds = this.timeEstimate - this.timeSpent;
- return prettyTime.parseSeconds(diffSeconds);
- },
- timeRemainingHumanReadable() {
- return prettyTime.stringifyTime(this.parsedRemaining);
- },
- timeRemainingTooltip() {
- const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
- return `${prefix} ${this.timeRemainingHumanReadable}`;
- },
- /* Diff values for comparison meter */
- timeRemainingMinutes() {
- return this.timeEstimate - this.timeSpent;
- },
- timeRemainingPercent() {
- return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
- },
- timeRemainingStatusClass() {
- return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
- },
- /* Parsed time values */
- parsedEstimate() {
- return prettyTime.parseSeconds(this.timeEstimate);
- },
- parsedSpent() {
- return prettyTime.parseSeconds(this.timeSpent);
- },
- },
- template: `
- <div class='time-tracking-comparison-pane'>
- <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
- :aria-valuenow='timeRemainingTooltip'
- :title='timeRemainingTooltip'
- :data-original-title='timeRemainingTooltip'
- :class='timeRemainingStatusClass'>
- <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
- <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
- </div>
- <div class='compare-display-container'>
- <div class='compare-display pull-left'>
- <span class='compare-label'>Spent</span>
- <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
- </div>
- <div class='compare-display estimated pull-right'>
- <span class='compare-label'>Est</span>
- <span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
- </div>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
deleted file mode 100644
index a7fbd704c40..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-estimate-only-pane', {
- name: 'time-tracking-estimate-only-pane',
- props: ['timeEstimateHumanReadable'],
- template: `
- <div class='time-tracking-estimate-only-pane'>
- <span class='bold'>Estimated:</span>
- {{ timeEstimateHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js
deleted file mode 100644
index 344b29ebea4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-help-state', {
- name: 'time-tracking-help-state',
- props: ['docsUrl'],
- template: `
- <div class='time-tracking-help-state'>
- <div class='time-tracking-info'>
- <h4>Track time with slash commands</h4>
- <p>Slash commands can be used in the issues description and comment boxes.</p>
- <p>
- <code>/estimate</code>
- will update the estimated time with the latest command.
- </p>
- <p>
- <code>/spend</code>
- will update the sum of the time spent.
- </p>
- <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
deleted file mode 100644
index b081adf5e64..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-no-tracking-pane', {
- name: 'time-tracking-no-tracking-pane',
- template: `
- <div class='time-tracking-no-tracking-pane'>
- <span class='no-value'>No estimate or time spent</span>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
deleted file mode 100644
index edb9169112f..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-spent-only-pane', {
- name: 'time-tracking-spent-only-pane',
- props: ['timeSpentHumanReadable'],
- template: `
- <div class='time-tracking-spend-only-pane'>
- <span class='bold'>Spent:</span>
- {{ timeSpentHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
deleted file mode 100644
index 0213522f551..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import Vue from 'vue';
-
-require('./help_state');
-require('./collapsed_state');
-require('./spent_only_pane');
-require('./no_tracking_pane');
-require('./estimate_only_pane');
-require('./comparison_pane');
-
-(() => {
- Vue.component('issuable-time-tracker', {
- name: 'issuable-time-tracker',
- props: [
- 'time_estimate',
- 'time_spent',
- 'human_time_estimate',
- 'human_time_spent',
- 'docsUrl',
- ],
- data() {
- return {
- showHelp: false,
- };
- },
- computed: {
- timeSpent() {
- return this.time_spent;
- },
- timeEstimate() {
- return this.time_estimate;
- },
- timeEstimateHumanReadable() {
- return this.human_time_estimate;
- },
- timeSpentHumanReadable() {
- return this.human_time_spent;
- },
- hasTimeSpent() {
- return !!this.timeSpent;
- },
- hasTimeEstimate() {
- return !!this.timeEstimate;
- },
- showComparisonState() {
- return this.hasTimeEstimate && this.hasTimeSpent;
- },
- showEstimateOnlyState() {
- return this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showSpentOnlyState() {
- return this.hasTimeSpent && !this.hasTimeEstimate;
- },
- showNoTimeTrackingState() {
- return !this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showHelpState() {
- return !!this.showHelp;
- },
- },
- methods: {
- toggleHelpState(show) {
- this.showHelp = show;
- },
- },
- template: `
- <div class='time_tracker time-tracking-component-wrap' v-cloak>
- <time-tracking-collapsed-state
- :show-comparison-state='showComparisonState'
- :show-help-state='showHelpState'
- :show-spent-only-state='showSpentOnlyState'
- :show-estimate-only-state='showEstimateOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-collapsed-state>
- <div class='title hide-collapsed'>
- Time tracking
- <div class='help-button pull-right'
- v-if='!showHelpState'
- @click='toggleHelpState(true)'>
- <i class='fa fa-question-circle' aria-hidden='true'></i>
- </div>
- <div class='close-help-button pull-right'
- v-if='showHelpState'
- @click='toggleHelpState(false)'>
- <i class='fa fa-close' aria-hidden='true'></i>
- </div>
- </div>
- <div class='time-tracking-content hide-collapsed'>
- <time-tracking-estimate-only-pane
- v-if='showEstimateOnlyState'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-estimate-only-pane>
- <time-tracking-spent-only-pane
- v-if='showSpentOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'>
- </time-tracking-spent-only-pane>
- <time-tracking-no-tracking-pane
- v-if='showNoTimeTrackingState'>
- </time-tracking-no-tracking-pane>
- <time-tracking-comparison-pane
- v-if='showComparisonState'
- :time-estimate='timeEstimate'
- :time-spent='timeSpent'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-comparison-pane>
- <transition name='help-state-toggle'>
- <time-tracking-help-state
- v-if='showHelpState'
- :docs-url='docsUrl'>
- </time-tracking-help-state>
- </transition>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
deleted file mode 100644
index 1689a69e1ed..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('./components/time_tracker');
-require('../../smart_interval');
-require('../../subbable_resource');
-
-Vue.use(VueResource);
-
-(() => {
- /* This Vue instance represents what will become the parent instance for the
- * sidebar. It will be responsible for managing `issuable` state and propagating
- * changes to sidebar components. We will want to create a separate service to
- * interface with the server at that point.
- */
-
- class IssuableTimeTracking {
- constructor(issuableJSON) {
- const parsedIssuable = JSON.parse(issuableJSON);
- return this.initComponent(parsedIssuable);
- }
-
- initComponent(parsedIssuable) {
- this.parentInstance = new Vue({
- el: '#issuable-time-tracker',
- data: {
- issuable: parsedIssuable,
- },
- methods: {
- fetchIssuable() {
- return gl.IssuableResource.get.call(gl.IssuableResource, {
- type: 'GET',
- url: gl.IssuableResource.endpoint,
- });
- },
- updateState(data) {
- this.issuable = data;
- },
- subscribeToUpdates() {
- gl.IssuableResource.subscribe(data => this.updateState(data));
- },
- listenForSlashCommands() {
- $(document).on('ajax:success', '.gfm-form', (e, data) => {
- const subscribedCommands = ['spend_time', 'time_estimate'];
- const changedCommands = data.commands_changes
- ? Object.keys(data.commands_changes)
- : [];
- if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
- this.fetchIssuable();
- }
- });
- },
- },
- created() {
- this.fetchIssuable();
- },
- mounted() {
- this.subscribeToUpdates();
- this.listenForSlashCommands();
- },
- });
- }
- }
-
- gl.IssuableTimeTracking = IssuableTimeTracking;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
new file mode 100644
index 00000000000..c39ffdb2e0f
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -0,0 +1,160 @@
+/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
+/* global IssuableIndex */
+/* global Flash */
+import _ from 'underscore';
+
+export default {
+ init({ container, form, issues, prefixId } = {}) {
+ this.prefixId = prefixId || 'issue_';
+ this.form = form || this.getElement('.bulk-update');
+ this.$labelDropdown = this.form.find('.js-label-select');
+ this.issues = issues || this.getElement('.issues-list .issue');
+ this.willUpdateLabels = false;
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
+ },
+
+ onFormSubmit(e) {
+ e.preventDefault();
+ return this.submit();
+ },
+
+ submit() {
+ const _this = this;
+ const xhr = $.ajax({
+ url: this.form.attr('action'),
+ method: this.form.attr('method'),
+ dataType: 'JSON',
+ data: this.getFormDataAsObject()
+ });
+ xhr.done(() => window.location.reload());
+ xhr.fail(() => this.onFormSubmitFailure());
+ },
+
+ onFormSubmitFailure() {
+ this.form.find('[type="submit"]').enable();
+ return new Flash("Issue update failed");
+ },
+
+ getSelectedIssues() {
+ return this.issues.has('.selected_issue:checked');
+ },
+
+ getLabelsFromSelection() {
+ const labels = [];
+ this.getSelectedIssues().map(function() {
+ const labelsData = $(this).data('labels');
+ if (labelsData) {
+ return labelsData.map(function(labelId) {
+ if (labels.indexOf(labelId) === -1) {
+ return labels.push(labelId);
+ }
+ });
+ }
+ });
+ return labels;
+ },
+
+ /**
+ * Will return only labels that were marked previously and the user has unmarked
+ * @return {Array} Label IDs
+ */
+
+ getUnmarkedIndeterminedLabels() {
+ const result = [];
+ const labelsToKeep = this.$labelDropdown.data('indeterminate');
+
+ this.getLabelsFromSelection().forEach((id) => {
+ if (labelsToKeep.indexOf(id) === -1) {
+ result.push(id);
+ }
+ });
+
+ return result;
+ },
+
+ /**
+ * Simple form serialization, it will return just what we need
+ * Returns key/value pairs from form data
+ */
+
+ getFormDataAsObject() {
+ const formData = {
+ update: {
+ state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
+ assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
+ milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
+ issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
+ subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
+ add_label_ids: [],
+ remove_label_ids: []
+ }
+ };
+ if (this.willUpdateLabels) {
+ formData.update.add_label_ids = this.$labelDropdown.data('marked');
+ formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
+ }
+ return formData;
+ },
+
+ setOriginalDropdownData() {
+ const $labelSelect = $('.bulk-update .js-label-select');
+ $labelSelect.data('common', this.getOriginalCommonIds());
+ $labelSelect.data('marked', this.getOriginalMarkedIds());
+ $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalCommonIds() {
+ const labelIds = [];
+
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalMarkedIds() {
+ const labelIds = [];
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalIndeterminateIds() {
+ const uniqueIds = [];
+ const labelIds = [];
+ let issuableLabels = [];
+
+ // Collect unique label IDs for all checked issues
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
+ issuableLabels.forEach((labelId) => {
+ // Store unique IDs
+ if (uniqueIds.indexOf(labelId) === -1) {
+ uniqueIds.push(labelId);
+ }
+ });
+ // Store array of IDs per issuable
+ labelIds.push(issuableLabels);
+ });
+ // Add uniqueIds to add it as argument for _.intersection
+ labelIds.unshift(uniqueIds);
+ // Return IDs that are present but not in all selected issueables
+ return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ },
+
+ getElement(selector) {
+ this.scopeEl = this.scopeEl || $('.content');
+ return this.scopeEl.find(selector);
+ },
+};
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
new file mode 100644
index 00000000000..d314f3c4d43
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -0,0 +1,158 @@
+/* eslint-disable class-methods-use-this, no-new */
+/* global LabelsSelect */
+/* global MilestoneSelect */
+/* global IssueStatusSelect */
+/* global SubscriptionSelect */
+
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import SidebarHeightManager from './sidebar_height_manager';
+
+const HIDDEN_CLASS = 'hidden';
+const DISABLED_CONTENT_CLASS = 'disabled-content';
+const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar';
+const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar';
+
+export default class IssuableBulkUpdateSidebar {
+ constructor() {
+ this.initDomElements();
+ this.bindEvents();
+ this.initDropdowns();
+ this.setupBulkUpdateActions();
+ }
+
+ initDomElements() {
+ this.$page = $('.page-with-sidebar');
+ this.$sidebar = $('.right-sidebar');
+ this.$sidebarInnerContainer = this.$sidebar.find('.issuable-sidebar');
+ this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
+ this.$bulkEditSubmitBtn = $('.update-selected-issues');
+ this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle');
+ this.$otherFilters = $('.issues-other-filters');
+ this.$checkAllContainer = $('.check-all-holder');
+ this.$issueChecks = $('.issue-check');
+ this.$issuesList = $('.selected_issue');
+ this.$issuableIdsInput = $('#update_issuable_ids');
+ }
+
+ bindEvents() {
+ this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true));
+ this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false));
+ this.$checkAllContainer.on('click', e => this.selectAll(e));
+ this.$issuesList.on('change', () => this.updateFormState());
+ this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
+ this.$checkAllContainer.on('click', () => this.updateFormState());
+ }
+
+ initDropdowns() {
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+ }
+
+ getNavHeight() {
+ const navbarHeight = $('.navbar-gitlab').outerHeight();
+ const layoutNavHeight = $('.layout-nav').outerHeight();
+ const subNavScroll = $('.sub-nav-scroll').outerHeight();
+ return navbarHeight + layoutNavHeight + subNavScroll;
+ }
+
+ setupBulkUpdateActions() {
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ updateFormState() {
+ const noCheckedIssues = !$('.selected_issue:checked').length;
+
+ this.toggleSubmitButtonDisabled(noCheckedIssues);
+ this.updateSelectedIssuableIds();
+
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ prepForSubmit() {
+ // if submit button is disabled, submission is blocked. This ensures we disable after
+ // form submission is carried out
+ setTimeout(() => this.$bulkEditSubmitBtn.disable());
+ this.updateSelectedIssuableIds();
+ }
+
+ toggleBulkEdit(e, enable) {
+ e.preventDefault();
+
+ this.toggleSidebarDisplay(enable);
+ this.toggleBulkEditButtonDisabled(enable);
+ this.toggleOtherFiltersDisabled(enable);
+ this.toggleCheckboxDisplay(enable);
+
+ if (enable) {
+ this.initAffix();
+ SidebarHeightManager.init();
+ }
+ }
+
+ initAffix() {
+ if (!this.$sidebar.hasClass('affix-top')) {
+ const offsetTop = $('.scrolling-tabs-container').outerHeight() + $('.sub-nav-scroll').outerHeight();
+
+ this.$sidebar.affix({
+ offset: {
+ top: offsetTop,
+ },
+ });
+ }
+ }
+
+ updateSelectedIssuableIds() {
+ this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds());
+ }
+
+ selectAll() {
+ const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked');
+
+ this.$issuesList.prop('checked', checkAllButtonState);
+ }
+
+ toggleSidebarDisplay(show) {
+ this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebarInnerContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ }
+
+ toggleBulkEditButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkUpdateEnableBtn.disable();
+ } else {
+ this.$bulkUpdateEnableBtn.enable();
+ }
+ }
+
+ toggleCheckboxDisplay(show) {
+ this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
+ }
+
+ toggleOtherFiltersDisabled(disable) {
+ this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable);
+ }
+
+ toggleSubmitButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkEditSubmitBtn.disable();
+ } else {
+ this.$bulkEditSubmitBtn.enable();
+ }
+ }
+
+ static getCheckedIssueIds() {
+ const $checkedIssues = $('.selected_issue:checked');
+
+ if ($checkedIssues.length > 0) {
+ return $.map($checkedIssues, value => $(value).data('id'));
+ }
+
+ return [];
+ }
+}
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 834b98e8601..70c364e51fe 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,8 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */
-/* global UsersSelect */
-/* global bp */
-
import Cookies from 'js-cookie';
+import bp from './breakpoints';
+import UsersSelect from './users_select';
+
+const PARTICIPANTS_ROW_COUNT = 7;
(function() {
this.IssuableContext = (function() {
@@ -47,15 +48,12 @@ import Cookies from 'js-cookie';
Cookies.set('collapsed_gutter', true);
}
});
- $(".right-sidebar").niceScroll();
}
IssuableContext.prototype.initParticipants = function() {
- var _this;
- _this = this;
$(document).on("click", ".js-participants-more", this.toggleHiddenParticipants);
return $(".js-participants-author").each(function(i) {
- if (i >= _this.PARTICIPANTS_ROW_COUNT) {
+ if (i >= PARTICIPANTS_ROW_COUNT) {
return $(this).addClass("js-participants-hidden").hide();
}
});
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 687c2bb6110..3f848e0859b 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,14 +1,14 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */
/* global GitLab */
-/* global UsersSelect */
-/* global ZenMode */
/* global Autosave */
/* global dateFormat */
-/* global Pikaday */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+import Pikaday from 'pikaday';
+import UsersSelect from './users_select';
+import GfmAutoComplete from './gfm_auto_complete';
+import ZenMode from './zen_mode';
+(function() {
this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
@@ -17,11 +17,11 @@
function IssuableForm(form) {
var $issuableDueDate, calendar;
this.form = form;
- this.toggleWip = bind(this.toggleWip, this);
- this.renderWipExplanation = bind(this.renderWipExplanation, this);
- this.resetAutosave = bind(this.resetAutosave, this);
- this.handleSubmit = bind(this.handleSubmit, this);
- gl.GfmAutoComplete.setup();
+ this.toggleWip = this.toggleWip.bind(this);
+ this.renderWipExplanation = this.renderWipExplanation.bind(this);
+ this.resetAutosave = this.resetAutosave.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
new UsersSelect();
new ZenMode();
this.titleField = this.form.find("input[name*='[title]']");
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
new file mode 100644
index 00000000000..ece0220c927
--- /dev/null
+++ b/app/assets/javascripts/issuable_index.js
@@ -0,0 +1,171 @@
+/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */
+/* global IssuableIndex */
+import _ from 'underscore';
+import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
+((global) => {
+ var issuable_created;
+
+ issuable_created = false;
+
+ global.IssuableIndex = {
+ init: function(pagePrefix) {
+ IssuableIndex.initTemplates();
+ IssuableIndex.initSearch();
+ IssuableIndex.initBulkUpdate(pagePrefix);
+ IssuableIndex.initResetFilters();
+ IssuableIndex.resetIncomingEmailToken();
+ IssuableIndex.initLabelFilterRemove();
+ },
+ initTemplates: function() {
+ return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
+ },
+ initSearch: function() {
+ const $searchInput = $('#issuable_search');
+
+ IssuableIndex.initSearchState($searchInput);
+
+ // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
+ const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false);
+
+ $searchInput.off('keyup').on('keyup', debouncedExecSearch);
+
+ // ensures existing filters are preserved when manually submitted
+ $('#issuable_search_form').on('submit', (e) => {
+ e.preventDefault();
+ debouncedExecSearch(e);
+ });
+ },
+ initSearchState: function($searchInput) {
+ const currentSearchVal = $searchInput.val();
+
+ IssuableIndex.searchState = {
+ elem: $searchInput,
+ current: currentSearchVal
+ };
+
+ IssuableIndex.maybeFocusOnSearch();
+ },
+ accessSearchPristine: function(set) {
+ // store reference to previous value to prevent search on non-mutating keyup
+ const state = IssuableIndex.searchState;
+ const currentSearchVal = state.elem.val();
+
+ if (set) {
+ state.current = currentSearchVal;
+ } else {
+ return state.current === currentSearchVal;
+ }
+ },
+ maybeFocusOnSearch: function() {
+ const currentSearchVal = IssuableIndex.searchState.current;
+ if (currentSearchVal && currentSearchVal !== '') {
+ const queryLength = currentSearchVal.length;
+ const $searchInput = IssuableIndex.searchState.elem;
+
+ /* The following ensures that the cursor is initially placed at
+ * the end of search input when focus is applied. It accounts
+ * for differences in browser implementations of `setSelectionRange`
+ * and cursor placement for elements in focus.
+ */
+ $searchInput.focus();
+ if ($searchInput.setSelectionRange) {
+ $searchInput.setSelectionRange(queryLength, queryLength);
+ } else {
+ $searchInput.val(currentSearchVal);
+ }
+ }
+ },
+ executeSearch: function(e) {
+ const $search = $('#issuable_search');
+ const $searchName = $search.attr('name');
+ const $searchValue = $search.val();
+ const $filtersForm = $('.js-filter-form');
+ const $input = $(`input[name='${$searchName}']`, $filtersForm);
+ const isPristine = IssuableIndex.accessSearchPristine();
+
+ if (isPristine) {
+ return;
+ }
+
+ if (!$input.length) {
+ $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`);
+ } else {
+ $input.val($searchValue);
+ }
+
+ IssuableIndex.filterResults($filtersForm);
+ },
+ initLabelFilterRemove: function() {
+ return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
+ var $button;
+ $button = $(this);
+ // Remove the label input box
+ $('input[name="label_name[]"]').filter(function() {
+ return this.value === $button.data('label');
+ }).remove();
+ // Submit the form to get new data
+ IssuableIndex.filterResults($('.filter-form'));
+ });
+ },
+ filterResults: (function(_this) {
+ return function(form) {
+ var formAction, formData, issuesUrl;
+ formData = form.serializeArray();
+ formData = formData.filter(function(data) {
+ return data.value !== '';
+ });
+ formData = $.param(formData);
+ formAction = form.attr('action');
+ issuesUrl = formAction;
+ issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&');
+ issuesUrl += formData;
+ return gl.utils.visitUrl(issuesUrl);
+ };
+ })(this),
+ initResetFilters: function() {
+ $('.reset-filters').on('click', function(e) {
+ e.preventDefault();
+ const target = e.target;
+ const $form = $(target).parents('.js-filter-form');
+ const baseIssuesUrl = target.href;
+
+ $form.attr('action', baseIssuesUrl);
+ gl.utils.visitUrl(baseIssuesUrl);
+ });
+ },
+ initBulkUpdate: function(pagePrefix) {
+ const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
+ const alreadyInitialized = !!this.bulkUpdateSidebar;
+
+ if (userCanBulkUpdate && !alreadyInitialized) {
+ IssuableBulkUpdateActions.init({
+ prefixId: pagePrefix,
+ });
+
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
+ }
+ },
+ resetIncomingEmailToken: function() {
+ $('.incoming-email-token-reset').on('click', function(e) {
+ e.preventDefault();
+
+ $.ajax({
+ type: 'PUT',
+ url: $('.incoming-email-token-reset').attr('href'),
+ dataType: 'json',
+ success: function(response) {
+ $('#issue_email').val(response.new_issue_address).focus();
+ },
+ beforeSend: function() {
+ $('.incoming-email-token-reset').text('resetting...');
+ },
+ complete: function() {
+ $('.incoming-email-token-reset').text('reset it');
+ }
+ });
+ });
+ }
+ };
+})(window);
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 011043e992f..2bee4fb045a 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,15 +1,17 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
/* global Flash */
-require('./flash');
-require('~/lib/utils/text_utility');
-require('vendor/jquery.waitforimages');
-require('./task_list');
+import 'vendor/jquery.waitforimages';
+import '~/lib/utils/text_utility';
+import './flash';
+import TaskList from './task_list';
+import CreateMergeRequestDropdown from './create_merge_request_dropdown';
+import IssuablesHelper from './helpers/issuables_helper';
class Issue {
constructor() {
if ($('a.btn-close').length) {
- this.taskList = new gl.TaskList({
+ this.taskList = new TaskList({
dataType: 'issue',
fieldName: 'description',
selector: '.detail-page-description',
@@ -18,66 +20,104 @@ class Issue {
document.querySelector('#task_status_short').innerText = result.task_status_short;
}
});
- Issue.initIssueBtnEventListeners();
+ this.initIssueBtnEventListeners();
}
Issue.$btnNewBranch = $('#new-branch');
+ Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
Issue.initMergeRequests();
Issue.initRelatedBranches();
- Issue.initCanCreateBranch();
+
+ this.closeButtons = $('a.btn-close');
+ this.reopenButtons = $('a.btn-reopen');
+
+ this.initCloseReopenReport();
+
+ if (Issue.createMrDropdownWrap) {
+ this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
+ }
}
- static initIssueBtnEventListeners() {
+ initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
- const closeButtons = $('a.btn-close');
- const isClosedBadge = $('div.status-box-closed');
- const isOpenBadge = $('div.status-box-open');
- const projectIssuesCounter = $('.issue_counter');
- const reopenButtons = $('a.btn-reopen');
-
- return closeButtons.add(reopenButtons).on('click', function(e) {
- var $this, shouldSubmit, url;
+ return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => {
+ var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
- $this = $(this);
- shouldSubmit = $this.hasClass('btn-comment');
+ $button = $(e.currentTarget);
+ shouldSubmit = $button.hasClass('btn-comment');
if (shouldSubmit) {
- Issue.submitNoteForm($this.closest('form'));
+ Issue.submitNoteForm($button.closest('form'));
}
- $this.prop('disabled', true);
- Issue.setNewBranchButtonState(true, null);
- url = $this.attr('href');
+
+ this.disableCloseReopenButton($button);
+
+ url = $button.attr('href');
return $.ajax({
type: 'PUT',
url: url
- }).fail(function(jqXHR, textStatus, errorThrown) {
- new Flash(issueFailMessage);
- Issue.initCanCreateBranch();
- }).done(function(data, textStatus, jqXHR) {
+ })
+ .fail(() => new Flash(issueFailMessage))
+ .done((data) => {
+ const isClosedBadge = $('div.status-box-closed');
+ const isOpenBadge = $('div.status-box-open');
+ const projectIssuesCounter = $('.issue_counter');
+
if ('id' in data) {
$(document).trigger('issuable:change');
- const isClosed = $this.hasClass('btn-close');
- closeButtons.toggleClass('hidden', isClosed);
- reopenButtons.toggleClass('hidden', !isClosed);
+ const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
+ this.toggleCloseReopenButton(isClosed);
+
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
+
+ if (this.createMergeRequestDropdown) {
+ if (isClosed) {
+ this.createMergeRequestDropdown.unavailable();
+ this.createMergeRequestDropdown.disable();
+ } else {
+ // We should check in case a branch was created in another tab
+ this.createMergeRequestDropdown.checkAbilityToCreateBranch();
+ }
+ }
} else {
new Flash(issueFailMessage);
}
-
- $this.prop('disabled', false);
- Issue.initCanCreateBranch();
+ })
+ .then(() => {
+ this.disableCloseReopenButton($button, false);
});
});
}
+ initCloseReopenReport() {
+ this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
+
+ if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button');
+ if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button');
+ }
+
+ disableCloseReopenButton($button, shouldDisable) {
+ if (this.closeReopenReportToggle) {
+ this.closeReopenReportToggle.setDisable(shouldDisable);
+ } else {
+ $button.prop('disabled', shouldDisable);
+ }
+ }
+
+ toggleCloseReopenButton(isClosed) {
+ if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed);
+ this.closeButtons.toggleClass('hidden', isClosed);
+ this.reopenButtons.toggleClass('hidden', !isClosed);
+ }
+
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
@@ -109,29 +149,6 @@ class Issue {
}
});
}
-
- static initCanCreateBranch() {
- // If the user doesn't have the required permissions the container isn't
- // rendered at all.
- if (Issue.$btnNewBranch.length === 0) {
- return;
- }
- return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() {
- Issue.setNewBranchButtonState(false, false);
- new Flash('Failed to check if a new branch can be created.');
- }).done(function(data) {
- Issue.setNewBranchButtonState(false, data.can_create_branch);
- });
- }
-
- static setNewBranchButtonState(isPending, canCreate) {
- if (Issue.$btnNewBranch.length === 0) {
- return;
- }
-
- Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate);
- Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate);
- }
}
export default Issue;
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
new file mode 100644
index 00000000000..efae112923d
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -0,0 +1,270 @@
+<script>
+/* global Flash */
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import Service from '../services/index';
+import Store from '../stores';
+import titleComponent from './title.vue';
+import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
+import formComponent from './form.vue';
+import '../../lib/utils/url_utility';
+
+export default {
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ canMove: {
+ required: true,
+ type: Boolean,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ initialTitleHtml: {
+ type: String,
+ required: true,
+ },
+ initialTitleText: {
+ type: String,
+ required: true,
+ },
+ initialDescriptionHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialTaskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isConfidential: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const store = new Store({
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
+ descriptionHtml: this.initialDescriptionHtml,
+ descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ taskStatus: this.initialTaskStatus,
+ });
+
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ };
+ },
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
+ },
+ components: {
+ descriptionComponent,
+ titleComponent,
+ editedComponent,
+ formComponent,
+ },
+ methods: {
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ confidential: this.isConfidential,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
+ updateIssuable() {
+ const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
+ confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
+
+ if (!canPostUpdate) {
+ this.store.setFormState({
+ updateLoading: false,
+ });
+ return;
+ }
+
+ this.service.updateIssuable(this.store.formState)
+ .then(res => res.json())
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ gl.utils.visitUrl(data.web_url);
+ } else if (data.confidential !== this.isConfidential) {
+ gl.utils.visitUrl(location.pathname);
+ }
+
+ return this.service.getData();
+ })
+ .then(res => res.json())
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error updating issue');
+ });
+ },
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.json())
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issue not existing
+ this.poll.stop();
+
+ gl.utils.visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error deleting issue');
+ });
+ },
+ },
+ created() {
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: res => res.json().then(data => this.store.updateState(data)),
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.deleteIssuable);
+ eventHub.$off('update.issuable', this.updateIssuable);
+ eventHub.$off('close.form', this.closeForm);
+ eventHub.$off('open.form', this.openForm);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <form-component
+ v-if="canUpdate && showForm"
+ :form-state="formState"
+ :can-move="canMove"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs="markdownDocs"
+ :markdown-preview-url="markdownPreviewUrl"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :projects-autocomplete-url="projectsAutocompleteUrl"
+ />
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText" />
+ <description-component
+ v-if="state.descriptionHtml"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus" />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
new file mode 100644
index 00000000000..48bad8f1e68
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -0,0 +1,101 @@
+<script>
+ import animateMixin from '../mixins/animate';
+ import TaskList from '../../task_list';
+
+ export default {
+ mixins: [animateMixin],
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionHtml: {
+ type: String,
+ required: true,
+ },
+ descriptionText: {
+ type: String,
+ required: true,
+ },
+ taskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ };
+ },
+ watch: {
+ descriptionHtml() {
+ this.animateChange();
+
+ this.$nextTick(() => {
+ this.renderGFM();
+ });
+ },
+ taskStatus() {
+ this.updateTaskStatusText();
+ },
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['gfm-content']).renderGFM();
+
+ if (this.canUpdate) {
+ // eslint-disable-next-line no-new
+ new TaskList({
+ dataType: 'issue',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ });
+ }
+ },
+ updateTaskStatusText() {
+ const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
+ const $issuableHeader = $('.issuable-meta');
+ const $tasks = $('#task_status', $issuableHeader);
+ const $tasksShort = $('#task_status_short', $issuableHeader);
+
+ if (taskRegexMatches) {
+ $tasks.text(this.taskStatus);
+ $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
+ } else {
+ $tasks.text('');
+ $tasksShort.text('');
+ }
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ this.updateTaskStatusText();
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="descriptionHtml"
+ class="description"
+ :class="{
+ 'js-task-list-container': canUpdate
+ }">
+ <div
+ class="wiki"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="descriptionHtml"
+ ref="gfm-content">
+ </div>
+ <textarea
+ class="hidden js-task-list-field"
+ v-if="descriptionText"
+ v-model="descriptionText">
+ </textarea>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
new file mode 100644
index 00000000000..8c81575fe6f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -0,0 +1,79 @@
+<script>
+ import updateMixin from '../mixins/update';
+ import eventHub from '../event_hub';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteLoading: false,
+ };
+ },
+ computed: {
+ isSubmitEnabled() {
+ return this.formState.title.trim() !== '';
+ },
+ },
+ methods: {
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Issue will be removed! Are you sure?')) {
+ this.deleteLoading = true;
+
+ eventHub.$emit('delete.issuable');
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prepend-top-default append-bottom-default clearfix">
+ <button
+ class="btn btn-save pull-left"
+ :class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
+ type="submit"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ @click.prevent="updateIssuable">
+ Save changes
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="formState.updateLoading">
+ </i>
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="closeForm">
+ Cancel
+ </button>
+ <button
+ v-if="canDestroy"
+ class="btn btn-danger pull-right append-right-default"
+ :class="{ disabled: deleteLoading }"
+ type="button"
+ :disabled="deleteLoading"
+ @click="deleteIssuable">
+ Delete
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="deleteLoading">
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
new file mode 100644
index 00000000000..d59e6d11032
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -0,0 +1,56 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small
+ class="edited-text"
+ >
+ Edited
+ <time-ago-tooltip
+ v-if="updatedAt"
+ placement="bottom"
+ :time="updatedAt"
+ />
+ <span
+ v-if="hasUpdatedBy"
+ >
+ by
+ <a
+ class="author_link"
+ :href="updatedByPath"
+ >
+ <span>{{updatedByName}}</span>
+ </a>
+ </span>
+ </small>
+</template>
+
diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
new file mode 100644
index 00000000000..a0ff08e9111
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
@@ -0,0 +1,23 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset class="checkbox">
+ <label for="issue-confidential">
+ <input
+ type="checkbox"
+ value="1"
+ id="issue-confidential"
+ v-model="formState.confidential" />
+ This issue is confidential and should only be visible to team members with at least Reporter access.
+ </label>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
new file mode 100644
index 00000000000..27b1b814f9a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -0,0 +1,55 @@
+<script>
+ /* global Flash */
+ import updateMixin from '../../mixins/update';
+ import markdownField from '../../../vue_shared/components/markdown/field.vue';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ markdownField,
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ };
+</script>
+
+<template>
+ <div class="common-note-form">
+ <label
+ class="sr-only"
+ for="issue-description">
+ Description
+ </label>
+ <markdown-field
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs">
+ <textarea
+ id="issue-description"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-quick-actionss="false"
+ aria-label="Description"
+ v-model="formState.description"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable">
+ </textarea>
+ </markdown-field>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
new file mode 100644
index 00000000000..1c40b286513
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -0,0 +1,111 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ issuableTemplatesJson() {
+ return JSON.stringify(this.issuableTemplates);
+ },
+ },
+ mounted() {
+ // Create the editor for the template
+ const editor = document.querySelector('.detail-page-description .note-textarea') || {};
+ editor.setValue = (val) => {
+ this.formState.description = val;
+ };
+ editor.getValue = () => this.formState.description;
+
+ this.issuableTemplate = new gl.IssuableTemplateSelectors({
+ $dropdowns: $(this.$refs.toggle),
+ editor,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown js-issuable-selector-wrap"
+ data-issuable-type="issue">
+ <button
+ class="dropdown-menu-toggle js-issuable-selector"
+ type="button"
+ ref="toggle"
+ data-field-name="issuable_template"
+ data-selected="null"
+ data-toggle="dropdown"
+ :data-namespace-path="projectNamespace"
+ :data-project-path="projectPath"
+ :data-data="issuableTemplatesJson">
+ <span class="dropdown-toggle-text">
+ Choose a template
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down">
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-select">
+ <div class="dropdown-title">
+ Choose a template
+ <button
+ class="dropdown-title-button dropdown-menu-close"
+ aria-label="Close"
+ type="button">
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon">
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ placeholder="Filter"
+ autocomplete="off" />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search">
+ </i>
+ <i
+ role="button"
+ aria-label="Clear templates search input"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a class="no-template">
+ No template
+ </a>
+ </li>
+ <li>
+ <a class="reset-template">
+ Reset template
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
new file mode 100644
index 00000000000..7bf2be8b28a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -0,0 +1,83 @@
+<script>
+ import tooltip from '../../../vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const $moveDropdown = $(this.$refs['move-dropdown']);
+
+ $moveDropdown.select2({
+ ajax: {
+ url: this.projectsAutocompleteUrl,
+ quietMillis: 125,
+ data(term, page, context) {
+ return {
+ search: term,
+ offset_id: context,
+ };
+ },
+ results(data) {
+ const more = data.length >= 50;
+ const context = data[data.length - 1] ? data[data.length - 1].id : null;
+
+ return {
+ results: data,
+ more,
+ context,
+ };
+ },
+ },
+ formatResult(project) {
+ return project.name_with_namespace;
+ },
+ formatSelection(project) {
+ return project.name_with_namespace;
+ },
+ })
+ .on('change', (e) => {
+ this.formState.move_to_project_id = parseInt(e.target.value, 10);
+ });
+ },
+ beforeDestroy() {
+ $(this.$refs['move-dropdown']).select2('destroy');
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ for="issuable-move"
+ class="sr-only">
+ Move
+ </label>
+ <div class="issuable-form-select-holder append-right-5">
+ <input
+ ref="move-dropdown"
+ type="hidden"
+ id="issuable-move"
+ data-placeholder="Move to a different project" />
+ </div>
+ <span
+ v-tooltip
+ data-placement="auto top"
+ title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.">
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true">
+ </i>
+ </span>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
new file mode 100644
index 00000000000..83af8e1e245
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -0,0 +1,32 @@
+<script>
+ import updateMixin from '../../mixins/update';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ class="sr-only"
+ for="issue-title">
+ Title
+ </label>
+ <input
+ id="issue-title"
+ class="form-control"
+ type="text"
+ placeholder="Issue title"
+ aria-label="Issue title"
+ v-model="formState.title"
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable" />
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
new file mode 100644
index 00000000000..76ec3dc9a5d
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -0,0 +1,104 @@
+<script>
+ import lockedWarning from './locked_warning.vue';
+ import titleField from './fields/title.vue';
+ import descriptionField from './fields/description.vue';
+ import editActions from './edit_actions.vue';
+ import descriptionTemplate from './fields/description_template.vue';
+ import projectMove from './fields/project_move.vue';
+ import confidentialCheckbox from './fields/confidential_checkbox.vue';
+
+ export default {
+ props: {
+ canMove: {
+ type: Boolean,
+ required: true,
+ },
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ projectMove,
+ confidentialCheckbox,
+ },
+ computed: {
+ hasIssuableTemplates() {
+ return this.issuableTemplates.length;
+ },
+ },
+ };
+</script>
+
+<template>
+ <form>
+ <locked-warning v-if="formState.lockedWarningVisible" />
+ <div class="row">
+ <div
+ class="col-sm-4 col-lg-3"
+ v-if="hasIssuableTemplates">
+ <description-template
+ :form-state="formState"
+ :issuable-templates="issuableTemplates"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace" />
+ </div>
+ <div
+ :class="{
+ 'col-sm-8 col-lg-9': hasIssuableTemplates,
+ 'col-xs-12': !hasIssuableTemplates,
+ }">
+ <title-field
+ :form-state="formState"
+ :issuable-templates="issuableTemplates" />
+ </div>
+ </div>
+ <description-field
+ :form-state="formState"
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs" />
+ <confidential-checkbox
+ :form-state="formState" />
+ <project-move
+ v-if="canMove"
+ :form-state="formState"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
+ <edit-actions
+ :form-state="formState"
+ :can-destroy="canDestroy" />
+ </form>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
new file mode 100644
index 00000000000..1c2789f154a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ computed: {
+ currentPath() {
+ return location.pathname;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="alert alert-danger">
+ Someone edited the issue at the same time you did. Please check out
+ <a
+ :href="currentPath"
+ target="_blank"
+ rel="nofollow">the issue</a>
+ and make sure your changes will not unintentionally remove theirs.
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
new file mode 100644
index 00000000000..a9dabd4cff1
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -0,0 +1,53 @@
+<script>
+ import animateMixin from '../mixins/animate';
+
+ export default {
+ mixins: [animateMixin],
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ props: {
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ titleHtml: {
+ type: String,
+ required: true,
+ },
+ titleText: {
+ type: String,
+ required: true,
+ },
+ },
+ watch: {
+ titleHtml() {
+ this.setPageTitle();
+ this.animateChange();
+ },
+ },
+ methods: {
+ setPageTitle() {
+ const currentPageTitleScope = this.titleEl.innerText.split('·');
+ currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
+ this.titleEl.textContent = currentPageTitleScope.join('·');
+ },
+ },
+ };
+</script>
+
+<template>
+ <h2
+ class="title"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="titleHtml"
+ >
+ </h2>
+</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 4d491e70d83..ad8cb6465e2 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,20 +1,53 @@
import Vue from 'vue';
-import IssueTitle from './issue_title.vue';
+import eventHub from './event_hub';
+import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
-(() => {
- const issueTitleData = document.querySelector('.issue-title-data').dataset;
- const { initialTitle, endpoint } = issueTitleData;
+document.addEventListener('DOMContentLoaded', () => {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+ const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
- const vm = new Vue({
- el: '.issue-title-entrypoint',
- render: createElement => createElement(IssueTitle, {
- props: {
- initialTitle,
- endpoint,
- },
- }),
+ $('.issuable-edit').on('click', (e) => {
+ e.preventDefault();
+
+ eventHub.$emit('open.form');
});
- return vm;
-})();
+ return new Vue({
+ el: document.getElementById('js-issuable-app'),
+ components: {
+ issuableApp,
+ },
+ data() {
+ return {
+ ...initialData,
+ };
+ },
+ render(createElement) {
+ return createElement('issuable-app', {
+ props: {
+ canUpdate: this.canUpdate,
+ canDestroy: this.canDestroy,
+ canMove: this.canMove,
+ endpoint: this.endpoint,
+ issuableRef: this.issuableRef,
+ initialTitleHtml: this.initialTitleHtml,
+ initialTitleText: this.initialTitleText,
+ initialDescriptionHtml: this.initialDescriptionHtml,
+ initialDescriptionText: this.initialDescriptionText,
+ issuableTemplates: this.issuableTemplates,
+ isConfidential: this.isConfidential,
+ markdownPreviewUrl: this.markdownPreviewUrl,
+ markdownDocs: this.markdownDocs,
+ projectPath: this.projectPath,
+ projectNamespace: this.projectNamespace,
+ projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ initialTaskStatus: this.initialTaskStatus,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue
deleted file mode 100644
index 00b0e56030a..00000000000
--- a/app/assets/javascripts/issue_show/issue_title.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import Visibility from 'visibilityjs';
-import Poll from './../lib/utils/poll';
-import Service from './services/index';
-
-export default {
- props: {
- initialTitle: { required: true, type: String },
- endpoint: { required: true, type: String },
- },
- data() {
- const resource = new Service(this.$http, this.endpoint);
-
- const poll = new Poll({
- resource,
- method: 'getTitle',
- successCallback: (res) => {
- this.renderResponse(res);
- },
- errorCallback: (err) => {
- if (process.env.NODE_ENV !== 'production') {
- // eslint-disable-next-line no-console
- console.error('ISSUE SHOW TITLE REALTIME ERROR', err);
- } else {
- throw new Error(err);
- }
- },
- });
-
- return {
- poll,
- timeoutId: null,
- title: this.initialTitle,
- };
- },
- methods: {
- renderResponse(res) {
- const body = JSON.parse(res.body);
- this.triggerAnimation(body);
- },
- triggerAnimation(body) {
- const { title } = body;
-
- /**
- * since opacity is changed, even if there is no diff for Vue to update
- * we must check the title even on a 304 to ensure no visual change
- */
- if (this.title === title) return;
-
- this.$el.style.opacity = 0;
-
- this.timeoutId = setTimeout(() => {
- this.title = title;
-
- this.$el.style.transition = 'opacity 0.2s ease';
- this.$el.style.opacity = 1;
-
- clearTimeout(this.timeoutId);
- }, 100);
- },
- },
- created() {
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- },
-};
-</script>
-
-<template>
- <h2 class="title" v-html="title"></h2>
-</template>
diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js
new file mode 100644
index 00000000000..4816393da1f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/animate.js
@@ -0,0 +1,13 @@
+export default {
+ methods: {
+ animateChange() {
+ this.preAnimation = true;
+ this.pulseAnimation = false;
+
+ setTimeout(() => {
+ this.preAnimation = false;
+ this.pulseAnimation = true;
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js
new file mode 100644
index 00000000000..72be65b426f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/update.js
@@ -0,0 +1,10 @@
+import eventHub from '../event_hub';
+
+export default {
+ methods: {
+ updateIssuable() {
+ this.formState.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index c4ab0b1e07a..6f0fd0b1768 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,10 +1,29 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
export default class Service {
- constructor(resource, endpoint) {
- this.resource = resource;
+ constructor(endpoint) {
this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
+ realtimeChanges: {
+ method: 'GET',
+ url: `${this.endpoint}/realtime_changes`,
+ },
+ });
+ }
+
+ getData() {
+ return this.resource.realtimeChanges();
+ }
+
+ deleteIssuable() {
+ return this.resource.delete();
}
- getTitle() {
- return this.resource.get(this.endpoint);
+ updateIssuable(data) {
+ return this.resource.update(data);
}
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
new file mode 100644
index 00000000000..0c8bd6f1cc3
--- /dev/null
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -0,0 +1,37 @@
+export default class Store {
+ constructor(initialState) {
+ this.state = initialState;
+ this.formState = {
+ title: '',
+ confidential: false,
+ description: '',
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ };
+ }
+
+ updateState(data) {
+ if (this.stateShouldUpdate(data)) {
+ this.formState.lockedWarningVisible = true;
+ }
+
+ this.state.titleHtml = data.title;
+ this.state.titleText = data.title_text;
+ this.state.descriptionHtml = data.description;
+ this.state.descriptionText = data.description_text;
+ this.state.taskStatus = data.task_status;
+ this.state.updatedAt = data.updated_at;
+ this.state.updatedByName = data.updated_by_name;
+ this.state.updatedByPath = data.updated_by_path;
+ }
+
+ stateShouldUpdate(data) {
+ return this.state.titleText !== data.title_text ||
+ this.state.descriptionText !== data.description_text;
+ }
+
+ setFormState(state) {
+ this.formState = Object.assign(this.formState, state);
+ }
+}
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index b2cfd3ef2a3..56cb536dcde 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
deleted file mode 100644
index e0ebd36a65c..00000000000
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
-/* global Issuable */
-/* global Flash */
-
-((global) => {
- class IssuableBulkActions {
- constructor({ container, form, issues, prefixId } = {}) {
- this.prefixId = prefixId || 'issue_';
- this.form = form || this.getElement('.bulk-update');
- this.$labelDropdown = this.form.find('.js-label-select');
- this.issues = issues || this.getElement('.issues-list .issue');
- this.form.data('bulkActions', this);
- this.willUpdateLabels = false;
- this.bindEvents();
- // Fixes bulk-assign not working when navigating through pages
- Issuable.initChecks();
- }
-
- bindEvents() {
- return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
- }
-
- onFormSubmit(e) {
- e.preventDefault();
- return this.submit();
- }
-
- submit() {
- const _this = this;
- const xhr = $.ajax({
- url: this.form.attr('action'),
- method: this.form.attr('method'),
- dataType: 'JSON',
- data: this.getFormDataAsObject()
- });
- xhr.done(() => window.location.reload());
- xhr.fail(() => new Flash("Issue update failed"));
- return xhr.always(this.onFormSubmitAlways.bind(this));
- }
-
- onFormSubmitAlways() {
- return this.form.find('[type="submit"]').enable();
- }
-
- getSelectedIssues() {
- return this.issues.has('.selected_issue:checked');
- }
-
- getLabelsFromSelection() {
- const labels = [];
- this.getSelectedIssues().map(function() {
- const labelsData = $(this).data('labels');
- if (labelsData) {
- return labelsData.map(function(labelId) {
- if (labels.indexOf(labelId) === -1) {
- return labels.push(labelId);
- }
- });
- }
- });
- return labels;
- }
-
- /**
- * Will return only labels that were marked previously and the user has unmarked
- * @return {Array} Label IDs
- */
-
- getUnmarkedIndeterminedLabels() {
- const result = [];
- const labelsToKeep = this.$labelDropdown.data('indeterminate');
-
- this.getLabelsFromSelection().forEach((id) => {
- if (labelsToKeep.indexOf(id) === -1) {
- result.push(id);
- }
- });
-
- return result;
- }
-
- /**
- * Simple form serialization, it will return just what we need
- * Returns key/value pairs from form data
- */
-
- getFormDataAsObject() {
- const formData = {
- update: {
- state_event: this.form.find('input[name="update[state_event]"]').val(),
- assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
- milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
- issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
- subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
- add_label_ids: [],
- remove_label_ids: []
- }
- };
- if (this.willUpdateLabels) {
- formData.update.add_label_ids = this.$labelDropdown.data('marked');
- formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
- }
- return formData;
- }
-
- setOriginalDropdownData() {
- const $labelSelect = $('.bulk-update .js-label-select');
- $labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', this.getOriginalMarkedIds());
- $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
- }
-
- // From issuable's initial bulk selection
- getOriginalCommonIds() {
- const labelIds = [];
-
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalMarkedIds() {
- const labelIds = [];
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalIndeterminateIds() {
- const uniqueIds = [];
- const labelIds = [];
- let issuableLabels = [];
-
- // Collect unique label IDs for all checked issues
- this.getElement('.selected_issue:checked').each((i, el) => {
- issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
- issuableLabels.forEach((labelId) => {
- // Store unique IDs
- if (uniqueIds.indexOf(labelId) === -1) {
- uniqueIds.push(labelId);
- }
- });
- // Store array of IDs per issuable
- labelIds.push(issuableLabels);
- });
- // Add uniqueIds to add it as argument for _.intersection
- labelIds.unshift(uniqueIds);
- // Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
- }
-
- getElement(selector) {
- this.scopeEl = this.scopeEl || $('.content');
- return this.scopeEl.find(selector);
- }
- }
-
- global.IssuableBulkActions = IssuableBulkActions;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
new file mode 100644
index 00000000000..3f6f40d47ba
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -0,0 +1,83 @@
+<script>
+ import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ name: 'jobHeaderSection',
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
+ data() {
+ return {
+ actions: this.getActions(),
+ };
+ },
+ computed: {
+ status() {
+ return this.job && this.job.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.job).length;
+ },
+ },
+ methods: {
+ getActions() {
+ const actions = [];
+
+ if (this.job.new_issue_path) {
+ actions.push({
+ label: 'New issue',
+ path: this.job.new_issue_path,
+ cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
+ type: 'link',
+ });
+ }
+
+ if (this.job.retry_path) {
+ actions.push({
+ label: 'Retry',
+ path: this.job.retry_path,
+ cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
+ type: 'ujs-link',
+ });
+ }
+
+ return actions;
+ },
+ },
+ watch: {
+ job() {
+ this.actions = this.getActions();
+ },
+ },
+ };
+</script>
+<template>
+ <div class="js-build-header build-header top-area">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ item-name="Job"
+ :item-id="job.id"
+ :time="job.created_at"
+ :user="job.user"
+ :actions="actions"
+ :hasSidebarButton="true"
+ />
+ <loading-icon
+ v-if="isLoading"
+ size="2"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
new file mode 100644
index 00000000000..ab2bcd728a8
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -0,0 +1,31 @@
+<script>
+ export default {
+ name: 'SidebarDetailRow',
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ hasTitle() {
+ return this.title.length > 0;
+ },
+ },
+ };
+</script>
+<template>
+ <p class="build-detail-row">
+ <span
+ v-if="hasTitle"
+ class="build-light-text">
+ {{title}}:
+ </span>
+ {{value}}
+ </p>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
new file mode 100644
index 00000000000..d0145fed396
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
@@ -0,0 +1,161 @@
+<script>
+ import detailRow from './sidebar_detail_row.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import timeagoMixin from '../../vue_shared/mixins/timeago';
+ import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
+
+ export default {
+ name: 'SidebarDetailsBlock',
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ mixins: [
+ timeagoMixin,
+ ],
+ components: {
+ detailRow,
+ loadingIcon,
+ },
+ computed: {
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.job).length > 0;
+ },
+ coverage() {
+ return `${this.job.coverage}%`;
+ },
+ duration() {
+ return timeIntervalInWords(this.job.duration);
+ },
+ queued() {
+ return timeIntervalInWords(this.job.queued);
+ },
+ runnerId() {
+ return `#${this.job.runner.id}`;
+ },
+ renderBlock() {
+ return this.job.merge_request ||
+ this.job.duration ||
+ this.job.finished_data ||
+ this.job.erased_at ||
+ this.job.queued ||
+ this.job.runner ||
+ this.job.coverage ||
+ this.job.tags.length ||
+ this.job.cancel_path;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <template v-if="shouldRenderContent">
+ <div
+ class="block retry-link"
+ v-if="job.retry_path || job.new_issue_path">
+ <a
+ v-if="job.new_issue_path"
+ class="js-new-issue btn btn-new btn-inverted"
+ :href="job.new_issue_path">
+ New issue
+ </a>
+ <a
+ v-if="job.retry_path"
+ class="js-retry-job btn btn-inverted-secondary"
+ :href="job.retry_path"
+ data-method="post"
+ rel="nofollow">
+ Retry
+ </a>
+ </div>
+ <div :class="{block : renderBlock }">
+ <p
+ class="build-detail-row js-job-mr"
+ v-if="job.merge_request">
+ <span
+ class="build-light-text">
+ Merge Request:
+ </span>
+ <a :href="job.merge_request.path">
+ !{{job.merge_request.iid}}
+ </a>
+ </p>
+
+ <detail-row
+ class="js-job-duration"
+ v-if="job.duration"
+ title="Duration"
+ :value="duration"
+ />
+ <detail-row
+ class="js-job-finished"
+ v-if="job.finished_at"
+ title="Finished"
+ :value="timeFormated(job.finished_at)"
+ />
+ <detail-row
+ class="js-job-erased"
+ v-if="job.erased_at"
+ title="Erased"
+ :value="timeFormated(job.erased_at)"
+ />
+ <detail-row
+ class="js-job-queued"
+ v-if="job.queued"
+ title="Queued"
+ :value="queued"
+ />
+ <detail-row
+ class="js-job-runner"
+ v-if="job.runner"
+ title="Runner"
+ :value="runnerId"
+ />
+ <detail-row
+ class="js-job-coverage"
+ v-if="job.coverage"
+ title="Coverage"
+ :value="coverage"
+ />
+ <p
+ class="build-detail-row js-job-tags"
+ v-if="job.tags.length">
+ <span
+ class="build-light-text">
+ Tags:
+ </span>
+ <span
+ v-for="tag in job.tags"
+ key="tag"
+ class="label label-primary">
+ {{tag}}
+ </span>
+ </p>
+
+ <div
+ v-if="job.cancel_path"
+ class="btn-group prepend-top-5"
+ role="group">
+ <a
+ class="js-cancel-job btn btn-sm btn-default"
+ :href="job.cancel_path"
+ data-method="post"
+ rel="nofollow">
+ Cancel
+ </a>
+ </div>
+ </div>
+ </template>
+ <loading-icon
+ class="prepend-top-10"
+ v-if="isLoading"
+ size="2"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js
new file mode 100644
index 00000000000..f92e669414a
--- /dev/null
+++ b/app/assets/javascripts/jobs/job_details_bundle.js
@@ -0,0 +1,60 @@
+/* global Flash */
+
+import Vue from 'vue';
+import JobMediator from './job_details_mediator';
+import jobHeader from './components/header.vue';
+import detailsBlock from './components/sidebar_details_block.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const dataset = document.getElementById('js-job-details-vue').dataset;
+ const mediator = new JobMediator({ endpoint: dataset.endpoint });
+
+ mediator.fetchJob();
+
+ // Header
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-build-header-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ jobHeader,
+ },
+ mounted() {
+ this.mediator.initBuildClass();
+ },
+ render(createElement) {
+ return createElement('job-header', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ job: this.mediator.store.state.job,
+ },
+ });
+ },
+ });
+
+ // Sidebar information block
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-details-block-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ detailsBlock,
+ },
+ render(createElement) {
+ return createElement('details-block', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ job: this.mediator.store.state.job,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/jobs/job_details_mediator.js b/app/assets/javascripts/jobs/job_details_mediator.js
new file mode 100644
index 00000000000..cc014b815c4
--- /dev/null
+++ b/app/assets/javascripts/jobs/job_details_mediator.js
@@ -0,0 +1,66 @@
+/* global Flash */
+/* global Build */
+
+import Visibility from 'visibilityjs';
+import Poll from '../lib/utils/poll';
+import JobStore from './stores/job_store';
+import JobService from './services/job_service';
+import '../build';
+
+export default class JobMediator {
+ constructor(options = {}) {
+ this.options = options;
+
+ this.store = new JobStore();
+ this.service = new JobService(options.endpoint);
+
+ this.state = {
+ isLoading: false,
+ };
+ }
+
+ initBuildClass() {
+ this.build = new Build();
+ }
+
+ fetchJob() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getJob',
+ successCallback: this.successCallback.bind(this),
+ errorCallback: this.errorCallback.bind(this),
+ });
+
+ if (!Visibility.hidden()) {
+ this.state.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.getJob();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ getJob() {
+ return this.service.getJob()
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
+
+ successCallback(response) {
+ this.state.isLoading = false;
+ return response.json().then(data => this.store.storeJob(data));
+ }
+
+ errorCallback() {
+ this.state.isLoading = false;
+
+ return new Flash('An error occurred while fetching the job.');
+ }
+}
diff --git a/app/assets/javascripts/jobs/services/job_service.js b/app/assets/javascripts/jobs/services/job_service.js
new file mode 100644
index 00000000000..eaf1c6e500a
--- /dev/null
+++ b/app/assets/javascripts/jobs/services/job_service.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class JobService {
+ constructor(endpoint) {
+ this.job = Vue.resource(endpoint);
+ }
+
+ getJob() {
+ return this.job.get();
+ }
+}
diff --git a/app/assets/javascripts/jobs/stores/job_store.js b/app/assets/javascripts/jobs/stores/job_store.js
new file mode 100644
index 00000000000..766194b8387
--- /dev/null
+++ b/app/assets/javascripts/jobs/stores/job_store.js
@@ -0,0 +1,11 @@
+export default class JobStore {
+ constructor() {
+ this.state = {
+ job: {},
+ };
+ }
+
+ storeJob(job = {}) {
+ this.state.job = job;
+ }
+}
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index 38b2eb9ff14..d8814802d9e 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -21,6 +21,7 @@
}
bindEvents() {
+ this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
}
@@ -36,6 +37,11 @@
_this.toggleEmptyState($label, $btn, action);
}
+ onButtonActionClick(e) {
+ e.stopPropagation();
+ $(e.currentTarget).tooltip('hide');
+ }
+
toggleEmptyState($label, $btn, action) {
this.emptyState.classList.toggle('hidden', !!this.prioritizedLabels[0].querySelector(':scope > li'));
}
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js
index 17a3fc1b1e4..03dd61b4263 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,11 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Labels = (function() {
function Labels() {
- this.setSuggestedColor = bind(this.setSuggestedColor, this);
- this.updateColorPreview = bind(this.updateColorPreview, this);
+ this.setSuggestedColor = this.setSuggestedColor.bind(this);
+ this.updateColorPreview = this.updateColorPreview.bind(this);
var form;
form = $('.label-form');
this.cleanBinding();
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 9a60f5464df..7d7f91227f9 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,6 +1,9 @@
/* eslint-disable no-useless-return, func-names, space-before-function-paren, wrap-iife, no-var, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, no-unused-vars, one-var-declaration-per-line, prefer-template, no-new, consistent-return, object-shorthand, comma-dangle, no-shadow, no-param-reassign, brace-style, vars-on-top, quotes, no-lonely-if, no-else-return, dot-notation, no-empty, no-return-assign, camelcase, prefer-spread */
/* global Issuable */
/* global ListLabel */
+import _ from 'underscore';
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import DropdownUtils from './filtered_search/dropdown_utils';
(function() {
this.LabelsSelect = (function() {
@@ -216,18 +219,7 @@
}
}
if (label.duplicate) {
- spacing = 100 / label.color.length;
- // Reduce the colors to 4
- label.color = label.color.filter(function(color, i) {
- return i < 4;
- });
- color = _.map(label.color, function(color, i) {
- var percentFirst, percentSecond;
- percentFirst = Math.floor(spacing * i);
- percentSecond = Math.floor(spacing * (i + 1));
- return color + " " + percentFirst + "%," + color + " " + percentSecond + "% ";
- }).join(',');
- color = "linear-gradient(" + color + ")";
+ color = gl.DropdownUtils.duplicateLabelColor(label.color);
}
else {
if (label.color != null) {
@@ -330,7 +322,10 @@
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(label, $el, e, isMarking) {
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const label = options.selectedObj;
+
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
$loading.fadeOut();
@@ -352,7 +347,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown();
- _this.setDropdownData($dropdown, isMarking, this.id(label));
+ _this.setDropdownData($dropdown, isMarking, label.id);
return;
}
@@ -427,20 +422,15 @@
if ($('.selected_issue:checked').length) {
return;
}
- return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
};
LabelsSelect.prototype.enableBulkLabelDropdown = function() {
- var issuableBulkActions;
- if ($('.selected_issue:checked').length) {
- issuableBulkActions = $('.bulk-update').data('bulkActions');
- return issuableBulkActions.willUpdateLabels = true;
- }
+ IssuableBulkUpdateActions.willUpdateLabels = true;
};
LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds;
- var issuableBulkActions = $('.bulk-update').data('bulkActions');
markedIds = $dropdown.data('marked') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
@@ -466,13 +456,13 @@
}
// If an indeterminate item is being unmarked
- if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
// If a marked item is being unmarked
// (a marked item could also be a label that is present in all selection)
- if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
}
diff --git a/app/assets/javascripts/landing.js b/app/assets/javascripts/landing.js
new file mode 100644
index 00000000000..8c0950ad5d5
--- /dev/null
+++ b/app/assets/javascripts/landing.js
@@ -0,0 +1,37 @@
+import Cookies from 'js-cookie';
+
+class Landing {
+ constructor(landingElement, dismissButton, cookieName) {
+ this.landingElement = landingElement;
+ this.cookieName = cookieName;
+ this.dismissButton = dismissButton;
+ this.eventWrapper = {};
+ }
+
+ toggle() {
+ const isDismissed = this.isDismissed();
+
+ this.landingElement.classList.toggle('hidden', isDismissed);
+ if (!isDismissed) this.addEvents();
+ }
+
+ addEvents() {
+ this.eventWrapper.dismissLanding = this.dismissLanding.bind(this);
+ this.dismissButton.addEventListener('click', this.eventWrapper.dismissLanding);
+ }
+
+ removeEvents() {
+ this.dismissButton.removeEventListener('click', this.eventWrapper.dismissLanding);
+ }
+
+ dismissLanding() {
+ this.landingElement.classList.add('hidden');
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
+ }
+
+ isDismissed() {
+ return Cookies.get(this.cookieName) === 'true';
+ }
+}
+
+export default Landing;
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index a5f99bcdd8f..5c1ba416a03 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,4 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */
+import _ from 'underscore';
+import Cookies from 'js-cookie';
+import NewNavSidebar from './new_sidebar';
+import initFlyOutNav from './fly_out_nav';
(function() {
var hideEndFade;
@@ -45,4 +49,20 @@
}
});
});
+
+ function applyScrollNavClass() {
+ const scrollOpacityHeight = 40;
+ $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1));
+ }
+
+ $(() => {
+ if (Cookies.get('new_nav') === 'true') {
+ const newNavSidebar = new NewNavSidebar();
+ newNavSidebar.bindEvents();
+
+ initFlyOutNav();
+ }
+
+ $(window).on('scroll', _.throttle(applyScrollNavClass, 100));
+ });
}).call(window);
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
new file mode 100644
index 00000000000..3d64b121fa7
--- /dev/null
+++ b/app/assets/javascripts/lazy_loader.js
@@ -0,0 +1,76 @@
+/* eslint-disable one-export, one-var, one-var-declaration-per-line */
+
+import _ from 'underscore';
+
+export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+const SCROLL_THRESHOLD = 300;
+
+export default class LazyLoader {
+ constructor(options = {}) {
+ this.lazyImages = [];
+ this.observerNode = options.observerNode || '#content-body';
+
+ const throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
+ const debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
+
+ window.addEventListener('scroll', throttledScrollCheck);
+ window.addEventListener('resize', debouncedElementsInView);
+
+ const scrollContainer = options.scrollContainer || window;
+ scrollContainer.addEventListener('load', () => this.loadCheck());
+ }
+ searchLazyImages() {
+ this.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
+ this.checkElementsInView();
+ }
+ startContentObserver() {
+ const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
+
+ if (contentNode) {
+ const observer = new MutationObserver(() => this.searchLazyImages());
+
+ observer.observe(contentNode, {
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+ loadCheck() {
+ this.searchLazyImages();
+ this.startContentObserver();
+ }
+ scrollCheck() {
+ requestAnimationFrame(() => this.checkElementsInView());
+ }
+ checkElementsInView() {
+ const scrollTop = pageYOffset;
+ const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD;
+ let imgBoundRect, imgTop, imgBound;
+
+ // Loading Images which are in the current viewport or close to them
+ this.lazyImages = this.lazyImages.filter((selectedImage) => {
+ if (selectedImage.getAttribute('data-src')) {
+ imgBoundRect = selectedImage.getBoundingClientRect();
+
+ imgTop = scrollTop + imgBoundRect.top;
+ imgBound = imgTop + imgBoundRect.height;
+
+ if (scrollTop < imgBound && visHeight > imgTop) {
+ LazyLoader.loadImage(selectedImage);
+ return false;
+ }
+
+ return true;
+ }
+ return false;
+ });
+ }
+ static loadImage(img) {
+ if (img.getAttribute('data-src')) {
+ img.setAttribute('src', img.getAttribute('data-src'));
+ img.removeAttribute('data-src');
+ img.classList.remove('lazy');
+ img.classList.add('js-lazy-loaded');
+ }
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
new file mode 100644
index 00000000000..1d18992af63
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -0,0 +1,47 @@
+function isPropertyAccessSafe(base, property) {
+ let safe;
+
+ try {
+ safe = !!base[property];
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isFunctionCallSafe(base, functionName, ...args) {
+ let safe = true;
+
+ try {
+ base[functionName](...args);
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isLocalStorageAccessSafe() {
+ let safe;
+
+ const TEST_KEY = 'isLocalStorageAccessSafe';
+ const TEST_VALUE = 'true';
+
+ safe = isPropertyAccessSafe(window, 'localStorage');
+ if (!safe) return safe;
+
+ safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE);
+
+ if (safe) window.localStorage.removeItem(TEST_KEY);
+
+ return safe;
+}
+
+const AccessorUtilities = {
+ isPropertyAccessSafe,
+ isFunctionCallSafe,
+ isLocalStorageAccessSafe,
+};
+
+export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
new file mode 100644
index 00000000000..629d8f44e18
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -0,0 +1,48 @@
+import Cache from './cache';
+
+class AjaxCache extends Cache {
+ constructor() {
+ super();
+ this.pendingRequests = { };
+ }
+
+ override(endpoint, data) {
+ this.internalStorage[endpoint] = data;
+ }
+
+ retrieve(endpoint, forceRetrieve) {
+ if (this.hasData(endpoint) && !forceRetrieve) {
+ return Promise.resolve(this.get(endpoint));
+ }
+
+ let pendingRequest = this.pendingRequests[endpoint];
+
+ if (!pendingRequest) {
+ pendingRequest = new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.ajax(endpoint) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${endpoint}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ })
+ .then((data) => {
+ this.internalStorage[endpoint] = data;
+ delete this.pendingRequests[endpoint];
+ })
+ .catch((error) => {
+ delete this.pendingRequests[endpoint];
+ throw error;
+ });
+
+ this.pendingRequests[endpoint] = pendingRequest;
+ }
+
+ return pendingRequest.then(() => this.get(endpoint));
+ }
+}
+
+export default new AjaxCache();
diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
index 2955bda1a36..0bf2ba6acc2 100644
--- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
+++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
@@ -31,82 +31,78 @@
*
* ### How to use
*
- * new window.gl.LinkedTabs({
+ * new LinkedTabs({
* action: "#{controller.action_name}",
* defaultAction: 'tab1',
* parentEl: '.tab-links'
* });
*/
-(() => {
- window.gl = window.gl || {};
+export default class LinkedTabs {
+ /**
+ * Binds the events and activates de default tab.
+ *
+ * @param {Object} options
+ */
+ constructor(options = {}) {
+ this.options = options;
- window.gl.LinkedTabs = class LinkedTabs {
- /**
- * Binds the events and activates de default tab.
- *
- * @param {Object} options
- */
- constructor(options) {
- this.options = options || {};
+ this.defaultAction = this.options.defaultAction;
+ this.action = this.options.action || this.defaultAction;
- this.defaultAction = this.options.defaultAction;
- this.action = this.options.action || this.defaultAction;
-
- if (this.action === 'show') {
- this.action = this.defaultAction;
- }
+ if (this.action === 'show') {
+ this.action = this.defaultAction;
+ }
- this.currentLocation = window.location;
+ this.currentLocation = window.location;
- const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
+ const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
- // since this is a custom event we need jQuery :(
- $(document)
- .off('shown.bs.tab', tabSelector)
- .on('shown.bs.tab', tabSelector, e => this.tabShown(e));
+ // since this is a custom event we need jQuery :(
+ $(document)
+ .off('shown.bs.tab', tabSelector)
+ .on('shown.bs.tab', tabSelector, e => this.tabShown(e));
- this.activateTab(this.action);
- }
+ this.activateTab(this.action);
+ }
- /**
- * Handles the `shown.bs.tab` event to set the currect url action.
- *
- * @param {type} evt
- * @return {Function}
- */
- tabShown(evt) {
- const source = evt.target.getAttribute('href');
+ /**
+ * Handles the `shown.bs.tab` event to set the currect url action.
+ *
+ * @param {type} evt
+ * @return {Function}
+ */
+ tabShown(evt) {
+ const source = evt.target.getAttribute('href');
- return this.setCurrentAction(source);
- }
+ return this.setCurrentAction(source);
+ }
- /**
- * Updates the URL with the path that matched the given action.
- *
- * @param {String} source
- * @return {String}
- */
- setCurrentAction(source) {
- const copySource = source;
+ /**
+ * Updates the URL with the path that matched the given action.
+ *
+ * @param {String} source
+ * @return {String}
+ */
+ setCurrentAction(source) {
+ const copySource = source;
- copySource.replace(/\/+$/, '');
+ copySource.replace(/\/+$/, '');
- const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
+ const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
- history.replaceState({
- url: newState,
- }, document.title, newState);
- return newState;
- }
+ history.replaceState({
+ url: newState,
+ }, document.title, newState);
+ return newState;
+ }
- /**
- * Given the current action activates the correct tab.
- * http://getbootstrap.com/javascript/#tab-show
- * Note: Will trigger `shown.bs.tab`
- */
- activateTab() {
- return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
- }
- };
-})();
+ /**
+ * Given the current action activates the correct tab.
+ * http://getbootstrap.com/javascript/#tab-show
+ * Note: Will trigger `shown.bs.tab`
+ */
+ activateTab() {
+ return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js
new file mode 100644
index 00000000000..3141f1eeafc
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/cache.js
@@ -0,0 +1,19 @@
+class Cache {
+ constructor() {
+ this.internalStorage = { };
+ }
+
+ get(key) {
+ return this.internalStorage[key];
+ }
+
+ hasData(key) {
+ return Object.prototype.hasOwnProperty.call(this.internalStorage, key);
+ }
+
+ remove(key) {
+ delete this.internalStorage[key];
+ }
+}
+
+export default Cache;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8058672eaa9..e916724b666 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -35,6 +35,14 @@
});
};
+ w.gl.utils.ajaxPost = function(url, data) {
+ return $.ajax({
+ type: 'POST',
+ url: url,
+ data: data,
+ });
+ };
+
w.gl.utils.extractLast = function(term) {
return this.split(term).pop();
};
@@ -78,18 +86,31 @@
// This is required to handle non-unicode characters in hash
hash = decodeURIComponent(hash);
+ const fixedTabs = document.querySelector('.js-tabs-affix');
+ const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck');
+ const fixedNav = document.querySelector('.navbar-gitlab');
+
+ var adjustment = 0;
+ if (fixedNav) adjustment -= fixedNav.offsetHeight;
+
// scroll to user-generated markdown anchor if we cannot find a match
if (document.getElementById(hash) === null) {
var target = document.getElementById('user-content-' + hash);
if (target && target.scrollIntoView) {
target.scrollIntoView(true);
+ window.scrollBy(0, adjustment);
}
} else {
// only adjust for fixedTabs when not targeting user-generated content
- var fixedTabs = document.querySelector('.js-tabs-affix');
if (fixedTabs) {
- window.scrollBy(0, -fixedTabs.offsetHeight);
+ adjustment -= fixedTabs.offsetHeight;
+ }
+
+ if (fixedDiffStats) {
+ adjustment -= fixedDiffStats.offsetHeight;
}
+
+ window.scrollBy(0, adjustment);
}
};
@@ -127,7 +148,10 @@
gl.utils.getUrlParamsArray = function () {
// We can trust that each param has one & since values containing & will be encoded
// Remove the first character of search as it is always ?
- return window.location.search.slice(1).split('&');
+ return window.location.search.slice(1).split('&').map((param) => {
+ const split = param.split('=');
+ return [decodeURI(split[0]), split[1]].join('=');
+ });
};
gl.utils.isMetaKey = function(e) {
@@ -156,8 +180,8 @@
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
*/
- w.gl.utils.getParameterByName = (name) => {
- const url = window.location.href;
+ w.gl.utils.getParameterByName = (name, parseUrl) => {
+ const url = parseUrl || window.location.href;
name = name.replace(/[[\]]/g, '\\$&');
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
@@ -187,10 +211,12 @@
const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length);
- const newText = textBefore + text + textAfter;
+
+ const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
+ const newText = textBefore + insertedText + textAfter;
target.value = newText;
- target.selectionStart = target.selectionEnd = selectionStart + text.length;
+ target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
$(target).trigger('input');
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 1e96c7ab5cd..7a72509d234 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,2 +1,3 @@
/* eslint-disable import/prefer-default-export */
export const BYTES_IN_KIB = 1024;
+export const HIDDEN_CLASS = 'hidden';
diff --git a/app/assets/javascripts/lib/utils/datefix.js b/app/assets/javascripts/lib/utils/datefix.js
new file mode 100644
index 00000000000..990dc3f6d1a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datefix.js
@@ -0,0 +1,8 @@
+const DateFix = {
+ dashedFix(val) {
+ const [y, m, d] = val.split('-');
+ return new Date(y, m - 1, d);
+ },
+};
+
+export default DateFix;
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 82dcbdc26c8..1d1763c3963 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,9 +1,15 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */
-/* global timeago */
-/* global dateFormat */
-window.timeago = require('timeago.js');
-window.dateFormat = require('vendor/date.format');
+import timeago from 'timeago.js';
+import dateFormat from 'vendor/date.format';
+
+import {
+ lang,
+ s__,
+} from '../../locale';
+
+window.timeago = timeago;
+window.dateFormat = dateFormat;
(function() {
(function(w) {
@@ -28,7 +34,7 @@ window.dateFormat = require('vendor/date.format');
w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago = true) {
$timeagoEls.each((i, el) => {
- el.setAttribute('title', gl.utils.formatDate(el.getAttribute('datetime')));
+ el.setAttribute('title', el.getAttribute('title'));
if (setTimeago) {
// Recreate with custom template
@@ -47,26 +53,45 @@ window.dateFormat = require('vendor/date.format');
var locale;
if (!timeagoInstance) {
+ const localeRemaining = function(number, index) {
+ return [
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')],
+ [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')],
+ [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')],
+ [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')],
+ [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')]
+ ][index];
+ };
locale = function(number, index) {
return [
- ['less than a minute ago', 'a while'],
- ['less than a minute ago', 'in %s seconds'],
- ['about a minute ago', 'in 1 minute'],
- ['%s minutes ago', 'in %s minutes'],
- ['about an hour ago', 'in 1 hour'],
- ['about %s hours ago', 'in %s hours'],
- ['a day ago', 'in 1 day'],
- ['%s days ago', 'in %s days'],
- ['a week ago', 'in 1 week'],
- ['%s weeks ago', 'in %s weeks'],
- ['a month ago', 'in 1 month'],
- ['%s months ago', 'in %s months'],
- ['a year ago', 'in 1 year'],
- ['%s years ago', 'in %s years']
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')],
+ [s__('Timeago|a day ago'), s__('Timeago|in 1 day')],
+ [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ [s__('Timeago|a week ago'), s__('Timeago|in 1 week')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ [s__('Timeago|a month ago'), s__('Timeago|in 1 month')],
+ [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ [s__('Timeago|a year ago'), s__('Timeago|in 1 year')],
+ [s__('Timeago|%s years ago'), s__('Timeago|in %s years')]
][index];
};
- timeago.register('gl_en', locale);
+ timeago.register(lang, locale);
+ timeago.register(`${lang}-remaining`, localeRemaining);
timeagoInstance = timeago();
}
@@ -78,41 +103,20 @@ window.dateFormat = require('vendor/date.format');
if (!time) {
return '';
}
- suffix || (suffix = 'remaining');
- expiredLabel || (expiredLabel = 'Past due');
- timefor = gl.utils.getTimeago().format(time).replace('in', '');
- if (timefor.indexOf('ago') > -1) {
+ if (new Date(time) < new Date()) {
+ expiredLabel || (expiredLabel = s__('Timeago|Past due'));
timefor = expiredLabel;
} else {
- timefor = timefor.trim() + ' ' + suffix;
+ timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim();
}
return timefor;
};
- w.gl.utils.cachedTimeagoElements = [];
w.gl.utils.renderTimeago = function($els) {
- if (!$els && !w.gl.utils.cachedTimeagoElements.length) {
- w.gl.utils.cachedTimeagoElements = [].slice.call(document.querySelectorAll('.js-timeago-render'));
- } else if ($els) {
- w.gl.utils.cachedTimeagoElements = w.gl.utils.cachedTimeagoElements.concat($els.toArray());
- }
-
- w.gl.utils.cachedTimeagoElements.forEach(gl.utils.updateTimeagoText);
- };
-
- w.gl.utils.updateTimeagoText = function(el) {
- const timeago = gl.utils.getTimeago();
- const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en');
+ const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
- if (el.textContent !== formattedDate) {
- el.textContent = formattedDate;
- }
- };
-
- w.gl.utils.initTimeagoTimeout = function() {
- gl.utils.renderTimeago();
-
- gl.utils.timeagoTimeout = setTimeout(gl.utils.initTimeagoTimeout, 1000);
+ // timeago.js sets timeouts internally for each timeago value to be updated in real time
+ gl.utils.getTimeago().render(timeagoEls, lang);
};
w.gl.utils.getDayDifference = function(a, b) {
@@ -124,3 +128,24 @@ window.dateFormat = require('vendor/date.format');
};
})(window);
}).call(window);
+
+/**
+ * Port of ruby helper time_interval_in_words.
+ *
+ * @param {Number} seconds
+ * @return {String}
+ */
+// eslint-disable-next-line import/prefer-default-export
+export function timeIntervalInWords(intervalInSeconds) {
+ const secondsInteger = parseInt(intervalInSeconds, 10);
+ const minutes = Math.floor(secondsInteger / 60);
+ const seconds = secondsInteger - (minutes * 60);
+ let text = '';
+
+ if (minutes >= 1) {
+ text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
+ } else {
+ text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
+ }
+ return text;
+}
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
new file mode 100644
index 00000000000..de65ea15a60
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -0,0 +1,7 @@
+/* eslint-disable import/prefer-default-export */
+
+export const addClassIfElementExists = (element, className) => {
+ if (element) {
+ element.classList.add(className);
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index bc109a69c20..625e53ee9de 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -2,9 +2,8 @@
* exports HTTP status codes
*/
-const statusCodes = {
+export default {
+ ABORTED: 0,
NO_CONTENT: 204,
OK: 200,
};
-
-module.exports = statusCodes;
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 66f39122a66..973d6119158 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,47 +1,48 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */
-(function() {
- (function(w) {
- var notificationGranted, notifyMe, notifyPermissions;
- notificationGranted = function(message, opts, onclick) {
- var notification;
- notification = new Notification(message, opts);
- setTimeout(function() {
- return notification.close();
- // Hide the notification after X amount of seconds
- }, 8000);
- if (onclick) {
- return notification.onclick = onclick;
- }
- };
- notifyPermissions = function() {
- if ('Notification' in window) {
- return Notification.requestPermission();
- }
- };
- notifyMe = function(message, body, icon, onclick) {
- var opts;
- opts = {
- body: body,
- icon: icon
- };
- // Let's check if the browser supports notifications
- if (!('Notification' in window)) {
+function notificationGranted(message, opts, onclick) {
+ var notification;
+ notification = new Notification(message, opts);
+ setTimeout(function() {
+ // Hide the notification after X amount of seconds
+ return notification.close();
+ }, 8000);
+
+ return notification.onclick = onclick || notification.close;
+}
- // do nothing
- } else if (Notification.permission === 'granted') {
- // If it's okay let's create a notification
+function notifyPermissions() {
+ if ('Notification' in window) {
+ return Notification.requestPermission();
+ }
+}
+
+function notifyMe(message, body, icon, onclick) {
+ var opts;
+ opts = {
+ body: body,
+ icon: icon
+ };
+ // Let's check if the browser supports notifications
+ if (!('Notification' in window)) {
+ // do nothing
+ } else if (Notification.permission === 'granted') {
+ // If it's okay let's create a notification
+ return notificationGranted(message, opts, onclick);
+ } else if (Notification.permission !== 'denied') {
+ return Notification.requestPermission(function(permission) {
+ // If the user accepts, let's create a notification
+ if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
- } else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
- // If the user accepts, let's create a notification
- if (permission === 'granted') {
- return notificationGranted(message, opts, onclick);
- }
- });
}
- };
- w.notify = notifyMe;
- return w.notifyPermissions = notifyPermissions;
- })(window);
-}).call(window);
+ });
+ }
+}
+
+const notify = {
+ notificationGranted,
+ notifyPermissions,
+ notifyMe,
+};
+
+export default notify;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f1b07408671..57394097944 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -42,3 +42,13 @@ export function formatRelevantDigits(number) {
export function bytesToKiB(number) {
return number / BYTES_IN_KIB;
}
+
+/**
+ * Utility function that calculates MiB of the given bytes.
+ *
+ * @param {Number} number bytes
+ * @return {Number} MiB
+ */
+export function bytesToMiB(number) {
+ return number / (BYTES_IN_KIB * BYTES_IN_KIB);
+}
diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js
index e31cc5fbabe..97666e13ebe 100644
--- a/app/assets/javascripts/lib/utils/poll.js
+++ b/app/assets/javascripts/lib/utils/poll.js
@@ -81,6 +81,9 @@ export default class Poll {
})
.catch((error) => {
notificationCallback(false);
+ if (error.status === httpStatusCodes.ABORTED) {
+ return;
+ }
errorCallback(error);
});
}
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js b/app/assets/javascripts/lib/utils/pretty_time.js
index ae397212e55..716aefbfcb7 100644
--- a/app/assets/javascripts/lib/utils/pretty_time.js
+++ b/app/assets/javascripts/lib/utils/pretty_time.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+
(() => {
/*
* TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js
new file mode 100644
index 00000000000..25ca98afbe7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/simple_poll.js
@@ -0,0 +1,15 @@
+export default (fn, interval = 2000, timeout = 60000) => {
+ const startTime = Date.now();
+
+ return new Promise((resolve, reject) => {
+ const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
+ const next = () => {
+ if (Date.now() - startTime < timeout) {
+ setTimeout(fn.bind(null, next, stop), interval);
+ } else {
+ reject(new Error('SIMPLE_POLL_TIMEOUT'));
+ }
+ };
+ fn(next, stop);
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js
new file mode 100644
index 00000000000..ff2b66046b4
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/sticky.js
@@ -0,0 +1,23 @@
+export const isSticky = (el, scrollY, stickyTop) => {
+ const top = el.offsetTop - scrollY;
+
+ if (top <= stickyTop) {
+ el.classList.add('is-stuck');
+ } else {
+ el.classList.remove('is-stuck');
+ }
+};
+
+export default (el) => {
+ if (!el) return;
+
+ const computedStyle = window.getComputedStyle(el);
+
+ if (!/sticky/.test(computedStyle.position)) return;
+
+ const stickyTop = parseInt(computedStyle.top, 10);
+
+ document.addEventListener('scroll', () => isSticky(el, window.scrollY, stickyTop), {
+ passive: true,
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index fecd531328d..021f936a4fa 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
-require('vendor/latinise');
+
+import 'vendor/latinise';
var base;
var w = window;
@@ -93,8 +94,8 @@ gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
- if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) {
- if (blockTag != null) {
+ if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
+ if (blockTag != null && blockTag !== '') {
insertText = this.blockTagText(text, textArea, blockTag, selected);
} else {
insertText = selectedSplit.map(function(val) {
@@ -169,7 +170,7 @@ gl.text.init = function(form) {
});
};
gl.text.removeListeners = function(form) {
- return $('.js-md', form).off();
+ return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js
index db62e0be324..be86f336bcd 100644
--- a/app/assets/javascripts/lib/utils/type_utility.js
+++ b/app/assets/javascripts/lib/utils/type_utility.js
@@ -1,15 +1,2 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */
-(function() {
- (function(w) {
- var base;
- if (w.gl == null) {
- w.gl = {};
- }
- if ((base = w.gl).utils == null) {
- base.utils = {};
- }
- return w.gl.utils.isObject = function(obj) {
- return (obj != null) && (obj.constructor === Object);
- };
- })(window);
-}).call(window);
+// eslint-disable-next-line import/prefer-default-export
+export const isObject = obj => obj && obj.constructor === Object;
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index b9d2fc25c39..3328ff9cc23 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) {
})()).join('&');
};
w.gl.utils.removeParams = (params) => {
- const url = new URL(window.location.href);
+ const url = document.createElement('a');
+ url.href = window.location.href;
params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param);
});
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
new file mode 100644
index 00000000000..88f8a622c00
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -0,0 +1,28 @@
+import Api from '../../api';
+import Cache from './cache';
+
+class UsersCache extends Cache {
+ retrieve(username) {
+ if (this.hasData(username)) {
+ return Promise.resolve(this.get(username));
+ }
+
+ return Api.users('', { username })
+ .then((users) => {
+ if (!users.length) {
+ throw new Error(`User "${username}" could not be found!`);
+ }
+
+ if (users.length > 1) {
+ throw new Error(`Expected username "${username}" to be unique!`);
+ }
+
+ const user = users[0];
+ this.internalStorage[username] = user;
+ return user;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+}
+
+export default new UsersCache();
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 1821ca18053..7400c22543f 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -4,8 +4,6 @@
//
// Handles single- and multi-line selection and highlight for blob views.
//
-require('vendor/jquery.scrollTo');
-
//
// ### Example Markup
//
@@ -31,8 +29,6 @@ require('vendor/jquery.scrollTo');
// </div>
//
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.LineHighlighter = (function() {
// CSS class applied to highlighted lines
LineHighlighter.prototype.highlightClass = 'hll';
@@ -41,20 +37,31 @@ require('vendor/jquery.scrollTo');
LineHighlighter.prototype._hash = '';
function LineHighlighter(hash) {
- var range;
if (hash == null) {
// Initialize a LineHighlighter object
//
// hash - String URL hash for dependency injection in tests
hash = location.hash;
}
- this.setHash = bind(this.setHash, this);
- this.highlightLine = bind(this.highlightLine, this);
- this.clickHandler = bind(this.clickHandler, this);
+ this.setHash = this.setHash.bind(this);
+ this.highlightLine = this.highlightLine.bind(this);
+ this.clickHandler = this.clickHandler.bind(this);
+ this.highlightHash = this.highlightHash.bind(this);
this._hash = hash;
this.bindEvents();
- if (hash !== '') {
- range = this.hashToRange(hash);
+ this.highlightHash();
+ }
+
+ LineHighlighter.prototype.bindEvents = function() {
+ const $fileHolder = $('.file-holder');
+ $fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
+ $fileHolder.on('highlight:line', this.highlightHash);
+ };
+
+ LineHighlighter.prototype.highlightHash = function() {
+ var range;
+ if (this._hash !== '') {
+ range = this.hashToRange(this._hash);
if (range[0]) {
this.highlightRange(range);
$.scrollTo("#L" + range[0], {
@@ -64,10 +71,6 @@ require('vendor/jquery.scrollTo');
});
}
}
- }
-
- LineHighlighter.prototype.bindEvents = function() {
- $('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler);
};
LineHighlighter.prototype.clickHandler = function(event) {
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
new file mode 100644
index 00000000000..7ba676d6d20
--- /dev/null
+++ b/app/assets/javascripts/locale/index.js
@@ -0,0 +1,70 @@
+import Jed from 'jed';
+
+/**
+ This is required to require all the translation folders in the current directory
+ this saves us having to do this manually & keep up to date with new languages
+**/
+function requireAll(requireContext) { return requireContext.keys().map(requireContext); }
+
+const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/));
+const locales = allLocales.reduce((d, obj) => {
+ const data = d;
+ const localeKey = Object.keys(obj)[0];
+
+ data[localeKey] = obj[localeKey];
+
+ return data;
+}, {});
+
+let lang = document.querySelector('html').getAttribute('lang') || 'en';
+lang = lang.replace(/-/g, '_');
+
+const locale = new Jed(locales[lang]);
+
+/**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+**/
+const gettext = locale.gettext.bind(locale);
+
+/**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+**/
+const ngettext = (text, pluralText, count) => {
+ const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|');
+
+ return translated[translated.length - 1];
+};
+
+/**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+**/
+const pgettext = (keyOrContext, key) => {
+ const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext;
+ const translated = gettext(normalizedKey).split('|');
+
+ return translated[translated.length - 1];
+};
+
+export { lang };
+export { gettext as __ };
+export { ngettext as n__ };
+export { pgettext as s__ };
+export default locale;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index be3c2c9fbb1..6d7c7e3c930 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,5 +1,4 @@
/* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len, no-multi-spaces, import/newline-after-import, import/first */
-/* global bp */
/* global Flash */
/* global ConfirmDangerModal */
/* global Aside */
@@ -7,7 +6,6 @@
import jQuery from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
-import Pikaday from 'pikaday';
import Dropzone from 'dropzone';
import Sortable from 'vendor/Sortable';
@@ -16,14 +14,10 @@ import 'mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
import 'vendor/fuzzaldrin-plus';
-// extensions
-import './extensions/array';
-
// expose common libraries as globals (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
window._ = _;
-window.Pikaday = Pikaday;
window.Dropzone = Dropzone;
window.Sortable = Sortable;
@@ -36,13 +30,6 @@ import './shortcuts_find_file';
import './shortcuts_issuable';
import './shortcuts_network';
-// behaviors
-import './behaviors/';
-
-// blob
-import './blob/create_branch_dropdown';
-import './blob/target_branch_dropdown';
-
// templates
import './templates/issuable_template_selector';
import './templates/issuable_template_selectors';
@@ -56,12 +43,13 @@ import './lib/utils/animate';
import './lib/utils/bootstrap_linked_tabs';
import './lib/utils/common_utils';
import './lib/utils/datetime_utility';
-import './lib/utils/notify';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
-import './lib/utils/type_utility';
import './lib/utils/url_utility';
+// behaviors
+import './behaviors/';
+
// u2f
import './u2f/authenticate';
import './u2f/error';
@@ -76,8 +64,8 @@ import './ajax_loading_spinner';
import './api';
import './aside';
import './autosave';
-import AwardsHandler from './awards_handler';
-import './breakpoints';
+import loadAwardsHandler from './awards_handler';
+import bp from './breakpoints';
import './broadcast_message';
import './build';
import './build_artifacts';
@@ -92,12 +80,10 @@ import './copy_as_gfm';
import './copy_to_clipboard';
import './create_label';
import './diff';
-import './dispatcher';
import './dropzone_input';
import './due_date_select';
import './files_comment_button';
import './flash';
-import './gfm_auto_complete';
import './gl_dropdown';
import './gl_field_error';
import './gl_field_errors';
@@ -107,24 +93,22 @@ import './group_label_subscription';
import './groups_select';
import './header';
import './importer_status';
-import './issuable';
+import './issuable_index';
import './issuable_context';
import './issuable_form';
import './issue';
import './issue_status_select';
-import './issues_bulk_assignment';
import './label_manager';
import './labels';
import './labels_select';
import './layout_nav';
+import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
import './member_expiration_date';
import './members';
import './merge_request';
import './merge_request_tabs';
-import './merge_request_widget';
-import './merged_buttons';
import './milestone';
import './milestone_select';
import './mini_pipeline_graph_dropdown';
@@ -148,41 +132,31 @@ import './project_select';
import './project_show';
import './project_variables';
import './projects_list';
-import './render_gfm';
+import './syntax_highlight';
import './render_math';
+import './render_gfm';
import './right_sidebar';
import './search';
import './search_autocomplete';
-import './signin_tabs_memoizer';
-import './single_file_diff';
import './smart_interval';
-import './snippets_list';
import './star';
-import './subbable_resource';
import './subscription';
import './subscription_select';
-import './syntax_highlight';
-import './task_list';
-import './todos';
-import './tree';
-import './usage_ping';
-import './user';
-import './user_tabs';
-import './username_validator';
-import './users_select';
-import './version_check_image';
-import './visibility_select';
-import './wikis';
-import './zen_mode';
-// eslint-disable-next-line global-require
+import './dispatcher';
+
+// eslint-disable-next-line global-require, import/no-commonjs
if (process.env.NODE_ENV !== 'production') require('./test_utils/');
+Dropzone.autoDiscover = false;
+
document.addEventListener('beforeunload', function () {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
$('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy');
+ // Close any open popover
+ $('[data-toggle="popover"]').popover('destroy');
});
window.addEventListener('hashchange', gl.utils.handleLocationHash);
@@ -191,6 +165,11 @@ window.addEventListener('load', function onLoad() {
gl.utils.handleLocationHash();
}, false);
+gl.lazyLoader = new LazyLoader({
+ scrollContainer: window,
+ observerNode: '#content-body'
+});
+
$(function () {
var $body = $('body');
var $document = $(document);
@@ -266,6 +245,11 @@ $(function () {
return $(el).data('placement') || 'bottom';
}
});
+ // Initialize popovers
+ $body.popover({
+ selector: '[data-toggle="popover"]',
+ trigger: 'focus'
+ });
$('.trigger-submit').on('change', function () {
return $(this).parents('form').submit();
// Form submitter
@@ -309,12 +293,7 @@ $(function () {
return $container.remove();
// Commit show suppressed diff
});
- $('.navbar-toggle').on('click', function () {
- $('.header-content .title').toggle();
- $('.header-content .header-logo').toggle();
- $('.header-content .navbar-collapse').toggle();
- return $('.navbar-toggle').toggleClass('active');
- });
+ $('.navbar-toggle').on('click', () => $('.header-content').toggleClass('menu-expanded'));
// Show/hide comments on diff
$body.on('click', '.js-toggle-diff-comments', function (e) {
var $this = $(this);
@@ -365,10 +344,20 @@ $(function () {
$window.off('resize.app').on('resize.app', function () {
return fitSidebarForSize();
});
- gl.awardsHandler = new AwardsHandler();
+ loadAwardsHandler();
new Aside();
- gl.utils.initTimeagoTimeout();
+ gl.utils.renderTimeago();
$(document).trigger('init.scrolling-tabs');
+
+ $('form.filter-form').on('submit', function (event) {
+ const link = document.createElement('a');
+ link.href = this.action;
+
+ const action = `${this.action}${link.search === '' ? '?' : '&'}`;
+
+ event.preventDefault();
+ gl.utils.visitUrl(`${action}${$(this).serialize()}`);
+ });
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index e034729bd39..cc9016e74da 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -1,5 +1,7 @@
-/* global Pikaday */
/* global dateFormat */
+
+import Pikaday from 'pikaday';
+
(() => {
// Add datepickers to all `js-access-expiration-date` elements. If those elements are
// children of an element with the `clearable-input` class, and have a sibling
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index e3f367a11eb..8291b8c4a70 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -31,8 +31,8 @@
toggleLabel(selected, $el) {
return $el.text();
},
- clicked: (selected, $link) => {
- this.formSubmit(null, $link);
+ clicked: (options) => {
+ this.formSubmit(null, options.$el);
},
});
});
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index c4e379a4a0b..8be7314ded8 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -175,7 +175,7 @@ import Cookies from 'js-cookie';
getConflictsCountText() {
const count = this.getConflictsCount();
- const text = count ? 'conflicts' : 'conflict';
+ const text = count > 1 ? 'conflicts' : 'conflict';
return `${count} ${text}`;
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 15992460146..d74cf5328ad 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -2,14 +2,14 @@
/* global Flash */
import Vue from 'vue';
-
-require('./merge_conflict_store');
-require('./merge_conflict_service');
-require('./mixins/line_conflict_utils');
-require('./mixins/line_conflict_actions');
-require('./components/diff_file_editor');
-require('./components/inline_conflict_lines');
-require('./components/parallel_conflict_lines');
+import initIssuableSidebar from '../init_issuable_sidebar';
+import './merge_conflict_store';
+import './merge_conflict_service';
+import './mixins/line_conflict_utils';
+import './mixins/line_conflict_actions';
+import './components/diff_file_editor';
+import './components/inline_conflict_lines';
+import './components/parallel_conflict_lines';
$(() => {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
@@ -20,6 +20,8 @@ $(() => {
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath
});
+ initIssuableSidebar();
+
gl.MergeConflictsResolverApp = new Vue({
el: '#conflicts',
data: mergeConflictsStore.state,
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 5e01aacf2ba..0db2abe507d 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,13 +1,12 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */
/* global MergeRequestTabs */
-require('vendor/jquery.waitforimages');
-require('./task_list');
-require('./merge_request_tabs');
+import 'vendor/jquery.waitforimages';
+import TaskList from './task_list';
+import './merge_request_tabs';
+import IssuablesHelper from './helpers/issuables_helper';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.MergeRequest = (function() {
function MergeRequest(opts) {
// Initialize MergeRequest behavior
@@ -16,18 +15,21 @@ require('./merge_request_tabs');
// action - String, current controller action
//
this.opts = opts != null ? opts : {};
- this.submitNoteForm = bind(this.submitNoteForm, this);
+ this.submitNoteForm = this.submitNoteForm.bind(this);
this.$el = $('.merge-request');
this.$('.show-all-commits').on('click', (function(_this) {
return function() {
return _this.showAllCommits();
};
})(this));
+
this.initTabs();
this.initMRBtnListeners();
this.initCommitMessageListeners();
+ this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
+
if ($("a.btn-close").length) {
- this.taskList = new gl.TaskList({
+ this.taskList = new TaskList({
dataType: 'merge_request',
fieldName: 'description',
selector: '.detail-page-description',
@@ -66,11 +68,15 @@ require('./merge_request_tabs');
if (shouldSubmit && $this.data('submitted')) {
return;
}
+
+ if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
+
if (shouldSubmit) {
if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
e.preventDefault();
e.stopImmediatePropagation();
- return _this.submitNoteForm($this.closest('form'), $this);
+
+ _this.submitNoteForm($this.closest('form'), $this);
}
}
});
@@ -106,6 +112,21 @@ require('./merge_request_tabs');
});
};
+ MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
+ $('.detail-page-header .status-box')
+ .removeClass(classToRemove)
+ .addClass(classToAdd)
+ .find('span')
+ .text(newStatusText);
+ };
+
+ MergeRequest.prototype.decreaseCounter = function(by = 1) {
+ const $el = $('.nav-links .js-merge-counter');
+ const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
+
+ $el.text(gl.text.addDelimiter(count));
+ };
+
return MergeRequest;
})();
}).call(window);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 93c30c54a8e..5a9b3d19f84 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,11 +1,12 @@
/* eslint-disable no-new, class-methods-use-this */
-/* global Breakpoints */
/* global Flash */
+/* global notes */
import Cookies from 'js-cookie';
-import './breakpoints';
import './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
+import initChangesDropdown from './init_changes_dropdown';
+import bp from './breakpoints';
/* eslint-disable max-len */
// MergeRequestTabs
@@ -132,7 +133,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
this.loadDiff($target.attr('href'));
- if (Breakpoints.get().getBreakpointSize() !== 'lg') {
+ if (bp.getBreakpointSize() !== 'lg') {
this.shrinkView();
}
if (this.diffViewType() === 'parallel') {
@@ -143,7 +144,9 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
this.resetViewContainer();
this.mountPipelinesView();
} else {
- this.expandView();
+ if (bp.getBreakpointSize() !== 'xs') {
+ this.expandView();
+ }
this.resetViewContainer();
this.destroyPipelinesView();
}
@@ -154,7 +157,10 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
scrollToElement(container) {
if (location.hash) {
- const offset = -$('.js-tabs-affix').outerHeight();
+ const offset = 0 - (
+ $('.navbar-gitlab').outerHeight() +
+ $('.js-tabs-affix').outerHeight()
+ );
const $el = $(`${container} ${location.hash}:not(.match)`);
if ($el.length) {
$.scrollTo($el[0], { offset });
@@ -164,9 +170,8 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
// Activate a tab based on the current action
activateTab(action) {
- const activate = action === 'show' ? 'notes' : action;
// important note: the .tab('show') method triggers 'shown.bs.tab' event itself
- $(`.merge-request-tabs a[data-action='${activate}']`).tab('show');
+ $(`.merge-request-tabs a[data-action='${action}']`).tab('show');
}
// Replaces the current Merge Request-specific action in the URL with a new one
@@ -181,7 +186,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
- // setCurrentAction('notes')
+ // setCurrentAction('show')
// location.pathname # => "/namespace/project/merge_requests/1"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
@@ -190,13 +195,13 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
//
// Returns the new URL String
setCurrentAction(action) {
- this.currentAction = action === 'show' ? 'notes' : action;
+ this.currentAction = action;
- // Remove a trailing '/commits' '/diffs' '/pipelines' '/new' '/new/diffs'
- let newState = location.pathname.replace(/\/(commits|diffs|pipelines|new|new\/diffs)(\.html)?\/?$/, '');
+ // Remove a trailing '/commits' '/diffs' '/pipelines'
+ let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, '');
// Append the new action if we're on a tab other than 'notes'
- if (this.currentAction !== 'notes') {
+ if (this.currentAction !== 'show' && this.currentAction !== 'new') {
newState += `/${this.currentAction}`;
}
@@ -232,11 +237,18 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
}
mountPipelinesView() {
- this.commitPipelinesTable = new gl.CommitPipelinesTable().$mount();
+ const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
+ const CommitPipelinesTable = gl.CommitPipelinesTable;
+ this.commitPipelinesTable = new CommitPipelinesTable({
+ propsData: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
+ },
+ }).$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')
- .appendChild(this.commitPipelinesTable.$el);
+ pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
}
loadDiff(source) {
@@ -251,7 +263,10 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
this.ajaxGet({
url: `${urlPathname}.json${location.search}`,
success: (data) => {
- $('#diffs').html(data.html);
+ const $container = $('#diffs');
+ $container.html(data.html);
+
+ initChangesDropdown();
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
@@ -278,6 +293,25 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
})
.init();
});
+
+ // Scroll any linked note into view
+ // Similar to `toggler_behavior` in the discussion tab
+ const hash = window.gl.utils.getLocationHash();
+ const anchor = hash && $container.find(`.note[id="${hash}"]`);
+ if (anchor && anchor.length > 0) {
+ const notesContent = anchor.closest('.notes_content');
+ const lineType = notesContent.hasClass('new') ? 'new' : 'old';
+ notes.toggleDiffNote({
+ target: anchor,
+ lineType,
+ forceShow: true,
+ });
+ anchor[0].scrollIntoView();
+ window.gl.utils.handleLocationHash();
+ // We have multiple elements on the page with `#note_xxx`
+ // (discussion and diff tabs) and `:target` only applies to the first
+ anchor.addClass('target');
+ }
},
});
}
@@ -353,10 +387,18 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
initAffix() {
const $tabs = $('.js-tabs-affix');
+ const $fixedNav = $('.navbar-gitlab');
// Screen space on small screens is usually very sparse
// So we dont affix the tabs on these
- if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return;
+ if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return;
+
+ /**
+ If the browser does not support position sticky, it returns the position as static.
+ If the browser does support sticky, then we allow the browser to handle it, if not
+ then we default back to Bootstraps affix
+ **/
+ if ($tabs.css('position') !== 'static') return;
const $diffTabs = $('#diff-notes-app');
@@ -364,7 +406,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
.affix({
offset: {
top: () => (
- $diffTabs.offset().top - $tabs.height()
+ $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
),
},
})
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
deleted file mode 100644
index 42ecf0d6cb2..00000000000
--- a/app/assets/javascripts/merge_request_widget.js
+++ /dev/null
@@ -1,305 +0,0 @@
-/* eslint-disable max-len, no-var, func-names, space-before-function-paren, vars-on-top, comma-dangle, no-return-assign, consistent-return, no-param-reassign, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, prefer-arrow-callback, no-unused-vars, no-underscore-dangle, no-shadow, no-mixed-operators, camelcase, default-case, wrap-iife */
-/* global notify */
-/* global notifyPermissions */
-/* global merge_request_widget */
-
-import './smart_interval';
-import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
-
-((global) => {
- var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
-
- const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>">
- <div class="ci_widget ci-success">
- <%= ci_success_icon %>
- <span>
- Deployed to
- <a href="<%- url %>" target="_blank" rel="noopener noreferrer" class="environment">
- <%- name %>
- </a>
- <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>">
- <%- deployed_at %>
- </span>
- <a class="js-environment-link" href="<%- external_url %>" target="_blank" rel="noopener noreferrer">
- <i class="fa fa-external-link"></i>
- View on <%- external_url_formatted %>
- </a>
- </span>
- <span class="stop-env-container js-stop-env-link">
- <a href="<%- stop_url %>" class="close-evn-link" data-method="post" rel="nofollow" data-confirm="Are you sure you want to stop this environment?">
- <i class="fa fa-stop-circle-o"/>
- Stop environment
- </a>
- </span>
- </div>
- </div>`;
-
- global.MergeRequestWidget = (function() {
- function MergeRequestWidget(opts) {
- // Initialize MergeRequestWidget behavior
- //
- // check_enable - Boolean, whether to check automerge status
- // merge_check_url - String, URL to use to check automerge status
- // ci_status_url - String, URL to use to check CI status
- // pipeline_status_url - String, URL to use to get CI status for Favicon
- //
- this.opts = opts;
- this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`;
- this.$widgetBody = $('.mr-widget-body');
- $('#modal_merge_info').modal({
- show: false
- });
- this.clearEventListeners();
- this.addEventListeners();
- this.getCIStatus(false);
- this.retrieveSuccessIcon();
-
- this.initMiniPipelineGraph();
-
- this.ciStatusInterval = new global.SmartInterval({
- callback: this.getCIStatus.bind(this, true),
- startingInterval: 10000,
- maxInterval: 30000,
- hiddenInterval: 120000,
- incrementByFactorOf: 5000,
- });
- this.ciEnvironmentStatusInterval = new global.SmartInterval({
- callback: this.getCIEnvironmentsStatus.bind(this),
- startingInterval: 30000,
- maxInterval: 120000,
- hiddenInterval: 240000,
- incrementByFactorOf: 15000,
- immediateExecution: true,
- });
-
- notifyPermissions();
- }
-
- MergeRequestWidget.prototype.clearEventListeners = function() {
- return $(document).off('DOMContentLoaded');
- };
-
- MergeRequestWidget.prototype.addEventListeners = function() {
- var allowedPages;
- allowedPages = ['show', 'commits', 'pipelines', 'changes'];
- $(document).on('DOMContentLoaded', (function(_this) {
- return function() {
- var page;
- page = $('body').data('page').split(':').last();
- if (allowedPages.indexOf(page) === -1) {
- return _this.clearEventListeners();
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.retrieveSuccessIcon = function() {
- const $ciSuccessIcon = $('.js-success-icon');
- this.$ciSuccessIcon = $ciSuccessIcon.html();
- $ciSuccessIcon.remove();
- };
-
- MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) {
- if (deleteSourceBranch == null) {
- deleteSourceBranch = false;
- }
- return $.ajax({
- type: 'GET',
- url: $('.merge-request').data('url'),
- success: (function(_this) {
- return function(data) {
- var callback, urlSuffix;
- if (data.state === "merged") {
- urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
- return window.location.href = window.location.pathname + urlSuffix;
- } else if (data.merge_error) {
- return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
- } else {
- callback = function() {
- return merge_request_widget.mergeInProgress(deleteSourceBranch);
- };
- return setTimeout(callback, 2000);
- }
- };
- })(this),
- dataType: 'json'
- });
- };
-
- MergeRequestWidget.prototype.cancelPolling = function () {
- this.ciStatusInterval.cancel();
- this.ciEnvironmentStatusInterval.cancel();
- };
-
- MergeRequestWidget.prototype.getMergeStatus = function() {
- return $.get(this.opts.merge_check_url, (data) => {
- var $html = $(data);
- this.updateMergeButton(this.status, this.hasCi, $html);
- $('.mr-widget-body').replaceWith($html.find('.mr-widget-body'));
- $('.mr-widget-footer').replaceWith($html.find('.mr-widget-footer'));
- });
- };
-
- MergeRequestWidget.prototype.ciLabelForStatus = function(status) {
- switch (status) {
- case 'success':
- return 'passed';
- case 'success_with_warnings':
- return 'passed with warnings';
- default:
- return status;
- }
- };
-
- MergeRequestWidget.prototype.getCIStatus = function(showNotification) {
- var _this;
- _this = this;
- $('.ci-widget-fetching').show();
- return $.getJSON(this.opts.ci_status_url, (function(_this) {
- return function(data) {
- var message, status, title, callback;
- _this.status = data.status;
- _this.hasCi = data.has_ci;
- _this.updateMergeButton(_this.status, _this.hasCi);
- gl.utils.setCiStatusFavicon(_this.opts.pipeline_status_url);
- if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
- if (data.status !== _this.opts.ci_status ||
- data.sha !== _this.opts.ci_sha ||
- data.pipeline !== _this.opts.ci_pipeline) {
- _this.opts.ci_status = data.status;
- _this.showCIStatus(data.status);
- if (data.coverage) {
- _this.showCICoverage(data.coverage);
- }
- if (data.pipeline) {
- _this.opts.ci_pipeline = data.pipeline;
- _this.updatePipelineUrls(data.pipeline);
- }
- if (data.sha) {
- _this.opts.ci_sha = data.sha;
- _this.updateCommitUrls(data.sha);
- }
- if (data.status === "success" || data.status === "failed") {
- callback = function() {
- return _this.getMergeStatus();
- };
- return setTimeout(callback, 2000);
- }
- if (showNotification && data.status) {
- status = _this.ciLabelForStatus(data.status);
- if (status === "preparing") {
- title = _this.opts.ci_title.preparing;
- status = status.charAt(0).toUpperCase() + status.slice(1);
- message = _this.opts.ci_message.preparing.replace('{{status}}', status);
- } else {
- title = _this.opts.ci_title.normal;
- message = _this.opts.ci_message.normal.replace('{{status}}', status);
- }
- title = title.replace('{{status}}', status);
- message = message.replace('{{sha}}', data.sha);
- message = message.replace('{{title}}', data.title);
- notify(title, message, _this.opts.gitlab_icon, function() {
- this.close();
- });
- }
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() {
- $.getJSON(this.opts.ci_environments_status_url, (environments) => {
- if (environments && environments.length) this.renderEnvironments(environments);
- });
- };
-
- MergeRequestWidget.prototype.renderEnvironments = function(environments) {
- for (let i = 0; i < environments.length; i += 1) {
- const environment = environments[i];
- if ($(`.mr-state-widget #${environment.id}`).length) return;
- const $template = $(DEPLOYMENT_TEMPLATE);
- if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove();
-
- if (!environment.stop_url) {
- $('.js-stop-env-link', $template).remove();
- }
-
- if (environment.deployed_at && environment.deployed_at_formatted) {
- environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
- } else {
- $('.js-environment-timeago', $template).remove();
- environment.name += '.';
- }
- environment.ci_success_icon = this.$ciSuccessIcon;
- const templateString = _.unescape($template[0].outerHTML);
- const template = _.template(templateString)(environment);
- this.$widgetBody.before(template);
- }
- };
-
- MergeRequestWidget.prototype.showCIStatus = function(state) {
- var allowed_states;
- if (state == null) {
- return;
- }
- $('.ci_widget').hide();
- $('.ci_widget.ci-' + state).show();
-
- this.initMiniPipelineGraph();
- };
-
- MergeRequestWidget.prototype.showCICoverage = function(coverage) {
- var text = `Coverage ${coverage}%`;
- return $('.ci_widget:visible .ci-coverage').text(text);
- };
-
- MergeRequestWidget.prototype.updateMergeButton = function(state, hasCi, $html) {
- const allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"];
- let stateClass = 'btn-danger';
- if (!hasCi) {
- stateClass = 'btn-create';
- } else if (indexOf.call(allowed_states, state) !== -1) {
- switch (state) {
- case "failed":
- case "canceled":
- case "not_found":
- stateClass = 'btn-danger';
- break;
- case "running":
- stateClass = 'btn-info';
- break;
- case "success":
- case "success_with_warnings":
- stateClass = 'btn-create';
- }
- } else {
- $('.ci_widget.ci-error').show();
- stateClass = 'btn-danger';
- }
-
- this.setMergeButtonClass(stateClass, $html);
- };
-
- MergeRequestWidget.prototype.setMergeButtonClass = function(css_class, $html = $('.mr-state-widget')) {
- return $html.find('.js-merge-button').removeClass('btn-danger btn-info btn-create').addClass(css_class);
- };
-
- MergeRequestWidget.prototype.updatePipelineUrls = function(id) {
- const pipelineUrl = this.opts.pipeline_path;
- $('.pipeline').text(`#${id}`).attr('href', [pipelineUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.updateCommitUrls = function(id) {
- const commitsUrl = this.opts.commits_path;
- $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
- new MiniPipelineGraph({
- container: '.js-pipeline-inline-mr-widget-graph:visible',
- }).bindEvents();
- };
-
- return MergeRequestWidget;
- })();
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js
deleted file mode 100644
index 21d7c3e168e..00000000000
--- a/app/assets/javascripts/merge_request_widget/ci_bundle.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* global merge_request_widget */
-
-(() => {
- $(() => {
- /* TODO: This needs a better home, or should be refactored. It was previously contained
- * in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml,
- * but Vue chokes on script tags and prevents their execution. So it was moved here
- * temporarily.
- * */
-
- $(document)
- .off('ajax:send', '.accept-mr-form')
- .on('ajax:send', '.accept-mr-form', () => {
- $('.accept-mr-form :input').disable();
- });
-
- $(document)
- .off('click', '.accept-merge-request')
- .on('click', '.accept-merge-request', () => {
- $('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress');
- });
-
- $(document)
- .off('click', '.merge-when-pipeline-succeeds')
- .on('click', '.merge-when-pipeline-succeeds', () => {
- $('#merge_when_pipeline_succeeds').val('1');
- });
-
- $(document)
- .off('click', '.js-merge-dropdown a')
- .on('click', '.js-merge-dropdown a', (e) => {
- e.preventDefault();
- $(e.target).closest('form').submit();
- });
- if ($('.rebase-in-progress').length) {
- merge_request_widget.rebaseInProgress();
- } else if ($('.rebase-mr-form').length) {
- $(document)
- .off('ajax:send', '.rebase-mr-form')
- .on('ajax:send', '.rebase-mr-form', () => {
- $('.rebase-mr-form :input').disable();
- });
-
- $(document)
- .off('click', '.js-rebase-button')
- .on('click', '.js-rebase-button', () => {
- $('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress");
- });
- } else {
- setTimeout(() => merge_request_widget.getMergeStatus(), 200);
- }
- });
-})();
diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js
deleted file mode 100644
index 7b0997c6520..00000000000
--- a/app/assets/javascripts/merged_buttons.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */
-
-import '~/lib/utils/url_utility';
-
-(function() {
- this.MergedButtons = (function() {
- function MergedButtons() {
- this.removeSourceBranch = this.removeSourceBranch.bind(this);
- this.removeBranchSuccess = this.removeBranchSuccess.bind(this);
- this.removeBranchError = this.removeBranchError.bind(this);
- this.$removeBranchWidget = $('.remove_source_branch_widget');
- this.$removeBranchProgress = $('.remove_source_branch_in_progress');
- this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
- this.cleanEventListeners();
- this.initEventListeners();
- }
-
- MergedButtons.prototype.cleanEventListeners = function() {
- $(document).off('click', '.remove_source_branch');
- $(document).off('ajax:success', '.remove_source_branch');
- return $(document).off('ajax:error', '.remove_source_branch');
- };
-
- MergedButtons.prototype.initEventListeners = function() {
- $(document).on('click', '.remove_source_branch', this.removeSourceBranch);
- $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess);
- $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError);
- };
-
- MergedButtons.prototype.removeSourceBranch = function() {
- this.$removeBranchWidget.hide();
- return this.$removeBranchProgress.show();
- };
-
- MergedButtons.prototype.removeBranchSuccess = function() {
- gl.utils.refreshCurrentPage();
- };
-
- MergedButtons.prototype.removeBranchError = function() {
- this.$removeBranchWidget.hide();
- this.$removeBranchProgress.hide();
- return this.$removeBranchFailed.show();
- };
-
- return MergedButtons;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 38c673e8907..3e07ec4d0aa 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -4,169 +4,48 @@
(function() {
this.Milestone = (function() {
- Milestone.updateIssue = function(li, issue_url, data) {
- return $.ajax({
- type: "PUT",
- url: issue_url,
- data: data,
- success: function(_data) {
- return Milestone.successCallback(_data, li);
- },
- error: function(data) {
- return new Flash("Issue update failed", 'alert');
- },
- dataType: "json"
- });
- };
-
- Milestone.sortIssues = function(data) {
- var sort_issues_url;
- sort_issues_url = location.href + "/sort_issues";
- return $.ajax({
- type: "PUT",
- url: sort_issues_url,
- data: data,
- success: function(_data) {
- return Milestone.successCallback(_data);
- },
- error: function() {
- return new Flash("Issues update failed", 'alert');
- },
- dataType: "json"
- });
- };
-
- Milestone.sortMergeRequests = function(data) {
- var sort_mr_url;
- sort_mr_url = location.href + "/sort_merge_requests";
- return $.ajax({
- type: "PUT",
- url: sort_mr_url,
- data: data,
- success: function(_data) {
- return Milestone.successCallback(_data);
- },
- error: function(data) {
- return new Flash("Issue update failed", 'alert');
- },
- dataType: "json"
- });
- };
-
- Milestone.updateMergeRequest = function(li, merge_request_url, data) {
- return $.ajax({
- type: "PUT",
- url: merge_request_url,
- data: data,
- success: function(_data) {
- return Milestone.successCallback(_data, li);
- },
- error: function(data) {
- return new Flash("Issue update failed", 'alert');
- },
- dataType: "json"
- });
- };
-
- Milestone.successCallback = function(data, element) {
- var img_tag;
- if (data.assignee) {
- img_tag = $('<img/>');
- img_tag.attr('src', data.assignee.avatar_url);
- img_tag.addClass('avatar s16');
- $(element).find('.assignee-icon img').replaceWith(img_tag);
- } else {
- $(element).find('.assignee-icon').empty();
- }
- };
-
function Milestone() {
- var oldMouseStart;
- this.bindIssuesSorting();
- this.bindMergeRequestSorting();
this.bindTabsSwitching();
- }
- Milestone.prototype.bindIssuesSorting = function() {
- $('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) {
- this.createSortable(el, {
- group: 'issue-list',
- listEls: $('.issues-sortable-list'),
- fieldName: 'issue',
- sortCallback: Milestone.sortIssues,
- updateCallback: Milestone.updateIssue,
- });
- }.bind(this));
- };
+ // Load merge request tab if it is active
+ // merge request tab is active based on different conditions in the backend
+ this.loadTab($('.js-milestone-tabs .active a'));
+
+ this.loadInitialTab();
+ }
Milestone.prototype.bindTabsSwitching = function() {
- return $('a[data-toggle="tab"]').on('show.bs.tab', function(e) {
- var currentTabClass, previousTabClass;
- currentTabClass = $(e.target).data('show');
- previousTabClass = $(e.relatedTarget).data('show');
- $(previousTabClass).hide();
- $(currentTabClass).removeClass('hidden');
- return $(currentTabClass).show();
- });
- };
+ return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
+ const $target = $(e.target);
- Milestone.prototype.bindMergeRequestSorting = function() {
- $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) {
- this.createSortable(el, {
- group: 'merge-request-list',
- listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"),
- fieldName: 'merge_request',
- sortCallback: Milestone.sortMergeRequests,
- updateCallback: Milestone.updateMergeRequest,
- });
- }.bind(this));
+ location.hash = $target.attr('href');
+ this.loadTab($target);
+ });
};
- Milestone.prototype.createSortable = function(el, opts) {
- return Sortable.create(el, {
- group: opts.group,
- filter: '.is-disabled',
- forceFallback: true,
- onStart: function(e) {
- opts.listEls.css('min-height', e.item.offsetHeight);
- },
- onEnd: function () {
- opts.listEls.css("min-height", "0px");
- },
- onUpdate: function(e) {
- var ids = this.toArray(),
- data;
+ Milestone.prototype.loadInitialTab = function() {
+ const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`);
- if (ids.length) {
- data = ids.map(function(id) {
- return 'sortable_' + opts.fieldName + '[]=' + id;
- }).join('&');
+ if ($target.length) {
+ $target.tab('show');
+ }
+ };
- opts.sortCallback(data);
- }
- },
- onAdd: function (e) {
- var data, issuableId, issuableUrl, newState;
- newState = e.to.dataset.state;
- issuableUrl = e.item.dataset.url;
- data = (function() {
- switch (newState) {
- case 'ongoing':
- return opts.fieldName + '[assignee_id]=' + gon.current_user_id;
- case 'unassigned':
- return opts.fieldName + '[assignee_id]=';
- case 'closed':
- return opts.fieldName + '[state_event]=close';
- }
- })();
- if (e.from.dataset.state === 'closed') {
- data += '&' + opts.fieldName + '[state_event]=reopen';
- }
+ Milestone.prototype.loadTab = function($target) {
+ const endpoint = $target.data('endpoint');
+ const tabElId = $target.attr('href');
- opts.updateCallback(e.item, issuableUrl, data);
- this.options.onUpdate.call(this, e);
- }
- });
+ if (endpoint && !$target.hasClass('is-loaded')) {
+ $.ajax({
+ url: endpoint,
+ dataType: 'JSON',
+ })
+ .fail(() => new Flash('Error loading milestone tab'))
+ .done((data) => {
+ $(tabElId).html(data.html);
+ $target.addClass('is-loaded');
+ });
+ }
};
return Milestone;
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index bebd0aa357e..04579058688 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, one-var-declaration-per-line, no-unused-vars, object-shorthand, comma-dangle, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */
/* global Issuable */
/* global ListMilestone */
+import _ from 'underscore';
(function() {
this.MilestoneSelect = (function() {
@@ -8,7 +9,7 @@
var _this, $els;
if (currentProject != null) {
_this = this;
- this.currentProject = JSON.parse(currentProject);
+ this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
}
$els = $(els);
@@ -18,12 +19,11 @@
}
$els.each(function(i, dropdown) {
- var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
+ var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
milestonesUrl = $dropdown.data('milestones');
issueUpdateURL = $dropdown.data('issueUpdate');
- selectedMilestone = $dropdown.data('selected');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
showMenuAbove = $dropdown.data('showMenuAbove');
@@ -31,6 +31,7 @@
showStarted = $dropdown.data('show-started');
useId = $dropdown.data('use-id');
defaultLabel = $dropdown.data('default-label');
+ defaultNo = $dropdown.data('default-no');
issuableId = $dropdown.data('issuable-id');
abilityName = $dropdown.data('ability-name');
$selectbox = $dropdown.closest('.selectbox');
@@ -38,6 +39,9 @@
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
$value = $block.find('.value');
$loading = $block.find('.block-loading').fadeOut();
+ selectedMilestoneDefault = (showAny ? '' : null);
+ selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault);
+ selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>');
milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
@@ -86,8 +90,18 @@
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
+ $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
});
},
+ renderRow: function(milestone) {
+ return `
+ <li data-milestone-id="${milestone.name}">
+ <a href='#' class='dropdown-menu-milestone-link'>
+ ${_.escape(milestone.title)}
+ </a>
+ </li>
+ `;
+ },
filterable: true,
search: {
fields: ['title']
@@ -120,12 +134,24 @@
// display:block overrides the hide-collapse rule
return $value.css('display', '');
},
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
+ }
+ $('a.is-active', $el).removeClass('is-active');
+ $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
+ },
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(selected, $el, e) {
- var data, isIssueIndex, isMRIndex, page, boardsStore;
+ clicked: function(options) {
+ const { $el, e } = options;
+ let selected = options.selectedObj;
+ var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ isSelecting = (selected.name !== selectedMilestone);
+ selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault;
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
return;
@@ -139,16 +165,11 @@
boardsStore[$dropdown.data('field-name')] = selected.name;
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- if (selected.name != null) {
- selectedMilestone = selected.name;
- } else {
- selectedMilestone = '';
- }
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (selected.id !== -1) {
+ if (selected.id !== -1 && isSelecting) {
gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
id: selected.id,
title: selected.name
diff --git a/app/assets/javascripts/monitoring/components/monitoring.vue b/app/assets/javascripts/monitoring/components/monitoring.vue
new file mode 100644
index 00000000000..a6a2d3119e3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring.vue
@@ -0,0 +1,157 @@
+<script>
+ /* global Flash */
+ import _ from 'underscore';
+ import statusCodes from '../../lib/utils/http_status';
+ import MonitoringService from '../services/monitoring_service';
+ import monitoringRow from './monitoring_row.vue';
+ import monitoringState from './monitoring_state.vue';
+ import MonitoringStore from '../stores/monitoring_store';
+ import eventHub from '../event_hub';
+
+ export default {
+
+ data() {
+ const metricsData = document.querySelector('#prometheus-graphs').dataset;
+ const store = new MonitoringStore();
+
+ return {
+ store,
+ state: 'gettingStarted',
+ hasMetrics: gl.utils.convertPermissionToBoolean(metricsData.hasMetrics),
+ documentationPath: metricsData.documentationPath,
+ settingsPath: metricsData.settingsPath,
+ endpoint: metricsData.additionalMetrics,
+ deploymentEndpoint: metricsData.deploymentEndpoint,
+ showEmptyState: true,
+ backOffRequestCounter: 0,
+ updateAspectRatio: false,
+ updatedAspectRatios: 0,
+ resizeThrottled: {},
+ };
+ },
+
+ components: {
+ monitoringRow,
+ monitoringState,
+ },
+
+ methods: {
+ getGraphsData() {
+ const maxNumberOfRequests = 3;
+ this.state = 'loading';
+ gl.utils.backOff((next, stop) => {
+ this.service.get().then((resp) => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ if (this.backOffRequestCounter < maxNumberOfRequests) {
+ next();
+ } else {
+ stop(new Error('Failed to connect to the prometheus server'));
+ }
+ } else {
+ stop(resp);
+ }
+ }).catch(stop);
+ })
+ .then((resp) => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ this.state = 'unableToConnect';
+ return false;
+ }
+ return resp.json();
+ })
+ .then((metricGroupsData) => {
+ if (!metricGroupsData) return false;
+ this.store.storeMetrics(metricGroupsData.data);
+ return this.getDeploymentData();
+ })
+ .then((deploymentData) => {
+ if (deploymentData !== false) {
+ this.store.storeDeploymentData(deploymentData.deployments);
+ this.showEmptyState = false;
+ }
+ return {};
+ })
+ .catch(() => {
+ this.state = 'unableToConnect';
+ });
+ },
+
+ getDeploymentData() {
+ return this.service.getDeploymentData(this.deploymentEndpoint)
+ .then(resp => resp.json())
+ .catch(() => new Flash('Error getting deployment information.'));
+ },
+
+ resize() {
+ this.updateAspectRatio = true;
+ },
+
+ toggleAspectRatio() {
+ this.updatedAspectRatios = this.updatedAspectRatios += 1;
+ if (this.store.getMetricsCount() === this.updatedAspectRatios) {
+ this.updateAspectRatio = !this.updateAspectRatio;
+ this.updatedAspectRatios = 0;
+ }
+ },
+
+ },
+
+ created() {
+ this.service = new MonitoringService(this.endpoint);
+ eventHub.$on('toggleAspectRatio', this.toggleAspectRatio);
+ },
+
+ beforeDestroy() {
+ eventHub.$off('toggleAspectRatio', this.toggleAspectRatio);
+ window.removeEventListener('resize', this.resizeThrottled, false);
+ },
+
+ mounted() {
+ this.resizeThrottled = _.throttle(this.resize, 600);
+ if (!this.hasMetrics) {
+ this.state = 'gettingStarted';
+ } else {
+ this.getGraphsData();
+ window.addEventListener('resize', this.resizeThrottled, false);
+ }
+ },
+ };
+</script>
+<template>
+ <div
+ class="prometheus-graphs"
+ v-if="!showEmptyState">
+ <div
+ class="row"
+ v-for="(groupData, index) in store.groups"
+ :key="index">
+ <div
+ class="col-md-12">
+ <div
+ class="panel panel-default prometheus-panel">
+ <div
+ class="panel-heading">
+ <h4>{{groupData.group}}</h4>
+ </div>
+ <div
+ class="panel-body">
+ <monitoring-row
+ v-for="(row, index) in groupData.metrics"
+ :key="index"
+ :row-data="row"
+ :update-aspect-ratio="updateAspectRatio"
+ :deployment-data="store.deploymentData"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <monitoring-state
+ :selected-state="state"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ v-else
+ />
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_column.vue b/app/assets/javascripts/monitoring/components/monitoring_column.vue
new file mode 100644
index 00000000000..407af51cb7a
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_column.vue
@@ -0,0 +1,296 @@
+<script>
+ import d3 from 'd3';
+ import monitoringLegends from './monitoring_legends.vue';
+ import monitoringFlag from './monitoring_flag.vue';
+ import monitoringDeployment from './monitoring_deployment.vue';
+ import MonitoringMixin from '../mixins/monitoring_mixins';
+ import eventHub from '../event_hub';
+ import measurements from '../utils/measurements';
+ import { formatRelevantDigits } from '../../lib/utils/number_utils';
+ import bp from '../../breakpoints';
+
+ const bisectDate = d3.bisector(d => d.time).left;
+
+ export default {
+ props: {
+ columnData: {
+ type: Object,
+ required: true,
+ },
+ classType: {
+ type: String,
+ required: true,
+ },
+ updateAspectRatio: {
+ type: Boolean,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ mixins: [MonitoringMixin],
+
+ data() {
+ return {
+ graphHeight: 450,
+ graphWidth: 600,
+ graphHeightOffset: 120,
+ xScale: {},
+ yScale: {},
+ margin: {},
+ data: [],
+ unitOfDisplay: '',
+ areaColorRgb: '#8fbce8',
+ lineColorRgb: '#1f78d1',
+ yAxisLabel: '',
+ legendTitle: '',
+ reducedDeploymentData: [],
+ area: '',
+ line: '',
+ measurements: measurements.large,
+ currentData: {
+ time: new Date(),
+ value: 0,
+ },
+ currentYCoordinate: 0,
+ currentXCoordinate: 0,
+ currentFlagPosition: 0,
+ metricUsage: '',
+ showFlag: false,
+ showDeployInfo: true,
+ };
+ },
+
+ components: {
+ monitoringLegends,
+ monitoringFlag,
+ monitoringDeployment,
+ },
+
+ computed: {
+ outterViewBox() {
+ return `0 0 ${this.graphWidth} ${this.graphHeight}`;
+ },
+
+ innerViewBox() {
+ if ((this.graphWidth - 150) > 0) {
+ return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`;
+ }
+ return '0 0 0 0';
+ },
+
+ axisTransform() {
+ return `translate(70, ${this.graphHeight - 100})`;
+ },
+
+ paddingBottomRootSvg() {
+ return {
+ paddingBottom: `${(Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0}%`,
+ };
+ },
+ },
+
+ methods: {
+ draw() {
+ const breakpointSize = bp.getBreakpointSize();
+ const query = this.columnData.queries[0];
+ this.margin = measurements.large.margin;
+ if (breakpointSize === 'xs' || breakpointSize === 'sm') {
+ this.graphHeight = 300;
+ this.margin = measurements.small.margin;
+ this.measurements = measurements.small;
+ }
+ this.data = query.result[0].values;
+ this.unitOfDisplay = query.unit || '';
+ this.yAxisLabel = this.columnData.y_label || 'Values';
+ this.legendTitle = query.label || 'Average';
+ this.graphWidth = this.$refs.baseSvg.clientWidth -
+ this.margin.left - this.margin.right;
+ this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
+ if (this.data !== undefined) {
+ this.renderAxesPaths();
+ this.formatDeployments();
+ }
+ },
+
+ handleMouseOverGraph(e) {
+ let point = this.$refs.graphData.createSVGPoint();
+ point.x = e.clientX;
+ point.y = e.clientY;
+ point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
+ point.x = point.x += 7;
+ const timeValueOverlay = this.xScale.invert(point.x);
+ const overlayIndex = bisectDate(this.data, timeValueOverlay, 1);
+ const d0 = this.data[overlayIndex - 1];
+ const d1 = this.data[overlayIndex];
+ if (d0 === undefined || d1 === undefined) return;
+ const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
+ this.currentData = evalTime ? d1 : d0;
+ this.currentXCoordinate = Math.floor(this.xScale(this.currentData.time));
+ const currentDeployXPos = this.mouseOverDeployInfo(point.x);
+ this.currentYCoordinate = this.yScale(this.currentData.value);
+
+ if (this.currentXCoordinate > (this.graphWidth - 200)) {
+ this.currentFlagPosition = this.currentXCoordinate - 103;
+ } else {
+ this.currentFlagPosition = this.currentXCoordinate;
+ }
+
+ if (currentDeployXPos) {
+ this.showFlag = false;
+ } else {
+ this.showFlag = true;
+ }
+
+ this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`;
+ },
+
+ renderAxesPaths() {
+ const axisXScale = d3.time.scale()
+ .range([0, this.graphWidth]);
+ this.yScale = d3.scale.linear()
+ .range([this.graphHeight - this.graphHeightOffset, 0]);
+ axisXScale.domain(d3.extent(this.data, d => d.time));
+ this.yScale.domain([0, d3.max(this.data.map(d => d.value))]);
+
+ const xAxis = d3.svg.axis()
+ .scale(axisXScale)
+ .ticks(measurements.xTicks)
+ .orient('bottom');
+
+ const yAxis = d3.svg.axis()
+ .scale(this.yScale)
+ .ticks(measurements.yTicks)
+ .orient('left');
+
+ d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis);
+
+ const width = this.graphWidth;
+ d3.select(this.$refs.baseSvg).select('.y-axis').call(yAxis)
+ .selectAll('.tick')
+ .each(function createTickLines(d, i) {
+ if (i > 0) {
+ d3.select(this).select('line')
+ .attr('x2', width)
+ .attr('class', 'axis-tick');
+ } // Avoid adding the class to the first tick, to prevent coloring
+ }); // This will select all of the ticks once they're rendered
+
+ this.xScale = d3.time.scale()
+ .range([0, this.graphWidth - 70]);
+
+ this.xScale.domain(d3.extent(this.data, d => d.time));
+
+ const areaFunction = d3.svg.area()
+ .x(d => this.xScale(d.time))
+ .y0(this.graphHeight - this.graphHeightOffset)
+ .y1(d => this.yScale(d.value))
+ .interpolate('linear');
+
+ const lineFunction = d3.svg.line()
+ .x(d => this.xScale(d.time))
+ .y(d => this.yScale(d.value));
+
+ this.line = lineFunction(this.data);
+
+ this.area = areaFunction(this.data);
+ },
+ },
+
+ watch: {
+ updateAspectRatio() {
+ if (this.updateAspectRatio) {
+ this.graphHeight = 450;
+ this.graphWidth = 600;
+ this.measurements = measurements.large;
+ this.draw();
+ eventHub.$emit('toggleAspectRatio');
+ }
+ },
+ },
+
+ mounted() {
+ this.draw();
+ },
+ };
+</script>
+<template>
+ <div
+ :class="classType">
+ <h5
+ class="text-center graph-title">
+ {{columnData.title}}
+ </h5>
+ <div
+ class="prometheus-svg-container"
+ :style="paddingBottomRootSvg">
+ <svg
+ :viewBox="outterViewBox"
+ ref="baseSvg">
+ <g
+ class="x-axis"
+ :transform="axisTransform">
+ </g>
+ <g
+ class="y-axis"
+ transform="translate(70, 20)">
+ </g>
+ <monitoring-legends
+ :graph-width="graphWidth"
+ :graph-height="graphHeight"
+ :margin="margin"
+ :measurements="measurements"
+ :area-color-rgb="areaColorRgb"
+ :legend-title="legendTitle"
+ :y-axis-label="yAxisLabel"
+ :metric-usage="metricUsage"
+ />
+ <svg
+ class="graph-data"
+ :viewBox="innerViewBox"
+ ref="graphData">
+ <path
+ class="metric-area"
+ :d="area"
+ :fill="areaColorRgb"
+ transform="translate(-5, 20)">
+ </path>
+ <path
+ class="metric-line"
+ :d="line"
+ :stroke="lineColorRgb"
+ fill="none"
+ stroke-width="2"
+ transform="translate(-5, 20)">
+ </path>
+ <rect
+ class="prometheus-graph-overlay"
+ :width="(graphWidth - 70)"
+ :height="(graphHeight - 100)"
+ transform="translate(-5, 20)"
+ ref="graphOverlay"
+ @mousemove="handleMouseOverGraph($event)">
+ </rect>
+ <monitoring-deployment
+ :show-deploy-info="showDeployInfo"
+ :deployment-data="reducedDeploymentData"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ />
+ <monitoring-flag
+ v-if="showFlag"
+ :current-x-coordinate="currentXCoordinate"
+ :current-y-coordinate="currentYCoordinate"
+ :current-data="currentData"
+ :current-flag-position="currentFlagPosition"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ />
+ </svg>
+ </svg>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_deployment.vue b/app/assets/javascripts/monitoring/components/monitoring_deployment.vue
new file mode 100644
index 00000000000..e6432ba3191
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_deployment.vue
@@ -0,0 +1,136 @@
+<script>
+ import {
+ dateFormat,
+ timeFormat,
+ } from '../constants';
+
+ export default {
+ props: {
+ showDeployInfo: {
+ type: Boolean,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
+ },
+ },
+
+ computed: {
+ calculatedHeight() {
+ return this.graphHeight - this.graphHeightOffset;
+ },
+ },
+
+ methods: {
+ refText(d) {
+ return d.tag ? d.ref : d.sha.slice(0, 6);
+ },
+
+ formatTime(deploymentTime) {
+ return timeFormat(deploymentTime);
+ },
+
+ formatDate(deploymentTime) {
+ return dateFormat(deploymentTime);
+ },
+
+ nameDeploymentClass(deployment) {
+ return `deploy-info-${deployment.id}`;
+ },
+
+ transformDeploymentGroup(deployment) {
+ return `translate(${Math.floor(deployment.xPos) + 1}, 20)`;
+ },
+ },
+ };
+</script>
+<template>
+ <g
+ class="deploy-info"
+ v-if="showDeployInfo">
+ <g
+ v-for="(deployment, index) in deploymentData"
+ :key="index"
+ :class="nameDeploymentClass(deployment)"
+ :transform="transformDeploymentGroup(deployment)">
+ <rect
+ x="0"
+ y="0"
+ :height="calculatedHeight"
+ width="3"
+ fill="url(#shadow-gradient)">
+ </rect>
+ <line
+ class="deployment-line"
+ x1="0"
+ y1="0"
+ x2="0"
+ :y2="calculatedHeight"
+ stroke="#000">
+ </line>
+ <svg
+ v-if="deployment.showDeploymentFlag"
+ class="js-deploy-info-box"
+ x="3"
+ y="0"
+ width="92"
+ height="60">
+ <rect
+ class="rect-text-metric deploy-info-rect rect-metric"
+ x="1"
+ y="1"
+ rx="2"
+ width="90"
+ height="58">
+ </rect>
+ <g
+ transform="translate(5, 2)">
+ <text
+ class="deploy-info-text text-metric-bold">
+ {{refText(deployment)}}
+ </text>
+ </g>
+ <text
+ class="deploy-info-text"
+ y="18"
+ transform="translate(5, 2)">
+ {{formatDate(deployment.time)}}
+ </text>
+ <text
+ class="deploy-info-text text-metric-bold"
+ y="38"
+ transform="translate(5, 2)">
+ {{formatTime(deployment.time)}}
+ </text>
+ </svg>
+ </g>
+ <svg
+ height="0"
+ width="0">
+ <defs>
+ <linearGradient
+ id="shadow-gradient">
+ <stop
+ offset="0%"
+ stop-color="#000"
+ stop-opacity="0.4">
+ </stop>
+ <stop
+ offset="100%"
+ stop-color="#000"
+ stop-opacity="0">
+ </stop>
+ </linearGradient>
+ </defs>
+ </svg>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_flag.vue b/app/assets/javascripts/monitoring/components/monitoring_flag.vue
new file mode 100644
index 00000000000..5a0e50fcab3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_flag.vue
@@ -0,0 +1,104 @@
+<script>
+ import {
+ dateFormat,
+ timeFormat,
+ } from '../constants';
+
+ export default {
+ props: {
+ currentXCoordinate: {
+ type: Number,
+ required: true,
+ },
+ currentYCoordinate: {
+ type: Number,
+ required: true,
+ },
+ currentFlagPosition: {
+ type: Number,
+ required: true,
+ },
+ currentData: {
+ type: Object,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ circleColorRgb: '#8fbce8',
+ };
+ },
+
+ computed: {
+ formatTime() {
+ return timeFormat(this.currentData.time);
+ },
+
+ formatDate() {
+ return dateFormat(this.currentData.time);
+ },
+
+ calculatedHeight() {
+ return this.graphHeight - this.graphHeightOffset;
+ },
+ },
+ };
+</script>
+<template>
+ <g class="mouse-over-flag">
+ <line
+ class="selected-metric-line"
+ :x1="currentXCoordinate"
+ :y1="0"
+ :x2="currentXCoordinate"
+ :y2="calculatedHeight"
+ transform="translate(-5, 20)">
+ </line>
+ <circle
+ class="circle-metric"
+ :fill="circleColorRgb"
+ stroke="#000"
+ :cx="currentXCoordinate"
+ :cy="currentYCoordinate"
+ r="5"
+ transform="translate(-5, 20)">
+ </circle>
+ <svg
+ class="rect-text-metric"
+ :x="currentFlagPosition"
+ y="0">
+ <rect
+ class="rect-metric"
+ x="4"
+ y="1"
+ rx="2"
+ width="90"
+ height="40"
+ transform="translate(-3, 20)">
+ </rect>
+ <text
+ class="text-metric text-metric-bold"
+ x="16"
+ y="35"
+ transform="translate(-5, 20)">
+ {{formatTime}}
+ </text>
+ <text
+ class="text-metric"
+ x="16"
+ y="15"
+ transform="translate(-5, 20)">
+ {{formatDate}}
+ </text>
+ </svg>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_legends.vue b/app/assets/javascripts/monitoring/components/monitoring_legends.vue
new file mode 100644
index 00000000000..922a5e1bf0e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_legends.vue
@@ -0,0 +1,144 @@
+<script>
+ export default {
+ props: {
+ graphWidth: {
+ type: Number,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ margin: {
+ type: Object,
+ required: true,
+ },
+ measurements: {
+ type: Object,
+ required: true,
+ },
+ areaColorRgb: {
+ type: String,
+ required: true,
+ },
+ legendTitle: {
+ type: String,
+ required: true,
+ },
+ yAxisLabel: {
+ type: String,
+ required: true,
+ },
+ metricUsage: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ yLabelWidth: 0,
+ yLabelHeight: 0,
+ };
+ },
+ computed: {
+ textTransform() {
+ const yCoordinate = (((this.graphHeight - this.margin.top)
+ + this.measurements.axisLabelLineOffset) / 2) || 0;
+
+ return `translate(15, ${yCoordinate}) rotate(-90)`;
+ },
+
+ rectTransform() {
+ const yCoordinate = ((this.graphHeight - this.margin.top) / 2)
+ + (this.yLabelWidth / 2) + 10 || 0;
+
+ return `translate(0, ${yCoordinate}) rotate(-90)`;
+ },
+
+ xPosition() {
+ return (((this.graphWidth + this.measurements.axisLabelLineOffset) / 2)
+ - this.margin.right) || 0;
+ },
+
+ yPosition() {
+ return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0;
+ },
+ },
+ mounted() {
+ this.$nextTick(() => {
+ const bbox = this.$refs.ylabel.getBBox();
+ this.yLabelWidth = bbox.width + 10; // Added some padding
+ this.yLabelHeight = bbox.height + 5;
+ });
+ },
+ };
+</script>
+<template>
+ <g
+ class="axis-label-container">
+ <line
+ class="label-x-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ :y1="yPosition"
+ :x2="graphWidth + 20"
+ :y2="yPosition">
+ </line>
+ <line
+ class="label-y-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ y1="0"
+ :x2="10"
+ :y2="yPosition">
+ </line>
+ <rect
+ class="rect-axis-text"
+ :transform="rectTransform"
+ :width="yLabelWidth"
+ :height="yLabelHeight">
+ </rect>
+ <text
+ class="label-axis-text y-label-text"
+ text-anchor="middle"
+ :transform="textTransform"
+ ref="ylabel">
+ {{yAxisLabel}}
+ </text>
+ <rect
+ class="rect-axis-text"
+ :x="xPosition + 60"
+ :y="graphHeight - 80"
+ width="35"
+ height="50">
+ </rect>
+ <text
+ class="label-axis-text x-label-text"
+ :x="xPosition + 60"
+ :y="yPosition"
+ dy=".35em">
+ Time
+ </text>
+ <rect
+ :fill="areaColorRgb"
+ :width="measurements.legends.width"
+ :height="measurements.legends.height"
+ x="20"
+ :y="graphHeight - measurements.legendOffset">
+ </rect>
+ <text
+ class="text-metric-title"
+ x="50"
+ :y="graphHeight - 25">
+ {{legendTitle}}
+ </text>
+ <text
+ class="text-metric-usage"
+ x="50"
+ :y="graphHeight - 10">
+ {{metricUsage}}
+ </text>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_row.vue b/app/assets/javascripts/monitoring/components/monitoring_row.vue
new file mode 100644
index 00000000000..e5528f17880
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_row.vue
@@ -0,0 +1,41 @@
+<script>
+ import monitoringColumn from './monitoring_column.vue';
+
+ export default {
+ props: {
+ rowData: {
+ type: Array,
+ required: true,
+ },
+ updateAspectRatio: {
+ type: Boolean,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ },
+ components: {
+ monitoringColumn,
+ },
+ computed: {
+ bootstrapClass() {
+ return this.rowData.length >= 2 ? 'col-md-6' : 'col-md-12';
+ },
+ },
+ };
+</script>
+<template>
+ <div
+ class="prometheus-row row">
+ <monitoring-column
+ v-for="(column, index) in rowData"
+ :column-data="column"
+ :class-type="bootstrapClass"
+ :key="index"
+ :update-aspect-ratio="updateAspectRatio"
+ :deployment-data="deploymentData"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_state.vue b/app/assets/javascripts/monitoring/components/monitoring_state.vue
new file mode 100644
index 00000000000..598021aa4df
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_state.vue
@@ -0,0 +1,112 @@
+<script>
+ import gettingStartedSvg from 'empty_states/monitoring/_getting_started.svg';
+ import loadingSvg from 'empty_states/monitoring/_loading.svg';
+ import unableToConnectSvg from 'empty_states/monitoring/_unable_to_connect.svg';
+
+ export default {
+ props: {
+ documentationPath: {
+ type: String,
+ required: true,
+ },
+ settingsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ selectedState: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ states: {
+ gettingStarted: {
+ svg: gettingStartedSvg,
+ title: 'Get started with performance monitoring',
+ description: 'Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.',
+ buttonText: 'Configure Prometheus',
+ },
+ loading: {
+ svg: loadingSvg,
+ title: 'Waiting for performance data',
+ description: 'Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.',
+ buttonText: 'View documentation',
+ },
+ unableToConnect: {
+ svg: unableToConnectSvg,
+ title: 'Unable to connect to Prometheus server',
+ description: 'Ensure connectivity is available from the GitLab server to the ',
+ buttonText: 'View documentation',
+ },
+ },
+ };
+ },
+ computed: {
+ currentState() {
+ return this.states[this.selectedState];
+ },
+
+ buttonPath() {
+ if (this.selectedState === 'gettingStarted') {
+ return this.settingsPath;
+ }
+ return this.documentationPath;
+ },
+
+ showButtonDescription() {
+ if (this.selectedState === 'unableToConnect') return true;
+ return false;
+ },
+ },
+ };
+</script>
+<template>
+ <div
+ class="prometheus-state">
+ <div
+ class="row">
+ <div
+ class="col-md-4 col-md-offset-4 state-svg"
+ v-html="currentState.svg">
+ </div>
+ </div>
+ <div
+ class="row">
+ <div
+ class="col-md-6 col-md-offset-3">
+ <h4
+ class="text-center state-title">
+ {{currentState.title}}
+ </h4>
+ </div>
+ </div>
+ <div
+ class="row">
+ <div
+ class="col-md-6 col-md-offset-3">
+ <div
+ class="description-text text-center state-description">
+ {{currentState.description}}
+ <a
+ :href="settingsPath"
+ v-if="showButtonDescription">
+ Prometheus server
+ </a>
+ </div>
+ </div>
+ </div>
+ <div
+ class="row state-button-section">
+ <div
+ class="col-md-4 col-md-offset-4 text-center state-button">
+ <a
+ class="btn btn-success"
+ :href="buttonPath">
+ {{currentState.buttonText}}
+ </a>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
new file mode 100644
index 00000000000..c3a8da52404
--- /dev/null
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -0,0 +1,4 @@
+import d3 from 'd3';
+
+export const dateFormat = d3.time.format('%b %d, %Y');
+export const timeFormat = d3.time.format('%H:%M%p');
diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/monitoring/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
new file mode 100644
index 00000000000..8e62fa63f13
--- /dev/null
+++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
@@ -0,0 +1,46 @@
+const mixins = {
+ methods: {
+ mouseOverDeployInfo(mouseXPos) {
+ if (!this.reducedDeploymentData) return false;
+
+ let dataFound = false;
+ this.reducedDeploymentData = this.reducedDeploymentData.map((d) => {
+ const deployment = d;
+ if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
+ dataFound = d.xPos + 1;
+
+ deployment.showDeploymentFlag = true;
+ } else {
+ deployment.showDeploymentFlag = false;
+ }
+ return deployment;
+ });
+
+ return dataFound;
+ },
+ formatDeployments() {
+ this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
+ const time = new Date(deployment.created_at);
+ const xPos = Math.floor(this.xScale(time));
+
+ time.setSeconds(this.data[0].time.getSeconds());
+
+ if (xPos >= 0) {
+ deploymentDataArray.push({
+ id: deployment.id,
+ time,
+ sha: deployment.sha,
+ tag: deployment.tag,
+ ref: deployment.ref.name,
+ xPos,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return deploymentDataArray;
+ }, []);
+ },
+ },
+};
+
+export default mixins;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index b3ce9310417..5d5cb56af72 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,6 +1,10 @@
-import PrometheusGraph from './prometheus_graph';
+import Vue from 'vue';
+import Monitoring from './components/monitoring.vue';
-document.addEventListener('DOMContentLoaded', function onLoad() {
- document.removeEventListener('DOMContentLoaded', onLoad, false);
- return new PrometheusGraph();
-}, false);
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#prometheus-graphs',
+ components: {
+ 'monitoring-dashboard': Monitoring,
+ },
+ render: createElement => createElement('monitoring-dashboard'),
+}));
diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js
deleted file mode 100644
index 78bb0e6fb47..00000000000
--- a/app/assets/javascripts/monitoring/prometheus_graph.js
+++ /dev/null
@@ -1,408 +0,0 @@
-/* eslint-disable no-new */
-/* global Flash */
-
-import d3 from 'd3';
-import statusCodes from '~/lib/utils/http_status';
-import { formatRelevantDigits } from '~/lib/utils/number_utils';
-import '../flash';
-
-const prometheusContainer = '.prometheus-container';
-const prometheusParentGraphContainer = '.prometheus-graphs';
-const prometheusGraphsContainer = '.prometheus-graph';
-const prometheusStatesContainer = '.prometheus-state';
-const metricsEndpoint = 'metrics.json';
-const timeFormat = d3.time.format('%H:%M');
-const dayFormat = d3.time.format('%b %e, %a');
-const bisectDate = d3.bisector(d => d.time).left;
-const extraAddedWidthParent = 100;
-
-class PrometheusGraph {
- constructor() {
- const $prometheusContainer = $(prometheusContainer);
- const hasMetrics = $prometheusContainer.data('has-metrics');
- this.docLink = $prometheusContainer.data('doc-link');
- this.integrationLink = $prometheusContainer.data('prometheus-integration');
- this.state = '';
-
- $(document).ajaxError(() => {});
-
- if (hasMetrics) {
- this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
- this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
- const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
- extraAddedWidthParent;
- this.originalWidth = parentContainerWidth;
- this.originalHeight = 330;
- this.width = parentContainerWidth - this.margin.left - this.margin.right;
- this.height = this.originalHeight - this.margin.top - this.margin.bottom;
- this.backOffRequestCounter = 0;
- this.configureGraph();
- this.init();
- } else {
- const prevState = this.state;
- this.state = '.js-getting-started';
- this.updateState(prevState);
- }
- }
-
- createGraph() {
- Object.keys(this.graphSpecificProperties).forEach((key) => {
- const value = this.graphSpecificProperties[key];
- if (value.data.length > 0) {
- this.plotValues(key);
- }
- });
- }
-
- init() {
- return this.getData().then((metricsResponse) => {
- let enoughData = true;
- if (typeof metricsResponse === 'undefined') {
- enoughData = false;
- } else {
- Object.keys(metricsResponse.metrics).forEach((key) => {
- if (key === 'cpu_values' || key === 'memory_values') {
- const currentData = (metricsResponse.metrics[key])[0];
- if (currentData.values.length <= 2) {
- enoughData = false;
- }
- }
- });
- }
- if (enoughData) {
- $(prometheusStatesContainer).hide();
- $(prometheusParentGraphContainer).show();
- this.transformData(metricsResponse);
- this.createGraph();
- }
- });
- }
-
- plotValues(key) {
- const graphSpecifics = this.graphSpecificProperties[key];
-
- const x = d3.time.scale()
- .range([0, this.width]);
-
- const y = d3.scale.linear()
- .range([this.height, 0]);
-
- graphSpecifics.xScale = x;
- graphSpecifics.yScale = y;
-
- const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`;
-
- const chart = d3.select(prometheusGraphContainer)
- .attr('width', this.width + this.margin.left + this.margin.right)
- .attr('height', this.height + this.margin.bottom + this.margin.top)
- .append('g')
- .attr('transform', `translate(${this.margin.left},${this.margin.top})`);
-
- const axisLabelContainer = d3.select(prometheusGraphContainer)
- .attr('width', this.originalWidth)
- .attr('height', this.originalHeight)
- .append('g')
- .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`);
-
- x.domain(d3.extent(graphSpecifics.data, d => d.time));
- y.domain([0, d3.max(graphSpecifics.data.map(metricValue => metricValue.value))]);
-
- const xAxis = d3.svg.axis()
- .scale(x)
- .ticks(this.commonGraphProperties.axis_no_ticks)
- .orient('bottom');
-
- const yAxis = d3.svg.axis()
- .scale(y)
- .ticks(this.commonGraphProperties.axis_no_ticks)
- .tickSize(-this.width)
- .orient('left');
-
- this.createAxisLabelContainers(axisLabelContainer, key);
-
- chart.append('g')
- .attr('class', 'x-axis')
- .attr('transform', `translate(0,${this.height})`)
- .call(xAxis);
-
- chart.append('g')
- .attr('class', 'y-axis')
- .call(yAxis);
-
- const area = d3.svg.area()
- .x(d => x(d.time))
- .y0(this.height)
- .y1(d => y(d.value))
- .interpolate('linear');
-
- const line = d3.svg.line()
- .x(d => x(d.time))
- .y(d => y(d.value));
-
- chart.append('path')
- .datum(graphSpecifics.data)
- .attr('d', area)
- .attr('class', 'metric-area')
- .attr('fill', graphSpecifics.area_fill_color);
-
- chart.append('path')
- .datum(graphSpecifics.data)
- .attr('class', 'metric-line')
- .attr('stroke', graphSpecifics.line_color)
- .attr('fill', 'none')
- .attr('stroke-width', this.commonGraphProperties.area_stroke_width)
- .attr('d', line);
-
- // Overlay area for the mouseover events
- chart.append('rect')
- .attr('class', 'prometheus-graph-overlay')
- .attr('width', this.width)
- .attr('height', this.height)
- .on('mousemove', this.handleMouseOverGraph.bind(this, prometheusGraphContainer));
- }
-
- // The legends from the metric
- createAxisLabelContainers(axisLabelContainer, key) {
- const graphSpecifics = this.graphSpecificProperties[key];
-
- axisLabelContainer.append('line')
- .attr('class', 'label-x-axis-line')
- .attr('stroke', '#000000')
- .attr('stroke-width', '1')
- .attr({
- x1: 10,
- y1: this.originalHeight - this.margin.top,
- x2: (this.originalWidth - this.margin.right) + 10,
- y2: this.originalHeight - this.margin.top,
- });
-
- axisLabelContainer.append('line')
- .attr('class', 'label-y-axis-line')
- .attr('stroke', '#000000')
- .attr('stroke-width', '1')
- .attr({
- x1: 10,
- y1: 0,
- x2: 10,
- y2: this.originalHeight - this.margin.top,
- });
-
- axisLabelContainer.append('rect')
- .attr('class', 'rect-axis-text')
- .attr('x', 0)
- .attr('y', 50)
- .attr('width', 30)
- .attr('height', 150);
-
- axisLabelContainer.append('text')
- .attr('class', 'label-axis-text')
- .attr('text-anchor', 'middle')
- .attr('transform', `translate(15, ${(this.originalHeight - this.margin.top) / 2}) rotate(-90)`)
- .text(graphSpecifics.graph_legend_title);
-
- axisLabelContainer.append('rect')
- .attr('class', 'rect-axis-text')
- .attr('x', (this.originalWidth / 2) - this.margin.right)
- .attr('y', this.originalHeight - 100)
- .attr('width', 30)
- .attr('height', 80);
-
- axisLabelContainer.append('text')
- .attr('class', 'label-axis-text')
- .attr('x', (this.originalWidth / 2) - this.margin.right)
- .attr('y', this.originalHeight - this.margin.top)
- .attr('dy', '.35em')
- .text('Time');
-
- // Legends
-
- // Metric Usage
- axisLabelContainer.append('rect')
- .attr('x', this.originalWidth - 170)
- .attr('y', (this.originalHeight / 2) - 60)
- .style('fill', graphSpecifics.area_fill_color)
- .attr('width', 20)
- .attr('height', 35);
-
- axisLabelContainer.append('text')
- .attr('class', 'text-metric-title')
- .attr('x', this.originalWidth - 140)
- .attr('y', (this.originalHeight / 2) - 50)
- .text('Average');
-
- axisLabelContainer.append('text')
- .attr('class', 'text-metric-usage')
- .attr('x', this.originalWidth - 140)
- .attr('y', (this.originalHeight / 2) - 25);
- }
-
- handleMouseOverGraph(prometheusGraphContainer) {
- const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`);
- const currentXCoordinate = d3.mouse(rectOverlay)[0];
-
- Object.keys(this.graphSpecificProperties).forEach((key) => {
- const currentGraphProps = this.graphSpecificProperties[key];
- const timeValueOverlay = currentGraphProps.xScale.invert(currentXCoordinate);
- const overlayIndex = bisectDate(currentGraphProps.data, timeValueOverlay, 1);
- const d0 = currentGraphProps.data[overlayIndex - 1];
- const d1 = currentGraphProps.data[overlayIndex];
- const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay;
- const currentData = evalTime ? d1 : d0;
- const currentTimeCoordinate = currentGraphProps.xScale(currentData.time);
- const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`;
- const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value));
- const maxMetricValue = currentGraphProps.yScale(maxValueFromData);
-
- // Clear up all the pieces of the flag
- d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric`).remove();
- d3.selectAll(`${currentPrometheusGraphContainer} .text-metric`).remove();
-
- const currentChart = d3.select(currentPrometheusGraphContainer).select('g');
- currentChart.append('line')
- .attr('class', 'selected-metric-line')
- .attr({
- x1: currentTimeCoordinate,
- y1: currentGraphProps.yScale(0),
- x2: currentTimeCoordinate,
- y2: maxMetricValue,
- });
-
- currentChart.append('circle')
- .attr('class', 'circle-metric')
- .attr('fill', currentGraphProps.line_color)
- .attr('cx', currentTimeCoordinate)
- .attr('cy', currentGraphProps.yScale(currentData.value))
- .attr('r', this.commonGraphProperties.circle_radius_metric);
-
- // The little box with text
- const rectTextMetric = currentChart.append('g')
- .attr('class', 'rect-text-metric')
- .attr('translate', `(${currentTimeCoordinate}, ${currentGraphProps.yScale(currentData.value)})`);
-
- rectTextMetric.append('rect')
- .attr('class', 'rect-metric')
- .attr('x', currentTimeCoordinate + 10)
- .attr('y', maxMetricValue)
- .attr('width', this.commonGraphProperties.rect_text_width)
- .attr('height', this.commonGraphProperties.rect_text_height);
-
- rectTextMetric.append('text')
- .attr('class', 'text-metric')
- .attr('x', currentTimeCoordinate + 35)
- .attr('y', maxMetricValue + 35)
- .text(timeFormat(currentData.time));
-
- rectTextMetric.append('text')
- .attr('class', 'text-metric-date')
- .attr('x', currentTimeCoordinate + 15)
- .attr('y', maxMetricValue + 15)
- .text(dayFormat(currentData.time));
-
- let currentMetricValue = formatRelevantDigits(currentData.value);
- if (key === 'cpu_values') {
- currentMetricValue = `${currentMetricValue}%`;
- } else {
- currentMetricValue = `${currentMetricValue} MB`;
- }
-
- d3.select(`${currentPrometheusGraphContainer} .text-metric-usage`)
- .text(currentMetricValue);
- });
- }
-
- configureGraph() {
- this.graphSpecificProperties = {
- cpu_values: {
- area_fill_color: '#edf3fc',
- line_color: '#5b99f7',
- graph_legend_title: 'CPU Usage (Cores)',
- data: [],
- xScale: {},
- yScale: {},
- },
- memory_values: {
- area_fill_color: '#fca326',
- line_color: '#fc6d26',
- graph_legend_title: 'Memory Usage (MB)',
- data: [],
- xScale: {},
- yScale: {},
- },
- };
-
- this.commonGraphProperties = {
- area_stroke_width: 2,
- median_total_characters: 8,
- circle_radius_metric: 5,
- rect_text_width: 90,
- rect_text_height: 40,
- axis_no_ticks: 3,
- };
- }
-
- getData() {
- const maxNumberOfRequests = 3;
- this.state = '.js-loading';
- this.updateState();
- return gl.utils.backOff((next, stop) => {
- $.ajax({
- url: metricsEndpoint,
- dataType: 'json',
- })
- .done((data, statusText, resp) => {
- if (resp.status === statusCodes.NO_CONTENT) {
- this.backOffRequestCounter = this.backOffRequestCounter += 1;
- if (this.backOffRequestCounter < maxNumberOfRequests) {
- next();
- } else if (this.backOffRequestCounter >= maxNumberOfRequests) {
- stop(new Error('loading'));
- }
- } else if (!data.success) {
- stop(new Error('loading'));
- } else {
- stop({
- status: resp.status,
- metrics: data,
- });
- }
- }).fail(stop);
- })
- .then((resp) => {
- if (resp.status === statusCodes.NO_CONTENT) {
- return {};
- }
- return resp.metrics;
- })
- .catch(() => {
- const prevState = this.state;
- this.state = '.js-unable-to-connect';
- this.updateState(prevState);
- });
- }
-
- transformData(metricsResponse) {
- Object.keys(metricsResponse.metrics).forEach((key) => {
- if (key === 'cpu_values' || key === 'memory_values') {
- const metricValues = (metricsResponse.metrics[key])[0];
- this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
- time: new Date(metric[0] * 1000),
- value: metric[1],
- }));
- }
- });
- }
-
- updateState(prevState) {
- const $statesContainer = $(prometheusStatesContainer);
- $(prometheusParentGraphContainer).hide();
- if (prevState) {
- $(`${prevState}`, $statesContainer).addClass('hidden');
- }
- $(`${this.state}`, $statesContainer).removeClass('hidden');
- $(prometheusStatesContainer).show();
- }
-}
-
-export default PrometheusGraph;
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
new file mode 100644
index 00000000000..1e9ae934853
--- /dev/null
+++ b/app/assets/javascripts/monitoring/services/monitoring_service.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class MonitoringService {
+ constructor(endpoint) {
+ this.graphs = Vue.resource(endpoint);
+ }
+
+ get() {
+ return this.graphs.get();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ getDeploymentData(endpoint) {
+ return Vue.http.get(endpoint);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
new file mode 100644
index 00000000000..737c964f12e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -0,0 +1,61 @@
+import _ from 'underscore';
+
+class MonitoringStore {
+ constructor() {
+ this.groups = [];
+ this.deploymentData = [];
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ createArrayRows(metrics = []) {
+ const currentMetrics = metrics;
+ const availableMetrics = [];
+ let metricsRow = [];
+ let index = 1;
+ Object.keys(currentMetrics).forEach((key) => {
+ const metricValues = currentMetrics[key].queries[0].result[0].values;
+ if (metricValues != null) {
+ const literalMetrics = metricValues.map(metric => ({
+ time: new Date(metric[0] * 1000),
+ value: metric[1],
+ }));
+ currentMetrics[key].queries[0].result[0].values = literalMetrics;
+ metricsRow.push(currentMetrics[key]);
+ if (index % 2 === 0) {
+ availableMetrics.push(metricsRow);
+ metricsRow = [];
+ }
+ index = index += 1;
+ }
+ });
+ if (metricsRow.length > 0) {
+ availableMetrics.push(metricsRow);
+ }
+ return availableMetrics;
+ }
+
+ storeMetrics(groups = []) {
+ this.groups = groups.map((group) => {
+ const currentGroup = group;
+ currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value();
+ currentGroup.metrics = this.createArrayRows(currentGroup.metrics);
+ return currentGroup;
+ });
+ }
+
+ storeDeploymentData(deploymentData = []) {
+ this.deploymentData = deploymentData;
+ }
+
+ getMetricsCount() {
+ let metricsCount = 0;
+ this.groups.forEach((group) => {
+ group.metrics.forEach((metric) => {
+ metricsCount = metricsCount += metric.length;
+ });
+ });
+ return metricsCount;
+ }
+}
+
+export default MonitoringStore;
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
new file mode 100644
index 00000000000..62cd19c86e1
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils/measurements.js
@@ -0,0 +1,40 @@
+export default {
+ small: { // Covers both xs and sm screen sizes
+ margin: {
+ top: 40,
+ right: 40,
+ bottom: 50,
+ left: 40,
+ },
+ legends: {
+ width: 15,
+ height: 25,
+ },
+ backgroundLegend: {
+ width: 30,
+ height: 50,
+ },
+ axisLabelLineOffset: -20,
+ legendOffset: 35,
+ },
+ large: { // This covers both md and lg screen sizes
+ margin: {
+ top: 80,
+ right: 80,
+ bottom: 100,
+ left: 80,
+ },
+ legends: {
+ width: 20,
+ height: 30,
+ },
+ backgroundLegend: {
+ width: 30,
+ height: 150,
+ },
+ axisLabelLineOffset: 20,
+ legendOffset: 38,
+ },
+ xTicks: 8,
+ yTicks: 3,
+};
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index b98e6121967..5da2db063a4 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */
-/* global Api */
+import Api from './api';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
window.NamespaceSelect = (function() {
function NamespaceSelect(opts) {
- this.onSelectItem = bind(this.onSelectItem, this);
+ this.onSelectItem = this.onSelectItem.bind(this);
var fieldName, showAny;
this.dropdown = opts.dropdown;
showAny = true;
@@ -58,7 +56,8 @@
});
}
- NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
+ NamespaceSelect.prototype.onSelectItem = function(options) {
+ const { e } = options;
return e.preventDefault();
};
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 5828f460a23..39fb302b644 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,15 +1,14 @@
/* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
+import RefSelectDropdown from '~/ref_select_dropdown';
+(function() {
this.NewBranchForm = (function() {
function NewBranchForm(form, availableRefs) {
- this.validate = bind(this.validate, this);
+ this.validate = this.validate.bind(this);
this.branchNameError = form.find('.js-branch-name-error');
this.name = form.find('.js-branch-name');
this.ref = form.find('#ref');
- this.setupAvailableRefs(availableRefs);
+ new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
this.setupRestrictions();
this.addBinding();
this.init();
@@ -25,33 +24,6 @@
}
};
- NewBranchForm.prototype.setupAvailableRefs = function(availableRefs) {
- var $branchSelect = $('.js-branch-select');
-
- $branchSelect.glDropdown({
- data: availableRefs,
- filterable: true,
- filterByText: true,
- remote: false,
- fieldName: $branchSelect.data('field-name'),
- selectable: true,
- isSelectable: function(branch, $el) {
- return !$el.hasClass('is-active');
- },
- text: function(branch) {
- return branch;
- },
- id: function(branch) {
- return branch;
- },
- toggleLabel: function(branch) {
- if (branch) {
- return branch;
- }
- }
- });
- };
-
NewBranchForm.prototype.setupRestrictions = function() {
var endsWith, invalid, single, startsWith;
startsWith = {
@@ -79,6 +51,8 @@
NewBranchForm.prototype.validate = function() {
var errorMessage, errors, formatter, unique, validator;
+ const indexOf = [].indexOf;
+
this.branchNameError.empty();
unique = function(values, value) {
if (indexOf.call(values, value) === -1) {
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index ad36f08840d..04073ef7270 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,25 +1,20 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NewCommitForm = (function() {
- function NewCommitForm(form, targetBranchName = 'target_branch') {
+ function NewCommitForm(form) {
this.form = form;
- this.targetBranchName = targetBranchName;
- this.renderDestination = bind(this.renderDestination, this);
- this.targetBranchDropdown = form.find('button.js-target-branch');
+ this.renderDestination = this.renderDestination.bind(this);
+ this.branchName = form.find('.js-branch-name');
this.originalBranch = form.find('.js-original-branch');
this.createMergeRequest = form.find('.js-create-merge-request');
this.createMergeRequestContainer = form.find('.js-create-merge-request-container');
- this.targetBranchDropdown.on('change.branch', this.renderDestination);
+ this.branchName.keyup(this.renderDestination);
this.renderDestination();
}
NewCommitForm.prototype.renderDestination = function() {
var different;
- var targetBranch = this.form.find(`input[name="${this.targetBranchName}"]`);
-
- different = targetBranch.val() !== this.originalBranch.val();
+ different = this.branchName.val() !== this.originalBranch.val();
if (different) {
this.createMergeRequestContainer.show();
if (!this.wasDifferent) {
diff --git a/app/assets/javascripts/new_sidebar.js b/app/assets/javascripts/new_sidebar.js
new file mode 100644
index 00000000000..2d1ed9e4076
--- /dev/null
+++ b/app/assets/javascripts/new_sidebar.js
@@ -0,0 +1,66 @@
+import Cookies from 'js-cookie';
+import _ from 'underscore';
+import bp from './breakpoints';
+
+export default class NewNavSidebar {
+ constructor() {
+ this.initDomElements();
+ this.render();
+ }
+
+ initDomElements() {
+ this.$page = $('.page-with-sidebar');
+ this.$sidebar = $('.nav-sidebar');
+ this.$overlay = $('.mobile-overlay');
+ this.$openSidebar = $('.toggle-mobile-nav');
+ this.$closeSidebar = $('.close-nav-button');
+ this.$sidebarToggle = $('.js-toggle-sidebar');
+ }
+
+ bindEvents() {
+ this.$openSidebar.on('click', () => this.toggleSidebarNav(true));
+ this.$closeSidebar.on('click', () => this.toggleSidebarNav(false));
+ this.$overlay.on('click', () => this.toggleSidebarNav(false));
+ this.$sidebarToggle.on('click', () => {
+ const value = !this.$sidebar.hasClass('sidebar-icons-only');
+ this.toggleCollapsedSidebar(value);
+ });
+
+ $(window).on('resize', () => _.debounce(this.render(), 100));
+ }
+
+ static setCollapsedCookie(value) {
+ if (bp.getBreakpointSize() !== 'lg') {
+ return;
+ }
+ Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
+ }
+
+ toggleSidebarNav(show) {
+ this.$sidebar.toggleClass('nav-sidebar-expanded', show);
+ this.$overlay.toggleClass('mobile-nav-open', show);
+ this.$sidebar.removeClass('sidebar-icons-only');
+ }
+
+ toggleCollapsedSidebar(collapsed) {
+ const breakpoint = bp.getBreakpointSize();
+
+ if (this.$sidebar.length) {
+ this.$sidebar.toggleClass('sidebar-icons-only', collapsed);
+ this.$page.toggleClass('page-with-new-sidebar', !collapsed);
+ this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
+ }
+ NewNavSidebar.setCollapsedCookie(collapsed);
+ }
+
+ render() {
+ const breakpoint = bp.getBreakpointSize();
+
+ if (breakpoint === 'sm' || breakpoint === 'md') {
+ this.toggleCollapsedSidebar(true);
+ } else if (breakpoint === 'lg') {
+ const collapse = Cookies.get('sidebar_collapsed') === 'true';
+ this.toggleCollapsedSidebar(collapse);
+ }
+ }
+}
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3e8240d10ec..814d2ea92b4 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -30,7 +30,7 @@
|
\\s\\$(?!\\$)
)
- (.+?)
+ ((.|\\n)+?)
(
\\s\\\\end{[a-zA-Z]+}$
|
@@ -45,15 +45,25 @@
let inline = false;
if (typeof katex !== 'undefined') {
- const katexString = text.replace(/\\/g, '\\');
- const matches = new RegExp(katexRegexString, 'gi').exec(katexString);
+ const katexString = text.replace(/&amp;/g, '&')
+ .replace(/&=&/g, '\\space=\\space')
+ .replace(/<(\/?)em>/g, '_');
+ const regex = new RegExp(katexRegexString, 'gi');
+ const matchLocation = katexString.search(regex);
+ const numberOfMatches = katexString.match(regex);
- if (matches && matches.length > 0) {
- if (matches[1].trim() === '$' && matches[3].trim() === '$') {
+ if (numberOfMatches && numberOfMatches.length !== 0) {
+ if (matchLocation > 0) {
+ let matches = regex.exec(katexString);
inline = true;
- text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`;
+ while (matches !== null) {
+ const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ matches = regex.exec(katexString);
+ }
} else {
+ const matches = regex.exec(katexString);
text = katex.renderToString(matches[2]);
}
}
@@ -79,7 +89,7 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join(''));
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 974fb0d83da..b38a6abc8d1 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,1076 +1,1531 @@
-/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */
+/* eslint-disable no-restricted-properties, func-names, space-before-function-paren,
+no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase,
+no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line,
+default-case, prefer-template, consistent-return, no-alert, no-return-assign,
+no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new,
+brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow,
+newline-per-chained-call, no-useless-escape, class-methods-use-this */
/* global Flash */
/* global Autosave */
/* global ResolveService */
/* global mrRefreshWidgetUrl */
+import $ from 'jquery';
+import _ from 'underscore';
import Cookies from 'js-cookie';
+import autosize from 'vendor/autosize';
+import Dropzone from 'dropzone';
+import 'vendor/jquery.caret'; // required by jquery.atwho
+import 'vendor/jquery.atwho';
+import AjaxCache from '~/lib/utils/ajax_cache';
import CommentTypeToggle from './comment_type_toggle';
-
-require('./autosave');
-window.autosize = require('vendor/autosize');
-window.Dropzone = require('dropzone');
-require('./dropzone_input');
-require('./gfm_auto_complete');
-require('vendor/jquery.caret'); // required by jquery.atwho
-require('vendor/jquery.atwho');
-require('./task_list');
-
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
- this.Notes = (function() {
- const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
-
- Notes.interval = null;
-
- function Notes(notes_url, note_ids, last_fetched_at, view) {
- this.updateTargetButtons = bind(this.updateTargetButtons, this);
- this.updateCloseButton = bind(this.updateCloseButton, this);
- this.visibilityChange = bind(this.visibilityChange, this);
- this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this);
- this.addDiffNote = bind(this.addDiffNote, this);
- this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this);
- this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this);
- this.removeNote = bind(this.removeNote, this);
- this.cancelEdit = bind(this.cancelEdit, this);
- this.updateNote = bind(this.updateNote, this);
- this.addDiscussionNote = bind(this.addDiscussionNote, this);
- this.addNoteError = bind(this.addNoteError, this);
- this.addNote = bind(this.addNote, this);
- this.resetMainTargetForm = bind(this.resetMainTargetForm, this);
- this.refresh = bind(this.refresh, this);
- this.keydownNoteText = bind(this.keydownNoteText, this);
- this.toggleCommitList = bind(this.toggleCommitList, this);
- this.notes_url = notes_url;
- this.note_ids = note_ids;
- this.last_fetched_at = last_fetched_at;
- this.noteable_url = document.URL;
- this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge"));
- this.basePollingInterval = 15000;
- this.maxPollingSteps = 4;
- this.cleanBinding();
- this.addBinding();
- this.setPollingInterval();
- this.setupMainTargetNoteForm();
- this.taskList = new gl.TaskList({
- dataType: 'note',
- fieldName: 'note',
- selector: '.notes'
- });
- this.collapseLongCommitList();
- this.setViewType(view);
-
- // We are in the Merge Requests page so we need another edit form for Changes tab
- if (gl.utils.getPagePath(1) === 'merge_requests') {
- $('.note-edit-form').clone()
- .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
- }
+import loadAwardsHandler from './awards_handler';
+import './autosave';
+import './dropzone_input';
+import TaskList from './task_list';
+
+window.autosize = autosize;
+window.Dropzone = Dropzone;
+
+function normalizeNewlines(str) {
+ return str.replace(/\r\n/g, '\n');
+}
+
+const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+
+export default class Notes {
+ constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
+ this.updateTargetButtons = this.updateTargetButtons.bind(this);
+ this.updateComment = this.updateComment.bind(this);
+ this.visibilityChange = this.visibilityChange.bind(this);
+ this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
+ this.onAddDiffNote = this.onAddDiffNote.bind(this);
+ this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
+ this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
+ this.removeNote = this.removeNote.bind(this);
+ this.cancelEdit = this.cancelEdit.bind(this);
+ this.updateNote = this.updateNote.bind(this);
+ this.addDiscussionNote = this.addDiscussionNote.bind(this);
+ this.addNoteError = this.addNoteError.bind(this);
+ this.addNote = this.addNote.bind(this);
+ this.resetMainTargetForm = this.resetMainTargetForm.bind(this);
+ this.refresh = this.refresh.bind(this);
+ this.keydownNoteText = this.keydownNoteText.bind(this);
+ this.toggleCommitList = this.toggleCommitList.bind(this);
+ this.postComment = this.postComment.bind(this);
+ this.clearFlashWrapper = this.clearFlash.bind(this);
+ this.onHashChange = this.onHashChange.bind(this);
+
+ this.notes_url = notes_url;
+ this.note_ids = note_ids;
+ this.enableGFM = enableGFM;
+ // Used to keep track of updated notes while people are editing things
+ this.updatedNotesTrackingMap = {};
+ this.last_fetched_at = last_fetched_at;
+ this.noteable_url = document.URL;
+ this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
+ this.basePollingInterval = 15000;
+ this.maxPollingSteps = 4;
+
+ this.cleanBinding();
+ this.addBinding();
+ this.setPollingInterval();
+ this.setupMainTargetNoteForm();
+ this.taskList = new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes'
+ });
+ this.collapseLongCommitList();
+ this.setViewType(view);
+
+ // We are in the Merge Requests page so we need another edit form for Changes tab
+ if (gl.utils.getPagePath(1) === 'merge_requests') {
+ $('.note-edit-form').clone()
+ .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
+ }
+ }
+
+ setViewType(view) {
+ this.view = Cookies.get('diff_view') || view;
+ }
+
+ addBinding() {
+ // Edit note link
+ $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
+ $(document).on('click', '.note-edit-cancel', this.cancelEdit);
+ // Reopen and close actions for Issue/MR combined with note form submit
+ $(document).on('click', '.js-comment-submit-button', this.postComment);
+ $(document).on('click', '.js-comment-save-button', this.updateComment);
+ $(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
+ // resolve a discussion
+ $(document).on('click', '.js-comment-resolve-button', this.postComment);
+ // remove a note (in general)
+ $(document).on('click', '.js-note-delete', this.removeNote);
+ // delete note attachment
+ $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
+ // reset main target form when clicking discard
+ $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
+ // update the file name when an attachment is selected
+ $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
+ // reply to diff/discussion notes
+ $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
+ // add diff note
+ $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
+ // hide diff note form
+ $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
+ // toggle commit list
+ $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
+ // fetch notes when tab becomes visible
+ $(document).on('visibilitychange', this.visibilityChange);
+ // when issue status changes, we need to refresh data
+ $(document).on('issuable:change', this.refresh);
+ // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
+ $(document).on('ajax:success', '.js-main-target-form', this.addNote);
+ $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
+ $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
+ $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
+ // when a key is clicked on the notes
+ $(document).on('keydown', '.js-note-text', this.keydownNoteText);
+ // When the URL fragment/hash has changed, `#note_xxx`
+ return $(window).on('hashchange', this.onHashChange);
+ }
+
+ cleanBinding() {
+ $(document).off('click', '.js-note-edit');
+ $(document).off('click', '.note-edit-cancel');
+ $(document).off('click', '.js-note-delete');
+ $(document).off('click', '.js-note-attachment-delete');
+ $(document).off('click', '.js-discussion-reply-button');
+ $(document).off('click', '.js-add-diff-note-button');
+ $(document).off('visibilitychange');
+ $(document).off('keyup input', '.js-note-text');
+ $(document).off('click', '.js-note-target-reopen');
+ $(document).off('click', '.js-note-target-close');
+ $(document).off('click', '.js-note-discard');
+ $(document).off('keydown', '.js-note-text');
+ $(document).off('click', '.js-comment-resolve-button');
+ $(document).off('click', '.system-note-commit-list-toggler');
+ $(document).off('ajax:success', '.js-main-target-form');
+ $(document).off('ajax:success', '.js-discussion-note-form');
+ $(document).off('ajax:complete', '.js-main-target-form');
+ $(window).off('hashchange', this.onHashChange);
+ }
+
+ static initCommentTypeToggle(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();
+ }
+
+ keydownNoteText(e) {
+ var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
+ if (gl.utils.isMetaKey(e)) {
+ return;
}
- Notes.prototype.setViewType = function(view) {
- this.view = Cookies.get('diff_view') || view;
- };
-
- Notes.prototype.addBinding = function() {
- // add note to UI after creation
- $(document).on("ajax:success", ".js-main-target-form", this.addNote);
- $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
- // catch note ajax errors
- $(document).on("ajax:error", ".js-main-target-form", this.addNoteError);
- // change note in UI after update
- $(document).on("ajax:success", "form.edit-note", this.updateNote);
- // Edit note link
- $(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
- $(document).on("click", ".note-edit-cancel", this.cancelEdit);
- // Reopen and close actions for Issue/MR combined with note form submit
- $(document).on("click", ".js-comment-button", this.updateCloseButton);
- $(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
- // resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
- // remove a note (in general)
- $(document).on("click", ".js-note-delete", this.removeNote);
- // delete note attachment
- $(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
- // reset main target form after submit
- $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
- $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
- // reset main target form when clicking discard
- $(document).on("click", ".js-note-discard", this.resetMainTargetForm);
- // update the file name when an attachment is selected
- $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment);
- // reply to diff/discussion notes
- $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote);
- // add diff note
- $(document).on("click", ".js-add-diff-note-button", this.addDiffNote);
- // hide diff note form
- $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm);
- // toggle commit list
- $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList);
- // fetch notes when tab becomes visible
- $(document).on("visibilitychange", this.visibilityChange);
- // when issue status changes, we need to refresh data
- $(document).on("issuable:change", this.refresh);
- // when a key is clicked on the notes
- return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
- };
-
- Notes.prototype.cleanBinding = function() {
- $(document).off("ajax:success", ".js-main-target-form");
- $(document).off("ajax:success", ".js-discussion-note-form");
- $(document).off("ajax:success", "form.edit-note");
- $(document).off("click", ".js-note-edit");
- $(document).off("click", ".note-edit-cancel");
- $(document).off("click", ".js-note-delete");
- $(document).off("click", ".js-note-attachment-delete");
- $(document).off("ajax:complete", ".js-main-target-form");
- $(document).off("ajax:success", ".js-main-target-form");
- $(document).off("click", ".js-discussion-reply-button");
- $(document).off("click", ".js-add-diff-note-button");
- $(document).off("visibilitychange");
- $(document).off("keyup", ".js-note-text");
- $(document).off("click", ".js-note-target-reopen");
- $(document).off("click", ".js-note-target-close");
- $(document).off("click", ".js-note-discard");
- $(document).off("keydown", ".js-note-text");
- $(document).off('click', '.js-comment-resolve-button');
- $(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)) {
- return;
- }
-
- $textarea = $(e.target);
- // Edit previous note when UP arrow is hit
- switch (e.which) {
- case 38:
+ $textarea = $(e.target);
+ // Edit previous note when UP arrow is hit
+ switch (e.which) {
+ case 38:
+ if ($textarea.val() !== '') {
+ return;
+ }
+ myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes'));
+ if (myLastNote.length) {
+ myLastNoteEditBtn = myLastNote.find('.js-note-edit');
+ return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
+ }
+ break;
+ // Cancel creating diff note or editing any note when ESCAPE is hit
+ case 27:
+ discussionNoteForm = $textarea.closest('.js-discussion-note-form');
+ if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- return;
- }
- myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last");
- if (myLastNote.length) {
- myLastNoteEditBtn = myLastNote.find('.js-note-edit');
- return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
- }
- break;
- // Cancel creating diff note or editing any note when ESCAPE is hit
- case 27:
- discussionNoteForm = $textarea.closest('.js-discussion-note-form');
- if (discussionNoteForm.length) {
- if ($textarea.val() !== '') {
- if (!confirm('Are you sure you want to cancel creating this comment?')) {
- return;
- }
+ if (!confirm('Are you sure you want to cancel creating this comment?')) {
+ return;
}
- this.removeDiscussionNoteForm(discussionNoteForm);
- return;
}
- editNote = $textarea.closest('.note');
- if (editNote.length) {
- originalText = $textarea.closest('form').data('original-note');
- newText = $textarea.val();
- if (originalText !== newText) {
- if (!confirm('Are you sure you want to cancel editing this comment?')) {
- return;
- }
+ this.removeDiscussionNoteForm(discussionNoteForm);
+ return;
+ }
+ editNote = $textarea.closest('.note');
+ if (editNote.length) {
+ originalText = $textarea.closest('form').data('original-note');
+ newText = $textarea.val();
+ if (originalText !== newText) {
+ if (!confirm('Are you sure you want to cancel editing this comment?')) {
+ return;
}
- return this.removeNoteEditForm(editNote);
}
- }
- };
+ return this.removeNoteEditForm(editNote);
+ }
+ }
+ }
- Notes.prototype.initRefresh = function() {
+ initRefresh() {
+ if (Notes.interval) {
clearInterval(Notes.interval);
- return Notes.interval = setInterval((function(_this) {
- return function() {
- return _this.refresh();
- };
- })(this), this.pollingInterval);
- };
-
- Notes.prototype.refresh = function() {
- if (!document.hidden) {
- return this.getContent();
- }
- };
+ }
+ return Notes.interval = setInterval((function(_this) {
+ return function() {
+ return _this.refresh();
+ };
+ })(this), this.pollingInterval);
+ }
+
+ refresh() {
+ if (!document.hidden) {
+ return this.getContent();
+ }
+ }
- Notes.prototype.getContent = function() {
- if (this.refreshing) {
- return;
- }
- this.refreshing = true;
- return $.ajax({
- url: this.notes_url,
- headers: { "X-Last-Fetched-At": this.last_fetched_at },
- dataType: "json",
- success: (function(_this) {
- return function(data) {
- var notes;
- notes = data.notes;
- _this.last_fetched_at = data.last_fetched_at;
- _this.setPollingInterval(data.notes.length);
- return $.each(notes, function(i, note) {
- _this.renderNote(note);
- });
- };
- })(this)
- }).always((function(_this) {
- return function() {
- return _this.refreshing = false;
+ getContent() {
+ if (this.refreshing) {
+ return;
+ }
+ this.refreshing = true;
+ return $.ajax({
+ url: this.notes_url,
+ headers: { 'X-Last-Fetched-At': this.last_fetched_at },
+ dataType: 'json',
+ success: (function(_this) {
+ return function(data) {
+ var notes;
+ notes = data.notes;
+ _this.last_fetched_at = data.last_fetched_at;
+ _this.setPollingInterval(data.notes.length);
+ return $.each(notes, function(i, note) {
+ _this.renderNote(note);
+ });
};
- })(this));
- };
-
- /*
- Increase @pollingInterval up to 120 seconds on every function call,
- if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
- will reset to @basePollingInterval.
-
- Note: this function is used to gradually increase the polling interval
- if there aren't new notes coming from the server
- */
-
- Notes.prototype.setPollingInterval = function(shouldReset) {
- var nthInterval;
- if (shouldReset == null) {
- shouldReset = true;
- }
- nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
- if (shouldReset) {
- this.pollingInterval = this.basePollingInterval;
- } else if (this.pollingInterval < nthInterval) {
- this.pollingInterval *= 2;
- }
- return this.initRefresh();
- };
-
- Notes.prototype.handleCreateChanges = function(note) {
- var votesBlock;
- if (typeof note === 'undefined') {
- return;
+ })(this)
+ }).always((function(_this) {
+ return function() {
+ return _this.refreshing = false;
+ };
+ })(this));
+ }
+
+ /**
+ * Increase @pollingInterval up to 120 seconds on every function call,
+ * if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
+ * will reset to @basePollingInterval.
+ *
+ * Note: this function is used to gradually increase the polling interval
+ * if there aren't new notes coming from the server
+ */
+ setPollingInterval(shouldReset) {
+ var nthInterval;
+ if (shouldReset == null) {
+ shouldReset = true;
+ }
+ nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ if (shouldReset) {
+ this.pollingInterval = this.basePollingInterval;
+ } else if (this.pollingInterval < nthInterval) {
+ this.pollingInterval *= 2;
+ }
+ return this.initRefresh();
+ }
+
+ handleQuickActions(noteEntity) {
+ var votesBlock;
+ if (noteEntity.commands_changes) {
+ if ('merge' in noteEntity.commands_changes) {
+ Notes.checkMergeRequestStatus();
}
- if (note.commands_changes) {
- if ('merge' in note.commands_changes) {
- $.get(mrRefreshWidgetUrl);
- }
+ if ('emoji_award' in noteEntity.commands_changes) {
+ votesBlock = $('.js-awards-block').eq(0);
- if ('emoji_award' in note.commands_changes) {
- votesBlock = $('.js-awards-block').eq(0);
- gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award);
- return gl.awardsHandler.scrollToAwards();
- }
+ loadAwardsHandler().then((awardsHandler) => {
+ awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
+ awardsHandler.scrollToAwards();
+ }).catch(() => {
+ // ignore
+ });
}
- };
+ }
+ }
- /*
- Render note in main comments area.
+ setupNewNote($note) {
+ // Update datetime format on the recent note
+ gl.utils.localTimeAgo($note.find('.js-timeago'), false);
- Note: for rendering inline notes use renderDiscussionNote
- */
+ this.collapseLongCommitList();
+ this.taskList.init();
- Notes.prototype.renderNote = function(note, $form) {
- var $notesList;
- if (note.discussion_html != null) {
- return this.renderDiscussionNote(note, $form);
- }
+ // This stops the note highlight, #note_xxx`, from being removed after real time update
+ // The `:target` selector does not re-evaluate after we replace element in the DOM
+ Notes.updateNoteTargetSelector($note);
+ this.$noteToCleanHighlight = $note;
+ }
- if (!note.valid) {
- if (note.errors.commands_only) {
- new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
- this.refresh();
- }
- return;
- }
-
- if (this.isNewNote(note)) {
- this.note_ids.push(note.id);
+ onHashChange() {
+ if (this.$noteToCleanHighlight) {
+ Notes.updateNoteTargetSelector(this.$noteToCleanHighlight);
+ }
- $notesList = window.$('ul.main-notes-list');
- Notes.animateAppendNote(note.html, $notesList);
+ this.$noteToCleanHighlight = null;
+ }
+
+ static updateNoteTargetSelector($note) {
+ const hash = gl.utils.getLocationHash();
+ // Needs to be an explicit true/false for the jQuery `toggleClass(force)`
+ const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0);
+ $note.toggleClass('target', addTargetClass);
+ }
+
+ /**
+ * Render note in main comments area.
+ *
+ * Note: for rendering inline notes use renderDiscussionNote
+ */
+ renderNote(noteEntity, $form, $notesList = $('.main-notes-list')) {
+ if (noteEntity.discussion_html) {
+ return this.renderDiscussionNote(noteEntity, $form);
+ }
- // Update datetime format on the recent note
- gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
- this.collapseLongCommitList();
- this.taskList.init();
+ if (!noteEntity.valid) {
+ if (noteEntity.errors.commands_only) {
+ if (noteEntity.commands_changes &&
+ Object.keys(noteEntity.commands_changes).length > 0) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
+ this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
- return this.updateNotesCount(1);
}
- };
-
- /*
- Check if note does not exists on page
- */
-
- Notes.prototype.isNewNote = function(note) {
- return $.inArray(note.id, this.note_ids) === -1;
- };
-
- Notes.prototype.isParallelView = function() {
- return Cookies.get('diff_view') === 'parallel';
- };
-
- /*
- Render note in discussion area.
+ return;
+ }
- Note: for rendering inline notes use renderDiscussionNote
- */
+ const $note = $notesList.find(`#note_${noteEntity.id}`);
+ if (Notes.isNewNote(noteEntity, this.note_ids)) {
+ this.note_ids.push(noteEntity.id);
- Notes.prototype.renderDiscussionNote = function(note, $form) {
- var discussionContainer, form, row, lineType, diffAvatarContainer;
- if (!this.isNewNote(note)) {
- return;
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
}
- this.note_ids.push(note.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');
- // is this the first note of discussion?
- discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`);
- if (!discussionContainer.length) {
- discussionContainer = form.closest('.discussion').find('.notes');
- }
- if (discussionContainer.length === 0) {
- if (note.diff_discussion_html) {
- var $discussion = $(note.diff_discussion_html).renderGFM();
+ const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
- 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());
- }
- }
- // Init discussion on 'Discussion' page if it is merge request page
- if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
- Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list'));
- }
- } else {
- // append new note to all matching discussions
- Notes.animateAppendNote(note.html, discussionContainer);
+ this.setupNewNote($newNote);
+ this.refresh();
+ return this.updateNotesCount(1);
+ }
+ // The server can send the same update multiple times so we need to make sure to only update once per actual update.
+ else if (Notes.isUpdatedNote(noteEntity, $note)) {
+ const isEditing = $note.hasClass('is-editing');
+ const initialContent = normalizeNewlines(
+ $note.find('.original-note-content').text().trim()
+ );
+ const $textarea = $note.find('.js-note-text');
+ const currentContent = $textarea.val();
+ // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
+ const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
+ const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote;
+
+ if (isEditing && isTextareaUntouched) {
+ $textarea.val(noteEntity.note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
}
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) {
- gl.diffNotesCompileComponents();
- this.renderDiscussionAvatar(diffAvatarContainer, note);
+ else if (isEditing && !isTextareaUntouched) {
+ this.putConflictEditWarningInPlace(noteEntity, $note);
+ this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
}
-
- gl.utils.localTimeAgo($('.js-timeago'), false);
- return this.updateNotesCount(1);
- };
-
- Notes.prototype.getLineHolder = function(changesDiscussionContainer) {
- return $(changesDiscussionContainer).closest('.notes_holder')
- .prevAll('.line_holder')
- .first()
- .get(0);
- };
-
- Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) {
- var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
- var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
-
- if (!avatarHolder.length) {
- avatarHolder = document.createElement('diff-note-avatars');
- avatarHolder.setAttribute('discussion-id', note.discussion_id);
-
- diffAvatarContainer.append(avatarHolder);
-
- gl.diffNotesCompileComponents();
+ else {
+ const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
+ this.setupNewNote($updatedNote);
}
+ }
+ }
+
+ isParallelView() {
+ return Cookies.get('diff_view') === 'parallel';
+ }
+
+ /**
+ * Render note in discussion area.
+ *
+ * Note: for rendering inline notes use renderDiscussionNote
+ */
+ renderDiscussionNote(noteEntity, $form) {
+ var discussionContainer, form, row, lineType, diffAvatarContainer;
+ if (!Notes.isNewNote(noteEntity, this.note_ids)) {
+ return;
+ }
+ this.note_ids.push(noteEntity.id);
+ form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.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');
+ // is this the first note of discussion?
+ discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
+ if (!discussionContainer.length) {
+ discussionContainer = form.closest('.discussion').find('.notes');
+ }
+ if (discussionContainer.length === 0) {
+ if (noteEntity.diff_discussion_html) {
+ var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
- if (commentButton.length) {
- commentButton.remove();
+ 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="${noteEntity.discussion_id}"]`);
+ var contentContainerClass = '.' + $notes.closest('.notes_content')
+ .attr('class')
+ .split(' ')
+ .join('.');
+
+ row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ }
}
- };
-
- /*
- Called in response the main target form has been successfully submitted.
-
- Removes any errors.
- Resets text and preview.
- Resets buttons.
- */
-
- Notes.prototype.resetMainTargetForm = function(e) {
- var form;
- form = $(".js-main-target-form");
- // remove validation errors
- form.find(".js-errors").remove();
- // reset text and preview
- form.find(".js-md-write-button").click();
- form.find(".js-note-text").val("").trigger("input");
- form.find(".js-note-text").data("autosave").reset();
-
- var event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- form.find('.js-autosize')[0].dispatchEvent(event);
-
- this.updateTargetButtons(e);
- };
-
- Notes.prototype.reenableTargetFormSubmitButton = function() {
- var form;
- form = $(".js-main-target-form");
- return form.find(".js-note-text").trigger("input");
- };
-
- /*
- Shows the main form and does some setup on it.
-
- Sets some hidden fields in the form.
- */
-
- Notes.prototype.setupMainTargetNoteForm = function() {
- var form;
- // find the form
- form = $(".js-new-note-form");
- // Set a global clone of the form for later cloning
- this.formClone = form.clone();
- // show the form
- this.setupNoteForm(form);
- // fix classes
- form.removeClass("js-new-note-form");
- form.addClass("js-main-target-form");
- form.find("#note_line_code").remove();
- form.find("#note_position").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();
- this.parentTimeline = form.parents('.timeline');
-
- if (form.length) {
- Notes.initCommentTypeToggle(form.get(0));
+ // Init discussion on 'Discussion' page if it is merge request page
+ const page = $('body').attr('data-page');
+ if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
+ Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
- };
+ } else {
+ // append new note to all matching discussions
+ Notes.animateAppendNote(noteEntity.html, discussionContainer);
+ }
- /*
- General note form setup.
-
- deactivates the submit button when text is empty
- hides the preview button when text is empty
- setup GFM auto complete
- show the form
- */
-
- Notes.prototype.setupNoteForm = function(form) {
- var textarea, key;
- new gl.GLForm(form);
- textarea = form.find(".js-note-text");
- 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);
- };
+ if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
+ gl.diffNotesCompileComponents();
+ this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
+ }
- /*
- Called in response to the new note form being submitted
+ gl.utils.localTimeAgo($('.js-timeago'), false);
+ Notes.checkMergeRequestStatus();
+ return this.updateNotesCount(1);
+ }
- Adds new note to list.
- */
+ getLineHolder(changesDiscussionContainer) {
+ return $(changesDiscussionContainer).closest('.notes_holder')
+ .prevAll('.line_holder')
+ .first()
+ .get(0);
+ }
- Notes.prototype.addNote = function(xhr, note, status) {
- this.handleCreateChanges(note);
- return this.renderNote(note);
- };
+ renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
+ var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
+ var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
- Notes.prototype.addNoteError = function(xhr, note, status) {
- return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline);
- };
+ if (!avatarHolder.length) {
+ avatarHolder = document.createElement('diff-note-avatars');
+ avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
- /*
- Called in response to the new note form being submitted
+ diffAvatarContainer.append(avatarHolder);
- Adds new note to list.
- */
+ gl.diffNotesCompileComponents();
+ }
- Notes.prototype.addDiscussionNote = function(xhr, note, status) {
- var $form = $(xhr.target);
+ if (commentButton.length) {
+ commentButton.remove();
+ }
+ }
+
+ /**
+ * Called in response the main target form has been successfully submitted.
+ *
+ * Removes any errors.
+ * Resets text and preview.
+ * Resets buttons.
+ */
+ resetMainTargetForm(e) {
+ var form;
+ form = $('.js-main-target-form');
+ // remove validation errors
+ form.find('.js-errors').remove();
+ // reset text and preview
+ form.find('.js-md-write-button').click();
+ form.find('.js-note-text').val('').trigger('input');
+ form.find('.js-note-text').data('autosave').reset();
+
+ var event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ form.find('.js-autosize')[0].dispatchEvent(event);
+
+ this.updateTargetButtons(e);
+ }
+
+ reenableTargetFormSubmitButton() {
+ var form;
+ form = $('.js-main-target-form');
+ return form.find('.js-note-text').trigger('input');
+ }
+
+ /**
+ * Shows the main form and does some setup on it.
+ *
+ * Sets some hidden fields in the form.
+ */
+ setupMainTargetNoteForm() {
+ var form;
+ // find the form
+ form = $('.js-new-note-form');
+ // Set a global clone of the form for later cloning
+ this.formClone = form.clone();
+ // show the form
+ this.setupNoteForm(form);
+ // fix classes
+ form.removeClass('js-new-note-form');
+ form.addClass('js-main-target-form');
+ form.find('#note_line_code').remove();
+ form.find('#note_position').remove();
+ form.find('#note_type').val('');
+ form.find('#note_project_id').remove();
+ form.find('#in_reply_to_discussion_id').remove();
+ form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
+ this.parentTimeline = form.parents('.timeline');
+
+ if (form.length) {
+ Notes.initCommentTypeToggle(form.get(0));
+ }
+ }
+
+ /**
+ * General note form setup.
+ *
+ * deactivates the submit button when text is empty
+ * hides the preview button when text is empty
+ * setup GFM auto complete
+ * show the form
+ */
+ setupNoteForm(form) {
+ var textarea, key;
+ new gl.GLForm(form, this.enableGFM);
+ textarea = form.find('.js-note-text');
+ 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('#note_project_id').val(),
+ form.find('#in_reply_to_discussion_id').val(),
- if ($form.attr('data-resolve-all') != null) {
- var projectPath = $form.data('project-path');
- var discussionId = $form.data('discussion-id');
- var mergeRequestId = $form.data('noteable-iid');
+ // LegacyDiffNote
+ form.find('#note_line_code').val(),
- if (ResolveService != null) {
- ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
- }
+ // DiffNote
+ form.find('#note_position').val()
+ ];
+ return new Autosave(textarea, key);
+ }
+
+ /**
+ * Called in response to the new note form being submitted
+ *
+ * Adds new note to list.
+ */
+ addNote($form, note) {
+ return this.renderNote(note);
+ }
+
+ addNoteError($form) {
+ let formParentTimeline;
+ if ($form.hasClass('js-main-target-form')) {
+ formParentTimeline = $form.parents('.timeline');
+ } else if ($form.hasClass('js-discussion-note-form')) {
+ formParentTimeline = $form.closest('.discussion-notes').find('.notes');
+ }
+ return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline);
+ }
+
+ updateNoteError($parentTimeline) {
+ new Flash('Your comment could not be updated! Please check your network connection and try again.');
+ }
+
+ /**
+ * Called in response to the new note form being submitted
+ *
+ * Adds new note to list.
+ */
+ addDiscussionNote($form, note, isNewDiffComment) {
+ if ($form.attr('data-resolve-all') != null) {
+ var projectPath = $form.data('project-path');
+ var discussionId = $form.data('discussion-id');
+ var mergeRequestId = $form.data('noteable-iid');
+
+ if (ResolveService != null) {
+ ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
}
+ }
- this.renderNote(note, $form);
- // cleanup after successfully creating a diff/discussion note
+ this.renderNote(note, $form);
+ // cleanup after successfully creating a diff/discussion note
+ if (isNewDiffComment) {
this.removeDiscussionNoteForm($form);
- };
-
- /*
- Called in response to the edit note form being submitted
-
- Updates the current note field.
- */
-
- Notes.prototype.updateNote = function(_xhr, note, _status) {
- var $html, $note_li;
- // Convert returned HTML to a jQuery object so we can modify it further
- $html = $(note.html);
- this.revertNoteEditForm();
- gl.utils.localTimeAgo($('.js-timeago', $html));
- $html.renderGFM();
- $html.find('.js-task-list-container').taskList('enable');
- // Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $('.note-row-' + note.id);
-
- $note_li.replaceWith($html);
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
- };
-
- Notes.prototype.checkContentToAllowEditing = function($el) {
- var initialContent = $el.find('.original-note-content').text().trim();
- var currentContent = $el.find('.note-textarea').val();
- var isAllowed = true;
+ }
+ }
+
+ /**
+ * Called in response to the edit note form being submitted
+ *
+ * Updates the current note field.
+ */
+ updateNote(noteEntity, $targetNote) {
+ var $noteEntityEl, $note_li;
+ // Convert returned HTML to a jQuery object so we can modify it further
+ $noteEntityEl = $(noteEntity.html);
+ $noteEntityEl.addClass('fade-in-full');
+ this.revertNoteEditForm($targetNote);
+ $noteEntityEl.renderGFM();
+ // Find the note's `li` element by ID and replace it with the updated HTML
+ $note_li = $('.note-row-' + noteEntity.id);
+
+ $note_li.replaceWith($noteEntityEl);
+ this.setupNewNote($noteEntityEl);
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
+ }
+ }
- if (currentContent === initialContent) {
- this.removeNoteEditForm($el);
- }
- else {
- var $buttons = $el.find('.note-form-actions');
- var isWidgetVisible = gl.utils.isInViewport($el.get(0));
+ checkContentToAllowEditing($el) {
+ var initialContent = $el.find('.original-note-content').text().trim();
+ var currentContent = $el.find('.js-note-text').val();
+ var isAllowed = true;
- if (!isWidgetVisible) {
- gl.utils.scrollToElement($el);
- }
+ if (currentContent === initialContent) {
+ this.removeNoteEditForm($el);
+ }
+ else {
+ var $buttons = $el.find('.note-form-actions');
+ var isWidgetVisible = gl.utils.isInViewport($el.get(0));
- $el.find('.js-edit-warning').show();
- isAllowed = false;
+ if (!isWidgetVisible) {
+ gl.utils.scrollToElement($el);
}
- return isAllowed;
- };
+ $el.find('.js-finish-edit-warning').show();
+ isAllowed = false;
+ }
- /*
- Called in response to clicking the edit note link
+ return isAllowed;
+ }
- Replaces the note text with the note edit form
- Adds a data attribute to the form with the original content of the note for cancellations
- */
- Notes.prototype.showEditForm = function(e, scrollTo, myLastNote) {
- e.preventDefault();
+ /**
+ * Called in response to clicking the edit note link
+ *
+ * Replaces the note text with the note edit form
+ * Adds a data attribute to the form with the original content of the note for cancellations
+ */
+ showEditForm(e, scrollTo, myLastNote) {
+ e.preventDefault();
- var $target = $(e.target);
- var $editForm = $(this.getEditFormSelector($target));
- var $note = $target.closest('.note');
- var $currentlyEditing = $('.note.is-editting:visible');
+ var $target = $(e.target);
+ var $editForm = $(this.getEditFormSelector($target));
+ var $note = $target.closest('.note');
+ var $currentlyEditing = $('.note.is-editing:visible');
- if ($currentlyEditing.length) {
- var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
+ if ($currentlyEditing.length) {
+ var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
- if (!isEditAllowed) {
- return;
- }
+ if (!isEditAllowed) {
+ return;
}
+ }
- $note.find('.js-note-attachment-delete').show();
- $editForm.addClass('current-note-edit-form');
- $note.addClass('is-editting');
- this.putEditFormInPlace($target);
- };
-
- /*
- Called in response to clicking the edit note link
-
- Hides edit form and restores the original note text to the editor textarea.
- */
-
- Notes.prototype.cancelEdit = function(e) {
- e.preventDefault();
- var $target = $(e.target);
- var note = $target.closest('.note');
- note.find('.js-edit-warning').hide();
- this.revertNoteEditForm($target);
- return this.removeNoteEditForm(note);
- };
-
- Notes.prototype.revertNoteEditForm = function($target) {
- $target = $target || $('.note.is-editting:visible');
- var selector = this.getEditFormSelector($target);
- var $editForm = $(selector);
-
- $editForm.insertBefore('.notes-form');
- $editForm.find('.js-comment-button').enable();
- $editForm.find('.js-edit-warning').hide();
- };
+ $note.find('.js-note-attachment-delete').show();
+ $editForm.addClass('current-note-edit-form');
+ $note.addClass('is-editing');
+ this.putEditFormInPlace($target);
+ }
+
+ /**
+ * Called in response to clicking the edit note link
+ *
+ * Hides edit form and restores the original note text to the editor textarea.
+ */
+ cancelEdit(e) {
+ e.preventDefault();
+ const $target = $(e.target);
+ const $note = $target.closest('.note');
+ const noteId = $note.attr('data-note-id');
+
+ this.revertNoteEditForm($target);
+
+ if (this.updatedNotesTrackingMap[noteId]) {
+ const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
+ $note.replaceWith($newNote);
+ this.setupNewNote($newNote);
+ // Now that we have taken care of the update, clear it out
+ delete this.updatedNotesTrackingMap[noteId];
+ }
+ else {
+ $note.find('.js-finish-edit-warning').hide();
+ this.removeNoteEditForm($note);
+ }
+ }
- Notes.prototype.getEditFormSelector = function($el) {
- var selector = '.note-edit-form:not(.mr-note-edit-form)';
+ revertNoteEditForm($target) {
+ $target = $target || $('.note.is-editing:visible');
+ var selector = this.getEditFormSelector($target);
+ var $editForm = $(selector);
- if ($el.parents('#diffs').length) {
- selector = '.note-edit-form.mr-note-edit-form';
- }
+ $editForm.insertBefore('.notes-form');
+ $editForm.find('.js-comment-save-button').enable();
+ $editForm.find('.js-finish-edit-warning').hide();
+ }
- return selector;
- };
+ getEditFormSelector($el) {
+ var selector = '.note-edit-form:not(.mr-note-edit-form)';
- Notes.prototype.removeNoteEditForm = function(note) {
- var form = note.find('.current-note-edit-form');
- note.removeClass('is-editting');
- form.removeClass('current-note-edit-form');
- form.find('.js-edit-warning').hide();
- // Replace markdown textarea text with original note text.
- return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note'));
- };
+ if ($el.parents('#diffs').length) {
+ selector = '.note-edit-form.mr-note-edit-form';
+ }
- /*
- Called in response to deleting a note of any kind.
-
- Removes the actual note from view.
- Removes the whole discussion if the last note is being removed.
- */
-
- Notes.prototype.removeNote = function(e) {
- var noteElId, noteId, dataNoteId, $note, lineHolder;
- $note = $(e.currentTarget).closest('.note');
- noteElId = $note.attr('id');
- noteId = $note.attr('data-note-id');
- lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
- .closest('.notes_holder')
- .prev('.line_holder');
- $(".note[id='" + noteElId + "']").each((function(_this) {
- // A same note appears in the "Discussion" and in the "Changes" tab, we have
- // to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
- // where $("#noteId") would return only one.
- return function(i, el) {
- var note, notes;
- note = $(el);
- notes = note.closest(".discussion-notes");
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
- }
+ return selector;
+ }
+
+ removeNoteEditForm($note) {
+ var form = $note.find('.current-note-edit-form');
+ $note.removeClass('is-editing');
+ form.removeClass('current-note-edit-form');
+ form.find('.js-finish-edit-warning').hide();
+ // Replace markdown textarea text with original note text.
+ return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note'));
+ }
+
+ /**
+ * Called in response to deleting a note of any kind.
+ *
+ * Removes the actual note from view.
+ * Removes the whole discussion if the last note is being removed.
+ */
+ removeNote(e) {
+ var noteElId, noteId, dataNoteId, $note, lineHolder;
+ $note = $(e.currentTarget).closest('.note');
+ noteElId = $note.attr('id');
+ noteId = $note.attr('data-note-id');
+ lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
+ .closest('.notes_holder')
+ .prev('.line_holder');
+ $(`.note[id="${noteElId}"]`).each((function(_this) {
+ // A same note appears in the "Discussion" and in the "Changes" tab, we have
+ // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
+ // where $('#noteId') would return only one.
+ return function(i, el) {
+ var $note, $notes;
+ $note = $(el);
+ $notes = $note.closest('.discussion-notes');
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ if (gl.diffNoteApps[noteElId]) {
+ gl.diffNoteApps[noteElId].$destroy();
}
+ }
- note.remove();
+ $note.remove();
- // check if this is the last note for this line
- if (notes.find(".note").length === 0) {
- var notesTr = notes.closest("tr");
+ // check if this is the last note for this line
+ if ($notes.find('.note').length === 0) {
+ var notesTr = $notes.closest('tr');
- // "Discussions" tab
- notes.closest(".timeline-entry").remove();
+ // "Discussions" tab
+ $notes.closest('.timeline-entry').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 {
- 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 {
+ notesTr.remove();
}
- };
- })(this));
- // Decrement the "Discussions" counter only once
- return this.updateNotesCount(-1);
- };
-
- /*
- Called in response to clicking the delete attachment link
-
- Removes the attachment wrapper view, including image tag if it exists
- Resets the note editing form
- */
-
- Notes.prototype.removeAttachment = function() {
- var note;
- note = $(this).closest(".note");
- note.find(".note-attachment").remove();
- note.find(".note-body > .note-text").show();
- note.find(".note-header").show();
- return note.find(".current-note-edit-form").remove();
- };
-
- /*
- Called when clicking on the "reply" button for a diff line.
-
- Shows the note form below the notes.
- */
-
- Notes.prototype.replyToDiscussionNote = function(e) {
- var form, replyLink;
- form = this.cleanForm(this.formClone.clone());
- replyLink = $(e.target).closest(".js-discussion-reply-button");
- // insert the form after the button
- replyLink
- .closest('.discussion-reply-holder')
- .hide()
- .after(form);
- // show the form
- return this.setupDiscussionNoteForm(replyLink, form);
- };
-
- /*
- Shows the diff or discussion form and does some setup on it.
-
- Sets some hidden fields in the form.
-
- Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
- */
+ }
+ };
+ })(this));
+
+ Notes.checkMergeRequestStatus();
+ return this.updateNotesCount(-1);
+ }
+
+ /**
+ * Called in response to clicking the delete attachment link
+ *
+ * Removes the attachment wrapper view, including image tag if it exists
+ * Resets the note editing form
+ */
+ removeAttachment() {
+ const $note = $(this).closest('.note');
+ $note.find('.note-attachment').remove();
+ $note.find('.note-body > .note-text').show();
+ $note.find('.note-header').show();
+ return $note.find('.current-note-edit-form').remove();
+ }
+
+ /**
+ * Called when clicking on the "reply" button for a diff line.
+ *
+ * Shows the note form below the notes.
+ */
+ onReplyToDiscussionNote(e) {
+ this.replyToDiscussionNote(e.target);
+ }
+
+ replyToDiscussionNote(target) {
+ var form, replyLink;
+ form = this.cleanForm(this.formClone.clone());
+ replyLink = $(target).closest('.js-discussion-reply-button');
+ // insert the form after the button
+ replyLink
+ .closest('.discussion-reply-holder')
+ .hide()
+ .after(form);
+ // show the form
+ return this.setupDiscussionNoteForm(replyLink, form);
+ }
+
+ /**
+ * Shows the diff or discussion form and does some setup on it.
+ *
+ * Sets some hidden fields in the form.
+ *
+ * Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
+ */
+ setupDiscussionNoteForm(dataHolder, form) {
+ // setup note target
+ const diffFileData = dataHolder.closest('.text-file');
+
+ var discussionID = dataHolder.data('discussionId');
+
+ if (discussionID) {
+ form.attr('data-discussion-id', discussionID);
+ form.find('#in_reply_to_discussion_id').val(discussionID);
+ }
- Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
- // setup note target
- var discussionID = dataHolder.data("discussionId");
+ form.find('#note_project_id').val(dataHolder.data('discussionProjectId'));
- 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('#line_type').val(dataHolder.data('lineType'));
- form.attr("data-line-code", dataHolder.data("lineCode"));
- form.find("#line_type").val(dataHolder.data("lineType"));
+ form.find('#note_noteable_type').val(diffFileData.data('noteableType'));
+ form.find('#note_noteable_id').val(diffFileData.data('noteableId'));
+ form.find('#note_commit_id').val(diffFileData.data('commitId'));
- 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"));
+ form.find('#note_type').val(dataHolder.data('noteType'));
- // LegacyDiffNote
- form.find("#note_line_code").val(dataHolder.data("lineCode"));
+ // LegacyDiffNote
+ form.find('#note_line_code').val(dataHolder.data('lineCode'));
- // DiffNote
- form.find("#note_position").val(dataHolder.attr("data-position"));
+ // DiffNote
+ form.find('#note_position').val(dataHolder.attr('data-position'));
- 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.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");
+ 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', `'${discussionID}'`);
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ var $commentBtn = form.find('comment-and-resolve-btn');
+ $commentBtn.attr(':discussion-id', `'${discussionID}'`);
- gl.diffNotesCompileComponents();
- }
+ gl.diffNotesCompileComponents();
+ }
- form.find(".js-note-text").focus();
- form
- .find('.js-comment-resolve-button')
- .attr('data-discussion-id', discussionID);
- };
+ form.find('.js-note-text').focus();
+ form
+ .find('.js-comment-resolve-button')
+ .attr('data-discussion-id', discussionID);
+ }
+
+ /**
+ * Called when clicking on the "add a comment" button on the side of a diff line.
+ *
+ * Inserts a temporary row for the form below the line.
+ * Sets up the form and shows it.
+ */
+ onAddDiffNote(e) {
+ e.preventDefault();
+ const link = e.currentTarget || e.target;
+ const $link = $(link);
+ const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
+ this.toggleDiffNote({
+ target: $link,
+ lineType: link.dataset.lineType,
+ showReplyInput
+ });
+ }
+
+ toggleDiffNote({
+ target,
+ lineType,
+ forceShow,
+ showReplyInput = false,
+ }) {
+ var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
+ $link = $(target);
+ row = $link.closest('tr');
+ const nextRow = row.next();
+ let targetRow = row;
+ if (nextRow.is('.notes_holder')) {
+ targetRow = nextRow;
+ }
- /*
- Called when clicking on the "add a comment" button on the side of a diff line.
-
- Inserts a temporary row for the form below the line.
- Sets up the form and shows it.
- */
-
- Notes.prototype.addDiffNote = function(e) {
- var $link, addForm, hasNotes, lineType, newForm, nextRow, noteForm, notesContent, notesContentSelector, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
- e.preventDefault();
- $link = $(e.currentTarget || e.target);
- row = $link.closest("tr");
- nextRow = row.next();
- hasNotes = nextRow.is(".notes_holder");
- addForm = false;
- notesContentSelector = ".notes_content";
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
- isDiffCommentAvatar = $link.hasClass('js-diff-comment-avatar');
- // In parallel view, look inside the correct left/right pane
- if (this.isParallelView()) {
- lineType = $link.data("lineType");
- notesContentSelector += "." + lineType;
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
- }
- notesContentSelector += " .content";
- notesContent = nextRow.find(notesContentSelector);
-
- if (hasNotes && !isDiffCommentAvatar) {
- nextRow.show();
- notesContent = nextRow.find(notesContentSelector);
- if (notesContent.length) {
- notesContent.show();
- replyButton = notesContent.find(".js-discussion-reply-button:visible");
- if (replyButton.length) {
- e.target = replyButton[0];
- $.proxy(this.replyToDiscussionNote, replyButton[0], e).call();
- } else {
- // In parallel view, the form may not be present in one of the panes
- noteForm = notesContent.find(".js-discussion-note-form");
- if (noteForm.length === 0) {
- addForm = true;
- }
+ hasNotes = nextRow.is('.notes_holder');
+ addForm = false;
+ let lineTypeSelector = '';
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>';
+ // In parallel view, look inside the correct left/right pane
+ if (this.isParallelView()) {
+ lineTypeSelector = `.${lineType}`;
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
+ }
+ const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
+ let notesContent = targetRow.find(notesContentSelector);
+
+ if (hasNotes && showReplyInput) {
+ targetRow.show();
+ notesContent = targetRow.find(notesContentSelector);
+ if (notesContent.length) {
+ notesContent.show();
+ replyButton = notesContent.find('.js-discussion-reply-button:visible');
+ if (replyButton.length) {
+ this.replyToDiscussionNote(replyButton[0]);
+ } else {
+ // In parallel view, the form may not be present in one of the panes
+ noteForm = notesContent.find('.js-discussion-note-form');
+ if (noteForm.length === 0) {
+ addForm = true;
}
}
- } else if (!isDiffCommentAvatar) {
- // add a notes row and insert the form
- row.after(rowCssToAdd);
- nextRow = row.next();
- notesContent = nextRow.find(notesContentSelector);
- addForm = true;
- } else {
- nextRow.show();
- notesContent.toggle(!notesContent.is(':visible'));
-
- if (!nextRow.find('.content:not(:empty)').is(':visible')) {
- nextRow.hide();
- }
}
+ } else if (showReplyInput) {
+ // add a notes row and insert the form
+ row.after(rowCssToAdd);
+ targetRow = row.next();
+ notesContent = targetRow.find(notesContentSelector);
+ addForm = true;
+ } else {
+ const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
+ const isForced = forceShow === true || forceShow === false;
+ const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
+
+ targetRow.toggle(showNow);
+ notesContent.toggle(showNow);
+ }
- if (addForm) {
- newForm = this.cleanForm(this.formClone.clone());
- newForm.appendTo(notesContent);
- // show the form
- return this.setupDiscussionNoteForm($link, newForm);
+ if (addForm) {
+ newForm = this.cleanForm(this.formClone.clone());
+ newForm.appendTo(notesContent);
+ // show the form
+ return this.setupDiscussionNoteForm($link, newForm);
+ }
+ }
+
+ /**
+ * Called in response to "cancel" on a diff note form.
+ *
+ * Shows the reply button again.
+ * Removes the form and if necessary it's temporary row.
+ */
+ removeDiscussionNoteForm(form) {
+ var glForm, row;
+ row = form.closest('tr');
+ glForm = form.data('gl-form');
+ glForm.destroy();
+ form.find('.js-note-text').data('autosave').reset();
+ // show the reply button (will only work for replies)
+ form
+ .prev('.discussion-reply-holder')
+ .show();
+ if (row.is('.js-temp-notes-holder')) {
+ // remove temporary row for diff lines
+ return row.remove();
+ } else {
+ // only remove the form
+ return form.remove();
+ }
+ }
+
+ cancelDiscussionForm(e) {
+ var form;
+ e.preventDefault();
+ form = $(e.target).closest('.js-discussion-note-form');
+ return this.removeDiscussionNoteForm(form);
+ }
+
+ /**
+ * Called after an attachment file has been selected.
+ *
+ * Updates the file name for the selected attachment.
+ */
+ updateFormAttachment() {
+ var filename, form;
+ form = $(this).closest('form');
+ // get only the basename
+ filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-attachment-filename').text(filename);
+ }
+
+ /**
+ * Called when the tab visibility changes
+ */
+ visibilityChange() {
+ return this.refresh();
+ }
+
+ updateTargetButtons(e) {
+ var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
+ textarea = $(e.target);
+ form = textarea.parents('form');
+ 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.attr('data-alternative-text');
+ closetext = closebtn.attr('data-alternative-text');
+ if (reopenbtn.text() !== reopentext) {
+ reopenbtn.text(reopentext);
}
- };
-
- /*
- Called in response to "cancel" on a diff note form.
-
- Shows the reply button again.
- Removes the form and if necessary it's temporary row.
- */
-
- Notes.prototype.removeDiscussionNoteForm = function(form) {
- var glForm, row;
- row = form.closest("tr");
- glForm = form.data('gl-form');
- glForm.destroy();
- form.find(".js-note-text").data("autosave").reset();
- // show the reply button (will only work for replies)
- form
- .prev('.discussion-reply-holder')
- .show();
- if (row.is(".js-temp-notes-holder")) {
- // remove temporary row for diff lines
- return row.remove();
- } else {
- // only remove the form
- return form.remove();
+ if (closebtn.text() !== closetext) {
+ closebtn.text(closetext);
}
- };
-
- Notes.prototype.cancelDiscussionForm = function(e) {
- var form;
- e.preventDefault();
- form = $(e.target).closest(".js-discussion-note-form");
- return this.removeDiscussionNoteForm(form);
- };
+ if (reopenbtn.is(':not(.btn-comment-and-reopen)')) {
+ reopenbtn.addClass('btn-comment-and-reopen');
+ }
+ if (closebtn.is(':not(.btn-comment-and-close)')) {
+ closebtn.addClass('btn-comment-and-close');
+ }
+ if (discardbtn.is(':hidden')) {
+ return discardbtn.show();
+ }
+ } else {
+ reopentext = reopenbtn.data('original-text');
+ closetext = closebtn.data('original-text');
+ if (reopenbtn.text() !== reopentext) {
+ reopenbtn.text(reopentext);
+ }
+ if (closebtn.text() !== closetext) {
+ closebtn.text(closetext);
+ }
+ if (reopenbtn.is('.btn-comment-and-reopen')) {
+ reopenbtn.removeClass('btn-comment-and-reopen');
+ }
+ if (closebtn.is('.btn-comment-and-close')) {
+ closebtn.removeClass('btn-comment-and-close');
+ }
+ if (discardbtn.is(':visible')) {
+ return discardbtn.hide();
+ }
+ }
+ }
+
+ putEditFormInPlace($el) {
+ var $editForm = $(this.getEditFormSelector($el));
+ var $note = $el.closest('.note');
+
+ $editForm.insertAfter($note.find('.note-text'));
+
+ var $originalContentEl = $note.find('.original-note-content');
+ var originalContent = $originalContentEl.text().trim();
+ var postUrl = $originalContentEl.data('post-url');
+ var targetId = $originalContentEl.data('target-id');
+ var targetType = $originalContentEl.data('target-type');
+
+ new gl.GLForm($editForm.find('form'), this.enableGFM);
+
+ $editForm.find('form')
+ .attr('action', postUrl)
+ .attr('data-remote', 'true');
+ $editForm.find('.js-form-target-id').val(targetId);
+ $editForm.find('.js-form-target-type').val(targetType);
+ $editForm.find('.js-note-text').focus().val(originalContent);
+ $editForm.find('.js-md-write-button').trigger('click');
+ $editForm.find('.referenced-users').hide();
+ }
+
+ putConflictEditWarningInPlace(noteEntity, $note) {
+ if ($note.find('.js-conflict-edit-warning').length === 0) {
+ const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
+ This comment has changed since you started editing, please review the
+ <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer">
+ updated comment
+ </a>
+ to ensure information is not lost
+ </div>`);
+ $alert.insertAfter($note.find('.note-text'));
+ }
+ }
- /*
- Called after an attachment file has been selected.
+ updateNotesCount(updateCount) {
+ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
+ }
- Updates the file name for the selected attachment.
- */
+ toggleCommitList(e) {
+ const $element = $(e.currentTarget);
+ const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
- Notes.prototype.updateFormAttachment = function() {
- var filename, form;
- form = $(this).closest("form");
- // get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, "");
- return form.find(".js-attachment-filename").text(filename);
- };
+ $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
+ $closestSystemCommitList.toggleClass('hide-shade');
+ }
- /*
- Called when the tab visibility changes
- */
+ /**
+ * Scans system notes with `ul` elements in system note body
+ * then collapse long commit list pushed by user to make it less
+ * intrusive.
+ */
+ collapseLongCommitList() {
+ const systemNotes = $('#notes-list').find('li.system-note').has('ul');
- Notes.prototype.visibilityChange = function() {
- return this.refresh();
- };
+ $.each(systemNotes, function(index, systemNote) {
+ const $systemNote = $(systemNote);
+ const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
- Notes.prototype.updateCloseButton = function(e) {
- var closebtn, form, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- closebtn = form.find('.js-note-target-close');
- return closebtn.text(closebtn.data('original-text'));
- };
+ $systemNote.find('.note-header .system-note-message').html(headerMessage);
- Notes.prototype.updateTargetButtons = function(e) {
- var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- 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.attr('data-alternative-text');
- closetext = closebtn.attr('data-alternative-text');
- if (reopenbtn.text() !== reopentext) {
- reopenbtn.text(reopentext);
- }
- if (closebtn.text() !== closetext) {
- closebtn.text(closetext);
- }
- if (reopenbtn.is(':not(.btn-comment-and-reopen)')) {
- reopenbtn.addClass('btn-comment-and-reopen');
- }
- if (closebtn.is(':not(.btn-comment-and-close)')) {
- closebtn.addClass('btn-comment-and-close');
- }
- if (discardbtn.is(':hidden')) {
- return discardbtn.show();
- }
+ if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) {
+ $systemNote.find('.note-text').addClass('system-note-commit-list');
+ $systemNote.find('.system-note-commit-list-toggler').show();
} else {
- reopentext = reopenbtn.data('original-text');
- closetext = closebtn.data('original-text');
- if (reopenbtn.text() !== reopentext) {
- reopenbtn.text(reopentext);
- }
- if (closebtn.text() !== closetext) {
- closebtn.text(closetext);
- }
- if (reopenbtn.is('.btn-comment-and-reopen')) {
- reopenbtn.removeClass('btn-comment-and-reopen');
- }
- if (closebtn.is('.btn-comment-and-close')) {
- closebtn.removeClass('btn-comment-and-close');
- }
- if (discardbtn.is(':visible')) {
- return discardbtn.hide();
- }
+ $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade');
}
+ });
+ }
+
+ addFlash(...flashParams) {
+ this.flashInstance = new Flash(...flashParams);
+ }
+
+ clearFlash() {
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
+ }
+
+ cleanForm($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;
+ }
+
+ /**
+ * Check if note does not exists on page
+ */
+ static isNewNote(noteEntity, noteIds) {
+ return $.inArray(noteEntity.id, noteIds) === -1;
+ }
+
+ /**
+ * Check if $note already contains the `noteEntity` content
+ */
+ static isUpdatedNote(noteEntity, $note) {
+ // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
+ const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim());
+ const currentNoteText = normalizeNewlines(
+ $note.find('.original-note-content').first().text().trim()
+ );
+ return sanitizedNoteEntityText !== currentNoteText;
+ }
+
+ static checkMergeRequestStatus() {
+ if (gl.utils.getPagePath(1) === 'merge_requests') {
+ gl.mrWidget.checkStatus();
+ }
+ }
+
+ static animateAppendNote(noteHtml, $notesList) {
+ const $note = $(noteHtml);
+
+ $note.addClass('fade-in-full').renderGFM();
+ $notesList.append($note);
+ return $note;
+ }
+
+ static animateUpdateNote(noteHtml, $note) {
+ const $updatedNote = $(noteHtml);
+
+ $updatedNote.addClass('fade-in').renderGFM();
+ $note.replaceWith($updatedNote);
+ return $updatedNote;
+ }
+
+ /**
+ * Get data from Form attributes to use for saving/submitting comment.
+ */
+ getFormData($form) {
+ return {
+ formData: $form.serialize(),
+ formContent: _.escape($form.find('.js-note-text').val()),
+ formAction: $form.attr('action'),
};
+ }
+
+ /**
+ * Identify if comment has any quick actions
+ */
+ hasQuickActions(formContent) {
+ return REGEX_QUICK_ACTIONS.test(formContent);
+ }
+
+ /**
+ * Remove quick actions and leave comment with pure message
+ */
+ stripQuickActions(formContent) {
+ return formContent.replace(REGEX_QUICK_ACTIONS, '').trim();
+ }
+
+ /**
+ * Gets appropriate description from quick actions found in provided `formContent`
+ */
+ getQuickActionDescription(formContent, availableQuickActions = []) {
+ let tempFormContent;
+
+ // Identify executed quick actions from `formContent`
+ const executedCommands = availableQuickActions.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
+ }
+ } else {
+ tempFormContent = 'Applying command';
+ }
- Notes.prototype.putEditFormInPlace = function($el) {
- var $editForm = $(this.getEditFormSelector($el));
- var $note = $el.closest('.note');
+ return tempFormContent;
+ }
+
+ /**
+ * Create placeholder note DOM element populated with comment body
+ * that we will show while comment is being posted.
+ * Once comment is _actually_ posted on server, we will have final element
+ * in response that we will show in place of this temporary element.
+ */
+ createPlaceholderNote({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
+ const discussionClass = isDiscussionNote ? 'discussion' : '';
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}" />
+ </a>
+ </div>
+ <div class="timeline-content ${discussionClass}">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a href="/${currentUsername}">
+ <span class="hidden-xs">${currentUserFullname}</span>
+ <span class="note-headline-light">@${currentUsername}</span>
+ </a>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>${formContent}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ }
+
+ /**
+ * Create Placeholder System Note DOM element populated with quick action description
+ */
+ createPlaceholderSystemNote({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ }
+
+ /**
+ * This method does following tasks step-by-step whenever a new comment
+ * is submitted by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
+ * 3) Build temporary placeholder element (using `createPlaceholderNote`)
+ * 4) Show placeholder note on UI
+ * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Remove placeholder element
+ * 2. Show submitted Note element
+ * 3. Perform post-submit errands
+ * a. Mark discussion as resolved if comment submission was for resolve.
+ * b. Reset comment form to original state.
+ * b) If request failed
+ * 1. Remove placeholder element
+ * 2. Show error Flash message about failure
+ */
+ postComment(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ let $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
+ const isMainForm = $form.hasClass('js-main-target-form');
+ const isDiscussionForm = $form.hasClass('js-discussion-note-form');
+ const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
+ const { formData, formContent, formAction } = this.getFormData($form);
+ let noteUniqueId;
+ let systemNoteUniqueId;
+ let hasQuickActions = false;
+ let $notesContainer;
+ let tempFormContent;
+
+ // Get reference to notes container based on type of comment
+ if (isDiscussionForm) {
+ $notesContainer = $form.parent('.discussion-notes').find('.notes');
+ } else if (isMainForm) {
+ $notesContainer = $('ul.main-notes-list');
+ }
- $editForm.insertAfter($note.find('.note-text'));
+ // If comment is to resolve discussion, disable submit buttons while
+ // comment posting is finished.
+ if (isDiscussionResolve) {
+ $submitBtn.disable();
+ $form.find('.js-comment-submit-button').disable();
+ }
- var $originalContentEl = $note.find('.original-note-content');
- var originalContent = $originalContentEl.text().trim();
- var postUrl = $originalContentEl.data('post-url');
- var targetId = $originalContentEl.data('target-id');
- var targetType = $originalContentEl.data('target-type');
+ tempFormContent = formContent;
+ if (this.hasQuickActions(formContent)) {
+ tempFormContent = this.stripQuickActions(formContent);
+ hasQuickActions = true;
+ }
- new gl.GLForm($editForm.find('form'));
+ // Show placeholder note
+ if (tempFormContent) {
+ noteUniqueId = _.uniqueId('tempNote_');
+ $notesContainer.append(this.createPlaceholderNote({
+ formContent: tempFormContent,
+ uniqueId: noteUniqueId,
+ isDiscussionNote,
+ currentUsername: gon.current_username,
+ currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }));
+ }
- $editForm.find('form')
- .attr('action', postUrl)
- .attr('data-remote', 'true');
- $editForm.find('.js-form-target-id').val(targetId);
- $editForm.find('.js-form-target-type').val(targetType);
- $editForm.find('.js-note-text').focus().val(originalContent);
- $editForm.find('.js-md-write-button').trigger('click');
- $editForm.find('.referenced-users').hide();
- };
+ // Show placeholder system note
+ if (hasQuickActions) {
+ systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ $notesContainer.append(this.createPlaceholderSystemNote({
+ formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
+ uniqueId: systemNoteUniqueId,
+ }));
+ }
- Notes.prototype.updateNotesCount = function(updateCount) {
- return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
- };
+ // Clear the form textarea
+ if ($notesContainer.length) {
+ if (isMainForm) {
+ this.resetMainTargetForm(e);
+ } else if (isDiscussionForm) {
+ this.removeDiscussionNoteForm($form);
+ }
+ }
- Notes.prototype.resolveDiscussion = function() {
- var $this = $(this);
- var discussionId = $this.attr('data-discussion-id');
+ /* eslint-disable promise/catch-or-return */
+ // Make request to submit comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! remove placeholder
+ $notesContainer.find(`#${noteUniqueId}`).remove();
- $this
- .closest('form')
- .attr('data-discussion-id', discussionId)
- .attr('data-resolve-all', 'true')
- .attr('data-project-path', $this.attr('data-project-path'));
- };
+ // Reset cached commands list when command is applied
+ if (hasQuickActions) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ }
- Notes.prototype.toggleCommitList = function(e) {
- const $element = $(e.currentTarget);
- const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
+ // Clear previous form errors
+ this.clearFlashWrapper();
- $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
- $closestSystemCommitList.toggleClass('hide-shade');
- };
+ // Check if this was discussion comment
+ if (isDiscussionForm) {
+ // Remove flash-container
+ $notesContainer.find('.flash-container').remove();
- /**
- Scans system notes with `ul` elements in system note body
- then collapse long commit list pushed by user to make it less
- intrusive.
- */
- Notes.prototype.collapseLongCommitList = function() {
- const systemNotes = $('#notes-list').find('li.system-note').has('ul');
+ // If comment intends to resolve discussion, do the same.
+ if (isDiscussionResolve) {
+ $form
+ .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-resolve-all', 'true')
+ .attr('data-project-path', $submitBtn.data('project-path'));
+ }
- $.each(systemNotes, function(index, systemNote) {
- const $systemNote = $(systemNote);
- const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
+ // Show final note element on UI
+ this.addDiscussionNote($form, note, $notesContainer.length === 0);
- $systemNote.find('.note-header .system-note-message').html(headerMessage);
+ // append flash-container to the Notes list
+ if ($notesContainer.length) {
+ $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
+ }
+ } else if (isMainForm) { // Check if this was main thread comment
+ // Show final note element on UI and perform form and action buttons cleanup
+ this.addNote($form, note);
+ this.reenableTargetFormSubmitButton(e);
+ }
- if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) {
- $systemNote.find('.note-text').addClass('system-note-commit-list');
- $systemNote.find('.system-note-commit-list-toggler').show();
- } else {
- $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade');
+ if (note.commands_changes) {
+ this.handleQuickActions(note);
}
- });
- };
- Notes.prototype.cleanForm = function($form) {
- // Remove JS classes that are not needed here
- $form
- .find('.js-comment-type-dropdown')
- .removeClass('btn-group');
+ $form.trigger('ajax:success', [note]);
+ }).fail(() => {
+ // Submission failed, remove placeholder note and show Flash error message
+ $notesContainer.find(`#${noteUniqueId}`).remove();
- // Remove dropdown
- $form
- .find('.dropdown-menu')
- .remove();
+ if (hasQuickActions) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
- return $form;
- };
+ // Show form again on UI on failure
+ if (isDiscussionForm && $notesContainer.length) {
+ const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ this.replyToDiscussionNote(replyButton[0]);
+ $form = $notesContainer.parent().find('form');
+ }
- Notes.animateAppendNote = function(noteHTML, $notesList) {
- const $note = window.$(noteHTML);
+ $form.find('.js-note-text').val(formContent);
+ this.reenableTargetFormSubmitButton(e);
+ this.addNoteError($form);
+ });
- $note.addClass('fade-in').renderGFM();
- $notesList.append($note);
- };
+ return $closeBtn.text($closeBtn.data('original-text'));
+ }
+
+ /**
+ * This method does following tasks step-by-step whenever an existing comment
+ * is updated by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Update note element with new content
+ * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Show submitted Note element
+ * b) If request failed
+ * 1. Revert Note element to original content
+ * 2. Show error Flash message about failure
+ */
+ updateComment(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ const $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const $editingNote = $form.parents('.note.is-editing');
+ const $noteBody = $editingNote.find('.js-task-list-container');
+ const $noteBodyText = $noteBody.find('.note-text');
+ const { formData, formContent, formAction } = this.getFormData($form);
+
+ // Cache original comment content
+ const cachedNoteBodyText = $noteBodyText.html();
+
+ // Show updated comment content temporarily
+ $noteBodyText.html(formContent);
+ $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
+ $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to update comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! render final note element
+ this.updateNote(note, $editingNote);
+ })
+ .fail(() => {
+ // Submission failed, revert back to original note
+ $noteBodyText.html(_.escape(cachedNoteBodyText));
+ $editingNote.removeClass('being-posted fade-in');
+ $editingNote.find('.fa.fa-spinner').remove();
+
+ // Show Flash message about failure
+ this.updateNoteError();
+ });
+
+ return $closeBtn.text($closeBtn.data('original-text'));
+ }
+}
- return Notes;
- })();
-}).call(window);
+window.Notes = Notes;
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 5005af90d48..2ab9c4fed2c 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,10 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NotificationsForm = (function() {
function NotificationsForm() {
- this.toggleCheckbox = bind(this.toggleCheckbox, this);
+ this.toggleCheckbox = this.toggleCheckbox.bind(this);
this.removeEventListeners();
this.initEventListeners();
}
diff --git a/app/assets/javascripts/oauth_remember_me.js b/app/assets/javascripts/oauth_remember_me.js
new file mode 100644
index 00000000000..ffc2dd6bbca
--- /dev/null
+++ b/app/assets/javascripts/oauth_remember_me.js
@@ -0,0 +1,32 @@
+/**
+ * OAuth-based login buttons have a separate "remember me" checkbox.
+ *
+ * Toggling this checkbox adds/removes a `remember_me` parameter to the
+ * login buttons' href, which is passed on to the omniauth callback.
+ **/
+
+export default class OAuthRememberMe {
+ constructor(opts = {}) {
+ this.container = opts.container || '';
+ this.loginLinkSelector = '.oauth-login';
+ }
+
+ bindEvents() {
+ $('#remember_me', this.container).on('click', this.toggleRememberMe);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ toggleRememberMe(event) {
+ const rememberMe = $(event.target).is(':checked');
+
+ $('.oauth-login', this.container).each((i, element) => {
+ const href = $(element).attr('href');
+
+ if (rememberMe) {
+ $(element).attr('href', `${href}?remember_me=1`);
+ } else {
+ $(element).attr('href', href.replace('?remember_me=1', ''));
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 5f6bc902cf8..01110420cca 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,16 +1,17 @@
-require('~/lib/utils/common_utils');
-require('~/lib/utils/url_utility');
+import '~/lib/utils/common_utils';
+import '~/lib/utils/url_utility';
(() => {
const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const Pager = {
- init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']);
this.limit = limit;
this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
+ this.prepareData = prepareData;
this.callback = callback;
this.loading = $('.loading').first();
if (preload) {
@@ -29,7 +30,7 @@ require('~/lib/utils/url_utility');
dataType: 'json',
error: () => this.loading.hide(),
success: (data) => {
- this.append(data.count, data.html);
+ this.append(data.count, this.prepareData(data.html));
this.callback();
// keep loading until we've filled the viewport height
diff --git a/app/assets/javascripts/pdf/assets/img/bg.gif b/app/assets/javascripts/pdf/assets/img/bg.gif
new file mode 100644
index 00000000000..c7e98e044f5
--- /dev/null
+++ b/app/assets/javascripts/pdf/assets/img/bg.gif
Binary files differ
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
new file mode 100644
index 00000000000..b874e484d45
--- /dev/null
+++ b/app/assets/javascripts/pdf/index.vue
@@ -0,0 +1,73 @@
+<template>
+ <div class="pdf-viewer" v-if="hasPDF">
+ <page v-for="(page, index) in pages"
+ :key="index"
+ :v-if="!loading"
+ :page="page"
+ :number="index + 1" />
+ </div>
+</template>
+
+<script>
+ import pdfjsLib from 'vendor/pdf';
+ import workerSrc from 'vendor/pdf.worker.min';
+
+ import page from './page/index.vue';
+
+ export default {
+ props: {
+ pdf: {
+ type: [String, Uint8Array],
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ pages: [],
+ };
+ },
+ components: { page },
+ watch: { pdf: 'load' },
+ computed: {
+ document() {
+ return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf };
+ },
+ hasPDF() {
+ return this.pdf && this.pdf.length > 0;
+ },
+ },
+ methods: {
+ load() {
+ this.pages = [];
+ return pdfjsLib.getDocument(this.document)
+ .then(this.renderPages)
+ .then(() => this.$emit('pdflabload'))
+ .catch(error => this.$emit('pdflaberror', error))
+ .then(() => { this.loading = false; });
+ },
+ renderPages(pdf) {
+ const pagePromises = [];
+ this.loading = true;
+ for (let num = 1; num <= pdf.numPages; num += 1) {
+ pagePromises.push(
+ pdf.getPage(num).then(p => this.pages.push(p)),
+ );
+ }
+ return Promise.all(pagePromises);
+ },
+ },
+ mounted() {
+ pdfjsLib.PDFJS.workerSrc = workerSrc;
+ if (this.hasPDF) this.load();
+ },
+ };
+</script>
+
+<style>
+ .pdf-viewer {
+ background: url('./assets/img/bg.gif');
+ display: flex;
+ flex-flow: column nowrap;
+ }
+</style>
diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue
new file mode 100644
index 00000000000..7b74ee4eb2e
--- /dev/null
+++ b/app/assets/javascripts/pdf/page/index.vue
@@ -0,0 +1,68 @@
+<template>
+ <canvas
+ class="pdf-page"
+ ref="canvas"
+ :data-page="number" />
+</template>
+
+<script>
+ export default {
+ props: {
+ page: {
+ type: Object,
+ required: true,
+ },
+ number: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scale: 4,
+ rendering: false,
+ };
+ },
+ computed: {
+ viewport() {
+ return this.page.getViewport(this.scale);
+ },
+ context() {
+ return this.$refs.canvas.getContext('2d');
+ },
+ renderContext() {
+ return {
+ canvasContext: this.context,
+ viewport: this.viewport,
+ };
+ },
+ },
+ mounted() {
+ this.$refs.canvas.height = this.viewport.height;
+ this.$refs.canvas.width = this.viewport.width;
+ this.rendering = true;
+ this.page.render(this.renderContext)
+ .then(() => { this.rendering = false; })
+ .catch(error => this.$emit('pdflaberror', error));
+ },
+ };
+</script>
+
+<style>
+.pdf-page {
+ margin: 8px auto 0 auto;
+ border-top: 1px #ddd solid;
+ border-bottom: 1px #ddd solid;
+ width: 100%;
+}
+
+.pdf-page:first-child {
+ margin-top: 0px;
+ border-top: 0px;
+}
+
+.pdf-page:last-child {
+ margin-bottom: 0px;
+ border-bottom: 0px;
+}
+</style>
diff --git a/app/assets/javascripts/performance_bar.js b/app/assets/javascripts/performance_bar.js
new file mode 100644
index 00000000000..9bbdf7f513c
--- /dev/null
+++ b/app/assets/javascripts/performance_bar.js
@@ -0,0 +1,62 @@
+import 'vendor/peek';
+import 'vendor/peek.performance_bar';
+
+export default class PerformanceBar {
+ constructor(opts) {
+ if (!PerformanceBar.singleton) {
+ this.init(opts);
+ PerformanceBar.singleton = this;
+ }
+ return PerformanceBar.singleton;
+ }
+
+ init(opts) {
+ const $container = $(opts.container);
+ this.$sqlProfileLink = $container.find('.js-toggle-modal-peek-sql');
+ this.$sqlProfileModal = $container.find('#modal-peek-pg-queries');
+ this.$lineProfileLink = $container.find('.js-toggle-modal-peek-line-profile');
+ this.$lineProfileModal = $('#modal-peek-line-profile');
+ this.initEventListeners();
+ this.showModalOnLoad();
+ }
+
+ initEventListeners() {
+ this.$sqlProfileLink.on('click', () => this.handleSQLProfileLink());
+ this.$lineProfileLink.on('click', e => this.handleLineProfileLink(e));
+ $(document).on('click', '.js-lineprof-file', PerformanceBar.toggleLineProfileFile);
+ }
+
+ showModalOnLoad() {
+ // When a lineprofiler query-string param is present, we show the line
+ // profiler modal upon page load
+ if (/lineprofiler/.test(window.location.search)) {
+ PerformanceBar.toggleModal(this.$lineProfileModal);
+ }
+ }
+
+ handleSQLProfileLink() {
+ PerformanceBar.toggleModal(this.$sqlProfileModal);
+ }
+
+ handleLineProfileLink(e) {
+ const lineProfilerParameter = gl.utils.getParameterValues('lineprofiler');
+ const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`);
+ const shouldToggleModal = lineProfilerParameter.length > 0 &&
+ lineProfilerParameterRegex.test(e.currentTarget.href);
+
+ if (shouldToggleModal) {
+ e.preventDefault();
+ PerformanceBar.toggleModal(this.$lineProfileModal);
+ }
+ }
+
+ static toggleModal($modal) {
+ if ($modal.length) {
+ $modal.modal('toggle');
+ }
+ }
+
+ static toggleLineProfileFile(e) {
+ $(e.currentTarget).parents('.peek-rblineprof-file').find('.data').toggle();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue
new file mode 100644
index 00000000000..b5d85299cf8
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue
@@ -0,0 +1,146 @@
+<script>
+ import _ from 'underscore';
+
+ export default {
+ props: {
+ initialCronInterval: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ inputNameAttribute: 'schedule[cron]',
+ cronInterval: this.initialCronInterval,
+ cronIntervalPresets: {
+ everyDay: '0 4 * * *',
+ everyWeek: '0 4 * * 0',
+ everyMonth: '0 4 1 * *',
+ },
+ cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
+ customInputEnabled: false,
+ };
+ },
+ computed: {
+ intervalIsPreset() {
+ return _.contains(this.cronIntervalPresets, this.cronInterval);
+ },
+ // The text input is editable when there's a custom interval, or when it's
+ // a preset interval and the user clicks the 'custom' radio button
+ isEditable() {
+ return !!(this.customInputEnabled || !this.intervalIsPreset);
+ },
+ },
+ methods: {
+ toggleCustomInput(shouldEnable) {
+ this.customInputEnabled = shouldEnable;
+
+ if (shouldEnable) {
+ // We need to change the value so other radios don't remain selected
+ // because the model (cronInterval) hasn't changed. The server trims it.
+ this.cronInterval = `${this.cronInterval} `;
+ }
+ },
+ },
+ created() {
+ if (this.intervalIsPreset) {
+ this.enableCustomInput = false;
+ }
+ },
+ watch: {
+ cronInterval() {
+ // updates field validation state when model changes, as
+ // glFieldError only updates on input.
+ this.$nextTick(() => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="interval-pattern-form-group">
+ <div class="cron-preset-radio-input">
+ <input
+ id="custom"
+ class="label-light"
+ type="radio"
+ :name="inputNameAttribute"
+ :value="cronInterval"
+ :checked="isEditable"
+ @click="toggleCustomInput(true)"
+ />
+
+ <label for="custom">
+ {{ s__('PipelineSheduleIntervalPattern|Custom') }}
+ </label>
+
+ <span class="cron-syntax-link-wrap">
+ (<a :href="cronSyntaxUrl" target="_blank">{{ __('Cron syntax') }}</a>)
+ </span>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-day"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyDay"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-day">
+ {{ __('Every day (at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-week"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyWeek"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-week">
+ {{ __('Every week (Sundays at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-month"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyMonth"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-month">
+ {{ __('Every month (on the 1st at 4:00am)') }}
+ </label>
+ </div>
+
+ <div class="cron-interval-input-wrapper">
+ <input
+ id="schedule_cron"
+ class="form-control inline cron-interval-input"
+ type="text"
+ :placeholder="__('Define a custom pattern with cron syntax')"
+ required="true"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :disabled="!isEditable"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue
new file mode 100644
index 00000000000..6e0bc2d697a
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue
@@ -0,0 +1,59 @@
+<script>
+ import Vue from 'vue';
+ import Cookies from 'js-cookie';
+ import Translate from '../../vue_shared/translate';
+ import illustrationSvg from '../icons/intro_illustration.svg';
+
+ Vue.use(Translate);
+
+ const cookieKey = 'pipeline_schedules_callout_dismissed';
+
+ export default {
+ name: 'PipelineSchedulesCallout',
+ data() {
+ return {
+ docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
+ calloutDismissed: Cookies.get(cookieKey) === 'true',
+ };
+ },
+ methods: {
+ dismissCallout() {
+ this.calloutDismissed = true;
+ Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ },
+ },
+ created() {
+ this.illustrationSvg = illustrationSvg;
+ },
+ };
+</script>
+<template>
+ <div
+ v-if="!calloutDismissed"
+ class="pipeline-schedules-user-callout user-callout">
+ <div class="bordered-box landing content-block">
+ <button
+ id="dismiss-callout-btn"
+ class="btn btn-default close"
+ @click="dismissCallout">
+ <i
+ aria-hidden="true"
+ class="fa fa-times">
+ </i>
+ </button>
+ <div class="svg-container" v-html="illustrationSvg"></div>
+ <div class="user-callout-copy">
+ <h4>{{ __('Scheduling Pipelines') }}</h4>
+ <p>
+ {{ __('The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.') }}
+ </p>
+ <p> {{ __('Learn more in the') }}
+ <a
+ :href="docsUrl"
+ target="_blank"
+ rel="nofollow">{{ s__('Learn more in the|pipeline schedules documentation') }}</a>. <!-- oneline to prevent extra space before period -->
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
new file mode 100644
index 00000000000..0c3926d76b5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
@@ -0,0 +1,52 @@
+export default class TargetBranchDropdown {
+ constructor() {
+ this.$dropdown = $('.js-target-branch-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_ref');
+ this.initDefaultBranch();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.formatBranchesList(),
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => item.name,
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatBranchesList() {
+ return this.$dropdown.data('data')
+ .map(val => ({ name: val }));
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ initDefaultBranch() {
+ const initialValue = this.$input.val();
+ const defaultBranch = this.$dropdown.data('defaultBranch');
+
+ if (!initialValue) {
+ this.$input.val(defaultBranch);
+ }
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+
+ this.$input.val(selectedObj.name);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
new file mode 100644
index 00000000000..95ed9c7dc21
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
@@ -0,0 +1,66 @@
+/* eslint-disable class-methods-use-this */
+
+const defaultTimezone = 'UTC';
+
+export default class TimezoneDropdown {
+ constructor() {
+ this.$dropdown = $('.js-timezone-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_cron_timezone');
+ this.timezoneData = this.$dropdown.data('data');
+ this.initDefaultTimezone();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.timezoneData,
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => this.formatTimezone(item),
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatUtcOffset(offset) {
+ let prefix = '';
+
+ if (offset > 0) {
+ prefix = '+';
+ } else if (offset < 0) {
+ prefix = '-';
+ }
+
+ return `${prefix} ${Math.abs(offset / 3600)}`;
+ }
+
+ formatTimezone(item) {
+ return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ }
+
+ initDefaultTimezone() {
+ const initialValue = this.$input.val();
+
+ if (!initialValue) {
+ this.$input.val(defaultTimezone);
+ }
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+ this.$input.val(selectedObj.identifier);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
new file mode 100644
index 00000000000..26d1ff97b3e
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
@@ -0,0 +1 @@
+<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
new file mode 100644
index 00000000000..50c725aa3d5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import Translate from '../vue_shared/translate';
+import intervalPatternInput from './components/interval_pattern_input.vue';
+import TimezoneDropdown from './components/timezone_dropdown';
+import TargetBranchDropdown from './components/target_branch_dropdown';
+import { setupPipelineVariableList } from './setup_pipeline_variable_list';
+
+Vue.use(Translate);
+
+function initIntervalPatternInput() {
+ const intervalPatternMount = document.getElementById('interval-pattern-input');
+ const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : '';
+
+ return new Vue({
+ el: intervalPatternMount,
+ components: {
+ intervalPatternInput,
+ },
+ render(createElement) {
+ return createElement('interval-pattern-input', {
+ props: {
+ initialCronInterval,
+ },
+ });
+ },
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ /* Most of the form is written in haml, but for fields with more complex behaviors,
+ * you should mount individual Vue components here. If at some point components need
+ * to share state, it may make sense to refactor the whole form to Vue */
+
+ initIntervalPatternInput();
+
+ // Initialize non-Vue JS components in the form
+
+ const formElement = document.getElementById('new-pipeline-schedule-form');
+
+ gl.timezoneDropdown = new TimezoneDropdown();
+ gl.targetBranchDropdown = new TargetBranchDropdown();
+ gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
+
+ setupPipelineVariableList($('.js-pipeline-variable-list'));
+});
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
new file mode 100644
index 00000000000..a6c945e22b0
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import PipelineSchedulesCallout from './components/pipeline_schedules_callout.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipeline-schedules-callout',
+ components: {
+ 'pipeline-schedules-callout': PipelineSchedulesCallout,
+ },
+ render(createElement) {
+ return createElement('pipeline-schedules-callout');
+ },
+}));
diff --git a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js b/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js
new file mode 100644
index 00000000000..644efd10509
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js
@@ -0,0 +1,71 @@
+function insertRow($row) {
+ const $rowClone = $row.clone();
+ $rowClone.removeAttr('data-is-persisted');
+ $rowClone.find('input, textarea').val('');
+ $row.after($rowClone);
+}
+
+function removeRow($row) {
+ const isPersisted = gl.utils.convertPermissionToBoolean($row.attr('data-is-persisted'));
+
+ if (isPersisted) {
+ $row.hide();
+ $row
+ .find('.js-destroy-input')
+ .val(1);
+ } else {
+ $row.remove();
+ }
+}
+
+function checkIfRowTouched($row) {
+ return $row.find('.js-user-input').toArray().some(el => $(el).val().length > 0);
+}
+
+function setupPipelineVariableList(parent = document) {
+ const $parent = $(parent);
+
+ $parent.on('click', '.js-row-remove-button', (e) => {
+ const $row = $(e.currentTarget).closest('.js-row');
+ removeRow($row);
+
+ e.preventDefault();
+ });
+
+ // Remove any empty rows except the last r
+ $parent.on('blur', '.js-user-input', (e) => {
+ const $row = $(e.currentTarget).closest('.js-row');
+
+ const isTouched = checkIfRowTouched($row);
+ if ($row.is(':not(:last-child)') && !isTouched) {
+ removeRow($row);
+ }
+ });
+
+ // Always make sure there is an empty last row
+ $parent.on('input', '.js-user-input', () => {
+ const $lastRow = $parent.find('.js-row').last();
+
+ const isTouched = checkIfRowTouched($lastRow);
+ if (isTouched) {
+ insertRow($lastRow);
+ }
+ });
+
+ // Clear out the empty last row so it
+ // doesn't get submitted and throw validation errors
+ $parent.closest('form').on('submit', () => {
+ const $lastRow = $parent.find('.js-row').last();
+
+ const isTouched = checkIfRowTouched($lastRow);
+ if (!isTouched) {
+ $lastRow.find('input, textarea').attr('name', '');
+ }
+ });
+}
+
+export {
+ setupPipelineVariableList,
+ insertRow,
+ removeRow,
+};
diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js
index 4252b615887..26a36ad54d1 100644
--- a/app/assets/javascripts/pipelines.js
+++ b/app/assets/javascripts/pipelines.js
@@ -1,42 +1,14 @@
-/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */
+import LinkedTabs from './lib/utils/bootstrap_linked_tabs';
-require('./lib/utils/bootstrap_linked_tabs');
-
-((global) => {
- class Pipelines {
- constructor(options = {}) {
- if (options.initTabs && options.tabsOptions) {
- new global.LinkedTabs(options.tabsOptions);
- }
-
- if (options.pipelineStatusUrl) {
- gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
- }
-
- this.addMarginToBuildColumns();
+export default class Pipelines {
+ constructor(options = {}) {
+ if (options.initTabs && options.tabsOptions) {
+ // eslint-disable-next-line no-new
+ new LinkedTabs(options.tabsOptions);
}
- addMarginToBuildColumns() {
- this.pipelineGraph = document.querySelector('.js-pipeline-graph');
-
- const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
-
- for (const buildNodeIndex in secondChildBuildNodes) {
- const buildNode = secondChildBuildNodes[buildNodeIndex];
- const firstChildBuildNode = buildNode.previousElementSibling;
- if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue;
- const multiBuildColumn = buildNode.closest('.stage-column');
- const previousColumn = multiBuildColumn.previousElementSibling;
- if (!previousColumn || !previousColumn.matches('.stage-column')) continue;
- multiBuildColumn.classList.add('left-margin');
- firstChildBuildNode.classList.add('left-connector');
- const columnBuilds = previousColumn.querySelectorAll('.build');
- if (columnBuilds.length === 1) previousColumn.classList.add('no-margin');
- }
-
- this.pipelineGraph.classList.remove('hidden');
+ if (options.pipelineStatusUrl) {
+ gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
}
-
- global.Pipelines = Pipelines;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
index d1c60b570de..16cc0761fc1 100644
--- a/app/assets/javascripts/pipelines/components/async_button.vue
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -1,8 +1,9 @@
<script>
/* eslint-disable no-new, no-alert */
-/* global Flash */
-import '~/flash';
+
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
@@ -10,49 +11,42 @@ export default {
type: String,
required: true,
},
-
- service: {
- type: Object,
- required: true,
- },
-
title: {
type: String,
required: true,
},
-
icon: {
type: String,
required: true,
},
-
cssClass: {
type: String,
required: true,
},
-
confirmActionMessage: {
type: String,
required: false,
},
},
-
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
data() {
return {
isLoading: false,
};
},
-
computed: {
iconClass() {
return `fa fa-${this.icon}`;
},
-
buttonClass() {
- return `btn has-tooltip ${this.cssClass}`;
+ return `btn ${this.cssClass}`;
},
},
-
methods: {
onClick() {
if (this.confirmActionMessage && confirm(this.confirmActionMessage)) {
@@ -61,21 +55,10 @@ export default {
this.makeRequest();
}
},
-
makeRequest() {
this.isLoading = true;
- $(this.$el).tooltip('destroy');
-
- this.service.postAction(this.endpoint)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshPipelines');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ eventHub.$emit('postAction', this.endpoint);
},
},
};
@@ -83,6 +66,7 @@ export default {
<template>
<button
+ v-tooltip
type="button"
@click="onClick"
:class="buttonClass"
@@ -93,10 +77,8 @@ export default {
:disabled="isLoading">
<i
:class="iconClass"
- aria-hidden="true" />
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- v-if="isLoading" />
+ aria-hidden="true">
+ </i>
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
new file mode 100644
index 00000000000..54227425d2a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -0,0 +1,63 @@
+<script>
+ import getActionIcon from '../../../vue_shared/ci_action_icons';
+ import tooltip from '../../../vue_shared/directives/tooltip';
+
+ /**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ * TODO: Remove UJS from here and use an async request instead.
+ */
+ export default {
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
+
+ link: {
+ type: String,
+ required: true,
+ },
+
+ actionMethod: {
+ type: String,
+ required: true,
+ },
+
+ actionIcon: {
+ type: String,
+ required: true,
+ },
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ computed: {
+ actionIconSvg() {
+ return getActionIcon(this.actionIcon);
+ },
+
+ cssClass() {
+ return `js-${gl.text.dasherize(this.actionIcon)}`;
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ v-tooltip
+ :data-method="actionMethod"
+ :title="tooltipText"
+ :href="link"
+ class="ci-action-icon-container"
+ data-container="body">
+
+ <i
+ class="ci-action-icon-wrapper"
+ :class="cssClass"
+ v-html="actionIconSvg"
+ aria-hidden="true"
+ />
+ </a>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
new file mode 100644
index 00000000000..18fe1847eef
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
@@ -0,0 +1,55 @@
+<script>
+ import getActionIcon from '../../../vue_shared/ci_action_icons';
+ import tooltip from '../../../vue_shared/directives/tooltip';
+
+ /**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ * TODO: Remove UJS from here and use an async request instead.
+ */
+ export default {
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
+
+ link: {
+ type: String,
+ required: true,
+ },
+
+ actionMethod: {
+ type: String,
+ required: true,
+ },
+
+ actionIcon: {
+ type: String,
+ required: true,
+ },
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ computed: {
+ actionIconSvg() {
+ return getActionIcon(this.actionIcon);
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ v-tooltip
+ :data-method="actionMethod"
+ :title="tooltipText"
+ :href="link"
+ rel="nofollow"
+ class="ci-action-icon-wrapper js-ci-status-icon"
+ data-container="body"
+ v-html="actionIconSvg"
+ aria-label="Job's action">
+ </a>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
new file mode 100644
index 00000000000..7695b04db74
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -0,0 +1,107 @@
+<script>
+ import jobNameComponent from './job_name_component.vue';
+ import jobComponent from './job_component.vue';
+ import tooltip from '../../../vue_shared/directives/tooltip';
+
+ /**
+ * Renders the dropdown for the pipeline graph.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "icon_action_retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+ export default {
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ components: {
+ jobComponent,
+ jobNameComponent,
+ },
+
+ computed: {
+ tooltipText() {
+ return `${this.job.name} - ${this.job.status.label}`;
+ },
+ },
+
+ methods: {
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
+ .on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+ },
+
+ mounted() {
+ this.stopDropdownClickPropagation();
+ },
+ };
+</script>
+<template>
+ <div>
+ <button
+ v-tooltip
+ type="button"
+ data-toggle="dropdown"
+ data-container="body"
+ class="dropdown-menu-toggle build-content"
+ :title="tooltipText">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status" />
+
+ <span class="dropdown-counter-badge">
+ {{job.size}}
+ </span>
+ </button>
+
+ <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
+ <li class="scrollable-menu">
+ <ul>
+ <li v-for="item in job.jobs">
+ <job-component
+ :job="item"
+ :is-dropdown="true"
+ css-class-job-name="mini-pipeline-graph-dropdown-item"
+ />
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
new file mode 100644
index 00000000000..66bc1d1979c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -0,0 +1,77 @@
+<script>
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import '~/flash';
+ import stageColumnComponent from './stage_column_component.vue';
+
+ export default {
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ stageColumnComponent,
+ loadingIcon,
+ },
+
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
+ },
+ },
+
+ methods: {
+ capitalizeStageName(name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ },
+
+ isFirstColumn(index) {
+ return index === 0;
+ },
+
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (index === 0 && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="build-content middle-block js-pipeline-graph">
+ <div class="pipeline-visualization pipeline-graph">
+ <div class="text-center">
+ <loading-icon
+ v-if="isLoading"
+ size="3"
+ />
+ </div>
+
+ <ul
+ v-if="!isLoading"
+ class="stage-column-list">
+ <stage-column-component
+ v-for="(stage, index) in graph"
+ :title="capitalizeStageName(stage.name)"
+ :jobs="stage.groups"
+ :key="stage.name"
+ :stage-connector-class="stageConnectorClass(index, stage)"
+ :is-first-column="isFirstColumn(index)"/>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
new file mode 100644
index 00000000000..1f5ed3f1074
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -0,0 +1,122 @@
+<script>
+ import actionComponent from './action_component.vue';
+ import dropdownActionComponent from './dropdown_action_component.vue';
+ import jobNameComponent from './job_name_component.vue';
+ import tooltip from '../../../vue_shared/directives/tooltip';
+
+ /**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "icon_action_retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+ export default {
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ isDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ components: {
+ actionComponent,
+ dropdownActionComponent,
+ jobNameComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ computed: {
+ tooltipText() {
+ return `${this.job.name} - ${this.job.status.label}`;
+ },
+
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <a
+ v-tooltip
+ v-if="job.status.details_path"
+ :href="job.status.details_path"
+ :title="tooltipText"
+ :class="cssClassJobName"
+ data-container="body">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status"
+ />
+ </a>
+
+ <div
+ v-else
+ v-tooltip
+ :title="tooltipText"
+ :class="cssClassJobName"
+ data-container="body">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status"
+ />
+ </div>
+
+ <action-component
+ v-if="hasAction && !isDropdown"
+ :tooltip-text="job.status.action.title"
+ :link="job.status.action.path"
+ :action-icon="job.status.action.icon"
+ :action-method="job.status.action.method"
+ />
+
+ <dropdown-action-component
+ v-if="hasAction && isDropdown"
+ :tooltip-text="job.status.action.title"
+ :link="job.status.action.path"
+ :action-icon="job.status.action.icon"
+ :action-method="job.status.action.method"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
new file mode 100644
index 00000000000..d8856e10668
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -0,0 +1,37 @@
+<script>
+ import ciIcon from '../../../vue_shared/components/ci_icon.vue';
+
+ /**
+ * Component that renders both the CI icon status and the job name.
+ * Used in
+ * - Badge component
+ * - Dropdown badge components
+ */
+ export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ ciIcon,
+ },
+ };
+</script>
+<template>
+ <span>
+ <ci-icon
+ :status="status" />
+
+ <span class="ci-status-text">
+ {{name}}
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
new file mode 100644
index 00000000000..9b1bbb0906f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -0,0 +1,83 @@
+<script>
+import jobComponent from './job_component.vue';
+import dropdownJobComponent from './dropdown_job_component.vue';
+
+export default {
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+
+ jobs: {
+ type: Array,
+ required: true,
+ },
+
+ isFirstColumn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ stageConnectorClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ components: {
+ jobComponent,
+ dropdownJobComponent,
+ },
+
+ methods: {
+ firstJob(list) {
+ return list[0];
+ },
+
+ jobId(job) {
+ return `ci-badge-${job.name}`;
+ },
+
+ buildConnnectorClass(index) {
+ return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ },
+ },
+};
+</script>
+<template>
+ <li
+ class="stage-column"
+ :class="stageConnectorClass">
+ <div class="stage-name">
+ {{title}}
+ </div>
+ <div class="builds-container">
+ <ul>
+ <li
+ v-for="(job, index) in jobs"
+ :key="job.id"
+ class="build"
+ :class="buildConnnectorClass(index)"
+ :id="jobId(job)">
+
+ <div class="curve"></div>
+
+ <job-component
+ v-if="job.size === 1"
+ :job="job"
+ css-class-job-name="build-content"
+ />
+
+ <dropdown-job-component
+ v-if="job.size > 1"
+ :job="job"
+ />
+
+ </li>
+ </ul>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
new file mode 100644
index 00000000000..2a1ecac3707
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -0,0 +1,97 @@
+<script>
+import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+export default {
+ name: 'PipelineHeaderSection',
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
+
+ data() {
+ return {
+ actions: this.getActions(),
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ },
+
+ methods: {
+ postAction(action) {
+ const index = this.actions.indexOf(action);
+
+ this.$set(this.actions[index], 'isLoading', true);
+
+ eventHub.$emit('headerPostAction', action);
+ },
+
+ getActions() {
+ const actions = [];
+
+ if (this.pipeline.retry_path) {
+ actions.push({
+ label: 'Retry',
+ path: this.pipeline.retry_path,
+ cssClass: 'js-retry-button btn btn-inverted-secondary',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ if (this.pipeline.cancel_path) {
+ actions.push({
+ label: 'Cancel running',
+ path: this.pipeline.cancel_path,
+ cssClass: 'js-btn-cancel-pipeline btn btn-danger',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ return actions;
+ },
+ },
+
+ watch: {
+ pipeline() {
+ this.actions = this.getActions();
+ },
+ },
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ item-name="Pipeline"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ :actions="actions"
+ @actionClicked="postAction"
+ />
+ <loading-icon
+ v-if="isLoading"
+ size="2"/>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.js b/app/assets/javascripts/pipelines/components/nav_controls.js
deleted file mode 100644
index 6aa10531034..00000000000
--- a/app/assets/javascripts/pipelines/components/nav_controls.js
+++ /dev/null
@@ -1,52 +0,0 @@
-export default {
- props: {
- newPipelinePath: {
- type: String,
- required: true,
- },
-
- hasCiEnabled: {
- type: Boolean,
- required: true,
- },
-
- helpPagePath: {
- type: String,
- required: true,
- },
-
- ciLintPath: {
- type: String,
- required: true,
- },
-
- canCreatePipeline: {
- type: Boolean,
- required: true,
- },
- },
-
- template: `
- <div class="nav-controls">
- <a
- v-if="canCreatePipeline"
- :href="newPipelinePath"
- class="btn btn-create">
- Run Pipeline
- </a>
-
- <a
- v-if="!hasCiEnabled"
- :href="helpPagePath"
- class="btn btn-info">
- Get started with Pipelines
- </a>
-
- <a
- :href="ciLintPath"
- class="btn btn-default">
- CI Lint
- </a>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue
new file mode 100644
index 00000000000..632fc167f2b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -0,0 +1,54 @@
+<script>
+export default {
+ name: 'PipelineNavControls',
+ props: {
+ newPipelinePath: {
+ type: String,
+ required: true,
+ },
+
+ hasCiEnabled: {
+ type: Boolean,
+ required: true,
+ },
+
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+
+ ciLintPath: {
+ type: String,
+ required: true,
+ },
+
+ canCreatePipeline: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="nav-controls">
+ <a
+ v-if="canCreatePipeline"
+ :href="newPipelinePath"
+ class="btn btn-create">
+ Run Pipeline
+ </a>
+
+ <a
+ v-if="!hasCiEnabled"
+ :href="helpPagePath"
+ class="btn btn-info">
+ Get started with Pipelines
+ </a>
+
+ <a
+ :href="ciLintPath"
+ class="btn btn-default">
+ CI Lint
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.js b/app/assets/javascripts/pipelines/components/navigation_tabs.js
deleted file mode 100644
index 1626ae17a30..00000000000
--- a/app/assets/javascripts/pipelines/components/navigation_tabs.js
+++ /dev/null
@@ -1,72 +0,0 @@
-export default {
- props: {
- scope: {
- type: String,
- required: true,
- },
-
- count: {
- type: Object,
- required: true,
- },
-
- paths: {
- type: Object,
- required: true,
- },
- },
-
- mounted() {
- $(document).trigger('init.scrolling-tabs');
- },
-
- template: `
- <ul class="nav-links scrolling-tabs">
- <li
- class="js-pipelines-tab-all"
- :class="{ 'active': scope === 'all'}">
- <a :href="paths.allPath">
- All
- <span class="badge js-totalbuilds-count">
- {{count.all}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-pending"
- :class="{ 'active': scope === 'pending'}">
- <a :href="paths.pendingPath">
- Pending
- <span class="badge">
- {{count.pending}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-running"
- :class="{ 'active': scope === 'running'}">
- <a :href="paths.runningPath">
- Running
- <span class="badge">
- {{count.running}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-finished"
- :class="{ 'active': scope === 'finished'}">
- <a :href="paths.finishedPath">
- Finished
- <span class="badge">
- {{count.finished}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-branches"
- :class="{ 'active': scope === 'branches'}">
- <a :href="paths.branchesPath">Branches</a>
- </li>
- <li class="js-pipelines-tab-tags"
- :class="{ 'active': scope === 'tags'}">
- <a :href="paths.tagsPath">Tags</a>
- </li>
- </ul>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.vue b/app/assets/javascripts/pipelines/components/navigation_tabs.vue
new file mode 100644
index 00000000000..d2f6d47f043
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/navigation_tabs.vue
@@ -0,0 +1,76 @@
+<script>
+export default {
+ name: 'PipelineNavigationTabs',
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ },
+ count: {
+ type: Object,
+ required: true,
+ },
+ paths: {
+ type: Object,
+ required: true,
+ },
+ },
+ mounted() {
+ $(document).trigger('init.scrolling-tabs');
+ },
+};
+</script>
+<template>
+ <ul class="nav-links scrolling-tabs">
+ <li
+ class="js-pipelines-tab-all"
+ :class="{ active: scope === 'all'}">
+ <a :href="paths.allPath">
+ All
+ <span class="badge js-totalbuilds-count">
+ {{count.all}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-pending"
+ :class="{ active: scope === 'pending'}">
+ <a :href="paths.pendingPath">
+ Pending
+ <span class="badge">
+ {{count.pending}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-running"
+ :class="{ active: scope === 'running'}">
+ <a :href="paths.runningPath">
+ Running
+ <span class="badge">
+ {{count.running}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-finished"
+ :class="{ active: scope === 'finished'}">
+ <a :href="paths.finishedPath">
+ Finished
+ <span class="badge">
+ {{count.finished}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-branches"
+ :class="{ active: scope === 'branches'}">
+ <a :href="paths.branchesPath">Branches</a>
+ </li>
+ <li
+ class="js-pipelines-tab-tags"
+ :class="{ active: scope === 'tags'}">
+ <a :href="paths.tagsPath">Tags</a>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js
deleted file mode 100644
index 4e183d5c8ec..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_url.js
+++ /dev/null
@@ -1,56 +0,0 @@
-export default {
- props: [
- 'pipeline',
- ],
- computed: {
- user() {
- return !!this.pipeline.user;
- },
- },
- template: `
- <td>
- <a
- :href="pipeline.path"
- class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
- </a>
- <span>by</span>
- <a
- class="js-pipeline-url-user"
- v-if="user"
- :href="pipeline.user.web_url">
- <img
- v-if="user"
- class="avatar has-tooltip s20 "
- :title="pipeline.user.name"
- data-container="body"
- :src="pipeline.user.avatar_url"
- >
- </a>
- <span
- v-if="!user"
- class="js-pipeline-url-api api monospace">
- API
- </span>
- <span
- v-if="pipeline.flags.latest"
- class="js-pipeline-url-lastest label label-success has-tooltip"
- title="Latest pipeline for this branch"
- data-original-title="Latest pipeline for this branch">
- latest
- </span>
- <span
- v-if="pipeline.flags.yaml_errors"
- class="js-pipeline-url-yaml label label-danger has-tooltip"
- :title="pipeline.yaml_errors"
- :data-original-title="pipeline.yaml_errors">
- yaml invalid
- </span>
- <span
- v-if="pipeline.flags.stuck"
- class="js-pipeline-url-stuck label label-warning">
- stuck
- </span>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
new file mode 100644
index 00000000000..2ca5ac2912f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -0,0 +1,67 @@
+<script>
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ userAvatarLink,
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-section section-15 hidden-xs hidden-sm">
+ <a
+ :href="pipeline.path"
+ class="js-pipeline-url-link">
+ <span class="pipeline-id">#{{pipeline.id}}</span>
+ </a>
+ <span>by</span>
+ <user-avatar-link
+ v-if="user"
+ class="js-pipeline-url-user"
+ :link-href="pipeline.user.path"
+ :img-src="pipeline.user.avatar_url"
+ :tooltip-text="pipeline.user.name"
+ />
+ <span
+ v-if="!user"
+ class="js-pipeline-url-api api">
+ API
+ </span>
+ <div class="label-container">
+ <span
+ v-if="pipeline.flags.latest"
+ v-tooltip
+ class="js-pipeline-url-latest label label-success"
+ title="Latest pipeline for this branch">
+ latest
+ </span>
+ <span
+ v-if="pipeline.flags.yaml_errors"
+ v-tooltip
+ class="js-pipeline-url-yaml label label-danger"
+ :title="pipeline.yaml_errors">
+ yaml invalid
+ </span>
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck label label-warning">
+ stuck
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
new file mode 100644
index 00000000000..5df317a76bf
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -0,0 +1,211 @@
+<script>
+ import PipelinesService from '../services/pipelines_service';
+ import pipelinesMixin from '../mixins/pipelines';
+ import tablePagination from '../../vue_shared/components/table_pagination.vue';
+ import navigationTabs from './navigation_tabs.vue';
+ import navigationControls from './nav_controls.vue';
+
+ export default {
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ tablePagination,
+ navigationTabs,
+ navigationControls,
+ },
+ mixins: [
+ pipelinesMixin,
+ ],
+ data() {
+ const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
+
+ return {
+ endpoint: pipelinesData.endpoint,
+ cssClass: pipelinesData.cssClass,
+ helpPagePath: pipelinesData.helpPagePath,
+ newPipelinePath: pipelinesData.newPipelinePath,
+ canCreatePipeline: pipelinesData.canCreatePipeline,
+ allPath: pipelinesData.allPath,
+ pendingPath: pipelinesData.pendingPath,
+ runningPath: pipelinesData.runningPath,
+ finishedPath: pipelinesData.finishedPath,
+ branchesPath: pipelinesData.branchesPath,
+ tagsPath: pipelinesData.tagsPath,
+ hasCi: pipelinesData.hasCi,
+ ciLintPath: pipelinesData.ciLintPath,
+ state: this.store.state,
+ apiScope: 'all',
+ pagenum: 1,
+ };
+ },
+ computed: {
+ canCreatePipelineParsed() {
+ return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
+ },
+ scope() {
+ const scope = gl.utils.getParameterByName('scope');
+ return scope === null ? 'all' : scope;
+ },
+
+ /**
+ * The empty state should only be rendered when the request is made to fetch all pipelines
+ * and none is returned.
+ *
+ * @return {Boolean}
+ */
+ shouldRenderEmptyState() {
+ return !this.isLoading &&
+ !this.hasError &&
+ this.hasMadeRequest &&
+ !this.state.pipelines.length &&
+ (this.scope === 'all' || this.scope === null);
+ },
+ /**
+ * When a specific scope does not have pipelines we render a message.
+ *
+ * @return {Boolean}
+ */
+ shouldRenderNoPipelinesMessage() {
+ return !this.isLoading &&
+ !this.hasError &&
+ !this.state.pipelines.length &&
+ this.scope !== 'all' &&
+ this.scope !== null;
+ },
+
+ shouldRenderTable() {
+ return !this.hasError &&
+ !this.isLoading && this.state.pipelines.length;
+ },
+ /**
+ * Pagination should only be rendered when there is more than one page.
+ *
+ * @return {Boolean}
+ */
+ shouldRenderPagination() {
+ return !this.isLoading &&
+ this.state.pipelines.length &&
+ this.state.pageInfo.total > this.state.pageInfo.perPage;
+ },
+ hasCiEnabled() {
+ return this.hasCi !== undefined;
+ },
+ paths() {
+ return {
+ allPath: this.allPath,
+ pendingPath: this.pendingPath,
+ finishedPath: this.finishedPath,
+ runningPath: this.runningPath,
+ branchesPath: this.branchesPath,
+ tagsPath: this.tagsPath,
+ };
+ },
+ pageParameter() {
+ return gl.utils.getParameterByName('page') || this.pagenum;
+ },
+ scopeParameter() {
+ return gl.utils.getParameterByName('scope') || this.apiScope;
+ },
+ },
+ created() {
+ this.service = new PipelinesService(this.endpoint);
+ this.requestData = { page: this.pageParameter, scope: this.scopeParameter };
+ },
+ methods: {
+ /**
+ * Will change the page number and update the URL.
+ *
+ * @param {Number} pageNumber desired page to go to.
+ */
+ change(pageNumber) {
+ const param = gl.utils.setParamInURL('page', pageNumber);
+
+ gl.utils.visitUrl(param);
+ return param;
+ },
+
+ successCallback(resp) {
+ return resp.json().then((response) => {
+ this.store.storeCount(response.count);
+ this.store.storePagination(resp.headers);
+ this.setCommonData(response.pipelines);
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div :class="cssClass">
+ <div
+ class="top-area scrolling-tabs-container inner-page-scroll-tabs"
+ v-if="!isLoading && !shouldRenderEmptyState">
+ <div class="fade-left">
+ <i
+ class="fa fa-angle-left"
+ aria-hidden="true">
+ </i>
+ </div>
+ <div class="fade-right">
+ <i
+ class="fa fa-angle-right"
+ aria-hidden="true">
+ </i>
+ </div>
+ <navigation-tabs
+ :scope="scope"
+ :count="state.count"
+ :paths="paths"
+ />
+
+ <navigation-controls
+ :new-pipeline-path="newPipelinePath"
+ :has-ci-enabled="hasCiEnabled"
+ :help-page-path="helpPagePath"
+ :ciLintPath="ciLintPath"
+ :can-create-pipeline="canCreatePipelineParsed "
+ />
+ </div>
+
+ <div class="content-list pipelines">
+
+ <loading-icon
+ label="Loading Pipelines"
+ size="3"
+ v-if="isLoading"
+ />
+
+ <empty-state
+ v-if="shouldRenderEmptyState"
+ :help-page-path="helpPagePath"
+ />
+
+ <error-state v-if="shouldRenderErrorState" />
+
+ <div
+ class="blank-state blank-state-no-icon"
+ v-if="shouldRenderNoPipelinesMessage">
+ <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
+ </div>
+
+ <div
+ class="table-holder"
+ v-if="shouldRenderTable">
+
+ <pipelines-table-component
+ :pipelines="state.pipelines"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
+ </div>
+
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="change"
+ :pageInfo="state.pageInfo"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js
deleted file mode 100644
index ffda18d2e0f..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/* eslint-disable no-new */
-/* global Flash */
-import '~/flash';
-import playIconSvg from 'icons/_icon_play.svg';
-import eventHub from '../event_hub';
-
-export default {
- props: {
- actions: {
- type: Array,
- required: true,
- },
-
- service: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- return {
- playIconSvg,
- isLoading: false,
- };
- },
-
- methods: {
- onClickAction(endpoint) {
- this.isLoading = true;
-
- $(this.$refs.tooltip).tooltip('destroy');
-
- this.service.postAction(endpoint)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshPipelines');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
- },
-
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
-
- return !action.playable;
- },
- },
-
- template: `
- <div class="btn-group" v-if="actions">
- <button
- type="button"
- class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
- title="Manual job"
- data-toggle="dropdown"
- data-placement="top"
- aria-label="Manual job"
- ref="tooltip"
- :disabled="isLoading">
- ${playIconSvg}
- <i
- class="fa fa-caret-down"
- aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </button>
-
- <ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="action in actions">
- <button
- type="button"
- class="js-pipeline-action-link no-btn btn"
- @click="onClickAction(action.path)"
- :class="{ 'disabled': isActionDisabled(action) }"
- :disabled="isActionDisabled(action)">
- ${playIconSvg}
- <span>{{action.name}}</span>
- </button>
- </li>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
new file mode 100644
index 00000000000..01dfe51cc17
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
@@ -0,0 +1,78 @@
+<script>
+ /* global Flash */
+ import '~/flash';
+ import playIconSvg from 'icons/_icon_play.svg';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ props: {
+ actions: {
+ type: Array,
+ required: true,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
+ data() {
+ return {
+ playIconSvg,
+ isLoading: false,
+ };
+ },
+ methods: {
+ onClickAction(endpoint) {
+ this.isLoading = true;
+
+ eventHub.$emit('postAction', endpoint);
+ },
+
+ isActionDisabled(action) {
+ if (action.playable === undefined) {
+ return false;
+ }
+
+ return !action.playable;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="btn-group">
+ <button
+ v-tooltip
+ type="button"
+ class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions"
+ title="Manual job"
+ data-toggle="dropdown"
+ data-placement="top"
+ aria-label="Manual job"
+ :disabled="isLoading">
+ <span v-html="playIconSvg"></span>
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ <loading-icon v-if="isLoading" />
+ </button>
+
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li v-for="action in actions">
+ <button
+ type="button"
+ class="js-pipeline-action-link no-btn btn"
+ @click="onClickAction(action.path)"
+ :class="{ disabled: isActionDisabled(action) }"
+ :disabled="isActionDisabled(action)">
+ <span v-html="playIconSvg"></span>
+ <span>{{action.name}}</span>
+ </button>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.js b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js
deleted file mode 100644
index f18e2dfadaf..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export default {
- props: {
- artifacts: {
- type: Array,
- required: true,
- },
- },
-
- template: `
- <div class="btn-group" role="group">
- <button
- class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
- title="Artifacts"
- data-placement="top"
- data-toggle="dropdown"
- aria-label="Artifacts">
- <i class="fa fa-download" aria-hidden="true"></i>
- <i class="fa fa-caret-down" aria-hidden="true"></i>
- </button>
- <ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="artifact in artifacts">
- <a
- rel="nofollow"
- download
- :href="artifact.path">
- <i class="fa fa-download" aria-hidden="true"></i>
- <span>Download {{artifact.name}} artifacts</span>
- </a>
- </li>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
new file mode 100644
index 00000000000..b19bd509a00
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -0,0 +1,51 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ };
+</script>
+<template>
+ <div
+ class="btn-group"
+ role="group">
+ <button
+ v-tooltip
+ class="dropdown-toggle btn btn-default build-artifacts js-pipeline-dropdown-download"
+ title="Artifacts"
+ data-placement="top"
+ data-toggle="dropdown"
+ aria-label="Artifacts">
+ <i
+ class="fa fa-download"
+ aria-hidden="true">
+ </i>
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li v-for="artifact in artifacts">
+ <a
+ rel="nofollow"
+ download
+ :href="artifact.path">
+ <i
+ class="fa fa-download"
+ aria-hidden="true">
+ </i>
+ <span>Download {{artifact.name}} artifacts</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
new file mode 100644
index 00000000000..5088d92209f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -0,0 +1,59 @@
+<script>
+ import pipelinesTableRowComponent from './pipelines_table_row.vue';
+
+ /**
+ * Pipelines Table Component.
+ *
+ * Given an array of objects, renders a table.
+ */
+ export default {
+ props: {
+ pipelines: {
+ type: Array,
+ required: true,
+ },
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ components: {
+ pipelinesTableRowComponent,
+ },
+ };
+</script>
+<template>
+ <div class="ci-table">
+ <div
+ class="gl-responsive-table-row table-row-header"
+ role="row">
+ <div
+ class="table-section section-10 js-pipeline-status pipeline-status"
+ role="rowheader">
+ Status
+ </div>
+ <div
+ class="table-section section-15 js-pipeline-info pipeline-info"
+ role="rowheader">
+ Pipeline
+ </div>
+ <div
+ class="table-section section-25 js-pipeline-commit pipeline-commit"
+ role="rowheader">
+ Commit
+ </div>
+ <div
+ class="table-section section-15 js-pipeline-stages pipeline-stages"
+ role="rowheader">
+ Stages
+ </div>
+ </div>
+ <pipelines-table-row-component
+ v-for="model in pipelines"
+ :key="model.id"
+ :pipeline="model"
+ :update-graph-dropdown="updateGraphDropdown"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
new file mode 100644
index 00000000000..c3f1c426d8a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -0,0 +1,297 @@
+<script>
+/* eslint-disable no-param-reassign */
+import asyncButtonComponent from './async_button.vue';
+import pipelinesActionsComponent from './pipelines_actions.vue';
+import pipelinesArtifactsComponent from './pipelines_artifacts.vue';
+import ciBadge from '../../vue_shared/components/ci_badge_link.vue';
+import pipelineStage from './stage.vue';
+import pipelineUrl from './pipeline_url.vue';
+import pipelinesTimeago from './time_ago.vue';
+import commitComponent from '../../vue_shared/components/commit.vue';
+
+/**
+ * Pipeline table row.
+ *
+ * Given the received object renders a table row in the pipelines' table.
+ */
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ components: {
+ asyncButtonComponent,
+ pipelinesActionsComponent,
+ pipelinesArtifactsComponent,
+ commitComponent,
+ pipelineStage,
+ pipelineUrl,
+ ciBadge,
+ pipelinesTimeago,
+ },
+ computed: {
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * This field needs a lot of verification, because of different possible cases:
+ *
+ * 1. person who is an author of a commit might be a GitLab user
+ * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
+ * 3. If GitLab user does not have avatar he/she might have a Gravatar
+ * 4. If committer is not a GitLab User he/she can have a Gravatar
+ * 5. We do not have consistent API object in this case
+ * 6. We should improve API and the code
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ let commitAuthorInformation;
+
+ if (!this.pipeline || !this.pipeline.commit) {
+ return null;
+ }
+
+ // 1. person who is an author of a commit might be a GitLab user
+ if (this.pipeline.commit.author) {
+ // 2. if person who is an author of a commit is a GitLab user
+ // he/she can have a GitLab avatar
+ if (this.pipeline.commit.author.avatar_url) {
+ commitAuthorInformation = this.pipeline.commit.author;
+
+ // 3. If GitLab user does not have avatar he/she might have a Gravatar
+ } else if (this.pipeline.commit.author_gravatar_url) {
+ commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ });
+ }
+ // 4. If committer is not a GitLab User he/she can have a Gravatar
+ } else {
+ commitAuthorInformation = {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ path: `mailto:${this.pipeline.commit.author_email}`,
+ username: this.pipeline.commit.author_name,
+ };
+ }
+
+ return commitAuthorInformation;
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.pipeline.ref &&
+ this.pipeline.ref.tag) {
+ return this.pipeline.ref.tag;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit ref.
+ * Needed to render the commit component column.
+ *
+ * Matches `path` prop sent in the API to `ref_url` prop needed
+ * in the commit component.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.pipeline.ref) {
+ return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
+ if (prop === 'path') {
+ accumulator.ref_url = this.pipeline.ref[prop];
+ } else {
+ accumulator[prop] = this.pipeline.ref[prop];
+ }
+ return accumulator;
+ }, {});
+ }
+
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit url.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.commit_path) {
+ return this.pipeline.commit.commit_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit short sha.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.short_id) {
+ return this.pipeline.commit.short_id;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit title.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.title) {
+ return this.pipeline.commit.title;
+ }
+ return undefined;
+ },
+
+ /**
+ * Timeago components expects a number
+ *
+ * @return {type} description
+ */
+ pipelineDuration() {
+ if (this.pipeline.details && this.pipeline.details.duration) {
+ return this.pipeline.details.duration;
+ }
+
+ return 0;
+ },
+
+ /**
+ * Timeago component expects a String.
+ *
+ * @return {String}
+ */
+ pipelineFinishedAt() {
+ if (this.pipeline.details && this.pipeline.details.finished_at) {
+ return this.pipeline.details.finished_at;
+ }
+
+ return '';
+ },
+
+ pipelineStatus() {
+ if (this.pipeline.details && this.pipeline.details.status) {
+ return this.pipeline.details.status;
+ }
+ return {};
+ },
+
+ displayPipelineActions() {
+ return this.pipeline.flags.retryable ||
+ this.pipeline.flags.cancelable ||
+ this.pipeline.details.manual_actions.length ||
+ this.pipeline.details.artifacts.length;
+ },
+ },
+};
+</script>
+<template>
+ <div class="commit gl-responsive-table-row">
+ <div class="table-section section-10 commit-link">
+ <div class="table-mobile-header"
+ role="rowheader">
+ Status
+ </div>
+ <div class="table-mobile-content">
+ <ci-badge :status="pipelineStatus"/>
+ </div>
+ </div>
+
+ <pipeline-url :pipeline="pipeline" />
+
+ <div class="table-section section-25">
+ <div
+ class="table-mobile-header"
+ role="rowheader">
+ Commit
+ </div>
+ <div class="table-mobile-content">
+ <commit-component
+ :tag="commitTag"
+ :commit-ref="commitRef"
+ :commit-url="commitUrl"
+ :short-sha="commitShortSha"
+ :title="commitTitle"
+ :author="commitAuthor"/>
+ </div>
+ </div>
+
+ <div class="table-section section-wrap section-15 stage-cell">
+ <div
+ class="table-mobile-header"
+ role="rowheader">
+ Stages
+ </div>
+ <div class="table-mobile-content">
+ <div class="stage-container dropdown js-mini-pipeline-graph"
+ v-if="pipeline.details.stages.length > 0"
+ v-for="stage in pipeline.details.stages">
+ <pipeline-stage
+ :stage="stage"
+ :update-dropdown="updateGraphDropdown"
+ />
+ </div>
+ </div>
+ </div>
+
+ <pipelines-timeago
+ :duration="pipelineDuration"
+ :finished-time="pipelineFinishedAt"
+ />
+
+ <div
+ v-if="displayPipelineActions"
+ class="table-section section-20 table-button-footer pipeline-actions">
+ <div class="btn-group table-action-buttons">
+ <pipelines-actions-component
+ v-if="pipeline.details.manual_actions.length"
+ :actions="pipeline.details.manual_actions"
+ />
+
+ <pipelines-artifacts-component
+ v-if="pipeline.details.artifacts.length"
+ class="hidden-xs hidden-sm"
+ :artifacts="pipeline.details.artifacts"
+ />
+
+ <async-button-component
+ v-if="pipeline.flags.retryable"
+ :endpoint="pipeline.retry_path"
+ css-class="js-pipelines-retry-button btn-default btn-retry"
+ title="Retry"
+ icon="repeat"
+ />
+
+ <async-button-component
+ v-if="pipeline.flags.cancelable"
+ :endpoint="pipeline.cancel_path"
+ css-class="js-pipelines-cancel-button btn-remove"
+ title="Cancel"
+ icon="remove"
+ confirm-action-message="Are you sure you want to cancel this pipeline?"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js
deleted file mode 100644
index 203485f2990..00000000000
--- a/app/assets/javascripts/pipelines/components/stage.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/* global Flash */
-import StatusIconEntityMap from '../../ci_status_icons';
-
-export default {
- props: {
- stage: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- return {
- builds: '',
- spinner: '<span class="fa fa-spinner fa-spin"></span>',
- };
- },
-
- updated() {
- if (this.builds) {
- this.stopDropdownClickPropagation();
- }
- },
-
- methods: {
- fetchBuilds(e) {
- const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
-
- if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
-
- return this.$http.get(this.stage.dropdown_path)
- .then((response) => {
- this.builds = JSON.parse(response.body).html;
- })
- .catch(() => {
- // If dropdown is opened we'll close it.
- if (this.$el.classList.contains('open')) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
-
- const flash = new Flash('Something went wrong on our end.');
- return flash;
- });
- },
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
- .on('click', (e) => {
- e.stopPropagation();
- });
- },
- },
- computed: {
- buildsOrSpinner() {
- return this.builds ? this.builds : this.spinner;
- },
- dropdownClass() {
- if (this.builds) return 'js-builds-dropdown-container';
- return 'js-builds-dropdown-loading builds-dropdown-loading';
- },
- buildStatus() {
- return `Build: ${this.stage.status.label}`;
- },
- tooltip() {
- return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
- },
- triggerButtonClass() {
- return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
- },
- svgHTML() {
- return StatusIconEntityMap[this.stage.status.icon];
- },
- },
- template: `
- <div>
- <button
- @click="fetchBuilds($event)"
- :class="triggerButtonClass"
- :title="stage.title"
- data-placement="top"
- data-toggle="dropdown"
- type="button"
- :aria-label="stage.title"
- ref="dropdown">
- <span
- v-html="svgHTML"
- aria-hidden="true">
- </span>
- <i
- class="fa fa-caret-down"
- aria-hidden="true" />
- </button>
- <ul
- ref="dropdown-content"
- class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
- <div
- class="arrow-up"
- aria-hidden="true"></div>
- <div
- :class="dropdownClass"
- class="js-builds-dropdown-list scrollable-menu"
- v-html="buildsOrSpinner">
- </div>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
new file mode 100644
index 00000000000..a4a27247406
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -0,0 +1,176 @@
+<script>
+
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+/* global Flash */
+import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: '',
+ };
+ },
+
+ components: {
+ loadingIcon,
+ },
+
+ updated() {
+ if (this.dropdownContent.length > 0) {
+ this.stopDropdownClickPropagation();
+ }
+ },
+
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown &&
+ this.isDropdownOpen() &&
+ !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+
+ methods: {
+ onClickStage() {
+ if (!this.isDropdownOpen()) {
+ this.isLoading = true;
+ this.fetchJobs();
+ }
+ },
+
+ fetchJobs() {
+ this.$http.get(this.stage.dropdown_path)
+ .then(response => response.json())
+ .then((data) => {
+ this.dropdownContent = data.html;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.closeDropdown();
+ this.isLoading = false;
+
+ const flash = new Flash('Something went wrong on our end.');
+ return flash;
+ });
+ },
+
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
+ .on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+
+ closeDropdown() {
+ if (this.isDropdownOpen()) {
+ $(this.$refs.dropdown).dropdown('toggle');
+ }
+ },
+
+ isDropdownOpen() {
+ return this.$el.classList.contains('open');
+ },
+ },
+
+ computed: {
+ dropdownClass() {
+ return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ },
+
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
+
+ svgIcon() {
+ return borderlessStatusIconEntityMap[this.stage.status.icon];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown">
+ <button
+ v-tooltip
+ :class="triggerButtonClass"
+ @click="onClickStage"
+ class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button"
+ :title="stage.title"
+ data-placement="top"
+ data-toggle="dropdown"
+ type="button"
+ id="stageDropdown"
+ aria-haspopup="true"
+ aria-expanded="false">
+
+ <span
+ v-html="svgIcon"
+ aria-hidden="true"
+ :aria-label="stage.title">
+ </span>
+
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ </button>
+
+ <ul
+ class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
+ aria-labelledby="stageDropdown">
+
+ <li
+ :class="dropdownClass"
+ class="js-builds-dropdown-list scrollable-menu">
+
+ <loading-icon v-if="isLoading"/>
+
+ <ul
+ v-else
+ v-html="dropdownContent">
+ </ul>
+ </li>
+ </ul>
+ </div>
+</script>
diff --git a/app/assets/javascripts/pipelines/components/status.js b/app/assets/javascripts/pipelines/components/status.js
deleted file mode 100644
index 21a281af438..00000000000
--- a/app/assets/javascripts/pipelines/components/status.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import canceledSvg from 'icons/_icon_status_canceled.svg';
-import createdSvg from 'icons/_icon_status_created.svg';
-import failedSvg from 'icons/_icon_status_failed.svg';
-import manualSvg from 'icons/_icon_status_manual.svg';
-import pendingSvg from 'icons/_icon_status_pending.svg';
-import runningSvg from 'icons/_icon_status_running.svg';
-import skippedSvg from 'icons/_icon_status_skipped.svg';
-import successSvg from 'icons/_icon_status_success.svg';
-import warningSvg from 'icons/_icon_status_warning.svg';
-
-export default {
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- const svgsDictionary = {
- icon_status_canceled: canceledSvg,
- icon_status_created: createdSvg,
- icon_status_failed: failedSvg,
- icon_status_manual: manualSvg,
- icon_status_pending: pendingSvg,
- icon_status_running: runningSvg,
- icon_status_skipped: skippedSvg,
- icon_status_success: successSvg,
- icon_status_warning: warningSvg,
- };
-
- return {
- svg: svgsDictionary[this.pipeline.details.status.icon],
- };
- },
-
- computed: {
- cssClasses() {
- return `ci-status ci-${this.pipeline.details.status.group}`;
- },
-
- detailsPath() {
- const { status } = this.pipeline.details;
- return status.has_details ? status.details_path : false;
- },
-
- content() {
- return `${this.svg} ${this.pipeline.details.status.text}`;
- },
- },
- template: `
- <td class="commit-link">
- <a
- :class="cssClasses"
- :href="detailsPath"
- v-html="content">
- </a>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/time_ago.js b/app/assets/javascripts/pipelines/components/time_ago.js
deleted file mode 100644
index 188f74cc705..00000000000
--- a/app/assets/javascripts/pipelines/components/time_ago.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import iconTimerSvg from 'icons/_icon_timer.svg';
-import '../../lib/utils/datetime_utility';
-
-export default {
- props: {
- finishedTime: {
- type: String,
- required: true,
- },
-
- duration: {
- type: Number,
- required: true,
- },
- },
-
- data() {
- return {
- iconTimerSvg,
- };
- },
-
- updated() {
- $(this.$refs.tooltip).tooltip('fixTitle');
- },
-
- computed: {
- hasDuration() {
- return this.duration > 0;
- },
-
- hasFinishedTime() {
- return this.finishedTime !== '';
- },
-
- localTimeFinished() {
- return gl.utils.formatDate(this.finishedTime);
- },
-
- durationFormated() {
- const date = new Date(this.duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- // left pad
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
- },
-
- finishedTimeFormated() {
- const timeAgo = gl.utils.getTimeago();
-
- return timeAgo.format(this.finishedTime);
- },
- },
-
- template: `
- <td class="pipelines-time-ago">
- <p
- class="duration"
- v-if="hasDuration">
- <span
- v-html="iconTimerSvg">
- </span>
- {{durationFormated}}
- </p>
-
- <p
- class="finished-at"
- v-if="hasFinishedTime">
-
- <i
- class="fa fa-calendar"
- aria-hidden="true" />
-
- <time
- ref="tooltip"
- data-toggle="tooltip"
- data-placement="top"
- data-container="body"
- :title="localTimeFinished">
- {{finishedTimeFormated}}
- </time>
- </p>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue
new file mode 100644
index 00000000000..037684b4e72
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/time_ago.vue
@@ -0,0 +1,95 @@
+<script>
+ import iconTimerSvg from 'icons/_icon_timer.svg';
+ import '../../lib/utils/datetime_utility';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import timeagoMixin from '../../vue_shared/mixins/timeago';
+
+ export default {
+ props: {
+ finishedTime: {
+ type: String,
+ required: true,
+ },
+ duration: {
+ type: Number,
+ required: true,
+ },
+ },
+ mixins: [
+ timeagoMixin,
+ ],
+ directives: {
+ tooltip,
+ },
+ data() {
+ return {
+ iconTimerSvg,
+ };
+ },
+ computed: {
+ hasDuration() {
+ return this.duration > 0;
+ },
+ hasFinishedTime() {
+ return this.finishedTime !== '';
+ },
+ durationFormated() {
+ const date = new Date(this.duration * 1000);
+
+ let hh = date.getUTCHours();
+ let mm = date.getUTCMinutes();
+ let ss = date.getSeconds();
+
+ // left pad
+ if (hh < 10) {
+ hh = `0${hh}`;
+ }
+ if (mm < 10) {
+ mm = `0${mm}`;
+ }
+ if (ss < 10) {
+ ss = `0${ss}`;
+ }
+
+ return `${hh}:${mm}:${ss}`;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="table-section section-15 pipelines-time-ago">
+ <div
+ class="table-mobile-header"
+ role="rowheader">
+ Duration
+ </div>
+ <div class="table-mobile-content">
+ <p
+ class="duration"
+ v-if="hasDuration">
+ <span
+ v-html="iconTimerSvg">
+ </span>
+ {{durationFormated}}
+ </p>
+
+ <p
+ class="finished-at hidden-xs hidden-sm"
+ v-if="hasFinishedTime">
+
+ <i
+ class="fa fa-calendar"
+ aria-hidden="true">
+ </i>
+
+ <time
+ v-tooltip
+ data-placement="top"
+ data-container="body"
+ :title="tooltipTitle(finishedTime)">
+ {{timeFormated(finishedTime)}}
+ </time>
+ </p>
+ </div>
+ </div>
+</script>
diff --git a/app/assets/javascripts/pipelines/index.js b/app/assets/javascripts/pipelines/index.js
deleted file mode 100644
index 48f9181a8d9..00000000000
--- a/app/assets/javascripts/pipelines/index.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Vue from 'vue';
-import PipelinesStore from './stores/pipelines_store';
-import PipelinesComponent from './pipelines';
-import '../vue_shared/vue_resource_interceptor';
-
-$(() => new Vue({
- el: document.querySelector('#pipelines-list-vue'),
-
- data() {
- const store = new PipelinesStore();
-
- return {
- store,
- };
- },
- components: {
- 'vue-pipelines': PipelinesComponent,
- },
- template: `
- <vue-pipelines :store="store" />
- `,
-}));
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
new file mode 100644
index 00000000000..9adc15e6266
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -0,0 +1,103 @@
+/* global Flash */
+import '~/flash';
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import emptyState from '../components/empty_state.vue';
+import errorState from '../components/error_state.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import pipelinesTableComponent from '../components/pipelines_table.vue';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ pipelinesTableComponent,
+ errorState,
+ emptyState,
+ loadingIcon,
+ },
+ computed: {
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ hasError: false,
+ isMakingRequest: false,
+ updateGraphDropdown: false,
+ hasMadeRequest: false,
+ };
+ },
+ beforeMount() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getPipelines',
+ data: this.requestData ? this.requestData : undefined,
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: this.setIsMakingRequest,
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ // If tab is not visible we need to make the first request so we don't show the empty
+ // state without knowing if there are any pipelines
+ this.fetchPipelines();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ eventHub.$on('refreshPipelines', this.fetchPipelines);
+ eventHub.$on('postAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('refreshPipelines');
+ eventHub.$on('postAction', this.postAction);
+ },
+ destroyed() {
+ this.poll.stop();
+ },
+ methods: {
+ fetchPipelines() {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.getPipelines(this.requestData)
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
+ },
+ setCommonData(pipelines) {
+ this.store.storePipelines(pipelines);
+ this.isLoading = false;
+ this.updateGraphDropdown = true;
+ this.hasMadeRequest = true;
+ },
+ errorCallback() {
+ this.hasError = true;
+ this.isLoading = false;
+ this.updateGraphDropdown = false;
+ },
+ setIsMakingRequest(isMakingRequest) {
+ this.isMakingRequest = isMakingRequest;
+
+ if (isMakingRequest) {
+ this.updateGraphDropdown = false;
+ }
+ },
+ postAction(endpoint) {
+ this.service.postAction(endpoint)
+ .then(() => eventHub.$emit('refreshPipelines'))
+ .catch(() => new Flash('An error occured while making the request.'));
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
new file mode 100644
index 00000000000..bfc416da50b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -0,0 +1,70 @@
+/* global Flash */
+
+import Vue from 'vue';
+import PipelinesMediator from './pipeline_details_mediatior';
+import pipelineGraph from './components/graph/graph_component.vue';
+import pipelineHeader from './components/header_component.vue';
+import eventHub from './event_hub';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
+
+ const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+
+ mediator.fetchPipeline();
+
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-pipeline-graph-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineGraph,
+ },
+ render(createElement) {
+ return createElement('pipeline-graph', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-pipeline-header-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineHeader,
+ },
+ created() {
+ eventHub.$on('headerPostAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('headerPostAction', this.postAction);
+ },
+ methods: {
+ postAction(action) {
+ this.mediator.service.postAction(action.path)
+ .then(() => this.mediator.refreshPipeline())
+ .catch(() => new Flash('An error occurred while making the request.'));
+ },
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
new file mode 100644
index 00000000000..385e7430a7d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
@@ -0,0 +1,59 @@
+/* global Flash */
+
+import Visibility from 'visibilityjs';
+import Poll from '../lib/utils/poll';
+import PipelineStore from './stores/pipeline_store';
+import PipelineService from './services/pipeline_service';
+
+export default class pipelinesMediator {
+ constructor(options = {}) {
+ this.options = options;
+ this.store = new PipelineStore();
+ this.service = new PipelineService(options.endpoint);
+
+ this.state = {};
+ this.state.isLoading = false;
+ }
+
+ fetchPipeline() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getPipeline',
+ successCallback: this.successCallback.bind(this),
+ errorCallback: this.errorCallback.bind(this),
+ });
+
+ if (!Visibility.hidden()) {
+ this.state.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.refreshPipeline();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ successCallback(response) {
+ return response.json().then((data) => {
+ this.state.isLoading = false;
+ this.store.storePipeline(data);
+ });
+ }
+
+ errorCallback() {
+ this.state.isLoading = false;
+ return new Flash('An error occurred while fetching the pipeline.');
+ }
+
+ refreshPipeline() {
+ this.service.getPipeline()
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
deleted file mode 100644
index 93d4818231f..00000000000
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ /dev/null
@@ -1,278 +0,0 @@
-import Visibility from 'visibilityjs';
-import PipelinesService from './services/pipelines_service';
-import eventHub from './event_hub';
-import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
-import TablePaginationComponent from '../vue_shared/components/table_pagination';
-import EmptyState from './components/empty_state.vue';
-import ErrorState from './components/error_state.vue';
-import NavigationTabs from './components/navigation_tabs';
-import NavigationControls from './components/nav_controls';
-import Poll from '../lib/utils/poll';
-
-export default {
- props: {
- store: {
- type: Object,
- required: true,
- },
- },
-
- components: {
- 'gl-pagination': TablePaginationComponent,
- 'pipelines-table-component': PipelinesTableComponent,
- 'empty-state': EmptyState,
- 'error-state': ErrorState,
- 'navigation-tabs': NavigationTabs,
- 'navigation-controls': NavigationControls,
- },
-
- data() {
- const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
-
- return {
- endpoint: pipelinesData.endpoint,
- cssClass: pipelinesData.cssClass,
- helpPagePath: pipelinesData.helpPagePath,
- newPipelinePath: pipelinesData.newPipelinePath,
- canCreatePipeline: pipelinesData.canCreatePipeline,
- allPath: pipelinesData.allPath,
- pendingPath: pipelinesData.pendingPath,
- runningPath: pipelinesData.runningPath,
- finishedPath: pipelinesData.finishedPath,
- branchesPath: pipelinesData.branchesPath,
- tagsPath: pipelinesData.tagsPath,
- hasCi: pipelinesData.hasCi,
- ciLintPath: pipelinesData.ciLintPath,
- state: this.store.state,
- apiScope: 'all',
- pagenum: 1,
- isLoading: false,
- hasError: false,
- isMakingRequest: false,
- };
- },
-
- computed: {
- canCreatePipelineParsed() {
- return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
- },
-
- scope() {
- const scope = gl.utils.getParameterByName('scope');
- return scope === null ? 'all' : scope;
- },
-
- shouldRenderErrorState() {
- return this.hasError && !this.isLoading;
- },
-
- /**
- * The empty state should only be rendered when the request is made to fetch all pipelines
- * and none is returned.
- *
- * @return {Boolean}
- */
- shouldRenderEmptyState() {
- return !this.isLoading &&
- !this.hasError &&
- !this.state.pipelines.length &&
- (this.scope === 'all' || this.scope === null);
- },
-
- /**
- * When a specific scope does not have pipelines we render a message.
- *
- * @return {Boolean}
- */
- shouldRenderNoPipelinesMessage() {
- return !this.isLoading &&
- !this.hasError &&
- !this.state.pipelines.length &&
- this.scope !== 'all' &&
- this.scope !== null;
- },
-
- shouldRenderTable() {
- return !this.hasError &&
- !this.isLoading && this.state.pipelines.length;
- },
-
- /**
- * Pagination should only be rendered when there is more than one page.
- *
- * @return {Boolean}
- */
- shouldRenderPagination() {
- return !this.isLoading &&
- this.state.pipelines.length &&
- this.state.pageInfo.total > this.state.pageInfo.perPage;
- },
-
- hasCiEnabled() {
- return this.hasCi !== undefined;
- },
-
- paths() {
- return {
- allPath: this.allPath,
- pendingPath: this.pendingPath,
- finishedPath: this.finishedPath,
- runningPath: this.runningPath,
- branchesPath: this.branchesPath,
- tagsPath: this.tagsPath,
- };
- },
-
- pageParameter() {
- return gl.utils.getParameterByName('page') || this.pagenum;
- },
-
- scopeParameter() {
- return gl.utils.getParameterByName('scope') || this.apiScope;
- },
- },
-
- created() {
- this.service = new PipelinesService(this.endpoint);
-
- const poll = new Poll({
- resource: this.service,
- method: 'getPipelines',
- data: { page: this.pageParameter, scope: this.scopeParameter },
- successCallback: this.successCallback,
- errorCallback: this.errorCallback,
- notificationCallback: this.setIsMakingRequest,
- });
-
- if (!Visibility.hidden()) {
- this.isLoading = true;
- poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
-
- eventHub.$on('refreshPipelines', this.fetchPipelines);
- },
-
- beforeDestroyed() {
- eventHub.$off('refreshPipelines');
- },
-
- methods: {
- /**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
- */
- change(pageNumber) {
- const param = gl.utils.setParamInURL('page', pageNumber);
-
- gl.utils.visitUrl(param);
- return param;
- },
-
- fetchPipelines() {
- if (!this.isMakingRequest) {
- this.isLoading = true;
-
- this.service.getPipelines({ scope: this.scopeParameter, page: this.pageParameter })
- .then(response => this.successCallback(response))
- .catch(() => this.errorCallback());
- }
- },
-
- successCallback(resp) {
- const response = {
- headers: resp.headers,
- body: resp.json(),
- };
-
- this.store.storeCount(response.body.count);
- this.store.storePipelines(response.body.pipelines);
- this.store.storePagination(response.headers);
-
- this.isLoading = false;
- },
-
- errorCallback() {
- this.hasError = true;
- this.isLoading = false;
- },
-
- setIsMakingRequest(isMakingRequest) {
- this.isMakingRequest = isMakingRequest;
- },
- },
-
- template: `
- <div :class="cssClass">
-
- <div
- class="top-area scrolling-tabs-container inner-page-scroll-tabs"
- v-if="!isLoading && !shouldRenderEmptyState">
- <div class="fade-left">
- <i class="fa fa-angle-left" aria-hidden="true"></i>
- </div>
- <div class="fade-right">
- <i class="fa fa-angle-right" aria-hidden="true"></i>
- </div>
- <navigation-tabs
- :scope="scope"
- :count="state.count"
- :paths="paths" />
-
- <navigation-controls
- :new-pipeline-path="newPipelinePath"
- :has-ci-enabled="hasCiEnabled"
- :help-page-path="helpPagePath"
- :ciLintPath="ciLintPath"
- :can-create-pipeline="canCreatePipelineParsed " />
- </div>
-
- <div class="content-list pipelines">
-
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
-
- <empty-state
- v-if="shouldRenderEmptyState"
- :help-page-path="helpPagePath" />
-
- <error-state v-if="shouldRenderErrorState" />
-
- <div
- class="blank-state blank-state-no-icon"
- v-if="shouldRenderNoPipelinesMessage">
- <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
- </div>
-
- <div
- class="table-holder"
- v-if="shouldRenderTable">
-
- <pipelines-table-component
- :pipelines="state.pipelines"
- :service="service"/>
- </div>
-
- <gl-pagination
- v-if="shouldRenderPagination"
- :pagenum="pagenum"
- :change="change"
- :count="state.count.all"
- :pageInfo="state.pageInfo"/>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/pipelines_bundle.js b/app/assets/javascripts/pipelines/pipelines_bundle.js
new file mode 100644
index 00000000000..923d9bfb248
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipelines_bundle.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import PipelinesStore from './stores/pipelines_store';
+import pipelinesComponent from './components/pipelines.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipelines-list-vue',
+ data() {
+ const store = new PipelinesStore();
+
+ return {
+ store,
+ };
+ },
+ components: {
+ pipelinesComponent,
+ },
+ render(createElement) {
+ return createElement('pipelines-component', {
+ props: {
+ store: this.store,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/pipelines/pipelines_charts.js b/app/assets/javascripts/pipelines/pipelines_charts.js
new file mode 100644
index 00000000000..001faf4be33
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipelines_charts.js
@@ -0,0 +1,38 @@
+import Chart from 'vendor/Chart';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const chartData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
+ const buildChart = (chartScope) => {
+ const data = {
+ labels: chartScope.labels,
+ datasets: [{
+ fillColor: '#7f8fa4',
+ strokeColor: '#7f8fa4',
+ pointColor: '#7f8fa4',
+ pointStrokeColor: '#EEE',
+ data: chartScope.totalValues,
+ },
+ {
+ fillColor: '#44aa22',
+ strokeColor: '#44aa22',
+ pointColor: '#44aa22',
+ pointStrokeColor: '#fff',
+ data: chartScope.successValues,
+ },
+ ],
+ };
+ const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d');
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+ };
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+ new Chart(ctx).Line(data, options);
+ };
+
+ chartData.forEach(scope => buildChart(scope));
+});
diff --git a/app/assets/javascripts/pipelines/pipelines_times.js b/app/assets/javascripts/pipelines/pipelines_times.js
new file mode 100644
index 00000000000..b5e7a0e53d9
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipelines_times.js
@@ -0,0 +1,27 @@
+import Chart from 'vendor/Chart';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const chartData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
+ const data = {
+ labels: chartData.labels,
+ datasets: [{
+ fillColor: 'rgba(220,220,220,0.5)',
+ strokeColor: 'rgba(220,220,220,1)',
+ barStrokeWidth: 1,
+ barValueSpacing: 1,
+ barDatasetSpacing: 1,
+ data: chartData.values,
+ }],
+ };
+ const ctx = $('#build_timesChart').get(0).getContext('2d');
+ const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+ };
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+ new Chart(ctx).Bar(data, options);
+});
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
new file mode 100644
index 00000000000..3e0c52c7726
--- /dev/null
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class PipelineService {
+ constructor(endpoint) {
+ this.pipeline = Vue.resource(endpoint);
+ }
+
+ getPipeline() {
+ return this.pipeline.get();
+ }
+
+ // eslint-disable-next-line
+ postAction(endpoint) {
+ return Vue.http.post(`${endpoint}.json`);
+ }
+}
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 255cd513490..e2285494e62 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -33,13 +33,11 @@ export default class PipelinesService {
/**
* Post request for all pipelines actions.
- * Endpoint content type needs to be:
- * `Content-Type:application/x-www-form-urlencoded`
*
* @param {String} endpoint
* @return {Promise}
*/
postAction(endpoint) {
- return Vue.http.post(endpoint, {}, { emulateJSON: true });
+ return Vue.http.post(`${endpoint}.json`);
}
}
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
new file mode 100644
index 00000000000..052e34a8aef
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -0,0 +1,11 @@
+export default class PipelineStore {
+ constructor() {
+ this.state = {};
+
+ this.state.pipeline = {};
+ }
+
+ storePipeline(pipeline = {}) {
+ this.state.pipeline = pipeline;
+ }
+}
diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index 07eea98e737..141333b2b4d 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -2,8 +2,9 @@
// MarkdownPreview
//
-// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview,
-// and showing a warning when more than `x` users are referenced.
+// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
+// (including the explanation of quick actions), and showing a warning when
+// more than `x` users are referenced.
//
(function () {
var lastTextareaPreviewed;
@@ -17,32 +18,45 @@
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
+ MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) {
var mdText;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
if (mdText.trim().length === 0) {
- preview.text('Nothing to preview.');
+ preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text('Loading...');
- this.fetchMarkdownPreview(mdText, (function (response) {
- preview.removeClass('md-preview-loading').html(response.body);
+ this.fetchMarkdownPreview(mdText, url, (function (response) {
+ var body;
+ if (response.body.length > 0) {
+ body = response.body;
+ } else {
+ body = this.emptyMessage;
+ }
+
+ preview.removeClass('md-preview-loading').html(body);
preview.renderGFM();
this.renderReferencedUsers(response.references.users, $form);
+
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
+ }
}).bind(this));
}
};
- MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) {
- if (!window.preview_markdown_path) {
+ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
+ if (!url) {
return;
}
if (text === this.ajaxCache.text) {
@@ -51,7 +65,7 @@
}
$.ajax({
type: 'POST',
- url: window.preview_markdown_path,
+ url: url,
data: {
text: text
},
@@ -83,6 +97,22 @@
}
};
+ MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
+ $form.find('.referenced-commands').hide();
+ };
+
+ MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
+ var referencedCommands;
+ referencedCommands = $form.find('.referenced-commands');
+ if (commands.length > 0) {
+ referencedCommands.html(commands);
+ referencedCommands.show();
+ } else {
+ referencedCommands.html('');
+ referencedCommands.hide();
+ }
+ };
+
return MarkdownPreview;
}());
@@ -137,6 +167,8 @@
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
+
+ markdownPreview.hideReferencedCommands($form);
});
$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index cf1566eeb87..291ae24aa68 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -1,6 +1,7 @@
/* eslint-disable no-useless-escape, max-len, quotes, no-var, no-underscore-dangle, func-names, space-before-function-paren, no-unused-vars, no-return-assign, object-shorthand, one-var, one-var-declaration-per-line, comma-dangle, consistent-return, class-methods-use-this, new-parens */
-import 'vendor/cropper';
+import 'cropper';
+import _ from 'underscore';
((global) => {
// Matches everything but the file name
diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js
index 15d32825583..ff35a9bcb83 100644
--- a/app/assets/javascripts/profile/profile_bundle.js
+++ b/app/assets/javascripts/profile/profile_bundle.js
@@ -1,2 +1,2 @@
-require('./gl_crop');
-require('./profile');
+import './gl_crop';
+import './profile';
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index f944fcc5a58..d7e3ab42f00 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -6,21 +6,27 @@ import Cookies from 'js-cookie';
(function() {
this.Project = (function() {
function Project() {
- $('ul.clone-options-dropdown a').click(function() {
- var url;
- if ($(this).hasClass('active')) {
- return;
- }
- $('.active').not($(this)).removeClass('active');
- $(this).toggleClass('active');
- url = $("#project_clone").val();
- $('#project_clone').val(url);
+ const $cloneOptions = $('ul.clone-options-dropdown');
+ const $projectCloneField = $('#project_clone');
+ const $cloneBtnText = $('a.clone-dropdown-btn span');
+
+ const selectedCloneOption = $cloneBtnText.text().trim();
+ if (selectedCloneOption.length > 0) {
+ $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active');
+ }
+
+ $('a', $cloneOptions).on('click', (e) => {
+ const $this = $(e.currentTarget);
+ const url = $this.attr('href');
+
+ e.preventDefault();
+
+ $('.is-active', $cloneOptions).not($this).removeClass('is-active');
+ $this.toggleClass('is-active');
+ $projectCloneField.val(url);
+ $cloneBtnText.text($this.text());
+
return $('.clone').text(url);
- // Git protocol switcher
- // Remove the active class for all buttons (ssh, http, kerberos if shown)
- // Add the active class for the clicked button
- // Update the input field
- // Update the command line instructions
});
// Ref switcher
this.initRefSwitcher();
@@ -84,6 +90,7 @@ import Cookies from 'js-cookie';
filterable: true,
filterRemote: true,
filterByText: true,
+ inputFieldName: $dropdown.data('input-field-name'),
fieldName: $dropdown.data('field-name'),
renderRow: function(ref) {
var li = refListItem.cloneNode(false);
@@ -112,13 +119,19 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
- clicked: function(selected, $el, e) {
+ clicked: function(options) {
+ const { e } = options;
e.preventDefault();
if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form');
+
+ var $visit = $dropdown.data('visit');
+ var shouldVisit = $visit ? true : $visit;
var action = $form.attr('action');
var divider = action.indexOf('?') === -1 ? '?' : '&';
- gl.utils.visitUrl(action + '' + divider + '' + $form.serialize());
+ if (shouldVisit) {
+ gl.utils.visitUrl(`${action}${divider}${$form.serialize()}`);
+ }
}
}
});
diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js
new file mode 100644
index 00000000000..7572fec15e0
--- /dev/null
+++ b/app/assets/javascripts/project_edit.js
@@ -0,0 +1,9 @@
+export default function setupProjectEdit() {
+ const $transferForm = $('.js-project-transfer-form');
+ const $selectNamespace = $transferForm.find('select.select2');
+
+ $selectNamespace.on('change', () => {
+ $transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
+ });
+ $selectNamespace.trigger('change');
+}
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index e01668eabef..11f9754780d 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,18 +2,16 @@
/* global fuzzaldrinPlus */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.ProjectFindFile = (function() {
var highlighter;
function ProjectFindFile(element1, options) {
this.element = element1;
this.options = options;
- this.goToBlob = bind(this.goToBlob, this);
- this.goToTree = bind(this.goToTree, this);
- this.selectRowDown = bind(this.selectRowDown, this);
- this.selectRowUp = bind(this.selectRowUp, this);
+ this.goToBlob = this.goToBlob.bind(this);
+ this.goToTree = this.goToTree.bind(this);
+ this.selectRowDown = this.selectRowDown.bind(this);
+ this.selectRowUp = this.selectRowUp.bind(this);
this.filePaths = {};
this.inputElement = this.element.find(".file-finder-input");
// init event
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index e9927c1bf51..fd89a1a85c3 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -1,13 +1,19 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+import VisibilitySelect from './visibility_select';
+
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+(function() {
this.ProjectNew = (function() {
function ProjectNew() {
- this.toggleSettings = bind(this.toggleSettings, this);
+ this.toggleSettings = this.toggleSettings.bind(this);
this.$selects = $('.features select');
this.$repoSelects = this.$selects.filter('.js-repo-select');
+ this.$projectSelects = this.$selects.not('.js-repo-select');
$('.project-edit-container').on('ajax:before', (function(_this) {
return function() {
@@ -26,8 +32,44 @@
ProjectNew.prototype.initVisibilitySelect = function() {
const visibilityContainer = document.querySelector('.js-visibility-select');
if (!visibilityContainer) return;
- const visibilitySelect = new gl.VisibilitySelect(visibilityContainer);
+ const visibilitySelect = new VisibilitySelect(visibilityContainer);
visibilitySelect.init();
+
+ const $visibilitySelect = $(visibilityContainer).find('select');
+ let projectVisibility = $visibilitySelect.val();
+ const PROJECT_VISIBILITY_PRIVATE = '0';
+
+ $visibilitySelect.on('change', () => {
+ const newProjectVisibility = $visibilitySelect.val();
+
+ if (projectVisibility !== newProjectVisibility) {
+ this.$projectSelects.each((idx, select) => {
+ const $select = $(select);
+ const $options = $select.find('option');
+ const values = $.map($options, e => e.value);
+
+ // if switched to "private", limit visibility options
+ if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ if ($select.val() !== values[0] && $select.val() !== values[1]) {
+ $select.val(values[1]).trigger('change');
+ highlightChanges($select);
+ }
+ $options.slice(2).disable();
+ }
+
+ // if switched from "private", increase visibility for non-disabled options
+ if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ $options.enable();
+ if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) {
+ $select.val(values[values.length - 1]).trigger('change');
+ highlightChanges($select);
+ }
+ }
+ });
+
+ projectVisibility = newProjectVisibility;
+ }
+ });
};
ProjectNew.prototype.toggleSettings = function() {
@@ -58,8 +100,10 @@
ProjectNew.prototype.toggleRepoVisibility = function () {
var $repoAccessLevel = $('.js-repo-access-level select');
+ var $lfsEnabledOption = $('.js-lfs-enabled select');
var containerRegistry = document.querySelectorAll('.js-container-registry')[0];
var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled');
+ var prevSelectedVal = parseInt($repoAccessLevel.val(), 10);
this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']")
.nextAll()
@@ -73,29 +117,40 @@
var $this = $(this);
var repoSelectVal = parseInt($this.val(), 10);
- $this.find('option').show();
+ $this.find('option').enable();
- if (selectedVal < repoSelectVal) {
- $this.val(selectedVal);
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
}
- $this.find("option[value='" + selectedVal + "']").nextAll().hide();
+ $this.find("option[value='" + selectedVal + "']").nextAll().disable();
});
if (selectedVal) {
this.$repoSelects.removeClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.removeClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = '';
}
} else {
this.$repoSelects.addClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.val('false').addClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = 'none';
containerRegistryCheckbox.checked = false;
}
}
+
+ prevSelectedVal = selectedVal;
}.bind(this));
};
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 3c1c1e7dceb..1b4ed6be90a 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */
-/* global Api */
+import Api from './api';
+import ProjectSelectComboButton from './project_select_combo_button';
(function() {
this.ProjectSelect = (function() {
@@ -51,11 +52,15 @@
this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups');
this.orderBy = $(select).data('order-by') || 'id';
+ this.withIssuesEnabled = $(select).data('with-issues-enabled');
+ this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
+
placeholder = "Search for project";
if (this.includeGroups) {
placeholder += " or group";
}
- return $(select).select2({
+
+ $(select).select2({
placeholder: placeholder,
minimumInputLength: 0,
query: (function(_this) {
@@ -84,18 +89,27 @@
if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else {
- return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback);
+ return Api.projects(query.term, {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled
+ }, projectsCallback);
}
};
})(this),
id: function(project) {
- return project.web_url;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
},
text: function(project) {
return project.name_with_namespace || project.name;
},
dropdownCssClass: "ajax-project-dropdown"
});
+
+ return new ProjectSelectComboButton(select);
});
}
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
new file mode 100644
index 00000000000..f799d9d619a
--- /dev/null
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -0,0 +1,85 @@
+import AccessorUtilities from './lib/utils/accessor';
+
+export default class ProjectSelectComboButton {
+ constructor(select) {
+ this.projectSelectInput = $(select);
+ this.newItemBtn = $('.new-project-item-link');
+ this.newItemBtnBaseText = this.newItemBtn.data('label');
+ this.itemType = this.deriveItemTypeFromLabel();
+ this.groupId = this.projectSelectInput.data('groupId');
+
+ this.bindEvents();
+ this.initLocalStorage();
+ }
+
+ bindEvents() {
+ this.projectSelectInput.siblings('.new-project-item-select-button')
+ .on('click', this.openDropdown);
+
+ this.projectSelectInput.on('change', () => this.selectProject());
+ }
+
+ initLocalStorage() {
+ const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (localStorageIsSafe) {
+ const itemTypeKebabed = this.newItemBtnBaseText.toLowerCase().split(' ').join('-');
+
+ this.localStorageKey = ['group', this.groupId, itemTypeKebabed, 'recent-project'].join('-');
+ this.setBtnTextFromLocalStorage();
+ }
+ }
+
+ openDropdown() {
+ $(this).siblings('.project-item-select').select2('open');
+ }
+
+ selectProject() {
+ const selectedProjectData = JSON.parse(this.projectSelectInput.val());
+ const projectUrl = `${selectedProjectData.url}/${this.projectSelectInput.data('relativePath')}`;
+ const projectName = selectedProjectData.name;
+
+ const projectMeta = {
+ url: projectUrl,
+ name: projectName,
+ };
+
+ this.setNewItemBtnAttributes(projectMeta);
+ this.setProjectInLocalStorage(projectMeta);
+ }
+
+ setBtnTextFromLocalStorage() {
+ const cachedProjectData = this.getProjectFromLocalStorage();
+
+ this.setNewItemBtnAttributes(cachedProjectData);
+ }
+
+ setNewItemBtnAttributes(project) {
+ if (project) {
+ this.newItemBtn.attr('href', project.url);
+ this.newItemBtn.text(`${this.newItemBtnBaseText} in ${project.name}`);
+ this.newItemBtn.enable();
+ } else {
+ this.newItemBtn.text(`Select project to create ${this.itemType}`);
+ this.newItemBtn.disable();
+ }
+ }
+
+ deriveItemTypeFromLabel() {
+ // label is either 'New issue' or 'New merge request'
+ return this.newItemBtnBaseText.split(' ').slice(1).join(' ');
+ }
+
+ getProjectFromLocalStorage() {
+ const projectString = localStorage.getItem(this.localStorageKey);
+
+ return JSON.parse(projectString);
+ }
+
+ setProjectInLocalStorage(projectMeta) {
+ const projectString = JSON.stringify(projectMeta);
+
+ localStorage.setItem(this.localStorageKey, projectString);
+ }
+}
+
diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
new file mode 100644
index 00000000000..c34927499fc
--- /dev/null
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -0,0 +1,14 @@
+import '../lib/utils/url_utility';
+
+const bindEvents = () => {
+ const path = gl.utils.getParameterValues('path')[0];
+
+ // get the path url and append it in the inputS
+ $('.js-path-name').val(path);
+};
+
+document.addEventListener('DOMContentLoaded', bindEvents);
+
+export default {
+ bindEvents,
+};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
new file mode 100644
index 00000000000..7f972b6f6ee
--- /dev/null
+++ b/app/assets/javascripts/projects/project_new.js
@@ -0,0 +1,67 @@
+let hasUserDefinedProjectPath = false;
+
+const deriveProjectPathFromUrl = ($projectImportUrl, $projectPath) => {
+ if (hasUserDefinedProjectPath) {
+ return;
+ }
+
+ let importUrl = $projectImportUrl.val().trim();
+ if (importUrl.length === 0) {
+ return;
+ }
+
+ /*
+ \/?: remove trailing slash
+ (\.git\/?)?: remove trailing .git (with optional trailing slash)
+ (\?.*)?: remove query string
+ (#.*)?: remove fragment identifier
+ */
+ importUrl = importUrl.replace(/\/?(\.git\/?)?(\?.*)?(#.*)?$/, '');
+
+ // extract everything after the last slash
+ const pathMatch = /\/([^/]+)$/.exec(importUrl);
+ if (pathMatch) {
+ $projectPath.val(pathMatch[1]);
+ }
+};
+
+const bindEvents = () => {
+ const $newProjectForm = $('#new_project');
+ const $projectImportUrl = $('#project_import_url');
+ const $projectPath = $('#project_path');
+
+ if ($newProjectForm.length !== 1) {
+ return;
+ }
+
+ $('.how_to_import_link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).next('.modal').show();
+ });
+
+ $('.modal-header .close').on('click', () => {
+ $('.modal').hide();
+ });
+
+ $('.btn_import_gitlab_project').on('click', () => {
+ const importHref = $('a.btn_import_gitlab_project').attr('href');
+ $('.btn_import_gitlab_project').attr('href', `${importHref}?namespace_id=${$('#project_namespace_id').val()}&path=${$projectPath.val()}`);
+ });
+
+ $newProjectForm.on('submit', () => {
+ $projectPath.val($projectPath.val().trim());
+ });
+
+ $projectPath.on('keyup', () => {
+ hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
+ });
+
+ $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl, $projectPath));
+};
+
+document.addEventListener('DOMContentLoaded', bindEvents);
+
+export default {
+ bindEvents,
+ deriveProjectPathFromUrl,
+};
diff --git a/app/assets/javascripts/prometheus_metrics/constants.js b/app/assets/javascripts/prometheus_metrics/constants.js
new file mode 100644
index 00000000000..50f1248456e
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/constants.js
@@ -0,0 +1,5 @@
+export default {
+ EMPTY: 'empty',
+ LOADING: 'loading',
+ LIST: 'list',
+};
diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js
new file mode 100644
index 00000000000..a0c43c5abe1
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/index.js
@@ -0,0 +1,6 @@
+import PrometheusMetrics from './prometheus_metrics';
+
+$(() => {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+});
diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
new file mode 100644
index 00000000000..ef4d6df5138
--- /dev/null
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -0,0 +1,109 @@
+import PANEL_STATE from './constants';
+
+export default class PrometheusMetrics {
+ constructor(wrapperSelector) {
+ this.backOffRequestCounter = 0;
+
+ this.$wrapper = $(wrapperSelector);
+
+ this.$monitoredMetricsPanel = this.$wrapper.find('.js-panel-monitored-metrics');
+ this.$monitoredMetricsCount = this.$monitoredMetricsPanel.find('.js-monitored-count');
+ this.$monitoredMetricsLoading = this.$monitoredMetricsPanel.find('.js-loading-metrics');
+ this.$monitoredMetricsEmpty = this.$monitoredMetricsPanel.find('.js-empty-metrics');
+ this.$monitoredMetricsList = this.$monitoredMetricsPanel.find('.js-metrics-list');
+
+ this.$missingEnvVarPanel = this.$wrapper.find('.js-panel-missing-env-vars');
+ this.$panelToggle = this.$missingEnvVarPanel.find('.js-panel-toggle');
+ this.$missingEnvVarMetricCount = this.$missingEnvVarPanel.find('.js-env-var-count');
+ this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list');
+
+ this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('active-metrics');
+
+ this.$panelToggle.on('click', e => this.handlePanelToggle(e));
+ }
+
+ /* eslint-disable class-methods-use-this */
+ handlePanelToggle(e) {
+ const $toggleBtn = $(e.currentTarget);
+ const $currentPanelBody = $toggleBtn.closest('.panel').find('.panel-body');
+ $currentPanelBody.toggleClass('hidden');
+ if ($toggleBtn.hasClass('fa-caret-down')) {
+ $toggleBtn.removeClass('fa-caret-down').addClass('fa-caret-right');
+ } else {
+ $toggleBtn.removeClass('fa-caret-right').addClass('fa-caret-down');
+ }
+ }
+
+ showMonitoringMetricsPanelState(stateName) {
+ switch (stateName) {
+ case PANEL_STATE.LOADING:
+ this.$monitoredMetricsLoading.removeClass('hidden');
+ this.$monitoredMetricsEmpty.addClass('hidden');
+ this.$monitoredMetricsList.addClass('hidden');
+ break;
+ case PANEL_STATE.LIST:
+ this.$monitoredMetricsLoading.addClass('hidden');
+ this.$monitoredMetricsEmpty.addClass('hidden');
+ this.$monitoredMetricsList.removeClass('hidden');
+ break;
+ default:
+ this.$monitoredMetricsLoading.addClass('hidden');
+ this.$monitoredMetricsEmpty.removeClass('hidden');
+ this.$monitoredMetricsList.addClass('hidden');
+ break;
+ }
+ }
+
+ populateActiveMetrics(metrics) {
+ let totalMonitoredMetrics = 0;
+ let totalMissingEnvVarMetrics = 0;
+
+ metrics.forEach((metric) => {
+ this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`);
+ totalMonitoredMetrics += metric.active_metrics;
+ if (metric.metrics_missing_requirements > 0) {
+ this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`);
+ totalMissingEnvVarMetrics += 1;
+ }
+ });
+
+ this.$monitoredMetricsCount.text(totalMonitoredMetrics);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ if (totalMissingEnvVarMetrics > 0) {
+ this.$missingEnvVarPanel.removeClass('hidden');
+ this.$missingEnvVarPanel.find('.flash-container').off('click');
+ this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ }
+ }
+
+ loadActiveMetrics() {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+ gl.utils.backOff((next, stop) => {
+ $.getJSON(this.activeMetricsEndpoint)
+ .done((res) => {
+ if (res && res.success) {
+ stop(res);
+ } else {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ if (this.backOffRequestCounter < 3) {
+ next();
+ } else {
+ stop(res);
+ }
+ }
+ })
+ .fail(stop);
+ })
+ .then((res) => {
+ if (res && res.data && res.data.length) {
+ this.populateActiveMetrics(res.data);
+ } else {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ }
+ })
+ .catch(() => {
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/index.js b/app/assets/javascripts/protected_branches/index.js
new file mode 100644
index 00000000000..c9e7af127d2
--- /dev/null
+++ b/app/assets/javascripts/protected_branches/index.js
@@ -0,0 +1,9 @@
+/* eslint-disable no-unused-vars */
+
+import ProtectedBranchCreate from './protected_branch_create';
+import ProtectedBranchEditList from './protected_branch_edit_list';
+
+$(() => {
+ const protectedBranchCreate = new ProtectedBranchCreate();
+ const protectedBranchEditList = new ProtectedBranchEditList();
+});
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index e7fff57ff45..38b1406a99f 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -1,29 +1,26 @@
-/* eslint-disable arrow-parens, no-param-reassign, object-shorthand, no-else-return, comma-dangle, max-len */
+export default class ProtectedBranchAccessDropdown {
+ constructor(options) {
+ this.options = options;
+ this.initDropdown();
+ }
-(global => {
- global.gl = global.gl || {};
-
- gl.ProtectedBranchAccessDropdown = class {
- constructor(options) {
- const { $dropdown, data, onSelect } = options;
-
- $dropdown.glDropdown({
- data: data,
- selectable: true,
- inputId: $dropdown.data('input-id'),
- fieldName: $dropdown.data('field-name'),
- toggleLabel(item, el) {
- if (el.is('.is-active')) {
- return item.text;
- } else {
- return 'Select';
- }
- },
- clicked(item, $el, e) {
- e.preventDefault();
- onSelect();
+ initDropdown() {
+ const { $dropdown, data, onSelect } = this.options;
+ $dropdown.glDropdown({
+ data,
+ selectable: true,
+ inputId: $dropdown.data('input-id'),
+ fieldName: $dropdown.data('field-name'),
+ toggleLabel(item, $el) {
+ if ($el.is('.is-active')) {
+ return item.text;
}
- });
- }
- };
-})(window);
+ return 'Select';
+ },
+ clicked(options) {
+ options.e.preventDefault();
+ onSelect();
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 57ea2f52814..10da3783123 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,55 +1,51 @@
-/* eslint-disable no-new, arrow-parens, no-param-reassign, comma-dangle, max-len */
-/* global ProtectedBranchDropdown */
-
-(global => {
- global.gl = global.gl || {};
-
- gl.ProtectedBranchCreate = class {
- constructor() {
- this.$wrap = this.$form = $('#new_protected_branch');
- this.buildDropdowns();
- }
-
- buildDropdowns() {
- const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
- const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
-
- // Cache callback
- this.onSelectCallback = this.onSelect.bind(this);
-
- // Allowed to Merge dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: $allowedToMergeDropdown,
- data: gon.merge_access_levels,
- onSelect: this.onSelectCallback
- });
-
- // Allowed to Push dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: $allowedToPushDropdown,
- data: gon.push_access_levels,
- onSelect: this.onSelectCallback
- });
-
- // Select default
- $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0);
- $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0);
-
- // Protected branch dropdown
- new ProtectedBranchDropdown({
- $dropdown: this.$wrap.find('.js-protected-branch-select'),
- onSelect: this.onSelectCallback
- });
- }
-
- // This will run after clicked callback
- onSelect() {
- // Enable submit button
- const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]');
- const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
- const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
-
- this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length));
- }
- };
-})(window);
+import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+import ProtectedBranchDropdown from './protected_branch_dropdown';
+
+export default class ProtectedBranchCreate {
+ constructor() {
+ this.$form = $('.js-new-protected-branch');
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge');
+ const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push');
+
+ // Cache callback
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ // Allowed to Merge dropdown
+ this.protectedBranchMergeAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Allowed to Push dropdown
+ this.protectedBranchPushAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Select default
+ $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0);
+ $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0);
+
+ // Protected branch dropdown
+ this.protectedBranchDropdown = new ProtectedBranchDropdown({
+ $dropdown: this.$form.find('.js-protected-branch-select'),
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ // This will run after clicked callback
+ onSelect() {
+ // Enable submit button
+ const $branchInput = this.$form.find('input[name="protected_branch[name]"]');
+ const $allowedToMergeInput = this.$form.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
+ const $allowedToPushInput = this.$form.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
+
+ this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length));
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
index 1d4bb8a13d6..678882a8d2c 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
@@ -1,6 +1,12 @@
-/* eslint-disable comma-dangle, no-unused-vars */
+import _ from 'underscore';
-class ProtectedBranchDropdown {
+export default class ProtectedBranchDropdown {
+ /**
+ * @param {Object} options containing
+ * `$dropdown` target element
+ * `onSelect` event callback
+ * $dropdown must be an element created using `dropdown_branch()` rails helper
+ */
constructor(options) {
this.onSelect = options.onSelect;
this.$dropdown = options.$dropdown;
@@ -12,7 +18,7 @@ class ProtectedBranchDropdown {
this.bindEvents();
// Hide footer
- this.$dropdownFooter.addClass('hidden');
+ this.toggleFooter(true);
}
buildDropdown() {
@@ -21,7 +27,7 @@ class ProtectedBranchDropdown {
filterable: true,
remote: false,
search: {
- fields: ['title']
+ fields: ['title'],
},
selectable: true,
toggleLabel(selected) {
@@ -35,10 +41,10 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
- e.preventDefault();
+ clicked: (options) => {
+ options.e.preventDefault();
this.onSelect();
- }
+ },
});
}
@@ -63,20 +69,22 @@ class ProtectedBranchDropdown {
}
toggleCreateNewButton(branchName) {
- this.selectedBranch = {
- title: branchName,
- id: branchName,
- text: branchName
- };
-
if (branchName) {
+ this.selectedBranch = {
+ title: branchName,
+ id: branchName,
+ text: branchName,
+ };
+
this.$dropdownContainer
.find('.js-create-new-protected-branch code')
.text(branchName);
}
- this.$dropdownFooter.toggleClass('hidden', !branchName);
+ this.toggleFooter(!branchName);
}
-}
-window.ProtectedBranchDropdown = ProtectedBranchDropdown;
+ toggleFooter(toggleState) {
+ this.$dropdownFooter.toggleClass('hidden', toggleState);
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 6ef59e94384..3b920942a3f 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,69 +1,67 @@
-/* eslint-disable no-new, arrow-parens, no-param-reassign, comma-dangle, max-len */
+/* eslint-disable no-new */
/* global Flash */
-(global => {
- global.gl = global.gl || {};
+import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
- gl.ProtectedBranchEdit = class {
- constructor(options) {
- this.$wrap = options.$wrap;
- this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
- this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+export default class ProtectedBranchEdit {
+ constructor(options) {
+ this.$wrap = options.$wrap;
+ this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
+ this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+ this.onSelectCallback = this.onSelect.bind(this);
- this.buildDropdowns();
- }
+ this.buildDropdowns();
+ }
- buildDropdowns() {
- // Allowed to merge dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: this.$allowedToMergeDropdown,
- data: gon.merge_access_levels,
- onSelect: this.onSelect.bind(this)
- });
+ buildDropdowns() {
+ // Allowed to merge dropdown
+ this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelectCallback,
+ });
- // Allowed to push dropdown
- new gl.ProtectedBranchAccessDropdown({
- $dropdown: this.$allowedToPushDropdown,
- data: gon.push_access_levels,
- onSelect: this.onSelect.bind(this)
- });
- }
+ // Allowed to push dropdown
+ this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+ }
- onSelect() {
- const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
- const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
+ onSelect() {
+ const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
+ const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
- // Do not update if one dropdown has not selected any option
- if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
+ // Do not update if one dropdown has not selected any option
+ if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
- this.$allowedToMergeDropdown.disable();
- this.$allowedToPushDropdown.disable();
+ this.$allowedToMergeDropdown.disable();
+ this.$allowedToPushDropdown.disable();
- $.ajax({
- type: 'POST',
- url: this.$wrap.data('url'),
- dataType: 'json',
- data: {
- _method: 'PATCH',
- protected_branch: {
- merge_access_levels_attributes: [{
- id: this.$allowedToMergeDropdown.data('access-level-id'),
- access_level: $allowedToMergeInput.val()
- }],
- push_access_levels_attributes: [{
- id: this.$allowedToPushDropdown.data('access-level-id'),
- access_level: $allowedToPushInput.val()
- }]
- }
+ $.ajax({
+ type: 'POST',
+ url: this.$wrap.data('url'),
+ dataType: 'json',
+ data: {
+ _method: 'PATCH',
+ protected_branch: {
+ merge_access_levels_attributes: [{
+ id: this.$allowedToMergeDropdown.data('access-level-id'),
+ access_level: $allowedToMergeInput.val(),
+ }],
+ push_access_levels_attributes: [{
+ id: this.$allowedToPushDropdown.data('access-level-id'),
+ access_level: $allowedToPushInput.val(),
+ }],
},
- error() {
- $.scrollTo(0);
- new Flash('Failed to update branch!');
- }
- }).always(() => {
- this.$allowedToMergeDropdown.enable();
- this.$allowedToPushDropdown.enable();
- });
- }
- };
-})(window);
+ },
+ error() {
+ new Flash('Failed to update branch!', null, $('.js-protected-branches-list'));
+ },
+ }).always(() => {
+ this.$allowedToMergeDropdown.enable();
+ this.$allowedToPushDropdown.enable();
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
index 336fa6c57a7..b40d3827c30 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
@@ -1,18 +1,18 @@
-/* eslint-disable arrow-parens, no-param-reassign, no-new, comma-dangle */
+/* eslint-disable no-new */
-(global => {
- global.gl = global.gl || {};
+import ProtectedBranchEdit from './protected_branch_edit';
- gl.ProtectedBranchEditList = class {
- constructor() {
- this.$wrap = $('.protected-branches-list');
+export default class ProtectedBranchEditList {
+ constructor() {
+ this.$wrap = $('.protected-branches-list');
+ this.initEditForm();
+ }
- // Build edit forms
- this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
- new gl.ProtectedBranchEdit({
- $wrap: $(el)
- });
+ initEditForm() {
+ this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
+ new ProtectedBranchEdit({
+ $wrap: $(el),
});
- }
- };
-})(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
deleted file mode 100644
index 849c1e31623..00000000000
--- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+++ /dev/null
@@ -1,5 +0,0 @@
-require('./protected_branch_access_dropdown');
-require('./protected_branch_create');
-require('./protected_branch_dropdown');
-require('./protected_branch_edit');
-require('./protected_branch_edit_list');
diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js
index 61e7ba53862..b1618e24e49 100644
--- a/app/assets/javascripts/protected_tags/index.js
+++ b/app/assets/javascripts/protected_tags/index.js
@@ -1,2 +1,9 @@
-export { default as ProtectedTagCreate } from './protected_tag_create';
-export { default as ProtectedTagEditList } from './protected_tag_edit_list';
+/* eslint-disable no-unused-vars */
+
+import ProtectedTagCreate from './protected_tag_create';
+import ProtectedTagEditList from './protected_tag_edit_list';
+
+$(() => {
+ const protectedtTagCreate = new ProtectedTagCreate();
+ const protectedtTagEditList = new ProtectedTagEditList();
+});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
index fff83f3af3b..d4c9a91a74a 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
}
return 'Select';
},
- clicked(item, $el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
onSelect();
},
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 5ff4e443262..a0224213aa0 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+
export default class ProtectedTagDropdown {
/**
* @param {Object} options containing
@@ -10,7 +12,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+ this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown();
this.bindEvents();
@@ -39,8 +41,8 @@ export default class ProtectedTagDropdown {
return _.escape(protectedTag.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
- e.preventDefault();
+ clicked: (options) => {
+ options.e.preventDefault();
this.onSelect();
},
});
@@ -73,7 +75,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
- .find('.create-new-protected-tag code')
+ .find('.js-create-new-protected-tag code')
.text(tagName);
}
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js
new file mode 100644
index 00000000000..edc2293915f
--- /dev/null
+++ b/app/assets/javascripts/raven/index.js
@@ -0,0 +1,20 @@
+import RavenConfig from './raven_config';
+
+const index = function index() {
+ RavenConfig.init({
+ sentryDsn: gon.sentry_dsn,
+ currentUserId: gon.current_user_id,
+ whitelistUrls: [gon.gitlab_url],
+ isProduction: process.env.NODE_ENV,
+ release: gon.revision,
+ tags: {
+ revision: gon.revision,
+ },
+ });
+
+ return RavenConfig;
+};
+
+index();
+
+export default index;
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
new file mode 100644
index 00000000000..ae54fa5f1a9
--- /dev/null
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -0,0 +1,103 @@
+import Raven from 'raven-js';
+import $ from 'jquery';
+
+const IGNORE_ERRORS = [
+ // Random plugins/extensions
+ 'top.GLOBALS',
+ // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
+ 'originalCreateNotification',
+ 'canvas.contentDocument',
+ 'MyApp_RemoveAllHighlights',
+ 'http://tt.epicplay.com',
+ 'Can\'t find variable: ZiteReader',
+ 'jigsaw is not defined',
+ 'ComboSearch is not defined',
+ 'http://loading.retry.widdit.com/',
+ 'atomicFindClose',
+ // Facebook borked
+ 'fb_xd_fragment',
+ // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
+ // reduce this. (thanks @acdha)
+ // See http://stackoverflow.com/questions/4113268
+ 'bmi_SafeAddOnload',
+ 'EBCallBackMessageReceived',
+ // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
+ 'conduitPage',
+];
+
+const IGNORE_URLS = [
+ // Facebook flakiness
+ /graph\.facebook\.com/i,
+ // Facebook blocked
+ /connect\.facebook\.net\/en_US\/all\.js/i,
+ // Woopra flakiness
+ /eatdifferent\.com\.woopra-ns\.com/i,
+ /static\.woopra\.com\/js\/woopra\.js/i,
+ // Chrome extensions
+ /extensions\//i,
+ /^chrome:\/\//i,
+ // Other plugins
+ /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
+ /webappstoolbarba\.texthelp\.com\//i,
+ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
+];
+
+const SAMPLE_RATE = 95;
+
+const RavenConfig = {
+ IGNORE_ERRORS,
+ IGNORE_URLS,
+ SAMPLE_RATE,
+ init(options = {}) {
+ this.options = options;
+
+ this.configure();
+ this.bindRavenErrors();
+ if (this.options.currentUserId) this.setUser();
+ },
+
+ configure() {
+ Raven.config(this.options.sentryDsn, {
+ release: this.options.release,
+ tags: this.options.tags,
+ whitelistUrls: this.options.whitelistUrls,
+ environment: this.options.isProduction ? 'production' : 'development',
+ ignoreErrors: this.IGNORE_ERRORS,
+ ignoreUrls: this.IGNORE_URLS,
+ shouldSendCallback: this.shouldSendSample.bind(this),
+ }).install();
+ },
+
+ setUser() {
+ Raven.setUserContext({
+ id: this.options.currentUserId,
+ });
+ },
+
+ bindRavenErrors() {
+ $(document).on('ajaxError.raven', this.handleRavenErrors);
+ },
+
+ handleRavenErrors(event, req, config, err) {
+ const error = err || req.statusText;
+ const responseText = req.responseText || 'Unknown response text';
+
+ Raven.captureMessage(error, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: responseText,
+ error,
+ event,
+ },
+ });
+ },
+
+ shouldSendSample() {
+ return Math.random() * 100 <= this.SAMPLE_RATE;
+ },
+};
+
+export default RavenConfig;
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
new file mode 100644
index 00000000000..65e4101352c
--- /dev/null
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -0,0 +1,47 @@
+class RefSelectDropdown {
+ constructor($dropdownButton, availableRefs) {
+ const availableRefsValue = availableRefs || JSON.parse(document.getElementById('availableRefs').innerHTML);
+ $dropdownButton.glDropdown({
+ data: availableRefsValue,
+ filterable: true,
+ filterByText: true,
+ remote: false,
+ fieldName: $dropdownButton.data('field-name'),
+ filterInput: 'input[type="search"]',
+ selectable: true,
+ isSelectable(branch, $el) {
+ return !$el.hasClass('is-active');
+ },
+ text(branch) {
+ return branch;
+ },
+ id(branch) {
+ return branch;
+ },
+ toggleLabel(branch) {
+ return branch;
+ },
+ });
+
+ const $dropdownContainer = $dropdownButton.closest('.dropdown');
+ const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer);
+ const $filterInput = $('input[type="search"]', $dropdownContainer);
+
+ $filterInput.on('keyup', (e) => {
+ const keyCode = e.keyCode || e.which;
+ if (keyCode !== 13) return;
+
+ const ref = $filterInput.val().trim();
+ if (ref === '') {
+ return;
+ }
+
+ $fieldInput.val(ref);
+ $('.dropdown-toggle-text', $dropdownButton).text(ref);
+
+ $dropdownContainer.removeClass('open');
+ });
+ }
+}
+
+export default RefSelectDropdown;
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js
index 2c3a9cacd38..bcdc0fd67b8 100644
--- a/app/assets/javascripts/render_gfm.js
+++ b/app/assets/javascripts/render_gfm.js
@@ -11,7 +11,5 @@
return this;
};
- $(document).on('ready load', function() {
- return $('body').renderGFM();
- });
+ $(() => $('body').renderGFM());
}).call(window);
diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue
new file mode 100644
index 00000000000..d6c864cb976
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo.vue
@@ -0,0 +1,70 @@
+<script>
+import RepoSidebar from './repo_sidebar.vue';
+import RepoCommitSection from './repo_commit_section.vue';
+import RepoTabs from './repo_tabs.vue';
+import RepoFileButtons from './repo_file_buttons.vue';
+import RepoPreview from './repo_preview.vue';
+import RepoMixin from '../mixins/repo_mixin';
+import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
+import Store from '../stores/repo_store';
+import Helper from '../helpers/repo_helper';
+import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
+
+export default {
+ data: () => Store,
+ mixins: [RepoMixin],
+ components: {
+ RepoSidebar,
+ RepoTabs,
+ RepoFileButtons,
+ 'repo-editor': MonacoLoaderHelper.repoEditorLoader,
+ RepoCommitSection,
+ PopupDialog,
+ RepoPreview,
+ },
+
+ mounted() {
+ Helper.getContent().catch(Helper.loadingError);
+ },
+
+ methods: {
+ toggleDialogOpen(toggle) {
+ this.dialog.open = toggle;
+ },
+
+ dialogSubmitted(status) {
+ this.toggleDialogOpen(false);
+ this.dialog.status = status;
+ },
+
+ toggleBlobView: Store.toggleBlobView,
+ },
+};
+</script>
+
+<template>
+ <div class="repository-view">
+ <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}">
+ <repo-sidebar/>
+ <div v-if="isMini"
+ class="panel-right"
+ :class="{'edit-mode': editMode}">
+ <repo-tabs/>
+ <component
+ :is="currentBlobView"
+ class="blob-viewer-container"/>
+ <repo-file-buttons/>
+ </div>
+ </div>
+ <repo-commit-section/>
+ <popup-dialog
+ v-show="dialog.open"
+ :primary-button-label="__('Discard changes')"
+ kind="warning"
+ :title="__('Are you sure?')"
+ :body="__('Are you sure you want to discard your changes?')"
+ @toggle="toggleDialogOpen"
+ @submit="dialogSubmitted"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue
new file mode 100644
index 00000000000..5ec4a9b6593
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_commit_section.vue
@@ -0,0 +1,130 @@
+<script>
+/* global Flash */
+import Store from '../stores/repo_store';
+import RepoMixin from '../mixins/repo_mixin';
+import Service from '../services/repo_service';
+
+export default {
+ data: () => Store,
+
+ mixins: [RepoMixin],
+
+ computed: {
+ showCommitable() {
+ return this.isCommitable && this.changedFiles.length;
+ },
+
+ branchPaths() {
+ return this.changedFiles.map(f => f.path);
+ },
+
+ cantCommitYet() {
+ return !this.commitMessage || this.submitCommitsLoading;
+ },
+
+ filePluralize() {
+ return this.changedFiles.length > 1 ? 'files' : 'file';
+ },
+ },
+
+ methods: {
+ makeCommit() {
+ // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
+ const commitMessage = this.commitMessage;
+ const actions = this.changedFiles.map(f => ({
+ action: 'update',
+ file_path: f.path,
+ content: f.newContent,
+ }));
+ const payload = {
+ branch: Store.targetBranch,
+ commit_message: commitMessage,
+ actions,
+ };
+ Store.submitCommitsLoading = true;
+ Service.commitFiles(payload, this.resetCommitState);
+ },
+
+ resetCommitState() {
+ this.submitCommitsLoading = false;
+ this.changedFiles = [];
+ this.commitMessage = '';
+ this.editMode = false;
+ window.scrollTo(0, 0);
+ },
+ },
+};
+</script>
+
+<template>
+<div
+ v-if="showCommitable"
+ id="commit-area">
+ <form
+ class="form-horizontal"
+ @submit.prevent="makeCommit">
+ <fieldset>
+ <div class="form-group">
+ <label class="col-md-4 control-label staged-files">
+ Staged files ({{changedFiles.length}})
+ </label>
+ <div class="col-md-6">
+ <ul class="list-unstyled changed-files">
+ <li
+ v-for="branchPath in branchPaths"
+ :key="branchPath">
+ <span class="help-block">
+ {{branchPath}}
+ </span>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div class="form-group">
+ <label
+ class="col-md-4 control-label"
+ for="commit-message">
+ Commit message
+ </label>
+ <div class="col-md-6">
+ <textarea
+ id="commit-message"
+ class="form-control"
+ name="commit-message"
+ v-model="commitMessage">
+ </textarea>
+ </div>
+ </div>
+ <div class="form-group target-branch">
+ <label
+ class="col-md-4 control-label"
+ for="target-branch">
+ Target branch
+ </label>
+ <div class="col-md-6">
+ <span class="help-block">
+ {{targetBranch}}
+ </span>
+ </div>
+ </div>
+ <div class="col-md-offset-4 col-md-6">
+ <button
+ ref="submitCommit"
+ type="submit"
+ :disabled="cantCommitYet"
+ class="btn btn-success">
+ <i
+ v-if="submitCommitsLoading"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ aria-label="loading">
+ </i>
+ <span class="commit-summary">
+ Commit {{changedFiles.length}} {{filePluralize}}
+ </span>
+ </button>
+ </div>
+ </fieldset>
+ </form>
+</div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue
new file mode 100644
index 00000000000..29b76975561
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_edit_button.vue
@@ -0,0 +1,58 @@
+<script>
+import Store from '../stores/repo_store';
+import RepoMixin from '../mixins/repo_mixin';
+
+export default {
+ data: () => Store,
+ mixins: [RepoMixin],
+ computed: {
+ buttonLabel() {
+ return this.editMode ? this.__('Cancel edit') : this.__('Edit');
+ },
+
+ showButton() {
+ return this.isCommitable &&
+ !this.activeFile.render_error &&
+ !this.binary &&
+ this.openedFiles.length;
+ },
+ },
+ methods: {
+ editCancelClicked() {
+ if (this.changedFiles.length) {
+ this.dialog.open = true;
+ return;
+ }
+ this.editMode = !this.editMode;
+ Store.toggleBlobView();
+ },
+ toggleProjectRefsForm() {
+ $('.project-refs-form').toggleClass('disabled', this.editMode);
+ $('.js-tree-ref-target-holder').toggle(this.editMode);
+ },
+ },
+
+ watch: {
+ editMode() {
+ this.toggleProjectRefsForm();
+ },
+ },
+};
+</script>
+
+<template>
+<button
+ v-if="showButton"
+ class="btn btn-default"
+ type="button"
+ @click.prevent="editCancelClicked">
+ <i
+ v-if="!editMode"
+ class="fa fa-pencil"
+ aria-hidden="true">
+ </i>
+ <span>
+ {{buttonLabel}}
+ </span>
+</button>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
new file mode 100644
index 00000000000..96d6a75bb61
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_editor.vue
@@ -0,0 +1,117 @@
+<script>
+/* global monaco */
+import Store from '../stores/repo_store';
+import Service from '../services/repo_service';
+import Helper from '../helpers/repo_helper';
+
+const RepoEditor = {
+ data: () => Store,
+
+ destroyed() {
+ if (Helper.monacoInstance) {
+ Helper.monacoInstance.destroy();
+ }
+ },
+
+ mounted() {
+ Service.getRaw(this.activeFile.raw_path)
+ .then((rawResponse) => {
+ Store.blobRaw = rawResponse.data;
+ Store.activeFile.plain = rawResponse.data;
+
+ const monacoInstance = Helper.monaco.editor.create(this.$el, {
+ model: null,
+ readOnly: false,
+ contextmenu: false,
+ });
+
+ Helper.monacoInstance = monacoInstance;
+
+ this.addMonacoEvents();
+
+ this.setupEditor();
+ })
+ .catch(Helper.loadingError);
+ },
+
+ methods: {
+ setupEditor() {
+ this.showHide();
+
+ Helper.setMonacoModelFromLanguage();
+ },
+
+ showHide() {
+ if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
+ this.$el.style.display = 'none';
+ } else {
+ this.$el.style.display = 'inline-block';
+ }
+ },
+
+ addMonacoEvents() {
+ Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp);
+ Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this));
+ },
+
+ onMonacoEditorKeysPressed() {
+ Store.setActiveFileContents(Helper.monacoInstance.getValue());
+ },
+
+ onMonacoEditorMouseUp(e) {
+ if (!e.target.position) return;
+ const lineNumber = e.target.position.lineNumber;
+ if (e.target.element.classList.contains('line-numbers')) {
+ location.hash = `L${lineNumber}`;
+ Store.activeLine = lineNumber;
+
+ Helper.monacoInstance.setPosition({
+ lineNumber: this.activeLine,
+ column: 1,
+ });
+ }
+ },
+ },
+
+ watch: {
+ dialog: {
+ handler(obj) {
+ const newObj = obj;
+ if (newObj.status) {
+ newObj.status = false;
+ this.openedFiles = this.openedFiles.map((file) => {
+ const f = file;
+ if (f.active) {
+ this.blobRaw = f.plain;
+ }
+ f.changed = false;
+ delete f.newContent;
+
+ return f;
+ });
+ this.editMode = false;
+ Store.toggleBlobView();
+ }
+ },
+ deep: true,
+ },
+
+ blobRaw() {
+ if (Helper.monacoInstance && !this.isTree) {
+ this.setupEditor();
+ }
+ },
+ },
+ computed: {
+ shouldHideEditor() {
+ return !this.openedFiles.length || (this.binary && !this.activeFile.raw);
+ },
+ },
+};
+
+export default RepoEditor;
+</script>
+
+<template>
+<div id="ide" v-if='!shouldHideEditor'></div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue
new file mode 100644
index 00000000000..20ebf840774
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_file.vue
@@ -0,0 +1,107 @@
+<script>
+import TimeAgoMixin from '../../vue_shared/mixins/timeago';
+
+const RepoFile = {
+ mixins: [TimeAgoMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ isMini: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ loading: {
+ type: Object,
+ required: false,
+ default() { return { tree: false }; },
+ },
+ hasFiles: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ activeFile: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ canShowFile() {
+ return !this.loading.tree || this.hasFiles;
+ },
+
+ fileIcon() {
+ const classObj = {
+ 'fa-spinner fa-spin': this.file.loading,
+ [this.file.icon]: !this.file.loading,
+ };
+ return classObj;
+ },
+
+ fileIndentation() {
+ return {
+ 'margin-left': `${this.file.level * 10}px`,
+ };
+ },
+
+ activeFileClass() {
+ return {
+ active: this.activeFile.url === this.file.url,
+ };
+ },
+ },
+
+ methods: {
+ linkClicked(file) {
+ this.$emit('linkclicked', file);
+ },
+ },
+};
+
+export default RepoFile;
+</script>
+
+<template>
+<tr
+ v-if="canShowFile"
+ class="file"
+ :class="activeFileClass"
+ @click.prevent="linkClicked(file)">
+ <td>
+ <i
+ class="fa fa-fw file-icon"
+ :class="fileIcon"
+ :style="fileIndentation"
+ aria-label="file icon">
+ </i>
+ <a
+ :href="file.url"
+ class="repo-file-name"
+ :title="file.url">
+ {{file.name}}
+ </a>
+ </td>
+
+ <template v-if="!isMini">
+ <td class="hidden-sm hidden-xs">
+ <div class="commit-message">
+ <a @click.stop :href="file.lastCommitUrl">
+ {{file.lastCommitMessage}}
+ </a>
+ </div>
+ </td>
+
+ <td class="hidden-xs">
+ <span
+ class="commit-update"
+ :title="tooltipTitle(file.lastCommitUpdate)">
+ {{timeFormated(file.lastCommitUpdate)}}
+ </span>
+ </td>
+ </template>
+</tr>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue
new file mode 100644
index 00000000000..e43ef366f47
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue
@@ -0,0 +1,69 @@
+<script>
+import Store from '../stores/repo_store';
+import Helper from '../helpers/repo_helper';
+import RepoMixin from '../mixins/repo_mixin';
+
+const RepoFileButtons = {
+ data: () => Store,
+
+ mixins: [RepoMixin],
+
+ computed: {
+
+ rawDownloadButtonLabel() {
+ return this.binary ? 'Download' : 'Raw';
+ },
+
+ canPreview() {
+ return Helper.isRenderable();
+ },
+ },
+
+ methods: {
+ rawPreviewToggle: Store.toggleRawPreview,
+ },
+};
+
+export default RepoFileButtons;
+</script>
+
+<template>
+ <div id="repo-file-buttons">
+ <a
+ :href="activeFile.raw_path"
+ target="_blank"
+ class="btn btn-default raw"
+ rel="noopener noreferrer">
+ {{rawDownloadButtonLabel}}
+ </a>
+
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="File actions">
+ <a
+ :href="activeFile.blame_path"
+ class="btn btn-default blame">
+ Blame
+ </a>
+ <a
+ :href="activeFile.commits_path"
+ class="btn btn-default history">
+ History
+ </a>
+ <a
+ :href="activeFile.permalink"
+ class="btn btn-default permalink">
+ Permalink
+ </a>
+ </div>
+
+ <a
+ v-if="canPreview"
+ href="#"
+ @click.prevent="rawPreviewToggle"
+ class="btn btn-default preview">
+ {{activeFileLabel}}
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue
new file mode 100644
index 00000000000..6a15755f029
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_file_options.vue
@@ -0,0 +1,25 @@
+<script>
+const RepoFileOptions = {
+ props: {
+ isMini: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ },
+};
+
+export default RepoFileOptions;
+</script>
+
+<template>
+ <tr v-if="isMini" class="repo-file-options">
+ <td>
+ <span class="title">{{projectName}}</span>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue
new file mode 100644
index 00000000000..bc8c64c8362
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_loading_file.vue
@@ -0,0 +1,76 @@
+<script>
+const RepoLoadingFile = {
+ props: {
+ loading: {
+ type: Object,
+ required: false,
+ default: {},
+ },
+ hasFiles: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMini: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ showGhostLines() {
+ return this.loading.tree && !this.hasFiles;
+ },
+ },
+
+ methods: {
+ lineOfCode(n) {
+ return `skeleton-line-${n}`;
+ },
+ },
+};
+
+export default RepoLoadingFile;
+</script>
+
+<template>
+ <tr
+ v-if="showGhostLines"
+ class="loading-file">
+ <td>
+ <div
+ class="animation-container animation-container-small">
+ <div
+ v-for="n in 6"
+ :key="n"
+ :class="lineOfCode(n)">
+ </div>
+ </div>
+ </td>
+
+ <td
+ v-if="!isMini"
+ class="hidden-sm hidden-xs">
+ <div class="animation-container">
+ <div
+ v-for="n in 6"
+ :key="n"
+ :class="lineOfCode(n)">
+ </div>
+ </div>
+ </td>
+
+ <td
+ v-if="!isMini"
+ class="hidden-xs">
+ <div class="animation-container animation-container-small">
+ <div
+ v-for="n in 6"
+ :key="n"
+ :class="lineOfCode(n)">
+ </div>
+ </div>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue
new file mode 100644
index 00000000000..bbdbdc61e38
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue
@@ -0,0 +1,38 @@
+<script>
+import RepoMixin from '../mixins/repo_mixin';
+
+const RepoPreviousDirectory = {
+ props: {
+ prevUrl: {
+ type: String,
+ required: true,
+ },
+ },
+
+ mixins: [RepoMixin],
+
+ computed: {
+ colSpanCondition() {
+ return this.isMini ? undefined : 3;
+ },
+ },
+
+ methods: {
+ linkClicked(file) {
+ this.$emit('linkclicked', file);
+ },
+ },
+};
+
+export default RepoPreviousDirectory;
+</script>
+
+<template>
+<tr class="prev-directory">
+ <td
+ :colspan="colSpanCondition"
+ @click.prevent="linkClicked(prevUrl)">
+ <a :href="prevUrl">..</a>
+ </td>
+</tr>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue
new file mode 100644
index 00000000000..2200754cbef
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_preview.vue
@@ -0,0 +1,52 @@
+<script>
+import Store from '../stores/repo_store';
+
+export default {
+ data: () => Store,
+ mounted() {
+ this.highlightFile();
+ },
+ computed: {
+ html() {
+ return this.activeFile.html;
+ },
+ },
+
+ methods: {
+ highlightFile() {
+ $(this.$el).find('.file-content').syntaxHighlight();
+ },
+ },
+
+ watch: {
+ html() {
+ this.$nextTick(() => {
+ this.highlightFile();
+ });
+ },
+ },
+};
+</script>
+
+<template>
+<div>
+ <div
+ v-if="!activeFile.render_error"
+ v-html="activeFile.html">
+ </div>
+ <div
+ v-else-if="activeFile.tooLarge"
+ class="vertical-center render-error">
+ <p class="text-center">
+ The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead.
+ </p>
+ </div>
+ <div
+ v-else
+ class="vertical-center render-error">
+ <p class="text-center">
+ The source could not be displayed because a rendering error occured. You can <a :href="activeFile.raw_path">download</a> it instead.
+ </p>
+ </div>
+</div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue
new file mode 100644
index 00000000000..72b40288566
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_sidebar.vue
@@ -0,0 +1,100 @@
+<script>
+import Service from '../services/repo_service';
+import Helper from '../helpers/repo_helper';
+import Store from '../stores/repo_store';
+import RepoPreviousDirectory from './repo_prev_directory.vue';
+import RepoFileOptions from './repo_file_options.vue';
+import RepoFile from './repo_file.vue';
+import RepoLoadingFile from './repo_loading_file.vue';
+import RepoMixin from '../mixins/repo_mixin';
+
+export default {
+ mixins: [RepoMixin],
+ components: {
+ 'repo-file-options': RepoFileOptions,
+ 'repo-previous-directory': RepoPreviousDirectory,
+ 'repo-file': RepoFile,
+ 'repo-loading-file': RepoLoadingFile,
+ },
+
+ created() {
+ this.addPopEventListener();
+ },
+
+ data: () => Store,
+
+ methods: {
+ addPopEventListener() {
+ window.addEventListener('popstate', () => {
+ if (location.href.indexOf('#') > -1) return;
+ this.linkClicked({
+ url: location.href,
+ });
+ });
+ },
+
+ fileClicked(clickedFile) {
+ let file = clickedFile;
+ if (file.loading) return;
+ file.loading = true;
+ if (file.type === 'tree' && file.opened) {
+ file = Store.removeChildFilesOfTree(file);
+ file.loading = false;
+ } else {
+ Service.url = file.url;
+ Helper.getContent(file)
+ .then(() => {
+ file.loading = false;
+ Helper.scrollTabsRight();
+ })
+ .catch(Helper.loadingError);
+ }
+ },
+
+ goToPreviousDirectoryClicked(prevURL) {
+ Service.url = prevURL;
+ Helper.getContent(null)
+ .then(() => Helper.scrollTabsRight())
+ .catch(Helper.loadingError);
+ },
+ },
+};
+</script>
+
+<template>
+<div id="sidebar" :class="{'sidebar-mini' : isMini}">
+ <table class="table">
+ <thead v-if="!isMini">
+ <tr>
+ <th class="name">Name</th>
+ <th class="hidden-sm hidden-xs last-commit">Last Commit</th>
+ <th class="hidden-xs last-update">Last Update</th>
+ </tr>
+ </thead>
+ <tbody>
+ <repo-file-options
+ :is-mini="isMini"
+ :project-name="projectName"/>
+ <repo-previous-directory
+ v-if="isRoot"
+ :prev-url="prevURL"
+ @linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
+ <repo-loading-file
+ v-for="n in 5"
+ :key="n"
+ :loading="loading"
+ :has-files="!!files.length"
+ :is-mini="isMini"/>
+ <repo-file
+ v-for="file in files"
+ :key="file.id"
+ :file="file"
+ :is-mini="isMini"
+ @linkclicked="fileClicked(file)"
+ :is-tree="isTree"
+ :has-files="!!files.length"
+ :active-file="activeFile"/>
+ </tbody>
+ </table>
+</div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue
new file mode 100644
index 00000000000..0d0c34ec741
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_tab.vue
@@ -0,0 +1,63 @@
+<script>
+import Store from '../stores/repo_store';
+
+const RepoTab = {
+ props: {
+ tab: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ closeLabel() {
+ if (this.tab.changed) {
+ return `${this.tab.name} changed`;
+ }
+ return `Close ${this.tab.name}`;
+ },
+ changedClass() {
+ const tabChangedObj = {
+ 'fa-times close-icon': !this.tab.changed,
+ 'fa-circle unsaved-icon': this.tab.changed,
+ };
+ return tabChangedObj;
+ },
+ },
+
+ methods: {
+ tabClicked: Store.setActiveFiles,
+
+ closeTab(file) {
+ if (file.changed) return;
+ this.$emit('tabclosed', file);
+ },
+ },
+};
+
+export default RepoTab;
+</script>
+
+<template>
+<li @click="tabClicked(tab)">
+ <a
+ href="#0"
+ class="close"
+ @click.stop.prevent="closeTab(tab)"
+ :aria-label="closeLabel">
+ <i
+ class="fa"
+ :class="changedClass"
+ aria-hidden="true">
+ </i>
+ </a>
+
+ <a
+ href="#"
+ class="repo-tab"
+ :title="tab.url"
+ @click.prevent="tabClicked(tab)">
+ {{tab.name}}
+ </a>
+</li>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue
new file mode 100644
index 00000000000..9c5bfc5d0cf
--- /dev/null
+++ b/app/assets/javascripts/repo/components/repo_tabs.vue
@@ -0,0 +1,36 @@
+<script>
+import Store from '../stores/repo_store';
+import RepoTab from './repo_tab.vue';
+import RepoMixin from '../mixins/repo_mixin';
+
+const RepoTabs = {
+ mixins: [RepoMixin],
+
+ components: {
+ 'repo-tab': RepoTab,
+ },
+
+ data: () => Store,
+
+ methods: {
+ tabClosed(file) {
+ Store.removeFromOpenedFiles(file);
+ },
+ },
+};
+
+export default RepoTabs;
+</script>
+
+<template>
+<ul id="tabs">
+ <repo-tab
+ v-for="tab in openedFiles"
+ :key="tab.id"
+ :tab="tab"
+ :class="{'active' : tab.active}"
+ @tabclosed="tabClosed"
+ />
+ <li class="tabs-divider" />
+</ul>
+</template>
diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js
new file mode 100644
index 00000000000..f8729bbf585
--- /dev/null
+++ b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js
@@ -0,0 +1,25 @@
+/* global monaco */
+import RepoEditor from '../components/repo_editor.vue';
+import Store from '../stores/repo_store';
+import Helper from '../helpers/repo_helper';
+import monacoLoader from '../monaco_loader';
+
+function repoEditorLoader() {
+ Store.monacoLoading = true;
+ return new Promise((resolve, reject) => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ Helper.monaco = monaco;
+ Store.monacoLoading = false;
+ resolve(RepoEditor);
+ }, () => {
+ Store.monacoLoading = false;
+ reject();
+ });
+ });
+}
+
+const MonacoLoaderHelper = {
+ repoEditorLoader,
+};
+
+export default MonacoLoaderHelper;
diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js
new file mode 100644
index 00000000000..2bd8d7eea65
--- /dev/null
+++ b/app/assets/javascripts/repo/helpers/repo_helper.js
@@ -0,0 +1,271 @@
+/* global Flash */
+import Service from '../services/repo_service';
+import Store from '../stores/repo_store';
+import '../../flash';
+
+const RepoHelper = {
+ monacoInstance: null,
+
+ getDefaultActiveFile() {
+ return {
+ active: true,
+ binary: false,
+ extension: '',
+ html: '',
+ mime_type: '',
+ name: '',
+ plain: '',
+ size: 0,
+ url: '',
+ raw: false,
+ newContent: '',
+ changed: false,
+ loading: false,
+ };
+ },
+
+ key: '',
+
+ isTree(data) {
+ return Object.hasOwnProperty.call(data, 'blobs');
+ },
+
+ Time: window.performance
+ && window.performance.now
+ ? window.performance
+ : Date,
+
+ getFileExtension(fileName) {
+ return fileName.split('.').pop();
+ },
+
+ getLanguageIDForFile(file, langs) {
+ const ext = RepoHelper.getFileExtension(file.name);
+ const foundLang = RepoHelper.findLanguage(ext, langs);
+
+ return foundLang ? foundLang.id : 'plaintext';
+ },
+
+ setMonacoModelFromLanguage() {
+ RepoHelper.monacoInstance.setModel(null);
+ const languages = RepoHelper.monaco.languages.getLanguages();
+ const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages);
+ const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID);
+ RepoHelper.monacoInstance.setModel(newModel);
+ },
+
+ findLanguage(ext, langs) {
+ return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1);
+ },
+
+ setDirectoryOpen(tree) {
+ const file = tree;
+ if (!file) return undefined;
+
+ file.opened = true;
+ file.icon = 'fa-folder-open';
+ RepoHelper.updateHistoryEntry(file.url, file.name);
+ return file;
+ },
+
+ isRenderable() {
+ const okExts = ['md', 'svg'];
+ return okExts.indexOf(Store.activeFile.extension) > -1;
+ },
+
+ setBinaryDataAsBase64(file) {
+ Service.getBase64Content(file.raw_path)
+ .then((response) => {
+ Store.blobRaw = response;
+ file.base64 = response; // eslint-disable-line no-param-reassign
+ })
+ .catch(RepoHelper.loadingError);
+ },
+
+ // when you open a directory you need to put the directory files under
+ // the directory... This will merge the list of the current directory and the new list.
+ getNewMergedList(inDirectory, currentList, newList) {
+ const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
+ if (!inDirectory) return newListSorted;
+ const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
+ if (!indexOfFile) return newListSorted;
+ return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
+ },
+
+ // within the get new merged list this does the merging of the current list of files
+ // and the new list of files. The files are never "in" another directory they just
+ // appear like they are because of the margin.
+ mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
+ newList.reverse().forEach((newFile) => {
+ const fileIndex = indexOfFile + 1;
+ const file = newFile;
+ file.level = inDirectory.level + 1;
+ oldList.splice(fileIndex, 0, file);
+ });
+
+ return oldList;
+ },
+
+ compareFilesCaseInsensitive(a, b) {
+ const aName = a.name.toLowerCase();
+ const bName = b.name.toLowerCase();
+ if (a.level > 0) return 0;
+ if (aName < bName) { return -1; }
+ if (aName > bName) { return 1; }
+ return 0;
+ },
+
+ isRoot(url) {
+ // the url we are requesting -> split by the project URL. Grab the right side.
+ const isRoot = !!url.split(Store.projectUrl)[1]
+ // remove the first "/"
+ .slice(1)
+ // split this by "/"
+ .split('/')
+ // remove the first two items of the array... usually /tree/master.
+ .slice(2)
+ // we want to know the length of the array.
+ // If greater than 0 not root.
+ .length;
+ return isRoot;
+ },
+
+ getContent(treeOrFile) {
+ let file = treeOrFile;
+ return Service.getContent()
+ .then((response) => {
+ const data = response.data;
+ Store.isTree = RepoHelper.isTree(data);
+ if (!Store.isTree) {
+ if (!file) file = data;
+ Store.binary = data.binary;
+
+ if (data.binary) {
+ // file might be undefined
+ RepoHelper.setBinaryDataAsBase64(data);
+ Store.setViewToPreview();
+ } else if (!Store.isPreviewView()) {
+ if (!data.render_error) {
+ Service.getRaw(data.raw_path)
+ .then((rawResponse) => {
+ Store.blobRaw = rawResponse.data;
+ data.plain = rawResponse.data;
+ RepoHelper.setFile(data, file);
+ }).catch(RepoHelper.loadingError);
+ }
+ }
+
+ if (Store.isPreviewView()) {
+ RepoHelper.setFile(data, file);
+ }
+
+ // if the file tree is empty
+ if (Store.files.length === 0) {
+ const parentURL = Service.blobURLtoParentTree(Service.url);
+ Service.url = parentURL;
+ RepoHelper.getContent();
+ }
+ } else {
+ // it's a tree
+ if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
+ file = RepoHelper.setDirectoryOpen(file);
+ const newDirectory = RepoHelper.dataToListOfFiles(data);
+ Store.addFilesToDirectory(file, Store.files, newDirectory);
+ Store.prevURL = Service.blobURLtoParentTree(Service.url);
+ }
+ }).catch(RepoHelper.loadingError);
+ },
+
+ setFile(data, file) {
+ const newFile = data;
+
+ newFile.url = file.url;
+ if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') {
+ newFile.tooLarge = true;
+ }
+ newFile.newContent = '';
+
+ Store.addToOpenedFiles(newFile);
+ Store.setActiveFiles(newFile);
+ },
+
+ serializeBlob(blob) {
+ const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
+ simpleBlob.lastCommitMessage = blob.last_commit.message;
+ simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
+ simpleBlob.loading = false;
+
+ return simpleBlob;
+ },
+
+ serializeTree(tree) {
+ return RepoHelper.serializeRepoEntity('tree', tree);
+ },
+
+ serializeSubmodule(submodule) {
+ return RepoHelper.serializeRepoEntity('submodule', submodule);
+ },
+
+ serializeRepoEntity(type, entity) {
+ const { url, name, icon, last_commit } = entity;
+ const returnObj = {
+ type,
+ name,
+ url,
+ icon: `fa-${icon}`,
+ level: 0,
+ loading: false,
+ };
+
+ if (entity.last_commit) {
+ returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
+ } else {
+ returnObj.lastCommitUrl = '';
+ }
+ return returnObj;
+ },
+
+ scrollTabsRight() {
+ // wait for the transition. 0.1 seconds.
+ setTimeout(() => {
+ const tabs = document.getElementById('tabs');
+ if (!tabs) return;
+ tabs.scrollLeft = tabs.scrollWidth;
+ }, 200);
+ },
+
+ dataToListOfFiles(data) {
+ const { blobs, trees, submodules } = data;
+ return [
+ ...blobs.map(blob => RepoHelper.serializeBlob(blob)),
+ ...trees.map(tree => RepoHelper.serializeTree(tree)),
+ ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
+ ];
+ },
+
+ genKey() {
+ return RepoHelper.Time.now().toFixed(3);
+ },
+
+ updateHistoryEntry(url, title) {
+ const history = window.history;
+
+ RepoHelper.key = RepoHelper.genKey();
+
+ history.pushState({ key: RepoHelper.key }, '', url);
+
+ if (title) {
+ document.title = `${title} · GitLab`;
+ }
+ },
+
+ findOpenedFileFromActive() {
+ return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url);
+ },
+
+ loadingError() {
+ Flash('Unable to load this content at this time.');
+ },
+};
+
+export default RepoHelper;
diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js
new file mode 100644
index 00000000000..6c1d468e937
--- /dev/null
+++ b/app/assets/javascripts/repo/index.js
@@ -0,0 +1,78 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import Service from './services/repo_service';
+import Store from './stores/repo_store';
+import Repo from './components/repo.vue';
+import RepoEditButton from './components/repo_edit_button.vue';
+import Translate from '../vue_shared/translate';
+
+function initDropdowns() {
+ $('.js-tree-ref-target-holder').hide();
+}
+
+function addEventsForNonVueEls() {
+ $(document).on('change', '.dropdown', () => {
+ Store.targetBranch = $('.project-refs-target-form input[name="ref"]').val();
+ });
+
+ window.onbeforeunload = function confirmUnload(e) {
+ const hasChanged = Store.openedFiles
+ .some(file => file.changed);
+ if (!hasChanged) return undefined;
+ const event = e || window.event;
+ if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?';
+ // For Safari
+ return 'Are you sure you want to lose unsaved changes?';
+ };
+}
+
+function setInitialStore(data) {
+ Store.service = Service;
+ Store.service.url = data.url;
+ Store.service.refsUrl = data.refsUrl;
+ Store.projectId = data.projectId;
+ Store.projectName = data.projectName;
+ Store.projectUrl = data.projectUrl;
+ Store.canCommit = data.canCommit;
+ Store.onTopOfBranch = data.onTopOfBranch;
+ Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
+ Store.checkIsCommitable();
+}
+
+function initRepo(el) {
+ return new Vue({
+ el,
+ components: {
+ repo: Repo,
+ },
+ render(createElement) {
+ return createElement('repo');
+ },
+ });
+}
+
+function initRepoEditButton(el) {
+ return new Vue({
+ el,
+ components: {
+ repoEditButton: RepoEditButton,
+ },
+ });
+}
+
+function initRepoBundle() {
+ const repo = document.getElementById('repo');
+ const editButton = document.querySelector('.editable-mode');
+ setInitialStore(repo.dataset);
+ addEventsForNonVueEls();
+ initDropdowns();
+
+ Vue.use(Translate);
+
+ initRepo(repo);
+ initRepoEditButton(editButton);
+}
+
+$(initRepoBundle);
+
+export default initRepoBundle;
diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js
new file mode 100644
index 00000000000..c8e8238a0d3
--- /dev/null
+++ b/app/assets/javascripts/repo/mixins/repo_mixin.js
@@ -0,0 +1,17 @@
+import Store from '../stores/repo_store';
+
+const RepoMixin = {
+ computed: {
+ isMini() {
+ return !!Store.openedFiles.length;
+ },
+
+ changedFiles() {
+ const changedFileList = this.openedFiles
+ .filter(file => file.changed);
+ return changedFileList;
+ },
+ },
+};
+
+export default RepoMixin;
diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/repo/monaco_loader.js
new file mode 100644
index 00000000000..ad1370a7730
--- /dev/null
+++ b/app/assets/javascripts/repo/monaco_loader.js
@@ -0,0 +1,13 @@
+/* eslint-disable no-underscore-dangle, camelcase */
+/* global __webpack_public_path__ */
+
+import monacoContext from 'monaco-editor/dev/vs/loader';
+
+monacoContext.require.config({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`,
+ },
+});
+
+window.__monaco_context__ = monacoContext;
+export default monacoContext.require;
diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js
new file mode 100644
index 00000000000..3cf204e6ec8
--- /dev/null
+++ b/app/assets/javascripts/repo/services/repo_service.js
@@ -0,0 +1,80 @@
+/* global Flash */
+import axios from 'axios';
+import Store from '../stores/repo_store';
+import Api from '../../api';
+import Helper from '../helpers/repo_helper';
+
+const RepoService = {
+ url: '',
+ options: {
+ params: {
+ format: 'json',
+ },
+ },
+ richExtensionRegExp: /md/,
+
+ getRaw(url) {
+ return axios.get(url, {
+ // Stop Axios from parsing a JSON file into a JS object
+ transformResponse: [res => res],
+ });
+ },
+
+ buildParams(url = this.url) {
+ // shallow clone object without reference
+ const params = Object.assign({}, this.options.params);
+
+ if (this.urlIsRichBlob(url)) params.viewer = 'rich';
+
+ return params;
+ },
+
+ urlIsRichBlob(url = this.url) {
+ const extension = Helper.getFileExtension(url);
+
+ return this.richExtensionRegExp.test(extension);
+ },
+
+ getContent(url = this.url) {
+ const params = this.buildParams(url);
+
+ return axios.get(url, {
+ params,
+ });
+ },
+
+ getBase64Content(url = this.url) {
+ const request = axios.get(url, {
+ responseType: 'arraybuffer',
+ });
+
+ return request.then(response => this.bufferToBase64(response.data));
+ },
+
+ bufferToBase64(data) {
+ return new Buffer(data, 'binary').toString('base64');
+ },
+
+ blobURLtoParentTree(url) {
+ const urlArray = url.split('/');
+ urlArray.pop();
+ const blobIndex = urlArray.lastIndexOf('blob');
+
+ if (blobIndex > -1) urlArray[blobIndex] = 'tree';
+
+ return urlArray.join('/');
+ },
+
+ commitFiles(payload, cb) {
+ Api.commitMultiple(Store.projectId, payload, (data) => {
+ if (data.short_id && data.stats) {
+ Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
+ } else {
+ Flash(data.message);
+ }
+ cb();
+ });
+ },
+};
+
+export default RepoService;
diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js
new file mode 100644
index 00000000000..1c0df528aea
--- /dev/null
+++ b/app/assets/javascripts/repo/stores/repo_store.js
@@ -0,0 +1,199 @@
+/* global Flash */
+import Helper from '../helpers/repo_helper';
+import Service from '../services/repo_service';
+
+const RepoStore = {
+ monaco: {},
+ monacoLoading: false,
+ service: '',
+ canCommit: false,
+ onTopOfBranch: false,
+ editMode: false,
+ isTree: false,
+ isRoot: false,
+ prevURL: '',
+ projectId: '',
+ projectName: '',
+ projectUrl: '',
+ blobRaw: '',
+ currentBlobView: 'repo-preview',
+ openedFiles: [],
+ submitCommitsLoading: false,
+ dialog: {
+ open: false,
+ title: '',
+ status: false,
+ },
+ activeFile: Helper.getDefaultActiveFile(),
+ activeFileIndex: 0,
+ activeLine: 0,
+ activeFileLabel: 'Raw',
+ files: [],
+ isCommitable: false,
+ binary: false,
+ currentBranch: '',
+ targetBranch: 'new-branch',
+ commitMessage: '',
+ binaryTypes: {
+ png: false,
+ md: false,
+ svg: false,
+ unknown: false,
+ },
+ loading: {
+ tree: false,
+ blob: false,
+ },
+
+ resetBinaryTypes() {
+ Object.keys(RepoStore.binaryTypes).forEach((key) => {
+ RepoStore.binaryTypes[key] = false;
+ });
+ },
+
+ // mutations
+ checkIsCommitable() {
+ RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
+ },
+
+ addFilesToDirectory(inDirectory, currentList, newList) {
+ RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
+ },
+
+ toggleRawPreview() {
+ RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
+ RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
+ },
+
+ setActiveFiles(file) {
+ if (RepoStore.isActiveFile(file)) return;
+ RepoStore.openedFiles = RepoStore.openedFiles
+ .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i));
+
+ RepoStore.setActiveToRaw();
+
+ if (file.binary) {
+ RepoStore.blobRaw = file.base64;
+ } else if (file.newContent || file.plain) {
+ RepoStore.blobRaw = file.newContent || file.plain;
+ } else {
+ Service.getRaw(file.raw_path)
+ .then((rawResponse) => {
+ RepoStore.blobRaw = rawResponse.data;
+ Helper.findOpenedFileFromActive().plain = rawResponse.data;
+ }).catch(Helper.loadingError);
+ }
+
+ if (!file.loading) Helper.updateHistoryEntry(file.url, file.name);
+ RepoStore.binary = file.binary;
+ },
+
+ setFileActivity(file, openedFile, i) {
+ const activeFile = openedFile;
+ activeFile.active = file.url === activeFile.url;
+
+ if (activeFile.active) RepoStore.setActiveFile(activeFile, i);
+
+ return activeFile;
+ },
+
+ setActiveFile(activeFile, i) {
+ RepoStore.activeFile = Object.assign({}, RepoStore.activeFile, activeFile);
+ RepoStore.activeFileIndex = i;
+ },
+
+ setActiveToRaw() {
+ RepoStore.activeFile.raw = false;
+ // can't get vue to listen to raw for some reason so RepoStore for now.
+ RepoStore.activeFileLabel = 'Display source';
+ },
+
+ removeChildFilesOfTree(tree) {
+ let foundTree = false;
+ const treeToClose = tree;
+ let canStopSearching = false;
+ RepoStore.files = RepoStore.files.filter((file) => {
+ const isItTheTreeWeWant = file.url === treeToClose.url;
+ // if it's the next tree
+ if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
+ canStopSearching = true;
+ return true;
+ }
+ if (canStopSearching) return true;
+
+ if (isItTheTreeWeWant) foundTree = true;
+
+ if (foundTree) return file.level <= treeToClose.level;
+ return true;
+ });
+
+ treeToClose.opened = false;
+ treeToClose.icon = 'fa-folder';
+ return treeToClose;
+ },
+
+ removeFromOpenedFiles(file) {
+ if (file.type === 'tree') return;
+ let foundIndex;
+ RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => {
+ if (openedFile.path === file.path) foundIndex = i;
+ return openedFile.path !== file.path;
+ });
+
+ // now activate the right tab based on what you closed.
+ if (RepoStore.openedFiles.length === 0) {
+ RepoStore.activeFile = {};
+ return;
+ }
+
+ if (RepoStore.openedFiles.length === 1 || foundIndex === 0) {
+ RepoStore.setActiveFiles(RepoStore.openedFiles[0]);
+ return;
+ }
+
+ if (foundIndex && foundIndex > 0) {
+ RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]);
+ }
+ },
+
+ addToOpenedFiles(file) {
+ const openFile = file;
+
+ const openedFilesAlreadyExists = RepoStore.openedFiles
+ .some(openedFile => openedFile.path === openFile.path);
+
+ if (openedFilesAlreadyExists) return;
+
+ openFile.changed = false;
+ RepoStore.openedFiles.push(openFile);
+ },
+
+ setActiveFileContents(contents) {
+ if (!RepoStore.editMode) return;
+ const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex];
+ RepoStore.activeFile.newContent = contents;
+ RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent;
+ currentFile.changed = RepoStore.activeFile.changed;
+ currentFile.newContent = contents;
+ },
+
+ toggleBlobView() {
+ RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview';
+ },
+
+ setViewToPreview() {
+ RepoStore.currentBlobView = 'repo-preview';
+ },
+
+ // getters
+
+ isActiveFile(file) {
+ return file && file.url === RepoStore.activeFile.url;
+ },
+
+ isPreviewView() {
+ return RepoStore.currentBlobView === 'repo-preview';
+ },
+};
+
+export default RepoStore;
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index a9b3de281e1..fa958d75fa4 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,14 +1,15 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, object-shorthand, comma-dangle, no-else-return, no-param-reassign, max-len */
+import _ from 'underscore';
import Cookies from 'js-cookie';
+import SidebarHeightManager from './sidebar_height_manager';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Sidebar = (function() {
function Sidebar(currentUser) {
- this.toggleTodo = bind(this.toggleTodo, this);
+ this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
+
this.removeListeners();
this.addEventListeners();
}
@@ -22,15 +23,14 @@ import Cookies from 'js-cookie';
};
Sidebar.prototype.addEventListeners = function() {
+ SidebarHeightManager.init();
const $document = $(document);
- const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight, 10);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
- $(window).on('resize', () => throttledSetSidebarHeight());
- $document.on('scroll', () => throttledSetSidebarHeight());
+
$document.on('click', '.js-sidebar-toggle', function(e, triggered) {
var $allGutterToggleIcons, $this, $thisIcon;
e.preventDefault();
@@ -208,17 +208,6 @@ import Cookies from 'js-cookie';
}
};
- Sidebar.prototype.setSidebarHeight = function() {
- const $navHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight() + $('.sub-nav-scroll').outerHeight();
- const $rightSidebar = $('.js-right-sidebar');
- const diff = $navHeight - $(window).scrollTop();
- if (diff > 0) {
- $rightSidebar.outerHeight($(window).height() - diff);
- } else {
- $rightSidebar.outerHeight('100%');
- }
- };
-
Sidebar.prototype.isOpen = function() {
return this.sidebar.is('.right-sidebar-expanded');
};
diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js
index 15f5963353a..05caf177aec 100644
--- a/app/assets/javascripts/search.js
+++ b/app/assets/javascripts/search.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */
-/* global Api */
+/* global Flash */
+import Api from './api';
(function() {
this.Search = (function() {
@@ -7,6 +8,7 @@
var $groupDropdown, $projectDropdown;
$groupDropdown = $('.js-search-group-dropdown');
$projectDropdown = $('.js-search-project-dropdown');
+ this.groupId = $groupDropdown.data('group-id');
this.eventListeners();
$groupDropdown.glDropdown({
selectable: true,
@@ -46,14 +48,18 @@
search: {
fields: ['name']
},
- data: function(term, callback) {
- return Api.projects(term, { order_by: 'id' }, function(data) {
- data.unshift({
- name_with_namespace: 'Any'
- });
- data.splice(1, 0, 'divider');
- return callback(data);
- });
+ data: (term, callback) => {
+ this.getProjectsData(term)
+ .then((data) => {
+ data.unshift({
+ name_with_namespace: 'Any'
+ });
+ data.splice(1, 0, 'divider');
+
+ return data;
+ })
+ .then(data => callback(data))
+ .catch(() => new Flash('Error fetching projects'));
},
id: function(obj) {
return obj.id;
@@ -95,6 +101,18 @@
return $('.js-search-input').val('').trigger('keyup').focus();
};
+ Search.prototype.getProjectsData = function(term) {
+ return new Promise((resolve) => {
+ if (this.groupId) {
+ Api.groupProjects(this.groupId, term, resolve);
+ } else {
+ Api.projects(term, {
+ order_by: 'id',
+ }, resolve);
+ }
+ });
+ };
+
return Search;
})();
}).call(window);
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
new file mode 100644
index 00000000000..7fa5996d600
--- /dev/null
+++ b/app/assets/javascripts/settings_panels.js
@@ -0,0 +1,44 @@
+function expandSectionParent($section, $content) {
+ $section.addClass('expanded');
+ $content.off('animationend.expandSectionParent');
+}
+
+function expandSection($section) {
+ $section.find('.js-settings-toggle').text('Collapse');
+
+ const $content = $section.find('.settings-content');
+ $content.addClass('expanded').off('scroll.expandSection').scrollTop(0);
+
+ if ($content.hasClass('no-animate')) {
+ expandSectionParent($section, $content);
+ } else {
+ $content.on('animationend.expandSectionParent', () => expandSectionParent($section, $content));
+ }
+}
+
+function closeSection($section) {
+ $section.find('.js-settings-toggle').text('Expand');
+
+ const $content = $section.find('.settings-content');
+ $content.removeClass('expanded').on('scroll.expandSection', () => expandSection($section));
+
+ $section.removeClass('expanded');
+}
+
+function toggleSection($section) {
+ const $content = $section.find('.settings-content');
+ $content.removeClass('no-animate');
+ if ($content.hasClass('expanded')) {
+ closeSection($section);
+ } else {
+ expandSection($section);
+ }
+}
+
+export default function initSettingsPanels() {
+ $('.settings').each((i, elm) => {
+ const $section = $(elm);
+ $section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section));
+ $section.find('.settings-content:not(.expanded)').on('scroll.expandSection', () => expandSection($section));
+ });
+}
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index 85659d7fa39..e3daa8cf949 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,14 +1,13 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, prefer-arrow-callback, consistent-return, object-shorthand, no-unused-vars, one-var, one-var-declaration-per-line, no-else-return, comma-dangle, max-len */
/* global Mousetrap */
-/* global findFileURL */
+import Cookies from 'js-cookie';
+
import findAndFollowLink from './shortcuts_dashboard_navigation';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Shortcuts = (function() {
function Shortcuts(skipResetBindings) {
- this.onToggleHelp = bind(this.onToggleHelp, this);
+ this.onToggleHelp = this.onToggleHelp.bind(this);
this.enabledHelp = [];
if (!skipResetBindings) {
Mousetrap.reset();
@@ -16,9 +15,11 @@ import findAndFollowLink from './shortcuts_dashboard_navigation';
Mousetrap.bind('?', this.onToggleHelp);
Mousetrap.bind('s', Shortcuts.focusSearch);
Mousetrap.bind('f', (e => this.focusFilter(e)));
+ Mousetrap.bind('p b', this.onTogglePerfBar);
const $globalDropdownMenu = $('.global-dropdown-menu');
const $globalDropdownToggle = $('.global-dropdown-toggle');
+ const findFileURL = document.body.dataset.findFile;
$('.global-dropdown').on('hide.bs.dropdown', () => {
$globalDropdownMenu.removeClass('shortcuts');
@@ -55,6 +56,17 @@ import findAndFollowLink from './shortcuts_dashboard_navigation';
return Shortcuts.toggleHelp(this.enabledHelp);
};
+ Shortcuts.prototype.onTogglePerfBar = function(e) {
+ e.preventDefault();
+ const performanceBarCookieName = 'perf_bar_enabled';
+ if (Cookies.get(performanceBarCookieName) === 'true') {
+ Cookies.remove(performanceBarCookieName, { path: '/' });
+ } else {
+ Cookies.set(performanceBarCookieName, 'true', { path: '/' });
+ }
+ gl.utils.refreshCurrentPage();
+ };
+
Shortcuts.prototype.toggleMarkdownPreview = function(e) {
// Check if short-cut was triggered while in Write Mode
const $target = $(e.target);
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index bfe90aef71e..ccbf7c59165 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,14 +1,14 @@
/* global Mousetrap */
/* global Shortcuts */
-require('./shortcuts');
+import './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
};
-class ShortcutsBlob extends Shortcuts {
+export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
@@ -25,5 +25,3 @@ class ShortcutsBlob extends Shortcuts {
}
}
}
-
-module.exports = ShortcutsBlob;
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index a27ac264a5c..b18b6139b35 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index fe58e98cee5..0be141eb5f9 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -3,8 +3,9 @@
/* global ShortcutsNavigation */
/* global sidebar */
-require('mousetrap');
-require('./shortcuts_navigation');
+import _ from 'underscore';
+import 'mousetrap';
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
@@ -38,7 +39,7 @@ require('./shortcuts_navigation');
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
- var quote, documentFragment, selected, separator;
+ var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note');
documentFragment = window.gl.utils.getSelectedFragment();
@@ -47,10 +48,8 @@ require('./shortcuts_navigation');
return;
}
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return;
-
- selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
+ el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ selected = window.gl.CopyAsGFM.nodeToGFM(el);
if (selected.trim() === "") {
return;
@@ -60,7 +59,7 @@ require('./shortcuts_navigation');
});
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
- replyField.val(function(_, current) {
+ replyField.val(function(a, current) {
return current + separator + quote.join('') + "\n";
});
@@ -79,7 +78,9 @@ require('./shortcuts_navigation');
ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn;
$editBtn = $('.issuable-edit');
- return gl.utils.visitUrl($editBtn.attr('href'));
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ $editBtn.get(0).click();
};
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index c74ab0afd0c..55bae0c08a1 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,9 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
/* global Mousetrap */
/* global Shortcuts */
-import findAndFollowLink from './shortcuts_dashboard_navigation';
-require('./shortcuts');
+import findAndFollowLink from './shortcuts_dashboard_navigation';
+import './shortcuts';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index 4c2bf8bf001..cc44082efa9 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
new file mode 100644
index 00000000000..5a6e47e566e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -0,0 +1,59 @@
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ showToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+ template: `
+ <div class="title hide-collapsed">
+ {{assigneeTitle}}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ />
+ <a
+ v-if="editable"
+ class="edit-link pull-right"
+ href="#"
+ >
+ Edit
+ </a>
+ <a
+ v-if="showToggle"
+ aria-label="Toggle sidebar"
+ class="gutter-toggle pull-right js-sidebar-toggle"
+ href="#"
+ role="button"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-angle-double-right"
+ />
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
new file mode 100644
index 00000000000..7e5feac622c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js
@@ -0,0 +1,224 @@
+export default {
+ name: 'Assignees',
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.avatar || user.avatar_url;
+ },
+ assigneeUrl(user) {
+ return `${this.rootPath}${user.username}`;
+ },
+ assigneeAlt(user) {
+ return `${user.name}'s avatar`;
+ },
+ assigneeUsername(user) {
+ return `@${user.username}`;
+ },
+ shouldRenderCollapsedAssignee(index) {
+ const firstTwo = this.users.length <= 2 && index <= 2;
+
+ return index === 0 || firstTwo;
+ },
+ },
+ template: `
+ <div>
+ <div
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ data-container="body"
+ data-placement="left"
+ :title="collapsedTooltipTitle"
+ >
+ <i
+ v-if="hasNoUsers"
+ aria-label="No Assignee"
+ class="fa fa-user"
+ />
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ >
+ <img
+ width="24"
+ class="avatar avatar-inline s24"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ <span class="author">
+ {{ user.name }}
+ </span>
+ </button>
+ <button
+ v-if="hasMoreThanTwoAssignees"
+ class="btn-link"
+ type="button"
+ >
+ <span
+ class="avatar-counter sidebar-avatar-counter"
+ >
+ {{ sidebarAvatarCounter }}
+ </span>
+ </button>
+ </div>
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value">
+ No assignee
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="btn-link"
+ @click="assignSelf"
+ >
+ assign yourself
+ </button>
+ </template>
+ </span>
+ </template>
+ <template v-else-if="hasOneUser">
+ <a
+ class="author_link bold"
+ :href="assigneeUrl(firstUser)"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(firstUser)"
+ :src="avatarUrl(firstUser)"
+ />
+ <span class="author">
+ {{ firstUser.name }}
+ </span>
+ <span class="username">
+ {{ assigneeUsername(firstUser) }}
+ </span>
+ </a>
+ </template>
+ <template v-else>
+ <div class="user-list">
+ <div
+ class="user-item"
+ v-for="(user, index) in users"
+ v-if="renderAssignee(index)"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-placement="bottom"
+ :href="assigneeUrl(user)"
+ :data-title="user.name"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="renderShowMoreSection"
+ class="user-list-more"
+ >
+ <button
+ type="button"
+ class="btn-link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenAssigneesLabel }}
+ </template>
+ <template v-else>
+ - show less
+ </template>
+ </button>
+ </div>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
new file mode 100644
index 00000000000..f83c3b037ed
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -0,0 +1,87 @@
+/* global Flash */
+
+import AssigneeTitle from './assignee_title';
+import Assignees from './assignees';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ loading: false,
+ field: '',
+ };
+ },
+ components: {
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeMount() {
+ this.field = this.$el.dataset.field;
+ this.signedIn = typeof this.$el.dataset.signedIn !== 'undefined';
+ },
+ template: `
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading || store.isFetching.assignees"
+ :editable="store.editable"
+ :show-toggle="!signedIn"
+ />
+ <assignees
+ v-if="!store.isFetching.assignees"
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
new file mode 100644
index 00000000000..8e7abdbffef
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -0,0 +1,82 @@
+<script>
+/* global Flash */
+import editForm from './edit_form.vue';
+
+export default {
+ components: {
+ editForm,
+ },
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ isEditable: {
+ required: true,
+ type: Boolean,
+ },
+ service: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ edit: false,
+ };
+ },
+ computed: {
+ faEye() {
+ const eye = this.isConfidential ? 'fa-eye-slash' : 'fa-eye';
+ return {
+ [eye]: true,
+ };
+ },
+ },
+ methods: {
+ toggleForm() {
+ this.edit = !this.edit;
+ },
+ updateConfidentialAttribute(confidential) {
+ this.service.update('issue', { confidential })
+ .then(() => location.reload())
+ .catch(() => new Flash('Something went wrong trying to change the confidentiality of this issue'));
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block confidentiality">
+ <div class="sidebar-collapsed-icon">
+ <i class="fa" :class="faEye" aria-hidden="true" data-hidden="true"></i>
+ </div>
+ <div class="title hide-collapsed">
+ Confidentiality
+ <a
+ v-if="isEditable"
+ class="pull-right confidential-edit"
+ href="#"
+ @click.prevent="toggleForm"
+ >
+ Edit
+ </a>
+ </div>
+ <div class="value confidential-value hide-collapsed">
+ <editForm
+ v-if="edit"
+ :toggle-form="toggleForm"
+ :is-confidential="isConfidential"
+ :update-confidential-attribute="updateConfidentialAttribute"
+ />
+ <div v-if="!isConfidential" class="no-value confidential-value">
+ <i class="fa fa-eye is-not-confidential"></i>
+ Not confidential
+ </div>
+ <div v-else class="value confidential-value hide-collapsed">
+ <i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i>
+ This issue is confidential
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
new file mode 100644
index 00000000000..d578b663a54
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -0,0 +1,47 @@
+<script>
+import editFormButtons from './edit_form_buttons.vue';
+
+export default {
+ components: {
+ editFormButtons,
+ },
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+ updateConfidentialAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown open">
+ <div class="dropdown-menu confidential-warning-message">
+ <div>
+ <p v-if="!isConfidential">
+ You are going to turn on the confidentiality. This means that only team members with
+ <strong>at least Reporter access</strong>
+ are able to see and leave comments on the issue.
+ </p>
+ <p v-else>
+ You are going to turn off the confidentiality. This means
+ <strong>everyone</strong>
+ will be able to see and leave a comment on this issue.
+ </p>
+ <edit-form-buttons
+ :is-confidential="isConfidential"
+ :toggle-form="toggleForm"
+ :update-confidential-attribute="updateConfidentialAttribute"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
new file mode 100644
index 00000000000..97af4a3f505
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -0,0 +1,45 @@
+<script>
+export default {
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+ updateConfidentialAttribute: {
+ required: true,
+ type: Function,
+ },
+ },
+ computed: {
+ onOrOff() {
+ return this.isConfidential ? 'Turn Off' : 'Turn On';
+ },
+ updateConfidentialBool() {
+ return !this.isConfidential;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="confidential-warning-message-actions">
+ <button
+ type="button"
+ class="btn btn-default append-right-10"
+ @click="toggleForm"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ class="btn btn-close"
+ @click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
+ >
+ {{ onOrOff }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
new file mode 100644
index 00000000000..0da265053bd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
@@ -0,0 +1,97 @@
+import stopwatchSvg from 'icons/_icon_stopwatch.svg';
+
+import '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'time-tracking-collapsed-state',
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class="sidebar-collapsed-icon">
+ ${stopwatchSvg}
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
new file mode 100644
index 00000000000..40f5c89c5bb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -0,0 +1,98 @@
+import '../../../lib/utils/pretty_time';
+
+const prettyTime = gl.utils.prettyTime;
+
+export default {
+ name: 'time-tracking-comparison-pane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parsedRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return prettyTime.parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return prettyTime.stringifyTime(this.parsedRemaining);
+ },
+ timeRemainingTooltip() {
+ const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.timeRemainingHumanReadable}`;
+ },
+ /* Diff values for comparison meter */
+ timeRemainingMinutes() {
+ return this.timeEstimate - this.timeSpent;
+ },
+ timeRemainingPercent() {
+ return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
+ },
+ timeRemainingStatusClass() {
+ return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
+ },
+ /* Parsed time values */
+ parsedEstimate() {
+ return prettyTime.parseSeconds(this.timeEstimate);
+ },
+ parsedSpent() {
+ return prettyTime.parseSeconds(this.timeSpent);
+ },
+ },
+ template: `
+ <div class="time-tracking-comparison-pane">
+ <div
+ class="compare-meter"
+ data-toggle="tooltip"
+ data-placement="top"
+ role="timeRemainingDisplay"
+ :aria-valuenow="timeRemainingTooltip"
+ :title="timeRemainingTooltip"
+ :data-original-title="timeRemainingTooltip"
+ :class="timeRemainingStatusClass"
+ >
+ <div
+ class="meter-container"
+ role="timeSpentPercent"
+ :aria-valuenow="timeRemainingPercent"
+ >
+ <div
+ :style="{ width: timeRemainingPercent }"
+ class="meter-fill"
+ />
+ </div>
+ <div class="compare-display-container">
+ <div class="compare-display pull-left">
+ <span class="compare-label">
+ Spent
+ </span>
+ <span class="compare-value spent">
+ {{ timeSpentHumanReadable }}
+ </span>
+ </div>
+ <div class="compare-display estimated pull-right">
+ <span class="compare-label">
+ Est
+ </span>
+ <span class="compare-value">
+ {{ timeEstimateHumanReadable }}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
new file mode 100644
index 00000000000..ad1b9179db0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'time-tracking-estimate-only-pane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ Estimated:
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
new file mode 100644
index 00000000000..142ad437509
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -0,0 +1,44 @@
+export default {
+ name: 'time-tracking-help-state',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ href() {
+ return `${this.rootPath}help/workflow/time_tracking.md`;
+ },
+ },
+ template: `
+ <div class="time-tracking-help-state">
+ <div class="time-tracking-info">
+ <h4>
+ Track time with quick actions
+ </h4>
+ <p>
+ Quick actions can be used in the issues description and comment boxes.
+ </p>
+ <p>
+ <code>
+ /estimate
+ </code>
+ will update the estimated time with the latest command.
+ </p>
+ <p>
+ <code>
+ /spend
+ </code>
+ will update the sum of the time spent.
+ </p>
+ <a
+ class="btn btn-default learn-more-button"
+ :href="href"
+ >
+ Learn more
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
new file mode 100644
index 00000000000..d1dd1dcdd27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -0,0 +1,10 @@
+export default {
+ name: 'time-tracking-no-tracking-pane',
+ template: `
+ <div class="time-tracking-no-tracking-pane">
+ <span class="no-value">
+ No estimate or time spent
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
new file mode 100644
index 00000000000..2d682215cf8
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -0,0 +1,53 @@
+import _ from 'underscore';
+
+import '~/smart_interval';
+
+import timeTracker from './time_tracker';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+export default {
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ };
+ },
+ components: {
+ 'issuable-time-tracker': timeTracker,
+ },
+ methods: {
+ listenForQuickActions() {
+ $(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+ },
+ quickActionListened(e, data) {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ let changedCommands;
+ if (data !== undefined) {
+ changedCommands = data.commands_changes
+ ? Object.keys(data.commands_changes)
+ : [];
+ } else {
+ changedCommands = [];
+ }
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.mediator.fetch();
+ }
+ },
+ },
+ mounted() {
+ this.listenForQuickActions();
+ },
+ template: `
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :rootPath="store.rootPath"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
new file mode 100644
index 00000000000..ed0d71a4f79
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
@@ -0,0 +1,163 @@
+import timeTrackingHelpState from './help_state';
+import timeTrackingCollapsedState from './collapsed_state';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'issuable-time-tracker',
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ components: {
+ 'time-tracking-collapsed-state': timeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ template: `
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <div class="title hide-collapsed">
+ Time tracking
+ <div
+ class="help-button pull-right"
+ v-if="!showHelpState"
+ @click="toggleHelpState(true)"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ />
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :rootPath="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js
new file mode 100644
index 00000000000..f35506fd5de
--- /dev/null
+++ b/app/assets/javascripts/sidebar/event_hub.js
@@ -0,0 +1,8 @@
+import Vue from 'vue';
+
+const eventHub = new Vue();
+
+// TODO: remove eventHub hack after code splitting refactor
+window.emitSidebarEvent = (...args) => eventHub.$emit(...args);
+
+export default eventHub;
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
new file mode 100644
index 00000000000..5a82d01dc41
--- /dev/null
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class SidebarService {
+ constructor(endpoint) {
+ if (!SidebarService.singleton) {
+ this.endpoint = endpoint;
+
+ SidebarService.singleton = this;
+ }
+
+ return SidebarService.singleton;
+ }
+
+ get() {
+ return Vue.http.get(this.endpoint);
+ }
+
+ update(key, data) {
+ return Vue.http.put(this.endpoint, {
+ [key]: data,
+ }, {
+ emulateJSON: true,
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..9edded3ead6
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import sidebarAssignees from './components/assignees/sidebar_assignees';
+import confidential from './components/confidential/confidential_issue_sidebar.vue';
+
+import Mediator from './sidebar_mediator';
+
+function domContentLoaded() {
+ const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+ const mediator = new Mediator(sidebarOptions);
+ mediator.fetch();
+
+ const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+ const confidentialEl = document.querySelector('#js-confidential-entry-point');
+ // Only create the sidebarAssignees vue app if it is found in the DOM
+ // We currently do not use sidebarAssignees for the MR page
+ if (sidebarAssigneesEl) {
+ new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
+ }
+
+ if (confidentialEl) {
+ const dataNode = document.getElementById('js-confidential-issue-data');
+ const initialData = JSON.parse(dataNode.innerHTML);
+
+ const ConfidentialComp = Vue.extend(confidential);
+
+ new ConfidentialComp({
+ propsData: {
+ isConfidential: initialData.is_confidential,
+ isEditable: initialData.is_editable,
+ service: mediator.service,
+ },
+ }).$mount(confidentialEl);
+ }
+
+ new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded);
+
+export default domContentLoaded;
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
new file mode 100644
index 00000000000..721e92221cf
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -0,0 +1,38 @@
+/* global Flash */
+
+import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
+
+export default class SidebarMediator {
+ constructor(options) {
+ if (!SidebarMediator.singleton) {
+ this.store = new Store(options);
+ this.service = new Service(options.endpoint);
+ SidebarMediator.singleton = this;
+ }
+
+ return SidebarMediator.singleton;
+ }
+
+ assignYourself() {
+ this.store.addAssignee(this.store.currentUser);
+ }
+
+ saveAssignees(field) {
+ const selected = this.store.assignees.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ return this.service.update(field, selected.length === 0 ? [0] : selected);
+ }
+
+ fetch() {
+ this.service.get()
+ .then(response => response.json())
+ .then((data) => {
+ this.store.setAssigneeData(data);
+ this.store.setTimeTrackingData(data);
+ })
+ .catch(() => new Flash('Error occured when fetching sidebar data'));
+ }
+}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
new file mode 100644
index 00000000000..3356dd0191f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -0,0 +1,56 @@
+export default class SidebarStore {
+ constructor(store) {
+ if (!SidebarStore.singleton) {
+ const { currentUser, rootPath, editable } = store;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+ this.isFetching = {
+ assignees: true,
+ };
+
+ SidebarStore.singleton = this;
+ }
+
+ return SidebarStore.singleton;
+ }
+
+ setAssigneeData(data) {
+ this.isFetching.assignees = false;
+ if (data.assignees) {
+ this.assignees = data.assignees;
+ }
+ }
+
+ setTimeTrackingData(data) {
+ this.timeEstimate = data.time_estimate;
+ this.totalTimeSpent = data.total_time_spent;
+ this.humanTimeEstimate = data.human_time_estimate;
+ this.humanTotalTimeSpent = data.human_total_time_spent;
+ }
+
+ addAssignee(assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(assignee);
+ }
+ }
+
+ findAssignee(findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee(removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees() {
+ this.assignees = [];
+ }
+}
diff --git a/app/assets/javascripts/sidebar_height_manager.js b/app/assets/javascripts/sidebar_height_manager.js
new file mode 100644
index 00000000000..2752fe2b911
--- /dev/null
+++ b/app/assets/javascripts/sidebar_height_manager.js
@@ -0,0 +1,37 @@
+import _ from 'underscore';
+import Cookies from 'js-cookie';
+
+export default {
+ init() {
+ if (!this.initialized) {
+ if (Cookies.get('new_nav') === 'true' && $('.js-issuable-sidebar').length) return;
+
+ this.$window = $(window);
+ this.$rightSidebar = $('.js-right-sidebar');
+ this.$navHeight = $('.navbar-gitlab').outerHeight() +
+ $('.layout-nav').outerHeight() +
+ $('.sub-nav-scroll').outerHeight();
+
+ const throttledSetSidebarHeight = _.throttle(() => this.setSidebarHeight(), 20);
+ const debouncedSetSidebarHeight = _.debounce(() => this.setSidebarHeight(), 200);
+
+ this.$window.on('scroll', throttledSetSidebarHeight);
+ this.$window.on('resize', debouncedSetSidebarHeight);
+ this.initialized = true;
+ }
+ },
+
+ setSidebarHeight() {
+ const currentScrollDepth = window.pageYOffset || 0;
+ const diff = this.$navHeight - currentScrollDepth;
+
+ if (diff > 0) {
+ const newSidebarHeight = window.innerHeight - diff;
+ this.$rightSidebar.outerHeight(newSidebarHeight);
+ this.sidebarHeightIsCustom = true;
+ } else if (this.sidebarHeightIsCustom) {
+ this.$rightSidebar.outerHeight('100%');
+ this.sidebarHeightIsCustom = false;
+ }
+ },
+};
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js
index d811d1cd53a..20255398047 100644
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/signin_tabs_memoizer.js
@@ -1,49 +1,53 @@
/* eslint no-param-reassign: ["error", { "props": false }]*/
/* eslint no-new: "off" */
-((global) => {
- /**
- * Memorize the last selected tab after reloading a page.
- * Does that setting the current selected tab in the localStorage
- */
- class ActiveTabMemoizer {
- constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
- this.currentTabKey = currentTabKey;
- this.tabSelector = tabSelector;
- this.bootstrap();
- }
+import AccessorUtilities from './lib/utils/accessor';
- bootstrap() {
- const tabs = document.querySelectorAll(this.tabSelector);
- if (tabs.length > 0) {
- tabs[0].addEventListener('click', (e) => {
- if (e.target && e.target.nodeName === 'A') {
- const anchorName = e.target.getAttribute('href');
- this.saveData(anchorName);
- }
- });
- }
+/**
+ * Memorize the last selected tab after reloading a page.
+ * Does that setting the current selected tab in the localStorage
+ */
+export default class SigninTabsMemoizer {
+ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
+ this.currentTabKey = currentTabKey;
+ this.tabSelector = tabSelector;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
- this.showTab();
- }
+ this.bootstrap();
+ }
- showTab() {
- const anchorName = this.readData();
- if (anchorName) {
- const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
- if (tab) {
- tab.click();
+ bootstrap() {
+ const tabs = document.querySelectorAll(this.tabSelector);
+ if (tabs.length > 0) {
+ tabs[0].addEventListener('click', (e) => {
+ if (e.target && e.target.nodeName === 'A') {
+ const anchorName = e.target.getAttribute('href');
+ this.saveData(anchorName);
}
- }
+ });
}
- saveData(val) {
- localStorage.setItem(this.currentTabKey, val);
- }
+ this.showTab();
+ }
- readData() {
- return localStorage.getItem(this.currentTabKey);
+ showTab() {
+ const anchorName = this.readData();
+ if (anchorName) {
+ const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
+ if (tab) {
+ tab.click();
+ }
}
}
- global.ActiveTabMemoizer = ActiveTabMemoizer;
-})(window);
+ saveData(val) {
+ if (!this.isLocalStorageAvailable) return undefined;
+
+ return window.localStorage.setItem(this.currentTabKey, val);
+ }
+
+ readData() {
+ if (!this.isLocalStorageAvailable) return null;
+
+ return window.localStorage.getItem(this.currentTabKey);
+ }
+}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 294d087554e..4505a79a2df 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,98 +1,83 @@
/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+import FilesCommentButton from './files_comment_button';
- window.SingleFileDiff = (function() {
- var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
+const WRAPPER = '<div class="diff-content"></div>';
+const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
+const ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
+const COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>';
- WRAPPER = '<div class="diff-content diff-wrap-lines"></div>';
-
- LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
-
- ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
+export default class SingleFileDiff {
+ constructor(file) {
+ this.file = file;
+ this.toggleDiff = this.toggleDiff.bind(this);
+ this.content = $('.diff-content', this.file);
+ this.$toggleIcon = $('.diff-toggle-caret', this.file);
+ this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path');
+ this.isOpen = !this.diffForPath;
+ if (this.diffForPath) {
+ this.collapsedContent = this.content;
+ this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide();
+ this.content = null;
+ this.collapsedContent.after(this.loadingContent);
+ this.$toggleIcon.addClass('fa-caret-right');
+ } else {
+ this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide();
+ this.content.after(this.collapsedContent);
+ this.$toggleIcon.addClass('fa-caret-down');
+ }
- COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>';
+ $('.js-file-title, .click-to-expand', this.file).on('click', (function (e) {
+ this.toggleDiff($(e.target));
+ }).bind(this));
+ }
- function SingleFileDiff(file) {
- this.file = file;
- this.toggleDiff = bind(this.toggleDiff, this);
- this.content = $('.diff-content', this.file);
- this.$toggleIcon = $('.diff-toggle-caret', this.file);
- this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path');
- this.isOpen = !this.diffForPath;
- if (this.diffForPath) {
- this.collapsedContent = this.content;
- this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide();
- this.content = null;
- this.collapsedContent.after(this.loadingContent);
- this.$toggleIcon.addClass('fa-caret-right');
- } else {
- this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide();
- this.content.after(this.collapsedContent);
- this.$toggleIcon.addClass('fa-caret-down');
+ toggleDiff($target, cb) {
+ if (!$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return;
+ this.isOpen = !this.isOpen;
+ if (!this.isOpen && !this.hasError) {
+ this.content.hide();
+ this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
+ this.collapsedContent.show();
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
}
-
- $('.js-file-title, .click-to-expand', this.file).on('click', (function (e) {
- this.toggleDiff($(e.target));
- }).bind(this));
+ } else if (this.content) {
+ this.collapsedContent.hide();
+ this.content.show();
+ this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ gl.diffNotesCompileComponents();
+ }
+ } else {
+ this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ return this.getContentHTML(cb);
}
+ }
- SingleFileDiff.prototype.toggleDiff = function($target, cb) {
- if (!$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return;
- this.isOpen = !this.isOpen;
- if (!this.isOpen && !this.hasError) {
- this.content.hide();
- this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
- this.collapsedContent.show();
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
+ getContentHTML(cb) {
+ this.collapsedContent.hide();
+ this.loadingContent.show();
+ $.get(this.diffForPath, (function(_this) {
+ return function(data) {
+ _this.loadingContent.hide();
+ if (data.html) {
+ _this.content = $(data.html);
+ _this.content.syntaxHighlight();
+ } else {
+ _this.hasError = true;
+ _this.content = $(ERROR_HTML);
}
- } else if (this.content) {
- this.collapsedContent.hide();
- this.content.show();
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ _this.collapsedContent.after(_this.content);
+
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
- } else {
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
- return this.getContentHTML(cb);
- }
- };
-
- SingleFileDiff.prototype.getContentHTML = function(cb) {
- this.collapsedContent.hide();
- this.loadingContent.show();
- $.get(this.diffForPath, (function(_this) {
- return function(data) {
- _this.loadingContent.hide();
- if (data.html) {
- _this.content = $(data.html);
- _this.content.syntaxHighlight();
- } else {
- _this.hasError = true;
- _this.content = $(ERROR_HTML);
- }
- _this.collapsedContent.after(_this.content);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
+ FilesCommentButton.init($(_this.file));
- if (cb) cb();
- };
- })(this));
- };
-
- return SingleFileDiff;
- })();
-
- $.fn.singleFileDiff = function() {
- return this.each(function() {
- if (!$.data(this, 'singleFileDiff')) {
- return $.data(this, 'singleFileDiff', new window.SingleFileDiff(this));
- }
- });
- };
-}).call(window);
+ if (cb) cb();
+ };
+ })(this));
+ }
+}
diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js
index d1bdc353be2..2bf7a3a5d61 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -1,158 +1,157 @@
-/*
-* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
-* and controllable by a public API.
-*
-* */
-
-(() => {
- class SmartInterval {
- /**
- * @param { function } opts.callback Function to be called on each iteration (required)
- * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
- * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
- * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
- * when the page is hidden
- * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor
- * @param { boolean } opts.lazyStart Configure if timer is initialized on
- * instantiation or lazily
- * @param { boolean } opts.immediateExecution Configure if callback should
- * be executed before the first interval.
- */
- constructor(opts = {}) {
- this.cfg = {
- callback: opts.callback,
- startingInterval: opts.startingInterval,
- maxInterval: opts.maxInterval,
- hiddenInterval: opts.hiddenInterval,
- incrementByFactorOf: opts.incrementByFactorOf,
- lazyStart: opts.lazyStart,
- immediateExecution: opts.immediateExecution,
- };
-
- this.state = {
- intervalId: null,
- currentInterval: this.cfg.startingInterval,
- pageVisibility: 'visible',
- };
-
- this.initInterval();
- }
- /* public */
-
- start() {
- const cfg = this.cfg;
- const state = this.state;
-
- if (cfg.immediateExecution) {
- cfg.immediateExecution = false;
- cfg.callback();
- }
+/**
+ * Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
+ * and controllable by a public API.
+ */
+
+class SmartInterval {
+ /**
+ * @param { function } opts.callback Function to be called on each iteration (required)
+ * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
+ * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
+ * when the page is hidden
+ * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } opts.lazyStart Configure if timer is initialized on
+ * instantiation or lazily
+ * @param { boolean } opts.immediateExecution Configure if callback should
+ * be executed before the first interval.
+ */
+ constructor(opts = {}) {
+ this.cfg = {
+ callback: opts.callback,
+ startingInterval: opts.startingInterval,
+ maxInterval: opts.maxInterval,
+ hiddenInterval: opts.hiddenInterval,
+ incrementByFactorOf: opts.incrementByFactorOf,
+ lazyStart: opts.lazyStart,
+ immediateExecution: opts.immediateExecution,
+ };
+
+ this.state = {
+ intervalId: null,
+ currentInterval: this.cfg.startingInterval,
+ pageVisibility: 'visible',
+ };
+
+ this.initInterval();
+ }
- state.intervalId = window.setInterval(() => {
- cfg.callback();
+ /* public */
- if (this.getCurrentInterval() === cfg.maxInterval) {
- return;
- }
+ start() {
+ const cfg = this.cfg;
+ const state = this.state;
- this.incrementInterval();
- this.resume();
- }, this.getCurrentInterval());
+ if (cfg.immediateExecution) {
+ cfg.immediateExecution = false;
+ cfg.callback();
}
- // cancel the existing timer, setting the currentInterval back to startingInterval
- cancel() {
- this.setCurrentInterval(this.cfg.startingInterval);
- this.stopTimer();
- }
+ state.intervalId = window.setInterval(() => {
+ cfg.callback();
- onVisibilityHidden() {
- if (this.cfg.hiddenInterval) {
- this.setCurrentInterval(this.cfg.hiddenInterval);
- this.resume();
- } else {
- this.cancel();
+ if (this.getCurrentInterval() === cfg.maxInterval) {
+ return;
}
- }
- // start a timer, using the existing interval
- resume() {
- this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
- this.start();
- }
+ this.incrementInterval();
+ this.resume();
+ }, this.getCurrentInterval());
+ }
- onVisibilityVisible() {
- this.cancel();
- this.start();
- }
+ // cancel the existing timer, setting the currentInterval back to startingInterval
+ cancel() {
+ this.setCurrentInterval(this.cfg.startingInterval);
+ this.stopTimer();
+ }
- destroy() {
+ onVisibilityHidden() {
+ if (this.cfg.hiddenInterval) {
+ this.setCurrentInterval(this.cfg.hiddenInterval);
+ this.resume();
+ } else {
this.cancel();
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- $(document).off('visibilitychange').off('beforeunload');
}
+ }
- /* private */
+ // start a timer, using the existing interval
+ resume() {
+ this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
+ this.start();
+ }
- initInterval() {
- const cfg = this.cfg;
+ onVisibilityVisible() {
+ this.cancel();
+ this.start();
+ }
- if (!cfg.lazyStart) {
- this.start();
- }
+ destroy() {
+ this.cancel();
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
+ $(document).off('visibilitychange').off('beforeunload');
+ }
- this.initVisibilityChangeHandling();
- this.initPageUnloadHandling();
- }
+ /* private */
- initVisibilityChangeHandling() {
- // cancel interval when tab no longer shown (prevents cached pages from polling)
- document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
- }
+ initInterval() {
+ const cfg = this.cfg;
- initPageUnloadHandling() {
- // TODO: Consider refactoring in light of turbolinks removal.
- // prevent interval continuing after page change, when kept in cache by Turbolinks
- $(document).on('beforeunload', () => this.cancel());
+ if (!cfg.lazyStart) {
+ this.start();
}
- handleVisibilityChange(e) {
- this.state.pageVisibility = e.target.visibilityState;
- const intervalAction = this.isPageVisible() ?
- this.onVisibilityVisible :
- this.onVisibilityHidden;
+ this.initVisibilityChangeHandling();
+ this.initPageUnloadHandling();
+ }
- intervalAction.apply(this);
- }
+ initVisibilityChangeHandling() {
+ // cancel interval when tab no longer shown (prevents cached pages from polling)
+ document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
+ }
- getCurrentInterval() {
- return this.state.currentInterval;
- }
+ initPageUnloadHandling() {
+ // TODO: Consider refactoring in light of turbolinks removal.
+ // prevent interval continuing after page change, when kept in cache by Turbolinks
+ $(document).on('beforeunload', () => this.cancel());
+ }
- setCurrentInterval(newInterval) {
- this.state.currentInterval = newInterval;
- }
+ handleVisibilityChange(e) {
+ this.state.pageVisibility = e.target.visibilityState;
+ const intervalAction = this.isPageVisible() ?
+ this.onVisibilityVisible :
+ this.onVisibilityHidden;
- incrementInterval() {
- const cfg = this.cfg;
- const currentInterval = this.getCurrentInterval();
- if (cfg.hiddenInterval && !this.isPageVisible()) return;
- let nextInterval = currentInterval * cfg.incrementByFactorOf;
+ intervalAction.apply(this);
+ }
- if (nextInterval > cfg.maxInterval) {
- nextInterval = cfg.maxInterval;
- }
+ getCurrentInterval() {
+ return this.state.currentInterval;
+ }
+
+ setCurrentInterval(newInterval) {
+ this.state.currentInterval = newInterval;
+ }
+
+ incrementInterval() {
+ const cfg = this.cfg;
+ const currentInterval = this.getCurrentInterval();
+ if (cfg.hiddenInterval && !this.isPageVisible()) return;
+ let nextInterval = currentInterval * cfg.incrementByFactorOf;
- this.setCurrentInterval(nextInterval);
+ if (nextInterval > cfg.maxInterval) {
+ nextInterval = cfg.maxInterval;
}
- isPageVisible() { return this.state.pageVisibility === 'visible'; }
+ this.setCurrentInterval(nextInterval);
+ }
- stopTimer() {
- const state = this.state;
+ isPageVisible() { return this.state.pageVisibility === 'visible'; }
- state.intervalId = window.clearInterval(state.intervalId);
- }
+ stopTimer() {
+ const state = this.state;
+
+ state.intervalId = window.clearInterval(state.intervalId);
}
- gl.SmartInterval = SmartInterval;
-})(window.gl || (window.gl = {}));
+}
+
+window.gl.SmartInterval = SmartInterval;
diff --git a/app/assets/javascripts/snippets_list.js b/app/assets/javascripts/snippets_list.js
deleted file mode 100644
index 2128007113f..00000000000
--- a/app/assets/javascripts/snippets_list.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/* eslint-disable arrow-parens, no-param-reassign, space-before-function-paren, func-names, no-var, max-len */
-
-(global => {
- global.gl = global.gl || {};
-
- gl.SnippetsList = function() {
- var $holder = $('.snippets-list-holder');
-
- $holder.find('.pagination').on('ajax:success', (e, data) => {
- $holder.replaceWith(data.html);
- });
- };
-})(window);
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index c75b44cc2fd..3a06b477d7c 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -1,30 +1,28 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-unused-vars, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, no-new, max-len */
/* global Flash */
-(function() {
- this.Star = (function() {
- function Star() {
- $('.project-home-panel .toggle-star').on('ajax:success', function(e, data, status, xhr) {
- var $starIcon, $starSpan, $this, toggleStar;
- $this = $(this);
- $starSpan = $this.find('span');
- $starIcon = $this.find('i');
- toggleStar = function(isStarred) {
- $this.parent().find('.star-count').text(data.star_count);
- if (isStarred) {
- $starSpan.removeClass('starred').text('Star');
- $starIcon.removeClass('fa-star').addClass('fa-star-o');
- } else {
- $starSpan.addClass('starred').text('Unstar');
- $starIcon.removeClass('fa-star-o').addClass('fa-star');
- }
- };
- toggleStar($starSpan.hasClass('starred'));
- }).on('ajax:error', function(e, xhr, status, error) {
- new Flash('Star toggle failed. Try again later.', 'alert');
- });
- }
+import { __, s__ } from './locale';
- return Star;
- })();
-}).call(window);
+export default class Star {
+ constructor() {
+ $('.project-home-panel .toggle-star').on('ajax:success', function(e, data, status, xhr) {
+ var $starIcon, $starSpan, $this, toggleStar;
+ $this = $(this);
+ $starSpan = $this.find('span');
+ $starIcon = $this.find('i');
+ toggleStar = function(isStarred) {
+ $this.parent().find('.star-count').text(data.star_count);
+ if (isStarred) {
+ $starSpan.removeClass('starred').text(s__('StarProject|Star'));
+ $starIcon.removeClass('fa-star').addClass('fa-star-o');
+ } else {
+ $starSpan.addClass('starred').text(__('Unstar'));
+ $starIcon.removeClass('fa-star-o').addClass('fa-star');
+ }
+ };
+ toggleStar($starSpan.hasClass('starred'));
+ }).on('ajax:error', function(e, xhr, status, error) {
+ new Flash('Star toggle failed. Try again later.', 'alert');
+ });
+ }
+}
diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js
deleted file mode 100644
index d8191605128..00000000000
--- a/app/assets/javascripts/subbable_resource.js
+++ /dev/null
@@ -1,51 +0,0 @@
-(() => {
-/*
-* SubbableResource can be extended to provide a pubsub-style service for one-off REST
-* calls. Subscribe by passing a callback or render method you will use to handle responses.
- *
-* */
-
- class SubbableResource {
- constructor(resourcePath) {
- this.endpoint = resourcePath;
-
- // TODO: Switch to axios.create
- this.resource = $.ajax;
- this.subscribers = [];
- }
-
- subscribe(callback) {
- this.subscribers.push(callback);
- }
-
- publish(newResponse) {
- const responseCopy = _.extend({}, newResponse);
- this.subscribers.forEach((fn) => {
- fn(responseCopy);
- });
- return newResponse;
- }
-
- get(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- post(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- put(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- delete(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
- }
-
- gl.SubbableResource = SubbableResource;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js
index 5f9a3e00c22..bb4d68fcd49 100644
--- a/app/assets/javascripts/subscription.js
+++ b/app/assets/javascripts/subscription.js
@@ -1,47 +1,45 @@
-(() => {
- class Subscription {
- constructor(containerElm) {
- this.containerElm = containerElm;
+class Subscription {
+ constructor(containerElm) {
+ this.containerElm = containerElm;
- const subscribeButton = containerElm.querySelector('.js-subscribe-button');
- if (subscribeButton) {
- // remove class so we don't bind twice
- subscribeButton.classList.remove('js-subscribe-button');
- subscribeButton.addEventListener('click', this.toggleSubscription.bind(this));
- }
+ const subscribeButton = containerElm.querySelector('.js-subscribe-button');
+ if (subscribeButton) {
+ // remove class so we don't bind twice
+ subscribeButton.classList.remove('js-subscribe-button');
+ subscribeButton.addEventListener('click', this.toggleSubscription.bind(this));
}
+ }
- toggleSubscription(event) {
- const button = event.currentTarget;
- const buttonSpan = button.querySelector('span');
- if (!buttonSpan || button.classList.contains('disabled')) {
- return;
- }
- button.classList.add('disabled');
+ toggleSubscription(event) {
+ const button = event.currentTarget;
+ const buttonSpan = button.querySelector('span');
+ if (!buttonSpan || button.classList.contains('disabled')) {
+ return;
+ }
+ button.classList.add('disabled');
- const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
- const toggleActionUrl = this.containerElm.dataset.url;
+ const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
+ const toggleActionUrl = this.containerElm.dataset.url;
- $.post(toggleActionUrl, () => {
- button.classList.remove('disabled');
+ $.post(toggleActionUrl, () => {
+ button.classList.remove('disabled');
- // hack to allow this to work with the issue boards Vue object
- if (document.querySelector('html').classList.contains('issue-boards-page')) {
- gl.issueBoards.boardStoreIssueSet(
- 'subscribed',
- !gl.issueBoards.BoardsStore.detail.issue.subscribed,
- );
- } else {
- buttonSpan.innerHTML = isSubscribed ? 'Subscribe' : 'Unsubscribe';
- }
- });
- }
+ // hack to allow this to work with the issue boards Vue object
+ if (document.querySelector('html').classList.contains('issue-boards-page')) {
+ gl.issueBoards.boardStoreIssueSet(
+ 'subscribed',
+ !gl.issueBoards.BoardsStore.detail.issue.subscribed,
+ );
+ } else {
+ buttonSpan.innerHTML = isSubscribed ? 'Subscribe' : 'Unsubscribe';
+ }
+ });
+ }
- static bindAll(selector) {
- [].forEach.call(document.querySelectorAll(selector), elm => new Subscription(elm));
- }
+ static bindAll(selector) {
+ [].forEach.call(document.querySelectorAll(selector), elm => new Subscription(elm));
}
+}
- window.gl = window.gl || {};
- window.gl.Subscription = Subscription;
-})();
+window.gl = window.gl || {};
+window.gl.Subscription = Subscription;
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 8b25f43ffc7..37e39ce5477 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,34 +1,33 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */
-(function() {
- this.SubscriptionSelect = (function() {
- function SubscriptionSelect() {
- $('.js-subscription-event').each(function(i, el) {
- var fieldName;
- fieldName = $(el).data("field-name");
- return $(el).glDropdown({
- selectable: true,
- fieldName: fieldName,
- toggleLabel: (function(_this) {
- return function(selected, el, instance) {
- var $item, label;
- label = 'Subscription';
- $item = instance.dropdown.find('.is-active');
- if ($item.length) {
- label = $item.text();
- }
- return label;
- };
- })(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
- },
- id: function(obj, el) {
- return $(el).data("id");
- }
- });
+
+class SubscriptionSelect {
+ constructor() {
+ $('.js-subscription-event').each(function(i, el) {
+ var fieldName;
+ fieldName = $(el).data("field-name");
+ return $(el).glDropdown({
+ selectable: true,
+ fieldName: fieldName,
+ toggleLabel: (function(_this) {
+ return function(selected, el, instance) {
+ var $item, label;
+ label = 'Subscription';
+ $item = instance.dropdown.find('.is-active');
+ if ($item.length) {
+ label = $item.text();
+ }
+ return label;
+ };
+ })(this),
+ clicked: function(options) {
+ return options.e.preventDefault();
+ },
+ id: function(obj, el) {
+ return $(el).data("id");
+ }
});
- }
+ });
+ }
+}
- return SubscriptionSelect;
- })();
-}).call(window);
+window.SubscriptionSelect = SubscriptionSelect;
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 7c063fae045..662d6b36c16 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -9,19 +9,18 @@
//
// <div class="js-syntax-highlight"></div>
//
-(function() {
- $.fn.syntaxHighlight = function() {
- var $children;
- if ($(this).hasClass('js-syntax-highlight')) {
- // Given the element itself, apply highlighting
- return $(this).addClass(gon.user_color_scheme);
- } else {
- // Given a parent element, recurse to any of its applicable children
- $children = $(this).find('.js-syntax-highlight');
- if ($children.length) {
- return $children.syntaxHighlight();
- }
+$.fn.syntaxHighlight = function() {
+ var $children;
+
+ if ($(this).hasClass('js-syntax-highlight')) {
+ // Given the element itself, apply highlighting
+ return $(this).addClass(gon.user_color_scheme);
+ } else {
+ // Given a parent element, recurse to any of its applicable children
+ $children = $(this).find('.js-syntax-highlight');
+ if ($children.length) {
+ return $children.syntaxHighlight();
}
- };
-}).call(window);
+ }
+};
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index b1402c0a880..c39f569da5e 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,7 +1,8 @@
/* global Flash */
-require('vendor/task_list');
-class TaskList {
+import 'deckar01-task_list';
+
+export default class TaskList {
constructor(options = {}) {
this.selector = options.selector;
this.dataType = options.dataType;
@@ -47,6 +48,3 @@ class TaskList {
});
}
}
-
-window.gl = window.gl || {};
-window.gl.TaskList = TaskList;
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index e62f429f1ae..9dd14488f22 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,5 +1,5 @@
/* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */
-/* global Api */
+import Api from '../api';
import TemplateSelector from '../blob/template_selector';
diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js
index 13cf3a10a38..134522ef961 100644
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ b/app/assets/javascripts/terminal/terminal_bundle.js
@@ -1,7 +1,9 @@
-require('vendor/xterm/encoding-indexes.js');
-require('vendor/xterm/encoding.js');
-window.Terminal = require('vendor/xterm/xterm.js');
-require('vendor/xterm/fit.js');
-require('./terminal.js');
+import 'vendor/xterm/encoding-indexes';
+import 'vendor/xterm/encoding';
+import Terminal from 'vendor/xterm/xterm';
+import 'vendor/xterm/fit';
+import './terminal';
+
+window.Terminal = Terminal;
$(() => new gl.Terminal({ selector: '#terminal' }));
diff --git a/app/assets/javascripts/test.js b/app/assets/javascripts/test.js
new file mode 100644
index 00000000000..c4c7918a68f
--- /dev/null
+++ b/app/assets/javascripts/test.js
@@ -0,0 +1 @@
+$.fx.off = true;
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index ef401abce2d..8875590f0f2 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,3 +1,5 @@
+import 'core-js/es6/map';
+import 'core-js/es6/set';
import simulateDrag from './simulate_drag';
// Export to global space for rspec to use
diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js
index 8be58023c84..a606852c22c 100644
--- a/app/assets/javascripts/todos.js
+++ b/app/assets/javascripts/todos.js
@@ -1,7 +1,8 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-/* global UsersSelect */
-class Todos {
+import UsersSelect from './users_select';
+
+export default class Todos {
constructor() {
this.initFilters();
this.bindEvents();
@@ -36,10 +37,6 @@ class Todos {
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
- $('form.filter-form').on('submit', function applyFilters(event) {
- event.preventDefault();
- gl.utils.visitUrl(`${this.action}&${$(this).serialize()}`);
- });
return new UsersSelect();
}
@@ -55,6 +52,7 @@ class Todos {
}
updateRowStateClicked(e) {
+ e.stopPropagation();
e.preventDefault();
const target = e.target;
@@ -95,6 +93,7 @@ class Todos {
}
updateAllStateClicked(e) {
+ e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
@@ -145,6 +144,7 @@ class Todos {
if (gl.utils.isMetaClick(e)) {
const windowTarget = '_blank';
const selected = e.target;
+ e.stopPropagation();
e.preventDefault();
if (selected.tagName === 'IMG') {
@@ -158,6 +158,3 @@ class Todos {
}
}
}
-
-window.gl = window.gl || {};
-gl.Todos = Todos;
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 76a821c7a17..7777ed1c3dc 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,68 +1,64 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, max-len */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, class-methods-use-this */
-(function() {
- this.TreeView = (function() {
- function TreeView() {
- this.initKeyNav();
- // Code browser tree slider
- // Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
- $(".tree-content-holder .tree-item").on('click', function(e) {
- var $clickedEl, path;
- $clickedEl = $(e.target);
- path = $('.tree-item-file-name a', this).attr('href');
- if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
- if (e.metaKey || e.which === 2) {
- e.preventDefault();
- return window.open(path, '_blank');
- } else {
- return gl.utils.visitUrl(path);
- }
+export default class TreeView {
+ constructor() {
+ this.initKeyNav();
+ // Code browser tree slider
+ // Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
+ $(".tree-content-holder .tree-item").on('click', function(e) {
+ var $clickedEl, path;
+ $clickedEl = $(e.target);
+ path = $('.tree-item-file-name a', this).attr('href');
+ if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
+ if (e.metaKey || e.which === 2) {
+ e.preventDefault();
+ return window.open(path, '_blank');
+ } else {
+ return gl.utils.visitUrl(path);
}
- });
- // Show the "Loading commit data" for only the first element
- $('span.log_loading:first').removeClass('hide');
- }
+ }
+ });
+ // Show the "Loading commit data" for only the first element
+ $('span.log_loading:first').removeClass('hide');
+ }
- TreeView.prototype.initKeyNav = function() {
- var li, liSelected;
- li = $("tr.tree-item");
- liSelected = null;
- return $('body').keydown(function(e) {
- var next, path;
- if ($("input:focus").length > 0 && (e.which === 38 || e.which === 40)) {
- return false;
- }
- if (e.which === 40) {
- if (liSelected) {
- next = liSelected.next();
- if (next.length > 0) {
- liSelected.removeClass("selected");
- liSelected = next.addClass("selected");
- }
- } else {
- liSelected = li.eq(0).addClass("selected");
- }
- return $(liSelected).focus();
- } else if (e.which === 38) {
- if (liSelected) {
- next = liSelected.prev();
- if (next.length > 0) {
- liSelected.removeClass("selected");
- liSelected = next.addClass("selected");
- }
- } else {
- liSelected = li.last().addClass("selected");
+ initKeyNav() {
+ var li, liSelected;
+ li = $("tr.tree-item");
+ liSelected = null;
+ return $('body').keydown(function(e) {
+ var next, path;
+ if ($("input:focus").length > 0 && (e.which === 38 || e.which === 40)) {
+ return false;
+ }
+ if (e.which === 40) {
+ if (liSelected) {
+ next = liSelected.next();
+ if (next.length > 0) {
+ liSelected.removeClass("selected");
+ liSelected = next.addClass("selected");
}
- return $(liSelected).focus();
- } else if (e.which === 13) {
- path = $('.tree-item.selected .tree-item-file-name a').attr('href');
- if (path) {
- return gl.utils.visitUrl(path);
+ } else {
+ liSelected = li.eq(0).addClass("selected");
+ }
+ return $(liSelected).focus();
+ } else if (e.which === 38) {
+ if (liSelected) {
+ next = liSelected.prev();
+ if (next.length > 0) {
+ liSelected.removeClass("selected");
+ liSelected = next.addClass("selected");
}
+ } else {
+ liSelected = li.last().addClass("selected");
}
- });
- };
-
- return TreeView;
- })();
-}).call(window);
+ return $(liSelected).focus();
+ } else if (e.which === 13) {
+ path = $('.tree-item.selected .tree-item-file-name a').attr('href');
+ if (path) {
+ return gl.utils.visitUrl(path);
+ }
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/two_factor_auth.js b/app/assets/javascripts/two_factor_auth.js
new file mode 100644
index 00000000000..d26f61562a5
--- /dev/null
+++ b/app/assets/javascripts/two_factor_auth.js
@@ -0,0 +1,13 @@
+/* global U2FRegister */
+document.addEventListener('DOMContentLoaded', () => {
+ const twoFactorNode = document.querySelector('.js-two-factor-auth');
+ const skippable = twoFactorNode.dataset.twoFactorSkippable === 'true';
+ if (skippable) {
+ const button = `<a class="btn btn-xs btn-warning pull-right" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
+ const flashAlert = document.querySelector('.flash-alert .container-fluid');
+ if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
+ }
+
+ const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
+ u2fRegister.start();
+});
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js
index 500b78fc5d8..8821b22477f 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -3,6 +3,8 @@
/* global U2FError */
/* global U2FUtil */
+import _ from 'underscore';
+
// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> authenticated -> POST to server
@@ -10,18 +12,16 @@
(function() {
const global = window.gl || (window.gl = {});
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
global.U2FAuthenticate = (function() {
function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderAuthenticated = bind(this.renderAuthenticated, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.authenticate = bind(this.authenticate, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderAuthenticated = this.renderAuthenticated.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.authenticate = this.authenticate.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.challenge = u2fParams.challenge;
this.form = form;
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index fd1829efe18..3119b3480c3 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -2,12 +2,10 @@
/* global u2f */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FError = (function() {
function U2FError(errorCode, u2fFlowType) {
this.errorCode = errorCode;
- this.message = bind(this.message, this);
+ this.message = this.message.bind(this);
this.httpsDisabled = window.location.protocol !== 'https:';
this.u2fFlowType = u2fFlowType;
}
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js
index 17631f2908d..3a2534d553b 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -3,24 +3,24 @@
/* global U2FError */
/* global U2FUtil */
+import _ from 'underscore';
+
// Register U2F (universal 2nd factor) devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> registered -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FRegister = (function() {
function U2FRegister(container, u2fParams) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderRegistered = bind(this.renderRegistered, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderSetup = bind(this.renderSetup, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.register = bind(this.register, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderRegistered = this.renderRegistered.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderSetup = this.renderSetup.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.register = this.register.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.registerRequests = u2fParams.register_requests;
this.signRequests = u2fParams.sign_requests;
diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js
new file mode 100644
index 00000000000..f503076715c
--- /dev/null
+++ b/app/assets/javascripts/ui_development_kit.js
@@ -0,0 +1,22 @@
+import Api from './api';
+
+document.addEventListener('DOMContentLoaded', () => {
+ $('#js-project-dropdown').glDropdown({
+ data: (term, callback) => {
+ Api.projects(term, {
+ order_by: 'last_activity_at',
+ }, (data) => {
+ callback(data);
+ });
+ },
+ text: project => (project.name_with_namespace || project.name),
+ selectable: true,
+ fieldName: 'author_id',
+ filterable: true,
+ search: {
+ fields: ['name_with_namespace'],
+ },
+ id: data => data.id,
+ isSelected: data => (data.id === 2),
+ });
+});
diff --git a/app/assets/javascripts/usage_ping.js b/app/assets/javascripts/usage_ping.js
index fd3af7d7ab6..2389056bd02 100644
--- a/app/assets/javascripts/usage_ping.js
+++ b/app/assets/javascripts/usage_ping.js
@@ -1,4 +1,4 @@
-function UsagePing() {
+export default function UsagePing() {
const usageDataUrl = $('.usage-data').data('endpoint');
$.ajax({
@@ -10,6 +10,3 @@ function UsagePing() {
},
});
}
-
-window.gl = window.gl || {};
-window.gl.UsagePing = UsagePing;
diff --git a/app/assets/javascripts/user.js b/app/assets/javascripts/user.js
deleted file mode 100644
index 19c9efe7fbd..00000000000
--- a/app/assets/javascripts/user.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/* eslint-disable class-methods-use-this, comma-dangle, arrow-parens, no-param-reassign */
-
-import Cookies from 'js-cookie';
-
-((global) => {
- global.User = class {
- constructor({ action }) {
- this.action = action;
- this.placeProfileAvatarsToTop();
- this.initTabs();
- this.hideProjectLimitMessage();
- }
-
- placeProfileAvatarsToTop() {
- $('.profile-groups-avatars').tooltip({
- placement: 'top'
- });
- }
-
- initTabs() {
- return new global.UserTabs({
- parentEl: '.user-profile',
- action: this.action
- });
- }
-
- hideProjectLimitMessage() {
- $('.hide-project-limit-message').on('click', e => {
- e.preventDefault();
- Cookies.set('hide_project_limit_message', 'false');
- $(this).parents('.project-limit-message').remove();
- });
- }
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index b9d57cbcad4..ff2208baeab 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,11 +1,10 @@
import Cookies from 'js-cookie';
-const USER_CALLOUT_COOKIE = 'user_callout_dismissed';
-
export default class UserCallout {
- constructor() {
- this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE);
- this.userCalloutBody = $('.user-callout');
+ constructor(className = 'user-callout') {
+ this.userCalloutBody = $(`.${className}`);
+ this.cookieName = this.userCalloutBody.data('uid');
+ this.isCalloutDismissed = Cookies.get(this.cookieName);
this.init();
}
@@ -18,7 +17,7 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 });
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/user_tabs.js b/app/assets/javascripts/user_tabs.js
deleted file mode 100644
index ce7eb76dc71..00000000000
--- a/app/assets/javascripts/user_tabs.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/* eslint-disable max-len, space-before-function-paren, no-underscore-dangle, consistent-return, comma-dangle, no-unused-vars, dot-notation, no-new, no-return-assign, camelcase, no-param-reassign, class-methods-use-this */
-
-/*
-UserTabs
-
-Handles persisting and restoring the current tab selection and lazily-loading
-content on the Users#show page.
-
-### Example Markup
-
- <ul class="nav-links">
- <li class="activity-tab active">
- <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username">
- Activity
- </a>
- </li>
- <li class="groups-tab">
- <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups">
- Groups
- </a>
- </li>
- <li class="contributed-tab">
- <a data-action="contributed" data-target="#contributed" data-toggle="tab" href="/u/username/contributed">
- Contributed projects
- </a>
- </li>
- <li class="projects-tab">
- <a data-action="projects" data-target="#projects" data-toggle="tab" href="/u/username/projects">
- Personal projects
- </a>
- </li>
- <li class="snippets-tab">
- <a data-action="snippets" data-target="#snippets" data-toggle="tab" href="/u/username/snippets">
- </a>
- </li>
- </ul>
-
- <div class="tab-content">
- <div class="tab-pane" id="activity">
- Activity Content
- </div>
- <div class="tab-pane" id="groups">
- Groups Content
- </div>
- <div class="tab-pane" id="contributed">
- Contributed projects content
- </div>
- <div class="tab-pane" id="projects">
- Projects content
- </div>
- <div class="tab-pane" id="snippets">
- Snippets content
- </div>
- </div>
-
- <div class="loading-status">
- <div class="loading">
- Loading Animation
- </div>
- </div>
-*/
-((global) => {
- class UserTabs {
- constructor ({ defaultAction, action, parentEl }) {
- this.loaded = {};
- this.defaultAction = defaultAction || 'activity';
- this.action = action || this.defaultAction;
- this.$parentEl = $(parentEl) || $(document);
- this._location = window.location;
- this.$parentEl.find('.nav-links a')
- .each((i, navLink) => {
- this.loaded[$(navLink).attr('data-action')] = false;
- });
- this.actions = Object.keys(this.loaded);
- this.bindEvents();
-
- if (this.action === 'show') {
- this.action = this.defaultAction;
- }
-
- this.activateTab(this.action);
- }
-
- bindEvents() {
- this.changeProjectsPageWrapper = this.changeProjectsPage.bind(this);
-
- this.$parentEl.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
- .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event));
-
- this.$parentEl.on('click', '.gl-pagination a', this.changeProjectsPageWrapper);
- }
-
- changeProjectsPage(e) {
- e.preventDefault();
-
- $('.tab-pane.active').empty();
- const endpoint = $(e.target).attr('href');
- this.loadTab(this.getCurrentAction(), endpoint);
- }
-
- tabShown(event) {
- const $target = $(event.target);
- const action = $target.data('action');
- const source = $target.attr('href');
- const endpoint = $target.data('endpoint');
- this.setTab(action, endpoint);
- return this.setCurrentAction(source);
- }
-
- activateTab(action) {
- return this.$parentEl.find(`.nav-links .js-${action}-tab a`)
- .tab('show');
- }
-
- setTab(action, endpoint) {
- if (this.loaded[action]) {
- return;
- }
- if (action === 'activity') {
- this.loadActivities();
- }
-
- const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
- if (loadableActions.indexOf(action) > -1) {
- return this.loadTab(action, endpoint);
- }
- }
-
- loadTab(action, endpoint) {
- return $.ajax({
- beforeSend: () => this.toggleLoading(true),
- complete: () => this.toggleLoading(false),
- dataType: 'json',
- type: 'GET',
- url: endpoint,
- success: (data) => {
- const tabSelector = `div#${action}`;
- this.$parentEl.find(tabSelector).html(data.html);
- this.loaded[action] = true;
- return gl.utils.localTimeAgo($('.js-timeago', tabSelector));
- }
- });
- }
-
- loadActivities() {
- if (this.loaded['activity']) {
- return;
- }
- const $calendarWrap = this.$parentEl.find('.user-calendar');
- $calendarWrap.load($calendarWrap.data('href'));
- new gl.Activities();
- return this.loaded['activity'] = true;
- }
-
- toggleLoading(status) {
- return this.$parentEl.find('.loading-status .loading')
- .toggle(status);
- }
-
- setCurrentAction(source) {
- let new_state = source;
- new_state = new_state.replace(/\/+$/, '');
- new_state += this._location.search + this._location.hash;
- history.replaceState({
- url: new_state
- }, document.title, new_state);
- return new_state;
- }
-
- getCurrentAction() {
- return this.$parentEl.find('.nav-links .active a').data('action');
- }
- }
- global.UserTabs = UserTabs;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/username_validator.js b/app/assets/javascripts/username_validator.js
index 137cefa3b8e..bb34d5d2008 100644
--- a/app/assets/javascripts/username_validator.js
+++ b/app/assets/javascripts/username_validator.js
@@ -1,135 +1,133 @@
/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
-((global) => {
- const debounceTimeoutDuration = 1000;
- const invalidInputClass = 'gl-field-error-outline';
- const successInputClass = 'gl-field-success-outline';
- const unavailableMessageSelector = '.username .validation-error';
- const successMessageSelector = '.username .validation-success';
- const pendingMessageSelector = '.username .validation-pending';
- const invalidMessageSelector = '.username .gl-field-error';
-
- class UsernameValidator {
- constructor() {
- this.inputElement = $('#new_user_username');
- this.inputDomElement = this.inputElement.get(0);
- this.state = {
- available: false,
- valid: false,
- pending: false,
- empty: true
- };
-
- const debounceTimeout = _.debounce((username) => {
- this.validateUsername(username);
- }, debounceTimeoutDuration);
-
- this.inputElement.on('keyup.username_check', () => {
- const username = this.inputElement.val();
-
- this.state.valid = this.inputDomElement.validity.valid;
- this.state.empty = !username.length;
-
- if (this.state.valid) {
- return debounceTimeout(username);
- }
-
- this.renderState();
- });
-
- // Override generic field validation
- this.inputElement.on('invalid', this.interceptInvalid.bind(this));
- }
-
- renderState() {
- // Clear all state
- this.clearFieldValidationState();
+import _ from 'underscore';
+
+const debounceTimeoutDuration = 1000;
+const invalidInputClass = 'gl-field-error-outline';
+const successInputClass = 'gl-field-success-outline';
+const unavailableMessageSelector = '.username .validation-error';
+const successMessageSelector = '.username .validation-success';
+const pendingMessageSelector = '.username .validation-pending';
+const invalidMessageSelector = '.username .gl-field-error';
+
+export default class UsernameValidator {
+ constructor() {
+ this.inputElement = $('#new_user_username');
+ this.inputDomElement = this.inputElement.get(0);
+ this.state = {
+ available: false,
+ valid: false,
+ pending: false,
+ empty: true
+ };
+
+ const debounceTimeout = _.debounce((username) => {
+ this.validateUsername(username);
+ }, debounceTimeoutDuration);
+
+ this.inputElement.on('keyup.username_check', () => {
+ const username = this.inputElement.val();
+
+ this.state.valid = this.inputDomElement.validity.valid;
+ this.state.empty = !username.length;
- if (this.state.valid && this.state.available) {
- return this.setSuccessState();
+ if (this.state.valid) {
+ return debounceTimeout(username);
}
- if (this.state.empty) {
- return this.clearFieldValidationState();
- }
+ this.renderState();
+ });
- if (this.state.pending) {
- return this.setPendingState();
- }
+ // Override generic field validation
+ this.inputElement.on('invalid', this.interceptInvalid.bind(this));
+ }
- if (!this.state.available) {
- return this.setUnavailableState();
- }
+ renderState() {
+ // Clear all state
+ this.clearFieldValidationState();
- if (!this.state.valid) {
- return this.setInvalidState();
- }
+ if (this.state.valid && this.state.available) {
+ return this.setSuccessState();
}
- interceptInvalid(event) {
- event.preventDefault();
- event.stopPropagation();
+ if (this.state.empty) {
+ return this.clearFieldValidationState();
}
- validateUsername(username) {
- if (this.state.valid) {
- this.state.pending = true;
- this.state.available = false;
- this.renderState();
- return $.ajax({
- type: 'GET',
- url: `${gon.relative_url_root}/users/${username}/exists`,
- dataType: 'json',
- success: (res) => this.setAvailabilityState(res.exists)
- });
- }
+ if (this.state.pending) {
+ return this.setPendingState();
}
- setAvailabilityState(usernameTaken) {
- if (usernameTaken) {
- this.state.valid = false;
- this.state.available = false;
- } else {
- this.state.available = true;
- }
- this.state.pending = false;
- this.renderState();
+ if (!this.state.available) {
+ return this.setUnavailableState();
}
- clearFieldValidationState() {
- this.inputElement.siblings('p').hide();
-
- this.inputElement.removeClass(invalidInputClass)
- .removeClass(successInputClass);
+ if (!this.state.valid) {
+ return this.setInvalidState();
}
+ }
- setUnavailableState() {
- const $usernameUnavailableMessage = this.inputElement.siblings(unavailableMessageSelector);
- this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
- $usernameUnavailableMessage.show();
- }
+ interceptInvalid(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
- setSuccessState() {
- const $usernameSuccessMessage = this.inputElement.siblings(successMessageSelector);
- this.inputElement.addClass(successInputClass).removeClass(invalidInputClass);
- $usernameSuccessMessage.show();
+ validateUsername(username) {
+ if (this.state.valid) {
+ this.state.pending = true;
+ this.state.available = false;
+ this.renderState();
+ return $.ajax({
+ type: 'GET',
+ url: `${gon.relative_url_root}/users/${username}/exists`,
+ dataType: 'json',
+ success: (res) => this.setAvailabilityState(res.exists)
+ });
}
+ }
- setPendingState() {
- const $usernamePendingMessage = $(pendingMessageSelector);
- if (this.state.pending) {
- $usernamePendingMessage.show();
- } else {
- $usernamePendingMessage.hide();
- }
+ setAvailabilityState(usernameTaken) {
+ if (usernameTaken) {
+ this.state.valid = false;
+ this.state.available = false;
+ } else {
+ this.state.available = true;
}
+ this.state.pending = false;
+ this.renderState();
+ }
- setInvalidState() {
- const $inputErrorMessage = $(invalidMessageSelector);
- this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
- $inputErrorMessage.show();
+ clearFieldValidationState() {
+ this.inputElement.siblings('p').hide();
+
+ this.inputElement.removeClass(invalidInputClass)
+ .removeClass(successInputClass);
+ }
+
+ setUnavailableState() {
+ const $usernameUnavailableMessage = this.inputElement.siblings(unavailableMessageSelector);
+ this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
+ $usernameUnavailableMessage.show();
+ }
+
+ setSuccessState() {
+ const $usernameSuccessMessage = this.inputElement.siblings(successMessageSelector);
+ this.inputElement.addClass(successInputClass).removeClass(invalidInputClass);
+ $usernameSuccessMessage.show();
+ }
+
+ setPendingState() {
+ const $usernamePendingMessage = $(pendingMessageSelector);
+ if (this.state.pending) {
+ $usernamePendingMessage.show();
+ } else {
+ $usernamePendingMessage.hide();
}
}
- global.UsernameValidator = UsernameValidator;
-})(window);
+ setInvalidState() {
+ const $inputErrorMessage = $(invalidMessageSelector);
+ this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
+ $inputErrorMessage.show();
+ }
+}
diff --git a/app/assets/javascripts/users/activity_calendar.js b/app/assets/javascripts/users/activity_calendar.js
new file mode 100644
index 00000000000..5e947769f8a
--- /dev/null
+++ b/app/assets/javascripts/users/activity_calendar.js
@@ -0,0 +1,233 @@
+import _ from 'underscore';
+import d3 from 'd3';
+
+const LOADING_HTML = `
+ <div class="text-center">
+ <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
+ </div>
+`;
+
+function getSystemDate(systemUtcOffsetSeconds) {
+ const date = new Date();
+ const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
+ const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60;
+ date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes);
+ return date;
+}
+
+function formatTooltipText({ date, count }) {
+ const dateObject = new Date(date);
+ const dateDayName = gl.utils.getDayName(dateObject);
+ const dateText = dateObject.format('mmm d, yyyy');
+
+ let contribText = 'No contributions';
+ if (count > 0) {
+ contribText = `${count} contribution${count > 1 ? 's' : ''}`;
+ }
+ return `${contribText}<br />${dateDayName} ${dateText}`;
+}
+
+const initColorKey = () => d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]);
+
+export default class ActivityCalendar {
+ constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) {
+ this.calendarActivitiesPath = calendarActivitiesPath;
+ this.clickDay = this.clickDay.bind(this);
+ this.currentSelectedDate = '';
+ this.daySpace = 1;
+ this.daySize = 15;
+ this.daySizeWithSpace = this.daySize + (this.daySpace * 2);
+ this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ this.months = [];
+
+ // Loop through the timestamps to create a group of objects
+ // The group of objects will be grouped based on the day of the week they are
+ this.timestampsTmp = [];
+ let group = 0;
+
+ const today = getSystemDate(utcOffset);
+ today.setHours(0, 0, 0, 0, 0);
+
+ const oneYearAgo = new Date(today);
+ oneYearAgo.setFullYear(today.getFullYear() - 1);
+
+ const days = gl.utils.getDayDifference(oneYearAgo, today);
+
+ for (let i = 0; i <= days; i += 1) {
+ const date = new Date(oneYearAgo);
+ date.setDate(date.getDate() + i);
+
+ const day = date.getDay();
+ const count = timestamps[date.format('yyyy-mm-dd')] || 0;
+
+ // Create a new group array if this is the first day of the week
+ // or if is first object
+ if ((day === 0 && i !== 0) || i === 0) {
+ this.timestampsTmp.push([]);
+ group += 1;
+ }
+
+ // Push to the inner array the values that will be used to render map
+ const innerArray = this.timestampsTmp[group - 1];
+ innerArray.push({ count, date, day });
+ }
+
+ // Init color functions
+ this.colorKey = initColorKey();
+ this.color = this.initColor();
+
+ // Init the svg element
+ this.svg = this.renderSvg(container, group);
+ this.renderDays();
+ this.renderMonths();
+ this.renderDayTitles();
+ this.renderKey();
+
+ // Init tooltips
+ $(`${container} .js-tooltip`).tooltip({ html: true });
+ }
+
+ // Add extra padding for the last month label if it is also the last column
+ getExtraWidthPadding(group) {
+ let extraWidthPadding = 0;
+ const lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth();
+ const secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
+
+ if (lastColMonth !== secondLastColMonth) {
+ extraWidthPadding = 3;
+ }
+
+ return extraWidthPadding;
+ }
+
+ renderSvg(container, group) {
+ const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group);
+ return d3.select(container)
+ .append('svg')
+ .attr('width', width)
+ .attr('height', 167)
+ .attr('class', 'contrib-calendar');
+ }
+
+ renderDays() {
+ this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g')
+ .attr('transform', (group, i) => {
+ _.each(group, (stamp, a) => {
+ if (a === 0 && stamp.day === 0) {
+ const month = stamp.date.getMonth();
+ const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace;
+ const lastMonth = _.last(this.months);
+ if (
+ lastMonth == null ||
+ (month !== lastMonth.month && x - this.daySizeWithSpace !== lastMonth.x)
+ ) {
+ this.months.push({ month, x });
+ }
+ }
+ });
+ return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`;
+ })
+ .selectAll('rect')
+ .data(stamp => stamp)
+ .enter()
+ .append('rect')
+ .attr('x', '0')
+ .attr('y', stamp => this.daySizeWithSpace * stamp.day)
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('fill', stamp => (
+ stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'
+ ))
+ .attr('title', stamp => formatTooltipText(stamp))
+ .attr('class', 'user-contrib-cell js-tooltip')
+ .attr('data-container', 'body')
+ .on('click', this.clickDay);
+ }
+
+ renderDayTitles() {
+ const days = [
+ {
+ text: 'M',
+ y: 29 + (this.daySizeWithSpace * 1),
+ }, {
+ text: 'W',
+ y: 29 + (this.daySizeWithSpace * 3),
+ }, {
+ text: 'F',
+ y: 29 + (this.daySizeWithSpace * 5),
+ },
+ ];
+ this.svg.append('g')
+ .selectAll('text')
+ .data(days)
+ .enter()
+ .append('text')
+ .attr('text-anchor', 'middle')
+ .attr('x', 8)
+ .attr('y', day => day.y)
+ .text(day => day.text)
+ .attr('class', 'user-contrib-text');
+ }
+
+ renderMonths() {
+ this.svg.append('g')
+ .attr('direction', 'ltr')
+ .selectAll('text')
+ .data(this.months)
+ .enter()
+ .append('text')
+ .attr('x', date => date.x)
+ .attr('y', 10)
+ .attr('class', 'user-contrib-text')
+ .text(date => this.monthNames[date.month]);
+ }
+
+ renderKey() {
+ const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
+ const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+
+ this.svg.append('g')
+ .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`)
+ .selectAll('rect')
+ .data(keyColors)
+ .enter()
+ .append('rect')
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('x', (color, i) => this.daySizeWithSpace * i)
+ .attr('y', 0)
+ .attr('fill', color => color)
+ .attr('class', 'js-tooltip')
+ .attr('title', (color, i) => keyValues[i])
+ .attr('data-container', 'body');
+ }
+
+ initColor() {
+ const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+ return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange);
+ }
+
+ clickDay(stamp) {
+ if (this.currentSelectedDate !== stamp.date) {
+ this.currentSelectedDate = stamp.date;
+
+ const date = [
+ this.currentSelectedDate.getFullYear(),
+ this.currentSelectedDate.getMonth() + 1,
+ this.currentSelectedDate.getDate(),
+ ].join('-');
+
+ $.ajax({
+ url: this.calendarActivitiesPath,
+ data: { date },
+ cache: false,
+ dataType: 'html',
+ beforeSend: () => $('.user-calendar-activities').html(LOADING_HTML),
+ success: data => $('.user-calendar-activities').html(data),
+ });
+ } else {
+ this.currentSelectedDate = '';
+ $('.user-calendar-activities').html('');
+ }
+ }
+}
diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js
deleted file mode 100644
index 754d448564f..00000000000
--- a/app/assets/javascripts/users/calendar.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, camelcase, vars-on-top, object-shorthand, comma-dangle, eqeqeq, no-mixed-operators, no-return-assign, newline-per-chained-call, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, no-else-return, max-len */
-
-import d3 from 'd3';
-
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
- this.Calendar = (function() {
- function Calendar(timestamps, calendar_activities_path) {
- this.calendar_activities_path = calendar_activities_path;
- this.clickDay = bind(this.clickDay, this);
- this.currentSelectedDate = '';
- this.daySpace = 1;
- this.daySize = 15;
- this.daySizeWithSpace = this.daySize + (this.daySpace * 2);
- this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
- this.months = [];
- // Loop through the timestamps to create a group of objects
- // The group of objects will be grouped based on the day of the week they are
- this.timestampsTmp = [];
- var group = 0;
-
- var today = new Date();
- today.setHours(0, 0, 0, 0, 0);
-
- var oneYearAgo = new Date(today);
- oneYearAgo.setFullYear(today.getFullYear() - 1);
-
- var days = gl.utils.getDayDifference(oneYearAgo, today);
-
- for (var i = 0; i <= days; i += 1) {
- var date = new Date(oneYearAgo);
- date.setDate(date.getDate() + i);
-
- var day = date.getDay();
- var count = timestamps[date.format('yyyy-mm-dd')];
-
- // Create a new group array if this is the first day of the week
- // or if is first object
- if ((day === 0 && i !== 0) || i === 0) {
- this.timestampsTmp.push([]);
- group += 1;
- }
-
- var innerArray = this.timestampsTmp[group - 1];
- // Push to the inner array the values that will be used to render map
- innerArray.push({
- count: count || 0,
- date: date,
- day: day
- });
- }
-
- // Init color functions
- this.colorKey = this.initColorKey();
- this.color = this.initColor();
- // Init the svg element
- this.renderSvg(group);
- this.renderDays();
- this.renderMonths();
- this.renderDayTitles();
- this.renderKey();
- this.initTooltips();
- }
-
- // Add extra padding for the last month label if it is also the last column
- Calendar.prototype.getExtraWidthPadding = function(group) {
- var extraWidthPadding = 0;
- var lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth();
- var secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
-
- if (lastColMonth != secondLastColMonth) {
- extraWidthPadding = 3;
- }
-
- return extraWidthPadding;
- };
-
- Calendar.prototype.renderSvg = function(group) {
- var width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
- return this.svg = d3.select('.js-contrib-calendar').append('svg').attr('width', width).attr('height', 167).attr('class', 'contrib-calendar');
- };
-
- Calendar.prototype.renderDays = function() {
- return this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g').attr('transform', (function(_this) {
- return function(group, i) {
- _.each(group, function(stamp, a) {
- var lastMonth, lastMonthX, month, x;
- if (a === 0 && stamp.day === 0) {
- month = stamp.date.getMonth();
- x = (_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace;
- lastMonth = _.last(_this.months);
- if (lastMonth != null) {
- lastMonthX = lastMonth.x;
- }
- if (lastMonth == null) {
- return _this.months.push({
- month: month,
- x: x
- });
- } else if (month !== lastMonth.month && x - _this.daySizeWithSpace !== lastMonthX) {
- return _this.months.push({
- month: month,
- x: x
- });
- }
- }
- });
- return "translate(" + ((_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace) + ", 18)";
- };
- })(this)).selectAll('rect').data(function(stamp) {
- return stamp;
- }).enter().append('rect').attr('x', '0').attr('y', (function(_this) {
- return function(stamp, i) {
- return _this.daySizeWithSpace * stamp.day;
- };
- })(this)).attr('width', this.daySize).attr('height', this.daySize).attr('title', (function(_this) {
- return function(stamp) {
- var contribText, date, dateText;
- date = new Date(stamp.date);
- contribText = 'No contributions';
- if (stamp.count > 0) {
- contribText = stamp.count + " contribution" + (stamp.count > 1 ? 's' : '');
- }
- dateText = date.format('mmm d, yyyy');
- return contribText + "<br />" + (gl.utils.getDayName(date)) + " " + dateText;
- };
- })(this)).attr('class', 'user-contrib-cell js-tooltip').attr('fill', (function(_this) {
- return function(stamp) {
- if (stamp.count !== 0) {
- return _this.color(Math.min(stamp.count, 40));
- } else {
- return '#ededed';
- }
- };
- })(this)).attr('data-container', 'body').on('click', this.clickDay);
- };
-
- Calendar.prototype.renderDayTitles = function() {
- var days;
- days = [
- {
- text: 'M',
- y: 29 + (this.daySizeWithSpace * 1)
- }, {
- text: 'W',
- y: 29 + (this.daySizeWithSpace * 3)
- }, {
- text: 'F',
- y: 29 + (this.daySizeWithSpace * 5)
- }
- ];
- return this.svg.append('g').selectAll('text').data(days).enter().append('text').attr('text-anchor', 'middle').attr('x', 8).attr('y', function(day) {
- return day.y;
- }).text(function(day) {
- return day.text;
- }).attr('class', 'user-contrib-text');
- };
-
- Calendar.prototype.renderMonths = function() {
- return this.svg.append('g').attr('direction', 'ltr').selectAll('text').data(this.months).enter().append('text').attr('x', function(date) {
- return date.x;
- }).attr('y', 10).attr('class', 'user-contrib-text').text((function(_this) {
- return function(date) {
- return _this.monthNames[date.month];
- };
- })(this));
- };
-
- Calendar.prototype.renderKey = function() {
- var keyColors;
- keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) {
- return function(color, i) {
- return _this.daySizeWithSpace * i;
- };
- })(this)).attr('y', 0).attr('fill', function(color) {
- return color;
- });
- };
-
- Calendar.prototype.initColor = function() {
- var colorRange;
- colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange);
- };
-
- Calendar.prototype.initColorKey = function() {
- return d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]);
- };
-
- Calendar.prototype.clickDay = function(stamp) {
- var formatted_date;
- if (this.currentSelectedDate !== stamp.date) {
- this.currentSelectedDate = stamp.date;
- formatted_date = this.currentSelectedDate.getFullYear() + "-" + (this.currentSelectedDate.getMonth() + 1) + "-" + this.currentSelectedDate.getDate();
- return $.ajax({
- url: this.calendar_activities_path,
- data: {
- date: formatted_date
- },
- cache: false,
- dataType: 'html',
- beforeSend: function() {
- return $('.user-calendar-activities').html('<div class="text-center"><i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i></div>');
- },
- success: function(data) {
- return $('.user-calendar-activities').html(data);
- }
- });
- } else {
- this.currentSelectedDate = '';
- return $('.user-calendar-activities').html('');
- }
- };
-
- Calendar.prototype.initTooltips = function() {
- return $('.js-contrib-calendar .js-tooltip').tooltip({
- html: true
- });
- };
-
- return Calendar;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/users/index.js b/app/assets/javascripts/users/index.js
new file mode 100644
index 00000000000..33a83f8dae5
--- /dev/null
+++ b/app/assets/javascripts/users/index.js
@@ -0,0 +1,19 @@
+import Cookies from 'js-cookie';
+import UserTabs from './user_tabs';
+
+export default function initUserProfile(action) {
+ // place profile avatars to top
+ $('.profile-groups-avatars').tooltip({
+ placement: 'top',
+ });
+
+ // eslint-disable-next-line no-new
+ new UserTabs({ parentEl: '.user-profile', action });
+
+ // hide project limit message
+ $('.hide-project-limit-message').on('click', (e) => {
+ e.preventDefault();
+ Cookies.set('hide_project_limit_message', 'false');
+ $(this).parents('.project-limit-message').remove();
+ });
+}
diff --git a/app/assets/javascripts/users/user_tabs.js b/app/assets/javascripts/users/user_tabs.js
new file mode 100644
index 00000000000..1215b265e28
--- /dev/null
+++ b/app/assets/javascripts/users/user_tabs.js
@@ -0,0 +1,194 @@
+import ActivityCalendar from './activity_calendar';
+
+/**
+ * UserTabs
+ *
+ * Handles persisting and restoring the current tab selection and lazily-loading
+ * content on the Users#show page.
+ *
+ * ### Example Markup
+ *
+ * <ul class="nav-links">
+ * <li class="activity-tab active">
+ * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username">
+ * Activity
+ * </a>
+ * </li>
+ * <li class="groups-tab">
+ * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups">
+ * Groups
+ * </a>
+ * </li>
+ * <li class="contributed-tab">
+ * ...
+ * </li>
+ * <li class="projects-tab">
+ * ...
+ * </li>
+ * <li class="snippets-tab">
+ * ...
+ * </li>
+ * </ul>
+ *
+ * <div class="tab-content">
+ * <div class="tab-pane" id="activity">
+ * Activity Content
+ * </div>
+ * <div class="tab-pane" id="groups">
+ * Groups Content
+ * </div>
+ * <div class="tab-pane" id="contributed">
+ * Contributed projects content
+ * </div>
+ * <div class="tab-pane" id="projects">
+ * Projects content
+ * </div>
+ * <div class="tab-pane" id="snippets">
+ * Snippets content
+ * </div>
+ * </div>
+ *
+ * <div class="loading-status">
+ * <div class="loading">
+ * Loading Animation
+ * </div>
+ * </div>
+ */
+
+const CALENDAR_TEMPLATE = `
+ <div class="clearfix calendar">
+ <div class="js-contrib-calendar"></div>
+ <div class="calendar-hint">
+ Summary of issues, merge requests, push events, and comments
+ </div>
+ </div>
+`;
+
+export default class UserTabs {
+ constructor({ defaultAction, action, parentEl }) {
+ this.loaded = {};
+ this.defaultAction = defaultAction || 'activity';
+ this.action = action || this.defaultAction;
+ this.$parentEl = $(parentEl) || $(document);
+ this.windowLocation = window.location;
+ this.$parentEl.find('.nav-links a')
+ .each((i, navLink) => {
+ this.loaded[$(navLink).attr('data-action')] = false;
+ });
+ this.actions = Object.keys(this.loaded);
+ this.bindEvents();
+
+ if (this.action === 'show') {
+ this.action = this.defaultAction;
+ }
+
+ this.activateTab(this.action);
+ }
+
+ bindEvents() {
+ this.$parentEl
+ .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
+ .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event))
+ .on('click', '.gl-pagination a', event => this.changeProjectsPage(event));
+ }
+
+ changeProjectsPage(e) {
+ e.preventDefault();
+
+ $('.tab-pane.active').empty();
+ const endpoint = $(e.target).attr('href');
+ this.loadTab(this.getCurrentAction(), endpoint);
+ }
+
+ tabShown(event) {
+ const $target = $(event.target);
+ const action = $target.data('action');
+ const source = $target.attr('href');
+ const endpoint = $target.data('endpoint');
+ this.setTab(action, endpoint);
+ return this.setCurrentAction(source);
+ }
+
+ activateTab(action) {
+ return this.$parentEl.find(`.nav-links .js-${action}-tab a`)
+ .tab('show');
+ }
+
+ setTab(action, endpoint) {
+ if (this.loaded[action]) {
+ return;
+ }
+ if (action === 'activity') {
+ this.loadActivities();
+ }
+
+ const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
+ if (loadableActions.indexOf(action) > -1) {
+ this.loadTab(action, endpoint);
+ }
+ }
+
+ loadTab(action, endpoint) {
+ return $.ajax({
+ beforeSend: () => this.toggleLoading(true),
+ complete: () => this.toggleLoading(false),
+ dataType: 'json',
+ url: endpoint,
+ success: (data) => {
+ const tabSelector = `div#${action}`;
+ this.$parentEl.find(tabSelector).html(data.html);
+ this.loaded[action] = true;
+ gl.utils.localTimeAgo($('.js-timeago', tabSelector));
+ },
+ });
+ }
+
+ loadActivities() {
+ if (this.loaded.activity) {
+ return;
+ }
+ const $calendarWrap = this.$parentEl.find('.user-calendar');
+ const calendarPath = $calendarWrap.data('calendarPath');
+ const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath');
+ const utcOffset = $calendarWrap.data('utcOffset');
+ let utcFormatted = 'UTC';
+ if (utcOffset !== 0) {
+ utcFormatted = `UTC${utcOffset > 0 ? '+' : ''}${(utcOffset / 3600)}`;
+ }
+
+ $.ajax({
+ dataType: 'json',
+ url: calendarPath,
+ success: (activityData) => {
+ $calendarWrap.html(CALENDAR_TEMPLATE);
+ $calendarWrap.find('.calendar-hint').append(`(Timezone: ${utcFormatted})`);
+
+ // eslint-disable-next-line no-new
+ new ActivityCalendar('.js-contrib-calendar', activityData, calendarActivitiesPath, utcOffset);
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new gl.Activities();
+ this.loaded.activity = true;
+ }
+
+ toggleLoading(status) {
+ return this.$parentEl.find('.loading-status .loading')
+ .toggle(status);
+ }
+
+ setCurrentAction(source) {
+ let newState = source;
+ newState = newState.replace(/\/+$/, '');
+ newState += this.windowLocation.search + this.windowLocation.hash;
+ history.replaceState({
+ url: newState,
+ }, document.title, newState);
+ return newState;
+ }
+
+ getCurrentAction() {
+ return this.$parentEl.find('.nav-links .active a').data('action');
+ }
+}
diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js
deleted file mode 100644
index 580e2d84be5..00000000000
--- a/app/assets/javascripts/users/users_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-require('./calendar');
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 0344ce9ffb4..16ebf5916dc 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,452 +1,702 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */
/* global Issuable */
-/* global ListUser */
-
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- slice = [].slice;
-
- this.UsersSelect = (function() {
- function UsersSelect(currentUser, els) {
- var $els;
- this.users = bind(this.users, this);
- this.user = bind(this.user, this);
- this.usersPath = "/autocomplete/users.json";
- this.userPath = "/autocomplete/users/:id.json";
- if (currentUser != null) {
- if (typeof currentUser === 'object') {
- this.currentUser = currentUser;
- } else {
- this.currentUser = JSON.parse(currentUser);
- }
+/* global emitSidebarEvent */
+import _ from 'underscore';
+
+// TODO: remove eventHub hack after code splitting refactor
+window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
+
+function UsersSelect(currentUser, els) {
+ var $els;
+ this.users = this.users.bind(this);
+ this.user = this.user.bind(this);
+ this.usersPath = "/autocomplete/users.json";
+ this.userPath = "/autocomplete/users/:id.json";
+ if (currentUser != null) {
+ if (typeof currentUser === 'object') {
+ this.currentUser = currentUser;
+ } else {
+ this.currentUser = JSON.parse(currentUser);
+ }
+ }
+
+ $els = $(els);
+
+ if (!els) {
+ $els = $('.js-user-search');
+ }
+
+ $els.each((function(_this) {
+ return function(i, dropdown) {
+ var options = {};
+ var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove;
+ $dropdown = $(dropdown);
+ options.projectId = $dropdown.data('project-id');
+ options.groupId = $dropdown.data('group-id');
+ options.showCurrentUser = $dropdown.data('current-user');
+ options.todoFilter = $dropdown.data('todo-filter');
+ options.todoStateFilter = $dropdown.data('todo-state-filter');
+ options.perPage = $dropdown.data('per-page');
+ showNullUser = $dropdown.data('null-user');
+ defaultNullUser = $dropdown.data('null-user-default');
+ showMenuAbove = $dropdown.data('showMenuAbove');
+ showAnyUser = $dropdown.data('any-user');
+ firstUser = $dropdown.data('first-user');
+ options.authorId = $dropdown.data('author-id');
+ defaultLabel = $dropdown.data('default-label');
+ issueURL = $dropdown.data('issueUpdate');
+ $selectbox = $dropdown.closest('.selectbox');
+ $block = $selectbox.closest('.block');
+ abilityName = $dropdown.data('ability-name');
+ $value = $block.find('.value');
+ $collapsedSidebar = $block.find('.sidebar-collapsed-user');
+ $loading = $block.find('.block-loading').fadeOut();
+ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
+ selectedId = $dropdown.data('selected');
+
+ if (selectedId === undefined) {
+ selectedId = selectedIdDefault;
}
- $els = $(els);
+ const assignYourself = function () {
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
- if (!els) {
- $els = $('.js-user-search');
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+
+ // Save current selected user to the DOM
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = $dropdown.data('field-name');
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+
+ if (currentUserInfo) {
+ input.value = currentUserInfo.id;
+ input.dataset.meta = currentUserInfo.name;
+ } else if (_this.currentUser) {
+ input.value = _this.currentUser.id;
+ }
+
+ if ($selectbox) {
+ $dropdown.parent().before(input);
+ } else {
+ $dropdown.after(input);
+ }
+ };
+
+ if ($block[0]) {
+ $block[0].addEventListener('assignYourself', assignYourself);
}
- $els.each((function(_this) {
- return function(i, dropdown) {
- var options = {};
- var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove;
- $dropdown = $(dropdown);
- options.projectId = $dropdown.data('project-id');
- options.groupId = $dropdown.data('group-id');
- options.showCurrentUser = $dropdown.data('current-user');
- options.todoFilter = $dropdown.data('todo-filter');
- options.todoStateFilter = $dropdown.data('todo-state-filter');
- showNullUser = $dropdown.data('null-user');
- showMenuAbove = $dropdown.data('showMenuAbove');
- showAnyUser = $dropdown.data('any-user');
- firstUser = $dropdown.data('first-user');
- options.authorId = $dropdown.data('author-id');
- selectedId = $dropdown.data('selected');
- defaultLabel = $dropdown.data('default-label');
- issueURL = $dropdown.data('issueUpdate');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- abilityName = $dropdown.data('ability-name');
- $value = $block.find('.value');
- $collapsedSidebar = $block.find('.sidebar-collapsed-user');
- $loading = $block.find('.block-loading').fadeOut();
-
- var updateIssueBoardsIssue = function () {
- $loading.removeClass('hidden').fadeIn();
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $loading.fadeOut();
- })
- .catch(function () {
- $loading.fadeOut();
- });
- };
+ const getSelectedUserInputs = function() {
+ return $selectbox
+ .find(`input[name="${$dropdown.data('field-name')}"]`);
+ };
- $('.assign-to-me-link').on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).hide();
- const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
- $input.val(gon.current_user_id);
- selectedId = $input.val();
- $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
- });
+ const getSelected = function() {
+ return getSelectedUserInputs()
+ .map((index, input) => parseInt(input.value, 10))
+ .get();
+ };
- $block.on('click', '.js-assign-yourself', function(e) {
- e.preventDefault();
+ const checkMaxSelect = function() {
+ const maxSelect = $dropdown.data('max-select');
+ if (maxSelect) {
+ const selected = getSelected();
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: _this.currentUser.id,
- username: _this.currentUser.username,
- name: _this.currentUser.name,
- avatar_url: _this.currentUser.avatar_url
- }));
+ if (selected.length > maxSelect) {
+ const firstSelectedId = selected[0];
+ const firstSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
- updateIssueBoardsIssue();
- } else {
- return assignTo(_this.currentUser.id);
- }
- });
- assignTo = function(selected) {
- var data;
- data = {};
- data[abilityName] = {};
- data[abilityName].assignee_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- dataType: 'json',
- url: issueURL,
- data: data
- }).done(function(data) {
- var user;
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- $selectbox.hide();
- if (data.assignee) {
- user = {
- name: data.assignee.name,
- username: data.assignee.username,
- avatar: data.assignee.avatar_url
- };
- } else {
- user = {
- name: 'Unassigned',
- username: '',
- avatar: ''
- };
- }
- $value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
- return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ firstSelected.remove();
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
});
- };
- collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
- assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
- return $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- var isAuthorFilter;
- isAuthorFilter = $('.js-author-search');
- return _this.users(term, options, function(users) {
- var anyUser, index, j, len, name, obj, showDivider;
- if (term.length === 0) {
- showDivider = 0;
- if (firstUser) {
- // Move current user to the front of the list
- for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
- obj = users[index];
- if (obj.username === firstUser) {
- users.splice(index, 1);
- users.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: 'Unassigned',
- id: 0
- });
- }
- if (showAnyUser) {
- showDivider += 1;
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- beforeDivider: true,
- name: name,
- id: null
- };
- users.unshift(anyUser);
- }
- }
- if (showDivider) {
- users.splice(showDivider, 0, "divider");
- }
+ }
+ }
+ };
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
- }
+ const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
+ const selectedUsers = getSelected()
+ .filter(u => u !== 0);
+
+ const firstUser = getSelectedUserInputs()
+ .map((index, input) => ({
+ name: input.dataset.meta,
+ value: parseInt(input.value, 10),
+ }))
+ .filter(u => u.id !== 0)
+ .get(0);
+
+ if (selectedUsers.length === 0) {
+ return 'Unassigned';
+ } else if (selectedUsers.length === 1) {
+ return firstUser.name;
+ } else if (isSelected) {
+ const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
+ return `${selectedUser.name} + ${otherSelected.length} more`;
+ } else {
+ return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ }
+ };
+
+ $('.assign-to-me-link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
+
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
+ checkMaxSelect();
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
+ } else {
+ const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
+ $input.val(gon.current_user_id);
+ selectedId = $input.val();
+ $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
+ }
+ });
+
+ $block.on('click', '.js-assign-yourself', (e) => {
+ e.preventDefault();
+ return assignTo(_this.currentUser.id);
+ });
+
+ assignTo = function(selected) {
+ var data;
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].assignee_id = selected != null ? selected : null;
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+
+ return $.ajax({
+ type: 'PUT',
+ dataType: 'json',
+ url: issueURL,
+ data: data
+ }).done(function(data) {
+ var user;
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.fadeOut();
+ if (data.assignee) {
+ user = {
+ name: data.assignee.name,
+ username: data.assignee.username,
+ avatar: data.assignee.avatar_url
+ };
+ } else {
+ user = {
+ name: 'Unassigned',
+ username: '',
+ avatar: ''
+ };
+ }
+ $value.html(assigneeTemplate(user));
+ $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
+ return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ });
+ };
+ collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
+ assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
+ return $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: function(term, callback) {
+ return _this.users(term, options, function(users) {
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ }.bind(this));
+ },
+ processData: function(term, data, callback) {
+ let users = data;
+
+ // Only show assigned user list when there is no search term
+ if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
+ const selectedInputs = getSelectedUserInputs();
+
+ // Potential duplicate entries when dealing with issue board
+ // because issue board is also managed by vue
+ const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ .filter((input) => {
+ const userId = parseInt(input.value, 10);
+ const inUsersArray = users.find(u => u.id === userId);
+
+ return !inUsersArray && userId !== 0;
+ })
+ .map((input) => {
+ const userId = parseInt(input.value, 10);
+ const { avatarUrl, avatar_url, name, username } = input.dataset;
+ return {
+ avatar_url: avatarUrl || avatar_url,
+ id: userId,
+ name,
+ username,
+ };
});
- },
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name', 'username']
- },
- selectable: true,
- fieldName: $dropdown.data('field-name'),
- toggleLabel: function(selected, el) {
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- if (selected.text) {
- return selected.text;
- } else {
- return selected.name;
+
+ users = data.concat(selectedUsers);
+ }
+
+ let anyUser;
+ let index;
+ let j;
+ let len;
+ let name;
+ let obj;
+ let showDivider;
+ if (term.length === 0) {
+ showDivider = 0;
+ if (firstUser) {
+ // Move current user to the front of the list
+ for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
+ obj = users[index];
+ if (obj.username === firstUser) {
+ users.splice(index, 1);
+ users.unshift(obj);
+ break;
}
- } else {
- return defaultLabel;
}
- },
- defaultLabel: defaultLabel,
- inputId: 'issue_assignee_id',
- hidden: function(e) {
- $selectbox.hide();
- // display:block overrides the hide-collapse rule
- return $value.css('display', '');
- },
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(user, $el, e) {
- var isIssueIndex, isMRIndex, page, selected;
- page = $('body').data('page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = (page === page && page === 'projects:merge_requests:index');
- if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
- e.preventDefault();
- selectedId = user.id;
- if (selectedId === gon.current_user_id) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- return;
+ }
+ if (showNullUser) {
+ showDivider += 1;
+ users.unshift({
+ beforeDivider: true,
+ name: 'Unassigned',
+ id: 0
+ });
+ }
+ if (showAnyUser) {
+ showDivider += 1;
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
}
- if ($el.closest('.add-issues-modal').length) {
- gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- selectedId = user.id;
- return Issuable.filterResults($dropdown.closest('form'));
- } else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (user.id) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: user.id,
- username: user.username,
- name: user.name,
- avatar_url: user.avatar_url
- }));
- } else {
- gl.issueBoards.boardStoreIssueDelete('assignee');
+ anyUser = {
+ beforeDivider: true,
+ name: name,
+ id: null
+ };
+ users.unshift(anyUser);
+ }
+
+ if (showDivider) {
+ users.splice(showDivider, 0, 'divider');
+ }
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
+
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdown-header')) {
+ showDivider += 1;
+ users.splice(showDivider, 0, {
+ header: $dropdown.data('dropdown-header'),
+ });
}
- updateIssueBoardsIssue();
- } else {
- selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
- return assignTo(selected);
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
+
+ users = users.filter(u => selected.indexOf(u.id) === -1);
+
+ selectedUsers.forEach((selectedUser) => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
+
+ users.splice(showDivider + 1, 0, 'divider');
}
- },
- id: function (user) {
- return user.id;
- },
- opened: function(e) {
- const $el = $(e.currentTarget);
- $el.find('.is-active').removeClass('is-active');
- $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
- },
- renderRow: function(user) {
- var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
- username = user.username ? "@" + user.username : "";
- avatar = user.avatar_url ? user.avatar_url : false;
- selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
- img = "";
- if (user.beforeDivider != null) {
- "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
- } else {
- if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
- }
+ }
+ }
+
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
+ },
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name', 'username']
+ },
+ selectable: true,
+ fieldName: $dropdown.data('field-name'),
+ toggleLabel: function(selected, el, glDropdown) {
+ const inputValue = glDropdown.filterInput.val();
+
+ if (this.multiSelect && inputValue === '') {
+ // Remove non-users from the fullData array
+ const users = glDropdown.filteredFullData();
+ const callback = glDropdown.parseData.bind(glDropdown);
+
+ // Update the data model
+ this.processData(inputValue, users, callback);
+ }
+
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
+
+ if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
+ if (selected.text) {
+ return selected.text;
+ } else {
+ return selected.name;
+ }
+ } else {
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
+ return defaultLabel;
+ }
+ },
+ defaultLabel: defaultLabel,
+ hidden: function(e) {
+ if ($dropdown.hasClass('js-multiselect')) {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
+
+ if (!$dropdown.data('always-show-selectbox')) {
+ $selectbox.hide();
+
+ // Recalculate where .value is because vue might have changed it
+ $block = $selectbox.closest('.block');
+ $value = $block.find('.value');
+ // display:block overrides the hide-collapse rule
+ $value.css('display', '');
+ }
+ },
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ inputMeta: $dropdown.data('input-meta'),
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const user = options.selectedObj;
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const isActive = $el.hasClass('is-active');
+ const previouslySelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
+
+ // Enables support for limiting the number of users selected
+ // Automatically removes the first on the list if more users are selected
+ checkMaxSelect();
+
+ if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
+ // Unassigned selected
+ previouslySelected.each((index, element) => {
+ const id = parseInt(element.value, 10);
+ element.remove();
+ });
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ emitSidebarEvent('sidebar.addAssignee', user);
+
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
}
- // split into three parts so we can remove the username section if nessesary
- listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
- listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
- listClosingTags = "</a> </li>";
- if (username === '') {
- listWithUserName = '';
+ } else {
+ if (previouslySelected.length === 0) {
+ // Select unassigned because there is no more selected users
+ this.addInput($dropdown.data('field-name'), 0, {});
}
- return listWithName + listWithUserName + listClosingTags;
+
+ // User unselected
+ emitSidebarEvent('sidebar.removeAssignee', user);
}
- });
- };
- })(this));
- $('.ajax-users-select').each((function(_this) {
- return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('project-id');
- options.groupId = $(select).data('group-id');
- options.showCurrentUser = $(select).data('current-user');
- options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
- options.authorId = $(select).data('author-id');
- options.skipUsers = $(select).data('skip-users');
- showNullUser = $(select).data('null-user');
- showAnyUser = $(select).data('any-user');
- showEmailUser = $(select).data('email-user');
- firstUser = $(select).data('first-user');
- return $(select).select2({
- placeholder: "Search for a user",
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query: function(query) {
- return _this.users(query.term, options, function(users) {
- var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
- data = {
- results: users
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- ref = data.results;
- for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
- obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- nullUser = {
- name: 'Unassigned',
- id: 0
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- name: name,
- id: null
- };
- data.results.unshift(anyUser);
- }
- }
- if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
- var trimmed = query.term.trim();
- emailUser = {
- name: "Invite \"" + query.term + "\"",
- username: trimmed,
- id: trimmed
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
- });
- },
- initSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: "ajax-users-dropdown",
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
+
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
}
- });
- };
- })(this));
- }
+ }
- UsersSelect.prototype.initSelection = function(element, callback) {
- var id, nullUser;
- id = $(element).val();
- if (id === "0") {
- nullUser = {
- name: 'Unassigned'
- };
- return callback(nullUser);
- } else if (id !== "") {
- return this.user(id, callback);
- }
- };
+ var isIssueIndex, isMRIndex, page, selected;
+ page = $('body').data('page');
+ isIssueIndex = page === 'projects:issues:index';
+ isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
+ e.preventDefault();
- UsersSelect.prototype.formatResult = function(user) {
- var avatar;
- if (user.avatar_url) {
- avatar = user.avatar_url;
- } else {
- avatar = gon.default_avatar_url;
- }
- return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
- };
+ const isSelecting = (user.id !== selectedId);
+ selectedId = isSelecting ? user.id : selectedIdDefault;
- UsersSelect.prototype.formatSelection = function(user) {
- return user.name;
- };
+ if (selectedId === gon.current_user_id) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ return;
+ }
+ if ($el.closest('.add-issues-modal').length) {
+ gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ return Issuable.filterResults($dropdown.closest('form'));
+ } else if ($dropdown.hasClass('js-filter-submit')) {
+ return $dropdown.closest('form').submit();
+ } else if (!$dropdown.hasClass('js-multiselect')) {
+ selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
+ return assignTo(selected);
+ }
- UsersSelect.prototype.user = function(user_id, callback) {
- if (!/^\d+$/.test(user_id)) {
- return false;
- }
+ // Automatically close dropdown after assignee is selected
+ // since CE has no multiple assignees
+ // EE does not have a max-select
+ if ($dropdown.data('max-select') &&
+ getSelected().length === $dropdown.data('max-select')) {
+ // Close the dropdown
+ $dropdown.dropdown('toggle');
+ }
+ },
+ id: function (user) {
+ return user.id;
+ },
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ const selected = getSelected();
+ if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
+ this.addInput($dropdown.data('field-name'), 0, {});
+ }
+ $el.find('.is-active').removeClass('is-active');
+
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
+
+ if (selected.length > 0) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ highlightSelected(0);
+ } else {
+ highlightSelected(selectedId);
+ }
+ },
+ updateLabel: $dropdown.data('dropdown-title'),
+ renderRow: function(user) {
+ var avatar, img, listClosingTags, listWithName, listWithUserName, username;
+ username = user.username ? "@" + user.username : "";
+ avatar = user.avatar_url ? user.avatar_url : false;
+
+ let selected = false;
+
+ if (this.multiSelect) {
+ selected = getSelected().find(u => user.id === u);
- var url;
- url = this.buildUrl(this.userPath);
- url = url.replace(':id', user_id);
- return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(user) {
- return callback(user);
+ const fieldName = this.fieldName;
+ const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
+
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ selected = user.id === selectedId;
+ }
+
+ img = "";
+ if (user.beforeDivider != null) {
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
+ } else {
+ if (avatar) {
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
+ }
+ }
+
+ return `
+ <li data-user-id=${user.id}>
+ <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
+ ${img}
+ <strong class='dropdown-menu-user-full-name'>
+ ${user.name}
+ </strong>
+ ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
+ </a>
+ </li>
+ `;
+ }
});
};
-
- // Return users list. Filtered by query
- // Only active users retrieved
- UsersSelect.prototype.users = function(query, options, callback) {
- var url;
- url = this.buildUrl(this.usersPath);
- return $.ajax({
- url: url,
- data: {
- search: query,
- per_page: 20,
- active: true,
- project_id: options.projectId || null,
- group_id: options.groupId || null,
- skip_ldap: options.skipLdap || null,
- todo_filter: options.todoFilter || null,
- todo_state_filter: options.todoStateFilter || null,
- current_user: options.showCurrentUser || null,
- push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
- author_id: options.authorId || null,
- skip_users: options.skipUsers || null
+ })(this));
+ $('.ajax-users-select').each((function(_this) {
+ return function(i, select) {
+ var firstUser, showAnyUser, showEmailUser, showNullUser;
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('project-id');
+ options.groupId = $(select).data('group-id');
+ options.showCurrentUser = $(select).data('current-user');
+ options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
+ options.authorId = $(select).data('author-id');
+ options.skipUsers = $(select).data('skip-users');
+ showNullUser = $(select).data('null-user');
+ showAnyUser = $(select).data('any-user');
+ showEmailUser = $(select).data('email-user');
+ firstUser = $(select).data('first-user');
+ return $(select).select2({
+ placeholder: "Search for a user",
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query: function(query) {
+ return _this.users(query.term, options, function(users) {
+ var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
+ data = {
+ results: users
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ ref = data.results;
+ for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
+ obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
+ }
+ }
+ if (showNullUser) {
+ nullUser = {
+ name: 'Unassigned',
+ id: 0
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
+ }
+ anyUser = {
+ name: name,
+ id: null
+ };
+ data.results.unshift(anyUser);
+ }
+ }
+ if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
+ var trimmed = query.term.trim();
+ emailUser = {
+ name: "Invite \"" + query.term + "\"",
+ username: trimmed,
+ id: trimmed
+ };
+ data.results.unshift(emailUser);
+ }
+ return query.callback(data);
+ });
+ },
+ initSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.initSelection.apply(_this, args);
+ },
+ formatResult: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatResult.apply(_this, args);
},
- dataType: "json"
- }).done(function(users) {
- return callback(users);
+ formatSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatSelection.apply(_this, args);
+ },
+ dropdownCssClass: "ajax-users-dropdown",
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup: function(m) {
+ return m;
+ }
});
};
+ })(this));
+}
- UsersSelect.prototype.buildUrl = function(url) {
- if (gon.relative_url_root != null) {
- url = gon.relative_url_root.replace(/\/$/, '') + url;
- }
- return url;
+UsersSelect.prototype.initSelection = function(element, callback) {
+ var id, nullUser;
+ id = $(element).val();
+ if (id === "0") {
+ nullUser = {
+ name: 'Unassigned'
};
+ return callback(nullUser);
+ } else if (id !== "") {
+ return this.user(id, callback);
+ }
+};
+
+UsersSelect.prototype.formatResult = function(user) {
+ var avatar;
+ if (user.avatar_url) {
+ avatar = user.avatar_url;
+ } else {
+ avatar = gon.default_avatar_url;
+ }
+ return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar avatar-inline s32' src='" + avatar + "'></div> <div class='user-name dropdown-menu-user-full-name'>" + user.name + "</div> <div class='user-username dropdown-menu-user-username'>" + ("@" + user.username || "") + "</div> </div>";
+};
+
+UsersSelect.prototype.formatSelection = function(user) {
+ return user.name;
+};
+
+UsersSelect.prototype.user = function(user_id, callback) {
+ if (!/^\d+$/.test(user_id)) {
+ return false;
+ }
+
+ var url;
+ url = this.buildUrl(this.userPath);
+ url = url.replace(':id', user_id);
+ return $.ajax({
+ url: url,
+ dataType: "json"
+ }).done(function(user) {
+ return callback(user);
+ });
+};
+
+// Return users list. Filtered by query
+// Only active users retrieved
+UsersSelect.prototype.users = function(query, options, callback) {
+ var url;
+ url = this.buildUrl(this.usersPath);
+ return $.ajax({
+ url: url,
+ data: {
+ search: query,
+ per_page: options.perPage || 20,
+ active: true,
+ project_id: options.projectId || null,
+ group_id: options.groupId || null,
+ skip_ldap: options.skipLdap || null,
+ todo_filter: options.todoFilter || null,
+ todo_state_filter: options.todoStateFilter || null,
+ current_user: options.showCurrentUser || null,
+ push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
+ },
+ dataType: "json"
+ }).done(function(users) {
+ return callback(users);
+ });
+};
+
+UsersSelect.prototype.buildUrl = function(url) {
+ if (gon.relative_url_root != null) {
+ url = gon.relative_url_root.replace(/\/$/, '') + url;
+ }
+ return url;
+};
- return UsersSelect;
- })();
-}).call(window);
+export default UsersSelect;
diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js
index d4f716acb72..ec515e892c6 100644
--- a/app/assets/javascripts/version_check_image.js
+++ b/app/assets/javascripts/version_check_image.js
@@ -1,10 +1,5 @@
-class VersionCheckImage {
+export default class VersionCheckImage {
static bindErrorEvent(imageElement) {
imageElement.off('error').on('error', () => imageElement.hide());
}
}
-
-window.gl = window.gl || {};
-gl.VersionCheckImage = VersionCheckImage;
-
-module.exports = VersionCheckImage;
diff --git a/app/assets/javascripts/visibility_select.js b/app/assets/javascripts/visibility_select.js
index f712d7ba930..0c928d0d5f6 100644
--- a/app/assets/javascripts/visibility_select.js
+++ b/app/assets/javascripts/visibility_select.js
@@ -1,27 +1,21 @@
-(() => {
- const gl = window.gl || (window.gl = {});
-
- class VisibilitySelect {
- constructor(container) {
- if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
- this.container = container;
- this.helpBlock = this.container.querySelector('.help-block');
- this.select = this.container.querySelector('select');
- }
-
- init() {
- if (this.select) {
- this.updateHelpText();
- this.select.addEventListener('change', this.updateHelpText.bind(this));
- } else {
- this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
- }
- }
+export default class VisibilitySelect {
+ constructor(container) {
+ if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
+ this.container = container;
+ this.helpBlock = this.container.querySelector('.help-block');
+ this.select = this.container.querySelector('select');
+ }
- updateHelpText() {
- this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
+ init() {
+ if (this.select) {
+ this.updateHelpText();
+ this.select.addEventListener('change', this.updateHelpText.bind(this));
+ } else {
+ this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
}
}
- gl.VisibilitySelect = VisibilitySelect;
-})();
+ updateHelpText() {
+ this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
new file mode 100644
index 00000000000..982b5e8e373
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
@@ -0,0 +1,28 @@
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ name: 'MRWidgetAuthor',
+ props: {
+ author: { type: Object, required: true },
+ showAuthorName: { type: Boolean, required: false, default: true },
+ showAuthorTooltip: { type: Boolean, required: false, default: false },
+ },
+ directives: {
+ tooltip,
+ },
+ template: `
+ <a
+ :href="author.webUrl || author.web_url"
+ class="author-link inline"
+ :v-tooltip="showAuthorTooltip"
+ :title="author.name">
+ <img
+ :src="author.avatarUrl || author.avatar_url"
+ class="avatar avatar-inline s16" />
+ <span
+ v-if="showAuthorName"
+ class="author">{{author.name}}
+ </span>
+ </a>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
new file mode 100644
index 00000000000..6d2ed5fda64
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
@@ -0,0 +1,27 @@
+import MRWidgetAuthor from './mr_widget_author';
+
+export default {
+ name: 'MRWidgetAuthorTime',
+ props: {
+ actionText: { type: String, required: true },
+ author: { type: Object, required: true },
+ dateTitle: { type: String, required: true },
+ dateReadable: { type: String, required: true },
+ },
+ components: {
+ 'mr-widget-author': MRWidgetAuthor,
+ },
+ template: `
+ <h4 class="js-mr-widget-author">
+ {{actionText}}
+ <mr-widget-author :author="author" />
+ <time
+ :title="dateTitle"
+ data-toggle="tooltip"
+ data-placement="top"
+ data-container="body">
+ {{dateReadable}}
+ </time>
+ </h4>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
new file mode 100644
index 00000000000..e98d147733c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
@@ -0,0 +1,113 @@
+/* global Flash */
+
+import '~/lib/utils/datetime_utility';
+import MemoryUsage from './mr_widget_memory_usage';
+import StatusIcon from './mr_widget_status_icon';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MRWidgetDeployment',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-memory-usage': MemoryUsage,
+ 'status-icon': StatusIcon,
+ },
+ methods: {
+ formatDate(date) {
+ return gl.utils.getTimeago().format(date);
+ },
+ hasExternalUrls(deployment = {}) {
+ return deployment.external_url && deployment.external_url_formatted;
+ },
+ hasDeploymentTime(deployment = {}) {
+ return deployment.deployed_at && deployment.deployed_at_formatted;
+ },
+ hasDeploymentMeta(deployment = {}) {
+ return deployment.url && deployment.name;
+ },
+ stopEnvironment(deployment) {
+ const msg = 'Are you sure you want to stop this environment?';
+ const isConfirmed = confirm(msg); // eslint-disable-line
+
+ if (isConfirmed) {
+ MRWidgetService.stopEnvironment(deployment.stop_url)
+ .then(res => res.json())
+ .then((res) => {
+ if (res.redirect_url) {
+ gl.utils.visitUrl(res.redirect_url);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line
+ });
+ }
+ },
+ },
+ template: `
+ <div class="mr-widget-heading deploy-heading">
+ <div v-for="deployment in mr.deployments">
+ <div class="ci-widget media">
+ <div class="ci-status-icon ci-status-icon-success">
+ <span class="js-icon-link icon-link">
+ <status-icon status="success" />
+ </span>
+ </div>
+ <div class="media-body space-children">
+ <span>
+ <span
+ v-if="hasDeploymentMeta(deployment)">
+ Deployed to
+ </span>
+ <a
+ v-if="hasDeploymentMeta(deployment)"
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-meta inline">
+ {{deployment.name}}
+ </a>
+ <span
+ v-if="hasExternalUrls(deployment)">
+ on
+ </span>
+ <a
+ v-if="hasExternalUrls(deployment)"
+ :href="deployment.external_url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-url inline">
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true" />
+ {{deployment.external_url_formatted}}
+ </a>
+ <span
+ v-if="hasDeploymentTime(deployment)"
+ :data-title="deployment.deployed_at_formatted"
+ class="js-deploy-time"
+ data-toggle="tooltip"
+ data-placement="top">
+ {{formatDate(deployment.deployed_at)}}
+ </span>
+ </span>
+ <button
+ type="button"
+ v-if="deployment.stop_url"
+ @click="stopEnvironment(deployment)"
+ class="btn btn-default btn-xs">
+ Stop environment
+ </button>
+ <mr-widget-memory-usage
+ v-if="deployment.metrics_url"
+ :metrics-url="deployment.metrics_url"
+ :metrics-monitoring-url="deployment.metrics_monitoring_url"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
new file mode 100644
index 00000000000..c05a76a3b4a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
@@ -0,0 +1,111 @@
+import tooltip from '../../vue_shared/directives/tooltip';
+import '../../lib/utils/text_utility';
+
+export default {
+ name: 'MRWidgetHeader',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ shouldShowCommitsBehindText() {
+ return this.mr.divergedCommitsCount > 0;
+ },
+ commitsText() {
+ return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
+ },
+ branchNameClipboardData() {
+ // This supports code in app/assets/javascripts/copy_to_clipboard.js that
+ // works around ClipboardJS limitations to allow the context-specific
+ // copy/pasting of plain text or GFM.
+ return JSON.stringify({
+ text: this.mr.sourceBranch,
+ gfm: `\`${this.mr.sourceBranch}\``,
+ });
+ },
+ },
+ methods: {
+ isBranchTitleLong(branchTitle) {
+ return branchTitle.length > 32;
+ },
+ },
+ template: `
+ <div class="mr-source-target">
+ <div class="normal">
+ <strong>
+ Request to merge
+ <span
+ class="label-branch"
+ :class="{'label-truncated': isBranchTitleLong(mr.sourceBranch)}"
+ :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
+ data-placement="bottom"
+ :v-tooltip="isBranchTitleLong(mr.sourceBranch)"
+ v-html="mr.sourceBranchLink"></span>
+ <button
+ v-tooltip
+ class="btn btn-transparent btn-clipboard"
+ data-title="Copy branch name to clipboard"
+ :data-clipboard-text="branchNameClipboardData">
+ <i
+ aria-hidden="true"
+ class="fa fa-clipboard"></i>
+ </button>
+ into
+ <span
+ class="label-branch"
+ :v-tooltip="isBranchTitleLong(mr.sourceBranch)"
+ :class="{'label-truncatedtooltip': isBranchTitleLong(mr.targetBranch)}"
+ :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
+ data-placement="bottom">
+ <a :href="mr.targetBranchTreePath">{{mr.targetBranch}}</a>
+ </span>
+ </strong>
+ <span
+ v-if="shouldShowCommitsBehindText"
+ class="diverged-commits-count">
+ (<a :href="mr.targetBranchPath">{{mr.divergedCommitsCount}} {{commitsText}} behind</a>)
+ </span>
+ </div>
+ <div v-if="mr.isOpen">
+ <a
+ href="#modal_merge_info"
+ data-toggle="modal"
+ class="btn btn-small inline">
+ Check out branch
+ </a>
+ <span class="dropdown inline prepend-left-10">
+ <a
+ class="btn btn-xs dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Download as"
+ role="button">
+ <i
+ class="fa fa-download"
+ aria-hidden="true" />
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true" />
+ </a>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li>
+ <a
+ :href="mr.emailPatchesPath"
+ download>
+ Email patches
+ </a>
+ </li>
+ <li>
+ <a
+ :href="mr.plainDiffPath"
+ download>
+ Plain diff
+ </a>
+ </li>
+ </ul>
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
new file mode 100644
index 00000000000..a4e34116c33
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
@@ -0,0 +1,153 @@
+import statusCodes from '../../lib/utils/http_status';
+import { bytesToMiB } from '../../lib/utils/number_utils';
+
+import MemoryGraph from '../../vue_shared/components/memory_graph';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MemoryUsage',
+ props: {
+ metricsUrl: {
+ type: String,
+ required: true,
+ },
+ metricsMonitoringUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ memoryFrom: 0,
+ memoryTo: 0,
+ memoryMetrics: [],
+ deploymentTime: 0,
+ hasMetrics: false,
+ loadFailed: false,
+ loadingMetrics: true,
+ backOffRequestCounter: 0,
+ };
+ },
+ components: {
+ 'mr-memory-graph': MemoryGraph,
+ },
+ computed: {
+ shouldShowLoading() {
+ return this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowMemoryGraph() {
+ return !this.loadingMetrics && this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowLoadFailure() {
+ return !this.loadingMetrics && !this.hasMetrics && this.loadFailed;
+ },
+ shouldShowMetricsUnavailable() {
+ return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ memoryChangeType() {
+ const memoryTo = Number(this.memoryTo);
+ const memoryFrom = Number(this.memoryFrom);
+
+ if (memoryTo > memoryFrom) {
+ return 'increased';
+ } else if (memoryTo < memoryFrom) {
+ return 'decreased';
+ }
+
+ return 'unchanged';
+ },
+ },
+ methods: {
+ getMegabytes(bytesString) {
+ const valueInBytes = Number(bytesString).toFixed(2);
+ return (bytesToMiB(valueInBytes)).toFixed(2);
+ },
+ computeGraphData(metrics, deploymentTime) {
+ this.loadingMetrics = false;
+ const { memory_before, memory_after, memory_values } = metrics;
+
+ // Both `memory_before` and `memory_after` objects
+ // have peculiar structure where accessing only a specific
+ // index yeilds correct value that we can use to show memory delta.
+ if (memory_before.length > 0) {
+ this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
+ }
+
+ if (memory_after.length > 0) {
+ this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
+ }
+
+ if (memory_values.length > 0) {
+ this.hasMetrics = true;
+ this.memoryMetrics = memory_values[0].values;
+ this.deploymentTime = deploymentTime;
+ }
+ },
+ loadMetrics() {
+ gl.utils.backOff((next, stop) => {
+ MRWidgetService.fetchMetrics(this.metricsUrl)
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ /* eslint-disable no-unused-expressions */
+ this.backOffRequestCounter < 3 ? next() : stop(res);
+ } else {
+ stop(res);
+ }
+ })
+ .catch(stop);
+ })
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ return res;
+ }
+
+ return res.json();
+ })
+ .then((res) => {
+ this.computeGraphData(res.metrics, res.deployment_time);
+ return res;
+ })
+ .catch(() => {
+ this.loadFailed = true;
+ this.loadingMetrics = false;
+ });
+ },
+ },
+ mounted() {
+ this.loadingMetrics = true;
+ this.loadMetrics();
+ },
+ template: `
+ <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
+ <p
+ v-if="shouldShowLoading"
+ class="usage-info js-usage-info usage-info-loading">
+ <i
+ class="fa fa-spinner fa-spin usage-info-load-spinner"
+ aria-hidden="true" />Loading deployment statistics
+ </p>
+ <p
+ v-if="shouldShowMemoryGraph"
+ class="usage-info js-usage-info">
+ <a :href="metricsMonitoringUrl">Memory</a> usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
+ </p>
+ <p
+ v-if="shouldShowLoadFailure"
+ class="usage-info js-usage-info usage-info-failed">
+ Failed to load deployment statistics
+ </p>
+ <p
+ v-if="shouldShowMetricsUnavailable"
+ class="usage-info js-usage-info usage-info-unavailable">
+ Deployment statistics are not available currently
+ </p>
+ <mr-memory-graph
+ v-if="shouldShowMemoryGraph"
+ :metrics="memoryMetrics"
+ :deploymentTime="deploymentTime"
+ height="25"
+ width="100" />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
new file mode 100644
index 00000000000..1d9f9863dd9
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
@@ -0,0 +1,23 @@
+export default {
+ name: 'MRWidgetMergeHelp',
+ props: {
+ missingBranch: { type: String, required: false, default: '' },
+ },
+ template: `
+ <section class="mr-widget-help">
+ <template
+ v-if="missingBranch">
+ If the {{missingBranch}} branch exists in your local repository, you
+ </template>
+ <template v-else>
+ You
+ </template>
+ can merge this merge request manually using the
+ <a
+ data-toggle="modal"
+ href="#modal_merge_info">
+ command line
+ </a>
+ </section>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
new file mode 100644
index 00000000000..6c2e9ba1d30
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
@@ -0,0 +1,85 @@
+import PipelineStage from '../../pipelines/components/stage.vue';
+import ciIcon from '../../vue_shared/components/ci_icon.vue';
+import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
+
+export default {
+ name: 'MRWidgetPipeline',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ 'pipeline-stage': PipelineStage,
+ ciIcon,
+ },
+ computed: {
+ hasCIError() {
+ const { hasCI, ciStatus } = this.mr;
+
+ return hasCI && !ciStatus;
+ },
+ svg() {
+ return statusIconEntityMap.icon_status_failed;
+ },
+ stageText() {
+ return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage';
+ },
+ status() {
+ return this.mr.pipeline.details.status || {};
+ },
+ },
+ template: `
+ <div class="mr-widget-heading">
+ <div class="ci-widget media">
+ <template v-if="hasCIError">
+ <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10">
+ <span
+ v-html="svg"
+ aria-hidden="true"></span>
+ </div>
+ <div class="media-body">
+ Could not connect to the CI server. Please check your settings and try again
+ </div>
+ </template>
+ <template v-else>
+ <div class="ci-status-icon append-right-10">
+ <a
+ class="icon-link"
+ :href="this.status.details_path">
+ <ci-icon :status="status" />
+ </a>
+ </div>
+ <div class="media-body">
+ <span>
+ Pipeline
+ <a
+ :href="mr.pipeline.path"
+ class="pipeline-id">#{{mr.pipeline.id}}</a>
+ </span>
+ <span class="mr-widget-pipeline-graph">
+ <span class="stage-cell">
+ <div
+ v-if="mr.pipeline.details.stages.length > 0"
+ v-for="stage in mr.pipeline.details.stages"
+ class="stage-container dropdown js-mini-pipeline-graph">
+ <pipeline-stage :stage="stage" />
+ </div>
+ </span>
+ </span>
+ <span>
+ {{mr.pipeline.details.status.label}} for
+ <a
+ :href="mr.pipeline.commit.commit_path"
+ class="commit-sha js-commit-link">
+ {{mr.pipeline.commit.short_id}}</a>.
+ </span>
+ <span
+ v-if="mr.pipeline.coverage"
+ class="js-mr-coverage">
+ Coverage {{mr.pipeline.coverage}}%
+ </span>
+ </div>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
new file mode 100644
index 00000000000..563267ad044
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
@@ -0,0 +1,37 @@
+export default {
+ name: 'MRWidgetRelatedLinks',
+ props: {
+ relatedLinks: { type: Object, required: true },
+ state: { type: String, required: false },
+ },
+ computed: {
+ hasLinks() {
+ const { closing, mentioned, assignToMe } = this.relatedLinks;
+ return closing || mentioned || assignToMe;
+ },
+ closesText() {
+ if (this.state === 'merged') {
+ return 'Closed';
+ }
+ if (this.state === 'closed') {
+ return 'Did not close';
+ }
+ return 'Closes';
+ },
+ },
+ template: `
+ <section
+ v-if="hasLinks"
+ class="mr-info-list mr-links">
+ <p v-if="relatedLinks.closing">
+ {{closesText}} <span v-html="relatedLinks.closing"></span>
+ </p>
+ <p v-if="relatedLinks.mentioned">
+ Mentions <span v-html="relatedLinks.mentioned"></span>
+ </p>
+ <p v-if="relatedLinks.assignToMe">
+ <span v-html="relatedLinks.assignToMe"></span>
+ </p>
+ </section>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js
new file mode 100644
index 00000000000..b01c923311b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js
@@ -0,0 +1,36 @@
+import ciIcon from '../../vue_shared/components/ci_icon.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+export default {
+ props: {
+ status: { type: String, required: true },
+ showDisabledButton: { type: Boolean, required: false },
+ },
+ components: {
+ ciIcon,
+ loadingIcon,
+ },
+ computed: {
+ statusObj() {
+ return {
+ group: this.status,
+ icon: `icon_status_${this.status}`,
+ };
+ },
+ },
+ template: `
+ <div class="space-children flex-container-block append-right-10">
+ <div v-if="status === 'loading'" class="mr-widget-icon">
+ <loading-icon />
+ </div>
+ <ci-icon v-else :status="statusObj" />
+ <button
+ v-if="showDisabledButton"
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
new file mode 100644
index 00000000000..2b16a2d6817
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
@@ -0,0 +1,26 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetArchived',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <div class="space-children">
+ <status-icon status="failed" />
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ </div>
+ <div class="media-body">
+ <span class="bold">
+ This project is archived, write access has been disabled
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
new file mode 100644
index 00000000000..5648208f7b1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
@@ -0,0 +1,47 @@
+import eventHub from '../../event_hub';
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetAutoMergeFailed',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ data() {
+ return {
+ isRefreshing: false,
+ };
+ },
+ components: {
+ statusIcon,
+ },
+ methods: {
+ refreshWidget() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isRefreshing = false;
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" />
+ <div class="media-body space-children">
+ <span class="bold">
+ <template v-if="mr.mergeError">{{mr.mergeError}}.</template>
+ This merge request failed to be merged automatically
+ </span>
+ <button
+ @click="refreshWidget"
+ :disabled="isRefreshing"
+ type="button"
+ class="btn btn-xs btn-default">
+ <i
+ v-if="isRefreshing"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Refresh
+ </button>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
new file mode 100644
index 00000000000..aaf9d3304a4
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
@@ -0,0 +1,18 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetChecking',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="loading" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ Checking ability to merge automatically
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
new file mode 100644
index 00000000000..4078aad7f83
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
@@ -0,0 +1,35 @@
+import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetClosed',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-author-and-time': mrWidgetAuthorTime,
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" />
+ <div class="media-body">
+ <mr-widget-author-and-time
+ actionText="Closed by"
+ :author="mr.closedBy"
+ :dateTitle="mr.updatedAt"
+ :dateReadable="mr.closedAt"
+ />
+ <section class="mr-info-list">
+ <p>
+ The changes were not merged into
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch">
+ {{mr.targetBranch}}</a>
+ </p>
+ </section>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
new file mode 100644
index 00000000000..f9cb79a0bc1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
@@ -0,0 +1,37 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetConflicts',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ There are merge conflicts<span v-if="!mr.canMerge">.</span>
+ <span v-if="!mr.canMerge">
+ Resolve these conflicts or ask someone with write access to this repository to merge it locally
+ </span>
+ </span>
+ <a
+ v-if="mr.canMerge && mr.conflictResolutionPath"
+ :href="mr.conflictResolutionPath"
+ class="btn btn-default btn-xs js-resolve-conflicts-button">
+ Resolve conflicts
+ </a>
+ <a
+ v-if="mr.canMerge"
+ class="btn btn-default btn-xs js-merge-locally-button"
+ data-toggle="modal"
+ href="#modal_merge_info">
+ Merge locally
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
new file mode 100644
index 00000000000..1cb24549d53
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
@@ -0,0 +1,78 @@
+import statusIcon from '../mr_widget_status_icon';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetFailedToMerge',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ data() {
+ return {
+ timer: 10,
+ isRefreshing: false,
+ };
+ },
+ mounted() {
+ setInterval(() => {
+ this.updateTimer();
+ }, 1000);
+ },
+ created() {
+ eventHub.$emit('DisablePolling');
+ },
+ computed: {
+ timerText() {
+ return this.timer > 1 ? `${this.timer} seconds` : 'a second';
+ },
+ },
+ methods: {
+ refresh() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('EnablePolling');
+ },
+ updateTimer() {
+ this.timer = this.timer - 1;
+
+ if (this.timer === 0) {
+ this.refresh();
+ }
+ },
+ },
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <template v-if="isRefreshing">
+ <status-icon status="loading" />
+ <span class="media-body bold js-refresh-label">
+ Refreshing now
+ </span>
+ </template>
+ <template v-else>
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ <span
+ class="has-error-message"
+ v-if="mr.mergeError">
+ {{mr.mergeError}}.
+ </span>
+ <span v-else>Merge failed.</span>
+ <span
+ :class="{ 'has-custom-error': mr.mergeError }">
+ Refreshing in {{timerText}} to show the updated status...
+ </span>
+ </span>
+ <button
+ @click="refresh"
+ class="btn btn-default btn-xs js-refresh-button"
+ type="button">
+ Refresh now
+ </button>
+ </div>
+ </template>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
new file mode 100644
index 00000000000..bdfd4d9667c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
@@ -0,0 +1,117 @@
+/* global Flash */
+import statusIcon from '../mr_widget_status_icon';
+import MRWidgetAuthor from '../../components/mr_widget_author';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetMergeWhenPipelineSucceeds',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-author': MRWidgetAuthor,
+ statusIcon,
+ },
+ data() {
+ return {
+ isCancellingAutoMerge: false,
+ isRemovingSourceBranch: false,
+ };
+ },
+ computed: {
+ canRemoveSourceBranch() {
+ const { shouldRemoveSourceBranch, canRemoveSourceBranch,
+ mergeUserId, currentUserId } = this.mr;
+
+ return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId;
+ },
+ },
+ methods: {
+ cancelAutomaticMerge() {
+ this.isCancellingAutoMerge = true;
+ this.service.cancelAutomaticMerge()
+ .then(res => res.json())
+ .then((res) => {
+ eventHub.$emit('UpdateWidgetData', res);
+ })
+ .catch(() => {
+ this.isCancellingAutoMerge = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ removeSourceBranch() {
+ const options = {
+ sha: this.mr.sha,
+ merge_when_pipeline_succeeds: true,
+ should_remove_source_branch: true,
+ };
+
+ this.isRemovingSourceBranch = true;
+ this.service.mergeResource.save(options)
+ .then(res => res.json())
+ .then((res) => {
+ if (res.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ }
+ })
+ .catch(() => {
+ this.isRemovingSourceBranch = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <h4>
+ Set by
+ <mr-widget-author :author="mr.setToMWPSBy" />
+ to be merged automatically when the pipeline succeeds
+ <a
+ v-if="mr.canCancelAutomaticMerge"
+ @click.prevent="cancelAutomaticMerge"
+ :disabled="isCancellingAutoMerge"
+ role="button"
+ href="#"
+ class="btn btn-xs btn-default js-cancel-auto-merge">
+ <i
+ v-if="isCancellingAutoMerge"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Cancel automatic merge
+ </a>
+ </h4>
+ <section class="mr-info-list">
+ <p>The changes will be merged into
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch">
+ {{mr.targetBranch}}
+ </a>
+ </p>
+ <p v-if="mr.shouldRemoveSourceBranch">
+ The source branch will be removed
+ </p>
+ <p v-else>
+ The source branch will not be removed
+ <a
+ v-if="canRemoveSourceBranch"
+ :disabled="isRemovingSourceBranch"
+ @click.prevent="removeSourceBranch"
+ role="button"
+ class="btn btn-xs btn-default js-remove-source-branch"
+ href="#">
+ <i
+ v-if="isRemovingSourceBranch"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Remove source branch
+ </a>
+ </p>
+ </section>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
new file mode 100644
index 00000000000..e452260a4d0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
@@ -0,0 +1,140 @@
+/* global Flash */
+
+import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
+import tooltip from '../../../vue_shared/directives/tooltip';
+import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+import statusIcon from '../mr_widget_status_icon';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetMerged',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ directives: {
+ tooltip,
+ },
+ components: {
+ 'mr-widget-author-and-time': mrWidgetAuthorTime,
+ loadingIcon,
+ statusIcon,
+ },
+ computed: {
+ shouldShowRemoveSourceBranch() {
+ const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr;
+
+ return !sourceBranchRemoved && canRemoveSourceBranch &&
+ !this.isMakingRequest && !isRemovingSourceBranch;
+ },
+ shouldShowSourceBranchRemoving() {
+ const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
+ return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
+ },
+ shouldShowMergedButtons() {
+ const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath,
+ cherryPickInForkPath } = this.mr;
+
+ return canRevertInCurrentMR || canCherryPickInCurrentMR ||
+ revertInForkPath || cherryPickInForkPath;
+ },
+ },
+ methods: {
+ removeSourceBranch() {
+ this.isMakingRequest = true;
+ this.service.removeSourceBranch()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.message === 'Branch was removed') {
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isMakingRequest = false;
+ });
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <div class="space-children">
+ <mr-widget-author-and-time
+ actionText="Merged by"
+ :author="mr.mergedBy"
+ :dateTitle="mr.updatedAt"
+ :dateReadable="mr.mergedAt" />
+ <a
+ v-if="mr.canRevertInCurrentMR"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ href="#modal-revert-commit"
+ data-toggle="modal"
+ data-container="body"
+ title="Revert this merge request in a new merge request">
+ Revert
+ </a>
+ <a
+ v-else-if="mr.revertInForkPath"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ data-method="post"
+ :href="mr.revertInForkPath"
+ title="Revert this merge request in a new merge request">
+ Revert
+ </a>
+ <a
+ v-if="mr.canCherryPickInCurrentMR"
+ v-tooltip
+ class="btn btn-default btn-xs"
+ href="#modal-cherry-pick-commit"
+ data-toggle="modal"
+ data-container="body"
+ title="Cherry-pick this merge request in a new merge request">
+ Cherry-pick
+ </a>
+ <a
+ v-else-if="mr.cherryPickInForkPath"
+ v-tooltip
+ class="btn btn-default btn-xs"
+ data-method="post"
+ :href="mr.cherryPickInForkPath"
+ title="Cherry-pick this merge request in a new merge request">
+ Cherry-pick
+ </a>
+ </div>
+ <section class="mr-info-list">
+ <p>
+ The changes were merged into
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
+ </span>
+ </p>
+ <p v-if="mr.sourceBranchRemoved">The source branch has been removed</p>
+ <p v-if="shouldShowRemoveSourceBranch" class="space-children">
+ <span>You can remove source branch now</span>
+ <button
+ @click="removeSourceBranch"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-xs btn-default js-remove-branch-button">
+ Remove Source Branch
+ </button>
+ </p>
+ <p v-if="shouldShowSourceBranchRemoving">
+ <loading-icon inline />
+ <span>The source branch is being removed</span>
+ </p>
+ </section>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js
new file mode 100644
index 00000000000..f6d1a4feeb2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js
@@ -0,0 +1,29 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetMerging',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body mr-state-locked media">
+ <status-icon status="loading" />
+ <div class="media-body">
+ <h4>
+ This merge request is in the process of being merged
+ </h4>
+ <section class="mr-info-list">
+ <p>
+ The changes will be merged into
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
+ </span>
+ </p>
+ </section>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
new file mode 100644
index 00000000000..9f0a359d01a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
@@ -0,0 +1,43 @@
+import statusIcon from '../mr_widget_status_icon';
+import tooltip from '../../../vue_shared/directives/tooltip';
+import mrWidgetMergeHelp from '../../components/mr_widget_merge_help';
+
+export default {
+ name: 'MRWidgetMissingBranch',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ directives: {
+ tooltip,
+ },
+ components: {
+ 'mr-widget-merge-help': mrWidgetMergeHelp,
+ statusIcon,
+ },
+ computed: {
+ missingBranchName() {
+ return this.mr.sourceBranchRemoved ? 'source' : 'target';
+ },
+ message() {
+ return `If the ${this.missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line`;
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold js-branch-text">
+ <span class="capitalize">
+ {{missingBranchName}}
+ </span> branch does not exist.
+ Please restore it or use a different {{missingBranchName}} branch
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ :title="message"
+ :aria-label="message"></i>
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
new file mode 100644
index 00000000000..797511d4e3a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
@@ -0,0 +1,19 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetNotAllowed',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="success" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ Ready to be merged automatically.
+ Ask someone with write access to this repository to merge this request
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
new file mode 100644
index 00000000000..ebfd6765934
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
@@ -0,0 +1,44 @@
+import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+
+export default {
+ name: 'MRWidgetNothingToMerge',
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { emptyStateSVG };
+ },
+ template: `
+ <div class="mr-widget-body mr-widget-empty-state">
+ <div class="row">
+ <div class="artwork col-sm-5 col-sm-push-7 col-xs-12 text-center">
+ <span v-html="emptyStateSVG"></span>
+ </div>
+ <div class="text col-sm-7 col-sm-pull-5 col-xs-12">
+ <span>
+ Merge requests are a place to propose changes you have made to a project
+ and discuss those changes with others.
+ </span>
+ <p>
+ Interested parties can even contribute by pushing commits if they want to.
+ </p>
+ <p>
+ Currently there are no changes in this merge request's source branch.
+ Please push new commits or use a different branch.
+ </p>
+ <div>
+ <a
+ v-if="mr.newBlobPath"
+ :href="mr.newBlobPath"
+ class="btn btn-inverted btn-save">
+ Create file
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
new file mode 100644
index 00000000000..167a0d4613a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
@@ -0,0 +1,18 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetPipelineBlocked',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ Pipeline blocked. The pipeline for this merge request requires a manual action to proceed
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
new file mode 100644
index 00000000000..c5be9a0530a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
@@ -0,0 +1,18 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetPipelineBlocked',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
new file mode 100644
index 00000000000..65187754009
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
@@ -0,0 +1,325 @@
+/* global Flash */
+import successSvg from 'icons/_icon_status_success.svg';
+import warningSvg from 'icons/_icon_status_warning.svg';
+import simplePoll from '~/lib/utils/simple_poll';
+import statusIcon from '../mr_widget_status_icon';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetReadyToMerge',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ data() {
+ return {
+ removeSourceBranch: this.mr.shouldRemoveSourceBranch,
+ mergeWhenBuildSucceeds: false,
+ useCommitMessageWithDescription: false,
+ setToMergeWhenPipelineSucceeds: false,
+ showCommitMessageEditor: false,
+ isMakingRequest: false,
+ isMergingImmediately: false,
+ commitMessage: this.mr.commitMessage,
+ successSvg,
+ warningSvg,
+ };
+ },
+ components: {
+ statusIcon,
+ },
+ computed: {
+ commitMessageLinkTitle() {
+ const withDesc = 'Include description in commit message';
+ const withoutDesc = "Don't include description in commit message";
+
+ return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
+ },
+ mergeButtonClass() {
+ const defaultClass = 'btn btn-small btn-success accept-merge-request';
+ const failedClass = `${defaultClass} btn-danger`;
+ const inActionClass = `${defaultClass} btn-info`;
+ const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
+
+ if (hasCI && !ciStatus) {
+ return failedClass;
+ } else if (!pipeline) {
+ return defaultClass;
+ } else if (isPipelineActive) {
+ return inActionClass;
+ } else if (isPipelineFailed) {
+ return failedClass;
+ }
+
+ return defaultClass;
+ },
+ mergeButtonText() {
+ if (this.isMergingImmediately) {
+ return 'Merge in progress';
+ } else if (this.mr.isPipelineActive) {
+ return 'Merge when pipeline succeeds';
+ }
+
+ return 'Merge';
+ },
+ shouldShowMergeOptionsDropdown() {
+ return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
+ isMergeButtonDisabled() {
+ const { commitMessage } = this;
+ return Boolean(!commitMessage.length
+ || !this.isMergeAllowed()
+ || this.isMakingRequest
+ || this.mr.preventMerge);
+ },
+ isRemoveSourceBranchButtonDisabled() {
+ return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
+ },
+ shouldShowSquashBeforeMerge() {
+ const { commitsCount, enableSquashBeforeMerge } = this.mr;
+ return enableSquashBeforeMerge && commitsCount > 1;
+ },
+ },
+ methods: {
+ isMergeAllowed() {
+ return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed);
+ },
+ updateCommitMessage() {
+ const cmwd = this.mr.commitMessageWithDescription;
+ this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription;
+ this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage;
+ },
+ toggleCommitMessageEditor() {
+ this.showCommitMessageEditor = !this.showCommitMessageEditor;
+ },
+ handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) {
+ // TODO: Remove no-param-reassign
+ if (mergeWhenBuildSucceeds === undefined) {
+ mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign
+ } else if (mergeImmediately) {
+ this.isMergingImmediately = true;
+ }
+
+ this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true;
+
+ const options = {
+ sha: this.mr.sha,
+ commit_message: this.commitMessage,
+ merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds,
+ should_remove_source_branch: this.removeSourceBranch === true,
+ };
+
+ // Only truthy in EE extension of this component
+ if (this.setAdditionalParams) {
+ this.setAdditionalParams(options);
+ }
+
+ this.isMakingRequest = true;
+ this.service.merge(options)
+ .then(res => res.json())
+ .then((res) => {
+ const hasError = res.status === 'failed' || res.status === 'hook_validation_error';
+
+ if (res.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ } else if (res.status === 'success') {
+ this.initiateMergePolling();
+ } else if (hasError) {
+ eventHub.$emit('FailedToMerge', res.merge_error);
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ initiateMergePolling() {
+ simplePoll((continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ });
+ },
+ handleMergePolling(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.state === 'merged') {
+ // If state is merged we should update the widget and stop the polling
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('FetchActionsContent');
+ if (window.mergeRequest) {
+ window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged');
+ window.mergeRequest.decreaseCounter();
+ }
+ stopPolling();
+
+ // If user checked remove source branch and we didn't remove the branch yet
+ // we should start another polling for source branch remove process
+ if (this.removeSourceBranch && res.source_branch_exists) {
+ this.initiateRemoveSourceBranchPolling();
+ }
+ } else if (res.merge_error) {
+ eventHub.$emit('FailedToMerge', res.merge_error);
+ stopPolling();
+ } else {
+ // MR is not merged yet, continue polling until the state becomes 'merged'
+ continuePolling();
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line
+ });
+ },
+ initiateRemoveSourceBranchPolling() {
+ // We need to show source branch is being removed spinner in another component
+ eventHub.$emit('SetBranchRemoveFlag', [true]);
+
+ simplePoll((continuePolling, stopPolling) => {
+ this.handleRemoveBranchPolling(continuePolling, stopPolling);
+ });
+ },
+ handleRemoveBranchPolling(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.json())
+ .then((res) => {
+ // If source branch exists then we should continue polling
+ // because removing a source branch is a background task and takes time
+ if (res.source_branch_exists) {
+ continuePolling();
+ } else {
+ // Branch is removed. Update widget, stop polling and hide the spinner
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ eventHub.$emit('SetBranchRemoveFlag', [false]);
+ });
+ stopPolling();
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <div class="media space-children">
+ <span class="btn-group">
+ <button
+ @click="handleMergeButtonClick()"
+ :disabled="isMergeButtonDisabled"
+ :class="mergeButtonClass"
+ type="button">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ {{mergeButtonText}}
+ </button>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-small btn-info dropdown-toggle js-merge-moment"
+ data-toggle="dropdown"
+ aria-label="Select merge moment">
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true" />
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu">
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(true)"
+ class="merge_when_pipeline_succeeds"
+ href="#">
+ <span class="media">
+ <span
+ v-html="successSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(false, true)"
+ class="accept-merge-request"
+ href="#">
+ <span class="media">
+ <span
+ v-html="warningSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge immediately</span>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </span>
+ <div class="media-body space-children">
+ <template v-if="isMergeAllowed()">
+ <label>
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ type="checkbox"/> Remove source branch
+ </label>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ :mr="mr"
+ :is-merge-button-disabled="isMergeButtonDisabled" />
+
+ <button
+ @click="toggleCommitMessageEditor"
+ :disabled="isMergeButtonDisabled"
+ class="btn btn-default btn-xs"
+ type="button">
+ Modify commit message
+ </button>
+ </template>
+ <template v-else>
+ <span class="bold">
+ The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure
+ </span>
+ </template>
+ </div>
+ </div>
+ <div
+ v-if="showCommitMessageEditor"
+ class="prepend-top-default commit-message-editor">
+ <div class="form-group clearfix">
+ <label
+ class="control-label"
+ for="commit-message">
+ Commit message
+ </label>
+ <div class="col-sm-10">
+ <div class="commit-message-container">
+ <div class="max-width-marker"></div>
+ <textarea
+ v-model="commitMessage"
+ class="form-control js-commit-message"
+ required="required"
+ rows="14"
+ name="Commit message"></textarea>
+ </div>
+ <p class="hint">Try to keep the first line under 52 characters and the others under 72</p>
+ <div class="hint">
+ <a
+ @click.prevent="updateCommitMessage"
+ href="#">{{commitMessageLinkTitle}}</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
new file mode 100644
index 00000000000..89f38e5bd2a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
@@ -0,0 +1,18 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetSHAMismatch',
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ The source branch HEAD has recently changed. Please reload the page and review the changes before merging
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
new file mode 100644
index 00000000000..bf8628d18a6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
@@ -0,0 +1,15 @@
+/*
+The squash-before-merge button is EE only, but it's located right in the middle
+of the readyToMerge state component template.
+
+If we didn't declare this component in CE, we'd need to maintain a separate copy
+of the readyToMergeState template in EE, which is pretty big and likely to change.
+
+Instead, in CE, we declare the component, but it's hidden and is configured to do nothing.
+In EE, the configuration extends this object to add a functioning squash-before-merge
+button.
+*/
+
+export default {
+ template: '',
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
new file mode 100644
index 00000000000..d762ca6e640
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
@@ -0,0 +1,27 @@
+import statusIcon from '../mr_widget_status_icon';
+
+export default {
+ name: 'MRWidgetUnresolvedDiscussions',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ statusIcon,
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" showDisabledButton />
+ <div class="media-body space-children">
+ <span class="bold">
+ There are unresolved discussions. Please resolve these discussions
+ </span>
+ <a
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="btn btn-default btn-xs js-create-issue">
+ Create an issue to resolve them later
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
new file mode 100644
index 00000000000..b11a06899cf
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
@@ -0,0 +1,67 @@
+/* global Flash */
+import statusIcon from '../mr_widget_status_icon';
+import tooltip from '../../../vue_shared/directives/tooltip';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetWIP',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ directives: {
+ tooltip,
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ components: {
+ statusIcon,
+ },
+ methods: {
+ removeWIP() {
+ this.isMakingRequest = true;
+ this.service.removeWIP()
+ .then(res => res.json())
+ .then((res) => {
+ eventHub.$emit('UpdateWidgetData', res);
+ new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
+ $('.merge-request .detail-page-description .title').text(this.mr.title);
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body media">
+ <status-icon status="failed" :showDisabledButton="Boolean(mr.removeWIPPath)" />
+ <div class="media-body space-children">
+ <span class="bold">
+ This is a Work in Progress
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged"
+ aria-label="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged">
+ </i>
+ </span>
+ <button
+ v-if="mr.removeWIPPath"
+ @click="removeWIP"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-default btn-xs js-remove-wip">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Resolve WIP status
+ </button>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
new file mode 100644
index 00000000000..49340c232c8
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -0,0 +1,44 @@
+/**
+ * This file is the centerpiece of an attempt to reduce potential conflicts
+ * between the CE and EE versions of the MR widget. EE additions to the MR widget should
+ * be contained in the ee/vue_merge_request_widget directory, and should **extend**
+ * rather than mutate CE MR Widget code.
+ *
+ * This file should be the only source of conflicts between EE and CE. EE-only components should
+ * imported directly where they are needed, and import paths for EE extensions of CE components
+ * should overwrite import paths **without** changing the order of dependencies listed here.
+ */
+
+export { default as Vue } from 'vue';
+export { default as SmartInterval } from '~/smart_interval';
+export { default as WidgetHeader } from './components/mr_widget_header';
+export { default as WidgetMergeHelp } from './components/mr_widget_merge_help';
+export { default as WidgetPipeline } from './components/mr_widget_pipeline';
+export { default as WidgetDeployment } from './components/mr_widget_deployment';
+export { default as WidgetRelatedLinks } from './components/mr_widget_related_links';
+export { default as MergedState } from './components/states/mr_widget_merged';
+export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge';
+export { default as ClosedState } from './components/states/mr_widget_closed';
+export { default as MergingState } from './components/states/mr_widget_merging';
+export { default as WipState } from './components/states/mr_widget_wip';
+export { default as ArchivedState } from './components/states/mr_widget_archived';
+export { default as ConflictsState } from './components/states/mr_widget_conflicts';
+export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge';
+export { default as MissingBranchState } from './components/states/mr_widget_missing_branch';
+export { default as NotAllowedState } from './components/states/mr_widget_not_allowed';
+export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge';
+export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch';
+export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions';
+export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked';
+export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
+export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds';
+export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed';
+export { default as CheckingState } from './components/states/mr_widget_checking';
+export { default as MRWidgetStore } from './stores/mr_widget_store';
+export { default as MRWidgetService } from './services/mr_widget_service';
+export { default as eventHub } from './event_hub';
+export { default as getStateKey } from './stores/get_state_key';
+export { default as mrWidgetOptions } from './mr_widget_options';
+export { default as stateMaps } from './stores/state_maps';
+export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge';
+export { default as notify } from '../lib/utils/notify';
diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
new file mode 100644
index 00000000000..43ef468c303
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -0,0 +1,14 @@
+import {
+ Vue,
+ mrWidgetOptions,
+} from './dependencies';
+
+document.addEventListener('DOMContentLoaded', () => {
+ gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
+
+ const vm = new Vue(mrWidgetOptions);
+
+ window.gl.mrWidget = {
+ checkStatus: vm.checkStatus,
+ };
+});
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
new file mode 100644
index 00000000000..0042c48816f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -0,0 +1,260 @@
+/* global Flash */
+
+import {
+ WidgetHeader,
+ WidgetMergeHelp,
+ WidgetPipeline,
+ WidgetDeployment,
+ WidgetRelatedLinks,
+ MergedState,
+ ClosedState,
+ MergingState,
+ WipState,
+ ArchivedState,
+ ConflictsState,
+ NothingToMergeState,
+ MissingBranchState,
+ NotAllowedState,
+ ReadyToMergeState,
+ SHAMismatchState,
+ UnresolvedDiscussionsState,
+ PipelineBlockedState,
+ PipelineFailedState,
+ FailedToMerge,
+ MergeWhenPipelineSucceedsState,
+ AutoMergeFailed,
+ CheckingState,
+ MRWidgetStore,
+ MRWidgetService,
+ eventHub,
+ stateMaps,
+ SquashBeforeMerge,
+ notify,
+} from './dependencies';
+
+export default {
+ el: '#js-vue-mr-widget',
+ name: 'MRWidget',
+ props: {
+ mrData: {
+ type: Object,
+ required: false,
+ },
+ },
+ data() {
+ const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData);
+ const service = this.createService(store);
+ return {
+ mr: store,
+ service,
+ };
+ },
+ computed: {
+ componentName() {
+ return stateMaps.stateToComponentMap[this.mr.state];
+ },
+ shouldRenderMergeHelp() {
+ return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
+ },
+ shouldRenderPipelines() {
+ return Object.keys(this.mr.pipeline).length || this.mr.hasCI;
+ },
+ shouldRenderRelatedLinks() {
+ return this.mr.relatedLinks;
+ },
+ shouldRenderDeployments() {
+ return this.mr.deployments.length;
+ },
+ },
+ methods: {
+ createService(store) {
+ const endpoints = {
+ mergePath: store.mergePath,
+ mergeCheckPath: store.mergeCheckPath,
+ cancelAutoMergePath: store.cancelAutoMergePath,
+ removeWIPPath: store.removeWIPPath,
+ sourceBranchPath: store.sourceBranchPath,
+ ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
+ statusPath: store.statusPath,
+ mergeActionsContentPath: store.mergeActionsContentPath,
+ };
+ return new MRWidgetService(endpoints);
+ },
+ checkStatus(cb) {
+ this.service.checkStatus()
+ .then(res => res.json())
+ .then((res) => {
+ this.handleNotification(res);
+ this.mr.setData(res);
+ this.setFavicon();
+
+ if (cb) {
+ cb.call(null, res);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ initPolling() {
+ this.pollingInterval = new gl.SmartInterval({
+ callback: this.checkStatus,
+ startingInterval: 10000,
+ maxInterval: 30000,
+ hiddenInterval: 120000,
+ incrementByFactorOf: 5000,
+ });
+ },
+ initDeploymentsPolling() {
+ this.deploymentsInterval = new gl.SmartInterval({
+ callback: this.fetchDeployments,
+ startingInterval: 30000,
+ maxInterval: 120000,
+ hiddenInterval: 240000,
+ incrementByFactorOf: 15000,
+ immediateExecution: true,
+ });
+ },
+ setFavicon() {
+ if (this.mr.ciStatusFaviconPath) {
+ gl.utils.setFavicon(this.mr.ciStatusFaviconPath);
+ }
+ },
+ fetchDeployments() {
+ this.service.fetchDeployments()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.length) {
+ this.mr.deployments = res;
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line
+ });
+ },
+ fetchActionsContent() {
+ this.service.fetchMergeActionsContent()
+ .then((res) => {
+ if (res.body) {
+ const el = document.createElement('div');
+ el.innerHTML = res.body;
+ document.body.appendChild(el);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ handleNotification(data) {
+ if (data.ci_status === this.mr.ciStatus) return;
+
+ const label = data.pipeline.details.status.label;
+ const title = `Pipeline ${label}`;
+ const message = `Pipeline ${label} for "${data.title}"`;
+
+ notify.notifyMe(title, message, this.mr.gitlabLogo);
+ },
+ resumePolling() {
+ this.pollingInterval.resume();
+ },
+ stopPolling() {
+ this.pollingInterval.stopTimer();
+ },
+ bindEventHubListeners() {
+ eventHub.$on('MRWidgetUpdateRequested', (cb) => {
+ this.checkStatus(cb);
+ });
+
+ // `params` should be an Array contains a Boolean, like `[true]`
+ // Passing parameter as Boolean didn't work.
+ eventHub.$on('SetBranchRemoveFlag', (params) => {
+ this.mr.isRemovingSourceBranch = params[0];
+ });
+
+ eventHub.$on('FailedToMerge', (mergeError) => {
+ this.mr.state = 'failedToMerge';
+ this.mr.mergeError = mergeError;
+ });
+
+ eventHub.$on('UpdateWidgetData', (data) => {
+ this.mr.setData(data);
+ });
+
+ eventHub.$on('FetchActionsContent', () => {
+ this.fetchActionsContent();
+ });
+
+ eventHub.$on('EnablePolling', () => {
+ this.resumePolling();
+ });
+
+ eventHub.$on('DisablePolling', () => {
+ this.stopPolling();
+ });
+ },
+ handleMounted() {
+ this.setFavicon();
+ this.initDeploymentsPolling();
+ },
+ },
+ created() {
+ this.initPolling();
+ this.bindEventHubListeners();
+ },
+ mounted() {
+ this.handleMounted();
+ },
+ components: {
+ 'mr-widget-header': WidgetHeader,
+ 'mr-widget-merge-help': WidgetMergeHelp,
+ 'mr-widget-pipeline': WidgetPipeline,
+ 'mr-widget-deployment': WidgetDeployment,
+ 'mr-widget-related-links': WidgetRelatedLinks,
+ 'mr-widget-merged': MergedState,
+ 'mr-widget-closed': ClosedState,
+ 'mr-widget-merging': MergingState,
+ 'mr-widget-failed-to-merge': FailedToMerge,
+ 'mr-widget-wip': WipState,
+ 'mr-widget-archived': ArchivedState,
+ 'mr-widget-conflicts': ConflictsState,
+ 'mr-widget-nothing-to-merge': NothingToMergeState,
+ 'mr-widget-not-allowed': NotAllowedState,
+ 'mr-widget-missing-branch': MissingBranchState,
+ 'mr-widget-ready-to-merge': ReadyToMergeState,
+ 'mr-widget-sha-mismatch': SHAMismatchState,
+ 'mr-widget-squash-before-merge': SquashBeforeMerge,
+ 'mr-widget-checking': CheckingState,
+ 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
+ 'mr-widget-pipeline-blocked': PipelineBlockedState,
+ 'mr-widget-pipeline-failed': PipelineFailedState,
+ 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
+ 'mr-widget-auto-merge-failed': AutoMergeFailed,
+ },
+ template: `
+ <div class="mr-state-widget prepend-top-default">
+ <mr-widget-header :mr="mr" />
+ <mr-widget-pipeline
+ v-if="shouldRenderPipelines"
+ :mr="mr" />
+ <mr-widget-deployment
+ v-if="shouldRenderDeployments"
+ :mr="mr"
+ :service="service" />
+ <div class="mr-widget-section">
+ <component
+ :is="componentName"
+ :mr="mr"
+ :service="service" />
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :state="mr.state"
+ :related-links="mr.relatedLinks" />
+ </div>
+ <div
+ class="mr-widget-footer"
+ v-if="shouldRenderMergeHelp">
+ <mr-widget-merge-help />
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
new file mode 100644
index 00000000000..79c3d335679
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class MRWidgetService {
+ constructor(endpoints) {
+ this.mergeResource = Vue.resource(endpoints.mergePath);
+ this.mergeCheckResource = Vue.resource(endpoints.statusPath);
+ this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath);
+ this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
+ this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
+ this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath);
+ this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`);
+ this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
+ }
+
+ merge(data) {
+ return this.mergeResource.save(data);
+ }
+
+ cancelAutomaticMerge() {
+ return this.cancelAutoMergeResource.save();
+ }
+
+ removeWIP() {
+ return this.removeWIPResource.save();
+ }
+
+ removeSourceBranch() {
+ return this.removeSourceBranchResource.delete();
+ }
+
+ fetchDeployments() {
+ return this.deploymentsResource.get();
+ }
+
+ poll() {
+ return this.pollResource.get();
+ }
+
+ checkStatus() {
+ return this.mergeCheckResource.get();
+ }
+
+ fetchMergeActionsContent() {
+ return this.mergeActionsContentResource.get();
+ }
+
+ static stopEnvironment(url) {
+ return Vue.http.post(url);
+ }
+
+ static fetchMetrics(metricsUrl) {
+ return Vue.http.get(`${metricsUrl}.json`);
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
new file mode 100644
index 00000000000..7c15abfff10
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -0,0 +1,30 @@
+export default function deviseState(data) {
+ if (data.project_archived) {
+ return 'archived';
+ } else if (data.branch_missing) {
+ return 'missingBranch';
+ } else if (!data.commits_count) {
+ return 'nothingToMerge';
+ } else if (this.mergeStatus === 'unchecked') {
+ return 'checking';
+ } else if (data.has_conflicts) {
+ return 'conflicts';
+ } else if (data.work_in_progress) {
+ return 'workInProgress';
+ } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
+ return 'pipelineFailed';
+ } else if (this.hasMergeableDiscussionsState) {
+ return 'unresolvedDiscussions';
+ } else if (this.isPipelineBlocked) {
+ return 'pipelineBlocked';
+ } else if (this.hasSHAChanged) {
+ return 'shaMismatch';
+ } else if (this.mergeWhenPipelineSucceeds) {
+ return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
+ } else if (!this.canMerge) {
+ return 'notAllowedToMerge';
+ } else if (this.canBeMerged) {
+ return 'readyToMerge';
+ }
+ return null;
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
new file mode 100644
index 00000000000..fbea764b739
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -0,0 +1,142 @@
+import Timeago from 'timeago.js';
+import { getStateKey } from '../dependencies';
+
+export default class MergeRequestStore {
+
+ constructor(data) {
+ this.sha = data.diff_head_sha;
+ this.gitlabLogo = data.gitlabLogo;
+
+ this.setData(data);
+ }
+
+ setData(data) {
+ const currentUser = data.current_user;
+ const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
+
+ this.title = data.title;
+ this.targetBranch = data.target_branch;
+ this.sourceBranch = data.source_branch;
+ this.mergeStatus = data.merge_status;
+ this.commitMessage = data.merge_commit_message;
+ this.commitMessageWithDescription = data.merge_commit_message_with_description;
+ this.commitsCount = data.commits_count;
+ this.divergedCommitsCount = data.diverged_commits_count;
+ this.pipeline = data.pipeline || {};
+ this.deployments = this.deployments || data.deployments || [];
+
+ if (data.issues_links) {
+ const links = data.issues_links;
+ const { closing } = links;
+ const mentioned = links.mentioned_but_not_closing;
+ const assignToMe = links.assign_to_closing;
+
+ if (closing || mentioned || assignToMe) {
+ this.relatedLinks = { closing, mentioned, assignToMe };
+ }
+ }
+
+ this.updatedAt = data.updated_at;
+ this.mergedAt = MergeRequestStore.getEventDate(data.merge_event);
+ this.closedAt = MergeRequestStore.getEventDate(data.closed_event);
+ this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event);
+ this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event);
+ this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} });
+ this.mergeUserId = data.merge_user_id;
+ this.currentUserId = gon.current_user_id;
+ this.sourceBranchPath = data.source_branch_path;
+ this.sourceBranchLink = data.source_branch_with_namespace_link;
+ this.mergeError = data.merge_error;
+ this.targetBranchPath = data.target_branch_commits_path;
+ this.targetBranchTreePath = data.target_branch_tree_path;
+ this.conflictResolutionPath = data.conflict_resolution_path;
+ this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
+ this.removeWIPPath = data.remove_wip_path;
+ this.sourceBranchRemoved = !data.source_branch_exists;
+ this.shouldRemoveSourceBranch = data.remove_source_branch || false;
+ this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
+ this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
+ this.mergePath = data.merge_path;
+ this.statusPath = data.status_path;
+ this.emailPatchesPath = data.email_patches_path;
+ this.plainDiffPath = data.plain_diff_path;
+ this.newBlobPath = data.new_blob_path;
+ this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
+ this.mergeCheckPath = data.merge_check_path;
+ this.mergeActionsContentPath = data.commit_change_content_path;
+ this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
+ this.isOpen = data.state === 'opened';
+ this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
+ this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
+ this.canMerge = !!data.merge_path;
+ this.canCreateIssue = currentUser.can_create_issue || false;
+ this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
+ this.hasSHAChanged = this.sha !== data.diff_head_sha;
+ this.canBeMerged = data.can_be_merged || false;
+ this.mergeOngoing = data.merge_ongoing;
+
+ // Cherry-pick and Revert actions related
+ this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
+ this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
+ this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
+ this.revertInForkPath = currentUser.revert_in_fork_path;
+
+ // CI related
+ this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
+ this.hasCI = data.has_ci;
+ this.ciStatus = data.ci_status;
+ this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false;
+ this.pipelineDetailedStatus = pipelineStatus;
+ this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
+ this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
+ this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+
+ this.setState(data);
+ }
+
+ setState(data) {
+ if (this.mergeOngoing) {
+ this.state = 'merging';
+ return;
+ }
+
+ if (this.isOpen) {
+ this.state = getStateKey.call(this, data);
+ } else {
+ switch (data.state) {
+ case 'merged':
+ this.state = 'merged';
+ break;
+ case 'closed':
+ this.state = 'closed';
+ break;
+ default:
+ this.state = null;
+ }
+ }
+ }
+
+ static getAuthorObject(event) {
+ if (!event) {
+ return {};
+ }
+
+ return {
+ name: event.author.name || '',
+ username: event.author.username || '',
+ webUrl: event.author.web_url || '',
+ avatarUrl: event.author.avatar_url || '',
+ };
+ }
+
+ static getEventDate(event) {
+ const timeagoInstance = new Timeago();
+
+ if (!event) {
+ return '';
+ }
+
+ return timeagoInstance.format(event.updated_at);
+ }
+
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
new file mode 100644
index 00000000000..9074a064a6d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -0,0 +1,37 @@
+const stateToComponentMap = {
+ merged: 'mr-widget-merged',
+ closed: 'mr-widget-closed',
+ merging: 'mr-widget-merging',
+ conflicts: 'mr-widget-conflicts',
+ missingBranch: 'mr-widget-missing-branch',
+ workInProgress: 'mr-widget-wip',
+ readyToMerge: 'mr-widget-ready-to-merge',
+ nothingToMerge: 'mr-widget-nothing-to-merge',
+ notAllowedToMerge: 'mr-widget-not-allowed',
+ archived: 'mr-widget-archived',
+ checking: 'mr-widget-checking',
+ unresolvedDiscussions: 'mr-widget-unresolved-discussions',
+ pipelineBlocked: 'mr-widget-pipeline-blocked',
+ pipelineFailed: 'mr-widget-pipeline-failed',
+ mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
+ failedToMerge: 'mr-widget-failed-to-merge',
+ autoMergeFailed: 'mr-widget-auto-merge-failed',
+ shaMismatch: 'mr-widget-sha-mismatch',
+};
+
+const statesToShowHelpWidget = [
+ 'merging',
+ 'conflicts',
+ 'workInProgress',
+ 'readyToMerge',
+ 'checking',
+ 'unresolvedDiscussions',
+ 'pipelineFailed',
+ 'pipelineBlocked',
+ 'autoMergeFailed',
+];
+
+export default {
+ stateToComponentMap,
+ statesToShowHelpWidget,
+};
diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js
new file mode 100644
index 00000000000..b21f0ab49fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/ci_action_icons.js
@@ -0,0 +1,21 @@
+import cancelSVG from 'icons/_icon_action_cancel.svg';
+import retrySVG from 'icons/_icon_action_retry.svg';
+import playSVG from 'icons/_icon_action_play.svg';
+import stopSVG from 'icons/_icon_action_stop.svg';
+
+/**
+ * For the provided action returns the respective SVG
+ *
+ * @param {String} action
+ * @return {SVG|String}
+ */
+export default function getActionIcon(action) {
+ const icons = {
+ icon_action_cancel: cancelSVG,
+ icon_action_play: playSVG,
+ icon_action_retry: retrySVG,
+ icon_action_stop: stopSVG,
+ };
+
+ return icons[action] || '';
+}
diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js
new file mode 100644
index 00000000000..d9d0cad38e4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/ci_status_icons.js
@@ -0,0 +1,43 @@
+import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
+import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
+import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
+import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
+import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
+import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
+import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
+import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
+import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
+
+import CANCELED_SVG from 'icons/_icon_status_canceled.svg';
+import CREATED_SVG from 'icons/_icon_status_created.svg';
+import FAILED_SVG from 'icons/_icon_status_failed.svg';
+import MANUAL_SVG from 'icons/_icon_status_manual.svg';
+import PENDING_SVG from 'icons/_icon_status_pending.svg';
+import RUNNING_SVG from 'icons/_icon_status_running.svg';
+import SKIPPED_SVG from 'icons/_icon_status_skipped.svg';
+import SUCCESS_SVG from 'icons/_icon_status_success.svg';
+import WARNING_SVG from 'icons/_icon_status_warning.svg';
+
+export const borderlessStatusIconEntityMap = {
+ icon_status_canceled: BORDERLESS_CANCELED_SVG,
+ icon_status_created: BORDERLESS_CREATED_SVG,
+ icon_status_failed: BORDERLESS_FAILED_SVG,
+ icon_status_manual: BORDERLESS_MANUAL_SVG,
+ icon_status_pending: BORDERLESS_PENDING_SVG,
+ icon_status_running: BORDERLESS_RUNNING_SVG,
+ icon_status_skipped: BORDERLESS_SKIPPED_SVG,
+ icon_status_success: BORDERLESS_SUCCESS_SVG,
+ icon_status_warning: BORDERLESS_WARNING_SVG,
+};
+
+export const statusIconEntityMap = {
+ icon_status_canceled: CANCELED_SVG,
+ icon_status_created: CREATED_SVG,
+ icon_status_failed: FAILED_SVG,
+ icon_status_manual: MANUAL_SVG,
+ icon_status_pending: PENDING_SVG,
+ icon_status_running: RUNNING_SVG,
+ icon_status_skipped: SKIPPED_SVG,
+ icon_status_success: SUCCESS_SVG,
+ icon_status_warning: WARNING_SVG,
+};
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
new file mode 100644
index 00000000000..caa28bff6db
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -0,0 +1,52 @@
+<script>
+import ciIcon from './ci_icon.vue';
+/**
+ * Renders CI Badge link with CI icon and status text based on
+ * API response shared between all places where it is used.
+ *
+ * Receives status object containing:
+ * status: {
+ * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
+ * group:"running" // used for CSS class
+ * icon: "icon_status_running" // used to render the icon
+ * label:"running" // used for potential tooltip
+ * text:"running" // text rendered
+ * }
+ *
+ * Used in:
+ * - Pipelines table - first column
+ * - Jobs table - first column
+ * - Pipeline show view - header
+ * - Job show view - header
+ * - MR widget
+ */
+
+export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ ciIcon,
+ },
+
+ computed: {
+ cssClass() {
+ const className = this.status.group;
+
+ return className ? `ci-status ci-${this.status.group}` : 'ci-status';
+ },
+ },
+};
+</script>
+<template>
+ <a
+ :href="status.details_path"
+ :class="cssClass">
+ <ci-icon :status="status" />
+ {{status.text}}
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
new file mode 100644
index 00000000000..ec88119e16c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -0,0 +1,50 @@
+<script>
+ import { statusIconEntityMap } from '../ci_status_icons';
+
+ /**
+ * Renders CI icon based on API response shared between all places where it is used.
+ *
+ * Receives status object containing:
+ * status: {
+ * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
+ * group:"running" // used for CSS class
+ * icon: "icon_status_running" // used to render the icon
+ * label:"running" // used for potential tooltip
+ * text:"running" // text rendered
+ * }
+ *
+ * Used in:
+ * - Pipelines table Badge
+ * - Pipelines table mini graph
+ * - Pipeline graph
+ * - Pipeline show view badge
+ * - Jobs table
+ * - Jobs show view header
+ * - Jobs show view sidebar
+ */
+ export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ statusIconSvg() {
+ return statusIconEntityMap[this.status.icon];
+ },
+
+ cssClass() {
+ const status = this.status.group;
+ return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
+ },
+ },
+ };
+</script>
+<template>
+ <span
+ :class="cssClass"
+ v-html="statusIconSvg">
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
deleted file mode 100644
index fb68abd95a2..00000000000
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import commitIconSvg from 'icons/_icon_commit.svg';
-
-export default {
- props: {
- /**
- * Indicates the existance of a tag.
- * Used to render the correct icon, if true will render `fa-tag` icon,
- * if false will render `fa-code-fork` icon.
- */
- tag: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- /**
- * If provided is used to render the branch name and url.
- * Should contain the following properties:
- * name
- * ref_url
- */
- commitRef: {
- type: Object,
- required: false,
- default: () => ({}),
- },
-
- /**
- * Used to link to the commit sha.
- */
- commitUrl: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * Used to show the commit short sha that links to the commit url.
- */
- shortSha: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * If provided shows the commit tile.
- */
- title: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * If provided renders information about the author of the commit.
- * When provided should include:
- * `avatar_url` to render the avatar icon
- * `web_url` to link to user profile
- * `username` to render alt and title tags
- */
- author: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
-
- computed: {
- /**
- * Used to verify if all the properties needed to render the commit
- * ref section were provided.
- *
- * TODO: Improve this! Use lodash _.has when we have it.
- *
- * @returns {Boolean}
- */
- hasCommitRef() {
- return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
- },
-
- /**
- * Used to verify if all the properties needed to render the commit
- * author section were provided.
- *
- * TODO: Improve this! Use lodash _.has when we have it.
- *
- * @returns {Boolean}
- */
- hasAuthor() {
- return this.author &&
- this.author.avatar_url &&
- this.author.web_url &&
- this.author.username;
- },
-
- /**
- * If information about the author is provided will return a string
- * to be rendered as the alt attribute of the img tag.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- return this.author &&
- this.author.username ? `${this.author.username}'s avatar` : null;
- },
- },
-
- data() {
- return { commitIconSvg };
- },
-
- template: `
- <div class="branch-commit">
-
- <div v-if="hasCommitRef" class="icon-container">
- <i v-if="tag" class="fa fa-tag"></i>
- <i v-if="!tag" class="fa fa-code-fork"></i>
- </div>
-
- <a v-if="hasCommitRef"
- class="monospace branch-name"
- :href="commitRef.ref_url">
- {{commitRef.name}}
- </a>
-
- <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
-
- <a class="commit-id monospace"
- :href="commitUrl">
- {{shortSha}}
- </a>
-
- <p class="commit-title">
- <span v-if="title">
- <a v-if="hasAuthor"
- class="avatar-image-container"
- :href="author.web_url">
- <img
- class="avatar has-tooltip s20"
- :src="author.avatar_url"
- :alt="userImageAltDescription"
- :title="author.username" />
- </a>
-
- <a class="commit-row-message"
- :href="commitUrl">
- {{title}}
- </a>
- </span>
- <span v-else>
- Cant find HEAD commit for this branch
- </span>
- </p>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
new file mode 100644
index 00000000000..262584769e0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -0,0 +1,166 @@
+<script>
+ import commitIconSvg from 'icons/_icon_commit.svg';
+ import userAvatarLink from './user_avatar/user_avatar_link.vue';
+
+ export default {
+ props: {
+ /**
+ * Indicates the existance of a tag.
+ * Used to render the correct icon, if true will render `fa-tag` icon,
+ * if false will render `fa-code-fork` icon.
+ */
+ tag: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ /**
+ * If provided is used to render the branch name and url.
+ * Should contain the following properties:
+ * name
+ * ref_url
+ */
+ commitRef: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ /**
+ * Used to link to the commit sha.
+ */
+ commitUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * Used to show the commit short sha that links to the commit url.
+ */
+ shortSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ /**
+ * If provided shows the commit tile.
+ */
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ /**
+ * If provided renders information about the author of the commit.
+ * When provided should include:
+ * `avatar_url` to render the avatar icon
+ * `web_url` to link to user profile
+ * `username` to render alt and title tags
+ */
+ author: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * ref section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasCommitRef() {
+ return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
+ },
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * author section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasAuthor() {
+ return this.author &&
+ this.author.avatar_url &&
+ this.author.path &&
+ this.author.username;
+ },
+ /**
+ * If information about the author is provided will return a string
+ * to be rendered as the alt attribute of the img tag.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ return this.author &&
+ this.author.username ? `${this.author.username}'s avatar` : null;
+ },
+ },
+ data() {
+ return { commitIconSvg };
+ },
+ components: {
+ userAvatarLink,
+ },
+ };
+</script>
+<template>
+ <div class="branch-commit">
+ <div v-if="hasCommitRef" class="icon-container hidden-xs">
+ <i
+ v-if="tag"
+ class="fa fa-tag"
+ aria-hidden="true">
+ </i>
+ <i
+ v-if="!tag"
+ class="fa fa-code-fork"
+ aria-hidden="true">
+ </i>
+ </div>
+
+ <a
+ v-if="hasCommitRef"
+ class="ref-name hidden-xs"
+ :href="commitRef.ref_url">
+ {{commitRef.name}}
+ </a>
+
+ <div
+ v-html="commitIconSvg"
+ class="commit-icon js-commit-icon">
+ </div>
+
+ <a
+ class="commit-sha"
+ :href="commitUrl">
+ {{shortSha}}
+ </a>
+
+ <div class="commit-title flex-truncate-parent">
+ <span
+ v-if="title"
+ class="flex-truncate-child">
+ <user-avatar-link
+ v-if="hasAuthor"
+ class="avatar-image-container"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="author.username"
+ />
+ <a class="commit-row-message"
+ :href="commitUrl">
+ {{title}}
+ </a>
+ </span>
+ <span v-else>
+ Cant find HEAD commit for this branch
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
new file mode 100644
index 00000000000..d305bd6acdc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -0,0 +1,159 @@
+<script>
+import ciIconBadge from './ci_badge_link.vue';
+import loadingIcon from './loading_icon.vue';
+import timeagoTooltip from './time_ago_tooltip.vue';
+import tooltip from '../directives/tooltip';
+import userAvatarImage from './user_avatar/user_avatar_image.vue';
+
+/**
+ * Renders header component for job and pipeline page based on UI mockups
+ *
+ * Used in:
+ * - job show page
+ * - pipeline show page
+ */
+export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ itemName: {
+ type: String,
+ required: true,
+ },
+ itemId: {
+ type: Number,
+ required: true,
+ },
+ time: {
+ type: String,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ hasSidebarButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ components: {
+ ciIconBadge,
+ loadingIcon,
+ timeagoTooltip,
+ userAvatarImage,
+ },
+
+ computed: {
+ userAvatarAltText() {
+ return `${this.user.name}'s avatar`;
+ },
+ },
+
+ methods: {
+ onClickAction(action) {
+ this.$emit('actionClicked', action);
+ },
+ },
+};
+</script>
+
+<template>
+ <header class="page-content-header ci-header-container">
+ <section class="header-main-content">
+
+ <ci-icon-badge :status="status" />
+
+ <strong>
+ {{itemName}} #{{itemId}}
+ </strong>
+
+ triggered
+
+ <timeago-tooltip :time="time" />
+
+ by
+
+ <template v-if="user">
+ <a
+ v-tooltip
+ :href="user.path"
+ :title="user.email"
+ class="js-user-link commit-committer-link">
+
+ <user-avatar-image
+ :img-src="user.avatar_url"
+ :img-alt="userAvatarAltText"
+ :tooltip-text="user.name"
+ :img-size="24"
+ />
+
+ {{user.name}}
+ </a>
+ </template>
+ </section>
+
+ <section
+ class="header-action-buttons"
+ v-if="actions.length">
+ <template
+ v-for="action in actions">
+ <a
+ v-if="action.type === 'link'"
+ :href="action.path"
+ :class="action.cssClass">
+ {{action.label}}
+ </a>
+
+ <a
+ v-else-if="action.type === 'ujs-link'"
+ :href="action.path"
+ data-method="post"
+ rel="nofollow"
+ :class="action.cssClass">
+ {{action.label}}
+ </a>
+
+ <button
+ v-else-if="action.type === 'button'"
+ @click="onClickAction(action)"
+ :disabled="action.isLoading"
+ :class="action.cssClass"
+ type="button">
+ {{action.label}}
+ <i
+ v-show="action.isLoading"
+ class="fa fa-spin fa-spinner"
+ aria-hidden="true">
+ </i>
+ </button>
+ </template>
+ <button
+ v-if="hasSidebarButton"
+ type="button"
+ class="btn btn-default visible-xs-block visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
+ aria-label="Toggle Sidebar"
+ id="toggleSidebar">
+ <i
+ class="fa fa-angle-double-left"
+ aria-hidden="true"
+ aria-labelledby="toggleSidebar">
+ </i>
+ </button>
+ </section>
+ </header>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue
new file mode 100644
index 00000000000..15581d5c2a0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -0,0 +1,44 @@
+<script>
+ export default {
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: 'Loading',
+ },
+
+ size: {
+ type: String,
+ required: false,
+ default: '1',
+ },
+
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ rootElementType() {
+ return this.inline ? 'span' : 'div';
+ },
+ cssClass() {
+ return `fa-${this.size}x`;
+ },
+ },
+ };
+</script>
+<template>
+ <component
+ :is="this.rootElementType"
+ class="text-center">
+ <i
+ class="fa fa-spin fa-spinner"
+ :class="cssClass"
+ aria-hidden="true"
+ :aria-label="label">
+ </i>
+ </component>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
new file mode 100644
index 00000000000..4e10bbc7408
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -0,0 +1,112 @@
+<script>
+ /* global Flash */
+ import markdownHeader from './header.vue';
+ import markdownToolbar from './toolbar.vue';
+
+ export default {
+ props: {
+ markdownPreviewUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ markdownPreview: '',
+ markdownPreviewLoading: false,
+ previewMarkdown: false,
+ };
+ },
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ },
+ methods: {
+ toggleMarkdownPreview() {
+ this.previewMarkdown = !this.previewMarkdown;
+
+ if (!this.previewMarkdown) {
+ this.markdownPreview = '';
+ } else {
+ this.markdownPreviewLoading = true;
+ this.$http.post(
+ this.markdownPreviewUrl,
+ {
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ text: this.$slots.textarea[0].elm.value,
+ },
+ )
+ .then(resp => resp.json())
+ .then((data) => {
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ }
+ },
+ },
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new gl.GLForm($(this.$refs['gl-form']), true);
+ },
+ beforeDestroy() {
+ const glForm = $(this.$refs['gl-form']).data('gl-form');
+ if (glForm) {
+ glForm.destroy();
+ }
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
+ ref="gl-form">
+ <markdown-header
+ :preview-markdown="previewMarkdown"
+ @toggle-markdown="toggleMarkdownPreview" />
+ <div
+ class="md-write-holder"
+ v-show="!previewMarkdown">
+ <div class="zen-backdrop">
+ <slot name="textarea"></slot>
+ <a
+ class="zen-control zen-control-leave js-zen-leave"
+ href="#"
+ aria-label="Enter zen mode">
+ <i
+ class="fa fa-compress"
+ aria-hidden="true">
+ </i>
+ </a>
+ <markdown-toolbar
+ :markdown-docs="markdownDocs" />
+ </div>
+ </div>
+ <div
+ class="md md-preview-holder md-preview"
+ v-show="previewMarkdown">
+ <div
+ ref="markdown-preview"
+ v-html="markdownPreview">
+ </div>
+ <span v-if="markdownPreviewLoading">
+ Loading...
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
new file mode 100644
index 00000000000..5bf2a90cc3b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -0,0 +1,113 @@
+<script>
+ import tooltip from '../../directives/tooltip';
+ import toolbarButton from './toolbar_button.vue';
+
+ export default {
+ props: {
+ previewMarkdown: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ components: {
+ toolbarButton,
+ },
+ methods: {
+ toggleMarkdownPreview(e, form) {
+ if (form && !form.find('.js-vue-markdown-field').length) {
+ return;
+ } else if (e.target.blur) {
+ e.target.blur();
+ }
+
+ this.$emit('toggle-markdown');
+ },
+ },
+ mounted() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ beforeDestroy() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ };
+</script>
+
+<template>
+ <div class="md-header">
+ <ul class="nav-links clearfix">
+ <li :class="{ active: !previewMarkdown }">
+ <a
+ href="#md-write-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Write
+ </a>
+ </li>
+ <li :class="{ active: previewMarkdown }">
+ <a
+ href="#md-preview-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Preview
+ </a>
+ </li>
+ <li class="pull-right">
+ <div class="toolbar-group">
+ <toolbar-button
+ tag="**"
+ button-title="Add bold text"
+ icon="bold" />
+ <toolbar-button
+ tag="*"
+ button-title="Add italic text"
+ icon="italic" />
+ <toolbar-button
+ tag="> "
+ :prepend="true"
+ button-title="Insert a quote"
+ icon="quote-right" />
+ <toolbar-button
+ tag="`"
+ tag-block="```"
+ button-title="Insert code"
+ icon="code" />
+ <toolbar-button
+ tag="* "
+ :prepend="true"
+ button-title="Add a bullet list"
+ icon="list-ul" />
+ <toolbar-button
+ tag="1. "
+ :prepend="true"
+ button-title="Add a numbered list"
+ icon="list-ol" />
+ <toolbar-button
+ tag="* [ ] "
+ :prepend="true"
+ button-title="Add a task list"
+ icon="check-square-o" />
+ </div>
+ <div class="toolbar-group">
+ <button
+ v-tooltip
+ aria-label="Go full screen"
+ class="toolbar-btn js-zen-enter"
+ data-container="body"
+ tabindex="-1"
+ title="Go full screen"
+ type="button">
+ <i
+ aria-hidden="true"
+ class="fa fa-arrows-alt fa-fw">
+ </i>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
new file mode 100644
index 00000000000..93252293ba6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="comment-toolbar clearfix">
+ <div class="toolbar-text">
+ <a
+ :href="markdownDocs"
+ target="_blank"
+ tabindex="-1">
+ Markdown is supported
+ </a>
+ </div>
+ <button
+ class="toolbar-button markdown-selector"
+ type="button"
+ tabindex="-1">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true">
+ </i>
+ Attach a file
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
new file mode 100644
index 00000000000..f7da7ebfcfe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -0,0 +1,58 @@
+<script>
+ import tooltip from '../../directives/tooltip';
+
+ export default {
+ props: {
+ buttonTitle: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: true,
+ },
+ tagBlock: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ prepend: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ iconClass() {
+ return `fa-${this.icon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ v-tooltip
+ type="button"
+ class="toolbar-btn js-md hidden-xs"
+ tabindex="-1"
+ data-container="body"
+ :data-md-tag="tag"
+ :data-md-block="tagBlock"
+ :data-md-prepend="prepend"
+ :title="buttonTitle"
+ :aria-label="buttonTitle">
+ <i
+ aria-hidden="true"
+ class="fa fa-fw"
+ :class="iconClass">
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js
new file mode 100644
index 00000000000..643b77e04c7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.js
@@ -0,0 +1,115 @@
+export default {
+ name: 'MemoryGraph',
+ props: {
+ metrics: { type: Array, required: true },
+ deploymentTime: { type: Number, required: true },
+ width: { type: String, required: true },
+ height: { type: String, required: true },
+ },
+ data() {
+ return {
+ pathD: '',
+ pathViewBox: '',
+ dotX: '',
+ dotY: '',
+ };
+ },
+ computed: {
+ getFormattedMedian() {
+ const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000);
+ return `Deployed ${deployedSince}`;
+ },
+ },
+ methods: {
+ /**
+ * Returns metric value index in metrics array
+ * with timestamp closest to matching median
+ */
+ getMedianMetricIndex(median, metrics) {
+ let matchIndex = 0;
+ let timestampDiff = 0;
+ let smallestDiff = 0;
+
+ const metricTimestamps = metrics.map(v => v[0]);
+
+ // Find metric timestamp which is closest to deploymentTime
+ timestampDiff = Math.abs(metricTimestamps[0] - median);
+ metricTimestamps.forEach((timestamp, index) => {
+ if (index === 0) { // Skip first element
+ return;
+ }
+
+ smallestDiff = Math.abs(timestamp - median);
+ if (smallestDiff < timestampDiff) {
+ matchIndex = index;
+ timestampDiff = smallestDiff;
+ }
+ });
+
+ return matchIndex;
+ },
+
+ /**
+ * Get Graph Plotting values to render Line and Dot
+ */
+ getGraphPlotValues(median, metrics) {
+ const renderData = metrics.map(v => v[1]);
+ const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
+ let cx = 0;
+ let cy = 0;
+
+ // Find Maximum and Minimum values from `renderData` array
+ const maxMemory = Math.max.apply(null, renderData);
+ const minMemory = Math.min.apply(null, renderData);
+
+ // Find difference between extreme ends
+ const diff = maxMemory - minMemory;
+ const lineWidth = renderData.length;
+
+ // Iterate over metrics values and perform following
+ // 1. Find x & y co-ords for deploymentTime's memory value
+ // 2. Return line path against maxMemory
+ const linePath = renderData.map((y, x) => {
+ if (medianMetricIndex === x) {
+ cx = x;
+ cy = maxMemory - y;
+ }
+ return `${x} ${maxMemory - y}`;
+ });
+
+ return {
+ pathD: linePath,
+ pathViewBox: {
+ lineWidth,
+ diff,
+ },
+ dotX: cx,
+ dotY: cy,
+ };
+ },
+
+ /**
+ * Render Graph based on provided median and metrics values
+ */
+ renderGraph(median, metrics) {
+ const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
+
+ // Set props and update graph on UI.
+ this.pathD = `M ${pathD}`;
+ this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
+ this.dotX = dotX;
+ this.dotY = dotY;
+ },
+ },
+ mounted() {
+ this.renderGraph(this.deploymentTime, this.metrics);
+ },
+ template: `
+ <div class="memory-graph-container">
+ <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
+ <path :d="pathD" :viewBox="pathViewBox" />
+ <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" />
+ </svg>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js
deleted file mode 100644
index afd8d7acf6b..00000000000
--- a/app/assets/javascripts/vue_shared/components/pipelines_table.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import PipelinesTableRowComponent from './pipelines_table_row';
-
-/**
- * Pipelines Table Component.
- *
- * Given an array of objects, renders a table.
- */
-export default {
- props: {
- pipelines: {
- type: Array,
- required: true,
- default: () => ([]),
- },
-
- service: {
- type: Object,
- required: true,
- },
- },
-
- components: {
- 'pipelines-table-row-component': PipelinesTableRowComponent,
- },
-
- template: `
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="js-pipeline-status pipeline-status">Status</th>
- <th class="js-pipeline-info pipeline-info">Pipeline</th>
- <th class="js-pipeline-commit pipeline-commit">Commit</th>
- <th class="js-pipeline-stages pipeline-stages">Stages</th>
- <th class="js-pipeline-date pipeline-date"></th>
- <th class="js-pipeline-actions pipeline-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template v-for="model in pipelines"
- v-bind:model="model">
- <tr is="pipelines-table-row-component"
- :pipeline="model"
- :service="service"></tr>
- </template>
- </tbody>
- </table>
- `,
-};
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
deleted file mode 100644
index 79806bc7204..00000000000
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ /dev/null
@@ -1,255 +0,0 @@
-/* eslint-disable no-param-reassign */
-import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
-import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
-import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
-import PipelinesStatusComponent from '../../pipelines/components/status';
-import PipelinesStageComponent from '../../pipelines/components/stage';
-import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
-import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
-import CommitComponent from './commit';
-
-/**
- * Pipeline table row.
- *
- * Given the received object renders a table row in the pipelines' table.
- */
-export default {
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
-
- service: {
- type: Object,
- required: true,
- },
- },
-
- components: {
- 'async-button-component': AsyncButtonComponent,
- 'pipelines-actions-component': PipelinesActionsComponent,
- 'pipelines-artifacts-component': PipelinesArtifactsComponent,
- 'commit-component': CommitComponent,
- 'dropdown-stage': PipelinesStageComponent,
- 'pipeline-url': PipelinesUrlComponent,
- 'status-scope': PipelinesStatusComponent,
- 'time-ago': PipelinesTimeagoComponent,
- },
-
- computed: {
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
- * 3. If GitLab user does not have avatar he/she might have a Gravatar
- * 4. If committer is not a GitLab User he/she can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline &&
- this.pipeline.commit &&
- this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // he/she can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar he/she might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- });
- }
- }
-
- // 4. If committer is not a GitLab User he/she can have a Gravatar
- if (this.pipeline &&
- this.pipeline.commit) {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- web_url: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
-
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.pipeline.ref &&
- this.pipeline.ref.tag) {
- return this.pipeline.ref.tag;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit ref.
- * Needed to render the commit component column.
- *
- * Matches `path` prop sent in the API to `ref_url` prop needed
- * in the commit component.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.pipeline.ref) {
- return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
- if (prop === 'path') {
- accumulator.ref_url = this.pipeline.ref[prop];
- } else {
- accumulator[prop] = this.pipeline.ref[prop];
- }
- return accumulator;
- }, {});
- }
-
- return undefined;
- },
-
- /**
- * If provided, returns the commit url.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.pipeline.commit &&
- this.pipeline.commit.commit_path) {
- return this.pipeline.commit.commit_path;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit short sha.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.pipeline.commit &&
- this.pipeline.commit.short_id) {
- return this.pipeline.commit.short_id;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit title.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.pipeline.commit &&
- this.pipeline.commit.title) {
- return this.pipeline.commit.title;
- }
- return undefined;
- },
-
- /**
- * Timeago components expects a number
- *
- * @return {type} description
- */
- pipelineDuration() {
- if (this.pipeline.details && this.pipeline.details.duration) {
- return this.pipeline.details.duration;
- }
-
- return 0;
- },
-
- /**
- * Timeago component expects a String.
- *
- * @return {String}
- */
- pipelineFinishedAt() {
- if (this.pipeline.details && this.pipeline.details.finished_at) {
- return this.pipeline.details.finished_at;
- }
-
- return '';
- },
- },
-
- template: `
- <tr class="commit">
- <status-scope :pipeline="pipeline"/>
-
- <pipeline-url :pipeline="pipeline"></pipeline-url>
-
- <td>
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"/>
- </td>
-
- <td class="stage-cell">
- <div class="stage-container dropdown js-mini-pipeline-graph"
- v-if="pipeline.details.stages.length > 0"
- v-for="stage in pipeline.details.stages">
- <dropdown-stage :stage="stage"/>
- </div>
- </td>
-
- <time-ago
- :duration="pipelineDuration"
- :finished-time="pipelineFinishedAt" />
-
- <td class="pipeline-actions">
- <div class="pull-right btn-group">
- <pipelines-actions-component
- v-if="pipeline.details.manual_actions.length"
- :actions="pipeline.details.manual_actions"
- :service="service" />
-
- <pipelines-artifacts-component
- v-if="pipeline.details.artifacts.length"
- :artifacts="pipeline.details.artifacts" />
-
- <async-button-component
- v-if="pipeline.flags.retryable"
- :service="service"
- :endpoint="pipeline.retry_path"
- css-class="js-pipelines-retry-button btn-default btn-retry"
- title="Retry"
- icon="repeat" />
-
- <async-button-component
- v-if="pipeline.flags.cancelable"
- :service="service"
- :endpoint="pipeline.cancel_path"
- css-class="js-pipelines-cancel-button btn-remove"
- title="Cancel"
- icon="remove"
- confirm-action-message="Are you sure you want to cancel this pipeline?" />
- </div>
- </td>
- </tr>
- `,
-};
diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue
new file mode 100644
index 00000000000..994b33bc1c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue
@@ -0,0 +1,85 @@
+<script>
+export default {
+ name: 'popup-dialog',
+
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ body: {
+ type: String,
+ required: true,
+ },
+ kind: {
+ type: String,
+ required: false,
+ default: 'primary',
+ },
+ closeButtonLabel: {
+ type: String,
+ required: false,
+ default: 'Cancel',
+ },
+ primaryButtonLabel: {
+ type: String,
+ required: true,
+ },
+ },
+
+ computed: {
+ btnKindClass() {
+ return {
+ [`btn-${this.kind}`]: true,
+ };
+ },
+ },
+
+ methods: {
+ close() {
+ this.$emit('toggle', false);
+ },
+ emitSubmit(status) {
+ this.$emit('submit', status);
+ },
+ },
+};
+</script>
+
+<template>
+<div
+ class="modal popup-dialog"
+ role="dialog"
+ tabindex="-1">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button"
+ class="close"
+ @click="close"
+ aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ <h4 class="modal-title">{{this.title}}</h4>
+ </div>
+ <div class="modal-body">
+ <p>{{this.body}}</p>
+ </div>
+ <div class="modal-footer">
+ <button
+ type="button"
+ class="btn btn-default"
+ @click="emitSubmit(false)">
+ {{closeButtonLabel}}
+ </button>
+ <button type="button"
+ class="btn"
+ :class="btnKindClass"
+ @click="emitSubmit(true)">
+ {{primaryButtonLabel}}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.js
deleted file mode 100644
index ebb14912b00..00000000000
--- a/app/assets/javascripts/vue_shared/components/table_pagination.js
+++ /dev/null
@@ -1,135 +0,0 @@
-const PAGINATION_UI_BUTTON_LIMIT = 4;
-const UI_LIMIT = 6;
-const SPREAD = '...';
-const PREV = 'Prev';
-const NEXT = 'Next';
-const FIRST = '« First';
-const LAST = 'Last »';
-
-export default {
- props: {
- /**
- This function will take the information given by the pagination component
-
- Here is an example `change` method:
-
- change(pagenum) {
- gl.utils.visitUrl(`?page=${pagenum}`);
- },
- */
- change: {
- type: Function,
- required: true,
- },
-
- /**
- pageInfo will come from the headers of the API call
- in the `.then` clause of the VueResource API call
- there should be a function that contructs the pageInfo for this component
-
- This is an example:
-
- const pageInfo = headers => ({
- perPage: +headers['X-Per-Page'],
- page: +headers['X-Page'],
- total: +headers['X-Total'],
- totalPages: +headers['X-Total-Pages'],
- nextPage: +headers['X-Next-Page'],
- previousPage: +headers['X-Prev-Page'],
- });
- */
- pageInfo: {
- type: Object,
- required: true,
- },
- },
- methods: {
- changePage(e) {
- const text = e.target.innerText;
- const { totalPages, nextPage, previousPage } = this.pageInfo;
-
- switch (text) {
- case SPREAD:
- break;
- case LAST:
- this.change(totalPages);
- break;
- case NEXT:
- this.change(nextPage);
- break;
- case PREV:
- this.change(previousPage);
- break;
- case FIRST:
- this.change(1);
- break;
- default:
- this.change(+text);
- break;
- }
- },
- },
- computed: {
- prev() {
- return this.pageInfo.previousPage;
- },
- next() {
- return this.pageInfo.nextPage;
- },
- getItems() {
- const total = this.pageInfo.totalPages;
- const page = this.pageInfo.page;
- const items = [];
-
- if (page > 1) items.push({ title: FIRST });
-
- if (page > 1) {
- items.push({ title: PREV, prev: true });
- } else {
- items.push({ title: PREV, disabled: true, prev: true });
- }
-
- if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
-
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
-
- for (let i = start; i <= end; i += 1) {
- const isActive = i === page;
- items.push({ title: i, active: isActive, page: true });
- }
-
- if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
- }
-
- if (page === total) {
- items.push({ title: NEXT, disabled: true, next: true });
- } else if (total - page >= 1) {
- items.push({ title: NEXT, next: true });
- }
-
- if (total - page >= 1) items.push({ title: LAST, last: true });
-
- return items;
- },
- },
- template: `
- <div class="gl-pagination">
- <ul class="pagination clearfix">
- <li v-for='item in getItems'
- :class='{
- page: item.page,
- prev: item.prev,
- next: item.next,
- separator: item.separator,
- active: item.active,
- disabled: item.disabled
- }'
- >
- <a @click="changePage($event)">{{item.title}}</a>
- </li>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue
new file mode 100644
index 00000000000..c9dbc048345
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -0,0 +1,145 @@
+<script>
+const PAGINATION_UI_BUTTON_LIMIT = 4;
+const UI_LIMIT = 6;
+const SPREAD = '...';
+const PREV = 'Prev';
+const NEXT = 'Next';
+const FIRST = '« First';
+const LAST = 'Last »';
+
+export default {
+ props: {
+ /**
+ This function will take the information given by the pagination component
+
+ Here is an example `change` method:
+
+ change(pagenum) {
+ gl.utils.visitUrl(`?page=${pagenum}`);
+ },
+ */
+ change: {
+ type: Function,
+ required: true,
+ },
+
+ /**
+ pageInfo will come from the headers of the API call
+ in the `.then` clause of the VueResource API call
+ there should be a function that contructs the pageInfo for this component
+
+ This is an example:
+
+ const pageInfo = headers => ({
+ perPage: +headers['X-Per-Page'],
+ page: +headers['X-Page'],
+ total: +headers['X-Total'],
+ totalPages: +headers['X-Total-Pages'],
+ nextPage: +headers['X-Next-Page'],
+ previousPage: +headers['X-Prev-Page'],
+ });
+ */
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ changePage(e) {
+ if (e.target.parentElement.classList.contains('disabled')) return;
+
+ const text = e.target.innerText;
+ const { totalPages, nextPage, previousPage } = this.pageInfo;
+
+ switch (text) {
+ case SPREAD:
+ break;
+ case LAST:
+ this.change(totalPages);
+ break;
+ case NEXT:
+ this.change(nextPage);
+ break;
+ case PREV:
+ this.change(previousPage);
+ break;
+ case FIRST:
+ this.change(1);
+ break;
+ default:
+ this.change(+text);
+ break;
+ }
+ },
+ },
+ computed: {
+ prev() {
+ return this.pageInfo.previousPage;
+ },
+ next() {
+ return this.pageInfo.nextPage;
+ },
+ getItems() {
+ const total = this.pageInfo.totalPages;
+ const page = this.pageInfo.page;
+ const items = [];
+
+ if (page > 1) {
+ items.push({ title: FIRST, first: true });
+ }
+
+ if (page > 1) {
+ items.push({ title: PREV, prev: true });
+ } else {
+ items.push({ title: PREV, disabled: true, prev: true });
+ }
+
+ if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
+
+ const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
+ const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
+
+ for (let i = start; i <= end; i += 1) {
+ const isActive = i === page;
+ items.push({ title: i, active: isActive, page: true });
+ }
+
+ if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
+ items.push({ title: SPREAD, separator: true, page: true });
+ }
+
+ if (page === total) {
+ items.push({ title: NEXT, disabled: true, next: true });
+ } else if (total - page >= 1) {
+ items.push({ title: NEXT, next: true });
+ }
+
+ if (total - page >= 1) {
+ items.push({ title: LAST, last: true });
+ }
+
+ return items;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-pagination">
+ <ul class="pagination clearfix">
+ <li
+ v-for="item in getItems"
+ :class="{
+ page: item.page,
+ 'js-previous-button': item.prev,
+ 'js-next-button': item.next,
+ 'js-last-button': item.last,
+ 'js-first-button': item.first,
+ separator: item.separator,
+ active: item.active,
+ disabled: item.disabled
+ }">
+ <a @click.prevent="changePage($event)">{{item.title}}</a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
new file mode 100644
index 00000000000..3ff7f6e2c4e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -0,0 +1,48 @@
+<script>
+import tooltip from '../directives/tooltip';
+import timeagoMixin from '../mixins/timeago';
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Port of ruby helper time_ago_with_tooltip
+ */
+
+export default {
+ props: {
+ time: {
+ type: String,
+ required: true,
+ },
+
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ mixins: [
+ timeagoMixin,
+ ],
+
+ directives: {
+ tooltip,
+ },
+};
+</script>
+<template>
+ <time
+ v-tooltip
+ :class="cssClass"
+ :title="tooltipTitle(time)"
+ :data-placement="tooltipPlacement"
+ data-container="body">
+ {{timeFormated(time)}}
+ </time>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
new file mode 100644
index 00000000000..dd9a2ebb184
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -0,0 +1,88 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import defaultAvatarUrl from 'images/no_avatar.png';
+import tooltip from '../../directives/tooltip';
+
+export default {
+ name: 'UserAvatarImage',
+ props: {
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: 'user avatar',
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ tooltipContainer() {
+ return this.tooltipText ? 'body' : null;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ imageSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
+ },
+};
+</script>
+
+<template>
+ <img
+ v-tooltip
+ class="avatar"
+ :class="[avatarSizeClass, cssClasses]"
+ :src="imageSource"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ :title="tooltipText"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
new file mode 100644
index 00000000000..95898d54cf7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import userAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLink',
+ components: {
+ userAvatarImage,
+ },
+ props: {
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+};
+</script>
+
+<template>
+ <a
+ class="user-avatar-link"
+ :href="linkHref">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ />
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
new file mode 100644
index 00000000000..d2ff2ac006e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
@@ -0,0 +1,45 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar svg (typically
+ for a blank state). It will receive styles comparable to the user avatar,
+ but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
+ The svg and avatar size can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-svg
+ :svg="potentialApproverSvg"
+ :size="20"
+ />
+
+*/
+
+export default {
+ props: {
+ svg: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ },
+ computed: {
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <svg
+ :class="avatarSizeClass"
+ :height="size"
+ :width="size"
+ v-html="svg">
+ </svg>
+</template>
+
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
new file mode 100644
index 00000000000..dc896cf5c7d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -0,0 +1,13 @@
+export default {
+ bind(el) {
+ $(el).tooltip();
+ },
+
+ componentUpdated(el) {
+ $(el).tooltip('fixTitle');
+ },
+
+ unbind(el) {
+ $(el).tooltip('destroy');
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
new file mode 100644
index 00000000000..20f63ab663c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -0,0 +1,18 @@
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Mixin with time ago methods used in some vue components
+ */
+export default {
+ methods: {
+ timeFormated(time) {
+ const timeago = gl.utils.getTimeago();
+
+ return timeago.format(time);
+ },
+
+ tooltipTitle(time) {
+ return gl.utils.formatDate(time);
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
new file mode 100644
index 00000000000..f83c4b00761
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -0,0 +1,42 @@
+import {
+ __,
+ n__,
+ s__,
+} from '../locale';
+
+export default (Vue) => {
+ Vue.mixin({
+ methods: {
+ /**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+ **/
+ __,
+ /**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+ **/
+ n__,
+ /**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+ **/
+ s__,
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
index d5f87588c28..7f8e514fda1 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
@@ -4,7 +4,7 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
// Maintain a global counter for active requests
-// see: spec/support/wait_for_vue_resource.rb
+// see: spec/support/wait_for_requests.rb
Vue.http.interceptors.push((request, next) => {
window.activeVueResources = window.activeVueResources || 0;
window.activeVueResources += 1;
@@ -14,11 +14,22 @@ Vue.http.interceptors.push((request, next) => {
});
});
-// Inject CSRF token so we don't break any tests.
+// Inject CSRF token and parse headers.
+// New Vue Resource version uses Headers, we are expecting a plain object to render pagination
+// and polling.
Vue.http.interceptors.push((request, next) => {
if ($.rails) {
- // eslint-disable-next-line no-param-reassign
- request.headers['X-CSRF-Token'] = $.rails.csrfToken();
+ request.headers.set('X-CSRF-Token', $.rails.csrfToken());
}
- next();
+
+ next((response) => {
+ // Headers object has a `forEach` property that iterates through all values.
+ const headers = {};
+
+ response.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+ // eslint-disable-next-line no-param-reassign
+ response.headers = headers;
+ });
});
diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js
new file mode 100644
index 00000000000..9a9cf395fb8
--- /dev/null
+++ b/app/assets/javascripts/webpack.js
@@ -0,0 +1,9 @@
+/**
+ * This is the first script loaded by webpack's runtime. It is used to manually configure
+ * config.output.publicPath to account for relative_url_root or CDN settings which cannot be
+ * baked-in to our webpack bundles.
+ */
+
+if (gon && gon.webpack_public_path) {
+ __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line
+}
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
index 75fd1394a03..a0025ddb598 100644
--- a/app/assets/javascripts/wikis.js
+++ b/app/assets/javascripts/wikis.js
@@ -1,69 +1,59 @@
-/* eslint-disable no-param-reassign */
-/* global Breakpoints */
+import bp from './breakpoints';
-require('./breakpoints');
-require('vendor/jquery.nicescroll');
+export default class Wikis {
+ constructor() {
+ this.sidebarEl = document.querySelector('.js-wiki-sidebar');
+ this.sidebarExpanded = false;
-((global) => {
- class Wikis {
- constructor() {
- this.bp = Breakpoints.get();
- this.sidebarEl = document.querySelector('.js-wiki-sidebar');
- this.sidebarExpanded = false;
- $(this.sidebarEl).niceScroll();
-
- const sidebarToggles = document.querySelectorAll('.js-sidebar-wiki-toggle');
- for (let i = 0; i < sidebarToggles.length; i += 1) {
- sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e));
- }
-
- this.newWikiForm = document.querySelector('form.new-wiki-page');
- if (this.newWikiForm) {
- this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e));
- }
+ const sidebarToggles = document.querySelectorAll('.js-sidebar-wiki-toggle');
+ for (let i = 0; i < sidebarToggles.length; i += 1) {
+ sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e));
+ }
- window.addEventListener('resize', () => this.renderSidebar());
- this.renderSidebar();
+ this.newWikiForm = document.querySelector('form.new-wiki-page');
+ if (this.newWikiForm) {
+ this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e));
}
- handleNewWikiSubmit(e) {
- if (!this.newWikiForm) return;
+ window.addEventListener('resize', () => this.renderSidebar());
+ this.renderSidebar();
+ }
- const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
- const slug = gl.text.slugify(slugInput.value);
+ handleNewWikiSubmit(e) {
+ if (!this.newWikiForm) return;
- if (slug.length > 0) {
- const wikisPath = slugInput.getAttribute('data-wikis-path');
- window.location.href = `${wikisPath}/${slug}`;
- e.preventDefault();
- }
- }
+ const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
+ const slug = gl.text.slugify(slugInput.value);
- handleToggleSidebar(e) {
+ if (slug.length > 0) {
+ const wikisPath = slugInput.getAttribute('data-wikis-path');
+ window.location.href = `${wikisPath}/${slug}`;
e.preventDefault();
- this.sidebarExpanded = !this.sidebarExpanded;
- this.renderSidebar();
}
+ }
- sidebarCanCollapse() {
- const bootstrapBreakpoint = this.bp.getBreakpointSize();
- return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
- }
+ handleToggleSidebar(e) {
+ e.preventDefault();
+ this.sidebarExpanded = !this.sidebarExpanded;
+ this.renderSidebar();
+ }
- renderSidebar() {
- if (!this.sidebarEl) return;
- const { classList } = this.sidebarEl;
- if (this.sidebarExpanded || !this.sidebarCanCollapse()) {
- if (!classList.contains('right-sidebar-expanded')) {
- classList.remove('right-sidebar-collapsed');
- classList.add('right-sidebar-expanded');
- }
- } else if (classList.contains('right-sidebar-expanded')) {
- classList.add('right-sidebar-collapsed');
- classList.remove('right-sidebar-expanded');
+ static sidebarCanCollapse() {
+ const bootstrapBreakpoint = bp.getBreakpointSize();
+ return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
+ }
+
+ renderSidebar() {
+ if (!this.sidebarEl) return;
+ const { classList } = this.sidebarEl;
+ if (this.sidebarExpanded || !Wikis.sidebarCanCollapse()) {
+ if (!classList.contains('right-sidebar-expanded')) {
+ classList.remove('right-sidebar-collapsed');
+ classList.add('right-sidebar-expanded');
}
+ } else if (classList.contains('right-sidebar-expanded')) {
+ classList.add('right-sidebar-collapsed');
+ classList.remove('right-sidebar-expanded');
}
}
-
- global.Wikis = Wikis;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index ce626cf7b46..99c7644e4d9 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,4 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */
-/* global Dropzone */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len, class-methods-use-this */
/* global Mousetrap */
// Zen Mode (full screen) textarea
@@ -7,10 +6,12 @@
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
-require('vendor/jquery.scrollTo');
-window.Dropzone = require('dropzone');
-require('mousetrap');
-require('mousetrap/plugins/pause/mousetrap-pause');
+import 'vendor/jquery.scrollTo';
+import Dropzone from 'dropzone';
+import 'mousetrap';
+import 'mousetrap/plugins/pause/mousetrap-pause';
+
+window.Dropzone = Dropzone;
//
// ### Events
@@ -33,65 +34,62 @@ require('mousetrap/plugins/pause/mousetrap-pause');
// **Cancelable** No
// **Target** a.js-zen-leave
//
-(function() {
- this.ZenMode = (function() {
- function ZenMode() {
- this.active_backdrop = null;
- this.active_textarea = null;
- $(document).on('click', '.js-zen-enter', function(e) {
- e.preventDefault();
- return $(e.currentTarget).trigger('zen_mode:enter');
- });
- $(document).on('click', '.js-zen-leave', function(e) {
- e.preventDefault();
- return $(e.currentTarget).trigger('zen_mode:leave');
- });
- $(document).on('zen_mode:enter', (function(_this) {
- return function(e) {
- return _this.enter($(e.target).closest('.md-area').find('.zen-backdrop'));
- };
- })(this));
- $(document).on('zen_mode:leave', (function(_this) {
- return function(e) {
- return _this.exit();
- };
- })(this));
- $(document).on('keydown', function(e) {
- // Esc
- if (e.keyCode === 27) {
- e.preventDefault();
- return $(document).trigger('zen_mode:leave');
- }
- });
- }
- ZenMode.prototype.enter = function(backdrop) {
- Mousetrap.pause();
- this.active_backdrop = $(backdrop);
- this.active_backdrop.addClass('fullscreen');
- this.active_textarea = this.active_backdrop.find('textarea');
- // Prevent a user-resized textarea from persisting to fullscreen
- this.active_textarea.removeAttr('style');
- return this.active_textarea.focus();
- };
-
- ZenMode.prototype.exit = function() {
- if (this.active_textarea) {
- Mousetrap.unpause();
- this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
- this.scrollTo(this.active_textarea);
- this.active_textarea = null;
- this.active_backdrop = null;
- return Dropzone.forElement('.div-dropzone').enable();
+export default class ZenMode {
+ constructor() {
+ this.active_backdrop = null;
+ this.active_textarea = null;
+ $(document).on('click', '.js-zen-enter', function(e) {
+ e.preventDefault();
+ return $(e.currentTarget).trigger('zen_mode:enter');
+ });
+ $(document).on('click', '.js-zen-leave', function(e) {
+ e.preventDefault();
+ return $(e.currentTarget).trigger('zen_mode:leave');
+ });
+ $(document).on('zen_mode:enter', (function(_this) {
+ return function(e) {
+ return _this.enter($(e.target).closest('.md-area').find('.zen-backdrop'));
+ };
+ })(this));
+ $(document).on('zen_mode:leave', (function(_this) {
+ return function(e) {
+ return _this.exit();
+ };
+ })(this));
+ $(document).on('keydown', function(e) {
+ // Esc
+ if (e.keyCode === 27) {
+ e.preventDefault();
+ return $(document).trigger('zen_mode:leave');
}
- };
+ });
+ }
+
+ enter(backdrop) {
+ Mousetrap.pause();
+ this.active_backdrop = $(backdrop);
+ this.active_backdrop.addClass('fullscreen');
+ this.active_textarea = this.active_backdrop.find('textarea');
+ // Prevent a user-resized textarea from persisting to fullscreen
+ this.active_textarea.removeAttr('style');
+ return this.active_textarea.focus();
+ }
- ZenMode.prototype.scrollTo = function(zen_area) {
- return $.scrollTo(zen_area, 0, {
- offset: -150
- });
- };
+ exit() {
+ if (this.active_textarea) {
+ Mousetrap.unpause();
+ this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
+ this.scrollTo(this.active_textarea);
+ this.active_textarea = null;
+ this.active_backdrop = null;
+ return Dropzone.forElement('.div-dropzone').enable();
+ }
+ }
- return ZenMode;
- })();
-}).call(window);
+ scrollTo(zen_area) {
+ return $.scrollTo(zen_area, 0, {
+ offset: -150
+ });
+ }
+}
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 83a8eeaafde..0665622fe4a 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -42,4 +42,4 @@
/*
* Styles for JS behaviors.
*/
-@import "behaviors.scss";
+@import "behaviors";
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 5bb7e8caec1..b2b3297e880 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -4,46 +4,50 @@
@import 'framework/tw_bootstrap';
@import "framework/layout";
-@import "framework/animations.scss";
-@import "framework/avatar.scss";
-@import "framework/asciidoctor.scss";
-@import "framework/blocks.scss";
-@import "framework/buttons.scss";
-@import "framework/badges.scss";
-@import "framework/calendar.scss";
-@import "framework/callout.scss";
-@import "framework/common.scss";
-@import "framework/dropdowns.scss";
-@import "framework/files.scss";
-@import "framework/filters.scss";
-@import "framework/flash.scss";
-@import "framework/forms.scss";
-@import "framework/gfm.scss";
-@import "framework/header.scss";
-@import "framework/highlight.scss";
-@import "framework/issue_box.scss";
-@import "framework/jquery.scss";
-@import "framework/lists.scss";
-@import "framework/logo.scss";
-@import "framework/markdown_area.scss";
-@import "framework/mobile.scss";
-@import "framework/modal.scss";
-@import "framework/nav.scss";
-@import "framework/pagination.scss";
-@import "framework/panels.scss";
-@import "framework/selects.scss";
-@import "framework/sidebar.scss";
-@import "framework/tables.scss";
-@import "framework/timeline.scss";
-@import "framework/typography.scss";
-@import "framework/zen.scss";
+@import "framework/animations";
+@import "framework/avatar";
+@import "framework/asciidoctor";
+@import "framework/blocks";
+@import "framework/buttons";
+@import "framework/badges";
+@import "framework/calendar";
+@import "framework/callout";
+@import "framework/common";
+@import "framework/dropdowns";
+@import "framework/files";
+@import "framework/filters";
+@import "framework/flash";
+@import "framework/forms";
+@import "framework/gfm";
+@import "framework/header";
+@import "framework/highlight";
+@import "framework/issue_box";
+@import "framework/jquery";
+@import "framework/lists";
+@import "framework/logo";
+@import "framework/markdown_area";
+@import "framework/media_object";
+@import "framework/mobile";
+@import "framework/modal";
+@import "framework/nav";
+@import "framework/pagination";
+@import "framework/panels";
+@import "framework/selects";
+@import "framework/sidebar";
+@import "framework/tables";
+@import "framework/notes";
+@import "framework/timeline";
+@import "framework/typography";
+@import "framework/zen";
@import "framework/blank";
-@import "framework/wells.scss";
-@import "framework/page-header.scss";
-@import "framework/awards.scss";
-@import "framework/images.scss";
+@import "framework/wells";
+@import "framework/page-header";
+@import "framework/awards";
+@import "framework/images";
@import "framework/broadcast-messages";
-@import "framework/emojis.scss";
-@import "framework/emoji-sprites.scss";
-@import "framework/icons.scss";
-@import "framework/snippets.scss";
+@import "framework/emojis";
+@import "framework/emoji-sprites";
+@import "framework/icons";
+@import "framework/snippets";
+@import "framework/memory_graph";
+@import "framework/responsive-tables";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 7c50b80fd2b..667b73e150d 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -159,3 +159,109 @@ a {
.fade-in {
animation: fadeIn $fade-in-duration 1;
}
+
+@keyframes fadeInHalf {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 0.5;
+ }
+}
+
+.fade-in-half {
+ animation: fadeInHalf $fade-in-duration 1;
+}
+
+@keyframes fadeInFull {
+ 0% {
+ opacity: 0.5;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+.fade-in-full {
+ animation: fadeInFull $fade-in-duration 1;
+}
+
+
+.animation-container {
+ background: $repo-editor-grey;
+ height: 40px;
+ overflow: hidden;
+ position: relative;
+
+ &.animation-container-small {
+ height: 12px;
+ }
+
+ &::before {
+ animation-duration: 1s;
+ animation-fill-mode: forwards;
+ animation-iteration-count: infinite;
+ animation-name: blockTextShine;
+ animation-timing-function: linear;
+ background-image: $repo-editor-linear-gradient;
+ background-repeat: no-repeat;
+ background-size: 800px 45px;
+ content: ' ';
+ display: block;
+ height: 100%;
+ position: relative;
+ }
+
+ div {
+ background: $white-light;
+ height: 6px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ }
+
+ .skeleton-line-1 {
+ left: 0;
+ top: 8px;
+ }
+
+ .skeleton-line-2 {
+ left: 150px;
+ top: 0;
+ height: 10px;
+ }
+
+ .skeleton-line-3 {
+ left: 0;
+ top: 23px;
+ }
+
+ .skeleton-line-4 {
+ left: 0;
+ top: 38px;
+ }
+
+ .skeleton-line-5 {
+ left: 200px;
+ top: 28px;
+ height: 10px;
+ }
+
+ .skeleton-line-6 {
+ top: 14px;
+ left: 230px;
+ height: 10px;
+ }
+}
+
+@keyframes blockTextShine {
+ 0% {
+ transform: translateX(-468px);
+ }
+
+ 100% {
+ transform: translateX(468px);
+ }
+}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 3f5b78ed445..486d88efbc5 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -10,6 +10,8 @@
border-radius: $avatar_radius;
border: 1px solid $avatar-border;
&.s16 { @include avatar-size(16px, 6px); }
+ &.s18 { @include avatar-size(18px, 6px); }
+ &.s19 { @include avatar-size(19px, 6px); }
&.s20 { @include avatar-size(20px, 7px); }
&.s24 { @include avatar-size(24px, 8px); }
&.s26 { @include avatar-size(26px, 8px); }
@@ -33,6 +35,8 @@
width: 40px;
height: 40px;
padding: 0;
+ background: $avatar-background;
+ overflow: hidden;
&.avatar-inline {
float: none;
@@ -58,7 +62,7 @@
}
&:not([href]):hover {
- border-color: rgba($avatar-border, .2);
+ border-color: darken($avatar-border, 10%);
}
}
@@ -84,6 +88,10 @@
overflow: hidden;
display: flex;
+ a {
+ display: flex;
+ }
+
.avatar {
border-radius: 0;
border: none;
@@ -92,4 +100,17 @@
margin: 0;
align-self: center;
}
+
+ &.s40 { min-width: 40px; min-height: 40px; }
+}
+
+.avatar-counter {
+ background-color: $gray-darkest;
+ color: $white-light;
+ border: 1px solid $avatar-border;
+ border-radius: 1em;
+ font-family: $regular_font;
+ font-size: 9px;
+ line-height: 16px;
+ text-align: center;
}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index b2102d2fbc5..bb30da4f4b2 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -1,4 +1,7 @@
.awards {
+ display: flex;
+ flex-wrap: wrap;
+
.emoji-icon {
width: 20px;
height: 20px;
@@ -10,7 +13,7 @@
top: 0;
margin-top: 3px;
padding: $gl-padding;
- z-index: 9;
+ z-index: 300;
width: 300px;
font-size: 14px;
background-color: $white-light;
@@ -21,7 +24,7 @@
opacity: 0;
transform: scale(.2);
transform-origin: 0 -45px;
- transition: .3s cubic-bezier(.67,.06,.19,1.44);
+ transition: .3s cubic-bezier(.67, .06, .19, 1.44);
transition-property: transform, opacity;
&.is-aligned-right {
@@ -100,7 +103,6 @@
.award-menu-holder {
display: inline-block;
- position: absolute;
.tooltip {
white-space: nowrap;
@@ -108,8 +110,11 @@
}
.award-control {
- margin-right: 5px;
+ margin: 4px 8px 4px 0;
outline: 0;
+ position: relative;
+ display: block;
+ float: left;
&.disabled {
cursor: default;
@@ -226,18 +231,14 @@
.award-control-icon-positive,
.award-control-icon-super-positive {
+ @include transition(opacity, transform);
position: absolute;
- left: 7px;
- bottom: 9px;
+ left: 10px;
+ bottom: 6px;
opacity: 0;
- @include transition(opacity, transform);
}
.award-control-text {
vertical-align: middle;
}
}
-
-.note-awards .award-control-icon-positive {
- left: 6px;
-}
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index a2fa2e7769b..6bb096fc5bd 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -1,9 +1,14 @@
-.blank-state-welcome {
- text-align: center;
- border-bottom: 1px solid $border-color;
+.blank-state-parent-container {
+ .section-container {
+ padding: 10px;
+ }
- .blank-state-text {
- margin-bottom: 0;
+ .section-body {
+ width: 100%;
+ height: 100%;
+ padding-bottom: 25px;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
}
}
@@ -11,41 +16,35 @@
padding-top: 20px;
padding-bottom: 20px;
text-align: center;
-}
-.blank-state-no-icon {
- padding-top: 40px;
- padding-bottom: 40px;
-}
-
-.blank-state-icon {
- padding-bottom: 20px;
- color: $gray-darkest;
- font-size: 56px;
+ &.blank-state-welcome {
+ .blank-state-welcome-title {
+ font-size: 24px;
+ }
- path,
- polygon {
- fill: currentColor;
+ .blank-state-text {
+ margin-bottom: 0;
+ }
}
-}
-.blank-state-title {
- margin-top: 0;
- margin-bottom: 5px;
- font-size: 18px;
- font-weight: normal;
-}
+ .blank-state-icon {
+ padding-bottom: 20px;
-.blank-state-text {
- margin-top: 0;
- margin-bottom: $gl-padding;
- font-size: 14px;
+ svg {
+ display: block;
+ margin: auto;
+ }
+ }
- > strong {
- font-weight: 600;
+ .blank-state-title {
+ margin-top: 0;
+ margin-bottom: 10px;
+ font-size: 18px;
}
-}
-.blank-state-welcome-title {
- font-size: 24px;
+ .blank-state-text {
+ max-width: $container-text-max-width;
+ margin: 0 auto $gl-padding;
+ font-size: 14px;
+ }
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index f3e2a5db0a6..95a08c960ea 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -23,7 +23,6 @@
.row-content-block {
margin-top: 0;
- margin-bottom: -$gl-padding;
background-color: $gray-light;
padding: $gl-padding;
margin-bottom: 0;
@@ -254,8 +253,65 @@
padding: 10px 0;
}
+.landing {
+ margin: $gl-padding auto;
+ overflow: hidden;
+ display: flex;
+ position: relative;
+ border: 1px solid $blue-300;
+ border-radius: $border-radius-default;
+ background-color: $blue-25;
+ justify-content: center;
+
+ .dismiss-button {
+ position: absolute;
+ right: 6px;
+ top: 6px;
+ cursor: pointer;
+ color: $blue-300;
+ z-index: 1;
+ border: none;
+ background-color: transparent;
+
+ &:hover,
+ &:focus {
+ border: none;
+ color: $blue-400;
+ }
+ }
+
+ .svg-container {
+ align-self: center;
+ }
+
+ .inner-content {
+ text-align: left;
+ white-space: nowrap;
+
+ h4 {
+ color: $gl-text-color;
+ font-size: 17px;
+ }
+
+ p {
+ color: $gl-text-color;
+ margin-bottom: $gl-padding;
+ }
+ }
+
+ @media (max-width: $screen-sm-min) {
+ flex-direction: column;
+
+ .inner-content {
+ white-space: normal;
+ padding: 0 28px;
+ text-align: center;
+ }
+ }
+}
+
.empty-state {
- margin: 100px 0 0;
+ margin: 5% auto 0;
.text-content {
max-width: 460px;
@@ -278,27 +334,12 @@
}
.btn {
- margin: $btn-side-margin $btn-side-margin 0 0;
- }
-
- @media(max-width: $screen-xs-max) {
- margin-top: 50px;
- text-align: center;
+ margin: $btn-side-margin 5px;
- .btn {
+ @media(max-width: $screen-xs-max) {
width: 100%;
}
}
-
- @media(min-width: $screen-xs-max) {
- &.merge-requests .text-content {
- margin-top: 40px;
- }
-
- &.labels .text-content {
- margin-top: 70px;
- }
- }
}
.flex-container-block {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 4369ae78bde..6eabdc63d9e 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -20,17 +20,29 @@
color: $text;
border-color: $border;
+ > .icon {
+ color: $text;
+ }
+
&:hover,
&:focus {
background-color: $hover-background;
border-color: $hover-border;
color: $hover-text;
+
+ > .icon {
+ color: $hover-text;
+ }
}
&:active {
background-color: $active-background;
border-color: $active-border;
color: $hover-text;
+
+ > .icon {
+ color: $hover-text;
+ }
}
}
@@ -163,7 +175,8 @@
@include btn-orange;
}
- &.btn-close {
+ &.btn-close,
+ &.btn-close-color {
@include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
}
@@ -181,7 +194,8 @@
float: right;
}
- &.btn-reopen {
+ &.btn-reopen,
+ .btn-reopen-color {
/* should be same as parent class for now */
}
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 759401a7806..0ded4a3b423 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -45,6 +45,7 @@
margin-top: -23px;
float: right;
font-size: 12px;
+ direction: ltr;
}
.pika-single.gitlab-theme {
@@ -93,7 +94,7 @@
.is-selected .pika-day,
.pika-day:hover,
- .is-today .pika-day:hover {
+ .is-today .pika-day {
background: $gl-primary;
color: $white-light;
box-shadow: none;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 638c1403b25..293aa194528 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -92,7 +92,8 @@ hr {
.item-title { font-weight: 600; }
/** FLASH message **/
-.author_link {
+.author_link,
+.author-link {
color: $gl-link-color;
}
@@ -335,11 +336,6 @@ table {
text-align: center;
}
-#nprogress .spinner {
- top: 15px !important;
- right: 10px !important;
-}
-
.header-with-avatar {
h3 {
margin: 0;
@@ -376,6 +372,10 @@ table {
background: $gl-success !important;
}
+.dz-message {
+ margin: 0;
+}
+
.space-right {
margin-right: 10px;
}
@@ -424,6 +424,11 @@ table {
}
}
+.bordered-box {
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
.str-truncated {
&-60 {
@include str-truncated(60%);
@@ -439,3 +444,8 @@ table {
word-wrap: break-word;
}
}
+
+.disabled-content {
+ pointer-events: none;
+ opacity: .5;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 1313ea25c2a..958159b507a 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -35,8 +35,8 @@
.open {
.dropdown-menu,
.dropdown-menu-nav {
- display: block;
@include set-visible;
+ display: block;
@media (max-width: $screen-xs-max) {
width: 100%;
@@ -48,6 +48,10 @@
@include chevron-active;
border-color: $gray-darkest;
}
+
+ [data-toggle="dropdown"] {
+ outline: 0;
+ }
}
.dropdown-toggle {
@@ -97,7 +101,7 @@
.fa-chevron-down {
font-size: $dropdown-chevron-size;
position: relative;
- top: -3px;
+ top: -2px;
margin-left: 5px;
}
@@ -109,6 +113,7 @@
&:focus:active {
@include chevron-active;
border-color: $dropdown-toggle-active-border-color;
+ outline: 0;
}
}
@@ -143,7 +148,6 @@
padding: 5px 8px;
color: $gl-text-color;
line-height: initial;
- text-overflow: ellipsis;
border-radius: 2px;
white-space: nowrap;
overflow: hidden;
@@ -179,13 +183,15 @@
.dropdown-menu,
.dropdown-menu-nav {
+ @include set-invisible;
display: block;
position: absolute;
- width: 100%;
+ width: auto;
top: 100%;
left: 0;
z-index: 9;
min-width: 240px;
+ max-width: 500px;
margin-top: 2px;
margin-bottom: 0;
font-size: 14px;
@@ -195,10 +201,10 @@
border: 1px solid $dropdown-border-color;
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
- @include set-invisible;
- @media (max-width: $screen-sm-min) {
- width: 100%;
+ &.dropdown-open-left {
+ right: 0;
+ left: auto;
}
&.is-loading {
@@ -251,15 +257,24 @@
}
.dropdown-header {
- color: $gl-text-color;
+ color: $gl-text-color-secondary;
font-size: 13px;
- font-weight: 600;
line-height: 22px;
+ padding: 0 16px;
+ }
+
+ &.capitalize-header .dropdown-header {
text-transform: capitalize;
+ }
+
+ .dropdown-bold-header {
+ font-weight: 600;
+ line-height: 22px;
padding: 0 16px;
}
- .separator + .dropdown-header {
+ .separator + .dropdown-header,
+ .separator + .dropdown-bold-header {
padding-top: 2px;
}
@@ -269,16 +284,84 @@
color: $gl-text-color-secondary;
}
- .badge {
- position: absolute;
- right: 8px;
- top: 5px;
+ .badge + span:not(.badge) {
+ // Expects up to 3 digits on the badge
+ margin-right: 40px;
+ }
+}
+
+.droplab-dropdown {
+ .dropdown-toggle > i {
+ pointer-events: none;
+ }
+
+ .dropdown-menu li {
+ padding: $gl-btn-padding;
+ cursor: pointer;
+
+ > a,
+ > button {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+ text-overflow: inherit;
+ background-color: inherit;
+ color: inherit;
+ border: inherit;
+ text-align: left;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
+ }
+
+ &.btn .fa:not(:last-child) {
+ margin-left: 5px;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ }
+
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
+
+ &.divider {
+ margin: 0 8px;
+ padding: 0;
+ border-top: $gray-darkest;
+ }
+
+ .icon {
+ visibility: hidden;
+ }
+
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 5px;
+
+ p {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .icon {
+ display: inline-block;
+ vertical-align: top;
+ padding-top: 2px;
}
}
-.filtered-search-box-input-container .dropdown-menu,
-.filtered-search-box-input-container .dropdown-menu-nav,
-.comment-type-dropdown .dropdown-menu {
+.droplab-dropdown .dropdown-menu,
+.droplab-dropdown .dropdown-menu-nav {
display: none;
opacity: 1;
visibility: visible;
@@ -289,7 +372,6 @@
.dropdown-menu,
.dropdown-menu-nav {
max-width: 280px;
- width: auto;
}
}
@@ -337,8 +419,8 @@
.dropdown-menu-user {
.avatar {
float: left;
- width: 30px;
- height: 30px;
+ width: 2 * $gl-padding;
+ height: 2 * $gl-padding;
margin: 0 10px 0 0;
}
}
@@ -376,11 +458,13 @@
.dropdown-menu-align-right {
left: auto;
right: 0;
+ margin-top: -5px;
}
.dropdown-menu-selectable {
a {
padding-left: 26px;
+ position: relative;
&.is-indeterminate,
&.is-active {
@@ -390,7 +474,8 @@
&::before {
position: absolute;
left: 6px;
- top: 6px;
+ top: 50%;
+ transform: translateY(-50%);
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
@@ -405,6 +490,9 @@
&.is-active::before {
content: "\f00c";
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
}
}
}
@@ -486,6 +574,7 @@
.dropdown-input-field,
.default-dropdown-input {
+ display: block;
width: 100%;
min-height: 30px;
padding: 0 7px;
@@ -581,8 +670,8 @@
}
.pika-single {
- position: relative!important;
- top: 0!important;
+ position: relative !important;
+ top: 0 !important;
border: 0;
box-shadow: none;
}
@@ -634,3 +723,62 @@
@include set-invisible;
overflow: hidden;
}
+
+// TODO: change global style and remove mixin
+@mixin new-style-dropdown($selector: '') {
+ #{$selector}.dropdown-menu,
+ #{$selector}.dropdown-menu-nav {
+ .divider {
+ margin: 6px 0;
+ }
+
+ li {
+ padding: 0 1px;
+
+ &.dropdown-header {
+ padding: 8px 16px;
+ }
+
+ a {
+ border-radius: 0;
+ padding: 8px 16px;
+
+ // make sure the text color is not overriden
+ &.text-danger {
+ @extend .text-danger;
+ }
+
+ &.is-focused,
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: $gray-darker;
+ }
+
+ &.is-active {
+ font-weight: inherit;
+
+ &::before {
+ top: 16px;
+ }
+ }
+ }
+ }
+
+ &.dropdown-menu-selectable {
+ li {
+ a {
+ padding: 8px 40px;
+
+ &.is-active::before {
+ left: 16px;
+ }
+ }
+ }
+ }
+ }
+
+ #{$selector}.dropdown-menu-align-right {
+ margin-top: 2px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index d86ae57cd9a..2d6bc17d4ff 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -1,5 +1,4 @@
gl-emoji {
- display: inline-block;
display: inline-flex;
vertical-align: middle;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index df819ffe4bc..8ad082f7a65 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -4,13 +4,22 @@
*/
.file-holder {
border: 1px solid $border-color;
+ border-radius: $border-radius-default;
&.file-holder-no-border {
border: 0;
}
&.readme-holder {
- margin: $gl-padding-top 0;
+ margin: $gl-padding 0;
+
+ &.limited-width-container .file-content {
+ max-width: $limited-layout-width-sm;
+ margin-left: auto;
+ margin-right: auto;
+ padding-top: 64px;
+ padding-bottom: 64px;
+ }
}
table {
@@ -25,7 +34,7 @@
text-align: left;
padding: 10px $gl-padding;
word-wrap: break-word;
- border-radius: 3px 3px 0 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
&.file-title-clear {
padding-left: 0;
@@ -58,15 +67,55 @@
}
}
+ .file-blame-legend {
+ background-color: $gray-light;
+ text-align: right;
+ padding: 8px $gl-padding;
+ border-bottom: 1px solid $border-color;
+
+ @media (max-width: $screen-xs-max) {
+ text-align: left;
+ }
+
+ .left-label {
+ padding-right: 5px;
+ }
+
+ .right-label {
+ padding-left: 5px;
+ }
+
+ .legend-box {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ padding: 0 2px;
+ }
+
+ @for $i from 0 through 5 {
+ .legend-box-#{$i} {
+ background-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
+ }
+ }
+
+ @for $i from 1 through 4 {
+ .legend-box-#{$i + 5} {
+ background-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
+ }
+ }
+ }
+
.file-content {
background: $white-light;
- &.image_file {
+ &.image_file,
+ &.video {
background: $file-image-bg;
text-align: center;
+ padding: 30px;
- img {
- padding: 20px;
+ img,
+ video {
max-width: 80%;
}
}
@@ -92,9 +141,16 @@
tr {
border-bottom: 1px solid $blame-border;
+
+ &:last-child {
+ border-bottom: none;
+ }
}
td {
+ border-top: none;
+ border-bottom: none;
+
&:first-child {
border-left: none;
}
@@ -105,9 +161,32 @@
}
td.blame-commit {
- padding: 0 10px;
+ padding: 5px 10px;
min-width: 400px;
+ max-width: 400px;
background: $gray-light;
+ border-left: 3px solid;
+
+ .commit-row-title {
+ display: flex;
+ }
+
+ .item-title {
+ flex: 1;
+ margin-right: 0.5em;
+ }
+ }
+
+ @for $i from 0 through 5 {
+ td.blame-commit-age-#{$i} {
+ border-left-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
+ }
+ }
+
+ @for $i from 1 through 4 {
+ td.blame-commit-age-#{$i + 5} {
+ border-left-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
+ }
}
td.line-numbers {
@@ -160,6 +239,18 @@
&.code {
padding: 0;
}
+
+ .list-inline.previews {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-content: flex-start;
+ align-items: baseline;
+
+ .preview {
+ padding: $gl-padding;
+ }
+ }
}
}
@@ -232,7 +323,7 @@ span.idiff {
border-bottom: 1px solid $border-color;
padding: 5px $gl-padding;
margin: 0;
- border-radius: 3px 3px 0 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
.file-header-content {
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 0692f65043b..ec13a86ccf7 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -22,12 +22,6 @@
}
@media (min-width: $screen-sm-min) {
- .issues_bulk_update {
- .dropdown-menu-toggle {
- width: 132px;
- }
- }
-
.filter-item:not(:last-child) {
margin-right: 6px;
}
@@ -76,6 +70,13 @@
.input-token {
max-width: 200px;
+ padding: 0;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
+ }
}
.input-token:only-child,
@@ -90,6 +91,7 @@
.filtered-search-term {
display: -webkit-flex;
display: flex;
+ flex-shrink: 0;
margin-top: 5px;
margin-bottom: 5px;
@@ -104,6 +106,22 @@
padding: 2px 7px;
}
+ .name {
+ background-color: $filter-name-resting-color;
+ color: $filter-name-text-color;
+ border-radius: 2px 0 0 2px;
+ margin-right: 1px;
+ text-transform: capitalize;
+ }
+
+ .value-container {
+ background-color: $white-normal;
+ color: $filter-value-text-color;
+ border-radius: 0 2px 2px 0;
+ margin-right: 5px;
+ padding-right: 8px;
+ }
+
.value {
padding-right: 0;
}
@@ -111,40 +129,47 @@
.remove-token {
display: inline-block;
padding-left: 4px;
- padding-right: 8px;
+ padding-right: 0;
.fa-close {
- color: $gl-text-color-disabled;
+ color: $gl-text-color-secondary;
}
&:hover .fa-close {
- color: $gl-text-color-secondary;
+ color: $gl-text-color;
+ }
+
+ &.inverted {
+ .fa-close {
+ color: $gl-text-color-secondary-inverted;
+ }
+
+ &:hover .fa-close {
+ color: $gl-text-color-inverted;
+ }
}
}
+}
+.filtered-search-token:hover,
+.filtered-search-token .selected,
+.filtered-search-term .selected {
.name {
- background-color: $filter-name-resting-color;
- color: $filter-name-text-color;
- border-radius: 2px 0 0 2px;
- margin-right: 1px;
- text-transform: capitalize;
+ background-color: $filter-name-selected-color;
}
.value-container {
- background-color: $white-normal;
- color: $filter-value-text-color;
- border-radius: 0 2px 2px 0;
- margin-right: 5px;
+ box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
+}
- .selected {
- .name {
- background-color: $filter-name-selected-color;
- }
+.droplab-dropdown li.filtered-search-token {
+ padding: 0;
- .value-container {
- background-color: $filter-value-selected-color;
- }
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
}
}
@@ -228,9 +253,6 @@
width: 35px;
background-color: $white-light;
border: none;
- position: absolute;
- right: 0;
- height: 100%;
outline: none;
z-index: 1;
@@ -253,7 +275,9 @@
}
.filtered-search-input-dropdown-menu {
+ max-height: 225px;
max-width: 280px;
+ overflow: auto;
@media (max-width: $screen-xs-min) {
width: auto;
@@ -273,17 +297,10 @@
.filtered-search-history-dropdown-toggle-button {
flex: 1;
width: auto;
- padding-right: 10px;
-
border-radius: 0;
- border-top: 0;
- border-left: 0;
- border-bottom: 0;
+ border: 0;
border-right: 1px solid $border-color;
-
color: $gl-text-color-secondary;
- line-height: 1;
-
transition: color 0.1s linear;
&:hover,
@@ -291,6 +308,17 @@
color: $gl-text-color;
border-color: $dropdown-input-focus-border;
outline: none;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ svg {
+ height: 14px;
+ width: 14px;
+ fill: $gl-text-color-secondary;
+ vertical-align: middle;
}
.dropdown-toggle-text {
@@ -302,11 +330,6 @@
color: inherit;
}
}
-
- .fa {
- position: static;
- }
-
}
.filtered-search-history-dropdown {
@@ -345,7 +368,7 @@
margin-right: 0.3em;
}
- & > .value {
+ > .value {
font-weight: 600;
}
}
@@ -359,21 +382,6 @@
}
}
-.dropdown-menu .filter-dropdown-item {
- padding: 0;
-}
-
-.filter-dropdown {
- max-height: 215px;
- overflow: auto;
-}
-
-@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .issue-bulk-update-dropdown-toggle {
- width: 100px;
- }
-}
-
@media (max-width: $screen-xs-max) {
.issues-details-filters {
padding: 0 0 10px;
@@ -385,7 +393,8 @@
@media (max-width: $screen-xs) {
.filter-dropdown-container {
.dropdown-toggle,
- .dropdown {
+ .dropdown,
+ .dropdown-menu {
width: 100%;
}
@@ -405,13 +414,16 @@
background-color: $dropdown-hover-color;
color: $white-light;
text-decoration: none;
+ outline: 0;
.avatar {
border-color: $white-light;
}
}
-.filter-dropdown-item {
+.droplab-dropdown .dropdown-menu .filter-dropdown-item {
+ padding: 0;
+
.btn {
border: none;
width: 100%;
@@ -423,6 +435,7 @@
.fa {
width: 15px;
+ line-height: $line-height-base;
}
.dropdown-label-box {
@@ -445,17 +458,14 @@
}
.dropdown-user {
- display: -webkit-flex;
display: flex;
}
.dropdown-user-details {
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: column;
flex-direction: column;
- &> span {
+ > span {
white-space: normal;
word-break: break-all;
}
@@ -468,4 +478,5 @@
.filter-dropdown-loading {
padding: 8px 16px;
+ text-align: center;
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index eadb9409fee..38d884bc7eb 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -16,6 +16,22 @@
@extend .alert;
@extend .alert-danger;
margin: 0;
+
+ .flash-text,
+ .flash-action {
+ display: inline-block;
+ }
+
+ a.flash-action {
+ margin-left: 5px;
+ text-decoration: none;
+ font-weight: normal;
+ border-bottom: 1px solid;
+
+ &:hover {
+ border-color: transparent;
+ }
+ }
}
.flash-notice,
@@ -36,6 +52,10 @@
border-radius: 0;
}
}
+
+ &:empty {
+ margin: 0;
+ }
}
@media (max-width: $screen-sm-max) {
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 432024779fd..61e3897f369 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -125,10 +125,11 @@ label {
.select-wrapper {
position: relative;
- .fa-caret-down {
+ .fa-chevron-down {
position: absolute;
+ font-size: 10px;
right: 10px;
- top: 10px;
+ top: 12px;
color: $gray-darkest;
pointer-events: none;
}
@@ -138,6 +139,12 @@ label {
padding-left: 10px;
padding-right: 10px;
-webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+
+ &::-ms-expand {
+ display: none;
+ }
}
.form-control-inline {
@@ -148,7 +155,8 @@ label {
margin-top: 35px;
}
-.form-group .control-label {
+.form-group .control-label,
+.form-group .control-label-full-width {
font-weight: normal;
}
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index c0de09f3968..dbdd5a4464b 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -2,7 +2,7 @@
* Styles that apply to all GFM related forms.
*/
+.gfm-commit,
.gfm-commit_range {
- font-family: $monospace_font;
- font-size: 90%;
+ @extend .commit-sha;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 6d9218310eb..b677882eba4 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -4,6 +4,8 @@
*/
header {
+ @include new-style-dropdown;
+
transition: padding $sidebar-transition-duration;
&.navbar-empty {
@@ -24,19 +26,35 @@ header {
&.navbar-gitlab {
padding: 0 16px;
- z-index: 100;
+ z-index: 1000;
margin-bottom: 0;
min-height: $header-height;
background-color: $gray-light;
border: none;
border-bottom: 1px solid $border-color;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ color: $gl-text-color-secondary;
+ border-radius: 0;
@media (max-width: $screen-xs-min) {
padding: 0 16px;
}
&.with-horizontal-nav {
- border-bottom: none;
+ border-bottom: 0;
+
+ .navbar-border {
+ height: 1px;
+ position: absolute;
+ right: 0;
+ left: 0;
+ bottom: -1px;
+ background-color: $border-color;
+ opacity: 0;
+ }
}
.container-fluid {
@@ -45,7 +63,7 @@ header {
padding: 0;
.nav > li > a {
- color: $gl-text-color-secondary;
+ color: currentColor;
font-size: 18px;
padding: 0;
margin: (($header-height - 28) / 2) 3px;
@@ -70,7 +88,7 @@ header {
&:hover,
&:focus,
&:active {
- background-color: $gray-light;
+ background-color: transparent;
color: $gl-text-color;
svg {
@@ -82,13 +100,19 @@ header {
font-size: 14px;
}
+ .fa-chevron-down {
+ position: relative;
+ top: -3px;
+ font-size: 10px;
+ }
+
svg {
position: relative;
top: 2px;
height: 17px;
// hack to get SVG to line up with FA icons
width: 23px;
- fill: $gl-text-color-secondary;
+ fill: currentColor;
}
}
@@ -110,6 +134,22 @@ header {
}
}
+ &.navbar-gitlab-new {
+ .fa-times {
+ display: none;
+ }
+
+ .menu-expanded {
+ .fa-ellipsis-v {
+ display: none;
+ }
+
+ .fa-times {
+ display: block;
+ }
+ }
+ }
+
.global-dropdown {
position: absolute;
left: -10px;
@@ -149,6 +189,19 @@ header {
min-height: $header-height;
padding-left: 30px;
+ &.menu-expanded {
+ @media (max-width: $screen-xs-max) {
+ .header-logo,
+ .title-container {
+ display: none;
+ }
+
+ .navbar-collapse {
+ display: block;
+ }
+ }
+ }
+
.dropdown-menu {
margin-top: -5px;
}
@@ -211,7 +264,7 @@ header {
}
a {
- color: $gl-text-color;
+ color: currentColor;
&:hover {
text-decoration: underline;
@@ -264,13 +317,17 @@ header {
}
}
+.with-performance-bar header.navbar-gitlab {
+ top: $performance-bar-height;
+}
+
.navbar-nav {
li {
.badge {
position: inherit;
- top: -3px;
+ top: -8px;
font-weight: normal;
- margin-left: -12px;
+ margin-left: -11px;
font-size: 11px;
color: $white-light;
padding: 1px 5px 2px;
@@ -308,7 +365,7 @@ header {
padding-left: 5px;
.nav > li:not(.hidden-xs) {
- display: table-cell!important;
+ display: table-cell !important;
width: 25%;
a {
@@ -332,6 +389,8 @@ header {
width: auto;
min-width: 140px;
margin-top: -5px;
+ color: $gl-text-color;
+ left: auto;
.current-user {
padding: 5px 18px;
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 6d27d7568cf..c63114f85b4 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -47,7 +47,7 @@
font-family: $monospace_font;
display: block;
font-size: $code_font_size !important;
- line-height: 19px;
+ min-height: 19px;
white-space: nowrap;
i {
@@ -61,7 +61,7 @@
&:focus {
outline: none;
- & i {
+ i {
visibility: visible;
}
}
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 87667f39ab8..ef864e8f6a9 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -1,4 +1,5 @@
-.ci-status-icon-success {
+.ci-status-icon-success,
+.ci-status-icon-passed {
color: $green-500;
svg {
@@ -64,3 +65,7 @@
text-decoration: none;
}
}
+
+.user-avatar-link {
+ text-decoration: none;
+}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 20c7bc93c28..bd521028c44 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -25,6 +25,10 @@ body {
.content-wrapper {
padding-bottom: 100px;
+
+ &:not(.page-with-layout-nav) {
+ margin-top: $header-height;
+ }
}
.container {
@@ -47,6 +51,10 @@ body {
&.limit-container-width {
max-width: $limited-layout-width;
}
+
+ &.limit-container-width-sm {
+ max-width: $limited-layout-width-sm;
+ }
}
.alert-wrapper {
@@ -101,14 +109,16 @@ body {
}
}
+.page-with-sidebar > .content-wrapper {
+ min-height: calc(100vh - #{$header-height});
+}
-/* The following prevents side effects related to iOS Safari's implementation of -webkit-overflow-scrolling: touch,
-which is applied to the body by jquery.nicescroling plugin to force hardware acceleration for momentum scrolling. Side
-effects are commonly related to inconsisent z-index behavior (e.g. tooltips). By applying the following to direct children
-of the body element here, we negate cascading side effects but allow momentum scrolling to be applied to the body */
+.with-performance-bar .page-with-sidebar {
+ margin-top: $header-height + $performance-bar-height;
+}
-.navbar,
-.page-gutter,
-.page-with-sidebar {
- -webkit-overflow-scrolling: auto;
+.vertical-center {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 15dc0aa6a52..df2bf561194 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -11,7 +11,6 @@
> li {
padding: 10px 15px;
min-height: 20px;
- border-bottom: 1px solid $list-border-light;
border-bottom: 1px solid $list-border;
&::after {
@@ -133,6 +132,8 @@ ul.content-list {
}
.controls {
+ @include new-style-dropdown;
+
float: right;
> .control-text {
@@ -152,6 +153,7 @@ ul.content-list {
margin-top: 3px;
margin-bottom: 4px;
+ &.has-tooltip,
&:last-child {
margin-right: 0;
@@ -236,6 +238,8 @@ ul.content-list {
ul.controls {
float: right;
list-style: none;
+ display: flex;
+ align-items: center;
.btn {
padding: 10px 14px;
@@ -255,11 +259,128 @@ ul.controls {
.avatar-inline {
margin-left: 0;
margin-right: 0;
+ margin-bottom: 0;
}
}
}
+
+ .issuable-pipeline-broken a,
+ .issuable-pipeline-status a,
+ .author_link {
+ display: flex;
+ }
}
ul.indent-list {
padding: 10px 0 0 30px;
}
+
+
+// Specific styles for tree list
+.group-list-tree {
+ .folder-toggle-wrap {
+ float: left;
+ line-height: $list-text-height;
+ font-size: 0;
+
+ span {
+ font-size: $gl-font-size;
+ }
+ }
+
+ .folder-caret,
+ .folder-icon {
+ display: inline-block;
+ }
+
+ .folder-caret {
+ width: 15px;
+ }
+
+ .folder-icon {
+ width: 20px;
+ }
+
+ > .group-row:not(.has-subgroups) {
+ .folder-caret .fa {
+ opacity: 0;
+ }
+ }
+
+ .content-list li:last-child {
+ padding-bottom: 0;
+ }
+
+ .group-list-tree {
+ margin-bottom: 0;
+ margin-left: 30px;
+ position: relative;
+
+ &::before {
+ content: '';
+ display: block;
+ width: 0;
+ position: absolute;
+ top: 5px;
+ bottom: 0;
+ left: -16px;
+ border-left: 2px solid $border-white-normal;
+ }
+
+ .group-row {
+ position: relative;
+
+ &::before {
+ content: "";
+ display: block;
+ width: 10px;
+ height: 0;
+ border-top: 2px solid $border-white-normal;
+ position: absolute;
+ top: 30px;
+ left: -16px;
+ }
+
+ &:last-child::before {
+ background: $white-light;
+ height: auto;
+ top: 30px;
+ bottom: 0;
+ }
+ }
+ }
+
+ .group-row {
+ padding: 0;
+ border: none;
+
+ &:last-of-type {
+ .group-row-contents:not(:hover) {
+ border-bottom: 1px solid transparent;
+ }
+ }
+ }
+
+ .group-row-contents {
+ padding: 10px 10px 8px;
+ border-top: solid 1px transparent;
+ border-bottom: solid 1px $white-normal;
+
+ &:hover {
+ border-color: $row-hover-border;
+ background-color: $row-hover;
+ cursor: pointer;
+ }
+
+ .avatar-container > a {
+ width: 100%;
+ }
+ }
+}
+
+.js-groups-list-holder {
+ .groups-list-loading {
+ font-size: 34px;
+ text-align: center;
+ }
+}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index a668a6c4c39..e3920b5d3d9 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -120,6 +120,10 @@
// Ensure that image does not exceed viewport
max-height: calc(100vh - 100px);
}
+
+ table {
+ @include markdown-table;
+ }
}
.toolbar-group {
@@ -161,8 +165,8 @@
.cur {
.avatar {
- border: 1px solid $white-light;
@include disableAllAnimation;
+ border: 1px solid $white-light;
}
}
@@ -170,3 +174,49 @@
white-space: nowrap;
}
}
+
+@media(max-width: $screen-xs-max) {
+ .atwho-view-ul {
+ width: 350px;
+ }
+
+ .atwho-view ul li {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+// TODO: fallback to global style
+.atwho-view {
+ .atwho-view-ul {
+ padding: 8px 1px;
+
+ li {
+ padding: 8px 16px;
+ border: 0;
+
+ &.cur {
+ background-color: $gray-darker;
+ color: $gl-text-color;
+
+ small {
+ color: inherit;
+ }
+ }
+
+ div.avatar {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+
+ .center {
+ line-height: 14px;
+ }
+ }
+
+ strong {
+ color: $gl-text-color;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/media_object.scss b/app/assets/stylesheets/framework/media_object.scss
new file mode 100644
index 00000000000..b573052c14a
--- /dev/null
+++ b/app/assets/stylesheets/framework/media_object.scss
@@ -0,0 +1,8 @@
+.media {
+ display: flex;
+ align-items: flex-start;
+}
+
+.media-body {
+ flex: 1;
+}
diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss
new file mode 100644
index 00000000000..81cdf6b59e4
--- /dev/null
+++ b/app/assets/stylesheets/framework/memory_graph.scss
@@ -0,0 +1,22 @@
+.memory-graph-container {
+ svg {
+ background: $white-light;
+ cursor: pointer;
+
+ &:hover {
+ box-shadow: 0 0 4px $gray-darkest inset;
+ }
+ }
+
+ path {
+ fill: none;
+ stroke: $blue-500;
+ stroke-width: 2px;
+ }
+
+ circle {
+ stroke: $blue-700;
+ fill: $blue-700;
+ stroke-width: 4px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index b3340d41333..6f91d11b369 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -13,6 +13,13 @@
}
/*
+ * Mixin for markdown tables
+ */
+@mixin markdown-table {
+ width: auto;
+}
+
+/*
* Base mixin for lists in GitLab
*/
@mixin basic-list {
@@ -111,3 +118,29 @@
@content;
}
}
+
+/*
+ * Mixin for status badges, as used for pipelines and commit signatures
+ */
+@mixin status-color($color-light, $color-main, $color-dark) {
+ color: $color-main;
+ border-color: $color-main;
+
+ &:not(span):hover {
+ background-color: $color-light;
+ color: $color-dark;
+ border-color: $color-dark;
+
+ svg {
+ fill: $color-dark;
+ }
+ }
+
+ svg {
+ fill: $color-main;
+ }
+}
+
+@mixin green-status-color {
+ @include status-color($green-50, $green-500, $green-700);
+}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index eb73f7cc794..600a1f53b58 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -29,10 +29,6 @@
display: none;
}
- .issues-holder .issue-check {
- display: none;
- }
-
.rss-btn {
display: none;
}
@@ -112,11 +108,6 @@
}
}
- .issue_edited_ago,
- .note_edited_ago {
- display: none;
- }
-
aside:not(.right-sidebar) {
display: none;
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 7098203321d..a28f54936be 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -21,3 +21,9 @@ body.modal-open {
width: 860px;
}
}
+
+@media (min-width: $screen-lg-min) {
+ .modal-full {
+ width: 98%;
+ }
+}
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
index b6cf5101d60..071f20fc457 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/nav.scss
@@ -24,10 +24,10 @@
}
@mixin scrolling-links() {
- white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
+ display: flex;
&::-webkit-scrollbar {
display: none;
@@ -35,6 +35,7 @@
}
.nav-links {
+ display: flex;
padding: 0;
margin: 0;
list-style: none;
@@ -42,17 +43,16 @@
border-bottom: 1px solid $border-color;
li {
- display: inline-block;
+ display: flex;
a {
- display: inline-block;
padding: $gl-btn-padding;
padding-bottom: 11px;
- margin-bottom: -1px;
font-size: 14px;
line-height: 28px;
color: $gl-text-color-secondary;
border-bottom: 2px solid transparent;
+ white-space: nowrap;
&:hover,
&:active,
@@ -85,10 +85,10 @@
.container-fluid {
background-color: $gray-normal;
margin-bottom: 0;
+ display: flex;
}
li {
-
&.active a {
border-bottom: none;
color: $link-underline-blue;
@@ -137,9 +137,9 @@
}
.nav-links {
- display: inline-block;
margin-bottom: 0;
border-bottom: none;
+ float: left;
&.wide {
width: 100%;
@@ -161,6 +161,8 @@
}
.nav-controls {
+ @include new-style-dropdown;
+
display: inline-block;
float: right;
text-align: right;
@@ -182,6 +184,12 @@
}
}
+ &.nav-controls-new-nav {
+ > .dropdown {
+ margin-right: 0;
+ }
+ }
+
> .btn-grouped {
float: none;
}
@@ -190,14 +198,6 @@
display: none;
}
- .btn,
- .dropdown,
- .dropdown-toggle,
- input,
- form {
- height: 35px;
- }
-
input {
display: inline-block;
position: relative;
@@ -253,7 +253,6 @@
// Applies on /dashboard/issues
.project-item-select-holder {
- display: block;
margin: 0;
}
}
@@ -285,12 +284,38 @@
}
}
+.project-item-select-holder.btn-group {
+ display: flex;
+ max-width: 350px;
+ overflow: hidden;
+
+ @media(max-width: $screen-xs-max) {
+ width: 100%;
+ max-width: none;
+ }
+
+ .new-project-item-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .new-project-item-select-button {
+ width: 32px;
+ }
+}
+
+.new-project-item-select-button .fa-caret-down {
+ margin-left: 2px;
+}
+
.layout-nav {
width: 100%;
background: $gray-light;
border-bottom: 1px solid $border-color;
transition: padding $sidebar-transition-duration;
text-align: center;
+ margin-top: $header-height;
.container-fluid {
position: relative;
@@ -324,7 +349,7 @@
position: absolute;
top: 7px;
right: 15px;
- z-index: 2;
+ z-index: 300;
li.active {
font-weight: bold;
@@ -336,6 +361,10 @@
border-bottom: none;
height: 51px;
+ @media (min-width: $screen-sm-min) {
+ justify-content: center;
+ }
+
li {
a {
padding-top: 10px;
@@ -344,9 +373,17 @@
}
}
+.with-performance-bar .layout-nav {
+ margin-top: $header-height + $performance-bar-height;
+}
+
.scrolling-tabs-container {
position: relative;
+ .merge-request-tabs-container & {
+ overflow: hidden;
+ }
+
.nav-links {
@include scrolling-links();
}
@@ -428,14 +465,30 @@
top: ($header-height + 1) * 3;
&.affix {
- top: 0;
+ top: $header-height;
}
}
}
}
-.activities {
- .nav-block {
+.with-performance-bar .page-with-layout-nav {
+ .right-sidebar {
+ top: ($header-height + 1) * 2 + $performance-bar-height;
+ }
+
+ &.page-with-sub-nav {
+ .right-sidebar {
+ top: ($header-height + 1) * 3 + $performance-bar-height;
+
+ &.affix {
+ top: $header-height + $performance-bar-height;
+ }
+ }
+ }
+}
+
+.nav-block {
+ &.activities {
border-bottom: 1px solid $border-color;
.nav-links {
@@ -484,10 +537,6 @@
.inner-page-scroll-tabs {
position: relative;
- .nav-links {
- padding-bottom: 1px;
- }
-
.fade-right {
@include fade(left, $white-light);
right: 0;
diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss
new file mode 100644
index 00000000000..d349e3fad9c
--- /dev/null
+++ b/app/assets/stylesheets/framework/notes.scss
@@ -0,0 +1,14 @@
+@mixin notes-media($condition, $breakpoint-width) {
+ @media (#{$condition}-width: ($breakpoint-width)) {
+ @content;
+ }
+
+ // Diff is side by side
+ .notes_content.parallel & {
+ // We hide at double what we normally hide at because
+ // there are two columns of notes
+ @media (#{$condition}-width: (2 * $breakpoint-width)) {
+ @content;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page-header.scss
index 5f4211147f3..f1ecd050a0a 100644
--- a/app/assets/stylesheets/framework/page-header.scss
+++ b/app/assets/stylesheets/framework/page-header.scss
@@ -59,4 +59,8 @@
margin: 0 2px 0 3px;
}
}
+
+ .ci-status {
+ margin-right: 10px;
+ }
}
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index 9d8d08dff88..e8d69e62194 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -1,50 +1,60 @@
.panel {
margin-bottom: $gl-padding;
+}
+
+.panel-slim {
+ @extend .panel;
+ margin-bottom: $gl-vert-padding;
+}
+
+
+.panel-heading {
+ padding: $gl-vert-padding $gl-padding;
+ line-height: 36px;
+
+ .controls {
+ margin-top: -2px;
+ float: right;
+ }
+
+ .dropdown-menu-toggle {
+ line-height: 20px;
+ }
- .panel-heading {
- padding: $gl-vert-padding $gl-padding;
- line-height: 36px;
-
- .controls {
- margin-top: -2px;
- float: right;
- }
-
- .dropdown-menu-toggle {
- line-height: 20px;
- }
-
- .badge {
- margin-top: -2px;
- margin-left: 5px;
- }
-
- &.split {
- display: flex;
- align-items: center;
- }
-
- .left {
- flex: 1 1 auto;
- }
-
- .right {
- flex: 0 0 auto;
- text-align: right;
- }
+ .badge {
+ margin-top: -2px;
+ margin-left: 5px;
}
- .panel-body {
- padding: $gl-padding;
+ &.split {
+ display: flex;
+ align-items: center;
+ }
- .form-actions {
- margin: -$gl-padding;
- margin-top: $gl-padding;
- }
+ .left {
+ flex: 1 1 auto;
}
- .panel-title {
- font-size: inherit;
- line-height: inherit;
+ .right {
+ flex: 0 0 auto;
+ text-align: right;
}
}
+
+.panel-empty-heading {
+ border-bottom: 0;
+}
+
+.panel-body {
+ padding: $gl-padding;
+
+ .form-actions {
+ margin: -$gl-padding;
+ margin-top: $gl-padding;
+ }
+}
+
+.panel-title {
+ font-size: inherit;
+ line-height: inherit;
+}
diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss
new file mode 100644
index 00000000000..8e653c443cf
--- /dev/null
+++ b/app/assets/stylesheets/framework/responsive-tables.scss
@@ -0,0 +1,137 @@
+@mixin flex-max-width($max) {
+ flex: 0 0 #{$max + '%'};
+ max-width: #{$max + '%'};
+}
+
+.gl-responsive-table-row {
+ margin-top: 10px;
+ border: 1px solid $border-color;
+
+ @media (min-width: $screen-md-min) {
+ padding: 15px 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ border: none;
+ border-bottom: 1px solid $white-normal;
+ }
+
+ .table-section {
+ white-space: nowrap;
+
+ $section-widths: 10 15 20 25 30 40;
+ @each $width in $section-widths {
+ &.section-#{$width} {
+ flex: 0 0 #{$width + '%'};
+
+ @media (min-width: $screen-md-min) {
+ max-width: #{$width + '%'};
+ }
+ }
+ }
+
+ &:not(.table-button-footer) {
+ @media (max-width: $screen-sm-max) {
+ display: flex;
+ align-self: stretch;
+ padding: 10px;
+ align-items: center;
+ min-height: 62px;
+
+ &:not(:first-of-type) {
+ border-top: 1px solid $white-normal;
+ }
+ }
+ }
+
+ &.section-wrap {
+ white-space: normal;
+
+ @media (max-width: $screen-sm-max) {
+ flex-wrap: wrap;
+ }
+ }
+ }
+}
+
+
+.table-button-footer {
+ @media (min-width: $screen-md-min) {
+ text-align: right;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ background-color: $gray-normal;
+ align-self: stretch;
+ border-top: 1px solid $border-color;
+
+ .table-action-buttons {
+ padding: 10px 5px;
+ display: flex;
+
+ .btn {
+ border-radius: 3px;
+ }
+
+ > .btn-group,
+ > .external-url,
+ > .btn {
+ flex: 1 1 28px;
+ margin: 0 5px;
+ }
+
+ .dropdown-new {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ min-width: initial;
+ }
+ }
+ }
+}
+
+.table-row-header {
+ font-size: 13px;
+
+ @media (max-width: $screen-sm-max) {
+ display: none;
+ }
+}
+
+.table-mobile-header {
+ @include flex-max-width(40);
+ color: $gl-text-color-secondary;
+ text-align: left;
+
+ @media (min-width: $screen-md-min) {
+ display: none;
+ }
+}
+
+.table-mobile-content {
+ @media (max-width: $screen-sm-max) {
+ @include flex-max-width(60);
+ text-align: right;
+ }
+}
+
+.flex-truncate-parent {
+ display: flex;
+}
+
+.flex-truncate-child {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (min-width: $screen-md-min) {
+ flex: 0 0 90%;
+ }
+
+ .avatar {
+ float: none;
+ margin-right: 4px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 9ab17e67d4c..40e654f4838 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -18,19 +18,28 @@
background-image: none;
background-color: transparent;
border: none;
- padding-top: 6px;
- padding-right: 10px;
+ padding-top: 12px;
+ padding-right: 20px;
+ font-size: 10px;
b {
- display: inline-block;
- width: 0;
- height: 0;
- margin-left: 2px;
- vertical-align: middle;
- border-top: 5px dashed;
- border-right: 5px solid transparent;
- border-left: 5px solid transparent;
+ display: none;
+ }
+
+ &::after {
+ content: "\f078";
+ position: absolute;
+ z-index: 1;
+ text-align: center;
+ pointer-events: none;
+ box-sizing: border-box;
color: $gray-darkest;
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
}
@@ -96,7 +105,6 @@
.select2-search-field input {
padding: 5px $gl-padding / 2;
- font-size: 13px;
height: auto;
font-family: inherit;
font-size: inherit;
@@ -110,10 +118,12 @@
line-height: 15px;
background-color: $gray-light;
background-image: none;
+ padding: 3px 18px 3px 5px;
.select2-search-choice-close {
- top: 4px;
- left: 3px;
+ top: 5px;
+ left: initial;
+ right: 3px;
}
&.select2-search-choice-focus {
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 746c9c25620..40e8a928e6e 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -33,7 +33,7 @@
padding-right: 0;
@media (min-width: $screen-sm-min) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
@@ -53,9 +53,10 @@
.right-sidebar-expanded {
padding-right: 0;
+ z-index: 300;
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
}
@@ -77,9 +78,57 @@
.right-sidebar {
border-left: 1px solid $border-color;
+ height: calc(100% - #{$header-height});
&.affix {
position: fixed;
- top: 0;
+ top: $header-height;
+ }
+}
+
+.with-performance-bar .right-sidebar.affix {
+ top: $header-height + $performance-bar-height;
+}
+
+@mixin maintain-sidebar-dimensions {
+ display: block;
+ width: $gutter-width;
+}
+
+.issues-bulk-update.right-sidebar {
+ @include maintain-sidebar-dimensions;
+ width: 0;
+ padding: 0;
+ transition: width $sidebar-transition-duration;
+
+ &.right-sidebar-expanded {
+ @include maintain-sidebar-dimensions;
+ width: $gutter-width;
+
+ .issuable-sidebar-header {
+ // matches `.top-area .nav-controls` for issuable index pages
+ padding: 11px 0;
+ }
+
+ .block:last-of-type {
+ border: none;
+ }
+ }
+
+ &.right-sidebar-collapsed {
+ @include maintain-sidebar-dimensions;
+ width: 0;
+ padding: 0;
+
+ .block {
+ padding: 16px 0;
+ width: 250px;
+ border-bottom: 1px solid $border-color;
+ }
+ }
+
+ .issuable-sidebar {
+ padding: 0 3px;
+ width: calc(100% + 35px);
}
}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index d2164a1d333..3d68a50f91f 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -3,33 +3,9 @@
margin: 0;
padding: 0;
- .timeline-entry {
- padding: $gl-padding $gl-btn-padding 0;
- border-color: $white-normal;
- color: $gl-text-color;
- border-bottom: 1px solid $border-white-light;
-
- .timeline-entry-inner {
- position: relative;
- }
-
- &:target {
- background: $line-target-blue;
- }
-
- .avatar {
- margin-right: 15px;
- }
-
- .controls {
- padding-top: 10px;
- float: right;
- }
- }
-
- .note-text {
- p:last-child {
- margin-bottom: 0;
+ &::before {
+ @include notes-media('max', $screen-xs-min) {
+ background: none;
}
}
@@ -46,13 +22,15 @@
}
}
-@media (max-width: $screen-xs-max) {
- .timeline {
- &::before {
- background: none;
- }
+.timeline-entry {
+ border-color: $white-normal;
+ color: $gl-text-color;
+ border-bottom: 1px solid $border-white-light;
+
+ .timeline-entry-inner {
+ position: relative;
- .timeline-entry .timeline-entry-inner {
+ @include notes-media('max', $screen-xs-min) {
.timeline-icon {
display: none;
}
@@ -62,6 +40,24 @@
}
}
}
+
+ &:target,
+ &.target {
+ background: $line-target-blue;
+
+ &.system-note .note-body .note-text.system-note-commit-list::after {
+ background: linear-gradient(rgba($line-target-blue, 0.1) -100px, $line-target-blue 100%);
+ }
+ }
+
+ .avatar {
+ margin-right: 15px;
+ }
+
+ .controls {
+ padding-top: 10px;
+ float: right;
+ }
}
.discussion .timeline-entry {
diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
index c9f345d24be..4c35e3a9c3c 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
@@ -74,9 +74,9 @@ $pagination-hover-color: $gl-text-color;
$pagination-hover-bg: $row-hover;
$pagination-hover-border: $border-color;
-$pagination-active-color: $blue-600;
-$pagination-active-bg: $white-light;
-$pagination-active-border: $border-color;
+$pagination-active-color: $white-light;
+$pagination-active-bg: $gl-link-color;
+$pagination-active-border: $gl-link-color;
$pagination-disabled-color: #cdcdcd;
$pagination-disabled-bg: $gray-light;
@@ -162,3 +162,5 @@ $pre-color: $gl-text-color !default;
$pre-border-color: $border-color;
$table-bg-accent: $gray-light;
+
+$zindex-popover: 900;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 96d8a812723..0bd84c9e08c 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -2,13 +2,27 @@
color: $gl-text-color;
word-wrap: break-word;
+ [dir="auto"] {
+ text-align: initial;
+ }
+
a {
color: $md-link-color;
}
img {
- max-width: 100%;
+ /*max-width: 100%;*/
margin: 0 0 8px;
+ min-width: 200px;
+ min-height: 100px;
+ background-color: $gray-lightest;
+ }
+
+ img.js-lazy-loaded,
+ img.emoji {
+ min-width: inherit;
+ min-height: inherit;
+ background-color: inherit;
}
p a:not(.no-attachment-icon) img {
@@ -21,6 +35,10 @@
margin-top: 0;
}
+ > :last-child {
+ margin-bottom: 0;
+ }
+
// Single code lines should wrap
code {
font-family: $monospace_font;
@@ -108,9 +126,12 @@
blockquote p {
color: $gl-grayish-blue !important;
- margin: 0;
font-size: inherit;
line-height: 1.5;
+
+ &:last-child {
+ margin: 0;
+ }
}
p {
@@ -139,6 +160,15 @@
line-height: 1.6em;
overflow-x: auto;
border-radius: 2px;
+
+
+ &.plain-readme {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ }
}
p > code {
@@ -148,7 +178,7 @@
ul,
ol {
padding: 0;
- margin: 0 0 16px !important;
+ margin: 0 0 16px;
}
ul:dir(rtl),
@@ -169,14 +199,14 @@
}
ul.task-list {
- li.task-list-item {
+ > li.task-list-item {
list-style-type: none;
position: relative;
min-height: 22px;
padding-left: 28px;
margin-left: 0 !important;
- input.task-list-item-checkbox {
+ > input.task-list-item-checkbox {
position: absolute;
left: 8px;
top: 5px;
@@ -279,19 +309,6 @@ h6 {
/** CODE **/
pre {
font-family: $monospace_font;
-
- &.plain-readme {
- background: none;
- border: none;
- padding: 0;
- margin: 0;
- font-size: 14px;
- }
-}
-
-.monospace {
- font-family: $monospace_font;
- font-size: 90%;
}
code {
@@ -305,6 +322,26 @@ a > code {
color: $link-color;
}
+.monospace {
+ font-family: $monospace_font;
+}
+
+.commit-sha,
+.ref-name {
+ @extend .monospace;
+ font-size: 95%;
+}
+
+.git-revision-dropdown-toggle {
+ @extend .monospace;
+}
+
+.git-revision-dropdown .dropdown-content ul li a {
+ @extend .ref-name;
+}
+
+@include new-style-dropdown('.git-revision-dropdown');
+
/**
* Apply Markdown typography
*
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 49741c963df..3c109a5a929 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -74,8 +74,21 @@ $red-700: #a62d19;
$red-800: #8b2615;
$red-900: #711e11;
+$indigo-50: #f7f7ff;
+$indigo-100: #ebebfa;
+$indigo-200: #d1d1f0;
+$indigo-300: #a6a6de;
+$indigo-400: #7c7ccc;
+$indigo-500: #6666c4;
+$indigo-600: #5b5bbd;
+$indigo-700: #4b4ba3;
+$indigo-800: #393982;
+$indigo-900: #292961;
+$indigo-950: #1a1a40;
+
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
+$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $darken-border-factor);
@@ -98,9 +111,12 @@ $well-light-text-color: #5b6169;
* Text
*/
$gl-font-size: 14px;
-$gl-text-color: rgba(0, 0, 0, .85);
-$gl-text-color-secondary: rgba(0, 0, 0, .55);
-$gl-text-color-disabled: rgba(0, 0, 0, .35);
+$gl-text-color: #2e2e2e;
+$gl-text-color-secondary: #707070;
+$gl-text-color-tertiary: #949494;
+$gl-text-color-quaternary: #d6d6d6;
+$gl-text-color-inverted: rgba(255, 255, 255, 1.0);
+$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
$gl-text-green: $green-600;
$gl-text-red: $red-500;
$gl-text-orange: $orange-600;
@@ -109,9 +125,10 @@ $gl-link-hover-color: $blue-800;
$gl-grayish-blue: #7f8fa4;
$gl-gray: $gl-text-color;
$gl-gray-dark: #313236;
+$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54;
$gl-header-nav-hover-color: #434343;
-$placeholder-text-color: rgba(0, 0, 0, .42);
+$placeholder-text-color: $gl-text-color-tertiary;
/*
* Lists
@@ -119,7 +136,7 @@ $placeholder-text-color: rgba(0, 0, 0, .42);
$list-font-size: $gl-font-size;
$list-title-color: $gl-text-color;
$list-text-color: $gl-text-color;
-$list-text-disabled-color: $gl-text-color-disabled;
+$list-text-disabled-color: $gl-text-color-tertiary;
$list-border-light: #eee;
$list-border: rgba(0, 0, 0, 0.05);
$list-text-height: 42px;
@@ -143,6 +160,7 @@ $code_line_height: 1.6;
* Padding
*/
$gl-padding: 16px;
+$gl-col-padding: 15px;
$gl-btn-padding: 10px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
@@ -158,9 +176,11 @@ $progress-color: #c0392b;
$header-height: 50px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
+$limited-layout-width-sm: 790px;
+$container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
-$border-radius-default: 2px;
+$border-radius-default: 3px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
@@ -184,7 +204,8 @@ $divergence-graph-bar-bg: #ccc;
$divergence-graph-separator-bg: #ccc;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
-
+$highlight-changes-color: rgb(235, 255, 232);
+$performance-bar-height: 35px;
/*
* Common component specific colors
@@ -244,7 +265,6 @@ $dark-diff-match-bg: rgba(255, 255, 255, 0.3);
$dark-diff-match-color: rgba(255, 255, 255, 0.1);
$file-mode-changed: #777;
$file-mode-changed: #777;
-$diff-image-bg: #ddd;
$diff-image-info-color: grey;
$diff-swipe-border: #999;
$diff-view-modes-color: grey;
@@ -253,7 +273,7 @@ $diff-view-modes-border: #c1c1c1;
/*
* Fonts
*/
-$monospace_font: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
+$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/*
@@ -279,6 +299,7 @@ $dropdown-toggle-active-border-color: darken($border-color, 14%);
/*
* Filtered Search
*/
+$filtered-search-term-shadow-color: rgba(0, 0, 0, 0.09);
$dropdown-hover-color: $blue-400;
/*
@@ -291,13 +312,19 @@ $btn-white-active: #848484;
/*
* Badges
*/
-$badge-bg: #eee;
+$badge-bg: rgba(0, 0, 0, 0.07);
$badge-color: $gl-text-color-secondary;
/*
+ * Status icons
+ */
+$status-icon-size: 22px;
+$status-icon-margin: $gl-btn-padding;
+
+/*
* Award emoji
*/
-$award-emoji-menu-shadow: rgba(0,0,0,.175);
+$award-emoji-menu-shadow: rgba(0, 0, 0, .175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
@@ -319,6 +346,7 @@ $note-disabled-comment-color: #b2b2b2;
$note-targe3-outside: #fffff0;
$note-targe3-inside: #ffffd3;
$note-line2-border: #ddd;
+$note-icon-gutter-width: 55px;
/*
@@ -358,10 +386,19 @@ $issue-boards-card-shadow: rgba(186, 186, 186, 0.5);
* Avatar
*/
$avatar_radius: 50%;
-$avatar-border: rgba(0, 0, 0, .1);
+$avatar-border: $gray-normal;
+$avatar-border-hover: $gray-darker;
+$avatar-background: $gray-lightest;
$gl-avatar-size: 40px;
/*
+* Blame
+*/
+$blame-gray: #ededed;
+$blame-cyan: #acd5f2;
+$blame-blue: #254e77;
+
+/*
* Builds
*/
$builds-trace-bg: #111;
@@ -423,6 +460,7 @@ $logs-p-color: #333;
/*
* Forms
*/
+$input-height: 34px;
$input-danger-bg: #f2dede;
$input-danger-border: $red-400;
$input-group-addon-bg: #f7f8fa;
@@ -539,7 +577,7 @@ $kdb-color: #555;
$kdb-border: #ccc;
$kdb-border-bottom: #bbb;
$kdb-shadow: #bbb;
-$body-text-shadow: rgba(255,255,255,0.01);
+$body-text-shadow: rgba(255, 255, 255, 0.01);
/*
* UI Dev Kit
@@ -555,6 +593,12 @@ $stage-hover-border: #d1e7fc;
$action-icon-color: #d6d6d6;
/*
+Pipeline Schedules
+*/
+$pipeline-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
+
+
+/*
Filtered Search
*/
$filter-name-resting-color: #f8f8f8;
@@ -567,3 +611,37 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
+
+/*
+Convdev Index
+*/
+$color-high-score: $green-400;
+$color-average-score: $orange-400;
+$color-low-score: $red-400;
+
+/*
+Repo editor
+*/
+$repo-editor-grey: #f6f7f9;
+$repo-editor-grey-darker: #e9ebee;
+$repo-editor-linear-gradient: linear-gradient(to right, $repo-editor-grey 0%, $repo-editor-grey-darker, 20%, $repo-editor-grey 40%, $repo-editor-grey 100%);
+
+/*
+Performance Bar
+*/
+$perf-bar-text: #999;
+$perf-bar-production: #222;
+$perf-bar-staging: #291430;
+$perf-bar-development: #4c1210;
+$perf-bar-bucket-bg: #111;
+$perf-bar-bucket-color: #ccc;
+$perf-bar-bucket-box-shadow-from: rgba($white-light, .2);
+$perf-bar-bucket-box-shadow-to: rgba($black, .25);
+
+
+/*
+Project Templates Icons
+*/
+$rails: #c00;
+$node: #353535;
+$java: #70ad51;
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 32eb750180f..b1ff2659131 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -3,6 +3,7 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+ margin-bottom: $gl-padding;
.well-segment {
padding: $gl-padding;
@@ -12,10 +13,19 @@
}
&.branch-info {
- .monospace,
+ .commit-sha,
.commit-info {
margin-left: 4px;
}
+
+ .ref-name {
+ font-size: 12px;
+ }
+ }
+
+ &.admin-well h4 {
+ border-bottom: 1px solid $border-color;
+ padding-bottom: 8px;
}
}
@@ -49,6 +59,14 @@
padding: 15px;
}
+.dark-well {
+ background-color: $gray-normal;
+
+ .btn {
+ width: 100%;
+ }
+}
+
.well-centered {
h1 {
font-weight: normal;
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 09951fe3d3e..6e3829d994f 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -185,6 +185,11 @@ $dark-il: #de935f;
color: $dark-highlight-color !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $dark-na;
+ }
+
.hll { background-color: $dark-hll-bg; }
.c { color: $dark-c; } /* Comment */
.err { color: $dark-err; } /* Error */
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index b6a6d298adf..68eb0c7720f 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -185,6 +185,11 @@ $monokai-gi: #a6e22e;
color: $black !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $monokai-k;
+ }
+
.hll { background-color: $monokai-hll; }
.c { color: $monokai-c; } /* Comment */
.err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index 4f7a50dcb4f..2cc968c32f2 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -188,6 +188,11 @@ $solarized-dark-il: #2aa198;
background-color: $solarized-dark-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-dark-kd;
+ }
+
/* Solarized Dark
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index 6463fe96c1b..b61b85a2cd1 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -196,6 +196,11 @@ $solarized-light-il: #2aa198;
background-color: $solarized-light-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-light-kd;
+ }
+
/* Solarized Light
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index ab2018bfbca..578f1902cce 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -113,7 +113,7 @@ $white-gc-bg: #eaf2f5;
border-color: $line-removed-dark;
a {
- color: scale-color($line-number-old,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
}
}
@@ -122,7 +122,7 @@ $white-gc-bg: #eaf2f5;
border-color: $line-added-dark;
a {
- color: scale-color($line-number-new,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
}
}
@@ -163,7 +163,7 @@ $white-gc-bg: #eaf2f5;
background-color: $line-removed;
&::before {
- color: scale-color($line-number-old,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
}
span.idiff {
@@ -175,7 +175,7 @@ $white-gc-bg: #eaf2f5;
background-color: $line-added;
&::before {
- color: scale-color($line-number-new,$red: -30%, $green: -30%, $blue: -30%);
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
}
span.idiff {
@@ -203,6 +203,11 @@ $white-gc-bg: #eaf2f5;
background-color: $white-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $white-nb;
+ }
+
.hll { background-color: $white-hll-bg; }
.c { color: $white-c; font-style: italic; }
.err { color: $white-err; background-color: $white-err-bg; }
diff --git a/app/assets/stylesheets/mailers/devise.scss b/app/assets/stylesheets/mailers/devise.scss
deleted file mode 100644
index 9f613710cf4..00000000000
--- a/app/assets/stylesheets/mailers/devise.scss
+++ /dev/null
@@ -1,140 +0,0 @@
-@import "framework/variables";
-
-// NOTE: This stylesheet is for the exclusive use of the `devise_mailer` layout
-// used for Devise email templates, and _should not_ be included in any
-// application stylesheets.
-//
-// Styles defined here are embedded directly into the resulting email HTML via
-// the `premailer` gem.
-
-$body-background-color: #363636;
-$message-background-color: #fafafa;
-
-$header-color: #6b4fbb;
-$body-color: #444;
-$cta-color: #e14329;
-$footer-link-color: #7e7e7e;
-
-$font-family: Helvetica, Arial, sans-serif;
-
-body {
- background-color: $body-background-color;
- font-family: $font-family;
- margin: 0;
- padding: 0;
-}
-
-table {
- -premailer-cellpadding: 0;
- -premailer-cellspacing: 0;
-
- border: 0;
- border-collapse: separate;
-
- &#wrapper {
- background-color: $body-background-color;
- width: 100%;
- }
-
- &#header {
- margin: 0 auto;
- text-align: left;
- width: 600px;
-
- & > td {
- text-align: center;
- }
- }
-
- &#body {
- background-color: $message-background-color;
- border: 1px solid $black;
- border-radius: 4px;
- margin: 0 auto;
- width: 600px;
- }
-
- &#footer {
- color: $footer-link-color;
- font-size: 14px;
- text-align: center;
- width: 100%;
- }
-
- td {
- &#body-container {
- padding: 20px 40px;
- }
- }
-}
-
-.center {
- text-align: center;
-}
-
-#logo {
- border: none;
- outline: none;
- min-height: 88px;
- width: 134px;
-}
-
-#content {
- h2 {
- color: $header-color;
- font-size: 30px;
- font-weight: 400;
- line-height: 34px;
- margin-top: 0;
- }
-
- p {
- color: $body-color;
- font-size: 17px;
- line-height: 24px;
- margin-bottom: 0;
- }
-}
-
-#cta {
- border: 1px solid $cta-color;
- border-radius: 3px;
- display: inline-block;
- margin: 20px 0;
- padding: 12px 24px;
-
- a {
- background-color: $message-background-color;
- color: $cta-color;
- display: inline-block;
- text-decoration: none;
- }
-}
-
-#tanuki {
- padding: 40px 0 0;
-
- img {
- border: none;
- outline: none;
- width: 37px;
- min-height: 36px;
- }
-}
-
-#tagline {
- font-size: 22px;
- font-weight: 100;
- padding: 4px 0 40px;
-}
-
-#social {
- padding: 0 10px 20px;
- width: 600px;
- word-spacing: 20px;
-
- a {
- color: $footer-link-color;
- text-decoration: none;
- }
-}
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
new file mode 100644
index 00000000000..3e2f23e6b2a
--- /dev/null
+++ b/app/assets/stylesheets/new_nav.scss
@@ -0,0 +1,450 @@
+@import "framework/variables";
+@import 'framework/tw_bootstrap_variables';
+@import "bootstrap/variables";
+
+header.navbar-gitlab-new {
+ color: $white-light;
+ background: linear-gradient(to right, $indigo-900, $indigo-800);
+ border-bottom: 0;
+
+ .header-content {
+ padding-left: 0;
+
+ .title-container {
+ align-items: stretch;
+ padding-top: 0;
+ overflow: visible;
+ }
+
+ .title {
+ display: flex;
+ padding-right: 0;
+ color: currentColor;
+
+ img {
+ height: 28px;
+ margin-right: 10px;
+ }
+
+ > a {
+ display: flex;
+ align-items: center;
+ padding-right: $gl-padding;
+ padding-left: $gl-padding;
+ margin-left: -$gl-padding;
+
+ @media (min-width: $screen-sm-min) {
+ padding-right: $gl-padding;
+ padding-left: $gl-padding;
+ }
+
+ svg {
+ margin-top: -3px;
+
+ @media (min-width: $screen-sm-min) {
+ margin-right: 10px;
+ }
+ }
+
+ .logo-text {
+ line-height: initial;
+
+ svg {
+ width: 55px;
+ height: 15px;
+ margin: 0;
+ fill: $white-light;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ .logo-text svg {
+ fill: $tanuki-yellow;
+ }
+ }
+ }
+ }
+
+ .dropdown.open {
+ > a {
+ border-bottom-color: $white-light;
+ }
+ }
+
+ .dropdown-menu {
+ margin-top: 4px;
+ min-width: 130px;
+
+ @media (max-width: $screen-xs-max) {
+ left: auto;
+ right: 0;
+ }
+ }
+ }
+
+ .navbar-collapse {
+ padding-left: 0;
+ color: $indigo-200;
+ box-shadow: 0;
+
+ @media (max-width: $screen-xs-max) {
+ margin-left: -$gl-padding;
+ margin-right: -10px;
+ }
+
+ .dropdown-bold-header {
+ color: initial;
+ }
+
+ .nav {
+ > li:not(.hidden-xs) a {
+ @media (max-width: $screen-xs-max) {
+ margin-left: 0;
+ min-width: 100%;
+ }
+ }
+ }
+ }
+
+ .container-fluid {
+ .navbar-toggle {
+ min-width: 45px;
+ padding: 6px $gl-padding;
+ margin-right: -7px;
+ font-size: 14px;
+ text-align: center;
+ color: currentColor;
+ border-left: 1px solid lighten($indigo-700, 10%);
+
+ &:hover,
+ &:focus,
+ &.active {
+ color: currentColor;
+ background-color: transparent;
+ }
+ }
+
+ .navbar-nav {
+ @media (max-width: $screen-xs-max) {
+ display: flex;
+ padding-right: 10px;
+ }
+
+ li {
+ .badge {
+ box-shadow: none;
+ font-weight: 600;
+ }
+ }
+ }
+
+ .nav > li {
+ &.header-user {
+ @media (max-width: $screen-xs-max) {
+ padding-left: 10px;
+ }
+ }
+
+ > a {
+ background: none;
+ will-change: color;
+
+ &.header-user-dropdown-toggle {
+ .header-user-avatar {
+ border-color: $indigo-200;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ color: $white-light;
+ opacity: 1;
+
+ > svg {
+ fill: $white-light;
+ }
+
+ &.header-user-dropdown-toggle {
+ .header-user-avatar {
+ border-color: $white-light;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.navbar-sub-nav {
+ display: flex;
+ margin-bottom: 0;
+ color: $indigo-200;
+
+ > li {
+ > a:hover,
+ > a:focus {
+ box-shadow: inset 0 -3px 0 rgba($indigo-200, .4);
+ text-decoration: none;
+ outline: 0;
+ color: $white-light;
+ }
+
+ &.active > a {
+ box-shadow: inset 0 -3px 0 $indigo-500;
+ color: $white-light;
+ font-weight: 700;
+ }
+
+ > a {
+ display: block;
+ padding: 16px 10px;
+ font-size: 13px;
+ color: currentColor;
+ box-shadow: inset 0 0 0 transparent;
+ will-change: box-shadow;
+ transition: box-shadow 0.15s;
+
+ @media (min-width: $screen-sm-min) {
+ padding: 15px $gl-padding;
+ font-size: 14px;
+ }
+ }
+ }
+
+ .dropdown-chevron {
+ position: relative;
+ top: -1px;
+ font-size: 10px;
+ }
+}
+
+.header-user .dropdown-menu-nav,
+.header-new .dropdown-menu-nav {
+ margin-top: 4px;
+}
+
+.search {
+ form {
+ border: 0;
+ background-color: rgba($indigo-200, .2);
+ transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, background-color ease-in-out 0.15s;
+
+ &:hover {
+ background-color: rgba($indigo-200, .3);
+ box-shadow: none;
+ }
+ }
+
+ &.search-active form {
+ background-color: rgba($indigo-200, .3);
+ box-shadow: none;
+ }
+
+ .search-input {
+ color: $white-light;
+ background: none;
+ }
+
+ .search-input::placeholder {
+ color: rgba($indigo-200, .8);
+ }
+
+ .location-badge {
+ font-size: 12px;
+ color: $indigo-100;
+ background-color: rgba($indigo-200, .1);
+ transition: color 0.15s;
+ will-change: color;
+ margin: -4px 4px -4px -4px;
+ line-height: 25px;
+ padding: 4px 8px;
+ border-radius: 2px 0 0 2px;
+ border-right: 1px solid $indigo-800;
+ height: 34px;
+ }
+
+ .search-input-wrap {
+ .search-icon,
+ .clear-icon {
+ color: rgba($indigo-200, .8);
+ }
+ }
+
+ &.search-active {
+ .location-badge {
+ color: $white-light;
+ background-color: rgba($indigo-200, .2);
+ }
+
+ .search-input-wrap {
+ .search-icon {
+ color: rgba($indigo-200, .8);
+ }
+
+ .clear-icon {
+ color: $white-light;
+ }
+ }
+ }
+}
+
+.breadcrumbs {
+ display: flex;
+ min-height: 61px;
+ color: $gl-text-color;
+ border-bottom: 1px solid $border-color;
+
+ .dropdown-toggle-caret {
+ position: relative;
+ top: -1px;
+ padding: 0 5px;
+ color: $gl-text-color-secondary;
+ font-size: 10px;
+ line-height: 1;
+ background: none;
+ border: 0;
+
+ &:focus {
+ outline: 0;
+ }
+ }
+
+ // TODO: fallback to global style
+ .dropdown-menu {
+ .divider {
+ margin: 6px 0;
+ }
+
+ li {
+ padding: 0 1px;
+
+ a {
+ border-radius: 0;
+ padding: 8px 16px;
+
+ &.is-focused,
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: $gray-darker;
+ }
+ }
+ }
+ }
+}
+
+.breadcrumbs-container {
+ display: flex;
+ width: 100%;
+ position: relative;
+ align-items: center;
+
+ .dropdown-menu-projects {
+ margin-top: -$gl-padding;
+ margin-left: $gl-padding;
+ }
+}
+
+.breadcrumbs-links {
+ flex: 1;
+ min-width: 0;
+ align-self: center;
+ color: $gl-text-color-quaternary;
+
+ a {
+ color: $gl-text-color-secondary;
+
+ &:not(:first-child),
+ &.group-path {
+ margin-left: 4px;
+ }
+
+ &:not(:last-of-type),
+ &.group-path {
+ margin-right: 3px;
+ }
+ }
+
+ .title {
+ display: inline-block;
+
+ > a {
+ &:last-of-type:not(:first-child) {
+ font-weight: 600;
+ }
+ }
+ }
+
+ .avatar-tile {
+ margin-right: 5px;
+ border: 1px solid $border-color;
+ border-radius: 50%;
+ vertical-align: sub;
+
+ &.identicon {
+ float: left;
+ width: 16px;
+ height: 16px;
+ margin-top: 2px;
+ font-size: 10px;
+ }
+ }
+
+ .text-expander {
+ margin-left: 4px;
+ margin-right: 4px;
+
+ > i {
+ position: relative;
+ top: 1px;
+ }
+ }
+}
+
+.breadcrumbs-extra {
+ display: flex;
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.breadcrumbs-sub-title {
+ margin: 2px 0;
+ font-size: 16px;
+ font-weight: normal;
+ line-height: 1;
+
+ ul {
+ margin: 0;
+ }
+
+ li {
+ display: inline-block;
+
+ &:not(:last-child) {
+ &::after {
+ content: "/";
+ margin: 0 2px 0 5px;
+ color: rgba($black, .65);
+ }
+ }
+
+ &:last-child a {
+ font-weight: 600;
+ }
+ }
+
+ a {
+ color: $gl-text-color;
+ }
+}
+
+.top-area {
+ .nav-controls-new-nav {
+ .dropdown {
+ @media (min-width: $screen-sm-min) {
+ margin-right: 0;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss
new file mode 100644
index 00000000000..cee5b22adb9
--- /dev/null
+++ b/app/assets/stylesheets/new_sidebar.scss
@@ -0,0 +1,479 @@
+@import "framework/variables";
+@import 'framework/tw_bootstrap_variables';
+@import "bootstrap/variables";
+
+$active-background: rgba(0, 0, 0, .04);
+$active-border: $indigo-500;
+$active-color: $indigo-700;
+$active-hover-background: $active-background;
+$active-hover-color: $gl-text-color;
+$inactive-badge-background: rgba(0, 0, 0, .08);
+$hover-background: $white-light;
+$hover-color: $gl-text-color;
+$inactive-color: $gl-text-color-secondary;
+$new-sidebar-width: 220px;
+$new-sidebar-collapsed-width: 50px;
+
+.page-with-new-sidebar {
+ @media (min-width: $screen-md-min) {
+ padding-left: $new-sidebar-collapsed-width;
+ }
+
+ @media (min-width: $screen-lg-min) {
+ padding-left: $new-sidebar-width;
+ }
+
+ // Override position: absolute
+ .right-sidebar {
+ position: fixed;
+ height: calc(100% - #{$header-height});
+ }
+
+ .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
+ padding: 10px 0 15px;
+ }
+}
+
+.page-with-icon-sidebar {
+ @media (min-width: $screen-sm-min) {
+ padding-left: $new-sidebar-collapsed-width;
+ }
+}
+
+.context-header {
+ position: relative;
+ margin-right: 2px;
+
+ a {
+ border-bottom: 1px solid $border-color;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ padding: 10px 16px 10px 10px;
+ color: $gl-text-color;
+ }
+
+ &:hover,
+ a:hover {
+ background-color: $hover-background;
+ color: $hover-color;
+
+ .settings-avatar {
+ i {
+ color: $hover-color;
+ }
+ }
+ }
+
+ .avatar-container {
+ flex: 0 0 40px;
+ background-color: $white-light;
+ }
+
+ .project-title,
+ .group-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.settings-avatar {
+ background-color: $white-light;
+
+ i {
+ font-size: 20px;
+ width: 100%;
+ color: $gl-text-color-secondary;
+ text-align: center;
+ align-self: center;
+ }
+}
+
+.nav-sidebar {
+ position: fixed;
+ z-index: 400;
+ width: $new-sidebar-width;
+ transition: left $sidebar-transition-duration;
+ top: $header-height;
+ bottom: 0;
+ left: 0;
+ background-color: $gray-normal;
+ box-shadow: inset -2px 0 0 $border-color;
+ transform: translate3d(0, 0, 0);
+
+ &.sidebar-icons-only {
+ width: $new-sidebar-collapsed-width;
+
+ .nav-sidebar-inner-scroll {
+ overflow-x: hidden;
+ }
+
+ .badge,
+ .project-title {
+ display: none;
+ }
+
+ .nav-item-name {
+ display: none;
+ }
+
+ .sidebar-top-level-items > li > a {
+ min-height: 44px;
+ }
+ }
+
+ &.nav-sidebar-expanded {
+ left: 0;
+ }
+
+ a {
+ transition: none;
+ text-decoration: none;
+ }
+
+ ul {
+ padding-left: 0;
+ list-style: none;
+ }
+
+ li {
+ white-space: nowrap;
+
+ a {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ color: $inactive-color;
+ }
+
+ svg {
+ fill: $inactive-color;
+ }
+ }
+
+ .nav-item-name {
+ flex: 1;
+ }
+
+ li.active {
+ box-shadow: inset 4px 0 0 $active-border;
+
+ > a {
+ color: $active-color;
+ font-weight: 700;
+ }
+
+ svg {
+ fill: $active-color;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ left: (-$new-sidebar-width);
+ }
+
+ .nav-icon-container {
+ display: flex;
+ margin-right: 8px;
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+ }
+}
+
+.nav-sidebar-inner-scroll {
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+}
+
+.with-performance-bar .nav-sidebar {
+ top: $header-height + $performance-bar-height;
+}
+
+.sidebar-sub-level-items {
+ display: none;
+ padding-bottom: 8px;
+
+ > li {
+ a {
+ padding: 8px 16px 8px 40px;
+
+ &:hover,
+ &:focus {
+ background: $active-hover-background;
+ color: $active-hover-color;
+ }
+ }
+
+ &.active {
+ a {
+ &,
+ &:hover,
+ &:focus {
+ background: $active-background;
+ color: $active-color;
+ }
+ }
+ }
+ }
+}
+
+.sidebar-top-level-items {
+ margin-bottom: 60px;
+
+ > li {
+ > a {
+ @media (min-width: $screen-sm-min) {
+ margin-right: 2px;
+ }
+
+ &:hover {
+ color: $gl-text-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+ }
+
+ &.is-showing-fly-out {
+ > a {
+ margin-right: 2px;
+ }
+
+ .sidebar-sub-level-items {
+ @media (min-width: $screen-sm-min) {
+ position: fixed;
+ top: 0;
+ left: $new-sidebar-width;
+ min-width: 150px;
+ margin-top: -1px;
+ padding: 8px 1px;
+ background-color: $white-light;
+ box-shadow: 2px 1px 3px $dropdown-shadow-color;
+ border: 1px solid $gray-darker;
+ border-left: 0;
+ border-radius: 0 3px 3px 0;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: -30px;
+ bottom: -30px;
+ left: -10px;
+ right: -30px;
+ z-index: -1;
+ }
+
+ &.is-above {
+ margin-top: 1px;
+ }
+
+ > .active {
+ box-shadow: none;
+
+ > a {
+ background-color: transparent;
+ }
+ }
+
+ a {
+ padding: 8px 16px;
+ color: $gl-text-color;
+
+ &:hover,
+ &:focus {
+ background-color: $gray-darker;
+ }
+ }
+ }
+ }
+ }
+
+ .badge {
+ background-color: $inactive-badge-background;
+ color: $inactive-color;
+ }
+
+ &.active {
+ background: $active-background;
+
+ > a {
+ margin-left: 4px;
+ padding-left: 12px;
+ }
+
+ .badge {
+ color: $active-color;
+ font-weight: 600;
+ }
+
+ .sidebar-sub-level-items {
+ display: block;
+ }
+ }
+
+ &.active > a:hover,
+ &.is-over > a {
+ background-color: $white-light;
+ }
+ }
+}
+
+
+// Collapsed nav
+
+.toggle-sidebar-button,
+.close-nav-button {
+ width: $new-sidebar-width - 2px;
+ position: fixed;
+ bottom: 0;
+ padding: 16px;
+ background-color: $gray-normal;
+ border: 0;
+ border-top: 2px solid $border-color;
+ color: $gl-text-color-secondary;
+ display: flex;
+ align-items: center;
+
+ i {
+ font-size: 20px;
+ margin-right: 8px;
+ }
+
+ .fa-angle-double-right {
+ display: none;
+ }
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+}
+
+.toggle-sidebar-button {
+ @media (max-width: $screen-xs-max) {
+ display: none;
+ }
+}
+
+
+.sidebar-icons-only {
+ .context-header {
+ height: 61px;
+
+ a {
+ padding: 10px 4px;
+ }
+ }
+
+ li a {
+ padding: 12px 15px;
+ }
+
+ .sidebar-top-level-items > li {
+ &.active a {
+ padding-left: 12px;
+ }
+
+ .sidebar-sub-level-items {
+ @media (min-width: $screen-sm-min) {
+ left: $new-sidebar-collapsed-width;
+ }
+
+ &:not(.flyout-list) {
+ display: none;
+ }
+ }
+ }
+
+ .toggle-sidebar-button {
+ width: $new-sidebar-collapsed-width - 2px;
+ padding: 16px 18px;
+
+ .collapse-text,
+ .fa-angle-double-left {
+ display: none;
+ }
+
+ .fa-angle-double-right {
+ display: block;
+ }
+ }
+}
+
+
+// Mobile nav
+
+.close-nav-button {
+ display: none;
+}
+
+.toggle-mobile-nav {
+ display: none;
+ background-color: transparent;
+ border: 0;
+ padding: 6px 16px;
+ margin: 0 16px 0 -15px;
+ height: 46px;
+ border-right: 1px solid $gl-text-color-quaternary;
+
+ i {
+ font-size: 20px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: inline-block;
+ }
+}
+
+@media (max-width: $screen-xs-max) {
+ .close-nav-button {
+ display: flex;
+ }
+}
+
+.mobile-overlay {
+ display: none;
+
+ &.mobile-nav-open {
+ display: block;
+ position: fixed;
+ background-color: $black-transparent;
+ height: 100%;
+ width: 100%;
+ z-index: 300;
+ }
+}
+
+
+// Make issue boards full-height now that sub-nav is gone
+
+.boards-list {
+ height: calc(100vh - #{$header-height});
+
+ @media (min-width: $screen-sm-min) {
+ height: 475px; // Needed for PhantomJS
+ // scss-lint:disable DuplicateProperty
+ height: calc(100vh - 180px);
+ // scss-lint:enable DuplicateProperty
+ }
+}
+
+.with-performance-bar .boards-list {
+ height: calc(100vh - #{$header-height} - #{$performance-bar-height});
+}
+
+
+// Change color of all horizontal tabs to match the new indigo color
+.nav-links li.active a {
+ border-bottom-color: $active-border;
+
+ .badge {
+ font-weight: 600;
+ }
+}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 0be1c215959..e5b467a2691 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -1,3 +1,5 @@
+@import "./issues/issue_count_badge";
+
[v-cloak] {
display: none;
}
@@ -9,7 +11,7 @@
.is-dragging {
// Important because plugin sets inline CSS
- opacity: 1!important;
+ opacity: 1 !important;
* {
-webkit-user-select: none;
@@ -17,8 +19,8 @@
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
- cursor: -webkit-grabbing!important;
- cursor: grabbing!important;
+ cursor: -webkit-grabbing !important;
+ cursor: grabbing !important;
}
}
@@ -72,7 +74,9 @@
@media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS
+ // scss-lint:disable DuplicateProperty
height: calc(100vh - 222px);
+ // scss-lint:enable DuplicateProperty
min-height: 475px;
transition: width .2s;
@@ -94,9 +98,51 @@
@media (min-width: $screen-sm-min) {
width: 400px;
}
+
+ &.is-expandable {
+ .board-header {
+ cursor: pointer;
+ }
+ }
+
+ &.is-collapsed {
+ width: 50px;
+
+ .board-header {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ .board-title {
+ position: initial;
+ padding: 0;
+ border-bottom: 0;
+
+ > span {
+ display: block;
+ transform: rotate(90deg) translate(25px, 0);
+ }
+ }
+
+ .board-title-expandable-toggle {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -10px;
+ }
+
+ .board-list-component,
+ .issue-count-badge {
+ display: none;
+ }
+ }
}
.board-inner {
+ position: relative;
height: 100%;
font-size: $issue-boards-font-size;
background: $gray-light;
@@ -119,6 +165,7 @@
.board-title {
padding-top: ($gl-padding - 3px);
+ padding-bottom: $gl-padding;
}
}
}
@@ -132,6 +179,7 @@
position: relative;
margin: 0;
padding: $gl-padding;
+ padding-bottom: ($gl-padding + 3px);
font-size: 1em;
border-bottom: 1px solid $border-color;
}
@@ -173,21 +221,53 @@
}
}
+.slide-down-enter {
+ transform: translateY(-100%);
+}
+
+.slide-down-enter-active {
+ transition: transform $fade-in-duration;
+
+ + .board-list {
+ transform: translateY(-136px);
+ transition: none;
+ }
+}
+
+.slide-down-enter-to {
+ + .board-list {
+ transform: translateY(0);
+ transition: transform $fade-in-duration ease;
+ }
+}
+
+.slide-down-leave {
+ transform: translateY(0);
+}
+
+.slide-down-leave-active {
+ transition: all $fade-in-duration;
+ transform: translateY(-136px);
+
+ + .board-list {
+ transition: transform $fade-in-duration ease;
+ transform: translateY(-136px);
+ }
+}
+
.board-list-component {
height: calc(100% - 49px);
+ overflow: hidden;
}
.board-list {
height: 100%;
+ width: 100%;
margin-bottom: 0;
padding: 5px;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
-
- &.is-smaller {
- height: calc(100% - 136px);
- }
}
.board-list-loading {
@@ -207,8 +287,13 @@
margin-bottom: 5px;
}
- &.is-active {
+ &.is-active,
+ &.is-active .card-assignee:hover a {
background-color: $row-hover;
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $row-hover;
+ }
}
.label {
@@ -224,7 +309,7 @@
}
.card-title {
- margin: 0;
+ margin: 0 30px 0 0;
font-size: 1em;
line-height: inherit;
@@ -240,10 +325,69 @@
min-height: 20px;
.card-assignee {
- margin-left: auto;
- margin-right: 5px;
- padding-left: 10px;
+ display: flex;
+ justify-content: flex-end;
+ position: absolute;
+ right: 15px;
height: 20px;
+ width: 20px;
+
+ .avatar-counter {
+ display: none;
+ vertical-align: middle;
+ min-width: 20px;
+ line-height: 19px;
+ height: 20px;
+ padding-left: 2px;
+ padding-right: 2px;
+ border-radius: 2em;
+ }
+
+ img {
+ vertical-align: top;
+ }
+
+ a {
+ position: relative;
+ margin-left: -15px;
+ }
+
+ a:nth-child(1) {
+ z-index: 3;
+ }
+
+ a:nth-child(2) {
+ z-index: 2;
+ }
+
+ a:nth-child(3) {
+ z-index: 1;
+ }
+
+ a:nth-child(4) {
+ display: none;
+ }
+
+ &:hover {
+ .avatar-counter {
+ display: inline-block;
+ }
+
+ a {
+ position: static;
+ background-color: $white-light;
+ transition: background-color 0s;
+ margin-left: auto;
+
+ &:nth-child(4) {
+ display: block;
+ }
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $white-light;
+ }
+ }
+ }
}
.avatar {
@@ -285,34 +429,14 @@
}
.board-new-issue-form {
+ z-index: 1;
margin: 5px;
}
-.board-issue-count-holder {
- margin-top: -3px;
-
- .btn {
- line-height: 12px;
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
- }
-}
-
-.board-issue-count {
- padding-right: 10px;
- padding-left: 10px;
- line-height: 21px;
- border-radius: $border-radius-base;
- border: 1px solid $border-color;
-
- &.has-btn {
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- border-width: 1px 0 1px 1px;
- }
-}
+.page-with-layout-nav.page-with-sub-nav .issue-boards-sidebar,
+.page-with-new-sidebar.page-with-sidebar .issue-boards-sidebar {
+ position: absolute;
-.page-with-layout-nav.page-with-sub-nav .issue-boards-sidebar {
&.right-sidebar {
top: 0;
bottom: 0;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 724b4080ee0..486424fb729 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -6,202 +6,207 @@
@keyframes blinking-dots {
0% {
background-color: rgba($white-light, 1);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
25% {
background-color: rgba($white-light, 0.4);
- box-shadow: 12px 0 0 0 rgba($white-light,2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
75% {
background-color: rgba($white-light, 0.4);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,1);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 1);
}
100% {
background-color: rgba($white-light, 1);
- box-shadow: 12px 0 0 0 rgba($white-light,0.2),
- 24px 0 0 0 rgba($white-light,0.2);
+ box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
+ 24px 0 0 0 rgba($white-light, 0.2);
}
}
-.build-page {
- pre.trace {
- background: $builds-trace-bg;
- color: $white-light;
- font-family: $monospace_font;
- white-space: pre-wrap;
- overflow: auto;
- overflow-y: hidden;
- font-size: 12px;
+@keyframes blinking-scroll-button {
+ 0% { opacity: 0.2; }
+ 25% { opacity: 0.5; }
+ 50% { opacity: 0.7; }
+ 100% { opacity: 1; }
+}
- .fa-spinner {
- font-size: 24px;
- margin-left: 20px;
- }
+.build-page {
+ .build-trace-container {
+ position: relative;
}
- .environment-information {
- background-color: $gray-light;
- border: 1px solid $border-color;
- padding: 12px $gl-padding;
- border-radius: $border-radius-default;
+ .build-trace {
+ background: $black;
+ color: $gray-darkest;
+ white-space: pre;
+ overflow-x: auto;
+ font-size: 12px;
+ border-radius: 0;
+ border: none;
- svg {
- position: relative;
- top: 1px;
- margin-right: 5px;
+ .bash {
+ display: block;
}
}
- .truncated-info {
- text-align: center;
- border-bottom: 1px solid;
- background-color: $black;
- height: 45px;
- padding: 15px;
+ .top-bar {
+ height: 35px;
+ display: flex;
+ justify-content: flex-end;
+ background: $gray-light;
+ border: 1px solid $border-color;
+ color: $gl-text-color;
+ position: sticky;
+ position: -webkit-sticky;
+ top: $header-height;
&.affix {
- top: 0;
+ top: $header-height;
}
// with sidebar
&.affix.sidebar-expanded {
- right: 312px;
- left: 22px;
+ right: 306px;
+ left: 16px;
}
// without sidebar
&.affix.sidebar-collapsed {
- right: 20px;
- left: 20px;
+ right: 16px;
+ left: 16px;
}
&.affix-top {
position: absolute;
+ right: 0;
+ left: 0;
top: 0;
- margin: 0 auto;
- right: 5px;
- left: 5px;
}
- .truncated-info-size {
- margin: 0 5px;
- }
+ .truncated-info {
+ margin: 0 auto;
+ align-self: center;
+
+ .truncated-info-size {
+ margin: 0 5px;
+ }
- .raw-link {
- color: inherit;
- margin-left: 5px;
- text-decoration: underline;
+ .raw-link {
+ color: $gl-text-color;
+ margin-left: 5px;
+ text-decoration: underline;
+ }
}
- }
-}
-.scroll-controls {
- height: 100%;
+ .controllers {
+ display: flex;
+ font-size: 15px;
+ justify-content: center;
+ align-items: center;
- .scroll-step {
- width: 31px;
- margin: 0 0 0 auto;
- }
+ svg {
+ height: 15px;
+ display: block;
+ fill: $gl-text-color;
+ }
- .scroll-link,
- .autoscroll-container {
- right: 25px;
- z-index: 1;
- }
+ .controllers-buttons {
+ color: $gl-text-color;
+ margin: 0 10px;
+ }
- .scroll-link {
- position: fixed;
- display: block;
- margin-bottom: 10px;
+ .btn-scroll.animate {
+ .first-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .3s;
+ }
- &.scroll-top .gitlab-icon-scroll-up-hover,
- &.scroll-top:hover .gitlab-icon-scroll-up,
- &.scroll-bottom .gitlab-icon-scroll-down-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down {
- display: none;
- }
+ .second-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .2s;
+ }
- &.scroll-top:hover .gitlab-icon-scroll-up-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down-hover {
- display: inline-block;
- }
+ .third-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ }
- &.scroll-top {
- top: 10px;
- }
+ &:disabled {
+ opacity: 1;
+ }
+ }
- &.scroll-bottom {
- bottom: -2px;
+ .btn-scroll:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ }
}
}
- .autoscroll-container {
- position: absolute;
- }
-
- &.sidebar-expanded {
+ .environment-information {
+ border: 1px solid $border-color;
+ padding: 8px $gl-padding 12px;
+ border-radius: $border-radius-default;
- .scroll-link,
- .autoscroll-container {
- right: ($gutter_width + ($gl-padding * 2));
+ svg {
+ position: relative;
+ top: 5px;
+ margin-right: 5px;
+ width: 22px;
+ height: 22px;
}
}
-}
-
-.status-message {
- display: inline-block;
- color: $white-light;
- .status-icon {
- display: inline-block;
- width: 16px;
- height: 33px;
+ .build-loader-animation {
+ position: relative;
+ width: 6px;
+ height: 6px;
+ margin: auto auto 12px 2px;
+ border-radius: 50%;
+ animation: blinking-dots 1s linear infinite;
}
+}
- .status-text {
- float: left;
- opacity: 0;
- margin-right: 10px;
- font-weight: normal;
- line-height: 1.8;
- transition: opacity 1s ease-out;
+.with-performance-bar .build-page {
+ .top-bar {
+ top: $header-height + $performance-bar-height;
- &.animate {
- animation: fade-out-status 2s ease;
+ &.affix {
+ top: $header-height + $performance-bar-height;
}
}
-
- &:hover .status-text {
- opacity: 1;
- }
}
.build-header {
- position: relative;
- padding: 0;
- display: flex;
- min-height: 58px;
- align-items: center;
+ .ci-header-container,
+ .header-action-buttons {
+ display: flex;
+ }
- @media (max-width: $screen-sm-max) {
- padding-right: 40px;
- margin-top: 6px;
+ .ci-header-container {
+ min-height: 54px;
+ }
- .btn-inverted {
- display: none;
+ .page-content-header {
+ padding: 10px 0 9px;
+ }
+
+ .header-action-buttons {
+ @media (max-width: $screen-xs-max) {
+ .sidebar-toggle-btn {
+ margin-top: 0;
+ margin-left: 10px;
+ max-height: 34px;
+ }
}
}
.header-content {
- flex: 1;
- line-height: 1.8;
-
a {
color: $gl-text-color;
@@ -223,46 +228,34 @@
}
}
-.build-trace {
- background: $black;
- color: $gray-darkest;
- white-space: pre;
- overflow-x: auto;
- font-size: 12px;
- position: relative;
-
- .fa-spinner {
- font-size: 24px;
- }
-
- .bash {
- display: block;
- }
-
- .build-loader-animation {
- position: relative;
- width: 6px;
- height: 6px;
- margin: auto auto 12px 2px;
- border-radius: 50%;
- animation: blinking-dots 1s linear infinite;
- }
-}
-
.right-sidebar.build-sidebar {
- padding: $gl-padding 0;
+ padding: 0;
&.right-sidebar-collapsed {
display: none;
}
+ .sidebar-container {
+ width: calc(100% + 100px);
+ padding-right: 100px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
.blocks-container {
padding: 0 $gl-padding;
+ width: 289px;
}
.block {
width: 100%;
+ &:last-child {
+ border-bottom: 1px solid $border-gray-normal;
+ }
+
&.coverage {
padding: 0 16px 11px;
}
@@ -272,34 +265,51 @@
}
}
- .js-build-variable {
+ .block-last {
+ padding: 16px 0;
+ }
+
+ .trigger-build-variables {
+ margin: 0;
+ overflow-x: auto;
+ -ms-overflow-style: scrollbar;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .trigger-build-variable {
+ font-weight: normal;
color: $code-color;
}
- .js-build-value {
+ .trigger-build-value {
padding: 2px 4px;
color: $black;
background-color: $white-light;
}
- .build-sidebar-header {
- padding: 0 $gl-padding $gl-padding;
-
- .gutter-toggle {
- margin-top: 0;
- }
+ .label {
+ margin-left: 2px;
}
.retry-link {
- color: $gl-link-color;
display: none;
- &:hover {
- text-decoration: underline;
+ .btn-inverted-secondary {
+ color: $blue-500;
+
+ &:hover {
+ color: $white-light;
+ }
}
@media (max-width: $screen-sm-max) {
display: block;
+
+ .btn {
+ i {
+ margin-left: 5px;
+ }
+ }
}
}
@@ -319,9 +329,13 @@
}
.dropdown-menu {
- right: $gl-padding;
- left: $gl-padding;
- width: auto;
+ margin-top: -$gl-padding;
+ }
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin-right: 3px;
}
}
@@ -330,6 +344,7 @@
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
max-height: 300px;
+ width: 289px;
overflow: auto;
svg {
@@ -378,18 +393,26 @@
background-color: $row-hover;
}
- .fa-spinner {
+ .fa-refresh {
font-size: 13px;
margin-left: 3px;
}
}
}
+
+ .link-commit {
+ color: $blue-600;
+ }
}
.build-sidebar {
.container-fluid.container-limited {
max-width: 100%;
}
+
+ .content-wrapper {
+ padding-bottom: 6px;
+ }
}
.build-detail-row {
@@ -402,6 +425,7 @@
.build-light-text {
color: $gl-text-color-secondary;
+ word-wrap: break-word;
}
.build-gutter-toggle {
diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss
index 90643832390..7b4eb689f1b 100644
--- a/app/assets/stylesheets/pages/ci_projects.scss
+++ b/app/assets/stylesheets/pages/ci_projects.scss
@@ -36,7 +36,6 @@
pre.commit-message {
background: none;
padding: 0;
- margin: 0;
border: none;
margin: 20px 0;
border-radius: 0;
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 9e3142c8aa3..46fbfe5f91e 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -54,7 +54,11 @@
.mr-widget-pipeline-graph {
display: inline-block;
vertical-align: middle;
- margin: 0 -6px 0 0;
+ margin-right: 4px;
+
+ .stage-cell .stage-container {
+ margin: 3px 3px 3px 0;
+ }
.dropdown-menu {
margin-top: 11px;
@@ -163,7 +167,6 @@
.avatar-cell {
width: 46px;
- padding-left: 10px;
img {
margin-right: 0;
@@ -175,7 +178,6 @@
justify-content: space-between;
align-items: flex-start;
flex-grow: 1;
- padding-left: 10px;
.merge-request-branches & {
flex-direction: column;
@@ -208,11 +210,11 @@
margin-left: $gl-padding;
}
}
-}
-.commit-short-id {
- font-family: $monospace_font;
- font-weight: 600;
+ .commit-sha {
+ font-size: 14px;
+ font-weight: 600;
+ }
}
.commit,
@@ -230,7 +232,7 @@
margin: 10px 0;
background: $gray-light;
display: none;
- white-space: pre-line;
+ white-space: pre-wrap;
word-break: normal;
pre {
@@ -252,8 +254,8 @@
}
.committed_ago {
- float: right;
@extend .cgray;
+ float: right;
}
}
}
@@ -273,7 +275,7 @@
}
}
- .commit-id {
+ .commit-sha {
color: $gl-link-color;
}
@@ -281,3 +283,67 @@
color: $gl-text-color;
}
}
+
+
+.gpg-status-box {
+ &:empty {
+ display: none;
+ }
+
+ &.valid {
+ @include green-status-color;
+ }
+
+ &.invalid {
+ @include status-color($gray-dark, $gray, $common-gray-dark);
+ border-color: $common-gray-light;
+ }
+}
+
+.gpg-popover-status {
+ display: flex;
+ align-items: center;
+ font-weight: normal;
+ line-height: 1.5;
+}
+
+.gpg-popover-icon {
+ // same margin as .s32.avatar
+ margin-right: $btn-side-margin;
+
+ &.valid {
+ svg {
+ border: 1px solid $brand-success;
+
+ fill: $brand-success;
+ }
+ }
+
+ &.invalid {
+ svg {
+ border: 1px solid $common-gray-light;
+
+ fill: $common-gray-light;
+ }
+ }
+
+ svg {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ vertical-align: middle;
+ }
+}
+
+.gpg-popover-user-link {
+ display: flex;
+ align-items: center;
+ margin-bottom: $gl-padding / 2;
+ text-decoration: none;
+ color: $gl-text-color;
+}
+
+.commit .gpg-popover-help-link {
+ display: block;
+ color: $link-color;
+}
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
new file mode 100644
index 00000000000..0413114c279
--- /dev/null
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -0,0 +1,255 @@
+$space-between-cards: 8px;
+
+.convdev-empty svg {
+ margin: 64px auto 32px;
+ max-width: 420px;
+}
+
+.convdev-header {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+
+ .convdev-header-title {
+ font-size: 48px;
+ line-height: 1;
+ margin: 0;
+ }
+
+ .convdev-header-subtitle {
+ font-size: 22px;
+ line-height: 1;
+ color: $gl-text-color-secondary;
+ margin-left: 8px;
+ font-weight: 500;
+
+ a {
+ font-size: 18px;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $blue-500;
+ }
+ }
+ }
+}
+
+.convdev-cards {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.convdev-card-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ width: 50%;
+ border-color: $border-color;
+ margin: 0 0 32px;
+ padding: $space-between-cards / 2;
+ position: relative;
+
+ @media (min-width: $screen-xs-min) {
+ width: percentage(1 / 4);
+ }
+
+ @media (min-width: $screen-sm-min) {
+ width: percentage(1 / 5);
+ }
+
+ @media (min-width: $screen-md-min) {
+ width: percentage(1 / 6);
+ }
+
+ @media (min-width: $screen-lg-min) {
+ width: percentage(1 / 10);
+ }
+}
+
+.convdev-card {
+ border: solid 1px $border-color;
+ border-radius: 3px;
+ border-top-width: 3px;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.convdev-card-low {
+ border-top-color: $color-low-score;
+
+ .card-score-big {
+ background-color: $red-25;
+ }
+}
+
+.convdev-card-average {
+ border-top-color: $color-average-score;
+
+ .card-score-big {
+ background-color: $orange-25;
+ }
+}
+
+.convdev-card-high {
+ border-top-color: $color-high-score;
+
+ .card-score-big {
+ background-color: $green-25;
+ }
+}
+
+.convdev-card-title {
+ margin: $gl-padding auto auto;
+ max-width: 100px;
+
+ h3 {
+ font-size: 14px;
+ margin: 0 0 2px;
+ }
+
+ .text-light {
+ font-size: 13px;
+ line-height: 1.25;
+ color: $gl-text-color-secondary;
+ }
+}
+
+.card-scores {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin: $gl-padding $gl-btn-padding;
+ line-height: 1;
+}
+
+.card-score {
+ color: $gl-text-color-secondary;
+
+ .card-score-name {
+ font-size: 13px;
+ margin-top: 4px;
+ }
+}
+
+.card-score-value {
+ font-size: 16px;
+ color: $gl-text-color;
+ font-weight: 500;
+}
+
+.card-score-big {
+ border-top: 2px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ font-size: 22px;
+ padding: 10px 0;
+ font-weight: 500;
+}
+
+.card-buttons {
+ display: flex;
+
+ > * {
+ font-size: 16px;
+ color: $gl-text-color-secondary;
+ padding: 10px;
+ flex-grow: 1;
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+
+ + * {
+ border-left: solid 1px $border-color;
+ }
+ }
+}
+
+.convdev-steps {
+ margin-top: $gl-padding;
+ height: 1px;
+ min-width: 100%;
+ justify-content: space-around;
+ position: relative;
+ background: $border-color;
+}
+
+.convdev-step {
+ $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
+ @each $pos in $step-positions {
+ $i: index($step-positions, $pos);
+
+ &:nth-child(#{$i}) {
+ left: $pos;
+ }
+ }
+
+ position: absolute;
+ transform-origin: 75% 50%;
+ padding: 8px;
+ height: 50px;
+ width: 50px;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: solid 1px $border-color;
+ background: $white-light;
+ transform: translate(-50%, -50%);
+ color: $gl-text-color-secondary;
+ fill: $gl-text-color-secondary;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &:hover {
+ padding: 8px 10px;
+ fill: currentColor;
+ z-index: 100;
+ height: auto;
+ width: auto;
+
+ .convdev-step-title {
+ max-height: 2em;
+ opacity: 1;
+ transition: opacity 0.2s;
+ }
+
+ svg {
+ transform: scale(1.5);
+ margin: $gl-btn-padding;
+ }
+ }
+
+ svg {
+ transition: transform 0.1s;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+}
+
+.convdev-step-title {
+ max-height: 0;
+ opacity: 0;
+ text-transform: uppercase;
+ margin: $gl-vert-padding 0 0;
+ text-align: center;
+ font-size: 12px;
+}
+
+.convdev-high-score {
+ color: $color-high-score;
+}
+
+.convdev-average-score {
+ color: $color-average-score;
+}
+
+.convdev-low-score {
+ color: $color-low-score;
+}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index ad3dbc7ac48..6753eb08285 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -1,13 +1,34 @@
#cycle-analytics {
+ @include new-style-dropdown;
+
max-width: 1000px;
margin: 24px auto 0;
position: relative;
+ .landing {
+ margin-top: 0;
+
+ .inner-content {
+ white-space: normal;
+
+ h4,
+ p {
+ margin: 7px 0 0;
+ max-width: 480px;
+ padding: 0 $gl-padding;
+
+ @media (max-width: $screen-sm-min) {
+ margin: 0 auto;
+ }
+ }
+ }
+ }
+
.col-headers {
ul {
+ @include clearfix;
margin: 0;
padding: 0;
- @include clearfix;
}
li {
@@ -93,11 +114,6 @@
top: $gl-padding-top;
}
- .bordered-box {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- }
-
.content-list {
li {
padding: 18px $gl-padding $gl-padding;
@@ -139,42 +155,9 @@
}
}
- .landing {
- margin-bottom: $gl-padding;
- overflow: hidden;
-
- .dismiss-icon {
- position: absolute;
- right: $cycle-analytics-box-padding;
- cursor: pointer;
- color: $cycle-analytics-dismiss-icon-color;
- }
-
- .svg-container {
- text-align: center;
-
- svg {
- width: 136px;
- height: 136px;
- }
- }
-
- .inner-content {
- @media (max-width: $screen-xs-max) {
- padding: 0 28px;
- text-align: center;
- }
-
- h4 {
- color: $gl-text-color;
- font-size: 17px;
- }
-
- p {
- color: $cycle-analytics-box-text-color;
- margin-bottom: $gl-padding;
- }
- }
+ .landing svg {
+ width: 136px;
+ height: 136px;
}
.fa-spinner {
@@ -208,12 +191,12 @@
}
li {
- list-style-type: none;
@include clearfix;
+ list-style-type: none;
}
.stage-nav-item {
- display: block;
+ display: flex;
line-height: 65px;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
@@ -247,14 +230,10 @@
}
.stage-nav-item-cell {
- float: left;
-
- &.stage-name {
- width: 65%;
- }
-
&.stage-median {
- width: 35%;
+ margin-left: auto;
+ margin-right: $gl-padding;
+ min-width: calc(35% - #{$gl-padding});
}
}
@@ -304,11 +283,11 @@
}
.stage-event-item {
+ @include clearfix;
list-style-type: none;
padding: 0 0 $gl-padding;
margin: 0 $gl-padding $gl-padding;
border-bottom: 1px solid $gray-darker;
- @include clearfix;
&:last-child {
border-bottom: none;
@@ -330,9 +309,9 @@
&.issue-title,
&.commit-title,
&.merge-merquest-title {
+ @include text-overflow();
max-width: 100%;
display: block;
- @include text-overflow();
a {
color: $gl-text-color;
@@ -410,7 +389,7 @@
padding: 0 3px 0 0;
}
- .branch-name {
+ .ref-name {
color: $black;
display: inline-block;
max-width: 180px;
@@ -421,7 +400,7 @@
vertical-align: top;
}
- .short-sha {
+ .commit-sha {
color: $gl-link-color;
line-height: 1.3;
vertical-align: top;
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index f3de05aa5f6..3d9eff35583 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -4,11 +4,7 @@
color: $gl-text-color;
line-height: 34px;
- .author {
- color: $gl-text-color;
- }
-
- .identifier {
+ a {
color: $gl-text-color;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 1b4694377b3..913a1a95dca 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -1,38 +1,6 @@
// Common
.diff-file {
- border: 1px solid $border-color;
margin-bottom: $gl-padding;
- border-radius: 3px;
-
- .commit-short-id {
- font-family: $regular_font;
- font-weight: 400;
- }
-
- .diff-header {
- position: relative;
- background: $gray-light;
- border-bottom: 1px solid $border-color;
- padding: 10px 16px;
- color: $gl-text-color;
- z-index: 10;
- border-radius: 3px 3px 0 0;
-
- .diff-title {
- font-family: $monospace_font;
- word-break: break-all;
- display: block;
-
- .file-mode {
- color: $file-mode-changed;
- }
- }
-
- .commit-short-id {
- font-family: $monospace_font;
- font-size: smaller;
- }
- }
.file-title,
.file-title-flex-parent {
@@ -52,8 +20,6 @@
}
.diff-content {
- overflow: auto;
- overflow-y: hidden;
background: $white-light;
color: $gl-text-color;
border-radius: 0 0 3px 3px;
@@ -125,8 +91,8 @@
.old_line,
.new_line {
+ @include user-select(none);
margin: 0;
- padding: 0;
border: none;
padding: 0 5px;
border-right: 1px solid;
@@ -134,7 +100,6 @@
min-width: 35px;
max-width: 50px;
width: 35px;
- @include user-select(none);
a {
float: left;
@@ -183,14 +148,10 @@
}
}
}
-
- .text-file.diff-wrap-lines table .line_holder td span {
- white-space: pre-wrap;
- }
}
.image {
- background: $diff-image-bg;
+ background: $file-image-bg;
text-align: center;
padding: 30px;
@@ -393,12 +354,12 @@
}
&.active {
+ cursor: default;
+ color: $gl-text-color;
+
&:hover {
text-decoration: none;
}
-
- cursor: default;
- color: $gl-text-color;
}
&.disabled {
@@ -425,12 +386,6 @@
float: right;
}
-.diffs {
- .content-block {
- border-bottom: none;
- }
-}
-
.files-changed {
border-bottom: none;
}
@@ -440,12 +395,11 @@
background-color: transparent;
border: 0;
color: $gl-link-color;
- transition: color 0.1s linear;
+ font-weight: 600;
&:hover,
&:focus {
outline: none;
- text-decoration: underline;
color: $gl-link-hover-color;
}
}
@@ -519,6 +473,7 @@
height: 19px;
width: 19px;
margin-left: -15px;
+ z-index: 100;
&:hover {
.diff-comment-avatar,
@@ -534,7 +489,7 @@
transform: translateX((($i * $x-pos) - $x-pos));
&:hover {
- transform: translateX((($i * $x-pos) - $x-pos)) scale(1.2);
+ transform: translateX((($i * $x-pos) - $x-pos));
}
}
}
@@ -576,14 +531,7 @@
.diff-comments-more-count,
.diff-notes-collapse {
- background-color: $gray-darkest;
- color: $white-light;
- border: 1px solid $white-light;
- border-radius: 1em;
- font-family: $regular_font;
- font-size: 9px;
- line-height: 17px;
- text-align: center;
+ @extend .avatar-counter;
}
.diff-notes-collapse {
@@ -592,6 +540,7 @@
height: 19px;
padding: 0;
transition: transform .1s ease-out;
+ z-index: 100;
svg {
position: absolute;
@@ -605,11 +554,80 @@
fill: $white-light;
}
- &:hover {
- transform: scale(1.2);
- }
-
&:focus {
outline: 0;
}
}
+
+.diff-files-changed {
+ .inline-parallel-buttons {
+ position: relative;
+ z-index: 1;
+ }
+
+ .commit-stat-summary {
+ @include new-style-dropdown;
+
+ @media (min-width: $screen-sm-min) {
+ margin-left: -$gl-padding;
+ padding-left: $gl-padding;
+ background-color: $white-light;
+ }
+ }
+
+ @media (min-width: $screen-sm-min) {
+ position: -webkit-sticky;
+ position: sticky;
+ top: 34px;
+ background-color: $white-light;
+ z-index: 190;
+
+ &.diff-files-changed-merge-request {
+ top: 84px;
+ }
+
+ + .files,
+ + .alert {
+ margin-top: 1px;
+ }
+
+ &:not(.is-stuck) .diff-stats-additions-deletions-collapsed {
+ display: none;
+ }
+
+ &.is-stuck {
+ padding-top: 0;
+ padding-bottom: 0;
+ border-bottom: 1px solid $white-dark;
+ transform: translateY(16px);
+
+ .diff-stats-additions-deletions-expanded,
+ .inline-parallel-buttons {
+ display: none;
+ }
+
+ + .files,
+ + .alert {
+ margin-top: 30px;
+ }
+ }
+ }
+}
+
+.diff-file-changes {
+ width: 450px;
+ z-index: 150;
+
+ @media (min-width: $screen-sm-min) {
+ left: $gl-padding;
+ }
+
+ a {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+}
+
+.diff-file-changes-path {
+ @include str-truncated(78%);
+}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 72e7d42858d..00ebf4e26ac 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -5,45 +5,13 @@
}
}
-.environments-list-loading {
- width: 100%;
- font-size: 34px;
-}
-
.environments-folder-name {
font-weight: normal;
padding-top: 20px;
}
.environments-container {
- .table-holder {
- width: 100%;
-
- @media (max-width: $screen-sm-max) {
- overflow: auto;
- }
- }
-
- .table.ci-table {
- .environments-actions {
- min-width: 300px;
- }
-
- .environments-commit,
- .environments-actions {
- width: 20%;
- }
-
- .environments-date {
- width: 10%;
- }
-
- .environments-name,
- .environments-deploy,
- .environments-build {
- width: 15%;
- }
-
+ .ci-table {
.deployment-column {
> span {
word-break: break-all;
@@ -69,12 +37,12 @@
}
}
- .commit-title {
- margin: 0;
+ .btn .text-center {
+ display: inline;
}
- .avatar-image-container {
- text-decoration: none;
+ .commit-title {
+ margin: 0;
}
.icon-play {
@@ -95,7 +63,7 @@
}
.build-link,
- .branch-name {
+ .ref-name {
color: $gl-text-color;
}
@@ -115,6 +83,7 @@
.avatar {
float: none;
+ margin-right: 0;
}
}
@@ -140,7 +109,7 @@
}
.branch-commit {
- .commit-id {
+ .commit-sha {
margin-right: 0;
}
}
@@ -155,19 +124,19 @@
}
}
-.prometheus-graph {
- text {
- fill: $stat-graph-axis-fill;
+.gl-responsive-table-row {
+ .branch-commit {
+ max-width: 100%;
}
+}
- .label-axis-text,
- .text-metric-usage {
- fill: $black;
- font-weight: 500;
- }
+.folder-row {
+ padding: 15px 0;
+ border-bottom: 1px solid $white-normal;
- .legend-axis-text {
- fill: $black;
+ @media (max-width: $screen-sm-max) {
+ border-top: 1px solid $white-normal;
+ margin-top: 10px;
}
}
@@ -210,32 +179,33 @@
.rect-text-metric {
fill: $white-light;
stroke-width: 1;
- stroke: $black;
+ stroke: $gray-darkest;
}
.rect-axis-text {
fill: $white-light;
}
-.text-metric,
-.text-median-metric,
-.text-metric-usage,
-.text-metric-date {
- fill: $black;
+.text-metric {
+ font-size: 12px;
}
-.text-metric-date {
- font-weight: 200;
+.selected-metric-line {
+ stroke: $gl-gray-dark;
+ stroke-width: 1;
}
-.selected-metric-line {
+.deployment-line {
stroke: $black;
stroke-width: 1;
}
+.deploy-info-text {
+ dominant-baseline: text-before-edge;
+}
+
.prometheus-state {
margin-top: 10px;
- display: none;
.state-button-section {
margin-top: 10px;
@@ -250,3 +220,78 @@
width: 38px;
}
}
+
+.prometheus-panel {
+ margin-top: 20px;
+}
+
+.prometheus-svg-container {
+ position: relative;
+ height: 0;
+ width: 100%;
+ padding: 0;
+ padding-bottom: 100%;
+}
+
+.prometheus-svg-container > svg {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+
+ text {
+ fill: $gl-text-color;
+ stroke-width: 0;
+ }
+
+ .text-metric-bold {
+ font-weight: 600;
+ }
+
+ .label-axis-text,
+ .text-metric-usage {
+ fill: $black;
+ font-weight: 500;
+ font-size: 12px;
+ }
+
+ .legend-axis-text {
+ fill: $black;
+ }
+
+ .tick > text {
+ font-size: 12px;
+ }
+
+ .text-metric-title {
+ font-size: 12px;
+ }
+
+ .y-label-text,
+ .x-label-text {
+ fill: $gray-darkest;
+ }
+
+ .axis-tick {
+ stroke: $gray-darker;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ .label-axis-text,
+ .text-metric-usage,
+ .legend-axis-text {
+ font-size: 8px;
+ }
+
+ .tick > text {
+ font-size: 8px;
+ }
+ }
+}
+
+.prometheus-row {
+ h5 {
+ font-size: 16px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 5b723f7c722..4c3fa1fb8d4 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -89,7 +89,6 @@
background: $gray-light;
border-radius: 0;
color: $events-pre-color;
- margin: 0 20px;
overflow: hidden;
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 73a5889867a..6f6c6839975 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -88,3 +88,24 @@
color: $gl-text-color-secondary;
margin-top: 10px;
}
+
+.explore-groups.landing {
+ .inner-content {
+ padding: 0;
+
+ p {
+ margin: 7px 0 0;
+ max-width: 480px;
+ padding: 0 $gl-padding;
+
+ @media (max-width: $screen-sm-min) {
+ margin: 0 auto;
+ }
+ }
+ }
+
+ svg {
+ width: 62px;
+ height: 50px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 97fab513b01..49839a9b528 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -5,8 +5,39 @@
margin-right: auto;
}
+.is-confidential {
+ color: $orange-600;
+ background-color: $orange-50;
+ border-radius: $border-radius-default;
+ padding: 5px;
+ margin: 0 3px 0 -4px;
+}
+
+.is-not-confidential {
+ border-radius: $border-radius-default;
+ padding: 5px;
+ margin: 0 3px 0 -4px;
+}
+
+.confidentiality {
+ .is-not-confidential {
+ margin: auto;
+ }
+
+ .is-confidential {
+ margin: auto;
+ }
+}
+
.limit-container-width {
- .detail-page-header {
+ .detail-page-header,
+ .page-content-header,
+ .commit-box,
+ .info-well,
+ .commit-ci-menu,
+ .files-changed-inner,
+ .limited-header-width,
+ .limited-width-notes {
@extend .fixed-width-container;
}
@@ -17,16 +48,6 @@
.merge-manually {
@extend .fixed-width-container;
}
-
- .merge-request-tabs-holder {
- &.affix {
- border-bottom: 1px solid $border-color;
-
- .nav-links {
- border: 0;
- }
- }
- }
}
.merge-request-details {
@@ -36,8 +57,7 @@
}
.diffs {
- .mr-version-controls,
- .files-changed {
+ .mr-version-controls {
@extend .fixed-width-container;
}
}
@@ -61,6 +81,11 @@
border: 1px solid $white-normal;
padding: 5px;
max-height: calc(100vh - 100px);
+ max-width: 100%;
+ }
+
+ .emoji-block {
+ padding: 10px 0;
}
}
@@ -90,10 +115,15 @@
}
.right-sidebar {
- a {
+ a,
+ .btn-link {
color: inherit;
}
+ .btn-link {
+ outline: none;
+ }
+
.issuable-header-text {
margin-top: 7px;
}
@@ -195,8 +225,20 @@
right: 0;
transition: width .3s;
background: $gray-light;
- padding: 10px 20px;
- z-index: 2;
+ z-index: 200;
+ overflow: hidden;
+
+ .issuable-sidebar {
+ width: calc(100% + 100px);
+ height: calc(100% - #{$header-height});
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ &.affix-top .issuable-sidebar {
+ height: 100%;
+ }
&.right-sidebar-expanded {
width: $gutter_width;
@@ -210,6 +252,24 @@
}
}
+ .issuable-sidebar {
+ padding: 0 20px;
+ }
+
+ .issuable-sidebar-header {
+ padding-top: 10px;
+ }
+
+ &:not(.issue-boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
+ .issuable-sidebar-header {
+ display: none;
+ }
+ }
+
+ .assign-yourself .btn-link {
+ padding-left: 0;
+ }
+
.light {
font-weight: normal;
}
@@ -227,6 +287,10 @@
border-left: 1px solid $border-gray-normal;
}
+ .title .gutter-toggle {
+ margin-top: 0;
+ }
+
.assignee .avatar {
float: left;
margin-right: 10px;
@@ -234,6 +298,10 @@
margin-left: 0;
}
+ .assignee .user-list .avatar {
+ margin: 0;
+ }
+
.username {
display: block;
margin-top: 4px;
@@ -255,11 +323,10 @@
}
width: $gutter_collapsed_width;
- padding-top: 0;
+ padding: 0;
.block {
width: $gutter_collapsed_width - 2px;
- margin-left: -19px;
padding: 15px 0 0;
border-bottom: none;
overflow: hidden;
@@ -286,9 +353,17 @@
margin-bottom: 10px;
color: $issuable-sidebar-color;
+ svg {
+ fill: $issuable-sidebar-color;
+ }
+
&:hover,
&:hover .todo-undone {
color: $gl-text-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
}
span {
@@ -296,6 +371,10 @@
margin-top: 0;
}
+ .sidebar-avatar-counter {
+ padding-top: 2px;
+ }
+
.todo-undone {
color: $gl-link-color;
}
@@ -304,7 +383,8 @@
display: none;
}
- .avatar:hover {
+ .avatar-counter:hover {
+ color: $issuable-sidebar-color;
border-color: $issuable-sidebar-color;
}
@@ -317,6 +397,17 @@
color: $gl-text-color;
}
}
+
+ &.multiple-users {
+ display: flex;
+ justify-content: center;
+ }
+ }
+
+ .sidebar-avatar-counter {
+ width: 24px;
+ height: 24px;
+ border-radius: 12px;
}
.sidebar-collapsed-user {
@@ -327,6 +418,37 @@
.issuable-header-btn {
display: none;
}
+
+ .multiple-users {
+ height: 24px;
+ margin-bottom: 17px;
+ margin-top: 4px;
+ padding-bottom: 4px;
+
+ .btn-link {
+ padding: 0;
+ border: 0;
+
+ .avatar {
+ margin: 0;
+ }
+ }
+
+ .btn-link:first-child {
+ position: absolute;
+ left: 10px;
+ z-index: 1;
+ }
+
+ .btn-link:last-child {
+ position: absolute;
+ right: 10px;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
}
a {
@@ -356,8 +478,16 @@
}
}
+.with-performance-bar .right-sidebar {
+ top: $header-height + $performance-bar-height;
+
+ .issuable-sidebar {
+ height: calc(100% - #{$header-height} - #{$performance-bar-height});
+ }
+}
+
.detail-page-description {
- padding: 16px 0 0;
+ padding: 16px 0;
small {
color: $gray-darkest;
@@ -367,7 +497,7 @@
.edited-text {
color: $gray-darkest;
display: block;
- margin: 0 0 16px;
+ margin: 16px 0 0;
.author_link {
color: $gray-darkest;
@@ -378,6 +508,12 @@
margin: -5px;
}
+
+.user-list {
+ display: flex;
+ flex-wrap: wrap;
+}
+
.participants-author {
display: inline-block;
padding: 5px;
@@ -395,13 +531,39 @@
}
}
-.participants-more {
+.user-item {
+ display: inline-block;
+ padding: 5px;
+ flex-basis: 20%;
+
+ .user-link {
+ display: inline-block;
+ }
+}
+
+.participants-more,
+.user-list-more {
margin-top: 5px;
margin-left: 5px;
- a {
+ a,
+ .btn-link {
color: $gl-text-color-secondary;
}
+
+ .btn-link {
+ outline: none;
+ padding: 0;
+ }
+
+ .btn-link:hover {
+ @extend a:hover;
+ text-decoration: none;
+ }
+
+ .btn-link:focus {
+ text-decoration: none;
+ }
}
.issuable-form-padding-top {
@@ -479,7 +641,38 @@
.issue-info-container {
-webkit-flex: 1;
flex: 1;
+ display: flex;
padding-right: $gl-padding;
+
+ .issue-main-info {
+ flex: 1 auto;
+ margin-right: 10px;
+ }
+
+ .issuable-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex: 1 0 auto;
+
+ .controls {
+ margin-bottom: 2px;
+ line-height: 20px;
+ padding: 0;
+ }
+
+ .issue-updated-at {
+ line-height: 20px;
+ }
+ }
+
+ @media(max-width: $screen-xs-max) {
+ .issuable-meta {
+ .controls li {
+ margin-right: 0;
+ }
+ }
+ }
}
.issue-check {
@@ -491,6 +684,43 @@
vertical-align: text-top;
}
}
+
+ .issuable-milestone,
+ .issuable-info,
+ .task-status,
+ .issuable-updated-at {
+ font-weight: normal;
+ color: $gl-text-color-secondary;
+
+ a {
+ color: $gl-text-color;
+
+ .fa {
+ color: $gl-text-color-secondary;
+ }
+ }
+ }
+
+ @media(max-width: $screen-md-max) {
+ .task-status,
+ .issuable-due-date,
+ .project-ref-path {
+ display: none;
+ }
+ }
+ }
+}
+
+.issuable-list li,
+.issue-info-container .controls {
+ .avatar-counter {
+ display: inline-block;
+ vertical-align: middle;
+ min-width: 16px;
+ line-height: 14px;
+ height: 16px;
+ padding-left: 2px;
+ padding-right: 2px;
}
}
@@ -610,3 +840,26 @@
}
}
}
+
+.issuable-close-button,
+.issuable-close-toggle {
+ @include transition(border-color, color);
+}
+
+.issuable-close-dropdown {
+ .dropdown-menu {
+ min-width: 270px;
+ left: auto;
+ right: 0;
+ }
+
+ .description {
+ .text {
+ margin: 0;
+ }
+ }
+
+ .dropdown-toggle > .icon {
+ margin: 0 3px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 2aa52986e0a..8cdb3f34ae5 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -1,3 +1,5 @@
+@import "./issues/issue_count_badge";
+
.issues-list {
.issue {
padding: 10px 0 10px $gl-padding;
@@ -18,6 +20,15 @@
}
}
+.issue-realtime-pre-pulse {
+ opacity: 0;
+}
+
+.issue-realtime-trigger-pulse {
+ transition: opacity $fade-in-duration linear;
+ opacity: 1;
+}
+
.check-all-holder {
line-height: 36px;
float: left;
@@ -42,6 +53,7 @@ ul.related-merge-requests > li {
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
+ align-items: center;
.merge-request-id {
flex-shrink: 0;
@@ -50,6 +62,14 @@ ul.related-merge-requests > li {
.merge-request-info {
margin-left: 5px;
}
+
+ .row_title {
+ vertical-align: bottom;
+ }
+
+ gl-emoji {
+ font-size: 1em;
+ }
}
.merge-requests-title,
@@ -105,7 +125,6 @@ ul.related-merge-requests > li {
.related-merge-requests {
.ci-status-link {
display: block;
- margin-top: 3px;
margin-right: 5px;
}
@@ -161,3 +180,90 @@ ul.related-merge-requests > li {
.recaptcha {
margin-bottom: 30px;
}
+
+.new-branch-col {
+ padding-top: 10px;
+}
+
+.create-mr-dropdown-wrap {
+ .btn-group:not(.hide) {
+ display: flex;
+ }
+
+ .js-create-merge-request {
+ flex-grow: 1;
+ flex-shrink: 0;
+ }
+
+ .dropdown-menu {
+ width: 300px;
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ display: none;
+ }
+
+ .dropdown-toggle {
+ .fa-caret-down {
+ pointer-events: none;
+ color: inherit;
+ margin-left: 0;
+ }
+ }
+
+ li:not(.divider) {
+ padding: 6px;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ }
+
+ &.droplab-item-selected {
+ .icon-container {
+ i {
+ visibility: visible;
+ }
+ }
+ }
+
+ .icon-container {
+ float: left;
+ padding-left: 6px;
+
+ i {
+ visibility: hidden;
+ }
+ }
+
+ .description {
+ padding-left: 30px;
+ font-size: 13px;
+
+ strong {
+ display: block;
+ font-weight: 600;
+ }
+ }
+ }
+}
+
+@media (min-width: $screen-sm-min) {
+ .emoji-block .row {
+ display: flex;
+
+ .new-branch-col {
+ padding-top: 0;
+ text-align: right;
+ align-self: center;
+ }
+
+ .create-mr-dropdown-wrap {
+ .btn-group:not(.hide) {
+ display: inline-block;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
new file mode 100644
index 00000000000..ccb62bfed18
--- /dev/null
+++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
@@ -0,0 +1,29 @@
+.issue-count-badge {
+ display: inline-flex;
+ align-items: stretch;
+ height: 24px;
+}
+
+.issue-count-badge-count {
+ display: flex;
+ align-items: center;
+ padding-right: 10px;
+ padding-left: 10px;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-base;
+ line-height: 1;
+
+ &.has-btn {
+ border-right: 0;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+}
+
+.issue-count-badge-add-button {
+ display: flex;
+ align-items: center;
+ border: 1px solid $border-color;
+ border-radius: 0 $border-radius-base $border-radius-base 0;
+ line-height: 1;
+}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index e1ef0b029a5..ee48f7a3626 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -116,7 +116,7 @@
}
.manage-labels-list {
- > li:not(.empty-message) {
+ > li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
cursor: -webkit-grab;
@@ -138,6 +138,7 @@
.fa {
font-size: 18px;
vertical-align: middle;
+ pointer-events: none;
}
&:hover {
@@ -278,5 +279,9 @@
.label-link {
display: inline-block;
- vertical-align: text-top;
+ vertical-align: top;
+
+ .label {
+ vertical-align: inherit;
+ }
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 8249e02b64a..3cbe8dededb 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -128,6 +128,7 @@
a {
width: 100%;
font-size: 18px;
+ margin-right: 0;
&:hover {
border: 1px solid transparent;
@@ -140,6 +141,7 @@
a {
border: none;
border-bottom: 2px solid $link-underline-blue;
+ margin-right: 0;
color: $black;
&:hover {
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index be7193bae04..e7c07ef67f0 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -3,6 +3,41 @@
border-bottom: 1px solid $border-color;
}
+.project-member-tabs {
+ background: $gray-light;
+ border: 1px solid $border-color;
+
+ li {
+ width: 50%;
+
+ &.active {
+ background: $white-light;
+ }
+
+ &:first-child {
+ border-right: 1px solid $border-color;
+ }
+
+ a {
+ width: 100%;
+ text-align: center;
+ }
+ }
+}
+
+.users-project-form {
+ .btn-create {
+ margin-right: 10px;
+ }
+}
+
+.project-member-tab-content {
+ padding: $gl-padding;
+ border: 1px solid $border-color;
+ border-top: 0;
+ margin-bottom: $gl-padding;
+}
+
.member {
.list-item-name {
@media (min-width: $screen-sm-min) {
@@ -19,8 +54,6 @@
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
- width: 400px;
- max-width: 50%;
}
}
@@ -30,7 +63,6 @@
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
- width: 100%;
margin-top: 3px;
}
}
@@ -46,18 +78,10 @@
.member-form-control {
@media (max-width: $screen-xs-max) {
- padding: 5px 0;
+ padding-bottom: 5px;
margin-left: 0;
margin-right: 0;
}
-
- @media (min-width: $screen-sm-min) {
- width: 50%;
- }
-
- .dropdown-menu-toggle {
- width: 100%;
- }
}
.member-access-text {
@@ -101,10 +125,6 @@
width: 250px;
}
- @media (min-width: $screen-md-min) {
- width: 350px;
- }
-
&.input-short {
@media (min-width: $screen-md-min) {
width: 170px;
@@ -133,3 +153,154 @@
right: 160px;
}
}
+
+.flex-project-members-panel {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ @media (max-width: $screen-sm-min) {
+ display: block;
+
+ .flex-project-title {
+ vertical-align: top;
+ display: inline-block;
+ max-width: 90%;
+ }
+ }
+
+ .flex-project-title {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .badge {
+ height: 17px;
+ line-height: 16px;
+ margin-right: 5px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ }
+
+ .flex-project-members-form {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ margin-left: auto;
+ }
+}
+
+.panel {
+ .panel-heading {
+ .badge {
+ margin-top: 0;
+ }
+
+ @media (max-width: $screen-sm-min) {
+ .badge {
+ margin-right: 0;
+ margin-left: 0;
+ }
+ }
+ }
+}
+
+.content-list.members-list li {
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-name {
+ float: none;
+ display: flex;
+ flex: 1;
+ }
+
+ .user-info {
+ padding-right: 10px;
+ }
+
+ .member {
+ font-weight: bold;
+ overflow-wrap: break-word;
+ word-break: break-all;
+ }
+
+ .member-group-link {
+ display: inline-block;
+ }
+
+ .form-control {
+ width: inherit;
+ }
+
+ .btn {
+ align-self: flex-start;
+ }
+
+ .form-horizontal ~ .btn {
+ margin-right: 0;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+
+ .controls > .btn {
+ margin-left: 0;
+ margin-right: 0;
+ display: block;
+ }
+
+ .form-control {
+ width: 100%;
+ }
+
+ .member-access-text {
+ line-height: 0;
+ margin-left: 50px;
+ }
+
+ .member-controls {
+ margin-top: 5px;
+ }
+
+ .form-horizontal {
+ margin-top: 10px;
+ }
+ }
+}
+
+.panel-mobile {
+ .content-list.members-list li {
+ display: block;
+
+ .member-controls {
+ float: none;
+ display: block;
+ }
+
+ .dropdown-menu-toggle,
+ .dropdown-menu,
+ .form-control,
+ .list-item-name {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ margin-top: 0;
+ }
+
+ .form-horizontal {
+ display: block;
+ }
+
+ .member-form-control {
+ margin: 5px 0;
+ }
+
+ .btn {
+ width: 100%;
+ margin-left: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 6a419384a34..6bb013cca85 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -2,10 +2,35 @@
* MR -> show: Automerge widget
*
*/
+
+.space-children {
+ @include clearfix;
+
+ > * {
+ float: left;
+ }
+
+ > *:not(:last-child) {
+ margin-right: 10px;
+ }
+}
+
.mr-state-widget {
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 2px;
+ line-height: 28px;
+
+ .mr-widget-heading,
+ .mr-widget-section,
+ .mr-widget-footer {
+ padding: $gl-padding;
+ border-top: solid 1px $border-color;
+ }
+
+ .mr-widget-footer {
+ padding: 0;
+ }
form {
margin-bottom: 0;
@@ -15,15 +40,35 @@
}
}
+ label {
+ margin-bottom: 0;
+ }
+
+ .btn {
+ font-size: $gl-font-size;
+
+ &[disabled] {
+ opacity: 0.3;
+ }
+
+ &.btn-xs {
+ line-height: 1;
+ padding: 5px 10px;
+ margin-top: 1px;
+ }
+
+ &.dropdown-toggle {
+ .fa {
+ color: inherit;
+ }
+ }
+ }
+
.accept-merge-holder {
.accept-action {
display: inline-block;
float: left;
- .btn-success.dropdown-toggle .fa {
- color: inherit;
- }
-
.accept-merge-request {
&.ci-pending,
&.ci-running {
@@ -37,12 +82,6 @@
@include btn-red;
}
}
-
- .dropdown-toggle {
- .fa {
- color: inherit;
- }
- }
}
.accept-control {
@@ -88,100 +127,120 @@
}
}
- .ci_widget {
- border-bottom: 1px solid $well-inner-border;
+ .ci-widget {
color: $gl-text-color;
- display: -webkit-flex;
display: flex;
- -webkit-align-items: center;
- align-items: center;
-
- i,
- svg {
- margin-right: 8px;
- }
-
- svg {
- position: relative;
- top: 1px;
- overflow: visible;
- }
-
- & > span {
- padding-right: 4px;
- }
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
+ }
- .ci-status-icon > .icon-link > svg {
- width: 22px;
- height: 22px;
- }
+ .mr-widget-icon {
+ font-size: 22px;
+ margin-right: $status-icon-margin;
}
- .mr-widget-body,
- .ci_widget,
- .mr-widget-footer {
- padding: 16px;
+ .ci-status-icon svg {
+ width: $status-icon-size;
+ height: $status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+
+ .mr-widget-body {
+ @include clearfix;
+
+ &.media > *:first-child {
+ margin-right: 10px;
+ }
}
.mr-widget-pipeline-graph {
- flex-shrink: 0;
+ padding: 0 4px;
.dropdown-menu {
- margin-top: 11px;
+ z-index: 300;
}
.ci-action-icon-wrapper {
line-height: 16px;
}
+ }
- @media (min-width: $screen-sm-min) {
- .stage-cell {
- padding: 0 4px;
- }
- }
-
- @media (max-width: $screen-xs-max) {
- order: 1;
- margin-top: $gl-padding-top;
- border-radius: 3px;
- background-color: $white-light;
- border: 1px solid $gray-darker;
- width: 100%;
- text-align: center;
+ .mini-pipeline-graph-dropdown-toggle {
+ vertical-align: top;
+ }
- .dropdown-menu {
- margin-left: -97.5px;
- }
+ .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item {
+ display: flex;
+ align-items: center;
- .arrow-up::before,
- .arrow-up::after, {
- margin-left: 97.5px;
- }
+ .ci-status-text,
+ .ci-status-icon {
+ top: 0;
+ margin-right: 10px;
}
}
.normal {
+ line-height: 28px;
+ }
+
+ .capitalize {
+ text-transform: capitalize;
+ }
+
+ .label-branch {
+ @extend .ref-name;
+
color: $gl-text-color;
+ font-weight: 600;
+ overflow: hidden;
+ word-break: break-all;
+
+ &.label-truncated {
+ position: relative;
+ display: inline-block;
+ width: 250px;
+ margin-bottom: -3px;
+ white-space: nowrap;
+ text-overflow: clip;
+ line-height: 14px;
+
+ &::after {
+ position: absolute;
+ content: '...';
+ right: 0;
+ font-family: $regular_font;
+ background-color: $gray-light;
+ }
+ }
}
- .js-deployment-link {
- display: inline-block;
+ .mr-widget-help {
+ padding: 10px 16px 10px 48px;
+ font-style: italic;
}
.mr-widget-body {
h4 {
+ float: left;
font-weight: 600;
- font-size: 16px;
- margin: 5px 0;
- color: $gl-text-color;
+ font-size: 14px;
+ line-height: inherit;
+ margin-top: 0;
+ margin-bottom: 0;
&.has-conflicts .fa-exclamation-triangle {
color: $gl-warning;
}
+
+ time {
+ font-weight: normal;
+ }
}
.btn-grouped {
@@ -189,19 +248,60 @@
margin-right: 7px;
}
- @media (max-width: $screen-xs-max) {
- h4 {
- font-size: 14px;
+ label {
+ font-weight: normal;
+ }
+
+ .spacing {
+ margin: 0 0 0 10px;
+ }
+
+ .bold {
+ font-weight: 600;
+ color: $gl-gray-light;
+ }
+
+ .state-label {
+ font-weight: 600;
+ padding-right: 10px;
+ }
+
+ .danger {
+ color: $gl-danger;
+ }
+
+ .spacing,
+ .bold {
+ vertical-align: middle;
+ }
+
+ .dropdown-menu {
+ li a {
+ padding: 5px;
}
- p {
- font-size: 13px;
+ .merge-opt-icon {
+ line-height: 1.5;
}
- .btn,
- .btn-group,
- .accept-action {
- margin-bottom: 4px;
+ .merge-opt-title {
+ margin-left: 8px;
+ }
+ }
+
+ .dropdown-toggle {
+ .fa {
+ color: inherit;
+ }
+ }
+
+ .has-custom-error {
+ display: inline-block;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ p {
+ font-size: 13px;
}
.btn-grouped {
@@ -220,10 +320,30 @@
margin: 0;
}
}
- }
- .mr-widget-footer {
- border-top: 1px solid $gray-darker;
+ .commit-message-editor {
+ label {
+ padding: 0;
+ }
+ }
+
+ &.mr-widget-empty-state {
+ line-height: 20px;
+
+ .artwork {
+ margin-bottom: $gl-padding;
+ }
+
+ .text {
+ span {
+ font-weight: 600;
+ }
+
+ p {
+ margin-top: $gl-padding;
+ }
+ }
+ }
}
.ci-coverage {
@@ -240,13 +360,19 @@
}
}
+.mr-state-widget .mr-widget-body {
+ .approve-btn {
+ margin-right: 5px;
+ }
+}
+
.mr_source_commit,
.mr_target_commit {
margin-bottom: 0;
.commit {
margin: 0;
- padding: 10px 0;
+ padding: 10px;
list-style: none;
&:hover {
@@ -255,16 +381,6 @@
}
}
-.label-branch {
- color: $gl-text-color;
- font-family: $monospace_font;
- font-weight: bold;
- overflow: hidden;
- font-size: 90%;
- margin: 0 3px;
- word-break: break-all;
-}
-
.commits-empty {
text-align: center;
@@ -334,8 +450,6 @@
}
.btn-clipboard {
- @extend .pull-right;
-
margin-right: 20px;
margin-top: 5px;
position: absolute;
@@ -343,61 +457,49 @@
}
}
-.remove-message-pipes {
- ul {
- margin: 10px 0 0 12px;
- padding: 0;
- list-style: none;
- border-left: 2px solid $border-color;
- display: inline-block;
- }
+.mr-links {
+ padding-left: $status-icon-size + $status-icon-margin;
+}
- li {
- position: relative;
+.mr-info-list {
+ clear: left;
+ position: relative;
+ padding-top: 4px;
+
+ p {
margin: 0;
- padding: 0;
- display: block;
+ position: relative;
+ padding: 4px 0;
- span {
- margin-left: 15px;
- max-height: 20px;
+ &:last-child {
+ padding-bottom: 0;
}
}
+}
- li::before {
- content: '';
- position: absolute;
- border-top: 2px solid $border-color;
- height: 1px;
- top: 8px;
- width: 8px;
+.mr-info-list.mr-memory-usage {
+ p {
+ float: left;
}
- li:last-child {
- &::before {
- top: 18px;
- }
-
- span {
- display: block;
- position: relative;
- top: 5px;
- margin-top: 5px;
- }
+ .memory-graph-container {
+ float: left;
+ margin-left: 5px;
}
}
.mr-source-target {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
background-color: $gray-light;
- line-height: 31px;
- border-style: solid;
- border-width: 1px;
- border-color: $border-color;
- border-top-right-radius: 3px;
- border-top-left-radius: 3px;
- border-bottom: none;
- padding: 16px;
- margin-bottom: -1px;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ padding: $gl-padding / 2 $gl-padding;
+
+ .dropdown-toggle .fa {
+ color: $gl-text-color;
+ }
}
.panel-new-merge-request {
@@ -482,6 +584,10 @@
}
}
+.target-branch-select-dropdown-container {
+ position: relative;
+}
+
.assign-to-me-link {
padding-left: 12px;
white-space: nowrap;
@@ -498,21 +604,16 @@
}
.merged-buttons {
- margin-top: 20px;
-
.btn {
float: left;
-
- &:not(:last-child) {
- margin-right: 10px;
- }
}
}
.mr-version-controls {
+ position: relative;
background: $gray-light;
- border-bottom: 1px solid $border-color;
color: $gl-text-color;
+ z-index: 199;
.mr-version-menus-container {
display: -webkit-flex;
@@ -550,12 +651,18 @@
}
.merge-request-tabs-holder {
+ top: $header-height;
+ z-index: 200;
background-color: $white-light;
+ border-bottom: 1px solid $border-color;
+
+ @media (min-width: $screen-sm-min) {
+ position: sticky;
+ position: -webkit-sticky;
+ }
&.affix {
- top: 0;
left: 0;
- z-index: 10;
transition: right .15s;
@media (max-width: $screen-xs-max) {
@@ -567,6 +674,20 @@
padding-right: $gl-padding;
}
}
+
+ .nav-links {
+ border: 0;
+ }
+}
+
+.with-performance-bar .merge-request-tabs-holder {
+ top: $header-height + $performance-bar-height;
+}
+
+.merge-request-tabs {
+ display: flex;
+ margin-bottom: 0;
+ padding: 0;
}
.limit-container-width {
@@ -574,6 +695,21 @@
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
+
+ .inner-page-scroll-tabs {
+ background-color: $white-light;
+ margin-left: -$gl-padding;
+ padding-left: $gl-padding;
+ }
+ }
+}
+
+.merge-request-tabs-container {
+ display: flex;
+ justify-content: space-between;
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column-reverse;
}
}
@@ -584,3 +720,10 @@
}
}
}
+
+.mr-memory-usage {
+ p.usage-info-loading .usage-info-load-spinner {
+ margin-right: 10px;
+ font-size: 16px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 335e587b8f4..55e0ee1936e 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -111,8 +111,8 @@
}
}
-.issues-sortable-list,
-.merge_requests-sortable-list {
+.milestone-issues-list,
+.milestone-merge_requests-list {
.issuable-detail {
display: block;
margin-top: 7px;
@@ -197,6 +197,4 @@
.issuable-row {
background-color: $white-light;
- cursor: -webkit-grab;
- cursor: grab;
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 62f654ed343..b4468d6d0a2 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -28,7 +28,7 @@
.note-edit-form {
.note-form-actions {
position: relative;
- margin: $gl-padding 0;
+ margin: $gl-padding 0 0;
}
.note-preview-holder {
@@ -104,30 +104,65 @@
}
.confidential-issue-warning {
- background-color: $gray-normal;
- border-radius: 3px;
+ color: $orange-600;
+ background-color: $orange-50;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ border: 1px solid $border-gray-normal;
+ border-bottom: none;
padding: 3px 12px;
margin: auto;
- margin-top: 0;
- text-align: center;
- font-size: 12px;
-
- @media (max-width: $screen-sm-max) {
- // On smaller devices the warning becomes the fourth item in the list,
- // rather than centering, and grows to span the full width of the
- // comment area.
- order: 4;
- -webkit-order: 4;
- margin: 6px auto;
- width: 100%;
+ align-items: center;
+}
+
+.confidential-value {
+ .fa {
+ background-color: inherit;
+ }
+}
+
+.confidential-warning-message {
+ line-height: 1.5;
+ padding: 16px;
+
+ .confidential-warning-message-actions {
+ display: flex;
+
+ button {
+ flex-grow: 1;
+ }
}
}
+.confidential-issue-warning + .md-area {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
.discussion-form {
- padding: $gl-padding-top $gl-padding;
+ padding: $gl-padding-top $gl-padding $gl-padding;
background-color: $white-light;
}
+.discussion-notes .disabled-comment {
+ padding: 6px 0;
+}
+
+.notes.notes-form > li.timeline-entry {
+ @include notes-media('max', $screen-sm-max) {
+ padding: 0;
+ }
+
+ .timeline-content {
+ @include notes-media('max', $screen-sm-max) {
+ margin: 0;
+ }
+ }
+
+ .timeline-entry-inner {
+ border: 0;
+ }
+}
+
.note-edit-form {
display: none;
font-size: 14px;
@@ -164,10 +199,6 @@
.discussion-body,
.diff-file {
- .notes .note {
- padding: 10px 15px;
- }
-
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
@@ -277,6 +308,7 @@
.toolbar-text {
font-size: 14px;
line-height: 16px;
+ margin-top: 2px;
@media (min-width: $screen-md-min) {
float: left;
@@ -323,7 +355,6 @@
color: $white-light;
padding-right: 2px;
margin-top: 2px;
- pointer-events: none;
}
}
@@ -333,56 +364,6 @@
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;
@@ -402,3 +383,45 @@
}
}
}
+
+.uploading-container {
+ float: right;
+
+ @media (max-width: $screen-xs-max) {
+ float: left;
+ margin-top: 5px;
+ }
+}
+
+.uploading-error-icon,
+.uploading-error-message {
+ color: $gl-text-red;
+}
+
+.uploading-error-message {
+ @media (max-width: $screen-xs-max) {
+ &::after {
+ content: "\a";
+ white-space: pre;
+ }
+ }
+}
+
+.uploading-progress {
+ margin-right: 5px;
+}
+
+.attach-new-file,
+.button-attach-file,
+.retry-uploading-link {
+ color: $gl-link-color;
+ padding: 0;
+ background: none;
+ border: 0;
+ font-size: 14px;
+ line-height: 16px;
+}
+
+.markdown-selector {
+ color: $gl-link-color;
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 69a95db6920..0a194f3707f 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -14,60 +14,93 @@ ul.notes {
margin: 0;
padding: 0;
- .timeline-icon {
- float: left;
-
- svg {
- width: 16px;
- height: 16px;
- fill: $gray-darkest;
- position: absolute;
- left: 0;
- top: 16px;
- }
- }
-
- .timeline-content {
- margin-left: 55px;
-
- &.timeline-content-form {
- @media (max-width: $screen-sm-max) {
- margin-left: 0;
- }
- }
- }
-
.note-created-ago,
.note-updated-at {
white-space: nowrap;
}
.discussion-body {
- padding-top: 15px;
+ padding-top: 8px;
+
+ .panel {
+ margin-bottom: 0;
+ }
}
.discussion {
- overflow: hidden;
display: block;
position: relative;
+
+ .diff-content {
+ overflow: visible;
+ }
}
- .note {
+ > li { // .timeline-entry
+ padding: 0;
display: block;
position: relative;
- border-bottom: 1px solid $white-normal;
+ border-bottom: 0;
- &.note-discussion {
- &.timeline-entry {
- padding: 14px 10px;
+ @include notes-media('min', $screen-sm-min) {
+ padding-left: $note-icon-gutter-width;
+ }
+
+ .timeline-entry-inner {
+ padding: $gl-padding $gl-btn-padding;
+ border-bottom: 1px solid $white-normal;
+ }
+
+ &:target,
+ &.target {
+ border-bottom: 1px solid $white-normal;
+
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
+ margin-top: -1px;
+ }
+
+ .timeline-entry-inner {
+ border-bottom: 0;
+ }
+ }
+
+ .timeline-icon {
+ @include notes-media('min', $screen-sm-min) {
+ margin-left: -$note-icon-gutter-width;
+ }
+ }
+
+ .timeline-content {
+ margin-left: $note-icon-gutter-width;
+
+ @include notes-media('min', $screen-sm-min) {
+ margin-left: 0;
+ }
+ }
+
+ &.being-posted {
+ pointer-events: none;
+ opacity: 0.5;
+
+ .dummy-avatar {
+ background-color: $kdb-border;
+ border: 1px solid darken($kdb-border, 25%);
}
- .system-note {
- padding: 0;
+ .note-headline-light,
+ .fa-spinner {
+ margin-left: 3px;
}
}
- &.is-editting {
+ &.note-discussion {
+ .timeline-entry-inner {
+ padding: $gl-padding 10px;
+ }
+ }
+
+ &.is-editing {
.note-header,
.note-text,
.edited-text {
@@ -88,33 +121,32 @@ ul.notes {
overflow-y: hidden;
.note-text {
- word-wrap: break-word;
@include md-typography;
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
+ word-wrap: break-word;
+
ul.task-list {
ul:not(.task-list) {
padding-left: 1.3em;
}
}
+
+ table {
+ @include markdown-table;
+ }
}
}
.note-awards {
.js-awards-block {
- margin-bottom: 16px;
+ margin-top: 16px;
}
}
.note-header {
- padding-bottom: 8px;
- padding-right: 20px;
- @media (min-width: $screen-sm-min) {
- padding-right: 0;
- }
-
- @media (max-width: $screen-xs-min) {
+ @include notes-media('max', $screen-xs-min) {
.inline {
display: block;
}
@@ -143,14 +175,9 @@ ul.notes {
.system-note {
font-size: 14px;
- padding: 0;
clear: both;
- @media (min-width: $screen-sm-min) {
- margin-left: 65px;
- }
-
- .note-header {
+ .note-header-info {
padding-bottom: 0;
}
@@ -180,11 +207,25 @@ ul.notes {
}
}
- .timeline-content {
- padding: 14px 10px;
+ .timeline-icon {
+ float: left;
+
+ @include notes-media('min', $screen-sm-min) {
+ margin-left: 0;
+ width: auto;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: $gray-darkest;
+ margin-top: 2px;
+ }
+ }
- @media (min-width: $screen-sm-min) {
- margin-left: 20px;
+ .timeline-content {
+ @include notes-media('min', $screen-sm-min) {
+ margin-left: 30px;
}
}
@@ -210,7 +251,7 @@ ul.notes {
}
.note-text {
- & p:first-child {
+ p:first-child {
display: none;
}
@@ -221,11 +262,6 @@ ul.notes {
ul {
margin: 3px 0 3px 16px !important;
-
- .gfm-commit {
- font-family: $monospace_font;
- font-size: 12px;
- }
}
p:first-child {
@@ -235,7 +271,7 @@ ul.notes {
&::after {
content: '';
width: 100%;
- height: 67px;
+ height: 70px;
position: absolute;
left: 0;
bottom: 0;
@@ -267,10 +303,6 @@ ul.notes {
}
}
- .diff-header > span {
- margin-right: 10px;
- }
-
.line_content {
white-space: pre-wrap;
}
@@ -361,16 +393,31 @@ ul.notes {
.note-header {
display: flex;
justify-content: space-between;
+
+ @include notes-media('max', $screen-xs-max) {
+ flex-flow: row wrap;
+ }
}
.note-header-info {
min-width: 0;
+ padding-bottom: 8px;
+}
+
+.system-note .note-header-info {
+ padding-bottom: 0;
+}
+
+.note-header-author-name {
+ @include notes-media('max', $screen-xs-max) {
+ display: none;
+ }
}
.note-headline-light {
display: inline;
- @media (max-width: $screen-xs-min) {
+ @include notes-media('max', $screen-xs-min) {
display: block;
}
}
@@ -406,48 +453,99 @@ ul.notes {
}
.note-actions {
+ align-self: flex-start;
flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
// For PhantomJS that does not support flex
float: right;
margin-left: 10px;
color: $gray-darkest;
- .note-action-button {
- margin-left: 8px;
+ @include notes-media('max', $screen-xs-max) {
+ float: none;
+ margin-left: 0;
+ }
+}
+
+.more-actions {
+ float: right; // phantomjs fallback
+ display: flex;
+ align-items: flex-end;
+
+ .tooltip {
+ white-space: nowrap;
+ }
+}
+
+.more-actions-toggle {
+ &:hover .icon,
+ &:focus .icon {
+ color: $blue-600;
+ }
+}
+
+.more-actions-dropdown {
+ width: 180px;
+ min-width: 180px;
+ margin-top: $gl-btn-padding;
+
+ li > a,
+ li > .btn {
+ color: $gl-text-color;
+ padding: $gl-btn-padding;
+ width: 100%;
+ text-align: left;
+
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ background-color: $blue-25;
+ border-radius: $border-radius-default;
+ }
}
}
.discussion-actions {
- @media (max-width: $screen-md-max) {
+ @include notes-media('max', $screen-md-max) {
float: none;
margin-left: 0;
-
- .note-action-button {
- margin-left: 0;
- }
}
}
-.note-action-button {
- display: inline;
- line-height: 20px;
+.note-actions-item {
+ margin-left: 15px;
+ display: flex;
+ align-items: center;
- @media (min-width: $screen-sm-min) {
+ &.more-actions {
+ // compensate for narrow icon
margin-left: 10px;
- line-height: 24px;
}
+}
+
+.note-action-button {
+ line-height: 1;
+ padding: 0;
+ min-width: 16px;
+ color: $gray-darkest;
.fa {
- color: $gray-darkest;
position: relative;
- font-size: 17px;
+ font-size: 16px;
}
+
+
svg {
height: 16px;
width: 16px;
- fill: $gray-darkest;
+ top: 0;
vertical-align: text-top;
+
+ path {
+ fill: currentColor;
+ }
}
.award-control-icon-positive,
@@ -520,15 +618,12 @@ ul.notes {
.note-role {
position: relative;
- top: -2px;
- display: inline-block;
- padding-left: 4px;
- padding-right: 4px;
+ padding: 0 7px;
color: $notes-role-color;
font-size: 12px;
line-height: 20px;
border: 1px solid $border-color;
- border-radius: $border-radius-base;
+ border-radius: $label-border-radius;
}
@@ -536,8 +631,14 @@ ul.notes {
* Line note button on the side of diffs
*/
+.line_holder .is-over:not(.no-comment-btn) {
+ .add-diff-note {
+ opacity: 1;
+ }
+}
+
.add-diff-note {
- display: none;
+ opacity: 0;
margin-top: -2px;
border-radius: 50%;
background: $white-light;
@@ -550,13 +651,11 @@ ul.notes {
width: 23px;
height: 23px;
border: 1px solid $blue-500;
- transition: transform .1s ease-in-out;
&:hover {
background: $blue-500;
border-color: $blue-600;
color: $white-light;
- transform: scale(1.15);
}
&:active {
@@ -564,6 +663,19 @@ ul.notes {
}
}
+.discussion-body,
+.diff-file {
+ .notes .note {
+ border-bottom: 1px solid $white-normal;
+
+ .timeline-entry-inner {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+ border-bottom: none;
+ }
+ }
+}
+
.diff-file {
.is-over {
.add-diff-note {
@@ -573,17 +685,11 @@ ul.notes {
}
.disabled-comment {
- margin-left: -$gl-padding-top;
- margin-right: -$gl-padding-top;
background-color: $gray-light;
border-radius: $border-radius-base;
border: 1px solid $border-gray-normal;
color: $note-disabled-comment-color;
- line-height: 200px;
-
- .disabled-comment-text {
- line-height: normal;
- }
+ padding: 90px 0;
a {
color: $gl-link-color;
@@ -591,6 +697,15 @@ ul.notes {
}
.line-resolve-all-container {
+ @include notes-media('min', $screen-sm-min) {
+ margin-right: 0;
+ padding-left: $gl-padding;
+ }
+
+ > div {
+ white-space: nowrap;
+ }
+
.btn-group {
margin-left: -4px;
}
@@ -624,7 +739,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: 6px 10px;
+ padding: 5px 10px 6px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
@@ -637,12 +752,16 @@ ul.notes {
.line-resolve-btn {
margin-right: 5px;
+
+ svg {
+ vertical-align: middle;
+ }
}
}
.line-resolve-btn {
position: relative;
- top: 2px;
+ top: 0;
padding: 0;
background-color: transparent;
border: none;
@@ -663,8 +782,8 @@ ul.notes {
svg {
fill: $gray-darkest;
- height: 15px;
- width: 15px;
+ height: 16px;
+ width: 16px;
}
.loading {
@@ -673,6 +792,10 @@ ul.notes {
}
}
+.line-resolve-text {
+ vertical-align: middle;
+}
+
.discussion-next-btn {
svg {
margin: 0;
@@ -683,13 +806,12 @@ ul.notes {
}
}
+.discussion-notes .flash-container {
+ margin-bottom: 0;
+}
+
// Merge request notes in diffs
.diff-file {
- // Diff is side by side
- .notes_content.parallel .note-header .note-headline-light {
- display: block;
- position: relative;
- }
// Diff is inline
.notes_content .note-header .note-headline-light {
display: inline-block;
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
new file mode 100644
index 00000000000..dc1654e006e
--- /dev/null
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -0,0 +1,157 @@
+.js-pipeline-schedule-form {
+ .dropdown-select,
+ .dropdown-menu-toggle {
+ width: 100% !important;
+ }
+
+ .gl-field-error {
+ margin: 10px 0 0;
+ }
+}
+
+.interval-pattern-form-group {
+ label {
+ margin-right: 10px;
+ font-weight: normal;
+
+ &[for='custom'] {
+ margin-right: 0;
+ }
+ }
+
+ .cron-interval-input-wrapper {
+ padding-left: 0;
+ }
+
+ .cron-interval-input {
+ margin: 10px 10px 0 0;
+ }
+
+ .cron-syntax-link-wrap {
+ margin-right: 10px;
+ font-size: 12px;
+ }
+}
+
+.pipeline-schedule-table-row {
+ .branch-name-cell {
+ max-width: 300px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .next-run-cell {
+ color: $gl-text-color-secondary;
+ }
+
+ a {
+ color: $text-color;
+ }
+}
+
+.pipeline-schedules-user-callout {
+ .bordered-box.content-block {
+ border: 1px solid $border-color;
+ background-color: transparent;
+ padding: 16px;
+ }
+
+ #dismiss-callout-btn {
+ color: $gl-text-color;
+ }
+}
+
+.cron-preset-radio-input {
+ display: inline-block;
+
+ @media (max-width: $screen-md-max) {
+ display: block;
+ margin: 0 0 5px 5px;
+ }
+
+ input {
+ margin-right: 3px;
+ }
+}
+
+.pipeline-variable-list {
+ margin-left: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ list-style: none;
+ clear: both;
+}
+
+.pipeline-variable-row {
+ display: flex;
+ align-items: flex-end;
+
+ &:not(:last-child) {
+ margin-bottom: $gl-btn-padding;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ padding-right: $gl-col-padding;
+ }
+
+ &:last-child {
+ .pipeline-variable-row-remove-button {
+ display: none;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ .pipeline-variable-value-input {
+ margin-right: $pipeline-variable-remove-button-width;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .pipeline-variable-row-body {
+ margin-right: $pipeline-variable-remove-button-width;
+ }
+ }
+ }
+}
+
+.pipeline-variable-row-body {
+ display: flex;
+ width: calc(75% - #{$gl-col-padding});
+ padding-left: $gl-col-padding;
+
+ @media (max-width: $screen-sm-max) {
+ width: 100%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+ }
+}
+
+.pipeline-variable-key-input {
+ margin-right: $gl-btn-padding;
+
+ @media (max-width: $screen-xs-max) {
+ margin-bottom: $gl-btn-padding;
+ }
+}
+
+.pipeline-variable-row-remove-button {
+ @include transition(color);
+ flex-shrink: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $pipeline-variable-remove-button-width;
+ height: $input-height;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ color: $gl-text-color-secondary;
+
+ &:hover,
+ &:focus {
+ outline: none;
+ color: $gl-text-color;
+ }
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index a4fe652b52f..85d1905ad40 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -1,10 +1,4 @@
.pipelines {
- .realtime-loading {
- font-size: 40px;
- text-align: center;
- margin: 0 auto;
- }
-
.stage {
max-width: 90px;
width: 90px;
@@ -14,23 +8,15 @@
white-space: nowrap;
}
- .empty-state {
- margin: 5% auto 0;
- }
-
.table-holder {
width: 100%;
-
- @media (max-width: $screen-sm-max) {
- overflow: auto;
- }
}
.commit-title {
margin: 0;
}
- .table.ci-table {
+ .ci-table {
.label {
margin-bottom: 3px;
@@ -40,11 +26,6 @@
color: $black;
}
- .stage-cell {
- min-width: 130px; // Guarantees we show at least 4 stages in line
- width: 20%;
- }
-
.pipelines-time-ago {
text-align: right;
}
@@ -98,6 +79,10 @@
}
}
+ .btn .text-center {
+ display: inline;
+ }
+
.tooltip {
white-space: nowrap;
}
@@ -114,39 +99,7 @@
}
}
-.table.ci-table {
-
- &.builds-page tbody tr {
- height: 71px;
- }
-
- tr {
- th {
- padding: 16px 8px;
- border: none;
- }
-
- td {
- padding: 10px 8px;
- }
-
- td.environments-actions {
- padding-right: 0;
- }
-
- td.stage-cell {
- padding: 10px 0;
- }
-
- .commit-link {
- padding: 9px 8px 10px 2px;
- }
- }
-
- tbody {
- border-top-width: 1px;
- }
-
+.ci-table {
.build.retried {
background-color: $gray-lightest;
}
@@ -168,15 +121,19 @@
float: none;
}
+ .api {
+ @extend .monospace;
+ }
+
.branch-commit {
- .branch-name {
+ .ref-name {
font-weight: bold;
max-width: 120px;
overflow: hidden;
display: inline-block;
white-space: nowrap;
- vertical-align: top;
+ vertical-align: middle;
text-overflow: ellipsis;
}
@@ -192,18 +149,10 @@
color: $gl-text-color;
}
- .commit-id {
+ .commit-sha {
color: $gl-link-color;
}
- .commit-title {
- margin-top: 4px;
- max-width: 225px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
.label {
margin-right: 4px;
}
@@ -230,7 +179,7 @@
.duration,
.finished-at {
color: $gl-text-color-secondary;
- margin: 4px 0;
+ margin: 0;
white-space: nowrap;
.fa {
@@ -256,11 +205,7 @@
}
.stage-cell {
- font-size: 0;
- padding: 10px 4px;
-
- > .stage-container > div > button > span > svg,
- > .stage-container > button > svg {
+ .mini-pipeline-graph-dropdown-toggle svg {
height: 22px;
width: 22px;
position: absolute;
@@ -273,8 +218,13 @@
.stage-container {
display: inline-block;
position: relative;
+ vertical-align: middle;
height: 22px;
- margin: 3px 6px 3px 0;
+ margin: 3px 0;
+
+ + .stage-container {
+ margin-left: 6px;
+ }
// Hack to show a button tooltip inline
button.has-tooltip + .tooltip {
@@ -316,6 +266,32 @@
}
}
+.build-failures {
+ .build-state {
+ padding: 20px 2px;
+
+ .build-name {
+ float: right;
+ font-weight: 500;
+ }
+
+ .ci-status-icon-failed svg {
+ vertical-align: middle;
+ }
+
+ .stage {
+ color: $gl-text-color-secondary;
+ font-weight: 500;
+ vertical-align: middle;
+ }
+ }
+
+ .build-log {
+ border: none;
+ line-height: initial;
+ }
+}
+
// Pipeline graph
.pipeline-graph {
width: 100%;
@@ -357,9 +333,9 @@
content: '';
position: absolute;
top: 48%;
- left: -48px;
+ left: -44px;
border-top: 2px solid $border-color;
- width: 48px;
+ width: 44px;
height: 1px;
}
}
@@ -459,7 +435,7 @@
color: $gl-text-color-secondary;
// Action Icons in big pipeline-graph nodes
- > .ci-action-icon-container .ci-action-icon-wrapper {
+ .ci-action-icon-container .ci-action-icon-wrapper {
height: 30px;
width: 30px;
background: $white-light;
@@ -484,7 +460,7 @@
}
}
- > .ci-action-icon-container {
+ .ci-action-icon-container {
position: absolute;
right: 5px;
top: 5px;
@@ -514,50 +490,23 @@
}
}
- > .build-content {
+ .build-content {
display: inline-block;
padding: 8px 10px 9px;
width: 100%;
border: 1px solid $border-color;
border-radius: 30px;
background-color: $white-light;
-
- &:hover {
- background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-border;
- color: $gl-text-color;
- }
}
-
- .arrow {
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: 18px;
- }
-
- &::before {
- left: -5px;
- margin-top: -6px;
- border-width: 7px 5px 7px 0;
- border-right-color: $border-color;
- }
-
- &::after {
- left: -4px;
- margin-top: -9px;
- border-width: 10px 7px 10px 0;
- border-right-color: $white-light;
- }
+ a.build-content:hover,
+ button.build-content:hover {
+ background-color: $stage-hover-bg;
+ border: 1px solid $stage-hover-border;
+ color: $gl-text-color;
}
+
// Connect first build in each stage with right horizontal line
&:first-child {
&::after {
@@ -634,8 +583,25 @@
font-weight: normal;
}
+@mixin mini-pipeline-graph-color($color-light, $color-main, $color-dark) {
+ border-color: $color-main;
+ color: $color-main;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: $color-light;
+ border-color: $color-dark;
+ color: $color-dark;
+
+ svg {
+ fill: $color-dark;
+ }
+ }
+}
+
// Dropdown button in mini pipeline graph
-.mini-pipeline-graph-dropdown-toggle {
+button.mini-pipeline-graph-dropdown-toggle {
border-radius: 100px;
background-color: $white-light;
border-width: 1px;
@@ -646,6 +612,7 @@
padding: 0;
transition: all 0.2s linear;
position: relative;
+ vertical-align: middle;
> .fa.fa-caret-down {
position: absolute;
@@ -673,100 +640,32 @@
// Dropdown button animation in mini pipeline graph
&.ci-status-icon-success {
- border-color: $green-500;
- color: $green-500;
-
- &:hover,
- &:focus,
- &:active {
- background-color: $green-50;
- border-color: $green-600;
- color: $green-600;
-
- svg {
- fill: $green-600;
- }
- }
+ @include mini-pipeline-graph-color($green-50, $green-500, $green-600);
}
&.ci-status-icon-failed {
- border-color: $red-500;
- color: $red-500;
-
- &:hover,
- &:focus,
- &:active {
- background-color: $red-50;
- border-color: $red-600;
- color: $red-600;
-
- svg {
- fill: $red-600;
- }
- }
+ @include mini-pipeline-graph-color($red-50, $red-500, $red-600);
}
&.ci-status-icon-pending,
&.ci-status-icon-success_with_warnings {
- border-color: $orange-500;
- color: $orange-500;
-
- &:hover,
- &:focus,
- &:active {
- background-color: $orange-50;
- border-color: $orange-600;
- color: $orange-600;
-
- svg {
- fill: $orange-600;
- }
- }
+ @include mini-pipeline-graph-color($orange-50, $orange-500, $orange-600);
}
&.ci-status-icon-running {
- border-color: $blue-400;
- color: $blue-400;
-
- &:hover,
- &:focus,
- &:active {
- background-color: $blue-50;
- border-color: $blue-600;
- color: $blue-600;
-
- svg {
- fill: $blue-600;
- }
- }
+ @include mini-pipeline-graph-color($blue-50, $blue-400, $blue-600);
}
&.ci-status-icon-canceled,
&.ci-status-icon-disabled,
&.ci-status-icon-not-found,
&.ci-status-icon-manual {
- border-color: $gl-text-color;
- color: $gl-text-color;
-
- &:hover,
- &:focus,
- &:active {
- background-color: rgba($gl-text-color, 0.1);
- border-color: $gl-text-color;
- }
+ @include mini-pipeline-graph-color(rgba($gl-text-color, 0.1), $gl-text-color, $gl-text-color);
}
&.ci-status-icon-created,
&.ci-status-icon-skipped {
- border-color: $gray-darkest;
- color: $gray-darkest;
-
- &:hover,
- &:focus,
- &:active {
- background-color: rgba($gray-darkest, 0.1);
- border-color: $gray-darkest;
- }
+ @include mini-pipeline-graph-color(rgba($gray-darkest, 0.1), $gray-darkest, $gray-darkest);
}
}
@@ -781,16 +680,11 @@
}
.scrollable-menu {
+ padding: 0;
max-height: 245px;
overflow: auto;
}
- // Loading icon
- .builds-dropdown-loading {
- margin: 0 auto;
- width: 20px;
- }
-
// Action icon on the right
a.ci-action-icon-wrapper {
color: $action-icon-color;
@@ -837,7 +731,8 @@
border-radius: 3px;
// build name
- .ci-build-text {
+ .ci-build-text,
+ .ci-status-text {
font-weight: 200;
overflow: hidden;
white-space: nowrap;
@@ -849,6 +744,10 @@
top: 1px;
vertical-align: text-bottom;
position: relative;
+
+ @media (max-width: $screen-xs-max) {
+ max-width: 60%;
+ }
}
// status icon on the left
@@ -890,37 +789,89 @@
}
/**
+ * Top arrow in the dropdown in the big pipeline graph
+ */
+.big-pipeline-graph-dropdown-menu {
+
+ &::before,
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+ top: 18px;
+ }
+
+ &::before {
+ left: -5px;
+ margin-top: -6px;
+ border-width: 7px 5px 7px 0;
+ border-right-color: $border-color;
+ }
+
+ &::after {
+ left: -4px;
+ margin-top: -9px;
+ border-width: 10px 7px 10px 0;
+ border-right-color: $white-light;
+ }
+}
+
+/**
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
- .arrow-up {
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: -6px;
- left: 2px;
- border-width: 0 5px 6px;
- }
+ z-index: 200;
- &::before {
- border-width: 0 5px 5px;
- border-bottom-color: $border-color;
- }
+ &::before,
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+ top: -6px;
+ left: 50%;
+ transform: translate(-50%, 0);
+ border-width: 0 5px 6px;
- &::after {
- margin-top: 1px;
- border-bottom-color: $white-light;
+ @media (max-width: $screen-sm-max) {
+ left: 100%;
+ margin-left: -12px;
}
}
+
+ &::before {
+ border-width: 0 5px 5px;
+ border-bottom-color: $border-color;
+ }
+
+ &::after {
+ margin-top: 1px;
+ border-bottom-color: $white-light;
+ }
}
/**
+ * Center dropdown menu in mini graph
+ */
+.mini-pipeline-graph-dropdown-menu.dropdown-menu {
+ transform: translate(-80%, 0);
+ min-width: 150px;
+
+ @media(min-width: $screen-md-min) {
+ transform: translate(-50%, 0);
+ right: auto;
+ left: 50%;
+ min-width: 240px;
+ }
+}
+/**
* Terminal
*/
.terminal-icon {
@@ -961,3 +912,18 @@
width: 12px;
}
}
+
+.ci-header-container {
+ min-height: 55px;
+
+ .text-center {
+ padding-top: 12px;
+ }
+
+ .header-action-buttons {
+ .btn,
+ a {
+ margin-left: 10px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index fe084eb9397..14ad06b0ac2 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -286,7 +286,7 @@ table.u2f-registrations {
}
.user-callout {
- margin: 0 auto;
+ margin: 20px -5px 0;
.bordered-box {
border: 1px solid $blue-300;
@@ -295,14 +295,15 @@ table.u2f-registrations {
position: relative;
display: flex;
justify-content: center;
+ align-items: center;
}
.landing {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
+ padding: 32px;
.close {
position: absolute;
+ top: 20px;
right: 20px;
opacity: 1;
@@ -330,11 +331,20 @@ table.u2f-registrations {
height: 110px;
vertical-align: top;
}
+
+ &.convdev {
+ margin: 0 0 0 30px;
+
+ svg {
+ height: 127px;
+ }
+ }
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
+ max-width: 570px;
}
}
@@ -348,13 +358,59 @@ table.u2f-registrations {
.landing {
.svg-container,
.user-callout-copy {
- margin: 0;
+ margin: 0 auto;
display: block;
svg {
height: 75px;
}
+
+ &.convdev {
+ margin: $gl-padding auto 0;
+
+ svg {
+ height: 120px;
+ }
+ }
}
}
}
}
+
+.nav-wip {
+ border: 1px solid $blue-500;
+ background: $blue-25;
+ padding: $gl-padding;
+ margin-bottom: $gl-padding;
+
+ a {
+ color: $blue-500;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.gpg-email-badge {
+ display: inline;
+ margin-right: $gl-padding / 2;
+
+ .gpg-email-badge-email {
+ display: inline;
+ margin-right: $gl-padding / 4;
+ }
+
+ .label-verification-status {
+ border-width: 1px;
+ border-style: solid;
+
+ &.verified {
+ @include green-status-color;
+ }
+
+ &.unverified {
+ @include status-color($gray-dark, $gray, $common-gray-dark);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index c119f0c9b22..d01326637ea 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -7,7 +7,8 @@
}
.new_project,
-.edit-project {
+.edit-project,
+.import-project {
.sharing-and-permissions {
.header {
@@ -26,9 +27,22 @@
margin-bottom: 5px;
}
- & > .form-group {
+ > .form-group {
padding-left: 0;
}
+
+ select option[disabled] {
+ display: none;
+ }
+ }
+
+ select {
+ transition: background 2s ease-out;
+
+ &.highlight-changes {
+ background: $highlight-changes-color;
+ transition: none;
+ }
}
.help-block {
@@ -69,7 +83,7 @@
border: 1px solid $border-color;
}
- & + .select2 a {
+ + .select2 a {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -247,7 +261,6 @@
font-size: 13px;
font-weight: 600;
line-height: 13px;
- padding: $gl-vert-padding $gl-padding;
letter-spacing: .4px;
padding: 6px 14px;
text-align: center;
@@ -269,6 +282,8 @@
}
.project-repo-buttons {
+ @include new-style-dropdown;
+
.project-action-button .dropdown-menu {
max-height: 250px;
overflow-y: auto;
@@ -364,10 +379,11 @@ a.deploy-project-label {
}
.breadcrumb.repo-breadcrumb {
+ flex: 1;
padding: 0;
background: transparent;
border: none;
- line-height: 36px;
+ line-height: 34px;
margin: 0;
> li + li::before {
@@ -384,10 +400,6 @@ a.deploy-project-label {
}
}
-.last-push-widget {
- margin-top: -1px;
-}
-
.fork-namespaces {
.row {
-webkit-flex-wrap: wrap;
@@ -446,6 +458,7 @@ a.deploy-project-label {
}
}
+.project-template,
.project-import {
.form-group {
margin-bottom: 5px;
@@ -460,7 +473,44 @@ a.deploy-project-label {
.btn {
padding: 8px;
- margin-left: 10px;
+ margin-right: 10px;
+ }
+
+ .blank-option {
+ min-width: 70px;
+ }
+
+ .btn-template-icon {
+ height: 24px;
+ width: inherit;
+ display: block;
+ margin: 0 auto 4px;
+ font-size: 24px;
+
+ @media (min-width: $screen-xs-max) {
+ top: 0;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .btn-template-icon {
+ display: inline-block;
+ height: 14px;
+ font-size: 14px;
+ margin: 0;
+ }
+ }
+
+ .icon-rails path {
+ fill: $rails;
+ }
+
+ .icon-node-express path {
+ fill: $node;
+ }
+
+ .icon-java-spring path {
+ fill: $java;
}
> div {
@@ -470,14 +520,106 @@ a.deploy-project-label {
}
}
+.project-templates-buttons .btn:last-child {
+ margin-right: 0;
+}
+
+.create-project-options {
+ display: flex;
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+ }
+
+ .first-column {
+ @media(min-width: $screen-xs-min) {
+ max-width: 50%;
+ padding-right: 30px;
+ }
+
+ @media(max-width: $screen-xs-max) {
+ max-width: 100%;
+ width: 100%;
+ }
+ }
+
+ .second-column {
+ @media(min-width: $screen-xs-min) {
+ width: 50%;
+ flex: 1;
+ padding-left: 30px;
+ position: relative;
+ }
+
+ @media(max-width: $screen-xs-max) {
+ max-width: 100%;
+ width: 100%;
+ padding-left: 0;
+ position: relative;
+ }
+
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ padding-top: 30px;
+ }
+
+ &::before {
+ content: "OR";
+ position: absolute;
+ left: -10px;
+ top: 50%;
+ z-index: 10;
+ padding: 8px 0;
+ text-align: center;
+ background-color: $white-light;
+ color: $gl-text-color-tertiary;
+ transform: translateY(-50%);
+ font-size: 12px;
+ font-weight: bold;
+ line-height: 20px;
+
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ left: 50%;
+ top: 0;
+ transform: translateX(-50%);
+ padding: 0 8px;
+ }
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ background-color: $border-color;
+ bottom: 0;
+ left: 0;
+ right: auto;
+ height: 100%;
+ width: 1px;
+ top: 0;
+
+ // Mobile
+ @media (max-width: $screen-xs-max) {
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ height: 1px;
+ width: auto;
+ }
+ }
+ }
+}
+
+
.project-stats {
font-size: 0;
text-align: center;
+ max-width: 100%;
+ border-bottom: 1px solid $border-color;
.nav {
padding-top: 12px;
padding-bottom: 12px;
- border-bottom: 1px solid $border-color;
}
.nav > li {
@@ -576,9 +718,9 @@ pre.light-well {
}
.project-row {
+ @include basic-list-stats;
display: flex;
align-items: center;
- @include basic-list-stats;
}
h3 {
@@ -639,59 +781,6 @@ pre.light-well {
}
}
-.project-last-commit {
- background-color: $gray-light;
- border: 1px solid $border-color;
- border-radius: $border-radius-base;
- padding: 12px;
-
- @media (min-width: $screen-sm-min) {
- margin-top: $gl-padding;
- }
-
- .ci-status {
- margin-right: $gl-padding;
- }
-
- .commit-row-message {
- color: $gl-text-color;
- }
-
- .commit_short_id {
- margin-right: 5px;
- color: $gl-link-color;
- font-weight: 600;
- }
-
- .commit-author-link {
- .commit-author-name {
- font-weight: 600;
- }
- }
-}
-
-.project-show-readme {
- .row-content-block {
- background-color: inherit;
- border: none;
- }
-
- .readme-holder {
- padding: $gl-padding 0;
- border-top: 0;
-
- .edit-project-readme {
- z-index: 2;
- position: relative;
- }
-
- .wiki h1 {
- border-bottom: none;
- padding: 0;
- }
- }
-}
-
.git-clone-holder {
width: 380px;
@@ -733,20 +822,23 @@ pre.light-well {
}
}
-.new_protected_branch {
+.new_protected_branch,
+.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
-.create-new-protected-branch-button {
+.create-new-protected-branch-button,
+.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
background-color: transparent;
border: 0;
text-align: left;
+ text-overflow: ellipsis;
}
.protected-branches-list,
@@ -782,7 +874,8 @@ pre.light-well {
}
}
-.protected-tags-list {
+.protected-tags-list,
+.protected-branches-list {
.dropdown-menu-toggle {
width: 100%;
max-width: 300px;
@@ -811,8 +904,7 @@ pre.light-well {
}
.project-refs-form .dropdown-menu,
-.dropdown-menu-projects,
-.dropdown-menu-branches {
+.dropdown-menu-projects {
width: 300px;
@media (min-width: $screen-sm-min) {
@@ -825,7 +917,8 @@ pre.light-well {
}
.compare-form-group {
- .dropdown-menu {
+ .dropdown-menu,
+ .inline-input-group {
width: 100%;
@media (min-width: $screen-sm-min) {
@@ -844,14 +937,6 @@ pre.light-well {
width: auto;
}
}
-
- .inline-input-group {
- width: 100%;
-
- @media (min-width: $screen-sm-min) {
- width: 250px;
- }
- }
}
.clearable-input {
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
new file mode 100644
index 00000000000..1f4d4698199
--- /dev/null
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -0,0 +1,324 @@
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity $sidebar-transition-duration;
+}
+
+.monaco-loader {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: $black-transparent;
+}
+
+.modal.popup-dialog {
+ display: block;
+ background-color: $black-transparent;
+ z-index: 2100;
+
+ @media (min-width: $screen-md-min) {
+ .modal-dialog {
+ width: 600px;
+ margin: 30px auto;
+ }
+ }
+}
+
+.project-refs-form,
+.project-refs-target-form {
+ display: inline-block;
+}
+
+.fade-enter,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.commit-message {
+ @include str-truncated(250px);
+}
+
+.editable-mode {
+ display: inline-block;
+}
+
+.blob-viewer[data-type="rich"] {
+ margin: 20px;
+}
+
+.repository-view {
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+ color: $almost-black;
+
+ .tree-content-holder {
+ display: flex;
+ max-height: 100vh;
+ min-height: 300px;
+ }
+
+ .tree-content-holder-mini {
+ height: 100vh;
+ }
+
+ .panel-right {
+ display: flex;
+ flex-direction: column;
+ width: 80%;
+ height: 100%;
+
+ .monaco-editor.vs {
+ .line-numbers {
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .cursor {
+ display: none !important;
+ }
+ }
+
+ &.edit-mode {
+ .blob-viewer-container {
+ overflow: hidden;
+ }
+
+ .monaco-editor.vs {
+ .cursor {
+ background: $black;
+ border-color: $black;
+ display: block !important;
+ }
+ }
+ }
+
+ .blob-viewer-container {
+ flex: 1;
+ overflow: auto;
+ }
+
+ #tabs {
+ flex-shrink: 0;
+ display: flex;
+ width: 100%;
+ padding-left: 0;
+ margin-bottom: 0;
+ white-space: nowrap;
+ overflow-y: hidden;
+ overflow-x: auto;
+
+ li {
+ animation: swipeRightAppear ease-in 0.1s;
+ animation-iteration-count: 1;
+ transform-origin: 0% 50%;
+ list-style-type: none;
+ background: $gray-normal;
+ display: inline-block;
+ padding: 10px 18px;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ white-space: nowrap;
+ cursor: pointer;
+
+ &.remove {
+ animation: swipeRightDissapear ease-in 0.1s;
+ animation-iteration-count: 1;
+ transform-origin: 0% 50%;
+
+ a {
+ width: 0;
+ }
+ }
+
+ &.active {
+ background: $white-light;
+ border-bottom: none;
+ }
+
+ a {
+ @include str-truncated(100px);
+ color: $black;
+ width: 100px;
+ text-align: center;
+ vertical-align: middle;
+ text-decoration: none;
+
+ &.close {
+ width: auto;
+ font-size: 15px;
+ opacity: 1;
+ margin-right: -6px;
+ }
+ }
+
+ .close-icon,
+ .unsaved-icon {
+ float: right;
+ margin-top: 3px;
+ margin-left: 15px;
+ color: $gray-darkest;
+ }
+
+ .unsaved-icon {
+ color: $brand-success;
+ }
+
+ &.tabs-divider {
+ width: 100%;
+ background-color: $white-light;
+ border-right: none;
+ border-top-right-radius: 2px;
+ }
+ }
+ }
+
+ #repo-file-buttons {
+ background-color: $white-light;
+ border-bottom: 1px solid $white-normal;
+ padding: 5px 10px;
+ position: relative;
+ border-top: 1px solid $white-normal;
+ margin-top: -5px;
+ }
+
+ #binary-viewer {
+ height: 80vh;
+ overflow: auto;
+ margin: 0;
+
+ .blob-viewer {
+ padding-top: 20px;
+ padding-left: 20px;
+ }
+
+ .binary-unknown {
+ text-align: center;
+ padding-top: 100px;
+ background: $gray-light;
+ height: 100%;
+ font-size: 17px;
+
+ span {
+ display: block;
+ }
+ }
+ }
+ }
+
+ #commit-area {
+ background: $gray-light;
+ padding: 20px;
+
+ .help-block {
+ padding-top: 7px;
+ margin-top: 0;
+ }
+ }
+
+ #view-toggler {
+ height: 41px;
+ position: relative;
+ display: block;
+ border-bottom: 1px solid $white-normal;
+ background: $white-light;
+ margin-top: -5px;
+ }
+
+ #binary-viewer {
+ img {
+ max-width: 100%;
+ }
+ }
+
+ #sidebar {
+ flex: 1;
+ height: 100%;
+
+ &.sidebar-mini {
+ width: 20%;
+ border-right: 1px solid $white-normal;
+ overflow: auto;
+ }
+
+ table {
+ margin-bottom: 0;
+ }
+
+ tr {
+ animation: fadein 0.5s;
+ cursor: pointer;
+
+ &.repo-file-options td {
+ padding: 0;
+ border-top: none;
+ background: $gray-light;
+ width: 100%;
+ display: inline-block;
+
+ &:first-child {
+ border-top-left-radius: 2px;
+ }
+
+ .title {
+ display: inline-block;
+ font-size: 10px;
+ text-transform: uppercase;
+ font-weight: bold;
+ color: $gray-darkest;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
+ padding: 2px 16px;
+ }
+ }
+
+ .file-icon {
+ margin-right: 5px;
+ }
+
+ td {
+ white-space: nowrap;
+ }
+ }
+
+ a {
+ @include str-truncated(250px);
+ color: $almost-black;
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+}
+
+.render-error {
+ min-height: calc(100vh - 62px);
+
+ p {
+ width: 100%;
+ }
+}
+
+@keyframes swipeRightAppear {
+ 0% {
+ transform: scaleX(0.00);
+ }
+
+ 100% {
+ transform: scaleX(1.00);
+ }
+}
+
+@keyframes swipeRightDissapear {
+ 0% {
+ transform: scaleX(1.00);
+ }
+
+ 100% {
+ transform: scaleX(0.00);
+ }
+}
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
index 9b6ff237557..57c73295d1e 100644
--- a/app/assets/stylesheets/pages/runners.scss
+++ b/app/assets/stylesheets/pages/runners.scss
@@ -33,3 +33,20 @@
font-weight: normal;
}
}
+
+.admin-runner-btn-group-cell {
+ min-width: 150px;
+
+ .btn-sm {
+ padding: 4px 9px;
+ }
+
+ .btn-default {
+ color: $gl-text-color-secondary;
+ }
+
+ .fa-pause,
+ .fa-play {
+ font-size: 11px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 3889deee21a..15df51e9c69 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,3 +1,110 @@
+@keyframes expandMaxHeight {
+ 0% {
+ max-height: 0;
+ }
+
+ 99% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: none;
+ }
+}
+
+@keyframes collapseMaxHeight {
+ 0% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: 0;
+ }
+}
+
+.settings {
+ overflow: hidden;
+ border-bottom: 1px solid $gray-darker;
+
+ &:first-of-type {
+ margin-top: 10px;
+ }
+
+ &.expanded {
+ overflow: visible;
+ }
+}
+
+.settings-header {
+ position: relative;
+ padding: 20px 110px 10px 0;
+
+ h4 {
+ margin-top: 0;
+ }
+
+ button {
+ position: absolute;
+ top: 20px;
+ right: 6px;
+ min-width: 80px;
+ }
+}
+
+.settings-content {
+ max-height: 1px;
+ overflow-y: scroll;
+ padding-right: 110px;
+ animation: collapseMaxHeight 300ms ease-out;
+
+ &.expanded {
+ max-height: none;
+ overflow-y: visible;
+ animation: expandMaxHeight 300ms ease-in;
+ }
+
+ &.no-animate {
+ animation: none;
+ }
+
+ @media(max-width: $screen-sm-max) {
+ padding-right: 20px;
+ }
+
+ &::before {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ }
+
+ &::after {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-top: 20px;
+ }
+
+ .sub-section {
+ margin-bottom: 32px;
+ padding: 16px;
+ border: 1px solid $border-color;
+ background-color: $gray-light;
+ }
+
+ .bs-callout,
+ .checkbox:first-child,
+ .help-block {
+ margin-top: 0;
+ }
+
+ .label-light {
+ margin-bottom: 0;
+ }
+}
+
.settings-list-icon {
color: $gl-text-color-secondary;
font-size: $settings-icon-size;
@@ -35,3 +142,66 @@
margin-left: 5px;
}
}
+
+.prometheus-metrics-monitoring {
+ .panel {
+ .panel-toggle {
+ width: 14px;
+ }
+
+ .badge {
+ font-size: inherit;
+ }
+
+ .panel-heading .badge-count {
+ color: $white-light;
+ background: $common-gray-dark;
+ }
+
+ .panel-body {
+ padding: 0;
+ }
+
+ .flash-container {
+ margin-bottom: 0;
+ cursor: default;
+
+ .flash-notice {
+ border-radius: 0;
+ }
+ }
+ }
+
+ .loading-metrics,
+ .empty-metrics {
+ padding: 30px 10px;
+
+ p,
+ .btn {
+ margin-top: 10px;
+ margin-bottom: 0;
+ }
+ }
+
+ .loading-metrics .metrics-load-spinner {
+ color: $loading-color;
+ }
+
+ .metrics-list {
+ margin-bottom: 0;
+
+ li {
+ padding: $gl-padding;
+
+ .badge {
+ margin-left: 5px;
+ background: $badge-bg;
+ }
+ }
+
+ /* Ensure we don't add border if there's only single li */
+ li + li {
+ border-top: 1px solid $border-color;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 4a284247143..36f622db136 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -1,142 +1,63 @@
-.container-fluid {
- .ci-status {
- padding: 2px 7px 4px;
- margin-right: 10px;
- border: 1px solid $gray-darker;
- white-space: nowrap;
- border-radius: 4px;
-
- &:hover,
- &:focus {
- text-decoration: none;
- }
-
- svg {
- height: 13px;
- width: 13px;
- position: relative;
- top: 2px;
- overflow: visible;
- }
-
- &.ci-failed,
- &.ci-failed_with_warnings {
- color: $red-500;
- border-color: $red-500;
-
- &:not(span):hover {
- background-color: $red-50;
- color: $red-600;
- border-color: $red-600;
-
- svg {
- fill: $red-600;
- }
- }
-
- svg {
- fill: $red-500;
- }
- }
-
- &.ci-success,
- &.ci-success_with_warnings {
- color: $green-600;
- border-color: $green-500;
-
- &:not(span):hover {
- background-color: $green-50;
- color: $green-700;
- border-color: $green-600;
-
- svg {
- fill: $green-600;
- }
- }
-
- svg {
- fill: $green-500;
- }
- }
-
- &.ci-canceled,
- &.ci-disabled {
- color: $gl-text-color;
- border-color: $gl-text-color;
-
- &:not(span):hover {
- background-color: rgba($gl-text-color, .07);
- }
+.ci-status {
+ padding: 2px 7px 4px;
+ border: 1px solid $gray-darker;
+ white-space: nowrap;
+ border-radius: 4px;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
- svg {
- fill: $gl-text-color;
- }
- }
+ svg {
+ height: 13px;
+ width: 13px;
+ position: relative;
+ top: 2px;
+ overflow: visible;
+ }
- &.ci-pending {
- color: $orange-600;
- border-color: $orange-500;
+ &.ci-failed {
+ @include status-color($red-50, $red-500, $red-600);
+ }
- &:not(span):hover {
- background-color: $orange-50;
- color: $orange-700;
- border-color: $orange-600;
+ &.ci-success {
+ @include green-status-color;
+ }
- svg {
- fill: $orange-600;
- }
- }
+ &.ci-canceled,
+ &.ci-disabled,
+ &.ci-manual {
+ color: $gl-text-color;
+ border-color: $gl-text-color;
- svg {
- fill: $orange-500;
- }
+ &:not(span):hover {
+ background-color: rgba($gl-text-color, .07);
}
+ }
- &.ci-info,
- &.ci-running {
- color: $blue-500;
- border-color: $blue-500;
-
- &:not(span):hover {
- background-color: $blue-50;
- color: $blue-600;
- border-color: $blue-600;
-
- svg {
- fill: $blue-600;
- }
- }
-
- svg {
- fill: $blue-500;
- }
- }
+ &.ci-pending,
+ &.ci-failed_with_warnings,
+ &.ci-success_with_warnings {
+ @include status-color($orange-50, $orange-500, $orange-700);
+ }
- &.ci-created,
- &.ci-skipped {
- color: $gl-text-color-secondary;
- border-color: $gl-text-color-secondary;
+ &.ci-info,
+ &.ci-running {
+ @include status-color($blue-50, $blue-500, $blue-600);
+ }
- &:not(span):hover {
- background-color: rgba($gl-text-color-secondary, .07);
- }
+ &.ci-created,
+ &.ci-skipped {
+ color: $gl-text-color-secondary;
+ border-color: $gl-text-color-secondary;
- svg {
- fill: $gl-text-color-secondary;
- }
+ &:not(span):hover {
+ background-color: rgba($gl-text-color-secondary, .07);
}
- &.ci-manual {
- color: $gl-text-color;
- border-color: $gl-text-color;
-
- &:not(span):hover {
- background-color: rgba($gl-text-color, .07);
- }
-
- svg {
- fill: $gl-text-color;
- }
+ svg {
+ fill: $gl-text-color-secondary;
}
}
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index a39815319f3..d7a9dda3770 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -54,8 +54,9 @@
background-color: $white-light;
&:hover {
- border-color: $white-dark;
+ border-color: $white-normal;
background-color: $gray-light;
+ border-top: 1px solid transparent;
.todo-avatar,
.todo-item {
@@ -80,7 +81,7 @@
.todo-title {
display: flex;
- & > .title-item {
+ > .title-item {
-webkit-flex: 0 0 auto;
flex: 0 0 auto;
margin: 0 2px;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 03c75ce61f5..0028e207f3e 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -1,10 +1,88 @@
.tree-holder {
- > .nav-block {
- margin: 11px 0;
+ @include new-style-dropdown;
+
+ .nav-block {
+ margin: 10px 0;
+
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+
+ .tree-ref-container {
+ flex: 1;
+ }
+
+ .tree-controls {
+ text-align: right;
+
+ .btn-group {
+ margin-left: 10px;
+ }
+
+ .control {
+ float: left;
+ margin-left: 10px;
+ }
+ }
+
+ .tree-ref-holder {
+ float: left;
+ margin-right: 15px;
+ }
+
+ .tree-ref-target-holder {
+ display: inline-block;
+ }
+
+ .repo-breadcrumb {
+ li:last-of-type {
+ position: relative;
+ }
+ }
+
+ .add-to-tree-dropdown {
+ position: absolute;
+ left: 18px;
+ }
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .repo-breadcrumb {
+ margin-top: 10px;
+ position: relative;
+
+ .dropdown-menu {
+ min-width: 100%;
+ width: 100%;
+ left: inherit;
+ right: 0;
+ }
+ }
+
+ .add-to-tree-dropdown {
+ position: absolute;
+ left: 0;
+ right: 0;
+ }
+
+ .tree-controls {
+ margin-bottom: 10px;
+
+ .btn,
+ .dropdown,
+ .btn-group {
+ width: 100%;
+ }
+
+ .btn {
+ margin: 10px 0 0;
+ }
+ }
}
.file-finder {
- width: 50%;
+ max-width: 500px;
+ width: 100%;
.file-finder-input {
width: 95%;
@@ -13,7 +91,7 @@
}
.add-to-tree {
- vertical-align: top;
+ vertical-align: middle;
padding: 6px 10px;
}
@@ -131,21 +209,20 @@
}
}
-.tree-ref-holder {
- float: left;
- margin-right: 15px;
-}
-
.blob-commit-info {
list-style: none;
- background: $gray-light;
- padding: 16px 16px 16px 6px;
- border: 1px solid $border-color;
- border-bottom: none;
margin: 0;
+ padding: 0;
+}
+
+.blob-content-holder {
+ margin-top: $gl-padding;
}
.blob-upload-dropzone-previews {
+ display: flex;
+ justify-content: center;
+ align-items: center;
text-align: center;
border: 2px;
border-style: dashed;
@@ -158,16 +235,6 @@
color: $md-link-color;
}
-.tree-controls {
- float: right;
- position: relative;
- z-index: 2;
-
- .project-action-button {
- margin-left: $btn-side-margin;
- }
-}
-
.repo-charts {
.sub-header {
margin: 20px 0;
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
index 8c87bc3cafd..798e060a261 100644
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ b/app/assets/stylesheets/pages/ui_dev_kit.scss
@@ -5,13 +5,13 @@
}
.example {
+ padding: 15px;
+ border: 1px dashed $ui-dev-kit-example-border;
+ margin-bottom: 15px;
+
&::before {
content: "Example";
color: $ui-dev-kit-example-color;
}
-
- padding: 15px;
- border: 1px dashed $ui-dev-kit-example-border;
- margin-bottom: 15px;
}
}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 9bc47bbe173..fa6bdd297eb 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -42,9 +42,7 @@
}
.git-access-header {
- padding: 16px 40px 11px 0;
- line-height: 28px;
- font-size: 18px;
+ padding: $gl-padding 0 $gl-padding-top;
}
.git-clone-holder {
@@ -66,12 +64,12 @@
.git-clone-holder {
width: 480px;
+ padding-bottom: $gl-padding;
}
.nav-controls {
width: auto;
min-width: 50%;
- white-space: nowrap;
}
}
@@ -90,19 +88,29 @@
margin: $gl-padding 0;
h3 {
- font-size: 22px;
+ font-size: 19px;
font-weight: normal;
- margin-top: 1.4em;
+ margin: $gl-padding 0;
}
}
.right-sidebar.wiki-sidebar {
- padding: $gl-padding 0;
+ padding: 0;
&.right-sidebar-collapsed {
display: none;
}
+ .sidebar-container {
+ padding: $gl-padding 0;
+ width: calc(100% + 100px);
+ padding-right: 100px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
.blocks-container {
padding: 0 $gl-padding;
}
@@ -149,13 +157,19 @@
}
ul.wiki-pages-list.content-list {
- & ul {
+ ul {
list-style: none;
margin-left: 0;
padding-left: 15px;
}
- & ul li {
+ ul li {
padding: 5px 0;
}
}
+
+.wiki {
+ table {
+ @include markdown-table;
+ }
+}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
new file mode 100644
index 00000000000..6e539e39ca1
--- /dev/null
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -0,0 +1,111 @@
+@import "framework/variables";
+@import "peek/views/performance_bar";
+@import "peek/views/rblineprof";
+
+#peek {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100%;
+ z-index: 2000;
+ overflow-x: hidden;
+
+ height: $performance-bar-height;
+ background: $black;
+ line-height: $performance-bar-height;
+ color: $perf-bar-text;
+
+ &.disabled {
+ display: none;
+ }
+
+ &.production {
+ background-color: $perf-bar-production;
+ }
+
+ &.staging {
+ background-color: $perf-bar-staging;
+ }
+
+ &.development {
+ background-color: $perf-bar-development;
+ }
+
+ .wrapper {
+ width: 80%;
+ height: $performance-bar-height;
+ margin: 0 auto;
+ }
+
+ // UI Elements
+ .bucket {
+ background: $perf-bar-bucket-bg;
+ display: inline-block;
+ padding: 4px 6px;
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+ line-height: 1;
+ color: $perf-bar-bucket-color;
+ border-radius: 3px;
+ box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
+
+ .hidden {
+ display: none;
+ }
+
+ &:hover .hidden {
+ display: inline;
+ }
+ }
+
+ strong {
+ color: $white-light;
+ }
+
+ table {
+ color: $black;
+
+ strong {
+ color: $black;
+ }
+ }
+
+ .view {
+ margin-right: 15px;
+ float: left;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .css-truncate {
+ &.css-truncate-target,
+ .css-truncate-target {
+ display: inline-block;
+ max-width: 125px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ vertical-align: top;
+ }
+
+ &.expandable:hover .css-truncate-target,
+ &.expandable:hover.css-truncate-target {
+ max-width: 10000px !important;
+ }
+ }
+}
+
+#modal-peek-pg-queries-content {
+ color: $black;
+}
+
+.peek-rblineprof-file {
+ pre.duration {
+ width: 280px;
+ }
+
+ .data {
+ overflow: visible;
+ }
+}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 6cc1cc8e263..113e6e86bb5 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -28,9 +28,6 @@ nav.navbar-collapse.collapse,
.profiler-results,
.tree-ref-holder,
.tree-holder .breadcrumb,
-.blob-commit-info,
-.file-title,
-.file-holder,
.nav,
.btn,
ul.notes-form,
@@ -40,7 +37,12 @@ ul.notes-form,
.issuable-details .content-block-small,
.edit-link,
.note-action-button {
- display: none!important;
+ display: none !important;
+}
+
+pre {
+ page-break-before: avoid;
+ page-break-inside: auto;
}
.page-gutter {
diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/test.scss
new file mode 100644
index 00000000000..7d9f3da79c5
--- /dev/null
+++ b/app/assets/stylesheets/test.scss
@@ -0,0 +1,17 @@
+* {
+ -o-transition: none !important;
+ -moz-transition: none !important;
+ -ms-transition: none !important;
+ -webkit-transition: none !important;
+ transition: none !important;
+ -o-transform: none !important;
+ -moz-transform: none !important;
+ -ms-transform: none !important;
+ -webkit-transform: none !important;
+ transform: none !important;
+ -webkit-animation: none !important;
+ -moz-animation: none !important;
+ -o-animation: none !important;
+ -ms-animation: none !important;
+ animation: none !important;
+}
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 2eac0cabf7a..ed13ead63f9 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -1,7 +1,9 @@
class AbuseReportsController < ApplicationController
+ before_action :set_user, only: [:new]
+
def new
@abuse_report = AbuseReport.new
- @abuse_report.user_id = params[:user_id]
+ @abuse_report.user_id = @user.id
@ref_url = params.fetch(:ref_url, '')
end
@@ -27,4 +29,14 @@ class AbuseReportsController < ApplicationController
user_id
))
end
+
+ def set_user
+ @user = User.find_by(id: params[:user_id])
+
+ if @user.nil?
+ redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted."
+ elsif @user.blocked?
+ redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked."
+ end
+ end
end
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 4b0ec54b3f4..92df1c8dff0 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -45,7 +45,7 @@ class Admin::AppearancesController < Admin::ApplicationController
# Use callbacks to share common setup or constraints between actions.
def set_appearance
- @appearance = Appearance.last || Appearance.new
+ @appearance = Appearance.current || Appearance.new
end
# Only allow a trusted parameter "white list" through.
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 643993d035e..8367c22d1ca 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -71,84 +71,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params[:application_setting][:disabled_oauth_sign_in_sources] =
AuthHelper.button_based_providers.map(&:to_s) -
Array(enabled_oauth_sign_in_sources)
+
+ params[:application_setting][:restricted_visibility_levels]&.delete("")
params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
params.require(:application_setting).permit(
- application_setting_params_ce
+ visible_application_setting_attributes
)
end
- def application_setting_params_ce
- [
- :admin_notification_email,
- :after_sign_out_path,
- :after_sign_up_text,
- :akismet_api_key,
- :akismet_enabled,
- :container_registry_token_expire_delay,
- :default_artifacts_expire_in,
- :default_branch_protection,
- :default_group_visibility,
- :default_project_visibility,
- :default_projects_limit,
- :default_snippet_visibility,
- :domain_blacklist_enabled,
+ def visible_application_setting_attributes
+ ApplicationSettingsHelper.visible_attributes + [
:domain_blacklist_file,
- :domain_blacklist_raw,
- :domain_whitelist_raw,
- :email_author_in_body,
- :enabled_git_access_protocol,
- :gravatar_enabled,
- :help_page_text,
- :home_page_url,
- :housekeeping_bitmaps_enabled,
- :housekeeping_enabled,
- :housekeeping_full_repack_period,
- :housekeeping_gc_period,
- :housekeeping_incremental_repack_period,
- :html_emails_enabled,
- :koding_enabled,
- :koding_url,
- :plantuml_enabled,
- :plantuml_url,
- :max_artifacts_size,
- :max_attachment_size,
- :max_pages_size,
- :metrics_enabled,
- :metrics_host,
- :metrics_method_call_threshold,
- :metrics_packet_size,
- :metrics_pool_size,
- :metrics_port,
- :metrics_sample_interval,
- :metrics_timeout,
- :recaptcha_enabled,
- :recaptcha_private_key,
- :recaptcha_site_key,
- :repository_checks_enabled,
- :require_two_factor_authentication,
- :session_expire_delay,
- :sign_in_text,
- :signin_enabled,
- :signup_enabled,
- :sentry_dsn,
- :sentry_enabled,
- :send_user_confirmation_email,
- :shared_runners_enabled,
- :shared_runners_text,
- :sidekiq_throttling_enabled,
- :sidekiq_throttling_factor,
- :two_factor_grace_period,
- :user_default_external,
- :user_oauth_applications,
- :unique_ips_limit_per_user,
- :unique_ips_limit_time_window,
- :unique_ips_limit_enabled,
- :version_check_enabled,
- :terminal_max_session_time,
- :polling_interval_multiplier,
- :usage_ping_enabled,
-
disabled_oauth_sign_in_sources: [],
import_sources: [],
repository_storages: [],
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 9c9f420c1e0..16590e66d61 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -39,7 +39,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def destroy
@application.destroy
- redirect_to admin_applications_url, notice: 'Application was successfully destroyed.'
+ redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.'
end
private
@@ -50,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through.
def application_params
- params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes)
+ params.require(:doorkeeper_application).permit(:name, :redirect_uri, :trusted, :scopes)
end
end
diff --git a/app/controllers/admin/builds_controller.rb b/app/controllers/admin/builds_controller.rb
deleted file mode 100644
index 88f3c0e2fd4..00000000000
--- a/app/controllers/admin/builds_controller.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-class Admin::BuildsController < Admin::ApplicationController
- def index
- @scope = params[:scope]
- @all_builds = Ci::Build
- @builds = @all_builds.order('created_at DESC')
- @builds =
- case @scope
- when 'pending'
- @builds.pending.reverse_order
- when 'running'
- @builds.running.reverse_order
- when 'finished'
- @builds.finished
- else
- @builds
- end
- @builds = @builds.page(params[:page]).per(30)
- end
-
- def cancel_all
- Ci::Build.running_or_pending.each(&:cancel)
-
- redirect_to admin_builds_path
- end
-end
diff --git a/app/controllers/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb
new file mode 100644
index 00000000000..921169d3e2b
--- /dev/null
+++ b/app/controllers/admin/conversational_development_index_controller.rb
@@ -0,0 +1,5 @@
+class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController
+ def show
+ @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present
+ end
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 8360ce08bdc..05e749c00c0 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -1,6 +1,6 @@
class Admin::DashboardController < Admin::ApplicationController
def index
- @projects = Project.with_route.limit(10)
+ @projects = Project.without_deleted.with_route.limit(10)
@users = User.limit(10)
@groups = Group.with_route.limit(10)
end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 4f6a7e9e2cb..e5cba774dcb 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -1,6 +1,6 @@
class Admin::DeployKeysController < Admin::ApplicationController
before_action :deploy_keys, only: [:index]
- before_action :deploy_key, only: [:destroy]
+ before_action :deploy_key, only: [:destroy, :edit, :update]
def index
end
@@ -10,12 +10,24 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create
- @deploy_key = deploy_keys.new(deploy_key_params.merge(user: current_user))
+ @deploy_key = deploy_keys.new(create_params.merge(user: current_user))
if @deploy_key.save
redirect_to admin_deploy_keys_path
else
- render "new"
+ render 'new'
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to admin_deploy_keys_path
+ else
+ render 'edit'
end
end
@@ -23,7 +35,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
deploy_key.destroy
respond_to do |format|
- format.html { redirect_to admin_deploy_keys_path }
+ format.html { redirect_to admin_deploy_keys_path, status: 302 }
format.json { head :ok }
end
end
@@ -38,7 +50,11 @@ class Admin::DeployKeysController < Admin::ApplicationController
@deploy_keys ||= DeployKey.are_public
end
- def deploy_key_params
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 5885b3543bb..2ce26de1768 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -43,19 +43,22 @@ class Admin::GroupsController < Admin::ApplicationController
end
def members_update
- status = Members::CreateService.new(@group, current_user, params).execute
+ member_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute
- if status
+ if result[:status] == :success
redirect_to [:admin, @group], notice: 'Users were successfully added.'
else
- redirect_to [:admin, @group], alert: 'No users specified.'
+ redirect_to [:admin, @group], alert: result[:message]
end
end
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to admin_groups_path,
+ status: 302,
+ alert: "Group '#{@group.name}' was scheduled for deletion."
end
private
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index caf4c138da8..65a17828feb 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -1,5 +1,12 @@
class Admin::HealthCheckController < Admin::ApplicationController
def show
@errors = HealthCheck::Utils.process_checks(['standard'])
+ @failing_storage_statuses = Gitlab::Git::Storage::Health.for_failing_storages
+ end
+
+ def reset_storage_health
+ Gitlab::Git::Storage::CircuitBreaker.reset_all!
+ redirect_to admin_health_check_path,
+ notice: _('Git storage health information has been reset')
end
end
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
new file mode 100644
index 00000000000..3017f96c26f
--- /dev/null
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -0,0 +1,29 @@
+class Admin::HookLogsController < Admin::ApplicationController
+ include HooksExecution
+
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ def show
+ end
+
+ def retry
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
+
+ set_hook_execution_notice(result)
+
+ redirect_to edit_admin_hook_path(@hook)
+ end
+
+ private
+
+ def hook
+ @hook ||= SystemHook.find(params[:hook_id])
+ end
+
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index cbfc4581411..77e3c95d197 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -1,4 +1,8 @@
class Admin::HooksController < Admin::ApplicationController
+ include HooksExecution
+
+ before_action :hook_logs, only: :edit
+
def index
@hooks = SystemHook.all
@hook = SystemHook.new
@@ -15,33 +19,49 @@ class Admin::HooksController < Admin::ApplicationController
end
end
+ def edit
+ end
+
+ def update
+ if hook.update_attributes(hook_params)
+ flash[:notice] = 'System hook was successfully updated.'
+ redirect_to admin_hooks_path
+ else
+ render 'edit'
+ end
+ end
+
def destroy
- @hook = SystemHook.find(params[:id])
- @hook.destroy
+ hook.destroy
- redirect_to admin_hooks_path
+ redirect_to admin_hooks_path, status: 302
end
def test
- @hook = SystemHook.find(params[:hook_id])
- data = {
- event_name: "project_create",
- name: "Ruby",
- path: "ruby",
- project_id: 1,
- owner_name: "Someone",
- owner_email: "example@gitlabhq.com"
- }
- @hook.execute(data, 'system_hooks')
+ result = TestHooks::SystemService.new(hook, current_user, params[:trigger]).execute
+
+ set_hook_execution_notice(result)
redirect_back_or_default
end
+ private
+
+ def hook
+ @hook ||= SystemHook.find(params[:id])
+ end
+
+ def hook_logs
+ @hook_logs ||=
+ Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
+ end
+
def hook_params
params.require(:hook).permit(
:enable_ssl_verification,
:push_events,
:tag_push_events,
+ :repository_update_events,
:token,
:url
)
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 79a53556f0a..43b4e3a2cc3 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -36,9 +36,9 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully removed.'
+ redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.'
else
- redirect_to admin_user_identities_path(@user), alert: 'Failed to remove user identity.'
+ redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.'
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 8e7adc06584..39dbf85f6c0 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -11,7 +11,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
session[:impersonator_id] = nil
- redirect_to admin_user_path(original_user)
+ redirect_to admin_user_path(original_user), status: 302
end
private
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
new file mode 100644
index 00000000000..5162273ef8a
--- /dev/null
+++ b/app/controllers/admin/jobs_controller.rb
@@ -0,0 +1,25 @@
+class Admin::JobsController < Admin::ApplicationController
+ def index
+ @scope = params[:scope]
+ @all_builds = Ci::Build
+ @builds = @all_builds.order('created_at DESC')
+ @builds =
+ case @scope
+ when 'pending'
+ @builds.pending.reverse_order
+ when 'running'
+ @builds.running.reverse_order
+ when 'finished'
+ @builds.finished
+ else
+ @builds
+ end
+ @builds = @builds.page(params[:page]).per(30)
+ end
+
+ def cancel_all
+ Ci::Build.running_or_pending.each(&:cancel)
+
+ redirect_to admin_jobs_path
+ end
+end
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 054bb52b696..0b76193a90e 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format|
if key.destroy
- format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' }
else
- format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' }
end
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index 4531657268c..cbc7a14ae83 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -41,7 +41,7 @@ class Admin::LabelsController < Admin::ApplicationController
respond_to do |format|
format.html do
- redirect_to(admin_labels_path, notice: 'Label was removed')
+ redirect_to admin_labels_path, status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index a1975c0e341..0b6cd71e651 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -3,18 +3,9 @@ class Admin::ProjectsController < Admin::ApplicationController
before_action :group, only: [:show, :transfer]
def index
- params[:sort] ||= 'latest_activity_desc'
- @projects = Project.with_statistics
- @projects = @projects.in_namespace(params[:namespace_id]) if params[:namespace_id].present?
- @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
- @projects = @projects.with_push if params[:with_push].present?
- @projects = @projects.abandoned if params[:abandoned].present?
- @projects = @projects.where(last_repository_check_failed: true) if params[:last_repository_check_failed].present?
- @projects = @projects.non_archived unless params[:archived].present?
- @projects = @projects.personal(current_user) if params[:personal].present?
- @projects = @projects.search(params[:name]) if params[:name].present?
- @projects = @projects.sort(@sort = params[:sort])
- @projects = @projects.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page])
+ finder = Admin::ProjectsFinder.new(params: params, current_user: current_user)
+ @projects = finder.execute
+ @sort = finder.sort
respond_to do |format|
format.html
@@ -40,14 +31,14 @@ class Admin::ProjectsController < Admin::ApplicationController
::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace)
@project.reload
- redirect_to admin_namespace_project_path(@project.namespace, @project)
+ redirect_to admin_project_path(@project)
end
def repository_check
RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id)
redirect_to(
- admin_namespace_project_path(@project.namespace, @project),
+ admin_project_path(@project),
notice: 'Repository check was triggered.'
)
end
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 70ac6a75434..7ed2de71028 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -18,7 +18,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
runner = rp.runner
rp.destroy
- redirect_to admin_runner_path(runner)
+ redirect_to admin_runner_path(runner), status: 302
end
private
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 348641e5ecb..719893c0bc8 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -27,7 +27,7 @@ class Admin::RunnersController < Admin::ApplicationController
def destroy
@runner.destroy
- redirect_to admin_runners_path
+ redirect_to admin_runners_path, status: 302
end
def resume
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 37a1a23178e..4c3d336b3af 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -16,6 +16,8 @@ class Admin::ServicesController < Admin::ApplicationController
def update
if service.update_attributes(service_params[:service])
+ PropagateServiceTemplateWorker.perform_async(service.id) if service.active?
+
redirect_to admin_application_settings_services_path,
notice: 'Application settings saved successfully'
else
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 1d66955bb71..d52d67a67a5 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -8,7 +8,9 @@ class Admin::SpamLogsController < Admin::ApplicationController
if params[:remove_user]
spam_log.remove_user(deleted_by: current_user)
- redirect_to admin_spam_logs_path, notice: "User #{spam_log.user.username} was successfully removed."
+ redirect_to admin_spam_logs_path,
+ status: 302,
+ notice: "User #{spam_log.user.username} was successfully removed."
else
spam_log.destroy
head :ok
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 563bcc65bd6..fa1bc72560e 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -54,7 +54,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def block
- if user.block
+ if update_user { |user| user.block }
redirect_back_or_admin_user(notice: "Successfully blocked")
else
redirect_back_or_admin_user(alert: "Error occurred. User was not blocked")
@@ -64,7 +64,7 @@ class Admin::UsersController < Admin::ApplicationController
def unblock
if user.ldap_blocked?
redirect_back_or_admin_user(alert: "This user cannot be unlocked manually from GitLab")
- elsif user.activate
+ elsif update_user { |user| user.activate }
redirect_back_or_admin_user(notice: "Successfully unblocked")
else
redirect_back_or_admin_user(alert: "Error occurred. User was not unblocked")
@@ -72,7 +72,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def unlock
- if user.unlock_access!
+ if update_user { |user| user.unlock_access! }
redirect_back_or_admin_user(alert: "Successfully unlocked")
else
redirect_back_or_admin_user(alert: "Error occurred. User was not unlocked")
@@ -80,7 +80,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def confirm
- if user.confirm
+ if update_user { |user| user.confirm }
redirect_back_or_admin_user(notice: "Successfully confirmed")
else
redirect_back_or_admin_user(alert: "Error occurred. User was not confirmed")
@@ -88,7 +88,8 @@ class Admin::UsersController < Admin::ApplicationController
end
def disable_two_factor
- user.disable_two_factor!
+ update_user { |user| user.disable_two_factor! }
+
redirect_to admin_user_path(user),
notice: 'Two-factor Authentication has been disabled for this user'
end
@@ -124,37 +125,43 @@ class Admin::UsersController < Admin::ApplicationController
end
respond_to do |format|
- user.skip_reconfirmation!
- if user.update_attributes(user_params_with_pass)
+ result = Users::UpdateService.new(user, user_params_with_pass).execute do |user|
+ user.skip_reconfirmation!
+ end
+
+ if result[:status] == :success
format.html { redirect_to [:admin, user], notice: 'User was successfully updated.' }
format.json { head :ok }
else
# restore username to keep form action url.
user.username = params[:id]
format.html { render "edit" }
- format.json { render json: user.errors, status: :unprocessable_entity }
+ format.json { render json: [result[:message]], status: result[:status] }
end
end
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
- format.html { redirect_to admin_users_path, notice: "The user is being deleted." }
+ format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." }
format.json { head :ok }
end
end
def remove_email
email = user.emails.find(params[:email_id])
- email.destroy
-
- user.update_secondary_emails!
+ success = Emails::DestroyService.new(user, email: email.email).execute
respond_to do |format|
- format.html { redirect_back_or_admin_user(notice: "Successfully removed email.") }
- format.js { head :ok }
+ if success
+ format.html { redirect_back_or_admin_user(notice: 'Successfully removed email.') }
+ format.json { head :ok }
+ else
+ format.html { redirect_back_or_admin_user(alert: 'There was an error removing the e-mail.') }
+ format.json { render json: 'There was an error removing the e-mail.', status: 400 }
+ end
end
end
@@ -202,4 +209,10 @@ class Admin::UsersController < Admin::ApplicationController
:website_url
]
end
+
+ def update_user(&block)
+ result = Users::UpdateService.new(user).execute(&block)
+
+ result[:status] == :success
+ end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e77094fe2a8..1d92ea11bda 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -9,18 +9,22 @@ class ApplicationController < ActionController::Base
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
+ include WithPerformanceBar
before_action :authenticate_user_from_private_token!
+ before_action :authenticate_user_from_rss_token!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
- before_action :add_gon_variables
+ before_action :add_gon_variables, unless: -> { request.path.start_with?('/-/peek') }
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
+ around_action :set_locale
+
protect_from_forgery with: :exception
helper_method :can?, :current_application_settings
@@ -36,6 +40,10 @@ class ApplicationController < ActionController::Base
render_404
end
+ rescue_from(ActionController::UnknownFormat) do
+ render_404
+ end
+
rescue_from Gitlab::Access::AccessDeniedError do |exception|
render_403
end
@@ -44,6 +52,15 @@ class ApplicationController < ActionController::Base
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
+ rescue_from Gitlab::Git::Storage::Inaccessible, GRPC::Unavailable, Gitlab::Git::CommandError do |exception|
+ Raven.capture_exception(exception) if sentry_enabled?
+ log_exception(exception)
+
+ headers['Retry-After'] = exception.retry_after if exception.respond_to?(:retry_after)
+
+ render_503
+ end
+
def redirect_back_or_default(default: root_path, options: {})
redirect_to request.referer.present? ? :back : default, options
end
@@ -56,12 +73,22 @@ class ApplicationController < ActionController::Base
if current_user
not_found
else
- redirect_to new_user_session_path
+ authenticate_user!
end
end
protected
+ def append_info_to_payload(payload)
+ super
+ payload[:remote_ip] = request.remote_ip
+
+ if current_user.present?
+ payload[:user_id] = current_user.id
+ payload[:username] = current_user.username
+ end
+ end
+
# This filter handles both private tokens and personal access tokens
def authenticate_user_from_private_token!
token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence
@@ -70,18 +97,27 @@ class ApplicationController < ActionController::Base
user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token)
- if user && can?(user, :log_in)
- # Notice we are passing store false, so the user is not
- # actually stored in the session and a token is needed
- # for every request. If you want the token to work as a
- # sign in token, you can simply remove store: false.
- sign_in user, store: false
- end
+ sessionless_sign_in(user)
+ end
+
+ # This filter handles authentication for atom request with an rss_token
+ def authenticate_user_from_rss_token!
+ return unless request.format.atom?
+
+ token = params[:rss_token].presence
+
+ return unless token.present?
+
+ user = User.find_by_rss_token(token)
+
+ sessionless_sign_in(user)
end
def log_exception(exception)
+ Raven.capture_exception(exception) if sentry_enabled?
+
application_trace = ActionDispatch::ExceptionWrapper.new(env, exception).application_trace
- application_trace.map!{ |t| " #{t}\n" }
+ application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
end
@@ -98,7 +134,10 @@ class ApplicationController < ActionController::Base
end
def access_denied!
- render "errors/access_denied", layout: "errors", status: 404
+ respond_to do |format|
+ format.json { head :not_found }
+ format.any { render "errors/access_denied", layout: "errors", status: 404 }
+ end
end
def git_not_found!
@@ -118,6 +157,23 @@ class ApplicationController < ActionController::Base
end
end
+ def respond_422
+ head :unprocessable_entity
+ end
+
+ def render_503
+ respond_to do |format|
+ format.html do
+ render(
+ file: Rails.root.join("public", "503"),
+ layout: false,
+ status: :service_unavailable
+ )
+ end
+ format.any { head :service_unavailable }
+ end
+ end
+
def no_cache_headers
response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
response.headers["Pragma"] = "no-cache"
@@ -146,7 +202,7 @@ class ApplicationController < ActionController::Base
end
def check_password_expiration
- if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user?
+ if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication?
return redirect_to new_profile_password_path
end
end
@@ -265,4 +321,18 @@ class ApplicationController < ActionController::Base
def u2f_app_id
request.base_url
end
+
+ def set_locale(&block)
+ Gitlab::I18n.with_user_locale(current_user, &block)
+ end
+
+ def sessionless_sign_in(user)
+ if user && can?(user, :log_in)
+ # Notice we are passing store false, so the user is not
+ # actually stored in the session and a token is needed
+ # for every request. If you want the token to work as a
+ # sign in token, you can simply remove store: false.
+ sign_in user, store: false
+ end
+ end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index b79ca034c5b..3120916c5bb 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -5,11 +5,11 @@ class AutocompleteController < ApplicationController
def users
@users ||= User.none
- @users = @users.search(params[:search]) if params[:search].present?
- @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present?
@users = @users.active
@users = @users.reorder(:name)
- @users = @users.page(params[:page])
+ @users = @users.search(params[:search]) if params[:search].present?
+ @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present?
+ @users = @users.page(params[:page]).per(params[:per_page])
if params[:todo_filter].present? && current_user
@users = @users.todo_authors(current_user.id, params[:todo_state_filter])
@@ -21,7 +21,7 @@ class AutocompleteController < ApplicationController
@users = [current_user, *@users].uniq
end
- if params[:author_id].present?
+ if params[:author_id].present? && current_user
author = User.find_by_id(params[:author_id])
@users = [author, *@users].uniq if author
end
@@ -41,7 +41,7 @@ class AutocompleteController < ApplicationController
no_project = {
id: 0,
- name_with_namespace: 'No project',
+ name_with_namespace: 'No project'
}
projects.unshift(no_project) unless params[:offset_id].present?
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index ea441b1736b..b75e401a8df 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -69,7 +69,7 @@ module AuthenticatesWithTwoFactor
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
# Remove any lingering user data from login
session.delete(:otp_user_id)
- session.delete(:challenges)
+ session.delete(:challenge)
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user)
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index 183eb00ef67..782f0be9c4a 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -1,11 +1,6 @@
module CreatesCommit
extend ActiveSupport::Concern
- def set_start_branch_to_branch_name
- branch_exists = @repository.find_branch(@branch_name)
- @start_branch = @branch_name if branch_exists
- end
-
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
if can?(current_user, :push_code, @project)
@project_to_commit_into = @project
@@ -83,8 +78,7 @@ module CreatesCommit
end
def new_merge_request_path
- new_namespace_project_merge_request_path(
- @project_to_commit_into.namespace,
+ project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
source_project_id: @project_to_commit_into.id,
@@ -96,14 +90,14 @@ module CreatesCommit
end
def existing_merge_request_path
- namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ project_merge_request_path(@project, @merge_request)
end
def merge_request_exists?
return @merge_request if defined?(@merge_request)
- @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.
- find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch)
+ @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
+ .find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch)
end
def different_project?
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 52e06f4945a..1ab107168c0 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -6,6 +6,13 @@ module CycleAnalyticsParams
end
def start_date(params)
- params[:start_date] == '30' ? 30.days.ago : 90.days.ago
+ case params[:start_date]
+ when '7'
+ 7.days.ago
+ when '30'
+ 30.days.ago
+ else
+ 90.days.ago
+ end
end
end
diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb
index 1efa9fe060f..d5388c4cd20 100644
--- a/app/controllers/concerns/diff_for_path.rb
+++ b/app/controllers/concerns/diff_for_path.rb
@@ -8,17 +8,6 @@ module DiffForPath
return render_404 unless diff_file
- diff_commit = commit_for_diff(diff_file)
- blob = diff_file.blob(diff_commit)
-
- locals = {
- diff_file: diff_file,
- diff_commit: diff_commit,
- diff_refs: diffs.diff_refs,
- blob: blob,
- project: project
- }
-
- render json: { html: view_to_html_string('projects/diffs/_content', locals) }
+ render json: { html: view_to_html_string('projects/diffs/_content', diff_file: diff_file) }
end
end
diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb
new file mode 100644
index 00000000000..a22e46b4860
--- /dev/null
+++ b/app/controllers/concerns/hooks_execution.rb
@@ -0,0 +1,18 @@
+module HooksExecution
+ extend ActiveSupport::Concern
+
+ private
+
+ def set_hook_execution_notice(result)
+ http_status = result[:http_status]
+ message = result[:message]
+
+ if http_status && http_status >= 200 && http_status < 400
+ flash[:notice] = "Hook executed successfully: HTTP #{http_status}"
+ elsif http_status
+ flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}"
+ else
+ flash[:alert] = "Hook execution failed: #{message}"
+ end
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 3ccf2a9ce33..4079072a930 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -10,11 +10,20 @@ module IssuableActions
def destroy
issuable.destroy
destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
- TodoService.new.public_send(destroy_method, issuable, current_user)
+ TodoService.new.public_send(destroy_method, issuable, current_user) # rubocop:disable GitlabSecurity/PublicSend
name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
- redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
+ index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
+
+ respond_to do |format|
+ format.html { redirect_to index_path }
+ format.json do
+ render json: {
+ web_url: index_path
+ }
+ end
+ end
end
def bulk_update
@@ -60,7 +69,7 @@ module IssuableActions
end
def bulk_update_params
- params.require(:update).permit(
+ permitted_keys = [
:issuable_ids,
:assignee_id,
:milestone_id,
@@ -69,7 +78,15 @@ module IssuableActions
label_ids: [],
add_label_ids: [],
remove_label_ids: []
- )
+ ]
+
+ if resource_name == 'issue'
+ permitted_keys << { assignee_ids: [] }
+ else
+ permitted_keys.unshift(:assignee_id)
+ end
+
+ params.require(:update).permit(permitted_keys)
end
def resource_name
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index c8a501d7319..b43b2c5621f 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -1,6 +1,7 @@
module IssuableCollections
extend ActiveSupport::Concern
include SortingHelper
+ include Gitlab::IssuableMetadata
included do
helper_method :issues_finder
@@ -9,45 +10,12 @@ module IssuableCollections
private
- def issuable_meta_data(issuable_collection, collection_type)
- # map has to be used here since using pluck or select will
- # throw an error when ordering issuables by priority which inserts
- # a new order into the collection.
- # We cannot use reorder to not mess up the paginated collection.
- issuable_ids = issuable_collection.map(&:id)
-
- return {} if issuable_ids.empty?
-
- issuable_note_count = Note.count_for_collection(issuable_ids, @collection_type)
- issuable_votes_count = AwardEmoji.votes_for_collection(issuable_ids, @collection_type)
- issuable_merge_requests_count =
- if collection_type == 'Issue'
- MergeRequestsClosingIssues.count_for_collection(issuable_ids)
- else
- []
- end
-
- issuable_ids.each_with_object({}) do |id, issuable_meta|
- downvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? }
- upvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? }
- notes = issuable_note_count.find { |notes| notes.noteable_id == id }
- merge_requests = issuable_merge_requests_count.find { |mr| mr.first == id }
-
- issuable_meta[id] = Issuable::IssuableMeta.new(
- upvotes.try(:count).to_i,
- downvotes.try(:count).to_i,
- notes.try(:count).to_i,
- merge_requests.try(:last).to_i
- )
- end
- end
-
def issues_collection
- issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
+ issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
end
def merge_requests_collection
- merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace)
+ merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :head_pipeline, target_project: :namespace, merge_request_diff: :merge_request_diff_commits)
end
def issues_finder
@@ -64,10 +32,10 @@ module IssuableCollections
def filter_params
set_sort_order_from_cookie
- set_default_scope
set_default_state
- @filter_params = params.dup
+ # Skip irrelevant Rails routing params
+ @filter_params = params.dup.except(:controller, :action, :namespace_id)
@filter_params[:sort] ||= default_sort_order
@sort = @filter_params[:sort]
@@ -87,10 +55,6 @@ module IssuableCollections
@filter_params
end
- def set_default_scope
- params[:scope] = 'all' if params[:scope].blank?
- end
-
def set_default_state
params[:state] = 'opened' if params[:state].blank?
end
diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb
index b17c138d5c7..404559c8707 100644
--- a/app/controllers/concerns/issues_action.rb
+++ b/app/controllers/concerns/issues_action.rb
@@ -14,7 +14,7 @@ module IssuesAction
respond_to do |format|
format.html
- format.atom { render layout: false }
+ format.atom { render layout: 'xml.atom' }
end
end
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index ed22b1e5470..2b6afaa6233 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -23,7 +23,7 @@ module LfsRequest
render(
json: {
message: 'Git LFS is not enabled on this GitLab server, contact your admin.',
- documentation_url: help_url,
+ documentation_url: help_url
},
status: 501
)
@@ -48,7 +48,7 @@ module LfsRequest
render(
json: {
message: 'Access forbidden. Check your access level.',
- documentation_url: help_url,
+ documentation_url: help_url
},
content_type: "application/vnd.git-lfs+json",
status: 403
@@ -59,7 +59,7 @@ module LfsRequest
render(
json: {
message: 'Not found.',
- documentation_url: help_url,
+ documentation_url: help_url
},
content_type: "application/vnd.git-lfs+json",
status: 404
@@ -106,4 +106,8 @@ module LfsRequest
def objects
@objects ||= (params[:objects] || []).to_a
end
+
+ def has_authentication_ability?(capability)
+ (authentication_abilities || []).include?(capability)
+ end
end
diff --git a/app/controllers/concerns/markdown_preview.rb b/app/controllers/concerns/markdown_preview.rb
deleted file mode 100644
index 40eff267348..00000000000
--- a/app/controllers/concerns/markdown_preview.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-module MarkdownPreview
- private
-
- def render_markdown_preview(text, markdown_context = {})
- render json: {
- body: view_context.markdown(text, markdown_context),
- references: {
- users: preview_referenced_users(text)
- }
- }
- end
-
- def preview_referenced_users(text)
- extractor = Gitlab::ReferenceExtractor.new(@project, current_user)
- extractor.analyze(text, author: current_user)
-
- extractor.users.map(&:username)
- end
-end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index b1bacc8ffe5..c6b1e443de6 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -2,20 +2,21 @@ module MembershipActions
extend ActiveSupport::Concern
def create
- status = Members::CreateService.new(membershipable, current_user, params).execute
+ create_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(membershipable, current_user, create_params).execute
redirect_url = members_page_url
- if status
+ if result[:status] == :success
redirect_to redirect_url, notice: 'Users were successfully added.'
else
- redirect_to redirect_url, alert: 'No users specified.'
+ redirect_to redirect_url, alert: result[:message]
end
end
def destroy
- Members::DestroyService.new(membershipable, current_user, params).
- execute(:all)
+ Members::DestroyService.new(membershipable, current_user, params)
+ .execute(:all)
respond_to do |format|
format.html do
@@ -41,8 +42,8 @@ module MembershipActions
end
def leave
- member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id).
- execute(:all)
+ member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id)
+ .execute(:all)
notice =
if member.request?
@@ -51,9 +52,14 @@ module MembershipActions
"You left the \"#{membershipable.human_name}\" #{source_type}."
end
- redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
+ respond_to do |format|
+ format.html do
+ redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
+ redirect_to redirect_path, notice: notice
+ end
- redirect_to redirect_path, notice: notice
+ format.json { render json: { notice: notice } }
+ end
end
protected
@@ -64,7 +70,7 @@ module MembershipActions
def members_page_url
if membershipable.is_a?(Project)
- project_settings_members_path(membershipable)
+ project_project_members_path(membershipable)
else
polymorphic_url([membershipable, :members])
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
new file mode 100644
index 00000000000..081f3336780
--- /dev/null
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -0,0 +1,55 @@
+module MilestoneActions
+ extend ActiveSupport::Concern
+
+ def merge_requests
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_merge_requests_tab", {
+ merge_requests: @milestone.sorted_merge_requests,
+ show_project_name: true
+ })
+ end
+ end
+ end
+
+ def participants
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_participants_tab", {
+ users: @milestone.participants
+ })
+ end
+ end
+ end
+
+ def labels
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_labels_tab", {
+ labels: @milestone.labels
+ })
+ end
+ end
+ end
+
+ private
+
+ def tabs_json(partial, data = {})
+ {
+ html: view_to_html_string(partial, data)
+ }
+ end
+
+ def milestone_redirect_path
+ if @project
+ project_milestone_path(@project, @milestone)
+ elsif @group
+ group_milestone_path(@group, @milestone.safe_title, title: @milestone.title)
+ else
+ dashboard_milestone_path(@milestone.safe_title, title: @milestone.title)
+ end
+ end
+end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
new file mode 100644
index 00000000000..af5f683bab5
--- /dev/null
+++ b/app/controllers/concerns/notes_actions.rb
@@ -0,0 +1,200 @@
+module NotesActions
+ include RendersNotes
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authorize_admin_note!, only: [:update, :destroy]
+ before_action :note_project, only: [:create]
+ end
+
+ 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)
+
+ notes_json[:notes] << note_json(note)
+ end
+
+ render json: notes_json
+ end
+
+ def create
+ 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(note_project, current_user, create_params).execute
+
+ if @note.is_a?(Note)
+ Banzai::NoteRenderer.render([@note], @project, current_user)
+ end
+
+ respond_to do |format|
+ format.json { render json: note_json(@note) }
+ format.html { redirect_back_or_default }
+ end
+ end
+
+ def update
+ @note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
+
+ if @note.is_a?(Note)
+ Banzai::NoteRenderer.render([@note], @project, current_user)
+ end
+
+ respond_to do |format|
+ format.json { render json: note_json(@note) }
+ format.html { redirect_back_or_default }
+ end
+ end
+
+ def destroy
+ if note.editable?
+ Notes::DestroyService.new(project, current_user).execute(note)
+ end
+
+ respond_to do |format|
+ format.js { head :ok }
+ end
+ end
+
+ private
+
+ def note_html(note)
+ render_to_string(
+ "shared/notes/_note",
+ layout: false,
+ formats: [:html],
+ locals: { note: note }
+ )
+ end
+
+ def note_json(note)
+ attrs = {
+ commands_changes: note.commands_changes
+ }
+
+ if note.persisted?
+ attrs.merge!(
+ valid: true,
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ note: note.note
+ )
+
+ 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)
+ )
+ end
+ else
+ attrs.merge!(
+ valid: false,
+ errors: note.errors
+ )
+ end
+
+ attrs
+ end
+
+ def diff_discussion_html(discussion)
+ return unless discussion.diff_discussion?
+
+ if params[:view] == 'parallel'
+ template = "discussions/_parallel_diff_discussion"
+ locals =
+ if params[:line_type] == 'old'
+ { discussions_left: [discussion], discussions_right: nil }
+ else
+ { discussions_left: nil, discussions_right: [discussion] }
+ end
+ else
+ template = "discussions/_diff_discussion"
+ locals = { discussions: [discussion] }
+ end
+
+ render_to_string(
+ template,
+ layout: false,
+ formats: [:html],
+ locals: locals
+ )
+ end
+
+ def discussion_html(discussion)
+ return if discussion.individual_note?
+
+ render_to_string(
+ "discussions/_discussion",
+ layout: false,
+ formats: [:html],
+ locals: { discussion: discussion }
+ )
+ end
+
+ def authorize_admin_note!
+ return access_denied! unless can?(current_user, :admin_note, note)
+ end
+
+ def note_params
+ params.require(:note).permit(
+ :project_id,
+ :noteable_type,
+ :noteable_id,
+ :commit_id,
+ :noteable,
+ :type,
+
+ :note,
+ :attachment,
+
+ # LegacyDiffNote
+ :line_code,
+
+ # DiffNote
+ :position
+ )
+ end
+
+ def noteable
+ @noteable ||= notes_finder.target
+ end
+
+ def last_fetched_at
+ request.headers['X-Last-Fetched-At']
+ end
+
+ def notes_finder
+ @notes_finder ||= NotesFinder.new(project, current_user, finder_params)
+ end
+
+ def note_project
+ return @note_project if defined?(@note_project)
+ return nil unless project
+
+ note_project_id = params[:note_project_id]
+
+ @note_project =
+ if note_project_id.present?
+ Project.find(note_project_id)
+ else
+ project
+ end
+
+ return access_denied! unless can?(current_user, :create_note, @note_project)
+
+ @note_project
+ end
+end
diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb
new file mode 100644
index 00000000000..ba7adcfea86
--- /dev/null
+++ b/app/controllers/concerns/renders_blob.rb
@@ -0,0 +1,32 @@
+module RendersBlob
+ extend ActiveSupport::Concern
+
+ def blob_json(blob)
+ viewer =
+ case params[:viewer]
+ when 'rich'
+ blob.rich_viewer
+ when 'auxiliary'
+ blob.auxiliary_viewer
+ else
+ blob.simple_viewer
+ end
+
+ return unless viewer
+
+ {
+ html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false)
+ }
+ end
+
+ def render_blob_json(blob)
+ json = blob_json(blob)
+ return render_404 unless json
+
+ render json: json
+ end
+
+ def conditionally_expand_blob(blob)
+ blob.expand! if params[:expanded] == 'true'
+ end
+end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index dd21066ac13..41c3114ad1e 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -10,6 +10,8 @@ module RendersNotes
private
def preload_max_access_for_authors(notes, project)
+ return nil unless project
+
user_ids = notes.map(&:author_id)
project.team.max_member_access_for_user_ids(user_ids)
end
diff --git a/app/controllers/concerns/repository_settings_redirect.rb b/app/controllers/concerns/repository_settings_redirect.rb
index 0854c73a02f..0576f0e6e70 100644
--- a/app/controllers/concerns/repository_settings_redirect.rb
+++ b/app/controllers/concerns/repository_settings_redirect.rb
@@ -2,6 +2,6 @@ module RepositorySettingsRedirect
extend ActiveSupport::Concern
def redirect_to_repository_settings(project)
- redirect_to namespace_project_settings_repository_path(project.namespace, project)
+ redirect_to project_settings_repository_path(project)
end
end
diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb
deleted file mode 100644
index 34ab1a97649..00000000000
--- a/app/controllers/concerns/requires_health_token.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-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/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
new file mode 100644
index 00000000000..ad2f4bbc486
--- /dev/null
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -0,0 +1,33 @@
+module RequiresWhitelistedMonitoringClient
+ extend ActiveSupport::Concern
+ included do
+ before_action :validate_ip_whitelisted_or_valid_token!
+ end
+
+ private
+
+ def validate_ip_whitelisted_or_valid_token!
+ render_404 unless client_ip_whitelisted? || valid_token?
+ end
+
+ def client_ip_whitelisted?
+ ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) }
+ end
+
+ def ip_whitelist
+ @ip_whitelist ||= Settings.monitoring.ip_whitelist.map(&IPAddr.method(:new))
+ end
+
+ def valid_token?
+ 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/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
new file mode 100644
index 00000000000..4199da9cdf5
--- /dev/null
+++ b/app/controllers/concerns/routable_actions.rb
@@ -0,0 +1,38 @@
+module RoutableActions
+ extend ActiveSupport::Concern
+
+ def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
+ routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
+
+ if routable_authorized?(routable, extra_authorization_proc)
+ ensure_canonical_path(routable, requested_full_path)
+ routable
+ else
+ route_not_found
+ nil
+ end
+ end
+
+ def routable_authorized?(routable, extra_authorization_proc)
+ action = :"read_#{routable.class.to_s.underscore}"
+ return false unless can?(current_user, action, routable)
+
+ if extra_authorization_proc
+ extra_authorization_proc.call(routable)
+ else
+ true
+ end
+ end
+
+ def ensure_canonical_path(routable, requested_full_path)
+ return unless request.get?
+
+ canonical_path = routable.full_path
+ if canonical_path != requested_full_path
+ if canonical_path.casecmp(requested_full_path) != 0
+ flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path."
+ end
+ redirect_to build_canonical_path(routable)
+ end
+ end
+end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index a8c0937569c..be2e6c7f193 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -38,6 +38,7 @@ module ServiceParams
:new_issue_url,
:notify,
:notify_only_broken_pipelines,
+ :notify_only_default_branch,
:password,
:priority,
:project_key,
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index ca6dffe1cc5..ffea712a833 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -5,10 +5,12 @@ module SnippetsActions
end
def raw
+ disposition = params[:inline] == 'false' ? 'attachment' : 'inline'
+
send_data(
convert_line_endings(@snippet.content),
type: 'text/plain; charset=utf-8',
- disposition: 'inline',
+ disposition: disposition,
filename: @snippet.sanitized_file_name
)
end
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index d0a692070d9..ada0dde87fb 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -9,18 +9,26 @@ module SpammableActions
def mark_as_spam
if SpamService.new(spammable).mark_as_spam!
- redirect_to spammable, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
+ redirect_to spammable_path, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
else
- redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to spammable_path, alert: 'Error with Akismet. Please check the logs for more info.'
end
end
private
+ def ensure_spam_config_loaded!
+ return @spam_config_loaded if defined?(@spam_config_loaded)
+
+ @spam_config_loaded = Gitlab::Recaptcha.load_configurations!
+ end
+
def recaptcha_check_with_fallback(&fallback)
if spammable.valid?
- redirect_to spammable
+ redirect_to spammable_path
elsif render_recaptcha?
+ ensure_spam_config_loaded!
+
if params[:recaptcha_verification]
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end
@@ -35,7 +43,7 @@ module SpammableActions
default_params = { request: request }
recaptcha_check = params[:recaptcha_verification] &&
- Gitlab::Recaptcha.load_configurations! &&
+ ensure_spam_config_loaded! &&
verify_recaptcha
return default_params unless recaptcha_check
@@ -48,6 +56,10 @@ module SpammableActions
raise NotImplementedError, "#{self.class} does not implement #{__method__}"
end
+ def spammable_path
+ raise NotImplementedError, "#{self.class} does not implement #{__method__}"
+ end
+
def authorize_submit_spammable!
access_denied! unless current_user.admin?
end
diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb
index fbf9a026b10..ba5b7d33f87 100644
--- a/app/controllers/concerns/toggle_award_emoji.rb
+++ b/app/controllers/concerns/toggle_award_emoji.rb
@@ -22,7 +22,8 @@ module ToggleAwardEmoji
def to_todoable(awardable)
case awardable
when Note
- awardable.noteable
+ # we don't create todos for personal snippet comments for now
+ awardable.for_personal_snippet? ? nil : awardable.noteable
when MergeRequest, Issue
awardable
when Snippet
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
new file mode 100644
index 00000000000..dec2e27335a
--- /dev/null
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -0,0 +1,27 @@
+module UploadsActions
+ def create
+ link_to_file = UploadService.new(model, params[:file], uploader_class).execute
+
+ respond_to do |format|
+ if link_to_file
+ format.json do
+ render json: { link: link_to_file }
+ end
+ else
+ format.json do
+ render json: 'Invalid file.', status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ def show
+ return render_404 unless uploader.exists?
+
+ disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+
+ expires_in 0.seconds, must_revalidate: true, private: true
+
+ send_file uploader.file.path, disposition: disposition
+ end
+end
diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb
new file mode 100644
index 00000000000..ed253042701
--- /dev/null
+++ b/app/controllers/concerns/with_performance_bar.rb
@@ -0,0 +1,17 @@
+module WithPerformanceBar
+ extend ActiveSupport::Concern
+
+ included do
+ include Peek::Rblineprof::CustomControllerHelpers
+ end
+
+ def peek_enabled?
+ return false unless Gitlab::PerformanceBar.enabled?(current_user)
+
+ if RequestStore.active?
+ RequestStore.fetch(:peek_enabled) { cookies[:perf_bar_enabled].present? }
+ else
+ cookies[:perf_bar_enabled].present?
+ end
+ end
+end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index d03265e9f20..742157d113d 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,16 +1,30 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
def index
- @group_members = current_user.group_members.includes(source: :route).joins(:group)
- @group_members = @group_members.merge(Group.search(params[:filter_groups])) if params[:filter_groups].present?
- @group_members = @group_members.merge(Group.sort(@sort = params[:sort]))
- @group_members = @group_members.page(params[:page])
+ @groups =
+ if params[:parent_id] && Group.supports_nested_groups?
+ parent = Group.find_by(id: params[:parent_id])
+
+ if can?(current_user, :read_group, parent)
+ GroupsFinder.new(current_user, parent: parent).execute
+ else
+ Group.none
+ end
+ else
+ current_user.groups
+ end
+
+ @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
+ @groups = @groups.includes(:route)
+ @groups = @groups.sort(@sort = params[:sort])
+ @groups = @groups.page(params[:page])
respond_to do |format|
format.html
format.json do
- render json: {
- html: view_to_html_string("dashboard/groups/_groups", locals: { group_members: @group_members })
- }
+ render json: GroupSerializer
+ .new(current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@groups)
end
end
end
diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb
index d5031da867a..9dcb3a0eb6d 100644
--- a/app/controllers/dashboard/labels_controller.rb
+++ b/app/controllers/dashboard/labels_controller.rb
@@ -1,9 +1,14 @@
class Dashboard::LabelsController < Dashboard::ApplicationController
def index
- labels = LabelsFinder.new(current_user).execute
-
respond_to do |format|
- format.json { render json: labels.as_json(only: [:id, :title, :color]) }
+ format.json { render json: LabelSerializer.new.represent_appearance(labels) }
end
end
+
+ def labels
+ finder_params = { project_ids: projects.select(:id) }
+ labels = LabelsFinder.new(current_user, finder_params).execute
+
+ GlobalLabel.build_collection(labels)
+ end
end
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index df528d10f6e..751dbbd8e96 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -1,6 +1,8 @@
class Dashboard::MilestonesController < Dashboard::ApplicationController
+ include MilestoneActions
+
before_action :projects
- before_action :milestone, only: [:show]
+ before_action :milestone, only: [:show, :merge_requests, :participants, :labels]
def index
respond_to do |format|
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 5a1efcab1a3..f71ab702e71 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -8,10 +8,10 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
respond_to do |format|
- format.html { @last_push = current_user.recent_push }
+ format.html
format.atom do
load_events
- render layout: false
+ render layout: 'xml.atom'
end
format.json do
render json: {
@@ -22,10 +22,9 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def starred
- @projects = load_projects(params.merge(starred: true)).
- includes(:forked_from_project, :tags).page(params[:page])
+ @projects = load_projects(params.merge(starred: true))
+ .includes(:forked_from_project, :tags).page(params[:page])
- @last_push = current_user.recent_push
@groups = []
respond_to do |format|
@@ -46,13 +45,17 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- ProjectsFinder.new(params: finder_params, current_user: current_user).
- execute.includes(:route, namespace: :route)
+ ProjectsFinder
+ .new(params: finder_params, current_user: current_user)
+ .execute
+ .includes(:route, :creator, namespace: :route)
end
def load_events
- @events = Event.in_projects(load_projects(params.merge(non_public: true)))
- @events = event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ projects = load_projects(params.merge(non_public: true))
+
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
end
end
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index bcfdbe14be9..8dd91264451 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -1,11 +1,10 @@
class Dashboard::SnippetsController < Dashboard::ApplicationController
def index
- @snippets = SnippetsFinder.new.execute(
+ @snippets = SnippetsFinder.new(
current_user,
- filter: :by_user,
- user: current_user,
+ author: current_user,
scope: params[:scope]
- )
+ ).execute
@snippets = @snippets.page(params[:page])
end
end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 4d7d45787fc..a8b2b93b458 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -1,6 +1,7 @@
class Dashboard::TodosController < Dashboard::ApplicationController
include ActionView::Helpers::NumberHelper
+ before_action :authorize_read_project!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
def index
@@ -12,10 +13,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def destroy
- TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
+ TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
+ format.html do
+ redirect_to dashboard_todos_path,
+ status: 302,
+ notice: 'Todo was successfully marked as done.'
+ end
format.js { head :ok }
format.json { render json: todos_counts }
end
@@ -25,14 +30,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController
updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' }
+ format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' }
format.js { head :ok }
format.json { render json: todos_counts.merge(updated_ids: updated_ids) }
end
end
def restore
- TodoService.new.mark_todos_as_pending_by_ids([params[:id]], current_user)
+ TodoService.new.mark_todos_as_pending_by_ids(params[:id], current_user)
render json: todos_counts
end
@@ -43,13 +48,17 @@ class Dashboard::TodosController < Dashboard::ApplicationController
render json: todos_counts
end
- # Used in TodosHelper also
- def self.todos_count_format(count)
- count >= 100 ? '99+' : count
- end
-
private
+ def authorize_read_project!
+ project_id = params[:project_id]
+
+ if project_id.present?
+ project = Project.find(project_id)
+ render_404 unless can?(current_user, :read_project, project)
+ end
+ end
+
def find_todos
@todos ||= TodosFinder.new(current_user, params).execute
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 79d420a32d3..19a5db6fd17 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -9,8 +9,6 @@ class DashboardController < Dashboard::ApplicationController
respond_to :html
def activity
- @last_push = current_user.recent_push
-
respond_to do |format|
format.html
@@ -26,14 +24,14 @@ class DashboardController < Dashboard::ApplicationController
def load_events
projects =
if params[:filter] == "starred"
- current_user.viewable_starred_projects
+ ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
else
current_user.authorized_projects
end
- @events = Event.in_projects(projects)
- @events = @event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: @event_filter)
+ .to_a
end
def set_show_full_reference
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 68228c095da..81883c543ba 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -1,6 +1,6 @@
class Explore::GroupsController < Explore::ApplicationController
def index
- @groups = GroupsFinder.new.execute(current_user)
+ @groups = GroupsFinder.new(current_user).execute
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
@groups = @groups.sort(@sort = params[:sort])
@groups = @groups.page(params[:page])
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 8f1870759e4..762c6ebf3a3 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -6,7 +6,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def index
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
- @projects = load_projects.page(params[:page])
+ @projects = load_projects
respond_to do |format|
format.html
@@ -21,7 +21,7 @@ class Explore::ProjectsController < Explore::ApplicationController
def trending
params[:trending] = true
@sort = params[:sort]
- @projects = load_projects.page(params[:page])
+ @projects = load_projects
respond_to do |format|
format.html
@@ -34,7 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def starred
- @projects = load_projects.reorder('star_count DESC').page(params[:page])
+ @projects = load_projects.reorder('star_count DESC')
respond_to do |format|
format.html
@@ -49,7 +49,10 @@ class Explore::ProjectsController < Explore::ApplicationController
private
def load_projects
- ProjectsFinder.new(current_user: current_user, params: params).
- execute.includes(:route, namespace: :route)
+ ProjectsFinder.new(current_user: current_user, params: params)
+ .execute
+ .includes(:route, namespace: :route)
+ .page(params[:page])
+ .without_count
end
end
diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb
index 28760c3f84b..d3f0e033068 100644
--- a/app/controllers/explore/snippets_controller.rb
+++ b/app/controllers/explore/snippets_controller.rb
@@ -1,6 +1,6 @@
class Explore::SnippetsController < Explore::ApplicationController
def index
- @snippets = SnippetsFinder.new.execute(current_user, filter: :all)
+ @snippets = SnippetsFinder.new(current_user).execute
@snippets = @snippets.page(params[:page])
end
end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 29ffaeb19c1..96ce686c989 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -1,4 +1,6 @@
class Groups::ApplicationController < ApplicationController
+ include RoutableActions
+
layout 'group'
skip_before_action :authenticate_user!
@@ -7,29 +9,17 @@ class Groups::ApplicationController < ApplicationController
private
def group
- unless @group
- id = params[:group_id] || params[:id]
- @group = Group.find_by_full_path(id)
- @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
-
- unless @group && can?(current_user, :read_group, @group)
- @group = nil
-
- if current_user.nil?
- authenticate_user!
- else
- render_404
- end
- end
- end
-
- @group
+ @group ||= find_routable!(Group, params[:group_id] || params[:id])
end
def group_projects
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
+ def group_merge_requests
+ @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute
+ end
+
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
return render_404
@@ -41,4 +31,10 @@ class Groups::ApplicationController < ApplicationController
return render_403
end
end
+
+ def build_canonical_path(group)
+ params[:group_id] = group.to_param
+
+ url_for(params)
+ end
end
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index ad2c20b42db..735915abdaa 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -5,6 +5,6 @@ class Groups::AvatarsController < Groups::ApplicationController
@group.remove_avatar!
@group.save
- redirect_to edit_group_path(@group)
+ redirect_to edit_group_path(@group), status: 302
end
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index facb25525b5..dda59262483 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController
format.json do
available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
- render json: available_labels.as_json(only: [:id, :title, :color])
+ render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
end
@@ -54,7 +54,7 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to do |format|
format.html do
- redirect_to group_labels_path(@group), notice: 'Label was removed'
+ redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 43102596201..5c10d7bc261 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -1,14 +1,19 @@
class Groups::MilestonesController < Groups::ApplicationController
+ include MilestoneActions
+
before_action :group_projects
- before_action :milestone, only: [:show, :update]
- before_action :authorize_admin_milestones!, only: [:new, :create, :update]
+ before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update]
def index
respond_to do |format|
format.html do
- @milestone_states = GlobalMilestone.states_count(@projects)
+ @milestone_states = GlobalMilestone.states_count(group_projects, group)
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
+ format.json do
+ render json: milestones.map { |m| m.for_display.slice(:title, :name) }
+ end
end
end
@@ -17,49 +22,41 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def create
- project_ids = params[:milestone][:project_ids].reject(&:blank?)
- title = milestone_params[:title]
+ @milestone = Milestones::CreateService.new(group, current_user, milestone_params).execute
- if create_milestones(project_ids)
- redirect_to milestone_path(title)
+ if @milestone.persisted?
+ redirect_to milestone_path
else
- render_new_with_error(project_ids.empty?)
+ render "new"
end
end
def show
end
- def update
- @milestone.milestones.each do |milestone|
- Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
- end
-
- redirect_back_or_default(default: milestone_path(@milestone.title))
+ def edit
+ render_404 if @milestone.is_legacy_group_milestone?
end
- private
-
- def create_milestones(project_ids)
- return false unless project_ids.present?
+ def update
+ # Keep this compatible with legacy group milestones where we have to update
+ # all projects milestones states at once.
+ if @milestone.is_legacy_group_milestone?
+ update_params = milestone_params.select { |key| key == "state_event" }
+ milestones = @milestone.milestones
+ else
+ update_params = milestone_params
+ milestones = [@milestone]
+ end
- ActiveRecord::Base.transaction do
- @projects.where(id: project_ids).each do |project|
- Milestones::CreateService.new(project, current_user, milestone_params).execute
- end
+ milestones.each do |milestone|
+ Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
end
- true
- rescue ActiveRecord::ActiveRecordError => e
- flash.now[:alert] = "An error occurred while creating the milestone: #{e.message}"
- false
+ redirect_to milestone_path
end
- def render_new_with_error(empty_project_ids)
- @milestone = Milestone.new(milestone_params)
- @milestone.errors.add(:base, "Please select at least one project.") if empty_project_ids
- render :new
- end
+ private
def authorize_admin_milestones!
return render_404 unless can?(current_user, :admin_milestones, group)
@@ -69,16 +66,31 @@ class Groups::MilestonesController < Groups::ApplicationController
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
- def milestone_path(title)
- group_milestone_path(@group, title.to_slug.to_s, title: title)
+ def milestone_path
+ if @milestone.is_legacy_group_milestone?
+ group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
+ else
+ group_milestone_path(group, @milestone.iid)
+ end
end
def milestones
- @milestones = GroupMilestone.build_collection(@group, @projects, params)
+ search_params = params.merge(group_ids: group.id)
+
+ milestones = MilestonesFinder.new(search_params).execute
+ legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
+
+ milestones + legacy_milestones
end
def milestone
- @milestone = GroupMilestone.build(@group, @projects, params[:title])
+ @milestone =
+ if params[:title]
+ GroupMilestone.build(group, group_projects, params[:title])
+ else
+ group.milestones.find_by_iid(params[:id])
+ end
+
render_404 unless @milestone
end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
new file mode 100644
index 00000000000..0142ad8278c
--- /dev/null
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -0,0 +1,24 @@
+module Groups
+ module Settings
+ class CiCdController < Groups::ApplicationController
+ before_action :authorize_admin_pipeline!
+
+ def show
+ define_secret_variables
+ end
+
+ private
+
+ def define_secret_variables
+ @variable = Ci::GroupVariable.new(group: group)
+ .present(current_user: current_user)
+ @variables = group.variables.order_key_asc
+ .map { |variable| variable.present(current_user: current_user) }
+ end
+
+ def authorize_admin_pipeline!
+ return render_404 unless can?(current_user, :admin_pipeline, group)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
new file mode 100644
index 00000000000..10038ff3ad9
--- /dev/null
+++ b/app/controllers/groups/variables_controller.rb
@@ -0,0 +1,64 @@
+module Groups
+ class VariablesController < Groups::ApplicationController
+ before_action :variable, only: [:show, :update, :destroy]
+ before_action :authorize_admin_build!
+
+ def index
+ redirect_to group_settings_ci_cd_path(group)
+ end
+
+ def show
+ end
+
+ def update
+ if variable.update(variable_params)
+ redirect_to group_variables_path(group),
+ notice: 'Variable was successfully updated.'
+ else
+ render "show"
+ end
+ end
+
+ def create
+ @variable = group.variables.create(variable_params)
+ .present(current_user: current_user)
+
+ if @variable.persisted?
+ redirect_to group_settings_ci_cd_path(group),
+ notice: 'Variable was successfully created.'
+ else
+ render "show"
+ end
+ end
+
+ def destroy
+ if variable.destroy
+ redirect_to group_settings_ci_cd_path(group),
+ status: 302,
+ notice: 'Variable was successfully removed.'
+ else
+ redirect_to group_settings_ci_cd_path(group),
+ status: 302,
+ notice: 'Failed to remove the variable.'
+ end
+ end
+
+ private
+
+ def variable_params
+ params.require(:variable).permit(*variable_params_attributes)
+ end
+
+ def variable_params_attributes
+ %i[key value protected]
+ end
+
+ def variable
+ @variable ||= group.variables.find(params[:id]).present(current_user: current_user)
+ end
+
+ def authorize_admin_build!
+ return render_404 unless can?(current_user, :admin_build, group)
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 593001e6396..f76b3f69e9e 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -12,8 +12,8 @@ class GroupsController < Groups::ApplicationController
before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects]
before_action :authorize_create_group!, only: [:new, :create]
- # Load group projects
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
+ before_action :group_merge_requests, only: [:merge_requests]
before_action :event_filter, only: [:activity]
before_action :user_actions, only: [:show, :subgroups]
@@ -58,13 +58,15 @@ class GroupsController < Groups::ApplicationController
format.atom do
load_events
- render layout: false
+ render layout: 'xml.atom'
end
end
end
def subgroups
- @nested_groups = group.children
+ return not_found unless Group.supports_nested_groups?
+
+ @nested_groups = GroupsFinder.new(current_user, parent: group).execute
@nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
end
@@ -99,7 +101,7 @@ class GroupsController < Groups::ApplicationController
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end
protected
@@ -158,15 +160,22 @@ class GroupsController < Groups::ApplicationController
end
def load_events
- @events = Event.in_projects(@projects)
- @events = event_filter.apply_filter(@events).with_associations
- @events = @events.limit(20).offset(params[:offset] || 0)
+ @events = EventCollection
+ .new(@projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
end
def user_actions
if current_user
- @last_push = current_user.recent_push
@notification_setting = current_user.notification_settings_for(group)
end
end
+
+ def build_canonical_path(group)
+ return group_path(group) if action_name == 'show' # root group path
+
+ params[:id] = group.to_param
+
+ url_for(params)
+ end
end
diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb
index 5d3109b7187..c3d18991fd4 100644
--- a/app/controllers/health_check_controller.rb
+++ b/app/controllers/health_check_controller.rb
@@ -1,3 +1,3 @@
class HealthCheckController < HealthCheck::HealthCheckController
- include RequiresHealthToken
+ include RequiresWhitelistedMonitoringClient
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index df0fc3132ed..98c2aaa3526 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -1,11 +1,14 @@
class HealthController < ActionController::Base
protect_from_forgery with: :exception
- include RequiresHealthToken
+ include RequiresWhitelistedMonitoringClient
CHECKS = [
Gitlab::HealthChecks::DbCheck,
- Gitlab::HealthChecks::RedisCheck,
- Gitlab::HealthChecks::FsShardsCheck,
+ Gitlab::HealthChecks::Redis::RedisCheck,
+ Gitlab::HealthChecks::Redis::CacheCheck,
+ Gitlab::HealthChecks::Redis::QueuesCheck,
+ Gitlab::HealthChecks::Redis::SharedStateCheck,
+ Gitlab::HealthChecks::FsShardsCheck
].freeze
def readiness
@@ -20,25 +23,8 @@ class HealthController < ActionController::Base
render_check_results(results)
end
- def metrics
- results = CHECKS.flat_map(&:metrics)
-
- response = results.map(&method(:metric_to_prom_line)).join("\n")
-
- render text: response, content_type: 'text/plain; version=0.0.4'
- end
-
private
- def metric_to_prom_line(metric)
- labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
- if labels.empty?
- "#{metric.name} #{metric.value}"
- else
- "#{metric.name}{#{labels}} #{metric.value}"
- end
- end
-
def render_check_results(results)
flattened = results.flat_map do |name, result|
if result.is_a?(Gitlab::HealthChecks::Result)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 53a5981e564..ab18d86dcae 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -64,19 +64,19 @@ class Import::GithubController < Import::BaseController
end
def import_enabled?
- __send__("#{provider}_import_enabled?")
+ __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
end
def new_import_url
- public_send("new_import_#{provider}_url")
+ public_send("new_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
end
def status_import_url
- public_send("status_import_#{provider}_url")
+ public_send("status_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
- public_send("callback_import_#{provider}_url")
+ public_send("callback_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 73837ffbe67..407154e59a0 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -15,7 +15,7 @@ class Import::GitlabController < Import::BaseController
@already_added_projects = current_user.created_projects.where(import_type: "gitlab")
already_added_projects_names = @already_added_projects.pluck(:import_source)
- @repos = @repos.to_a.reject{ |repo| already_added_projects_names.include? repo["path_with_namespace"] }
+ @repos = @repos.to_a.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] }
end
def jobs
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 36d246d185b..510813846a4 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -12,15 +12,7 @@ class Import::GitlabProjectsController < Import::BaseController
return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." })
end
- import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename)
-
- FileUtils.mkdir_p(File.dirname(import_upload_path))
- FileUtils.copy_entry(project_params[:file].path, import_upload_path)
-
- @project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id],
- current_user,
- import_upload_path,
- project_params[:path]).execute
+ @project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute
if @project.saved?
redirect_to(
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 7625187c7be..0982a61902b 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -63,7 +63,7 @@ class InvitesController < ApplicationController
when Project
project = member.source
label = "project #{project.name_with_namespace}"
- path = namespace_project_path(project.namespace, project)
+ path = project_path(project)
when Group
group = member.source
label = "group #{group.name}"
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 3109439b2ff..4bceb1d67a3 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -4,15 +4,15 @@ class JwtController < ApplicationController
before_action :authenticate_project_or_user
SERVICES = {
- Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService,
+ Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService
}.freeze
def auth
service = SERVICES[params[:service]]
return head :not_found unless service
- result = service.new(@authentication_result.project, @authentication_result.actor, auth_params).
- execute(authentication_abilities: @authentication_result.authentication_abilities)
+ result = service.new(@authentication_result.project, @authentication_result.actor, auth_params)
+ .execute(authentication_abilities: @authentication_result.authentication_abilities)
render json: result, status: result[:http_status]
end
@@ -25,8 +25,10 @@ class JwtController < ApplicationController
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
- render_unauthorized unless @authentication_result.success? &&
- (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User))
+ if @authentication_result.failed? ||
+ (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
+ render_unauthorized
+ end
end
rescue Gitlab::Auth::MissingPersonalTokenError
render_missing_personal_token
@@ -37,7 +39,7 @@ class JwtController < ApplicationController
errors: [
{ code: 'UNAUTHORIZED',
message: "HTTP Basic: Access denied\n" \
- "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
+ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}" }
]
}, status: 401
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
new file mode 100644
index 00000000000..37587a52eaf
--- /dev/null
+++ b/app/controllers/metrics_controller.rb
@@ -0,0 +1,21 @@
+class MetricsController < ActionController::Base
+ include RequiresWhitelistedMonitoringClient
+
+ protect_from_forgery with: :exception
+
+ before_action :validate_prometheus_metrics
+
+ def index
+ render text: metrics_service.metrics_text, content_type: 'text/plain; version=0.0.4'
+ end
+
+ private
+
+ def metrics_service
+ @metrics_service ||= MetricsService.new
+ end
+
+ def validate_prometheus_metrics
+ render_404 unless Gitlab::Metrics.prometheus_metrics_enabled?
+ end
+end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 4193ac11399..656107d2b26 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -10,6 +10,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
Doorkeeper::AccessToken.revoke_all_for(params[:id], current_resource_owner)
end
- redirect_to applications_profile_url, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
+ redirect_to applications_profile_url,
+ status: 302,
+ notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
end
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 58d50ad647b..7444826a5d1 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -1,5 +1,6 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
+ include Devise::Controllers::Rememberable
protect_from_forgery except: [:kerberos, :saml, :cas3]
@@ -33,12 +34,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if @user.two_factor_enabled?
prompt_for_two_factor(@user)
else
- log_audit_event(@user, with: :ldap)
+ log_audit_event(@user, with: oauth['provider'])
sign_in_and_redirect(@user)
end
else
- flash[:alert] = "Access denied for your LDAP account."
- redirect_to new_user_session_path
+ fail_ldap_login
end
end
@@ -67,7 +67,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def omniauth_error
@provider = params[:provider]
@error = params[:error]
- render 'errors/omniauth_error', layout: "errors", status: 422
+ render 'errors/omniauth_error', layout: "oauth_error", status: 422
end
def cas3
@@ -115,14 +115,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if @user.persisted? && @user.valid?
log_audit_event(@user, with: oauth['provider'])
if @user.two_factor_enabled?
+ params[:remember_me] = '1' if remember_me?
prompt_for_two_factor(@user)
else
+ remember_me(@user) if remember_me?
sign_in_and_redirect(@user)
end
else
- error_message = @user.errors.full_messages.to_sentence
-
- return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
+ fail_login
end
end
@@ -143,8 +143,25 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
@oauth ||= request.env['omniauth.auth']
end
+ def fail_login
+ error_message = @user.errors.full_messages.to_sentence
+
+ return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
+ end
+
+ def fail_ldap_login
+ flash[:alert] = 'Access denied for your LDAP account.'
+
+ redirect_to new_user_session_path
+ end
+
def log_audit_event(user, options = {})
- AuditEventService.new(user, user, options).
- for_authentication.security_event
+ AuditEventService.new(user, user, options)
+ .for_authentication.security_event
+ end
+
+ def remember_me?
+ request_params = request.env['omniauth.params']
+ (request_params['remember_me'] == '1') if request_params.present?
end
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index a8575e037e4..aa8cf630032 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,6 +1,8 @@
class PasswordsController < Devise::PasswordsController
+ include Gitlab::CurrentSettings
+
before_action :resource_from_email, only: [:create]
- before_action :prevent_ldap_reset, only: [:create]
+ before_action :check_password_authentication_available, only: [:create]
before_action :throttle_reset, only: [:create]
def edit
@@ -25,7 +27,7 @@ class PasswordsController < Devise::PasswordsController
def update
super do |resource|
- if resource.valid? && resource.require_password?
+ if resource.valid? && resource.require_password_creation?
resource.update_attribute(:password_automatically_set, false)
end
end
@@ -38,11 +40,11 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email)
end
- def prevent_ldap_reset
- return unless resource && resource.ldap_user?
+ def check_password_authentication_available
+ return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?)
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Cannot reset password for LDAP user."
+ alert: "Password authentication is unavailable."
end
def throttle_reset
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index daa51ae41df..408650aac54 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -1,10 +1,9 @@
class Profiles::AvatarsController < Profiles::ApplicationController
def destroy
@user = current_user
- @user.remove_avatar!
- @user.save
+ Users::UpdateService.new(@user).execute { |user| user.remove_avatar! }
- redirect_to profile_path
+ redirect_to profile_path, status: 302
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 6a1f468ba5a..2353f0840d6 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -39,7 +39,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
end
- redirect_to profile_chat_names_path
+ redirect_to profile_chat_names_path, status: 302
end
private
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index 1c24c4db993..17b66df43e7 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -5,9 +5,9 @@ class Profiles::EmailsController < Profiles::ApplicationController
end
def create
- @email = current_user.emails.new(email_params)
+ @email = Emails::CreateService.new(current_user, email_params).execute
- if @email.save
+ if @email.errors.blank?
NotificationService.new.new_email(@email)
else
flash[:alert] = @email.errors.full_messages.first
@@ -18,12 +18,11 @@ class Profiles::EmailsController < Profiles::ApplicationController
def destroy
@email = current_user.emails.find(params[:id])
- @email.destroy
- current_user.update_secondary_emails!
+ Emails::DestroyService.new(current_user, email: @email.email).execute
respond_to do |format|
- format.html { redirect_to profile_emails_url }
+ format.html { redirect_to profile_emails_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb
new file mode 100644
index 00000000000..6779cc6ddac
--- /dev/null
+++ b/app/controllers/profiles/gpg_keys_controller.rb
@@ -0,0 +1,47 @@
+class Profiles::GpgKeysController < Profiles::ApplicationController
+ before_action :set_gpg_key, only: [:destroy, :revoke]
+
+ def index
+ @gpg_keys = current_user.gpg_keys
+ @gpg_key = GpgKey.new
+ end
+
+ def create
+ @gpg_key = current_user.gpg_keys.new(gpg_key_params)
+
+ if @gpg_key.save
+ redirect_to profile_gpg_keys_path
+ else
+ @gpg_keys = current_user.gpg_keys.select(&:persisted?)
+ render :index
+ end
+ end
+
+ def destroy
+ @gpg_key.destroy
+
+ respond_to do |format|
+ format.html { redirect_to profile_gpg_keys_url, status: 302 }
+ format.js { head :ok }
+ end
+ end
+
+ def revoke
+ @gpg_key.revoke
+
+ respond_to do |format|
+ format.html { redirect_to profile_gpg_keys_url, status: 302 }
+ format.js { head :ok }
+ end
+ end
+
+ private
+
+ def gpg_key_params
+ params.require(:gpg_key).permit(:key)
+ end
+
+ def set_gpg_key
+ @gpg_key = current_user.gpg_keys.find(params[:id])
+ end
+end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index e4452f46056..88f49da555a 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -26,7 +26,7 @@ class Profiles::KeysController < Profiles::ApplicationController
@key.destroy
respond_to do |format|
- format.html { redirect_to profile_keys_url }
+ format.html { redirect_to profile_keys_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index a271e2dfc4b..960b7512602 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -7,7 +7,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController
end
def update
- if current_user.update_attributes(user_params)
+ result = Users::UpdateService.new(current_user, user_params).execute
+
+ if result[:status] == :success
flash[:notice] = "Notification settings saved"
else
flash[:alert] = "Failed to save new settings"
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index 6217ec5ecef..c423761ab24 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -15,17 +15,17 @@ class Profiles::PasswordsController < Profiles::ApplicationController
return
end
- new_password = user_params[:password]
- new_password_confirmation = user_params[:password_confirmation]
-
- result = @user.update_attributes(
- password: new_password,
- password_confirmation: new_password_confirmation,
+ password_attributes = {
+ password: user_params[:password],
+ password_confirmation: user_params[:password_confirmation],
password_automatically_set: false
- )
+ }
+
+ result = Users::UpdateService.new(@user, password_attributes).execute
+
+ if result[:status] == :success
+ Users::UpdateService.new(@user, password_expires_at: nil).execute
- if result
- @user.update_attributes(password_expires_at: nil)
redirect_to root_path, notice: 'Password successfully changed'
else
render :new
@@ -46,7 +46,9 @@ class Profiles::PasswordsController < Profiles::ApplicationController
return
end
- if @user.update_attributes(password_attributes)
+ result = Users::UpdateService.new(@user, password_attributes).execute
+
+ if result[:status] == :success
flash[:notice] = "Password was successfully updated. Please login with it"
redirect_to new_user_session_path
else
@@ -75,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def authorize_change_password!
- return render_404 if @user.ldap_user?
+ render_404 unless @user.allow_password_authentication?
end
def user_params
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 0abe7ea3c9b..f748d191ef4 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -38,7 +38,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def set_index_vars
- @scopes = Gitlab::Auth::API_SCOPES
+ @scopes = Gitlab::Auth::AVAILABLE_SCOPES
@personal_access_token = finder.build
@inactive_personal_access_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 0d891ef4004..1e557c47638 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -6,7 +6,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController
def update
begin
- if @user.update_attributes(preferences_params)
+ result = Users::UpdateService.new(user, preferences_params).execute
+
+ if result[:status] == :success
flash[:notice] = 'Preferences saved.'
else
flash[:alert] = 'Failed to save preferences.'
@@ -33,7 +35,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:color_scheme_id,
:layout,
:dashboard,
- :project_view,
+ :project_view
)
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index d3fa81cd623..1a4f77639e7 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -10,7 +10,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.otp_grace_period_started_at = Time.current
end
- current_user.save! if current_user.changed?
+ Users::UpdateService.new(current_user).execute!
if two_factor_authentication_required? && !current_user.two_factor_enabled?
two_factor_authentication_reason(
@@ -41,9 +41,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def create
if current_user.validate_and_consume_otp!(params[:pin_code])
- current_user.otp_required_for_login = true
- @codes = current_user.generate_otp_backup_codes!
- current_user.save!
+ Users::UpdateService.new(current_user, otp_required_for_login: true).execute! do |user|
+ @codes = user.generate_otp_backup_codes!
+ end
render 'create'
else
@@ -70,14 +70,15 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
def codes
- @codes = current_user.generate_otp_backup_codes!
- current_user.save!
+ Users::UpdateService.new(current_user).execute! do |user|
+ @codes = user.generate_otp_backup_codes!
+ end
end
def destroy
current_user.disable_two_factor!
- redirect_to profile_account_path
+ redirect_to profile_account_path, status: 302
end
def skip
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index c02fe85c3cc..e3d7737f44a 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -2,6 +2,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
+ redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device."
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 987b95e89b9..076076fd1b3 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -9,50 +9,67 @@ class ProfilesController < Profiles::ApplicationController
end
def update
- user_params.except!(:email) if @user.ldap_user?
+ user_params.except!(:email) if @user.external_email?
respond_to do |format|
- if @user.update_attributes(user_params)
+ result = Users::UpdateService.new(@user, user_params).execute
+
+ if result[:status] == :success
message = "Profile was successfully updated"
+
format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
format.json { render json: { message: message } }
else
- message = @user.errors.full_messages.uniq.join('. ')
- format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: "Failed to update profile. #{message}" }) }
- format.json { render json: { message: message }, status: :unprocessable_entity }
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: result[:message] }) }
+ format.json { render json: result }
end
end
end
def reset_private_token
- if current_user.reset_authentication_token!
- flash[:notice] = "Private token was successfully reset"
+ Users::UpdateService.new(@user).execute! do |user|
+ user.reset_authentication_token!
end
+ flash[:notice] = "Private token was successfully reset"
+
redirect_to profile_account_path
end
def reset_incoming_email_token
- if current_user.reset_incoming_email_token!
- flash[:notice] = "Incoming email token was successfully reset"
+ Users::UpdateService.new(@user).execute! do |user|
+ user.reset_incoming_email_token!
+ end
+
+ flash[:notice] = "Incoming email token was successfully reset"
+
+ redirect_to profile_account_path
+ end
+
+ def reset_rss_token
+ Users::UpdateService.new(@user).execute! do |user|
+ user.reset_rss_token!
end
+ flash[:notice] = "RSS token was successfully reset"
+
redirect_to profile_account_path
end
def audit_log
- @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id).
- order("created_at DESC").
- page(params[:page])
+ @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id)
+ .order("created_at DESC")
+ .page(params[:page])
end
def update_username
- if @user.update_attributes(username: user_params[:username])
- options = { notice: "Username successfully changed" }
- else
- message = @user.errors.full_messages.uniq.join('. ')
- options = { alert: "Username change failed - #{message}" }
- end
+ result = Users::UpdateService.new(@user, username: user_params[:username]).execute
+
+ options = if result[:status] == :success
+ { notice: "Username successfully changed" }
+ else
+ { alert: "Username change failed - #{result[:message]}" }
+ end
redirect_back_or_default(default: { action: 'show' }, options: options)
end
@@ -68,7 +85,7 @@ class ProfilesController < Profiles::ApplicationController
end
def user_params
- params.require(:user).permit(
+ @user_params ||= params.require(:user).permit(
:avatar,
:bio,
:email,
@@ -85,7 +102,8 @@ class ProfilesController < Profiles::ApplicationController
:twitter,
:username,
:website_url,
- :organization
+ :organization,
+ :preferred_language
)
end
end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index e2f81b09adc..221e01b415a 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -1,5 +1,8 @@
class Projects::ApplicationController < ApplicationController
+ include RoutableActions
+
skip_before_action :authenticate_user!
+ before_action :redirect_git_extension
before_action :project
before_action :repository
layout 'project'
@@ -8,40 +11,30 @@ class Projects::ApplicationController < ApplicationController
private
+ def redirect_git_extension
+ # Redirect from
+ # localhost/group/project.git
+ # to
+ # localhost/group/project
+ #
+ redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git'
+ end
+
def project
- unless @project
- namespace = params[:namespace_id]
- id = params[:project_id] || params[:id]
-
- # Redirect from
- # localhost/group/project.git
- # to
- # localhost/group/project
- #
- if params[:format] == 'git'
- redirect_to request.original_url.gsub(/\.git\/?\Z/, '')
- return
- end
-
- project_path = "#{namespace}/#{id}"
- @project = Project.find_by_full_path(project_path)
-
- if can?(current_user, :read_project, @project) && !@project.pending_delete?
- if @project.path_with_namespace != project_path
- redirect_to request.original_url.gsub(project_path, @project.path_with_namespace)
- end
- else
- @project = nil
-
- if current_user.nil?
- authenticate_user!
- else
- render_404
- end
- end
- end
+ return @project if @project
+ return nil unless params[:project_id] || params[:id]
- @project
+ path = File.join(params[:namespace_id], params[:project_id] || params[:id])
+ auth_proc = ->(project) { !project.pending_delete? }
+
+ @project = find_routable!(Project, path, extra_authorization_proc: auth_proc)
+ end
+
+ def build_canonical_path(project)
+ params[:namespace_id] = project.namespace.to_param
+ params[:project_id] = project.to_param
+
+ url_for(params)
end
def repository
@@ -55,13 +48,27 @@ class Projects::ApplicationController < ApplicationController
(current_user && current_user.already_forked?(project))
end
- def authorize_project!(action)
- return access_denied! unless can?(current_user, action, project)
+ def authorize_action!(action)
+ unless can?(current_user, action, project)
+ return access_denied!
+ end
+ end
+
+ def check_project_feature_available!(feature)
+ render_404 unless project.feature_available?(feature, current_user)
+ end
+
+ def check_issuables_available!
+ render_404 unless project.feature_available?(:issues, current_user) ||
+ project.feature_available?(:merge_requests, current_user)
end
def method_missing(method_sym, *arguments, &block)
- if method_sym.to_s =~ /\Aauthorize_(.*)!\z/
- authorize_project!($1.to_sym)
+ case method_sym.to_s
+ when /\Aauthorize_(.*)!\z/
+ authorize_action!($1.to_sym)
+ when /\Acheck_(.*)_available!\z/
+ check_project_feature_available!($1.to_sym)
else
super
end
@@ -70,13 +77,13 @@ class Projects::ApplicationController < ApplicationController
def require_non_empty_project
# Be sure to return status code 303 to avoid a double DELETE:
# http://api.rubyonrails.org/classes/ActionController/Redirecting.html
- redirect_to namespace_project_path(@project.namespace, @project), status: 303 if @project.empty_repo?
+ redirect_to project_path(@project), status: 303 if @project.empty_repo?
end
def require_branch_head
unless @repository.branch_exists?(@ref)
redirect_to(
- namespace_project_tree_path(@project.namespace, @project, @ref),
+ project_tree_path(@project, @ref),
notice: "This action is not allowed unless you are on a branch"
)
end
@@ -86,7 +93,7 @@ class Projects::ApplicationController < ApplicationController
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end
- def builds_enabled
- return render_404 unless @project.feature_available?(:builds, current_user)
+ def require_pages_enabled!
+ not_found unless Gitlab.config.pages.enabled
end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 59222637961..f637a9a803b 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -1,11 +1,13 @@
class Projects::ArtifactsController < Projects::ApplicationController
include ExtractsPath
+ include RendersBlob
layout 'project'
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
before_action :extract_ref_name_and_path
before_action :validate_artifacts!
+ before_action :set_path_and_entry, only: [:file, :raw]
def download
if artifacts_file.file_storage?
@@ -16,25 +18,35 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def browse
- directory = params[:path] ? "#{params[:path]}/" : ''
+ @path = params[:path]
+ directory = @path ? "#{@path}/" : ''
@entry = build.artifacts_metadata_entry(directory)
render_404 unless @entry.exists?
end
def file
- entry = build.artifacts_metadata_entry(params[:path])
+ blob = @entry.blob
+ conditionally_expand_blob(blob)
- if entry.exists?
- send_artifacts_entry(build, entry)
- else
- render_404
+ respond_to do |format|
+ format.html do
+ render 'file'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
end
end
+ def raw
+ send_artifacts_entry(build, @entry)
+ end
+
def keep
build.keep_artifacts!
- redirect_to namespace_project_build_path(project.namespace, project, build)
+ redirect_to project_job_path(project, build)
end
def latest_succeeded
@@ -60,11 +72,14 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def build
- @build ||= build_from_id || build_from_ref
+ @build ||= begin
+ build = build_from_id || build_from_ref
+ build&.present(current_user: current_user)
+ end
end
def build_from_id
- project.builds.find_by(id: params[:build_id]) if params[:build_id]
+ project.builds.find_by(id: params[:job_id]) if params[:job_id]
end
def build_from_ref
@@ -77,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
def artifacts_file
@artifacts_file ||= build.artifacts_file
end
+
+ def set_path_and_entry
+ @path = params[:path]
+ @entry = build.artifacts_metadata_entry(@path)
+
+ render_404 unless @entry.exists?
+ end
end
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 53788687076..21a403f3765 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController
@project.save
- redirect_to edit_project_path(@project)
+ redirect_to edit_project_path(@project), status: 302
end
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index 6c25cd83a24..06ba73d8e8d 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -3,11 +3,11 @@ class Projects::BadgesController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:index]
before_action :no_cache_headers, except: [:index]
- def build
- build_status = Gitlab::Badge::Build::Status
+ def pipeline
+ pipeline_status = Gitlab::Badge::Pipeline::Status
.new(project, params[:ref])
- render_badge build_status
+ render_badge pipeline_status
end
def coverage
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 9fce1db6742..2b8f3977e6e 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -2,6 +2,7 @@
class Projects::BlobController < Projects::ApplicationController
include ExtractsPath
include CreatesCommit
+ include RendersBlob
include ActionView::Helpers::SanitizeHelper
# Raised when given an invalid file path
@@ -25,22 +26,29 @@ class Projects::BlobController < Projects::ApplicationController
end
def create
- set_start_branch_to_branch_name
-
create_commit(Files::CreateService, success_notice: "The file has been successfully created.",
- success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @file_path)) },
+ success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
failure_view: :new,
- failure_path: namespace_project_new_blob_path(@project.namespace, @project, @ref))
+ failure_path: project_new_blob_path(@project, @ref))
end
def show
- environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
- @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ conditionally_expand_blob(@blob)
+
+ respond_to do |format|
+ format.html do
+ show_html
+ end
+
+ format.json do
+ show_json
+ end
+ end
end
def edit
if can_collaborate_with_project?
- blob.load_all_data!(@repository)
+ blob.load_all_data!
else
redirect_to action: 'show'
end
@@ -50,7 +58,7 @@ class Projects::BlobController < Projects::ApplicationController
@path = params[:file_path] if params[:file_path].present?
create_commit(Files::UpdateService, success_path: -> { after_edit_path },
failure_view: :edit,
- failure_path: namespace_project_blob_path(@project.namespace, @project, @id))
+ failure_path: project_blob_path(@project, @id))
rescue Files::UpdateService::FileChangedError
@conflict = true
@@ -59,7 +67,7 @@ class Projects::BlobController < Projects::ApplicationController
def preview
@content = params[:content]
- @blob.load_all_data!(@repository)
+ @blob.load_all_data!
diffy = Diffy::Diff.new(@blob.data, @content, diff: '-U 3', include_diff_info: true)
diff_lines = diffy.diff.scan(/.*\n/)[2..-1]
diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines)
@@ -70,17 +78,19 @@ class Projects::BlobController < Projects::ApplicationController
def destroy
create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.",
- success_path: -> { namespace_project_tree_path(@project.namespace, @project, @branch_name) },
+ success_path: -> { project_tree_path(@project, @branch_name) },
failure_view: :show,
- failure_path: namespace_project_blob_path(@project.namespace, @project, @id))
+ failure_path: project_blob_path(@project, @id))
end
def diff
apply_diff_view_cookie!
- @form = UnfoldForm.new(params)
- @lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path)
- @lines = @lines[@form.since - 1..@form.to - 1]
+ @blob.load_all_data!
+ @lines = Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: @repository).lines
+
+ @form = UnfoldForm.new(params)
+ @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe)
if @form.bottom?
@match_line = ''
@@ -96,14 +106,14 @@ class Projects::BlobController < Projects::ApplicationController
private
def blob
- @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path))
+ @blob ||= @repository.blob_at(@commit.id, @path)
if @blob
@blob
else
if tree = @repository.tree(@commit.id, @path)
if tree.entries.any?
- return redirect_to namespace_project_tree_path(@project.namespace, @project, File.join(@ref, @path))
+ return redirect_to project_tree_path(@project, File.join(@ref, @path))
end
end
@@ -128,10 +138,10 @@ class Projects::BlobController < Projects::ApplicationController
def after_edit_path
from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid])
if from_merge_request && @branch_name == @ref
- diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) +
+ diffs_project_merge_request_path(from_merge_request.target_project, from_merge_request) +
"##{hexdigest(@path)}"
else
- namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @path))
+ project_blob_path(@project, File.join(@branch_name, @path))
end
end
@@ -172,7 +182,42 @@ class Projects::BlobController < Projects::ApplicationController
end
def set_last_commit_sha
- @last_commit_sha = Gitlab::Git::Commit.
- last_for_path(@repository, @ref, @path).sha
+ @last_commit_sha = Gitlab::Git::Commit
+ .last_for_path(@repository, @ref, @path).sha
+ end
+
+ def show_html
+ environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
+ @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path)
+
+ render 'show'
+ end
+
+ def show_json
+ json = blob_json(@blob)
+ return render_404 unless json
+
+ path_segments = @path.split('/')
+ path_segments.pop
+ tree_path = path_segments.join('/')
+
+ render json: json.merge(
+ path: blob.path,
+ name: blob.name,
+ extension: blob.extension,
+ size: blob.raw_size,
+ mime_type: blob.mime_type,
+ binary: blob.raw_binary?,
+ simple_viewer: blob.simple_viewer&.class&.partial_name,
+ rich_viewer: blob.rich_viewer&.class&.partial_name,
+ show_viewer_switcher: !!blob.show_viewer_switcher?,
+ render_error: blob.simple_viewer&.render_error || blob.rich_viewer&.render_error,
+ raw_path: project_raw_path(project, @id),
+ blame_path: project_blame_path(project, @id),
+ commits_path: project_commits_path(project, @id),
+ tree_path: project_tree_path(project, File.join(@ref, tree_path)),
+ permalink: project_blob_path(project, File.join(@commit.id, @path))
+ )
end
end
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
index 28c9646910d..653e7bc7e40 100644
--- a/app/controllers/projects/boards/issues_controller.rb
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -66,7 +66,8 @@ module Projects
end
def filter_params
- params.merge(board_id: params[:board_id], id: params[:list_id]).compact
+ params.merge(board_id: params[:board_id], id: params[:list_id])
+ .reject { |_, value| value.nil? }
end
def move_params
@@ -82,7 +83,7 @@ module Projects
labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: {
- assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
index 67e3c9add81..ad53bb749a0 100644
--- a/app/controllers/projects/boards/lists_controller.rb
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -5,7 +5,9 @@ module Projects
before_action :authorize_read_list!, only: [:index]
def index
- render json: serialize_as_json(board.lists)
+ lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
+
+ render json: serialize_as_json(lists)
end
def create
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 840405f38cb..747768eefb1 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -8,12 +8,12 @@ class Projects::BranchesController < Projects::ApplicationController
before_action :authorize_push_code!, only: [:new, :create, :destroy, :destroy_all_merged]
def index
- @sort = params[:sort].presence || sort_value_name
+ @sort = params[:sort].presence || sort_value_recently_updated
@branches = BranchesFinder.new(@repository, params).execute
+ @branches = Kaminari.paginate_array(@branches).page(params[:page])
respond_to do |format|
format.html do
- paginate_branches
@refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name))
@max_commits = @branches.reduce(0) do |memo, branch|
@@ -22,7 +22,6 @@ class Projects::BranchesController < Projects::ApplicationController
end
end
format.json do
- paginate_branches unless params[:show_all]
render json: @branches.map(&:name)
end
end
@@ -38,47 +37,60 @@ class Projects::BranchesController < Projects::ApplicationController
redirect_to_autodeploy = project.empty_repo? && project.deployment_services.present?
- result = CreateBranchService.new(project, current_user).
- execute(branch_name, ref)
+ result = CreateBranchService.new(project, current_user)
+ .execute(branch_name, ref)
if params[:issue_iid]
issue = IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:issue_iid])
SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue
end
- if result[:status] == :success
- @branch = result[:branch]
+ respond_to do |format|
+ format.html do
+ if result[:status] == :success
+ if redirect_to_autodeploy
+ redirect_to url_to_autodeploy_setup(project, branch_name),
+ notice: view_context.autodeploy_flash_notice(branch_name)
+ else
+ redirect_to project_tree_path(@project, branch_name)
+ end
+ else
+ @error = result[:message]
+ render action: 'new'
+ end
+ end
- if redirect_to_autodeploy
- redirect_to(
- url_to_autodeploy_setup(project, branch_name),
- notice: view_context.autodeploy_flash_notice(branch_name))
- else
- redirect_to namespace_project_tree_path(@project.namespace, @project,
- @branch.name)
+ format.json do
+ if result[:status] == :success
+ render json: { name: branch_name, url: project_tree_url(@project, branch_name) }
+ else
+ render json: result[:messsage], status: :unprocessable_entity
+ end
end
- else
- @error = result[:message]
- render action: 'new'
end
end
def destroy
@branch_name = Addressable::URI.unescape(params[:id])
- status = DeleteBranchService.new(project, current_user).execute(@branch_name)
+ result = DeleteBranchService.new(project, current_user).execute(@branch_name)
+
respond_to do |format|
format.html do
- redirect_to namespace_project_branches_path(@project.namespace,
- @project), status: 303
+ flash_type = result[:status] == :error ? :alert : :notice
+ flash[flash_type] = result[:message]
+
+ redirect_to project_branches_path(@project), status: 303
end
- format.js { render nothing: true, status: status[:return_code] }
+
+ format.js { render nothing: true, status: result[:return_code] }
+ format.json { render json: { message: result[:message] }, status: result[:return_code] }
end
end
def destroy_all_merged
DeleteMergedBranchesService.new(@project, current_user).async_execute
- redirect_to namespace_project_branches_path(@project.namespace, @project),
+ redirect_to project_branches_path(@project),
notice: 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.'
end
@@ -93,13 +105,8 @@ class Projects::BranchesController < Projects::ApplicationController
end
end
- def paginate_branches
- @branches = Kaminari.paginate_array(@branches).page(params[:page])
- end
-
def url_to_autodeploy_setup(project, branch_name)
- namespace_project_new_blob_path(
- project.namespace,
+ project_new_blob_path(
project,
branch_name,
file_name: '.gitlab-ci.yml',
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
new file mode 100644
index 00000000000..b45e5d7ff43
--- /dev/null
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -0,0 +1,55 @@
+class Projects::BuildArtifactsController < Projects::ApplicationController
+ include ExtractsPath
+ include RendersBlob
+
+ before_action :authorize_read_build!
+ before_action :extract_ref_name_and_path
+ before_action :validate_artifacts!
+
+ def download
+ redirect_to download_project_job_artifacts_path(project, job)
+ end
+
+ def browse
+ redirect_to browse_project_job_artifacts_path(project, job, path: params[:path])
+ end
+
+ def file
+ redirect_to file_project_job_artifacts_path(project, job, path: params[:path])
+ end
+
+ def raw
+ redirect_to raw_project_job_artifacts_path(project, job, path: params[:path])
+ end
+
+ def latest_succeeded
+ redirect_to latest_succeeded_project_artifacts_path(project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job])
+ end
+
+ private
+
+ def validate_artifacts!
+ render_404 unless job && job.artifacts?
+ end
+
+ def extract_ref_name_and_path
+ return unless params[:ref_name_and_path]
+
+ @ref_name, @path = extract_ref(params[:ref_name_and_path])
+ end
+
+ def job
+ @job ||= job_from_id || job_from_ref
+ end
+
+ def job_from_id
+ project.builds.find_by(id: params[:build_id]) if params[:build_id]
+ end
+
+ def job_from_ref
+ return unless @ref_name
+
+ jobs = project.latest_successful_builds_for(@ref_name)
+ jobs.find_by(name: params[:job])
+ end
+end
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 04e8cdf6256..230b072dcea 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -1,112 +1,21 @@
class Projects::BuildsController < Projects::ApplicationController
- before_action :build, except: [:index, :cancel_all]
- before_action :authorize_read_build!, except: [:cancel, :cancel_all, :retry, :play]
- before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace]
- layout 'project'
+ before_action :authorize_read_build!
def index
- @scope = params[:scope]
- @all_builds = project.builds.relevant
- @builds = @all_builds.order('created_at DESC')
- @builds =
- case @scope
- when 'pending'
- @builds.pending.reverse_order
- when 'running'
- @builds.running.reverse_order
- when 'finished'
- @builds.finished
- else
- @builds
- end
- @builds = @builds.includes([
- { pipeline: :project },
- :project,
- :tags
- ])
- @builds = @builds.page(params[:page]).per(30)
- end
-
- def cancel_all
- @project.builds.running_or_pending.each(&:cancel)
- redirect_to namespace_project_builds_path(project.namespace, project)
+ redirect_to project_jobs_path(project)
end
def show
- @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
- @builds = @builds.where("id not in (?)", @build.id)
- @pipeline = @build.pipeline
- end
-
- def trace
- build.trace.read do |stream|
- respond_to do |format|
- format.json do
- result = {
- id: @build.id, status: @build.status, complete: @build.complete?
- }
-
- if stream.valid?
- stream.limit
- state = params[:state].presence
- trace = stream.html_with_state(state)
- result.merge!(trace.to_h)
- end
-
- render json: result
- end
- end
- end
- end
-
- def retry
- return render_404 unless @build.retryable?
-
- build = Ci::Build.retry(@build, current_user)
- redirect_to build_path(build)
- end
-
- def play
- return render_404 unless @build.playable?
-
- build = @build.play(current_user)
- redirect_to build_path(build)
- end
-
- def cancel
- @build.cancel
- redirect_to build_path(@build)
- end
-
- def status
- render json: BuildSerializer
- .new(project: @project, user: @current_user)
- .represent_status(@build)
- end
-
- def erase
- @build.erase(erased_by: current_user)
- redirect_to namespace_project_build_path(project.namespace, project, @build),
- notice: "Build has been successfully erased!"
+ redirect_to project_job_path(project, job)
end
def raw
- build.trace.read do |stream|
- if stream.file?
- send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
- else
- render_404
- end
- end
+ redirect_to raw_project_job_path(project, job)
end
private
- def build
- @build ||= project.builds.find_by!(id: params[:id]).present(current_user: current_user)
- end
-
- def build_path(build)
- namespace_project_build_path(build.project.namespace, build.project, build)
+ def job
+ @job ||= project.builds.find(params[:id])
end
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 2b5f0383ac1..6de125e7e80 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -38,9 +38,14 @@ class Projects::CommitController < Projects::ApplicationController
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: PipelineSerializer
- .new(project: @project, user: @current_user)
- .represent(@pipelines)
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines),
+ count: {
+ all: @pipelines.count
+ }
+ }
end
end
end
@@ -80,16 +85,16 @@ class Projects::CommitController < Projects::ApplicationController
end
def successful_change_path
- referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @branch_name)
+ referenced_merge_request_url || project_commits_url(@project, @branch_name)
end
def failed_change_path
- referenced_merge_request_url || namespace_project_commit_url(@project.namespace, @project, params[:id])
+ referenced_merge_request_url || project_commit_url(@project, params[:id])
end
def referenced_merge_request_url
if merge_request = @commit.merged_merge_request(current_user)
- namespace_project_merge_request_url(merge_request.target_project.namespace, merge_request.target_project, merge_request)
+ project_merge_request_url(merge_request.target_project, merge_request)
end
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index ad92f05a42d..2de9900d449 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -6,27 +6,18 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
+ before_action :set_commits
def show
- @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
- search = params[:search]
-
- @commits =
- if search.present?
- @repository.find_commits_by_message(search, @ref, @path, @limit, @offset)
- else
- @repository.commits(@ref, path: @path, limit: @limit, offset: @offset)
- end
+ @note_counts = project.notes.where(commit_id: @commits.map(&:id))
+ .group(:commit_id).count
- @note_counts = project.notes.where(commit_id: @commits.map(&:id)).
- group(:commit_id).count
-
- @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.
- find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref)
+ @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
+ .find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref)
respond_to do |format|
format.html
- format.atom { render layout: false }
+ format.atom { render layout: 'xml.atom' }
format.json do
pager_json(
@@ -37,4 +28,33 @@ class Projects::CommitsController < Projects::ApplicationController
end
end
end
+
+ def signatures
+ respond_to do |format|
+ format.json do
+ render json: {
+ signatures: @commits.select(&:has_signature?).map do |commit|
+ {
+ commit_sha: commit.sha,
+ html: view_to_html_string('projects/commit/_signature', signature: commit.signature)
+ }
+ end
+ }
+ end
+ end
+ end
+
+ private
+
+ def set_commits
+ @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
+ search = params[:search]
+
+ @commits =
+ if search.present?
+ @repository.find_commits_by_message(search, @ref, @path, @limit, @offset)
+ else
+ @repository.commits(@ref, path: @path, limit: @limit, offset: @offset)
+ end
+ end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 008d2f5815f..c8613c0d634 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -31,9 +31,9 @@ class Projects::CompareController < Projects::ApplicationController
from: params[:from].presence,
to: params[:to].presence
}
- redirect_to namespace_project_compare_index_path(@project.namespace, @project, from_to_vars)
+ redirect_to project_compare_index_path(@project, from_to_vars)
else
- redirect_to namespace_project_compare_path(@project.namespace, @project,
+ redirect_to project_compare_path(@project,
params[:from], params[:to])
end
end
@@ -51,13 +51,9 @@ class Projects::CompareController < Projects::ApplicationController
if @compare
@commits = @compare.commits
- @start_commit = @compare.start_commit
- @commit = @compare.commit
- @base_commit = @compare.base_commit
-
@diffs = @compare.diffs(diff_options)
- environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit }
+ environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
@diff_notes_disabled = true
@@ -65,7 +61,7 @@ class Projects::CompareController < Projects::ApplicationController
end
def merge_request
- @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.
- find_by(source_project: @project, source_branch: @head_ref, target_branch: @start_ref)
+ @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
+ .find_by(source_project: @project, source_branch: @head_ref, target_branch: @start_ref)
end
end
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index b69d46f2c41..26f3c114108 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -2,7 +2,7 @@ module Projects
module CycleAnalytics
class EventsController < Projects::ApplicationController
include CycleAnalyticsParams
-
+
before_action :authorize_read_cycle_analytics!
before_action :authorize_read_build!, only: [:test, :staging]
before_action :authorize_read_issue!, only: [:issue, :production]
@@ -11,33 +11,33 @@ module Projects
def issue
render_events(cycle_analytics[:issue].events)
end
-
+
def plan
render_events(cycle_analytics[:plan].events)
end
-
+
def code
render_events(cycle_analytics[:code].events)
end
-
+
def test
options(events_params)[:branch] = events_params[:branch_name]
-
+
render_events(cycle_analytics[:test].events)
end
-
+
def review
render_events(cycle_analytics[:review].events)
end
-
+
def staging
render_events(cycle_analytics[:staging].events)
end
-
+
def production
render_events(cycle_analytics[:production].events)
end
-
+
private
def render_events(events)
@@ -46,14 +46,14 @@ module Projects
format.json { render json: { events: events } }
end
end
-
+
def cycle_analytics
@cycle_analytics ||= ::CycleAnalytics.new(project, options(events_params))
end
-
+
def events_params
return {} unless params[:events].present?
-
+
params[:events].permit(:start_date, :branch_name)
end
end
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index d0c44e297e3..c2e621fa190 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -4,11 +4,17 @@ class Projects::DeployKeysController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
+ before_action :authorize_update_deploy_key!, only: [:edit, :update]
- layout "project_settings"
+ layout 'project_settings'
def index
- redirect_to_repository_settings(@project)
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json do
+ render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json
+ end
+ end
end
def new
@@ -16,18 +22,33 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKey.new(deploy_key_params.merge(user: current_user))
+ @key = DeployKey.new(create_params.merge(user: current_user))
unless @key.valid? && @project.deploy_keys << @key
- flash[:alert] = @key.errors.full_messages.join(', ').html_safe
+ flash[:alert] = @key.errors.full_messages.join(', ').html_safe
end
redirect_to_repository_settings(@project)
end
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to_repository_settings(@project)
+ else
+ render 'edit'
+ end
+ end
+
def enable
Projects::EnableDeployKeyService.new(@project, current_user, params).execute
- redirect_to_repository_settings(@project)
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json { head :ok }
+ end
end
def disable
@@ -35,12 +56,28 @@ class Projects::DeployKeysController < Projects::ApplicationController
return render_404 unless deploy_key_project
deploy_key_project.destroy!
- redirect_to_repository_settings(@project)
+
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.json { head :ok }
+ end
end
protected
- def deploy_key_params
+ def deploy_key
+ @deploy_key ||= DeployKey.find(params[:id])
+ end
+
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
+
+ def authorize_update_deploy_key!
+ access_denied! unless can?(current_user, :update_deploy_key, deploy_key)
+ end
end
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
new file mode 100644
index 00000000000..47c312ffddf
--- /dev/null
+++ b/app/controllers/projects/deployments_controller.rb
@@ -0,0 +1,50 @@
+class Projects::DeploymentsController < Projects::ApplicationController
+ before_action :authorize_read_environment!
+ before_action :authorize_read_deployment!
+
+ def index
+ deployments = environment.deployments.reorder(created_at: :desc)
+ deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time
+
+ render json: { deployments: DeploymentSerializer.new(project: project)
+ .represent_concise(deployments) }
+ end
+
+ def metrics
+ return render_404 unless deployment.has_metrics?
+ @metrics = deployment.metrics
+ if @metrics&.any?
+ render json: @metrics, status: :ok
+ else
+ head :no_content
+ end
+ rescue NotImplementedError
+ render_404
+ end
+
+ def additional_metrics
+ return render_404 unless deployment.has_additional_metrics?
+
+ respond_to do |format|
+ format.json do
+ metrics = deployment.additional_metrics
+
+ if metrics.any?
+ render json: metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
+ private
+
+ def deployment
+ @deployment ||= environment.deployments.find_by(iid: params[:id])
+ end
+
+ def environment
+ @environment ||= project.environments.find(params[:environment_id])
+ end
+end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index f4a18a5e8f7..2e6ab7903b8 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -1,5 +1,5 @@
class Projects::DiscussionsController < Projects::ApplicationController
- before_action :module_enabled
+ before_action :check_merge_requests_available!
before_action :merge_request
before_action :discussion
before_action :authorize_resolve_discussion!
@@ -34,8 +34,4 @@ class Projects::DiscussionsController < Projects::ApplicationController
def authorize_resolve_discussion!
access_denied! unless discussion.can_resolve?(current_user)
end
-
- def module_enabled
- render_404 unless @project.feature_available?(:merge_requests, current_user)
- end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index fa37963dfd4..29e223a5273 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -15,9 +15,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
render json: {
environments: EnvironmentSerializer
- .new(project: @project, user: @current_user)
+ .new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.within_folders
.represent(@environments),
@@ -31,13 +33,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def folder
folder_environments = project.environments.where(environment_type: params[:id])
@environments = folder_environments.with_state(params[:scope] || :available)
+ .order(:name)
respond_to do |format|
format.html
format.json do
render json: {
environments: EnvironmentSerializer
- .new(project: @project, user: @current_user)
+ .new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.represent(@environments),
available_count: folder_environments.available.count,
@@ -62,7 +65,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment = project.environments.create(environment_params)
if @environment.persisted?
- redirect_to namespace_project_environment_path(project.namespace, project, @environment)
+ redirect_to project_environment_path(project, @environment)
else
render :new
end
@@ -70,7 +73,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def update
if @environment.update(environment_params)
- redirect_to namespace_project_environment_path(project.namespace, project, @environment)
+ redirect_to project_environment_path(project, @environment)
else
render :edit
end
@@ -81,10 +84,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
stop_action = @environment.stop_with_action!(current_user)
- if stop_action
- redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action])
- else
- redirect_to namespace_project_environment_path(project.namespace, project, @environment)
+ action_or_env_url =
+ if stop_action
+ polymorphic_url([project.namespace.becomes(Namespace), project, stop_action])
+ else
+ project_environment_url(project, @environment)
+ end
+
+ respond_to do |format|
+ format.html { redirect_to action_or_env_url }
+ format.json { render json: { redirect_url: action_or_env_url } }
end
end
@@ -122,6 +131,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def additional_metrics
+ respond_to do |format|
+ format.json do
+ additional_metrics = environment.additional_metrics || {}
+
+ render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 1eb3800e49d..3f83bef2c79 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -44,12 +44,12 @@ class Projects::ForksController < Projects::ApplicationController
if @forked_project.saved? && @forked_project.forked?
if @forked_project.import_in_progress?
- redirect_to namespace_project_import_path(@forked_project.namespace, @forked_project, continue: continue_params)
+ redirect_to project_import_path(@forked_project, continue: continue_params)
else
if continue_params
redirect_to continue_params[:to], notice: continue_params[:notice]
else
- redirect_to namespace_project_path(@forked_project.namespace, @forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
+ redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
end
end
else
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 9a1bf037a95..7d0e2b3e2ef 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -4,7 +4,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
include ActionController::HttpAuthentication::Basic
include KerberosSpnegoHelper
- attr_reader :authentication_result
+ attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
@@ -14,7 +14,6 @@ class Projects::GitHttpClientController < Projects::ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :repository
before_action :authenticate_user
- before_action :ensure_project_found!
private
@@ -68,43 +67,19 @@ class Projects::GitHttpClientController < Projects::ApplicationController
headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
end
- def ensure_project_found!
- render_not_found if project.blank?
- end
-
def project
- return @project if defined?(@project)
-
- project_id, _ = project_id_with_suffix
- @project =
- if project_id.blank?
- nil
- else
- Project.find_by_full_path("#{params[:namespace_id]}/#{project_id}")
- end
- end
+ parse_repo_path unless defined?(@project)
- # This method returns two values so that we can parse
- # params[:project_id] (untrusted input!) in exactly one place.
- def project_id_with_suffix
- id = params[:project_id] || ''
-
- %w[.wiki.git .git].each do |suffix|
- if id.end_with?(suffix)
- # Be careful to only remove the suffix from the end of 'id'.
- # Accidentally removing it from the middle is how security
- # vulnerabilities happen!
- return [id.slice(0, id.length - suffix.length), suffix]
- end
- end
+ @project
+ end
- # Something is wrong with params[:project_id]; do not pass it on.
- [nil, nil]
+ def parse_repo_path
+ @project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
end
def render_missing_personal_token
render plain: "HTTP Basic: Access denied\n" \
- "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
+ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: 401
end
@@ -114,46 +89,19 @@ class Projects::GitHttpClientController < Projects::ApplicationController
end
def wiki?
- return @wiki if defined?(@wiki)
+ parse_repo_path unless defined?(@wiki)
- _, suffix = project_id_with_suffix
- @wiki = suffix == '.wiki.git'
- end
-
- def render_not_found
- render plain: 'Not Found', status: :not_found
+ @wiki
end
def handle_basic_authentication(login, password)
@authentication_result = Gitlab::Auth.find_for_git_client(
login, password, project: project, ip: request.ip)
- return false unless @authentication_result.success?
-
- if download_request?
- authentication_has_download_access?
- else
- authentication_has_upload_access?
- end
+ @authentication_result.success?
end
def ci?
authentication_result.ci?(project)
end
-
- def authentication_has_download_access?
- has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code)
- end
-
- def authentication_has_upload_access?
- has_authentication_ability?(:push_code)
- end
-
- def has_authentication_ability?(capability)
- (authentication_abilities || []).include?(capability)
- end
-
- def authentication_project
- authentication_result.project
- end
end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 10adddb4636..71ae60cb8cd 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -1,38 +1,27 @@
class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
+ before_action :access_check
+
+ rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
+ rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
- if upload_pack? && upload_pack_allowed?
- log_user_activity
-
- render_ok
- elsif receive_pack? && receive_pack_allowed?
- render_ok
- elsif http_blocked?
- render_http_not_allowed
- else
- render_denied
- end
+ log_user_activity if upload_pack?
+
+ render_ok
end
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- if upload_pack? && upload_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
# POST /foo/bar.git/git-receive-pack" (git push)
def git_receive_pack
- if receive_pack? && receive_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
private
@@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController
git_command == 'git-upload-pack'
end
- def receive_pack?
- git_command == 'git-receive-pack'
- end
-
def git_command
if action_name == 'info_refs'
params[:service]
@@ -59,50 +44,30 @@ class Projects::GitHttpController < Projects::GitHttpClientController
def render_ok
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.git_http_ok(repository, user, action_name)
+ render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
end
- def render_http_not_allowed
- render plain: access_check.message, status: :forbidden
+ def render_403(exception)
+ render plain: exception.message, status: :forbidden
end
- def render_denied
- if user && can?(user, :read_project, project)
- render plain: access_denied_message, status: :forbidden
- else
- # Do not leak information about project existence
- render_not_found
- end
- end
-
- def access_denied_message
- 'Access denied'
+ def render_404(exception)
+ render plain: exception.message, status: :not_found
end
- def upload_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.upload_pack
-
- access_check.allowed? || ci?
+ def access
+ @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, redirected_path: redirected_path)
end
- def access
- @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities)
+ def access_actor
+ return user if user
+ return :ci if ci?
end
def access_check
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
- @access_check ||= access.check(git_command, '_any')
- end
-
- def http_blocked?
- !access.protocol_allowed?
- end
-
- def receive_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.receive_pack
-
- access_check.allowed?
+ access.check(git_command, '_any')
end
def access_klass
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 43fc0c39801..475d4c86294 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -5,7 +5,6 @@ class Projects::GraphsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
- before_action :builds_enabled, only: :ci
def show
respond_to do |format|
@@ -30,7 +29,7 @@ class Projects::GraphsController < Projects::ApplicationController
end
def ci
- redirect_to charts_namespace_project_pipelines_path(@project.namespace, @project)
+ redirect_to charts_project_pipelines_path(@project)
end
private
@@ -44,23 +43,7 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_languages
- @languages = Linguist::Repository.new(@repository.rugged, @repository.rugged.head.target_id).languages
- total = @languages.map(&:last).sum
-
- @languages = @languages.map do |language|
- name, share = language
- color = Linguist::Language[name].color || "##{Digest::SHA256.hexdigest(name)[0...6]}"
- {
- value: (share.to_f * 100 / total).round(2),
- label: name,
- color: color,
- highlight: color
- }
- end
-
- @languages.sort! do |x, y|
- y[:value] <=> x[:value]
- end
+ @languages = @project.repository.languages
end
def fetch_graph
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 66b7bdbd988..f59200d3b1f 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -22,7 +22,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
flash[:alert] = 'Please select a group.'
end
- redirect_to namespace_project_settings_members_path(project.namespace, project)
+ redirect_to project_project_members_path(project)
end
def update
@@ -36,7 +36,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to namespace_project_settings_members_path(project.namespace, project)
+ redirect_to project_project_members_path(project), status: 302
end
format.js { head :ok }
end
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
new file mode 100644
index 00000000000..745e89fc843
--- /dev/null
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -0,0 +1,33 @@
+class Projects::HookLogsController < Projects::ApplicationController
+ include HooksExecution
+
+ before_action :authorize_admin_project!
+
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ layout 'project_settings'
+
+ def show
+ end
+
+ def retry
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
+
+ set_hook_execution_notice(result)
+
+ redirect_to edit_project_hook_path(@project, @hook)
+ end
+
+ private
+
+ def hook
+ @hook ||= @project.hooks.find(params[:hook_id])
+ end
+
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 1e41f980f31..85d35900c71 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -1,11 +1,18 @@
class Projects::HooksController < Projects::ApplicationController
+ include HooksExecution
+
# Authorize
before_action :authorize_admin_project!
+ before_action :hook_logs, only: :edit
respond_to :html
layout "project_settings"
+ def index
+ redirect_to project_settings_integrations_path(@project)
+ end
+
def create
@hook = @project.hooks.new(hook_params)
@hook.save
@@ -14,23 +21,25 @@ class Projects::HooksController < Projects::ApplicationController
@hooks = @project.hooks.select(&:persisted?)
flash[:alert] = @hook.errors.full_messages.join.html_safe
end
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ redirect_to project_settings_integrations_path(@project)
end
- def test
- if !@project.empty_repo?
- status, message = TestHookService.new.execute(hook, current_user)
-
- if status && status >= 200 && status < 400
- flash[:notice] = "Hook executed successfully: HTTP #{status}"
- elsif status
- flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}"
- else
- flash[:alert] = "Hook execution failed: #{message}"
- end
+ def edit
+ end
+
+ def update
+ if hook.update_attributes(hook_params)
+ flash[:notice] = 'Hook was successfully updated.'
+ redirect_to project_settings_integrations_path(@project)
else
- flash[:alert] = 'Hook execution failed. Ensure the project has commits.'
+ render 'edit'
end
+ end
+
+ def test
+ result = TestHooks::ProjectService.new(hook, current_user, params[:trigger]).execute
+
+ set_hook_execution_notice(result)
redirect_back_or_default(default: { action: 'index' })
end
@@ -38,7 +47,7 @@ class Projects::HooksController < Projects::ApplicationController
def destroy
hook.destroy
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ redirect_to project_settings_integrations_path(@project), status: 302
end
private
@@ -47,6 +56,11 @@ class Projects::HooksController < Projects::ApplicationController
@hook ||= @project.hooks.find(params[:id])
end
+ def hook_logs
+ @hook_logs ||=
+ Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
+ end
+
def hook_params
params.require(:hook).permit(
:job_events,
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index a1b84afcd91..49aa32119ef 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -14,17 +14,10 @@ class Projects::ImportsController < Projects::ApplicationController
@project.import_url = params[:project][:import_url]
if @project.save
- @project.reload
-
- if @project.import_failed?
- @project.import_retry
- else
- @project.import_start
- @project.add_import_job
- end
+ @project.reload.import_schedule
end
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project)
end
def show
@@ -32,10 +25,10 @@ class Projects::ImportsController < Projects::ApplicationController
if continue_params
redirect_to continue_params[:to], notice: continue_params[:notice]
else
- redirect_to namespace_project_path(@project.namespace, @project), notice: finished_notice
+ redirect_to project_path(@project), notice: finished_notice
end
elsif @project.import_failed?
- redirect_to new_namespace_project_import_path(@project.namespace, @project)
+ redirect_to new_project_import_path(@project)
else
if continue_params && continue_params[:notice_now]
flash.now[:notice] = continue_params[:notice_now]
@@ -57,19 +50,19 @@ class Projects::ImportsController < Projects::ApplicationController
def require_no_repo
if @project.repository_exists?
- redirect_to namespace_project_path(@project.namespace, @project)
+ redirect_to project_path(@project)
end
end
def redirect_if_progress
if @project.import_in_progress?
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project)
end
end
def redirect_if_no_import
if @project.repository_exists? && @project.no_import?
- redirect_to namespace_project_path(@project.namespace, @project)
+ redirect_to project_path(@project)
end
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index cbf67137261..8893a514207 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,13 +8,8 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new]
- before_action :redirect_to_external_issue_tracker, only: [:index, :new]
- before_action :module_enabled
- before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
- :related_branches, :can_create_branch, :rendered_title]
-
- # Allow read any issue
- before_action :authorize_read_issue!, only: [:show, :rendered_title]
+ before_action :check_issues_available!
+ before_action :issue, except: [:index, :new, :create, :bulk_update]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -22,6 +17,9 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow modify issue
before_action :authorize_update_issue!, only: [:edit, :update]
+ # Allow create a new branch and empty WIP merge request from current issue
+ before_action :authorize_create_merge_request!, only: [:create_merge_request]
+
respond_to :html
def index
@@ -52,7 +50,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
- format.atom { render layout: false }
+ format.atom { render layout: 'xml.atom' }
format.json do
render json: {
html: view_to_html_string("projects/issues/_issues"),
@@ -64,7 +62,7 @@ class Projects::IssuesController < Projects::ApplicationController
def new
params[:issue] ||= ActionController::Parameters.new(
- assignee_id: ""
+ assignee_ids: ""
)
build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -145,10 +143,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
- render json: @issue.to_json(methods: [:task_status, :task_status_short],
- include: { milestone: {},
- assignee: { only: [:name, :username], methods: [:avatar_url] },
- labels: { methods: :text_color } })
+ render json: IssueSerializer.new.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
@@ -191,75 +186,94 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.json do
- render json: { can_create_branch: can_create }
+ render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? }
end
end
end
- def rendered_title
+ def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: { title: view_context.markdown_field(@issue, :title) }
+
+ response = {
+ title: view_context.markdown_field(@issue, :title),
+ title_text: @issue.title,
+ description: view_context.markdown_field(@issue, :description),
+ description_text: @issue.description,
+ task_status: @issue.task_status
+ }
+
+ if @issue.is_edited?
+ response[:updated_at] = @issue.updated_at
+ response[:updated_by_name] = @issue.last_edited_by.name
+ response[:updated_by_path] = user_path(@issue.last_edited_by)
+ end
+
+ render json: response
+ end
+
+ def create_merge_request
+ result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
+
+ if result[:status] == :success
+ render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
+ else
+ render json: result[:messsage], status: :unprocessable_entity
+ end
end
protected
def issue
+ return @issue if defined?(@issue)
# The Sortable default scope causes performance issues when used with find_by
- @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old
+ @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
+
+ return render_404 unless can?(current_user, :read_issue, @issue)
+
+ @issue
end
alias_method :subscribable_resource, :issue
alias_method :issuable, :issue
alias_method :awardable, :issue
alias_method :spammable, :issue
- def authorize_read_issue!
- return render_404 unless can?(current_user, :read_issue, @issue)
+ def spammable_path
+ project_issue_path(@project, @issue)
end
def authorize_update_issue!
- return render_404 unless can?(current_user, :update_issue, @issue)
+ render_404 unless can?(current_user, :update_issue, @issue)
end
def authorize_admin_issues!
- return render_404 unless can?(current_user, :admin_issue, @project)
+ render_404 unless can?(current_user, :admin_issue, @project)
end
- def module_enabled
- return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker?
+ def authorize_create_merge_request!
+ render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
end
- def redirect_to_external_issue_tracker
- external = @project.external_issue_tracker
-
- return unless external
-
- if action_name == 'new'
- redirect_to external.new_issue_path
- else
- redirect_to external.project_path
- end
+ def check_issues_available!
+ return render_404 unless @project.feature_available?(:issues, current_user)
end
- # Since iids are implemented only in 6.1
- # user may navigate to issue page using old global ids.
- #
- # To prevent 404 errors we provide a redirect to correct iids until 7.0 release
- #
- def redirect_old
- issue = @project.issues.find_by(id: params[:id])
-
- if issue
- redirect_to issue_path(issue)
- else
- raise ActiveRecord::RecordNotFound.new
- end
+ def issue_params
+ params.require(:issue).permit(*issue_params_attributes)
end
- def issue_params
- params.require(:issue).permit(
- :title, :assignee_id, :position, :description, :confidential,
- :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: []
- )
+ def issue_params_attributes
+ %i[
+ title
+ assignee_id
+ position
+ description
+ confidential
+ milestone_id
+ due_date
+ state_event
+ task_num
+ lock_version
+ ] + [{ label_ids: [], assignee_ids: [] }]
end
def authenticate_user!
@@ -267,7 +281,10 @@ class Projects::IssuesController < Projects::ApplicationController
notice = "Please sign in to create the new issue."
- store_location_for :user, request.fullpath
+ if request.get? && !request.xhr?
+ store_location_for :user, request.fullpath
+ end
+
redirect_to new_user_session_path, notice: notice
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
new file mode 100644
index 00000000000..96abdac91b6
--- /dev/null
+++ b/app/controllers/projects/jobs_controller.rb
@@ -0,0 +1,142 @@
+class Projects::JobsController < Projects::ApplicationController
+ before_action :build, except: [:index, :cancel_all]
+
+ before_action :authorize_read_build!,
+ only: [:index, :show, :status, :raw, :trace]
+ before_action :authorize_update_build!,
+ except: [:index, :show, :status, :raw, :trace, :cancel_all]
+
+ layout 'project'
+
+ def index
+ @scope = params[:scope]
+ @all_builds = project.builds.relevant
+ @builds = @all_builds.order('created_at DESC')
+ @builds =
+ case @scope
+ when 'pending'
+ @builds.pending.reverse_order
+ when 'running'
+ @builds.running.reverse_order
+ when 'finished'
+ @builds.finished
+ else
+ @builds
+ end
+ @builds = @builds.includes([
+ { pipeline: :project },
+ :project,
+ :tags
+ ])
+ @builds = @builds.page(params[:page]).per(30)
+ end
+
+ def cancel_all
+ return access_denied! unless can?(current_user, :update_build, project)
+
+ @project.builds.running_or_pending.each do |build|
+ build.cancel if can?(current_user, :update_build, build)
+ end
+
+ redirect_to project_jobs_path(project)
+ end
+
+ def show
+ @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
+ @builds = @builds.where("id not in (?)", @build.id)
+ @pipeline = @build.pipeline
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@build, {}, BuildDetailsEntity)
+ end
+ end
+ end
+
+ def trace
+ build.trace.read do |stream|
+ respond_to do |format|
+ format.json do
+ result = {
+ id: @build.id, status: @build.status, complete: @build.complete?
+ }
+
+ if stream.valid?
+ stream.limit
+ state = params[:state].presence
+ trace = stream.html_with_state(state)
+ result.merge!(trace.to_h)
+ end
+
+ render json: result
+ end
+ end
+ end
+ end
+
+ def retry
+ return respond_422 unless @build.retryable?
+
+ build = Ci::Build.retry(@build, current_user)
+ redirect_to build_path(build)
+ end
+
+ def play
+ return respond_422 unless @build.playable?
+
+ build = @build.play(current_user)
+ redirect_to build_path(build)
+ end
+
+ def cancel
+ return respond_422 unless @build.cancelable?
+
+ @build.cancel
+ redirect_to build_path(@build)
+ end
+
+ def status
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent_status(@build)
+ end
+
+ def erase
+ if @build.erase(erased_by: current_user)
+ redirect_to project_job_path(project, @build),
+ notice: "Build has been successfully erased!"
+ else
+ respond_422
+ end
+ end
+
+ def raw
+ build.trace.read do |stream|
+ if stream.file?
+ send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
+ else
+ render_404
+ end
+ end
+ end
+
+ private
+
+ def authorize_update_build!
+ return access_denied! unless can?(current_user, :update_build, build)
+ end
+
+ def build
+ @build ||= project.builds.find(params[:id])
+ .present(current_user: current_user)
+ end
+
+ def build_path(build)
+ project_job_path(build.project, build)
+ end
+end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 2f55ba4e700..480a2dff262 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -1,14 +1,14 @@
class Projects::LabelsController < Projects::ApplicationController
include ToggleSubscriptionAction
- before_action :module_enabled
+ before_action :check_issuables_available!
before_action :label, only: [:edit, :update, :destroy, :promote]
before_action :find_labels, only: [:index, :set_priorities, :remove_priority, :toggle_subscription]
before_action :authorize_read_label!
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update,
:generate, :destroy, :remove_priority,
:set_priorities]
- before_action :authorize_admin_group!, only: [:promote]
+ before_action :authorize_admin_group_labels!, only: [:promote]
respond_to :js, :html
@@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: @available_labels.as_json(only: [:id, :title, :color])
+ render json: LabelSerializer.new.represent_appearance(@available_labels)
end
end
end
@@ -33,7 +33,7 @@ class Projects::LabelsController < Projects::ApplicationController
if @label.valid?
respond_to do |format|
- format.html { redirect_to namespace_project_labels_path(@project.namespace, @project) }
+ format.html { redirect_to project_labels_path(@project) }
format.json { render json: @label }
end
else
@@ -51,7 +51,7 @@ class Projects::LabelsController < Projects::ApplicationController
@label = Labels::UpdateService.new(label_params).execute(@label)
if @label.valid?
- redirect_to namespace_project_labels_path(@project.namespace, @project)
+ redirect_to project_labels_path(@project)
else
render :edit
end
@@ -61,12 +61,11 @@ class Projects::LabelsController < Projects::ApplicationController
Gitlab::IssuesLabels.generate(@project)
if params[:redirect] == 'issues'
- redirect_to namespace_project_issues_path(@project.namespace, @project)
+ redirect_to project_issues_path(@project)
elsif params[:redirect] == 'merge_requests'
- redirect_to namespace_project_merge_requests_path(@project.namespace,
- @project)
+ redirect_to project_merge_requests_path(@project)
else
- redirect_to namespace_project_labels_path(@project.namespace, @project)
+ redirect_to project_labels_path(@project)
end
end
@@ -74,7 +73,9 @@ class Projects::LabelsController < Projects::ApplicationController
@label.destroy
@labels = find_labels
- redirect_to(namespace_project_labels_path(@project.namespace, @project), notice: 'Label was removed')
+ redirect_to project_labels_path(@project),
+ status: 302,
+ notice: 'Label was removed'
end
def remove_priority
@@ -112,7 +113,7 @@ class Projects::LabelsController < Projects::ApplicationController
return render_404 unless promote_service.execute(@label)
respond_to do |format|
format.html do
- redirect_to(namespace_project_labels_path(@project.namespace, @project),
+ redirect_to(project_labels_path(@project),
notice: 'Label was promoted to a Group Label')
end
format.js
@@ -123,7 +124,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_labels_path(@project.namespace, @project),
+ redirect_to(project_labels_path(@project),
notice: 'Failed to promote label due to internal error. Please contact administrators.')
end
format.js
@@ -133,12 +134,6 @@ class Projects::LabelsController < Projects::ApplicationController
protected
- def module_enabled
- unless @project.feature_available?(:issues, current_user) || @project.feature_available?(:merge_requests, current_user)
- return render_404
- end
- end
-
def label_params
params.require(:label).permit(:title, :description, :color)
end
@@ -159,7 +154,7 @@ class Projects::LabelsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_label, @project)
end
- def authorize_admin_group!
- return render_404 unless can?(current_user, :admin_group, @project.group)
+ def authorize_admin_group_labels!
+ return render_404 unless can?(current_user, :admin_label, @project.group)
end
end
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index 8a5a645ed0e..1b0d3aab3fa 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -22,7 +22,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
render(
json: {
message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.',
- documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
status: 501
)
@@ -55,7 +55,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
else
object[:error] = {
code: 404,
- message: "Object does not exist on the server or you don't have permissions to access it",
+ message: "Object does not exist on the server or you don't have permissions to access it"
}
end
end
diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb
index 38f7e6eb5e9..0f6add3e287 100644
--- a/app/controllers/projects/mattermosts_controller.rb
+++ b/app/controllers/projects/mattermosts_controller.rb
@@ -16,12 +16,10 @@ class Projects::MattermostsController < Projects::ApplicationController
if result
flash[:notice] = 'This service is now configured'
- redirect_to edit_namespace_project_service_path(
- @project.namespace, @project, service)
+ redirect_to edit_project_service_path(@project, service)
else
flash[:alert] = message || 'Failed to configure service'
- redirect_to new_namespace_project_mattermost_path(
- @project.namespace, @project)
+ redirect_to new_project_mattermost_path(@project)
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
new file mode 100644
index 00000000000..6602b204fcb
--- /dev/null
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -0,0 +1,47 @@
+class Projects::MergeRequests::ApplicationController < Projects::ApplicationController
+ before_action :check_merge_requests_available!
+ before_action :merge_request
+ before_action :authorize_read_merge_request!
+ before_action :ensure_ref_fetched
+
+ private
+
+ def merge_request
+ @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
+ end
+
+ # Make sure merge requests created before 8.0
+ # have head file in refs/merge-requests/
+ def ensure_ref_fetched
+ @merge_request.ensure_ref_fetched
+ end
+
+ def merge_request_params
+ params.require(:merge_request).permit(merge_request_params_attributes)
+ end
+
+ def merge_request_params_attributes
+ [
+ :assignee_id,
+ :description,
+ :force_remove_source_branch,
+ :lock_version,
+ :milestone_id,
+ :source_branch,
+ :source_project_id,
+ :state_event,
+ :target_branch,
+ :target_project_id,
+ :task_num,
+ :title,
+
+ label_ids: []
+ ]
+ end
+
+ def set_pipeline_variables
+ @pipelines = @merge_request.all_pipelines
+ @pipeline = @merge_request.head_pipeline
+ @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
+ end
+end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
new file mode 100644
index 00000000000..28afef101a9
--- /dev/null
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -0,0 +1,66 @@
+class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::ApplicationController
+ include IssuableActions
+
+ before_action :authorize_can_resolve_conflicts!
+
+ def show
+ respond_to do |format|
+ format.html do
+ labels
+ end
+
+ format.json do
+ if @conflicts_list.can_be_resolved_in_ui?
+ render json: @conflicts_list
+ elsif @merge_request.can_be_merged?
+ render json: {
+ message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ type: 'error'
+ }
+ else
+ render json: {
+ message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ type: 'error'
+ }
+ end
+ end
+ end
+ end
+
+ def conflict_for_path
+ return render_404 unless @conflicts_list.can_be_resolved_in_ui?
+
+ file = @conflicts_list.file_for_path(params[:old_path], params[:new_path])
+
+ return render_404 unless file
+
+ render json: file, full_content: true
+ end
+
+ def resolve_conflicts
+ return render_404 unless @conflicts_list.can_be_resolved_in_ui?
+
+ if @merge_request.can_be_merged?
+ render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ return
+ end
+
+ begin
+ ::MergeRequests::Conflicts::ResolveService
+ .new(merge_request)
+ .execute(current_user, params)
+
+ flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+
+ render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
+ rescue Gitlab::Conflict::ResolutionError => e
+ render status: :bad_request, json: { message: e.message }
+ end
+ end
+
+ def authorize_can_resolve_conflicts!
+ @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request)
+
+ return render_404 unless @conflicts_list.can_be_resolved_by?(current_user)
+ end
+end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
new file mode 100644
index 00000000000..f35d53896ba
--- /dev/null
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -0,0 +1,128 @@
+class Projects::MergeRequests::CreationsController < Projects::MergeRequests::ApplicationController
+ include DiffForPath
+ include DiffHelper
+
+ skip_before_action :merge_request
+ skip_before_action :ensure_ref_fetched
+ before_action :authorize_create_merge_request!
+ before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
+ before_action :build_merge_request, except: [:create]
+
+ def new
+ define_new_vars
+ end
+
+ def create
+ @target_branches ||= []
+ @merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute
+
+ if @merge_request.valid?
+ redirect_to(merge_request_path(@merge_request))
+ else
+ @source_project = @merge_request.source_project
+ @target_project = @merge_request.target_project
+
+ define_new_vars
+ render action: "new"
+ end
+ end
+
+ def pipelines
+ @pipelines = @merge_request.all_pipelines
+
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines)
+ }
+ end
+
+ def diffs
+ @diffs = if @merge_request.can_be_created
+ @merge_request.diffs(diff_options)
+ else
+ []
+ end
+ @diff_notes_disabled = true
+
+ @environment = @merge_request.environments_for(current_user).last
+
+ render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) }
+ end
+
+ def diff_for_path
+ @diffs = @merge_request.diffs(diff_options)
+ @diff_notes_disabled = true
+
+ render_diff_for_path(@diffs)
+ end
+
+ def branch_from
+ # This is always source
+ @source_project = @merge_request.nil? ? @project : @merge_request.source_project
+
+ if params[:ref].present?
+ @ref = params[:ref]
+ @commit = @repository.commit("refs/heads/#{@ref}")
+ end
+
+ render layout: false
+ end
+
+ def branch_to
+ @target_project = selected_target_project
+
+ if params[:ref].present?
+ @ref = params[:ref]
+ @commit = @target_project.commit("refs/heads/#{@ref}")
+ end
+
+ render layout: false
+ end
+
+ def update_branches
+ @target_project = selected_target_project
+ @target_branches = @target_project.repository.branch_names
+
+ render layout: false
+ end
+
+ private
+
+ def build_merge_request
+ params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
+ @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
+ end
+
+ def define_new_vars
+ @noteable = @merge_request
+
+ @target_branches = if @merge_request.target_project
+ @merge_request.target_project.repository.branch_names
+ else
+ []
+ end
+
+ @target_project = @merge_request.target_project
+ @source_project = @merge_request.source_project
+ @commits = @merge_request.commits
+ @commit = @merge_request.diff_head_commit
+
+ @note_counts = Note.where(commit_id: @commits.map(&:id))
+ .group(:commit_id).count
+
+ @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
+
+ set_pipeline_variables
+ end
+
+ def selected_target_project
+ if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil?
+ @project
+ else
+ @project.forked_project_link.forked_from_project
+ end
+ end
+end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
new file mode 100644
index 00000000000..330b7df4541
--- /dev/null
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -0,0 +1,66 @@
+class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
+ include DiffForPath
+ include DiffHelper
+ include RendersNotes
+
+ before_action :apply_diff_view_cookie!
+ before_action :define_diff_vars
+ before_action :define_diff_comment_vars
+
+ def show
+ @environment = @merge_request.environments_for(current_user).last
+
+ render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") }
+ end
+
+ def diff_for_path
+ render_diff_for_path(@diffs)
+ end
+
+ private
+
+ def define_diff_vars
+ @merge_request_diff =
+ if params[:diff_id]
+ @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
+ else
+ @merge_request.merge_request_diff
+ end
+
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
+ @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
+
+ if params[:start_sha].present?
+ @start_sha = params[:start_sha]
+ @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
+
+ unless @start_version
+ @start_sha = @merge_request_diff.head_commit_sha
+ @start_version = @merge_request_diff
+ end
+ end
+
+ @compare =
+ if @start_sha
+ @merge_request_diff.compare_with(@start_sha)
+ else
+ @merge_request_diff
+ end
+
+ @diffs = @compare.diffs(diff_options)
+ end
+
+ def define_diff_comment_vars
+ @new_diff_note_attrs = {
+ noteable_type: 'MergeRequest',
+ noteable_id: @merge_request.id
+ }
+
+ @diff_notes_disabled = false
+
+ @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
+
+ @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
+ @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 09dc8b38229..2a3b73577a5 100755..100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -1,39 +1,17 @@
-class Projects::MergeRequestsController < Projects::ApplicationController
+class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationController
include ToggleSubscriptionAction
- include DiffForPath
- include DiffHelper
include IssuableActions
include RendersNotes
include ToggleAwardEmoji
include IssuableCollections
- before_action :module_enabled
- before_action :merge_request, only: [
- :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check,
- :ci_status, :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues
- ]
- before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines]
- before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines]
- before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check]
- before_action :define_commit_vars, only: [:diffs]
- before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines]
- before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines]
- before_action :apply_diff_view_cookie!, only: [:new_diffs]
- before_action :build_merge_request, only: [:new, :new_diffs]
-
- # Allow read any merge_request
- before_action :authorize_read_merge_request!
-
- # Allow write(create) merge_request
- before_action :authorize_create_merge_request!, only: [:new, :create]
-
- # Allow modify merge_request
+ skip_before_action :merge_request, only: [:index, :bulk_update]
+ skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update]
+
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authenticate_user!, only: [:assign_related_issues]
- before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :conflict_for_path, :resolve_conflicts]
-
def index
@collection_type = "MergeRequest"
@merge_requests = merge_requests_collection
@@ -73,11 +51,31 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
def show
+ validates_merge_request
+ ensure_ref_fetched
+ close_merge_request_without_source_project
+ check_if_can_be_merged
+
respond_to do |format|
- format.html { define_discussion_vars }
+ format.html do
+ # Build a note object for comment form
+ @note = @project.notes.new(noteable: @merge_request)
+
+ @discussions = @merge_request.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+
+ @noteable = @merge_request
+ @commits_count = @merge_request.commits_count
+
+ labels
+
+ set_pipeline_variables
+ end
format.json do
- render json: MergeRequestSerializer.new.represent(@merge_request)
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: serializer.represent(@merge_request, basic: params[:basic])
end
format.patch do
@@ -94,197 +92,45 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- def diffs
- apply_diff_view_cookie!
-
- respond_to do |format|
- format.html { define_discussion_vars }
- format.json do
- define_diff_vars
- define_diff_comment_vars
-
- @environment = @merge_request.environments_for(current_user).last
-
- render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") }
- end
- end
- end
-
- # With an ID param, loads the MR at that ID. Otherwise, accepts the same params as #new
- # and uses that (unsaved) MR.
- #
- def diff_for_path
- if params[:id]
- merge_request
- define_diff_vars
- define_diff_comment_vars
- else
- build_merge_request
- @diffs = @merge_request.diffs(diff_options)
- @diff_notes_disabled = true
- end
-
- define_commit_vars
-
- render_diff_for_path(@diffs)
- end
-
def commits
- respond_to do |format|
- format.html do
- define_discussion_vars
-
- render 'show'
- end
- format.json do
- # Get commits from repository
- # or from cache if already merged
- @commits = @merge_request.commits
- @note_counts = Note.where(commit_id: @commits.map(&:id)).
- group(:commit_id).count
+ # Get commits from repository
+ # or from cache if already merged
+ @commits = @merge_request.commits
+ @note_counts = Note.where(commit_id: @commits.map(&:id))
+ .group(:commit_id).count
- render json: { html: view_to_html_string('projects/merge_requests/show/_commits') }
- end
- end
- end
-
- def conflicts
- respond_to do |format|
- format.html { define_discussion_vars }
-
- format.json do
- if @merge_request.conflicts_can_be_resolved_in_ui?
- render json: @merge_request.conflicts
- elsif @merge_request.can_be_merged?
- render json: {
- message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
- type: 'error'
- }
- else
- render json: {
- message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
- type: 'error'
- }
- end
- end
- end
- end
-
- def conflict_for_path
- return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
-
- file = @merge_request.conflicts.file_for_path(params[:old_path], params[:new_path])
-
- return render_404 unless file
-
- render json: file, full_content: true
- end
-
- def resolve_conflicts
- return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
-
- if @merge_request.can_be_merged?
- render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
- return
- end
-
- begin
- MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
-
- flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
-
- render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
- rescue Gitlab::Conflict::ResolutionError => e
- render status: :bad_request, json: { message: e.message }
- end
+ render json: { html: view_to_html_string('projects/merge_requests/_commits') }
end
def pipelines
@pipelines = @merge_request.all_pipelines
- respond_to do |format|
- format.html do
- define_discussion_vars
-
- render 'show'
- end
-
- format.json do
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
-
- render json: PipelineSerializer
- .new(project: @project, user: @current_user)
- .represent(@pipelines)
- end
- end
- end
-
- def new
- respond_to do |format|
- format.html { define_new_vars }
- format.json do
- define_pipelines_vars
-
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: {
- pipelines: PipelineSerializer
- .new(project: @project, user: @current_user)
- .represent(@pipelines)
- }
- end
- end
- end
-
- def new_diffs
- respond_to do |format|
- format.html do
- define_new_vars
- @show_changes_tab = true
- render "new"
- end
- format.json do
- @diffs = if @merge_request.can_be_created
- @merge_request.diffs(diff_options)
- else
- []
- end
- @diff_notes_disabled = true
-
- @environment = @merge_request.environments_for(current_user).last
-
- render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs, environment: @environment) }
- end
- end
- end
-
- def create
- @target_branches ||= []
- @merge_request = MergeRequests::CreateService.new(project, current_user, merge_request_params).execute
-
- if @merge_request.valid?
- redirect_to(merge_request_path(@merge_request))
- else
- @source_project = @merge_request.source_project
- @target_project = @merge_request.target_project
- render action: "new"
- end
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipelines),
+ count: {
+ all: @pipelines.count
+ }
+ }
end
def edit
- @source_project = @merge_request.source_project
- @target_project = @merge_request.target_project
- @target_branches = @merge_request.target_project.repository.branch_names
+ define_edit_vars
end
def update
- @merge_request = MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request)
+ @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request)
respond_to do |format|
format.html do
if @merge_request.valid?
redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request])
else
+ define_edit_vars
+
render :edit
end
end
@@ -294,21 +140,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
rescue ActiveRecord::StaleObjectError
+ define_edit_vars if request.format.html?
+
render_conflict_response
end
def remove_wip
- MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request)
+ @merge_request = ::MergeRequests::UpdateService
+ .new(project, current_user, wip_event: 'unwip')
+ .execute(@merge_request)
- redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request),
- notice: "The merge request can now be merged."
+ render json: serializer.represent(@merge_request)
end
- def merge_check
- @merge_request.check_if_can_be_merged
- @pipelines = @merge_request.all_pipelines
-
- render partial: "projects/merge_requests/widget/show.html.haml", layout: false
+ def commit_change_content
+ render partial: 'projects/merge_requests/widget/commit_change_content', layout: false
end
def cancel_merge_when_pipeline_succeeds
@@ -316,148 +162,43 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return access_denied!
end
- MergeRequests::MergeWhenPipelineSucceedsService
+ ::MergeRequests::MergeWhenPipelineSucceedsService
.new(@project, current_user)
.cancel(@merge_request)
+
+ render json: serializer.represent(@merge_request)
end
def merge
return access_denied! unless @merge_request.can_be_merged_by?(current_user)
- # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have
- # to wait until CI completes to know
- unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?)
- @status = :failed
- return
- end
-
- if params[:sha] != @merge_request.diff_head_sha
- @status = :sha_mismatch
- return
- end
+ status = merge!
- @merge_request.update(merge_error: nil)
-
- if params[:merge_when_pipeline_succeeds].present?
- unless @merge_request.head_pipeline
- @status = :failed
- return
- end
-
- if @merge_request.head_pipeline.active?
- MergeRequests::MergeWhenPipelineSucceedsService
- .new(@project, current_user, merge_params)
- .execute(@merge_request)
-
- @status = :merge_when_pipeline_succeeds
- elsif @merge_request.head_pipeline.success?
- # This can be triggered when a user clicks the auto merge button while
- # the tests finish at about the same time
- MergeWorker.perform_async(@merge_request.id, current_user.id, params)
- @status = :success
- else
- @status = :failed
- end
+ if @merge_request.merge_error
+ render json: { status: status, merge_error: @merge_request.merge_error }
else
- MergeWorker.perform_async(@merge_request.id, current_user.id, params)
- @status = :success
+ render json: { status: status }
end
end
- def merge_widget_refresh
- @status =
- if merge_request.merge_when_pipeline_succeeds
- :merge_when_pipeline_succeeds
- else
- # Only MRs that can be merged end in this action
- # MR can be already picked up for merge / merged already or can be waiting for worker to be picked up
- # in last case it does not have any special status. Possible error is handled inside widget js function
- :success
- end
-
- render 'merge'
- end
-
- def branch_from
- # This is always source
- @source_project = @merge_request.nil? ? @project : @merge_request.source_project
-
- if params[:ref].present?
- @ref = params[:ref]
- @commit = @repository.commit("refs/heads/#{@ref}")
- end
-
- render layout: false
- end
-
- def branch_to
- @target_project = selected_target_project
-
- if params[:ref].present?
- @ref = params[:ref]
- @commit = @target_project.commit("refs/heads/#{@ref}")
- end
-
- render layout: false
- end
-
- def update_branches
- @target_project = selected_target_project
- @target_branches = @target_project.repository.branch_names
-
- render layout: false
- end
-
def assign_related_issues
- result = MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute
-
- respond_to do |format|
- format.html do
- case result[:count]
- when 0
- flash[:error] = "Failed to assign you issues related to the merge request"
- when 1
- flash[:notice] = "1 issue has been assigned to you"
- else
- flash[:notice] = "#{result[:count]} issues have been assigned to you"
- end
-
- redirect_to(merge_request_path(@merge_request))
- end
- end
- end
-
- def ci_status
- pipeline = @merge_request.head_pipeline
- @pipelines = @merge_request.all_pipelines
+ result = ::MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute
- if pipeline
- status = pipeline.status
- coverage = pipeline.coverage
-
- status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
-
- status ||= "preparing"
+ case result[:count]
+ when 0
+ flash[:error] = "Failed to assign you issues related to the merge request"
+ when 1
+ flash[:notice] = "1 issue has been assigned to you"
else
- ci_service = @merge_request.source_project.try(:ci_service)
- status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service
+ flash[:notice] = "#{result[:count]} issues have been assigned to you"
end
- response = {
- title: merge_request.title,
- sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha),
- status: status,
- coverage: coverage,
- pipeline: pipeline.try(:id),
- has_ci: @merge_request.has_ci?
- }
-
- render json: response
+ redirect_to(merge_request_path(@merge_request))
end
def pipeline_status
render json: PipelineSerializer
- .new(project: @project, user: @current_user)
+ .new(project: @project, current_user: @current_user)
.represent_status(@merge_request.head_pipeline)
end
@@ -470,13 +211,25 @@ class Projects::MergeRequestsController < Projects::ApplicationController
stop_url =
if environment.stop_action? && can?(current_user, :create_deployment, environment)
- stop_namespace_project_environment_path(project.namespace, project, environment)
+ stop_project_environment_path(project, environment)
+ end
+
+ metrics_url =
+ if can?(current_user, :read_environment, environment) && environment.has_metrics?
+ metrics_project_environment_deployment_path(environment.project, environment, deployment)
+ end
+
+ metrics_monitoring_url =
+ if can?(current_user, :read_environment, environment)
+ environment_metrics_path(environment)
end
{
id: environment.id,
name: environment.name,
- url: namespace_project_environment_path(project.namespace, project, environment),
+ url: project_environment_path(project, environment),
+ metrics_url: metrics_url,
+ metrics_monitoring_url: metrics_monitoring_url,
stop_url: stop_url,
external_url: environment.external_url,
external_url_formatted: environment.formatted_external_url,
@@ -491,17 +244,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
protected
- def selected_target_project
- if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil?
- @project
- else
- @project.forked_project_link.forked_from_project
- end
- end
-
- def merge_request
- @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
- end
alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request
alias_method :awardable, :merge_request
@@ -514,14 +256,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
- def authorize_can_resolve_conflicts!
- return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
- end
-
- def module_enabled
- return render_404 unless @project.feature_available?(:merge_requests, current_user)
- end
-
def validates_merge_request
# Show git not found page
# if there is no saved commits between source & target branch
@@ -531,164 +265,79 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
- def define_show_vars
- @noteable = @merge_request
- @commits_count = @merge_request.commits_count
-
- if @merge_request.locked_long_ago?
- @merge_request.unlock_mr
- @merge_request.close
- end
-
- labels
- define_pipelines_vars
+ def invalid_mr
+ # Render special view for MR with removed target branch
+ render 'invalid'
end
- # Discussion tab data is rendered on html responses of actions
- # :show, :diff, :commits, :builds. but not when request the data through AJAX
- def define_discussion_vars
- # Build a note object for comment form
- @note = @project.notes.new(noteable: @merge_request)
-
- @discussions = @merge_request.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+ def merge_params
+ params.permit(merge_params_attributes)
end
- def define_widget_vars
- @pipeline = @merge_request.head_pipeline
+ def merge_params_attributes
+ [:should_remove_source_branch, :commit_message]
end
- def define_commit_vars
- @commit = @merge_request.diff_head_commit
- @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit
+ def merge_when_pipeline_succeeds_active?
+ params[:merge_when_pipeline_succeeds].present? &&
+ @merge_request.head_pipeline && @merge_request.head_pipeline.active?
end
- def define_diff_vars
- @merge_request_diff =
- if params[:diff_id]
- @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
- else
- @merge_request.merge_request_diff
- end
-
- @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
- @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
-
- if params[:start_sha].present?
- @start_sha = params[:start_sha]
- @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
-
- unless @start_version
- @start_sha = @merge_request_diff.head_commit_sha
- @start_version = @merge_request_diff
- end
+ def close_merge_request_without_source_project
+ if !@merge_request.source_project && @merge_request.open?
+ @merge_request.close
end
-
- @diffs =
- if @start_sha
- @merge_request_diff.compare_with(@start_sha).diffs(diff_options)
- else
- @merge_request_diff.diffs(diff_options)
- end
end
- def define_diff_comment_vars
- @new_diff_note_attrs = {
- noteable_type: 'MergeRequest',
- noteable_id: @merge_request.id
- }
-
- @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha
+ private
- @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
-
- @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs)
- @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
- end
-
- def define_pipelines_vars
- @pipelines = @merge_request.all_pipelines
- @pipeline = @merge_request.head_pipeline
- @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
+ def check_if_can_be_merged
+ @merge_request.check_if_can_be_merged
end
- def define_new_vars
- @noteable = @merge_request
-
- @target_branches = if @merge_request.target_project
- @merge_request.target_project.repository.branch_names
- else
- []
- end
-
- @target_project = merge_request.target_project
- @source_project = merge_request.source_project
- @commits = @merge_request.compare_commits.reverse
- @commit = @merge_request.diff_head_commit
- @base_commit = @merge_request.diff_base_commit
-
- @note_counts = Note.where(commit_id: @commits.map(&:id)).
- group(:commit_id).count
-
- @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
-
- @show_changes_tab = params[:show_changes].present?
+ def merge!
+ # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have
+ # to wait until CI completes to know
+ unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?)
+ return :failed
+ end
- define_pipelines_vars
- end
+ return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha
- def invalid_mr
- # Render special view for MR with removed target branch
- render 'invalid'
- end
+ @merge_request.update(merge_error: nil)
- def merge_request_params
- params.require(:merge_request)
- .permit(merge_request_params_ce)
- end
+ if params[:merge_when_pipeline_succeeds].present?
+ return :failed unless @merge_request.head_pipeline
- def merge_request_params_ce
- [
- :assignee_id,
- :description,
- :force_remove_source_branch,
- :lock_version,
- :milestone_id,
- :source_branch,
- :source_project_id,
- :state_event,
- :target_branch,
- :target_project_id,
- :task_num,
- :title,
-
- label_ids: []
- ]
- end
+ if @merge_request.head_pipeline.active?
+ ::MergeRequests::MergeWhenPipelineSucceedsService
+ .new(@project, current_user, merge_params)
+ .execute(@merge_request)
- def merge_params
- params.permit(:should_remove_source_branch, :commit_message)
- end
+ :merge_when_pipeline_succeeds
+ elsif @merge_request.head_pipeline.success?
+ # This can be triggered when a user clicks the auto merge button while
+ # the tests finish at about the same time
+ MergeWorker.perform_async(@merge_request.id, current_user.id, params)
- # Make sure merge requests created before 8.0
- # have head file in refs/merge-requests/
- def ensure_ref_fetched
- @merge_request.ensure_ref_fetched
- end
+ :success
+ else
+ :failed
+ end
+ else
+ MergeWorker.perform_async(@merge_request.id, current_user.id, params)
- def merge_when_pipeline_succeeds_active?
- params[:merge_when_pipeline_succeeds].present? &&
- @merge_request.head_pipeline && @merge_request.head_pipeline.active?
+ :success
+ end
end
- def build_merge_request
- params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
- @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
+ def serializer
+ MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
end
- def close_merge_request_without_source_project
- if !@merge_request.source_project && @merge_request.open?
- @merge_request.close
- end
+ def define_edit_vars
+ @source_project = @merge_request.source_project
+ @target_project = @merge_request.target_project
+ @target_branches = @merge_request.target_project.repository.branch_names
end
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index d0dd524c484..c94384d2a1a 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -1,30 +1,28 @@
class Projects::MilestonesController < Projects::ApplicationController
- before_action :module_enabled
- before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests]
+ include MilestoneActions
+
+ before_action :check_issuables_available!
+ before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels]
# Allow read any milestone
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
respond_to :html
def index
- @milestones =
- case params[:state]
- when 'all' then @project.milestones
- when 'closed' then @project.milestones.closed
- else @project.milestones.active
- end
-
@sort = params[:sort] || 'due_date_asc'
- @milestones = @milestones.sort(@sort)
+ @milestones = milestones.sort(@sort)
respond_to do |format|
format.html do
@project_namespace = @project.namespace.becomes(Namespace)
- @milestones = @milestones.includes(:project)
+ # We need to show group milestones in the JSON response
+ # so that people can filter by and assign group milestones,
+ # but we don't need to show them on the project milestones page itself.
+ @milestones = @milestones.for_projects
@milestones = @milestones.page(params[:page])
end
format.json do
@@ -43,14 +41,14 @@ class Projects::MilestonesController < Projects::ApplicationController
end
def show
+ @project_namespace = @project.namespace.becomes(Namespace)
end
def create
@milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute
- if @milestone.save
- redirect_to namespace_project_milestone_path(@project.namespace,
- @project, @milestone)
+ if @milestone.valid?
+ redirect_to project_milestone_path(@project, @milestone)
else
render "new"
end
@@ -63,8 +61,7 @@ class Projects::MilestonesController < Projects::ApplicationController
format.js
format.html do
if @milestone.valid?
- redirect_to namespace_project_milestone_path(@project.namespace,
- @project, @milestone)
+ redirect_to project_milestone_path(@project, @milestone)
else
render :edit
end
@@ -78,29 +75,25 @@ class Projects::MilestonesController < Projects::ApplicationController
Milestones::DestroyService.new(project, current_user).execute(milestone)
respond_to do |format|
- format.html { redirect_to namespace_project_milestones_path }
+ format.html { redirect_to namespace_project_milestones_path, status: 302 }
format.js { head :ok }
end
end
- def sort_issues
- @milestone.sort_issues(params['sortable_issue'].map(&:to_i))
+ protected
- render json: { saved: true }
- end
+ def milestones
+ @milestones ||= begin
+ if @project.group && can?(current_user, :read_group, @project.group)
+ group = @project.group
+ end
- def sort_merge_requests
- @merge_requests = @milestone.merge_requests.where(id: params['sortable_merge_request'])
- @merge_requests.each do |merge_request|
- merge_request.position = params['sortable_merge_request'].index(merge_request.id.to_s) + 1
- merge_request.save
- end
+ search_params = params.merge(project_ids: @project.id, group_ids: group&.id)
- render json: { saved: true }
+ MilestonesFinder.new(search_params).execute
+ end
end
- protected
-
def milestone
@milestone ||= @project.milestones.find_by!(iid: params[:id])
end
@@ -109,12 +102,6 @@ class Projects::MilestonesController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_milestone, @project)
end
- def module_enabled
- unless @project.feature_available?(:issues, current_user) || @project.feature_available?(:merge_requests, current_user)
- return render_404
- end
- end
-
def milestone_params
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb
index 33a152ad34f..dfa5e4f7f46 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -8,8 +8,8 @@ class Projects::NetworkController < Projects::ApplicationController
before_action :assign_commit
def show
- @url = namespace_project_network_path(@project.namespace, @project, @ref, @options.merge(format: :json))
- @commit_url = namespace_project_commit_path(@project.namespace, @project, 'ae45ca32').gsub("ae45ca32", "%s")
+ @url = project_network_path(@project, @ref, @options.merge(format: :json))
+ @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s")
respond_to do |format|
format.html do
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 405ea3c0a4f..41a13f6f577 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,68 +1,22 @@
class Projects::NotesController < Projects::ApplicationController
- include RendersNotes
+ include NotesActions
include ToggleAwardEmoji
- # Authorize
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
- before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- 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)
-
- notes_json[:notes] << note_json(note)
- end
-
- render json: notes_json
- end
-
+ #
+ # This is a fix to make spinach feature tests passing:
+ # Controller actions are returned from AbstractController::Base and methods of parent classes are
+ # excluded in order to return only specific controller related methods.
+ # That is ok for the app (no :create method in ancestors)
+ # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors)
+ #
+ # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78
+ #
def create
- 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)
- Banzai::NoteRenderer.render([@note], @project, current_user)
- end
-
- respond_to do |format|
- format.json { render json: note_json(@note) }
- format.html { redirect_back_or_default }
- end
- end
-
- def update
- @note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
-
- if @note.is_a?(Note)
- Banzai::NoteRenderer.render([@note], @project, current_user)
- end
-
- respond_to do |format|
- format.json { render json: note_json(@note) }
- format.html { redirect_back_or_default }
- end
- end
-
- def destroy
- if note.editable?
- Notes::DestroyService.new(project, current_user).execute(note)
- end
-
- respond_to do |format|
- format.js { head :ok }
- end
+ super
end
def delete_attachment
@@ -108,120 +62,11 @@ class Projects::NotesController < Projects::ApplicationController
end
alias_method :awardable, :note
- def note_html(note)
- render_to_string(
- "projects/notes/_note",
- layout: false,
- formats: [:html],
- locals: { note: note }
- )
- 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?
-
- if params[:view] == 'parallel'
- template = "discussions/_parallel_diff_discussion"
- locals =
- if params[:line_type] == 'old'
- { discussions_left: [discussion], discussions_right: nil }
- else
- { discussions_left: nil, discussions_right: [discussion] }
- end
- else
- template = "discussions/_diff_discussion"
- locals = { discussions: [discussion] }
- end
-
- render_to_string(
- template,
- layout: false,
- formats: [:html],
- locals: locals
- )
- end
-
- def note_json(note)
- attrs = {
- commands_changes: note.commands_changes
- }
-
- if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
-
- 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)
- )
- end
- else
- attrs.merge!(
- valid: false,
- errors: note.errors
- )
- end
-
- attrs
- end
-
- def authorize_admin_note!
- return access_denied! unless can?(current_user, :admin_note, note)
+ def finder_params
+ params.merge(last_fetched_at: last_fetched_at)
end
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
-
- def note_params
- params.require(:note).permit(
- :project_id,
- :noteable_type,
- :noteable_id,
- :commit_id,
- :noteable,
- :type,
-
- :note,
- :attachment,
-
- # LegacyDiffNote
- :line_code,
-
- # DiffNote
- :position
- )
- end
-
- 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
- request.headers['X-Last-Fetched-At']
- end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index fbd18b68141..d421b1a8eb5 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -1,6 +1,7 @@
class Projects::PagesController < Projects::ApplicationController
layout 'project_settings'
+ before_action :require_pages_enabled!
before_action :authorize_read_pages!, only: [:show]
before_action :authorize_update_pages!, except: [:show]
@@ -14,8 +15,9 @@ class Projects::PagesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Pages were removed')
+ redirect_to project_pages_path(@project),
+ status: 302,
+ notice: 'Pages were removed'
end
end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index b8c253f6ae3..15e77d854dc 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -1,6 +1,7 @@
class Projects::PagesDomainsController < Projects::ApplicationController
layout 'project_settings'
+ before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy]
@@ -15,7 +16,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
@domain = @project.pages_domains.create(pages_domain_params)
if @domain.valid?
- redirect_to namespace_project_pages_path(@project.namespace, @project)
+ redirect_to project_pages_path(@project)
else
render 'new'
end
@@ -26,8 +27,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Domain was removed')
+ redirect_to project_pages_path(@project),
+ status: 302,
+ notice: 'Domain was removed'
end
format.js
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
new file mode 100644
index 00000000000..ec7c645df5a
--- /dev/null
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -0,0 +1,80 @@
+class Projects::PipelineSchedulesController < Projects::ApplicationController
+ before_action :schedule, except: [:index, :new, :create]
+
+ before_action :authorize_read_pipeline_schedule!
+ before_action :authorize_create_pipeline_schedule!, only: [:new, :create]
+ before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create]
+ before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
+
+ def index
+ @scope = params[:scope]
+ @all_schedules = PipelineSchedulesFinder.new(@project).execute
+ @schedules = PipelineSchedulesFinder.new(@project).execute(scope: params[:scope])
+ .includes(:last_pipeline)
+ end
+
+ def new
+ @schedule = project.pipeline_schedules.new
+ end
+
+ def create
+ @schedule = Ci::CreatePipelineScheduleService
+ .new(@project, current_user, schedule_params)
+ .execute
+
+ if @schedule.persisted?
+ redirect_to pipeline_schedules_path(@project)
+ else
+ render :new
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if schedule.update(schedule_params)
+ redirect_to project_pipeline_schedules_path(@project)
+ else
+ render :edit
+ end
+ end
+
+ def take_ownership
+ if schedule.update(owner: current_user)
+ redirect_to pipeline_schedules_path(@project)
+ else
+ redirect_to pipeline_schedules_path(@project), alert: _("Failed to change the owner")
+ end
+ end
+
+ def destroy
+ if schedule.destroy
+ redirect_to pipeline_schedules_path(@project), status: 302
+ else
+ redirect_to pipeline_schedules_path(@project),
+ status: :forbidden,
+ alert: _("Failed to remove the pipeline schedule")
+ end
+ end
+
+ private
+
+ def schedule
+ @schedule ||= project.pipeline_schedules.find(params[:id])
+ end
+
+ def schedule_params
+ params.require(:schedule)
+ .permit(:description, :cron, :cron_timezone, :ref, :active,
+ variables_attributes: [:id, :key, :value, :_destroy] )
+ end
+
+ def authorize_update_pipeline_schedule!
+ return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule)
+ end
+
+ def authorize_admin_pipeline_schedule!
+ return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule)
+ end
+end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 1780cc0233c..a3bfbf0694e 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -1,27 +1,30 @@
class Projects::PipelinesController < Projects::ApplicationController
before_action :pipeline, except: [:index, :new, :create, :charts]
- before_action :commit, only: [:show, :builds]
+ before_action :commit, only: [:show, :builds, :failures]
before_action :authorize_read_pipeline!
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
- before_action :builds_enabled, only: :charts
+
+ wrap_parameters Ci::Pipeline
+
+ POLLING_INTERVAL = 10_000
def index
@scope = params[:scope]
@pipelines = PipelinesFinder
- .new(project)
- .execute(scope: @scope)
+ .new(project, scope: @scope)
+ .execute
.page(params[:page])
.per(30)
@running_count = PipelinesFinder
- .new(project).execute(scope: 'running').count
+ .new(project, scope: 'running').execute.count
@pending_count = PipelinesFinder
- .new(project).execute(scope: 'pending').count
+ .new(project, scope: 'pending').execute.count
@finished_count = PipelinesFinder
- .new(project).execute(scope: 'finished').count
+ .new(project, scope: 'finished').execute.count
@pipelines_count = PipelinesFinder
.new(project).execute.count
@@ -29,18 +32,18 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- Gitlab::PollingInterval.set_header(response, interval: 10_000)
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: {
pipelines: PipelineSerializer
- .new(project: @project, user: @current_user)
+ .new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.represent(@pipelines),
count: {
all: @pipelines_count,
running: @running_count,
pending: @pending_count,
- finished: @finished_count,
+ finished: @finished_count
}
}
end
@@ -54,34 +57,48 @@ class Projects::PipelinesController < Projects::ApplicationController
def create
@pipeline = Ci::CreatePipelineService
.new(project, current_user, create_params)
- .execute(ignore_skip_ci: true, save_on_errors: false)
- unless @pipeline.persisted?
+ .execute(:web, ignore_skip_ci: true, save_on_errors: false)
+
+ if @pipeline.persisted?
+ redirect_to project_pipeline_path(project, @pipeline)
+ else
render 'new'
- return
end
-
- redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
end
def show
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
+
+ render json: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipeline, grouped: true)
+ end
+ end
end
def builds
- respond_to do |format|
- format.html do
- render 'show'
- end
+ render_show
+ end
+
+ def failures
+ if @pipeline.statuses.latest.failed.present?
+ render_show
+ else
+ redirect_to pipeline_path(@pipeline)
end
end
def status
render json: PipelineSerializer
- .new(project: @project, user: @current_user)
+ .new(project: @project, current_user: @current_user)
.represent_status(@pipeline)
end
def stage
- @stage = pipeline.stage(params[:stage])
+ @stage = pipeline.legacy_stage(params[:stage])
return not_found unless @stage
respond_to do |format|
@@ -92,13 +109,25 @@ class Projects::PipelinesController < Projects::ApplicationController
def retry
pipeline.retry_failed(current_user)
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ respond_to do |format|
+ format.html do
+ redirect_back_or_default default: project_pipelines_path(project)
+ end
+
+ format.json { head :no_content }
+ end
end
def cancel
pipeline.cancel_running
- redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
+ respond_to do |format|
+ format.html do
+ redirect_back_or_default default: project_pipelines_path(project)
+ end
+
+ format.json { head :no_content }
+ end
end
def charts
@@ -106,11 +135,24 @@ class Projects::PipelinesController < Projects::ApplicationController
@charts[:week] = Ci::Charts::WeekChart.new(project)
@charts[:month] = Ci::Charts::MonthChart.new(project)
@charts[:year] = Ci::Charts::YearChart.new(project)
- @charts[:build_times] = Ci::Charts::BuildTime.new(project)
+ @charts[:pipeline_times] = Ci::Charts::PipelineTime.new(project)
+
+ @counts = {}
+ @counts[:total] = @project.pipelines.count(:all)
+ @counts[:success] = @project.pipelines.success.count(:all)
+ @counts[:failed] = @project.pipelines.failed.count(:all)
end
private
+ def render_show
+ respond_to do |format|
+ format.html do
+ render 'show'
+ end
+ end
+ end
+
def create_params
params.require(:pipeline).permit(:ref)
end
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index ff50602831c..9d24ebe2138 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -2,13 +2,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
def show
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project, params: params)
+ redirect_to project_settings_ci_cd_path(@project, params: params)
end
def update
if @project.update_attributes(update_params)
- flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated."
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+ redirect_to project_settings_ci_cd_path(@project)
else
render 'show'
end
@@ -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, :auto_cancel_pending_pipelines
+ :public_builds, :auto_cancel_pending_pipelines, :ci_config_path
)
end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index d2d26738582..f8ff7413b53 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -6,8 +6,23 @@ class Projects::ProjectMembersController < Projects::ApplicationController
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index
- sort = params[:sort].presence || sort_value_name
- redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort)
+ @sort = params[:sort].presence || sort_value_name
+ @group_links = @project.project_group_links
+
+ @skip_groups = @group_links.pluck(:group_id)
+ @skip_groups << @project.namespace_id unless @project.personal?
+ @skip_groups += @project.group.ancestors.pluck(:id) if @project.group
+
+ @project_members = MembersFinder.new(@project, current_user).execute
+
+ if params[:search].present?
+ @project_members = @project_members.joins(:user).merge(User.search(params[:search]))
+ @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
+ end
+
+ @project_members = @project_members.sort(@sort).page(params[:page])
+ @requesters = AccessRequestsFinder.new(@project).execute(current_user)
+ @project_member = @project.project_members.new
end
def update
@@ -19,7 +34,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
def resend_invite
- redirect_path = namespace_project_settings_members_path(@project.namespace, @project)
+ redirect_path = project_project_members_path(@project)
@project_member = @project.project_members.find(params[:id])
@@ -42,7 +57,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
return render_404
end
- redirect_to(namespace_project_settings_members_path(project.namespace, project),
+ redirect_to(project_project_members_path(project),
notice: notice)
end
diff --git a/app/controllers/projects/prometheus_controller.rb b/app/controllers/projects/prometheus_controller.rb
new file mode 100644
index 00000000000..507468d7102
--- /dev/null
+++ b/app/controllers/projects/prometheus_controller.rb
@@ -0,0 +1,24 @@
+class Projects::PrometheusController < Projects::ApplicationController
+ before_action :authorize_read_project!
+ before_action :require_prometheus_metrics!
+
+ def active_metrics
+ respond_to do |format|
+ format.json do
+ matched_metrics = project.prometheus_service.matched_metrics || {}
+
+ if matched_metrics.any?
+ render json: matched_metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
+ private
+
+ def require_prometheus_metrics!
+ render_404 unless project.prometheus_service.present?
+ end
+end
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index ba24fa9acfe..d1719f12072 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params
params.require(:protected_branch).permit(:name,
- merge_access_levels_attributes: [:access_level, :id],
- push_access_levels_attributes: [:access_level, :id])
+ merge_access_levels_attributes: access_level_attributes,
+ push_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 083a70968e5..b51bdf7aa78 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok }
end
end
+
+ protected
+
+ def access_level_attributes
+ %i(access_level id)
+ end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index c61ddf145e6..a5dbd7e46ae 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
end
def protected_ref_params
- params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index c55b37ae0dd..a02cc477e08 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController
return if cached_blob?
- if @blob.lfs_pointer? && project.lfs_enabled?
+ if @blob.stored_externally?
send_lfs_object
else
send_git_blob @repository, @blob
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 667f4870c7a..1eb78d8b522 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -13,21 +13,21 @@ class Projects::RefsController < Projects::ApplicationController
new_path =
case params[:destination]
when "tree"
- namespace_project_tree_path(@project.namespace, @project, @id)
+ project_tree_path(@project, @id)
when "blob"
- namespace_project_blob_path(@project.namespace, @project, @id)
+ project_blob_path(@project, @id)
when "graph"
- namespace_project_network_path(@project.namespace, @project, @id, @options)
+ project_network_path(@project, @id, @options)
when "graphs"
- namespace_project_graph_path(@project.namespace, @project, @id)
+ project_graph_path(@project, @id)
when "find_file"
- namespace_project_find_file_path(@project.namespace, @project, @id)
+ project_find_file_path(@project, @id)
when "graphs_commits"
- commits_namespace_project_graph_path(@project.namespace, @project, @id)
+ commits_project_graph_path(@project, @id)
when "badges"
- namespace_project_pipelines_settings_path(@project.namespace, @project, ref: @id)
+ project_pipelines_settings_path(@project, ref: @id)
else
- namespace_project_commits_path(@project.namespace, @project, @id)
+ project_commits_path(@project, @id)
end
redirect_to new_path
@@ -62,7 +62,7 @@ class Projects::RefsController < Projects::ApplicationController
offset = (@offset + @limit)
if contents.size > offset
- @more_log_url = logs_file_namespace_project_ref_path(@project.namespace, @project, @ref, @path || '', offset: offset)
+ @more_log_url = logs_file_project_ref_path(@project, @ref, @path || '', offset: offset)
end
respond_to do |format|
@@ -74,6 +74,6 @@ class Projects::RefsController < Projects::ApplicationController
private
def validate_ref_id
- return not_found! if params[:id].present? && params[:id] !~ Gitlab::Regex.git_reference_regex
+ return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
end
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 17f391ba07f..71e7dc70a4d 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -10,10 +10,12 @@ module Projects
def destroy
if image.destroy
- redirect_to project_container_registry_path(@project),
+ redirect_to project_container_registry_index_path(@project),
+ status: 302,
notice: 'Image repository has been removed successfully!'
else
- redirect_to project_container_registry_path(@project),
+ redirect_to project_container_registry_index_path(@project),
+ status: 302,
alert: 'Failed to remove image repository!'
end
end
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index d689cade3ab..ae72bd03cfb 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -5,10 +5,12 @@ module Projects
def destroy
if tag.delete
- redirect_to project_container_registry_path(@project),
+ redirect_to project_container_registry_index_path(@project),
+ status: 302,
notice: 'Registry tag has been removed successfully!'
else
- redirect_to project_container_registry_path(@project),
+ redirect_to project_container_registry_index_path(@project),
+ status: 302,
alert: 'Failed to remove registry tag!'
end
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 2c097cb4d8d..3e0a530fdb9 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -19,7 +19,7 @@ class Projects::ReleasesController < Projects::ApplicationController
release.destroy
end
- redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name)
+ redirect_to project_tag_path(@project, @tag.name)
end
private
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 8267b14941d..3cb01405b05 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -22,6 +22,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
runner_project = project.runner_projects.find(params[:id])
runner_project.destroy
- redirect_to runners_path(project)
+ redirect_to runners_path(project), status: 302
end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 8b50ea207a5..9f9773575a5 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -5,7 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def edit
@@ -24,7 +24,7 @@ class Projects::RunnersController < Projects::ApplicationController
@runner.destroy
end
- redirect_to runners_path(@project)
+ redirect_to runners_path(@project), status: 302
end
def resume
@@ -49,7 +49,7 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
project.toggle!(:shared_runners_enabled)
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
protected
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index f9d798d0455..d54a1111f11 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
before_action :service, only: [:edit, :update, :test]
+ before_action :update_service, only: [:update, :test]
respond_to :html
@@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController
end
def update
- @service.assign_attributes(service_params[:service])
if @service.save(context: :manual_change)
- redirect_to(
- edit_namespace_project_service_path(@project.namespace, @project, @service.to_param),
- notice: 'Successfully updated.'
- )
+ redirect_to(project_settings_integrations_path(@project), notice: success_message)
else
render 'edit'
end
end
def test
- return render_404 unless @service.can_test?
+ message = {}
+
+ if @service.can_test?
+ data = @service.test_data(project, current_user)
+ outcome = @service.test(data)
- data = @service.test_data(project, current_user)
- outcome = @service.test(data)
+ unless outcome[:success]
+ message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s }
+ end
- if outcome[:success]
- message = { notice: 'We sent a request to the provided URL' }
+ status = :ok
else
- error_message = "We tried to send a request to the provided URL but an error occurred"
- error_message << ": #{outcome[:result]}" if outcome[:result].present?
- message = { alert: error_message }
+ status = :not_found
end
- redirect_back_or_default(options: message)
+ render json: message, status: status
end
private
+ def success_message
+ if @service.active?
+ "#{@service.title} activated."
+ else
+ "#{@service.title} settings saved, but not activated."
+ end
+ end
+
+ def update_service
+ @service.assign_attributes(service_params[:service])
+ end
+
def service
@service ||= @project.find_or_initialize_service(params[:id])
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 6f009d61950..15a2ff56b92 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -14,14 +14,17 @@ module Projects
def define_runners_variables
@project_runners = @project.runners.ordered
- @assignable_runners = current_user.ci_authorized_runners.
- assignable_for(project).ordered.page(params[:page]).per(20)
+ @assignable_runners = current_user.ci_authorized_runners
+ .assignable_for(project).ordered.page(params[:page]).per(20)
@shared_runners = Ci::Runner.shared.active
@shared_runners_count = @shared_runners.count(:all)
end
def define_secret_variables
- @variable = Ci::Variable.new
+ @variable = Ci::Variable.new(project: project)
+ .present(current_user: current_user)
+ @variables = project.variables.order_key_asc
+ .map { |variable| variable.present(current_user: current_user) }
end
def define_triggers_variables
@@ -32,7 +35,7 @@ module Projects
def define_badges_variables
@ref = params[:ref] || @project.default_branch || 'master'
- @badges = [Gitlab::Badge::Build::Status,
+ @badges = [Gitlab::Badge::Pipeline::Status,
Gitlab::Badge::Coverage::Report]
@badges.map! do |badge|
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index fb2a4837735..1ff08cce8cb 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -5,7 +5,7 @@ module Projects
before_action :authorize_admin_project!
layout "project_settings"
-
+
def show
@hooks = @project.hooks
@hook = ProjectHook.new
diff --git a/app/controllers/projects/settings/members_controller.rb b/app/controllers/projects/settings/members_controller.rb
deleted file mode 100644
index 54f9dceddef..00000000000
--- a/app/controllers/projects/settings/members_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-module Projects
- module Settings
- class MembersController < Projects::ApplicationController
- include SortingHelper
-
- def show
- @sort = params[:sort].presence || sort_value_name
- @group_links = @project.project_group_links
-
- @skip_groups = @group_links.pluck(:group_id)
- @skip_groups << @project.namespace_id unless @project.personal?
- @skip_groups += @project.group.ancestors.pluck(:id) if @project.group
-
- @project_members = MembersFinder.new(@project, current_user).execute
-
- if params[:search].present?
- @project_members = @project_members.joins(:user).merge(User.search(params[:search]))
- @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
- end
-
- @project_members = @project_members.sort(@sort).page(params[:page])
- @requesters = AccessRequestsFinder.new(@project).execute(current_user)
- @project_member = @project.project_members.new
- end
- end
- end
-end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 5c9e0d4d1a1..d07143d294f 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -3,8 +3,9 @@ class Projects::SnippetsController < Projects::ApplicationController
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
+ include RendersBlob
- before_action :module_enabled
+ before_action :check_snippets_available!
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
# Allow read any snippet
@@ -22,15 +23,14 @@ class Projects::SnippetsController < Projects::ApplicationController
respond_to :html
def index
- @snippets = SnippetsFinder.new.execute(
+ @snippets = SnippetsFinder.new(
current_user,
- filter: :by_project,
project: @project,
scope: params[:scope]
- )
+ ).execute
@snippets = @snippets.page(params[:page])
if @snippets.out_of_range? && @snippets.total_pages != 0
- redirect_to namespace_project_snippets_path(page: @snippets.total_pages)
+ redirect_to project_snippets_path(@project, page: @snippets.total_pages)
end
end
@@ -55,11 +55,23 @@ class Projects::SnippetsController < Projects::ApplicationController
end
def show
- @note = @project.notes.new(noteable: @snippet)
- @noteable = @snippet
-
- @discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+ blob = @snippet.blob
+ conditionally_expand_blob(blob)
+
+ respond_to do |format|
+ format.html do
+ @note = @project.notes.new(noteable: @snippet)
+ @noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+ render 'show'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
+ end
end
def destroy
@@ -67,7 +79,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@snippet.destroy
- redirect_to namespace_project_snippets_path(@project.namespace, @project)
+ redirect_to project_snippets_path(@project), status: 302
end
protected
@@ -78,6 +90,10 @@ class Projects::SnippetsController < Projects::ApplicationController
alias_method :awardable, :snippet
alias_method :spammable, :snippet
+ def spammable_path
+ project_snippet_path(@project, @snippet)
+ end
+
def authorize_read_project_snippet!
return render_404 unless can?(current_user, :read_project_snippet, @snippet)
end
@@ -90,11 +106,7 @@ class Projects::SnippetsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_project_snippet, @snippet)
end
- def module_enabled
- return render_404 unless @project.feature_available?(:snippets, current_user)
- end
-
def snippet_params
- params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index e13f0bde315..b62d7d9b7c5 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -29,15 +29,17 @@ class Projects::TagsController < Projects::ApplicationController
end
def create
- result = Tags::CreateService.new(@project, current_user).
- execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
+ result = Tags::CreateService.new(@project, current_user)
+ .execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
if result[:status] == :success
@tag = result[:tag]
- redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name)
+ redirect_to project_tag_path(@project, @tag.name)
else
@error = result[:message]
+ @message = params[:message]
+ @release_description = params[:release_description]
render action: 'new'
end
end
@@ -48,7 +50,7 @@ class Projects::TagsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
format.html do
- redirect_to namespace_project_tags_path(@project.namespace, @project)
+ redirect_to project_tags_path(@project), status: 303
end
format.js
@@ -56,8 +58,8 @@ class Projects::TagsController < Projects::ApplicationController
@error = result[:message]
format.html do
- redirect_to namespace_project_tags_path(@project.namespace, @project),
- alert: @error
+ redirect_to project_tags_path(@project),
+ alert: @error, status: 303
end
format.js do
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 5e2182c883e..1fc276b8c03 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -16,7 +16,7 @@ class Projects::TreeController < Projects::ApplicationController
if tree.entries.empty?
if @repository.blob_at(@commit.id, @path)
return redirect_to(
- namespace_project_blob_path(@project.namespace, @project,
+ project_blob_path(@project,
File.join(@ref, @path))
)
elsif @path.present?
@@ -25,19 +25,27 @@ class Projects::TreeController < Projects::ApplicationController
end
respond_to do |format|
- format.html
- # Disable cache so browser history works
- format.js { no_cache_headers }
+ format.html do
+ @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
+ end
+
+ format.js do
+ # Disable cache so browser history works
+ no_cache_headers
+ end
+
+ format.json do
+ render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree)
+ end
end
end
def create_dir
return render_404 unless @commit_params.values.all?
- set_start_branch_to_branch_name
create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.",
- success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@branch_name, @dir_name)),
- failure_path: namespace_project_tree_path(@project.namespace, @project, @ref))
+ success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
+ failure_path: project_tree_path(@project, @ref))
end
private
@@ -48,7 +56,7 @@ class Projects::TreeController < Projects::ApplicationController
@dir_name = File.join(@path, params[:dir_name])
@commit_params = {
file_path: @dir_name,
- commit_message: params[:commit_message],
+ commit_message: params[:commit_message]
}
end
end
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index afa56de920b..e04145dd0b3 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -7,7 +7,7 @@ class Projects::TriggersController < Projects::ApplicationController
layout 'project_settings'
def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def create
@@ -19,7 +19,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not create a new trigger.'
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def take_ownership
@@ -29,7 +29,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = 'You could not take ownership of trigger.'
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def edit
@@ -37,7 +37,7 @@ class Projects::TriggersController < Projects::ApplicationController
def update
if trigger.update(trigger_params)
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.'
+ redirect_to project_settings_ci_cd_path(@project), notice: 'Trigger was successfully updated.'
else
render action: "edit"
end
@@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = "Could not remove the trigger."
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project), status: 302
end
private
@@ -69,8 +69,7 @@ class Projects::TriggersController < Projects::ApplicationController
def trigger_params
params.require(:trigger).permit(
- :description,
- trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref]
+ :description
)
end
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 61686499bd3..6966a7c5fee 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -1,33 +1,11 @@
class Projects::UploadsController < Projects::ApplicationController
+ include UploadsActions
+
skip_before_action :project, :repository,
if: -> { action_name == 'show' && image_or_video? }
before_action :authorize_upload_file!, only: [:create]
- def create
- link_to_file = ::Projects::UploadService.new(project, params[:file]).
- execute
-
- respond_to do |format|
- if link_to_file
- format.json do
- render json: { link: link_to_file }
- end
- else
- format.json do
- render json: 'Invalid file.', status: :unprocessable_entity
- end
- end
- end
- end
-
- def show
- return render_404 if uploader.nil? || !uploader.file.exists?
-
- disposition = uploader.image_or_video? ? 'inline' : 'attachment'
- send_file uploader.file.path, disposition: disposition
- end
-
private
def uploader
@@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController
def image_or_video?
uploader && uploader.file.exists? && uploader.image_or_video?
end
+
+ def uploader_class
+ FileUploader
+ end
+
+ alias_method :model, :project
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index a4d1b1ee69b..6a825137564 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -1,47 +1,60 @@
class Projects::VariablesController < Projects::ApplicationController
+ before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
layout 'project_settings'
def index
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to project_settings_ci_cd_path(@project)
end
def show
- @variable = @project.variables.find(params[:id])
end
def update
- @variable = @project.variables.find(params[:id])
-
- if @variable.update_attributes(project_params)
- redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variable was successfully updated.'
+ if variable.update(variable_params)
+ redirect_to project_variables_path(project),
+ notice: 'Variable was successfully updated.'
else
- render action: "show"
+ render "show"
end
end
def create
- @variable = Ci::Variable.new(project_params)
+ @variable = project.variables.create(variable_params)
+ .present(current_user: current_user)
- if @variable.valid? && @project.variables << @variable
- flash[:notice] = 'Variables were successfully updated.'
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project)
+ if @variable.persisted?
+ redirect_to project_settings_ci_cd_path(project),
+ notice: 'Variable was successfully created.'
else
render "show"
end
end
def destroy
- @key = @project.variables.find(params[:id])
- @key.destroy
-
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.'
+ if variable.destroy
+ redirect_to project_settings_ci_cd_path(project),
+ status: 302,
+ notice: 'Variable was successfully removed.'
+ else
+ redirect_to project_settings_ci_cd_path(project),
+ status: 302,
+ notice: 'Failed to remove the variable.'
+ end
end
private
- def project_params
- params.require(:variable).permit([:id, :key, :value, :_destroy])
+ def variable_params
+ params.require(:variable).permit(*variable_params_attributes)
+ end
+
+ def variable_params_attributes
+ %i[id key value protected _destroy]
+ end
+
+ def variable
+ @variable ||= project.variables.find(params[:id]).present(current_user: current_user)
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 96125684da0..968d880886c 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -1,6 +1,4 @@
class Projects::WikisController < Projects::ApplicationController
- include MarkdownPreview
-
before_action :authorize_read_wiki!
before_action :authorize_create_wiki!, only: [:edit, :create, :history]
before_action :authorize_admin_wiki!, only: :destroy
@@ -51,12 +49,15 @@ class Projects::WikisController < Projects::ApplicationController
if @page.valid?
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, @page),
+ project_wiki_path(@project, @page),
notice: 'Wiki was successfully updated.'
)
else
render 'edit'
end
+ rescue WikiPage::PageChangedError
+ @conflict = true
+ render 'edit'
end
def create
@@ -64,7 +65,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.persisted?
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, @page),
+ project_wiki_path(@project, @page),
notice: 'Wiki was successfully updated.'
)
else
@@ -77,7 +78,7 @@ class Projects::WikisController < Projects::ApplicationController
unless @page
redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, :home),
+ project_wiki_path(@project, :home),
notice: "Page not found"
)
end
@@ -87,19 +88,23 @@ class Projects::WikisController < Projects::ApplicationController
@page = @project_wiki.find_page(params[:id])
WikiPages::DestroyService.new(@project, current_user).execute(@page)
- redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, :home),
- notice: "Page was successfully deleted"
- )
+ redirect_to project_wiki_path(@project, :home),
+ status: 302,
+ notice: "Page was successfully deleted"
end
def git_access
end
def preview_markdown
- context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
-
- render_markdown_preview(params[:text], context)
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
+ references: {
+ users: result[:users]
+ }
+ }
end
private
@@ -117,6 +122,6 @@ class Projects::WikisController < Projects::ApplicationController
end
def wiki_params
- params.require(:wiki).permit(:title, :content, :format, :message)
+ params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 9f6ee4826e6..1d24563a6a6 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -1,13 +1,13 @@
class ProjectsController < Projects::ApplicationController
include IssuableCollections
include ExtractsPath
- include MarkdownPreview
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :project, except: [:index, :new, :create]
before_action :repository, except: [:index, :new, :create]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
+ before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@@ -35,7 +35,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Project '#{@project.name}' was successfully created."
+ notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
)
else
render 'new'
@@ -50,11 +50,14 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
- flash[:notice] = "Project '#{@project.name}' was successfully updated."
+ flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
+
format.html do
redirect_to(edit_project_path(@project))
end
else
+ flash[:alert] = result[:message]
+
format.html { render 'edit' }
end
@@ -77,7 +80,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_fork_project, @project)
if ::Projects::UnlinkForkService.new(@project, current_user).execute
- flash[:notice] = 'The fork relationship has been removed.'
+ flash[:notice] = _('The fork relationship has been removed.')
end
end
@@ -93,12 +96,12 @@ class ProjectsController < Projects::ApplicationController
def show
if @project.import_in_progress?
- redirect_to namespace_project_import_path(@project.namespace, @project)
+ redirect_to project_import_path(@project)
return
end
if @project.pending_delete?
- flash[:alert] = "Project #{@project.name} queued for deletion."
+ flash.now[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
end
respond_to do |format|
@@ -109,7 +112,7 @@ class ProjectsController < Projects::ApplicationController
format.atom do
load_events
- render layout: false
+ render layout: 'xml.atom'
end
end
end
@@ -118,11 +121,11 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_project, @project)
::Projects::DestroyService.new(@project, current_user, {}).async_execute
- flash[:alert] = "Project '#{@project.name_with_namespace}' will be deleted."
+ flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace }
- redirect_to dashboard_projects_path
+ redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
- redirect_to edit_project_path(@project), alert: ex.message
+ redirect_to edit_project_path(@project), status: 302, alert: ex.message
end
def new_issue_address
@@ -157,7 +160,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Housekeeping successfully started"
+ notice: _("Housekeeping successfully started")
)
rescue ::Projects::HousekeepingService::LeaseTaken => ex
redirect_to(
@@ -171,7 +174,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
edit_project_path(@project),
- notice: "Project export started. A download link will be sent by email."
+ notice: _("Project export started. A download link will be sent by email.")
)
end
@@ -183,16 +186,16 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export link has expired. Please generate a new export from your project settings."
+ alert: _("Project export link has expired. Please generate a new export from your project settings.")
)
end
end
def remove_export
if @project.remove_exports
- flash[:notice] = "Project export has been deleted."
+ flash[:notice] = _("Project export has been deleted.")
else
- flash[:alert] = "Project export could not be deleted."
+ flash[:alert] = _("Project export could not be deleted.")
end
redirect_to(edit_project_path(@project))
end
@@ -203,7 +206,7 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export could not be deleted."
+ alert: _("Project export could not be deleted.")
)
end
end
@@ -218,21 +221,34 @@ class ProjectsController < Projects::ApplicationController
end
def refs
- branches = BranchesFinder.new(@repository, params).execute.map(&:name)
+ find_refs = params['find']
- options = {
- 'Branches' => branches.take(100),
- }
+ find_branches = true
+ find_tags = true
+ find_commits = true
+
+ unless find_refs.nil?
+ find_branches = find_refs.include?('branches')
+ find_tags = find_refs.include?('tags')
+ find_commits = find_refs.include?('commits')
+ end
- unless @repository.tag_count.zero?
- tags = TagsFinder.new(@repository, params).execute.map(&:name)
+ options = {}
- options['Tags'] = tags.take(100)
+ if find_branches
+ branches = BranchesFinder.new(@repository, params).execute.take(100).map(&:name)
+ options[s_('RefSwitcher|Branches')] = branches
+ end
+
+ if find_tags && @repository.tag_count.nonzero?
+ tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name)
+
+ options[s_('RefSwitcher|Tags')] = tags
end
# If reference is commit id - we should add it to branch/tag selectbox
ref = Addressable::URI.unescape(params[:ref])
- if ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/
+ if find_commits && ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/
options['Commits'] = [ref]
end
@@ -240,7 +256,15 @@ class ProjectsController < Projects::ApplicationController
end
def preview_markdown
- render_markdown_preview(params[:text])
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text]),
+ references: {
+ users: result[:users],
+ commands: view_context.markdown(result[:commands])
+ }
+ }
end
private
@@ -250,7 +274,7 @@ class ProjectsController < Projects::ApplicationController
#
# pages list order: repository readme, wiki home, issues list, customize workflow
def render_landing_page
- if @project.feature_available?(:repository, current_user)
+ if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists?
render 'projects/empty' if @project.empty_repo?
else
@@ -278,18 +302,19 @@ class ProjectsController < Projects::ApplicationController
end
def load_events
- @events = @project.events.recent
- @events = event_filter.apply_filter(@events).with_associations
- limit = (params[:limit] || 20).to_i
- @events = @events.limit(limit).offset(params[:offset] || 0)
+ projects = Project.where(id: @project.id)
+
+ @events = EventCollection
+ .new(projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
end
def project_params
params.require(:project)
- .permit(project_params_ce)
+ .permit(project_params_attributes)
end
- def project_params_ce
+ def project_params_attributes
[
:avatar,
:build_allow_git_fetch,
@@ -314,6 +339,7 @@ class ProjectsController < Projects::ApplicationController
:runners_token,
:tag_list,
:visibility_level,
+ :template_name,
project_feature_attributes: %i[
builds_access_level
@@ -358,4 +384,15 @@ class ProjectsController < Projects::ApplicationController
def project_view_files_allowed?
!project.empty_repo? && can?(current_user, :download_code, project)
end
+
+ def build_canonical_path(project)
+ params[:namespace_id] = project.namespace.to_param
+ params[:id] = project.to_param
+
+ url_for(params)
+ end
+
+ def project_export_enabled
+ render_404 unless current_application_settings.project_export_enabled?
+ end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 3ca14dee33c..1bc6520370a 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -25,12 +25,12 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, current_user.id)
+ current_user.delete_async(deleted_by: current_user)
respond_to do |format|
format.html do
session.try(:destroy)
- redirect_to new_user_session_path, notice: "Account scheduled for removal."
+ redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal."
end
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 4a579601785..d58c8d14a75 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -44,7 +44,7 @@ class SearchController < ApplicationController
query = params[:search].strip.downcase
found_by_commit_sha = Commit.valid_hash?(query) && only_commit.sha.start_with?(query)
- redirect_to namespace_project_commit_path(@project.namespace, @project, only_commit) if found_by_commit_sha
+ redirect_to project_commit_path(@project, only_commit) if found_by_commit_sha
end
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 8c6ba4915cd..9e743685d60 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -5,6 +5,14 @@ class SessionsController < Devise::SessionsController
skip_before_action :check_two_factor_requirement, only: [:destroy]
+ # Explicitly call protect from forgery before anything else. Otherwise the
+ # CSFR-token might be cleared before authentication is done. This was the case
+ # when LDAP was enabled and the `OmniauthCallbacksController` is loaded
+ #
+ # *Note:* `prepend: true` is the default for rails4, but this will be changed
+ # to `prepend: false` in rails5.
+ protect_from_forgery prepend: true, with: :exception
+
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
if: :two_factor_enabled?, only: [:create]
@@ -15,12 +23,7 @@ class SessionsController < Devise::SessionsController
def new
set_minimum_password_length
- @ldap_servers =
- if Gitlab.config.ldap.enabled
- Gitlab::LDAP::Config.servers
- else
- []
- end
+ @ldap_servers = Gitlab::LDAP::Config.available_servers
super
end
@@ -47,6 +50,10 @@ class SessionsController < Devise::SessionsController
private
+ def login_counter
+ @login_counter ||= Gitlab::Metrics.counter(:user_session_logins_total, 'User sign in count')
+ end
+
# Handle an "initial setup" state, where there's only one user, it's an admin,
# and they require a password change.
def check_initial_setup
@@ -54,12 +61,13 @@ class SessionsController < Devise::SessionsController
user = User.admins.last
- return unless user && user.require_password?
+ return unless user && user.require_password_creation?
- token = user.generate_reset_token
- user.save
+ Users::UpdateService.new(user).execute do |user|
+ @token = user.generate_reset_token
+ end
- redirect_to edit_user_password_path(reset_password_token: token),
+ redirect_to edit_user_password_path(reset_password_token: @token),
notice: "Please create a password for your new account."
end
@@ -90,7 +98,7 @@ class SessionsController < Devise::SessionsController
# Prevent a 'you are already signed in' message directly after signing:
# we should never redirect to '/users/sign_in' after signing in successfully.
- unless redirect_path == new_user_session_path
+ unless URI(redirect_path).path == new_user_session_path
store_location_for(:redirect, redirect_path)
end
end
@@ -103,6 +111,10 @@ class SessionsController < Devise::SessionsController
provider = Gitlab.config.omniauth.auto_sign_in_with_provider
return unless provider.present?
+ # If a "auto_sign_in" query parameter is set to a falsy value, don't auto sign-in.
+ # Otherwise, the default is to auto sign-in.
+ return if Gitlab::Utils.to_boolean(params[:auto_sign_in]) == false
+
# Auto sign in with an Omniauth provider only if the standard "you need to sign-in" alert is
# registered or no alert at all. In case of another alert (such as a blocked user), it is safer
# to do nothing to prevent redirection loops with certain Omniauth providers.
@@ -120,11 +132,12 @@ class SessionsController < Devise::SessionsController
end
def log_audit_event(user, options = {})
- AuditEventService.new(user, user, options).
- for_authentication.security_event
+ AuditEventService.new(user, user, options)
+ .for_authentication.security_event
end
def log_user_activity(user)
+ login_counter.increment
Users::ActivityService.new(user, 'login').execute
end
diff --git a/app/controllers/sherlock/application_controller.rb b/app/controllers/sherlock/application_controller.rb
index 682ca5e3821..6bdd3568a78 100644
--- a/app/controllers/sherlock/application_controller.rb
+++ b/app/controllers/sherlock/application_controller.rb
@@ -4,8 +4,8 @@ module Sherlock
def find_transaction
if params[:transaction_id]
- @transaction = Gitlab::Sherlock.collection.
- find_transaction(params[:transaction_id])
+ @transaction = Gitlab::Sherlock.collection
+ .find_transaction(params[:transaction_id])
end
end
end
diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb
index ccc739da879..cb6c3a7cd98 100644
--- a/app/controllers/sherlock/transactions_controller.rb
+++ b/app/controllers/sherlock/transactions_controller.rb
@@ -13,7 +13,7 @@ module Sherlock
def destroy_all
Gitlab::Sherlock.collection.clear
- redirect_to(:back)
+ redirect_to :back, status: 302
end
end
end
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
new file mode 100644
index 00000000000..f9496787b15
--- /dev/null
+++ b/app/controllers/snippets/notes_controller.rb
@@ -0,0 +1,35 @@
+class Snippets::NotesController < ApplicationController
+ include NotesActions
+ include ToggleAwardEmoji
+
+ skip_before_action :authenticate_user!, only: [:index]
+ before_action :snippet
+ before_action :authorize_read_snippet!, only: [:show, :index, :create]
+
+ private
+
+ def note
+ @note ||= snippet.notes.find(params[:id])
+ end
+ alias_method :awardable, :note
+
+ def project
+ nil
+ end
+
+ def snippet
+ PersonalSnippet.find_by(id: params[:snippet_id])
+ end
+
+ def note_params
+ super.merge(noteable_id: params[:snippet_id])
+ end
+
+ def finder_params
+ params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet')
+ end
+
+ def authorize_read_snippet!
+ return render_404 unless can?(current_user, :read_personal_snippet, snippet)
+ end
+end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 056910fad67..8c3abd0a085 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -1,13 +1,14 @@
class SnippetsController < ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
- include MarkdownPreview
+ include RendersBlob
- before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :download]
+ before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
# Allow read snippet
- before_action :authorize_read_snippet!, only: [:show, :raw, :download]
+ before_action :authorize_read_snippet!, only: [:show, :raw]
# Allow modify snippet
before_action :authorize_update_snippet!, only: [:edit, :update]
@@ -15,7 +16,7 @@ class SnippetsController < ApplicationController
# Allow destroy snippet
before_action :authorize_admin_snippet!, only: [:destroy]
- skip_before_action :authenticate_user!, only: [:index, :show, :raw, :download]
+ skip_before_action :authenticate_user!, only: [:index, :show, :raw]
layout 'snippets'
respond_to :html
@@ -26,12 +27,8 @@ class SnippetsController < ApplicationController
return render_404 unless @user
- @snippets = SnippetsFinder.new.execute(current_user, {
- filter: :by_user,
- user: @user,
- scope: params[:scope]
- })
- .page(params[:page])
+ @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope])
+ .execute.page(params[:page])
render 'index'
else
@@ -48,6 +45,8 @@ class SnippetsController < ApplicationController
@snippet = CreateSnippetService.new(nil, current_user, create_params).execute
+ move_temporary_files if @snippet.valid? && params[:files]
+
recaptcha_check_with_fallback { render :new }
end
@@ -60,6 +59,24 @@ class SnippetsController < ApplicationController
end
def show
+ blob = @snippet.blob
+ conditionally_expand_blob(blob)
+
+ @note = Note.new(noteable: @snippet)
+ @noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
+
+ respond_to do |format|
+ format.html do
+ render 'show'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
+ end
end
def destroy
@@ -67,38 +84,41 @@ class SnippetsController < ApplicationController
@snippet.destroy
- redirect_to snippets_path
- end
-
- def download
- send_data(
- convert_line_endings(@snippet.content),
- type: 'text/plain; charset=utf-8',
- filename: @snippet.sanitized_file_name
- )
+ redirect_to snippets_path, status: 302
end
def preview_markdown
- render_markdown_preview(params[:text], skip_project_check: true)
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+
+ render json: {
+ body: view_context.markdown(result[:text], skip_project_check: true),
+ references: {
+ users: result[:users]
+ }
+ }
end
protected
def snippet
- @snippet ||= if current_user
- PersonalSnippet.where("author_id = ? OR visibility_level IN (?)",
- current_user.id,
- [Snippet::PUBLIC, Snippet::INTERNAL]).
- find(params[:id])
- else
- PersonalSnippet.find(params[:id])
- end
+ @snippet ||= PersonalSnippet.find_by(id: params[:id])
end
+
alias_method :awardable, :snippet
alias_method :spammable, :snippet
+ def spammable_path
+ snippet_path(@snippet)
+ end
+
def authorize_read_snippet!
- authenticate_user! unless can?(current_user, :read_personal_snippet, @snippet)
+ return if can?(current_user, :read_personal_snippet, @snippet)
+
+ if current_user
+ render_404
+ else
+ authenticate_user!
+ end
end
def authorize_update_snippet!
@@ -110,6 +130,12 @@ class SnippetsController < ApplicationController
end
def snippet_params
- params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
+ end
+
+ def move_temporary_files
+ params[:files].each do |file|
+ FileMover.new(file, @snippet).execute
+ end
end
end
diff --git a/app/controllers/unicorn_test_controller.rb b/app/controllers/unicorn_test_controller.rb
new file mode 100644
index 00000000000..ed04bd1f77d
--- /dev/null
+++ b/app/controllers/unicorn_test_controller.rb
@@ -0,0 +1,14 @@
+# :nocov:
+if Rails.env.test?
+ class UnicornTestController < ActionController::Base
+ def pid
+ render plain: Process.pid.to_s
+ end
+
+ def kill
+ Process.kill(params[:signal], Process.pid)
+ render plain: 'Bye!'
+ end
+ end
+end
+# :nocov:
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index f1bfd574f04..16a74f82d3f 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -1,50 +1,51 @@
class UploadsController < ApplicationController
- skip_before_action :authenticate_user!
- before_action :find_model, :authorize_access!
-
- def show
- uploader = @model.send(upload_mount)
+ include UploadsActions
- unless uploader.file_storage?
- return redirect_to uploader.url
- end
-
- unless uploader.file && uploader.file.exists?
- return render_404
- end
-
- disposition = uploader.image? ? 'inline' : 'attachment'
-
- expires_in 0.seconds, must_revalidate: true, private: true
- send_file uploader.file.path, disposition: disposition
- end
+ skip_before_action :authenticate_user!
+ before_action :find_model
+ before_action :authorize_access!, only: [:show]
+ before_action :authorize_create_access!, only: [:create]
private
def find_model
- unless upload_model && upload_mount
- return render_404
- end
+ return nil unless params[:id]
+
+ return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id])
end
def authorize_access!
+ return nil unless model
+
authorized =
- case @model
- when Project
- can?(current_user, :read_project, @model)
- when Group
- can?(current_user, :read_group, @model)
+ case model
when Note
- can?(current_user, :read_project, @model.project)
- else
- # No authentication required for user avatars.
+ can?(current_user, :read_project, model.project)
+ when User
+ true
+ when Appearance
true
+ else
+ permission = "read_#{model.class.to_s.underscore}".to_sym
+
+ can?(current_user, permission, model)
end
- return if authorized
+ render_unauthorized unless authorized
+ end
+
+ def authorize_create_access!
+ return nil unless model
+ # for now we support only personal snippets comments
+ authorized = can?(current_user, :comment_personal_snippet, model)
+
+ render_unauthorized unless authorized
+ end
+
+ def render_unauthorized
if current_user
render_404
else
@@ -58,17 +59,49 @@ class UploadsController < ApplicationController
"project" => Project,
"note" => Note,
"group" => Group,
- "appearance" => Appearance
+ "appearance" => Appearance,
+ "personal_snippet" => PersonalSnippet
}
upload_models[params[:model]]
end
def upload_mount
+ return true unless params[:mounted_as]
+
upload_mounts = %w(avatar attachment file logo header_logo)
if upload_mounts.include?(params[:mounted_as])
params[:mounted_as]
end
end
+
+ def uploader
+ return @uploader if defined?(@uploader)
+
+ case model
+ when nil
+ @uploader = PersonalFileUploader.new(nil, params[:secret])
+
+ @uploader.retrieve_from_store!(params[:filename])
+ when PersonalSnippet
+ @uploader = PersonalFileUploader.new(model, params[:secret])
+
+ @uploader.retrieve_from_store!(params[:filename])
+ else
+ @uploader = @model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
+
+ redirect_to @uploader.url unless @uploader.file_storage?
+ end
+
+ @uploader
+ end
+
+ def uploader_class
+ PersonalFileUploader
+ end
+
+ def model
+ @model ||= find_model
+ end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index a452bbba422..4ee855806ab 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,7 +1,8 @@
class UsersController < ApplicationController
+ include RoutableActions
+
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
- before_action :authorize_read_user!, only: [:show]
def show
respond_to do |format|
@@ -9,7 +10,7 @@ class UsersController < ApplicationController
format.atom do
load_events
- render layout: false
+ render layout: 'xml.atom'
end
format.json do
@@ -72,10 +73,7 @@ class UsersController < ApplicationController
end
def calendar
- calendar = contributions_calendar
- @activity_dates = calendar.activity_dates
-
- render 'calendar', layout: false
+ render json: contributions_calendar.activity_dates
end
def calendar_activities
@@ -91,12 +89,8 @@ class UsersController < ApplicationController
private
- def authorize_read_user!
- render_404 unless can?(current_user, :read_user, user)
- end
-
def user
- @user ||= User.find_by_username!(params[:username])
+ @user ||= find_routable!(User, params[:username])
end
def contributed_projects
@@ -109,11 +103,11 @@ class UsersController < ApplicationController
def load_events
# Get user activity feed for projects common for both users
- @events = user.recent_events.
- merge(projects_for_current_user).
- references(:project).
- with_associations.
- limit_recent(20, params[:offset])
+ @events = user.recent_events
+ .merge(projects_for_current_user)
+ .references(:project)
+ .with_associations
+ .limit_recent(20, params[:offset])
end
def load_projects
@@ -131,15 +125,18 @@ class UsersController < ApplicationController
end
def load_snippets
- @snippets = SnippetsFinder.new.execute(
+ @snippets = SnippetsFinder.new(
current_user,
- filter: :by_user,
- user: user,
+ author: user,
scope: params[:scope]
- ).page(params[:page])
+ ).execute.page(params[:page])
end
def projects_for_current_user
ProjectsFinder.new(current_user: current_user).execute
end
+
+ def build_canonical_path(user)
+ url_for(params.merge(username: user.to_param))
+ end
end
diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb
new file mode 100644
index 00000000000..7176bfe22d6
--- /dev/null
+++ b/app/finders/admin/projects_finder.rb
@@ -0,0 +1,33 @@
+class Admin::ProjectsFinder
+ attr_reader :sort, :namespace_id, :visibility_level, :with_push,
+ :abandoned, :last_repository_check_failed, :archived,
+ :personal, :name, :page, :current_user
+
+ def initialize(params:, current_user:)
+ @current_user = current_user
+ @sort = params.fetch(:sort) { 'latest_activity_desc' }
+ @namespace_id = params[:namespace_id]
+ @visibility_level = params[:visibility_level]
+ @with_push = params[:with_push]
+ @abandoned = params[:abandoned]
+ @last_repository_check_failed = params[:last_repository_check_failed]
+ @archived = params[:archived]
+ @personal = params[:personal]
+ @name = params[:name]
+ @page = params[:page]
+ end
+
+ def execute
+ items = Project.without_deleted.with_statistics
+ items = items.in_namespace(namespace_id) if namespace_id.present?
+ items = items.where(visibility_level: visibility_level) if visibility_level.present?
+ items = items.with_push if with_push.present?
+ items = items.abandoned if abandoned.present?
+ items = items.where(last_repository_check_failed: true) if last_repository_check_failed.present?
+ items = items.non_archived unless archived.present?
+ items = items.personal(current_user) if personal.present?
+ items = items.search(name) if name.present?
+ items = items.sort(sort)
+ items.includes(:namespace).order("namespaces.path, projects.name ASC").page(page)
+ end
+end
diff --git a/app/finders/concerns/created_at_filter.rb b/app/finders/concerns/created_at_filter.rb
new file mode 100644
index 00000000000..ac9ac77732c
--- /dev/null
+++ b/app/finders/concerns/created_at_filter.rb
@@ -0,0 +1,8 @@
+module CreatedAtFilter
+ def by_created_at(items)
+ items = items.created_before(params[:created_before]) if params[:created_before].present?
+ items = items.created_after(params[:created_after]) if params[:created_after].present?
+
+ items
+ end
+end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
new file mode 100644
index 00000000000..46ecbaba73a
--- /dev/null
+++ b/app/finders/events_finder.rb
@@ -0,0 +1,63 @@
+class EventsFinder
+ attr_reader :source, :params, :current_user
+
+ # Used to filter Events
+ #
+ # Arguments:
+ # source - which user or project to looks for events on
+ # current_user - only return events for projects visible to this user
+ # params:
+ # action: string
+ # target_type: string
+ # before: datetime
+ # after: datetime
+ #
+ def initialize(params = {})
+ @source = params.delete(:source)
+ @current_user = params.delete(:current_user)
+ @params = params
+ end
+
+ def execute
+ events = source.events
+
+ events = by_current_user_access(events)
+ events = by_action(events)
+ events = by_target_type(events)
+ events = by_created_at_before(events)
+ events = by_created_at_after(events)
+
+ events
+ end
+
+ private
+
+ def by_current_user_access(events)
+ events.merge(ProjectsFinder.new(current_user: current_user).execute)
+ .joins(:project)
+ end
+
+ def by_action(events)
+ return events unless Event::ACTIONS[params[:action]]
+
+ events.where(action: Event::ACTIONS[params[:action]])
+ end
+
+ def by_target_type(events)
+ return events unless Event::TARGET_TYPES[params[:target_type]]
+
+ events.where(target_type: Event::TARGET_TYPES[params[:target_type]])
+ end
+
+ def by_created_at_before(events)
+ return events unless params[:before]
+
+ events.where('events.created_at < ?', params[:before].beginning_of_day)
+ end
+
+ def by_created_at_after(events)
+ return events unless params[:after]
+
+ events.where('events.created_at > ?', params[:after].end_of_day)
+ end
+end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index fce3775f40e..067aff408df 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -8,9 +8,9 @@ class GroupMembersFinder
return group_members unless @group.parent
- parents_members = GroupMember.non_request.
- where(source_id: @group.ancestors.select(:id)).
- where.not(user_id: @group.users.select(:id))
+ parents_members = GroupMember.non_request
+ .where(source_id: @group.ancestors.select(:id))
+ .where.not(user_id: @group.users.select(:id))
wheres = ["members.id IN (#{group_members.select(:id).to_sql})"]
wheres << "members.id IN (#{parents_members.select(:id).to_sql})"
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index f043c38c6f9..f2d3b90b8e2 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -29,35 +29,69 @@ class GroupProjectsFinder < ProjectsFinder
private
def init_collection
- only_owned = options.fetch(:only_owned, false)
- only_shared = options.fetch(:only_shared, false)
+ projects = if current_user
+ collection_with_user
+ else
+ collection_without_user
+ end
- projects = []
+ union(projects)
+ end
- if current_user
- if group.users.include?(current_user)
- projects << group.projects unless only_shared
- projects << group.shared_projects unless only_owned
+ def collection_with_user
+ if group.users.include?(current_user)
+ if only_shared?
+ [shared_projects]
+ elsif only_owned?
+ [owned_projects]
else
- unless only_shared
- projects << group.projects.visible_to_user(current_user)
- projects << group.projects.public_to_user(current_user)
- end
-
- unless only_owned
- projects << group.shared_projects.visible_to_user(current_user)
- projects << group.shared_projects.public_to_user(current_user)
- end
+ [shared_projects, owned_projects]
end
else
- projects << group.projects.public_only unless only_shared
- projects << group.shared_projects.public_only unless only_owned
+ if only_shared?
+ [shared_projects.public_or_visible_to_user(current_user)]
+ elsif only_owned?
+ [owned_projects.public_or_visible_to_user(current_user)]
+ else
+ [
+ owned_projects.public_or_visible_to_user(current_user),
+ shared_projects.public_or_visible_to_user(current_user)
+ ]
+ end
end
+ end
- projects
+ def collection_without_user
+ if only_shared?
+ [shared_projects.public_only]
+ elsif only_owned?
+ [owned_projects.public_only]
+ else
+ [shared_projects.public_only, owned_projects.public_only]
+ end
end
def union(items)
- find_union(items, Project)
+ if items.one?
+ items.first
+ else
+ find_union(items, Project)
+ end
+ end
+
+ def only_owned?
+ options.fetch(:only_owned, false)
+ end
+
+ def only_shared?
+ options.fetch(:only_shared, false)
+ end
+
+ def owned_projects
+ group.projects
+ end
+
+ def shared_projects
+ group.shared_projects
end
end
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index d932a17883f..e6fb112e7f2 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -1,18 +1,42 @@
class GroupsFinder < UnionFinder
- def execute(current_user = nil)
- segments = all_groups(current_user)
+ def initialize(current_user = nil, params = {})
+ @current_user = current_user
+ @params = params
+ end
- find_union(segments, Group).with_route.order_id_desc
+ def execute
+ items = all_groups.map do |item|
+ by_parent(item)
+ end
+ find_union(items, Group).with_route.order_id_desc
end
private
- def all_groups(current_user)
+ attr_reader :current_user, :params
+
+ def all_groups
groups = []
- groups << current_user.authorized_groups if current_user
+ if current_user
+ groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups
+ end
groups << Group.unscoped.public_to_user(current_user)
groups
end
+
+ def groups_for_ancestors
+ current_user.authorized_groups
+ end
+
+ def groups_for_descendants
+ current_user.groups
+ end
+
+ def by_parent(groups)
+ return groups unless params[:parent]
+
+ groups.where(parent: params[:parent])
+ end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 4cc42b88a2a..08a843ada97 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -11,6 +11,7 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# author_id: integer
# assignee_id: integer
# search: string
# label_name: string
@@ -19,7 +20,10 @@
# iids: integer[]
#
class IssuableFinder
+ include CreatedAtFilter
+
NONE = '0'.freeze
+ IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze
attr_accessor :current_user, :params
@@ -31,6 +35,7 @@ class IssuableFinder
def execute
items = init_collection
items = by_scope(items)
+ items = by_created_at(items)
items = by_state(items)
items = by_group(items)
items = by_search(items)
@@ -61,7 +66,7 @@ class IssuableFinder
# grouping and counting within that query.
#
def count_by_state
- count_params = params.merge(state: nil, sort: nil)
+ count_params = params.merge(state: nil, sort: nil, for_counting: true)
labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params)
counts = Hash.new(0)
@@ -76,7 +81,6 @@ class IssuableFinder
end
counts[:all] = counts.values.sum
- counts[:opened] += counts[:reopened]
counts
end
@@ -85,6 +89,16 @@ class IssuableFinder
execute.find_by!(*params)
end
+ def state_counter_cache_key
+ cache_key(state_counter_cache_key_components)
+ end
+
+ def clear_caches!
+ state_counter_cache_key_components_permutations.each do |components|
+ Rails.cache.delete(cache_key(components))
+ end
+ end
+
def group
return @group if defined?(@group)
@@ -141,9 +155,17 @@ class IssuableFinder
@milestones =
if milestones?
- scope = Milestone.where(project_id: projects)
+ if project?
+ group_id = project.group&.id
+ project_id = project.id
+ end
+
+ group_id = group.id if group
- scope.where(title: params[:milestone_title])
+ search_params =
+ { title: params[:milestone_title], project_ids: project_id, group_ids: group_id }
+
+ MilestonesFinder.new(search_params).execute
else
Milestone.none
end
@@ -231,7 +253,7 @@ class IssuableFinder
when 'created-by-me', 'authored'
items.where(author_id: current_user.id)
when 'assigned-to-me'
- items.where(assignee_id: current_user.id)
+ items.assigned_to(current_user)
else
items
end
@@ -325,11 +347,6 @@ class IssuableFinder
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else
items = items.with_milestone(params[:milestone_title])
- items_projects = projects(items)
-
- if items_projects
- items = items.where(milestones: { project_id: items_projects })
- end
end
end
@@ -405,4 +422,20 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
+
+ def state_counter_cache_key_components
+ opts = params.with_indifferent_access
+ opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
+ opts.delete_if { |_, value| value.blank? }
+
+ ['issuables_count', klass.to_ability_name, opts.sort]
+ end
+
+ def state_counter_cache_key_components_permutations
+ [state_counter_cache_key_components]
+ end
+
+ def cache_key(components)
+ Digest::SHA1.hexdigest(components.flatten.join('-'))
+ end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 76715e5970d..0ec42a4e6eb 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -16,30 +16,94 @@
# sort: string
#
class IssuesFinder < IssuableFinder
+ CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
+
def klass
Issue
end
+ def with_confidentiality_access_check
+ return Issue.all if user_can_see_all_confidential_issues?
+ return Issue.where('issues.confidential IS NOT TRUE') if user_cannot_see_confidential_issues?
+
+ Issue.where('
+ issues.confidential IS NOT TRUE
+ OR (issues.confidential = TRUE
+ AND (issues.author_id = :user_id
+ OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
+ OR issues.project_id IN(:project_ids)))',
+ user_id: current_user.id,
+ project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
+ end
+
private
def init_collection
- IssuesFinder.not_restricted_by_confidentiality(current_user)
+ with_confidentiality_access_check
end
- def self.not_restricted_by_confidentiality(user)
- return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
+ def user_can_see_all_confidential_issues?
+ return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues)
- return Issue.all if user.admin?
+ return @user_can_see_all_confidential_issues = false if current_user.blank?
+ return @user_can_see_all_confidential_issues = true if current_user.full_private_access?
- Issue.where('
- issues.confidential IS NULL
- OR issues.confidential IS FALSE
- OR (issues.confidential = TRUE
- AND (issues.author_id = :user_id
- OR issues.assignee_id = :user_id
- OR issues.project_id IN(:project_ids)))',
- user_id: user.id,
- project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
+ @user_can_see_all_confidential_issues =
+ project? &&
+ project &&
+ project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
+ end
+
+ # Anonymous users can't see any confidential issues.
+ #
+ # Users without access to see _all_ confidential issues (as in
+ # `user_can_see_all_confidential_issues?`) are more complicated, because they
+ # can see confidential issues where:
+ # 1. They are an assignee.
+ # 2. They are an author.
+ #
+ # That's fine for most cases, but if we're just counting, we need to cache
+ # effectively. If we cached this accurately, we'd have a cache key for every
+ # authenticated user without sufficient access to the project. Instead, when
+ # we are counting, we treat them as if they can't see any confidential issues.
+ #
+ # This does mean the counts may be wrong for those users, but avoids an
+ # explosion in cache keys.
+ def user_cannot_see_confidential_issues?(for_counting: false)
+ return false if user_can_see_all_confidential_issues?
+
+ current_user.blank? || for_counting || params[:for_counting]
+ end
+
+ def state_counter_cache_key_components
+ extra_components = [
+ user_can_see_all_confidential_issues?,
+ user_cannot_see_confidential_issues?(for_counting: true)
+ ]
+
+ super + extra_components
+ end
+
+ def state_counter_cache_key_components_permutations
+ # Ignore the last two, as we'll provide both options for them.
+ components = super.first[0..-3]
+
+ [
+ components + [false, true],
+ components + [true, false]
+ ]
+ end
+
+ def by_assignee(items)
+ if assignee
+ items.assigned_to(assignee)
+ elsif no_assignee?
+ items.unassigned
+ elsif assignee_id? || assignee_username? # assignee not found
+ items.none
+ else
+ items
+ end
end
def item_project_ids(items)
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index 042d792dada..ce432ddbfe6 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -83,7 +83,12 @@ class LabelsFinder < UnionFinder
def projects
return @projects if defined?(@projects)
- @projects = skip_authorization ? Project.all : ProjectsFinder.new(current_user: current_user).execute
+ @projects = if skip_authorization
+ Project.all
+ else
+ ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute
+ end
+
@projects = @projects.in_namespace(params[:group_id]) if group?
@projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil)
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 2fc34f186ad..771da3d441d 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -10,6 +10,7 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# author_id: integer
# assignee_id: integer
# search: string
# label_name: string
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 630c73c2a94..0a5a0ea2f35 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -1,12 +1,56 @@
+# Search for milestones
+#
+# params - Hash
+# project_ids: Array of project ids or single project id.
+# group_ids: Array of group ids or single group id.
+# order - Orders by field default due date asc.
+# title - filter by title.
+# state - filters by state.
+
class MilestonesFinder
- def execute(projects, params)
- milestones = Milestone.of_projects(projects)
- milestones = milestones.reorder("due_date ASC")
-
- case params[:state]
- when 'closed' then milestones.closed
- when 'all' then milestones
- else milestones.active
+ attr_reader :params, :project_ids, :group_ids
+
+ def initialize(params = {})
+ @project_ids = Array(params[:project_ids])
+ @group_ids = Array(params[:group_ids])
+ @params = params
+ end
+
+ def execute
+ return Milestone.none if project_ids.empty? && group_ids.empty?
+
+ items = Milestone.all
+ items = by_groups_and_projects(items)
+ items = by_title(items)
+ items = by_state(items)
+
+ order(items)
+ end
+
+ private
+
+ def by_groups_and_projects(items)
+ items.for_projects_and_groups(project_ids, group_ids)
+ end
+
+ def by_title(items)
+ if params[:title]
+ items.where(title: params[:title])
+ else
+ items
+ end
+ end
+
+ def by_state(items)
+ Milestone.filter_by_state(items, params[:state])
+ end
+
+ def order(items)
+ if params.has_key?(:order)
+ items.reorder(params[:order])
+ else
+ order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
+ items.reorder(order_statement)
end
end
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 3c499184b41..02eb983bf55 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -67,7 +67,9 @@ class NotesFinder
when "merge_request"
MergeRequestsFinder.new(@current_user, project_id: @project.id).execute
when "snippet", "project_snippet"
- SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project)
+ SnippetsFinder.new(@current_user, project: @project).execute
+ when "personal_snippet"
+ PersonalSnippet.all
else
raise 'invalid target_type'
end
diff --git a/app/finders/pipeline_schedules_finder.rb b/app/finders/pipeline_schedules_finder.rb
new file mode 100644
index 00000000000..2ac4289fbbe
--- /dev/null
+++ b/app/finders/pipeline_schedules_finder.rb
@@ -0,0 +1,22 @@
+class PipelineSchedulesFinder
+ attr_reader :project, :pipeline_schedules
+
+ def initialize(project)
+ @project = project
+ @pipeline_schedules = project.pipeline_schedules
+ end
+
+ def execute(scope: nil)
+ scoped_schedules =
+ case scope
+ when 'active'
+ pipeline_schedules.active
+ when 'inactive'
+ pipeline_schedules.inactive
+ else
+ pipeline_schedules
+ end
+
+ scoped_schedules.order(id: :desc)
+ end
+end
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index a9172f6767f..f187a3b61fe 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -1,29 +1,23 @@
class PipelinesFinder
- attr_reader :project, :pipelines
+ attr_reader :project, :pipelines, :params
- def initialize(project)
+ ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze
+
+ def initialize(project, params = {})
@project = project
@pipelines = project.pipelines
+ @params = params
end
- def execute(scope: nil)
- scoped_pipelines =
- case scope
- when 'running'
- pipelines.running
- when 'pending'
- pipelines.pending
- when 'finished'
- pipelines.finished
- when 'branches'
- from_ids(ids_for_ref(branches))
- when 'tags'
- from_ids(ids_for_ref(tags))
- else
- pipelines
- end
-
- scoped_pipelines.order(id: :desc)
+ def execute
+ items = pipelines
+ items = by_scope(items)
+ items = by_status(items)
+ items = by_ref(items)
+ items = by_name(items)
+ items = by_username(items)
+ items = by_yaml_errors(items)
+ sort_items(items)
end
private
@@ -43,4 +37,78 @@ class PipelinesFinder
def tags
project.repository.tag_names
end
+
+ def by_scope(items)
+ case params[:scope]
+ when 'running'
+ items.running
+ when 'pending'
+ items.pending
+ when 'finished'
+ items.finished
+ when 'branches'
+ from_ids(ids_for_ref(branches))
+ when 'tags'
+ from_ids(ids_for_ref(tags))
+ else
+ items
+ end
+ end
+
+ def by_status(items)
+ return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+
+ items.where(status: params[:status])
+ end
+
+ def by_ref(items)
+ if params[:ref].present?
+ items.where(ref: params[:ref])
+ else
+ items
+ end
+ end
+
+ def by_name(items)
+ if params[:name].present?
+ items.joins(:user).where(users: { name: params[:name] })
+ else
+ items
+ end
+ end
+
+ def by_username(items)
+ if params[:username].present?
+ items.joins(:user).where(users: { username: params[:username] })
+ else
+ items
+ end
+ end
+
+ def by_yaml_errors(items)
+ case Gitlab::Utils.to_boolean(params[:yaml_errors])
+ when true
+ items.where("yaml_errors IS NOT NULL")
+ when false
+ items.where("yaml_errors IS NULL")
+ else
+ items
+ end
+ end
+
+ def sort_items(items)
+ order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by])
+ params[:order_by]
+ else
+ :id
+ end
+
+ sort = if params[:sort] =~ /\A(ASC|DESC)\z/i
+ params[:sort]
+ else
+ :desc
+ end
+
+ items.order(order_by => sort)
+ end
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index f6d8226bf3f..aa80dfc3f37 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -7,6 +7,7 @@
# project_ids_relation: int[] - project ids to use
# params:
# trending: boolean
+# owned: boolean
# non_public: boolean
# starred: boolean
# sort: string
@@ -27,36 +28,67 @@ class ProjectsFinder < UnionFinder
end
def execute
- items = init_collection
- items = by_ids(items)
- items = union(items)
- items = by_personal(items)
- items = by_visibilty_level(items)
- items = by_tags(items)
- items = by_search(items)
- items = by_archived(items)
- sort(items)
+ user = params.delete(:user)
+ collection =
+ if user
+ PersonalProjectsFinder.new(user).execute(current_user)
+ else
+ init_collection
+ end
+
+ collection = by_ids(collection)
+ collection = by_personal(collection)
+ collection = by_starred(collection)
+ collection = by_trending(collection)
+ collection = by_visibilty_level(collection)
+ collection = by_tags(collection)
+ collection = by_search(collection)
+ collection = by_archived(collection)
+
+ sort(collection)
end
private
def init_collection
- projects = []
+ if current_user
+ collection_with_user
+ else
+ collection_without_user
+ end
+ end
+
+ def collection_with_user
+ if owned_projects?
+ current_user.owned_projects
+ else
+ if private_only?
+ current_user.authorized_projects
+ else
+ Project.public_or_visible_to_user(current_user)
+ end
+ end
+ end
- if params[:trending].present?
- projects << Project.trending
- elsif params[:starred].present? && current_user
- projects << current_user.viewable_starred_projects
+ # Builds a collection for an anonymous user.
+ def collection_without_user
+ if private_only? || owned_projects?
+ Project.none
else
- projects << current_user.authorized_projects if current_user
- projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present?
+ Project.public_to_user
end
+ end
+
+ def owned_projects?
+ params[:owned].present?
+ end
- projects
+ def private_only?
+ params[:non_public].present?
end
def by_ids(items)
- project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items
+ project_ids_relation ? items.where(id: project_ids_relation) : items
end
def union(items)
@@ -67,6 +99,14 @@ class ProjectsFinder < UnionFinder
(params[:personal].present? && current_user) ? items.personal(current_user) : items
end
+ def by_starred(items)
+ (params[:starred].present? && current_user) ? items.starred_by(current_user) : items
+ end
+
+ def by_trending(items)
+ params[:trending].present? ? items.trending : items
+ end
+
def by_visibilty_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index da6e6e87a6f..c04f61de79c 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -1,66 +1,74 @@
-class SnippetsFinder
- def execute(current_user, params = {})
- filter = params[:filter]
- user = params.fetch(:user, current_user)
-
- case filter
- when :all then
- snippets(current_user).fresh
- when :public then
- Snippet.are_public.fresh
- when :by_user then
- by_user(current_user, user, params[:scope])
- when :by_project
- by_project(current_user, params[:project], params[:scope])
- end
+class SnippetsFinder < UnionFinder
+ attr_accessor :current_user, :params
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ items = init_collection
+ items = by_project(items)
+ items = by_author(items)
+ items = by_visibility(items)
+
+ items.fresh
end
private
- def snippets(current_user)
- if current_user
- Snippet.public_and_internal
- else
- # Not authenticated
- #
- # Return only:
- # public snippets
- Snippet.are_public
- end
+ def init_collection
+ items = Snippet.all
+
+ accessible(items)
end
- def by_user(current_user, user, scope)
- snippets = user.snippets.fresh
+ def accessible(items)
+ segments = []
+ segments << items.public_to_user(current_user)
+ segments << authorized_to_user(items) if current_user
- if current_user
- include_private = user == current_user
- by_scope(snippets, scope, include_private)
- else
- snippets.are_public
- end
+ find_union(segments, Snippet)
end
- def by_project(current_user, project, scope)
- snippets = project.snippets.fresh
+ def authorized_to_user(items)
+ items.where(
+ 'author_id = :author_id
+ OR project_id IN (:project_ids)',
+ author_id: current_user.id,
+ project_ids: current_user.authorized_projects.select(:id))
+ end
- if current_user
- include_private = project.team.member?(current_user) || current_user.admin?
- by_scope(snippets, scope, include_private)
- else
- snippets.are_public
- end
+ def by_visibility(items)
+ visibility = params[:visibility] || visibility_from_scope
+
+ return items unless visibility
+
+ items.where(visibility_level: visibility)
+ end
+
+ def by_author(items)
+ return items unless params[:author]
+
+ items.where(author_id: params[:author].id)
+ end
+
+ def by_project(items)
+ return items unless params[:project]
+
+ items.where(project_id: params[:project].id)
end
- def by_scope(snippets, scope = nil, include_private = false)
- case scope.to_s
+ def visibility_from_scope
+ case params[:scope].to_s
when 'are_private'
- include_private ? snippets.are_private : Snippet.none
+ Snippet::PRIVATE
when 'are_internal'
- snippets.are_internal
+ Snippet::INTERNAL
when 'are_public'
- snippets.are_public
+ Snippet::PUBLIC
else
- include_private ? snippets : snippets.public_and_internal
+ nil
end
end
end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index dc13386184e..b276116f0c6 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -39,7 +39,7 @@ class TodosFinder
private
def action_id?
- action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i)
+ action_id.present? && Todo::ACTION_NAMES.key?(action_id.to_i)
end
def action_id
@@ -83,6 +83,8 @@ class TodosFinder
if project?
@project = Project.find(params[:project_id])
+ @project = nil if @project.pending_delete?
+
unless Ability.allowed?(current_user, :read_project, @project)
@project = nil
end
@@ -93,9 +95,18 @@ class TodosFinder
@project
end
+ def project_ids(items)
+ ids = items.except(:order).select(:project_id)
+ if Gitlab::Database.mysql?
+ # To make UPDATE work on MySQL, wrap it in a SELECT with an alias
+ ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
+ end
+
+ ids
+ end
+
def projects(items)
- item_project_ids = items.reorder(nil).select(:project_id)
- ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids).execute
+ ProjectsFinder.new(current_user: current_user, project_ids_relation: project_ids(items)).execute
end
def type?
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
new file mode 100644
index 00000000000..33f7ae90598
--- /dev/null
+++ b/app/finders/users_finder.rb
@@ -0,0 +1,77 @@
+# UsersFinder
+#
+# Used to filter users by set of params
+#
+# Arguments:
+# current_user - which user use
+# params:
+# username: string
+# extern_uid: string
+# provider: string
+# search: string
+# active: boolean
+# blocked: boolean
+# external: boolean
+#
+class UsersFinder
+ include CreatedAtFilter
+
+ attr_accessor :current_user, :params
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ users = User.all
+ users = by_username(users)
+ users = by_search(users)
+ users = by_blocked(users)
+ users = by_active(users)
+ users = by_external_identity(users)
+ users = by_external(users)
+ users = by_created_at(users)
+
+ users
+ end
+
+ private
+
+ def by_username(users)
+ return users unless params[:username]
+
+ users.where(username: params[:username])
+ end
+
+ def by_search(users)
+ return users unless params[:search].present?
+
+ users.search(params[:search])
+ end
+
+ def by_blocked(users)
+ return users unless params[:blocked]
+
+ users.blocked
+ end
+
+ def by_active(users)
+ return users unless params[:active]
+
+ users.active
+ end
+
+ def by_external_identity(users)
+ return users unless current_user&.admin? && params[:extern_uid] && params[:provider]
+
+ users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid]))
+ end
+
+ def by_external(users)
+ return users = users.where.not(external: true) unless current_user&.admin?
+ return users unless params[:external]
+
+ users.external
+ end
+end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 16136d02530..cdf5fa5d4b7 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -20,7 +20,7 @@ module AppearancesHelper
end
def brand_item
- @appearance ||= Appearance.first
+ @appearance ||= Appearance.current
end
def brand_header_logo
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fff57472a4f..bcee81bdc15 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -68,7 +68,7 @@ module ApplicationHelper
end
end
- def avatar_icon(user_or_email = nil, size = nil, scale = 2)
+ def avatar_icon(user_or_email = nil, size = nil, scale = 2, only_path: true)
user =
if user_or_email.is_a?(User)
user_or_email
@@ -77,7 +77,7 @@ module ApplicationHelper
end
if user
- user.avatar_url(size) || default_avatar
+ user.avatar_url(size: size, only_path: only_path) || default_avatar
else
gravatar_icon(user_or_email, size, scale)
end
@@ -131,10 +131,7 @@ module ApplicationHelper
end
def body_data_page
- path = controller.controller_path.split('/')
- namespace = path.first if path.second
-
- [namespace, controller.controller_name, controller.action_name].compact.join(':')
+ [*controller.controller_path.split('/'), controller.action_name].compact.join(':')
end
# shortcut for gitlab config
@@ -167,9 +164,9 @@ module ApplicationHelper
css_classes = short_format ? 'js-short-timeago' : 'js-timeago'
css_classes << " #{html_class}" unless html_class.blank?
- element = content_tag :time, time.strftime("%b %d, %Y"),
+ element = content_tag :time, l(time, format: "%b %d, %Y"),
class: css_classes,
- title: time.to_time.in_time_zone.to_s(:medium),
+ title: l(time.to_time.in_time_zone, format: :timeago_tooltip),
datetime: time.to_time.getutc.iso8601,
data: {
toggle: 'tooltip',
@@ -180,16 +177,16 @@ module ApplicationHelper
element
end
- def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false)
- return if object.updated_at == object.created_at
+ def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
+ return unless object.is_edited?
- content_tag :small, class: "edited-text" do
- output = content_tag(:span, "Edited ")
- output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class)
+ content_tag :small, class: 'edited-text' do
+ output = content_tag(:span, 'Edited ')
+ output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class)
- if include_author && object.updated_by && object.updated_by != object.author
- output << content_tag(:span, " by ")
- output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil)
+ if !exclude_author && object.last_edited_by
+ output << content_tag(:span, ' by ')
+ output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil)
end
output
@@ -204,6 +201,10 @@ module ApplicationHelper
'https://' + promo_host
end
+ def support_url
+ current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
+ end
+
def page_filter_path(options = {})
without = options.delete(:without)
add_label = options.delete(:label)
@@ -263,7 +264,11 @@ module ApplicationHelper
end
def page_class
- "issue-boards-page" if current_controller?(:boards)
+ class_names = []
+ class_names << 'issue-boards-page' if current_controller?(:boards)
+ class_names << 'with-performance-bar' if performance_bar_enabled?
+
+ class_names
end
# Returns active css class when condition returns true
@@ -275,7 +280,37 @@ module ApplicationHelper
'active' if condition
end
- def show_user_callout?
- cookies[:user_callout_dismissed] == 'true'
+ def show_callout?(name)
+ cookies[name] != 'true'
+ end
+
+ def linkedin_url(user)
+ name = user.linkedin
+ if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/}
+ name
+ else
+ "https://www.linkedin.com/in/#{name}"
+ end
+ end
+
+ def twitter_url(user)
+ name = user.twitter
+ if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/}
+ name
+ else
+ "https://www.twitter.com/#{name}"
+ end
+ end
+
+ def show_new_nav?
+ cookies["new_nav"] == "true"
+ end
+
+ def collapsed_sidebar?
+ cookies["sidebar_collapsed"] == "true"
+ end
+
+ def show_new_repo?
+ cookies["new_repo"] == "true" && body_data_page != 'projects:show'
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index ca326dd0627..150188f0b65 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -1,7 +1,8 @@
module ApplicationSettingsHelper
+ extend self
delegate :gravatar_enabled?,
:signup_enabled?,
- :signin_enabled?,
+ :password_authentication_enabled?,
:akismet_enabled?,
:koding_enabled?,
to: :current_application_settings
@@ -34,17 +35,17 @@ module ApplicationSettingsHelper
# Return a group of checkboxes that use Bootstrap's button plugin for a
# toggle button effect.
- def restricted_level_checkboxes(help_block_id)
- Gitlab::VisibilityLevel.options.map do |name, level|
+ def restricted_level_checkboxes(help_block_id, checkbox_name)
+ Gitlab::VisibilityLevel.values.map do |level|
checked = restricted_visibility_levels(true).include?(level)
css_class = checked ? 'active' : ''
- checkbox_name = "application_setting[restricted_visibility_levels][]"
+ tag_name = "application_setting_visibility_level_#{level}"
- label_tag(name, class: css_class) do
+ label_tag(tag_name, class: css_class) do
check_box_tag(checkbox_name, level, checked,
autocomplete: 'off',
'aria-describedby' => help_block_id,
- id: name) + visibility_level_icon(level) + name
+ id: tag_name) + visibility_level_icon(level) + visibility_level_label(level)
end
end
end
@@ -91,4 +92,89 @@ module ApplicationSettingsHelper
def sidekiq_queue_options_for_select
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
end
+
+ def visible_attributes
+ [
+ :admin_notification_email,
+ :after_sign_out_path,
+ :after_sign_up_text,
+ :akismet_api_key,
+ :akismet_enabled,
+ :clientside_sentry_dsn,
+ :clientside_sentry_enabled,
+ :container_registry_token_expire_delay,
+ :default_artifacts_expire_in,
+ :default_branch_protection,
+ :default_group_visibility,
+ :default_project_visibility,
+ :default_projects_limit,
+ :default_snippet_visibility,
+ :disabled_oauth_sign_in_sources,
+ :domain_blacklist_enabled,
+ :domain_blacklist_raw,
+ :domain_whitelist_raw,
+ :email_author_in_body,
+ :enabled_git_access_protocol,
+ :gravatar_enabled,
+ :help_page_hide_commercial_content,
+ :help_page_support_url,
+ :help_page_text,
+ :home_page_url,
+ :housekeeping_bitmaps_enabled,
+ :housekeeping_enabled,
+ :housekeeping_full_repack_period,
+ :housekeeping_gc_period,
+ :housekeeping_incremental_repack_period,
+ :html_emails_enabled,
+ :import_sources,
+ :koding_enabled,
+ :koding_url,
+ :max_artifacts_size,
+ :max_attachment_size,
+ :max_pages_size,
+ :metrics_enabled,
+ :metrics_host,
+ :metrics_method_call_threshold,
+ :metrics_packet_size,
+ :metrics_pool_size,
+ :metrics_port,
+ :metrics_sample_interval,
+ :metrics_timeout,
+ :password_authentication_enabled,
+ :performance_bar_allowed_group_id,
+ :performance_bar_enabled,
+ :plantuml_enabled,
+ :plantuml_url,
+ :polling_interval_multiplier,
+ :project_export_enabled,
+ :prometheus_metrics_enabled,
+ :recaptcha_enabled,
+ :recaptcha_private_key,
+ :recaptcha_site_key,
+ :repository_checks_enabled,
+ :repository_storages,
+ :require_two_factor_authentication,
+ :restricted_visibility_levels,
+ :send_user_confirmation_email,
+ :sentry_dsn,
+ :sentry_enabled,
+ :session_expire_delay,
+ :shared_runners_enabled,
+ :shared_runners_text,
+ :sidekiq_throttling_enabled,
+ :sidekiq_throttling_factor,
+ :sidekiq_throttling_queues,
+ :sign_in_text,
+ :signup_enabled,
+ :terminal_max_session_time,
+ :two_factor_grace_period,
+ :unique_ips_limit_enabled,
+ :unique_ips_limit_per_user,
+ :unique_ips_limit_time_window,
+ :usage_ping_enabled,
+ :user_default_external,
+ :user_oauth_applications,
+ :version_check_enabled
+ ]
+ end
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index b7e0ff8ecd0..4b51269533c 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -8,18 +8,24 @@ module AvatarsHelper
}))
end
- def user_avatar(options = {})
+ def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
- css_class = options[:css_class] || ''
-
- avatar = image_tag(
- avatar_icon(options[:user] || options[:user_email], avatar_size),
- class: "avatar has-tooltip s#{avatar_size} #{css_class}",
+ avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
+ data_attributes = { container: 'body' }
+
+ image_tag(
+ avatar_url,
+ class: %W[avatar has-tooltip s#{avatar_size}].push(*options[:css_class]),
alt: "#{user_name}'s avatar",
title: user_name,
- data: { container: 'body' }
+ data: data_attributes,
+ lazy: true
)
+ end
+
+ def user_avatar(options = {})
+ avatar = user_avatar_without_link(options)
if options[:user]
link_to(avatar, user_path(options[:user]))
diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb
index 167b09e678f..86b19368cfd 100644
--- a/app/helpers/award_emoji_helper.rb
+++ b/app/helpers/award_emoji_helper.rb
@@ -1,10 +1,14 @@
module AwardEmojiHelper
def toggle_award_url(awardable)
- return url_for([:toggle_award_emoji, awardable]) unless @project
+ return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note)
if awardable.is_a?(Note)
# We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x)
- toggle_award_emoji_namespace_project_note_url(@project.namespace, @project, awardable.id)
+ if awardable.for_personal_snippet?
+ toggle_award_emoji_snippet_note_path(awardable.noteable, awardable)
+ else
+ toggle_award_emoji_project_note_path(@project, awardable.id)
+ end
else
url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable])
end
diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb
new file mode 100644
index 00000000000..d1dc4d94560
--- /dev/null
+++ b/app/helpers/blame_helper.rb
@@ -0,0 +1,21 @@
+module BlameHelper
+ def age_map_duration(blame_groups, project)
+ now = Time.zone.now
+ start_date = blame_groups.map { |blame_group| blame_group[:commit].committed_date }
+ .append(project.created_at).min
+
+ {
+ now: now,
+ started_days_ago: (now - start_date).to_i / 1.day
+ }
+ end
+
+ def age_map_class(commit_date, duration)
+ commit_date_days_ago = (duration[:now] - commit_date).to_i / 1.day
+ # Numbers 0 to 10 come from this calculation, but only commits on the oldest
+ # day get number 10 (all other numbers can be multiple days), so the range
+ # is normalized to 0-9
+ age_group = [(10 * commit_date_days_ago) / duration[:started_days_ago], 9].min
+ "blame-commit-age-#{age_group}"
+ end
+end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 36b16421e8f..18075ee8be7 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -9,7 +9,7 @@ module BlobHelper
end
def edit_path(project = @project, ref = @ref, path = @path, options = {})
- namespace_project_edit_blob_path(project.namespace, project,
+ project_edit_blob_path(project,
tree_join(ref, path),
options[:link_opts])
end
@@ -18,7 +18,7 @@ module BlobHelper
blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
- return unless blob
+ return unless blob && blob.readable_text?
common_classes = "btn js-edit-blob #{options[:extra_class]}"
@@ -33,7 +33,7 @@ module BlobHelper
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now
}
- fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
+ fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params)
button_tag 'Edit',
class: "#{common_classes} js-edit-blob-link-fork-toggler",
@@ -52,7 +52,7 @@ module BlobHelper
if !on_top_of_branch?(project, ref)
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' }
- elsif blob.lfs_pointer?
+ elsif blob.stored_externally?
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' }
elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
@@ -62,7 +62,7 @@ module BlobHelper
notice: edit_in_new_fork_notice + " Try to #{action} this file again.",
notice_now: edit_in_new_fork_notice_now
}
- fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
+ fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params)
button_tag label,
class: "#{common_classes} js-edit-blob-link-fork-toggler",
@@ -95,7 +95,7 @@ module BlobHelper
end
def can_modify_blob?(blob, project = @project, ref = @ref)
- !blob.lfs_pointer? && can_edit_tree?(project, ref)
+ !blob.stored_externally? && can_edit_tree?(project, ref)
end
def leave_edit_message
@@ -118,28 +118,25 @@ module BlobHelper
icon("#{file_type_icon_class('file', mode, name)} fw")
end
- def blob_text_viewable?(blob)
- blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw?
- end
-
- def blob_rendered_as_text?(blob)
- blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text'
- end
-
- def blob_size(blob)
- if blob.lfs_pointer?
- blob.lfs_size
- else
- blob.size
+ def blob_raw_path
+ if @build && @entry
+ raw_project_job_artifacts_path(@project, @build, path: @entry.path)
+ elsif @snippet
+ if @snippet.project_id
+ raw_project_snippet_path(@project, @snippet)
+ else
+ raw_snippet_path(@snippet)
+ end
+ elsif @blob
+ project_raw_path(@project, @id)
end
end
# SVGs can contain malicious JavaScript; only include whitelisted
# elements and attributes. Note that this whitelist is by no means complete
# and may omit some elements.
- def sanitize_svg(blob)
- blob.data = Gitlab::Sanitizers::SVG.clean(blob.data)
- blob
+ def sanitize_svg_data(data)
+ Gitlab::Sanitizers::SVG.clean(data)
end
# If we blindly set the 'real' content type when serving a Git blob we
@@ -221,13 +218,75 @@ module BlobHelper
clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
end
- def copy_blob_content_button(blob)
- return if markup?(blob.name)
+ def copy_blob_source_button(blob)
+ return unless blob.rendered_as_text?(ignore_errors: false)
+
+ clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard")
+ end
+
+ def open_raw_blob_button(blob)
+ return if blob.empty?
+
+ if blob.raw_binary? || blob.stored_externally?
+ icon = icon('download')
+ title = 'Download'
+ else
+ icon = icon('file-code-o')
+ title = 'Open raw'
+ end
+
+ link_to icon, blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
+ end
+
+ def blob_render_error_reason(viewer)
+ case viewer.render_error
+ when :collapsed
+ "it is larger than #{number_to_human_size(viewer.collapse_limit)}"
+ when :too_large
+ "it is larger than #{number_to_human_size(viewer.size_limit)}"
+ when :server_side_but_stored_externally
+ case viewer.blob.external_storage
+ when :lfs
+ 'it is stored in LFS'
+ when :build_artifact
+ 'it is stored as a job artifact'
+ else
+ 'it is stored externally'
+ end
+ end
+ end
+
+ def blob_render_error_options(viewer)
+ error = viewer.render_error
+ options = []
+
+ if error == :collapsed
+ options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil)))
+ end
+
+ # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error,
+ # so don't bother switching.
+ if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally
+ options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' })
+ end
+
+ options << link_to('download it', blob_raw_path, target: '_blank', rel: 'noopener noreferrer')
- clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
+ options
end
- def open_raw_file_button(path)
- link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' }
+ def contribution_options(project)
+ options = []
+
+ if can?(current_user, :create_issue, project)
+ options << link_to("submit an issue", new_project_issue_path(project))
+ end
+
+ merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project))
+ if merge_project
+ options << link_to("create a merge request", project_new_merge_request_path(project))
+ end
+
+ options
end
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index f43827da446..8b33c362a9c 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -3,12 +3,13 @@ module BoardsHelper
board = @board || @boards.first
{
- endpoint: namespace_project_boards_path(@project.namespace, @project),
+ endpoint: project_boards_path(@project),
board_id: board.id,
disabled: "#{!can?(current_user, :admin_list, @project)}",
- issue_link_base: namespace_project_issues_path(@project.namespace, @project),
+ issue_link_base: project_issues_path(@project),
root_path: root_path,
- bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
+ bulk_update_path: bulk_update_project_issues_path(@project),
+ default_avatar: image_path(default_avatar)
}
end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index b7a28b1b4a7..686437fc99a 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,14 +1,4 @@
module BranchesHelper
- def can_remove_branch?(project, branch_name)
- if ProtectedBranch.protected?(project, branch_name)
- false
- elsif branch_name == project.repository.root_ref
- false
- else
- can?(current_user, :push_code, project)
- end
- end
-
def filter_branches_path(options = {})
exist_opts = {
search: params[:search],
@@ -17,7 +7,7 @@ module BranchesHelper
options = exist_opts.merge(options)
- namespace_project_branches_path(@project.namespace, @project, @id, options)
+ project_branches_path(@project, @id, options)
end
def can_push_branch?(project, branch_name)
diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb
new file mode 100644
index 00000000000..abe8edd6a8c
--- /dev/null
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -0,0 +1,25 @@
+module BreadcrumbsHelper
+ def add_to_breadcrumbs(text, link)
+ @breadcrumbs_extra_links ||= []
+ @breadcrumbs_extra_links.push({
+ text: text,
+ link: link
+ })
+ end
+
+ def breadcrumb_title_link
+ return @breadcrumb_link if @breadcrumb_link
+
+ if controller.available_action?(:index)
+ url_for(action: "index")
+ else
+ request.path
+ end
+ end
+
+ def breadcrumb_title(title)
+ return if defined?(@breadcrumb_title)
+
+ @breadcrumb_title = title
+ end
+end
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index eb03ced67eb..0a15c29cfb5 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -1,5 +1,5 @@
module BroadcastMessagesHelper
- def broadcast_message(message = BroadcastMessage.current)
+ def broadcast_message(message)
return unless message.present?
content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 2fcb7a59fc3..85bc784d53c 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -1,4 +1,16 @@
module BuildsHelper
+ def build_summary(build, skip: false)
+ if build.has_trace?
+ if skip
+ link_to "View job trace", pipeline_job_url(build.pipeline, build)
+ else
+ build.trace.html(last_lines: 10).html_safe
+ end
+ else
+ "No job trace"
+ end
+ end
+
def sidebar_build_class(build, current_build)
build_class = ''
build_class += ' active' if build.id === current_build.id
@@ -8,8 +20,8 @@ module BuildsHelper
def javascript_build_options
{
- page_url: namespace_project_build_url(@project.namespace, @project, @build),
- build_url: namespace_project_build_url(@project.namespace, @project, @build, :json),
+ page_url: project_job_url(@project, @build),
+ build_url: project_job_url(@project, @build, :json),
build_status: @build.status,
build_stage: @build.stage,
log_state: ''
@@ -19,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
title: "Build Failed ##{@build.id}",
- description: namespace_project_build_url(@project.namespace, @project, @build)
+ description: project_job_url(@project, @build)
}
end
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index c85e96cf78d..bf9ad95b7c2 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -42,23 +42,33 @@ module ButtonHelper
class: "btn #{css_class}",
data: data,
type: :button,
- title: title
+ title: title,
+ aria: {
+ label: title
+ }
end
def http_clone_button(project, placement = 'right', append_link: true)
klass = 'http-selector'
- klass << ' has-tooltip' if current_user.try(:require_password?)
+ klass << ' has-tooltip' if current_user.try(:require_password_creation?) || current_user.try(:require_personal_access_token_creation_for_git_auth?)
protocol = gitlab_config.protocol.upcase
+ tooltip_title =
+ if current_user.try(:require_password_creation?)
+ _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ else
+ _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ end
+
content_tag (append_link ? :a : :span), protocol,
class: klass,
- href: (project.http_url_to_repo(current_user) if append_link),
+ href: (project.http_url_to_repo if append_link),
data: {
html: true,
placement: placement,
container: 'body',
- title: "Set a password on your account<br>to pull or push via #{protocol}"
+ title: tooltip_title
}
end
@@ -73,7 +83,7 @@ module ButtonHelper
html: true,
placement: placement,
container: 'body',
- title: 'Add an SSH key to your profile<br>to pull or push via SSH.'
+ title: _('Add an SSH key to your profile to pull or push via SSH.')
}
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 32b1e7822af..8022547a6ad 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -8,7 +8,7 @@
module CiStatusHelper
def ci_status_path(pipeline)
project = pipeline.project
- namespace_project_pipeline_path(project.namespace, project, pipeline)
+ project_pipeline_path(project, pipeline)
end
def ci_label_for_status(status)
@@ -16,16 +16,18 @@ module CiStatusHelper
return status.label
end
- case status
- when 'success'
- 'passed'
- when 'success_with_warnings'
- 'passed with warnings'
- when 'manual'
- 'waiting for manual action'
- else
- status
- end
+ label = case status
+ when 'success'
+ 'passed'
+ when 'success_with_warnings'
+ 'passed with warnings'
+ when 'manual'
+ 'waiting for manual action'
+ else
+ status
+ end
+ translation = "CiStatusLabel|#{label}"
+ s_(translation)
end
def ci_text_for_status(status)
@@ -35,13 +37,22 @@ module CiStatusHelper
case status
when 'success'
- 'passed'
+ s_('CiStatusText|passed')
when 'success_with_warnings'
- 'passed'
+ s_('CiStatusText|passed')
when 'manual'
- 'blocked'
+ s_('CiStatusText|blocked')
else
- status
+ # All states are already being translated inside the detailed statuses:
+ # :running => Gitlab::Ci::Status::Running
+ # :skipped => Gitlab::Ci::Status::Skipped
+ # :failed => Gitlab::Ci::Status::Failed
+ # :success => Gitlab::Ci::Status::Success
+ # :canceled => Gitlab::Ci::Status::Canceled
+ # The following states are customized above:
+ # :manual => Gitlab::Ci::Status::Manual
+ status_translation = "CiStatusText|#{status}"
+ s_(status_translation)
end
end
@@ -88,10 +99,7 @@ module CiStatusHelper
def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left')
project = pipeline_status.project
- path = pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- pipeline_status.sha)
+ path = pipelines_project_commit_path(project, pipeline_status.sha)
render_status_with_link(
'commit',
@@ -102,10 +110,7 @@ module CiStatusHelper
def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left')
project = commit.project
- path = pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- commit)
+ path = pipelines_project_commit_path(project, commit)
render_status_with_link(
'commit',
@@ -116,7 +121,7 @@ module CiStatusHelper
def render_pipeline_status(pipeline, tooltip_placement: 'auto left')
project = pipeline.project
- path = namespace_project_pipeline_path(project.namespace, project, pipeline)
+ path = project_pipeline_path(project, pipeline)
render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index cef624430da..72e26b64e60 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -15,16 +15,6 @@ module CommitsHelper
commit_person_link(commit, options.merge(source: :committer))
end
- def image_diff_class(diff)
- if diff.deleted_file
- "deleted"
- elsif diff.new_file
- "added"
- else
- nil
- end
- end
-
def commit_to_html(commit, ref, project)
render 'projects/commits/commit',
commit: commit,
@@ -40,7 +30,7 @@ module CommitsHelper
crumbs = content_tag(:li) do
link_to(
@project.path,
- namespace_project_commits_path(@project.namespace, @project, @ref)
+ project_commits_path(@project, @ref)
)
end
@@ -52,8 +42,7 @@ module CommitsHelper
# The text is just the individual part, but the link needs all the parts before it
link_to(
part,
- namespace_project_commits_path(
- @project.namespace,
+ project_commits_path(
@project,
tree_join(@ref, parts[0..i].join('/'))
)
@@ -74,12 +63,8 @@ module CommitsHelper
# Returns the sorted alphabetically links to branches, separated by a comma
def commit_branches_links(project, branches)
branches.sort.map do |branch|
- link_to(
- namespace_project_tree_path(project.namespace, project, branch)
- ) do
- content_tag :span, class: 'label label-gray' do
- icon('code-fork') + ' ' + branch
- end
+ link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do
+ icon('code-fork') + " #{branch}"
end
end.join(" ").html_safe
end
@@ -88,39 +73,32 @@ module CommitsHelper
def commit_tags_links(project, tags)
sorted = VersionSorter.rsort(tags)
sorted.map do |tag|
- link_to(
- namespace_project_commits_path(project.namespace, project,
- project.repository.find_tag(tag).name)
- ) do
- content_tag :span, class: 'label label-gray' do
- icon('tag') + ' ' + tag
- end
+ link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do
+ icon('tag') + " #{tag}"
end
end.join(" ").html_safe
end
def link_to_browse_code(project, commit)
+ return unless current_controller?(:commits)
+
if @path.blank?
return link_to(
- "Browse Files",
- namespace_project_tree_path(project.namespace, project, commit),
+ _("Browse Files"),
+ project_tree_path(project, commit),
class: "btn btn-default"
)
- end
-
- return unless current_controller?(:projects, :commits)
-
- if @repo.blob_at(commit.id, @path)
+ elsif @repo.blob_at(commit.id, @path)
return link_to(
- "Browse File",
- namespace_project_blob_path(project.namespace, project,
+ _("Browse File"),
+ project_blob_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
elsif @path.present?
return link_to(
- "Browse Directory",
- namespace_project_tree_path(project.namespace, project,
+ _("Browse Directory"),
+ project_tree_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
@@ -135,6 +113,10 @@ module CommitsHelper
commit_action_link('cherry-pick', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip)
end
+ def commit_signature_badge_classes(additional_classes)
+ %w(btn status-box gpg-status-box) + Array(additional_classes)
+ end
+
protected
# Private: Returns a link to a person. If the person has a matching user and
@@ -146,10 +128,10 @@ module CommitsHelper
# avatar: true will prepend the avatar image
# size: size of the avatar image in px
def commit_person_link(commit, options = {})
- user = commit.send(options[:source])
+ user = commit.public_send(options[:source]) # rubocop:disable GitlabSecurity/PublicSend
- source_name = clean(commit.send "#{options[:source]}_name".to_sym)
- source_email = clean(commit.send "#{options[:source]}_email".to_sym)
+ source_name = clean(commit.public_send(:"#{options[:source]}_name")) # rubocop:disable GitlabSecurity/PublicSend
+ source_email = clean(commit.public_send(:"#{options[:source]}_email")) # rubocop:disable GitlabSecurity/PublicSend
person_name = user.try(:name) || source_name
@@ -186,7 +168,7 @@ module CommitsHelper
notice: "#{edit_in_new_fork_notice} Try to #{action} this commit again.",
notice_now: edit_in_new_fork_notice_now
}
- fork_path = namespace_project_forks_path(@project.namespace, @project,
+ fork_path = project_forks_path(@project,
namespace_key: current_user.namespace.id,
continue: continue_params)
@@ -196,12 +178,12 @@ module CommitsHelper
def view_file_button(commit_sha, diff_new_path, project)
link_to(
- namespace_project_blob_path(project.namespace, project,
+ project_blob_path(project,
tree_join(commit_sha, diff_new_path)),
class: 'btn view-file js-view-file'
) do
- raw('View file @') + content_tag(:span, commit_sha[0..6],
- class: 'commit-short-id')
+ raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha),
+ class: 'commit-sha')
end
end
diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb
index 2aa0449c46e..2c28dd81c87 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -9,8 +9,7 @@ module CompareHelper
end
def create_mr_path(from = params[:from], to = params[:to], project = @project)
- new_namespace_project_merge_request_path(
- project.namespace,
+ project_new_merge_request_path(
project,
merge_request: {
source_branch: to,
diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb
new file mode 100644
index 00000000000..1ff54415811
--- /dev/null
+++ b/app/helpers/conversational_development_index_helper.rb
@@ -0,0 +1,16 @@
+module ConversationalDevelopmentIndexHelper
+ def score_level(score)
+ if score < 33.33
+ 'low'
+ elsif score < 66.66
+ 'average'
+ else
+ 'high'
+ end
+ end
+
+ def format_score(score)
+ precision = score < 1 ? 2 : 1
+ number_with_precision(score, precision: precision)
+ end
+end
diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb
new file mode 100644
index 00000000000..e1567556e5e
--- /dev/null
+++ b/app/helpers/defer_script_tag_helper.rb
@@ -0,0 +1,6 @@
+module DeferScriptTagHelper
+ # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading
+ def javascript_include_tag(*sources)
+ super(*sources, defer: true)
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index dc144906548..28f591a4e22 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -8,24 +8,24 @@ module DiffHelper
[marked_old_line, marked_new_line]
end
- def expand_all_diffs?
- params[:expand_all_diffs].present?
+ def diffs_expanded?
+ params[:expanded].present?
end
def diff_view
@diff_view ||= begin
diff_views = %w(inline parallel)
- diff_view = cookies[:diff_view]
+ diff_view = params[:view] || cookies[:diff_view]
diff_view = diff_views.first unless diff_views.include?(diff_view)
diff_view.to_sym
end
end
def diff_options
- options = { ignore_whitespace_change: hide_whitespace?, no_collapse: expand_all_diffs? }
+ options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? }
if action_name == 'diff_for_path'
- options[:no_collapse] = true
+ options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
end
@@ -63,15 +63,15 @@ module DiffHelper
def parallel_diff_discussions(left, right, diff_file)
return unless @grouped_diff_discussions
-
+
discussions_left = discussions_right = nil
- if left && (left.unchanged? || left.removed?)
+ if left && left.discussable? && (left.unchanged? || left.removed?)
line_code = diff_file.line_code(left)
discussions_left = @grouped_diff_discussions[line_code]
end
- if right && right.added?
+ if right && right.discussable? && right.added?
line_code = diff_file.line_code(right)
discussions_right = @grouped_diff_discussions[line_code]
end
@@ -88,40 +88,82 @@ module DiffHelper
end
def submodule_link(blob, ref, repository = @repository)
- tree, commit = submodule_links(blob, ref, repository)
- commit_id = if commit.nil?
+ project_url, tree_url = submodule_links(blob, ref, repository)
+ commit_id = if tree_url.nil?
Commit.truncate_sha(blob.id)
else
- link_to Commit.truncate_sha(blob.id), commit
+ link_to Commit.truncate_sha(blob.id), tree_url
end
[
- content_tag(:span, link_to(truncate(blob.name, length: 40), tree)),
+ content_tag(:span, link_to(truncate(blob.name, length: 40), project_url)),
'@',
- content_tag(:span, commit_id, class: 'monospace'),
+ content_tag(:span, commit_id, class: 'commit-sha')
].join(' ').html_safe
end
- def commit_for_diff(diff_file)
- return diff_file.content_commit if diff_file.content_commit
+ def diff_file_blob_raw_path(diff_file)
+ project_raw_path(@project, tree_join(diff_file.content_sha, diff_file.file_path))
+ end
- if diff_file.deleted_file
- @base_commit || @commit.parent || @commit
- else
- @commit
- end
+ def diff_file_old_blob_raw_path(diff_file)
+ sha = diff_file.old_content_sha
+ return unless sha
+ project_raw_path(@project, tree_join(diff_file.old_content_sha, diff_file.old_path))
end
def diff_file_html_data(project, diff_file_path, diff_commit_id)
{
- blob_diff_path: namespace_project_blob_diff_path(project.namespace, project,
+ blob_diff_path: project_blob_diff_path(project,
tree_join(diff_commit_id, diff_file_path)),
view: diff_view
}
end
- def editable_diff?(diff)
- !diff.deleted_file && @merge_request && @merge_request.source_project
+ def editable_diff?(diff_file)
+ !diff_file.deleted_file? && @merge_request && @merge_request.source_project
+ end
+
+ def diff_render_error_reason(viewer)
+ case viewer.render_error
+ when :too_large
+ "it is too large"
+ when :server_side_but_stored_externally
+ case viewer.diff_file.external_storage
+ when :lfs
+ 'it is stored in LFS'
+ else
+ 'it is stored externally'
+ end
+ end
+ end
+
+ def diff_render_error_options(viewer)
+ diff_file = viewer.diff_file
+ options = []
+
+ blob_url = project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.file_path))
+ options << link_to('view the blob', blob_url)
+
+ options
+ end
+
+ def diff_file_changed_icon(diff_file)
+ if diff_file.deleted_file? || diff_file.renamed_file?
+ "minus"
+ elsif diff_file.new_file?
+ "plus"
+ else
+ "adjust"
+ end
+ end
+
+ def diff_file_changed_icon_color(diff_file)
+ if diff_file.deleted_file?
+ "cred"
+ elsif diff_file.new_file?
+ "cgreen"
+ end
end
private
@@ -139,17 +181,17 @@ module DiffHelper
end
def commit_diff_whitespace_link(project, commit, options)
- url = namespace_project_commit_path(project.namespace, project, commit.id, params_with_whitespace)
+ url = project_commit_path(project, commit.id, params_with_whitespace)
toggle_whitespace_link(url, options)
end
def diff_merge_request_whitespace_link(project, merge_request, options)
- url = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, params_with_whitespace)
+ url = diffs_project_merge_request_path(project, merge_request, params_with_whitespace)
toggle_whitespace_link(url, options)
end
def diff_compare_whitespace_link(project, from, to, options)
- url = namespace_project_compare_path(project.namespace, project, from, to, params_with_whitespace)
+ url = project_compare_path(project, from, to, params_with_whitespace)
toggle_whitespace_link(url, options)
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 8ed99642c7a..ff305fa39b4 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,27 +1,27 @@
module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block)
- content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
+ content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
- if options.has_key?(:data)
+ if options.key?(:data)
data_attr = options[:data].merge(data_attr)
end
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
- dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do
+ dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = ""
- if options.has_key?(:title)
+ if options.key?(:title)
output << dropdown_title(options[:title])
end
- if options.has_key?(:filter)
+ if options.key?(:filter)
output << dropdown_filter(options[:placeholder])
end
- output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
- capture(&block) if block && !options.has_key?(:footer_content)
+ output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
+ capture(&block) if block && !options.key?(:footer_content)
end
if block && options[:footer_content]
@@ -41,18 +41,18 @@ module DropdownsHelper
def dropdown_toggle(toggle_text, data_attr, options = {})
default_label = data_attr[:default_label]
- content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do
+ content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
output << icon('chevron-down')
output.html_safe
end
end
- def dropdown_title(title, back: false)
+ def dropdown_title(title, options: {})
content_tag :div, class: "dropdown-title" do
title_output = ""
- if back
+ if options.fetch(:back, false)
title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do
icon('arrow-left')
end
@@ -60,14 +60,25 @@ module DropdownsHelper
title_output << content_tag(:span, title)
- title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do
- icon('times', class: 'dropdown-menu-close-icon')
+ if options.fetch(:close, true)
+ title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do
+ icon('times', class: 'dropdown-menu-close-icon')
+ end
end
title_output.html_safe
end
end
+ def dropdown_input(placeholder, input_id: nil)
+ content_tag :div, class: "dropdown-input" do
+ filter_output = text_field_tag input_id, nil, class: "dropdown-input-field dropdown-no-filter", placeholder: placeholder, autocomplete: 'off'
+ filter_output << icon('times', class: "dropdown-input-clear js-dropdown-input-clear", role: "button")
+
+ filter_output.html_safe
+ end
+ end
+
def dropdown_filter(placeholder, search_id: nil)
content_tag :div, class: "dropdown-input" do
filter_output = search_field_tag search_id, nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index f927cfc998f..5f11fe62030 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -12,7 +12,7 @@ module EmailsHelper
"action" => {
"@type" => "ViewAction",
"name" => name,
- "url" => url,
+ "url" => url
}
}
@@ -61,9 +61,22 @@ module EmailsHelper
else
image_tag(
image_url('mailers/gitlab_header_logo.gif'),
- size: "55x50",
- alt: "GitLab"
+ size: '55x50',
+ alt: 'GitLab'
)
end
end
+
+ def email_default_heading(text)
+ content_tag :h1, text, style: [
+ "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif",
+ 'color:#333333',
+ 'font-size:18px',
+ 'font-weight:400',
+ 'line-height:1.4',
+ 'padding:0',
+ 'margin:0',
+ 'text-align:center'
+ ].join(';')
+ end
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index ff8550439d0..1e78a189c08 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -8,7 +8,7 @@ module EnvironmentHelper
def environment_link_for_build(project, build)
environment = environment_for_build(project, build)
if environment
- link_to environment.name, namespace_project_environment_path(project.namespace, project, environment)
+ link_to environment.name, project_environment_path(project, environment)
else
content_tag :span, build.expanded_environment_name
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 515e802e01e..4ce89f89fa9 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -1,7 +1,7 @@
module EnvironmentsHelper
def environments_list_data
{
- endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
+ endpoint: project_environments_path(@project, format: :json)
}
end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 5f5c76d3722..48c87dca217 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -10,11 +10,12 @@ module EventsHelper
'deleted' => 'icon_trash_o'
}.freeze
- def link_to_author(event)
+ def link_to_author(event, self_added: false)
author = event.author
if author
- link_to author.name, user_path(author.username), title: author.name
+ name = self_added ? 'You' : author.name
+ link_to name, user_path(author.username), title: name
else
event.author_name
end
@@ -40,7 +41,7 @@ module EventsHelper
link_opts = {
class: "event-filter-link",
id: "#{key}_event_filter",
- title: "Filter by #{tooltip.downcase}",
+ title: "Filter by #{tooltip.downcase}"
}
content_tag :li, class: active do
@@ -98,13 +99,12 @@ module EventsHelper
def event_feed_url(event)
if event.issue?
- namespace_project_issue_url(event.project.namespace, event.project,
+ project_issue_url(event.project,
event.issue)
elsif event.merge_request?
- namespace_project_merge_request_url(event.project.namespace,
- event.project, event.merge_request)
+ project_merge_request_url(event.project, event.merge_request)
elsif event.commit_note?
- namespace_project_commit_url(event.project.namespace, event.project,
+ project_commit_url(event.project,
event.note_target)
elsif event.note?
if event.note_target
@@ -118,15 +118,15 @@ module EventsHelper
def push_event_feed_url(event)
if event.push_with_commits? && event.md_ref?
if event.commits_count > 1
- namespace_project_compare_url(event.project.namespace, event.project,
+ project_compare_url(event.project,
from: event.commit_from, to:
event.commit_to)
else
- namespace_project_commit_url(event.project.namespace, event.project,
+ project_commit_url(event.project,
id: event.commit_to)
end
else
- namespace_project_commits_url(event.project.namespace, event.project,
+ project_commits_url(event.project,
event.ref_name)
end
end
@@ -145,15 +145,9 @@ module EventsHelper
def event_note_target_path(event)
if event.commit_note?
- namespace_project_commit_path(event.project.namespace,
- event.project,
- event.note_target,
- anchor: dom_id(event.target))
+ project_commit_path(event.project, event.note_target, anchor: dom_id(event.target))
elsif event.project_snippet_note?
- namespace_project_snippet_path(event.project.namespace,
- event.project,
- event.note_target,
- anchor: dom_id(event.target))
+ project_snippet_path(event.project, event.note_target, anchor: dom_id(event.target))
else
polymorphic_path([event.project.namespace.becomes(Namespace),
event.project, event.note_target],
@@ -163,9 +157,14 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
- link_to(event_note_target_path(event), title: event.target_title, class: 'has-tooltip') do
- "#{event.note_target_type} #{event.note_target_reference}"
- end
+ text = raw("#{event.note_target_type} ") +
+ if event.commit_note?
+ content_tag(:span, event.note_target_reference, class: 'commit-sha')
+ else
+ event.note_target_reference
+ end
+
+ link_to(text, event_note_target_path(event), title: event.target_title, class: 'has-tooltip')
else
content_tag(:strong, '(deleted)')
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 7bd212a3ef9..b981a1e8242 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -10,7 +10,7 @@ module ExploreHelper
personal: params[:personal],
archived: params[:archived],
shared: params[:shared],
- namespace_id: params[:namespace_id],
+ namespace_id: params[:namespace_id]
}
options = exist_opts.merge(options).delete_if { |key, value| value.blank? }
diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb
index defd87d6bbe..8cf890b74a8 100644
--- a/app/helpers/external_wiki_helper.rb
+++ b/app/helpers/external_wiki_helper.rb
@@ -4,7 +4,7 @@ module ExternalWikiHelper
if external_wiki_service
external_wiki_service.properties['external_wiki_url']
else
- namespace_project_wiki_path(project.namespace, project, :home)
+ project_wiki_path(project, :home)
end
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 1182939f656..9247b1f72de 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -8,11 +8,35 @@ module FormHelper
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
content_tag(:ul) do
- model.errors.full_messages.
- map { |msg| content_tag(:li, msg) }.
- join.
- html_safe
+ model.errors.full_messages
+ .map { |msg| content_tag(:li, msg) }
+ .join
+ .html_safe
end
end
end
+
+ def issue_assignees_dropdown_options
+ {
+ toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
+ title: 'Select assignee',
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
+ placeholder: 'Search users',
+ data: {
+ first_user: current_user&.username,
+ null_user: true,
+ current_user: true,
+ project_id: @project.id,
+ field_name: 'issue[assignee_ids][]',
+ default_label: 'Unassigned',
+ 'max-select': 1,
+ 'dropdown-header': 'Assignee',
+ multi_select: true,
+ 'input-meta': 'name',
+ 'always-show-selectbox': true,
+ current_user_info: current_user.to_json(only: [:id, :name])
+ }
+ }
+ end
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index e9b7cbbad6a..d4a91e533c1 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -1,132 +1,89 @@
-# Shorter routing method for project and project items
-# Since update to rails 4.1.9 we are now allowed to use `/` in project routing
-# so we use nested routing for project resources which include project and
-# project namespace. To avoid writing long methods every time we define shortcuts for
-# some of routing.
-#
-# For example instead of this:
-#
-# namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
-#
-# We can simply use shortcut:
-#
-# merge_request_path(merge_request)
-#
+# Shorter routing method for some project items
module GitlabRoutingHelper
- # Project
- def project_path(project, *args)
- namespace_project_path(project.namespace, project, *args)
- end
-
- def project_url(project, *args)
- namespace_project_url(project.namespace, project, *args)
- end
+ extend ActiveSupport::Concern
- def edit_project_path(project, *args)
- edit_namespace_project_path(project.namespace, project, *args)
+ included do
+ Gitlab::Routing.includes_helpers(self)
end
- def edit_project_url(project, *args)
- edit_namespace_project_url(project.namespace, project, *args)
- end
-
- def project_files_path(project, *args)
- namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref)
- end
-
- def project_commits_path(project, *args)
- namespace_project_commits_path(project.namespace, project, @ref || project.repository.root_ref)
- end
-
- def project_pipelines_path(project, *args)
- namespace_project_pipelines_path(project.namespace, project, *args)
- end
-
- def project_environments_path(project, *args)
- namespace_project_environments_path(project.namespace, project, *args)
- end
-
- def project_cycle_analytics_path(project, *args)
- namespace_project_cycle_analytics_path(project.namespace, project, *args)
- end
-
- def project_builds_path(project, *args)
- namespace_project_builds_path(project.namespace, project, *args)
+ # Project
+ def project_tree_path(project, ref = nil, *args)
+ namespace_project_tree_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
end
- def project_container_registry_path(project, *args)
- namespace_project_container_registry_index_path(project.namespace, project, *args)
+ def project_commits_path(project, ref = nil, *args)
+ namespace_project_commits_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
end
- def activity_project_path(project, *args)
- activity_namespace_project_path(project.namespace, project, *args)
+ def project_ref_path(project, ref_name, *args)
+ project_commits_path(project, ref_name, *args)
end
def runners_path(project, *args)
- namespace_project_runners_path(project.namespace, project, *args)
+ project_runners_path(project, *args)
end
def runner_path(runner, *args)
- namespace_project_runner_path(@project.namespace, @project, runner, *args)
+ project_runner_path(@project, runner, *args)
end
def environment_path(environment, *args)
- namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
+ project_environment_path(environment.project, environment, *args)
end
def environment_metrics_path(environment, *args)
- metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
+ metrics_project_environment_path(environment.project, environment, *args)
end
def issue_path(entity, *args)
- namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args)
+ project_issue_path(entity.project, entity, *args)
end
def merge_request_path(entity, *args)
- namespace_project_merge_request_path(entity.project.namespace, entity.project, entity, *args)
+ project_merge_request_path(entity.project, entity, *args)
end
def pipeline_path(pipeline, *args)
- namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
- end
-
- def milestone_path(entity, *args)
- namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args)
+ project_pipeline_path(pipeline.project, pipeline.id, *args)
end
def issue_url(entity, *args)
- namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args)
+ project_issue_url(entity.project, entity, *args)
end
def merge_request_url(entity, *args)
- namespace_project_merge_request_url(entity.project.namespace, entity.project, entity, *args)
+ project_merge_request_url(entity.project, entity, *args)
end
def pipeline_url(pipeline, *args)
- namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
+ project_pipeline_url(pipeline.project, pipeline.id, *args)
end
- def pipeline_build_url(pipeline, build, *args)
- namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args)
+ def pipeline_job_url(pipeline, build, *args)
+ project_job_url(pipeline.project, build.id, *args)
end
def commits_url(entity, *args)
- namespace_project_commits_url(entity.project.namespace, entity.project, entity.ref, *args)
+ project_commits_url(entity.project, entity.ref, *args)
end
def commit_url(entity, *args)
- namespace_project_commit_url(entity.project.namespace, entity.project, entity.sha, *args)
+ project_commit_url(entity.project, entity.sha, *args)
end
- def project_snippet_url(entity, *args)
- namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
+ def preview_markdown_path(project, *args)
+ if @snippet.is_a?(PersonalSnippet)
+ preview_markdown_snippets_path
+ else
+ preview_markdown_project_path(project, *args)
+ end
end
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
- toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity)
+ toggle_subscription_project_issue_path(entity.project, entity)
else
- toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity)
+ toggle_subscription_project_merge_request_path(entity.project, entity)
end
end
@@ -140,32 +97,27 @@ module GitlabRoutingHelper
## Members
def project_members_url(project, *args)
- namespace_project_project_members_url(project.namespace, project)
+ project_project_members_url(project, *args)
end
def project_member_path(project_member, *args)
- namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
+ project_project_member_path(project_member.source, project_member)
end
def request_access_project_members_path(project, *args)
- request_access_namespace_project_project_members_path(project.namespace, project)
+ request_access_project_project_members_path(project)
end
def leave_project_members_path(project, *args)
- leave_namespace_project_project_members_path(project.namespace, project)
+ leave_project_project_members_path(project)
end
def approve_access_request_project_member_path(project_member, *args)
- approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
+ approve_access_request_project_project_member_path(project_member.source, project_member)
end
def resend_invite_project_member_path(project_member, *args)
- resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
- end
-
- # Snippets
- def personal_snippet_url(snippet, *args)
- snippet_url(snippet)
+ resend_invite_project_project_member_path(project_member.source, project_member)
end
# Groups
@@ -199,28 +151,37 @@ module GitlabRoutingHelper
def artifacts_action_path(path, project, build)
action, path_params = path.split('/', 2)
- args = [project.namespace, project, build, path_params]
+ args = [project, build, path_params]
case action
when 'download'
- download_namespace_project_build_artifacts_path(*args)
+ download_project_job_artifacts_path(*args)
when 'browse'
- browse_namespace_project_build_artifacts_path(*args)
+ browse_project_job_artifacts_path(*args)
when 'file'
- file_namespace_project_build_artifacts_path(*args)
+ file_project_job_artifacts_path(*args)
+ when 'raw'
+ raw_project_job_artifacts_path(*args)
end
end
- # Settings
- def project_settings_integrations_path(project, *args)
- namespace_project_settings_integrations_path(project.namespace, project, *args)
+ # Pipeline Schedules
+ def pipeline_schedules_path(project, *args)
+ project_pipeline_schedules_path(project, *args)
+ end
+
+ def pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ project_pipeline_schedule_path(project, schedule, *args)
end
- def project_settings_members_path(project, *args)
- namespace_project_settings_members_path(project.namespace, project, *args)
+ def edit_pipeline_schedule_path(schedule)
+ project = schedule.project
+ edit_project_pipeline_schedule_path(project, schedule)
end
- def project_settings_ci_cd_path(project, *args)
- namespace_project_settings_ci_cd_path(project.namespace, project, *args)
+ def take_ownership_pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ take_ownership_project_pipeline_schedule_path(project, schedule, *args)
end
end
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index c2ab80f2e0d..c53ea4519da 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -3,7 +3,7 @@ module GraphHelper
refs = ""
# Commit::ref_names already strips the refs/XXX from important refs (e.g. refs/heads/XXX)
# so anything leftover is internally used by GitLab
- commit_refs = commit.ref_names(repo).reject{ |name| name.starts_with?('refs/') }
+ commit_refs = commit.ref_names(repo).reject { |name| name.starts_with?('refs/') }
refs << commit_refs.join(' ')
# append note count
@@ -17,13 +17,10 @@ module GraphHelper
ids.zip(parent_spaces)
end
- def success_ratio(success_builds, failed_builds)
- failed_builds = failed_builds.count(:all)
- success_builds = success_builds.count(:all)
+ def success_ratio(counts)
+ return 100 if counts[:failed].zero?
- return 100 if failed_builds.zero?
-
- ratio = (success_builds.to_f / (success_builds + failed_builds)) * 100
+ ratio = (counts[:success].to_f / (counts[:success] + counts[:failed])) * 100
ratio.to_i
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a6014088e92..4123a96911f 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -8,19 +8,20 @@ module GroupsHelper
group = Group.find_by_full_path(group)
end
- group.try(:avatar_url) || image_path('no_group_avatar.png')
+ group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png')
end
def group_title(group, name = nil, url = nil)
@has_group_title = true
full_title = ''
- group.ancestors.each do |parent|
- full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable')
+ group.ancestors.reverse.each do |parent|
+ full_title += group_title_link(parent, hidable: true)
+
full_title += '<span class="hidable"> / </span>'.html_safe
end
- full_title += link_to(simple_sanitize(group.name), group_path(group), class: 'group-path')
+ full_title += group_title_link(group)
full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path') if name
content_tag :span, class: 'group-title' do
@@ -56,4 +57,25 @@ module GroupsHelper
def group_issues(group)
IssuesFinder.new(current_user, group_id: group.id).execute
end
+
+ def remove_group_message(group)
+ _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
+ { group_name: group.name }
+ end
+
+ private
+
+ def group_title_link(group, hidable: false)
+ link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do
+ output =
+ if show_new_nav?
+ image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16)
+ else
+ ""
+ end
+
+ output << simple_sanitize(group.name)
+ output.html_safe
+ end
+ end
end
diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb
new file mode 100644
index 00000000000..551b9cca6b1
--- /dev/null
+++ b/app/helpers/hooks_helper.rb
@@ -0,0 +1,17 @@
+module HooksHelper
+ def link_to_test_hook(hook, trigger)
+ path = case hook
+ when ProjectHook
+ project = hook.project
+ test_project_hook_path(project, hook, trigger: trigger)
+ when SystemHook
+ test_admin_hook_path(hook, trigger: trigger)
+ end
+
+ trigger_human_name = trigger.to_s.tr('_', ' ').camelize
+
+ link_to path, rel: 'nofollow' do
+ content_tag(:span, trigger_human_name)
+ end
+ end
+end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 55fa81e95ef..9a404832423 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -1,4 +1,5 @@
module IconsHelper
+ extend self
include FontAwesome::Rails::IconHelper
# Creates an icon tag given icon name(s) and possible icon modifiers.
@@ -7,9 +8,10 @@ module IconsHelper
# font-awesome-rails gem, but should we ever use a different icon pack in the
# future we won't have to change hundreds of method calls.
def icon(names, options = {})
- if (options.keys & %w[aria-hidden aria-label]).empty?
- # Add `aria-hidden` if there are no aria's set
+ if (options.keys & %w[aria-hidden aria-label data-hidden]).empty?
+ # Add 'aria-hidden' and 'data-hidden' if they are not set in options.
options['aria-hidden'] = true
+ options['data-hidden'] = true
end
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
@@ -19,6 +21,8 @@ module IconsHelper
case names
when "standard"
names = "key"
+ when "two-factor"
+ names = "key"
end
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index a57b5a8fea5..a18ebfb6030 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -5,7 +5,7 @@ module ImportHelper
end
def provider_project_link(provider, path_with_namespace)
- url = __send__("#{provider}_project_url", path_with_namespace)
+ url = __send__("#{provider}_project_url", path_with_namespace) # rubocop:disable GitlabSecurity/PublicSend
link_to path_with_namespace, url, target: '_blank', rel: 'noopener noreferrer'
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 0b13dbf5f8d..197c90c4081 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -26,9 +26,9 @@ module IssuablesHelper
project = issuable.project
if issuable.is_a?(MergeRequest)
- namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json)
+ project_merge_request_path(project, issuable.iid, :json)
else
- namespace_project_issue_path(project.namespace, project, issuable.iid, :json)
+ project_issue_path(project, issuable.iid, :json)
end
end
@@ -37,7 +37,10 @@ module IssuablesHelper
when Issue
IssueSerializer.new.represent(issuable).to_json
when MergeRequest
- MergeRequestSerializer.new.represent(issuable).to_json
+ MergeRequestSerializer
+ .new(current_user: current_user, project: issuable.project)
+ .represent(issuable)
+ .to_json
end
end
@@ -63,6 +66,17 @@ module IssuablesHelper
end
end
+ def users_dropdown_label(selected_users)
+ case selected_users.length
+ when 0
+ "Unassigned"
+ when 1
+ selected_users[0].name
+ else
+ "#{selected_users[0].name} + #{selected_users.length - 1} more"
+ end
+ end
+
def user_dropdown_label(user_id, default_label)
return default_label if user_id.nil?
return "Unassigned" if user_id == "0"
@@ -123,11 +137,9 @@ module IssuablesHelper
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg")
end
- if issuable.tasks?
- output << "&ensp;".html_safe
- output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm")
- output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg")
- end
+ output << "&ensp;".html_safe
+ output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "hidden-xs hidden-sm")
+ output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "hidden-md hidden-lg")
output
end
@@ -139,7 +151,7 @@ module IssuablesHelper
end
def issuable_labels_tooltip(labels, limit: 5)
- first, last = labels.partition.with_index{ |_, i| i < limit }
+ first, last = labels.partition.with_index { |_, i| i < limit }
label_names = first.collect(&:name)
label_names << "and #{last.size} more" unless last.empty?
@@ -153,11 +165,7 @@ module IssuablesHelper
}
state_title = titles[state] || state.to_s.humanize
-
- count =
- Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
- issuables_count_for_state(issuable_type, state)
- end
+ count = issuables_count_for_state(issuable_type, state)
html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
@@ -166,7 +174,14 @@ module IssuablesHelper
end
def assigned_issuables_count(issuable_type)
- current_user.public_send("assigned_open_#{issuable_type}_count")
+ case issuable_type
+ when :issues
+ current_user.assigned_open_issues_count
+ when :merge_requests
+ current_user.assigned_open_merge_requests_count
+ else
+ raise ArgumentError, "invalid issuable `#{issuable_type}`"
+ end
end
def issuable_filter_params
@@ -187,40 +202,115 @@ module IssuablesHelper
issuable_filter_params.any? { |k| params.key?(k) }
end
- private
+ def issuable_initial_data(issuable)
+ data = {
+ endpoint: project_issue_path(@project, issuable),
+ canUpdate: can?(current_user, :update_issue, issuable),
+ canDestroy: can?(current_user, :destroy_issue, issuable),
+ canMove: current_user ? issuable.can_move?(current_user) : false,
+ issuableRef: issuable.to_reference,
+ isConfidential: issuable.confidential,
+ markdownPreviewUrl: preview_markdown_path(@project),
+ markdownDocs: help_page_path('user/markdown'),
+ projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
+ issuableTemplates: issuable_templates(issuable),
+ projectPath: ref_project.path,
+ projectNamespace: ref_project.namespace.full_path,
+ initialTitleHtml: markdown_field(issuable, :title),
+ initialTitleText: issuable.title,
+ initialDescriptionHtml: markdown_field(issuable, :description),
+ initialDescriptionText: issuable.description,
+ initialTaskStatus: issuable.task_status
+ }
- def sidebar_gutter_collapsed?
- cookies[:collapsed_gutter] == 'true'
+ data.merge!(updated_at_by(issuable))
+
+ data.to_json
end
- def base_issuable_scope(issuable)
- issuable.project.send(issuable.class.table_name).send(issuable_state_scope(issuable))
+ def updated_at_by(issuable)
+ return {} unless issuable.is_edited?
+
+ {
+ updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: issuable.last_edited_by.name,
+ path: user_path(issuable.last_edited_by)
+ }
+ }
end
- def issuable_state_scope(issuable)
- if issuable.respond_to?(:merged?) && issuable.merged?
- :merged
- else
- issuable.open? ? :opened : :closed
+ def issuables_count_for_state(issuable_type, state, finder: nil)
+ finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
+ cache_key = finder.state_counter_cache_key
+
+ @counts ||= {}
+ @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
+ finder.count_by_state
end
+
+ @counts[cache_key][state]
end
- def issuables_count_for_state(issuable_type, state)
- @counts ||= {}
- @counts[issuable_type] ||= public_send("#{issuable_type}_finder").count_by_state
- @counts[issuable_type][state]
+ def close_issuable_url(issuable)
+ issuable_url(issuable, close_reopen_params(issuable, :close))
+ end
+
+ def reopen_issuable_url(issuable)
+ issuable_url(issuable, close_reopen_params(issuable, :reopen))
+ end
+
+ def close_reopen_issuable_url(issuable, should_inverse = false)
+ issuable.closed? ^ should_inverse ? reopen_issuable_url(issuable) : close_issuable_url(issuable)
+ end
+
+ def issuable_url(issuable, *options)
+ case issuable
+ when Issue
+ issue_url(issuable, *options)
+ when MergeRequest
+ merge_request_url(issuable, *options)
+ end
+ end
+
+ def issuable_button_visibility(issuable, closed)
+ case issuable
+ when Issue
+ issue_button_visibility(issuable, closed)
+ when MergeRequest
+ merge_request_button_visibility(issuable, closed)
+ end
+ end
+
+ def issuable_close_reopen_button_method(issuable)
+ case issuable
+ when Issue
+ ''
+ when MergeRequest
+ 'put'
+ end
end
- IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page].freeze
- private_constant :IRRELEVANT_PARAMS_FOR_CACHE_KEY
+ def issuable_author_is_current_user(issuable)
+ issuable.author == current_user
+ end
+
+ def issuable_display_type(issuable)
+ issuable.model_name.human.downcase
+ end
+
+ private
- def issuables_state_counter_cache_key(issuable_type, state)
- opts = params.with_indifferent_access
- opts[:state] = state
- opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
- opts.delete_if { |_, value| value.blank? }
+ def sidebar_gutter_collapsed?
+ cookies[:collapsed_gutter] == 'true'
+ end
- hexdigest(['issuables_count', issuable_type, opts.sort].flatten.join('-'))
+ def issuable_state_scope(issuable)
+ if issuable.respond_to?(:merged?) && issuable.merged?
+ :merged
+ else
+ issuable.open? ? :opened : :closed
+ end
end
def issuable_templates(issuable)
@@ -230,8 +320,6 @@ module IssuablesHelper
issue_template_names
when MergeRequest
merge_request_template_names
- else
- raise 'Unknown issuable type!'
end
end
@@ -244,7 +332,7 @@ module IssuablesHelper
end
def selected_template(issuable)
- params[:issuable_template] if issuable_templates(issuable).any?{ |template| template[:name] == params[:issuable_template] }
+ params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
def issuable_todo_button_data(issuable, todo, is_collapsed)
@@ -255,10 +343,28 @@ module IssuablesHelper
mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil),
issuable_id: issuable.id,
issuable_type: issuable.class.name.underscore,
- url: namespace_project_todos_path(@project.namespace, @project),
+ url: project_todos_path(@project),
delete_path: (dashboard_todo_path(todo) if todo),
placement: (is_collapsed ? 'left' : nil),
container: (is_collapsed ? 'body' : nil)
}
end
+
+ def close_reopen_params(issuable, action)
+ {
+ issuable.model_name.to_s.underscore => { state_event: action }
+ }.tap do |params|
+ params[:format] = :json if issuable.is_a?(Issue)
+ end
+ end
+
+ def issuable_sidebar_options(issuable, can_edit_issuable)
+ {
+ endpoint: "#{issuable_json_path(issuable)}?basic=true",
+ editable: can_edit_issuable,
+ currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
+ rootPath: root_path,
+ fullPath: @project.full_path
+ }
+ end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 82288f1da35..7e1ccb23e9e 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -17,10 +17,10 @@ module IssuesHelper
return '' if project.nil?
url =
- if options[:only_path]
- project.issues_tracker.issue_path(issue_iid)
+ if options[:internal]
+ url_for_internal_issue(issue_iid, project, options)
else
- project.issues_tracker.issue_url(issue_iid)
+ url_for_tracker_issue(issue_iid, project, options)
end
# Ensure we return a valid URL to prevent possible XSS.
@@ -29,6 +29,24 @@ module IssuesHelper
''
end
+ def url_for_tracker_issue(issue_iid, project, options)
+ if options[:only_path]
+ project.issues_tracker.issue_path(issue_iid)
+ else
+ project.issues_tracker.issue_url(issue_iid)
+ end
+ end
+
+ def url_for_internal_issue(issue_iid, project = @project, options = {})
+ helpers = Gitlab::Routing.url_helpers
+
+ if options[:only_path]
+ helpers.namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue_iid)
+ else
+ helpers.namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: issue_iid)
+ end
+ end
+
def bulk_update_milestone_options
milestones = @project.milestones.active.reorder(due_date: :asc, title: :asc).to_a
milestones.unshift(Milestone::None)
@@ -150,7 +168,7 @@ module IssuesHelper
Gitlab::UrlBuilder.build(single_discussion.first_note)
else
project = merge_request.project
- namespace_project_merge_request_path(project.namespace, project, merge_request)
+ project_merge_request_path(project, merge_request)
end
link_to link_text, path
@@ -158,4 +176,6 @@ module IssuesHelper
# Required for Banzai::Filter::IssueReferenceFilter
module_function :url_for_issue
+ module_function :url_for_internal_issue
+ module_function :url_for_tracker_issue
end
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index e5b1e6e8bc7..e60513b35c7 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -43,11 +43,11 @@ module LabelsHelper
def label_filter_path(subject, label, type: :issue)
case subject
when Group
- send("#{type.to_s.pluralize}_group_path",
+ send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
subject,
label_name: [label.name])
when Project
- send("namespace_project_#{type.to_s.pluralize}_path",
+ send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
subject.namespace,
subject,
label_name: [label.name])
@@ -57,25 +57,24 @@ module LabelsHelper
def edit_label_path(label)
case label
when GroupLabel then edit_group_label_path(label.group, label)
- when ProjectLabel then edit_namespace_project_label_path(label.project.namespace, label.project, label)
+ when ProjectLabel then edit_project_label_path(label.project, label)
end
end
def destroy_label_path(label)
case label
when GroupLabel then group_label_path(label.group, label)
- when ProjectLabel then namespace_project_label_path(label.project.namespace, label.project, label)
+ when ProjectLabel then project_label_path(label.project, label)
end
end
def render_colored_label(label, label_suffix = '', tooltip: true)
- label_color = label.color || Label::DEFAULT_COLOR
- text_color = text_color_for_bg(label_color)
+ text_color = text_color_for_bg(label.color)
# Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter
span = %(<span class="label color-label #{"has-tooltip" if tooltip}" ) +
- %(style="background-color: #{label_color}; color: #{text_color}" ) +
+ %(style="background-color: #{label.color}; color: #{text_color}" ) +
%(title="#{escape_once(label.description)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>)
@@ -128,27 +127,34 @@ module LabelsHelper
project = @target_project || @project
if project
- namespace_project_labels_path(project.namespace, project, :json)
+ project_labels_path(project, :json)
else
dashboard_labels_path(:json)
end
end
+ def can_subscribe_to_label_in_different_levels?(label)
+ defined?(@project) && label.is_a?(GroupLabel)
+ end
+
def label_subscription_status(label, project)
- return 'project-level' if label.subscribed?(current_user, project)
return 'group-level' if label.subscribed?(current_user)
+ return 'project-level' if label.subscribed?(current_user, project)
'unsubscribed'
end
- def group_label_unsubscribe_path(label, project)
+ def toggle_subscription_label_path(label, project)
+ return toggle_subscription_group_label_path(label.group, label) unless project
+
case label_subscription_status(label, project)
- when 'project-level' then toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)
when 'group-level' then toggle_subscription_group_label_path(label.group, label)
+ when 'project-level' then toggle_subscription_project_label_path(project, label)
+ when 'unsubscribed' then toggle_subscription_project_label_path(project, label)
end
end
- def label_subscription_toggle_button_text(label, project)
+ def label_subscription_toggle_button_text(label, project = nil)
label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe'
end
diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb
new file mode 100644
index 00000000000..2c5619ac41b
--- /dev/null
+++ b/app/helpers/lazy_image_tag_helper.rb
@@ -0,0 +1,24 @@
+module LazyImageTagHelper
+ def placeholder_image
+ "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
+ end
+
+ # Override the default ActionView `image_tag` helper to support lazy-loading
+ def image_tag(source, options = {})
+ options = options.symbolize_keys
+
+ unless options.delete(:lazy) == false
+ options[:data] ||= {}
+ options[:data][:src] = path_to_image(source)
+ options[:class] ||= ""
+ options[:class] << " lazy"
+
+ source = placeholder_image
+ end
+
+ super(source, options)
+ end
+
+ # Required for Banzai::Filter::ImageLazyLoadFilter
+ module_function :placeholder_image
+end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 0781874d7fc..941cfce8370 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -1,6 +1,9 @@
require 'nokogiri'
module MarkupHelper
+ include ActionView::Helpers::TagHelper
+ include ActionView::Context
+
def plain?(filename)
Gitlab::MarkupHelper.plain?(filename)
end
@@ -32,7 +35,7 @@ module MarkupHelper
context = {
project: @project,
current_user: (current_user if defined?(current_user)),
- pipeline: :single_line,
+ pipeline: :single_line
}
gfm_body = Banzai.render(body, context)
@@ -74,7 +77,7 @@ module MarkupHelper
context[:project] ||= @project
html = markdown_unsafe(text, context)
- banzai_postprocess(html, context)
+ prepare_for_rendering(html, context)
end
def markdown_field(object, field)
@@ -82,13 +85,13 @@ module MarkupHelper
return '' unless object.present?
html = Banzai.render_field(object, field)
- banzai_postprocess(html, object.banzai_render_context(field))
+ prepare_for_rendering(html, object.banzai_render_context(field))
end
def markup(file_name, text, context = {})
context[:project] ||= @project
html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
- banzai_postprocess(html, context)
+ prepare_for_rendering(html, context)
end
def render_wiki_content(wiki_page)
@@ -107,22 +110,22 @@ module MarkupHelper
wiki_page.formatted_content.html_safe
end
- banzai_postprocess(html, context)
+ prepare_for_rendering(html, context)
end
def markup_unsafe(file_name, text, context = {})
return '' unless text.present?
if gitlab_markdown?(file_name)
- Hamlit::RailsHelpers.preserve(markdown_unsafe(text, context))
+ markdown_unsafe(text, context)
elsif asciidoc?(file_name)
- asciidoc_unsafe(text)
+ asciidoc_unsafe(text, context)
elsif plain?(file_name)
content_tag :pre, class: 'plain-readme' do
text
end
else
- other_markup_unsafe(file_name, text)
+ other_markup_unsafe(file_name, text, context)
end
rescue RuntimeError
simple_format(text)
@@ -217,16 +220,15 @@ module MarkupHelper
Banzai.render(text, context)
end
- def asciidoc_unsafe(text)
- Gitlab::Asciidoc.render(text)
+ def asciidoc_unsafe(text, context = {})
+ Gitlab::Asciidoc.render(text, context)
end
- def other_markup_unsafe(file_name, text)
- Gitlab::OtherMarkup.render(file_name, text)
+ def other_markup_unsafe(file_name, text, context = {})
+ Gitlab::OtherMarkup.render(file_name, text, context)
end
- # Calls Banzai.post_process with some common context options
- def banzai_postprocess(html, context = {})
+ def prepare_for_rendering(html, context = {})
return '' unless html.present?
context.merge!(
@@ -239,7 +241,9 @@ module MarkupHelper
requested_path: @path
)
- Banzai.post_process(html, context)
+ html = Banzai.post_process(html, context)
+
+ Hamlit::RailsHelpers.preserve(html)
end
extend self
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index e347f61fb8d..c31023f2d9a 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -1,8 +1,7 @@
module MergeRequestsHelper
def new_mr_path_from_push_event(event)
- target_project = event.project.forked_from_project || event.project
- new_namespace_project_merge_request_path(
- event.project.namespace,
+ target_project = event.project.default_merge_request_target
+ project_new_merge_request_path(
event.project,
new_mr_from_push_event(event, target_project)
)
@@ -19,14 +18,6 @@ module MergeRequestsHelper
}
end
- def mr_widget_refresh_url(mr)
- if mr && mr.target_project
- merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr)
- else
- ''
- end
- end
-
def mr_css_classes(mr)
classes = "merge-request"
classes << " closed" if mr.closed?
@@ -49,71 +40,25 @@ module MergeRequestsHelper
def merge_path_description(merge_request, separator)
if merge_request.for_fork?
- "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.path_with_namespace}:#{@merge_request.target_branch}"
+ "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}"
else
"Branches: #{@merge_request.source_branch} #{separator} #{@merge_request.target_branch}"
end
end
- def issues_sentence(issues)
- # Issuable sorter will sort local issues, then issues from the same
- # namespace, then all other issues.
- issues = Gitlab::IssuableSorter.sort(@project, issues).map do |issue|
- issue.to_reference(@project)
- end
- issues.to_sentence
- end
-
- def mr_closes_issues
- @mr_closes_issues ||= @merge_request.closes_issues(current_user)
- end
-
- def mr_issues_mentioned_but_not_closing
- @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user)
- end
-
def mr_change_branches_path(merge_request)
- new_namespace_project_merge_request_path(
- @project.namespace, @project,
+ project_new_merge_request_path(
+ @project,
merge_request: {
source_project_id: merge_request.source_project_id,
target_project_id: merge_request.target_project_id,
source_branch: merge_request.source_branch,
- target_branch: merge_request.target_branch,
+ target_branch: merge_request.target_branch
},
change_branches: true
)
end
- def mr_assign_issues_link
- issues = MergeRequests::AssignIssuesService.new(@project,
- current_user,
- merge_request: @merge_request,
- closes_issues: mr_closes_issues
- ).assignable_issues
- path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- if issues.present?
- pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
- link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
- end
- end
-
- def source_branch_with_namespace(merge_request)
- namespace = merge_request.source_project_namespace
- branch = merge_request.source_branch
-
- if merge_request.source_branch_exists?
- namespace = link_to(namespace, project_path(merge_request.source_project))
- branch = link_to(branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch))
- end
-
- if merge_request.for_fork?
- namespace + ":" + branch
- else
- branch
- end
- end
-
def format_mr_branch_names(merge_request)
source_path = merge_request.source_project_path
target_path = merge_request.target_project_path
@@ -127,14 +72,16 @@ module MergeRequestsHelper
end
end
+ def target_projects(project)
+ [project, project.default_merge_request_target].uniq
+ end
+
def merge_request_button_visibility(merge_request, closed)
return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork?
end
def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil)
- diffs_namespace_project_merge_request_path(
- project.namespace, project, merge_request,
- diff_id: merge_request_diff.id, start_sha: start_sha)
+ diffs_project_merge_request_path(project, merge_request, diff_id: merge_request_diff.id, start_sha: start_sha)
end
def version_index(merge_request_diff)
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index c9e70faa52e..86666022a2a 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -1,7 +1,7 @@
module MilestonesHelper
def milestones_filter_path(opts = {})
if @project
- namespace_project_milestones_path(@project.namespace, @project, opts)
+ project_milestones_path(@project, opts)
elsif @group
group_milestones_path(@group, opts)
else
@@ -11,7 +11,7 @@ module MilestonesHelper
def milestones_label_path(opts = {})
if @project
- namespace_project_issues_path(@project.namespace, @project, opts)
+ project_issues_path(@project, opts)
elsif @group
issues_group_path(@group, opts)
else
@@ -32,7 +32,18 @@ module MilestonesHelper
end
def milestone_issues_by_label_count(milestone, label, state:)
- milestone.issues.with_label(label.title).send(state).size
+ issues = milestone.issues.with_label(label.title)
+ issues =
+ case state
+ when :opened
+ issues.opened
+ when :closed
+ issues.closed
+ else
+ raise ArgumentError, "invalid milestone state `#{state}`"
+ end
+
+ issues.size
end
# Returns count of milestones for different states
@@ -54,8 +65,10 @@ module MilestonesHelper
def milestone_class_for_state(param, check, match_blank_param = false)
if match_blank_param
'active' if param.blank? || param == check
+ elsif param == check
+ 'active'
else
- 'active' if param == check
+ check
end
end
@@ -73,7 +86,9 @@ module MilestonesHelper
def milestones_filter_dropdown_path
project = @target_project || @project
if project
- namespace_project_milestones_path(project.namespace, project, :json)
+ project_milestones_path(project, :json)
+ elsif @group
+ group_milestones_path(@group, :json)
else
dashboard_milestones_path(:json)
end
@@ -115,4 +130,44 @@ module MilestonesHelper
end
end
end
+
+ def milestone_merge_request_tab_path(milestone)
+ if @project
+ merge_requests_project_milestone_path(@project, milestone, format: :json)
+ elsif @group
+ merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ merge_requests_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
+ end
+ end
+
+ def milestone_participants_tab_path(milestone)
+ if @project
+ participants_project_milestone_path(@project, milestone, format: :json)
+ elsif @group
+ participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ participants_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
+ end
+ end
+
+ def milestone_labels_tab_path(milestone)
+ if @project
+ labels_project_milestone_path(@project, milestone, format: :json)
+ elsif @group
+ labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json)
+ else
+ labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
+ end
+ end
+
+ def group_milestone_route(milestone, params = {})
+ params = nil if params.empty?
+
+ if milestone.is_legacy_group_milestone?
+ group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
+ else
+ group_milestone_path(@group, milestone.iid, milestone: params)
+ end
+ end
end
diff --git a/app/helpers/milestones_routing_helper.rb b/app/helpers/milestones_routing_helper.rb
new file mode 100644
index 00000000000..766d5262018
--- /dev/null
+++ b/app/helpers/milestones_routing_helper.rb
@@ -0,0 +1,17 @@
+module MilestonesRoutingHelper
+ def milestone_path(milestone, *args)
+ if milestone.is_group_milestone?
+ group_milestone_path(milestone.group, milestone, *args)
+ elsif milestone.is_project_milestone?
+ project_milestone_path(milestone.project, milestone, *args)
+ end
+ end
+
+ def milestone_url(milestone, *args)
+ if milestone.is_group_milestone?
+ group_milestone_url(milestone.group, milestone, *args)
+ elsif milestone.is_project_milestone?
+ project_milestone_url(milestone.project, milestone, *args)
+ end
+ end
+end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 17bfd07e00f..b63b3b70903 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -1,42 +1,50 @@
module NavHelper
+ def page_with_sidebar_class
+ class_name = page_gutter_class
+ class_name << 'page-with-new-sidebar' if defined?(@new_sidebar) && @new_sidebar
+ class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @new_sidebar
+
+ class_name
+ end
+
def page_gutter_class
if current_path?('merge_requests#show') ||
- current_path?('merge_requests#diffs') ||
- current_path?('merge_requests#commits') ||
- current_path?('merge_requests#builds') ||
- current_path?('merge_requests#conflicts') ||
- current_path?('merge_requests#pipelines') ||
+ current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show')
if cookies[:collapsed_gutter] == 'true'
- "page-gutter right-sidebar-collapsed"
+ %w[page-gutter right-sidebar-collapsed]
else
- "page-gutter right-sidebar-expanded"
+ %w[page-gutter right-sidebar-expanded]
end
- elsif current_path?('builds#show')
- "page-gutter build-sidebar right-sidebar-expanded"
+ elsif current_path?('jobs#show')
+ %w[page-gutter build-sidebar right-sidebar-expanded]
elsif current_path?('wikis#show') ||
current_path?('wikis#edit') ||
current_path?('wikis#update') ||
current_path?('wikis#history') ||
current_path?('wikis#git_access')
- "page-gutter wiki-sidebar right-sidebar-expanded"
+ %w[page-gutter wiki-sidebar right-sidebar-expanded]
+ else
+ []
end
end
def nav_header_class
- class_name = ''
- class_name << " with-horizontal-nav" if defined?(nav) && nav
+ class_names = []
+ class_names << 'with-horizontal-nav' if defined?(nav) && nav
- class_name
+ class_names
end
def layout_nav_class
- class_name = ''
- class_name << " page-with-layout-nav" if defined?(nav) && nav
- class_name << " page-with-sub-nav" if content_for?(:sub_nav)
+ return [] if show_new_nav?
- class_name
+ class_names = []
+ class_names << 'page-with-layout-nav' if defined?(nav) && nav
+ class_names << 'page-with-sub-nav' if content_for?(:sub_nav)
+
+ class_names
end
def nav_control_class
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index eab0738a368..e857e837c16 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -10,8 +10,8 @@ module NotesHelper
Ability.can_edit_note?(current_user, note)
end
- def note_supports_slash_commands?(note)
- Notes::SlashCommandsService.supported?(note, current_user)
+ def note_supports_quick_actions?(note)
+ Notes::QuickActionsService.supported?(note, current_user)
end
def noteable_json(noteable)
@@ -19,7 +19,7 @@ module NotesHelper
id: noteable.id,
class: noteable.class.name,
resources: noteable.class.table_name,
- project_id: noteable.project.id,
+ project_id: noteable.project.id
}.to_json
end
@@ -34,7 +34,7 @@ module NotesHelper
data = {
line_code: line_code,
- line_type: line_type,
+ line_type: line_type
}
if @use_legacy_diff_notes
@@ -47,10 +47,26 @@ module NotesHelper
data
end
+ def add_diff_note_button(line_code, position, line_type)
+ return if @diff_notes_disabled
+
+ button_tag '',
+ class: 'add-diff-note js-add-diff-note-button',
+ type: 'submit', name: 'button',
+ data: diff_view_line_data(line_code, position, line_type),
+ title: 'Add a comment to this line' do
+ icon('comment-o')
+ end
+ end
+
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
- data = { discussion_id: discussion.id, line_type: line_type }
+ data = {
+ discussion_id: discussion.reply_id,
+ discussion_project_id: discussion.project&.id,
+ line_type: line_type
+ }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply'
@@ -60,24 +76,72 @@ module NotesHelper
note.project.team.human_max_access(note.author_id)
end
- def discussion_diff_path(discussion)
- if discussion.for_merge_request? && discussion.diff_discussion?
- if discussion.active?
- # Without a diff ID, the link always points to the latest diff version
- diff_id = nil
- elsif merge_request_diff = discussion.latest_merge_request_diff
- diff_id = merge_request_diff.id
- else
- # If the discussion is not active, and we cannot find the latest
- # merge request diff for this discussion, we return no path at all.
- return
- end
-
- diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code)
+ def discussion_path(discussion)
+ if discussion.for_merge_request?
+ return unless discussion.diff_discussion?
+
+ version_params = discussion.merge_request_version_params
+ return unless version_params
+
+ path_params = version_params.merge(anchor: discussion.line_code)
+
+ diffs_project_merge_request_path(discussion.project, discussion.noteable, path_params)
elsif discussion.for_commit?
anchor = discussion.line_code if discussion.diff_discussion?
- namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor)
+ project_commit_path(discussion.project, discussion.noteable, anchor: anchor)
+ end
+ end
+
+ def notes_url
+ if @snippet.is_a?(PersonalSnippet)
+ snippet_notes_path(@snippet)
+ else
+ project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+ end
+ end
+
+ def note_url(note, project = @project)
+ if note.noteable.is_a?(PersonalSnippet)
+ snippet_note_path(note.noteable, note)
+ else
+ project_note_path(project, note)
end
end
+
+ def noteable_note_url(note)
+ Gitlab::UrlBuilder.build(note)
+ end
+
+ def form_resources
+ if @snippet.is_a?(PersonalSnippet)
+ [@note]
+ else
+ [@project.namespace.becomes(Namespace), @project, @note]
+ end
+ end
+
+ def new_form_url
+ return nil unless @snippet.is_a?(PersonalSnippet)
+
+ snippet_notes_path(@snippet)
+ end
+
+ def can_create_note?
+ if @snippet.is_a?(PersonalSnippet)
+ can?(current_user, :comment_personal_snippet, @snippet)
+ else
+ can?(current_user, :create_note, @project)
+ end
+ end
+
+ def initial_notes_data(autocomplete)
+ {
+ notesUrl: notes_url,
+ notesIds: @notes.map(&:id),
+ now: Time.now.to_i,
+ diffView: diff_view,
+ autocomplete: autocomplete
+ }
+ end
end
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 03cc8f2b6bd..fde961e2da4 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -21,30 +21,36 @@ module NotificationsHelper
end
def notification_title(level)
+ # Can be anything in `NotificationSetting.level:
case level.to_sym
when :participating
- 'Participate'
+ s_('NotificationLevel|Participate')
when :mention
- 'On mention'
+ s_('NotificationLevel|On mention')
else
- level.to_s.titlecase
+ N_('NotificationLevel|Global')
+ N_('NotificationLevel|Watch')
+ N_('NotificationLevel|Disabled')
+ N_('NotificationLevel|Custom')
+ level = "NotificationLevel|#{level.to_s.humanize}"
+ s_(level)
end
end
def notification_description(level)
case level.to_sym
when :participating
- 'You will only receive notifications for threads you have participated in'
+ _('You will only receive notifications for threads you have participated in')
when :mention
- 'You will receive notifications only for comments in which you were @mentioned'
+ _('You will receive notifications only for comments in which you were @mentioned')
when :watch
- 'You will receive notifications for any activity'
+ _('You will receive notifications for any activity')
when :disabled
- 'You will not get any notifications via email'
+ _('You will not get any notifications via email')
when :global
- 'Use your global notification setting'
+ _('Use your global notification setting')
when :custom
- 'You will only receive notifications for the events you choose'
+ _('You will only receive notifications for the events you choose')
end
end
@@ -76,11 +82,22 @@ module NotificationsHelper
end
def notification_event_name(event)
+ # All values from NotificationSetting::EMAIL_EVENTS
case event
when :success_pipeline
- 'Successful pipeline'
+ s_('NotificationEvent|Successful pipeline')
else
- event.to_s.humanize
+ N_('NotificationEvent|New note')
+ N_('NotificationEvent|New issue')
+ N_('NotificationEvent|Reopen issue')
+ N_('NotificationEvent|Close issue')
+ N_('NotificationEvent|Reassign issue')
+ N_('NotificationEvent|New merge request')
+ N_('NotificationEvent|Close merge request')
+ N_('NotificationEvent|Reassign merge request')
+ N_('NotificationEvent|Merge merge request')
+ N_('NotificationEvent|Failed pipeline')
+ s_(event.to_s.humanize)
end
end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 3286a92a8a7..b30b2eb1d03 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -4,6 +4,10 @@ module PageLayoutHelper
@page_title.push(*titles.compact) if titles.any?
+ if show_new_nav? && titles.any? && !defined?(@breadcrumb_title)
+ @breadcrumb_title = @page_title.last
+ end
+
# Segments are seperated by middot
@page_title.join(" \u00b7 ")
end
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
new file mode 100644
index 00000000000..83dd76a01dd
--- /dev/null
+++ b/app/helpers/pagination_helper.rb
@@ -0,0 +1,21 @@
+module PaginationHelper
+ def paginate_collection(collection, remote: nil)
+ if collection.is_a?(Kaminari::PaginatableWithoutCount)
+ paginate_without_count(collection)
+ elsif collection.respond_to?(:total_pages)
+ paginate_with_count(collection, remote: remote)
+ end
+ end
+
+ def paginate_without_count(collection)
+ render(
+ 'kaminari/gitlab/without_count',
+ previous_path: path_to_prev_page(collection),
+ next_path: path_to_next_page(collection)
+ )
+ end
+
+ def paginate_with_count(collection, remote: nil)
+ paginate(collection, remote: remote, theme: 'gitlab')
+ end
+end
diff --git a/app/helpers/performance_bar_helper.rb b/app/helpers/performance_bar_helper.rb
new file mode 100644
index 00000000000..d24efe37f5f
--- /dev/null
+++ b/app/helpers/performance_bar_helper.rb
@@ -0,0 +1,7 @@
+module PerformanceBarHelper
+ # This is a hack since using `alias_method :performance_bar_enabled?, :peek_enabled?`
+ # in WithPerformanceBar breaks tests (but works in the browser).
+ def performance_bar_enabled?
+ peek_enabled?
+ end
+end
diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb
new file mode 100644
index 00000000000..6edaf78de1b
--- /dev/null
+++ b/app/helpers/pipeline_schedules_helper.rb
@@ -0,0 +1,11 @@
+module PipelineSchedulesHelper
+ def timezone_data
+ ActiveSupport::TimeZone.all.map do |timezone|
+ {
+ name: timezone.name,
+ offset: timezone.utc_offset,
+ identifier: timezone.tzinfo.identifier
+ }
+ end
+ end
+end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index de959f13713..d36bb4ab074 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -49,7 +49,7 @@ module PreferencesHelper
user_view = current_user.project_view
- if @project.feature_available?(:repository, current_user)
+ if can?(current_user, :download_code, @project)
user_view
elsif user_view == "activity"
"activity"
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
new file mode 100644
index 00000000000..45238f12ac7
--- /dev/null
+++ b/app/helpers/profiles_helper.rb
@@ -0,0 +1,7 @@
+module ProfilesHelper
+ def email_provider_label
+ return unless current_user.external_email?
+
+ current_user.email_provider.present? ? Gitlab::OAuth::Provider.label_for(current_user.email_provider) : "LDAP"
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8c26348a975..bee4950e414 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -58,7 +58,17 @@ module ProjectsHelper
link_to(simple_sanitize(owner.name), user_path(owner))
end
- project_link = link_to simple_sanitize(project.name), project_path(project), { class: "project-item-select-holder" }
+ project_link = link_to project_path(project), { class: "project-item-select-holder" } do
+ output =
+ if show_new_nav?
+ project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16)
+ else
+ ""
+ end
+
+ output << simple_sanitize(project.name)
+ output.html_safe
+ end
if current_user
project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do
@@ -70,21 +80,30 @@ module ProjectsHelper
end
def remove_project_message(project)
- "You are going to remove #{project.name_with_namespace}.\n Removed project CANNOT be restored!\n Are you ABSOLUTELY sure?"
+ _("You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def transfer_project_message(project)
- "You are going to transfer #{project.name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+ _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def remove_fork_project_message(project)
- "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?"
+ _("You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?") %
+ { forked_from_project: @project.forked_from_project.name_with_namespace }
end
def project_nav_tabs
@nav_tabs ||= get_project_nav_tabs(@project, current_user)
end
+ def project_search_tabs?(tab)
+ abilities = Array(search_tab_ability_map[tab])
+
+ abilities.any? { |ability| can?(current_user, ability, @project) }
+ end
+
def project_nav_tab?(name)
project_nav_tabs.include? name
end
@@ -110,15 +129,13 @@ module ProjectsHelper
end
def license_short_name(project)
- return 'LICENSE' if project.repository.license_key.nil?
-
- license = Licensee::License.new(project.repository.license_key)
-
- license.nickname || license.name
+ license = project.repository.license
+ license&.nickname || license&.name || 'LICENSE'
end
def last_push_event
return unless current_user
+ return current_user.recent_push unless @project
project_ids = [@project.id]
if fork = current_user.fork_of(@project)
@@ -132,43 +149,103 @@ module ProjectsHelper
# Don't show option "everyone with access" if project is private
options = project_feature_options
+ level = @project.project_feature.public_send(field) # rubocop:disable GitlabSecurity/PublicSend
+
if @project.private?
- level = @project.project_feature.send(field)
- options.delete('Everyone with access')
- highest_available_option = options.values.max if level == ProjectFeature::ENABLED
+ disabled_option = ProjectFeature::ENABLED
+ highest_available_option = ProjectFeature::PRIVATE if level == disabled_option
end
- options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field))
+ options = options_for_select(
+ options.invert,
+ selected: highest_available_option || level,
+ disabled: disabled_option
+ )
- content_tag(
- :select,
- options,
- name: "project[project_feature_attributes][#{field}]",
- id: "project_project_feature_attributes_#{field}",
- class: "pull-right form-control #{repo_children_classes(field)}",
- data: { field: field }
- ).html_safe
+ content_tag :div, class: "select-wrapper" do
+ concat(
+ content_tag(
+ :select,
+ options,
+ name: "project[project_feature_attributes][#{field}]",
+ id: "project_project_feature_attributes_#{field}",
+ class: "pull-right form-control select-control #{repo_children_classes(field)} ",
+ data: { field: field }
+ )
+ )
+ concat(
+ icon('chevron-down')
+ )
+ end.html_safe
end
def link_to_autodeploy_doc
- link_to 'About auto deploy', help_page_path('ci/autodeploy/index'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank'
end
def autodeploy_flash_notice(branch_name)
- "Branch <strong>#{truncate(sanitize(branch_name))}</strong> was created. To set up auto deploy, \
- choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe
+ translation = _("Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}") %
+ { branch_name: truncate(sanitize(branch_name)), link_to_autodeploy_doc: link_to_autodeploy_doc }
+ translation.html_safe
end
def project_list_cache_key(project)
- key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.4']
+ key = [
+ project.route.cache_key,
+ project.cache_key,
+ controller.controller_name,
+ controller.action_name,
+ current_application_settings.cache_key,
+ 'v2.5'
+ ]
+
key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
key
end
def load_pipeline_status(projects)
- Gitlab::Cache::Ci::ProjectPipelineStatus.
- load_in_batch_for_projects(projects)
+ Gitlab::Cache::Ci::ProjectPipelineStatus
+ .load_in_batch_for_projects(projects)
+ end
+
+ def show_no_ssh_key_message?
+ cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key?
+ end
+
+ def show_no_password_message?
+ cookies[:hide_no_password_message].blank? && !current_user.hide_no_password &&
+ ( current_user.require_password_creation? || current_user.require_personal_access_token_creation_for_git_auth? )
+ end
+
+ def link_to_set_password
+ if current_user.require_password_creation?
+ link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path
+ else
+ link_to s_('CreateTokenToCloneLink|create a personal access token'), profile_personal_access_tokens_path
+ end
+ end
+
+ # Returns true if any projects are present.
+ #
+ # If the relation has a LIMIT applied we'll cast the relation to an Array
+ # since repeated any? checks would otherwise result in multiple COUNT queries
+ # being executed.
+ #
+ # If no limit is applied we'll just issue a COUNT since the result set could
+ # be too large to load into memory.
+ def any_projects?(projects)
+ return projects.any? if projects.is_a?(Array)
+
+ if projects.limit_value
+ projects.to_a.any?
+ else
+ projects.except(:offset).any?
+ end
+ end
+
+ def has_projects_or_name?(projects, params)
+ !!(params[:name] || any_projects?(projects))
end
private
@@ -198,18 +275,9 @@ module ProjectsHelper
nav_tabs << :container_registry
end
- tab_ability_map = {
- environments: :read_environment,
- milestones: :read_milestone,
- pipelines: :read_pipeline,
- snippets: :read_project_snippet,
- settings: :admin_project,
- builds: :read_build,
- labels: :read_label,
- issues: :read_issue,
- team: :read_project_member,
- wiki: :read_wiki
- }
+ if project.builds_enabled? && can?(current_user, :read_pipeline, project)
+ nav_tabs << :pipelines
+ end
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
@@ -220,14 +288,37 @@ module ProjectsHelper
nav_tabs.flatten
end
+ def tab_ability_map
+ {
+ environments: :read_environment,
+ milestones: :read_milestone,
+ snippets: :read_project_snippet,
+ settings: :admin_project,
+ builds: :read_build,
+ labels: :read_label,
+ issues: :read_issue,
+ project_members: :read_project_member,
+ wiki: :read_wiki
+ }
+ end
+
+ def search_tab_ability_map
+ @search_tab_ability_map ||= tab_ability_map.merge(
+ blobs: :download_code,
+ commits: :download_code,
+ merge_requests: :read_merge_request,
+ notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
+ )
+ end
+
def project_lfs_status(project)
if project.lfs_enabled?
content_tag(:span, class: 'lfs-enabled') do
- 'Enabled'
+ s_('LFSStatus|Enabled')
end
else
content_tag(:span, class: 'lfs-disabled') do
- 'Disabled'
+ s_('LFSStatus|Disabled')
end
end
end
@@ -236,7 +327,7 @@ module ProjectsHelper
if current_user
current_user.name
else
- "Your name"
+ _("Your name")
end
end
@@ -253,7 +344,7 @@ module ProjectsHelper
when 'ssh'
project.ssh_url_to_repo
else
- project.http_url_to_repo(current_user)
+ project.http_url_to_repo
end
end
@@ -273,25 +364,24 @@ module ProjectsHelper
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
else
- "Never"
+ s_("ProjectLastActivity|Never")
end
end
def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil)
- namespace_project_new_blob_path(
- project.namespace,
+ commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name.downcase }
+ project_new_blob_path(
project,
project.default_branch || 'master',
file_name: file_name,
- commit_message: commit_message || "Add #{file_name.downcase}",
+ commit_message: commit_message,
branch_name: branch_name,
context: context
)
end
def add_koding_stack_path(project)
- namespace_project_new_blob_path(
- project.namespace,
+ project_new_blob_path(
project,
project.default_branch || 'master',
file_name: '.koding.yml',
@@ -331,7 +421,7 @@ module ProjectsHelper
if project
import_path = "/Home/Stacks/import"
- repo = project.path_with_namespace
+ repo = project.full_path
branch ||= project.default_branch
sha ||= project.commit.short_id
@@ -345,8 +435,7 @@ module ProjectsHelper
def contribution_guide_path(project)
if project && contribution_guide = project.repository.contribution_guide
- namespace_project_blob_path(
- project.namespace,
+ project_blob_path(
project,
tree_join(project.default_branch,
contribution_guide.name)
@@ -376,7 +465,7 @@ module ProjectsHelper
def project_wiki_path_with_version(proj, page, version, is_newest)
url_params = is_newest ? {} : { version_id: version }
- namespace_project_wiki_path(proj.namespace, proj, page, url_params)
+ project_wiki_path(proj, page, url_params)
end
def project_status_css_class(status)
@@ -392,7 +481,7 @@ module ProjectsHelper
def readme_cache_key
sha = @project.commit.try(:sha) || 'nil'
- [@project.path_with_namespace, sha, "readme"].join('-')
+ [@project.full_path, sha, "readme"].join('-')
end
def current_ref
@@ -400,9 +489,8 @@ module ProjectsHelper
end
def filename_path(project, filename)
- if project && blob = project.repository.send(filename)
- namespace_project_blob_path(
- project.namespace,
+ if project && blob = project.repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
+ project_blob_path(
project,
tree_join(project.default_branch, blob.name)
)
@@ -420,9 +508,9 @@ module ProjectsHelper
def project_feature_options
{
- 'Disabled' => ProjectFeature::DISABLED,
- 'Only team members' => ProjectFeature::PRIVATE,
- 'Everyone with access' => ProjectFeature::ENABLED
+ ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'),
+ ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'),
+ ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access')
}
end
@@ -453,4 +541,12 @@ module ProjectsHelper
current_application_settings.restricted_visibility_levels || []
end
+
+ def find_file_path
+ return unless @project && !@project.empty_repo?
+
+ ref = @ref || @project.repository.root_ref
+
+ project_find_file_path(@project, ref)
+ end
end
diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb
index ea5d2932ef4..9ac4df88dc3 100644
--- a/app/helpers/rss_helper.rb
+++ b/app/helpers/rss_helper.rb
@@ -1,5 +1,5 @@
module RssHelper
def rss_url_options
- { format: :atom, private_token: current_user.try(:private_token) }
+ { format: :atom, rss_token: current_user.try(:rss_token) }
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 8ff8db16514..ae0e0aa3cf9 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -42,7 +42,7 @@ module SearchHelper
{ category: "Settings", label: "User settings", url: profile_path },
{ category: "Settings", label: "SSH Keys", url: profile_keys_path },
{ category: "Settings", label: "Dashboard", url: root_path },
- { category: "Settings", label: "Admin Section", url: admin_root_path },
+ { category: "Settings", label: "Admin Section", url: admin_root_path }
]
end
@@ -57,7 +57,7 @@ module SearchHelper
{ category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") },
{ category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") },
{ category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") },
+ { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }
]
end
@@ -67,16 +67,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "Current Project", label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) },
- { category: "Current Project", label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) },
- { category: "Current Project", label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) },
- { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) },
- { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) },
- { category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) },
- { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) },
+ { category: "Current Project", label: "Files", url: project_tree_path(@project, ref) },
+ { category: "Current Project", label: "Commits", url: project_commits_path(@project, ref) },
+ { category: "Current Project", label: "Network", url: project_network_path(@project, ref) },
+ { category: "Current Project", label: "Graph", url: project_graph_path(@project, ref) },
+ { category: "Current Project", label: "Issues", url: project_issues_path(@project) },
+ { category: "Current Project", label: "Merge Requests", url: project_merge_requests_path(@project) },
+ { category: "Current Project", label: "Milestones", url: project_milestones_path(@project) },
+ { category: "Current Project", label: "Snippets", url: project_snippets_path(@project) },
+ { category: "Current Project", label: "Members", url: project_project_members_path(@project) },
+ { category: "Current Project", label: "Wiki", url: project_wikis_path(@project) }
]
else
[]
@@ -97,14 +97,14 @@ module SearchHelper
# Autocomplete results for the current user's projects
def projects_autocomplete(term, limit = 5)
- current_user.authorized_projects.search_by_title(term).
- sorted_by_stars.non_archived.limit(limit).map do |p|
+ current_user.authorized_projects.search_by_title(term)
+ .sorted_by_stars.non_archived.limit(limit).map do |p|
{
category: "Projects",
id: p.id,
value: "#{search_result_sanitize(p.name)}",
label: "#{search_result_sanitize(p.name_with_namespace)}",
- url: namespace_project_path(p.namespace, p)
+ url: project_path(p)
}
end
end
@@ -126,6 +126,26 @@ module SearchHelper
search_path(options)
end
+ def search_filter_input_options(type)
+ opts = {
+ id: "filtered-search-#{type}",
+ placeholder: 'Search or filter results...',
+ data: {
+ 'username-params' => @users.to_json(only: [:id, :username])
+ }
+ }
+
+ if @project.present?
+ opts[:data]['project-id'] = @project.id
+ opts[:data]['base-endpoint'] = project_path(@project)
+ else
+ # Group context
+ opts[:data]['base-endpoint'] = group_canonical_path(@group)
+ end
+
+ opts
+ end
+
# Sanitize a HTML field for search display. Most tags are stripped out and the
# maximum length is set to 200 characters.
def search_md_sanitize(object, field)
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 8706876ae4a..1a4f1431bdc 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -45,6 +45,14 @@ module SelectsHelper
end
end
+ with_feature_enabled_data_attribute =
+ case opts.delete(:with_feature_enabled)
+ when 'issues' then 'data-with-issues-enabled'
+ when 'merge_requests' then 'data-with-merge-requests-enabled'
+ end
+
+ opts[with_feature_enabled_data_attribute] = true
+
hidden_field_tag(id, opts[:selected], opts)
end
@@ -67,7 +75,7 @@ module SelectsHelper
current_user: opts[:current_user] || false,
"push-code-to-protected-branches" => opts[:push_code_to_protected_branches],
author_id: opts[:author_id] || '',
- skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil,
+ skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil
}
end
end
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 979264c9421..b447d4952e7 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -1,19 +1,26 @@
module SnippetsHelper
def reliable_snippet_path(snippet, opts = nil)
if snippet.project_id?
- namespace_project_snippet_path(snippet.project.namespace,
- snippet.project, snippet, opts)
+ project_snippet_path(snippet.project, snippet, opts)
else
snippet_path(snippet, opts)
end
end
+ def download_snippet_path(snippet)
+ if snippet.project_id
+ raw_project_snippet_path(@project, snippet, inline: false)
+ else
+ raw_snippet_path(snippet, inline: false)
+ end
+ end
+
# Return the path of a snippets index for a user or for a project
#
# @returns String, path to snippet index
def subject_snippets_path(subject = nil, opts = nil)
if subject.is_a?(Project)
- namespace_project_snippets_path(subject.namespace, subject, opts)
+ project_snippets_path(subject, opts)
else # assume subject === User
dashboard_snippets_path(opts)
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 2fda98cae90..b408ec0c6a4 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -58,7 +58,7 @@ module SortingHelper
sort_value_due_date_soon => sort_title_due_date_soon,
sort_value_due_date_later => sort_title_due_date_later,
sort_value_start_date_soon => sort_title_start_date_soon,
- sort_value_start_date_later => sort_title_start_date_later,
+ sort_value_start_date_later => sort_title_start_date_later
}
end
@@ -70,6 +70,14 @@ module SortingHelper
}
end
+ def tags_sort_options_hash
+ {
+ sort_value_name => sort_title_name,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+ end
+
def sort_title_priority
'Priority'
end
diff --git a/app/helpers/storage_health_helper.rb b/app/helpers/storage_health_helper.rb
new file mode 100644
index 00000000000..544c9efb845
--- /dev/null
+++ b/app/helpers/storage_health_helper.rb
@@ -0,0 +1,37 @@
+module StorageHealthHelper
+ def failing_storage_health_message(storage_health)
+ storage_name = content_tag(:strong, h(storage_health.storage_name))
+ host_names = h(storage_health.failing_on_hosts.to_sentence)
+ translation_params = { storage_name: storage_name,
+ host_names: host_names,
+ failed_attempts: storage_health.total_failures }
+
+ translation = n_('%{storage_name}: failed storage access attempt on host:',
+ '%{storage_name}: %{failed_attempts} failed storage access attempts:',
+ storage_health.total_failures) % translation_params
+
+ translation.html_safe
+ end
+
+ def message_for_circuit_breaker(circuit_breaker)
+ maximum_failures = circuit_breaker.failure_count_threshold
+ current_failures = circuit_breaker.failure_count
+ permanently_broken = circuit_breaker.circuit_broken? && current_failures >= maximum_failures
+
+ translation_params = { number_of_failures: current_failures,
+ maximum_failures: maximum_failures,
+ number_of_seconds: circuit_breaker.failure_wait_time }
+
+ if permanently_broken
+ s_("%{number_of_failures} of %{maximum_failures} failures. GitLab will not "\
+ "retry automatically. Reset storage information when the problem is "\
+ "resolved.") % translation_params
+ elsif circuit_breaker.circuit_broken?
+ _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
+ "block access for %{number_of_seconds} seconds.") % translation_params
+ else
+ _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
+ "allow access on the next attempt.") % translation_params
+ end
+ end
+end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index a762b320d56..88f7702db1e 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -1,28 +1,46 @@
module SubmoduleHelper
- include Gitlab::ShellAdapter
+ extend self
+
+ VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze
# links to files listing for submodule if submodule is a project on this server
def submodule_links(submodule_item, ref = nil, repository = @repository)
url = repository.submodule_url_for(ref, submodule_item.path)
- return url, nil unless url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
-
- namespace = $1
- project = $2
- project.chomp!('.git')
-
- if self_url?(url, namespace, project)
- return namespace_project_path(namespace, project),
- namespace_project_tree_path(namespace, project,
- submodule_item.id)
- elsif relative_self_url?(url)
- relative_self_links(url, submodule_item.id)
- elsif github_dot_com_url?(url)
- standard_links('github.com', namespace, project, submodule_item.id)
- elsif gitlab_dot_com_url?(url)
- standard_links('gitlab.com', namespace, project, submodule_item.id)
+ if url == '.' || url == './'
+ url = File.join(Gitlab.config.gitlab.url, @project.full_path)
+ end
+
+ if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
+ namespace, project = $1, $2
+ gitlab_hosts = [Gitlab.config.gitlab.url,
+ Gitlab.config.gitlab_shell.ssh_path_prefix]
+
+ gitlab_hosts.each do |host|
+ if url.start_with?(host)
+ namespace, _, project = url.sub(host, '').rpartition('/')
+ break
+ end
+ end
+
+ namespace.sub!(/\A\//, '')
+ project.rstrip!
+ project.sub!(/\.git\z/, '')
+
+ if self_url?(url, namespace, project)
+ [namespace_project_path(namespace, project),
+ namespace_project_tree_path(namespace, project, submodule_item.id)]
+ elsif relative_self_url?(url)
+ relative_self_links(url, submodule_item.id)
+ elsif github_dot_com_url?(url)
+ standard_links('github.com', namespace, project, submodule_item.id)
+ elsif gitlab_dot_com_url?(url)
+ standard_links('gitlab.com', namespace, project, submodule_item.id)
+ else
+ [sanitize_submodule_url(url), nil]
+ end
else
- return url, nil
+ [sanitize_submodule_url(url), nil]
end
end
@@ -41,7 +59,7 @@ module SubmoduleHelper
return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/',
project].join('')
url_with_dotgit = url_no_dotgit + '.git'
- url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join(''))
+ url_with_dotgit == Gitlab::Shell.new.url_to_repo([namespace, '/', project].join(''))
end
def relative_self_url?(url)
@@ -55,6 +73,7 @@ module SubmoduleHelper
end
def relative_self_links(url, commit)
+ url.rstrip!
# Map relative links to a namespace and project
# For example:
# ../bar.git -> same namespace, repo bar
@@ -73,4 +92,16 @@ module SubmoduleHelper
namespace_project_tree_path(namespace, base, commit)
]
end
+
+ def sanitize_submodule_url(url)
+ uri = URI.parse(url)
+
+ if uri.scheme.in?(VALID_SUBMODULE_PROTOCOLS)
+ uri.to_s
+ else
+ nil
+ end
+ rescue URI::InvalidURIError
+ nil
+ end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 1ea60e39386..08fd97cd048 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -1,6 +1,7 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
'commit' => 'icon_commit',
+ 'description' => 'icon_edit',
'merge' => 'icon_merge',
'merged' => 'icon_merged',
'opened' => 'icon_status_open',
@@ -16,7 +17,9 @@ module SystemNoteHelper
'visible' => 'icon_eye',
'milestone' => 'icon_clock_o',
'discussion' => 'icon_comment_o',
- 'moved' => 'icon_arrow_circle_o_right'
+ 'moved' => 'icon_arrow_circle_o_right',
+ 'outdated' => 'icon_edit',
+ 'duplicate' => 'icon_clone'
}.freeze
def icon_for_system_note(note)
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index 1a55ee05996..ee701076a14 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -107,8 +107,7 @@ module TabHelper
def branches_tab_class
if current_controller?(:protected_branches) ||
current_controller?(:branches) ||
- current_page?(namespace_project_repository_path(@project.namespace,
- @project))
+ current_page?(project_repository_path(@project))
'active'
end
end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 31aaf9e5607..d000d6b1c0a 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -10,7 +10,7 @@ module TagsHelper
}
options = exist_opts.merge(options)
- namespace_project_tags_path(@project.namespace, @project, @id, options)
+ project_tags_path(@project, @id, options)
end
def tag_list(project)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 4f5adf623f2..2a7aa299e83 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -4,7 +4,7 @@ module TodosHelper
end
def todos_count_format(count)
- count > 99 ? '99+' : count
+ count > 99 ? '99+' : count.to_s
end
def todos_done_count
@@ -13,21 +13,24 @@ module TodosHelper
def todo_action_name(todo)
case todo.action
- when Todo::ASSIGNED then 'assigned you'
- when Todo::MENTIONED then 'mentioned you on'
+ when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you'
+ when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on"
when Todo::BUILD_FAILED then 'The build failed for'
when Todo::MARKED then 'added a todo for'
- when Todo::APPROVAL_REQUIRED then 'set you as an approver for'
+ when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for"
when Todo::UNMERGEABLE then 'Could not merge'
- when Todo::DIRECTLY_ADDRESSED then 'directly addressed you on'
+ when Todo::DIRECTLY_ADDRESSED then "directly addressed #{todo_action_subject(todo)} on"
end
end
def todo_target_link(todo)
- target = todo.target_type.titleize.downcase
- link_to "#{target} #{todo.target_reference}", todo_target_path(todo),
- class: 'has-tooltip',
- title: todo.target.title
+ text = raw("#{todo.target_type.titleize.downcase} ") +
+ if todo.for_commit?
+ content_tag(:span, todo.target_reference, class: 'commit-sha')
+ else
+ todo.target_reference
+ end
+ link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title
end
def todo_target_path(todo)
@@ -36,7 +39,7 @@ module TodosHelper
anchor = dom_id(todo.note) if todo.note.present?
if todo.for_commit?
- namespace_project_commit_path(todo.project.namespace.becomes(Namespace), todo.project,
+ project_commit_path(todo.project,
todo.target, anchor: anchor)
else
path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target]
@@ -63,7 +66,7 @@ module TodosHelper
project_id: params[:project_id],
author_id: params[:author_id],
type: params[:type],
- action_id: params[:action_id],
+ action_id: params[:action_id]
}
end
@@ -148,6 +151,10 @@ module TodosHelper
private
+ def todo_action_subject(todo)
+ todo.self_added? ? 'yourself' : 'you'
+ end
+
def show_todo_state?(todo)
(todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state)
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index f7b5a5f4dfc..e0d3e9b88f3 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -76,19 +76,19 @@ module TreeHelper
"A new branch will be created in your fork and a new merge request will be started."
end
- def tree_breadcrumbs(tree, max_links = 2)
+ def path_breadcrumbs(max_links = 6)
if @path.present?
part_path = ""
parts = @path.split('/')
- yield('..', nil) if parts.count > max_links
+ yield('..', File.join(*parts.first(parts.count - 2))) if parts.count > max_links
parts.each do |part|
part_path = File.join(part_path, part) unless part_path.empty?
part_path = part if part_path.empty?
next if parts.count > max_links && !parts.last(2).include?(part)
- yield(part, tree_join(@ref, part_path))
+ yield(part, part_path)
end
end
end
diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb
index a48d4475e97..ce435ca2241 100644
--- a/app/helpers/triggers_helper.rb
+++ b/app/helpers/triggers_helper.rb
@@ -8,6 +8,6 @@ module TriggersHelper
end
def service_trigger_url(service)
- "#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger"
+ "#{Settings.gitlab.url}/api/v4/projects/#{service.project_id}/services/#{service.to_param}/trigger"
end
end
diff --git a/app/helpers/u2f_helper.rb b/app/helpers/u2f_helper.rb
index 143b4ca6b51..81bfe5d4eeb 100644
--- a/app/helpers/u2f_helper.rb
+++ b/app/helpers/u2f_helper.rb
@@ -1,5 +1,5 @@
module U2fHelper
def inject_u2f_api?
- browser.chrome? && browser.version.to_i >= 41 && !browser.device.mobile?
+ ((browser.chrome? && browser.version.to_i >= 41) || (browser.opera? && browser.version.to_i >= 40)) && !browser.device.mobile?
end
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 9c623c9ba7c..b5f54d3e154 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -4,4 +4,14 @@ module UsersHelper
title: user.email,
class: 'has-tooltip commit-committer-link')
end
+
+ def user_email_help_text(user)
+ return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
+
+ confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
+
+ h('Please click the link in the confirmation email before continuing. It was sent to ') +
+ content_tag(:strong) { user.unconfirmed_email } + h('.') +
+ content_tag(:p) { confirmation_link }
+ end
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index b4aaf498068..35755bc149b 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -29,11 +29,11 @@ module VisibilityLevelHelper
def project_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "Project access must be granted explicitly to each user."
+ _("Project access must be granted explicitly to each user.")
when Gitlab::VisibilityLevel::INTERNAL
- "The project can be cloned by any logged in user."
+ _("The project can be accessed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The project can be cloned without any authentication."
+ _("The project can be accessed without any authentication.")
end
end
@@ -81,7 +81,9 @@ module VisibilityLevelHelper
end
def visibility_level_label(level)
- Project.visibility_levels.key(level)
+ # The visibility level can be:
+ # 'VisibilityLevel|Private', 'VisibilityLevel|Internal', 'VisibilityLevel|Public'
+ s_(Project.visibility_levels.key(level))
end
def restricted_visibility_levels(show_all = false)
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 6bacda9fe75..33453dd178f 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -11,20 +11,31 @@ module WebpackHelper
paths = Webpack::Rails::Manifest.asset_paths(source)
if extension
- paths = paths.select { |p| p.ends_with? ".#{extension}" }
+ paths.select! { |p| p.ends_with? ".#{extension}" }
end
- # include full webpack-dev-server url for rspec tests running locally
+ force_host = webpack_public_host
+ if force_host
+ paths.map! { |p| "#{force_host}#{p}" }
+ end
+
+ paths
+ end
+
+ def webpack_public_host
if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled
host = Rails.configuration.webpack.dev_server.host
port = Rails.configuration.webpack.dev_server.port
protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http'
-
- paths.map! do |p|
- "#{protocol}://#{host}:#{port}#{p}"
- end
+ "#{protocol}://#{host}:#{port}"
+ else
+ ActionController::Base.asset_host.try(:chomp, '/')
end
+ end
- paths
+ def webpack_public_path
+ relative_path = Rails.application.config.relative_url_root
+ webpack_path = Rails.application.config.webpack.public_path
+ File.join(webpack_public_host.to_s, relative_path.to_s, webpack_path.to_s, '')
end
end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 3e3f6246fc5..99212a3438f 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -6,8 +6,8 @@ module WikiHelper
# Returns a String composed of the capitalized name of each directory and the
# capitalized name of the page itself.
def breadcrumb(page_slug)
- page_slug.split('/').
- map { |dir_or_page| WikiPage.unhyphenize(dir_or_page).capitalize }.
- join(' / ')
+ page_slug.split('/')
+ .map { |dir_or_page| WikiPage.unhyphenize(dir_or_page).capitalize }
+ .join(' / ')
end
end
diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb
index d2980db218a..654468bc7fe 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,4 +1,6 @@
class BaseMailer < ActionMailer::Base
+ around_action :render_with_default_locale
+
helper ApplicationHelper
helper MarkupHelper
@@ -14,6 +16,10 @@ class BaseMailer < ActionMailer::Base
private
+ def render_with_default_locale(&block)
+ Gitlab::I18n.with_default_locale(&block)
+ end
+
def default_sender_address
address = Mail::Address.new(Gitlab.config.gitlab.email_from)
address.display_name = Gitlab.config.gitlab.email_display_name
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index f7ed61625f4..962570a0efd 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -2,7 +2,9 @@ class DeviseMailer < Devise::Mailer
default from: "#{Gitlab.config.gitlab.email_display_name} <#{Gitlab.config.gitlab.email_from}>"
default reply_to: Gitlab.config.gitlab.email_reply_to
- layout 'devise_mailer'
+ layout 'mailer/devise'
+
+ helper EmailsHelper
protected
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index d64e48f774b..64ca2d2eacf 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -11,10 +11,12 @@ module Emails
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
- def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
+ def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id)
- @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
+ @previous_assignees = []
+ @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
@@ -29,7 +31,7 @@ module Emails
setup_issue_mail(issue_id, recipient_id)
@label_names = label_names
- @labels_url = namespace_project_labels_url(@project.namespace, @project)
+ @labels_url = project_labels_url(@project)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end
@@ -54,7 +56,7 @@ module Emails
def setup_issue_mail(issue_id, recipient_id)
@issue = Issue.find(issue_id)
@project = @issue.project
- @target_url = namespace_project_issue_url(@project.namespace, @project, @issue)
+ @target_url = project_issue_url(@project, @issue)
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 7b617b359ea..d76c61c369f 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -11,11 +11,11 @@ module Emails
@member_source_type = member_source_type
@member_id = member_id
- admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email)
+ admins = member_source.members.owners_and_masters.pluck(:notification_email)
# A project in a group can have no explicit owners/masters, in that case
# we fallbacks to the group's owners/masters.
if admins.empty? && member_source.respond_to?(:group) && member_source.group
- admins = member_source.group.members.owners_and_masters.includes(:user).pluck(:notification_email)
+ admins = member_source.group.members.owners_and_masters.pluck(:notification_email)
end
mail(to: admins,
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index ec27ac517db..3626f8ce416 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -22,7 +22,7 @@ module Emails
setup_merge_request_mail(merge_request_id, recipient_id)
@label_names = label_names
- @labels_url = namespace_project_labels_url(@project.namespace, @project)
+ @labels_url = project_labels_url(@project)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
@@ -59,7 +59,7 @@ module Emails
def setup_merge_request_mail(merge_request_id, recipient_id)
@merge_request = MergeRequest.find(merge_request_id)
@project = @merge_request.project
- @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request)
+ @target_url = project_merge_request_url(@project, @merge_request)
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 00707a0023e..77a82b895ce 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -4,7 +4,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@commit = @note.noteable
- @target_url = namespace_project_commit_url(*note_target_url_options)
+ @target_url = project_commit_url(*note_target_url_options)
mail_answer_thread(@commit, note_thread_options(recipient_id))
end
@@ -12,7 +12,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@issue = @note.noteable
- @target_url = namespace_project_issue_url(*note_target_url_options)
+ @target_url = project_issue_url(*note_target_url_options)
mail_answer_thread(@issue, note_thread_options(recipient_id))
end
@@ -20,7 +20,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@merge_request = @note.noteable
- @target_url = namespace_project_merge_request_url(*note_target_url_options)
+ @target_url = project_merge_request_url(*note_target_url_options)
mail_answer_thread(@merge_request, note_thread_options(recipient_id))
end
@@ -28,7 +28,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@snippet = @note.noteable
- @target_url = namespace_project_snippet_url(*note_target_url_options)
+ @target_url = project_snippet_url(*note_target_url_options)
mail_answer_thread(@snippet, note_thread_options(recipient_id))
end
@@ -43,7 +43,7 @@ module Emails
private
def note_target_url_options
- [@project.namespace, @project, @note.noteable, anchor: "note_#{@note.id}"]
+ [@project, @note.noteable, anchor: "note_#{@note.id}"]
end
def note_thread_options(recipient_id)
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 256cbcd73a1..c401030e34a 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -14,7 +14,7 @@ module Emails
end
def new_ssh_key_email(key_id)
- @key = Key.find_by_id(key_id)
+ @key = Key.find_by(id: key_id)
return unless @key
@@ -22,5 +22,15 @@ module Emails
@target_url = user_url(@user)
mail(to: @user.notification_email, subject: subject("SSH key was added to your account"))
end
+
+ def new_gpg_key_email(gpg_key_id)
+ @gpg_key = GpgKey.find_by(id: gpg_key_id)
+
+ return unless @gpg_key
+
+ @current_user = @user = @gpg_key.user
+ @target_url = user_url(@user)
+ mail(to: @user.notification_email, subject: subject("GPG key was added to your account"))
+ end
end
end
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index e0af7081411..761d873c01c 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -3,7 +3,7 @@ module Emails
def project_was_moved_email(project_id, user_id, old_path_with_namespace)
@current_user = @user = User.find user_id
@project = Project.find project_id
- @target_url = namespace_project_url(@project.namespace, @project)
+ @target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
mail(to: @user.notification_email,
subject: subject("Project was moved"))
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index f315e38bcaa..9efabe3f44e 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -1,5 +1,6 @@
class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
+ include GitlabRoutingHelper
include Emails::Issues
include Emails::MergeRequests
@@ -164,7 +165,7 @@ class Notify < BaseMailer
headers['X-GitLab-Project'] = @project.name
headers['X-GitLab-Project-Id'] = @project.id
- headers['X-GitLab-Project-Path'] = @project.path_with_namespace
+ headers['X-GitLab-Project-Path'] = @project.full_path
end
def add_unsubscription_headers_and_links
diff --git a/app/models/ability.rb b/app/models/ability.rb
index f3692a5a067..0b6bcbde5d9 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -1,35 +1,20 @@
+require_dependency 'declarative_policy'
+
class Ability
class << self
# Given a list of users and a project this method returns the users that can
# read the given project.
def users_that_can_read_project(users, project)
- if project.public?
- users
- else
- users.select do |user|
- if user.admin?
- true
- elsif project.internal? && !user.external?
- true
- elsif project.owner == user
- true
- elsif project.team.members.include?(user)
- true
- else
- false
- end
- end
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_project, project) }
end
end
# Given a list of users and a snippet this method returns the users that can
# read the given snippet.
def users_that_can_read_personal_snippet(users, snippet)
- case snippet.visibility_level
- when Snippet::INTERNAL, Snippet::PUBLIC
- users
- when Snippet::PRIVATE
- users.include?(snippet.author) ? [snippet.author] : []
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_personal_snippet, snippet) }
end
end
@@ -38,42 +23,35 @@ class Ability
# issues - The issues to reduce down to those readable by the user.
# user - The User for which to check the issues
def issues_readable_by_user(issues, user = nil)
- return issues if user && user.admin?
-
- issues.select { |issue| issue.visible_to_user?(user) }
+ DeclarativePolicy.user_scope do
+ issues.select { |issue| issue.visible_to_user?(user) }
+ end
end
- # TODO: make this private and use the actual abilities stuff for this
def can_edit_note?(user, note)
- return false if !note.editable? || !user.present?
- return true if note.author == user || user.admin?
-
- if note.project
- max_access_level = note.project.team.max_member_access(user.id)
- max_access_level >= Gitlab::Access::MASTER
- else
- false
- end
+ allowed?(user, :edit_note, note)
end
- def allowed?(user, action, subject = :global)
- allowed(user, subject).include?(action)
- end
+ def allowed?(user, action, subject = :global, opts = {})
+ if subject.is_a?(Hash)
+ opts, subject = subject, :global
+ end
- def allowed(user, subject = :global)
- return BasePolicy::RuleSet.none if subject.nil?
- return uncached_allowed(user, subject) unless RequestStore.active?
+ policy = policy_for(user, subject)
- user_key = user ? user.id : 'anonymous'
- subject_key = subject == :global ? 'global' : "#{subject.class.name}/#{subject.id}"
- key = "/ability/#{user_key}/#{subject_key}"
- RequestStore[key] ||= uncached_allowed(user, subject).freeze
+ case opts[:scope]
+ when :user
+ DeclarativePolicy.user_scope { policy.can?(action) }
+ when :subject
+ DeclarativePolicy.subject_scope { policy.can?(action) }
+ else
+ policy.can?(action)
+ end
end
- private
-
- def uncached_allowed(user, subject)
- BasePolicy.class_for(subject).abilities(user, subject)
+ def policy_for(user, subject = :global)
+ cache = RequestStore.active? ? RequestStore : {}
+ DeclarativePolicy.policy_for(user, subject, cache: cache)
end
end
end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 0d7c2d20029..4cbd90c5817 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base
alias_method :author, :reporter
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def notify
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index c79326e8427..ff15689ecac 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -8,7 +8,27 @@ class Appearance < ActiveRecord::Base
validates :logo, file_size: { maximum: 1.megabyte }
validates :header_logo, file_size: { maximum: 1.megabyte }
+ validate :single_appearance_row, on: :create
+
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
- has_many :uploads, as: :model, dependent: :destroy
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ CACHE_KEY = 'current_appearance'.freeze
+
+ after_commit :flush_redis_cache
+
+ def self.current
+ Rails.cache.fetch(CACHE_KEY) { first }
+ end
+
+ def flush_redis_cache
+ Rails.cache.delete(CACHE_KEY)
+ end
+
+ def single_appearance_row
+ if self.class.any?
+ errors.add(:single_appearance_row, 'Only 1 appearances row can exist')
+ end
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index cf042717c95..8e446ff6dd8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters
}x
- serialize :restricted_visibility_levels
- serialize :import_sources
- serialize :disabled_oauth_sign_in_sources, Array
- serialize :domain_whitelist, Array
- serialize :domain_blacklist, Array
- serialize :repository_storages
- serialize :sidekiq_throttling_queues, Array
+ serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :domain_whitelist, Array # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :domain_blacklist, Array # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiveRecordSerialize
cache_markdown_field :sign_in_text
cache_markdown_field :help_page_text
@@ -37,7 +37,12 @@ class ApplicationSetting < ActiveRecord::Base
validates :home_page_url,
allow_blank: true,
url: true,
- if: :home_page_url_column_exist
+ if: :home_page_url_column_exists?
+
+ validates :help_page_support_url,
+ allow_blank: true,
+ url: true,
+ if: :help_page_support_url_column_exists?
validates :after_sign_out_path,
allow_blank: true,
@@ -62,6 +67,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :sentry_enabled
+ validates :clientside_sentry_dsn,
+ presence: true,
+ if: :clientside_sentry_enabled
+
validates :akismet_api_key,
presence: true,
if: :akismet_enabled
@@ -139,7 +148,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
- unless Gitlab::VisibilityLevel.options.has_value?(level)
+ unless Gitlab::VisibilityLevel.options.value?(level)
record.errors.add(attr, "'#{level}' is not a valid visibility level")
end
end
@@ -147,7 +156,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :import_sources do |record, attr, value|
value&.each do |source|
- unless Gitlab::ImportSources.options.has_value?(source)
+ unless Gitlab::ImportSources.options.value?(source)
record.errors.add(attr, "'#{source}' is not a import source")
end
end
@@ -175,6 +184,9 @@ class ApplicationSetting < ActiveRecord::Base
Rails.cache.fetch(CACHE_KEY) do
ApplicationSetting.last
end
+ rescue
+ # Fall back to an uncached value if there are any problems (e.g. redis down)
+ ApplicationSetting.last
end
def self.expire
@@ -185,8 +197,9 @@ class ApplicationSetting < ActiveRecord::Base
end
def self.cached
- ensure_cache_setup
- Rails.cache.fetch(CACHE_KEY)
+ value = Rails.cache.read(CACHE_KEY)
+ ensure_cache_setup if value.present?
+ value
end
def self.ensure_cache_setup
@@ -195,7 +208,7 @@ class ApplicationSetting < ActiveRecord::Base
ApplicationSetting.define_attribute_methods
end
- def self.defaults_ce
+ def self.defaults
{
after_sign_up_text: nil,
akismet_enabled: false,
@@ -210,6 +223,7 @@ class ApplicationSetting < ActiveRecord::Base
domain_whitelist: Settings.gitlab['domain_whitelist'],
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
+ help_page_hide_commercial_content: false,
unique_ips_limit_per_user: 10,
unique_ips_limit_time_window: 3600,
unique_ips_limit_enabled: false,
@@ -223,8 +237,11 @@ class ApplicationSetting < ActiveRecord::Base
koding_url: nil,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
+ password_authentication_enabled: Settings.gitlab['password_authentication_enabled'],
+ performance_bar_allowed_group_id: nil,
plantuml_enabled: false,
plantuml_url: nil,
+ project_export_enabled: true,
recaptcha_enabled: false,
repository_checks_enabled: true,
repository_storages: ['default'],
@@ -236,20 +253,15 @@ class ApplicationSetting < ActiveRecord::Base
shared_runners_text: nil,
sidekiq_throttling_enabled: false,
sign_in_text: nil,
- signin_enabled: Settings.gitlab['signin_enabled'],
signup_enabled: Settings.gitlab['signup_enabled'],
terminal_max_session_time: 0,
two_factor_grace_period: 48,
user_default_external: false,
polling_interval_multiplier: 1,
- usage_ping_enabled: true
+ usage_ping_enabled: Settings.gitlab['usage_ping_enabled']
}
end
- def self.defaults
- defaults_ce
- end
-
def self.create_from_defaults
create(defaults)
end
@@ -262,10 +274,14 @@ class ApplicationSetting < ActiveRecord::Base
end
end
- def home_page_url_column_exist
+ def home_page_url_column_exists?
ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url)
end
+ def help_page_support_url_column_exists?
+ ActiveRecord::Base.connection.column_exists?(:application_settings, :help_page_support_url)
+ end
+
def sidekiq_throttling_column_exists?
ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled)
end
@@ -300,7 +316,9 @@ class ApplicationSetting < ActiveRecord::Base
Array(read_attribute(:repository_storages))
end
+ # DEPRECATED
# repository_storage is still required in the API. Remove in 9.0
+ # Still used in API v3
def repository_storage
repository_storages.first
end
@@ -325,6 +343,48 @@ class ApplicationSetting < ActiveRecord::Base
super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) })
end
+ def performance_bar_allowed_group_id=(group_full_path)
+ group_full_path = nil if group_full_path.blank?
+
+ if group_full_path.nil?
+ if group_full_path != performance_bar_allowed_group_id
+ super(group_full_path)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+ return
+ end
+
+ group = Group.find_by_full_path(group_full_path)
+
+ if group
+ if group.id != performance_bar_allowed_group_id
+ super(group.id)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+ else
+ super(nil)
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+ end
+
+ def performance_bar_allowed_group
+ Group.find_by_id(performance_bar_allowed_group_id)
+ end
+
+ # Return true if the Performance Bar is enabled for a given group
+ def performance_bar_enabled
+ performance_bar_allowed_group_id.present?
+ end
+
+ # - If `enable` is true, we early return since the actual attribute that holds
+ # the enabling/disabling is `performance_bar_allowed_group_id`
+ # - If `enable` is false, we set `performance_bar_allowed_group_id` to `nil`
+ def performance_bar_enabled=(enable)
+ return if enable
+
+ self.performance_bar_allowed_group_id = nil
+ end
+
# Choose one of the available repository storage options. Currently all have
# equal weighting.
def pick_repository_storage
@@ -345,6 +405,14 @@ class ApplicationSetting < ActiveRecord::Base
sidekiq_throttling_enabled
end
+ def usage_ping_can_be_configured?
+ Settings.gitlab.usage_ping_enabled
+ end
+
+ def usage_ping_enabled
+ usage_ping_can_be_configured? && super
+ end
+
private
def ensure_uuid!
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 967ffd46db0..112a8778b4e 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,5 +1,5 @@
class AuditEvent < ActiveRecord::Base
- serialize :details, Hash
+ serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 6ada6fae4eb..91b62dabbcd 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -5,7 +5,7 @@ class AwardEmoji < ActiveRecord::Base
include Participable
include GhostUser
- belongs_to :awardable, polymorphic: true
+ belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
validates :awardable, :user, presence: true
@@ -19,9 +19,9 @@ class AwardEmoji < ActiveRecord::Base
class << self
def votes_for_collection(ids, type)
- select('name', 'awardable_id', 'COUNT(*) as count').
- where('name IN (?) AND awardable_type = ? AND awardable_id IN (?)', [DOWNVOTE_NAME, UPVOTE_NAME], type, ids).
- group('name', 'awardable_id')
+ select('name', 'awardable_id', 'COUNT(*) as count')
+ .where('name IN (?) AND awardable_type = ? AND awardable_id IN (?)', [DOWNVOTE_NAME, UPVOTE_NAME], type, ids)
+ .group('name', 'awardable_id')
end
end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 55872acef51..954d4e4d779 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -3,8 +3,62 @@ class Blob < SimpleDelegator
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
- # The maximum size of an SVG that can be displayed.
- MAXIMUM_SVG_SIZE = 2.megabytes
+ MAXIMUM_TEXT_HIGHLIGHT_SIZE = 1.megabyte
+
+ # Finding a viewer for a blob happens based only on extension and whether the
+ # blob is binary or text, which means 1 blob should only be matched by 1 viewer,
+ # and the order of these viewers doesn't really matter.
+ #
+ # However, when the blob is an LFS pointer, we cannot know for sure whether the
+ # file being pointed to is binary or text. In this case, we match only on
+ # extension, preferring binary viewers over text ones if both exist, since the
+ # large files referred to in "Large File Storage" are much more likely to be
+ # binary than text.
+ #
+ # `.stl` files, for example, exist in both binary and text forms, and are
+ # handled by different viewers (`BinarySTL` and `TextSTL`) depending on blob
+ # type. LFS pointers to `.stl` files are assumed to always be the binary kind,
+ # and use the `BinarySTL` viewer.
+ RICH_VIEWERS = [
+ BlobViewer::Markup,
+ BlobViewer::Notebook,
+ BlobViewer::SVG,
+
+ BlobViewer::Image,
+ BlobViewer::Sketch,
+ BlobViewer::Balsamiq,
+
+ BlobViewer::Video,
+
+ BlobViewer::PDF,
+
+ BlobViewer::BinarySTL,
+ BlobViewer::TextSTL
+ ].sort_by { |v| v.binary? ? 0 : 1 }.freeze
+
+ AUXILIARY_VIEWERS = [
+ BlobViewer::GitlabCiYml,
+ BlobViewer::RouteMap,
+
+ BlobViewer::Readme,
+ BlobViewer::License,
+ BlobViewer::Contributing,
+ BlobViewer::Changelog,
+
+ BlobViewer::Cartfile,
+ BlobViewer::ComposerJson,
+ BlobViewer::Gemfile,
+ BlobViewer::Gemspec,
+ BlobViewer::GodepsJson,
+ BlobViewer::PackageJson,
+ BlobViewer::Podfile,
+ BlobViewer::Podspec,
+ BlobViewer::PodspecJson,
+ BlobViewer::RequirementsTxt,
+ BlobViewer::YarnLock
+ ].freeze
+
+ attr_reader :project
# Wrap a Gitlab::Git::Blob object, or return nil when given nil
#
@@ -16,10 +70,16 @@ class Blob < SimpleDelegator
#
# blob = Blob.decorate(nil)
# puts "truthy" if blob # No output
- def self.decorate(blob)
+ def self.decorate(blob, project = nil)
return if blob.nil?
- new(blob)
+ new(blob, project)
+ end
+
+ def initialize(blob, project = nil)
+ @project = project
+
+ super(blob)
end
# Returns the data of the blob.
@@ -34,83 +94,136 @@ class Blob < SimpleDelegator
end
end
+ def load_all_data!
+ super(project.repository) if project
+ end
+
def no_highlighting?
- size && size > 1.megabyte
+ raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE
+ end
+
+ def empty?
+ raw_size == 0
end
- def only_display_raw?
- size && truncated?
+ def external_storage_error?
+ if external_storage == :lfs
+ !project&.lfs_enabled?
+ else
+ false
+ end
+ end
+
+ def stored_externally?
+ return @stored_externally if defined?(@stored_externally)
+
+ @stored_externally = external_storage && !external_storage_error?
+ end
+
+ # Returns the size of the file that this blob represents. If this blob is an
+ # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is
+ # the size of the blob itself.
+ def raw_size
+ if stored_externally?
+ external_size
+ else
+ size
+ end
+ end
+
+ # Returns whether the file that this blob represents is binary. If this blob is
+ # an LFS pointer, we assume the file stored in LFS is binary, unless a
+ # text-based rich blob viewer matched on the file's extension. Otherwise, this
+ # depends on the type of the blob itself.
+ def raw_binary?
+ if stored_externally?
+ if rich_viewer
+ rich_viewer.binary?
+ elsif Linguist::Language.find_by_filename(name).any?
+ false
+ elsif _mime_type
+ _mime_type.binary?
+ else
+ true
+ end
+ else
+ binary?
+ end
end
def extension
- extname.downcase.delete('.')
+ @extension ||= extname.downcase.delete('.')
end
- def svg?
- text? && language && language.name == 'SVG'
+ def file_type
+ Gitlab::FileDetector.type_of(path)
end
- def pdf?
- extension == 'pdf'
+ def video?
+ UploaderHelper::VIDEO_EXT.include?(extension)
end
- def ipython_notebook?
- text? && language&.name == 'Jupyter Notebook'
+ def readable_text?
+ text? && !stored_externally? && !truncated?
end
- def sketch?
- binary? && extension == 'sketch'
+ def simple_viewer
+ @simple_viewer ||= simple_viewer_class.new(self)
end
- def stl?
- extension == 'stl'
+ def rich_viewer
+ return @rich_viewer if defined?(@rich_viewer)
+
+ @rich_viewer = rich_viewer_class&.new(self)
end
- def markup?
- text? && Gitlab::MarkupHelper.markup?(name)
+ def auxiliary_viewer
+ return @auxiliary_viewer if defined?(@auxiliary_viewer)
+
+ @auxiliary_viewer = auxiliary_viewer_class&.new(self)
end
- def size_within_svg_limits?
- size <= MAXIMUM_SVG_SIZE
+ def rendered_as_text?(ignore_errors: true)
+ simple_viewer.is_a?(BlobViewer::Text) && (ignore_errors || simple_viewer.render_error.nil?)
end
- def video?
- UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.'))
+ def show_viewer_switcher?
+ rendered_as_text? && rich_viewer
end
- def to_partial_path(project)
- if lfs_pointer?
- if project.lfs_enabled?
- 'download'
- else
- 'text'
- end
- elsif image?
- 'image'
- elsif svg?
- 'svg'
- elsif pdf?
- 'pdf'
- elsif ipython_notebook?
- 'notebook'
- elsif sketch?
- 'sketch'
- elsif stl?
- 'stl'
- elsif markup?
- if only_display_raw?
- 'too_large'
- else
- 'markup'
- end
- elsif text?
- if only_display_raw?
- 'too_large'
- else
- 'text'
- end
- else
- 'download'
+ def expanded?
+ !!@expanded
+ end
+
+ def expand!
+ @expanded = true
+ end
+
+ private
+
+ def simple_viewer_class
+ if empty?
+ BlobViewer::Empty
+ elsif raw_binary?
+ BlobViewer::Download
+ else # text
+ BlobViewer::Text
end
end
+
+ def rich_viewer_class
+ viewer_class_from(RICH_VIEWERS)
+ end
+
+ def auxiliary_viewer_class
+ viewer_class_from(AUXILIARY_VIEWERS)
+ end
+
+ def viewer_class_from(classes)
+ return if empty? || external_storage_error?
+
+ verify_binary = !stored_externally?
+
+ classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) }
+ end
end
diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb
new file mode 100644
index 00000000000..1bea225f17c
--- /dev/null
+++ b/app/models/blob_viewer/auxiliary.rb
@@ -0,0 +1,18 @@
+module BlobViewer
+ module Auxiliary
+ extend ActiveSupport::Concern
+
+ include Gitlab::Allowable
+
+ included do
+ self.loading_partial_name = 'loading_auxiliary'
+ self.type = :auxiliary
+ self.collapse_limit = 100.kilobytes
+ self.size_limit = 100.kilobytes
+ end
+
+ def visible_to?(current_user)
+ true
+ end
+ end
+end
diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb
new file mode 100644
index 00000000000..f982521db99
--- /dev/null
+++ b/app/models/blob_viewer/balsamiq.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Balsamiq < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'balsamiq'
+ self.extensions = %w(bmpr)
+ self.binary = true
+ self.switcher_icon = 'file-image-o'
+ self.switcher_title = 'preview'
+ end
+end
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
new file mode 100644
index 00000000000..bf3453b3063
--- /dev/null
+++ b/app/models/blob_viewer/base.rb
@@ -0,0 +1,99 @@
+module BlobViewer
+ class Base
+ PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze
+
+ class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :collapse_limit, :size_limit
+
+ self.loading_partial_name = 'loading'
+
+ delegate :partial_path, :loading_partial_path, :rich?, :simple?, :load_async?, :text?, :binary?, to: :class
+
+ attr_reader :blob
+
+ delegate :project, to: :blob
+
+ def initialize(blob)
+ @blob = blob
+ @initially_binary = blob.binary?
+ end
+
+ def self.partial_path
+ File.join(PARTIAL_PATH_PREFIX, partial_name)
+ end
+
+ def self.loading_partial_path
+ File.join(PARTIAL_PATH_PREFIX, loading_partial_name)
+ end
+
+ def self.rich?
+ type == :rich
+ end
+
+ def self.simple?
+ type == :simple
+ end
+
+ def self.auxiliary?
+ type == :auxiliary
+ end
+
+ def self.load_async?
+ load_async
+ end
+
+ def self.binary?
+ binary
+ end
+
+ def self.text?
+ !binary?
+ end
+
+ def self.can_render?(blob, verify_binary: true)
+ return false if verify_binary && binary? != blob.binary?
+ return true if extensions&.include?(blob.extension)
+ return true if file_types&.include?(blob.file_type)
+
+ false
+ end
+
+ def collapsed?
+ return @collapsed if defined?(@collapsed)
+
+ @collapsed = !blob.expanded? && collapse_limit && blob.raw_size > collapse_limit
+ end
+
+ def too_large?
+ return @too_large if defined?(@too_large)
+
+ @too_large = size_limit && blob.raw_size > size_limit
+ end
+
+ def binary_detected_after_load?
+ !@initially_binary && blob.binary?
+ end
+
+ # This method is used on the server side to check whether we can attempt to
+ # render the blob at all. Human-readable error messages are found in the
+ # `BlobHelper#blob_render_error_reason` helper.
+ #
+ # This method does not and should not load the entire blob contents into
+ # memory, and should not be overridden to do so in order to validate the
+ # format of the blob.
+ #
+ # Prefer to implement a client-side viewer, where the JS component loads the
+ # binary from `blob_raw_path` and does its own format validation and error
+ # rendering, especially for potentially large binary formats.
+ def render_error
+ if too_large?
+ :too_large
+ elsif collapsed?
+ :collapsed
+ end
+ end
+
+ def prepare!
+ # To be overridden by subclasses
+ end
+ end
+end
diff --git a/app/models/blob_viewer/binary_stl.rb b/app/models/blob_viewer/binary_stl.rb
new file mode 100644
index 00000000000..80393471ef2
--- /dev/null
+++ b/app/models/blob_viewer/binary_stl.rb
@@ -0,0 +1,10 @@
+module BlobViewer
+ class BinarySTL < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'stl'
+ self.extensions = %w(stl)
+ self.binary = true
+ end
+end
diff --git a/app/models/blob_viewer/cartfile.rb b/app/models/blob_viewer/cartfile.rb
new file mode 100644
index 00000000000..d8471bc33c0
--- /dev/null
+++ b/app/models/blob_viewer/cartfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Cartfile < DependencyManager
+ include Static
+
+ self.file_types = %i(cartfile)
+
+ def manager_name
+ 'Carthage'
+ end
+
+ def manager_url
+ 'https://github.com/Carthage/Carthage'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/changelog.rb b/app/models/blob_viewer/changelog.rb
new file mode 100644
index 00000000000..0464ae27f71
--- /dev/null
+++ b/app/models/blob_viewer/changelog.rb
@@ -0,0 +1,16 @@
+module BlobViewer
+ class Changelog < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'changelog'
+ self.file_types = %i(changelog)
+ self.binary = false
+
+ def render_error
+ return if project.repository.tag_count > 0
+
+ :no_tags
+ end
+ end
+end
diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb
new file mode 100644
index 00000000000..079cfbe3616
--- /dev/null
+++ b/app/models/blob_viewer/client_side.rb
@@ -0,0 +1,11 @@
+module BlobViewer
+ module ClientSide
+ extend ActiveSupport::Concern
+
+ included do
+ self.load_async = false
+ self.collapse_limit = 10.megabytes
+ self.size_limit = 50.megabytes
+ end
+ end
+end
diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb
new file mode 100644
index 00000000000..ef8b4aef8e8
--- /dev/null
+++ b/app/models/blob_viewer/composer_json.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class ComposerJson < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(composer_json)
+
+ def manager_name
+ 'Composer'
+ end
+
+ def manager_url
+ 'https://getcomposer.com/'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+
+ def package_url
+ "https://packagist.org/packages/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/contributing.rb b/app/models/blob_viewer/contributing.rb
new file mode 100644
index 00000000000..fbd1dd48697
--- /dev/null
+++ b/app/models/blob_viewer/contributing.rb
@@ -0,0 +1,10 @@
+module BlobViewer
+ class Contributing < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'contributing'
+ self.file_types = %i(contributing)
+ self.binary = false
+ end
+end
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
new file mode 100644
index 00000000000..a8d9be945dc
--- /dev/null
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -0,0 +1,43 @@
+module BlobViewer
+ class DependencyManager < Base
+ include Auxiliary
+
+ self.partial_name = 'dependency_manager'
+ self.binary = false
+
+ def manager_name
+ raise NotImplementedError
+ end
+
+ def manager_url
+ raise NotImplementedError
+ end
+
+ def package_type
+ 'package'
+ end
+
+ def package_name
+ nil
+ end
+
+ def package_url
+ nil
+ end
+
+ private
+
+ def package_name_from_json(key)
+ prepare!
+
+ JSON.parse(blob.data)[key] rescue nil
+ end
+
+ def package_name_from_method_call(name)
+ prepare!
+
+ match = blob.data.match(/#{name}\s*=\s*["'](?<name>[^"']+)["']/)
+ match[:name] if match
+ end
+ end
+end
diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb
new file mode 100644
index 00000000000..074e7204814
--- /dev/null
+++ b/app/models/blob_viewer/download.rb
@@ -0,0 +1,9 @@
+module BlobViewer
+ class Download < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'download'
+ self.binary = true
+ end
+end
diff --git a/app/models/blob_viewer/empty.rb b/app/models/blob_viewer/empty.rb
new file mode 100644
index 00000000000..2380578ed72
--- /dev/null
+++ b/app/models/blob_viewer/empty.rb
@@ -0,0 +1,8 @@
+module BlobViewer
+ class Empty < Base
+ include Simple
+ include ServerSide
+
+ self.partial_name = 'empty'
+ end
+end
diff --git a/app/models/blob_viewer/gemfile.rb b/app/models/blob_viewer/gemfile.rb
new file mode 100644
index 00000000000..fae8c8df23f
--- /dev/null
+++ b/app/models/blob_viewer/gemfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Gemfile < DependencyManager
+ include Static
+
+ self.file_types = %i(gemfile gemfile_lock)
+
+ def manager_name
+ 'Bundler'
+ end
+
+ def manager_url
+ 'http://bundler.io/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/gemspec.rb b/app/models/blob_viewer/gemspec.rb
new file mode 100644
index 00000000000..7802edeb754
--- /dev/null
+++ b/app/models/blob_viewer/gemspec.rb
@@ -0,0 +1,27 @@
+module BlobViewer
+ class Gemspec < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(gemspec)
+
+ def manager_name
+ 'RubyGems'
+ end
+
+ def manager_url
+ 'https://rubygems.org/'
+ end
+
+ def package_type
+ 'gem'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_method_call('name')
+ end
+
+ def package_url
+ "https://rubygems.org/gems/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb
new file mode 100644
index 00000000000..7267c3965d3
--- /dev/null
+++ b/app/models/blob_viewer/gitlab_ci_yml.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class GitlabCiYml < Base
+ include ServerSide
+ include Auxiliary
+
+ self.partial_name = 'gitlab_ci_yml'
+ self.loading_partial_name = 'gitlab_ci_yml_loading'
+ self.file_types = %i(gitlab_ci)
+ self.binary = false
+
+ def validation_message
+ return @validation_message if defined?(@validation_message)
+
+ prepare!
+
+ @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data)
+ end
+
+ def valid?
+ validation_message.blank?
+ end
+ end
+end
diff --git a/app/models/blob_viewer/godeps_json.rb b/app/models/blob_viewer/godeps_json.rb
new file mode 100644
index 00000000000..e19a602603b
--- /dev/null
+++ b/app/models/blob_viewer/godeps_json.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class GodepsJson < DependencyManager
+ include Static
+
+ self.file_types = %i(godeps_json)
+
+ def manager_name
+ 'godep'
+ end
+
+ def manager_url
+ 'https://github.com/tools/godep'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb
new file mode 100644
index 00000000000..c4eae5c79c2
--- /dev/null
+++ b/app/models/blob_viewer/image.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Image < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'image'
+ self.extensions = UploaderHelper::IMAGE_EXT
+ self.binary = true
+ self.switcher_icon = 'picture-o'
+ self.switcher_title = 'image'
+ end
+end
diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb
new file mode 100644
index 00000000000..57355f2c3aa
--- /dev/null
+++ b/app/models/blob_viewer/license.rb
@@ -0,0 +1,20 @@
+module BlobViewer
+ class License < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'license'
+ self.file_types = %i(license)
+ self.binary = false
+
+ def license
+ project.repository.license
+ end
+
+ def render_error
+ return if license
+
+ :unknown_license
+ end
+ end
+end
diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb
new file mode 100644
index 00000000000..33b59c4f512
--- /dev/null
+++ b/app/models/blob_viewer/markup.rb
@@ -0,0 +1,11 @@
+module BlobViewer
+ class Markup < Base
+ include Rich
+ include ServerSide
+
+ self.partial_name = 'markup'
+ self.extensions = Gitlab::MarkupHelper::EXTENSIONS
+ self.file_types = %i(readme)
+ self.binary = false
+ end
+end
diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb
new file mode 100644
index 00000000000..e00b47e6c17
--- /dev/null
+++ b/app/models/blob_viewer/notebook.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Notebook < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'notebook'
+ self.extensions = %w(ipynb)
+ self.binary = false
+ self.switcher_icon = 'file-text-o'
+ self.switcher_title = 'notebook'
+ end
+end
diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb
new file mode 100644
index 00000000000..09221efb56c
--- /dev/null
+++ b/app/models/blob_viewer/package_json.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class PackageJson < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(package_json)
+
+ def manager_name
+ 'npm'
+ end
+
+ def manager_url
+ 'https://www.npmjs.com/'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+
+ def package_url
+ "https://www.npmjs.com/package/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb
new file mode 100644
index 00000000000..65805f5f388
--- /dev/null
+++ b/app/models/blob_viewer/pdf.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class PDF < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'pdf'
+ self.extensions = %w(pdf)
+ self.binary = true
+ self.switcher_icon = 'file-pdf-o'
+ self.switcher_title = 'PDF'
+ end
+end
diff --git a/app/models/blob_viewer/podfile.rb b/app/models/blob_viewer/podfile.rb
new file mode 100644
index 00000000000..507bc734cb4
--- /dev/null
+++ b/app/models/blob_viewer/podfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Podfile < DependencyManager
+ include Static
+
+ self.file_types = %i(podfile)
+
+ def manager_name
+ 'CocoaPods'
+ end
+
+ def manager_url
+ 'https://cocoapods.org/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/podspec.rb b/app/models/blob_viewer/podspec.rb
new file mode 100644
index 00000000000..a4c242db3a9
--- /dev/null
+++ b/app/models/blob_viewer/podspec.rb
@@ -0,0 +1,27 @@
+module BlobViewer
+ class Podspec < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(podspec)
+
+ def manager_name
+ 'CocoaPods'
+ end
+
+ def manager_url
+ 'https://cocoapods.org/'
+ end
+
+ def package_type
+ 'pod'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_method_call('name')
+ end
+
+ def package_url
+ "https://cocoapods.org/pods/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb
new file mode 100644
index 00000000000..602f4a51fd9
--- /dev/null
+++ b/app/models/blob_viewer/podspec_json.rb
@@ -0,0 +1,9 @@
+module BlobViewer
+ class PodspecJson < Podspec
+ self.file_types = %i(podspec_json)
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+ end
+end
diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb
new file mode 100644
index 00000000000..4604a9934a0
--- /dev/null
+++ b/app/models/blob_viewer/readme.rb
@@ -0,0 +1,20 @@
+module BlobViewer
+ class Readme < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'readme'
+ self.file_types = %i(readme)
+ self.binary = false
+
+ def visible_to?(current_user)
+ can?(current_user, :read_wiki, project)
+ end
+
+ def render_error
+ return if project.has_external_wiki? || (project.wiki_enabled? && project.wiki.has_home_page?)
+
+ :no_wiki
+ end
+ end
+end
diff --git a/app/models/blob_viewer/requirements_txt.rb b/app/models/blob_viewer/requirements_txt.rb
new file mode 100644
index 00000000000..83ac55f61d0
--- /dev/null
+++ b/app/models/blob_viewer/requirements_txt.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class RequirementsTxt < DependencyManager
+ include Static
+
+ self.file_types = %i(requirements_txt)
+
+ def manager_name
+ 'pip'
+ end
+
+ def manager_url
+ 'https://pip.pypa.io/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb
new file mode 100644
index 00000000000..be373dbc948
--- /dev/null
+++ b/app/models/blob_viewer/rich.rb
@@ -0,0 +1,11 @@
+module BlobViewer
+ module Rich
+ extend ActiveSupport::Concern
+
+ included do
+ self.type = :rich
+ self.switcher_icon = 'file-text-o'
+ self.switcher_title = 'rendered file'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb
new file mode 100644
index 00000000000..153b4eeb2c9
--- /dev/null
+++ b/app/models/blob_viewer/route_map.rb
@@ -0,0 +1,30 @@
+module BlobViewer
+ class RouteMap < Base
+ include ServerSide
+ include Auxiliary
+
+ self.partial_name = 'route_map'
+ self.loading_partial_name = 'route_map_loading'
+ self.file_types = %i(route_map)
+ self.binary = false
+
+ def validation_message
+ return @validation_message if defined?(@validation_message)
+
+ prepare!
+
+ @validation_message =
+ begin
+ Gitlab::RouteMap.new(blob.data)
+
+ nil
+ rescue Gitlab::RouteMap::FormatError => e
+ e.message
+ end
+ end
+
+ def valid?
+ validation_message.blank?
+ end
+ end
+end
diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb
new file mode 100644
index 00000000000..86afcc86aa0
--- /dev/null
+++ b/app/models/blob_viewer/server_side.rb
@@ -0,0 +1,26 @@
+module BlobViewer
+ module ServerSide
+ extend ActiveSupport::Concern
+
+ included do
+ self.load_async = true
+ self.collapse_limit = 2.megabytes
+ self.size_limit = 5.megabytes
+ end
+
+ def prepare!
+ blob.load_all_data!
+ end
+
+ def render_error
+ # Files that are not stored in the repository, like LFS files and
+ # build artifacts, can only be rendered using a client-side viewer,
+ # since we do not want to read large amounts of data into memory on the
+ # server side. Client-side viewers use JS and can fetch the file from
+ # `blob_raw_path` using AJAX.
+ return :server_side_but_stored_externally if blob.stored_externally?
+
+ super
+ end
+ end
+end
diff --git a/app/models/blob_viewer/simple.rb b/app/models/blob_viewer/simple.rb
new file mode 100644
index 00000000000..454a20495fc
--- /dev/null
+++ b/app/models/blob_viewer/simple.rb
@@ -0,0 +1,11 @@
+module BlobViewer
+ module Simple
+ extend ActiveSupport::Concern
+
+ included do
+ self.type = :simple
+ self.switcher_icon = 'code'
+ self.switcher_title = 'source'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb
new file mode 100644
index 00000000000..818456778e1
--- /dev/null
+++ b/app/models/blob_viewer/sketch.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Sketch < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'sketch'
+ self.extensions = %w(sketch)
+ self.binary = true
+ self.switcher_icon = 'file-image-o'
+ self.switcher_title = 'preview'
+ end
+end
diff --git a/app/models/blob_viewer/static.rb b/app/models/blob_viewer/static.rb
new file mode 100644
index 00000000000..c9e257e5388
--- /dev/null
+++ b/app/models/blob_viewer/static.rb
@@ -0,0 +1,14 @@
+module BlobViewer
+ module Static
+ extend ActiveSupport::Concern
+
+ included do
+ self.load_async = false
+ end
+
+ # We can always render a static viewer, even if the blob is too large.
+ def render_error
+ nil
+ end
+ end
+end
diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb
new file mode 100644
index 00000000000..b7e5cd71e6b
--- /dev/null
+++ b/app/models/blob_viewer/svg.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class SVG < Base
+ include Rich
+ include ServerSide
+
+ self.partial_name = 'svg'
+ self.extensions = %w(svg)
+ self.binary = false
+ self.switcher_icon = 'picture-o'
+ self.switcher_title = 'image'
+ end
+end
diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb
new file mode 100644
index 00000000000..f68cbb7e212
--- /dev/null
+++ b/app/models/blob_viewer/text.rb
@@ -0,0 +1,11 @@
+module BlobViewer
+ class Text < Base
+ include Simple
+ include ServerSide
+
+ self.partial_name = 'text'
+ self.binary = false
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 10.megabytes
+ end
+end
diff --git a/app/models/blob_viewer/text_stl.rb b/app/models/blob_viewer/text_stl.rb
new file mode 100644
index 00000000000..8184dc0104c
--- /dev/null
+++ b/app/models/blob_viewer/text_stl.rb
@@ -0,0 +1,5 @@
+module BlobViewer
+ class TextSTL < BinarySTL
+ self.binary = false
+ end
+end
diff --git a/app/models/blob_viewer/video.rb b/app/models/blob_viewer/video.rb
new file mode 100644
index 00000000000..057f9fe516f
--- /dev/null
+++ b/app/models/blob_viewer/video.rb
@@ -0,0 +1,12 @@
+module BlobViewer
+ class Video < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'video'
+ self.extensions = UploaderHelper::VIDEO_EXT
+ self.binary = true
+ self.switcher_icon = 'film'
+ self.switcher_title = 'video'
+ end
+end
diff --git a/app/models/blob_viewer/yarn_lock.rb b/app/models/blob_viewer/yarn_lock.rb
new file mode 100644
index 00000000000..31588ddcbab
--- /dev/null
+++ b/app/models/blob_viewer/yarn_lock.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class YarnLock < DependencyManager
+ include Static
+
+ self.file_types = %i(yarn_lock)
+
+ def manager_name
+ 'Yarn'
+ end
+
+ def manager_url
+ 'https://yarnpkg.com/'
+ end
+ end
+end
diff --git a/app/models/board.rb b/app/models/board.rb
index cf8317891b5..97d0f550925 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,10 +1,14 @@
class Board < ActiveRecord::Base
belongs_to :project
- has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
+ has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :project, presence: true
+ def backlog_list
+ lists.merge(List.backlog).take
+ end
+
def closed_list
lists.merge(List.closed).take
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index cb40f33932a..3692bcc680d 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -14,9 +14,15 @@ class BroadcastMessage < ActiveRecord::Base
default_value_for :color, '#E75E40'
default_value_for :font, '#FFFFFF'
+ CACHE_KEY = 'broadcast_message_current'.freeze
+
+ after_commit :flush_redis_cache
+
def self.current
- Rails.cache.fetch("broadcast_message_current", expires_in: 1.minute) do
- where("ends_at > :now AND starts_at <= :now", now: Time.zone.now).last
+ Rails.cache.fetch(CACHE_KEY) do
+ where('ends_at > :now AND starts_at <= :now', now: Time.zone.now)
+ .reorder(id: :asc)
+ .to_a
end
end
@@ -31,4 +37,8 @@ class BroadcastMessage < ActiveRecord::Base
def ended?
ends_at < Time.zone.now
end
+
+ def flush_redis_cache
+ Rails.cache.delete(CACHE_KEY)
+ end
end
diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb
index c52b6f15913..25ecf2d5937 100644
--- a/app/models/chat_team.rb
+++ b/app/models/chat_team.rb
@@ -3,4 +3,13 @@ class ChatTeam < ActiveRecord::Base
validates :namespace, uniqueness: true
belongs_to :namespace
+
+ def remove_mattermost_team(current_user)
+ Mattermost::Team.new(current_user).destroy(team_id: team_id)
+ rescue Mattermost::ClientError => e
+ # Either the group is not found, or the user doesn't have the proper
+ # access on the mattermost instance. In the first case, we're done either way
+ # in the latter case, we can't recover by retrying, so we just log what happened
+ Rails.logger.error("Mattermost team deletion failed: #{e}")
+ end
end
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
new file mode 100644
index 00000000000..b35febc9ac5
--- /dev/null
+++ b/app/models/ci/artifact_blob.rb
@@ -0,0 +1,35 @@
+module Ci
+ class ArtifactBlob
+ include BlobLike
+
+ attr_reader :entry
+
+ def initialize(entry)
+ @entry = entry
+ end
+
+ delegate :name, :path, to: :entry
+
+ def id
+ Digest::SHA1.hexdigest(path)
+ end
+
+ def size
+ entry.metadata[:size]
+ end
+
+ def data
+ "Build artifact #{path}"
+ end
+
+ def mode
+ entry.metadata[:mode]
+ end
+
+ def external_storage
+ :build_artifact
+ end
+
+ alias_method :external_size, :size
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index b426c27afbb..4692fb5644a 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -19,8 +19,8 @@ module Ci
)
end
- serialize :options
- serialize :yaml_variables, Gitlab::Serializer::Ci::Variables
+ serialize :options # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize
delegate :name, to: :project, prefix: true
@@ -33,7 +33,7 @@ module Ci
scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
- scope :manual_actions, ->() { where(when: :manual).relevant }
+ scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
mount_uploader :artifacts_file, ArtifactUploader
mount_uploader :artifacts_metadata, ArtifactUploader
@@ -47,10 +47,16 @@ module Ci
before_destroy { unscoped_project }
after_create :execute_hooks
- after_save :update_project_statistics, if: :artifacts_size_changed?
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics_after_save, on: [:create, :update]
+ after_commit :update_project_statistics, on: :destroy
class << self
+ # This is needed for url_for to work,
+ # as the controller is JobsController
+ def model_name
+ ActiveModel::Name.new(self, nil, 'job')
+ end
+
def first_pending
pending.unstarted.order('created_at ASC').first
end
@@ -90,6 +96,14 @@ module Ci
BuildSuccessWorker.perform_async(id)
end
end
+
+ before_transition any => [:failed] do |build|
+ next if build.retries_max.zero?
+
+ if build.retries_count < build.retries_max
+ Ci::Build.retry(build, build.user)
+ end
+ end
end
def detailed_status(current_user)
@@ -103,7 +117,7 @@ module Ci
end
def playable?
- action? && manual?
+ action? && (manual? || complete?)
end
def action?
@@ -111,14 +125,9 @@ module Ci
end
def play(current_user)
- # Try to queue a current build
- if self.enqueue
- self.update(user: current_user)
- self
- else
- # Otherwise we need to create a duplicate
- Ci::Build.retry(self, current_user)
- end
+ Ci::PlayBuildService
+ .new(project, current_user)
+ .execute(self)
end
def cancelable?
@@ -129,8 +138,16 @@ module Ci
success? || failed? || canceled?
end
- def retried?
- !self.pipeline.statuses.latest.include?(self)
+ def retries_count
+ pipeline.builds.retried.where(name: self.name).count
+ end
+
+ def retries_max
+ self.options.fetch(:retry, 0).to_i
+ end
+
+ def latest?
+ !retried?
end
def expanded_environment_name
@@ -175,13 +192,19 @@ module Ci
# * Lowercased
# * Anything not matching [a-z0-9-] is replaced with a -
# * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
def ref_slug
- slugified = ref.to_s.downcase
- slugified.gsub(/[^a-z0-9]/, '-')[0..62]
+ Gitlab::Utils.slugify(ref.to_s)
end
- # Variables whose value does not depend on other variables
+ # Variables whose value does not depend on environment
def simple_variables
+ variables(environment: nil)
+ end
+
+ # All variables, including those dependent on environment, which could
+ # contain unexpanded variables.
+ def variables(environment: persisted_environment)
variables = predefined_variables
variables += project.predefined_variables
variables += pipeline.predefined_variables
@@ -190,27 +213,30 @@ module Ci
variables += project.deployment_variables if has_environment?
variables += yaml_variables
variables += user_variables
- variables += project.secret_variables
+ variables += project.group.secret_variables_for(ref, project).map(&:to_runner_variable) if project.group
+ variables += secret_variables(environment: environment)
variables += trigger_request.user_variables if trigger_request
- variables
- end
+ variables += pipeline.variables.map(&:to_runner_variable)
+ variables += pipeline.pipeline_schedule.job_variables if pipeline.pipeline_schedule
+ variables += persisted_environment_variables if environment
- # All variables, including those dependent on other variables
- def variables
- variables = simple_variables
- variables += persisted_environment.predefined_variables if persisted_environment.present?
variables
end
def merge_request
- merge_requests = MergeRequest.includes(:merge_request_diff)
- .where(source_branch: ref,
- source_project: pipeline.project)
- .reorder(iid: :asc)
-
- merge_requests.find do |merge_request|
- merge_request.commits_sha.include?(pipeline.sha)
- end
+ return @merge_request if defined?(@merge_request)
+
+ @merge_request ||=
+ begin
+ merge_requests = MergeRequest.includes(:merge_request_diff)
+ .where(source_branch: ref,
+ source_project: pipeline.project)
+ .reorder(iid: :desc)
+
+ merge_requests.find do |merge_request|
+ merge_request.commit_shas.include?(pipeline.sha)
+ end
+ end
end
def repo_url
@@ -254,38 +280,6 @@ module Ci
Time.now - updated_at > 15.minutes.to_i
end
- ##
- # Deprecated
- #
- # This contains a hotfix for CI build data integrity, see #4246
- #
- # This method is used by `ArtifactUploader` to create a store_dir.
- # Warning: Uploader uses it after AND before file has been stored.
- #
- # This method returns old path to artifacts only if it already exists.
- #
- def artifacts_path
- # We need the project even if it's soft deleted, because whenever
- # we're really deleting the project, we'll also delete the builds,
- # and in order to delete the builds, we need to know where to find
- # the artifacts, which is depending on the data of the project.
- # We need to retain the project in this case.
- the_project = project || unscoped_project
-
- old = File.join(created_at.utc.strftime('%Y_%m'),
- the_project.ci_id.to_s,
- id.to_s)
-
- old_store = File.join(ArtifactUploader.artifacts_path, old)
- return old if the_project.ci_id && File.directory?(old_store)
-
- File.join(
- created_at.utc.strftime('%Y_%m'),
- the_project.id.to_s,
- id.to_s
- )
- end
-
def valid_token?(token)
self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
end
@@ -305,8 +299,8 @@ module Ci
def execute_hooks
return unless project
build_data = Gitlab::DataBuilder::Build.build(self)
- project.execute_hooks(build_data.dup, :build_hooks)
- project.execute_services(build_data.dup, :build_hooks)
+ project.execute_hooks(build_data.dup, :job_hooks)
+ project.execute_services(build_data.dup, :job_hooks)
PagesService.new(build_data).execute
project.running_or_pending_build_count(force: true)
end
@@ -366,7 +360,7 @@ module Ci
end
def has_expiring_artifacts?
- artifacts_expire_at.present?
+ artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
def keep_artifacts!
@@ -394,6 +388,11 @@ module Ci
]
end
+ def secret_variables(environment: persisted_environment)
+ project.secret_variables_for(ref: ref, environment: environment)
+ .map(&:to_runner_variable)
+ end
+
def steps
[Gitlab::Ci::Build::Step.from_commands(self),
Gitlab::Ci::Build::Step.from_after_script(self)].compact
@@ -493,6 +492,19 @@ module Ci
variables.concat(legacy_variables)
end
+ def persisted_environment_variables
+ return [] unless persisted_environment
+
+ variables = persisted_environment.predefined_variables
+
+ # Here we're passing unexpanded environment_url for runner to expand,
+ # and we need to make sure that CI_ENVIRONMENT_NAME and
+ # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
+ variables << { key: 'CI_ENVIRONMENT_URL', value: environment_url, public: true } if environment_url
+
+ variables
+ end
+
def legacy_variables
variables = [
{ key: 'CI_BUILD_ID', value: id.to_s, public: true },
@@ -511,6 +523,10 @@ module Ci
variables
end
+ def environment_url
+ options&.dig(:environment, :url) || persisted_environment&.external_url
+ end
+
def build_attributes_from_config
return {} unless pipeline.config_processor
@@ -522,5 +538,11 @@ module Ci
ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size])
end
+
+ def update_project_statistics_after_save
+ if previous_changes.include?('artifacts_size')
+ update_project_statistics
+ end
+ end
end
end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
new file mode 100644
index 00000000000..87898b086c6
--- /dev/null
+++ b/app/models/ci/group.rb
@@ -0,0 +1,40 @@
+module Ci
+ ##
+ # This domain model is a representation of a group of jobs that are related
+ # to each other, like `rspec 0 1`, `rspec 0 2`.
+ #
+ # It is not persisted in the database.
+ #
+ class Group
+ include StaticModel
+
+ attr_reader :stage, :name, :jobs
+
+ delegate :size, to: :jobs
+
+ def initialize(stage, name:, jobs:)
+ @stage = stage
+ @name = name
+ @jobs = jobs
+ end
+
+ def status
+ @status ||= commit_statuses.status
+ end
+
+ def detailed_status(current_user)
+ if jobs.one?
+ jobs.first.detailed_status(current_user)
+ else
+ Gitlab::Ci::Status::Group::Factory
+ .new(self, current_user).fabricate!
+ end
+ end
+
+ private
+
+ def commit_statuses
+ @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id))
+ end
+ end
+end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
new file mode 100644
index 00000000000..f64bc245a67
--- /dev/null
+++ b/app/models/ci/group_variable.rb
@@ -0,0 +1,13 @@
+module Ci
+ class GroupVariable < ActiveRecord::Base
+ extend Ci::Model
+ include HasVariable
+ include Presentable
+
+ belongs_to :group
+
+ validates :key, uniqueness: { scope: :group_id }
+
+ scope :unprotected, -> { where(protected: false) }
+ end
+end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
new file mode 100644
index 00000000000..9b536af672b
--- /dev/null
+++ b/app/models/ci/legacy_stage.rb
@@ -0,0 +1,64 @@
+module Ci
+ # Currently this is artificial object, constructed dynamically
+ # We should migrate this object to actual database record in the future
+ class LegacyStage
+ include StaticModel
+
+ attr_reader :pipeline, :name
+
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline, name:, status: nil, warnings: nil)
+ @pipeline = pipeline
+ @name = name
+ @status = status
+ @warnings = warnings
+ end
+
+ def groups
+ @groups ||= statuses.ordered.latest
+ .sort_by(&:sortable_name).group_by(&:group_name)
+ .map do |group_name, grouped_statuses|
+ Ci::Group.new(self, name: group_name, jobs: grouped_statuses)
+ end
+ end
+
+ def to_param
+ name
+ end
+
+ def statuses_count
+ @statuses_count ||= statuses.count
+ end
+
+ def status
+ @status ||= statuses.latest.status
+ end
+
+ def detailed_status(current_user)
+ Gitlab::Ci::Status::Stage::Factory
+ .new(self, current_user)
+ .fabricate!
+ end
+
+ def statuses
+ @statuses ||= pipeline.statuses.where(stage: name)
+ end
+
+ def builds
+ @builds ||= pipeline.builds.where(stage: name)
+ end
+
+ def success?
+ status.to_s == 'success'
+ end
+
+ def has_warnings?
+ if @warnings.is_a?(Integer)
+ @warnings > 0
+ else
+ statuses.latest.failed_but_allowed.any?
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 4be4aa9ffe2..ea7331cb27f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -9,22 +9,30 @@ module Ci
belongs_to :project
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+ belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
- has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
- has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
-
+ has_many :stages
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :builds, foreign_key: :commit_id
- has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id
+ has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :variables, class_name: 'Ci::PipelineVariable'
+
+ # Merge requests for which the current pipeline is running against
+ # the merge request's latest commit.
+ has_many :merge_requests, foreign_key: "head_pipeline_id"
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
- has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
- has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build'
- has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
delegate :id, to: :project, prefix: true
+ validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
validates :status, presence: { unless: :importing? }
@@ -32,6 +40,16 @@ module Ci
after_create :keep_around_commits, unless: :importing?
+ enum source: {
+ unknown: nil,
+ push: 1,
+ web: 2,
+ trigger: 3,
+ schedule: 4,
+ api: 5,
+ external: 6
+ }
+
state_machine :status, initial: :created do
event :enqueue do
transition created: :pending
@@ -123,6 +141,7 @@ module Ci
where(id: max_id)
end
end
+ scope :internal, -> { where(source: internal_sources) }
def self.latest_status(ref = nil)
latest(ref).status
@@ -146,21 +165,25 @@ module Ci
where.not(duration: nil).sum(:duration)
end
- def stage(name)
- stage = Ci::Stage.new(self, name: name)
- stage unless stage.statuses_count.zero?
+ def self.internal_sources
+ sources.reject { |source| source == "external" }.values
end
def stages_count
statuses.select(:stage).distinct.count
end
- def stages_name
- statuses.order(:stage_idx).distinct.
- pluck(:stage, :stage_idx).map(&:first)
+ def stages_names
+ statuses.order(:stage_idx).distinct
+ .pluck(:stage, :stage_idx).map(&:first)
+ end
+
+ def legacy_stage(name)
+ stage = Ci::LegacyStage.new(self, name: name)
+ stage unless stage.statuses_count.zero?
end
- def stages
+ def legacy_stages
# TODO, this needs refactoring, see gitlab-ce#26481.
stages_query = statuses
@@ -175,7 +198,7 @@ module Ci
.pluck('sg.stage', status_sql, "(#{warnings_sql})")
stages_with_statuses.map do |stage|
- Ci::Stage.new(self, Hash[%i[name status warnings].zip(stage)])
+ Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)])
end
end
@@ -264,10 +287,6 @@ module Ci
commit.sha == sha
end
- def triggered?
- trigger_requests.any?
- end
-
def retried
@retried ||= (statuses.order(id: :desc) - statuses.latest)
end
@@ -279,12 +298,14 @@ module Ci
end
end
- def config_builds_attributes
+ def stage_seeds
return [] unless config_processor
- config_processor.
- builds_for_ref(ref, tag?, trigger_requests.first).
- sort_by { |build| build[:stage_idx] }
+ @stage_seeds ||= config_processor.stage_seeds(self)
+ end
+
+ def has_stage_seeds?
+ stage_seeds.any?
end
def has_warnings?
@@ -292,11 +313,11 @@ module Ci
end
def config_processor
- return nil unless ci_yaml_file
+ return unless ci_yaml_file
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
- Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace)
+ Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.full_path)
rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e
self.yaml_errors = e.message
nil
@@ -306,10 +327,24 @@ module Ci
end
end
+ def ci_yaml_file_path
+ if project.ci_config_path.blank?
+ '.gitlab-ci.yml'
+ else
+ project.ci_config_path
+ end
+ end
+
def ci_yaml_file
return @ci_yaml_file if defined?(@ci_yaml_file)
- @ci_yaml_file = project.repository.gitlab_ci_yml_for(sha) rescue nil
+ @ci_yaml_file = begin
+ project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
+ rescue Rugged::ReferenceError, GRPC::NotFound, GRPC::Internal
+ self.yaml_errors =
+ "Failed to load CI/CD config file at #{ci_yaml_file_path}"
+ nil
+ end
end
def has_yaml_errors?
@@ -357,7 +392,8 @@ module Ci
def predefined_variables
[
- { key: 'CI_PIPELINE_ID', value: id.to_s, public: true }
+ { key: 'CI_PIPELINE_ID', value: id.to_s, public: true },
+ { key: 'CI_CONFIG_PATH', value: ci_yaml_file_path, public: true }
]
end
@@ -380,14 +416,6 @@ module Ci
project.execute_services(data, :pipeline_hooks)
end
- # Merge requests for which the current pipeline is running against
- # the merge request's latest commit.
- def merge_requests
- @merge_requests ||= project.merge_requests
- .where(source_branch: self.ref)
- .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id }
- end
-
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||= project.merge_requests.where(source_branch: ref)
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
new file mode 100644
index 00000000000..085eeeae157
--- /dev/null
+++ b/app/models/ci/pipeline_schedule.rb
@@ -0,0 +1,64 @@
+module Ci
+ class PipelineSchedule < ActiveRecord::Base
+ extend Ci::Model
+ include Importable
+
+ acts_as_paranoid
+
+ belongs_to :project
+ belongs_to :owner, class_name: 'User'
+ has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
+ has_many :pipelines
+ has_many :variables, class_name: 'Ci::PipelineScheduleVariable'
+
+ validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? }
+ validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
+ validates :ref, presence: { unless: :importing? }
+ validates :description, presence: true
+ validates :variables, variable_duplicates: true
+
+ before_save :set_next_run_at
+
+ scope :active, -> { where(active: true) }
+ scope :inactive, -> { where(active: false) }
+
+ accepts_nested_attributes_for :variables, allow_destroy: true
+
+ def owned_by?(current_user)
+ owner == current_user
+ end
+
+ def own!(user)
+ update(owner: user)
+ end
+
+ def inactive?
+ !active?
+ end
+
+ def deactivate!
+ update_attribute(:active, false)
+ end
+
+ def set_next_run_at
+ self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
+ end
+
+ def schedule_next_run!
+ save! # with set_next_run_at
+ rescue ActiveRecord::RecordInvalid
+ update_attribute(:next_run_at, nil) # update without validation
+ end
+
+ def real_next_run(
+ worker_cron: Settings.cron_jobs['pipeline_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
+
+ def job_variables
+ variables&.map(&:to_runner_variable) || []
+ end
+ end
+end
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
new file mode 100644
index 00000000000..1ff177616e8
--- /dev/null
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -0,0 +1,8 @@
+module Ci
+ class PipelineScheduleVariable < ActiveRecord::Base
+ extend Ci::Model
+ include HasVariable
+
+ belongs_to :pipeline_schedule
+ end
+end
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
new file mode 100644
index 00000000000..00b419c3efa
--- /dev/null
+++ b/app/models/ci/pipeline_variable.rb
@@ -0,0 +1,10 @@
+module Ci
+ class PipelineVariable < ActiveRecord::Base
+ extend Ci::Model
+ include HasVariable
+
+ belongs_to :pipeline
+
+ validates :key, uniqueness: { scope: :pipeline_id }
+ end
+end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 487ba61bc9c..c6d23898560 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -3,12 +3,12 @@ module Ci
extend Ci::Model
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
- LAST_CONTACT_TIME = 1.hour.ago
+ ONLINE_CONTACT_TIMEOUT = 1.hour
AVAILABLE_SCOPES = %w[specific shared active paused online].freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze
has_many :builds
- has_many :runner_projects, dependent: :destroy
+ has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
@@ -19,7 +19,7 @@ module Ci
scope :shared, ->() { where(is_shared: true) }
scope :active, ->() { where(active: true) }
scope :paused, ->() { where(active: false) }
- scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) }
+ scope :online, ->() { where('contacted_at > ?', contact_time_deadline) }
scope :ordered, ->() { order(id: :desc) }
scope :owned_or_shared, ->(project_id) do
@@ -30,8 +30,8 @@ module Ci
scope :assignable_for, ->(project) do
# FIXME: That `to_sql` is needed to workaround a weird Rails bug.
# Without that, placeholders would miss one and couldn't match.
- where(locked: false).
- where.not("id IN (#{project.runners.select(:id).to_sql})").specific
+ where(locked: false)
+ .where.not("id IN (#{project.runners.select(:id).to_sql})").specific
end
validate :tag_constraints
@@ -59,6 +59,10 @@ module Ci
where(t[:token].matches(pattern).or(t[:description].matches(pattern)))
end
+ def self.contact_time_deadline
+ ONLINE_CONTACT_TIMEOUT.ago
+ end
+
def set_default_values
self.token = SecureRandom.hex(15) if self.token.blank?
end
@@ -80,7 +84,7 @@ module Ci
end
def online?
- contacted_at && contacted_at > LAST_CONTACT_TIME
+ contacted_at && contacted_at > self.class.contact_time_deadline
end
def status
@@ -145,7 +149,7 @@ module Ci
private
def cleanup_runner_queue
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Queues.with do |redis|
redis.del(runner_queue_key)
end
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index e7d6b17d445..59570924c8d 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,56 +1,11 @@
module Ci
- # Currently this is artificial object, constructed dynamically
- # We should migrate this object to actual database record in the future
- class Stage
- include StaticModel
+ class Stage < ActiveRecord::Base
+ extend Ci::Model
- attr_reader :pipeline, :name
+ belongs_to :project
+ belongs_to :pipeline
- delegate :project, to: :pipeline
-
- def initialize(pipeline, name:, status: nil, warnings: nil)
- @pipeline = pipeline
- @name = name
- @status = status
- @warnings = warnings
- end
-
- def to_param
- name
- end
-
- def statuses_count
- @statuses_count ||= statuses.count
- end
-
- def status
- @status ||= statuses.latest.status
- end
-
- def detailed_status(current_user)
- Gitlab::Ci::Status::Stage::Factory
- .new(self, current_user)
- .fabricate!
- end
-
- def statuses
- @statuses ||= pipeline.statuses.where(stage: name)
- end
-
- def builds
- @builds ||= pipeline.builds.where(stage: name)
- end
-
- def success?
- status.to_s == 'success'
- end
-
- def has_warnings?
- if @warnings.is_a?(Integer)
- @warnings > 0
- else
- statuses.latest.failed_but_allowed.any?
- end
- end
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
+ has_many :builds, foreign_key: :commit_id
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 2f64f70685a..6df41a3f301 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -8,14 +8,11 @@ module Ci
belongs_to :owner, class_name: "User"
has_many :trigger_requests
- has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true
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
@@ -39,9 +36,5 @@ 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_request.rb b/app/models/ci/trigger_request.rb
index 2b807731d0d..c58ce5c3717 100644
--- a/app/models/ci/trigger_request.rb
+++ b/app/models/ci/trigger_request.rb
@@ -6,7 +6,7 @@ module Ci
belongs_to :pipeline, foreign_key: :commit_id
has_many :builds
- serialize :variables
+ serialize :variables # rubocop:disable Cop/ActiveRecordSerialize
def user_variables
return [] unless variables
diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb
deleted file mode 100644
index 012a18eb439..00000000000
--- a/app/models/ci/trigger_schedule.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-module Ci
- class TriggerSchedule < ActiveRecord::Base
- extend Ci::Model
- include Importable
-
- acts_as_paranoid
-
- belongs_to :project
- belongs_to :trigger
-
- validates :trigger, 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
-
- def schedule_next_run!
- save! # with set_next_run_at
- 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/ci/variable.rb b/app/models/ci/variable.rb
index 6c6586110c5..cf0fe04ddaf 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -1,22 +1,13 @@
module Ci
class Variable < ActiveRecord::Base
extend Ci::Model
+ include HasVariable
+ include Presentable
belongs_to :project
- validates :key,
- presence: true,
- uniqueness: { scope: :project_id },
- length: { maximum: 255 },
- format: { with: /\A[a-zA-Z0-9_]+\z/,
- message: "can contain only letters, digits and '_'." }
+ validates :key, uniqueness: { scope: [:project_id, :environment_scope] }
- scope :order_key_asc, -> { reorder(key: :asc) }
-
- attr_encrypted :value,
- mode: :per_attribute_iv_and_salt,
- insecure_mode: true,
- key: Gitlab::Application.secrets.db_key_base,
- algorithm: 'aes-256-cbc'
+ scope :unprotected, -> { where(protected: false) }
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 8b8b3f00202..d41c88b4e30 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -1,5 +1,6 @@
class Commit
extend ActiveModel::Naming
+ extend Gitlab::Cache::RequestCache
include ActiveModel::Conversion
include Noteable
@@ -14,7 +15,7 @@ class Commit
participant :committer
participant :notes_with_associations
- attr_accessor :project
+ attr_accessor :project, :author
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -49,12 +50,13 @@ class Commit
def max_diff_options
{
max_files: DIFF_HARD_LIMIT_FILES,
- max_lines: DIFF_HARD_LIMIT_LINES,
+ max_lines: DIFF_HARD_LIMIT_LINES
}
end
def from_hash(hash, project)
- new(Gitlab::Git::Commit.new(hash), project)
+ raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash)
+ new(raw_commit, project)
end
def valid_hash?(key)
@@ -114,16 +116,16 @@ class Commit
#
# Usually, the commit title is the first line of the commit message.
# In case this first line is longer than 100 characters, it is cut off
- # after 80 characters and ellipses (`&hellp;`) are appended.
+ # after 80 characters + `...`
def title
- full_title.length > 100 ? full_title[0..79] << "…" : full_title
+ return full_title if full_title.length < 100
+
+ full_title.truncate(81, separator: ' ', omission: '…')
end
# Returns the full commits title
def full_title
- return @full_title if @full_title
-
- @full_title =
+ @full_title ||=
if safe_message.blank?
no_commit_message
else
@@ -131,17 +133,12 @@ class Commit
end
end
- # Returns the commits description
- #
- # cut off, ellipses (`&hellp;`) are prepended to the commit message.
+ # Returns full commit message if title is truncated (greater than 99 characters)
+ # otherwise returns commit message without first line
def description
- title_end = safe_message.index("\n")
- @description ||=
- if (!title_end && safe_message.length > 100) || (title_end && title_end > 100)
- "…" << safe_message[80..-1]
- else
- safe_message.split("\n", 2)[1].try(:chomp)
- end
+ return safe_message if full_title.length >= 100
+
+ safe_message.split("\n", 2)[1].try(:chomp)
end
def description?
@@ -174,19 +171,9 @@ class Commit
end
def author
- if RequestStore.active?
- key = "commit_author:#{author_email.downcase}"
- # nil is a valid value since no author may exist in the system
- if RequestStore.store.has_key?(key)
- @author = RequestStore.store[key]
- else
- @author = find_author_by_any_email
- RequestStore.store[key] = @author
- end
- else
- @author ||= find_author_by_any_email
- end
+ User.find_by_any_email(author_email.downcase)
end
+ request_cache(:author) { author_email.downcase }
def committer
@committer ||= User.find_by_any_email(committer_email.downcase)
@@ -213,7 +200,7 @@ class Commit
end
def method_missing(m, *args, &block)
- @raw.send(m, *args, &block)
+ @raw.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def respond_to_missing?(method, include_private = false)
@@ -236,8 +223,8 @@ class Commit
project.pipelines.where(sha: sha)
end
- def latest_pipeline
- pipelines.last
+ def last_pipeline
+ @last_pipeline ||= pipelines.last
end
def status(ref = nil)
@@ -248,6 +235,14 @@ class Commit
@statuses[ref] = pipelines.latest_status(ref)
end
+ def signature
+ return @signature if defined?(@signature)
+
+ @signature = gpg_commit.signature
+ end
+
+ delegate :has_signature?, to: :gpg_commit
+
def revert_branch_name
"revert-#{short_id}"
end
@@ -316,7 +311,7 @@ class Commit
def uri_type(path)
entry = @raw.tree.path(path)
if entry[:type] == :blob
- blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]))
+ blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
blob.image? || blob.video? ? :raw : :blob
else
entry[:type]
@@ -326,13 +321,11 @@ class Commit
end
def raw_diffs(*args)
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitaly/issues/178 is resolved
- # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
- # Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
- # else
raw.diffs(*args)
- # end
+ end
+
+ def raw_deltas
+ @deltas ||= raw.deltas
end
def diffs(diff_options = nil)
@@ -365,14 +358,10 @@ class Commit
end
end
- def find_author_by_any_email
- User.find_by_any_email(author_email.downcase)
- end
-
def repo_changes
changes = { added: [], modified: [], removed: [] }
- raw_diffs(deltas_only: true).each do |diff|
+ raw_deltas.each do |diff|
if diff.deleted_file
changes[:removed] << diff.old_path
elsif diff.renamed_file || diff.new_file
@@ -392,4 +381,8 @@ class Commit
def merged_merge_request_no_cache(user)
MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit?
end
+
+ def gpg_commit
+ @gpg_commit ||= Gitlab::Gpg::Commit.for_commit(self)
+ end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2c4033146bf..07cec63b939 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -5,26 +5,20 @@ class CommitStatus < ActiveRecord::Base
self.table_name = 'ci_builds'
+ belongs_to :user
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
- belongs_to :user
delegate :commit, to: :pipeline
delegate :sha, :short_sha, to: :pipeline
validates :pipeline, presence: true, unless: :importing?
- validates :name, presence: true
+ validates :name, presence: true, unless: :importing?
alias_attribute :author, :user
- scope :latest, -> do
- max_id = unscope(:select).select("max(#{quoted_table_name}.id)")
-
- where(id: max_id.group(:name, :commit_id))
- end
-
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
end
@@ -37,7 +31,8 @@ class CommitStatus < ActiveRecord::Base
false, all_state_names - [:failed, :canceled, :manual])
end
- scope :retried, -> { where.not(id: latest) }
+ scope :latest, -> { where(retried: [false, nil]) }
+ scope :retried, -> { where(retried: true) }
scope :ordered, -> { order(:name) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
@@ -88,13 +83,15 @@ class CommitStatus < ActiveRecord::Base
next if transition.loopback?
commit_status.run_after_commit do
- pipeline.try do |pipeline|
+ if pipeline
if complete? || manual?
PipelineProcessWorker.perform_async(pipeline.id)
else
PipelineUpdateWorker.perform_async(pipeline.id)
end
end
+
+ ExpireJobCacheWorker.perform_async(commit_status.id)
end
end
@@ -115,7 +112,7 @@ class CommitStatus < ActiveRecord::Base
end
def group_name
- name.gsub(/\d+[\s:\/\\]+\d+\s*/, '').strip
+ name.to_s.gsub(/\d+[\s:\/\\]+\d+\s*/, '').strip
end
def failed_but_allowed?
@@ -130,6 +127,16 @@ class CommitStatus < ActiveRecord::Base
false
end
+ # To be overriden when inherrited from
+ def retryable?
+ false
+ end
+
+ # To be overriden when inherrited from
+ def cancelable?
+ false
+ end
+
def stuck?
false
end
@@ -142,12 +149,6 @@ class CommitStatus < ActiveRecord::Base
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
- 'dummy'
- end
-
def detailed_status(current_user)
Gitlab::Ci::Status::Factory
.new(self, current_user)
@@ -155,7 +156,7 @@ class CommitStatus < ActiveRecord::Base
end
def sortable_name
- name.split(/(\d+)/).map do |v|
+ name.to_s.split(/(\d+)/).map do |v|
v =~ /\d+/ ? v.to_i : v
end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
new file mode 100644
index 00000000000..8fbfed11bdf
--- /dev/null
+++ b/app/models/concerns/avatarable.rb
@@ -0,0 +1,18 @@
+module Avatarable
+ extend ActiveSupport::Concern
+
+ def avatar_path(only_path: true)
+ return unless self[:avatar].present?
+
+ # If only_path is true then use the relative path of avatar.
+ # Otherwise use full path (including host).
+ asset_host = ActionController::Base.asset_host
+ gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url
+
+ # If asset_host is set then it is expected that assets are handled by a standalone host.
+ # That means we do not want to get GitLab's relative_url_root option anymore.
+ host = asset_host.present? ? asset_host : gitlab_host
+
+ [host, avatar.url].join
+ end
+end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index a7fd0a15f0f..f4f9b037957 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -2,7 +2,7 @@ module Awardable
extend ActiveSupport::Concern
included do
- has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy
+ has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
if self < Participable
# By default we always load award_emoji user association
diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb
new file mode 100644
index 00000000000..adb81561000
--- /dev/null
+++ b/app/models/concerns/blob_like.rb
@@ -0,0 +1,48 @@
+module BlobLike
+ extend ActiveSupport::Concern
+ include Linguist::BlobHelper
+
+ def id
+ raise NotImplementedError
+ end
+
+ def name
+ raise NotImplementedError
+ end
+
+ def path
+ raise NotImplementedError
+ end
+
+ def size
+ 0
+ end
+
+ def data
+ nil
+ end
+
+ def mode
+ nil
+ end
+
+ def binary?
+ false
+ end
+
+ def load_all_data!(repository)
+ # No-op
+ end
+
+ def truncated?
+ false
+ end
+
+ def external_storage
+ nil
+ end
+
+ def external_size
+ nil
+ end
+end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index f033028c4e5..193e459977a 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -11,7 +11,7 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_VERSION = 1
+ CACHE_VERSION = 2
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
@@ -78,6 +78,9 @@ module CacheMarkdownField
def cached_html_up_to_date?(markdown_field)
html_field = cached_markdown_fields.html_field(markdown_field)
+ cached = cached_html_for(markdown_field).present? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend
+ return false unless cached
+
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
@@ -90,14 +93,14 @@ module CacheMarkdownField
end
def attribute_invalidated?(attr)
- __send__("#{attr}_invalidated?")
+ __send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
end
def cached_html_for(markdown_field)
raise ArgumentError.new("Unknown field: #{field}") unless
cached_markdown_fields.markdown_fields.include?(markdown_field)
- __send__(cached_markdown_fields.html_field(markdown_field))
+ __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end
included do
diff --git a/app/models/concerns/created_at_filterable.rb b/app/models/concerns/created_at_filterable.rb
new file mode 100644
index 00000000000..e8a3e41203d
--- /dev/null
+++ b/app/models/concerns/created_at_filterable.rb
@@ -0,0 +1,12 @@
+module CreatedAtFilterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :created_before, ->(date) { where(scoped_table[:created_at].lteq(date)) }
+ scope :created_after, ->(date) { where(scoped_table[:created_at].gteq(date)) }
+
+ def self.scoped_table
+ arel_table.alias(table_name)
+ end
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index 8ee42875670..eee1a36ac6b 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -11,6 +11,7 @@ module DiscussionOnDiff
:diff_line,
:for_line?,
:active?,
+ :created_at_diff?,
to: :first_note
@@ -46,4 +47,12 @@ module DiscussionOnDiff
prev_lines
end
+
+ def line_code_in_diffs(diff_refs)
+ if active?(diff_refs)
+ line_code
+ elsif diff_refs && created_at_diff?(diff_refs)
+ original_line_code
+ end
+ end
end
diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb
new file mode 100644
index 00000000000..6ddbb8da1a9
--- /dev/null
+++ b/app/models/concerns/each_batch.rb
@@ -0,0 +1,81 @@
+module EachBatch
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Iterates over the rows in a relation in batches, similar to Rails'
+ # `in_batches` but in a more efficient way.
+ #
+ # Unlike `in_batches` provided by Rails this method does not support a
+ # custom start/end range, nor does it provide support for the `load:`
+ # keyword argument.
+ #
+ # This method will yield an ActiveRecord::Relation to the supplied block, or
+ # return an Enumerator if no block is given.
+ #
+ # Example:
+ #
+ # User.each_batch do |relation|
+ # relation.update_all(updated_at: Time.now)
+ # end
+ #
+ # The supplied block is also passed an optional batch index:
+ #
+ # User.each_batch do |relation, index|
+ # puts index # => 1, 2, 3, ...
+ # end
+ #
+ # You can also specify an alternative column to use for ordering the rows:
+ #
+ # User.each_batch(column: :created_at) do |relation|
+ # ...
+ # end
+ #
+ # This will produce SQL queries along the lines of:
+ #
+ # User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
+ # (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)
+ #
+ # of - The number of rows to retrieve per batch.
+ # column - The column to use for ordering the batches.
+ def each_batch(of: 1000, column: primary_key)
+ unless column
+ raise ArgumentError,
+ 'the column: argument must be set to a column name to use for ordering rows'
+ end
+
+ start = except(:select)
+ .select(column)
+ .reorder(column => :asc)
+ .take
+
+ return unless start
+
+ start_id = start[column]
+ arel_table = self.arel_table
+
+ 1.step do |index|
+ stop = except(:select)
+ .select(column)
+ .where(arel_table[column].gteq(start_id))
+ .reorder(column => :asc)
+ .offset(of)
+ .limit(1)
+ .take
+
+ relation = where(arel_table[column].gteq(start_id))
+
+ if stop
+ stop_id = stop[column]
+ start_id = stop_id
+ relation = relation.where(arel_table[column].lt(stop_id))
+ end
+
+ # Any ORDER BYs are useless for this relation and can lead to less
+ # efficient UPDATE queries, hence we get rid of it.
+ yield relation.except(:order), index
+
+ break unless stop
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb
new file mode 100644
index 00000000000..28623d257a6
--- /dev/null
+++ b/app/models/concerns/editable.rb
@@ -0,0 +1,11 @@
+module Editable
+ extend ActiveSupport::Concern
+
+ def is_edited?
+ last_edited_at.present? && last_edited_at != created_at
+ end
+
+ def last_edited_by
+ super || User.ghost
+ end
+end
diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb
new file mode 100644
index 00000000000..5db64fe82c4
--- /dev/null
+++ b/app/models/concerns/feature_gate.rb
@@ -0,0 +1,7 @@
+module FeatureGate
+ def flipper_id
+ return nil if new_record?
+
+ "#{self.class.name}:#{id}"
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index dff7b6e3523..32af5566135 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -11,18 +11,21 @@ module HasStatus
class_methods do
def status_sql
- scope = respond_to?(:exclude_ignored) ? exclude_ignored : all
-
- builds = scope.select('count(*)').to_sql
- created = scope.created.select('count(*)').to_sql
- success = scope.success.select('count(*)').to_sql
- manual = scope.manual.select('count(*)').to_sql
- pending = scope.pending.select('count(*)').to_sql
- running = scope.running.select('count(*)').to_sql
- skipped = scope.skipped.select('count(*)').to_sql
- canceled = scope.canceled.select('count(*)').to_sql
+ scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all
+ scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none
+
+ builds = scope_relevant.select('count(*)').to_sql
+ created = scope_relevant.created.select('count(*)').to_sql
+ success = scope_relevant.success.select('count(*)').to_sql
+ manual = scope_relevant.manual.select('count(*)').to_sql
+ pending = scope_relevant.pending.select('count(*)').to_sql
+ running = scope_relevant.running.select('count(*)').to_sql
+ skipped = scope_relevant.skipped.select('count(*)').to_sql
+ canceled = scope_relevant.canceled.select('count(*)').to_sql
+ warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false'
"(CASE
+ WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success'
WHEN (#{builds})=(#{skipped}) THEN 'skipped'
WHEN (#{builds})=(#{success}) THEN 'success'
WHEN (#{builds})=(#{created}) THEN 'created'
@@ -82,7 +85,7 @@ module HasStatus
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
scope :cancelable, -> do
- where(status: [:running, :pending, :created, :manual])
+ where(status: [:running, :pending, :created])
end
end
diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb
new file mode 100644
index 00000000000..9585b5583dc
--- /dev/null
+++ b/app/models/concerns/has_variable.rb
@@ -0,0 +1,23 @@
+module HasVariable
+ extend ActiveSupport::Concern
+
+ included do
+ validates :key,
+ presence: true,
+ length: { maximum: 255 },
+ format: { with: /\A[a-zA-Z0-9_]+\z/,
+ message: "can contain only letters, digits and '_'." }
+
+ scope :order_key_asc, -> { reorder(key: :asc) }
+
+ attr_encrypted :value,
+ mode: :per_attribute_iv_and_salt,
+ insecure_mode: true,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ def to_runner_variable
+ { key: key, value: value, public: false }
+ end
+ end
+end
diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb
index 5382dde6765..a3d0ac8d862 100644
--- a/app/models/concerns/internal_id.rb
+++ b/app/models/concerns/internal_id.rb
@@ -8,7 +8,8 @@ module InternalId
def set_iid
if iid.blank?
- records = project.send(self.class.name.tableize)
+ parent = project || group
+ records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
records = records.with_deleted if self.paranoid?
max_iid = records.maximum(:iid)
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 26dbf4d9570..3731b7c8577 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -15,6 +15,8 @@ module Issuable
include Taskable
include TimeTrackable
include Importable
+ include Editable
+ include AfterCommitQueue
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
@@ -26,10 +28,11 @@ module Issuable
cache_markdown_field :description, issuable_state_filter_enabled: true
belongs_to :author, class_name: "User"
- belongs_to :assignee, class_name: "User"
belongs_to :updated_by, class_name: "User"
+ belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
- has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
+
+ has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded?
# We check first if we're loaded to not load unnecessarily.
loaded? && to_a.all? { |note| note.association(:author).loaded? }
@@ -41,9 +44,9 @@ module Issuable
end
end
- has_many :label_links, as: :target, dependent: :destroy
+ has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, through: :label_links
- has_many :todos, as: :target, dependent: :destroy
+ has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :metrics
@@ -65,17 +68,12 @@ module Issuable
validates :title, presence: true, length: { maximum: 255 }
scope :authored, ->(user) { where(author_id: user) }
- scope :assigned_to, ->(u) { where(assignee_id: u.id)}
scope :recent, -> { reorder(id: :desc) }
- scope :order_position_asc, -> { reorder(position: :asc) }
- scope :assigned, -> { where("assignee_id IS NOT NULL") }
- scope :unassigned, -> { where("assignee_id IS NULL") }
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
- scope :opened, -> { with_state(:opened, :reopened) }
+ scope :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) }
- scope :only_reopened, -> { with_state(:reopened) }
scope :closed, -> { with_state(:closed) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
@@ -92,28 +90,27 @@ module Issuable
attr_mentionable :description
participant :author
- participant :assignee
participant :notes_with_associations
strip_attributes :title
acts_as_paranoid
- after_save :update_assignee_cache_counts, if: :assignee_id_changed?
after_save :record_metrics, unless: :imported?
- def update_assignee_cache_counts
- # make sure we flush the cache for both the old *and* new assignees(if they exist)
- previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
- previous_assignee&.update_cache_counts
- assignee&.update_cache_counts
- end
-
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
title_changed? || description_changed?
end
+
+ def allows_multiple_assignees?
+ false
+ end
+
+ def has_multiple_assignees?
+ assignees.count > 1
+ end
end
module ClassMethods
@@ -150,7 +147,6 @@ module Issuable
when 'upvotes_desc' then order_upvotes_desc
when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
- when 'position_asc' then order_position_asc
else
order_by(method)
end
@@ -174,9 +170,9 @@ module Issuable
#
milestones_due_date = 'MIN(milestones.due_date)'
- order_milestone_due_asc.
- order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]).
- reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'),
+ order_milestone_due_asc
+ .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
+ .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'),
Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
@@ -195,9 +191,9 @@ module Issuable
"(#{highest_priority}) AS highest_priority"
] + extra_select_columns
- select(select_columns.join(', ')).
- group(arel_table[:id]).
- reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
+ select(select_columns.join(', '))
+ .group(arel_table[:id])
+ .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
def with_label(title, sort = nil)
@@ -237,12 +233,8 @@ module Issuable
today? && created_at == updated_at
end
- def is_being_reassigned?
- assignee_id_changed?
- end
-
def open?
- opened? || reopened?
+ opened?
end
def user_notes_count
@@ -269,7 +261,11 @@ module Issuable
# DEPRECATED
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
}
- hook_data[:assignee] = assignee.hook_attrs if assignee
+ if self.is_a?(Issue)
+ hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
+ else
+ hook_data[:assignee] = assignee.hook_attrs if assignee
+ end
hook_data
end
@@ -331,11 +327,6 @@ module Issuable
false
end
- def assignee_or_author?(user)
- # We're comparing IDs here so we don't need to load any associations.
- author_id == user.id || assignee_id == user.id
- end
-
def record_metrics
metrics = self.metrics || create_metrics
metrics.record!
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 7e56e371b27..1db6b2d2fa2 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -44,28 +44,29 @@ module Mentionable
end
def all_references(current_user = nil, extractor: nil)
+ @extractors ||= {}
+
# Use custom extractor if it's passed in the function parameters.
if extractor
- @extractor = extractor
+ @extractors[current_user] = extractor
else
- @extractor ||= Gitlab::ReferenceExtractor.
- new(project, current_user)
+ extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)
- @extractor.reset_memoized_values
+ extractor.reset_memoized_values
end
self.class.mentionable_attrs.each do |attr, options|
- text = __send__(attr)
+ text = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
options = options.merge(
cache_key: [self, attr],
author: author,
skip_project_check: skip_project_check?
)
- @extractor.analyze(text, options)
+ extractor.analyze(text, options)
end
- @extractor
+ extractor
end
def mentioned_users(current_user = nil)
@@ -78,6 +79,8 @@ module Mentionable
# Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
def referenced_mentionables(current_user = self.author)
+ return [] unless matches_cross_reference_regex?
+
refs = all_references(current_user)
refs = (refs.issues + refs.merge_requests + refs.commits)
@@ -87,6 +90,20 @@ module Mentionable
refs.reject { |ref| ref == local_reference }
end
+ # Uses regex to quickly determine if mentionables might be referenced
+ # Allows heavy processing to be skipped
+ def matches_cross_reference_regex?
+ reference_pattern = if !project || project.default_issues_tracker?
+ ReferenceRegexes::DEFAULT_PATTERN
+ else
+ ReferenceRegexes::EXTERNAL_PATTERN
+ end
+
+ self.class.mentionable_attrs.any? do |attr, _|
+ __send__(attr) =~ reference_pattern # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
# Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
def create_cross_references!(author = self.author, without = [])
refs = referenced_mentionables(author)
diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb
new file mode 100644
index 00000000000..2d86a70c395
--- /dev/null
+++ b/app/models/concerns/mentionable/reference_regexes.rb
@@ -0,0 +1,22 @@
+module Mentionable
+ module ReferenceRegexes
+ def self.reference_pattern(link_patterns, issue_pattern)
+ Regexp.union(link_patterns,
+ issue_pattern,
+ Commit.reference_pattern,
+ MergeRequest.reference_pattern)
+ end
+
+ DEFAULT_PATTERN = begin
+ issue_pattern = Issue.reference_pattern
+ link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern))
+ reference_pattern(link_patterns, issue_pattern)
+ end
+
+ EXTERNAL_PATTERN = begin
+ issue_pattern = IssueTrackerService.reference_pattern
+ link_patterns = URI.regexp(%w(http https))
+ reference_pattern(link_patterns, issue_pattern)
+ end
+ end
+end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index f449229864d..f0998465822 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -40,10 +40,18 @@ module Milestoneish
def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params)
- .execute.where(milestone_id: milestoneish_ids)
+ .execute.preload(:assignees).where(milestone_id: milestoneish_ids)
end
end
+ def sorted_issues(user)
+ issues_visible_to_user(user).preload_associations.sort('label_priority')
+ end
+
+ def sorted_merge_requests
+ merge_requests.sort('label_priority')
+ end
+
def upcoming?
start_date && start_date.future?
end
@@ -62,6 +70,22 @@ module Milestoneish
due_date && due_date.past?
end
+ def is_group_milestone?
+ false
+ end
+
+ def is_project_milestone?
+ false
+ end
+
+ def is_legacy_group_milestone?
+ false
+ end
+
+ def is_dashboard_milestone?
+ false
+ end
+
private
def count_issues_by_state(user)
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index 6c27dd5aa5c..f734952fa6c 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -30,13 +30,7 @@ module NoteOnDiff
raise NotImplementedError
end
- private
-
- def noteable_diff_refs
- if noteable.respond_to?(:diff_sha_refs)
- noteable.diff_sha_refs
- else
- noteable.diff_refs
- end
+ def created_at_diff?(diff_refs)
+ false
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index dd1e6630642..c7bdc997eca 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -43,7 +43,12 @@ module Noteable
end
def resolvable_discussions
- @resolvable_discussions ||= discussion_notes.resolvable.discussions(self)
+ @resolvable_discussions ||=
+ if defined?(@discussions)
+ @discussions.select(&:resolvable?)
+ else
+ discussion_notes.resolvable.discussions(self)
+ end
end
def discussions_resolvable?
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 4865c0a14b1..ce69fd34ac5 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -82,7 +82,7 @@ module Participable
if attr.respond_to?(:call)
source.instance_exec(current_user, ext, &attr)
else
- process << source.__send__(attr)
+ process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend
end
end
when Enumerable, ActiveRecord::Relation
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 60734bc6660..cb59b4da3d7 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -32,6 +32,6 @@ module ProjectFeaturesCompatibility
build_project_feature unless project_feature
access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED
- project_feature.send(:write_attribute, field, access_level)
+ project_feature.__send__(:write_attribute, field, access_level) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index c41b807df8a..fde1cc44afa 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -1,11 +1,35 @@
module ProtectedBranchAccess
extend ActiveSupport::Concern
+ ALLOWED_ACCESS_LEVELS ||= [
+ Gitlab::Access::MASTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::NO_ACCESS
+ ].freeze
+
included do
include ProtectedRefAccess
belongs_to :protected_branch
delegate :project, to: :protected_branch
+
+ validates :access_level, presence: true, inclusion: {
+ in: ALLOWED_ACCESS_LEVELS
+ }
+
+ 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
end
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 62eaec2407f..ef95d6b0f98 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -8,32 +8,51 @@ module ProtectedRef
validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+ end
- def self.protected_ref_accessible_to?(ref, user, action:)
- access_levels_for_ref(ref, action: action).any? do |access_level|
+ def commit
+ project.commit(self.name)
+ end
+
+ class_methods do
+ def protected_ref_access_levels(*types)
+ types.each do |type|
+ # We need to set `inverse_of` to make sure the `belongs_to`-object is set
+ # when creating children using `accepts_nested_attributes_for`.
+ #
+ # If we don't `protected_branch` or `protected_tag` would be empty and
+ # `project` cannot be delegated to it, which in turn would cause validations
+ # to fail.
+ has_many :"#{type}_access_levels", dependent: :destroy, inverse_of: self.model_name.singular # rubocop:disable Cop/ActiveRecordDependent
+
+ validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
+
+ accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
+ end
+ end
+
+ def protected_ref_accessible_to?(ref, user, action:, protected_refs: nil)
+ access_levels_for_ref(ref, action: action, protected_refs: protected_refs).any? do |access_level|
access_level.check_access(user)
end
end
- def self.developers_can?(action, ref)
+ def developers_can?(action, ref)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.access_level == Gitlab::Access::DEVELOPER
end
end
- def self.access_levels_for_ref(ref, action:)
- self.matching(ref).map(&:"#{action}_access_levels").flatten
+ def access_levels_for_ref(ref, action:, protected_refs: nil)
+ self.matching(ref, protected_refs: protected_refs)
+ .map(&:"#{action}_access_levels").flatten
end
- def self.matching(ref_name, protected_refs: nil)
+ def matching(ref_name, protected_refs: nil)
ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
end
end
- def commit
- project.commit(self.name)
- end
-
private
def ref_matcher
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index da803c7f481..78ac4f324e7 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -25,6 +25,19 @@ module Referable
to_reference(from_project)
end
+ included do
+ alias_method :non_referable_inspect, :inspect
+ alias_method :inspect, :referable_inspect
+ end
+
+ def referable_inspect
+ if respond_to?(:id)
+ "#<#{self.class.name} id:#{id} #{to_reference(full: true)}>"
+ else
+ "#<#{self.class.name} #{to_reference(full: true)}>"
+ end
+ end
+
module ClassMethods
# The character that prefixes the actual reference identifier
#
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index f1d8532a6d6..7cb9a28a284 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -18,10 +18,10 @@ module RelativePositioning
prev_pos = nil
if self.relative_position
- prev_pos = self.class.
- in_projects(project.id).
- where('relative_position < ?', self.relative_position).
- maximum(:relative_position)
+ prev_pos = self.class
+ .in_projects(project.id)
+ .where('relative_position < ?', self.relative_position)
+ .maximum(:relative_position)
end
prev_pos
@@ -31,10 +31,10 @@ module RelativePositioning
next_pos = nil
if self.relative_position
- next_pos = self.class.
- in_projects(project.id).
- where('relative_position > ?', self.relative_position).
- minimum(:relative_position)
+ next_pos = self.class
+ .in_projects(project.id)
+ .where('relative_position > ?', self.relative_position)
+ .minimum(:relative_position)
end
next_pos
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index b28e05d0c28..f5048d17d80 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -4,7 +4,8 @@ module Routable
extend ActiveSupport::Concern
included do
- has_one :route, as: :source, autosave: true, dependent: :destroy
+ has_one :route, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates_associated :route
validates :route, presence: true
@@ -26,16 +27,31 @@ module Routable
# Klass.find_by_full_path('gitlab-org/gitlab-ce')
#
# Returns a single object, or nil.
- def find_by_full_path(path)
+ def find_by_full_path(path, follow_redirects: false)
# On MySQL we want to ensure the ORDER BY uses a case-sensitive match so
# any literal matches come first, for this we have to use "BINARY".
# Without this there's still no guarantee in what order MySQL will return
# rows.
+ #
+ # Why do we do this?
+ #
+ # Even though we have Rails validation on Route for unique paths
+ # (case-insensitive), there are old projects in our DB (and possibly
+ # clients' DBs) that have the same path with different cases.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that
+ # our unique index is case-sensitive in Postgres.
binary = Gitlab::Database.mysql? ? 'BINARY' : ''
-
order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)"
-
- where_full_path_in([path]).reorder(order_sql).take
+ found = where_full_path_in([path]).reorder(order_sql).take
+ return found if found
+
+ if follow_redirects
+ if Gitlab::Database.postgresql?
+ joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path)
+ else
+ joins(:redirect_routes).find_by(redirect_routes: { path: path })
+ end
+ end
end
# Builds a relation to find multiple objects by their full paths.
@@ -68,89 +84,6 @@ module Routable
joins(:route).where(wheres.join(' OR '))
end
end
-
- # Builds a relation to find multiple objects that are nested under user membership
- #
- # Usage:
- #
- # Klass.member_descendants(1)
- #
- # Returns an ActiveRecord::Relation.
- def member_descendants(user_id)
- joins(:route).
- joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
- INNER JOIN members ON members.source_id = r2.source_id
- AND members.source_type = r2.source_type").
- where('members.user_id = ?', user_id)
- end
-
- # Builds a relation to find multiple objects that are nested under user
- # membership. Includes the parent, as opposed to `#member_descendants`
- # which only includes the descendants.
- #
- # Usage:
- #
- # Klass.member_self_and_descendants(1)
- #
- # Returns an ActiveRecord::Relation.
- def member_self_and_descendants(user_id)
- joins(:route).
- joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
- OR routes.path = r2.path
- INNER JOIN members ON members.source_id = r2.source_id
- AND members.source_type = r2.source_type").
- where('members.user_id = ?', user_id)
- end
-
- # Returns all objects in a hierarchy, where any node in the hierarchy is
- # under the user membership.
- #
- # Usage:
- #
- # Klass.member_hierarchy(1)
- #
- # Examples:
- #
- # Given the following group tree...
- #
- # _______group_1_______
- # | |
- # | |
- # nested_group_1 nested_group_2
- # | |
- # | |
- # nested_group_1_1 nested_group_2_1
- #
- #
- # ... the following results are returned:
- #
- # * the user is a member of group 1
- # => 'group_1',
- # 'nested_group_1', nested_group_1_1',
- # 'nested_group_2', 'nested_group_2_1'
- #
- # * the user is a member of nested_group_2
- # => 'group1',
- # 'nested_group_2', 'nested_group_2_1'
- #
- # * the user is a member of nested_group_2_1
- # => 'group1',
- # 'nested_group_2', 'nested_group_2_1'
- #
- # Returns an ActiveRecord::Relation.
- def member_hierarchy(user_id)
- paths = member_self_and_descendants(user_id).pluck('routes.path')
-
- return none if paths.empty?
-
- wheres = paths.map do |path|
- "#{connection.quote(path)} = routes.path
- OR
- #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
- end
-
- joins(:route).where(wheres.join(' OR '))
- end
end
def full_name
@@ -170,8 +103,20 @@ module Routable
def full_path
return uncached_full_path unless RequestStore.active?
- key = "routable/full_path/#{self.class.name}/#{self.id}"
- RequestStore[key] ||= uncached_full_path
+ RequestStore[full_path_key] ||= uncached_full_path
+ end
+
+ def expires_full_path_cache
+ RequestStore.delete(full_path_key) if RequestStore.active?
+ @full_path = nil
+ end
+
+ def build_full_path
+ if parent && path
+ parent.full_path + '/' + path
+ else
+ path
+ end
end
private
@@ -194,6 +139,10 @@ module Routable
path_changed? || parent_changed?
end
+ def full_path_key
+ @full_path_key ||= "routable/full_path/#{self.class.name}/#{self.id}"
+ end
+
def build_full_name
if parent && name
parent.human_name + ' / ' + name
@@ -202,14 +151,6 @@ module Routable
end
end
- def build_full_path
- if parent && path
- parent.full_path + '/' + path
- else
- path
- end
- end
-
def update_route
prepare_route
route.save
diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb
index 50a1d7fc3e1..58194b0ea13 100644
--- a/app/models/concerns/select_for_project_authorization.rb
+++ b/app/models/concerns/select_for_project_authorization.rb
@@ -3,7 +3,11 @@ module SelectForProjectAuthorization
module ClassMethods
def select_for_project_authorization
- select("members.user_id, projects.id AS project_id, members.access_level")
+ select("projects.id AS project_id, members.access_level")
+ end
+
+ def select_as_master_for_project_authorization
+ select(["projects.id AS project_id", "#{Gitlab::Access::MASTER} AS access_level"])
end
end
end
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
new file mode 100644
index 00000000000..67ecf470f7e
--- /dev/null
+++ b/app/models/concerns/sha_attribute.rb
@@ -0,0 +1,20 @@
+module ShaAttribute
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def sha_attribute(name)
+ return unless table_exists?
+
+ column = columns.find { |c| c.name == name.to_s }
+
+ # In case the table doesn't exist we won't be able to find the column,
+ # thus we will only check the type if the column is present.
+ if column && column.type != :binary
+ raise ArgumentError,
+ "sha_attribute #{name.inspect} is invalid since the column type is not :binary"
+ end
+
+ attribute(name, Gitlab::Database::ShaAttribute.new)
+ end
+ end
+end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index b9a2d812edd..a155a064032 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -39,12 +39,12 @@ module Sortable
private
def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: [])
- query = Label.select(LabelPriority.arel_table[:priority].minimum).
- left_join_priorities.
- joins(:label_links).
- where("label_priorities.project_id = #{project_column}").
- where("label_links.target_id = #{target_column}").
- reorder(nil)
+ query = Label.select(LabelPriority.arel_table[:priority].minimum)
+ .left_join_priorities
+ .joins(:label_links)
+ .where("label_priorities.project_id = #{project_column}")
+ .where("label_links.target_id = #{target_column}")
+ .reorder(nil)
query =
if target_type_column
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 647a6cad3d7..f2707022a4b 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -8,7 +8,7 @@ module Spammable
end
included do
- has_one :user_agent_detail, as: :subject, dependent: :destroy
+ has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
attr_accessor :spam_log
@@ -58,7 +58,7 @@ module Spammable
options.fetch(:spam_title, false)
end
- public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym) # rubocop:disable GitlabSecurity/PublicSend
end
def spam_description
@@ -66,12 +66,12 @@ module Spammable
options.fetch(:spam_description, false)
end
- public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym) # rubocop:disable GitlabSecurity/PublicSend
end
def spammable_text
result = self.class.spammable_attrs.map do |attr|
- public_send(attr.first)
+ public_send(attr.first) # rubocop:disable GitlabSecurity/PublicSend
end
result.reject(&:blank?).join("\n")
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
new file mode 100644
index 00000000000..5ab5c80a2f5
--- /dev/null
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -0,0 +1,102 @@
+module Storage
+ module LegacyNamespace
+ extend ActiveSupport::Concern
+
+ def move_dir
+ if any_project_has_container_registry_tags?
+ raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
+ end
+
+ # Move the namespace directory in all storage paths used by member projects
+ repository_storage_paths.each do |repository_storage_path|
+ # Ensure old directory exists before moving it
+ gitlab_shell.add_namespace(repository_storage_path, full_path_was)
+
+ unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
+ Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
+
+ # if we cannot move namespace directory we should rollback
+ # db changes in order to prevent out of sync between db and fs
+ raise Gitlab::UpdatePathError.new('namespace directory cannot be moved')
+ end
+ end
+
+ Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
+ Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
+
+ remove_exports!
+
+ # If repositories moved successfully we need to
+ # send update instructions to users.
+ # However we cannot allow rollback since we moved namespace dir
+ # So we basically we mute exceptions in next actions
+ begin
+ send_update_instructions
+ true
+ rescue
+ # Returning false does not rollback after_* transaction but gives
+ # us information about failing some of tasks
+ false
+ end
+ end
+
+ # Hooks
+
+ # Save the storage paths before the projects are destroyed to use them on after destroy
+ def prepare_for_destroy
+ old_repository_storage_paths
+ end
+
+ private
+
+ def old_repository_storage_paths
+ @old_repository_storage_paths ||= repository_storage_paths
+ end
+
+ def repository_storage_paths
+ # We need to get the storage paths for all the projects, even the ones that are
+ # pending delete. Unscoping also get rids of the default order, which causes
+ # problems with SELECT DISTINCT.
+ Project.unscoped do
+ all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path)
+ end
+ end
+
+ def rm_dir
+ # Remove the namespace directory in all storages paths used by member projects
+ old_repository_storage_paths.each do |repository_storage_path|
+ # Move namespace directory into trash.
+ # We will remove it later async
+ new_path = "#{full_path}+#{id}+deleted"
+
+ if gitlab_shell.mv_namespace(repository_storage_path, full_path, new_path)
+ Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}")
+
+ # Remove namespace directroy async with delay so
+ # GitLab has time to remove all projects first
+ run_after_commit do
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ end
+ end
+ end
+
+ remove_exports!
+ end
+
+ def remove_exports!
+ Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
+ end
+
+ def export_path
+ File.join(Gitlab::ImportExport.storage_path, full_path_was)
+ end
+
+ def full_path_was
+ if parent
+ parent.full_path + '/' + path_was
+ else
+ path_was
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project.rb b/app/models/concerns/storage/legacy_project.rb
new file mode 100644
index 00000000000..815db712285
--- /dev/null
+++ b/app/models/concerns/storage/legacy_project.rb
@@ -0,0 +1,76 @@
+module Storage
+ module LegacyProject
+ extend ActiveSupport::Concern
+
+ def disk_path
+ full_path
+ end
+
+ def ensure_storage_path_exist
+ gitlab_shell.add_namespace(repository_storage_path, namespace.full_path)
+ end
+
+ def rename_repo
+ path_was = previous_changes['path'].first
+ old_path_with_namespace = File.join(namespace.full_path, path_was)
+ new_path_with_namespace = File.join(namespace.full_path, path)
+
+ Rails.logger.error "Attempting to rename #{old_path_with_namespace} -> #{new_path_with_namespace}"
+
+ if has_container_registry_tags?
+ Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
+
+ # we currently doesn't support renaming repository if it contains images in container registry
+ raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
+ end
+
+ expire_caches_before_rename(old_path_with_namespace)
+
+ if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
+ # If repository moved successfully we need to send update instructions to users.
+ # However we cannot allow rollback since we moved repository
+ # So we basically we mute exceptions in next actions
+ begin
+ gitlab_shell.mv_repository(repository_storage_path, "#{old_path_with_namespace}.wiki", "#{new_path_with_namespace}.wiki")
+ send_move_instructions(old_path_with_namespace)
+ expires_full_path_cache
+
+ @old_path_with_namespace = old_path_with_namespace
+
+ SystemHooksService.new.execute_hooks_for(self, :rename)
+
+ @repository = nil
+ rescue => e
+ Rails.logger.error "Exception renaming #{old_path_with_namespace} -> #{new_path_with_namespace}: #{e}"
+ # Returning false does not rollback after_* transaction but gives
+ # us information about failing some of tasks
+ false
+ end
+ else
+ Rails.logger.error "Repository could not be renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
+
+ # if we cannot move namespace directory we should rollback
+ # db changes in order to prevent out of sync between db and fs
+ raise StandardError.new('repository cannot be renamed')
+ end
+
+ Gitlab::AppLogger.info "Project was renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
+
+ Gitlab::UploadsTransfer.new.rename_project(path_was, path, namespace.full_path)
+ Gitlab::PagesTransfer.new.rename_project(path_was, path, namespace.full_path)
+ end
+
+ def create_repository(force: false)
+ # Forked import is handled asynchronously
+ return if forked? && !force
+
+ if gitlab_shell.add_repository(repository_storage_path, path_with_namespace)
+ repository.after_create
+ true
+ else
+ errors.add(:base, 'Failed to create repository via gitlab-shell')
+ false
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb
new file mode 100644
index 00000000000..ff82cb0ffa9
--- /dev/null
+++ b/app/models/concerns/storage/legacy_project_wiki.rb
@@ -0,0 +1,9 @@
+module Storage
+ module LegacyProjectWiki
+ extend ActiveSupport::Concern
+
+ def disk_path
+ project.disk_path + '.wiki'
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_repository.rb b/app/models/concerns/storage/legacy_repository.rb
new file mode 100644
index 00000000000..593749bf019
--- /dev/null
+++ b/app/models/concerns/storage/legacy_repository.rb
@@ -0,0 +1,7 @@
+module Storage
+ module LegacyRepository
+ extend ActiveSupport::Concern
+
+ delegate :disk_path, to: :project
+ end
+end
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index 83daa9b1a64..274b38a7708 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -9,7 +9,7 @@ module Subscribable
extend ActiveSupport::Concern
included do
- has_many :subscriptions, dependent: :destroy, as: :subscribable
+ has_many :subscriptions, dependent: :destroy, as: :subscribable # rubocop:disable Cop/ActiveRecordDependent
end
def subscribed?(user, project = nil)
@@ -27,16 +27,16 @@ module Subscribable
end
def subscribers(project)
- subscriptions_available(project).
- where(subscribed: true).
- map(&:user)
+ subscriptions_available(project)
+ .where(subscribed: true)
+ .map(&:user)
end
def toggle_subscription(user, project = nil)
unsubscribe_from_other_levels(user, project)
- find_or_initialize_subscription(user, project).
- update(subscribed: !subscribed?(user, project))
+ find_or_initialize_subscription(user, project)
+ .update(subscribed: !subscribed?(user, project))
end
def subscribe(user, project = nil)
@@ -69,14 +69,14 @@ module Subscribable
end
def find_or_initialize_subscription(user, project)
- subscriptions.
- find_or_initialize_by(user_id: user.id, project_id: project.try(:id))
+ subscriptions
+ .find_or_initialize_by(user_id: user.id, project_id: project.try(:id))
end
def subscriptions_available(project)
t = Subscription.arel_table
- subscriptions.
- where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id))))
+ subscriptions
+ .where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id))))
end
end
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
index 9cf83440784..b517ddaebd7 100644
--- a/app/models/concerns/time_trackable.rb
+++ b/app/models/concerns/time_trackable.rb
@@ -18,7 +18,7 @@ module TimeTrackable
validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
validate :check_negative_time_spent
- has_many :timelogs, dependent: :destroy
+ has_many :timelogs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
end
def spend_time(options)
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index 1ca7f91dc03..a7d5de48c66 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -44,7 +44,8 @@ module TokenAuthenticatable
end
define_method("ensure_#{token_field}!") do
- send("reset_#{token_field}!") if read_attribute(token_field).blank?
+ send("reset_#{token_field}!") if read_attribute(token_field).blank? # rubocop:disable GitlabSecurity/PublicSend
+
read_attribute(token_field)
end
diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb
new file mode 100644
index 00000000000..e8f09dc9161
--- /dev/null
+++ b/app/models/conversational_development_index/card.rb
@@ -0,0 +1,26 @@
+module ConversationalDevelopmentIndex
+ class Card
+ attr_accessor :metric, :title, :description, :feature, :blog, :docs
+
+ def initialize(metric:, title:, description:, feature:, blog:, docs: nil)
+ self.metric = metric
+ self.title = title
+ self.description = description
+ self.feature = feature
+ self.blog = blog
+ self.docs = docs
+ end
+
+ def instance_score
+ metric.instance_score(feature)
+ end
+
+ def leader_score
+ metric.leader_score(feature)
+ end
+
+ def percentage_score
+ metric.percentage_score(feature)
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb
new file mode 100644
index 00000000000..6e1753c9f30
--- /dev/null
+++ b/app/models/conversational_development_index/idea_to_production_step.rb
@@ -0,0 +1,19 @@
+module ConversationalDevelopmentIndex
+ class IdeaToProductionStep
+ attr_accessor :metric, :title, :features
+
+ def initialize(metric:, title:, features:)
+ self.metric = metric
+ self.title = title
+ self.features = features
+ end
+
+ def percentage_score
+ sum = features.sum do |feature|
+ metric.percentage_score(feature)
+ end
+
+ sum / features.size.to_f
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
new file mode 100644
index 00000000000..0bee62f954f
--- /dev/null
+++ b/app/models/conversational_development_index/metric.rb
@@ -0,0 +1,19 @@
+module ConversationalDevelopmentIndex
+ class Metric < ActiveRecord::Base
+ include Presentable
+
+ self.table_name = 'conversational_development_index_metrics'
+
+ def instance_score(feature)
+ self["instance_#{feature}"]
+ end
+
+ def leader_score(feature)
+ self["leader_#{feature}"]
+ end
+
+ def percentage_score(feature)
+ self["percentage_#{feature}"]
+ end
+ end
+end
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
index 646c1e5ce1a..fac7c5e5c85 100644
--- a/app/models/dashboard_milestone.rb
+++ b/app/models/dashboard_milestone.rb
@@ -2,4 +2,8 @@ class DashboardMilestone < GlobalMilestone
def issues_finder_params
{ authorized_only: true }
end
+
+ def is_dashboard_milestone?
+ true
+ end
end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 053f2a11aa0..51768dd96bc 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -1,5 +1,5 @@
class DeployKey < Key
- has_many :deploy_keys_projects, dependent: :destroy
+ has_many :deploy_keys_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :deploy_keys_projects
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index ae8486bd9ac..b37b9bfbdac 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -12,7 +12,7 @@ class DeployKeysProject < ActiveRecord::Base
def destroy_orphaned_deploy_key
return unless self.deploy_key.destroyed_when_orphaned? && self.deploy_key.orphaned?
-
+
self.deploy_key.destroy
end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index afad001d50f..056c49e7162 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -4,7 +4,7 @@ class Deployment < ActiveRecord::Base
belongs_to :project, required: true, validate: true
belongs_to :environment, required: true, validate: true
belongs_to :user
- belongs_to :deployable, polymorphic: true
+ belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :sha, presence: true
validates :ref, presence: true
@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base
delegate :name, to: :environment, prefix: true
after_create :create_ref
+ after_create :invalidate_cache
def commit
project.commit(sha)
@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base
project.repository.create_ref(ref, ref_path)
end
+ def invalidate_cache
+ environment.expire_etag_cache
+ end
+
def manual_actions
@manual_actions ||= deployable.try(:other_actions)
end
@@ -53,10 +58,10 @@ class Deployment < ActiveRecord::Base
def update_merge_request_metrics!
return unless environment.update_merge_request_metrics?
- merge_requests = project.merge_requests.
- joins(:metrics).
- where(target_branch: self.ref, merge_request_metrics: { first_deployed_to_production_at: nil }).
- where("merge_request_metrics.merged_at <= ?", self.created_at)
+ merge_requests = project.merge_requests
+ .joins(:metrics)
+ .where(target_branch: self.ref, merge_request_metrics: { first_deployed_to_production_at: nil })
+ .where("merge_request_metrics.merged_at <= ?", self.created_at)
if previous_deployment
merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.created_at)
@@ -71,22 +76,22 @@ class Deployment < ActiveRecord::Base
merge_requests.map(&:id)
end
- MergeRequest::Metrics.
- where(merge_request_id: merge_request_ids, first_deployed_to_production_at: nil).
- update_all(first_deployed_to_production_at: self.created_at)
+ MergeRequest::Metrics
+ .where(merge_request_id: merge_request_ids, first_deployed_to_production_at: nil)
+ .update_all(first_deployed_to_production_at: self.created_at)
end
def previous_deployment
@previous_deployment ||=
- project.deployments.joins(:environment).
- where(environments: { name: self.environment.name }, ref: self.ref).
- where.not(id: self.id).
- take
+ project.deployments.joins(:environment)
+ .where(environments: { name: self.environment.name }, ref: self.ref)
+ .where.not(id: self.id)
+ .take
end
def stop_action
- return nil unless on_stop.present?
- return nil unless manual_actions
+ return unless on_stop.present?
+ return unless manual_actions
@stop_action ||= manual_actions.find_by(name: on_stop)
end
@@ -99,6 +104,27 @@ class Deployment < ActiveRecord::Base
created_at.to_time.in_time_zone.to_s(:medium)
end
+ def has_metrics?
+ project.monitoring_service.present?
+ end
+
+ def metrics
+ return {} unless has_metrics?
+
+ project.monitoring_service.deployment_metrics(self)
+ end
+
+ def has_additional_metrics?
+ project.prometheus_service.present?
+ end
+
+ def additional_metrics
+ return {} unless project.prometheus_service.present?
+
+ metrics = project.prometheus_service.additional_deployment_metrics(self)
+ metrics&.merge(deployment_time: created_at.to_i) || {}
+ end
+
private
def ref_path
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 6a6466b493b..07c4846e2ac 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -10,7 +10,7 @@ class DiffDiscussion < Discussion
delegate :position,
:original_position,
- :latest_merge_request_diff,
+ :change_position,
to: :first_note
@@ -18,10 +18,17 @@ class DiffDiscussion < Discussion
false
end
+ def merge_request_version_params
+ return unless for_merge_request?
+ return {} if active?
+
+ noteable.version_params_for(position.diff_refs)
+ end
+
def reply_attributes
super.merge(
original_position: original_position.to_json,
- position: position.to_json,
+ position: position.to_json
)
end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index abe4518d62a..e9a60e6ce09 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -6,8 +6,9 @@ class DiffNote < Note
NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
- serialize :original_position, Gitlab::Diff::Position
- serialize :position, Gitlab::Diff::Position
+ serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :change_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
validates :original_position, presence: true
validates :position, presence: true
@@ -25,7 +26,7 @@ class DiffNote < Note
DiffDiscussion
end
- %i(original_position position).each do |meth|
+ %i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil
@@ -36,6 +37,8 @@ class DiffNote < Note
new_position = Gitlab::Diff::Position.new(new_position)
end
+ return if new_position == read_attribute(meth)
+
super(new_position)
end
end
@@ -45,7 +48,7 @@ class DiffNote < Note
end
def diff_line
- @diff_line ||= diff_file.line_for_position(self.original_position) if diff_file
+ @diff_line ||= diff_file&.line_for_position(self.original_position)
end
def for_line?(line)
@@ -60,15 +63,16 @@ class DiffNote < Note
return false unless supported?
return true if for_commit?
- diff_refs ||= noteable_diff_refs
+ diff_refs ||= noteable.diff_refs
self.position.diff_refs == diff_refs
end
- def latest_merge_request_diff
- return unless for_merge_request?
+ def created_at_diff?(diff_refs)
+ return false unless supported?
+ return true if for_commit?
- self.noteable.merge_request_diff_for(self.position.diff_refs)
+ self.original_position.diff_refs == diff_refs
end
private
@@ -91,13 +95,21 @@ class DiffNote < Note
return if active?
- Notes::DiffPositionUpdateService.new(
- self.project,
- nil,
+ tracer = Gitlab::Diff::PositionTracer.new(
+ project: self.project,
old_diff_refs: self.position.diff_refs,
- new_diff_refs: noteable_diff_refs,
+ new_diff_refs: self.noteable.diff_refs,
paths: self.position.paths
- ).execute(self)
+ )
+
+ result = tracer.trace(self.position)
+ return unless result
+
+ if result[:outdated]
+ self.change_position = result[:position]
+ else
+ self.position = result[:position]
+ end
end
def verify_supported
diff --git a/app/models/diff_viewer/added.rb b/app/models/diff_viewer/added.rb
new file mode 100644
index 00000000000..1909e6ef9d8
--- /dev/null
+++ b/app/models/diff_viewer/added.rb
@@ -0,0 +1,8 @@
+module DiffViewer
+ class Added < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'added'
+ end
+end
diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb
new file mode 100644
index 00000000000..0cbe714288d
--- /dev/null
+++ b/app/models/diff_viewer/base.rb
@@ -0,0 +1,87 @@
+module DiffViewer
+ class Base
+ PARTIAL_PATH_PREFIX = 'projects/diffs/viewers'.freeze
+
+ class_attribute :partial_name, :type, :extensions, :file_types, :binary, :switcher_icon, :switcher_title
+
+ # These limits relate to the sum of the old and new blob sizes.
+ # Limits related to the actual size of the diff are enforced in Gitlab::Diff::File.
+ class_attribute :collapse_limit, :size_limit
+
+ delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class
+
+ attr_reader :diff_file
+
+ delegate :project, to: :diff_file
+
+ def initialize(diff_file)
+ @diff_file = diff_file
+ @initially_binary = diff_file.binary?
+ end
+
+ def self.partial_path
+ File.join(PARTIAL_PATH_PREFIX, partial_name)
+ end
+
+ def self.rich?
+ type == :rich
+ end
+
+ def self.simple?
+ type == :simple
+ end
+
+ def self.binary?
+ binary
+ end
+
+ def self.text?
+ !binary?
+ end
+
+ def self.can_render?(diff_file, verify_binary: true)
+ can_render_blob?(diff_file.old_blob, verify_binary: verify_binary) &&
+ can_render_blob?(diff_file.new_blob, verify_binary: verify_binary)
+ end
+
+ def self.can_render_blob?(blob, verify_binary: true)
+ return true if blob.nil?
+ return false if verify_binary && binary? != blob.binary?
+ return true if extensions&.include?(blob.extension)
+ return true if file_types&.include?(blob.file_type)
+
+ false
+ end
+
+ def collapsed?
+ return @collapsed if defined?(@collapsed)
+ return @collapsed = true if diff_file.collapsed?
+
+ @collapsed = !diff_file.expanded? && collapse_limit && diff_file.raw_size > collapse_limit
+ end
+
+ def too_large?
+ return @too_large if defined?(@too_large)
+ return @too_large = true if diff_file.too_large?
+
+ @too_large = size_limit && diff_file.raw_size > size_limit
+ end
+
+ def binary_detected_after_load?
+ !@initially_binary && diff_file.binary?
+ end
+
+ # This method is used on the server side to check whether we can attempt to
+ # render the diff_file at all. Human-readable error messages are found in the
+ # `BlobHelper#diff_render_error_reason` helper.
+ def render_error
+ if too_large?
+ :too_large
+ end
+ end
+
+ def prepare!
+ # To be overridden by subclasses
+ end
+ end
+end
diff --git a/app/models/diff_viewer/client_side.rb b/app/models/diff_viewer/client_side.rb
new file mode 100644
index 00000000000..cf41d07f8eb
--- /dev/null
+++ b/app/models/diff_viewer/client_side.rb
@@ -0,0 +1,10 @@
+module DiffViewer
+ module ClientSide
+ extend ActiveSupport::Concern
+
+ included do
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 10.megabytes
+ end
+ end
+end
diff --git a/app/models/diff_viewer/deleted.rb b/app/models/diff_viewer/deleted.rb
new file mode 100644
index 00000000000..9c129bac694
--- /dev/null
+++ b/app/models/diff_viewer/deleted.rb
@@ -0,0 +1,8 @@
+module DiffViewer
+ class Deleted < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'deleted'
+ end
+end
diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb
new file mode 100644
index 00000000000..759d9a36ebb
--- /dev/null
+++ b/app/models/diff_viewer/image.rb
@@ -0,0 +1,12 @@
+module DiffViewer
+ class Image < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'image'
+ self.extensions = UploaderHelper::IMAGE_EXT
+ self.binary = true
+ self.switcher_icon = 'picture-o'
+ self.switcher_title = 'image diff'
+ end
+end
diff --git a/app/models/diff_viewer/mode_changed.rb b/app/models/diff_viewer/mode_changed.rb
new file mode 100644
index 00000000000..d487d996f8d
--- /dev/null
+++ b/app/models/diff_viewer/mode_changed.rb
@@ -0,0 +1,8 @@
+module DiffViewer
+ class ModeChanged < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'mode_changed'
+ end
+end
diff --git a/app/models/diff_viewer/no_preview.rb b/app/models/diff_viewer/no_preview.rb
new file mode 100644
index 00000000000..5455fee4490
--- /dev/null
+++ b/app/models/diff_viewer/no_preview.rb
@@ -0,0 +1,9 @@
+module DiffViewer
+ class NoPreview < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'no_preview'
+ self.binary = true
+ end
+end
diff --git a/app/models/diff_viewer/not_diffable.rb b/app/models/diff_viewer/not_diffable.rb
new file mode 100644
index 00000000000..4f9638626ea
--- /dev/null
+++ b/app/models/diff_viewer/not_diffable.rb
@@ -0,0 +1,9 @@
+module DiffViewer
+ class NotDiffable < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'not_diffable'
+ self.binary = true
+ end
+end
diff --git a/app/models/diff_viewer/renamed.rb b/app/models/diff_viewer/renamed.rb
new file mode 100644
index 00000000000..f1fbfd8c6d5
--- /dev/null
+++ b/app/models/diff_viewer/renamed.rb
@@ -0,0 +1,8 @@
+module DiffViewer
+ class Renamed < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'renamed'
+ end
+end
diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb
new file mode 100644
index 00000000000..3b0ca6e4cff
--- /dev/null
+++ b/app/models/diff_viewer/rich.rb
@@ -0,0 +1,11 @@
+module DiffViewer
+ module Rich
+ extend ActiveSupport::Concern
+
+ included do
+ self.type = :rich
+ self.switcher_icon = 'file-text-o'
+ self.switcher_title = 'rendered diff'
+ end
+ end
+end
diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb
new file mode 100644
index 00000000000..aed1a0791b1
--- /dev/null
+++ b/app/models/diff_viewer/server_side.rb
@@ -0,0 +1,26 @@
+module DiffViewer
+ module ServerSide
+ extend ActiveSupport::Concern
+
+ included do
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 5.megabytes
+ end
+
+ def prepare!
+ diff_file.old_blob&.load_all_data!
+ diff_file.new_blob&.load_all_data!
+ end
+
+ def render_error
+ # Files that are not stored in the repository, like LFS files and
+ # build artifacts, can only be rendered using a client-side viewer,
+ # since we do not want to read large amounts of data into memory on the
+ # server side. Client-side viewers use JS and can fetch the file from
+ # `diff_file_blob_raw_path` and `diff_file_old_blob_raw_path` using AJAX.
+ return :server_side_but_stored_externally if diff_file.stored_externally?
+
+ super
+ end
+ end
+end
diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb
new file mode 100644
index 00000000000..65750996ee4
--- /dev/null
+++ b/app/models/diff_viewer/simple.rb
@@ -0,0 +1,11 @@
+module DiffViewer
+ module Simple
+ extend ActiveSupport::Concern
+
+ included do
+ self.type = :simple
+ self.switcher_icon = 'code'
+ self.switcher_title = 'source diff'
+ end
+ end
+end
diff --git a/app/models/diff_viewer/static.rb b/app/models/diff_viewer/static.rb
new file mode 100644
index 00000000000..d761328b3f6
--- /dev/null
+++ b/app/models/diff_viewer/static.rb
@@ -0,0 +1,10 @@
+module DiffViewer
+ module Static
+ extend ActiveSupport::Concern
+
+ # We can always render a static viewer, even if the diff is too large.
+ def render_error
+ nil
+ end
+ end
+end
diff --git a/app/models/diff_viewer/text.rb b/app/models/diff_viewer/text.rb
new file mode 100644
index 00000000000..98f4b2aea2a
--- /dev/null
+++ b/app/models/diff_viewer/text.rb
@@ -0,0 +1,15 @@
+module DiffViewer
+ class Text < Base
+ include Simple
+ include ServerSide
+
+ self.partial_name = 'text'
+ self.binary = false
+
+ # Since the text diff viewer doesn't render the old and new blobs in full,
+ # we only need the limits related to the actual size of the diff which are
+ # already enforced in Gitlab::Diff::File.
+ self.collapse_limit = nil
+ self.size_limit = nil
+ end
+end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 0b6b920ed66..d1cec7613af 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -21,7 +21,8 @@ class Discussion
end
def self.build_collection(notes, context_noteable = nil)
- notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) }
+ grouped_notes = notes.group_by { |n| n.discussion_id(context_noteable) }
+ grouped_notes.values.map { |notes| build(notes, context_noteable) }
end
# Returns an alphanumeric discussion ID based on `build_discussion_id`
@@ -84,6 +85,12 @@ class Discussion
first_note.discussion_id(context_noteable)
end
+ def reply_id
+ # To reply to this discussion, we need the actual discussion_id from the database,
+ # not the potentially overwritten one based on the noteable.
+ first_note.discussion_id
+ end
+
alias_method :to_param, :id
def diff_discussion?
diff --git a/app/models/environment.rb b/app/models/environment.rb
index bf33010fd21..e9ebf0637f3 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -6,7 +6,7 @@ class Environment < ActiveRecord::Base
belongs_to :project, required: true, validate: true
- has_many :deployments, dependent: :destroy
+ has_many :deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :last_deployment, -> { order('deployments.id DESC') }, class_name: 'Deployment'
before_validation :nullify_external_url
@@ -40,11 +40,12 @@ class Environment < ActiveRecord::Base
scope :stopped, -> { with_state(:stopped) }
scope :order_by_last_deployed_at, -> do
max_deployment_id_sql =
- Deployment.select(Deployment.arel_table[:id].maximum).
- where(Deployment.arel_table[:environment_id].eq(arel_table[:id])).
- to_sql
+ Deployment.select(Deployment.arel_table[:id].maximum)
+ .where(Deployment.arel_table[:environment_id].eq(arel_table[:id]))
+ .to_sql
order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC'))
end
+ scope :in_review_folder, -> { where(environment_type: "review") }
state_machine :state, initial: :available do
event :start do
@@ -57,12 +58,16 @@ class Environment < ActiveRecord::Base
state :available
state :stopped
+
+ after_transition do |environment|
+ environment.expire_etag_cache
+ end
end
def predefined_variables
[
{ key: 'CI_ENVIRONMENT_NAME', value: name, public: true },
- { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true },
+ { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }
]
end
@@ -150,7 +155,17 @@ class Environment < ActiveRecord::Base
end
def metrics
- project.monitoring_service.metrics(self) if has_metrics?
+ project.monitoring_service.environment_metrics(self) if has_metrics?
+ end
+
+ def has_additional_metrics?
+ project.prometheus_service.present? && available? && last_deployment.present?
+ end
+
+ def additional_metrics
+ if has_additional_metrics?
+ project.prometheus_service.additional_environment_metrics(self)
+ end
end
# An environment name is not necessarily suitable for use in URLs, DNS
@@ -196,6 +211,18 @@ class Environment < ActiveRecord::Base
[external_url, public_path].join('/')
end
+ def expire_etag_cache
+ Gitlab::EtagCaching::Store.new.tap do |store|
+ store.touch(etag_cache_key)
+ end
+ end
+
+ def etag_cache_key
+ Gitlab::Routing.url_helpers.project_environments_path(
+ project,
+ format: :json)
+ end
+
private
# Slugifying a name may remove the uniqueness guarantee afforded by it being
diff --git a/app/models/event.rb b/app/models/event.rb
index b780c1faf81..f2a560a6b56 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -14,6 +14,30 @@ class Event < ActiveRecord::Base
DESTROYED = 10
EXPIRED = 11 # User left project due to expiry
+ ACTIONS = HashWithIndifferentAccess.new(
+ created: CREATED,
+ updated: UPDATED,
+ closed: CLOSED,
+ reopened: REOPENED,
+ pushed: PUSHED,
+ commented: COMMENTED,
+ merged: MERGED,
+ joined: JOINED,
+ left: LEFT,
+ destroyed: DESTROYED,
+ expired: EXPIRED
+ ).freeze
+
+ TARGET_TYPES = HashWithIndifferentAccess.new(
+ issue: Issue,
+ milestone: Milestone,
+ merge_request: MergeRequest,
+ note: Note,
+ project: Project,
+ snippet: Snippet,
+ user: User
+ ).freeze
+
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true
@@ -23,26 +47,60 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+ has_one :push_event_payload, foreign_key: :event_id
# For Hash only
- serialize :data
+ serialize :data # rubocop:disable Cop/ActiveRecordSerialize
# Callbacks
after_create :reset_project_activity
+ after_create :set_last_repository_updated_at, if: :push?
+ after_create :replicate_event_for_push_events_migration
# Scopes
scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) }
- scope :in_projects, ->(projects) do
- where(project_id: projects.pluck(:id)).recent
+ scope :in_projects, -> (projects) do
+ sub_query = projects
+ .except(:order)
+ .select(1)
+ .where('projects.id = events.project_id')
+
+ where('EXISTS (?)', sub_query).recent
+ end
+
+ scope :with_associations, -> do
+ # We're using preload for "push_event_payload" as otherwise the association
+ # is not always available (depending on the query being built).
+ includes(:author, :project, project: :namespace)
+ .preload(:target, :push_event_payload)
end
- scope :with_associations, -> { includes(:author, :project, project: :namespace).preload(:target) }
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
+ self.inheritance_column = 'action'
+
class << self
+ def find_sti_class(action)
+ if action.to_i == PUSHED
+ PushEvent
+ else
+ Event
+ end
+ end
+
+ def subclass_from_attributes(attrs)
+ # Without this Rails will keep calling this method on the returned class,
+ # resulting in an infinite loop.
+ return unless self == Event
+
+ action = attrs.with_indifferent_access[inheritance_column].to_i
+
+ PushEvent if action == PUSHED
+ end
+
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
@@ -54,6 +112,14 @@ class Event < ActiveRecord::Base
def limit_recent(limit = 20, offset = nil)
recent.limit(limit).offset(offset)
end
+
+ def actions
+ ACTIONS.keys
+ end
+
+ def target_types
+ TARGET_TYPES.keys
+ end
end
def visible_to_user?(user = nil)
@@ -257,6 +323,16 @@ class Event < ActiveRecord::Base
@commits ||= (data[:commits] || []).reverse
end
+ def commit_title
+ commit = commits.last
+
+ commit[:message] if commit
+ end
+
+ def commit_id
+ commit_to || commit_from
+ end
+
def commits_count
data[:total_commits_count] || commits.count || 0
end
@@ -343,18 +419,33 @@ class Event < ActiveRecord::Base
# At this point it's possible for multiple threads/processes to try to
# update the project. Only one query should actually perform the update,
# hence we add the extra WHERE clause for last_activity_at.
- Project.unscoped.where(id: project_id).
- where('last_activity_at <= ?', RESET_PROJECT_ACTIVITY_INTERVAL.ago).
- update_all(last_activity_at: created_at)
+ Project.unscoped.where(id: project_id)
+ .where('last_activity_at <= ?', RESET_PROJECT_ACTIVITY_INTERVAL.ago)
+ .update_all(last_activity_at: created_at)
end
def authored_by?(user)
user ? author_id == user.id : false
end
+ # We're manually replicating data into the new table since database triggers
+ # are not dumped to db/schema.rb. This could mean that a new installation
+ # would not have the triggers in place, thus losing events data in GitLab
+ # 10.0.
+ def replicate_event_for_push_events_migration
+ new_attributes = attributes.with_indifferent_access.except(:title, :data)
+
+ EventForMigration.create!(new_attributes)
+ end
+
private
def recent_update?
project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago
end
+
+ def set_last_repository_updated_at
+ Project.unscoped.where(id: project_id)
+ .update_all(last_repository_updated_at: created_at)
+ end
end
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
new file mode 100644
index 00000000000..8b8244314af
--- /dev/null
+++ b/app/models/event_collection.rb
@@ -0,0 +1,98 @@
+# A collection of events to display in an event list.
+#
+# An EventCollection is meant to be used for displaying events to a user (e.g.
+# in a controller), it's not suitable for building queries that are used for
+# building other queries.
+class EventCollection
+ # To prevent users from putting too much pressure on the database by cycling
+ # through thousands of events we put a limit on the number of pages.
+ MAX_PAGE = 10
+
+ # projects - An ActiveRecord::Relation object that returns the projects for
+ # which to retrieve events.
+ # filter - An EventFilter instance to use for filtering events.
+ def initialize(projects, limit: 20, offset: 0, filter: nil)
+ @projects = projects
+ @limit = limit
+ @offset = offset
+ @filter = filter
+ end
+
+ # Returns an Array containing the events.
+ def to_a
+ return [] if current_page > MAX_PAGE
+
+ relation = if Gitlab::Database.join_lateral_supported?
+ relation_with_join_lateral
+ else
+ relation_without_join_lateral
+ end
+
+ relation.with_associations.to_a
+ end
+
+ private
+
+ # Returns the events relation to use when JOIN LATERAL is not supported.
+ #
+ # This relation simply gets all the events for all authorized projects, then
+ # limits that set.
+ def relation_without_join_lateral
+ events = filtered_events.in_projects(projects)
+
+ paginate_events(events)
+ end
+
+ # Returns the events relation to use when JOIN LATERAL is supported.
+ #
+ # This relation is built using JOIN LATERAL, producing faster queries than a
+ # regular LIMIT + OFFSET approach.
+ def relation_with_join_lateral
+ projects_for_lateral = projects.select(:id).to_sql
+
+ lateral = filtered_events
+ .limit(limit_for_join_lateral)
+ .where('events.project_id = projects_for_lateral.id')
+ .to_sql
+
+ # The outer query does not need to re-apply the filters since the JOIN
+ # LATERAL body already takes care of this.
+ outer = base_relation
+ .from("(#{projects_for_lateral}) projects_for_lateral")
+ .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true")
+
+ paginate_events(outer)
+ end
+
+ def filtered_events
+ @filter ? @filter.apply_filter(base_relation) : base_relation
+ end
+
+ def paginate_events(events)
+ events.limit(@limit).offset(@offset)
+ end
+
+ def base_relation
+ # We want to have absolute control over the event queries being built, thus
+ # we're explicitly opting out of any default scopes that may be set.
+ Event.unscoped.recent
+ end
+
+ def limit_for_join_lateral
+ # Applying the OFFSET on the inside of a JOIN LATERAL leads to incorrect
+ # results. To work around this we need to increase the inner limit for every
+ # page.
+ #
+ # This means that on page 1 we use LIMIT 20, and an outer OFFSET of 0. On
+ # page 2 we use LIMIT 40 and an outer OFFSET of 20.
+ @limit + @offset
+ end
+
+ def current_page
+ (@offset / @limit) + 1
+ end
+
+ def projects
+ @projects.except(:order)
+ end
+end
diff --git a/app/models/event_for_migration.rb b/app/models/event_for_migration.rb
new file mode 100644
index 00000000000..a1672da5eec
--- /dev/null
+++ b/app/models/event_for_migration.rb
@@ -0,0 +1,5 @@
+# This model is used to replicate events between the old "events" table and the
+# new "events_for_migration" table that will replace "events" in GitLab 10.0.
+class EventForMigration < ActiveRecord::Base
+ self.table_name = 'events_for_migration'
+end
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index e63f89a9f85..0bf18e529f0 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -38,11 +38,6 @@ class ExternalIssue
@project.id
end
- # Pattern used to extract `JIRA-123` issue references from text
- def self.reference_pattern
- @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
- end
-
def to_reference(_from_project = nil, full: nil)
id
end
diff --git a/app/models/forked_project_link.rb b/app/models/forked_project_link.rb
index 36cf7ad6a28..8d35864eff6 100644
--- a/app/models/forked_project_link.rb
+++ b/app/models/forked_project_link.rb
@@ -1,4 +1,4 @@
class ForkedProjectLink < ActiveRecord::Base
- belongs_to :forked_to_project, class_name: 'Project'
- belongs_to :forked_from_project, class_name: 'Project'
+ belongs_to :forked_to_project, -> { where.not(pending_delete: true) }, class_name: 'Project'
+ belongs_to :forked_from_project, -> { where.not(pending_delete: true) }, class_name: 'Project'
end
diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb
index 8867ba0d2ff..532b8f4ad69 100644
--- a/app/models/generic_commit_status.rb
+++ b/app/models/generic_commit_status.rb
@@ -11,6 +11,7 @@ class GenericCommitStatus < CommitStatus
def set_default_values
self.context ||= 'default'
self.stage ||= 'external'
+ self.stage_idx ||= 1000000
end
def tags
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
index 698a7bbd327..2a1b7564962 100644
--- a/app/models/global_label.rb
+++ b/app/models/global_label.rb
@@ -2,7 +2,7 @@ class GlobalLabel
attr_accessor :title, :labels
alias_attribute :name, :title
- delegate :color, :description, to: :@first_label
+ delegate :color, :text_color, :description, to: :@first_label
def for_display
@first_label
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 0afbca2cb32..c0864769314 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -2,6 +2,7 @@ class GlobalMilestone
include Milestoneish
EPOCH = DateTime.parse('1970-01-01')
+ STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
attr_accessor :title, :milestones
alias_attribute :name, :title
@@ -11,7 +12,10 @@ class GlobalMilestone
end
def self.build_collection(projects, params)
- child_milestones = MilestonesFinder.new.execute(projects, params)
+ params =
+ { project_ids: projects.map(&:id), state: params[:state] }
+
+ child_milestones = MilestonesFinder.new(params).execute
milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
milestones_relation = Milestone.where(id: grouped.map(&:id))
@@ -28,15 +32,44 @@ class GlobalMilestone
new(title, child_milestones)
end
- def self.states_count(projects)
- relation = MilestonesFinder.new.execute(projects, state: 'all')
- milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
+ def self.states_count(projects, group = nil)
+ legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
+ group_milestones_count = group_milestones_states_count(group)
+
+ legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
+ legacy_group_milestones_count + group_milestones_count
+ end
+ end
+
+ def self.group_milestones_states_count(group)
+ return STATE_COUNT_HASH unless group
+
+ params = { group_ids: [group.id], state: 'all', order: nil }
+
+ relation = MilestonesFinder.new(params).execute
+ grouped_by_state = relation.group(:state).count
+
+ {
+ opened: grouped_by_state['active'] || 0,
+ closed: grouped_by_state['closed'] || 0,
+ all: grouped_by_state.values.sum
+ }
+ end
+
+ # Counts the legacy group milestones which must be grouped by title
+ def self.legacy_group_milestone_states_count(projects)
+ return STATE_COUNT_HASH unless projects
+
+ params = { project_ids: projects.map(&:id), state: 'all', order: nil }
+
+ relation = MilestonesFinder.new(params).execute
+ project_milestones_by_state_and_title = relation.group(:state, :title).count
- opened = count_by_state(milestones_by_state_and_title, 'active')
- closed = count_by_state(milestones_by_state_and_title, 'closed')
- all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
+ opened = count_by_state(project_milestones_by_state_and_title, 'active')
+ closed = count_by_state(project_milestones_by_state_and_title, 'closed')
+ all = project_milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
- {
+ {
opened: opened,
closed: closed,
all: all
@@ -86,7 +119,7 @@ class GlobalMilestone
end
def issues
- @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels)
+ @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
end
def merge_requests
@@ -94,7 +127,7 @@ class GlobalMilestone
end
def participants
- @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq
+ @participants ||= milestones.map(&:participants).flatten.uniq
end
def labels
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
new file mode 100644
index 00000000000..3df60ddc950
--- /dev/null
+++ b/app/models/gpg_key.rb
@@ -0,0 +1,107 @@
+class GpgKey < ActiveRecord::Base
+ KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze
+ KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze
+
+ include ShaAttribute
+
+ sha_attribute :primary_keyid
+ sha_attribute :fingerprint
+
+ belongs_to :user
+ has_many :gpg_signatures
+
+ validates :user, presence: true
+
+ validates :key,
+ presence: true,
+ uniqueness: true,
+ format: {
+ with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/m,
+ message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}' and ends with '#{KEY_SUFFIX}'"
+ }
+
+ validates :fingerprint,
+ presence: true,
+ uniqueness: true,
+ # only validate when the `key` is valid, as we don't want the user to show
+ # the error about the fingerprint
+ unless: -> { errors.has_key?(:key) }
+
+ validates :primary_keyid,
+ presence: true,
+ uniqueness: true,
+ # only validate when the `key` is valid, as we don't want the user to show
+ # the error about the fingerprint
+ unless: -> { errors.has_key?(:key) }
+
+ before_validation :extract_fingerprint, :extract_primary_keyid
+ after_commit :update_invalid_gpg_signatures, on: :create
+ after_commit :notify_user, on: :create
+
+ def primary_keyid
+ super&.upcase
+ end
+
+ def fingerprint
+ super&.upcase
+ end
+
+ def key=(value)
+ super(value&.strip)
+ end
+
+ def user_infos
+ @user_infos ||= Gitlab::Gpg.user_infos_from_key(key)
+ end
+
+ def verified_user_infos
+ user_infos.select do |user_info|
+ user_info[:email] == user.email
+ end
+ end
+
+ def emails_with_verified_status
+ user_infos.map do |user_info|
+ [
+ user_info[:email],
+ user_info[:email] == user.email
+ ]
+ end.to_h
+ end
+
+ def verified?
+ emails_with_verified_status.any? { |_email, verified| verified }
+ end
+
+ def update_invalid_gpg_signatures
+ InvalidGpgSignatureUpdateWorker.perform_async(self.id)
+ end
+
+ def revoke
+ GpgSignature.where(gpg_key: self, valid_signature: true).update_all(
+ gpg_key_id: nil,
+ valid_signature: false,
+ updated_at: Time.zone.now
+ )
+
+ destroy
+ end
+
+ private
+
+ def extract_fingerprint
+ # we can assume that the result only contains one item as the validation
+ # only allows one key
+ self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first
+ end
+
+ def extract_primary_keyid
+ # we can assume that the result only contains one item as the validation
+ # only allows one key
+ self.primary_keyid = Gitlab::Gpg.primary_keyids_from_key(key).first
+ end
+
+ def notify_user
+ NotificationService.new.new_gpg_key(self)
+ end
+end
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
new file mode 100644
index 00000000000..50fb35c77ec
--- /dev/null
+++ b/app/models/gpg_signature.rb
@@ -0,0 +1,25 @@
+class GpgSignature < ActiveRecord::Base
+ include ShaAttribute
+
+ sha_attribute :commit_sha
+ sha_attribute :gpg_key_primary_keyid
+
+ belongs_to :project
+ belongs_to :gpg_key
+
+ validates :commit_sha, presence: true
+ validates :project_id, presence: true
+ validates :gpg_key_primary_keyid, presence: true
+
+ def gpg_key_primary_keyid
+ super&.upcase
+ end
+
+ def commit
+ project.commit(commit_sha)
+ end
+
+ def gpg_commit
+ Gitlab::Gpg::Commit.new(project, commit_sha)
+ end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index cbc10b00cf5..2816a68257c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -2,12 +2,12 @@ require 'carrierwave/orm/activerecord'
class Group < Namespace
include Gitlab::ConfigHelper
- include Gitlab::VisibilityLevel
include AccessRequestable
+ include Avatarable
include Referable
include SelectForProjectAuthorization
- has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
+ has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
has_many :users, through: :group_members
has_many :owners,
@@ -15,12 +15,14 @@ class Group < Namespace
through: :group_members,
source: :user
- has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember'
+ has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
- has_many :project_group_links, dependent: :destroy
+ has_many :milestones
+ has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :shared_projects, through: :project_group_links, source: :project
- has_many :notification_settings, dependent: :destroy, as: :source
+ has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, class_name: 'GroupLabel'
+ has_many :variables, class_name: 'Ci::GroupVariable'
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :visibility_level_allowed_by_projects
@@ -30,13 +32,17 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
class << self
+ def supports_nested_groups?
+ Gitlab::Database.postgresql?
+ end
+
# Searches for groups matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
@@ -77,7 +83,7 @@ class Group < Namespace
if current_scope.joins_values.include?(:shared_projects)
joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id')
.where('project_namespace.share_with_group_lock = ?', false)
- .select("members.user_id, projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
+ .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
else
super
end
@@ -96,10 +102,6 @@ class Group < Namespace
full_name
end
- def visibility_level_field
- :visibility_level
- end
-
def visibility_level_allowed_by_projects
allowed_by_projects = self.projects.where('visibility_level > ?', self.visibility_level).none?
@@ -111,10 +113,10 @@ class Group < Namespace
allowed_by_projects
end
- def avatar_url(size = nil)
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- end
+ def avatar_url(**args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args)
end
def lfs_enabled?
@@ -165,10 +167,14 @@ class Group < Namespace
end
def has_owner?(user)
+ return false unless user
+
members_with_parents.owners.where(user_id: user).any?
end
def has_master?(user)
+ return false unless user
+
members_with_parents.masters.where(user_id: user).any?
end
@@ -201,20 +207,54 @@ class Group < Namespace
end
def refresh_members_authorized_projects
- UserProjectAccessChangedService.new(user_ids_for_project_authorizations).
- execute
+ UserProjectAccessChangedService.new(user_ids_for_project_authorizations)
+ .execute
end
def user_ids_for_project_authorizations
- users_with_parents.pluck(:id)
+ members_with_parents.pluck(:user_id)
end
def members_with_parents
- GroupMember.non_request.where(source_id: ancestors.pluck(:id).push(id))
+ # Avoids an unnecessary SELECT when the group has no parents
+ source_ids =
+ if parent_id
+ self_and_ancestors.reorder(nil).select(:id)
+ else
+ id
+ end
+
+ GroupMember
+ .active_without_invites
+ .where(source_id: source_ids)
+ end
+
+ def members_with_descendants
+ GroupMember
+ .active_without_invites
+ .where(source_id: self_and_descendants.reorder(nil).select(:id))
end
def users_with_parents
- User.where(id: members_with_parents.select(:user_id))
+ User
+ .where(id: members_with_parents.select(:user_id))
+ .reorder(nil)
+ end
+
+ def users_with_descendants
+ User
+ .where(id: members_with_descendants.select(:user_id))
+ .reorder(nil)
+ end
+
+ def max_member_access_for_user(user)
+ return GroupMember::OWNER if user.admin?
+
+ members_with_parents
+ .where(user_id: user)
+ .reorder(access_level: :desc)
+ .first&.
+ access_level || GroupMember::NO_ACCESS
end
def mattermost_team_params
@@ -227,6 +267,14 @@ class Group < Namespace
}
end
+ def secret_variables_for(ref, project)
+ list_of_ids = [self] + ancestors
+ variables = Ci::GroupVariable.where(group: list_of_ids)
+ variables = variables.unprotected unless project.protected_for?(ref)
+ variables = variables.group_by(&:group_id)
+ list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten
+ end
+
protected
def update_two_factor_requirement
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index 86d38e5468b..65249bd7bfc 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -16,4 +16,8 @@ class GroupMilestone < GlobalMilestone
def issues_finder_params
{ group_id: group.id }
end
+
+ def is_legacy_group_milestone?
+ true
+ end
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index c631e7a7df5..a8c424a6614 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -1,11 +1,20 @@
class ProjectHook < WebHook
- belongs_to :project
+ TRIGGERS = {
+ push_hooks: :push_events,
+ tag_push_hooks: :tag_push_events,
+ issue_hooks: :issues_events,
+ confidential_issue_hooks: :confidential_issues_events,
+ note_hooks: :note_events,
+ merge_request_hooks: :merge_requests_events,
+ job_hooks: :job_events,
+ pipeline_hooks: :pipeline_events,
+ wiki_page_hooks: :wiki_page_events
+ }.freeze
+
+ TRIGGERS.each do |trigger, event|
+ scope trigger, -> { where(event => true) }
+ end
- scope :issue_hooks, -> { where(issues_events: true) }
- scope :confidential_issue_hooks, -> { where(confidential_issues_events: true) }
- scope :note_hooks, -> { where(note_events: true) }
- scope :merge_request_hooks, -> { where(merge_requests_events: true) }
- scope :build_hooks, -> { where(build_events: true) }
- scope :pipeline_hooks, -> { where(pipeline_events: true) }
- scope :wiki_page_hooks, -> { where(wiki_page_events: true) }
+ belongs_to :project
+ validates :project, presence: true
end
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index eef24052a06..aef11514945 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -1,7 +1,8 @@
class ServiceHook < WebHook
belongs_to :service
+ validates :service, presence: true
def execute(data)
- super(data, 'service_hook')
+ WebHookService.new(self, data, 'service_hook').execute
end
end
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 777bad1e724..180c479c41b 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -1,5 +1,14 @@
class SystemHook < WebHook
- def async_execute(data, hook_name)
- Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name)
+ TRIGGERS = {
+ repository_update_hooks: :repository_update_events,
+ push_hooks: :push_events,
+ tag_push_hooks: :tag_push_events
+ }.freeze
+
+ TRIGGERS.each do |trigger, event|
+ scope trigger, -> { where(event => true) }
end
+
+ default_value_for :push_events, false
+ default_value_for :repository_update_events, true
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 595602e80fe..5a70e114f56 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -1,63 +1,15 @@
class WebHook < ActiveRecord::Base
include Sortable
- include HTTParty
- default_value_for :push_events, true
- default_value_for :issues_events, false
- default_value_for :confidential_issues_events, false
- default_value_for :note_events, false
- default_value_for :merge_requests_events, false
- default_value_for :tag_push_events, false
- default_value_for :build_events, false
- default_value_for :pipeline_events, false
- default_value_for :enable_ssl_verification, true
-
- scope :push_hooks, -> { where(push_events: true) }
- scope :tag_push_hooks, -> { where(tag_push_events: true) }
-
- # HTTParty timeout
- default_timeout Gitlab.config.gitlab.webhook_timeout
+ has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :url, presence: true, url: true
def execute(data, hook_name)
- parsed_url = URI.parse(url)
- if parsed_url.userinfo.blank?
- response = WebHook.post(url,
- body: data.to_json,
- headers: build_headers(hook_name),
- verify: enable_ssl_verification)
- else
- post_url = url.gsub("#{parsed_url.userinfo}@", '')
- auth = {
- username: CGI.unescape(parsed_url.user),
- password: CGI.unescape(parsed_url.password),
- }
- response = WebHook.post(post_url,
- body: data.to_json,
- headers: build_headers(hook_name),
- verify: enable_ssl_verification,
- basic_auth: auth)
- end
-
- [response.code, response.to_s]
- rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
- logger.error("WebHook Error => #{e}")
- [false, e.to_s]
+ WebHookService.new(self, data, hook_name).execute
end
def async_execute(data, hook_name)
- Sidekiq::Client.enqueue(ProjectWebHookWorker, id, data, hook_name)
- end
-
- private
-
- def build_headers(hook_name)
- headers = {
- 'Content-Type' => 'application/json',
- 'X-Gitlab-Event' => hook_name.singularize.titleize
- }
- headers['X-Gitlab-Token'] = token if token.present?
- headers
+ WebHookService.new(self, data, hook_name).async_execute
end
end
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
new file mode 100644
index 00000000000..e72c125fb69
--- /dev/null
+++ b/app/models/hooks/web_hook_log.rb
@@ -0,0 +1,13 @@
+class WebHookLog < ActiveRecord::Base
+ belongs_to :web_hook
+
+ serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :request_data, Hash # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :response_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
+
+ validates :web_hook, presence: true
+
+ def success?
+ response_status =~ /^2/
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 305fc01f041..1c948c8957e 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -9,6 +9,10 @@ class Issue < ActiveRecord::Base
include Spammable
include FasterCacheKeys
include RelativePositioning
+ include IgnorableColumn
+ include CreatedAtFilterable
+
+ ignore_column :position
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -20,14 +24,26 @@ class Issue < ActiveRecord::Base
belongs_to :project
belongs_to :moved_to, class_name: 'Issue'
- has_many :events, as: :target, dependent: :destroy
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :merge_requests_closing_issues,
+ class_name: 'MergeRequestsClosingIssues',
+ dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :issue_assignees
+ has_many :assignees, class_name: "User", through: :issue_assignees
- has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ has_many :issue_assignees
+ has_many :assignees, class_name: "User", through: :issue_assignees
validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
+ scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
+ scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
+ scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
+
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
@@ -35,26 +51,25 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
- scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
-
- scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
+ scope :preload_associations, -> { preload(:labels, project: :namespace) }
after_save :expire_etag_cache
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
+ participant :assignees
+
state_machine :state, initial: :opened do
event :close do
- transition [:reopened, :opened] => :closed
+ transition [:opened] => :closed
end
event :reopen do
- transition closed: :reopened
+ transition closed: :opened
end
state :opened
- state :reopened
state :closed
before_transition any => :closed do |issue|
@@ -63,10 +78,14 @@ class Issue < ActiveRecord::Base
end
def hook_attrs
+ assignee_ids = self.assignee_ids
+
attrs = {
total_time_spent: total_time_spent,
human_total_time_spent: human_total_time_spent,
- human_time_estimate: human_time_estimate
+ human_time_estimate: human_time_estimate,
+ assignee_ids: assignee_ids,
+ assignee_id: assignee_ids.first # This key is deprecated
}
attributes.merge!(attrs)
@@ -108,12 +127,28 @@ class Issue < ActiveRecord::Base
end
def self.order_by_position_and_priority
- order_labels_priority.
- reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'),
+ order_labels_priority
+ .reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'),
Gitlab::Database.nulls_last_order('highest_priority', 'ASC'),
"id DESC")
end
+ # Returns a Hash of attributes to be used for Twitter card metadata
+ def card_attributes
+ {
+ 'Author' => author.try(:name),
+ 'Assignee' => assignee_list
+ }
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignees.exists?(user.id)
+ end
+
+ def assignee_list
+ assignees.map(&:name).to_sentence
+ end
+
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -143,6 +178,14 @@ class Issue < ActiveRecord::Base
branches_with_iid - branches_with_merge_request
end
+ # Returns boolean if a related branch exists for the current issue
+ # ignores merge requests branchs
+ def has_related_branch?
+ project.repository.branch_names.any? do |branch|
+ /\A#{iid}-(?!\d+-stable)/i =~ branch
+ end
+ end
+
# To allow polymorphism with MergeRequest.
def source_project
project
@@ -214,9 +257,9 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user]
+ json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user]
- if options.has_key?(:labels)
+ if options.key?(:labels)
json[:labels] = labels.as_json(
project: project,
only: [:id, :title, :description, :color, :priority],
@@ -240,7 +283,7 @@ class Issue < ActiveRecord::Base
true
elsif confidential?
author == user ||
- assignee == user ||
+ assignees.include?(user) ||
project.team.member?(user, Gitlab::Access::REPORTER)
else
project.public? ||
@@ -255,11 +298,7 @@ class Issue < ActiveRecord::Base
end
def expire_etag_cache
- key = Gitlab::Routing.url_helpers.rendered_title_namespace_project_issue_path(
- project.namespace,
- project,
- self
- )
+ key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Gitlab::EtagCaching::Store.new.touch(key)
end
end
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
new file mode 100644
index 00000000000..06d760b6a89
--- /dev/null
+++ b/app/models/issue_assignee.rb
@@ -0,0 +1,6 @@
+class IssueAssignee < ActiveRecord::Base
+ extend Gitlab::CurrentSettings
+
+ belongs_to :issue
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id
+end
diff --git a/app/models/issue_collection.rb b/app/models/issue_collection.rb
index f0b7d9914c8..49f011c113f 100644
--- a/app/models/issue_collection.rb
+++ b/app/models/issue_collection.rb
@@ -17,9 +17,9 @@ class IssueCollection
# Given all the issue projects we get a list of projects that the current
# user has at least reporter access to.
- projects_with_reporter_access = user.
- projects_with_reporter_access_limited_to(project_ids).
- pluck(:id)
+ projects_with_reporter_access = user
+ .projects_with_reporter_access_limited_to(project_ids)
+ .pluck(:id)
collection.select do |issue|
if projects_with_reporter_access.include?(issue.project_id)
diff --git a/app/models/key.rb b/app/models/key.rb
index 9c74ca84753..49bc26122fa 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,6 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include AfterCommitQueue
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -17,21 +16,20 @@ class Key < ActiveRecord::Base
presence: true,
length: { maximum: 5000 },
format: { with: /\A(ssh|ecdsa)-.*\Z/ }
- validates :key,
- format: { without: /\n|\r/, message: 'should be a single line' }
validates :fingerprint,
uniqueness: true,
presence: { message: 'cannot be generated' }
delegate :name, :email, to: :user, prefix: true
- after_create :add_to_shell
- after_create :notify_user
+ after_commit :add_to_shell, on: :create
+ after_commit :notify_user, on: :create
after_create :post_create_hook
- after_destroy :remove_from_shell
+ after_commit :remove_from_shell, on: :destroy
after_destroy :post_destroy_hook
def key=(value)
+ value&.delete!("\n\r")
value.strip! unless value.blank?
write_attribute(:key, value)
end
@@ -74,7 +72,7 @@ class Key < ActiveRecord::Base
GitlabShellWorker.perform_async(
:remove_key,
shell_id,
- key,
+ key
)
end
@@ -93,6 +91,6 @@ class Key < ActiveRecord::Base
end
def notify_user
- run_after_commit { NotificationService.new.new_key(self) }
+ NotificationService.new.new_key(self)
end
end
diff --git a/app/models/label.rb b/app/models/label.rb
index d8b0e250732..674bb3f2720 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -15,9 +15,9 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
- has_many :lists, dependent: :destroy
+ has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :priorities, class_name: 'LabelPriority'
- has_many :label_links, dependent: :destroy
+ has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
@@ -34,6 +34,7 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
+ scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) }
def self.prioritized(project)
joins(:priorities)
@@ -45,9 +46,9 @@ class Label < ActiveRecord::Base
labels = Label.arel_table
priorities = LabelPriority.arel_table
- label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin).
- on(labels[:id].eq(priorities[:label_id]).and(priorities[:project_id].eq(project.id))).
- join_sources
+ label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin)
+ .on(labels[:id].eq(priorities[:label_id]).and(priorities[:project_id].eq(project.id)))
+ .join_sources
joins(label_priorities).where(priorities[:priority].eq(nil))
end
@@ -56,9 +57,9 @@ class Label < ActiveRecord::Base
labels = Label.arel_table
priorities = LabelPriority.arel_table
- label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin).
- on(labels[:id].eq(priorities[:label_id])).
- join_sources
+ label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin)
+ .on(labels[:id].eq(priorities[:label_id]))
+ .join_sources
joins(label_priorities)
end
@@ -132,6 +133,10 @@ class Label < ActiveRecord::Base
template
end
+ def color
+ super || DEFAULT_COLOR
+ end
+
def text_color
LabelsHelper.text_color_for_bg(self.color)
end
@@ -167,7 +172,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:priority] = priority(options[:project]) if options.has_key?(:project)
+ json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index 51b5c2b1f4c..d68e1f54317 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,7 +1,7 @@
class LabelLink < ActiveRecord::Base
include Importable
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :label
validates :target, presence: true, unless: :importing?
diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb
index e617ce36f56..3c1d34db5fa 100644
--- a/app/models/legacy_diff_discussion.rb
+++ b/app/models/legacy_diff_discussion.rb
@@ -9,14 +9,14 @@ class LegacyDiffDiscussion < Discussion
memoized_values << :active
- def legacy_diff_discussion?
- true
- end
-
def self.note_class
LegacyDiffNote
end
+ def legacy_diff_discussion?
+ true
+ end
+
def active?(*args)
return @active if @active.present?
@@ -27,6 +27,16 @@ class LegacyDiffDiscussion < Discussion
!active?
end
+ def merge_request_version_params
+ return unless for_merge_request?
+
+ if active?
+ {}
+ else
+ nil
+ end
+ end
+
def reply_attributes
super.merge(line_code: line_code)
end
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index d7c627432d2..c36be956ff0 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -7,7 +7,7 @@
class LegacyDiffNote < Note
include NoteOnDiff
- serialize :st_diff
+ serialize :st_diff # rubocop:disable Cop/ActiveRecordSerialize
validates :line_code, presence: true, line_code: true
@@ -42,7 +42,7 @@ class LegacyDiffNote < Note
end
def for_line?(line)
- !line.meta? && diff_file.line_code(line) == self.line_code
+ line.discussable? && diff_file.line_code(line) == self.line_code
end
def original_line_code
@@ -61,7 +61,7 @@ class LegacyDiffNote < Note
return true if for_commit?
return true unless diff_line
return false unless noteable
- return false if diff_refs && diff_refs != noteable_diff_refs
+ return false if diff_refs && diff_refs != noteable.diff_refs
noteable_diff = find_noteable_diff
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 7712d5783e0..b7cf96abe83 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -1,5 +1,5 @@
class LfsObject < ActiveRecord::Base
- has_many :lfs_objects_projects, dependent: :destroy
+ has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :lfs_objects_projects
validates :oid, presence: true, uniqueness: true
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 007eed5600a..b0625c52b62 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base
validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
- after_create :update_project_statistics
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics, on: [:create, :destroy]
private
diff --git a/app/models/list.rb b/app/models/list.rb
index fbd19acd1f5..918275be142 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -2,7 +2,7 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
- enum list_type: { label: 1, closed: 2 }
+ enum list_type: { backlog: 0, label: 1, closed: 2 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
@@ -28,7 +28,7 @@ class List < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.has_key?(:label)
+ if options.key?(:label)
json[:label] = label.as_json(
project: board.project,
only: [:id, :title, :description, :color]
diff --git a/app/models/member.rb b/app/models/member.rb
index 97fba501759..ee2cb13697b 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -8,7 +8,7 @@ class Member < ActiveRecord::Base
belongs_to :created_by, class_name: "User"
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
delegate :name, :username, :email, to: :user, prefix: true
@@ -41,9 +41,20 @@ class Member < ActiveRecord::Base
is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil))
user_is_active = User.arel_table[:state].eq(:active)
- includes(:user).references(:users)
- .where(is_external_invite.or(user_is_active))
+ user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_active)
+
+ left_join_users
+ .where(user_ok)
+ .where(requested_at: nil)
+ .reorder(nil)
+ end
+
+ # Like active, but without invites. For when a User is required.
+ scope :active_without_invites, -> do
+ left_join_users
+ .where(users: { state: 'active' })
.where(requested_at: nil)
+ .reorder(nil)
end
scope :invite, -> { where.not(invite_token: nil) }
@@ -99,9 +110,9 @@ class Member < ActiveRecord::Base
users = User.arel_table
members = Member.arel_table
- member_users = members.join(users, Arel::Nodes::OuterJoin).
- on(members[:user_id].eq(users[:id])).
- join_sources
+ member_users = members.join(users, Arel::Nodes::OuterJoin)
+ .on(members[:user_id].eq(users[:id]))
+ .join_sources
joins(member_users)
end
@@ -154,6 +165,11 @@ class Member < ActiveRecord::Base
def add_users(source, users, access_level, current_user: nil, expires_at: nil)
return [] unless users.present?
+ # Collect all user ids into separate array
+ # so we can use single sql query to get user objects
+ user_ids = users.select { |user| user =~ /\A\d+\Z/ }
+ users = users - user_ids + User.where(id: user_ids)
+
self.transaction do
users.map do |user|
add_user(
@@ -195,6 +211,10 @@ class Member < ActiveRecord::Base
source_type
end
+ def access_field
+ access_level
+ end
+
def invite?
self.invite_token.present?
end
@@ -267,6 +287,13 @@ class Member < ActiveRecord::Base
@notification_setting ||= user.notification_settings_for(source)
end
+ def notifiable?(type, opts = {})
+ # always notify when there isn't a user yet
+ return true if user.blank?
+
+ NotificationRecipientService.notifiable?(user, type, notifiable_options.merge(opts))
+ end
+
private
def send_invite
@@ -323,4 +350,8 @@ class Member < ActiveRecord::Base
def notification_service
NotificationService.new
end
+
+ def notifiable_options
+ {}
+ end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 28e10bc6172..661e668dbf9 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -25,15 +25,15 @@ class GroupMember < Member
source
end
- def access_field
- access_level
- end
-
# Because source_type is `Namespace`...
def real_source_type
'Group'
end
+ def notifiable_options
+ { group: group }
+ end
+
private
def send_invite
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b3a91feb091..b6f1dd272cd 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -79,10 +79,6 @@ class ProjectMember < Member
end
end
- def access_field
- access_level
- end
-
def project
source
end
@@ -91,6 +87,10 @@ class ProjectMember < Member
project.owner == user
end
+ def notifiable_options
+ { project: project }
+ end
+
private
def delete_member_todos
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 9d2288c311e..ac08dc0ee1f 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -4,27 +4,35 @@ class MergeRequest < ActiveRecord::Base
include Noteable
include Referable
include Sortable
+ include IgnorableColumn
+ include CreatedAtFilterable
+
+ ignore_column :position
+ ignore_column :locked_at
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
- has_many :merge_request_diffs, dependent: :destroy
+ has_many :merge_request_diffs
has_one :merge_request_diff,
- -> { order('merge_request_diffs.id DESC') }
+ -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request
+
+ belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline"
+
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :events, as: :target, dependent: :destroy
+ has_many :merge_requests_closing_issues,
+ class_name: 'MergeRequestsClosingIssues',
+ dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
+ belongs_to :assignee, class_name: "User"
- serialize :merge_params, Hash
+ serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize
after_create :ensure_merge_request_diff, unless: :importing?
after_update :reload_diff_if_branch_changed
- delegate :commits, :real_size, :commits_sha, :commits_count,
- to: :merge_request_diff, prefix: nil
-
# When this attribute is true some MR validation is ignored
# It allows us to close or modify broken merge requests
attr_accessor :allow_broken
@@ -35,37 +43,26 @@ class MergeRequest < ActiveRecord::Base
state_machine :state, initial: :opened do
event :close do
- transition [:reopened, :opened] => :closed
+ transition [:opened] => :closed
end
event :mark_as_merged do
- transition [:reopened, :opened, :locked] => :merged
+ transition [:opened, :locked] => :merged
end
event :reopen do
- transition closed: :reopened
+ transition closed: :opened
end
event :lock_mr do
- transition [:reopened, :opened] => :locked
+ transition [:opened] => :locked
end
event :unlock_mr do
- transition locked: :reopened
- end
-
- after_transition any => :locked do |merge_request, transition|
- merge_request.locked_at = Time.now
- merge_request.save
- end
-
- after_transition locked: (any - :locked) do |merge_request, transition|
- merge_request.locked_at = nil
- merge_request.save
+ transition locked: :opened
end
state :opened
- state :reopened
state :closed
state :merged
state :locked
@@ -100,6 +97,7 @@ class MergeRequest < ActiveRecord::Base
validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing?
validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?]
validate :validate_fork, unless: :closed_without_fork?
+ validate :validate_target_project, on: :create
scope :by_source_or_target_branch, ->(branch_name) do
where("source_branch = :branch OR target_branch = :branch", branch: branch_name)
@@ -113,6 +111,11 @@ class MergeRequest < ActiveRecord::Base
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
+ scope :assigned, -> { where("assignee_id IS NOT NULL") }
+ scope :unassigned, -> { where("assignee_id IS NULL") }
+ scope :assigned_to, ->(u) { where(assignee_id: u.id)}
+
+ participant :assignee
after_save :keep_around_commit
@@ -159,7 +162,7 @@ class MergeRequest < ActiveRecord::Base
target = unscoped.where(target_project_id: relation).select(:id)
union = Gitlab::SQL::Union.new([source, target])
- where("merge_requests.id IN (#{union.to_sql})")
+ where("merge_requests.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
@@ -176,6 +179,31 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}"
end
+ # Returns a Hash of attributes to be used for Twitter card metadata
+ def card_attributes
+ {
+ 'Author' => author.try(:name),
+ 'Assignee' => assignee.try(:name)
+ }
+ end
+
+ # These method are needed for compatibility with issues to not mess view and other code
+ def assignees
+ Array(assignee)
+ end
+
+ def assignee_ids
+ Array(assignee_id)
+ end
+
+ def assignee_ids=(ids)
+ write_attribute(:assignee_id, ids.last)
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignee_id == user.id
+ end
+
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -183,6 +211,36 @@ class MergeRequest < ActiveRecord::Base
"#{project.to_reference(from, full: full)}#{reference}"
end
+ def commits
+ if persisted?
+ merge_request_diff.commits
+ elsif compare_commits
+ compare_commits.reverse
+ else
+ []
+ end
+ end
+
+ def commits_count
+ if persisted?
+ merge_request_diff.commits_count
+ elsif compare_commits
+ compare_commits.size
+ else
+ 0
+ end
+ end
+
+ def commit_shas
+ if persisted?
+ merge_request_diff.commit_shas
+ elsif compare_commits
+ compare_commits.reverse.map(&:sha)
+ else
+ []
+ end
+ end
+
def first_commit
merge_request_diff ? merge_request_diff.first_commit : compare_commits.first
end
@@ -193,10 +251,10 @@ class MergeRequest < ActiveRecord::Base
def diffs(diff_options = {})
if compare
- # When saving MR diffs, `no_collapse` is implicitly added (because we need
+ # When saving MR diffs, `expanded` is implicitly added (because we need
# to save the entire contents to the DB), so add that here for
# consistency.
- compare.diffs(diff_options.merge(no_collapse: true))
+ compare.diffs(diff_options.merge(expanded: true))
else
merge_request_diff.diffs(diff_options)
end
@@ -205,9 +263,7 @@ class MergeRequest < ActiveRecord::Base
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
- return real_size if merge_request_diff
-
- diffs.real_size
+ merge_request_diff&.real_size || diffs.real_size
end
def diff_base_commit
@@ -218,19 +274,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- # MRs created before 8.4 don't store a MergeRequestDiff#base_commit_sha,
- # but we need to get a commit for the "View file @ ..." link by deleted files,
- # so we find the likely one if we can't get the actual one.
- # This will not be the actual base commit if the target branch was merged into
- # the source branch after the merge request was created, but it is good enough
- # for the specific purpose of linking to a commit.
- # It is not good enough for use in `Gitlab::Git::DiffRefs`, which needs the
- # true base commit, so we can't simply have `#diff_base_commit` fall back on
- # this method.
- def likely_diff_base_commit
- first_commit.try(:parent) || first_commit
- end
-
def diff_start_commit
if persisted?
merge_request_diff.start_commit
@@ -266,6 +309,8 @@ class MergeRequest < ActiveRecord::Base
attr_writer :target_branch_sha, :source_branch_sha
def source_branch_head
+ return unless source_project
+
source_branch_ref = @source_branch_sha || source_branch
source_project.repository.commit(source_branch_ref) if source_branch_ref
end
@@ -293,21 +338,14 @@ class MergeRequest < ActiveRecord::Base
end
def diff_refs
- return unless diff_start_commit || diff_base_commit
-
- Gitlab::Diff::DiffRefs.new(
- base_sha: diff_base_sha,
- start_sha: diff_start_sha,
- head_sha: diff_head_sha
- )
- end
-
- # Return diff_refs instance trying to not touch the git repository
- def diff_sha_refs
- if merge_request_diff && merge_request_diff.diff_refs_by_sha?
+ if persisted?
merge_request_diff.diff_refs
else
- diff_refs
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: diff_base_sha,
+ start_sha: diff_start_sha,
+ head_sha: diff_head_sha
+ )
end
end
@@ -320,7 +358,7 @@ class MergeRequest < ActiveRecord::Base
errors.add :branch_conflict, "You can not use same project/branch for source and target"
end
- if opened? || reopened?
+ if opened?
similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened
similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id
if similar_mrs.any?
@@ -330,6 +368,12 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def validate_target_project
+ return true if target_project.merge_requests_enabled?
+
+ errors.add :base, 'Target project has disabled merge requests'
+ end
+
def validate_fork
return true unless target_project && source_project
return true if target_project == source_project
@@ -339,6 +383,12 @@ class MergeRequest < ActiveRecord::Base
'Source project is not a fork of the target project'
end
+ def merge_ongoing?
+ return false unless merge_jid
+
+ Gitlab::SidekiqStatus.num_running([merge_jid]) > 0
+ end
+
def closed_without_fork?
closed? && source_project_missing?
end
@@ -367,21 +417,39 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff(true)
end
- def merge_request_diff_for(diff_refs)
- @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs|
- h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs)
+ def merge_request_diff_for(diff_refs_or_sha)
+ @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha|
+ diffs = merge_request_diffs.viewable.select_without_diff
+ h[diff_refs_or_sha] =
+ if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
+ diffs.find_by_diff_refs(diff_refs_or_sha)
+ else
+ diffs.find_by(head_commit_sha: diff_refs_or_sha)
+ end
end
- @merge_request_diffs_by_diff_refs[diff_refs]
+ @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
+ end
+
+ def version_params_for(diff_refs)
+ if diff = merge_request_diff_for(diff_refs)
+ { diff_id: diff.id }
+ elsif diff = merge_request_diff_for(diff_refs.head_sha)
+ {
+ diff_id: diff.id,
+ start_sha: diff_refs.start_sha
+ }
+ end
end
def reload_diff_if_branch_changed
- if source_branch_changed? || target_branch_changed?
+ if (source_branch_changed? || target_branch_changed?) &&
+ (source_branch_head && target_branch_head)
reload_diff
end
end
- def reload_diff
+ def reload_diff(current_user = nil)
return unless open?
old_diff_refs = self.diff_refs
@@ -389,9 +457,10 @@ class MergeRequest < ActiveRecord::Base
MergeRequests::MergeRequestDiffCacheService.new.execute(self)
new_diff_refs = self.diff_refs
- update_diff_notes_positions(
+ update_diff_discussion_positions(
old_diff_refs: old_diff_refs,
- new_diff_refs: new_diff_refs
+ new_diff_refs: new_diff_refs,
+ current_user: current_user
)
end
@@ -472,7 +541,7 @@ class MergeRequest < ActiveRecord::Base
def related_notes
# Fetch comments only from last 100 commits
commits_for_notes_limit = 100
- commit_ids = commits.last(commits_for_notes_limit).map(&:id)
+ commit_ids = commit_shas.take(commits_for_notes_limit)
Note.where(
"(project_id = :target_project_id AND noteable_type = 'MergeRequest' AND noteable_id = :mr_id) OR" +
@@ -524,7 +593,7 @@ class MergeRequest < ActiveRecord::Base
# running `ReferenceExtractor` on each of them separately.
# This optimization does not apply to issues from external sources.
def cache_merge_request_closes_issues!(current_user)
- return if project.has_external_issue_tracker?
+ return unless project.issues_enabled?
transaction do
self.merge_requests_closing_issues.delete_all
@@ -541,8 +610,8 @@ class MergeRequest < ActiveRecord::Base
messages = [title, description]
messages.concat(commits.map(&:safe_message)) if merge_request_diff
- Gitlab::ClosingIssueExtractor.new(project, current_user).
- closed_by_message(messages.join("\n"))
+ Gitlab::ClosingIssueExtractor.new(project, current_user)
+ .closed_by_message(messages.join("\n"))
else
[]
end
@@ -559,7 +628,7 @@ class MergeRequest < ActiveRecord::Base
def target_project_path
if target_project
- target_project.path_with_namespace
+ target_project.full_path
else
"(removed)"
end
@@ -567,7 +636,7 @@ class MergeRequest < ActiveRecord::Base
def source_project_path
if source_project
- source_project.path_with_namespace
+ source_project.full_path
else
"(removed)"
end
@@ -654,12 +723,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- def locked_long_ago?
- return false unless locked?
-
- locked_at.nil? || locked_at < (Time.now - 1.day)
- end
-
def has_ci?
has_ci_integration = source_project.try(:ci_service)
uses_gitlab_ci = all_pipelines.any?
@@ -730,11 +793,8 @@ class MergeRequest < ActiveRecord::Base
end
def fetch_ref
- target_project.repository.fetch_ref(
- source_project.repository.path_to_repo,
- "refs/heads/#{source_branch}",
- ref_path
- )
+ write_ref
+ update_column(:ref_fetched, true)
end
def ref_path
@@ -742,7 +802,13 @@ class MergeRequest < ActiveRecord::Base
end
def ref_fetched?
- project.repository.ref_exists?(ref_path)
+ super ||
+ begin
+ computed_value = project.repository.ref_exists?(ref_path)
+ update_column(:ref_fetched, true) if computed_value
+
+ computed_value
+ end
end
def ensure_ref_fetched
@@ -784,25 +850,22 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
- def head_pipeline
- return unless diff_head_sha && source_project
-
- @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha)
- end
-
def all_pipelines
return Ci::Pipeline.none unless source_project
@all_pipelines ||= source_project.pipelines
- .where(sha: all_commits_sha, ref: source_branch)
+ .where(sha: all_commit_shas, ref: source_branch)
.order(id: :desc)
end
# Note that this could also return SHA from now dangling commits
#
- def all_commits_sha
+ def all_commit_shas
if persisted?
- merge_request_diffs.flat_map(&:commits_sha).uniq
+ column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).pluck('DISTINCT(sha)')
+ serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
+
+ (column_shas + serialised_shas).uniq
elsif compare_commits
compare_commits.to_a.reverse.map(&:id)
else
@@ -819,38 +882,34 @@ class MergeRequest < ActiveRecord::Base
end
def can_be_cherry_picked?
- merge_commit
+ merge_commit.present?
end
def has_complete_diff_refs?
- diff_sha_refs && diff_sha_refs.complete?
+ diff_refs && diff_refs.complete?
end
- def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
+ def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
- active_diff_notes = self.notes.new_diff_notes.select do |note|
- note.active?(old_diff_refs)
+ active_diff_discussions = self.notes.new_diff_notes.discussions.select do |discussion|
+ discussion.active?(old_diff_refs)
end
+ return if active_diff_discussions.empty?
- return if active_diff_notes.empty?
-
- paths = active_diff_notes.flat_map { |n| n.diff_file.paths }.uniq
+ paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq
- service = Notes::DiffPositionUpdateService.new(
+ service = Discussions::UpdateDiffPositionService.new(
self.project,
- nil,
+ current_user,
old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs,
paths: paths
)
- transaction do
- active_diff_notes.each do |note|
- service.execute(note)
- Gitlab::Timeless.timeless(note, &:save)
- end
+ active_diff_discussions.each do |discussion|
+ service.execute(discussion)
end
end
@@ -858,32 +917,6 @@ class MergeRequest < ActiveRecord::Base
project.repository.keep_around(self.merge_commit_sha)
end
- def conflicts
- @conflicts ||= Gitlab::Conflict::FileCollection.new(self)
- end
-
- def conflicts_can_be_resolved_by?(user)
- access = ::Gitlab::UserAccess.new(user, project: source_project)
- access.can_push_to_branch?(source_branch)
- end
-
- def conflicts_can_be_resolved_in_ui?
- return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
-
- return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
- return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
-
- begin
- # Try to parse each conflict. If the MR's mergeable status hasn't been updated,
- # ensure that we don't say there are conflicts to resolve when there are no conflict
- # files.
- conflicts.files.each(&:lines)
- @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
- rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing
- @conflicts_can_be_resolved_in_ui = false
- end
- end
-
def has_commits?
merge_request_diff && commits_count > 0
end
@@ -892,7 +925,7 @@ class MergeRequest < ActiveRecord::Base
!has_commits?
end
- def mergeable_with_slash_command?(current_user, autocomplete_precheck: false, last_diff_sha: nil)
+ def mergeable_with_quick_action?(current_user, autocomplete_precheck: false, last_diff_sha: nil)
return false unless can_be_merged_by?(current_user)
return true if autocomplete_precheck
@@ -903,4 +936,17 @@ class MergeRequest < ActiveRecord::Base
true
end
+
+ private
+
+ def write_ref
+ target_project.repository.with_repo_branch_commit(
+ source_project.repository, source_branch) do |commit|
+ if commit
+ target_project.repository.write_ref(ref_path, commit.sha)
+ else
+ raise Rugged::ReferenceError, 'source repository is empty'
+ end
+ end
+ end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index f0a3c30ea74..58050e1f438 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,7 +1,7 @@
class MergeRequestDiff < ActiveRecord::Base
include Sortable
include Importable
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
# Prevent store of diff if commits amount more then 500
COMMITS_SAFE_SIZE = 100
@@ -10,9 +10,11 @@ class MergeRequestDiff < ActiveRecord::Base
VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta].freeze
belongs_to :merge_request
+ has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
- serialize :st_commits
- serialize :st_diffs
+ serialize :st_commits # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :st_diffs # rubocop:disable Cop/ActiveRecordSerialize
state_machine :state, initial: :empty do
state :collected
@@ -46,14 +48,13 @@ class MergeRequestDiff < ActiveRecord::Base
# Collect information about commits and diff from repository
# and save it to the database as serialized data
def save_git_content
- ensure_commits_sha
+ ensure_commit_shas
save_commits
- reload_commits
save_diffs
keep_around_commits
end
- def ensure_commits_sha
+ def ensure_commit_shas
merge_request.fetch_ref
self.start_commit_sha ||= merge_request.target_branch_sha
self.head_commit_sha ||= merge_request.source_branch_sha
@@ -65,7 +66,7 @@ class MergeRequestDiff < ActiveRecord::Base
# created before version 8.4 that does not store head_commit_sha in separate db field.
def head_commit_sha
if persisted? && super.nil?
- last_commit.try(:sha)
+ last_commit_sha
else
super
end
@@ -84,28 +85,19 @@ class MergeRequestDiff < ActiveRecord::Base
def raw_diffs(options = {})
if options[:ignore_whitespace_change]
- @diffs_no_whitespace ||=
- Gitlab::Git::Compare.new(
- repository.raw_repository,
- safe_start_commit_sha,
- head_commit_sha).diffs(options)
+ @diffs_no_whitespace ||= compare.diffs(options)
else
@raw_diffs ||= {}
- @raw_diffs[options] ||= load_diffs(st_diffs, options)
+ @raw_diffs[options] ||= load_diffs(options)
end
end
def commits
- @commits ||= load_commits(st_commits)
+ @commits ||= load_commits
end
- def reload_commits
- @commits = nil
- commits
- end
-
- def last_commit
- commits.first
+ def last_commit_sha
+ commit_shas.first
end
def first_commit
@@ -130,8 +122,12 @@ class MergeRequestDiff < ActiveRecord::Base
project.commit(head_commit_sha)
end
- def commits_sha
- st_commits.map { |commit| commit[:id] }
+ def commit_shas
+ if st_commits.present?
+ st_commits.map { |commit| commit[:id] }
+ else
+ merge_request_diff_commits.map(&:sha)
+ end
end
def diff_refs=(new_diff_refs)
@@ -150,6 +146,29 @@ class MergeRequestDiff < ActiveRecord::Base
)
end
+ # MRs created before 8.4 don't store their true diff refs (start and base),
+ # but we need to get a commit SHA for the "View file @ ..." link by a file,
+ # so we use an approximation of the diff refs if we can't get the actual one.
+ #
+ # These will not be the actual diff refs if the target branch was merged into
+ # the source branch after the merge request was created, but it is good enough
+ # for the specific purpose of linking to a commit.
+ #
+ # It is not good enough for highlighting diffs, so we can't simply pass
+ # these as `diff_refs.`
+ def fallback_diff_refs
+ real_refs = diff_refs
+ return real_refs if real_refs
+
+ likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha
+
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: likely_base_commit_sha,
+ start_sha: safe_start_commit_sha,
+ head_sha: head_commit_sha
+ )
+ end
+
def diff_refs_by_sha?
base_commit_sha? && head_commit_sha? && start_commit_sha?
end
@@ -175,16 +194,19 @@ class MergeRequestDiff < ActiveRecord::Base
self == merge_request.merge_request_diff
end
- def compare_with(sha, straight: true)
+ def compare_with(sha)
# When compare merge request versions we want diff A..B instead of A...B
# so we handle cases when user does squash and rebase of the commits between versions.
# For this reason we set straight to true by default.
- CompareService.new(project, head_commit_sha)
- .execute(project, sha, straight: straight)
+ CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
end
def commits_count
- st_commits.count
+ if st_commits.present?
+ st_commits.size
+ else
+ merge_request_diff_commits.size
+ end
end
def utf8_st_diffs
@@ -208,66 +230,75 @@ class MergeRequestDiff < ActiveRecord::Base
raw.any? { |element| VALID_CLASSES.include?(element.class) }
end
- def dump_commits(commits)
- commits.map(&:to_hash)
- end
+ def create_merge_request_diff_files(diffs)
+ rows = diffs.map.with_index do |diff, index|
+ diff_hash = diff.to_hash.merge(
+ binary: false,
+ merge_request_diff_id: self.id,
+ relative_order: index
+ )
+
+ # Compatibility with old diffs created with Psych.
+ diff_hash.tap do |hash|
+ diff_text = hash[:diff]
+
+ if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?
+ hash[:binary] = true
+ hash[:diff] = [diff_text].pack('m0')
+ end
+ end
+ end
- def load_commits(array)
- array.map { |hash| Commit.new(Gitlab::Git::Commit.new(hash), merge_request.source_project) }
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
end
- # Load all commits related to current merge request diff from repo
- # and save it as array of hashes in st_commits db field
- def save_commits
- new_attributes = {}
+ def load_diffs(options)
+ return Gitlab::Git::DiffCollection.new([]) unless diffs_from_database
- commits = compare.commits
+ raw = diffs_from_database
- if commits.present?
- commits = Commit.decorate(commits, merge_request.source_project).reverse
- new_attributes[:st_commits] = dump_commits(commits)
+ if paths = options[:paths]
+ raw = raw.select do |diff|
+ paths.include?(diff[:old_path]) || paths.include?(diff[:new_path])
+ end
end
- update_columns_serialized(new_attributes)
+ Gitlab::Git::DiffCollection.new(raw, options)
end
- def dump_diffs(diffs)
- if diffs.respond_to?(:map)
- diffs.map(&:to_hash)
- end
- end
+ def diffs_from_database
+ return @diffs_from_database if defined?(@diffs_from_database)
- def load_diffs(raw, options)
- if valid_raw_diff?(raw)
- if paths = options[:paths]
- raw = raw.select do |diff|
- paths.include?(diff[:old_path]) || paths.include?(diff[:new_path])
+ @diffs_from_database =
+ if st_diffs.present?
+ if valid_raw_diff?(st_diffs)
+ st_diffs
end
+ elsif merge_request_diff_files.present?
+ merge_request_diff_files.map(&:to_hash)
end
+ end
- Gitlab::Git::DiffCollection.new(raw, options)
- else
- Gitlab::Git::DiffCollection.new([])
- end
+ def load_commits
+ commits = st_commits.presence || merge_request_diff_commits
+
+ commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
end
- # Load diffs between branches related to current merge request diff from repo
- # and save it as array of hashes in st_diffs db field
def save_diffs
new_attributes = {}
- if commits.size.zero?
+ if compare.commits.size.zero?
new_attributes[:state] = :empty
else
diff_collection = compare.diffs(Commit.max_diff_options)
new_attributes[:real_size] = diff_collection.real_size
if diff_collection.any?
- new_diffs = dump_diffs(diff_collection)
new_attributes[:state] = :collected
- end
- new_attributes[:st_diffs] = new_diffs || []
+ create_merge_request_diff_files(diff_collection)
+ end
# Set our state to 'overflow' to make the #empty? and #collected?
# methods (generated by StateMachine) return false.
@@ -277,7 +308,13 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :overflow if diff_collection.overflow?
end
- update_columns_serialized(new_attributes)
+ update(new_attributes)
+ end
+
+ def save_commits
+ MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
+
+ merge_request_diff_commits.reload
end
def repository
@@ -290,29 +327,6 @@ class MergeRequestDiff < ActiveRecord::Base
project.merge_base_commit(head_commit_sha, start_commit_sha).try(:sha)
end
- #
- # #save or #update_attributes providing changes on serialized attributes do a lot of
- # serialization and deserialization calls resulting in bad performance.
- # Using #update_columns solves the problem with just one YAML.dump per serialized attribute that we provide.
- # As a tradeoff we need to reload the current instance to properly manage time objects on those serialized
- # attributes. So to keep the same behaviour as the attribute assignment we reload the instance.
- # The difference is in the usage of
- # #write_attribute= (#update_attributes) and #raw_write_attribute= (#update_columns)
- #
- # Ex:
- #
- # new_attributes[:st_commits].first.slice(:committed_date)
- # => {:committed_date=>2014-02-27 11:01:38 +0200}
- # YAML.load(YAML.dump(new_attributes[:st_commits].first.slice(:committed_date)))
- # => {:committed_date=>2014-02-27 10:01:38 +0100}
- #
- def update_columns_serialized(new_attributes)
- return unless new_attributes.any?
-
- update_columns(new_attributes.merge(updated_at: current_time_from_proper_timezone))
- reload
- end
-
def keep_around_commits
[repository, merge_request.source_project.repository].each do |repo|
repo.keep_around(start_commit_sha)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
new file mode 100644
index 00000000000..670b26d4ca3
--- /dev/null
+++ b/app/models/merge_request_diff_commit.rb
@@ -0,0 +1,38 @@
+class MergeRequestDiffCommit < ActiveRecord::Base
+ include ShaAttribute
+
+ belongs_to :merge_request_diff
+
+ sha_attribute :sha
+ alias_attribute :id, :sha
+
+ def self.create_bulk(merge_request_diff_id, commits)
+ sha_attribute = Gitlab::Database::ShaAttribute.new
+
+ rows = commits.map.with_index do |commit, index|
+ # See #parent_ids.
+ commit_hash = commit.to_hash.except(:parent_ids)
+ sha = commit_hash.delete(:id)
+
+ commit_hash.merge(
+ merge_request_diff_id: merge_request_diff_id,
+ relative_order: index,
+ sha: sha_attribute.type_cast_for_database(sha)
+ )
+ end
+
+ Gitlab::Database.bulk_insert(self.table_name, rows)
+ end
+
+ def to_hash
+ Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash|
+ hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ # We don't save these, because they would need a table or a serialised
+ # field. They aren't used anywhere, so just pretend the commit has no parents.
+ def parent_ids
+ []
+ end
+end
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
new file mode 100644
index 00000000000..1199ff5af22
--- /dev/null
+++ b/app/models/merge_request_diff_file.rb
@@ -0,0 +1,21 @@
+class MergeRequestDiffFile < ActiveRecord::Base
+ include Gitlab::EncodingHelper
+
+ belongs_to :merge_request_diff
+
+ def utf8_diff
+ return '' if diff.blank?
+
+ encode_utf8(diff) if diff.respond_to?(:encoding)
+ end
+
+ def diff
+ binary? ? super.unpack('m0').first : super
+ end
+
+ def to_hash
+ keys = Gitlab::Git::Diff::SERIALIZE_KEYS - [:diff]
+
+ as_json(only: keys).merge(diff: diff).with_indifferent_access
+ end
+end
diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb
index daafb137be4..7f7c114803d 100644
--- a/app/models/merge_requests_closing_issues.rb
+++ b/app/models/merge_requests_closing_issues.rb
@@ -7,9 +7,9 @@ class MergeRequestsClosingIssues < ActiveRecord::Base
class << self
def count_for_collection(ids)
- group(:issue_id).
- where(issue_id: ids).
- pluck('issue_id', 'COUNT(*) as count')
+ group(:issue_id)
+ .where(issue_id: ids)
+ .pluck('issue_id', 'COUNT(*) as count')
end
end
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 652b1551928..01e0d0155a3 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -18,18 +18,32 @@ class Milestone < ActiveRecord::Base
cache_markdown_field :description
belongs_to :project
+ belongs_to :group
+
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
- has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
- has_many :events, as: :target, dependent: :destroy
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
- scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :for_projects, -> { where(group: nil).includes(:project) }
+
+ scope :for_projects_and_groups, -> (project_ids, group_ids) do
+ conditions = []
+ conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
+ conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?
- validates :title, presence: true, uniqueness: { scope: :project_id }
- validates :project, presence: true
+ where(conditions.reduce(:or))
+ end
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
strip_attributes :title
@@ -64,6 +78,14 @@ class Milestone < ActiveRecord::Base
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
end
+
+ def filter_by_state(milestones, state)
+ case state
+ when 'closed' then milestones.closed
+ when 'all' then milestones
+ else milestones.active
+ end
+ end
end
def self.reference_prefix
@@ -99,14 +121,18 @@ class Milestone < ActiveRecord::Base
if Gitlab::Database.postgresql?
rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
else
- rel.
- group(:project_id).
- having('due_date = MIN(due_date)').
- pluck(:id, :project_id, :due_date).
- map(&:first)
+ rel
+ .group(:project_id)
+ .having('due_date = MIN(due_date)')
+ .pluck(:id, :project_id, :due_date)
+ .map(&:first)
end
end
+ def participants
+ User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
+ end
+
def self.sort(method)
case method.to_s
when 'due_date_asc'
@@ -123,7 +149,9 @@ class Milestone < ActiveRecord::Base
end
##
- # Returns the String necessary to reference this Milestone in Markdown
+ # Returns the String necessary to reference this Milestone in Markdown. Group
+ # milestones only support name references, and do not support cross-project
+ # references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
@@ -135,10 +163,16 @@ class Milestone < ActiveRecord::Base
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1"
#
def to_reference(from_project = nil, format: :iid, full: false)
+ return if is_group_milestone? && format != :name
+
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
- "#{project.to_reference(from_project, full: full)}#{reference}"
+ if project
+ "#{project.to_reference(from_project, full: full)}#{reference}"
+ else
+ reference
+ end
end
def reference_link_text(from_project = nil)
@@ -149,6 +183,10 @@ class Milestone < ActiveRecord::Base
id
end
+ def for_display
+ self
+ end
+
def can_be_closed?
active? && issues.opened.count.zero?
end
@@ -161,40 +199,45 @@ class Milestone < ActiveRecord::Base
write_attribute(:title, sanitize_title(value)) if value.present?
end
- # Sorts the issues for the given IDs.
- #
- # This method runs a single SQL query using a CASE statement to update the
- # position of all issues in the current milestone (scoped to the list of IDs).
- #
- # Given the ids [10, 20, 30] this method produces a SQL query something like
- # the following:
- #
- # UPDATE issues
- # SET position = CASE
- # WHEN id = 10 THEN 1
- # WHEN id = 20 THEN 2
- # WHEN id = 30 THEN 3
- # ELSE position
- # END
- # WHERE id IN (10, 20, 30);
- #
- # This method expects that the IDs given in `ids` are already Fixnums.
- def sort_issues(ids)
- pairs = []
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
- ids.each_with_index do |id, index|
- pairs << id
- pairs << index + 1
- end
+ def parent
+ group || project
+ end
- conditions = 'WHEN id = ? THEN ? ' * ids.length
+ def is_group_milestone?
+ group_id.present?
+ end
- issues.where(id: ids).
- update_all(["position = CASE #{conditions} ELSE position END", *pairs])
+ def is_project_milestone?
+ project_id.present?
end
private
+ # Milestone titles must be unique across project milestones and group milestones
+ def uniqueness_of_title
+ if project
+ relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
+ elsif group
+ project_ids = group.projects.map(&:id)
+ relation = Milestone.for_projects_and_groups(project_ids, [group.id])
+ end
+
+ title_exists = relation.find_by_title(title)
+ errors.add(:title, "already being used for another group or project milestone.") if title_exists
+ end
+
+ # Milestone should be either a project milestone or a group milestone
+ def milestone_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, "milestone should belong either to a project or a group.")
+ end
+ end
+
def milestone_format_reference(format = :iid)
raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9bfa731785f..e7bc1d1b080 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,11 +1,14 @@
class Namespace < ActiveRecord::Base
- acts_as_paranoid
+ acts_as_paranoid without_default_scope: true
include CacheMarkdownField
include Sortable
include Gitlab::ShellAdapter
include Gitlab::CurrentSettings
+ include Gitlab::VisibilityLevel
include Routable
+ include AfterCommitQueue
+ include Storage::LegacyNamespace
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -14,13 +17,13 @@ class Namespace < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
- has_many :projects, dependent: :destroy
+ has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
belongs_to :owner, class_name: "User"
belongs_to :parent, class_name: "Namespace"
has_many :children, class_name: "Namespace", foreign_key: :parent_id
- has_one :chat_team, dependent: :destroy
+ has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
@@ -33,20 +36,21 @@ class Namespace < ActiveRecord::Base
validates :path,
presence: true,
length: { maximum: 255 },
- namespace: true
+ dynamic_path: true
validate :nesting_level_allowed
delegate :name, to: :owner, allow_nil: true, prefix: true
- after_update :move_dir, if: :path_changed?
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
- # Save the storage paths before the projects are destroyed to use them on after destroy
+ # Legacy Storage specific hooks
+
+ after_update :move_dir, if: :path_changed?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
- scope :root, -> { where('type IS NULL') }
+ scope :for_user, -> { where('type IS NULL') }
scope :with_statistics, -> do
joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id')
@@ -56,7 +60,7 @@ class Namespace < ActiveRecord::Base
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
- 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size'
)
end
@@ -104,6 +108,10 @@ class Namespace < ActiveRecord::Base
end
end
+ def visibility_level_field
+ :visibility_level
+ end
+
def to_param
full_path
end
@@ -112,43 +120,6 @@ class Namespace < ActiveRecord::Base
owner_name
end
- def move_dir
- if any_project_has_container_registry_tags?
- raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
- end
-
- # Move the namespace directory in all storages paths used by member projects
- repository_storage_paths.each do |repository_storage_path|
- # Ensure old directory exists before moving it
- gitlab_shell.add_namespace(repository_storage_path, full_path_was)
-
- unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
- Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
-
- # if we cannot move namespace directory we should rollback
- # db changes in order to prevent out of sync between db and fs
- raise Gitlab::UpdatePathError.new('namespace directory cannot be moved')
- end
- end
-
- Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
- Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
-
- remove_exports!
-
- # If repositories moved successfully we need to
- # send update instructions to users.
- # However we cannot allow rollback since we moved namespace dir
- # So we basically we mute exceptions in next actions
- begin
- send_update_instructions
- rescue
- # Returning false does not rollback after_* transaction but gives
- # us information about failing some of tasks
- false
- end
- end
-
def any_project_has_container_registry_tags?
all_projects.any?(&:has_container_registry_tags?)
end
@@ -176,26 +147,34 @@ class Namespace < ActiveRecord::Base
projects.with_shared_runners.any?
end
- # Scopes the model on ancestors of the record
+ # Returns all the ancestors of the current namespaces.
def ancestors
- if parent_id
- path = route ? route.path : full_path
- paths = []
-
- until path.blank?
- path = path.rpartition('/').first
- paths << path
- end
-
- self.class.joins(:route).where('routes.path IN (?)', paths).reorder('routes.path ASC')
- else
- self.class.none
- end
+ return self.class.none unless parent_id
+
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: parent_id))
+ .base_and_ancestors
end
- # Scopes the model on direct and indirect children of the record
+ def self_and_ancestors
+ return self.class.where(id: id) unless parent_id
+
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: id))
+ .base_and_ancestors
+ end
+
+ # Returns all the descendants of the current namespace.
def descendants
- self.class.joins(:route).merge(Route.inside_path(route.path)).reorder('routes.path ASC')
+ Gitlab::GroupHierarchy
+ .new(self.class.where(parent_id: id))
+ .base_and_descendants
+ end
+
+ def self_and_descendants
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: id))
+ .base_and_descendants
end
def user_ids_for_project_authorizations
@@ -206,72 +185,29 @@ class Namespace < ActiveRecord::Base
parent_id_changed?
end
- def prepare_for_destroy
- old_repository_storage_paths
- end
-
- def old_repository_storage_paths
- @old_repository_storage_paths ||= repository_storage_paths
- end
-
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
Project.inside_path(full_path)
end
- private
-
- def repository_storage_paths
- # We need to get the storage paths for all the projects, even the ones that are
- # pending delete. Unscoping also get rids of the default order, which causes
- # problems with SELECT DISTINCT.
- Project.unscoped do
- all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path)
- end
+ def has_parent?
+ parent.present?
end
- def rm_dir
- # Remove the namespace directory in all storages paths used by member projects
- old_repository_storage_paths.each do |repository_storage_path|
- # Move namespace directory into trash.
- # We will remove it later async
- new_path = "#{full_path}+#{id}+deleted"
-
- if gitlab_shell.mv_namespace(repository_storage_path, full_path, new_path)
- message = "Namespace directory \"#{full_path}\" moved to \"#{new_path}\""
- Gitlab::AppLogger.info message
-
- # Remove namespace directroy async with delay so
- # GitLab has time to remove all projects first
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
- end
- end
-
- remove_exports!
+ def soft_delete_without_removing_associations
+ # We can't use paranoia's `#destroy` since this will hard-delete projects.
+ # Project uses `pending_delete` instead of the acts_as_paranoia gem.
+ self.deleted_at = Time.now
end
- def refresh_access_of_projects_invited_groups
- Group.
- joins(project_group_links: :project).
- where(projects: { namespace_id: id }).
- find_each(&:refresh_members_authorized_projects)
- end
-
- def remove_exports!
- Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
- end
-
- def export_path
- File.join(Gitlab::ImportExport.storage_path, full_path_was)
- end
+ private
- def full_path_was
- if parent
- parent.full_path + '/' + path_was
- else
- path_was
- end
+ def refresh_access_of_projects_invited_groups
+ Group
+ .joins(project_group_links: :project)
+ .where(projects: { namespace_id: id })
+ .find_each(&:refresh_members_authorized_projects)
end
def nesting_level_allowed
diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb
index 8417f200e36..9357e55b419 100644
--- a/app/models/network/commit.rb
+++ b/app/models/network/commit.rb
@@ -12,7 +12,7 @@ module Network
end
def method_missing(m, *args, &block)
- @commit.send(m, *args, &block)
+ @commit.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def space
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index 59737bb6085..0e5acb22d50 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -113,7 +113,7 @@ module Network
opts[:ref] = @commit.id if @filter_ref
- @repo.find_commits(opts)
+ Gitlab::Git::Commit.find_all(@repo.raw_repository, opts)
end
def commits_sort_by_ref
@@ -206,7 +206,7 @@ module Network
# Visit branching chains
leaves.each do |l|
- parents = l.parents(@map).select{|p| p.space.zero?}
+ parents = l.parents(@map).select {|p| p.space.zero?}
parents.each do |p|
place_chain(p, l.time)
end
diff --git a/app/models/note.rb b/app/models/note.rb
index e720bfba030..a752c897d63 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -13,11 +13,17 @@ class Note < ActiveRecord::Base
include AfterCommitQueue
include ResolvableNote
include IgnorableColumn
+ include Editable
ignore_column :original_discussion_id
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
+ # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102
+ alias_attribute :last_edited_at, :updated_at
+ alias_attribute :last_edited_by, :updated_by
+
# Attribute containing rendered and redacted Markdown as generated by
# Banzai::ObjectRenderer.
attr_accessor :redacted_note_html
@@ -26,7 +32,7 @@ class Note < ActiveRecord::Base
# Banzai::ObjectRenderer
attr_accessor :user_visible_reference_count
- # Attribute used to store the attributes that have ben changed by slash commands.
+ # Attribute used to store the attributes that have ben changed by quick actions.
attr_accessor :commands_changes
default_value_for :system, false
@@ -35,12 +41,13 @@ class Note < ActiveRecord::Base
participant :author
belongs_to :project
- belongs_to :noteable, polymorphic: true, touch: true
+ belongs_to :noteable, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
+ belongs_to :last_edited_by, class_name: 'User'
- has_many :todos, dependent: :destroy
- has_many :events, as: :target, dependent: :destroy
+ has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
delegate :gfm_reference, :local_reference, to: :noteable
@@ -70,20 +77,20 @@ class Note < ActiveRecord::Base
# Scopes
scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
- scope :system, ->{ where(system: true) }
- 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 :system, -> { where(system: true) }
+ 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
includes(:project, :author, :updated_by, :resolved_by, :award_emoji, :system_note_metadata)
end
- scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) }
- scope :new_diff_notes, ->{ where(type: 'DiffNote') }
- scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) }
+ scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
+ 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
@@ -104,7 +111,7 @@ class Note < ActiveRecord::Base
end
def discussions(context_noteable = nil)
- Discussion.build_collection(fresh, context_noteable)
+ Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
end
def find_discussion(discussion_id)
@@ -115,17 +122,24 @@ class Note < ActiveRecord::Base
end
def grouped_diff_discussions(diff_refs = nil)
- diff_notes.
- fresh.
- discussions.
- select { |n| n.active?(diff_refs) }.
- group_by(&:line_code)
+ groups = {}
+
+ diff_notes.fresh.discussions.each do |discussion|
+ line_code = discussion.line_code_in_diffs(diff_refs)
+
+ if line_code
+ discussions = groups[line_code] ||= []
+ discussions << discussion
+ end
+ end
+
+ groups
end
def count_for_collection(ids, type)
- user.select('noteable_id', 'COUNT(*) as count').
- group(:noteable_id).
- where(noteable_type: type, noteable_id: ids)
+ user.select('noteable_id', 'COUNT(*) as count')
+ .group(:noteable_id)
+ .where(noteable_type: type, noteable_id: ids)
end
end
@@ -141,10 +155,6 @@ class Note < ActiveRecord::Base
true
end
- def latest_merge_request_diff
- nil
- end
-
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
@@ -180,7 +190,7 @@ class Note < ActiveRecord::Base
# override to return commits, which are not active record
def noteable
if for_commit?
- project.commit(commit_id)
+ @commit ||= project.commit(commit_id)
else
super
end
@@ -320,8 +330,7 @@ class Note < ActiveRecord::Base
def expire_etag_cache
return unless for_issue?
- key = Gitlab::Routing.url_helpers.namespace_project_noteable_notes_path(
- noteable.project.namespace,
+ key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
noteable.project,
target_type: noteable_type.underscore,
target_id: noteable.id
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
new file mode 100644
index 00000000000..dc862565a71
--- /dev/null
+++ b/app/models/notification_recipient.rb
@@ -0,0 +1,142 @@
+class NotificationRecipient
+ attr_reader :user, :type
+ def initialize(
+ user, type,
+ custom_action: nil,
+ target: nil,
+ acting_user: nil,
+ project: nil,
+ group: nil,
+ skip_read_ability: false
+ )
+ unless NotificationSetting.levels.key?(type) || type == :subscription
+ raise ArgumentError, "invalid type: #{type.inspect}"
+ end
+
+ @custom_action = custom_action
+ @acting_user = acting_user
+ @target = target
+ @project = project || default_project
+ @group = group || @project&.group
+ @user = user
+ @type = type
+ @skip_read_ability = skip_read_ability
+ end
+
+ def notification_setting
+ @notification_setting ||= find_notification_setting
+ end
+
+ def raw_notification_level
+ notification_setting&.level&.to_sym
+ end
+
+ def notification_level
+ # custom is treated the same as watch if it's enabled - otherwise it's
+ # set to :custom, meaning to send exactly when our type is :participating
+ # or :mention.
+ @notification_level ||=
+ case raw_notification_level
+ when :custom
+ if @custom_action && notification_setting&.event_enabled?(@custom_action)
+ :watch
+ else
+ :custom
+ end
+ else
+ raw_notification_level
+ end
+ end
+
+ def notifiable?
+ return false unless has_access?
+ return false if own_activity?
+
+ return true if @type == :subscription
+
+ return false if notification_level.nil? || notification_level == :disabled
+
+ return %i[participating mention].include?(@type) if notification_level == :custom
+
+ return false if %i[watch participating].include?(notification_level) && excluded_watcher_action?
+
+ return false unless NotificationSetting.levels[notification_level] <= NotificationSetting.levels[@type]
+
+ return false if unsubscribed?
+
+ true
+ end
+
+ def unsubscribed?
+ return false unless @target
+ return false unless @target.respond_to?(:subscriptions)
+
+ subscription = @target.subscriptions.find_by_user_id(@user.id)
+ subscription && !subscription.subscribed
+ end
+
+ def own_activity?
+ return false unless @acting_user
+ return false if @acting_user.notified_of_own_activity?
+
+ user == @acting_user
+ end
+
+ def has_access?
+ DeclarativePolicy.subject_scope do
+ return false unless user.can?(:receive_notifications)
+ return true if @skip_read_ability
+
+ return false if @project && !user.can?(:read_project, @project)
+
+ return true unless read_ability
+ return true unless DeclarativePolicy.has_policy?(@target)
+
+ user.can?(read_ability, @target)
+ end
+ end
+
+ def excluded_watcher_action?
+ return false unless @custom_action
+ return false if raw_notification_level == :custom
+
+ NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action)
+ end
+
+ private
+
+ def read_ability
+ return nil if @skip_read_ability
+ return @read_ability if instance_variable_defined?(:@read_ability)
+
+ @read_ability =
+ case @target
+ when Issuable
+ :"read_#{@target.to_ability_name}"
+ when Ci::Pipeline
+ :read_build # We have build trace in pipeline emails
+ when ActiveRecord::Base
+ :"read_#{@target.class.model_name.name.underscore}"
+ else
+ nil
+ end
+ end
+
+ def default_project
+ return nil if @target.nil?
+ return @target if @target.is_a?(Project)
+ return @target.project if @target.respond_to?(:project)
+ end
+
+ def find_notification_setting
+ project_setting = @project && user.notification_settings_for(@project)
+
+ return project_setting unless project_setting.nil? || project_setting.global?
+
+ group_setting = @group && user.notification_settings_for(@group)
+
+ return group_setting unless group_setting.nil? || group_setting.global?
+
+ user.global_notification_setting
+ end
+end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index e4726e62e93..245f8dddcf9 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -1,10 +1,14 @@
class NotificationSetting < ActiveRecord::Base
+ include IgnorableColumn
+
+ ignore_column :events
+
enum level: { global: 3, watch: 2, mention: 4, participating: 1, disabled: 0, custom: 5 }
default_value_for :level, NotificationSetting.levels[:global]
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :project, foreign_key: 'source_id'
validates :user, presence: true
@@ -19,7 +23,7 @@ class NotificationSetting < ActiveRecord::Base
# pending delete).
#
scope :for_projects, -> do
- includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil })
+ includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil, pending_delete: true })
end
EMAIL_EVENTS = [
@@ -41,11 +45,6 @@ class NotificationSetting < ActiveRecord::Base
:success_pipeline
].freeze
- store :events, accessors: EMAIL_EVENTS, coder: JSON
-
- before_create :set_events
- before_save :events_to_boolean
-
def self.find_or_create_for(source)
setting = find_or_initialize_by(source: source)
@@ -56,23 +55,6 @@ class NotificationSetting < ActiveRecord::Base
setting
end
- # Set all event attributes to false when level is not custom or being initialized for UX reasons
- def set_events
- return if custom?
-
- self.events = {}
- end
-
- # Validates store accessors values as boolean
- # It is a text field so it does not cast correct boolean values in JSON
- def events_to_boolean
- EMAIL_EVENTS.each do |event|
- bool = ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(public_send(event))
-
- events[event] = bool
- end
- end
-
# Allow people to receive failed pipeline notifications if they already have
# custom notifications enabled, as these are more like mentions than the other
# custom settings.
@@ -81,4 +63,9 @@ class NotificationSetting < ActiveRecord::Base
bool.nil? || bool
end
+ alias_method :failed_pipeline?, :failed_pipeline
+
+ def event_enabled?(event)
+ respond_to?(event) && !!public_send(event) # rubocop:disable GitlabSecurity/PublicSend
+ end
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index f2f2fc1e32a..5d798247863 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,7 +1,7 @@
class PagesDomain < ActiveRecord::Base
belongs_to :project
- validates :domain, hostname: true
+ validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, certificate: true, allow_nil: true, allow_blank: true
validates :key, certificate_key: true, allow_nil: true, allow_blank: true
@@ -98,7 +98,7 @@ class PagesDomain < ActiveRecord::Base
def validate_pages_domain
return unless domain
- if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase)
+ if domain.downcase.ends_with?(Settings.pages.host.downcase)
self.errors.add(:domain, "*.#{Settings.pages.host} is restricted")
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index e8b000ddad6..654be927ed8 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable
add_authentication_token_field :token
- serialize :scopes, Array
+ serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user
@@ -15,11 +15,10 @@ class PersonalAccessToken < ActiveRecord::Base
scope :without_impersonation, -> { where(impersonation: false) }
validates :scopes, presence: true
- validate :validate_api_scopes
+ validate :validate_scopes
def revoke!
- self.revoked = true
- self.save
+ update!(revoked: true)
end
def active?
@@ -28,9 +27,9 @@ class PersonalAccessToken < ActiveRecord::Base
protected
- def validate_api_scopes
- unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) }
- errors.add :scopes, "can only contain API scopes"
+ def validate_scopes
+ unless scopes.all? { |scope| Gitlab::Auth::AVAILABLE_SCOPES.include?(scope.to_sym) }
+ errors.add :scopes, "can only contain available scopes"
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index c7dc562c238..be248bc99e1 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -6,6 +6,7 @@ class Project < ActiveRecord::Base
include Gitlab::VisibilityLevel
include Gitlab::CurrentSettings
include AccessRequestable
+ include Avatarable
include CacheMarkdownField
include Referable
include Sortable
@@ -16,6 +17,7 @@ class Project < ActiveRecord::Base
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
include Routable
+ include Storage::LegacyProject
extend Gitlab::ConfigHelper
@@ -42,9 +44,8 @@ class Project < ActiveRecord::Base
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
- after_create :ensure_dir_exist
+ after_create :ensure_storage_path_exist
after_create :create_project_feature, unless: :project_feature
- after_save :ensure_dir_exist, if: :namespace_id_changed?
after_save :update_project_statistics, if: :namespace_id_changed?
# set last_activity_at to the same as created_at
@@ -53,27 +54,28 @@ class Project < ActiveRecord::Base
update_column(:last_activity_at, self.created_at)
end
- after_destroy :remove_pages
+ after_create :set_last_repository_updated_at
+ def set_last_repository_updated_at
+ update_column(:last_repository_updated_at, self.created_at)
+ end
+
+ before_destroy :remove_private_deploy_keys
+ after_destroy -> { run_after_commit { remove_pages } }
# update visibility_level of forks
after_update :update_forks_visibility_level
- def update_forks_visibility_level
- return unless visibility_level < visibility_level_was
-
- forks.each do |forked_project|
- if forked_project.visibility_level > visibility_level
- forked_project.visibility_level = visibility_level
- forked_project.save!
- end
- end
- end
after_validation :check_pending_delete
+ # Legacy Storage specific hooks
+
+ after_save :ensure_storage_path_exist, if: :namespace_id_changed?
+
acts_as_taggable
attr_accessor :new_default_branch
attr_accessor :old_path_with_namespace
+ attr_accessor :template_name
attr_writer :pipeline_status
alias_attribute :title, :name
@@ -84,103 +86,116 @@ class Project < ActiveRecord::Base
belongs_to :namespace
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
- has_many :boards, before_add: :validate_board_limit, dependent: :destroy
+ has_many :boards, before_add: :validate_board_limit
# Project services
- has_one :campfire_service, dependent: :destroy
- has_one :drone_ci_service, dependent: :destroy
- has_one :emails_on_push_service, dependent: :destroy
- has_one :pipelines_email_service, dependent: :destroy
- has_one :irker_service, dependent: :destroy
- has_one :pivotaltracker_service, dependent: :destroy
- has_one :hipchat_service, dependent: :destroy
- has_one :flowdock_service, dependent: :destroy
- has_one :assembla_service, dependent: :destroy
- has_one :asana_service, dependent: :destroy
- has_one :gemnasium_service, dependent: :destroy
- has_one :mattermost_slash_commands_service, dependent: :destroy
- has_one :mattermost_service, dependent: :destroy
- has_one :slack_slash_commands_service, dependent: :destroy
- has_one :slack_service, dependent: :destroy
- has_one :buildkite_service, dependent: :destroy
- has_one :bamboo_service, dependent: :destroy
- has_one :teamcity_service, dependent: :destroy
- has_one :pushover_service, dependent: :destroy
- has_one :jira_service, dependent: :destroy
- has_one :redmine_service, dependent: :destroy
- has_one :custom_issue_tracker_service, dependent: :destroy
- has_one :bugzilla_service, dependent: :destroy
- has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
- has_one :external_wiki_service, dependent: :destroy
- has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
- has_one :prometheus_service, dependent: :destroy, inverse_of: :project
- has_one :mock_ci_service, dependent: :destroy
- has_one :mock_deployment_service, dependent: :destroy
- has_one :mock_monitoring_service, dependent: :destroy
- has_one :microsoft_teams_service, dependent: :destroy
-
- has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
+ has_one :campfire_service
+ has_one :drone_ci_service
+ has_one :emails_on_push_service
+ has_one :pipelines_email_service
+ has_one :irker_service
+ has_one :pivotaltracker_service
+ has_one :hipchat_service
+ has_one :flowdock_service
+ has_one :assembla_service
+ has_one :asana_service
+ has_one :gemnasium_service
+ has_one :mattermost_slash_commands_service
+ has_one :mattermost_service
+ has_one :slack_slash_commands_service
+ has_one :slack_service
+ has_one :buildkite_service
+ has_one :bamboo_service
+ has_one :teamcity_service
+ has_one :pushover_service
+ has_one :jira_service
+ has_one :redmine_service
+ has_one :custom_issue_tracker_service
+ has_one :bugzilla_service
+ has_one :gitlab_issue_tracker_service, inverse_of: :project
+ has_one :external_wiki_service
+ has_one :kubernetes_service, inverse_of: :project
+ has_one :prometheus_service, inverse_of: :project
+ has_one :mock_ci_service
+ has_one :mock_deployment_service
+ has_one :mock_monitoring_service
+ has_one :microsoft_teams_service
+
+ has_one :forked_project_link, foreign_key: "forked_to_project_id"
has_one :forked_from_project, through: :forked_project_link
has_many :forked_project_links, foreign_key: "forked_from_project_id"
has_many :forks, through: :forked_project_links, source: :forked_to_project
# Merge Requests for target project should be removed with it
- has_many :merge_requests, dependent: :destroy, foreign_key: 'target_project_id'
- has_many :issues, dependent: :destroy
- has_many :labels, dependent: :destroy, class_name: 'ProjectLabel'
- has_many :services, dependent: :destroy
- has_many :events, dependent: :destroy
- has_many :milestones, dependent: :destroy
- has_many :notes, dependent: :destroy
- 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 :merge_requests, foreign_key: 'target_project_id'
+ has_many :issues
+ has_many :labels, class_name: 'ProjectLabel'
+ has_many :services
+ has_many :events
+ has_many :milestones
+ has_many :notes
+ has_many :snippets, class_name: 'ProjectSnippet'
+ has_many :hooks, class_name: 'ProjectHook'
+ has_many :protected_branches
+ has_many :protected_tags
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
- has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
+ has_many :project_members, -> { where(requested_at: nil) },
+ as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
alias_method :members, :project_members
has_many :users, through: :project_members
- has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'ProjectMember'
+ has_many :requesters, -> { where.not(requested_at: nil) },
+ as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- has_many :deploy_keys_projects, dependent: :destroy
+ has_many :deploy_keys_projects
has_many :deploy_keys, through: :deploy_keys_projects
- has_many :users_star_projects, dependent: :destroy
+ has_many :users_star_projects
has_many :starrers, through: :users_star_projects, source: :user
- has_many :releases, dependent: :destroy
- has_many :lfs_objects_projects, dependent: :destroy
+ has_many :releases
+ has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :lfs_objects, through: :lfs_objects_projects
- has_many :project_group_links, dependent: :destroy
+ has_many :project_group_links
has_many :invited_groups, through: :project_group_links, source: :group
- has_many :pages_domains, dependent: :destroy
- has_many :todos, dependent: :destroy
- has_many :notification_settings, dependent: :destroy, as: :source
-
- has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
- has_one :project_feature, dependent: :destroy
- has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
- has_many :container_repositories, dependent: :destroy
-
- has_many :commit_statuses, dependent: :destroy
- has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
- has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses
- has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject'
+ has_many :pages_domains
+ has_many :todos
+ has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
+ has_one :project_feature
+ has_one :statistics, class_name: 'ProjectStatistics'
+
+ # Container repositories need to remove data from the container registry,
+ # which is not managed by the DB. Hence we're still using dependent: :destroy
+ # here.
+ has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :commit_statuses
+ has_many :pipelines, class_name: 'Ci::Pipeline'
+
+ # Ci::Build objects store data on the file system such as artifact files and
+ # build traces. Currently there's no efficient way of removing this data in
+ # bulk that doesn't involve loading the rows into memory. As a result we're
+ # still using `dependent: :destroy` here.
+ has_many :builds, class_name: 'Ci::Build', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :runner_projects, class_name: 'Ci::RunnerProject'
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
- has_many :variables, dependent: :destroy, class_name: 'Ci::Variable'
- has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger'
- has_many :environments, dependent: :destroy
- has_many :deployments, dependent: :destroy
+ has_many :variables, class_name: 'Ci::Variable'
+ has_many :triggers, class_name: 'Ci::Trigger'
+ has_many :environments
+ has_many :deployments
+ has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature
+ accepts_nested_attributes_for :import_data
delegate :name, to: :owner, allow_nil: true, prefix: true
- delegate :count, to: :forks, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team
@@ -189,6 +204,11 @@ class Project < ActiveRecord::Base
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
+ validates :ci_config_path,
+ format: { without: /\.{2}/,
+ message: 'cannot include directory traversal.' },
+ length: { maximum: 255 },
+ allow_blank: true
validates :name,
presence: true,
length: { maximum: 255 },
@@ -196,13 +216,14 @@ class Project < ActiveRecord::Base
message: Gitlab::Regex.project_name_regex_message }
validates :path,
presence: true,
- project_path: true,
+ dynamic_path: true,
length: { maximum: 255 },
- format: { with: Gitlab::Regex.project_path_regex,
- message: Gitlab::Regex.project_path_regex_message }
+ format: { with: Gitlab::PathRegex.project_path_format_regex,
+ message: Gitlab::PathRegex.project_path_format_message },
+ uniqueness: { scope: :namespace_id }
+
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
- validates :path, uniqueness: { scope: :namespace_id }
validates :import_url, addressable_url: true, if: :external_import?
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
@@ -221,12 +242,11 @@ class Project < ActiveRecord::Base
before_save :ensure_runners_token
mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Scopes
- default_scope { where(pending_delete: false) }
-
- scope :with_deleted, -> { unscope(where: :pending_delete) }
+ scope :pending_delete, -> { where(pending_delete: true) }
+ scope :without_deleted, -> { where(pending_delete: false) }
scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
@@ -234,6 +254,7 @@ class Project < ActiveRecord::Base
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) }
+ scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) }
scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) }
scope :non_archived, -> { where(archived: false) }
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
@@ -245,8 +266,8 @@ class Project < ActiveRecord::Base
scope :inside_path, ->(path) do
# We need routes alias rs for JOIN so it does not conflict with
# includes(:route) which we use in ProjectsFinder.
- joins("INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'").
- where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%")
+ joins("INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'")
+ .where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%")
end
# "enabled" here means "not disabled". It includes private features!
@@ -263,23 +284,53 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
+ # Returns a collection of projects that is either public or visible to the
+ # logged in user.
+ def self.public_or_visible_to_user(user = nil)
+ if user
+ authorized = user
+ .project_authorizations
+ .select(1)
+ .where('project_authorizations.project_id = projects.id')
+
+ levels = Gitlab::VisibilityLevel.levels_for_user(user)
+
+ where('EXISTS (?) OR projects.visibility_level IN (?)', authorized, levels)
+ else
+ public_to_user
+ end
+ end
+
# 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.
+ #
+ # This method uses an optimised version of `with_feature_access_level` for
+ # logged in users to more efficiently get private projects with the given
+ # feature.
def self.with_feature_available_for_user(feature, user)
- return with_feature_enabled(feature) if user.try(:admin?)
+ visible = [nil, ProjectFeature::ENABLED]
- unconditional = with_feature_access_level(feature, [nil, ProjectFeature::ENABLED])
- return unconditional if user.nil?
+ if user&.admin?
+ with_feature_enabled(feature)
+ elsif user
+ column = ProjectFeature.quoted_access_level_column(feature)
- conditional = with_feature_access_level(feature, ProjectFeature::PRIVATE)
- authorized = user.authorized_projects.merge(conditional.reorder(nil))
+ authorized = user.project_authorizations.select(1)
+ .where('project_authorizations.project_id = projects.id')
- union = Gitlab::SQL::Union.new([unconditional.select(:id), authorized.select(:id)])
- where(arel_table[:id].in(Arel::Nodes::SqlLiteral.new(union.to_sql)))
+ with_project_feature
+ .where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
+ visible,
+ ProjectFeature::PRIVATE,
+ authorized)
+ else
+ with_feature_access_level(feature, visible)
+ end
end
scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') }
@@ -288,8 +339,16 @@ class Project < ActiveRecord::Base
scope :excluding_project, ->(project) { where.not(id: project) }
state_machine :import_status, initial: :none do
+ event :import_schedule do
+ transition [:none, :finished, :failed] => :scheduled
+ end
+
+ event :force_import_start do
+ transition [:none, :finished, :failed] => :started
+ end
+
event :import_start do
- transition [:none, :finished] => :started
+ transition scheduled: :started
end
event :import_finish do
@@ -297,18 +356,38 @@ class Project < ActiveRecord::Base
end
event :import_fail do
- transition started: :failed
+ transition [:scheduled, :started] => :failed
end
event :import_retry do
transition failed: :started
end
+ state :scheduled
state :started
state :finished
state :failed
- after_transition any => :finished, do: :reset_cache_and_import_attrs
+ after_transition [:none, :finished, :failed] => :scheduled do |project, _|
+ project.run_after_commit do
+ job_id = add_import_job
+ update(import_jid: job_id) if job_id
+ end
+ end
+
+ after_transition started: :finished do |project, _|
+ project.reset_cache_and_import_attrs
+
+ if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists?
+ project.run_after_commit do
+ begin
+ Projects::HousekeepingService.new(project).execute
+ rescue Projects::HousekeepingService::LeaseTaken => e
+ Rails.logger.info("Could not perform housekeeping for project #{project.full_path} (#{project.id}): #{e}")
+ end
+ end
+ end
+ end
end
class << self
@@ -327,22 +406,18 @@ class Project < ActiveRecord::Base
# unscoping unnecessary conditions that'll be applied
# when executing `where("projects.id IN (#{union.to_sql})")`
projects = unscoped.select(:id).where(
- ptable[:path].matches(pattern).
- or(ptable[:name].matches(pattern)).
- or(ptable[:description].matches(pattern))
+ ptable[:path].matches(pattern)
+ .or(ptable[:name].matches(pattern))
+ .or(ptable[:description].matches(pattern))
)
- namespaces = unscoped.select(:id).
- joins(:namespace).
- where(ntable[:name].matches(pattern))
+ namespaces = unscoped.select(:id)
+ .joins(:namespace)
+ .where(ntable[:name].matches(pattern))
union = Gitlab::SQL::Union.new([projects, namespaces])
- where("projects.id IN (#{union.to_sql})")
- end
-
- def search_by_visibility(level)
- where(visibility_level: Gitlab::VisibilityLevel.string_options[level])
+ where("projects.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
def search_by_title(query)
@@ -372,17 +447,15 @@ class Project < ActiveRecord::Base
end
def reference_pattern
- name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR
-
%r{
- ((?<namespace>#{name_pattern})\/)?
- (?<project>#{name_pattern})
+ ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
+ (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
}x
end
def trending
- joins('INNER JOIN trending_projects ON projects.id = trending_projects.project_id').
- reorder('trending_projects.id ASC')
+ joins('INNER JOIN trending_projects ON projects.id = trending_projects.project_id')
+ .reorder('trending_projects.id ASC')
end
def cached_count
@@ -411,17 +484,19 @@ class Project < ActiveRecord::Base
end
def repository
- @repository ||= Repository.new(path_with_namespace, self)
+ @repository ||= Repository.new(full_path, self, disk_path: disk_path)
end
def container_registry_url
if Gitlab.config.registry.enabled
- "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
+ "#{Gitlab.config.registry.host_port}/#{full_path.downcase}"
end
end
def has_container_registry_tags?
- container_repositories.to_a.any?(&:has_tags?) ||
+ return @images if defined?(@images)
+
+ @images = container_repositories.to_a.any?(&:has_tags?) ||
has_root_container_repository_tags?
end
@@ -452,24 +527,46 @@ class Project < ActiveRecord::Base
def add_import_job
job_id =
if forked?
- RepositoryForkWorker.perform_async(id, forked_from_project.repository_storage_path,
- forked_from_project.path_with_namespace,
- self.namespace.full_path)
+ RepositoryForkWorker.perform_async(id,
+ forked_from_project.repository_storage_path,
+ forked_from_project.full_path,
+ self.namespace.full_path)
else
RepositoryImportWorker.perform_async(self.id)
end
+ log_import_activity(job_id)
+
+ job_id
+ end
+
+ def log_import_activity(job_id, type: :import)
+ job_type = type.to_s.capitalize
+
if job_id
- Rails.logger.info "Import job started for #{path_with_namespace} with job ID #{job_id}"
+ Rails.logger.info("#{job_type} job scheduled for #{full_path} with job ID #{job_id}.")
else
- Rails.logger.error "Import job failed to start for #{path_with_namespace}"
+ Rails.logger.error("#{job_type} job failed to create for #{full_path}.")
end
end
def reset_cache_and_import_attrs
- ProjectCacheWorker.perform_async(self.id)
+ run_after_commit do
+ ProjectCacheWorker.perform_async(self.id)
+ end
- self.import_data&.destroy
+ update(import_error: nil)
+ remove_import_data
+ end
+
+ # This method is overriden in EE::Project model
+ def remove_import_data
+ import_data&.destroy
+ end
+
+ def ci_config_path=(value)
+ # Strip all leading slashes so that //foo -> foo
+ super(value&.sub(%r{\A/+}, '')&.delete("\0"))
end
def import_url=(value)
@@ -505,8 +602,6 @@ class Project < ActiveRecord::Base
project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials)
end
-
- project_import_data.save
end
def import?
@@ -526,9 +621,17 @@ class Project < ActiveRecord::Base
end
def import_in_progress?
+ import_started? || import_scheduled?
+ end
+
+ def import_started?
import? && import_status == 'started'
end
+ def import_scheduled?
+ import_status == 'scheduled'
+ end
+
def import_failed?
import_status == 'failed'
end
@@ -603,7 +706,7 @@ class Project < ActiveRecord::Base
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
if full || cross_namespace_reference?(from)
- path_with_namespace
+ full_path
elsif cross_project_reference?(from)
path
end
@@ -618,7 +721,7 @@ class Project < ActiveRecord::Base
end
def web_url
- Gitlab::Routing.url_helpers.namespace_project_url(self.namespace, self)
+ Gitlab::Routing.url_helpers.project_url(self)
end
def new_issue_address(author)
@@ -627,7 +730,7 @@ class Project < ActiveRecord::Base
author.ensure_incoming_email_token!
Gitlab::IncomingEmail.reply_address(
- "#{path_with_namespace}+#{author.incoming_email_token}")
+ "#{full_path}+#{author.incoming_email_token}")
end
def build_commit_note(commit)
@@ -639,7 +742,7 @@ class Project < ActiveRecord::Base
end
def last_activity_date
- last_activity_at || updated_at
+ last_repository_updated_at || last_activity_at || updated_at
end
def project_id
@@ -647,9 +750,11 @@ class Project < ActiveRecord::Base
end
def get_issue(issue_id, current_user)
- if default_issues_tracker?
- IssuesFinder.new(current_user, project_id: id).find_by(iid: issue_id)
- else
+ issue = IssuesFinder.new(current_user, project_id: id).find_by(iid: issue_id) if issues_enabled?
+
+ if issue
+ issue
+ elsif external_issue_tracker
ExternalIssue.new(issue_id, self)
end
end
@@ -670,8 +775,8 @@ class Project < ActiveRecord::Base
end
end
- def issue_reference_pattern
- issues_tracker.reference_pattern
+ def external_issue_reference_pattern
+ external_issue_tracker.class.reference_pattern(only_long: issues_enabled?)
end
def default_issues_tracker?
@@ -716,10 +821,12 @@ class Project < ActiveRecord::Base
update_column(:has_external_wiki, services.external_wikis.any?)
end
- def find_or_initialize_services
+ def find_or_initialize_services(exceptions: [])
services_templates = Service.where(template: true)
- Service.available_services_names.map do |service_name|
+ available_services_names = Service.available_services_names - exceptions
+
+ available_services_names.map do |service_name|
service = find_service(services, service_name)
if service
@@ -730,7 +837,7 @@ class Project < ActiveRecord::Base
if template.nil?
# If no template, we should create an instance. Ex `build_gitlab_ci_service`
- public_send("build_#{service_name}_service")
+ public_send("build_#{service_name}_service") # rubocop:disable GitlabSecurity/PublicSend
else
Service.build_from_template(id, template)
end
@@ -791,12 +898,10 @@ class Project < ActiveRecord::Base
repository.avatar
end
- def avatar_url
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- elsif avatar_in_git
- Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self)
- end
+ def avatar_url(**args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args) || (Gitlab::Routing.url_helpers.project_avatar_url(self) if avatar_in_git)
end
# For compatibility with old code
@@ -828,14 +933,14 @@ class Project < ActiveRecord::Base
end
def execute_hooks(data, hooks_scope = :push_hooks)
- hooks.send(hooks_scope).each do |hook|
+ hooks.public_send(hooks_scope).each do |hook| # rubocop:disable GitlabSecurity/PublicSend
hook.async_execute(data, hooks_scope.to_s)
end
end
def execute_services(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
- services.send(hooks_scope).each do |service|
+ services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend
service.async_execute(data)
end
end
@@ -848,11 +953,11 @@ class Project < ActiveRecord::Base
end
def repo
- repository.raw
+ repository.rugged
end
def url_to_repo
- gitlab_shell.url_to_repo(path_with_namespace)
+ gitlab_shell.url_to_repo(full_path)
end
def repo_exists?
@@ -869,10 +974,8 @@ class Project < ActiveRecord::Base
url_to_repo
end
- def http_url_to_repo(user = nil)
- credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user)
-
- Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url
+ def http_url_to_repo
+ "#{web_url}.git"
end
def user_can_push_to_empty_repo?(user)
@@ -887,55 +990,6 @@ class Project < ActiveRecord::Base
!group
end
- def rename_repo
- path_was = previous_changes['path'].first
- old_path_with_namespace = File.join(namespace.full_path, path_was)
- new_path_with_namespace = File.join(namespace.full_path, path)
-
- Rails.logger.error "Attempting to rename #{old_path_with_namespace} -> #{new_path_with_namespace}"
-
- expire_caches_before_rename(old_path_with_namespace)
-
- if has_container_registry_tags?
- Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
-
- # we currently doesn't support renaming repository if it contains images in container registry
- raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
- end
-
- if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
- # If repository moved successfully we need to send update instructions to users.
- # However we cannot allow rollback since we moved repository
- # So we basically we mute exceptions in next actions
- begin
- gitlab_shell.mv_repository(repository_storage_path, "#{old_path_with_namespace}.wiki", "#{new_path_with_namespace}.wiki")
- send_move_instructions(old_path_with_namespace)
-
- @old_path_with_namespace = old_path_with_namespace
-
- SystemHooksService.new.execute_hooks_for(self, :rename)
-
- @repository = nil
- rescue => e
- Rails.logger.error "Exception renaming #{old_path_with_namespace} -> #{new_path_with_namespace}: #{e}"
- # Returning false does not rollback after_* transaction but gives
- # us information about failing some of tasks
- false
- end
- else
- Rails.logger.error "Repository could not be renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
-
- # if we cannot move namespace directory we should rollback
- # db changes in order to prevent out of sync between db and fs
- raise StandardError.new('repository cannot be renamed')
- end
-
- Gitlab::AppLogger.info "Project was renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}"
-
- Gitlab::UploadsTransfer.new.rename_project(path_was, path, namespace.full_path)
- Gitlab::PagesTransfer.new.rename_project(path_was, path, namespace.full_path)
- end
-
# Expires various caches before a project is renamed.
def expire_caches_before_rename(old_path)
repo = Repository.new(old_path, self)
@@ -960,8 +1014,9 @@ class Project < ActiveRecord::Base
git_http_url: http_url_to_repo,
namespace: namespace.name,
visibility_level: visibility_level,
- path_with_namespace: path_with_namespace,
+ path_with_namespace: full_path,
default_branch: default_branch,
+ ci_config_path: ci_config_path
}
# Backward compatibility
@@ -1003,13 +1058,16 @@ class Project < ActiveRecord::Base
end
def change_head(branch)
- repository.before_change_head
- repository.rugged.references.create('HEAD',
- "refs/heads/#{branch}",
- force: true)
- repository.copy_gitattributes(branch)
- repository.after_change_head
- reload_default_branch
+ if repository.branch_exists?(branch)
+ repository.before_change_head
+ repository.write_ref('HEAD', "refs/heads/#{branch}")
+ repository.copy_gitattributes(branch)
+ repository.after_change_head
+ reload_default_branch
+ else
+ errors.add(:base, "Could not change HEAD: branch '#{branch}' does not exist")
+ false
+ end
end
def forked_from?(project)
@@ -1020,23 +1078,25 @@ class Project < ActiveRecord::Base
merge_requests.where(source_project_id: self.id)
end
- def create_repository
- # Forked import is handled asynchronously
- unless forked?
- if gitlab_shell.add_repository(repository_storage_path, path_with_namespace)
- repository.after_create
- true
- else
- errors.add(:base, 'Failed to create repository via gitlab-shell')
- false
- end
- end
+ def ensure_repository
+ create_repository(force: true) unless repository_exists?
end
def repository_exists?
!!repository.exists?
end
+ def update_forks_visibility_level
+ return unless visibility_level < visibility_level_was
+
+ forks.each do |forked_project|
+ if forked_project.visibility_level > visibility_level
+ forked_project.visibility_level = visibility_level
+ forked_project.save!
+ end
+ end
+ end
+
def create_wiki
ProjectWiki.new(self, self.owner).wiki
true
@@ -1045,6 +1105,10 @@ class Project < ActiveRecord::Base
false
end
+ def wiki
+ @wiki ||= ProjectWiki.new(self, self.owner)
+ end
+
def jira_tracker_active?
jira_tracker? && jira_service.active
end
@@ -1061,11 +1125,6 @@ class Project < ActiveRecord::Base
pipelines.order(id: :desc).find_by(sha: sha, ref: ref)
end
- def ensure_pipeline(ref, sha, current_user = nil)
- pipeline_for(ref, sha) ||
- pipelines.create(sha: sha, ref: ref, user: current_user)
- end
-
def enable_ci
project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED)
end
@@ -1154,14 +1213,35 @@ class Project < ActiveRecord::Base
end
def pages_path
- File.join(Settings.pages.path, path_with_namespace)
+ File.join(Settings.pages.path, disk_path)
end
def public_pages_path
File.join(pages_path, 'public')
end
+ def remove_private_deploy_keys
+ exclude_keys_linked_to_other_projects = <<-SQL
+ NOT EXISTS (
+ SELECT 1
+ FROM deploy_keys_projects dkp2
+ WHERE dkp2.deploy_key_id = deploy_keys_projects.deploy_key_id
+ AND dkp2.project_id != deploy_keys_projects.project_id
+ )
+ SQL
+
+ deploy_keys.where(public: false)
+ .where(exclude_keys_linked_to_other_projects)
+ .delete_all
+ end
+
+ # TODO: what to do here when not using Legacy Storage? Do we still need to rename and delay removal?
def remove_pages
+ # Projects with a missing namespace cannot have their pages removed
+ return unless namespace
+
+ ::Projects::UpdatePagesConfigurationService.new(self).execute
+
# 1. We rename pages to temporary directory
# 2. We wait 5 minutes, due to NFS caching
# 3. We asynchronously remove pages with force
@@ -1172,10 +1252,6 @@ class Project < ActiveRecord::Base
end
end
- def wiki
- @wiki ||= ProjectWiki.new(self, self.owner)
- end
-
def running_or_pending_build_count(force: false)
Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do
builds.running_or_pending.count(:all)
@@ -1210,7 +1286,7 @@ class Project < ActiveRecord::Base
end
def export_path
- File.join(Gitlab::ImportExport.storage_path, path_with_namespace)
+ File.join(Gitlab::ImportExport.storage_path, disk_path)
end
def export_project_path
@@ -1222,15 +1298,16 @@ class Project < ActiveRecord::Base
status.zero?
end
- def ensure_dir_exist
- gitlab_shell.add_namespace(repository_storage_path, namespace.full_path)
+ def full_path_slug
+ Gitlab::Utils.slugify(full_path.to_s)
end
def predefined_variables
[
{ key: 'CI_PROJECT_ID', value: id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: path, public: true },
- { key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true },
+ { key: 'CI_PROJECT_PATH', value: full_path, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: web_url, public: true }
]
@@ -1250,12 +1327,20 @@ class Project < ActiveRecord::Base
variables
end
- def secret_variables
- variables.map do |variable|
- { key: variable.key, value: variable.value, public: false }
+ def secret_variables_for(ref:, environment: nil)
+ # EE would use the environment
+ if protected_for?(ref)
+ variables
+ else
+ variables.unprotected
end
end
+ def protected_for?(ref)
+ ProtectedBranch.protected?(self, ref) ||
+ ProtectedTag.protected?(self, ref)
+ end
+
def deployment_variables
return [] unless deployment_service
@@ -1263,25 +1348,28 @@ class Project < ActiveRecord::Base
end
def append_or_update_attribute(name, value)
- old_values = public_send(name.to_s)
+ old_values = public_send(name.to_s) # rubocop:disable GitlabSecurity/PublicSend
if Project.reflect_on_association(name).try(:macro) == :has_many && old_values.any?
update_attribute(name, old_values + value)
else
update_attribute(name, value)
end
+
+ rescue ActiveRecord::RecordNotSaved => e
+ handle_update_attribute_error(e, value)
end
def pushes_since_gc
- Gitlab::Redis.with { |redis| redis.get(pushes_since_gc_redis_key).to_i }
+ Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
def increment_pushes_since_gc
- Gitlab::Redis.with { |redis| redis.incr(pushes_since_gc_redis_key) }
+ Gitlab::Redis::SharedState.with { |redis| redis.incr(pushes_since_gc_redis_shared_state_key) }
end
def reset_pushes_since_gc
- Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) }
+ Gitlab::Redis::SharedState.with { |redis| redis.del(pushes_since_gc_redis_shared_state_key) }
end
def route_map_for(commit_sha)
@@ -1314,10 +1402,23 @@ class Project < ActiveRecord::Base
namespace_id_changed?
end
+ def default_merge_request_target
+ if forked_from_project&.merge_requests_enabled?
+ forked_from_project
+ else
+ self
+ end
+ end
+
alias_method :name_with_namespace, :full_name
alias_method :human_name, :full_name
+ # @deprecated cannot remove yet because it has an index with its name in elasticsearch
alias_method :path_with_namespace, :full_path
+ def forks_count
+ Projects::ForksCountService.new(self).count
+ end
+
private
def cross_namespace_reference?(from)
@@ -1336,7 +1437,7 @@ class Project < ActiveRecord::Base
from && self != from
end
- def pushes_since_gc_redis_key
+ def pushes_since_gc_redis_shared_state_key
"projects/#{id}/pushes_since_gc"
end
@@ -1370,7 +1471,7 @@ class Project < ActiveRecord::Base
def pending_delete_twin
return false unless path
- Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
+ Project.pending_delete.find_by_full_path(full_path)
end
##
@@ -1383,4 +1484,16 @@ class Project < ActiveRecord::Base
ContainerRepository.build_root_repository(self).has_tags?
end
+
+ def handle_update_attribute_error(ex, value)
+ if ex.message.start_with?('Failed to replace')
+ if value.respond_to?(:each)
+ invalid = value.detect(&:invalid?)
+
+ raise ex, ([ex.message] + invalid.errors.full_messages).join(' ') if invalid
+ end
+ end
+
+ raise ex
+ end
end
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index 4c7f4f5a429..73302207e6b 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -6,6 +6,12 @@ class ProjectAuthorization < ActiveRecord::Base
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
+ def self.select_from_union(union)
+ select(['project_id', 'MAX(access_level) AS access_level'])
+ .from("(#{union.to_sql}) #{ProjectAuthorization.table_name}")
+ .group(:project_id)
+ end
+
def self.insert_authorizations(rows, per_batch = 1000)
rows.each_slice(per_batch) do |slice|
tuples = slice.map do |tuple|
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index e3ef4919b28..fb1db0255aa 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -27,6 +27,13 @@ class ProjectFeature < ActiveRecord::Base
"#{feature}_access_level".to_sym
end
+
+ def quoted_access_level_column(feature)
+ attribute = connection.quote_column_name(access_level_attribute(feature))
+ table = connection.quote_table_name(table_name)
+
+ "#{table}.#{attribute}"
+ end
end
# Default scopes force us to unscope here since a service may need to check
@@ -44,8 +51,11 @@ class ProjectFeature < ActiveRecord::Base
default_value_for :repository_access_level, value: ENABLED, allows_nil: false
def feature_available?(feature, user)
- access_level = public_send(ProjectFeature.access_level_attribute(feature))
- get_permission(user, access_level)
+ get_permission(user, access_level(feature))
+ end
+
+ def access_level(feature)
+ public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
def builds_enabled?
@@ -70,7 +80,7 @@ class ProjectFeature < ActiveRecord::Base
# which cannot be higher than repository access level
def repository_children_level
validator = lambda do |field|
- level = public_send(field) || ProjectFeature::ENABLED
+ level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > repository_access_level
self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed
end
@@ -83,7 +93,7 @@ class ProjectFeature < ActiveRecord::Base
when DISABLED
false
when PRIVATE
- user && (project.team.member?(user) || user.admin?)
+ user && (project.team.member?(user) || user.full_private_access?)
when ENABLED
true
else
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index 331123a5a5b..6da6632f4f2 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -1,7 +1,7 @@
require 'carrierwave/orm/activerecord'
class ProjectImportData < ActiveRecord::Base
- belongs_to :project
+ belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials,
key: Gitlab::Application.secrets.db_key_base,
marshal: true,
@@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base
insecure_mode: true,
algorithm: 'aes-256-cbc'
- serialize :data, JSON
+ serialize :data, JSON # rubocop:disable Cop/ActiveRecordSerialize
validates :project, presence: true
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index 3728f5642e4..9ce2d1153a7 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -34,7 +34,8 @@ http://app.asana.com/-/account_api'
{
type: 'text',
name: 'api_key',
- placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.'
+ placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.',
+ required: true
},
{
type: 'text',
diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb
index aeeff8917bf..ae6af732ed4 100644
--- a/app/models/project_services/assembla_service.rb
+++ b/app/models/project_services/assembla_service.rb
@@ -18,7 +18,7 @@ class AssemblaService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: '' },
+ { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' }
]
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 400020ee04a..42939ea0ec8 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -47,12 +47,12 @@ class BambooService < CiService
def fields
[
{ type: 'text', name: 'bamboo_url',
- placeholder: 'Bamboo root URL like https://bamboo.example.com' },
+ placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true },
{ type: 'text', name: 'build_key',
- placeholder: 'Bamboo build plan key like KEY' },
+ placeholder: 'Bamboo build plan key like KEY', required: true },
{ type: 'text', name: 'username',
placeholder: 'A user with API access, if applicable' },
- { type: 'password', name: 'password' },
+ { type: 'password', name: 'password' }
]
end
diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb
index 5fb95050b83..fc30f6e3365 100644
--- a/app/models/project_services/buildkite_service.rb
+++ b/app/models/project_services/buildkite_service.rb
@@ -58,11 +58,11 @@ class BuildkiteService < CiService
[
{ type: 'text',
name: 'token',
- placeholder: 'Buildkite project GitLab token' },
+ placeholder: 'Buildkite project GitLab token', required: true },
{ type: 'text',
name: 'project_url',
- placeholder: "#{ENDPOINT}/example/project" },
+ placeholder: "#{ENDPOINT}/example/project", required: true },
{ type: 'checkbox',
name: 'enable_ssl_verification',
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index 0de59af5652..c3f5b310619 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -18,7 +18,7 @@ class CampfireService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: '' },
+ { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' },
{ type: 'text', name: 'room', placeholder: '' }
]
@@ -76,7 +76,7 @@ class CampfireService < Service
# Returns a list of rooms, or [].
# https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms
def rooms(auth)
- res = self.class.get("/rooms.json", auth)
+ res = self.class.get("/rooms.json", auth)
res.code == 200 ? res["rooms"] : []
end
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index 7621a5fa2d8..e2ad586aea7 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -50,5 +50,16 @@ module ChatMessage
def link(text, url)
"[#{text}](#{url})"
end
+
+ def pretty_duration(seconds)
+ parse_string =
+ if duration < 1.hour
+ '%M:%S'
+ else
+ '%H:%M:%S'
+ end
+
+ Time.at(seconds).utc.strftime(parse_string)
+ end
end
end
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 4628d9b1a7b..d63d4ec2b12 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -15,7 +15,7 @@ module ChatMessage
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status]
- @duration = pipeline_attributes[:duration]
+ @duration = pipeline_attributes[:duration].to_i
@pipeline_id = pipeline_attributes[:id]
end
@@ -35,9 +35,9 @@ module ChatMessage
def activity
{
- title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
+ title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}",
subtitle: "in #{project_link}",
- text: "in #{duration} #{time_measure}",
+ text: "in #{pretty_duration(duration)}",
image: user_avatar || ''
}
end
@@ -45,7 +45,7 @@ module ChatMessage
private
def message
- "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
+ "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}"
end
def humanized_status
@@ -84,9 +84,5 @@ module ChatMessage
def pipeline_link
"[##{pipeline_id}](#{pipeline_url})"
end
-
- def time_measure
- 'second'.pluralize(duration)
- end
end
end
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index fa782c6fbb7..7b15a5dd04d 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -21,10 +21,6 @@ class ChatNotificationService < Service
end
end
- def can_test?
- valid?
- end
-
def self.supported_events
%w[push issue confidential_issue merge_request note tag_push
pipeline wiki_page]
@@ -36,10 +32,10 @@ class ChatNotificationService < Service
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
+ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true },
{ type: 'text', name: 'username', placeholder: 'e.g. GitLab' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'checkbox', name: 'notify_only_default_branch' },
+ { type: 'checkbox', name: 'notify_only_default_branch' }
]
end
@@ -119,7 +115,7 @@ class ChatNotificationService < Service
def get_channel_field(event)
field_name = event_channel_name(event)
- self.public_send(field_name)
+ self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend
end
def build_event_channels
@@ -150,7 +146,7 @@ class ChatNotificationService < Service
def notify_for_ref?(data)
return true if data[:object_attributes][:tag]
- return true unless notify_only_default_branch
+ return true unless notify_only_default_branch?
data[:object_attributes][:ref] == project.default_branch
end
diff --git a/app/models/project_services/chat_slash_commands_service.rb b/app/models/project_services/chat_slash_commands_service.rb
deleted file mode 100644
index 8b5bc24fd3c..00000000000
--- a/app/models/project_services/chat_slash_commands_service.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# Base class for Chat services
-# This class is not meant to be used directly, but only to inherrit from.
-class ChatSlashCommandsService < Service
- default_value_for :category, 'chat'
-
- prop_accessor :token
-
- has_many :chat_names, foreign_key: :service_id, dependent: :destroy
-
- def valid_token?(token)
- self.respond_to?(:token) &&
- self.token.present? &&
- ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
- end
-
- def self.supported_events
- %w()
- end
-
- def can_test?
- false
- end
-
- def fields
- [
- { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' }
- ]
- end
-
- def trigger(params)
- return unless valid_token?(params[:token])
-
- user = find_chat_user(params)
-
- if user
- Gitlab::ChatCommands::Command.new(project, user, params).execute
- else
- url = authorize_chat_name_url(params)
- Gitlab::ChatCommands::Presenters::Access.new(url).authorize
- end
- end
-
- private
-
- def find_chat_user(params)
- ChatNames::FindUserService.new(self, params).execute
- end
-
- def authorize_chat_name_url(params)
- ChatNames::AuthorizeUserService.new(self, params).execute
- end
-end
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index dea915a4d05..b9e3e982b64 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService
[
{ type: 'text', name: 'title', placeholder: title },
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url' },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url' },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' }
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
+ { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
]
end
end
diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb
index 91a55514a9a..5b8320158fc 100644
--- a/app/models/project_services/deployment_service.rb
+++ b/app/models/project_services/deployment_service.rb
@@ -30,4 +30,8 @@ class DeploymentService < Service
def terminals(environment)
raise NotImplementedError
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 2717c240f05..c93f1632652 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -93,8 +93,8 @@ class DroneCiService < CiService
def fields
[
- { type: 'text', name: 'token', placeholder: 'Drone CI project specific token' },
- { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com' },
+ { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true },
+ { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true },
{ type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" }
]
end
@@ -114,7 +114,7 @@ class DroneCiService < CiService
end
def merge_request_valid?(data)
- %w(opened reopened).include?(data[:object_attributes][:state]) &&
+ data[:object_attributes][:state] == 'opened' &&
data[:object_attributes][:merge_status] == 'unchecked'
end
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index f4f913ee0b6..1a236e232f9 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -47,7 +47,7 @@ class EmailsOnPushService < Service
help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." },
{ type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs",
help: "Don't include possibly sensitive code diffs in notification body." },
- { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' },
+ { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }
]
end
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index bdf6fa6a586..720ad61162e 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -19,7 +19,7 @@ class ExternalWikiService < Service
def fields
[
- { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' },
+ { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true }
]
end
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 10a13c3fbdc..4d23a17a545 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -18,7 +18,7 @@ class FlowdockService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Flowdock Git source token' }
+ { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true }
]
end
@@ -35,9 +35,9 @@ class FlowdockService < Service
data[:after],
token: token,
repo: project.repository.path_to_repo,
- repo_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}",
- commit_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/%s",
- diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s",
+ repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}",
+ commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s",
+ diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s"
)
end
end
diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb
index f271e1f1739..017a9b2df6e 100644
--- a/app/models/project_services/gemnasium_service.rb
+++ b/app/models/project_services/gemnasium_service.rb
@@ -18,8 +18,8 @@ class GemnasiumService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ' },
- { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com' }
+ { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ', required: true },
+ { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com', required: true }
]
end
diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb
index ad4eb9536e1..88c428b4aae 100644
--- a/app/models/project_services/gitlab_issue_tracker_service.rb
+++ b/app/models/project_services/gitlab_issue_tracker_service.rb
@@ -1,5 +1,5 @@
class GitlabIssueTrackerService < IssueTrackerService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated?
@@ -12,26 +12,26 @@ class GitlabIssueTrackerService < IssueTrackerService
end
def project_url
- namespace_project_issues_url(project.namespace, project)
+ project_issues_url(project)
end
def new_issue_url
- new_namespace_project_issue_url(namespace_id: project.namespace, project_id: project)
+ new_project_issue_url(project)
end
def issue_url(iid)
- namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: iid)
+ project_issue_url(project, id: iid)
end
- def project_path
- namespace_project_issues_path(project.namespace, project)
+ def issue_tracker_path
+ project_issues_path(project)
end
def new_issue_path
- new_namespace_project_issue_path(namespace_id: project.namespace, project_id: project)
+ new_project_issue_path(project)
end
def issue_path(iid)
- namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: iid)
+ project_issue_path(project, id: iid)
end
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 8b181221bb0..f422e0ea036 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -33,7 +33,7 @@ class HipchatService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Room token' },
+ { type: 'text', name: 'token', placeholder: 'Room token', required: true },
{ type: 'text', name: 'room', placeholder: 'Room name or ID' },
{ type: 'checkbox', name: 'notify' },
{ type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
@@ -41,7 +41,7 @@ class HipchatService < Service
placeholder: 'Leave blank for default (v2)' },
{ type: 'text', name: 'server',
placeholder: 'Leave blank for default. https://hipchat.example.com' },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' }
]
end
@@ -53,7 +53,7 @@ class HipchatService < Service
return unless supported_events.include?(data[:object_kind])
message = create_message(data)
return unless message.present?
- gate[room].send('GitLab', message, message_options(data))
+ gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend
end
def test(data)
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index c62bb4fa120..19357f90810 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -49,7 +49,7 @@ class IrkerService < Service
help: 'A default IRC URI to prepend before each recipient (optional)',
placeholder: 'irc://irc.network.net:6697/' },
{ type: 'textarea', name: 'recipients',
- placeholder: 'Recipients/channels separated by whitespaces',
+ placeholder: 'Recipients/channels separated by whitespaces', required: true,
help: 'Recipients have to be specified with a full URI: '\
'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\
'you want the channel to be a nickname instead, append ",isnick" to ' \
@@ -58,7 +58,7 @@ class IrkerService < Service
' want to use a password, you have to omit the "#" on the channel). If you ' \
' specify a default IRC URI to prepend before each recipient, you can just ' \
' give a channel name.' },
- { type: 'checkbox', name: 'colorize_messages' },
+ { type: 'checkbox', name: 'colorize_messages' }
]
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 50435b67eda..31984c5d7ed 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -5,8 +5,15 @@ class IssueTrackerService < Service
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
- def reference_pattern
- @reference_pattern ||= %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
+ # This pattern does not support cross-project references
+ # The other code assumes that this pattern is a superset of all
+ # overriden patterns. See ReferenceRegexes::EXTERNAL_PATTERN
+ def self.reference_pattern(only_long: false)
+ if only_long
+ %r{(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)}
+ else
+ %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
+ end
end
def default?
@@ -17,7 +24,7 @@ class IssueTrackerService < Service
self.issues_url.gsub(':id', iid.to_s)
end
- def project_path
+ def issue_tracker_path
project_url
end
@@ -32,9 +39,9 @@ class IssueTrackerService < Service
def fields
[
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url' },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url' },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' }
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
+ { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
]
end
@@ -76,7 +83,7 @@ class IssueTrackerService < Service
message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}"
result = true
end
- rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => error
+ rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error
message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}"
end
Rails.logger.info(message)
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 97e997d3899..9ee3a533c1e 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -1,11 +1,10 @@
class JiraService < IssueTrackerService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
validates :url, url: true, presence: true, if: :activated?
- validates :project_key, presence: true, if: :activated?
+ validates :api_url, url: true, allow_blank: true
- prop_accessor :username, :password, :url, :project_key,
- :jira_issue_transition_id, :title, :description
+ prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description
before_update :reset_password
@@ -17,7 +16,7 @@ class JiraService < IssueTrackerService
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
- def reference_pattern
+ def self.reference_pattern(only_long: true)
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
end
@@ -25,20 +24,18 @@ class JiraService < IssueTrackerService
super do
self.properties = {
title: issues_tracker['title'],
- url: issues_tracker['url']
+ url: issues_tracker['url'],
+ api_url: issues_tracker['api_url']
}
end
end
def reset_password
- # don't reset the password if a new one is provided
- if url_changed? && !password_touched?
- self.password = nil
- end
+ self.password = nil if reset_password?
end
def options
- url = URI.parse(self.url)
+ url = URI.parse(client_url)
{
username: self.username,
@@ -55,10 +52,6 @@ class JiraService < IssueTrackerService
@client ||= JIRA::Client.new(options)
end
- def jira_project
- @jira_project ||= jira_request { client.Project.find(project_key) }
- end
-
def help
"You need to configure JIRA before enabling this service. For more details
read the
@@ -87,19 +80,14 @@ class JiraService < IssueTrackerService
def fields
[
- { type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' },
- { 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: '' }
+ { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true },
+ { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
+ { type: 'text', name: 'username', placeholder: '', required: true },
+ { type: 'password', name: 'password', placeholder: '', required: true },
+ { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID', placeholder: '' }
]
end
- # URLs to redirect from Gitlab issues pages to jira issue tracker
- def project_url
- "#{url}/issues/?jql=project=#{project_key}"
- end
-
def issues_url
"#{url}/browse/:id"
end
@@ -116,7 +104,7 @@ class JiraService < IssueTrackerService
def close_issue(entity, external_issue)
issue = jira_request { client.Issue.find(external_issue.iid) }
- return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present?
+ return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present?
commit_id = if entity.is_a?(Commit)
entity.id
@@ -130,7 +118,7 @@ class JiraService < IssueTrackerService
# may or may not be allowed. Refresh the issue after transition and check
# if it is closed, so we don't have one comment for every commit.
issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
- add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution
+ add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue)
end
def create_cross_reference_note(mentioned, noteable, author)
@@ -149,11 +137,11 @@ class JiraService < IssueTrackerService
data = {
user: {
name: author.name,
- url: resource_url(user_path(author)),
+ url: resource_url(user_path(author))
},
project: {
- name: self.project.path_with_namespace,
- url: resource_url(namespace_project_path(project.namespace, self.project))
+ name: project.full_path,
+ url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper
},
entity: {
name: noteable_type.humanize.downcase,
@@ -172,11 +160,10 @@ class JiraService < IssueTrackerService
def test(_)
result = test_settings
- { success: result.present?, result: result }
- end
+ success = result.present?
+ result = @error if @error && !success
- def can_test?
- username.present? && password.present?
+ { success: success, result: result }
end
# JIRA does not need test data.
@@ -186,9 +173,9 @@ class JiraService < IssueTrackerService
end
def test_settings
- return unless url.present?
+ return unless client_url.present?
# Test settings by getting the project
- jira_request { jira_project.present? }
+ jira_request { client.ServerInfo.all.attrs }
end
private
@@ -229,6 +216,10 @@ class JiraService < IssueTrackerService
end
end
+ def has_resolution?(issue)
+ issue.respond_to?(:resolution) && issue.resolution.present?
+ end
+
def comment_exists?(issue, message)
comments = jira_request { issue.comments }
@@ -236,20 +227,29 @@ class JiraService < IssueTrackerService
end
def send_message(issue, message, remote_link_props)
- return unless url.present?
+ return unless client_url.present?
jira_request do
- if issue.comments.build.save!(body: message)
- remote_link = issue.remotelink.build
+ remote_link = find_remote_link(issue, remote_link_props[:object][:url])
+ if remote_link
remote_link.save!(remote_link_props)
- result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
+ elsif issue.comments.build.save!(body: message)
+ new_remote_link = issue.remotelink.build
+ new_remote_link.save!(remote_link_props)
end
+ result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}."
Rails.logger.info(result_message)
result_message
end
end
+ def find_remote_link(issue, url)
+ links = jira_request { issue.remotelink.all }
+
+ links.find { |link| link.object["url"] == url }
+ end
+
def build_remote_link_props(url:, title:, resolved: false)
status = {
resolved: resolved
@@ -294,8 +294,22 @@ class JiraService < IssueTrackerService
def jira_request
yield
- rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e
- Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
+ @error = e.message
+ Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{@error}"
nil
end
+
+ def client_url
+ api_url.present? ? api_url : url
+ end
+
+ def reset_password?
+ # don't reset the password if a new one is provided
+ return false if password_touched?
+ return true if api_url_changed?
+ return false if api_url.present?
+
+ url_changed?
+ end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index 9c56518c991..dee99bbb859 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -59,24 +59,32 @@ class KubernetesService < DeploymentService
def fields
[
{ type: 'text',
- name: 'namespace',
- title: 'Kubernetes namespace',
- placeholder: namespace_placeholder },
- { type: 'text',
name: 'api_url',
title: 'API URL',
placeholder: 'Kubernetes API URL, like https://kube.example.com/' },
- { type: 'text',
- name: 'token',
- title: 'Service token',
- placeholder: 'Service token' },
{ type: 'textarea',
name: 'ca_pem',
- title: 'Custom CA bundle',
+ title: 'CA Certificate',
placeholder: 'Certificate Authority bundle (PEM format)' },
+ { type: 'text',
+ name: 'namespace',
+ title: 'Project namespace (optional/unique)',
+ placeholder: namespace_placeholder },
+ { type: 'text',
+ name: 'token',
+ title: 'Token',
+ placeholder: 'Service token' }
]
end
+ def actual_namespace
+ if namespace.present?
+ namespace
+ else
+ default_namespace
+ end
+ end
+
# Check we can connect to the Kubernetes API
def test(*args)
kubeclient = build_kubeclient!
@@ -88,10 +96,13 @@ class KubernetesService < DeploymentService
end
def predefined_variables
+ config = YAML.dump(kubeconfig)
+
variables = [
{ key: 'KUBE_URL', value: api_url, public: true },
{ key: 'KUBE_TOKEN', value: token, public: false },
- { key: 'KUBE_NAMESPACE', value: namespace_variable, public: true }
+ { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true },
+ { key: 'KUBECONFIG', value: config, public: false, file: true }
]
if ca_pem.present?
@@ -108,46 +119,35 @@ class KubernetesService < DeploymentService
# short time later
def terminals(environment)
with_reactive_cache do |data|
- pods = data.fetch(:pods, nil)
- filter_pods(pods, app: environment.slug).
- flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }.
- each { |terminal| add_terminal_auth(terminal, terminal_auth) }
+ pods = filter_by_label(data[:pods], app: environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
- # Caches all pods in the namespace so other calls don't need to block on
- # network access.
+ # Caches resources in the namespace so other calls don't need to block on
+ # network access
def calculate_reactive_cache
return unless active? && project && !project.pending_delete?
- kubeclient = build_kubeclient!
-
- # Store as hashes, rather than as third-party types
- pods = begin
- kubeclient.get_pods(namespace: namespace).as_json
- rescue KubeException => err
- raise err unless err.error_code == 404
- []
- end
-
# We may want to cache extra things in the future
- { pods: pods }
+ { pods: read_pods }
end
TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze
private
- def namespace_placeholder
- default_namespace || TEMPLATE_PLACEHOLDER
+ def kubeconfig
+ to_kubeconfig(
+ url: api_url,
+ namespace: actual_namespace,
+ token: token,
+ ca_pem: ca_pem)
end
- def namespace_variable
- if namespace.present?
- namespace
- else
- default_namespace
- end
+ def namespace_placeholder
+ default_namespace || TEMPLATE_PLACEHOLDER
end
def default_namespace
@@ -155,7 +155,7 @@ class KubernetesService < DeploymentService
end
def build_kubeclient!(api_path: 'api', api_version: 'v1')
- raise "Incomplete settings" unless api_url && namespace && token
+ raise "Incomplete settings" unless api_url && actual_namespace && token
::Kubeclient::Client.new(
join_api_url(api_path),
@@ -166,6 +166,16 @@ class KubernetesService < DeploymentService
)
end
+ # Returns a hash of all pods in the namespace
+ def read_pods
+ kubeclient = build_kubeclient!
+
+ kubeclient.get_pods(namespace: actual_namespace).as_json
+ rescue KubeException => err
+ raise err unless err.error_code == 404
+ []
+ end
+
def kubeclient_ssl_options
opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
@@ -181,11 +191,11 @@ class KubernetesService < DeploymentService
{ bearer_token: token }
end
- def join_api_url(*parts)
+ def join_api_url(api_path)
url = URI.parse(api_url)
prefix = url.path.sub(%r{/+\z}, '')
- url.path = [prefix, *parts].join("/")
+ url.path = [prefix, api_path].join("/")
url.to_s
end
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index 56f42d63b2d..4d2037286a2 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -1,4 +1,4 @@
-class MattermostSlashCommandsService < ChatSlashCommandsService
+class MattermostSlashCommandsService < SlashCommandsService
include TriggersHelper
prop_accessor :token
@@ -20,8 +20,8 @@ class MattermostSlashCommandsService < ChatSlashCommandsService
end
def configure(user, params)
- token = Mattermost::Command.new(user).
- create(command(params))
+ token = Mattermost::Command.new(user)
+ .create(command(params))
update(active: true, token: token) if token
rescue Mattermost::Error => e
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index 9b218fd81b4..2facff53e26 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -35,7 +35,7 @@ class MicrosoftTeamsService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'checkbox', name: 'notify_only_default_branch' },
+ { type: 'checkbox', name: 'notify_only_default_branch' }
]
end
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index a8d581a1f67..72ddf9a4be3 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -21,7 +21,8 @@ class MockCiService < CiService
[
{ type: 'text',
name: 'mock_service_url',
- placeholder: 'http://localhost:4004' },
+ placeholder: 'http://localhost:4004',
+ required: true }
]
end
@@ -79,4 +80,8 @@ class MockCiService < CiService
:error
end
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb
index dd04e04e198..ed0318c6b27 100644
--- a/app/models/project_services/mock_monitoring_service.rb
+++ b/app/models/project_services/mock_monitoring_service.rb
@@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService
def metrics(environment)
JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
index ea585721e8f..ee9cd78327a 100644
--- a/app/models/project_services/monitoring_service.rb
+++ b/app/models/project_services/monitoring_service.rb
@@ -9,8 +9,11 @@ class MonitoringService < Service
%w()
end
- # Environments have a number of metrics
- def metrics(environment)
+ def environment_metrics(environment)
+ raise NotImplementedError
+ end
+
+ def deployment_metrics(deployment)
raise NotImplementedError
end
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index ac617f409d9..9d37184be2c 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -53,9 +53,10 @@ class PipelinesEmailService < Service
[
{ type: 'textarea',
name: 'recipients',
- placeholder: 'Emails separated by comma' },
+ placeholder: 'Emails separated by comma',
+ required: true },
{ type: 'checkbox',
- name: 'notify_only_broken_pipelines' },
+ name: 'notify_only_broken_pipelines' }
]
end
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index d86f4f6f448..f9dfa2e91c3 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -23,7 +23,8 @@ class PivotaltrackerService < Service
{
type: 'text',
name: 'token',
- placeholder: 'Pivotal Tracker API token.'
+ placeholder: 'Pivotal Tracker API token.',
+ required: true
},
{
type: 'text',
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 6854d2243d7..217f753f05f 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -1,7 +1,6 @@
class PrometheusService < MonitoringService
- include ReactiveCaching
+ include ReactiveService
- self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
@@ -29,17 +28,6 @@ class PrometheusService < MonitoringService
'Prometheus monitoring'
end
- def help
- <<-MD.strip_heredoc
- Retrieves the Kubernetes node metrics `container_cpu_usage_seconds_total`
- and `container_memory_usage_bytes` from the configured Prometheus server.
-
- If you are not using [Auto-Deploy](https://docs.gitlab.com/ee/ci/autodeploy/index.html)
- or have set up your own Prometheus server, an `environment` label is required on each metric to
- [identify the Environment](https://docs.gitlab.com/ce/user/project/integrations/prometheus.html#metrics-and-labels).
- MD
- end
-
def self.to_param
'prometheus'
end
@@ -50,7 +38,9 @@ class PrometheusService < MonitoringService
type: 'text',
name: 'api_url',
title: 'API URL',
- placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/'
+ placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/',
+ help: 'By default, Prometheus listens on ‘http://localhost:9090’. It’s not recommended to change the default address and port as this might affect or conflict with other services running on the GitLab server.',
+ required: true
}
]
end
@@ -64,37 +54,49 @@ class PrometheusService < MonitoringService
{ success: false, result: err }
end
- def metrics(environment)
- with_reactive_cache(environment.slug) do |data|
- data
- end
+ def environment_metrics(environment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &method(:rename_data_to_metrics))
+ end
+
+ def deployment_metrics(deployment)
+ metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &method(:rename_data_to_metrics))
+ metrics&.merge(deployment_time: deployment.created_at.to_i) || {}
+ end
+
+ def additional_environment_metrics(environment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery.name, environment.id, &:itself)
+ end
+
+ def additional_deployment_metrics(deployment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.id, &:itself)
+ end
+
+ def matched_metrics
+ with_reactive_cache(Gitlab::Prometheus::Queries::MatchedMetricsQuery.name, &:itself)
end
# Cache metrics for specific environment
- def calculate_reactive_cache(environment_slug)
+ def calculate_reactive_cache(query_class_name, *args)
return unless active? && project && !project.pending_delete?
- memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024}
- cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100}
-
+ data = Kernel.const_get(query_class_name).new(client).query(*args)
{
success: true,
- metrics: {
- # Average Memory used in MB
- memory_values: client.query_range(memory_query, start: 8.hours.ago),
- memory_current: client.query(memory_query),
- # Average CPU Utilization
- cpu_values: client.query_range(cpu_query, start: 8.hours.ago),
- cpu_current: client.query(cpu_query)
- },
+ data: data,
last_update: Time.now.utc
}
-
rescue Gitlab::PrometheusError => err
{ success: false, result: err.message }
end
def client
- @prometheus ||= Gitlab::Prometheus.new(api_url: api_url)
+ @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url)
+ end
+
+ private
+
+ def rename_data_to_metrics(metrics)
+ metrics[:metrics] = metrics.delete :data
+ metrics
end
end
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index 3e618a8dbf1..aa7bd4c3c84 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -19,10 +19,10 @@ class PushoverService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your application key' },
- { type: 'text', name: 'user_key', placeholder: 'Your user key' },
+ { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true },
+ { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true },
{ type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' },
- { type: 'select', name: 'priority', choices:
+ { type: 'select', name: 'priority', required: true, choices:
[
['Lowest Priority', -2],
['Low Priority', -1],
@@ -55,7 +55,7 @@ class PushoverService < Service
['Pushover Echo (long)', 'echo'],
['Up Down (long)', 'updown'],
['None (silent)', 'none']
- ] },
+ ] }
]
end
diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb
index 2182c1c7e4b..1c3892a3f75 100644
--- a/app/models/project_services/slack_slash_commands_service.rb
+++ b/app/models/project_services/slack_slash_commands_service.rb
@@ -1,4 +1,4 @@
-class SlackSlashCommandsService < ChatSlashCommandsService
+class SlackSlashCommandsService < SlashCommandsService
include TriggersHelper
def title
diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb
new file mode 100644
index 00000000000..eb4da68bb7e
--- /dev/null
+++ b/app/models/project_services/slash_commands_service.rb
@@ -0,0 +1,52 @@
+# Base class for Chat services
+# This class is not meant to be used directly, but only to inherrit from.
+class SlashCommandsService < Service
+ default_value_for :category, 'chat'
+
+ prop_accessor :token
+
+ has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ def valid_token?(token)
+ self.respond_to?(:token) &&
+ self.token.present? &&
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def can_test?
+ false
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' }
+ ]
+ end
+
+ def trigger(params)
+ return unless valid_token?(params[:token])
+
+ user = find_chat_user(params)
+
+ if user
+ Gitlab::SlashCommands::Command.new(project, user, params).execute
+ else
+ url = authorize_chat_name_url(params)
+ Gitlab::SlashCommands::Presenters::Access.new(url).authorize
+ end
+ end
+
+ private
+
+ def find_chat_user(params)
+ ChatNames::FindUserService.new(self, params).execute
+ end
+
+ def authorize_chat_name_url(params)
+ ChatNames::AuthorizeUserService.new(self, params).execute
+ end
+end
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index cbaffb8ce48..cbe137452bd 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -50,12 +50,12 @@ class TeamcityService < CiService
def fields
[
{ type: 'text', name: 'teamcity_url',
- placeholder: 'TeamCity root URL like https://teamcity.example.com' },
+ placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true },
{ type: 'text', name: 'build_type',
- placeholder: 'Build configuration ID' },
+ placeholder: 'Build configuration ID', required: true },
{ type: 'text', name: 'username',
placeholder: 'A user with permissions to trigger a manual build' },
- { type: 'password', name: 'password' },
+ { type: 'password', name: 'password' }
]
end
@@ -78,7 +78,7 @@ class TeamcityService < CiService
auth = {
username: username,
- password: password,
+ password: password
}
branch = Gitlab::Git.ref_name(data[:ref])
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index aeaf63abab9..715b215d1db 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -14,7 +14,7 @@ class ProjectStatistics < ActiveRecord::Base
def refresh!(only: nil)
STATISTICS_COLUMNS.each do |column, generator|
if only.blank? || only.include?(column)
- public_send("update_#{column}")
+ public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 543b9b293e0..674eacd28e8 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -167,17 +167,24 @@ class ProjectTeam
access = RequestStore.store[key]
end
- # Lookup only the IDs we need
+ # Look up only the IDs we need
user_ids = user_ids - access.keys
return access if user_ids.empty?
- users_access = project.project_authorizations.
- where(user: user_ids).
- group(:user_id).
- maximum(:access_level)
+ users_access = project.project_authorizations
+ .where(user: user_ids)
+ .group(:user_id)
+ .maximum(:access_level)
access.merge!(users_access)
+
+ missing_user_ids = user_ids - users_access.keys
+
+ missing_user_ids.each do |user_id|
+ access[user_id] = Gitlab::Access::NO_ACCESS
+ end
+
access
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 70eef359cdd..698fdf7a20c 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -1,5 +1,6 @@
class ProjectWiki
include Gitlab::ShellAdapter
+ include Storage::LegacyProjectWiki
MARKUPS = {
'Markdown' => :markdown,
@@ -26,31 +27,31 @@ class ProjectWiki
@project.path + '.wiki'
end
- def path_with_namespace
- @project.path_with_namespace + ".wiki"
+ def full_path
+ @project.full_path + '.wiki'
end
+ # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
+ alias_method :path_with_namespace, :full_path
+
def web_url
- Gitlab::Routing.url_helpers.namespace_project_wiki_url(@project.namespace, @project, :home)
+ Gitlab::Routing.url_helpers.project_wiki_url(@project, :home)
end
def url_to_repo
- gitlab_shell.url_to_repo(path_with_namespace)
+ gitlab_shell.url_to_repo(full_path)
end
def ssh_url_to_repo
url_to_repo
end
- def http_url_to_repo(user = nil)
- url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git"
- credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user)
-
- Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url
+ def http_url_to_repo
+ "#{Gitlab.config.gitlab.url}/#{full_path}.git"
end
def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, "/", @project.path_with_namespace, "/wikis"].join('')
+ [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/wikis'].join('')
end
# Returns the Gollum::Wiki object.
@@ -66,6 +67,10 @@ class ProjectWiki
!!repository.exists?
end
+ def has_home_page?
+ !!find_page('home')
+ end
+
# Returns an Array of Gitlab WikiPage instances or an
# empty Array if this Wiki has no pages.
def pages
@@ -108,10 +113,10 @@ class ProjectWiki
return false
end
- def update_page(page, content, format = :markdown, message = nil)
+ def update_page(page, content:, title: nil, format: :markdown, message: nil)
commit = commit_details(:updated, message, page.title)
- wiki.update_page(page, page.name, format.to_sym, content, commit)
+ wiki.update_page(page, title || page.name, format.to_sym, content, commit)
update_project_activity
end
@@ -133,7 +138,7 @@ class ProjectWiki
end
def repository
- @repository ||= Repository.new(path_with_namespace, @project)
+ @repository ||= Repository.new(full_path, @project, disk_path: disk_path)
end
def default_branch
@@ -141,7 +146,7 @@ class ProjectWiki
end
def create_repo!
- if init_repo(path_with_namespace)
+ if init_repo(disk_path)
wiki = Gollum::Wiki.new(path_to_repo)
else
raise CouldNotCreateWikiError
@@ -152,20 +157,24 @@ class ProjectWiki
wiki
end
+ def ensure_repository
+ create_repo! unless repository_exists?
+ end
+
def hook_attrs
{
web_url: web_url,
git_ssh_url: ssh_url_to_repo,
git_http_url: http_url_to_repo,
- path_with_namespace: path_with_namespace,
+ path_with_namespace: full_path,
default_branch: default_branch
}
end
private
- def init_repo(path_with_namespace)
- gitlab_shell.add_repository(project.repository_storage_path, path_with_namespace)
+ def init_repo(disk_path)
+ gitlab_shell.add_repository(project.repository_storage_path, disk_path)
end
def commit_details(action, message = nil, title = nil)
@@ -179,10 +188,10 @@ class ProjectWiki
end
def path_to_repo
- @path_to_repo ||= File.join(project.repository_storage_path, "#{path_with_namespace}.git")
+ @path_to_repo ||= File.join(project.repository_storage_path, "#{disk_path}.git")
end
def update_project_activity
- @project.touch(:last_activity_at)
+ @project.touch(:last_activity_at, :last_repository_updated_at)
end
end
diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb
index 122fbce257d..c96edc5a259 100644
--- a/app/models/protectable_dropdown.rb
+++ b/app/models/protectable_dropdown.rb
@@ -1,5 +1,9 @@
class ProtectableDropdown
+ REF_TYPES = %i[branches tags].freeze
+
def initialize(project, ref_type)
+ raise ArgumentError, "invalid ref type `#{ref_type}`" unless ref_type.in?(REF_TYPES)
+
@project = project
@ref_type = ref_type
end
@@ -16,7 +20,7 @@ class ProtectableDropdown
private
def refs
- @project.repository.public_send(@ref_type)
+ @project.repository.public_send(@ref_type) # rubocop:disable GitlabSecurity/PublicSend
end
def ref_names
@@ -24,7 +28,7 @@ class ProtectableDropdown
end
def protections
- @project.public_send("protected_#{@ref_type}")
+ @project.public_send("protected_#{@ref_type}") # rubocop:disable GitlabSecurity/PublicSend
end
def non_wildcard_protected_ref_names
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 28b7d5ad072..5f0d0802ac9 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :merge_access_levels, dependent: :destroy
- has_many :push_access_levels, dependent: :destroy
-
- validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
- validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
-
- accepts_nested_attributes_for :push_access_levels
- accepts_nested_attributes_for :merge_access_levels
+ protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
def self.protected?(project, ref_name)
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index 771e3376613..e8d35ac326f 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,13 +1,3 @@
class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
include ProtectedBranchAccess
-
- validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER,
- Gitlab::Access::DEVELOPER] }
-
- def self.human_access_levels
- {
- Gitlab::Access::MASTER => "Masters",
- Gitlab::Access::DEVELOPER => "Developers + Masters"
- }.with_indifferent_access
- end
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 14610cb42b7..7a2e9e5ec5d 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,21 +1,3 @@
class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
include ProtectedBranchAccess
-
- 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/protected_tag.rb b/app/models/protected_tag.rb
index 83964095516..f38109c0e52 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :create_access_levels, dependent: :destroy
-
- validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
-
- accepts_nested_attributes_for :create_access_levels
+ protected_ref_access_levels :create
def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present?
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
new file mode 100644
index 00000000000..3f1ff979de6
--- /dev/null
+++ b/app/models/push_event.rb
@@ -0,0 +1,126 @@
+class PushEvent < Event
+ # This validation exists so we can't accidentally use PushEvent with a
+ # different "action" value.
+ validate :validate_push_action
+
+ # Authors are required as they're used to display who pushed data.
+ #
+ # We're just validating the presence of the ID here as foreign key constraints
+ # should ensure the ID points to a valid user.
+ validates :author_id, presence: true
+
+ # The project is required to build links to commits, commit ranges, etc.
+ #
+ # We're just validating the presence of the ID here as foreign key constraints
+ # should ensure the ID points to a valid project.
+ validates :project_id, presence: true
+
+ # The "data" field must not be set for push events since it's not used and a
+ # waste of space.
+ validates :data, absence: true
+
+ # These fields are also not used for push events, thus storing them would be a
+ # waste.
+ validates :target_id, absence: true
+ validates :target_type, absence: true
+
+ def self.sti_name
+ PUSHED
+ end
+
+ def push?
+ true
+ end
+
+ def push_with_commits?
+ !!(commit_from && commit_to)
+ end
+
+ def tag?
+ return super unless push_event_payload
+
+ push_event_payload.tag?
+ end
+
+ def branch?
+ return super unless push_event_payload
+
+ push_event_payload.branch?
+ end
+
+ def valid_push?
+ return super unless push_event_payload
+
+ push_event_payload.ref.present?
+ end
+
+ def new_ref?
+ return super unless push_event_payload
+
+ push_event_payload.created?
+ end
+
+ def rm_ref?
+ return super unless push_event_payload
+
+ push_event_payload.removed?
+ end
+
+ def commit_from
+ return super unless push_event_payload
+
+ push_event_payload.commit_from
+ end
+
+ def commit_to
+ return super unless push_event_payload
+
+ push_event_payload.commit_to
+ end
+
+ def ref_name
+ return super unless push_event_payload
+
+ push_event_payload.ref
+ end
+
+ def ref_type
+ return super unless push_event_payload
+
+ push_event_payload.ref_type
+ end
+
+ def branch_name
+ return super unless push_event_payload
+
+ ref_name
+ end
+
+ def tag_name
+ return super unless push_event_payload
+
+ ref_name
+ end
+
+ def commit_title
+ return super unless push_event_payload
+
+ push_event_payload.commit_title
+ end
+
+ def commit_id
+ commit_to || commit_from
+ end
+
+ def commits_count
+ return super unless push_event_payload
+
+ push_event_payload.commit_count
+ end
+
+ def validate_push_action
+ return if action == PUSHED
+
+ errors.add(:action, "the action #{action.inspect} is not valid")
+ end
+end
diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb
new file mode 100644
index 00000000000..6cdb1cd4fe9
--- /dev/null
+++ b/app/models/push_event_payload.rb
@@ -0,0 +1,22 @@
+class PushEventPayload < ActiveRecord::Base
+ include ShaAttribute
+
+ belongs_to :event, inverse_of: :push_event_payload
+
+ validates :event_id, :commit_count, :action, :ref_type, presence: true
+ validates :commit_title, length: { maximum: 70 }
+
+ sha_attribute :commit_from
+ sha_attribute :commit_to
+
+ enum action: {
+ created: 0,
+ removed: 1,
+ pushed: 2
+ }
+
+ enum ref_type: {
+ branch: 0,
+ tag: 1
+ }
+end
diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb
new file mode 100644
index 00000000000..1863a08f1de
--- /dev/null
+++ b/app/models/readme_blob.rb
@@ -0,0 +1,13 @@
+class ReadmeBlob < SimpleDelegator
+ attr_reader :repository
+
+ def initialize(blob, repository)
+ @repository = repository
+
+ super(blob)
+ end
+
+ def rendered_markup
+ repository.rendered_readme
+ end
+end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
new file mode 100644
index 00000000000..31de204d824
--- /dev/null
+++ b/app/models/redirect_route.rb
@@ -0,0 +1,20 @@
+class RedirectRoute < ActiveRecord::Base
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
+ validates :source, presence: true
+
+ validates :path,
+ length: { within: 1..255 },
+ presence: true,
+ uniqueness: { case_sensitive: false }
+
+ scope :matching_path_and_descendants, -> (path) do
+ wheres = if Gitlab::Database.postgresql?
+ 'LOWER(redirect_routes.path) = LOWER(?) OR LOWER(redirect_routes.path) LIKE LOWER(?)'
+ else
+ 'redirect_routes.path = ? OR redirect_routes.path LIKE ?'
+ end
+
+ where(wheres, path, "#{sanitize_sql_like(path)}/%")
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e74edb8e6f7..c1e4fcf94a4 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -4,7 +4,7 @@ class Repository
include Gitlab::ShellAdapter
include RepositoryMirroring
- attr_accessor :path_with_namespace, :project
+ attr_accessor :full_path, :disk_path, :project
delegate :ref_name_for_sha, to: :raw_repository
@@ -30,7 +30,7 @@ class Repository
METHOD_CACHES_FOR_FILE_TYPES = {
readme: :rendered_readme,
changelog: :changelog,
- license: %i(license_blob license_key),
+ license: %i(license_blob license_key license),
contributing: :contribution_guide,
gitignore: :gitignore,
koding: :koding_yml,
@@ -42,31 +42,36 @@ class Repository
# variable.
#
# This only works for methods that do not take any arguments.
- def self.cache_method(name, fallback: nil)
+ def self.cache_method(name, fallback: nil, memoize_only: false)
original = :"_uncached_#{name}"
alias_method(original, name)
define_method(name) do
- cache_method_output(name, fallback: fallback) { __send__(original) }
+ cache_method_output(name, fallback: fallback, memoize_only: memoize_only) do
+ __send__(original) # rubocop:disable GitlabSecurity/PublicSend
+ end
end
end
- def initialize(path_with_namespace, project)
- @path_with_namespace = path_with_namespace
+ def initialize(full_path, project, disk_path: nil)
+ @full_path = full_path
+ @disk_path = disk_path || full_path
@project = project
end
def raw_repository
- return nil unless path_with_namespace
+ return nil unless full_path
@raw_repository ||= initialize_raw_repository
end
+ alias_method :raw, :raw_repository
+
# Return absolute path to repository
def path_to_repo
@path_to_repo ||= File.expand_path(
- File.join(repository_storage_path, path_with_namespace + ".git")
+ File.join(repository_storage_path, disk_path + '.git')
)
end
@@ -123,21 +128,19 @@ class Repository
commits
end
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384
def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0)
unless exists? && has_visible_content? && query.present?
return []
end
- ref ||= root_ref
-
- args = %W(
- #{Gitlab.config.git.bin_path} log #{ref} --pretty=%H --skip #{offset}
- --max-count #{limit} --grep=#{query} --regexp-ignore-case
- )
- args = args.concat(%W(-- #{path})) if path.present?
-
- git_log_results = Gitlab::Popen.popen(args, path_to_repo).first.lines
- git_log_results.map { |c| commit(c.chomp) }.compact
+ raw_repository.gitaly_migrate(:commits_by_message) do |is_enabled|
+ if is_enabled
+ find_commits_by_message_by_gitaly(query, ref, path, limit, offset)
+ else
+ find_commits_by_message_by_shelling_out(query, ref, path, limit, offset)
+ end
+ end
end
def find_branch(name, fresh_repo: true)
@@ -223,7 +226,7 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes)
begin
- rugged.references.create(keep_around_ref_name(sha), sha, force: true)
+ write_ref(keep_around_ref_name(sha), sha)
rescue Rugged::ReferenceError => ex
Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
rescue Rugged::OSError => ex
@@ -236,16 +239,20 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
+ def write_ref(ref_path, sha)
+ rugged.references.create(ref_path, sha, force: true)
+ end
+
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
# Rugged seems to throw a `ReferenceError` when given branch_names rather
# than SHA-1 hashes
- number_commits_behind = raw_repository.
- count_commits_between(branch.dereferenced_target.sha, root_ref_hash)
+ number_commits_behind = raw_repository
+ .count_commits_between(branch.dereferenced_target.sha, root_ref_hash)
- number_commits_ahead = raw_repository.
- count_commits_between(root_ref_hash, branch.dereferenced_target.sha)
+ number_commits_ahead = raw_repository
+ .count_commits_between(root_ref_hash, branch.dereferenced_target.sha)
{ behind: number_commits_behind, ahead: number_commits_ahead }
end
@@ -299,7 +306,7 @@ class Repository
expire_method_caches(to_refresh)
- to_refresh.each { |method| send(method) }
+ to_refresh.each { |method| send(method) } # rubocop:disable GitlabSecurity/PublicSend
end
def expire_branch_cache(branch_name = nil)
@@ -438,9 +445,9 @@ class Repository
def method_missing(m, *args, &block)
if m == :lookup && !block_given?
lookup_cache[m] ||= {}
- lookup_cache[m][args.join(":")] ||= raw_repository.send(m, *args, &block)
+ lookup_cache[m][args.join(":")] ||= raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
else
- raw_repository.send(m, *args, &block)
+ raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -450,16 +457,12 @@ class Repository
def blob_at(sha, path)
unless Gitlab::Git.blank_ref?(sha)
- Blob.decorate(Gitlab::Git::Blob.find(self, sha, path))
+ Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project)
end
rescue Gitlab::Git::Repository::NoRepository
nil
end
- def blob_by_oid(oid)
- Gitlab::Git::Blob.raw(self, oid)
- end
-
def root_ref
if raw_repository
raw_repository.root_ref
@@ -470,8 +473,17 @@ class Repository
end
cache_method :root_ref
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/314
def exists?
- refs_directory_exists?
+ return false unless full_path
+
+ Gitlab::GitalyClient.migrate(:repository_exists) do |enabled|
+ if enabled
+ raw_repository.exists?
+ else
+ refs_directory_exists?
+ end
+ end
end
cache_method :exists?
@@ -505,14 +517,8 @@ class Repository
delegate :tag_names, to: :raw_repository
cache_method :tag_names, fallback: []
- def branch_count
- branches.size
- end
+ delegate :branch_count, :tag_count, to: :raw_repository
cache_method :branch_count, fallback: 0
-
- def tag_count
- raw_repository.rugged.tags.count
- end
cache_method :tag_count, fallback: 0
def avatar
@@ -523,8 +529,8 @@ class Repository
cache_method :avatar
def readme
- if head = tree(:head)
- head.readme
+ if readme = tree(:head)&.readme
+ ReadmeBlob.new(readme, self)
end
end
@@ -555,6 +561,13 @@ class Repository
end
cache_method :license_key
+ def license
+ return unless license_key
+
+ Licensee::License.new(license_key)
+ end
+ cache_method :license, memoize_only: true
+
def gitignore
file_on_head(:gitignore)
end
@@ -604,33 +617,27 @@ class Repository
end
end
- # Returns url for submodule
- #
- # Ex.
- # @repository.submodule_url_for('master', 'rack')
- # # => git@localhost:rack.git
- #
- def submodule_url_for(ref, path)
- if submodules(ref).any?
- submodule = submodules(ref)[path]
-
- if submodule
- submodule['url']
+ def last_commit_for_path(sha, path)
+ raw_repository.gitaly_migrate(:last_commit_for_path) do |is_enabled|
+ if is_enabled
+ last_commit_for_path_by_gitaly(sha, path)
+ else
+ last_commit_for_path_by_rugged(sha, path)
end
end
end
- def last_commit_for_path(sha, path)
- sha = last_commit_id_for_path(sha, path)
- commit(sha)
- end
-
def last_commit_id_for_path(sha, path)
key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}"
cache.fetch(key) do
- args = %W(#{Gitlab.config.git.bin_path} rev-list --max-count=1 #{sha} -- #{path})
- Gitlab::Popen.popen(args, path_to_repo).first.strip
+ raw_repository.gitaly_migrate(:last_commit_for_path) do |is_enabled|
+ if is_enabled
+ last_commit_for_path_by_gitaly(sha, path).id
+ else
+ last_commit_id_for_path_by_shelling_out(sha, path)
+ end
+ end
end
end
@@ -648,22 +655,8 @@ class Repository
"#{name}-#{highest_branch_id + 1}"
end
- # Remove archives older than 2 hours
def branches_sorted_by(value)
- case value
- when 'name'
- branches.sort_by(&:name)
- when 'updated_desc'
- branches.sort do |a, b|
- commit(b.dereferenced_target).committed_date <=> commit(a.dereferenced_target).committed_date
- end
- when 'updated_asc'
- branches.sort do |a, b|
- commit(a.dereferenced_target).committed_date <=> commit(b.dereferenced_target).committed_date
- end
- else
- branches
- end
+ raw_repository.local_branches(sort_by: value)
end
def tags_sorted_by(value)
@@ -699,8 +692,8 @@ class Repository
end
def refs_contains_sha(ref_type, sha)
- args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
- names = Gitlab::Popen.popen(args, path_to_repo).first
+ args = %W(#{ref_type} --contains #{sha})
+ names = run_git(args).first
if names.respond_to?(:split)
names = names.split("\n").map(&:strip)
@@ -778,14 +771,14 @@ class Repository
index = Gitlab::Git::Index.new(raw_repository)
if start_commit
- index.read_tree(start_commit.raw_commit.tree)
+ index.read_tree(start_commit.rugged_commit.tree)
parents = [start_commit.sha]
else
parents = []
end
actions.each do |options|
- index.public_send(options.delete(:action), options)
+ index.public_send(options.delete(:action), options) # rubocop:disable GitlabSecurity/PublicSend
end
options = {
@@ -795,7 +788,7 @@ class Repository
}
options.merge!(get_committer_and_author(user, email: author_email, name: author_name))
- Rugged::Commit.create(rugged, options)
+ create_commit(options)
end
end
# rubocop:enable Metrics/ParameterLists
@@ -839,10 +832,10 @@ class Repository
actual_options = options.merge(
parents: [our_commit, their_commit],
- tree: merge_index.write_tree(rugged),
+ tree: merge_index.write_tree(rugged)
)
- commit_id = Rugged::Commit.create(rugged, actual_options)
+ commit_id = create_commit(actual_options)
merge_request.update(in_progress_merge_commit_sha: commit_id)
commit_id
end
@@ -865,12 +858,11 @@ class Repository
committer = user_to_committer(user)
- Rugged::Commit.create(rugged,
- message: commit.revert_message(user),
- author: committer,
- committer: committer,
- tree: revert_tree_id,
- parents: [start_commit.sha])
+ create_commit(message: commit.revert_message(user),
+ author: committer,
+ committer: committer,
+ tree: revert_tree_id,
+ parents: [start_commit.sha])
end
end
@@ -889,16 +881,15 @@ class Repository
committer = user_to_committer(user)
- Rugged::Commit.create(rugged,
- message: commit.message,
- author: {
- email: commit.author_email,
- name: commit.author_name,
- time: commit.authored_date
- },
- committer: committer,
- tree: cherry_pick_tree_id,
- parents: [start_commit.sha])
+ create_commit(message: commit.message,
+ author: {
+ email: commit.author_email,
+ name: commit.author_name,
+ time: commit.authored_date
+ },
+ committer: committer,
+ tree: cherry_pick_tree_id,
+ parents: [start_commit.sha])
end
end
@@ -906,7 +897,7 @@ class Repository
GitOperationService.new(user, self).with_branch(branch_name) do
committer = user_to_committer(user)
- Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
+ create_commit(params.merge(author: committer, committer: committer))
end
end
@@ -961,15 +952,15 @@ class Repository
end
def is_ancestor?(ancestor_id, descendant_id)
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/30586 is resolved
- # Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
- # if is_enabled
- # raw_repository.is_ancestor?(ancestor_id, descendant_id)
- # else
- merge_base_commit(ancestor_id, descendant_id) == ancestor_id
- # end
- # end
+ return false if ancestor_id.nil? || descendant_id.nil?
+
+ Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
+ if is_enabled
+ raw_repository.is_ancestor?(ancestor_id, descendant_id)
+ else
+ rugged_is_ancestor?(ancestor_id, descendant_id)
+ end
+ end
end
def empty_repo?
@@ -980,15 +971,17 @@ class Repository
return [] if empty_repo? || query.blank?
offset = 2
- args = %W(#{Gitlab.config.git.bin_path} grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
- Gitlab::Popen.popen(args, path_to_repo).first.scrub.split(/^--$/)
+ args = %W(grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
+
+ run_git(args).first.scrub.split(/^--$/)
end
def search_files_by_name(query, ref)
return [] if empty_repo? || query.blank?
- args = %W(#{Gitlab.config.git.bin_path} ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)})
- Gitlab::Popen.popen(args, path_to_repo).first.lines.map(&:strip)
+ args = %W(ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)})
+
+ run_git(args).first.lines.map(&:strip)
end
def with_repo_branch_commit(start_repository, start_branch_name)
@@ -998,12 +991,10 @@ class Repository
if start_repository == self
start_branch_name
else
- tmp_ref = "refs/tmp/#{SecureRandom.hex}/head"
-
- fetch_ref(
+ tmp_ref = fetch_ref(
start_repository.path_to_repo,
"#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}",
- tmp_ref
+ "refs/tmp/#{SecureRandom.hex}/head"
)
start_repository.commit(start_branch_name).sha
@@ -1029,12 +1020,17 @@ class Repository
end
def fetch_remote(remote, forced: false, no_tags: false)
- gitlab_shell.fetch_remote(repository_storage_path, path_with_namespace, remote, forced: forced, no_tags: no_tags)
+ gitlab_shell.fetch_remote(repository_storage_path, disk_path, remote, forced: forced, no_tags: no_tags)
end
def fetch_ref(source_path, source_ref, target_ref)
- args = %W(#{Gitlab.config.git.bin_path} fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref})
- Gitlab::Popen.popen(args, path_to_repo)
+ args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref})
+ message, status = run_git(args)
+
+ # Make sure ref was created, and raise Rugged::ReferenceError when not
+ raise Rugged::ReferenceError, message if status != 0
+
+ target_ref
end
def create_ref(ref, ref_path)
@@ -1071,14 +1067,20 @@ class Repository
#
# key - The name of the key to cache the data in.
# fallback - A value to fall back to in the event of a Git error.
- def cache_method_output(key, fallback: nil, &block)
+ def cache_method_output(key, fallback: nil, memoize_only: false, &block)
ivar = cache_instance_variable_name(key)
if instance_variable_defined?(ivar)
instance_variable_get(ivar)
else
begin
- instance_variable_set(ivar, cache.fetch(key, &block))
+ value =
+ if memoize_only
+ yield
+ else
+ cache.fetch(key, &block)
+ end
+ instance_variable_set(ivar, value)
rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository
# if e.g. HEAD or the entire repository doesn't exist we want to
# gracefully handle this and not cache anything.
@@ -1093,8 +1095,8 @@ class Repository
def file_on_head(type)
if head = tree(:head)
- head.blobs.find do |file|
- Gitlab::FileDetector.type_of(file.name) == type
+ head.blobs.find do |blob|
+ Gitlab::FileDetector.type_of(blob.path) == type
end
end
end
@@ -1103,28 +1105,35 @@ class Repository
blob_data_at(sha, '.gitlab/route-map.yml')
end
- def gitlab_ci_yml_for(sha)
- blob_data_at(sha, '.gitlab-ci.yml')
+ def gitlab_ci_yml_for(sha, path = '.gitlab-ci.yml')
+ blob_data_at(sha, path)
end
private
+ def run_git(args)
+ circuit_breaker.perform do
+ Gitlab::Popen.popen([Gitlab.config.git.bin_path, *args], path_to_repo)
+ end
+ end
+
def blob_data_at(sha, path)
blob = blob_at(sha, path)
return unless blob
- blob.load_all_data!(self)
+ blob.load_all_data!
blob.data
end
def refs_directory_exists?
- return false unless path_with_namespace
-
- File.exist?(File.join(path_to_repo, 'refs'))
+ circuit_breaker.perform do
+ File.exist?(File.join(path_to_repo, 'refs'))
+ end
end
def cache
- @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
+ # TODO: should we use UUIDs here? We could move repositories without clearing this cache
+ @cache ||= RepositoryCache.new(full_path, @project.id)
end
def tags_sorted_by_committed_date
@@ -1147,16 +1156,60 @@ class Repository
end
def repository_event(event, tags = {})
- Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
+ Gitlab::Metrics.add_event(event, { path: full_path }.merge(tags))
+ end
+
+ def create_commit(params = {})
+ params[:message].delete!("\r")
+
+ Rugged::Commit.create(rugged, params)
+ end
+
+ def last_commit_for_path_by_gitaly(sha, path)
+ c = raw_repository.gitaly_commit_client.last_commit_for_path(sha, path)
+ commit(c)
+ end
+
+ def last_commit_for_path_by_rugged(sha, path)
+ sha = last_commit_id_for_path_by_shelling_out(sha, path)
+ commit(sha)
+ end
+
+ def last_commit_id_for_path_by_shelling_out(sha, path)
+ args = %W(rev-list --max-count=1 #{sha} -- #{path})
+ run_git(args).first.strip
end
def repository_storage_path
@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')
+ Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git')
+ end
+
+ def circuit_breaker
+ @circuit_breaker ||= Gitlab::Git::Storage::CircuitBreaker.for_storage(project.repository_storage)
+ end
+
+ def find_commits_by_message_by_shelling_out(query, ref, path, limit, offset)
+ ref ||= root_ref
+
+ args = %W(
+ log #{ref} --pretty=%H --skip #{offset}
+ --max-count #{limit} --grep=#{query} --regexp-ignore-case
+ )
+ args = args.concat(%W(-- #{path})) if path.present?
+
+ git_log_results = run_git(args).first.lines
+
+ git_log_results.map { |c| commit(c.chomp) }.compact
+ end
+
+ def find_commits_by_message_by_gitaly(query, ref, path, limit, offset)
+ raw_repository
+ .gitaly_commit_client
+ .commits_by_message(query, revision: ref, path: path, limit: limit, offset: offset)
+ .map { |c| commit(c) }
end
end
diff --git a/app/models/route.rb b/app/models/route.rb
index 4b3efab5c3c..97e8a6ad9e9 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,5 +1,5 @@
class Route < ActiveRecord::Base
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
@@ -8,29 +8,58 @@ class Route < ActiveRecord::Base
presence: true,
uniqueness: { case_sensitive: false }
+ after_create :delete_conflicting_redirects
+ after_update :delete_conflicting_redirects, if: :path_changed?
+ after_update :create_redirect_for_old_path
after_update :rename_descendants
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
def rename_descendants
- if path_changed? || name_changed?
- descendants = self.class.inside_path(path_was)
+ return unless path_changed? || name_changed?
- descendants.each do |route|
- attributes = {}
+ descendant_routes = self.class.inside_path(path_was)
- if path_changed? && route.path.present?
- attributes[:path] = route.path.sub(path_was, path)
- end
+ descendant_routes.each do |route|
+ attributes = {}
- if name_changed? && name_was.present? && route.name.present?
- attributes[:name] = route.name.sub(name_was, name)
- end
+ if path_changed? && route.path.present?
+ attributes[:path] = route.path.sub(path_was, path)
+ end
- # Note that update_columns skips validation and callbacks.
- # We need this to avoid recursive call of rename_descendants method
- route.update_columns(attributes) unless attributes.empty?
+ if name_changed? && name_was.present? && route.name.present?
+ attributes[:name] = route.name.sub(name_was, name)
+ end
+
+ if attributes.present?
+ old_path = route.path
+
+ # Callbacks must be run manually
+ route.update_columns(attributes.merge(updated_at: Time.now))
+
+ # We are not calling route.delete_conflicting_redirects here, in hopes
+ # of avoiding deadlocks. The parent (self, in this method) already
+ # called it, which deletes conflicts for all descendants.
+ route.create_redirect(old_path) if attributes[:path]
end
end
end
+
+ def delete_conflicting_redirects
+ conflicting_redirects.delete_all
+ end
+
+ def conflicting_redirects
+ RedirectRoute.matching_path_and_descendants(path)
+ end
+
+ def create_redirect(path)
+ RedirectRoute.create(source: source, path: path)
+ end
+
+ private
+
+ def create_redirect_for_old_path
+ create_redirect(path_was) if path_changed?
+ end
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index bfaf0eb2fae..298569cb7a6 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,8 +1,8 @@
class SentNotification < ActiveRecord::Base
- serialize :position, Gitlab::Diff::Position
+ serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :project
- belongs_to :noteable, polymorphic: true
+ belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :recipient, class_name: "User"
validates :project, :recipient, presence: true
@@ -39,7 +39,7 @@ class SentNotification < ActiveRecord::Base
noteable_type: noteable.class.name,
noteable_id: noteable_id,
- commit_id: commit_id,
+ commit_id: commit_id
)
create(attrs)
diff --git a/app/models/service.rb b/app/models/service.rb
index c71a7d169ec..6b64079215f 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,7 +2,7 @@
# and implement a set of methods
class Service < ActiveRecord::Base
include Sortable
- serialize :properties, JSON
+ serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
default_value_for :push_events, true
@@ -12,7 +12,7 @@ class Service < ActiveRecord::Base
default_value_for :merge_requests_events, true
default_value_for :tag_push_events, true
default_value_for :note_events, true
- default_value_for :build_events, true
+ default_value_for :job_events, true
default_value_for :pipeline_events, true
default_value_for :wiki_page_events, true
@@ -40,7 +40,7 @@ class Service < ActiveRecord::Base
scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) }
scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
scope :note_hooks, -> { where(note_events: true, active: true) }
- scope :build_hooks, -> { where(build_events: true, active: true) }
+ scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
@@ -51,6 +51,14 @@ class Service < ActiveRecord::Base
active
end
+ def show_active_box?
+ true
+ end
+
+ def editable?
+ true
+ end
+
def template?
template
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 380835707e8..09d5ff46618 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -1,6 +1,5 @@
class Snippet < ActiveRecord::Base
include Gitlab::VisibilityLevel
- include Linguist::BlobHelper
include CacheMarkdownField
include Noteable
include Participable
@@ -9,10 +8,17 @@ class Snippet < ActiveRecord::Base
include Awardable
include Mentionable
include Spammable
+ include Editable
cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
cache_markdown_field :content
+ # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102
+ alias_attribute :last_edited_at, :updated_at
+ alias_attribute :last_edited_by, :updated_by
+
# If file_name changes, it invalidates content
alias_method :default_content_html_invalidator, :content_html_invalidated?
def content_html_invalidated?
@@ -24,16 +30,14 @@ class Snippet < ActiveRecord::Base
belongs_to :author, class_name: 'User'
belongs_to :project
- has_many :notes, as: :noteable, dependent: :destroy
+ has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :author, presence: true
validates :title, presence: true, length: { maximum: 255 }
validates :file_name,
- length: { maximum: 255 },
- format: { with: Gitlab::Regex.file_name_regex,
- message: Gitlab::Regex.file_name_regex_message }
+ length: { maximum: 255 }
validates :content, presence: true
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
@@ -87,47 +91,26 @@ class Snippet < ActiveRecord::Base
]
end
- def data
- content
+ def blob
+ @blob ||= Blob.decorate(SnippetBlob.new(self), nil)
end
def hook_attrs
attributes
end
- def size
- 0
- end
-
def file_name
super.to_s
end
- # alias for compatibility with blobs and highlighting
- def path
- file_name
- end
-
- def name
- file_name
- end
-
def sanitized_file_name
file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '')
end
- def mode
- nil
- end
-
def visibility_level_field
:visibility_level
end
- def no_highlighting?
- content.lines.count > 1000
- end
-
def notes_with_associations
notes.includes(:author)
end
@@ -169,18 +152,5 @@ class Snippet < ActiveRecord::Base
where(table[:content].matches(pattern))
end
-
- def accessible_to(user)
- return are_public unless user.present?
- return all if user.admin?
-
- where(
- 'visibility_level IN (:visibility_levels)
- OR author_id = :author_id
- OR project_id IN (:project_ids)',
- visibility_levels: [Snippet::PUBLIC, Snippet::INTERNAL],
- author_id: user.id,
- project_ids: user.authorized_projects.select(:id))
- end
end
end
diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb
new file mode 100644
index 00000000000..fa5fa151607
--- /dev/null
+++ b/app/models/snippet_blob.rb
@@ -0,0 +1,31 @@
+class SnippetBlob
+ include BlobLike
+
+ attr_reader :snippet
+
+ def initialize(snippet)
+ @snippet = snippet
+ end
+
+ delegate :id, to: :snippet
+
+ def name
+ snippet.file_name
+ end
+
+ alias_method :path, :name
+
+ def size
+ data.bytesize
+ end
+
+ def data
+ snippet.content
+ end
+
+ def rendered_markup
+ return unless Gitlab::MarkupHelper.gitlab_markdown?(name)
+
+ Banzai.render_field(snippet, :content)
+ end
+end
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index dd21ee15c6c..56a115d1db4 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base
validates :user, presence: true
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def text
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 17869c8bac2..2f0c9640744 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,7 +1,7 @@
class Subscription < ActiveRecord::Base
belongs_to :user
belongs_to :project
- belongs_to :subscribable, polymorphic: true
+ belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user, :subscribable, presence: true
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 1e6fc837a75..0b33e45473b 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,7 +1,9 @@
class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[
- commit merge confidential visible label assignee cross_reference
- title time_tracking branch milestone discussion task moved opened closed merged
+ commit description merge confidential visible label assignee cross_reference
+ title time_tracking branch milestone discussion task moved
+ opened closed merged duplicate
+ outdated
].freeze
validates :note, presence: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index da3fa7277c2..7af54b2beb2 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
- belongs_to :target, polymorphic: true, touch: true
+ belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
@@ -70,9 +70,9 @@ class Todo < ActiveRecord::Base
highest_priority = highest_label_priority(params).to_sql
- select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
- order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')).
- order('todos.created_at')
+ select("#{table_name}.*, (#{highest_priority}) AS highest_priority")
+ .order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
+ .order('todos.created_at')
end
end
@@ -84,6 +84,10 @@ class Todo < ActiveRecord::Base
action == BUILD_FAILED
end
+ def assigned?
+ action == ASSIGNED
+ end
+
def action_name
ACTION_NAMES[action]
end
@@ -117,6 +121,14 @@ class Todo < ActiveRecord::Base
end
end
+ def self_added?
+ author == user
+ end
+
+ def self_assigned?
+ assigned? && self_added?
+ end
+
private
def keep_around_commit
diff --git a/app/models/tree.rb b/app/models/tree.rb
index fe148b0ec65..c89b8eca9be 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -40,10 +40,7 @@ class Tree
readme_path = path == '/' ? readme_tree.name : File.join(path, readme_tree.name)
- git_repo = repository.raw_repository
- @readme = Gitlab::Git::Blob.find(git_repo, sha, readme_path)
- @readme.load_all_data!(git_repo)
- @readme
+ @readme = repository.blob_at(sha, readme_path)
end
def trees
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 13987931b05..f194d7bdb80 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -2,7 +2,7 @@ class Upload < ActiveRecord::Base
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
- belongs_to :model, polymorphic: true
+ belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :size, presence: true
validates :path, presence: true
diff --git a/app/models/user.rb b/app/models/user.rb
index bd9c9f99663..02c3ab6654b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,15 +5,22 @@ class User < ActiveRecord::Base
include Gitlab::ConfigHelper
include Gitlab::CurrentSettings
+ include Avatarable
include Referable
include Sortable
include CaseSensitivity
include TokenAuthenticatable
+ include IgnorableColumn
+ include FeatureGate
+ include CreatedAtFilterable
DEFAULT_NOTIFICATION_LEVEL = :participating
+ ignore_column :authorized_projects_populated
+
add_authentication_token_field :authentication_token
add_authentication_token_field :incoming_email_token
+ add_authentication_token_field :rss_token
default_value_for :admin, false
default_value_for(:external) { current_application_settings.user_default_external }
@@ -23,6 +30,7 @@ class User < ActiveRecord::Base
default_value_for :hide_no_password, false
default_value_for :project_view, :files
default_value_for :notified_of_own_activity, false
+ default_value_for :preferred_language, I18n.default_locale
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
@@ -34,11 +42,22 @@ class User < ActiveRecord::Base
otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
- serialize :otp_backup_codes, JSON
+ serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
+ # Override Devise::Models::Trackable#update_tracked_fields!
+ # to limit database writes to at most once every hour
+ def update_tracked_fields!(request)
+ update_tracked_fields(request)
+
+ lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i)
+ return unless lease.try_obtain
+
+ Users::UpdateService.new(self).execute(validate: false)
+ end
+
attr_accessor :force_random_password
# Virtual attribute for authenticating by either username or email
@@ -49,24 +68,25 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id
+ has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true # rubocop:disable Cop/ActiveRecordDependent
# Profile
has_many :keys, -> do
type = Key.arel_table[:type]
where(type.not_eq('DeployKey').or(type.eq(nil)))
- end, dependent: :destroy
- has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy
+ end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :gpg_keys
- has_many :emails, dependent: :destroy
- has_many :personal_access_tokens, dependent: :destroy
- has_many :identities, dependent: :destroy, autosave: true
- has_many :u2f_registrations, dependent: :destroy
- has_many :chat_names, dependent: :destroy
+ has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Groups
- has_many :members, dependent: :destroy
- has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, source: 'GroupMember'
+ has_many :members, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, source: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :groups, through: :group_members
has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group
has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group
@@ -74,35 +94,35 @@ class User < ActiveRecord::Base
# Projects
has_many :groups_projects, through: :groups, source: :projects
has_many :personal_projects, through: :namespace, source: :projects
- has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy
+ has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
- has_many :users_star_projects, dependent: :destroy
+ has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations
has_many :authorized_projects, through: :project_authorizations, source: :project
- has_many :snippets, dependent: :destroy, foreign_key: :author_id
- has_many :notes, dependent: :destroy, foreign_key: :author_id
- has_many :merge_requests, dependent: :destroy, foreign_key: :author_id
- has_many :events, dependent: :destroy, foreign_key: :author_id
- has_many :subscriptions, dependent: :destroy
+ has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :events, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
- has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
- has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
- has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
- has_many :spam_logs, dependent: :destroy
- has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
- has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
- has_many :todos, dependent: :destroy
- has_many :notification_settings, dependent: :destroy
- has_many :award_emoji, dependent: :destroy
- has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
-
- # Issues that a user owns are expected to be moved to the "ghost" user before
- # the user is destroyed. If the user owns any issues during deletion, this
- # should be treated as an exceptional condition.
- has_many :issues, dependent: :restrict_with_exception, foreign_key: :author_id
+ has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
+ has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :notification_settings, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :issue_assignees
+ has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
+ has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
#
# Validations
@@ -118,26 +138,29 @@ class User < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
validates :username,
- namespace: true,
+ dynamic_path: true,
presence: true,
uniqueness: { case_sensitive: false }
- validate :namespace_uniq, if: ->(user) { user.username_changed? }
+ validate :namespace_uniq, if: :username_changed?
+ validate :namespace_move_dir_allowed, if: :username_changed?
+
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
- validate :unique_email, if: ->(user) { user.email_changed? }
- validate :owns_notification_email, if: ->(user) { user.notification_email_changed? }
- validate :owns_public_email, if: ->(user) { user.public_email_changed? }
+ validate :unique_email, if: :email_changed?
+ validate :owns_notification_email, if: :notification_email_changed?
+ validate :owns_public_email, if: :public_email_changed?
validate :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id }
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
before_validation :sanitize_attrs
- before_validation :set_notification_email, if: ->(user) { user.email_changed? }
- before_validation :set_public_email, if: ->(user) { user.public_email_changed? }
+ before_validation :set_notification_email, if: :email_changed?
+ before_validation :set_public_email, if: :public_email_changed?
- after_update :update_emails_with_primary_email, if: ->(user) { user.email_changed? }
+ after_update :update_emails_with_primary_email, if: :email_changed?
before_save :ensure_authentication_token, :ensure_incoming_email_token
- before_save :ensure_external_user_rights
+ before_save :ensure_user_rights_and_limits, if: :external_changed?
after_save :ensure_namespace_correct
+ after_commit :update_invalid_gpg_signatures, on: :update, if: -> { previous_changes.key?('email') }
after_initialize :set_projects_limit
after_destroy :post_destroy_hook
@@ -149,8 +172,13 @@ class User < ActiveRecord::Base
enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos]
# User's Project preference
- # Note: When adding an option, it MUST go on the end of the array.
- enum project_view: [:readme, :activity, :files]
+ #
+ # Note: When adding an option, it MUST go on the end of the hash with a
+ # number higher than the current max. We cannot move options and/or change
+ # their numbers.
+ #
+ # We skip 0 because this was used by an option that has since been removed.
+ enum project_view: { activity: 1, files: 2 }
alias_attribute :private_token, :authentication_token
@@ -188,27 +216,26 @@ class User < ActiveRecord::Base
end
mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Scopes
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
- scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all }
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') }
scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'ASC')) }
def self.with_two_factor
- joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id").
- where("u2f.id IS NOT NULL OR otp_required_for_login = ?", true).distinct(arel_table[:id])
+ joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
+ .where("u2f.id IS NOT NULL OR otp_required_for_login = ?", true).distinct(arel_table[:id])
end
def self.without_two_factor
- joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id").
- where("u2f.id IS NULL AND otp_required_for_login = ?", false)
+ joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
+ .where("u2f.id IS NULL AND otp_required_for_login = ?", false)
end
#
@@ -278,11 +305,20 @@ class User < ActiveRecord::Base
table = arel_table
pattern = "%#{query}%"
+ order = <<~SQL
+ CASE
+ WHEN users.name = %{query} THEN 0
+ WHEN users.username = %{query} THEN 1
+ WHEN users.email = %{query} THEN 2
+ ELSE 3
+ END
+ SQL
+
where(
- table[:name].matches(pattern).
- or(table[:email].matches(pattern)).
- or(table[:username].matches(pattern))
- )
+ table[:name].matches(pattern)
+ .or(table[:email].matches(pattern))
+ .or(table[:username].matches(pattern))
+ ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
end
# searches user by given pattern
@@ -296,10 +332,10 @@ class User < ActiveRecord::Base
matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].matches(pattern))
where(
- table[:name].matches(pattern).
- or(table[:email].matches(pattern)).
- or(table[:username].matches(pattern)).
- or(table[:id].in(matched_by_emails_user_ids))
+ table[:name].matches(pattern)
+ .or(table[:email].matches(pattern))
+ .or(table[:username].matches(pattern))
+ .or(table[:id].in(matched_by_emails_user_ids))
)
end
@@ -332,6 +368,11 @@ class User < ActiveRecord::Base
find_by(id: Key.unscoped.select(:user_id).where(id: key_id))
end
+ def find_by_full_path(path, follow_redirects: false)
+ namespace = Namespace.for_user.find_by_full_path(path, follow_redirects: follow_redirects)
+ namespace&.owner
+ end
+
def reference_prefix
'@'
end
@@ -339,21 +380,28 @@ class User < ActiveRecord::Base
# Pattern used to extract `@user` user references from text
def reference_pattern
%r{
+ (?<!\w)
#{Regexp.escape(reference_prefix)}
- (?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR})
+ (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
}x
end
# Return (create if necessary) the ghost user. The ghost user
# owns records previously belonging to deleted users.
def ghost
- unique_internal(where(ghost: true), 'ghost', 'ghost%s@example.com') do |u|
+ email = 'ghost%s@example.com'
+ unique_internal(where(ghost: true), 'ghost', email) do |u|
u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.'
u.name = 'Ghost User'
+ u.notification_email = email
end
end
end
+ def full_path
+ username
+ end
+
def self.internal_attributes
[:ghost]
end
@@ -436,6 +484,12 @@ class User < ActiveRecord::Base
end
end
+ def namespace_move_dir_allowed
+ if namespace&.any_project_has_container_registry_tags?
+ errors.add(:username, 'cannot be changed if a personal project has container registry tags.')
+ end
+ end
+
def avatar_type
unless avatar.image?
errors.add :avatar, "only images allowed"
@@ -463,38 +517,33 @@ class User < ActiveRecord::Base
def update_emails_with_primary_email
primary_email_record = emails.find_by(email: email)
if primary_email_record
- primary_email_record.destroy
- emails.create(email: email_was)
-
- update_secondary_emails!
+ Emails::DestroyService.new(self, email: email).execute
+ Emails::CreateService.new(self, email: email_was).execute
end
end
+ def update_invalid_gpg_signatures
+ gpg_keys.each(&:update_invalid_gpg_signatures)
+ end
+
# Returns the groups a user has access to
def authorized_groups
- union = Gitlab::SQL::Union.
- new([groups.select(:id), authorized_projects.select(:namespace_id)])
-
- Group.where("namespaces.id IN (#{union.to_sql})")
- end
+ union = Gitlab::SQL::Union
+ .new([groups.select(:id), authorized_projects.select(:namespace_id)])
- def nested_groups
- Group.member_descendants(id)
+ Group.where("namespaces.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
+ # Returns a relation of groups the user has access to, including their parent
+ # and child groups (recursively).
def all_expanded_groups
- Group.member_hierarchy(id)
+ Gitlab::GroupHierarchy.new(groups).all_groups
end
def expanded_groups_requiring_two_factor_authentication
all_expanded_groups.where(require_two_factor_authentication: true)
end
- def nested_groups_projects
- Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
- member_descendants(id)
- end
-
def refresh_authorized_projects
Users::RefreshAuthorizedProjectsService.new(self).execute
end
@@ -503,18 +552,15 @@ class User < ActiveRecord::Base
project_authorizations.where(project_id: project_ids).delete_all
end
- def set_authorized_projects_column
- unless authorized_projects_populated
- update_column(:authorized_projects_populated, true)
- end
- end
-
def authorized_projects(min_access_level = nil)
- refresh_authorized_projects unless authorized_projects_populated
-
- # We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association
+ # We're overriding an association, so explicitly call super with no
+ # arguments or it would be passed as `force_reload` to the association
projects = super()
- projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level
+
+ if min_access_level
+ projects = projects
+ .where('project_authorizations.access_level >= ?', min_access_level)
+ end
projects
end
@@ -533,12 +579,6 @@ class User < ActiveRecord::Base
authorized_projects(Gitlab::Access::REPORTER).where(id: projects)
end
- def viewable_starred_projects
- starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)",
- [Project::PUBLIC, Project::INTERNAL],
- authorized_projects.select(:project_id))
- end
-
def owned_projects
@owned_projects ||=
Project.where('namespace_id IN (?) OR namespace_id = ?',
@@ -556,8 +596,18 @@ class User < ActiveRecord::Base
keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh')
end
- def require_password?
- password_automatically_set? && !ldap_user?
+ def require_password_creation?
+ password_automatically_set? && allow_password_authentication?
+ end
+
+ def require_personal_access_token_creation_for_git_auth?
+ return false if allow_password_authentication? || ldap_user?
+
+ PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
+ end
+
+ def allow_password_authentication?
+ !ldap_user? && current_application_settings.password_authentication_enabled?
end
def can_change_username?
@@ -585,7 +635,11 @@ class User < ActiveRecord::Base
end
def projects_limit_left
- projects_limit - personal_projects.count
+ projects_limit - personal_projects_count
+ end
+
+ def personal_projects_count
+ @personal_projects_count ||= personal_projects.count
end
def projects_limit_percent
@@ -599,16 +653,14 @@ class User < ActiveRecord::Base
events = events.where(project_id: project_ids) if project_ids
# Use the latest event that has not been pushed or merged recently
- events.recent.find do |event|
- project = Project.find_by_id(event.project_id)
- next unless project
-
- if project.repository.branch_exists?(event.branch_name)
- merge_requests = MergeRequest.where("created_at >= ?", event.created_at).
- where(source_project_id: project.id,
- source_branch: event.branch_name)
- merge_requests.empty?
- end
+ events.includes(:project).recent.find do |event|
+ next unless event.project.repository.branch_exists?(event.branch_name)
+
+ merge_requests = MergeRequest.where("created_at >= ?", event.created_at)
+ .where(source_project_id: event.project.id,
+ source_branch: event.branch_name)
+
+ merge_requests.empty?
end
end
@@ -669,9 +721,9 @@ class User < ActiveRecord::Base
end
def sanitize_attrs
- %w[name username skype linkedin twitter].each do |attr|
- value = public_send(attr)
- public_send("#{attr}=", Sanitize.clean(value)) if value.present?
+ %i[skype linkedin twitter].each do |attr|
+ value = self[attr]
+ self[attr] = Sanitize.clean(value) if value.present?
end
end
@@ -730,7 +782,7 @@ class User < ActiveRecord::Base
def with_defaults
User.defaults.each do |k, v|
- public_send("#{k}=", v)
+ public_send("#{k}=", v) # rubocop:disable GitlabSecurity/PublicSend
end
self
@@ -759,12 +811,10 @@ class User < ActiveRecord::Base
email.start_with?('temp-email-for-oauth')
end
- def avatar_url(size = nil, scale = 2)
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- else
- GravatarService.new.execute(email, size, scale)
- end
+ def avatar_url(size: nil, scale: 2, **args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args) || GravatarService.new.execute(email, size, scale, username: username)
end
def all_emails
@@ -778,7 +828,7 @@ class User < ActiveRecord::Base
{
name: name,
username: username,
- avatar_url: avatar_url
+ avatar_url: avatar_url(only_path: false)
}
end
@@ -796,6 +846,11 @@ class User < ActiveRecord::Base
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def delete_async(deleted_by:, params: {})
+ block if params[:hard_delete]
+ DeleteUserWorker.perform_async(deleted_by.id, id, params)
+ end
+
def notification_service
NotificationService.new
end
@@ -814,8 +869,8 @@ class User < ActiveRecord::Base
def toggle_star(project)
UsersStarProject.transaction do
- user_star_project = users_star_projects.
- where(project: project, user: self).lock(true).first
+ user_star_project = users_star_projects
+ .where(project: project, user: self).lock(true).first
if user_star_project
user_star_project.destroy
@@ -851,11 +906,11 @@ class User < ActiveRecord::Base
# ms on a database with a similar size to GitLab.com's database. On the other
# hand, using a subquery means we can get the exact same data in about 40 ms.
def contributed_projects
- events = Event.select(:project_id).
- contributions.where(author_id: self).
- where("created_at > ?", Time.now - 1.year).
- uniq.
- reorder(nil)
+ events = Event.select(:project_id)
+ .contributions.where(author_id: self)
+ .where("created_at > ?", Time.now - 1.year)
+ .uniq
+ .reorder(nil)
Project.where(id: events)
end
@@ -866,9 +921,9 @@ class User < ActiveRecord::Base
def ci_authorized_runners
@ci_authorized_runners ||= begin
- runner_ids = Ci::RunnerProject.
- where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})").
- select(:runner_id)
+ runner_ids = Ci::RunnerProject
+ .where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ .select(:runner_id)
Ci::Runner.specific.where(id: runner_ids)
end
end
@@ -889,13 +944,13 @@ class User < ActiveRecord::Base
end
def assigned_open_merge_requests_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do
+ Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do
MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end
end
def assigned_open_issues_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do
+ Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end
end
@@ -905,6 +960,19 @@ class User < ActiveRecord::Base
assigned_open_issues_count(force: true)
end
+ def invalidate_cache_counts
+ invalidate_issue_cache_counts
+ invalidate_merge_request_cache_counts
+ end
+
+ def invalidate_issue_cache_counts
+ Rails.cache.delete(['users', id, 'assigned_open_issues_count'])
+ end
+
+ def invalidate_merge_request_cache_counts
+ Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
+ end
+
def todos_done_count(force: false)
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do
TodosFinder.new(self, state: :done).execute.count
@@ -934,7 +1002,7 @@ class User < ActiveRecord::Base
if attempts_exceeded?
lock_access! unless access_locked?
else
- save(validate: false)
+ Users::UpdateService.new(self).execute(validate: false)
end
end
@@ -953,6 +1021,12 @@ class User < ActiveRecord::Base
self.admin = (new_level == 'admin')
end
+ # Does the user have access to all private groups & projects?
+ # Overridden in EE to also check auditor?
+ def full_private_access?
+ admin?
+ end
+
def update_two_factor_requirement
periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
@@ -962,6 +1036,13 @@ class User < ActiveRecord::Base
save
end
+ # each existing user needs to have an `rss_token`.
+ # we do this on read since migrating all existing users is not a feasible
+ # solution.
+ def rss_token
+ ensure_rss_token!
+ end
+
protected
# override, from Devise::Validatable
@@ -983,14 +1064,27 @@ class User < ActiveRecord::Base
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
def send_devise_notification(notification, *args)
- devise_mailer.send(notification, self, *args).deliver_later
+ return true unless can?(:receive_notifications)
+ devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
end
- def ensure_external_user_rights
- return unless external?
+ # This works around a bug in Devise 4.2.0 that erroneously causes a user to
+ # be considered active in MySQL specs due to a sub-second comparison
+ # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709
+ def confirmation_period_valid?
+ return false if self.class.allow_unconfirmed_access_for == 0.days
+
+ super
+ end
- self.can_create_group = false
- self.projects_limit = 0
+ def ensure_user_rights_and_limits
+ if external?
+ self.can_create_group = false
+ self.projects_limit = 0
+ else
+ self.can_create_group = gitlab_config.default_can_create_group
+ self.projects_limit = current_application_settings.default_projects_limit
+ end
end
def signup_domain_valid?
@@ -1073,7 +1167,8 @@ class User < ActiveRecord::Base
email: email,
&creation_block
)
- user.save(validate: false)
+
+ Users::UpdateService.new(user).execute(validate: false)
user
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
index 0949c6ef083..2d05fdd3e54 100644
--- a/app/models/user_agent_detail.rb
+++ b/app/models/user_agent_detail.rb
@@ -1,5 +1,5 @@
class UserAgentDetail < ActiveRecord::Base
- belongs_to :subject, polymorphic: true
+ belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index c771c22f46a..5c7c2204374 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -1,4 +1,6 @@
class WikiPage
+ PageChangedError = Class.new(StandardError)
+
include ActiveModel::Validations
include ActiveModel::Conversion
include StaticModel
@@ -22,16 +24,16 @@ class WikiPage
def self.group_by_directory(pages)
return [] if pages.blank?
- pages.sort_by { |page| [page.directory, page.slug] }.
- group_by(&:directory).
- map do |dir, pages|
+ pages.sort_by { |page| [page.directory, page.slug] }
+ .group_by(&:directory)
+ .map do |dir, pages|
if dir.present?
WikiDirectory.new(dir, pages)
else
pages
end
- end.
- flatten
+ end
+ .flatten
end
def self.unhyphenize(name)
@@ -136,6 +138,10 @@ class WikiPage
versions.first
end
+ def last_commit_sha
+ commit&.sha
+ end
+
# Returns the Date that this latest version was
# created on.
def created_at
@@ -174,26 +180,50 @@ class WikiPage
#
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
- def create(attr = {})
- @attributes.merge!(attr)
+ def create(attrs = {})
+ @attributes.merge!(attrs)
- save :create_page, title, content, format, message
+ save(page_details: title) do
+ wiki.create_page(title, content, format, message)
+ end
end
# Updates an existing Wiki Page, creating a new version.
#
- # new_content - The raw markup content to replace the existing.
- # format - Optional symbol representing the content format.
- # See ProjectWiki::MARKUPS Hash for available formats.
- # message - Optional commit message to set on the new version.
+ # attrs - Hash of attributes to be updated on the page.
+ # :content - The raw markup content to replace the existing.
+ # :format - Optional symbol representing the content format.
+ # See ProjectWiki::MARKUPS Hash for available formats.
+ # :message - Optional commit message to set on the new version.
+ # :last_commit_sha - Optional last commit sha to validate the page unchanged.
+ # :title - The Title to replace existing title
#
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
- def update(new_content = "", format = :markdown, message = nil)
- @attributes[:content] = new_content
- @attributes[:format] = format
+ def update(attrs = {})
+ last_commit_sha = attrs.delete(:last_commit_sha)
+ if last_commit_sha && last_commit_sha != self.last_commit_sha
+ raise PageChangedError.new("You are attempting to update a page that has changed since you started editing it.")
+ end
- save :update_page, @page, content, format, message
+ attrs.slice!(:content, :format, :message, :title)
+ @attributes.merge!(attrs)
+ page_details =
+ if title.present? && @page.title != title
+ title
+ else
+ @page.url_path
+ end
+
+ save(page_details: page_details) do
+ wiki.update_page(
+ @page,
+ content: content,
+ format: format,
+ message: attrs[:message],
+ title: title
+ )
+ end
end
# Destroys the Wiki Page.
@@ -225,30 +255,19 @@ class WikiPage
attributes[:format] = @page.format
end
- def save(method, *args)
- saved = false
+ def save(page_details:)
+ return unless valid?
- project_wiki = wiki
- if valid? && project_wiki.send(method, *args)
-
- page_details = if method == :update_page
- # Use url_path instead of path to omit format extension
- @page.url_path
- else
- title
- end
-
- page_title, page_dir = project_wiki.page_title_and_dir(page_details)
- gollum_wiki = project_wiki.wiki
- @page = gollum_wiki.paged(page_title, page_dir)
+ unless yield
+ errors.add(:base, wiki.error_message)
+ return false
+ end
- set_attributes
+ page_title, page_dir = wiki.page_title_and_dir(page_details)
+ gollum_wiki = wiki.wiki
+ @page = gollum_wiki.paged(page_title, page_dir)
- @persisted = true
- saved = true
- else
- errors.add(:base, project_wiki.error_message) if project_wiki.error_message
- end
- saved
+ set_attributes
+ @persisted = errors.blank?
end
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 8890409d056..a605a3457c8 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -1,123 +1,20 @@
-class BasePolicy
- class RuleSet
- attr_reader :can_set, :cannot_set
- def initialize(can_set, cannot_set)
- @can_set = can_set
- @cannot_set = cannot_set
- end
+require_dependency 'declarative_policy'
- delegate :size, to: :to_set
+class BasePolicy < DeclarativePolicy::Base
+ include Gitlab::CurrentSettings
- def self.empty
- new(Set.new, Set.new)
- end
+ desc "User is an instance admin"
+ with_options scope: :user, score: 0
+ condition(:admin) { @user&.admin? }
- def self.none
- empty.freeze
- end
+ with_options scope: :user, score: 0
+ condition(:external_user) { @user.nil? || @user.external? }
- def can?(ability)
- @can_set.include?(ability) && !@cannot_set.include?(ability)
- end
+ with_options scope: :user, score: 0
+ condition(:can_create_group) { @user&.can_create_group }
- def include?(ability)
- can?(ability)
- end
-
- def to_set
- @can_set - @cannot_set
- end
-
- def merge(other)
- @can_set.merge(other.can_set)
- @cannot_set.merge(other.cannot_set)
- end
-
- def can!(*abilities)
- @can_set.merge(abilities)
- end
-
- def cannot!(*abilities)
- @cannot_set.merge(abilities)
- end
-
- def freeze
- @can_set.freeze
- @cannot_set.freeze
- super
- end
- end
-
- def self.abilities(user, subject)
- new(user, subject).abilities
- end
-
- def self.class_for(subject)
- return GlobalPolicy if subject == :global
- raise ArgumentError, 'no policy for nil' if subject.nil?
-
- if subject.class.try(:presenter?)
- subject = subject.subject
- end
-
- subject.class.ancestors.each do |klass|
- next unless klass.name
-
- begin
- policy_class = "#{klass.name}Policy".constantize
-
- # NOTE: the < operator here tests whether policy_class
- # inherits from BasePolicy
- return policy_class if policy_class < BasePolicy
- rescue NameError
- nil
- end
- end
-
- raise "no policy for #{subject.class.name}"
- end
-
- attr_reader :user, :subject
- def initialize(user, subject)
- @user = user
- @subject = subject
- end
-
- def abilities
- return RuleSet.none if @user && @user.blocked?
- return anonymous_abilities if @user.nil?
- collect_rules { rules }
- end
-
- def anonymous_abilities
- collect_rules { anonymous_rules }
- end
-
- def anonymous_rules
- rules
- end
-
- def delegate!(new_subject)
- @rule_set.merge(Ability.allowed(@user, new_subject))
- end
-
- def can?(rule)
- @rule_set.can?(rule)
- end
-
- def can!(*rules)
- @rule_set.can!(*rules)
- end
-
- def cannot!(*rules)
- @rule_set.cannot!(*rules)
- end
-
- private
-
- def collect_rules(&b)
- @rule_set = RuleSet.empty
- yield
- @rule_set
+ desc "The application is restricted from public visibility"
+ condition(:restricted_public_level, scope: :global) do
+ current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 8b25332b73c..984e5482288 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -1,13 +1,15 @@
module Ci
class BuildPolicy < CommitStatusPolicy
- def rules
- super
+ condition(:protected_ref) do
+ access = ::Gitlab::UserAccess.new(@user, project: @subject.project)
- # If we can't read build we should also not have that
- # ability when looking at this in context of commit_status
- %w[read create update admin].each do |rule|
- cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build"
+ if @subject.tag?
+ !access.can_create_tag?(@subject.ref)
+ else
+ !access.can_update_branch?(@subject.ref)
end
end
+
+ rule { protected_ref }.prevent :update_build
end
end
diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb
index 3d2eef1c50c..4e689a9efd5 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -1,4 +1,17 @@
module Ci
- class PipelinePolicy < BuildPolicy
+ class PipelinePolicy < BasePolicy
+ delegate { @subject.project }
+
+ condition(:protected_ref) do
+ access = ::Gitlab::UserAccess.new(@user, project: @subject.project)
+
+ if @subject.tag?
+ !access.can_create_tag?(@subject.ref)
+ else
+ !access.can_update_branch?(@subject.ref)
+ end
+ end
+
+ rule { protected_ref }.prevent :update_pipeline
end
end
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
new file mode 100644
index 00000000000..6b7598e1821
--- /dev/null
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -0,0 +1,14 @@
+module Ci
+ class PipelineSchedulePolicy < PipelinePolicy
+ alias_method :pipeline_schedule, :subject
+
+ condition(:owner_of_schedule) do
+ can?(:developer_access) && pipeline_schedule.owned_by?(@user)
+ end
+
+ rule { can?(:master_access) | owner_of_schedule }.policy do
+ enable :update_pipeline_schedule
+ enable :admin_pipeline_schedule
+ end
+ end
+end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 416d93ffe63..7dff8470e23 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -1,13 +1,16 @@
module Ci
class RunnerPolicy < BasePolicy
- def rules
- return unless @user
+ with_options scope: :subject, score: 0
+ condition(:shared) { @subject.is_shared? }
- can! :assign_runner if @user.admin?
+ with_options scope: :subject, score: 0
+ condition(:locked, scope: :subject) { @subject.locked? }
- return if @subject.is_shared? || @subject.locked?
+ condition(:authorized_runner) { @user.ci_authorized_runners.include?(@subject) }
- can! :assign_runner if @user.ci_authorized_runners.include?(@subject)
- end
+ rule { anonymous }.prevent_all
+ rule { admin | authorized_runner }.enable :assign_runner
+ rule { ~admin & shared }.prevent :assign_runner
+ rule { ~admin & locked }.prevent :assign_runner
end
end
diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb
index c90c9ac0583..5592ac30812 100644
--- a/app/policies/ci/trigger_policy.rb
+++ b/app/policies/ci/trigger_policy.rb
@@ -1,13 +1,16 @@
module Ci
class TriggerPolicy < BasePolicy
- def rules
- delegate! @subject.project
-
- if can?(:admin_build)
- can! :admin_trigger if @subject.owner.blank? ||
- @subject.owner == @user
- can! :manage_trigger
- end
- end
+ delegate { @subject.project }
+
+ with_options scope: :subject, score: 0
+ condition(:legacy) { @subject.legacy? }
+
+ with_score 0
+ condition(:is_owner) { @user && @subject.owner_id == @user.id }
+
+ rule { ~can?(:admin_build) }.prevent :admin_trigger
+ rule { legacy | is_owner }.enable :admin_trigger
+
+ rule { can?(:admin_build) }.enable :manage_trigger
end
end
diff --git a/app/policies/commit_status_policy.rb b/app/policies/commit_status_policy.rb
index 593df738328..24b2a4cc7fd 100644
--- a/app/policies/commit_status_policy.rb
+++ b/app/policies/commit_status_policy.rb
@@ -1,5 +1,7 @@
class CommitStatusPolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
+
+ %w[read create update admin].each do |action|
+ rule { ~can?(:"#{action}_commit_status") }.prevent :"#{action}_build"
end
end
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
new file mode 100644
index 00000000000..62a22a59be6
--- /dev/null
+++ b/app/policies/deploy_key_policy.rb
@@ -0,0 +1,11 @@
+class DeployKeyPolicy < BasePolicy
+ with_options scope: :subject, score: 0
+ condition(:private_deploy_key) { @subject.private? }
+
+ condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) }
+
+ rule { anonymous }.prevent_all
+
+ rule { admin }.enable :update_deploy_key
+ rule { private_deploy_key & has_deploy_key }.enable :update_deploy_key
+end
diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb
index 163d070ff90..62b63b9f87b 100644
--- a/app/policies/deployment_policy.rb
+++ b/app/policies/deployment_policy.rb
@@ -1,5 +1,3 @@
class DeploymentPolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb
index f4219569161..375a5535359 100644
--- a/app/policies/environment_policy.rb
+++ b/app/policies/environment_policy.rb
@@ -1,5 +1,9 @@
class EnvironmentPolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
+
+ condition(:stop_action_allowed) do
+ @subject.stop_action? && can?(:update_build, @subject.stop_action)
end
+
+ rule { can?(:create_deployment) & stop_action_allowed }.enable :stop_environment
end
diff --git a/app/policies/external_issue_policy.rb b/app/policies/external_issue_policy.rb
index d9e28bd107a..e031b38078c 100644
--- a/app/policies/external_issue_policy.rb
+++ b/app/policies/external_issue_policy.rb
@@ -1,5 +1,3 @@
class ExternalIssuePolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 4757ba71680..1be7bbe9953 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -1,16 +1,50 @@
class GlobalPolicy < BasePolicy
- def rules
- return unless @user
-
- can! :create_group if @user.can_create_group
- can! :read_users_list
-
- unless @user.blocked? || @user.internal?
- can! :log_in unless @user.access_locked?
- can! :access_api
- can! :access_git
- can! :receive_notifications
- can! :use_slash_commands
- end
+ desc "User is blocked"
+ with_options scope: :user, score: 0
+ condition(:blocked) { @user.blocked? }
+
+ desc "User is an internal user"
+ with_options scope: :user, score: 0
+ condition(:internal) { @user.internal? }
+
+ desc "User's access has been locked"
+ with_options scope: :user, score: 0
+ condition(:access_locked) { @user.access_locked? }
+
+ rule { anonymous }.policy do
+ prevent :log_in
+ prevent :access_api
+ prevent :access_git
+ prevent :receive_notifications
+ prevent :use_quick_actions
+ prevent :create_group
+ end
+
+ rule { default }.policy do
+ enable :log_in
+ enable :access_api
+ enable :access_git
+ enable :receive_notifications
+ enable :use_quick_actions
+ end
+
+ rule { blocked | internal }.policy do
+ prevent :log_in
+ prevent :access_api
+ prevent :access_git
+ prevent :receive_notifications
+ prevent :use_quick_actions
+ end
+
+ rule { can_create_group }.policy do
+ enable :create_group
+ end
+
+ rule { access_locked }.policy do
+ prevent :log_in
+ end
+
+ rule { ~(anonymous & restricted_public_level) }.policy do
+ enable :read_users_list
end
end
diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb
index 7b34aa182eb..e3dd3296699 100644
--- a/app/policies/group_label_policy.rb
+++ b/app/policies/group_label_policy.rb
@@ -1,5 +1,3 @@
class GroupLabelPolicy < BasePolicy
- def rules
- delegate! @subject.group
- end
+ delegate { @subject.group }
end
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index 5a3fe814b77..23dd0d7cd23 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -1,25 +1,22 @@
class GroupMemberPolicy < BasePolicy
- def rules
- return unless @user
+ delegate :group
- target_user = @subject.user
- group = @subject.group
+ with_scope :subject
+ condition(:last_owner) { @subject.group.last_owner?(@subject.user) }
- return if group.last_owner?(target_user)
+ desc "Membership is users' own"
+ with_score 0
+ condition(:is_target_user) { @user && @subject.user_id == @user.id }
- can_manage = Ability.allowed?(@user, :admin_group_member, group)
+ rule { anonymous }.prevent_all
+ rule { last_owner }.prevent_all
- if can_manage
- can! :update_group_member
- can! :destroy_group_member
- elsif @user == target_user
- can! :destroy_group_member
- end
-
- additional_rules!
+ rule { can?(:admin_group_member) }.policy do
+ enable :update_group_member
+ enable :destroy_group_member
end
- def additional_rules!
- # This is meant to be overriden in EE
+ rule { is_target_user }.policy do
+ enable :destroy_group_member
end
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 87398303c68..6defab75fce 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -1,47 +1,60 @@
class GroupPolicy < BasePolicy
- def rules
- can! :read_group if @subject.public?
- return unless @user
-
- globally_viewable = @subject.public? || (@subject.internal? && !@user.external?)
- member = @subject.users_with_parents.include?(@user)
- owner = @user.admin? || @subject.has_owner?(@user)
- master = owner || @subject.has_master?(@user)
-
- can_read = false
- can_read ||= globally_viewable
- can_read ||= member
- can_read ||= @user.admin?
- can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
- can! :read_group if can_read
-
- # Only group masters and group owners can create new projects
- if master
- can! :create_projects
- can! :admin_milestones
- can! :admin_label
- end
-
- # Only group owner and administrators can admin group
- if owner
- can! :admin_group
- can! :admin_namespace
- can! :admin_group_member
- can! :change_visibility_level
- can! :create_subgroup if @user.can_create_group
- end
-
- if globally_viewable && @subject.request_access_enabled && !member
- can! :request_access
- end
- end
+ desc "Group is public"
+ with_options scope: :subject, score: 0
+ condition(:public_group) { @subject.public? }
+
+ with_score 0
+ condition(:logged_in_viewable) { @user && @subject.internal? && !@user.external? }
+
+ condition(:has_access) { access_level != GroupMember::NO_ACCESS }
- def can_read_group?
- return true if @subject.public?
- return true if @user.admin?
- return true if @subject.internal? && !@user.external?
- return true if @subject.users.include?(@user)
+ condition(:guest) { access_level >= GroupMember::GUEST }
+ condition(:owner) { access_level >= GroupMember::OWNER }
+ condition(:master) { access_level >= GroupMember::MASTER }
+ condition(:reporter) { access_level >= GroupMember::REPORTER }
+ condition(:has_projects) do
GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
end
+
+ with_options scope: :subject, score: 0
+ condition(:request_access_enabled) { @subject.request_access_enabled }
+
+ rule { public_group } .enable :read_group
+ rule { logged_in_viewable }.enable :read_group
+ rule { guest } .enable :read_group
+ rule { admin } .enable :read_group
+ rule { has_projects } .enable :read_group
+
+ rule { reporter }.enable :admin_label
+
+ rule { master }.policy do
+ enable :create_projects
+ enable :admin_milestones
+ enable :admin_pipeline
+ enable :admin_build
+ end
+
+ rule { owner }.policy do
+ enable :admin_group
+ enable :admin_namespace
+ enable :admin_group_member
+ enable :change_visibility_level
+ end
+
+ rule { owner & can_create_group }.enable :create_subgroup
+
+ rule { public_group | logged_in_viewable }.enable :view_globally
+
+ rule { default }.enable(:request_access)
+
+ rule { ~request_access_enabled }.prevent :request_access
+ rule { ~can?(:view_globally) }.prevent :request_access
+ rule { has_access }.prevent :request_access
+
+ def access_level
+ return GroupMember::NO_ACCESS if @user.nil?
+
+ @access_level ||= @subject.max_member_access_for_user(@user)
+ end
end
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 9501e499507..daf6fa9e18a 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -1,14 +1,15 @@
class IssuablePolicy < BasePolicy
- def action_name
- @subject.class.name.underscore
- end
+ delegate { @subject.project }
- def rules
- if @user && @subject.assignee_or_author?(@user)
- can! :"read_#{action_name}"
- can! :"update_#{action_name}"
- end
+ desc "User is the assignee or author"
+ condition(:assignee_or_author) do
+ @user && @subject.assignee_or_author?(@user)
+ end
- delegate! @subject.project
+ rule { assignee_or_author }.policy do
+ enable :read_issue
+ enable :update_issue
+ enable :read_merge_request
+ enable :update_merge_request
end
end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 88f3179c6ff..bd2d417b2a8 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -3,25 +3,17 @@ class IssuePolicy < IssuablePolicy
# Make sure to sync this class checks with issue.rb to avoid security problems.
# Check commit 002ad215818450d2cbbc5fa065850a953dc7ada8 for more information.
- def issue
- @subject
+ desc "User can read confidential issues"
+ condition(:can_read_confidential) do
+ @user && IssueCollection.new([@subject]).visible_to(@user).any?
end
- def rules
- super
+ desc "Issue is confidential"
+ condition(:confidential, scope: :subject) { @subject.confidential? }
- if @subject.confidential? && !can_read_confidential?
- cannot! :read_issue
- cannot! :update_issue
- cannot! :admin_issue
- end
- end
-
- private
-
- def can_read_confidential?
- return false unless @user
-
- IssueCollection.new([@subject]).visible_to(@user).any?
+ rule { confidential & ~can_read_confidential }.policy do
+ prevent :read_issue
+ prevent :update_issue
+ prevent :admin_issue
end
end
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index 29bb357e00a..85b67f0a237 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -1,10 +1,10 @@
class NamespacePolicy < BasePolicy
- def rules
- return unless @user
+ rule { anonymous }.prevent_all
- if @subject.owner == @user || @user.admin?
- can! :create_projects
- can! :admin_namespace
- end
+ condition(:owner) { @subject.owner == @user }
+
+ rule { owner | admin }.policy do
+ enable :create_projects
+ enable :admin_namespace
end
end
diff --git a/app/policies/nil_policy.rb b/app/policies/nil_policy.rb
new file mode 100644
index 00000000000..13f46ba60f0
--- /dev/null
+++ b/app/policies/nil_policy.rb
@@ -0,0 +1,3 @@
+class NilPolicy < BasePolicy
+ rule { default }.prevent_all
+end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 5326061bd07..20cd51cfb99 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -1,19 +1,24 @@
class NotePolicy < BasePolicy
- def rules
- delegate! @subject.project
+ delegate { @subject.project }
- return unless @user
+ condition(:is_author) { @user && @subject.author == @user }
+ condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
+ condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id }
- if @subject.author == @user
- can! :read_note
- can! :update_note
- can! :admin_note
- can! :resolve_note
- end
+ condition(:editable, scope: :subject) { @subject.editable? }
- if @subject.for_merge_request? &&
- @subject.noteable.author == @user
- can! :resolve_note
- end
+ rule { ~editable | anonymous }.prevent :edit_note
+ rule { is_author | admin }.enable :edit_note
+ rule { can?(:master_access) }.enable :edit_note
+
+ rule { is_author }.policy do
+ enable :read_note
+ enable :update_note
+ enable :admin_note
+ enable :resolve_note
+ end
+
+ rule { for_merge_request & is_noteable_author }.policy do
+ enable :resolve_note
end
end
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index d3913986cd8..cac0530b9f7 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -1,21 +1,28 @@
class PersonalSnippetPolicy < BasePolicy
- def rules
- can! :read_personal_snippet if @subject.public?
- return unless @user
+ condition(:public_snippet, scope: :subject) { @subject.public? }
+ condition(:is_author) { @user && @subject.author == @user }
+ condition(:internal_snippet, scope: :subject) { @subject.internal? }
- if @subject.author == @user
- can! :read_personal_snippet
- can! :update_personal_snippet
- can! :destroy_personal_snippet
- can! :admin_personal_snippet
- end
+ rule { public_snippet }.policy do
+ enable :read_personal_snippet
+ enable :comment_personal_snippet
+ end
+
+ rule { is_author }.policy do
+ enable :read_personal_snippet
+ enable :update_personal_snippet
+ enable :destroy_personal_snippet
+ enable :admin_personal_snippet
+ enable :comment_personal_snippet
+ end
- unless @user.external?
- can! :create_personal_snippet
- end
+ rule { ~anonymous }.enable :create_personal_snippet
+ rule { external_user }.prevent :create_personal_snippet
- if @subject.internal? && !@user.external?
- can! :read_personal_snippet
- end
+ rule { internal_snippet & ~external_user }.policy do
+ enable :read_personal_snippet
+ enable :comment_personal_snippet
end
+
+ rule { anonymous }.prevent :comment_personal_snippet
end
diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb
index b12b4c5166b..2d0f021118b 100644
--- a/app/policies/project_label_policy.rb
+++ b/app/policies/project_label_policy.rb
@@ -1,5 +1,3 @@
class ProjectLabelPolicy < BasePolicy
- def rules
- delegate! @subject.project
- end
+ delegate { @subject.project }
end
diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index 1c038dddd4b..9aedb620be9 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -1,22 +1,16 @@
class ProjectMemberPolicy < BasePolicy
- def rules
- # anonymous users have no abilities here
- return unless @user
+ delegate { @subject.project }
- target_user = @subject.user
- project = @subject.project
+ condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner }
+ condition(:target_is_self) { @user && @subject.user == @user }
- return if target_user == project.owner
+ rule { anonymous }.prevent_all
+ rule { target_is_owner }.prevent_all
- can_manage = Ability.allowed?(@user, :admin_project_member, project)
-
- if can_manage
- can! :update_project_member
- can! :destroy_project_member
- end
-
- if @user == target_user
- can! :destroy_project_member
- end
+ rule { can?(:admin_project_member) }.policy do
+ enable :update_project_member
+ enable :destroy_project_member
end
+
+ rule { target_is_self }.enable :destroy_project_member
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 5baac9ebe4b..0133091db57 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -1,289 +1,349 @@
class ProjectPolicy < BasePolicy
- def rules
- team_access!(user)
+ def self.create_read_update_admin(name)
+ [
+ :"create_#{name}",
+ :"read_#{name}",
+ :"update_#{name}",
+ :"admin_#{name}"
+ ]
+ end
- owner_access! if user.admin? || owner?
- team_member_owner_access! if owner?
+ desc "User is a project owner"
+ condition :owner do
+ (project.owner.present? && project.owner == @user) ||
+ project.group&.has_owner?(@user)
+ end
- if project.public? || (project.internal? && !user.external?)
- guest_access!
- public_access!
- can! :request_access if access_requestable?
- end
+ desc "Project has public builds enabled"
+ condition(:public_builds, scope: :subject) { project.public_builds? }
- archived_access! if project.archived?
+ # For guest access we use #is_team_member? so we can use
+ # project.members, which gets cached in subject scope.
+ # This is safe because team_access_level is guaranteed
+ # by ProjectAuthorization's validation to be at minimum
+ # GUEST
+ desc "User has guest access"
+ condition(:guest) { is_team_member? }
- disabled_features!
- end
+ desc "User has reporter access"
+ condition(:reporter) { team_access_level >= Gitlab::Access::REPORTER }
- def project
- @subject
+ desc "User has developer access"
+ condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER }
+
+ desc "User has master access"
+ condition(:master) { team_access_level >= Gitlab::Access::MASTER }
+
+ desc "Project is public"
+ condition(:public_project, scope: :subject) { project.public? }
+
+ desc "Project is visible to internal users"
+ condition(:internal_access) do
+ project.internal? && !user.external?
end
- def owner?
- return @owner if defined?(@owner)
+ desc "User is a member of the group"
+ condition(:group_member, scope: :subject) { project_group_member? }
+
+ desc "Project is archived"
+ condition(:archived, scope: :subject) { project.archived? }
- @owner = project.owner == user ||
- (project.group && project.group.has_owner?(user))
+ condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? }
+
+ desc "Container registry is disabled"
+ condition(:container_registry_disabled, scope: :subject) do
+ !project.container_registry_enabled
end
- def guest_access!
- can! :read_project
- can! :read_board
- can! :read_list
- can! :read_wiki
- can! :read_issue
- can! :read_label
- can! :read_milestone
- can! :read_project_snippet
- can! :read_project_member
- can! :read_note
- can! :create_project
- can! :create_issue
- can! :create_note
- can! :upload_file
- can! :read_cycle_analytics
-
- if project.public_builds?
- can! :read_pipeline
- can! :read_build
- end
+ desc "Project has an external wiki"
+ condition(:has_external_wiki, scope: :subject) { project.has_external_wiki? }
+
+ desc "Project has request access enabled"
+ condition(:request_access_enabled, scope: :subject) { project.request_access_enabled }
+
+ features = %w[
+ merge_requests
+ issues
+ repository
+ snippets
+ wiki
+ builds
+ ]
+
+ features.each do |f|
+ # these are scored high because they are unlikely
+ desc "Project has #{f} disabled"
+ condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) }
end
- def reporter_access!
- can! :download_code
- can! :download_wiki_code
- can! :fork_project
- can! :create_project_snippet
- can! :update_issue
- can! :admin_issue
- can! :admin_label
- can! :admin_list
- can! :read_commit_status
- can! :read_build
- can! :read_container_image
- can! :read_pipeline
- can! :read_environment
- can! :read_deployment
- can! :read_merge_request
+ rule { guest }.enable :guest_access
+ rule { reporter }.enable :reporter_access
+ rule { developer }.enable :developer_access
+ rule { master }.enable :master_access
+
+ rule { owner | admin }.policy do
+ enable :guest_access
+ enable :reporter_access
+ enable :developer_access
+ enable :master_access
+
+ enable :change_namespace
+ enable :change_visibility_level
+ enable :rename_project
+ enable :remove_project
+ enable :archive_project
+ enable :remove_fork_project
+ enable :destroy_merge_request
+ enable :destroy_issue
+ enable :remove_pages
end
- # Permissions given when an user is team member of a project
- def team_member_reporter_access!
- can! :build_download_code
- can! :build_read_container_image
+ rule { owner | reporter }.policy do
+ enable :build_download_code
+ enable :build_read_container_image
end
- def developer_access!
- can! :admin_merge_request
- can! :update_merge_request
- can! :create_commit_status
- can! :update_commit_status
- can! :create_build
- can! :update_build
- can! :create_pipeline
- can! :update_pipeline
- can! :create_merge_request
- can! :create_wiki
- can! :push_code
- can! :resolve_note
- can! :create_container_image
- can! :update_container_image
- can! :create_environment
- can! :create_deployment
+ rule { can?(:guest_access) }.policy do
+ enable :read_project
+ enable :read_board
+ enable :read_list
+ enable :read_wiki
+ enable :read_issue
+ enable :read_label
+ enable :read_milestone
+ enable :read_project_snippet
+ enable :read_project_member
+ enable :read_note
+ enable :create_project
+ enable :create_issue
+ enable :create_note
+ enable :upload_file
+ enable :read_cycle_analytics
+ enable :read_project_snippet
end
- def master_access!
- can! :push_code_to_protected_branches
- can! :update_project_snippet
- can! :update_environment
- can! :update_deployment
- can! :admin_milestone
- can! :admin_project_snippet
- can! :admin_project_member
- can! :admin_note
- can! :admin_wiki
- can! :admin_project
- can! :admin_commit_status
- can! :admin_build
- can! :admin_container_image
- can! :admin_pipeline
- can! :admin_environment
- can! :admin_deployment
- can! :admin_pages
- can! :read_pages
- can! :update_pages
+ rule { can?(:reporter_access) }.policy do
+ enable :download_code
+ enable :download_wiki_code
+ enable :fork_project
+ enable :create_project_snippet
+ enable :update_issue
+ enable :admin_issue
+ enable :admin_label
+ enable :admin_list
+ enable :read_commit_status
+ enable :read_build
+ enable :read_container_image
+ enable :read_pipeline
+ enable :read_pipeline_schedule
+ enable :read_environment
+ enable :read_deployment
+ enable :read_merge_request
end
- def public_access!
- can! :download_code
- can! :fork_project
- can! :read_commit_status
- can! :read_pipeline
- can! :read_container_image
- can! :build_download_code
- can! :build_read_container_image
- can! :read_merge_request
+ rule { (~anonymous & public_project) | internal_access }.policy do
+ enable :public_user_access
end
- def owner_access!
- guest_access!
- reporter_access!
- developer_access!
- master_access!
- can! :change_namespace
- can! :change_visibility_level
- can! :rename_project
- can! :remove_project
- can! :archive_project
- can! :remove_fork_project
- can! :destroy_merge_request
- can! :destroy_issue
- can! :remove_pages
+ rule { can?(:public_user_access) }.policy do
+ enable :guest_access
+ enable :request_access
end
- def team_member_owner_access!
- team_member_reporter_access!
+ rule { owner | admin | guest | group_member }.prevent :request_access
+ rule { ~request_access_enabled }.prevent :request_access
+
+ rule { can?(:developer_access) }.policy do
+ enable :admin_merge_request
+ enable :update_merge_request
+ enable :create_commit_status
+ enable :update_commit_status
+ enable :create_build
+ enable :update_build
+ enable :create_pipeline
+ enable :update_pipeline
+ enable :create_pipeline_schedule
+ enable :create_merge_request
+ enable :create_wiki
+ enable :push_code
+ enable :resolve_note
+ enable :create_container_image
+ enable :update_container_image
+ enable :create_environment
+ enable :create_deployment
end
- # Push abilities on the users team role
- def team_access!(user)
- access = project.team.max_member_access(user.id)
+ rule { can?(:master_access) }.policy do
+ enable :delete_protected_branch
+ enable :update_project_snippet
+ enable :update_environment
+ enable :update_deployment
+ enable :admin_milestone
+ enable :admin_project_snippet
+ enable :admin_project_member
+ enable :admin_note
+ enable :admin_wiki
+ enable :admin_project
+ enable :admin_commit_status
+ enable :admin_build
+ enable :admin_container_image
+ enable :admin_pipeline
+ enable :admin_environment
+ enable :admin_deployment
+ enable :admin_pages
+ enable :read_pages
+ enable :update_pages
+ end
- return if access < Gitlab::Access::GUEST
- guest_access!
+ rule { can?(:public_user_access) }.policy do
+ enable :public_access
- return if access < Gitlab::Access::REPORTER
- reporter_access!
- team_member_reporter_access!
+ enable :fork_project
+ enable :build_download_code
+ enable :build_read_container_image
+ end
- return if access < Gitlab::Access::DEVELOPER
- developer_access!
+ rule { archived }.policy do
+ prevent :create_merge_request
+ prevent :push_code
+ prevent :delete_protected_branch
+ prevent :update_merge_request
+ prevent :admin_merge_request
+ end
- return if access < Gitlab::Access::MASTER
- master_access!
+ rule { merge_requests_disabled | repository_disabled }.policy do
+ prevent(*create_read_update_admin(:merge_request))
end
- def archived_access!
- cannot! :create_merge_request
- cannot! :push_code
- cannot! :push_code_to_protected_branches
- cannot! :update_merge_request
- cannot! :admin_merge_request
+ rule { issues_disabled & merge_requests_disabled }.policy do
+ prevent(*create_read_update_admin(:label))
+ prevent(*create_read_update_admin(:milestone))
end
- def disabled_features!
- repository_enabled = project.feature_available?(:repository, user)
+ rule { snippets_disabled }.policy do
+ prevent(*create_read_update_admin(:project_snippet))
+ end
- block_issues_abilities
+ rule { wiki_disabled & ~has_external_wiki }.policy do
+ prevent(*create_read_update_admin(:wiki))
+ prevent(:download_wiki_code)
+ end
- unless project.feature_available?(:merge_requests, user) && repository_enabled
- cannot!(*named_abilities(:merge_request))
- end
+ rule { builds_disabled | repository_disabled }.policy do
+ prevent(*create_read_update_admin(:build))
+ prevent(*(create_read_update_admin(:pipeline) - [:read_pipeline]))
+ prevent(*create_read_update_admin(:pipeline_schedule))
+ prevent(*create_read_update_admin(:environment))
+ prevent(*create_read_update_admin(:deployment))
+ end
- unless project.feature_available?(:issues, user) || project.feature_available?(:merge_requests, user)
- cannot!(*named_abilities(:label))
- cannot!(*named_abilities(:milestone))
- end
+ rule { repository_disabled }.policy do
+ prevent :push_code
+ prevent :push_code_to_protected_branches
+ prevent :download_code
+ prevent :fork_project
+ prevent :read_commit_status
+ end
- unless project.feature_available?(:snippets, user)
- cannot!(*named_abilities(:project_snippet))
- end
+ rule { container_registry_disabled }.policy do
+ prevent(*create_read_update_admin(:container_image))
+ end
- unless project.feature_available?(:wiki, user) || project.has_external_wiki?
- cannot!(*named_abilities(:wiki))
- cannot!(:download_wiki_code)
- end
+ rule { anonymous & ~public_project }.prevent_all
+ rule { public_project }.enable(:public_access)
+
+ rule { can?(:public_access) }.policy do
+ enable :read_project
+ enable :read_board
+ enable :read_list
+ enable :read_wiki
+ enable :read_label
+ enable :read_milestone
+ enable :read_project_snippet
+ enable :read_project_member
+ enable :read_merge_request
+ enable :read_note
+ enable :read_pipeline
+ enable :read_pipeline_schedule
+ enable :read_commit_status
+ enable :read_container_image
+ enable :download_code
+ enable :download_wiki_code
+ enable :read_cycle_analytics
- unless project.feature_available?(:builds, user) && repository_enabled
- cannot!(*named_abilities(:build))
- cannot!(*named_abilities(:pipeline))
- cannot!(*named_abilities(:environment))
- cannot!(*named_abilities(:deployment))
- end
+ # NOTE: may be overridden by IssuePolicy
+ enable :read_issue
+ end
- unless repository_enabled
- cannot! :push_code
- cannot! :push_code_to_protected_branches
- cannot! :download_code
- cannot! :fork_project
- cannot! :read_commit_status
- end
+ rule { public_builds }.policy do
+ enable :read_build
+ end
- unless project.container_registry_enabled
- cannot!(*named_abilities(:container_image))
- end
+ rule { public_builds & can?(:guest_access) }.policy do
+ enable :read_pipeline
+ enable :read_pipeline_schedule
end
- def anonymous_rules
- return unless project.public?
+ rule { issues_disabled }.policy do
+ prevent :create_issue
+ prevent :update_issue
+ prevent :admin_issue
+ prevent :read_issue
+ end
- base_readonly_access!
+ private
- # Allow to read builds by anonymous user if guests are allowed
- can! :read_build if project.public_builds?
+ def is_team_member?
+ return false if @user.nil?
- disabled_features!
- end
+ greedy_load_subject = false
- def block_issues_abilities
- unless project.feature_available?(:issues, user)
- cannot! :read_issue if project.default_issues_tracker?
- cannot! :create_issue
- cannot! :update_issue
- cannot! :admin_issue
- end
- end
+ # when scoping by subject, we want to be greedy
+ # and load *all* the members with one query.
+ greedy_load_subject ||= DeclarativePolicy.preferred_scope == :subject
- def named_abilities(name)
- [
- :"read_#{name}",
- :"create_#{name}",
- :"update_#{name}",
- :"admin_#{name}"
- ]
+ # in this case we're likely to have loaded #members already
+ # anyways, and #member? would fail with an error
+ greedy_load_subject ||= !@user.persisted?
+
+ if greedy_load_subject
+ project.team.members.include?(user)
+ else
+ # otherwise we just make a specific query for
+ # this particular user.
+ team_access_level >= Gitlab::Access::GUEST
+ end
end
- private
+ def project_group_member?
+ return false if @user.nil?
- def project_group_member?(user)
project.group &&
(
- project.group.members_with_parents.exists?(user_id: user.id) ||
- project.group.requesters.exists?(user_id: user.id)
+ project.group.members_with_parents.exists?(user_id: @user.id) ||
+ project.group.requesters.exists?(user_id: @user.id)
)
end
- def access_requestable?
- project.request_access_enabled &&
- !owner? &&
- !user.admin? &&
- !project.team.member?(user) &&
- !project_group_member?(user)
+ def team_access_level
+ return -1 if @user.nil?
+
+ # NOTE: max_member_access has its own cache
+ project.team.max_member_access(@user.id)
end
- # A base set of abilities for read-only users, which
- # is then augmented as necessary for anonymous and other
- # read-only users.
- def base_readonly_access!
- can! :read_project
- can! :read_board
- can! :read_list
- can! :read_wiki
- can! :read_label
- can! :read_milestone
- can! :read_project_snippet
- can! :read_project_member
- can! :read_merge_request
- can! :read_note
- can! :read_pipeline
- can! :read_commit_status
- can! :read_container_image
- can! :download_code
- can! :download_wiki_code
- can! :read_cycle_analytics
+ def feature_available?(feature)
+ case project.project_feature.access_level(feature)
+ when ProjectFeature::DISABLED
+ false
+ when ProjectFeature::PRIVATE
+ guest? || admin?
+ else
+ true
+ end
+ end
- # NOTE: may be overridden by IssuePolicy
- can! :read_issue
+ def project
+ @subject
end
end
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index 3a96836917e..dd270643bbf 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -1,20 +1,45 @@
class ProjectSnippetPolicy < BasePolicy
- def rules
- can! :read_project_snippet if @subject.public?
- return unless @user
-
- if @user && (@subject.author == @user || @user.admin?)
- can! :read_project_snippet
- can! :update_project_snippet
- can! :admin_project_snippet
- end
-
- if @subject.internal? && !@user.external?
- can! :read_project_snippet
- end
-
- if @subject.private? && @subject.project.team.member?(@user)
- can! :read_project_snippet
- end
+ delegate :project
+
+ desc "Snippet is public"
+ condition(:public_snippet, scope: :subject) { @subject.public? }
+ condition(:private_snippet, scope: :subject) { @subject.private? }
+ condition(:public_project, scope: :subject) { @subject.project.public? }
+
+ condition(:is_author) { @user && @subject.author == @user }
+
+ condition(:internal, scope: :subject) { @subject.internal? }
+
+ # We have to check both project feature visibility and a snippet visibility and take the stricter one
+ # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573
+ rule { ~can?(:read_project) }.policy do
+ prevent :read_project_snippet
+ prevent :update_project_snippet
+ prevent :admin_project_snippet
+ end
+
+ # we have to use this complicated prevent because the delegated project policy
+ # is overly greedy in allowing :read_project_snippet, since it doesn't have any
+ # information about the snippet. However, :read_project_snippet on the *project*
+ # is used to hide/show various snippet-related controls, so we can't just move
+ # all of the handling here.
+ rule do
+ all?(private_snippet | (internal & external_user),
+ ~project.guest,
+ ~admin,
+ ~is_author)
+ end.prevent :read_project_snippet
+
+ rule { internal & ~is_author & ~admin }.policy do
+ prevent :update_project_snippet
+ prevent :admin_project_snippet
+ end
+
+ rule { public_snippet }.enable :read_project_snippet
+
+ rule { is_author | admin }.policy do
+ enable :read_project_snippet
+ enable :update_project_snippet
+ enable :admin_project_snippet
end
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 229846e368c..0905ddd9b38 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -1,19 +1,13 @@
class UserPolicy < BasePolicy
- include Gitlab::CurrentSettings
+ desc "The current user is the user in question"
+ condition(:user_is_self, score: 0) { @subject == @user }
- def rules
- can! :read_user if @user || !restricted_public_level?
+ desc "This is the ghost user"
+ condition(:subject_ghost, scope: :subject, score: 0) { @subject.ghost? }
- if @user
- if @user.admin? || @subject == @user
- can! :destroy_user
- end
+ rule { ~restricted_public_level }.enable :read_user
+ rule { ~anonymous }.enable :read_user
- cannot! :destroy_user if @subject.ghost?
- end
- end
-
- def restricted_public_level?
- current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
- end
+ rule { user_is_self | admin }.enable :destroy_user
+ rule { subject_ghost }.prevent :destroy_user
end
diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb
new file mode 100644
index 00000000000..81fea106a5c
--- /dev/null
+++ b/app/presenters/ci/group_variable_presenter.rb
@@ -0,0 +1,25 @@
+module Ci
+ class GroupVariablePresenter < Gitlab::View::Presenter::Delegated
+ presents :variable
+
+ def placeholder
+ 'GROUP_VARIABLE'
+ end
+
+ def form_path
+ if variable.persisted?
+ group_variable_path(group, variable)
+ else
+ group_variables_path(group)
+ end
+ end
+
+ def edit_path
+ group_variable_path(group, variable)
+ end
+
+ def delete_path
+ group_variable_path(group, variable)
+ end
+ end
+end
diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb
new file mode 100644
index 00000000000..5d7998393a6
--- /dev/null
+++ b/app/presenters/ci/variable_presenter.rb
@@ -0,0 +1,25 @@
+module Ci
+ class VariablePresenter < Gitlab::View::Presenter::Delegated
+ presents :variable
+
+ def placeholder
+ 'PROJECT_VARIABLE'
+ end
+
+ def form_path
+ if variable.persisted?
+ project_variable_path(project, variable)
+ else
+ project_variables_path(project)
+ end
+ end
+
+ def edit_path
+ project_variable_path(project, variable)
+ end
+
+ def delete_path
+ project_variable_path(project, variable)
+ end
+ end
+end
diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb
new file mode 100644
index 00000000000..bb65ba2646b
--- /dev/null
+++ b/app/presenters/conversational_development_index/metric_presenter.rb
@@ -0,0 +1,144 @@
+module ConversationalDevelopmentIndex
+ class MetricPresenter < Gitlab::View::Presenter::Simple
+ def cards
+ [
+ Card.new(
+ metric: subject,
+ title: 'Issues',
+ description: 'created per active user',
+ feature: 'issues',
+ blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Comments',
+ description: 'created per active user',
+ feature: 'notes',
+ blog: 'http://conversationaldevelopment.com/why/'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Milestones',
+ description: 'created per active user',
+ feature: 'milestones',
+ blog: 'http://conversationaldevelopment.com/shorten-cycle/',
+ docs: help_page_path('user/project/milestones/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Boards',
+ description: 'created per active user',
+ feature: 'boards',
+ blog: 'http://jpattonassociates.com/user-story-mapping/',
+ docs: help_page_path('user/project/issue_board')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Merge Requests',
+ description: 'per active user',
+ feature: 'merge_requests',
+ blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html',
+ docs: help_page_path('user/project/merge_requests/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Pipelines',
+ description: 'created per active user',
+ feature: 'ci_pipelines',
+ blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html',
+ docs: help_page_path('ci/README')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Environments',
+ description: 'created per active user',
+ feature: 'environments',
+ blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/',
+ docs: help_page_path('ci/environments')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Deployments',
+ description: 'created per active user',
+ feature: 'deployments',
+ blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Monitoring',
+ description: 'fraction of all projects',
+ feature: 'projects_prometheus_active',
+ blog: 'https://prometheus.io/docs/introduction/overview/',
+ docs: help_page_path('user/project/integrations/prometheus')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Service Desk',
+ description: 'issues created per active user',
+ feature: 'service_desk_issues',
+ blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic',
+ docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html'
+ )
+ ]
+ end
+
+ def idea_to_production_steps
+ [
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Idea',
+ features: %w(issues)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Issue',
+ features: %w(issues notes)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Plan',
+ features: %w(milestones boards)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Code',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Commit',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Test',
+ features: %w(ci_pipelines)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Review',
+ features: %w(ci_pipelines environments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Staging',
+ features: %w(environments deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Production',
+ features: %w(deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Feedback',
+ features: %w(projects_prometheus_active service_desk_issues)
+ )
+ ]
+ end
+
+ def average_percentage_score
+ cards.sum(&:percentage_score) / cards.size.to_f
+ end
+ end
+end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
new file mode 100644
index 00000000000..2df84e58575
--- /dev/null
+++ b/app/presenters/merge_request_presenter.rb
@@ -0,0 +1,185 @@
+class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::UrlHelper
+ include GitlabRoutingHelper
+ include MarkupHelper
+ include TreeHelper
+
+ presents :merge_request
+
+ def ci_status
+ if pipeline
+ status = pipeline.status
+ status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
+
+ status || "preparing"
+ else
+ ci_service = source_project.try(:ci_service)
+ ci_service&.commit_status(diff_head_sha, source_branch)
+ end
+ end
+
+ def cancel_merge_when_pipeline_succeeds_path
+ if can_cancel_merge_when_pipeline_succeeds?(current_user)
+ cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def create_issue_to_resolve_discussions_path
+ if can?(current_user, :create_issue, project) && project.issues_enabled?
+ new_project_issue_path(project, merge_request_to_resolve_discussions_of: iid)
+ end
+ end
+
+ def remove_wip_path
+ if can?(current_user, :update_merge_request, merge_request.project)
+ remove_wip_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def merge_path
+ if can_be_merged_by?(current_user)
+ merge_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def revert_in_fork_path
+ if user_can_fork_project? && can_be_reverted?(current_user)
+ continue_params = {
+ to: merge_request_path(merge_request),
+ notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
+ notice_now: edit_in_new_fork_notice_now
+ }
+
+ project_forks_path(merge_request.project,
+ namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ end
+ end
+
+ def cherry_pick_in_fork_path
+ if user_can_fork_project? && can_be_cherry_picked?
+ continue_params = {
+ to: merge_request_path(merge_request),
+ notice: "#{edit_in_new_fork_notice} Try to revert this commit again.",
+ notice_now: edit_in_new_fork_notice_now
+ }
+
+ project_forks_path(project,
+ namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ end
+ end
+
+ def conflict_resolution_path
+ if conflicts.can_be_resolved_in_ui? && conflicts.can_be_resolved_by?(current_user)
+ conflicts_project_merge_request_path(project, merge_request)
+ end
+ end
+
+ def target_branch_tree_path
+ if target_branch_exists?
+ project_tree_path(project, target_branch)
+ end
+ end
+
+ def target_branch_commits_path
+ if target_branch_exists?
+ project_commits_path(project, target_branch)
+ end
+ end
+
+ def source_branch_path
+ if source_branch_exists?
+ project_branch_path(source_project, source_branch)
+ end
+ end
+
+ def source_branch_with_namespace_link
+ namespace = source_project_namespace
+ branch = source_branch
+
+ if source_branch_exists?
+ namespace = link_to(namespace, project_path(source_project))
+ branch = link_to(branch, project_tree_path(source_project, source_branch))
+ end
+
+ if for_fork?
+ namespace + ":" + branch
+ else
+ branch
+ end
+ end
+
+ def closing_issues_links
+ markdown(
+ issues_sentence(project, closing_issues),
+ pipeline: :gfm,
+ author: author,
+ project: project,
+ issuable_state_filter_enabled: true
+ )
+ end
+
+ def mentioned_issues_links
+ mentioned_issues = issues_mentioned_but_not_closing(current_user)
+ markdown(
+ issues_sentence(project, mentioned_issues),
+ pipeline: :gfm,
+ author: author,
+ project: project,
+ issuable_state_filter_enabled: true
+ )
+ end
+
+ def assign_to_closing_issues_link
+ issues = MergeRequests::AssignIssuesService.new(project,
+ current_user,
+ merge_request: merge_request,
+ closes_issues: closing_issues
+ ).assignable_issues
+ path = assign_related_issues_project_merge_request_path(project, merge_request)
+ if issues.present?
+ pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
+ link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
+ end
+ end
+
+ def can_revert_on_current_merge_request?
+ user_can_collaborate_with_project? && can_be_reverted?(current_user)
+ end
+
+ def can_cherry_pick_on_current_merge_request?
+ user_can_collaborate_with_project? && can_be_cherry_picked?
+ end
+
+ private
+
+ def conflicts
+ @conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request)
+ end
+
+ def closing_issues
+ @closing_issues ||= closes_issues(current_user)
+ end
+
+ def pipeline
+ @pipeline ||= head_pipeline
+ end
+
+ def issues_sentence(project, issues)
+ # Sorting based on the `#123` or `group/project#123` reference will sort
+ # local issues first.
+ issues.map do |issue|
+ issue.to_reference(project)
+ end.sort.to_sentence
+ end
+
+ def user_can_collaborate_with_project?
+ can?(current_user, :push_code, project) ||
+ (current_user && current_user.already_forked?(project))
+ end
+
+ def user_can_fork_project?
+ can?(current_user, :fork_project, project)
+ end
+end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 86ac513b3c0..229311eb6ee 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -11,7 +11,7 @@ module Projects
end
def enabled_keys
- @enabled_keys ||= project.deploy_keys
+ @enabled_keys ||= project.deploy_keys.includes(:projects)
end
def any_keys_enabled?
@@ -23,11 +23,7 @@ module Projects
end
def available_project_keys
- @available_project_keys ||= current_user.project_deploy_keys - enabled_keys
- end
-
- def any_available_project_keys_enabled?
- available_project_keys.any?
+ @available_project_keys ||= current_user.project_deploy_keys.includes(:projects) - enabled_keys
end
def key_available?(deploy_key)
@@ -37,15 +33,22 @@ module Projects
def available_public_keys
return @available_public_keys if defined?(@available_public_keys)
- @available_public_keys ||= DeployKey.are_public - enabled_keys
+ @available_public_keys ||= DeployKey.are_public.includes(:projects) - enabled_keys
# Public keys that are already used by another accessible project are already
# in @available_project_keys.
@available_public_keys -= available_project_keys
end
- def any_available_public_keys_enabled?
- available_public_keys.any?
+ def as_json
+ serializer = DeployKeySerializer.new
+ opts = { user: current_user }
+
+ {
+ enabled_keys: serializer.represent(enabled_keys, opts),
+ available_project_keys: serializer.represent(available_project_keys, opts),
+ public_keys: serializer.represent(available_public_keys, opts)
+ }
end
def to_partial_path
diff --git a/app/serializers/README.md b/app/serializers/README.md
new file mode 100644
index 00000000000..0337f88db5f
--- /dev/null
+++ b/app/serializers/README.md
@@ -0,0 +1,325 @@
+# Serializers
+
+This is a documentation for classes located in `app/serializers` directory.
+
+In GitLab, we use [grape-entities][grape-entity-project], accompanied by a
+serializer, to convert a Ruby object to its JSON representation.
+
+Serializers are typically used in controllers to build a JSON response
+that is usually consumed by a frontend code.
+
+## Why using a serializer is important?
+
+Using serializers, instead of `to_json` method, has several benefits:
+
+* it helps to prevent exposure of a sensitive data stored in the database
+* it makes it easier to test what should and should not be exposed
+* it makes it easier to reuse serialization entities that are building blocks
+* it makes it easier to move complexity from controllers to easily testable
+ classes
+* it encourages hiding complexity behind intentions-revealing interfaces
+* it makes it easier to take care about serialization performance concerns
+* it makes it easier to reduce merge conflicts between CE -> EE
+* it makes it easier to benefit from domain driven development techniques
+
+## What is a serializer?
+
+A serializer is a class that encapsulates all business rules for building a
+JSON response using serialization entities.
+
+It is designed to be testable and to support passing additional context from
+the controller.
+
+## What is a serialization entity?
+
+Entities are lightweight structures that allow to represent domain models
+in a consistent and abstracted way, and reuse them as building blocks to
+create a payload.
+
+Entities located in `app/serializers` are usually derived from a
+[`Grape::Entity`][grape-entity-class] class.
+
+Serialization entities that do require to have a knowledge about specific
+elements of the request, need to mix `RequestAwareEntity` in.
+
+A serialization entity usually maps a domain model class into its JSON
+representation. It rarely happens that a serialization entity exists without
+a corresponding domain model class. As an example, we have an `Issue` class and
+a corresponding `IssueSerializer`.
+
+Serialization entites are designed to reuse other serialization entities, which
+is a convenient way to create a multi-level JSON representation of a piece of
+a domain model you want to serialize.
+
+See [documentation for Grape Entites][grape-entity-readme] for more details.
+
+## How to implement a serializer?
+
+### Base implementation
+
+In order to effectively implement a serializer it is necessary to create a new
+class in `app/serializers`. See existing serializers as an example.
+
+A new serializer should inherit from a `BaseSerializer` class. It is necessary
+to specify which serialization entity will be used to serialize a resource.
+
+```ruby
+class MyResourceSerializer < BaseSerialize
+ entity MyResourceEntity
+end
+```
+
+The example above shows how a most simple serializer can look like.
+
+Given that the entity `MyResourceEntity` exists, you can now use
+`MyResourceSerializer` in the controller by creating an instance of it, and
+calling `MyResourceSerializer#represent(resource)` method.
+
+Note that a `resource` can be either a single object, an array of objects or an
+`ActiveRecord::Relation` object. A serialization entity should be smart enough
+to accurately represent each of these.
+
+It should not be necessary to use `Enumerable#map`, and it should be avoided
+from the performance reasons.
+
+### Choosing what gets serialized
+
+It often happens that you might want to use the same serializer in many places,
+but sometimes the intention is to only expose a small subset of object's
+attributes in one place, and a different subset in another.
+
+`BaseSerializer#represent(resource, opts = {})` method can take an additional
+hash argument, `opts`, that defines what is going to be serialized.
+
+`BaseSerializer` will pass these options to a serialization entity. See
+how it is [documented in the upstream project][grape-entity-only].
+
+With this approach you can extend the serializer to respond to methods that will
+create a JSON response according to your needs.
+
+```ruby
+class PipelineSerializer < BaseSerializer
+ entity PipelineEntity
+
+ def represent_details(resource)
+ represent(resource, only: [:details])
+ end
+
+ def represent_status(resource)
+ represent(resource, only: [:status])
+ end
+end
+```
+
+It is possible to use `only` and `except` keywords. Both keywords do support
+nested attributes, like `except: [:id, { user: [:id] }]`.
+
+Passing `only` and `except` to the `represent` method from a controller is
+possible, but it defies principles of encapsulation and testability, and it is
+better to avoid it, and to add a specific method to the serializer instead.
+
+### Reusing serialization entities from the API
+
+Public API in GitLab is implemented using [Grape][grape-project].
+
+Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes.
+This means that it is possible to reuse these classes to implement internal
+serializers.
+
+You can either use such entity directly:
+
+```ruby
+class MyResourceSerializer < BaseSerializer
+ entity API::Entities::SomeEntity
+end
+```
+
+Or derive a new serialization entity class from it:
+
+```ruby
+class MyEntity < API::Entities::SomeEntity
+ include RequestAwareEntity
+
+ unexpose :something
+end
+```
+
+It might be a good idea to write specs for entities that do inherit from
+the API, because when API payloads are changed / extended, it is easy to forget
+about the impact on the internal API through a serializer that reuses API
+entities.
+
+It is usually safe to do that, because API entities rarely break backward
+compatibility, but additional exposure may have a performance impact when API
+gets extended significantly. Write tests that check if only necessary data is
+exposed.
+
+## How to write tests for a serializer?
+
+Like every other class in the project, creating a serializer warrants writing
+tests for it.
+
+It is usually a good idea to test each public method in the serializer against
+a valid payload. `BaseSerializer#represent` returns a hash, so it is possible
+to use usual RSpec matchers like `include`.
+
+Sometimes, when the payload is large, it makes sense to validate it entirely
+using `match_response_schema` matcher along with a new fixture that can be
+stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema`
+gem, which is quite flexible, see a [documentation][json-schema-gem] for it.
+
+## How to use a serializer in a controller?
+
+Once a new serializer is implemented, it is possible to use it in a controller.
+
+Create an instance of the serializer and render the response.
+
+```ruby
+def index
+ format.json do
+ render json: MyResourceSerializer
+ .new(current_user: @current_user)
+ .represent_details(@project.resources)
+ nd
+end
+```
+
+If it is necessary to include additional information in the payload, it is
+possible to extend what is going to be rendered, the usual way:
+
+```ruby
+def index
+ format.json do
+ render json: {
+ resources: MyResourceSerializer
+ .new(current_user: @current_user)
+ .represent_details(@project.resources),
+ count: @project.resources.count
+ }
+ nd
+end
+```
+
+Note that in these examples an additional context is being passed to the
+serializer (`current_user: @current_user`).
+
+## How to pass an additional context from the controller?
+
+It is possible to pass an additional context from a controller to a
+serializer and each serialization entity that is used in the process.
+
+Serialization entities that do require an additional context have
+`RequestAwareEntity` concern mixed in. This piece of the code exposes a method
+called `request` in every serialization entity that is instantiated during
+serialization.
+
+An object returned by this method is an instance of `EntityRequest`, which
+behaves like an `OpenStruct` object, with the difference that it will raise
+an error if an unknown method is called.
+
+In other words, in the previous example, `request` method will return an
+instance of `EntityRequest` that responds to `current_user` method. It will be
+available in every serialization entity instantiated by `MyResourceSerializer`.
+
+`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be
+refactored soon. Please avoid passing an additional context that is not
+required by a serialization entity.
+
+At the moment, the context that is passed to entities most often is
+`current_user` and `project`.
+
+## How is this related to using presenters?
+
+Payload created by a serializer is usually a representation of the backed code,
+combined with the current request data. Therefore, technically, serializers
+are presenters that create payload consumed by a frontend code, usually Vue
+components.
+
+In GitLab, it is possible to use [presenters][presenters-readme], but
+`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898].
+
+It is possible to use presenters when serializer is used to represent only
+a single object. It is not supported when `ActiveRecord::Relation` is being
+serialized.
+
+```ruby
+MyObjectSerializer.new.represent(object.present)
+```
+
+## Best practices
+
+1. Do not invoke a serializer from within a serialization entity.
+
+ If you need to use a serializer from within a serialization entity, it is
+ possible that you are missing a class for an important domain concept.
+
+ Consider creating a new domain class and a corresponding serialization
+ entity for it.
+
+1. Use only one approach to switch behavior of the serializer.
+
+ It is possible to use a few approaches to switch a behavior of the
+ serializer. Most common are using a [Fluent Interface][fluent-interface]
+ and creating a separate `represent_something` methods.
+
+ Whatever you choose, it might be better to use only one approach at a time.
+
+1. Do not forget about creating specs for serialization entities.
+
+ Writing tests for the serializer indeed does cover testing a behavior of
+ serialization entities that the serializer instantiates. However it might
+ be a good idea to write separate tests for entities as well, because these
+ are meant to be reused in different serializers, and a serializer can
+ change a behavior of a serialization entity.
+
+1. Use `ActiveRecord::Relation` where possible
+
+ Using an `ActiveRecord::Relation` might help from the performance perspective.
+
+1. Be diligent about passing an additional context from the controller.
+
+ Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack
+ of high-level mechanism. It is meant to be refactored, and current
+ implementation is error prone. Imagine the situation that one serialization
+ entity requires `request.user` attribute, but the second one wants
+ `request.current_user`. When it happens that these two entities are used in
+ the same serialization request, you might need to pass both parameters to
+ the serializer, which is obviously not a perfect situation.
+
+ When in doubt, pass only `current_user` and `project` if these are required.
+
+1. Keep performance concerns in mind
+
+ Using a serializer incorrectly can have significant impact on the
+ performance.
+
+ Because serializers are technically presenters, it is often necessary
+ to calculate, for example, paths to various controller-actions.
+ Since using URL helpers usually involve passing `project` and `namespace`
+ adding `includes(project: :namespace)` in the serializer, can help to avoid
+ N+1 queries.
+
+ Also, try to avoid using `Enumerable#map` or other methods that will
+ execute a database query eagerly.
+
+1. Avoid passing `only` and `except` from the controller.
+1. Write tests checking for N+1 queries.
+1. Write controller tests for actions / formats using serializers.
+1. Write tests that check if only necessary data is exposed.
+1. Write tests that check if no sensitive data is exposed.
+
+## Future
+
+* [Next iteration of serializers][issue-27569]
+
+[grape-project]: http://www.ruby-grape.org
+[grape-entity-project]: https://github.com/ruby-grape/grape-entity
+[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md
+[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb
+[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want
+[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md
+[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface
+[json-schema-gem]: https://github.com/ruby-json-schema/json-schema
+[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045
+[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898
+[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569
diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb
index a0db5b8f0f4..bdc22d71202 100644
--- a/app/serializers/analytics_build_entity.rb
+++ b/app/serializers/analytics_build_entity.rb
@@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity
end
expose :url do |build|
- url_to(:namespace_project_build, build)
+ url_to(:namespace_project_job, build)
end
expose :commit_url do |build|
@@ -35,6 +35,6 @@ class AnalyticsBuildEntity < Grape::Entity
private
def url_to(route, build, id = nil)
- public_send("#{route}_url", build.project.namespace, build.project, id || build)
+ public_send("#{route}_url", build.project.namespace, build.project, id || build) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/serializers/analytics_issue_entity.rb b/app/serializers/analytics_issue_entity.rb
index 44c50f18613..b7d95ea020f 100644
--- a/app/serializers/analytics_issue_entity.rb
+++ b/app/serializers/analytics_issue_entity.rb
@@ -24,6 +24,6 @@ class AnalyticsIssueEntity < Grape::Entity
private
def url_to(route, id)
- public_send("#{route}_url", request.project.namespace, request.project, id)
+ public_send("#{route}_url", request.project.namespace, request.project, id) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index 69bf693de8d..564612202b5 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity
include EntityDateHelper
expose :title
+ expose :name
expose :legend
expose :description
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index 91803ec07f5..9c37afd53e1 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -1,7 +1,4 @@
class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
-
- expose :title do |object|
- object.title.pluralize(object.value)
- end
+ expose :title
end
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 311ee9c96be..4e6c15f673b 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -3,8 +3,10 @@ class BaseSerializer
@request = EntityRequest.new(parameters)
end
- def represent(resource, opts = {})
- self.class.entity_class
+ def represent(resource, opts = {}, entity_class = nil)
+ entity_class = entity_class || self.class.entity_class
+
+ entity_class
.represent(resource, opts.merge(request: @request))
.as_json
end
diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb
new file mode 100644
index 00000000000..56f173e5a27
--- /dev/null
+++ b/app/serializers/blob_entity.rb
@@ -0,0 +1,17 @@
+class BlobEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :last_commit do |blob|
+ request.project.repository.last_commit_for_path(blob.commit_id, blob.path)
+ end
+
+ expose :icon do |blob|
+ IconsHelper.file_type_icon_class('file', blob.mode, blob.name)
+ end
+
+ expose :url do |blob|
+ project_blob_path(request.project, File.join(request.ref, blob.path))
+ end
+end
diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb
index 184b4b7a681..f2d76a8ad81 100644
--- a/app/serializers/build_action_entity.rb
+++ b/app/serializers/build_action_entity.rb
@@ -6,11 +6,16 @@ class BuildActionEntity < Grape::Entity
end
expose :path do |build|
- play_namespace_project_build_path(
- build.project.namespace,
- build.project,
- build)
+ play_project_job_path(build.project, build)
end
expose :playable?, as: :playable
+
+ private
+
+ alias_method :build, :object
+
+ def playable?
+ build.playable? && can?(request.current_user, :update_build, build)
+ end
end
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index 8b643d8e783..6e0e33bc09b 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -1,14 +1,30 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
- expose :name do |build|
- build.name
+ expose :name do |job|
+ job.name
end
- expose :path do |build|
- download_namespace_project_build_artifacts_path(
- build.project.namespace,
- build.project,
- build)
+ expose :artifacts_expired?, as: :expired
+ expose :artifacts_expire_at, as: :expire_at
+
+ expose :path do |job|
+ download_project_job_artifacts_path(project, job)
+ end
+
+ expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
+ keep_project_job_artifacts_path(project, job)
+ end
+
+ expose :browse_path do |job|
+ browse_project_job_artifacts_path(project, job)
+ end
+
+ private
+
+ alias_method :job, :object
+
+ def project
+ job.project
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
new file mode 100644
index 00000000000..743a08acefe
--- /dev/null
+++ b/app/serializers/build_details_entity.rb
@@ -0,0 +1,46 @@
+class BuildDetailsEntity < JobEntity
+ expose :coverage, :erased_at, :duration
+ expose :tag_list, as: :tags
+ expose :user, using: UserEntity
+ expose :runner, using: RunnerEntity
+ expose :pipeline, using: PipelineEntity
+
+ expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build|
+ erase_project_job_path(project, build)
+ end
+
+ expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do
+ expose :iid do |build|
+ build.merge_request.iid
+ end
+
+ expose :path do |build|
+ project_merge_request_path(build.merge_request.project,
+ build.merge_request)
+ end
+ end
+
+ expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build|
+ new_project_issue_path(project, issue: build_failed_issue_options)
+ end
+
+ expose :raw_path do |build|
+ raw_project_job_path(project, build)
+ end
+
+ private
+
+ def build_failed_issue_options
+ { title: "Build Failed ##{build.id}",
+ description: project_job_path(project, build) }
+ end
+
+ def current_user
+ request.current_user
+ end
+
+ def project
+ build.project
+ end
+end
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
deleted file mode 100644
index b804d6d0e8a..00000000000
--- a/app/serializers/build_entity.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-class BuildEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :id
- expose :name
-
- expose :build_path do |build|
- path_to(:namespace_project_build, build)
- end
-
- expose :retry_path do |build|
- path_to(:retry_namespace_project_build, build)
- end
-
- expose :play_path, if: ->(build, _) { build.playable? } do |build|
- path_to(:play_namespace_project_build, build)
- end
-
- expose :playable?, as: :playable
- expose :created_at
- expose :updated_at
- expose :detailed_status, as: :status, with: StatusEntity
-
- private
-
- alias_method :build, :object
-
- def path_to(route, build)
- send("#{route}_path", build.project.namespace, build.project, build)
- end
-
- def detailed_status
- build.detailed_status(request.user)
- end
-end
diff --git a/app/serializers/build_serializer.rb b/app/serializers/build_serializer.rb
index 79b67001199..bae9932847f 100644
--- a/app/serializers/build_serializer.rb
+++ b/app/serializers/build_serializer.rb
@@ -1,5 +1,5 @@
class BuildSerializer < BaseSerializer
- entity BuildEntity
+ entity JobEntity
def represent_status(resource)
data = represent(resource, { only: [:status] })
diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb
index 31763955f97..e4e9d8ef90a 100644
--- a/app/serializers/commit_entity.rb
+++ b/app/serializers/commit_entity.rb
@@ -8,16 +8,10 @@ class CommitEntity < API::Entities::RepoCommit
end
expose :commit_url do |commit|
- namespace_project_commit_url(
- request.project.namespace,
- request.project,
- commit)
+ project_commit_url(request.project, commit)
end
expose :commit_path do |commit|
- namespace_project_commit_path(
- request.project.namespace,
- request.project,
- commit)
+ project_commit_path(request.project, commit)
end
end
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
new file mode 100644
index 00000000000..c75431a79ae
--- /dev/null
+++ b/app/serializers/deploy_key_entity.rb
@@ -0,0 +1,21 @@
+class DeployKeyEntity < Grape::Entity
+ expose :id
+ expose :user_id
+ expose :title
+ expose :fingerprint
+ expose :can_push
+ expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
+ expose :almost_orphaned?, as: :almost_orphaned
+ expose :created_at
+ expose :updated_at
+ expose :projects, using: ProjectEntity do |deploy_key|
+ deploy_key.projects.without_deleted.select { |project| options[:user].can?(:read_project, project) }
+ end
+ expose :can_edit
+
+ private
+
+ def can_edit
+ options[:user].can?(:update_deploy_key, object)
+ end
+end
diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb
new file mode 100644
index 00000000000..8f849eb88b7
--- /dev/null
+++ b/app/serializers/deploy_key_serializer.rb
@@ -0,0 +1,3 @@
+class DeployKeySerializer < BaseSerializer
+ entity DeployKeyEntity
+end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index d610fbe0c8a..241c689bccd 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -11,17 +11,16 @@ class DeploymentEntity < Grape::Entity
end
expose :ref_path do |deployment|
- namespace_project_tree_path(
- deployment.project.namespace,
- deployment.project,
- id: deployment.ref)
+ project_tree_path(deployment.project, id: deployment.ref)
end
end
+ expose :created_at
expose :tag
expose :last?
+
expose :user, using: UserEntity
expose :commit, using: CommitEntity
- expose :deployable, using: BuildEntity
- expose :manual_actions, using: BuildEntity
+ expose :deployable, using: JobEntity
+ expose :manual_actions, using: JobEntity
end
diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb
new file mode 100644
index 00000000000..cba5c3f311f
--- /dev/null
+++ b/app/serializers/deployment_serializer.rb
@@ -0,0 +1,8 @@
+class DeploymentSerializer < BaseSerializer
+ entity DeploymentEntity
+
+ def represent_concise(resource, opts = {})
+ opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]]
+ represent(resource, opts)
+ end
+end
diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb
index 9607ad55a8b..71d9a65fb58 100644
--- a/app/serializers/entity_date_helper.rb
+++ b/app/serializers/entity_date_helper.rb
@@ -4,7 +4,7 @@ module EntityDateHelper
def interval_in_words(diff)
return 'Not started' unless diff
- "#{distance_of_time_in_words(Time.now, diff)} ago"
+ distance_of_time_in_words(Time.now, diff, scope: 'datetime.time_ago_in_words')
end
# Converts seconds into a hash such as:
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 4ff15a78115..dcaccc3007d 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -10,32 +10,20 @@ class EnvironmentEntity < Grape::Entity
expose :stop_action?
expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment|
- metrics_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ metrics_project_environment_path(environment.project, environment)
end
expose :environment_path do |environment|
- namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ project_environment_path(environment.project, environment)
end
expose :stop_path do |environment|
- stop_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ stop_project_environment_path(environment.project, environment)
end
expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment|
- can?(request.user, :admin_environment, environment.project) &&
- terminal_namespace_project_environment_path(
- environment.project.namespace,
- environment.project,
- environment)
+ can?(request.current_user, :admin_environment, environment.project) &&
+ terminal_project_environment_path(environment.project, environment)
end
expose :created_at, :updated_at
diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb
new file mode 100644
index 00000000000..935d67a4f37
--- /dev/null
+++ b/app/serializers/event_entity.rb
@@ -0,0 +1,4 @@
+class EventEntity < Grape::Entity
+ expose :author, using: UserEntity
+ expose :updated_at
+end
diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb
new file mode 100644
index 00000000000..7c872a3e986
--- /dev/null
+++ b/app/serializers/group_entity.rb
@@ -0,0 +1,50 @@
+class GroupEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+ include MembersHelper
+ include GroupsHelper
+
+ expose :id, :name, :path, :description, :visibility
+ expose :full_name, :full_path
+ expose :web_url
+ expose :parent_id
+ expose :created_at, :updated_at
+
+ expose :group_path do |group|
+ group_path(group)
+ end
+
+ expose :permissions do
+ expose :human_group_access do |group, options|
+ group.group_members.find_by(user_id: request.current_user)&.human_access
+ end
+ end
+
+ expose :edit_path do |group|
+ edit_group_path(group)
+ end
+
+ expose :leave_path do |group|
+ leave_group_group_members_path(group)
+ end
+
+ expose :can_edit do |group|
+ can?(request.current_user, :admin_group, group)
+ end
+
+ expose :has_subgroups do |group|
+ GroupsFinder.new(request.current_user, parent: group).execute.any?
+ end
+
+ expose :number_projects_with_delimiter do |group|
+ number_with_delimiter(GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.count)
+ end
+
+ expose :number_users_with_delimiter do |group|
+ number_with_delimiter(group.users.count)
+ end
+
+ expose :avatar_url do |group|
+ group_icon(group)
+ end
+end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
new file mode 100644
index 00000000000..26e8566828b
--- /dev/null
+++ b/app/serializers/group_serializer.rb
@@ -0,0 +1,19 @@
+class GroupSerializer < BaseSerializer
+ entity GroupEntity
+
+ def with_pagination(request, response)
+ tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ @paginator.present?
+ end
+
+ def represent(resource, opts = {})
+ if paginated?
+ super(@paginator.paginate(resource), opts)
+ else
+ super(resource, opts)
+ end
+ end
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index 29aecb50849..bd5211b8e58 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -1,12 +1,10 @@
class IssuableEntity < Grape::Entity
expose :id
expose :iid
- expose :assignee_id
expose :author_id
expose :description
expose :lock_version
expose :milestone_id
- expose :position
expose :state
expose :title
expose :updated_by_id
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 6429159ebe1..c189a4992da 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,9 +1,16 @@
class IssueEntity < IssuableEntity
+ include RequestAwareEntity
+
expose :branch_name
expose :confidential
+ expose :assignees, using: API::Entities::UserBasic
expose :due_date
expose :moved_to_id
expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
+
+ expose :web_url do |issue|
+ project_issue_path(issue.project, issue)
+ end
end
diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb
new file mode 100644
index 00000000000..72e56a2c77f
--- /dev/null
+++ b/app/serializers/job_entity.rb
@@ -0,0 +1,51 @@
+class JobEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :name
+
+ expose :build_path do |build|
+ build.target_url || path_to(:namespace_project_job, build)
+ end
+
+ expose :retry_path, if: -> (*) { retryable? } do |build|
+ path_to(:retry_namespace_project_job, build)
+ end
+
+ expose :cancel_path, if: -> (*) { cancelable? } do |build|
+ path_to(:cancel_namespace_project_job, build)
+ end
+
+ expose :play_path, if: -> (*) { playable? } do |build|
+ path_to(:play_namespace_project_job, build)
+ end
+
+ expose :playable?, as: :playable
+ expose :created_at
+ expose :updated_at
+ expose :detailed_status, as: :status, with: StatusEntity
+
+ private
+
+ alias_method :build, :object
+
+ def cancelable?
+ build.cancelable? && can?(request.current_user, :update_build, build)
+ end
+
+ def retryable?
+ build.retryable? && can?(request.current_user, :update_build, build)
+ end
+
+ def playable?
+ build.playable? && can?(request.current_user, :update_build, build)
+ end
+
+ def detailed_status
+ build.detailed_status(request.current_user)
+ end
+
+ def path_to(route, build)
+ send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb
new file mode 100644
index 00000000000..8554de55517
--- /dev/null
+++ b/app/serializers/job_group_entity.rb
@@ -0,0 +1,16 @@
+class JobGroupEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :name
+ expose :size
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :jobs, with: JobEntity
+
+ private
+
+ alias_method :group, :object
+
+ def detailed_status
+ group.detailed_status(request.current_user)
+ end
+end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index 304fd9de08f..4452161051e 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -1,11 +1,13 @@
class LabelEntity < Grape::Entity
- expose :id
+ expose :id, if: ->(label, _) { !label.is_a?(GlobalLabel) }
+
expose :title
expose :color
expose :description
expose :group_id
expose :project_id
expose :template
+ expose :text_color
expose :created_at
expose :updated_at
end
diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb
new file mode 100644
index 00000000000..ad6ba8c46c9
--- /dev/null
+++ b/app/serializers/label_serializer.rb
@@ -0,0 +1,7 @@
+class LabelSerializer < BaseSerializer
+ entity LabelEntity
+
+ def represent_appearance(resource)
+ represent(resource, { only: [:id, :title, :color, :text_color] })
+ end
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
new file mode 100644
index 00000000000..8461f158bb5
--- /dev/null
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -0,0 +1,11 @@
+class MergeRequestBasicEntity < Grape::Entity
+ expose :assignee_id
+ expose :merge_status
+ expose :merge_error
+ expose :state
+ expose :source_branch_exists?, as: :source_branch_exists
+ expose :time_estimate
+ expose :total_time_spent
+ expose :human_time_estimate
+ expose :human_total_time_spent
+end
diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb
new file mode 100644
index 00000000000..cc5c664c8fa
--- /dev/null
+++ b/app/serializers/merge_request_basic_serializer.rb
@@ -0,0 +1,3 @@
+class MergeRequestBasicSerializer < BaseSerializer
+ entity MergeRequestBasicEntity
+end
diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb
new file mode 100644
index 00000000000..11234313293
--- /dev/null
+++ b/app/serializers/merge_request_create_entity.rb
@@ -0,0 +1,7 @@
+class MergeRequestCreateEntity < Grape::Entity
+ expose :iid
+
+ expose :url do |merge_request|
+ Gitlab::UrlBuilder.build(merge_request)
+ end
+end
diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb
new file mode 100644
index 00000000000..08daf473319
--- /dev/null
+++ b/app/serializers/merge_request_create_serializer.rb
@@ -0,0 +1,3 @@
+class MergeRequestCreateSerializer < BaseSerializer
+ entity MergeRequestCreateEntity
+end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index 5f80ab397a9..07650ce6f20 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -1,6 +1,7 @@
class MergeRequestEntity < IssuableEntity
+ include RequestAwareEntity
+
expose :in_progress_merge_commit_sha
- expose :locked_at
expose :merge_commit_sha
expose :merge_error
expose :merge_params
@@ -11,4 +12,167 @@ class MergeRequestEntity < IssuableEntity
expose :source_project_id
expose :target_branch
expose :target_project_id
+
+ # Events
+ expose :merge_event, using: EventEntity
+ expose :closed_event, using: EventEntity
+
+ # User entities
+ expose :author, using: UserEntity
+ expose :merge_user, using: UserEntity
+
+ # Diff sha's
+ expose :diff_head_sha do |merge_request|
+ merge_request.diff_head_sha if merge_request.diff_head_commit
+ end
+
+ expose :merge_commit_sha
+ expose :merge_commit_message
+ expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline
+
+ # Booleans
+ expose :merge_ongoing?, as: :merge_ongoing
+ expose :work_in_progress?, as: :work_in_progress
+ expose :source_branch_exists?, as: :source_branch_exists
+ expose :mergeable_discussions_state?, as: :mergeable_discussions_state
+ expose :branch_missing?, as: :branch_missing
+ expose :commits_count
+ expose :cannot_be_merged?, as: :has_conflicts
+ expose :can_be_merged?, as: :can_be_merged
+ expose :remove_source_branch?, as: :remove_source_branch
+
+ expose :project_archived do |merge_request|
+ merge_request.project.archived?
+ end
+
+ expose :only_allow_merge_if_pipeline_succeeds do |merge_request|
+ merge_request.project.only_allow_merge_if_pipeline_succeeds?
+ end
+
+ # CI related
+ expose :has_ci?, as: :has_ci
+ expose :ci_status do |merge_request|
+ presenter(merge_request).ci_status
+ end
+
+ expose :issues_links do
+ expose :assign_to_closing do |merge_request|
+ presenter(merge_request).assign_to_closing_issues_link
+ end
+
+ expose :closing do |merge_request|
+ presenter(merge_request).closing_issues_links
+ end
+
+ expose :mentioned_but_not_closing do |merge_request|
+ presenter(merge_request).mentioned_issues_links
+ end
+ end
+
+ expose :source_branch_with_namespace_link do |merge_request|
+ presenter(merge_request).source_branch_with_namespace_link
+ end
+
+ expose :source_branch_path do |merge_request|
+ presenter(merge_request).source_branch_path
+ end
+
+ expose :current_user do
+ expose :can_remove_source_branch do |merge_request|
+ merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
+ end
+
+ expose :can_revert_on_current_merge_request do |merge_request|
+ presenter(merge_request).can_revert_on_current_merge_request?
+ end
+
+ expose :can_cherry_pick_on_current_merge_request do |merge_request|
+ presenter(merge_request).can_cherry_pick_on_current_merge_request?
+ end
+ end
+
+ # Paths
+ #
+ expose :target_branch_commits_path do |merge_request|
+ presenter(merge_request).target_branch_commits_path
+ end
+
+ expose :target_branch_tree_path do |merge_request|
+ presenter(merge_request).target_branch_tree_path
+ end
+
+ expose :new_blob_path do |merge_request|
+ if can?(current_user, :push_code, merge_request.project)
+ project_new_blob_path(merge_request.project, merge_request.source_branch)
+ end
+ end
+
+ expose :conflict_resolution_path do |merge_request|
+ presenter(merge_request).conflict_resolution_path
+ end
+
+ expose :remove_wip_path do |merge_request|
+ presenter(merge_request).remove_wip_path
+ end
+
+ expose :cancel_merge_when_pipeline_succeeds_path do |merge_request|
+ presenter(merge_request).cancel_merge_when_pipeline_succeeds_path
+ end
+
+ expose :create_issue_to_resolve_discussions_path do |merge_request|
+ presenter(merge_request).create_issue_to_resolve_discussions_path
+ end
+
+ expose :merge_path do |merge_request|
+ presenter(merge_request).merge_path
+ end
+
+ expose :cherry_pick_in_fork_path do |merge_request|
+ presenter(merge_request).cherry_pick_in_fork_path
+ end
+
+ expose :revert_in_fork_path do |merge_request|
+ presenter(merge_request).revert_in_fork_path
+ end
+
+ expose :email_patches_path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request, format: :patch)
+ end
+
+ expose :plain_diff_path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request, format: :diff)
+ end
+
+ expose :status_path do |merge_request|
+ project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ end
+
+ expose :ci_environments_status_path do |merge_request|
+ ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ expose :merge_commit_message_with_description do |merge_request|
+ merge_request.merge_commit_message(include_description: true)
+ end
+
+ expose :diverged_commits_count do |merge_request|
+ if merge_request.open? && merge_request.diverged_from_target_branch?
+ merge_request.diverged_commits_count
+ else
+ 0
+ end
+ end
+
+ expose :commit_change_content_path do |merge_request|
+ commit_change_content_project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ private
+
+ delegate :current_user, to: :request
+
+ def presenter(merge_request)
+ @presenters ||= {}
+ @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user)
+ end
end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index aa6e00dfcb4..f67034ce47a 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -1,3 +1,9 @@
class MergeRequestSerializer < BaseSerializer
- entity MergeRequestEntity
+ # This overrided method takes care of which entity should be used
+ # to serialize the `merge_request` based on `basic` key in `opts` param.
+ # Hence, `entity` doesn't need to be declared on the class scope.
+ def represent(merge_request, opts = {})
+ entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity
+ super(merge_request, opts, entity)
+ end
end
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
new file mode 100644
index 00000000000..130968a44c1
--- /dev/null
+++ b/app/serializers/pipeline_details_entity.rb
@@ -0,0 +1,7 @@
+class PipelineDetailsEntity < PipelineEntity
+ expose :details do
+ expose :legacy_stages, as: :stages, using: StageEntity
+ expose :artifacts, using: BuildArtifactEntity
+ expose :manual_actions, using: BuildActionEntity
+ end
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index ad8b4d43e8f..c4f000b0ca3 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -3,32 +3,30 @@ class PipelineEntity < Grape::Entity
expose :id
expose :user, using: UserEntity
+ expose :active?, as: :active
+ expose :coverage
+ expose :source
- expose :path do |pipeline|
- namespace_project_pipeline_path(
- pipeline.project.namespace,
- pipeline.project,
- pipeline)
- end
+ expose :created_at, :updated_at
- expose :details do
- expose :detailed_status, as: :status, with: StatusEntity
- expose :duration
- expose :finished_at
- expose :stages, using: StageEntity
- expose :artifacts, using: BuildArtifactEntity
- expose :manual_actions, using: BuildActionEntity
+ expose :path do |pipeline|
+ project_pipeline_path(pipeline.project, pipeline)
end
expose :flags do
expose :latest?, as: :latest
- expose :triggered?, as: :triggered
expose :stuck?, as: :stuck
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
end
+ expose :details do
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :duration
+ expose :finished_at
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -36,10 +34,7 @@ class PipelineEntity < Grape::Entity
expose :path do |pipeline|
if pipeline.ref
- namespace_project_tree_path(
- pipeline.project.namespace,
- pipeline.project,
- id: pipeline.ref)
+ project_ref_path(pipeline.project, pipeline.ref)
end
end
@@ -48,37 +43,32 @@ class PipelineEntity < Grape::Entity
end
expose :commit, using: CommitEntity
- expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? }
- expose :retry_path, if: proc { can_retry? } do |pipeline|
- retry_namespace_project_pipeline_path(pipeline.project.namespace,
- pipeline.project,
- pipeline.id)
+ expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
+ retry_project_pipeline_path(pipeline.project, pipeline)
end
- expose :cancel_path, if: proc { can_cancel? } do |pipeline|
- cancel_namespace_project_pipeline_path(pipeline.project.namespace,
- pipeline.project,
- pipeline.id)
+ expose :cancel_path, if: -> (*) { can_cancel? } do |pipeline|
+ cancel_project_pipeline_path(pipeline.project, pipeline)
end
- expose :created_at, :updated_at
+ expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
private
alias_method :pipeline, :object
def can_retry?
- can?(request.user, :update_pipeline, pipeline) &&
+ can?(request.current_user, :update_pipeline, pipeline) &&
pipeline.retryable?
end
def can_cancel?
- can?(request.user, :update_pipeline, pipeline) &&
+ can?(request.current_user, :update_pipeline, pipeline) &&
pipeline.cancelable?
end
def detailed_status
- pipeline.detailed_status(request.user)
+ pipeline.detailed_status(request.current_user)
end
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index e7a9df8ac4e..661bf17983c 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,7 +1,7 @@
class PipelineSerializer < BaseSerializer
InvalidResourceError = Class.new(StandardError)
- entity PipelineEntity
+ entity PipelineDetailsEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
@@ -13,14 +13,15 @@ class PipelineSerializer < BaseSerializer
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
+
resource = resource.preload([
:retryable_builds,
:cancelable_statuses,
:trigger_requests,
:project,
- { pending_builds: :project },
- { manual_actions: :project },
- { artifacts: :project }
+ :manual_actions,
+ :artifacts,
+ { pending_builds: :project }
])
end
@@ -37,4 +38,11 @@ class PipelineSerializer < BaseSerializer
data = represent(resource, { only: [{ details: [:status] }] })
data.dig(:details, :status) || {}
end
+
+ def represent_stages(resource)
+ return {} unless resource.present?
+
+ data = represent(resource, { only: [{ details: [:stages] }] })
+ data.dig(:details, :stages) || []
+ end
end
diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb
new file mode 100644
index 00000000000..b3e5fd21e97
--- /dev/null
+++ b/app/serializers/project_entity.rb
@@ -0,0 +1,14 @@
+class ProjectEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :name
+
+ expose :full_path do |project|
+ project_path(project)
+ end
+
+ expose :full_name do |project|
+ project.full_name
+ end
+end
diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb
index 3039014aaaa..d53fcfb8c1b 100644
--- a/app/serializers/request_aware_entity.rb
+++ b/app/serializers/request_aware_entity.rb
@@ -3,6 +3,7 @@ module RequestAwareEntity
included do
include Gitlab::Routing
+ include GitlabRoutingHelper
include Gitlab::Allowable
end
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
new file mode 100644
index 00000000000..e9999a36d8a
--- /dev/null
+++ b/app/serializers/runner_entity.rb
@@ -0,0 +1,18 @@
+class RunnerEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :description
+
+ expose :edit_path,
+ if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner|
+ edit_project_runner_path(project, runner)
+ end
+
+ private
+
+ alias_method :runner, :object
+
+ def project
+ request.project
+ end
+end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index 7a047bdc712..4523b15152e 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -7,21 +7,21 @@ class StageEntity < Grape::Entity
"#{stage.name}: #{detailed_status.label}"
end
- expose :detailed_status,
- as: :status,
- with: StatusEntity
+ expose :groups,
+ if: -> (_, opts) { opts[:grouped] },
+ with: JobGroupEntity
+
+ expose :detailed_status, as: :status, with: StatusEntity
expose :path do |stage|
- namespace_project_pipeline_path(
- stage.pipeline.project.namespace,
+ project_pipeline_path(
stage.pipeline.project,
stage.pipeline,
anchor: stage.name)
end
expose :dropdown_path do |stage|
- stage_namespace_project_pipeline_path(
- stage.pipeline.project.namespace,
+ stage_project_pipeline_path(
stage.pipeline.project,
stage.pipeline,
stage: stage.name,
@@ -33,6 +33,6 @@ class StageEntity < Grape::Entity
alias_method :stage, :object
def detailed_status
- stage.detailed_status(request.user)
+ stage.detailed_status(request.current_user)
end
end
diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb
index 944472f3e51..3e40ecf1c1c 100644
--- a/app/serializers/status_entity.rb
+++ b/app/serializers/status_entity.rb
@@ -7,6 +7,16 @@ class StatusEntity < Grape::Entity
expose :details_path
expose :favicon do |status|
- ActionController::Base.helpers.image_path(File.join('ci_favicons', "#{status.favicon}.ico"))
+ dir = 'ci_favicons'
+ dir = File.join(dir, 'dev') if Rails.env.development?
+
+ ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico"))
+ end
+
+ expose :action, if: -> (status, _) { status.has_action? } do
+ expose :action_icon, as: :icon
+ expose :action_title, as: :title
+ expose :action_path, as: :path
+ expose :action_method, as: :method
end
end
diff --git a/app/serializers/submodule_entity.rb b/app/serializers/submodule_entity.rb
new file mode 100644
index 00000000000..9a7eb5e7880
--- /dev/null
+++ b/app/serializers/submodule_entity.rb
@@ -0,0 +1,23 @@
+class SubmoduleEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :icon do |blob|
+ 'archive'
+ end
+
+ expose :project_url do |blob|
+ submodule_links(blob, request).first
+ end
+
+ expose :tree_url do |blob|
+ submodule_links(blob, request).last
+ end
+
+ private
+
+ def submodule_links(blob, request)
+ @submodule_links ||= SubmoduleHelper.submodule_links(blob, request.ref, request.repository)
+ end
+end
diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb
new file mode 100644
index 00000000000..555e5cf83bd
--- /dev/null
+++ b/app/serializers/tree_entity.rb
@@ -0,0 +1,17 @@
+class TreeEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :path, :name, :mode
+
+ expose :last_commit do |tree|
+ request.project.repository.last_commit_for_path(tree.commit_id, tree.path)
+ end
+
+ expose :icon do |tree|
+ IconsHelper.file_type_icon_class('folder', tree.mode, tree.name)
+ end
+
+ expose :url do |tree|
+ project_tree_path(request.project, File.join(request.ref, tree.path))
+ end
+end
diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb
new file mode 100644
index 00000000000..69702ae1493
--- /dev/null
+++ b/app/serializers/tree_root_entity.rb
@@ -0,0 +1,21 @@
+# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`.
+class TreeRootEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :path
+
+ expose :trees, using: TreeEntity
+ expose :blobs, using: BlobEntity
+ expose :submodules, using: SubmoduleEntity
+
+ expose :parent_tree_url do |tree|
+ path = tree.path.sub(%r{\A/}, '')
+ next unless path.present?
+
+ path_segments = path.split('/')
+ path_segments.pop
+ parent_tree_path = path_segments.join('/')
+
+ project_tree_path(request.project, File.join(request.ref, parent_tree_path))
+ end
+end
diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb
new file mode 100644
index 00000000000..713ade23bc9
--- /dev/null
+++ b/app/serializers/tree_serializer.rb
@@ -0,0 +1,3 @@
+class TreeSerializer < BaseSerializer
+ entity TreeRootEntity
+end
diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb
index 43754ea94f7..876512b12dc 100644
--- a/app/serializers/user_entity.rb
+++ b/app/serializers/user_entity.rb
@@ -1,2 +1,7 @@
class UserEntity < API::Entities::UserBasic
+ include RequestAwareEntity
+
+ expose :path do |user|
+ user_path(user)
+ end
end
diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb
index b2a543daa00..9c00ea789ec 100644
--- a/app/services/access_token_validation_service.rb
+++ b/app/services/access_token_validation_service.rb
@@ -5,10 +5,11 @@ class AccessTokenValidationService
REVOKED = :revoked
INSUFFICIENT_SCOPE = :insufficient_scope
- attr_reader :token
+ attr_reader :token, :request
- def initialize(token)
+ def initialize(token, request: nil)
@token = token
+ @request = request
end
def validate(scopes: [])
@@ -27,12 +28,23 @@ class AccessTokenValidationService
end
# True if the token's scope contains any of the passed scopes.
- def include_any_scope?(scopes)
- if scopes.blank?
+ def include_any_scope?(required_scopes)
+ if required_scopes.blank?
true
else
- # Check whether the token is allowed access to any of the required scopes.
- Set.new(scopes).intersection(Set.new(token.scopes)).present?
+ # We're comparing each required_scope against all token scopes, which would
+ # take quadratic time. This consideration is irrelevant here because of the
+ # small number of records involved.
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12300/#note_33689006
+ token_scopes = token.scopes.map(&:to_sym)
+
+ required_scopes.any? do |scope|
+ if scope.respond_to?(:sufficient?)
+ scope.sufficient?(token_scopes, request)
+ else
+ API::Scope.new(scope).sufficient?(token_scopes, request)
+ end
+ end
end
end
end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index 76b9f1feda7..59153cbbc0a 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -16,7 +16,7 @@ class AkismetService
created_at: DateTime.now,
author: owner.name,
author_email: owner.email,
- referrer: options[:referrer],
+ referrer: options[:referrer]
}
begin
@@ -58,7 +58,7 @@ class AkismetService
}
begin
- akismet_client.public_send(type, options[:ip_address], options[:user_agent], params)
+ akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 8a000585e89..5ad9a50687c 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -8,7 +8,7 @@ class AuditEventService
with: @details[:with],
target_id: @author.id,
target_type: 'User',
- target_details: @author.name,
+ target_details: @author.name
}
self
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 5e151b0f044..7dae5880931 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -103,6 +103,8 @@ module Auth
build_can_pull?(requested_project) || user_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
+ when '*'
+ user_can_admin?(requested_project)
else
false
end
@@ -120,6 +122,11 @@ module Auth
(requested_project == project || can?(current_user, :build_read_container_image, requested_project))
end
+ def user_can_admin?(requested_project)
+ has_authentication_ability?(:admin_container_image) &&
+ can?(current_user, :admin_container_image, requested_project)
+ end
+
def user_can_pull?(requested_project)
has_authentication_ability?(:read_container_image) &&
can?(current_user, :read_container_image, requested_project)
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index fd9ff115eab..9eedb9e65a2 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -1,18 +1,22 @@
module Boards
class CreateService < BaseService
def execute
- if project.boards.empty?
- create_board!
- else
- project.boards.first
- end
+ create_board! if can_create_board?
end
private
+ def can_create_board?
+ project.boards.size == 0
+ end
+
def create_board!
- board = project.boards.create
- board.lists.create(list_type: :closed)
+ board = project.boards.create(params)
+
+ if board.persisted?
+ board.lists.create(list_type: :backlog)
+ board.lists.create(list_type: :closed)
+ end
board
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 533e6787855..eb345fead2d 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -3,7 +3,7 @@ module Boards
class ListService < BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
- issues = without_board_labels(issues) unless list
+ issues = without_board_labels(issues) unless movable_list? || closed_list?
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority
end
@@ -21,21 +21,24 @@ module Boards
end
def movable_list?
- @movable_list ||= list.present? && list.movable?
+ return @movable_list if defined?(@movable_list)
+
+ @movable_list = list.present? && list.movable?
+ end
+
+ def closed_list?
+ return @closed_list if defined?(@closed_list)
+
+ @closed_list = list.present? && list.closed?
end
def filter_params
- set_default_scope
set_project
set_state
params
end
- def set_default_scope
- params[:scope] = 'all'
- end
-
def set_project
params[:project_id] = project.id
end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index d5735f13c1e..ecabb2a48e4 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -38,7 +38,7 @@ module Boards
attrs.merge!(
add_label_ids: add_label_ids,
remove_label_ids: remove_label_ids,
- state_event: issue_state,
+ state_event: issue_state
)
end
@@ -61,7 +61,7 @@ module Boards
if moving_to_list.movable?
moving_from_list.label_id
else
- project.boards.joins(:lists).merge(List.movable).pluck(:label_id)
+ Label.on_project_boards(project.id).pluck(:label_id)
end
Array(label_ids).compact
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index c579ed4c869..df2a01a69e5 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -2,6 +2,8 @@ module Boards
module Lists
class ListService < BaseService
def execute(board)
+ board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
+
board.lists
end
end
diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb
index 321bf3a9205..7256466c9e8 100644
--- a/app/services/chat_names/authorize_user_service.rb
+++ b/app/services/chat_names/authorize_user_service.rb
@@ -1,6 +1,6 @@
module ChatNames
class AuthorizeUserService
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
def initialize(service, params)
@service = service
diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb
deleted file mode 100644
index 70fb2c5e38f..00000000000
--- a/app/services/ci/create_pipeline_builds_service.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-module Ci
- class CreatePipelineBuildsService < BaseService
- attr_reader :pipeline
-
- def execute(pipeline)
- @pipeline = pipeline
-
- new_builds.map do |build_attributes|
- create_build(build_attributes)
- end
- end
-
- delegate :project, to: :pipeline
-
- private
-
- def create_build(build_attributes)
- build_attributes = build_attributes.merge(
- pipeline: pipeline,
- project: project,
- ref: pipeline.ref,
- tag: pipeline.tag,
- user: current_user,
- trigger_request: trigger_request
- )
- build = pipeline.builds.create(build_attributes)
-
- # Create the environment before the build starts. This sets its slug and
- # makes it available as an environment variable
- project.environments.find_or_create_by(name: build.expanded_environment_name) if
- build.has_environment?
-
- build
- end
-
- def new_builds
- @new_builds ||= pipeline.config_builds_attributes.
- reject { |build| existing_build_names.include?(build[:name]) }
- end
-
- def existing_build_names
- @existing_build_names ||= pipeline.builds.pluck(:name)
- end
-
- def trigger_request
- return @trigger_request if defined?(@trigger_request)
-
- @trigger_request ||= pipeline.trigger_requests.first
- end
- end
-end
diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb
new file mode 100644
index 00000000000..cd40deb6187
--- /dev/null
+++ b/app/services/ci/create_pipeline_schedule_service.rb
@@ -0,0 +1,13 @@
+module Ci
+ class CreatePipelineScheduleService < BaseService
+ def execute
+ project.pipeline_schedules.create(pipeline_schedule_params)
+ end
+
+ private
+
+ def pipeline_schedule_params
+ params.merge(owner: current_user)
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 21350be5557..884b681ff81 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,23 +2,61 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
- def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil)
@pipeline = Ci::Pipeline.new(
+ source: source,
project: project,
ref: ref,
sha: sha,
before_sha: before_sha,
tag: tag?,
trigger_requests: Array(trigger_request),
- user: current_user
+ user: current_user,
+ pipeline_schedule: schedule
)
+ result = validate(current_user || trigger_request.trigger.owner,
+ ignore_skip_ci: ignore_skip_ci,
+ save_on_errors: save_on_errors)
+
+ return result if result
+
+ begin
+ Ci::Pipeline.transaction do
+ pipeline.save!
+
+ yield(pipeline) if block_given?
+
+ Ci::CreatePipelineStagesService
+ .new(project, current_user)
+ .execute(pipeline)
+ end
+ rescue ActiveRecord::RecordInvalid => e
+ return error("Failed to persist the pipeline: #{e}")
+ end
+
+ update_merge_requests_head_pipeline
+
+ cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+
+ pipeline_created_counter.increment(source: source)
+
+ pipeline.tap(&:process!)
+ end
+
+ private
+
+ def validate(triggering_user, ignore_skip_ci:, save_on_errors:)
unless project.builds_enabled?
return error('Pipeline is disabled')
end
- unless trigger_request || can?(current_user, :create_pipeline, project)
- return error('Insufficient permissions to create a new pipeline')
+ unless allowed_to_trigger_pipeline?(triggering_user)
+ if can?(triggering_user, :create_pipeline, project)
+ return error("Insufficient permissions for protected ref '#{ref}'")
+ else
+ return error('Insufficient permissions to create a new pipeline')
+ end
end
unless branch? || tag?
@@ -31,7 +69,7 @@ module Ci
unless pipeline.config_processor
unless pipeline.ci_yaml_file
- return error('Missing .gitlab-ci.yml file')
+ return error("Missing #{pipeline.ci_yaml_file_path} file")
end
return error(pipeline.yaml_errors, save: save_on_errors)
end
@@ -41,24 +79,38 @@ module Ci
return pipeline
end
- unless pipeline.config_builds_attributes.present?
- return error('No builds for this pipeline.')
+ unless pipeline.has_stage_seeds?
+ return error('No stages / jobs for this pipeline.')
end
+ end
- Ci::Pipeline.transaction do
- pipeline.save
-
- Ci::CreatePipelineBuildsService
- .new(project, current_user)
- .execute(pipeline)
+ def allowed_to_trigger_pipeline?(triggering_user)
+ if triggering_user
+ allowed_to_create?(triggering_user)
+ else # legacy triggers don't have a corresponding user
+ !project.protected_for?(ref)
end
+ end
- cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+ def allowed_to_create?(triggering_user)
+ access = Gitlab::UserAccess.new(triggering_user, project: project)
- pipeline.tap(&:process!)
+ can?(triggering_user, :create_pipeline, project) &&
+ if branch?
+ access.can_update_branch?(ref)
+ elsif tag?
+ access.can_create_tag?(ref)
+ else
+ true # Allow it for now and we'll reject when we check ref existence
+ end
end
- private
+ def update_merge_requests_head_pipeline
+ return unless pipeline.latest?
+
+ MergeRequest.where(source_project: @pipeline.project, source_branch: @pipeline.ref)
+ .update_all(head_pipeline_id: @pipeline.id)
+ end
def skip_ci?
return false unless pipeline.git_commit_message
@@ -102,15 +154,21 @@ module Ci
end
def branch?
- project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref)
+ return @is_branch if defined?(@is_branch)
+
+ @is_branch =
+ project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref)
end
def tag?
- project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref)
+ return @is_tag if defined?(@is_tag)
+
+ @is_tag =
+ project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref)
end
def ref
- Gitlab::Git.ref_name(origin_ref)
+ @ref ||= Gitlab::Git.ref_name(origin_ref)
end
def valid_sha?
@@ -122,5 +180,9 @@ module Ci
pipeline.drop if save
pipeline
end
+
+ def pipeline_created_counter
+ @pipeline_created_counter ||= Gitlab::Metrics.counter(:pipelines_created_total, "Counter of pipelines created")
+ end
end
end
diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb
new file mode 100644
index 00000000000..f2c175adee6
--- /dev/null
+++ b/app/services/ci/create_pipeline_stages_service.rb
@@ -0,0 +1,20 @@
+module Ci
+ class CreatePipelineStagesService < BaseService
+ def execute(pipeline)
+ pipeline.stage_seeds.each do |seed|
+ seed.user = current_user
+
+ seed.create! do |build|
+ ##
+ # Create the environment before the build starts. This sets its slug and
+ # makes it available as an environment variable
+ #
+ if build.has_environment?
+ environment_name = build.expanded_environment_name
+ project.environments.find_or_create_by(name: environment_name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb
index dca5aa9f5d7..b2aa457bbd5 100644
--- a/app/services/ci/create_trigger_request_service.rb
+++ b/app/services/ci/create_trigger_request_service.rb
@@ -1,13 +1,19 @@
+# This class is deprecated because we're closing Ci::TriggerRequest.
+# New class is PipelineTriggerService (app/services/ci/pipeline_trigger_service.rb)
+# which is integrated with Ci::PipelineVariable instaed of Ci::TriggerRequest.
+# We remove this class after we removed v1 and v3 API. This class is still being
+# referred by such legacy code.
module Ci
- class CreateTriggerRequestService
- def execute(project, trigger, ref, variables = nil)
+ module CreateTriggerRequestService
+ Result = Struct.new(:trigger_request, :pipeline)
+
+ def self.execute(project, trigger, ref, variables = nil)
trigger_request = trigger.trigger_requests.create(variables: variables)
- pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref).
- execute(ignore_skip_ci: true, trigger_request: trigger_request)
- if pipeline.persisted?
- trigger_request
- end
+ pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref)
+ .execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request)
+
+ Result.new(trigger_request, pipeline)
end
end
end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
new file mode 100644
index 00000000000..1e5ad28ba57
--- /dev/null
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -0,0 +1,44 @@
+module Ci
+ class PipelineTriggerService < BaseService
+ def execute
+ if trigger_from_token
+ create_pipeline_from_trigger(trigger_from_token)
+ end
+ end
+
+ private
+
+ def create_pipeline_from_trigger(trigger)
+ # this check is to not leak the presence of the project if user cannot read it
+ return unless trigger.project == project
+
+ pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: params[:ref])
+ .execute(:trigger, ignore_skip_ci: true) do |pipeline|
+ trigger.trigger_requests.create!(pipeline: pipeline)
+ create_pipeline_variables!(pipeline)
+ end
+
+ if pipeline.persisted?
+ success(pipeline: pipeline)
+ else
+ error(pipeline.errors.messages, 400)
+ end
+ end
+
+ def trigger_from_token
+ return @trigger if defined?(@trigger)
+
+ @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
+ end
+
+ def create_pipeline_variables!(pipeline)
+ return unless params[:variables]
+
+ variables = params[:variables].map do |key, value|
+ { key: key, value: value }
+ end
+
+ pipeline.variables.create!(variables)
+ end
+ end
+end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
new file mode 100644
index 00000000000..e24f48c2d16
--- /dev/null
+++ b/app/services/ci/play_build_service.rb
@@ -0,0 +1,17 @@
+module Ci
+ class PlayBuildService < ::BaseService
+ def execute(build)
+ unless can?(current_user, :update_build, build)
+ raise Gitlab::Access::AccessDeniedError
+ end
+
+ # Try to enqueue the build, otherwise create a duplicate.
+ #
+ if build.enqueue
+ build.tap { |action| action.update(user: current_user) }
+ else
+ Ci::Build.retry(build, current_user)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 33edcd60944..55af193d717 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -5,6 +5,8 @@ module Ci
def execute(pipeline)
@pipeline = pipeline
+ update_retried
+
new_builds =
stage_indexes_of_created_builds.map do |index|
process_stage(index)
@@ -50,7 +52,7 @@ module Ci
when 'always'
%w[success failed skipped]
when 'manual'
- %w[success]
+ %w[success skipped]
else
[]
end
@@ -71,5 +73,23 @@ module Ci
def created_builds
pipeline.builds.created
end
+
+ # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
+ # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb
+ # and ensures that functionality will not be broken before migration is run
+ # this updates only when there are data that needs to be updated, there are two groups with no retried flag
+ def update_retried
+ # find the latest builds for each name
+ latest_statuses = pipeline.statuses.latest
+ .group(:name)
+ .having('count(*) > 1')
+ .pluck('max(id)', 'name')
+
+ # mark builds that are retried
+ pipeline.statuses.latest
+ .where(name: latest_statuses.map(&:second))
+ .where.not(id: latest_statuses.map(&:first))
+ .update_all(retried: true) if latest_statuses.any?
+ end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index d6a4280ce4c..414f672cc6a 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -30,6 +30,7 @@ module Ci
# with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method.
build.runner_id = runner.id
build.run!
+ register_success(build)
return Result.new(build, true)
rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError
@@ -46,6 +47,7 @@ module Ci
end
end
+ register_failure
Result.new(nil, valid)
end
@@ -54,24 +56,24 @@ module Ci
def builds_for_shared_runner
new_builds.
# don't run projects which have not enabled shared runners and builds
- joins(:project).where(projects: { shared_runners_enabled: true }).
- joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id').
- where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0').
+ joins(:project).where(projects: { shared_runners_enabled: true, pending_delete: false })
+ .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
+ .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0').
# Implement fair scheduling
# this returns builds that are ordered by number of running builds
# we prefer projects that don't use shared runners at all
- joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id").
- order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC')
+ joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id")
+ .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC')
end
def builds_for_specific_runner
- new_builds.where(project: runner.projects.with_builds_enabled).order('created_at ASC')
+ new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC')
end
def running_builds_for_shared_runners
- Ci::Build.running.where(runner: Ci::Runner.shared).
- group(:project_id).select(:project_id, 'count(*) AS running_builds')
+ Ci::Build.running.where(runner: Ci::Runner.shared)
+ .group(:project_id).select(:project_id, 'count(*) AS running_builds')
end
def new_builds
@@ -81,5 +83,27 @@ module Ci
def shared_runner_build_limits_feature_enabled?
ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true'
end
+
+ def register_failure
+ failed_attempt_counter.increment
+ attempt_counter.increment
+ end
+
+ def register_success(job)
+ job_queue_duration_seconds.observe({ shared_runner: @runner.shared? }, Time.now - job.created_at)
+ attempt_counter.increment
+ end
+
+ def failed_attempt_counter
+ @failed_attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_failed_total, "Counts the times a runner tries to register a job")
+ end
+
+ def attempt_counter
+ @attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_total, "Counts the times a runner tries to register a job")
+ end
+
+ def job_queue_duration_seconds
+ @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time')
+ end
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 89da05b72bb..ea3b8d66ed9 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,12 +1,12 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage stage_idx trigger_request
+ allow_failure stage_id stage stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list].freeze
def execute(build)
- reprocess(build).tap do |new_build|
+ reprocess!(build).tap do |new_build|
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build.enqueue!
@@ -17,18 +17,25 @@ module Ci
end
end
- def reprocess(build)
+ def reprocess!(build)
unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError
end
attributes = CLONE_ACCESSORS.map do |attribute|
- [attribute, build.send(attribute)]
+ [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end
attributes.push([:user, current_user])
- project.builds.create(Hash[attributes])
+ Ci::Build.transaction do
+ # mark all other builds of that name as retried
+ build.pipeline.builds.latest
+ .where(name: build.name)
+ .update_all(retried: true)
+
+ project.builds.create!(Hash[attributes])
+ end
end
end
end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index ecc6173a96a..c5a43869990 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -8,8 +8,10 @@ module Ci
end
pipeline.retryable_builds.find_each do |build|
+ next unless can?(current_user, :update_build, build)
+
Ci::RetryBuildService.new(project, current_user)
- .reprocess(build)
+ .reprocess!(build)
end
pipeline.builds.latest.skipped.find_each do |skipped|
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb
index 42c72aba7dd..43c9a065fcf 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/ci/stop_environments_service.rb
@@ -5,10 +5,11 @@ module Ci
def execute(branch_name)
@ref = branch_name
- return unless has_ref?
+ return unless @ref.present?
environments.each do |environment|
- next unless can?(current_user, :create_deployment, project)
+ next unless environment.stop_action?
+ next unless can?(current_user, :stop_environment, environment)
environment.stop_with_action!(current_user)
end
@@ -16,13 +17,10 @@ module Ci
private
- def has_ref?
- @ref.present?
- end
-
def environments
- @environments ||=
- EnvironmentsFinder.new(project, current_user, ref: @ref, recently_updated: true).execute
+ @environments ||= EnvironmentsFinder
+ .new(project, current_user, ref: @ref, recently_updated: true)
+ .execute
end
end
end
diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb
index a48d6a976f0..85c2fcf9ea6 100644
--- a/app/services/commits/change_service.rb
+++ b/app/services/commits/change_service.rb
@@ -11,6 +11,7 @@ module Commits
def commit_change(action)
raise NotImplementedError unless repository.respond_to?(action)
+ # rubocop:disable GitlabSecurity/PublicSend
repository.public_send(
action,
current_user,
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index ab4c02a97a0..a5ae4927412 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -17,18 +17,18 @@ class CompareService
start_branch_name) do |commit|
break unless commit
- compare(commit.sha, target_project, target_branch, straight)
+ compare(commit.sha, target_project, target_branch, straight: straight)
end
end
private
- def compare(source_sha, target_project, target_branch, straight)
+ def compare(source_sha, target_project, target_branch, straight:)
raw_compare = Gitlab::Git::Compare.new(
target_project.repository.raw_repository,
target_branch,
source_sha,
- straight
+ straight: straight
)
Compare.new(raw_compare, target_project, straight: straight)
diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb
index 910a2a15e5d..7d45b4aa26a 100644
--- a/app/services/concerns/issues/resolve_discussions.rb
+++ b/app/services/concerns/issues/resolve_discussions.rb
@@ -10,9 +10,9 @@ module Issues
def merge_request_to_resolve_discussions_of
return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of)
- @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id).
- execute.
- find_by(iid: merge_request_to_resolve_discussions_of_iid)
+ @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id)
+ .execute
+ .find_by(iid: merge_request_to_resolve_discussions_of_iid)
end
def discussions_to_resolve
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 47f9b2c621c..63b85c3de7d 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -1,71 +1,71 @@
-class CreateDeploymentService < BaseService
- def execute(deployable = nil)
+class CreateDeploymentService
+ attr_reader :job
+
+ delegate :expanded_environment_name,
+ :variables,
+ :project,
+ to: :job
+
+ def initialize(job)
+ @job = job
+ end
+
+ def execute
return unless executable?
ActiveRecord::Base.transaction do
- @deployable = deployable
-
- @environment = environment
- @environment.external_url = expanded_url if expanded_url
- @environment.fire_state_event(action)
+ environment.external_url = expanded_environment_url if
+ expanded_environment_url
+ environment.fire_state_event(action)
- return unless @environment.save
- return if @environment.stopped?
+ return unless environment.save
+ return if environment.stopped?
- deploy.tap do |deployment|
- deployment.update_merge_request_metrics!
- end
+ deploy.tap(&:update_merge_request_metrics!)
end
end
private
def executable?
- project && name.present?
+ project && job.environment.present? && environment
end
def deploy
project.deployments.create(
- environment: @environment,
- ref: params[:ref],
- tag: params[:tag],
- sha: params[:sha],
- user: current_user,
- deployable: @deployable,
- on_stop: options[:on_stop])
+ environment: environment,
+ ref: job.ref,
+ tag: job.tag,
+ sha: job.sha,
+ user: job.user,
+ deployable: job,
+ on_stop: on_stop)
end
def environment
- @environment ||= project.environments.find_or_create_by(name: expanded_name)
+ @environment ||= job.persisted_environment
end
- def expanded_name
- ExpandVariables.expand(name, variables)
+ def environment_options
+ @environment_options ||= job.options&.dig(:environment) || {}
end
- def expanded_url
- return unless url
-
- @expanded_url ||= ExpandVariables.expand(url, variables)
- end
-
- def name
- params[:environment]
- end
+ def expanded_environment_url
+ return @expanded_environment_url if defined?(@expanded_environment_url)
- def url
- options[:url]
+ @expanded_environment_url =
+ ExpandVariables.expand(environment_url, variables) if environment_url
end
- def options
- params[:options] || {}
+ def environment_url
+ environment_options[:url]
end
- def variables
- params[:variables] || []
+ def on_stop
+ environment_options[:on_stop]
end
def action
- options[:action] || 'start'
+ environment_options[:action] || 'start'
end
end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 38a113caec7..64b3c0118fb 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -3,22 +3,14 @@ class DeleteBranchService < BaseService
repository = project.repository
branch = repository.find_branch(branch_name)
- unless branch
- return error('No such branch', 404)
- end
-
- if branch_name == repository.root_ref
- return error('Cannot remove HEAD branch', 405)
- end
-
- if ProtectedBranch.protected?(project, branch_name)
- return error('Protected branch cant be removed', 405)
- end
-
unless current_user.can?(:push_code, project)
return error('You dont have push access to repo', 405)
end
+ unless branch
+ return error('No such branch', 404)
+ end
+
if repository.rm_branch(current_user, branch_name)
success('Branch was removed')
else
diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
index 3b611588466..ff11bd59d29 100644
--- a/app/services/delete_merged_branches_service.rb
+++ b/app/services/delete_merged_branches_service.rb
@@ -10,6 +10,8 @@ class DeleteMergedBranchesService < BaseService
branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) }
# Prevent deletion of branches relevant to open merge requests
branches -= merge_request_branch_names
+ # Prevent deletion of protected branches
+ branches = branches.reject { |branch| project.protected_for?(branch) }
branches.each do |branch|
DeleteBranchService.new(project, current_user).execute(branch)
diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb
new file mode 100644
index 00000000000..1ef8d9edbe1
--- /dev/null
+++ b/app/services/discussions/update_diff_position_service.rb
@@ -0,0 +1,41 @@
+module Discussions
+ class UpdateDiffPositionService < BaseService
+ def execute(discussion)
+ result = tracer.trace(discussion.position)
+ return unless result
+
+ position = result[:position]
+ outdated = result[:outdated]
+
+ discussion.notes.each do |note|
+ if outdated
+ note.change_position = position
+ else
+ note.position = position
+ note.change_position = nil
+ end
+ end
+
+ Note.transaction do
+ discussion.notes.each do |note|
+ Gitlab::Timeless.timeless(note, &:save)
+ end
+
+ if outdated && current_user
+ SystemNoteService.diff_discussion_outdated(discussion, project, current_user, position)
+ end
+ end
+ end
+
+ private
+
+ def tracer
+ @tracer ||= Gitlab::Diff::PositionTracer.new(
+ project: project,
+ old_diff_refs: params[:old_diff_refs],
+ new_diff_refs: params[:new_diff_refs],
+ paths: params[:paths]
+ )
+ end
+ end
+end
diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb
new file mode 100644
index 00000000000..ace49889097
--- /dev/null
+++ b/app/services/emails/base_service.rb
@@ -0,0 +1,8 @@
+module Emails
+ class BaseService
+ def initialize(user, opts)
+ @user = user
+ @email = opts[:email]
+ end
+ end
+end
diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb
new file mode 100644
index 00000000000..b6491ee9804
--- /dev/null
+++ b/app/services/emails/create_service.rb
@@ -0,0 +1,7 @@
+module Emails
+ class CreateService < ::Emails::BaseService
+ def execute
+ @user.emails.create(email: @email)
+ end
+ end
+end
diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb
new file mode 100644
index 00000000000..d586b9dfe0c
--- /dev/null
+++ b/app/services/emails/destroy_service.rb
@@ -0,0 +1,17 @@
+module Emails
+ class DestroyService < ::Emails::BaseService
+ def execute
+ Email.find_by_email!(@email).destroy && update_secondary_emails!
+ end
+
+ private
+
+ def update_secondary_emails!
+ result = ::Users::UpdateService.new(@user).execute do |user|
+ user.update_secondary_emails!
+ end
+
+ result[:status] == 'success'
+ end
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 0f3a485a3fd..0b7e4f187f7 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -71,7 +71,14 @@ class EventCreateService
end
def push(project, current_user, push_data)
- create_event(project, current_user, Event::PUSHED, data: push_data)
+ # We're using an explicit transaction here so that any errors that may occur
+ # when creating push payload data will result in the event creation being
+ # rolled back as well.
+ Event.transaction do
+ event = create_event(project, current_user, Event::PUSHED)
+
+ PushEventPayloadService.new(event, push_data).execute
+ end
Users::ActivityService.new(current_user, 'push').execute
end
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index f23a9f6d57c..bcca1386bed 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -28,8 +28,8 @@ module Files
end
def last_commit
- @last_commit ||= Gitlab::Git::Commit.
- last_for_path(@start_project.repository, @start_branch, @file_path)
+ @last_commit ||= Gitlab::Git::Commit
+ .last_for_path(@start_project.repository, @start_branch, @file_path)
end
def validate!
diff --git a/app/services/git_hooks_service.rb b/app/services/git_hooks_service.rb
index d222d1e63aa..eab65d09299 100644
--- a/app/services/git_hooks_service.rb
+++ b/app/services/git_hooks_service.rb
@@ -3,8 +3,8 @@ class GitHooksService
attr_accessor :oldrev, :newrev, :ref
- def execute(user, repo_path, oldrev, newrev, ref)
- @repo_path = repo_path
+ def execute(user, project, oldrev, newrev, ref)
+ @project = project
@user = Gitlab::GlId.gl_id(user)
@oldrev = oldrev
@newrev = newrev
@@ -26,7 +26,7 @@ class GitHooksService
private
def run_hook(name)
- hook = Gitlab::Git::Hook.new(name, @repo_path)
+ hook = Gitlab::Git::Hook.new(name, @project)
hook.trigger(@user, oldrev, newrev, ref)
end
end
diff --git a/app/services/git_operation_service.rb b/app/services/git_operation_service.rb
index ed6ea638235..545ca0742e4 100644
--- a/app/services/git_operation_service.rb
+++ b/app/services/git_operation_service.rb
@@ -60,7 +60,7 @@ class GitOperationService
start_branch_name = nil if start_repository.empty_repo?
if start_branch_name && !start_repository.branch_exists?(start_branch_name)
- raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.path_with_namespace}"
+ raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.full_path}"
end
update_branch_with_hooks(branch_name) do
@@ -120,7 +120,7 @@ class GitOperationService
def with_hooks(ref, newrev, oldrev)
GitHooksService.new.execute(
user,
- repository.path_to_repo,
+ repository.project,
oldrev,
newrev,
ref) do |service|
@@ -129,6 +129,7 @@ class GitOperationService
end
end
+ # Gitaly note: JV: wait with migrating #update_ref until we know how to migrate its call sites.
def update_ref(ref, newrev, oldrev)
# We use 'git update-ref' because libgit2/rugged currently does not
# offer 'compare and swap' ref updates. Without compare-and-swap we can
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 45411c779cc..e81a56672e2 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -45,6 +45,7 @@ class GitPushService < BaseService
elsif push_to_existing_branch?
# Collect data for this git push
@push_commits = @project.repository.commits_between(params[:oldrev], params[:newrev])
+
process_commit_messages
# Update the bare repositories info/attributes file using the contents of the default branches
@@ -56,6 +57,8 @@ class GitPushService < BaseService
perform_housekeeping
update_caches
+
+ update_signatures
end
def update_gitattributes
@@ -64,15 +67,21 @@ class GitPushService < BaseService
def update_caches
if is_default_branch?
- paths = Set.new
+ if push_to_new_branch?
+ # If this is the initial push into the default branch, the file type caches
+ # will already be reset as a result of `Project#change_head`.
+ types = []
+ else
+ paths = Set.new
- @push_commits.each do |commit|
- commit.raw_diffs(deltas_only: true).each do |diff|
- paths << diff.new_path
+ @push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
+ commit.raw_deltas.each do |diff|
+ paths << diff.new_path
+ end
end
- end
- types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ end
else
types = []
end
@@ -80,13 +89,32 @@ class GitPushService < BaseService
ProjectCacheWorker.perform_async(@project.id, types, [:commit_count, :repository_size])
end
+ def update_signatures
+ commit_shas = @push_commits.last(PROCESS_COMMIT_LIMIT).map(&:sha)
+
+ return if commit_shas.empty?
+
+ shas_with_cached_signatures = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
+ commit_shas -= shas_with_cached_signatures
+
+ return if commit_shas.empty?
+
+ commit_shas = Gitlab::Git::Commit.shas_with_signatures(project.repository, commit_shas)
+
+ commit_shas.each do |sha|
+ CreateGpgSignatureWorker.perform_async(sha, project.id)
+ end
+ end
+
# Schedules processing of commit messages.
def process_commit_messages
default = is_default_branch?
- push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
- ProcessCommitWorker.
- perform_async(project.id, current_user.id, commit.to_hash, default)
+ @push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
+ if commit.matches_cross_reference_regex?
+ ProcessCommitWorker
+ .perform_async(project.id, current_user.id, commit.to_hash, default)
+ end
end
end
@@ -99,12 +127,12 @@ class GitPushService < BaseService
UpdateMergeRequestsWorker
.perform_async(@project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref])
- SystemHookPushWorker.perform_async(build_push_data.dup, :push_hooks)
-
EventCreateService.new.push(@project, current_user, build_push_data)
+ Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute(:push)
+
+ SystemHookPushWorker.perform_async(build_push_data.dup, :push_hooks)
@project.execute_hooks(build_push_data.dup, :push_hooks)
@project.execute_services(build_push_data.dup, :push_hooks)
- Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute
if push_remove_branch?
AfterBranchDeleteService
@@ -121,7 +149,10 @@ class GitPushService < BaseService
end
def process_default_branch
- @push_commits = project.repository.commits(params[:newrev])
+ @push_commits_count = project.repository.commit_count_for_ref(params[:ref])
+
+ offset = [@push_commits_count - PROCESS_COMMIT_LIMIT, 0].max
+ @push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
# Ensure HEAD points to the default branch in case it is not master
project.change_head(branch_name)
@@ -150,7 +181,8 @@ class GitPushService < BaseService
params[:oldrev],
params[:newrev],
params[:ref],
- push_commits)
+ @push_commits,
+ commits_count: @push_commits_count)
end
def push_to_existing_branch?
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
index 96432837481..9917a39b795 100644
--- a/app/services/git_tag_push_service.rb
+++ b/app/services/git_tag_push_service.rb
@@ -8,10 +8,12 @@ class GitTagPushService < BaseService
@push_data = build_push_data
EventCreateService.new.push(project, current_user, @push_data)
+ Ci::CreatePipelineService.new(project, current_user, @push_data).execute(:push)
+
SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks)
project.execute_hooks(@push_data.dup, :tag_push_hooks)
project.execute_services(@push_data.dup, :tag_push_hooks)
- Ci::CreatePipelineService.new(project, current_user, @push_data).execute
+
ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size])
true
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index 433ecc2df32..e77e08aa380 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -1,15 +1,20 @@
class GravatarService
include Gitlab::CurrentSettings
- def execute(email, size = nil, scale = 2)
- if current_application_settings.gravatar_enabled? && email.present?
- size = 40 if size.nil? || size <= 0
+ def execute(email, size = nil, scale = 2, username: nil)
+ return unless current_application_settings.gravatar_enabled?
- sprintf gravatar_url,
- hash: Digest::MD5.hexdigest(email.strip.downcase),
- size: size * scale,
- email: email.strip
- end
+ identifier = email.presence || username.presence
+ return unless identifier
+
+ hash = Digest::MD5.hexdigest(identifier.strip.downcase)
+ size = 40 unless size && size > 0
+
+ sprintf gravatar_url,
+ hash: hash,
+ size: size * scale,
+ email: ERB::Util.url_encode(email&.strip || ''),
+ username: ERB::Util.url_encode(username&.strip || '')
end
def gitlab_config
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 497fdb09cdc..f565612a89d 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -1,8 +1,7 @@
module Groups
class DestroyService < Groups::BaseService
def async_execute
- # Soft delete via paranoia gem
- group.destroy
+ group.soft_delete_without_removing_associations
job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
end
@@ -10,7 +9,7 @@ module Groups
def execute
group.prepare_for_destroy
- group.projects.with_deleted.each do |project|
+ group.projects.each do |project|
# Execute the destruction of the models immediately to ensure atomic cleanup.
# Skip repository removal because we remove directory with namespace
# that contain all these repositories
@@ -22,6 +21,8 @@ module Groups
DestroyService.new(group, current_user).execute
end
+ group.chat_team&.remove_mattermost_team(current_user)
+
group.really_destroy!
end
end
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 60891cbb255..5d42a89fced 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -7,10 +7,14 @@ module Issuable
ids = params.delete(:issuable_ids).split(",")
items = model_class.where(id: ids)
- %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key|
+ permitted_attrs(type).each do |key|
params.delete(key) unless params[key].present?
end
+ if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
+ params[:assignee_ids] = []
+ end
+
items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable)
@@ -22,5 +26,17 @@ module Issuable
success: !items.count.zero?
}
end
+
+ private
+
+ def permitted_attrs(type)
+ attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event)
+
+ if type == 'issue'
+ attrs.push(:assignee_ids)
+ else
+ attrs.push(:assignee_id)
+ end
+ end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index b071a398481..4a4f2b91182 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -1,11 +1,6 @@
class IssuableBaseService < BaseService
private
- def create_assignee_note(issuable)
- SystemNoteService.change_assignee(
- issuable, issuable.project, current_user, issuable.assignee)
- end
-
def create_milestone_note(issuable)
SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone)
@@ -24,6 +19,10 @@ class IssuableBaseService < BaseService
issuable, issuable.project, current_user, old_title)
end
+ def create_description_change_note(issuable)
+ SystemNoteService.change_description(issuable, issuable.project, current_user)
+ end
+
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
@@ -53,8 +52,10 @@ class IssuableBaseService < BaseService
params.delete(:add_label_ids)
params.delete(:remove_label_ids)
params.delete(:label_ids)
+ params.delete(:assignee_ids)
params.delete(:assignee_id)
params.delete(:due_date)
+ params.delete(:canonical_issue_id)
end
filter_assignee(issuable)
@@ -77,7 +78,7 @@ class IssuableBaseService < BaseService
def assignee_can_read?(issuable, assignee_id)
new_assignee = User.find_by_id(assignee_id)
- return false unless new_assignee.present?
+ return false unless new_assignee
ability_name = :"read_#{issuable.to_ability_name}"
resource = issuable.persisted? ? issuable : project
@@ -89,10 +90,12 @@ class IssuableBaseService < BaseService
milestone_id = params[:milestone_id]
return unless milestone_id
- if milestone_id == IssuableFinder::NONE ||
- project.milestones.find_by(id: milestone_id).nil?
- params[:milestone_id] = ''
- end
+ params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
+
+ milestone =
+ Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
+
+ params[:milestone_id] = '' unless milestone
end
def filter_labels
@@ -142,13 +145,13 @@ class IssuableBaseService < BaseService
LabelsFinder.new(current_user, project_id: @project.id).execute
end
- def merge_slash_commands_into_params!(issuable)
+ def merge_quick_actions_into_params!(issuable)
description, command_params =
- SlashCommands::InterpretService.new(project, current_user).
- execute(params[:description], issuable)
+ QuickActions::InterpretService.new(project, current_user)
+ .execute(params[:description], issuable)
# Avoid a description already set on an issuable to be overwritten by a nil
- params[:description] = description if params.has_key?(:description)
+ params[:description] = description if params.key?(:description)
params.merge!(command_params)
end
@@ -162,7 +165,7 @@ class IssuableBaseService < BaseService
end
def create(issuable)
- merge_slash_commands_into_params!(issuable)
+ merge_quick_actions_into_params!(issuable)
filter_params(issuable)
params.delete(:state_event)
@@ -176,8 +179,8 @@ class IssuableBaseService < BaseService
if params.present? && create_issuable(issuable, params, label_ids: label_ids)
after_create(issuable)
- issuable.create_cross_references!(current_user)
execute_hooks(issuable)
+ invalidate_cache_counts(issuable, users: issuable.assignees)
end
issuable
@@ -207,6 +210,7 @@ class IssuableBaseService < BaseService
filter_params(issuable)
old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a
+ old_assignees = issuable.assignees.to_a
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
@@ -214,6 +218,10 @@ class IssuableBaseService < BaseService
if issuable.changed? || params.present?
issuable.assign_attributes(params.merge(updated_by: current_user))
+ if has_title_or_description_changed?(issuable)
+ issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user)
+ end
+
before_update(issuable)
if issuable.with_transaction_returning_status { issuable.save }
@@ -222,7 +230,19 @@ class IssuableBaseService < BaseService
handle_common_system_notes(issuable, old_labels: old_labels)
end
- handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users)
+ handle_changes(
+ issuable,
+ old_labels: old_labels,
+ old_mentioned_users: old_mentioned_users,
+ old_assignees: old_assignees
+ )
+
+ new_assignees = issuable.assignees.to_a
+ affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
+
+ # Don't clear the project cache, because it will be handled by the
+ # appropriate service (close / reopen / merge / etc.).
+ invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true)
after_update(issuable)
issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update')
@@ -236,6 +256,10 @@ class IssuableBaseService < BaseService
old_label_ids.sort != new_label_ids.sort
end
+ def has_title_or_description_changed?(issuable)
+ issuable.title_changed? || issuable.description_changed?
+ end
+
def change_state(issuable)
case params.delete(:state_event)
when 'reopen'
@@ -260,7 +284,7 @@ class IssuableBaseService < BaseService
todo_service.mark_todo(issuable, current_user)
when 'done'
todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
- todo_service.mark_todos_as_done([todo], current_user) if todo
+ todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
end
end
@@ -272,7 +296,7 @@ class IssuableBaseService < BaseService
end
end
- def has_changes?(issuable, old_labels: [])
+ def has_changes?(issuable, old_labels: [], old_assignees: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr|
@@ -281,7 +305,9 @@ class IssuableBaseService < BaseService
labels_changed = issuable.labels != old_labels
- attrs_changed || labels_changed
+ assignees_changed = issuable.assignees != old_assignees
+
+ attrs_changed || labels_changed || assignees_changed
end
def handle_common_system_notes(issuable, old_labels: [])
@@ -289,8 +315,14 @@ class IssuableBaseService < BaseService
create_title_change_note(issuable, issuable.previous_changes['title'].first)
end
- if issuable.previous_changes.include?('description') && issuable.tasks?
- create_task_status_note(issuable)
+ if issuable.previous_changes.include?('description')
+ if issuable.tasks? && issuable.updated_tasks.any?
+ create_task_status_note(issuable)
+ else
+ # TODO: Show this note if non-task content was modified.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/33577
+ create_description_change_note(issuable)
+ end
end
if issuable.previous_changes.include?('time_estimate')
@@ -303,4 +335,19 @@ class IssuableBaseService < BaseService
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
+
+ def invalidate_cache_counts(issuable, users: [], skip_project_cache: false)
+ users.each do |user|
+ user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ unless skip_project_cache
+ case issuable
+ when Issue
+ IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches!
+ when MergeRequest
+ MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches!
+ end
+ end
+ end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index ee1b40db718..4c198fc96ea 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -7,13 +7,43 @@ module Issues
issue_data
end
+ def reopen_service
+ Issues::ReopenService
+ end
+
+ def close_service
+ Issues::CloseService
+ end
+
private
+ def create_assignee_note(issue, old_assignees)
+ SystemNoteService.change_issue_assignees(
+ issue, issue.project, current_user, old_assignees)
+ end
+
def execute_hooks(issue, action = 'open')
issue_data = hook_data(issue, action)
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope)
end
+
+ def filter_assignee(issuable)
+ return if params[:assignee_ids].blank?
+
+ # The number of assignees is limited by one for GitLab CE
+ params[:assignee_ids] = params[:assignee_ids][0, 1]
+
+ assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
+
+ if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
+ params[:assignee_ids] = []
+ elsif assignee_ids.any?
+ params[:assignee_ids] = assignee_ids
+ else
+ params.delete(:assignee_ids)
+ end
+ end
end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index f1030912c68..74459c3342c 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -16,18 +16,19 @@ module Issues
# The code calling this method is responsible for ensuring that a user is
# allowed to close the given issue.
def close_issue(issue, commit: nil, notifications: true, system_note: true)
- if project.jira_tracker? && project.jira_service.active
+ if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue)
project.jira_service.close_issue(commit, issue)
todo_service.close_issue(issue, current_user)
return issue
end
- if project.default_issues_tracker? && issue.close
+ if project.issues_enabled? && issue.close
event_service.close_issue(issue, current_user)
create_note(issue, commit) if system_note
notification_service.close_issue(issue, current_user) if notifications
todo_service.close_issue(issue, current_user)
execute_hooks(issue, 'close')
+ invalidate_cache_counts(issue, users: issue.assignees)
end
issue
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 3cf4b82b9f2..234fcbede03 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -15,11 +15,15 @@ module Issues
def before_create(issue)
spam_check(issue, current_user)
issue.move_to_end
+
+ # current_user (defined in BaseService) is not available within run_after_commit block
+ user = current_user
+ issue.run_after_commit do
+ NewIssueWorker.perform_async(issue.id, user.id)
+ end
end
def after_create(issuable)
- event_service.open_issue(issuable, current_user)
- notification_service.new_issue(issuable, current_user)
todo_service.new_issue(issuable, current_user)
user_agent_detail_service.create
resolve_discussions_with_issue(issuable)
@@ -30,8 +34,8 @@ module Issues
Discussions::ResolveService.new(project, current_user,
merge_request: merge_request_to_resolve_discussions_of,
- follow_up_issue: issue).
- execute(discussions_to_resolve)
+ follow_up_issue: issue)
+ .execute(discussions_to_resolve)
end
private
diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb
new file mode 100644
index 00000000000..5c0854e664d
--- /dev/null
+++ b/app/services/issues/duplicate_service.rb
@@ -0,0 +1,24 @@
+module Issues
+ class DuplicateService < Issues::BaseService
+ def execute(duplicate_issue, canonical_issue)
+ return if canonical_issue == duplicate_issue
+ return unless can?(current_user, :update_issue, duplicate_issue)
+ return unless can?(current_user, :create_note, canonical_issue)
+
+ create_issue_duplicate_note(duplicate_issue, canonical_issue)
+ create_issue_canonical_note(canonical_issue, duplicate_issue)
+
+ close_service.new(project, current_user, {}).execute(duplicate_issue)
+ end
+
+ private
+
+ def create_issue_duplicate_note(duplicate_issue, canonical_issue)
+ SystemNoteService.mark_duplicate_issue(duplicate_issue, duplicate_issue.project, current_user, canonical_issue)
+ end
+
+ def create_issue_canonical_note(canonical_issue, duplicate_issue)
+ SystemNoteService.mark_canonical_issue_of_duplicate(canonical_issue, canonical_issue.project, current_user, duplicate_issue)
+ end
+ end
+end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 711f4035c55..29def25719d 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -61,8 +61,18 @@ module Issues
end
def cloneable_milestone_id
- @new_project.milestones
- .find_by(title: @old_issue.milestone.try(:title)).try(:id)
+ title = @old_issue.milestone&.title
+ return unless title
+
+ if @new_project.group && can?(current_user, :read_group, @new_project.group)
+ group_id = @new_project.group.id
+ end
+
+ params =
+ { title: title, project_ids: @new_project.id, group_ids: group_id }
+
+ milestones = MilestonesFinder.new(params).execute
+ milestones.first&.id
end
def rewrite_notes
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 40fbe354492..35de4337b15 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -5,9 +5,10 @@ module Issues
if issue.reopen
event_service.reopen_issue(issue, current_user)
- create_note(issue)
+ create_note(issue, 'reopened')
notification_service.reopen_issue(issue, current_user)
execute_hooks(issue, 'reopen')
+ invalidate_cache_counts(issue, users: issue.assignees)
end
issue
@@ -15,8 +16,8 @@ module Issues
private
- def create_note(issue)
- SystemNoteService.change_status(issue, issue.project, current_user, issue.state, nil)
+ def create_note(issue, state = issue.state)
+ SystemNoteService.change_status(issue, issue.project, current_user, state, nil)
end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b7fe5cb168b..8d918ccc635 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -5,6 +5,7 @@ module Issues
def execute(issue)
handle_move_between_iids(issue)
filter_spam_check_params
+ change_issue_duplicate(issue)
update(issue)
end
@@ -12,8 +13,12 @@ module Issues
spam_check(issue, current_user)
end
- def handle_changes(issue, old_labels: [], old_mentioned_users: [])
- if has_changes?(issue, old_labels: old_labels)
+ def handle_changes(issue, options)
+ old_labels = options[:old_labels] || []
+ old_mentioned_users = options[:old_mentioned_users] || []
+ old_assignees = options[:old_assignees] || []
+
+ if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(issue, current_user)
end
@@ -26,9 +31,9 @@ module Issues
create_milestone_note(issue)
end
- if issue.previous_changes.include?('assignee_id')
- create_assignee_note(issue)
- notification_service.reassigned_issue(issue, current_user)
+ if issue.assignees != old_assignees
+ create_assignee_note(issue, old_assignees)
+ notification_service.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_issue(issue, current_user)
end
@@ -49,14 +54,6 @@ module Issues
end
end
- def reopen_service
- Issues::ReopenService
- end
-
- def close_service
- Issues::CloseService
- end
-
def handle_move_between_iids(issue)
return unless params[:move_between_iids]
@@ -68,6 +65,15 @@ module Issues
issue.move_between(issue_before, issue_after)
end
+ def change_issue_duplicate(issue)
+ canonical_issue_id = params.delete(:canonical_issue_id)
+ canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
+
+ if canonical_issue
+ Issues::DuplicateService.new(project, current_user).execute(issue, canonical_issue)
+ end
+ end
+
private
def get_issue_if_allowed(project, iid)
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index 76d0ba67b07..43b539ded53 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -26,29 +26,29 @@ module Labels
private
def label_ids_for_merge(new_label)
- LabelsFinder.
- new(current_user, title: new_label.title, group_id: project.group.id).
- execute(skip_authorization: true).
- where.not(id: new_label).
- select(:id) # Can't use pluck() to avoid object-creation because of the batching
+ LabelsFinder
+ .new(current_user, title: new_label.title, group_id: project.group.id)
+ .execute(skip_authorization: true)
+ .where.not(id: new_label)
+ .select(:id) # Can't use pluck() to avoid object-creation because of the batching
end
def update_issuables(new_label, label_ids)
- LabelLink.
- where(label: label_ids).
- update_all(label_id: new_label)
+ LabelLink
+ .where(label: label_ids)
+ .update_all(label_id: new_label)
end
def update_issue_board_lists(new_label, label_ids)
- List.
- where(label: label_ids).
- update_all(label_id: new_label)
+ List
+ .where(label: label_ids)
+ .update_all(label_id: new_label)
end
def update_priorities(new_label, label_ids)
- LabelPriority.
- where(label: label_ids).
- update_all(label_id: new_label)
+ LabelPriority
+ .where(label: label_ids)
+ .update_all(label_id: new_label)
end
def update_project_labels(label_ids)
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index 514679ed29d..775efed48eb 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -37,20 +37,20 @@ module Labels
union = Gitlab::SQL::Union.new(label_ids)
- Label.where("labels.id IN (#{union.to_sql})").reorder(nil).uniq
+ Label.where("labels.id IN (#{union.to_sql})").reorder(nil).uniq # rubocop:disable GitlabSecurity/SqlInjection
end
def group_labels_applied_to_issues
- Label.joins(:issues).
- where(
+ Label.joins(:issues)
+ .where(
issues: { project_id: project.id },
labels: { type: 'GroupLabel', group_id: old_group.id }
)
end
def group_labels_applied_to_merge_requests
- Label.joins(:merge_requests).
- where(
+ Label.joins(:merge_requests)
+ .where(
merge_requests: { target_project_id: project.id },
labels: { type: 'GroupLabel', group_id: old_group.id }
)
@@ -64,15 +64,15 @@ module Labels
end
def update_label_links(labels, old_label_id:, new_label_id:)
- LabelLink.joins(:label).
- merge(labels).
- where(label_id: old_label_id).
- update_all(label_id: new_label_id)
+ LabelLink.joins(:label)
+ .merge(labels)
+ .where(label_id: old_label_id)
+ .update_all(label_id: new_label_id)
end
def update_label_priorities(old_label_id:, new_label_id:)
- LabelPriority.where(project_id: project.id, label_id: old_label_id).
- update_all(label_id: new_label_id)
+ LabelPriority.where(project_id: project.id, label_id: old_label_id)
+ .update_all(label_id: new_label_id)
end
end
end
diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
index 1711be7211c..de3a252d6c6 100644
--- a/app/services/members/authorized_destroy_service.rb
+++ b/app/services/members/authorized_destroy_service.rb
@@ -10,7 +10,7 @@ module Members
return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
Member.transaction do
- unassign_issues_and_merge_requests(member)
+ unassign_issues_and_merge_requests(member) unless member.invite?
member.destroy
end
@@ -26,18 +26,35 @@ module Members
def unassign_issues_and_merge_requests(member)
if member.is_a?(GroupMember)
- IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
- execute.
- update_all(assignee_id: nil)
- MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
- execute.
- update_all(assignee_id: nil)
+ issues = Issue.unscoped.select(1)
+ .joins(:project)
+ .where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id)
+
+ # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...)
+ IssueAssignee.unscoped
+ .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues)
+ .delete_all
+
+ MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id)
+ .execute
+ .update_all(assignee_id: nil)
else
project = member.source
- project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil)
+
+ # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X
+ issues = Issue.unscoped.select(1)
+ .where('issues.id = issue_assignees.issue_id')
+ .where(project_id: project.id)
+
+ # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...)
+ IssueAssignee.unscoped
+ .where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues)
+ .delete_all
+
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
- member.user.update_cache_counts
end
+
+ member.user.invalidate_cache_counts
end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 3a58f6c065d..26906ae7167 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -1,22 +1,38 @@
module Members
class CreateService < BaseService
+ DEFAULT_LIMIT = 100
+
def initialize(source, current_user, params = {})
@source = source
@current_user = current_user
@params = params
+ @error = nil
end
def execute
- return false if params[:user_ids].blank?
+ return error('No users specified.') if params[:user_ids].blank?
+
+ user_ids = params[:user_ids].split(',').uniq
+
+ return error("Too many users specified (limit is #{user_limit})") if
+ user_limit && user_ids.size > user_limit
@source.add_users(
- params[:user_ids].split(','),
+ user_ids,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
- true
+ success
+ end
+
+ private
+
+ def user_limit
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
+
+ limit && limit < 0 ? nil : limit
end
end
end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 2e089149ca8..46c505baf8b 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -31,7 +31,7 @@ module Members
source.members.find_by(condition) ||
source.requesters.find_by!(condition)
else
- source.public_send(scope).find_by!(condition)
+ source.public_send(scope).find_by!(condition) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb
index 066efa1acc3..8c6c4841020 100644
--- a/app/services/merge_requests/assign_issues_service.rb
+++ b/app/services/merge_requests/assign_issues_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
@assignable_issues ||= begin
if current_user == merge_request.author
closes_issues.select do |issue|
- !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue)
+ !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
end
else
[]
@@ -14,7 +14,7 @@ module MergeRequests
def execute
assignable_issues.each do |issue|
- Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue)
+ Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue)
end
{
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 582d5c47b66..35ccff26262 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -1,7 +1,7 @@
module MergeRequests
class BaseService < ::IssuableBaseService
- def create_note(merge_request)
- SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
+ def create_note(merge_request, state = merge_request.state)
+ SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, state, nil)
end
def create_title_change_note(issuable, old_title)
@@ -38,8 +38,13 @@ module MergeRequests
private
+ def create_assignee_note(merge_request)
+ SystemNoteService.change_assignee(
+ merge_request, merge_request.project, current_user, merge_request.assignee)
+ end
+
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
- def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
+ def merge_requests_for(source_branch, mr_states: [:opened])
MergeRequest
.with_state(mr_states)
.where(source_branch: source_branch, source_project_id: @project.id)
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index d45da5180e1..bc0e7ad4e39 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -28,7 +28,7 @@ module MergeRequests
def find_target_project
return target_project if target_project.present? && can?(current_user, :read_project, target_project)
- project.forked_from_project || project
+ project.default_merge_request_target
end
def find_target_branch
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index f2053bda83a..c0ce01f7523 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -13,6 +13,7 @@ module MergeRequests
notification_service.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
execute_hooks(merge_request, 'close')
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
end
merge_request
diff --git a/app/services/merge_requests/conflicts/base_service.rb b/app/services/merge_requests/conflicts/base_service.rb
new file mode 100644
index 00000000000..b50875347d9
--- /dev/null
+++ b/app/services/merge_requests/conflicts/base_service.rb
@@ -0,0 +1,11 @@
+module MergeRequests
+ module Conflicts
+ class BaseService
+ attr_reader :merge_request
+
+ def initialize(merge_request)
+ @merge_request = merge_request
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
new file mode 100644
index 00000000000..9835606812c
--- /dev/null
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -0,0 +1,36 @@
+module MergeRequests
+ module Conflicts
+ class ListService < MergeRequests::Conflicts::BaseService
+ delegate :file_for_path, :to_json, to: :conflicts
+
+ def can_be_resolved_by?(user)
+ return false unless merge_request.source_project
+
+ access = ::Gitlab::UserAccess.new(user, project: merge_request.source_project)
+ access.can_push_to_branch?(merge_request.source_branch)
+ end
+
+ def can_be_resolved_in_ui?
+ return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
+
+ return @conflicts_can_be_resolved_in_ui = false unless merge_request.cannot_be_merged?
+ return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs?
+ return @conflicts_can_be_resolved_in_ui = false if merge_request.branch_missing?
+
+ begin
+ # Try to parse each conflict. If the MR's mergeable status hasn't been
+ # updated, ensure that we don't say there are conflicts to resolve
+ # when there are no conflict files.
+ conflicts.files.each(&:lines)
+ @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
+ rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+ @conflicts_can_be_resolved_in_ui = false
+ end
+ end
+
+ def conflicts
+ @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb
new file mode 100644
index 00000000000..6b6e231f4f9
--- /dev/null
+++ b/app/services/merge_requests/conflicts/resolve_service.rb
@@ -0,0 +1,55 @@
+module MergeRequests
+ module Conflicts
+ class ResolveService < MergeRequests::Conflicts::BaseService
+ MissingFiles = Class.new(Gitlab::Conflict::ResolutionError)
+
+ def execute(current_user, params)
+ rugged = merge_request.source_project.repository.rugged
+
+ Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution|
+ merge_index = conflicts_for_resolution.merge_index
+
+ params[:files].each do |file_params|
+ conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path])
+
+ write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params)
+ end
+
+ unless merge_index.conflicts.empty?
+ missing_files = merge_index.conflicts.map { |file| file[:ours][:path] }
+
+ raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}"
+ end
+
+ commit_params = {
+ message: params[:commit_message] || conflicts_for_resolution.default_commit_message,
+ parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid),
+ tree: merge_index.write_tree(rugged)
+ }
+
+ conflicts_for_resolution
+ .project
+ .repository
+ .resolve_conflicts(current_user, merge_request.source_branch, commit_params)
+ end
+ end
+
+ private
+
+ def write_resolved_file_to_index(merge_index, rugged, file, params)
+ if params[:sections]
+ new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n")
+
+ new_file << "\n" if file.our_blob.data.ends_with?("\n")
+ elsif params[:content]
+ new_file = file.resolve_content(params[:content])
+ end
+
+ our_path = file.our_path
+
+ merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
+ merge_index.conflict_remove(our_path)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
new file mode 100644
index 00000000000..738cedbaed7
--- /dev/null
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -0,0 +1,54 @@
+module MergeRequests
+ class CreateFromIssueService < MergeRequests::CreateService
+ def execute
+ return error('Invalid issue iid') unless issue_iid.present? && issue.present?
+
+ result = CreateBranchService.new(project, current_user).execute(branch_name, ref)
+ return result if result[:status] == :error
+
+ SystemNoteService.new_issue_branch(issue, project, current_user, branch_name)
+
+ new_merge_request = create(merge_request)
+
+ if new_merge_request.valid?
+ success(new_merge_request)
+ else
+ error(new_merge_request.errors)
+ end
+ end
+
+ private
+
+ def issue_iid
+ @isssue_iid ||= params.delete(:issue_iid)
+ end
+
+ def issue
+ @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
+ end
+
+ def branch_name
+ @branch_name ||= issue.to_branch_name
+ end
+
+ def ref
+ project.default_branch || 'master'
+ end
+
+ def merge_request
+ MergeRequests::BuildService.new(project, current_user, merge_request_params).execute
+ end
+
+ def merge_request_params
+ {
+ source_project_id: project.id,
+ source_branch: branch_name,
+ target_project_id: project.id
+ }
+ end
+
+ def success(merge_request)
+ super().merge(merge_request: merge_request)
+ end
+ end
+end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index b0ae2dfe4ce..194413bf321 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -7,20 +7,45 @@ module MergeRequests
source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id]
- params[:target_project_id] ||= source_project.id
-
merge_request = MergeRequest.new
+ merge_request.target_project = @project
merge_request.source_project = source_project
+ merge_request.source_branch = params[:source_branch]
merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
create(merge_request)
end
+ def before_create(merge_request)
+ # current_user (defined in BaseService) is not available within run_after_commit block
+ user = current_user
+ merge_request.run_after_commit do
+ NewMergeRequestWorker.perform_async(merge_request.id, user.id)
+ end
+ end
+
def after_create(issuable)
- event_service.open_mr(issuable, current_user)
- notification_service.new_merge_request(issuable, current_user)
todo_service.new_merge_request(issuable, current_user)
issuable.cache_merge_request_closes_issues!(current_user)
+ update_merge_requests_head_pipeline(issuable)
+ end
+
+ private
+
+ def update_merge_requests_head_pipeline(merge_request)
+ pipeline = head_pipeline_for(merge_request)
+ merge_request.update(head_pipeline_id: pipeline.id) if pipeline
+ end
+
+ def head_pipeline_for(merge_request)
+ return unless merge_request.source_project
+
+ sha = merge_request.source_branch_sha
+ return unless sha
+
+ pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha)
+
+ pipelines.order(id: :desc).first
end
end
end
diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb
index f00a33969a8..668a1741736 100644
--- a/app/services/merge_requests/get_urls_service.rb
+++ b/app/services/merge_requests/get_urls_service.rb
@@ -49,13 +49,13 @@ module MergeRequests
def url_for_new_merge_request(branch_name)
merge_request_params = { source_branch: branch_name }
- url = Gitlab::Routing.url_helpers.new_namespace_project_merge_request_url(project.namespace, project, merge_request: merge_request_params)
+ url = Gitlab::Routing.url_helpers.project_new_merge_request_url(project, merge_request: merge_request_params)
{ branch_name: branch_name, url: url, new_merge_request: true }
end
def url_for_existing_merge_request(merge_request)
target_project = merge_request.target_project
- url = Gitlab::Routing.url_helpers.namespace_project_merge_request_url(target_project.namespace, target_project, merge_request)
+ url = Gitlab::Routing.url_helpers.project_merge_request_url(target_project, merge_request)
{ branch_name: merge_request.source_branch, url: url, new_merge_request: false }
end
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index fac3ac7a4c7..bc846e07f24 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -61,8 +61,12 @@ module MergeRequests
MergeRequests::PostMergeService.new(project, current_user).execute(merge_request)
if params[:should_remove_source_branch].present? || @merge_request.force_remove_source_branch?
- DeleteBranchService.new(@merge_request.source_project, branch_deletion_user).
- execute(merge_request.source_branch)
+ # Verify again that the source branch can be removed, since branch may be protected,
+ # or the source branch may have been updated.
+ if @merge_request.can_remove_source_branch?(branch_deletion_user)
+ DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
+ .execute(merge_request.source_branch)
+ end
end
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index e8fb1b59752..261a8bfa200 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -13,6 +13,7 @@ module MergeRequests
create_note(merge_request)
notification_service.merge_mr(merge_request, current_user)
execute_hooks(merge_request, 'merge')
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
end
private
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 1131d6f4913..bc4a13cf4bc 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -35,17 +35,18 @@ module MergeRequests
# target branch manually
def close_merge_requests
commit_ids = @commits.map(&:id)
- merge_requests = @project.merge_requests.opened.where(target_branch: @branch_name).to_a
+ merge_requests = @project.merge_requests.preload(:merge_request_diff).opened.where(target_branch: @branch_name).to_a
merge_requests = merge_requests.select(&:diff_head_commit)
merge_requests = merge_requests.select do |merge_request|
- commit_ids.include?(merge_request.diff_head_sha)
+ commit_ids.include?(merge_request.diff_head_sha) &&
+ merge_request.merge_request_diff.state != 'empty'
end
filter_merge_requests(merge_requests).each do |merge_request|
- MergeRequests::PostMergeService.
- new(merge_request.target_project, @current_user).
- execute(merge_request)
+ MergeRequests::PostMergeService
+ .new(merge_request.target_project, @current_user)
+ .execute(merge_request)
end
end
@@ -56,8 +57,8 @@ module MergeRequests
# Refresh merge request diff if we push to source or target branch of merge request
# Note: we should update merge requests from forks too
def reload_merge_requests
- merge_requests = @project.merge_requests.opened.
- by_source_or_target_branch(@branch_name).to_a
+ merge_requests = @project.merge_requests.opened
+ .by_source_or_target_branch(@branch_name).to_a
# Fork merge requests
merge_requests += MergeRequest.opened
@@ -66,12 +67,12 @@ module MergeRequests
filter_merge_requests(merge_requests).each do |merge_request|
if merge_request.source_branch == @branch_name || force_push?
- merge_request.reload_diff
+ merge_request.reload_diff(current_user)
else
- mr_commit_ids = merge_request.commits_sha
+ mr_commit_ids = merge_request.commit_shas
push_commit_ids = @commits.map(&:id)
matches = mr_commit_ids & push_commit_ids
- merge_request.reload_diff if matches.any?
+ merge_request.reload_diff(current_user) if matches.any?
end
merge_request.mark_as_unchecked
@@ -128,7 +129,7 @@ module MergeRequests
return unless @commits.present?
merge_requests_for_source_branch.each do |merge_request|
- mr_commit_ids = Set.new(merge_request.commits_sha)
+ mr_commit_ids = Set.new(merge_request.commit_shas)
new_commits, existing_commits = @commits.partition do |commit|
mr_commit_ids.include?(commit.id)
@@ -144,7 +145,7 @@ module MergeRequests
return unless @commits.present?
merge_requests_for_source_branch.each do |merge_request|
- commit_shas = merge_request.commits_sha
+ commit_shas = merge_request.commit_shas
wip_commit = @commits.detect do |commit|
commit.work_in_progress? && commit_shas.include?(commit.sha)
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index fadcce5d9b6..b9c65be36ec 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -5,11 +5,12 @@ module MergeRequests
if merge_request.reopen
event_service.reopen_mr(merge_request, current_user)
- create_note(merge_request)
+ create_note(merge_request, 'reopened')
notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
- merge_request.reload_diff
+ merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
+ invalidate_cache_counts(merge_request, users: merge_request.assignees)
end
merge_request
diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb
deleted file mode 100644
index 82cd89d9a0b..00000000000
--- a/app/services/merge_requests/resolve_service.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-module MergeRequests
- class ResolveService < MergeRequests::BaseService
- MissingFiles = Class.new(Gitlab::Conflict::ResolutionError)
-
- attr_accessor :conflicts, :rugged, :merge_index, :merge_request
-
- def execute(merge_request)
- @conflicts = merge_request.conflicts
- @rugged = project.repository.rugged
- @merge_index = conflicts.merge_index
- @merge_request = merge_request
-
- fetch_their_commit!
-
- params[:files].each do |file_params|
- conflict_file = merge_request.conflicts.file_for_path(file_params[:old_path], file_params[:new_path])
-
- write_resolved_file_to_index(conflict_file, file_params)
- end
-
- unless merge_index.conflicts.empty?
- missing_files = merge_index.conflicts.map { |file| file[:ours][:path] }
-
- raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}"
- end
-
- commit_params = {
- message: params[:commit_message] || conflicts.default_commit_message,
- parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid),
- tree: merge_index.write_tree(rugged)
- }
-
- project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params)
- end
-
- def write_resolved_file_to_index(file, params)
- new_file = if params[:sections]
- file.resolve_lines(params[:sections]).map(&:text).join("\n")
- elsif params[:content]
- file.resolve_content(params[:content])
- end
-
- our_path = file.our_path
-
- merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
- merge_index.conflict_remove(our_path)
- end
-
- # If their commit (in the target project) doesn't exist in the source project, it
- # can't be a parent for the merge commit we're about to create. If that's the case,
- # fetch the target branch ref into the source project so the commit exists in both.
- #
- def fetch_their_commit!
- return if rugged.include?(conflicts.their_commit.oid)
-
- random_string = SecureRandom.hex
-
- project.repository.fetch_ref(
- merge_request.target_project.repository.path_to_repo,
- "refs/heads/#{merge_request.target_branch}",
- "refs/tmp/#{random_string}/head"
- )
- end
- end
-end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index ab7fcf3b6e2..75a65aecd1a 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -7,7 +7,7 @@ module MergeRequests
params.except!(:target_project_id)
params.except!(:source_branch)
- merge_from_slash_command(merge_request) if params[:merge]
+ merge_from_quick_action(merge_request) if params[:merge]
if merge_request.closed_without_fork?
params.except!(:target_branch, :force_remove_source_branch)
@@ -21,7 +21,10 @@ module MergeRequests
update(merge_request)
end
- def handle_changes(merge_request, old_labels: [], old_mentioned_users: [])
+ def handle_changes(merge_request, options)
+ old_labels = options[:old_labels] || []
+ old_mentioned_users = options[:old_mentioned_users] || []
+
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
@@ -71,9 +74,9 @@ module MergeRequests
end
end
- def merge_from_slash_command(merge_request)
+ def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
- return unless merge_request.mergeable_with_slash_command?(current_user, last_diff_sha: last_diff_sha)
+ return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
merge_request.update(merge_error: nil)
diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb
new file mode 100644
index 00000000000..a02eee4961b
--- /dev/null
+++ b/app/services/metrics_service.rb
@@ -0,0 +1,36 @@
+require 'prometheus/client/formats/text'
+
+class MetricsService
+ CHECKS = [
+ Gitlab::HealthChecks::DbCheck,
+ Gitlab::HealthChecks::Redis::RedisCheck,
+ Gitlab::HealthChecks::Redis::CacheCheck,
+ Gitlab::HealthChecks::Redis::QueuesCheck,
+ Gitlab::HealthChecks::Redis::SharedStateCheck,
+ Gitlab::HealthChecks::FsShardsCheck
+ ].freeze
+
+ def prometheus_metrics_text
+ Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ end
+
+ def health_metrics_text
+ metrics = CHECKS.flat_map(&:metrics)
+
+ formatter.marshal(metrics)
+ end
+
+ def metrics_text
+ "#{health_metrics_text}#{prometheus_metrics_text}"
+ end
+
+ private
+
+ def formatter
+ @formatter ||= Gitlab::HealthChecks::PrometheusTextFormat.new
+ end
+
+ def multiprocess_metrics_path
+ ::Prometheus::Client.configuration.multiprocess_files_dir
+ end
+end
diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb
index 176ab9f1ab5..4963601ea8b 100644
--- a/app/services/milestones/base_service.rb
+++ b/app/services/milestones/base_service.rb
@@ -1,4 +1,10 @@
module Milestones
class BaseService < ::BaseService
+ # Parent can either a group or a project
+ attr_accessor :parent, :current_user, :params
+
+ def initialize(parent, user, params = {})
+ @parent, @current_user, @params = parent, user, params.dup
+ end
end
end
diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb
index 608fc49d766..776ec4b287b 100644
--- a/app/services/milestones/close_service.rb
+++ b/app/services/milestones/close_service.rb
@@ -1,7 +1,7 @@
module Milestones
class CloseService < Milestones::BaseService
def execute(milestone)
- if milestone.close
+ if milestone.close && milestone.is_project_milestone?
event_service.close_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb
index b8e08c9f1eb..aef3124c7e3 100644
--- a/app/services/milestones/create_service.rb
+++ b/app/services/milestones/create_service.rb
@@ -1,9 +1,9 @@
module Milestones
class CreateService < Milestones::BaseService
def execute
- milestone = project.milestones.new(params)
+ milestone = parent.milestones.new(params)
- if milestone.save
+ if milestone.save && milestone.is_project_milestone?
event_service.open_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb
index e457212508f..600ebcfbecb 100644
--- a/app/services/milestones/destroy_service.rb
+++ b/app/services/milestones/destroy_service.rb
@@ -1,15 +1,17 @@
module Milestones
class DestroyService < Milestones::BaseService
def execute(milestone)
+ return unless milestone.is_project_milestone?
+
Milestone.transaction do
update_params = { milestone: nil }
milestone.issues.each do |issue|
- Issues::UpdateService.new(project, current_user, update_params).execute(issue)
+ Issues::UpdateService.new(parent, current_user, update_params).execute(issue)
end
milestone.merge_requests.each do |merge_request|
- MergeRequests::UpdateService.new(project, current_user, update_params).execute(merge_request)
+ MergeRequests::UpdateService.new(parent, current_user, update_params).execute(merge_request)
end
event_service.destroy_milestone(milestone, current_user)
diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb
index 573f9ee5c21..5b8b682caaf 100644
--- a/app/services/milestones/reopen_service.rb
+++ b/app/services/milestones/reopen_service.rb
@@ -1,7 +1,7 @@
module Milestones
class ReopenService < Milestones::BaseService
def execute(milestone)
- if milestone.activate
+ if milestone.activate && milestone.is_project_milestone?
event_service.reopen_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb
index ed64847f429..31b441ed476 100644
--- a/app/services/milestones/update_service.rb
+++ b/app/services/milestones/update_service.rb
@@ -5,9 +5,9 @@ module Milestones
case state
when 'activate'
- Milestones::ReopenService.new(project, current_user, {}).execute(milestone)
+ Milestones::ReopenService.new(parent, current_user, {}).execute(milestone)
when 'close'
- Milestones::CloseService.new(project, current_user, {}).execute(milestone)
+ Milestones::CloseService.new(parent, current_user, {}).execute(milestone)
end
if params.present?
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index ea7cacc956c..abf25bb778b 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -3,8 +3,8 @@ module Notes
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)
+ if in_reply_to_discussion_id.present?
+ discussion = find_discussion(in_reply_to_discussion_id)
unless discussion
note = Note.new
@@ -21,5 +21,19 @@ module Notes
note
end
+
+ def find_discussion(discussion_id)
+ if project
+ project.notes.find_discussion(discussion_id)
+ else
+ # only PersonalSnippets can have discussions without project association
+ discussion = Note.find_discussion(discussion_id)
+ noteable = discussion.noteable
+
+ return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
+
+ discussion
+ end
+ end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index f3954f6f8c4..06971483992 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -9,11 +9,11 @@ module Notes
# We execute commands (extracted from `params[:note]`) on the noteable
# **before** we save the note because if the note consists of commands
# only, there is no need be create a note!
- slash_commands_service = SlashCommandsService.new(project, current_user)
+ quick_actions_service = QuickActionsService.new(project, current_user)
- if slash_commands_service.supported?(note)
+ if quick_actions_service.supported?(note)
options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
- content, command_params = slash_commands_service.extract_commands(note, options)
+ content, command_params = quick_actions_service.extract_commands(note, options)
only_commands = content.empty?
@@ -30,7 +30,7 @@ module Notes
end
if command_params.present?
- slash_commands_service.execute(command_params, note)
+ quick_actions_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset
# when #save is called
diff --git a/app/services/notes/diff_position_update_service.rb b/app/services/notes/diff_position_update_service.rb
deleted file mode 100644
index 0cb731f5bc3..00000000000
--- a/app/services/notes/diff_position_update_service.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-module Notes
- class DiffPositionUpdateService < BaseService
- def execute(note)
- new_position = tracer.trace(note.position)
-
- # Don't update the position if the type doesn't match, since that means
- # the diff line commented on was changed, and the comment is now outdated
- old_position = note.position
- if new_position &&
- new_position != old_position &&
- new_position.type == old_position.type
-
- note.position = new_position
- end
-
- note
- end
-
- private
-
- def tracer
- @tracer ||= Gitlab::Diff::PositionTracer.new(
- repository: project.repository,
- old_diff_refs: params[:old_diff_refs],
- new_diff_refs: params[:new_diff_refs],
- paths: params[:paths]
- )
- end
- end
-end
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
new file mode 100644
index 00000000000..a8d0cc15527
--- /dev/null
+++ b/app/services/notes/quick_actions_service.rb
@@ -0,0 +1,36 @@
+module Notes
+ class QuickActionsService < BaseService
+ UPDATE_SERVICES = {
+ 'Issue' => Issues::UpdateService,
+ 'MergeRequest' => MergeRequests::UpdateService
+ }.freeze
+
+ def self.noteable_update_service(note)
+ UPDATE_SERVICES[note.noteable_type]
+ end
+
+ def self.supported?(note, current_user)
+ noteable_update_service(note) &&
+ current_user &&
+ current_user.can?(:"update_#{note.to_ability_name}", note.noteable)
+ end
+
+ def supported?(note)
+ self.class.supported?(note, current_user)
+ end
+
+ def extract_commands(note, options = {})
+ return [note.note, {}] unless supported?(note)
+
+ QuickActions::InterpretService.new(project, current_user, options)
+ .execute(note.note, note.noteable)
+ end
+
+ def execute(command_params, note)
+ return if command_params.empty?
+ return unless supported?(note)
+
+ self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
+ end
+ end
+end
diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb
deleted file mode 100644
index ad1e6f6774a..00000000000
--- a/app/services/notes/slash_commands_service.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-module Notes
- class SlashCommandsService < BaseService
- UPDATE_SERVICES = {
- 'Issue' => Issues::UpdateService,
- 'MergeRequest' => MergeRequests::UpdateService
- }.freeze
-
- def self.noteable_update_service(note)
- UPDATE_SERVICES[note.noteable_type]
- end
-
- def self.supported?(note, current_user)
- noteable_update_service(note) &&
- current_user &&
- current_user.can?(:"update_#{note.to_ability_name}", note.noteable)
- end
-
- def supported?(note)
- self.class.supported?(note, current_user)
- end
-
- def extract_commands(note, options = {})
- return [note.note, {}] unless supported?(note)
-
- SlashCommands::InterpretService.new(project, current_user, options).
- execute(note.note, note.noteable)
- end
-
- def execute(command_params, note)
- return if command_params.empty?
- return unless supported?(note)
-
- self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
- end
- end
-end
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 8bb995158de..21c9c314a2a 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -1,323 +1,288 @@
#
# Used by NotificationService to determine who should receive notification
#
-class NotificationRecipientService
- attr_reader :project
-
- def initialize(project)
- @project = project
+module NotificationRecipientService
+ def self.notifiable_users(users, *args)
+ users.compact.map { |u| NotificationRecipient.new(u, *args) }.select(&:notifiable?).map(&:user)
end
- def build_recipients(target, current_user, action: nil, previous_assignee: nil, skip_current_user: true)
- custom_action = build_custom_key(action, target)
-
- recipients = target.participants(current_user)
- recipients = add_project_watchers(recipients)
- recipients = add_custom_notifications(recipients, custom_action)
- recipients = reject_mention_users(recipients)
-
- # Re-assign is considered as a mention of the new assignee so we add the
- # new assignee to the list of recipients after we rejected users with
- # the "on mention" notification level
- if [:reassign_merge_request, :reassign_issue].include?(custom_action)
- recipients << previous_assignee if previous_assignee
- recipients << target.assignee
- end
-
- recipients = reject_muted_users(recipients)
- recipients = add_subscribed_users(recipients, target)
-
- if [:new_issue, :new_merge_request].include?(custom_action)
- recipients = add_labels_subscribers(recipients, target)
- end
-
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
-
- recipients.delete(current_user) if skip_current_user && !current_user.notified_of_own_activity?
-
- recipients.uniq
+ def self.notifiable?(user, *args)
+ NotificationRecipient.new(user, *args).notifiable?
end
- def build_pipeline_recipients(target, current_user, action:)
- return [] unless current_user
-
- custom_action =
- case action.to_s
- when 'failed'
- :failed_pipeline
- when 'success'
- :success_pipeline
- end
-
- notification_setting = notification_setting_for_user_project(current_user, target.project)
-
- return [] if notification_setting.mention? || notification_setting.disabled?
-
- return [] if notification_setting.custom? && !notification_setting.public_send(custom_action)
-
- return [] if (notification_setting.watch? || notification_setting.participating?) && NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
-
- reject_users_without_access([current_user], target)
+ def self.build_recipients(*a)
+ Builder::Default.new(*a).recipient_users
end
- def build_relabeled_recipients(target, current_user, labels:)
- recipients = add_labels_subscribers([], target, labels: labels)
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
- recipients.delete(current_user) unless current_user.notified_of_own_activity?
- recipients.uniq
+ def self.build_new_note_recipients(*a)
+ Builder::NewNote.new(*a).recipient_users
end
- def build_new_note_recipients(note)
- target = note.noteable
+ module Builder
+ class Base
+ def initialize(*)
+ raise 'abstract'
+ end
- ability, subject = if note.for_personal_snippet?
- [:read_personal_snippet, note.noteable]
- else
- [:read_project, note.project]
- end
+ def build!
+ raise 'abstract'
+ end
- mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) }
+ def filter!
+ recipients.select!(&:notifiable?)
+ end
- # Add all users participating in the thread (author, assignee, comment authors)
- recipients =
- if target.respond_to?(:participants)
- target.participants(note.author)
- else
- mentioned_users
+ def acting_user
+ current_user
end
- unless note.for_personal_snippet?
- # Merge project watchers
- recipients = add_project_watchers(recipients)
+ def target
+ raise 'abstract'
+ end
- # Merge project with custom notification
- recipients = add_custom_notifications(recipients, :new_note)
- end
+ # rubocop:disable Rails/Delegate
+ def project
+ target.project
+ end
- # Reject users with Mention notification level, except those mentioned in _this_ note.
- recipients = reject_mention_users(recipients - mentioned_users)
- recipients = recipients + mentioned_users
+ def recipients
+ @recipients ||= []
+ end
- recipients = reject_muted_users(recipients)
+ def <<(pair)
+ users, type = pair
- recipients = add_subscribed_users(recipients, note.noteable)
- recipients = reject_unsubscribed_users(recipients, note.noteable)
- recipients = reject_users_without_access(recipients, note.noteable)
+ if users.is_a?(ActiveRecord::Relation)
+ users = users.includes(:notification_settings)
+ end
- recipients.delete(note.author) unless note.author.notified_of_own_activity?
- recipients.uniq
- end
+ users = Array(users)
+ users.compact!
+ recipients.concat(users.map { |u| make_recipient(u, type) })
+ end
- # Remove users with disabled notifications from array
- # Also remove duplications and nil recipients
- def reject_muted_users(users)
- reject_users(users, :disabled)
- end
+ def user_scope
+ User.includes(:notification_settings)
+ end
- protected
+ def make_recipient(user, type)
+ NotificationRecipient.new(
+ user, type,
+ project: project,
+ custom_action: custom_action,
+ target: target,
+ acting_user: acting_user
+ )
+ end
- # Get project/group users with CUSTOM notification level
- def add_custom_notifications(recipients, action)
- user_ids = []
+ def recipient_users
+ @recipient_users ||=
+ begin
+ build!
+ filter!
+ users = recipients.map(&:user)
+ users.uniq!
+ users.freeze
+ end
+ end
- # Users with a notification setting on group or project
- user_ids += user_ids_notifiable_on(project, :custom, action)
- user_ids += user_ids_notifiable_on(project.group, :custom, action)
+ def custom_action
+ nil
+ end
- # Users with global level custom
- user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
+ protected
- global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
- user_ids += user_ids_with_global_level_custom(global_users_ids, action)
+ def add_participants(user)
+ return unless target.respond_to?(:participants)
- recipients.concat(User.find(user_ids))
- end
+ self << [target.participants(user), :watch]
+ end
- def add_project_watchers(recipients)
- recipients.concat(project_watchers).compact
- end
+ # Get project/group users with CUSTOM notification level
+ def add_custom_notifications
+ user_ids = []
- # Get project users with WATCH notification level
- def project_watchers
- project_members_ids = user_ids_notifiable_on(project)
+ # Users with a notification setting on group or project
+ user_ids += user_ids_notifiable_on(project, :custom)
+ user_ids += user_ids_notifiable_on(project.group, :custom)
- user_ids_with_project_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
+ # Users with global level custom
+ user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
- user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
+ global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
+ user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
- user_ids_with_project_setting = select_project_members_ids(project, user_ids_with_project_global, user_ids)
- user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
+ self << [user_scope.where(id: user_ids), :watch]
+ end
- User.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq).to_a
- end
+ def add_project_watchers
+ self << [project_watchers, :watch]
+ end
- # Remove users with notification level 'Mentioned'
- def reject_mention_users(users)
- reject_users(users, :mention)
- end
+ # Get project users with WATCH notification level
+ def project_watchers
+ project_members_ids = user_ids_notifiable_on(project)
- def add_subscribed_users(recipients, target)
- return recipients unless target.respond_to? :subscribers
+ user_ids_with_project_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
- recipients + target.subscribers(project)
- end
+ user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
- def user_ids_notifiable_on(resource, notification_level = nil, action = nil)
- return [] unless resource
+ user_ids_with_project_setting = select_project_members_ids(user_ids_with_project_global, user_ids)
+ user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
- if notification_level
- settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
- settings = settings.select { |setting| setting.events[action] } if action.present?
- settings.map(&:user_id)
- else
- resource.notification_settings.pluck(:user_id)
- end
- end
-
- # Build a list of user_ids based on project notification settings
- def select_project_members_ids(project, global_setting, user_ids_global_level_watch)
- user_ids = user_ids_notifiable_on(project, :watch)
-
- # If project setting is global, add to watch list if global setting is watch
- global_setting.each do |user_id|
- if user_ids_global_level_watch.include?(user_id)
- user_ids << user_id
+ user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq)
end
- end
-
- user_ids
- end
- # Build a list of user_ids based on group notification settings
- def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
- uids = user_ids_notifiable_on(group, :watch)
+ def add_subscribed_users
+ return unless target.respond_to? :subscribers
- # Group setting is watch, add to user_ids list if user is not project member
- user_ids = []
- uids.each do |user_id|
- if project_members.exclude?(user_id)
- user_ids << user_id
+ self << [target.subscribers(project), :subscription]
end
- end
-
- # Group setting is global, add to user_ids list if global setting is watch
- global_setting.each do |user_id|
- if project_members.exclude?(user_id) && user_ids_global_level_watch.include?(user_id)
- user_ids << user_id
- end
- end
-
- user_ids
- end
- def user_ids_with_global_level_watch(ids)
- settings_with_global_level_of(:watch, ids).pluck(:user_id)
- end
+ def user_ids_notifiable_on(resource, notification_level = nil)
+ return [] unless resource
- def user_ids_with_global_level_custom(ids, action)
- settings = settings_with_global_level_of(:custom, ids)
- settings = settings.select { |setting| setting.events[action] }
- settings.map(&:user_id)
- end
+ scope = resource.notification_settings
- def settings_with_global_level_of(level, ids)
- NotificationSetting.where(
- user_id: ids,
- source_type: nil,
- level: NotificationSetting.levels[level]
- )
- end
+ if notification_level
+ scope = scope.where(level: NotificationSetting.levels[notification_level])
+ end
- # Reject users which has certain notification level
- #
- # Example:
- # reject_users(users, :watch, project)
- #
- def reject_users(users, level)
- level = level.to_s
+ scope.pluck(:user_id)
+ end
- unless NotificationSetting.levels.keys.include?(level)
- raise 'Invalid notification level'
- end
+ # Build a list of user_ids based on project notification settings
+ def select_project_members_ids(global_setting, user_ids_global_level_watch)
+ user_ids = user_ids_notifiable_on(project, :watch)
- users = users.to_a.compact.uniq
- users = users.select { |u| u.can?(:receive_notifications) }
+ # If project setting is global, add to watch list if global setting is watch
+ user_ids + (global_setting & user_ids_global_level_watch)
+ end
- users.reject do |user|
- global_notification_setting = user.global_notification_setting
+ # Build a list of user_ids based on group notification settings
+ def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
+ uids = user_ids_notifiable_on(group, :watch)
- next global_notification_setting.level == level unless project
+ # Group setting is global, add to user_ids list if global setting is watch
+ uids + (global_setting & user_ids_global_level_watch) - project_members
+ end
- setting = user.notification_settings_for(project)
+ def user_ids_with_global_level_watch(ids)
+ settings_with_global_level_of(:watch, ids).pluck(:user_id)
+ end
- if project.group && (setting.nil? || setting.global?)
- setting = user.notification_settings_for(project.group)
+ def user_ids_with_global_level_custom(ids, action)
+ settings_with_global_level_of(:custom, ids).pluck(:user_id)
end
- # reject users who globally set mention notification and has no setting per project/group
- next global_notification_setting.level == level unless setting
+ def settings_with_global_level_of(level, ids)
+ NotificationSetting.where(
+ user_id: ids,
+ source_type: nil,
+ level: NotificationSetting.levels[level]
+ )
+ end
- # reject users who set mention notification in project
- next true if setting.level == level
+ def add_labels_subscribers(labels: nil)
+ return unless target.respond_to? :labels
- # reject users who have mention level in project and disabled in global settings
- setting.global? && global_notification_setting.level == level
+ (labels || target.labels).each do |label|
+ self << [label.subscribers(project), :subscription]
+ end
+ end
end
- end
-
- def reject_unsubscribed_users(recipients, target)
- return recipients unless target.respond_to? :subscriptions
- recipients.reject do |user|
- subscription = target.subscriptions.find_by_user_id(user.id)
- subscription && !subscription.subscribed
- end
- end
+ class Default < Base
+ attr_reader :target
+ attr_reader :current_user
+ attr_reader :action
+ attr_reader :previous_assignee
+ attr_reader :skip_current_user
+ def initialize(target, current_user, action:, previous_assignee: nil, skip_current_user: true)
+ @target = target
+ @current_user = current_user
+ @action = action
+ @previous_assignee = previous_assignee
+ @skip_current_user = skip_current_user
+ end
- def reject_users_without_access(recipients, target)
- ability = case target
- when Issuable
- :"read_#{target.to_ability_name}"
- when Ci::Pipeline
- :read_build # We have build trace in pipeline emails
- end
+ def build!
+ add_participants(current_user)
+ add_project_watchers
+ add_custom_notifications
+
+ # Re-assign is considered as a mention of the new assignee
+ case custom_action
+ when :reassign_merge_request
+ self << [previous_assignee, :mention]
+ self << [target.assignee, :mention]
+ when :reassign_issue
+ previous_assignees = Array(previous_assignee)
+ self << [previous_assignees, :mention]
+ self << [target.assignees, :mention]
+ end
+
+ add_subscribed_users
+
+ if [:new_issue, :new_merge_request].include?(custom_action)
+ add_labels_subscribers
+ end
+ end
- return recipients unless ability
+ def acting_user
+ current_user if skip_current_user
+ end
- recipients.select do |user|
- user.can?(ability, target)
+ # Build event key to search on custom notification level
+ # Check NotificationSetting::EMAIL_EVENTS
+ def custom_action
+ @custom_action ||= "#{action}_#{target.class.model_name.name.underscore}".to_sym
+ end
end
- end
- def add_labels_subscribers(recipients, target, labels: nil)
- return recipients unless target.respond_to? :labels
+ class NewNote < Base
+ attr_reader :note
+ def initialize(note)
+ @note = note
+ end
- (labels || target.labels).each do |label|
- recipients += label.subscribers(project)
- end
+ def target
+ note.noteable
+ end
- recipients
- end
+ # NOTE: may be nil, in the case of a PersonalSnippet
+ #
+ # (this is okay because NotificationRecipient is written
+ # to handle nil projects)
+ def project
+ note.project
+ end
- # Build event key to search on custom notification level
- # Check NotificationSetting::EMAIL_EVENTS
- def build_custom_key(action, object)
- "#{action}_#{object.class.model_name.name.underscore}".to_sym
- end
+ def build!
+ # Add all users participating in the thread (author, assignee, comment authors)
+ add_participants(note.author)
+ self << [note.mentioned_users, :mention]
- def notification_setting_for_user_project(user, project)
- project_setting = user.notification_settings_for(project)
+ unless note.for_personal_snippet?
+ # Merge project watchers
+ add_project_watchers
- return project_setting unless project_setting.global?
+ # Merge project with custom notification
+ add_custom_notifications
+ end
- group_setting = user.notification_settings_for(project.group)
+ add_subscribed_users
+ end
- return group_setting unless group_setting.global?
+ def custom_action
+ :new_note
+ end
- user.global_notification_setting
+ def acting_user
+ note.author
+ end
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 6b186263bd1..e2a80db06a6 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -1,3 +1,5 @@
+# rubocop:disable GitlabSecurity/PublicSend
+
# NotificationService class
#
# Used for notifying users with emails about different events
@@ -10,16 +12,28 @@ class NotificationService
# only if ssh key is not deploy key
#
# This is security email so it will be sent
- # even if user disabled notifications
+ # even if user disabled notifications. However,
+ # it won't be sent to internal users like the
+ # ghost user or the EE support bot.
def new_key(key)
- if key.user
+ if key.user&.can?(:receive_notifications)
mailer.new_ssh_key_email(key.id).deliver_later
end
end
+ # Always notify the user about gpg key added
+ #
+ # This is a security email so it will be sent even if the user user disabled
+ # notifications
+ def new_gpg_key(gpg_key)
+ if gpg_key.user&.can?(:receive_notifications)
+ mailer.new_gpg_key_email(gpg_key.id).deliver_later
+ end
+ end
+
# Always notify user about email added to profile
def new_email(email)
- if email.user
+ if email.user&.can?(:receive_notifications)
mailer.new_email_email(email.id).deliver_later
end
end
@@ -32,7 +46,7 @@ class NotificationService
# * users with custom level checked with "new issue"
#
def new_issue(issue, current_user)
- new_resource_email(issue, issue.project, :new_issue_email)
+ new_resource_email(issue, :new_issue_email)
end
# When issue text is updated, we should send an email to:
@@ -42,7 +56,6 @@ class NotificationService
def new_mentions_in_issue(issue, new_mentioned_users, current_user)
new_mentions_in_resource_email(
issue,
- issue.project,
new_mentioned_users,
current_user,
:new_mention_in_issue_email
@@ -57,7 +70,7 @@ class NotificationService
# * users with custom level checked with "close issue"
#
def close_issue(issue, current_user)
- close_resource_email(issue, issue.project, current_user, :closed_issue_email)
+ close_resource_email(issue, current_user, :closed_issue_email)
end
# When we reassign an issue we should send an email to:
@@ -66,8 +79,25 @@ class NotificationService
# * issue new assignee if their notification level is not Disabled
# * users with custom level checked with "reassign issue"
#
- def reassigned_issue(issue, current_user)
- reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email)
+ def reassigned_issue(issue, current_user, previous_assignees = [])
+ recipients = NotificationRecipientService.build_recipients(
+ issue,
+ current_user,
+ action: "reassign",
+ previous_assignee: previous_assignees
+ )
+
+ previous_assignee_ids = previous_assignees.map(&:id)
+
+ recipients.each do |recipient|
+ mailer.send(
+ :reassigned_issue_email,
+ recipient.id,
+ issue.id,
+ previous_assignee_ids,
+ current_user.id
+ ).deliver_later
+ end
end
# When we add labels to an issue we should send an email to:
@@ -75,7 +105,7 @@ class NotificationService
# * watchers of the issue's labels
#
def relabeled_issue(issue, added_labels, current_user)
- relabeled_resource_email(issue, issue.project, added_labels, current_user, :relabeled_issue_email)
+ relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email)
end
# When create a merge request we should send an email to:
@@ -86,7 +116,7 @@ class NotificationService
# * users with custom level checked with "new merge request"
#
def new_merge_request(merge_request, current_user)
- new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email)
+ new_resource_email(merge_request, :new_merge_request_email)
end
# When merge request text is updated, we should send an email to:
@@ -96,7 +126,6 @@ class NotificationService
def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user)
new_mentions_in_resource_email(
merge_request,
- merge_request.target_project,
new_mentioned_users,
current_user,
:new_mention_in_merge_request_email
@@ -110,7 +139,7 @@ class NotificationService
# * users with custom level checked with "reassign merge request"
#
def reassigned_merge_request(merge_request, current_user)
- reassign_resource_email(merge_request, merge_request.target_project, current_user, :reassigned_merge_request_email)
+ reassign_resource_email(merge_request, current_user, :reassigned_merge_request_email)
end
# When we add labels to a merge request we should send an email to:
@@ -118,21 +147,20 @@ class NotificationService
# * watchers of the mr's labels
#
def relabeled_merge_request(merge_request, added_labels, current_user)
- relabeled_resource_email(merge_request, merge_request.target_project, added_labels, current_user, :relabeled_merge_request_email)
+ relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email)
end
def close_mr(merge_request, current_user)
- close_resource_email(merge_request, merge_request.target_project, current_user, :closed_merge_request_email)
+ close_resource_email(merge_request, current_user, :closed_merge_request_email)
end
def reopen_issue(issue, current_user)
- reopen_resource_email(issue, issue.project, current_user, :issue_status_changed_email, 'reopened')
+ reopen_resource_email(issue, current_user, :issue_status_changed_email, 'reopened')
end
def merge_mr(merge_request, current_user)
close_resource_email(
merge_request,
- merge_request.target_project,
current_user,
:merged_merge_request_email,
skip_current_user: !merge_request.merge_when_pipeline_succeeds?
@@ -142,7 +170,6 @@ class NotificationService
def reopen_mr(merge_request, current_user)
reopen_resource_email(
merge_request,
- merge_request.target_project,
current_user,
:merge_request_status_email,
'reopened'
@@ -150,7 +177,7 @@ class NotificationService
end
def resolve_all_discussions(merge_request, current_user)
- recipients = NotificationRecipientService.new(merge_request.target_project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
merge_request,
current_user,
action: "resolve_all_discussions")
@@ -162,6 +189,8 @@ class NotificationService
# Notify new user with email after creation
def new_user(user, token = nil)
+ return true unless notifiable?(user, :mention)
+
# Don't email omniauth created users
mailer.new_user_email(user.id, token).deliver_later unless user.identities.any?
end
@@ -175,7 +204,7 @@ class NotificationService
notify_method = "note_#{note.to_ability_name}_email".to_sym
- recipients = NotificationRecipientService.new(note.project).build_new_note_recipients(note)
+ recipients = NotificationRecipientService.build_new_note_recipients(note)
recipients.each do |recipient|
mailer.send(notify_method, recipient.id, note.id).deliver_later
end
@@ -183,19 +212,27 @@ class NotificationService
# Members
def new_access_request(member)
+ return true unless member.notifiable?(:subscription)
+
mailer.member_access_requested_email(member.real_source_type, member.id).deliver_later
end
def decline_access_request(member)
+ return true unless member.notifiable?(:subscription)
+
mailer.member_access_denied_email(member.real_source_type, member.source_id, member.user_id).deliver_later
end
# Project invite
def invite_project_member(project_member, token)
+ return true unless project_member.notifiable?(:subscription)
+
mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later
end
def accept_project_invite(project_member)
+ return true unless project_member.notifiable?(:subscription)
+
mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
end
@@ -209,10 +246,14 @@ class NotificationService
end
def new_project_member(project_member)
+ return true unless project_member.notifiable?(:mention, skip_read_ability: true)
+
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
def update_project_member(project_member)
+ return true unless project_member.notifiable?(:mention)
+
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
@@ -226,6 +267,9 @@ class NotificationService
end
def decline_group_invite(group_member)
+ # always send this one, since it's a response to the user's own
+ # action
+
mailer.member_invite_declined_email(
group_member.real_source_type,
group_member.group.id,
@@ -235,16 +279,19 @@ class NotificationService
end
def new_group_member(group_member)
+ return true unless group_member.notifiable?(:mention)
+
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def update_group_member(group_member)
+ return true unless group_member.notifiable?(:mention)
+
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def project_was_moved(project, old_path_with_namespace)
- recipients = project.team.members
- recipients = NotificationRecipientService.new(project).reject_muted_users(recipients)
+ recipients = notifiable_users(project.team.members, :mention, project: project)
recipients.each do |recipient|
mailer.project_was_moved_email(
@@ -256,7 +303,7 @@ class NotificationService
end
def issue_moved(issue, new_issue, current_user)
- recipients = NotificationRecipientService.new(issue.project).build_recipients(issue, current_user)
+ recipients = NotificationRecipientService.build_recipients(issue, current_user, action: 'moved')
recipients.map do |recipient|
email = mailer.issue_moved_email(recipient, issue, new_issue, current_user)
@@ -266,10 +313,14 @@ class NotificationService
end
def project_exported(project, current_user)
+ return true unless notifiable?(current_user, :mention, project: project)
+
mailer.project_was_exported_email(current_user, project).deliver_later
end
def project_not_exported(project, current_user, errors)
+ return true unless notifiable?(current_user, :mention, project: project)
+
mailer.project_was_not_exported_email(current_user, project, errors).deliver_later
end
@@ -278,10 +329,10 @@ class NotificationService
return unless mailer.respond_to?(email_template)
- recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients(
- pipeline,
- pipeline.user,
- action: pipeline.status,
+ recipients ||= notifiable_users(
+ [pipeline.user], :watch,
+ custom_action: :"#{pipeline.status}_pipeline",
+ target: pipeline
).map(&:notification_email)
if recipients.any?
@@ -291,16 +342,16 @@ class NotificationService
protected
- def new_resource_email(target, project, method)
- recipients = NotificationRecipientService.new(project).build_recipients(target, target.author, action: "new")
+ def new_resource_email(target, method)
+ recipients = NotificationRecipientService.build_recipients(target, target.author, action: "new")
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id).deliver_later
end
end
- def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method)
- recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "new")
+ def new_mentions_in_resource_email(target, new_mentioned_users, current_user, method)
+ recipients = NotificationRecipientService.build_recipients(target, current_user, action: "new")
recipients = recipients & new_mentioned_users
recipients.each do |recipient|
@@ -308,10 +359,10 @@ class NotificationService
end
end
- def close_resource_email(target, project, current_user, method, skip_current_user: true)
+ def close_resource_email(target, current_user, method, skip_current_user: true)
action = method == :merged_merge_request_email ? "merge" : "close"
- recipients = NotificationRecipientService.new(project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
target,
current_user,
action: action,
@@ -323,11 +374,11 @@ class NotificationService
end
end
- def reassign_resource_email(target, project, current_user, method)
+ def reassign_resource_email(target, current_user, method)
previous_assignee_id = previous_record(target, 'assignee_id')
previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
- recipients = NotificationRecipientService.new(project).build_recipients(
+ recipients = NotificationRecipientService.build_recipients(
target,
current_user,
action: "reassign",
@@ -345,8 +396,14 @@ class NotificationService
end
end
- def relabeled_resource_email(target, project, labels, current_user, method)
- recipients = NotificationRecipientService.new(project).build_relabeled_recipients(target, current_user, labels: labels)
+ def relabeled_resource_email(target, labels, current_user, method)
+ recipients = labels.flat_map { |l| l.subscribers(target.project) }
+ recipients = notifiable_users(
+ recipients, :subscription,
+ target: target,
+ acting_user: current_user
+ )
+
label_names = labels.map(&:name)
recipients.each do |recipient|
@@ -354,8 +411,8 @@ class NotificationService
end
end
- def reopen_resource_email(target, project, current_user, method, status)
- recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "reopen")
+ def reopen_resource_email(target, current_user, method, status)
+ recipients = NotificationRecipientService.build_recipients(target, current_user, action: "reopen")
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id, status, current_user.id).deliver_later
@@ -367,10 +424,20 @@ class NotificationService
end
def previous_record(object, attribute)
- if object && attribute
- if object.previous_changes.include?(attribute)
- object.previous_changes[attribute].first
- end
+ return unless object && attribute
+
+ if object.previous_changes.include?(attribute)
+ object.previous_changes[attribute].first
end
end
+
+ private
+
+ def notifiable?(*args)
+ NotificationRecipientService.notifiable?(*args)
+ end
+
+ def notifiable_users(*args)
+ NotificationRecipientService.notifiable_users(*args)
+ end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
new file mode 100644
index 00000000000..4ee2c1796bd
--- /dev/null
+++ b/app/services/preview_markdown_service.rb
@@ -0,0 +1,45 @@
+class PreviewMarkdownService < BaseService
+ def execute
+ text, commands = explain_quick_actions(params[:text])
+ users = find_user_references(text)
+
+ success(
+ text: text,
+ users: users,
+ commands: commands.join(' ')
+ )
+ end
+
+ private
+
+ def explain_quick_actions(text)
+ return text, [] unless %w(Issue MergeRequest).include?(commands_target_type)
+
+ quick_actions_service = QuickActions::InterpretService.new(project, current_user)
+ quick_actions_service.explain(text, find_commands_target)
+ end
+
+ def find_user_references(text)
+ extractor = Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor.analyze(text, author: current_user)
+ extractor.users.map(&:username)
+ end
+
+ def find_commands_target
+ if commands_target_id.present?
+ finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder
+ finder.new(current_user, project_id: project.id).find(commands_target_id)
+ else
+ collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests
+ collection.build
+ end
+ end
+
+ def commands_target_type
+ params[:quick_actions_target_type]
+ end
+
+ def commands_target_id
+ params[:quick_actions_target_id]
+ end
+end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 015f2828921..724a77c873a 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -5,7 +5,15 @@ module Projects
end
def milestones
- @project.milestones.active.reorder(due_date: :asc, title: :asc).select([:iid, :title])
+ finder_params = {
+ project_ids: [@project.id],
+ state: :active,
+ order: { due_date: :asc, title: :asc }
+ }
+
+ finder_params[:group_ids] = [@project.group.id] if @project.group
+
+ MilestonesFinder.new(finder_params).execute.select([:iid, :title])
end
def merge_requests
@@ -32,7 +40,7 @@ module Projects
issuable: noteable,
current_user: current_user
}
- SlashCommands::InterpretService.command_definitions.map do |definition|
+ QuickActions::InterpretService.command_definitions.map do |definition|
next unless definition.available?(opts)
definition.to_h(opts)
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
new file mode 100644
index 00000000000..87d9ed7a0e6
--- /dev/null
+++ b/app/services/projects/create_from_template_service.rb
@@ -0,0 +1,15 @@
+module Projects
+ class CreateFromTemplateService < BaseService
+ def initialize(user, params)
+ @current_user, @params = user, params.dup
+ end
+
+ def execute
+ params[:file] = Gitlab::ProjectTemplate.find(params[:template_name]).file
+
+ GitlabProjectsImportService.new(@current_user, @params).execute
+ ensure
+ params[:file]&.close
+ end
+ end
+end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 535d93385e6..48578b6d9e5 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -5,6 +5,10 @@ module Projects
end
def execute
+ if @params[:template_name]&.present?
+ return ::Projects::CreateFromTemplateService.new(current_user, params).execute
+ end
+
forked_from_project_id = params.delete(:forked_from_project_id)
import_data = params.delete(:import_data)
@skip_wiki = params.delete(:skip_wiki)
@@ -48,15 +52,14 @@ module Projects
save_project_and_import_data(import_data)
- @project.import_start if @project.import?
-
after_create_actions if @project.persisted?
if @project.errors.empty?
- @project.add_import_job if @project.import?
+ @project.import_schedule if @project.import?
else
fail(error: @project.errors.full_messages.join(', '))
end
+
@project
rescue ActiveRecord::RecordInvalid => e
message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} "
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 06d8d143231..54eb75ab9bf 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -7,50 +7,56 @@ module Projects
DELETED_FLAG = '+deleted'.freeze
def async_execute
- project.transaction do
- project.update_attribute(:pending_delete, true)
- job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
- Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
- end
+ project.update_attribute(:pending_delete, true)
+ job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}")
end
def execute
return false unless can?(current_user, :remove_project, project)
- repo_path = project.path_with_namespace
- wiki_path = repo_path + '.wiki'
-
# Flush the cache for both repositories. This has to be done _before_
# removing the physical repositories as some expiration code depends on
# Git data (e.g. a list of branch names).
- flush_caches(project, wiki_path)
+ flush_caches(project)
Projects::UnlinkForkService.new(project, current_user).execute
- Project.transaction do
- project.team.truncate
- project.destroy!
-
- unless remove_legacy_registry_tags
- raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
- end
-
- unless remove_repository(repo_path)
- raise_error('Failed to remove project repository. Please try again or contact administrator.')
- end
+ attempt_destroy_transaction(project)
- unless remove_repository(wiki_path)
- raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
- end
- end
-
- log_info("Project \"#{project.path_with_namespace}\" was removed")
system_hook_service.execute_hooks_for(project, :destroy)
+ log_info("Project \"#{project.full_path}\" was removed")
+
true
+ rescue => error
+ attempt_rollback(project, error.message)
+ false
+ rescue Exception => error # rubocop:disable Lint/RescueException
+ # Project.transaction can raise Exception
+ attempt_rollback(project, error.message)
+ raise
end
private
+ def repo_path
+ project.disk_path
+ end
+
+ def wiki_path
+ repo_path + '.wiki'
+ end
+
+ def trash_repositories!
+ unless remove_repository(repo_path)
+ raise_error('Failed to remove project repository. Please try again or contact administrator.')
+ end
+
+ unless remove_repository(wiki_path)
+ raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
+ end
+ end
+
def remove_repository(path)
# Skip repository removal. We use this flag when remove user or group
return true if params[:skip_repo] == true
@@ -62,12 +68,36 @@ module Projects
if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path)
+
+ project.run_after_commit do
+ # self is now project
+ GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path)
+ end
else
false
end
end
+ def attempt_rollback(project, message)
+ return unless project
+
+ project.update_attributes(delete_error: message, pending_delete: false)
+ log_error("Deletion failed on #{project.full_path} with the following message: #{message}")
+ end
+
+ def attempt_destroy_transaction(project)
+ Project.transaction do
+ unless remove_legacy_registry_tags
+ raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
+ end
+
+ trash_repositories!
+
+ project.team.truncate
+ project.destroy!
+ end
+ end
+
##
# This method makes sure that we correctly remove registry tags
# for legacy image repository (when repository path equals project path).
@@ -94,10 +124,12 @@ module Projects
"#{path}+#{project.id}#{DELETED_FLAG}"
end
- def flush_caches(project, wiki_path)
+ def flush_caches(project)
project.repository.before_delete
- Repository.new(wiki_path, project).before_delete
+ Repository.new(wiki_path, project, disk_path: repo_path).before_delete
+
+ Projects::ForksCountService.new(project).delete_cache
end
end
end
diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb
index 3cf4264ce9b..121385afca3 100644
--- a/app/services/projects/enable_deploy_key_service.rb
+++ b/app/services/projects/enable_deploy_key_service.rb
@@ -4,7 +4,10 @@ module Projects
key = accessible_keys.find_by(id: params[:key_id] || params[:id])
return unless key
- project.deploy_keys << key
+ unless project.deploy_keys.include?(key)
+ project.deploy_keys << key
+ end
+
key
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index a2b23ea6171..ad67e68a86a 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -21,11 +21,17 @@ module Projects
builds_access_level = @project.project_feature.builds_access_level
new_project.project_feature.update_attributes(builds_access_level: builds_access_level)
+ refresh_forks_count
+
new_project
end
private
+ def refresh_forks_count
+ Projects::ForksCountService.new(@project).refresh_cache
+ end
+
def allowed_visibility_level
project_level = @project.visibility_level
diff --git a/app/services/projects/forks_count_service.rb b/app/services/projects/forks_count_service.rb
new file mode 100644
index 00000000000..e2e2b1da91d
--- /dev/null
+++ b/app/services/projects/forks_count_service.rb
@@ -0,0 +1,30 @@
+module Projects
+ # Service class for getting and caching the number of forks of a project.
+ class ForksCountService
+ def initialize(project)
+ @project = project
+ end
+
+ def count
+ Rails.cache.fetch(cache_key) { uncached_count }
+ end
+
+ def refresh_cache
+ Rails.cache.write(cache_key, uncached_count)
+ end
+
+ def delete_cache
+ Rails.cache.delete(cache_key)
+ end
+
+ private
+
+ def uncached_count
+ @project.forks.count
+ end
+
+ def cache_key
+ ['projects', @project.id, 'forks_count']
+ end
+ end
+end
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
new file mode 100644
index 00000000000..4ca6414b73b
--- /dev/null
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -0,0 +1,36 @@
+# This service is an adapter used to for the GitLab Import feature, and
+# creating a project from a template.
+# The latter will under the hood just import an archive supplied by GitLab.
+module Projects
+ class GitlabProjectsImportService
+ attr_reader :current_user, :params
+
+ def initialize(user, params)
+ @current_user, @params = user, params.dup
+ end
+
+ def execute
+ FileUtils.mkdir_p(File.dirname(import_upload_path))
+ FileUtils.copy_entry(file.path, import_upload_path)
+
+ Gitlab::ImportExport::ProjectCreator.new(params[:namespace_id],
+ current_user,
+ import_upload_path,
+ params[:path]).execute
+ end
+
+ private
+
+ def import_upload_path
+ @import_upload_path ||= Gitlab::ImportExport.import_upload_path(filename: tmp_filename)
+ end
+
+ def tmp_filename
+ "#{SecureRandom.hex}_#{params[:path]}"
+ end
+
+ def file
+ params[:file]
+ end
+ end
+end
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 535da706159..fe4e8ea10bf 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -2,7 +2,7 @@ module Projects
module ImportExport
class ExportService < BaseService
def execute(_options = {})
- @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.path_with_namespace, 'work'))
+ @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.disk_path, 'work'))
save_all
end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index eea17e24903..c3bf0031409 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -11,7 +11,7 @@ module Projects
success
rescue => e
- error("Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}")
+ error("Error importing repository #{project.import_url} into #{project.full_path} - #{e.message}")
end
private
@@ -34,8 +34,12 @@ module Projects
def import_repository
raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url)
+ # We should return early for a GitHub import because the new GitHub
+ # importer fetch the project repositories for us.
+ return if project.github_import?
+
begin
- if project.github_import? || project.gitea_import?
+ if project.gitea_import?
fetch_repository
else
clone_repository
@@ -51,11 +55,11 @@ module Projects
end
def clone_repository
- gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url)
+ gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url)
end
def fetch_repository
- project.create_repository
+ project.ensure_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)
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
new file mode 100644
index 00000000000..a8ef2108492
--- /dev/null
+++ b/app/services/projects/propagate_service_template.rb
@@ -0,0 +1,103 @@
+module Projects
+ class PropagateServiceTemplate
+ BATCH_SIZE = 100
+
+ def self.propagate(*args)
+ new(*args).propagate
+ end
+
+ def initialize(template)
+ @template = template
+ end
+
+ def propagate
+ return unless @template.active?
+
+ Rails.logger.info("Propagating services for template #{@template.id}")
+
+ propagate_projects_with_template
+ end
+
+ private
+
+ def propagate_projects_with_template
+ loop do
+ batch = project_ids_batch
+
+ bulk_create_from_template(batch) unless batch.empty?
+
+ break if batch.size < BATCH_SIZE
+ end
+ end
+
+ def bulk_create_from_template(batch)
+ service_list = batch.map do |project_id|
+ service_hash.values << project_id
+ end
+
+ Project.transaction do
+ bulk_insert_services(service_hash.keys << 'project_id', service_list)
+ run_callbacks(batch)
+ end
+ end
+
+ def project_ids_batch
+ Project.connection.select_values(
+ <<-SQL
+ SELECT id
+ FROM projects
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM services
+ WHERE services.project_id = projects.id
+ AND services.type = '#{@template.type}'
+ )
+ AND projects.pending_delete = false
+ AND projects.archived = false
+ LIMIT #{BATCH_SIZE}
+ SQL
+ )
+ end
+
+ def bulk_insert_services(columns, values_array)
+ ActiveRecord::Base.connection.execute(
+ <<-SQL.strip_heredoc
+ INSERT INTO services (#{columns.join(', ')})
+ VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
+ SQL
+ )
+ end
+
+ def service_hash
+ @service_hash ||=
+ begin
+ template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id')
+
+ template_hash.each_with_object({}) do |(key, value), service_hash|
+ value = value.is_a?(Hash) ? value.to_json : value
+
+ service_hash[ActiveRecord::Base.connection.quote_column_name(key)] =
+ ActiveRecord::Base.sanitize(value)
+ end
+ end
+ end
+
+ def run_callbacks(batch)
+ if active_external_issue_tracker?
+ Project.where(id: batch).update_all(has_external_issue_tracker: true)
+ end
+
+ if active_external_wiki?
+ Project.where(id: batch).update_all(has_external_wiki: true)
+ end
+ end
+
+ def active_external_issue_tracker?
+ @template.issue_tracker? && !@template.default
+ end
+
+ def active_external_wiki?
+ @template.type == 'ExternalWikiService'
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index da6e6acd4a7..5957f612e84 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -12,86 +12,124 @@ module Projects
TransferError = Class.new(StandardError)
def execute(new_namespace)
- if allowed_transfer?(current_user, project, new_namespace)
- transfer(project, new_namespace)
- else
- project.errors.add(:new_namespace, 'is invalid')
- false
+ @new_namespace = new_namespace
+
+ if @new_namespace.blank?
+ raise TransferError, 'Please select a new namespace for your project.'
+ end
+
+ unless allowed_transfer?(current_user, project)
+ raise TransferError, 'Transfer failed, please contact an admin.'
end
+
+ transfer(project)
+
+ true
rescue Projects::TransferService::TransferError => ex
project.reload
project.errors.add(:new_namespace, ex.message)
false
end
- def transfer(project, new_namespace)
- old_namespace = project.namespace
+ private
- Project.transaction do
- old_path = project.path_with_namespace
- old_group = project.group
- new_path = File.join(new_namespace.try(:full_path) || '', project.path)
+ def transfer(project)
+ @old_path = project.full_path
+ @old_group = project.group
+ @new_path = File.join(@new_namespace.try(:full_path) || '', project.path)
+ @old_namespace = project.namespace
- if Project.where(path: project.path, namespace_id: new_namespace.try(:id)).present?
- raise TransferError.new("Project with same path in target namespace already exists")
- end
+ if Project.where(path: project.path, namespace_id: @new_namespace.try(:id)).exists?
+ raise TransferError.new("Project with same path in target namespace already exists")
+ end
- if project.has_container_registry_tags?
- # we currently doesn't support renaming repository if it contains tags in container registry
- raise TransferError.new('Project cannot be transferred, because tags are present in its container registry')
- end
+ if project.has_container_registry_tags?
+ # We currently don't support renaming repository if it contains tags in container registry
+ raise TransferError.new('Project cannot be transferred, because tags are present in its container registry')
+ end
- project.expire_caches_before_rename(old_path)
+ attempt_transfer_transaction
+ end
+
+ def attempt_transfer_transaction
+ Project.transaction do
+ project.expire_caches_before_rename(@old_path)
- # Apply new namespace id and visibility level
- project.namespace = new_namespace
- project.visibility_level = new_namespace.visibility_level unless project.visibility_level_allowed_by_group?
- project.save!
+ update_namespace_and_visibility(@new_namespace)
# Notifications
- project.send_move_instructions(old_path)
+ project.send_move_instructions(@old_path)
# Move main repository
- unless gitlab_shell.mv_repository(project.repository_storage_path, old_path, new_path)
+ # TODO: check storage type and NOOP when not using Legacy
+ unless move_repo_folder(@old_path, @new_path)
raise TransferError.new('Cannot move project')
end
# Move wiki repo also if present
- gitlab_shell.mv_repository(project.repository_storage_path, "#{old_path}.wiki", "#{new_path}.wiki")
+ # TODO: check storage type and NOOP when not using Legacy
+ move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki")
# Move missing group labels to project
- Labels::TransferService.new(current_user, old_group, project).execute
+ Labels::TransferService.new(current_user, @old_group, project).execute
# Move uploads
- Gitlab::UploadsTransfer.new.move_project(project.path, old_namespace.full_path, new_namespace.full_path)
+ Gitlab::UploadsTransfer.new.move_project(project.path, @old_namespace.full_path, @new_namespace.full_path)
# Move pages
- Gitlab::PagesTransfer.new.move_project(project.path, old_namespace.full_path, new_namespace.full_path)
+ Gitlab::PagesTransfer.new.move_project(project.path, @old_namespace.full_path, @new_namespace.full_path)
- project.old_path_with_namespace = old_path
+ project.old_path_with_namespace = @old_path
+ project.expires_full_path_cache
- SystemHooksService.new.execute_hooks_for(project, :transfer)
+ execute_system_hooks
end
-
- refresh_permissions(old_namespace, new_namespace)
-
- true
+ rescue Exception # rubocop:disable Lint/RescueException
+ rollback_side_effects
+ raise
+ ensure
+ refresh_permissions
end
- def allowed_transfer?(current_user, project, namespace)
- namespace &&
+ def allowed_transfer?(current_user, project)
+ @new_namespace &&
can?(current_user, :change_namespace, project) &&
- namespace.id != project.namespace_id &&
- current_user.can?(:create_projects, namespace)
+ @new_namespace.id != project.namespace_id &&
+ current_user.can?(:create_projects, @new_namespace)
end
- def refresh_permissions(old_namespace, new_namespace)
+ def update_namespace_and_visibility(to_namespace)
+ # Apply new namespace id and visibility level
+ project.namespace = to_namespace
+ project.visibility_level = to_namespace.visibility_level unless project.visibility_level_allowed_by_group?
+ project.save!
+ end
+
+ def refresh_permissions
# This ensures we only schedule 1 job for every user that has access to
# the namespaces.
- user_ids = old_namespace.user_ids_for_project_authorizations |
- new_namespace.user_ids_for_project_authorizations
+ user_ids = @old_namespace.user_ids_for_project_authorizations |
+ @new_namespace.user_ids_for_project_authorizations
UserProjectAccessChangedService.new(user_ids).execute
end
+
+ def rollback_side_effects
+ rollback_folder_move
+ update_namespace_and_visibility(@old_namespace)
+ end
+
+ def rollback_folder_move
+ move_repo_folder(@new_path, @old_path)
+ move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki")
+ end
+
+ def move_repo_folder(from_name, to_name)
+ gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
+ end
+
+ def execute_system_hooks
+ SystemHooksService.new.execute_hooks_for(project, :transfer)
+ end
end
end
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index 315c3e16292..f30b40423c8 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -10,10 +10,16 @@ module Projects
merge_requests = @project.forked_from_project.merge_requests.opened.from_project(@project)
merge_requests.each do |mr|
- MergeRequests::CloseService.new(@project, @current_user).execute(mr)
+ ::MergeRequests::CloseService.new(@project, @current_user).execute(mr)
end
+ refresh_forks_count(@project.forked_from_project)
+
@project.forked_project_link.destroy
end
+
+ def refresh_forks_count(project)
+ Projects::ForksCountService.new(project).refresh_cache
+ end
end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index eb4809afa85..cacb74b1205 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -27,7 +27,7 @@ module Projects
{
domain: domain.domain,
certificate: domain.certificate,
- key: domain.key,
+ key: domain.key
}
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 17cf71cf098..394b336a638 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -33,8 +33,10 @@ module Projects
success
end
rescue => e
+ register_failure
error(e.message)
ensure
+ register_attempt
build.erase_artifacts! unless build.has_expiring_artifacts?
end
@@ -93,10 +95,11 @@ module Projects
end
# Requires UnZip at least 6.00 Info-ZIP.
+ # -qq be (very) quiet
# -n never overwrite existing files
# We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories
site_path = File.join(SITE_PATH, '*')
- unless system(*%W(unzip -n #{artifacts} #{site_path} -d #{temp_path}))
+ unless system(*%W(unzip -qq -n #{artifacts} #{site_path} -d #{temp_path}))
raise 'pages failed to extract'
end
end
@@ -129,7 +132,11 @@ module Projects
end
def max_size
- current_application_settings.max_pages_size.megabytes || MAX_SIZE
+ max_pages_size = current_application_settings.max_pages_size.megabytes
+
+ return MAX_SIZE if max_pages_size.zero?
+
+ [max_pages_size, MAX_SIZE].min
end
def tmp_path
@@ -163,5 +170,21 @@ module Projects
def sha
build.sha
end
+
+ def register_attempt
+ pages_deployments_total_counter.increment
+ end
+
+ def register_failure
+ pages_deployments_failed_total_counter.increment
+ end
+
+ def pages_deployments_total_counter
+ @pages_deployments_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_total, "Counter of GitLab Pages deployments triggered")
+ end
+
+ def pages_deployments_failed_total_counter
+ @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
+ end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 55d9cb13ae4..cf69007bc3b 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -1,22 +1,16 @@
module Projects
class UpdateService < BaseService
def execute
- # check that user is allowed to set specified visibility_level
- new_visibility = params[:visibility_level]
-
- if new_visibility && new_visibility.to_i != project.visibility_level
- unless can?(current_user, :change_visibility_level, project) &&
- Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
-
- deny_visibility_level(project, new_visibility)
- return error('Visibility level unallowed')
- end
+ unless visibility_level_allowed?
+ return error('New visibility level not allowed!')
end
- new_branch = params[:default_branch]
+ if renaming_project_with_container_registry_tags?
+ return error('Cannot rename project because it contains container registry tags!')
+ end
- if project.repository.exists? && new_branch && new_branch != project.default_branch
- project.change_head(new_branch)
+ if changing_default_branch?
+ return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
if project.update_attributes(params.except(:default_branch))
@@ -28,8 +22,40 @@ module Projects
success
else
- error('Project could not be updated')
+ error('Project could not be updated!')
+ end
+ end
+
+ private
+
+ def visibility_level_allowed?
+ # check that user is allowed to set specified visibility_level
+ new_visibility = params[:visibility_level]
+
+ if new_visibility && new_visibility.to_i != project.visibility_level
+ unless can?(current_user, :change_visibility_level, project) &&
+ Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
+
+ deny_visibility_level(project, new_visibility)
+ return false
+ end
end
+
+ true
+ end
+
+ def renaming_project_with_container_registry_tags?
+ new_path = params[:path]
+
+ new_path && new_path != project.path &&
+ project.has_container_registry_tags?
+ end
+
+ def changing_default_branch?
+ new_branch = params[:default_branch]
+
+ project.repository.exists? &&
+ new_branch && new_branch != project.default_branch
end
end
end
diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb
deleted file mode 100644
index be34d4fa9b8..00000000000
--- a/app/services/projects/upload_service.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module Projects
- class UploadService < BaseService
- def initialize(project, file)
- @project, @file = project, file
- end
-
- def execute
- return nil unless @file && @file.size <= max_attachment_size
-
- uploader = FileUploader.new(@project)
- uploader.store!(@file)
-
- uploader.to_h
- end
-
- private
-
- def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
- end
- end
-end
diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb
new file mode 100644
index 00000000000..b0a389c85f9
--- /dev/null
+++ b/app/services/push_event_payload_service.rb
@@ -0,0 +1,120 @@
+# Service class for creating push event payloads as stored in the
+# "push_event_payloads" table.
+#
+# Example:
+#
+# data = Gitlab::DataBuilder::Push.build(...)
+# event = Event.create(...)
+#
+# PushEventPayloadService.new(event, data).execute
+class PushEventPayloadService
+ # event - The event this push payload belongs to.
+ # push_data - A Hash produced by `Gitlab::DataBuilder::Push.build` to use for
+ # building the push payload.
+ def initialize(event, push_data)
+ @event = event
+ @push_data = push_data
+ end
+
+ # Creates and returns a new PushEventPayload row.
+ #
+ # This method will raise upon encountering validation errors.
+ #
+ # Returns an instance of PushEventPayload.
+ def execute
+ @event.build_push_event_payload(
+ commit_count: commit_count,
+ action: action,
+ ref_type: ref_type,
+ commit_from: commit_from_id,
+ commit_to: commit_to_id,
+ ref: trimmed_ref,
+ commit_title: commit_title,
+ event_id: @event.id
+ )
+
+ @event.push_event_payload.save!
+ @event.push_event_payload
+ end
+
+ # Returns the commit title to use.
+ #
+ # The commit title is limited to the first line and a maximum of 70
+ # characters.
+ def commit_title
+ commit = @push_data.fetch(:commits).last
+
+ return nil unless commit && commit[:message]
+
+ raw_msg = commit[:message]
+
+ # Find where the first line ends, without turning the entire message into an
+ # Array of lines (this is a waste of memory for large commit messages).
+ index = raw_msg.index("\n")
+ message = index ? raw_msg[0..index] : raw_msg
+
+ message.strip.truncate(70)
+ end
+
+ def commit_from_id
+ if create?
+ nil
+ else
+ revision_before
+ end
+ end
+
+ def commit_to_id
+ if remove?
+ nil
+ else
+ revision_after
+ end
+ end
+
+ def commit_count
+ @push_data.fetch(:total_commits_count)
+ end
+
+ def ref
+ @push_data.fetch(:ref)
+ end
+
+ def revision_before
+ @push_data.fetch(:before)
+ end
+
+ def revision_after
+ @push_data.fetch(:after)
+ end
+
+ def trimmed_ref
+ Gitlab::Git.ref_name(ref)
+ end
+
+ def create?
+ Gitlab::Git.blank_ref?(revision_before)
+ end
+
+ def remove?
+ Gitlab::Git.blank_ref?(revision_after)
+ end
+
+ def action
+ if create?
+ :created
+ elsif remove?
+ :removed
+ else
+ :pushed
+ end
+ end
+
+ def ref_type
+ if Gitlab::Git.tag_ref?(ref)
+ :tag
+ else
+ :branch
+ end
+ end
+end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
new file mode 100644
index 00000000000..c7832c47e1a
--- /dev/null
+++ b/app/services/quick_actions/interpret_service.rb
@@ -0,0 +1,572 @@
+module QuickActions
+ class InterpretService < BaseService
+ include Gitlab::QuickActions::Dsl
+
+ attr_reader :issuable
+
+ SHRUG = '¯\\_(ツ)_/¯'.freeze
+ TABLEFLIP = '(╯°□°)╯︵ â”»â”â”»'.freeze
+
+ # 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_quick_actions)
+
+ @issuable = issuable
+ @updates = {}
+
+ content, commands = extractor.extract_commands(content, context)
+ extract_updates(commands, context)
+
+ [content, @updates]
+ end
+
+ # Takes a text and interprets the commands that are extracted from it.
+ # Returns the content without commands, and array of changes explained.
+ def explain(content, issuable)
+ return [content, []] unless current_user.can?(:use_quick_actions)
+
+ @issuable = issuable
+
+ content, commands = extractor.extract_commands(content, context)
+ commands = explain_commands(commands, context)
+ [content, commands]
+ end
+
+ private
+
+ def extractor
+ Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
+ end
+
+ desc do
+ "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
+ end
+ explanation do
+ "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.open? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :close do
+ @updates[:state_event] = 'close'
+ end
+
+ desc do
+ "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
+ end
+ explanation do
+ "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.closed? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :reopen do
+ @updates[:state_event] = 'reopen'
+ end
+
+ desc 'Merge (when the pipeline succeeds)'
+ explanation 'Merges this merge request when the pipeline succeeds.'
+ condition do
+ last_diff_sha = params && params[:merge_request_diff_head_sha]
+ issuable.is_a?(MergeRequest) &&
+ issuable.persisted? &&
+ issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
+ end
+ command :merge do
+ @updates[:merge] = params[:merge_request_diff_head_sha]
+ end
+
+ desc 'Change title'
+ explanation do |title_param|
+ "Changes the title to \"#{title_param}\"."
+ end
+ params '<New title>'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :title do |title_param|
+ @updates[:title] = title_param
+ end
+
+ desc 'Assign'
+ explanation do |users|
+ users = issuable.allows_multiple_assignees? ? users : users.take(1)
+ "Assigns #{users.map(&:to_reference).to_sentence}."
+ end
+ params do
+ issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user'
+ end
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ parse_params do |assignee_param|
+ extract_users(assignee_param)
+ end
+ command :assign do |users|
+ next if users.empty?
+
+ @updates[:assignee_ids] =
+ if issuable.allows_multiple_assignees?
+ issuable.assignees.pluck(:id) + users.map(&:id)
+ else
+ [users.last.id]
+ end
+ end
+
+ desc do
+ if issuable.allows_multiple_assignees?
+ 'Remove all or specific assignee(s)'
+ else
+ 'Remove assignee'
+ end
+ end
+ explanation do
+ "Removes #{'assignee'.pluralize(issuable.assignees.size)} #{issuable.assignees.map(&:to_reference).to_sentence}."
+ end
+ params do
+ issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.assignees.any? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ parse_params do |unassign_param|
+ # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed
+ extract_users(unassign_param) if issuable.allows_multiple_assignees?
+ end
+ command :unassign do |users = nil|
+ @updates[:assignee_ids] =
+ if users&.any?
+ issuable.assignees.pluck(:id) - users.map(&:id)
+ else
+ []
+ end
+ end
+
+ desc 'Set milestone'
+ explanation do |milestone|
+ "Sets the milestone to #{milestone.to_reference}." if milestone
+ end
+ params '%"milestone"'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
+ project.milestones.active.any?
+ end
+ parse_params do |milestone_param|
+ extract_references(milestone_param, :milestone).first ||
+ project.milestones.find_by(title: milestone_param.strip)
+ end
+ command :milestone do |milestone|
+ @updates[:milestone_id] = milestone.id if milestone
+ end
+
+ desc 'Remove milestone'
+ explanation do
+ "Removes #{issuable.milestone.to_reference(format: :name)} milestone."
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.milestone_id? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_milestone do
+ @updates[:milestone_id] = nil
+ end
+
+ desc 'Add label(s)'
+ explanation do |labels_param|
+ labels = find_label_references(labels_param)
+
+ "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ end
+ params '~label1 ~"label 2"'
+ condition do
+ available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
+
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
+ available_labels.any?
+ end
+ command :label do |labels_param|
+ label_ids = find_label_ids(labels_param)
+
+ if label_ids.any?
+ @updates[:add_label_ids] ||= []
+ @updates[:add_label_ids] += label_ids
+
+ @updates[:add_label_ids].uniq!
+ end
+ end
+
+ desc 'Remove all or specific label(s)'
+ explanation do |labels_param = nil|
+ if labels_param.present?
+ labels = find_label_references(labels_param)
+ "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ else
+ 'Removes all labels.'
+ end
+ end
+ params '~label1 ~"label 2"'
+ condition do
+ issuable.persisted? &&
+ issuable.labels.any? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :unlabel do |labels_param = nil|
+ if labels_param.present?
+ label_ids = find_label_ids(labels_param)
+
+ if label_ids.any?
+ @updates[:remove_label_ids] ||= []
+ @updates[:remove_label_ids] += label_ids
+
+ @updates[:remove_label_ids].uniq!
+ end
+ else
+ @updates[:label_ids] = []
+ end
+ end
+
+ desc 'Replace all label(s)'
+ explanation do |labels_param|
+ labels = find_label_references(labels_param)
+ "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
+ end
+ params '~label1 ~"label 2"'
+ condition do
+ issuable.persisted? &&
+ issuable.labels.any? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :relabel do |labels_param|
+ label_ids = find_label_ids(labels_param)
+
+ if label_ids.any?
+ @updates[:label_ids] ||= []
+ @updates[:label_ids] += label_ids
+
+ @updates[:label_ids].uniq!
+ end
+ end
+
+ desc 'Add a todo'
+ explanation 'Adds a todo.'
+ condition do
+ issuable.persisted? &&
+ !TodoService.new.todo_exist?(issuable, current_user)
+ end
+ command :todo do
+ @updates[:todo_event] = 'add'
+ end
+
+ desc 'Mark todo as done'
+ explanation 'Marks todo as done.'
+ condition do
+ issuable.persisted? &&
+ TodoService.new.todo_exist?(issuable, current_user)
+ end
+ command :done do
+ @updates[:todo_event] = 'done'
+ end
+
+ desc 'Subscribe'
+ explanation do
+ "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
+ condition do
+ issuable.persisted? &&
+ !issuable.subscribed?(current_user, project)
+ end
+ command :subscribe do
+ @updates[:subscription_event] = 'subscribe'
+ end
+
+ desc 'Unsubscribe'
+ explanation do
+ "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.subscribed?(current_user, project)
+ end
+ command :unsubscribe do
+ @updates[:subscription_event] = 'unsubscribe'
+ end
+
+ desc 'Set due date'
+ explanation do |due_date|
+ "Sets the due date to #{due_date.to_s(:medium)}." if due_date
+ end
+ params '<in 2 days | this Friday | December 31st>'
+ condition do
+ issuable.respond_to?(:due_date) &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ parse_params do |due_date_param|
+ Chronic.parse(due_date_param).try(:to_date)
+ end
+ command :due do |due_date|
+ @updates[:due_date] = due_date if due_date
+ end
+
+ desc 'Remove due date'
+ explanation 'Removes the due date.'
+ condition do
+ issuable.persisted? &&
+ issuable.respond_to?(:due_date) &&
+ issuable.due_date? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_due_date do
+ @updates[:due_date] = nil
+ end
+
+ desc 'Toggle the Work In Progress status'
+ explanation do
+ verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
+ noun = issuable.to_ability_name.humanize(capitalize: false)
+ "#{verb} this #{noun} as Work In Progress."
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.respond_to?(:work_in_progress?) &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :wip do
+ @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
+ end
+
+ desc 'Toggle emoji award'
+ explanation do |name|
+ "Toggles :#{name}: emoji award." if name
+ end
+ params ':emoji:'
+ condition do
+ issuable.persisted?
+ end
+ parse_params do |emoji_param|
+ match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
+ match[1] if match
+ end
+ command :award do |name|
+ if name && issuable.user_can_award?(current_user, name)
+ @updates[:emoji_award] = name
+ end
+ end
+
+ desc 'Set time estimate'
+ explanation do |time_estimate|
+ time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)
+
+ "Sets time estimate to #{time_estimate}." if time_estimate
+ end
+ params '<1w 3d 2h 14m>'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ parse_params do |raw_duration|
+ Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ end
+ command :estimate do |time_estimate|
+ if time_estimate
+ @updates[:time_estimate] = time_estimate
+ end
+ end
+
+ desc 'Add or substract spent time'
+ explanation do |time_spent|
+ if time_spent
+ if time_spent > 0
+ verb = 'Adds'
+ value = time_spent
+ else
+ verb = 'Substracts'
+ value = -time_spent
+ end
+
+ "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
+ end
+ end
+ params '<1h 30m | -1h 30m>'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
+ end
+ parse_params do |raw_duration|
+ Gitlab::TimeTrackingFormatter.parse(raw_duration)
+ end
+ command :spend do |time_spent|
+ if time_spent
+ @updates[:spend_time] = { duration: time_spent, user: current_user }
+ end
+ end
+
+ desc 'Remove time estimate'
+ explanation 'Removes time estimate.'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_estimate do
+ @updates[:time_estimate] = 0
+ end
+
+ desc 'Remove spent time'
+ explanation 'Removes spent time.'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_time_spent do
+ @updates[:spend_time] = { duration: :reset, user: current_user }
+ end
+
+ desc "Append the comment with #{SHRUG}"
+ params '<Comment>'
+ substitution :shrug do |comment|
+ "#{comment} #{SHRUG}"
+ end
+
+ desc "Append the comment with #{TABLEFLIP}"
+ params '<Comment>'
+ substitution :tableflip do |comment|
+ "#{comment} #{TABLEFLIP}"
+ end
+
+ # This is a dummy command, so that it appears in the autocomplete commands
+ desc 'CC'
+ params '@user'
+ command :cc
+
+ desc 'Set target branch'
+ explanation do |branch_name|
+ "Sets target branch to #{branch_name}."
+ end
+ params '<Local branch name>'
+ condition do
+ issuable.respond_to?(:target_branch) &&
+ (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
+ issuable.new_record?)
+ end
+ parse_params do |target_branch_param|
+ target_branch_param.strip
+ end
+ command :target_branch do |branch_name|
+ @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name)
+ end
+
+ desc 'Move issue from one column of the board to another'
+ explanation do |target_list_name|
+ label = find_label_references(target_list_name).first
+ "Moves issue to #{label} column in the board." if label
+ end
+ params '~"Target column"'
+ condition do
+ issuable.is_a?(Issue) &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable) &&
+ issuable.project.boards.count == 1
+ end
+ command :board_move do |target_list_name|
+ label_ids = find_label_ids(target_list_name)
+
+ if label_ids.size == 1
+ label_id = label_ids.first
+
+ # Ensure this label corresponds to a list on the board
+ next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists?
+
+ @updates[:remove_label_ids] =
+ issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id)
+ @updates[:add_label_ids] = [label_id]
+ end
+ end
+
+ desc 'Mark this issue as a duplicate of another issue'
+ explanation do |duplicate_reference|
+ "Marks this issue as a duplicate of #{duplicate_reference}."
+ end
+ params '#issue'
+ condition do
+ issuable.is_a?(Issue) &&
+ issuable.persisted? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :duplicate do |duplicate_param|
+ canonical_issue = extract_references(duplicate_param, :issue).first
+
+ if canonical_issue.present?
+ @updates[:canonical_issue_id] = canonical_issue.id
+ end
+ end
+
+ def extract_users(params)
+ return [] if params.nil?
+
+ users = extract_references(params, :user)
+
+ if users.empty?
+ users =
+ if params == 'me'
+ [current_user]
+ else
+ User.where(username: params.split(' ').map(&:strip))
+ end
+ end
+
+ users
+ end
+
+ def find_labels(labels_param)
+ extract_references(labels_param, :label) |
+ LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
+ end
+
+ def find_label_references(labels_param)
+ find_labels(labels_param).map(&:to_reference)
+ end
+
+ def find_label_ids(labels_param)
+ find_labels(labels_param).map(&:id)
+ end
+
+ def explain_commands(commands, opts)
+ commands.map do |name, arg|
+ definition = self.class.definition_by_name(name)
+ next unless definition
+
+ definition.explain(self, opts, arg)
+ end.compact
+ end
+
+ def extract_updates(commands, opts)
+ commands.each do |name, arg|
+ definition = self.class.definition_by_name(name)
+ next unless definition
+
+ definition.execute(self, opts, arg)
+ end
+ end
+
+ def extract_references(arg, type)
+ ext = Gitlab::ReferenceExtractor.new(project, current_user)
+ ext.analyze(arg, author: current_user)
+
+ ext.references(type)
+ end
+
+ def context
+ {
+ issuable: issuable,
+ current_user: current_user,
+ project: project,
+ params: params
+ }
+ end
+ end
+end
diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb
index 4f161beea4d..85da0be6fff 100644
--- a/app/services/search/snippet_service.rb
+++ b/app/services/search/snippet_service.rb
@@ -7,7 +7,7 @@ module Search
end
def execute
- snippets = Snippet.accessible_to(current_user)
+ snippets = SnippetsFinder.new(current_user).execute
Gitlab::SnippetSearchResults.new(snippets, params[:search])
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 22736c71725..1d4d03a8b7d 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -12,7 +12,7 @@ class SearchService
@project =
if params[:project_id].present?
the_project = Project.find_by(id: params[:project_id])
- can?(current_user, :download_code, the_project) ? the_project : nil
+ can?(current_user, :read_project, the_project) ? the_project : nil
else
nil
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
deleted file mode 100644
index 49d45ec9dbd..00000000000
--- a/app/services/slash_commands/interpret_service.rb
+++ /dev/null
@@ -1,352 +0,0 @@
-module SlashCommands
- class InterpretService < BaseService
- include Gitlab::SlashCommands::Dsl
-
- attr_reader :issuable, :options
-
- # 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 = {}
-
- opts = {
- issuable: issuable,
- current_user: current_user,
- project: project,
- params: params
- }
-
- content, commands = extractor.extract_commands(content, opts)
-
- commands.each do |name, arg|
- definition = self.class.command_definitions_by_name[name.to_sym]
- next unless definition
-
- definition.execute(self, opts, arg)
- end
-
- [content, @updates]
- end
-
- private
-
- def extractor
- Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
- end
-
- desc do
- "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- condition do
- issuable.persisted? &&
- issuable.open? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :close do
- @updates[:state_event] = 'close'
- end
-
- desc do
- "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- condition do
- issuable.persisted? &&
- issuable.closed? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :reopen do
- @updates[:state_event] = 'reopen'
- end
-
- desc 'Merge (when the pipeline succeeds)'
- condition do
- last_diff_sha = params && params[:merge_request_diff_head_sha]
- issuable.is_a?(MergeRequest) &&
- issuable.persisted? &&
- issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
- end
- command :merge do
- @updates[:merge] = params[:merge_request_diff_head_sha]
- end
-
- desc 'Change title'
- params '<New title>'
- condition do
- issuable.persisted? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :title do |title_param|
- @updates[:title] = title_param
- end
-
- desc 'Assign'
- params '@user'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :assign do |assignee_param|
- user = extract_references(assignee_param, :user).first
- user ||= User.find_by(username: assignee_param)
-
- @updates[:assignee_id] = user.id if user
- end
-
- desc 'Remove assignee'
- condition do
- issuable.persisted? &&
- issuable.assignee_id? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :unassign do
- @updates[:assignee_id] = nil
- end
-
- desc 'Set milestone'
- params '%"milestone"'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- project.milestones.active.any?
- end
- command :milestone do |milestone_param|
- milestone = extract_references(milestone_param, :milestone).first
- milestone ||= project.milestones.find_by(title: milestone_param.strip)
-
- @updates[:milestone_id] = milestone.id if milestone
- end
-
- desc 'Remove milestone'
- condition do
- issuable.persisted? &&
- issuable.milestone_id? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_milestone do
- @updates[:milestone_id] = nil
- end
-
- desc 'Add label(s)'
- params '~label1 ~"label 2"'
- condition do
- available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
-
- current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- available_labels.any?
- end
- command :label do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:add_label_ids] ||= []
- @updates[:add_label_ids] += label_ids
-
- @updates[:add_label_ids].uniq!
- end
- end
-
- desc 'Remove all or specific label(s)'
- params '~label1 ~"label 2"'
- condition do
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :unlabel do |labels_param = nil|
- if labels_param.present?
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:remove_label_ids] ||= []
- @updates[:remove_label_ids] += label_ids
-
- @updates[:remove_label_ids].uniq!
- end
- else
- @updates[:label_ids] = []
- end
- end
-
- desc 'Replace all label(s)'
- params '~label1 ~"label 2"'
- condition do
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :relabel do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:label_ids] ||= []
- @updates[:label_ids] += label_ids
-
- @updates[:label_ids].uniq!
- end
- end
-
- desc 'Add a todo'
- condition do
- issuable.persisted? &&
- !TodoService.new.todo_exist?(issuable, current_user)
- end
- command :todo do
- @updates[:todo_event] = 'add'
- end
-
- desc 'Mark todo as done'
- condition do
- issuable.persisted? &&
- TodoService.new.todo_exist?(issuable, current_user)
- end
- command :done do
- @updates[:todo_event] = 'done'
- end
-
- desc 'Subscribe'
- condition do
- issuable.persisted? &&
- !issuable.subscribed?(current_user, project)
- end
- command :subscribe do
- @updates[:subscription_event] = 'subscribe'
- end
-
- desc 'Unsubscribe'
- condition do
- issuable.persisted? &&
- issuable.subscribed?(current_user, project)
- end
- command :unsubscribe do
- @updates[:subscription_event] = 'unsubscribe'
- end
-
- desc 'Set due date'
- params '<in 2 days | this Friday | December 31st>'
- condition do
- issuable.respond_to?(:due_date) &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :due do |due_date_param|
- due_date = Chronic.parse(due_date_param).try(:to_date)
-
- @updates[:due_date] = due_date if due_date
- end
-
- desc 'Remove due date'
- condition do
- issuable.persisted? &&
- issuable.respond_to?(:due_date) &&
- issuable.due_date? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_due_date do
- @updates[:due_date] = nil
- end
-
- desc do
- "Toggle the Work In Progress status"
- end
- condition do
- issuable.persisted? &&
- issuable.respond_to?(:work_in_progress?) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :wip do
- @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
- end
-
- desc 'Toggle emoji reward'
- params ':emoji:'
- condition do
- issuable.persisted?
- end
- command :award do |emoji|
- name = award_emoji_name(emoji)
- if name && issuable.user_can_award?(current_user, name)
- @updates[:emoji_award] = name
- end
- end
-
- desc 'Set time estimate'
- params '<1w 3d 2h 14m>'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :estimate do |raw_duration|
- time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
-
- if time_estimate
- @updates[:time_estimate] = time_estimate
- end
- end
-
- desc 'Add or substract spent time'
- params '<1h 30m | -1h 30m>'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :spend do |raw_duration|
- time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
-
- if time_spent
- @updates[:spend_time] = { duration: time_spent, user: current_user }
- end
- end
-
- desc 'Remove time estimate'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_estimate do
- @updates[:time_estimate] = 0
- end
-
- desc 'Remove spent time'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_time_spent do
- @updates[:spend_time] = { duration: :reset, user: current_user }
- end
-
- # This is a dummy command, so that it appears in the autocomplete commands
- desc 'CC'
- params '@user'
- command :cc
-
- desc 'Defines target branch for MR'
- params '<Local branch name>'
- condition do
- issuable.respond_to?(:target_branch) &&
- (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
- issuable.new_record?)
- end
- command :target_branch do |target_branch_param|
- branch_name = target_branch_param.strip
- @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name)
- end
-
- def find_label_ids(labels_param)
- label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
- labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id)
-
- label_ids_by_reference | labels_ids_by_name
- end
-
- def extract_references(arg, type)
- ext = Gitlab::ReferenceExtractor.new(project, current_user)
- ext.analyze(arg, author: current_user)
-
- ext.references(type)
- end
-
- def award_emoji_name(emoji)
- match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern)
- match[1] if match
- end
- end
-end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
new file mode 100644
index 00000000000..14171bce782
--- /dev/null
+++ b/app/services/submit_usage_ping_service.rb
@@ -0,0 +1,43 @@
+class SubmitUsagePingService
+ URL = 'https://version.gitlab.com/usage_data'.freeze
+
+ METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
+ percentage_notes leader_milestones instance_milestones percentage_milestones
+ leader_boards instance_boards percentage_boards leader_merge_requests
+ instance_merge_requests percentage_merge_requests leader_ci_pipelines
+ instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
+ percentage_environments leader_deployments instance_deployments percentage_deployments
+ leader_projects_prometheus_active instance_projects_prometheus_active
+ percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
+ percentage_service_desk_issues].freeze
+
+ include Gitlab::CurrentSettings
+
+ def execute
+ return false unless current_application_settings.usage_ping_enabled?
+
+ response = HTTParty.post(
+ URL,
+ body: Gitlab::UsageData.to_json(force_refresh: true),
+ headers: { 'Content-type' => 'application/json' }
+ )
+
+ store_metrics(response)
+
+ true
+ rescue HTTParty::Error => e
+ Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
+
+ false
+ end
+
+ private
+
+ def store_metrics(response)
+ return unless response['conv_index'].present?
+
+ ConversationalDevelopmentIndex::Metric.create!(
+ response['conv_index'].slice(*METRICS)
+ )
+ end
+end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index af0ddbe5934..a1c2f8d0180 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -4,7 +4,7 @@ class SystemHooksService
end
def execute_hooks(data, hooks_scope = :all)
- SystemHook.send(hooks_scope).each do |hook|
+ SystemHook.public_send(hooks_scope).find_each do |hook| # rubocop:disable GitlabSecurity/PublicSend
hook.async_execute(data, 'system_hooks')
end
end
@@ -24,7 +24,7 @@ class SystemHooksService
key: model.key,
id: model.id
)
-
+
if model.user
data[:username] = model.user.username
end
@@ -51,12 +51,12 @@ class SystemHooksService
path: model.path,
group_id: model.id,
owner_name: owner.respond_to?(:name) ? owner.name : nil,
- owner_email: owner.respond_to?(:email) ? owner.email : nil,
+ owner_email: owner.respond_to?(:email) ? owner.email : nil
)
when GroupMember
data.merge!(group_member_data(model))
end
-
+
data
end
@@ -79,7 +79,7 @@ class SystemHooksService
{
name: model.name,
path: model.path,
- path_with_namespace: model.path_with_namespace,
+ path_with_namespace: model.full_path,
project_id: model.id,
owner_name: owner.name,
owner_email: owner.respond_to?(:email) ? owner.email : "",
@@ -93,7 +93,7 @@ class SystemHooksService
{
project_name: project.name,
project_path: project.path,
- project_path_with_namespace: project.path_with_namespace,
+ project_path_with_namespace: project.full_path,
project_id: project.id,
user_username: model.user.username,
user_name: model.user.name,
@@ -113,7 +113,7 @@ class SystemHooksService
user_name: model.user.name,
user_email: model.user.email,
user_id: model.user.id,
- group_access: model.human_access,
+ group_access: model.human_access
}
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index c9e25c7aaa2..1763f64a4e4 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -49,6 +49,44 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
+ # Called when the assignees of an Issue is changed or removed
+ #
+ # issue - Issue object
+ # project - Project owning noteable
+ # author - User performing the change
+ # assignees - Users being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed all assignees"
+ #
+ # "assigned to @user1 additionally to @user2"
+ #
+ # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
+ #
+ # "assigned to @user1 and @user2"
+ #
+ # Returns the created Note object
+ def change_issue_assignees(issue, project, author, old_assignees)
+ body =
+ if issue.assignees.any? && old_assignees.any?
+ unassigned_users = old_assignees - issue.assignees
+ added_users = issue.assignees.to_a - old_assignees
+
+ text_parts = []
+ text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+
+ text_parts.join(' and ')
+ elsif old_assignees.any?
+ "removed assignee"
+ elsif issue.assignees.any?
+ "assigned to #{issue.assignees.map(&:to_reference).to_sentence}"
+ end
+
+ create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
+ end
+
# Called when one or more labels on a Noteable are added and/or removed
#
# noteable - Noteable object
@@ -104,7 +142,8 @@ module SystemNoteService
#
# Returns the created Note object
def change_milestone(noteable, project, author, milestone)
- body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project)}"
+ format = milestone&.is_group_milestone? ? :name : :iid
+ body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone'))
end
@@ -220,7 +259,7 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
- def self.resolve_all_discussions(merge_request, project, author)
+ def resolve_all_discussions(merge_request, project, author)
body = "resolved all discussions"
create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion'))
@@ -236,6 +275,28 @@ module SystemNoteService
note
end
+ def diff_discussion_outdated(discussion, project, author, change_position)
+ merge_request = discussion.noteable
+ diff_refs = change_position.diff_refs
+ version_index = merge_request.merge_request_diffs.viewable.count
+
+ body = "changed this line in"
+ if version_params = merge_request.version_params_for(diff_refs)
+ line_code = change_position.line_code(project.repository)
+ url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code))
+
+ body << " [version #{version_index} of the diff](#{url})"
+ else
+ body << " version #{version_index} of the diff"
+ end
+
+ note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
+ note = Note.create(note_attributes.merge(system: true))
+ note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated')
+
+ note
+ end
+
# Called when the title of a Noteable is changed
#
# noteable - Noteable object that responds to `title`
@@ -253,14 +314,31 @@ module SystemNoteService
old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs
- marked_old_title = Gitlab::Diff::InlineDiffMarker.new(old_title).mark(old_diffs, mode: :deletion, markdown: true)
- marked_new_title = Gitlab::Diff::InlineDiffMarker.new(new_title).mark(new_diffs, mode: :addition, markdown: true)
+ marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion)
+ marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition)
body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
+ # Called when the description of a Noteable is changed
+ #
+ # noteable - Noteable object that responds to `description`
+ # project - Project owning noteable
+ # author - User performing the change
+ #
+ # Example Note text:
+ #
+ # "changed the description"
+ #
+ # Returns the created Note object
+ def change_description(noteable, project, author)
+ body = 'changed the description'
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
+ end
+
# Called when the confidentiality changes
#
# issue - Issue object
@@ -336,7 +414,7 @@ module SystemNoteService
#
# "created branch `201-issue-branch-button`"
def new_issue_branch(issue, project, author, branch)
- link = url_helpers.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch)
+ link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch)
body = "created branch [`#{branch}`](#{link})"
@@ -475,6 +553,44 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
+ # Called when a Noteable has been marked as a duplicate of another Issue
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # canonical_issue - Issue that this is a duplicate of
+ #
+ # Example Note text:
+ #
+ # "marked this issue as a duplicate of #1234"
+ #
+ # "marked this issue as a duplicate of other_project#5678"
+ #
+ # Returns the created Note object
+ def mark_duplicate_issue(noteable, project, author, canonical_issue)
+ body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
+ # Called when a Noteable has been marked as the canonical Issue of a duplicate
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # duplicate_issue - Issue that was a duplicate of this
+ #
+ # Example Note text:
+ #
+ # "marked #1234 as a duplicate of this issue"
+ #
+ # "marked other_project#5678 as a duplicate of this issue"
+ #
+ # Returns the created Note object
+ def mark_canonical_issue_of_duplicate(noteable, project, author, duplicate_issue)
+ body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
private
def notes_for_mentioner(mentioner, noteable, notes)
@@ -553,10 +669,9 @@ module SystemNoteService
def diff_comparison_url(merge_request, project, oldrev)
diff_id = merge_request.merge_request_diff.id
- url_helpers.diffs_namespace_project_merge_request_url(
- project.namespace,
+ url_helpers.diffs_project_merge_request_url(
project,
- merge_request.iid,
+ merge_request,
diff_id: diff_id,
start_sha: oldrev
)
diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb
index 1756da9e519..674792f6138 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -19,8 +19,8 @@ module Tags
if new_tag
if release_description
- CreateReleaseService.new(@project, @current_user).
- execute(tag_name, release_description)
+ CreateReleaseService.new(@project, @current_user)
+ .execute(tag_name, release_description)
end
success.merge(tag: new_tag)
diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb
deleted file mode 100644
index 280c81f7d2d..00000000000
--- a/app/services/test_hook_service.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class TestHookService
- def execute(hook, current_user)
- data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user)
- hook.execute(data, 'push_hooks')
- end
-end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
new file mode 100644
index 00000000000..4abd2c44b2f
--- /dev/null
+++ b/app/services/test_hooks/base_service.rb
@@ -0,0 +1,41 @@
+module TestHooks
+ class BaseService
+ attr_accessor :hook, :current_user, :trigger
+
+ def initialize(hook, current_user, trigger)
+ @hook = hook
+ @current_user = current_user
+ @trigger = trigger
+ end
+
+ def execute
+ trigger_data_method = "#{trigger}_data"
+
+ if !self.respond_to?(trigger_data_method, true) ||
+ !hook.class::TRIGGERS.value?(trigger.to_sym)
+
+ return error('Testing not available for this hook')
+ end
+
+ error_message = catch(:validation_error) do
+ sample_data = self.__send__(trigger_data_method) # rubocop:disable GitlabSecurity/PublicSend
+
+ return hook.execute(sample_data, trigger)
+ end
+
+ error(error_message)
+ end
+
+ private
+
+ def error(message, http_status = nil)
+ result = {
+ message: message,
+ status: :error
+ }
+
+ result[:http_status] = http_status if http_status
+ result
+ end
+ end
+end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
new file mode 100644
index 00000000000..01d5d774cd5
--- /dev/null
+++ b/app/services/test_hooks/project_service.rb
@@ -0,0 +1,63 @@
+module TestHooks
+ class ProjectService < TestHooks::BaseService
+ private
+
+ def project
+ @project ||= hook.project
+ end
+
+ def push_events_data
+ throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo?
+
+ Gitlab::DataBuilder::Push.build_sample(project, current_user)
+ end
+
+ alias_method :tag_push_events_data, :push_events_data
+
+ def note_events_data
+ note = project.notes.first
+ throw(:validation_error, 'Ensure the project has notes.') unless note.present?
+
+ Gitlab::DataBuilder::Note.build(note, current_user)
+ end
+
+ def issues_events_data
+ issue = project.issues.first
+ throw(:validation_error, 'Ensure the project has issues.') unless issue.present?
+
+ issue.to_hook_data(current_user)
+ end
+
+ alias_method :confidential_issues_events_data, :issues_events_data
+
+ def merge_requests_events_data
+ merge_request = project.merge_requests.first
+ throw(:validation_error, 'Ensure the project has merge requests.') unless merge_request.present?
+
+ merge_request.to_hook_data(current_user)
+ end
+
+ def job_events_data
+ build = project.builds.first
+ throw(:validation_error, 'Ensure the project has CI jobs.') unless build.present?
+
+ Gitlab::DataBuilder::Build.build(build)
+ end
+
+ def pipeline_events_data
+ pipeline = project.pipelines.first
+ throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present?
+
+ Gitlab::DataBuilder::Pipeline.build(pipeline)
+ end
+
+ def wiki_page_events_data
+ page = project.wiki.pages.first
+ if !project.wiki_enabled? || page.blank?
+ throw(:validation_error, 'Ensure the wiki is enabled and has pages.')
+ end
+
+ Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
+ end
+ end
+end
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
new file mode 100644
index 00000000000..76c3c19bd74
--- /dev/null
+++ b/app/services/test_hooks/system_service.rb
@@ -0,0 +1,48 @@
+module TestHooks
+ class SystemService < TestHooks::BaseService
+ private
+
+ def project
+ @project ||= begin
+ project = Project.first
+
+ throw(:validation_error, 'Ensure that at least one project exists.') unless project
+
+ project
+ end
+ end
+
+ def push_events_data
+ if project.empty_repo?
+ throw(:validation_error, "Ensure project \"#{project.human_name}\" has commits.")
+ end
+
+ Gitlab::DataBuilder::Push.build_sample(project, current_user)
+ end
+
+ def tag_push_events_data
+ if project.repository.tags.empty?
+ throw(:validation_error, "Ensure project \"#{project.human_name}\" has tags.")
+ end
+
+ Gitlab::DataBuilder::Push.build_sample(project, current_user)
+ end
+
+ def repository_update_events_data
+ commit = project.commit
+ ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{project.default_branch}"
+
+ unless commit
+ throw(:validation_error, "Ensure project \"#{project.human_name}\" has commits.")
+ end
+
+ change = Gitlab::DataBuilder::Repository.single_change(
+ commit.parent_id || Gitlab::Git::BLANK_SHA,
+ commit.id,
+ ref
+ )
+
+ Gitlab::DataBuilder::Repository.update(project, current_user, [change], [ref])
+ end
+ end
+end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index b6e88b0280f..6ee96d6a0f8 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -170,20 +170,22 @@ class TodoService
# When user marks some todos as done
def mark_todos_as_done(todos, current_user)
- update_todos_state_by_ids(todos.select(&:id), current_user, :done)
+ update_todos_state(todos, current_user, :done)
end
def mark_todos_as_done_by_ids(ids, current_user)
- update_todos_state_by_ids(ids, current_user, :done)
+ todos = todos_by_ids(ids, current_user)
+ mark_todos_as_done(todos, current_user)
end
# When user marks some todos as pending
def mark_todos_as_pending(todos, current_user)
- update_todos_state_by_ids(todos.select(&:id), current_user, :pending)
+ update_todos_state(todos, current_user, :pending)
end
def mark_todos_as_pending_by_ids(ids, current_user)
- update_todos_state_by_ids(ids, current_user, :pending)
+ todos = todos_by_ids(ids, current_user)
+ mark_todos_as_pending(todos, current_user)
end
# When user marks an issue as todo
@@ -198,9 +200,11 @@ class TodoService
private
- def update_todos_state_by_ids(ids, current_user, state)
- todos = current_user.todos.where(id: ids)
+ def todos_by_ids(ids, current_user)
+ current_user.todos.where(id: Array(ids))
+ end
+ def update_todos_state(todos, current_user, state)
# Only update those that are not really on that state
todos = todos.where.not(state: state)
todos_ids = todos.pluck(:id)
@@ -251,9 +255,9 @@ class TodoService
end
def create_assignment_todo(issuable, author)
- if issuable.assignee
+ if issuable.assignees.any?
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
- create_todos(issuable.assignee, attributes)
+ create_todos(issuable.assignees, attributes)
end
end
@@ -281,7 +285,7 @@ class TodoService
def attributes_for_target(target)
attributes = {
- project_id: target.project.id,
+ project_id: target&.project&.id,
target_id: target.id,
target_type: target.class.name,
commit_id: nil
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
new file mode 100644
index 00000000000..6c5b2baff41
--- /dev/null
+++ b/app/services/upload_service.rb
@@ -0,0 +1,20 @@
+class UploadService
+ def initialize(model, file, uploader_class = FileUploader)
+ @model, @file, @uploader_class = model, file, uploader_class
+ end
+
+ def execute
+ return nil unless @file && @file.size <= max_attachment_size
+
+ uploader = @uploader_class.new(@model)
+ uploader.store!(@file)
+
+ uploader.to_h
+ end
+
+ private
+
+ def max_attachment_size
+ current_application_settings.max_attachment_size.megabytes.to_i
+ end
+end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index facf21a7f5c..ab532a1fdcf 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -16,7 +16,7 @@ module Users
def record_activity
Gitlab::UserActivities.record(@author.id)
- Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}")
+ Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})")
end
end
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 363135ef09b..ff234a3440f 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -1,5 +1,4 @@
module Users
- # Service for building a new user.
class BuildService < BaseService
def initialize(current_user, params = {})
@current_user = current_user
diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb
index e22f7225ae2..74abc017cea 100644
--- a/app/services/users/create_service.rb
+++ b/app/services/users/create_service.rb
@@ -1,5 +1,4 @@
module Users
- # Service for creating a new user.
class CreateService < BaseService
def initialize(current_user, params = {})
@current_user = current_user
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 9eb6a600f6b..9d7237c2fbb 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -6,12 +6,27 @@ module Users
@current_user = current_user
end
+ # Synchronously destroys +user+
+ #
+ # The operation will fail if the user is the sole owner of any groups. To
+ # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in
+ # +options+.
+ #
+ # The user's contributions will be migrated to a global ghost user. To
+ # force the contributions to be destroyed, pass `hard_delete: true` in
+ # +options+.
+ #
+ # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform
+ # a hard deletion without destroying solo-owned groups, pass
+ # `delete_solo_owned_groups: false, hard_delete: true` in +options+.
def execute(user, options = {})
+ delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
+
unless Ability.allowed?(current_user, :destroy_user, user)
raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!"
end
- if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present?
+ if !delete_solo_owned_groups && user.solo_owned_groups.present?
user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user'
return user
end
@@ -20,7 +35,7 @@ module Users
Groups::DestroyService.new(group, current_user).execute
end
- user.personal_projects.with_deleted.each do |project|
+ user.personal_projects.each do |project|
# Skip repository removal because we remove directory with namespace
# that contain all this repositories
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 4628c4c6f6e..3a9c151cf9b 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -50,10 +50,12 @@ module Users
def migrate_issues
user.issues.update_all(author_id: ghost_user.id)
+ Issue.where(last_edited_by_id: user.id).update_all(last_edited_by_id: ghost_user.id)
end
def migrate_merge_requests
user.merge_requests.update_all(author_id: ghost_user.id)
+ MergeRequest.where(merge_user_id: user.id).update_all(merge_user_id: ghost_user.id)
end
def migrate_notes
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index 8f6f5b937c4..f028f5eb0d4 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -34,7 +34,7 @@ module Users
# Keep trying until we obtain the lease. If we don't do so we may end up
# not updating the list of authorized projects properly. To prevent
# hammering Redis too much we'll wait for a bit between retries.
- sleep(1)
+ sleep(0.1)
end
begin
@@ -73,12 +73,11 @@ module Users
# remove - The IDs of the authorization rows to remove.
# add - Rows to insert in the form `[user id, project id, access level]`
def update_authorizations(remove = [], add = [])
- return if remove.empty? && add.empty? && user.authorized_projects_populated
+ return if remove.empty? && add.empty?
User.transaction do
user.remove_project_authorizations(remove) unless remove.empty?
ProjectAuthorization.insert_authorizations(add) unless add.empty?
- user.set_authorized_projects_column
end
# Since we batch insert authorization rows, Rails' associations may get
@@ -101,38 +100,13 @@ module Users
end
def fresh_authorizations
- ProjectAuthorization.
- unscoped.
- select('project_id, MAX(access_level) AS access_level').
- from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}").
- group(:project_id)
- end
-
- private
-
- # Returns a union query of projects that the user is authorized to access
- def project_authorizations_union
- relations = [
- # Personal projects
- user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"),
-
- # Projects the user is a member of
- user.projects.select_for_project_authorization,
-
- # Projects of groups the user is a member of
- user.groups_projects.select_for_project_authorization,
-
- # Projects of subgroups of groups the user is a member of
- user.nested_groups_projects.select_for_project_authorization,
-
- # Projects shared with groups the user is a member of
- user.groups.joins(:shared_projects).select_for_project_authorization,
-
- # Projects shared with subgroups of groups the user is a member of
- user.nested_groups.joins(:shared_projects).select_for_project_authorization
- ]
+ klass = if Group.supports_nested_groups?
+ Gitlab::ProjectAuthorizations::WithNestedGroups
+ else
+ Gitlab::ProjectAuthorizations::WithoutNestedGroups
+ end
- Gitlab::SQL::Union.new(relations)
+ klass.new(user).calculate
end
end
end
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
new file mode 100644
index 00000000000..dfbd6016c3f
--- /dev/null
+++ b/app/services/users/update_service.rb
@@ -0,0 +1,34 @@
+module Users
+ class UpdateService < BaseService
+ def initialize(user, params = {})
+ @user = user
+ @params = params.dup
+ end
+
+ def execute(validate: true, &block)
+ yield(@user) if block_given?
+
+ assign_attributes(&block)
+
+ if @user.save(validate: validate)
+ success
+ else
+ error(@user.errors.full_messages.uniq.join('. '))
+ end
+ end
+
+ def execute!(*args, &block)
+ result = execute(*args, &block)
+
+ raise ActiveRecord::RecordInvalid.new(@user) unless result[:status] == :success
+
+ true
+ end
+
+ private
+
+ def assign_attributes(&block)
+ @user.assign_attributes(params) if params.any?
+ end
+ end
+end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
new file mode 100644
index 00000000000..2825478926a
--- /dev/null
+++ b/app/services/web_hook_service.rb
@@ -0,0 +1,133 @@
+class WebHookService
+ class InternalErrorResponse
+ attr_reader :body, :headers, :code
+
+ def initialize
+ @headers = HTTParty::Response::Headers.new({})
+ @body = ''
+ @code = 'internal error'
+ end
+ end
+
+ include HTTParty
+
+ # HTTParty timeout
+ default_timeout Gitlab.config.gitlab.webhook_timeout
+
+ attr_accessor :hook, :data, :hook_name
+
+ def initialize(hook, data, hook_name)
+ @hook = hook
+ @data = data
+ @hook_name = hook_name
+ end
+
+ def execute
+ start_time = Time.now
+
+ response = if parsed_url.userinfo.blank?
+ make_request(hook.url)
+ else
+ make_request_with_auth
+ end
+
+ log_execution(
+ trigger: hook_name,
+ url: hook.url,
+ request_data: data,
+ response: response,
+ execution_duration: Time.now - start_time
+ )
+
+ {
+ status: :success,
+ http_status: response.code,
+ message: response.to_s
+ }
+ rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
+ log_execution(
+ trigger: hook_name,
+ url: hook.url,
+ request_data: data,
+ response: InternalErrorResponse.new,
+ execution_duration: Time.now - start_time,
+ error_message: e.to_s
+ )
+
+ Rails.logger.error("WebHook Error => #{e}")
+
+ {
+ status: :error,
+ message: e.to_s
+ }
+ end
+
+ def async_execute
+ Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name)
+ end
+
+ private
+
+ def parsed_url
+ @parsed_url ||= URI.parse(hook.url)
+ end
+
+ def make_request(url, basic_auth = false)
+ self.class.post(url,
+ body: data.to_json,
+ headers: build_headers(hook_name),
+ verify: hook.enable_ssl_verification,
+ basic_auth: basic_auth)
+ end
+
+ def make_request_with_auth
+ post_url = hook.url.gsub("#{parsed_url.userinfo}@", '')
+ basic_auth = {
+ username: CGI.unescape(parsed_url.user),
+ password: CGI.unescape(parsed_url.password)
+ }
+ make_request(post_url, basic_auth)
+ end
+
+ def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil)
+ # logging for ServiceHook's is not available
+ return if hook.is_a?(ServiceHook)
+
+ WebHookLog.create(
+ web_hook: hook,
+ trigger: trigger,
+ url: url,
+ execution_duration: execution_duration,
+ request_headers: build_headers(hook_name),
+ request_data: request_data,
+ response_headers: format_response_headers(response),
+ response_body: safe_response_body(response),
+ response_status: response.code,
+ internal_error_message: error_message
+ )
+ end
+
+ def build_headers(hook_name)
+ @headers ||= begin
+ {
+ 'Content-Type' => 'application/json',
+ 'X-Gitlab-Event' => hook_name.singularize.titleize
+ }.tap do |hash|
+ hash['X-Gitlab-Token'] = hook.token if hook.token.present?
+ end
+ end
+ end
+
+ # Make response headers more stylish
+ # Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] }
+ # This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' }
+ def format_response_headers(response)
+ response.headers.each_capitalized.to_h
+ end
+
+ def safe_response_body(response)
+ return '' unless response.body
+
+ response.body.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
+ end
+end
diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb
index 14317ea65c8..260c04a8b94 100644
--- a/app/services/wiki_pages/base_service.rb
+++ b/app/services/wiki_pages/base_service.rb
@@ -1,23 +1,9 @@
module WikiPages
class BaseService < ::BaseService
- def hook_data(page, action)
- hook_data = {
- object_kind: page.class.name.underscore,
- user: current_user.hook_attrs,
- project: @project.hook_attrs,
- wiki: @project.wiki.hook_attrs,
- object_attributes: page.hook_attrs
- }
-
- page_url = Gitlab::UrlBuilder.build(page)
- hook_data[:object_attributes].merge!(url: page_url, action: action)
- hook_data
- end
-
private
def execute_hooks(page, action = 'create')
- page_data = hook_data(page, action)
+ page_data = Gitlab::DataBuilder::WikiPage.build(page, current_user, action)
@project.execute_hooks(page_data, :wiki_page_hooks)
@project.execute_services(page_data, :wiki_page_hooks)
end
diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb
index 8f6a50da838..93cbd9a509f 100644
--- a/app/services/wiki_pages/update_service.rb
+++ b/app/services/wiki_pages/update_service.rb
@@ -1,7 +1,7 @@
module WikiPages
class UpdateService < WikiPages::BaseService
def execute(page)
- if page.update(@params[:content], @params[:format], @params[:message])
+ if page.update(@params)
execute_hooks(page, 'update')
end
diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb
index e84944ed411..14addb6cf14 100644
--- a/app/uploaders/artifact_uploader.rb
+++ b/app/uploaders/artifact_uploader.rb
@@ -1,37 +1,39 @@
class ArtifactUploader < GitlabUploader
storage :file
- attr_accessor :build, :field
+ attr_reader :job, :field
- def self.artifacts_path
+ def self.local_artifacts_store
Gitlab.config.artifacts.path
end
def self.artifacts_upload_path
- File.join(self.artifacts_path, 'tmp/uploads/')
+ File.join(self.local_artifacts_store, 'tmp/uploads/')
end
- def self.artifacts_cache_path
- File.join(self.artifacts_path, 'tmp/cache/')
- end
-
- def initialize(build, field)
- @build, @field = build, field
+ def initialize(job, field)
+ @job, @field = job, field
end
def store_dir
- File.join(self.class.artifacts_path, @build.artifacts_path)
+ default_local_path
end
def cache_dir
- File.join(self.class.artifacts_cache_path, @build.artifacts_path)
+ File.join(self.class.local_artifacts_store, 'tmp/cache')
end
- def filename
- file.try(:filename)
+ def work_dir
+ File.join(self.class.local_artifacts_store, 'tmp/work')
+ end
+
+ private
+
+ def default_local_path
+ File.join(self.class.local_artifacts_store, default_path)
end
- def exists?
- file.try(:exists?)
+ def default_path
+ File.join(job.created_at.utc.strftime('%Y_%m'), job.project_id.to_s, job.id.to_s)
end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
new file mode 100644
index 00000000000..00c2888d224
--- /dev/null
+++ b/app/uploaders/file_mover.rb
@@ -0,0 +1,63 @@
+class FileMover
+ attr_reader :secret, :file_name, :model, :update_field
+
+ def initialize(file_path, model, update_field = :description)
+ @secret = File.split(File.dirname(file_path)).last
+ @file_name = File.basename(file_path)
+ @model = model
+ @update_field = update_field
+ end
+
+ def execute
+ move
+ uploader.record_upload if update_markdown
+ end
+
+ private
+
+ def move
+ FileUtils.mkdir_p(File.dirname(file_path))
+ FileUtils.move(temp_file_path, file_path)
+ end
+
+ def update_markdown
+ updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
+ model.update_attribute(update_field, updated_text)
+
+ true
+ rescue
+ revert
+
+ false
+ end
+
+ def temp_file_path
+ return @temp_file_path if @temp_file_path
+
+ temp_file_uploader.retrieve_from_store!(file_name)
+
+ @temp_file_path = temp_file_uploader.file.path
+ end
+
+ def file_path
+ return @file_path if @file_path
+
+ uploader.retrieve_from_store!(file_name)
+
+ @file_path = uploader.file.path
+ end
+
+ def uploader
+ @uploader ||= PersonalFileUploader.new(model, secret)
+ end
+
+ def temp_file_uploader
+ @temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
+ end
+
+ def revert
+ Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
+
+ FileUtils.move(file_path, temp_file_path)
+ end
+end
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index d2783ce5b2f..7027ac4b5db 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -13,6 +13,13 @@ class FileUploader < GitlabUploader
)
end
+ # Not using `GitlabUploader.base_dir` because all project namespaces are in
+ # the `public/uploads` dir.
+ #
+ def self.base_dir
+ root_dir
+ end
+
# Returns the part of `store_dir` that can change based on the model's current
# path
#
@@ -23,14 +30,14 @@ class FileUploader < GitlabUploader
#
# Returns a String without a trailing slash
def self.dynamic_path_segment(model)
- File.join(CarrierWave.root, base_dir, model.path_with_namespace)
+ File.join(CarrierWave.root, base_dir, model.full_path)
end
- attr_accessor :project
+ attr_accessor :model
attr_reader :secret
- def initialize(project, secret = nil)
- @project = project
+ def initialize(model, secret = nil)
+ @model = model
@secret = secret || generate_secret
end
@@ -38,10 +45,6 @@ class FileUploader < GitlabUploader
File.join(dynamic_path_segment, @secret)
end
- def model
- project
- end
-
def relative_path
self.file.path.sub("#{dynamic_path_segment}/", '')
end
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index d662ba6820c..05a2091633a 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -3,14 +3,30 @@ class GitlabUploader < CarrierWave::Uploader::Base
File.join(CarrierWave.root, upload_record.path)
end
- def self.base_dir
+ def self.root_dir
'uploads'
end
- delegate :base_dir, to: :class
+ # When object storage is used, keep the `root_dir` as `base_dir`.
+ # The files aren't really in folders there, they just have a name.
+ # The files that contain user input in their name, also contain a hash, so
+ # the names are still unique
+ #
+ # This method is overridden in the `FileUploader`
+ def self.base_dir
+ return root_dir unless file_storage?
+
+ File.join(root_dir, '-', 'system')
+ end
- def file_storage?
- self.class.storage == CarrierWave::Storage::File
+ def self.file_storage?
+ self.storage == CarrierWave::Storage::File
+ end
+
+ delegate :base_dir, :file_storage?, to: :class
+
+ def file_cache_storage?
+ cache_storage.is_a?(CarrierWave::Storage::File)
end
# Reduce disk IO
@@ -33,4 +49,31 @@ class GitlabUploader < CarrierWave::Uploader::Base
def relative_path
self.file.path.sub("#{root}/", '')
end
+
+ def exists?
+ file.try(:exists?)
+ end
+
+ # Override this if you don't want to save files by default to the Rails.root directory
+ def work_dir
+ # Default path set by CarrierWave:
+ # https://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L182
+ CarrierWave.tmp_path
+ end
+
+ def filename
+ super || file&.filename
+ end
+
+ private
+
+ # To prevent files from moving across filesystems, override the default
+ # implementation:
+ # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183
+ def workfile_path(for_file = original_filename)
+ # To be safe, keep this directory outside of the the cache directory
+ # because calling CarrierWave.clean_cache_files! will remove any files in
+ # the cache directory.
+ File.join(work_dir, @cache_id, version_name.to_s, for_file)
+ end
end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index faab539b8e0..d11ebf0f9ca 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -9,11 +9,11 @@ class LfsObjectUploader < GitlabUploader
"#{Gitlab.config.lfs.storage_path}/tmp/cache"
end
- def exists?
- file.try(:exists?)
- end
-
def filename
model.oid[4..-1]
end
+
+ def work_dir
+ File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ end
end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
new file mode 100644
index 00000000000..3298ad104ec
--- /dev/null
+++ b/app/uploaders/personal_file_uploader.rb
@@ -0,0 +1,23 @@
+class PersonalFileUploader < FileUploader
+ def self.dynamic_path_segment(model)
+ File.join(CarrierWave.root, model_path(model))
+ end
+
+ def self.base_dir
+ File.join(root_dir, '-', 'system')
+ end
+
+ private
+
+ def secure_url
+ File.join(self.class.model_path(model), secret, file.filename)
+ end
+
+ def self.model_path(model)
+ if model
+ File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ else
+ File.join("/#{base_dir}", 'temp')
+ end
+ end
+end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 4c127f29250..feb4f04d7b7 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -6,8 +6,6 @@ module RecordsUploads
before :remove, :destroy_upload
end
- private
-
# After storing an attachment, create a corresponding Upload record
#
# NOTE: We're ignoring the argument passed to this callback because we want
@@ -15,13 +13,16 @@ module RecordsUploads
# `Tempfile` object the callback gets.
#
# Called `after :store`
- def record_upload(_tempfile)
+ def record_upload(_tempfile = nil)
+ return unless model
return unless file_storage?
return unless file.exists?
Upload.record(self)
end
+ private
+
# Before removing an attachment, destroy any Upload records at the same path
#
# Called `before :remove`
diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb
new file mode 100644
index 00000000000..4688aabc2a8
--- /dev/null
+++ b/app/validators/dynamic_path_validator.rb
@@ -0,0 +1,53 @@
+# DynamicPathValidator
+#
+# Custom validator for GitLab path values.
+# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project`
+#
+# Values are checked for formatting and exclusion from a list of illegal path
+# names.
+class DynamicPathValidator < ActiveModel::EachValidator
+ extend Gitlab::EncodingHelper
+
+ class << self
+ def valid_user_path?(path)
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.root_namespace_path_regex
+ end
+
+ def valid_group_path?(path)
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.full_namespace_path_regex
+ end
+
+ def valid_project_path?(path)
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex
+ end
+ end
+
+ def path_valid_for_record?(record, value)
+ full_path = record.respond_to?(:build_full_path) ? record.build_full_path : value
+
+ return true unless full_path
+
+ case record
+ when Project
+ self.class.valid_project_path?(full_path)
+ when Group
+ self.class.valid_group_path?(full_path)
+ else # User or non-Group Namespace
+ self.class.valid_user_path?(full_path)
+ end
+ end
+
+ def validate_each(record, attribute, value)
+ unless value =~ Gitlab::PathRegex.namespace_format_regex
+ record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message)
+ return
+ end
+
+ unless path_valid_for_record?(record, value)
+ record.errors.add(attribute, "#{value} is a reserved name")
+ end
+ end
+end
diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb
deleted file mode 100644
index 77ca033e97f..00000000000
--- a/app/validators/namespace_validator.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# NamespaceValidator
-#
-# Custom validator for GitLab namespace values.
-#
-# Values are checked for formatting and exclusion from a list of reserved path
-# names.
-class NamespaceValidator < ActiveModel::EachValidator
- RESERVED = %w[
- .well-known
- admin
- all
- assets
- ci
- dashboard
- files
- groups
- help
- hooks
- issues
- merge_requests
- new
- notes
- profile
- projects
- public
- repository
- robots.txt
- s
- search
- services
- snippets
- teams
- u
- unsubscribes
- users
- ].freeze
-
- WILDCARD_ROUTES = %w[tree commits wikis new edit create update logs_tree
- preview blob blame raw files create_dir find_file
- artifacts graphs refs badges].freeze
-
- STRICT_RESERVED = (RESERVED + WILDCARD_ROUTES).freeze
-
- def self.valid?(value)
- !reserved?(value) && follow_format?(value)
- end
-
- def self.reserved?(value, strict: false)
- if strict
- STRICT_RESERVED.include?(value)
- else
- RESERVED.include?(value)
- end
- end
-
- def self.follow_format?(value)
- value =~ Gitlab::Regex.namespace_regex
- end
-
- delegate :reserved?, :follow_format?, to: :class
-
- def validate_each(record, attribute, value)
- unless follow_format?(value)
- record.errors.add(attribute, Gitlab::Regex.namespace_regex_message)
- end
-
- strict = record.is_a?(Group) && record.parent_id
-
- if reserved?(value, strict: strict)
- record.errors.add(attribute, "#{value} is a reserved name")
- end
- end
-end
diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb
deleted file mode 100644
index ee2ae65be7b..00000000000
--- a/app/validators/project_path_validator.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# ProjectPathValidator
-#
-# Custom validator for GitLab project path values.
-#
-# Values are checked for formatting and exclusion from a list of reserved path
-# names.
-class ProjectPathValidator < ActiveModel::EachValidator
- # All project routes with wildcard argument must be listed here.
- # Otherwise it can lead to routing issues when route considered as project name.
- #
- # Example:
- # /group/project/tree/deploy_keys
- #
- # without tree as reserved name routing can match 'group/project' as group name,
- # 'tree' as project name and 'deploy_keys' as route.
- #
- RESERVED = (NamespaceValidator::STRICT_RESERVED -
- %w[dashboard help ci admin search notes services assets profile public]).freeze
-
- def self.valid?(value)
- !reserved?(value)
- end
-
- def self.reserved?(value)
- RESERVED.include?(value)
- end
-
- delegate :reserved?, to: :class
-
- def validate_each(record, attribute, value)
- if reserved?(value)
- record.errors.add(attribute, "#{value} is a reserved name")
- end
- end
-end
diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb
new file mode 100644
index 00000000000..8a9d8892e9b
--- /dev/null
+++ b/app/validators/variable_duplicates_validator.rb
@@ -0,0 +1,13 @@
+# VariableDuplicatesValidator
+#
+# This validtor is designed for especially the following condition
+# - Use `accepts_nested_attributes_for :xxx` in a parent model
+# - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model
+class VariableDuplicatesValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ duplicates = value.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
+ if duplicates.any?
+ record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}")
+ end
+ end
+end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 0dc1103eece..8bf6556079b 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -22,7 +22,9 @@
.form-group
= f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
.col-sm-10
- - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ - checkbox_name = 'application_setting[restricted_visibility_levels][]'
+ = hidden_field_tag(checkbox_name)
+ - restricted_level_checkboxes('restricted-visibility-help', checkbox_name).each do |level|
.checkbox
= level
%span.help-block#restricted-visibility-help
@@ -46,6 +48,12 @@
= select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
%span.help-block#clone-protocol-help
Allow only the selected protocols to be used for Git access.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :project_export_enabled do
+ = f.check_box :project_export_enabled
+ Project export enabled
%fieldset
%legend Account and Limit Settings
@@ -143,9 +151,9 @@
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
- = f.label :signin_enabled do
- = f.check_box :signin_enabled
- Sign-in enabled
+ = f.label :password_authentication_enabled do
+ = f.check_box :password_authentication_enabled
+ Password authentication enabled
- if omniauth_enabled? && button_based_providers.any?
.form-group
= f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2'
@@ -180,11 +188,25 @@
.col-sm-10
= f.text_area :sign_in_text, class: 'form-control', rows: 4
.help-block Markdown enabled
+
+ %fieldset
+ %legend Help Page
.form-group
= f.label :help_page_text, class: 'control-label col-sm-2'
.col-sm-10
= f.text_area :help_page_text, class: 'form-control', rows: 4
.help-block Markdown enabled
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :help_page_hide_commercial_content do
+ = f.check_box :help_page_hide_commercial_content
+ Hide marketing-related entries from help
+ .form-group
+ = f.label :help_page_support_url, 'Support page URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
+ %span.help-block#support_help_block Alternate support URL for help page
%fieldset
%legend Pages
@@ -232,7 +254,7 @@
= f.number_field :container_registry_token_expire_delay, class: 'form-control'
%fieldset
- %legend Metrics
+ %legend Metrics - Influx
%p
Setup InfluxDB to measure a wide variety of statistics like the time spent
in running SQL queries. These settings require a
@@ -297,6 +319,47 @@
results in fewer but larger UDP packets being sent.
%fieldset
+ %legend Metrics - Prometheus
+ %p
+ Enable a Prometheus metrics endpoint at
+ %code= metrics_path
+ to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available
+ = link_to 'here', admin_health_check_path
+ \. This setting requires a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/index')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :prometheus_metrics_enabled do
+ = f.check_box :prometheus_metrics_enabled
+ Enable Prometheus Metrics
+ - unless Gitlab::Metrics.metrics_folder_present?
+ .help-block
+ %strong.cred WARNING:
+ Environment variable
+ %code prometheus_multiproc_dir
+ does not exist or is not pointing to a valid directory.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory')
+
+ %fieldset
+ %legend Profiling - Performance Bar
+ %p
+ Enable the Performance Bar for a given group.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :performance_bar_enabled do
+ = f.check_box :performance_bar_enabled
+ Enable the Performance Bar
+ .form-group
+ = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
+
+ %fieldset
%legend Background Jobs
%p
These settings require a restart to take effect.
@@ -394,8 +457,6 @@
%fieldset
%legend Error Reporting and Logging
- %p
- These settings require a restart to take effect.
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -403,6 +464,7 @@
= f.check_box :sentry_enabled
Enable Sentry
.help-block
+ %p This setting requires a restart to take effect.
Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here:
%a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com
@@ -411,6 +473,21 @@
.col-sm-10
= f.text_field :sentry_dsn, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :clientside_sentry_enabled do
+ = f.check_box :clientside_sentry_enabled
+ Enable Clientside Sentry
+ .help-block
+ Sentry can also be used for reporting and logging clientside exceptions.
+ %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/
+
+ .form-group
+ = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :clientside_sentry_dsn, class: 'form-control'
+
%fieldset
%legend Repository Storage
.form-group
@@ -488,17 +565,24 @@
Let GitLab inform you when an update is available.
.form-group
.col-sm-offset-2.col-sm-10
+ - can_be_configured = @application_setting.usage_ping_can_be_configured?
.checkbox
= f.label :usage_ping_enabled do
- = f.check_box :usage_ping_enabled
+ = f.check_box :usage_ping_enabled, disabled: !can_be_configured
Usage ping enabled
- = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-data")
+ = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
.help-block
- Every week GitLab will report license usage back to GitLab, Inc.
- Disable this option if you do not want this to occur. To see the
- JSON payload that will be sent, visit the
- = succeed '.' do
- = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping')
+ - if can_be_configured
+ Every week GitLab will report license usage back to GitLab, Inc.
+ Disable this option if you do not want this to occur. To see the
+ JSON payload that will be sent, visit the
+ = succeed '.' do
+ = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping')
+ - else
+ The usage ping is disabled, and cannot be configured through this
+ form. For more information, see the documentation on
+ = succeed '.' do
+ = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping')
%fieldset
%legend Email
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 061f8991b11..93827d6a1ab 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -6,6 +6,7 @@
.col-sm-10
= f.text_field :name, class: 'form-control'
= doorkeeper_errors_for application, :name
+
= content_tag :div, class: 'form-group' do
= f.label :redirect_uri, class: 'col-sm-2 control-label'
.col-sm-10
@@ -19,6 +20,13 @@
%code= Doorkeeper.configuration.native_redirect_uri
for local tests
+ = content_tag :div, class: 'form-group' do
+ = f.label :trusted, class: 'col-sm-2 control-label'
+ .col-sm-10
+ = f.check_box :trusted
+ %span.help-block
+ Trusted applications are automatically authorized on GitLab OAuth flow.
+
.form-group
= f.label :scopes, class: 'col-sm-2 control-label'
.col-sm-10
diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml
index c596866bde2..13b583e6072 100644
--- a/app/views/admin/applications/edit.html.haml
+++ b/app/views/admin/applications/edit.html.haml
@@ -1,4 +1,5 @@
- page_title "Edit", @application.name, "Applications"
+
%h3.page-title Edit application
- @url = admin_application_path(@application)
= render 'form', application: @application
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index eb4293c7e37..94d33fa6489 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -11,6 +11,7 @@
%th Name
%th Callback URL
%th Clients
+ %th Trusted
%th
%th
%tbody.oauth-applications
@@ -19,5 +20,6 @@
%td= link_to application.name, admin_application_path(application)
%td= application.redirect_uri
%td= application.access_tokens.map(&:resource_owner_id).uniq.count
+ %td= application.trusted? ? 'Y': 'N'
%td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link'
%td= render 'delete_form', application: application
diff --git a/app/views/admin/applications/new.html.haml b/app/views/admin/applications/new.html.haml
index 6310d89bd6b..346c58877d9 100644
--- a/app/views/admin/applications/new.html.haml
+++ b/app/views/admin/applications/new.html.haml
@@ -1,4 +1,6 @@
+- breadcrumb_title "Applications"
- page_title "New Application"
+
%h3.page-title New application
- @url = admin_applications_path
= render 'form', application: @application
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 14683cc66e9..5125aa21b06 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -23,6 +23,12 @@
%div
%span.monospace= uri
+ %tr
+ %td
+ Trusted
+ %td
+ = @application.trusted? ? 'Y' : 'N'
+
= render "shared/tokens/scopes_list", token: @application
.form-actions
diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml
deleted file mode 100644
index b3530915068..00000000000
--- a/app/views/admin/background_jobs/_head.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-= content_for :sub_nav do
- .scrolling-tabs-container.sub-nav-scroll
- = render 'shared/nav_scroll'
- .nav-links.sub-nav.scrolling-tabs
- %ul{ class: (container_class) }
- = nav_link(controller: :system_info) do
- = link_to admin_system_info_path, title: 'System Info' do
- %span
- System Info
- = nav_link(controller: :background_jobs) do
- = link_to admin_background_jobs_path, title: 'Background Jobs' do
- %span
- Background Jobs
- = nav_link(controller: :logs) do
- = link_to admin_logs_path, title: 'Logs' do
- %span
- Logs
- = nav_link(controller: :health_check) do
- = link_to admin_health_check_path, title: 'Health Check' do
- %span
- Health Check
- = nav_link(controller: :requests_profiles) do
- = link_to admin_requests_profiles_path, title: 'Requests Profiles' do
- %span
- Requests Profiles
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index ac36bb5bb17..e5842bd1ea0 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Background Jobs"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title Background Jobs
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 2269fb1fd8c..5a4ed1c3a2a 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -21,11 +21,11 @@
.form-group.js-toggle-colors-container.hide
= f.label :color, "Background Color", class: 'control-label'
.col-sm-10
- = f.text_field :color, class: "form-control"
+ = f.color_field :color, class: "form-control"
.form-group.js-toggle-colors-container.hide
= f.label :font, "Font Color", class: 'control-label'
.col-sm-10
- = f.text_field :font, class: "form-control"
+ = f.color_field :font, class: "form-control"
.form-group
= f.label :starts_at, class: 'control-label'
.col-sm-10.datetime-controls
diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml
index 45e053eb31d..8cbc4597e32 100644
--- a/app/views/admin/broadcast_messages/edit.html.haml
+++ b/app/views/admin/broadcast_messages/edit.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Messages"
- page_title "Broadcast Messages"
= render 'form'
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 4f2ae081d7a..b806882eee3 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Messages"
- page_title "Broadcast Messages"
%h3.page-title
diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml
deleted file mode 100644
index 66d633119c2..00000000000
--- a/app/views/admin/builds/index.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-- @no_container = true
-= render "admin/dashboard/head"
-
-%div{ class: container_class }
-
- .top-area
- - build_path_proc = ->(scope) { admin_builds_path(scope: scope) }
- = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
-
- .nav-controls
- - if @all_builds.running_or_pending.any?
- = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
-
- .row-content-block.second-block
- #{(@scope || 'all').capitalize} jobs
-
- %ul.content-list.builds-content-list.admin-builds-table
- = render "projects/builds/table", builds: @builds, admin: true
diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml
new file mode 100644
index 00000000000..33a4dab1e00
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_callout.html.haml
@@ -0,0 +1,13 @@
+.prepend-top-default
+.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
+ .bordered-box.landing.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss ConvDev introduction' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .user-callout-copy
+ %h4
+ Introducing Your Conversational Development Index
+ %p
+ Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ .svg-container.convdev
+ = custom_icon('convdev_overview')
diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml
new file mode 100644
index 00000000000..6c8688e06ae
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_card.html.haml
@@ -0,0 +1,25 @@
+.convdev-card-wrapper
+ .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" }
+ .convdev-card-title
+ %h3
+ = card.title
+ .text-light
+ = card.description
+ .card-scores
+ .card-score
+ .card-score-value
+ = format_score(card.instance_score)
+ .card-score-name You
+ .card-score
+ .card-score-value
+ = format_score(card.leader_score)
+ .card-score-name Lead
+ .card-score-big
+ = number_to_percentage(card.percentage_score, precision: 1)
+ .card-buttons
+ - if card.blog
+ %a{ href: card.blog }
+ = icon('info-circle', 'aria-hidden' => 'true')
+ - if card.docs
+ %a{ href: card.docs }
+ = icon('question-circle', 'aria-hidden' => 'true')
diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml
new file mode 100644
index 00000000000..975d7df3da6
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_disabled.html.haml
@@ -0,0 +1,9 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_index')
+ %h4 Usage ping is not enabled
+ %p
+ ConvDev is only shown when the
+ = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank'
+ is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective
+ = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary'
diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml
new file mode 100644
index 00000000000..b23d2b5ec3a
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_no_data.html.haml
@@ -0,0 +1,7 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_data')
+ %h4 Data is still calculating...
+ %p
+ In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
+ = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank'
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
new file mode 100644
index 00000000000..833d4c612f8
--- /dev/null
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -0,0 +1,35 @@
+- @no_container = true
+- page_title 'ConvDev Index'
+
+= render 'admin/monitoring/head'
+
+.container
+ - if show_callout?('convdev_intro_callout_dismissed')
+ = render 'callout'
+
+ .prepend-top-default
+ - if !current_application_settings.usage_ping_enabled
+ = render 'disabled'
+ - elsif @metric.blank?
+ = render 'no_data'
+ - else
+ .convdev
+ .convdev-header
+ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
+ = number_to_percentage(@metric.average_percentage_score, precision: 1)
+ .convdev-header-subtitle
+ index
+ %br
+ score
+ = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev')
+
+ .convdev-cards.card-container
+ - @metric.cards.each do |card|
+ = render 'card', card: card
+
+ .convdev-steps.visible-lg
+ - @metric.idea_to_production_steps.each_with_index do |step, index|
+ .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" }
+ = custom_icon("i2p_step_#{index + 1}")
+ %h4.convdev-step-title
+ = step.title
diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml
index 163bd5662b0..dff549f502c 100644
--- a/app/views/admin/dashboard/_head.html.haml
+++ b/app/views/admin/dashboard/_head.html.haml
@@ -20,7 +20,7 @@
%span
Groups
= nav_link path: 'builds#index' do
- = link_to admin_builds_path, title: 'Jobs' do
+ = link_to admin_jobs_path, title: 'Jobs' do
%span
Jobs
= nav_link path: ['runners#index', 'runners#show'] do
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 8c9fdc9ae42..8e94e68bc11 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -5,170 +5,182 @@
.admin-dashboard.prepend-top-default
.row
.col-md-4
- %h4 Statistics
- %hr
- %p
- Forks
- %span.light.pull-right
- = number_with_delimiter(ForkedProjectLink.count)
- %p
- Issues
- %span.light.pull-right
- = number_with_delimiter(Issue.count)
- %p
- Merge Requests
- %span.light.pull-right
- = number_with_delimiter(MergeRequest.count)
- %p
- Notes
- %span.light.pull-right
- = number_with_delimiter(Note.count)
- %p
- Snippets
- %span.light.pull-right
- = number_with_delimiter(Snippet.count)
- %p
- SSH Keys
- %span.light.pull-right
- = number_with_delimiter(Key.count)
- %p
- Milestones
- %span.light.pull-right
- = number_with_delimiter(Milestone.count)
- %p
- Active Users
- %span.light.pull-right
- = number_with_delimiter(User.active.count)
+ .info-well
+ .well-segment.admin-well
+ %h4 Statistics
+ %p
+ Forks
+ %span.light.pull-right
+ = number_with_delimiter(ForkedProjectLink.count)
+ %p
+ Issues
+ %span.light.pull-right
+ = number_with_delimiter(Issue.count)
+ %p
+ Merge Requests
+ %span.light.pull-right
+ = number_with_delimiter(MergeRequest.count)
+ %p
+ Notes
+ %span.light.pull-right
+ = number_with_delimiter(Note.count)
+ %p
+ Snippets
+ %span.light.pull-right
+ = number_with_delimiter(Snippet.count)
+ %p
+ SSH Keys
+ %span.light.pull-right
+ = number_with_delimiter(Key.count)
+ %p
+ Milestones
+ %span.light.pull-right
+ = number_with_delimiter(Milestone.count)
+ %p
+ Active Users
+ %span.light.pull-right
+ = number_with_delimiter(User.active.count)
.col-md-4
- %h4
- Features
- %hr
- - sign_up = "Sign up"
- %p{ "aria-label" => "#{sign_up}: status " + (signup_enabled? ? "on" : "off") }
- = sign_up
- %span.light.pull-right
- = boolean_to_icon signup_enabled?
- - ldap = "LDAP"
- %p{ "aria-label" => "#{ldap}: status " + (Gitlab.config.ldap.enabled ? "on" : "off") }
- = ldap
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.ldap.enabled
- - gravatar = "Gravatar"
- %p{ "aria-label" => "#{gravatar}: status " + (gravatar_enabled? ? "on" : "off") }
- = gravatar
- %span.light.pull-right
- = boolean_to_icon gravatar_enabled?
- - omniauth = "OmniAuth"
- %p{ "aria-label" => "#{omniauth}: status " + (Gitlab.config.omniauth.enabled ? "on" : "off") }
- = omniauth
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.omniauth.enabled
- - reply_email = "Reply by email"
- %p{ "aria-label" => "#{reply_email}: status " + (Gitlab::IncomingEmail.enabled? ? "on" : "off") }
- = reply_email
- %span.light.pull-right
- = boolean_to_icon Gitlab::IncomingEmail.enabled?
- - container_reg = "Container Registry"
- %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") }
- = container_reg
- %span.light.pull-right
- = boolean_to_icon Gitlab.config.registry.enabled
-
+ .info-well
+ .well-segment.admin-well
+ %h4 Features
+ - sign_up = "Sign up"
+ %p{ "aria-label" => "#{sign_up}: status " + (signup_enabled? ? "on" : "off") }
+ = sign_up
+ %span.light.pull-right
+ = boolean_to_icon signup_enabled?
+ - ldap = "LDAP"
+ %p{ "aria-label" => "#{ldap}: status " + (Gitlab.config.ldap.enabled ? "on" : "off") }
+ = ldap
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.ldap.enabled
+ - gravatar = "Gravatar"
+ %p{ "aria-label" => "#{gravatar}: status " + (gravatar_enabled? ? "on" : "off") }
+ = gravatar
+ %span.light.pull-right
+ = boolean_to_icon gravatar_enabled?
+ - omniauth = "OmniAuth"
+ %p{ "aria-label" => "#{omniauth}: status " + (Gitlab.config.omniauth.enabled ? "on" : "off") }
+ = omniauth
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.omniauth.enabled
+ - reply_email = "Reply by email"
+ %p{ "aria-label" => "#{reply_email}: status " + (Gitlab::IncomingEmail.enabled? ? "on" : "off") }
+ = reply_email
+ %span.light.pull-right
+ = boolean_to_icon Gitlab::IncomingEmail.enabled?
+ - container_reg = "Container Registry"
+ %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") }
+ = container_reg
+ %span.light.pull-right
+ = boolean_to_icon Gitlab.config.registry.enabled
+ - gitlab_pages = 'GitLab Pages'
+ - gitlab_pages_enabled = Gitlab.config.pages.enabled
+ %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") }
+ = gitlab_pages
+ %span.light.pull-right
+ = boolean_to_icon gitlab_pages_enabled
+ - gitlab_shared_runners = 'Shared Runners'
+ - gitlab_shared_runners_enabled = Gitlab.config.gitlab_ci.shared_runners_enabled
+ %p{ "aria-label" => "#{gitlab_shared_runners}: status " + (gitlab_shared_runners_enabled ? "on" : "off") }
+ = gitlab_shared_runners
+ %span.light.pull-right
+ = boolean_to_icon gitlab_shared_runners_enabled
.col-md-4
- %h4
- Components
- - if current_application_settings.version_check_enabled
- .pull-right
- = version_status_badge
-
- %hr
- %p
- GitLab
- %span.pull-right
- = Gitlab::VERSION
- %p
- GitLab Shell
- %span.pull-right
- = Gitlab::Shell.new.version
- %p
- GitLab Workhorse
- %span.pull-right
- = gitlab_workhorse_version
- %p
- GitLab API
- %span.pull-right
- = API::API::version
- %p
- Git
- %span.pull-right
- = Gitlab::Git.version
- %p
- Ruby
- %span.pull-right
- #{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
-
- %p
- Rails
- %span.pull-right
- #{Rails::VERSION::STRING}
-
- %p
- = Gitlab::Database.adapter_name
- %span.pull-right
- = Gitlab::Database.version
- %hr
+ .info-well
+ .well-segment.admin-well
+ %h4
+ Components
+ - if current_application_settings.version_check_enabled
+ .pull-right
+ = version_status_badge
+ %p
+ GitLab
+ %span.pull-right
+ = Gitlab::VERSION
+ %p
+ GitLab Shell
+ %span.pull-right
+ = Gitlab::Shell.new.version
+ %p
+ GitLab Workhorse
+ %span.pull-right
+ = gitlab_workhorse_version
+ %p
+ GitLab API
+ %span.pull-right
+ = API::API::version
+ %p
+ Git
+ %span.pull-right
+ = Gitlab::Git.version
+ %p
+ Ruby
+ %span.pull-right
+ #{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
+ %p
+ Rails
+ %span.pull-right
+ #{Rails::VERSION::STRING}
+ %p
+ = Gitlab::Database.adapter_name
+ %span.pull-right
+ = Gitlab::Database.version
.row
.col-sm-4
- .light-well.well-centered
- %h4 Projects
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_projects_path do
- %h1= number_with_delimiter(Project.cached_count)
+ %h3.text-center
+ Projects:
+ = number_with_delimiter(Project.cached_count)
%hr
= link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4
- .light-well.well-centered
- %h4 Users
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_users_path do
- %h1= number_with_delimiter(User.count)
+ %h3.text-center
+ Users:
+ = number_with_delimiter(User.count)
%hr
= link_to 'New user', new_admin_user_path, class: "btn btn-new"
.col-sm-4
- .light-well.well-centered
- %h4 Groups
- .data
+ .info-well.dark-well
+ .well-segment.well-centered
= link_to admin_groups_path do
- %h1= number_with_delimiter(Group.count)
+ %h3.text-center
+ Groups:
+ = number_with_delimiter(Group.count)
%hr
= link_to 'New group', new_admin_group_path, class: "btn btn-new"
-
- .row.prepend-top-10
+ .row
.col-md-4
- %h4 Latest projects
- %hr
- - @projects.each do |project|
- %p
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
- %span.light.pull-right
- #{time_ago_with_tooltip(project.created_at)}
-
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest projects
+ - @projects.each do |project|
+ %p
+ = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
+ %span.light.pull-right
+ #{time_ago_with_tooltip(project.created_at)}
.col-md-4
- %h4 Latest users
- %hr
- - @users.each do |user|
- %p
- = link_to [:admin, user], class: 'str-truncated-60' do
- = user.name
- %span.light.pull-right
- #{time_ago_with_tooltip(user.created_at)}
-
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest users
+ - @users.each do |user|
+ %p
+ = link_to [:admin, user], class: 'str-truncated-60' do
+ = user.name
+ %span.light.pull-right
+ #{time_ago_with_tooltip(user.created_at)}
.col-md-4
- %h4 Latest groups
- %hr
- - @groups.each do |group|
- %p
- = link_to [:admin, group], class: 'str-truncated-60' do
- = group.full_name
- %span.light.pull-right
- #{time_ago_with_tooltip(group.created_at)}
+ .info-well
+ .well-segment.admin-well
+ %h4 Latest groups
+ - @groups.each do |group|
+ %p
+ = link_to [:admin, group], class: 'str-truncated-60' do
+ = group.full_name
+ %span.light.pull-right
+ #{time_ago_with_tooltip(group.created_at)}
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..3a59282e578
--- /dev/null
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit public deploy key
+%hr
+
+%div
+ = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 007da8c1d29..92370034baa 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -31,4 +31,6 @@
%span.cgray
added #{time_ago_with_tooltip(deploy_key.created_at)}
%td
- = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key pull-right'
+ .pull-right
+ = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
+ = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key'
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index a064efc231f..13f5259698f 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,31 +1,10 @@
-- page_title "New Deploy Key"
+- page_title 'New Deploy Key'
%h3.page-title New public deploy key
%hr
%div
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
- = form_errors(@deploy_key)
-
- .form-group
- = f.label :title, class: "control-label"
- .col-sm-10= f.text_field :title, class: 'form-control'
- .form-group
- = f.label :key, class: "control-label"
- .col-sm-10
- %p.light
- Paste a machine public key here. Read more about how to generate it
- = link_to "here", help_page_path("ssh/README")
- = f.text_area :key, class: "form-control thin_area", rows: 5
- .form-group
- .control-label
- .col-sm-10
- = f.label :can_push do
- = f.check_box :can_push
- %strong Write access allowed
- %p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
-
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Create', class: "btn-create btn"
- = link_to "Cancel", admin_deploy_keys_path, class: "btn btn-cancel"
-
+ = f.submit 'Create', class: 'btn-create btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 9149b8e7fb9..2aadc071c75 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -70,7 +70,7 @@
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
- %span.monospace= project.path_with_namespace + ".git"
+ %span.monospace= project.full_path + '.git'
.panel-footer
= paginate @projects, param_name: 'projects_page', theme: 'gitlab'
@@ -88,7 +88,7 @@
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
- %span.monospace= project.path_with_namespace + ".git"
+ %span.monospace= project.full_path + '.git'
.col-md-6
- if can?(current_user, :admin_group_member, @group)
@@ -107,8 +107,7 @@
= select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2"
%hr
= button_tag 'Add users to group', class: "btn btn-create"
-
- = render 'shared/members/requests', membership_source: @group, requesters: @requesters
+ = render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true
.panel.panel-default
.panel-heading
@@ -117,7 +116,7 @@
%span.badge= @group.members.size
.pull-right
= link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@group, :members]), class: "btn btn-xs"
- %ul.well-list.group-users-list.content-list
+ %ul.well-list.group-users-list.content-list.members-list
= render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @members, param_name: 'members_page', theme: 'gitlab'
diff --git a/app/views/admin/health_check/_failing_storages.html.haml b/app/views/admin/health_check/_failing_storages.html.haml
new file mode 100644
index 00000000000..6830201538d
--- /dev/null
+++ b/app/views/admin/health_check/_failing_storages.html.haml
@@ -0,0 +1,15 @@
+- if failing_storages.any?
+ = _('There are problems accessing Git storage: ')
+ %ul
+ - failing_storages.each do |storage_health|
+ %li
+ = failing_storage_health_message(storage_health)
+ %ul
+ - storage_health.failing_circuit_breakers.each do |circuit_breaker|
+ %li
+ #{circuit_breaker.hostname}: #{message_for_circuit_breaker(circuit_breaker)}
+
+ = _("Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again.")
+ .prepend-top-10
+ = button_to _("Reset git storage health information"), reset_storage_health_admin_health_check_path,
+ method: :post, class: 'btn btn-default'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 6a208d76a38..517db50b97f 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,52 +1,43 @@
- @no_container = true
-- page_title "Health Check"
-= render 'admin/background_jobs/head'
+- page_title _('Health Check')
+- no_errors = @errors.blank? && @failing_storage_statuses.blank?
+= render 'admin/monitoring/head'
%div{ class: container_class }
- %h3.page-title
- Health Check
+ %h3.page-title= page_title
.bs-callout.clearfix
.pull-left
%p
- Access token is
+ #{ s_('HealthCheck|Access token is') }
%code#health-check-token= current_application_settings.health_check_access_token
- = button_to reset_health_check_token_admin_application_settings_path,
- method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset the health check token?' } do
- = icon('spinner')
- Reset health check access token
+ .prepend-top-10
+ = button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path,
+ method: :put, class: 'btn btn-default',
+ data: { confirm: _('Are you sure you want to reset the health check token?') }
%p.light
- Health information can be retrieved as plain text, JSON, or XML using:
+ #{ _('Health information can be retrieved from the following endpoints. More information is available') }
+ = link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check')
%ul
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token)
+ %code= readiness_url(token: current_application_settings.health_check_access_token)
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json)
+ %code= liveness_url(token: current_application_settings.health_check_access_token)
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml)
-
- %p.light
- You can also ask for the status of specific services:
- %ul
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache)
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database)
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations)
+ %code= metrics_url(token: current_application_settings.health_check_access_token)
%hr
.panel.panel-default
.panel-heading
Current Status:
- - if @errors.blank?
+ - if no_errors
= icon('circle', class: 'cgreen')
- Healthy
+ #{ s_('HealthCheck|Healthy') }
- else
= icon('warning', class: 'cred')
- Unhealthy
+ #{ s_('HealthCheck|Unhealthy') }
.panel-body
- - if @errors.blank?
- No Health Problems Detected
+ - if no_errors
+ #{ s_('HealthCheck|No Health Problems Detected') }
- else
= @errors
+ = render partial: 'failing_storages', object: @failing_storage_statuses
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
new file mode 100644
index 00000000000..7dd9943190f
--- /dev/null
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -0,0 +1,37 @@
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Recent Deliveries
+ %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ .col-lg-9
+ - if hook_logs.any?
+ %table.table
+ %thead
+ %tr
+ %th Status
+ %th Trigger
+ %th URL
+ %th Elapsed time
+ %th Request time
+ %th
+ - hook_logs.each do |hook_log|
+ %tr
+ %td
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+ %td
+ = truncate(hook_log.url, length: 50)
+ %td.light
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ %td.light
+ = time_ago_with_tooltip(hook_log.created_at)
+ %td
+ = link_to 'View details', admin_hook_hook_log_path(hook, hook_log)
+
+ = paginate hook_logs, theme: 'gitlab'
+
+ - else
+ .settings-message.text-center
+ You don't have any webhooks deliveries
diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml
new file mode 100644
index 00000000000..56127bacda2
--- /dev/null
+++ b/app/views/admin/hook_logs/show.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Request details'
+%h3.page-title
+ Request details
+
+%hr
+
+= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), class: "btn btn-default pull-right prepend-left-10"
+
+= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
+
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
new file mode 100644
index 00000000000..645005c6deb
--- /dev/null
+++ b/app/views/admin/hooks/_form.html.haml
@@ -0,0 +1,47 @@
+= form_errors(hook)
+
+.form-group
+ = form.label :url, 'URL', class: 'control-label'
+ .col-sm-10
+ = form.text_field :url, class: 'form-control'
+.form-group
+ = form.label :token, 'Secret Token', class: 'control-label'
+ .col-sm-10
+ = form.text_field :token, class: 'form-control'
+ %p.help-block
+ Use this token to validate received payloads
+.form-group
+ = form.label :url, 'Trigger', class: 'control-label'
+ .col-sm-10.prepend-top-10
+ %div
+ System hook will be triggered on set of events like creating project
+ or adding ssh key. But you can also enable extra triggers like Push events.
+
+ .prepend-top-default
+ = form.check_box :repository_update_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :repository_update_events, class: 'list-label' do
+ %strong Repository update events
+ %p.light
+ This URL will be triggered when repository is updated
+ %div
+ = form.check_box :push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :push_events, class: 'list-label' do
+ %strong Push events
+ %p.light
+ This URL will be triggered for each branch updated to the repository
+ %div
+ = form.check_box :tag_push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :tag_push_events, class: 'list-label' do
+ %strong Tag push events
+ %p.light
+ This URL will be triggered when a new tag is pushed to the repository
+.form-group
+ = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox'
+ .col-sm-10
+ .checkbox
+ = form.label :enable_ssl_verification do
+ = form.check_box :enable_ssl_verification
+ %strong Enable SSL verification
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
new file mode 100644
index 00000000000..665e8c7e74f
--- /dev/null
+++ b/app/views/admin/hooks/edit.html.haml
@@ -0,0 +1,20 @@
+- page_title 'Edit System Hook'
+%h3.page-title
+ Edit System Hook
+
+%p.light
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
+ used for binding events when GitLab creates a User or Project.
+
+%hr
+
+= form_for @hook, as: :hook, url: admin_hook_path, html: { class: 'form-horizontal' } do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn btn-create'
+ = render 'shared/web_hooks/test_button', triggers: SystemHook::TRIGGERS, hook: @hook
+ = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' }
+
+%hr
+
+= render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index d9c7948763a..fed6002528d 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,57 +1,17 @@
-- page_title "System Hooks"
+- page_title 'System Hooks'
%h3.page-title
System hooks
%p.light
- #{link_to "System hooks ", help_page_path("system_hooks/system_hooks"), class: "vlink"} can be
+ #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be
used for binding events when GitLab creates a User or Project.
%hr
-
= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f|
- = form_errors(@hook)
-
- .form-group
- = f.label :url, 'URL', class: 'control-label'
- .col-sm-10
- = f.text_field :url, class: 'form-control'
- .form-group
- = f.label :token, 'Secret Token', class: 'control-label'
- .col-sm-10
- = f.text_field :token, class: 'form-control'
- %p.help-block
- Use this token to validate received payloads
- .form-group
- = f.label :url, "Trigger", class: 'control-label'
- .col-sm-10.prepend-top-10
- %div
- System hook will be triggered on set of events like creating project
- or adding ssh key. But you can also enable extra triggers like Push events.
-
- .prepend-top-default
- = f.check_box :push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :push_events, class: 'list-label' do
- %strong Push events
- %p.light
- This url will be triggered by a push to the repository
- %div
- = f.check_box :tag_push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :tag_push_events, class: 'list-label' do
- %strong Tag push events
- %p.light
- This url will be triggered when a new tag is pushed to the repository
- .form-group
- = f.label :enable_ssl_verification, "SSL verification", class: 'control-label checkbox'
- .col-sm-10
- .checkbox
- = f.label :enable_ssl_verification do
- = f.check_box :enable_ssl_verification
- %strong Enable SSL verification
+ = render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- = f.submit "Add system hook", class: "btn btn-create"
+ = f.submit 'Add system hook', class: 'btn btn-create'
%hr
- if @hooks.any?
@@ -62,11 +22,12 @@
- @hooks.each do |hook|
%li
.controls
- = link_to 'Test hook', admin_hook_test_path(hook), class: "btn btn-sm"
- = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-remove btn-sm"
+ = render 'shared/web_hooks/test_button', triggers: SystemHook::TRIGGERS, hook: hook, button_class: 'btn-small'
+ = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm'
+ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
.monospace= hook.url
%div
- - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger|
- - if hook.send(trigger)
- %span.label.label-gray= trigger.titleize
- %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"}
+ - SystemHook::TRIGGERS.each_value do |event|
+ - if hook.public_send(event)
+ %span.label.label-gray= event.to_s.titleize
+ %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
new file mode 100644
index 00000000000..09be17f07be
--- /dev/null
+++ b/app/views/admin/jobs/index.html.haml
@@ -0,0 +1,18 @@
+- @no_container = true
+= render "admin/dashboard/head"
+
+%div{ class: container_class }
+
+ .top-area
+ - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
+ = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
+
+ .nav-controls
+ - if @all_builds.running_or_pending.any?
+ = link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+
+ .row-content-block.second-block
+ #{(@scope || 'all').capitalize} jobs
+
+ %ul.content-list.builds-content-list.admin-builds-table
+ = render "projects/jobs/table", builds: @builds, admin: true
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index 5e585ce789b..487f1cf5c4f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -3,7 +3,7 @@
- loggers = [Gitlab::GitLogger, Gitlab::AppLogger,
Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger]
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
diff --git a/app/views/admin/monitoring/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
new file mode 100644
index 00000000000..901e30275fd
--- /dev/null
+++ b/app/views/admin/monitoring/_head.html.haml
@@ -0,0 +1,29 @@
+= content_for :sub_nav do
+ .scrolling-tabs-container.sub-nav-scroll
+ = render 'shared/nav_scroll'
+ .nav-links.sub-nav.scrolling-tabs
+ %ul{ class: (container_class) }
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
+ = nav_link(controller: :system_info) do
+ = link_to admin_system_info_path, title: 'System Info' do
+ %span
+ System Info
+ = nav_link(controller: :background_jobs) do
+ = link_to admin_background_jobs_path, title: 'Background Jobs' do
+ %span
+ Background Jobs
+ = nav_link(controller: :logs) do
+ = link_to admin_logs_path, title: 'Logs' do
+ %span
+ Logs
+ = nav_link(controller: :health_check) do
+ = link_to admin_health_check_path, title: 'Health Check' do
+ %span
+ Health Check
+ = nav_link(controller: :requests_profiles) do
+ = link_to admin_requests_profiles_path, title: 'Requests Profiles' do
+ %span
+ Requests Profiles
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 596f367a00d..c69c2761189 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -4,7 +4,7 @@
- @projects.each_with_index do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
- = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn"
+ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
= link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove"
.stats
%span.badge
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 08a8f627113..7b1b15cfeb8 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -108,7 +108,7 @@
.panel-heading
Transfer project
.panel-body
- = form_for @project, url: transfer_admin_namespace_project_path(@project.namespace, @project), method: :put, html: { class: 'form-horizontal' } do |f|
+ = form_for @project, url: transfer_admin_project_path(@project), method: :put, html: { class: 'form-horizontal' } do |f|
.form-group
= f.label :new_namespace_id, "Namespace", class: 'control-label'
.col-sm-10
@@ -128,7 +128,7 @@
.panel-heading
Repository check
.panel-body
- = form_for @project, url: repository_check_admin_namespace_project_path(@project.namespace, @project), method: :post do |f|
+ = form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
This repository has never been checked.
@@ -160,12 +160,12 @@
.pull-right
= link_to admin_group_path(@group), class: 'btn btn-xs' do
= icon('pencil-square-o', text: 'Manage access')
- %ul.well-list.content-list
+ %ul.well-list.content-list.members-list
= render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters
+ = render 'shared/members/requests', membership_source: @project, requesters: @requesters, force_mobile_view: true
.panel.panel-default
.panel-heading
@@ -174,7 +174,7 @@
%span.badge= @project.users.size
.pull-right
= link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@project, :members]), class: "btn btn-xs"
- %ul.well-list.project_members.content-list
+ %ul.well-list.project_members.content-list.members-list
= render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false }
.panel-footer
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index ae918086a57..b7db18b2d32 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title 'Requests Profiles'
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
@@ -20,7 +20,7 @@
%ul.content-list
- profiles.each do |profile|
%li
- = link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true}
+ = link_to profile.time.to_s(:long), admin_requests_profile_path(profile)
- else
%p
No profiles found
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index d4d166ab7b6..140688b52d3 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -32,13 +32,16 @@
#{time_ago_in_words(runner.contacted_at)} ago
- else
Never
- %td
- .pull-right
- = link_to 'Edit', admin_runner_path(runner), class: 'btn btn-sm'
+ %td.admin-runner-btn-group-cell
+ .pull-right.btn-group
+ = link_to admin_runner_path(runner), class: 'btn btn-sm btn-default has-tooltip', title: 'Edit', ref: 'tooltip', aria: { label: 'Edit' }, data: { placement: 'top', container: 'body'} do
+ = icon('pencil')
&nbsp;
- if runner.active?
- = link_to 'Pause', [:pause, :admin, runner], data: { confirm: "Are you sure?" }, method: :get, class: 'btn btn-danger btn-sm'
+ = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-sm btn-default has-tooltip', title: 'Pause', ref: 'tooltip', aria: { label: 'Pause' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
+ = icon('pause')
- else
- = link_to 'Resume', [:resume, :admin, runner], method: :get, class: 'btn btn-success btn-sm'
- = link_to 'Remove', [:admin, runner], data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
-
+ = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-sm has-tooltip', title: 'Resume', ref: 'tooltip', aria: { label: 'Resume' }, data: { placement: 'top', container: 'body'} do
+ = icon('play')
+ = link_to [:admin, runner], method: :delete, class: 'btn btn-danger btn-sm has-tooltip', title: 'Remove', ref: 'tooltip', aria: { label: 'Remove' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
+ = icon('remove')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f118804cace..126550ee10e 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -2,28 +2,6 @@
= render "admin/dashboard/head"
%div{ class: container_class }
-
- %p.prepend-top-default
- %span
- To register a new Runner you should enter the following registration
- token.
- With this token the Runner will request a unique Runner token and use
- that for future communication.
- %br
- Registration token is
- %code#runners-token= current_application_settings.runners_registration_token
-
- .bs-callout.clearfix
- .pull-left
- %p
- You can reset runners registration token by pressing a button below.
- %p
- = button_to reset_runners_token_admin_application_settings_path,
- method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset registration token?' } do
- = icon('spinner')
- Reset runners registration token
-
.bs-callout
%p
A 'Runner' is a process which runs a job.
@@ -48,6 +26,19 @@
%span.label.label-danger paused
\- Runner will not receive any new jobs
+ .bs-callout.clearfix
+ .pull-left
+ %p
+ You can reset runners registration token by pressing a button below.
+ .prepend-top-10
+ = button_to _("Reset runners registration token"), reset_runners_token_admin_application_settings_path,
+ method: :put, class: 'btn btn-default',
+ data: { confirm: _("Are you sure you want to reset registration token?") }
+
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: current_application_settings.runners_registration_token,
+ type: 'shared' }
+
.append-bottom-20.clearfix
.pull-left
= form_tag admin_runners_path, id: 'runners-search', class: 'form-inline', method: :get do
@@ -60,20 +51,23 @@
%br
- .table-holder
- %table.table
- %thead
- %tr
- %th Type
- %th Runner token
- %th Description
- %th Version
- %th Projects
- %th Jobs
- %th Tags
- %th Last contact
- %th
+ - if @runners.any?
+ .table-holder
+ %table.table
+ %thead
+ %tr
+ %th Type
+ %th Runner token
+ %th Description
+ %th Version
+ %th Projects
+ %th Jobs
+ %th Tags
+ %th Last contact
+ %th
- - @runners.each do |runner|
- = render "admin/runners/runner", runner: runner
- = paginate @runners, theme: "gitlab"
+ - @runners.each do |runner|
+ = render "admin/runners/runner", runner: runner
+ = paginate @runners, theme: "gitlab"
+ - else
+ .nothing-here-block No runners found
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index dc4116e1ce0..df2bf27be9d 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -85,7 +85,7 @@
%tr.build
%td.id
- if project
- = link_to namespace_project_build_path(project.namespace, project, build) do
+ = link_to project_job_path(project, build) do
%strong ##{build.id}
- else
%strong ##{build.id}
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 2e5f120c4e4..fd0281e4961 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "System Info"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
.prepend-top-default
@@ -31,3 +31,8 @@
%h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
%p= disk[:disk_name]
%p= disk[:mount_path]
+ .col-sm-4
+ .light-well
+ %h4 Uptime
+ .data
+ %h1= time_ago_with_tooltip(Rails.application.config.booted_at)
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 8862455688f..ca6e43e091c 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -34,9 +34,16 @@
- if user.access_locked?
%li
= link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' }
- - if user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ - if can?(current_user, :destroy_user, user)
%li.divider
+ - if user.can_be_removed?
+ %li
+ = link_to 'Remove user', admin_user_path(user),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" },
+ class: 'text-danger',
+ method: :delete
%li
- = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
- class: 'btn btn-remove btn-block',
- method: :delete
+ = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" },
+ class: 'text-danger',
+ method: :delete
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index c7cd86527d3..5516134d8a0 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -3,41 +3,43 @@
= render "admin/dashboard/head"
%div{ class: container_class }
- .top-area
- .prepend-top-default
- = form_tag admin_users_path, method: :get do
- - if params[:filter].present?
- = hidden_field_tag "filter", h(params[:filter])
- .search-holder
- .search-field-holder
- = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false
- = icon("search", class: "search-icon")
- .dropdown
- - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end
- = dropdown_toggle(toggle_text, { toggle: 'dropdown' })
- %ul.dropdown-menu.dropdown-menu-align-right
- %li.dropdown-header
- Sort by
- %li
- = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do
- = sort_title_name
- = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do
- = sort_title_recently_signin
- = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do
- = sort_title_oldest_signin
- = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do
- = sort_title_recently_created
- = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do
- = sort_title_oldest_created
- = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do
- = sort_title_recently_updated
- = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do
- = sort_title_oldest_updated
- = link_to 'New user', new_admin_user_path, class: 'btn btn-new btn-search'
+ .prepend-top-default
+ = form_tag admin_users_path, method: :get do
+ - if params[:filter].present?
+ = hidden_field_tag "filter", h(params[:filter])
+ .search-holder
+ .search-field-holder
+ = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false
+ = icon("search", class: "search-icon")
+ .dropdown
+ - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end
+ = dropdown_toggle(toggle_text, { toggle: 'dropdown' })
+ %ul.dropdown-menu.dropdown-menu-align-right
+ %li.dropdown-header
+ Sort by
+ %li
+ = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do
+ = sort_title_name
+ = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do
+ = sort_title_recently_signin
+ = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do
+ = sort_title_oldest_signin
+ = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do
+ = sort_title_recently_created
+ = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do
+ = sort_title_oldest_created
+ = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do
+ = sort_title_recently_updated
+ = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do
+ = sort_title_oldest_updated
+ = link_to 'New user', new_admin_user_path, class: 'btn btn-new btn-search'
- .nav-block
- %ul.nav-links.wide.scrolling-tabs.white.scrolling-tabs
- .fade-left
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left
+ = icon('angle-left')
+ .fade-right
+ = icon('angle-right')
+ %ul.nav-links.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
Active
@@ -66,7 +68,6 @@
= link_to admin_users_path(filter: "wop") do
Without projects
%small.badge= number_with_delimiter(User.without_projects.count)
- .fade-right
%ul.flex-list.content-list
- if @users.empty?
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 15eaf1c0e67..4a440f3f6d4 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -33,7 +33,7 @@
- member = project.team.find_member(@user.id)
%li.project_member
.list-item-name
- = link_to admin_namespace_project_path(project.namespace, project), class: dom_class(project) do
+ = link_to admin_project_path(project), class: dom_class(project) do
= project.name_with_namespace
- if member
@@ -44,5 +44,5 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
- = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove prepend-left-10", title: 'Remove user from project' do
+ = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove prepend-left-10", title: 'Remove user from project' do
%i.fa.fa-times
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 840d843f069..b556ff056c0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -175,13 +175,9 @@
.panel-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p Deleting a user has the following effects:
- %ul
- %li All user content like authored issues, snippets, comments will be removed
- - rp = @user.personal_projects.count
- - unless rp.zero?
- %li #{pluralize rp, 'personal project'} will be removed and cannot be restored
+ = render 'users/deletion_guidance', user: @user
%br
- = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
- if @user.solo_owned_groups.present?
%p
@@ -192,3 +188,22 @@
- else
%p
You don't have access to delete this user.
+
+ .panel.panel-danger
+ .panel-heading
+ Remove user and contributions
+ .panel-body
+ - if can?(current_user, :destroy_user, @user)
+ %p
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ - else
+ %p
+ You don't have access to delete this user.
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 9aabfb49a29..5f07d2720c2 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -12,9 +12,9 @@
- if current_user
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
- 'aria-label': 'Add emoji',
+ 'aria-label': 'Add reaction',
class: ("js-user-authored" if user_authored),
- data: { title: 'Add emoji', placement: "bottom" } }
+ data: { title: 'Add reaction', placement: "bottom" } }
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml
index dfbc7772698..e6408f35201 100644
--- a/app/views/ci/lints/show.html.haml
+++ b/app/views/ci/lints/show.html.haml
@@ -1,6 +1,6 @@
- page_title "CI Lint"
- page_description "Validate your GitLab CI configuration file"
-- content_for :page_specific_javascripts do
+- content_for :library_javascripts do
= page_specific_javascript_tag('lib/ace.js')
%h2 Check your .gitlab-ci.yml
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
new file mode 100644
index 00000000000..b75dab0acc5
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -0,0 +1,16 @@
+- link = link_to _("GitLab Runner section"), 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'
+.bs-callout.help-callout
+ %h4= _("How to setup a #{type} Runner for a new project")
+
+ %ol
+ %li
+ = _("Install a Runner compatible with GitLab CI")
+ = (_("(checkout the %{link} for information on how to install it).") % { link: link }).html_safe
+ %li
+ = _("Specify the following URL during the Runner setup:")
+ %code= root_url(only_path: false)
+ %li
+ = _("Use the following registration token during setup:")
+ %code#registration_token= registration_token
+ %li
+ = _("Start the Runner!")
diff --git a/app/views/ci/status/_graph_badge.html.haml b/app/views/ci/status/_graph_badge.html.haml
deleted file mode 100644
index 128b418090f..00000000000
--- a/app/views/ci/status/_graph_badge.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
--# Renders the graph node with both the status icon, status name and action icon
-
-- subject = local_assigns.fetch(:subject)
-- status = subject.detailed_status(current_user)
-- klass = "ci-status-icon ci-status-icon-#{status.group} js-ci-status-icon-#{status.group}"
-- tooltip = "#{subject.name} - #{status.label}"
-
-- if status.has_details?
- = link_to status.details_path, class: 'build-content has-tooltip', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
- %span{ class: klass }= custom_icon(status.icon)
- .ci-status-text= subject.name
-- else
- .build-content.has-tooltip{ data: { toggle: 'tooltip', title: tooltip } }
- %span{ class: klass }= custom_icon(status.icon)
- .ci-status-text= subject.name
-
-- if status.has_action?
- = link_to status.action_path, class: 'ci-action-icon-container has-tooltip', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
- %i.ci-action-icon-wrapper{ class: "js-#{status.action_icon.dasherize}" }
- = custom_icon(status.action_icon)
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
new file mode 100644
index 00000000000..98f618ca3b8
--- /dev/null
+++ b/app/views/ci/variables/_content.html.haml
@@ -0,0 +1,9 @@
+%h4.prepend-top-0
+ Secret variables
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank'
+%p
+ These variables will be set to environment by the runner, and could be protected by exposing only to protected branches or tags.
+%p
+ So you can use them for passwords, secret keys or whatever you want.
+%p
+ The value of the variable can be visible in job log if explicitly asked to do so.
diff --git a/app/views/ci/variables/_form.html.haml b/app/views/ci/variables/_form.html.haml
new file mode 100644
index 00000000000..eebd0955c80
--- /dev/null
+++ b/app/views/ci/variables/_form.html.haml
@@ -0,0 +1,19 @@
+= form_for @variable, as: :variable, url: @variable.form_path do |f|
+ = form_errors(@variable)
+
+ .form-group
+ = f.label :key, "Key", class: "label-light"
+ = f.text_field :key, class: "form-control", placeholder: @variable.placeholder, required: true
+ .form-group
+ = f.label :value, "Value", class: "label-light"
+ = f.text_area :value, class: "form-control", placeholder: @variable.placeholder
+ .form-group
+ .checkbox
+ = f.label :protected do
+ = f.check_box :protected
+ %strong Protected
+ .help-block
+ This variable will be passed only to pipelines running on protected branches and tags
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank'
+
+ = f.submit btn_text, class: "btn btn-save"
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
new file mode 100644
index 00000000000..007c2344b5a
--- /dev/null
+++ b/app/views/ci/variables/_index.html.haml
@@ -0,0 +1,16 @@
+.row.prepend-top-default.append-bottom-default
+ .col-lg-4
+ = render "ci/variables/content"
+ .col-lg-8
+ %h5.prepend-top-0
+ Add a variable
+ = render "ci/variables/form", btn_text: "Add new variable"
+ %hr
+ %h5.prepend-top-0
+ Your variables (#{@variables.size})
+ - if @variables.empty?
+ %p.settings-message.text-center.append-bottom-0
+ No variables found, add one with the form above.
+ - else
+ = render "ci/variables/table"
+ %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values
diff --git a/app/views/ci/variables/_show.html.haml b/app/views/ci/variables/_show.html.haml
new file mode 100644
index 00000000000..2bfb290629d
--- /dev/null
+++ b/app/views/ci/variables/_show.html.haml
@@ -0,0 +1,9 @@
+- page_title "Variables"
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ = render "ci/variables/content"
+ .col-lg-9
+ %h5.prepend-top-0
+ Update variable
+ = render "ci/variables/form", btn_text: "Save variable"
diff --git a/app/views/ci/variables/_table.html.haml b/app/views/ci/variables/_table.html.haml
new file mode 100644
index 00000000000..71a0b56c4f4
--- /dev/null
+++ b/app/views/ci/variables/_table.html.haml
@@ -0,0 +1,28 @@
+.table-responsive.variables-table
+ %table.table
+ %colgroup
+ %col
+ %col
+ %col
+ %col{ width: 100 }
+ %thead
+ %th Key
+ %th Value
+ %th Protected
+ %th
+ %tbody
+ - @variables.each do |variable|
+ - if variable.id?
+ %tr
+ %td.variable-key= variable.key
+ %td.variable-value{ "data-value" => variable.value }******
+ %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected)
+ %td.variable-menu
+ = link_to variable.edit_path, class: "btn btn-transparent btn-variable-edit" do
+ %span.sr-only
+ Update
+ = icon("pencil")
+ = link_to variable.delete_path, class: "btn btn-transparent btn-variable-delete", method: :delete, data: { confirm: "Are you sure?" } do
+ %span.sr-only
+ Remove
+ = icon("trash")
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index 89d991abe54..a676eba2aee 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -1,7 +1,4 @@
-.hidden-xs
- = render "events/event_last_push", event: @last_push
-
-.nav-block
+.nav-block.activities
.controls
= link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do
%i.fa.fa-rss
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 0e848386ebb..5a379eae8f4 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,14 +1,17 @@
+- if show_new_nav? && current_user.can_create_group?
+ - content_for :breadcrumbs_extra do
+ = link_to "New group", new_group_path, class: "btn btn-new"
+
.top-area
%ul.nav-links
= nav_link(page: dashboard_groups_path) do
= link_to dashboard_groups_path, title: 'Your groups' do
- Your Groups
+ Your groups
= nav_link(page: explore_groups_path) do
- = link_to explore_groups_path, title: 'Explore groups' do
- Explore Groups
- .nav-controls
+ = link_to explore_groups_path, title: 'Explore public groups' do
+ Explore public groups
+ .nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
- = link_to new_group_path, class: "btn btn-new" do
- New group
+ = link_to "New group", new_group_path, class: "btn btn-new #{("visible-xs" if show_new_nav?)}"
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 64b737ee886..1f9a5b401b6 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -1,5 +1,10 @@
= content_for :flash_message do
= render 'shared/project_limit'
+
+- if show_new_nav? && current_user.can_create_project?
+ - content_for :breadcrumbs_extra do
+ = link_to "New project", new_project_path, class: 'btn btn-new'
+
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
@@ -14,9 +19,8 @@
= link_to explore_root_path, title: 'Explore', data: {placement: 'right'} do
Explore projects
- .nav-controls
+ .nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
= render 'shared/projects/search_form'
= render 'shared/projects/dropdown'
- if current_user.can_create_project?
- = link_to new_project_path, class: 'btn btn-new' do
- New project
+ = link_to "New project", new_project_path, class: "btn btn-new #{("visible-xs" if show_new_nav?)}"
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 02e90bbfa55..fd5389106bb 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -1,3 +1,7 @@
+- if show_new_nav? && current_user
+ - content_for :breadcrumbs_extra do
+ = link_to "New snippet", new_snippet_path, class: "btn btn-new", title: "New snippet"
+
.top-area
%ul.nav-links
= nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do
@@ -8,6 +12,5 @@
Explore Snippets
- if current_user
- .nav-controls.hidden-xs
- = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do
- New snippet
+ .nav-controls.hidden-xs{ class: ("hidden-sm hidden-md hidden-lg" if show_new_nav?) }
+ = link_to "New snippet", new_snippet_path, class: "btn btn-new", title: "New snippet"
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 190ad4b40a5..ad35d05c29a 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -1,10 +1,17 @@
+- @hide_top_links = true
+- @no_container = true
+
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
-= render 'dashboard/activity_head'
+.hidden-xs
+ = render "projects/last_push"
+
+%div{ class: container_class }
+ = render 'dashboard/activity_head'
-%section.activities
- = render 'activities'
+ %section.activities
+ = render 'activities'
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 6c3bf1a2b3b..168e6272d8e 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,6 +1,9 @@
.js-groups-list-holder
- %ul.content-list
- - @group_members.each do |group_member|
- = render 'shared/groups/group', group: group_member.group, group_member: group_member
-
- = paginate @group_members, theme: 'gitlab'
+ #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
+ .groups-list-loading
+ = icon('spinner spin', 'v-show' => 'isLoading')
+ %template{ 'v-if' => '!isLoading && isEmpty' }
+ %div{ 'v-cloak' => true }
+ = render 'empty_state'
+ %template{ 'v-else-if' => '!isLoading && !isEmpty' }
+ %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 73ab2c95ff9..1cea8182733 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,8 +1,12 @@
+- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
= render 'dashboard/groups_head'
-- if @group_members.empty?
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
+- if @groups.empty?
= render 'empty_state'
- else
= render 'groups'
diff --git a/app/views/dashboard/issues.atom.builder b/app/views/dashboard/issues.atom.builder
index 06fb531b546..70ec6bc6257 100644
--- a/app/views/dashboard/issues.atom.builder
+++ b/app/views/dashboard/issues.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{current_user.name} issues"
- xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
- xml.link href: issues_dashboard_url, rel: "alternate", type: "text/html"
- xml.id issues_dashboard_url
- xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
+xml.title "#{current_user.name} issues"
+xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: issues_dashboard_url, rel: "alternate", type: "text/html"
+xml.id issues_dashboard_url
+xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
- xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
-end
+xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index faa68468043..52e0012fd7d 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,14 +1,21 @@
+- @hide_top_links = true
- page_title "Issues"
- header_title "Issues", issues_dashboard_path(assignee_id: current_user.id)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{current_user.name} issues")
+- if show_new_nav?
+ - content_for :breadcrumbs_extra do
+ = link_to params.merge(rss_url_options), class: 'btn has-tooltip append-right-10', title: 'Subscribe' do
+ = icon('rss')
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues'
+
.top-area
= render 'shared/issuable/nav', type: :issues
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
= link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
= icon('rss')
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues'
= render 'shared/issuable/filter', type: :issues
= render 'shared/issues'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 12966c01950..c3fe14da2b2 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,10 +1,15 @@
+- @hide_top_links = true
- page_title "Merge Requests"
- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id)
+- if show_new_nav?
+ - content_for :breadcrumbs_extra do
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests'
+
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- .nav-controls
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request"
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests'
= render 'shared/issuable/filter', type: :merge_requests
= render 'shared/merge_requests'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 664ec618b79..37dbcaf5cb8 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,10 +1,15 @@
+- @hide_top_links = true
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
+- if show_new_nav?
+ - content_for :breadcrumbs_extra do
+ = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true
+
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
= render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true
.milestones
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
new file mode 100644
index 00000000000..57544559824
--- /dev/null
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -0,0 +1,33 @@
+.blank-state
+ .blank-state-icon
+ = custom_icon("add_new_user", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Add user
+ %p.blank-state-text
+ Add your team members and others to GitLab.
+ = link_to new_admin_user_path, class: "btn btn-new" do
+ New user
+
+.blank-state
+ .blank-state-icon
+ = custom_icon("configure_server", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Configure GitLab
+ %p.blank-state-text
+ Make adjustments to how your GitLab instance is set up.
+ = link_to admin_root_path, class: "btn btn-new" do
+ Configure
+
+- if current_user.can_create_group?
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_group", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a group
+ %p.blank-state-text
+ Groups are a great way to organize projects and people.
+ = link_to new_group_path, class: "btn btn-new" do
+ New group
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
new file mode 100644
index 00000000000..a93a3415ee1
--- /dev/null
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -0,0 +1,48 @@
+- public_project_count = ProjectsFinder.new(current_user: current_user).execute.count
+
+- if current_user.can_create_group?
+ .blank-state
+ .blank-state-icon
+ = custom_icon("add_new_group", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a group for several dependent projects.
+ %p.blank-state-text
+ Groups are the best way to manage projects and members.
+ = link_to new_group_path, class: "btn btn-new" do
+ New group
+
+.blank-state
+ .blank-state-icon
+ = custom_icon("add_new_project", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ - if current_user.can_create_project?
+ You don't have access to any projects right now.
+ You can create up to
+ %strong= number_with_delimiter(current_user.projects_limit)
+ = succeed "." do
+ = "project".pluralize(current_user.projects_limit)
+ - else
+ If you are added to a project, it will be displayed here.
+ - if current_user.can_create_project?
+ = link_to new_project_path, class: "btn btn-new" do
+ New project
+
+- if public_project_count > 0
+ .blank-state
+ .blank-state-icon
+ = custom_icon("globe", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Explore public projects
+ %p.blank-state-text
+ There are
+ = number_with_delimiter(public_project_count)
+ public projects on this server.
+ Public projects are an easy way to allow
+ everyone to have read-only access.
+ = link_to trending_explore_projects_path, class: "btn btn-new" do
+ Browse projects
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index 8843d4e7c84..ad3fac6d164 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,47 +1,12 @@
-- publicish_project_count = ProjectsFinder.new(current_user: current_user).execute.count
-.blank-state.blank-state-welcome
- %h2.blank-state-welcome-title
- Welcome to GitLab
- %p.blank-state-text
- Code, test, and deploy together
-
-- if current_user.can_create_group?
- .blank-state
- .blank-state-icon
- = custom_icon("group", size: 50)
- %h3.blank-state-title
- You can create a group for several dependent projects.
- %p.blank-state-text
- Groups are the best way to manage projects and members.
- = link_to new_group_path, class: "btn btn-new" do
- New group
-
-.blank-state
- .blank-state-icon
- = custom_icon("project", size: 50)
- %h3.blank-state-title
- You don't have access to any projects right now
- %p.blank-state-text
- - if current_user.can_create_project?
- You can create up to
- %strong= number_with_delimiter(current_user.projects_limit)
- = succeed "." do
- = "project".pluralize(current_user.projects_limit)
- - else
- If you are added to a project, it will be displayed here.
- - if current_user.can_create_project?
- = link_to new_project_path, class: "btn btn-new" do
- New project
-
-- if publicish_project_count > 0
- .blank-state
- .blank-state-icon
- = icon("globe")
- %h3.blank-state-title
- There are
- = number_with_delimiter(publicish_project_count)
- public projects on this server.
- %p.blank-state-text
- Public projects are an easy way to allow everyone to have read-only access.
- = link_to trending_explore_projects_path, class: "btn btn-new" do
- Browse projects
+.row.blank-state-parent-container
+ .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
+ .container.section-body
+ .blank-state.blank-state-welcome
+ %h2.blank-state-welcome-title
+ Welcome to GitLab
+ %p.blank-state-text
+ Code, test, and deploy together
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
diff --git a/app/views/dashboard/projects/index.atom.builder b/app/views/dashboard/projects/index.atom.builder
index 13f7a8ddcec..747c53b440e 100644
--- a/app/views/dashboard/projects/index.atom.builder
+++ b/app/views/dashboard/projects/index.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "Activity"
- xml.link href: dashboard_projects_url(rss_url_options), rel: "self", type: "application/atom+xml"
- xml.link href: dashboard_projects_url, rel: "alternate", type: "text/html"
- xml.id dashboard_projects_url
- xml.updated @events[0].updated_at.xmlschema if @events[0]
+xml.title "Activity"
+xml.link href: dashboard_projects_url(rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: dashboard_projects_url, rel: "alternate", type: "text/html"
+xml.id dashboard_projects_url
+xml.updated @events[0].updated_at.xmlschema if @events[0]
- xml << render(partial: 'events/event', collection: @events) if @events.any?
-end
+xml << render(partial: 'events/event', collection: @events) if @events.any?
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 596499230f9..c546252455a 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -1,19 +1,20 @@
+- @no_container = true
+- @hide_top_links = true
+
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
-- unless show_user_callout?
- = render 'shared/user_callout'
-
-- if @projects.any? || params[:name]
- = render 'dashboard/projects_head'
+= render "projects/last_push"
-- if @last_push
- = render "events/event_last_push", event: @last_push
+%div{ class: container_class }
+ - if show_callout?('user_callout_dismissed')
+ = render 'shared/user_callout'
-- if @projects.any? || params[:name]
- = render 'projects'
-- else
- = render "zero_authorized_projects"
+ - if has_projects_or_name?(@projects, params)
+ = render 'dashboard/projects_head'
+ = render 'projects'
+ - else
+ = render "zero_authorized_projects"
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index 162ae153b1c..14f9f8cd70a 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -1,13 +1,16 @@
+- @hide_top_links = true
+- @no_container = true
+- breadcrumb_title "Projects"
- page_title "Starred Projects"
- header_title "Projects", dashboard_projects_path
-= render 'dashboard/projects_head'
+= render "projects/last_push"
-- if @last_push
- = render "events/event_last_push", event: @last_push
+%div{ class: container_class }
+ = render 'dashboard/projects_head'
-- if @projects.any? || params[:filter_projects]
- = render 'projects'
-- else
- %h3 You don't have starred projects yet
- %p.slead Visit project page and press on star icon and it will appear on this page.
+ - if params[:filter_projects] || any_projects?(@projects)
+ = render 'projects'
+ - else
+ %h3 You don't have starred projects yet
+ %p.slead Visit project page and press on star icon and it will appear on this page.
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 85cbe0bf0e6..e86b1ab3116 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", dashboard_snippets_path
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index d0c12aa57ae..38fd053ae65 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -9,7 +9,7 @@
.title-item.author-name
- if todo.author
- = link_to_author(todo)
+ = link_to_author(todo, self_added: todo.self_added?)
- else
(removed)
@@ -22,6 +22,10 @@
- else
(removed)
+ - if todo.self_assigned?
+ .title-item.action-name
+ to yourself
+
.title-item
&middot;
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 52d6ebd8a14..9b615ec999e 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Todos"
- header_title "Todos", dashboard_todos_path
diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml
index 086bb8e083d..a508b7537a2 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.haml
+++ b/app/views/devise/mailer/confirmation_instructions.html.haml
@@ -1,16 +1,15 @@
-.center
- - if @resource.unconfirmed_email.present?
- #content
- %h2= @resource.unconfirmed_email
- %p Click the link below to confirm your email address.
- #cta
- = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
- - else
- #content
- - if Gitlab.com?
- %h2 Thanks for signing up to GitLab!
- - else
- %h2 Welcome, #{@resource.name}!
- %p To get started, click the link below to confirm your account.
- #cta
- = link_to 'Confirm your account', confirmation_url(@resource, confirmation_token: @token)
+- if @resource.unconfirmed_email.present?
+ #content
+ = email_default_heading(@resource.unconfirmed_email)
+ %p Click the link below to confirm your email address.
+ #cta
+ = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
+- else
+ #content
+ - if Gitlab.com?
+ = email_default_heading('Thanks for signing up to GitLab!')
+ - else
+ = email_default_heading("Welcome, #{@resource.name}!")
+ %p To get started, click the link below to confirm your account.
+ #cta
+ = link_to 'Confirm your account', confirmation_url(@resource, confirmation_token: @token)
diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml
index 3349ee84807..5ec515285f2 100644
--- a/app/views/devise/mailer/password_change.html.haml
+++ b/app/views/devise/mailer/password_change.html.haml
@@ -1,10 +1,8 @@
-.center
- #content
- %h2 Hello, #{@resource.name}!
- %p
- The password for your GitLab account on
- #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
- has successfully been changed.
- %p
- If you did not initiate this change, please contact your administrator
- immediately.
+= email_default_heading("Hello, #{@resource.name}!")
+%p
+ The password for your GitLab account on
+ #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
+ has successfully been changed.
+%p
+ If you did not initiate this change, please contact your administrator
+ immediately.
diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml
index e91c9522520..47e192afa52 100644
--- a/app/views/devise/mailer/reset_password_instructions.html.haml
+++ b/app/views/devise/mailer/reset_password_instructions.html.haml
@@ -1,12 +1,10 @@
-.center
- #content
- %h2 Hello, #{@resource.name}!
- %p
- Someone, hopefully you, has requested to reset the password for your
- GitLab account on #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}.
- %p
- If you did not perform this request, you can safely ignore this email.
- %p
- Otherwise, click the link below to complete the process.
- #cta
- = link_to('Reset password', edit_password_url(@resource, reset_password_token: @token))
+= email_default_heading("Hello, #{@resource.name}!")
+%p
+ Someone, hopefully you, has requested to reset the password for your
+ GitLab account on #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}.
+%p
+ If you did not perform this request, you can safely ignore this email.
+%p
+ Otherwise, click the link below to complete the process.
+#cta
+ = link_to('Reset password', edit_password_url(@resource, reset_password_token: @token))
diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml
index 9990d1ccac6..79e3a35cc9a 100644
--- a/app/views/devise/mailer/unlock_instructions.html.haml
+++ b/app/views/devise/mailer/unlock_instructions.html.haml
@@ -1,9 +1,8 @@
-.center
- #content
- %h2 Hello, #{@resource.name}!
- %p
- Your GitLab account has been locked due to an excessive amount of unsuccessful
- sign in attempts. Your account will automatically unlock in #{time_ago_in_words(Devise.unlock_in.from_now)}
- or you may click the link below to unlock now.
- #cta
- = link_to('Unlock account', unlock_url(@resource, unlock_token: @token))
+#content
+ = email_default_heading("Hello, #{@resource.name}!")
+ %p
+ Your GitLab account has been locked due to an excessive amount of unsuccessful
+ sign in attempts. Your account will automatically unlock in #{time_ago_in_words(Devise.unlock_in.from_now)}
+ or you may click the link below to unlock now.
+ #cta
+ = link_to('Unlock account', unlock_url(@resource, unlock_token: @token))
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 5e189e6dc54..eb0e6701627 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -6,10 +6,10 @@
= devise_error_messages!
= f.hidden_field :reset_password_token
.form-group
- = f.label 'New password', for: :password
+ = f.label 'New password', for: "user_password"
= f.password_field :password, class: "form-control top", required: true, title: 'This field is required'
.form-group
- = f.label 'Confirm new password', for: :password_confirmation
+ = f.label 'Confirm new password', for: "user_password_confirmation"
= f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true
.clearfix
= f.submit "Change your password", class: "btn btn-primary"
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 21c751a23f8..4095f30c369 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,6 +1,6 @@
= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
.form-group
- = f.label "Username or email", for: :login
+ = f.label "Username or email", for: "user_login"
= f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required."
.form-group
= f.label :password
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index af87129e49e..dd61dcf2a7b 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -6,15 +6,15 @@
- else
= render 'devise/shared/tabs_normal'
.tab-content
- - if signin_enabled? || ldap_enabled? || crowd_enabled?
+ - if password_authentication_enabled? || ldap_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
-# Signup only makes sense if you can also sign-in
- - if signin_enabled? && signup_enabled?
+ - if password_authentication_enabled? && signup_enabled?
= render 'devise/shared/signup_box'
-# Show a message if none of the mechanisms above are enabled
- - if !signin_enabled? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
+ - if !password_authentication_enabled? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
No authentication methods configured.
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index f92f89e73ff..bfd7dd25a7d 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -6,4 +6,7 @@
- providers.each do |provider|
%span.light
- has_icon = provider_has_icon?(provider)
- = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn')
+ = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: 'oauth-login' + (has_icon ? ' oauth-image-link' : ' btn'), id: "oauth-login-#{provider}"
+ %fieldset.prepend-top-10
+ = check_box_tag :remember_me
+ = label_tag :remember_me, 'Remember me'
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index da4769e214e..3b06008febe 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -7,12 +7,12 @@
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && !crowd_enabled?) }
.login-body
= render 'devise/sessions/new_ldap', server: server
- - if signin_enabled?
+ - if password_authentication_enabled?
.login-box.tab-pane{ id: 'ldap-standard', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
-- elsif signin_enabled?
+- elsif password_authentication_enabled?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index a2f6a7ab1cb..298604dee8c 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -8,7 +8,7 @@
= f.text_field :name, class: "form-control top", required: true, title: "This field is required."
.username.form-group
= f.label :username
- = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, required: true, title: 'Please create a username with only alphanumeric characters.'
+ = f.text_field :username, class: "form-control middle", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.'
%p.validation-error.hide Username is already taken.
%p.validation-success.hide Username is available.
%p.validation-pending.hide Checking username availability...
@@ -25,7 +25,7 @@
%div
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
- %div
+ .submit-container
= f.submit "Register", class: "btn-register btn"
.clearfix.submit-container
%p
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index dd34600490e..6d0243a325d 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -5,9 +5,9 @@
- @ldap_servers.each_with_index do |server, i|
%li{ class: active_when(i.zero? && !crowd_enabled?) }
= link_to server['label'], "##{server['provider_name']}", 'data-toggle' => 'tab'
- - if signin_enabled?
+ - if password_authentication_enabled?
%li
= link_to 'Standard', '#ldap-standard', 'data-toggle' => 'tab'
- - if signin_enabled? && signup_enabled?
+ - if password_authentication_enabled? && signup_enabled?
%li
= link_to 'Register', '#register-pane', 'data-toggle' => 'tab'
diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml
index c225d800a98..212856c0676 100644
--- a/app/views/devise/shared/_tabs_normal.html.haml
+++ b/app/views/devise/shared/_tabs_normal.html.haml
@@ -1,6 +1,6 @@
%ul.nav-links.new-session-tabs.nav-tabs{ role: 'tablist' }
%li.active{ role: 'presentation' }
%a{ href: '#login-pane', data: { toggle: 'tab' }, role: 'tab' } Sign in
- - if signin_enabled? && signup_enabled?
+ - if password_authentication_enabled? && signup_enabled?
%li{ role: 'presentation' }
%a{ href: '#register-pane', data: { toggle: 'tab' }, role: 'tab' } Register
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 549364761e6..4a41be972da 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -2,8 +2,9 @@
- blob = discussion.blob
.diff-file.file-holder
- .js-file-title.file-title
- = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion)
+ .js-file-title.file-title.file-title-flex-parent
+ .file-header-content
+ = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
.diff-content.code.js-syntax-highlight
%table
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 8440fb3d785..578e751ab47 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -20,22 +20,21 @@
= discussion.author.to_reference
started a discussion
- - url = discussion_diff_path(discussion)
+ - url = discussion_path(discussion)
- if discussion.for_commit? && @noteable != discussion.noteable
on
- commit = discussion.noteable
- if commit
commit
- = link_to commit.short_id, url, class: 'monospace'
+ = link_to commit.short_id, url, class: 'commit-sha'
- else
a deleted commit
- elsif discussion.diff_discussion?
on
= conditional_link_to url.present?, url do
- - if discussion.active?
- the diff
- - else
- an outdated diff
+ - unless discussion.active?
+ an old version of
+ the diff
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
= render "discussions/headline", discussion: discussion
diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml
index 69bd416c4de..3db509f24a5 100644
--- a/app/views/discussions/_jump_to_next.html.haml
+++ b/app/views/discussions/_jump_to_next.html.haml
@@ -3,7 +3,7 @@
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
%button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
- title: "Jump to next unresolved discussion",
- "aria-label" => "Jump to next unresolved discussion",
+ ":title" => "buttonText",
+ ":aria-label" => "buttonText",
data: { container: "body" } }
= custom_icon("next_discussion")
diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml
index ca9e0e8728a..cab346fb514 100644
--- a/app/views/discussions/_new_issue_for_all_discussions.html.haml
+++ b/app/views/discussions/_new_issue_for_all_discussions.html.haml
@@ -3,4 +3,4 @@
.btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve all discussions in new issue",
"aria-label" => "Resolve all discussions in a new issue",
"data-container" => "body" }
- = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion'
+ = link_to custom_icon('icon_mr_issue'), new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion'
diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml
index df5546a1e32..a9bc317b8f8 100644
--- a/app/views/discussions/_new_issue_for_discussion.html.haml
+++ b/app/views/discussions/_new_issue_for_discussion.html.haml
@@ -5,4 +5,4 @@
.btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve this discussion in a new issue",
"aria-label" => "Resolve this discussion in a new issue",
"data-container" => "body" }
- = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion'
+ = link_to custom_icon('icon_mr_issue'), new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion'
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 34789808f10..db5ab939948 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,9 +1,11 @@
.discussion-notes
%ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+ = render partial: "shared/notes/note", collection: discussion.notes, as: :note
- - if current_user
- .discussion-reply-holder
+ .flash-container
+
+ .discussion-reply-holder
+ - if can_create_note?
- if discussion.potentially_resolvable?
- line_type = local_assigns.fetch(:line_type, nil)
@@ -18,3 +20,10 @@
= render "discussions/jump_to_next", discussion: discussion
- else
= link_to_reply_discussion(discussion)
+ - elsif !current_user
+ .disabled-comment.text-center
+ Please
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ or
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ to reply
diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml
index fb6aa30acee..49f90298a50 100644
--- a/app/views/doorkeeper/applications/edit.html.haml
+++ b/app/views/doorkeeper/applications/edit.html.haml
@@ -1,3 +1,4 @@
- page_title "Edit", @application.name, "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
%h3.page-title Edit application
= render 'form', application: @application
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index aa271150b07..d1237d7bf6f 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -1,7 +1,8 @@
- page_title "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
@@ -10,7 +11,7 @@
and applications that you've authorized to use your account.
- else
Manage applications that you've authorized to use your account.
- .col-lg-9
+ .col-lg-8
- if user_oauth_applications?
%h5.prepend-top-0
Add new application
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 559de63d96d..72eab964766 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -1,4 +1,6 @@
- page_title @application.name, "Applications"
+- @content_class = "limit-container-width" unless fluid_layout
+
%h3.page-title
Application: #{@application.name}
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index 72508b91134..20b7fa471a0 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -1,16 +1,15 @@
- content_for(:title, 'Auth Error')
-%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') }
- %h1
- 422
+
.container
+ = render "shared/errors/graphic_422.svg"
%h3 Sign-in using #{@provider} auth failed
- %hr
- %p Sign-in failed because #{@error}.
- %p There are couple of steps you can take:
-%ul
- %li Try logging in using your email
- %li Try logging in using your username
- %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) }
+ %p.light.subtitle Sign-in failed because #{@error}.
+
+ %p Try logging in using your username or email. If you have forgotten your password, try recovering it
-%p If none of the options work, try contacting the GitLab administrator.
+ = link_to "Sign in", new_session_path(:user), class: 'btn primary'
+ = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary'
+
+ %hr
+ %p.light If none of the options work, try contacting a GitLab administrator.
diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml
index 1bc9f604438..98cdcca3ecc 100644
--- a/app/views/events/_commit.html.haml
+++ b/app/views/events/_commit.html.haml
@@ -1,5 +1,5 @@
%li.commit
.commit-row-title
- = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id])
+ = link_to truncate_sha(event.commit_id), project_commit_path(project, event.commit_id), class: "commit-sha", alt: '', title: truncate_sha(event.commit_id)
&middot;
- = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author
+ = markdown event_commit_title(event.commit_title), project: project, pipeline: :single_line, author: event.author
diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml
deleted file mode 100644
index 1584695a62b..00000000000
--- a/app/views/events/_event_last_push.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- if show_last_push_widget?(event)
- .row-content-block.clear-block.last-push-widget
- .event-last-push
- .event-last-push-text
- %span You pushed to
- = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do
- %strong= event.ref_name
- %span at
- %strong= link_to_project event.project
- #{time_ago_with_tooltip(event.created_at)}
-
- .pull-right
- = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
- Create merge request
diff --git a/app/views/events/_event_push.atom.haml b/app/views/events/_event_push.atom.haml
index f8f0bcb7608..bf655f9d21a 100644
--- a/app/views/events/_event_push.atom.haml
+++ b/app/views/events/_event_push.atom.haml
@@ -1,14 +1,13 @@
%div{ xmlns: "http://www.w3.org/1999/xhtml" }
- - event.commits.first(15).each do |commit|
- %p
- %strong= commit[:author][:name]
- = link_to "(##{truncate_sha(commit[:id])})", namespace_project_commit_path(event.project.namespace, event.project, id: commit[:id])
- %i
- at
- = commit[:timestamp].to_time.to_s(:short)
- %blockquote= markdown(escape_once(commit[:message]), pipeline: :atom, project: event.project, author: event.author)
- - if event.commits_count > 15
+ %p
+ %strong= event.author_name
+ = link_to "(#{truncate_sha(event.commit_id)})", project_commit_path(event.project, event.commit_id)
+ %i
+ at
+ = event.created_at.to_s(:short)
+ %blockquote= markdown(escape_once(event.commit_title), pipeline: :atom, project: event.project, author: event.author)
+ - if event.commits_count > 1
%p
%i
\... and
- = pluralize(event.commits_count - 15, "more commit")
+ = pluralize(event.commits_count - 1, "more commit")
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index c0943100ae3..973c652ad88 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -6,17 +6,15 @@
%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)
- = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link
+ - commits_link = project_commits_path(project, event.ref_name)
+ = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name'
= render "events/event_scope", event: event
- if event.push_with_commits?
.event-body
%ul.well-list.event_commits
- - few_commits = event.commits[0...2]
- - few_commits.each do |commit|
- = render "events/commit", commit: commit, project: project, event: event
+ = render "events/commit", project: project, event: event
- create_mr = event.new_ref? && create_mr_button?(project.default_branch, event.ref_name, project) && event.authored_by?(current_user)
- if event.commits_count > 1
@@ -31,7 +29,7 @@
- from = project.default_branch
- from_label = from
- = link_to namespace_project_compare_path(project.namespace, project, from: from, to: event.commit_to) do
+ = link_to project_compare_path(project, from: from, to: event.commit_to) do
Compare #{from_label}...#{truncate_sha(event.commit_to)}
- if create_mr
@@ -44,9 +42,6 @@
= link_to create_mr_path(project.default_branch, event.ref_name, project) do
Create Merge Request
- elsif event.rm_ref?
- - repository = project.repository
- - last_commit = repository.commit(event.commit_from)
- - if last_commit
- .event-body
- %ul.well-list.event_commits
- = render "events/commit", commit: last_commit, project: project, event: event
+ .event-body
+ %ul.well-list.event_commits
+ = render "events/commit", project: project, event: event
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index bb2cd0d44c8..2651ef37e67 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
@@ -7,6 +8,15 @@
= render 'explore/head'
= render 'nav'
+- if cookies[:explore_groups_landing_dismissed] != 'true'
+ .explore-groups.landing.content-block.js-explore-groups-landing.hidden
+ %button.dismiss-button{ type: 'button', 'aria-label' => 'Dismiss' }= icon('times')
+ .svg-container
+ = custom_icon('icon_explore_groups_splash')
+ .inner-content
+ %p Below you will find all the groups that are public.
+ %p You can easily contribute to them by requesting to join these groups.
+
- if @groups.present?
= render 'groups'
- else
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index ec461755103..f00802e0af7 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index e5706d04736..94fc4ac21d2 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -1,3 +1,4 @@
+- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", snippets_path
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index d7851c79990..fd6e7111f38 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -1,6 +1,3 @@
-.hidden-xs
- = render "events/event_last_push", event: @last_push
-
.nav-block
.controls
= link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do
diff --git a/app/views/groups/_head.html.haml b/app/views/groups/_head.html.haml
index 873504099d4..0f63774fb9b 100644
--- a/app/views/groups/_head.html.haml
+++ b/app/views/groups/_head.html.haml
@@ -12,3 +12,6 @@
= link_to activity_group_path(@group), title: 'Activity' do
%span
Activity
+
+.hidden-xs
+ = render "projects/last_push"
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 41f54f6bf42..181c7bee702 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -3,7 +3,7 @@
.avatar-container.s70.group-avatar
= image_tag group_icon(@group), class: "avatar s70 avatar-tile"
%h1.group-title
- @#{@group.path}
+ = @group.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, fw: false)
diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml
index 2454e7355a7..623d233a46a 100644
--- a/app/views/groups/_settings_head.html.haml
+++ b/app/views/groups/_settings_head.html.haml
@@ -12,3 +12,8 @@
= link_to projects_group_path(@group), title: 'Projects' do
%span
Projects
+
+ = nav_link(controller: :ci_cd) do
+ = link_to group_settings_ci_cd_path(@group), title: 'Pipelines' do
+ %span
+ Pipelines
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
deleted file mode 100644
index b1694c919d0..00000000000
--- a/app/views/groups/_shared_projects.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false
diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml
index b2097e88741..35b75bc0923 100644
--- a/app/views/groups/_show_nav.html.haml
+++ b/app/views/groups/_show_nav.html.haml
@@ -2,6 +2,7 @@
= nav_link(page: group_path(@group)) do
= link_to group_path(@group) do
Projects
- = nav_link(page: subgroups_group_path(@group)) do
- = link_to subgroups_group_path(@group) do
- Subgroups
+ - if Group.supports_nested_groups?
+ = nav_link(page: subgroups_group_path(@group)) do
+ = link_to subgroups_group_path(@group) do
+ Subgroups
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 7d5add3cc1c..9ebb3894c55 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -45,10 +45,13 @@
.panel.panel-danger
.panel-heading Remove group
.panel-body
- %p
- Removing group will cause all child projects and resources to be removed.
- %br
- %strong Removed group can not be restored!
+ = form_tag(@group, method: :delete) do
+ %p
+ Removing group will cause all child projects and resources to be removed.
+ %br
+ %strong Removed group can not be restored!
- .form-actions
- = link_to 'Remove group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove"
+ .form-actions
+ = button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) }
+
+= render 'shared/confirm_modal', phrase: @group.path
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 2e4e4511bb6..ad9d5562ded 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -27,6 +27,6 @@
Members with access to
%strong= @group.name
%span.badge= @members.total_count
- %ul.content-list
+ %ul.content-list.members-list
= render partial: 'shared/members/member', collection: @members, as: :member
= paginate @members, theme: 'gitlab'
diff --git a/app/views/groups/issues.atom.builder b/app/views/groups/issues.atom.builder
index 469768d83f2..a239ea8caf0 100644
--- a/app/views/groups/issues.atom.builder
+++ b/app/views/groups/issues.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@group.name} issues"
- xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
- xml.link href: issues_group_url, rel: "alternate", type: "text/html"
- xml.id issues_group_url
- xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
+xml.title "#{@group.name} issues"
+xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: issues_group_url, rel: "alternate", type: "text/html"
+xml.id issues_group_url
+xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
- xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
-end
+xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 182dbe2f98a..f83ebbf09ef 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,19 +1,30 @@
- page_title "Issues"
+- group_issues_exists = group_issues(@group).exists?
= render "head_issues"
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
-- if group_issues(@group).exists?
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'filtered_search'
+
+- if show_new_nav? && group_issues_exists
+ - content_for :breadcrumbs_extra do
+ = link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do
+ = icon('rss')
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
+
+- if group_issues_exists
.top-area
= render 'shared/issuable/nav', type: :issues
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
= link_to params.merge(rss_url_options), class: 'btn' do
= icon('rss')
%span.icon-label
Subscribe
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
- = render 'shared/issuable/filter', type: :issues
+ = render 'shared/issuable/search_bar', type: :issues
.row-content-block.second-block
Only issues from the
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 2bc00fb16c8..50179a47797 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,14 +1,18 @@
- page_title 'Labels'
+- if show_new_nav? && can?(current_user, :admin_label, @group)
+ - content_for :breadcrumbs_extra do
+ = link_to "New label", new_group_label_path(@group), class: "btn btn-new"
+
= render "groups/head_issues"
+
.top-area.adjust
.nav-text
Labels can be applied to issues and merge requests. Group labels are available for any project within the group.
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
- if can?(current_user, :admin_label, @group)
- = link_to new_group_label_path(@group), class: "btn btn-new" do
- New label
+ = link_to "New label", new_group_label_path(@group), class: "btn btn-new"
.labels
.other-labels
diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml
index 2be87460b1d..ae240490bbd 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Labels"
- page_title 'New Label'
- header_title group_title(@group, 'Labels', group_labels_path(@group))
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 8fe0bd149f3..997c82c77d9 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,12 +1,16 @@
- page_title "Merge Requests"
+- if show_new_nav? && current_user
+ - content_for :breadcrumbs_extra do
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request"
+
- if @group_merge_requests.empty?
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- if current_user
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request"
= render 'shared/issuable/filter', type: :merge_requests
@@ -18,5 +22,4 @@
- if current_user
To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page.
- .prepend-top-default
- = render 'shared/merge_requests'
+ = render 'shared/merge_requests'
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
new file mode 100644
index 00000000000..7f450cd9a93
--- /dev/null
+++ b/app/views/groups/milestones/_form.html.haml
@@ -0,0 +1,27 @@
+= form_for [@group, @milestone], html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+ .row
+ = form_errors(@milestone)
+
+ .col-md-6
+ .form-group
+ = f.label :title, "Title", class: "control-label"
+ .col-sm-10
+ = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
+ .form-group.milestone-description
+ = f.label :description, "Description", class: "control-label"
+ .col-sm-10
+ = render layout: 'projects/md_preview', locals: { url: '' } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
+ .clearfix
+ .error-alert
+
+ = render "shared/milestones/form_dates", f: f
+
+ .form-actions
+ - if @milestone.new_record?
+ = f.submit 'Create milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+ - else
+ = f.submit 'Update milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
+
diff --git a/app/views/groups/milestones/_milestone.html.haml b/app/views/groups/milestones/_milestone.html.haml
index 4c4e0a26728..bae8997e24c 100644
--- a/app/views/groups/milestones/_milestone.html.haml
+++ b/app/views/groups/milestones/_milestone.html.haml
@@ -1,5 +1,6 @@
+
= render 'shared/milestones/milestone',
- milestone_path: group_milestone_path(@group, milestone.safe_title, title: milestone.title),
+ milestone_path: group_milestone_route(milestone),
issues_path: issues_group_path(@group, milestone_title: milestone.title),
merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml
new file mode 100644
index 00000000000..5f6d7d209d0
--- /dev/null
+++ b/app/views/groups/milestones/edit.html.haml
@@ -0,0 +1,7 @@
+- page_title "Milestones"
+- render "header_title"
+
+%h3.page-title
+ Edit Milestone
+
+= render "form"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index f91bee0b610..66c6cc9e279 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,18 +1,16 @@
- page_title "Milestones"
+- if show_new_nav? && can?(current_user, :admin_milestones, @group)
+ - content_for :breadcrumbs_extra do
+ = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
+
= render "groups/head_issues"
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
- .nav-controls
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
- if can?(current_user, :admin_milestones, @group)
- = link_to new_group_milestone_path(@group), class: "btn btn-new" do
- New milestone
-
-.row-content-block
- Only milestones from
- %strong= @group.name
- group are listed here.
+ = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
.milestones
%ul.content-list
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 8d3aa4d1a74..eca7fb9ddb1 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -1,43 +1,8 @@
+- breadcrumb_title "Milestones"
- page_title "Milestones"
- header_title group_title(@group, "Milestones", group_milestones_path(@group))
%h3.page-title
New Milestone
-%p.light
- This will create milestone in every selected project
-%hr
-
-= form_for @milestone, url: group_milestones_path(@group), html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- .row
- - if @milestone.errors.any?
- #error_explanation
- .alert.alert-danger
- %ul
- - @milestone.errors.full_messages.each do |msg|
- %li
- = msg
-
- .col-md-6
- .form-group
- = f.label :title, "Title", class: "control-label"
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
- .form-group.milestone-description
- = f.label :description, "Description", class: "control-label"
- .col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
- = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
- .clearfix
- .error-alert
- .form-group
- = f.label :projects, "Projects", class: "control-label"
- .col-sm-10
- = f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
- { selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
-
- = render "shared/milestones/form_dates", f: f
-
- .form-actions
- = f.submit 'Create milestone', class: "btn-create btn"
- = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+= render "form"
diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml
index 33e68bc766e..54b1b7a734a 100644
--- a/app/views/groups/milestones/show.html.haml
+++ b/app/views/groups/milestones/show.html.haml
@@ -1,4 +1,4 @@
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
-= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
+= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.is_legacy_group_milestone?
= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 000c7af2326..e9daac95ca1 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -1,3 +1,6 @@
+- @breadcrumb_link = dashboard_groups_path
+- breadcrumb_title "Groups"
+- @hide_top_links = true
- page_title 'New Group'
- header_title "Groups", dashboard_groups_path
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 62ad47972b9..7a2e688a114 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -20,8 +20,8 @@
%span.label.label-warning archived
%span.badge
= storage_counter(project.statistics.storage_size)
- = link_to 'Members', namespace_project_project_members_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
- = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
+ = link_to 'Members', project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
+ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
= link_to 'Remove', project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-sm btn-remove"
- if @projects.blank?
.nothing-here-block This group has no projects yet
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
new file mode 100644
index 00000000000..bf36baf48ab
--- /dev/null
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -0,0 +1,4 @@
+- page_title "Pipelines"
+= render "groups/settings_head"
+
+= render 'ci/variables/index'
diff --git a/app/views/groups/show.atom.builder b/app/views/groups/show.atom.builder
index 914091dfd15..0f67b15c301 100644
--- a/app/views/groups/show.atom.builder
+++ b/app/views/groups/show.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@group.name} activity"
- xml.link href: group_url(@group, rss_url_options), rel: "self", type: "application/atom+xml"
- xml.link href: group_url(@group), rel: "alternate", type: "text/html"
- xml.id group_url(@group)
- xml.updated @events[0].updated_at.xmlschema if @events[0]
+xml.title "#{@group.name} activity"
+xml.link href: group_url(@group, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: group_url(@group), rel: "alternate", type: "text/html"
+xml.id group_url(@group)
+xml.updated @events[0].updated_at.xmlschema if @events[0]
- xml << render(@events) if @events.any?
-end
+xml << render(@events) if @events.any?
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 18997baa998..e07f61c94e4 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- breadcrumb_title "Group"
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
@@ -6,7 +7,6 @@
= render 'groups/head'
= render 'groups/home_panel'
-
.groups-header{ class: container_class }
.top-area
= render 'groups/show_nav'
diff --git a/app/views/groups/variables/show.html.haml b/app/views/groups/variables/show.html.haml
new file mode 100644
index 00000000000..df533952b76
--- /dev/null
+++ b/app/views/groups/variables/show.html.haml
@@ -0,0 +1 @@
+= render 'ci/variables/show'
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index ea8bbe92d86..b18b3dd5766 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -27,6 +27,11 @@
%td.shortcut
.key f
%td Focus Filter
+ - if performance_bar_enabled?
+ %tr
+ %td.shortcut
+ .key p b
+ %td Show/hide the Performance Bar
%tr
%td.shortcut
.key ?
@@ -116,7 +121,7 @@
.key g
.key p
%td
- Go to the project's home page
+ Go to the project's overview page
%tr
%td.shortcut
.key g
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index f93b6b63426..c25eae63eec 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,10 +1,15 @@
%div
+- if current_application_settings.help_page_text.present?
+ = markdown_field(current_application_settings, :help_page_text)
+ %hr
+
+- unless current_application_settings.help_page_hide_commercial_content?
%h1
GitLab
Community Edition
- if user_signed_in?
%span= Gitlab::VERSION
- %small= Gitlab::REVISION
+ %small= link_to Gitlab::REVISION, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', 'gitlab-ce', Gitlab::REVISION)
= version_status_badge
%p.slead
GitLab is open source software to collaborate on code.
@@ -18,24 +23,20 @@
Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.
%br
Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}.
- - if current_application_settings.help_page_text.present?
- %hr
- = markdown_field(current_application_settings, :help_page_text)
-
-%hr
+ %hr
-.row
+.row.prepend-top-default
.col-md-8
.documentation-index
- = preserve do
- = markdown(@help_index)
+ = markdown(@help_index)
.col-md-4
.panel.panel-default
.panel-heading
Quick help
%ul.well-list
- %li= link_to 'See our website for getting help', promo_url + '/getting-help/'
+ %li= link_to 'See our website for getting help', support_url
%li= link_to 'Use the search bar on the top of this page', '#', onclick: 'Shortcuts.focusSearch(event)'
%li= link_to 'Use shortcuts', '#', onclick: 'Shortcuts.toggleHelp()'
- %li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/'
- %li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare'
+ - unless current_application_settings.help_page_hide_commercial_content?
+ %li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/'
+ %li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare'
diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml
index f6ebd76af9d..c07c148a12a 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,3 +1,3 @@
- page_title @path.split("/").reverse.map(&:humanize)
-.documentation.wiki
+.documentation.wiki.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 615dd56afbd..445f0dffbcc 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -1,5 +1,7 @@
- page_title "UI Development Kit", "Help"
- lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare."
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('ui_development_kit')
.gitlab-ui-dev-kit
%h1 GitLab UI development kit
@@ -187,7 +189,7 @@
= icon('chevron-down')
%ul.dropdown-menu
%li
- %a Sort by date
+ = link_to 'Sort by date', '#'
= link_to 'New issue', '#', class: 'btn btn-new btn-inverted'
@@ -407,29 +409,6 @@
.dropdown-content
.dropdown-loading
= icon('spinner spin')
- :javascript
- $('#js-project-dropdown').glDropdown({
- data: function (term, callback) {
- Api.projects(term, { order_by: 'last_activity_at' }, function (data) {
- callback(data);
- });
- },
- text: function (project) {
- return project.name_with_namespace || project.name;
- },
- selectable: true,
- fieldName: "author_id",
- filterable: true,
- search: {
- fields: ['name_with_namespace']
- },
- id: function (data) {
- return data.id;
- },
- isSelected: function (data) {
- return data.id === 2;
- }
- })
.example
%div
@@ -525,7 +504,7 @@
%h4
%code .file-holder
- - blob = Snippet.new(content: "Wow\nSuch\nFile")
+ - blob = Snippet.new(content: "Wow\nSuch\nFile").blob
.example
.file-holder
.js-file-title.file-title
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 0e7f0b5ed4f..e9a04e6c122 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -25,7 +25,7 @@
%td
= provider_project_link(provider, project.import_source)
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml
index 8e929538351..4dc3a4a0acf 100644
--- a/app/views/import/base/create.js.haml
+++ b/app/views/import/base/create.js.haml
@@ -4,10 +4,10 @@
job.attr("id", "project_#{@project.id}")
target_field = job.find(".import-target")
target_field.empty()
- target_field.append('#{link_to @project.path_with_namespace, namespace_project_path(@project.namespace, @project)}')
+ target_field.append('#{link_to @project.full_path, project_path(@project)}')
$("table.import-jobs tbody").prepend(job)
job.addClass("active").find(".import-actions").html("<i class='fa fa-spinner fa-spin'></i> started")
- else
:plain
job = $("tr#repo_#{@repo_id}")
- job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(@project.errors.full_messages.join(','))}")
+ job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(h(@project.errors.full_messages.join(',')))}")
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index e6058617ac9..9589e0956f4 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -35,7 +35,7 @@
%td
= link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer'
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 9999a4362c6..84e0009487f 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -10,7 +10,7 @@
Customize how FogBugz email addresses and usernames are imported into GitLab.
In the next step, you'll be able to select the projects you want to import.
%p
- The user map is a mapping of the FogBugz users that participated on your projects to the way their email address and usernames wil be imported into GitLab. You can change this by populating the table below.
+ The user map is a mapping of the FogBugz users that participated on your projects to the way their email address and usernames will be imported into GitLab. You can change this by populating the table below.
%ul
%li
%strong Default: Map a FogBugz account ID to a full name
@@ -46,6 +46,3 @@
.form-actions
= submit_tag 'Continue to the next step', class: 'btn btn-create'
-
-:javascript
- new UsersSelect();
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index 5de5da5e6a2..7b832c6a23a 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -33,7 +33,7 @@
%td
= project.import_source
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index 7456799ca0e..37734414835 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -28,7 +28,7 @@
%td
= link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank"
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 767dffb5589..008e8287aa3 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,25 +1,43 @@
- page_title "GitLab Import"
- header_title "Projects", root_path
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'project_import_gl'
+
%h3.page-title
= icon('gitlab')
Import an exported GitLab project
%hr
-= form_tag import_gitlab_project_path, class: 'form-horizontal', multipart: true do
- %p
- Project will be imported as
- %strong
- #{@namespace.name}/#{@path}
+= form_tag import_gitlab_project_path, class: 'new_project', multipart: true do
+ .row
+ .form-group.col-xs-12.col-sm-6
+ = label_tag :namespace_id, 'Project path', class: 'label-light'
+ .form-group
+ .input-group
+ - if current_user.can_select_namespace?
+ .input-group-addon
+ = root_url
+ = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1
- %p
- To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.
- .form-group
- = hidden_field_tag :namespace_id, @namespace.id
- = hidden_field_tag :path, @path
- = label_tag :file, class: 'control-label' do
- %span GitLab project export
- .col-sm-10
- = file_field_tag :file, class: ''
+ - else
+ .input-group-addon.static-namespace
+ #{root_url}#{current_user.username}/
+ = hidden_field_tag :namespace_id, value: current_user.namespace_id
+ .form-group.col-xs-12.col-sm-6.project-path
+ = label_tag :path, 'Project name', class: 'label-light'
+ = text_field_tag :path, nil, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, autofocus: true, required: true
- .form-actions
- = submit_tag 'Import project', class: 'btn btn-create'
+ .row
+ .form-group.col-md-12
+ To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.
+ .row
+ .form-group.col-sm-12
+ = hidden_field_tag :namespace_id, @namespace.id
+ = hidden_field_tag :path, @path
+ = label_tag :file, 'GitLab project export', class: 'label-light'
+ .form-group
+ = file_field_tag :file, class: ''
+ .row
+ .form-actions
+ = submit_tag 'Import project', class: 'btn btn-create'
+ = link_to 'Cancel', new_project_path, class: 'btn btn-cancel'
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
index 60de6bfe816..bc61aeece72 100644
--- a/app/views/import/google_code/status.html.haml
+++ b/app/views/import/google_code/status.html.haml
@@ -38,7 +38,7 @@
%td
= link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer'
%td
- = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project]
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
- if project.import_status == 'finished'
%span
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 882fdf1317d..ad6213b4efd 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -12,7 +12,7 @@
- project = @member.source
project
%strong
- = link_to project.name_with_namespace, namespace_project_url(project.namespace, project)
+ = link_to project.name_with_namespace, project_url(project)
- when Group
- group = @member.source
group
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index 23a88448055..0c113c08526 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -1,6 +1,6 @@
xml.entry do
- xml.id namespace_project_issue_url(issue.project.namespace, issue.project, issue)
- xml.link href: namespace_project_issue_url(issue.project.namespace, issue.project, issue)
+ xml.id project_issue_url(issue.project, issue)
+ xml.link href: project_issue_url(issue.project, issue)
xml.title truncate(issue.title, length: 80)
xml.updated issue.updated_at.xmlschema
xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(issue.author_email))
@@ -23,10 +23,19 @@ xml.entry do
end
end
- if issue.assignee
+ if issue.assignees.any?
+ xml.assignees do
+ issue.assignees.each do |assignee|
+ xml.assignee do
+ xml.name assignee.name
+ xml.email assignee.public_email
+ end
+ end
+ end
+
xml.assignee do
- xml.name issue.assignee.name
- xml.email issue.assignee_public_email
+ xml.name issue.assignees.first.name
+ xml.email issue.assignees.first.public_email
end
end
end
diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml
new file mode 100644
index 00000000000..250029c4475
--- /dev/null
+++ b/app/views/kaminari/gitlab/_without_count.html.haml
@@ -0,0 +1,8 @@
+.gl-pagination
+ %ul.pagination.clearfix
+ - if previous_path
+ %li.prev
+ = link_to(t('views.pagination.previous'), previous_path, rel: 'prev')
+ - if next_path
+ %li.next
+ = link_to(t('views.pagination.next'), next_path, rel: 'next')
diff --git a/app/views/layouts/_bootlint.haml b/app/views/layouts/_bootlint.haml
index 69280687a9d..d603a74c4e4 100644
--- a/app/views/layouts/_bootlint.haml
+++ b/app/views/layouts/_bootlint.haml
@@ -1,4 +1,5 @@
+-# haml-lint:disable InlineJavaScript
:javascript
- jQuery(document).ready(function() {
- javascript:(function(){var s=document.createElement("script");s.onload=function(){bootlint.showLintReportForCurrentDocument([], {hasProblems: false, problemFree: false});};s.src="https://maxcdn.bootstrapcdn.com/bootlint/latest/bootlint.min.js";document.body.appendChild(s)})();
- });
+ window.onload = function() {
+ var s=document.createElement("script");s.onload=function(){bootlint.showLintReportForCurrentDocument([], {hasProblems: false, problemFree: false});};s.src="https://maxcdn.bootstrapcdn.com/bootlint/latest/bootlint.min.js";document.body.appendChild(s);
+ }
diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml
index 3a7e0929c16..e2dbdcbb939 100644
--- a/app/views/layouts/_broadcast.html.haml
+++ b/app/views/layouts/_broadcast.html.haml
@@ -1 +1,2 @@
-= broadcast_message
+- BroadcastMessage.current&.each do |message|
+ = broadcast_message(message)
diff --git a/app/views/layouts/_google_analytics.html.haml b/app/views/layouts/_google_analytics.html.haml
index 81e03c7eff2..98ea96b0b77 100644
--- a/app/views/layouts/_google_analytics.html.haml
+++ b/app/views/layouts/_google_analytics.html.haml
@@ -1,3 +1,4 @@
+-# haml-lint:disable InlineJavaScript
:javascript
var _gaq = _gaq || [];
_gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 19473b6ab27..3babdae3968 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -11,6 +11,8 @@
%meta{ property: 'og:title', content: page_title }
%meta{ property: 'og:description', content: page_description }
%meta{ property: 'og:image', content: page_image }
+ %meta{ property: 'og:image:width', content: '64' }
+ %meta{ property: 'og:image:height', content: '64' }
%meta{ property: 'og:url', content: request.base_url + request.fullpath }
-# Twitter Card - https://dev.twitter.com/cards/types/summary
@@ -27,10 +29,25 @@
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
+ = stylesheet_link_tag "test", media: "all" if Rails.env.test?
+ = stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
- = webpack_bundle_tag "runtime"
+ - if show_new_nav?
+ = stylesheet_link_tag "new_nav", media: "all"
+ = stylesheet_link_tag "new_sidebar", media: "all"
+
+ = Gon::Base.render_data
+
+ - if content_for?(:library_javascripts)
+ = yield :library_javascripts
+
+ = webpack_bundle_tag "webpack_runtime"
= webpack_bundle_tag "common"
+ = webpack_bundle_tag "locale"
= webpack_bundle_tag "main"
+ = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled
+ = webpack_bundle_tag "test" if Rails.env.test?
+ = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 769f6fb0151..fe0ec35d003 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -2,14 +2,15 @@
- noteable_type = @noteable.class if @noteable.present?
- if project
+ -# haml-lint:disable InlineJavaScript
:javascript
+ gl = window.gl || {};
+ gl.GfmAutoComplete = gl.GfmAutoComplete || {};
gl.GfmAutoComplete.dataSources = {
- members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}",
- issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- mergeRequests: "#{merge_requests_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- labels: "#{labels_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}",
- commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
+ members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
+ issues: "#{issues_project_autocomplete_sources_path(project)}",
+ mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
+ labels: "#{labels_project_autocomplete_sources_path(project)}",
+ milestones: "#{milestones_project_autocomplete_sources_path(project)}",
+ commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
};
-
- gl.GfmAutoComplete.setup();
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
new file mode 100644
index 00000000000..983ed22a506
--- /dev/null
+++ b/app/views/layouts/_mailer.html.haml
@@ -0,0 +1,74 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+%html{ lang: "en" }
+ %head
+ %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
+ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
+ %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/
+ %title= message.subject
+ :css
+ /* CLIENT-SPECIFIC STYLES */
+ body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
+ table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
+ img { -ms-interpolation-mode: bicubic; }
+
+ /* iOS BLUE LINKS */
+ a[x-apple-data-detectors] {
+ color: inherit !important;
+ text-decoration: none !important;
+ font-size: inherit !important;
+ font-family: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important;
+ }
+
+ /* ANDROID MARGIN HACK */
+ body { margin:0 !important; }
+ div[style*="margin: 16px 0"] { margin:0 !important; }
+
+ @media only screen and (max-width: 639px) {
+ body, #body {
+ min-width: 320px !important;
+ }
+ table.wrapper {
+ width: 100% !important;
+ min-width: 320px !important;
+ }
+ table.wrapper > tbody > tr > td {
+ border-left: 0 !important;
+ border-right: 0 !important;
+ border-radius: 0 !important;
+ padding-left: 10px !important;
+ padding-right: 10px !important;
+ }
+ }
+ %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" }
+ %tbody
+ %tr.line
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }  
+ %tr.header
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ = header_logo
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ = yield
+
+ %tr.footer
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ %img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
+ %div
+ %a{ href: profile_notifications_url, style: "color:#3777b0;text-decoration:none;" } Manage all notifications
+ &middot;
+ %a{ href: help_url, style: "color:#3777b0;text-decoration:none;" } Help
+ %div
+ You're receiving this email because of your account on
+ = succeed "." do
+ %a{ href: root_url, style: "color:#3777b0;text-decoration:none;" }= Gitlab.config.gitlab.host
+
+ = yield :additional_footer
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index b7df11681d3..c4f8cd71395 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,13 +1,24 @@
-.page-with-sidebar{ class: page_gutter_class }
- - if defined?(nav) && nav
- .layout-nav
- .container-fluid
- = render "layouts/nav/#{nav}"
- - if content_for?(:sub_nav)
- = yield :sub_nav
+.page-with-sidebar{ class: page_with_sidebar_class }
+ - if show_new_nav?
+ - if defined?(nav) && nav
+ = render "layouts/nav/#{nav}"
+ - else
+ - if defined?(nav) && nav
+ .layout-nav
+ .container-fluid
+ = render "layouts/nav/#{nav}"
+ - if content_for?(:sub_nav)
+ = yield :sub_nav
.content-wrapper{ class: layout_nav_class }
+ - if show_new_nav?
+ .mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
+ - if show_new_nav?
+ - if content_for?(:new_global_flash)
+ = yield :new_global_flash
+ - unless @hide_breadcrumbs
+ = render "layouts/nav/breadcrumbs"
= render "layouts/flash"
= yield :flash_message
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
index 259b4f7cdfc..a888e8ae187 100644
--- a/app/views/layouts/_piwik.html.haml
+++ b/app/views/layouts/_piwik.html.haml
@@ -1,4 +1,5 @@
<!-- Piwik -->
+-# haml-lint:disable InlineJavaScript
:javascript
var _paq = _paq || [];
_paq.push(['trackPageView']);
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 0e64ebd71b8..59f16b47bf7 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -5,7 +5,7 @@
- if @group && @group.persisted? && @group.path
- group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) }
- if @project && @project.persisted?
- - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: namespace_project_issues_path(@project.namespace, @project), mr_path: namespace_project_merge_requests_path(@project.namespace, @project) }
+ - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project) }
.search.search-form{ class: "#{'has-location-badge' if label.present?}" }
= form_tag search_path, method: :get, class: 'navbar-form' do |f|
.search-input-container
@@ -13,7 +13,7 @@
.location-badge= label
.search-input-wrap
.dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }
+ = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' }
.dropdown-menu.dropdown-select
= dropdown_content do
%ul
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 87064cc9b3f..ae9eee215e0 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -1,5 +1,9 @@
- page_title "Admin Area"
- header_title "Admin Area", admin_root_path
-- nav "admin"
+- if show_new_nav?
+ - nav "new_admin_sidebar"
+ - @new_sidebar = true
+- else
+ - nav "admin"
= render template: "layouts/application"
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 36543edc040..b53f382fa3d 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,11 +1,13 @@
!!! 5
-%html{ lang: "en", class: "#{page_class}" }
+%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
- = Gon::Base.render_data
-
- = render "layouts/header/default", title: header_title
+ %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
+ = render "layouts/init_auto_complete" if @gfm_form
+ = render 'peek/bar'
+ - if show_new_nav?
+ = render "layouts/header/new"
+ - else
+ = render "layouts/header/default", title: header_title
= render 'layouts/page', sidebar: sidebar, nav: nav
= yield :scripts_body
- = render "layouts/init_auto_complete" if @gfm_form
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 3368a9beb29..52fb46eb8c9 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -3,7 +3,6 @@
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap
- = Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index 7466423a934..ed6731bde95 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -2,7 +2,6 @@
%html{ lang: "en" }
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless
- = Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
diff --git a/app/views/layouts/devise_mailer.html.haml b/app/views/layouts/devise_mailer.html.haml
deleted file mode 100644
index e1e1f9ae516..00000000000
--- a/app/views/layouts/devise_mailer.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-!!! 5
-%html
- %head
- %meta{ content: 'text/html; charset=UTF-8', 'http-equiv'=> 'Content-Type' }
- = stylesheet_link_tag 'mailers/devise'
-
- %body
- %table#wrapper
- %tr
- %td
- %table#header
- %td{ valign: "top" }
- = image_tag('mailers/gitlab_header_logo.png', id: 'logo', alt: 'GitLab Wordmark')
-
- %table#body
- %tr
- %td#body-container
- = yield
-
- - if Gitlab.com?
- %table#footer
- %tr
- %td#tanuki
- = image_tag('mailers/gitlab_tanuki_2x.png', alt: 'GitLab Logo')
- %tr
- %td#tagline
- Everyone can contribute
- %tr
- %td#social
- = link_to 'Blog', 'https://about.gitlab.com/blog/'
- = link_to 'Twitter', 'https://twitter.com/gitlab'
- = link_to 'Facebook', 'https://www.facebook.com/gitlab/'
- = link_to 'YouTube', 'https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg'
- = link_to 'LinkedIn', 'https://www.linkedin.com/company/gitlab-com'
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index f06acc98ca1..35abfa0e80c 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -1,6 +1,10 @@
- page_title @group.name
- page_description @group.description unless page_description
- header_title group_title(@group) unless header_title
-- nav "group"
+- if show_new_nav?
+ - nav "new_group_sidebar"
+ - @new_sidebar = true
+- else
+ - nav "group"
= render template: "layouts/application"
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 659d548df18..b32cfe158bb 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,5 @@
%header.navbar.navbar-gitlab{ class: nav_header_class }
+ .navbar-border
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
@@ -16,7 +17,7 @@
= link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do
= brand_header_logo
- .title-container
+ .title-container.js-title-container
%h1.title{ class: ('initializing' if @has_group_title) }= title
.navbar-collapse.collapse
@@ -35,10 +36,7 @@
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
- - if current_user.can_create_project?
- %li
- = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('plus fw')
+ = render 'layouts/header/new_dropdown'
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, title: 'Sherlock Transactions',
@@ -46,7 +44,7 @@
= icon('tachometer fw')
%li
= link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('hashtag fw')
+ = custom_icon('issues')
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
@@ -73,12 +71,14 @@
@#{current_user.username}
%li.divider
%li
- = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username }
+ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
+ %li
+ = link_to "Settings", profile_path
%li
- = link_to "Settings", profile_path, aria: { label: "Settings" }
+ = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation")
%li.divider
%li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link", aria: { label: "Sign out" }
+ = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- else
%li
%div
@@ -91,8 +91,3 @@
= yield :header_content
= render 'shared/outdated_browser'
-
-- if @project && !@project.empty_repo?
- - if ref = @ref || @project.repository.root_ref
- :javascript
- var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, ref)}";
diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml
new file mode 100644
index 00000000000..2c1c23d6ea9
--- /dev/null
+++ b/app/views/layouts/header/_new.html.haml
@@ -0,0 +1,86 @@
+%header.navbar.navbar-gitlab.navbar-gitlab-new{ class: nav_header_class }
+ %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
+ .container-fluid
+ .header-content
+ .title-container
+ %h1.title
+ = link_to root_path, title: 'Dashboard' do
+ = brand_header_logo
+ %span.logo-text.hidden-xs
+ = render 'shared/logo_type.svg'
+
+ - if current_user
+ = render "layouts/nav/new_dashboard"
+ - else
+ = render "layouts/nav/new_explore"
+
+ .navbar-collapse.collapse
+ %ul.nav.navbar-nav
+ %li.hidden-sm.hidden-xs
+ = render 'layouts/search' unless current_controller?(:search)
+ %li.visible-sm-inline-block.visible-xs-inline-block
+ = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = icon('search')
+ - if current_user
+ - if session[:impersonator_id]
+ %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.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')
+ = render 'layouts/header/new_dropdown'
+ - if Gitlab::Sherlock.enabled?
+ %li
+ = link_to sherlock_transactions_path, title: 'Sherlock Transactions',
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = icon('tachometer fw')
+ %li
+ = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = custom_icon('issues')
+ - issues_count = assigned_issuables_count(:issues)
+ %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')
+ - merge_requests_count = assigned_issuables_count(:merge_requests)
+ %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{ 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
+ = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar"
+ = icon('chevron-down')
+ .dropdown-menu-nav.dropdown-menu-align-right
+ %ul
+ %li.current-user
+ .user-name.bold
+ = current_user.name
+ @#{current_user.username}
+ %li.divider
+ %li
+ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
+ %li
+ = link_to "Settings", profile_path
+ %li
+ = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation")
+ %li.divider
+ %li
+ = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
+ - else
+ %li
+ %div
+ = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success'
+
+ %button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' }
+ %span.sr-only Toggle navigation
+ = icon('ellipsis-v', class: 'js-navbar-toggle-right')
+ = icon('times', class: 'js-navbar-toggle-left')
+
+= render 'shared/outdated_browser'
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
new file mode 100644
index 00000000000..9da739b0974
--- /dev/null
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -0,0 +1,49 @@
+%li.header-new.dropdown
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
+ - if show_new_nav?
+ = icon('plus')
+ = icon('chevron-down')
+ - else
+ = icon('plus fw')
+ = icon('caret-down')
+ .dropdown-menu-nav.dropdown-menu-align-right
+ %ul
+ - if @group&.persisted?
+ - create_group_project = can?(current_user, :create_projects, @group)
+ - create_group_subgroup = can?(current_user, :create_subgroup, @group)
+ - if create_group_project || create_group_subgroup
+ %li.dropdown-bold-header This group
+ - if create_group_project
+ %li.header-new-group-project
+ = link_to 'New project', new_project_path(namespace_id: @group.id)
+ - if create_group_subgroup
+ %li
+ = link_to 'New subgroup', new_group_path(parent_id: @group.id)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+
+ - if @project&.persisted?
+ - create_project_issue = can?(current_user, :create_issue, @project)
+ - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+ - create_project_snippet = can?(current_user, :create_project_snippet, @project)
+ - if create_project_issue || merge_project || create_project_snippet
+ %li.dropdown-bold-header This project
+ - if create_project_issue
+ %li
+ = link_to 'New issue', new_project_issue_path(@project)
+ - if merge_project
+ %li
+ = link_to 'New merge request', project_new_merge_request_path(merge_project)
+ - if create_project_snippet
+ %li.header-new-project-snippet
+ = link_to 'New snippet', new_project_snippet_path(@project)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+ - if current_user.can_create_project?
+ %li
+ = link_to 'New project', new_project_path
+ - if current_user.can_create_group?
+ %li
+ = link_to 'New group', new_group_path
+ %li
+ = link_to 'New snippet', new_snippet_path
diff --git a/app/views/layouts/help.html.haml b/app/views/layouts/help.html.haml
index 224b24befbe..78927bfffcd 100644
--- a/app/views/layouts/help.html.haml
+++ b/app/views/layouts/help.html.haml
@@ -1,3 +1,4 @@
+- @breadcrumb_title = "Help"
- page_title "Help"
- header_title "Help", help_path
diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml
index 53268cc22f8..28dcbce7183 100644
--- a/app/views/layouts/mailer.html.haml
+++ b/app/views/layouts/mailer.html.haml
@@ -1,72 +1 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
- %head
- %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
- %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
- %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/
- %title= message.subject
- :css
- /* CLIENT-SPECIFIC STYLES */
- body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
- table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
- img { -ms-interpolation-mode: bicubic; }
-
- /* iOS BLUE LINKS */
- a[x-apple-data-detectors] {
- color: inherit !important;
- text-decoration: none !important;
- font-size: inherit !important;
- font-family: inherit !important;
- font-weight: inherit !important;
- line-height: inherit !important;
- }
-
- /* ANDROID MARGIN HACK */
- body { margin:0 !important; }
- div[style*="margin: 16px 0"] { margin:0 !important; }
-
- @media only screen and (max-width: 639px) {
- body, #body {
- min-width: 320px !important;
- }
- table.wrapper {
- width: 100% !important;
- min-width: 320px !important;
- }
- table.wrapper > tbody > tr > td {
- border-left: 0 !important;
- border-right: 0 !important;
- border-radius: 0 !important;
- padding-left: 10px !important;
- padding-right: 10px !important;
- }
- }
- %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
- %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" }
- %tbody
- %tr.line
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }  
- %tr.header
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
- = header_logo
- %tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
- %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" }
- %tbody
- %tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
- %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
- %tbody
- = yield
-
- %tr.footer
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
- %img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
- %div
- %a{ href: profile_notifications_url, style: "color:#3777b0;text-decoration:none;" } Manage all notifications
- &middot;
- %a{ href: help_url, style: "color:#3777b0;text-decoration:none;" } Help
- %div
- You're receiving this email because of your account on
- = succeed "." do
- %a{ href: root_url, style: "color:#3777b0;text-decoration:none;" }= Gitlab.config.gitlab.host
+= render 'layouts/mailer'
diff --git a/app/views/layouts/mailer/devise.html.haml b/app/views/layouts/mailer/devise.html.haml
new file mode 100644
index 00000000000..054b2c2fa26
--- /dev/null
+++ b/app/views/layouts/mailer/devise.html.haml
@@ -0,0 +1,21 @@
+- if Gitlab.com?
+ - content_for :additional_footer do
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ %div
+ Everyone can contribute
+ %div
+ = link_to 'Blog', 'https://about.gitlab.com/blog/', style: "color:#3777b0;text-decoration:none;"
+ &middot;
+ = link_to 'Twitter', 'https://twitter.com/gitlab', style: "color:#3777b0;text-decoration:none;"
+ &middot;
+ = link_to 'Facebook', 'https://www.facebook.com/gitlab/', style: "color:#3777b0;text-decoration:none;"
+ &middot;
+ = link_to 'YouTube', 'https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg', style: "color:#3777b0;text-decoration:none;"
+ &middot;
+ = link_to 'LinkedIn', 'https://www.linkedin.com/company/gitlab-com', style: "color:#3777b0;text-decoration:none;"
+
+= render layout: 'layouts/mailer' do
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:400;line-height:1.4;padding:15px 5px;text-align:center;" }
+ = yield
diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml
index d068c895fa3..6df0adfd742 100644
--- a/app/views/layouts/nav/_admin.html.haml
+++ b/app/views/layouts/nav/_admin.html.haml
@@ -5,19 +5,19 @@
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
- = nav_link(controller: %w(dashboard admin projects users groups builds runners), html_options: {class: 'home'}) do
+ = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
- = link_to admin_system_info_path, title: 'Monitoring' do
+ = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_conversational_development_index_path, title: 'Monitoring' do
%span
Monitoring
= nav_link(controller: :broadcast_messages) do
= link_to admin_broadcast_messages_path, title: 'Messages' do
%span
Messages
- = nav_link(controller: :hooks) do
+ = nav_link(controller: [:hooks, :hook_logs]) do
= link_to admin_hooks_path, title: 'Hooks' do
%span
System Hooks
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
new file mode 100644
index 00000000000..4db84771f4e
--- /dev/null
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -0,0 +1,27 @@
+- breadcrumb_link = breadcrumb_title_link
+- hide_top_links = @hide_top_links || false
+
+%nav.breadcrumbs{ role: "navigation" }
+ .breadcrumbs-container{ class: [container_class, @content_class] }
+ - if defined?(@new_sidebar)
+ = button_tag class: 'toggle-mobile-nav', type: 'button' do
+ %span.sr-only Open sidebar
+ = icon ('bars')
+ .breadcrumbs-links.js-title-container
+ - unless hide_top_links
+ .title
+ = link_to "GitLab", root_path
+ \/
+ - if content_for?(:header_title_before)
+ = yield :header_title_before
+ \/
+ = header_title
+ %h2.breadcrumbs-sub-title
+ %ul.list-unstyled
+ - if @breadcrumbs_extra_links
+ - @breadcrumbs_extra_links.each do |extra|
+ %li= link_to extra[:text], extra[:link]
+ %li= link_to @breadcrumb_title, breadcrumb_link
+ - if content_for?(:breadcrumbs_extra)
+ .breadcrumbs-extra.hidden-xs= yield :breadcrumbs_extra
+ = yield :header_content
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index ac222ad8c82..be7d27df2a0 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -42,18 +42,18 @@
.key
= icon('arrow-up', 'aria-label' => 'hidden')
I
+ %span.badge.pull-right= number_with_delimiter(assigned_issuables_count(:issues))
%span
Issues
- .badge= number_with_delimiter(assigned_issuables_count(:issues))
= nav_link(path: 'dashboard#merge_requests') do
= link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
M
+ %span.badge.pull-right= number_with_delimiter(assigned_issuables_count(:merge_requests))
%span
Merge Requests
- .badge= number_with_delimiter(assigned_issuables_count(:merge_requests))
= nav_link(controller: 'dashboard/snippets') do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
.shortcut-mappings
diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml
index 8605380848d..261445ecd2b 100644
--- a/app/views/layouts/nav/_group.html.haml
+++ b/app/views/layouts/nav/_group.html.haml
@@ -25,7 +25,7 @@
%span
Members
- if current_user && can?(current_user, :admin_group, @group)
- = nav_link(path: %w[groups#projects groups#edit]) do
+ = nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
= link_to edit_group_path(@group), title: 'Settings' do
%span
Settings
diff --git a/app/views/layouts/nav/_new_admin_sidebar.html.haml b/app/views/layouts/nav/_new_admin_sidebar.html.haml
new file mode 100644
index 00000000000..3cbcd841aff
--- /dev/null
+++ b/app/views/layouts/nav/_new_admin_sidebar.html.haml
@@ -0,0 +1,151 @@
+.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to admin_root_path, title: 'Admin Overview' do
+ .avatar-container.s40.settings-avatar
+ = icon('wrench')
+ .project-title Admin Area
+ %ul.sidebar-top-level-items
+ = nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts), html_options: {class: 'home'}) do
+ = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
+ .nav-icon-container
+ = custom_icon('overview')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: :dashboard, html_options: {class: 'home'}) do
+ = link_to admin_root_path, title: 'Overview' do
+ %span
+ Dashboard
+ = nav_link(controller: [:admin, :projects]) do
+ = link_to admin_projects_path, title: 'Projects' do
+ %span
+ Projects
+ = nav_link(controller: :users) do
+ = link_to admin_users_path, title: 'Users' do
+ %span
+ Users
+ = nav_link(controller: :groups) do
+ = link_to admin_groups_path, title: 'Groups' do
+ %span
+ Groups
+ = nav_link path: 'jobs#index' do
+ = link_to admin_jobs_path, title: 'Jobs' do
+ %span
+ Jobs
+ = nav_link path: ['runners#index', 'runners#show'] do
+ = link_to admin_runners_path, title: 'Runners' do
+ %span
+ Runners
+ = nav_link path: 'cohorts#index' do
+ = link_to admin_cohorts_path, title: 'Cohorts' do
+ %span
+ Cohorts
+
+ = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_conversational_development_index_path, title: 'Monitoring' do
+ .nav-icon-container
+ = custom_icon('monitoring')
+ %span.nav-item-name
+ Monitoring
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
+ = nav_link(controller: :system_info) do
+ = link_to admin_system_info_path, title: 'System Info' do
+ %span
+ System Info
+ = nav_link(controller: :background_jobs) do
+ = link_to admin_background_jobs_path, title: 'Background Jobs' do
+ %span
+ Background Jobs
+ = nav_link(controller: :logs) do
+ = link_to admin_logs_path, title: 'Logs' do
+ %span
+ Logs
+ = nav_link(controller: :health_check) do
+ = link_to admin_health_check_path, title: 'Health Check' do
+ %span
+ Health Check
+ = nav_link(controller: :requests_profiles) do
+ = link_to admin_requests_profiles_path, title: 'Requests Profiles' do
+ %span
+ Requests Profiles
+
+ = nav_link(controller: :broadcast_messages) do
+ = link_to admin_broadcast_messages_path, title: 'Messages' do
+ .nav-icon-container
+ = custom_icon('messages')
+ %span.nav-item-name
+ Messages
+ = nav_link(controller: [:hooks, :hook_logs]) do
+ = link_to admin_hooks_path, title: 'Hooks' do
+ .nav-icon-container
+ = custom_icon('system_hooks')
+ %span.nav-item-name
+ System Hooks
+
+ = nav_link(controller: :applications) do
+ = link_to admin_applications_path, title: 'Applications' do
+ .nav-icon-container
+ = custom_icon('applications')
+ %span.nav-item-name
+ Applications
+
+ = nav_link(controller: :abuse_reports) do
+ = link_to admin_abuse_reports_path, title: "Abuse Reports" do
+ .nav-icon-container
+ = custom_icon('abuse_reports')
+ %span.nav-item-name
+ Abuse Reports
+ %span.badge.count= number_with_delimiter(AbuseReport.count(:all))
+
+ - if akismet_enabled?
+ = nav_link(controller: :spam_logs) do
+ = link_to admin_spam_logs_path, title: "Spam Logs" do
+ .nav-icon-container
+ = custom_icon('spam_logs')
+ %span.nav-item-name
+ Spam Logs
+
+ = nav_link(controller: :deploy_keys) do
+ = link_to admin_deploy_keys_path, title: 'Deploy Keys' do
+ .nav-icon-container
+ = custom_icon('key')
+ %span.nav-item-name
+ Deploy Keys
+
+ = nav_link(controller: :services) do
+ = link_to admin_application_settings_services_path, title: 'Service Templates' do
+ .nav-icon-container
+ = custom_icon('service_templates')
+ %span.nav-item-name
+ Service Templates
+
+ = nav_link(controller: :labels) do
+ = link_to admin_labels_path, title: 'Labels' do
+ .nav-icon-container
+ = custom_icon('labels')
+ %span.nav-item-name
+ Labels
+
+ = nav_link(controller: :appearances) do
+ = link_to admin_appearances_path, title: 'Appearances' do
+ .nav-icon-container
+ = custom_icon('appearance')
+ %span.nav-item-name
+ Appearance
+
+ %li.divider
+ = nav_link(controller: :application_settings) do
+ = link_to admin_application_settings_path, title: 'Settings' do
+ .nav-icon-container
+ = custom_icon('settings')
+ %span.nav-item-name
+ Settings
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml
new file mode 100644
index 00000000000..cfdfcbebc9f
--- /dev/null
+++ b/app/views/layouts/nav/_new_dashboard.html.haml
@@ -0,0 +1,33 @@
+%ul.list-unstyled.navbar-sub-nav
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do
+ = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ Projects
+
+ = nav_link(controller: ['dashboard/groups', 'explore/groups']) do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
+ Groups
+
+ = nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm" }) do
+ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
+ Activity
+
+ %li.dropdown
+ %a{ href: "#", data: { toggle: "dropdown" } }
+ More
+ = icon("chevron-down", class: "dropdown-chevron")
+ .dropdown-menu
+ %ul
+ = nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm" }) do
+ = link_to activity_dashboard_path, title: 'Activity' do
+ Activity
+
+ = nav_link(controller: 'dashboard/milestones') do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
+ Milestones
+
+ = nav_link(controller: 'dashboard/snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
+ Snippets
+ %li.divider
+ %li
+ = link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/_new_explore.html.haml b/app/views/layouts/nav/_new_explore.html.haml
new file mode 100644
index 00000000000..40385f251e3
--- /dev/null
+++ b/app/views/layouts/nav/_new_explore.html.haml
@@ -0,0 +1,19 @@
+%ul.list-unstyled.navbar-sub-nav
+ = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
+ = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ Projects
+ = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
+ = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
+ Groups
+ %li.dropdown
+ %a{ href: "#", data: { toggle: "dropdown" } }
+ More
+ = icon("chevron-down", class: "dropdown-chevron")
+ .dropdown-menu
+ %ul
+ = nav_link(controller: :snippets) do
+ = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
+ Snippets
+ %li.divider
+ %li
+ = link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/_new_group_sidebar.html.haml b/app/views/layouts/nav/_new_group_sidebar.html.haml
new file mode 100644
index 00000000000..ed5793f09fe
--- /dev/null
+++ b/app/views/layouts/nav/_new_group_sidebar.html.haml
@@ -0,0 +1,90 @@
+.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to group_path(@group), title: @group.name do
+ .avatar-container.s40.group-avatar
+ = image_tag group_icon(@group), class: "avatar s40 avatar-tile"
+ .group-title
+ = @group.name
+ %ul.sidebar-top-level-items
+ = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to group_path(@group), title: 'Group overview' do
+ .nav-icon-container
+ = custom_icon('project')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to group_path(@group), title: 'Group details' do
+ %span
+ Details
+
+ = nav_link(path: 'groups#activity') do
+ = link_to activity_group_path(@group), title: 'Activity' do
+ %span
+ Activity
+
+ = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
+ = link_to issues_group_path(@group), title: 'Issues' do
+ .nav-icon-container
+ = custom_icon('issues')
+ %span.nav-item-name
+ - issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
+ Issues
+ %span.badge.count= number_with_delimiter(issues.count)
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
+ = link_to issues_group_path(@group), title: 'List' do
+ %span
+ List
+
+ = nav_link(path: 'labels#index') do
+ = link_to group_labels_path(@group), title: 'Labels' do
+ %span
+ Labels
+
+ = nav_link(path: 'milestones#index') do
+ = link_to group_milestones_path(@group), title: 'Milestones' do
+ %span
+ Milestones
+
+ = nav_link(path: 'groups#merge_requests') do
+ = link_to merge_requests_group_path(@group), title: 'Merge Requests' do
+ .nav-icon-container
+ = custom_icon('mr_bold')
+ %span.nav-item-name
+ - merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
+ Merge Requests
+ %span.badge.count= number_with_delimiter(merge_requests.count)
+ = nav_link(path: 'group_members#index') do
+ = link_to group_group_members_path(@group), title: 'Members' do
+ .nav-icon-container
+ = custom_icon('members')
+ %span.nav-item-name
+ Members
+ - if current_user && can?(current_user, :admin_group, @group)
+ = nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
+ = link_to edit_group_path(@group), title: 'Settings' do
+ .nav-icon-container
+ = custom_icon('settings')
+ %span.nav-item-name
+ Settings
+ %ul.sidebar-sub-level-items
+ = nav_link(path: 'groups#edit') do
+ = link_to edit_group_path(@group), title: 'General' do
+ %span
+ General
+
+ = nav_link(path: 'groups#projects') do
+ = link_to projects_group_path(@group), title: 'Projects' do
+ %span
+ Projects
+
+ = nav_link(controller: :ci_cd) do
+ = link_to group_settings_ci_cd_path(@group), title: 'CI / CD' do
+ %span
+ CI / CD
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml
new file mode 100644
index 00000000000..4234df56d1d
--- /dev/null
+++ b/app/views/layouts/nav/_new_profile_sidebar.html.haml
@@ -0,0 +1,85 @@
+.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ .context-header
+ = link_to profile_path, title: 'Profile Settings' do
+ .avatar-container.s40.settings-avatar
+ = icon('user')
+ .project-title User Settings
+ %ul.sidebar-top-level-items
+ = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
+ = link_to profile_path, title: 'Profile Settings' do
+ .nav-icon-container
+ = custom_icon('profile')
+ %span.nav-item-name
+ Profile
+ = nav_link(controller: [:accounts, :two_factor_auths]) do
+ = link_to profile_account_path, title: 'Account' do
+ .nav-icon-container
+ = custom_icon('account')
+ %span.nav-item-name
+ Account
+ - if current_application_settings.user_oauth_applications?
+ = nav_link(controller: 'oauth/applications') do
+ = link_to applications_profile_path, title: 'Applications' do
+ .nav-icon-container
+ = custom_icon('applications')
+ %span.nav-item-name
+ Applications
+ = nav_link(controller: :chat_names) do
+ = link_to profile_chat_names_path, title: 'Chat' do
+ .nav-icon-container
+ = custom_icon('chat')
+ %span.nav-item-name
+ Chat
+ = nav_link(controller: :personal_access_tokens) do
+ = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do
+ .nav-icon-container
+ = custom_icon('access_tokens')
+ %span.nav-item-name
+ Access Tokens
+ = nav_link(controller: :emails) do
+ = link_to profile_emails_path, title: 'Emails' do
+ .nav-icon-container
+ = custom_icon('emails')
+ %span.nav-item-name
+ Emails
+ - unless current_user.ldap_user?
+ = nav_link(controller: :passwords) do
+ = link_to edit_profile_password_path, title: 'Password' do
+ .nav-icon-container
+ = custom_icon('lock')
+ %span.nav-item-name
+ Password
+ = nav_link(controller: :notifications) do
+ = link_to profile_notifications_path, title: 'Notifications' do
+ .nav-icon-container
+ = custom_icon('notifications')
+ %span.nav-item-name
+ Notifications
+
+ = nav_link(controller: :keys) do
+ = link_to profile_keys_path, title: 'SSH Keys' do
+ .nav-icon-container
+ = custom_icon('key')
+ %span.nav-item-name
+ SSH Keys
+ = nav_link(controller: :gpg_keys) do
+ = link_to profile_gpg_keys_path, title: 'GPG Keys' do
+ .nav-icon-container
+ = custom_icon('key_2')
+ %span.nav-item-name
+ GPG Keys
+ = nav_link(controller: :preferences) do
+ = link_to profile_preferences_path, title: 'Preferences' do
+ .nav-icon-container
+ = custom_icon('preferences')
+ %span.nav-item-name
+ Preferences
+ = nav_link(path: 'profiles#audit_log') do
+ = link_to audit_log_profile_path, title: 'Authentication log' do
+ .nav-icon-container
+ = custom_icon('authentication_log')
+ %span.nav-item-name
+ Authentication log
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml
new file mode 100644
index 00000000000..0ef81375c3a
--- /dev/null
+++ b/app/views/layouts/nav/_new_project_sidebar.html.haml
@@ -0,0 +1,262 @@
+.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+ .nav-sidebar-inner-scroll
+ - can_edit = can?(current_user, :admin_project, @project)
+ .context-header
+ = link_to project_path(@project), title: @project.name do
+ .avatar-container.s40.project-avatar
+ = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
+ .project-title
+ = @project.name
+ %ul.sidebar-top-level-items
+ = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do
+ = link_to project_path(@project), title: 'Project overview', class: 'shortcuts-project' do
+ .nav-icon-container
+ = custom_icon('project')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: 'projects#show') do
+ = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do
+ %span= _('Details')
+
+ = nav_link(path: 'projects#activity') do
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ %span= _('Activity')
+
+ - if can?(current_user, :read_cycle_analytics, @project)
+ = nav_link(path: 'cycle_analytics#show') do
+ = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
+ %span= _('Cycle Analytics')
+
+ - if project_nav_tab? :files
+ = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
+ = link_to project_tree_path(@project), title: 'Repository', class: 'shortcuts-tree' do
+ .nav-icon-container
+ = custom_icon('doc_text')
+ %span.nav-item-name
+ Repository
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
+ = link_to project_tree_path(@project) do
+ #{ _('Files') }
+
+ = nav_link(controller: [:commit, :commits]) do
+ = link_to project_commits_path(@project, current_ref) do
+ #{ _('Commits') }
+
+ = nav_link(html_options: {class: branches_tab_class}) do
+ = link_to project_branches_path(@project) do
+ #{ _('Branches') }
+
+ = nav_link(controller: [:tags, :releases]) do
+ = link_to project_tags_path(@project) do
+ #{ _('Tags') }
+
+ = nav_link(path: 'graphs#show') do
+ = link_to project_graph_path(@project, current_ref) do
+ #{ _('Contributors') }
+
+ = nav_link(controller: %w(network)) do
+ = link_to project_network_path(@project, current_ref) do
+ #{ s_('ProjectNetworkGraph|Graph') }
+
+ = nav_link(controller: :compare) do
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do
+ #{ _('Compare') }
+
+ = nav_link(path: 'graphs#charts') do
+ = link_to charts_project_graph_path(@project, current_ref) do
+ #{ _('Charts') }
+
+ - if project_nav_tab? :container_registry
+ = nav_link(controller: %w[projects/registry/repositories]) do
+ = link_to project_container_registry_index_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
+ .nav-icon-container
+ = custom_icon('container_registry')
+ %span.nav-item-name
+ Registry
+
+ - if project_nav_tab? :issues
+ = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
+ = link_to project_issues_path(@project), title: 'Issues', class: 'shortcuts-issues' do
+ .nav-icon-container
+ = custom_icon('issues')
+ %span.nav-item-name
+ Issues
+ - if @project.issues_enabled?
+ %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count)
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: :issues) do
+ = link_to project_issues_path(@project), title: 'Issues' do
+ %span
+ List
+
+ = nav_link(controller: :boards) do
+ = link_to project_boards_path(@project), title: 'Board' do
+ %span
+ Board
+
+ = nav_link(controller: :labels) do
+ = link_to project_labels_path(@project), title: 'Labels' do
+ %span
+ Labels
+
+ = nav_link(controller: :milestones) do
+ = link_to project_milestones_path(@project), title: 'Milestones' do
+ %span
+ Milestones
+
+ - if project_nav_tab? :merge_requests
+ = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
+ = link_to project_merge_requests_path(@project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
+ .nav-icon-container
+ = custom_icon('mr_bold')
+ %span.nav-item-name
+ Merge Requests
+ %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count)
+
+ - if project_nav_tab? :pipelines
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do
+ = link_to project_pipelines_path(@project), title: 'CI / CD', class: 'shortcuts-pipelines' do
+ .nav-icon-container
+ = custom_icon('pipeline')
+ %span.nav-item-name
+ CI / CD
+
+ %ul.sidebar-sub-level-items
+ - if project_nav_tab? :pipelines
+ = nav_link(path: ['pipelines#index', 'pipelines#show']) do
+ = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
+ %span
+ Pipelines
+
+ - if project_nav_tab? :builds
+ = nav_link(controller: [:jobs, :artifacts]) do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ %span
+ Jobs
+
+ - if project_nav_tab? :pipelines
+ = nav_link(controller: :pipeline_schedules) do
+ = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do
+ %span
+ Schedules
+
+ - if project_nav_tab? :environments
+ = nav_link(controller: :environments) do
+ = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do
+ %span
+ Environments
+
+ - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
+ = nav_link(path: 'pipelines#charts') do
+ = link_to charts_project_pipelines_path(@project), title: 'Charts', class: 'shortcuts-pipelines-charts' do
+ %span
+ Charts
+
+ - if project_nav_tab? :wiki
+ = nav_link(controller: :wikis) do
+ = link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do
+ .nav-icon-container
+ = custom_icon('wiki')
+ %span.nav-item-name
+ Wiki
+
+ - if project_nav_tab? :snippets
+ = nav_link(controller: :snippets) do
+ = link_to project_snippets_path(@project), title: 'Snippets', class: 'shortcuts-snippets' do
+ .nav-icon-container
+ = custom_icon('snippets')
+ %span.nav-item-name
+ Snippets
+
+ - if project_nav_tab? :settings
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do
+ = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
+ .nav-icon-container
+ = custom_icon('settings')
+ %span.nav-item-name
+ Settings
+
+ %ul.sidebar-sub-level-items
+ - can_edit = can?(current_user, :admin_project, @project)
+ - if can_edit
+ = nav_link(path: %w[projects#edit]) do
+ = link_to edit_project_path(@project), title: 'General' do
+ %span
+ General
+ = nav_link(controller: :project_members) do
+ = link_to project_project_members_path(@project), title: 'Members' do
+ %span
+ Members
+ - if can_edit
+ = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
+ = link_to project_settings_integrations_path(@project), title: 'Integrations' do
+ %span
+ Integrations
+ = nav_link(controller: :repository) do
+ = link_to project_settings_repository_path(@project), title: 'Repository' do
+ %span
+ Repository
+ - if @project.feature_available?(:builds, current_user)
+ = nav_link(controller: :ci_cd) do
+ = link_to project_settings_ci_cd_path(@project), title: 'CI / CD' do
+ %span
+ CI / CD
+ - if Gitlab.config.pages.enabled
+ = nav_link(controller: :pages) do
+ = link_to project_pages_path(@project), title: 'Pages' do
+ %span
+ Pages
+
+ - else
+ = nav_link(path: %w[members#show]) do
+ = link_to project_settings_members_path(@project), title: 'Members', class: 'shortcuts-tree' do
+ .nav-icon-container
+ = custom_icon('members')
+ %span.nav-item-name
+ Members
+
+ = render 'shared/sidebar_toggle_button'
+
+ -# Shortcut to Project > Activity
+ %li.hidden
+ = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
+ %span
+ Activity
+
+ -# Shortcut to Repository > Graph (formerly, Network)
+ - if project_nav_tab? :network
+ %li.hidden
+ = link_to project_network_path(@project, current_ref), title: 'Network', class: 'shortcuts-network' do
+ Graph
+
+ -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
+ - unless @project.empty_repo?
+ %li.hidden
+ = link_to charts_project_graph_path(@project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
+ Charts
+
+ -# Shortcut to Issues > New Issue
+ %li.hidden
+ = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
+ Create a new issue
+
+ -# Shortcut to Pipelines > Jobs
+ - if project_nav_tab? :builds
+ %li.hidden
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ Jobs
+
+ -# Shortcut to commits page
+ - if project_nav_tab? :commits
+ %li.hidden
+ = link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do
+ Commits
+
+ -# Shortcut to issue boards
+ %li.hidden
+ = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index e06301bda14..26d9640e98a 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -29,7 +29,7 @@
= link_to profile_emails_path, title: 'Emails' do
%span
Emails
- - unless current_user.ldap_user?
+ - if current_user.allow_password_authentication?
= nav_link(controller: :passwords) do
= link_to edit_profile_password_path, title: 'Password' do
%span
@@ -43,11 +43,15 @@
= link_to profile_keys_path, title: 'SSH Keys' do
%span
SSH Keys
+ = nav_link(controller: :gpg_keys) do
+ = link_to profile_gpg_keys_path, title: 'GPG Keys' do
+ %span
+ GPG Keys
= nav_link(controller: :preferences) do
= link_to profile_preferences_path, title: 'Preferences' do
%span
Preferences
= nav_link(path: 'profiles#audit_log') do
- = link_to audit_log_profile_path, title: 'Audit Log' do
+ = link_to audit_log_profile_path, title: 'Authentication log' do
%span
- Audit Log
+ Authentication log
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index 8ab9747efc5..924cd2e9681 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -12,33 +12,35 @@
- if project_nav_tab? :files
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
- = link_to project_files_path(@project), title: 'Repository', class: 'shortcuts-tree' do
+ = link_to project_tree_path(@project), title: 'Repository', class: 'shortcuts-tree' do
%span
Repository
- if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do
- = link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
+ = link_to project_container_registry_index_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
%span
Registry
- if project_nav_tab? :issues
- = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do
- = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do
+ = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
+ = link_to project_issues_path(@project), title: 'Issues', class: 'shortcuts-issues' do
%span
Issues
- - if @project.default_issues_tracker?
- %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count)
+ - if @project.issues_enabled?
+ %span.badge.count.issue_counter= number_with_delimiter(issuables_count_for_state(:issues, :opened, finder: IssuesFinder.new(current_user, project_id: @project.id)))
- if project_nav_tab? :merge_requests
- = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
+ - controllers = [:merge_requests, 'projects/merge_requests/conflicts']
+ - controllers.push(:merge_requests, :labels, :milestones) unless @project.issues_enabled?
+ = nav_link(controller: controllers) do
+ = link_to project_merge_requests_path(@project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
%span
Merge Requests
- %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count)
+ %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(issuables_count_for_state(:merge_requests, :opened, finder: MergeRequestsFinder.new(current_user, project_id: @project.id)))
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :environments]) do
+ = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do
= link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
%span
Pipelines
@@ -51,20 +53,21 @@
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
- = link_to namespace_project_snippets_path(@project.namespace, @project), title: 'Snippets', class: 'shortcuts-snippets' do
+ = link_to project_snippets_path(@project), title: 'Snippets', class: 'shortcuts-snippets' do
%span
Snippets
+ - if project_nav_tab? :project_members
+ = nav_link(controller: :project_members) do
+ = link_to project_project_members_path(@project), title: 'Members', class: 'shortcuts-members' do
+ %span
+ Members
+
- if project_nav_tab? :settings
= nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do
= link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
%span
Settings
- - else
- = nav_link(path: %w[members#show]) do
- = link_to namespace_project_settings_members_path(@project.namespace, @project), title: 'Settings', class: 'shortcuts-tree' do
- %span
- Settings
-# Shortcut to Project > Activity
%li.hidden
@@ -75,24 +78,24 @@
-# Shortcut to Repository > Graph (formerly, Network)
- if project_nav_tab? :network
%li.hidden
- = link_to namespace_project_network_path(@project.namespace, @project, current_ref), title: 'Network', class: 'shortcuts-network' do
+ = link_to project_network_path(@project, current_ref), title: 'Network', class: 'shortcuts-network' do
Graph
-# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
- unless @project.empty_repo?
%li.hidden
- = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
+ = link_to charts_project_graph_path(@project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
Charts
-# Shortcut to Issues > New Issue
%li.hidden
- = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'shortcuts-new-issue' do
+ = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
Create a new issue
-# Shortcut to Pipelines > Jobs
- if project_nav_tab? :builds
%li.hidden
- = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
Jobs
-# Shortcut to commits page
@@ -103,4 +106,4 @@
-# Shortcut to issue boards
%li.hidden
- = link_to 'Issue Boards', namespace_project_boards_path(@project.namespace, @project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
+ = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml
new file mode 100644
index 00000000000..34bcd2a8b3a
--- /dev/null
+++ b/app/views/layouts/oauth_error.html.haml
@@ -0,0 +1,127 @@
+!!! 5
+%html{ lang: "en" }
+ %head
+ %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" }
+ %title= yield(:title)
+ :css
+ body {
+ color: #666;
+ text-align: center;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ margin: auto;
+ font-size: 16px;
+ }
+
+ .container {
+ margin: auto 20px;
+ }
+
+ h3 {
+ color: #456;
+ font-size: 22px;
+ font-weight: bold;
+ margin-bottom: 6px;
+ }
+
+ p {
+ max-width: 470px;
+ margin: 16px auto;
+ }
+
+ .subtitle {
+ margin: 0 auto 20px;
+ }
+
+ svg {
+ width: 280px;
+ height: 280px;
+ display: block;
+ margin: 40px auto;
+ }
+
+ .tv-screen path {
+ animation: move-lines 1s linear infinite;
+ }
+
+
+ @keyframes move-lines {
+ 0% {transform: translateY(0)}
+ 50% {transform: translateY(-10px)}
+ 100% {transform: translateY(-20px)}
+ }
+
+ .tv-screen path:nth-child(1) {
+ animation-delay: .2s
+ }
+
+ .tv-screen path:nth-child(2) {
+ animation-delay: .4s
+ }
+
+ .tv-screen path:nth-child(3) {
+ animation-delay: .6s
+ }
+
+ .tv-screen path:nth-child(4) {
+ animation-delay: .8s
+ }
+
+ .tv-screen path:nth-child(5) {
+ animation-delay: 2s
+ }
+
+ .text-422 {
+ animation: flicker 1s infinite;
+ }
+
+ @keyframes flicker {
+ 0% {opacity: 0.3;}
+ 10% {opacity: 1;}
+ 15% {opacity: .3;}
+ 20% {opacity: .5;}
+ 25% {opacity: 1;}
+ }
+
+ .light {
+ color: #8D8D8D;
+ }
+
+ hr {
+ max-width: 600px;
+ margin: 18px auto;
+ border: 0;
+ border-top: 1px solid #EEE;
+ }
+
+ .btn {
+ padding: 8px 14px;
+ border-radius: 3px;
+ border: 1px solid;
+ display: inline-block;
+ text-decoration: none;
+ margin: 4px 8px;
+ font-size: 14px;
+ }
+
+ .primary {
+ color: #fff;
+ background-color: #1aaa55;
+ border-color: #168f48;
+ }
+
+ .primary:hover {
+ background-color: #168f48;
+ }
+
+ .secondary {
+ color: #1aaa55;
+ background-color: #fff;
+ border-color: #1aaa55;
+ }
+
+ .secondary:hover {
+ background-color: #f3fff8;
+ }
+
+%body
+ = yield
diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml
index 0ee8a57dbd4..c365839e605 100644
--- a/app/views/layouts/profile.html.haml
+++ b/app/views/layouts/profile.html.haml
@@ -1,6 +1,10 @@
- page_title "User Settings"
- header_title "User Settings", profile_path unless header_title
- sidebar "dashboard"
-- nav "profile"
+- if show_new_nav?
+ - nav "new_profile_sidebar"
+ - @new_sidebar = true
+- else
+ - nav "profile"
= render template: "layouts/application"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index f5e7ea7710d..54d56e9b873 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,18 +1,18 @@
- page_title @project.name_with_namespace
- page_description @project.description unless page_description
- header_title project_title(@project) unless header_title
-- nav "project"
+- if show_new_nav?
+ - nav "new_project_sidebar"
+ - @new_sidebar = true
+- else
+ - nav "project"
- content_for :project_javascripts do
- project = @target_project || @project
- - if @project_wiki && @page
- - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug)
- - else
- - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project)
- if current_user
+ -# haml-lint:disable InlineJavaScript
:javascript
- window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
- window.preview_markdown_path = "#{preview_markdown_path}";
+ window.uploads_path = "#{project_uploads_path(project)}";
- content_for :header_content do
.js-dropdown-menu-projects
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 02ca3ee7a28..849075a0ba5 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,3 +1,9 @@
- header_title "Snippets", snippets_path
+- content_for :page_specific_javascripts do
+ - if @snippet && current_user
+ -# haml-lint:disable InlineJavaScript
+ :javascript
+ window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
+
= render template: "layouts/application"
diff --git a/app/views/layouts/xml.atom.builder b/app/views/layouts/xml.atom.builder
new file mode 100644
index 00000000000..4ee09cb87a1
--- /dev/null
+++ b/app/views/layouts/xml.atom.builder
@@ -0,0 +1,4 @@
+xml.instruct!
+xml.feed 'xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:media' => 'http://search.yahoo.com/mrss/' do
+ xml << yield
+end
diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml
deleted file mode 100644
index fd35713f79c..00000000000
--- a/app/views/notify/_reassigned_issuable_email.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%p
- Assignee changed
- - if @previous_assignee
- from
- %strong= @previous_assignee.name
- to
- - if issuable.assignee_id
- %strong= issuable.assignee_name
- - else
- %strong Unassigned
diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb
deleted file mode 100644
index daf20a226dd..00000000000
--- a/app/views/notify/_reassigned_issuable_email.text.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %>
-
-<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
-
-Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
- to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %>
diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml
index bc12e38675f..b35d4b7502d 100644
--- a/app/views/notify/closed_issue_email.text.haml
+++ b/app/views/notify/closed_issue_email.text.haml
@@ -1,3 +1,3 @@
Issue was closed by #{@updated_by.name}
-Issue ##{@issue.iid}: #{namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)}
+Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index d0c96b83976..c4e06cb3cb1 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml
index 40f7d61fe19..472c31e9a5e 100644
--- a/app/views/notify/issue_moved_email.html.haml
+++ b/app/views/notify/issue_moved_email.html.haml
@@ -2,5 +2,5 @@
Issue was moved to another project.
%p
New issue:
- = link_to namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) do
+ = link_to project_issue_url(@new_project, @new_issue) do
= @new_issue.title
diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb
index b3bd43c2055..66ede43635b 100644
--- a/app/views/notify/issue_moved_email.text.erb
+++ b/app/views/notify/issue_moved_email.text.erb
@@ -1,4 +1,4 @@
Issue was moved to another project.
New issue location:
-<%= namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) %>
+<%= project_issue_url(@new_project, @new_issue) %>
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index e6ab3fcde77..4200881f7e8 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,4 @@
Issue was <%= @issue_status %> by <%= @updated_by.name %>
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
diff --git a/app/views/notify/links/ci/builds/_build.html.haml b/app/views/notify/links/ci/builds/_build.html.haml
index d35b3839171..644cf506eff 100644
--- a/app/views/notify/links/ci/builds/_build.html.haml
+++ b/app/views/notify/links/ci/builds/_build.html.haml
@@ -1,2 +1,2 @@
-%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
+%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
= build.name
diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb
index 741c7f344c8..773ae8174e9 100644
--- a/app/views/notify/links/ci/builds/_build.text.erb
+++ b/app/views/notify/links/ci/builds/_build.text.erb
@@ -1 +1 @@
-Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> )
+Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> )
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index 4c9719ba732..ae2a2933865 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 46c1c9dee0b..661c23bcbe2 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was merged
-Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)}
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml
new file mode 100644
index 00000000000..4b9350c4e88
--- /dev/null
+++ b/app/views/notify/new_gpg_key_email.html.haml
@@ -0,0 +1,10 @@
+%p
+ Hi #{@user.name}!
+%p
+ A new GPG key was added to your account:
+%p
+ Fingerprint:
+ %code= @gpg_key.fingerprint
+%p
+ If this key was added in error, you can remove it under
+ = link_to "GPG Keys", profile_gpg_keys_url
diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb
new file mode 100644
index 00000000000..80b5a1fd7ff
--- /dev/null
+++ b/app/views/notify/new_gpg_key_email.text.erb
@@ -0,0 +1,7 @@
+Hi <%= @user.name %>!
+
+A new GPG key was added to your account:
+
+Fingerprint: <%= @gpg_key.fingerprint %>
+
+If this key was added in error, you can remove it at <%= profile_gpg_keys_url %>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index c762578971a..eb5157ccac9 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -2,9 +2,9 @@
%p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
-- if @issue.assignee_id.present?
+- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_name}
+ Assignee: #{@issue.assignee_list}
- if @issue.description
%div
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index ca5c2f2688c..3c716f77296 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -1,7 +1,7 @@
New Issue was created.
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_name %>
+Assignee: <%= @issue.assignee_list %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index 457e94b4800..23213106c5b 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -1,7 +1,7 @@
You have been mentioned in an issue.
-Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_name %>
+Assignee: <%= @issue.assignee_list %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 5bf0282e097..6fcebb22fc4 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -1,6 +1,6 @@
You have been mentioned in Merge Request <%= @merge_request.to_reference %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index 3c8f178ac77..7d98400e6fe 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -1,6 +1,6 @@
New Merge Request <%= @merge_request.to_reference %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index a83faa839df..b7a60938132 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index 9c2e2a599b2..3f16885b8e3 100644
--- a/app/views/notify/pipeline_success_email.html.haml
+++ b/app/views/notify/pipeline_success_email.html.haml
@@ -60,7 +60,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
+ %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml
index 3def26342a1..f0ba7827cef 100644
--- a/app/views/notify/project_was_exported_email.html.haml
+++ b/app/views/notify/project_was_exported_email.html.haml
@@ -2,7 +2,7 @@
Project #{@project.name} was exported successfully.
%p
The project export can be downloaded from:
- = link_to download_export_namespace_project_url(@project.namespace, @project), rel: 'nofollow', download: '' do
+ = link_to download_export_project_url(@project), rel: 'nofollow', download: '' do
= @project.name_with_namespace + " export"
%p
The download link will expire in 24 hours.
diff --git a/app/views/notify/project_was_exported_email.text.erb b/app/views/notify/project_was_exported_email.text.erb
index 42c4d176876..cd3a1f7934f 100644
--- a/app/views/notify/project_was_exported_email.text.erb
+++ b/app/views/notify/project_was_exported_email.text.erb
@@ -1,6 +1,6 @@
Project <%= @project.name %> was exported successfully.
The project export can be downloaded from:
-<%= download_export_namespace_project_url(@project.namespace, @project) %>
+<%= download_export_project_url(@project) %>
The download link will expire in 24 hours.
diff --git a/app/views/notify/project_was_moved_email.html.haml b/app/views/notify/project_was_moved_email.html.haml
index 87b3ff7f0b3..c476a39b661 100644
--- a/app/views/notify/project_was_moved_email.html.haml
+++ b/app/views/notify/project_was_moved_email.html.haml
@@ -2,7 +2,7 @@
Project #{@old_path_with_namespace} was moved to another location
%p
The project is now located under
- = link_to namespace_project_url(@project.namespace, @project) do
+ = link_to project_url(@project) do
= @project.name_with_namespace
%p
To update the remote url in your local repository run (for ssh):
diff --git a/app/views/notify/project_was_moved_email.text.erb b/app/views/notify/project_was_moved_email.text.erb
index b2c5f71e465..7c45163e0e8 100644
--- a/app/views/notify/project_was_moved_email.text.erb
+++ b/app/views/notify/project_was_moved_email.text.erb
@@ -1,7 +1,7 @@
Project <%= @old_path_with_namespace %> was moved to another location
The project is now located under
-<%= namespace_project_url(@project.namespace, @project) %>
+<%= project_url(@project) %>
To update the remote url in your local repository run (for ssh):
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index 498ba8b8365..ee2f40e1683 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -1 +1,10 @@
-= render 'reassigned_issuable_email', issuable: @issue
+%p
+ Assignee changed
+ - if @previous_assignees.any?
+ from
+ %strong= @previous_assignees.map(&:name).to_sentence
+ to
+ - if @issue.assignees.any?
+ %strong= @issue.assignee_list
+ - else
+ %strong Unassigned
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index 710253be984..6c357f1074a 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -1 +1,6 @@
-<%= render 'reassigned_issuable_email', issuable: @issue %>
+Reassigned Issue <%= @issue.iid %>
+
+<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
+
+Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
+ to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index 2a650130f59..24c2b08810b 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -1 +1,10 @@
-= render 'reassigned_issuable_email', issuable: @merge_request
+%p
+ Assignee changed
+ - if @previous_assignee
+ from
+ %strong= @previous_assignee.name
+ to
+ - if @merge_request.assignee_id
+ %strong= @merge_request.assignee_name
+ - else
+ %strong Unassigned
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index b5b4f1ff99a..998a40fefde 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -1 +1,6 @@
-<%= render 'reassigned_issuable_email', issuable: @merge_request %>
+Reassigned Merge Request <%= @merge_request.iid %>
+
+<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
+
+Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
+ to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %>
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index c6b1db17f91..5c5520f4cb8 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -3,7 +3,7 @@
%h3
#{@message.author_name} #{@message.action_name} #{@message.ref_type} #{@message.ref_name}
- at #{link_to(@message.project_name_with_namespace, namespace_project_url(@message.project_namespace, @message.project))}
+ at #{link_to(@message.project_name_with_namespace, project_url(@message.project))}
- if @message.compare
- if @message.reverse_compare?
@@ -17,7 +17,7 @@
%ul
- @message.commits.each do |commit|
%li
- %strong= link_to(commit.short_id, namespace_project_commit_url(@message.project_namespace, @message.project, commit))
+ %strong= link_to(commit.short_id, project_commit_url(@message.project, commit))
%div
%span by #{commit.author_name}
%i at #{commit.committed_date.to_s(:iso8601)}
@@ -27,40 +27,38 @@
%h4 #{pluralize @message.diffs_count, "changed file"}:
%ul
- - @message.diffs.each do |diff|
+ - @message.diffs.each do |diff_file|
%li.file-stats
- %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff.file_path)}" }
- - if diff.deleted_file
+ %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff_file.file_path)}" }
+ - if diff_file.deleted_file?
%span.deleted-file
&minus;
- = diff.old_path
- - elsif diff.renamed_file
- = diff.old_path
+ = diff_file.old_path
+ - elsif diff_file.renamed_file?
+ = diff_file.old_path
&rarr;
- = diff.new_path
- - elsif diff.new_file
+ = diff_file.new_path
+ - elsif diff_file.new_file?
%span.new-file
&#43;
- = diff.new_path
+ = diff_file.new_path
- else
- = diff.new_path
+ = diff_file.new_path
- unless @message.disable_diffs?
- - diff_files = @message.diffs
-
- if @message.compare_timeout
%h5 The diff was not included because it is too large.
- else
%h4 Changes:
- - diff_files.each do |diff_file|
+ - @message.diffs.each do |diff_file|
- file_hash = hexdigest(diff_file.file_path)
%li{ id: file_hash }
%a{ href: @message.target_url + "##{file_hash}" }<
- - if diff_file.deleted_file
+ - if diff_file.deleted_file?
%strong<
= diff_file.old_path
deleted
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
%strong<
= diff_file.old_path
&rarr;
@@ -74,7 +72,7 @@
- else
%hr
- blob = diff_file.blob
- - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
+ - if blob && blob.readable_text?
%table.code.white
= render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
- else
diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml
index 5ac23aa3997..895d8807e47 100644
--- a/app/views/notify/repository_push_email.text.haml
+++ b/app/views/notify/repository_push_email.text.haml
@@ -15,15 +15,15 @@
\
#{pluralize @message.diffs_count, "changed file"}:
\
- - @message.diffs.each do |diff|
- - if diff.deleted_file
- \- − #{diff.old_path}
- - elsif diff.renamed_file
- \- #{diff.old_path} → #{diff.new_path}
- - elsif diff.new_file
- \- + #{diff.new_path}
+ - @message.diffs.each do |diff_file|
+ - if diff_file.deleted_file?
+ \- − #{diff_file.old_path}
+ - elsif diff_file.renamed_file?
+ \- #{diff_file.old_path} → #{diff_file.new_path}
+ - elsif diff_file.new_file?
+ \- + #{diff_file.new_path}
- else
- \- #{diff.new_path}
+ \- #{diff_file.new_path}
- unless @message.disable_diffs?
- if @message.compare_timeout
\
@@ -36,9 +36,9 @@
- @message.diffs.each do |diff_file|
\
\=====================================
- - if diff_file.deleted_file
+ - if diff_file.deleted_file?
#{diff_file.old_path} deleted
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
#{diff_file.old_path} → #{diff_file.new_path}
- else
= diff_file.new_path
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
index b0d380af8fc..2881f3e699e 100644
--- a/app/views/notify/resolved_all_discussions_email.text.erb
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -1,3 +1,3 @@
All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
-<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/peek/views/_host.html.haml b/app/views/peek/views/_host.html.haml
new file mode 100644
index 00000000000..40769b5c6f6
--- /dev/null
+++ b/app/views/peek/views/_host.html.haml
@@ -0,0 +1,2 @@
+%span.current-host
+ = truncate(view.hostname)
diff --git a/app/views/peek/views/_mysql2.html.haml b/app/views/peek/views/_mysql2.html.haml
new file mode 100644
index 00000000000..ac811a10ef5
--- /dev/null
+++ b/app/views/peek/views/_mysql2.html.haml
@@ -0,0 +1,4 @@
+- local_assigns.fetch(:view)
+
+= render 'peek/views/sql', view: view
+mysql
diff --git a/app/views/peek/views/_pg.html.haml b/app/views/peek/views/_pg.html.haml
new file mode 100644
index 00000000000..ee94c2f3274
--- /dev/null
+++ b/app/views/peek/views/_pg.html.haml
@@ -0,0 +1,4 @@
+- local_assigns.fetch(:view)
+
+= render 'peek/views/sql', view: view
+pg
diff --git a/app/views/peek/views/_rblineprof.html.haml b/app/views/peek/views/_rblineprof.html.haml
new file mode 100644
index 00000000000..6c037930ca9
--- /dev/null
+++ b/app/views/peek/views/_rblineprof.html.haml
@@ -0,0 +1,7 @@
+Profile:
+
+= link_to 'all', url_for(lineprofiler: 'true'), class: 'js-toggle-modal-peek-line-profile'
+\/
+= link_to 'app & lib', url_for(lineprofiler: 'app'), class: 'js-toggle-modal-peek-line-profile'
+\/
+= link_to 'views', url_for(lineprofiler: 'views'), class: 'js-toggle-modal-peek-line-profile'
diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml
new file mode 100644
index 00000000000..dd8b524064f
--- /dev/null
+++ b/app/views/peek/views/_sql.html.haml
@@ -0,0 +1,13 @@
+%strong
+ %a.js-toggle-modal-peek-sql
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
+#modal-peek-pg-queries.modal{ tabindex: -1 }
+ .modal-dialog.modal-full
+ .modal-content
+ .modal-header
+ %button.close.btn.btn-link.btn-sm{ type: 'button', data: { dismiss: 'modal' } } X
+ %h4
+ SQL queries
+ .modal-body{ data: { defer_to: "#{view.defer_key}-queries" } }...
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index 879fc170f92..d0ad90ac6cc 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -9,7 +9,6 @@
Signed in with
= event.details[:with]
authentication
- %span.pull-right
- #{time_ago_in_words event.created_at} ago
+ %span.pull-right= time_ago_with_tooltip(event.created_at)
= paginate events, theme: "gitlab"
diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml
new file mode 100644
index 00000000000..c31a4a8ecd4
--- /dev/null
+++ b/app/views/profiles/accounts/_reset_token.html.haml
@@ -0,0 +1,11 @@
+- name = label.parameterize
+- attribute = name.underscore
+
+.reset-action
+ %p.cgray
+ = label_tag name, label, class: "label-light"
+ = text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()'
+ %p.help-block
+ = help_text
+ .prepend-top-default
+ = link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token'
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index d843cacd52d..5d778d67ae7 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,4 +1,5 @@
- page_title "Account"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- if current_user.ldap_user?
@@ -6,46 +7,28 @@
Some options are unavailable for LDAP accounts
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- = incoming_email_token_enabled? ? "Private Tokens" : "Private Token"
+ Private Tokens
%p
- Keep
- = incoming_email_token_enabled? ? "these tokens" : "this token"
- secret, anyone with access to them can interact with GitLab as if they were you.
- .col-lg-9.private-tokens-reset
- .reset-action
- %p.cgray
- - if current_user.private_token
- = label_tag "private-token", "Private token", class: "label-light"
- = text_field_tag "private-token", current_user.private_token, class: "form-control", readonly: true, onclick: "this.select()"
- - else
- %span You don't have one yet. Click generate to fix it.
- %p.help-block
- Your private token is used to access the API and Atom feeds without username/password authentication.
- .prepend-top-default
- - if current_user.private_token
- = link_to 'Reset private token', reset_private_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default private-token"
- - else
- = f.submit 'Generate', class: "btn btn-default"
+ Keep these tokens secret, anyone with access to them can interact with
+ GitLab as if they were you.
+ .col-lg-8.private-tokens-reset
+ = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' }
+
+ = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' }
+
- if incoming_email_token_enabled?
- .reset-action
- %p.cgray
- = label_tag "incoming-email-token", "Incoming Email Token", class: 'label-light'
- = text_field_tag "incoming-email-token", current_user.incoming_email_token, class: "form-control", readonly: true, onclick: "this.select()"
- %p.help-block
- Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.
- .prepend-top-default
- = link_to 'Reset incoming email token', reset_incoming_email_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default incoming-email-token"
+ = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' }
%hr
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Two-Factor Authentication
%p
Increase your account's security by enabling Two-Factor Authentication (2FA).
- .col-lg-9
+ .col-lg-8
%p
Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
- if current_user.two_factor_enabled?
@@ -61,12 +44,12 @@
%hr
- if button_based_providers.any?
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Social sign-in
%p
Activate signin with one of the following services
- .col-lg-9
+ .col-lg-8
%label.label-light
Connected Accounts
%p Click on icon to activate signin with one of the following services
@@ -87,12 +70,12 @@
%hr
- if current_user.can_change_username?
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0.warning-title
Change username
%p
Changing your username will change path to all personal projects!
- .col-lg-9
+ .col-lg-8
= form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f|
.form-group
= f.label :username, "Path", class: "label-light"
@@ -109,29 +92,24 @@
Update username
%hr
-- if signup_enabled?
- .row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0.danger-title
- Remove account
- .col-lg-9
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0.danger-title
+ Remove account
+ .col-lg-8
+ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ %p
+ Deleting an account has the following effects:
+ = render 'users/deletion_guidance', user: current_user
+ = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove"
+ - else
+ - if @user.solo_owned_groups.present?
%p
- Deleting an account has the following effects:
- %ul
- %li All user content like authored issues, snippets, comments will be removed
- - rp = current_user.personal_projects.count
- - unless rp.zero?
- %li #{pluralize rp, 'personal project'} will be removed and cannot be restored
- = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove"
+ Your account is currently an owner in these groups:
+ %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ %p
+ You must transfer ownership or delete these groups before you can delete your account.
- else
- - if @user.solo_owned_groups.present?
- %p
- Your account is currently an owner in these groups:
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
- %p
- You must transfer ownership or delete these groups before you can delete your account.
- - else
- %p
- You don't have access to delete this user.
+ %p
+ You don't have access to delete this user.
.append-bottom-default
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 9fe86e6b291..1a392e29e2a 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,11 +1,12 @@
-- page_title "Audit Log"
+- page_title "Authentication log"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h3.prepend-top-0
= page_title
%p
This is a security log of important events involving your account.
- .col-lg-9
+ .col-lg-8
= render 'event_table', events: @events
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 1ec1e7c70e4..fe1cf802971 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -10,7 +10,7 @@
%td
%strong
- if can?(current_user, :admin_project, project)
- = link_to service.title, edit_namespace_project_service_path(project.namespace, project, service)
+ = link_to service.title, edit_project_service_path(project, service)
- else
= service.title
%td
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 20cc636b2da..8f7121afe02 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,14 +1,15 @@
- page_title 'Chat'
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
You can see your Chat accounts.
- .col-lg-9
+ .col-lg-8
%h5 Active chat names (#{@chat_names.size})
- if @chat_names.present?
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index f5a323dbaf8..612ecbbb96a 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,13 +1,14 @@
- page_title "Emails"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
Control emails linked to your account
- .col-lg-9
+ .col-lg-8
%h4.prepend-top-0
Add email address
= form_for 'email', url: profile_emails_path do |f|
diff --git a/app/views/profiles/gpg_keys/_email_with_badge.html.haml b/app/views/profiles/gpg_keys/_email_with_badge.html.haml
new file mode 100644
index 00000000000..5f7844584e1
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_email_with_badge.html.haml
@@ -0,0 +1,8 @@
+- css_classes = %w(label label-verification-status)
+- css_classes << (verified ? 'verified': 'unverified')
+- text = verified ? 'Verified' : 'Unverified'
+
+.gpg-email-badge
+ .gpg-email-badge-email= email
+ %div{ class: css_classes }
+ = text
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
new file mode 100644
index 00000000000..3fcf563d970
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -0,0 +1,10 @@
+%div
+ = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f|
+ = form_errors(@gpg_key)
+
+ .form-group
+ = f.label :key, class: 'label-light'
+ = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'."
+
+ .prepend-top-default
+ = f.submit 'Add key', class: "btn btn-create"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
new file mode 100644
index 00000000000..b04981f90e3
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -0,0 +1,18 @@
+%li.key-list-item
+ .pull-left.append-right-10
+ = icon 'key', class: "settings-list-icon hidden-xs"
+ .key-list-item-info
+ - key.emails_with_verified_status.map do |email, verified|
+ = render partial: 'email_with_badge', locals: { email: email, verified: verified }
+
+ .description
+ %code= key.fingerprint
+ .pull-right
+ %span.key-created-at
+ created #{time_ago_with_tooltip(key.created_at)}
+ = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only Remove
+ = icon('trash')
+ = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only Revoke
+ Revoke
diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml
new file mode 100644
index 00000000000..cabb92c5a24
--- /dev/null
+++ b/app/views/profiles/gpg_keys/_key_table.html.haml
@@ -0,0 +1,11 @@
+- is_admin = local_assigns.fetch(:admin, false)
+
+- if @gpg_keys.any?
+ %ul.well-list
+ = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin }
+- else
+ %p.settings-message.text-center
+ - if is_admin
+ There are no GPG keys associated with this account.
+ - else
+ There are no GPG keys with access to your account.
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
new file mode 100644
index 00000000000..8331daeeb75
--- /dev/null
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -0,0 +1,21 @@
+- page_title "GPG Keys"
+= render 'profiles/head'
+
+.row.prepend-top-default
+ .col-lg-3.profile-settings-sidebar
+ %h4.prepend-top-0
+ = page_title
+ %p
+ GPG keys allow you to verify signed commits.
+ .col-lg-9
+ %h5.prepend-top-0
+ Add a GPG key
+ %p.profile-settings-content
+ Before you can add a GPG key you need to
+ = link_to 'generate it.', help_page_path('workflow/gpg_signed_commits/index.md')
+ = render 'form'
+ %hr
+ %h5
+ Your GPG keys (#{@gpg_keys.count})
+ .append-bottom-default
+ = render 'key_table'
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 71b224a413b..5f7b41cf30e 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,13 +1,14 @@
- page_title "SSH Keys"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
SSH keys allow you to establish a secure connection between your computer and GitLab.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Add an SSH key
%p.profile-settings-content
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 6283ceebf10..172c0450381 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,3 +1,4 @@
- page_title @key.title, "SSH Keys"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= render "key_details"
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 51c4e8e5a73..e98fdfc7a3d 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,4 +1,5 @@
- page_title "Notifications"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
%div
@@ -10,14 +11,14 @@
= hidden_field_tag :notification_type, 'global'
.row
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4
= page_title
%p
You can specify notification level per group or per project.
%p
By default, all projects and groups will use the global notifications setting.
- .col-lg-9
+ .col-lg-8
%h5
Global notification settings
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 243428b690e..985bb79508f 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -1,12 +1,13 @@
- page_title "Password"
+- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
After a successful password update, you will be redirected to the login page where you can log in with your new password.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Change your password
- unless @user.password_automatically_set?
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index c852107e69a..2216708d354 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -1,8 +1,10 @@
- page_title "Personal Access Tokens"
+- @content_class = "limit-container-width" unless fluid_layout
+
= render 'profiles/head'
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
@@ -11,14 +13,14 @@
You can also use personal access tokens to authenticate against Git over HTTP.
They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
- .col-lg-9
+ .col-lg-8
- if flash[:personal_access_token]
.created-personal-access-token-container
%h5.prepend-top-0
Your New Personal Access Token
.form-group
- = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block"
+ = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block"
= clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
%span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again.
@@ -27,8 +29,3 @@
= render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes
= render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens
-
-:javascript
- $("#created-personal-access-token").click(function() {
- this.select();
- });
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 99690e6b98a..f08dcc0c242 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,15 +1,16 @@
- page_title 'Preferences'
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Syntax highlighting theme
%p
This setting allows you to customize the appearance of the syntax.
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
- .col-lg-9.syntax-theme
+ .col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
.preview= image_tag "#{scheme.css_class}-scheme-preview.png"
@@ -17,14 +18,36 @@
= scheme.name
.col-sm-12
%hr
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar#new-navigation
+ %h4.prepend-top-0
+ New Navigation
+ %p
+ This setting allows you to turn on or off the new upcoming navigation concept.
+ .col-lg-8.syntax-theme
+ .nav-wip
+ %p
+ The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation.
+ %p
+ %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more
+ about the improvements that are coming soon!
+ = label_tag do
+ .preview= image_tag "old_nav.png"
+ %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? }
+ Old
+ = label_tag do
+ .preview= image_tag "new_nav.png"
+ %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? }
+ New
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Behavior
%p
This setting allows you to customize the behavior of the system layout and default views.
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
- .col-lg-9
+ .col-lg-8
.form-group
= f.label :layout, class: 'label-light' do
Layout width
@@ -37,9 +60,9 @@
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
.form-group
= f.label :project_view, class: 'label-light' do
- Project view
+ Project overview content
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.help-block
- Choose what content you want to see on a project's home page.
+ Choose what content you want to see on a project’s overview page
.form-group
= f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index c74b3249a13..a8ae0b92334 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,22 +1,24 @@
+- breadcrumb_title "Profile"
+- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
-= form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default' }, authenticity_token: true do |f|
= form_errors(@user)
.row
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Public Avatar
%p
- if @user.avatar?
You can change your avatar here
- if gravatar_enabled?
- or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host}
+ or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
- else
You can upload an avatar here
- if gravatar_enabled?
- or change it at #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host}
- .col-lg-9
+ or change it at #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host}
+ .col-lg-8
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon(@user, 160), alt: '', class: 'avatar s160'
@@ -26,96 +28,67 @@
%a.btn.js-choose-user-avatar-button
Browse file...
%span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen
- = f.file_field :avatar, class: "js-user-avatar-input hidden", accept: "image/*"
+ = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.help-block
The maximum file size allowed is 200KB.
- if @user.avatar?
%hr
- = link_to 'Remove avatar', profile_avatar_path, data: { confirm: "Avatar will be removed. Are you sure?" }, method: :delete, class: "btn btn-gray"
+ = link_to 'Remove avatar', profile_avatar_path, data: { confirm: 'Avatar will be removed. Are you sure?' }, method: :delete, class: 'btn btn-gray'
%hr
.row
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Main settings
%p
This information will appear on your profile.
- if current_user.ldap_user?
Some options are unavailable for LDAP accounts
- .col-lg-9
- .form-group
- = f.label :name, class: "label-light"
- = f.text_field :name, class: "form-control", required: true
- %span.help-block Enter your name, so people you know can recognize you.
+ .col-lg-8
+ .row
+ = f.text_field :name, required: true, wrapper: { class: 'col-md-9' },
+ help: 'Enter your name, so people you know can recognize you.'
+ = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- .form-group
- = f.label :email, class: "label-light"
- - if @user.ldap_user? && @user.ldap_email?
- = f.text_field :email, class: "form-control", required: true, readonly: true
- %span.help-block.light
- Your email address was automatically set based on the LDAP server.
- - else
- - if @user.temp_oauth_email?
- = f.text_field :email, class: "form-control", required: true, value: nil
- - else
- = f.text_field :email, class: "form-control", required: true
- - if @user.unconfirmed_email.present?
- %span.help-block
- Please click the link in the confirmation email before continuing. It was sent to
- = succeed "." do
- %strong= @user.unconfirmed_email
- %p
- = link_to "Resend confirmation e-mail", user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
-
- - else
- %span.help-block We also use email for avatar detection if no avatar is uploaded.
- .form-group
- = f.label :public_email, class: "label-light"
- = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2"
- %span.help-block This email will be displayed on your public profile.
- .form-group
- = f.label :skype, class: "label-light"
- = f.text_field :skype, class: "form-control"
- .form-group
- = f.label :linkedin, class: "label-light"
- = f.text_field :linkedin, class: "form-control"
- .form-group
- = f.label :twitter, class: "label-light"
- = f.text_field :twitter, class: "form-control"
- .form-group
- = f.label :website_url, 'Website', class: "label-light"
- = f.text_field :website_url, class: "form-control"
- .form-group
- = f.label :location, 'Location', class: "label-light"
- = f.text_field :location, class: "form-control"
- .form-group
- = f.label :organization, 'Organization', class: "label-light"
- = f.text_field :organization, class: "form-control"
- .form-group
- = f.label :bio, class: "label-light"
- = f.text_area :bio, rows: 4, class: "form-control", maxlength: 250
- %span.help-block Tell us about yourself in fewer than 250 characters.
+ - if @user.external_email?
+ = f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{email_provider_label} account."
+ - else
+ = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
+ help: user_email_help_text(@user)
+ = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
+ { help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' },
+ control_class: 'select2'
+ = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
+ { help: 'This feature is experimental and translations are not complete yet.' },
+ control_class: 'select2'
+ = f.text_field :skype
+ = f.text_field :linkedin
+ = f.text_field :twitter
+ = f.text_field :website_url, label: 'Website'
+ = f.text_field :location
+ = f.text_field :organization
+ = f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
.prepend-top-default.append-bottom-default
- = f.submit 'Update profile settings', class: "btn btn-success"
- = link_to "Cancel", user_path(current_user), class: "btn btn-cancel"
+ = f.submit 'Update profile settings', class: 'btn btn-success'
+ = link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel'
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
- %button.close{ :type => "button", :'data-dismiss' => "modal" }
+ %button.close{ type: 'button', 'data-dismiss': 'modal' }
%span
&times;
%h4.modal-title
Position and size your new avatar
.modal-body
.profile-crop-image-container
- %img.modal-profile-crop-image{ alt: "Avatar cropper" }
+ %img.modal-profile-crop-image{ alt: 'Avatar cropper' }
.crop-controls
.btn-group
- %button.btn.btn-primary{ data: { method: "zoom", option: "0.1" } }
+ %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } }
%span.fa.fa-search-plus
- %button.btn.btn-primary{ data: { method: "zoom", option: "-0.1" } }
+ %button.btn.btn-primary{ data: { method: 'zoom', option: '-0.1' } }
%span.fa.fa-search-minus
.modal-footer
- %button.btn.btn-primary.js-upload-user-avatar{ :type => "button" }
+ %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' }
Set new profile picture
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0ff05098cd7..33e062c1c9c 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,98 +1,98 @@
- page_title 'Two-Factor Authentication', 'Account'
-- header_title "Two-Factor Authentication", profile_two_factor_auth_path
+- if show_new_nav?
+ - add_to_breadcrumbs("Account", profile_account_path)
+- else
+ - header_title "Two-Factor Authentication", profile_two_factor_auth_path
+- @content_class = "limit-container-width" unless fluid_layout
+
= render 'profiles/head'
-- if inject_u2f_api?
- - content_for :page_specific_javascripts do
+- content_for :page_specific_javascripts do
+ - if inject_u2f_api?
= page_specific_javascript_bundle_tag('u2f')
+ = page_specific_javascript_bundle_tag('two_factor_auth')
-.row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- Register Two-Factor Authentication App
- %p
- Use an app on your mobile device to enable two-factor authentication (2FA).
- .col-lg-9
- - if current_user.two_factor_otp_enabled?
- = icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page."
- - else
+.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
+ .row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Register Two-Factor Authentication App
%p
- Download the Google Authenticator application from App Store or Google Play Store and scan this code.
- More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}.
- .row.append-bottom-10
- .col-md-3
- = raw @qr_code
- .col-md-9
- .account-well
- %p.prepend-top-0.append-bottom-0
- Can't scan the code?
- %p.prepend-top-0.append-bottom-0
- To add the entry manually, provide the following details to the application on your phone.
- %p.prepend-top-0.append-bottom-0
- Account:
- = @account_string
- %p.prepend-top-0.append-bottom-0
- Key:
- = current_user.otp_secret.scan(/.{4}/).join(' ')
- %p.two-factor-new-manual-content
- Time based: Yes
- = form_tag profile_two_factor_auth_path, method: :post do |f|
- - if @error
- .alert.alert-danger
- = @error
- .form-group
- = label_tag :pin_code, nil, class: "label-light"
- = text_field_tag :pin_code, nil, class: "form-control", required: true
- .prepend-top-default
- = submit_tag 'Register with two-factor app', class: 'btn btn-success'
-
-%hr
+ Use an app on your mobile device to enable two-factor authentication (2FA).
+ .col-lg-8
+ - if current_user.two_factor_otp_enabled?
+ = icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page."
+ - else
+ %p
+ Download the Google Authenticator application from App Store or Google Play Store and scan this code.
+ More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}.
+ .row.append-bottom-10
+ .col-md-4
+ = raw @qr_code
+ .col-md-8
+ .account-well
+ %p.prepend-top-0.append-bottom-0
+ Can't scan the code?
+ %p.prepend-top-0.append-bottom-0
+ To add the entry manually, provide the following details to the application on your phone.
+ %p.prepend-top-0.append-bottom-0
+ Account:
+ = @account_string
+ %p.prepend-top-0.append-bottom-0
+ Key:
+ = current_user.otp_secret.scan(/.{4}/).join(' ')
+ %p.two-factor-new-manual-content
+ Time based: Yes
+ = form_tag profile_two_factor_auth_path, method: :post do |f|
+ - if @error
+ .alert.alert-danger
+ = @error
+ .form-group
+ = label_tag :pin_code, nil, class: "label-light"
+ = text_field_tag :pin_code, nil, class: "form-control", required: true
+ .prepend-top-default
+ = submit_tag 'Register with two-factor app', class: 'btn btn-success'
-.row.prepend-top-default
+ %hr
- .col-lg-3
- %h4.prepend-top-0
- Register Universal Two-Factor (U2F) Device
- %p
- Use a hardware device to add the second factor of authentication.
- %p
- As U2F devices are only supported by a few browsers, we require that you set up a
- two-factor authentication app before a U2F device. That way you'll always be able to
- log in - even when you're using an unsupported browser.
- .col-lg-9
- - if @u2f_registration.errors.present?
- = form_errors(@u2f_registration)
- = render "u2f/register"
+ .row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Register Universal Two-Factor (U2F) Device
+ %p
+ Use a hardware device to add the second factor of authentication.
+ %p
+ As U2F devices are only supported by a few browsers, we require that you set up a
+ two-factor authentication app before a U2F device. That way you'll always be able to
+ log in - even when you're using an unsupported browser.
+ .col-lg-8
+ - if @u2f_registration.errors.present?
+ = form_errors(@u2f_registration)
+ = render "u2f/register"
- %hr
+ %hr
- %h5 U2F Devices (#{@u2f_registrations.length})
+ %h5 U2F Devices (#{@u2f_registrations.length})
- - if @u2f_registrations.present?
- .table-responsive
- %table.table.table-bordered.u2f-registrations
- %colgroup
- %col{ width: "50%" }
- %col{ width: "30%" }
- %col{ width: "20%" }
- %thead
- %tr
- %th Name
- %th Registered On
- %th
- %tbody
- - @u2f_registrations.each do |registration|
+ - if @u2f_registrations.present?
+ .table-responsive
+ %table.table.table-bordered.u2f-registrations
+ %colgroup
+ %col{ width: "50%" }
+ %col{ width: "30%" }
+ %col{ width: "20%" }
+ %thead
%tr
- %td= registration.name.presence || "<no name set>"
- %td= registration.created_at.to_date.to_s(:medium)
- %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
-
- - else
- .settings-message.text-center
- You don't have any U2F devices registered yet.
-
+ %th Name
+ %th Registered On
+ %th
+ %tbody
+ - @u2f_registrations.each do |registration|
+ %tr
+ %td= registration.name.presence || "<no name set>"
+ %td= registration.created_at.to_date.to_s(:medium)
+ %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
-- if two_factor_skippable?
- :javascript
- var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
- $(".flash-alert").append(button);
+ - else
+ .settings-message.text-center
+ You don't have any U2F devices registered yet.
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index aa0cb3e1a50..ad63f5e73ae 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -1,18 +1,10 @@
-- @no_container = true
-
%div{ class: container_class }
- .nav-block.activity-filter-block
+ .nav-block.activity-filter-block.activities
.controls
- = link_to namespace_project_path(@project.namespace, @project, rss_url_options), title: "Subscribe", class: 'btn rss-btn has-tooltip' do
+ = link_to project_path(@project, rss_url_options), title: "Subscribe", class: 'btn rss-btn has-tooltip' do
= icon('rss')
= render 'shared/event_filter'
.content_list.project-activity{ :"data-href" => activity_project_path(@project) }
= spinner
-
-:javascript
- var activity = new gl.Activities();
- $(document).on('page:restore', function (event) {
- activity.reloadActivities()
- })
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
new file mode 100644
index 00000000000..4f3698f91e6
--- /dev/null
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -0,0 +1,6 @@
+- project = local_assigns.fetch(:project)
+- return unless project.delete_error.present?
+
+.project-deletion-failed-message.alert.alert-warning
+ This project was scheduled for deletion, but failed with the following message:
+ = project.delete_error
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
new file mode 100644
index 00000000000..623d3bc91c6
--- /dev/null
+++ b/app/views/projects/_export.html.haml
@@ -0,0 +1,41 @@
+- return unless current_application_settings.project_export_enabled?
+
+- project = local_assigns.fetch(:project)
+- expanded = Rails.env.test?
+
+%section.settings
+ .settings-header
+ %h4
+ Export project
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .bs-callout.bs-callout-info
+ %p.append-bottom-0
+ %p
+ The following items will be exported:
+ %ul
+ %li Project and wiki repositories
+ %li Project uploads
+ %li Project configuration including web hooks and services
+ %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
+ %p
+ The following items will NOT be exported:
+ %ul
+ %li Job traces and artifacts
+ %li LFS objects
+ %li Container registry images
+ %li CI variables
+ %li Any encrypted tokens
+ %p
+ Once the exported file is ready, you will receive a notification email with a download link.
+ - if project.export_project_path
+ = link_to 'Download export', download_export_project_path(project),
+ rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
+ = link_to 'Generate new export', generate_new_export_project_path(project),
+ method: :post, class: "btn btn-default"
+ - else
+ = link_to 'Export project', export_project_path(project),
+ method: :post, class: "btn btn-default"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 96c2fa87f45..3a7a99462a6 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,6 +1,13 @@
+- commit = local_assigns.fetch(:commit) { @repository.commit }
+- ref = local_assigns.fetch(:ref) { current_ref }
+- project = local_assigns.fetch(:project) { @project }
+- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
+
#tree-holder.tree-holder.clearfix
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- = render 'projects/tree/tree_content', tree: @tree
+ - if !show_new_repo? && commit
+ = render 'shared/commit_well', commit: commit, ref: ref, project: project
+ = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index 3feb11645a0..da1b2d7f9b6 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,3 +1,3 @@
-= link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do
+= link_to project_find_file_path(@project, @ref), class: 'btn shortcuts-find-file', rel: 'nofollow' do
= icon('search')
- %span Find file
+ %span= _('Find file')
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
new file mode 100644
index 00000000000..f47d84ef755
--- /dev/null
+++ b/app/views/projects/_flash_messages.html.haml
@@ -0,0 +1,8 @@
+- project = local_assigns.fetch(:project)
+- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message
+
+= content_for flash_message_container do
+ = render partial: 'deletion_failed', locals: { project: project }
+ - if current_user && can?(current_user, :download_code, project)
+ = render 'shared/no_ssh'
+ = render 'shared/no_password'
diff --git a/app/views/projects/_head.html.haml b/app/views/projects/_head.html.haml
index db08b77c8e0..dba84838a52 100644
--- a/app/views/projects/_head.html.haml
+++ b/app/views/projects/_head.html.haml
@@ -4,17 +4,14 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: container_class }
= nav_link(path: 'projects#show') do
- = link_to project_path(@project), title: 'Project home', class: 'shortcuts-project' do
- %span
- Home
+ = link_to project_path(@project), title: _('Project home'), class: 'shortcuts-project' do
+ %span= _('Home')
= nav_link(path: 'projects#activity') do
- = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
- %span
- Activity
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ %span= _('Activity')
- if can?(current_user, :read_cycle_analytics, @project)
= nav_link(path: 'cycle_analytics#show') do
- = link_to project_cycle_analytics_path(@project), title: 'Cycle Analytics', class: 'shortcuts-project-cycle-analytics' do
- %span
- Cycle Analytics
+ = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
+ %span= _('Cycle Analytics')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 0fd19780570..873b3045ea9 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,7 +14,7 @@
- if forked_from_project = @project.forked_from_project
%p
- Forked from
+ #{ s_('ForkedFromProjectPath|Forked from') }
= link_to project_path(forked_from_project) do
= forked_from_project.namespace.try(:name)
@@ -24,7 +24,7 @@
= render 'projects/buttons/fork'
%span.hidden-xs
- - if @project.feature_available?(:repository, current_user)
+ - if can?(current_user, :download_code, @project)
.project-clone-holder
= render "shared/clone_panel"
diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml
deleted file mode 100644
index df3b1c75508..00000000000
--- a/app/views/projects/_last_commit.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- ref = local_assigns.fetch(:ref)
-- status = commit.status(ref)
-- if status
- = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do
- = ci_icon_for_status(status)
- = ci_text_for_status(status)
-
-= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id"
-= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message"
-&middot;
-#{time_ago_with_tooltip(commit.committed_date)} by
-= commit_author_link(commit, avatar: true, size: 24)
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 768bc1fb323..1a71bfca2e2 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -1,18 +1,18 @@
-- if event = last_push_event
- - if show_last_push_widget?(event)
- .row-content-block.top-block.hidden-xs.white
- %div{ class: container_class }
- .event-last-push
- .event-last-push-text
- %span You pushed to
- = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do
- %strong= event.ref_name
- - if @project && event.project != @project
- %span at
- %strong= link_to_project event.project
- = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
- #{time_ago_with_tooltip(event.created_at)}
+- event = last_push_event
+- if event && show_last_push_widget?(event)
+ .row-content-block.top-block.hidden-xs.white
+ .event-last-push
+ .event-last-push-text
+ %span You pushed to
+ %strong
+ = link_to event.ref_name, project_commits_path(event.project, event.ref_name), class: 'ref-name'
- .pull-right
- = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
- Create merge request
+ - if event.project != @project
+ %span at
+ %strong= link_to_project event.project
+
+ #{time_ago_with_tooltip(event.created_at)}
+
+ .pull-right
+ = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
+ #{ _('Create merge request') }
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 23e27c1105c..97041b87c48 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,3 +1,10 @@
+- referenced_users = local_assigns.fetch(:referenced_users, nil)
+
+- if defined?(@issue) && @issue.confidential?
+ .confidential-issue-warning
+ = confidential_icon(@issue)
+ %span This is a confidential issue. Your comment will not be visible to the public.
+
.md-area
.md-header
%ul.nav-links.clearfix
@@ -8,11 +15,6 @@
%a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 }
Preview
- - if defined?(@issue) && @issue.confidential?
- %li.confidential-issue-warning
- = icon('warning')
- %span This is a confidential issue. Your comment will not be visible to the public.
-
%li.pull-right
.toolbar-group
= markdown_toolbar_button({ icon: "bold fw", data: { "md-tag" => "**" }, title: "Add bold text" })
@@ -28,9 +30,10 @@
.md-write-holder
= yield
- .md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) }
+ .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } }
+ .referenced-commands.hide
- - if defined?(referenced_users) && referenced_users
+ - if referenced_users
.referenced-users.hide
%span
= icon("exclamation-triangle")
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index 818010bc7d3..cc5afa943cf 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -1,8 +1,3 @@
- form = local_assigns.fetch(:form)
-%fieldset.features.merge-requests-feature.append-bottom-default
- %hr
- %h5.prepend-top-0
- Merge Requests
-
- = render 'projects/merge_request_merge_settings', form: form
+= render 'projects/merge_request_merge_settings', form: form
diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
new file mode 100644
index 00000000000..97cf13df070
--- /dev/null
+++ b/app/views/projects/_project_templates.html.haml
@@ -0,0 +1,10 @@
+.project-templates-buttons.import-buttons{ data: { toggle: "buttons" } }
+ .btn.blank-option.active
+ %input{ type: "radio", autocomplete: "off", name: "project_templates", id: "blank", checked: "true" }
+ = icon('file-o', class: 'btn-template-icon')
+ Blank
+ - Gitlab::ProjectTemplate.all.each do |template|
+ .btn
+ %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name }
+ = custom_icon(template.logo)
+ = template.title
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
deleted file mode 100644
index c0d12cbc66e..00000000000
--- a/app/views/projects/_readme.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- if readme = @repository.readme
- %article.readme-holder
- .pull-right
- - if can?(current_user, :push_code, @project)
- = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)), class: 'light edit-project-readme'
- .file-content.wiki
- = markup(readme.name, readme.data, rendered: @repository.rendered_readme)
-- else
- .row-content-block.second-block.center
- %h3.page-title
- This project does not have a README yet
- - if can?(current_user, :push_code, @project)
- %p
- A
- %code README
- file contains information about other files in a repository and is commonly
- distributed with computer software, forming part of its documentation.
- %p
- We recommend you to
- = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link'
- file to the repository and GitLab will render it here instead of this message.
diff --git a/app/views/projects/_visibility_select.html.haml b/app/views/projects/_visibility_select.html.haml
index 65fc0a36ca9..4026b9e3c46 100644
--- a/app/views/projects/_visibility_select.html.haml
+++ b/app/views/projects/_visibility_select.html.haml
@@ -1,5 +1,7 @@
- if can_change_visibility_level?(@project, current_user)
- = form.select(model_method, visibility_select_options(@project, selected_level), {}, class: 'form-control visibility-select')
+ .select-wrapper
+ = form.select(model_method, visibility_select_options(@project, selected_level), {}, class: 'form-control visibility-select select-control')
+ = icon('chevron-down')
- else
.info.js-locked{ data: { help_block: visibility_level_description(@project.visibility_level, @project) } }
= visibility_level_icon(@project.visibility_level)
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 41d42740f61..a56c3503c77 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -2,8 +2,7 @@
%div{ class: container_class }
.wiki-holder.prepend-top-default.append-bottom-default
.wiki
- = preserve do
- = render_wiki_content(@wiki_home)
+ = render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
.project-home-empty{ class: [('row-content-block' if can_create_wiki), ('content-block' unless can_create_wiki)] }
@@ -15,5 +14,5 @@
Add a homepage to your wiki that contains information about your project
%p
We recommend you
- = link_to "add a homepage", namespace_project_wiki_path(@project.namespace, @project, :home)
+ = link_to "add a homepage", project_wiki_path(@project, :home)
to your project's wiki and GitLab will show it here instead of this message.
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index 0c8241053e7..afc40ca4eab 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -1,10 +1,16 @@
- @gfm_form = true
-- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
+- current_text ||= nil
+- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
+- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f
- = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
+ = f.text_area attr,
+ class: classes,
+ placeholder: placeholder,
+ data: { supports_quick_actions: supports_quick_actions,
+ supports_autocomplete: supports_autocomplete }
- else
- = text_area_tag attr, nil, class: classes, placeholder: placeholder
+ = text_area_tag attr, current_text, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
= icon('compress')
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index 27c8e3c7fca..9e2688e492e 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -1,3 +1,8 @@
+- @no_container = true
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Project", project_path(@project))
+
- page_title "Activity"
= render "projects/head"
diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml
index 9e49c93388a..03be6f15313 100644
--- a/app/views/projects/artifacts/_tree_directory.html.haml
+++ b/app/views/projects/artifacts/_tree_directory.html.haml
@@ -1,8 +1,8 @@
-- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path)
+- path_to_directory = browse_project_job_artifacts_path(@project, @build, path: directory.path)
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
= tree_icon('folder', '755', directory.name)
- %span.str-truncated
- = link_to directory.name, path_to_directory
+ = link_to path_to_directory do
+ %span.str-truncated= directory.name
%td
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index 36fb4c998c9..8edb9be049a 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -1,9 +1,10 @@
-- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path)
+- path_to_file = file_project_job_artifacts_path(@project, @build, path: file.path)
%tr.tree-item{ 'data-link' => path_to_file }
+ - blob = file.blob
%td.tree-item-file-name
- = tree_icon('file', '664', file.name)
- %span.str-truncated
- = link_to file.name, path_to_file
+ = tree_icon('file', blob.mode, blob.name)
+ = link_to path_to_file do
+ %span.str-truncated= blob.name
%td
- = number_to_human_size(file.metadata[:size], precision: 2)
+ = number_to_human_size(blob.size, precision: 2)
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index de8c173f26f..a33743c2f57 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,13 +1,23 @@
-- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+= render "projects/pipelines/head"
-.top-block.row-content-block.clearfix
- .pull-right
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build),
- rel: 'nofollow', download: '', class: 'btn btn-default download' do
- = icon('download')
- Download artifacts archive
+= render "projects/jobs/header", show_controls: false
.tree-holder
+ .nav-block
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build)
+ - path_breadcrumbs do |title, path|
+ %li
+ = link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path)
+
+ .tree-controls
+ = link_to download_project_job_artifacts_path(@project, @build),
+ rel: 'nofollow', download: '', class: 'btn btn-default download' do
+ = icon('download')
+ Download artifacts archive
+
.tree-content-holder
%table.table.tree-table
%thead
diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml
new file mode 100644
index 00000000000..b85bbcb980e
--- /dev/null
+++ b/app/views/projects/artifacts/file.html.haml
@@ -0,0 +1,33 @@
+- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+= render "projects/pipelines/head"
+
+= render "projects/jobs/header", show_controls: false
+
+.tree-holder
+ .nav-block
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build)
+ - path_breadcrumbs do |title, path|
+ - title = truncate(title, length: 40)
+ %li
+ - if path == @path
+ = link_to file_project_job_artifacts_path(@project, @build, path) do
+ %strong= title
+ - else
+ = link_to title, browse_project_job_artifacts_path(@project, @build, path)
+
+
+ %article.file-holder
+ - blob = @entry.blob
+ .js-file-title.file-title-flex-parent
+ = render 'projects/blob/header_content', blob: blob
+
+ .file-actions.hidden-xs
+ = render 'projects/blob/viewer_switcher', blob: blob
+
+ .btn-group{ role: "group" }<
+ = copy_blob_source_button(blob)
+ = open_raw_blob_button(blob)
+
+ = render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blame/_age_map_legend.html.haml b/app/views/projects/blame/_age_map_legend.html.haml
new file mode 100644
index 00000000000..533dc20ffb3
--- /dev/null
+++ b/app/views/projects/blame/_age_map_legend.html.haml
@@ -0,0 +1,12 @@
+%span.left-label Newer
+%span.legend-box.legend-box-0
+%span.legend-box.legend-box-1
+%span.legend-box.legend-box-2
+%span.legend-box.legend-box-3
+%span.legend-box.legend-box-4
+%span.legend-box.legend-box-5
+%span.legend-box.legend-box-6
+%span.legend-box.legend-box-7
+%span.legend-box.legend-box-8
+%span.legend-box.legend-box-9
+%span.right-label Older
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 35885b2c7b4..c7359d873d9 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,28 +1,30 @@
- @no_container = true
+- project_duration = age_map_duration(@blame_groups, @project)
- page_title "Blame", @blob.path, @ref
= render "projects/commits/head"
%div{ class: container_class }
- %h3.page-title Blame view
-
#blob-content-holder.tree-holder
+ = render "projects/blob/breadcrumb", blob: @blob, blame: true
+
.file-holder
= render "projects/blob/header", blob: @blob, blame: true
-
+ .file-blame-legend
+ = render 'age_map_legend'
.table-responsive.file-content.blame.code.js-syntax-highlight
%table
- current_line = 1
- @blame_groups.each do |blame_group|
%tr
- %td.blame-commit
+ - commit = blame_group[:commit]
+ %td.blame-commit{ class: age_map_class(commit.committed_date, project_duration) }
.commit
- - commit = blame_group[:commit]
= author_avatar(commit, size: 36)
.commit-row-title
- %strong
- = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark"
+ %span.item-title.str-truncated-100
+ = link_to_gfm commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title
.pull-right
- = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "monospace"
+ = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha"
&nbsp;
.light
= commit_author_link(commit, avatar: false)
diff --git a/app/views/projects/blob/_auxiliary_viewer.html.haml b/app/views/projects/blob/_auxiliary_viewer.html.haml
new file mode 100644
index 00000000000..9749afdc580
--- /dev/null
+++ b/app/views/projects/blob/_auxiliary_viewer.html.haml
@@ -0,0 +1,5 @@
+- blob = local_assigns.fetch(:blob)
+- auxiliary_viewer = blob.auxiliary_viewer
+- if auxiliary_viewer && auxiliary_viewer.render_error.nil? && auxiliary_viewer.visible_to?(current_user)
+ .well-segment.blob-auxiliary-viewer
+ = render 'projects/blob/viewer', viewer: auxiliary_viewer
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 9aafff343f0..849716a679b 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -1,34 +1,13 @@
-.nav-block
- .tree-ref-holder
- = render 'shared/ref_switcher', destination: 'blob', path: @path
+= render "projects/blob/breadcrumb", blob: blob
- %ul.breadcrumb.repo-breadcrumb
- %li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
- = @project.path
- - tree_breadcrumbs(@tree, 6) do |title, path|
- %li
- - if path
- - if path.end_with?(@path)
- = link_to namespace_project_blob_path(@project.namespace, @project, path) do
- %strong
- = truncate(title, length: 40)
- - else
- = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path)
- - else
- = link_to title, '#'
+.info-well.hidden-xs
+ .well-segment
+ %ul.blob-commit-info
+ = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref
-%ul.blob-commit-info.hidden-xs
- - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path)
- = render blob_commit, project: @project, ref: @ref
+ = render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
%article.file-holder
- = render "projects/blob/header", blob: blob
-
- - if blob.empty?
- .file-content.code
- .nothing-here-block
- Empty file
- - else
- = render blob.to_partial_path(@project), blob: blob
+ = render 'projects/blob/header', blob: blob
+ = render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
new file mode 100644
index 00000000000..1c148de9678
--- /dev/null
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -0,0 +1,37 @@
+- blame = local_assigns.fetch(:blame, false)
+.nav-block
+ .tree-ref-container
+ .tree-ref-holder
+ = render 'shared/ref_switcher', destination: 'blob', path: @path
+
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to project_tree_path(@project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ - title = truncate(title, length: 40)
+ %li
+ - if path == @path
+ = link_to project_blob_path(@project, tree_join(@ref, path)) do
+ %strong= title
+ - else
+ = link_to title, project_tree_path(@project, tree_join(@ref, path))
+
+ .tree-controls
+ = render 'projects/find_file_link'
+
+ .btn-group{ role: "group" }<
+ -# only show normal/blame view links for text files
+ - if blob.readable_text?
+ - if blame
+ = link_to 'Normal view', project_blob_path(@project, @id),
+ class: 'btn'
+ - else
+ = link_to 'Blame', project_blame_path(@project, @id),
+ class: 'btn js-blob-blame-link' unless blob.empty?
+
+ = link_to 'History', project_commits_path(@project, @id),
+ class: 'btn'
+
+ = link_to 'Permalink', project_blob_path(@project,
+ tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml
new file mode 100644
index 00000000000..7afbd85cd6d
--- /dev/null
+++ b/app/views/projects/blob/_content.html.haml
@@ -0,0 +1,8 @@
+- simple_viewer = blob.simple_viewer
+- rich_viewer = blob.rich_viewer
+- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
+
+= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
+
+- if rich_viewer
+ = render 'projects/blob/viewer', viewer: rich_viewer, hidden: !rich_viewer_active
diff --git a/app/views/projects/blob/_download.html.haml b/app/views/projects/blob/_download.html.haml
deleted file mode 100644
index 7908fcae3de..00000000000
--- a/app/views/projects/blob/_download.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.file-content.blob_file.blob-no-preview
- .center
- = link_to namespace_project_raw_path(@project.namespace, @project, @id) do
- %h1.light
- %i.fa.fa-download
- %h4
- Download (#{number_to_human_size blob_size(blob)})
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index c553db84ee0..0be15cc179f 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -1,40 +1,17 @@
- blame = local_assigns.fetch(:blame, false)
.js-file-title.file-title-flex-parent
- .file-header-content
- = blob_icon blob.mode, blob.name
-
- %strong.file-title-name
- = blob.name
-
- = copy_file_path_button(blob.path)
-
- %small
- = number_to_human_size(blob_size(blob))
+ = render 'projects/blob/header_content', blob: blob
.file-actions.hidden-xs
- .btn-group{ role: "group" }<
- = copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob)
- = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id))
- = view_on_environment_button(@commit.sha, @path, @environment) if @environment
+ = render 'projects/blob/viewer_switcher', blob: blob unless blame
.btn-group{ role: "group" }<
- -# only show normal/blame view links for text files
- - if blob_text_viewable?(blob)
- - if blame
- = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
- class: 'btn btn-sm'
- - else
- = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id),
- class: 'btn btn-sm js-blob-blame-link' unless blob.empty?
-
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id),
- class: 'btn btn-sm'
-
- = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
- tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
+ = copy_blob_source_button(blob) unless blame
+ = open_raw_blob_button(blob)
+ = view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
- = edit_blob_link if blob_text_viewable?(blob)
+ = edit_blob_link
- if current_user
= replace_blob_link
= delete_blob_link
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
new file mode 100644
index 00000000000..98bedae650a
--- /dev/null
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -0,0 +1,10 @@
+.file-header-content
+ = blob_icon blob.mode, blob.name
+
+ %strong.file-title-name
+ = blob.name
+
+ = copy_file_path_button(blob.path)
+
+ %small
+ = number_to_human_size(blob.raw_size)
diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml
deleted file mode 100644
index 73877d730f5..00000000000
--- a/app/views/projects/blob/_image.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-.file-content.image_file
- %img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name }
diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml
deleted file mode 100644
index 0090f7a11df..00000000000
--- a/app/views/projects/blob/_markup.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- blob.load_all_data!(@repository)
-
-.file-content.wiki
- = markup(blob.name, blob.data)
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 7f470b890ba..03ab1bb59e4 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -3,23 +3,20 @@
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title Create New Directory
+ %h3.page-title= _('Create New Directory')
.modal-body
- = form_tag namespace_project_create_dir_path(@project.namespace, @project, @id), method: :post, remote: false, class: 'form-horizontal js-create-dir-form js-quick-submit js-requires-input' do
+ = form_tag project_create_dir_path(@project, @id), method: :post, remote: false, class: 'form-horizontal js-create-dir-form js-quick-submit js-requires-input' do
.form-group
- = label_tag :dir_name, 'Directory name', class: 'control-label'
+ = label_tag :dir_name, _('Directory name'), class: 'control-label'
.col-sm-10
= text_field_tag :dir_name, params[:dir_name], required: true, class: 'form-control'
- = render 'shared/new_commit_form', placeholder: "Add new directory"
+ = render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag "Create directory", class: 'btn btn-create'
+ = submit_tag _("Create directory"), class: 'btn btn-create'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- unless can?(current_user, :push_code, @project)
.inline.prepend-left-10
= commit_in_fork_help
-
-:javascript
- new NewCommitForm($('.js-create-dir-form'))
diff --git a/app/views/projects/blob/_notebook.html.haml b/app/views/projects/blob/_notebook.html.haml
deleted file mode 100644
index ab1cf933944..00000000000
--- a/app/views/projects/blob/_notebook.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('notebook_viewer')
-
-.file-content#js-notebook-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } }
diff --git a/app/views/projects/blob/_pdf.html.haml b/app/views/projects/blob/_pdf.html.haml
deleted file mode 100644
index 58dc88e3bf7..00000000000
--- a/app/views/projects/blob/_pdf.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('pdf_viewer')
-
-.file-content#js-pdf-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } }
diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
index db6662a95ac..750bdef3308 100644
--- a/app/views/projects/blob/_remove.html.haml
+++ b/app/views/projects/blob/_remove.html.haml
@@ -6,13 +6,10 @@
%h3.page-title Delete #{@blob.name}
.modal-body
- = form_tag namespace_project_blob_path(@project.namespace, @project, @id), method: :delete, class: 'form-horizontal js-replace-blob-form js-quick-submit js-requires-input' do
+ = form_tag project_blob_path(@project, @id), method: :delete, class: 'form-horizontal js-delete-blob-form js-quick-submit js-requires-input' do
= render 'shared/new_commit_form', placeholder: "Delete #{@blob.name}"
.form-group
.col-sm-offset-2.col-sm-10
= button_tag 'Delete file', class: 'btn btn-remove btn-remove-file'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
-
-:javascript
- new NewCommitForm($('.js-replace-blob-form'))
diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml
new file mode 100644
index 00000000000..9eef6cafd04
--- /dev/null
+++ b/app/views/projects/blob/_render_error.html.haml
@@ -0,0 +1,7 @@
+.file-content.code
+ .nothing-here-block
+ The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}.
+
+ You can
+ = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+ instead.
diff --git a/app/views/projects/blob/_sketch.html.haml b/app/views/projects/blob/_sketch.html.haml
deleted file mode 100644
index dad9369cb2a..00000000000
--- a/app/views/projects/blob/_sketch.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('sketch_viewer')
-
-.file-content#js-sketch-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } }
- .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
- = icon('spinner spin 2x', 'aria-hidden' => 'true');
diff --git a/app/views/projects/blob/_stl.html.haml b/app/views/projects/blob/_stl.html.haml
deleted file mode 100644
index a9332a0eeb6..00000000000
--- a/app/views/projects/blob/_stl.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('stl_viewer')
-
-.file-content.is-stl-loading
- .text-center#js-stl-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } }
- = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
- .text-center.prepend-top-default.append-bottom-default.stl-controls
- .btn-group
- %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
- Wireframe
- %button.btn.btn-default.btn-sm.active.js-material-changer{ data: { type: 'default' } }
- Solid
diff --git a/app/views/projects/blob/_svg.html.haml b/app/views/projects/blob/_svg.html.haml
deleted file mode 100644
index 93be58fc658..00000000000
--- a/app/views/projects/blob/_svg.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- if blob.size_within_svg_limits?
- -# We need to scrub SVG but we cannot do so in the RawController: it would
- -# be wrong/strange if RawController modified the data.
- - blob.load_all_data!(@repository)
- - blob = sanitize_svg(blob)
- .file-content.image_file
- %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name }
-- else
- = render 'too_large'
diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml
deleted file mode 100644
index 20638f6961d..00000000000
--- a/app/views/projects/blob/_text.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- blob.load_all_data!(@repository)
-= render 'shared/file_highlight', blob: blob, repository: @repository
diff --git a/app/views/projects/blob/_too_large.html.haml b/app/views/projects/blob/_too_large.html.haml
deleted file mode 100644
index a505f87df40..00000000000
--- a/app/views/projects/blob/_too_large.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.file-content.code
- .nothing-here-block
- The file could not be displayed as it is too large, you can
- #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')}
- instead.
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index e14885f264b..05b7dfe2872 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -9,16 +9,20 @@
.dropzone
.dropzone-previews.blob-upload-dropzone-previews
%p.dz-message.light
- Attach a file by drag &amp; drop or
- = link_to 'click to upload', '#', class: "markdown-selector"
+ - upload_link = link_to s_('UploadLink|click to upload'), '#', class: "markdown-selector"
+ - dropzone_text = _('Attach a file by drag &amp; drop or %{upload_link}') % { upload_link: upload_link }
+ #{ dropzone_text.html_safe }
+
%br
.dropzone-alerts.alert.alert-danger.data{ style: "display:none" }
= render 'shared/new_commit_form', placeholder: placeholder
.form-actions
- = button_tag button_title, class: 'btn btn-small btn-create btn-upload-file', id: 'submit-all'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = button_tag class: 'btn btn-create btn-upload-file', id: 'submit-all', type: 'button' do
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
+ = button_title
+ = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- unless can?(current_user, :push_code, @project)
.inline.prepend-left-10
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
new file mode 100644
index 00000000000..cc85e5de40f
--- /dev/null
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -0,0 +1,20 @@
+- hidden = local_assigns.fetch(:hidden, false)
+- render_error = viewer.render_error
+- load_async = local_assigns.fetch(:load_async, viewer.load_async? && render_error.nil?)
+
+- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async
+.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) }
+ - if render_error
+ = render 'projects/blob/render_error', viewer: viewer
+ - elsif load_async
+ = render viewer.loading_partial_path, viewer: viewer
+ - else
+ - viewer.prepare!
+
+ -# In the rare case where the first kilobyte of the file looks like text,
+ -# but the file turns out to actually be binary after loading all data,
+ -# we fall back on the binary Download viewer.
+ - viewer = BlobViewer::Download.new(viewer.blob) if viewer.binary_detected_after_load?
+
+ = render viewer.partial_path, viewer: viewer
+
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
new file mode 100644
index 00000000000..6a521069418
--- /dev/null
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -0,0 +1,12 @@
+- if blob.show_viewer_switcher?
+ - simple_viewer = blob.simple_viewer
+ - rich_viewer = blob.rich_viewer
+
+ .btn-group.js-blob-viewer-switcher{ role: "group" }
+ - simple_label = "Display #{simple_viewer.switcher_title}"
+ %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
+ = icon(simple_viewer.switcher_icon)
+
+ - rich_label = "Display #{rich_viewer.switcher_title}"
+ %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
+ = icon(rich_viewer.switcher_icon)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 4af62461151..992fe7f717f 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Repository"
- @no_container = true
- page_title "Edit", @blob.path, @ref
- content_for :page_specific_javascripts do
@@ -9,7 +10,7 @@
- if @conflict
.alert.alert-danger
Someone edited the file the same time you did. Please check out
- = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer'
+ = link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer'
and make sure your changes will not unintentionally remove theirs.
.editor-title-row
%h3.page-title.blob-edit-page-title
@@ -22,13 +23,13 @@
Write
%li
- = link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do
+ = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do
= editing_preview_title(@blob.name)
- = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do
+ = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data
= render 'shared/new_commit_form', placeholder: "Update #{@blob.name}"
= hidden_field_tag 'last_commit_sha', @last_commit_sha
= hidden_field_tag 'content', '', id: "file-content"
= hidden_field_tag 'from_merge_request_iid', params[:from_merge_request_iid]
- = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id)
+ = render 'projects/commit_button', ref: @ref, cancel_path: project_blob_path(@project, @id)
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 2afb909572a..a4263774dfd 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Repository"
- page_title "New File", @path.presence, @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
@@ -7,10 +8,10 @@
New file
= render 'template_selectors'
.file-editor
- = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
+ = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file"
= hidden_field_tag 'content', '', id: 'file-content'
= render 'projects/commit_button', ref: @ref,
- cancel_path: namespace_project_tree_path(@project.namespace, @project, @id)
+ cancel_path: project_tree_path(@project, @id)
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index e87b73c9a34..da2cef17e8a 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,4 +1,4 @@
-.diff-file
+.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
.file-content.wiki
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index b9b3f3ec7a3..240e62d5ac5 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -1,15 +1,27 @@
+- breadcrumb_title "Repository"
- @no_container = true
+
- page_title @blob.path, @ref
= render "projects/commits/head"
-%div{ class: container_class }
- = render 'projects/last_push'
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'blob'
+
+ - if show_new_repo?
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'repo'
- #tree-holder.tree-holder
- = render 'blob', blob: @blob
+= render 'projects/last_push'
+
+%div{ class: container_class }
+ - if show_new_repo?
+ = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id)
+ - else
+ #tree-holder.tree-holder
+ = render 'blob', blob: @blob
- - if can_modify_blob?(@blob)
- = render 'projects/blob/remove'
+ - if can_modify_blob?(@blob)
+ = render 'projects/blob/remove'
- - title = "Replace #{@blob.name}"
- = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put
+ - title = "Replace #{@blob.name}"
+ = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
new file mode 100644
index 00000000000..1e7c461f02e
--- /dev/null
+++ b/app/views/projects/blob/viewers/_balsamiq.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('balsamiq_viewer')
+
+.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml
new file mode 100644
index 00000000000..46e3e7f798a
--- /dev/null
+++ b/app/views/projects/blob/viewers/_changelog.html.haml
@@ -0,0 +1,4 @@
+= icon('history fw')
+= succeed '.' do
+ To find the state of this project's repository at the time of any of these versions, check out
+ = link_to "the tags", project_tags_path(viewer.project)
diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml
new file mode 100644
index 00000000000..c78f04c9c7c
--- /dev/null
+++ b/app/views/projects/blob/viewers/_contributing.html.haml
@@ -0,0 +1,9 @@
+= icon('book fw')
+After you've reviewed these contribution guidelines, you'll be all set to
+
+- options = contribution_options(viewer.project)
+- if options.any?
+ = succeed '.' do
+ = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+- else
+ contribute to this project.
diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml
new file mode 100644
index 00000000000..a0f0215a5ff
--- /dev/null
+++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml
@@ -0,0 +1,11 @@
+= icon('cubes fw')
+= succeed '.' do
+ This project manages its dependencies using
+ %strong= viewer.manager_name
+
+ - if viewer.package_name
+ and defines a #{viewer.package_type} named
+ %strong<
+ = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer'
+
+= link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml
new file mode 100644
index 00000000000..6d1138f7959
--- /dev/null
+++ b/app/views/projects/blob/viewers/_download.html.haml
@@ -0,0 +1,7 @@
+.file-content.blob_file.blob-no-preview
+ .center
+ = link_to blob_raw_path do
+ %h1.light
+ = icon('download')
+ %h4
+ Download (#{number_to_human_size(viewer.blob.raw_size)})
diff --git a/app/views/projects/blob/viewers/_empty.html.haml b/app/views/projects/blob/viewers/_empty.html.haml
new file mode 100644
index 00000000000..a293a8de231
--- /dev/null
+++ b/app/views/projects/blob/viewers/_empty.html.haml
@@ -0,0 +1,3 @@
+.file-content.code
+ .nothing-here-block
+ Empty file
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
new file mode 100644
index 00000000000..28c5be6ebf3
--- /dev/null
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
@@ -0,0 +1,9 @@
+- if viewer.valid?
+ = icon('check fw')
+ This GitLab CI configuration is valid.
+- else
+ = icon('warning fw')
+ This GitLab CI configuration is invalid:
+ = viewer.validation_message
+
+= link_to 'Learn more', help_page_path('ci/yaml/README')
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
new file mode 100644
index 00000000000..10cbf6a2f7a
--- /dev/null
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
@@ -0,0 +1,4 @@
+= icon('spinner spin fw')
+Validating GitLab CI configuration…
+
+= link_to 'Learn more', help_page_path('ci/yaml/README')
diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml
new file mode 100644
index 00000000000..26ea028c5d7
--- /dev/null
+++ b/app/views/projects/blob/viewers/_image.html.haml
@@ -0,0 +1,2 @@
+.file-content.image_file
+ = image_tag(blob_raw_path, alt: viewer.blob.name)
diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml
new file mode 100644
index 00000000000..fb9d0b99d09
--- /dev/null
+++ b/app/views/projects/blob/viewers/_license.html.haml
@@ -0,0 +1,8 @@
+- license = viewer.license
+
+= icon('balance-scale fw')
+This project is licensed under the
+= succeed '.' do
+ %strong= license.name
+
+= link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
new file mode 100644
index 00000000000..120c0540335
--- /dev/null
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -0,0 +1,2 @@
+.text-center.prepend-top-default.append-bottom-default
+ = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…')
diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
new file mode 100644
index 00000000000..c7dc9e3250a
--- /dev/null
+++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
@@ -0,0 +1,2 @@
+= icon('spinner spin fw')
+Analyzing file…
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
new file mode 100644
index 00000000000..230305b488d
--- /dev/null
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -0,0 +1,4 @@
+- blob = viewer.blob
+- rendered_markup = blob.rendered_markup if blob.respond_to?(:rendered_markup)
+.file-content.wiki
+ = markup(blob.name, blob.data, rendered: rendered_markup)
diff --git a/app/views/projects/blob/viewers/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml
new file mode 100644
index 00000000000..8a41bc53004
--- /dev/null
+++ b/app/views/projects/blob/viewers/_notebook.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('notebook_viewer')
+
+.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml
new file mode 100644
index 00000000000..ec2b18bd4ab
--- /dev/null
+++ b/app/views/projects/blob/viewers/_pdf.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('pdf_viewer')
+
+.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
new file mode 100644
index 00000000000..d8492abc638
--- /dev/null
+++ b/app/views/projects/blob/viewers/_readme.html.haml
@@ -0,0 +1,4 @@
+= icon('info-circle fw')
+= succeed '.' do
+ To learn more about this project, read
+ = link_to "the wiki", get_project_wiki_path(viewer.project)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
new file mode 100644
index 00000000000..d0fcd55f6c1
--- /dev/null
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -0,0 +1,9 @@
+- if viewer.valid?
+ = icon('check fw')
+ This Route Map is valid.
+- else
+ = icon('warning fw')
+ This Route Map is invalid:
+ = viewer.validation_message
+
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
new file mode 100644
index 00000000000..2318cf82f58
--- /dev/null
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -0,0 +1,4 @@
+= icon('spinner spin fw')
+Validating Route Map…
+
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
new file mode 100644
index 00000000000..775e4584f77
--- /dev/null
+++ b/app/views/projects/blob/viewers/_sketch.html.haml
@@ -0,0 +1,7 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('sketch_viewer')
+
+.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } }
+ .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
+ = icon('spinner spin 2x', 'aria-hidden' => 'true');
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
new file mode 100644
index 00000000000..6578d826ace
--- /dev/null
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -0,0 +1,12 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('stl_viewer')
+
+.file-content.is-stl-loading
+ .text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } }
+ = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+ .text-center.prepend-top-default.append-bottom-default.stl-controls
+ .btn-group
+ %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
+ Wireframe
+ %button.btn.btn-default.btn-sm.active.js-material-changer{ data: { type: 'default' } }
+ Solid
diff --git a/app/views/projects/blob/viewers/_svg.html.haml b/app/views/projects/blob/viewers/_svg.html.haml
new file mode 100644
index 00000000000..62f647581b6
--- /dev/null
+++ b/app/views/projects/blob/viewers/_svg.html.haml
@@ -0,0 +1,4 @@
+- blob = viewer.blob
+- data = sanitize_svg_data(blob.data)
+.file-content.image_file
+ %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(data)}", alt: blob.name }
diff --git a/app/views/projects/blob/viewers/_text.html.haml b/app/views/projects/blob/viewers/_text.html.haml
new file mode 100644
index 00000000000..a91df321ca0
--- /dev/null
+++ b/app/views/projects/blob/viewers/_text.html.haml
@@ -0,0 +1 @@
+= render 'shared/file_highlight', blob: viewer.blob, repository: @repository
diff --git a/app/views/projects/blob/viewers/_video.html.haml b/app/views/projects/blob/viewers/_video.html.haml
new file mode 100644
index 00000000000..36039c08d52
--- /dev/null
+++ b/app/views/projects/blob/viewers/_video.html.haml
@@ -0,0 +1,2 @@
+.file-content.video
+ %video{ src: blob_raw_path, controls: true, data: { setup: '{}' } }
diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml
index 7ca0ec8ed2b..2076e46fde8 100644
--- a/app/views/projects/boards/_show.html.haml
+++ b/app/views/projects/boards/_show.html.haml
@@ -2,10 +2,13 @@
- @content_class = "issue-boards-content"
- page_title "Boards"
+- if show_new_nav?
+ - add_to_breadcrumbs("Issues", project_issues_path(@project))
+
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('filtered_search')
- = page_specific_javascript_bundle_tag('boards')
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'filtered_search'
+ = webpack_bundle_tag 'boards'
%script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
@@ -26,10 +29,11 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
+ ":board-id" => "boardId",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
- "new-issue-path" => new_namespace_project_issue_path(@project.namespace, @project),
+ "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
"label-path" => labels_filter_path,
":issue-link-base" => "issueLinkBase",
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index 5a4eaf92b16..64f5f6d7ba0 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -1,22 +1,33 @@
-.board{ ":class" => '{ "is-draggable": !list.preset }',
+.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }',
":data-id" => "list.id" }
.board-inner
- %header.board-header{ ":class" => '{ "has-border": list.label }', ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
%h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
- %span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")',
- data: { container: "body", placement: "bottom" } }
+ %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
+ ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
+ "aria-hidden": "true" }
+
+ %span.has-tooltip{ "v-if": "list.type !== \"label\"",
+ ":title" => '(list.label ? list.label.description : "")' }
{{ list.title }}
- .board-issue-count-holder.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' }
- %span.board-issue-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
+
+ %span.has-tooltip{ "v-if": "list.type === \"label\"",
+ ":title" => '(list.label ? list.label.description : "")',
+ data: { container: "body", placement: "bottom" },
+ class: "label color-label title",
+ ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" }
+ {{ list.title }}
+ .issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' }
+ %span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
- %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button",
+ %button.issue-count-badge-add-button.btn.btn-small.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
- "aria-label" => "Add an issue",
- "title" => "Add an issue",
+ "aria-label" => "New issue",
+ "title" => "New issue",
data: { placement: "top", container: "body" } }
- = icon("plus")
+ = icon("plus", class: "js-no-trigger-collapse")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
diff --git a/app/views/projects/boards/components/_sidebar.html.haml b/app/views/projects/boards/components/_sidebar.html.haml
index 24d76da6f06..09d70f658a3 100644
--- a/app/views/projects/boards/components/_sidebar.html.haml
+++ b/app/views/projects/boards/components/_sidebar.html.haml
@@ -23,4 +23,5 @@
= render "projects/boards/components/sidebar/labels"
= render "projects/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
- ":list" => "list" }
+ ":list" => "list",
+ "v-if" => "canRemove" }
diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml
index e75ce305440..8d957613be1 100644
--- a/app/views/projects/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml
@@ -1,39 +1,31 @@
-.block.assignee
- .title.hide-collapsed
- Assignee
- - if can?(current_user, :admin_issue, @project)
- = icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
- .value.hide-collapsed
- %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" }
- No assignee
- - if can?(current_user, :admin_issue, @project)
- \-
- %a.js-assign-yourself{ href: "#" }
- assign yourself
- %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username",
- "v-if" => "issue.assignee" }
- %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar",
- width: "32", alt: "Avatar" }
- %span.author
- {{ issue.assignee.name }}
- %span.username
- = precede "@" do
- {{ issue.assignee.username }}
+.block.assignee{ ref: "assigneeBlock" }
+ %template{ "v-if" => "issue.assignees" }
+ %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
+ ":loading" => "loadingAssignees",
+ ":editable" => can?(current_user, :admin_issue, @project) }
+ %assignees.value{ "root-path" => "#{root_url}",
+ ":users" => "issue.assignees",
+ ":editable" => can?(current_user, :admin_issue, @project),
+ "@assign-self" => "assignSelf" }
+
- if can?(current_user, :admin_issue, @project)
.selectbox.hide-collapsed
- %input{ type: "hidden",
- name: "issue[assignee_id]",
- id: "issue_assignee_id",
- ":value" => "issue.assignee.id",
- "v-if" => "issue.assignee" }
+ %input.js-vue{ type: "hidden",
+ name: "issue[assignee_ids][]",
+ ":value" => "assignee.id",
+ "v-if" => "issue.assignees",
+ "v-for" => "assignee in issue.assignees",
+ ":data-avatar_url" => "assignee.avatar",
+ ":data-name" => "assignee.name",
+ ":data-username" => "assignee.username" }
.dropdown
- %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true" },
+ - dropdown_options = issue_assignees_dropdown_options
+ %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: { toggle: 'dropdown', field_name: 'issue[assignee_ids][]', first_user: current_user&.username, current_user: 'true', project_id: @project.id, null_user: 'true', multi_select: 'true', 'dropdown-header': dropdown_options[:data][:'dropdown-header'], 'max-select': dropdown_options[:data][:'max-select'] },
":data-issuable-id" => "issue.id",
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
- Select assignee
+ ":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
+ = dropdown_options[:title]
= icon("chevron-down")
- .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
+ .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
diff --git a/app/views/projects/boards/components/sidebar/_due_date.html.haml b/app/views/projects/boards/components/sidebar/_due_date.html.haml
index 1a3b88e28c5..f44a9d49a54 100644
--- a/app/views/projects/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/projects/boards/components/sidebar/_due_date.html.haml
@@ -23,7 +23,7 @@
.dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
+ ":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml
index 0f0a84c156d..7d0c35fe183 100644
--- a/app/views/projects/boards/components/sidebar/_labels.html.haml
+++ b/app/views/projects/boards/components/sidebar/_labels.html.haml
@@ -19,8 +19,8 @@
":value" => "label.id" }
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
- data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) },
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
+ data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: project_labels_path(@project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) },
+ ":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml
index 190e7290303..002e9994ee0 100644
--- a/app/views/projects/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml
@@ -16,9 +16,10 @@
name: "issue[milestone_id]",
"v-if" => "issue.milestone" }
.dropdown
- %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" },
+ %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), ability_name: "issue", use_id: "true", default_no: "true" },
+ ":data-selected" => "milestoneTitle",
":data-issuable-id" => "issue.id",
- ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
+ ":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
diff --git a/app/views/projects/boards/components/sidebar/_notifications.html.haml b/app/views/projects/boards/components/sidebar/_notifications.html.haml
index a08c7f2af09..aaddd7e249f 100644
--- a/app/views/projects/boards/components/sidebar/_notifications.html.haml
+++ b/app/views/projects/boards/components/sidebar/_notifications.html.haml
@@ -1,5 +1,5 @@
- if current_user
- .block.light.subscription{ ":data-url" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '/toggle_subscription'" }
+ .block.light.subscription{ ":data-url" => "'#{project_issues_path(@project)}/' + issue.id + '/toggle_subscription'" }
%span.issuable-header-text.hide-collapsed.pull-left
Notifications
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 0f9ef3eded3..19712a8f1be 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -6,7 +6,8 @@
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
%li{ class: "js-branch-#{branch.name}" }
%div
- = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated ref-name' do
+ = icon('code-fork')
= branch.name
&nbsp;
- if branch.name == @repository.root_ref
@@ -24,22 +25,43 @@
Merge request
- if branch.name != @repository.root_ref
- = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do
Compare
= render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
- if can?(current_user, :push_code, @project)
- = link_to namespace_project_branch_path(@project.namespace, @project, branch.name),
- class: "btn btn-remove remove-row js-ajax-loading-spinner #{can_remove_branch?(@project, branch.name) ? '' : 'disabled'}",
- method: :delete,
- data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" },
- remote: true,
- "aria-label" => "Delete branch" do
- = icon("trash-o")
+ - if branch.name == @project.repository.root_ref
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: "The default branch cannot be deleted" }
+ = icon("trash-o")
+ - elsif protected_branch?(@project, branch)
+ - if can?(current_user, :delete_protected_branch, @project)
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ title: "Delete protected branch",
+ data: { toggle: "modal",
+ target: "#modal-delete-branch",
+ delete_path: project_branch_path(@project, branch.name),
+ branch_name: branch.name } }
+ = icon("trash-o")
+ - else
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: "Only a project master or owner can delete a protected branch" }
+ = icon("trash-o")
+ - else
+ = link_to project_branch_path(@project, branch.name),
+ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ title: "Delete branch",
+ method: :delete,
+ data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" },
+ remote: true,
+ "aria-label" => "Delete branch" do
+ = icon("trash-o")
- if branch.name != @repository.root_ref
- .divergence-graph{ title: "#{number_commits_ahead} commits ahead, #{number_commits_behind} commits behind #{@repository.root_ref}" }
+ .divergence-graph{ title: "#{number_commits_behind} commits behind #{@repository.root_ref}, #{number_commits_ahead} commits ahead" }
.graph-side
.bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
%span.count.count-behind= number_commits_behind
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index de607772df6..18fbb81c167 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,9 +1,9 @@
.branch-commit
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-id monospace"
+ = link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha"
&middot;
%span.str-truncated
- = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message"
+ = link_to_gfm commit.title, project_commit_path(project, commit.id), class: "commit-row-message"
&middot;
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
new file mode 100644
index 00000000000..c5888afa54d
--- /dev/null
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -0,0 +1,34 @@
+#modal-delete-branch.modal{ tabindex: -1 }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %button.close{ data: { dismiss: 'modal' } } ×
+ %h3.page-title
+ Delete protected branch
+ = surround "'", "'?" do
+ %span.js-branch-name>[branch name]
+
+ .modal-body
+ %p
+ You’re about to permanently delete the protected branch
+ = succeed '.' do
+ %strong.js-branch-name [branch name]
+ %p
+ Once you confirm and press
+ = succeed ',' do
+ %strong Delete protected branch
+ it cannot be undone or recovered.
+ %p
+ %strong To confirm, type
+ %kbd.js-branch-name [branch name]
+
+ .form-group
+ = text_field_tag 'delete_branch_input', '', class: 'form-control js-delete-branch-input'
+
+ .modal-footer
+ %button.btn{ data: { dismiss: 'modal' } } Cancel
+ = link_to 'Delete protected branch', '',
+ class: "btn btn-danger js-delete-branch",
+ title: 'Delete branch',
+ method: :delete,
+ "aria-label" => "Delete"
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 91b86280e4c..945a5c11d6d 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -2,11 +2,15 @@
- page_title "Branches"
= render "projects/commits/head"
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
+
%div{ class: container_class }
.top-area.adjust
- .nav-text
- Protected branches can be managed in
- = link_to 'project settings', namespace_project_protected_branches_path(@project.namespace, @project)
+ - if can?(current_user, :admin_project, @project)
+ .nav-text
+ Protected branches can be managed in
+ = link_to 'project settings', project_protected_branches_path(@project)
.nav-controls
= form_tag(filter_branches_path, method: :get) do
@@ -25,9 +29,9 @@
= link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value)
- if can? current_user, :push_code, @project
- = link_to namespace_project_merged_branches_path(@project.namespace, @project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do
+ = link_to project_merged_branches_path(@project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do
Delete merged branches
- = link_to new_namespace_project_branch_path(@project.namespace, @project), class: 'btn btn-create' do
+ = link_to new_project_branch_path(@project), class: 'btn btn-create' do
New branch
- if @branches.any?
@@ -37,3 +41,5 @@
= paginate @branches, theme: 'gitlab'
- else
.nothing-here-block No branches to show
+
+= render 'projects/branches/delete_protected_modal'
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index d3c3e40d518..2baaaf6ac5b 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -1,4 +1,5 @@
- page_title "New Branch"
+- default_ref = params[:ref] || @project.default_branch
- if @error
.alert.alert-danger
@@ -16,18 +17,15 @@
.help-block.text-danger.js-branch-name-error
.form-group
= label_tag :ref, 'Create from', class: 'control-label'
- .col-sm-10
- = hidden_field_tag :ref, params[:ref] || @project.default_branch
- = dropdown_tag(params[:ref] || @project.default_branch,
- options: { toggle_class: 'js-branch-select wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches",
- data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } })
+ .col-sm-10.create-from
+ .dropdown
+ = hidden_field_tag :ref, default_ref
+ = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
+ .text-left.dropdown-toggle-text= default_ref
+ = icon('chevron-down')
+ = render 'shared/ref_dropdown', dropdown_class: 'wide'
.help-block Existing branch name, tag, or commit SHA
.form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_branches_path(@project.namespace, @project), class: 'btn btn-cancel'
-
-:javascript
- var availableRefs = #{@project.repository.ref_names.to_json};
-
- new NewBranchForm($('.js-create-branch-form'), availableRefs)
+ = link_to 'Cancel', project_branches_path(@project), class: 'btn btn-cancel'
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml
deleted file mode 100644
index 104db85809c..00000000000
--- a/app/views/projects/builds/_header.html.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-- pipeline = @build.pipeline
-
-.content-block.build-header.top-area
- .header-content
- = 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(pipeline) do
- %strong ##{pipeline.id}
- for commit
- = 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
- = @build.ref
- - if @build.user
- = render "user"
- = time_ago_with_tooltip(@build.created_at)
- .nav-controls
- - if can?(current_user, :create_issue, @project) && @build.failed?
- = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
- - if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
- %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml
deleted file mode 100644
index c4159ce1a36..00000000000
--- a/app/views/projects/builds/_sidebar.html.haml
+++ /dev/null
@@ -1,142 +0,0 @@
-- builds = @build.pipeline.builds.to_a
-
-%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "153", "spy" => "affix" } }
- .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default
- Job
- %strong ##{@build.id}
- %a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" }
- = icon('angle-double-right')
- - if @build.coverage
- .block.coverage
- .title
- Test coverage
- %p.build-detail-row
- #{@build.coverage}%
-
- .blocks-container
- - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
- .block{ class: ("block-first" if !@build.coverage) }
- .title
- Job artifacts
- - if @build.artifacts_expired?
- %p.build-detail-row
- The artifacts were removed
- #{time_ago_with_tooltip(@build.artifacts_expire_at)}
- - elsif @build.has_expiring_artifacts?
- %p.build-detail-row
- The artifacts will be removed in
- %span.js-artifacts-remove= @build.artifacts_expire_at
-
- - if @build.artifacts?
- .btn-group.btn-group-justified{ role: :group }
- - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
- = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
- Keep
-
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
- Download
-
- - if @build.artifacts_metadata?
- = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Browse
-
- .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
- .title
- Job details
- - if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
- - if @build.merge_request
- %p.build-detail-row
- %span.build-light-text Merge Request:
- = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request)
- - if @build.duration
- %p.build-detail-row
- %span.build-light-text Duration:
- = time_interval_in_words(@build.duration)
- - if @build.finished_at
- %p.build-detail-row
- %span.build-light-text Finished:
- #{time_ago_with_tooltip(@build.finished_at)}
- - if @build.erased_at
- %p.build-detail-row
- %span.build-light-text Erased:
- #{time_ago_with_tooltip(@build.erased_at)}
- %p.build-detail-row
- %span.build-light-text Runner:
- - if @build.runner && current_user && current_user.admin
- = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id)
- - elsif @build.runner
- \##{@build.runner.id}
- .btn-group.btn-group-justified{ role: :group }
- - if @build.has_trace?
- = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- - if @build.active?
- = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- - if can?(current_user, :update_build, @project) && @build.erasable?
- = link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
- class: "btn btn-sm btn-default", method: :post,
- data: { confirm: "Are you sure you want to erase this build?" } do
- Erase
-
- - if @build.trigger_request
- .build-widget
- %h4.title
- Trigger
-
- %p
- %span.build-light-text Token:
- #{@build.trigger_request.trigger.short_token}
-
- - if @build.trigger_request.variables
- %p
- %button.btn.group.btn-group-justified.reveal-variables Reveal Variables
-
-
- - @build.trigger_request.variables.each do |key, value|
- .hide.js-build
- .js-build-variable= key
- .js-build-value= value
-
- .block
- .title
- Commit title
- %p.build-light-text.append-bottom-0
- #{@build.pipeline.git_commit_title}
-
- - if @build.tags.any?
- .block
- .title
- Tags
- - @build.tag_list.each do |tag|
- %span.label.label-primary
- = tag
-
- - if @build.pipeline.stages_count > 1
- .dropdown.build-dropdown
- .title Stage
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.stage-selection More
- = icon('chevron-down')
- %ul.dropdown-menu
- - @build.pipeline.stages.each do |stage|
- %li
- %a.stage-item= stage.name
-
- .builds-container
- - HasStatus::ORDERED_STATUSES.each do |build_status|
- - builds.select{|build| build.status == build_status}.each do |build|
- .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } }
- = link_to namespace_project_build_path(@project.namespace, @project, build) do
- = icon('arrow-right')
- %span{ class: "ci-status-icon-#{build.status}" }
- = ci_icon_for_status(build.status)
- %span
- - if build.name
- = build.name
- - else
- = build.id
- - if build.retried?
- %i.fa.fa-spinner.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
-
-:javascript
- new Sidebar();
diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml
deleted file mode 100644
index 65162aacda1..00000000000
--- a/app/views/projects/builds/index.html.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- @no_container = true
-- page_title "Jobs"
-= render "projects/pipelines/head"
-
-%div{ class: container_class }
- .top-area
- - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) }
- = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
-
- .nav-controls
- - if can?(current_user, :update_build, @project)
- - if @all_builds.running_or_pending.any?
- = link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project),
- data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
-
- - unless @repository.gitlab_ci_yml
- = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
-
- = link_to ci_lint_path, class: 'btn btn-default' do
- %span CI lint
-
- .content-list.builds-content-list
- = render "table", builds: @builds, project: @project
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
deleted file mode 100644
index 7cb2ec83cc7..00000000000
--- a/app/views/projects/builds/show.html.haml
+++ /dev/null
@@ -1,86 +0,0 @@
-- @no_container = true
-- page_title "#{@build.name} (##{@build.id})", "Jobs"
-= render "projects/pipelines/head"
-
-%div{ class: container_class }
- .build-page
- = render "header"
-
- - if @build.stuck?
- - unless @build.any_runners_online?
- .bs-callout.bs-callout-warning
- %p
- - if no_runners_for_project?(@build.project)
- This job is stuck, because the project doesn't have any runners online assigned to it.
- - elsif @build.tags.any?
- This job is stuck, because you don't have any active runners online with any of these tags assigned to them:
- - @build.tags.each do |tag|
- %span.label.label-primary
- = tag
- - else
- This job is stuck, because you don't have any active runners that can run this job.
-
- %br
- Go to
- = link_to namespace_project_runners_path(@build.project.namespace, @build.project) do
- Runners page
-
- - if @build.starts_environment?
- .prepend-top-default
- .environment-information
- - if @build.outdated_deployment?
- = ci_icon_for_status('success_with_warnings')
- - else
- = ci_icon_for_status(@build.status)
-
- - environment = environment_for_build(@build.project, @build)
- - if @build.success? && @build.last_deployment.present?
- - if @build.last_deployment.last?
- This job is the most recent deployment to #{environment_link_for_build(@build.project, @build)}.
- - else
- This job is an out-of-date deployment to #{environment_link_for_build(@build.project, @build)}.
- View the most recent deployment #{deployment_link(environment.last_deployment)}.
- - elsif @build.complete? && !@build.success?
- The deployment of this job to #{environment_link_for_build(@build.project, @build)} did not succeed.
- - else
- This job is creating a deployment to #{environment_link_for_build(@build.project, @build)}
- - if environment.try(:last_deployment)
- and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')}
-
- .prepend-top-default
- - if @build.erased?
- .erased.alert.alert-warning
- - if @build.erased_by_user?
- Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
- - else
- Job has been erased #{time_ago_with_tooltip(@build.erased_at)}
- - else
- #js-build-scroll.scroll-controls
- .scroll-step
- %a.scroll-link.scroll-top{ href: '#up-build-trace', id: 'scroll-top', title: 'Scroll to top' }
- = custom_icon('scroll_up')
- = custom_icon('scroll_up_hover_active')
- %a.scroll-link.scroll-bottom{ href: '#down-build-trace', id: 'scroll-bottom', title: 'Scroll to bottom' }
- = custom_icon('scroll_down')
- = custom_icon('scroll_down_hover_active')
- - if @build.active?
- .autoscroll-container
- %span.status-message#autoscroll-status{ data: { state: 'disabled' } }
- %span.status-text Autoscroll active
- %i.status-icon
- = custom_icon('scroll_down_hover_active')
- #up-build-trace
- %pre.build-trace#build-trace
- .js-truncated-info.truncated-info.hidden<
- Showing last
- %span.js-truncated-info-size.truncated-info-size><
- KiB of log -
- %a.js-raw-link.raw-link{ :href => raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw
- %code.bash.js-build-output
- .build-loader-animation.js-build-refresh
-
- #down-build-trace
-
- = render "sidebar"
-
-.js-build-options{ data: javascript_build_options }
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index d90d4a27cd6..883922dbf04 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -2,29 +2,29 @@
- if !project.empty_repo? && can?(current_user, :download_code, project)
.project-action-button.dropdown.inline>
- %button.btn{ 'data-toggle' => 'dropdown' }
+ %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
= icon('download')
= icon("caret-down")
- %span.sr-only
- Select Archive Format
+ %span.sr-only= _('Select Archive Format')
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
- %li.dropdown-header Source code
+ %li.dropdown-header
+ #{ _('Source code') }
%li
- = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
+ = link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download zip
+ %span= _('Download zip')
%li
- = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.gz
+ %span= _('Download tar.gz')
%li
- = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.bz2
+ %span= _('Download tar.bz2')
%li
- = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
+ = link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar
+ %span= _('Download tar')
- if pipeline
- artifacts = pipeline.builds.latest.with_artifacts
@@ -37,6 +37,7 @@
%li.dropdown-header Previous Artifacts
- artifacts.each do |job|
%li
- = link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
+ = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download '#{job.name}'
+ %span
+ #{ s_('DownloadArtifacts|Download') } '#{job.name}'
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 67de8699b2e..b04d6a1fa5e 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -1,6 +1,6 @@
- if current_user
.project-action-button.dropdown.inline
- %a.btn.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" }
+ %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...') }
= icon('plus')
= icon("caret-down")
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
@@ -10,50 +10,50 @@
- if can_create_issue
%li
- = link_to new_namespace_project_issue_path(@project.namespace, @project) do
+ = link_to new_project_issue_path(@project) do
= icon('exclamation-circle fw')
- New issue
+ #{ _('New issue') }
- if merge_project
%li
- = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do
+ = link_to project_new_merge_request_path(merge_project) do
= icon('tasks fw')
- New merge request
+ #{ _('New merge request') }
- if can_create_snippet
%li
- = link_to new_namespace_project_snippet_path(@project.namespace, @project) do
+ = link_to new_project_snippet_path(@project) do
= icon('file-text-o fw')
- New snippet
+ #{ _('New snippet') }
- if can_create_issue || merge_project || can_create_snippet
%li.divider
- if can?(current_user, :push_code, @project)
%li
- = link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
+ = link_to project_new_blob_path(@project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
%li
- = link_to new_namespace_project_branch_path(@project.namespace, @project) do
+ = link_to new_project_branch_path(@project) do
= icon('code-fork fw')
- New branch
+ #{ _('New branch') }
%li
- = link_to new_namespace_project_tag_path(@project.namespace, @project) do
+ = link_to new_project_tag_path(@project) do
= icon('tags fw')
- New tag
+ #{ _('New tag') }
- elsif current_user && current_user.already_forked?(@project)
%li
- = link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
+ = link_to project_new_blob_path(@project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
- elsif can?(current_user, :fork_project, @project)
%li
- - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master'),
+ - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'),
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
continue: continue_params)
= link_to fork_path, method: :post do
= icon('file fw')
- New file
+ #{ _('New file') }
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 851fe44a86d..f45cc7f0f45 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,14 +1,18 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn has-tooltip' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn has-tooltip' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('GoToYourFork|Fork')
+ - elsif !current_user.can_create_project?
+ = link_to new_project_fork_path(@project), title: _('You have reached your project limit'), class: 'btn has-tooltip disabled' do
+ = custom_icon('icon_fork')
+ %span= s_('CreateNewFork|Fork')
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn' do
+ = link_to new_project_fork_path(@project), class: 'btn' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('CreateNewFork|Fork')
.count-with-arrow
%span.arrow
- = link_to namespace_project_forks_path(@project.namespace, @project), title: 'Forks', class: 'count' do
+ = link_to project_forks_path(@project), title: n_('Fork', 'Forks', @project.forks_count), class: 'count' do
= @project.forks_count
diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
index a5a9e4d0621..de2d61d4aa3 100644
--- a/app/views/projects/buttons/_koding.html.haml
+++ b/app/views/projects/buttons/_koding.html.haml
@@ -1,3 +1,3 @@
- if koding_enabled? && current_user && @repository.koding_yml && can_push_branch?(@project, @project.default_branch)
= link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do
- Run in IDE (Koding)
+ _('Run in IDE (Koding)')
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index d57eb2cbfbc..c82ae35a685 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,20 +1,20 @@
- if current_user
- = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
+ = link_to toggle_star_project_path(@project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
- if current_user.starred?(@project)
= icon('star')
- %span.starred Unstar
+ %span.starred= _('Unstar')
- else
= icon('star-o')
- %span Star
+ %span= s_('StarProject|Star')
.count-with-arrow
%span.arrow
%span.count.star-count
= @project.star_count
- else
- = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do
+ = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: _('You must sign in to star a project') do
= icon('star')
- Star
+ #{ s_('StarProject|Star') }
.count-with-arrow
%span.arrow
%span.count
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 2c3fd1fcd4d..c1842527480 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -14,7 +14,7 @@
%td.branch-commit
- if can?(current_user, :read_build, job)
- = link_to namespace_project_build_url(job.project.namespace, job.project, job) do
+ = link_to project_job_url(job.project, job) do
%span.build-link ##{job.id}
- else
%span.build-link ##{job.id}
@@ -23,20 +23,20 @@
- if job.ref
.icon-container
= 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"
+ = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
.light none
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace"
+ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha"
- if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
- if retried
- = icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried')
+ = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried')
.label-container
- if job.tags.any?
@@ -58,12 +58,12 @@
- if pipeline.user
= user_avatar(user: pipeline.user, size: 20)
- else
- %span.monospace API
+ %span.api API
- if admin
%td
- if job.project
- = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project)
+ = link_to job.project.name_with_namespace, admin_project_path(job.project)
%td
- if job.try(:runner)
= runner_link(job.runner)
@@ -95,16 +95,16 @@
%td
.pull-right
- 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
+ = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= icon('download')
- 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
+ = link_to cancel_project_job_path(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 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
+ - if job.playable? && !admin && can?(current_user, :update_build, job)
+ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= custom_icon('icon_play')
- 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
+ = link_to retry_project_job_path(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/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml
new file mode 100644
index 00000000000..83821326aec
--- /dev/null
+++ b/app/views/projects/commit/_ajax_signature.html.haml
@@ -0,0 +1,2 @@
+- if commit.has_signature?
+ %button{ class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } }
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index b5f67cae341..d0a380516f9 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -1,40 +1,37 @@
- case type.to_s
- when 'revert'
- - label = 'Revert'
- - branch_label = 'Revert in branch'
+ - label = s_('ChangeTypeAction|Revert')
+ - branch_label = s_('ChangeTypeActionLabel|Revert in branch')
+ - revert_merge_request = _('Revert this merge request')
+ - revert_commit = _('Revert this commit')
+ - title = commit.merged_merge_request(current_user) ? revert_merge_request : revert_commit
- when 'cherry-pick'
- - label = 'Cherry-pick'
- - branch_label = 'Pick into branch'
+ - label = s_('ChangeTypeAction|Cherry-pick')
+ - branch_label = s_('ChangeTypeActionLabel|Pick into branch')
+ - title = commit.merged_merge_request(current_user) ? _('Cherry-pick this merge request') : _('Cherry-pick this commit')
.modal{ id: "modal-#{type}-commit" }
.modal-dialog
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title== #{label} this #{commit.change_type_title(current_user)}
+ %h3.page-title= title
.modal-body
= form_tag [type.underscore, @project.namespace.becomes(Namespace), @project, commit], method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
.form-group.branch
= label_tag 'start_branch', branch_label, class: 'control-label'
.col-sm-10
= hidden_field_tag :start_branch, @project.default_branch, id: 'start_branch'
- = dropdown_tag(@project.default_branch, options: { title: "Switch branch", filter: true, placeholder: "Search branches", toggle_class: 'js-project-refs-dropdown js-target-branch dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false } })
+ = dropdown_tag(@project.default_branch, options: { title: s_("BranchSwitcherTitle|Switch branch"), filter: true, placeholder: s_("BranchSwitcherPlaceholder|Search branches"), toggle_class: 'js-project-refs-dropdown dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: project_branches_path(@project), submit_form_on_click: false } })
- if can?(current_user, :push_code, @project)
- .js-create-merge-request-container
- .checkbox
- = label_tag do
- = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: nil
- Start a <strong>new merge request</strong> with these changes
+ = render 'shared/new_merge_request_checkbox'
- else
= hidden_field_tag 'create_merge_request', 1, id: nil
.form-actions
= submit_tag label, class: 'btn btn-create'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- unless can?(current_user, :push_code, @project)
.inline.prepend-left-10
= commit_in_fork_help
-
-:javascript
- new NewCommitForm($('.js-#{type}-form'), 'start_branch')
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index 8aed88da38b..7338468967f 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -1,10 +1,10 @@
%ul.nav-links.no-top.no-bottom.commit-ci-menu
= nav_link(path: 'commit#show') do
- = link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do
+ = link_to project_commit_path(@project, @commit.id) do
Changes
%span.badge= @diffs.size
- if can?(current_user, :read_pipeline, @project)
= nav_link(path: 'commit#pipelines') do
- = link_to pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) do
+ = link_to pipelines_project_commit_path(@project, @commit.id) do
Pipelines
- %span.badge= @commit.pipelines.size
+ %span.badge.js-pipelines-mr-count= @commit.pipelines.size
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index f604d6e5fbb..09bcd187e59 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,15 +1,18 @@
-.page-content-header
+.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
.header-main-content
- %strong Commit #{@commit.short_id}
- = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
+ = render partial: 'signature', object: @commit.signature
+ %strong
+ #{ s_('CommitBoxTitle|Commit') }
+ %span.commit-sha= @commit.short_id
+ = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard"))
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}
- %span by
+ %span= s_('ByAuthor|by')
= author_avatar(@commit, size: 24)
%strong
= commit_author_link(@commit, avatar: true, size: 24)
- if @commit.different_committer?
- %span.light Committed by
+ %span.light= _('Committed by')
%strong
= commit_committer_link(@commit, avatar: true, size: 24)
#{time_ago_with_tooltip(@commit.committed_date)}
@@ -19,30 +22,30 @@
%span.btn.disabled.btn-grouped.hidden-xs.append-right-10
= icon('comment')
= @notes_count
- = link_to namespace_project_tree_path(@project.namespace, @project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do
- Browse files
+ = link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do
+ #{ _('Browse files') }
.dropdown.inline
%a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } }
- %span Options
+ %span= _('Options')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li.visible-xs-block.visible-sm-block
- = link_to namespace_project_tree_path(@project.namespace, @project, @commit) do
- Browse Files
+ = link_to project_tree_path(@project, @commit) do
+ #{ _('Browse Files') }
- unless @commit.has_been_reverted?(current_user)
%li.clearfix
- = revert_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false)
+ = revert_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
%li.clearfix
- = cherry_pick_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false)
+ = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
- if can_collaborate_with_project?
%li.clearfix
- = link_to "Tag", new_namespace_project_tag_path(@project.namespace, @project, ref: @commit)
+ = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit)
%li.divider
%li.dropdown-header
- Download
+ #{ _('Download') }
- unless @commit.parents.length > 1
- %li= link_to "Email Patches", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch)
- %li= link_to "Plain Diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff)
+ %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch)
+ %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff)
.commit-box
%h3.commit-title
@@ -55,25 +58,24 @@
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
- %span.cgray= pluralize(@commit.parents.count, "parent")
+ %span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
- = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "monospace"
+ = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
%span.commit-info.branches
%i.fa.fa-spinner.fa-spin
- - if @commit.status
+ - if @commit.last_pipeline
+ - last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
.status-icon-container{ class: "ci-status-icon-#{@commit.status}" }
- = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do
- = ci_icon_for_status(@commit.status)
- Pipeline
- = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace"
- = ci_label_for_status(@commit.status)
- - if @commit.latest_pipeline.stages.any?
+ = link_to project_pipeline_path(@project, last_pipeline.id) do
+ = ci_icon_for_status(last_pipeline.status)
+ #{ _('Pipeline') }
+ = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
+ = ci_label_for_status(last_pipeline.status)
+ - if last_pipeline.stages_count.nonzero?
+ #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) }
.mr-widget-pipeline-graph
- = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph'
+ = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
in
- = time_interval_in_words @commit.pipelines.total_duration
-
-:javascript
- $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}");
+ = time_interval_in_words last_pipeline.duration
diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml
new file mode 100644
index 00000000000..3a73aae9d95
--- /dev/null
+++ b/app/views/projects/commit/_invalid_signature_badge.html.haml
@@ -0,0 +1,9 @@
+- title = capture do
+ .gpg-popover-icon.invalid
+ = render 'shared/icons/icon_status_notfound_borderless.svg'
+ %div
+ This commit was signed with an <strong>unverified</strong> signature.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
deleted file mode 100644
index 3ee85723ebe..00000000000
--- a/app/views/projects/commit/_pipeline.html.haml
+++ /dev/null
@@ -1,52 +0,0 @@
-.pipeline-graph-container
- .row-content-block.build-content.middle-block.pipeline-actions
- .pull-right
- - if can?(current_user, :update_pipeline, pipeline.project)
- - if pipeline.builds.latest.failed.any?(&:retryable?)
- = link_to "Retry", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'js-retry-button btn btn-grouped btn-primary', method: :post
-
- - if pipeline.builds.running_or_pending.any?
- = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post
-
- .oneline.clearfix
- - if defined?(pipeline_details) && pipeline_details
- Pipeline
- = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace"
- with
- = pluralize pipeline.statuses.count(:id), "job"
- - if pipeline.ref
- for
- = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace"
- - if defined?(link_to_commit) && link_to_commit
- for commit
- = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace"
- - if pipeline.duration
- in
- = time_interval_in_words pipeline.duration
-
- .row-content-block.build-content.middle-block.js-pipeline-graph.hidden
- = render "projects/pipelines/graph", pipeline: pipeline
-
-- if pipeline.yaml_errors.present?
- .bs-callout.bs-callout-danger
- %h4 Found errors in your .gitlab-ci.yml:
- %ul
- - pipeline.yaml_errors.split(",").each do |error|
- %li= error
- You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path}
-
-- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
- .bs-callout.bs-callout-warning
- \.gitlab-ci.yml not found in this commit
-
-.table-holder.pipeline-holder
- %table.table.ci-table.pipeline
- %thead
- %tr
- %th Status
- %th Job ID
- %th Name
- %th
- %th Coverage
- %th
- = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml
new file mode 100644
index 00000000000..60fa52557ef
--- /dev/null
+++ b/app/views/projects/commit/_signature.html.haml
@@ -0,0 +1,5 @@
+- if signature
+ - if signature.valid_signature?
+ = render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature }
+ - else
+ = render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature }
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
new file mode 100644
index 00000000000..66f00eb5507
--- /dev/null
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -0,0 +1,18 @@
+- css_classes = commit_signature_badge_classes(css_classes)
+
+- title = capture do
+ .gpg-popover-status
+ = title
+
+- content = capture do
+ .clearfix
+ = content
+
+ GPG Key ID:
+ %span.monospace= signature.gpg_key_primary_keyid
+
+
+ = link_to('Learn more about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
+
+%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } }
+ = label
diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml
new file mode 100644
index 00000000000..db1a41bbf64
--- /dev/null
+++ b/app/views/projects/commit/_valid_signature_badge.html.haml
@@ -0,0 +1,32 @@
+- title = capture do
+ .gpg-popover-icon.valid
+ = render 'shared/icons/icon_status_success_borderless.svg'
+ %div
+ This commit was signed with a <strong>verified</strong> signature.
+
+- content = capture do
+ - gpg_key = signature.gpg_key
+ - user = gpg_key&.user
+ - user_name = signature.gpg_key_user_name
+ - user_email = signature.gpg_key_user_email
+
+ - if user
+ = link_to user_path(user), class: 'gpg-popover-user-link' do
+ %div
+ = user_avatar_without_link(user: user, size: 32)
+
+ %div
+ %strong= gpg_key.user.name
+ %div @#{gpg_key.user.username}
+ - else
+ = mail_to user_email do
+ %div
+ = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
+
+ %div
+ %strong= user_name
+ %div= user_email
+
+- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml
index 2b0c9a4b4de..911c9ddce06 100644
--- a/app/views/projects/commit/branches.html.haml
+++ b/app/views/projects/commit/branches.html.haml
@@ -1,15 +1,15 @@
-- if @branches.any?
- %span
- - branch = commit_default_branch(@project, @branches)
- = link_to(namespace_project_tree_path(@project.namespace, @project, branch)) do
- %span.label.label-gray
- = branch
- - if @branches.any? || @tags.any?
- = link_to("#", class: "js-details-expand") do
- %span.label.label-gray
- \...
+- if @branches.any? || @tags.any?
+ - branch = commit_default_branch(@project, @branches)
+ = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do
+ = icon('code-fork')
+ = branch
+
+ -# `commit_default_branch` deletes the default branch from `@branches`,
+ -# so only render this if we have more branches left
+ - if @branches.any? || @tags.any?
+ %span
+ = link_to "…", "#", class: "js-details-expand label label-gray"
+
%span.js-details-content.hide
- - if @branches.any?
- = commit_branches_links(@project, @branches)
- - if @tags.any?
- = commit_tags_links(@project, @tags)
+ = commit_branches_links(@project, @branches) if @branches.any?
+ = commit_tags_links(@project, @tags) if @tags.any?
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index ac93eac41ac..c66ea873dba 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -2,4 +2,4 @@
= render 'commit_box'
= render 'ci_menu'
-= render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id)
+= render 'projects/commit/pipelines_list', endpoint: pipelines_project_commit_path(@project, @commit.id)
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 0d11da2451a..07c83c0a590 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -1,9 +1,12 @@
- @no_container = true
+- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
+- limited_container_width = fluid_layout ? '' : 'limit-container-width'
+- @content_class = limited_container_width
- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
- page_description @commit.description
= render "projects/commits/head"
-%div{ class: container_class }
+.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
- if @commit.status
= render "ci_menu"
@@ -11,7 +14,8 @@
.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|
- = render "projects/commit/change", type: type, commit: @commit, title: @commit.title
+ .limited-width-notes
+ = render "shared/notes/notes_with_form", :autocomplete => true
+ - if can_collaborate_with_project?
+ - %w(revert cherry-pick).each do |type|
+ = render "projects/commit/change", type: type, commit: @commit, title: @commit.title
diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder
index 1657fb46163..d806acdda13 100644
--- a/app/views/projects/commits/_commit.atom.builder
+++ b/app/views/projects/commits/_commit.atom.builder
@@ -1,6 +1,6 @@
xml.entry do
- xml.id namespace_project_commit_url(@project.namespace, @project, id: commit.id)
- xml.link href: namespace_project_commit_url(@project.namespace, @project, id: commit.id)
+ xml.id project_commit_url(@project, id: commit.id)
+ xml.link href: project_commit_url(@project, id: commit.id)
xml.title truncate(commit.title, length: 80)
xml.updated commit.committed_date.xmlschema
xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(commit.author_email))
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 8f32d2b72e5..e7da47032be 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -5,18 +5,18 @@
- notes = commit.notes
- note_count = notes.user.count
-- cache_key = [project.path_with_namespace, commit.id, current_application_settings, note_count]
+- cache_key = [project.full_path, commit.id, current_application_settings, note_count, @path.presence, current_controller?(:commits)]
- cache_key.push(commit.status(ref)) if commit.status(ref)
= cache(cache_key, expires_in: 1.day) do
- %li.commit.flex-list.js-toggle-container{ id: "commit-#{commit.short_id}" }
+ %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
.avatar-cell.hidden-xs
= author_avatar(commit, size: 36)
.commit-detail
.commit-content
- = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message item-title"
+ = link_to_gfm commit.title, project_commit_path(project, commit.id), class: "commit-row-message item-title"
%span.commit-row-message.visible-xs-inline
&middot;
= commit.short_id
@@ -30,13 +30,21 @@
%pre.commit-row-description.js-toggle-content
= preserve(markdown(commit.description, pipeline: :single_line, author: commit.author))
.commiter
- = commit_author_link(commit, avatar: false, size: 24)
- committed
- #{time_ago_with_tooltip(commit.committed_date)}
+ - commit_author_link = commit_author_link(commit, avatar: false, size: 24)
+ - commit_timeago = time_ago_with_tooltip(commit.committed_date)
+ - commit_text = _('%{commit_author_link} committed %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
+ #{ commit_text.html_safe }
- .commit-actions.flex-row.hidden-xs
+
+ .commit-actions.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
+
+ - if request.xhr?
+ = render partial: 'projects/commit/signature', object: commit.signature
+ - else
+ = render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
+
+ = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha btn btn-transparent"
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"))
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 88c7d7bc44b..d14897428d0 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -2,11 +2,14 @@
- commits, hidden = limited_commits(@commits)
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits|
- %li.commit-header #{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')}
- %li.commits-row
- %ul.content-list.commit-list
- = render commits, project: project, ref: ref
+ %li.commit-header.js-commit-header{ data: { day: day } }
+ %span.day= l(day, format: '%d %b, %Y')
+ %span.commits-count= n_("%d commit", "%d commits", commits.count) % commits.count
+
+ %li.commits-row{ data: { day: day } }
+ %ul.content-list.commit-list.flex-list
+ = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref }
- if hidden > 0
%li.alert.alert-warning
- #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues.
+ = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
index dd6797f10c0..e1549baef89 100644
--- a/app/views/projects/commits/_head.html.haml
+++ b/app/views/projects/commits/_head.html.haml
@@ -4,33 +4,33 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
- = link_to project_files_path(@project) do
- Files
+ = link_to project_tree_path(@project) do
+ #{ _('Files') }
= nav_link(controller: [:commit, :commits]) do
- = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- Commits
+ = link_to project_commits_path(@project, current_ref) do
+ #{ _('Commits') }
= nav_link(html_options: {class: branches_tab_class}) do
- = link_to namespace_project_branches_path(@project.namespace, @project) do
- Branches
+ = link_to project_branches_path(@project) do
+ #{ _('Branches') }
= nav_link(controller: [:tags, :releases]) do
- = link_to namespace_project_tags_path(@project.namespace, @project) do
- Tags
+ = link_to project_tags_path(@project) do
+ #{ _('Tags') }
= nav_link(path: 'graphs#show') do
- = link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Contributors
+ = link_to project_graph_path(@project, current_ref) do
+ #{ _('Contributors') }
= nav_link(controller: %w(network)) do
- = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do
- Graph
+ = link_to project_network_path(@project, current_ref) do
+ #{ s_('ProjectNetworkGraph|Graph') }
= nav_link(controller: :compare) do
- = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do
- Compare
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do
+ #{ _('Compare') }
= nav_link(path: 'graphs#charts') do
- = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Charts
+ = link_to charts_project_graph_path(@project, current_ref) do
+ #{ _('Charts') }
diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml
index c03bc3f9df9..48cefbe45f2 100644
--- a/app/views/projects/commits/_inline_commit.html.haml
+++ b/app/views/projects/commits/_inline_commit.html.haml
@@ -1,8 +1,8 @@
%li.commit.inline-commit
.commit-row-title
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id"
+ = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha"
&nbsp;
%span.str-truncated
- = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message"
+ = link_to_gfm commit.title, project_commit_path(project, commit.id), class: "commit-row-message"
.pull-right
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/commits/show.atom.builder b/app/views/projects/commits/show.atom.builder
index 2f0b6e39800..a9b77631474 100644
--- a/app/views/projects/commits/show.atom.builder
+++ b/app/views/projects/commits/show.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@project.name}:#{@ref} commits"
- xml.link href: namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), rel: "self", type: "application/atom+xml"
- xml.link href: namespace_project_commits_url(@project.namespace, @project, @ref), rel: "alternate", type: "text/html"
- xml.id namespace_project_commits_url(@project.namespace, @project, @ref)
- xml.updated @commits.first.committed_date.xmlschema if @commits.any?
+xml.title "#{@project.name}:#{@ref} commits"
+xml.link href: project_commits_url(@project, @ref, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: project_commits_url(@project, @ref), rel: "alternate", type: "text/html"
+xml.id project_commits_url(@project, @ref)
+xml.updated @commits.first.committed_date.xmlschema if @commits.any?
- xml << render(@commits) if @commits.any?
-end
+xml << render(@commits) if @commits.any?
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index c1c2fb3d299..7ae56086177 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -1,39 +1,42 @@
- @no_container = true
+- breadcrumb_title _("Commits")
-- page_title "Commits", @ref
+- page_title _("Commits"), @ref
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
+ = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
= content_for :sub_nav do
= render "head"
-%div{ class: container_class }
- .row-content-block.second-block.content-component-block.flex-container-block
- .tree-ref-holder
- = render 'shared/ref_switcher', destination: 'commits'
-
- %ul.breadcrumb.repo-breadcrumb
- = commits_breadcrumbs
-
- .block-controls.hidden-xs.hidden-sm
- - if @merge_request.present?
- .control
- = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn'
- - elsif create_mr_button?(@repository.root_ref, @ref)
- .control
- = link_to "Create merge request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
-
- .control
- = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do
- = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
- .control
- = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: "Commits feed", class: 'btn' do
- = icon("rss")
-
- %div{ id: dom_id(@project) }
- %ol#commits-list.list-unstyled.content_list
- = render 'commits', project: @project, ref: @ref
- = spinner
-
-:javascript
- CommitsList.init(#{@limit});
+.js-project-commits-show{ 'data-commits-limit' => @limit }
+ %div{ class: container_class }
+ .tree-holder
+ .nav-block
+ .tree-ref-container
+ .tree-ref-holder
+ = render 'shared/ref_switcher', destination: 'commits'
+
+ %ul.breadcrumb.repo-breadcrumb
+ = commits_breadcrumbs
+ .tree-controls.hidden-xs.hidden-sm
+ - if @merge_request.present?
+ .control
+ = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
+ - elsif create_mr_button?(@repository.root_ref, @ref)
+ .control
+ = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
+
+ .control
+ = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form', data: { 'signatures-path' => namespace_project_signatures_path }) do
+ = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
+ .control
+ = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
+ = icon("rss")
+
+ %div{ id: dom_id(@project) }
+ %ol#commits-list.list-unstyled.content_list
+ = render 'commits', project: @project, ref: @ref
+ = spinner
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index 0f080b6acee..94b7db5eb25 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,4 +1,4 @@
-= form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do
+= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input' do
.clearfix
- if params[:to] && params[:from]
.compare-switch-container
@@ -7,20 +7,20 @@
.input-group.inline-input-group
%span.input-group-addon from
= hidden_field_tag :from, params[:from]
- = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
+ = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
.dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag'
- = render "ref_dropdown"
+ = render 'shared/ref_dropdown'
.compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group
%span.input-group-addon to
= hidden_field_tag :to, params[:to]
- = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
+ = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
.dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag'
- = render "ref_dropdown"
+ = render 'shared/ref_dropdown'
&nbsp;
= button_tag "Compare", class: "btn btn-create commits-compare-btn"
- if @merge_request.present?
- = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn'
+ = link_to "View open merge request", project_merge_request_path(@project, @merge_request), class: 'prepend-left-10 btn'
- elsif create_mr_button?
= link_to "Create merge request", create_mr_path, class: 'prepend-left-10 btn'
diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/projects/compare/_ref_dropdown.html.haml
deleted file mode 100644
index 05fb37cdc0f..00000000000
--- a/app/views/projects/compare/_ref_dropdown.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.dropdown-menu.dropdown-menu-selectable
- = dropdown_title "Select Git revision"
- = dropdown_filter "Filter by Git revision"
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 45be6581cfc..05de21e8dbf 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,15 +1,17 @@
- @no_container = true
- page_title "Compare"
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
= render "projects/commits/head"
%div{ class: container_class }
.sub-header-block
Compare Git revisions.
%br
- Fill input field with commit id like
- %code.label-branch 4eedf23
+ Fill input field with commit SHA like
+ %code.ref-name 4eedf23
or branch/tag name like
- %code.label-branch master
+ %code.ref-name master
and press compare button for the commits list and a code diff.
%br
Changes are shown <b>from</b> the version in the first field <b>to</b> the version in the second field.
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 0dfc9fe20ed..8bc863f77b3 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,5 +1,8 @@
- @no_container = true
+- breadcrumb_title "Compare"
- page_title "#{params[:from]}...#{params[:to]}"
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
= render "projects/commits/head"
%div{ class: container_class }
@@ -16,9 +19,9 @@
There isn't anything to compare.
%p.slead
- if params[:to] == params[:from]
- %span.label-branch= params[:from]
+ %span.ref-name= params[:from]
and
- %span.label-branch= params[:to]
+ %span.ref-name= params[:to]
are the same.
- else
You'll need to use different branch names to get a valid comparison.
diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml
index c3f95860e92..cdad0bc7231 100644
--- a/app/views/projects/cycle_analytics/_empty_stage.html.haml
+++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml
@@ -2,6 +2,6 @@
.empty-stage
.icon-no-data
= custom_icon ('icon_no_data')
- %h4 We don't have enough data to show this stage.
+ %h4 {{ __('We don\'t have enough data to show this stage.') }}
%p
{{currentStage.emptyStageText}}
diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml
index 0ffc79b3181..c3eda398234 100644
--- a/app/views/projects/cycle_analytics/_no_access.html.haml
+++ b/app/views/projects/cycle_analytics/_no_access.html.haml
@@ -2,6 +2,6 @@
.no-access-stage
.icon-lock
= custom_icon ('icon_lock')
- %h4 You need permission.
+ %h4 {{ __('You need permission.') }}
%p
- Want to see the data? Please ask administrator for access.
+ {{ __('Want to see the data? Please ask an administrator for access.') }}
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index dd3fa814716..3467e357c49 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,5 +1,7 @@
- @no_container = true
- page_title "Cycle Analytics"
+- if show_new_nav?
+ - add_to_breadcrumbs("Project", project_path(@project))
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('cycle_analytics')
@@ -8,23 +10,23 @@
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
- .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" }
- = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()")
- .row
- .col-sm-3.col-xs-12.svg-container
- = custom_icon('icon_cycle_analytics_splash')
- .col-sm-8.col-xs-12.inner-content
- %h4
- Introducing Cycle Analytics
- %p
- Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
-
- = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
+ .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" }
+ %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box', "@click" => "dismissOverviewDialog()" }
+ = icon("times")
+ .svg-container
+ = custom_icon('icon_cycle_analytics_splash')
+ .inner-content
+ %h4
+ {{ __('Introducing Cycle Analytics') }}
+ %p
+ {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }}
+ %p
+ = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
= icon("spinner spin", "v-show" => "isLoading")
.wrapper{ "v-show" => "!isLoading && !hasError" }
.panel.panel-default
.panel-heading
- Pipeline Health
+ {{ __('Pipeline Health') }}
.content-block
.container-fluid
.row
@@ -34,15 +36,18 @@
.col-sm-3.col-xs-12.column
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
- %span.dropdown-label Last 30 days
+ %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
+ %a{ "href" => "#", "data-value" => "7" }
+ {{ n__('Last %d day', 'Last %d days', 7) }}
+ %li
%a{ "href" => "#", "data-value" => "30" }
- Last 30 days
+ {{ n__('Last %d day', 'Last %d days', 30) }}
%li
%a{ "href" => "#", "data-value" => "90" }
- Last 90 days
+ {{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.panel.panel-default.stage-panel
.panel-heading
@@ -50,20 +55,20 @@
%ul
%li.stage-header
%span.stage-name
- Stage
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" }
+ {{ s__('ProjectLifecycle|Stage') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
%li.median-header
%span.stage-name
- Median
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" }
+ {{ __('Median') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" }
%li.event-header
%span.stage-name
- {{ currentStage ? currentStage.legend : 'Related Issues' }}
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" }
+ {{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
%li.total-time-header
%span.stage-name
- Total Time
- %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" }
+ {{ __('Total Time') }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
.stage-panel-body
%nav.stage-nav
%ul
@@ -75,10 +80,10 @@
%span{ "v-if" => "stage.value" }
{{ stage.value }}
%span.stage-empty{ "v-else" => true }
- Not enough data
+ {{ __('Not enough data') }}
%template{ "v-else" => true }
%span.not-available
- Not available
+ {{ __('Not available') }}
.section.stage-events
%template{ "v-if" => "isLoadingStage" }
= icon("spinner spin")
diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml
deleted file mode 100644
index ec8fc4c9ee8..00000000000
--- a/app/views/projects/deploy_keys/_deploy_key.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-%li
- .pull-left.append-right-10.hidden-xs
- = icon "key", class: "key-icon"
- .deploy-key-content.key-list-item-info
- %strong.title
- = deploy_key.title
- .description
- = deploy_key.fingerprint
- - if deploy_key.can_push?
- .write-access-allowed
- Write access allowed
- .deploy-key-content.prepend-left-default.deploy-key-projects
- - deploy_key.projects.each do |project|
- - if can?(current_user, :read_project, project)
- = link_to namespace_project_path(project.namespace, project), class: "label deploy-project-label" do
- = project.name_with_namespace
- .deploy-key-content
- %span.key-created-at
- created #{time_ago_with_tooltip(deploy_key.created_at)}
- .visible-xs-block.visible-sm-block
- - if @deploy_keys.key_available?(deploy_key)
- = link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do
- Enable
- - else
- - if deploy_key.destroyed_when_orphaned? && deploy_key.almost_orphaned?
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: "You are going to remove deploy key. Are you sure?" }, method: :put, class: "btn btn-warning btn-sm prepend-left-10" do
- Remove
- - else
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-warning btn-sm prepend-left-10", method: :put do
- Disable
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml
index 1421da72418..edaa3a1119e 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/projects/deploy_keys/_form.html.haml
@@ -2,7 +2,7 @@
= form_errors(@deploy_keys.new_key)
.form-group
= f.label :title, class: "label-light"
- = f.text_field :title, class: 'form-control', autofocus: true, required: true
+ = f.text_field :title, class: 'form-control', required: true
.form-group
= f.label :key, class: "label-light"
= f.text_area :key, class: "form-control", rows: 5, required: true
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 4cfbd9add00..45985a5ecef 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,34 +1,15 @@
-.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
+- expanded = Rails.env.test?
+%section.settings
+ .settings-header
+ %h4
Deploy Keys
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
%p
Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
- .col-lg-9
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
%h5.prepend-top-0
Create a new deploy key for this project
= render @deploy_keys.form_partial_path
- .col-lg-9.col-lg-offset-3
%hr
- .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys
- %h5.prepend-top-0
- Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size})
- - if @deploy_keys.any_keys_enabled?
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key
- - else
- .settings-message.text-center
- No deploy keys found. Create one with the form above.
- %h5.prepend-top-default
- Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size})
- - if @deploy_keys.any_available_project_keys_enabled?
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key
- - else
- .settings-message.text-center
- No deploy keys from your projects could be found. Create one with the form above or add existing one below.
- - if @deploy_keys.any_available_public_keys_enabled?
- %h5.prepend-top-default
- Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size})
- %ul.well-list
- = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key
+ #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project) } }
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..cd910b82b57
--- /dev/null
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit Deploy Key
+%hr
+
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @deploy_key], html: { class: 'form-horizontal js-requires-input' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', project_settings_repository_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/deploy_keys/new.html.haml b/app/views/projects/deploy_keys/new.html.haml
deleted file mode 100644
index 01fab3008a7..00000000000
--- a/app/views/projects/deploy_keys/new.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- page_title "New Deploy Key"
-%h3.page-title New Deploy Key
-%hr
-
-= render 'form'
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index 506246f2ee6..e2baaa625ae 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -8,6 +8,7 @@
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
- actions.each do |action|
+ - next unless can?(current_user, :update_build, action)
%li
= link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do
= custom_icon('icon_play')
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 170d786ecbf..4c22166c256 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,16 +1,17 @@
-.branch-commit
- - if deployment.ref
- .icon-container
- = deployment.tag? ? icon('tag') : icon('code-fork')
- = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name"
- .icon-container.commit-icon
- = custom_icon("icon_commit")
- = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace"
+.table-mobile-content
+ .branch-commit
+ - if deployment.ref
+ %span.icon-container
+ = deployment.tag? ? icon('tag') : icon('code-fork')
+ = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
+ .icon-container.commit-icon
+ = custom_icon("icon_commit")
+ = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha"
- %p.commit-title
- %span
- - if commit_title = deployment.commit_title
- = author_avatar(deployment.commit, size: 20)
- = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
- - else
- Cant find HEAD commit for this branch
+ %p.commit-title.flex-truncate-parent
+ %span.flex-truncate-child
+ - if commit_title = deployment.commit_title
+ = author_avatar(deployment.commit, size: 20)
+ = link_to_gfm commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message"
+ - else
+ Cant find HEAD commit for this branch
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 260c9023daf..520696b01c6 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -1,22 +1,30 @@
-%tr.deployment
- %td
- %strong ##{deployment.iid}
+.gl-responsive-table-row.deployment{ role: 'row' }
+ .table-section.section-10{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } ID
+ %strong.table-mobile-content ##{deployment.iid}
- %td
+ .table-section.section-30{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Commit
= render 'projects/deployments/commit', deployment: deployment
- %td.build-column
+ .table-section.section-25.build-column{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Job
- if deployment.deployable
- = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
- #{deployment.deployable.name} (##{deployment.deployable.id})
- - if deployment.user
- by
- = user_avatar(user: deployment.user, size: 20)
+ .table-mobile-content
+ .flex-truncate-parent
+ .flex-truncate-child
+ = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
+ #{deployment.deployable.name} (##{deployment.deployable.id})
+ - if deployment.user
+ %div
+ by
+ = user_avatar(user: deployment.user, size: 20)
- %td
- #{time_ago_with_tooltip(deployment.created_at)}
+ .table-section.section-15{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Created
+ %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at)
- %td.hidden-xs
- .pull-right.btn-group
+ .table-section.section-20.table-button-footer{ role: 'gridcell' }
+ .btn-group.table-action-buttons
= render 'projects/deployments/actions', deployment: deployment
= render 'projects/deployments/rollback', deployment: deployment
diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/_collapsed.html.haml
new file mode 100644
index 00000000000..8772bd4705f
--- /dev/null
+++ b/app/views/projects/diffs/_collapsed.html.haml
@@ -0,0 +1,5 @@
+- diff_file = viewer.diff_file
+- url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
+.nothing-here-block.diff-collapsed{ data: { diff_for_path: url } }
+ This diff is collapsed.
+ %a.click-to-expand Click to expand it.
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 438a98c3e95..68f74f702ea 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,34 +1,2 @@
-.diff-content.diff-wrap-lines
- -# Skip all non non-supported blobs
- - return unless blob.respond_to?(:text?)
- - if diff_file.too_large?
- .nothing-here-block This diff could not be displayed because it is too large.
- - elsif blob.only_display_raw?
- .nothing-here-block The file could not be displayed because it is too large.
- - elsif blob_text_viewable?(blob)
- - if !project.repository.diffable?(blob)
- .nothing-here-block This diff was suppressed by a .gitattributes entry.
- - elsif diff_file.collapsed?
- - url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
- .nothing-here-block.diff-collapsed{ data: { diff_for_path: url } }
- This diff is collapsed.
- %a.click-to-expand
- Click to expand it.
- - elsif diff_file.diff_lines.length > 0
- - total_lines = 0
- - if blob.lines.any?
- - total_lines = blob.lines.last.chomp == '' ? blob.lines.size - 1 : blob.lines.size
- - if diff_view == :parallel
- = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines
- - else
- = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines
- - else
- - if diff_file.mode_changed?
- .nothing-here-block File mode changed
- - elsif diff_file.renamed_file
- .nothing-here-block File moved
- - elsif blob.image?
- - old_blob = diff_file.old_blob(diff_file.old_content_commit || @base_commit)
- = render "projects/diffs/image", diff_file: diff_file, old_file: old_blob, file: blob
- - else
- .nothing-here-block No preview for this file type
+.diff-content
+ = render 'projects/diffs/viewer', viewer: diff_file.rich_viewer || diff_file.simple_viewer
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 4b49bed835f..376f672f424 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,33 +2,27 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_files = diffs.diff_files
+- merge_request = local_assigns.fetch(:merge_request, false)
-.content-block.oneline-block.files-changed
- .inline-parallel-buttons
- - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? }
- = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default'
- - if show_whitespace_toggle
- - if current_controller?(:commit)
- = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs')
- - elsif current_controller?(:merge_requests)
- = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs')
- - elsif current_controller?(:compare)
- = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs')
- .btn-group
- = inline_diff_btn
- = parallel_diff_btn
- = render 'projects/diffs/stats', diff_files: diff_files
+.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed{ class: ("diff-files-changed-merge-request" if merge_request) }
+ .files-changed-inner
+ .inline-parallel-buttons.hidden-xs.hidden-sm
+ - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
+ = link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
+ - if show_whitespace_toggle
+ - if current_controller?(:commit)
+ = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs')
+ - elsif current_controller?('projects/merge_requests/diffs')
+ = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs')
+ - elsif current_controller?(:compare)
+ = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs')
+ .btn-group
+ = inline_diff_btn
+ = parallel_diff_btn
+ = render 'projects/diffs/stats', diff_files: diff_files
- if render_overflow_warning?(diff_files)
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- - diff_files.each_with_index do |diff_file|
- - diff_commit = commit_for_diff(diff_file)
- - blob = diff_file.blob(diff_commit)
- - next unless blob
- - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw?
- - file_hash = hexdigest(diff_file.file_path)
-
- = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project,
- diff_file: diff_file, diff_commit: diff_commit, blob: blob, environment: environment
+ = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 4622b980754..b5aea217384 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,12 +1,14 @@
- environment = local_assigns.fetch(:environment, nil)
-.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id) }
+- file_hash = hexdigest(diff_file.file_path)
+.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) }
.js-file-title.file-title-flex-parent
.file-header-content
- = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}"
+ = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}"
- unless diff_file.submodule?
+ - blob = diff_file.blob
.file-actions.hidden-xs
- - if blob_text_viewable?(blob)
+ - if blob.readable_text?
= link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do
= icon('comment')
\
@@ -15,9 +17,9 @@
= edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
blob: blob, link_opts: link_opts)
- = view_file_button(diff_commit.id, diff_file.new_path, project)
- = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment
+ = view_file_button(diff_file.content_sha, diff_file.file_path, project)
+ = view_on_environment_button(diff_file.content_sha, diff_file.file_path, environment) if environment
= render 'projects/fork_suggestion'
- = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project
+ = render 'projects/diffs/content', diff_file: diff_file
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 7d6b3701f95..73c316472e3 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -1,17 +1,22 @@
-%i.fa.diff-toggle-caret.fa-fw
-- if defined?(blob) && blob && diff_file.submodule?
+- show_toggle = local_assigns.fetch(:show_toggle, true)
+
+- if show_toggle
+ %i.fa.diff-toggle-caret.fa-fw
+
+- if diff_file.submodule?
+ - blob = diff_file.blob
%span
= icon('archive fw')
%strong.file-title-name
- = submodule_link(blob, diff_commit.id, project.repository)
+ = submodule_link(blob, diff_file.content_sha, diff_file.repository)
= copy_file_path_button(blob.path)
- else
= conditional_link_to url.present?, url do
= blob_icon diff_file.b_mode, diff_file.file_path
- - if diff_file.renamed_file
+ - if diff_file.renamed_file?
- old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
%strong.file-title-name.has-tooltip{ data: { title: diff_file.old_path, container: 'body' } }
= old_path
@@ -19,12 +24,13 @@
%strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } }
= new_path
- else
- %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } }
- = diff_file.new_path
- - if diff_file.deleted_file
+ %strong.file-title-name.has-tooltip{ data: { title: diff_file.file_path, container: 'body' } }
+ = diff_file.file_path
+
+ - if diff_file.deleted_file?
deleted
- = copy_file_path_button(diff_file.new_path)
+ = copy_file_path_button(diff_file.file_path)
- if diff_file.mode_changed?
%small
diff --git a/app/views/projects/diffs/_image.html.haml b/app/views/projects/diffs/_image.html.haml
deleted file mode 100644
index ca10921c5e2..00000000000
--- a/app/views/projects/diffs/_image.html.haml
+++ /dev/null
@@ -1,69 +0,0 @@
-- diff = diff_file.diff
-- file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path))
-// diff_refs will be nil for orphaned commits (e.g. first commit in repo)
-- if diff_file.old_ref
- - old_file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path))
-
-- if diff.renamed_file || diff.new_file || diff.deleted_file
- .image
- %span.wrap
- .frame{ class: image_diff_class(diff) }
- %img{ src: diff.deleted_file ? old_file_raw_path : file_raw_path, alt: diff.new_path }
- %p.image-info= number_to_human_size(file.size)
-- else
- .image
- .two-up.view
- %span.wrap
- .frame.deleted
- %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) }
- %img{ src: old_file_raw_path, alt: diff.old_path }
- %p.image-info.hide
- %span.meta-filesize= number_to_human_size(old_file.size)
- |
- %b W:
- %span.meta-width
- |
- %b H:
- %span.meta-height
- %span.wrap
- .frame.added
- %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) }
- %img{ src: file_raw_path, alt: diff.new_path }
- %p.image-info.hide
- %span.meta-filesize= number_to_human_size(file.size)
- |
- %b W:
- %span.meta-width
- |
- %b H:
- %span.meta-height
-
- .swipe.view.hide
- .swipe-frame
- .frame.deleted
- %img{ src: old_file_raw_path, alt: diff.old_path }
- .swipe-wrap
- .frame.added
- %img{ src: file_raw_path, alt: diff.new_path }
- %span.swipe-bar
- %span.top-handle
- %span.bottom-handle
-
- .onion-skin.view.hide
- .onion-skin-frame
- .frame.deleted
- %img{ src: old_file_raw_path, alt: diff.old_path }
- .frame.added
- %img{ src: file_raw_path, alt: diff.new_path }
- .controls
- .transparent
- .drag-track
- .dragger{ :style => "left: 0px;" }
- .opaque
-
-
- .view-modes.hide
- %ul.view-modes-menu
- %li.two-up{ data: { mode: 'two-up' } } 2-up
- %li.swipe{ data: { mode: 'swipe' } } Swipe
- %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 3e426ee9e7d..cd0fb21f8a7 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -3,7 +3,7 @@
- discussions = local_assigns.fetch(:discussions, nil)
- type = line.type
- line_code = diff_file.line_code(line)
-- if discussions && !line.meta?
+- if discussions && line.discussable?
- line_discussions = discussions[line_code]
%tr.line_holder{ class: type, id: (line_code unless plain) }
- case type
@@ -19,6 +19,7 @@
- if plain
= link_text
- else
+ = add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
@@ -29,12 +30,12 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
+ %td.line_content.noteable_line{ class: type }<
- if email
%pre= line.text
- else
= diff_line_content(line.text)
-- if line_discussions
+- if line_discussions&.any?
- 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 45c95f7ab6a..56d63250714 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -1,4 +1,5 @@
/ Side-by-side diff view
+
.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data }
%table
- diff_file.parallel_diff_lines.each do |line|
@@ -18,11 +19,12 @@
- left_line_code = diff_file.line_code(left)
- 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 } }
+ = add_diff_note_button(left_line_code, left_position, 'old')
%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)
+ %td.line_content.parallel.noteable_line{ class: left.type }= diff_line_content(left.text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
@@ -38,18 +40,19 @@
- right_line_code = diff_file.line_code(right)
- 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 } }
+ = add_diff_note_button(right_line_code, right_position, 'new')
%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)
+ %td.line_content.parallel.noteable_line{ class: right.type }= diff_line_content(right.text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
- 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?
+ - 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
%tr.line_holder.parallel
diff --git a/app/views/projects/diffs/_render_error.html.haml b/app/views/projects/diffs/_render_error.html.haml
new file mode 100644
index 00000000000..47a9ac3ee6b
--- /dev/null
+++ b/app/views/projects/diffs/_render_error.html.haml
@@ -0,0 +1,6 @@
+.nothing-here-block
+ This #{viewer.switcher_title} could not be displayed because #{diff_render_error_reason(viewer)}.
+
+ You can
+ = diff_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+ instead.
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index fd4f3c8d3cc..02fd54c97fb 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -1,36 +1,34 @@
-.js-toggle-container
- .commit-stat-summary
- Showing
- %button.diff-stats-summary-toggler.js-toggle-button{ type: "button" }
- %strong= pluralize(diff_files.size, "changed file")
+- sum_added_lines = diff_files.sum(&:added_lines)
+- sum_removed_lines = diff_files.sum(&:removed_lines)
+.commit-stat-summary.dropdown
+ Showing
+ %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown" } }<
+ = pluralize(diff_files.size, "changed file")
+ = icon("caret-down", class: "prepend-left-5")
+ %span.diff-stats-additions-deletions-expanded#diff-stats
with
- %strong.cgreen #{diff_files.sum(&:added_lines)} additions
+ %strong.cgreen #{sum_added_lines} additions
and
- %strong.cred #{diff_files.sum(&:removed_lines)} deletions
- .file-stats.js-toggle-content.hide
- %ul
- - diff_files.each do |diff_file|
- - file_hash = hexdigest(diff_file.file_path)
- %li
- - if diff_file.deleted_file
- %span.deleted-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-minus
- = diff_file.old_path
- - elsif diff_file.renamed_file
- %span.renamed-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-minus
- = diff_file.old_path
- &rarr;
- = diff_file.new_path
- - elsif diff_file.new_file
- %span.new-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-plus
- = diff_file.new_path
- - else
- %span.edit-file
- %a{ href: "##{file_hash}" }
- %i.fa.fa-adjust
- = diff_file.new_path
+ %strong.cred #{sum_removed_lines} deletions
+ .diff-stats-additions-deletions-collapsed.pull-right.hidden-xs.hidden-sm{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
+ %strong.cgreen<
+ +#{sum_added_lines}
+ %strong.cred<
+ \-#{sum_removed_lines}
+ .dropdown-menu.diff-file-changes
+ = dropdown_filter("Search files")
+ .dropdown-content
+ %ul
+ - diff_files.each do |diff_file|
+ %li
+ %a{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
+ = icon("#{diff_file_changed_icon(diff_file)} fw", class: "#{diff_file_changed_icon_color(diff_file)} append-right-5")
+ %span.diff-file-changes-path= diff_file.new_path
+ .pull-right
+ %span.cgreen<
+ +#{diff_file.added_lines}
+ %span.cred<
+ \-#{diff_file.removed_lines}
+ %li.dropdown-menu-empty-link.hidden
+ %a{ href: "#" }
+ No files found.
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 5f3968b6709..e8a5e63e59e 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -3,13 +3,13 @@
.suppressed-container
%a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show.
-%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
+%table.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
= render partial: "projects/diffs/line",
collection: diff_file.highlighted_diff_lines,
as: :line,
locals: { diff_file: diff_file, discussions: @grouped_diff_discussions }
- - if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any?
+ - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any?
- last_line = diff_file.highlighted_diff_lines.last
- if last_line.new_pos < total_lines
%tr.line_holder
diff --git a/app/views/projects/diffs/_viewer.html.haml b/app/views/projects/diffs/_viewer.html.haml
new file mode 100644
index 00000000000..5c4d1760871
--- /dev/null
+++ b/app/views/projects/diffs/_viewer.html.haml
@@ -0,0 +1,16 @@
+- hidden = local_assigns.fetch(:hidden, false)
+
+.diff-viewer{ data: { type: viewer.type }, class: ('hidden' if hidden) }
+ - if viewer.render_error
+ = render 'projects/diffs/render_error', viewer: viewer
+ - elsif viewer.collapsed?
+ = render 'projects/diffs/collapsed', viewer: viewer
+ - else
+ - viewer.prepare!
+
+ -# In the rare case where the first kilobyte of the file looks like text,
+ -# but the file turns out to actually be binary after loading all data,
+ -# we fall back on the binary No Preview viewer.
+ - viewer = DiffViewer::NoPreview.new(viewer.diff_file) if viewer.binary_detected_after_load?
+
+ = render viewer.partial_path, viewer: viewer
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 295a1b62535..da34a83d8e0 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -2,13 +2,12 @@
%h4
Too many changes to show.
.pull-right
- - if current_controller?(:commit) or current_controller?(:merge_requests)
- - if current_controller?(:commit)
- = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm"
- = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm"
- - elsif @merge_request && @merge_request.persisted?
- = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
- = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
+ - if current_controller?(:commit)
+ = link_to "Plain diff", project_commit_path(@project, @commit, format: :diff), class: "btn btn-sm"
+ = link_to "Email patch", project_commit_path(@project, @commit, format: :patch), class: "btn btn-sm"
+ - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted?
+ = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
+ = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
%p
To preserve performance only
%strong #{diff_files.size} of #{diff_files.real_size}
diff --git a/app/views/projects/diffs/viewers/_added.html.haml b/app/views/projects/diffs/viewers/_added.html.haml
new file mode 100644
index 00000000000..8004fe16688
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_added.html.haml
@@ -0,0 +1,2 @@
+.nothing-here-block
+ File added
diff --git a/app/views/projects/diffs/viewers/_deleted.html.haml b/app/views/projects/diffs/viewers/_deleted.html.haml
new file mode 100644
index 00000000000..0ac7b4ca8f6
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_deleted.html.haml
@@ -0,0 +1,2 @@
+.nothing-here-block
+ File deleted
diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml
new file mode 100644
index 00000000000..aa004a739d7
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_image.html.haml
@@ -0,0 +1,69 @@
+- diff_file = viewer.diff_file
+- blob = diff_file.blob
+- old_blob = diff_file.old_blob
+- blob_raw_path = diff_file_blob_raw_path(diff_file)
+- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file)
+
+- if diff_file.new_file? || diff_file.deleted_file?
+ .image
+ %span.wrap
+ .frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') }
+ = image_tag(blob_raw_path, alt: diff_file.file_path)
+ %p.image-info= number_to_human_size(blob.size)
+- else
+ .image
+ .two-up.view
+ %span.wrap
+ .frame.deleted
+ %a{ href: project_blob_path(@project, tree_join(diff_file.old_content_sha, diff_file.old_path)) }
+ = image_tag(old_blob_raw_path, alt: diff_file.old_path)
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(old_blob.size)
+ |
+ %b W:
+ %span.meta-width
+ |
+ %b H:
+ %span.meta-height
+ %span.wrap
+ .frame.added
+ %a{ href: project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.new_path)) }
+ = image_tag(blob_raw_path, alt: diff_file.new_path)
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(blob.size)
+ |
+ %b W:
+ %span.meta-width
+ |
+ %b H:
+ %span.meta-height
+
+ .swipe.view.hide
+ .swipe-frame
+ .frame.deleted
+ = image_tag(old_blob_raw_path, alt: diff_file.old_path)
+ .swipe-wrap
+ .frame.added
+ = image_tag(blob_raw_path, alt: diff_file.new_path)
+ %span.swipe-bar
+ %span.top-handle
+ %span.bottom-handle
+
+ .onion-skin.view.hide
+ .onion-skin-frame
+ .frame.deleted
+ = image_tag(old_blob_raw_path, alt: diff_file.old_path)
+ .frame.added
+ = image_tag(blob_raw_path, alt: diff_file.new_path)
+ .controls
+ .transparent
+ .drag-track
+ .dragger{ :style => "left: 0px;" }
+ .opaque
+
+
+ .view-modes.hide
+ %ul.view-modes-menu
+ %li.two-up{ data: { mode: 'two-up' } } 2-up
+ %li.swipe{ data: { mode: 'swipe' } } Swipe
+ %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin
diff --git a/app/views/projects/diffs/viewers/_mode_changed.html.haml b/app/views/projects/diffs/viewers/_mode_changed.html.haml
new file mode 100644
index 00000000000..69bc96bbdad
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_mode_changed.html.haml
@@ -0,0 +1,3 @@
+- diff_file = viewer.diff_file
+.nothing-here-block
+ File mode changed from #{diff_file.a_mode} to #{diff_file.b_mode}
diff --git a/app/views/projects/diffs/viewers/_no_preview.html.haml b/app/views/projects/diffs/viewers/_no_preview.html.haml
new file mode 100644
index 00000000000..befe070af2b
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_no_preview.html.haml
@@ -0,0 +1,2 @@
+.nothing-here-block
+ No preview for this file type
diff --git a/app/views/projects/diffs/viewers/_not_diffable.html.haml b/app/views/projects/diffs/viewers/_not_diffable.html.haml
new file mode 100644
index 00000000000..b2c677ec59c
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_not_diffable.html.haml
@@ -0,0 +1,2 @@
+.nothing-here-block
+ This diff was suppressed by a .gitattributes entry.
diff --git a/app/views/projects/diffs/viewers/_renamed.html.haml b/app/views/projects/diffs/viewers/_renamed.html.haml
new file mode 100644
index 00000000000..ef05ee38d8d
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_renamed.html.haml
@@ -0,0 +1,2 @@
+.nothing-here-block
+ File moved
diff --git a/app/views/projects/diffs/viewers/_text.html.haml b/app/views/projects/diffs/viewers/_text.html.haml
new file mode 100644
index 00000000000..509e68598c9
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_text.html.haml
@@ -0,0 +1,8 @@
+- diff_file = viewer.diff_file
+- blob = diff_file.blob
+- total_lines = blob.lines.size
+- total_lines -= 1 if total_lines > 0 && blob.lines.last.blank?
+- if diff_view == :parallel
+ = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines
+- else
+ = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 160345cfaa5..6178abe9160 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,10 +1,19 @@
+- page_title "General"
+- @content_class = "limit-container-width" unless fluid_layout
+- expanded = Rails.env.test?
+
= render "projects/settings/head"
+
.project-edit-container
- .row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
- Project settings
- .col-lg-9
+ %section.settings.general-settings
+ .settings-header
+ %h4
+ General project settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Update your project name, description, avatar, and other general settings.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
.project-edit-errors
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f|
%fieldset
@@ -33,89 +42,7 @@
= f.label :tag_list, "Tags", class: 'label-light'
= f.text_field :tag_list, value: @project.tag_list.sort.join(', '), maxlength: 2000, class: "form-control"
%p.help-block Separate tags with commas.
- %hr
- %fieldset
- %h5.prepend-top-0
- Sharing &amp; Permissions
- .form_group.prepend-top-20.sharing-and-permissions
- .row.js-visibility-select
- .col-md-9
- %label.label-light
- = label_tag :project_visibility, 'Project Visibility', class: 'label-light'
- = link_to "(?)", help_page_path("public_access/public_access")
- %span.help-block
- .col-md-3.visibility-select-container
- = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
- = f.fields_for :project_feature do |feature_fields|
- %fieldset.features
- .row
- .col-md-9.project-feature
- = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
- %span.help-block View and edit files in this project
- .col-md-3.js-repo-access-level
- = project_feature_access_select(:repository_access_level)
-
- .row
- .col-md-9.project-feature.nested
- = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
- %span.help-block Submit changes to be merged upstream
- .col-md-3
- = project_feature_access_select(:merge_requests_access_level)
-
- .row
- .col-md-9.project-feature.nested
- = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
- %span.help-block Submit, test and deploy your changes before merge
- .col-md-3
- = project_feature_access_select(:builds_access_level)
-
- .row
- .col-md-9.project-feature
- = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
- %span.help-block Share code pastes with others out of Git repository
- .col-md-3
- = project_feature_access_select(:snippets_access_level)
-
- .row
- .col-md-9.project-feature
- = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
- %span.help-block Lightweight issue tracking system for this project
- .col-md-3
- = project_feature_access_select(:issues_access_level)
-
- .row
- .col-md-9.project-feature
- = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
- %span.help-block Pages for project documentation
- .col-md-3
- = project_feature_access_select(:wiki_access_level)
- .form-group
- = render 'shared/allow_request_access', form: f
- - if Gitlab.config.lfs.enabled && current_user.admin?
- .row
- .col-md-9
- = f.label :lfs_enabled, 'LFS', class: 'label-light'
- %span.help-block
- Git Large File Storage
- = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
- .col-md-3
- = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control', data: { field: 'lfs_enabled' }
-
-
- - if Gitlab.config.registry.enabled
- .form-group.js-container-registry{ style: ("display: none;" if @project.project_feature.send(:repository_access_level) == 0) }
- .checkbox
- = f.label :container_registry_enabled do
- = f.check_box :container_registry_enabled
- %strong Container Registry
- %br
- %span.descr Enable Container Registry for this project
- = link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank'
-
- = render 'merge_request_settings', form: f
-
- %hr
- %fieldset.features.append-bottom-default
+ %fieldset.features
%h5.prepend-top-0
Project avatar
.form-group
@@ -132,163 +59,210 @@
.help-block The maximum file size allowed is 200KB.
- if @project.avatar?
%hr
- = link_to 'Remove avatar', namespace_project_avatar_path(@project.namespace, @project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
+ = link_to 'Remove avatar', project_avatar_path(@project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
= f.submit 'Save changes', class: "btn btn-save"
- .row.prepend-top-default
- %hr
- .row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- Housekeeping
- %p.append-bottom-0
- %p
- Runs a number of housekeeping tasks within the current repository,
- such as compressing file revisions and removing unreachable objects.
- .col-lg-9
- = link_to 'Housekeeping', housekeeping_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
- %hr
- .row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- Export project
- %p.append-bottom-0
- %p
- Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
- %p
- Once the exported file is ready, you will receive a notification email with a download link.
+ %section.settings.sharing-permissions
+ .settings-header
+ %h4
+ Sharing and permissions
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Enable or disable certain project features and choose access levels.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ .form_group.sharing-and-permissions
+ .row.js-visibility-select
+ .col-md-8
+ .label-light
+ = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
+ = link_to icon('question-circle'), help_page_path("public_access/public_access")
+ %span.help-block
+ .col-md-4.visibility-select-container
+ = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
+ = f.fields_for :project_feature do |feature_fields|
+ %fieldset.features
+ .row
+ .col-md-8.project-feature
+ = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
+ %span.help-block View and edit files in this project
+ .col-md-4.js-repo-access-level
+ = project_feature_access_select(:repository_access_level)
- .col-lg-9
+ .row
+ .col-md-8.project-feature.nested
+ = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
+ %span.help-block Submit changes to be merged upstream
+ .col-md-4
+ = project_feature_access_select(:merge_requests_access_level)
- - if @project.export_project_path
- = link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project),
- rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
- = link_to 'Generate new export', generate_new_export_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
- - else
- = link_to 'Export project', export_namespace_project_path(@project.namespace, @project),
- method: :post, class: "btn btn-default"
+ .row
+ .col-md-8.project-feature.nested
+ = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
+ %span.help-block Build, test, and deploy your changes
+ .col-md-4
+ = project_feature_access_select(:builds_access_level)
- .bs-callout.bs-callout-info
- %p.append-bottom-0
- %p
- The following items will be exported:
- %ul
- %li Project and wiki repositories
- %li Project uploads
- %li Project configuration including web hooks and services
- %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
- %p
- The following items will NOT be exported:
- %ul
- %li Job traces and artifacts
- %li LFS objects
- %li Container registry images
- %li CI variables
- %li Any encrypted tokens
- - if can? current_user, :archive_project, @project
- %hr
- .row.prepend-top-default
- .col-lg-3
- %h4.warning-title.prepend-top-0
- - if @project.archived?
- Unarchive project
- - else
- Archive project
- %p.append-bottom-0
+ .row
+ .col-md-8.project-feature
+ = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
+ %span.help-block Share code pastes with others out of Git repository
+ .col-md-4
+ = project_feature_access_select(:snippets_access_level)
+
+ .row
+ .col-md-8.project-feature
+ = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
+ %span.help-block Lightweight issue tracking system for this project
+ .col-md-4
+ = project_feature_access_select(:issues_access_level)
+
+ .row
+ .col-md-8.project-feature
+ = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
+ %span.help-block Pages for project documentation
+ .col-md-4
+ = project_feature_access_select(:wiki_access_level)
+ .form-group
+ = render 'shared/allow_request_access', form: f
+ - if Gitlab.config.lfs.enabled && current_user.admin?
+ .row.js-lfs-enabled.form-group.sharing-and-permissions
+ .col-md-8
+ = f.label :lfs_enabled, 'Git Large File Storage', class: 'label-light'
+ = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
+ %span.help-block Manages large files such as audio, video and graphics files.
+ .col-md-4
+ .select-wrapper
+ = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' }
+ = icon('chevron-down')
+ - if Gitlab.config.registry.enabled
+ .form-group.js-container-registry{ style: ("display: none;" if @project.project_feature.send(:repository_access_level) == 0) }
+ .checkbox
+ = f.label :container_registry_enabled do
+ = f.check_box :container_registry_enabled
+ %strong Container Registry
+ %br
+ %span.descr Enable Container Registry for this project
+ = link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank'
+ = f.submit 'Save changes', class: "btn btn-save"
+
+
+ %section.settings.merge-requests-feature{ style: ("display: none;" if @project.project_feature.send(:merge_requests_access_level) == 0) }
+ .settings-header
+ %h4
+ Merge request settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Customize your merge request restrictions.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f|
+ = render 'merge_request_settings', form: f
+ = f.submit 'Save changes', class: "btn btn-save"
+
+ = render 'export', project: @project
+
+ %section.settings.advanced-settings
+ .settings-header
+ %h4
+ Advanced settings
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Perform advanced options such as housekeeping, exporting, archiving, renaming, transferring, or removing your project.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .sub-section
+ %h4 Housekeeping
+ %p
+ Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.
+ = link_to 'Run housekeeping', housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
+ - if can? current_user, :archive_project, @project
+ .sub-section
+ %h4.warning-title
+ - if @project.archived?
+ Unarchive project
+ - else
+ Archive project
- if @project.archived?
- Unarchiving the project will mark its repository as active. The project can be committed to.
+ %p
+ Unarchiving the project will mark its repository as active. The project can be committed to.
+ %strong Once active this project shows up in the search and on the dashboard.
+ = link_to 'Unarchive project', unarchive_project_path(@project),
+ data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." },
+ method: :post, class: "btn btn-success"
- else
- Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
- .col-lg-9
- - if @project.archived?
- %p
- %strong Once active this project shows up in the search and on the dashboard.
- = link_to 'Unarchive project', unarchive_namespace_project_path(@project.namespace, @project),
- data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." },
- method: :post, class: "btn btn-success"
- - else
- %p
- %strong Archived projects cannot be committed to!
- = link_to 'Archive project', archive_namespace_project_path(@project.namespace, @project),
- data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." },
- method: :post, class: "btn btn-warning"
- %hr
- .row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0.warning-title
- Rename repository
- .col-lg-9
- = render 'projects/errors'
- = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
- .form-group.project_name_holder
- = f.label :name, class: 'label-light' do
- Project name
- .form-group
- = f.text_field :name, class: "form-control"
- .form-group
- = f.label :path, class: 'label-light' do
- %span Path
- .form-group
- .input-group
- .input-group-addon
- #{URI.join(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control'
- %ul
- %li Be careful. Renaming a project's repository can have unintended side effects.
- %li You will need to update your local repositories to point to the new location.
- - if @project.deployment_services.any?
- %li Your deployment services will be broken, you will need to manually fix the services after renaming.
- = f.submit 'Rename project', class: "btn btn-warning"
- - if can?(current_user, :change_namespace, @project)
- %hr
- .row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0.danger-title
- Transfer project
- .col-lg-9
- = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true) do |f|
+ %p
+ Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
+ %strong Archived projects cannot be committed to!
+ = link_to 'Archive project', archive_project_path(@project),
+ data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." },
+ method: :post, class: "btn btn-warning"
+ .sub-section.rename-respository
+ %h4.warning-title
+ Rename repository
+ %p
+ Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
+ = render 'projects/errors'
+ = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
+ .form-group.project_name_holder
+ = f.label :name, class: 'label-light' do
+ Project name
+ .form-group
+ = f.text_field :name, class: "form-control"
.form-group
- = label_tag :new_namespace_id, nil, class: 'label-light' do
- %span Namespace
+ = f.label :path, class: 'label-light' do
+ %span Path
.form-group
- = select_tag :new_namespace_id, namespaces_options(@project.namespace_id), { prompt: 'Choose a project namespace', class: 'select2' }
+ .input-group
+ .input-group-addon
+ #{URI.join(root_url, @project.namespace.full_path)}/
+ = f.text_field :path, class: 'form-control'
%ul
- %li Be careful. Changing the project's namespace can have unintended side effects.
- %li You can only transfer the project to namespaces you manage.
+ %li Be careful. Renaming a project's repository can have unintended side effects.
%li You will need to update your local repositories to point to the new location.
- %li Project visibility level will be changed to match namespace rules when transfering to a group.
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
- - if @project.forked? && can?(current_user, :remove_fork_project, @project)
- %hr
- .row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0.danger-title
- Remove fork relationship
- %p.append-bottom-0
+ - if @project.deployment_services.any?
+ %li Your deployment services will be broken, you will need to manually fix the services after renaming.
+ = f.submit 'Rename project', class: "btn btn-warning"
+ - if can?(current_user, :change_namespace, @project)
+ .sub-section
+ %h4.danger-title
+ Transfer project
+ = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
+ .form-group
+ = label_tag :new_namespace_id, nil, class: 'label-light' do
+ %span Select a new namespace
+ .form-group
+ = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
+ %ul
+ %li Be careful. Changing the project's namespace can have unintended side effects.
+ %li You can only transfer the project to namespaces you manage.
+ %li You will need to update your local repositories to point to the new location.
+ %li Project visibility level will be changed to match namespace rules when transferring to a group.
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ - if @project.forked? && can?(current_user, :remove_fork_project, @project)
+ .sub-section
+ %h4.danger-title
+ Remove fork relationship
%p
This will remove the fork relationship to source project
= succeed "." do
= link_to @project.forked_from_project.name_with_namespace, project_path(@project.forked_from_project)
- .col-lg-9
- = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_namespace_project_path(@project.namespace, @project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
- %p
- %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
- = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
- - if can?(current_user, :remove_project, @project)
- %hr
- .row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0.danger-title
- Remove project
- %p.append-bottom-0
- Removing the project will delete its repository and all related resources including issues, merge requests etc.
- .col-lg-9
- = form_tag(namespace_project_path(@project.namespace, @project), method: :delete) do
+ = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
+ %p
+ %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
+ = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
+ - if can?(current_user, :remove_project, @project)
+ .sub-section
+ %h4.danger-title
+ Remove project
%p
- %strong Removed projects cannot be restored!
- = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
+ Removing the project will delete its repository and all related resources including issues, merge requests etc.
+ = form_tag(project_path(@project), method: :delete) do
+ %p
+ %strong Removed projects cannot be restored!
+ = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
.save-project-loader.hide
.center
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 85e442e115c..d17709380d5 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,9 +1,6 @@
- @no_container = true
-= content_for :flash_message do
- - if current_user && can?(current_user, :download_code, @project)
- = render 'shared/no_ssh'
- = render 'shared/no_password'
+= render partial: 'flash_messages', locals: { project: @project }
= render "projects/head"
= render "home_panel"
@@ -60,7 +57,7 @@
git init
git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')}
git add .
- git commit
+ git commit -m "Initial commit"
git push -u origin master
%fieldset
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index 6d040f5cfe6..1605f3a3351 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -19,4 +19,4 @@
.form-actions
= f.submit 'Save', class: 'btn btn-save'
- = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel'
+ = link_to 'Cancel', project_environments_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/environments/_stop.html.haml b/app/views/projects/environments/_stop.html.haml
index 14a2d627203..c35f9af2873 100644
--- a/app/views/projects/environments/_stop.html.haml
+++ b/app/views/projects/environments/_stop.html.haml
@@ -1,5 +1,5 @@
- if can?(current_user, :create_deployment, environment) && environment.stop_action?
.inline
- = link_to stop_namespace_project_environment_path(@project.namespace, @project, environment), method: :post,
+ = link_to stop_project_environment_path(@project, environment), method: :post,
class: 'btn stop-env-link', rel: 'nofollow', data: { confirm: 'Are you sure you want to stop this environment?' } do
= icon('stop', class: 'stop-env-icon')
diff --git a/app/views/projects/environments/_terminal_button.html.haml b/app/views/projects/environments/_terminal_button.html.haml
index 97de9c95de7..a6201bdbc42 100644
--- a/app/views/projects/environments/_terminal_button.html.haml
+++ b/app/views/projects/environments/_terminal_button.html.haml
@@ -1,3 +1,3 @@
- if environment.has_terminals? && can?(current_user, :admin_environment, @project)
- = link_to terminal_namespace_project_environment_path(@project.namespace, @project, environment), class: 'btn terminal-button' do
+ = link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do
= icon('terminal')
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 80d2b6f5d95..d0f723af5bf 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -2,6 +2,9 @@
- page_title "Environments"
= render "projects/pipelines/head"
+- if show_new_nav?
+ - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
+
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag("environments")
@@ -12,6 +15,6 @@
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
"project-environments-path" => project_environments_path(@project),
"project-stopped-environments-path" => project_environments_path(@project, scope: :stopped),
- "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project),
+ "new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments"),
"css-class" => container_class } }
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index 766f119116f..e9e1ad9ef30 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,80 +1,21 @@
- @no_container = true
- page_title "Metrics for environment", @environment.name
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('monitoring')
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'common_d3'
+ = webpack_bundle_tag 'monitoring'
= render "projects/pipelines/head"
-.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" }
+.prometheus-container{ class: container_class }
.top-area
.row
.col-sm-6
- %h3.page-title
+ %h3
Environment:
= link_to @environment.name, environment_path(@environment)
- .prometheus-state
- .js-getting-started.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/getting_started.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Get started with performance monitoring
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.
- = link_to help_page_path('administration/monitoring/prometheus/index.md') do
- Learn more about performance monitoring
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do
- Configure Prometheus
- .js-loading.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/loading.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Waiting for performance data
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do
- View documentation
- .js-unable-to-connect.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- = render "shared/empty_states/monitoring/unable_to_connect.svg"
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Unable to connect to Prometheus server
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Ensure connectivity is available from the GitLab server to the
- = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do
- Prometheus server
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- = link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do
- View documentation
+ #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'),
+ "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'),
+ "additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json),
+ "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } }
- .prometheus-graphs
- .row
- .col-sm-12
- %h4
- CPU utilization
- %svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
- .row
- .col-sm-12
- %h4
- Memory usage
- %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index 24638c77cbb..88f43a1e7e4 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- breadcrumb_title "Environments"
- page_title 'New Environment'
= render "projects/pipelines/head"
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 7315e671056..0ce0f5465fc 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -3,7 +3,7 @@
= render "projects/pipelines/head"
%div{ class: container_class }
- .top-area.adjust
+ .row.top-area.adjust
.col-md-7
%h3.page-title= @environment.name
.col-md-5
@@ -12,9 +12,9 @@
= 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?
- = link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post
+ = link_to 'Edit', edit_project_environment_path(@project, @environment), class: 'btn'
+ - if can?(current_user, :stop_environment, @environment)
+ = link_to 'Stop', stop_project_environment_path(@project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post
.environments-container
- if @deployments.blank?
@@ -28,14 +28,12 @@
= link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
- %table.table.ci-table.environments
- %thead
- %tr
- %th ID
- %th Commit
- %th Job
- %th Created
- %th.hidden-xs
+ .ci-table.environments{ role: 'grid' }
+ .gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-10{ role: 'columnheader' } ID
+ .table-section.section-30{ role: 'columnheader' } Commit
+ .table-section.section-25{ role: 'columnheader' } Job
+ .table-section.section-15{ role: 'columnheader' } Created
= render @deployments
diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml
index c8363087d6a..464135b5ac7 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -16,9 +16,10 @@
.col-sm-6
.nav-controls
- = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
- = icon('external-link')
+ - if @environment.external_url.present?
+ = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = icon('external-link')
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.terminal-container{ class: container_class }
- #terminal{ data: { project_path: "#{terminal_namespace_project_environment_path(@project.namespace, @project, @environment)}.ws" } }
+ #terminal{ data: { project_path: "#{terminal_project_environment_path(@project, @environment)}.ws" } }
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 4cdb44325b3..021575160ea 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,26 +1,19 @@
- page_title "Find File", @ref
+= render "projects/commits/head"
-.file-finder-holder.tree-holder.clearfix
+.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, @options.merge(format: :json)))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) }
.nav-block
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'find_file', path: @path
%ul.breadcrumb.repo-breadcrumb
%li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
+ = link_to project_tree_path(@project, @ref) do
= @project.path
%li.file-finder
- %input#file_find.form-control.file-finder-input{ type: "text", placeholder: 'Find by path', autocomplete: 'off' }
+ %input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' }
.tree-content-holder
.table-holder
%table.table.files-slider{ class: "table_#{@hex_path} tree-table table-striped" }
%tbody
= spinner nil, true
-
-:javascript
- var projectFindFile = new ProjectFindFile($(".file-finder-holder"), {
- url: "#{escape_javascript(namespace_project_files_path(@project.namespace, @project, @ref, @options.merge(format: :json)))}",
- treeUrl: "#{escape_javascript(namespace_project_tree_path(@project.namespace, @project, @ref))}",
- blobUrlTemplate: "#{escape_javascript(namespace_project_blob_path(@project.namespace, @project, @id || @commit.id))}"
- });
- new ShortcutsFindFile(projectFindFile);
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 524b77783ef..d365bcd4ecc 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -20,6 +20,6 @@
= error
%p
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork", class: "btn" do
+ = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do
%i.fa.fa-code-fork
Try to fork again
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index f4aa523b32d..111cbcda266 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -34,7 +34,7 @@
= custom_icon('icon_fork')
%span Fork
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn btn-new' do
+ = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-new' do
= custom_icon('icon_fork')
%span Fork
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 5242bc72b71..0f36e1a7353 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -30,7 +30,7 @@
= namespace.human_name
- else
.fork-thumbnail
- = link_to namespace_project_forks_path(@project.namespace, @project, namespace_key: namespace.id), method: "POST" do
+ = link_to project_forks_path(@project, namespace_key: namespace.id), method: "POST" do
- if /no_((\w*)_)*avatar/.match(avatar)
.no-avatar
= icon 'question'
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index f458646522c..b98dc09534f 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -20,14 +20,14 @@
- if generic_commit_status.ref
.icon-container
= generic_commit_status.tags.any? ? icon('tag') : icon('code-fork')
- = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref)
+ = link_to generic_commit_status.ref, project_commits_path(generic_commit_status.project, generic_commit_status.ref)
- else
.light none
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-id monospace"
+ = link_to generic_commit_status.short_sha, project_commit_path(generic_commit_status.project, generic_commit_status.sha), class: "commit-sha"
- if retried
= icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.')
@@ -48,12 +48,12 @@
- if generic_commit_status.pipeline.user
= user_avatar(user: generic_commit_status.pipeline.user, size: 20)
- else
- %span.monospace API
+ %span.api API
- if admin
%td
- if generic_commit_status.project
- = link_to generic_commit_status.project.name_with_namespace, admin_namespace_project_path(generic_commit_status.project.namespace, generic_commit_status.project)
+ = link_to generic_commit_status.project.name_with_namespace, admin_project_path(generic_commit_status.project)
%td
- if generic_commit_status.try(:runner)
= runner_link(generic_commit_status.runner)
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 464ac34d961..9f5a1239a82 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,8 +1,11 @@
- @no_container = true
- page_title "Charts"
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
+ = webpack_bundle_tag('common_d3')
+ = webpack_bundle_tag('graphs')
+ = webpack_bundle_tag('graphs_charts')
= render "projects/commits/head"
.repo-charts{ class: container_class }
@@ -73,55 +76,10 @@
Commits per day hour (UTC)
%canvas#hour-chart
-:javascript
- var responsiveChart = function (selector, data) {
- var options = { "scaleOverlay": true, responsive: true, pointHitDetectionRadius: 2, maintainAspectRatio: false };
- // get selector by context
- var ctx = selector.get(0).getContext("2d");
- // pointing parent container to make chart.js inherit its width
- var container = $(selector).parent();
- var generateChart = function() {
- selector.attr('width', $(container).width());
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- return new Chart(ctx).Bar(data, options);
- };
- // enabling auto-resizing
- $(window).resize(generateChart);
- return generateChart();
- };
-
- var chartData = function (keys, values) {
- var data = {
- labels : keys,
- datasets : [{
- fillColor : "rgba(220,220,220,0.5)",
- strokeColor : "rgba(220,220,220,1)",
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data : values
- }]
- };
- return data;
- };
-
- var hourData = chartData(#{@commits_per_time.keys.to_json}, #{@commits_per_time.values.to_json});
- responsiveChart($('#hour-chart'), hourData);
-
- var dayData = chartData(#{@commits_per_week_days.keys.to_json}, #{@commits_per_week_days.values.to_json});
- responsiveChart($('#weekday-chart'), dayData);
-
- var monthData = chartData(#{@commits_per_month.keys.to_json}, #{@commits_per_month.values.to_json});
- responsiveChart($('#month-chart'), monthData);
-
- var data = #{@languages.to_json};
- var ctx = $("#languages-chart").get(0).getContext("2d");
- var options = {
- scaleOverlay: true,
- responsive: true,
- maintainAspectRatio: false
- }
- var myPieChart = new Chart(ctx).Pie(data, options);
+%script#projectChartData{ type: "application/json" }
+ - projectChartData = {};
+ - projectChartData['hour'] = @commits_per_time
+ - projectChartData['weekDays'] = @commits_per_week_days
+ - projectChartData['month'] = @commits_per_month
+ - projectChartData['languages'] = @languages
+ = projectChartData.to_json.html_safe
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index 680f8ae6c8f..f41a0d8293b 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,11 +1,16 @@
- @no_container = true
- page_title "Contributors"
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
+ = webpack_bundle_tag('common_d3')
+ = webpack_bundle_tag('graphs')
+ = webpack_bundle_tag('graphs_show')
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
+
= render 'projects/commits/head'
-%div{ class: container_class }
+.js-graphs-show{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) }
.sub-header-block
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'graphs'
@@ -29,24 +34,3 @@
#contributors-master
#contributors.clearfix
%ol.contributors-list.clearfix
-
-
-
-:javascript
- $.ajax({
- type: "GET",
- url: "#{namespace_project_graph_path(@project.namespace, @project, current_ref, format: :json)}",
- dataType: "json",
- success: function (data) {
- var graph = new ContributorsStatGraph();
- graph.init(data);
-
- $("#brush_change").change(function(){
- graph.change_date_header();
- graph.redraw_authors();
- });
-
- $(".stat-graph").fadeIn();
- $(".loading-graph").hide();
- }
- });
diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml
deleted file mode 100644
index b6116dbec41..00000000000
--- a/app/views/projects/group_links/_index.html.haml
+++ /dev/null
@@ -1,55 +0,0 @@
-- page_title "Groups"
-.row.prepend-top-default
- .col-lg-3.settings-sidebar
- %h4.prepend-top-0
- Share project with other groups
- %p
- Projects can be stored in only one group at once. However you can share a project with other groups here.
- .col-lg-9
- %h5.prepend-top-0
- Set a group to share
- = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do
- .form-group
- = label_tag :link_group_id, "Group", class: "label-light"
- = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true)
- .form-group
- = label_tag :link_group_access, "Max access level", class: "label-light"
- .select-wrapper
- = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
- = icon('caret-down')
- .form-group
- = label_tag :expires_at, 'Access expiration date', class: 'label-light'
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Select access expiration date', id: 'expires_at_groups'
- %i.clear-icon.js-clear-input
- .help-block
- On this date, all members in the group will automatically lose access to this project.
- = submit_tag "Share", class: "btn btn-create"
- .col-lg-9.col-lg-offset-3
- %hr
- .col-lg-9.col-lg-offset-3.append-bottom-default.enabled-groups
- %h5.prepend-top-0
- Groups you share with (#{@group_links.size})
- - if @group_links.present?
- %ul.well-list
- - @group_links.each do |group_link|
- - group = group_link.group
- %li
- .pull-left.append-right-10.hidden-xs
- = icon("folder-open-o", class: "settings-list-icon")
- .pull-left
- = link_to group do
- = group.full_name
- %br
- up to #{group_link.human_access}
- - if group_link.expires?
- ·
- %span{ class: ('text-warning' if group_link.expires_soon?) }
- expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
- .pull-right
- = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: "btn btn-transparent" do
- %span.sr-only disable sharing
- = icon("trash")
- - else
- .settings-message.text-center
- There are no groups with access to your project, add one in the form above
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
new file mode 100644
index 00000000000..05b06cfc8b2
--- /dev/null
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -0,0 +1,37 @@
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Recent Deliveries
+ %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ .col-lg-9
+ - if hook_logs.any?
+ %table.table
+ %thead
+ %tr
+ %th Status
+ %th Trigger
+ %th URL
+ %th Elapsed time
+ %th Request time
+ %th
+ - hook_logs.each do |hook_log|
+ %tr
+ %td
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+ %td
+ = truncate(hook_log.url, length: 50)
+ %td.light
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ %td.light
+ = time_ago_with_tooltip(hook_log.created_at)
+ %td
+ = link_to 'View details', project_hook_hook_log_path(project, hook, hook_log)
+
+ = paginate hook_logs, theme: 'gitlab'
+
+ - else
+ .settings-message.text-center
+ You don't have any webhooks deliveries
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
new file mode 100644
index 00000000000..ab5a7b117d7
--- /dev/null
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -0,0 +1,11 @@
+= render 'projects/settings/head'
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Request details
+ .col-lg-9
+
+ = link_to 'Resend Request', retry_project_hook_hook_log_path(@project, @hook, @hook_log), class: "btn btn-default pull-right prepend-left-10"
+
+ = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml
index 8faad351463..776681ea09a 100644
--- a/app/views/projects/hooks/_index.html.haml
+++ b/app/views/projects/hooks/_index.html.haml
@@ -1 +1,23 @@
-= render 'shared/web_hooks/form', hook: @hook, hooks: @hooks, url_components: [@project.namespace.becomes(Namespace), @project]
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
+ used for binding events when something is happening within the project.
+
+ .col-lg-8.append-bottom-default
+ = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f|
+ = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+ = f.submit 'Add webhook', class: 'btn btn-create'
+
+ %hr
+ %h5.prepend-top-default
+ Webhooks (#{@hooks.count})
+ - if @hooks.any?
+ %ul.well-list
+ - @hooks.each do |hook|
+ = render 'project_hook', hook: hook
+ - else
+ %p.settings-message.text-center.append-bottom-0
+ No webhooks found, add one in the form above.
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
new file mode 100644
index 00000000000..c8c17d2d828
--- /dev/null
+++ b/app/views/projects/hooks/edit.html.haml
@@ -0,0 +1,22 @@
+- page_title 'Integrations'
+= render 'projects/settings/head'
+
+.row.prepend-top-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
+ used for binding events when something is happening within the project.
+ .col-lg-9.append-bottom-default
+ = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
+ = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+
+ = f.submit 'Save changes', class: 'btn btn-create'
+ = render 'shared/web_hooks/test_button', triggers: ProjectHook::TRIGGERS, hook: @hook
+ = link_to 'Remove', project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' }
+
+%hr
+
+= render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project }
+
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 2cd8d03e30e..778ff91362d 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -10,9 +10,9 @@
.panel-body
%pre
:preserve
- #{sanitize_repo_path(@project, @project.import_error)}
+ #{h(sanitize_repo_path(@project, @project.import_error))}
-= form_for @project, url: namespace_project_import_path(@project.namespace, @project), method: :post, html: { class: 'form-horizontal' } do |f|
+= form_for @project, url: project_import_path(@project), method: :post, html: { class: 'form-horizontal' } do |f|
= render "shared/import_form", f: f
.form-actions
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index c52b3860636..8c490773a56 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -10,5 +10,3 @@
- if @project.external_import?
%p.monospace git clone --bare #{@project.safe_import_url}
%p Please wait while we import the repository for you. Refresh at will.
- :javascript
- new ProjectImport();
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 5d4e593e4ef..8b095f4ca10 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,7 @@
- content_for :note_actions do
- if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
#notes
- = render 'projects/notes/notes_with_form'
+ = render 'shared/notes/notes_with_form', :autocomplete => true
diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml
index 7a188cb6445..e9f21594a71 100644
--- a/app/views/projects/issues/_head.html.haml
+++ b/app/views/projects/issues/_head.html.haml
@@ -5,29 +5,29 @@
%ul{ class: (container_class) }
- if project_nav_tab?(:issues) && !current_controller?(:merge_requests)
= nav_link(controller: :issues) do
- = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do
+ = link_to project_issues_path(@project), title: 'Issues' do
%span
List
= nav_link(controller: :boards) do
- = link_to namespace_project_boards_path(@project.namespace, @project), title: 'Board' do
+ = link_to project_boards_path(@project), title: 'Board' do
%span
Board
- if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
= nav_link(controller: :merge_requests) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
+ = link_to project_merge_requests_path(@project), title: 'Merge Requests' do
%span
Merge Requests
- if project_nav_tab? :labels
= nav_link(controller: :labels) do
- = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do
+ = link_to project_labels_path(@project), title: 'Labels' do
%span
Labels
- if project_nav_tab? :milestones
= nav_link(controller: :milestones) do
- = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do
+ = link_to project_milestones_path(@project), title: 'Milestones' do
%span
Milestones
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 0e3902c066a..7dc35be57a6 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -1,46 +1,52 @@
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } }
.issue-box
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue"
.issue-info-container
- .issue-title.title
- %span.issue-title-text
- = confidential_icon(issue)
- = link_to issue.title, issue_path(issue)
+ .issue-main-info
+ .issue-title.title
+ %span.issue-title-text
+ = confidential_icon(issue)
+ = link_to issue.title, issue_path(issue)
+ - if issue.tasks?
+ %span.task-status.hidden-xs
+ &nbsp;
+ = issue.task_status
+
+ .issuable-info
+ %span.issuable-reference
+ #{issuable_reference(issue)}
+ %span.issuable-authored.hidden-xs
+ &middot;
+ opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
+ by #{link_to_member(@project, issue.author, avatar: false)}
+ - if issue.milestone
+ %span.issuable-milestone.hidden-xs
+ &nbsp;
+ = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title) do
+ = icon('clock-o')
+ = issue.milestone.title
+ - if issue.due_date
+ %span.issuable-due-date.hidden-xs{ class: "#{'cred' if issue.overdue?}" }
+ &nbsp;
+ = icon('calendar')
+ = issue.due_date.to_s(:medium)
+ - if issue.labels.any?
+ &nbsp;
+ - issue.labels.each do |label|
+ = link_to_label(label, subject: issue.project, css_class: 'label-link')
+
+ .issuable-meta
%ul.controls
- if issue.closed?
- %li
+ %li.issuable-status
CLOSED
-
- - if issue.assignee
+ - if issue.assignees.any?
%li
- = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name")
+ = render 'shared/issuable/assignees', project: @project, issue: issue
= render 'shared/issuable_meta_data', issuable: issue
- .issue-info
- #{issuable_reference(issue)} &middot;
- opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
- by #{link_to_member(@project, issue.author, avatar: false)}
- - if issue.milestone
- &nbsp;
- = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do
- = icon('clock-o')
- = issue.milestone.title
- - if issue.due_date
- %span{ class: "#{'cred' if issue.overdue?}" }
- &nbsp;
- = icon('calendar')
- = issue.due_date.to_s(:medium)
- - if issue.labels.any?
- &nbsp;
- - issue.labels.each do |label|
- = link_to_label(label, subject: issue.project, css_class: 'label-link')
- - if issue.tasks?
- &nbsp;
- %span.task-status
- = issue.task_status
-
- .pull-right.issue-updated-at
+ .pull-right.issuable-updated-at.hidden-xs
%span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')}
diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml
index da65157a10b..264032a3a31 100644
--- a/app/views/projects/issues/_issue_by_email.html.haml
+++ b/app/views/projects/issues/_issue_by_email.html.haml
@@ -20,7 +20,7 @@
%p
The subject will be used as the title of the new issue, and the message will be the description.
- = link_to 'Slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1
+ = link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
and styling with
= link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
are supported.
@@ -30,5 +30,5 @@
Anyone who gets ahold of it can create issues as if they were you.
You should
- = link_to 'reset it', new_issue_address_namespace_project_path(@project.namespace, @project), class: 'incoming-email-token-reset'
+ = link_to 'reset it', new_issue_address_project_path(@project), class: 'incoming-email-token-reset'
if that ever happens.
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
index d48923b422a..6a567487514 100644
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ b/app/views/projects/issues/_merge_requests.html.haml
@@ -14,11 +14,11 @@
= merge_request.to_reference
%span.merge-request-info
%strong
- = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "row_title"
+ = link_to merge_request.title, merge_request_path(merge_request), class: "row_title"
- unless @issue.project.id == merge_request.target_project.id
in
- project = merge_request.target_project
- = link_to project.name_with_namespace, namespace_project_path(project.namespace, project)
+ = link_to project.name_with_namespace, project_path(project)
- if merge_request.merged?
%span.merge-request-status.prepend-left-10.merged
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
new file mode 100644
index 00000000000..13809da6523
--- /dev/null
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -0,0 +1,10 @@
+= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
+ = icon('rss')
+- if @can_bulk_update
+ = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
+= link_to "New issue", new_project_issue_path(@project,
+ issue: { assignee_id: issues_finder.assignee.try(:id),
+ milestone_id: issues_finder.milestones.first.try(:id) }),
+ class: "btn btn-new",
+ title: "New issue",
+ id: "new_issue_link"
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 13e2150f997..e1b4a49850a 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,9 +1,29 @@
- if can?(current_user, :push_code, @project)
- .pull-right
- #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) }
- = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid),
- method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do
- New branch
- = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do
- = icon('exclamation-triangle')
- New branch unavailable
+ .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
+ .btn-group.unavailable
+ %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
+ = icon('spinner', class: 'fa-spin')
+ %span.text
+ Checking branch availability…
+ .btn-group.available.hide
+ %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } }
+ %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
+ = icon('caret-down')
+ %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+ %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
+ .menu-item
+ .icon-container
+ = icon('check')
+ .description
+ %strong Create a merge request
+ %span
+ Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'.
+ %li.divider.droplab-item-ignore
+ %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
+ .menu-item
+ .icon-container
+ = icon('check')
+ .description
+ %strong Create a branch
+ %span
+ Creates a branch named after this issue, from '#{@project.default_branch}'.
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 1892ebb512f..1df38db9fd4 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -11,5 +11,4 @@
= render_pipeline_status(pipeline)
%span.related-branch-info
%strong
- = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do
- = branch
+ = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
diff --git a/app/views/projects/issues/index.atom.builder b/app/views/projects/issues/index.atom.builder
index 4feec09bb5d..4029926f373 100644
--- a/app/views/projects/issues/index.atom.builder
+++ b/app/views/projects/issues/index.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@project.name} issues"
- xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
- xml.link href: namespace_project_issues_url(@project.namespace, @project), rel: "alternate", type: "text/html"
- xml.id namespace_project_issues_url(@project.namespace, @project)
- xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
+xml.title "#{@project.name} issues"
+xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: project_issues_url(@project), rel: "alternate", type: "text/html"
+xml.id project_issues_url(@project)
+xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
- xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
-end
+xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any?
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 4ac0bc1d028..aacb057840d 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_issue, @project)
+- @can_bulk_update = can?(current_user, :admin_issue, @project)
- page_title "Issues"
- new_issue_email = @project.new_issue_address(current_user)
@@ -7,31 +7,30 @@
= render "projects/issues/head"
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('filtered_search')
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'filtered_search'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
+- if show_new_nav?
+ - content_for :breadcrumbs_extra do
+ = render "projects/issues/nav_btns"
+
- if project_issues(@project).exists?
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
- .nav-controls
- = link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
- = icon('rss')
- = link_to new_namespace_project_issue_path(@project.namespace,
- @project,
- issue: { assignee_id: issues_finder.assignee.try(:id),
- milestone_id: issues_finder.milestones.first.try(:id) }),
- class: "btn btn-new",
- title: "New issue",
- id: "new_issue_link" do
- New issue
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
+ = render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
+
.issues-holder
= render 'issues'
- if new_issue_email
= render 'issue_by_email', email: new_issue_email
- else
- = render 'shared/empty_states/issues', button_path: new_namespace_project_issue_path(@project.namespace, @project)
+ = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project)
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index e8aae0f47e2..60fe442014f 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -1,3 +1,4 @@
+- breadcrumb_title "Issues"
- page_title "New Issue"
%h3.page-title
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 4d56aa214e2..ad5befc6ee5 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,8 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
+- can_update_issue = can?(current_user, :update_issue, @issue)
+- can_report_spam = @issue.submittable_as_spam_by?(current_user)
.clearfix.detail-page-header
.issuable-header
@@ -17,7 +19,8 @@
= icon('angle-double-left')
.issuable-meta
- = confidential_icon(@issue)
+ - if @issue.confidential
+ = icon('eye-slash', class: 'is-confidential')
= issuable_meta(@issue, @project, "Issue")
.issuable-actions
@@ -27,52 +30,53 @@
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
- %li
- = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link'
- - if can?(current_user, :update_issue, @issue)
- %li
- = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- %li
- = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- %li
- = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
- - if @issue.submittable_as_spam_by?(current_user)
- %li
- = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
+ - if can_update_issue
+ %li= link_to 'Edit', edit_project_issue_path(@project, @issue)
+ - unless current_user == @issue.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
+ - if can_update_issue
+ %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ - if can_report_spam
+ %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
+ - if can_update_issue || can_report_spam
+ %li.divider
+ %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
- = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
+ - if can_update_issue
+ = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
+
+ = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue
+
+ - if can_report_spam
+ = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue
- - if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- - if @issue.submittable_as_spam_by?(current_user)
- = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
- = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details
.detail-page-description.content-block
- .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
- "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
- } }
- .issue-title-entrypoint
- - if @issue.description.present?
- .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
- .wiki
- = preserve do
- = markdown_field(@issue, :description)
- %textarea.hidden.js-task-list-field
- = @issue.description
- = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago')
+ %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue)
+ #js-issuable-app
+ %h2.title= markdown_field(@issue, :title)
+ - if @issue.description.present?
+ .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
+ .wiki= markdown_field(@issue, :description)
+ %textarea.hidden.js-task-list-field= @issue.description
+
+ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
- #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
+ #merge-requests{ data: { url: referenced_merge_requests_project_issue_url(@project, @issue) } }
// This element is filled in using JavaScript.
- #related-branches{ data: { url: related_branches_namespace_project_issue_url(@project.namespace, @project, @issue) } }
+ #related-branches{ data: { url: related_branches_project_issue_url(@project, @issue) } }
// This element is filled in using JavaScript.
- .content-block.content-block-small
- = render 'new_branch' unless @issue.confidential?
- = render 'award_emoji/awards_block', awardable: @issue, inline: true
+ .content-block.emoji-block
+ .row
+ .col-sm-8
+ = render 'award_emoji/awards_block', awardable: @issue, inline: true
+ .col-sm-4.new-branch-col
+ = render 'new_branch' unless @issue.confidential?
%section.issuable-discussion
= render 'projects/issues/discussion'
diff --git a/app/views/projects/jobs/_header.html.haml b/app/views/projects/jobs/_header.html.haml
new file mode 100644
index 00000000000..83a2af1dc74
--- /dev/null
+++ b/app/views/projects/jobs/_header.html.haml
@@ -0,0 +1,31 @@
+- show_controls = local_assigns.fetch(:show_controls, true)
+- pipeline = @build.pipeline
+
+.content-block.build-header.top-area.page-content-header
+ .header-content
+ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
+ %strong
+ Job
+ = link_to "##{@build.id}", project_job_path(@project, @build), class: 'js-build-id'
+ in pipeline
+ %strong
+ = link_to "##{pipeline.id}", pipeline_path(pipeline)
+ for
+ %strong
+ = link_to pipeline.short_sha, project_commit_path(@project, pipeline.sha), class: 'commit-sha'
+ from
+ %strong
+ = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name'
+
+ = render "projects/jobs/user" if @build.user
+
+ = time_ago_with_tooltip(@build.created_at)
+
+ - if show_controls
+ .nav-controls
+ - if can?(current_user, :create_issue, @project) && @build.failed?
+ = link_to "New issue", new_project_issue_path(@project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry job", retry_project_job_path(@project, @build), class: 'btn btn-inverted-secondary', method: :post
+ %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
new file mode 100644
index 00000000000..f5d5bc7eda9
--- /dev/null
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -0,0 +1,101 @@
+- builds = @build.pipeline.builds.to_a
+
+%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
+ .sidebar-container
+ .blocks-container
+ .block
+ %strong
+ = @build.name
+ %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
+ = icon('angle-double-right')
+
+ #js-details-block-vue
+
+ - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
+ .block
+ .title
+ Job artifacts
+ - if @build.artifacts_expired?
+ %p.build-detail-row
+ The artifacts were removed
+ #{time_ago_with_tooltip(@build.artifacts_expire_at)}
+ - elsif @build.has_expiring_artifacts?
+ %p.build-detail-row
+ The artifacts will be removed in
+ %span.js-artifacts-remove= @build.artifacts_expire_at
+
+ - if @build.artifacts?
+ .btn-group.btn-group-justified{ role: :group }
+ - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
+ = link_to keep_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default', method: :post do
+ Keep
+
+ = link_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
+ Download
+
+ - if @build.artifacts_metadata?
+ = link_to browse_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default' do
+ Browse
+
+ - if @build.trigger_request
+ .build-widget.block
+ %h4.title
+ Trigger
+
+ %p
+ %span.build-light-text Token:
+ #{@build.trigger_request.trigger.short_token}
+
+ - if @build.trigger_request.variables
+ %p
+ %button.btn.group.btn-group-justified.reveal-variables Reveal Variables
+
+ %dl.js-build-variables.trigger-build-variables.hide
+ - @build.trigger_request.variables.each do |key, value|
+ %dt.js-build-variable.trigger-build-variable= key
+ %dd.js-build-value.trigger-build-value= value
+
+ %div{ class: (@build.pipeline.stages_count > 1 ? "block" : "block-last") }
+ %p
+ Commit
+ = link_to @build.pipeline.short_sha, project_commit_path(@project, @build.pipeline.sha), class: 'commit-sha link-commit'
+ = clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard")
+ - if @build.merge_request
+ in
+ = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit'
+
+ %p.build-light-text.append-bottom-0
+ #{@build.pipeline.git_commit_title}
+
+ - if @build.pipeline.stages_count > 1
+ .block-last.dropdown.build-dropdown
+ %div
+ %span{ class: "ci-status-icon-#{@build.pipeline.status}" }
+ = ci_icon_for_status(@build.pipeline.status)
+ Pipeline
+ = link_to "##{@build.pipeline.id}", project_pipeline_path(@project, @build.pipeline), class: 'link-commit'
+ from
+ = link_to "#{@build.pipeline.ref}", project_ref_path(@project, @build.pipeline.ref), class: 'link-commit ref-name'
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.stage-selection More
+ = icon('chevron-down')
+ %ul.dropdown-menu
+ - @build.pipeline.legacy_stages.each do |stage|
+ %li
+ %a.stage-item= stage.name
+
+ .builds-container
+ - HasStatus::ORDERED_STATUSES.each do |build_status|
+ - builds.select{|build| build.status == build_status}.each do |build|
+ .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } }
+ = link_to project_job_path(@project, build) do
+ = icon('arrow-right')
+ %span{ class: "ci-status-icon-#{build.status}" }
+ = ci_icon_for_status(build.status)
+ %span
+ - if build.name
+ = build.name
+ - else
+ = build.id
+ - if build.retried?
+ %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 82806f022ee..82806f022ee 100644
--- a/app/views/projects/builds/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
diff --git a/app/views/projects/builds/_user.html.haml b/app/views/projects/jobs/_user.html.haml
index 83f299da651..83f299da651 100644
--- a/app/views/projects/builds/_user.html.haml
+++ b/app/views/projects/jobs/_user.html.haml
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
new file mode 100644
index 00000000000..d78891546f7
--- /dev/null
+++ b/app/views/projects/jobs/index.html.haml
@@ -0,0 +1,26 @@
+- @no_container = true
+- page_title "Jobs"
+= render "projects/pipelines/head"
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
+
+%div{ class: container_class }
+ .top-area
+ - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
+ = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
+
+ .nav-controls
+ - if can?(current_user, :update_build, @project)
+ - if @all_builds.running_or_pending.any?
+ = link_to 'Cancel running', cancel_all_project_jobs_path(@project),
+ data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+
+ - unless @repository.gitlab_ci_yml
+ = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
+
+ = link_to ci_lint_path, class: 'btn btn-default' do
+ %span CI lint
+
+ .content-list.builds-content-list
+ = render "table", builds: @builds, project: @project
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
new file mode 100644
index 00000000000..fa086413fbe
--- /dev/null
+++ b/app/views/projects/jobs/show.html.haml
@@ -0,0 +1,100 @@
+- @no_container = true
+- page_title "#{@build.name} (##{@build.id})", "Jobs"
+= render "projects/pipelines/head"
+
+%div{ class: container_class }
+ .build-page.js-build-page
+ #js-build-header-vue
+ - if @build.stuck?
+ - unless @build.any_runners_online?
+ .bs-callout.bs-callout-warning.js-build-stuck
+ %p
+ - if no_runners_for_project?(@build.project)
+ This job is stuck, because the project doesn't have any runners online assigned to it.
+ - elsif @build.tags.any?
+ This job is stuck, because you don't have any active runners online with any of these tags assigned to them:
+ - @build.tags.each do |tag|
+ %span.label.label-primary
+ = tag
+ - else
+ This job is stuck, because you don't have any active runners that can run this job.
+
+ %br
+ Go to
+ = link_to project_runners_path(@build.project) do
+ Runners page
+
+ - if @build.starts_environment?
+ .prepend-top-default.js-environment-container
+ .environment-information
+ - if @build.outdated_deployment?
+ = ci_icon_for_status('success_with_warnings')
+ - else
+ = ci_icon_for_status(@build.status)
+
+ - environment = environment_for_build(@build.project, @build)
+ - if @build.success? && @build.last_deployment.present?
+ - if @build.last_deployment.last?
+ This job is the most recent deployment to #{environment_link_for_build(@build.project, @build)}.
+ - else
+ This job is an out-of-date deployment to #{environment_link_for_build(@build.project, @build)}.
+ View the most recent deployment #{deployment_link(environment.last_deployment)}.
+ - elsif @build.complete? && !@build.success?
+ The deployment of this job to #{environment_link_for_build(@build.project, @build)} did not succeed.
+ - else
+ This job is creating a deployment to #{environment_link_for_build(@build.project, @build)}
+ - if environment.try(:last_deployment)
+ and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')}
+
+ - if @build.erased?
+ .prepend-top-default.js-build-erased
+ .erased.alert.alert-warning
+ - if @build.erased_by_user?
+ Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
+ - else
+ Job has been erased #{time_ago_with_tooltip(@build.erased_at)}
+
+ .build-trace-container.prepend-top-default
+ .top-bar.js-top-bar
+ .js-truncated-info.truncated-info.hidden<
+ Showing last
+ %span.js-truncated-info-size.truncated-info-size><
+ KiB of log -
+ %a.js-raw-link.raw-link{ href: raw_project_job_path(@project, @build) }>< Complete Raw
+
+ .controllers
+ - if @build.has_trace?
+ = link_to raw_project_job_path(@project, @build),
+ title: 'Show complete raw',
+ data: { placement: 'top', container: 'body' },
+ class: 'js-raw-link-controller has-tooltip controllers-buttons' do
+ = icon('file-text-o')
+
+ - if can?(current_user, :update_build, @project) && @build.erasable?
+ = link_to erase_project_job_path(@project, @build),
+ method: :post,
+ data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
+ title: 'Erase job log',
+ class: 'has-tooltip js-erase-link controllers-buttons' do
+ = icon('trash')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_up')
+ .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
+ %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
+ = custom_icon('scroll_down')
+
+ %pre.build-trace#build-trace
+ %code.bash.js-build-output
+ .build-loader-animation.js-build-refresh
+
+
+ = render "sidebar"
+
+.js-build-options{ data: javascript_build_options }
+
+#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json) } }
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('common_vue')
+ = webpack_bundle_tag('job_details')
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index 7f0059cdcda..84b0b65d1c0 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -6,4 +6,4 @@
%h3.page-title
Edit Label
%hr
- = render 'shared/labels/form', url: namespace_project_label_path(@project.namespace.becomes(Namespace), @project, @label), back_path: namespace_project_labels_path(@project.namespace, @project)
+ = render 'shared/labels/form', url: project_label_path(@project, @label), back_path: project_labels_path(@project)
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index fc72c4fb635..4b9da02c6b8 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -1,26 +1,34 @@
- @no_container = true
- page_title "Labels"
- hide_class = ''
+- can_admin_label = can?(current_user, :admin_label, @project)
+
+- if show_new_nav? && can?(current_user, :admin_label, @project)
+ - content_for :breadcrumbs_extra do
+ = link_to "New label", new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new"
+
= render "shared/mr_head"
- if @labels.exists? || @prioritized_labels.exists?
%div{ class: container_class }
.top-area.adjust
.nav-text
- Labels can be applied to issues and merge requests. Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.
+ Labels can be applied to issues and merge requests.
+ - if can_admin_label
+ Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.
- .nav-controls
- - if can?(current_user, :admin_label, @project)
- = link_to new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new" do
+ - if can_admin_label
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
+ = link_to new_project_label_path(@project), class: "btn btn-new" do
New label
.labels
- - if can?(current_user, :admin_label, @project)
+ - if can_admin_label
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
.prioritized-labels{ class: ('hide' if hide) }
%h5 Prioritized Labels
- %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_namespace_project_labels_path(@project.namespace, @project) }
+ %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) }
#js-priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty?}" }
= render 'shared/empty_states/priority_labels'
- if @prioritized_labels.present?
@@ -28,7 +36,7 @@
- if @labels.present?
.other-labels
- - if can?(current_user, :admin_label, @project)
+ - if can_admin_label
%h5{ class: ('hide' if hide) } Other Labels
%ul.content-list.manage-labels-list.js-other-labels
= render partial: 'shared/label', subject: @project, collection: @labels, as: :label
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 8f6c085a361..562b6fb8d8c 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- breadcrumb_title "Labels"
- page_title "New Label"
= render "shared/mr_head"
@@ -6,4 +7,4 @@
%h3.page-title
New Label
%hr
- = render 'shared/labels/form', url: namespace_project_labels_path(@project.namespace.becomes(Namespace), @project), back_path: namespace_project_labels_path(@project.namespace, @project)
+ = render 'shared/labels/form', url: project_labels_path(@project), back_path: project_labels_path(@project)
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index aac74a25b75..243dcfdc187 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -13,4 +13,4 @@
and try again.
%hr
.clearfix
- = link_to 'Go back', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg pull-right'
+ = link_to 'Go back', edit_project_service_path(@project, @service), class: 'btn btn-lg pull-right'
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index 04bd4e8b683..20acd476f73 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -2,7 +2,7 @@
This service will be installed on the Mattermost instance at
%strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host
%hr
-= form_for(:mattermost, method: :post, url: namespace_project_mattermost_path(@project.namespace, @project), html: { class: 'js-requires-input'} ) do |f|
+= form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input'} ) do |f|
%h4 Team
%p
= @teams.one? ? 'The team' : 'Select the team'
@@ -33,7 +33,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
%p
Reserved:
= link_to 'https://docs.mattermost.com/help/messaging/executing-commands.html#built-in-commands', target: '__blank' do
@@ -42,5 +42,5 @@
%hr
.clearfix
.pull-right
- = link_to 'Cancel', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg'
+ = link_to 'Cancel', edit_project_service_path(@project, @service), class: 'btn btn-lg'
= f.submit 'Install', class: 'btn btn-save btn-lg'
diff --git a/app/views/projects/merge_requests/show/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
index 11793919ff7..11793919ff7 100644
--- a/app/views/projects/merge_requests/show/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 15b5a51c1d0..3303aa72604 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -4,8 +4,8 @@
= 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-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 }}
+ %comment-and-resolve-btn{ "inline-template" => true }
+ %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 }}
-#notes= render "projects/notes/notes_with_form"
+#notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_head.html.haml b/app/views/projects/merge_requests/_head.html.haml
index b7f73fe5339..1e505222887 100644
--- a/app/views/projects/merge_requests/_head.html.haml
+++ b/app/views/projects/merge_requests/_head.html.haml
@@ -4,18 +4,18 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
= nav_link(controller: :merge_requests) do
- = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
+ = link_to project_merge_requests_path(@project), title: 'Merge Requests' do
%span
List
- if project_nav_tab? :labels
= nav_link(controller: :labels) do
- = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do
+ = link_to project_labels_path(@project), title: 'Labels' do
%span
Labels
- if project_nav_tab? :milestones
= nav_link(controller: :milestones) do
- = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do
+ = link_to project_milestones_path(@project), title: 'Milestones' do
%span
Milestones
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
new file mode 100644
index 00000000000..917ec7fdbda
--- /dev/null
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -0,0 +1,55 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('how_to_merge')
+
+#modal_merge_info.modal
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %a.close{ href: "#", "data-dismiss" => "modal" } ×
+ %h3 Check out, review, and merge locally
+ .modal-body
+ %p
+ %strong Step 1.
+ Fetch and check out the branch for this merge request
+ = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-1
+ - if @merge_request.for_fork?
+ :preserve
+ git fetch #{h default_url_to_repo(@merge_request.source_project)} #{h @merge_request.source_branch}
+ git checkout -b #{h @merge_request.source_project_path}-#{h @merge_request.source_branch} FETCH_HEAD
+ - else
+ :preserve
+ git fetch origin
+ git checkout -b #{h @merge_request.source_branch} origin/#{h @merge_request.source_branch}
+ %p
+ %strong Step 2.
+ Review the changes locally
+
+ %p
+ %strong Step 3.
+ Merge the branch and fix any conflicts that come up
+ = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-3
+ - if @merge_request.for_fork?
+ :preserve
+ git checkout #{h @merge_request.target_branch}
+ git merge --no-ff #{h @merge_request.source_project_path}-#{h @merge_request.source_branch}
+ - else
+ :preserve
+ git checkout #{h @merge_request.target_branch}
+ git merge --no-ff #{h @merge_request.source_branch}
+ %p
+ %strong Step 4.
+ Push the result of the merge to GitLab
+ = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
+ %pre.dark#merge-info-4
+ :preserve
+ git push origin #{h @merge_request.target_branch}
+ - unless @merge_request.can_be_merged_by?(current_user)
+ %p
+ Note that pushing to GitLab requires write access to this repository.
+ %p
+ %strong Tip:
+ = succeed '.' do
+ You can also checkout merge requests locally by
+ = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 11b7aaec704..0a1ebcb8124 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -1,61 +1,63 @@
%li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } }
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue"
.issue-info-container
- .merge-request-title.title
- %span.merge-request-title-text
- = link_to merge_request.title, merge_request_path(merge_request)
+ .issue-main-info
+ .merge-request-title.title
+ %span.merge-request-title-text
+ = link_to merge_request.title, merge_request_path(merge_request)
+ - if merge_request.tasks?
+ %span.task-status.hidden-xs
+ &nbsp;
+ = merge_request.task_status
+
+ .issuable-info
+ %span.issuable-reference
+ #{issuable_reference(merge_request)}
+ %span.issuable-authored.hidden-xs
+ &middot;
+ opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
+ by #{link_to_member(@project, merge_request.author, avatar: false)}
+ - if merge_request.milestone
+ %span.issuable-milestone.hidden-xs
+ &nbsp;
+ = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title) do
+ = icon('clock-o')
+ = merge_request.milestone.title
+ - if merge_request.target_project.default_branch != merge_request.target_branch
+ %span.project-ref-path
+ &nbsp;
+ = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
+ = icon('code-fork')
+ = merge_request.target_branch
+ - if merge_request.labels.any?
+ &nbsp;
+ - merge_request.labels.each do |label|
+ = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
+
+ .issuable-meta
%ul.controls
- if merge_request.merged?
- %li
+ %li.issuable-status.hidden-xs
MERGED
- elsif merge_request.closed?
- %li
+ %li.issuable-status.hidden-xs
= icon('ban')
CLOSED
-
- if merge_request.head_pipeline
- %li
+ %li.issuable-pipeline-status.hidden-xs
= render_pipeline_status(merge_request.head_pipeline)
-
- if merge_request.open? && merge_request.broken?
- %li
+ %li.issuable-pipeline-broken.hidden-xs
= link_to merge_request_path(merge_request), class: "has-tooltip", title: "Cannot be merged automatically", data: { container: 'body' } do
= icon('exclamation-triangle')
-
- if merge_request.assignee
%li
= link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: "Assigned to :name")
= render 'shared/issuable_meta_data', issuable: merge_request
- .merge-request-info
- #{issuable_reference(merge_request)} &middot;
- opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
- by #{link_to_member(@project, merge_request.author, avatar: false)}
- - if merge_request.target_project.default_branch != merge_request.target_branch
- &nbsp;
- = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do
- = icon('code-fork')
- = merge_request.target_branch
-
- - if merge_request.milestone
- &nbsp;
- = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do
- = icon('clock-o')
- = merge_request.milestone.title
-
- - if merge_request.labels.any?
- &nbsp;
- - merge_request.labels.each do |label|
- = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
-
- - if merge_request.tasks?
- &nbsp;
- %span.task-status
- = merge_request.task_status
-
- .pull-right.hidden-xs
+ .pull-right.issuable-updated-at.hidden-xs
%span updated #{time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago')}
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
new file mode 100644
index 00000000000..8a390cf8700
--- /dev/null
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -0,0 +1,13 @@
+.detail-page-description.content-block
+ %h2.title
+ = markdown_field(@merge_request, :title)
+
+ %div
+ - if @merge_request.description.present?
+ .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
+ .wiki
+ = markdown_field(@merge_request, :description)
+ %textarea.hidden.js-task-list-field
+ = @merge_request.description
+
+ = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
new file mode 100644
index 00000000000..a2e819fb3a7
--- /dev/null
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -0,0 +1,40 @@
+- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
+
+- if @merge_request.closed_without_fork?
+ .alert.alert-danger
+ %p The source project of this merge request has been removed.
+
+.clearfix.detail-page-header
+ .issuable-header
+ .issuable-status-box.status-box{ class: status_box_class(@merge_request) }
+ = icon(@merge_request.state_icon_name, class: "hidden-sm hidden-md hidden-lg")
+ %span.hidden-xs
+ = @merge_request.state_human_name
+
+ %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ = icon('angle-double-left')
+
+ .issuable-meta
+ = issuable_meta(@merge_request, @project, "Merge request")
+
+ .issuable-actions
+ .clearfix.issue-btn-group.dropdown
+ %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
+ Options
+ = icon('caret-down')
+ .dropdown-menu.dropdown-menu-align-right.hidden-lg
+ %ul
+ - if can_update_merge_request
+ %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'issuable-edit'
+ - unless current_user == @merge_request.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
+ - if can_update_merge_request
+ %li{ class: merge_request_button_visibility(@merge_request, true) }
+ = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
+ %li{ class: merge_request_button_visibility(@merge_request, false) }
+ = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
+
+ - if can_update_merge_request
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit"
+
+ = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
new file mode 100644
index 00000000000..e73dab8ad4a
--- /dev/null
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -0,0 +1,5 @@
+- if @can_bulk_update
+ = button_tag "Edit merge requests", class: "btn append-right-10 js-bulk-update-toggle"
+- if merge_project
+ = link_to new_merge_request_path, class: "btn btn-new", title: "New merge request" do
+ New merge request
diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml
deleted file mode 100644
index 8d134aaac67..00000000000
--- a/app/views/projects/merge_requests/_new_compare.html.haml
+++ /dev/null
@@ -1,75 +0,0 @@
-%h3.page-title
- New Merge Request
-
-= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: new_namespace_project_merge_request_path(@project.namespace, @project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f|
- .hide.alert.alert-danger.mr-compare-errors
- .merge-request-branches.row
- .col-md-6
- .panel.panel-default.panel-new-merge-request
- .panel-heading
- Source branch
- .panel-body.clearfix
- .merge-request-select.dropdown
- = f.hidden_field :source_project_id
- = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-source-project
- = dropdown_title("Select source project")
- = dropdown_filter("Search projects")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/project',
- projects: [@merge_request.source_project],
- selected: f.object.source_project_id
- .merge-request-select.dropdown
- = f.hidden_field :source_branch
- = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch
- = dropdown_title("Select source branch")
- = dropdown_filter("Search branches")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.source_branches,
- selected: f.object.source_branch
- .panel-footer
- .text-center= icon('spinner spin', class: 'js-source-loading')
- %ul.list-unstyled.mr_source_commit
-
- .col-md-6
- .panel.panel-default.panel-new-merge-request
- .panel-heading
- Target branch
- .panel-body.clearfix
- - projects = @project.forked_from_project.nil? ? [@project] : [@project, @project.forked_from_project]
- .merge-request-select.dropdown
- = f.hidden_field :target_project_id
- = dropdown_toggle f.object.target_project.path_with_namespace, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-target-project
- = dropdown_title("Select target project")
- = dropdown_filter("Search projects")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/project',
- projects: projects,
- selected: f.object.target_project_id
- .merge-request-select.dropdown
- = f.hidden_field :target_branch
- = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown
- = dropdown_title("Select target branch")
- = dropdown_filter("Search branches")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.target_branches,
- selected: f.object.target_branch
- .panel-footer
- .text-center= icon('spinner spin', class: "js-target-loading")
- %ul.list-unstyled.mr_target_commit
-
- - if @merge_request.errors.any?
- = form_errors(@merge_request)
- = f.submit 'Compare branches and continue', class: "btn btn-new mr-compare-btn"
-
-:javascript
- new Compare({
- targetProjectUrl: "#{update_branches_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}",
- sourceBranchUrl: "#{branch_from_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}",
- targetBranchUrl: "#{branch_to_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}"
- });
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
deleted file mode 100644
index da79ca2ee75..00000000000
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ /dev/null
@@ -1,58 +0,0 @@
-%h3.page-title
- New Merge Request
-%p.slead
- - source_title, target_title = format_mr_branch_names(@merge_request)
- From
- %strong.label-branch= source_title
- %span into
- %strong.label-branch= target_title
-
- %span.pull-right
- = link_to 'Change branches', mr_change_branches_path(@merge_request)
-%hr
-= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal common-note-form js-requires-input js-quick-submit' } do |f|
- = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits
- = f.hidden_field :source_project_id
- = f.hidden_field :source_branch
- = f.hidden_field :target_project_id
- = f.hidden_field :target_branch
-
-.mr-compare.merge-request
- - if @commits.empty?
- .commits-empty
- %h4
- There are no commits yet.
- = custom_icon ('illustration_no_commits')
- - else
- %ul.merge-request-tabs.nav-links.no-top.no-bottom
- %li.commits-tab.active
- = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
- Commits
- %span.badge= @commits.size
- - if @pipelines.any?
- %li.builds-tab
- = link_to url_for(params), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
- Pipelines
- %span.badge= @pipelines.size
- %li.diffs-tab
- = link_to url_for(params.merge(action: 'new_diffs')), data: {target: 'div#diffs', action: 'new/diffs', toggle: 'tab'} do
- Changes
- %span.badge= @merge_request.diff_size
-
- .tab-content
- #commits.commits.tab-pane.active
- = render "projects/merge_requests/show/commits"
- #diffs.diffs.tab-pane
- -# This tab is always loaded via AJAX
- - if @pipelines.any?
- #pipelines.pipelines.tab-pane
- = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)), disable_initialization: true
-
- .mr-loading-status
- = spinner
-
-:javascript
- var merge_request = new MergeRequest({
- action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}",
- setUrl: false,
- });
diff --git a/app/views/projects/merge_requests/_pipelines.html.haml b/app/views/projects/merge_requests/_pipelines.html.haml
new file mode 100644
index 00000000000..473b7b919c8
--- /dev/null
+++ b/app/views/projects/merge_requests/_pipelines.html.haml
@@ -0,0 +1,4 @@
+- endpoint_path = local_assigns[:endpoint] || pipelines_project_merge_request_path(@project, @merge_request, format: :json)
+- disable_initialization = local_assigns.fetch(:disable_initialization, false)
+
+= render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
deleted file mode 100644
index 881ee9fd596..00000000000
--- a/app/views/projects/merge_requests/_show.html.haml
+++ /dev/null
@@ -1,121 +0,0 @@
-- @content_class = "limit-container-width" unless fluid_layout
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
-- page_description @merge_request.description
-- page_card_attributes @merge_request.card_attributes
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('diff_notes')
-
-.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) }
- = render "projects/merge_requests/show/mr_title"
-
- .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
- = render "projects/merge_requests/show/mr_box"
- .append-bottom-default.mr-source-target.prepend-top-default
- - if @merge_request.open?
- .pull-right
- - if @merge_request.source_branch_exists?
- - if koding_enabled? && @repository.koding_yml
- = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do
- Run in IDE (Koding)
- = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do
- Check out branch
-
- %span.dropdown.inline.prepend-left-5
- %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} }
- Download as
- = icon('caret-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch)
- %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff)
- .normal
- %span <b>Request to merge</b>
- %span.label-branch= source_branch_with_namespace(@merge_request)
- %span <b>into</b>
- %span.label-branch
- = link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch)
- - if @merge_request.open? && @merge_request.diverged_from_target_branch?
- %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind)
-
- - if @merge_request.source_branch_exists?
- = render "projects/merge_requests/show/how_to_merge"
-
- = render "projects/merge_requests/widget/show.html.haml"
-
- - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user)
- .merge-manually.light.prepend-top-default
- You can also accept this merge request manually using the
- = succeed '.' do
- = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal"
-
- .content-block.content-block-small.emoji-list-container
- = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
-
- .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
- .merge-request-tabs-container.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- %ul.merge-request-tabs.nav-links.scrolling-tabs
- %li.notes-tab
- = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do
- Discussion
- %span.badge= @merge_request.related_notes.user.count
- - if @merge_request.source_project
- %li.commits-tab
- = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
- Commits
- %span.badge= @commits_count
- - if @pipelines.any?
- %li.pipelines-tab
- = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
- Pipelines
- %span.badge= @pipelines.size
- %li.diffs-tab
- = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
- Changes
- %span.badge= @merge_request.diff_size
- %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
- %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
- %div
- .line-resolve-all{ "v-show" => "discussionCount > 0",
- ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
- %span.line-resolve-btn.is-disabled{ type: "button",
- ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
- = render "shared/icons/icon_status_success.svg"
- %span.line-resolve-text
- {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
- = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
- = render "discussions/jump_to_next"
-
- .tab-content#diff-notes-app
- #notes.notes.tab-pane.voting_notes
- .row
- %section.col-md-12
- .issuable-discussion
- = render "projects/merge_requests/discussion"
-
- #commits.commits.tab-pane
- -# This tab is always loaded via AJAX
- #pipelines.pipelines.tab-pane
- - if @pipelines.any?
- = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- #diffs.diffs.tab-pane
- -# This tab is always loaded via AJAX
-
- .mr-loading-status
- = spinner
-
-= render 'shared/issuable/sidebar', issuable: @merge_request
-- if @merge_request.can_be_reverted?(current_user)
- = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
-- if @merge_request.can_be_cherry_picked?
- = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
-
-:javascript
- $(function () {
- new MergeRequest({
- action: "#{controller.action_name}"
- });
- });
-
- var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}";
diff --git a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml
deleted file mode 100644
index eab5be488b5..00000000000
--- a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-:plain
- $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}");
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index 51d59280be8..454bc359b6b 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -3,15 +3,15 @@
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
-= render "projects/merge_requests/show/mr_title"
+= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details
- = render "projects/merge_requests/show/mr_box"
+ = render "projects/merge_requests/mr_box"
= render 'shared/issuable/sidebar', issuable: @merge_request
-#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
- resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
+#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
.loading{ "v-if" => "isLoading" }
%i.fa.fa-spinner.fa-spin
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index 62c9748c510..13026b7566a 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -1,7 +1,7 @@
.form-horizontal.resolve-conflicts-form
.form-group
%label.col-sm-2.control-label{ "for" => "commit-message" }
- Commit message
+ #{ _('Commit message') }
.col-sm-10
.commit-message-container
.max-width-marker
@@ -13,4 +13,4 @@
%button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
%span {{commitButtonText}}
.col-xs-6.text-right
- = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
+ = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "btn btn-cancel"
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
new file mode 100644
index 00000000000..454bc359b6b
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -0,0 +1,38 @@
+- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('merge_conflicts')
+ = page_specific_javascript_tag('lib/ace.js')
+= render "projects/merge_requests/mr_title"
+
+.merge-request-details.issuable-details
+ = render "projects/merge_requests/mr_box"
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+
+#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
+ .loading{ "v-if" => "isLoading" }
+ %i.fa.fa-spinner.fa-spin
+
+ .nothing-here-block{ "v-if" => "hasError" }
+ {{conflictsData.errorMessage}}
+
+ = render partial: "projects/merge_requests/conflicts/commit_stats"
+
+ .files-wrapper{ "v-if" => "!isLoading && !hasError" }
+ .files
+ .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" }
+ .js-file-title.file-title
+ %i.fa.fa-fw{ ":class" => "file.iconClass" }
+ %strong {{file.filePath}}
+ = render partial: 'projects/merge_requests/conflicts/file_actions'
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
+ .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ %parallel-conflict-lines{ ":file" => "file" }
+ %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
+ = render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
+
+ = render partial: "projects/merge_requests/conflicts/submit_form"
diff --git a/app/views/projects/merge_requests/_new_diffs.html.haml b/app/views/projects/merge_requests/creations/_diffs.html.haml
index 627fc4e9671..627fc4e9671 100644
--- a/app/views/projects/merge_requests/_new_diffs.html.haml
+++ b/app/views/projects/merge_requests/creations/_diffs.html.haml
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
new file mode 100644
index 00000000000..9d5cebdda53
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -0,0 +1,68 @@
+%h3.page-title
+ New Merge Request
+
+= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f|
+ .hide.alert.alert-danger.mr-compare-errors
+ .merge-request-branches.js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
+ .col-md-6
+ .panel.panel-default.panel-new-merge-request
+ .panel-heading
+ Source branch
+ .panel-body.clearfix
+ .merge-request-select.dropdown
+ = f.hidden_field :source_project_id
+ = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-source-project
+ = dropdown_title("Select source project")
+ = dropdown_filter("Search projects")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/project',
+ projects: [@merge_request.source_project],
+ selected: f.object.source_project_id
+ .merge-request-select.dropdown
+ = f.hidden_field :source_branch
+ = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown
+ = dropdown_title("Select source branch")
+ = dropdown_filter("Search branches")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/branch',
+ branches: @merge_request.source_branches,
+ selected: f.object.source_branch
+ .panel-footer
+ .text-center= icon('spinner spin', class: 'js-source-loading')
+ %ul.list-unstyled.mr_source_commit
+
+ .col-md-6
+ .panel.panel-default.panel-new-merge-request
+ .panel-heading
+ Target branch
+ .panel-body.clearfix
+ - projects = target_projects(@project)
+ .merge-request-select.dropdown
+ = f.hidden_field :target_project_id
+ = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-target-project
+ = dropdown_title("Select target project")
+ = dropdown_filter("Search projects")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/project',
+ projects: projects,
+ selected: f.object.target_project_id
+ .merge-request-select.dropdown
+ = f.hidden_field :target_branch
+ = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown
+ = dropdown_title("Select target branch")
+ = dropdown_filter("Search branches")
+ = dropdown_content do
+ = render 'projects/merge_requests/dropdowns/branch',
+ branches: @merge_request.target_branches,
+ selected: f.object.target_branch
+ .panel-footer
+ .text-center= icon('spinner spin', class: "js-target-loading")
+ %ul.list-unstyled.mr_target_commit
+
+ - if @merge_request.errors.any?
+ = form_errors(@merge_request)
+ = f.submit 'Compare branches and continue', class: "btn btn-new mr-compare-btn"
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
new file mode 100644
index 00000000000..4b5fa28078a
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -0,0 +1,52 @@
+%h3.page-title
+ New Merge Request
+%p.slead
+ - source_title, target_title = format_mr_branch_names(@merge_request)
+ From
+ %strong.ref-name= source_title
+ %span into
+ %strong.ref-name= target_title
+
+ %span.pull-right
+ = link_to 'Change branches', mr_change_branches_path(@merge_request)
+%hr
+= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal common-note-form js-requires-input js-quick-submit' } do |f|
+ = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits
+ = f.hidden_field :source_project_id
+ = f.hidden_field :source_branch
+ = f.hidden_field :target_project_id
+ = f.hidden_field :target_branch
+
+.mr-compare.merge-request.js-merge-request-new-submit{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" }
+ - if @commits.empty?
+ .commits-empty
+ %h4
+ There are no commits yet.
+ = custom_icon ('illustration_no_commits')
+ - else
+ %ul.merge-request-tabs.nav-links.no-top.no-bottom
+ %li.commits-tab.active
+ = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
+ Commits
+ %span.badge= @commits.size
+ - if @pipelines.any?
+ %li.builds-tab
+ = link_to url_for(params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
+ Pipelines
+ %span.badge= @pipelines.size
+ %li.diffs-tab
+ = link_to url_for(params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
+ Changes
+ %span.badge= @merge_request.diff_size
+
+ .tab-content
+ #commits.commits.tab-pane.active
+ = render "projects/merge_requests/commits"
+ #diffs.diffs.tab-pane
+ -# This tab is always loaded via AJAX
+ - if @pipelines.any?
+ #pipelines.pipelines.tab-pane
+ = render 'projects/merge_requests/pipelines', endpoint: url_for(params.merge(action: 'pipelines', format: :json)), disable_initialization: true
+
+ .mr-loading-status
+ = spinner
diff --git a/app/views/projects/merge_requests/branch_from.html.haml b/app/views/projects/merge_requests/creations/branch_from.html.haml
index 3837c4b388d..3837c4b388d 100644
--- a/app/views/projects/merge_requests/branch_from.html.haml
+++ b/app/views/projects/merge_requests/creations/branch_from.html.haml
diff --git a/app/views/projects/merge_requests/branch_to.html.haml b/app/views/projects/merge_requests/creations/branch_to.html.haml
index d69b71790a0..d69b71790a0 100644
--- a/app/views/projects/merge_requests/branch_to.html.haml
+++ b/app/views/projects/merge_requests/creations/branch_to.html.haml
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
new file mode 100644
index 00000000000..3220512d60d
--- /dev/null
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -0,0 +1,7 @@
+- breadcrumb_title "Merge Requests"
+- page_title "New Merge Request"
+
+- if @merge_request.can_be_created && !params[:change_branches]
+ = render 'new_submit'
+- else
+ = render 'new_compare'
diff --git a/app/views/projects/merge_requests/update_branches.html.haml b/app/views/projects/merge_requests/creations/update_branches.html.haml
index 64482973a89..64482973a89 100644
--- a/app/views/projects/merge_requests/update_branches.html.haml
+++ b/app/views/projects/merge_requests/creations/update_branches.html.haml
diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/projects/merge_requests/diffs.html.haml
deleted file mode 100644
index 2a5b8b1441e..00000000000
--- a/app/views/projects/merge_requests/diffs.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render "show"
diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml
new file mode 100644
index 00000000000..0d30d6da68f
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml
@@ -0,0 +1,5 @@
+- if @merge_request_diff.collected? || @merge_request_diff.overflow?
+ = render 'projects/merge_requests/diffs/versions'
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true
+- elsif @merge_request_diff.empty?
+ .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
diff --git a/app/views/projects/merge_requests/diffs/_versions.html.haml b/app/views/projects/merge_requests/diffs/_versions.html.haml
new file mode 100644
index 00000000000..9f7152b9824
--- /dev/null
+++ b/app/views/projects/merge_requests/diffs/_versions.html.haml
@@ -0,0 +1,97 @@
+- if @merge_request_diffs.size > 1
+ .mr-version-controls
+ .mr-version-menus-container.content-block
+ Changes between
+ %span.dropdown.inline.mr-version-dropdown
+ %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} }
+ %span
+ - if @merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(@merge_request_diff)}
+ = icon('caret-down')
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Version:
+ %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
+ = icon('times', class: 'dropdown-menu-close-icon')
+ .dropdown-content
+ %ul
+ - @merge_request_diffs.each do |merge_request_diff|
+ %li
+ = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do
+ %div
+ %strong
+ - if merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(merge_request_diff)}
+ %div
+ %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
+ %div
+ %small
+ #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
+ = time_ago_with_tooltip(merge_request_diff.created_at)
+
+ - if @merge_request_diff.base_commit_sha
+ and
+ %span.dropdown.inline.mr-version-compare-dropdown
+ %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} }
+ - if @start_version
+ version #{version_index(@start_version)}
+ - else
+ %span.ref-name= @merge_request.target_branch
+ = icon('caret-down')
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Compared with:
+ %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
+ = icon('times', class: 'dropdown-menu-close-icon')
+ .dropdown-content
+ %ul
+ - @comparable_diffs.each do |merge_request_diff|
+ %li
+ = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do
+ %div
+ %strong
+ - if merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(merge_request_diff)}
+ %div
+ %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
+ %div
+ %small
+ = time_ago_with_tooltip(merge_request_diff.created_at)
+ %li
+ = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do
+ %div
+ %strong
+ %span.ref-name= @merge_request.target_branch
+ (base)
+ %div
+ %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha)
+
+ - if different_base?(@start_version, @merge_request_diff)
+ .content-block
+ = icon('info-circle')
+ Selected versions have different base commits.
+ Changes will include
+ = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
+ new commits
+ from
+ = succeed '.' do
+ %code= @merge_request.target_branch
+
+ - if @start_version || !@merge_request_diff.latest?
+ .comments-disabled-notif.content-block
+ = icon('info-circle')
+ Not all comments are displayed because you're
+ - if @start_version
+ comparing two versions
+ - else
+ viewing an old version
+ of the diff.
+
+ .pull-right
+ = link_to 'Show latest version', diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm'
diff --git a/app/views/projects/merge_requests/dropdowns/_project.html.haml b/app/views/projects/merge_requests/dropdowns/_project.html.haml
index 25d5dc92f8a..aaf1ab00eeb 100644
--- a/app/views/projects/merge_requests/dropdowns/_project.html.haml
+++ b/app/views/projects/merge_requests/dropdowns/_project.html.haml
@@ -2,4 +2,4 @@
- projects.each do |project|
%li
%a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id } }
- = project.path_with_namespace
+ = project.full_path
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 6bf0035e051..c020e7db380 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -1,28 +1,36 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_merge_request, @project)
+- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
+- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+- new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project
- page_title "Merge Requests"
-- unless @project.default_issues_tracker?
+- unless @project.issues_enabled?
= content_for :sub_nav do
= render "projects/merge_requests/head"
-= render 'projects/last_push'
- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('filtered_search')
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'filtered_search'
+
+- if show_new_nav?
+ - content_for :breadcrumbs_extra do
+ = render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
+
+= render 'projects/last_push'
- if @project.merge_requests.exists?
%div{ class: container_class }
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- .nav-controls
- - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- - if merge_project
- = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do
- New merge request
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
+ = render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'shared/issuable/search_bar', type: :merge_requests
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :merge_requests
+
.merge-requests-holder
= render 'merge_requests'
- else
- = render 'shared/empty_states/merge_requests', button_path: new_namespace_project_merge_request_path(@project.namespace, @project)
+ = render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index a00d3128ffe..6df19d6438b 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,8 +1,8 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
.merge-request
- = render "projects/merge_requests/show/mr_title"
- = render "projects/merge_requests/show/mr_box"
+ = render "projects/merge_requests/mr_title"
+ = render "projects/merge_requests/mr_box"
.alert.alert-danger
%p
diff --git a/app/views/projects/merge_requests/merge.js.haml b/app/views/projects/merge_requests/merge.js.haml
deleted file mode 100644
index e632fc681cf..00000000000
--- a/app/views/projects/merge_requests/merge.js.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- case @status
-- when :success
- - remove_source_branch = params[:should_remove_source_branch] == '1' || @merge_request.remove_source_branch?
- :plain
- merge_request_widget.mergeInProgress(#{remove_source_branch});
-- when :merge_when_pipeline_succeeds
- :plain
- $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}");
-- when :sha_mismatch
- :plain
- $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}");
-- else
- :plain
- $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}");
diff --git a/app/views/projects/merge_requests/new.html.haml b/app/views/projects/merge_requests/new.html.haml
deleted file mode 100644
index 2e798ce780a..00000000000
--- a/app/views/projects/merge_requests/new.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- page_title "New Merge Request"
-
-- if @merge_request.can_be_created && !params[:change_branches]
- = render 'new_submit'
-- else
- = render 'new_compare'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 2a5b8b1441e..d27e121beb4 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1 +1,91 @@
-= render "show"
+- @content_class = "limit-container-width" unless fluid_layout
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
+- page_description @merge_request.description
+- page_card_attributes @merge_request.card_attributes
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('common_vue')
+ = webpack_bundle_tag('diff_notes')
+
+.merge-request{ 'data-mr-action': "#{j params[:tab].presence || 'show'}", 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) }
+ = render "projects/merge_requests/mr_title"
+
+ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
+ = render "projects/merge_requests/mr_box"
+
+ - if @merge_request.source_branch_exists?
+ = render "projects/merge_requests/how_to_merge"
+
+ -# haml-lint:disable InlineJavaScript
+ :javascript
+ window.gl = window.gl || {};
+ window.gl.mrWidgetData = #{serialize_issuable(@merge_request)}
+
+ #js-vue-mr-widget.mr-widget
+
+ - content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'vue_merge_request_widget'
+
+ .content-block.content-block-small.emoji-list-container
+ = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
+
+ .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
+ .merge-request-tabs-container
+ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ .nav-links.scrolling-tabs
+ %ul.merge-request-tabs
+ %li.notes-tab
+ = link_to project_merge_request_path(@project, @merge_request), data: { target: 'div#notes', action: 'show', toggle: 'tab' } do
+ Discussion
+ %span.badge= @merge_request.related_notes.user.count
+ - if @merge_request.source_project
+ %li.commits-tab
+ = link_to commits_project_merge_request_path(@project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
+ Commits
+ %span.badge= @commits_count
+ - if @pipelines.any?
+ %li.pipelines-tab
+ = link_to pipelines_project_merge_request_path(@project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
+ Pipelines
+ %span.badge.js-pipelines-mr-count= @pipelines.size
+ %li.diffs-tab
+ = link_to diffs_project_merge_request_path(@project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
+ Changes
+ %span.badge= @merge_request.diff_size
+ #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
+ %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
+ %div
+ .line-resolve-all{ "v-show" => "discussionCount > 0",
+ ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
+ %span.line-resolve-btn.is-disabled{ type: "button",
+ ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
+ = render "shared/icons/icon_status_success.svg"
+ %span.line-resolve-text
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
+ = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
+ = render "discussions/jump_to_next"
+
+ .tab-content#diff-notes-app
+ #notes.notes.tab-pane.voting_notes
+ .row
+ %section.col-md-12
+ .issuable-discussion
+ = render "projects/merge_requests/discussion"
+
+ #commits.commits.tab-pane
+ -# This tab is always loaded via AJAX
+ #pipelines.pipelines.tab-pane
+ - if @pipelines.any?
+ = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
+ #diffs.diffs.tab-pane
+ -# This tab is always loaded via AJAX
+
+ .mr-loading-status
+ = spinner
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+- if @merge_request.can_be_reverted?(current_user)
+ = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
+- if @merge_request.can_be_cherry_picked?
+ = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml
deleted file mode 100644
index 7f0913ea516..00000000000
--- a/app/views/projects/merge_requests/show/_diffs.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- if @merge_request_diff.collected? || @merge_request_diff.overflow?
- = render 'projects/merge_requests/show/versions'
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment
-- elsif @merge_request_diff.empty?
- .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml
deleted file mode 100644
index f3372c7657f..00000000000
--- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml
+++ /dev/null
@@ -1,63 +0,0 @@
-#modal_merge_info.modal
- .modal-dialog
- .modal-content
- .modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3 Check out, review, and merge locally
- .modal-body
- %p
- %strong Step 1.
- Fetch and check out the branch for this merge request
- = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
- %pre.dark#merge-info-1
- - if @merge_request.for_fork?
- :preserve
- git fetch #{h default_url_to_repo(@merge_request.source_project)} #{h @merge_request.source_branch}
- git checkout -b #{h @merge_request.source_project_path}-#{h @merge_request.source_branch} FETCH_HEAD
- - else
- :preserve
- git fetch origin
- git checkout -b #{h @merge_request.source_branch} origin/#{h @merge_request.source_branch}
- %p
- %strong Step 2.
- Review the changes locally
-
- %p
- %strong Step 3.
- Merge the branch and fix any conflicts that come up
- = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
- %pre.dark#merge-info-3
- - if @merge_request.for_fork?
- :preserve
- git checkout #{h @merge_request.target_branch}
- git merge --no-ff #{h @merge_request.source_project_path}-#{h @merge_request.source_branch}
- - else
- :preserve
- git checkout #{h @merge_request.target_branch}
- git merge --no-ff #{h @merge_request.source_branch}
- %p
- %strong Step 4.
- Push the result of the merge to GitLab
- = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
- %pre.dark#merge-info-4
- :preserve
- git push origin #{h @merge_request.target_branch}
- - unless @merge_request.can_be_merged_by?(current_user)
- %p
- Note that pushing to GitLab requires write access to this repository.
- %p
- %strong Tip:
- = succeed '.' do
- You can also checkout merge requests locally by
- = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
-
-:javascript
- $(function(){
- var modal = $('#modal_merge_info').modal({modal: true, show:false});
- $('.how_to_merge_link').bind("click", function(){
- modal.show();
- });
- $('.modal-header .close').bind("click", function(){
- modal.hide();
- })
- })
diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml
deleted file mode 100644
index 683cb8a5a27..00000000000
--- a/app/views/projects/merge_requests/show/_mr_box.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-.detail-page-description.content-block
- %h2.title
- = markdown_field(@merge_request, :title)
-
- %div
- - if @merge_request.description.present?
- .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
- .wiki
- = preserve do
- = markdown_field(@merge_request, :description)
- %textarea.hidden.js-task-list-field
- = @merge_request.description
-
- = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml
deleted file mode 100644
index e7c5bca6a37..00000000000
--- a/app/views/projects/merge_requests/show/_mr_title.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- if @merge_request.closed_without_fork?
- .alert.alert-danger
- %p The source project of this merge request has been removed.
-
-.clearfix.detail-page-header
- .issuable-header
- .issuable-status-box.status-box{ class: status_box_class(@merge_request) }
- = icon(@merge_request.state_icon_name, class: "hidden-sm hidden-md hidden-lg")
- %span.hidden-xs
- = @merge_request.state_human_name
-
- %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
- .issuable-meta
- = issuable_meta(@merge_request, @project, "Merge Request")
-
- - if can?(current_user, :update_merge_request, @merge_request)
- .issuable-actions
- .clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
- Options
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-align-right.hidden-lg
- %ul
- %li{ class: merge_request_button_visibility(@merge_request, true) }
- = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- %li{ class: merge_request_button_visibility(@merge_request, false) }
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
- %li
- = link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'issuable-edit'
- = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{merge_request_button_visibility(@merge_request, true)}", title: 'Close merge request'
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen reopen-mr-link #{merge_request_button_visibility(@merge_request, false)}", title: 'Reopen merge request'
- = link_to edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" do
- Edit
diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml
deleted file mode 100644
index 2f1dbe87619..00000000000
--- a/app/views/projects/merge_requests/show/_pipelines.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- endpoint_path = local_assigns[:endpoint] || pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, format: :json)
-- disable_initialization = local_assigns.fetch(:disable_initialization, false)
-
-= render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization
diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml
deleted file mode 100644
index 547be78992e..00000000000
--- a/app/views/projects/merge_requests/show/_versions.html.haml
+++ /dev/null
@@ -1,87 +0,0 @@
-- if @merge_request_diffs.size > 1
- .mr-version-controls
- .mr-version-menus-container.content-block
- Changes between
- %span.dropdown.inline.mr-version-dropdown
- %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} }
- %span
- - if @merge_request_diff.latest?
- latest version
- - else
- version #{version_index(@merge_request_diff)}
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Version:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @merge_request_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- .monospace= short_sha(merge_request_diff.head_commit_sha)
- %small
- #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
- = time_ago_with_tooltip(merge_request_diff.created_at)
-
- - if @merge_request_diff.base_commit_sha
- and
- %span.dropdown.inline.mr-version-compare-dropdown
- %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} }
- %span
- - if @start_sha
- version #{version_index(@start_version)}
- - else
- #{@merge_request.target_branch}
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Compared with:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @comparable_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- .monospace= short_sha(merge_request_diff.head_commit_sha)
- %small
- = time_ago_with_tooltip(merge_request_diff.created_at)
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do
- %strong
- #{@merge_request.target_branch} (base)
- .monospace= short_sha(@merge_request_diff.base_commit_sha)
-
- - if different_base?(@start_version, @merge_request_diff)
- .content-block
- = icon('info-circle')
- Selected versions have different base commits.
- Changes will include
- = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
- new commits
- from
- = succeed '.' do
- %code= @merge_request.target_branch
-
- - if @diff_notes_disabled
- .comments-disabled-notif.content-block
- = icon('info-circle')
- - if @start_sha
- Comments are disabled because you're comparing two versions of this merge request.
- - else
- Discussions on this version of the merge request are displayed but comment creation is disabled.
-
- .pull-right
- = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
diff --git a/app/views/projects/merge_requests/widget/_closed.html.haml b/app/views/projects/merge_requests/widget/_closed.html.haml
deleted file mode 100644
index 15f47ecf210..00000000000
--- a/app/views/projects/merge_requests/widget/_closed.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-.mr-state-widget
- = render 'projects/merge_requests/widget/heading'
- .mr-widget-body
- %h4
- Closed
- - if @merge_request.closed_event
- by #{link_to_member(@project, @merge_request.closed_event.author, avatar: true)}
- #{time_ago_with_tooltip(@merge_request.closed_event.created_at)}
- %p
- = succeed '.' do
- The changes were not merged into
- %span.label-branch= @merge_request.target_branch
diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml
new file mode 100644
index 00000000000..ad0ce7bf501
--- /dev/null
+++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml
@@ -0,0 +1,4 @@
+- if @merge_request.can_be_reverted?(current_user)
+ = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
+- if @merge_request.can_be_cherry_picked?
+ = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml
deleted file mode 100644
index 1298376ac25..00000000000
--- a/app/views/projects/merge_requests/widget/_heading.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-- if @pipeline
- .mr-widget-heading
- - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status|
- .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) }
- %div{ class: "ci-status-icon ci-status-icon-#{status}" }
- = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do
- = ci_icon_for_status(status)
- %span
- Pipeline
- = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline'
- = ci_label_for_status(status)
- - if @pipeline.stages.any?
- .mr-widget-pipeline-graph
- = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph'
- %span
- for
- = succeed "." do
- = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link"
- %span.ci-coverage
-
-- elsif @merge_request.has_ci?
- -# Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX
- -# TODO, remove in later versions when services like Jenkins will set CI status via Commit status API
- .mr-widget-heading
- - %w[success skipped canceled failed running pending].each do |status|
- .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: "display:none" }
- = ci_icon_for_status(status)
- %span
- CI job
- = ci_label_for_status(status)
- for
- - commit = @merge_request.diff_head_commit
- = succeed "." do
- = link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace"
- %span.ci-coverage
-
- .ci_widget
- = icon("spinner spin")
- Checking CI status for #{@merge_request.diff_head_commit.short_id}&hellip;
-
- .ci_widget.ci-not_found{ style: "display:none" }
- = icon("times-circle")
- Could not find CI status for #{@merge_request.diff_head_commit.short_id}.
-
- .ci_widget.ci-error{ style: "display:none" }
- = icon("times-circle")
- Could not connect to the CI server. Please check your settings and try again.
-
-.js-success-icon.hidden
- = ci_icon_for_status('success')
diff --git a/app/views/projects/merge_requests/widget/_locked.html.haml b/app/views/projects/merge_requests/widget/_locked.html.haml
deleted file mode 100644
index 78d0783cba0..00000000000
--- a/app/views/projects/merge_requests/widget/_locked.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-.mr-state-widget
- = render 'projects/merge_requests/widget/heading'
- .mr-widget-body
- %h4
- = icon("spinner spin")
- Merge in progress&hellip;
- %p
- This merge request is in the process of being merged, during which time it is locked and cannot be closed.
-
diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml
deleted file mode 100644
index adc3bbc37f3..00000000000
--- a/app/views/projects/merge_requests/widget/_merged.html.haml
+++ /dev/null
@@ -1,52 +0,0 @@
-.mr-state-widget
- = render 'projects/merge_requests/widget/heading'
- .mr-widget-body
- %h4
- Merged
- - if @merge_request.merge_event
- by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
- #{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- - if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
- .remove-message-pipes
- %ul
- %li
- %span
- The changes were merged into
- #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
- %li
- %span
- The source branch has been removed.
- = render 'projects/merge_requests/widget/merged_buttons'
- - elsif @merge_request.can_remove_source_branch?(current_user)
- .remove_source_branch_widget.remove-message-pipes
- %ul
- %li
- %span
- The changes were merged into
- #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
- %li
- %span
- You can remove the source branch now.
- = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true
- .remove_source_branch_widget.failed.remove-message-pipes.hide
- %ul
- %li
- %span
- Failed to remove source branch '#{@merge_request.source_branch}'.
- .remove_source_branch_in_progress.remove-message-pipes.hide
- %ul
- %li
- %span
- = icon('spinner spin')
- Removing source branch '#{@merge_request.source_branch}'.
- %li
- %span
- Please wait, this page will be automatically reloaded.
- - else
- .remove-message-pipes
- %ul
- %li
- %span
- The changes were merged into
- #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
- = render 'projects/merge_requests/widget/merged_buttons'
diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml
deleted file mode 100644
index a0f54bd28ec..00000000000
--- a/app/views/projects/merge_requests/widget/_merged_buttons.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- can_remove_source_branch = local_assigns.fetch(:source_branch_exists, false) && @merge_request.can_remove_source_branch?(current_user)
-- mr_can_be_reverted = @merge_request.can_be_reverted?(current_user)
-- mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked?
-
-- if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked
- .clearfix.merged-buttons
- - if can_remove_source_branch
- = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do
- = icon('trash-o')
- Remove source branch
- - if mr_can_be_reverted
- = revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close")
- - if mr_can_be_cherry_picked
- = cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default")
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
deleted file mode 100644
index 0872a1a0503..00000000000
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ /dev/null
@@ -1,49 +0,0 @@
-.mr-state-widget
- = render 'projects/merge_requests/widget/heading'
- .mr-widget-body
- -# After conflicts are resolved, the user is redirected back to the MR page.
- -# There is a short window before background workers run and GitLab processes
- -# the new push and commits, during which it will think the conflicts still exist.
- -# We send this param to get the widget to treat the MR as having no more conflicts.
- - resolved_conflicts = params[:resolved_conflicts]
-
- - if @project.archived?
- = render 'projects/merge_requests/widget/open/archived'
- - elsif @merge_request.branch_missing?
- = render 'projects/merge_requests/widget/open/missing_branch'
- - elsif @merge_request.has_no_commits?
- = render 'projects/merge_requests/widget/open/nothing'
- - elsif @merge_request.unchecked?
- = render 'projects/merge_requests/widget/open/check'
- - elsif @merge_request.cannot_be_merged? && !resolved_conflicts
- = render 'projects/merge_requests/widget/open/conflicts'
- - elsif @merge_request.work_in_progress?
- = render 'projects/merge_requests/widget/open/wip'
- - elsif @merge_request.merge_when_pipeline_succeeds? && @merge_request.merge_error.present?
- = render 'projects/merge_requests/widget/open/error'
- - elsif @merge_request.merge_when_pipeline_succeeds?
- = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds'
- - elsif !@merge_request.can_be_merged_by?(current_user)
- = render 'projects/merge_requests/widget/open/not_allowed'
- - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?)
- = render 'projects/merge_requests/widget/open/build_failed'
- - elsif !@merge_request.mergeable_discussions_state?
- = render 'projects/merge_requests/widget/open/unresolved_discussions'
- - elsif @pipeline&.blocked?
- = render 'projects/merge_requests/widget/open/manual'
- - elsif @merge_request.can_be_merged? || resolved_conflicts
- = render 'projects/merge_requests/widget/open/accept'
-
- - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present?
- .mr-widget-footer
- %span
- = icon('check')
- - if mr_closes_issues.present?
- Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)}
- = succeed '.' do
- != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author
- = mr_assign_issues_link
- - if mr_issues_mentioned_but_not_closing.present?
- #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)}
- != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author
- #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed.
diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml
deleted file mode 100644
index c716b69b35b..00000000000
--- a/app/views/projects/merge_requests/widget/_show.html.haml
+++ /dev/null
@@ -1,40 +0,0 @@
-- if @merge_request.open?
- = render 'projects/merge_requests/widget/open'
-- elsif @merge_request.merged?
- = render 'projects/merge_requests/widget/merged'
-- elsif @merge_request.closed?
- = render 'projects/merge_requests/widget/closed'
-- elsif @merge_request.locked?
- = render 'projects/merge_requests/widget/locked'
-
-:javascript
- var opts = {
- merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
- check_enable: #{@merge_request.unchecked? ? "true" : "false"},
- ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
- pipeline_status_url: "#{pipeline_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
- ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
- gitlab_icon: "#{asset_path 'gitlab_logo.png'}",
- ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}",
- ci_message: {
- normal: "Pipeline {{status}} for \"{{title}}\"",
- preparing: "{{status}} pipeline for \"{{title}}\""
- },
- ci_enable: #{@project.ci_service ? "true" : "false"},
- ci_title: {
- preparing: "{{status}} pipeline",
- normal: "Pipeline {{status}}"
- },
- ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}",
- ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json},
- commits_path: "#{project_commits_path(@project)}",
- pipeline_path: "#{project_pipelines_path(@project)}",
- pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
- };
-
- if (typeof merge_request_widget !== 'undefined') {
- merge_request_widget.cancelPolling();
- merge_request_widget.clearEventListeners();
- }
-
- merge_request_widget = new window.gl.MergeRequestWidget(opts);
diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml
deleted file mode 100644
index 4cbd22150c7..00000000000
--- a/app/views/projects/merge_requests/widget/open/_accept.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('merge_request_widget')
-
-= form_for [:merge, @project.namespace.becomes(Namespace), @project, @merge_request], remote: true, method: :post, html: { class: 'accept-mr-form js-quick-submit js-requires-input' } do |f|
- = hidden_field_tag :authenticity_token, form_authenticity_token
- = hidden_field_tag :sha, @merge_request.diff_head_sha
- .accept-merge-holder.clearfix.js-toggle-container
- .clearfix
- .accept-action
- - if @pipeline && @pipeline.active?
- %span.btn-group
- = button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do
- Merge when pipeline succeeds
- - unless @project.only_allow_merge_if_pipeline_succeeds?
- = button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do
- = icon('caret-down')
- %span.sr-only
- Select merge moment
- %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- %li
- = link_to "#", class: "merge-when-pipeline-succeeds" do
- = icon('check fw')
- Merge when pipeline succeeds
- %li
- = link_to "#", class: "accept-merge-request" do
- = icon('warning fw')
- Merge immediately
- - else
- = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do
- Accept merge request
- - if @merge_request.force_remove_source_branch?
- .accept-control
- The source branch will be removed.
- - elsif @merge_request.can_remove_source_branch?(current_user)
- .accept-control.checkbox
- = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do
- = check_box_tag :should_remove_source_branch
- Remove source branch
- .accept-control
- %button.modify-merge-commit-link.js-toggle-button{ type: "button" }
- = icon('edit')
- Modify commit message
- .js-toggle-content.hide.prepend-top-default
- = render 'shared/commit_message_container', params: params,
- message_with_description: @merge_request.merge_commit_message(include_description: true),
- message_without_description: @merge_request.merge_commit_message,
- text: @merge_request.merge_commit_message,
- rows: 14, hint: true
-
- = hidden_field_tag :merge_when_pipeline_succeeds, "", autocomplete: "off"
diff --git a/app/views/projects/merge_requests/widget/open/_archived.html.haml b/app/views/projects/merge_requests/widget/open/_archived.html.haml
deleted file mode 100644
index 0d61e56d8fb..00000000000
--- a/app/views/projects/merge_requests/widget/open/_archived.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-%h4
- Project is archived
-%p
- This merge request cannot be merged because archived projects cannot be written to.
diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml
deleted file mode 100644
index 3979d5fa8ed..00000000000
--- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%h4
- = icon('exclamation-triangle')
- The pipeline for this merge request failed
-
-%p
- Please retry the job or push a new commit to fix the failure.
diff --git a/app/views/projects/merge_requests/widget/open/_check.html.haml b/app/views/projects/merge_requests/widget/open/_check.html.haml
deleted file mode 100644
index 909dc52fc06..00000000000
--- a/app/views/projects/merge_requests/widget/open/_check.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('merge_request_widget')
-
-%strong
- = icon("spinner spin")
- Checking ability to merge automatically&hellip;
diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
deleted file mode 100644
index 621ee313026..00000000000
--- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-- can_resolve = @merge_request.conflicts_can_be_resolved_by?(current_user)
-- can_resolve_in_ui = @merge_request.conflicts_can_be_resolved_in_ui?
-- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user)
-
-%h4.has-conflicts
- %p
- = icon("exclamation-triangle")
- This merge request contains merge conflicts
-
-.remove-message-pipes
- %ul
- %li
- %span
- To merge this request, resolve these conflicts
- - if can_resolve && !can_resolve_in_ui
- locally
- or
- - unless can_merge
- ask someone with write access to this repository to
- merge it locally.
-
-- if (can_resolve && can_resolve_in_ui) || can_merge
- .merged-buttons.clearfix
- - if can_resolve && can_resolve_in_ui
- = link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn"
- - if can_merge
- = link_to "Merge locally", "#modal_merge_info", class: "btn how_to_merge_link vlink", "data-toggle" => "modal"
diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml
deleted file mode 100644
index 9078b7e21dd..00000000000
--- a/app/views/projects/merge_requests/widget/open/_manual.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-%h4
- Pipeline blocked
-%p
- The pipeline for this merge request requires a manual action to proceed.
diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml
deleted file mode 100644
index 76cc1ecd8a5..00000000000
--- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('merge_request_widget')
-
-%h4
- Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)}
- to be merged automatically when the pipeline succeeds.
-.remove-message-pipes
- %ul
- %li
- %span
- = succeed '.' do
- The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}
- - if @merge_request.remove_source_branch?
- %li
- %span
- The source branch will be removed.
- - else
- %li
- %span
- The source branch will not be removed.
-
- - remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user
- - user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user)
- - if remove_source_branch_button || user_can_cancel_automatic_merge
- .clearfix.prepend-top-10
- - if remove_source_branch_button
- = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do
- = icon('times')
- Remove source branch when merged
-
- - if user_can_cancel_automatic_merge
- = link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do
- Cancel automatic merge
diff --git a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml b/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml
deleted file mode 100644
index c9f07629493..00000000000
--- a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- unless @merge_request.source_branch_exists?
- %h4
- = icon("exclamation-triangle")
- Source branch
- %span.label-branch= source_branch_with_namespace(@merge_request)
- does not exist
- %p
- Please restore the source branch or close this merge request and open a new merge request with a different source branch.
-- else
- %h4
- = icon("exclamation-triangle")
- Target branch
- %span.label-branch= @merge_request.target_branch
- does not exist
- %p
- Please restore the target branch or use a different target branch.
diff --git a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml b/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml
deleted file mode 100644
index 57ce1959021..00000000000
--- a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%h4
- Ready to be merged automatically
-%p
- Ask someone with write access to this repository to merge this request.
- - if @merge_request.force_remove_source_branch?
- The source branch will be removed.
diff --git a/app/views/projects/merge_requests/widget/open/_nothing.html.haml b/app/views/projects/merge_requests/widget/open/_nothing.html.haml
deleted file mode 100644
index 7af8c01c134..00000000000
--- a/app/views/projects/merge_requests/widget/open/_nothing.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%h4
- = icon("exclamation-triangle")
- Nothing to merge from
- %span.label-branch= source_branch_with_namespace(@merge_request)
- into
- %span.label-branch= @merge_request.target_branch
-%p
- Please push new commits to the source branch or use a different target branch.
diff --git a/app/views/projects/merge_requests/widget/open/_reload.html.haml b/app/views/projects/merge_requests/widget/open/_reload.html.haml
deleted file mode 100644
index acfc31725eb..00000000000
--- a/app/views/projects/merge_requests/widget/open/_reload.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%h4
- = icon("exclamation-triangle")
- This merge request failed to be merged automatically
-
-%p
- Please reload the page to find out the reason.
diff --git a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml b/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml
deleted file mode 100644
index 499624f8dd8..00000000000
--- a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%h4
- = icon("exclamation-triangle")
- This merge request has received new commits since the page was loaded.
-
-%p
- Please reload the page to review the new commits before merging.
diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml
deleted file mode 100644
index ec9346ce89b..00000000000
--- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%h4
- = icon('exclamation-triangle')
- This merge request has unresolved discussions
-
-%p
- Please resolve these discussions
- - if @project.issues_enabled? && can?(current_user, :create_issue, @project)
- or
- = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid)
- to allow this merge request to be merged.
diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml
deleted file mode 100644
index c296422a9cf..00000000000
--- a/app/views/projects/merge_requests/widget/open/_wip.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-%h4
- This merge request is currently a Work In Progress
-
-- if can?(current_user, :update_merge_request, @merge_request)
- %p
- When this merge request is ready,
- = link_to remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post do
- remove the
- %code WIP:
- prefix from the title
- to allow it to be merged.
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 0f4a8508751..2e74b1b83cb 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -9,9 +9,9 @@
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
@@ -19,7 +19,7 @@
.form-actions
- if @milestone.new_record?
= f.submit 'Create milestone', class: "btn-create btn"
- = link_to "Cancel", namespace_project_milestones_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_milestones_path(@project), class: "btn btn-cancel"
- else
= f.submit 'Save changes', class: "btn-save btn"
- = link_to "Cancel", namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-cancel"
+ = link_to "Cancel", project_milestone_path(@project, @milestone), class: "btn btn-cancel"
diff --git a/app/views/projects/milestones/_milestone.html.haml b/app/views/projects/milestones/_milestone.html.haml
index 77b566db6b6..bc82b45f902 100644
--- a/app/views/projects/milestones/_milestone.html.haml
+++ b/app/views/projects/milestones/_milestone.html.haml
@@ -1,5 +1,5 @@
= render 'shared/milestones/milestone',
- milestone_path: namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone),
- issues_path: namespace_project_issues_path(milestone.project.namespace, milestone.project, milestone_title: milestone.title),
- merge_requests_path: namespace_project_merge_requests_path(milestone.project.namespace, milestone.project, milestone_title: milestone.title),
+ milestone_path: project_milestone_path(milestone.project, milestone),
+ issues_path: project_issues_path(milestone.project, milestone_title: milestone.title),
+ merge_requests_path: project_merge_requests_path(milestone.project, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index e1096bd1d67..e0b29b0c2e1 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,15 +1,20 @@
- @no_container = true
- page_title 'Milestones'
+
+- if show_new_nav? && can?(current_user, :admin_milestone, @project)
+ - content_for :breadcrumbs_extra do
+ = link_to "New milestone", new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone'
+
= render "shared/mr_head"
%div{ class: container_class }
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
- .nav-controls
+ .nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone' do
+ = link_to new_project_milestone_path(@project), class: "btn btn-new #{("visible-xs" if show_new_nav?)}", title: 'New milestone' do
New milestone
.milestones
diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml
index 586eb909afa..84ffbc0a926 100644
--- a/app/views/projects/milestones/new.html.haml
+++ b/app/views/projects/milestones/new.html.haml
@@ -1,4 +1,5 @@
- @no_container = true
+- breadcrumb_title "Milestones"
- page_title "New Milestone"
= render "shared/mr_head"
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 33bbbd9a3f8..0bf0e11c107 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -23,14 +23,14 @@
.milestone-buttons
- if can?(current_user, :admin_milestone, @project)
- if @milestone.active?
- = link_to 'Close milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
+ = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
- else
- = link_to 'Reopen milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
+ = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
- = link_to edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-grouped btn-nr" do
+ = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do
Edit
- = link_to namespace_project_milestone_path(@project.namespace, @project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
+ = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
Delete
%a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" }
@@ -43,8 +43,7 @@
- if @milestone.description.present?
.description
.wiki
- = preserve do
- = markdown_field(@milestone, :description)
+ = markdown_field(@milestone, :description)
- if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero?
.alert.alert-success.prepend-top-default
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index ed6077f6c6b..ab948df4a3f 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -1,12 +1,15 @@
+- breadcrumb_title "Graph"
- page_title "Graph", @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('network')
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
= render "projects/commits/head"
= render "head"
%div{ class: container_class }
.project-network
.controls
- = form_tag namespace_project_network_path(@project.namespace, @project, @id), method: :get, class: 'form-inline network-form' do |f|
+ = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f|
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Git revision", class: 'search-input form-control input-mx-250 search-sha'
= button_tag class: 'btn btn-success' do
= icon('search')
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 9e292729425..647e0a772b1 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -1,6 +1,11 @@
+- @breadcrumb_link = dashboard_projects_path
+- breadcrumb_title "Projects"
+- @hide_top_links = true
- page_title 'New Project'
- header_title "Projects", dashboard_projects_path
- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'project_new'
.project-edit-container
.project-edit-errors
@@ -9,10 +14,71 @@
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
New project
- %p
- Create or Import your project from popular Git services
- .col-lg-9
+ - if import_sources_enabled?
+ %p
+ Create or Import your project from popular Git services
+ .col-lg-9.js-toggle-container
= form_for @project, html: { class: 'new_project' } do |f|
+ .create-project-options
+ .first-column
+ .project-template
+ .form-group
+ = f.label :template_project, class: 'label-light' do
+ Create from template
+ = link_to icon('question-circle'), help_page_path("gitlab-basics/create-project"), target: '_blank', aria: { label: "What’s included in a template?" }, title: "What’s included in a template?", class: 'has-tooltip', data: { placement: 'top'}
+ %div
+ = render 'project_templates', f: f
+ .second-column
+ - if import_sources_enabled?
+ .project-import
+ .form-group.clearfix
+ = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
+ Import project from
+ .col-sm-12.import-buttons
+ %div
+ - if github_import_enabled?
+ = link_to new_import_github_path, class: 'btn import_github' do
+ = icon('github', text: 'GitHub')
+ %div
+ - if bitbucket_import_enabled?
+ = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
+ = icon('bitbucket', text: 'Bitbucket')
+ - unless bitbucket_import_configured?
+ = render 'bitbucket_import_modal'
+ %div
+ - if gitlab_import_enabled?
+ = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
+ = icon('gitlab', text: 'GitLab.com')
+ - unless gitlab_import_configured?
+ = render 'gitlab_import_modal'
+ %div
+ - if google_code_import_enabled?
+ = link_to new_import_google_code_path, class: 'btn import_google_code' do
+ = icon('google', text: 'Google Code')
+ %div
+ - if fogbugz_import_enabled?
+ = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
+ = icon('bug', text: 'Fogbugz')
+ %div
+ - if gitea_import_enabled?
+ = link_to new_import_gitea_url, class: 'btn import_gitea' do
+ = custom_icon('go_logo')
+ Gitea
+ %div
+ - if git_import_enabled?
+ %button.btn.js-toggle-button.import_git{ type: "button" }
+ = icon('git', text: 'Repo by URL')
+ .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+ = icon('gitlab', text: 'GitLab export')
+
+ .row
+ .col-lg-12
+ .js-toggle-content.hide
+ %hr
+ = render "shared/import_form", f: f
+ %hr
+
.row
.form-group.col-xs-12.col-sm-6
= f.label :namespace_id, class: 'label-light' do
@@ -30,7 +96,7 @@
#{root_url}#{current_user.username}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
.form-group.col-xs-12.col-sm-6.project-path
- = f.label :namespace_id, class: 'label-light' do
+ = f.label :path, class: 'label-light' do
%span
Project name
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true
@@ -39,53 +105,6 @@
Want to house several dependent projects under the same namespace?
= link_to "Create a group", new_group_path
- - if import_sources_enabled?
- .project-import.js-toggle-container
- .form-group.clearfix
- = f.label :visibility_level, class: 'label-light' do
- Import project from
- .col-sm-12.import-buttons
- %div
- - if github_import_enabled?
- = link_to new_import_github_path, class: 'btn import_github' do
- = icon('github', text: 'GitHub')
- %div
- - if bitbucket_import_enabled?
- = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
- - unless bitbucket_import_configured?
- = render 'bitbucket_import_modal'
- %div
- - if gitlab_import_enabled?
- = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
- = icon('gitlab', text: 'GitLab.com')
- - unless gitlab_import_configured?
- = render 'gitlab_import_modal'
- %div
- - if google_code_import_enabled?
- = link_to new_import_google_code_path, class: 'btn import_google_code' do
- = icon('google', text: 'Google Code')
- %div
- - if fogbugz_import_enabled?
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
- = icon('bug', text: 'Fogbugz')
- %div
- - if gitea_import_enabled?
- = link_to new_import_gitea_url, class: 'btn import_gitea' do
- = custom_icon('go_logo')
- Gitea
- %div
- - if git_import_enabled?
- %button.btn.js-toggle-button.import_git{ type: "button" }
- = icon('git', text: 'Repo by URL')
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- - if gitlab_project_import_enabled?
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- = icon('gitlab', text: 'GitLab export')
-
- .js-toggle-content.hide
- = render "shared/import_form", f: f
-
.form-group
= f.label :description, class: 'label-light' do
Project description
@@ -95,7 +114,7 @@
.form-group.project-visibility-level-holder
= f.label :visibility_level, class: 'label-light' do
Visibility Level
- = link_to icon('question-circle'), help_page_path("public_access/public_access")
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }
= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
= f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4
@@ -107,46 +126,3 @@
%i.fa.fa-spinner.fa-spin
Creating project &amp; repository.
%p Please wait a moment, this page will automatically refresh when ready.
-
-:javascript
- var importBtnTooltip = "Please enter a valid project name.";
- var $importBtnWrapper = $('.import_gitlab_project');
-
- $('.how_to_import_link').bind('click', function (e) {
- e.preventDefault();
- var import_modal = $(this).next(".modal").show();
- });
-
- $('.modal-header .close').bind('click', function() {
- $(".modal").hide();
- });
-
- $('.btn_import_gitlab_project').bind('click', function() {
- var _href = $("a.btn_import_gitlab_project").attr("href");
- $(".btn_import_gitlab_project").attr("href", _href + '?namespace_id=' + $("#project_namespace_id").val() + '&path=' + $("#project_path").val());
- });
-
- $('.btn_import_gitlab_project').attr('disabled', $('#project_path').val().trim().length === 0);
- $importBtnWrapper.attr('title', importBtnTooltip);
-
- $('#new_project').submit(function(){
- var $path = $('#project_path');
- $path.val($path.val().trim());
- });
-
- $('#project_path').keyup(function(){
- if($(this).val().trim().length !== 0) {
- $('.btn_import_gitlab_project').attr('disabled', false);
- $importBtnWrapper.attr('title','');
- $importBtnWrapper.removeClass('has-tooltip');
- } else {
- $('.btn_import_gitlab_project').attr('disabled',true);
- $importBtnWrapper.addClass('has-tooltip');
- }
- });
-
- $('#project_import_url').disable();
- $('.import_git').click(function( event ) {
- $projectImportUrl = $('#project_import_url');
- $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled'));
- });
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 720957e8336..ba5845877e5 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,22 +1,22 @@
%h2
%i.fa.fa-warning
- No repository
+ #{ _('No repository') }
%p.slead
- The repository for this project does not exist.
+ #{ _('The repository for this project does not exist.') }
%br
- This means you can not push code until you create an empty repository or import existing one.
+ #{ _('This means you can not push code until you create an empty repository or import existing one.') }
%hr
.no-repo-actions
- = link_to namespace_project_repository_path(@project.namespace, @project), method: :post, class: 'btn btn-primary' do
- Create empty bare repository
+ = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
+ #{ _('Create empty bare repository') }
%strong.prepend-left-10.append-right-10 or
- = link_to new_namespace_project_import_path(@project.namespace, @project), class: 'btn' do
- Import repository
+ = link_to new_project_import_path(@project), class: 'btn' do
+ #{ _('Import repository') }
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to 'Remove project', project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
+ = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
new file mode 100644
index 00000000000..cb737d129f0
--- /dev/null
+++ b/app/views/projects/notes/_actions.html.haml
@@ -0,0 +1,48 @@
+- access = note_max_access_for_user(note)
+- if access
+ %span.note-role= access
+
+- if note.resolvable?
+ - can_resolve = can?(current_user, :resolve_note, note)
+ %resolve-btn{ "project-path" => project_path(note.project),
+ "discussion-id" => note.discussion_id(@noteable),
+ ":note-id" => note.id,
+ ":resolved" => note.resolved?,
+ ":can-resolve" => can_resolve,
+ ":author-name" => "'#{j(note.author.name)}'",
+ "author-avatar" => note.author.avatar_url,
+ ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'",
+ ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'",
+ "v-show" => "#{can_resolve || note.resolved?}",
+ "inline-template" => true,
+ "ref" => "note_#{note.id}" }
+
+ .note-actions-item
+ %button.note-action-button.line-resolve-btn{ type: "button",
+ class: ("is-disabled" unless can_resolve),
+ ":class" => "{ 'is-active': isResolved }",
+ ":aria-label" => "buttonText",
+ "@click" => "resolve",
+ ":title" => "buttonText",
+ ":ref" => "'button'" }
+
+ = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+ %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg'
+
+- if current_user
+ - if note.emoji_awardable?
+ - user_authored = note.user_authored?(current_user)
+ .note-actions-item
+ = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
+ = icon('spinner spin')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
+ %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+
+ - if note_editable
+ .note-actions-item
+ = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ %span.link-highlight
+ = custom_icon('icon_pencil')
+
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml
deleted file mode 100644
index 29cf5825292..00000000000
--- a/app/views/projects/notes/_comment_button.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-- 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.droplab-item-ignore
-
- %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/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml
deleted file mode 100644
index 8b4e5928e0d..00000000000
--- a/app/views/projects/notes/_edit_form.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-.note-edit-form
- = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
- = hidden_field_tag :target_id, '', class: 'js-form-target-id'
- = hidden_field_tag :target_type, '', class: 'js-form-target-type'
- = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do
- = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..."
- = render 'projects/notes/hints'
-
- .note-form-actions.clearfix
- .settings-message.note-edit-warning.js-edit-warning
- Finish editing this message first!
- = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button'
- %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
- Cancel
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
deleted file mode 100644
index 0d835a9e949..00000000000
--- a/app/views/projects/notes/_form.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- supports_slash_commands = note_supports_slash_commands?(@note)
-
-= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
- = 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 :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
- = render 'projects/zen', f: f,
- attr: :note,
- classes: 'note-textarea js-note-text',
- placeholder: "Write a comment or drag your files here...",
- supports_slash_commands: supports_slash_commands
- = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands
- .error-alert
-
- .note-form-actions.clearfix
- = 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/_hints.html.haml b/app/views/projects/notes/_hints.html.haml
deleted file mode 100644
index 81d97eabe65..00000000000
--- a/app/views/projects/notes/_hints.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
-.comment-toolbar.clearfix
- .toolbar-text
- = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
- - if supports_slash_commands
- and
- = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1
- are
- - else
- is
- supported
- %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
- = icon('file-image-o', class: 'toolbar-button-icon')
- Attach a file
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
new file mode 100644
index 00000000000..5930209a682
--- /dev/null
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -0,0 +1,16 @@
+- is_current_user = current_user == note.author
+
+- if note_editable || !is_current_user
+ .dropdown.more-actions.note-actions-item
+ = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do
+ %span.icon
+ = custom_icon('ellipsis_v')
+ %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
+ - unless is_current_user
+ %li
+ = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
+ Report as abuse
+ - if note_editable
+ %li
+ = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do
+ %span.text-danger Delete comment
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
deleted file mode 100644
index 7cf604bb772..00000000000
--- a/app/views/projects/notes/_note.html.haml
+++ /dev/null
@@ -1,102 +0,0 @@
-- return unless note.author
-- return if note.cross_reference_not_visible_for?(current_user)
-
-- note_editable = note_editable?(note)
-%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} }
- .timeline-entry-inner
- .timeline-icon
- - if note.system
- = icon_for_system_note(note)
- - else
- %a{ href: user_path(note.author) }
- = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
- .timeline-content
- .note-header
- .note-header-info
- %a{ href: user_path(note.author) }
- %span.hidden-xs
- = sanitize(note.author.name)
- %span.note-headline-light
- = note.author.to_reference
- %span.note-headline-light
- %span.note-headline-meta
- - unless note.system
- commented
- - if note.system
- %span.system-note-message
- = note.redacted_note_html
- %a{ href: "##{dom_id(note)}" }
- = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- - unless note.system?
- .note-actions
- - access = note_max_access_for_user(note)
- - if access
- %span.note-role= access
-
- - if note.resolvable?
- - can_resolve = can?(current_user, :resolve_note, note)
- %resolve-btn{ "project-path" => project_path(note.project),
- "discussion-id" => note.discussion_id(@noteable),
- ":note-id" => note.id,
- ":resolved" => note.resolved?,
- ":can-resolve" => can_resolve,
- ":author-name" => "'#{j(note.author.name)}'",
- "author-avatar" => note.author.avatar_url,
- ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'",
- ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'",
- "v-show" => "#{can_resolve || note.resolved?}",
- "inline-template" => true,
- "ref" => "note_#{note.id}" }
-
- %button.note-action-button.line-resolve-btn{ type: "button",
- class: ("is-disabled" unless can_resolve),
- ":class" => "{ 'is-active': isResolved }",
- ":aria-label" => "buttonText",
- "@click" => "resolve",
- ":title" => "buttonText",
- ":ref" => "'button'" }
-
- = icon("spin spinner", "v-show" => "loading", class: 'loading')
- %div{ 'v-show' => '!loading' }= render "shared/icons/icon_status_success.svg"
-
- - if current_user
- - if note.emoji_awardable?
- - user_authored = note.user_authored?(current_user)
- = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do
- = icon('spinner spin')
- %span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley')
- %span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile')
-
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
- = icon('pencil', class: 'link-highlight')
- = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do
- = icon('trash-o', class: 'danger-highlight')
- .note-body{ class: note_editable ? 'js-task-list-container' : '' }
- .note-text.md
- = preserve do
- = note.redacted_note_html
- = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true)
- - if note_editable
- .original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
- #{note.note}
- %textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note
- .note-awards
- = render 'award_emoji/awards_block', awardable: note, inline: false
- - if note.system
- .system-note-commit-list-toggler
- Toggle commit list
- %i.fa.fa-angle-down
- - if note.attachment.url
- .note-attachment
- - if note.attachment.image?
- = link_to note.attachment.url, target: '_blank' do
- = image_tag note.attachment.url, class: 'note-image-attach'
- .attachment
- = link_to note.attachment.url, target: '_blank' do
- = icon('paperclip')
- = note.attachment_identifier
- = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note),
- title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
- = icon('trash-o', class: 'cred')
diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml
deleted file mode 100644
index 2b2bab09c74..00000000000
--- a/app/views/projects/notes/_notes.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- if defined?(@discussions)
- - @discussions.each do |discussion|
- - if discussion.individual_note?
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
- - else
- = render 'discussions/discussion', discussion: discussion
-- else
- = render partial: "projects/notes/note", collection: @notes, as: :note
diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml
deleted file mode 100644
index 90a150aa74c..00000000000
--- a/app/views/projects/notes/_notes_with_form.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-%ul#notes-list.notes.main-notes-list.timeline
- = render "projects/notes/notes"
-
-= render 'projects/notes/edit_form'
-
-%ul.notes.notes-form.timeline
- %li.timeline-entry
- .flash-container.timeline-content
-
- - if can? current_user, :create_note, @project
- .timeline-icon.hidden-xs.hidden-sm
- %a.author_link{ href: user_path(current_user) }
- = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40'
- .timeline-content.timeline-content-form
- = render "projects/notes/form", view: diff_view
- - elsif !current_user
- .disabled-comment.text-center
- .disabled-comment-text.inline
- Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
- or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
- to post a comment
-
-:javascript
- var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}")
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index 42d9ef5ccba..7d6c30b7f8d 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -7,6 +7,6 @@
%p
Removing the pages will prevent from exposing them to outside world.
.form-actions
- = link_to 'Remove pages', namespace_project_pages_path(@project.namespace, @project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
- else
.nothing-here-block Only the project owner can remove pages
diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml
deleted file mode 100644
index ad51fbc6cab..00000000000
--- a/app/views/projects/pages/_disabled.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.panel.panel-default
- .nothing-here-block
- GitLab Pages are disabled.
- Ask your system's administrator to enable it.
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 4f2dd1a1398..a85cda407af 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -6,8 +6,8 @@
- @domains.each do |domain|
%li
.pull-right
- = link_to 'Details', namespace_project_pages_domain_path(@project.namespace, @project, domain), class: "btn btn-sm btn-grouped"
- = link_to 'Remove', namespace_project_pages_domain_path(@project.namespace, @project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
+ = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped"
+ = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
.clearfix
%span= link_to domain.domain, domain.url
%p
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 259d5bd63d6..098b0ef56ef 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -5,7 +5,7 @@
Pages
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
- = link_to new_namespace_project_pages_domain_path(@project.namespace, @project), class: 'btn btn-new pull-right', title: 'New Domain' do
+ = link_to new_project_pages_domain_path(@project), class: 'btn btn-new pull-right', title: 'New Domain' do
%i.fa.fa-plus
New Domain
@@ -16,13 +16,10 @@
%hr.clearfix
-- if Gitlab.config.pages.enabled
- = render 'access'
- = render 'use'
- - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
- = render 'list'
- - else
- = render 'no_domains'
- = render 'destroy'
+= render 'access'
+= render 'use'
+- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+ = render 'list'
- else
- = render 'disabled'
+ = render 'no_domains'
+= render 'destroy'
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
new file mode 100644
index 00000000000..857ae00d0ab
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'schedule_form'
+
+= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f|
+ = form_errors(@schedule)
+ .form-group
+ .col-md-9
+ = f.label :description, _('Description'), class: 'label-light'
+ = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: s_('PipelineSchedules|Provide a short description for this pipeline')
+ .form-group
+ .col-md-9
+ = f.label :cron, _('Interval Pattern'), class: 'label-light'
+ #interval-pattern-input{ data: { initial_interval: @schedule.cron } }
+ .form-group
+ .col-md-9
+ = f.label :cron_timezone, _('Cron Timezone'), class: 'label-light'
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
+ .form-group
+ .col-md-9
+ = f.label :ref, _('Target Branch'), class: 'label-light'
+ = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
+ .form-group
+ .col-md-9
+ %label.label-light
+ #{ s_('PipelineSchedules|Variables') }
+ %ul.js-pipeline-variable-list.pipeline-variable-list
+ - @schedule.variables.each do |variable|
+ = render 'variable_row', id: variable.id, key: variable.key, value: variable.value
+ = render 'variable_row'
+ .form-group
+ .col-md-9
+ = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-light'
+ %div
+ = f.check_box :active, required: false, value: @schedule.active?
+ = _('Active')
+ .footer-block.row-content-block
+ = f.submit _('Save pipeline schedule'), class: 'btn btn-create', tabindex: 3
+ = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
new file mode 100644
index 00000000000..bd8c38292d6
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -0,0 +1,37 @@
+- if pipeline_schedule
+ %tr.pipeline-schedule-table-row
+ %td
+ = pipeline_schedule.description
+ %td.branch-name-cell
+ = icon('code-fork')
+ - if pipeline_schedule.ref.present?
+ = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name"
+ %td
+ - if pipeline_schedule.last_pipeline
+ .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" }
+ = link_to project_pipeline_path(@project, pipeline_schedule.last_pipeline.id) do
+ = ci_icon_for_status(pipeline_schedule.last_pipeline.status)
+ %span ##{pipeline_schedule.last_pipeline.id}
+ - else
+ = s_("PipelineSchedules|None")
+ %td.next-run-cell
+ - if pipeline_schedule.active? && pipeline_schedule.next_run_at
+ = time_ago_with_tooltip(pipeline_schedule.real_next_run)
+ - else
+ = s_("PipelineSchedules|Inactive")
+ %td
+ - if pipeline_schedule.owner
+ = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20"
+ = link_to user_path(pipeline_schedule.owner) do
+ = pipeline_schedule.owner&.name
+ %td
+ .pull-right.btn-group
+ - if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
+ = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
+ = s_('PipelineSchedules|Take ownership')
+ - if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
+ = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do
+ = icon('pencil')
+ - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
+ = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn btn-remove', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
+ = icon('trash')
diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml
new file mode 100644
index 00000000000..d0c7ea77263
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/_table.html.haml
@@ -0,0 +1,12 @@
+.table-holder
+ %table.table.ci-table
+ %thead
+ %tr
+ %th= _("Description")
+ %th= s_("PipelineSchedules|Target")
+ %th= _("Last Pipeline")
+ %th= s_("PipelineSchedules|Next Run")
+ %th= _("Owner")
+ %th
+
+ = render partial: "pipeline_schedule", collection: @schedules
diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml
new file mode 100644
index 00000000000..7fcb624a9dd
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/_tabs.html.haml
@@ -0,0 +1,18 @@
+%ul.nav-links
+ %li{ class: active_when(scope.nil?) }>
+ = link_to schedule_path_proc.call(nil) do
+ = s_("PipelineSchedules|All")
+ %span.badge.js-totalbuilds-count
+ = number_with_delimiter(all_schedules.count(:id))
+
+ %li{ class: active_when(scope == 'active') }>
+ = link_to schedule_path_proc.call('active') do
+ = s_("PipelineSchedules|Active")
+ %span.badge
+ = number_with_delimiter(all_schedules.active.count(:id))
+
+ %li{ class: active_when(scope == 'inactive') }>
+ = link_to schedule_path_proc.call('inactive') do
+ = s_("PipelineSchedules|Inactive")
+ %span.badge
+ = number_with_delimiter(all_schedules.inactive.count(:id))
diff --git a/app/views/projects/pipeline_schedules/_variable_row.html.haml b/app/views/projects/pipeline_schedules/_variable_row.html.haml
new file mode 100644
index 00000000000..564cb5d1ca9
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/_variable_row.html.haml
@@ -0,0 +1,17 @@
+- id = local_assigns.fetch(:id, nil)
+- key = local_assigns.fetch(:key, "")
+- value = local_assigns.fetch(:value, "")
+%li.js-row.pipeline-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
+ .pipeline-variable-row-body
+ %input{ type: "hidden", name: "schedule[variables_attributes][][id]", value: id }
+ %input.js-destroy-input{ type: "hidden", name: "schedule[variables_attributes][][_destroy]" }
+ %input.js-user-input.pipeline-variable-key-input.form-control{ type: "text",
+ name: "schedule[variables_attributes][][key]",
+ value: key,
+ placeholder: s_('PipelineSchedules|Input variable key') }
+ %textarea.js-user-input.pipeline-variable-value-input.form-control{ rows: 1,
+ name: "schedule[variables_attributes][][value]",
+ placeholder: s_('PipelineSchedules|Input variable value') }
+ = value
+ %button.js-row-remove-button.pipeline-variable-row-remove-button{ 'aria-label': s_('PipelineSchedules|Remove variable row') }
+ %i.fa.fa-minus-circle{ 'aria-hidden': "true" }
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
new file mode 100644
index 00000000000..9b2a7b5821d
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -0,0 +1,7 @@
+- page_title _("Edit"), @schedule.description, _("Pipeline Schedule")
+
+%h3.page-title
+ = _("Edit Pipeline Schedule %{id}") % { id: @schedule.id }
+%hr
+
+= render "form"
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
new file mode 100644
index 00000000000..8426b29bb14
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -0,0 +1,34 @@
+- breadcrumb_title "Schedules"
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'schedules_index'
+
+- @no_container = true
+- page_title _("Pipeline Schedules")
+
+- if show_new_nav? && can?(current_user, :create_pipeline_schedule, @project)
+ - content_for :breadcrumbs_extra do
+ = link_to _('New schedule'), new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create'
+
+ - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
+
+= render "projects/pipelines/head"
+
+%div{ class: container_class }
+ #pipeline-schedules-callout{ data: { docs_url: help_page_path('user/project/pipelines/schedules') } }
+ .top-area
+ - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
+ = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
+
+ - if can?(current_user, :create_pipeline_schedule, @project)
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
+ = link_to new_project_pipeline_schedule_path(@project), class: 'btn btn-create' do
+ %span= _('New schedule')
+
+ - if @schedules.present?
+ %ul.content-list
+ = render partial: "table"
+ - else
+ .light-well
+ .nothing-here-block= _("No schedules")
diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
new file mode 100644
index 00000000000..c7237cb96d8
--- /dev/null
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -0,0 +1,12 @@
+- breadcrumb_title "Schedules"
+- @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project)
+- page_title _("New Pipeline Schedule")
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
+
+%h3.page-title
+ = _("Schedule a new pipeline")
+%hr
+
+= render "form"
diff --git a/app/views/projects/pipelines/_graph.html.haml b/app/views/projects/pipelines/_graph.html.haml
deleted file mode 100644
index 0202833c0bf..00000000000
--- a/app/views/projects/pipelines/_graph.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- pipeline = local_assigns.fetch(:pipeline)
-.pipeline-visualization.pipeline-graph
- %ul.stage-column-list
- = render partial: "projects/stage/graph", collection: pipeline.stages, as: :stage
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
index bc57f7f1c46..ee2f236cec4 100644
--- a/app/views/projects/pipelines/_head.html.haml
+++ b/app/views/projects/pipelines/_head.html.haml
@@ -4,17 +4,23 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
- if project_nav_tab? :pipelines
- = nav_link(path: 'pipelines#index', controller: :pipelines) do
+ = nav_link(path: ['pipelines#index', 'pipelines#show']) do
= link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
%span
Pipelines
- if project_nav_tab? :builds
- = nav_link(controller: :builds) do
- = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ = nav_link(controller: [:jobs, :artifacts]) do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
%span
Jobs
+ - if project_nav_tab? :pipelines
+ = nav_link(controller: :pipeline_schedules) do
+ = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do
+ %span
+ Schedules
+
- if project_nav_tab? :environments
= nav_link(controller: :environments) do
= link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do
@@ -23,6 +29,6 @@
- if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
= nav_link(path: 'pipelines#charts') do
- = link_to charts_namespace_project_pipelines_path(@project.namespace, @project), title: 'Charts', class: 'shortcuts-pipelines-charts' do
+ = link_to charts_project_pipelines_path(@project), title: 'Charts', class: 'shortcuts-pipelines-charts' do
%span
Charts
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index ab6baaf35b6..f5149306734 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,18 +1,4 @@
-.page-content-header
- .header-main-content
- = 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
- by
- = user_avatar(user: @pipeline.user, size: 24)
- = user_link(@pipeline.user)
- .header-action-buttons
- - if can?(current_user, :update_pipeline, @pipeline.project)
- - if @pipeline.retryable?
- = link_to "Retry", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'js-retry-button btn btn-inverted-secondary', method: :post
- - if @pipeline.cancelable?
- = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+#js-pipeline-header-vue.pipeline-header-container
- if @commit
.commit-box
@@ -30,7 +16,7 @@
= pluralize @pipeline.statuses.count(:id), "job"
- if @pipeline.ref
from
- = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
+ = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- if @pipeline.duration
in
= time_interval_in_words(@pipeline.duration)
@@ -40,10 +26,10 @@
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short"
+ = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short"
= link_to("#", class: "js-details-expand hidden-xs hidden-sm") do
%span.text-expander
\...
%span.js-details-content.hide
- = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
+ = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full"
= clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index d7cefb8613e..ad61f033a1c 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,19 +1,23 @@
+- failed_builds = @pipeline.statuses.latest.failed
+
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom
%li.js-pipeline-tab-link
- = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
+ = link_to project_pipeline_path(@project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
Pipeline
%li.js-builds-tab-link
- = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
+ = link_to builds_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
Jobs
%span.badge.js-builds-counter= pipeline.statuses.count
-
-
+ - if failed_builds.present?
+ %li.js-failures-tab-link
+ = link_to failures_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
+ Failed Jobs
+ %span.badge.js-failures-counter= failed_builds.count
.tab-content
#js-tab-pipeline.tab-pane
- .build-content.middle-block.js-pipeline-graph
- = render "projects/pipelines/graph", pipeline: pipeline
+ #js-pipeline-graph-vue
#js-tab-builds.tab-pane
- if pipeline.yaml_errors.present?
@@ -38,4 +42,14 @@
%th
%th Coverage
%th
- = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
+ = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
+ - if failed_builds.present?
+ #js-tab-failures.build-failures.tab-pane
+ - failed_builds.each_with_index do |build, index|
+ .build-state
+ %span.ci-status-icon-failed= custom_icon('icon_status_failed')
+ %span.stage
+ = build.stage.titleize
+ %span.build-name
+ = link_to build.name, pipeline_job_url(pipeline, build)
+ %pre.build-log= build_summary(build, skip: index >= 10)
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 4a5043aac3c..fd3ad69d85d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,5 +1,7 @@
- @no_container = true
-- page_title "Charts", "Pipelines"
+- page_title _("Charts"), _("Pipelines")
+- if show_new_nav?
+ - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_d3')
= page_specific_javascript_bundle_tag('graphs')
@@ -8,14 +10,14 @@
%div{ class: container_class }
.sub-header-block
.oneline
- A collection of graphs for Continuous Integration
+ = _("A collection of graphs regarding Continuous Integration")
#charts.ci-charts
.row
.col-md-6
= render 'projects/pipelines/charts/overall'
.col-md-6
- = render 'projects/pipelines/charts/build_times'
+ = render 'projects/pipelines/charts/pipeline_times'
%hr
- = render 'projects/pipelines/charts/builds'
+ = render 'projects/pipelines/charts/pipelines'
diff --git a/app/views/projects/pipelines/charts/_build_times.haml b/app/views/projects/pipelines/charts/_build_times.haml
deleted file mode 100644
index bb0975a9535..00000000000
--- a/app/views/projects/pipelines/charts/_build_times.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-%div
- %p.light
- Commit duration in minutes for last 30 commits
-
- %canvas#build_timesChart{ height: 200 }
-
-:javascript
- var data = {
- labels : #{@charts[:build_times].labels.to_json},
- datasets : [
- {
- fillColor : "rgba(220,220,220,0.5)",
- strokeColor : "rgba(220,220,220,1)",
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data : #{@charts[:build_times].build_times.to_json}
- }
- ]
- }
- var ctx = $("#build_timesChart").get(0).getContext("2d");
- var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false };
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- new Chart(ctx).Bar(data, options);
diff --git a/app/views/projects/pipelines/charts/_builds.haml b/app/views/projects/pipelines/charts/_builds.haml
deleted file mode 100644
index b6f453b9736..00000000000
--- a/app/views/projects/pipelines/charts/_builds.haml
+++ /dev/null
@@ -1,56 +0,0 @@
-%h4 Pipelines charts
-%p
- &nbsp;
- %span.cgreen
- = icon("circle")
- success
- &nbsp;
- %span.cgray
- = icon("circle")
- all
-
-.prepend-top-default
- %p.light
- Jobs for last week
- (#{date_from_to(Date.today - 7.days, Date.today)})
- %canvas#weekChart{ height: 200 }
-
-.prepend-top-default
- %p.light
- Jobs for last month
- (#{date_from_to(Date.today - 30.days, Date.today)})
- %canvas#monthChart{ height: 200 }
-
-.prepend-top-default
- %p.light
- Jobs for last year
- %canvas#yearChart.padded{ height: 250 }
-
-- [:week, :month, :year].each do |scope|
- :javascript
- var data = {
- labels : #{@charts[scope].labels.to_json},
- datasets : [
- {
- fillColor : "#7f8fa4",
- strokeColor : "#7f8fa4",
- pointColor : "#7f8fa4",
- pointStrokeColor : "#EEE",
- data : #{@charts[scope].total.to_json}
- },
- {
- fillColor : "#44aa22",
- strokeColor : "#44aa22",
- pointColor : "#44aa22",
- pointStrokeColor : "#fff",
- data : #{@charts[scope].success.to_json}
- }
- ]
- }
- var ctx = $("##{scope}Chart").get(0).getContext("2d");
- var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false };
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8
- }
- new Chart(ctx).Line(data, options);
diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml
index edc4f7b079f..66786c7ff59 100644
--- a/app/views/projects/pipelines/charts/_overall.haml
+++ b/app/views/projects/pipelines/charts/_overall.haml
@@ -1,19 +1,15 @@
-%h4 Overall stats
+%h4= s_("PipelineCharts|Overall statistics")
%ul
%li
- Total:
- %strong= pluralize @project.builds.count(:all), 'build'
+ = s_("PipelineCharts|Total:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:total]) % @counts[:total]
%li
- Successful:
- %strong= pluralize @project.builds.success.count(:all), 'build'
+ = s_("PipelineCharts|Successful:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:success]) % @counts[:success]
%li
- Failed:
- %strong= pluralize @project.builds.failed.count(:all), 'build'
+ = s_("PipelineCharts|Failed:")
+ %strong= n_("1 pipeline", "%d pipelines", @counts[:failed]) % @counts[:failed]
%li
- Success ratio:
+ = s_("PipelineCharts|Success ratio:")
%strong
- #{success_ratio(@project.builds.success, @project.builds.failed)}%
- %li
- Commits covered:
- %strong
- = @project.pipelines.count(:all)
+ #{success_ratio(@counts)}%
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
new file mode 100644
index 00000000000..a5dbd1b1532
--- /dev/null
+++ b/app/views/projects/pipelines/charts/_pipeline_times.haml
@@ -0,0 +1,10 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('pipelines_times')
+
+%div
+ %p.light
+ = _("Commit duration in minutes for last 30 commits")
+
+ %canvas#build_timesChart{ height: 200 }
+
+%script#pipelinesTimesChartsData{ type: "application/json" }= { :labels => @charts[:pipeline_times].labels, :values => @charts[:pipeline_times].pipeline_times }.to_json.html_safe
diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml
new file mode 100644
index 00000000000..02f1ef4b6da
--- /dev/null
+++ b/app/views/projects/pipelines/charts/_pipelines.haml
@@ -0,0 +1,36 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('pipelines_charts')
+
+%h4= _("Pipelines charts")
+%p
+ &nbsp;
+ %span.cgreen
+ = icon("circle")
+ = s_("Pipeline|success")
+ &nbsp;
+ %span.cgray
+ = icon("circle")
+ = s_("Pipeline|all")
+
+.prepend-top-default
+ %p.light
+ = _("Jobs for last week")
+ (#{date_from_to(Date.today - 7.days, Date.today)})
+ %canvas#weekChart{ height: 200 }
+
+.prepend-top-default
+ %p.light
+ = _("Jobs for last month")
+ (#{date_from_to(Date.today - 30.days, Date.today)})
+ %canvas#monthChart{ height: 200 }
+
+.prepend-top-default
+ %p.light
+ = _("Jobs for last year")
+ %canvas#yearChart.padded{ height: 250 }
+
+%script#pipelinesChartsData{ type: "application/json" }
+ - chartData = []
+ - [:week, :month, :year].each do |scope|
+ - chartData.push({ 'scope' => scope, 'labels' => @charts[scope].labels, 'totalValues' => @charts[scope].total, 'successValues' => @charts[scope].success })
+ = chartData.to_json.html_safe
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 38237d2d97d..c1729850cf4 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -2,10 +2,10 @@
- page_title "Pipelines"
= render "projects/pipelines/head"
-#pipelines-list-vue{ data: { endpoint: namespace_project_pipelines_path(@project.namespace, @project, format: :json),
+#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
"css-class" => container_class,
"help-page-path" => help_page_path('ci/quick_start/README'),
- "new-pipeline-path" => new_namespace_project_pipeline_path(@project.namespace, @project),
+ "new-pipeline-path" => new_project_pipeline_path(@project),
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
"all-path" => project_pipelines_path(@project),
"pending-path" => project_pipelines_path(@project, scope: :pending),
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 14a270a3039..4ad37d0e882 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,25 +1,23 @@
+- breadcrumb_title "Pipelines"
- page_title "New Pipeline"
%h3.page-title
New Pipeline
%hr
-= form_for @pipeline, as: :pipeline, url: namespace_project_pipelines_path(@project.namespace, @project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f|
+= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f|
= form_errors(@pipeline)
.form-group
= f.label :ref, 'Create for', class: 'control-label'
.col-sm-10
= hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch
= dropdown_tag(params[:ref] || @project.default_branch,
- options: { toggle_class: 'js-branch-select wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches",
+ options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches",
data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } })
.help-block Existing branch name, tag
.form-actions
= f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_pipelines_path(@project.namespace, @project), class: 'btn btn-cancel'
+ = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel'
-:javascript
- var availableRefs = #{@project.repository.ref_names.to_json};
-
- new NewBranchForm($('.js-new-pipeline-form'), availableRefs)
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 49c1d886423..63f85fc69a2 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -7,3 +7,9 @@
= render "projects/pipelines/info"
= render "projects/pipelines/with_tabs", pipeline: @pipeline
+
+.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } }
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('common_vue')
+ = webpack_bundle_tag('pipelines_details')
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml
index 43bbd735059..3de518c8b9a 100644
--- a/app/views/projects/pipelines_settings/_badge.html.haml
+++ b/app/views/projects/pipelines_settings/_badge.html.haml
@@ -1,8 +1,8 @@
%div{ class: badge.title.gsub(' ', '-') }
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= badge.title.capitalize
- .col-lg-9
+ .col-lg-8
.prepend-top-10
.panel.panel-default
.panel-heading
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index a3f84476dea..255d7ef38e0 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -1,14 +1,14 @@
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
+ .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- CI/CD Pipelines
- .col-lg-9
- = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f|
+ Pipelines
+ .col-lg-8
+ = form_for @project, url: project_pipelines_settings_path(@project) do |f|
%fieldset.builds-feature
- unless @repository.gitlab_ci_yml
.form-group
%p Pipelines need to be configured before you can begin using Continuous Integration.
- = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
+ = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
%hr
.form-group.append-bottom-default
= f.label :runners_token, "Runner token", class: 'label-light'
@@ -42,11 +42,19 @@
= f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light'
= 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.
+ 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'), target: '_blank'
%hr
.form-group
+ = f.label :ci_config_path, 'Custom CI config path', class: 'label-light'
+ = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
+ %p.help-block
+ The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
+
+ %hr
+ .form-group
.checkbox
= f.label :public_builds do
= f.check_box :public_builds
diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml
deleted file mode 100644
index c7996077bc7..00000000000
--- a/app/views/projects/project_members/_group_members.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-.panel.panel-default
- .panel-heading
- Group members with access to
- %strong= @group.name
- %span.badge= members.size
- - if can?(current_user, :admin_group_member, @group)
- .controls
- = link_to 'Manage group members',
- group_group_members_path(@group),
- class: 'btn'
- %ul.content-list
- = render partial: 'shared/members/member',
- collection: members.limit(20),
- as: :member,
- locals: { show_controls: false }
- - if members.size > 20
- %li
- and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)}
diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml
deleted file mode 100644
index ab0771b5751..00000000000
--- a/app/views/projects/project_members/_index.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-.row.prepend-top-default
- .col-lg-3.settings-sidebar
- %h4.prepend-top-0
- Members
- - if can?(current_user, :admin_project_member, @project)
- %p
- Add a new member to
- %strong= @project.name
- .col-lg-9
- .light.prepend-top-default
- - if can?(current_user, :admin_project_member, @project)
- = render "projects/project_members/new_project_member"
-
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters
- .append-bottom-default.clearfix
- %h5.member.existing-title
- Existing members and groups
- - if @group_links.any?
- = render 'projects/project_members/groups', group_links: @group_links
-
- = render 'projects/project_members/team', members: @project_members
- = paginate @project_members, theme: "gitlab"
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 2b1c23f7dda..bf5b11ea30c 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -1,18 +1,21 @@
-= form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'users-project-form' } do |f|
- .form-group
- = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
- .help-block.append-bottom-10
- Search for members by name, username, or email, or invite new ones using their email address.
- .form-group
- = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select"
- .help-block.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
- about role permissions
- .form-group
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
- %i.clear-icon.js-clear-input
- .help-block.append-bottom-10
- On this date, the member(s) will automatically lose access to this project.
- = f.submit "Add to project", class: "btn btn-create"
- = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default", title: "Import members from another project"
+.row
+ .col-sm-12
+ = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f|
+ .form-group
+ = label_tag :user_ids, "Select members to invite", class: "label-light"
+ = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
+ .form-group
+ = label_tag :access_level, "Choose a role permission", class: "label-light"
+ .select-wrapper
+ = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control"
+ = icon('chevron-down')
+ .help-block.append-bottom-10
+ = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ about role permissions
+ .form-group
+ .clearable-input
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
+ %i.clear-icon.js-clear-input
+ = f.submit "Add to project", class: "btn btn-create"
+ = link_to "Import", import_project_project_members_path(@project), class: "btn btn-default", title: "Import members from another project"
diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_shared_group.html.haml
new file mode 100644
index 00000000000..c10ef648a8f
--- /dev/null
+++ b/app/views/projects/project_members/_new_shared_group.html.haml
@@ -0,0 +1,20 @@
+.row
+ .col-sm-12
+ = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
+ .form-group
+ = label_tag :link_group_id, "Select a group to share with", class: "label-light"
+ = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
+ .form-group
+ = label_tag :link_group_access, "Max access level", class: "label-light"
+ .select-wrapper
+ = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
+ = icon('chevron-down')
+ .help-block.append-bottom-10
+ = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ about role permissions
+ .form-group
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups'
+ %i.clear-icon.js-clear-input
+ = submit_tag "Share", class: "btn btn-create"
diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml
deleted file mode 100644
index 7902ddb1ae9..00000000000
--- a/app/views/projects/project_members/_shared_group_members.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- @project_group_links.each do |group_links|
- - shared_group = group_links.group
- - shared_group_members = shared_group.members
- - shared_group_users_count = shared_group_members.size
- .panel.panel-default
- .panel-heading
- Shared with
- %strong= shared_group.name
- group, members with
- %strong= group_links.human_access
- role (#{shared_group_users_count})
- - if can?(current_user, :admin_group, shared_group)
- .panel-head-actions
- = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do
- %i.fa.fa-pencil-square-o
- Edit group members
- %ul.content-list
- = render partial: 'shared/members/member',
- collection: shared_group_members.order(access_level: :desc).limit(20),
- as: :member,
- locals: { show_controls: false, show_roles: false }
- - if shared_group_users_count > 20
- %li
- and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)}
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 81d57c77edf..e71d58ec26d 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -1,13 +1,15 @@
.panel.panel-default
- .panel-heading
- Members with access to
- %strong= @project.name
+ .panel-heading.flex-project-members-panel
+ %span.flex-project-title
+ Members of
+ %strong
+ #{@project.name}
%span.badge= @project_members.total_count
- = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do
+ = form_tag project_project_members_path(@project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
%button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
= icon("search")
= render 'shared/members/sort_dropdown'
- %ul.content-list
+ %ul.content-list.members-list
= render partial: 'shared/members/member', collection: members, as: :member
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
index 42ce4f8001b..f6ca8d5a921 100644
--- a/app/views/projects/project_members/import.html.haml
+++ b/app/views/projects/project_members/import.html.haml
@@ -5,11 +5,11 @@
%p.light
Only project members will be imported. Group members will be skipped.
%hr
-= form_tag apply_import_namespace_project_project_members_path(@project.namespace, @project), method: 'post', class: 'form-horizontal' do
+= form_tag apply_import_project_project_members_path(@project), method: 'post', class: 'form-horizontal' do
.form-group
= label_tag :source_project_id, "Project", class: 'control-label'
.col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(current_user.authorized_projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
.form-actions
= button_tag 'Import project members', class: "btn btn-create"
- = link_to "Cancel", namespace_project_settings_members_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_project_members_path(@project), class: "btn btn-cancel"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
new file mode 100644
index 00000000000..9f7c5a315eb
--- /dev/null
+++ b/app/views/projects/project_members/index.html.haml
@@ -0,0 +1,44 @@
+- page_title "Members"
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Settings", edit_project_path(@project))
+
+.row.prepend-top-default
+ .col-lg-12
+ %h4
+ Project members
+ - if can?(current_user, :admin_project_member, @project)
+ %p
+ You can add a new member to
+ %strong= @project.name
+ or share it with another group.
+ - else
+ %p
+ Members can be added by project
+ %i Masters
+ or
+ %i Owners
+ .light
+ - if can?(current_user, :admin_project_member, @project)
+ %ul.nav-links.project-member-tabs{ role: 'tablist' }
+ %li.active{ role: 'presentation' }
+ %a{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
+ - if @project.allowed_to_share_with_group?
+ %li{ role: 'presentation' }
+ %a{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
+
+ .tab-content.project-member-tab-content
+ .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: 'Add member'
+ .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
+
+ = render 'shared/members/requests', membership_source: @project, requesters: @requesters
+ .clearfix
+ %h5.member.existing-title
+ Existing members and groups
+ - if @group_links.any?
+ = render 'projects/project_members/groups', group_links: @group_links
+
+ = render 'projects/project_members/team', members: @project_members
+ = paginate @project_members, theme: "gitlab"
diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml
index cf0db943865..5377d745371 100644
--- a/app/views/projects/protected_branches/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/_branches_list.html.haml
@@ -1,28 +1,4 @@
-.panel.panel-default.protected-branches-list
- - if @protected_branches.empty?
- .panel-heading
- %h3.panel-title
- Protected branch (#{@protected_branches.size})
- %p.settings-message.text-center
- There are currently no protected branches, protect a branch with the form above.
- - else
- - can_admin_project = can?(current_user, :admin_project, @project)
+- can_admin_project = can?(current_user, :admin_project, @project)
- %table.table.table-bordered
- %colgroup
- %col{ width: "25%" }
- %col{ width: "30%" }
- %col{ width: "25%" }
- %col{ width: "20%" }
- %thead
- %tr
- %th Protected branch (#{@protected_branches.size})
- %th Last commit
- %th Allowed to merge
- %th Allowed to push
- - if can_admin_project
- %th
- %tbody
- = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
-
- = paginate @protected_branches, theme: 'gitlab'
+= render layout: 'projects/protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do
+ = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index b8e885b4d9a..98d56a3e5c5 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,41 +1,14 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f|
- .panel.panel-default
- .panel-heading
- %h3.panel-title
- Protect a branch
- .panel-body
- .form-horizontal
- = form_errors(@protected_branch)
- .form-group
- = f.label :name, class: 'col-md-2 text-right' do
- Branch:
- .col-md-10
- = render partial: "projects/protected_branches/dropdown", locals: { f: f }
- .help-block
- = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
- such as
- %code *-stable
- or
- %code production/*
- are supported
- .form-group
- %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
- Allowed to merge:
- .col-md-10
- .merge_access_levels-container
- = dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-merge wide',
- dropdown_class: 'dropdown-menu-selectable',
- data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
- .form-group
- %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
- Allowed to push:
- .col-md-10
- .push_access_levels-container
- = dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-push wide',
- dropdown_class: 'dropdown-menu-selectable',
- data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
+- content_for :merge_access_levels do
+ .merge_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-merge wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
+- content_for :push_access_levels do
+ .push_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-push wide',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
- .panel-footer
- = f.submit 'Protect', class: 'btn-create btn', disabled: true
+= render 'projects/protected_branches/shared/create_protected_branch'
diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml
deleted file mode 100644
index 5af0cc7a2f3..00000000000
--- a/app/views/projects/protected_branches/_dropdown.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-= f.hidden_field(:name)
-
-= dropdown_tag('Select branch or create wildcard',
- options: { toggle_class: 'js-protected-branch-select js-filter-submit wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches",
- footer_content: true,
- data: { show_no: true, show_any: true, show_upcoming: true,
- selected: params[:protected_branch_name],
- project_id: @project.try(:id) } }) do
-
- %ul.dropdown-footer-list
- %li
- %button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" }
- Create wildcard
- %code
diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 2d8c519c025..2f30fe33a97 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,21 +1,10 @@
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_branches')
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
- Protected Branches
- %p Keep stable branches secure and force developers to use merge requests.
- %p.prepend-top-20
- By default, protected branches are designed to:
- %ul
- %li prevent their creation, if not already created, from everybody except Masters
- %li prevent pushes from everybody except Masters
- %li prevent <strong>anyone</strong> from force pushing to the branch
- %li prevent <strong>anyone</strong> from deleting the branch
- %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
- .col-lg-9
- - if can? current_user, :admin_project, @project
- = render 'projects/protected_branches/create_protected_branch'
+- content_for :create_protected_branch do
+ = render 'projects/protected_branches/create_protected_branch'
- = render "projects/protected_branches/branches_list"
+- content_for :branches_list do
+ = render "projects/protected_branches/branches_list"
+
+= render 'projects/protected_branches/shared/index'
diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml
deleted file mode 100644
index 8a5332ca5bb..00000000000
--- a/app/views/projects/protected_branches/_matching_branch.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-%tr
- %td
- = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name)
- - if @project.root_ref?(matching_branch.name)
- %span.label.label-info.prepend-left-5 default
- %td
- - commit = @project.commit(matching_branch.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_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index b2a6b8469a3..b12ae995ece 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,21 +1,2 @@
-%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
- %td
- = protected_branch.name
- - if @project.root_ref?(protected_branch.name)
- %span.label.label-info.prepend-left-5 default
- %td
- - if protected_branch.wildcard?
- - matching_branches = protected_branch.matching(repository.branches)
- = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
- - else
- - if commit = protected_branch.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
- (branch was removed from repository)
-
+= render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do
= render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch }
-
- - if can_admin_project
- %td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml
index d6044aacaec..c61b2951e1e 100644
--- a/app/views/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml
@@ -1,10 +1,10 @@
%td
= hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level
= dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container',
+ options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }})
%td
= hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level
= dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container',
+ options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }})
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
new file mode 100644
index 00000000000..2a0704bc7af
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -0,0 +1,30 @@
+.panel.panel-default.protected-branches-list.js-protected-branches-list
+ - if @protected_branches.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected branch (#{@protected_branches.size})
+ %p.settings-message.text-center
+ There are currently no protected branches, protect a branch with the form above.
+ - else
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ %col{ width: "20%" }
+ - if can_admin_project
+ %col
+ %thead
+ %tr
+ %th Protected branch (#{@protected_branches.size})
+ %th Last commit
+ %th Allowed to merge
+ %th Allowed to push
+ - if can_admin_project
+ %th
+ %tbody
+ %tr
+ %td.flash-container{ colspan: 5 }
+ = yield
+
+ = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
new file mode 100644
index 00000000000..9f0c4f3b3a8
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -0,0 +1,33 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ Protect a branch
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_branch)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Branch:
+ .col-md-10
+ = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
+ such as
+ %code *-stable
+ or
+ %code production/*
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
+ Allowed to merge:
+ .col-md-10
+ = yield :merge_access_levels
+ .form-group
+ %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
+ Allowed to push:
+ .col-md-10
+ = yield :push_access_levels
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
new file mode 100644
index 00000000000..6e9c473494e
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -0,0 +1,15 @@
+= f.hidden_field(:name)
+
+= dropdown_tag('Select branch or create wildcard',
+ options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches",
+ footer_content: true,
+ data: { show_no: true, show_any: true, show_upcoming: true,
+ selected: params[:protected_branch_name],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ %button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" }
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
new file mode 100644
index 00000000000..6a47cbdf724
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -0,0 +1,24 @@
+- expanded = Rails.env.test?
+
+%section.settings
+ .settings-header
+ %h4
+ Protected Branches
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Keep stable branches secure and force developers to use merge requests.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
+ By default, protected branches are designed to:
+ %ul
+ %li prevent their creation, if not already created, from everybody except Masters
+ %li prevent pushes from everybody except Masters
+ %li prevent <strong>anyone</strong> from force pushing to the branch
+ %li prevent <strong>anyone</strong> from deleting the branch
+ %p Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
+
+ - if can? current_user, :admin_project, @project
+ = content_for :create_protected_branch
+
+ = content_for :branches_list
diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
new file mode 100644
index 00000000000..98793d632e6
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
@@ -0,0 +1,10 @@
+%tr
+ %td
+ = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name'
+
+ - if @project.root_ref?(matching_branch.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - commit = @project.commit(matching_branch.name)
+ = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
new file mode 100644
index 00000000000..10b81e42572
--- /dev/null
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -0,0 +1,24 @@
+- can_admin_project = can?(current_user, :admin_project, @project)
+
+%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
+ %td
+ %span.ref-name= protected_branch.name
+
+ - if @project.root_ref?(protected_branch.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - if protected_branch.wildcard?
+ - matching_branches = protected_branch.matching(repository.branches)
+ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
+ - else
+ - if commit = protected_branch.commit
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (branch was removed from repository)
+
+ = yield
+
+ - if can_admin_project
+ %td
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index f8cfe5e4b11..1012ceefe93 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -2,7 +2,7 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.prepend-top-0.ref-name
= @protected_ref.name
.col-lg-9
@@ -19,7 +19,7 @@
%th Last commit
%tbody
- @matching_refs.each do |matching_branch|
- = render partial: "matching_branch", object: matching_branch
+ = render partial: "projects/protected_branches/shared/matching_branch", object: matching_branch
- else
%p.settings-message.text-center
Couldn't find any matching branches.
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index 6e187b54a59..ea91e8af70e 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,32 +1,8 @@
-= 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' }})
+- content_for :create_access_levels do
+ .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
+= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
deleted file mode 100644
index 74851519077..00000000000
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-= 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
index 0bfb1ad191d..955220562a6 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,18 +1,10 @@
- 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'
+- content_for :create_protected_tag do
+ = render 'projects/protected_tags/create_protected_tag'
- = render "projects/protected_tags/tags_list"
+- content_for :tag_list do
+ = render "projects/protected_tags/tags_list"
+
+= render 'projects/protected_tags/shared/index'
diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml
deleted file mode 100644
index 97e5cd6f9d2..00000000000
--- a/app/views/projects/protected_tags/_matching_tag.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-%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
index 26bd3a1f5ed..da1f97c8d6a 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -1,21 +1,2 @@
-%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 layout: 'projects/protected_tags/shared/protected_tag', locals: { protected_tag: protected_tag } do
= 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
index 728afd75b50..a6b18cc9f8f 100644
--- a/app/views/projects/protected_tags/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -1,28 +1,4 @@
-.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)
+- 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'
+= render layout: 'projects/protected_tags/shared/tags_list' do
+ = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project}
diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml
index 62823bee46e..cc80bd04dd0 100644
--- a/app/views/projects/protected_tags/_update_protected_tag.haml
+++ b/app/views/projects/protected_tags/_update_protected_tag.haml
@@ -1,5 +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',
+ options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header 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/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
new file mode 100644
index 00000000000..5a53c704fcb
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -0,0 +1,29 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ 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.protected-tags-dropdown
+ = render partial: "projects/protected_tags/shared/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
+ = yield :create_access_levels
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
new file mode 100644
index 00000000000..9b6923210f7
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_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 git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags",
+ footer_content: true,
+ data: { show_no: true, show_any: true, show_upcoming: true,
+ selected: params[:protected_tag_name],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
new file mode 100644
index 00000000000..c07bd454ff6
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -0,0 +1,24 @@
+- expanded = Rails.env.test?
+
+%section.settings
+ .settings-header
+ %h4
+ Protected Tags
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Limit access to creating and updating tags.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
+ By default, protected tags are designed to:
+ %ul
+ %li Prevent tag creation by everybody except Masters
+ %li Prevent <strong>anyone</strong> from updating the tag
+ %li Prevent <strong>anyone</strong> from deleting the tag
+
+ %p Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}.
+
+ - if can? current_user, :admin_project, @project
+ = yield :create_protected_tag
+
+ = yield :tag_list
diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
new file mode 100644
index 00000000000..05f102d1ca3
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
@@ -0,0 +1,10 @@
+%tr
+ %td
+ = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-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, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
new file mode 100644
index 00000000000..c778f7b9781
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -0,0 +1,22 @@
+%tr.js-protected-tag-edit-form{ data: { url: project_protected_tag_path(@project, protected_tag) } }
+ %td
+ %span.ref-name= 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"), project_protected_tag_path(@project, protected_tag)
+ - else
+ - if commit = protected_tag.commit
+ = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (tag was removed from repository)
+
+ = yield
+
+ - if can? current_user, :admin_project, @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/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
new file mode 100644
index 00000000000..3f42ae58438
--- /dev/null
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -0,0 +1,30 @@
+.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%" }
+ - if can_admin_project
+ %col
+ %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 }
+ = yield
+
+ = paginate @protected_tags, theme: 'gitlab'
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 63743f28b3c..86629f1753b 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -2,10 +2,10 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.prepend-top-0.ref-name
= @protected_ref.name
- .col-lg-9
+ .col-lg-9.edit_protected_tag
%h5 Matching Tags
- if @matching_refs.present?
.table-responsive
@@ -19,7 +19,7 @@
%th Last commit
%tbody
- @matching_refs.each do |matching_tag|
- = render partial: "matching_tag", object: matching_tag
+ = render partial: "projects/protected_tags/shared/matching_tag", object: matching_tag
- else
%p.settings-message.text-center
Couldn't find any matching tags.
diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml
index 8bc78f8d018..a0535edafc3 100644
--- a/app/views/projects/registry/repositories/_image.html.haml
+++ b/app/views/projects/registry/repositories/_image.html.haml
@@ -6,13 +6,14 @@
= clipboard_button(clipboard_text: "docker pull #{image.location}")
- .controls.hidden-xs.pull-right
- = link_to namespace_project_container_registry_path(@project.namespace, @project, image),
- class: 'btn btn-remove has-tooltip',
- title: 'Remove repository',
- data: { confirm: 'Are you sure?' },
- method: :delete do
- = icon('trash cred', 'aria-hidden': 'true')
+ - if can?(current_user, :update_container_image, @project)
+ .controls.hidden-xs.pull-right
+ = link_to project_container_registry_path(@project, image),
+ class: 'btn btn-remove has-tooltip',
+ title: 'Remove repository',
+ data: { confirm: 'Are you sure?' },
+ method: :delete do
+ = icon('trash cred', 'aria-hidden': 'true')
.container-image-tags.js-toggle-content.hide
- if image.has_tags?
@@ -29,4 +30,3 @@
= render partial: 'tag', collection: image.tags
- else
.nothing-here-block No tags in Container Registry for this container image.
-
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index 378a23f07e6..0b082a2137f 100644
--- a/app/views/projects/registry/repositories/_tag.html.haml
+++ b/app/views/projects/registry/repositories/_tag.html.haml
@@ -25,7 +25,7 @@
- if can?(current_user, :update_container_image, @project)
%td.content
.controls.hidden-xs.pull-right
- = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
+ = link_to project_registry_repository_tag_path(@project, tag.repository, tag.name),
method: :delete,
class: 'btn btn-remove has-tooltip',
title: 'Remove tag',
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index be128e92fa7..5661af01302 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,26 +1,60 @@
- page_title "Container Registry"
-%hr
-
-%ul.content-list
- %li.light.prepend-top-default
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = page_title
%p
- A 'container image' is a snapshot of a container.
- You can host your container images with GitLab.
- %br
- To start using container images hosted on GitLab you first need to login:
- %pre
- %code
+ With the Docker Container Registry integrated into GitLab, every project
+ can have its own space to store its Docker images.
+ %p.append-bottom-0
+ = succeed '.' do
+ Learn more about
+ = link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank'
+
+ .col-lg-9
+ .panel.panel-default
+ .panel-heading
+ %h4.panel-title
+ How to use the Container Registry
+ .panel-body
+ %p
+ First log in to GitLab&rsquo;s Container Registry using your GitLab username
+ and password. If you have
+ = link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank'
+ you need to use a
+ = succeed ':' do
+ = link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank'
+ %pre
docker login #{Gitlab.config.registry.host_port}
- %br
- Then you are free to create and upload a container image with build and push commands:
- %pre
- docker build -t #{escape_once(@project.container_registry_url)}/image .
%br
- docker push #{escape_once(@project.container_registry_url)}/image
+ %p
+ Once you log in, you&rsquo;re free to create and upload a container image
+ using the common
+ %code build
+ and
+ %code push
+ commands:
+ %pre
+ :plain
+ docker build -t #{escape_once(@project.container_registry_url)} .
+ docker push #{escape_once(@project.container_registry_url)}
- - if @images.blank?
- .nothing-here-block No container image repositories in Container Registry for this project.
+ %hr
+ %h5.prepend-top-default
+ Use different image names
+ %p.light
+ GitLab supports up to 3 levels of image names. The following
+ examples of images are valid for your project:
+ %pre
+ :plain
+ #{escape_once(@project.container_registry_url)}:tag
+ #{escape_once(@project.container_registry_url)}/optional-image-name:tag
+ #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
- - else
- = render partial: 'image', collection: @images
+ - if @images.blank?
+ %p.settings-message.text-center.append-bottom-default
+ No container images stored for this project. Add one by following the
+ instructions above.
+ - else
+ = render partial: 'image', collection: @images
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
index 79d8d721aa9..0a5a38a3694 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -10,11 +10,11 @@
%strong= @tag.name
- = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
+ = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.error-alert
.prepend-top-default
= f.submit 'Save changes', class: 'btn btn-save'
- = link_to "Cancel", namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-default btn-cancel"
+ = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn btn-default btn-cancel"
diff --git a/app/views/projects/remove_fork.js.haml b/app/views/projects/remove_fork.js.haml
index 17b9fecfeb1..6d083c5c516 100644
--- a/app/views/projects/remove_fork.js.haml
+++ b/app/views/projects/remove_fork.js.haml
@@ -1,2 +1,2 @@
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml
index d9c39fb87b7..170f9e259df 100644
--- a/app/views/projects/repositories/_feed.html.haml
+++ b/app/views/projects/repositories/_feed.html.haml
@@ -1,7 +1,7 @@
- commit = update
%tr
%td
- = link_to namespace_project_commits_path(@project.namespace, @project, commit.head.name) do
+ = link_to project_commits_path(@project, commit.head.name) do
%strong
= commit.head.name
- if @project.root_ref?(commit.head.name)
@@ -9,7 +9,7 @@
%td
%div
- = link_to namespace_project_commits_path(@project.namespace, @project, commit.id) do
+ = link_to project_commits_path(@project, commit.id) do
%code= commit.short_id
= image_tag avatar_icon(commit.author_email), class: "", width: 16, alt: ''
= markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author)
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index deeadb609f6..abc97bcdff5 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -1,15 +1,18 @@
%li.runner{ id: dom_id(runner) }
%h4
= runner_status_icon(runner)
- %span.monospace
- - if @project_runners.include?(runner)
- = link_to runner.short_sha, runner_path(runner)
- - if runner.locked?
- = icon('lock', class: 'has-tooltip', title: 'Locked to current projects')
- %small
- = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do
- %i.fa.fa-edit.btn
- - else
+
+ - if @project_runners.include?(runner)
+ = link_to runner.short_sha, runner_path(runner), class: 'commit-sha'
+
+ - if runner.locked?
+ = icon('lock', class: 'has-tooltip', title: 'Locked to current projects')
+
+ %small
+ = link_to edit_project_runner_path(@project, runner) do
+ %i.fa.fa-edit.btn
+ - else
+ %span.commit-sha
= runner.short_sha
.pull-right
@@ -18,7 +21,7 @@
= link_to 'Remove Runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner)
- = link_to 'Disable for this project', namespace_project_runner_project_path(@project.namespace, @project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
+ = link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- elsif runner.specific?
= form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index 0671dd66e78..a4e820628f3 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -9,10 +9,10 @@
on GitLab.com).
%hr
- if @project.shared_runners_enabled?
- = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-warning', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-warning', method: :post do
Disable shared Runners
- else
- = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-success', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
Enable shared Runners
&nbsp; for this project
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 6b8e6bd4fee..28ccbf7eb15 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -1,21 +1,8 @@
%h3 Specific Runners
-.bs-callout.help-callout
- %h4 How to setup a specific Runner for a new project
-
- %ol
- %li
- Install a Runner compatible with GitLab CI
- (checkout the #{link_to 'GitLab Runner section', 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'} for information on how to install it).
- %li
- Specify the following URL during the Runner setup:
- %code= ci_root_url(only_path: false)
- %li
- Use the following registration token during setup:
- %code= @project.runners_token
- %li
- Start the Runner!
-
+= render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: @project.runners_token,
+ type: 'specific' }
- if @project_runners.any?
%h4.underlined-title Runners activated for this project
diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml
index 95706888655..78dc4817ed7 100644
--- a/app/views/projects/runners/edit.html.haml
+++ b/app/views/projects/runners/edit.html.haml
@@ -3,4 +3,4 @@
%h4 Runner ##{@runner.id}
%hr
- = render 'form', runner: @runner, runner_form_url: runner_path(@runner)
+ = render 'form', runner: @runner, runner_form_url: runner_path(@runner)
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index f1a80f1d5e1..b842fd57cf3 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,3 +1,6 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('integrations')
+
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
@@ -6,15 +9,22 @@
%p= @service.description
.col-lg-9
- = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'form-horizontal' }) do |form|
+ = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
= render 'shared/service_settings', form: form, subject: @service
- .footer-block.row-content-block
- = form.submit 'Save changes', class: 'btn btn-save'
- &nbsp;
- - if @service.valid? && @service.activated?
- - unless @service.can_test?
- - disabled_class = 'disabled'
- - disabled_title = @service.disabled_title
+ - if @service.editable?
+ .footer-block.row-content-block
+ %button.btn.btn-save{ type: 'submit' }
+ = icon('spinner spin', class: 'hidden js-btn-spinner')
+ %span.js-btn-label
+ Save changes
+ &nbsp;
+ - if @service.valid? && @service.activated?
+ - unless @service.can_test?
+ - disabled_class = 'disabled'
+ - disabled_title = @service.disabled_title
+
+ = link_to 'Cancel', project_settings_integrations_path(@project), class: 'btn btn-cancel'
- = link_to 'Test settings', test_namespace_project_service_path(@project.namespace, @project, @service), class: "btn #{disabled_class}", title: disabled_title
- = link_to "Cancel", namespace_project_settings_integrations_path(@project.namespace, @project), class: "btn btn-cancel"
+- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true)
+ %hr
+ = render "projects/services/#{@service.to_param}/show"
diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml
index 86d5a0ec7b8..915c6b22162 100644
--- a/app/views/projects/services/_index.html.haml
+++ b/app/views/projects/services/_index.html.haml
@@ -1,9 +1,9 @@
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
Project services
%p Project services allow you to integrate GitLab with other applications
- .col-lg-9
+ .col-lg-8
%table.table
%colgroup
%col
@@ -21,7 +21,7 @@
%td{ "aria-label" => "#{service.title}: status " + (service.activated? ? "on" : "off") }
= boolean_to_icon service.activated?
%td
- = link_to edit_namespace_project_service_path(@project.namespace, @project, service.to_param) do
+ = link_to edit_project_service_path(@project, service.to_param) do
%strong= service.title
%td.hidden-xs
= service.description
diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml
index 0f1a76a104a..8056217bb1e 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -1,3 +1,8 @@
+- breadcrumb_title "Integrations"
- page_title @service.title, "Services"
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Settings", edit_project_path(@project))
+
= render "projects/settings/head"
= render 'form'
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index ef3599460f1..5dbcbf7eba6 100644
--- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
@@ -39,7 +39,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
.form-group
= label_tag :request_url, 'Request URL', class: 'col-sm-2 col-xs-12 control-label'
diff --git a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
index fcc91be11cd..44c0b7a90dc 100644
--- a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
@@ -2,6 +2,6 @@
- unless @service.activated?
.row
.col-sm-9.col-sm-offset-3
- = link_to new_namespace_project_mattermost_path(@project.namespace, @project), class: 'btn btn-lg' do
+ = link_to new_project_mattermost_path(@project), class: 'btn btn-lg' do
= custom_icon('mattermost_logo', size: 15)
Add to Mattermost
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
new file mode 100644
index 00000000000..d8e11500964
--- /dev/null
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -0,0 +1,45 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('prometheus_metrics')
+
+.row.prepend-top-default.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
+ .col-lg-3
+ %h4.prepend-top-0
+ Metrics
+ %p
+ Metrics are automatically configured and monitored
+ based on a library of metrics from popular exporters.
+ = link_to 'More information', help_page_path('user/project/integrations/prometheus')
+
+ .col-lg-9
+ .panel.panel-default.js-panel-monitored-metrics{ data: { "active-metrics" => "#{project_prometheus_active_metrics_path(@project, :json)}" } }
+ .panel-heading
+ %h3.panel-title
+ Monitored
+ %span.badge.js-monitored-count 0
+ .panel-body
+ .loading-metrics.text-center.js-loading-metrics
+ = icon('spinner spin 3x', class: 'metrics-load-spinner')
+ %p Finding and configuring metrics...
+ .empty-metrics.text-center.hidden.js-empty-metrics
+ = custom_icon('icon_empty_metrics')
+ %p No metrics are being monitored. To start monitoring, deploy to an environment.
+ = link_to project_environments_path(@project), title: 'View environments', class: 'btn btn-success' do
+ View environments
+ %ul.list-unstyled.metrics-list.hidden.js-metrics-list
+
+ .panel.panel-default.hidden.js-panel-missing-env-vars
+ .panel-heading
+ %h3.panel-title
+ = icon('caret-right lg fw', class: 'panel-toggle js-panel-toggle', 'aria-label' => 'Toggle panel')
+ Missing environment variable
+ %span.badge.js-env-var-count 0
+ .panel-body.hidden
+ .flash-container
+ .flash-notice
+ .flash-text
+ To set up automatic monitoring, add the environment variable
+ %code
+ $CI_ENVIRONMENT_SLUG
+ to exporter&rsquo;s queries.
+ = link_to 'More information', help_page_path('user/project/integrations/prometheus', anchor: 'metrics-and-labels')
+ %ul.list-unstyled.metrics-list.js-missing-var-metrics-list
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 73b99453a4b..c31c95608c6 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -33,7 +33,7 @@
Suggestions:
%code= 'gitlab'
%code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ %code= @project.full_path
.form-group
= label_tag :url, 'URL', class: 'col-sm-2 col-xs-12 control-label'
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
index e50a543ffa8..15ba09b10ba 100644
--- a/app/views/projects/settings/_head.html.haml
+++ b/app/views/projects/settings/_head.html.haml
@@ -9,25 +9,22 @@
= link_to edit_project_path(@project), title: 'General' do
%span
General
- = nav_link(controller: :members) do
- = link_to project_settings_members_path(@project), title: 'Members' do
- %span
- Members
- if can_edit
- = nav_link(controller: [:integrations, :services]) do
+ = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do
%span
Integrations
= nav_link(controller: :repository) do
- = link_to namespace_project_settings_repository_path(@project.namespace, @project), title: 'Repository' do
+ = link_to project_settings_repository_path(@project), title: 'Repository' do
%span
Repository
- if @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
- = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do
+ = link_to project_settings_ci_cd_path(@project), title: 'Pipelines' do
%span
- CI/CD Pipelines
- = nav_link(controller: :pages) do
- = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do
- %span
- Pages
+ Pipelines
+ - if Gitlab.config.pages.enabled
+ = nav_link(controller: :pages) do
+ = link_to project_pages_path(@project), title: 'Pages' do
+ %span
+ Pages
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index e2603096014..0c4130857da 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,7 +1,12 @@
-- page_title "CI/CD Pipelines"
+- @content_class = "limit-container-width" unless fluid_layout
+- page_title "Pipelines"
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Settings", edit_project_path(@project))
+
= render "projects/settings/head"
= render 'projects/runners/index'
-= render 'projects/variables/index'
+= render 'ci/variables/index'
= render 'projects/triggers/index'
= render 'projects/pipelines_settings/show'
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
index ceabe2eab3d..d5792e95f5a 100644
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ b/app/views/projects/settings/integrations/_project_hook.html.haml
@@ -3,13 +3,14 @@
.col-md-8.col-lg-7
%strong.light-header= hook.url
%div
- - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger|
- - if hook.send(trigger)
- %span.label.label-gray.deploy-project-label= trigger.titleize
+ - ProjectHook::TRIGGERS.each_value do |event|
+ - if hook.public_send(event)
+ %span.label.label-gray.deploy-project-label= event.to_s.titleize
.col-md-4.col-lg-5.text-right-lg.prepend-top-5
%span.append-right-10.inline
- SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"}
- = link_to "Test", test_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm"
- = link_to namespace_project_hook_path(@project.namespace, @project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent" do
+ SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
+ = link_to 'Edit', edit_project_hook_path(@project, hook), class: 'btn btn-sm'
+ = render 'shared/web_hooks/test_button', triggers: ProjectHook::TRIGGERS, hook: hook, button_class: 'btn-small'
+ = link_to project_hook_path(@project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-transparent' do
%span.sr-only Remove
= icon('trash')
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index f69992566b5..149da96d3f6 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -1,4 +1,7 @@
+- @content_class = "limit-container-width" unless fluid_layout
- page_title 'Integrations'
+- if show_new_nav?
+ - add_to_breadcrumbs("Settings", edit_project_path(@project))
= render "projects/settings/head"
= render 'projects/hooks/index'
= render 'projects/services/index'
diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml
index 20e1ad68244..1e7695ac397 100644
--- a/app/views/projects/settings/members/show.html.haml
+++ b/app/views/projects/settings/members/show.html.haml
@@ -1,7 +1,6 @@
+- @content_class = "limit-container-width" unless fluid_layout
+
- page_title "Members"
= render "projects/settings/head"
= render "projects/project_members/index"
-- if can?(current_user, :admin_project, @project)
- - if @project.allowed_to_share_with_group?
- = render "projects/group_links/index"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 5402320cb66..cb37f3c7580 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,6 +1,19 @@
- page_title "Repository"
+- @content_class = "limit-container-width" unless fluid_layout
+
+- if show_new_nav?
+ - add_to_breadcrumbs("Settings", edit_project_path(@project))
+
= render "projects/settings/head"
-= render @deploy_keys
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('common_vue')
+ = page_specific_javascript_bundle_tag('deploy_keys')
+
+-# Protected branches & tags use a lot of nested partials.
+-# The shared parts of the views can be found in the `shared` directory.
+-# Those are used throughout the actual views. These `shared` views are then
+-# reused in EE.
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
+= render @deploy_keys
diff --git a/app/views/projects/show.atom.builder b/app/views/projects/show.atom.builder
index 5c7f2e315f0..39f8cb9a0e0 100644
--- a/app/views/projects/show.atom.builder
+++ b/app/views/projects/show.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@project.name} activity"
- xml.link href: namespace_project_url(@project.namespace, @project, rss_url_options), rel: "self", type: "application/atom+xml"
- xml.link href: namespace_project_url(@project.namespace, @project), rel: "alternate", type: "text/html"
- xml.id namespace_project_url(@project.namespace, @project)
- xml.updated @events[0].updated_at.xmlschema if @events[0]
+xml.title "#{@project.name} activity"
+xml.link href: project_url(@project, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: project_url(@project), rel: "alternate", type: "text/html"
+xml.id project_url(@project)
+xml.updated @events[0].updated_at.xmlschema if @events[0]
- xml << render(@events) if @events.any?
-end
+xml << render(@events) if @events.any?
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index d6c4195e2d0..a9b39cedb1d 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,12 +1,11 @@
- @no_container = true
+- breadcrumb_title "Project"
+- @content_class = "limit-container-width" unless fluid_layout
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, namespace_project_path(@project.namespace, @project, rss_url_options), title: "#{@project.name} activity")
+ = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
-= content_for :flash_message do
- - if current_user && can?(current_user, :download_code, @project)
- = render 'shared/no_ssh'
- = render 'shared/no_password'
+= render partial: 'flash_messages', locals: { project: @project }
= render "projects/head"
= render "projects/last_push"
@@ -16,25 +15,25 @@
%nav.project-stats{ class: container_class }
%ul.nav
%li
- = link_to project_files_path(@project) do
- Files (#{storage_counter(@project.statistics.total_repository_size)})
+ = link_to project_tree_path(@project) do
+ #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
%li
- = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- #{'Commit'.pluralize(@project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
+ = link_to project_commits_path(@project, current_ref) do
+ #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
%li
- = link_to namespace_project_branches_path(@project.namespace, @project) do
- #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
+ = link_to project_branches_path(@project) do
+ #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
%li
- = link_to namespace_project_tags_path(@project.namespace, @project) do
- #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
+ = link_to project_tags_path(@project) do
+ #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
- if default_project_view != 'readme' && @repository.readme
%li
- = link_to 'Readme', readme_path(@project)
+ = link_to _('Readme'), readme_path(@project)
- if @repository.changelog
%li
- = link_to 'Changelog', changelog_path(@project)
+ = link_to _('Changelog'), changelog_path(@project)
- if @repository.license_blob
%li
@@ -42,48 +41,43 @@
- if @repository.contribution_guide
%li
- = link_to 'Contribution guide', contribution_guide_path(@project)
+ = link_to _('Contribution guide'), contribution_guide_path(@project)
- if @repository.gitlab_ci_yml
%li
- = link_to 'CI configuration', ci_configuration_path(@project)
+ = link_to _('CI configuration'), ci_configuration_path(@project)
- if current_user && can_push_branch?(@project, @project.default_branch)
- unless @repository.changelog
%li.missing
= link_to add_special_file_path(@project, file_name: 'CHANGELOG') do
- Add Changelog
+ #{ _('Add Changelog') }
- unless @repository.license_blob
%li.missing
= link_to add_special_file_path(@project, file_name: 'LICENSE') do
- Add License
+ #{ _('Add License') }
- unless @repository.contribution_guide
%li.missing
= link_to add_special_file_path(@project, file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') do
- Add Contribution guide
+ #{ _('Add Contribution guide') }
- unless @repository.gitlab_ci_yml
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
- Set up CI
+ #{ _('Set up CI') }
- if koding_enabled? && @repository.koding_yml.blank?
%li.missing
- = link_to 'Set up Koding', add_koding_stack_path(@project)
+ = link_to _('Set up Koding'), add_koding_stack_path(@project)
- if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present?
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do
- Set up auto deploy
+ #{ _('Set up auto deploy') }
- - if @repository.commit
- %div{ class: container_class }
- .project-last-commit
- = render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project
-
-%div{ class: container_class }
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
= icon("exclamation-triangle fw")
- Archived project! Repository is read-only
+ #{ _('Archived project! Repository is read-only') }
- view_path = default_project_view
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 34ee4ff1937..f09871c7fcc 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -2,16 +2,16 @@
.hidden-xs
- if can?(current_user, :update_project_snippet, @snippet)
- = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do
+ = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do
Edit
- if can?(current_user, :update_project_snippet, @snippet)
- = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
+ = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
Delete
- if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do
+ = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do
New snippet
- if @snippet.submittable_as_spam_by?(current_user)
- = link_to 'Submit as spam', mark_as_spam_namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam'
- if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet)
.visible-xs-block.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
@@ -21,16 +21,16 @@
%ul
- if can?(current_user, :create_project_snippet, @project)
%li
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), title: "New snippet" do
+ = link_to new_project_snippet_path(@project), title: "New snippet" do
New snippet
- if can?(current_user, :update_project_snippet, @snippet)
%li
- = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do
+ = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do
Delete
- if can?(current_user, :update_project_snippet, @snippet)
%li
- = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet) do
+ = link_to edit_project_snippet_path(@project, @snippet) do
Edit
- if @snippet.submittable_as_spam_by?(current_user)
%li
- = link_to 'Submit as spam', mark_as_spam_namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :post
+ = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post
diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml
index 24b92094b7d..d41cc8e0425 100644
--- a/app/views/projects/snippets/edit.html.haml
+++ b/app/views/projects/snippets/edit.html.haml
@@ -3,4 +3,4 @@
%h3.page-title
Edit Snippet
%hr
-= render "shared/snippets/form", url: namespace_project_snippet_path(@project.namespace, @project, @snippet)
+= render "shared/snippets/form", url: project_snippet_path(@project, @snippet)
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 84e05cd6d88..ccc5fe80755 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -1,19 +1,16 @@
- page_title "Snippets"
+- if show_new_nav? && can?(current_user, :create_project_snippet, @project)
+ - content_for :breadcrumbs_extra do
+ = link_to "New snippet", new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet"
+
- if current_user
.top-area
- include_private = @project.team.member?(current_user) || current_user.admin?
= render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
- .nav-controls.hidden-xs
+ .nav-controls{ class: ("visible-xs" if show_new_nav?) }
- if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet" do
- New snippet
-
-- if can?(current_user, :create_project_snippet, @project)
- .visible-xs
- &nbsp;
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-block", title: "New snippet" do
- New snippet
+ = link_to "New snippet", new_project_snippet_path(@project), class: "btn btn-new", title: "New snippet"
= render 'snippets/snippets'
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index cfed3a79bc5..d3e6b456f48 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -3,4 +3,4 @@
%h3.page-title
New Snippet
%hr
-= render "shared/snippets/form", url: namespace_project_snippets_path(@project.namespace, @project, @snippet)
+= render "shared/snippets/form", url: project_snippets_path(@project, @snippet)
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 7c6be003d4c..d8e448dd2af 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -1,12 +1,13 @@
+- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
.project-snippets
%article.file-holder.snippet-file-content
- = render 'shared/snippets/blob', raw_path: raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
+ = render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes= render "projects/notes/notes_with_form"
+ #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/stage/_graph.html.haml b/app/views/projects/stage/_graph.html.haml
deleted file mode 100644
index 4ee30b023ac..00000000000
--- a/app/views/projects/stage/_graph.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-- stage = local_assigns.fetch(:stage)
-- statuses = stage.statuses.latest
-- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name)
-%li.stage-column
- .stage-name
- %a{ name: stage.name }
- = stage.name.titleize
- .builds-container
- %ul
- - status_groups.each do |group_name, grouped_statuses|
- - if grouped_statuses.one?
- - status = grouped_statuses.first
- %li.build{ 'id' => "ci-badge-#{group_name}" }
- .curve
- = render 'ci/status/graph_badge', subject: status
- - else
- %li.build{ 'id' => "ci-badge-#{group_name}" }
- .curve
- = render 'projects/stage/in_stage_group', name: group_name, subject: grouped_statuses
diff --git a/app/views/projects/stage/_in_stage_group.html.haml b/app/views/projects/stage/_in_stage_group.html.haml
deleted file mode 100644
index 671a3ef481c..00000000000
--- a/app/views/projects/stage/_in_stage_group.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- group_status = CommitStatus.where(id: subject).status
-%button.dropdown-menu-toggle.build-content.has-tooltip{ type: 'button', data: { toggle: 'dropdown', title: "#{name} - #{group_status}", container: 'body' } }
- %span{ class: "ci-status-icon ci-status-icon-#{group_status}" }
- = ci_icon_for_status(group_status)
- %span.ci-status-text
- = name
- %span.dropdown-counter-badge= subject.size
-
-%ul.dropdown-menu.big-pipeline-graph-dropdown-menu.js-grouped-pipeline-dropdown
- .arrow
- .scrollable-menu
- - subject.each do |status|
- %li
- = render 'ci/status/dropdown_graph_badge', subject: status
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 451e011a4b8..468ab922542 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,10 +2,9 @@
- release = @releases.find { |release| release.tag == tag.name }
%li.flex-row
.row-main-content.str-truncated
- = link_to namespace_project_tag_path(@project.namespace, @project, tag.name) do
- %span.item-title
- = icon('tag')
- = tag.name
+ = link_to project_tag_path(@project, tag.name), class: 'item-title ref-name' do
+ = icon('tag')
+ = tag.name
- if protected_tag?(@project, tag)
%span.label.label-success
@@ -24,16 +23,15 @@
- if release && release.description.present?
.description.prepend-top-default
.wiki
- = preserve do
- = markdown_field(release, :description)
+ = markdown_field(release, :description)
.row-fixed-content.controls
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :push_code, @project)
- = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do
+ = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do
= 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 #{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
+ = link_to project_tag_path(@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/index.html.haml b/app/views/projects/tags/index.html.haml
index 7f9a44e565f..00000e0667c 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,7 +1,11 @@
- @no_container = true
+- @sort ||= sort_value_recently_updated
- page_title "Tags"
= render "projects/commits/head"
+- if show_new_nav?
+ - add_to_breadcrumbs("Repository", project_tree_path(@project))
+
.flex-list{ class: container_class }
.top-area.adjust
.nav-text.row-main-content
@@ -14,18 +18,16 @@
.dropdown
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} }
%span.light
- = projects_sort_options_hash[@sort]
+ = tags_sort_options_hash[@sort]
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li
- = link_to filter_tags_path(sort: sort_value_name) do
- = sort_title_name
- = link_to filter_tags_path(sort: sort_value_recently_updated) do
- = sort_title_recently_updated
- = link_to filter_tags_path(sort: sort_value_oldest_updated) do
- = sort_title_oldest_updated
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ Sort by
+ - tags_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
- if can?(current_user, :push_code, @project)
- = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do
+ = link_to new_project_tag_path(@project), class: 'btn btn-create new-tag-btn' do
New tag
.tags
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 160d4c7a223..521b4d927bc 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -1,4 +1,5 @@
- page_title "New Tag"
+- default_ref = params[:ref] || @project.default_branch
- if @error
.alert.alert-danger
@@ -16,30 +17,27 @@
= text_field_tag :tag_name, params[:tag_name], required: true, tabindex: 1, autofocus: true, class: 'form-control'
.form-group
= label_tag :ref, 'Create from', class: 'control-label'
- .col-sm-10
- = text_field_tag :ref, params[:ref] || @project.default_branch, required: true, tabindex: 2, class: 'form-control'
- .help-block Branch name or commit SHA
+ .col-sm-10.create-from
+ .dropdown
+ = hidden_field_tag :ref, default_ref
+ = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
+ .text-left.dropdown-toggle-text= default_ref
+ = render 'shared/ref_dropdown', dropdown_class: 'wide'
+ .help-block Existing branch name, tag, or commit SHA
.form-group
= label_tag :message, nil, class: 'control-label'
.col-sm-10
- = text_area_tag :message, nil, required: false, tabindex: 3, class: 'form-control', rows: 5
+ = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5
.help-block Optionally, add a message to the tag.
%hr
.form-group
= label_tag :release_description, 'Release notes', class: 'control-label'
.col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
- = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
- = render 'projects/notes/hints'
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description
+ = render 'shared/notes/hints'
.help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.
.form-actions
= button_tag 'Create tag', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', namespace_project_tags_path(@project.namespace, @project), class: 'btn btn-cancel'
-
-:javascript
- var availableRefs = #{@project.repository.ref_names.to_json};
-
- $("#ref").autocomplete({
- source: availableRefs,
- minLength: 1
- });
+ = link_to 'Cancel', project_tags_path(@project), class: 'btn btn-cancel'
+%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 1c4135c8a54..d02cd70f4c3 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -6,7 +6,9 @@
.top-area.multi-line
.nav-text
.title
- %span.item-title= @tag.name
+ %span.item-title.ref-name
+ = icon('tag')
+ = @tag.name
- if protected_tag?(@project, @tag)
%span.label.label-success
protected
@@ -17,17 +19,17 @@
.nav-controls.controls-flex
- if can?(current_user, :push_code, @project)
- = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Edit release notes' do
+ = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Edit release notes' do
= icon("pencil")
- = link_to namespace_project_tree_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse files' do
+ = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse files' do
= icon('files-o')
- = link_to namespace_project_commits_path(@project.namespace, @project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse commits' do
+ = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse commits' do
= icon('history')
.btn-container.controls-item
= 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 #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
+ = link_to project_tag_path(@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?
@@ -38,7 +40,6 @@
- if @release.description.present?
.description
.wiki
- = preserve do
- = markdown_field(@release, :description)
+ = markdown_field(@release, :description)
- else
This tag has no release notes.
diff --git a/app/views/projects/transfer.js.haml b/app/views/projects/transfer.js.haml
index 17b9fecfeb1..6d083c5c516 100644
--- a/app/views/projects/transfer.js.haml
+++ b/app/views/projects/transfer.js.haml
@@ -1,2 +1,2 @@
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml
index 425b460eb09..fd8175e1e01 100644
--- a/app/views/projects/tree/_blob_item.html.haml
+++ b/app/views/projects/tree/_blob_item.html.haml
@@ -2,7 +2,7 @@
%td.tree-item-file-name
= tree_icon(type, blob_item.mode, blob_item.name)
- file_name = blob_item.name
- = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do
+ = link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do
%span.str-truncated= file_name
%td.hidden-xs.tree-commit
%td.tree-time-ago.cgray.text-right
diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml
new file mode 100644
index 00000000000..820b947804e
--- /dev/null
+++ b/app/views/projects/tree/_old_tree_content.html.haml
@@ -0,0 +1,24 @@
+.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
+ .table-holder
+ %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
+ %thead
+ %tr
+ %th= s_('ProjectFileTree|Name')
+ %th.hidden-xs
+ .pull-left= _('Last commit')
+ %th.text-right= _('Last Update')
+ - if @path.present?
+ %tr.tree-item
+ %td.tree-item-file-name
+ = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
+ %td
+ %td.hidden-xs
+
+ = render_tree(tree)
+
+ - if tree.readme
+ = render "projects/tree/readme", readme: tree.readme
+
+- if can_edit_tree?
+ = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
+ = render 'projects/blob/new_dir'
diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml
new file mode 100644
index 00000000000..13705ca303b
--- /dev/null
+++ b/app/views/projects/tree/_old_tree_header.html.haml
@@ -0,0 +1,70 @@
+%ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to project_tree_path(@project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ %li
+ = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
+
+ - if current_user
+ %li
+ - if !on_top_of_branch?
+ %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } }
+ = icon('plus')
+ - else
+ %span.dropdown
+ %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" }
+ = icon('plus')
+ .add-to-tree-dropdown
+ %ul.dropdown-menu
+ - if can_edit_tree?
+ %li
+ = link_to project_new_blob_path(@project, @id) do
+ = icon('pencil fw')
+ #{ _('New file') }
+ %li
+ = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
+ = icon('file fw')
+ #{ _('Upload file') }
+ %li
+ = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
+ = icon('folder fw')
+ #{ _('New directory') }
+ - elsif can?(current_user, :fork_project, @project)
+ %li
+ - continue_params = { to: project_new_blob_path(@project, @id),
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ = icon('pencil fw')
+ #{ _('New file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to upload a file again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ = icon('file fw')
+ #{ _('Upload file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to create a new directory again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ = icon('folder fw')
+ #{ _('New directory') }
+
+ %li.divider
+ %li
+ = link_to new_project_branch_path(@project) do
+ = icon('code-fork fw')
+ #{ _('New branch') }
+ %li
+ = link_to new_project_tag_path(@project) do
+ = icon('tags fw')
+ #{ _('New tag') }
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index 01599060844..4579a912f39 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,8 +1,9 @@
-%article.file-holder.readme-holder
- .js-file-title.file-title
- = blob_icon readme.mode, readme.name
- = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do
- %strong
- = readme.name
- .file-content.wiki
- = markup(readme.name, readme.data)
+- if readme.rich_viewer
+ %article.file-holder.readme-holder{ class: ("limited-width-container" unless fluid_layout) }
+ .js-file-title.file-title
+ = blob_icon readme.mode, readme.name
+ = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
+ %strong
+ = readme.name
+
+ = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: project_blob_path(@project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml
index 84da16b6bb1..f3d4706809f 100644
--- a/app/views/projects/tree/_tree_commit_column.html.haml
+++ b/app/views/projects/tree/_tree_commit_column.html.haml
@@ -1,2 +1,2 @@
%span.str-truncated
- = link_to_gfm commit.full_title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link"
+ = link_to_gfm commit.full_title, project_commit_path(@project, commit.id), class: "tree-commit-link"
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 2497a2d91b1..a4bdd67209d 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -1,40 +1,5 @@
-.tree-content-holder
- .table-holder
- %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
- %thead
- %tr
- %th Name
- %th.hidden-xs
- .pull-left Last commit
- .last-commit.hidden-sm.pull-left
- %i.fa.fa-angle-right
- %small.light
- = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
- = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
- = time_ago_with_tooltip(@commit.committed_date)
- \-
- = @commit.full_title
- %small.commit-history-link-spacer &#124;
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link'
- %th.text-right Last Update
- - if @path.present?
- %tr.tree-item
- %td.tree-item-file-name
- = link_to "..", namespace_project_tree_path(@project.namespace, @project, up_dir_path), class: 'prepend-left-10'
- %td
- %td.hidden-xs
-
- = render_tree(tree)
-
- - if tree.readme
- = render "projects/tree/readme", readme: tree.readme
-
-- if can_edit_tree?
- = render 'projects/blob/upload', title: 'Upload New File', placeholder: 'Upload new file', button_title: 'Upload file', form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post
- = render 'projects/blob/new_dir'
-
-:javascript
- // Load last commit log for each file in tree
- $('#tree-slider').waitForImages(function() {
- gl.utils.ajaxGet("#{escape_javascript(@logs_path)}");
- });
+- content_url = local_assigns.fetch(:content_url, nil)
+- if show_new_repo?
+ = render 'shared/repo/repo', project: @project, content_url: content_url
+- else
+ = render 'projects/tree/old_tree_content', tree: tree
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index e7b3fe3ffda..853e2a6e7ec 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,79 +1,20 @@
-.tree-controls
- = render 'projects/find_file_link'
- = render 'projects/buttons/download', project: @project, ref: @ref
+.tree-ref-container
+ .tree-ref-holder
+ = render 'shared/ref_switcher', destination: 'tree', path: @path
+ - if show_new_repo?
+ .tree-ref-target-holder.js-tree-ref-target-holder
+ = icon('long-arrow-right', title: 'to target branch')
+ = render 'shared/target_switcher', destination: 'tree', path: @path
-.tree-ref-holder
- = render 'shared/ref_switcher', destination: 'tree', path: @path
+ - unless show_new_repo?
+ = render 'projects/tree/old_tree_header'
-%ul.breadcrumb.repo-breadcrumb
- %li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
- = @project.path
- - tree_breadcrumbs(tree, 6) do |title, path|
- %li
- - if path
- = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path)
- - else
- = link_to title, '#'
+.tree-controls
+ - if show_new_repo?
+ = render 'shared/repo/editable_mode'
+ - else
+ = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
- - if current_user
- %li
- - if !on_top_of_branch?
- %span.btn.add-to-tree.disabled.has-tooltip{ title: "You can only add files when you are on a branch", data: { container: 'body' } }
- = icon('plus')
- - else
- %span.dropdown
- %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown" }
- = icon('plus')
- %ul.dropdown-menu
- - if can_edit_tree?
- %li
- = link_to namespace_project_new_blob_path(@project.namespace, @project, @id) do
- = icon('pencil fw')
- New file
- %li
- = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
- = icon('file fw')
- Upload file
- %li
- = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
- = icon('folder fw')
- New directory
- - elsif can?(current_user, :fork_project, @project)
- %li
- - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @id),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
- continue: continue_params)
- = link_to fork_path, method: :post do
- = icon('pencil fw')
- New file
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to upload a file again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
- continue: continue_params)
- = link_to fork_path, method: :post do
- = icon('file fw')
- Upload file
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to create a new directory again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = namespace_project_forks_path(@project.namespace, @project, namespace_key: current_user.namespace.id,
- continue: continue_params)
- = link_to fork_path, method: :post do
- = icon('folder fw')
- New directory
+ = render 'projects/find_file_link'
- %li.divider
- %li
- = link_to new_namespace_project_branch_path(@project.namespace, @project) do
- = icon('code-fork fw')
- New branch
- %li
- = link_to new_namespace_project_tag_path(@project.namespace, @project) do
- = icon('tags fw')
- New tag
+ = render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml
index 15c9536133c..0c9c8750f2c 100644
--- a/app/views/projects/tree/_tree_item.html.haml
+++ b/app/views/projects/tree/_tree_item.html.haml
@@ -2,7 +2,7 @@
%td.tree-item-file-name
= tree_icon(type, tree_item.mode, tree_item.name)
- path = flatten_tree(tree_item)
- = link_to namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)), title: path do
+ = link_to project_tree_path(@project, tree_join(@id || @commit.id, path)), title: path do
%span.str-truncated= path
%td.hidden-xs.tree-commit
%td.tree-time-ago.text-right
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 910d765aed0..375e6764add 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,10 +1,18 @@
- @no_container = true
+- breadcrumb_title _("Repository")
+- @content_class = "limit-container-width" unless fluid_layout
-- page_title @path.presence || "Files", @ref
+- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
+ = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
+
+- if show_new_repo?
+ - content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'repo'
+
= render "projects/commits/head"
-= render 'projects/last_push'
-%div{ class: container_class }
- = render 'projects/files'
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = render 'projects/last_push'
+ = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 70d654fa9a0..5f708b3a2ed 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -8,26 +8,4 @@
.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/README', anchor: 'using-scheduled-triggers')
- .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 84e945ee0df..e9a2f803edd 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,7 +1,7 @@
.row.prepend-top-default.append-bottom-default.triggers-container
- .col-lg-3
+ .col-lg-4
= render "projects/triggers/content"
- .col-lg-9
+ .col-lg-8
.panel.panel-default
.panel-heading
%h4.panel-title
@@ -22,8 +22,6 @@
%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 ebd91a8e2af..6249c32b7cc 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -29,20 +29,14 @@
- 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?"
- if trigger.owner != current_user && can?(current_user, :manage_trigger, trigger)
- = link_to 'Take ownership', take_ownership_namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: take_ownership_confirmation }, method: :post, class: "btn btn-default btn-sm btn-trigger-take-ownership"
+ = link_to 'Take ownership', take_ownership_project_trigger_path(@project, trigger), data: { confirm: take_ownership_confirmation }, method: :post, class: "btn btn-default btn-sm btn-trigger-take-ownership"
- if can?(current_user, :admin_trigger, trigger)
- = link_to edit_namespace_project_trigger_path(@project.namespace, @project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
+ = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
%i.fa.fa-pencil
- if can?(current_user, :manage_trigger, trigger)
- = link_to namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
+ = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
%i.fa.fa-trash
diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml
index dcf1f767bf7..2c05ebe52ae 100644
--- a/app/views/projects/update.js.haml
+++ b/app/views/projects/update.js.haml
@@ -1,6 +1,6 @@
- if @project.valid?
:plain
- location.href = "#{edit_namespace_project_path(@project.namespace, @project)}";
+ location.href = "#{edit_project_path(@project)}";
- else
:plain
$(".project-edit-errors").html("#{escape_javascript(render('errors'))}");
diff --git a/app/views/projects/variables/_content.html.haml b/app/views/projects/variables/_content.html.haml
deleted file mode 100644
index 06477aba103..00000000000
--- a/app/views/projects/variables/_content.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%h4.prepend-top-0
- Secret Variables
-%p
- These variables will be set to environment by the runner.
-%p
- So you can use them for passwords, secret keys or whatever you want.
-%p
- The value of the variable can be visible in job log if explicitly asked to do so.
diff --git a/app/views/projects/variables/_form.html.haml b/app/views/projects/variables/_form.html.haml
deleted file mode 100644
index 1ae86d258af..00000000000
--- a/app/views/projects/variables/_form.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @variable] do |f|
- = form_errors(@variable)
-
- .form-group
- = f.label :key, "Key", class: "label-light"
- = f.text_field :key, class: "form-control", placeholder: "PROJECT_VARIABLE", required: true
- .form-group
- = f.label :value, "Value", class: "label-light"
- = f.text_area :value, class: "form-control", placeholder: "PROJECT_VARIABLE"
- = f.submit btn_text, class: "btn btn-save"
diff --git a/app/views/projects/variables/_index.html.haml b/app/views/projects/variables/_index.html.haml
deleted file mode 100644
index 1b852a9c5b3..00000000000
--- a/app/views/projects/variables/_index.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "projects/variables/content"
- .col-lg-9
- %h5.prepend-top-0
- Add a variable
- = render "projects/variables/form", btn_text: "Add new variable"
- %hr
- %h5.prepend-top-0
- Your variables (#{@project.variables.size})
- - if @project.variables.empty?
- %p.settings-message.text-center.append-bottom-0
- No variables found, add one with the form above.
- - else
- = render "projects/variables/table"
- %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values
diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml
deleted file mode 100644
index 0ce597dcf21..00000000000
--- a/app/views/projects/variables/_table.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-.table-responsive.variables-table
- %table.table
- %colgroup
- %col
- %col
- %col{ width: 100 }
- %thead
- %th Key
- %th Value
- %th
- %tbody
- - @project.variables.order_key_asc.each do |variable|
- - if variable.id?
- %tr
- %td.variable-key= variable.key
- %td.variable-value{ "data-value" => variable.value }******
- %td.variable-menu
- = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-edit" do
- %span.sr-only
- Update
- = icon("pencil")
- = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-delete", method: :delete, data: { confirm: "Are you sure?" } do
- %span.sr-only
- Remove
- = icon("trash")
diff --git a/app/views/projects/variables/show.html.haml b/app/views/projects/variables/show.html.haml
index 297a53ca98c..df533952b76 100644
--- a/app/views/projects/variables/show.html.haml
+++ b/app/views/projects/variables/show.html.haml
@@ -1,9 +1 @@
-- page_title "Variables"
-
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "content"
- .col-lg-9
- %h5.prepend-top-0
- Update variable
- = render "form", btn_text: "Save variable"
+= render 'ci/variables/show'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 0d2cd4a7476..e5a1fccf9ba 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -3,18 +3,23 @@
= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'form-horizontal wiki-form common-note-form prepend-top-default js-quick-submit' } do |f|
= form_errors(@page)
- = f.hidden_field :title, value: @page.title
+ - if @page.persisted?
+ = f.hidden_field :last_commit_sha, value: @page.last_commit_sha
+
+ .form-group
+ .col-sm-12= f.label :title, class: 'control-label-full-width'
+ .col-sm-12= f.text_field :title, class: 'form-control', value: @page.title
.form-group
- = f.label :format, class: 'control-label'
- .col-sm-10
+ .col-sm-12= f.label :format, class: 'control-label-full-width'
+ .col-sm-12
= f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: "form-control"
.form-group
- = f.label :content, class: 'control-label'
- .col-sm-10
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
+ .col-sm-12= f.label :content, class: 'control-label-full-width'
+ .col-sm-12
+ = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do
= render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...'
- = render 'projects/notes/hints'
+ = render 'shared/notes/hints'
.clearfix
.error-alert
@@ -29,15 +34,15 @@
= link_to 'documentation', help_page_path("user/markdown", anchor: "wiki-specific-markdown")
.form-group
- = f.label :commit_message, class: 'control-label'
- .col-sm-10= f.text_field :message, class: 'form-control', rows: 18, value: commit_message
+ .col-sm-12= f.label :commit_message, class: 'control-label-full-width'
+ .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message
.form-actions
- if @page && @page.persisted?
= f.submit 'Save changes', class: "btn-save btn"
.pull-right
- = link_to "Cancel", namespace_project_wiki_path(@project.namespace, @project, @page), class: "btn btn-cancel btn-grouped"
+ = link_to "Cancel", project_wiki_path(@project, @page), class: "btn btn-cancel btn-grouped"
- else
= f.submit 'Create page', class: "btn-create btn"
.pull-right
- = link_to "Cancel", namespace_project_wiki_path(@project.namespace, @project, :home), class: "btn btn-cancel"
+ = link_to "Cancel", project_wiki_path(@project, :home), class: "btn btn-cancel"
diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml
index 6a578dbf640..3bbd8042c3a 100644
--- a/app/views/projects/wikis/_main_links.html.haml
+++ b/app/views/projects/wikis/_main_links.html.haml
@@ -2,8 +2,8 @@
- if can?(current_user, :create_wiki, @project)
= link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
New page
- = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
+ = link_to project_wiki_history_path(@project, @page), class: "btn" do
Page history
- if can?(current_user, :create_wiki, @project) && @page.latest?
- = link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn js-wiki-edit" do
+ = link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit" do
Edit
diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml
index ba47574563d..13dd8461433 100644
--- a/app/views/projects/wikis/_new.html.haml
+++ b/app/views/projects/wikis/_new.html.haml
@@ -1,21 +1,18 @@
-- @no_container = true
-
-%div{ class: container_class }
- #modal-new-wiki.modal
- .modal-dialog
- .modal-content
- .modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title New Wiki Page
- .modal-body
- %form.new-wiki-page
- .form-group
- = label_tag :new_wiki_path do
- %span Page slug
- = text_field_tag :new_wiki_path, nil, placeholder: 'how-to-setup', class: 'form-control', required: true, :'data-wikis-path' => namespace_project_wikis_path(@project.namespace, @project), autofocus: true
- %span.new-wiki-page-slug-tip
- = icon('lightbulb-o')
- Tip: You can specify the full path for the new file.
- We will automatically create any missing directories.
- .form-actions
- = button_tag 'Create page', class: 'build-new-wiki btn btn-create'
+#modal-new-wiki.modal
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %a.close{ href: "#", "data-dismiss" => "modal" } ×
+ %h3.page-title New Wiki Page
+ .modal-body
+ %form.new-wiki-page
+ .form-group
+ = label_tag :new_wiki_path do
+ %span Page slug
+ = text_field_tag :new_wiki_path, nil, placeholder: 'how-to-setup', class: 'form-control', required: true, :'data-wikis-path' => project_wikis_path(@project), autofocus: true
+ %span.new-wiki-page-slug-tip
+ = icon('lightbulb-o')
+ Tip: You can specify the full path for the new file.
+ We will automatically create any missing directories.
+ .form-actions
+ = button_tag 'Create page', class: 'build-new-wiki btn btn-create'
diff --git a/app/views/projects/wikis/_pages_wiki_page.html.haml b/app/views/projects/wikis/_pages_wiki_page.html.haml
index 6298cf6c8da..7c2f562d422 100644
--- a/app/views/projects/wikis/_pages_wiki_page.html.haml
+++ b/app/views/projects/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, namespace_project_wiki_path(@project.namespace, @project, wiki_page)
+ = link_to wiki_page.title, project_wiki_path(@project, wiki_page)
%small (#{wiki_page.format})
.pull-right
%small Last edited #{time_ago_with_tooltip(wiki_page.commit.authored_date)}
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 713b758727e..f7283ae4739 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -1,24 +1,22 @@
-%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" } }
- .block.wiki-sidebar-header.append-bottom-default
- %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" }
- = icon('angle-double-right')
+%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } }
+ .sidebar-container
+ .block.wiki-sidebar-header.append-bottom-default
+ %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" }
+ = icon('angle-double-right')
- - git_access_url = namespace_project_wikis_git_access_path(@project.namespace, @project)
- = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
- = succeed '&nbsp;' do
- = icon('cloud-download')
- Clone repository
+ - git_access_url = project_wikis_git_access_path(@project)
+ = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
+ = succeed '&nbsp;' do
+ = icon('cloud-download')
+ Clone repository
- .blocks-container
- .block.block-first
- %ul.wiki-pages
- = render @sidebar_wiki_entries, context: 'sidebar'
+ .blocks-container
+ .block.block-first
+ %ul.wiki-pages
+ = render @sidebar_wiki_entries, context: 'sidebar'
- .block
- = link_to namespace_project_wikis_pages_path(@project.namespace, @project), class: 'btn btn-block' do
- More Pages
+ .block
+ = link_to project_wikis_pages_path(@project), class: 'btn btn-block' do
+ More Pages
= render 'projects/wikis/new'
-
-:javascript
- new Sidebar();
diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
index 0a61d90177b..2423ac6abce 100644
--- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,3 @@
%li{ class: active_when(params[:id] == wiki_page.slug) }
- = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_page) do
+ = link_to project_wiki_path(@project, wiki_page) do
= wiki_page.title.capitalize
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index b995d08cd02..8fd60216536 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -1,35 +1,40 @@
-- @no_container = true
+- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
- page_title "Edit", @page.title.capitalize, "Wiki"
-%div{ class: container_class }
- .wiki-page-header.has-sidebar-toggle
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+- if @conflict
+ .alert.alert-danger
+ Someone edited the page the same time you did. Please check out
+ = link_to "the page", project_wiki_path(@project, @page), target: "_blank"
+ and make sure your changes will not unintentionally remove theirs.
- .nav-text
- %h2.wiki-page-title
+.wiki-page-header.has-sidebar-toggle
+ %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
+
+ .nav-text
+ %h2.wiki-page-title
+ - if @page.persisted?
+ = link_to @page.title.capitalize, project_wiki_path(@project, @page)
+ - else
+ = @page.title.capitalize
+ %span.light
+ &middot;
- if @page.persisted?
- = link_to @page.title.capitalize, namespace_project_wiki_path(@project.namespace, @project, @page)
+ Edit Page
- else
- = @page.title.capitalize
- %span.light
- &middot;
- - if @page.persisted?
- Edit Page
- - else
- Create Page
+ Create Page
- .nav-controls
- - if can?(current_user, :create_wiki, @project)
- = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
- New page
- - if @page.persisted?
- = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
- Page history
- - if can?(current_user, :admin_wiki, @project)
- = link_to namespace_project_wiki_path(@project.namespace, @project, @page), data: { confirm: "Are you sure you want to delete this page?"}, method: :delete, class: "btn btn-danger" do
- Delete
+ .nav-controls
+ - if can?(current_user, :create_wiki, @project)
+ = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
+ New page
+ - if @page.persisted?
+ = link_to project_wiki_history_path(@project, @page), class: "btn" do
+ Page history
+ - if can?(current_user, :admin_wiki, @project)
+ = link_to project_wiki_path(@project, @page), data: { confirm: "Are you sure you want to delete this page?"}, method: :delete, class: "btn btn-danger" do
+ Delete
- = render 'form'
+= render 'form'
= render 'sidebar'
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index fb0efd85dcd..e740fb93ea4 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -1,43 +1,42 @@
-- @no_container = true
+- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
- page_title "Git Access", "Wiki"
-%div{ class: container_class }
- .wiki-page-header.has-sidebar-toggle
- %button.btn.btn-default.visible-xs.visible-sm.pull-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+.wiki-page-header.has-sidebar-toggle
+ %button.btn.btn-default.visible-xs.visible-sm.pull-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
- .git-access-header
- Clone repository
- %strong= @project_wiki.path_with_namespace
+ .git-access-header
+ Clone repository
+ %strong= @project_wiki.full_path
- = render "shared/clone_panel", project: @project_wiki
+ = render "shared/clone_panel", project: @project_wiki
- .wiki-git-access
- %h3 Install Gollum
- %pre.dark
- :preserve
- gem install gollum
- %p
- It is recommended to install
- %code github-markdown
- so that GFM features render locally:
- %pre.dark
- :preserve
- gem install github-markdown
+.wiki-git-access
+ %h3 Install Gollum
+ %pre.dark
+ :preserve
+ gem install gollum
+ %p
+ It is recommended to install
+ %code github-markdown
+ so that GFM features render locally:
+ %pre.dark
+ :preserve
+ gem install github-markdown
- %h3 Clone your wiki
- %pre.dark
- :preserve
- git clone #{ content_tag(:span, default_url_to_repo(@project_wiki), class: 'clone')}
- cd #{h @project_wiki.path}
+ %h3 Clone your wiki
+ %pre.dark
+ :preserve
+ git clone #{ content_tag(:span, h(default_url_to_repo(@project_wiki)), class: 'clone')}
+ cd #{h @project_wiki.path}
- %h3 Start Gollum and edit locally
- %pre.dark
- :preserve
- gollum
- == Sinatra/1.3.5 has taken the stage on 4567 for development with backup from Thin
- >> Thin web server (v1.5.0 codename Knife)
- >> Maximum connections set to 1024
- >> Listening on 0.0.0.0:4567, CTRL+C to stop
+ %h3 Start Gollum and edit locally
+ %pre.dark
+ :preserve
+ gollum
+ == Sinatra/1.3.5 has taken the stage on 4567 for development with backup from Thin
+ >> Thin web server (v1.5.0 codename Knife)
+ >> Maximum connections set to 1024
+ >> Listening on 0.0.0.0:4567, CTRL+C to stop
= render 'sidebar'
diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml
index dd7213622c1..306feeff259 100644
--- a/app/views/projects/wikis/history.html.haml
+++ b/app/views/projects/wikis/history.html.haml
@@ -1,42 +1,41 @@
- page_title "History", @page.title.capitalize, "Wiki"
-%div{ class: container_class }
- .wiki-page-header.has-sidebar-toggle
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+.wiki-page-header.has-sidebar-toggle
+ %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
- .nav-text
- %h2.wiki-page-title
- = link_to @page.title.capitalize, namespace_project_wiki_path(@project.namespace, @project, @page)
- %span.light
- &middot;
- History
+ .nav-text
+ %h2.wiki-page-title
+ = link_to @page.title.capitalize, project_wiki_path(@project, @page)
+ %span.light
+ &middot;
+ History
- .table-holder
- %table.table
- %thead
+.table-holder
+ %table.table
+ %thead
+ %tr
+ %th Page version
+ %th Author
+ %th Commit Message
+ %th Last updated
+ %th Format
+ %tbody
+ - @page.versions.each_with_index do |version, index|
+ - commit = version
%tr
- %th Page version
- %th Author
- %th Commit Message
- %th Last updated
- %th Format
- %tbody
- - @page.versions.each_with_index do |version, index|
- - commit = version
- %tr
- %td
- = link_to project_wiki_path_with_version(@project, @page,
- commit.id, index == 0) do
- = truncate_sha(commit.id)
- %td
- = commit.author.name
- %td
- = commit.message
- %td
- #{time_ago_with_tooltip(version.authored_date)}
- %td
- %strong
- = @page.page.wiki.page(@page.page.name, commit.id).try(:format)
+ %td
+ = link_to project_wiki_path_with_version(@project, @page,
+ commit.id, index == 0) do
+ = truncate_sha(commit.id)
+ %td
+ = commit.author.name
+ %td
+ = commit.message
+ %td
+ #{time_ago_with_tooltip(version.authored_date)}
+ %td
+ %strong
+ = @page.page.wiki.page(@page.page.name, commit.id).try(:format)
= render 'sidebar'
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index 5fba2b1a5ae..dece1fad0bb 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -9,7 +9,7 @@
Wiki Pages
.nav-controls
- = link_to namespace_project_wikis_git_access_path(@project.namespace, @project), class: 'btn' do
+ = link_to project_wikis_git_access_path(@project), class: 'btn' do
= icon('cloud-download')
Clone repository
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 3609461b721..9dadd685ea2 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -1,33 +1,32 @@
-- @no_container = true
+- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
+- breadcrumb_title "Wiki"
- page_title @page.title.capitalize, "Wiki"
-%div{ class: container_class }
- .wiki-page-header.has-sidebar-toggle
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+.wiki-page-header.has-sidebar-toggle
+ %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
+ = icon('angle-double-left')
- .wiki-breadcrumb
- %span= breadcrumb(@page.slug)
+ .wiki-breadcrumb
+ %span= breadcrumb(@page.slug)
- .nav-text
- %h2.wiki-page-title= @page.title.capitalize
- %span.wiki-last-edit-by
- Last edited by
- %strong
- #{@page.commit.author.name}
- #{time_ago_with_tooltip(@page.commit.authored_date)}
+ .nav-text
+ %h2.wiki-page-title= @page.title.capitalize
+ %span.wiki-last-edit-by
+ Last edited by
+ %strong
+ #{@page.commit.author.name}
+ #{time_ago_with_tooltip(@page.commit.authored_date)}
- .nav-controls
- = render 'main_links'
+ .nav-controls
+ = render 'main_links'
- - if @page.historical?
- .warning_message
- This is an old version of this page.
- You can view the #{link_to "most recent version", namespace_project_wiki_path(@project.namespace, @project, @page)} or browse the #{link_to "history", namespace_project_wiki_history_path(@project.namespace, @project, @page)}.
+- if @page.historical?
+ .warning_message
+ This is an old version of this page.
+ You can view the #{link_to "most recent version", project_wiki_path(@project, @page)} or browse the #{link_to "history", project_wiki_history_path(@project, @page)}.
- .wiki-holder.prepend-top-default.append-bottom-default
- .wiki
- = preserve do
- = render_wiki_content(@page)
+.wiki-holder.prepend-top-default.append-bottom-default
+ .wiki
+ = render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 059a0d1ac78..314d8e9cb25 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -3,41 +3,48 @@
.fade-right= icon('angle-right')
%ul.nav-links.search-filter.scrolling-tabs
- if @project
- %li{ class: active_when(@scope == 'blobs') }
- = link_to search_filter_path(scope: 'blobs') do
- Code
- %span.badge
- = @search_results.blobs_count
- %li{ class: active_when(@scope == 'issues') }
- = link_to search_filter_path(scope: 'issues') do
- Issues
- %span.badge
- = @search_results.issues_count
- %li{ class: active_when(@scope == 'merge_requests') }
- = link_to search_filter_path(scope: 'merge_requests') do
- Merge requests
- %span.badge
- = @search_results.merge_requests_count
- %li{ class: active_when(@scope == 'milestones') }
- = link_to search_filter_path(scope: 'milestones') do
- Milestones
- %span.badge
- = @search_results.milestones_count
- %li{ class: active_when(@scope == 'notes') }
- = link_to search_filter_path(scope: 'notes') do
- Comments
- %span.badge
- = @search_results.notes_count
- %li{ class: active_when(@scope == 'wiki_blobs') }
- = link_to search_filter_path(scope: 'wiki_blobs') do
- Wiki
- %span.badge
- = @search_results.wiki_blobs_count
- %li{ class: active_when(@scope == 'commits') }
- = link_to search_filter_path(scope: 'commits') do
- Commits
- %span.badge
- = @search_results.commits_count
+ - if project_search_tabs?(:blobs)
+ %li{ class: active_when(@scope == 'blobs') }
+ = link_to search_filter_path(scope: 'blobs') do
+ Code
+ %span.badge
+ = @search_results.blobs_count
+ - if project_search_tabs?(:issues)
+ %li{ class: active_when(@scope == 'issues') }
+ = link_to search_filter_path(scope: 'issues') do
+ Issues
+ %span.badge
+ = @search_results.issues_count
+ - if project_search_tabs?(:merge_requests)
+ %li{ class: active_when(@scope == 'merge_requests') }
+ = link_to search_filter_path(scope: 'merge_requests') do
+ Merge requests
+ %span.badge
+ = @search_results.merge_requests_count
+ - if project_search_tabs?(:milestones)
+ %li{ class: active_when(@scope == 'milestones') }
+ = link_to search_filter_path(scope: 'milestones') do
+ Milestones
+ %span.badge
+ = @search_results.milestones_count
+ - if project_search_tabs?(:notes)
+ %li{ class: active_when(@scope == 'notes') }
+ = link_to search_filter_path(scope: 'notes') do
+ Comments
+ %span.badge
+ = @search_results.notes_count
+ - if project_search_tabs?(:wiki)
+ %li{ class: active_when(@scope == 'wiki_blobs') }
+ = link_to search_filter_path(scope: 'wiki_blobs') do
+ Wiki
+ %span.badge
+ = @search_results.wiki_blobs_count
+ - if project_search_tabs?(:commits)
+ %li{ class: active_when(@scope == 'commits') }
+ = link_to search_filter_path(scope: 'commits') do
+ Commits
+ %span.badge
+ = @search_results.commits_count
- elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') }
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index 938be20c7cf..e43796e9654 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -3,7 +3,7 @@
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
.dropdown
- %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:" } }
+ %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } }
%span.dropdown-toggle-text
Group:
- if @group.present?
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 7f1f807e2e7..de473c23d66 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -3,7 +3,7 @@
.file-holder
.js-file-title.file-title
- ref = @search_results.repository_ref
- - blob_link = namespace_project_blob_path(@project.namespace, @project, tree_join(ref, file_name))
+ - blob_link = project_blob_path(@project, tree_join(ref, file_name))
= link_to blob_link do
%i.fa.fa-file
%strong
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index fc4385865a4..b4bc8982c05 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -8,7 +8,6 @@
.pull-right ##{issue.iid}
- if issue.description.present?
.description.term
- = preserve do
- = search_md_sanitize(issue, :description)
+ = search_md_sanitize(issue, :description)
%span.light
#{issue.project.name_with_namespace}
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 9b583285d02..1a5499e4d58 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -9,7 +9,6 @@
.pull-right= merge_request.to_reference
- if merge_request.description.present?
.description.term
- = preserve do
- = search_md_sanitize(merge_request, :description)
+ = search_md_sanitize(merge_request, :description)
%span.light
#{merge_request.project.name_with_namespace}
diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml
index 9664f65a36e..2daa96e34d1 100644
--- a/app/views/search/results/_milestone.html.haml
+++ b/app/views/search/results/_milestone.html.haml
@@ -5,5 +5,4 @@
- if milestone.description.present?
.description.term
- = preserve do
- = search_md_sanitize(milestone, :description)
+ = search_md_sanitize(milestone, :description)
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index f3701b89bb4..a7e178dfa71 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -22,5 +22,4 @@
.note-search-result
.term
- = preserve do
- = search_md_sanitize(note, :note)
+ = search_md_sanitize(note, :note)
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index f2fe5742c12..c4a5131c1a7 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -39,7 +39,7 @@
.blob-content
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
- = highlight(snippet.file_name, chunk[:data], repository: nil, plain: snippet.no_highlighting?)
+ = highlight(snippet.file_name, chunk[:data], repository: nil, plain: snippet.blob.no_highlighting?)
- else
.file-content.code
.nothing-here-block Empty file
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index 026f404ce07..aef825691e0 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -11,7 +11,7 @@
%small.pull-right.cgray
- if snippet_title.project_id?
- = link_to snippet_title.project.name_with_namespace, namespace_project_path(snippet_title.project.namespace, snippet_title.project)
+ = link_to snippet_title.project.name_with_namespace, project_path(snippet_title.project)
.snippet-info
= snippet_title.to_reference
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index d87f9df2677..16a0e432d62 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -2,7 +2,7 @@
.blob-result
.file-holder
.js-file-title.file-title
- = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.basename) do
+ = link_to project_wiki_path(@project, wiki_blob.basename) do
%i.fa.fa-file
%strong
= wiki_blob.basename
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 215dbb3909e..499697f2777 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -1,3 +1,5 @@
+- @hide_top_links = true
+- breadcrumb_title "Search"
- page_title @search_term
.prepend-top-default
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 9ce6a1aeef5..de52fd00157 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,16 +1,14 @@
- noteable = @sent_notification.noteable
-- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false)
+- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-
-- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace
-
+- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace
%h3.page-title
- Unsubscribe from #{noteable_type} #{noteable_text}
+ Unsubscribe from #{noteable_type}
%p
= succeed '?' do
- Are you sure you want to unsubscribe from #{noteable_type}
+ Are you sure you want to unsubscribe from the #{noteable_type}:
= link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable])
%p
diff --git a/app/views/shared/_branch_switcher.html.haml b/app/views/shared/_branch_switcher.html.haml
deleted file mode 100644
index 69e3f3042a9..00000000000
--- a/app/views/shared/_branch_switcher.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- dropdown_toggle_text = @branch_name || tree_edit_branch
-= hidden_field_tag 'branch_name', dropdown_toggle_text
-
-.dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: 'dropdown', selected: dropdown_toggle_text, field_name: 'branch_name', form_id: '.js-edit-blob-form', refs_url: namespace_project_branches_path(@project.namespace, @project) }, { toggle_class: 'js-project-branches-dropdown js-target-branch' }
- .dropdown-menu.dropdown-menu-selectable.dropdown-menu-paging.dropdown-menu-branches
- = render partial: 'shared/projects/blob/branch_page_default'
- = render partial: 'shared/projects/blob/branch_page_create'
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 34a4d7398bc..3d9c90c38fe 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -11,20 +11,12 @@
%span
= default_clone_protocol.upcase
= icon('caret-down')
- %ul.dropdown-menu.dropdown-menu-right.clone-options-dropdown
+ %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown
%li
= ssh_clone_button(project)
%li
= http_clone_button(project)
- = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true
+ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-btn
- = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard")
-
-:javascript
- $('ul.clone-options-dropdown a').on('click',function(e){
- e.preventDefault();
- var $this = $(this);
- $('a.clone-dropdown-btn span').text($this.text());
- $('#project_clone').val($this.attr('href'));
- });
+ = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"), class: "btn-default btn-clipboard")
diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml
index 4b98ff88241..2329de9e11f 100644
--- a/app/views/shared/_commit_message_container.html.haml
+++ b/app/views/shared/_commit_message_container.html.haml
@@ -2,7 +2,7 @@
- nonce = SecureRandom.hex
- descriptions = local_assigns.slice(:message_with_description, :message_without_description)
= label_tag "commit_message-#{nonce}", class: 'control-label' do
- Commit message
+ #{ _('Commit message') }
.col-sm-10
.commit-message-container
.max-width-marker
diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml
new file mode 100644
index 00000000000..50e3d80a84d
--- /dev/null
+++ b/app/views/shared/_commit_well.html.haml
@@ -0,0 +1,4 @@
+.info-well.hidden-xs.project-last-commit.append-bottom-default
+ .well-segment
+ %ul.blob-commit-info
+ = render 'projects/commits/commit', commit: commit, ref: ref, project: project
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 8d6e16f74c3..795447a9ca6 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -3,25 +3,26 @@
- value = @service.send(name)
- type = field[:type]
- placeholder = field[:placeholder]
+- required = field[:required]
- choices = field[:choices]
- default_choice = field[:default_choice]
- help = field[:help]
.form-group
- if type == "password" && value.present?
- = form.label name, "Change #{title}", class: "control-label"
+ = form.label name, "Enter new #{title.downcase}", class: "control-label"
- else
= form.label name, title, class: "control-label"
.col-sm-10
- if type == 'text'
- = form.text_field name, class: "form-control", placeholder: placeholder
+ = form.text_field name, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'textarea'
- = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder
+ = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'checkbox'
= form.check_box name
- elsif type == 'select'
= form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" }
- elsif type == 'password'
- = form.password_field name, autocomplete: "new-password", class: 'form-control'
+ = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required
- if help
%span.help-block= help
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 90ae3f06a98..8d5b5129454 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -15,7 +15,7 @@
%strong= parent.full_path + '/'
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
- pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS,
+ pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: 'Please choose a group path with no special characters.',
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- if parent
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 1c7c73be933..233d8c95eda 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -1,16 +1,16 @@
.form-group.import-url-data
- = f.label :import_url, class: 'control-label' do
+ = f.label :import_url, class: 'label-light' do
%span Git repository URL
- .col-sm-10
- = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
- .well.prepend-top-20
- %ul
- %li
- The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.
- %li
- If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.
- %li
- The import will time out after 15 minutes. For repositories that take longer, use a clone/push combination.
- %li
- To migrate an SVN repository, check out #{link_to "this document", help_page_path('workflow/importing/migrating_from_svn')}.
+ = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
+
+ .well.prepend-top-20
+ %ul
+ %li
+ The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.
+ %li
+ If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.
+ %li
+ The import will time out after 15 minutes. For repositories that take longer, use a clone/push combination.
+ %li
+ To migrate an SVN repository, check out #{link_to "this document", help_page_path('user/project/import/svn')}.
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 1d4fd71522d..435acbc634c 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -5,21 +5,21 @@
- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
- if issuable_mr > 0
- %li
+ %li.issuable-mr.hidden-xs
= image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged')
= issuable_mr
- if upvotes > 0
- %li
+ %li.issuable-upvotes.hidden-xs
= icon('thumbs-up')
= upvotes
- if downvotes > 0
- %li
+ %li.issuable-downvotes.hidden-xs
= icon('thumbs-down')
= downvotes
-%li
+%li.issuable-comments.hidden-xs
= link_to issuable_url, class: ('no-comments' if note_count.zero?) do
= icon('comments')
= note_count
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 3a49227961f..49555b6ff4e 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,6 +1,6 @@
- if @issues.to_a.any?
.panel.panel-default.panel-small.panel-without-border
- %ul.content-list.issues-list
+ %ul.content-list.issues-list.issuable-list
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index bd994cdad01..8ded7440de3 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -1,12 +1,13 @@
- label_css_id = dom_id(label)
- status = label_subscription_status(label, @project).inquiry if current_user
- subject = local_assigns[:subject]
+- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
%li{ id: label_css_id, data: { id: label.id } }
= render "shared/label_row", label: label
.visible-xs.visible-sm-inline-block.visible-md-inline-block.dropdown
- %button.btn.btn-default.label-options-toggle{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.label-options-toggle{ type: 'button', data: { toggle: "dropdown" } }
Options
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right
@@ -17,18 +18,18 @@
%li
= link_to_label(label, subject: subject) do
view open issues
- - if current_user && defined?(@project)
+ - if current_user
%li.label-subscription
- - if label.is_a?(ProjectLabel)
- %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', data: { status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
- %span= label_subscription_toggle_button_text(label, @project)
- - else
- %a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: group_label_unsubscribe_path(label, @project) } }
+ - if can_subscribe_to_label_in_different_levels?(label)
+ %a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path } }
%span Unsubscribe
- %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } }
%span Subscribe at project level
%a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } }
%span Subscribe at group level
+ - else
+ %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', data: { status: status, url: toggle_subscription_path } }
+ %span= label_subscription_toggle_button_text(label, @project)
- if can?(current_user, :admin_label, label)
%li
@@ -42,14 +43,10 @@
= link_to_label(label, subject: subject, css_class: 'btn btn-transparent btn-action') do
view open issues
- - if current_user && defined?(@project)
+ - if current_user
.label-subscription.inline
- - if label.is_a?(ProjectLabel)
- %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', data: { status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
- %span= label_subscription_toggle_button_text(label, @project)
- = icon('spinner spin', class: 'label-subscribe-button-loading')
- - else
- %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', class: ('hidden' if status.unsubscribed?), data: { url: group_label_unsubscribe_path(label, @project) } }
+ - if can_subscribe_to_label_in_different_levels?(label)
+ %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path } }
%span Unsubscribe
= icon('spinner spin', class: 'label-subscribe-button-loading')
@@ -59,13 +56,17 @@
= icon('chevron-down')
%ul.dropdown-menu
%li
- %a.js-subscribe-button{ data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
+ %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } }
Project level
- %a.js-subscribe-button{ data: { url: toggle_subscription_group_label_path(label.group, label) } }
+ %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } }
Group level
+ - else
+ %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', data: { status: status, url: toggle_subscription_path } }
+ %span= label_subscription_toggle_button_text(label, @project)
+ = icon('spinner spin', class: 'label-subscribe-button-loading')
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_group, label.project.group)
- = link_to promote_namespace_project_label_path(label.project.namespace, label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting this label will make this label available to all projects inside this group. Existing project labels with the same name will be merged. Are you sure?", toggle: "tooltip"}, method: :post do
+ - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting this label will make this label available to all projects inside this group. Existing project labels with the same name will be merged. Are you sure?", toggle: "tooltip"}, method: :post do
%span.sr-only Promote to Group
= icon('level-up')
- if can?(current_user, :admin_label, label)
@@ -75,11 +76,3 @@
= link_to destroy_label_path(label), title: "Delete", class: 'btn btn-transparent btn-action remove-row', method: :delete, data: {confirm: label_deletion_confirm_text(label), toggle: "tooltip"} do
%span.sr-only Delete
= icon('trash-o')
-
- - if current_user && defined?(@project)
- - if label.is_a?(ProjectLabel)
- :javascript
- new gl.ProjectLabelSubscription('##{dom_id(label)} .label-subscription');
- - else
- :javascript
- new gl.GroupLabelSubscription('##{dom_id(label)} .label-subscription');
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index d28f9421ecf..7f58298c60f 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -2,11 +2,11 @@
- if can?(current_user, :admin_label, @project)
.draggable-handler
= icon('bars')
- .js-toggle-priority.toggle-priority{ data: { url: remove_priority_namespace_project_label_path(@project.namespace, @project, label),
+ .js-toggle-priority.toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
- %button.add-priority.btn.has-tooltip{ title: 'Prioritize', :'data-placement' => 'top' }
+ %button.add-priority.btn.has-tooltip{ title: 'Prioritize', type: 'button', :'data-placement' => 'top' }
= icon('star-o')
- %button.remove-priority.btn.has-tooltip{ title: 'Remove priority', :'data-placement' => 'top' }
+ %button.remove-priority.btn.has-tooltip{ title: 'Remove priority', type: 'button', :'data-placement' => 'top' }
= icon('star')
%span.label-name
= link_to_label(label, subject: @project, tooltip: false)
diff --git a/app/views/shared/_logo_type.svg b/app/views/shared/_logo_type.svg
new file mode 100644
index 00000000000..cb07e2634a9
--- /dev/null
+++ b/app/views/shared/_logo_type.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 617 169"><path d="M315.26 2.97h-21.8l.1 162.5h88.3v-20.1h-66.5l-.1-142.4M465.89 136.95c-5.5 5.7-14.6 11.4-27 11.4-16.6 0-23.3-8.2-23.3-18.9 0-16.1 11.2-23.8 35-23.8 4.5 0 11.7.5 15.4 1.2v30.1h-.1m-22.6-98.5c-17.6 0-33.8 6.2-46.4 16.7l7.7 13.4c8.9-5.2 19.8-10.4 35.5-10.4 17.9 0 25.8 9.2 25.8 24.6v7.9c-3.5-.7-10.7-1.2-15.1-1.2-38.2 0-57.6 13.4-57.6 41.4 0 25.1 15.4 37.7 38.7 37.7 15.7 0 30.8-7.2 36-18.9l4 15.9h15.4v-83.2c-.1-26.3-11.5-43.9-44-43.9M557.63 149.1c-8.2 0-15.4-1-20.8-3.5V70.5c7.4-6.2 16.6-10.7 28.3-10.7 21.1 0 29.2 14.9 29.2 39 0 34.2-13.1 50.3-36.7 50.3m9.2-110.6c-19.5 0-30 13.3-30 13.3v-21l-.1-27.8h-21.3l.1 158.5c10.7 4.5 25.3 6.9 41.2 6.9 40.7 0 60.3-26 60.3-70.9-.1-35.5-18.2-59-50.2-59M77.9 20.6c19.3 0 31.8 6.4 39.9 12.9l9.4-16.3C114.5 6 97.3 0 78.9 0 32.5 0 0 28.3 0 85.4c0 59.8 35.1 83.1 75.2 83.1 20.1 0 37.2-4.7 48.4-9.4l-.5-63.9V75.1H63.6v20.1h38l.5 48.5c-5 2.5-13.6 4.5-25.3 4.5-32.2 0-53.8-20.3-53.8-63-.1-43.5 22.2-64.6 54.9-64.6M231.43 2.95h-21.3l.1 27.3v94.3c0 26.3 11.4 43.9 43.9 43.9 4.5 0 8.9-.4 13.1-1.2v-19.1c-3.1.5-6.4.7-9.9.7-17.9 0-25.8-9.2-25.8-24.6v-65h35.7v-17.8h-35.7l-.1-38.5M155.96 165.47h21.3v-124h-21.3v124M155.96 24.37h21.3V3.07h-21.3v21.3"/></svg>
diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml
index eecbb32e90e..0517896cfbd 100644
--- a/app/views/shared/_merge_requests.html.haml
+++ b/app/views/shared/_merge_requests.html.haml
@@ -1,6 +1,6 @@
- if @merge_requests.to_a.any?
.panel.panel-default.panel-small.panel-without-border
- %ul.content-list.mr-list
+ %ul.content-list.mr-list.issuable-list
= render partial: 'projects/merge_requests/merge_request', collection: @merge_requests
= paginate @merge_requests, theme: "gitlab"
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index b0778653d4e..dff847159d3 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -1,18 +1,18 @@
.stage-cell
- - pipeline.stages.each do |stage|
+ - pipeline.legacy_stages.each do |stage|
- if stage.status
- detailed_status = stage.detailed_status(current_user)
- icon_status = "#{detailed_status.icon}_borderless"
- status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}"
.stage-container.dropdown{ class: klass }
- %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: stage.name) } }
+ %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
= custom_icon(icon_status)
= icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
- .arrow-up
- .js-builds-dropdown-list.scrollable-menu
+ %li.js-builds-dropdown-list.scrollable-menu
- .js-builds-dropdown-loading.builds-dropdown-loading.hidden
- %span.fa.fa-spinner.fa-spin
+ %li.js-builds-dropdown-loading.hidden
+ .text-center
+ %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
diff --git a/app/views/shared/_mr_head.html.haml b/app/views/shared/_mr_head.html.haml
index 4211ec6351d..e7355ae2eea 100644
--- a/app/views/shared/_mr_head.html.haml
+++ b/app/views/shared/_mr_head.html.haml
@@ -1,4 +1,4 @@
-- if @project.default_issues_tracker?
+- if @project.issues_enabled?
= render "projects/issues/head"
- else
= render "projects/merge_requests/head"
diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml
index 0b37fe3013b..0a4a24ae807 100644
--- a/app/views/shared/_new_commit_form.html.haml
+++ b/app/views/shared/_new_commit_form.html.haml
@@ -5,16 +5,12 @@
- else
- if can?(current_user, :push_code, @project)
.form-group.branch
- = label_tag 'branch_name', 'Target branch', class: 'control-label'
+ = label_tag 'branch_name', _('Target Branch'), class: 'control-label'
.col-sm-10
- = render 'shared/branch_switcher'
+ = text_field_tag 'branch_name', @branch_name || tree_edit_branch, required: true, class: "form-control js-branch-name ref-name"
.js-create-merge-request-container
- .checkbox
- - nonce = SecureRandom.hex
- = label_tag "create_merge_request-#{nonce}" do
- = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}"
- Start a <strong>new merge request</strong> with these changes
+ = render 'shared/new_merge_request_checkbox'
- else
= hidden_field_tag 'branch_name', @branch_name || tree_edit_branch
= hidden_field_tag 'create_merge_request', 1
diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml
new file mode 100644
index 00000000000..133c31f09c4
--- /dev/null
+++ b/app/views/shared/_new_merge_request_checkbox.html.haml
@@ -0,0 +1,8 @@
+.checkbox
+ - nonce = SecureRandom.hex
+ = label_tag "create_merge_request-#{nonce}" do
+ = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}"
+ - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" }
+ - translation = _('Start a %{new_merge_request} with these changes') % translation_variables
+ #{ translation.html_safe }
+
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index fbbf6f358c5..96502d7ce93 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,19 +1,7 @@
-- if @projects.any?
- .project-item-select-holder
- = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' }
- %a.btn.btn-new.new-project-item-select-button
- = local_assigns[:label]
+- if any_projects?(@projects)
+ .project-item-select-holder.btn-group.pull-right
+ %a.btn.btn-new.new-project-item-link{ href: '', data: { label: local_assigns[:label] } }
+ = icon('spinner spin')
+ = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path] }, with_feature_enabled: local_assigns[:with_feature_enabled]
+ %button.btn.btn-new.new-project-item-select-button
= icon('caret-down')
-
- :javascript
- $('.new-project-item-select-button').on('click', function() {
- $('.project-item-select').select2('open');
- });
-
- var relativePath = '#{local_assigns[:path]}';
-
- $('.project-item-select').on('click', function() {
- window.location = $(this).val() + '/' + relativePath;
- });
-
- new ProjectSelect()
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index ed6fc76c61e..9b1a467df6b 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -1,8 +1,9 @@
-- if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password?
+- if show_no_password_message?
.no-password-message.alert.alert-warning
- You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account
-
+ - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: link_to_set_password }
+ - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params
+ = set_password_message.html_safe
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put
+ = link_to _("Don't show again"), profile_path(user: {hide_no_password: true}), method: :put
|
- = link_to 'Remind later', '#', class: 'hide-no-password-message'
+ = link_to _('Remind later'), '#', class: 'hide-no-password-message'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index d663fa13d10..17ef5327341 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,8 +1,9 @@
-- if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key?
+- if show_no_ssh_key_message?
.no-ssh-key-message.alert.alert-warning
- You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile
-
+ - add_ssh_key_link = link_to s_('MissingSSHKeyWarningLink|add an SSH key'), profile_keys_path, class: 'alert-link'
+ - ssh_message = _("You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile") % { add_ssh_key_link: add_ssh_key_link }
+ = ssh_message.html_safe
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
+ = link_to _("Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
|
- = link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link'
+ = link_to _('Remind later'), '#', class: 'hide-no-ssh-message alert-link'
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index b20055a564e..e415ec64c38 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -23,18 +23,3 @@
.prepend-top-default
= f.submit "Create #{type} token", class: "btn btn-create"
-
-:javascript
- var $dateField = $('.datepicker');
- var date = $dateField.val();
-
- new Pikaday({
- field: $dateField.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $dateField.parent().get(0),
- onSelect: function(dateText) {
- $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
- }
- });
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml
index ab7a2db002e..c5e4d6e2871 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/_personal_access_tokens_table.html.haml
@@ -39,22 +39,3 @@
- else
.settings-message.text-center
This user has no active #{type} Tokens.
-
-%hr
-
-%h5 Inactive #{type} Tokens (#{inactive_tokens.length})
-- if inactive_tokens.present?
- .table-responsive
- %table.table.inactive-tokens
- %thead
- %tr
- %th Name
- %th Created
- %tbody
- - inactive_tokens.each do |token|
- %tr
- %td= token.name
- %td= token.created_at.to_date.to_s(:medium)
-- else
- .settings-message.text-center
- This user has no inactive #{type} Tokens.
diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml
new file mode 100644
index 00000000000..8b2a3bee407
--- /dev/null
+++ b/app/views/shared/_ref_dropdown.html.haml
@@ -0,0 +1,7 @@
+- dropdown_class = local_assigns.fetch(:dropdown_class, '')
+
+.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class }
+ = dropdown_title "Select Git revision"
+ = dropdown_filter "Filter by Git revision"
+ = dropdown_content
+ = dropdown_loading
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 9a8252ab087..7ad743b3b81 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -1,14 +1,14 @@
- dropdown_toggle_text = @ref || @project.default_branch
-= form_tag switch_namespace_project_refs_path(@project.namespace, @project), method: :get, class: "project-refs-form" do
+= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
- if defined?(path)
= hidden_field_tag :path, path
- @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil
.dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" }
- .dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
- = dropdown_title "Switch branch/tag"
- = dropdown_filter "Search branches and tags"
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" }
+ .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
+ = dropdown_title _("Switch branch/tag")
+ = dropdown_filter _("Search branches and tags")
= dropdown_content
= dropdown_loading
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 9c5053dace5..7ca14ac93cc 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -4,14 +4,14 @@
= render "projects/services/#{@service.to_param}/help", subject: subject
- elsif @service.help.present?
.well
- = preserve do
- = markdown @service.help
+ = markdown @service.help
.service-settings
- .form-group
- = form.label :active, "Active", class: "control-label"
- .col-sm-10
- = form.check_box :active
+ - if @service.show_active_box?
+ .form-group
+ = form.label :active, "Active", class: "control-label"
+ .col-sm-10
+ = form.check_box :active
- if @service.supported_events.present?
.form-group
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
new file mode 100644
index 00000000000..eb5ddb0dde4
--- /dev/null
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -0,0 +1,8 @@
+%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
+ = icon('angle-double-left')
+ = icon('angle-double-right')
+ %span.collapse-text Collapse sidebar
+
+= button_tag class: 'close-nav-button', type: 'button' do
+ = icon ('times')
+ %span.collapse-text Close sidebar
diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml
index a212c714826..785a500e44e 100644
--- a/app/views/shared/_sort_dropdown.html.haml
+++ b/app/views/shared/_sort_dropdown.html.haml
@@ -1,3 +1,5 @@
+- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
+
.dropdown.inline.prepend-left-10
%button.dropdown-toggle{ type: 'button', data: {toggle: 'dropdown' } }
- if @sort.present?
@@ -23,7 +25,7 @@
= sort_title_milestone_soon
= link_to page_filter_path(sort: sort_value_milestone_later, label: true) do
= sort_title_milestone_later
- - if controller.controller_name == 'issues' || controller.action_name == 'issues'
+ - if viewing_issues
= link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do
= sort_title_due_date_soon
= link_to page_filter_path(sort: sort_value_due_date_later, label: true) do
diff --git a/app/views/shared/_target_switcher.html.haml b/app/views/shared/_target_switcher.html.haml
new file mode 100644
index 00000000000..9236868652f
--- /dev/null
+++ b/app/views/shared/_target_switcher.html.haml
@@ -0,0 +1,20 @@
+- dropdown_toggle_text = @ref || @project.default_branch
+= form_tag nil, method: :get, style: { display: 'none' }, class: "project-refs-target-form" do
+ = hidden_field_tag :destination, destination
+ - if defined?(path)
+ = hidden_field_tag :path, path
+ - @options && @options.each do |key, value|
+ = hidden_field_tag key, value, id: nil
+ .dropdown
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, find: ['branches']), field_name: 'ref', input_field_name: 'new-branch', submit_form_on_click: true, visit: false }, { toggle_class: "js-project-refs-dropdown" }
+ %ul.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
+ %li
+ = dropdown_title _("Create a new branch")
+ %li
+ = dropdown_input _("Create a new branch")
+ %li
+ = dropdown_title _("Select existing branch"), options: {close: false}
+ %li
+ = dropdown_filter _("Search branches and tags")
+ = dropdown_content
+ = dropdown_loading
diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml
index 8308baa7829..17ffcba69d8 100644
--- a/app/views/shared/_user_callout.html.haml
+++ b/app/views/shared/_user_callout.html.haml
@@ -1,4 +1,4 @@
-.user-callout
+.user-callout{ data: { uid: 'user_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss customize experience box' }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
new file mode 100644
index 00000000000..e6075c3ae3a
--- /dev/null
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -0,0 +1,30 @@
+- form = local_assigns.fetch(:form)
+- deploy_key = local_assigns.fetch(:deploy_key)
+
+= form_errors(deploy_key)
+
+.form-group
+ = form.label :title, class: 'control-label'
+ .col-sm-10= form.text_field :title, class: 'form-control'
+
+.form-group
+ - if deploy_key.new_record?
+ = form.label :key, class: 'control-label'
+ .col-sm-10
+ %p.light
+ Paste a machine public key here. Read more about how to generate it
+ = link_to 'here', help_page_path('ssh/README')
+ = form.text_area :key, class: 'form-control thin_area', rows: 5
+ - else
+ = form.label :fingerprint, class: 'control-label'
+ .col-sm-10
+ = form.text_field :fingerprint, class: 'form-control', readonly: 'readonly'
+
+.form-group
+ .control-label
+ .col-sm-10
+ = form.label :can_push do
+ = form.check_box :can_push
+ %strong Write access allowed
+ %p.light.append-bottom-0
+ Allow this key to push to repository as well? (Default only allows pull access.)
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index c229d18903f..b0c0ab523c7 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -3,10 +3,10 @@
- has_button = button_path || project_select_button
.row.empty-state
- .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/issues.svg'
- .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12.text-center
.text-content
- if has_button && current_user
%h4
@@ -16,8 +16,8 @@
Also, issues are searchable and filterable.
- if project_select_button
= render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue'
- = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
+ - else
+ = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
- else
.text-center
%h4 There are no issues to show.
- = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index 00fb77bdb3b..bfda522f2f6 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,11 +1,11 @@
.row.empty-state.labels
- .pull-right.col-xs-12.col-sm-6
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/labels.svg'
- .col-xs-12.col-sm-6
+ .col-xs-12.text-center
.text-content
%h4 Labels can be applied to issues and merge requests to categorize them.
%p You can also star a label to make it a priority label.
- if can?(current_user, :admin_label, @project)
- = link_to 'New label', new_namespace_project_label_path(@project.namespace, @project), class: 'btn btn-new', title: 'New label', id: 'new_label_link'
- = link_to 'Generate a default set of labels', generate_namespace_project_labels_path(@project.namespace, @project), method: :post, class: 'btn btn-success btn-inverted', title: 'Generate a default set of labels', id: 'generate_labels_link'
+ = link_to 'New label', new_project_label_path(@project), class: 'btn btn-new', title: 'New label', id: 'new_label_link'
+ = link_to 'Generate a default set of labels', generate_project_labels_path(@project), method: :post, class: 'btn btn-success btn-inverted', title: 'Generate a default set of labels', id: 'generate_labels_link'
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 7f2f99f3406..3e64f403b8b 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -3,10 +3,10 @@
- has_button = button_path || project_select_button
.row.empty-state.merge-requests
- .col-xs-12{ class: "#{'col-sm-6 pull-right' if has_button}" }
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/merge_requests.svg'
- .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12.text-center
.text-content
- if has_button
%h4
diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg
index 8119d5bebe0..7c672538097 100644
--- a/app/views/shared/empty_states/icons/_pipelines_empty.svg
+++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg> \ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg>
diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg
new file mode 100644
index 00000000000..87128ecd69d
--- /dev/null
+++ b/app/views/shared/errors/_graphic_422.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg>
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
new file mode 100644
index 00000000000..f65bb6a29e6
--- /dev/null
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -0,0 +1,23 @@
+- project = local_assigns.fetch(:project)
+- model = local_assigns.fetch(:model)
+
+- form = local_assigns.fetch(:form)
+- supports_quick_actions = model.new_record?
+
+- if supports_quick_actions
+ - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name)
+- else
+ - preview_url = preview_markdown_path(project)
+
+.form-group.detail-page-description
+ = form.label :description, 'Description', class: 'control-label'
+ .col-sm-10
+
+ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
+ = render 'projects/zen', f: form, attr: :description,
+ classes: 'note-textarea',
+ placeholder: "Write a comment or drag your files here...",
+ supports_quick_actions: supports_quick_actions
+ = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
+ .clearfix
+ .error-alert
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 37589b634fa..760370a6984 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,10 +1,10 @@
-.dropdown.inline
+.dropdown.inline.js-group-filter-dropdown-wrap
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ %span.dropdown-label
+ - if @sort.present?
+ = sort_options_hash[@sort]
+ - else
+ = sort_title_recently_created
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
new file mode 100644
index 00000000000..af6a499fadb
--- /dev/null
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -0,0 +1,44 @@
+%p
+ %strong Request URL:
+ POST
+ = hook_log.url
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+
+%p
+ %strong Trigger:
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+%p
+ %strong Elapsed time:
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+%p
+ %strong Request time:
+ = time_ago_with_tooltip(hook_log.created_at)
+
+%hr
+
+- if hook_log.internal_error_message.present?
+ .bs-callout.bs-callout-danger
+ = hook_log.internal_error_message
+
+%h5 Request headers:
+%pre
+ - hook_log.request_headers.each do |k,v|
+ <strong>#{k}:</strong> #{v}
+ %br
+
+%h5 Request body:
+%pre
+ :plain
+ #{JSON.pretty_generate(hook_log.request_data)}
+%h5 Response headers:
+%pre
+ - hook_log.response_headers.each do |k,v|
+ <strong>#{k}:</strong> #{v}
+ %br
+
+%h5 Response body:
+%pre
+ :plain
+ #{hook_log.response_body}
diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml
new file mode 100644
index 00000000000..b4ea8e6f952
--- /dev/null
+++ b/app/views/shared/hook_logs/_status_label.html.haml
@@ -0,0 +1,3 @@
+- label_status = hook_log.success? ? 'label-success' : 'label-danger'
+%span{ class: "label #{label_status}" }
+ = hook_log.response_status
diff --git a/app/views/shared/icons/_abuse_reports.svg b/app/views/shared/icons/_abuse_reports.svg
new file mode 100644
index 00000000000..fb16b269150
--- /dev/null
+++ b/app/views/shared/icons/_abuse_reports.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12zm-2.163-3.275a2.499 2.499 0 0 1 4.343.03.5.5 0 0 1-.871.49 1.5 1.5 0 0 0-2.607-.018.5.5 0 1 1-.865-.502zM5 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm6 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_access_tokens.svg b/app/views/shared/icons/_access_tokens.svg
new file mode 100644
index 00000000000..07ea6dab715
--- /dev/null
+++ b/app/views/shared/icons/_access_tokens.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m13 2h-10c-1.7 0-3 1.3-3 3v6c0 1.7 1.3 3 3 3h10c1.7 0 3-1.3 3-3v-6c0-1.7-1.3-3-3-3m1 9c0 .6-.4 1-1 1h-10c-.6 0-1-.4-1-1v-6c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v6"/><circle cx="4" cy="8" r="1"/><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/></svg>
diff --git a/app/views/shared/icons/_account.svg b/app/views/shared/icons/_account.svg
new file mode 100644
index 00000000000..d47e4f59914
--- /dev/null
+++ b/app/views/shared/icons/_account.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m6.8 8c-.3 0-.5 0-.8 0-5 0-6 2.7-6 4.5s.1 2.5 6 2.5c.6 0 1.1 0 1.5 0-1-1.1-1.5-2.5-1.5-4 0-1.1.3-2.1.8-3"/><circle cx="6" cy="4" r="3"/><path d="m15.9 11.5l-.9-.6c0-.3-.1-.7-.2-.9l.6-.9c.1-.1.1-.2 0-.3l-.4-.5c-.1-.1-.2-.1-.3-.1l-.9.4c-.3-.2-.5-.3-.9-.4l-.3-1c0-.1-.1-.2-.2-.2h-.6c-.1 0-.2.1-.2.2l-.3 1c-.3.1-.6.2-.9.4l-1.1-.4c-.1 0-.2 0-.3.1l-.4.5c0 .1 0 .2 0 .3l.6.9c-.1.3-.2.6-.2.9l-.9.5c-.1.1-.1.2-.1.3l.1.6c0 .1.1.2.2.2l1.1.1c.1.2.3.4.5.6l-.2 1.2c0 .1 0 .2.1.3l.6.3c.1 0 .2 0 .3-.1l.9-.9c.2 0 .4 0 .6 0l.9.9c.1.1.2.1.3 0l.6-.3c.1 0 .2-.2.1-.3l-.1-1.1c.2-.2.4-.4.5-.6l1.1-.1c.1 0 .2-.1.2-.2l.1-.6c.1-.1.1-.2 0-.2m-3.9.5c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1-.4 1-1 1"/></svg>
diff --git a/app/views/shared/icons/_add_new_group.svg b/app/views/shared/icons/_add_new_group.svg
new file mode 100644
index 00000000000..ecd52c5e99f
--- /dev/null
+++ b/app/views/shared/icons/_add_new_group.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#E1DBF2" fill-rule="nonzero" d="M59.65 32.65H60l-2-2.42-2 2.4-2-2.4-2 2.4-2-2.4-2 2.4-2-2.4-2 2.42h.77C45.57 34.6 46 36.75 46 39c0 2.84-.7 5.5-1.92 7.86 1.97 2.28 4.83 3.64 7.92 3.64 5.8 0 10.5-4.74 10.5-10.6 0-2.8-1.08-5.36-2.85-7.25zM43.18 29.6c2.4-2.1 5.52-3.3 8.82-3.3 7.46 0 13.5 6.1 13.5 13.6S59.46 53.5 52 53.5c-3.68 0-7.1-1.5-9.6-4.04C39.3 53.44 34.44 56 29 56c-9.4 0-17-7.6-17-17s7.6-17 17-17c3.22 0 6.23.9 8.8 2.45 2.13 1.3 3.97 3.05 5.38 5.16zM17 34c-.65 1.54-1 3.23-1 5 0 7.18 5.82 13 13 13s13-5.82 13-13c0-1.77-.35-3.46-1-5h-9c-.53 0-1.04-.2-1.4-.6L29 31.84l-1.6 1.58c-.36.4-.87.6-1.4.6h-9zm21.38-4c-2.4-2.5-5.76-4-9.38-4-3.62 0-6.98 1.5-9.38 4h5.55l2.42-2.4c.74-.8 2-.8 2.8 0l2.4 2.4h5.54z"/>
+ <path fill="#6B4FBB" d="M47.6 42.32c-.66 0-1.2-.54-1.2-1.2 0-.68.54-1.22 1.2-1.22.66 0 1.2.54 1.2 1.2 0 .68-.54 1.22-1.2 1.22zm8.8 0c-.66 0-1.2-.54-1.2-1.2 0-.68.54-1.22 1.2-1.22.66 0 1.2.54 1.2 1.2 0 .68-.54 1.22-1.2 1.22zM25 44h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-1c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_add_new_project.svg b/app/views/shared/icons/_add_new_project.svg
new file mode 100644
index 00000000000..3c1e15453df
--- /dev/null
+++ b/app/views/shared/icons/_add_new_project.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M30 24a4 4 0 0 0-4 4v22a4 4 0 0 0 4 4h18a4 4 0 0 0 4-4V28a4 4 0 0 0-4-4H30zm0-4h18a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z"/><path fill="#FC6D26" d="M33 30h8a2 2 0 1 1 0 4h-8a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4z"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/icons/_add_new_user.svg b/app/views/shared/icons/_add_new_user.svg
new file mode 100644
index 00000000000..0ad40498d7b
--- /dev/null
+++ b/app/views/shared/icons/_add_new_user.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#E1DBF2" d="M44 31l-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7H49l-2.5-3-2.5 3z"/>
+ <path fill="#E1DBF2" fill-rule="nonzero" d="M39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z"/>
+ <path fill="#6B4FBB" d="M35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_appearance.svg b/app/views/shared/icons/_appearance.svg
new file mode 100644
index 00000000000..8ffeb780cb4
--- /dev/null
+++ b/app/views/shared/icons/_appearance.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11.161 12.456l.232.121c.1.053.175.094.249.137.53.318.844.75.857 1.402.012 1.397-1.116 1.756-3.12 1.858-.411.022-.744.026-1.38.026A8 8 0 0 1 0 8a8 8 0 0 1 8-8c4.417 0 7.998 3.582 7.998 7.977.06 2.621-1.312 3.586-4.48 3.648-.602.008-1.068.043-1.4.104.228.192.598.47 1.043.727zm-3.287-.943c-.019-1.495 1.228-1.856 3.611-1.888C13.67 9.582 14.028 9.33 13.998 8A6 6 0 1 0 8 14c.603 0 .91-.004 1.277-.023.172-.009.332-.02.478-.035-1.172-.738-1.868-1.47-1.88-2.43zM6 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm6 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm-2-3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zM4 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_applications.svg b/app/views/shared/icons/_applications.svg
new file mode 100644
index 00000000000..65442867174
--- /dev/null
+++ b/app/views/shared/icons/_applications.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1 0h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm6-6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 1v2h2V1H7zm0 5h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm6-6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm0 6h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm0 1v2h2V7h-2zM1 12h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zm0 1v2h2v-2H1zm6-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zm6 0h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/></svg>
diff --git a/app/views/shared/icons/_authentication_log.svg b/app/views/shared/icons/_authentication_log.svg
new file mode 100644
index 00000000000..0beb84c2912
--- /dev/null
+++ b/app/views/shared/icons/_authentication_log.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4zm1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3-5h3a1 1 0 0 1 0 2H8a1 1 0 1 1 0-2zm0 3h3a1 1 0 0 1 0 2H8a1 1 0 1 1 0-2zm-3 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3-2h3a1 1 0 0 1 0 2H8a1 1 0 0 1 0-2z"/></svg>
diff --git a/app/views/shared/icons/_chat.svg b/app/views/shared/icons/_chat.svg
new file mode 100644
index 00000000000..0c474c9f980
--- /dev/null
+++ b/app/views/shared/icons/_chat.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M5.414 12l-3.707 3.707C1.077 16.337 0 15.891 0 15V3a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H5.414zM2 12.586l2.293-2.293A1 1 0 0 1 5 10h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v9.586z"/></svg>
diff --git a/app/views/shared/icons/_configure_server.svg b/app/views/shared/icons/_configure_server.svg
new file mode 100644
index 00000000000..b1137b7ec94
--- /dev/null
+++ b/app/views/shared/icons/_configure_server.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".03" d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/>
+ <path fill="#FEE1D3" fill-rule="nonzero" d="M24.92 35.15c-1.72-1.4-1.98-3.9-.6-5.63l1.26-1.55c1.4-1.72 3.9-2 5.63-.6l.7.56c.7-.4 1.4-.73 2.1-1V26c0-2.2 1.8-4 4-4h2c2.2 0 4 1.8 4 4v.92c.8.28 1.5.62 2.1 1l.7-.55c1.7-1.4 4.3-1.12 5.7.6l1.3 1.55c1.4 1.72 1.2 4.23-.6 5.63l-.7.6c.3.74.4 1.5.5 2.3l.9.2c2.2.5 3.5 2.64 3 4.8L56.4 45c-.5 2.15-2.64 3.5-4.8 3l-.88-.2c-.44.63-.92 1.24-1.46 1.8l.4.82c.9 1.98.1 4.38-1.9 5.35l-1.8.87c-2 .97-4.37.15-5.34-1.84l-.46-.85c-.34.03-.74.05-1.13.05-.4 0-.8-.02-1.2-.05l-.4.85c-.95 2-3.34 2.8-5.33 1.84l-1.8-.87c-1.97-.97-2.8-3.37-1.83-5.35l.4-.8c-.54-.58-1.02-1.2-1.46-1.83l-.8.2c-2.2.5-4.3-.9-4.8-3l-.4-2c-.5-2.2.85-4.3 3-4.8l.9-.2c.1-.8.3-1.6.5-2.3l-.7-.6zm4.95.77c-.53 1.2-.83 2.47-.87 3.8-.02.9-.66 1.68-1.55 1.9l-2.32.53.45 1.94 2.3-.6c.9-.2 1.8.2 2.23 1 .7 1.1 1.5 2.2 2.5 3 .7.6.9 1.6.5 2.4l-1 2.1 1.8.9 1.1-2.1c.4-.8 1.3-1.3 2.2-1.1.7.1 1.3.2 2 .2s1.3-.1 2-.2c.9-.2 1.8.3 2.2 1.1l1 2.1 1.8-.9-1.2-2c-.4-.8-.2-1.8.5-2.4 1-.85 1.84-1.88 2.45-3.05.4-.82 1.33-1.24 2.2-1.04l2.33.54.45-1.95-2.32-.54c-.9-.2-1.52-.97-1.54-1.88-.03-1.4-.33-2.6-.86-3.8-.4-.9-.2-1.8.5-2.4l1.9-1.5-1.3-1.6-1.8 1.5c-.8.5-1.8.6-2.5 0-1.1-.8-2.3-1.4-3.5-1.7-.9-.2-1.5-1-1.5-1.9V26h-2v2.38c0 .9-.6 1.7-1.5 1.93-1.3.4-2.5 1-3.5 1.7-.8.6-1.8.6-2.5 0l-1.9-1.5-1.26 1.6 1.8 1.5c.7.6.94 1.6.6 2.4z"/>
+ <path fill="#FC6D26" fill-rule="nonzero" d="M39 46c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6zm0-4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_container_registry.svg b/app/views/shared/icons/_container_registry.svg
new file mode 100644
index 00000000000..56d62aab670
--- /dev/null
+++ b/app/views/shared/icons/_container_registry.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m16 11.764v-8.764c0-1.657-1.343-3-3-3h-10c-1.657 0-3 1.343-3 3v8.764c.531-.475 1.232-.764 2-.764v-8c0-.552.448-1 1-1h10c.552 0 1 .448 1 1v8c.768 0 1.469.289 2 .764m-14 .236h12c1.105 0 2 .895 2 2 0 1.105-.895 2-2 2h-12c-1.105 0-2-.895-2-2 0-1.105.895-2 2-2m10 1c-.552 0-1 .448-1 1 0 .552.448 1 1 1 .552 0 1-.448 1-1 0-.552-.448-1-1-1"/></svg>
diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg
new file mode 100644
index 00000000000..ed32b2333e7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_data.svg
@@ -0,0 +1,40 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/>
+ <g transform="translate(214 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/>
+ <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(118 7)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g fill-rule="nonzero">
+ <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/>
+ <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/>
+ </g>
+ <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(26 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(38 42)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M4 14h106v4H4z"/>
+ <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg
new file mode 100644
index 00000000000..95c00e81d10
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_index.svg
@@ -0,0 +1,67 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200">
+ <g fill="none" fill-rule="evenodd" transform="translate(3 11)">
+ <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/>
+ <g transform="translate(0 2)">
+ <rect width="110" height="168" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/>
+ <g transform="translate(19 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(67 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(36 40)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M2 12h106v4H2z"/>
+ <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(122)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(243)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg
new file mode 100644
index 00000000000..2f31113bad7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_overview.svg
@@ -0,0 +1,64 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <rect id="a" width="58" height="98" y="17" rx="6"/>
+ <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/>
+ <rect id="c" width="58" height="98.394" rx="6"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(1)">
+ <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/>
+ <g transform="translate(74)">
+ <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#a"/>
+ <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 45.185)">
+ <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/>
+ <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/>
+ <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/>
+ </g>
+ <g transform="translate(10.81)">
+ <circle cx="18.19" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/>
+ <g transform="translate(10 11)">
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/>
+ <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/>
+ <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/>
+ </g>
+ </g>
+ </g>
+ <g transform="translate(144.5)">
+ <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#b"/>
+ <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(19 46.185)">
+ <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/>
+ <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/>
+ </g>
+ <g transform="translate(14.413)">
+ <circle cx="18.087" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/>
+ <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/>
+ </g>
+ </g>
+ <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <g transform="translate(0 16.754)">
+ <use fill="#FFF" xlink:href="#c"/>
+ <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 29.618)">
+ <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/>
+ </g>
+ </g>
+ <g transform="translate(10.41)">
+ <circle cx="18.589" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/>
+ <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_doc_text.svg b/app/views/shared/icons/_doc_text.svg
new file mode 100644
index 00000000000..92902a5b449
--- /dev/null
+++ b/app/views/shared/icons/_doc_text.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V7h-3a2 2 0 0 1-2-2V2zm2 .414V5h2.586L10 2.414zM5 0h4.586A2 2 0 0 1 11 .586L14.414 4A2 2 0 0 1 15 5.414V12a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm.5 11h5a.5.5 0 1 1 0 1h-5a.5.5 0 1 1 0-1zm0-2h5a.5.5 0 1 1 0 1h-5a.5.5 0 0 1 0-1zm0-2h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"/></svg>
diff --git a/app/views/shared/icons/_ellipsis_v.svg b/app/views/shared/icons/_ellipsis_v.svg
new file mode 100644
index 00000000000..9117a9bb9ec
--- /dev/null
+++ b/app/views/shared/icons/_ellipsis_v.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><path d="M1088 1248v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm0-512v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V224q0-40 28-68t68-28h192q40 0 68 28t28 68z"/></svg>
diff --git a/app/views/shared/icons/_emails.svg b/app/views/shared/icons/_emails.svg
new file mode 100644
index 00000000000..3ebc64bb03e
--- /dev/null
+++ b/app/views/shared/icons/_emails.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm0-2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3z"/><path d="M3.212 4L8 8.31 12.788 4H3.212zm6.126 5.796a2 2 0 0 1-2.676 0L.183 3.965A3.001 3.001 0 0 1 3 2h10c1.293 0 2.395.818 2.817 1.965l-6.48 5.83z"/></svg>
diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg
new file mode 100644
index 00000000000..f2c94319f19
--- /dev/null
+++ b/app/views/shared/icons/_express.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express">
+ <g fill="none" fill-rule="evenodd" transform="translate(-3)">
+ <rect width="32" height="32"/>
+ <path fill="#353535" d="M4.19170065,16.2667139 C4.23142421,18.3323387 4.47969269,20.2489714 4.93651356,22.0166696 C5.39333443,23.7843677 6.09841693,25.3236323 7.05178222,26.6345096 C8.00514751,27.9453869 9.23655921,28.9781838 10.7460543,29.7329313 C12.2555493,30.4876788 14.1026668,30.8650469 16.2874623,30.8650469 C19.5050701,30.8650469 22.1764391,30.0209341 24.3016492,28.3326831 C26.4268593,26.644432 27.7476477,24.1120935 28.2640539,20.7355914 L29.4557545,20.7355914 C29.0187954,24.3107112 27.6086304,27.0813875 25.2252172,29.0477034 C22.841804,31.0140194 19.9023051,31.9971626 16.4066324,31.9971626 C14.0232191,32.0368861 11.9874175,31.659518 10.2991665,30.8650469 C8.61091547,30.0705759 7.23054269,28.9484023 6.15800673,27.4984926 C5.08547078,26.0485829 4.29101162,24.3404957 3.77460543,22.3741798 C3.25819923,20.4078639 3,18.2926164 3,16.0283738 C3,13.4860664 3.3773681,11.2218578 4.13211562,9.23568007 C4.88686314,7.24950238 5.87993709,5.57120741 7.11136726,4.20074481 C8.34279742,2.8302822 9.77282391,1.78755456 11.4014896,1.07253059 C13.0301553,0.357506621 14.6985195,0 16.4066324,0 C18.7900456,0 20.8457087,0.456814016 22.5736832,1.37045575 C24.3016578,2.28409749 25.7118228,3.4956477 26.8042206,5.00514275 C27.8966183,6.51463779 28.6910775,8.24258646 29.1876219,10.1890406 C29.6841663,12.1354947 29.8927118,14.1613656 29.8132647,16.2667139 L4.19170065,16.2667139 Z M28.6215641,15.0750133 C28.6215641,13.2080062 28.3633648,11.4304039 27.8469586,9.74215285 C27.3305524,8.05390181 26.5658855,6.57422163 25.5529349,5.30306791 C24.5399843,4.03191419 23.2787803,3.0289095 21.7692853,2.29402376 C20.2597903,1.55913801 18.5119801,1.19170065 16.5258024,1.19170065 C14.8574132,1.19170065 13.2982871,1.50948432 11.8483774,2.14506118 C10.3984676,2.78063804 9.12733299,3.70419681 8.03493526,4.9157652 C6.94253754,6.12733359 6.05870172,7.58715229 5.38340131,9.2952651 C4.70810089,11.0033779 4.31087132,12.9299414 4.19170065,15.0750133 L28.6215641,15.0750133 Z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_globe.svg b/app/views/shared/icons/_globe.svg
new file mode 100644
index 00000000000..c2daae5f317
--- /dev/null
+++ b/app/views/shared/icons/_globe.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#E1DBF2" d="M30.24 27.823A14.98 14.98 0 0 0 24 40c0 2.549.636 4.949 1.757 7.051-.297-2.684.644-4.026 2.823-4.026 3.707 0 2.462 5.365 4.473 5.761 2.01.396 4.175.396 4.267 3.29.04 1.257-.265 2.157-.917 2.7a15.095 15.095 0 0 0 8.555-1.006c.035-1.91.303-4.941 2.21-5.61 2.373-.833-.55-1.431.734-3.368 1.17-1.762-3.297-5.2 0-4.832 3.477.388 5.044-.816 6.024-1.456a14.903 14.903 0 0 0-1.373-4.94c-.873.4-2.19.465-3.702-.538-.757-.502-1.084-3.944-2.107-3.944-3.823 0-4.065 3.17-5.994 3.944-1.076.431-4.193 3.773-5.614 3.596-1.126-.14-1.071-4.417-2.45-5.166-1.359-.738-2.174-1.948-2.447-3.633zM39 59c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-8.507 19-19 19z"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg
new file mode 100644
index 00000000000..9dedcd5291a
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_1.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/>
+ <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/>
+ <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/>
+ <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg
new file mode 100644
index 00000000000..dd6fd1457ff
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_10.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/>
+ <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/>
+ <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/>
+ <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/>
+ <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/>
+ <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg
new file mode 100644
index 00000000000..b8805b90275
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_2.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg
new file mode 100644
index 00000000000..6c783ed8289
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_3.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg
new file mode 100644
index 00000000000..af804c838e0
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_4.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/>
+ <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg
new file mode 100644
index 00000000000..e54f707019e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_5.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg
new file mode 100644
index 00000000000..c57baccc06b
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_6.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg
new file mode 100644
index 00000000000..e9083de3afa
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_7.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+ <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/>
+ <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/>
+ <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg
new file mode 100644
index 00000000000..62676b0e12e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_8.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg
new file mode 100644
index 00000000000..e4285a14425
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_9.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/>
+</svg>
diff --git a/app/views/shared/icons/_icon_clone.svg b/app/views/shared/icons/_icon_clone.svg
new file mode 100644
index 00000000000..ccc897aa98f
--- /dev/null
+++ b/app/views/shared/icons/_icon_clone.svg
@@ -0,0 +1,3 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14" height="14" viewBox="0 0 14 14">
+<path d="M13 12.75v-8.5q0-0.102-0.074-0.176t-0.176-0.074h-8.5q-0.102 0-0.176 0.074t-0.074 0.176v8.5q0 0.102 0.074 0.176t0.176 0.074h8.5q0.102 0 0.176-0.074t0.074-0.176zM14 4.25v8.5q0 0.516-0.367 0.883t-0.883 0.367h-8.5q-0.516 0-0.883-0.367t-0.367-0.883v-8.5q0-0.516 0.367-0.883t0.883-0.367h8.5q0.516 0 0.883 0.367t0.367 0.883zM11 1.25v1.25h-1v-1.25q0-0.102-0.074-0.176t-0.176-0.074h-8.5q-0.102 0-0.176 0.074t-0.074 0.176v8.5q0 0.102 0.074 0.176t0.176 0.074h1.25v1h-1.25q-0.516 0-0.883-0.367t-0.367-0.883v-8.5q0-0.516 0.367-0.883t0.883-0.367h8.5q0.516 0 0.883 0.367t0.367 0.883z"></path>
+</svg>
diff --git a/app/views/shared/icons/_icon_empty_metrics.svg b/app/views/shared/icons/_icon_empty_metrics.svg
new file mode 100644
index 00000000000..24fa353f3ba
--- /dev/null
+++ b/app/views/shared/icons/_icon_empty_metrics.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
+ <g fill="#E5E5E5">
+ <path d="M32 64C30.8954305 64 30 63.1045695 30 62 30 60.8954305 30.8954305 60 32 60 33.8894444 60 35.7536611 59.8131396 37.574335 59.4454933 38.6570511 59.2268618 39.7120017 59.9273408 39.9306331 61.0100569 40.1492646 62.0927729 39.4487856 63.1477235 38.3660695 63.366355 36.285133 63.7865558 34.1557023 64 32 64zM49.2301062 58.9696428C51.0302775 57.8173242 52.7114504 56.4871355 54.247711 55.0008916 55.0415758 54.232873 55.0625283 52.9667164 54.2945097 52.1728516 53.5264912 51.3789869 52.2603346 51.3580344 51.4664698 52.1260529 50.1212672 53.4274592 48.6493395 54.5920875 47.0736141 55.6007347 46.1433158 56.1962335 45.8719072 57.4331365 46.4674061 58.3634348 47.0629049 59.2937331 48.2998079 59.5651416 49.2301062 58.9696428zM61.0426034 45.4531856C61.9412068 43.5163476 62.6441937 41.4911051 63.1388045 39.4034279 63.393449 38.3286117 62.7285685 37.2508708 61.6537523 36.9962262 60.5789361 36.7415816 59.5011952 37.4064621 59.2465506 38.4812784 58.8141946 40.3061875 58.1997219 42.0764286 57.4141077 43.7697311 56.9492346 44.7717126 57.3846469 45.9608331 58.3866284 46.4257062 59.3886098 46.8905793 60.5777303 46.455167 61.0426034 45.4531856zM63.7270657 27.8034151C63.4476841 25.6718707 62.9558906 23.5863203 62.2616468 21.5714028 61.9018246 20.527084 60.7635435 19.9721898 59.7192246 20.3320119 58.6749058 20.6918341 58.1200116 21.8301152 58.4798337 22.874434 59.0867105 24.6357842 59.5166381 26.45898 59.760988 28.3232492 59.9045362 29.4184513 60.9087418 30.1899192 62.0039439 30.046371 63.099146 29.9028228 63.8706139 28.8986173 63.7270657 27.8034151zM56.4699838 11.3781121C55.0919588 9.74451505 53.5537382 8.25140603 51.8798083 6.92273835 51.0146495 6.23602588 49.7566092 6.38068523 49.0698968 7.24584403 48.3831843 8.11100284 48.5278436 9.36904308 49.3930024 10.0557555 50.8587525 11.2191822 52.2058153 12.5267396 53.4125204 13.9572433 54.1247279 14.8015385 55.3865225 14.9086168 56.2308177 14.1964094 57.0751129 13.484202 57.1821912 12.2224073 56.4699838 11.3781121zM41.481294 1.42849704C39.4470333.798260231 37.3474846.371987025 35.2067823.158824109 34.1076485.0493765922 33.1278998.851675811 33.0184523 1.95080957 32.9090048 3.04994333 33.711304 4.02969203 34.8104377 4.13913955 36.6833634 4.32563829 38.5191483 4.69835932 40.297557 5.24933028 41.3526509 5.57621023 42.4729622 4.98587613 42.7998421 3.93078217 43.1267221 2.8756882 42.536388 1.75537699 41.481294 1.42849704zM23.6558195 1.0993008C21.5852929 1.6571259 19.5822296 2.42161363 17.6728876 3.37914679 16.6855233 3.874309 16.2865147 5.07613416 16.7816769 6.06349841 17.2768392 7.05086266 18.4786643 7.44987125 19.4660286 6.95470905 21.1354949 6.11747332 22.8864813 5.44919307 24.6963667 4.96158787 25.7629079 4.67424869 26.3945759 3.57671185 26.1072367 2.51017072 25.8198975 1.44362959 24.7223606.811961615 23.6558195 1.0993008zM8.36290105 10.4291871C6.92120358 12.00815 5.63985273 13.7275139 4.53998784 15.5610549 3.97179016 16.5082746 4.27904822 17.7367631 5.22626792 18.3049608 6.17348763 18.8731585 7.40197615 18.5659004 7.97017383 17.6186807 8.9327668 16.0139803 10.054503 14.5087932 11.3168098 13.126301 12.0615972 12.3106016 12.0041117 11.0455771 11.1884123 10.3007897 10.372713 9.55600224 9.10768848 9.61348772 8.36290105 10.4291871zM.450120287 26.6230259C.151304663 28.3883054 0 30.1850053 0 32 0 32.2974081.00406268322 32.594367.0121750297 32.8908218.0423897377 33.994978.96197903 34.8655796 2.0661352 34.8353649 3.17029137 34.8051502 4.04089294 33.8855609 4.01067824 32.7814047 4.00356366 32.521412 4 32.2609289 4 32 4 30.4089462 4.13249902 28.8355581 4.39401589 27.2906242 4.57836807 26.2015475 3.84494393 25.1692294 2.75586724 24.9848772 1.66679054 24.800525.634472466 25.5339492.450120287 26.6230259zM2.45830096 44.3202494C3.28286321 46.2952494 4.30407075 48.1806071 5.50459135 49.9494734 6.124886 50.8634254 7.36863868 51.1014818 8.28259072 50.4811871 9.19654276 49.8608925 9.43459912 48.6171398 8.81430448 47.7031878 7.76386025 46.1554464 6.87058107 44.5062706 6.14951581 42.7791677 5.72395784 41.7598668 4.55266835 41.2785432 3.53336751 41.7041011 2.51406668 42.1296591 2.03274299 43.3009486 2.45830096 44.3202494zM13.73374 58.2776222C15.4883094 59.4994144 17.3614388 60.5433005 19.3262717 61.39161 20.3403619 61.8294398 21.5173756 61.3622885 21.9552054 60.3481983 22.3930351 59.3341082 21.9258838 58.1570945 20.9117937 57.7192647 19.1934726 56.9773858 17.5548741 56.0642026 16.0195384 54.9950736 15.1130877 54.3638678 13.8665707 54.5869979 13.2353649 55.4934487 12.6041591 56.3998995 12.8272892 57.6464164 13.73374 58.2776222zM30.6955071 63.9738646C29.5918263 63.9295649 28.7330282 62.9989428 28.7773279 61.895262 28.8216276 60.7915812 29.7522497 59.9327832 30.8559305 59.9770829 31.2344492 59.9922759 31.6140624 59.9999282 31.9946308 59.9999995 33.0992003 60.0002065 33.994463 60.8958047 33.994256 62.0003742 33.9940491 63.1049437 33.0984508 64.0002064 31.9938814 63.9999994 31.5600677 63.9999181 31.1272192 63.9911927 30.6955071 63.9738646zM30.1721098 44.2840559C30.7941711 46.023825 33.2407935 46.0619159 33.9167124 44.3423547L38.9452693 31.5495297 41.1315797 35.2685507C41.4908522 35.8796908 42.1468005 36.2549751 42.8557214 36.2549751L51.1106965 36.2549751C52.215266 36.2549751 53.1106965 35.3595446 53.1106965 34.2549751 53.1106965 33.1504056 52.215266 32.2549751 51.1106965 32.2549751L43.9999712 32.2549751 40.3112064 25.9802055C39.465988 24.5424477 37.3358287 24.7099356 36.7257006 26.2621229L32.1439734 37.9181973 26.2115967 21.3266406C25.5807315 19.562249 23.0875908 19.5563214 22.4483429 21.3176933L18.4775633 32.2587065 13 32.2587065C11.8954305 32.2587065 11 33.154137 11 34.2587065 11 35.363276 11.8954305 36.2587065 13 36.2587065L19.8793532 36.2587065C20.720826 36.2587065 21.4722973 35.732004 21.7593685 34.9410132L24.314328 27.9011249 30.1721098 44.2840559z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_explore_groups_splash.svg b/app/views/shared/icons/_icon_explore_groups_splash.svg
new file mode 100644
index 00000000000..79f17872739
--- /dev/null
+++ b/app/views/shared/icons/_icon_explore_groups_splash.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="62" height="50" viewBox="260 141 62 50" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M24.6 7.7H56c3.3 0 6 2.7 6 6V44c0 3.3-2.7 6-6 6H6c-3.3 0-6-2.7-6-6V4.8C0 2 2.2 0 4.8 0h12c1.5 0 3 1 4 2l3.8 5.7z"/><mask id="e" width="62" height="50" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="f" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#b"/></mask><path id="c" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="g" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#c"/></mask><path id="d" d="M5.4 16c4.7 0 5.3-2.3 5.3-6 0-3.5-1.7-4.6-5.3-4.6C1.7 5.4 0 6.4 0 10s.6 6 5.4 6z"/><mask id="h" width="13.1" height="13.1" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 4.2h13v13H-1z"/><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(260 141)"><use fill="#FFF" stroke="#EEE" stroke-width="4.8" mask="url(#e)" xlink:href="#a"/><g transform="translate(33.98 22.62)"><use fill="#B5A7DD" xlink:href="#b"/><use stroke="#FFF" stroke-width="2.4" mask="url(#f)" xlink:href="#b"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(19.673 22.62)"><use fill="#B5A7DD" xlink:href="#c"/><use stroke="#FFF" stroke-width="2.4" mask="url(#g)" xlink:href="#c"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(25.635 21.43)"><use fill="#B5A7DD" xlink:href="#d"/><use stroke="#FFF" stroke-width="2.4" mask="url(#h)" xlink:href="#d"/><ellipse cx="5.4" cy="3.6" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3.6" ry="3.6"/></g></g></svg>
diff --git a/app/views/shared/icons/_icon_history.svg b/app/views/shared/icons/_icon_history.svg
new file mode 100644
index 00000000000..41096da19c5
--- /dev/null
+++ b/app/views/shared/icons/_icon_history.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792"><path d="M1664 896q0 156-61 298t-164 245-245 164-298 61q-172 0-327-72.5T305 1387q-7-10-6.5-22.5t8.5-20.5l137-138q10-9 25-9 16 2 23 12 73 95 179 147t225 52q104 0 198.5-40.5T1258 1258t109.5-163.5T1408 896t-40.5-198.5T1258 534t-163.5-109.5T896 384q-98 0-188 35.5T548 521l137 138q31 30 14 69-17 40-59 40H192q-26 0-45-19t-19-45V256q0-42 40-59 39-17 69 14l130 129q107-101 244.5-156.5T896 128q156 0 298 61t245 164 164 245 61 298zm-640-288v448q0 14-9 23t-23 9H672q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224V608q0-14 9-23t23-9h64q14 0 23 9t9 23z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_notfound_borderless.svg b/app/views/shared/icons/_icon_status_notfound_borderless.svg
new file mode 100644
index 00000000000..e58bd264ef8
--- /dev/null
+++ b/app/views/shared/icons/_icon_status_notfound_borderless.svg
@@ -0,0 +1 @@
+<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M12.822 11.29c.816-.581 1.421-1.348 1.683-2.322.603-2.243-.973-4.553-3.53-4.553-1.15 0-2.085.41-2.775 1.089-.42.413-.672.835-.8 1.167a1.179 1.179 0 0 0 2.2.847c.016-.043.1-.184.252-.334.264-.259.613-.412 1.123-.412.938 0 1.47.78 1.254 1.584-.105.39-.37.726-.773 1.012a3.25 3.25 0 0 1-.945.47 1.179 1.179 0 0 0-.874 1.138v2.234a1.179 1.179 0 1 0 2.358 0V11.78a5.9 5.9 0 0 0 .827-.492z" fill-rule="nonzero"/><ellipse cx="10.825" cy="16.711" rx="1.275" ry="1.322"/></svg>
diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg
index 1998dfef9ea..a9ba29c922c 100755
--- a/app/views/shared/icons/_icon_status_skipped.svg
+++ b/app/views/shared/icons/_icon_status_skipped.svg
@@ -1 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7.69 7.7l-.905.905a.7.7 0 0 0 .99.99l1.85-1.85c.411-.412.411-1.078 0-1.49l-1.85-1.85a.7.7 0 0 0-.99.99l.905.905H4.48a.7.7 0 0 0 0 1.4h3.21z"/></g></svg>
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF" fill-rule="nonzero"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_skipped_borderless.svg b/app/views/shared/icons/_icon_status_skipped_borderless.svg
index fb3e930b3cb..3c8a26d7f4d 100644
--- a/app/views/shared/icons/_icon_status_skipped_borderless.svg
+++ b/app/views/shared/icons/_icon_status_skipped_borderless.svg
@@ -1 +1 @@
-<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.0846,12.1 L10.6623,13.5223 C10.2454306,13.9539168 10.2513924,14.6399933 10.6756996,15.0643004 C11.1000067,15.4886076 11.7860832,15.4945694 12.2177,15.0777 L15.1261,12.1693 C15.7708612,11.5230891 15.7708612,10.4769109 15.1261,9.8307 L12.2177,6.9223 C11.7860832,6.50543057 11.1000067,6.51139239 10.6756996,6.93569957 C10.2513924,7.36000675 10.2454306,8.04608322 10.6623,8.4777 L12.0846,9.9 L7.04,9.9 C6.43248678,9.9 5.94,10.3924868 5.94,11 C5.94,11.6075132 6.43248678,12.1 7.04,12.1 L12.0846,12.1 L12.0846,12.1 Z" id="Shape"></path></svg>
+<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></svg>
diff --git a/app/views/shared/icons/_issues.svg b/app/views/shared/icons/_issues.svg
new file mode 100644
index 00000000000..439023c86d0
--- /dev/null
+++ b/app/views/shared/icons/_issues.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.458 15.012l.311.055a3 3 0 0 0 3.476-2.433l1.389-7.879A3 3 0 0 0 13.2 1.28L11.23.933a3.002 3.002 0 0 0-.824-.031c.364.59.58 1.28.593 2.02l1.854.328a1 1 0 0 1 .811 1.158l-1.389 7.879a1 1 0 0 1-1.158.81l-.118-.02a3.98 3.98 0 0 1-.541 1.935zM3 0h4a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3z"/></svg>
diff --git a/app/views/shared/icons/_issues.svg.erb b/app/views/shared/icons/_issues.svg.erb
deleted file mode 100644
index fa8655b5609..00000000000
--- a/app/views/shared/icons/_issues.svg.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-<svg width="<%= size %>" height="<%= size %>" viewBox="0 0 16 16" class="gitlab-icon">
- <path fill="#7E7C7C" d="M8,0 C3.581,0 0,3.581 0,8 C0,12.419 3.581,16 8,16 C12.419,16 16,12.419 16,8 C16,3.581 12.419,0 8,0 M8,2 C11.308,2 14,4.692 14,8 C14,11.308 11.308,14 8,14 C4.692,14 2,11.308 2,8 C2,4.692 4.692,2 8,2"></path>
- <path fill="#7E7C7C" d="M7.1597,4 L8.8887,4 L8.8887,8 L7.1107,8 L7.1597,4 Z M7.1597,9.6667 L8.8887,9.6667 L8.8887,11.4447 L7.1107,11.4447 L7.1597,9.6667 Z"></path>
-</svg>
diff --git a/app/views/shared/icons/_key.svg b/app/views/shared/icons/_key.svg
new file mode 100644
index 00000000000..5ad03ed4480
--- /dev/null
+++ b/app/views/shared/icons/_key.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.574 6.689a4.002 4.002 0 0 1 6.275-4.861 4 4 0 0 1-4.86 6.275l-2.21 2.21.706.707a1 1 0 0 1-1.414 1.415l-.707-.708-.707.708.707.707a1 1 0 0 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.415-1.414l5.746-5.746zm2.033-.618a2 2 0 1 0 2.828-2.829 2 2 0 0 0-2.828 2.829z"/></svg>
diff --git a/app/views/shared/icons/_key_2.svg b/app/views/shared/icons/_key_2.svg
new file mode 100644
index 00000000000..368b2876c60
--- /dev/null
+++ b/app/views/shared/icons/_key_2.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M5.172 14.157l-.344.344-2.485.133a.462.462 0 0 1-.497-.503l.14-2.24a.599.599 0 0 1 .177-.382l5.155-5.155a4 4 0 1 1 2.828 2.828l-1.439 1.44-1.06-.354-.708.707.354 1.06-.707.708-1.06-.354-.708.707.354 1.06zm6.01-8.839a1 1 0 1 0 1.414-1.414 1 1 0 0 0-1.414 1.414z"/></svg>
diff --git a/app/views/shared/icons/_labels.svg b/app/views/shared/icons/_labels.svg
new file mode 100644
index 00000000000..1ebad4bb4fa
--- /dev/null
+++ b/app/views/shared/icons/_labels.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.424 2.254l2.08-.905a1 1 0 0 1 1.206.326l3.013 4.12a1 1 0 0 1 .16.849l-1.947 7.264a3 3 0 0 1-3.675 2.122l-.5-.135a3.999 3.999 0 0 0 1.082-1.782 1 1 0 0 0 1.16-.722l1.823-6.802-2.258-3.087-.687.299a2 2 0 0 0-.628-.88l-.829-.667z"/><path d="M.377 3.7L4.4.498a1 1 0 0 1 1.25.003L9.627 3.7a1 1 0 0 1 .373.78V13a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4.482A1 1 0 0 1 .377 3.7zM2 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4.958L5.02 2.561 2 4.964V13zm3-6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_lock.svg b/app/views/shared/icons/_lock.svg
new file mode 100644
index 00000000000..703c09611a3
--- /dev/null
+++ b/app/views/shared/icons/_lock.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m8 9c-.6 0-1 .4-1 1v1c0 .6.4 1 1 1s1-.4 1-1v-1c0-.6-.4-1-1-1"/><path d="m12 5v-1c0-2.2-1.8-4-4-4s-4 1.8-4 4v1c-1.7 0-3 1.3-3 3v5c0 1.7 1.3 3 3 3h8c1.7 0 3-1.3 3-3v-5c0-1.7-1.3-3-3-3m-6-1c0-1.1.9-2 2-2s2 .9 2 2v1h-4v-1m7 9c0 .6-.4 1-1 1h-8c-.6 0-1-.4-1-1v-5c0-.6.4-1 1-1h8c.6 0 1 .4 1 1v5"/></svg>
diff --git a/app/views/shared/icons/_members.svg b/app/views/shared/icons/_members.svg
new file mode 100644
index 00000000000..68d957d6d11
--- /dev/null
+++ b/app/views/shared/icons/_members.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10.521 8.01C15.103 8.19 16 10.755 16 12.48c0 1.533-.056 2.29-3.808 2.475.609-.54.808-1.331.808-2.475 0-1.911-.804-3.503-2.479-4.47zm-1.67-1.228A3.987 3.987 0 0 0 9.976 4a3.987 3.987 0 0 0-1.125-2.782 3 3 0 1 1 0 5.563zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8s6 2.692 6 4.48c0 1.788-.076 2.52-6 2.52z"/></svg>
diff --git a/app/views/shared/icons/_messages.svg b/app/views/shared/icons/_messages.svg
new file mode 100644
index 00000000000..9a2ea15c35d
--- /dev/null
+++ b/app/views/shared/icons/_messages.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.588 8.942l1.173 5.862A1 1 0 0 1 8.78 16H7.22a1 1 0 0 1-.98-1.196l1.172-5.862a3.014 3.014 0 0 0 1.176 0zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zM4.464 2.464L5.88 3.88a3 3 0 0 0 0 4.242L4.464 9.536a5 5 0 0 1 0-7.072zm7.072 7.072L10.12 8.12a3 3 0 0 0 0-4.242l1.415-1.415a5 5 0 0 1 0 7.072zM2.343.343l1.414 1.414a6 6 0 0 0 0 8.486l-1.414 1.414a8 8 0 0 1 0-11.314zm11.314 11.314l-1.414-1.414a6 6 0 0 0 0-8.486L13.657.343a8 8 0 0 1 0 11.314z"/></svg>
diff --git a/app/views/shared/icons/_monitoring.svg b/app/views/shared/icons/_monitoring.svg
new file mode 100644
index 00000000000..21689b0877c
--- /dev/null
+++ b/app/views/shared/icons/_monitoring.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10 13v1h3a1 1 0 0 1 0 2H3a1 1 0 0 1 0-2h3v-1H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3h-3zM3 2a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm5.723 6.416l-2.66-1.773-1.71 1.71a.5.5 0 1 1-.707-.707l2-2a.5.5 0 0 1 .631-.062l2.66 1.773 2.71-2.71a.5.5 0 0 1 .707.707l-3 3a.5.5 0 0 1-.631.062z"/></svg>
diff --git a/app/views/shared/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg
new file mode 100644
index 00000000000..6a811893b2d
--- /dev/null
+++ b/app/views/shared/icons/_mr_widget_empty_state.svg
@@ -0,0 +1 @@
+<svg width="256" height="146" viewBox="0 0 256 146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="178.714" height="115.389" rx="10"/><mask id="d" x="0" y="0" width="178.714" height="115.389" fill="#fff"><use xlink:href="#a"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="b"/><mask id="e" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#b"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="c"/><mask id="f" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.868)" fill="#F9F9F9"><rect x="19.286" width="77.143" height="14.182" rx="7.091"/><rect y="28.364" width="84.857" height="14.182" rx="7.091"/><rect x="133.714" y="42.546" width="122.143" height="14.182" rx="7.091"/><rect x="82.929" y="126.992" width="101.571" height="14.182" rx="7.091"/><rect x="42.429" y="99.273" width="101.571" height="14.182" rx="7.091"/><rect x="19.929" y="70.909" width="225" height="14.182" rx="7.091"/><path d="M98.37 14.182H13.488h13.81a7.098 7.098 0 0 1 7.094 7.09 7.09 7.09 0 0 1-7.094 7.092h-13.81 84.88-23.452a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.096-7.092h23.452zm162 42.545h-75.238 23.452a7.098 7.098 0 0 1 7.095 7.09 7.09 7.09 0 0 1-7.096 7.092h-23.452 75.237-23.453a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.093h23.452zM103.512 85.09H28.275h23.452a7.098 7.098 0 0 1 7.095 7.092 7.09 7.09 0 0 1-7.095 7.09H28.275h75.237H80.06a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.09h23.452zm48.215 28.365H76.49 90.3a7.098 7.098 0 0 1 7.093 7.09 7.09 7.09 0 0 1-7.094 7.092H76.49h75.237-33.096a7.098 7.098 0 0 1-7.094-7.09 7.09 7.09 0 0 1 7.095-7.092h33.097z"/></g><g transform="translate(38.57 12.248)"><use stroke="#EEE" mask="url(#d)" stroke-width="8" fill="#FFF" xlink:href="#a"/><path fill="#EEE" d="M2.57 18.694h174.215v2.58H2.57z"/><g transform="translate(21.857 38.678)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 59.95)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#FC6D26" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 81.223)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(100.93 38.033)"><rect fill="#FDE5D8" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="14.826" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="21.917" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" y="21.273" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="37.286" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="35.455" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="28.364" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="30.857" y="21.273" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="35.455" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="21.273" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="30.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="39.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="49.5" y="14.182" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="29.008" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="36.099" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="43.19" width="3.857" height="1.289" rx=".645"/><rect fill="#6B4FBB" x="9.643" y="42.546" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="56.727" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="34.071" y="49.636" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="56.727" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="49.636" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="42.546" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="50.281" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="57.372" width="3.857" height="1.289" rx=".645"/></g></g><g transform="translate(196.07)"><use stroke="#FDE5D8" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#b"/><rect fill="#FDB692" x="9" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="14.826" width="25.071" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="20.628" width="18.643" height="1.934" rx=".967"/></g><g transform="translate(189 41.256)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#FFF7F4" cx="10.286" cy="9.669" rx="9.643" ry="9.669"/><path d="M.023 9.002a8.352 8.352 0 0 0 7.94-4.308M9 .644c0-.21-.008-.416-.023-.62" stroke="#FC6D26" stroke-width="2"/><path d="M5.045 2.008A10.266 10.266 0 0 0 13.5 6.446c2.112 0 4.076-.638 5.71-1.733" stroke="#FC6D26" stroke-width="2"/><ellipse fill="#FC6D26" cx="6.75" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#FC6D26" cx="13.821" cy="11.281" rx=".964" ry=".967"/></g><g transform="translate(46.93 96.05)"><ellipse stroke="#6B4FBB" stroke-width="3" fill="#F4F1FA" cx="9.643" cy="10.314" rx="9.643" ry="9.669"/><path d="M12.86 4.51h-.005L11.25 2.58 9.645 4.51H9.64L8.036 2.58 6.43 4.51h-.002L4.82 2.58 3.215 4.512h-1.75A9.646 9.646 0 0 1 9.642 0c3.447 0 6.47 1.8 8.176 4.508h-1.75l-1.605-1.93L12.86 4.51z" fill="#6B4FBB"/><ellipse fill="#6B4FBB" cx="6.107" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#6B4FBB" cx="13.179" cy="11.281" rx=".964" ry=".967"/></g><g transform="matrix(-1 0 0 1 56.57 54.794)"><use stroke="#E2DCF2" mask="url(#f)" stroke-width="8" fill="#FFF" xlink:href="#c"/><rect fill="#6B4FBB" opacity=".5" x="15.429" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="14.826" width="12.214" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="20.628" width="12.214" height="1.934" rx=".967"/></g></g></svg>
diff --git a/app/views/shared/icons/_notifications.svg b/app/views/shared/icons/_notifications.svg
new file mode 100644
index 00000000000..da55de041da
--- /dev/null
+++ b/app/views/shared/icons/_notifications.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6 14H2.435a2 2 0 0 1-1.761-2.947c.962-1.788 1.521-3.065 1.68-3.832.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024c3.755.528 4.375 4.27 4.761 6.043.188.86.742 2.188 1.661 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0zm5.805-6.468c-.325-1.492-.37-1.674-.61-2.288C10.6 3.716 9.742 3 8.07 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.208 1.012-.827 2.424-1.877 4.375H13.64c-.993-1.937-1.6-3.396-1.835-4.468z"/></svg>
diff --git a/app/views/shared/icons/_overview.svg b/app/views/shared/icons/_overview.svg
new file mode 100644
index 00000000000..4791282df7f
--- /dev/null
+++ b/app/views/shared/icons/_overview.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 2v3h3V2H2zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm9 2v3h3V2h-3zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zM2 11v3h3v-3H2zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm9 2v3h3v-3h-3zm0-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2z"/></svg>
diff --git a/app/views/shared/icons/_pipeline.svg b/app/views/shared/icons/_pipeline.svg
new file mode 100644
index 00000000000..5bedc96a1bd
--- /dev/null
+++ b/app/views/shared/icons/_pipeline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16"><path d="m8 0c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8m0 14c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6"/><circle cx="12.5" cy="9.5" r=".5"/><circle cx="12.5" cy="6.5" r=".5"/><circle cx="10.5" cy="12.5" r=".5"/><circle cx="10.5" cy="3.5" r=".5"/><circle cx="5.5" cy="12.5" r=".5"/><circle cx="5.5" cy="3.5" r=".5"/><circle cx="3.5" cy="9.5" r=".5"/><circle cx="3.5" cy="6.5" r=".5"/><path d="m9 7.2c0 0 0-.1 0-.2v-1.9c0-.1 0-.1-.1-.2l-.8-.8c0 0-.1 0-.1 0l-.9.8c-.1.1-.1.1-.1.2v1.9c0 .1 0 .2 0 .2-.6.4-1 1-1 1.8 0 1.1.9 2 2 2s2-.9 2-2c0-.8-.4-1.4-1-1.8m-1 2.8c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1-.4 1-1 1"/></svg>
diff --git a/app/views/shared/icons/_preferences.svg b/app/views/shared/icons/_preferences.svg
new file mode 100644
index 00000000000..cbd7a4fe9f0
--- /dev/null
+++ b/app/views/shared/icons/_preferences.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6 2h9a1 1 0 0 1 0 2H6a1 1 0 1 1-2 0V2a1 1 0 1 1 2 0zM3 2H1a1 1 0 1 0 0 2h2V2zm10 5h2a1 1 0 0 1 0 2h-2a1 1 0 0 1-2 0V7a1 1 0 0 1 2 0zm-3 0H1a1 1 0 1 0 0 2h9V7zm-5 5h10a1 1 0 0 1 0 2H5a1 1 0 0 1-2 0v-2a1 1 0 0 1 2 0zm-3 0H1a1 1 0 0 0 0 2h1v-2z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_profile.svg b/app/views/shared/icons/_profile.svg
new file mode 100644
index 00000000000..29e360a9051
--- /dev/null
+++ b/app/views/shared/icons/_profile.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12zm-4.274-3.404C4.412 9.709 5.694 9 8 9c2.313 0 3.595.7 4.28 1.586A4.997 4.997 0 0 1 8 13a4.997 4.997 0 0 1-4.274-2.404zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
diff --git a/app/views/shared/icons/_project.svg b/app/views/shared/icons/_project.svg
new file mode 100644
index 00000000000..bbfdd939e7b
--- /dev/null
+++ b/app/views/shared/icons/_project.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.462 2.177l-.038.044a.505.505 0 0 0 .038-.044zm-.787 0a.5.5 0 0 0 .038.043l-.038-.043zM3.706 7h8.725L8.069 2.585 3.706 7zM7 13.369V12a1 1 0 0 1 2 0v1.369h3V9H4v4.369h3zM14 9v4.836c0 .833-.657 1.533-1.5 1.533h-9c-.843 0-1.5-.7-1.5-1.533V9h-.448a1.1 1.1 0 0 1-.783-1.873L6.934.887a1.5 1.5 0 0 1 2.269 0l6.165 6.24A1.1 1.1 0 0 1 14.585 9H14z"/></svg>
diff --git a/app/views/shared/icons/_project.svg.erb b/app/views/shared/icons/_project.svg.erb
deleted file mode 100644
index 2f60bb7245e..00000000000
--- a/app/views/shared/icons/_project.svg.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="<%= size %>" height="<%= size %>" viewBox="0 0 16 16">
- <path d="M6,6 L12,6 L12,5 L6,5 L6,6 Z M6,8 L12,8 L12,7 L6,7 L6,8 Z M6,10 L12,10 L12,9 L6,9 L6,10 Z M6,12 L12,12 L12,11 L6,11 L6,12 Z M4,6 L5,6 L5,5 L4,5 L4,6 Z M4,8 L5,8 L5,7 L4,7 L4,8 Z M4,10 L5,10 L5,9 L4,9 L4,10 Z M4,12 L5,12 L5,11 L4,11 L4,12 Z M13,3 L10,3 L10,4 L6,4 L6,3 L3,3 L3,13 L13,13 L13,3 Z M2,14 L14,14 L14,2 L2,2 L2,14 Z M1,0 C0.448,0 0,0.448 0,1 L0,15 C0,15.552 0.448,16 1,16 L15,16 C15.552,16 16,15.552 16,15 L16,1 C16,0.448 15.552,0 15,0 L1,0 Z" fill="#7F7E7E" fill-rule="evenodd"></path>
-</svg>
diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg
new file mode 100644
index 00000000000..0bb09a705df
--- /dev/null
+++ b/app/views/shared/icons/_rails.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails">
+ <g fill="none" fill-rule="evenodd" transform="translate(0 -6)">
+ <rect width="32" height="32"/>
+ <path fill="#C00" fill-rule="nonzero" d="M0.984615385,25.636044 C0.984615385,25.636044 1.40659341,21.4725275 4.36043956,16.5494505 C7.31428571,11.6263736 12.3498901,7.8989011 16.4430769,7.53318681 C24.5872527,6.71736264 31.9015385,14.0175824 31.9015385,14.0175824 C31.9015385,14.0175824 31.6624176,14.1863736 31.4092308,14.3973626 C23.4197802,8.48967033 18.5389011,11.2747253 17.0057143,12.0202198 C9.97274725,15.9446154 12.0967033,25.636044 12.0967033,25.636044 L0.984615385,25.636044 Z M24.1371429,8.32087912 C23.687033,8.13802198 23.2369231,7.96923077 22.7727473,7.81450549 L22.829011,6.88615385 C23.7151648,7.13934066 24.0668132,7.30813187 24.1934066,7.37846154 L24.1371429,8.32087912 Z M22.8008791,11.3028571 C23.250989,11.330989 23.7151648,11.3872527 24.1934066,11.4857143 L24.1371429,12.3578022 C23.672967,12.2593407 23.2087912,12.2030769 22.7446154,12.189011 L22.8008791,11.3028571 Z M17.5964835,6.91428571 C17.1885714,6.91428571 16.7806593,6.92835165 16.3727473,6.97054945 L16.1054945,6.14065934 C16.5696703,6.0843956 17.0197802,6.05626374 17.4558242,6.05626374 L17.7371429,6.91428571 C17.6949451,6.91428571 17.6386813,6.91428571 17.5964835,6.91428571 Z M18.2716484,12.0905495 C18.6232967,11.9358242 19.0312088,11.7810989 19.5094505,11.6404396 L19.8189011,12.5687912 C19.410989,12.6953846 19.0030769,12.8641758 18.5951648,13.0610989 L18.2716484,12.0905495 Z M11.8857143,8.39120879 C11.52,8.57406593 11.1683516,8.78505495 10.8026374,9.01010989 L10.1556044,8.02549451 C10.5353846,7.80043956 10.9010989,7.60351648 11.2527473,7.42065934 L11.8857143,8.39120879 Z M14.7692308,14.7208791 C15.0224176,14.3973626 15.3178022,14.0738462 15.6413187,13.7784615 L16.2742857,14.7349451 C15.9648352,15.0584615 15.6835165,15.381978 15.4443956,15.7336264 L14.7692308,14.7208791 Z M12.7296703,19.2501099 C12.8421978,18.7437363 12.9687912,18.2232967 13.1516484,17.7028571 L14.1643956,18.5046154 C14.0237363,19.0531868 13.9252747,19.6017582 13.869011,20.1503297 L12.7296703,19.2501099 Z M6.56879121,12.5687912 C6.23120879,12.9204396 5.90769231,13.3002198 5.61230769,13.68 L4.52923077,12.7516484 C4.85274725,12.4 5.2043956,12.0483516 5.57010989,11.6967033 L6.56879121,12.5687912 Z M2.32087912,18.8562637 C2.09582418,19.3767033 1.80043956,20.0659341 1.61758242,20.5441758 L0,19.9534066 C0.140659341,19.5736264 0.436043956,18.8703297 0.703296703,18.2654945 L2.32087912,18.8562637 Z M12.5186813,22.8228571 L14.0378022,23.3714286 C14.1221978,24.0325275 14.2487912,24.6514286 14.3753846,25.2 L12.6874725,24.5951648 C12.6171429,24.1731868 12.5468132,23.5683516 12.5186813,22.8228571 Z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg
index acf22ac9314..1d22870ec09 100644
--- a/app/views/shared/icons/_scroll_down.svg
+++ b/app/views/shared/icons/_scroll_down.svg
@@ -1,3 +1,5 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-down" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M1.385 5.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15V5.535a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/>
+<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg">
+ <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/>
+ <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/>
+ <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/>
</svg>
diff --git a/app/views/shared/icons/_scroll_down_hover_active.svg b/app/views/shared/icons/_scroll_down_hover_active.svg
deleted file mode 100644
index 262576acf54..00000000000
--- a/app/views/shared/icons/_scroll_down_hover_active.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-down-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/>
-</svg>
diff --git a/app/views/shared/icons/_scroll_up.svg b/app/views/shared/icons/_scroll_up.svg
index f11288fd59c..70b1e4d9c91 100644
--- a/app/views/shared/icons/_scroll_up.svg
+++ b/app/views/shared/icons/_scroll_up.svg
@@ -1,3 +1 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-up" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M1.385 14.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15v-12.47a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 2.609V6.96a.688.688 0 0 1-.69.688.687.687 0 0 1-.69-.688V2.627L6.155 3.972a.69.69 0 0 1-.976-.976L7.705.47a.685.685 0 0 1 .494-.2.685.685 0 0 1 .493.2l2.526 2.526a.69.69 0 1 1-.976.976L8.88 2.609zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/>
-</svg>
+<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"><path d="M1.048 1.845a.508.508 0 0 1-.32-.105c-.091-.07-.136-.154-.136-.25V.78c0-.095.045-.178.135-.249a.508.508 0 0 1 .321-.105h10.043c.124 0 .23.035.321.105.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105"/><path d="M.687 7.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 3.09A.458.458 0 0 0 6.257 3h-.37a.626.626 0 0 0-.136.09"/><path d="M.687 14.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 10.09A.458.458 0 0 0 6.257 10h-.37a.626.626 0 0 0-.136.09"/></svg>
diff --git a/app/views/shared/icons/_scroll_up_hover_active.svg b/app/views/shared/icons/_scroll_up_hover_active.svg
deleted file mode 100644
index 4658dbb1bb7..00000000000
--- a/app/views/shared/icons/_scroll_up_hover_active.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-up-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M8.88 2.646l1.362 1.362a.69.69 0 0 0 .976-.976L8.692.507A.685.685 0 0 0 8.2.306a.685.685 0 0 0-.494.2L5.179 3.033a.69.69 0 1 0 .976.976L7.5 2.663v4.179c0 .38.306.688.69.688.381 0 .69-.306.69-.688V2.646zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/>
-</svg>
diff --git a/app/views/shared/icons/_service_templates.svg b/app/views/shared/icons/_service_templates.svg
new file mode 100644
index 00000000000..b65cd8300b2
--- /dev/null
+++ b/app/views/shared/icons/_service_templates.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm.8 2h2.4a.8.8 0 0 1 .8.8v1.4a.8.8 0 0 1-.8.8H3.8a.8.8 0 0 1-.8-.8V4.8a.8.8 0 0 1 .8-.8zm4.7 0h4a.5.5 0 1 1 0 1h-4a.5.5 0 0 1 0-1zm0 2h4a.5.5 0 1 1 0 1h-4a.5.5 0 0 1 0-1zm-5 3h9a.5.5 0 1 1 0 1h-9a.5.5 0 0 1 0-1zm0 2h9a.5.5 0 1 1 0 1h-9a.5.5 0 1 1 0-1z"/></svg>
diff --git a/app/views/shared/icons/_settings.svg b/app/views/shared/icons/_settings.svg
new file mode 100644
index 00000000000..96c5ef8c04d
--- /dev/null
+++ b/app/views/shared/icons/_settings.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2.415 5.803L1.317 4.084A.5.5 0 0 1 1.35 3.5l.805-.994a.5.5 0 0 1 .564-.153l1.878.704a5.975 5.975 0 0 1 1.65-.797L6.885.342A.5.5 0 0 1 7.36 0h1.28a.5.5 0 0 1 .474.342l.639 1.918c.594.181 1.15.452 1.65.797l1.877-.704a.5.5 0 0 1 .565.153l.805.994a.5.5 0 0 1 .032.584l-1.097 1.719c.217.551.354 1.143.399 1.76l1.731 1.058a.5.5 0 0 1 .227.54l-.288 1.246a.5.5 0 0 1-.44.385l-2.008.19a6.026 6.026 0 0 1-1.142 1.431l.265 1.995a.5.5 0 0 1-.277.516l-1.15.56a.5.5 0 0 1-.576-.1l-1.424-1.452a6.047 6.047 0 0 1-1.804 0l-1.425 1.453a.5.5 0 0 1-.576.1l-1.15-.561a.5.5 0 0 1-.276-.516l.265-1.995a6.026 6.026 0 0 1-1.143-1.43l-2.008-.191a.5.5 0 0 1-.44-.385L.058 9.16a.5.5 0 0 1 .226-.539l1.732-1.058a5.968 5.968 0 0 1 .399-1.76zM8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/></svg>
diff --git a/app/views/shared/icons/_snippets.svg b/app/views/shared/icons/_snippets.svg
new file mode 100644
index 00000000000..1e1340187b4
--- /dev/null
+++ b/app/views/shared/icons/_snippets.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.67 9.31a3.001 3.001 0 0 1 2.062 5.546 3 3 0 0 1-3.771-4.559 1.007 1.007 0 0 1-.095-.137l-4.5-7.794a1 1 0 1 1 1.732-1l4.5 7.794c.028.05.052.1.071.15zm-3.283.35l-.289.5c-.028.05-.06.095-.095.137a3.001 3.001 0 0 1-3.77 4.56A3 3 0 0 1 5.294 9.31c.02-.051.043-.102.071-.15l.866-1.5 1.155 2zm2.31-4l-1.156-2 1.325-2.294a1 1 0 1 1 1.732 1L9.696 5.66zm-5.465 7.464a1 1 0 1 0 1-1.732 1 1 0 0 0-1 1.732zm7.5 0a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732z"/></svg>
diff --git a/app/views/shared/icons/_spam_logs.svg b/app/views/shared/icons/_spam_logs.svg
new file mode 100644
index 00000000000..80ee0eb3856
--- /dev/null
+++ b/app/views/shared/icons/_spam_logs.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.75.433l5.428 3.134a1.5 1.5 0 0 1 .75 1.299v6.268a1.5 1.5 0 0 1-.75 1.299L8.75 15.567a1.5 1.5 0 0 1-1.5 0l-5.428-3.134a1.5 1.5 0 0 1-.75-1.299V4.866a1.5 1.5 0 0 1 .75-1.299L7.25.433a1.5 1.5 0 0 1 1.5 0zM3.072 5.155v5.69L8 13.691l4.928-2.846v-5.69L8 2.309 3.072 5.155zM8 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg
new file mode 100644
index 00000000000..508349aa456
--- /dev/null
+++ b/app/views/shared/icons/_spring.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring">
+ <g fill="none" fill-rule="evenodd">
+ <rect width="32" height="32"/>
+ <path fill="#70AD51" d="M5.46647617,27.9932117 C6.0517027,28.4658996 6.91159892,28.3777063 7.38425926,27.7914452 C7.85922261,27.2048452 7.76991326,26.3449044 7.18398981,25.8699411 C6.59874295,25.3956543 5.74015536,25.4869934 5.26383884,26.0722403 C4.81393367,26.6267596 4.87238621,27.4284565 5.37913494,27.9159868 L5.11431334,27.6818383 C1.97157151,24.7616933 0,20.5966301 0,15.9782542 C0,7.16842834 7.16775175,0 15.9796074,0 C20.4586065,0 24.5113565,1.8565519 27.4145869,4.8362365 C28.0749348,3.93840692 28.6466499,2.93435335 29.115524,1.82069284 C31.1513712,7.93770658 32.3482517,13.0811131 31.909824,17.1311567 C31.3178113,25.4044499 24.4017495,31.9585382 15.9796074,31.9585382 C12.0682639,31.9585382 8.48438805,30.5444735 5.7042963,28.2034861 L5.46647617,27.9932117 Z M29.0471888,23.0106888 C33.0546075,17.6737787 30.8211972,9.04527781 28.9612624,3.529749 C27.3029502,6.98304378 23.2217836,9.62375882 19.6981239,10.4613722 C16.3950312,11.2482417 13.4715032,10.6021021 10.4153644,11.7780085 C3.44517575,14.457289 3.55613585,22.7698242 7.39373146,24.6365249 C7.39711439,24.6392312 7.62444728,24.7616933 7.62174094,24.7576338 C7.62309411,24.7562806 13.2658211,23.6358542 16.3862356,22.4843049 C20.9450718,20.7996058 25.9524846,16.6494275 27.5986182,11.8273993 C26.723116,16.8415779 22.4179995,21.6669891 18.093262,23.8828081 C15.7908399,25.0648038 14.0005934,25.3279957 10.2123886,26.6385428 C9.74892722,26.798217 9.38492397,26.9538318 9.38492397,26.9538318 C10.3463526,26.7948341 11.301692,26.7420604 11.301692,26.7420604 C16.6954354,26.4869875 25.1087819,28.2582896 29.0471888,23.0106888 Z"/>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_system_hooks.svg b/app/views/shared/icons/_system_hooks.svg
new file mode 100644
index 00000000000..7b95a6f29f3
--- /dev/null
+++ b/app/views/shared/icons/_system_hooks.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M10 3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1h4zm0 1H6v1a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V4zM7 8a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3v4a2 2 0 1 0 4 0h-.44a.3.3 0 0 1-.25-.466l1.44-2.16a.3.3 0 0 1 .5 0l1.44 2.16a.3.3 0 0 1-.25.466H15a4 4 0 0 1-7 2.646A4 4 0 0 1 1 12H.56a.3.3 0 0 1-.25-.466l1.44-2.16a.3.3 0 0 1 .5 0l1.44 2.16a.3.3 0 0 1-.25.466H3a2 2 0 1 0 4 0V8z"/></svg>
diff --git a/app/views/shared/icons/_wiki.svg b/app/views/shared/icons/_wiki.svg
new file mode 100644
index 00000000000..b5ad38d9863
--- /dev/null
+++ b/app/views/shared/icons/_wiki.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1v4.191a.5.5 0 0 1-.724.447l-1.052-.526a.5.5 0 0 0-.448 0l-1.052.526A.5.5 0 0 1 8 6.191V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></svg>
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
new file mode 100644
index 00000000000..217af7c9fac
--- /dev/null
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -0,0 +1,14 @@
+- max_render = 3
+- max = [max_render, issue.assignees.length].min
+
+- issue.assignees.take(max).each do |assignee|
+ = link_to_member(@project, assignee, name: false, title: "Assigned to :name")
+
+- if issue.assignees.length > max_render
+ - counter = issue.assignees.length - max_render
+
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } }
+ - if counter < 99
+ = "+#{counter}"
+ - else
+ 99+
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
new file mode 100644
index 00000000000..0d507cc7a6e
--- /dev/null
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -0,0 +1,53 @@
+- type = local_assigns.fetch(:type)
+
+%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
+ .issuable-sidebar.hidden
+ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
+ .block.issuable-sidebar-header
+ .filter-item.inline.update-issues-btn.pull-left
+ = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true
+ = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide pull-right"
+ .block
+ .title
+ Status
+ .filter-item
+ = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "reopen" } } Open
+ %li
+ %a{ href: "#", data: { id: "close" } } Closed
+ .block
+ .title
+ Assignee
+ .filter-item
+ - if type == :issues
+ - field_name = "update[assignee_ids][]"
+ - else
+ - field_name = "update[assignee_id]"
+ = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
+ placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
+ .block
+ .title
+ Milestone
+ .filter-item
+ = dropdown_tag("Select milestone", options: { title: "Assign milestone", toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), use_id: true, default_label: "Milestone" } })
+ .block
+ .title
+ Labels
+ .filter-item.labels-filter
+ = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true
+ .block
+ .title
+ Subscriptions
+ .filter-item
+ = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "subscribe" } } Subscribe
+ %li
+ %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
+
+ = hidden_field_tag "update[issuable_ids]", []
+ = hidden_field_tag :state_event, params[:state_event]
+
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
new file mode 100644
index 00000000000..8a1268a1c6d
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -0,0 +1,14 @@
+- is_current_user = issuable_author_is_current_user(issuable)
+- display_issuable_type = issuable_display_type(issuable)
+- button_method = issuable_close_reopen_button_method(issuable)
+
+- if can_update && is_current_user
+ = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
+ class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
+ = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method,
+ class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
+- elsif can_update && !is_current_user
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
+- else
+ = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ class: 'hidden-xs hidden-sm btn btn-grouped btn-close-color', title: 'Report abuse'
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
new file mode 100644
index 00000000000..6756a7f17fd
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -0,0 +1,49 @@
+- display_issuable_type = issuable_display_type(issuable)
+- button_action = issuable.closed? ? 'reopen' : 'close'
+- display_button_action = button_action.capitalize
+- button_responsive_class = 'hidden-xs hidden-sm'
+- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button"
+- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
+- button_method = issuable_close_reopen_button_method(issuable)
+
+.pull-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
+ = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_url(issuable),
+ method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}"
+
+ = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
+ data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do
+ = icon('caret-down', class: 'toggle-icon icon')
+
+ %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ class: button_responsive_class, data: { dropdown: true } }
+ %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
+ data: { text: "Close #{display_issuable_type}", url: close_issuable_url(issuable),
+ button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title
+ Close
+ = display_issuable_type
+
+ %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
+ data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_url(issuable),
+ button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title
+ Reopen
+ = display_issuable_type
+
+ %li.divider.droplab-item-ignore
+
+ %li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong.title Report abuse
+ %p.text
+ Report
+ = display_issuable_type.pluralize
+ that are abusive, inappropriate or spam.
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 1a12f110945..c4ed7f6e750 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -6,10 +6,6 @@
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
.issues-other-filters
.filter-item.inline
- if params[:author_id].present?
@@ -36,47 +32,7 @@
.pull-right
= render 'shared/sort_dropdown'
- - if @bulk_edit
- .issues_bulk_update.hide
- = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
- .filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "reopen" } } Open
- %li
- %a{ href: "#", data: {id: "close" } } Closed
- .filter-item.inline
- = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
- .filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
- .filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
- .filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
- has_labels = @labels && @labels.any?
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
- if has_labels
= render 'shared/labels_row', labels: @labels
-
-:javascript
- new UsersSelect();
- new LabelsSelect();
- new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
- $('form.filter-form').on('submit', function (event) {
- event.preventDefault();
- gl.utils.visitUrl(this.action + '&' + $(this).serialize());
- });
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 17107f55a2d..c016aa2abcd 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,7 +17,7 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
-= render 'shared/issuable/form/description', issuable: issuable, form: form
+= render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index 93c7fa0c7d6..34911fd2712 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -9,8 +9,10 @@
- selected = local_assigns.fetch(:selected, nil)
- selected_toggle = local_assigns.fetch(:selected_toggle, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"}
+- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"}
- dropdown_data.merge!(data_options)
+- label_name = local_assigns.fetch(:label_name, "Labels")
+- no_default_styles = local_assigns.fetch(:no_default_styles, false)
- classes << 'js-extra-options' if extra_options
- classes << 'js-filter-submit' if filter_submit
@@ -20,8 +22,9 @@
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data }
- %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) }
- = multi_label_name(selected, "Labels")
+ - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
+ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
+ = multi_label_name(selected, label_name)
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index bd66f39fa59..0a692d9653f 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -1,5 +1,5 @@
.dropdown-page-two.dropdown-new-label
- = dropdown_title("Create new label", back: true)
+ = dropdown_title("Create new label", options: { back: true })
= dropdown_content do
.dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" }
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index 9a8529c6cbb..e8feff32d26 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -20,7 +20,7 @@
%a.dropdown-toggle-page{ href: "#" }
Create new label
%li
- = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
+ = link_to project_labels_path(@project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
Manage labels
- else
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index f0d50828e2a..955b8866c2c 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -6,15 +6,15 @@
- if selected.present? || params[:milestone_title].present?
= hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone",
- placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
+ placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
%li
- = link_to new_namespace_project_milestone_path(project.namespace, project), title: "New Milestone" do
+ = link_to new_project_milestone_path(project), title: "New Milestone" do
Create new
%li
- = link_to namespace_project_milestones_path(project.namespace, project) do
+ = link_to project_milestones_path(project) do
- if can? current_user, :admin_milestone, project
Manage milestones
- else
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index ad995cbe962..3f03cc7a275 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -4,22 +4,22 @@
%ul.nav-links.issues-state-filters
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened." do
+ = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.' do
+ = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
#{issuables_state_counter_text(type, :merged)}
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.' do
+ = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-all', title: 'Filter by issues that are currently closed.' do
+ = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed)}
%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}." do
+ = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
#{issuables_state_counter_text(type, :all)}
diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml
index 171da899937..8a71819aa8e 100644
--- a/app/views/shared/issuable/_participants.html.haml
+++ b/app/views/shared/issuable/_participants.html.haml
@@ -12,9 +12,7 @@
- participants.each do |participant|
.participants-author.js-participants-author
= link_to_member(@project, participant, name: false, size: 24)
- - if participants_extra > 0
- .participants-more
- %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
- + #{participants_extra} more
-:javascript
- IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row};
+ - if participants_extra > 0
+ .hide-collapsed.participants-more
+ %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
+ + #{participants_extra} more
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index f1350169bbe..1ad00461d76 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,33 +1,31 @@
- type = local_assigns.fetch(:type)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
+- full_path = @project.present? ? @project.full_path : @group.full_path
.issues-filters
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
+ - if @can_bulk_update
+ .check-all-holder.hidden
+ = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
.issues-other-filters.filtered-search-wrapper
.filtered-search-box
- if type != :boards_modal && type != :boards
- = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'),
+ = dropdown_tag(custom_icon('icon_history'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
- .js-filtered-search-history-dropdown
- .filtered-search-box-input-container
+ .js-filtered-search-history-dropdown{ data: { full_path: full_path } }
+ .filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
- %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) } }
+ %input.form-control.filtered-search{ search_filter_input_options(type) }
= icon('filter')
- %button.clear-search.hidden{ type: 'button' }
- = icon('times')
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
@@ -45,32 +43,29 @@
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
- #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
+ #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
+ - if current_user
+ %ul{ data: { dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
- %span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
No Assignee
%li.divider
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
- %span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
@@ -86,7 +81,7 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
{{title}}
- #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
+ #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
@@ -98,11 +93,13 @@
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
+ %button.clear-search.hidden{ type: 'button' }
+ = icon('times')
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, @project)
.dropdown.prepend-left-10#js-add-list
- %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } }
+ %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) } }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
@@ -112,51 +109,3 @@
#js-add-issues-btn.prepend-left-10
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
-
- - if @bulk_edit
- .issues_bulk_update.hide
- = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
- .filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "reopen" } } Open
- %li
- %a{ href: "#", data: { id: "close" } } Closed
- .filter-item.inline
- = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } })
- .filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
- .filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
- .filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline.update-issues-btn
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
-
-- unless type === :boards_modal
- :javascript
- new UsersSelect();
- new LabelsSelect();
- new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
-
- $(document).off('page:restore').on('page:restore', function (event) {
- if (gl.FilteredSearchManager) {
- new gl.FilteredSearchManager();
- }
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: 'issue_',
- });
- });
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 2e0d6a129fb..c3f25c9d255 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,10 +1,10 @@
- todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('issuable')
+ = page_specific_javascript_bundle_tag('sidebar')
-%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
- .issuable-sidebar
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { "offset-top" => ("50" unless show_new_nav?), "spy" => ("affix" unless show_new_nav?), signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+ .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
- if current_user
@@ -20,36 +20,7 @@
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
.block.assignee
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
- .title.hide-collapsed
- Assignee
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
- .value.hide-collapsed
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
- - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee)
- %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- = issuable.assignee.to_reference
- - else
- %span.assign-yourself.no-value
- No assignee
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- assign yourself
-
- .selectbox.hide-collapsed
- = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id'
- = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } })
-
+ = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
.block.milestone
.sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true')
@@ -66,20 +37,19 @@
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.hide-collapsed
- if issuable.milestone
- = link_to issuable.milestone.title, namespace_project_milestone_path(@project.namespace, @project, issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
+ = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
- else
%span.no-value None
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
- = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
+ = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
- %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') }
- // Fallback while content is loading
- .title.hide-collapsed
- Time tracking
- = icon('spinner spin', 'aria-hidden': 'true')
+ // Fallback while content is loading
+ .title.hide-collapsed
+ Time tracking
+ = icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
@@ -136,7 +106,7 @@
- selected_labels.each do |label|
= hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@project, :json) if @project) } }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
@@ -145,6 +115,10 @@
- if can? current_user, :admin_label, @project and @project
= render partial: "shared/issuable/label_page_create"
+ - if issuable.has_attribute?(:confidential)
+ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe
+ #js-confidential-entry-point
+
= render "shared/issuable/participants", participants: issuable.participants(current_user)
- if current_user
- subscribed = issuable.subscribed?(current_user, @project)
@@ -168,12 +142,4 @@
= project_ref
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
- :javascript
- gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
- new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
- new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
- new LabelsSelect();
- new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
- gl.Subscription.bindAll('.subscription');
- new gl.DueDateSelectors();
- window.sidebar = new Sidebar();
+ %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
new file mode 100644
index 00000000000..57392cd7fbb
--- /dev/null
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -0,0 +1,56 @@
+- if issuable.is_a?(Issue)
+ #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } }
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin')
+- else
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
+ - if issuable.assignee
+ = link_to_member(@project, issuable.assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ - if !signed_in
+ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
+ = sidebar_gutter_toggle_icon
+ .value.hide-collapsed
+ - if issuable.assignee
+ = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
+ - if !issuable.can_be_merged_by?(issuable.assignee)
+ %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
+ = icon('exclamation-triangle', 'aria-hidden': 'true')
+ %span.username
+ = issuable.assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
+
+.selectbox.hide-collapsed
+ - issuable.assignees.each do |assignee|
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
+
+ - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }
+ - title = 'Select assignee'
+
+ - if issuable.is_a?(Issue)
+ - unless issuable.assignees.any?
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
+ - dropdown_options = issue_assignees_dropdown_options
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
+
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml
new file mode 100644
index 00000000000..c18e4975bb8
--- /dev/null
+++ b/app/views/shared/issuable/_user_dropdown_item.html.haml
@@ -0,0 +1,12 @@
+- user = local_assigns.fetch(:user)
+- avatar = local_assigns.fetch(:avatar, { })
+
+%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) }
+ %button.btn.btn-link.dropdown-user{ type: :button }
+ .avatar-container.s40
+ = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 40).gsub('/images/{{avatar_url}}','{{avatar_url}}').html_safe
+ .dropdown-user-details
+ %span
+ = user.name
+ %span.dropdown-light-content
+ = user.to_reference
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 2793e7bcff4..203d2adc8db 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -10,12 +10,16 @@
= form.label :source_branch, class: 'control-label'
.col-sm-10
.issuable-form-select-holder
- = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true })
+ = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true })
.form-group
= form.label :target_branch, class: 'control-label'
- .col-sm-10
+ .col-sm-10.target-branch-select-dropdown-container
.issuable-form-select-holder
- = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }})
+ = form.select(:target_branch, issuable.target_branches,
+ { include_blank: true },
+ { class: 'target_branch js-target-branch-select ref-name',
+ disabled: issuable.new_record?,
+ data: { placeholder: "Select branch" }})
- if issuable.new_record?
&nbsp;
= link_to 'Change branches', mr_change_branches_path(issuable)
diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml
deleted file mode 100644
index dbace9ce401..00000000000
--- a/app/views/shared/issuable/form/_description.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- issuable = local_assigns.fetch(:issuable)
-- form = local_assigns.fetch(:form)
-
-.form-group.detail-page-description
- = form.label :description, 'Description', class: 'control-label'
- .col-sm-10
-
- = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
- = render 'projects/zen', f: form, attr: :description,
- classes: 'note-textarea',
- placeholder: "Write a comment or drag your files here...",
- supports_slash_commands: !issuable.persisted?
- = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
- .clearfix
- .error-alert
diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml
new file mode 100644
index 00000000000..66091d95a91
--- /dev/null
+++ b/app/views/shared/issuable/form/_issue_assignee.html.haml
@@ -0,0 +1,31 @@
+- issue = issuable
+- assignees = issue.assignees
+.block.assignee
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) }
+ - if assignees.any?
+ - assignees.each do |assignee|
+ = link_to_member(@project, assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ .value.hide-collapsed
+ - if assignees.any?
+ - assignees.each do |assignee|
+ = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do
+ %span.username
+ = assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids'
+ = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } })
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 03309722326..8f6509a8ce8 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -3,7 +3,8 @@
- return unless issuable.is_a?(MergeRequest)
- return if issuable.closed_without_fork?
--# This check is duplicated below, to avoid conflicts with EE.
+-# This check is duplicated below to avoid CE -> EE merge conflicts.
+-# This comment and the following line should only exist in CE.
- return unless issuable.can_remove_source_branch?(current_user)
.form-group
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
new file mode 100644
index 00000000000..18011d528a0
--- /dev/null
+++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
@@ -0,0 +1,31 @@
+- merge_request = issuable
+.block.assignee
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) }
+ - if merge_request.assignee
+ = link_to_member(@project, merge_request.assignee, size: 24)
+ - else
+ = icon('user', 'aria-hidden': 'true')
+ .title.hide-collapsed
+ Assignee
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to 'Edit', '#', class: 'edit-link pull-right'
+ .value.hide-collapsed
+ - if merge_request.assignee
+ = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
+ - unless merge_request.can_be_merged_by?(merge_request.assignee)
+ %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
+ = icon('exclamation-triangle', 'aria-hidden': 'true')
+ %span.username
+ = merge_request.assignee.to_reference
+ - else
+ %span.assign-yourself.no-value
+ No assignee
+ - if can_edit_issuable
+ \-
+ %a.js-assign-yourself{ href: '#' }
+ assign yourself
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id'
+ = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } })
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 9dbfedb84f1..1608bd59cf1 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -10,13 +10,10 @@
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
.form-group.issue-assignee
- = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
- .col-sm-10{ class: ("col-lg-8" if has_due_date) }
- .issuable-form-select-holder
- = form.hidden_field :assignee_id
- = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
- placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
+ - if issuable.is_a?(Issue)
+ = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date
+ - else
+ = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date
.form-group.issue-milestone
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
new file mode 100644
index 00000000000..567cde764e2
--- /dev/null
+++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
@@ -0,0 +1,11 @@
+= form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+.col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder.selectbox
+ - issuable.assignees.each do |assignee|
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name, avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
+
+ - if issuable.assignees.length === 0
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
+
+ = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_assignees_dropdown_options)
+ = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
new file mode 100644
index 00000000000..d0ea4e149df
--- /dev/null
+++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
@@ -0,0 +1,8 @@
+= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
+.col-sm-10{ class: ("col-lg-8" if has_due_date) }
+ .issuable-form-select-holder
+ = form.hidden_field :assignee_id
+
+ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
+ placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
+ = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml
index fb795ad1c72..40224cec9e8 100644
--- a/app/views/shared/members/_access_request_buttons.html.haml
+++ b/app/views/shared/members/_access_request_buttons.html.haml
@@ -1,17 +1,20 @@
- model_name = source.model_name.to_s.downcase
-.project-action-button.inline
- - if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
- = link_to "Leave #{model_name}", polymorphic_path([:leave, source, :members]),
+- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
+ .project-action-button.inline
+ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
+ = link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
class: 'btn'
- - elsif requester = source.requesters.find_by(user_id: current_user.id)
- = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]),
+- elsif requester = source.requesters.find_by(user_id: current_user.id)
+ .project-action-button.inline
+ = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: remove_member_message(requester) },
class: 'btn'
- - elsif source.request_access_enabled && can?(current_user, :request_access, source)
- = link_to 'Request Access', polymorphic_path([:request_access, source, :members]),
+- elsif source.request_access_enabled && can?(current_user, :request_access, source)
+ .project-action-button.inline
+ = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post,
class: 'btn'
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 1d5a61cffce..bcdad3c153a 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -14,7 +14,7 @@
%span{ class: ('text-warning' if group_link.expires_soon?) }
Expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
.controls.member-controls
- = form_tag namespace_project_group_link_path(@project.namespace, @project, group_link), method: :put, remote: true, class: 'form-horizontal js-edit-member-form' do
+ = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'form-horizontal js-edit-member-form' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
.member-form-control.dropdown.append-right-5
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
@@ -36,7 +36,7 @@
= text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: 'Expiration date', id: "member_expires_at_#{group.id}", disabled: !can_admin_member
%i.clear-icon.js-clear-input
- if can_admin_member
- = link_to namespace_project_group_link_path(@project.namespace, @project, group_link),
+ = link_to project_group_link_path(@project, group_link),
method: :delete,
data: { confirm: "Are you sure you want to remove #{group.name}?" },
class: 'btn btn-remove prepend-left-10' do
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index a5aa768b1b2..951b4dd7b36 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -1,5 +1,6 @@
- show_roles = local_assigns.fetch(:show_roles, true)
- show_controls = local_assigns.fetch(:show_controls, true)
+- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
- user = local_assigns.fetch(:user, member.user)
- source = member.source
- can_admin_member = can?(current_user, action_member_permission(:update, member), member)
@@ -8,45 +9,53 @@
%span.list-item-name
- if user
= image_tag avatar_icon(user, 40), class: "avatar s40", alt: ''
- %strong
- = link_to user.name, user_path(user)
- %span.cgray= user.to_reference
+ .user-info
+ = link_to user.name, user_path(user), class: 'member'
+ %span.cgray= user.to_reference
- - if user == current_user
- %span.label.label-success.prepend-left-5 It's you
+ - if user == current_user
+ %span.label.label-success.prepend-left-5 It's you
- - if user.blocked?
- %label.label.label-danger
- %strong Blocked
+ - if user.blocked?
+ %label.label.label-danger
+ %strong Blocked
- - if source.instance_of?(Group) && source != @group
- &middot;
- = link_to source.full_name, source, class: "member-group-link"
+ - if source.instance_of?(Group) && source != @group
+ &middot;
+ = link_to source.full_name, source, class: "member-group-link"
- .hidden-xs.cgray
- - if member.request?
- Requested
- = time_ago_with_tooltip(member.requested_at)
- - else
- Joined #{time_ago_with_tooltip(member.created_at)}
- - if member.expires?
- ·
- %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
- Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
+ .cgray
+ - if member.request?
+ Requested
+ = time_ago_with_tooltip(member.requested_at)
+ - else
+ Joined #{time_ago_with_tooltip(member.created_at)}
+ - if member.expires?
+ ·
+ %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
+ Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
- else
= image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: ''
- %strong= member.invite_email
- .cgray
- Invited
- - if member.created_by
- by
- = link_to member.created_by.name, user_path(member.created_by)
- = time_ago_with_tooltip(member.created_at)
+ .user-info
+ .member= member.invite_email
+ .cgray
+ Invited
+ - if member.created_by
+ by
+ = link_to member.created_by.name, user_path(member.created_by)
+ = time_ago_with_tooltip(member.created_at)
- if show_roles
- current_resource = @project || @group
.controls.member-controls
- if show_controls && member.source == current_resource
+
+ - if member.invite? && can?(current_user, action_member_permission(:admin, member), member.source)
+ = link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]),
+ method: :post,
+ class: 'btn btn-default prepend-left-10 hidden-xs',
+ title: 'Resend invite'
+
- if user != current_user && can_admin_member
= form_for member, remote: true, html: { class: 'form-horizontal js-edit-member-form' } do |f|
= f.hidden_field :access_level
@@ -75,13 +84,17 @@
- if member.invite? && can?(current_user, action_member_permission(:admin, member), member.source)
= link_to 'Resend invite', polymorphic_path([:resend_invite, member]),
method: :post,
- class: 'btn btn-default prepend-left-10'
+ class: 'btn btn-default prepend-left-10 visible-xs-block'
- elsif member.request? && can_admin_member
- = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]),
+ = link_to polymorphic_path([:approve_access_request, member]),
method: :post,
class: 'btn btn-success prepend-left-10',
- title: 'Grant access'
+ title: 'Grant access' do
+ %span{ class: ('visible-xs-block' unless force_mobile_view) }
+ Grant access
+ - unless force_mobile_view
+ = icon('check inverse', class: 'hidden-xs')
- if can?(current_user, action_member_permission(:destroy, member), member)
- if current_user == user
@@ -96,8 +109,9 @@
data: { confirm: remove_member_message(member) },
class: 'btn btn-remove prepend-left-10',
title: remove_member_title(member) do
- %span.visible-xs-block
+ %span{ class: ('visible-xs-block' unless force_mobile_view) }
Delete
- = icon('trash', class: 'hidden-xs')
+ - unless force_mobile_view
+ = icon('trash', class: 'hidden-xs')
- else
%span.member-access-text= member.human_access
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 10050adfda5..09b9944082f 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,8 +1,10 @@
+- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
+
- if requesters.any?
- .panel.panel-default
+ .panel.panel-default.prepend-top-default{ class: ('panel-mobile' if force_mobile_view ) }
.panel-heading
Users requesting access to
%strong= membership_source.name
%span.badge= requesters.size
- %ul.content-list
- = render partial: 'shared/members/member', collection: requesters, as: :member
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member', collection: requesters, as: :member, locals: { force_mobile_view: force_mobile_view }
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 5247d6a51e6..3739f4c221d 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -1,14 +1,13 @@
-# @project is present when viewing Project's milestone
- project = @project || issuable.project
- namespace = @project_namespace || project.namespace.becomes(Namespace)
-- assignee = issuable.assignee
-- issuable_type = issuable.class.table_name
+- labels = issuable.labels
+- assignees = issuable.assignees
- base_url_args = [namespace, project]
-- issuable_type_args = base_url_args + [issuable_type]
+- issuable_type_args = base_url_args + [issuable.class.table_name]
- issuable_url_args = base_url_args + [issuable]
-- can_update = can?(current_user, :"update_#{issuable.to_ability_name}", issuable)
-%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable_url_args) }
+%li.issuable-row
%span
- if show_project_name
%strong #{project.name} &middot;
@@ -16,17 +15,17 @@
%strong #{project.name_with_namespace} &middot;
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
- = link_to_gfm issuable.title, issuable_url_args, title: issuable.title
+ = link_to issuable.title, issuable_url_args, title: issuable.title
.issuable-detail
- = link_to [project.namespace.becomes(Namespace), project, issuable] do
+ = link_to [namespace, project, issuable] do
%span.issuable-number= issuable.to_reference
- - issuable.labels.each do |label|
+ - labels.each do |label|
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
- render_colored_label(label)
%span.assignee-icon
- - if assignee
- = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }),
+ - assignees.each do |assignee|
+ = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do
- - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '')
+ - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '')
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index 8af3bd597c5..7175e275f95 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -8,11 +8,11 @@
= title
- if show_counter
.counter
- = number_with_delimiter(issuables.size)
+ = number_with_delimiter(issuables.length)
- class_prefix = dom_class(issuables).pluralize
- %ul{ class: "well-list #{class_prefix}-sortable-list", id: "#{class_prefix}-list-#{id}", "data-state" => id }
+ %ul{ class: "well-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" }
= render partial: 'shared/milestones/issuable',
- collection: issuables.order_position_asc,
+ collection: issuables,
as: :issuable,
locals: { show_project_name: show_project_name, show_full_project_name: show_full_project_name }
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 33f93dccd3c..a26b3b8009e 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -2,7 +2,7 @@
- labels.each do |label|
- options = { milestone_title: @milestone.title, label_name: label.title }
- %li
+ %li.is-not-draggable
%span.label-row
%span.label-name
= link_to milestones_label_path(options) do
@@ -10,10 +10,8 @@
%span.prepend-description-left
= markdown_field(label, :description)
- .pull-info-right
- %span.append-right-20
- = link_to milestones_label_path(options.merge(state: 'opened')) do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
- %span.append-right-20
- = link_to milestones_label_path(options.merge(state: 'closed')) do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
+ .pull-right.hidden-xs.hidden-sm.hidden-md
+ = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
+ = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 9e6a76e1ddb..6a85f7d0564 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -1,10 +1,15 @@
- dashboard = local_assigns[:dashboard]
-- custom_dom_id = dom_id(@project ? milestone : milestone.milestones.first)
+- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
.col-sm-6
- %strong= link_to_gfm truncate(milestone.title, length: 100), milestone_path
+ %strong= link_to truncate(milestone.title, length: 100), milestone_path
+ - if milestone.is_group_milestone?
+ %span - Group Milestone
+ - else
+ %span - Project Milestone
+
.col-sm-6
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
@@ -13,31 +18,37 @@
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)
- - if milestone.is_a?(GlobalMilestone)
+ - if milestone.is_a?(GlobalMilestone) || milestone.is_group_milestone?
.row
.col-sm-6
- .expiration= render('shared/milestone_expired', milestone: milestone)
- .projects
- - milestone.milestones.each do |milestone|
- = link_to milestone_path(milestone) do
- %span.label.label-gray
- = dashboard ? milestone.project.name_with_namespace : milestone.project.name
+ - if milestone.is_legacy_group_milestone?
+ .expiration= render('shared/milestone_expired', milestone: milestone)
+ .projects
+ - milestone.milestones.each do |milestone|
+ = link_to milestone_path(milestone) do
+ %span.label.label-gray
+ = dashboard ? milestone.project.name_with_namespace : milestone.project.name
- if @group
- .col-sm-6
+ .col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
+ - if milestone.is_group_milestone?
+ = link_to edit_group_milestone_path(@group, milestone), class: "btn btn-xs btn-grouped" do
+ Edit
+ \
- if milestone.closed?
- = link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else
- = link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-close"
+ = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close"
- if @project
.row
- .col-sm-6= render('shared/milestone_expired', milestone: milestone)
+ .col-sm-6
+ = render('shared/milestone_expired', milestone: milestone)
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
- = link_to edit_namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), class: "btn btn-xs btn-grouped" do
+ = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
Edit
\
- = link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
- = link_to namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
+ = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
+ = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
Delete
diff --git a/app/views/shared/milestones/_participants_tab.html.haml b/app/views/shared/milestones/_participants_tab.html.haml
index 549d2e2f61e..1615871385e 100644
--- a/app/views/shared/milestones/_participants_tab.html.haml
+++ b/app/views/shared/milestones/_participants_tab.html.haml
@@ -4,5 +4,5 @@
= link_to user, title: user.name, class: "darken" do
= image_tag avatar_icon(user, 32), class: "avatar s32"
%strong= truncate(user.name, length: 40)
- %br
- %small.cgray= user.username
+ %div
+ %small.cgray= user.username
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 774d20fb5ba..40379f48393 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -1,7 +1,7 @@
-- affix_offset = local_assigns.fetch(:affix_offset, "102")
+- affix_offset = local_assigns.fetch(:affix_offset, "50")
- project = local_assigns[:project]
-%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
@@ -21,7 +21,7 @@
.title
Start date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
.value
%span.value-content
- if milestone.start_date
@@ -51,7 +51,7 @@
.title.hide-collapsed
Due date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if milestone.due_date
@@ -64,16 +64,16 @@
%span.remaining-days= remaining_days
- if !project || can?(current_user, :read_issue, project)
- .block
+ .block.issues
.sidebar-collapsed-icon
%strong
- = icon('hashtag', 'aria-hidden': 'true')
+ = custom_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
Issues
%span.badge= milestone.issues_visible_to_user(current_user).count
- if project && can?(current_user, :create_issue, project)
- = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do
+ = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do
New issue
.value.hide-collapsed.bold
%span.milestone-stat
@@ -85,7 +85,7 @@
Closed:
= milestone.issues_visible_to_user(current_user).closed.count
- .block
+ .block.merge-requests
.sidebar-collapsed-icon
%strong
= icon('exclamation', 'aria-hidden': 'true')
diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml
new file mode 100644
index 00000000000..68458c2d0aa
--- /dev/null
+++ b/app/views/shared/milestones/_tab_loading.html.haml
@@ -0,0 +1,2 @@
+.text-center.prepend-top-default
+ = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content')
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 9a4502873ef..b95a4ea674d 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,43 +1,50 @@
+- issues_accessible = milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs
- - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
+ %ul.nav-links.scrolling-tabs.js-milestone-tabs
+ - if issues_accessible
%li.active
= link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do
Issues
%span.badge= milestone.issues_visible_to_user(current_user).size
%li
- = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do
+ = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
%span.badge= milestone.merge_requests.size
- else
%li.active
- = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do
+ = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
%span.badge= milestone.merge_requests.size
%li
- = link_to '#tab-participants', 'data-toggle' => 'tab' do
+ = link_to '#tab-participants', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do
Participants
%span.badge= milestone.participants.count
%li
- = link_to '#tab-labels', 'data-toggle' => 'tab' do
+ = link_to '#tab-labels', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do
Labels
%span.badge= milestone.labels.count
+- issues = milestone.sorted_issues(current_user)
- show_project_name = local_assigns.fetch(:show_project_name, false)
- show_full_project_name = local_assigns.fetch(:show_full_project_name, false)
.tab-content.milestone-content
- - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project)
- .tab-pane.active#tab-issues
- = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ - if issues_accessible
+ .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } }
+ = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name
.tab-pane#tab-merge-requests
- = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ -# loaded async
+ = render "shared/milestones/tab_loading"
- else
.tab-pane.active#tab-merge-requests
- = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ -# loaded async
+ = render "shared/milestones/tab_loading"
.tab-pane#tab-participants
- = render 'shared/milestones/participants_tab', users: milestone.participants
+ -# loaded async
+ = render "shared/milestones/tab_loading"
.tab-pane#tab-labels
- = render 'shared/milestones/labels_tab', labels: milestone.labels
+ -# loaded async
+ = render "shared/milestones/tab_loading"
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 2562f085338..3014300fbe7 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -22,39 +22,55 @@
- if group
.pull-right
- if can?(current_user, :admin_milestones, group)
+ - if milestone.is_group_milestone?
+ = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do
+ Edit
- if milestone.active?
- = link_to 'Close Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
+ = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
- else
- = link_to 'Reopen Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
.detail-page-description.milestone-detail
%h2.title
= markdown_field(milestone, :title)
+ - if @milestone.is_group_milestone? && @milestone.description.present?
+ %div
+ .description
+ .wiki
+ = markdown_field(@milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg}
-.table-holder
- %table.table
- %thead
- %tr
- %th Project
- %th Open issues
- %th State
- %th Due date
- - milestone.milestones.each do |ms|
- %tr
- %td
- - project_name = group ? ms.project.name : ms.project.name_with_namespace
- = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms)
- %td
- = ms.issues_visible_to_user(current_user).opened.count
- %td
- - if ms.closed?
- Closed
- - else
- Open
- %td
- = ms.expires_at
+- if @milestone.is_legacy_group_milestone? || @milestone.is_dashboard_milestone?
+ .table-holder
+ %table.table
+ %thead
+ %tr
+ %th Project
+ %th Open issues
+ %th State
+ %th Due date
+ - milestone.milestones.each do |ms|
+ %tr
+ %td
+ - project_name = group ? ms.project.name : ms.project.name_with_namespace
+ = link_to project_name, project_milestone_path(ms.project, ms)
+ %td
+ = ms.issues_visible_to_user(current_user).opened.count
+ %td
+ - if ms.closed?
+ Closed
+ - else
+ Open
+ %td
+ = ms.expires_at
+- elsif @milestone.is_group_milestone?
+ %br
+ View
+ = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
+ or
+ = link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title)
+ in this milestone
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
new file mode 100644
index 00000000000..1dfe380db16
--- /dev/null
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -0,0 +1,30 @@
+- noteable_name = @note.noteable.human_class_name
+
+.pull-left.btn-group.append-right-10.droplab-dropdown.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}" } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .description
+ %strong Comment
+ %p
+ Add a general comment to this #{noteable_name}.
+
+ %li.divider.droplab-item-ignore
+
+ %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
+ %button.btn.btn-transparent
+ = icon('check', class: 'icon')
+ .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/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml
new file mode 100644
index 00000000000..f4b3aac29b4
--- /dev/null
+++ b/app/views/shared/notes/_edit.html.haml
@@ -0,0 +1 @@
+%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
new file mode 100644
index 00000000000..8923e5602a4
--- /dev/null
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -0,0 +1,14 @@
+.note-edit-form
+ = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
+ = hidden_field_tag :target_id, '', class: 'js-form-target-id'
+ = hidden_field_tag :target_type, '', class: 'js-form-target-type'
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
+ = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..."
+ = render 'shared/notes/hints'
+
+ .note-form-actions.clearfix
+ .settings-message.note-edit-warning.js-finish-edit-warning
+ Finish editing this message first!
+ = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button'
+ %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
+ Cancel
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
new file mode 100644
index 00000000000..725bf916592
--- /dev/null
+++ b/app/views/shared/notes/_form.html.haml
@@ -0,0 +1,43 @@
+- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
+- supports_quick_actions = note_supports_quick_actions?(@note)
+- if supports_quick_actions
+ - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id)
+- else
+ - preview_url = preview_markdown_path(@project)
+
+= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
+ = 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
+ = hidden_field_tag :note_project_id
+
+ = note_target_fields(@note)
+ = 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: { url: preview_url, referenced_users: true } do
+ = render 'projects/zen', f: f,
+ attr: :note,
+ classes: 'note-textarea js-note-text',
+ placeholder: "Write a comment or drag your files here...",
+ supports_quick_actions: supports_quick_actions,
+ supports_autocomplete: supports_autocomplete
+ = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
+ .error-alert
+
+ .note-form-actions.clearfix
+ = render partial: 'shared/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/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
new file mode 100644
index 00000000000..bc1ac3d8ac2
--- /dev/null
+++ b/app/views/shared/notes/_hints.html.haml
@@ -0,0 +1,35 @@
+- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
+.comment-toolbar.clearfix
+ .toolbar-text
+ = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+ - if supports_quick_actions
+ and
+ = link_to 'quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
+ are
+ - else
+ is
+ supported
+
+ %span.uploading-container
+ %span.uploading-progress-container.hide
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ %span.attaching-file-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %span.uploading-progress 0%
+ %span.uploading-spinner
+ = icon('spinner spin', class: 'toolbar-button-icon')
+
+ %span.uploading-error-container.hide
+ %span.uploading-error-icon
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ %span.uploading-error-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %button.retry-uploading-link{ type: 'button' } Try again
+ or
+ %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file
+
+ %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' }
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ Attach a file
+
+ %button.btn.btn-default.btn-xs.hide.button-cancel-uploading-files{ type: 'button' } Cancel
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
new file mode 100644
index 00000000000..7174855e176
--- /dev/null
+++ b/app/views/shared/notes/_note.html.haml
@@ -0,0 +1,65 @@
+- return unless note.author
+- return if note.cross_reference_not_visible_for?(current_user)
+
+- note_editable = note_editable?(note)
+%li.timeline-entry{ id: dom_id(note),
+ class: ["note", "note-row-#{note.id}", ('system-note' if note.system)],
+ data: { author_id: note.author.id,
+ editable: note_editable,
+ note_id: note.id } }
+ .timeline-entry-inner
+ .timeline-icon
+ - if note.system
+ = icon_for_system_note(note)
+ - else
+ %a{ href: user_path(note.author) }
+ = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
+ .timeline-content
+ .note-header
+ .note-header-info
+ %a{ href: user_path(note.author) }
+ %span.note-header-author-name
+ = sanitize(note.author.name)
+ %span.note-headline-light
+ = note.author.to_reference
+ %span.note-headline-light
+ %span.note-headline-meta
+ - unless note.system
+ commented
+ - if note.system
+ %span.system-note-message
+ = note.redacted_note_html
+ %a{ href: "##{dom_id(note)}" }
+ = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
+ - unless note.system?
+ .note-actions
+ - if note.for_personal_snippet?
+ = render 'snippets/notes/actions', note: note, note_editable: note_editable
+ - else
+ = render 'projects/notes/actions', note: note, note_editable: note_editable
+ .note-body{ class: note_editable ? 'js-task-list-container' : '' }
+ .note-text.md
+ = note.redacted_note_html
+ = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
+ .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
+ #{note.note}
+ - if note_editable
+ = render 'shared/notes/edit', note: note
+ .note-awards
+ = render 'award_emoji/awards_block', awardable: note, inline: false
+ - if note.system
+ .system-note-commit-list-toggler
+ Toggle commit list
+ %i.fa.fa-angle-down
+ - if note.attachment.url
+ .note-attachment
+ - if note.attachment.image?
+ = link_to note.attachment.url, target: '_blank' do
+ = image_tag note.attachment.url, class: 'note-image-attach'
+ .attachment
+ = link_to note.attachment.url, target: '_blank' do
+ = icon('paperclip')
+ = note.attachment_identifier
+ = link_to delete_attachment_project_note_path(note.project, note),
+ title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
+ = icon('trash-o', class: 'cred')
diff --git a/app/views/shared/notes/_notes.html.haml b/app/views/shared/notes/_notes.html.haml
new file mode 100644
index 00000000000..cfdfeeb9e97
--- /dev/null
+++ b/app/views/shared/notes/_notes.html.haml
@@ -0,0 +1,8 @@
+- if defined?(@discussions)
+ - @discussions.each do |discussion|
+ - if discussion.individual_note?
+ = render partial: "shared/notes/note", collection: discussion.notes, as: :note
+ - else
+ = render 'discussions/discussion', discussion: discussion
+- else
+ = render partial: "shared/notes/note", collection: @notes, as: :note
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
new file mode 100644
index 00000000000..eae04c9bbb8
--- /dev/null
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -0,0 +1,25 @@
+%ul#notes-list.notes.main-notes-list.timeline
+ = render "shared/notes/notes"
+
+= render 'shared/notes/edit_form', project: @project
+
+- if can_create_note?
+ %ul.notes.notes-form.timeline
+ %li.timeline-entry
+ .timeline-entry-inner
+ .flash-container.timeline-content
+
+ .timeline-icon.hidden-xs.hidden-sm
+ %a.author_link{ href: user_path(current_user) }
+ = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40'
+ .timeline-content.timeline-content-form
+ = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
+- elsif !current_user
+ .disabled-comment.text-center.prepend-top-default
+ Please
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ or
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ to comment
+
+%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 1d072c16b32..e99d8d0973f 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -6,14 +6,14 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 708adbc38f1..9186c2ba9c9 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -1,11 +1,11 @@
-.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), aria: { labelledby: "custom-notifications-title" } }
+.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" }
.modal-dialog
.modal-content
.modal-header
- %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } }
- %span{ aria: { hidden: "true" } } ×
+ %button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } }
+ %span{ "aria-hidden": "true" } ×
%h4#custom-notifications-title.modal-title
- Custom notification events
+ #{ _('Custom notification events') }
.modal-body
.container-fluid
@@ -13,12 +13,11 @@
= hidden_setting_source_input(notification_setting)
.row
.col-lg-4
- %h4.prepend-top-0
- Notification events
+ %h4.prepend-top-0= _('Notification events')
%p
- Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out
- = succeed "." do
- %a{ href: help_page_path('workflow/notifications'), target: "_blank" } notification emails
+ - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank'
+ - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
+ #{ paragraph.html_safe }
.col-lg-8
- NotificationSetting::EMAIL_EVENTS.each_with_index do |event, index|
- field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]"
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index aaffc0927eb..0bedfea3502 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -10,10 +10,10 @@
- load_pipeline_status(projects)
.js-projects-list-holder
- - if projects.any?
+ - if any_projects?(projects)
%ul.projects-list
- projects.each_with_index do |project, i|
- - css_class = (i >= projects_limit) ? 'hide' : nil
+ - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar,
forks: forks, show_last_commit_as_description: show_last_commit_as_description
@@ -22,7 +22,7 @@
%li.project-row.private-forks-notice
= icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon')
%strong= pluralize(@private_forks_count, 'private fork')
- %span you have no access to.
- = paginate(projects, remote: remote, theme: "gitlab") if projects.respond_to? :total_pages
+ %span &nbsp;you have no access to.
+ = paginate_collection(projects, remote: remote)
- else
.nothing-here-block No projects found
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 71ed23476d2..f4f155c8d94 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -7,14 +7,14 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project)
-- updated_tooltip = time_ago_with_tooltip(project.updated_at)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
%li.project-row{ class: css_class }
= cache(cache_key) do
- if avatar
.avatar-container.s40
= link_to project_path(project), class: dom_class(project) do
- - if use_creator_avatar
+ - if project.creator && use_creator_avatar
= image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
@@ -31,7 +31,7 @@
- if show_last_commit_as_description
.description.prepend-top-5
- = link_to_gfm project.commit.title, namespace_project_commit_path(project.namespace, project, project.commit),
+ = link_to_gfm project.commit.title, project_commit_path(project, project.commit),
class: "commit-row-message"
- elsif project.description.present?
.description.prepend-top-5
@@ -54,5 +54,5 @@
= number_with_delimiter(project.star_count)
%span.prepend-left-10.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project) }
= visibility_level_icon(project.visibility_level, fw: true)
- .prepend-top-5
+ .prepend-top-0
updated #{updated_tooltip}
diff --git a/app/views/shared/projects/blob/_branch_page_create.html.haml b/app/views/shared/projects/blob/_branch_page_create.html.haml
deleted file mode 100644
index c279a0d8846..00000000000
--- a/app/views/shared/projects/blob/_branch_page_create.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-.dropdown-page-two.dropdown-new-branch
- = dropdown_title('Create new branch', back: true)
- = dropdown_content do
- %input#new_branch_name.default-dropdown-input.append-bottom-10{ type: "text", placeholder: "Name new branch" }
- %button.btn.btn-primary.pull-left.js-new-branch-btn{ type: "button" }
- Create
- %button.btn.btn-default.pull-right.js-cancel-branch-btn{ type: "button" }
- Cancel
diff --git a/app/views/shared/projects/blob/_branch_page_default.html.haml b/app/views/shared/projects/blob/_branch_page_default.html.haml
deleted file mode 100644
index 9bf78d10878..00000000000
--- a/app/views/shared/projects/blob/_branch_page_default.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-.dropdown-page-one
- = dropdown_title "Select branch"
- = dropdown_filter "Search branches"
- = dropdown_content
- = dropdown_loading
- = dropdown_footer do
- %ul.dropdown-footer-list
- %li
- %a.create-new-branch.dropdown-toggle-page{ href: "#" }
- Create new branch
diff --git a/app/views/shared/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml
new file mode 100644
index 00000000000..73fdb8b523f
--- /dev/null
+++ b/app/views/shared/repo/_editable_mode.html.haml
@@ -0,0 +1,2 @@
+.editable-mode
+ %repo-edit-button
diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml
new file mode 100644
index 00000000000..87fa2007d16
--- /dev/null
+++ b/app/views/shared/repo/_repo.html.haml
@@ -0,0 +1,7 @@
+#repo{ data: { url: content_url,
+ project_name: project.name,
+ refs_url: refs_project_path(project, format: :json),
+ project_url: project_path(project),
+ project_id: project.id,
+ can_commit: (!!can_push_branch?(project, @ref)).to_s,
+ on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s } }
diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml
index 895c3f1e99d..11f0fa7c49f 100644
--- a/app/views/shared/snippets/_blob.html.haml
+++ b/app/views/shared/snippets/_blob.html.haml
@@ -1,29 +1,14 @@
+- blob = @snippet.blob
.js-file-title.file-title-flex-parent
- .file-header-content
- = blob_icon @snippet.mode, @snippet.path
-
- %strong.file-title-name
- = @snippet.path
-
- = copy_file_path_button(@snippet.path)
+ = render 'projects/blob/header_content', blob: blob
.file-actions.hidden-xs
+ = render 'projects/blob/viewer_switcher', blob: blob
+
.btn-group{ role: "group" }<
- = copy_blob_content_button(@snippet)
- = open_raw_file_button(raw_path)
+ = copy_blob_source_button(blob)
+ = open_raw_blob_button(blob)
- - if defined?(download_path) && download_path
- = link_to icon('download'), download_path, class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' }
+ = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' }
-- if @snippet.content.empty?
- .file-content.code
- .nothing-here-block Empty file
-- else
- - if markup?(@snippet.file_name)
- .file-content.wiki
- - if gitlab_markdown?(@snippet.file_name)
- = preserve(markdown_field(@snippet, :content))
- - else
- = markup(@snippet.file_name, @snippet.content)
- - else
- = render 'shared/file_highlight', blob: @snippet
+= render 'projects/blob/content', blob: blob
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 0296597b294..43322978749 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('snippet')
.snippet-form-holder
- = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f|
+ = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group
@@ -11,6 +11,8 @@
.col-sm-10
= f.text_field :title, class: 'form-control', required: true, autofocus: true
+ = render 'shared/form_elements/description', model: @snippet, project: @project, form: f
+
= render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet
.file-editor
@@ -23,6 +25,9 @@
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
+ - if params[:files]
+ - params[:files].each_with_index do |file, index|
+ = hidden_field_tag "files[]", file, id: "files_#{index}"
.form-actions
- if @snippet.new_record?
@@ -31,6 +36,6 @@
= f.submit 'Save changes', class: "btn-save btn"
- if @snippet.project_id
- = link_to "Cancel", namespace_project_snippets_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
- else
= link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index d084f5e9684..17b34c5eeb3 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -16,9 +16,15 @@
- else
= render "snippets/actions"
-.snippet-header
+.snippet-header.limited-header-width
%h2.snippet-title.prepend-top-0.append-bottom-0
= markdown_field(@snippet, :title)
- if @snippet.updated_at != @snippet.created_at
- = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago')
+ = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
+ - if @snippet.description.present?
+ .description
+ .wiki
+ = markdown_field(@snippet, :description)
+ %textarea.hidden.js-task-list-field
+ = @snippet.description
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 5d2d2317f22..7388f20a9fd 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -30,7 +30,7 @@
- if link_project && snippet.project_id?
%span.hidden-xs
in
- = link_to namespace_project_path(snippet.project.namespace, snippet.project) do
+ = link_to project_path(snippet.project) do
= snippet.project.name_with_namespace
.pull-right.snippet-updated-at
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index ee3be3c789a..1f0e7629fb4 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -1,102 +1,82 @@
-.row.prepend-top-default
- .col-lg-3
- %h4.prepend-top-0
- = page_title
- %p
- #{link_to "Webhooks", help_page_path("user/project/integrations/webhooks")} can be
- used for binding events when something is happening within the project.
- .col-lg-9.append-bottom-default
- = form_for hook, as: :hook, url: polymorphic_path(url_components + [:hooks]) do |f|
- = form_errors(hook)
+= form_errors(hook)
- .form-group
- = f.label :url, "URL", class: 'label-light'
- = f.text_field :url, class: "form-control", placeholder: 'http://example.com/trigger-ci.json'
- .form-group
- = f.label :token, "Secret Token", class: 'label-light'
- = f.text_field :token, class: "form-control", placeholder: ''
- %p.help-block
- Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.
- .form-group
- = f.label :url, "Trigger", class: 'label-light'
- %ul.list-unstyled
- %li
- = f.check_box :push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :push_events, class: 'list-label' do
- %strong Push events
- %p.light
- This URL will be triggered by a push to the repository
- %li
- = f.check_box :tag_push_events, class: 'pull-left'
- .prepend-left-20
- = f.label :tag_push_events, class: 'list-label' do
- %strong Tag push events
- %p.light
- This URL will be triggered when a new tag is pushed to the repository
- %li
- = f.check_box :note_events, class: 'pull-left'
- .prepend-left-20
- = f.label :note_events, class: 'list-label' do
- %strong Comments
- %p.light
- This URL will be triggered when someone adds a comment
- %li
- = f.check_box :issues_events, class: 'pull-left'
- .prepend-left-20
- = f.label :issues_events, class: 'list-label' do
- %strong Issues events
- %p.light
- This URL will be triggered when an issue is created/updated/merged
- %li
- = f.check_box :confidential_issues_events, class: 'pull-left'
- .prepend-left-20
- = f.label :confidential_issues_events, class: 'list-label' do
- %strong Confidential Issues events
- %p.light
- This URL will be triggered when a confidential issue is created/updated/merged
- %li
- = f.check_box :merge_requests_events, class: 'pull-left'
- .prepend-left-20
- = f.label :merge_requests_events, class: 'list-label' do
- %strong Merge Request events
- %p.light
- This URL will be triggered when a merge request is created/updated/merged
- %li
- = f.check_box :build_events, class: 'pull-left'
- .prepend-left-20
- = f.label :build_events, class: 'list-label' do
- %strong Jobs events
- %p.light
- This URL will be triggered when the job status changes
- %li
- = f.check_box :pipeline_events, class: 'pull-left'
- .prepend-left-20
- = f.label :pipeline_events, class: 'list-label' do
- %strong Pipeline events
- %p.light
- This URL will be triggered when the pipeline status changes
- %li
- = f.check_box :wiki_page_events, class: 'pull-left'
- .prepend-left-20
- = f.label :wiki_page_events, class: 'list-label' do
- %strong Wiki Page events
- %p.light
- This URL will be triggered when a wiki page is created/updated
- .form-group
- = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox'
- .checkbox
- = f.label :enable_ssl_verification do
- = f.check_box :enable_ssl_verification
- %strong Enable SSL verification
- = f.submit "Add webhook", class: "btn btn-create"
- %hr
- %h5.prepend-top-default
- Webhooks (#{hooks.count})
- - if hooks.any?
- %ul.well-list
- - hooks.each do |hook|
- = render "project_hook", hook: hook
- - else
- %p.settings-message.text-center.append-bottom-0
- No webhooks found, add one in the form above.
+.form-group
+ = form.label :url, 'URL', class: 'label-light'
+ = form.text_field :url, class: 'form-control', placeholder: 'http://example.com/trigger-ci.json'
+.form-group
+ = form.label :token, 'Secret Token', class: 'label-light'
+ = form.text_field :token, class: 'form-control', placeholder: ''
+ %p.help-block
+ Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.
+.form-group
+ = form.label :url, 'Trigger', class: 'label-light'
+ %ul.list-unstyled
+ %li
+ = form.check_box :push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :push_events, class: 'list-label' do
+ %strong Push events
+ %p.light
+ This URL will be triggered by a push to the repository
+ %li
+ = form.check_box :tag_push_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :tag_push_events, class: 'list-label' do
+ %strong Tag push events
+ %p.light
+ This URL will be triggered when a new tag is pushed to the repository
+ %li
+ = form.check_box :note_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :note_events, class: 'list-label' do
+ %strong Comments
+ %p.light
+ This URL will be triggered when someone adds a comment
+ %li
+ = form.check_box :issues_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :issues_events, class: 'list-label' do
+ %strong Issues events
+ %p.light
+ This URL will be triggered when an issue is created/updated/merged
+ %li
+ = form.check_box :confidential_issues_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :confidential_issues_events, class: 'list-label' do
+ %strong Confidential Issues events
+ %p.light
+ This URL will be triggered when a confidential issue is created/updated/merged
+ %li
+ = form.check_box :merge_requests_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :merge_requests_events, class: 'list-label' do
+ %strong Merge Request events
+ %p.light
+ This URL will be triggered when a merge request is created/updated/merged
+ %li
+ = form.check_box :job_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :job_events, class: 'list-label' do
+ %strong Job events
+ %p.light
+ This URL will be triggered when the job status changes
+ %li
+ = form.check_box :pipeline_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :pipeline_events, class: 'list-label' do
+ %strong Pipeline events
+ %p.light
+ This URL will be triggered when the pipeline status changes
+ %li
+ = form.check_box :wiki_page_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :wiki_page_events, class: 'list-label' do
+ %strong Wiki Page events
+ %p.light
+ This URL will be triggered when a wiki page is created/updated
+.form-group
+ = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox'
+ .checkbox
+ = form.label :enable_ssl_verification do
+ = form.check_box :enable_ssl_verification
+ %strong Enable SSL verification
diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml
new file mode 100644
index 00000000000..cf1d5e061c6
--- /dev/null
+++ b/app/views/shared/web_hooks/_test_button.html.haml
@@ -0,0 +1,12 @@
+- triggers = local_assigns.fetch(:triggers)
+- button_class = local_assigns.fetch(:button_class, '')
+- hook = local_assigns.fetch(:hook)
+
+.hook-test-button.dropdown.inline
+ %button.btn{ 'data-toggle' => 'dropdown', class: button_class }
+ Test
+ = icon('caret-down')
+ %ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
+ - triggers.each_value do |event|
+ %li
+ = link_to_test_hook(hook, event)
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index ac3701233ad..dfea8b40bd8 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,4 +1,3 @@
-- remote = local_assigns.fetch(:remote, false)
- link_project = local_assigns.fetch(:link_project, false)
.snippets-list-holder
@@ -8,7 +7,4 @@
%li
.nothing-here-block Nothing here.
- = paginate @snippets, theme: 'gitlab', remote: remote
-
-:javascript
- gl.SnippetsList();
+ = paginate @snippets, theme: 'gitlab'
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index ca8afb4bb6a..f01915107e3 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -1,3 +1,5 @@
+- @hide_top_links = true
+- breadcrumb_title "Snippets"
- page_title "New Snippet"
%h3.page-title
New Snippet
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
new file mode 100644
index 00000000000..3a50324770d
--- /dev/null
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -0,0 +1,17 @@
+- if current_user
+ - if note.emoji_awardable?
+ - user_authored = note.user_authored?(current_user)
+ .note-actions-item
+ = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
+ = icon('spinner spin')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
+ %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+
+ - if note_editable
+ .note-actions-item
+ = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ %span.link-highlight
+ = custom_icon('icon_pencil')
+
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index e5711ca79c7..706f13dd004 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -1,9 +1,14 @@
+- @hide_top_links = true
+- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
= render 'shared/snippets/header'
-%article.file-holder.snippet-file-content
- = render 'shared/snippets/blob', raw_path: raw_snippet_path(@snippet), download_path: download_snippet_path(@snippet)
+.personal-snippets
+ %article.file-holder.snippet-file-content
+ = render 'shared/snippets/blob'
-.row-content-block.top-block.content-component-block
- = render 'award_emoji/awards_block', awardable: @snippet, inline: true
+ .row-content-block.top-block.content-component-block
+ = render 'award_emoji/awards_block', awardable: @snippet, inline: true
+
+ #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index 00788e77b6b..093b2d82813 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -37,7 +37,3 @@
.col-md-3
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag "Register U2F device", class: "btn btn-success"
-
-:javascript
- var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
- u2fRegister.start();
diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml
new file mode 100644
index 00000000000..0545cab538c
--- /dev/null
+++ b/app/views/users/_deletion_guidance.html.haml
@@ -0,0 +1,10 @@
+- user = local_assigns.fetch(:user)
+
+%ul
+ %li
+ %p
+ Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the
+ = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records")
+ - personal_projects_count = user.personal_projects.count
+ - unless personal_projects_count.zero?
+ %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored
diff --git a/app/views/users/calendar.html.haml b/app/views/users/calendar.html.haml
deleted file mode 100644
index 57b8845c55d..00000000000
--- a/app/views/users/calendar.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-.clearfix.calendar
- .js-contrib-calendar
- .calendar-hint
- Summary of issues, merge requests, push events, and comments
-:javascript
- new Calendar(
- #{@activity_dates.to_json},
- '#{user_calendar_activities_path}'
- );
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index d1e88274878..6b1d75c6e72 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -1,6 +1,6 @@
%h4.prepend-top-20
Contributions for
- %strong= @calendar_date.to_s(:short)
+ %strong= @calendar_date.to_s(:medium)
- if @events.any?
%ul.bordered-list
@@ -8,11 +8,11 @@
%li
%span.light
%i.fa.fa-clock-o
- = event.created_at.to_s(:time)
+ = event.created_at.strftime('%-I:%M%P')
- if event.push?
#{event.action_name} #{event.ref_type}
%strong
- - commits_path = namespace_project_commits_path(event.project.namespace, event.project, event.ref_name)
+ - commits_path = project_commits_path(event.project, event.ref_name)
= link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path
- else
= event_action_name(event)
@@ -30,4 +30,4 @@
= event.project_name
- else
%p
- No contributions found for #{@calendar_date.to_s(:short)}
+ No contributions found for #{@calendar_date.to_s(:medium)}
diff --git a/app/views/users/show.atom.builder b/app/views/users/show.atom.builder
index 6c85e5f9fbd..e95814875f1 100644
--- a/app/views/users/show.atom.builder
+++ b/app/views/users/show.atom.builder
@@ -1,10 +1,7 @@
-xml.instruct!
-xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://search.yahoo.com/mrss/" do
- xml.title "#{@user.name} activity"
- xml.link href: user_url(@user, :atom), rel: "self", type: "application/atom+xml"
- xml.link href: user_url(@user), rel: "alternate", type: "text/html"
- xml.id user_url(@user)
- xml.updated @events[0].updated_at.xmlschema if @events[0]
+xml.title "#{@user.name} activity"
+xml.link href: user_url(@user, :atom), rel: "self", type: "application/atom+xml"
+xml.link href: user_url(@user), rel: "alternate", type: "text/html"
+xml.id user_url(@user)
+xml.updated @events[0].updated_at.xmlschema if @events[0]
- xml << render(@events) if @events.any?
-end
+xml << render(@events) if @events.any?
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 03e5dd97405..879e0f99b14 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,8 +1,7 @@
+- @hide_top_links = true
+- @hide_breadcrumbs = true
- page_title @user.name
- page_description @user.bio
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('users')
- header_title @user.name, user_path(@user)
- @no_container = true
@@ -10,7 +9,7 @@
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
.user-profile
- .cover-block.user-cover-block
+ .cover-block.user-cover-block.layout-nav
.cover-controls
- if @user == current_user
= link_to profile_path, class: 'btn btn-gray has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do
@@ -56,11 +55,11 @@
= icon('skype')
- unless @user.linkedin.blank?
.profile-link-holder.middle-dot-divider
- = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do
+ = link_to linkedin_url(@user), title: "LinkedIn" do
= icon('linkedin-square')
- unless @user.twitter.blank?
.profile-link-holder.middle-dot-divider
- = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do
+ = link_to twitter_url(@user), title: "Twitter" do
= icon('twitter-square')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider
@@ -71,7 +70,7 @@
= @user.location
- unless @user.organization.blank?
.profile-link-holder.middle-dot-divider
- = icon('building')
+ = icon('briefcase')
= @user.organization
- if @user.bio.present?
@@ -82,7 +81,7 @@
.scrolling-tabs-container
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.center.user-profile-nav.scrolling-tabs
+ %ul.nav-links.user-profile-nav.scrolling-tabs
%li.js-activity-tab
= link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
Activity
@@ -100,12 +99,12 @@
Snippets
%div{ class: container_class }
- - if @user == current_user && !show_user_callout?
+ - if @user == current_user && show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
.tab-content
#activity.tab-pane
.row-content-block.calender-block.white.second-block.hidden-xs
- .user-calendar{ data: { href: user_calendar_path } }
+ .user-calendar{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
%h4.center.light
%i.fa.fa-spinner.fa-spin
.user-calendar-activities
@@ -129,10 +128,3 @@
.loading-status
= spinner
-
-:javascript
- var userProfile;
-
- userProfile = new gl.User({
- action: "#{controller.action_name}"
- });
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
new file mode 100644
index 00000000000..45ce49bb5c0
--- /dev/null
+++ b/app/workers/background_migration_worker.rb
@@ -0,0 +1,39 @@
+class BackgroundMigrationWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ # Enqueues a number of jobs in bulk.
+ #
+ # The `jobs` argument should be an Array of Arrays, each sub-array must be in
+ # the form:
+ #
+ # [migration-class, [arg1, arg2, ...]]
+ def self.perform_bulk(jobs)
+ Sidekiq::Client.push_bulk('class' => self,
+ 'queue' => sidekiq_options['queue'],
+ 'args' => jobs)
+ end
+
+ # Schedules multiple jobs in bulk, with a delay.
+ #
+ def self.perform_bulk_in(delay, jobs)
+ now = Time.now.to_i
+ schedule = now + delay.to_i
+
+ if schedule <= now
+ raise ArgumentError, 'The schedule time must be in the future!'
+ end
+
+ Sidekiq::Client.push_bulk('class' => self,
+ 'queue' => sidekiq_options['queue'],
+ 'args' => jobs,
+ 'at' => schedule)
+ end
+
+ # Performs the background migration.
+ #
+ # See Gitlab::BackgroundMigration.perform for more information.
+ def perform(class_name, arguments = [])
+ Gitlab::BackgroundMigration.perform(class_name, arguments)
+ end
+end
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index e17add7421f..bf009dfab0f 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -11,15 +11,6 @@ class BuildSuccessWorker
private
def create_deployment(build)
- service = CreateDeploymentService.new(
- build.project, build.user,
- environment: build.environment,
- sha: build.sha,
- ref: build.ref,
- tag: build.tag,
- options: build.options.to_h[:environment],
- variables: build.variables)
-
- service.execute(build)
+ CreateDeploymentService.new(build).execute
end
end
diff --git a/app/workers/concerns/new_issuable.rb b/app/workers/concerns/new_issuable.rb
new file mode 100644
index 00000000000..eb0d6c9c36c
--- /dev/null
+++ b/app/workers/concerns/new_issuable.rb
@@ -0,0 +1,26 @@
+module NewIssuable
+ attr_reader :issuable, :user
+
+ def objects_found?(issuable_id, user_id)
+ set_user(user_id)
+ set_issuable(issuable_id)
+
+ user && issuable
+ end
+
+ def set_user(user_id)
+ @user = User.find_by(id: user_id)
+
+ log_error(User, user_id) unless @user
+ end
+
+ def set_issuable(issuable_id)
+ @issuable = issuable_class.find_by(id: issuable_id)
+
+ log_error(issuable_class, issuable_id) unless @issuable
+ end
+
+ def log_error(record_class, record_id)
+ Rails.logger.error("#{self.class}: couldn't find #{record_class} with ID=#{record_id}, skipping job")
+ end
+end
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
new file mode 100644
index 00000000000..f34dff2d656
--- /dev/null
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -0,0 +1,12 @@
+class CreateGpgSignatureWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ def perform(commit_sha, project_id)
+ project = Project.find_by(id: project_id)
+ return unless project
+
+ # This calculates and caches the signature in the database
+ Gitlab::Gpg::Commit.new(project, commit_sha).signature
+ end
+end
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index d3f7e479a8d..1afa24c8e2a 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -31,8 +31,6 @@ class EmailReceiverWorker
when Gitlab::Email::EmptyEmailError
can_retry = true
"It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies."
- when Gitlab::Email::AutoGeneratedEmailError
- "The email was marked as 'auto generated', which we can't accept. Please create your comment through the web interface."
when Gitlab::Email::UserNotFoundError
"We couldn't figure out what user corresponds to the email. Please create your comment through the web interface."
when Gitlab::Email::UserBlockedError
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index eb403c134d1..7b59e976492 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -8,7 +8,7 @@ class ExpireBuildInstanceArtifactsWorker
.reorder(nil)
.find_by(id: build_id)
- return unless build.try(:project)
+ return unless build&.project && !build.project.pending_delete
Rails.logger.info "Removing artifacts for build #{build.id}..."
build.erase_artifacts!
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
new file mode 100644
index 00000000000..e383202260d
--- /dev/null
+++ b/app/workers/expire_job_cache_worker.rb
@@ -0,0 +1,27 @@
+class ExpireJobCacheWorker
+ include Sidekiq::Worker
+ include BuildQueue
+
+ def perform(job_id)
+ job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id)
+ return unless job
+
+ pipeline = job.pipeline
+ project = job.project
+
+ Gitlab::EtagCaching::Store.new.tap do |store|
+ store.touch(project_pipeline_path(project, pipeline))
+ store.touch(project_job_path(project, job))
+ end
+ end
+
+ private
+
+ def project_pipeline_path(project, pipeline)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
+ end
+
+ def project_job_path(project, job)
+ Gitlab::Routing.url_helpers.project_build_path(project, job.id, format: :json)
+ end
+end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 603e2f1aaea..7c02d6cf892 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -10,6 +10,7 @@ class ExpirePipelineCacheWorker
store = Gitlab::EtagCaching::Store.new
store.touch(project_pipelines_path(project))
+ store.touch(project_pipeline_path(project, pipeline))
store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit
store.touch(new_merge_request_pipelines_path(project))
each_pipelines_merge_request_path(project, pipeline) do |path|
@@ -22,34 +23,24 @@ class ExpirePipelineCacheWorker
private
def project_pipelines_path(project)
- Gitlab::Routing.url_helpers.namespace_project_pipelines_path(
- project.namespace,
- project,
- format: :json)
+ Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
+ end
+
+ def project_pipeline_path(project, pipeline)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
end
def commit_pipelines_path(project, commit)
- Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path(
- project.namespace,
- project,
- commit.id,
- format: :json)
+ Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
end
def new_merge_request_pipelines_path(project)
- Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path(
- project.namespace,
- project,
- format: :json)
+ Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
end
def each_pipelines_merge_request_path(project, pipeline)
pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path(
- project.namespace,
- project,
- merge_request,
- format: :json)
+ path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json)
yield(path)
end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index d369b639ae9..c95497dfaba 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -5,6 +5,12 @@ class GitGarbageCollectWorker
sidekiq_options retry: false
+ GITALY_MIGRATED_TASKS = {
+ gc: :garbage_collect,
+ full_repack: :repack_full,
+ incremental_repack: :repack_incremental
+ }.freeze
+
def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil)
project = Project.find(project_id)
task = task.to_sym
@@ -15,8 +21,14 @@ class GitGarbageCollectWorker
Gitlab::GitLogger.info(description)
- output, status = Gitlab::Popen.popen(cmd, repo_path)
- Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero?
+ gitaly_migrate(GITALY_MIGRATED_TASKS[task]) do |is_enabled|
+ if is_enabled
+ gitaly_call(task, project.repository.raw_repository)
+ else
+ output, status = Gitlab::Popen.popen(cmd, repo_path)
+ Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero?
+ end
+ end
# Refresh the branch cache in case garbage collection caused a ref lookup to fail
flush_ref_caches(project) if task == :gc
@@ -26,6 +38,19 @@ class GitGarbageCollectWorker
private
+ ## `repository` has to be a Gitlab::Git::Repository
+ def gitaly_call(task, repository)
+ client = Gitlab::GitalyClient::RepositoryService.new(repository)
+ case task
+ when :gc
+ client.garbage_collect(bitmaps_enabled?)
+ when :full_repack
+ client.repack_full(bitmaps_enabled?)
+ when :incremental_repack
+ client.repack_incremental
+ end
+ end
+
def command(task)
case task
when :gc
@@ -55,4 +80,14 @@ class GitGarbageCollectWorker
config_value = write_bitmaps ? 'true' : 'false'
%W[git -c repack.writeBitmaps=#{config_value}]
end
+
+ def gitaly_migrate(method, &block)
+ Gitlab::GitalyClient.migrate(method, &block)
+ rescue GRPC::NotFound => e
+ Gitlab::GitLogger.error("#{method} failed:\nRepository not found")
+ raise Gitlab::Git::Repository::NoRepository.new(e)
+ rescue GRPC::BadStatus => e
+ Gitlab::GitLogger.error("#{method} failed:\n#{e}")
+ raise Gitlab::Git::CommandError.new(e)
+ end
end
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index 964287a1793..0ec871e00e1 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -4,6 +4,6 @@ class GitlabShellWorker
include DedicatedSidekiqQueue
def perform(action, *arg)
- gitlab_shell.send(action, *arg)
+ gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index 2f02235b0ac..0a55aab63fd 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -3,29 +3,17 @@ class GitlabUsagePingWorker
include Sidekiq::Worker
include CronjobQueue
- include HTTParty
def perform
- return unless current_application_settings.usage_ping_enabled
-
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
return unless try_obtain_lease
- begin
- HTTParty.post(url,
- body: Gitlab::UsageData.to_json(force_refresh: true),
- headers: { 'Content-type' => 'application/json' }
- )
- rescue HTTParty::Error => e
- Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
- end
+ SubmitUsagePingService.new.execute
end
+ private
+
def try_obtain_lease
Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain
end
-
- def url
- 'https://version.gitlab.com/usage_data'
- end
end
diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb
new file mode 100644
index 00000000000..db6b1ea8e8d
--- /dev/null
+++ b/app/workers/invalid_gpg_signature_update_worker.rb
@@ -0,0 +1,12 @@
+class InvalidGpgSignatureUpdateWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ def perform(gpg_key_id)
+ gpg_key = GpgKey.find_by(id: gpg_key_id)
+
+ return unless gpg_key
+
+ Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run
+ end
+end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index c9658b3fe17..3dd14466994 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -66,7 +66,7 @@ class IrkerWorker
end
def send_new_branch(project, repo_name, committer, branch)
- repo_path = project.path_with_namespace
+ repo_path = project.full_path
newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches"
newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors
@@ -109,7 +109,7 @@ class IrkerWorker
end
def send_commits_count(data, project, repo, committer, branch)
- url = compare_url data, project.path_with_namespace
+ url = compare_url data, project.full_path
commits = colorize_commits data['total_commits_count']
new_commits = 'new commit'
@@ -142,10 +142,10 @@ class IrkerWorker
end
def files_count(commit)
- diffs = commit.raw_diffs(deltas_only: true)
+ diff_size = commit.raw_deltas.size
- files = "#{diffs.real_size} file"
- files += 's' if diffs.size > 1
+ files = "#{diff_size} file"
+ files += 's' if diff_size > 1
files
end
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index 79efca4f2f9..c3b58df92c1 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -7,7 +7,9 @@ class MergeWorker
current_user = User.find(current_user_id)
merge_request = MergeRequest.find(merge_request_id)
- MergeRequests::MergeService.new(merge_request.target_project, current_user, params).
- execute(merge_request)
+ merge_request.update_column(:merge_jid, jid)
+
+ MergeRequests::MergeService.new(merge_request.target_project, current_user, params)
+ .execute(merge_request)
end
end
diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb
new file mode 100644
index 00000000000..a9073742ff7
--- /dev/null
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -0,0 +1,39 @@
+# Worker to destroy projects that do not have a namespace
+#
+# It destroys everything it can without having the info about the namespace it
+# used to belong to. Projects in this state should be rare.
+# The worker will reject doing anything for projects that *do* have a
+# namespace. For those use ProjectDestroyWorker instead.
+class NamespacelessProjectDestroyWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ def self.bulk_perform_async(args_list)
+ Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list)
+ end
+
+ def perform(project_id)
+ begin
+ project = Project.unscoped.find(project_id)
+ rescue ActiveRecord::RecordNotFound
+ return
+ end
+ return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace
+
+ project.team.truncate
+
+ unlink_fork(project) if project.forked?
+
+ project.destroy!
+ end
+
+ private
+
+ def unlink_fork(project)
+ merge_requests = project.forked_from_project.merge_requests.opened.from_project(project)
+
+ merge_requests.update_all(state: 'closed')
+
+ project.forked_project_link.destroy
+ end
+end
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
new file mode 100644
index 00000000000..d9a8e892e90
--- /dev/null
+++ b/app/workers/new_issue_worker.rb
@@ -0,0 +1,17 @@
+class NewIssueWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+ include NewIssuable
+
+ def perform(issue_id, user_id)
+ return unless objects_found?(issue_id, user_id)
+
+ EventCreateService.new.open_issue(issuable, user)
+ NotificationService.new.new_issue(issuable, user)
+ issuable.create_cross_references!(user)
+ end
+
+ def issuable_class
+ Issue
+ end
+end
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
new file mode 100644
index 00000000000..1910c490159
--- /dev/null
+++ b/app/workers/new_merge_request_worker.rb
@@ -0,0 +1,17 @@
+class NewMergeRequestWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+ include NewIssuable
+
+ def perform(merge_request_id, user_id)
+ return unless objects_found?(merge_request_id, user_id)
+
+ EventCreateService.new.open_mr(issuable, user)
+ NotificationService.new.new_merge_request(issuable, user)
+ issuable.create_cross_references!(user)
+ end
+
+ def issuable_class
+ MergeRequest
+ end
+end
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index 4eeb9666bb0..64788da7299 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -4,7 +4,7 @@ class PagesWorker
sidekiq_options queue: :pages, retry: false
def perform(action, *arg)
- send(action, *arg)
+ send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
def deploy(build_id)
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
new file mode 100644
index 00000000000..d7087f20dfc
--- /dev/null
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -0,0 +1,22 @@
+class PipelineScheduleWorker
+ include Sidekiq::Worker
+ include CronjobQueue
+
+ def perform
+ Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
+ .preload(:owner, :project).find_each do |schedule|
+ begin
+ pipeline = Ci::CreatePipelineService.new(schedule.project,
+ schedule.owner,
+ ref: schedule.ref)
+ .execute(:schedule, save_on_errors: false, schedule: schedule)
+
+ schedule.deactivate! unless pipeline.persisted?
+ rescue => e
+ Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
+ ensure
+ schedule.schedule_next_run!
+ end
+ end
+ end
+end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 015a41b6e82..b8f8d3750d9 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -2,34 +2,34 @@ class PostReceive
include Sidekiq::Worker
include DedicatedSidekiqQueue
- def perform(repo_path, identifier, changes)
- repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path)
+ def perform(gl_repository, identifier, changes)
+ project, is_wiki = Gitlab::GlRepository.parse(gl_repository)
+
+ if project.nil?
+ log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"")
+ return false
+ end
changes = Base64.decode64(changes) unless changes.include?(' ')
# Use Sidekiq.logger so arguments can be correlated with execution
# time and thread ID's.
Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
- post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes)
-
- if post_received.project.nil?
- log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"")
- return false
- end
+ post_received = Gitlab::GitPostReceive.new(project, identifier, changes)
- if post_received.wiki?
- # Nothing defined here yet.
- elsif post_received.regular_project?
- process_project_changes(post_received)
+ if is_wiki
+ process_wiki_changes(post_received)
else
- log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"")
- false
+ process_project_changes(post_received)
end
end
+ private
+
def process_project_changes(post_received)
- post_received.changes.each do |change|
- oldrev, newrev, ref = change.strip.split(' ')
+ changes = []
+ refs = Set.new
+ post_received.changes_refs do |oldrev, newrev, ref|
@user ||= post_received.identify(newrev)
unless @user
@@ -42,10 +42,22 @@ class PostReceive
elsif Gitlab::Git.branch_ref?(ref)
GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute
end
+
+ changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
+ refs << ref
end
+
+ after_project_changes_hooks(post_received, @user, refs.to_a, changes)
end
- private
+ def after_project_changes_hooks(post_received, user, refs, changes)
+ hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs)
+ SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
+ end
+
+ def process_wiki_changes(post_received)
+ # Nothing defined here yet.
+ end
def log(message)
Gitlab::GitLogger.error("POST-RECEIVE: #{message}")
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 2f7967cf531..c0c03848a40 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -17,12 +17,14 @@ class ProcessCommitWorker
project = Project.find_by(id: project_id)
return unless project
+ return if commit_exists_in_upstream?(project, commit_hash)
user = User.find_by(id: user_id)
return unless user
commit = build_commit(project, commit_hash)
+
author = commit.author || user
process_commit_message(project, commit, user, author, default)
@@ -45,8 +47,8 @@ class ProcessCommitWorker
# therefor we use IssueCollection here and skip the authorization check in
# Issues::CloseService#execute.
IssueCollection.new(issues).updatable_by_user(user).each do |issue|
- Issues::CloseService.new(project, author).
- close_issue(issue, commit: commit)
+ Issues::CloseService.new(project, author)
+ .close_issue(issue, commit: commit)
end
end
@@ -55,8 +57,8 @@ class ProcessCommitWorker
return if mentioned_issues.empty?
- Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil).
- update_all(first_mentioned_in_commit_at: commit.committed_date)
+ Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil)
+ .update_all(first_mentioned_in_commit_at: commit.committed_date)
end
def build_commit(project, hash)
@@ -73,4 +75,16 @@ class ProcessCommitWorker
Commit.from_hash(hash, project)
end
+
+ private
+
+ # Avoid reprocessing commits that already exist in the upstream
+ # when project is forked. This will also prevent duplicated system notes.
+ def commit_exists_in_upstream?(project, commit_hash)
+ return false unless project.forked?
+
+ upstream_project = project.forked_from_project
+ commit_id = commit_hash.with_indifferent_access[:id]
+ upstream_project.commit(commit_id).present?
+ end
end
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 8ff9d07860f..505ff9e086e 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -32,8 +32,8 @@ class ProjectCacheWorker
private
def try_obtain_lease_for(project_id, section)
- Gitlab::ExclusiveLease.
- new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT).
- try_obtain
+ Gitlab::ExclusiveLease
+ .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT)
+ .try_obtain
end
end
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index b462327490e..a9188b78460 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -3,14 +3,11 @@ class ProjectDestroyWorker
include DedicatedSidekiqQueue
def perform(project_id, user_id, params)
- begin
- project = Project.unscoped.find(project_id)
- rescue ActiveRecord::RecordNotFound
- return
- end
-
+ project = Project.find(project_id)
user = User.find(user_id)
::Projects::DestroyService.new(project, user, params.symbolize_keys).execute
+ rescue ActiveRecord::RecordNotFound => error
+ logger.error("Failed to delete project (#{project_id}): #{error.message}")
end
end
diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb
index fdfdeab7b41..4883d848c53 100644
--- a/app/workers/project_service_worker.rb
+++ b/app/workers/project_service_worker.rb
@@ -2,6 +2,8 @@ class ProjectServiceWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
+ sidekiq_options dead: false
+
def perform(hook_id, data)
data = data.with_indifferent_access
Service.find(hook_id).execute(data)
diff --git a/app/workers/project_web_hook_worker.rb b/app/workers/project_web_hook_worker.rb
deleted file mode 100644
index d973e662ff2..00000000000
--- a/app/workers/project_web_hook_worker.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-class ProjectWebHookWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- sidekiq_options retry: 4
-
- def perform(hook_id, data, hook_name)
- data = data.with_indifferent_access
- WebHook.find(hook_id).execute(data, hook_name)
- end
-end
diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb
new file mode 100644
index 00000000000..6b607451c7a
--- /dev/null
+++ b/app/workers/propagate_service_template_worker.rb
@@ -0,0 +1,21 @@
+# Worker for updating any project specific caches.
+class PropagateServiceTemplateWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ LEASE_TIMEOUT = 4.hours.to_i
+
+ def perform(template_id)
+ return unless try_obtain_lease_for(template_id)
+
+ Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id))
+ end
+
+ private
+
+ def try_obtain_lease_for(template_id)
+ Gitlab::ExclusiveLease
+ .new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT)
+ .try_obtain
+ end
+end
diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb
index 392abb9c21b..2b43bb19ad1 100644
--- a/app/workers/prune_old_events_worker.rb
+++ b/app/workers/prune_old_events_worker.rb
@@ -10,9 +10,9 @@ class PruneOldEventsWorker
'(id IN (SELECT id FROM (?) ids_to_remove))',
Event.unscoped.where(
'created_at < ?',
- (12.months + 1.day).ago).
- select(:id).
- limit(10_000)).
- delete_all
+ (12.months + 1.day).ago)
+ .select(:id)
+ .limit(10_000))
+ .delete_all
end
end
diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb
new file mode 100644
index 00000000000..555e1bb8691
--- /dev/null
+++ b/app/workers/remove_old_web_hook_logs_worker.rb
@@ -0,0 +1,10 @@
+class RemoveOldWebHookLogsWorker
+ include Sidekiq::Worker
+ include CronjobQueue
+
+ WEB_HOOK_LOG_LIFETIME = 2.days
+
+ def perform
+ WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME])
+ end
+end
diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb
index c3e7491ec4e..b94d83bd709 100644
--- a/app/workers/repository_check/batch_worker.rb
+++ b/app/workers/repository_check/batch_worker.rb
@@ -32,10 +32,10 @@ module RepositoryCheck
# has to sit and wait for this query to finish.
def project_ids
limit = 10_000
- never_checked_projects = Project.where('last_repository_check_at IS NULL AND created_at < ?', 24.hours.ago).
- limit(limit).pluck(:id)
- old_check_projects = Project.where('last_repository_check_at < ?', 1.month.ago).
- reorder('last_repository_check_at ASC').limit(limit).pluck(:id)
+ never_checked_projects = Project.where('last_repository_check_at IS NULL AND created_at < ?', 24.hours.ago)
+ .limit(limit).pluck(:id)
+ old_check_projects = Project.where('last_repository_check_at < ?', 1.month.ago)
+ .reorder('last_repository_check_at ASC').limit(limit).pluck(:id)
never_checked_projects + old_check_projects
end
diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb
index 1f1b38540ee..85bc9103538 100644
--- a/app/workers/repository_check/clear_worker.rb
+++ b/app/workers/repository_check/clear_worker.rb
@@ -8,7 +8,7 @@ module RepositoryCheck
Project.select(:id).find_in_batches(batch_size: 100) do |batch|
Project.where(id: batch.map(&:id)).update_all(
last_repository_check_failed: nil,
- last_repository_check_at: nil,
+ last_repository_check_at: nil
)
end
end
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index 3d8bfc6fc6c..164586cf0b7 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -7,7 +7,7 @@ module RepositoryCheck
project = Project.find(project_id)
project.update_columns(
last_repository_check_failed: !check(project),
- last_repository_check_at: Time.now,
+ last_repository_check_at: Time.now
)
end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index efc99ec962a..cde5b45ad41 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,36 +1,50 @@
class RepositoryForkWorker
+ ForkError = Class.new(StandardError)
+
include Sidekiq::Worker
include Gitlab::ShellAdapter
include DedicatedSidekiqQueue
+ sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
+
def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
+ project = Project.find(project_id)
+
+ return unless start_fork(project)
+
Gitlab::Metrics.add_event(:fork_repository,
source_path: source_path,
target_path: target_path)
- project = Project.find_by_id(project_id)
-
- unless project.present?
- logger.error("Project #{project_id} no longer exists!")
- return
- end
-
result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path,
project.repository_storage_path, target_path)
- unless result
- logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}")
- project.mark_import_as_failed('The project could not be forked.')
- return
- end
+ raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result
project.repository.after_import
-
- unless project.valid_repo?
- logger.error("Project #{project_id} had an invalid repository after fork")
- project.mark_import_as_failed('The forked repository is invalid.')
- return
- end
+ raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
project.import_finish
+ rescue ForkError => ex
+ fail_fork(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_fork(project, ex.message)
+ raise ForkError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def start_fork(project)
+ return true if project.import_start
+
+ Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.")
+ false
+ end
+
+ def fail_fork(project, message)
+ Rails.logger.error(message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index b33ba2ed7c1..2c2d1e8b91f 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,29 +1,45 @@
class RepositoryImportWorker
+ ImportError = Class.new(StandardError)
+
include Sidekiq::Worker
include DedicatedSidekiqQueue
- sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_EXPIRATION
-
- attr_accessor :project, :current_user
+ sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
def perform(project_id)
- @project = Project.find(project_id)
- @current_user = @project.creator
-
- Gitlab::Metrics.add_event(:import_repository,
- import_url: @project.import_url,
- path: @project.path_with_namespace)
+ project = Project.find(project_id)
- project.update_columns(import_jid: self.jid, import_error: nil)
+ return unless start_import(project)
- result = Projects::ImportService.new(project, current_user).execute
+ Gitlab::Metrics.add_event(:import_repository,
+ import_url: project.import_url,
+ path: project.full_path)
- if result[:status] == :error
- project.mark_import_as_failed(result[:message])
- return
- end
+ result = Projects::ImportService.new(project, project.creator).execute
+ raise ImportError, result[:message] if result[:status] == :error
project.repository.after_import
project.import_finish
+ rescue ImportError => ex
+ fail_import(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_import(project, ex.message)
+ raise ImportError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def start_import(project)
+ return true if project.import_start
+
+ Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.")
+ false
+ end
+
+ def fail_import(project, message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index ae8c980c9e4..8b0cfcc8af8 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -45,7 +45,7 @@ class StuckCiJobsWorker
def search(status, timeout)
builds = Ci::Build.where(status: status).where('ci_builds.updated_at < ?', timeout.ago)
- builds.joins(:project).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build|
+ builds.joins(:project).merge(Project.without_deleted).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build|
yield(build)
end
end
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index bfc5e667bb6..f850e459cd9 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -2,36 +2,60 @@ class StuckImportJobsWorker
include Sidekiq::Worker
include CronjobQueue
- IMPORT_EXPIRATION = 15.hours.to_i
+ IMPORT_JOBS_EXPIRATION = 15.hours.to_i
def perform
- stuck_projects.find_in_batches(batch_size: 500) do |group|
+ projects_without_jid_count = mark_projects_without_jid_as_failed!
+ projects_with_jid_count = mark_projects_with_jid_as_failed!
+
+ Gitlab::Metrics.add_event(:stuck_import_jobs,
+ projects_without_jid_count: projects_without_jid_count,
+ projects_with_jid_count: projects_with_jid_count)
+ end
+
+ private
+
+ def mark_projects_without_jid_as_failed!
+ started_projects_without_jid.each do |project|
+ project.mark_import_as_failed(error_message)
+ end.count
+ end
+
+ def mark_projects_with_jid_as_failed!
+ completed_jids_count = 0
+
+ started_projects_with_jid.find_in_batches(batch_size: 500) do |group|
jids = group.map(&:import_jid)
# Find the jobs that aren't currently running or that exceeded the threshold.
- completed_jids = Gitlab::SidekiqStatus.completed_jids(jids)
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(jids).to_set
if completed_jids.any?
- completed_ids = group.select { |project| completed_jids.include?(project.import_jid) }.map(&:id)
+ completed_jids_count += completed_jids.count
+ group.each do |project|
+ project.mark_import_as_failed(error_message) if completed_jids.include?(project.import_jid)
+ end
- fail_batch!(completed_jids, completed_ids)
+ Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.to_a.join(', ')}")
end
end
- end
- private
+ completed_jids_count
+ end
- def stuck_projects
- Project.select('id, import_jid').with_import_status(:started).where.not(import_jid: nil)
+ def started_projects
+ Project.with_import_status(:started)
end
- def fail_batch!(completed_jids, completed_ids)
- Project.where(id: completed_ids).update_all(import_status: 'failed', import_error: error_message)
+ def started_projects_with_jid
+ started_projects.where.not(import_jid: nil)
+ end
- Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.join(', ')}")
+ def started_projects_without_jid
+ started_projects.where(import_jid: nil)
end
def error_message
- "Import timed out. Import took longer than #{IMPORT_EXPIRATION} seconds"
+ "Import timed out. Import took longer than #{IMPORT_JOBS_EXPIRATION} seconds"
end
end
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
new file mode 100644
index 00000000000..7843179d77c
--- /dev/null
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -0,0 +1,34 @@
+class StuckMergeJobsWorker
+ include Sidekiq::Worker
+ include CronjobQueue
+
+ def perform
+ stuck_merge_requests.find_in_batches(batch_size: 100) do |group|
+ jids = group.map(&:merge_jid)
+
+ # Find the jobs that aren't currently running or that exceeded the threshold.
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(jids)
+
+ if completed_jids.any?
+ completed_ids = group.select { |merge_request| completed_jids.include?(merge_request.merge_jid) }.map(&:id)
+
+ apply_current_state!(completed_jids, completed_ids)
+ end
+ end
+ end
+
+ private
+
+ def apply_current_state!(completed_jids, completed_ids)
+ merge_requests = MergeRequest.where(id: completed_ids)
+
+ merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged)
+ merge_requests.where(merge_commit_sha: nil).update_all(state: :opened)
+
+ Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
+ end
+
+ def stuck_merge_requests
+ MergeRequest.select('id, merge_jid').with_state(:locked).where.not(merge_jid: nil).reorder(nil)
+ end
+end
diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb
deleted file mode 100644
index 55d4e7d6dab..00000000000
--- a/app/workers/system_hook_worker.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-class SystemHookWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- sidekiq_options retry: 4
-
- def perform(hook_id, data, hook_name)
- SystemHook.find(hook_id).execute(data, hook_name)
- end
-end
diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb
deleted file mode 100644
index 9c1baf7e6c5..00000000000
--- a/app/workers/trigger_schedule_worker.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-class TriggerScheduleWorker
- include Sidekiq::Worker
- include CronjobQueue
-
- def perform
- 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,
- trigger_schedule.ref)
- rescue => e
- Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}"
- ensure
- trigger_schedule.schedule_next_run!
- end
- end
- end
-end
diff --git a/app/workers/update_user_activity_worker.rb b/app/workers/update_user_activity_worker.rb
index b3c2f13aa33..31bbdb69edb 100644
--- a/app/workers/update_user_activity_worker.rb
+++ b/app/workers/update_user_activity_worker.rb
@@ -7,8 +7,8 @@ class UpdateUserActivityWorker
ids = pairs.keys
conditions = 'WHEN id = ? THEN ? ' * ids.length
- User.where(id: ids).
- update_all([
+ User.where(id: ids)
+ .update_all([
"last_activity_on = CASE #{conditions} ELSE last_activity_on END",
*pairs.to_a.flatten
])
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
new file mode 100644
index 00000000000..713c0228040
--- /dev/null
+++ b/app/workers/web_hook_worker.rb
@@ -0,0 +1,13 @@
+class WebHookWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ sidekiq_options retry: 4, dead: false
+
+ def perform(hook_id, data, hook_name)
+ hook = WebHook.find(hook_id)
+ data = data.with_indifferent_access
+
+ WebHookService.new(hook, data, hook_name).execute
+ end
+end
diff --git a/bin/changelog b/bin/changelog
index 4c894f8ff5b..61d4de06e90 100755
--- a/bin/changelog
+++ b/bin/changelog
@@ -14,54 +14,107 @@ Options = Struct.new(
:dry_run,
:force,
:merge_request,
- :title
+ :title,
+ :type
)
+INVALID_TYPE = -1
class ChangelogOptionParser
- def self.parse(argv)
- options = Options.new
+ Type = Struct.new(:name, :description)
+ TYPES = [
+ Type.new('added', 'New feature'),
+ Type.new('fixed', 'Bug fix'),
+ Type.new('changed', 'Feature change'),
+ Type.new('deprecated', 'New deprecation'),
+ Type.new('removed', 'Feature removal'),
+ Type.new('security', 'Security fix'),
+ Type.new('other', 'Other')
+ ].freeze
+ TYPES_OFFSET = 1
+
+ class << self
+ def parse(argv)
+ options = Options.new
+
+ parser = OptionParser.new do |opts|
+ opts.banner = "Usage: #{__FILE__} [options] [title]\n\n"
+
+ # Note: We do not provide a shorthand for this in order to match the `git
+ # commit` interface
+ opts.on('--amend', 'Amend the previous commit') do |value|
+ options.amend = value
+ end
+
+ opts.on('-f', '--force', 'Overwrite an existing entry') do |value|
+ options.force = value
+ end
+
+ opts.on('-m', '--merge-request [integer]', Integer, 'Merge Request ID') do |value|
+ options.merge_request = value
+ end
+
+ opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value|
+ options.dry_run = value
+ end
+
+ opts.on('-u', '--git-username', 'Use Git user.name configuration as the author') do |value|
+ options.author = git_user_name if value
+ end
+
+ opts.on('-t', '--type [string]', String, "The category of the change, valid options are: #{TYPES.map(&:name).join(', ')}") do |value|
+ options.type = parse_type(value)
+ end
+
+ opts.on('-h', '--help', 'Print help message') do
+ $stdout.puts opts
+ exit
+ end
+ end
- parser = OptionParser.new do |opts|
- opts.banner = "Usage: #{__FILE__} [options] [title]\n\n"
+ parser.parse!(argv)
- # Note: We do not provide a shorthand for this in order to match the `git
- # commit` interface
- opts.on('--amend', 'Amend the previous commit') do |value|
- options.amend = value
- end
+ # Title is everything that remains, but let's clean it up a bit
+ options.title = argv.join(' ').strip.squeeze(' ').tr("\r\n", '')
- opts.on('-f', '--force', 'Overwrite an existing entry') do |value|
- options.force = value
- end
+ options
+ end
- opts.on('-m', '--merge-request [integer]', Integer, 'Merge Request ID') do |value|
- options.merge_request = value
- end
+ def read_type
+ read_type_message
- opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value|
- options.dry_run = value
- end
+ type = TYPES[$stdin.getc.to_i - TYPES_OFFSET]
+ assert_valid_type!(type)
- opts.on('-u', '--git-username', 'Use Git user.name configuration as the author') do |value|
- options.author = git_user_name if value
- end
+ type.name
+ end
+
+ private
- opts.on('-h', '--help', 'Print help message') do
- $stdout.puts opts
- exit
+ def parse_type(name)
+ type_found = TYPES.find do |type|
+ type.name == name
end
+ type_found ? type_found.name : INVALID_TYPE
end
- parser.parse!(argv)
-
- # Title is everything that remains, but let's clean it up a bit
- options.title = argv.join(' ').strip.squeeze(' ').tr("\r\n", '')
+ def read_type_message
+ $stdout.puts "\n>> Please specify the index for the category of your change:"
+ TYPES.each_with_index do |type, index|
+ $stdout.puts "#{index + TYPES_OFFSET}. #{type.description}"
+ end
+ $stdout.print "\n?> "
+ end
- options
- end
+ def assert_valid_type!(type)
+ unless type
+ $stderr.puts "Invalid category index, please select an index between 1 and #{TYPES.length}"
+ exit 1
+ end
+ end
- def self.git_user_name
- %x{git config user.name}.strip
+ def git_user_name
+ %x{git config user.name}.strip
+ end
end
end
@@ -72,8 +125,12 @@ class ChangelogEntry
@options = options
assert_feature_branch!
- assert_new_file!
assert_title!
+ assert_new_file!
+
+ # Read type from $stdin unless is already set
+ options.type ||= ChangelogOptionParser.read_type
+ assert_valid_type!
$stdout.puts "\e[32mcreate\e[0m #{file_path}"
$stdout.puts contents
@@ -90,7 +147,8 @@ class ChangelogEntry
yaml_content = YAML.dump(
'title' => title,
'merge_request' => options.merge_request,
- 'author' => options.author
+ 'author' => options.author,
+ 'type' => options.type
)
remove_trailing_whitespace(yaml_content)
end
@@ -129,6 +187,12 @@ class ChangelogEntry
" to use the title from the previous commit."
end
+ def assert_valid_type!
+ return unless options.type && options.type == INVALID_TYPE
+
+ fail_with 'Invalid category given!'
+ end
+
def title
if options.title.empty?
last_commit_subject
diff --git a/bin/ci/upgrade.rb b/bin/ci/upgrade.rb
deleted file mode 100755
index aab4f60ec60..00000000000
--- a/bin/ci/upgrade.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-require_relative "../lib/ci/upgrader"
-
-Ci::Upgrader.new.execute
diff --git a/bin/rspec-stackprof b/bin/rspec-stackprof
index df79feb201d..810863ea4a0 100755
--- a/bin/rspec-stackprof
+++ b/bin/rspec-stackprof
@@ -1,5 +1,6 @@
#!/usr/bin/env ruby
+require 'bundler/setup'
require 'stackprof'
$:.unshift 'spec'
require 'rails_helper'
@@ -13,4 +14,4 @@ StackProf.run(mode: :wall, out: output_file, interval: interval) do
RSpec::Core::Runner.run(ARGV, $stderr, $stdout)
end
-system("stackprof #{output_file} --text --limit #{limit}")
+system("bundle exec stackprof #{output_file} --text --limit #{limit}")
diff --git a/changelogs/unreleased/10085-stop-encoding-user-name.yml b/changelogs/unreleased/10085-stop-encoding-user-name.yml
new file mode 100644
index 00000000000..8fab474e047
--- /dev/null
+++ b/changelogs/unreleased/10085-stop-encoding-user-name.yml
@@ -0,0 +1,4 @@
+---
+title: "Insert user name directly without encoding"
+merge_request: 10085
+author: Nathan Neulinger <nneul@neulinger.org>
diff --git a/changelogs/unreleased/12673-fix_v3_project_hooks_build_events b/changelogs/unreleased/12673-fix_v3_project_hooks_build_events
new file mode 100644
index 00000000000..59bc646406f
--- /dev/null
+++ b/changelogs/unreleased/12673-fix_v3_project_hooks_build_events
@@ -0,0 +1,4 @@
+---
+title: "Fix v3 api project_hooks POST and PUT operations for build_events"
+merge_request: 12673
+author: Richard Clamp
diff --git a/changelogs/unreleased/12892-reset-css-text-align-to-initial-for-rtl.md b/changelogs/unreleased/12892-reset-css-text-align-to-initial-for-rtl.md
new file mode 100644
index 00000000000..87e95240bba
--- /dev/null
+++ b/changelogs/unreleased/12892-reset-css-text-align-to-initial-for-rtl.md
@@ -0,0 +1,4 @@
+---
+title: "reset text-align to initial to let elements with dir="auto" align texts to right in RTL languages ( default is left )"
+merge_request: 12892
+author: goshhob
diff --git a/changelogs/unreleased/12910-personal-snippet-prep-2.yml b/changelogs/unreleased/12910-personal-snippet-prep-2.yml
deleted file mode 100644
index bd9527c30c8..00000000000
--- a/changelogs/unreleased/12910-personal-snippet-prep-2.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Support Markdown previews for personal snippets
-merge_request: 10810
-author:
diff --git a/changelogs/unreleased/13247-api_project_events_target_iid.yml b/changelogs/unreleased/13247-api_project_events_target_iid.yml
new file mode 100644
index 00000000000..08a31039f77
--- /dev/null
+++ b/changelogs/unreleased/13247-api_project_events_target_iid.yml
@@ -0,0 +1,4 @@
+---
+title: Expose target_iid in Events API
+merge_request: 13247
+author: sue445
diff --git a/changelogs/unreleased/13265-project_events_noteable_iid.yml b/changelogs/unreleased/13265-project_events_noteable_iid.yml
new file mode 100644
index 00000000000..54d538bb548
--- /dev/null
+++ b/changelogs/unreleased/13265-project_events_noteable_iid.yml
@@ -0,0 +1,4 @@
+---
+title: Expose noteable_iid in Note
+merge_request: 13265
+author: sue445
diff --git a/changelogs/unreleased/13325-bugfix-silence-on-disabled-notifications.yml b/changelogs/unreleased/13325-bugfix-silence-on-disabled-notifications.yml
new file mode 100644
index 00000000000..90b169390d2
--- /dev/null
+++ b/changelogs/unreleased/13325-bugfix-silence-on-disabled-notifications.yml
@@ -0,0 +1,6 @@
+---
+title: disabling notifications globally now properly turns off group/project added
+ emails
+merge_request: 13325
+author: @jneen
+type: fixed
diff --git a/changelogs/unreleased/1440-db-backup-ssl-support.yml b/changelogs/unreleased/1440-db-backup-ssl-support.yml
deleted file mode 100644
index c78bb4fd351..00000000000
--- a/changelogs/unreleased/1440-db-backup-ssl-support.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Database SSL support for backup script.
-merge_request: 9715
-author: Guillaume Simon
diff --git a/changelogs/unreleased/1827-prevent-concurrent-editing-wiki.yml b/changelogs/unreleased/1827-prevent-concurrent-editing-wiki.yml
new file mode 100644
index 00000000000..c8c2bb3eb4c
--- /dev/null
+++ b/changelogs/unreleased/1827-prevent-concurrent-editing-wiki.yml
@@ -0,0 +1,4 @@
+---
+title: Alert the user if a Wiki page changed while they were editing it in order to prevent overwriting changes.
+merge_request: 9707
+author: Hiroyuki Sato
diff --git a/changelogs/unreleased/19629-remove-inactive-tokens-list.yml b/changelogs/unreleased/19629-remove-inactive-tokens-list.yml
new file mode 100644
index 00000000000..414e3d49e29
--- /dev/null
+++ b/changelogs/unreleased/19629-remove-inactive-tokens-list.yml
@@ -0,0 +1,4 @@
+---
+title: Remove Inactive Personal Access Tokens list from Access Tokens page
+merge_request: 12866
+author:
diff --git a/changelogs/unreleased/20378-natural-sort-issue-numbers.yml b/changelogs/unreleased/20378-natural-sort-issue-numbers.yml
deleted file mode 100644
index 2ebc8485ddf..00000000000
--- a/changelogs/unreleased/20378-natural-sort-issue-numbers.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Change issues list in MR to natural sorting
-merge_request: 7110
-author: Jeff Stubler
diff --git a/changelogs/unreleased/20817-please-add-coordinator-url-to-admin-area-runner-page.yml b/changelogs/unreleased/20817-please-add-coordinator-url-to-admin-area-runner-page.yml
new file mode 100644
index 00000000000..c4c3fc7ceb2
--- /dev/null
+++ b/changelogs/unreleased/20817-please-add-coordinator-url-to-admin-area-runner-page.yml
@@ -0,0 +1,4 @@
+---
+title: Add coordinator url to admin area runner page
+merge_request: 11603
+author:
diff --git a/changelogs/unreleased/21683-show-created-group-name-flash.yml b/changelogs/unreleased/21683-show-created-group-name-flash.yml
deleted file mode 100644
index 06ef5e972fc..00000000000
--- a/changelogs/unreleased/21683-show-created-group-name-flash.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Show group name on flash container when group is created from Admin area.
-merge_request: 10905
-author:
diff --git a/changelogs/unreleased/21949-add-type-to-changelog.yml b/changelogs/unreleased/21949-add-type-to-changelog.yml
new file mode 100644
index 00000000000..a20f6b7ad4e
--- /dev/null
+++ b/changelogs/unreleased/21949-add-type-to-changelog.yml
@@ -0,0 +1,4 @@
+---
+title: Added type to CHANGELOG entries
+merge_request:
+author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/2246-uuid-is-nil-for-new-installation.yml b/changelogs/unreleased/2246-uuid-is-nil-for-new-installation.yml
deleted file mode 100644
index 70d35f06af4..00000000000
--- a/changelogs/unreleased/2246-uuid-is-nil-for-new-installation.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Lazily sets UUID in ApplicationSetting for new installations
-merge_request:
-author:
diff --git a/changelogs/unreleased/22600-related-resources-uris-using-grape-source-helpers.yml b/changelogs/unreleased/22600-related-resources-uris-using-grape-source-helpers.yml
new file mode 100644
index 00000000000..837a34bd067
--- /dev/null
+++ b/changelogs/unreleased/22600-related-resources-uris-using-grape-source-helpers.yml
@@ -0,0 +1,4 @@
+---
+title: Declare related resources into V4 API entities
+merge_request:
+author:
diff --git a/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml b/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml
deleted file mode 100644
index ad7c011933f..00000000000
--- a/changelogs/unreleased/22714-update-all-instances-of-fa-refresh.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Update all instances of the old loading icon
-merge_request: 10490
-author: Andrew Torres
diff --git a/changelogs/unreleased/22826-ui-inconsistency-different-files-views-find-file-button-missing.yml b/changelogs/unreleased/22826-ui-inconsistency-different-files-views-find-file-button-missing.yml
deleted file mode 100644
index c42fbd4e1f1..00000000000
--- a/changelogs/unreleased/22826-ui-inconsistency-different-files-views-find-file-button-missing.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix UI inconsistency different files view (find file button missing)
-merge_request: 9847
-author: TM Lee
diff --git a/changelogs/unreleased/23036-replace-dashboard-event-filters-spinach.yml b/changelogs/unreleased/23036-replace-dashboard-event-filters-spinach.yml
new file mode 100644
index 00000000000..807cd097178
--- /dev/null
+++ b/changelogs/unreleased/23036-replace-dashboard-event-filters-spinach.yml
@@ -0,0 +1,4 @@
+---
+title: Replaces dashboard/event_filters.feature spinach with rspec
+merge_request: 12651
+author: Alexander Randa (@randaalex)
diff --git a/changelogs/unreleased/23036-replace-dashboard-spinach.yml b/changelogs/unreleased/23036-replace-dashboard-spinach.yml
new file mode 100644
index 00000000000..b3197c4cfa6
--- /dev/null
+++ b/changelogs/unreleased/23036-replace-dashboard-spinach.yml
@@ -0,0 +1,4 @@
+---
+title: Replaces dashboard/dashboard.feature spinach with rspec
+merge_request: 12876
+author: Alexander Randa (@randaalex)
diff --git a/changelogs/unreleased/26208-animate-drodowns.yml b/changelogs/unreleased/26208-animate-drodowns.yml
deleted file mode 100644
index 580f6c12f67..00000000000
--- a/changelogs/unreleased/26208-animate-drodowns.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add animations to all the dropdowns
-merge_request: 8419
-author:
diff --git a/changelogs/unreleased/26372-duplicate-issue-slash-command.yml b/changelogs/unreleased/26372-duplicate-issue-slash-command.yml
new file mode 100644
index 00000000000..3108344e0bf
--- /dev/null
+++ b/changelogs/unreleased/26372-duplicate-issue-slash-command.yml
@@ -0,0 +1,4 @@
+---
+title: Added /duplicate quick action to close a duplicate issue
+merge_request: 12845
+author: Ryan Scott
diff --git a/changelogs/unreleased/26437-closed-by.yml b/changelogs/unreleased/26437-closed-by.yml
deleted file mode 100644
index 6325d3576bc..00000000000
--- a/changelogs/unreleased/26437-closed-by.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add issues/:iid/closed_by api endpoint
-merge_request:
-author: mhasbini
diff --git a/changelogs/unreleased/26509-show-update-time.yml b/changelogs/unreleased/26509-show-update-time.yml
deleted file mode 100644
index 012fd00dd87..00000000000
--- a/changelogs/unreleased/26509-show-update-time.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add update time to project lists.
-merge_request: 8514
-author: Jeff Stubler
diff --git a/changelogs/unreleased/26585-remove-readme-view-caching.yml b/changelogs/unreleased/26585-remove-readme-view-caching.yml
deleted file mode 100644
index 6aefae982bf..00000000000
--- a/changelogs/unreleased/26585-remove-readme-view-caching.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: 'Remove view fragment caching for project READMEs'
-merge_request: 8838
-author:
diff --git a/changelogs/unreleased/27376-bvl-load-pipelinestatus-in-batch.yml b/changelogs/unreleased/27376-bvl-load-pipelinestatus-in-batch.yml
deleted file mode 100644
index 3d615f5d8a7..00000000000
--- a/changelogs/unreleased/27376-bvl-load-pipelinestatus-in-batch.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fetch pipeline status in batch from redis
-merge_request: 10785
-author:
diff --git a/changelogs/unreleased/27616-fix-contributions-graph-utc-offset-mysql.yml b/changelogs/unreleased/27616-fix-contributions-graph-utc-offset-mysql.yml
new file mode 100644
index 00000000000..1b3c3b8538d
--- /dev/null
+++ b/changelogs/unreleased/27616-fix-contributions-graph-utc-offset-mysql.yml
@@ -0,0 +1,4 @@
+---
+title: Fix timezone inconsistencies in user contribution graph
+merge_request: 13208
+author:
diff --git a/changelogs/unreleased/27655-clear-emoji-search-after-selection.yml b/changelogs/unreleased/27655-clear-emoji-search-after-selection.yml
deleted file mode 100644
index 5fd02696323..00000000000
--- a/changelogs/unreleased/27655-clear-emoji-search-after-selection.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Clear emoji search in awards menu after picking emoji
-merge_request:
-author:
diff --git a/changelogs/unreleased/27729-improve-webpack-dev-environment.yml b/changelogs/unreleased/27729-improve-webpack-dev-environment.yml
deleted file mode 100644
index d04ea70ab1c..00000000000
--- a/changelogs/unreleased/27729-improve-webpack-dev-environment.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add webpack_bundle_tag helper to improve non-localhost GDK configurations
-merge_request: 10604
-author:
diff --git a/changelogs/unreleased/27827-cleanup-markdown.yml b/changelogs/unreleased/27827-cleanup-markdown.yml
deleted file mode 100644
index a8890b78763..00000000000
--- a/changelogs/unreleased/27827-cleanup-markdown.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Cleanup markdown spacing
-merge_request:
-author:
diff --git a/changelogs/unreleased/28017-separate-ce-params-on-api.yml b/changelogs/unreleased/28017-separate-ce-params-on-api.yml
deleted file mode 100644
index 039a8d207b0..00000000000
--- a/changelogs/unreleased/28017-separate-ce-params-on-api.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Separate CE params on Grape API
-merge_request:
-author:
diff --git a/changelogs/unreleased/28202_decrease_abc_threshold_step1.yml b/changelogs/unreleased/28202_decrease_abc_threshold_step1.yml
deleted file mode 100644
index 8f1520c8b42..00000000000
--- a/changelogs/unreleased/28202_decrease_abc_threshold_step1.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Decrease ABC threshold to 57.08
-merge_request: 10724
-author: Rydkin Maxim
diff --git a/changelogs/unreleased/28202_decrease_abc_threshold_step2.yml b/changelogs/unreleased/28202_decrease_abc_threshold_step2.yml
new file mode 100644
index 00000000000..b8f30b52b18
--- /dev/null
+++ b/changelogs/unreleased/28202_decrease_abc_threshold_step2.yml
@@ -0,0 +1,4 @@
+---
+title: Decrease ABC threshold to 56.96
+merge_request: 11227
+author: Maxim Rydkin
diff --git a/changelogs/unreleased/28472-ignore-auto-generated-mails.yml b/changelogs/unreleased/28472-ignore-auto-generated-mails.yml
new file mode 100644
index 00000000000..af63b43e62e
--- /dev/null
+++ b/changelogs/unreleased/28472-ignore-auto-generated-mails.yml
@@ -0,0 +1,4 @@
+---
+title: Don't send rejection mails for all auto-generated mails
+merge_request: 13254
+author:
diff --git a/changelogs/unreleased/28575-expand-collapse-look.yml b/changelogs/unreleased/28575-expand-collapse-look.yml
deleted file mode 100644
index d8943316300..00000000000
--- a/changelogs/unreleased/28575-expand-collapse-look.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Expand/collapse button -> Change to make it look like a toggle
-merge_request: 10720
-author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml b/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml
deleted file mode 100644
index 0ebb9d57611..00000000000
--- a/changelogs/unreleased/29056-backport-ee-cleanup-database-file.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Turns true value and false value database methods from instance to class methods
-merge_request: 10583
-author:
diff --git a/changelogs/unreleased/29289-project-destroy-clean-up-after-failure.yml b/changelogs/unreleased/29289-project-destroy-clean-up-after-failure.yml
new file mode 100644
index 00000000000..488b37ac37f
--- /dev/null
+++ b/changelogs/unreleased/29289-project-destroy-clean-up-after-failure.yml
@@ -0,0 +1,4 @@
+---
+title: Handle errors while a project is being deleted asynchronously.
+merge_request: 11088
+author:
diff --git a/changelogs/unreleased/29385-add_shrug_command.yml b/changelogs/unreleased/29385-add_shrug_command.yml
new file mode 100644
index 00000000000..14b8f486d5c
--- /dev/null
+++ b/changelogs/unreleased/29385-add_shrug_command.yml
@@ -0,0 +1,4 @@
+---
+title: Add /shrug and /tableflip commands
+merge_request: 10068
+author: Alex Ives
diff --git a/changelogs/unreleased/29505-allow-admins-sudo-to-blocked-users.yml b/changelogs/unreleased/29505-allow-admins-sudo-to-blocked-users.yml
deleted file mode 100644
index 42fd71ccd5f..00000000000
--- a/changelogs/unreleased/29505-allow-admins-sudo-to-blocked-users.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Allow admins to sudo to blocked users via the API
-merge_request: 10842
-author:
diff --git a/changelogs/unreleased/29595-customize-experience-callout.yml b/changelogs/unreleased/29595-customize-experience-callout.yml
deleted file mode 100644
index ec8393142c6..00000000000
--- a/changelogs/unreleased/29595-customize-experience-callout.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: 29595 Update callout design
-merge_request:
-author:
diff --git a/changelogs/unreleased/29712-unnecessary-wait-for-ajax.yml b/changelogs/unreleased/29712-unnecessary-wait-for-ajax.yml
deleted file mode 100644
index 8dc657a4aba..00000000000
--- a/changelogs/unreleased/29712-unnecessary-wait-for-ajax.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Remove unnecessary test helpers includes
-merge_request: 10567
-author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml b/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml
deleted file mode 100644
index ca4a8889454..00000000000
--- a/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Remove pipeline controls for last deployment from Environment monitoring page
-merge_request: 10769
-author:
diff --git a/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml b/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml
deleted file mode 100644
index 9c5df690085..00000000000
--- a/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add Slack slash command api to services documentation and rearrange order and
- cases
-merge_request: 10757
-author: TM Lee
diff --git a/changelogs/unreleased/29811-fix-line-number-alignment.yml b/changelogs/unreleased/29811-fix-line-number-alignment.yml
new file mode 100644
index 00000000000..94b3328a7f2
--- /dev/null
+++ b/changelogs/unreleased/29811-fix-line-number-alignment.yml
@@ -0,0 +1,4 @@
+---
+title: Fix the alignment of line numbers to lines of code in code viewer
+merge_request: 13403
+author: Trevor Flynn \ No newline at end of file
diff --git a/changelogs/unreleased/29816-create-keyboard-shortcut-for-editing-wiki-page.yml b/changelogs/unreleased/29816-create-keyboard-shortcut-for-editing-wiki-page.yml
deleted file mode 100644
index a165c70a6d3..00000000000
--- a/changelogs/unreleased/29816-create-keyboard-shortcut-for-editing-wiki-page.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add keyboard edit shotcut for wiki
-merge_request: 10245
-author: George Andrinopoulos
diff --git a/changelogs/unreleased/29901-refactor-initialization-dropzone_input-js.yml b/changelogs/unreleased/29901-refactor-initialization-dropzone_input-js.yml
new file mode 100644
index 00000000000..8850422fc88
--- /dev/null
+++ b/changelogs/unreleased/29901-refactor-initialization-dropzone_input-js.yml
@@ -0,0 +1,4 @@
+---
+title: refactor initializations in dropzone_input.js
+merge_request: 12768
+author: Brandon Everett
diff --git a/changelogs/unreleased/29903-remove-user-is-admin-flag-from-api.yml b/changelogs/unreleased/29903-remove-user-is-admin-flag-from-api.yml
deleted file mode 100644
index a0d497ac1e9..00000000000
--- a/changelogs/unreleased/29903-remove-user-is-admin-flag-from-api.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Don't display the is_admin flag in most API responses
-merge_request: 10846
-author:
diff --git a/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml b/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml
deleted file mode 100644
index c1640777e12..00000000000
--- a/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Added quick-update (fade-in) animation to newly rendered notes
-merge_request: 10623
-author:
diff --git a/changelogs/unreleased/30305-oauth-token-push-code.yml b/changelogs/unreleased/30305-oauth-token-push-code.yml
deleted file mode 100644
index aadfb5ca419..00000000000
--- a/changelogs/unreleased/30305-oauth-token-push-code.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Allow OAuth clients to push code
-merge_request: 10677
-author:
diff --git a/changelogs/unreleased/30349-create-users-build-service.yml b/changelogs/unreleased/30349-create-users-build-service.yml
deleted file mode 100644
index 49b571f5646..00000000000
--- a/changelogs/unreleased/30349-create-users-build-service.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Implement Users::BuildService
-merge_request: 30349
-author: George Andrinopoulos
diff --git a/changelogs/unreleased/30466-click-x-to-remove-filter.yml b/changelogs/unreleased/30466-click-x-to-remove-filter.yml
deleted file mode 100644
index 2cf08e84ed1..00000000000
--- a/changelogs/unreleased/30466-click-x-to-remove-filter.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add button to delete filters from filtered search bar
-merge_request:
-author:
diff --git a/changelogs/unreleased/30484-profile-dropdown-account-name.yml b/changelogs/unreleased/30484-profile-dropdown-account-name.yml
deleted file mode 100644
index 71aa1ce139b..00000000000
--- a/changelogs/unreleased/30484-profile-dropdown-account-name.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Added profile name to user dropdown
-merge_request:
-author:
diff --git a/changelogs/unreleased/30634-protected-pipeline.yml b/changelogs/unreleased/30634-protected-pipeline.yml
new file mode 100644
index 00000000000..e46538e5b46
--- /dev/null
+++ b/changelogs/unreleased/30634-protected-pipeline.yml
@@ -0,0 +1,5 @@
+---
+title: Disallow running the pipeline if ref is protected and user cannot merge the
+ branch or create the tag
+merge_request: 11910
+author:
diff --git a/changelogs/unreleased/30645-show-pipeline-events-description.yml b/changelogs/unreleased/30645-show-pipeline-events-description.yml
deleted file mode 100644
index fb75dde1d86..00000000000
--- a/changelogs/unreleased/30645-show-pipeline-events-description.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix pipeline events description for Slack and Mattermost integration
-merge_request: 10908
-author:
diff --git a/changelogs/unreleased/30672-versioned-markdown-cache.yml b/changelogs/unreleased/30672-versioned-markdown-cache.yml
deleted file mode 100644
index d8f977b01de..00000000000
--- a/changelogs/unreleased/30672-versioned-markdown-cache.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Replace rake cache:clear:db with an automatic mechanism
-merge_request: 10597
-author:
diff --git a/changelogs/unreleased/30678-improve-dev-server-process.yml b/changelogs/unreleased/30678-improve-dev-server-process.yml
deleted file mode 100644
index efa2fc210e3..00000000000
--- a/changelogs/unreleased/30678-improve-dev-server-process.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Keep webpack-dev-server process functional across branch changes
-merge_request: 10581
-author:
diff --git a/changelogs/unreleased/30973-fix-network-graph-ordering.yml b/changelogs/unreleased/30973-fix-network-graph-ordering.yml
deleted file mode 100644
index 420ec107842..00000000000
--- a/changelogs/unreleased/30973-fix-network-graph-ordering.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix ordering of commits in the network graph
-merge_request: 10936
-author:
diff --git a/changelogs/unreleased/31009-disable-test-settings-on-services-when-repository-is-empty.yml b/changelogs/unreleased/31009-disable-test-settings-on-services-when-repository-is-empty.yml
deleted file mode 100644
index 6e43a032f20..00000000000
--- a/changelogs/unreleased/31009-disable-test-settings-on-services-when-repository-is-empty.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Disable test settings on chat notification services when repository is empty
-merge_request: 10759
-author:
diff --git a/changelogs/unreleased/31129-jira-project-key-elim.yml b/changelogs/unreleased/31129-jira-project-key-elim.yml
new file mode 100644
index 00000000000..bfa0e99f250
--- /dev/null
+++ b/changelogs/unreleased/31129-jira-project-key-elim.yml
@@ -0,0 +1,4 @@
+---
+title: Remove project_key from the Jira configuration
+merge_request: 12050
+author:
diff --git a/changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml b/changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml
deleted file mode 100644
index cb1de425d66..00000000000
--- a/changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Improves test settings for chat notification services for empty projects
-merge_request: 10886
-author:
diff --git a/changelogs/unreleased/31193-ff-copy.yml b/changelogs/unreleased/31193-ff-copy.yml
deleted file mode 100644
index 4d44d83d458..00000000000
--- a/changelogs/unreleased/31193-ff-copy.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: fix inline diff copy in firefox
-merge_request:
-author:
diff --git a/changelogs/unreleased/31207-clean-locked-merge-requests.yml b/changelogs/unreleased/31207-clean-locked-merge-requests.yml
new file mode 100644
index 00000000000..1f52987baef
--- /dev/null
+++ b/changelogs/unreleased/31207-clean-locked-merge-requests.yml
@@ -0,0 +1,4 @@
+---
+title: Unlock stuck merge request and set the proper state
+merge_request: 13207
+author:
diff --git a/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml b/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml
deleted file mode 100644
index dee831c668b..00000000000
--- a/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed milestone sidebar showing incorrect number of MRs when collapsed
-merge_request: 10933
-author:
diff --git a/changelogs/unreleased/31362_decrease_cyclomatic_complexity_threshold_step1.yml b/changelogs/unreleased/31362_decrease_cyclomatic_complexity_threshold_step1.yml
deleted file mode 100644
index fedf4de04d3..00000000000
--- a/changelogs/unreleased/31362_decrease_cyclomatic_complexity_threshold_step1.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Decrease Cyclomatic Complexity threshold to 16
-merge_request: 10928
-author: Rydkin Maxim
diff --git a/changelogs/unreleased/31533-usage-data-projects-stats.yml b/changelogs/unreleased/31533-usage-data-projects-stats.yml
new file mode 100644
index 00000000000..11bb6118337
--- /dev/null
+++ b/changelogs/unreleased/31533-usage-data-projects-stats.yml
@@ -0,0 +1,4 @@
+---
+title: Add Slack and JIRA services counts to Usage Data
+merge_request:
+author:
diff --git a/changelogs/unreleased/31571-don-t-let-webhooks-jobs-go-to-the-dead-jobs-queue.yml b/changelogs/unreleased/31571-don-t-let-webhooks-jobs-go-to-the-dead-jobs-queue.yml
new file mode 100644
index 00000000000..69900f0b314
--- /dev/null
+++ b/changelogs/unreleased/31571-don-t-let-webhooks-jobs-go-to-the-dead-jobs-queue.yml
@@ -0,0 +1,5 @@
+---
+title: Prevent web hook and project service background jobs from going to the dead
+ jobs queue
+merge_request:
+author:
diff --git a/changelogs/unreleased/32340-correct-jobs-api-documentation b/changelogs/unreleased/32340-correct-jobs-api-documentation
new file mode 100644
index 00000000000..4ada62356eb
--- /dev/null
+++ b/changelogs/unreleased/32340-correct-jobs-api-documentation
@@ -0,0 +1,4 @@
+---
+title: "Correction to documention for manual steps on the Jobs API"
+merge_request: 11411
+author: Zac Sturgess \ No newline at end of file
diff --git a/changelogs/unreleased/32483-jira-error.yml b/changelogs/unreleased/32483-jira-error.yml
new file mode 100644
index 00000000000..1c530ca5e0f
--- /dev/null
+++ b/changelogs/unreleased/32483-jira-error.yml
@@ -0,0 +1,4 @@
+---
+title: Display specific error message when JIRA test fails
+merge_request:
+author:
diff --git a/changelogs/unreleased/32844-issuables-performance.yml b/changelogs/unreleased/32844-issuables-performance.yml
new file mode 100644
index 00000000000..e9b21c1aa45
--- /dev/null
+++ b/changelogs/unreleased/32844-issuables-performance.yml
@@ -0,0 +1,4 @@
+---
+title: Move some code from services to workers in order to improve performance
+merge_request: 13326
+author:
diff --git a/changelogs/unreleased/33095-mr-widget-ui.yml b/changelogs/unreleased/33095-mr-widget-ui.yml
new file mode 100644
index 00000000000..9ce3086df27
--- /dev/null
+++ b/changelogs/unreleased/33095-mr-widget-ui.yml
@@ -0,0 +1,4 @@
+---
+title: clean up merge request widget UI
+merge_request:
+author:
diff --git a/changelogs/unreleased/33097-issue-tracker.yml b/changelogs/unreleased/33097-issue-tracker.yml
new file mode 100644
index 00000000000..0b13f7165db
--- /dev/null
+++ b/changelogs/unreleased/33097-issue-tracker.yml
@@ -0,0 +1,4 @@
+---
+title: Associate Issues tab only with internal issues tracker
+merge_request:
+author:
diff --git a/changelogs/unreleased/33601-add-csrf-token-verification-to-api.yml b/changelogs/unreleased/33601-add-csrf-token-verification-to-api.yml
new file mode 100644
index 00000000000..88cfb99a73e
--- /dev/null
+++ b/changelogs/unreleased/33601-add-csrf-token-verification-to-api.yml
@@ -0,0 +1,4 @@
+---
+title: Add CSRF token verification to API
+merge_request: 12154
+author: Vitaliy @blackst0ne Klachkov
diff --git a/changelogs/unreleased/33620-remove-events-from-notification_settings.yml b/changelogs/unreleased/33620-remove-events-from-notification_settings.yml
new file mode 100644
index 00000000000..f5f3ef3fb82
--- /dev/null
+++ b/changelogs/unreleased/33620-remove-events-from-notification_settings.yml
@@ -0,0 +1,4 @@
+---
+title: Remove events column from notification settings table
+merge_request:
+author:
diff --git a/changelogs/unreleased/33741-clarify-k8s-service-keys.yml b/changelogs/unreleased/33741-clarify-k8s-service-keys.yml
new file mode 100644
index 00000000000..91142a0d580
--- /dev/null
+++ b/changelogs/unreleased/33741-clarify-k8s-service-keys.yml
@@ -0,0 +1,5 @@
+---
+title: Clarifies and rearranges the input variables on the kubernetes integration
+ page and adjusts the docs slightly to meet the same order
+merge_request: !12188
+author:
diff --git a/changelogs/unreleased/33770-respect-blockquote-line-breaks.yml b/changelogs/unreleased/33770-respect-blockquote-line-breaks.yml
new file mode 100644
index 00000000000..3a45ad88270
--- /dev/null
+++ b/changelogs/unreleased/33770-respect-blockquote-line-breaks.yml
@@ -0,0 +1,4 @@
+---
+title: Respect blockquote line breaks in markdown
+merge_request:
+author:
diff --git a/changelogs/unreleased/33874_confi.yml b/changelogs/unreleased/33874_confi.yml
new file mode 100644
index 00000000000..940753d9aaa
--- /dev/null
+++ b/changelogs/unreleased/33874_confi.yml
@@ -0,0 +1,5 @@
+---
+title: Update confidential issue UI - add confidential visibility and settings to
+ sidebar
+merge_request:
+author:
diff --git a/changelogs/unreleased/34027-add-icons-to-sidebar.yml b/changelogs/unreleased/34027-add-icons-to-sidebar.yml
new file mode 100644
index 00000000000..f5b50ca1dee
--- /dev/null
+++ b/changelogs/unreleased/34027-add-icons-to-sidebar.yml
@@ -0,0 +1,4 @@
+---
+title: Add icons to contextual sidebars
+merge_request:
+author:
diff --git a/changelogs/unreleased/34028-collapse-sidebar.yml b/changelogs/unreleased/34028-collapse-sidebar.yml
new file mode 100644
index 00000000000..468212240ac
--- /dev/null
+++ b/changelogs/unreleased/34028-collapse-sidebar.yml
@@ -0,0 +1,4 @@
+---
+title: Make contextual sidebar collapsible
+merge_request:
+author:
diff --git a/changelogs/unreleased/34049-public-commits-should-not-require-authentication.yml b/changelogs/unreleased/34049-public-commits-should-not-require-authentication.yml
new file mode 100644
index 00000000000..278ef2a8acb
--- /dev/null
+++ b/changelogs/unreleased/34049-public-commits-should-not-require-authentication.yml
@@ -0,0 +1,4 @@
+---
+title: Added tests for commits API unauthenticated user and public/private project
+merge_request: 13287
+author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/34075-pipelines-count-mt.yml b/changelogs/unreleased/34075-pipelines-count-mt.yml
new file mode 100644
index 00000000000..3846e7b06a4
--- /dev/null
+++ b/changelogs/unreleased/34075-pipelines-count-mt.yml
@@ -0,0 +1,5 @@
+---
+title: Update Pipeline's badge count in Merge Request and Commits view to match real-time
+ content
+merge_request:
+author:
diff --git a/changelogs/unreleased/34110-memory-usage-notice-doesn-t-link-anywhere.yml b/changelogs/unreleased/34110-memory-usage-notice-doesn-t-link-anywhere.yml
new file mode 100644
index 00000000000..1911705dd2b
--- /dev/null
+++ b/changelogs/unreleased/34110-memory-usage-notice-doesn-t-link-anywhere.yml
@@ -0,0 +1,4 @@
+---
+title: Added link to the MR widget that directs to the monitoring dashboard
+merge_request:
+author:
diff --git a/changelogs/unreleased/34339-user_avatar-url-in-push-event-webhook-json-payload-is-relative-should-be-absolute.yml b/changelogs/unreleased/34339-user_avatar-url-in-push-event-webhook-json-payload-is-relative-should-be-absolute.yml
new file mode 100644
index 00000000000..13f28da8577
--- /dev/null
+++ b/changelogs/unreleased/34339-user_avatar-url-in-push-event-webhook-json-payload-is-relative-should-be-absolute.yml
@@ -0,0 +1,4 @@
+---
+title: Use full path of user's avatar in webhooks
+merge_request: 13401
+author: Vitaliy @blackst0ne Klachkov
diff --git a/changelogs/unreleased/34361-lazy-load-images-on-the-frontend.yml b/changelogs/unreleased/34361-lazy-load-images-on-the-frontend.yml
new file mode 100644
index 00000000000..d188a558d38
--- /dev/null
+++ b/changelogs/unreleased/34361-lazy-load-images-on-the-frontend.yml
@@ -0,0 +1,4 @@
+---
+title: Lazy load images for better Frontend performance
+merge_request: 12503
+author:
diff --git a/changelogs/unreleased/34371-pipeline-schedule-vue-files.yml b/changelogs/unreleased/34371-pipeline-schedule-vue-files.yml
new file mode 100644
index 00000000000..7de30d82601
--- /dev/null
+++ b/changelogs/unreleased/34371-pipeline-schedule-vue-files.yml
@@ -0,0 +1,6 @@
+---
+title: Improves performance of vue code by using vue files and moving svg out of data
+ function in pipeline schedule callout
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/34519-extend-api-group-secret-variable.yml b/changelogs/unreleased/34519-extend-api-group-secret-variable.yml
new file mode 100644
index 00000000000..e0b625c392f
--- /dev/null
+++ b/changelogs/unreleased/34519-extend-api-group-secret-variable.yml
@@ -0,0 +1,4 @@
+---
+title: Extend API for Group Secret Variable
+merge_request: 12936
+author:
diff --git a/changelogs/unreleased/34527-make-edit-comment-button-always-available-outside-of-dropdown.yml b/changelogs/unreleased/34527-make-edit-comment-button-always-available-outside-of-dropdown.yml
new file mode 100644
index 00000000000..08171f6bcec
--- /dev/null
+++ b/changelogs/unreleased/34527-make-edit-comment-button-always-available-outside-of-dropdown.yml
@@ -0,0 +1,4 @@
+---
+title: move edit comment button outside of dropdown
+merge_request:
+author:
diff --git a/changelogs/unreleased/34533-speed-up-group-project-authorizations.yml b/changelogs/unreleased/34533-speed-up-group-project-authorizations.yml
new file mode 100644
index 00000000000..ddaaf4a2507
--- /dev/null
+++ b/changelogs/unreleased/34533-speed-up-group-project-authorizations.yml
@@ -0,0 +1,5 @@
+---
+title: Fix timeouts when creating projects in groups with many members
+merge_request: 13508
+author:
+type: fixed
diff --git a/changelogs/unreleased/34534-update-vue-resource.yml b/changelogs/unreleased/34534-update-vue-resource.yml
new file mode 100644
index 00000000000..2d0af0c9bfe
--- /dev/null
+++ b/changelogs/unreleased/34534-update-vue-resource.yml
@@ -0,0 +1,4 @@
+---
+title: Updates vue resource and code according to breaking changes
+merge_request:
+author:
diff --git a/changelogs/unreleased/34549-extract-devise-mappings-into-helper.yml b/changelogs/unreleased/34549-extract-devise-mappings-into-helper.yml
new file mode 100644
index 00000000000..e843bbac239
--- /dev/null
+++ b/changelogs/unreleased/34549-extract-devise-mappings-into-helper.yml
@@ -0,0 +1,4 @@
+---
+title: Extract "@request.env[devise.mapping] = Devise.mappings[:user]" to a test helper
+merge_request: 12742
+author: Jacopo Beschi @jacopo-beschi
diff --git a/changelogs/unreleased/34563-usage-ping-github.yml b/changelogs/unreleased/34563-usage-ping-github.yml
new file mode 100644
index 00000000000..3ab982beea3
--- /dev/null
+++ b/changelogs/unreleased/34563-usage-ping-github.yml
@@ -0,0 +1,4 @@
+---
+title: Add GitHub imported projects count to usage data
+merge_request:
+author:
diff --git a/changelogs/unreleased/34643-fix-project-path-slugify.yml b/changelogs/unreleased/34643-fix-project-path-slugify.yml
new file mode 100644
index 00000000000..f7018a1aca5
--- /dev/null
+++ b/changelogs/unreleased/34643-fix-project-path-slugify.yml
@@ -0,0 +1,4 @@
+---
+title: Fix CI_PROJECT_PATH_SLUG slugify
+merge_request: 13350
+author: Ivan Chernov
diff --git a/changelogs/unreleased/34764-rename-to-overview.yml b/changelogs/unreleased/34764-rename-to-overview.yml
new file mode 100644
index 00000000000..5b9643285b7
--- /dev/null
+++ b/changelogs/unreleased/34764-rename-to-overview.yml
@@ -0,0 +1,4 @@
+---
+title: Rename about to overview for group and project page
+merge_request:
+author:
diff --git a/changelogs/unreleased/34810-vue-pagination.yml b/changelogs/unreleased/34810-vue-pagination.yml
new file mode 100644
index 00000000000..5cd03518a98
--- /dev/null
+++ b/changelogs/unreleased/34810-vue-pagination.yml
@@ -0,0 +1,4 @@
+---
+title: Prevent disabled pagination button to be clicked
+merge_request:
+author:
diff --git a/changelogs/unreleased/34831-remove-coffee-rails-gem.yml b/changelogs/unreleased/34831-remove-coffee-rails-gem.yml
new file mode 100644
index 00000000000..b555f112b8d
--- /dev/null
+++ b/changelogs/unreleased/34831-remove-coffee-rails-gem.yml
@@ -0,0 +1,4 @@
+---
+title: Remove coffee-rails gem
+merge_request:
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/34858-bump-scss-lint-to-0-54-0.yml b/changelogs/unreleased/34858-bump-scss-lint-to-0-54-0.yml
new file mode 100644
index 00000000000..e6cd834aed2
--- /dev/null
+++ b/changelogs/unreleased/34858-bump-scss-lint-to-0-54-0.yml
@@ -0,0 +1,4 @@
+---
+title: Bump scss-lint to 0.54.0
+merge_request: 12733
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/34867-remove-net-ssh-gem.yml b/changelogs/unreleased/34867-remove-net-ssh-gem.yml
new file mode 100644
index 00000000000..f5648d62467
--- /dev/null
+++ b/changelogs/unreleased/34867-remove-net-ssh-gem.yml
@@ -0,0 +1,4 @@
+---
+title: Remove net-ssh gem
+merge_request:
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/34869-bump-rubocop-to-0-49-1-and-rubocop-rspec-to-1-15-1.yml b/changelogs/unreleased/34869-bump-rubocop-to-0-49-1-and-rubocop-rspec-to-1-15-1.yml
new file mode 100644
index 00000000000..0eb2d069719
--- /dev/null
+++ b/changelogs/unreleased/34869-bump-rubocop-to-0-49-1-and-rubocop-rspec-to-1-15-1.yml
@@ -0,0 +1,4 @@
+---
+title: Bump rubocop to 0.49.1 and rubocop-rspec to 1.15.1
+merge_request:
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/34921-global-dropdown-ui-improvement.yml b/changelogs/unreleased/34921-global-dropdown-ui-improvement.yml
new file mode 100644
index 00000000000..6a17353ba3f
--- /dev/null
+++ b/changelogs/unreleased/34921-global-dropdown-ui-improvement.yml
@@ -0,0 +1,4 @@
+---
+title: Improve CSS for global nav dropdown UI
+merge_request: 12772
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/34927-protect-manual-actions-on-tags.yml b/changelogs/unreleased/34927-protect-manual-actions-on-tags.yml
new file mode 100644
index 00000000000..d996ae2826a
--- /dev/null
+++ b/changelogs/unreleased/34927-protect-manual-actions-on-tags.yml
@@ -0,0 +1,4 @@
+---
+title: Protect manual actions against protected tag too
+merge_request: 12908
+author:
diff --git a/changelogs/unreleased/34978-remove-public-ci-favicon-ico.yml b/changelogs/unreleased/34978-remove-public-ci-favicon-ico.yml
new file mode 100644
index 00000000000..25cc8b5e45f
--- /dev/null
+++ b/changelogs/unreleased/34978-remove-public-ci-favicon-ico.yml
@@ -0,0 +1,4 @@
+---
+title: Remove public/ci/favicon.ico
+merge_request: 12803
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35044-projects-logo-are-not-centered-vertically-on-projects-page.yml b/changelogs/unreleased/35044-projects-logo-are-not-centered-vertically-on-projects-page.yml
new file mode 100644
index 00000000000..9de4dbefd35
--- /dev/null
+++ b/changelogs/unreleased/35044-projects-logo-are-not-centered-vertically-on-projects-page.yml
@@ -0,0 +1,4 @@
+---
+title: Fix project logos that are not centered vertically on list pages
+merge_request: 13124
+author: Florian Lemaitre
diff --git a/changelogs/unreleased/35072-fix-pages-delete.yml b/changelogs/unreleased/35072-fix-pages-delete.yml
new file mode 100644
index 00000000000..21af2bde201
--- /dev/null
+++ b/changelogs/unreleased/35072-fix-pages-delete.yml
@@ -0,0 +1,5 @@
+---
+title: Fix deleting GitLab Pages files when a project is removed
+merge_request: 13631
+author:
+type: fixed
diff --git a/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml b/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml
new file mode 100644
index 00000000000..3cdb3011f5b
--- /dev/null
+++ b/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml
@@ -0,0 +1,4 @@
+---
+title: Raise guessed encoding confidence threshold to 50
+merge_request: 12990
+author:
diff --git a/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml b/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml
new file mode 100644
index 00000000000..ea8f31cca9d
--- /dev/null
+++ b/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml
@@ -0,0 +1,4 @@
+---
+title: Fix bar chart does not display label at 0 hour
+merge_request: 35136
+author: Jason Dai
diff --git a/changelogs/unreleased/35155-upgrade-fog-core-to-1-44-3-and-its-providers-to-the-latest.yml b/changelogs/unreleased/35155-upgrade-fog-core-to-1-44-3-and-its-providers-to-the-latest.yml
new file mode 100644
index 00000000000..9d9558347ba
--- /dev/null
+++ b/changelogs/unreleased/35155-upgrade-fog-core-to-1-44-3-and-its-providers-to-the-latest.yml
@@ -0,0 +1,4 @@
+---
+title: Bump fog-core to 1.44.3 and fog providers' plugins to latest
+merge_request: 12897
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35163-url-in-commit-message-can-be-broken-in-blame.yml b/changelogs/unreleased/35163-url-in-commit-message-can-be-broken-in-blame.yml
new file mode 100644
index 00000000000..4fd60a79782
--- /dev/null
+++ b/changelogs/unreleased/35163-url-in-commit-message-can-be-broken-in-blame.yml
@@ -0,0 +1,4 @@
+---
+title: Use only CSS to truncate commit message in blame
+merge_request: 12900
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35164-cycle-analytics-firefox.yml b/changelogs/unreleased/35164-cycle-analytics-firefox.yml
new file mode 100644
index 00000000000..0b7115136ca
--- /dev/null
+++ b/changelogs/unreleased/35164-cycle-analytics-firefox.yml
@@ -0,0 +1,4 @@
+---
+title: allow closing Cycle Analytics intro box in firefox
+merge_request:
+author:
diff --git a/changelogs/unreleased/35181-cannot-create-label-from-board-page.yml b/changelogs/unreleased/35181-cannot-create-label-from-board-page.yml
new file mode 100644
index 00000000000..4afe603720d
--- /dev/null
+++ b/changelogs/unreleased/35181-cannot-create-label-from-board-page.yml
@@ -0,0 +1,4 @@
+---
+title: Fix label creation from new list for subgroup projects
+merge_request:
+author:
diff --git a/changelogs/unreleased/35191-prioritized-labels-for-non-member.yml b/changelogs/unreleased/35191-prioritized-labels-for-non-member.yml
new file mode 100644
index 00000000000..fbe55d4c2b0
--- /dev/null
+++ b/changelogs/unreleased/35191-prioritized-labels-for-non-member.yml
@@ -0,0 +1,4 @@
+---
+title: Remove help message about prioritized labels for non-members
+merge_request: 12912
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35204-doc-api-ci-lint-typo.yml b/changelogs/unreleased/35204-doc-api-ci-lint-typo.yml
new file mode 100644
index 00000000000..45b6c57579b
--- /dev/null
+++ b/changelogs/unreleased/35204-doc-api-ci-lint-typo.yml
@@ -0,0 +1,4 @@
+---
+title: Add link to doc/api/ci/lint.md
+merge_request: 12914
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35225-transient-poll.yml b/changelogs/unreleased/35225-transient-poll.yml
new file mode 100644
index 00000000000..59e2e738c7b
--- /dev/null
+++ b/changelogs/unreleased/35225-transient-poll.yml
@@ -0,0 +1,4 @@
+---
+title: fix transient js error in rspec tests
+merge_request:
+author:
diff --git a/changelogs/unreleased/35253-desc-protected-branches-for-non-member.yml b/changelogs/unreleased/35253-desc-protected-branches-for-non-member.yml
new file mode 100644
index 00000000000..9b2a66da1c3
--- /dev/null
+++ b/changelogs/unreleased/35253-desc-protected-branches-for-non-member.yml
@@ -0,0 +1,4 @@
+---
+title: Hide description about protected branches to non-member
+merge_request: 12945
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/35391-fix-star-i18n-in-js.yml b/changelogs/unreleased/35391-fix-star-i18n-in-js.yml
new file mode 100644
index 00000000000..a6fd4dc89fd
--- /dev/null
+++ b/changelogs/unreleased/35391-fix-star-i18n-in-js.yml
@@ -0,0 +1,4 @@
+---
+title: Fix translations for Star/Unstar in JS file
+merge_request:
+author:
diff --git a/changelogs/unreleased/35408-group-auto-avatars.yml b/changelogs/unreleased/35408-group-auto-avatars.yml
new file mode 100644
index 00000000000..77b644a7f94
--- /dev/null
+++ b/changelogs/unreleased/35408-group-auto-avatars.yml
@@ -0,0 +1,4 @@
+---
+title: Show auto-generated avatars for Groups without avatars
+merge_request: 13188
+author:
diff --git a/changelogs/unreleased/35483-improve-mobile-sidebar.yml b/changelogs/unreleased/35483-improve-mobile-sidebar.yml
new file mode 100644
index 00000000000..eb3dab1da9e
--- /dev/null
+++ b/changelogs/unreleased/35483-improve-mobile-sidebar.yml
@@ -0,0 +1,4 @@
+---
+title: Improve mobile sidebar
+merge_request:
+author:
diff --git a/changelogs/unreleased/35659-rename-pipeline.yml b/changelogs/unreleased/35659-rename-pipeline.yml
new file mode 100644
index 00000000000..0fe211868e4
--- /dev/null
+++ b/changelogs/unreleased/35659-rename-pipeline.yml
@@ -0,0 +1,4 @@
+---
+title: Rename Pipelines tab to CI / CD in new navigation
+merge_request:
+author:
diff --git a/changelogs/unreleased/35695-comment-appears-in-a-wrong-place-after-changing-diff-view-to-inline.yml b/changelogs/unreleased/35695-comment-appears-in-a-wrong-place-after-changing-diff-view-to-inline.yml
new file mode 100644
index 00000000000..1c9ad20bc95
--- /dev/null
+++ b/changelogs/unreleased/35695-comment-appears-in-a-wrong-place-after-changing-diff-view-to-inline.yml
@@ -0,0 +1,4 @@
+---
+title: Fix display of new diff comments after changing b between diff views
+merge_request:
+author:
diff --git a/changelogs/unreleased/35761-convdev-perc.yml b/changelogs/unreleased/35761-convdev-perc.yml
new file mode 100644
index 00000000000..319c4d18219
--- /dev/null
+++ b/changelogs/unreleased/35761-convdev-perc.yml
@@ -0,0 +1,4 @@
+---
+title: Store & use ConvDev percentages returned by the Version app
+merge_request:
+author:
diff --git a/changelogs/unreleased/35769-fix-ruby-2-4-compatibility.yml b/changelogs/unreleased/35769-fix-ruby-2-4-compatibility.yml
new file mode 100644
index 00000000000..ac480993d85
--- /dev/null
+++ b/changelogs/unreleased/35769-fix-ruby-2-4-compatibility.yml
@@ -0,0 +1,4 @@
+---
+title: Fix Issue board when using Ruby 2.4
+merge_request: 13220
+author:
diff --git a/changelogs/unreleased/35815-webhook-log-encoding-error.yml b/changelogs/unreleased/35815-webhook-log-encoding-error.yml
new file mode 100644
index 00000000000..76ec235086c
--- /dev/null
+++ b/changelogs/unreleased/35815-webhook-log-encoding-error.yml
@@ -0,0 +1,4 @@
+---
+title: Fix encoding error for WebHook logging
+merge_request: 13230
+author: Alexander Randa (@randaalex)
diff --git a/changelogs/unreleased/36010-api-v4-allows-setting-a-branch-that-doesn-t-exist-as-the-default-one.yml b/changelogs/unreleased/36010-api-v4-allows-setting-a-branch-that-doesn-t-exist-as-the-default-one.yml
new file mode 100644
index 00000000000..04791e09b84
--- /dev/null
+++ b/changelogs/unreleased/36010-api-v4-allows-setting-a-branch-that-doesn-t-exist-as-the-default-one.yml
@@ -0,0 +1,4 @@
+---
+title: Add checks for branch existence before changing HEAD
+merge_request: 13359
+author: Vitaliy @blackst0ne Klachkov
diff --git a/changelogs/unreleased/36087-users-cannot-delete-their-account.yml b/changelogs/unreleased/36087-users-cannot-delete-their-account.yml
new file mode 100644
index 00000000000..9ba75d8b1d0
--- /dev/null
+++ b/changelogs/unreleased/36087-users-cannot-delete-their-account.yml
@@ -0,0 +1,5 @@
+---
+title: allow all users to delete their account
+merge_request: 13636
+author: Jacopo Beschi @jacopo-beschi
+type: changed
diff --git a/changelogs/unreleased/36119-issuable-workers.yml b/changelogs/unreleased/36119-issuable-workers.yml
new file mode 100644
index 00000000000..beb01ae5b1a
--- /dev/null
+++ b/changelogs/unreleased/36119-issuable-workers.yml
@@ -0,0 +1,4 @@
+---
+title: Simplify checking if objects exist code in new issaubles workers
+merge_request:
+author:
diff --git a/changelogs/unreleased/36185-or-separator.yml b/changelogs/unreleased/36185-or-separator.yml
new file mode 100644
index 00000000000..4e46e60ea1b
--- /dev/null
+++ b/changelogs/unreleased/36185-or-separator.yml
@@ -0,0 +1,4 @@
+---
+title: Align OR separator to center in new project page
+merge_request:
+author:
diff --git a/changelogs/unreleased/36213-return-is_admin-in-users-api-when-current_user-is-admin.yml b/changelogs/unreleased/36213-return-is_admin-in-users-api-when-current_user-is-admin.yml
new file mode 100644
index 00000000000..b51b5e58b39
--- /dev/null
+++ b/changelogs/unreleased/36213-return-is_admin-in-users-api-when-current_user-is-admin.yml
@@ -0,0 +1,6 @@
+---
+title: Include the `is_admin` field in the `GET /users/:id` API when current user
+ is an admin
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/36385-pipeline-graph-dropdown.yml b/changelogs/unreleased/36385-pipeline-graph-dropdown.yml
new file mode 100644
index 00000000000..1a43c66debd
--- /dev/null
+++ b/changelogs/unreleased/36385-pipeline-graph-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Prevents jobs dropdown from closing in pipeline graph
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/3686_make_tarball_download_url.yml b/changelogs/unreleased/3686_make_tarball_download_url.yml
new file mode 100644
index 00000000000..4e75e52e3ac
--- /dev/null
+++ b/changelogs/unreleased/3686_make_tarball_download_url.yml
@@ -0,0 +1,4 @@
+---
+title: repository archive download url now ends with selected file extension
+merge_request: 13178
+author: haseebeqx
diff --git a/changelogs/unreleased/5971-webhook-testing.yml b/changelogs/unreleased/5971-webhook-testing.yml
new file mode 100644
index 00000000000..58233091977
--- /dev/null
+++ b/changelogs/unreleased/5971-webhook-testing.yml
@@ -0,0 +1,4 @@
+---
+title: Allow testing any events for project hooks and system hooks
+merge_request: 11728
+author: Alexander Randa (@randaalex)
diff --git a/changelogs/unreleased/6260-frontend-prevent-authored-votes.yml b/changelogs/unreleased/6260-frontend-prevent-authored-votes.yml
deleted file mode 100644
index 82e852fa197..00000000000
--- a/changelogs/unreleased/6260-frontend-prevent-authored-votes.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: 'Frontend prevent authored votes'
-merge_request: 6260
-author: Barthc
diff --git a/changelogs/unreleased/add-aria-to-icon.yml b/changelogs/unreleased/add-aria-to-icon.yml
deleted file mode 100644
index fd6a25784c6..00000000000
--- a/changelogs/unreleased/add-aria-to-icon.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixes an issue preventing screen readers from reading some icons
-merge_request:
-author:
diff --git a/changelogs/unreleased/add-filtered-search-group-issues-ce.yml b/changelogs/unreleased/add-filtered-search-group-issues-ce.yml
new file mode 100644
index 00000000000..f83f4173890
--- /dev/null
+++ b/changelogs/unreleased/add-filtered-search-group-issues-ce.yml
@@ -0,0 +1,4 @@
+---
+title: Add filtered search to group issue dashboard
+merge_request:
+author:
diff --git a/changelogs/unreleased/add-star-for-action-scope.yml b/changelogs/unreleased/add-star-for-action-scope.yml
new file mode 100644
index 00000000000..a8119a01ec4
--- /dev/null
+++ b/changelogs/unreleased/add-star-for-action-scope.yml
@@ -0,0 +1,4 @@
+---
+title: Add star for action scope, in order to delete image from registry
+merge_request: 13248
+author: jean
diff --git a/changelogs/unreleased/add-username-to-activity-feed.yml b/changelogs/unreleased/add-username-to-activity-feed.yml
deleted file mode 100644
index f4c216a3954..00000000000
--- a/changelogs/unreleased/add-username-to-activity-feed.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add username to activity atom feed
-merge_request: 10802
-author: winniehell
diff --git a/changelogs/unreleased/add-vue-loader.yml b/changelogs/unreleased/add-vue-loader.yml
deleted file mode 100644
index 382ef61ff21..00000000000
--- a/changelogs/unreleased/add-vue-loader.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: add support for .vue templates
-merge_request: 10517
-author:
diff --git a/changelogs/unreleased/add_index_on_ci_builds_user_id.yml b/changelogs/unreleased/add_index_on_ci_builds_user_id.yml
deleted file mode 100644
index 655ebdb76fa..00000000000
--- a/changelogs/unreleased/add_index_on_ci_builds_user_id.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add index on ci_builds.user_id
-merge_request: 10874
-author: blackst0ne
diff --git a/changelogs/unreleased/appearances-caching-and-schema.yml b/changelogs/unreleased/appearances-caching-and-schema.yml
new file mode 100644
index 00000000000..5743f6e0f2d
--- /dev/null
+++ b/changelogs/unreleased/appearances-caching-and-schema.yml
@@ -0,0 +1,4 @@
+---
+title: Cache Appearance instances in Redis
+merge_request:
+author:
diff --git a/changelogs/unreleased/artifacts-download-dropdown-menu-is-too-narrow.yml b/changelogs/unreleased/artifacts-download-dropdown-menu-is-too-narrow.yml
new file mode 100644
index 00000000000..7d47c60e262
--- /dev/null
+++ b/changelogs/unreleased/artifacts-download-dropdown-menu-is-too-narrow.yml
@@ -0,0 +1,4 @@
+---
+title: Increase width of dropdown menus automatically
+merge_request: 12809
+author: Thomas Wucher
diff --git a/changelogs/unreleased/bb_save_trace.yml b/changelogs/unreleased/bb_save_trace.yml
deleted file mode 100644
index 6ff31f4f111..00000000000
--- a/changelogs/unreleased/bb_save_trace.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: "[BB Importer] Save the error trace and the whole raw document to debug problems
- easier"
-merge_request:
-author:
diff --git a/changelogs/unreleased/boards-done-add-tooltip.yml b/changelogs/unreleased/boards-done-add-tooltip.yml
deleted file mode 100644
index 139f1efc8ee..00000000000
--- a/changelogs/unreleased/boards-done-add-tooltip.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add tooltip to header of Done board
-merge_request: 10574
-author: Andy Brown
diff --git a/changelogs/unreleased/breadcrumbs-collapsed-title-width-fix.yml b/changelogs/unreleased/breadcrumbs-collapsed-title-width-fix.yml
new file mode 100644
index 00000000000..988fdacb5fd
--- /dev/null
+++ b/changelogs/unreleased/breadcrumbs-collapsed-title-width-fix.yml
@@ -0,0 +1,4 @@
+---
+title: Fixed breadcrumbs title aggressively collapsing
+merge_request:
+author:
diff --git a/changelogs/unreleased/broadcast-messages-cache.yml b/changelogs/unreleased/broadcast-messages-cache.yml
new file mode 100644
index 00000000000..a3c9e1ff465
--- /dev/null
+++ b/changelogs/unreleased/broadcast-messages-cache.yml
@@ -0,0 +1,4 @@
+---
+title: Better caching and indexing of broadcast messages
+merge_request:
+author:
diff --git a/changelogs/unreleased/bump-omniauth-ldap-gem-version-2-0-4.yml b/changelogs/unreleased/bump-omniauth-ldap-gem-version-2-0-4.yml
new file mode 100644
index 00000000000..7571999fa75
--- /dev/null
+++ b/changelogs/unreleased/bump-omniauth-ldap-gem-version-2-0-4.yml
@@ -0,0 +1,4 @@
+---
+title: Bumps omniauth-ldap gem version to 2.0.4
+merge_request: 13465
+author:
diff --git a/changelogs/unreleased/bump-omniauth-ldap-gem-version.yml b/changelogs/unreleased/bump-omniauth-ldap-gem-version.yml
new file mode 100644
index 00000000000..42e1c9e8f83
--- /dev/null
+++ b/changelogs/unreleased/bump-omniauth-ldap-gem-version.yml
@@ -0,0 +1,4 @@
+---
+title: Prevent LDAP login callback from being called with a GET request
+merge_request: 13059
+author:
diff --git a/changelogs/unreleased/bvl-add-all-settings-to-api.yml b/changelogs/unreleased/bvl-add-all-settings-to-api.yml
new file mode 100644
index 00000000000..bfaf237a21c
--- /dev/null
+++ b/changelogs/unreleased/bvl-add-all-settings-to-api.yml
@@ -0,0 +1,4 @@
+---
+title: Make all application-settings accessible through the API
+merge_request: 12851
+author:
diff --git a/changelogs/unreleased/bvl-free-unused-names.yml b/changelogs/unreleased/bvl-free-unused-names.yml
new file mode 100644
index 00000000000..53acb95e5bb
--- /dev/null
+++ b/changelogs/unreleased/bvl-free-unused-names.yml
@@ -0,0 +1,5 @@
+---
+title: Free up some top level words, reject top level groups named like files in the
+ public folder
+merge_request: 12932
+author:
diff --git a/changelogs/unreleased/bvl-nfs-circuitbreaker.yml b/changelogs/unreleased/bvl-nfs-circuitbreaker.yml
new file mode 100644
index 00000000000..151854ed31f
--- /dev/null
+++ b/changelogs/unreleased/bvl-nfs-circuitbreaker.yml
@@ -0,0 +1,4 @@
+---
+title: Block access to failing repository storage
+merge_request: 11449
+author:
diff --git a/changelogs/unreleased/bvl-rollback-renamed-system-namespace.yml b/changelogs/unreleased/bvl-rollback-renamed-system-namespace.yml
new file mode 100644
index 00000000000..a24cc7a1c43
--- /dev/null
+++ b/changelogs/unreleased/bvl-rollback-renamed-system-namespace.yml
@@ -0,0 +1,4 @@
+---
+title: Don't rename namespace called system when upgrading from 9.1.x to 9.5
+merge_request: 13228
+author:
diff --git a/changelogs/unreleased/commits-list-page-limit.yml b/changelogs/unreleased/commits-list-page-limit.yml
new file mode 100644
index 00000000000..2fd54c5960a
--- /dev/null
+++ b/changelogs/unreleased/commits-list-page-limit.yml
@@ -0,0 +1,5 @@
+---
+title: Fix commit list not loading the correct page when scrolling
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/diff-changed-files-dropdown.yml b/changelogs/unreleased/diff-changed-files-dropdown.yml
new file mode 100644
index 00000000000..2d2a26ffea2
--- /dev/null
+++ b/changelogs/unreleased/diff-changed-files-dropdown.yml
@@ -0,0 +1,4 @@
+---
+title: Moved diff changed files into a dropdown
+merge_request:
+author:
diff --git a/changelogs/unreleased/diff-discussion-buttons-spacing.yml b/changelogs/unreleased/diff-discussion-buttons-spacing.yml
deleted file mode 100644
index dc76973e55b..00000000000
--- a/changelogs/unreleased/diff-discussion-buttons-spacing.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed spacing of discussion submit buttons
-merge_request:
-author:
diff --git a/changelogs/unreleased/disable-project-export.yml b/changelogs/unreleased/disable-project-export.yml
new file mode 100644
index 00000000000..d7ca9f46193
--- /dev/null
+++ b/changelogs/unreleased/disable-project-export.yml
@@ -0,0 +1,4 @@
+---
+title: Add option to disable project export on instance
+merge_request: 13211
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/dm-fix-ghost-user-validation.yml b/changelogs/unreleased/dm-fix-ghost-user-validation.yml
deleted file mode 100644
index 4214786cb5a..00000000000
--- a/changelogs/unreleased/dm-fix-ghost-user-validation.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Skip validation when creating internal (ghost, service desk) users
-merge_request:
-author:
diff --git a/changelogs/unreleased/dm-fix-position-tracer-for-hidden-lines.yml b/changelogs/unreleased/dm-fix-position-tracer-for-hidden-lines.yml
deleted file mode 100644
index d9ba26a0657..00000000000
--- a/changelogs/unreleased/dm-fix-position-tracer-for-hidden-lines.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix commenting on an existing discussion on an unchanged line that is no longer
- in the diff
-merge_request:
-author:
diff --git a/changelogs/unreleased/dm-large-push-performance.yml b/changelogs/unreleased/dm-large-push-performance.yml
new file mode 100644
index 00000000000..f5fe1bd3b28
--- /dev/null
+++ b/changelogs/unreleased/dm-large-push-performance.yml
@@ -0,0 +1,4 @@
+---
+title: Improve performance of large (initial) push into default branch
+merge_request:
+author:
diff --git a/changelogs/unreleased/dm-link-discussion-to-outdated-diff.yml b/changelogs/unreleased/dm-link-discussion-to-outdated-diff.yml
deleted file mode 100644
index d489bada7ea..00000000000
--- a/changelogs/unreleased/dm-link-discussion-to-outdated-diff.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Link to outdated diff in older MR version from outdated diff discussion
-merge_request:
-author:
diff --git a/changelogs/unreleased/dm-sidekiq-5.yml b/changelogs/unreleased/dm-sidekiq-5.yml
deleted file mode 100644
index 69c94b18929..00000000000
--- a/changelogs/unreleased/dm-sidekiq-5.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Bump Sidekiq to 5.0.0
-merge_request:
-author:
diff --git a/changelogs/unreleased/dont-use-limit-offset-when-counting-projects.yml b/changelogs/unreleased/dont-use-limit-offset-when-counting-projects.yml
new file mode 100644
index 00000000000..8ecea635ce5
--- /dev/null
+++ b/changelogs/unreleased/dont-use-limit-offset-when-counting-projects.yml
@@ -0,0 +1,4 @@
+---
+title: "Improve performance of checking for projects on the projects dashboard"
+merge_request:
+author:
diff --git a/changelogs/unreleased/dz-cleanup-add-users.yml b/changelogs/unreleased/dz-cleanup-add-users.yml
deleted file mode 100644
index ba1e2d609f9..00000000000
--- a/changelogs/unreleased/dz-cleanup-add-users.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Refactor add_users method for project and group
-merge_request: 10850
-author:
diff --git a/changelogs/unreleased/dz-fix-calendar-today.yml b/changelogs/unreleased/dz-fix-calendar-today.yml
new file mode 100644
index 00000000000..5320d8b26b5
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-calendar-today.yml
@@ -0,0 +1,4 @@
+---
+title: Fix today day highlight in calendar
+merge_request: 13048
+author:
diff --git a/changelogs/unreleased/dz-refactor-admin-group-members.yml b/changelogs/unreleased/dz-refactor-admin-group-members.yml
deleted file mode 100644
index 993a6cac0df..00000000000
--- a/changelogs/unreleased/dz-refactor-admin-group-members.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Refactor Admin::GroupsController#members_update method and add some specs
-merge_request: 10735
-author:
diff --git a/changelogs/unreleased/dz-refactor-create-members.yml b/changelogs/unreleased/dz-refactor-create-members.yml
deleted file mode 100644
index 8cff21eabb1..00000000000
--- a/changelogs/unreleased/dz-refactor-create-members.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Refactor code that creates project/group members
-merge_request: 10735
-author:
diff --git a/changelogs/unreleased/dz-remove-repo-version.yml b/changelogs/unreleased/dz-remove-repo-version.yml
deleted file mode 100644
index f9e51a920f9..00000000000
--- a/changelogs/unreleased/dz-remove-repo-version.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Remove Repository#version method and tests
-merge_request: 10734
-author:
diff --git a/changelogs/unreleased/eager-load-project-creators-for-project-dashboards.yml b/changelogs/unreleased/eager-load-project-creators-for-project-dashboards.yml
new file mode 100644
index 00000000000..e550e0b2f44
--- /dev/null
+++ b/changelogs/unreleased/eager-load-project-creators-for-project-dashboards.yml
@@ -0,0 +1,4 @@
+---
+title: Eager load project creators for project dashboards
+merge_request:
+author:
diff --git a/changelogs/unreleased/empty-task-list-alignment.yml b/changelogs/unreleased/empty-task-list-alignment.yml
deleted file mode 100644
index ca04e1cab5a..00000000000
--- a/changelogs/unreleased/empty-task-list-alignment.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed alignment of empty task list items
-merge_request:
-author:
diff --git a/changelogs/unreleased/enable-scss-lint-bang-format.yml b/changelogs/unreleased/enable-scss-lint-bang-format.yml
new file mode 100644
index 00000000000..0b73760198e
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-bang-format.yml
@@ -0,0 +1,4 @@
+---
+title: Enable BangFormat in scss-lint [ci skip]
+merge_request: 12815
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/enable-scss-lint-declaration-order.yml b/changelogs/unreleased/enable-scss-lint-declaration-order.yml
new file mode 100644
index 00000000000..7ac2f55592e
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-declaration-order.yml
@@ -0,0 +1,4 @@
+---
+title: Enable DeclarationOrder in scss-lint
+merge_request: 12805
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/enable-scss-lint-import-path.yml b/changelogs/unreleased/enable-scss-lint-import-path.yml
new file mode 100644
index 00000000000..d158cf5b5f3
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-import-path.yml
@@ -0,0 +1,4 @@
+---
+title: Enable ImportPath in scss-lint
+merge_request: 12749
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/enable-scss-lint-property-spelling.yml b/changelogs/unreleased/enable-scss-lint-property-spelling.yml
new file mode 100644
index 00000000000..c5a5a4dddb6
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-property-spelling.yml
@@ -0,0 +1,4 @@
+---
+title: Enable PropertySpelling in scss-lint
+merge_request: 12752
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/enable-scss-lint-space-after-comma.yml b/changelogs/unreleased/enable-scss-lint-space-after-comma.yml
new file mode 100644
index 00000000000..210f34fbb87
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-space-after-comma.yml
@@ -0,0 +1,4 @@
+---
+title: Enable SpaceAfterComma in scss-lint
+merge_request: 12734
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/enable-scss-lint-unnecessary-parent-reference.yml b/changelogs/unreleased/enable-scss-lint-unnecessary-parent-reference.yml
new file mode 100644
index 00000000000..59d5df56525
--- /dev/null
+++ b/changelogs/unreleased/enable-scss-lint-unnecessary-parent-reference.yml
@@ -0,0 +1,4 @@
+---
+title: Enable UnnecessaryParentReference in scss-lint
+merge_request: 12738
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/ericy_ts-protected_branches_api.yml b/changelogs/unreleased/ericy_ts-protected_branches_api.yml
new file mode 100644
index 00000000000..4cd275c5e8f
--- /dev/null
+++ b/changelogs/unreleased/ericy_ts-protected_branches_api.yml
@@ -0,0 +1,5 @@
+---
+title: Add API for protected branches to allow for wildcard matching and no access
+ restrictions
+merge_request: 12756
+author: Eric Yu
diff --git a/changelogs/unreleased/feature-backup-custom-path.yml b/changelogs/unreleased/feature-backup-custom-path.yml
new file mode 100644
index 00000000000..1c5f25b3ee5
--- /dev/null
+++ b/changelogs/unreleased/feature-backup-custom-path.yml
@@ -0,0 +1,4 @@
+---
+title: Support custom directory in gitlab:backup:create task
+merge_request: 12984
+author: Markus Koller
diff --git a/changelogs/unreleased/feature-gb-auto-retry-failed-ci-job.yml b/changelogs/unreleased/feature-gb-auto-retry-failed-ci-job.yml
new file mode 100644
index 00000000000..bdafc5929c0
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-auto-retry-failed-ci-job.yml
@@ -0,0 +1,4 @@
+---
+title: Allow to configure automatic retry of a failed CI/CD job
+merge_request: 12909
+author:
diff --git a/changelogs/unreleased/feature-gpg-signed-commits.yml b/changelogs/unreleased/feature-gpg-signed-commits.yml
new file mode 100644
index 00000000000..99bc5a309ef
--- /dev/null
+++ b/changelogs/unreleased/feature-gpg-signed-commits.yml
@@ -0,0 +1,4 @@
+---
+title: GPG signed commits integration
+merge_request: 9546
+author: Alexis Reigel
diff --git a/changelogs/unreleased/fix-500-error-when-rendering-avatar-for-deleted-project-creator.yml b/changelogs/unreleased/fix-500-error-when-rendering-avatar-for-deleted-project-creator.yml
new file mode 100644
index 00000000000..be6f1ea00fb
--- /dev/null
+++ b/changelogs/unreleased/fix-500-error-when-rendering-avatar-for-deleted-project-creator.yml
@@ -0,0 +1,4 @@
+---
+title: Modify if condition to be more readable
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-btn-alignment.yml b/changelogs/unreleased/fix-btn-alignment.yml
new file mode 100644
index 00000000000..e5dce3d3a0e
--- /dev/null
+++ b/changelogs/unreleased/fix-btn-alignment.yml
@@ -0,0 +1,5 @@
+---
+title: Fix inconsistent spacing for edit buttons on issues and merge request page
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-edit-merge-request-button-case.yml b/changelogs/unreleased/fix-edit-merge-request-button-case.yml
new file mode 100644
index 00000000000..8550f3e3c1b
--- /dev/null
+++ b/changelogs/unreleased/fix-edit-merge-request-button-case.yml
@@ -0,0 +1,5 @@
+---
+title: Fix edit merge request and issues button inconsistent letter casing
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-gb-handle-max-pages-artifacts-size-correctly.yml b/changelogs/unreleased/fix-gb-handle-max-pages-artifacts-size-correctly.yml
new file mode 100644
index 00000000000..3d9592bbf2a
--- /dev/null
+++ b/changelogs/unreleased/fix-gb-handle-max-pages-artifacts-size-correctly.yml
@@ -0,0 +1,4 @@
+---
+title: Handle maximum pages artifacts size correctly
+merge_request: 13072
+author:
diff --git a/changelogs/unreleased/fix-import-symbolink-links.yml b/changelogs/unreleased/fix-import-symbolink-links.yml
new file mode 100644
index 00000000000..36e73821bdc
--- /dev/null
+++ b/changelogs/unreleased/fix-import-symbolink-links.yml
@@ -0,0 +1,4 @@
+---
+title: Remove hidden symlinks from project import files
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml b/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml
deleted file mode 100644
index e684a1f6684..00000000000
--- a/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Removed target blank from the metrics action inside the environments list
-merge_request: 10726
-author:
diff --git a/changelogs/unreleased/fix-notify-post-receive.yml b/changelogs/unreleased/fix-notify-post-receive.yml
deleted file mode 100644
index 6b68396d5c5..00000000000
--- a/changelogs/unreleased/fix-notify-post-receive.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed wrong method call on notify_post_receive
-merge_request:
-author: Luigi Leoni
diff --git a/changelogs/unreleased/fix-replying-to-commit-comment-in-mr-from-fork.yml b/changelogs/unreleased/fix-replying-to-commit-comment-in-mr-from-fork.yml
new file mode 100644
index 00000000000..f4136460626
--- /dev/null
+++ b/changelogs/unreleased/fix-replying-to-commit-comment-in-mr-from-fork.yml
@@ -0,0 +1,4 @@
+---
+title: Fix replying to commit comments on merge requests created from forks
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-thread-safe-gpgme-tmp-directory.yml b/changelogs/unreleased/fix-thread-safe-gpgme-tmp-directory.yml
new file mode 100644
index 00000000000..66b5b6b4f47
--- /dev/null
+++ b/changelogs/unreleased/fix-thread-safe-gpgme-tmp-directory.yml
@@ -0,0 +1,4 @@
+---
+title: Make GPGME temporary directory handling thread safe
+merge_request: 13481
+author: Alexis Reigel
diff --git a/changelogs/unreleased/fix-user-profile-tabs-showing-raw-json-instead.yml b/changelogs/unreleased/fix-user-profile-tabs-showing-raw-json-instead.yml
deleted file mode 100644
index 410172864e3..00000000000
--- a/changelogs/unreleased/fix-user-profile-tabs-showing-raw-json-instead.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Prevent user profile tabs to display raw json when going back and forward in
- browser history
-merge_request:
-author:
diff --git a/changelogs/unreleased/fix-web_hooks-index.yml b/changelogs/unreleased/fix-web_hooks-index.yml
deleted file mode 100644
index 16f233e2e7c..00000000000
--- a/changelogs/unreleased/fix-web_hooks-index.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add index to webhooks type column
-merge_request:
-author:
diff --git a/changelogs/unreleased/fix_build_header_line_height.yml b/changelogs/unreleased/fix_build_header_line_height.yml
deleted file mode 100644
index 95b6221f8d2..00000000000
--- a/changelogs/unreleased/fix_build_header_line_height.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Change line-height on build-header so elements don't overlap
-merge_request:
-author: Dino Maric
diff --git a/changelogs/unreleased/fix_cache_expiration_in_repository.yml b/changelogs/unreleased/fix_cache_expiration_in_repository.yml
deleted file mode 100644
index 5f34f2bd040..00000000000
--- a/changelogs/unreleased/fix_cache_expiration_in_repository.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix redundant cache expiration in Repository
-merge_request: 10575
-author: blackst0ne
diff --git a/changelogs/unreleased/fix_emoji_parser.yml b/changelogs/unreleased/fix_emoji_parser.yml
deleted file mode 100644
index 2b1fffe2457..00000000000
--- a/changelogs/unreleased/fix_emoji_parser.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix rendering emoji inside a string
-merge_request: 10647
-author: blackst0ne
diff --git a/changelogs/unreleased/fix_link_in_readme.yml b/changelogs/unreleased/fix_link_in_readme.yml
deleted file mode 100644
index be5ceac8656..00000000000
--- a/changelogs/unreleased/fix_link_in_readme.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix dead link to GDK on the README page
-merge_request:
-author: Dino Maric
diff --git a/changelogs/unreleased/fix_spaces_in_label_title.yml b/changelogs/unreleased/fix_spaces_in_label_title.yml
deleted file mode 100644
index 51f07438edb..00000000000
--- a/changelogs/unreleased/fix_spaces_in_label_title.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Remove heading and trailing spaces from label's color and title
-merge_request: 10603
-author: blackst0ne
diff --git a/changelogs/unreleased/fixes-for-internal-auth-disabled.yml b/changelogs/unreleased/fixes-for-internal-auth-disabled.yml
new file mode 100644
index 00000000000..188d2770455
--- /dev/null
+++ b/changelogs/unreleased/fixes-for-internal-auth-disabled.yml
@@ -0,0 +1,4 @@
+---
+title: Fixes needed when GitLab sign-in is not enabled
+merge_request: 12491
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/forks-count-cache.yml b/changelogs/unreleased/forks-count-cache.yml
new file mode 100644
index 00000000000..da8c53c2abd
--- /dev/null
+++ b/changelogs/unreleased/forks-count-cache.yml
@@ -0,0 +1,5 @@
+---
+title: Cache the number of forks of a project
+merge_request: 13535
+author:
+type: other
diff --git a/changelogs/unreleased/form-focus-previous-incorrect-form.yml b/changelogs/unreleased/form-focus-previous-incorrect-form.yml
deleted file mode 100644
index efabb78de6b..00000000000
--- a/changelogs/unreleased/form-focus-previous-incorrect-form.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixued preview shortcut focusing wrong preview tab
-merge_request:
-author:
diff --git a/changelogs/unreleased/github.yml b/changelogs/unreleased/github.yml
new file mode 100644
index 00000000000..585b9b13b65
--- /dev/null
+++ b/changelogs/unreleased/github.yml
@@ -0,0 +1,4 @@
+---
+title: Reduce memory usage of the GitHub importer
+merge_request: 12886
+author:
diff --git a/changelogs/unreleased/gl-version-backup-file.yml b/changelogs/unreleased/gl-version-backup-file.yml
deleted file mode 100644
index 9b5abd58ae7..00000000000
--- a/changelogs/unreleased/gl-version-backup-file.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Refactor backup/restore docs
-merge_request:
-author:
diff --git a/changelogs/unreleased/group-assignee-dropdown-send-group-id.yml b/changelogs/unreleased/group-assignee-dropdown-send-group-id.yml
deleted file mode 100644
index 4f153f9817d..00000000000
--- a/changelogs/unreleased/group-assignee-dropdown-send-group-id.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed group issues assignee dropdown loading all users
-merge_request:
-author:
diff --git a/changelogs/unreleased/group-milestone-references-system-notes.yml b/changelogs/unreleased/group-milestone-references-system-notes.yml
new file mode 100644
index 00000000000..58215352305
--- /dev/null
+++ b/changelogs/unreleased/group-milestone-references-system-notes.yml
@@ -0,0 +1,4 @@
+---
+title: Support Markdown references, autocomplete, and quick actions for group milestones
+merge_request:
+author:
diff --git a/changelogs/unreleased/group-new-issue.yml b/changelogs/unreleased/group-new-issue.yml
new file mode 100644
index 00000000000..5480a44526b
--- /dev/null
+++ b/changelogs/unreleased/group-new-issue.yml
@@ -0,0 +1,4 @@
+---
+title: Cache recent projects for group-level new resource creation.
+merge_request: !13058
+author:
diff --git a/changelogs/unreleased/handle-reserved-words-for-oauth-usernames.yml b/changelogs/unreleased/handle-reserved-words-for-oauth-usernames.yml
new file mode 100644
index 00000000000..0d64844a2b8
--- /dev/null
+++ b/changelogs/unreleased/handle-reserved-words-for-oauth-usernames.yml
@@ -0,0 +1,4 @@
+---
+title: Uniquify reserved word usernames on OAuth user creation
+merge_request: 13244
+author: Robin Bobbitt
diff --git a/changelogs/unreleased/issue_31790.yml b/changelogs/unreleased/issue_31790.yml
new file mode 100644
index 00000000000..df02cad423a
--- /dev/null
+++ b/changelogs/unreleased/issue_31790.yml
@@ -0,0 +1,4 @@
+---
+title: Fix API responses when dealing with txt files
+merge_request:
+author:
diff --git a/changelogs/unreleased/issue_35580.yml b/changelogs/unreleased/issue_35580.yml
new file mode 100644
index 00000000000..3a94e771e25
--- /dev/null
+++ b/changelogs/unreleased/issue_35580.yml
@@ -0,0 +1,4 @@
+---
+title: Fix project milestones import when projects belongs to a group
+merge_request:
+author:
diff --git a/changelogs/unreleased/memoize-user-personal-projects-count.yml b/changelogs/unreleased/memoize-user-personal-projects-count.yml
new file mode 100644
index 00000000000..3839a97f185
--- /dev/null
+++ b/changelogs/unreleased/memoize-user-personal-projects-count.yml
@@ -0,0 +1,4 @@
+---
+title: Memoize the number of personal projects a user has to reduce COUNT queries
+merge_request:
+author:
diff --git a/changelogs/unreleased/merge-issuable-reopened-into-opened-state.yml b/changelogs/unreleased/merge-issuable-reopened-into-opened-state.yml
new file mode 100644
index 00000000000..5d7af8971e5
--- /dev/null
+++ b/changelogs/unreleased/merge-issuable-reopened-into-opened-state.yml
@@ -0,0 +1,4 @@
+---
+title: Merge issuable "reopened" state into "opened"
+merge_request:
+author:
diff --git a/changelogs/unreleased/metrics-graph-error-fix.yml b/changelogs/unreleased/metrics-graph-error-fix.yml
deleted file mode 100644
index 2698b92e1f1..00000000000
--- a/changelogs/unreleased/metrics-graph-error-fix.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed Prometheus monitoring graphs not showing empty states in certain scenarios
-merge_request:
-author:
diff --git a/changelogs/unreleased/migrate-events-into-a-new-format.yml b/changelogs/unreleased/migrate-events-into-a-new-format.yml
new file mode 100644
index 00000000000..8a29f75323f
--- /dev/null
+++ b/changelogs/unreleased/migrate-events-into-a-new-format.yml
@@ -0,0 +1,4 @@
+---
+title: Migrate events into a new format to reduce the storage necessary and improve performance
+merge_request:
+author:
diff --git a/changelogs/unreleased/milestone-not-showing-correctly-title.yml b/changelogs/unreleased/milestone-not-showing-correctly-title.yml
deleted file mode 100644
index 7c21094d737..00000000000
--- a/changelogs/unreleased/milestone-not-showing-correctly-title.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Removed the milestone references from the milestone views
-merge_request:
-author:
diff --git a/changelogs/unreleased/mk-fix-wiki-backup.yml b/changelogs/unreleased/mk-fix-wiki-backup.yml
new file mode 100644
index 00000000000..ba9c1e85955
--- /dev/null
+++ b/changelogs/unreleased/mk-fix-wiki-backup.yml
@@ -0,0 +1,4 @@
+---
+title: Fix improperly skipped backups of wikis.
+merge_request: 13096
+author:
diff --git a/changelogs/unreleased/more-mr-filters.yml b/changelogs/unreleased/more-mr-filters.yml
deleted file mode 100644
index 3c2114f6614..00000000000
--- a/changelogs/unreleased/more-mr-filters.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: 'API: Filter merge requests by milestone and labels'
-merge_request: Robert Schilling
-author: 10924
diff --git a/changelogs/unreleased/move-search-labels.yml b/changelogs/unreleased/move-search-labels.yml
deleted file mode 100644
index 3a1d23d622e..00000000000
--- a/changelogs/unreleased/move-search-labels.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Move labels of search results from bottom to title
-merge_request: 10705
-author: dr
diff --git a/changelogs/unreleased/mr-branch-link-use-tree.yml b/changelogs/unreleased/mr-branch-link-use-tree.yml
new file mode 100644
index 00000000000..f4c4d9f5082
--- /dev/null
+++ b/changelogs/unreleased/mr-branch-link-use-tree.yml
@@ -0,0 +1,4 @@
+---
+title: MR branch link now links to tree instead of commits
+merge_request:
+author:
diff --git a/changelogs/unreleased/mr-diff-size-overflow.yml b/changelogs/unreleased/mr-diff-size-overflow.yml
deleted file mode 100644
index 87449930cf2..00000000000
--- a/changelogs/unreleased/mr-diff-size-overflow.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Show sizes correctly in merge requests when diffs overflow
-merge_request:
-author:
diff --git a/changelogs/unreleased/mrchrisw-22740-merge-api.yml b/changelogs/unreleased/mrchrisw-22740-merge-api.yml
deleted file mode 100644
index e75160aec70..00000000000
--- a/changelogs/unreleased/mrchrisw-22740-merge-api.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix updating merge_when_build_succeeds via merge API endpoint
-merge_request: 10873
-author:
diff --git a/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml b/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml
deleted file mode 100644
index 3b9284258cb..00000000000
--- a/changelogs/unreleased/omnibus-gitlab-1993-check-shell-repositories-path-group-is-root.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: "Make the `gitlab:gitlab_shell:check` task check that the repositories storage path are owned by the `root` group"
-merge_request:
-author:
diff --git a/changelogs/unreleased/optimise-pipelines-json.yml b/changelogs/unreleased/optimise-pipelines-json.yml
deleted file mode 100644
index 948679dcbeb..00000000000
--- a/changelogs/unreleased/optimise-pipelines-json.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Optimise pipelines.json endpoint
-merge_request:
-author:
diff --git a/changelogs/unreleased/pagination-projects-explore.yml b/changelogs/unreleased/pagination-projects-explore.yml
new file mode 100644
index 00000000000..dc9c4218793
--- /dev/null
+++ b/changelogs/unreleased/pagination-projects-explore.yml
@@ -0,0 +1,4 @@
+---
+title: Use Prev/Next pagination for exploring projects
+merge_request:
+author:
diff --git a/changelogs/unreleased/pass-before-script-as-is.yml b/changelogs/unreleased/pass-before-script-as-is.yml
new file mode 100644
index 00000000000..ac6513dcff6
--- /dev/null
+++ b/changelogs/unreleased/pass-before-script-as-is.yml
@@ -0,0 +1,4 @@
+---
+title: Pass before_script and script as-is preserving arrays
+merge_request:
+author:
diff --git a/changelogs/unreleased/pawel-add-sidekiq-metrics-endpoint-32145.yml b/changelogs/unreleased/pawel-add-sidekiq-metrics-endpoint-32145.yml
new file mode 100644
index 00000000000..71eabdc16d2
--- /dev/null
+++ b/changelogs/unreleased/pawel-add-sidekiq-metrics-endpoint-32145.yml
@@ -0,0 +1,4 @@
+---
+title: Add Prometheus metrics exporter to Sidekiq
+merge_request: 13082
+author:
diff --git a/changelogs/unreleased/pawel-add_more_variables_to_additional_metrics-35267.yml b/changelogs/unreleased/pawel-add_more_variables_to_additional_metrics-35267.yml
new file mode 100644
index 00000000000..c1e831306df
--- /dev/null
+++ b/changelogs/unreleased/pawel-add_more_variables_to_additional_metrics-35267.yml
@@ -0,0 +1,4 @@
+---
+title: Add support for kube_namespace in Metrics queries
+merge_request: 16169
+author:
diff --git a/changelogs/unreleased/post-upload-pack-opt-out.yml b/changelogs/unreleased/post-upload-pack-opt-out.yml
new file mode 100644
index 00000000000..302a99795a0
--- /dev/null
+++ b/changelogs/unreleased/post-upload-pack-opt-out.yml
@@ -0,0 +1,4 @@
+---
+title: Enable gitaly_post_upload_pack by default
+merge_request: 13078
+author:
diff --git a/changelogs/unreleased/query-users-by-extern-uid.yml b/changelogs/unreleased/query-users-by-extern-uid.yml
deleted file mode 100644
index 39d1cf8d3f3..00000000000
--- a/changelogs/unreleased/query-users-by-extern-uid.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Implement search by extern_uid in Users API
-merge_request: 10509
-author: Robin Bobbitt
diff --git a/changelogs/unreleased/rc-fix-branches-api-endpoint.yml b/changelogs/unreleased/rc-fix-branches-api-endpoint.yml
new file mode 100644
index 00000000000..b36663bbe91
--- /dev/null
+++ b/changelogs/unreleased/rc-fix-branches-api-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the /projects/:id/repository/branches endpoint to handle dots in the branch
+ name when the project full path contains a `/`
+merge_request: 13115
+author:
diff --git a/changelogs/unreleased/rc-fix-commits-api.yml b/changelogs/unreleased/rc-fix-commits-api.yml
new file mode 100644
index 00000000000..215429eaf6b
--- /dev/null
+++ b/changelogs/unreleased/rc-fix-commits-api.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the /projects/:id/repository/commits endpoint to handle dots in the ref
+ name when the project full path contains a `/`
+merge_request: 13370
+author:
diff --git a/changelogs/unreleased/rc-fix-tags-api.yml b/changelogs/unreleased/rc-fix-tags-api.yml
new file mode 100644
index 00000000000..0a7dd5ca6ab
--- /dev/null
+++ b/changelogs/unreleased/rc-fix-tags-api.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the /projects/:id/repository/tags endpoint to handle dots in the tag name
+ when the project full path contains a `/`
+merge_request: 13368
+author:
diff --git a/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml b/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml
deleted file mode 100644
index 198b6ce15ae..00000000000
--- a/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fixed alignment of CI icon in issues related branches
-merge_request:
-author:
diff --git a/changelogs/unreleased/remove-double-newline-for-single-attachments.yml b/changelogs/unreleased/remove-double-newline-for-single-attachments.yml
deleted file mode 100644
index 98a28e1ede1..00000000000
--- a/changelogs/unreleased/remove-double-newline-for-single-attachments.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Only add newlines between multiple uploads
-merge_request: 10545
-author:
diff --git a/changelogs/unreleased/remove-nprogress-gleaning.yml b/changelogs/unreleased/remove-nprogress-gleaning.yml
new file mode 100644
index 00000000000..78e4dc82dd4
--- /dev/null
+++ b/changelogs/unreleased/remove-nprogress-gleaning.yml
@@ -0,0 +1,4 @@
+---
+title: Remove CSS for nprogress removed
+merge_request: 12737
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/remove-redundant-query-when-retrieving-recent-pushes.yml b/changelogs/unreleased/remove-redundant-query-when-retrieving-recent-pushes.yml
new file mode 100644
index 00000000000..83934217e6a
--- /dev/null
+++ b/changelogs/unreleased/remove-redundant-query-when-retrieving-recent-pushes.yml
@@ -0,0 +1,4 @@
+---
+title: Remove redundant query when retrieving the most recent push of a user
+merge_request:
+author:
diff --git a/changelogs/unreleased/reorganise-issues-indexes-for-sorting.yml b/changelogs/unreleased/reorganise-issues-indexes-for-sorting.yml
new file mode 100644
index 00000000000..5bfe55e562f
--- /dev/null
+++ b/changelogs/unreleased/reorganise-issues-indexes-for-sorting.yml
@@ -0,0 +1,4 @@
+---
+title: Re-organise "issues" indexes for faster ordering
+merge_request:
+author:
diff --git a/changelogs/unreleased/replace_header_mr_icon.yml b/changelogs/unreleased/replace_header_mr_icon.yml
deleted file mode 100644
index 2ef6500f88a..00000000000
--- a/changelogs/unreleased/replace_header_mr_icon.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Replace header merge request icon
-merge_request: 10932
-author: blackst0ne
diff --git a/changelogs/unreleased/replace_spinach_spec_browse_files.yml b/changelogs/unreleased/replace_spinach_spec_browse_files.yml
new file mode 100644
index 00000000000..7380d39fa9f
--- /dev/null
+++ b/changelogs/unreleased/replace_spinach_spec_browse_files.yml
@@ -0,0 +1,4 @@
+---
+title: Replace 'browse_files.feature' spinach test with an rspec analog
+merge_request: 12251
+author: @blackst0ne
diff --git a/changelogs/unreleased/request-store-wrap.yml b/changelogs/unreleased/request-store-wrap.yml
new file mode 100644
index 00000000000..8017054b77b
--- /dev/null
+++ b/changelogs/unreleased/request-store-wrap.yml
@@ -0,0 +1,4 @@
+---
+title: Add RequestCache which makes caching with RequestStore easier
+merge_request: 12920
+author:
diff --git a/changelogs/unreleased/reset-new-branch-button.yml b/changelogs/unreleased/reset-new-branch-button.yml
deleted file mode 100644
index 318ee46298f..00000000000
--- a/changelogs/unreleased/reset-new-branch-button.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Reset New branch button when issue state changes
-merge_request: 5962
-author: winniehell
diff --git a/changelogs/unreleased/restrict-haml-javascript.yml b/changelogs/unreleased/restrict-haml-javascript.yml
new file mode 100644
index 00000000000..3d0a52f416d
--- /dev/null
+++ b/changelogs/unreleased/restrict-haml-javascript.yml
@@ -0,0 +1,4 @@
+---
+title: Add custom linter for inline JavaScript to haml_lint
+merge_request: 9742
+author: winniehell
diff --git a/changelogs/unreleased/right-sidebar-closed-default-mobile.yml b/changelogs/unreleased/right-sidebar-closed-default-mobile.yml
deleted file mode 100644
index cf0ec418f0e..00000000000
--- a/changelogs/unreleased/right-sidebar-closed-default-mobile.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Set the issuable sidebar to remain closed for mobile devices
-merge_request:
-author:
diff --git a/changelogs/unreleased/rs-alphanumeric-ssh-params.yml b/changelogs/unreleased/rs-alphanumeric-ssh-params.yml
new file mode 100644
index 00000000000..426b01cafad
--- /dev/null
+++ b/changelogs/unreleased/rs-alphanumeric-ssh-params.yml
@@ -0,0 +1,5 @@
+---
+title: Disallow Git URLs that include a username or hostname beginning with a non-alphanumeric
+ character
+merge_request:
+author:
diff --git a/changelogs/unreleased/seven-days-cycle-analytics.yml b/changelogs/unreleased/seven-days-cycle-analytics.yml
new file mode 100644
index 00000000000..ff660bdd603
--- /dev/null
+++ b/changelogs/unreleased/seven-days-cycle-analytics.yml
@@ -0,0 +1,5 @@
+---
+title: Add a `Last 7 days` option for Cycle Analytics view
+merge_request: 13443
+author: Mehdi Lahmam (@mehlah)
+type: added
diff --git a/changelogs/unreleased/sh-bump-sidekiq-version.yml b/changelogs/unreleased/sh-bump-sidekiq-version.yml
deleted file mode 100644
index 5369b78b76a..00000000000
--- a/changelogs/unreleased/sh-bump-sidekiq-version.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Upgrade Sidekiq to 4.2.10
-merge_request:
-author:
diff --git a/changelogs/unreleased/sh-optimize-duplicate-routable-full-path.yml b/changelogs/unreleased/sh-optimize-duplicate-routable-full-path.yml
deleted file mode 100644
index b1ef00f09b2..00000000000
--- a/changelogs/unreleased/sh-optimize-duplicate-routable-full-path.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Cache Routable#full_path in RequestStore to reduce duplicate route loads
-merge_request:
-author:
diff --git a/changelogs/unreleased/sh-structured-logging.yml b/changelogs/unreleased/sh-structured-logging.yml
new file mode 100644
index 00000000000..d89eb93f689
--- /dev/null
+++ b/changelogs/unreleased/sh-structured-logging.yml
@@ -0,0 +1,4 @@
+---
+title: Add structured logging for Rails processes
+merge_request:
+author:
diff --git a/changelogs/unreleased/skip-oauth-authorization-for-trusted-applications.yml b/changelogs/unreleased/skip-oauth-authorization-for-trusted-applications.yml
new file mode 100644
index 00000000000..7b4ae355978
--- /dev/null
+++ b/changelogs/unreleased/skip-oauth-authorization-for-trusted-applications.yml
@@ -0,0 +1,4 @@
+---
+title: Skip oAuth authorization for trusted applications
+merge_request:
+author:
diff --git a/changelogs/unreleased/spec_for_schema.yml b/changelogs/unreleased/spec_for_schema.yml
deleted file mode 100644
index 7ea0b8672ce..00000000000
--- a/changelogs/unreleased/spec_for_schema.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add spec for schema.rb
-merge_request: 10580
-author: blackst0ne
diff --git a/changelogs/unreleased/submodules-no-dotgit.yml b/changelogs/unreleased/submodules-no-dotgit.yml
deleted file mode 100644
index 2ff0ee997fa..00000000000
--- a/changelogs/unreleased/submodules-no-dotgit.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: 'repository browser: handle submodule urls that don''t end with .git'
-merge_request:
-author: David Turner
diff --git a/changelogs/unreleased/tc-api-root-merge-requests.yml b/changelogs/unreleased/tc-api-root-merge-requests.yml
new file mode 100644
index 00000000000..17456f943eb
--- /dev/null
+++ b/changelogs/unreleased/tc-api-root-merge-requests.yml
@@ -0,0 +1,4 @@
+---
+title: Add top-level merge_requests API endpoint
+merge_request: 13060
+author:
diff --git a/changelogs/unreleased/tc-issue-api-assignee.yml b/changelogs/unreleased/tc-issue-api-assignee.yml
new file mode 100644
index 00000000000..8d6360d5baf
--- /dev/null
+++ b/changelogs/unreleased/tc-issue-api-assignee.yml
@@ -0,0 +1,4 @@
+---
+title: Add author_id & assignee_id param to /issues API
+merge_request: 13004
+author:
diff --git a/changelogs/unreleased/tc-make-user-master-project-by-admin.yml b/changelogs/unreleased/tc-make-user-master-project-by-admin.yml
deleted file mode 100644
index 459d6178bdd..00000000000
--- a/changelogs/unreleased/tc-make-user-master-project-by-admin.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Ensure namespace owner is Master of project upon creation
-merge_request: 10910
-author:
diff --git a/changelogs/unreleased/tc-no-todo-service-select.yml b/changelogs/unreleased/tc-no-todo-service-select.yml
new file mode 100644
index 00000000000..ddcae334aa7
--- /dev/null
+++ b/changelogs/unreleased/tc-no-todo-service-select.yml
@@ -0,0 +1,4 @@
+---
+title: Avoid plucking Todo ids in TodoService
+merge_request: 10845
+author:
diff --git a/changelogs/unreleased/toggle-new-project-import-description.yml b/changelogs/unreleased/toggle-new-project-import-description.yml
new file mode 100644
index 00000000000..8f0d09e0540
--- /dev/null
+++ b/changelogs/unreleased/toggle-new-project-import-description.yml
@@ -0,0 +1,4 @@
+---
+title: Toggle import description with import_sources_enabled
+merge_request: 12691
+author: Brianna Kicia \ No newline at end of file
diff --git a/changelogs/unreleased/uassign_on_member_removing.yml b/changelogs/unreleased/uassign_on_member_removing.yml
deleted file mode 100644
index cd60bdf5b3d..00000000000
--- a/changelogs/unreleased/uassign_on_member_removing.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Unassign all Issues and Merge Requests when member leaves a team
-merge_request:
-author:
diff --git a/changelogs/unreleased/use-a-specialized-class-for-querying-events.yml b/changelogs/unreleased/use-a-specialized-class-for-querying-events.yml
new file mode 100644
index 00000000000..6c1ec10aa12
--- /dev/null
+++ b/changelogs/unreleased/use-a-specialized-class-for-querying-events.yml
@@ -0,0 +1,4 @@
+---
+title: Use a specialized class for querying events to improve performance
+merge_request:
+author:
diff --git a/changelogs/unreleased/use-hashie-forbidden_attributes.yml b/changelogs/unreleased/use-hashie-forbidden_attributes.yml
deleted file mode 100644
index 4f429b03a0d..00000000000
--- a/changelogs/unreleased/use-hashie-forbidden_attributes.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Add hashie-forbidden_attributes gem
-merge_request: 10579
-author: Andy Brown
diff --git a/changelogs/unreleased/user-activity-scroll-bar.yml b/changelogs/unreleased/user-activity-scroll-bar.yml
deleted file mode 100644
index 97cccee42cb..00000000000
--- a/changelogs/unreleased/user-activity-scroll-bar.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Fix preemptive scroll bar on user activity calendar.
-merge_request: !10636
-author:
diff --git a/changelogs/unreleased/wiki_title.yml b/changelogs/unreleased/wiki_title.yml
new file mode 100644
index 00000000000..3ef5fa2969b
--- /dev/null
+++ b/changelogs/unreleased/wiki_title.yml
@@ -0,0 +1,4 @@
+---
+title: Allow wiki pages to be renamed in the UI
+merge_request: 10069
+author: wendy0402
diff --git a/changelogs/unreleased/winh-derive-project-name.yml b/changelogs/unreleased/winh-derive-project-name.yml
new file mode 100644
index 00000000000..2244d21d768
--- /dev/null
+++ b/changelogs/unreleased/winh-derive-project-name.yml
@@ -0,0 +1,4 @@
+---
+title: Derive project path from import URL
+merge_request: 13131
+author:
diff --git a/changelogs/unreleased/zj-delete-mm-team.yml b/changelogs/unreleased/zj-delete-mm-team.yml
new file mode 100644
index 00000000000..f0c782c4566
--- /dev/null
+++ b/changelogs/unreleased/zj-delete-mm-team.yml
@@ -0,0 +1,4 @@
+---
+title: Remove Mattermost team when deleting a group
+merge_request: 11362
+author:
diff --git a/changelogs/unreleased/zj-dockerfiles.yml b/changelogs/unreleased/zj-dockerfiles.yml
deleted file mode 100644
index 40cb7dcfb76..00000000000
--- a/changelogs/unreleased/zj-dockerfiles.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Dockerfiles templates are imported from gitlab.com/gitlab-org/Dockerfile
-merge_request: 10663
-author:
diff --git a/changelogs/unreleased/zj-pipeline-badge-improvements.yml b/changelogs/unreleased/zj-pipeline-badge-improvements.yml
new file mode 100644
index 00000000000..735192ede2d
--- /dev/null
+++ b/changelogs/unreleased/zj-pipeline-badge-improvements.yml
@@ -0,0 +1,4 @@
+---
+title: Update build badges to be pipeline badges and display passing instead of success
+merge_request:
+author:
diff --git a/changelogs/unreleased/zj-project-templates.yml b/changelogs/unreleased/zj-project-templates.yml
new file mode 100644
index 00000000000..ab6e0f2d5f2
--- /dev/null
+++ b/changelogs/unreleased/zj-project-templates.yml
@@ -0,0 +1,4 @@
+---
+title: Projects can be created from templates
+merge_request: 13108
+author:
diff --git a/changelogs/unreleased/zj-upgrade-grape.yml b/changelogs/unreleased/zj-upgrade-grape.yml
new file mode 100644
index 00000000000..daa6a234c07
--- /dev/null
+++ b/changelogs/unreleased/zj-upgrade-grape.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade grape to 1.0
+merge_request:
+author:
+type: other
diff --git a/config/README.md b/config/README.md
index 0a5ea2424e0..2778d0d4f02 100644
--- a/config/README.md
+++ b/config/README.md
@@ -19,4 +19,132 @@ an ERB file and then loads the resulting YML as its configuration.
This file is called `resque.yml` for historical reasons. We are **NOT**
using Resque at the moment. It is used to specify Redis configuration
-values instead.
+values when a single database instance of Redis is desired.
+
+# Advanced Redis configuration files
+
+In more advanced configurations of Redis key-value storage, it is desirable
+to separate the keys by lifecycle and intended use to ease provisioning and
+management of scalable Redis clusters.
+
+These settings provide routing and other configuration data (such as sentinel,
+persistence policies, and other Redis customization) for connections
+to Redis single instances, Redis sentinel, and Redis clusters.
+
+If desired, the routing URL provided by these settings can be used with:
+1. Unix Socket
+ 1. named socket for each Redis instance desired.
+ 2. `database number` for each Redis instance desired.
+2. TCP Socket
+ 1. `host name` or IP for each Redis instance desired
+ 2. TCP port number for each Redis instance desired
+ 3. `database number` for each Redis instance desired
+
+## Example URL attribute formats for GitLab Redis `.yml` configuration files
+* Unix Socket, default Redis database (0)
+ * `url: unix:/path/to/redis.sock`
+ * `url: unix:/path/to/redis.sock?db=`
+* Unix Socket, Redis database 44
+ * `url: unix:/path/to/redis.sock?db=44`
+ * `url: unix:/path/to/redis.sock?extra=foo&db=44`
+* TCP Socket for Redis on localhost, port 6379, database 33
+ * `url: redis://:mynewpassword@localhost:6379/33`
+* TCP Socket for Redis on remote host `myserver`, port 6379, database 33
+ * `url: redis://:mynewpassword@myserver:6379/33`
+
+## redis.cache.yml
+
+If configured, `redis.cache.yml` overrides the
+`resque.yml` settings to configure the Redis database instance
+used for `Rails.cache` and other volatile non-persistent data which enhances
+the performance of GitLab.
+Settings here can be overridden by the environment variable
+`GITLAB_REDIS_CACHE_CONFIG_FILE` which provides
+an alternate location for configuration settings.
+
+The order of precedence for the URL used to connect to the Redis instance
+used for `cache` is:
+1. URL from a configuration file pointed to by the
+`GITLAB_REDIS_CACHE_CONFIG_FILE` environment variable
+2. URL from `redis.cache.yml`
+3. URL from a configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. URL from `resque.yml`
+5. `redis://localhost:6380`
+
+The order of precedence for all other configuration settings for `cache`
+are selected from only the first of the following files found (if a setting
+is not provided in an earlier file, the remainder of the files are not
+searched):
+1. the configuration file pointed to by the
+`GITLAB_REDIS_CACHE_CONFIG_FILE` environment variable
+2. the configuration file `redis.cache.yml`
+3. the configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. the configuration file `resque.yml`
+
+## redis.queues.yml
+
+If configured, `redis.queues.yml` overrides the
+`resque.yml` settings to configure the Redis database instance
+used for clients of `::Gitlab::Redis::Queues`.
+These queues are intended to be the foundation
+of reliable inter-process communication between modules, whether on the same
+host node, or within a cluster. The primary clients of the queues are
+SideKiq, Mailroom, CI Runner, Workhorse, and push services. Settings here can
+be overridden by the environment variable
+`GITLAB_REDIS_QUEUES_CONFIG_FILE` which provides an alternate location for
+configuration settings.
+
+The order of precedence for the URL used to connect to the Redis instance
+used for `queues` is:
+1. URL from a configuration file pointed to by the
+`GITLAB_REDIS_QUEUES_CONFIG_FILE` environment variable
+2. URL from `redis.queues.yml`
+3. URL from a configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. URL from `resque.yml`
+5. `redis://localhost:6381`
+
+The order of precedence for all other configuration settings for `queues`
+are selected from only the first of the following files found (if a setting
+is not provided in an earlier file, the remainder of the files are not
+searched):
+1. the configuration file pointed to by the
+`GITLAB_REDIS_QUEUES_CONFIG_FILE` environment variable
+2. the configuration file `redis.queues.yml`
+3. the configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. the configuration file `resque.yml`
+
+## redis.shared_state.yml
+
+If configured, `redis.shared_state.yml` overrides the
+`resque.yml` settings to configure the Redis database instance
+used for clients of `::Gitlab::Redis::SharedState` such as session state,
+and rate limiting.
+Settings here can be overridden by the environment variable
+`GITLAB_REDIS_SHARED_STATE_CONFIG_FILE` which provides
+an alternate location for configuration settings.
+
+The order of precedence for the URL used to connect to the Redis instance
+used for `shared_state` is:
+1. URL from a configuration file pointed to by the
+`GITLAB_REDIS_SHARED_STATE_CONFIG_FILE` environment variable
+2. URL from `redis.shared_state.yml`
+3. URL from a configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. URL from `resque.yml`
+5. `redis://localhost:6382`
+
+The order of precedence for all other configuration settings for `shared_state`
+are selected from only the first of the following files found (if a setting
+is not provided in an earlier file, the remainder of the files are not
+searched):
+1. the configuration file pointed to by the
+`GITLAB_REDIS_SHARED_STATE_CONFIG_FILE` environment variable
+2. the configuration file `redis.shared_state.yml`
+3. the configuration file pointed to by the
+`GITLAB_REDIS_CONFIG_FILE` environment variable
+4. the configuration file `resque.yml`
+
diff --git a/config/application.rb b/config/application.rb
index f2ecc4ce77c..f69dab4de39 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -6,7 +6,9 @@ Bundler.require(:default, Rails.env)
module Gitlab
class Application < Rails::Application
- require_dependency Rails.root.join('lib/gitlab/redis')
+ require_dependency Rails.root.join('lib/gitlab/redis/cache')
+ require_dependency Rails.root.join('lib/gitlab/redis/queues')
+ require_dependency Rails.root.join('lib/gitlab/redis/shared_state')
require_dependency Rails.root.join('lib/gitlab/request_context')
# Settings in config/environments/* take precedence over those specified here.
@@ -21,13 +23,13 @@ module Gitlab
# https://github.com/rails/rails/blob/v4.2.6/railties/lib/rails/engine.rb#L687
# This is a nice reference article on autoloading/eager loading:
# http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload
- config.eager_load_paths.push(*%W(#{config.root}/lib
- #{config.root}/app/models/ci
+ config.eager_load_paths.push(*%W[#{config.root}/lib
#{config.root}/app/models/hooks
#{config.root}/app/models/members
#{config.root}/app/models/project_services
#{config.root}/app/workers/concerns
- #{config.root}/app/services/concerns))
+ #{config.root}/app/services/concerns
+ #{config.root}/app/finders/concerns])
config.generators.templates.push("#{config.root}/generator_templates")
@@ -40,6 +42,9 @@ module Gitlab
# config.i18n.default_locale = :de
config.i18n.enforce_available_locales = false
+ # Translation for AR attrs is not working well for POROs like WikiPage
+ config.gettext_i18n_rails.use_for_active_record_attributes = false
+
# Configure the default encoding used in templates for Ruby 1.9.
config.encoding = "utf-8"
@@ -63,6 +68,7 @@ module Gitlab
hook
import_url
incoming_email_token
+ rss_token
key
otp_attempt
password
@@ -102,8 +108,12 @@ module Gitlab
config.assets.precompile << "katex.css"
config.assets.precompile << "katex.js"
config.assets.precompile << "xterm/xterm.css"
+ config.assets.precompile << "performance_bar.css"
config.assets.precompile << "lib/ace.js"
config.assets.precompile << "vendor/assets/fonts/*"
+ config.assets.precompile << "test.css"
+ config.assets.precompile << "new_nav.css"
+ config.assets.precompile << "new_sidebar.css"
# Version of your assets, change this if you want to expire all your assets
config.assets.version = '1.0'
@@ -134,15 +144,15 @@ module Gitlab
end
end
- # Use Redis caching across all environments
- redis_config_hash = Gitlab::Redis.params
- redis_config_hash[:namespace] = Gitlab::Redis::CACHE_NAMESPACE
- redis_config_hash[:expires_in] = 2.weeks # Cache should not grow forever
+ # Use caching across all environments
+ caching_config_hash = Gitlab::Redis::Cache.params
+ caching_config_hash[:namespace] = Gitlab::Redis::Cache::CACHE_NAMESPACE
+ caching_config_hash[:expires_in] = 2.weeks # Cache should not grow forever
if Sidekiq.server? # threaded context
- redis_config_hash[:pool_size] = Sidekiq.options[:concurrency] + 5
- redis_config_hash[:pool_timeout] = 1
+ caching_config_hash[:pool_size] = Sidekiq.options[:concurrency] + 5
+ caching_config_hash[:pool_timeout] = 1
end
- config.cache_store = :redis_store, redis_config_hash
+ config.cache_store = :redis_store, caching_config_hash
config.active_record.raise_in_transactional_callbacks = true
@@ -155,5 +165,27 @@ module Gitlab
config.generators do |g|
g.factory_girl false
end
+
+ config.after_initialize do
+ Rails.application.reload_routes!
+
+ project_url_helpers = Module.new do
+ extend ActiveSupport::Concern
+
+ Gitlab::Application.routes.named_routes.helper_names.each do |name|
+ next unless name.include?('namespace_project')
+
+ define_method(name.sub('namespace_project', 'project')) do |project, *args|
+ send(name, project&.namespace, project, *args) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+
+ # We add the MilestonesRoutingHelper because we know that this does not
+ # conflict with the methods defined in `project_url_helpers`, and we want
+ # these methods available in the same places.
+ Gitlab::Routing.add_helpers(project_url_helpers)
+ Gitlab::Routing.add_helpers(MilestonesRoutingHelper)
+ end
end
end
diff --git a/config/database.yml.mysql b/config/database.yml.mysql
index db1b712d3bc..eb71d3f5fe1 100644
--- a/config/database.yml.mysql
+++ b/config/database.yml.mysql
@@ -42,3 +42,4 @@ test: &test
password:
# host: localhost
# socket: /tmp/mysql.sock
+ prepared_statements: false
diff --git a/config/database.yml.postgresql b/config/database.yml.postgresql
index c517a4c0cb8..4b30982fe82 100644
--- a/config/database.yml.postgresql
+++ b/config/database.yml.postgresql
@@ -46,3 +46,4 @@ test: &test
username: postgres
password:
# host: localhost
+ prepared_statements: false
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 59c7050a14d..ca5b941aebf 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -398,3 +398,9 @@
:why: https://github.com/remy/undefsafe/blob/master/LICENSE
:versions: []
:when: 2017-04-10 06:30:00.002555000 Z
+- - :approve
+ - thunky
+ - :who: Mike Greiling
+ :why: https://github.com/mafintosh/thunky/blob/master/README.md#license
+ :versions: []
+ :when: 2017-08-07 05:56:09.907045000 Z
diff --git a/config/environments/production.rb b/config/environments/production.rb
index a9d8ac4b6d4..c5cbfcf64cf 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -16,7 +16,7 @@ Rails.application.configure do
# config.assets.css_compressor = :sass
# Don't fallback to assets pipeline if a precompiled asset is missed
- config.assets.compile = true
+ config.assets.compile = false
# Generate digests for assets URLs
config.assets.digest = true
@@ -50,7 +50,7 @@ Rails.application.configure do
# config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
# Enable serving of images, stylesheets, and JavaScripts from an asset server
- # config.action_controller.asset_host = "http://assets.example.com"
+ config.action_controller.asset_host = ENV['GITLAB_CDN_HOST'] if ENV['GITLAB_CDN_HOST'].present?
# Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added)
# config.assets.precompile += %w( search.js )
diff --git a/config/environments/test.rb b/config/environments/test.rb
index c3b788c038e..278144b8943 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -43,4 +43,9 @@ Rails.application.configure do
config.cache_store = :null_store
config.active_job.queue_adapter = :test
+
+ if ENV['CI'] && !ENV['RAILS_ENABLE_TEST_LOG']
+ config.logger = ActiveSupport::TaggedLogging.new(Logger.new(nil))
+ config.log_level = :fatal
+ end
end
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index c2eaf263937..25285525846 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -169,7 +169,7 @@ production: &base
## Gravatar
## For Libravatar see: http://doc.gitlab.com/ce/customization/libravatar.html
gravatar:
- # gravatar urls: possible placeholders: %{hash} %{size} %{email}
+ # gravatar urls: possible placeholders: %{hash} %{size} %{email} %{username}
# plain_url: "http://..." # default: http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon
# ssl_url: "https://..." # default: https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon
@@ -181,8 +181,8 @@ production: &base
stuck_ci_jobs_worker:
cron: "0 * * * *"
# Execute scheduled triggers
- trigger_schedule_worker:
- cron: "0 */12 * * *"
+ pipeline_schedule_worker:
+ cron: "19 * * * *"
# Remove expired build artifacts
expire_build_artifacts_worker:
cron: "50 * * * *"
@@ -228,7 +228,8 @@ production: &base
# ==========================
## LDAP settings
- # You can inspect a sample of the LDAP users with login access by running:
+ # You can test connections and inspect a sample of the LDAP users with login
+ # access by running:
# bundle exec rake gitlab:ldap:check RAILS_ENV=production
ldap:
enabled: false
@@ -251,13 +252,45 @@ production: &base
# Example: 'Paris' or 'Acme, Ltd.'
label: 'LDAP'
+ # Example: 'ldap.mydomain.com'
host: '_your_ldap_server'
- port: 389
- uid: 'sAMAccountName'
- method: 'plain' # "tls" or "ssl" or "plain"
+ # This port is an example, it is sometimes different but it is always an integer and not a string
+ port: 389 # usually 636 for SSL
+ uid: 'sAMAccountName' # This should be the attribute, not the value that maps to uid.
+
+ # Examples: 'america\\momo' or 'CN=Gitlab Git,CN=Users,DC=mydomain,DC=com'
bind_dn: '_the_full_dn_of_the_user_you_will_bind_with'
password: '_the_password_of_the_bind_user'
+ # Encryption method. The "method" key is deprecated in favor of
+ # "encryption".
+ #
+ # Examples: "start_tls" or "simple_tls" or "plain"
+ #
+ # Deprecated values: "tls" was replaced with "start_tls" and "ssl" was
+ # replaced with "simple_tls".
+ #
+ encryption: 'plain'
+
+ # Enables SSL certificate verification if encryption method is
+ # "start_tls" or "simple_tls". (Defaults to false for backward-
+ # compatibility)
+ verify_certificates: false
+
+ # Specifies the path to a file containing a PEM-format CA certificate,
+ # e.g. if you need to use an internal CA.
+ #
+ # Example: '/etc/ca.pem'
+ #
+ ca_file: ''
+
+ # Specifies the SSL version for OpenSSL to use, if the OpenSSL default
+ # is not appropriate.
+ #
+ # Example: 'TLSv1_1'
+ #
+ ssl_version: ''
+
# Set a timeout, in seconds, for LDAP queries. This helps avoid blocking
# a request if the LDAP server becomes unresponsive.
# A value of 0 means there is no timeout.
@@ -286,17 +319,20 @@ production: &base
# Base where we can search for users
#
- # Ex. ou=People,dc=gitlab,dc=example
+ # Ex. 'ou=People,dc=gitlab,dc=example' or 'DC=mydomain,DC=com'
#
base: ''
# Filter LDAP users
#
- # Format: RFC 4515 http://tools.ietf.org/search/rfc4515
+ # Format: RFC 4515 https://tools.ietf.org/search/rfc4515
# Ex. (employeeType=developer)
#
# Note: GitLab does not support omniauth-ldap's custom filter syntax.
#
+ # Example for getting only specific users:
+ # '(&(objectclass=user)(|(samaccountname=momo)(samaccountname=toto)))'
+ #
user_filter: ''
# LDAP attributes that GitLab will use to create an account for the LDAP user.
@@ -337,6 +373,10 @@ production: &base
# showing GitLab's sign-in page (default: show the GitLab sign-in page)
# auto_sign_in_with_provider: saml
+ # Sync user's email address from the specified Omniauth provider every time the user logs
+ # in (default: nil). And consequently make this field read-only.
+ # sync_email_from_provider: cas3
+
# CAUTION!
# This allows users to login without having a user account first. Define the allowed providers
# using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none.
@@ -379,13 +419,13 @@ production: &base
# service_validate_url: '/cas/p3/serviceValidate',
# logout_url: '/cas/logout'} }
# - { name: 'authentiq',
- # # for client credentials (client ID and secret), go to https://www.authentiq.com/
+ # # for client credentials (client ID and secret), go to https://www.authentiq.com/developers
# app_id: 'YOUR_CLIENT_ID',
# app_secret: 'YOUR_CLIENT_SECRET',
# args: {
# scope: 'aq:name email~rs address aq:push'
- # # redirect_uri parameter is optional except when 'gitlab.host' in this file is set to 'localhost'
- # # redirect_uri: 'YOUR_REDIRECT_URI'
+ # # callback_url parameter is optional except when 'gitlab.host' in this file is set to 'localhost'
+ # # callback_url: 'YOUR_CALLBACK_URL'
# }
# }
# - { name: 'github',
@@ -446,10 +486,10 @@ production: &base
# Gitaly settings
gitaly:
- # This setting controls whether GitLab uses Gitaly (new component
- # introduced in 9.0). Eventually Gitaly use will become mandatory and
- # this option will disappear.
- enabled: false
+ # Default Gitaly authentication token. Can be overriden per storage. Can
+ # be left blank when Gitaly is running locally on a Unix socket, which
+ # is the normal way to deploy Gitaly.
+ token:
#
# 4. Advanced settings
@@ -465,6 +505,12 @@ production: &base
default:
path: /home/git/repositories/
gitaly_address: unix:/home/git/gitlab/tmp/sockets/private/gitaly.socket # TCP connections are supported too (e.g. tcp://host:port)
+ # gitaly_token: 'special token' # Optional: override global gitaly.token for this storage.
+ failure_count_threshold: 10 # number of failures before stopping attempts
+ failure_wait_time: 30 # Seconds after an access failure before allowing access again
+ failure_reset_time: 1800 # Time in seconds to expire failures
+ storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt
+
## Backup settings
backup:
@@ -502,6 +548,9 @@ production: &base
upload_pack: true
receive_pack: true
+ # Git import/fetch timeout
+ # git_timeout: 800
+
# If you use non-standard ssh port you need to specify it
# ssh_port: 22
@@ -532,6 +581,21 @@ production: &base
# host: localhost
# port: 3808
+ ## Monitoring
+ # Built in monitoring settings
+ monitoring:
+ # Time between sampling of unicorn socket metrics, in seconds
+ # unicorn_sampler_interval: 10
+ # IP whitelist to access monitoring endpoints
+ ip_whitelist:
+ - 127.0.0.0/8
+
+ # Sidekiq exporter is webserver built in to Sidekiq to expose Prometheus metrics
+ sidekiq_exporter:
+ # enabled: true
+ # address: localhost
+ # port: 3807
+
#
# 5. Extra customization
# ==========================
@@ -585,8 +649,16 @@ test:
default:
path: tmp/tests/repositories/
gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
+ failure_count_threshold: 999999
+ failure_wait_time: 0
+ storage_timeout: 30
+ broken:
+ path: tmp/tests/non-existent-repositories
+ gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
+
gitaly:
enabled: true
+ token: secret
backup:
path: tmp/tests/backups
gitlab_shell:
@@ -602,6 +674,52 @@ test:
title: "JIRA"
url: https://sample_company.atlassian.net
project_key: PROJECT
+
+ omniauth:
+ enabled: true
+ allow_single_sign_on: true
+ external_providers: []
+
+ providers:
+ - { name: 'cas3',
+ label: 'cas3',
+ args: { url: 'https://sso.example.com',
+ disable_ssl_verification: false,
+ login_url: '/cas/login',
+ service_validate_url: '/cas/p3/serviceValidate',
+ logout_url: '/cas/logout'} }
+ - { name: 'github',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ url: "https://github.com/",
+ verify_ssl: false,
+ args: { scope: 'user:email' } }
+ - { name: 'bitbucket',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET' }
+ - { name: 'gitlab',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ args: { scope: 'api' } }
+ - { name: 'google_oauth2',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ args: { access_type: 'offline', approval_prompt: '' } }
+ - { name: 'facebook',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET' }
+ - { name: 'twitter',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET' }
+ - { name: 'auth0',
+ args: {
+ client_id: 'YOUR_AUTH0_CLIENT_ID',
+ client_secret: 'YOUR_AUTH0_CLIENT_SECRET',
+ namespace: 'YOUR_AUTH0_DOMAIN' } }
+ - { name: 'authentiq',
+ app_id: 'YOUR_CLIENT_ID',
+ app_secret: 'YOUR_CLIENT_SECRET',
+ args: { scope: 'aq:name email~rs address aq:push' } }
ldap:
enabled: false
servers:
@@ -610,7 +728,7 @@ test:
host: 127.0.0.1
port: 3890
uid: 'uid'
- method: 'plain' # "tls" or "ssl" or "plain"
+ encryption: 'plain' # "start_tls" or "simple_tls" or "plain"
base: 'dc=example,dc=com'
user_filter: ''
group_base: 'ou=groups,dc=example,dc=com'
diff --git a/config/initializers/0_acts_as_taggable.rb b/config/initializers/0_acts_as_taggable.rb
new file mode 100644
index 00000000000..50dc47673ab
--- /dev/null
+++ b/config/initializers/0_acts_as_taggable.rb
@@ -0,0 +1,9 @@
+ActsAsTaggableOn.strict_case_match = true
+
+# tags_counter enables caching count of tags which results in an update whenever a tag is added or removed
+# since the count is not used anywhere its better performance wise to disable this cache
+ActsAsTaggableOn.tags_counter = false
+
+# validate that counter cache is disabled
+raise "Counter cache is not disabled" if
+ ActsAsTaggableOn::Tagging.reflections["tag"].options[:counter_cache]
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 7a8f00f11b2..38ade18bdc0 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -1,3 +1,5 @@
+# rubocop:disable GitlabSecurity/PublicSend
+
require_dependency Rails.root.join('lib/gitlab') # Load Gitlab as soon as possible
class Settings < Settingslogic
@@ -71,7 +73,7 @@ class Settings < Settingslogic
# check that `current` (string or integer) is a contant in `modul`.
def verify_constant(modul, current, default)
- constant = modul.constants.find{ |name| modul.const_get(name) == current }
+ constant = modul.constants.find { |name| modul.const_get(name) == current }
value = constant.nil? ? default : modul.const_get(constant)
if current.is_a? String
value = modul.const_get(current.upcase) rescue default
@@ -145,6 +147,24 @@ if Settings.ldap['enabled'] || Rails.env.test?
server['attributes'] = {} if server['attributes'].nil?
server['provider_name'] ||= "ldap#{key}".downcase
server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name'])
+
+ # For backwards compatibility
+ server['encryption'] ||= server['method']
+ server['encryption'] = 'simple_tls' if server['encryption'] == 'ssl'
+ server['encryption'] = 'start_tls' if server['encryption'] == 'tls'
+
+ # Certificates are not verified for backwards compatibility.
+ # This default should be flipped to true in 9.5.
+ if server['verify_certificates'].nil?
+ server['verify_certificates'] = false
+
+ message = <<-MSG.strip_heredoc
+ LDAP SSL certificate verification is disabled for backwards-compatibility.
+ Please add the "verify_certificates" option to gitlab.yml for each LDAP
+ server. Certificate verification will be enabled by default in GitLab 9.5.
+ MSG
+ Rails.logger.warn(message)
+ end
end
end
@@ -156,6 +176,7 @@ Settings.omniauth['external_providers'] = [] if Settings.omniauth['external_prov
Settings.omniauth['block_auto_created_users'] = true if Settings.omniauth['block_auto_created_users'].nil?
Settings.omniauth['auto_link_ldap_user'] = false if Settings.omniauth['auto_link_ldap_user'].nil?
Settings.omniauth['auto_link_saml_user'] = false if Settings.omniauth['auto_link_saml_user'].nil?
+Settings.omniauth['sync_email_from_provider'] ||= nil
Settings.omniauth['providers'] ||= []
Settings.omniauth['cas3'] ||= Settingslogic.new({})
@@ -204,7 +225,7 @@ Settings.gitlab['default_can_create_group'] = true if Settings.gitlab['default_c
Settings.gitlab['host'] ||= ENV['GITLAB_HOST'] || 'localhost'
Settings.gitlab['ssh_host'] ||= Settings.gitlab.host
Settings.gitlab['https'] = false if Settings.gitlab['https'].nil?
-Settings.gitlab['port'] ||= Settings.gitlab.https ? 443 : 80
+Settings.gitlab['port'] ||= ENV['GITLAB_PORT'] || (Settings.gitlab.https ? 443 : 80)
Settings.gitlab['relative_url_root'] ||= ENV['RAILS_RELATIVE_URL_ROOT'] || ''
Settings.gitlab['protocol'] ||= Settings.gitlab.https ? "https" : "http"
Settings.gitlab['email_enabled'] ||= true if Settings.gitlab['email_enabled'].nil?
@@ -222,7 +243,7 @@ rescue ArgumentError # no user configured
end
Settings.gitlab['time_zone'] ||= nil
Settings.gitlab['signup_enabled'] ||= true if Settings.gitlab['signup_enabled'].nil?
-Settings.gitlab['signin_enabled'] ||= true if Settings.gitlab['signin_enabled'].nil?
+Settings.gitlab['password_authentication_enabled'] ||= true if Settings.gitlab['password_authentication_enabled'].nil?
Settings.gitlab['restricted_visibility_levels'] = Settings.__send__(:verify_constant_array, Gitlab::VisibilityLevel, Settings.gitlab['restricted_visibility_levels'], [])
Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username_changing_enabled'].nil?
Settings.gitlab['issue_closing_pattern'] = '((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)' if Settings.gitlab['issue_closing_pattern'].nil?
@@ -241,6 +262,7 @@ Settings.gitlab['domain_whitelist'] ||= []
Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab google_code fogbugz git gitlab_project gitea]
Settings.gitlab['trusted_proxies'] ||= []
Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml'))
+Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil?
#
# CI
@@ -323,9 +345,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['stuck_ci_jobs_worker']['job_class'] = 'StuckCiJobsWorker'
-Settings.cron_jobs['trigger_schedule_worker'] ||= Settingslogic.new({})
-Settings.cron_jobs['trigger_schedule_worker']['cron'] ||= '0 */12 * * *'
-Settings.cron_jobs['trigger_schedule_worker']['job_class'] = 'TriggerScheduleWorker'
+Settings.cron_jobs['pipeline_schedule_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['pipeline_schedule_worker']['cron'] ||= '19 * * * *'
+Settings.cron_jobs['pipeline_schedule_worker']['job_class'] = 'PipelineScheduleWorker'
Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *'
Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker'
@@ -367,11 +389,18 @@ Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time)
Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
-# Every day at 00:30
Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *'
Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker'
+Settings.cron_jobs['remove_old_web_hook_logs_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['remove_old_web_hook_logs_worker']['cron'] ||= '40 0 * * *'
+Settings.cron_jobs['remove_old_web_hook_logs_worker']['job_class'] = 'RemoveOldWebHookLogsWorker'
+
+Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *'
+Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker'
+
#
# GitLab Shell
#
@@ -386,6 +415,7 @@ Settings.gitlab_shell['ssh_port'] ||= 22
Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user
Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user
Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix)
+Settings.gitlab_shell['git_timeout'] ||= 800
#
# Workhorse
@@ -409,6 +439,17 @@ end
Settings.repositories.storages.values.each do |storage|
# Expand relative paths
storage['path'] = Settings.absolute(storage['path'])
+ # Set failure defaults
+ storage['failure_count_threshold'] ||= 10
+ storage['failure_wait_time'] ||= 30
+ storage['failure_reset_time'] ||= 1800
+ storage['storage_timeout'] ||= 5
+ # Set turn strings into numbers
+ storage['failure_count_threshold'] = storage['failure_count_threshold'].to_i
+ storage['failure_wait_time'] = storage['failure_wait_time'].to_i
+ storage['failure_reset_time'] = storage['failure_reset_time'].to_i
+ # We might want to have a timeout shorter than 1 second.
+ storage['storage_timeout'] = storage['storage_timeout'].to_f
end
#
@@ -435,10 +476,6 @@ Settings.backup['pg_schema'] = nil
Settings.backup['path'] = Settings.absolute(Settings.backup['path'] || "tmp/backups/")
Settings.backup['archive_permissions'] ||= 0600
Settings.backup['upload'] ||= Settingslogic.new({ 'remote_directory' => nil, 'connection' => nil })
-# Convert upload connection settings to use symbol keys, to make Fog happy
-if Settings.backup['upload']['connection']
- Settings.backup['upload']['connection'] = Hash[Settings.backup['upload']['connection'].map { |k, v| [k.to_sym, v] }]
-end
Settings.backup['upload']['multipart_chunk_size'] ||= 104857600
Settings.backup['upload']['encryption'] ||= nil
Settings.backup['upload']['storage_class'] ||= nil
@@ -477,7 +514,6 @@ Settings.rack_attack.git_basic_auth['bantime'] ||= 1.hour
# Gitaly
#
Settings['gitaly'] ||= Settingslogic.new({})
-Settings.gitaly['enabled'] ||= false
#
# Webpack settings
@@ -489,6 +525,17 @@ Settings.webpack.dev_server['host'] ||= 'localhost'
Settings.webpack.dev_server['port'] ||= 3808
#
+# Monitoring settings
+#
+Settings['monitoring'] ||= Settingslogic.new({})
+Settings.monitoring['ip_whitelist'] ||= ['127.0.0.1/8']
+Settings.monitoring['unicorn_sampler_interval'] ||= 10
+Settings.monitoring['sidekiq_exporter'] ||= Settingslogic.new({})
+Settings.monitoring.sidekiq_exporter['enabled'] ||= false
+Settings.monitoring.sidekiq_exporter['address'] ||= 'localhost'
+Settings.monitoring.sidekiq_exporter['port'] ||= 3807
+
+#
# Testing settings
#
if Rails.env.test?
diff --git a/config/initializers/5_backend.rb b/config/initializers/5_backend.rb
index 2bd159ca7f1..482613dacc9 100644
--- a/config/initializers/5_backend.rb
+++ b/config/initializers/5_backend.rb
@@ -1,6 +1,8 @@
-required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required)
-current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.new.version)
+unless Rails.env.test?
+ required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required)
+ current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.new.version)
-unless current_version.valid? && required_version <= current_version
- warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell."
+ unless current_version.valid? && required_version <= current_version
+ warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell."
+ end
end
diff --git a/config/initializers/6_validations.rb b/config/initializers/6_validations.rb
index 9e24f42d284..f8e67ce04c9 100644
--- a/config/initializers/6_validations.rb
+++ b/config/initializers/6_validations.rb
@@ -7,6 +7,13 @@ def find_parent_path(name, path)
Gitlab.config.repositories.storages.detect do |n, rs|
name != n && Pathname.new(rs['path']).realpath == parent
end
+rescue Errno::EIO, Errno::ENOENT => e
+ warning = "WARNING: couldn't verify #{path} (#{name}). "\
+ "If this is an external storage, it might be offline."
+ message = "#{warning}\n#{e.message}"
+ Rails.logger.error("#{message}\n\t" + e.backtrace.join("\n\t"))
+
+ nil
end
def storage_validation_error(message)
@@ -29,6 +36,15 @@ def validate_storages_config
if !repository_storage.is_a?(Hash) || repository_storage['path'].nil?
storage_validation_error("#{name} is not a valid storage, because it has no `path` key. Refer to gitlab.yml.example for an updated example")
end
+
+ %w(failure_count_threshold failure_reset_time storage_timeout).each do |setting|
+ # Falling back to the defaults is fine!
+ next if repository_storage[setting].nil?
+
+ unless repository_storage[setting].to_f > 0
+ storage_validation_error("`#{setting}` for storage `#{name}` needs to be greater than 0")
+ end
+ end
end
end
diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb
new file mode 100644
index 00000000000..54c797e0714
--- /dev/null
+++ b/config/initializers/7_prometheus_metrics.rb
@@ -0,0 +1,18 @@
+require 'prometheus/client'
+
+Prometheus::Client.configure do |config|
+ config.logger = Rails.logger
+
+ config.initial_mmap_file_size = 4 * 1024
+ config.multiprocess_files_dir = ENV['prometheus_multiproc_dir']
+
+ if Rails.env.development? || Rails.env.test?
+ config.multiprocess_files_dir ||= Rails.root.join('tmp/prometheus_multiproc_dir')
+ end
+end
+
+Sidekiq.configure_server do |config|
+ config.on(:startup) do
+ Gitlab::Metrics::SidekiqMetricsExporter.instance.start
+ end
+end
diff --git a/config/initializers/7_redis.rb b/config/initializers/7_redis.rb
index ae2ca258df1..af4967521b8 100644
--- a/config/initializers/7_redis.rb
+++ b/config/initializers/7_redis.rb
@@ -1,3 +1,8 @@
-# Make sure we initialize a Redis connection pool before Sidekiq starts
-# multi-threaded execution.
-Gitlab::Redis.with { nil }
+# Make sure we initialize a Redis connection pool before multi-threaded
+# execution starts by
+# 1. Sidekiq
+# 2. Rails.cache
+# 3. HTTP clients
+Gitlab::Redis::Cache.with { nil }
+Gitlab::Redis::Queues.with { nil }
+Gitlab::Redis::SharedState.with { nil }
diff --git a/config/initializers/8_gitaly.rb b/config/initializers/8_gitaly.rb
index 42ec7240b0f..f4f116e67f7 100644
--- a/config/initializers/8_gitaly.rb
+++ b/config/initializers/8_gitaly.rb
@@ -1,6 +1,6 @@
require 'uri'
-# Make sure we initialize our Gitaly channels before Sidekiq starts multi-threaded execution.
-if Gitlab.config.gitaly.enabled || Rails.env.test?
- Gitlab::GitalyClient.configure_channels
+Gitlab.config.repositories.storages.keys.each do |storage|
+ # Force validation of each address
+ Gitlab::GitalyClient.address(storage)
end
diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb
index 5e0eefdb154..2aeb94d47cd 100644
--- a/config/initializers/8_metrics.rb
+++ b/config/initializers/8_metrics.rb
@@ -113,9 +113,22 @@ def instrument_classes(instrumentation)
# This is a Rails scope so we have to instrument it manually.
instrumentation.instrument_method(Project, :visible_to_user)
+
+ # Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/34509
+ instrumentation.instrument_method(MarkupHelper, :link_to_gfm)
+
+ # Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/30224#note_32306159
+ instrumentation.instrument_instance_method(MergeRequestDiff, :load_commits)
end
# rubocop:enable Metrics/AbcSize
+Gitlab::Metrics::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start
+
+Gitlab::Application.configure do |config|
+ # 0 should be Sentry to catch errors in this middleware
+ config.middleware.insert(1, Gitlab::Metrics::RequestsRackMiddleware)
+end
+
if Gitlab::Metrics.enabled?
require 'pathname'
require 'influxdb'
@@ -151,8 +164,8 @@ if Gitlab::Metrics.enabled?
ActiveRecord::Querying.public_instance_methods(false).map(&:to_s)
)
- Gitlab::Metrics::Instrumentation.
- instrument_class_hierarchy(ActiveRecord::Base) do |klass, method|
+ Gitlab::Metrics::Instrumentation
+ .instrument_class_hierarchy(ActiveRecord::Base) do |klass, method|
# Instrumenting the ApplicationSetting class can lead to an infinite
# loop. Since the data is cached any way we don't really need to
# instrument it.
@@ -164,6 +177,10 @@ if Gitlab::Metrics.enabled?
loc && loc[0].start_with?(models) && method.source =~ regex
end
end
+
+ # Ability is in app/models, is not an ActiveRecord model, but should still
+ # be instrumented.
+ Gitlab::Metrics::Instrumentation.instrument_methods(Ability)
end
Gitlab::Metrics::Instrumentation.configure do |config|
@@ -172,7 +189,7 @@ if Gitlab::Metrics.enabled?
GC::Profiler.enable
- Gitlab::Metrics::Sampler.new.start
+ Gitlab::Metrics::InfluxSampler.initialize_instance.start
module TrackNewRedisConnections
def connect(*args)
diff --git a/config/initializers/active_record_array_type_casting.rb b/config/initializers/active_record_array_type_casting.rb
new file mode 100644
index 00000000000..d94d592add6
--- /dev/null
+++ b/config/initializers/active_record_array_type_casting.rb
@@ -0,0 +1,20 @@
+module ActiveRecord
+ class PredicateBuilder
+ class ArrayHandler
+ module TypeCasting
+ def call(attribute, value)
+ # This is necessary because by default ActiveRecord does not respect
+ # custom type definitions (like our `ShaAttribute`) when providing an
+ # array in `where`, like in `where(commit_sha: [sha1, sha2, sha3])`.
+ model = attribute.relation&.engine
+ type = model.user_provided_columns[attribute.name] if model
+ value = value.map { |value| type.type_cast_for_database(value) } if type
+
+ super(attribute, value)
+ end
+ end
+
+ prepend TypeCasting
+ end
+ end
+end
diff --git a/config/initializers/active_record_data_types.rb b/config/initializers/active_record_data_types.rb
new file mode 100644
index 00000000000..fef591c397d
--- /dev/null
+++ b/config/initializers/active_record_data_types.rb
@@ -0,0 +1,81 @@
+# ActiveRecord custom data type for storing datetimes with timezone information.
+# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11229
+
+if Gitlab::Database.postgresql?
+ require 'active_record/connection_adapters/postgresql_adapter'
+
+ module ActiveRecord::ConnectionAdapters::PostgreSQL::OID
+ # Add the class `DateTimeWithTimeZone` so we can map `timestamptz` to it.
+ class DateTimeWithTimeZone < DateTime
+ def type
+ :datetime_with_timezone
+ end
+ end
+ end
+
+ module RegisterDateTimeWithTimeZone
+ # Run original `initialize_type_map` and then register `timestamptz` as a
+ # `DateTimeWithTimeZone`.
+ #
+ # Apparently it does not matter that the original `initialize_type_map`
+ # aliases `timestamptz` to `timestamp`.
+ #
+ # When schema dumping, `timestamptz` columns will be output as
+ # `t.datetime_with_timezone`.
+ def initialize_type_map(mapping)
+ super mapping
+
+ mapping.register_type 'timestamptz' do |_, _, sql_type|
+ precision = extract_precision(sql_type)
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::OID::DateTimeWithTimeZone.new(precision: precision)
+ end
+ end
+ end
+
+ class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
+ prepend RegisterDateTimeWithTimeZone
+
+ # Add column type `datetime_with_timezone` so we can do this in
+ # migrations:
+ #
+ # add_column(:users, :datetime_with_timezone)
+ #
+ NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamptz' }
+ end
+elsif Gitlab::Database.mysql?
+ require 'active_record/connection_adapters/mysql2_adapter'
+
+ module RegisterDateTimeWithTimeZone
+ # Run original `initialize_type_map` and then register `timestamp` as a
+ # `MysqlDateTimeWithTimeZone`.
+ #
+ # When schema dumping, `timestamp` columns will be output as
+ # `t.datetime_with_timezone`.
+ def initialize_type_map(mapping)
+ super mapping
+
+ mapping.register_type(%r(timestamp)i) do |sql_type|
+ precision = extract_precision(sql_type)
+ ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlDateTimeWithTimeZone.new(precision: precision)
+ end
+ end
+ end
+
+ class ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
+ prepend RegisterDateTimeWithTimeZone
+
+ # Add the class `DateTimeWithTimeZone` so we can map `timestamp` to it.
+ class MysqlDateTimeWithTimeZone < MysqlDateTime
+ def type
+ :datetime_with_timezone
+ end
+ end
+
+ # Add column type `datetime_with_timezone` so we can do this in
+ # migrations:
+ #
+ # add_column(:users, :datetime_with_timezone)
+ #
+ NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamp' }
+ end
+end
diff --git a/config/initializers/active_record_locking.rb b/config/initializers/active_record_locking.rb
new file mode 100644
index 00000000000..150aaa2a8c2
--- /dev/null
+++ b/config/initializers/active_record_locking.rb
@@ -0,0 +1,74 @@
+# rubocop:disable Lint/RescueException
+
+# This patch fixes https://github.com/rails/rails/issues/26024
+# TODO: Remove it when it's no longer necessary
+
+module ActiveRecord
+ module Locking
+ module Optimistic
+ # We overwrite this method because we don't want to have default value
+ # for newly created records
+ def _create_record(attribute_names = self.attribute_names, *) # :nodoc:
+ super
+ end
+
+ def _update_record(attribute_names = self.attribute_names) #:nodoc:
+ return super unless locking_enabled?
+ return 0 if attribute_names.empty?
+
+ lock_col = self.class.locking_column
+
+ previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend
+
+ # This line is added as a patch
+ previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0
+
+ increment_lock
+
+ attribute_names += [lock_col]
+ attribute_names.uniq!
+
+ begin
+ relation = self.class.unscoped
+
+ affected_rows = relation.where(
+ self.class.primary_key => id,
+ lock_col => previous_lock_value
+ ).update_all(
+ attributes_for_update(attribute_names).map do |name|
+ [name, _read_attribute(name)]
+ end.to_h
+ )
+
+ unless affected_rows == 1
+ raise ActiveRecord::StaleObjectError.new(self, "update")
+ end
+
+ affected_rows
+
+ # If something went wrong, revert the version.
+ rescue Exception
+ send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend
+ raise
+ end
+ end
+
+ # This is patched because we need it to query `lock_version IS NULL`
+ # rather than `lock_version = 0` whenever lock_version is NULL.
+ def relation_for_destroy
+ return super unless locking_enabled?
+
+ column_name = self.class.locking_column
+ super.where(self.class.arel_table[column_name].eq(self[column_name]))
+ end
+ end
+
+ # This is patched because we want `lock_version` default to `NULL`
+ # rather than `0`
+ class LockingType < SimpleDelegator
+ def type_cast_from_database(value)
+ super
+ end
+ end
+ end
+end
diff --git a/config/initializers/active_record_mysql_timestamp.rb b/config/initializers/active_record_mysql_timestamp.rb
new file mode 100644
index 00000000000..af74c4ff6fb
--- /dev/null
+++ b/config/initializers/active_record_mysql_timestamp.rb
@@ -0,0 +1,30 @@
+# Make sure that MySQL won't try to use CURRENT_TIMESTAMP when the timestamp
+# column is NOT NULL. See https://gitlab.com/gitlab-org/gitlab-ce/issues/36405
+# And also: https://bugs.mysql.com/bug.php?id=75098
+# This patch was based on:
+# https://github.com/rails/rails/blob/15ef55efb591e5379486ccf53dd3e13f416564f6/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb#L34-L36
+
+if Gitlab::Database.mysql?
+ require 'active_record/connection_adapters/abstract/schema_creation'
+
+ module MySQLTimestampFix
+ def add_column_options!(sql, options)
+ # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values,
+ # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP
+ # column to contain NULL, explicitly declare it with the NULL attribute.
+ # See http://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html
+ if sql.end_with?('timestamp') && !options[:primary_key]
+ if options[:null] != false
+ sql << ' NULL'
+ elsif options[:column].default.nil?
+ sql << ' DEFAULT 0'
+ end
+ end
+
+ super
+ end
+ end
+
+ ActiveRecord::ConnectionAdapters::AbstractAdapter::SchemaCreation
+ .prepend(MySQLTimestampFix)
+end
diff --git a/config/initializers/active_record_preloader.rb b/config/initializers/active_record_preloader.rb
new file mode 100644
index 00000000000..3b16014f302
--- /dev/null
+++ b/config/initializers/active_record_preloader.rb
@@ -0,0 +1,15 @@
+module ActiveRecord
+ module Associations
+ class Preloader
+ module NoCommitPreloader
+ def preloader_for(reflection, owners, rhs_klass)
+ return NullPreloader if rhs_klass == ::Commit
+
+ super
+ end
+ end
+
+ prepend NoCommitPreloader
+ end
+ end
+end
diff --git a/config/initializers/active_record_query_trace.rb b/config/initializers/active_record_query_trace.rb
deleted file mode 100644
index 4b3c2803b3b..00000000000
--- a/config/initializers/active_record_query_trace.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-if ENV['ENABLE_QUERY_TRACE']
- require 'active_record_query_trace'
-
- ActiveRecordQueryTrace.enabled = 'true'
-end
diff --git a/config/initializers/active_record_table_definition.rb b/config/initializers/active_record_table_definition.rb
new file mode 100644
index 00000000000..8e3a1c7a62f
--- /dev/null
+++ b/config/initializers/active_record_table_definition.rb
@@ -0,0 +1,34 @@
+# ActiveRecord custom method definitions with timezone information.
+# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11229
+
+require 'active_record/connection_adapters/abstract/schema_definitions'
+
+module ActiveRecord
+ module ConnectionAdapters
+ class TableDefinition
+ # Appends columns `created_at` and `updated_at` to a table.
+ #
+ # It is used in table creation like:
+ # create_table 'users' do |t|
+ # t.timestamps_with_timezone
+ # end
+ def timestamps_with_timezone(**options)
+ options[:null] = false if options[:null].nil?
+
+ [:created_at, :updated_at].each do |column_name|
+ column(column_name, :datetime_with_timezone, options)
+ end
+ end
+
+ # Adds specified column with appropriate timestamp type
+ #
+ # It is used in table creation like:
+ # create_table 'users' do |t|
+ # t.datetime_with_timezone :did_something_at
+ # end
+ def datetime_with_timezone(column_name, **options)
+ column(column_name, :datetime_with_timezone, options)
+ end
+ end
+ end
+end
diff --git a/config/initializers/acts_as_taggable.rb b/config/initializers/acts_as_taggable.rb
deleted file mode 100644
index c564c0cab11..00000000000
--- a/config/initializers/acts_as_taggable.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-ActsAsTaggableOn.strict_case_match = true
-
-# tags_counter enables caching count of tags which results in an update whenever a tag is added or removed
-# since the count is not used anywhere its better performance wise to disable this cache
-ActsAsTaggableOn.tags_counter = false
diff --git a/config/initializers/ar_monkey_patch.rb b/config/initializers/ar_monkey_patch.rb
deleted file mode 100644
index 6979f4641b0..00000000000
--- a/config/initializers/ar_monkey_patch.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# rubocop:disable Lint/RescueException
-
-# This patch fixes https://github.com/rails/rails/issues/26024
-# TODO: Remove it when it's no longer necessary
-
-module ActiveRecord
- module Locking
- module Optimistic
- # We overwrite this method because we don't want to have default value
- # for newly created records
- def _create_record(attribute_names = self.attribute_names, *) # :nodoc:
- super
- end
-
- def _update_record(attribute_names = self.attribute_names) #:nodoc:
- return super unless locking_enabled?
- return 0 if attribute_names.empty?
-
- lock_col = self.class.locking_column
-
- previous_lock_value = send(lock_col).to_i
-
- # This line is added as a patch
- previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0
-
- increment_lock
-
- attribute_names += [lock_col]
- attribute_names.uniq!
-
- begin
- relation = self.class.unscoped
-
- affected_rows = relation.where(
- self.class.primary_key => id,
- lock_col => previous_lock_value,
- ).update_all(
- attributes_for_update(attribute_names).map do |name|
- [name, _read_attribute(name)]
- end.to_h
- )
-
- unless affected_rows == 1
- raise ActiveRecord::StaleObjectError.new(self, "update")
- end
-
- affected_rows
-
- # If something went wrong, revert the version.
- rescue Exception
- send(lock_col + '=', previous_lock_value)
- raise
- end
- end
-
- # This is patched because we need it to query `lock_version IS NULL`
- # rather than `lock_version = 0` whenever lock_version is NULL.
- def relation_for_destroy
- return super unless locking_enabled?
-
- column_name = self.class.locking_column
- super.where(self.class.arel_table[column_name].eq(self[column_name]))
- end
- end
-
- # This is patched because we want `lock_version` default to `NULL`
- # rather than `0`
- class LockingType < SimpleDelegator
- def type_cast_from_database(value)
- super
- end
- end
- end
-end
diff --git a/config/initializers/ar_speed_up_migration_checking.rb b/config/initializers/ar_speed_up_migration_checking.rb
index 1fe5defc01d..aae774daa35 100644
--- a/config/initializers/ar_speed_up_migration_checking.rb
+++ b/config/initializers/ar_speed_up_migration_checking.rb
@@ -10,7 +10,7 @@ if Rails.env.test?
# it reads + parses `db/migrate/*` each time. Memoizing it can save 0.5
# seconds per spec.
def migrations(paths)
- @migrations ||= migrations_unmemoized(paths)
+ (@migrations ||= migrations_unmemoized(paths)).dup
end
end
end
diff --git a/config/initializers/bootstrap_form.rb b/config/initializers/bootstrap_form.rb
new file mode 100644
index 00000000000..11171b38a85
--- /dev/null
+++ b/config/initializers/bootstrap_form.rb
@@ -0,0 +1,7 @@
+module BootstrapFormBuilderCustomization
+ def label_class
+ "label-light"
+ end
+end
+
+BootstrapForm::FormBuilder.prepend(BootstrapFormBuilderCustomization)
diff --git a/config/initializers/carrierwave.rb b/config/initializers/carrierwave.rb
index 1933afcbfb1..cd7df44351a 100644
--- a/config/initializers/carrierwave.rb
+++ b/config/initializers/carrierwave.rb
@@ -6,6 +6,8 @@ if File.exist?(aws_file)
AWS_CONFIG = YAML.load(File.read(aws_file))[Rails.env]
CarrierWave.configure do |config|
+ config.fog_provider = 'fog/aws'
+
config.fog_credentials = {
provider: 'AWS', # required
aws_access_key_id: AWS_CONFIG['access_key_id'], # required
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 3b1317030bc..3aed2136f1b 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -192,7 +192,7 @@ Devise.setup do |config|
#
# The :"*/*" and "*/*" formats below is required to match Internet
# Explorer requests.
- # config.navigational_formats = [:"*/*", "*/*", :html]
+ config.navigational_formats = [:"*/*", "*/*", :html, :zip]
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
@@ -206,11 +206,11 @@ Devise.setup do |config|
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
- config.warden do |manager|
- manager.failure_app = Gitlab::DeviseFailure
- # manager.intercept_401 = false
- # manager.default_strategies(scope: :user).unshift :some_external_strategy
- end
+ # config.warden do |manager|
+ # manager.failure_app = Gitlab::DeviseFailure
+ # manager.intercept_401 = false
+ # manager.default_strategies(scope: :user).unshift :some_external_strategy
+ # end
if Gitlab::LDAP::Config.enabled?
Gitlab::LDAP::Config.providers.each do |provider|
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index a5636765774..40e635bf2cf 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -87,16 +87,14 @@ Doorkeeper.configure do
# "password" => Resource Owner Password Credentials Grant Flow
# "client_credentials" => Client Credentials Grant Flow
#
- # If not specified, Doorkeeper enables all the four grant flows.
- #
- grant_flows %w(authorization_code password client_credentials)
+ grant_flows %w(authorization_code implicit password client_credentials)
# Under some circumstances you might want to have applications auto-approved,
# so that the user skips the authorization step.
# For example if dealing with trusted a application.
- # skip_authorization do |resource_owner, client|
- # client.superapp? or resource_owner.admin?
- # end
+ skip_authorization do |resource_owner, client|
+ client.application.trusted?
+ end
# WWW-Authenticate Realm (default "Doorkeeper").
# realm "Doorkeeper"
diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb
index 700ca25b884..c58f425b19b 100644
--- a/config/initializers/doorkeeper_openid_connect.rb
+++ b/config/initializers/doorkeeper_openid_connect.rb
@@ -29,8 +29,8 @@ Doorkeeper::OpenidConnect.configure do
o.claim(:email) { |user| user.public_email }
o.claim(:email_verified) { |user| true if user.public_email? }
o.claim(:website) { |user| user.full_website_url if user.website_url? }
- o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user }
- o.claim(:picture) { |user| user.avatar_url }
+ o.claim(:profile) { |user| Gitlab::Routing.url_helpers.user_url user }
+ o.claim(:picture) { |user| user.avatar_url(only_path: false) }
end
end
end
diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb
new file mode 100644
index 00000000000..eb589ecdb52
--- /dev/null
+++ b/config/initializers/fast_gettext.rb
@@ -0,0 +1,6 @@
+FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po
+FastGettext.default_text_domain = 'gitlab'
+FastGettext.default_available_locales = Gitlab::I18n.available_locales
+FastGettext.default_locale = :en
+
+I18n.available_locales = Gitlab::I18n.available_locales
diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb
new file mode 100644
index 00000000000..bfab8c77a4b
--- /dev/null
+++ b/config/initializers/flipper.rb
@@ -0,0 +1,8 @@
+require 'flipper/middleware/memoizer'
+
+unless Rails.env.test?
+ Rails.application.config.middleware.use Flipper::Middleware::Memoizer,
+ lambda { Feature.flipper }
+
+ Feature.register_feature_groups
+end
diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb
new file mode 100644
index 00000000000..a78711fe599
--- /dev/null
+++ b/config/initializers/forbid_sidekiq_in_transactions.rb
@@ -0,0 +1,49 @@
+module Sidekiq
+ module Worker
+ mattr_accessor :skip_transaction_check
+ self.skip_transaction_check = false
+
+ def self.skipping_transaction_check(&block)
+ skip_transaction_check = self.skip_transaction_check
+ self.skip_transaction_check = true
+ yield
+ ensure
+ self.skip_transaction_check = skip_transaction_check
+ end
+
+ module ClassMethods
+ module NoSchedulingFromTransactions
+ NESTING = ::Rails.env.test? ? 1 : 0
+
+ %i(perform_async perform_at perform_in).each do |name|
+ define_method(name) do |*args|
+ return super(*args) if Sidekiq::Worker.skip_transaction_check
+ return super(*args) unless ActiveRecord::Base.connection.open_transactions > NESTING
+
+ raise <<-MSG.strip_heredoc
+ `#{self}.#{name}` cannot be called inside a transaction as this can lead to
+ race conditions when the worker runs before the transaction is committed and
+ tries to access a model that has not been saved yet.
+
+ Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead.
+ MSG
+ end
+ end
+ end
+
+ prepend NoSchedulingFromTransactions
+ end
+ end
+end
+
+module ActiveRecord
+ class Base
+ module SkipTransactionCheckAfterCommit
+ def committed!(*)
+ Sidekiq::Worker.skipping_transaction_check { super }
+ end
+ end
+
+ prepend SkipTransactionCheckAfterCommit
+ end
+end
diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb
new file mode 100644
index 00000000000..377e5104f9d
--- /dev/null
+++ b/config/initializers/gettext_rails_i18n_patch.rb
@@ -0,0 +1,41 @@
+require 'gettext_i18n_rails/haml_parser'
+require 'gettext_i18n_rails_js/parser/javascript'
+
+VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/
+
+module GettextI18nRails
+ class HamlParser
+ singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code)
+
+ # We need to convert text in Mustache format
+ # to a format that can be parsed by Gettext scripts.
+ # If we found a content like "{{ __('Stage') }}"
+ # in a HAML file we convert it to "= _('Stage')", that way
+ # it can be processed by the "rake gettext:find" script.
+ #
+ # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9
+ def self.convert_to_code(text)
+ text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)")
+
+ old_convert_to_code(text)
+ end
+ end
+end
+
+module GettextI18nRailsJs
+ module Parser
+ module Javascript
+ # This is required to tell the `rake gettext:find` script to use the Javascript
+ # parser for *.vue files.
+ #
+ # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L36
+ def target?(file)
+ [
+ ".js",
+ ".jsx",
+ ".vue"
+ ].include? ::File.extname(file)
+ end
+ end
+ end
+end
diff --git a/config/initializers/grape_route_helpers_fix.rb b/config/initializers/grape_route_helpers_fix.rb
new file mode 100644
index 00000000000..d3cf9e453d0
--- /dev/null
+++ b/config/initializers/grape_route_helpers_fix.rb
@@ -0,0 +1,35 @@
+if defined?(GrapeRouteHelpers)
+ module GrapeRouteHelpers
+ class DecoratedRoute
+ # GrapeRouteHelpers gem tries to parse the versions
+ # from a string, not supporting Grape `version` array definition.
+ #
+ # Without the following fix, we get this on route helpers generation:
+ #
+ # => undefined method `scan' for ["v3", "v4"]
+ #
+ # 2.0.0 implementation of this method:
+ #
+ # ```
+ # def route_versions
+ # version_pattern = /[^\[",\]\s]+/
+ # if route_version
+ # route_version.scan(version_pattern)
+ # else
+ # [nil]
+ # end
+ # end
+ # ```
+ def route_versions
+ return [nil] if route_version.nil? || route_version.empty?
+
+ if route_version.is_a?(String)
+ version_pattern = /[^\[",\]\s]+/
+ route_version.scan(version_pattern)
+ else
+ route_version
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/hamlit.rb b/config/initializers/hamlit.rb
index 7b545d8c06c..51dbffeda05 100644
--- a/config/initializers/hamlit.rb
+++ b/config/initializers/hamlit.rb
@@ -3,7 +3,7 @@ module Hamlit
def call(template)
Engine.new(
generator: Temple::Generators::RailsOutputBuffer,
- attr_quote: '"',
+ attr_quote: '"'
).call(template.source)
end
end
@@ -11,7 +11,7 @@ end
ActionView::Template.register_template_handler(
:haml,
- Hamlit::TemplateHandler.new,
+ Hamlit::TemplateHandler.new
)
Hamlit::Filters.remove_filter('coffee')
diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb
new file mode 100644
index 00000000000..21fe8d72459
--- /dev/null
+++ b/config/initializers/lograge.rb
@@ -0,0 +1,24 @@
+# Only use Lograge for Rails
+unless Sidekiq.server?
+ filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log")
+
+ Rails.application.configure do
+ config.lograge.enabled = true
+ # Store the lograge JSON files in a separate file
+ config.lograge.keep_original_rails_log = true
+ # Don't use the Logstash formatter since this requires logstash-event, an
+ # unmaintained gem that monkey patches `Time`
+ config.lograge.formatter = Lograge::Formatters::Json.new
+ config.lograge.logger = ActiveSupport::Logger.new(filename)
+ # Add request parameters to log output
+ config.lograge.custom_options = lambda do |event|
+ {
+ time: event.time.utc.iso8601(3),
+ params: event.payload[:params].except(*%w(controller action format)),
+ remote_ip: event.payload[:remote_ip],
+ user_id: event.payload[:user_id],
+ username: event.payload[:username]
+ }
+ end
+ end
+end
diff --git a/config/initializers/mysql_set_length_for_binary_indexes.rb b/config/initializers/mysql_set_length_for_binary_indexes.rb
new file mode 100644
index 00000000000..de0bc5322aa
--- /dev/null
+++ b/config/initializers/mysql_set_length_for_binary_indexes.rb
@@ -0,0 +1,21 @@
+# This patches ActiveRecord so indexes for binary columns created using the
+# MySQL adapter apply a length of 20. Otherwise MySQL can't create an index on
+# binary columns.
+
+module MysqlSetLengthForBinaryIndex
+ def add_index(table_name, column_names, options = {})
+ Array(column_names).each do |column_name|
+ column = ActiveRecord::Base.connection.columns(table_name).find { |c| c.name == column_name }
+
+ if column&.type == :binary
+ options[:length] = 20
+ end
+ end
+
+ super(table_name, column_names, options)
+ end
+end
+
+if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter.send(:prepend, MysqlSetLengthForBinaryIndex)
+end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index f7fa6d1c2de..56c279ffcf4 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -1,13 +1,16 @@
if Gitlab::LDAP::Config.enabled?
module OmniAuth::Strategies
- server = Gitlab.config.ldap.servers.values.first
- klass = server['provider_class']
- const_set(klass, Class.new(LDAP)) unless klass == 'LDAP'
+ Gitlab::LDAP::Config.available_servers.each do |server|
+ # do not redeclare LDAP
+ next if server['provider_name'] == 'ldap'
+ const_set(server['provider_class'], Class.new(LDAP))
+ end
end
OmniauthCallbacksController.class_eval do
- server = Gitlab.config.ldap.servers.values.first
- alias_method server['provider_name'], :ldap
+ Gitlab::LDAP::Config.available_servers.each do |server|
+ alias_method server['provider_name'], :ldap
+ end
end
end
@@ -16,7 +19,7 @@ OmniAuth.config.allowed_request_methods = [:post]
# In case of auto sign-in, the GET method is used (users don't get to click on a button)
OmniAuth.config.allowed_request_methods << :get if Gitlab.config.omniauth.auto_sign_in_with_provider.present?
OmniAuth.config.before_request_phase do |env|
- OmniAuth::RequestForgeryProtection.call(env)
+ Gitlab::RequestForgeryProtection.call(env)
end
if Gitlab.config.omniauth.enabled
diff --git a/config/initializers/peek.rb b/config/initializers/peek.rb
new file mode 100644
index 00000000000..a54d53cbbe2
--- /dev/null
+++ b/config/initializers/peek.rb
@@ -0,0 +1,28 @@
+Rails.application.config.peek.adapter = :redis, { client: ::Redis.new(Gitlab::Redis::Cache.params) }
+
+Peek.into Peek::Views::Host
+Peek.into Peek::Views::PerformanceBar
+if Gitlab::Database.mysql?
+ require 'peek-mysql2'
+ PEEK_DB_CLIENT = ::Mysql2::Client
+ PEEK_DB_VIEW = Peek::Views::Mysql2
+else
+ require 'peek-pg'
+ PEEK_DB_CLIENT = ::PG::Connection
+ PEEK_DB_VIEW = Peek::Views::PG
+end
+Peek.into PEEK_DB_VIEW
+Peek.into Peek::Views::Redis
+Peek.into Peek::Views::Sidekiq
+Peek.into Peek::Views::Rblineprof
+Peek.into Peek::Views::GC
+
+# rubocop:disable Style/ClassAndModuleCamelCase
+class PEEK_DB_CLIENT
+ class << self
+ attr_accessor :query_details
+ end
+ self.query_details = Concurrent::Array.new
+end
+
+PEEK_DB_VIEW.prepend ::Gitlab::PerformanceBar::PeekQueryTracker
diff --git a/config/initializers/postgresql_cte.rb b/config/initializers/postgresql_cte.rb
new file mode 100644
index 00000000000..7f0df8949db
--- /dev/null
+++ b/config/initializers/postgresql_cte.rb
@@ -0,0 +1,132 @@
+# Adds support for WITH statements when using PostgreSQL. The code here is taken
+# from https://github.com/shmay/ctes_in_my_pg which at the time of writing has
+# not been pushed to RubyGems. The license of this repository is as follows:
+#
+# The MIT License (MIT)
+#
+# Copyright (c) 2012 Dan McClain
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+module ActiveRecord
+ class Relation
+ class Merger # :nodoc:
+ def normal_values
+ NORMAL_VALUES + [:with]
+ end
+ end
+ end
+end
+
+module ActiveRecord::Querying
+ delegate :with, to: :all
+end
+
+module ActiveRecord
+ class Relation
+ # WithChain objects act as placeholder for queries in which #with does not have any parameter.
+ # In this case, #with must be chained with #recursive to return a new relation.
+ class WithChain
+ def initialize(scope)
+ @scope = scope
+ end
+
+ # Returns a new relation expressing WITH RECURSIVE
+ def recursive(*args)
+ @scope.with_values += args
+ @scope.recursive_value = true
+ @scope
+ end
+ end
+
+ def with_values
+ @values[:with] || []
+ end
+
+ def with_values=(values)
+ raise ImmutableRelation if @loaded
+ @values[:with] = values
+ end
+
+ def recursive_value=(value)
+ raise ImmutableRelation if @loaded
+ @values[:recursive] = value
+ end
+
+ def recursive_value
+ @values[:recursive]
+ end
+
+ def with(opts = :chain, *rest)
+ if opts == :chain
+ WithChain.new(spawn)
+ elsif opts.blank?
+ self
+ else
+ spawn.with!(opts, *rest)
+ end
+ end
+
+ def with!(opts = :chain, *rest) # :nodoc:
+ if opts == :chain
+ WithChain.new(self)
+ else
+ self.with_values += [opts] + rest
+ self
+ end
+ end
+
+ def build_arel
+ arel = super()
+
+ build_with(arel) if @values[:with]
+
+ arel
+ end
+
+ def build_with(arel)
+ with_statements = with_values.flat_map do |with_value|
+ case with_value
+ when String
+ with_value
+ when Hash
+ with_value.map do |name, expression|
+ case expression
+ when String
+ select = Arel::Nodes::SqlLiteral.new "(#{expression})"
+ when ActiveRecord::Relation, Arel::SelectManager
+ select = Arel::Nodes::SqlLiteral.new "(#{expression.to_sql})"
+ end
+ Arel::Nodes::As.new Arel::Nodes::SqlLiteral.new("\"#{name}\""), select
+ end
+ when Arel::Nodes::As
+ with_value
+ end
+ end
+
+ unless with_statements.empty?
+ if recursive_value
+ arel.with :recursive, with_statements
+ else
+ arel.with with_statements
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/relative_naming_ci_namespace.rb b/config/initializers/relative_naming_ci_namespace.rb
index 59abe1b9b91..d9d3034150f 100644
--- a/config/initializers/relative_naming_ci_namespace.rb
+++ b/config/initializers/relative_naming_ci_namespace.rb
@@ -4,10 +4,10 @@
# - [project.namespace, project, build]
#
# instead of:
-# - namespace_project_build_path(project.namespace, project, build)
+# - project_job_path(project, build)
#
# Without that, Ci:: namespace is used for resolving routes:
-# - namespace_project_ci_build_path(project.namespace, project, build)
+# - project_ci_build_path(project, build)
module Ci
def self.use_relative_model_naming?
diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb
index b909cc5b9a4..16b9d5b15e5 100644
--- a/config/initializers/rspec_profiling.rb
+++ b/config/initializers/rspec_profiling.rb
@@ -32,14 +32,14 @@ end
if Rails.env.test?
RspecProfiling.configure do |config|
- if ENV['RSPEC_PROFILING_POSTGRES_URL']
+ if ENV['RSPEC_PROFILING_POSTGRES_URL'].present?
RspecProfiling::Collectors::PSQL.prepend(RspecProfilingExt::PSQL)
config.collector = RspecProfiling::Collectors::PSQL
end
- end
- if ENV.has_key?('CI') && ENV['GITLAB_DATABASE'] == 'postgresql'
- RspecProfiling::VCS::Git.prepend(RspecProfilingExt::Git)
- RspecProfiling::Run.prepend(RspecProfilingExt::Run)
+ if ENV.key?('CI')
+ RspecProfiling::VCS::Git.prepend(RspecProfilingExt::Git)
+ RspecProfiling::Run.prepend(RspecProfilingExt::Run)
+ end
end
end
diff --git a/config/initializers/rugged_use_gitlab_git_attributes.rb b/config/initializers/rugged_use_gitlab_git_attributes.rb
new file mode 100644
index 00000000000..7d652799786
--- /dev/null
+++ b/config/initializers/rugged_use_gitlab_git_attributes.rb
@@ -0,0 +1,25 @@
+# We don't want to ever call Rugged::Repository#fetch_attributes, because it has
+# a lot of I/O overhead:
+# <https://gitlab.com/gitlab-org/gitlab_git/commit/340e111e040ae847b614d35b4d3173ec48329015>
+#
+# While we don't do this from within the GitLab source itself, the Linguist gem
+# has a dependency on Rugged and uses the gitattributes file when calculating
+# repository-wide language statistics:
+# <https://github.com/github/linguist/blob/v4.7.0/lib/linguist/lazy_blob.rb#L33-L36>
+#
+# The options passed by Linguist are those assumed by Gitlab::Git::Attributes
+# anyway, and there is no great efficiency gain from just fetching the listed
+# attributes with our implementation, so we ignore the additional arguments.
+#
+module Rugged
+ class Repository
+ module UseGitlabGitAttributes
+ def fetch_attributes(name, *)
+ @attributes ||= Gitlab::Git::Attributes.new(path)
+ @attributes.attributes(name)
+ end
+ end
+
+ prepend UseGitlabGitAttributes
+ end
+end
diff --git a/config/initializers/server_uptime.rb b/config/initializers/server_uptime.rb
new file mode 100644
index 00000000000..46bf242e143
--- /dev/null
+++ b/config/initializers/server_uptime.rb
@@ -0,0 +1 @@
+Rails.application.config.booted_at = Time.now
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
index 70be2617cab..e8213ac8ba4 100644
--- a/config/initializers/session_store.rb
+++ b/config/initializers/session_store.rb
@@ -10,16 +10,22 @@ rescue
Settings.gitlab['session_expire_delay'] ||= 10080
end
+cookie_key = if Rails.env.development?
+ "_gitlab_session_#{Digest::SHA256.hexdigest(Rails.root.to_s)}"
+ else
+ "_gitlab_session"
+ end
+
if Rails.env.test?
Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session"
else
- redis_config = Gitlab::Redis.params
- redis_config[:namespace] = Gitlab::Redis::SESSION_NAMESPACE
+ sessions_config = Gitlab::Redis::SharedState.params
+ sessions_config[:namespace] = Gitlab::Redis::SharedState::SESSION_NAMESPACE
Gitlab::Application.config.session_store(
:redis_store, # Using the cookie_store would enable session replay attacks.
- servers: redis_config,
- key: '_gitlab_session',
+ servers: sessions_config,
+ key: cookie_key,
secure: Gitlab.config.gitlab.https,
httponly: true,
expires_in: Settings.gitlab['session_expire_delay'] * 60,
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index ecd73956488..a1cc9655319 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -1,12 +1,12 @@
-# Custom Redis configuration
-redis_config_hash = Gitlab::Redis.params
-redis_config_hash[:namespace] = Gitlab::Redis::SIDEKIQ_NAMESPACE
+# Custom Queues configuration
+queues_config_hash = Gitlab::Redis::Queues.params
+queues_config_hash[:namespace] = Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE
# Default is to retry 25 times with exponential backoff. That's too much.
Sidekiq.default_worker_options = { retry: 3 }
Sidekiq.configure_server do |config|
- config.redis = redis_config_hash
+ config.redis = queues_config_hash
config.server_middleware do |chain|
chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if ENV['SIDEKIQ_LOG_ARGUMENTS']
@@ -54,7 +54,7 @@ Sidekiq.configure_server do |config|
end
Sidekiq.configure_client do |config|
- config.redis = redis_config_hash
+ config.redis = queues_config_hash
config.client_middleware do |chain|
chain.add Gitlab::SidekiqStatus::ClientMiddleware
@@ -74,5 +74,5 @@ begin
end
end
end
-rescue Redis::BaseError, SocketError, Errno::ENOENT, Errno::EAFNOSUPPORT, Errno::ECONNRESET, Errno::ECONNREFUSED
+rescue Redis::BaseError, SocketError, Errno::ENOENT, Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, Errno::ECONNRESET, Errno::ECONNREFUSED
end
diff --git a/config/initializers/static_files.rb b/config/initializers/static_files.rb
index 74aba6c5d06..943e01f1496 100644
--- a/config/initializers/static_files.rb
+++ b/config/initializers/static_files.rb
@@ -1,15 +1,15 @@
app = Rails.application
if app.config.serve_static_files
- # The `ActionDispatch::Static` middleware intercepts requests for static files
- # by checking if they exist in the `/public` directory.
+ # The `ActionDispatch::Static` middleware intercepts requests for static files
+ # by checking if they exist in the `/public` directory.
# We're replacing it with our `Gitlab::Middleware::Static` that does the same,
# except ignoring `/uploads`, letting those go through to the GitLab Rails app.
app.config.middleware.swap(
- ActionDispatch::Static,
- Gitlab::Middleware::Static,
- app.paths["public"].first,
+ ActionDispatch::Static,
+ Gitlab::Middleware::Static,
+ app.paths["public"].first,
app.config.static_cache_control
)
@@ -23,21 +23,21 @@ if app.config.serve_static_files
host: dev_server.host,
port: dev_server.port,
manifest_host: dev_server.host,
- manifest_port: dev_server.port,
+ manifest_port: dev_server.port
}
if Rails.env.development?
settings.merge!(
host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
- https: Gitlab.config.gitlab.https,
+ https: Gitlab.config.gitlab.https
)
app.config.middleware.insert_before(
Gitlab::Middleware::Static,
Gitlab::Middleware::WebpackProxy,
proxy_path: app.config.webpack.public_path,
proxy_host: dev_server.host,
- proxy_port: dev_server.port,
+ proxy_port: dev_server.port
)
end
diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb
index fc4f02453d7..0c32528311e 100644
--- a/config/initializers/trusted_proxies.rb
+++ b/config/initializers/trusted_proxies.rb
@@ -2,7 +2,7 @@
# as the ActionDispatch::Request object. This is necessary for libraries
# like rack_attack where they don't use ActionDispatch, and we want them
# to block/throttle requests on private networks.
-# Rack Attack specific issue: https://github.com/kickstarter/rack-attack/issues/145
+# Rack Attack specific issue: https://github.com/kickstarter/rack-attack/issues/145
module Rack
class Request
def trusted_proxy?(ip)
diff --git a/config/karma.config.js b/config/karma.config.js
index eb082dd28bf..2f571978e08 100644
--- a/config/karma.config.js
+++ b/config/karma.config.js
@@ -13,13 +13,26 @@ if (webpackConfig.plugins) {
});
}
+webpackConfig.devtool = 'cheap-inline-source-map';
+
// Karma configuration
module.exports = function(config) {
var progressReporter = process.env.CI ? 'mocha' : 'progress';
var karmaConfig = {
basePath: ROOT_PATH,
- browsers: ['PhantomJS'],
+ browsers: ['ChromeHeadlessCustom'],
+ customLaunchers: {
+ ChromeHeadlessCustom: {
+ base: 'ChromeHeadless',
+ displayName: 'Chrome',
+ flags: [
+ // chrome cannot run in sandboxed mode inside a docker container unless it is run with
+ // escalated kernel privileges (e.g. docker run --cap-add=CAP_SYS_ADMIN)
+ '--no-sandbox',
+ ],
+ }
+ },
frameworks: ['jasmine'],
files: [
{ pattern: 'spec/javascripts/test_bundle.js', watched: false },
@@ -41,6 +54,25 @@ module.exports = function(config) {
subdir: '.',
fixWebpackSourcePaths: true
};
+ karmaConfig.browserNoActivityTimeout = 60000; // 60 seconds
+ }
+
+ if (process.env.DEBUG) {
+ karmaConfig.logLevel = config.LOG_DEBUG;
+ process.env.CHROME_LOG_FILE = process.env.CHROME_LOG_FILE || 'chrome_debug.log';
+ }
+
+ if (process.env.CHROME_LOG_FILE) {
+ karmaConfig.customLaunchers.ChromeHeadlessCustom.flags.push('--enable-logging', '--v=1');
+ }
+
+ if (process.env.DEBUG) {
+ karmaConfig.logLevel = config.LOG_DEBUG;
+ process.env.CHROME_LOG_FILE = process.env.CHROME_LOG_FILE || 'chrome_debug.log';
+ }
+
+ if (process.env.CHROME_LOG_FILE) {
+ karmaConfig.customLaunchers.ChromeHeadlessCustom.flags.push('--enable-logging', '--v=1');
}
config.set(karmaConfig);
diff --git a/config/locales/de.yml b/config/locales/de.yml
new file mode 100644
index 00000000000..38c3711c6c7
--- /dev/null
+++ b/config/locales/de.yml
@@ -0,0 +1,256 @@
+---
+de:
+ activerecord:
+ errors:
+ messages:
+ record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'
+ restrict_dependent_destroy:
+ has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz
+ existiert.
+ has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.
+ date:
+ abbr_day_names:
+ - So
+ - Mo
+ - Di
+ - Mi
+ - Do
+ - Fr
+ - Sa
+ abbr_month_names:
+ -
+ - Jan
+ - Feb
+ - Mär
+ - Apr
+ - Mai
+ - Jun
+ - Jul
+ - Aug
+ - Sep
+ - Okt
+ - Nov
+ - Dez
+ day_names:
+ - Sonntag
+ - Montag
+ - Dienstag
+ - Mittwoch
+ - Donnerstag
+ - Freitag
+ - Samstag
+ formats:
+ default: "%d.%m.%Y"
+ long: "%e. %B %Y"
+ short: "%e. %b"
+ month_names:
+ -
+ - Januar
+ - Februar
+ - März
+ - April
+ - Mai
+ - Juni
+ - Juli
+ - August
+ - September
+ - Oktober
+ - November
+ - Dezember
+ order:
+ - :day
+ - :month
+ - :year
+ datetime:
+ # used in a custom scope that has been created to fix https://gitlab.com/gitlab-org/gitlab-ce/issues/32747
+ time_ago_in_words:
+ half_a_minute: vor einer halben Minute
+ less_than_x_seconds:
+ one: vor weniger als einer Sekunde
+ other: "vor weniger als %{count} Sekunden"
+ x_seconds:
+ one: vor einer Sekunde
+ other: "vor %{count} Sekunden"
+ less_than_x_minutes:
+ one: vor weniger als einer Minute
+ other: vor weniger als %{count} Minuten
+ x_minutes:
+ one: vor einer Minute
+ other: "vor %{count} Minuten"
+ about_x_hours:
+ one: vor etwa einer Stunde
+ other: "vor etwa %{count} Stunden"
+ x_days:
+ one: vor einem Tag
+ other: "vor %{count} Tagen"
+ about_x_months:
+ one: vor etwa einem Monat
+ other: "vor etwa %{count} Monaten"
+ x_months:
+ one: vor einem Monat
+ other: "vor %{count} Monaten"
+ about_x_years:
+ one: vor etwa einem Jahr
+ other: "vor etwa %{count} Jahren"
+ over_x_years:
+ one: vor mehr als einem Jahr
+ other: "vor mehr als %{count} Jahren"
+ almost_x_years:
+ one: vor fast einem Jahr
+ other: "vor fast %{count} Jahren"
+ # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words()
+ distance_in_words:
+ about_x_hours:
+ one: etwa eine Stunde
+ other: etwa %{count} Stunden
+ about_x_months:
+ one: etwa ein Monat
+ other: etwa %{count} Monate
+ about_x_years:
+ one: etwa ein Jahr
+ other: etwa %{count} Jahre
+ almost_x_years:
+ one: fast ein Jahr
+ other: fast %{count} Jahre
+ half_a_minute: eine halbe Minute
+ less_than_x_minutes:
+ one: weniger als eine Minute
+ other: weniger als %{count} Minuten
+ less_than_x_seconds:
+ one: weniger als eine Sekunde
+ other: weniger als %{count} Sekunden
+ over_x_years:
+ one: mehr als ein Jahr
+ other: mehr als %{count} Jahre
+ x_days:
+ one: ein Tag
+ other: "%{count} Tage"
+ x_minutes:
+ one: eine Minute
+ other: "%{count} Minuten"
+ x_months:
+ one: ein Monat
+ other: "%{count} Monate"
+ x_seconds:
+ one: eine Sekunde
+ other: "%{count} Sekunden"
+ prompts:
+ day: Tag
+ hour: Stunden
+ minute: Minute
+ month: Monat
+ second: Sekunde
+ year: Jahr
+ errors:
+ format: "%{attribute} %{message}"
+ messages:
+ accepted: muss akzeptiert werden
+ blank: muss ausgefüllt werden
+ present: darf nicht ausgefüllt werden
+ confirmation: stimmt nicht mit %{attribute} überein
+ empty: muss ausgefüllt werden
+ equal_to: muss genau %{count} sein
+ even: muss gerade sein
+ exclusion: ist nicht verfügbar
+ greater_than: muss größer als %{count} sein
+ greater_than_or_equal_to: muss größer oder gleich %{count} sein
+ inclusion: ist kein gültiger Wert
+ invalid: ist nicht gültig
+ less_than: muss kleiner als %{count} sein
+ less_than_or_equal_to: muss kleiner oder gleich %{count} sein
+ model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'
+ not_a_number: ist keine Zahl
+ not_an_integer: muss ganzzahlig sein
+ odd: muss ungerade sein
+ required: muss ausgefüllt werden
+ taken: ist bereits vergeben
+ too_long:
+ one: ist zu lang (mehr als 1 Zeichen)
+ other: ist zu lang (mehr als %{count} Zeichen)
+ too_short:
+ one: ist zu kurz (weniger als 1 Zeichen)
+ other: ist zu kurz (weniger als %{count} Zeichen)
+ wrong_length:
+ one: hat die falsche Länge (muss genau 1 Zeichen haben)
+ other: hat die falsche Länge (muss genau %{count} Zeichen haben)
+ other_than: darf nicht gleich %{count} sein
+ template:
+ body: 'Bitte überprüfen Sie die folgenden Felder:'
+ header:
+ one: 'Konnte %{model} nicht speichern: ein Fehler.'
+ other: 'Konnte %{model} nicht speichern: %{count} Fehler.'
+ helpers:
+ select:
+ prompt: Bitte wählen
+ submit:
+ create: "%{model} erstellen"
+ submit: "%{model} speichern"
+ update: "%{model} aktualisieren"
+ number:
+ currency:
+ format:
+ delimiter: "."
+ format: "%n %u"
+ precision: 2
+ separator: ","
+ significant: false
+ strip_insignificant_zeros: false
+ unit: "€"
+ format:
+ delimiter: "."
+ precision: 2
+ separator: ","
+ significant: false
+ strip_insignificant_zeros: false
+ human:
+ decimal_units:
+ format: "%n %u"
+ units:
+ billion:
+ one: Milliarde
+ other: Milliarden
+ million:
+ one: Million
+ other: Millionen
+ quadrillion:
+ one: Billiarde
+ other: Billiarden
+ thousand: Tausend
+ trillion:
+ one: Billion
+ other: Billionen
+ unit: ''
+ format:
+ delimiter: ''
+ precision: 3
+ significant: true
+ strip_insignificant_zeros: true
+ storage_units:
+ format: "%n %u"
+ units:
+ byte:
+ one: Byte
+ other: Bytes
+ gb: GB
+ kb: KB
+ mb: MB
+ tb: TB
+ percentage:
+ format:
+ delimiter: ''
+ format: "%n %"
+ precision:
+ format:
+ delimiter: ''
+ support:
+ array:
+ last_word_connector: " und "
+ two_words_connector: " und "
+ words_connector: ", "
+ time:
+ am: vormittags
+ formats:
+ default: "%A, %d. %B %Y, %H:%M Uhr"
+ long: "%A, %d. %B %Y, %H:%M Uhr"
+ short: "%d. %B, %H:%M Uhr"
+ pm: nachmittags
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 12a59be79f0..8932db138d9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2,14 +2,251 @@
# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
en:
- hello: "Hello world"
+ views:
+ pagination:
+ previous: "Prev"
+ next: "Next"
+ date:
+ abbr_day_names:
+ - Sun
+ - Mon
+ - Tue
+ - Wed
+ - Thu
+ - Fri
+ - Sat
+ abbr_month_names:
+ -
+ - Jan
+ - Feb
+ - Mar
+ - Apr
+ - May
+ - Jun
+ - Jul
+ - Aug
+ - Sep
+ - Oct
+ - Nov
+ - Dec
+ day_names:
+ - Sunday
+ - Monday
+ - Tuesday
+ - Wednesday
+ - Thursday
+ - Friday
+ - Saturday
+ formats:
+ default: "%Y-%m-%d"
+ long: "%B %d, %Y"
+ short: "%b %d"
+ month_names:
+ -
+ - January
+ - February
+ - March
+ - April
+ - May
+ - June
+ - July
+ - August
+ - September
+ - October
+ - November
+ - December
+ order:
+ - :year
+ - :month
+ - :day
+ datetime:
+ time_ago_in_words:
+ half_a_minute: "half a minute ago"
+ less_than_x_seconds:
+ one: "less than 1 second ago"
+ other: "less than %{count} seconds ago"
+ x_seconds:
+ one: "1 second ago"
+ other: "%{count} seconds ago"
+ less_than_x_minutes:
+ one: "less than a minute ago"
+ other: "less than %{count} minutes ago"
+ x_minutes:
+ one: "1 minute ago"
+ other: "%{count} minutes ago"
+ about_x_hours:
+ one: "about 1 hour ago"
+ other: "about %{count} hours ago"
+ x_days:
+ one: "1 day ago"
+ other: "%{count} days ago"
+ about_x_months:
+ one: "about 1 month ago"
+ other: "about %{count} months ago"
+ x_months:
+ one: "1 month ago"
+ other: "%{count} months ago"
+ about_x_years:
+ one: "about 1 year ago"
+ other: "about %{count} years ago"
+ over_x_years:
+ one: "over 1 year ago"
+ other: "over %{count} years ago"
+ almost_x_years:
+ one: "almost 1 year ago"
+ other: "almost %{count} years ago"
+ distance_in_words:
+ about_x_hours:
+ one: about 1 hour
+ other: about %{count} hours
+ about_x_months:
+ one: about 1 month
+ other: about %{count} months
+ about_x_years:
+ one: about 1 year
+ other: about %{count} years
+ almost_x_years:
+ one: almost 1 year
+ other: almost %{count} years
+ half_a_minute: half a minute
+ less_than_x_minutes:
+ one: less than a minute
+ other: less than %{count} minutes
+ less_than_x_seconds:
+ one: less than 1 second
+ other: less than %{count} seconds
+ over_x_years:
+ one: over 1 year
+ other: over %{count} years
+ x_days:
+ one: 1 day
+ other: "%{count} days"
+ x_minutes:
+ one: 1 minute
+ other: "%{count} minutes"
+ x_months:
+ one: 1 month
+ other: "%{count} months"
+ x_years:
+ one: 1 year
+ other: "%{count} years"
+ x_seconds:
+ one: 1 second
+ other: "%{count} seconds"
+ prompts:
+ day: Day
+ hour: Hour
+ minute: Minute
+ month: Month
+ second: Seconds
+ year: Year
errors:
+ format: "%{attribute} %{message}"
messages:
label_already_exists_at_group_level: "already exists at group level for %{group}. Please choose another one."
wrong_size: "is the wrong size (should be %{file_size})"
size_too_small: "is too small (should be at least %{file_size})"
size_too_big: "is too big (should be at most %{file_size})"
- views:
- pagination:
- previous: "Prev"
- next: "Next"
+ accepted: must be accepted
+ blank: can't be blank
+ present: must be blank
+ confirmation: doesn't match %{attribute}
+ empty: can't be empty
+ equal_to: must be equal to %{count}
+ even: must be even
+ exclusion: is reserved
+ greater_than: must be greater than %{count}
+ greater_than_or_equal_to: must be greater than or equal to %{count}
+ inclusion: is not included in the list
+ invalid: is invalid
+ less_than: must be less than %{count}
+ less_than_or_equal_to: must be less than or equal to %{count}
+ model_invalid: "Validation failed: %{errors}"
+ not_a_number: is not a number
+ not_an_integer: must be an integer
+ odd: must be odd
+ required: must exist
+ taken: has already been taken
+ too_long:
+ one: is too long (maximum is 1 character)
+ other: is too long (maximum is %{count} characters)
+ too_short:
+ one: is too short (minimum is 1 character)
+ other: is too short (minimum is %{count} characters)
+ wrong_length:
+ one: is the wrong length (should be 1 character)
+ other: is the wrong length (should be %{count} characters)
+ other_than: must be other than %{count}
+ template:
+ body: 'There were problems with the following fields:'
+ header:
+ one: 1 error prohibited this %{model} from being saved
+ other: "%{count} errors prohibited this %{model} from being saved"
+ helpers:
+ select:
+ prompt: Please select
+ submit:
+ create: Create %{model}
+ submit: Save %{model}
+ update: Update %{model}
+ number:
+ currency:
+ format:
+ delimiter: ","
+ format: "%u%n"
+ precision: 2
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ unit: "$"
+ format:
+ delimiter: ","
+ precision: 3
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ human:
+ decimal_units:
+ format: "%n %u"
+ units:
+ billion: Billion
+ million: Million
+ quadrillion: Quadrillion
+ thousand: Thousand
+ trillion: Trillion
+ unit: ''
+ format:
+ delimiter: ''
+ precision: 3
+ significant: true
+ strip_insignificant_zeros: true
+ storage_units:
+ format: "%n %u"
+ units:
+ byte:
+ one: Byte
+ other: Bytes
+ gb: GB
+ kb: KB
+ mb: MB
+ tb: TB
+ percentage:
+ format:
+ delimiter: ''
+ format: "%n%"
+ precision:
+ format:
+ delimiter: ''
+ support:
+ array:
+ last_word_connector: ", and "
+ two_words_connector: " and "
+ words_connector: ", "
+ time:
+ am: am
+ formats:
+ default: "%a, %d %b %Y %H:%M:%S %z"
+ long: "%B %d, %Y %H:%M"
+ short: "%d %b %H:%M"
+ timeago_tooltip: "%b %-d, %Y %-l:%M%P"
+ pm: pm
diff --git a/config/locales/es.yml b/config/locales/es.yml
new file mode 100644
index 00000000000..fdc52b4ae11
--- /dev/null
+++ b/config/locales/es.yml
@@ -0,0 +1,255 @@
+---
+es:
+ activerecord:
+ errors:
+ messages:
+ record_invalid: "La validación falló: %{errors}"
+ restrict_dependent_destroy:
+ has_one: No se puede eliminar el registro porque existe un %{record} dependiente
+ has_many: No se puede eliminar el registro porque existen %{record} dependientes
+ date:
+ abbr_day_names:
+ - dom
+ - lun
+ - mar
+ - mié
+ - jue
+ - vie
+ - sáb
+ abbr_month_names:
+ -
+ - ene
+ - feb
+ - mar
+ - abr
+ - may
+ - jun
+ - jul
+ - ago
+ - sep
+ - oct
+ - nov
+ - dic
+ day_names:
+ - domingo
+ - lunes
+ - martes
+ - miércoles
+ - jueves
+ - viernes
+ - sábado
+ formats:
+ default: "%d/%m/%Y"
+ long: "%d de %B de %Y"
+ short: "%d de %b"
+ month_names:
+ -
+ - enero
+ - febrero
+ - marzo
+ - abril
+ - mayo
+ - junio
+ - julio
+ - agosto
+ - septiembre
+ - octubre
+ - noviembre
+ - diciembre
+ order:
+ - :day
+ - :month
+ - :year
+ datetime:
+ # used in a custom scope that has been created to fix https://gitlab.com/gitlab-org/gitlab-ce/issues/32747
+ time_ago_in_words:
+ half_a_minute: "hace medio minuto"
+ less_than_x_seconds:
+ one: "hace menos de 1 segundo"
+ other: "hace menos de %{count} segundos"
+ x_seconds:
+ one: "hace 1 segundo"
+ other: "hace %{count} segundos"
+ less_than_x_minutes:
+ one: "hace menos de un minuto"
+ other: "hace menos de %{count} minutos"
+ x_minutes:
+ one: "hace 1 minuto"
+ other: "hace %{count} minutos"
+ about_x_hours:
+ one: "hace alrededor de 1 hora"
+ other: "hace alrededor de %{count} horas"
+ x_days:
+ one: "hace un día"
+ other: "hace %{count} días"
+ about_x_months:
+ one: "hace alrededor de 1 mes"
+ other: "hace alrededor de %{count} meses"
+ x_months:
+ one: "hace 1 mes"
+ other: "hace %{count} meses"
+ about_x_years:
+ one: "hace alrededor de 1 año"
+ other: "hace alrededor de %{count} años"
+ over_x_years:
+ one: "hace más de 1 año"
+ other: "hace %{count} años"
+ almost_x_years:
+ one: "hace casi 1 año"
+ other: "hace casi %{count} años"
+ # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words()
+ distance_in_words:
+ about_x_hours:
+ one: alrededor de 1 hora
+ other: alrededor de %{count} horas
+ about_x_months:
+ one: alrededor de 1 mes
+ other: alrededor de %{count} meses
+ about_x_years:
+ one: alrededor de 1 año
+ other: alrededor de %{count} años
+ almost_x_years:
+ one: casi 1 año
+ other: casi %{count} años
+ half_a_minute: medio minuto
+ less_than_x_minutes:
+ one: menos de 1 minuto
+ other: menos de %{count} minutos
+ less_than_x_seconds:
+ one: menos de 1 segundo
+ other: menos de %{count} segundos
+ over_x_years:
+ one: más de 1 año
+ other: más de %{count} años
+ x_days:
+ one: 1 día
+ other: "%{count} días"
+ x_minutes:
+ one: 1 minuto
+ other: "%{count} minutos"
+ x_months:
+ one: 1 mes
+ other: "%{count} meses"
+ x_years:
+ one: 1 año
+ other: "%{count} años"
+ x_seconds:
+ one: 1 segundo
+ other: "%{count} segundos"
+ prompts:
+ day: Día
+ hour: Hora
+ minute: Minutos
+ month: Mes
+ second: Segundos
+ year: Año
+ errors:
+ format: "%{attribute} %{message}"
+ messages:
+ accepted: debe ser aceptado
+ blank: no puede estar en blanco
+ present: debe estar en blanco
+ confirmation: no coincide
+ empty: no puede estar vacío
+ equal_to: debe ser igual a %{count}
+ even: debe ser par
+ exclusion: está reservado
+ greater_than: debe ser mayor que %{count}
+ greater_than_or_equal_to: debe ser mayor que o igual a %{count}
+ inclusion: no está incluido en la lista
+ invalid: no es válido
+ less_than: debe ser menor que %{count}
+ less_than_or_equal_to: debe ser menor que o igual a %{count}
+ model_invalid: "La validación falló: %{errors}"
+ not_a_number: no es un número
+ not_an_integer: debe ser un entero
+ odd: debe ser impar
+ required: debe existir
+ taken: ya está en uso
+ too_long:
+ one: "es demasiado largo (1 carácter máximo)"
+ other: "es demasiado largo (%{count} caracteres máximo)"
+ too_short:
+ one: "es demasiado corto (1 carácter mínimo)"
+ other: "es demasiado corto (%{count} caracteres mínimo)"
+ wrong_length:
+ one: "no tiene la longitud correcta (1 carácter exactos)"
+ other: "no tiene la longitud correcta (%{count} caracteres exactos)"
+ other_than: debe ser distinto de %{count}
+ template:
+ body: 'Se encontraron problemas con los siguientes campos:'
+ header:
+ one: No se pudo guardar este/a %{model} porque se encontró 1 error
+ other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores
+ helpers:
+ select:
+ prompt: Por favor seleccione
+ submit:
+ create: Crear %{model}
+ submit: Guardar %{model}
+ update: Actualizar %{model}
+ number:
+ currency:
+ format:
+ delimiter: "."
+ format: "%n %u"
+ precision: 2
+ separator: ","
+ significant: false
+ strip_insignificant_zeros: false
+ unit: "€"
+ format:
+ delimiter: "."
+ precision: 3
+ separator: ","
+ significant: false
+ strip_insignificant_zeros: false
+ human:
+ decimal_units:
+ format: "%n %u"
+ units:
+ billion: mil millones
+ million:
+ one: millón
+ other: millones
+ quadrillion: mil billones
+ thousand: mil
+ trillion:
+ one: billón
+ other: billones
+ unit: ''
+ format:
+ delimiter: ''
+ precision: 1
+ significant: true
+ strip_insignificant_zeros: true
+ storage_units:
+ format: "%n %u"
+ units:
+ byte:
+ one: Byte
+ other: Bytes
+ gb: GB
+ kb: KB
+ mb: MB
+ tb: TB
+ percentage:
+ format:
+ delimiter: ''
+ format: "%n %"
+ precision:
+ format:
+ delimiter: ''
+ support:
+ array:
+ last_word_connector: " y "
+ two_words_connector: " y "
+ words_connector: ", "
+ time:
+ am: am
+ formats:
+ default: "%A, %d de %B de %Y %H:%M:%S %z"
+ long: "%d de %B de %Y %H:%M"
+ short: "%d de %b %H:%M"
+ timeago_tooltip: "%d de %B de %Y %H:%M"
+ pm: pm
diff --git a/config/mail_room.yml b/config/mail_room.yml
index 88d93d4bc6b..c3a5be8d38c 100644
--- a/config/mail_room.yml
+++ b/config/mail_room.yml
@@ -21,7 +21,7 @@
:delivery_method: sidekiq
:delivery_options:
:redis_url: <%= config[:redis_url].to_json %>
- :namespace: <%= Gitlab::Redis::SIDEKIQ_NAMESPACE %>
+ :namespace: <%= Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE %>
:queue: email_receiver
:worker: EmailReceiverWorker
<% if config[:sentinels] %>
@@ -36,7 +36,7 @@
:arbitration_method: redis
:arbitration_options:
:redis_url: <%= config[:redis_url].to_json %>
- :namespace: <%= Gitlab::Redis::MAILROOM_NAMESPACE %>
+ :namespace: <%= Gitlab::Redis::Queues::MAILROOM_NAMESPACE %>
<% if config[:sentinels] %>
:sentinels:
<% config[:sentinels].each do |sentinel| %>
diff --git a/config/prometheus/additional_metrics.yml b/config/prometheus/additional_metrics.yml
new file mode 100644
index 00000000000..0642a0b2fe9
--- /dev/null
+++ b/config/prometheus/additional_metrics.yml
@@ -0,0 +1,133 @@
+- group: Response metrics (NGINX Ingress)
+ priority: 10
+ metrics:
+ - title: "Throughput"
+ y_label: "Requests / Sec"
+ required_metrics:
+ - nginx_upstream_requests_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(nginx_upstream_requests_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m]))'
+ label: Total
+ unit: req / sec
+ - title: "Latency"
+ y_label: "Latency (ms)"
+ required_metrics:
+ - nginx_upstream_response_msecs_avg
+ weight: 1
+ queries:
+ - query_range: 'avg(nginx_upstream_response_msecs_avg{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"})'
+ label: Average
+ unit: ms
+ - title: "HTTP Error Rate"
+ y_label: "HTTP 500 Errors / Sec"
+ required_metrics:
+ - nginx_upstream_responses_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m]))'
+ label: HTTP Errors
+ unit: "errors / sec"
+- group: Response metrics (HA Proxy)
+ priority: 10
+ metrics:
+ - title: "Throughput"
+ y_label: "Requests / Sec"
+ required_metrics:
+ - haproxy_frontend_http_requests_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(haproxy_frontend_http_requests_total{%{environment_filter}}[2m]))'
+ label: Total
+ unit: req / sec
+ - title: "HTTP Error Rate"
+ y_label: "Error Rate (%)"
+ required_metrics:
+ - haproxy_frontend_http_responses_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(haproxy_frontend_http_responses_total{code="5xx",%{environment_filter}}[2m])) / sum(rate(haproxy_frontend_http_responses_total{%{environment_filter}}[2m]))'
+ label: HTTP Errors
+ unit: "%"
+- group: Response metrics (AWS ELB)
+ priority: 10
+ metrics:
+ - title: "Throughput"
+ y_label: "Requests / Sec"
+ required_metrics:
+ - aws_elb_request_count_sum
+ weight: 1
+ queries:
+ - query_range: 'sum(aws_elb_request_count_sum{%{environment_filter}}) / 60'
+ label: Total
+ unit: req / sec
+ - title: "Latency"
+ y_label: "Latency (ms)"
+ required_metrics:
+ - aws_elb_latency_average
+ weight: 1
+ queries:
+ - query_range: 'avg(aws_elb_latency_average{%{environment_filter}}) * 1000'
+ label: Average
+ unit: ms
+ - title: "HTTP Error Rate"
+ y_label: "Error Rate (%)"
+ required_metrics:
+ - aws_elb_request_count_sum
+ - aws_elb_httpcode_backend_5_xx_sum
+ weight: 1
+ queries:
+ - query_range: 'sum(aws_elb_httpcode_backend_5_xx_sum{%{environment_filter}}) / sum(aws_elb_request_count_sum{%{environment_filter}})'
+ label: HTTP Errors
+ unit: "%"
+- group: Response metrics (NGINX)
+ priority: 10
+ metrics:
+ - title: "Throughput"
+ y_label: "Requests / Sec"
+ required_metrics:
+ - nginx_requests_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(nginx_requests_total{server_zone!="*", server_zone!="_", %{environment_filter}}[2m]))'
+ label: Total
+ unit: req / sec
+ - title: "Latency"
+ y_label: "Latency (ms)"
+ required_metrics:
+ - nginx_upstream_response_msecs_avg
+ weight: 1
+ queries:
+ - query_range: 'avg(nginx_upstream_response_msecs_avg{%{environment_filter}})'
+ label: Upstream
+ unit: ms
+ - title: "HTTP Error Rate"
+ y_label: "HTTP 500 Errors / Sec"
+ required_metrics:
+ - nginx_responses_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(nginx_responses_total{status_code="5xx", %{environment_filter}}[2m]))'
+ label: HTTP Errors
+ unit: "errors / sec"
+- group: System metrics (Kubernetes)
+ priority: 5
+ metrics:
+ - title: "Memory Usage"
+ y_label: "Memory Usage (MB)"
+ required_metrics:
+ - container_memory_usage_bytes
+ weight: 1
+ queries:
+ - query_range: '(sum(container_memory_usage_bytes{container_name!="POD",%{environment_filter}}) / count(container_memory_usage_bytes{container_name!="POD",%{environment_filter}})) /1024/1024'
+ label: Average
+ unit: MB
+ - title: "CPU Utilization"
+ y_label: "CPU Utilization (%)"
+ required_metrics:
+ - container_cpu_usage_seconds_total
+ weight: 1
+ queries:
+ - query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}) * 100'
+ label: Average
+ unit: "%"
diff --git a/config/redis.cache.yml.example b/config/redis.cache.yml.example
new file mode 100644
index 00000000000..27478f0a93e
--- /dev/null
+++ b/config/redis.cache.yml.example
@@ -0,0 +1,38 @@
+# If you change this file in a Merge Request, please also create
+# a Merge Request on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests
+#
+development:
+ url: redis://localhost:6379/10
+ #
+ # url: redis://localhost:6380
+ # sentinels:
+ # -
+ # host: localhost
+ # port: 26380 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26380 # point to sentinel, not to redis port
+test:
+ url: redis://localhost:6379/10
+ #
+ # url: redis://localhost:6380
+production:
+ # Redis (single instance)
+ url: unix:/var/run/redis/redis.cache.sock
+ ##
+ # Redis + Sentinel (for HA)
+ #
+ # Please read instructions carefully before using it as you may lose data:
+ # http://redis.io/topics/sentinel
+ #
+ # You must specify a list of a few sentinels that will handle client connection
+ # please read here for more information: https://docs.gitlab.com/ce/administration/high_availability/redis.html
+ ##
+ # url: redis://master:6380
+ # sentinels:
+ # -
+ # host: slave1
+ # port: 26380 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26380 # point to sentinel, not to redis port
diff --git a/config/redis.queues.yml.example b/config/redis.queues.yml.example
new file mode 100644
index 00000000000..dab1f26b096
--- /dev/null
+++ b/config/redis.queues.yml.example
@@ -0,0 +1,38 @@
+# If you change this file in a Merge Request, please also create
+# a Merge Request on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests
+#
+development:
+ url: redis://localhost:6379/11
+ #
+ # url: redis://localhost:6381
+ # sentinels:
+ # -
+ # host: localhost
+ # port: 26381 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26381 # point to sentinel, not to redis port
+test:
+ url: redis://localhost:6379/11
+ #
+ # url: redis://localhost:6381
+production:
+ # Redis (single instance)
+ url: unix:/var/run/redis/redis.queues.sock
+ ##
+ # Redis + Sentinel (for HA)
+ #
+ # Please read instructions carefully before using it as you may lose data:
+ # http://redis.io/topics/sentinel
+ #
+ # You must specify a list of a few sentinels that will handle client connection
+ # please read here for more information: https://docs.gitlab.com/ce/administration/high_availability/redis.html
+ ##
+ # url: redis://master:6381
+ # sentinels:
+ # -
+ # host: slave1
+ # port: 26381 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26381 # point to sentinel, not to redis port
diff --git a/config/redis.shared_state.yml.example b/config/redis.shared_state.yml.example
new file mode 100644
index 00000000000..9371e3619b7
--- /dev/null
+++ b/config/redis.shared_state.yml.example
@@ -0,0 +1,38 @@
+# If you change this file in a Merge Request, please also create
+# a Merge Request on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests
+#
+development:
+ url: redis://localhost:6379/12
+ #
+ # url: redis://localhost:6382
+ # sentinels:
+ # -
+ # host: localhost
+ # port: 26382 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26382 # point to sentinel, not to redis port
+test:
+ url: redis://localhost:6379/12
+ #
+ # url: redis://localhost:6382
+production:
+ # Redis (single instance)
+ url: unix:/var/run/redis/redis.shared_state.sock
+ ##
+ # Redis + Sentinel (for HA)
+ #
+ # Please read instructions carefully before using it as you may lose data:
+ # http://redis.io/topics/sentinel
+ #
+ # You must specify a list of a few sentinels that will handle client connection
+ # please read here for more information: https://docs.gitlab.com/ce/administration/high_availability/redis.html
+ ##
+ # url: redis://master:6382
+ # sentinels:
+ # -
+ # host: slave1
+ # port: 26382 # point to sentinel, not to redis port
+ # -
+ # host: slave2
+ # port: 26382 # point to sentinel, not to redis port
diff --git a/config/routes.rb b/config/routes.rb
index 1da226a3b57..4fd6cb5d439 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,5 @@
require 'sidekiq/web'
require 'sidekiq/cron/web'
-require 'constraints/group_url_constrainer'
Rails.application.routes.draw do
concern :access_requestable do
@@ -39,10 +38,11 @@ Rails.application.routes.draw do
# Health check
get 'health_check(/:checks)' => 'health_check#index', as: :health_check
- scope path: '-', controller: 'health' do
- get :liveness
- get :readiness
- get :metrics
+ scope path: '-' do
+ get 'liveness' => 'health#liveness'
+ get 'readiness' => 'health#readiness'
+ resources :metrics, only: [:index]
+ mount Peek::Railtie => '/peek'
end
# Koding route
@@ -85,19 +85,7 @@ Rails.application.routes.draw do
root to: "root#index"
- # Since group show page is wildcard routing
- # we want all other routing to be checked before matching this one
- constraints(GroupUrlConstrainer.new) do
- scope(path: '*id',
- as: :group,
- constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ },
- controller: :groups) do
- get '/', action: :show
- patch '/', action: :update
- put '/', action: :update
- delete '/', action: :destroy
- end
- end
+ draw :test if Rails.env.test?
get '*unmatched_route', to: 'application#route_not_found'
end
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 52ba10604d4..c0748231813 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -36,7 +36,7 @@ namespace :admin do
scope(path: 'groups/*id',
controller: :groups,
- constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do
+ constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do
scope(as: :group) do
put :members_update
@@ -48,10 +48,18 @@ namespace :admin do
end
end
- resources :deploy_keys, only: [:index, :new, :create, :destroy]
+ resources :deploy_keys, only: [:index, :new, :create, :edit, :update, :destroy]
- resources :hooks, only: [:index, :create, :destroy] do
- get :test
+ resources :hooks, only: [:index, :create, :edit, :update, :destroy] do
+ member do
+ get :test
+ end
+
+ resources :hook_logs, only: [:show] do
+ member do
+ get :retry
+ end
+ end
end
resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do
@@ -59,17 +67,23 @@ namespace :admin do
end
resource :logs, only: [:show]
- resource :health_check, controller: 'health_check', only: [:show]
+ resource :health_check, controller: 'health_check', only: [:show] do
+ post :reset_storage_health
+ end
resource :background_jobs, controller: 'background_jobs', only: [:show]
resource :system_info, controller: 'system_info', only: [:show]
resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ }
+ get 'conversational_development_index' => 'conversational_development_index#show'
+
resources :projects, only: [:index]
- scope(path: 'projects/*namespace_id', as: :namespace) do
+ scope(path: 'projects/*namespace_id',
+ as: :namespace,
+ constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do
resources(:projects,
path: '/',
- constraints: { id: Gitlab::Regex.project_route_regex },
+ constraints: { id: Gitlab::PathRegex.project_route_regex },
only: [:show]) do
member do
@@ -108,7 +122,7 @@ namespace :admin do
resources :cohorts, only: :index
- resources :builds, only: :index do
+ resources :jobs, only: :index do
collection do
post :cancel_all
end
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 69c8efc151c..ce7a7c88900 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -1,2 +1,2 @@
API::API.logger Rails.logger
-mount API::API => '/api'
+mount API::API => '/'
diff --git a/config/routes/dashboard.rb b/config/routes/dashboard.rb
index 8e380a0b0ac..d2437285cdf 100644
--- a/config/routes/dashboard.rb
+++ b/config/routes/dashboard.rb
@@ -4,7 +4,13 @@ resource :dashboard, controller: 'dashboard', only: [] do
get :activity
scope module: :dashboard do
- resources :milestones, only: [:index, :show]
+ resources :milestones, only: [:index, :show] do
+ member do
+ get :merge_requests
+ get :participants
+ get :labels
+ end
+ end
resources :labels, only: [:index]
resources :groups, only: [:index]
diff --git a/config/routes/git_http.rb b/config/routes/git_http.rb
index 42d874eeebc..a53c94326d4 100644
--- a/config/routes/git_http.rb
+++ b/config/routes/git_http.rb
@@ -1,5 +1,7 @@
-scope(path: '*namespace_id/:project_id', constraints: { format: nil }) do
- scope(constraints: { project_id: Gitlab::Regex.project_git_route_regex }, module: :projects) do
+scope(path: '*namespace_id/:project_id',
+ format: nil,
+ constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do
+ scope(constraints: { project_id: Gitlab::PathRegex.project_git_route_regex }, module: :projects) do
# Git HTTP clients ('git clone' etc.)
scope(controller: :git_http) do
get '/info/refs', action: :info_refs
@@ -26,7 +28,7 @@ scope(path: '*namespace_id/:project_id', constraints: { format: nil }) do
end
# Redirect /group/project/info/refs to /group/project.git/info/refs
- scope(constraints: { project_id: Gitlab::Regex.project_route_regex }) do
+ scope(constraints: { project_id: Gitlab::PathRegex.project_route_regex }) do
# Allow /info/refs, /info/refs?service=git-upload-pack, and
# /info/refs?service=git-receive-pack, but nothing else.
#
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 73f69d76995..23052a6c6dc 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -1,25 +1,41 @@
+require 'constraints/group_url_constrainer'
+
resources :groups, only: [:index, :new, :create]
scope(path: 'groups/*group_id',
module: :groups,
as: :group,
- constraints: { group_id: Gitlab::Regex.namespace_route_regex }) do
+ constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do
resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do
post :resend_invite, on: :member
delete :leave, on: :collection
end
resource :avatar, only: [:destroy]
- resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create]
+ resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :edit, :update, :new, :create] do
+ member do
+ get :merge_requests
+ get :participants
+ get :labels
+ end
+ end
resources :labels, except: [:show] do
post :toggle_subscription, on: :member
end
+
+ scope path: '-' do
+ namespace :settings do
+ resource :ci_cd, only: [:show], controller: 'ci_cd'
+ end
+
+ resources :variables, only: [:index, :show, :update, :create, :destroy]
+ end
end
scope(path: 'groups/*id',
controller: :groups,
- constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do
+ constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do
get :edit, as: :edit_group
get :issues, as: :issues_group
get :merge_requests, as: :merge_requests_group
@@ -28,3 +44,15 @@ scope(path: 'groups/*id',
get :subgroups, as: :subgroups_group
get '/', action: :show, as: :group_canonical
end
+
+constraints(GroupUrlConstrainer.new) do
+ scope(path: '*id',
+ as: :group,
+ constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ },
+ controller: :groups) do
+ get '/', action: :show
+ patch '/', action: :update
+ put '/', action: :update
+ delete '/', action: :destroy
+ end
+end
diff --git a/config/routes/legacy_builds.rb b/config/routes/legacy_builds.rb
new file mode 100644
index 00000000000..5ab2b953ce1
--- /dev/null
+++ b/config/routes/legacy_builds.rb
@@ -0,0 +1,22 @@
+resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do
+ collection do
+ resources :artifacts, only: [], controller: 'build_artifacts' do
+ collection do
+ get :latest_succeeded,
+ path: '*ref_name_and_path',
+ format: false
+ end
+ end
+ end
+
+ member do
+ get :raw
+ end
+
+ resource :artifacts, only: [], controller: 'build_artifacts' do
+ get :download
+ get :browse, path: 'browse(/*path)', format: false
+ get :file, path: 'file/*path', format: false
+ get :raw, path: 'raw/*path', format: false
+ end
+end
diff --git a/config/routes/profile.rb b/config/routes/profile.rb
index 07c341999ea..3e4e6111ab8 100644
--- a/config/routes/profile.rb
+++ b/config/routes/profile.rb
@@ -5,6 +5,7 @@ resource :profile, only: [:show, :update] do
put :reset_private_token
put :reset_incoming_email_token
+ put :reset_rss_token
put :update_username
end
@@ -22,6 +23,11 @@ resource :profile, only: [:show, :update] do
end
resource :preferences, only: [:show, :update]
resources :keys, only: [:index, :show, :create, :destroy]
+ resources :gpg_keys, only: [:index, :create, :destroy] do
+ member do
+ put :revoke
+ end
+ end
resources :emails, only: [:index, :create, :destroy]
resources :chat_names, only: [:index, :new, :create, :destroy] do
collection do
diff --git a/config/routes/project.rb b/config/routes/project.rb
index fa92202c1ea..06928c7b9ce 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -5,9 +5,24 @@ resources :projects, only: [:index, :new, :create]
draw :git_http
constraints(ProjectUrlConstrainer.new) do
- scope(path: '*namespace_id', as: :namespace) do
+ # If the route has a wildcard segment, the segment has a regex constraint,
+ # the segment is potentially followed by _another_ wildcard segment, and
+ # the `format` option is not set to false, we need to specify that
+ # regex constraint _outside_ of `constraints: {}`.
+ #
+ # Otherwise, Rails will overwrite the constraint with `/.+?/`,
+ # which breaks some of our wildcard routes like `/blob/*id`
+ # and `/tree/*id` that depend on the negative lookahead inside
+ # `Gitlab::PathRegex.full_namespace_route_regex`, which helps the router
+ # determine whether a certain path segment is part of `*namespace_id`,
+ # `:project_id`, or `*id`.
+ #
+ # See https://github.com/rails/rails/blob/v4.2.8/actionpack/lib/action_dispatch/routing/mapper.rb#L155
+ scope(path: '*namespace_id',
+ as: :namespace,
+ namespace_id: Gitlab::PathRegex.full_namespace_route_regex) do
scope(path: ':project_id',
- constraints: { project_id: Gitlab::Regex.project_route_regex },
+ constraints: { project_id: Gitlab::PathRegex.project_route_regex },
module: :projects,
as: :project) do
@@ -44,20 +59,24 @@ constraints(ProjectUrlConstrainer.new) do
resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do
member do
- get 'raw'
+ get :raw
post :mark_as_spam
end
end
resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do
member do
- get :test
+ put :test
end
end
resource :mattermost, only: [:new, :create]
- resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do
+ namespace :prometheus do
+ get :active_metrics
+ end
+
+ resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
member do
put :enable
put :disable
@@ -67,34 +86,41 @@ constraints(ProjectUrlConstrainer.new) do
resources :forks, only: [:index, :new, :create]
resource :import, only: [:new, :create, :show]
- resources :merge_requests, concerns: :awardable, constraints: { id: /\d+/ } do
+ resources :merge_requests, concerns: :awardable, except: [:new, :create], constraints: { id: /\d+/ } do
member do
- get :commits
- get :diffs
- get :conflicts
- get :conflict_for_path
- get :pipelines
- get :merge_check
+ get :commit_change_content
post :merge
- get :merge_widget_refresh
post :cancel_merge_when_pipeline_succeeds
- get :ci_status
get :pipeline_status
get :ci_environments_status
post :toggle_subscription
post :remove_wip
- get :diff_for_path
- post :resolve_conflicts
post :assign_related_issues
+
+ scope constraints: { format: nil }, action: :show do
+ get :commits, defaults: { tab: 'commits' }
+ get :pipelines, defaults: { tab: 'pipelines' }
+ get :diffs, defaults: { tab: 'diffs' }
+ end
+
+ scope constraints: { format: 'json' }, as: :json do
+ get :commits
+ get :pipelines
+ get :diffs, to: 'merge_requests/diffs#show'
+ end
+
+ get :diff_for_path, controller: 'merge_requests/diffs'
+
+ scope controller: 'merge_requests/conflicts' do
+ get :conflicts, action: :show
+ get :conflict_for_path
+ post :resolve_conflicts
+ end
end
collection do
- get :branch_from
- get :branch_to
- get :update_branches
get :diff_for_path
post :bulk_update
- get :new_diffs, path: 'new/diffs'
end
resources :discussions, only: [], constraints: { id: /\h{40}/ } do
@@ -105,6 +131,29 @@ constraints(ProjectUrlConstrainer.new) do
end
end
+ controller 'merge_requests/creations', path: 'merge_requests' do
+ post '', action: :create, as: nil
+
+ scope path: 'new', as: :new_merge_request do
+ get '', action: :new
+
+ scope constraints: { format: nil }, action: :new do
+ get :diffs, defaults: { tab: 'diffs' }
+ get :pipelines, defaults: { tab: 'pipelines' }
+ end
+
+ scope constraints: { format: 'json' }, as: :json do
+ get :diffs
+ get :pipelines
+ end
+
+ get :diff_for_path
+ get :update_branches
+ get :branch_from
+ get :branch_to
+ end
+ end
+
resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :triggers, only: [:index, :create, :edit, :update, :destroy] do
member do
@@ -123,21 +172,36 @@ constraints(ProjectUrlConstrainer.new) do
post :cancel
post :retry
get :builds
+ get :failures
get :status
end
end
+ resources :pipeline_schedules, except: [:show] do
+ member do
+ post :take_ownership
+ end
+ end
+
resources :environments, except: [:destroy] do
member do
post :stop
get :terminal
get :metrics
+ get :additional_metrics
get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil }
end
collection do
get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ }
end
+
+ resources :deployments, only: [:index] do
+ member do
+ get :metrics
+ get :additional_metrics
+ end
+ end
end
resource :cycle_analytics, only: [:show]
@@ -154,41 +218,52 @@ constraints(ProjectUrlConstrainer.new) do
end
end
- resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do
- collection do
- post :cancel_all
-
- resources :artifacts, only: [] do
- collection do
- get :latest_succeeded,
- path: '*ref_name_and_path',
- format: false
+ scope '-' do
+ resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
+ collection do
+ post :cancel_all
+
+ resources :artifacts, only: [] do
+ collection do
+ get :latest_succeeded,
+ path: '*ref_name_and_path',
+ format: false
+ end
end
end
- end
- member do
- get :status
- post :cancel
- post :retry
- post :play
- post :erase
- get :trace
- get :raw
- end
+ member do
+ get :status
+ post :cancel
+ post :retry
+ post :play
+ post :erase
+ get :trace, defaults: { format: 'json' }
+ get :raw
+ end
- resource :artifacts, only: [] do
- get :download
- get :browse, path: 'browse(/*path)', format: false
- get :file, path: 'file/*path', format: false
- post :keep
+ resource :artifacts, only: [] do
+ get :download
+ get :browse, path: 'browse(/*path)', format: false
+ get :file, path: 'file/*path', format: false
+ get :raw, path: 'raw/*path', format: false
+ post :keep
+ end
end
end
- resources :hooks, only: [:index, :create, :destroy], constraints: { id: /\d+/ } do
+ draw :legacy_builds
+
+ resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do
member do
get :test
end
+
+ resources :hook_logs, only: [:show] do
+ member do
+ get :retry
+ end
+ end
end
resources :container_registry, only: [:index, :destroy],
@@ -197,7 +272,7 @@ constraints(ProjectUrlConstrainer.new) do
namespace :registry do
resources :repository, only: [] do
resources :tags, only: [:destroy],
- constraints: { id: Gitlab::Regex.container_registry_reference_regex }
+ constraints: { id: Gitlab::Regex.container_registry_tag_regex }
end
end
@@ -205,6 +280,9 @@ constraints(ProjectUrlConstrainer.new) do
member do
put :sort_issues
put :sort_merge_requests
+ get :merge_requests
+ get :participants
+ get :labels
end
end
@@ -228,7 +306,8 @@ constraints(ProjectUrlConstrainer.new) do
get :referenced_merge_requests
get :related_branches
get :can_create_branch
- get :rendered_title
+ get :realtime_changes
+ post :create_merge_request
end
collection do
post :bulk_update
@@ -298,16 +377,18 @@ constraints(ProjectUrlConstrainer.new) do
resources :runner_projects, only: [:create, :destroy]
resources :badges, only: [:index] do
collection do
- scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do
+ scope '*ref', constraints: { ref: Gitlab::PathRegex.git_reference_regex } do
constraints format: /svg/ do
- get :build
+ # Keep around until 10.0, see gitlab-org/gitlab-ce#35307
+ get :build, to: "badges#pipeline"
+ get :pipeline
get :coverage
end
end
end
end
namespace :settings do
- resource :members, only: [:show]
+ get :members, to: redirect('/%{namespace_id}/%{project_id}/project_members')
resource :ci_cd, only: [:show], controller: 'ci_cd'
resource :integrations, only: [:show]
resource :repository, only: [:show], controller: :repository
@@ -321,7 +402,7 @@ constraints(ProjectUrlConstrainer.new) do
resources(:projects,
path: '/',
- constraints: { id: Gitlab::Regex.project_route_regex },
+ constraints: { id: Gitlab::PathRegex.project_route_regex },
only: [:edit, :show, :update, :destroy]) do
member do
put :transfer
diff --git a/config/routes/repository.rb b/config/routes/repository.rb
index 5cf37a06e97..9ffdebbcff1 100644
--- a/config/routes/repository.rb
+++ b/config/routes/repository.rb
@@ -2,7 +2,10 @@
resource :repository, only: [:create] do
member do
- get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex }
+ get ':ref/archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex, ref: /.+/ }, action: 'archive', as: 'archive'
+
+ # deprecated since GitLab 9.5
+ get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex }, as: 'archive_alternative'
end
end
@@ -24,7 +27,7 @@ scope format: false do
member do
# tree viewer logs
- get 'logs_tree', constraints: { id: Gitlab::Regex.git_reference_regex }
+ get 'logs_tree', constraints: { id: Gitlab::PathRegex.git_reference_regex }
# Directories with leading dots erroneously get rejected if git
# ref regex used in constraints. Regex verification now done in controller.
get 'logs_tree/*path', action: :logs_tree, as: :logs_file, format: false, constraints: {
@@ -34,7 +37,7 @@ scope format: false do
end
end
- scope constraints: { id: Gitlab::Regex.git_reference_regex } do
+ scope constraints: { id: Gitlab::PathRegex.git_reference_regex } do
resources :network, only: [:show]
resources :graphs, only: [:show] do
@@ -76,6 +79,8 @@ scope format: false do
get '/tree/*id', to: 'tree#show', as: :tree
get '/raw/*id', to: 'raw#show', as: :raw
get '/blame/*id', to: 'blame#show', as: :blame
+
+ get '/commits/*id/signatures', to: 'commits#signatures', as: :signatures
get '/commits/*id', to: 'commits#show', as: :commits
post '/create_dir/*id', to: 'tree#create_dir', as: :create_dir
diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb
index 56534f677be..0a4ebac3ca3 100644
--- a/config/routes/snippets.rb
+++ b/config/routes/snippets.rb
@@ -1,10 +1,20 @@
resources :snippets, concerns: :awardable do
member do
- get 'raw'
- get 'download'
+ get :raw
post :mark_as_spam
+ end
+
+ collection do
post :preview_markdown
end
+
+ scope module: :snippets do
+ resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do
+ member do
+ delete :delete_attachment
+ end
+ end
+ end
end
get '/s/:username', to: redirect('/u/%{username}/snippets'),
diff --git a/config/routes/test.rb b/config/routes/test.rb
new file mode 100644
index 00000000000..ac477cdbbbc
--- /dev/null
+++ b/config/routes/test.rb
@@ -0,0 +1,2 @@
+get '/unicorn_test/pid' => 'unicorn_test#pid'
+post '/unicorn_test/kill' => 'unicorn_test#kill'
diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb
index 2b22148a134..d7bca8310e4 100644
--- a/config/routes/uploads.rb
+++ b/config/routes/uploads.rb
@@ -1,11 +1,21 @@
scope path: :uploads do
# Note attachments and User/Group/Project avatars
- get ":model/:mounted_as/:id/:filename",
+ get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ }
+ # show uploads for models, snippets (notes) available for now
+ get '-/system/:model/:id/:secret/:filename',
+ to: 'uploads#show',
+ constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ }
+
+ # show temporary uploads
+ get '-/system/temp/:secret/:filename',
+ to: 'uploads#show',
+ constraints: { filename: /[^\/]+/ }
+
# Appearance
- get ":model/:mounted_as/:id/:filename",
+ get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /appearance/, mounted_as: /logo|header_logo/, filename: /.+/ }
@@ -13,6 +23,12 @@ scope path: :uploads do
get ":namespace_id/:project_id/:secret/:filename",
to: "projects/uploads#show",
constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ }
+
+ # create uploads for models, snippets (notes) available for now
+ post ':model',
+ to: 'uploads#create',
+ constraints: { model: /personal_snippet/, id: /\d+/ },
+ as: 'upload'
end
# Redirect old note attachments path to new uploads path.
diff --git a/config/routes/user.rb b/config/routes/user.rb
index b064a15e802..e682dcd6663 100644
--- a/config/routes/user.rb
+++ b/config/routes/user.rb
@@ -11,19 +11,7 @@ devise_scope :user do
get '/users/almost_there' => 'confirmations#almost_there'
end
-constraints(UserUrlConstrainer.new) do
- # Get all keys of user
- get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::Regex.namespace_route_regex }
-
- scope(path: ':username',
- as: :user,
- constraints: { username: Gitlab::Regex.namespace_route_regex },
- controller: :users) do
- get '/', action: :show
- end
-end
-
-scope(constraints: { username: Gitlab::Regex.namespace_route_regex }) do
+scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do
scope(path: 'users/:username',
as: :user,
controller: :users) do
@@ -34,7 +22,7 @@ scope(constraints: { username: Gitlab::Regex.namespace_route_regex }) do
get :contributed, as: :contributed_projects
get :snippets
get :exists
- get '/', to: redirect('/%{username}')
+ get '/', to: redirect('/%{username}'), as: nil
end
# Compatibility with old routing
@@ -46,3 +34,15 @@ scope(constraints: { username: Gitlab::Regex.namespace_route_regex }) do
get '/u/:username/snippets', to: redirect('/users/%{username}/snippets')
get '/u/:username/contributed', to: redirect('/users/%{username}/contributed')
end
+
+constraints(UserUrlConstrainer.new) do
+ # Get all keys of user
+ get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }
+
+ scope(path: ':username',
+ as: :user,
+ constraints: { username: Gitlab::PathRegex.root_namespace_route_regex },
+ controller: :users) do
+ get '/', action: :show
+ end
+end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index c3bd73533d0..83abc83c9f0 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -23,12 +23,16 @@
- [update_merge_requests, 3]
- [process_commit, 3]
- [new_note, 2]
+ - [new_issue, 2]
+ - [new_merge_request, 2]
- [build, 2]
- [pipeline, 2]
- [gitlab_shell, 2]
- [email_receiver, 2]
- [emails_on_push, 2]
- [mailers, 2]
+ - [invalid_gpg_signature_update, 2]
+ - [create_gpg_signature, 2]
- [upload_checksum, 1]
- [use_key, 1]
- [repository_fork, 1]
@@ -40,12 +44,12 @@
- [expire_build_instance_artifacts, 1]
- [group_destroy, 1]
- [irker, 1]
+ - [namespaceless_project_destroy, 1]
- [project_cache, 1]
- [project_destroy, 1]
- [project_export, 1]
- - [project_web_hook, 1]
+ - [web_hook, 1]
- [repository_check, 1]
- - [system_hook, 1]
- [git_garbage_collect, 1]
- [reactive_caching, 1]
- [cronjob, 1]
@@ -53,3 +57,5 @@
- [pages, 1]
- [system_hook_push, 1]
- [update_user_activity, 1]
+ - [propagate_service_template, 1]
+ - [background_migration, 1]
diff --git a/config/unicorn.rb.example b/config/unicorn.rb.example
index 40a16a32359..cc10da2bd88 100644
--- a/config/unicorn.rb.example
+++ b/config/unicorn.rb.example
@@ -121,6 +121,10 @@ after_fork do |server, worker|
defined?(ActiveRecord::Base) and
ActiveRecord::Base.establish_connection
+ # reset prometheus client, this will cause any opened metrics files to be closed
+ defined?(::Prometheus::Client.reinitialize_on_pid_change) &&
+ Prometheus::Client.reinitialize_on_pid_change
+
# if preload_app is true, then you may also want to check and
# restart any other shared sockets/descriptors such as Memcached,
# and Redis. TokyoCabinet file handles are safe to reuse
diff --git a/config/webpack.config.js b/config/webpack.config.js
index cb0a57a3a41..6a347c2e660 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -3,22 +3,30 @@
var fs = require('fs');
var path = require('path');
var webpack = require('webpack');
-var StatsPlugin = require('stats-webpack-plugin');
+var StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin;
+var CopyWebpackPlugin = require('copy-webpack-plugin');
var CompressionPlugin = require('compression-webpack-plugin');
+var NameAllModulesPlugin = require('name-all-modules-plugin');
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
var ROOT_PATH = path.resolve(__dirname, '..');
var IS_PRODUCTION = process.env.NODE_ENV === 'production';
-var IS_DEV_SERVER = process.argv[1].indexOf('webpack-dev-server') !== -1;
+var IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1;
var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
var WEBPACK_REPORT = process.env.WEBPACK_REPORT;
+var NO_COMPRESSION = process.env.NO_COMPRESSION;
var config = {
+ // because sqljs requires fs.
+ node: {
+ fs: "empty"
+ },
context: path.join(ROOT_PATH, 'app/assets/javascripts'),
entry: {
+ balsamiq_viewer: './blob/balsamiq_viewer.js',
blob: './blob_edit/blob_bundle.js',
boards: './boards/boards_bundle.js',
common: './commons/index.js',
@@ -26,42 +34,64 @@ var config = {
common_d3: ['d3'],
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
commit_pipelines: './commit/pipelines/pipelines_bundle.js',
+ deploy_keys: './deploy_keys/index.js',
diff_notes: './diff_notes/diff_notes_bundle.js',
environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
+ graphs_charts: './graphs/graphs_charts.js',
+ graphs_show: './graphs/graphs_show.js',
group: './group.js',
+ groups: './groups/index.js',
groups_list: './groups_list.js',
- issuable: './issuable/issuable_bundle.js',
+ how_to_merge: './how_to_merge.js',
issue_show: './issue_show/index.js',
+ integrations: './integrations',
+ job_details: './jobs/job_details_bundle.js',
+ locale: './locale/index.js',
main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
- merge_request_widget: './merge_request_widget/ci_bundle.js',
monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js',
pdf_viewer: './blob/pdf_viewer.js',
- pipelines: './pipelines/index.js',
+ pipelines: './pipelines/pipelines_bundle.js',
+ pipelines_charts: './pipelines/pipelines_charts.js',
+ pipelines_details: './pipelines/pipeline_details_bundle.js',
+ pipelines_times: './pipelines/pipelines_times.js',
profile: './profile/profile_bundle.js',
- protected_branches: './protected_branches/protected_branches_bundle.js',
+ project_import_gl: './projects/project_import_gitlab_project.js',
+ project_new: './projects/project_new.js',
+ prometheus_metrics: './prometheus_metrics',
+ protected_branches: './protected_branches',
protected_tags: './protected_tags',
+ repo: './repo/index.js',
+ sidebar: './sidebar/sidebar_bundle.js',
+ schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js',
+ schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js',
snippet: './snippet/snippet_bundle.js',
sketch_viewer: './blob/sketch_viewer.js',
stl_viewer: './blob/stl_viewer.js',
terminal: './terminal/terminal_bundle.js',
u2f: ['vendor/u2f'],
- users: './users/users_bundle.js',
+ ui_development_kit: './ui_development_kit.js',
+ users: './users/index.js',
+ raven: './raven/index.js',
+ vue_merge_request_widget: './vue_merge_request_widget/index.js',
+ test: './test.js',
+ two_factor_auth: './two_factor_auth.js',
+ performance_bar: './performance_bar.js',
+ webpack_runtime: './webpack.js',
},
output: {
path: path.join(ROOT_PATH, 'public/assets/webpack'),
publicPath: '/assets/webpack/',
- filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js'
+ filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js',
+ chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js',
},
- devtool: 'cheap-module-source-map',
-
module: {
rules: [
{
@@ -78,22 +108,49 @@ var config = {
loader: 'raw-loader',
},
{
- test: /\.(worker\.js|pdf)$/,
+ test: /\.(gif|png)$/,
+ loader: 'url-loader',
+ options: { limit: 2048 },
+ },
+ {
+ test: /\.(worker(\.min)?\.js|pdf|bmpr)$/,
exclude: /node_modules/,
loader: 'file-loader',
+ options: {
+ name: '[name].[hash].[ext]',
+ }
+ },
+ {
+ test: /locale\/\w+\/(.*)\.js$/,
+ loader: 'exports-loader?locales',
},
- ]
+ {
+ test: /monaco-editor\/\w+\/vs\/loader\.js$/,
+ use: [
+ { loader: 'exports-loader', options: 'l.global' },
+ { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' },
+ ],
+ }
+ ],
+
+ noParse: [/monaco-editor\/\w+\/vs\//],
},
plugins: [
// manifest filename must match config.webpack.manifest_filename
// webpack-rails only needs assetsByChunkName to function properly
- new StatsPlugin('manifest.json', {
- chunkModules: false,
- source: false,
- chunks: false,
- modules: false,
- assets: true
+ new StatsWriterPlugin({
+ filename: 'manifest.json',
+ transform: function(data, opts) {
+ var stats = opts.compiler.getStats().toJson({
+ chunkModules: false,
+ source: false,
+ chunks: false,
+ modules: false,
+ assets: true
+ });
+ return JSON.stringify(stats, null, 2);
+ }
}),
// prevent pikaday from including moment.js
@@ -105,10 +162,20 @@ var config = {
jQuery: 'jquery',
}),
- // use deterministic module ids in all environments
- IS_PRODUCTION ?
- new webpack.HashedModuleIdsPlugin() :
- new webpack.NamedModulesPlugin(),
+ // assign deterministic module ids
+ new webpack.NamedModulesPlugin(),
+ new NameAllModulesPlugin(),
+
+ // assign deterministic chunk ids
+ new webpack.NamedChunksPlugin((chunk) => {
+ if (chunk.name) {
+ return chunk.name;
+ }
+ return chunk.modules.map((m) => {
+ var chunkPath = m.request.split('!').pop();
+ return path.relative(m.context, chunkPath);
+ }).join('_');
+ }),
// create cacheable common library bundle for all vue chunks
new webpack.optimize.CommonsChunkPlugin({
@@ -117,15 +184,25 @@ var config = {
'boards',
'commit_pipelines',
'cycle_analytics',
+ 'deploy_keys',
'diff_notes',
'environments',
'environments_folder',
- 'issuable',
+ 'filtered_search',
+ 'groups',
'issue_show',
+ 'job_details',
'merge_conflicts',
+ 'monitoring',
'notebook_viewer',
'pdf_viewer',
'pipelines',
+ 'pipelines_details',
+ 'repo',
+ 'schedule_form',
+ 'schedules_index',
+ 'sidebar',
+ 'vue_merge_request_widget',
],
minChunks: function(module, count) {
return module.resource && (/vue_shared/).test(module.resource);
@@ -137,15 +214,37 @@ var config = {
name: 'common_d3',
chunks: [
'graphs',
- 'users',
'monitoring',
],
}),
// create cacheable common library bundles
new webpack.optimize.CommonsChunkPlugin({
- names: ['main', 'common', 'runtime'],
+ names: ['main', 'locale', 'common', 'webpack_runtime'],
}),
+
+ // enable scope hoisting
+ new webpack.optimize.ModuleConcatenationPlugin(),
+
+ // copy pre-compiled vendor libraries verbatim
+ new CopyWebpackPlugin([
+ {
+ from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`),
+ to: 'monaco-editor/vs',
+ transform: function(content, path) {
+ if (/\.js$/.test(path) && !/worker/i.test(path)) {
+ return (
+ '(function(){\n' +
+ 'var define = this.define, require = this.require;\n' +
+ 'window.define = define; window.require = require;\n' +
+ content +
+ '\n}.call(window.__monaco_context__ || (window.__monaco_context__ = {})));'
+ );
+ }
+ return content;
+ }
+ }
+ ]),
],
resolve: {
@@ -155,6 +254,7 @@ var config = {
'emojis': path.join(ROOT_PATH, 'fixtures/emojis'),
'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'),
'icons': path.join(ROOT_PATH, 'app/views/shared/icons'),
+ 'images': path.join(ROOT_PATH, 'app/assets/images'),
'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'),
'vue$': 'vue/dist/vue.esm.js',
}
@@ -174,11 +274,18 @@ if (IS_PRODUCTION) {
}),
new webpack.DefinePlugin({
'process.env': { NODE_ENV: JSON.stringify('production') }
- }),
- new CompressionPlugin({
- asset: '[path].gz[query]',
})
);
+
+ // zopfli requires a lot of compute time and is disabled in CI
+ if (!NO_COMPRESSION) {
+ // gracefully fall back to gzip if `node-zopfli` is unavailable (e.g. in CentOS 6)
+ try {
+ config.plugins.push(new CompressionPlugin({ algorithm: 'zopfli' }));
+ } catch(err) {
+ config.plugins.push(new CompressionPlugin({ algorithm: 'gzip' }));
+ }
+ }
}
if (IS_DEV_SERVER) {
@@ -186,15 +293,19 @@ if (IS_DEV_SERVER) {
config.devServer = {
host: DEV_SERVER_HOST,
port: DEV_SERVER_PORT,
+ disableHostCheck: true,
headers: { 'Access-Control-Allow-Origin': '*' },
stats: 'errors-only',
+ hot: DEV_SERVER_LIVERELOAD,
inline: DEV_SERVER_LIVERELOAD
};
- config.output.publicPath = '//' + DEV_SERVER_HOST + ':' + DEV_SERVER_PORT + config.output.publicPath;
config.plugins.push(
// watch node_modules for changes if we encounter a missing module compile error
new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules'))
);
+ if (DEV_SERVER_LIVERELOAD) {
+ config.plugins.push(new webpack.HotModuleReplacementPlugin());
+ }
}
if (WEBPACK_REPORT) {
diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb
index c2b8f7ba819..6553c5d457a 100644
--- a/db/fixtures/development/04_project.rb
+++ b/db/fixtures/development/04_project.rb
@@ -71,7 +71,9 @@ Sidekiq::Testing.inline! do
# hook won't run until after the fixture is loaded. That is too late
# since the Sidekiq::Testing block has already exited. Force clearing
# the `after_commit` queue to ensure the job is run now.
- project.send(:_run_after_commit_queue)
+ Sidekiq::Worker.skipping_transaction_check do
+ project.send(:_run_after_commit_queue)
+ end
if project.valid? && project.valid_repo?
print '.'
diff --git a/db/fixtures/development/09_issues.rb b/db/fixtures/development/09_issues.rb
index d93d133d157..0b32a461d56 100644
--- a/db/fixtures/development/09_issues.rb
+++ b/db/fixtures/development/09_issues.rb
@@ -8,7 +8,7 @@ Gitlab::Seeder.quiet do
description: FFaker::Lorem.sentence,
state: ['opened', 'closed'].sample,
milestone: project.milestones.sample,
- assignee: project.team.users.sample
+ assignees: [project.team.users.sample]
}
Issues::CreateService.new(project, project.team.users.sample, issue_params).execute
diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb
index c304e0706dc..30244ee4431 100644
--- a/db/fixtures/development/10_merge_requests.rb
+++ b/db/fixtures/development/10_merge_requests.rb
@@ -28,6 +28,8 @@ Gitlab::Seeder.quiet do
project = Project.find_by_full_path('gitlab-org/gitlab-test')
+ next if project.empty_repo? # We don't have repository on CI
+
params = {
source_branch: 'feature',
target_branch: 'master',
diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb
index 51e22137d6f..c405ecfdaf3 100644
--- a/db/fixtures/development/11_keys.rb
+++ b/db/fixtures/development/11_keys.rb
@@ -1,17 +1,26 @@
require './spec/support/sidekiq'
+
# Creating keys runs a gitlab-shell worker. Since we may not have the right
# gitlab-shell path set (yet) we need to disable this for these fixtures.
Sidekiq::Testing.disable! do
Gitlab::Seeder.quiet do
+ # We want to run `add_to_shell` immediately instead of after the commit, so
+ # that it falls under `Sidekiq::Testing.disable!`.
+ Key.skip_callback(:commit, :after, :add_to_shell)
+
User.first(10).each do |user|
key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0="
- user.keys.create(
+ key = user.keys.create(
title: "Sample key #{user.id}",
key: key
)
+ Sidekiq::Worker.skipping_transaction_check do
+ key.add_to_shell
+ end
+
print '.'
end
end
diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb
index 3c42f7db6d5..5de5339b70e 100644
--- a/db/fixtures/development/14_pipelines.rb
+++ b/db/fixtures/development/14_pipelines.rb
@@ -98,7 +98,7 @@ class Gitlab::Seeder::Pipelines
def create_pipeline!(project, ref, commit)
- project.pipelines.create(sha: commit.id, ref: ref)
+ project.pipelines.create(sha: commit.id, ref: ref, source: :push)
end
def build_create!(pipeline, opts = {})
@@ -112,6 +112,10 @@ class Gitlab::Seeder::Pipelines
setup_artifacts(build)
setup_build_log(build)
+
+ build.project.environments.
+ find_or_create_by(name: build.expanded_environment_name)
+
build.save
end
end
diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb
index 0d7eb1a7c93..7c1d758dada 100644
--- a/db/fixtures/development/17_cycle_analytics.rb
+++ b/db/fixtures/development/17_cycle_analytics.rb
@@ -190,7 +190,7 @@ class Gitlab::Seeder::CycleAnalytics
service = Ci::CreatePipelineService.new(merge_request.project,
@user,
ref: "refs/heads/#{merge_request.source_branch}")
- pipeline = service.execute(ignore_skip_ci: true, save_on_errors: false)
+ pipeline = service.execute(:push, ignore_skip_ci: true, save_on_errors: false)
pipeline.run!
Timecop.travel rand(1..6).hours.from_now
@@ -212,12 +212,9 @@ class Gitlab::Seeder::CycleAnalytics
merge_requests.each do |merge_request|
Timecop.travel 12.hours.from_now
- CreateDeploymentService.new(merge_request.project, @user, {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: @project.repository.commit('master').sha
- }).execute
+ job = merge_request.head_pipeline.builds.where.not(environment: nil).last
+
+ CreateDeploymentService.new(job).execute
end
end
end
diff --git a/db/fixtures/development/19_environments.rb b/db/fixtures/development/19_environments.rb
index 93214b9d3e7..c1bbc9af6d6 100644
--- a/db/fixtures/development/19_environments.rb
+++ b/db/fixtures/development/19_environments.rb
@@ -33,7 +33,7 @@ class Gitlab::Seeder::Environments
create_deployment!(
merge_request.source_project,
- "review/#{merge_request.source_branch}",
+ "review/#{merge_request.source_branch.gsub(/[^a-zA-Z0-9]/, '')}",
merge_request.source_branch,
merge_request.diff_head_sha
)
diff --git a/db/fixtures/development/21_conversational_development_index_metrics.rb b/db/fixtures/development/21_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..4cd0a82ed1a
--- /dev/null
+++ b/db/fixtures/development/21_conversational_development_index_metrics.rb
@@ -0,0 +1,40 @@
+Gitlab::Seeder.quiet do
+ conversational_development_index_metric = ConversationalDevelopmentIndex::Metric.new(
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1
+ )
+
+ if conversational_development_index_metric.save
+ print '.'
+ else
+ puts conversational_development_index_metric.errors.full_messages
+ print 'F'
+ end
+end
diff --git a/db/fixtures/production/010_settings.rb b/db/fixtures/production/010_settings.rb
index 5522f31629a..7626cdb0b9c 100644
--- a/db/fixtures/production/010_settings.rb
+++ b/db/fixtures/production/010_settings.rb
@@ -1,16 +1,26 @@
-if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present?
- settings = ApplicationSetting.current || ApplicationSetting.create_from_defaults
- settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'])
-
+def save(settings, topic)
if settings.save
- puts "Saved Runner Registration Token".color(:green)
+ puts "Saved #{topic}".color(:green)
else
- puts "Could not save Runner Registration Token".color(:red)
+ puts "Could not save #{topic}".color(:red)
puts
settings.errors.full_messages.map do |message|
puts "--> #{message}".color(:red)
end
puts
- exit 1
+ exit(1)
end
end
+
+if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present?
+ settings = Gitlab::CurrentSettings.current_application_settings
+ settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'])
+ save(settings, 'Runner Registration Token')
+end
+
+if ENV['GITLAB_PROMETHEUS_METRICS_ENABLED'].present?
+ settings = Gitlab::CurrentSettings.current_application_settings
+ value = Gitlab::Utils.to_boolean(ENV['GITLAB_PROMETHEUS_METRICS_ENABLED']) || false
+ settings.prometheus_metrics_enabled = value
+ save(settings, 'Prometheus metrics enabled flag')
+end
diff --git a/db/migrate/20160314114439_add_requested_at_to_members.rb b/db/migrate/20160314114439_add_requested_at_to_members.rb
index 273819d4cd8..76c8b8a1a24 100644
--- a/db/migrate/20160314114439_add_requested_at_to_members.rb
+++ b/db/migrate/20160314114439_add_requested_at_to_members.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class AddRequestedAtToMembers < ActiveRecord::Migration
def change
add_column :members, :requested_at, :datetime
diff --git a/db/migrate/20160415062917_create_personal_access_tokens.rb b/db/migrate/20160415062917_create_personal_access_tokens.rb
index ce0b33f32bd..c7b49870bf7 100644
--- a/db/migrate/20160415062917_create_personal_access_tokens.rb
+++ b/db/migrate/20160415062917_create_personal_access_tokens.rb
@@ -1,3 +1,5 @@
+# rubocop:disable Migration/Datetime
+# rubocop:disable Migration/Timestamps
class CreatePersonalAccessTokens < ActiveRecord::Migration
def change
create_table :personal_access_tokens do |t|
diff --git a/db/migrate/20160419122101_add_only_allow_merge_if_build_succeeds_to_projects.rb b/db/migrate/20160419122101_add_only_allow_merge_if_build_succeeds_to_projects.rb
index 69d64ccd006..22bac46e25c 100644
--- a/db/migrate/20160419122101_add_only_allow_merge_if_build_succeeds_to_projects.rb
+++ b/db/migrate/20160419122101_add_only_allow_merge_if_build_succeeds_to_projects.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddOnlyAllowMergeIfBuildSucceedsToProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20160608195742_add_repository_storage_to_projects.rb b/db/migrate/20160608195742_add_repository_storage_to_projects.rb
index c700d2b569d..0f3664c13ef 100644
--- a/db/migrate/20160608195742_add_repository_storage_to_projects.rb
+++ b/db/migrate/20160608195742_add_repository_storage_to_projects.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddRepositoryStorageToProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb
index cb144ea8a6d..0e7e6e747a3 100644
--- a/db/migrate/20160610204157_add_deployments.rb
+++ b/db/migrate/20160610204157_add_deployments.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Datetime
class AddDeployments < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160610204158_add_environments.rb b/db/migrate/20160610204158_add_environments.rb
index e1c71d173c4..699cee2b246 100644
--- a/db/migrate/20160610204158_add_environments.rb
+++ b/db/migrate/20160610204158_add_environments.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Datetime
class AddEnvironments < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
index bd0463886bc..5336b036bca 100644
--- a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
+++ b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb
@@ -1,6 +1,9 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class SetMissingStageOnCiBuilds < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:ci_builds, :stage, :test) do |table, query|
query.where(table[:stage].eq(nil))
diff --git a/db/migrate/20160705054938_add_protected_branches_push_access.rb b/db/migrate/20160705054938_add_protected_branches_push_access.rb
index f27295524e1..97aaaf9d2c8 100644
--- a/db/migrate/20160705054938_add_protected_branches_push_access.rb
+++ b/db/migrate/20160705054938_add_protected_branches_push_access.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Timestamps
class AddProtectedBranchesPushAccess < ActiveRecord::Migration
DOWNTIME = false
diff --git a/db/migrate/20160705054952_add_protected_branches_merge_access.rb b/db/migrate/20160705054952_add_protected_branches_merge_access.rb
index 32adfa266cd..51a52a5ac17 100644
--- a/db/migrate/20160705054952_add_protected_branches_merge_access.rb
+++ b/db/migrate/20160705054952_add_protected_branches_merge_access.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Timestamps
class AddProtectedBranchesMergeAccess < ActiveRecord::Migration
DOWNTIME = false
diff --git a/db/migrate/20160713205315_add_domain_blacklist_to_application_settings.rb b/db/migrate/20160713205315_add_domain_blacklist_to_application_settings.rb
index ecdd1bd7e5e..f64dfa7675f 100644
--- a/db/migrate/20160713205315_add_domain_blacklist_to_application_settings.rb
+++ b/db/migrate/20160713205315_add_domain_blacklist_to_application_settings.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/SaferBooleanColumn
class AddDomainBlacklistToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160715154212_add_request_access_enabled_to_projects.rb b/db/migrate/20160715154212_add_request_access_enabled_to_projects.rb
index bf0131c6d76..5dc26f8982a 100644
--- a/db/migrate/20160715154212_add_request_access_enabled_to_projects.rb
+++ b/db/migrate/20160715154212_add_request_access_enabled_to_projects.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddRequestAccessEnabledToProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20160715204316_add_request_access_enabled_to_groups.rb b/db/migrate/20160715204316_add_request_access_enabled_to_groups.rb
index e7b14cd3ee2..4a317646788 100644
--- a/db/migrate/20160715204316_add_request_access_enabled_to_groups.rb
+++ b/db/migrate/20160715204316_add_request_access_enabled_to_groups.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddRequestAccessEnabledToGroups < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
index 1eb99feb40c..abe8e701e23 100644
--- a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
+++ b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb
@@ -1,9 +1,12 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class DropAndReaddHasExternalWikiInProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:projects, :has_external_wiki, nil) do |table, query|
query.where(table[:has_external_wiki].not_eq(nil))
diff --git a/db/migrate/20160724205507_add_resolved_to_notes.rb b/db/migrate/20160724205507_add_resolved_to_notes.rb
index b8ebcdbd156..3aca272a3f7 100644
--- a/db/migrate/20160724205507_add_resolved_to_notes.rb
+++ b/db/migrate/20160724205507_add_resolved_to_notes.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class AddResolvedToNotes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb
index ed4ccfedc0a..3eb36f8464f 100644
--- a/db/migrate/20160727163552_create_user_agent_details.rb
+++ b/db/migrate/20160727163552_create_user_agent_details.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateUserAgentDetails < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb
index 56afbd4e030..9ec8df1b8e8 100644
--- a/db/migrate/20160727191041_create_boards.rb
+++ b/db/migrate/20160727191041_create_boards.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateBoards < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb
index 61d501215f2..3fd95dc8cfc 100644
--- a/db/migrate/20160727193336_create_lists.rb
+++ b/db/migrate/20160727193336_create_lists.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateLists < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160804142904_add_ci_config_file_to_project.rb b/db/migrate/20160804142904_add_ci_config_file_to_project.rb
new file mode 100644
index 00000000000..341ae555c1b
--- /dev/null
+++ b/db/migrate/20160804142904_add_ci_config_file_to_project.rb
@@ -0,0 +1,11 @@
+class AddCiConfigFileToProject < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :projects, :ci_config_path, :string
+ end
+
+ def down
+ remove_column :projects, :ci_config_path
+ end
+end
diff --git a/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb
index 30d98a0124e..404c253e18b 100644
--- a/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb
+++ b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
# rubocop:disable RemoveIndex
class AddDeletedAtToNamespaces < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160810142633_remove_redundant_indexes.rb b/db/migrate/20160810142633_remove_redundant_indexes.rb
index d7ab022d7bc..ea7d1f9a436 100644
--- a/db/migrate/20160810142633_remove_redundant_indexes.rb
+++ b/db/migrate/20160810142633_remove_redundant_indexes.rb
@@ -69,7 +69,7 @@ class RemoveRedundantIndexes < ActiveRecord::Migration
[:namespaces, 'index_namespaces_on_created_at_and_id'],
[:notes, 'index_notes_on_created_at_and_id'],
[:projects, 'index_projects_on_created_at_and_id'],
- [:users, 'index_users_on_created_at_and_id'],
+ [:users, 'index_users_on_created_at_and_id']
]
transaction do
diff --git a/db/migrate/20160817133006_add_koding_to_application_settings.rb b/db/migrate/20160817133006_add_koding_to_application_settings.rb
index 915d3d78e40..46120652d8e 100644
--- a/db/migrate/20160817133006_add_koding_to_application_settings.rb
+++ b/db/migrate/20160817133006_add_koding_to_application_settings.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/SaferBooleanColumn
class AddKodingToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160824124900_add_table_issue_metrics.rb b/db/migrate/20160824124900_add_table_issue_metrics.rb
index e9bb79b3c62..30d35ef1db2 100644
--- a/db/migrate/20160824124900_add_table_issue_metrics.rb
+++ b/db/migrate/20160824124900_add_table_issue_metrics.rb
@@ -1,6 +1,8 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Datetime
+# rubocop:disable Migration/Timestamps
class AddTableIssueMetrics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160825052008_add_table_merge_request_metrics.rb b/db/migrate/20160825052008_add_table_merge_request_metrics.rb
index e01cc5038b9..56b39634dfd 100644
--- a/db/migrate/20160825052008_add_table_merge_request_metrics.rb
+++ b/db/migrate/20160825052008_add_table_merge_request_metrics.rb
@@ -1,6 +1,8 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Datetime
+# rubocop:disable Migration/Timestamps
class AddTableMergeRequestMetrics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160829114652_add_markdown_cache_columns.rb b/db/migrate/20160829114652_add_markdown_cache_columns.rb
index 9cb44dfa9f9..6ad7237f4cd 100644
--- a/db/migrate/20160829114652_add_markdown_cache_columns.rb
+++ b/db/migrate/20160829114652_add_markdown_cache_columns.rb
@@ -25,7 +25,7 @@ class AddMarkdownCacheColumns < ActiveRecord::Migration
notes: [:note],
projects: [:description],
releases: [:description],
- snippets: [:title, :content],
+ snippets: [:title, :content]
}.freeze
def change
diff --git a/db/migrate/20160831214002_create_project_features.rb b/db/migrate/20160831214002_create_project_features.rb
index 343953826f0..7ac6c8ec654 100644
--- a/db/migrate/20160831214002_create_project_features.rb
+++ b/db/migrate/20160831214002_create_project_features.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateProjectFeatures < ActiveRecord::Migration
DOWNTIME = false
diff --git a/db/migrate/20160831223750_remove_features_enabled_from_projects.rb b/db/migrate/20160831223750_remove_features_enabled_from_projects.rb
index a2c207b49ea..7414a28ac97 100644
--- a/db/migrate/20160831223750_remove_features_enabled_from_projects.rb
+++ b/db/migrate/20160831223750_remove_features_enabled_from_projects.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class RemoveFeaturesEnabledFromProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
index f1a1f001cb3..f8486e3e1a6 100644
--- a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
+++ b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb
@@ -1,8 +1,11 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class SetConfidentialIssuesEventsOnWebhooks < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:web_hooks, :confidential_issues_events, true) do |table, query|
query.where(table[:issues_events].eq(true))
diff --git a/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb b/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb
index 18ea9d43a43..0100e30a733 100644
--- a/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb
+++ b/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class RemoveProjectsPushesSinceGc < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160915042921_create_merge_requests_closing_issues.rb b/db/migrate/20160915042921_create_merge_requests_closing_issues.rb
index 94874a853da..10c5604bb5c 100644
--- a/db/migrate/20160915042921_create_merge_requests_closing_issues.rb
+++ b/db/migrate/20160915042921_create_merge_requests_closing_issues.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Timestamps
class CreateMergeRequestsClosingIssues < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20160919144305_add_type_to_labels.rb b/db/migrate/20160919144305_add_type_to_labels.rb
index 66172bda6ff..d08b339cd27 100644
--- a/db/migrate/20160919144305_add_type_to_labels.rb
+++ b/db/migrate/20160919144305_add_type_to_labels.rb
@@ -1,9 +1,12 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class AddTypeToLabels < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = true
DOWNTIME_REASON = 'Labels will not work as expected until this migration is complete.'
+ disable_ddl_transaction!
+
def change
add_column :labels, :type, :string
diff --git a/db/migrate/20161014173530_create_label_priorities.rb b/db/migrate/20161014173530_create_label_priorities.rb
index 2c22841c28a..28937c81e02 100644
--- a/db/migrate/20161014173530_create_label_priorities.rb
+++ b/db/migrate/20161014173530_create_label_priorities.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateLabelPriorities < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161017125927_add_unique_index_to_labels.rb b/db/migrate/20161017125927_add_unique_index_to_labels.rb
index b8f6a803a0a..fcdd79d3b02 100644
--- a/db/migrate/20161017125927_add_unique_index_to_labels.rb
+++ b/db/migrate/20161017125927_add_unique_index_to_labels.rb
@@ -10,7 +10,7 @@ class AddUniqueIndexToLabels < ActiveRecord::Migration
def up
select_all('SELECT title, project_id, COUNT(id) as cnt FROM labels GROUP BY project_id, title HAVING COUNT(id) > 1').each do |label|
label_title = quote_string(label['title'])
- duplicated_ids = select_all("SELECT id FROM labels WHERE project_id = #{label['project_id']} AND title = '#{label_title}' ORDER BY id ASC").map{ |label| label['id'] }
+ duplicated_ids = select_all("SELECT id FROM labels WHERE project_id = #{label['project_id']} AND title = '#{label_title}' ORDER BY id ASC").map { |label| label['id'] }
label_id = duplicated_ids.first
duplicated_ids.delete(label_id)
diff --git a/db/migrate/20161018124658_make_project_owners_masters.rb b/db/migrate/20161018124658_make_project_owners_masters.rb
index a576bb7b622..cb93b449067 100644
--- a/db/migrate/20161018124658_make_project_owners_masters.rb
+++ b/db/migrate/20161018124658_make_project_owners_masters.rb
@@ -1,8 +1,11 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class MakeProjectOwnersMasters < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:members, :access_level, 40) do |table, query|
query.where(table[:access_level].eq(50).and(table[:source_type].eq('Project')))
diff --git a/db/migrate/20161020075830_default_request_access_projects.rb b/db/migrate/20161020075830_default_request_access_projects.rb
index cb790291b24..a3a53350e8d 100644
--- a/db/migrate/20161020075830_default_request_access_projects.rb
+++ b/db/migrate/20161020075830_default_request_access_projects.rb
@@ -1,7 +1,7 @@
class DefaultRequestAccessProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
-
+
def up
change_column_default :projects, :request_access_enabled, false
end
diff --git a/db/migrate/20161103191444_add_sidekiq_throttling_to_application_settings.rb b/db/migrate/20161103191444_add_sidekiq_throttling_to_application_settings.rb
index e644a174964..522437b92b4 100644
--- a/db/migrate/20161103191444_add_sidekiq_throttling_to_application_settings.rb
+++ b/db/migrate/20161103191444_add_sidekiq_throttling_to_application_settings.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/SaferBooleanColumn
class AddSidekiqThrottlingToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161113184239_create_user_chat_names_table.rb b/db/migrate/20161113184239_create_user_chat_names_table.rb
index 97b597654f7..62ccb599f2e 100644
--- a/db/migrate/20161113184239_create_user_chat_names_table.rb
+++ b/db/migrate/20161113184239_create_user_chat_names_table.rb
@@ -1,3 +1,5 @@
+# rubocop:disable Migration/Datetime
+# rubocop:disable Migration/Timestamps
class CreateUserChatNamesTable < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161124111402_add_routes_table.rb b/db/migrate/20161124111402_add_routes_table.rb
index a02e046a18e..f5241d906d1 100644
--- a/db/migrate/20161124111402_add_routes_table.rb
+++ b/db/migrate/20161124111402_add_routes_table.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Timestamps
class AddRoutesTable < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
index e5292cfba07..bcdae272209 100644
--- a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
+++ b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
@@ -6,9 +6,9 @@ class MigrateProcessCommitWorkerJobs < ActiveRecord::Migration
class Project < ActiveRecord::Base
def self.find_including_path(id)
- select("projects.*, CONCAT(namespaces.path, '/', projects.path) AS path_with_namespace").
- joins('INNER JOIN namespaces ON namespaces.id = projects.namespace_id').
- find_by(id: id)
+ select("projects.*, CONCAT(namespaces.path, '/', projects.path) AS path_with_namespace")
+ .joins('INNER JOIN namespaces ON namespaces.id = projects.namespace_id')
+ .find_by(id: id)
end
def repository_storage_path
@@ -16,6 +16,7 @@ class MigrateProcessCommitWorkerJobs < ActiveRecord::Migration
end
def repository_path
+ # TODO: review if the change from Legacy storage needs to reflect here as well.
File.join(repository_storage_path, read_attribute(:path_with_namespace) + '.git')
end
diff --git a/db/migrate/20161128161412_add_html_emails_enabled_to_application_settings.rb b/db/migrate/20161128161412_add_html_emails_enabled_to_application_settings.rb
index 1c59241d0fe..38f5781745b 100644
--- a/db/migrate/20161128161412_add_html_emails_enabled_to_application_settings.rb
+++ b/db/migrate/20161128161412_add_html_emails_enabled_to_application_settings.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/SaferBooleanColumn
class AddHtmlEmailsEnabledToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb
index 3677f978cc2..7f56ecf4c9e 100644
--- a/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb
+++ b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/SaferBooleanColumn
class AddPlantUmlEnabledToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161207231620_fixup_environment_name_uniqueness.rb b/db/migrate/20161207231620_fixup_environment_name_uniqueness.rb
index a20a903a752..f73e4f6c99b 100644
--- a/db/migrate/20161207231620_fixup_environment_name_uniqueness.rb
+++ b/db/migrate/20161207231620_fixup_environment_name_uniqueness.rb
@@ -8,11 +8,11 @@ class FixupEnvironmentNameUniqueness < ActiveRecord::Migration
environments = Arel::Table.new(:environments)
# Get all [project_id, name] pairs that occur more than once
- finder_sql = environments.
- group(environments[:project_id], environments[:name]).
- having(Arel.sql("COUNT(1)").gt(1)).
- project(environments[:project_id], environments[:name]).
- to_sql
+ finder_sql = environments
+ .group(environments[:project_id], environments[:name])
+ .having(Arel.sql("COUNT(1)").gt(1))
+ .project(environments[:project_id], environments[:name])
+ .to_sql
conflicting = connection.exec_query(finder_sql)
@@ -28,12 +28,12 @@ class FixupEnvironmentNameUniqueness < ActiveRecord::Migration
# Rename conflicting environments by appending "-#{id}" to all but the first
def fix_duplicates(project_id, name)
environments = Arel::Table.new(:environments)
- finder_sql = environments.
- where(environments[:project_id].eq(project_id)).
- where(environments[:name].eq(name)).
- order(environments[:id].asc).
- project(environments[:id], environments[:name]).
- to_sql
+ finder_sql = environments
+ .where(environments[:project_id].eq(project_id))
+ .where(environments[:name].eq(name))
+ .order(environments[:id].asc)
+ .project(environments[:id], environments[:name])
+ .to_sql
# Now we have the data for all the conflicting rows
conflicts = connection.exec_query(finder_sql).rows
@@ -41,11 +41,11 @@ class FixupEnvironmentNameUniqueness < ActiveRecord::Migration
conflicts.each do |id, name|
update_sql =
- Arel::UpdateManager.new(ActiveRecord::Base).
- table(environments).
- set(environments[:name] => name + "-" + id.to_s).
- where(environments[:id].eq(id)).
- to_sql
+ Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(environments)
+ .set(environments[:name] => name + "-" + id.to_s)
+ .where(environments[:id].eq(id))
+ .to_sql
connection.exec_update(update_sql, self.class.name, [])
end
diff --git a/db/migrate/20161207231626_add_environment_slug.rb b/db/migrate/20161207231626_add_environment_slug.rb
index 8e98ee5b9ba..83cdd484c4c 100644
--- a/db/migrate/20161207231626_add_environment_slug.rb
+++ b/db/migrate/20161207231626_add_environment_slug.rb
@@ -19,10 +19,10 @@ class AddEnvironmentSlug < ActiveRecord::Migration
finder = environments.project(:id, :name)
connection.exec_query(finder.to_sql).rows.each do |id, name|
- updater = Arel::UpdateManager.new(ActiveRecord::Base).
- table(environments).
- set(environments[:slug] => generate_slug(name)).
- where(environments[:id].eq(id))
+ updater = Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(environments)
+ .set(environments[:slug] => generate_slug(name))
+ .where(environments[:id].eq(id))
connection.exec_update(updater.to_sql, self.class.name, [])
end
diff --git a/db/migrate/20161221152132_add_last_used_at_to_key.rb b/db/migrate/20161221152132_add_last_used_at_to_key.rb
index fb2b15817de..86dc7870247 100644
--- a/db/migrate/20161221152132_add_last_used_at_to_key.rb
+++ b/db/migrate/20161221152132_add_last_used_at_to_key.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class AddLastUsedAtToKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161223034646_create_timelogs_ce.rb b/db/migrate/20161223034646_create_timelogs_ce.rb
index 66d9cd823fb..1e894cc9161 100644
--- a/db/migrate/20161223034646_create_timelogs_ce.rb
+++ b/db/migrate/20161223034646_create_timelogs_ce.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateTimelogsCe < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
index 50ad7437227..6b15e5caccf 100644
--- a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
+++ b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb
@@ -1,8 +1,11 @@
+# rubocop:disable Migration/UpdateColumnInBatches
class RenameSlackAndMattermostNotificationServices < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:services, :type, 'SlackService') do |table, query|
query.where(table[:type].eq('SlackNotificationService'))
diff --git a/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb b/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb
index af1bac897cc..16f7cc487ce 100644
--- a/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb
+++ b/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/Datetime
class ChangeExpiresAtToDateInPersonalAccessTokens < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170120131253_create_chat_teams.rb b/db/migrate/20170120131253_create_chat_teams.rb
index 7995d383986..52208821911 100644
--- a/db/migrate/20170120131253_create_chat_teams.rb
+++ b/db/migrate/20170120131253_create_chat_teams.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateChatTeams < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb b/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb
index df5cddeb205..ae37da275fd 100644
--- a/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb
+++ b/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddTwoFactorColumnsToNamespaces < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170124193205_add_two_factor_columns_to_users.rb b/db/migrate/20170124193205_add_two_factor_columns_to_users.rb
index 1d1021fcbb3..8d4aefa4365 100644
--- a/db/migrate/20170124193205_add_two_factor_columns_to_users.rb
+++ b/db/migrate/20170124193205_add_two_factor_columns_to_users.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddTwoFactorColumnsToUsers < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170130221926_create_uploads.rb b/db/migrate/20170130221926_create_uploads.rb
index 6f06c5dd840..4d9fa0bb692 100644
--- a/db/migrate/20170130221926_create_uploads.rb
+++ b/db/migrate/20170130221926_create_uploads.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class CreateUploads < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb
new file mode 100644
index 00000000000..541228e8735
--- /dev/null
+++ b/db/migrate/20170222111732_create_gpg_keys.rb
@@ -0,0 +1,19 @@
+class CreateGpgKeys < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :gpg_keys do |t|
+ t.timestamps_with_timezone null: false
+
+ t.references :user, index: true, foreign_key: { on_delete: :cascade }
+
+ t.binary :primary_keyid
+ t.binary :fingerprint
+
+ t.text :key
+
+ t.index :primary_keyid, unique: true, length: Gitlab::Database.mysql? ? 20 : nil
+ t.index :fingerprint, unique: true, length: Gitlab::Database.mysql? ? 20 : nil
+ end
+ end
+end
diff --git a/db/migrate/20170222143317_drop_ci_projects.rb b/db/migrate/20170222143317_drop_ci_projects.rb
index 4db8658f36f..9973e53501c 100644
--- a/db/migrate/20170222143317_drop_ci_projects.rb
+++ b/db/migrate/20170222143317_drop_ci_projects.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class DropCiProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb b/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb
index f54608ecceb..7ad01a04815 100644
--- a/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb
+++ b/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb
@@ -1,6 +1,7 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddPrintingMergeRequestLinkEnabledToProject < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20170301205639_remove_unused_ci_tables_and_columns.rb b/db/migrate/20170301205639_remove_unused_ci_tables_and_columns.rb
index 69dd15b8b4e..1a77d5934a3 100644
--- a/db/migrate/20170301205639_remove_unused_ci_tables_and_columns.rb
+++ b/db/migrate/20170301205639_remove_unused_ci_tables_and_columns.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class RemoveUnusedCiTablesAndColumns < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
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
index aa64f2dddca..f335e77fb5e 100644
--- a/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb
+++ b/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class AddAutoCancelPendingPipelinesToProject < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170309173138_create_protected_tags.rb b/db/migrate/20170309173138_create_protected_tags.rb
index 796f3c90344..4684c9964c4 100644
--- a/db/migrate/20170309173138_create_protected_tags.rb
+++ b/db/migrate/20170309173138_create_protected_tags.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateProtectedTags < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170314082049_create_system_note_metadata.rb b/db/migrate/20170314082049_create_system_note_metadata.rb
index dd1e6cf8172..fee47e96053 100644
--- a/db/migrate/20170314082049_create_system_note_metadata.rb
+++ b/db/migrate/20170314082049_create_system_note_metadata.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateSystemNoteMetadata < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170315174634_revert_add_notified_of_own_activity_to_users.rb b/db/migrate/20170315174634_revert_add_notified_of_own_activity_to_users.rb
index b39c0a3be0f..6c9fe19ca34 100644
--- a/db/migrate/20170315174634_revert_add_notified_of_own_activity_to_users.rb
+++ b/db/migrate/20170315174634_revert_add_notified_of_own_activity_to_users.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/AddColumnWithDefaultToLargeTable
class RevertAddNotifiedOfOwnActivityToUsers < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
diff --git a/db/migrate/20170315194013_add_closed_at_to_issues.rb b/db/migrate/20170315194013_add_closed_at_to_issues.rb
index 1326118cc8d..34a1bd7ca8c 100644
--- a/db/migrate/20170315194013_add_closed_at_to_issues.rb
+++ b/db/migrate/20170315194013_add_closed_at_to_issues.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class AddClosedAtToIssues < ActiveRecord::Migration
DOWNTIME = false
diff --git a/db/migrate/20170316163845_move_uploads_to_system_dir.rb b/db/migrate/20170316163845_move_uploads_to_system_dir.rb
new file mode 100644
index 00000000000..cfcb909ddaf
--- /dev/null
+++ b/db/migrate/20170316163845_move_uploads_to_system_dir.rb
@@ -0,0 +1,59 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class MoveUploadsToSystemDir < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORIES_TO_MOVE = %w(user project note group appearance).freeze
+
+ def up
+ return unless file_storage?
+
+ FileUtils.mkdir_p(new_upload_dir)
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ source = File.join(old_upload_dir, dir)
+ destination = File.join(new_upload_dir, dir)
+ next unless File.directory?(source)
+ next if File.directory?(destination)
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.mv(source, destination)
+ FileUtils.ln_s(destination, source)
+ end
+ end
+
+ def down
+ return unless file_storage?
+ return unless File.directory?(new_upload_dir)
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ source = File.join(new_upload_dir, dir)
+ destination = File.join(old_upload_dir, dir)
+ next unless File.directory?(source)
+ next if File.directory?(destination) && !File.symlink?(destination)
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.rm(destination) if File.symlink?(destination)
+ FileUtils.mv(source, destination)
+ end
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "-", "system")
+ end
+end
diff --git a/db/migrate/20170317203554_index_routes_path_for_like.rb b/db/migrate/20170317203554_index_routes_path_for_like.rb
index 7ac09b2abe5..8d3609135d0 100644
--- a/db/migrate/20170317203554_index_routes_path_for_like.rb
+++ b/db/migrate/20170317203554_index_routes_path_for_like.rb
@@ -21,9 +21,8 @@ class IndexRoutesPathForLike < ActiveRecord::Migration
def down
return unless Gitlab::Database.postgresql?
+ return unless index_exists?(:routes, :path, name: INDEX_NAME)
- if index_exists?(:routes, :path, name: INDEX_NAME)
- execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};")
- end
+ remove_concurrent_index_by_name(:routes, INDEX_NAME)
end
end
diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb
new file mode 100644
index 00000000000..7b61e811317
--- /dev/null
+++ b/db/migrate/20170320173259_migrate_assignees.rb
@@ -0,0 +1,42 @@
+# rubocop:disable Migration/UpdateColumnInBatches
+class MigrateAssignees < 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 up
+ # Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com
+ update_column_in_batches(:issues, :assignee_id, nil) do |table, query|
+ query.where(table[:assignee_id].eq(0))
+ end
+
+ users = Arel::Table.new(:users)
+
+ update_column_in_batches(:issues, :assignee_id, nil) do |table, query|
+ query.where(table[:assignee_id].not_eq(nil)\
+ .and(
+ users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not
+ ))
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20170322013926_create_container_repository.rb b/db/migrate/20170322013926_create_container_repository.rb
index 91540bc88bd..242f7b8d17d 100644
--- a/db/migrate/20170322013926_create_container_repository.rb
+++ b/db/migrate/20170322013926_create_container_repository.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Timestamps
class CreateContainerRepository < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/migrate/20170327091750_add_created_at_index_to_deployments.rb b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb
new file mode 100644
index 00000000000..fd6ed499b80
--- /dev/null
+++ b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb
@@ -0,0 +1,15 @@
+class AddCreatedAtIndexToDeployments < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :deployments, :created_at
+ end
+
+ def down
+ remove_concurrent_index :deployments, :created_at
+ end
+end
diff --git a/db/migrate/20170329095907_create_ci_trigger_schedules.rb b/db/migrate/20170329095907_create_ci_trigger_schedules.rb
index cfcfa27ebb5..06a2010db23 100644
--- a/db/migrate/20170329095907_create_ci_trigger_schedules.rb
+++ b/db/migrate/20170329095907_create_ci_trigger_schedules.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Migration/Datetime
class CreateCiTriggerSchedules < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
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 9d4380ef960..84635fa39b9 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
@@ -11,13 +11,7 @@ class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration
disable_ddl_transaction!
def up
- if index_exists? :users, :current_sign_in_at
- if Gitlab::Database.postgresql?
- execute 'DROP INDEX CONCURRENTLY index_users_on_current_sign_in_at;'
- else
- remove_concurrent_index :users, :current_sign_in_at
- end
- end
+ remove_concurrent_index :users, :current_sign_in_at
end
def down
diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb
new file mode 100644
index 00000000000..92f1d6f2436
--- /dev/null
+++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb
@@ -0,0 +1,16 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPreferredLanguageToUsers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ add_column :users, :preferred_language, :string
+ end
+
+ def down
+ remove_column :users, :preferred_language
+ end
+end
diff --git a/db/migrate/20170425112128_create_pipeline_schedules_table.rb b/db/migrate/20170425112128_create_pipeline_schedules_table.rb
new file mode 100644
index 00000000000..57df47f5f42
--- /dev/null
+++ b/db/migrate/20170425112128_create_pipeline_schedules_table.rb
@@ -0,0 +1,30 @@
+# rubocop:disable Migration/Datetime
+# rubocop:disable Migration/Timestamps
+class CreatePipelineSchedulesTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ create_table :ci_pipeline_schedules do |t|
+ t.string :description
+ t.string :ref
+ t.string :cron
+ t.string :cron_timezone
+ t.datetime :next_run_at
+ t.integer :project_id
+ t.integer :owner_id
+ t.boolean :active, default: true
+ t.datetime :deleted_at
+
+ t.timestamps
+ end
+
+ add_index(:ci_pipeline_schedules, :project_id)
+ add_index(:ci_pipeline_schedules, [:next_run_at, :active])
+ end
+
+ def down
+ drop_table :ci_pipeline_schedules
+ end
+end
diff --git a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb
new file mode 100644
index 00000000000..1587eee06ae
--- /dev/null
+++ b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb
@@ -0,0 +1,23 @@
+class RemoveForeighKeyCiTriggerSchedules < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ if fk_on_trigger_schedules?
+ remove_foreign_key :ci_trigger_schedules, column: :trigger_id
+ end
+ end
+
+ def down
+ # no op, the foreign key should not have been here
+ end
+
+ private
+
+ # Not made more generic and lifted to the helpers as Rails 5 will provide
+ # such an API
+ def fk_on_trigger_schedules?
+ connection.foreign_keys(:ci_trigger_schedules).include?("ci_triggers")
+ end
+end
diff --git a/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb b/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb
new file mode 100644
index 00000000000..ddb27d4dc81
--- /dev/null
+++ b/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb
@@ -0,0 +1,9 @@
+class AddPipelineScheduleIdToPipelines < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_pipelines, :pipeline_schedule_id, :integer
+ end
+end
diff --git a/db/migrate/20170427103502_create_web_hook_logs.rb b/db/migrate/20170427103502_create_web_hook_logs.rb
new file mode 100644
index 00000000000..3643c52180c
--- /dev/null
+++ b/db/migrate/20170427103502_create_web_hook_logs.rb
@@ -0,0 +1,22 @@
+# rubocop:disable all
+class CreateWebHookLogs < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :web_hook_logs do |t|
+ t.references :web_hook, null: false, index: true, foreign_key: { on_delete: :cascade }
+
+ t.string :trigger
+ t.string :url
+ t.text :request_headers
+ t.text :request_data
+ t.text :response_headers
+ t.text :response_body
+ t.string :response_status
+ t.float :execution_duration
+ t.string :internal_error_message
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20170427215854_create_redirect_routes.rb b/db/migrate/20170427215854_create_redirect_routes.rb
new file mode 100644
index 00000000000..6db508e5db4
--- /dev/null
+++ b/db/migrate/20170427215854_create_redirect_routes.rb
@@ -0,0 +1,15 @@
+# rubocop:disable Migration/Timestamps
+class CreateRedirectRoutes < ActiveRecord::Migration
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ create_table :redirect_routes do |t|
+ t.integer :source_id, null: false
+ t.string :source_type, null: false
+ t.string :path, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20170428064307_add_column_delete_error_to_projects.rb b/db/migrate/20170428064307_add_column_delete_error_to_projects.rb
new file mode 100644
index 00000000000..09f9d9b5b7a
--- /dev/null
+++ b/db/migrate/20170428064307_add_column_delete_error_to_projects.rb
@@ -0,0 +1,7 @@
+class AddColumnDeleteErrorToProjects < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :projects, :delete_error, :text
+ end
+end
diff --git a/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb
new file mode 100644
index 00000000000..03bf626a08a
--- /dev/null
+++ b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb
@@ -0,0 +1,13 @@
+class MakeAutoCancelPendingPipelinesOnByDefault < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ change_column_default(:projects, :auto_cancel_pending_pipelines, 1)
+ end
+
+ def down
+ change_column_default(:projects, :auto_cancel_pending_pipelines, 0)
+ end
+end
diff --git a/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb
new file mode 100644
index 00000000000..008a94d8334
--- /dev/null
+++ b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb
@@ -0,0 +1,2 @@
+# rubocop:disable all
+require_relative 'markdown_cache_limits_to_mysql'
diff --git a/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb
new file mode 100644
index 00000000000..b64d7e0e3f6
--- /dev/null
+++ b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb
@@ -0,0 +1,21 @@
+class CreateIndexCiPipelinesAutoCanceledById < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # MySQL would already have the index
+ unless index_exists?(:ci_pipelines, :auto_canceled_by_id)
+ add_concurrent_index(:ci_pipelines, :auto_canceled_by_id)
+ end
+ end
+
+ def down
+ # We cannot remove index for MySQL because it's needed for foreign key
+ if Gitlab::Database.postgresql?
+ remove_concurrent_index(:ci_pipelines, :auto_canceled_by_id)
+ end
+ end
+end
diff --git a/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb
new file mode 100644
index 00000000000..0a8d2c8ff61
--- /dev/null
+++ b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb
@@ -0,0 +1,21 @@
+class CreateIndexCiBuildsAutoCanceledById < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # MySQL would already have the index
+ unless index_exists?(:ci_builds, :auto_canceled_by_id)
+ add_concurrent_index(:ci_builds, :auto_canceled_by_id)
+ end
+ end
+
+ def down
+ # We cannot remove index for MySQL because it's needed for foreign key
+ if Gitlab::Database.postgresql?
+ remove_concurrent_index(:ci_builds, :auto_canceled_by_id)
+ end
+ end
+end
diff --git a/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb
new file mode 100644
index 00000000000..2ea49f62742
--- /dev/null
+++ b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb
@@ -0,0 +1,8 @@
+# rubocop:disable Migration/Datetime
+class AddLastRepositoryUpdatedAtToProjects < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :projects, :last_repository_updated_at, :datetime
+ end
+end
diff --git a/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb
new file mode 100644
index 00000000000..6144d74745c
--- /dev/null
+++ b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb
@@ -0,0 +1,15 @@
+class AddIndexToLastRepositoryUpdatedAtOnProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:projects, :last_repository_updated_at)
+ end
+
+ def down
+ remove_concurrent_index(:projects, :last_repository_updated_at) if index_exists?(:projects, :last_repository_updated_at)
+ end
+end
diff --git a/db/migrate/20170503004426_add_retried_to_ci_build.rb b/db/migrate/20170503004426_add_retried_to_ci_build.rb
new file mode 100644
index 00000000000..2851e3de473
--- /dev/null
+++ b/db/migrate/20170503004426_add_retried_to_ci_build.rb
@@ -0,0 +1,9 @@
+class AddRetriedToCiBuild < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column(:ci_builds, :retried, :boolean)
+ end
+end
diff --git a/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb
new file mode 100644
index 00000000000..6ac10723c82
--- /dev/null
+++ b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb
@@ -0,0 +1,14 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddLastEditedAtAndLastEditedByIdToIssues < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :issues, :last_edited_at, :timestamp
+ add_column :issues, :last_edited_by_id, :integer
+ end
+end
diff --git a/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb
new file mode 100644
index 00000000000..7a1acdcbf69
--- /dev/null
+++ b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb
@@ -0,0 +1,14 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddLastEditedAtAndLastEditedByIdToMergeRequests < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :merge_requests, :last_edited_at, :timestamp
+ add_column :merge_requests, :last_edited_by_id, :integer
+ end
+end
diff --git a/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb
new file mode 100644
index 00000000000..0faea87a962
--- /dev/null
+++ b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb
@@ -0,0 +1,15 @@
+class AddRepositoryUpdateEventsToWebHooks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default :web_hooks, :repository_update_events, :boolean, default: false, allow_null: false
+ end
+
+ def down
+ remove_column :web_hooks, :repository_update_events
+ end
+end
diff --git a/db/migrate/20170503114228_add_description_to_snippets.rb b/db/migrate/20170503114228_add_description_to_snippets.rb
new file mode 100644
index 00000000000..3fc960b2da5
--- /dev/null
+++ b/db/migrate/20170503114228_add_description_to_snippets.rb
@@ -0,0 +1,12 @@
+class AddDescriptionToSnippets < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def change
+ add_column :snippets, :description, :text
+ add_column :snippets, :description_html, :text
+ end
+end
diff --git a/db/migrate/20170503140201_reschedule_project_authorizations.rb b/db/migrate/20170503140201_reschedule_project_authorizations.rb
new file mode 100644
index 00000000000..fa45adadbae
--- /dev/null
+++ b/db/migrate/20170503140201_reschedule_project_authorizations.rb
@@ -0,0 +1,44 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RescheduleProjectAuthorizations < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+ end
+
+ def up
+ offset = 0
+ batch = 5000
+ start = Time.now
+
+ loop do
+ relation = User.where('id > ?', offset)
+ user_ids = relation.limit(batch).reorder(id: :asc).pluck(:id)
+
+ break if user_ids.empty?
+
+ offset = user_ids.last
+
+ # This will schedule each batch 5 minutes after the previous batch was
+ # scheduled. This smears out the load over time, instead of immediately
+ # scheduling a million jobs.
+ Sidekiq::Client.push_bulk(
+ 'queue' => 'authorized_projects',
+ 'args' => user_ids.zip,
+ 'class' => 'AuthorizedProjectsWorker',
+ 'at' => start.to_i
+ )
+
+ start += 5.minutes
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb b/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb
new file mode 100644
index 00000000000..33908ae1156
--- /dev/null
+++ b/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb
@@ -0,0 +1,123 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+# This migration depends on code external to it. For example, it relies on
+# updating a namespace to also rename directories (uploads, GitLab pages, etc).
+# The alternative is to copy hundreds of lines of code into this migration,
+# adjust them where needed, etc; something which doesn't work well at all.
+class TurnNestedGroupsIntoRegularGroupsForMysql < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def run_migration?
+ Gitlab::Database.mysql?
+ end
+
+ def up
+ return unless run_migration?
+
+ # For all sub-groups we need to give the right people access. We do this as
+ # follows:
+ #
+ # 1. Get all the ancestors for the current namespace
+ # 2. Get all the members of these namespaces, along with their higher access
+ # level
+ # 3. Give these members access to the current namespace
+ Namespace.unscoped.where('parent_id IS NOT NULL').find_each do |namespace|
+ rows = []
+ existing = namespace.members.pluck(:user_id)
+
+ all_members_for(namespace).each do |member|
+ next if existing.include?(member[:user_id])
+
+ rows << {
+ access_level: member[:access_level],
+ source_id: namespace.id,
+ source_type: 'Namespace',
+ user_id: member[:user_id],
+ notification_level: 3, # global
+ type: 'GroupMember',
+ created_at: Time.current,
+ updated_at: Time.current
+ }
+ end
+
+ bulk_insert_members(rows)
+
+ # This method relies on the parent to determine the proper path.
+ # Because we reset "parent_id" this method will not return the right path
+ # when moving namespaces.
+ full_path_was = namespace.send(:full_path_was)
+
+ namespace.define_singleton_method(:full_path_was) { full_path_was }
+
+ namespace.update!(parent_id: nil, path: new_path_for(namespace))
+ end
+ end
+
+ def down
+ # There is no way to go back from regular groups to nested groups.
+ end
+
+ # Generates a new (unique) path for a namespace.
+ def new_path_for(namespace)
+ counter = 1
+ base = namespace.full_path.tr('/', '-')
+ new_path = base
+
+ while Namespace.unscoped.where(path: new_path).exists?
+ new_path = base + "-#{counter}"
+ counter += 1
+ end
+
+ new_path
+ end
+
+ # Returns an Array containing all the ancestors of the current namespace.
+ #
+ # This method is not particularly efficient, but it's probably still faster
+ # than using the "routes" table. Most importantly of all, it _only_ depends
+ # on the namespaces table and the "parent_id" column.
+ def ancestors_for(namespace)
+ ancestors = []
+ current = namespace
+
+ while current&.parent_id
+ # We're using find_by(id: ...) here to deal with cases where the
+ # parent_id may point to a missing row.
+ current = Namespace.unscoped.select([:id, :parent_id])
+ .find_by(id: current.parent_id)
+
+ ancestors << current.id if current
+ end
+
+ ancestors
+ end
+
+ # Returns a relation containing all the members that have access to any of
+ # the current namespace's parent namespaces.
+ def all_members_for(namespace)
+ Member
+ .unscoped
+ .select(['user_id', 'MAX(access_level) AS access_level'])
+ .where(source_type: 'Namespace', source_id: ancestors_for(namespace))
+ .group(:user_id)
+ end
+
+ def bulk_insert_members(rows)
+ return if rows.empty?
+
+ keys = rows.first.keys
+
+ tuples = rows.map do |row|
+ row.map { |(_, value)| connection.quote(value) }
+ end
+
+ execute <<-EOF.strip_heredoc
+ INSERT INTO members (#{keys.join(', ')})
+ VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
+ EOF
+ end
+end
diff --git a/db/migrate/20170503184421_add_index_to_redirect_routes.rb b/db/migrate/20170503184421_add_index_to_redirect_routes.rb
new file mode 100644
index 00000000000..9062cf19a73
--- /dev/null
+++ b/db/migrate/20170503184421_add_index_to_redirect_routes.rb
@@ -0,0 +1,21 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexToRedirectRoutes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:redirect_routes, :path, unique: true)
+ add_concurrent_index(:redirect_routes, [:source_type, :source_id])
+ end
+
+ def down
+ remove_concurrent_index(:redirect_routes, :path) if index_exists?(:redirect_routes, :path)
+ remove_concurrent_index(:redirect_routes, [:source_type, :source_id]) if index_exists?(:redirect_routes, [:source_type, :source_id])
+ end
+end
diff --git a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb
new file mode 100644
index 00000000000..8eb20faa03a
--- /dev/null
+++ b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb
@@ -0,0 +1,28 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class IndexRedirectRoutesPathForLike < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ INDEX_NAME = 'index_redirect_routes_on_path_text_pattern_ops'
+
+ disable_ddl_transaction!
+
+ def up
+ return unless Gitlab::Database.postgresql?
+
+ unless index_exists?(:redirect_routes, :path, name: INDEX_NAME)
+ execute("CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON redirect_routes (path varchar_pattern_ops);")
+ end
+ end
+
+ def down
+ return unless Gitlab::Database.postgresql?
+ return unless index_exists?(:redirect_routes, :path, name: INDEX_NAME)
+
+ remove_concurrent_index_by_name(:redirect_routes, INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb
new file mode 100644
index 00000000000..141112f8b50
--- /dev/null
+++ b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb
@@ -0,0 +1,33 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddClientsideSentryToApplicationSettings < 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 "add_concurrent_index" or "remove_concurrent_index" methods make sure
+ # that either of them 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 or removing fails
+ # and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false
+ add_column :application_settings, :clientside_sentry_dsn, :string
+ end
+
+ def down
+ remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn
+ end
+end
diff --git a/db/migrate/20170504182103_add_index_project_group_links_group_id.rb b/db/migrate/20170504182103_add_index_project_group_links_group_id.rb
new file mode 100644
index 00000000000..62bf641daa6
--- /dev/null
+++ b/db/migrate/20170504182103_add_index_project_group_links_group_id.rb
@@ -0,0 +1,19 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexProjectGroupLinksGroupId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :project_group_links, :group_id
+ end
+
+ def down
+ remove_concurrent_index :project_group_links, :group_id
+ end
+end
diff --git a/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb b/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb
new file mode 100644
index 00000000000..08a7f3fc9ab
--- /dev/null
+++ b/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb
@@ -0,0 +1,19 @@
+class AddIndexToPipelinePipelineScheduleId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless index_exists?(:ci_pipelines, :pipeline_schedule_id)
+ add_concurrent_index(:ci_pipelines, :pipeline_schedule_id)
+ end
+ end
+
+ def down
+ if index_exists?(:ci_pipelines, :pipeline_schedule_id)
+ remove_concurrent_index(:ci_pipelines, :pipeline_schedule_id)
+ end
+ end
+end
diff --git a/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb b/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb
new file mode 100644
index 00000000000..7f2dba702af
--- /dev/null
+++ b/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb
@@ -0,0 +1,15 @@
+class AddForeignKeyToPipelineSchedules < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key :ci_pipeline_schedules, :projects, column: :project_id
+ end
+
+ def down
+ remove_foreign_key :ci_pipeline_schedules, :projects
+ end
+end
diff --git a/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb b/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb
new file mode 100644
index 00000000000..55bf40ba24d
--- /dev/null
+++ b/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb
@@ -0,0 +1,23 @@
+class AddForeignKeyPipelineSchedulesAndPipelines < 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_pipeline_schedules,
+ column: :pipeline_schedule_id, on_delete: on_delete
+ end
+
+ def down
+ remove_foreign_key :ci_pipelines, column: :pipeline_schedule_id
+ end
+end
diff --git a/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb
new file mode 100644
index 00000000000..8fc6e380a77
--- /dev/null
+++ b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb
@@ -0,0 +1,7 @@
+class AddHeadPipelineIdToMergeRequests < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :merge_requests, :head_pipeline_id, :integer
+ end
+end
diff --git a/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb
new file mode 100644
index 00000000000..41c687a4f6e
--- /dev/null
+++ b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb
@@ -0,0 +1,12 @@
+class AddNotNullContraintsToCiVariables < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ change_column(:ci_variables, :key, :string, null: false)
+ change_column(:ci_variables, :project_id, :integer, null: false)
+ end
+
+ def down
+ # no op
+ end
+end
diff --git a/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb
new file mode 100644
index 00000000000..20ecaa2c36c
--- /dev/null
+++ b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb
@@ -0,0 +1,24 @@
+class AddForeignKeyToCiVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ execute <<~SQL
+ DELETE FROM ci_variables
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM projects
+ WHERE projects.id = ci_variables.project_id
+ )
+ SQL
+
+ add_concurrent_foreign_key(:ci_variables, :projects, column: :project_id)
+ end
+
+ def down
+ remove_foreign_key(:ci_variables, column: :project_id)
+ end
+end
diff --git a/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb
new file mode 100644
index 00000000000..a2320a911b7
--- /dev/null
+++ b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.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 RenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :web_hooks, :build_events, :job_events
+ end
+
+ def down
+ cleanup_concurrent_column_rename :web_hooks, :job_events, :build_events
+ end
+end
diff --git a/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb b/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb
new file mode 100644
index 00000000000..303d47078e7
--- /dev/null
+++ b/db/migrate/20170511083824_rename_services_build_events_to_job_events.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 RenameServicesBuildEventsToJobEvents < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :services, :build_events, :job_events
+ end
+
+ def down
+ cleanup_concurrent_column_rename :services, :job_events, :build_events
+ end
+end
diff --git a/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb b/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb
new file mode 100644
index 00000000000..eed9f00d8b2
--- /dev/null
+++ b/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb
@@ -0,0 +1,83 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class MigrateAssigneeToSeparateTable < 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", "remove_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" or "remove_concurrent_index" methods make sure
+ # that either of them 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 or removing fails
+ # and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def up
+ drop_table(:issue_assignees) if table_exists?(:issue_assignees)
+
+ if Gitlab::Database.mysql?
+ execute <<-EOF
+ CREATE TABLE issue_assignees AS
+ SELECT assignee_id AS user_id, id AS issue_id FROM issues WHERE assignee_id IS NOT NULL
+ EOF
+ else
+ ActiveRecord::Base.transaction do
+ execute('LOCK TABLE issues IN EXCLUSIVE MODE')
+
+ execute <<-EOF
+ CREATE TABLE issue_assignees AS
+ SELECT assignee_id AS user_id, id AS issue_id FROM issues WHERE assignee_id IS NOT NULL
+ EOF
+
+ execute <<-EOF
+ CREATE OR REPLACE FUNCTION replicate_assignee_id()
+ RETURNS trigger AS
+ $BODY$
+ BEGIN
+ if OLD IS NOT NULL AND OLD.assignee_id IS NOT NULL THEN
+ DELETE FROM issue_assignees WHERE issue_id = OLD.id;
+ END IF;
+
+ if NEW.assignee_id IS NOT NULL THEN
+ INSERT INTO issue_assignees (user_id, issue_id) VALUES (NEW.assignee_id, NEW.id);
+ END IF;
+
+ RETURN NEW;
+ END;
+ $BODY$
+ LANGUAGE 'plpgsql'
+ VOLATILE;
+
+ CREATE TRIGGER replicate_assignee_id
+ BEFORE INSERT OR UPDATE OF assignee_id
+ ON issues
+ FOR EACH ROW EXECUTE PROCEDURE replicate_assignee_id();
+ EOF
+ end
+ end
+ end
+
+ def down
+ drop_table(:issue_assignees) if table_exists?(:issue_assignees)
+
+ if Gitlab::Database.postgresql?
+ execute <<-EOF
+ DROP TRIGGER IF EXISTS replicate_assignee_id ON issues;
+ DROP FUNCTION IF EXISTS replicate_assignee_id();
+ EOF
+ end
+ end
+end
diff --git a/db/migrate/20170516183131_add_indices_to_issue_assignees.rb b/db/migrate/20170516183131_add_indices_to_issue_assignees.rb
new file mode 100644
index 00000000000..a1f064c6848
--- /dev/null
+++ b/db/migrate/20170516183131_add_indices_to_issue_assignees.rb
@@ -0,0 +1,41 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndicesToIssueAssignees < 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", "remove_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" or "remove_concurrent_index" methods make sure
+ # that either of them 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 or removing fails
+ # and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :issue_assignees, [:issue_id, :user_id], unique: true, name: 'index_issue_assignees_on_issue_id_and_user_id'
+ add_concurrent_index :issue_assignees, :user_id, name: 'index_issue_assignees_on_user_id'
+ add_concurrent_foreign_key :issue_assignees, :users, column: :user_id, on_delete: :cascade
+ add_concurrent_foreign_key :issue_assignees, :issues, column: :issue_id, on_delete: :cascade
+ end
+
+ def down
+ remove_foreign_key :issue_assignees, column: :user_id
+ remove_foreign_key :issue_assignees, column: :issue_id
+ remove_concurrent_index :issue_assignees, [:issue_id, :user_id] if index_exists?(:issue_assignees, [:issue_id, :user_id])
+ remove_concurrent_index :issue_assignees, :user_id if index_exists?(:issue_assignees, :user_id)
+ end
+end
diff --git a/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb
new file mode 100644
index 00000000000..6ec2ed712b9
--- /dev/null
+++ b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb
@@ -0,0 +1,16 @@
+class AddPrometheusSettingsToMetricsSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_column_with_default(:application_settings, :prometheus_metrics_enabled, :boolean,
+ default: false, allow_null: false)
+ end
+
+ def down
+ remove_column(:application_settings, :prometheus_metrics_enabled)
+ end
+end
diff --git a/db/migrate/20170521184006_add_change_position_to_notes.rb b/db/migrate/20170521184006_add_change_position_to_notes.rb
new file mode 100644
index 00000000000..219ed1ade4c
--- /dev/null
+++ b/db/migrate/20170521184006_add_change_position_to_notes.rb
@@ -0,0 +1,13 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddChangePositionToNotes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :notes, :change_position, :text
+ end
+end
diff --git a/db/migrate/20170523091700_add_rss_token_to_users.rb b/db/migrate/20170523091700_add_rss_token_to_users.rb
new file mode 100644
index 00000000000..06a85f6ac3d
--- /dev/null
+++ b/db/migrate/20170523091700_add_rss_token_to_users.rb
@@ -0,0 +1,19 @@
+class AddRssTokenToUsers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :users, :rss_token, :string
+
+ add_concurrent_index :users, :rss_token
+ end
+
+ def down
+ remove_concurrent_index :users, :rss_token if index_exists? :users, :rss_token
+
+ remove_column :users, :rss_token
+ end
+end
diff --git a/db/migrate/20170523121229_create_conversational_development_index_metrics.rb b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..7026a867ae1
--- /dev/null
+++ b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
@@ -0,0 +1,40 @@
+# rubocop:disable Migration/Timestamps
+class CreateConversationalDevelopmentIndexMetrics < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :conversational_development_index_metrics do |t|
+ t.float :leader_issues, null: false
+ t.float :instance_issues, null: false
+
+ t.float :leader_notes, null: false
+ t.float :instance_notes, null: false
+
+ t.float :leader_milestones, null: false
+ t.float :instance_milestones, null: false
+
+ t.float :leader_boards, null: false
+ t.float :instance_boards, null: false
+
+ t.float :leader_merge_requests, null: false
+ t.float :instance_merge_requests, null: false
+
+ t.float :leader_ci_pipelines, null: false
+ t.float :instance_ci_pipelines, null: false
+
+ t.float :leader_environments, null: false
+ t.float :instance_environments, null: false
+
+ t.float :leader_deployments, null: false
+ t.float :instance_deployments, null: false
+
+ t.float :leader_projects_prometheus_active, null: false
+ t.float :instance_projects_prometheus_active, null: false
+
+ t.float :leader_service_desk_issues, null: false
+ t.float :instance_service_desk_issues, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20170524125940_add_source_to_ci_pipeline.rb b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb
new file mode 100644
index 00000000000..1fa3d48037b
--- /dev/null
+++ b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb
@@ -0,0 +1,9 @@
+class AddSourceToCiPipeline < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_pipelines, :source, :integer
+ end
+end
diff --git a/db/migrate/20170524161101_add_protected_to_ci_variables.rb b/db/migrate/20170524161101_add_protected_to_ci_variables.rb
new file mode 100644
index 00000000000..99d4861e889
--- /dev/null
+++ b/db/migrate/20170524161101_add_protected_to_ci_variables.rb
@@ -0,0 +1,15 @@
+class AddProtectedToCiVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:ci_variables, :protected, :boolean, default: false)
+ end
+
+ def down
+ remove_column(:ci_variables, :protected)
+ end
+end
diff --git a/db/migrate/20170525130346_create_group_variables_table.rb b/db/migrate/20170525130346_create_group_variables_table.rb
new file mode 100644
index 00000000000..eaa38dbc40d
--- /dev/null
+++ b/db/migrate/20170525130346_create_group_variables_table.rb
@@ -0,0 +1,23 @@
+class CreateGroupVariablesTable < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ create_table :ci_group_variables do |t|
+ t.string :key, null: false
+ t.text :value
+ t.text :encrypted_value
+ t.string :encrypted_value_salt
+ t.string :encrypted_value_iv
+ t.integer :group_id, null: false
+ t.boolean :protected, default: false, null: false
+
+ t.timestamps_with_timezone null: false
+ end
+
+ add_index :ci_group_variables, [:group_id, :key], unique: true
+ end
+
+ def down
+ drop_table :ci_group_variables
+ end
+end
diff --git a/db/migrate/20170525130758_add_foreign_key_to_group_variables.rb b/db/migrate/20170525130758_add_foreign_key_to_group_variables.rb
new file mode 100644
index 00000000000..0146235c5ba
--- /dev/null
+++ b/db/migrate/20170525130758_add_foreign_key_to_group_variables.rb
@@ -0,0 +1,15 @@
+class AddForeignKeyToGroupVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key :ci_group_variables, :namespaces, column: :group_id
+ end
+
+ def down
+ remove_foreign_key :ci_group_variables, column: :group_id
+ end
+end
diff --git a/db/migrate/20170525132202_create_pipeline_stages.rb b/db/migrate/20170525132202_create_pipeline_stages.rb
new file mode 100644
index 00000000000..825993aa41e
--- /dev/null
+++ b/db/migrate/20170525132202_create_pipeline_stages.rb
@@ -0,0 +1,26 @@
+# rubocop:disable Migration/Timestamps
+class CreatePipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :ci_stages do |t|
+ t.integer :project_id
+ t.integer :pipeline_id
+ t.timestamps null: true
+ t.string :name
+ end
+
+ add_concurrent_foreign_key :ci_stages, :projects, column: :project_id, on_delete: :cascade
+ add_concurrent_foreign_key :ci_stages, :ci_pipelines, column: :pipeline_id, on_delete: :cascade
+ add_concurrent_index :ci_stages, :project_id
+ add_concurrent_index :ci_stages, :pipeline_id
+ end
+
+ def down
+ drop_table :ci_stages
+ end
+end
diff --git a/db/migrate/20170525174156_create_feature_tables.rb b/db/migrate/20170525174156_create_feature_tables.rb
new file mode 100644
index 00000000000..a083c89c85f
--- /dev/null
+++ b/db/migrate/20170525174156_create_feature_tables.rb
@@ -0,0 +1,26 @@
+class CreateFeatureTables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def self.up
+ create_table :features do |t|
+ t.string :key, null: false
+ t.timestamps null: false
+ end
+ add_index :features, :key, unique: true
+
+ create_table :feature_gates do |t|
+ t.string :feature_key, null: false
+ t.string :key, null: false
+ t.string :value
+ t.timestamps null: false
+ end
+ add_index :feature_gates, [:feature_key, :key, :value], unique: true
+ end
+
+ def self.down
+ drop_table :feature_gates
+ drop_table :features
+ end
+end
diff --git a/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb
new file mode 100644
index 00000000000..d27cba76d81
--- /dev/null
+++ b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb
@@ -0,0 +1,13 @@
+class AddStageIdToCiBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ add_column :ci_builds, :stage_id, :integer
+ end
+
+ def down
+ remove_column :ci_builds, :stage_id, :integer
+ end
+end
diff --git a/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb
new file mode 100644
index 00000000000..af6d10b5158
--- /dev/null
+++ b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb
@@ -0,0 +1,205 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ProjectForeignKeysWithCascadingDeletes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ CONCURRENCY = 4
+
+ disable_ddl_transaction!
+
+ # The tables/columns for which to remove orphans and add foreign keys. Order
+ # matters as some tables/columns should be processed before others.
+ TABLES = [
+ [:boards, :projects, :project_id],
+ [:lists, :labels, :label_id],
+ [:lists, :boards, :board_id],
+ [:services, :projects, :project_id],
+ [:forked_project_links, :projects, :forked_to_project_id],
+ [:merge_requests, :projects, :target_project_id],
+ [:labels, :projects, :project_id],
+ [:issues, :projects, :project_id],
+ [:events, :projects, :project_id],
+ [:milestones, :projects, :project_id],
+ [:notes, :projects, :project_id],
+ [:snippets, :projects, :project_id],
+ [:web_hooks, :projects, :project_id],
+ [:protected_branch_merge_access_levels, :protected_branches, :protected_branch_id],
+ [:protected_branch_push_access_levels, :protected_branches, :protected_branch_id],
+ [:protected_branches, :projects, :project_id],
+ [:protected_tags, :projects, :project_id],
+ [:deploy_keys_projects, :projects, :project_id],
+ [:users_star_projects, :projects, :project_id],
+ [:releases, :projects, :project_id],
+ [:project_group_links, :projects, :project_id],
+ [:pages_domains, :projects, :project_id],
+ [:todos, :projects, :project_id],
+ [:project_import_data, :projects, :project_id],
+ [:project_features, :projects, :project_id],
+ [:ci_builds, :projects, :project_id],
+ [:ci_pipelines, :projects, :project_id],
+ [:ci_runner_projects, :projects, :project_id],
+ [:ci_triggers, :projects, :project_id],
+ [:environments, :projects, :project_id],
+ [:deployments, :projects, :project_id]
+ ]
+
+ def up
+ # These existing foreign keys don't have an "ON DELETE CASCADE" clause.
+ remove_foreign_key_without_error(:boards, :project_id)
+ remove_foreign_key_without_error(:lists, :label_id)
+ remove_foreign_key_without_error(:lists, :board_id)
+ remove_foreign_key_without_error(:protected_branch_merge_access_levels,
+ :protected_branch_id)
+
+ remove_foreign_key_without_error(:protected_branch_push_access_levels,
+ :protected_branch_id)
+
+ remove_orphaned_rows
+ add_foreign_keys
+
+ # These columns are not indexed yet, meaning a cascading delete would take
+ # forever.
+ add_index_if_not_exists(:project_group_links, :project_id)
+ add_index_if_not_exists(:pages_domains, :project_id)
+ end
+
+ def down
+ TABLES.each do |(source, _, column)|
+ remove_foreign_key_without_error(source, column)
+ end
+
+ add_foreign_key_if_not_exists(:boards, :projects, column: :project_id)
+ add_foreign_key_if_not_exists(:lists, :labels, column: :label_id)
+ add_foreign_key_if_not_exists(:lists, :boards, column: :board_id)
+
+ add_foreign_key_if_not_exists(:protected_branch_merge_access_levels,
+ :protected_branches,
+ column: :protected_branch_id)
+
+ add_foreign_key_if_not_exists(:protected_branch_push_access_levels,
+ :protected_branches,
+ column: :protected_branch_id)
+
+ remove_index_without_error(:project_group_links, :project_id)
+ remove_index_without_error(:pages_domains, :project_id)
+ end
+
+ def add_foreign_keys
+ TABLES.each do |(source, target, column)|
+ add_foreign_key_if_not_exists(source, target, column: column)
+ end
+ end
+
+ # Removes orphans from various tables concurrently.
+ def remove_orphaned_rows
+ Gitlab::Database.with_connection_pool(CONCURRENCY) do |pool|
+ queues = queues_for_rows(TABLES)
+
+ threads = queues.map do |queue|
+ Thread.new do
+ pool.with_connection do |connection|
+ Thread.current[:foreign_key_connection] = connection
+
+ # Disables statement timeouts for the current connection. This is
+ # necessary as removing of orphaned data might otherwise exceed the
+ # statement timeout.
+ disable_statement_timeout
+
+ remove_orphans(*queue.pop) until queue.empty?
+
+ steal_from_queues(queues - [queue])
+ end
+ end
+ end
+
+ threads.each(&:join)
+ end
+ end
+
+ def steal_from_queues(queues)
+ loop do
+ stolen = false
+
+ queues.each do |queue|
+ # Stealing is racy so it's possible a pop might be called on an
+ # already-empty queue.
+ begin
+ remove_orphans(*queue.pop(true))
+ stolen = true
+ rescue ThreadError
+ end
+ end
+
+ break unless stolen
+ end
+ end
+
+ def remove_orphans(source, target, column)
+ quoted_source = quote_table_name(source)
+ quoted_target = quote_table_name(target)
+ quoted_column = quote_column_name(column)
+
+ execute <<-EOF.strip_heredoc
+ DELETE FROM #{quoted_source}
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM #{quoted_target}
+ WHERE #{quoted_target}.id = #{quoted_source}.#{quoted_column}
+ )
+ AND #{quoted_source}.#{quoted_column} IS NOT NULL
+ EOF
+ end
+
+ def add_foreign_key_if_not_exists(source, target, column:)
+ return if foreign_key_exists?(source, column)
+
+ add_concurrent_foreign_key(source, target, column: column)
+ end
+
+ def add_index_if_not_exists(table, column)
+ return if index_exists?(table, column)
+
+ add_concurrent_index(table, column)
+ end
+
+ def remove_foreign_key_without_error(table, column)
+ remove_foreign_key(table, column: column)
+ rescue ArgumentError
+ end
+
+ def remove_index_without_error(table, column)
+ remove_concurrent_index(table, column)
+ rescue ArgumentError
+ end
+
+ def foreign_key_exists?(table, column)
+ foreign_keys(table).any? do |key|
+ key.options[:column] == column.to_s
+ end
+ end
+
+ def connection
+ # Rails memoizes connection objects, but this causes them to be shared
+ # amongst threads; we don't want that.
+ Thread.current[:foreign_key_connection] || ActiveRecord::Base.connection
+ end
+
+ def queues_for_rows(rows)
+ queues = Array.new(CONCURRENCY) { Queue.new }
+ slice_size = rows.length / CONCURRENCY
+
+ # Divide all the tuples as evenly as possible amongst the queues.
+ rows.each_slice(slice_size).each_with_index do |slice, index|
+ bucket = index % CONCURRENCY
+
+ slice.each do |row|
+ queues[bucket] << row
+ end
+ end
+
+ queues
+ end
+end
diff --git a/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb
new file mode 100644
index 00000000000..470c3b8166c
--- /dev/null
+++ b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb
@@ -0,0 +1,15 @@
+class RenameUsersLdapEmailToExternalEmail < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :users, :ldap_email, :external_email
+ end
+
+ def down
+ cleanup_concurrent_column_rename :users, :external_email, :ldap_email
+ end
+end
diff --git a/db/migrate/20170602154736_add_help_page_hide_commercial_content_to_application_settings.rb b/db/migrate/20170602154736_add_help_page_hide_commercial_content_to_application_settings.rb
new file mode 100644
index 00000000000..d358020d182
--- /dev/null
+++ b/db/migrate/20170602154736_add_help_page_hide_commercial_content_to_application_settings.rb
@@ -0,0 +1,10 @@
+# rubocop:disable Migration/SaferBooleanColumn
+class AddHelpPageHideCommercialContentToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :help_page_hide_commercial_content, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20170602154813_add_help_page_support_url_to_application_settings.rb b/db/migrate/20170602154813_add_help_page_support_url_to_application_settings.rb
new file mode 100644
index 00000000000..138fe9b2a37
--- /dev/null
+++ b/db/migrate/20170602154813_add_help_page_support_url_to_application_settings.rb
@@ -0,0 +1,9 @@
+class AddHelpPageSupportUrlToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :help_page_support_url, :string
+ end
+end
diff --git a/db/migrate/20170603200744_add_email_provider_to_users.rb b/db/migrate/20170603200744_add_email_provider_to_users.rb
new file mode 100644
index 00000000000..ed90af9aadc
--- /dev/null
+++ b/db/migrate/20170603200744_add_email_provider_to_users.rb
@@ -0,0 +1,9 @@
+class AddEmailProviderToUsers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :users, :email_provider, :string
+ end
+end
diff --git a/db/migrate/20170606154216_add_notification_setting_columns.rb b/db/migrate/20170606154216_add_notification_setting_columns.rb
new file mode 100644
index 00000000000..0a9b5da6583
--- /dev/null
+++ b/db/migrate/20170606154216_add_notification_setting_columns.rb
@@ -0,0 +1,26 @@
+class AddNotificationSettingColumns < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ COLUMNS = [
+ :new_note,
+ :new_issue,
+ :reopen_issue,
+ :close_issue,
+ :reassign_issue,
+ :new_merge_request,
+ :reopen_merge_request,
+ :close_merge_request,
+ :reassign_merge_request,
+ :merge_merge_request,
+ :failed_pipeline,
+ :success_pipeline
+ ]
+
+ def change
+ COLUMNS.each do |column|
+ add_column(:notification_settings, column, :boolean)
+ end
+ end
+end
diff --git a/db/migrate/20170608152747_prepare_events_table_for_push_events_migration.rb b/db/migrate/20170608152747_prepare_events_table_for_push_events_migration.rb
new file mode 100644
index 00000000000..f4f03bbabaf
--- /dev/null
+++ b/db/migrate/20170608152747_prepare_events_table_for_push_events_migration.rb
@@ -0,0 +1,51 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class PrepareEventsTableForPushEventsMigration < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # The order of these columns is deliberate and results in the following
+ # columns and sizes:
+ #
+ # * id (4 bytes)
+ # * project_id (4 bytes)
+ # * author_id (4 bytes)
+ # * target_id (4 bytes)
+ # * created_at (8 bytes)
+ # * updated_at (8 bytes)
+ # * action (2 bytes)
+ # * target_type (variable)
+ #
+ # Unfortunately we can't make the "id" column a bigint/bigserial as Rails 4
+ # does not support this properly.
+ create_table :events_for_migration do |t|
+ t.references :project,
+ index: true,
+ foreign_key: { on_delete: :cascade }
+
+ t.integer :author_id, index: true, null: false
+ t.integer :target_id
+
+ t.timestamps_with_timezone null: false
+
+ t.integer :action, null: false, limit: 2, index: true
+ t.string :target_type
+
+ t.index %i[target_type target_id]
+ end
+
+ # t.references doesn't like it when the column name doesn't make the table
+ # name so we have to add the foreign key separately.
+ add_concurrent_foreign_key(:events_for_migration, :users, column: :author_id)
+ end
+
+ def down
+ drop_table :events_for_migration
+ end
+end
diff --git a/db/migrate/20170608152748_create_push_event_payloads_tables.rb b/db/migrate/20170608152748_create_push_event_payloads_tables.rb
new file mode 100644
index 00000000000..6c55ad1f2f7
--- /dev/null
+++ b/db/migrate/20170608152748_create_push_event_payloads_tables.rb
@@ -0,0 +1,46 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CreatePushEventPayloadsTables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :push_event_payloads, id: false do |t|
+ t.bigint :commit_count, null: false
+
+ t.integer :event_id, null: false
+ t.integer :action, null: false, limit: 2
+ t.integer :ref_type, null: false, limit: 2
+
+ t.binary :commit_from
+ t.binary :commit_to
+
+ t.text :ref
+ t.string :commit_title, limit: 70
+
+ t.index :event_id, unique: true
+ end
+
+ # We're adding a foreign key to the _shadow_ table, and this is deliberate.
+ # By using the shadow table we don't have to recreate/revalidate this
+ # foreign key after swapping the "events_for_migration" and "events" tables.
+ #
+ # The "events_for_migration" table has a foreign key to "projects.id"
+ # ensuring that project removals also remove events from the shadow table
+ # (and thus also from this table).
+ add_concurrent_foreign_key(
+ :push_event_payloads,
+ :events_for_migration,
+ column: :event_id
+ )
+ end
+
+ def down
+ drop_table :push_event_payloads
+ end
+end
diff --git a/db/migrate/20170608171156_create_merge_request_diff_files.rb b/db/migrate/20170608171156_create_merge_request_diff_files.rb
new file mode 100644
index 00000000000..bf0c0d29adc
--- /dev/null
+++ b/db/migrate/20170608171156_create_merge_request_diff_files.rb
@@ -0,0 +1,22 @@
+class CreateMergeRequestDiffFiles < ActiveRecord::Migration
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def change
+ create_table :merge_request_diff_files, id: false do |t|
+ t.belongs_to :merge_request_diff, null: false, foreign_key: { on_delete: :cascade }
+ t.integer :relative_order, null: false
+ t.boolean :new_file, null: false
+ t.boolean :renamed_file, null: false
+ t.boolean :deleted_file, null: false
+ t.boolean :too_large, null: false
+ t.string :a_mode, null: false
+ t.string :b_mode, null: false
+ t.text :new_path, null: false
+ t.text :old_path, null: false
+ t.text :diff, null: false
+ t.index [:merge_request_diff_id, :relative_order], name: 'index_merge_request_diff_files_on_mr_diff_id_and_order', unique: true
+ end
+ end
+end
diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb
new file mode 100644
index 00000000000..f6b5e7ebb7b
--- /dev/null
+++ b/db/migrate/20170613154149_create_gpg_signatures.rb
@@ -0,0 +1,23 @@
+class CreateGpgSignatures < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :gpg_signatures do |t|
+ t.timestamps_with_timezone null: false
+
+ t.references :project, index: true, foreign_key: { on_delete: :cascade }
+ t.references :gpg_key, index: true, foreign_key: { on_delete: :nullify }
+
+ t.boolean :valid_signature
+
+ t.binary :commit_sha
+ t.binary :gpg_key_primary_keyid
+
+ t.text :gpg_key_user_name
+ t.text :gpg_key_user_email
+
+ t.index :commit_sha, unique: true, length: Gitlab::Database.mysql? ? 20 : nil
+ t.index :gpg_key_primary_keyid, length: Gitlab::Database.mysql? ? 20 : nil
+ end
+ end
+end
diff --git a/db/migrate/20170614115405_merge_request_diff_file_limits_to_mysql.rb b/db/migrate/20170614115405_merge_request_diff_file_limits_to_mysql.rb
new file mode 100644
index 00000000000..4c1cf08aa06
--- /dev/null
+++ b/db/migrate/20170614115405_merge_request_diff_file_limits_to_mysql.rb
@@ -0,0 +1 @@
+require_relative 'merge_request_diff_file_limits_to_mysql'
diff --git a/db/migrate/20170616133147_create_merge_request_diff_commits.rb b/db/migrate/20170616133147_create_merge_request_diff_commits.rb
new file mode 100644
index 00000000000..616464cb470
--- /dev/null
+++ b/db/migrate/20170616133147_create_merge_request_diff_commits.rb
@@ -0,0 +1,20 @@
+class CreateMergeRequestDiffCommits < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :merge_request_diff_commits, id: false do |t|
+ t.datetime_with_timezone :authored_date
+ t.datetime_with_timezone :committed_date
+ t.belongs_to :merge_request_diff, null: false, foreign_key: { on_delete: :cascade }
+ t.integer :relative_order, null: false
+ t.binary :sha, null: false, limit: 20
+ t.text :author_name
+ t.text :author_email
+ t.text :committer_name
+ t.text :committer_email
+ t.text :message
+
+ t.index [:merge_request_diff_id, :relative_order], name: 'index_merge_request_diff_commits_on_mr_diff_id_and_order', unique: true
+ end
+ end
+end
diff --git a/db/migrate/20170619144837_add_index_for_head_pipeline_merge_request.rb b/db/migrate/20170619144837_add_index_for_head_pipeline_merge_request.rb
new file mode 100644
index 00000000000..02863bee082
--- /dev/null
+++ b/db/migrate/20170619144837_add_index_for_head_pipeline_merge_request.rb
@@ -0,0 +1,15 @@
+class AddIndexForHeadPipelineMergeRequest < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :merge_requests, :head_pipeline_id
+ end
+
+ def down
+ remove_concurrent_index :merge_requests, :head_pipeline_id if index_exists?(:merge_requests, :head_pipeline_id)
+ end
+end
diff --git a/db/migrate/20170620064728_create_ci_pipeline_schedule_variables.rb b/db/migrate/20170620064728_create_ci_pipeline_schedule_variables.rb
new file mode 100644
index 00000000000..92833765a82
--- /dev/null
+++ b/db/migrate/20170620064728_create_ci_pipeline_schedule_variables.rb
@@ -0,0 +1,25 @@
+class CreateCiPipelineScheduleVariables < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ create_table :ci_pipeline_schedule_variables do |t|
+ t.string :key, null: false
+ t.text :value
+ t.text :encrypted_value
+ t.string :encrypted_value_salt
+ t.string :encrypted_value_iv
+ t.integer :pipeline_schedule_id, null: false
+
+ t.timestamps_with_timezone null: true
+ end
+
+ add_index :ci_pipeline_schedule_variables,
+ [:pipeline_schedule_id, :key],
+ name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key",
+ unique: true
+ end
+
+ def down
+ drop_table :ci_pipeline_schedule_variables
+ end
+end
diff --git a/db/migrate/20170620065449_add_foreign_key_to_ci_pipeline_schedule_variables.rb b/db/migrate/20170620065449_add_foreign_key_to_ci_pipeline_schedule_variables.rb
new file mode 100644
index 00000000000..7bbf66e0ac3
--- /dev/null
+++ b/db/migrate/20170620065449_add_foreign_key_to_ci_pipeline_schedule_variables.rb
@@ -0,0 +1,15 @@
+class AddForeignKeyToCiPipelineScheduleVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key(:ci_pipeline_schedule_variables, :ci_pipeline_schedules, column: :pipeline_schedule_id)
+ end
+
+ def down
+ remove_foreign_key(:ci_pipeline_schedule_variables, column: :pipeline_schedule_id)
+ end
+end
diff --git a/db/migrate/20170622130029_correct_protected_branches_foreign_keys.rb b/db/migrate/20170622130029_correct_protected_branches_foreign_keys.rb
new file mode 100644
index 00000000000..46497775527
--- /dev/null
+++ b/db/migrate/20170622130029_correct_protected_branches_foreign_keys.rb
@@ -0,0 +1,40 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CorrectProtectedBranchesForeignKeys < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ remove_foreign_key_without_error(:protected_branch_push_access_levels,
+ column: :protected_branch_id)
+
+ execute <<-EOF
+ DELETE FROM protected_branch_push_access_levels
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM protected_branches
+ WHERE protected_branch_push_access_levels.protected_branch_id = protected_branches.id
+ )
+ AND protected_branch_id IS NOT NULL
+ EOF
+
+ add_concurrent_foreign_key(:protected_branch_push_access_levels,
+ :protected_branches,
+ column: :protected_branch_id)
+ end
+
+ def down
+ # Previously there was a foreign key without a CASCADING DELETE, so we'll
+ # just leave the foreign key in place.
+ end
+
+ def remove_foreign_key_without_error(*args)
+ remove_foreign_key(*args)
+ rescue ArgumentError
+ end
+end
diff --git a/db/migrate/20170622132212_add_foreign_key_for_merge_request_diffs.rb b/db/migrate/20170622132212_add_foreign_key_for_merge_request_diffs.rb
new file mode 100644
index 00000000000..9f524fac8a7
--- /dev/null
+++ b/db/migrate/20170622132212_add_foreign_key_for_merge_request_diffs.rb
@@ -0,0 +1,30 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddForeignKeyForMergeRequestDiffs < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ execute <<-EOF
+ DELETE FROM merge_request_diffs
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM merge_requests
+ WHERE merge_requests.id = merge_request_diffs.merge_request_id
+ )
+ EOF
+
+ add_concurrent_foreign_key(:merge_request_diffs,
+ :merge_requests,
+ column: :merge_request_id)
+ end
+
+ def down
+ remove_foreign_key(:merge_request_diffs, column: :merge_request_id)
+ end
+end
diff --git a/db/migrate/20170622135451_rename_duplicated_variable_key.rb b/db/migrate/20170622135451_rename_duplicated_variable_key.rb
new file mode 100644
index 00000000000..368718ab0ce
--- /dev/null
+++ b/db/migrate/20170622135451_rename_duplicated_variable_key.rb
@@ -0,0 +1,38 @@
+class RenameDuplicatedVariableKey < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ execute(<<~SQL)
+ UPDATE ci_variables
+ SET #{key} = CONCAT(#{key}, #{underscore}, id)
+ WHERE id IN (
+ SELECT *
+ FROM ( -- MySQL requires an extra layer
+ SELECT dup.id
+ FROM ci_variables dup
+ INNER JOIN (SELECT max(id) AS id, #{key}, project_id
+ FROM ci_variables tmp
+ GROUP BY #{key}, project_id) var
+ USING (#{key}, project_id) where dup.id <> var.id
+ ) dummy
+ )
+ SQL
+ end
+
+ def down
+ # noop
+ end
+
+ def key
+ # key needs to be quoted in MySQL
+ quote_column_name('key')
+ end
+
+ def underscore
+ quote('_')
+ end
+end
diff --git a/db/migrate/20170622135628_add_environment_scope_to_ci_variables.rb b/db/migrate/20170622135628_add_environment_scope_to_ci_variables.rb
new file mode 100644
index 00000000000..17fe062d8d5
--- /dev/null
+++ b/db/migrate/20170622135628_add_environment_scope_to_ci_variables.rb
@@ -0,0 +1,15 @@
+class AddEnvironmentScopeToCiVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:ci_variables, :environment_scope, :string, default: '*')
+ end
+
+ def down
+ remove_column(:ci_variables, :environment_scope)
+ end
+end
diff --git a/db/migrate/20170622135728_add_unique_constraint_to_ci_variables.rb b/db/migrate/20170622135728_add_unique_constraint_to_ci_variables.rb
new file mode 100644
index 00000000000..8b2cc40ee59
--- /dev/null
+++ b/db/migrate/20170622135728_add_unique_constraint_to_ci_variables.rb
@@ -0,0 +1,38 @@
+class AddUniqueConstraintToCiVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless this_index_exists?
+ add_concurrent_index(:ci_variables, columns, name: index_name, unique: true)
+ end
+ end
+
+ def down
+ if this_index_exists?
+ if Gitlab::Database.mysql? && !index_exists?(:ci_variables, :project_id)
+ # Need to add this index for MySQL project_id foreign key constraint
+ add_concurrent_index(:ci_variables, :project_id)
+ end
+
+ remove_concurrent_index(:ci_variables, columns, name: index_name)
+ end
+ end
+
+ private
+
+ def this_index_exists?
+ index_exists?(:ci_variables, columns, name: index_name)
+ end
+
+ def columns
+ @columns ||= [:project_id, :key, :environment_scope]
+ end
+
+ def index_name
+ 'index_ci_variables_on_project_id_and_key_and_environment_scope'
+ end
+end
diff --git a/db/migrate/20170622162730_add_ref_fetched_to_merge_request.rb b/db/migrate/20170622162730_add_ref_fetched_to_merge_request.rb
new file mode 100644
index 00000000000..62aa1a4b4f0
--- /dev/null
+++ b/db/migrate/20170622162730_add_ref_fetched_to_merge_request.rb
@@ -0,0 +1,9 @@
+class AddRefFetchedToMergeRequest < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :merge_requests, :ref_fetched, :boolean
+ end
+end
diff --git a/db/migrate/20170623080805_remove_ci_variables_project_id_index.rb b/db/migrate/20170623080805_remove_ci_variables_project_id_index.rb
new file mode 100644
index 00000000000..ddcc0292b9d
--- /dev/null
+++ b/db/migrate/20170623080805_remove_ci_variables_project_id_index.rb
@@ -0,0 +1,19 @@
+class RemoveCiVariablesProjectIdIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ if index_exists?(:ci_variables, :project_id)
+ remove_concurrent_index(:ci_variables, :project_id)
+ end
+ end
+
+ def down
+ unless index_exists?(:ci_variables, :project_id)
+ add_concurrent_index(:ci_variables, :project_id)
+ end
+ end
+end
diff --git a/db/migrate/20170629171610_rename_application_settings_signin_enabled_to_password_authentication_enabled.rb b/db/migrate/20170629171610_rename_application_settings_signin_enabled_to_password_authentication_enabled.rb
new file mode 100644
index 00000000000..858b3bebace
--- /dev/null
+++ b/db/migrate/20170629171610_rename_application_settings_signin_enabled_to_password_authentication_enabled.rb
@@ -0,0 +1,15 @@
+class RenameApplicationSettingsSigninEnabledToPasswordAuthenticationEnabled < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :application_settings, :signin_enabled, :password_authentication_enabled
+ end
+
+ def down
+ cleanup_concurrent_column_rename :application_settings, :password_authentication_enabled, :signin_enabled
+ end
+end
diff --git a/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb b/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb
new file mode 100644
index 00000000000..68b947583d3
--- /dev/null
+++ b/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb
@@ -0,0 +1,35 @@
+class AddStageIdForeignKeyToBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless index_exists?(:ci_builds, :stage_id)
+ add_concurrent_index(:ci_builds, :stage_id)
+ end
+
+ unless foreign_key_exists?(:ci_builds, :stage_id)
+ add_concurrent_foreign_key(:ci_builds, :ci_stages, column: :stage_id, on_delete: :cascade)
+ end
+ end
+
+ def down
+ if foreign_key_exists?(:ci_builds, :stage_id)
+ remove_foreign_key(:ci_builds, column: :stage_id)
+ end
+
+ if index_exists?(:ci_builds, :stage_id)
+ remove_concurrent_index(:ci_builds, :stage_id)
+ end
+ end
+
+ private
+
+ def foreign_key_exists?(table, column)
+ foreign_keys(:ci_builds).any? do |key|
+ key.options[:column] == column.to_s
+ end
+ end
+end
diff --git a/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb b/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb
new file mode 100644
index 00000000000..fe9970ddc71
--- /dev/null
+++ b/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb
@@ -0,0 +1,9 @@
+class AddPerformanceBarAllowedGroupIdToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :performance_bar_allowed_group_id, :integer
+ end
+end
diff --git a/db/migrate/20170707183807_add_group_id_to_milestones.rb b/db/migrate/20170707183807_add_group_id_to_milestones.rb
new file mode 100644
index 00000000000..675ffd4a1c9
--- /dev/null
+++ b/db/migrate/20170707183807_add_group_id_to_milestones.rb
@@ -0,0 +1,20 @@
+class AddGroupIdToMilestones < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ return if column_exists? :milestones, :group_id
+
+ change_column_null :milestones, :project_id, true
+
+ add_column :milestones, :group_id, :integer
+ end
+
+ def down
+ # We cannot rollback project_id not null constraint if there are records
+ # with null values.
+ execute "DELETE from milestones WHERE project_id IS NULL"
+
+ remove_column :milestones, :group_id
+ change_column :milestones, :project_id, :integer, null: false
+ end
+end
diff --git a/db/migrate/20170707184243_add_group_milestone_id_indexes.rb b/db/migrate/20170707184243_add_group_milestone_id_indexes.rb
new file mode 100644
index 00000000000..aa48fe90cad
--- /dev/null
+++ b/db/migrate/20170707184243_add_group_milestone_id_indexes.rb
@@ -0,0 +1,21 @@
+class AddGroupMilestoneIdIndexes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ return if index_exists?(:milestones, :group_id)
+
+ add_concurrent_foreign_key :milestones, :namespaces, column: :group_id, on_delete: :cascade
+
+ add_concurrent_index :milestones, :group_id
+ end
+
+ def down
+ remove_foreign_key :milestones, column: :group_id
+
+ remove_concurrent_index :milestones, :group_id
+ end
+end
diff --git a/db/migrate/20170707184244_remove_wrong_versions_from_schema_versions.rb b/db/migrate/20170707184244_remove_wrong_versions_from_schema_versions.rb
new file mode 100644
index 00000000000..38536a8b06a
--- /dev/null
+++ b/db/migrate/20170707184244_remove_wrong_versions_from_schema_versions.rb
@@ -0,0 +1,10 @@
+class RemoveWrongVersionsFromSchemaVersions < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ execute("DELETE FROM schema_migrations WHERE version IN ('20170723183807', '20170724184243')")
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20170710083355_clean_stage_id_reference_migration.rb b/db/migrate/20170710083355_clean_stage_id_reference_migration.rb
new file mode 100644
index 00000000000..681203eaf40
--- /dev/null
+++ b/db/migrate/20170710083355_clean_stage_id_reference_migration.rb
@@ -0,0 +1,18 @@
+class CleanStageIdReferenceMigration < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ ##
+ # `MigrateStageIdReferenceInBackground` background migration cleanup.
+ #
+ def up
+ Gitlab::BackgroundMigration.steal('MigrateBuildStageIdReference')
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb
new file mode 100644
index 00000000000..c25d4fd5986
--- /dev/null
+++ b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb
@@ -0,0 +1,45 @@
+class AddForeignKeyToMergeRequests < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class MergeRequest < ActiveRecord::Base
+ self.table_name = 'merge_requests'
+ include ::EachBatch
+ end
+
+ def up
+ scope = <<-SQL.strip_heredoc
+ head_pipeline_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM ci_pipelines
+ WHERE ci_pipelines.id = merge_requests.head_pipeline_id
+ )
+ SQL
+
+ MergeRequest.where(scope).each_batch(of: 1000) do |merge_requests|
+ merge_requests.update_all(head_pipeline_id: nil)
+ end
+
+ unless foreign_key_exists?(:merge_requests, :head_pipeline_id)
+ add_concurrent_foreign_key(:merge_requests, :ci_pipelines,
+ column: :head_pipeline_id, on_delete: :nullify)
+ end
+ end
+
+ def down
+ if foreign_key_exists?(:merge_requests, :head_pipeline_id)
+ remove_foreign_key(:merge_requests, column: :head_pipeline_id)
+ end
+ end
+
+ private
+
+ def foreign_key_exists?(table, column)
+ foreign_keys(table).any? do |key|
+ key.options[:column] == column.to_s
+ end
+ end
+end
diff --git a/db/migrate/20170717074009_move_system_upload_folder.rb b/db/migrate/20170717074009_move_system_upload_folder.rb
new file mode 100644
index 00000000000..d3caa53a7a4
--- /dev/null
+++ b/db/migrate/20170717074009_move_system_upload_folder.rb
@@ -0,0 +1,70 @@
+class MoveSystemUploadFolder < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ unless file_storage?
+ say 'Using object storage, no need to move.'
+ return
+ end
+
+ unless File.directory?(old_directory)
+ say "#{old_directory} doesn't exist, no need to move it."
+ return
+ end
+
+ if File.directory?(new_directory)
+ say "#{new_directory} already exists. No need to redo the move."
+ return
+ end
+
+ FileUtils.mkdir_p(File.join(base_directory, '-'))
+
+ say "Moving #{old_directory} -> #{new_directory}"
+ FileUtils.mv(old_directory, new_directory)
+ FileUtils.ln_s(new_directory, old_directory)
+ end
+
+ def down
+ unless file_storage?
+ say 'Using object storage, no need to move.'
+ return
+ end
+
+ unless File.directory?(new_directory)
+ say "#{new_directory} doesn't exist, no need to move it."
+ return
+ end
+
+ if !File.symlink?(old_directory) && File.directory?(old_directory)
+ say "#{old_directory} already exists and is not a symlink, no need to revert."
+ return
+ end
+
+ if File.symlink?(old_directory)
+ say "Removing #{old_directory} -> #{new_directory} symlink"
+ FileUtils.rm(old_directory)
+ end
+
+ say "Moving #{new_directory} -> #{old_directory}"
+ FileUtils.mv(new_directory, old_directory)
+ end
+
+ def new_directory
+ File.join(base_directory, '-', 'system')
+ end
+
+ def old_directory
+ File.join(base_directory, 'system')
+ end
+
+ def base_directory
+ File.join(Rails.root, 'public', 'uploads')
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+end
diff --git a/db/migrate/20170717200542_add_trusted_column_to_oauth_applications.rb b/db/migrate/20170717200542_add_trusted_column_to_oauth_applications.rb
new file mode 100644
index 00000000000..1a013e6aefb
--- /dev/null
+++ b/db/migrate/20170717200542_add_trusted_column_to_oauth_applications.rb
@@ -0,0 +1,15 @@
+class AddTrustedColumnToOauthApplications < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:oauth_applications, :trusted, :boolean, default: false)
+ end
+
+ def down
+ remove_column(:oauth_applications, :trusted)
+ end
+end
diff --git a/db/migrate/20170720130522_create_ci_pipeline_variables.rb b/db/migrate/20170720130522_create_ci_pipeline_variables.rb
new file mode 100644
index 00000000000..a784f5dd142
--- /dev/null
+++ b/db/migrate/20170720130522_create_ci_pipeline_variables.rb
@@ -0,0 +1,20 @@
+class CreateCiPipelineVariables < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ create_table :ci_pipeline_variables do |t|
+ t.string :key, null: false
+ t.text :value
+ t.text :encrypted_value
+ t.string :encrypted_value_salt
+ t.string :encrypted_value_iv
+ t.integer :pipeline_id, null: false
+ end
+
+ add_index :ci_pipeline_variables, [:pipeline_id, :key], unique: true
+ end
+
+ def down
+ drop_table :ci_pipeline_variables
+ end
+end
diff --git a/db/migrate/20170720130749_add_foreign_key_to_ci_pipeline_variables.rb b/db/migrate/20170720130749_add_foreign_key_to_ci_pipeline_variables.rb
new file mode 100644
index 00000000000..550b8a88f02
--- /dev/null
+++ b/db/migrate/20170720130749_add_foreign_key_to_ci_pipeline_variables.rb
@@ -0,0 +1,15 @@
+class AddForeignKeyToCiPipelineVariables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key(:ci_pipeline_variables, :ci_pipelines, column: :pipeline_id)
+ end
+
+ def down
+ remove_foreign_key(:ci_pipeline_variables, column: :pipeline_id)
+ end
+end
diff --git a/db/migrate/20170724214302_add_lower_path_index_to_redirect_routes.rb b/db/migrate/20170724214302_add_lower_path_index_to_redirect_routes.rb
new file mode 100644
index 00000000000..db60c2087b9
--- /dev/null
+++ b/db/migrate/20170724214302_add_lower_path_index_to_redirect_routes.rb
@@ -0,0 +1,34 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddLowerPathIndexToRedirectRoutes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_on_redirect_routes_lower_path'
+
+ disable_ddl_transaction!
+
+ def up
+ return unless Gitlab::Database.postgresql?
+
+ execute "CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON redirect_routes (LOWER(path));"
+ end
+
+ def down
+ return unless Gitlab::Database.postgresql?
+
+ # Why not use remove_concurrent_index_by_name?
+ #
+ # `index_exists?` doesn't work on this index. Perhaps this is related to the
+ # fact that the index doesn't show up in the schema. And apparently it isn't
+ # trivial to write a query that checks for an index. BUT there is a
+ # convenient `IF EXISTS` parameter for `DROP INDEX`.
+ if supports_drop_index_concurrently?
+ disable_statement_timeout
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME};"
+ else
+ execute "DROP INDEX IF EXISTS #{INDEX_NAME};"
+ end
+ end
+end
diff --git a/db/migrate/20170725145659_add_binary_to_merge_request_diff_files.rb b/db/migrate/20170725145659_add_binary_to_merge_request_diff_files.rb
new file mode 100644
index 00000000000..1f5fa7e3d49
--- /dev/null
+++ b/db/migrate/20170725145659_add_binary_to_merge_request_diff_files.rb
@@ -0,0 +1,9 @@
+class AddBinaryToMergeRequestDiffFiles < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :merge_request_diff_files, :binary, :boolean
+ end
+end
diff --git a/db/migrate/20170727123534_add_index_on_events_project_id_id.rb b/db/migrate/20170727123534_add_index_on_events_project_id_id.rb
new file mode 100644
index 00000000000..1c4aaaf9dd6
--- /dev/null
+++ b/db/migrate/20170727123534_add_index_on_events_project_id_id.rb
@@ -0,0 +1,37 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexOnEventsProjectIdId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ COLUMNS = %i[project_id id].freeze
+ TABLES = %i[events events_for_migration].freeze
+
+ disable_ddl_transaction!
+
+ def up
+ TABLES.each do |table|
+ add_concurrent_index(table, COLUMNS) unless index_exists?(table, COLUMNS)
+
+ # We remove the index _after_ adding the new one since MySQL doesn't let
+ # you remove an index when a foreign key exists for the same column.
+ if index_exists?(table, :project_id)
+ remove_concurrent_index(table, :project_id)
+ end
+ end
+ end
+
+ def down
+ TABLES.each do |table|
+ unless index_exists?(table, :project_id)
+ add_concurrent_index(table, :project_id)
+ end
+
+ unless index_exists?(table, COLUMNS)
+ remove_concurrent_index(table, COLUMNS)
+ end
+ end
+ end
+end
diff --git a/db/migrate/20170731175128_add_percentages_to_conv_dev.rb b/db/migrate/20170731175128_add_percentages_to_conv_dev.rb
new file mode 100644
index 00000000000..1819bfc96bb
--- /dev/null
+++ b/db/migrate/20170731175128_add_percentages_to_conv_dev.rb
@@ -0,0 +1,32 @@
+class AddPercentagesToConvDev < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_column_with_default :conversational_development_index_metrics, :percentage_boards, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_ci_pipelines, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_deployments, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_environments, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_issues, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_merge_requests, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_milestones, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_notes, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_projects_prometheus_active, :float, allow_null: false, default: 0
+ add_column_with_default :conversational_development_index_metrics, :percentage_service_desk_issues, :float, allow_null: false, default: 0
+ end
+
+ def down
+ remove_column :conversational_development_index_metrics, :percentage_boards
+ remove_column :conversational_development_index_metrics, :percentage_ci_pipelines
+ remove_column :conversational_development_index_metrics, :percentage_deployments
+ remove_column :conversational_development_index_metrics, :percentage_environments
+ remove_column :conversational_development_index_metrics, :percentage_issues
+ remove_column :conversational_development_index_metrics, :percentage_merge_requests
+ remove_column :conversational_development_index_metrics, :percentage_milestones
+ remove_column :conversational_development_index_metrics, :percentage_notes
+ remove_column :conversational_development_index_metrics, :percentage_projects_prometheus_active
+ remove_column :conversational_development_index_metrics, :percentage_service_desk_issues
+ end
+end
diff --git a/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb b/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb
new file mode 100644
index 00000000000..a7d8f2f3604
--- /dev/null
+++ b/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb
@@ -0,0 +1,7 @@
+class AddMergeJidToMergeRequests < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :merge_requests, :merge_jid, :string
+ end
+end
diff --git a/db/migrate/20170803130232_reorganise_issues_indexes_for_faster_sorting.rb b/db/migrate/20170803130232_reorganise_issues_indexes_for_faster_sorting.rb
new file mode 100644
index 00000000000..eb7d1be1732
--- /dev/null
+++ b/db/migrate/20170803130232_reorganise_issues_indexes_for_faster_sorting.rb
@@ -0,0 +1,43 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ReorganiseIssuesIndexesForFasterSorting < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ REMOVE_INDEX_COLUMNS = %i[project_id created_at due_date updated_at].freeze
+
+ ADD_INDEX_COLUMNS = [
+ %i[project_id created_at id state],
+ %i[project_id due_date id state],
+ %i[project_id updated_at id state]
+ ].freeze
+
+ TABLE = :issues
+
+ def up
+ add_indexes(ADD_INDEX_COLUMNS)
+ remove_indexes(REMOVE_INDEX_COLUMNS)
+ end
+
+ def down
+ add_indexes(REMOVE_INDEX_COLUMNS)
+ remove_indexes(ADD_INDEX_COLUMNS)
+ end
+
+ def add_indexes(columns)
+ columns.each do |column|
+ add_concurrent_index(TABLE, column) unless index_exists?(TABLE, column)
+ end
+ end
+
+ def remove_indexes(columns)
+ columns.each do |column|
+ remove_concurrent_index(TABLE, column) if index_exists?(TABLE, column)
+ end
+ end
+end
diff --git a/db/migrate/20170809133343_add_broadcast_messages_index.rb b/db/migrate/20170809133343_add_broadcast_messages_index.rb
new file mode 100644
index 00000000000..4ab2ddb059d
--- /dev/null
+++ b/db/migrate/20170809133343_add_broadcast_messages_index.rb
@@ -0,0 +1,21 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddBroadcastMessagesIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ COLUMNS = %i[starts_at ends_at id].freeze
+
+ def up
+ add_concurrent_index :broadcast_messages, COLUMNS
+ end
+
+ def down
+ remove_concurrent_index :broadcast_messages, COLUMNS
+ end
+end
diff --git a/db/migrate/20170809134534_add_broadcast_message_not_null_constraints.rb b/db/migrate/20170809134534_add_broadcast_message_not_null_constraints.rb
new file mode 100644
index 00000000000..5551fb51a6e
--- /dev/null
+++ b/db/migrate/20170809134534_add_broadcast_message_not_null_constraints.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 AddBroadcastMessageNotNullConstraints < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ COLUMNS = %i[starts_at ends_at created_at updated_at message_html]
+
+ class BroadcastMessage < ActiveRecord::Base
+ self.table_name = 'broadcast_messages'
+ end
+
+ def up
+ COLUMNS.each do |column|
+ BroadcastMessage.where(column => nil).delete_all
+
+ change_column_null :broadcast_messages, column, false
+ end
+ end
+
+ def down
+ COLUMNS.each do |column|
+ change_column_null :broadcast_messages, column, true
+ end
+ end
+end
diff --git a/db/migrate/20170809142252_cleanup_appearances_schema.rb b/db/migrate/20170809142252_cleanup_appearances_schema.rb
new file mode 100644
index 00000000000..90d12925ba2
--- /dev/null
+++ b/db/migrate/20170809142252_cleanup_appearances_schema.rb
@@ -0,0 +1,33 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanupAppearancesSchema < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ NOT_NULL_COLUMNS = %i[title description description_html created_at updated_at]
+
+ TIME_COLUMNS = %i[created_at updated_at]
+
+ def up
+ NOT_NULL_COLUMNS.each do |column|
+ change_column_null :appearances, column, false
+ end
+
+ TIME_COLUMNS.each do |column|
+ change_column :appearances, column, :datetime_with_timezone
+ end
+ end
+
+ def down
+ NOT_NULL_COLUMNS.each do |column|
+ change_column_null :appearances, column, true
+ end
+
+ TIME_COLUMNS.each do |column|
+ change_column :appearances, column, :datetime # rubocop: disable Migration/Datetime
+ end
+ end
+end
diff --git a/db/migrate/20170809161910_add_project_export_enabled_to_application_settings.rb b/db/migrate/20170809161910_add_project_export_enabled_to_application_settings.rb
new file mode 100644
index 00000000000..4baba1ade6d
--- /dev/null
+++ b/db/migrate/20170809161910_add_project_export_enabled_to_application_settings.rb
@@ -0,0 +1,14 @@
+class AddProjectExportEnabledToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_column_with_default(:application_settings, :project_export_enabled, :boolean, default: true)
+ end
+
+ def down
+ remove_column(:application_settings, :project_export_enabled)
+ end
+end
diff --git a/db/migrate/markdown_cache_limits_to_mysql.rb b/db/migrate/markdown_cache_limits_to_mysql.rb
new file mode 100644
index 00000000000..f6686db3dc0
--- /dev/null
+++ b/db/migrate/markdown_cache_limits_to_mysql.rb
@@ -0,0 +1,13 @@
+class MarkdownCacheLimitsToMysql < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ return unless Gitlab::Database.mysql?
+
+ change_column :snippets, :content_html, :text, limit: 2147483647
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/migrate/merge_request_diff_file_limits_to_mysql.rb b/db/migrate/merge_request_diff_file_limits_to_mysql.rb
new file mode 100644
index 00000000000..3958380e4b9
--- /dev/null
+++ b/db/migrate/merge_request_diff_file_limits_to_mysql.rb
@@ -0,0 +1,12 @@
+class MergeRequestDiffFileLimitsToMysql < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ return unless Gitlab::Database.mysql?
+
+ change_column :merge_request_diff_files, :diff, :text, limit: 2147483647
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20161109150329_fix_project_records_with_invalid_visibility.rb b/db/post_migrate/20161109150329_fix_project_records_with_invalid_visibility.rb
index 14b5ef476f0..69007b8e8ed 100644
--- a/db/post_migrate/20161109150329_fix_project_records_with_invalid_visibility.rb
+++ b/db/post_migrate/20161109150329_fix_project_records_with_invalid_visibility.rb
@@ -13,13 +13,13 @@ class FixProjectRecordsWithInvalidVisibility < ActiveRecord::Migration
namespaces = Arel::Table.new(:namespaces)
finder_sql =
- projects.
- join(namespaces, Arel::Nodes::InnerJoin).
- on(projects[:namespace_id].eq(namespaces[:id])).
- where(projects[:visibility_level].gt(namespaces[:visibility_level])).
- project(projects[:id], namespaces[:visibility_level]).
- take(BATCH_SIZE).
- to_sql
+ projects
+ .join(namespaces, Arel::Nodes::InnerJoin)
+ .on(projects[:namespace_id].eq(namespaces[:id]))
+ .where(projects[:visibility_level].gt(namespaces[:visibility_level]))
+ .project(projects[:id], namespaces[:visibility_level])
+ .take(BATCH_SIZE)
+ .to_sql
# Update matching rows in batches. Each batch can cause up to 3 UPDATE
# statements, in addition to the SELECT: one per visibility_level
@@ -33,10 +33,10 @@ class FixProjectRecordsWithInvalidVisibility < ActiveRecord::Migration
end
updates.each do |visibility_level, project_ids|
- updater = Arel::UpdateManager.new(ActiveRecord::Base).
- table(projects).
- set(projects[:visibility_level] => visibility_level).
- where(projects[:id].in(project_ids))
+ updater = Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(projects)
+ .set(projects[:visibility_level] => visibility_level)
+ .where(projects[:id].in(project_ids))
ActiveRecord::Base.connection.exec_update(updater.to_sql, self.class.name, [])
end
diff --git a/db/post_migrate/20161221153951_rename_reserved_project_names.rb b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
index 49a6bc884a8..d322844e2fd 100644
--- a/db/post_migrate/20161221153951_rename_reserved_project_names.rb
+++ b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
@@ -79,17 +79,17 @@ class RenameReservedProjectNames < ActiveRecord::Migration
private
def reserved_projects
- Project.unscoped.
- includes(:namespace).
- where('EXISTS (SELECT 1 FROM namespaces WHERE projects.namespace_id = namespaces.id)').
- where('projects.path' => KNOWN_PATHS)
+ Project.unscoped
+ .includes(:namespace)
+ .where('EXISTS (SELECT 1 FROM namespaces WHERE projects.namespace_id = namespaces.id)')
+ .where('projects.path' => KNOWN_PATHS)
end
def route_exists?(full_path)
quoted_path = ActiveRecord::Base.connection.quote_string(full_path)
- ActiveRecord::Base.connection.
- select_all("SELECT id, path FROM routes WHERE path = '#{quoted_path}'").present?
+ ActiveRecord::Base.connection
+ .select_all("SELECT id, path FROM routes WHERE path = '#{quoted_path}'").present?
end
# Adds number to the end of the path that is not taken by other route
diff --git a/db/post_migrate/20170104150317_requeue_pending_delete_projects.rb b/db/post_migrate/20170104150317_requeue_pending_delete_projects.rb
index f399950bd5e..d7be004d47f 100644
--- a/db/post_migrate/20170104150317_requeue_pending_delete_projects.rb
+++ b/db/post_migrate/20170104150317_requeue_pending_delete_projects.rb
@@ -39,11 +39,11 @@ class RequeuePendingDeleteProjects < ActiveRecord::Migration
def find_batch
projects = Arel::Table.new(:projects)
- projects.project(projects[:id]).
- where(projects[:pending_delete].eq(true)).
- where(projects[:namespace_id].not_eq(nil)).
- skip(@offset * BATCH_SIZE).
- take(BATCH_SIZE).
- to_sql
+ projects.project(projects[:id])
+ .where(projects[:pending_delete].eq(true))
+ .where(projects[:namespace_id].not_eq(nil))
+ .skip(@offset * BATCH_SIZE)
+ .take(BATCH_SIZE)
+ .to_sql
end
end
diff --git a/db/post_migrate/20170106142508_fill_authorized_projects.rb b/db/post_migrate/20170106142508_fill_authorized_projects.rb
index 314c8440c8b..0ca20587981 100644
--- a/db/post_migrate/20170106142508_fill_authorized_projects.rb
+++ b/db/post_migrate/20170106142508_fill_authorized_projects.rb
@@ -15,8 +15,8 @@ class FillAuthorizedProjects < ActiveRecord::Migration
disable_ddl_transaction!
def up
- relation = User.select(:id).
- where('authorized_projects_populated IS NOT TRUE')
+ relation = User.select(:id)
+ .where('authorized_projects_populated IS NOT TRUE')
relation.find_in_batches(batch_size: 1_000) do |rows|
args = rows.map { |row| [row.id] }
diff --git a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb
index b518038e93a..82f8147547e 100644
--- a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb
+++ b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb
@@ -1,6 +1,4 @@
-# See http://doc.gitlab.com/ce/development/migration_style_guide.html
-# for more information on how to write migrations for GitLab.
-
+# rubocop:disable Migration/UpdateColumnInBatches
class ResetUsersAuthorizedProjectsPopulated < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
index b61dd7cfc61..01fff680183 100644
--- a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
+++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb
@@ -1,11 +1,11 @@
-# See http://doc.gitlab.com/ce/development/migration_style_guide.html
-# for more information on how to write migrations for GitLab.
-
+# rubocop:disable Migration/UpdateColumnInBatches
class ResetRelativePositionForIssue < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:issues, :relative_position, nil) do |table, query|
query.where(table[:relative_position].not_eq(nil))
@@ -13,5 +13,6 @@ class ResetRelativePositionForIssue < ActiveRecord::Migration
end
def down
+ # noop
end
end
diff --git a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
index 44c688fa134..6a49450cc50 100644
--- a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
+++ b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
@@ -21,17 +21,17 @@ class RenameMoreReservedProjectNames < ActiveRecord::Migration
private
def reserved_projects
- Project.unscoped.
- includes(:namespace).
- where('EXISTS (SELECT 1 FROM namespaces WHERE projects.namespace_id = namespaces.id)').
- where('projects.path' => KNOWN_PATHS)
+ Project.unscoped
+ .includes(:namespace)
+ .where('EXISTS (SELECT 1 FROM namespaces WHERE projects.namespace_id = namespaces.id)')
+ .where('projects.path' => KNOWN_PATHS)
end
def route_exists?(full_path)
quoted_path = ActiveRecord::Base.connection.quote_string(full_path)
- ActiveRecord::Base.connection.
- select_all("SELECT id, path FROM routes WHERE path = '#{quoted_path}'").present?
+ ActiveRecord::Base.connection
+ .select_all("SELECT id, path FROM routes WHERE path = '#{quoted_path}'").present?
end
# Adds number to the end of the path that is not taken by other route
diff --git a/db/post_migrate/20170317162059_update_upload_paths_to_system.rb b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
new file mode 100644
index 00000000000..92e33848bf0
--- /dev/null
+++ b/db/post_migrate/20170317162059_update_upload_paths_to_system.rb
@@ -0,0 +1,57 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class UpdateUploadPathsToSystem < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ AFFECTED_MODELS = %w(User Project Note Namespace Appearance)
+
+ disable_ddl_transaction!
+
+ def up
+ update_column_in_batches(:uploads, :path, replace_sql(arel_table[:path], base_directory, new_upload_dir)) do |_table, query|
+ query.where(uploads_to_switch_to_new_path)
+ end
+ end
+
+ def down
+ update_column_in_batches(:uploads, :path, replace_sql(arel_table[:path], new_upload_dir, base_directory)) do |_table, query|
+ query.where(uploads_to_switch_to_old_path)
+ end
+ end
+
+ # "SELECT \"uploads\".* FROM \"uploads\" WHERE \"uploads\".\"model_type\" IN ('User', 'Project', 'Note', 'Namespace', 'Appearance') AND (\"uploads\".\"path\" ILIKE 'uploads/%' AND NOT (\"uploads\".\"path\" ILIKE 'uploads/system/%'))"
+ def uploads_to_switch_to_new_path
+ affected_uploads.and(starting_with_base_directory).and(starting_with_new_upload_directory.not)
+ end
+
+ # "SELECT \"uploads\".* FROM \"uploads\" WHERE \"uploads\".\"model_type\" IN ('User', 'Project', 'Note', 'Namespace', 'Appearance') AND (\"uploads\".\"path\" ILIKE 'uploads/%' AND \"uploads\".\"path\" ILIKE 'uploads/system/%')"
+ def uploads_to_switch_to_old_path
+ affected_uploads.and(starting_with_new_upload_directory)
+ end
+
+ def starting_with_base_directory
+ arel_table[:path].matches("#{base_directory}/%")
+ end
+
+ def starting_with_new_upload_directory
+ arel_table[:path].matches("#{new_upload_dir}/%")
+ end
+
+ def affected_uploads
+ arel_table[:model_type].in(AFFECTED_MODELS)
+ end
+
+ def base_directory
+ "uploads"
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "-", "system")
+ end
+
+ def arel_table
+ Arel::Table.new(:uploads)
+ end
+end
diff --git a/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb b/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb
index 9ad36482c8a..cb1b4f1855d 100644
--- a/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb
+++ b/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb
@@ -38,11 +38,11 @@ class MigrateUserActivitiesToUsersLastActivityOn < ActiveRecord::Migration
activities = activities(day.at_beginning_of_day, day.at_end_of_day, page: page)
update_sql =
- Arel::UpdateManager.new(ActiveRecord::Base).
- table(users_table).
- set(users_table[:last_activity_on] => day.to_date).
- where(users_table[:username].in(activities.map(&:first))).
- to_sql
+ Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(users_table)
+ .set(users_table[:last_activity_on] => day.to_date)
+ .where(users_table[:username].in(activities.map(&:first)))
+ .to_sql
connection.exec_update(update_sql, self.class.name, [])
@@ -56,7 +56,7 @@ class MigrateUserActivitiesToUsersLastActivityOn < ActiveRecord::Migration
end
def activities(from, to, page: 1)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.zrangebyscore(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i,
with_scores: true,
limit: limit(page))
@@ -64,7 +64,7 @@ class MigrateUserActivitiesToUsersLastActivityOn < ActiveRecord::Migration
end
def activities_count(from, to)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.zcount(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i)
end
end
diff --git a/db/post_migrate/20170406111121_clean_upload_symlinks.rb b/db/post_migrate/20170406111121_clean_upload_symlinks.rb
new file mode 100644
index 00000000000..f2ce25d4524
--- /dev/null
+++ b/db/post_migrate/20170406111121_clean_upload_symlinks.rb
@@ -0,0 +1,52 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanUploadSymlinks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORIES_TO_MOVE = %w(user project note group appearance)
+
+ def up
+ return unless file_storage?
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ symlink_location = File.join(old_upload_dir, dir)
+ next unless File.symlink?(symlink_location)
+ say "removing symlink: #{symlink_location}"
+ FileUtils.rm(symlink_location)
+ end
+ end
+
+ def down
+ return unless file_storage?
+
+ DIRECTORIES_TO_MOVE.each do |dir|
+ symlink = File.join(old_upload_dir, dir)
+ destination = File.join(new_upload_dir, dir)
+
+ next if File.directory?(symlink)
+ next unless File.directory?(destination)
+
+ say "Creating symlink #{symlink} -> #{destination}"
+ FileUtils.ln_s(destination, symlink)
+ end
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "-", "system")
+ end
+end
diff --git a/db/post_migrate/20170406142253_migrate_user_project_view.rb b/db/post_migrate/20170406142253_migrate_user_project_view.rb
index 22f0f2ac200..c4e910b3b44 100644
--- a/db/post_migrate/20170406142253_migrate_user_project_view.rb
+++ b/db/post_migrate/20170406142253_migrate_user_project_view.rb
@@ -7,6 +7,8 @@ class MigrateUserProjectView < ActiveRecord::Migration
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
+ disable_ddl_transaction!
+
def up
update_column_in_batches(:users, :project_view, 2) do |table, query|
query.where(table[:project_view].eq(0))
diff --git a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb
new file mode 100644
index 00000000000..08cf366f0a1
--- /dev/null
+++ b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb
@@ -0,0 +1,62 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RenameReservedDynamicPaths < ActiveRecord::Migration
+ include Gitlab::Database::RenameReservedPathsMigration::V1
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ DISALLOWED_ROOT_PATHS = %w[
+ -
+ abuse_reports
+ api
+ autocomplete
+ explore
+ health_check
+ import
+ invites
+ jwt
+ koding
+ member
+ notification_settings
+ oauth
+ sent_notifications
+ unicorn_test
+ uploads
+ users
+ ]
+
+ DISALLOWED_WILDCARD_PATHS = %w[
+ environments/folders
+ gitlab-lfs/objects
+ info/lfs/objects
+ ]
+
+ DISSALLOWED_GROUP_PATHS = %w[
+ activity
+ analytics
+ audit_events
+ avatar
+ group_members
+ hooks
+ labels
+ ldap
+ ldap_group_links
+ milestones
+ notification_setting
+ pipeline_quota
+ subgroups
+ ]
+
+ def up
+ rename_root_paths(DISALLOWED_ROOT_PATHS)
+ rename_wildcard_paths(DISALLOWED_WILDCARD_PATHS)
+ rename_child_paths(DISSALLOWED_GROUP_PATHS)
+ end
+
+ def down
+ # nothing to do
+ end
+end
diff --git a/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb b/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb
new file mode 100644
index 00000000000..dae9750558f
--- /dev/null
+++ b/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb
@@ -0,0 +1,48 @@
+class MigrateTriggerSchedulesToPipelineSchedules < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ connection.execute <<~SQL
+ DELETE FROM ci_trigger_schedules WHERE NOT EXISTS
+ (SELECT true FROM projects
+ WHERE ci_trigger_schedules.project_id = projects.id
+ )
+ SQL
+
+ connection.execute <<-SQL
+ INSERT INTO ci_pipeline_schedules (
+ project_id,
+ created_at,
+ updated_at,
+ deleted_at,
+ cron,
+ cron_timezone,
+ next_run_at,
+ ref,
+ active,
+ owner_id,
+ description
+ )
+ SELECT
+ ci_trigger_schedules.project_id,
+ ci_trigger_schedules.created_at,
+ ci_trigger_schedules.updated_at,
+ ci_trigger_schedules.deleted_at,
+ ci_trigger_schedules.cron,
+ ci_trigger_schedules.cron_timezone,
+ ci_trigger_schedules.next_run_at,
+ ci_trigger_schedules.ref,
+ ci_trigger_schedules.active,
+ ci_triggers.owner_id,
+ ci_triggers.description
+ FROM ci_trigger_schedules
+ INNER JOIN ci_triggers ON ci_trigger_schedules.trigger_id=ci_triggers.id;
+ SQL
+ end
+
+ def down
+ # no op as the data has been removed
+ end
+end
diff --git a/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb b/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb
new file mode 100644
index 00000000000..159b533eaaa
--- /dev/null
+++ b/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb
@@ -0,0 +1,33 @@
+# rubocop:disable Migration/Datetime
+class DropCiTriggerSchedulesTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ drop_table :ci_trigger_schedules
+ end
+
+ def down
+ create_table "ci_trigger_schedules", force: :cascade do |t|
+ t.integer "project_id"
+ t.integer "trigger_id", null: false
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "cron"
+ t.string "cron_timezone"
+ t.datetime "next_run_at"
+ t.string "ref"
+ t.boolean "active"
+ end
+
+ add_index "ci_trigger_schedules", %w(active next_run_at), name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree
+ add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree
+ add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at"
+
+ add_concurrent_foreign_key "ci_trigger_schedules", "ci_triggers", column: :trigger_id, on_delete: :cascade
+ end
+end
diff --git a/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb
new file mode 100644
index 00000000000..765daa0a347
--- /dev/null
+++ b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb
@@ -0,0 +1,18 @@
+# rubocop:disable Migration/UpdateColumnInBatches
+class EnableAutoCancelPendingPipelinesForAll < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ disable_statement_timeout
+
+ update_column_in_batches(:projects, :auto_cancel_pending_pipelines, 1)
+ end
+
+ def down
+ # Nothing we can do!
+ end
+end
diff --git a/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb
new file mode 100644
index 00000000000..5238a2ba1b7
--- /dev/null
+++ b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb
@@ -0,0 +1,47 @@
+# This is the counterpart of RequeuePendingDeleteProjects and cleans all
+# projects with `pending_delete = true` and that do not have a namespace.
+class CleanupNamespacelessPendingDeleteProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ @offset = 0
+
+ loop do
+ ids = pending_delete_batch
+
+ break if ids.empty?
+
+ args = ids.map { |id| Array(id) }
+
+ NamespacelessProjectDestroyWorker.bulk_perform_async(args)
+
+ @offset += 1
+ end
+ end
+
+ def down
+ # noop
+ end
+
+ private
+
+ def pending_delete_batch
+ connection.exec_query(find_batch).map { |row| row['id'].to_i }
+ end
+
+ BATCH_SIZE = 5000
+
+ def find_batch
+ projects = Arel::Table.new(:projects)
+ projects.project(projects[:id])
+ .where(projects[:pending_delete].eq(true))
+ .where(projects[:namespace_id].eq(nil))
+ .skip(@offset * BATCH_SIZE)
+ .take(BATCH_SIZE)
+ .to_sql
+ end
+end
diff --git a/db/post_migrate/20170503004427_update_retried_for_ci_build.rb b/db/post_migrate/20170503004427_update_retried_for_ci_build.rb
new file mode 100644
index 00000000000..3a4d6c4916b
--- /dev/null
+++ b/db/post_migrate/20170503004427_update_retried_for_ci_build.rb
@@ -0,0 +1,68 @@
+class UpdateRetriedForCiBuild < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ disable_statement_timeout
+
+ if Gitlab::Database.mysql?
+ up_mysql
+ else
+ up_postgres
+ end
+ end
+
+ def down
+ end
+
+ private
+
+ def up_mysql
+ # This is a trick to overcome MySQL limitation:
+ # Mysql2::Error: Table 'ci_builds' is specified twice, both as a target for 'UPDATE' and as a separate source for data
+ # However, this leads to create a temporary table from `max(ci_builds.id)` which is slow and do full database update
+ execute <<-SQL.strip_heredoc
+ UPDATE ci_builds SET retried=
+ (id NOT IN (
+ SELECT * FROM (SELECT MAX(ci_builds.id) FROM ci_builds GROUP BY commit_id, name) AS latest_jobs
+ ))
+ WHERE retried IS NULL
+ SQL
+ end
+
+ def up_postgres
+ with_temporary_partial_index do
+ latest_id = <<-SQL.strip_heredoc
+ SELECT MAX(ci_builds2.id)
+ FROM ci_builds ci_builds2
+ WHERE ci_builds.commit_id=ci_builds2.commit_id
+ AND ci_builds.name=ci_builds2.name
+ SQL
+
+ # This is slow update as it does single-row query
+ # This is designed to be run as idle, or a post deployment migration
+ is_retried = Arel.sql("((#{latest_id}) != ci_builds.id)")
+
+ update_column_in_batches(:ci_builds, :retried, is_retried) do |table, query|
+ query.where(table[:retried].eq(nil))
+ end
+ end
+ end
+
+ def with_temporary_partial_index
+ if Gitlab::Database.postgresql?
+ unless index_exists?(:ci_builds, name: :index_for_ci_builds_retried_migration)
+ execute 'CREATE INDEX CONCURRENTLY index_for_ci_builds_retried_migration ON ci_builds (id) WHERE retried IS NULL;'
+ end
+ end
+
+ yield
+
+ if Gitlab::Database.postgresql? && index_exists?(:ci_builds, name: :index_for_ci_builds_retried_migration)
+ execute 'DROP INDEX CONCURRENTLY index_for_ci_builds_retried_migration'
+ end
+ end
+end
diff --git a/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb b/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb
new file mode 100644
index 00000000000..1b44334395f
--- /dev/null
+++ b/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb
@@ -0,0 +1,15 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveUsersAuthorizedProjectsPopulated < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def change
+ remove_column :users, :authorized_projects_populated, :boolean
+ end
+end
diff --git a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb
new file mode 100644
index 00000000000..f77078ddd70
--- /dev/null
+++ b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb
@@ -0,0 +1,27 @@
+class AddHeadPipelineForEachMergeRequest < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ disable_statement_timeout
+
+ pipelines = Arel::Table.new(:ci_pipelines)
+ merge_requests = Arel::Table.new(:merge_requests)
+
+ head_id = pipelines
+ .project(Arel::Nodes::NamedFunction.new('max', [pipelines[:id]]))
+ .from(pipelines)
+ .where(pipelines[:ref].eq(merge_requests[:source_branch]))
+ .where(pipelines[:project_id].eq(merge_requests[:source_project_id]))
+
+ sub_query = Arel::Nodes::SqlLiteral.new(Arel::Nodes::Grouping.new(head_id).to_sql)
+
+ update_column_in_batches(:merge_requests, :head_pipeline_id, sub_query)
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb
new file mode 100644
index 00000000000..6a870f08e89
--- /dev/null
+++ b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb
@@ -0,0 +1,35 @@
+class AddForeignKeyOnPipelineScheduleOwner < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ execute <<-SQL
+ UPDATE ci_pipeline_schedules
+ SET owner_id = NULL
+ WHERE NOT EXISTS (
+ SELECT true
+ FROM users
+ WHERE ci_pipeline_schedules.owner_id = users.id
+ )
+ SQL
+
+ add_concurrent_foreign_key(:ci_pipeline_schedules, :users, column: :owner_id, on_delete: on_delete)
+ end
+
+ def down
+ remove_foreign_key(:ci_pipeline_schedules, column: :owner_id)
+ end
+
+ private
+
+ def on_delete
+ if Gitlab::Database.mysql?
+ :nullify
+ else
+ 'SET NULL'
+ end
+ end
+end
diff --git a/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb
new file mode 100644
index 00000000000..281be90163a
--- /dev/null
+++ b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.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 CleanupRenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :web_hooks, :build_events, :job_events
+ end
+
+ def down
+ rename_column_concurrently :web_hooks, :job_events, :build_events
+ end
+end
diff --git a/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb
new file mode 100644
index 00000000000..5d26df5688f
--- /dev/null
+++ b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.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 CleanupRenameServicesBuildEventsToJobEvents < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :services, :build_events, :job_events
+ end
+
+ def down
+ rename_column_concurrently :services, :job_events, :build_events
+ end
+end
diff --git a/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb b/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb
new file mode 100644
index 00000000000..378fe5603c3
--- /dev/null
+++ b/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb
@@ -0,0 +1,39 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanupTriggerForIssues < 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", "remove_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" or "remove_concurrent_index" methods make sure
+ # that either of them 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 or removing fails
+ # and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ disable_ddl_transaction!
+
+ def up
+ if Gitlab::Database.postgresql?
+ execute <<-EOF
+ DROP TRIGGER IF EXISTS replicate_assignee_id ON issues;
+ DROP FUNCTION IF EXISTS replicate_assignee_id();
+ EOF
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb b/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb
new file mode 100644
index 00000000000..6fa573c5b49
--- /dev/null
+++ b/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb
@@ -0,0 +1,37 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddConstraintsToIssueAssigneesTable < 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", "remove_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" or "remove_concurrent_index" methods make sure
+ # that either of them 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 or removing fails
+ # and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def up
+ change_column_null :issue_assignees, :issue_id, false
+ change_column_null :issue_assignees, :user_id, false
+ end
+
+ def down
+ change_column_null :issue_assignees, :issue_id, true
+ change_column_null :issue_assignees, :user_id, true
+ end
+end
diff --git a/db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb b/db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb
new file mode 100644
index 00000000000..da0fcda87a6
--- /dev/null
+++ b/db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb
@@ -0,0 +1,50 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RenameUsersWithRenamedNamespace < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ DISALLOWED_ROOT_PATHS = %w[
+ abuse_reports
+ api
+ autocomplete
+ explore
+ health_check
+ import
+ invites
+ jwt
+ koding
+ member
+ notification_settings
+ oauth
+ sent_notifications
+ unicorn_test
+ uploads
+ users
+ ]
+
+ def up
+ DISALLOWED_ROOT_PATHS.each do |path|
+ users = Arel::Table.new(:users)
+ namespaces = Arel::Table.new(:namespaces)
+ predicate = namespaces[:owner_id].eq(users[:id])
+ .and(namespaces[:type].eq(nil))
+ .and(users[:username].matches(path))
+ update_sql = if Gitlab::Database.postgresql?
+ "UPDATE users SET username = namespaces.path "\
+ "FROM namespaces WHERE #{predicate.to_sql}"
+ else
+ "UPDATE users INNER JOIN namespaces "\
+ "ON namespaces.owner_id = users.id "\
+ "SET username = namespaces.path "\
+ "WHERE #{predicate.to_sql}"
+ end
+
+ connection.execute(update_sql)
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170518231126_fix_wrongly_renamed_routes.rb b/db/post_migrate/20170518231126_fix_wrongly_renamed_routes.rb
new file mode 100644
index 00000000000..c78beda9d21
--- /dev/null
+++ b/db/post_migrate/20170518231126_fix_wrongly_renamed_routes.rb
@@ -0,0 +1,104 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class FixWronglyRenamedRoutes < ActiveRecord::Migration
+ include Gitlab::Database::RenameReservedPathsMigration::V1
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ DISALLOWED_ROOT_PATHS = %w[
+ -
+ abuse_reports
+ api
+ autocomplete
+ explore
+ health_check
+ import
+ invites
+ jwt
+ koding
+ member
+ notification_settings
+ oauth
+ sent_notifications
+ unicorn_test
+ uploads
+ users
+ ]
+
+ FIXED_PATHS = DISALLOWED_ROOT_PATHS.map { |p| "#{p}0" }
+
+ class Route < Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Route
+ self.table_name = 'routes'
+ end
+
+ def routes
+ @routes ||= Route.arel_table
+ end
+
+ def namespaces
+ @namespaces ||= Arel::Table.new(:namespaces)
+ end
+
+ def wildcard_collection(collection)
+ collection.map { |word| "#{word}%" }
+ end
+
+ # The routes that got incorrectly renamed before, still have a namespace that
+ # contains the correct path.
+ # This query fetches all rows from the `routes` table that meet the following
+ # conditions using `api` as an example:
+ # - route.path ILIKE `api0%`
+ # - route.source_type = `Namespace`
+ # - namespace.parent_id IS NULL
+ # - namespace.path ILIKE `api%`
+ # - NOT(namespace.path ILIKE `api0%`)
+ # This gives us all root-routes, that were renamed, but their namespace was not.
+ #
+ def wrongly_renamed
+ Route.joins("INNER JOIN namespaces ON routes.source_id = namespaces.id")
+ .where(
+ routes[:source_type].eq('Namespace')
+ .and(namespaces[:parent_id].eq(nil))
+ )
+ .where(namespaces[:path].matches_any(wildcard_collection(DISALLOWED_ROOT_PATHS)))
+ .where.not(namespaces[:path].matches_any(wildcard_collection(FIXED_PATHS)))
+ .where(routes[:path].matches_any(wildcard_collection(FIXED_PATHS)))
+ end
+
+ # Using the query above, we just fetch the `route.path` & the `namespace.path`
+ # `route.path` is the part of the route that is now incorrect
+ # `namespace.path` is what it should be
+ # We can use `route.path` to find all the namespaces that need to be fixed
+ # And we can use `namespace.path` to apply the correct name.
+ #
+ def paths_and_corrections
+ connection.select_all(
+ wrongly_renamed.select(routes[:path], namespaces[:path].as('namespace_path')).to_sql
+ )
+ end
+
+ # This can be used to limit the `update_in_batches` call to all routes for a
+ # single namespace, note the `/` that's what went wrong in the initial migration.
+ #
+ def routes_in_namespace_query(namespace)
+ routes[:path].matches_any([namespace, "#{namespace}/%"])
+ end
+
+ def up
+ paths_and_corrections.each do |root_namespace|
+ wrong_path = root_namespace['path']
+ correct_path = root_namespace['namespace_path']
+ replace_statement = replace_sql(Route.arel_table[:path], wrong_path, correct_path)
+
+ update_column_in_batches(:routes, :path, replace_statement) do |table, query|
+ query.where(routes_in_namespace_query(wrong_path))
+ end
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170523083112_migrate_old_artifacts.rb b/db/post_migrate/20170523083112_migrate_old_artifacts.rb
new file mode 100644
index 00000000000..3a77b9751d3
--- /dev/null
+++ b/db/post_migrate/20170523083112_migrate_old_artifacts.rb
@@ -0,0 +1,72 @@
+class MigrateOldArtifacts < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ # This uses special heuristic to find potential candidates for data migration
+ # Read more about this here: https://gitlab.com/gitlab-org/gitlab-ce/issues/32036#note_30422345
+
+ def up
+ builds_with_artifacts.find_each do |build|
+ build.migrate_artifacts!
+ end
+ end
+
+ def down
+ end
+
+ private
+
+ def builds_with_artifacts
+ Build.with_artifacts
+ .joins('JOIN projects ON projects.id = ci_builds.project_id')
+ .where('ci_builds.id < ?', min_id)
+ .where('projects.ci_id IS NOT NULL')
+ .select('id', 'created_at', 'project_id', 'projects.ci_id AS ci_id')
+ end
+
+ def min_id
+ Build.joins('JOIN projects ON projects.id = ci_builds.project_id')
+ .where('projects.ci_id IS NULL')
+ .pluck('coalesce(min(ci_builds.id), 0)')
+ .first
+ end
+
+ class Build < ActiveRecord::Base
+ self.table_name = 'ci_builds'
+
+ scope :with_artifacts, -> { where.not(artifacts_file: [nil, '']) }
+
+ def migrate_artifacts!
+ return unless File.exist?(source_artifacts_path)
+ return if File.exist?(target_artifacts_path)
+
+ ensure_target_path
+
+ FileUtils.move(source_artifacts_path, target_artifacts_path)
+ end
+
+ private
+
+ def source_artifacts_path
+ @source_artifacts_path ||=
+ File.join(Gitlab.config.artifacts.path,
+ created_at.utc.strftime('%Y_%m'),
+ ci_id.to_s, id.to_s)
+ end
+
+ def target_artifacts_path
+ @target_artifacts_path ||=
+ File.join(Gitlab.config.artifacts.path,
+ created_at.utc.strftime('%Y_%m'),
+ project_id.to_s, id.to_s)
+ end
+
+ def ensure_target_path
+ directory = File.dirname(target_artifacts_path)
+ FileUtils.mkdir_p(directory) unless Dir.exist?(directory)
+ end
+ end
+end
diff --git a/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb b/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb
new file mode 100644
index 00000000000..9441b236c8d
--- /dev/null
+++ b/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb
@@ -0,0 +1,113 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RenameAllReservedPathsAgain < ActiveRecord::Migration
+ include Gitlab::Database::RenameReservedPathsMigration::V1
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ TOP_LEVEL_ROUTES = %w[
+ -
+ .well-known
+ abuse_reports
+ admin
+ all
+ api
+ assets
+ autocomplete
+ ci
+ dashboard
+ explore
+ files
+ groups
+ health_check
+ help
+ hooks
+ import
+ invites
+ issues
+ jwt
+ koding
+ member
+ merge_requests
+ new
+ notes
+ notification_settings
+ oauth
+ profile
+ projects
+ public
+ repository
+ robots.txt
+ s
+ search
+ sent_notifications
+ services
+ snippets
+ teams
+ u
+ unicorn_test
+ unsubscribes
+ uploads
+ users
+ ].freeze
+
+ PROJECT_WILDCARD_ROUTES = %w[
+ badges
+ blame
+ blob
+ builds
+ commits
+ create
+ create_dir
+ edit
+ environments/folders
+ files
+ find_file
+ gitlab-lfs/objects
+ info/lfs/objects
+ new
+ preview
+ raw
+ refs
+ tree
+ update
+ wikis
+ ].freeze
+
+ GROUP_ROUTES = %w[
+ activity
+ analytics
+ audit_events
+ avatar
+ edit
+ group_members
+ hooks
+ issues
+ labels
+ ldap
+ ldap_group_links
+ merge_requests
+ milestones
+ notification_setting
+ pipeline_quota
+ projects
+ subgroups
+ ].freeze
+
+ def up
+ disable_statement_timeout
+
+ TOP_LEVEL_ROUTES.each { |route| rename_root_paths(route) }
+ PROJECT_WILDCARD_ROUTES.each { |route| rename_wildcard_paths(route) }
+ GROUP_ROUTES.each { |route| rename_child_paths(route) }
+ end
+
+ def down
+ disable_statement_timeout
+
+ revert_renames
+ end
+end
diff --git a/db/post_migrate/20170526185842_migrate_pipeline_stages.rb b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb
new file mode 100644
index 00000000000..afd4db183c2
--- /dev/null
+++ b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb
@@ -0,0 +1,22 @@
+class MigratePipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ disable_statement_timeout
+
+ execute <<-SQL.strip_heredoc
+ INSERT INTO ci_stages (project_id, pipeline_id, name)
+ SELECT project_id, commit_id, stage FROM ci_builds
+ WHERE stage IS NOT NULL
+ AND stage_id IS NULL
+ AND EXISTS (SELECT 1 FROM projects WHERE projects.id = ci_builds.project_id)
+ AND EXISTS (SELECT 1 FROM ci_pipelines WHERE ci_pipelines.id = ci_builds.commit_id)
+ GROUP BY project_id, commit_id, stage
+ ORDER BY MAX(stage_idx)
+ SQL
+ end
+end
diff --git a/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb
new file mode 100644
index 00000000000..ec9ff33b6b7
--- /dev/null
+++ b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb
@@ -0,0 +1,15 @@
+class CreateIndexInPipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:ci_stages, [:pipeline_id, :name])
+ end
+
+ def down
+ remove_concurrent_index(:ci_stages, [:pipeline_id, :name])
+ end
+end
diff --git a/db/post_migrate/20170526185901_remove_stage_id_index_from_builds.rb b/db/post_migrate/20170526185901_remove_stage_id_index_from_builds.rb
new file mode 100644
index 00000000000..3879cf9133b
--- /dev/null
+++ b/db/post_migrate/20170526185901_remove_stage_id_index_from_builds.rb
@@ -0,0 +1,18 @@
+class RemoveStageIdIndexFromBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ if index_exists?(:ci_builds, :stage_id)
+ remove_foreign_key(:ci_builds, column: :stage_id)
+ remove_concurrent_index(:ci_builds, :stage_id)
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20170526185921_migrate_build_stage_reference.rb b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb
new file mode 100644
index 00000000000..98c32d8284c
--- /dev/null
+++ b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb
@@ -0,0 +1,19 @@
+class MigrateBuildStageReference < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ ##
+ # This is an empty migration, content has been moved to a new one:
+ # post migrate 20170526190000 MigrateBuildStageReferenceAgain
+ #
+ # See gitlab-org/gitlab-ce!12337 for more details.
+
+ def up
+ # noop
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20170526190000_migrate_build_stage_reference_again.rb b/db/post_migrate/20170526190000_migrate_build_stage_reference_again.rb
new file mode 100644
index 00000000000..97cb242415d
--- /dev/null
+++ b/db/post_migrate/20170526190000_migrate_build_stage_reference_again.rb
@@ -0,0 +1,27 @@
+class MigrateBuildStageReferenceAgain < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ disable_statement_timeout
+
+ stage_id = Arel.sql <<-SQL.strip_heredoc
+ (SELECT id FROM ci_stages
+ WHERE ci_stages.pipeline_id = ci_builds.commit_id
+ AND ci_stages.name = ci_builds.stage)
+ SQL
+
+ update_column_in_batches(:ci_builds, :stage_id, stage_id) do |table, query|
+ query.where(table[:stage_id].eq(nil))
+ end
+ end
+
+ def down
+ disable_statement_timeout
+
+ update_column_in_batches(:ci_builds, :stage_id, nil)
+ end
+end
diff --git a/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb
new file mode 100644
index 00000000000..15edb402b86
--- /dev/null
+++ b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb
@@ -0,0 +1,15 @@
+class CleanupUsersLdapEmailRename < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :users, :ldap_email, :external_email
+ end
+
+ def down
+ rename_column_concurrently :users, :external_email, :ldap_email
+ end
+end
diff --git a/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb b/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb
new file mode 100644
index 00000000000..07935ab8a52
--- /dev/null
+++ b/db/post_migrate/20170606202615_move_appearance_to_system_dir.rb
@@ -0,0 +1,57 @@
+class MoveAppearanceToSystemDir < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ DIRECTORY_TO_MOVE = 'appearance'.freeze
+
+ def up
+ source = File.join(old_upload_dir, DIRECTORY_TO_MOVE)
+ destination = File.join(new_upload_dir, DIRECTORY_TO_MOVE)
+
+ move_directory(source, destination)
+ end
+
+ def down
+ source = File.join(new_upload_dir, DIRECTORY_TO_MOVE)
+ destination = File.join(old_upload_dir, DIRECTORY_TO_MOVE)
+
+ move_directory(source, destination)
+ end
+
+ def move_directory(source, destination)
+ unless file_storage?
+ say 'Not using file storage, skipping'
+ return
+ end
+
+ unless File.directory?(source)
+ say "#{source} did not exist, skipping"
+ return
+ end
+
+ if File.directory?(destination)
+ say "#{destination} already existed, skipping"
+ return
+ end
+
+ say "Moving #{source} -> #{destination}"
+ FileUtils.mv(source, destination)
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "-", "system")
+ end
+end
diff --git a/db/post_migrate/20170607121233_convert_custom_notification_settings_to_columns.rb b/db/post_migrate/20170607121233_convert_custom_notification_settings_to_columns.rb
new file mode 100644
index 00000000000..9abda6a1d73
--- /dev/null
+++ b/db/post_migrate/20170607121233_convert_custom_notification_settings_to_columns.rb
@@ -0,0 +1,55 @@
+class ConvertCustomNotificationSettingsToColumns < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class NotificationSetting < ActiveRecord::Base
+ self.table_name = 'notification_settings'
+
+ store :events, coder: JSON
+ end
+
+ EMAIL_EVENTS = [
+ :new_note,
+ :new_issue,
+ :reopen_issue,
+ :close_issue,
+ :reassign_issue,
+ :new_merge_request,
+ :reopen_merge_request,
+ :close_merge_request,
+ :reassign_merge_request,
+ :merge_merge_request,
+ :failed_pipeline,
+ :success_pipeline
+ ]
+
+ # We only need to migrate (up or down) rows where at least one of these
+ # settings is set.
+ def up
+ NotificationSetting.where("events LIKE '%true%'").find_each do |notification_setting|
+ EMAIL_EVENTS.each do |event|
+ notification_setting[event] = notification_setting.events[event]
+ end
+
+ notification_setting[:events] = nil
+ notification_setting.save!
+ end
+ end
+
+ def down
+ NotificationSetting.where(EMAIL_EVENTS.join(' OR ')).find_each do |notification_setting|
+ events = {}
+
+ EMAIL_EVENTS.each do |event|
+ events[event] = !!notification_setting.public_send(event)
+ notification_setting[event] = nil
+ end
+
+ notification_setting[:events] = events
+ notification_setting.save!
+ end
+ end
+end
diff --git a/db/post_migrate/20170609183112_remove_position_from_issuables.rb b/db/post_migrate/20170609183112_remove_position_from_issuables.rb
new file mode 100644
index 00000000000..4caaa2e83e8
--- /dev/null
+++ b/db/post_migrate/20170609183112_remove_position_from_issuables.rb
@@ -0,0 +1,8 @@
+class RemovePositionFromIssuables < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ remove_column :issues, :position, :integer
+ remove_column :merge_requests, :position, :integer
+ end
+end
diff --git a/db/post_migrate/20170612071012_move_personal_snippets_files.rb b/db/post_migrate/20170612071012_move_personal_snippets_files.rb
new file mode 100644
index 00000000000..2b79a87ccd8
--- /dev/null
+++ b/db/post_migrate/20170612071012_move_personal_snippets_files.rb
@@ -0,0 +1,91 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+class MovePersonalSnippetsFiles < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ return unless file_storage?
+
+ @source_relative_location = File.join('/uploads', 'personal_snippet')
+ @destination_relative_location = File.join('/uploads', '-', 'system', 'personal_snippet')
+
+ move_personal_snippet_files
+ end
+
+ def down
+ return unless file_storage?
+
+ @source_relative_location = File.join('/uploads', '-', 'system', 'personal_snippet')
+ @destination_relative_location = File.join('/uploads', 'personal_snippet')
+
+ move_personal_snippet_files
+ end
+
+ def move_personal_snippet_files
+ query = "SELECT uploads.path, uploads.model_id, snippets.description FROM uploads "\
+ "INNER JOIN snippets ON snippets.id = uploads.model_id WHERE uploader = 'PersonalFileUploader'"
+ select_all(query).each do |upload|
+ secret = upload['path'].split('/')[0]
+ file_name = upload['path'].split('/')[1]
+
+ next unless move_file(upload['model_id'], secret, file_name)
+ update_markdown(upload['model_id'], secret, file_name, upload['description'])
+ end
+ end
+
+ def move_file(snippet_id, secret, file_name)
+ source_dir = File.join(base_directory, @source_relative_location, snippet_id.to_s, secret)
+ destination_dir = File.join(base_directory, @destination_relative_location, snippet_id.to_s, secret)
+
+ source_file_path = File.join(source_dir, file_name)
+ destination_file_path = File.join(destination_dir, file_name)
+
+ unless File.exist?(source_file_path)
+ say "Source file `#{source_file_path}` doesn't exist. Skipping."
+ return
+ end
+
+ say "Moving file #{source_file_path} -> #{destination_file_path}"
+
+ FileUtils.mkdir_p(destination_dir)
+ FileUtils.move(source_file_path, destination_file_path)
+
+ true
+ end
+
+ def update_markdown(snippet_id, secret, file_name, description)
+ source_markdown_path = File.join(@source_relative_location, snippet_id.to_s, secret, file_name)
+ destination_markdown_path = File.join(@destination_relative_location, snippet_id.to_s, secret, file_name)
+
+ source_markdown = "](#{source_markdown_path})"
+ destination_markdown = "](#{destination_markdown_path})"
+
+ if description.present?
+ description = description.gsub(source_markdown, destination_markdown)
+ quoted_description = quote_string(description)
+
+ execute("UPDATE snippets SET description = '#{quoted_description}', description_html = NULL "\
+ "WHERE id = #{snippet_id}")
+ end
+
+ query = "SELECT id, note FROM notes WHERE noteable_id = #{snippet_id} "\
+ "AND noteable_type = 'Snippet' AND note IS NOT NULL"
+ select_all(query).each do |note|
+ text = note['note'].gsub(source_markdown, destination_markdown)
+ quoted_text = quote_string(text)
+
+ execute("UPDATE notes SET note = '#{quoted_text}', note_html = NULL WHERE id = #{note['id']}")
+ end
+ end
+
+ def base_directory
+ File.join(Rails.root, 'public')
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+end
diff --git a/db/post_migrate/20170613111224_clean_appearance_symlinks.rb b/db/post_migrate/20170613111224_clean_appearance_symlinks.rb
new file mode 100644
index 00000000000..acb895e426f
--- /dev/null
+++ b/db/post_migrate/20170613111224_clean_appearance_symlinks.rb
@@ -0,0 +1,52 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanAppearanceSymlinks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ return unless file_storage?
+
+ symlink_location = File.join(old_upload_dir, dir)
+
+ return unless File.symlink?(symlink_location)
+ say "removing symlink: #{symlink_location}"
+ FileUtils.rm(symlink_location)
+ end
+
+ def down
+ return unless file_storage?
+
+ symlink = File.join(old_upload_dir, dir)
+ destination = File.join(new_upload_dir, dir)
+
+ return if File.directory?(symlink)
+ return unless File.directory?(destination)
+
+ say "Creating symlink #{symlink} -> #{destination}"
+ FileUtils.ln_s(destination, symlink)
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def dir
+ 'appearance'
+ end
+
+ def base_directory
+ Rails.root
+ end
+
+ def old_upload_dir
+ File.join(base_directory, "public", "uploads")
+ end
+
+ def new_upload_dir
+ File.join(base_directory, "public", "uploads", "system")
+ end
+end
diff --git a/db/post_migrate/20170621102400_add_stage_id_index_to_builds.rb b/db/post_migrate/20170621102400_add_stage_id_index_to_builds.rb
new file mode 100644
index 00000000000..ac61b5c84a8
--- /dev/null
+++ b/db/post_migrate/20170621102400_add_stage_id_index_to_builds.rb
@@ -0,0 +1,17 @@
+class AddStageIdIndexToBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ ##
+ # Improved in 20170703102400_add_stage_id_foreign_key_to_builds.rb
+ #
+
+ def up
+ # noop
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20170627101016_schedule_event_migrations.rb b/db/post_migrate/20170627101016_schedule_event_migrations.rb
new file mode 100644
index 00000000000..1f34375ff0d
--- /dev/null
+++ b/db/post_migrate/20170627101016_schedule_event_migrations.rb
@@ -0,0 +1,40 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ScheduleEventMigrations < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BUFFER_SIZE = 1000
+
+ disable_ddl_transaction!
+
+ class Event < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'events'
+ end
+
+ def up
+ jobs = []
+
+ Event.each_batch(of: 1000) do |relation|
+ min, max = relation.pluck('MIN(id), MAX(id)').first
+
+ if jobs.length == BUFFER_SIZE
+ # We push multiple jobs at a time to reduce the time spent in
+ # Sidekiq/Redis operations. We're using this buffer based approach so we
+ # don't need to run additional queries for every range.
+ BackgroundMigrationWorker.perform_bulk(jobs)
+ jobs.clear
+ end
+
+ jobs << ['MigrateEventsToPushEventPayloads', [min, max]]
+ end
+
+ BackgroundMigrationWorker.perform_bulk(jobs) unless jobs.empty?
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170628080858_migrate_stage_id_reference_in_background.rb b/db/post_migrate/20170628080858_migrate_stage_id_reference_in_background.rb
new file mode 100644
index 00000000000..f31015d77a3
--- /dev/null
+++ b/db/post_migrate/20170628080858_migrate_stage_id_reference_in_background.rb
@@ -0,0 +1,33 @@
+class MigrateStageIdReferenceInBackground < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 10000
+ RANGE_SIZE = 1000
+ MIGRATION = 'MigrateBuildStageIdReference'.freeze
+
+ disable_ddl_transaction!
+
+ class Build < ActiveRecord::Base
+ self.table_name = 'ci_builds'
+ include ::EachBatch
+ end
+
+ ##
+ # It will take around 3 days to process 20M ci_builds.
+ #
+ def up
+ Build.where(stage_id: nil).each_batch(of: BATCH_SIZE) do |relation, index|
+ relation.each_batch(of: RANGE_SIZE) do |relation|
+ range = relation.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker
+ .perform_in(index * 2.minutes, MIGRATION, range)
+ end
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20170629180131_cleanup_application_settings_signin_enabled_rename.rb b/db/post_migrate/20170629180131_cleanup_application_settings_signin_enabled_rename.rb
new file mode 100644
index 00000000000..52a773ddfee
--- /dev/null
+++ b/db/post_migrate/20170629180131_cleanup_application_settings_signin_enabled_rename.rb
@@ -0,0 +1,15 @@
+class CleanupApplicationSettingsSigninEnabledRename < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :application_settings, :signin_enabled, :password_authentication_enabled
+ end
+
+ def down
+ rename_column_concurrently :application_settings, :password_authentication_enabled, :signin_enabled
+ end
+end
diff --git a/db/post_migrate/20170703130158_schedule_merge_request_diff_migrations.rb b/db/post_migrate/20170703130158_schedule_merge_request_diff_migrations.rb
new file mode 100644
index 00000000000..17a9dc293f1
--- /dev/null
+++ b/db/post_migrate/20170703130158_schedule_merge_request_diff_migrations.rb
@@ -0,0 +1,33 @@
+class ScheduleMergeRequestDiffMigrations < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 2500
+ MIGRATION = 'DeserializeMergeRequestDiffsAndCommits'
+
+ disable_ddl_transaction!
+
+ class MergeRequestDiff < ActiveRecord::Base
+ self.table_name = 'merge_request_diffs'
+
+ include ::EachBatch
+ end
+
+ # Assuming that there are 5 million rows affected (which is more than on
+ # GitLab.com), and that each batch of 2,500 rows takes up to 5 minutes, then
+ # we can migrate all the rows in 7 days.
+ #
+ # On staging, plucking the IDs themselves takes 5 seconds.
+ def up
+ non_empty = 'st_commits IS NOT NULL OR st_diffs IS NOT NULL'
+
+ MergeRequestDiff.where(non_empty).each_batch(of: BATCH_SIZE) do |relation, index|
+ range = relation.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker.perform_in(index * 5.minutes, MIGRATION, range)
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb b/db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb
new file mode 100644
index 00000000000..26b99b61424
--- /dev/null
+++ b/db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb
@@ -0,0 +1,40 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanupMoveSystemUploadFolderSymlink < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ if File.symlink?(old_directory)
+ say "Removing #{old_directory} -> #{new_directory} symlink"
+ FileUtils.rm(old_directory)
+ else
+ say "Symlink #{old_directory} non existant, nothing to do."
+ end
+ end
+
+ def down
+ if File.directory?(new_directory)
+ say "Symlinking #{old_directory} -> #{new_directory}"
+ FileUtils.ln_s(new_directory, old_directory)
+ else
+ say "#{new_directory} doesn't exist, skipping."
+ end
+ end
+
+ def new_directory
+ File.join(base_directory, '-', 'system')
+ end
+
+ def old_directory
+ File.join(base_directory, 'system')
+ end
+
+ def base_directory
+ File.join(Rails.root, 'public', 'uploads')
+ end
+end
diff --git a/db/post_migrate/20170717150329_enqueue_migrate_system_uploads_to_new_folder.rb b/db/post_migrate/20170717150329_enqueue_migrate_system_uploads_to_new_folder.rb
new file mode 100644
index 00000000000..87069dce006
--- /dev/null
+++ b/db/post_migrate/20170717150329_enqueue_migrate_system_uploads_to_new_folder.rb
@@ -0,0 +1,20 @@
+class EnqueueMigrateSystemUploadsToNewFolder < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ OLD_FOLDER = 'uploads/system/'
+ NEW_FOLDER = 'uploads/-/system/'
+
+ disable_ddl_transaction!
+
+ def up
+ BackgroundMigrationWorker.perform_async('MigrateSystemUploadsToNewFolder',
+ [OLD_FOLDER, NEW_FOLDER])
+ end
+
+ def down
+ BackgroundMigrationWorker.perform_async('MigrateSystemUploadsToNewFolder',
+ [NEW_FOLDER, OLD_FOLDER])
+ end
+end
diff --git a/db/post_migrate/20170719150301_merge_issuable_reopened_into_opened_state.rb b/db/post_migrate/20170719150301_merge_issuable_reopened_into_opened_state.rb
new file mode 100644
index 00000000000..acc0fc7a0ac
--- /dev/null
+++ b/db/post_migrate/20170719150301_merge_issuable_reopened_into_opened_state.rb
@@ -0,0 +1,32 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class MergeIssuableReopenedIntoOpenedState < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Issue < ActiveRecord::Base
+ self.table_name = 'issues'
+
+ include EachBatch
+ end
+
+ class MergeRequest < ActiveRecord::Base
+ self.table_name = 'merge_requests'
+
+ include EachBatch
+ end
+
+ def up
+ [Issue, MergeRequest].each do |model|
+ say "Changing #{model.table_name}.state from 'reopened' to 'opened'"
+
+ model.where(state: 'reopened').each_batch do |batch|
+ batch.update_all(state: 'opened')
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20170728101014_remove_events_from_notification_settings.rb b/db/post_migrate/20170728101014_remove_events_from_notification_settings.rb
new file mode 100644
index 00000000000..cd533391d8d
--- /dev/null
+++ b/db/post_migrate/20170728101014_remove_events_from_notification_settings.rb
@@ -0,0 +1,9 @@
+class RemoveEventsFromNotificationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ remove_column :notification_settings, :events, :text
+ end
+end
diff --git a/db/post_migrate/20170803090603_calculate_conv_dev_index_percentages.rb b/db/post_migrate/20170803090603_calculate_conv_dev_index_percentages.rb
new file mode 100644
index 00000000000..9af76c94bf3
--- /dev/null
+++ b/db/post_migrate/20170803090603_calculate_conv_dev_index_percentages.rb
@@ -0,0 +1,30 @@
+class CalculateConvDevIndexPercentages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ DOWNTIME = false
+
+ class ConversationalDevelopmentIndexMetric < ActiveRecord::Base
+ self.table_name = 'conversational_development_index_metrics'
+
+ METRICS = %w[boards ci_pipelines deployments environments issues merge_requests milestones notes
+ projects_prometheus_active service_desk_issues]
+ end
+
+ def up
+ ConversationalDevelopmentIndexMetric.find_each do |conv_dev_index|
+ update = []
+
+ ConversationalDevelopmentIndexMetric::METRICS.each do |metric|
+ instance_score = conv_dev_index["instance_#{metric}"].to_f
+ leader_score = conv_dev_index["leader_#{metric}"].to_f
+
+ percentage = leader_score.zero? ? 0.0 : (instance_score / leader_score) * 100
+ update << "percentage_#{metric} = '#{percentage}'"
+ end
+
+ execute("UPDATE conversational_development_index_metrics SET #{update.join(',')} WHERE id = #{conv_dev_index.id}")
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb b/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb
new file mode 100644
index 00000000000..ea3d1fb3e02
--- /dev/null
+++ b/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb
@@ -0,0 +1,11 @@
+class RemoveLockedAtColumnFromMergeRequests < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ remove_column :merge_requests, :locked_at
+ end
+
+ def down
+ add_column :merge_requests, :locked_at, :datetime_with_timezone
+ end
+end
diff --git a/db/post_migrate/20170807190736_move_personal_snippet_files_into_correct_folder.rb b/db/post_migrate/20170807190736_move_personal_snippet_files_into_correct_folder.rb
new file mode 100644
index 00000000000..e3d2446b897
--- /dev/null
+++ b/db/post_migrate/20170807190736_move_personal_snippet_files_into_correct_folder.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 MovePersonalSnippetFilesIntoCorrectFolder < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+ NEW_DIRECTORY = File.join('/uploads', '-', 'system', 'personal_snippet')
+ OLD_DIRECTORY = File.join('/uploads', 'system', 'personal_snippet')
+
+ def up
+ return unless file_storage?
+
+ BackgroundMigrationWorker.perform_async('MovePersonalSnippetFiles',
+ [OLD_DIRECTORY, NEW_DIRECTORY])
+ end
+
+ def down
+ return unless file_storage?
+
+ BackgroundMigrationWorker.perform_async('MovePersonalSnippetFiles',
+ [NEW_DIRECTORY, OLD_DIRECTORY])
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+end
diff --git a/db/post_migrate/20170815060945_remove_duplicate_mr_events.rb b/db/post_migrate/20170815060945_remove_duplicate_mr_events.rb
new file mode 100644
index 00000000000..6132b553177
--- /dev/null
+++ b/db/post_migrate/20170815060945_remove_duplicate_mr_events.rb
@@ -0,0 +1,26 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveDuplicateMrEvents < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ DOWNTIME = false
+
+ class Event < ActiveRecord::Base
+ self.table_name = 'events'
+ end
+
+ def up
+ base_condition = "action = 1 AND target_type = 'MergeRequest' AND created_at > '2017-08-13'"
+ Event.select('target_id, count(*)')
+ .where(base_condition)
+ .group('target_id').having('count(*) > 1').each do |event|
+ duplicates = Event.where("#{base_condition} AND target_id = #{event.target_id}").pluck(:id)
+ duplicates.shift
+
+ Event.where(id: duplicates).delete_all
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a615a078e49..2ea6ae29dc7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170425101740) do
+ActiveRecord::Schema.define(version: 20170815060945) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -28,20 +28,19 @@ ActiveRecord::Schema.define(version: 20170425101740) do
end
create_table "appearances", force: :cascade do |t|
- t.string "title"
- t.text "description"
+ t.string "title", null: false
+ t.text "description", null: false
t.string "header_logo"
t.string "logo"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.text "description_html"
+ t.text "description_html", null: false
t.integer "cached_markdown_version"
end
create_table "application_settings", force: :cascade do |t|
t.integer "default_projects_limit"
t.boolean "signup_enabled"
- t.boolean "signin_enabled"
t.boolean "gravatar_enabled"
t.text "sign_in_text"
t.datetime "created_at"
@@ -95,6 +94,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.string "enabled_git_access_protocol"
t.boolean "domain_blacklist_enabled", default: false
t.text "domain_blacklist"
+ t.boolean "usage_ping_enabled", default: true, null: false
t.boolean "koding_enabled"
t.string "koding_url"
t.text "sign_in_text_html"
@@ -113,14 +113,21 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.string "plantuml_url"
t.boolean "plantuml_enabled"
t.integer "terminal_max_session_time", default: 0, null: false
- t.string "default_artifacts_expire_in", default: "0", null: false
t.integer "unique_ips_limit_per_user"
t.integer "unique_ips_limit_time_window"
t.boolean "unique_ips_limit_enabled", default: false, null: false
+ t.string "default_artifacts_expire_in", default: "0", null: false
+ t.string "uuid"
t.decimal "polling_interval_multiplier", default: 1.0, null: false
t.integer "cached_markdown_version"
- t.boolean "usage_ping_enabled", default: true, null: false
- t.string "uuid"
+ t.boolean "clientside_sentry_enabled", default: false, null: false
+ t.string "clientside_sentry_dsn"
+ t.boolean "prometheus_metrics_enabled", default: false, null: false
+ t.boolean "help_page_hide_commercial_content", default: false
+ t.string "help_page_support_url"
+ t.integer "performance_bar_allowed_group_id"
+ t.boolean "password_authentication_enabled"
+ t.boolean "project_export_enabled", default: true, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -157,16 +164,18 @@ ActiveRecord::Schema.define(version: 20170425101740) do
create_table "broadcast_messages", force: :cascade do |t|
t.text "message", null: false
- t.datetime "starts_at"
- t.datetime "ends_at"
- t.datetime "created_at"
- t.datetime "updated_at"
+ t.datetime "starts_at", null: false
+ t.datetime "ends_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
t.string "color"
t.string "font"
- t.text "message_html"
+ t.text "message_html", null: false
t.integer "cached_markdown_version"
end
+ add_index "broadcast_messages", ["starts_at", "ends_at", "id"], name: "index_broadcast_messages_on_starts_at_and_ends_at_and_id", using: :btree
+
create_table "chat_names", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "service_id", null: false
@@ -230,20 +239,79 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "lock_version"
t.string "coverage_regex"
t.integer "auto_canceled_by_id"
+ t.boolean "retried"
+ t.integer "stage_id"
end
+ add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree
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
add_index "ci_builds", ["commit_id", "status", "type"], name: "index_ci_builds_on_commit_id_and_status_and_type", using: :btree
add_index "ci_builds", ["commit_id", "type", "name", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_name_and_ref", using: :btree
add_index "ci_builds", ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree
add_index "ci_builds", ["project_id"], name: "index_ci_builds_on_project_id", using: :btree
add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree
+ add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree
add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree
add_index "ci_builds", ["status"], name: "index_ci_builds_on_status", using: :btree
add_index "ci_builds", ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree
add_index "ci_builds", ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree
add_index "ci_builds", ["user_id"], name: "index_ci_builds_on_user_id", using: :btree
+ create_table "ci_group_variables", force: :cascade do |t|
+ t.string "key", null: false
+ t.text "value"
+ t.text "encrypted_value"
+ t.string "encrypted_value_salt"
+ t.string "encrypted_value_iv"
+ t.integer "group_id", null: false
+ t.boolean "protected", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "ci_group_variables", ["group_id", "key"], name: "index_ci_group_variables_on_group_id_and_key", unique: true, using: :btree
+
+ create_table "ci_pipeline_schedule_variables", force: :cascade do |t|
+ t.string "key", null: false
+ t.text "value"
+ t.text "encrypted_value"
+ t.string "encrypted_value_salt"
+ t.string "encrypted_value_iv"
+ t.integer "pipeline_schedule_id", null: false
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "ci_pipeline_schedule_variables", ["pipeline_schedule_id", "key"], name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key", unique: true, using: :btree
+
+ create_table "ci_pipeline_schedules", force: :cascade do |t|
+ t.string "description"
+ t.string "ref"
+ t.string "cron"
+ t.string "cron_timezone"
+ t.datetime "next_run_at"
+ t.integer "project_id"
+ t.integer "owner_id"
+ t.boolean "active", default: true
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "ci_pipeline_schedules", ["next_run_at", "active"], name: "index_ci_pipeline_schedules_on_next_run_at_and_active", using: :btree
+ add_index "ci_pipeline_schedules", ["project_id"], name: "index_ci_pipeline_schedules_on_project_id", using: :btree
+
+ create_table "ci_pipeline_variables", force: :cascade do |t|
+ t.string "key", null: false
+ t.text "value"
+ t.text "encrypted_value"
+ t.string "encrypted_value_salt"
+ t.string "encrypted_value_iv"
+ t.integer "pipeline_id", null: false
+ end
+
+ add_index "ci_pipeline_variables", ["pipeline_id", "key"], name: "index_ci_pipeline_variables_on_pipeline_id_and_key", unique: true, using: :btree
+
create_table "ci_pipelines", force: :cascade do |t|
t.string "ref"
t.string "sha"
@@ -261,8 +329,12 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "user_id"
t.integer "lock_version"
t.integer "auto_canceled_by_id"
+ t.integer "pipeline_schedule_id"
+ t.integer "source"
end
+ add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree
+ add_index "ci_pipelines", ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree
add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree
add_index "ci_pipelines", ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha", using: :btree
add_index "ci_pipelines", ["project_id"], name: "index_ci_pipelines_on_project_id", using: :btree
@@ -301,32 +373,27 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree
add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree
- create_table "ci_trigger_requests", force: :cascade do |t|
- t.integer "trigger_id", null: false
- t.text "variables"
+ create_table "ci_stages", force: :cascade do |t|
+ t.integer "project_id"
+ t.integer "pipeline_id"
t.datetime "created_at"
t.datetime "updated_at"
- t.integer "commit_id"
+ t.string "name"
end
- add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree
+ add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", using: :btree
+ add_index "ci_stages", ["pipeline_id"], name: "index_ci_stages_on_pipeline_id", using: :btree
+ add_index "ci_stages", ["project_id"], name: "index_ci_stages_on_project_id", using: :btree
- create_table "ci_trigger_schedules", force: :cascade do |t|
- t.integer "project_id"
+ create_table "ci_trigger_requests", force: :cascade do |t|
t.integer "trigger_id", null: false
- t.datetime "deleted_at"
+ t.text "variables"
t.datetime "created_at"
t.datetime "updated_at"
- t.string "cron"
- t.string "cron_timezone"
- t.datetime "next_run_at"
- t.string "ref"
- t.boolean "active"
+ t.integer "commit_id"
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
+ add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree
create_table "ci_triggers", force: :cascade do |t|
t.string "token"
@@ -342,15 +409,17 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree
create_table "ci_variables", force: :cascade do |t|
- t.string "key"
+ t.string "key", null: false
t.text "value"
t.text "encrypted_value"
t.string "encrypted_value_salt"
t.string "encrypted_value_iv"
- t.integer "project_id"
+ t.integer "project_id", null: false
+ t.boolean "protected", default: false, null: false
+ t.string "environment_scope", default: "*", null: false
end
- add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree
+ add_index "ci_variables", ["project_id", "key", "environment_scope"], name: "index_ci_variables_on_project_id_and_key_and_environment_scope", unique: true, using: :btree
create_table "container_repositories", force: :cascade do |t|
t.integer "project_id", null: false
@@ -362,6 +431,41 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree
add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree
+ create_table "conversational_development_index_metrics", force: :cascade do |t|
+ t.float "leader_issues", null: false
+ t.float "instance_issues", null: false
+ t.float "leader_notes", null: false
+ t.float "instance_notes", null: false
+ t.float "leader_milestones", null: false
+ t.float "instance_milestones", null: false
+ t.float "leader_boards", null: false
+ t.float "instance_boards", null: false
+ t.float "leader_merge_requests", null: false
+ t.float "instance_merge_requests", null: false
+ t.float "leader_ci_pipelines", null: false
+ t.float "instance_ci_pipelines", null: false
+ t.float "leader_environments", null: false
+ t.float "instance_environments", null: false
+ t.float "leader_deployments", null: false
+ t.float "instance_deployments", null: false
+ t.float "leader_projects_prometheus_active", null: false
+ t.float "instance_projects_prometheus_active", null: false
+ t.float "leader_service_desk_issues", null: false
+ t.float "instance_service_desk_issues", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.float "percentage_boards", default: 0.0, null: false
+ t.float "percentage_ci_pipelines", default: 0.0, null: false
+ t.float "percentage_deployments", default: 0.0, null: false
+ t.float "percentage_environments", default: 0.0, null: false
+ t.float "percentage_issues", default: 0.0, null: false
+ t.float "percentage_merge_requests", default: 0.0, null: false
+ t.float "percentage_milestones", default: 0.0, null: false
+ t.float "percentage_notes", default: 0.0, null: false
+ t.float "percentage_projects_prometheus_active", default: 0.0, null: false
+ t.float "percentage_service_desk_issues", default: 0.0, null: false
+ end
+
create_table "deploy_keys_projects", force: :cascade do |t|
t.integer "deploy_key_id", null: false
t.integer "project_id", null: false
@@ -386,6 +490,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.string "on_stop"
end
+ add_index "deployments", ["created_at"], name: "index_deployments_on_created_at", using: :btree
add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree
add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true, using: :btree
@@ -428,10 +533,43 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "events", ["action"], name: "index_events_on_action", using: :btree
add_index "events", ["author_id"], name: "index_events_on_author_id", using: :btree
add_index "events", ["created_at"], name: "index_events_on_created_at", using: :btree
- add_index "events", ["project_id"], name: "index_events_on_project_id", using: :btree
+ add_index "events", ["project_id", "id"], name: "index_events_on_project_id_and_id", using: :btree
add_index "events", ["target_id"], name: "index_events_on_target_id", using: :btree
add_index "events", ["target_type"], name: "index_events_on_target_type", using: :btree
+ create_table "events_for_migration", force: :cascade do |t|
+ t.integer "project_id"
+ t.integer "author_id", null: false
+ t.integer "target_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "action", limit: 2, null: false
+ t.string "target_type"
+ end
+
+ add_index "events_for_migration", ["action"], name: "index_events_for_migration_on_action", using: :btree
+ add_index "events_for_migration", ["author_id"], name: "index_events_for_migration_on_author_id", using: :btree
+ add_index "events_for_migration", ["project_id", "id"], name: "index_events_for_migration_on_project_id_and_id", using: :btree
+ add_index "events_for_migration", ["target_type", "target_id"], name: "index_events_for_migration_on_target_type_and_target_id", using: :btree
+
+ create_table "feature_gates", force: :cascade do |t|
+ t.string "feature_key", null: false
+ t.string "key", null: false
+ t.string "value"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "feature_gates", ["feature_key", "key", "value"], name: "index_feature_gates_on_feature_key_and_key_and_value", unique: true, using: :btree
+
+ create_table "features", force: :cascade do |t|
+ t.string "key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "features", ["key"], name: "index_features_on_key", unique: true, using: :btree
+
create_table "forked_project_links", force: :cascade do |t|
t.integer "forked_to_project_id", null: false
t.integer "forked_from_project_id", null: false
@@ -441,6 +579,36 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "forked_project_links", ["forked_to_project_id"], name: "index_forked_project_links_on_forked_to_project_id", unique: true, using: :btree
+ create_table "gpg_keys", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "user_id"
+ t.binary "primary_keyid"
+ t.binary "fingerprint"
+ t.text "key"
+ end
+
+ add_index "gpg_keys", ["fingerprint"], name: "index_gpg_keys_on_fingerprint", unique: true, using: :btree
+ add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", unique: true, using: :btree
+ add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree
+
+ create_table "gpg_signatures", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "project_id"
+ t.integer "gpg_key_id"
+ t.boolean "valid_signature"
+ t.binary "commit_sha"
+ t.binary "gpg_key_primary_keyid"
+ t.text "gpg_key_user_name"
+ t.text "gpg_key_user_email"
+ end
+
+ add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree
+ add_index "gpg_signatures", ["gpg_key_id"], name: "index_gpg_signatures_on_gpg_key_id", using: :btree
+ add_index "gpg_signatures", ["gpg_key_primary_keyid"], name: "index_gpg_signatures_on_gpg_key_primary_keyid", using: :btree
+ add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree
+
create_table "identities", force: :cascade do |t|
t.string "extern_uid"
t.string "provider"
@@ -451,6 +619,14 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree
+ create_table "issue_assignees", id: false, force: :cascade do |t|
+ t.integer "user_id", null: false
+ t.integer "issue_id", null: false
+ end
+
+ add_index "issue_assignees", ["issue_id", "user_id"], name: "index_issue_assignees_on_issue_id_and_user_id", unique: true, using: :btree
+ add_index "issue_assignees", ["user_id"], name: "index_issue_assignees_on_user_id", using: :btree
+
create_table "issue_metrics", force: :cascade do |t|
t.integer "issue_id", null: false
t.datetime "first_mentioned_in_commit_at"
@@ -469,7 +645,6 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "project_id"
t.datetime "created_at"
t.datetime "updated_at"
- t.integer "position", default: 0
t.string "branch_name"
t.text "description"
t.integer "milestone_id"
@@ -487,17 +662,20 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "relative_position"
t.datetime "closed_at"
t.integer "cached_markdown_version"
+ t.datetime "last_edited_at"
+ t.integer "last_edited_by_id"
end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree
add_index "issues", ["confidential"], name: "index_issues_on_confidential", using: :btree
- add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree
add_index "issues", ["deleted_at"], name: "index_issues_on_deleted_at", using: :btree
add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
- add_index "issues", ["due_date"], name: "index_issues_on_due_date", using: :btree
add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree
+ add_index "issues", ["project_id", "created_at", "id", "state"], name: "index_issues_on_project_id_and_created_at_and_id_and_state", using: :btree
+ add_index "issues", ["project_id", "due_date", "id", "state"], name: "index_issues_on_project_id_and_due_date_and_id_and_state", using: :btree
add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree
+ add_index "issues", ["project_id", "updated_at", "id", "state"], name: "index_issues_on_project_id_and_updated_at_and_id_and_state", using: :btree
add_index "issues", ["relative_position"], name: "index_issues_on_relative_position", using: :btree
add_index "issues", ["state"], name: "index_issues_on_state", using: :btree
add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
@@ -613,6 +791,38 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree
add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree
+ create_table "merge_request_diff_commits", id: false, force: :cascade do |t|
+ t.datetime "authored_date"
+ t.datetime "committed_date"
+ t.integer "merge_request_diff_id", null: false
+ t.integer "relative_order", null: false
+ t.binary "sha", null: false
+ t.text "author_name"
+ t.text "author_email"
+ t.text "committer_name"
+ t.text "committer_email"
+ t.text "message"
+ end
+
+ add_index "merge_request_diff_commits", ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_commits_on_mr_diff_id_and_order", unique: true, using: :btree
+
+ create_table "merge_request_diff_files", id: false, force: :cascade do |t|
+ t.integer "merge_request_diff_id", null: false
+ t.integer "relative_order", null: false
+ t.boolean "new_file", null: false
+ t.boolean "renamed_file", null: false
+ t.boolean "deleted_file", null: false
+ t.boolean "too_large", null: false
+ t.string "a_mode", null: false
+ t.string "b_mode", null: false
+ t.text "new_path", null: false
+ t.text "old_path", null: false
+ t.text "diff", null: false
+ t.boolean "binary"
+ end
+
+ add_index "merge_request_diff_files", ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_files_on_mr_diff_id_and_order", unique: true, using: :btree
+
create_table "merge_request_diffs", force: :cascade do |t|
t.string "state"
t.text "st_commits"
@@ -658,8 +868,6 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "target_project_id", null: false
t.integer "iid"
t.text "description"
- t.integer "position", default: 0
- t.datetime "locked_at"
t.integer "updated_by_id"
t.text "merge_error"
t.text "merge_params"
@@ -673,6 +881,11 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.text "description_html"
t.integer "time_estimate"
t.integer "cached_markdown_version"
+ t.datetime "last_edited_at"
+ t.integer "last_edited_by_id"
+ t.integer "head_pipeline_id"
+ t.boolean "ref_fetched"
+ t.string "merge_jid"
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
@@ -680,6 +893,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "merge_requests", ["created_at"], name: "index_merge_requests_on_created_at", using: :btree
add_index "merge_requests", ["deleted_at"], name: "index_merge_requests_on_deleted_at", using: :btree
add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
+ add_index "merge_requests", ["head_pipeline_id"], name: "index_merge_requests_on_head_pipeline_id", using: :btree
add_index "merge_requests", ["milestone_id"], name: "index_merge_requests_on_milestone_id", using: :btree
add_index "merge_requests", ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree
add_index "merge_requests", ["source_project_id"], name: "index_merge_requests_on_source_project_id", using: :btree
@@ -700,7 +914,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
create_table "milestones", force: :cascade do |t|
t.string "title", null: false
- t.integer "project_id", null: false
+ t.integer "project_id"
t.text "description"
t.date "due_date"
t.datetime "created_at"
@@ -711,10 +925,12 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.text "description_html"
t.date "start_date"
t.integer "cached_markdown_version"
+ t.integer "group_id"
end
add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree
+ add_index "milestones", ["group_id"], name: "index_milestones_on_group_id", using: :btree
add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
@@ -773,6 +989,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.string "discussion_id"
t.text "note_html"
t.integer "cached_markdown_version"
+ t.text "change_position"
end
add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree
@@ -793,7 +1010,18 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.integer "level", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.text "events"
+ t.boolean "new_note"
+ t.boolean "new_issue"
+ t.boolean "reopen_issue"
+ t.boolean "close_issue"
+ t.boolean "reassign_issue"
+ t.boolean "new_merge_request"
+ t.boolean "reopen_merge_request"
+ t.boolean "close_merge_request"
+ t.boolean "reassign_merge_request"
+ t.boolean "merge_merge_request"
+ t.boolean "failed_pipeline"
+ t.boolean "success_pipeline"
end
add_index "notification_settings", ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type", using: :btree
@@ -838,6 +1066,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.datetime "updated_at"
t.integer "owner_id"
t.string "owner_type"
+ t.boolean "trusted", default: false, null: false
end
add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree
@@ -858,6 +1087,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
end
add_index "pages_domains", ["domain"], name: "index_pages_domains_on_domain", unique: true, using: :btree
+ add_index "pages_domains", ["project_id"], name: "index_pages_domains_on_project_id", using: :btree
create_table "personal_access_tokens", force: :cascade do |t|
t.integer "user_id", null: false
@@ -906,6 +1136,9 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.date "expires_at"
end
+ add_index "project_group_links", ["group_id"], name: "index_project_group_links_on_group_id", using: :btree
+ add_index "project_group_links", ["project_id"], name: "index_project_group_links_on_project_id", using: :btree
+
create_table "project_import_data", force: :cascade do |t|
t.integer "project_id"
t.text "data"
@@ -966,10 +1199,13 @@ ActiveRecord::Schema.define(version: 20170425101740) 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.integer "auto_cancel_pending_pipelines", default: 1, null: false
t.string "import_jid"
t.integer "cached_markdown_version"
+ t.datetime "last_repository_updated_at"
+ t.string "ci_config_path"
+ t.text "delete_error"
end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
@@ -978,6 +1214,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree
add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree
+ add_index "projects", ["last_repository_updated_at"], name: "index_projects_on_last_repository_updated_at", using: :btree
add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"}
add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree
add_index "projects", ["path"], name: "index_projects_on_path", using: :btree
@@ -1035,6 +1272,31 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree
+ create_table "push_event_payloads", id: false, force: :cascade do |t|
+ t.integer "commit_count", limit: 8, null: false
+ t.integer "event_id", null: false
+ t.integer "action", limit: 2, null: false
+ t.integer "ref_type", limit: 2, null: false
+ t.binary "commit_from"
+ t.binary "commit_to"
+ t.text "ref"
+ t.string "commit_title", limit: 70
+ end
+
+ add_index "push_event_payloads", ["event_id"], name: "index_push_event_payloads_on_event_id", unique: true, using: :btree
+
+ create_table "redirect_routes", force: :cascade do |t|
+ t.integer "source_id", null: false
+ t.string "source_type", null: false
+ t.string "path", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree
+ add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path_text_pattern_ops", using: :btree, opclasses: {"path"=>"varchar_pattern_ops"}
+ add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree
+
create_table "releases", force: :cascade do |t|
t.string "tag"
t.text "description"
@@ -1090,13 +1352,13 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.boolean "merge_requests_events", default: true
t.boolean "tag_push_events", default: true
t.boolean "note_events", default: true, null: false
- t.boolean "build_events", default: false, null: false
t.string "category", default: "common", null: false
t.boolean "default", default: false
t.boolean "wiki_page_events", default: true
t.boolean "pipeline_events", default: false, null: false
t.boolean "confidential_issues_events", default: true, null: false
t.boolean "commit_events", default: true, null: false
+ t.boolean "job_events", default: false, null: false
end
add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree
@@ -1115,6 +1377,8 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.text "title_html"
t.text "content_html"
t.integer "cached_markdown_version"
+ t.text "description"
+ t.text "description_html"
end
add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree
@@ -1315,16 +1579,18 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.boolean "hide_project_limit", default: false
t.string "unlock_token"
t.datetime "otp_grace_period_started_at"
- t.boolean "ldap_email", default: false, null: false
t.boolean "external", default: false
t.string "incoming_email_token"
t.string "organization"
- t.boolean "authorized_projects_populated"
+ t.boolean "require_two_factor_authentication_from_group", default: false, null: false
+ t.integer "two_factor_grace_period", default: 48, null: false
t.boolean "ghost"
t.date "last_activity_on"
t.boolean "notified_of_own_activity"
- t.boolean "require_two_factor_authentication_from_group", default: false, null: false
- t.integer "two_factor_grace_period", default: 48, null: false
+ t.string "preferred_language"
+ t.string "rss_token"
+ t.boolean "external_email", default: false, null: false
+ t.string "email_provider"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
@@ -1338,6 +1604,7 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "users", ["name"], name: "index_users_on_name", using: :btree
add_index "users", ["name"], name: "index_users_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"}
add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree
+ add_index "users", ["rss_token"], name: "index_users_on_rss_token", using: :btree
add_index "users", ["state"], name: "index_users_on_state", using: :btree
add_index "users", ["username"], name: "index_users_on_username", using: :btree
add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"}
@@ -1352,6 +1619,23 @@ ActiveRecord::Schema.define(version: 20170425101740) do
add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree
add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree
+ create_table "web_hook_logs", force: :cascade do |t|
+ t.integer "web_hook_id", null: false
+ t.string "trigger"
+ t.string "url"
+ t.text "request_headers"
+ t.text "request_data"
+ t.text "response_headers"
+ t.text "response_body"
+ t.string "response_status"
+ t.float "execution_duration"
+ t.string "internal_error_message"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "web_hook_logs", ["web_hook_id"], name: "index_web_hook_logs_on_web_hook_id", using: :btree
+
create_table "web_hooks", force: :cascade do |t|
t.string "url", limit: 2000
t.integer "project_id"
@@ -1365,48 +1649,98 @@ ActiveRecord::Schema.define(version: 20170425101740) do
t.boolean "tag_push_events", default: false
t.boolean "note_events", default: false, null: false
t.boolean "enable_ssl_verification", default: true
- t.boolean "build_events", default: false, null: false
t.boolean "wiki_page_events", default: false, null: false
t.string "token"
t.boolean "pipeline_events", default: false, null: false
t.boolean "confidential_issues_events", default: false, null: false
+ t.boolean "repository_update_events", default: false, null: false
+ t.boolean "job_events", default: false, null: false
end
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
add_index "web_hooks", ["type"], name: "index_web_hooks_on_type", using: :btree
- add_foreign_key "boards", "projects"
+ add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
+ add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade
+ add_foreign_key "ci_builds", "projects", name: "fk_befce0568a", on_delete: :cascade
+ add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade
+ add_foreign_key "ci_pipeline_schedule_variables", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_41c35fda51", on_delete: :cascade
+ add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade
+ add_foreign_key "ci_pipeline_schedules", "users", column: "owner_id", name: "fk_9ea99f58d2", on_delete: :nullify
+ add_foreign_key "ci_pipeline_variables", "ci_pipelines", column: "pipeline_id", name: "fk_f29c5f4380", on_delete: :cascade
+ add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify
add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify
+ add_foreign_key "ci_pipelines", "projects", name: "fk_86635dbd80", on_delete: :cascade
+ add_foreign_key "ci_runner_projects", "projects", name: "fk_4478a6f1e4", on_delete: :cascade
+ add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade
+ add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade
add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade
- add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade
+ add_foreign_key "ci_triggers", "projects", name: "fk_e3e63f966e", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
+ add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
+ add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade
+ add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade
+ add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade
+ add_foreign_key "events", "projects", name: "fk_0434b48643", on_delete: :cascade
+ add_foreign_key "events_for_migration", "projects", on_delete: :cascade
+ add_foreign_key "events_for_migration", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade
+ add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade
+ add_foreign_key "gpg_keys", "users", on_delete: :cascade
+ add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify
+ add_foreign_key "gpg_signatures", "projects", on_delete: :cascade
+ add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade
+ add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
+ add_foreign_key "issues", "projects", name: "fk_899c8f3231", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade
- add_foreign_key "lists", "boards"
- add_foreign_key "lists", "labels"
+ add_foreign_key "labels", "projects", name: "fk_7de4989a69", on_delete: :cascade
+ add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade
+ add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade
+ add_foreign_key "merge_request_diff_commits", "merge_request_diffs", on_delete: :cascade
+ add_foreign_key "merge_request_diff_files", "merge_request_diffs", on_delete: :cascade
+ add_foreign_key "merge_request_diffs", "merge_requests", name: "fk_8483f3258f", on_delete: :cascade
add_foreign_key "merge_request_metrics", "ci_pipelines", column: "pipeline_id", on_delete: :cascade
add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade
+ add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify
+ add_foreign_key "merge_requests", "projects", column: "target_project_id", name: "fk_a6963e8447", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade
+ add_foreign_key "milestones", "namespaces", column: "group_id", name: "fk_95650a40d4", on_delete: :cascade
+ add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade
+ add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade
add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"
+ add_foreign_key "pages_domains", "projects", name: "fk_ea2f6dfc6f", on_delete: :cascade
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", on_delete: :cascade
+ add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade
+ add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade
+ add_foreign_key "project_import_data", "projects", name: "fk_ffb9ee3a10", on_delete: :cascade
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_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade
+ add_foreign_key "protected_branch_push_access_levels", "protected_branches", name: "fk_9ffc86a3d9", on_delete: :cascade
+ add_foreign_key "protected_branches", "projects", name: "fk_7a9c6d93e7", on_delete: :cascade
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 "protected_tags", "projects", name: "fk_8e4af87648", on_delete: :cascade
+ add_foreign_key "push_event_payloads", "events_for_migration", column: "event_id", name: "fk_36c74129da", on_delete: :cascade
+ add_foreign_key "releases", "projects", name: "fk_47fe2a0596", on_delete: :cascade
+ add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
+ add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
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
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
+ add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users"
+ add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade
+ add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade
+ add_foreign_key "web_hooks", "projects", name: "fk_0c8ca6d9d1", on_delete: :cascade
end
diff --git a/doc/README.md b/doc/README.md
index 6406040da4b..267487520cd 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,12 +1,24 @@
-# GitLab Community Edition
+---
+toc: false
+---
-[GitLab](https://about.gitlab.com/) is a Git-based fully featured platform
-for software development.
+# GitLab Documentation
-**GitLab Community Edition (CE)** is an opensource product, self-hosted, free to use.
-All [GitLab products](https://about.gitlab.com/products/) contain the features
-available in GitLab CE. Premium features are available in
-[GitLab Enterprise Edition (EE)](https://about.gitlab.com/gitlab-ee/).
+Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured
+platform for software development!
+
+GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans:
+
+- **GitLab Community Edition (CE)** is an [opensource product](https://gitlab.com/gitlab-org/gitlab-ce/),
+self-hosted, free to use. Every feature available in GitLab CE is also available on GitLab Enterprise Edition (Starter and Premium) and GitLab.com.
+- **GitLab Enterprise Edition (EE)** is an [opencore product](https://gitlab.com/gitlab-org/gitlab-ee/),
+self-hosted, fully featured solution of GitLab, available under distinct [subscriptions](https://about.gitlab.com/products/): **GitLab Enterprise Edition Starter (EES)** and **GitLab Enterprise Edition Premium (EEP)**.
+- **GitLab.com**: SaaS GitLab solution, with [free and paid subscriptions](https://about.gitlab.com/gitlab-com/). GitLab.com is hosted by GitLab, Inc., and administrated by GitLab (users don't have access to admin settings).
+
+> **GitLab EE** contains all features available in **GitLab CE**,
+plus premium features available in each version: **Enterprise Edition Starter**
+(**EES**) and **Enterprise Edition Premium** (**EEP**). Everything available in
+**EES** is also available in **EEP**.
----
@@ -18,34 +30,42 @@ Shortcuts to GitLab's most visited docs:
| [Configuring `.gitlab-ci.yml`](ci/yaml/README.md) | [SSH authentication](ssh/README.md) |
| [Using Docker images](ci/docker/using_docker_images.md) | [GitLab Pages](user/project/pages/index.md) |
+- [User documentation](user/index.md)
+- [Administrator documentation](#administrator-documentation)
+- [Technical Articles](articles/index.md)
+
## Getting started with GitLab
- [GitLab Basics](gitlab-basics/README.md): Start working on your command line and on GitLab.
- [GitLab Workflow](workflow/README.md): Enhance your workflow with the best of GitLab Workflow.
- See also [GitLab Workflow - an overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/).
- [GitLab Markdown](user/markdown.md): GitLab's advanced formatting system (GitLab Flavored Markdown).
-- [GitLab Slash Commands](user/project/slash_commands.md): Textual shortcuts for common actions on issues or merge requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
+- [GitLab Quick Actions](user/project/quick_actions.md): Textual shortcuts for common actions on issues or merge requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
### User account
-- [Authentication](topics/authentication/index.md): Account security with two-factor authentication, setup your ssh keys and deploy keys for secure access to your projects.
-- [Profile settings](profile/README.md): Manage your profile settings, two factor authentication and more.
+- [User account](user/profile/index.md): Manage your account
+ - [Authentication](topics/authentication/index.md): Account security with two-factor authentication, setup your ssh keys and deploy keys for secure access to your projects.
+ - [Profile settings](user/profile/index.md#profile-settings): Manage your profile settings, two factor authentication and more.
- [User permissions](user/permissions.md): Learn what each role in a project (external/guest/reporter/developer/master/owner) can do.
### Projects and groups
-- [Create a project](gitlab-basics/create-project.md)
-- [Fork a project](gitlab-basics/fork-project.md)
-- [Importing and exporting projects between instances](user/project/settings/import_export.md).
-- [Project access](public_access/public_access.md): Setting up your project's visibility to public, internal, or private.
-- [Groups](workflow/groups.md): Organize your projects in groups.
- - [Create a group](gitlab-basics/create-group.md)
- - [GitLab Subgroups](user/group/subgroups/index.md)
+- [Projects](user/project/index.md):
+ - [Create a project](gitlab-basics/create-project.md)
+ - [Fork a project](gitlab-basics/fork-project.md)
+ - [Importing and exporting projects between instances](user/project/settings/import_export.md).
+ - [Project access](public_access/public_access.md): Setting up your project's visibility to public, internal, or private.
+ - [GitLab Pages](user/project/pages/index.md): Build, test, and deploy your static website with GitLab Pages.
+- [Groups](user/group/index.md): Organize your projects in groups.
+ - [Subgroups](user/group/subgroups/index.md)
- [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.
+- [Wikis](user/project/wiki/index.md): Enhance your repository documentation with built-in wikis.
### Repository
-Manage files and branches from the UI (user interface):
+Manage your [repositories](user/project/repository/index.md) from the UI (user interface):
- Files
- [Create a file](user/project/repository/web_editor.md#create-a-file)
@@ -56,15 +76,13 @@ Manage files and branches from the UI (user interface):
- Branches
- [Create a branch](user/project/repository/web_editor.md#create-a-new-branch)
- [Protected branches](user/project/protected_branches.md#protected-branches)
+ - [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches)
### Issues and Merge Requests (MRs)
- [Discussions](user/discussions/index.md) Threads, comments, and resolvable discussions in issues, commits, and merge requests.
-- Issues
- - [Create an issue](gitlab-basics/create-issue.md#how-to-create-an-issue-in-gitlab)
- - [Confidential Issues](user/project/issues/confidential_issues.md)
- - [Automatic issue closing](user/project/issues/automatic_issue_closing.md)
- - [Issue Boards](user/project/issue_board.md)
+- [Issues](user/project/issues/index.md)
+- [Issue Board](user/project/issue_board.md)
- [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests.
- [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles.
- [Merge Requests](user/project/merge_requests/index.md)
@@ -80,20 +98,13 @@ Manage files and branches from the UI (user interface):
- [Git](topics/git/index.md): Getting started with Git, branching strategies, Git LFS, advanced use.
- [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 Flow](workflow/gitlab_flow.md): explore the best of Git with the GitLab Flow strategy.
+- [Signing commits](workflow/gpg_signed_commits/index.md): use GPG to sign your commits.
### Migrate and import your projects from other platforms
-- [Importing to GitLab](workflow/importing/README.md): Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab.
+- [Importing to GitLab](user/project/import/index.md): Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab.
- [Migrating from SVN](workflow/importing/migrating_from_svn.md): Convert a SVN repository to Git and GitLab.
-## GitLab's superpowers
-
-Take a step ahead and dive into GitLab's advanced features.
-
-- [GitLab Pages](user/project/pages/index.md): Build, test, and deploy your static website with GitLab Pages.
-- [Snippets](user/snippets.md): Snippets allow you to create little bits of code.
-- [Wikis](workflow/project_features.md#wiki): Enhance your repository documentation with built-in wikis.
-
### Continuous Integration, Delivery, and Deployment
- [GitLab CI](ci/README.md): Explore the features and capabilities of Continuous Integration, Continuous Delivery, and Continuous Deployment with GitLab.
@@ -111,6 +122,7 @@ Take a step ahead and dive into GitLab's advanced features.
- [Project Services](user/project/integrations/project_services.md): Integrate a project with external services, such as CI and chat.
- [GitLab Integration](integration/README.md): Integrate with multiple third-party services with GitLab to allow external issue trackers and external authentication.
+- [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up
----
@@ -132,7 +144,7 @@ have access to GitLab administration tools and settings.
- [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](topics/authentication/index.md#gitlab-administrators): Enforce 2FA, configure external authentication with LDAP, SAML, CAS and additional Omniauth providers.
-### GitLab admins' superpowers
+### Features
- [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.
@@ -162,6 +174,7 @@ have access to GitLab administration tools and settings.
- [Operations](administration/operations.md): Keeping GitLab up and running.
- [Polling](administration/polling.md): Configure how often the GitLab UI polls for updates.
- [Request Profiling](administration/monitoring/performance/request_profiling.md): Get a detailed profile on slow requests.
+- [Performance Bar](administration/monitoring/performance/performance_bar.md): Get performance information for the current page.
### Customization
@@ -174,6 +187,7 @@ have access to GitLab administration tools and settings.
### Admin tools
+- [Gitaly](administration/gitaly/index.md): Configuring Gitaly, GitLab's Git repository storage service
- [Raketasks](raketasks/README.md): Backups, maintenance, automatic webhook setup and the importing of projects.
- [Backup and restore](raketasks/backup_restore.md): Backup and restore your GitLab instance.
- [Reply by email](administration/reply_by_email.md): Allow users to comment on issues and merge requests by replying to notification emails.
diff --git a/doc/administration/auth/authentiq.md b/doc/administration/auth/authentiq.md
index fb1a16b0f96..1528f1d2b17 100644
--- a/doc/administration/auth/authentiq.md
+++ b/doc/administration/auth/authentiq.md
@@ -32,7 +32,7 @@ Authentiq will generate a Client ID and the accompanying Client Secret for you t
"app_id" => "YOUR_CLIENT_ID",
"app_secret" => "YOUR_CLIENT_SECRET",
"args" => {
- scope: 'aq:name email~rs aq:push'
+ "scope": 'aq:name email~rs address aq:push'
}
}
]
@@ -45,21 +45,20 @@ Authentiq will generate a Client ID and the accompanying Client Secret for you t
app_id: 'YOUR_CLIENT_ID',
app_secret: 'YOUR_CLIENT_SECRET',
args: {
- scope: 'aq:name email~rs aq:push'
+ scope: 'aq:name email~rs address aq:push'
}
}
```
5. The `scope` is set to request the user's name, email (required and signed), and permission to send push notifications to sign in on subsequent visits.
-See [OmniAuth Authentiq strategy](https://github.com/AuthentiqID/omniauth-authentiq#scopes-and-redirect-uri-configuration) for more information on scopes and modifiers.
+See [OmniAuth Authentiq strategy](https://github.com/AuthentiqID/omniauth-authentiq/wiki/Scopes,-callback-url-configuration-and-responses) for more information on scopes and modifiers.
6. Change `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` to the Client credentials you received in step 1.
7. Save the configuration file.
-8. [Reconfigure](../restart_gitlab.md#omnibus-gitlab-reconfigure) or [restart GitLab](../restart_gitlab.md#installations-from-source)
- for the changes to take effect if you installed GitLab via Omnibus or from source respectively.
+8. [Reconfigure](../restart_gitlab.md#omnibus-gitlab-reconfigure) or [restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect if you installed GitLab via Omnibus or from source respectively.
On the sign in page there should now be an Authentiq icon below the regular sign in form. Click the icon to begin the authentication process.
diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md
index f6027b2f99e..425c924cdf2 100644
--- a/doc/administration/auth/ldap.md
+++ b/doc/administration/auth/ldap.md
@@ -65,18 +65,46 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server
#
# Example: 'Paris' or 'Acme, Ltd.'
label: 'LDAP'
-
+
# Example: 'ldap.mydomain.com'
host: '_your_ldap_server'
# This port is an example, it is sometimes different but it is always an integer and not a string
- port: 389
- uid: 'sAMAccountName'
- method: 'plain' # "tls" or "ssl" or "plain"
-
+ port: 389 # usually 636 for SSL
+ uid: 'sAMAccountName' # This should be the attribute, not the value that maps to uid.
+
# Examples: 'america\\momo' or 'CN=Gitlab Git,CN=Users,DC=mydomain,DC=com'
bind_dn: '_the_full_dn_of_the_user_you_will_bind_with'
password: '_the_password_of_the_bind_user'
+ # Encryption method. The "method" key is deprecated in favor of
+ # "encryption".
+ #
+ # Examples: "start_tls" or "simple_tls" or "plain"
+ #
+ # Deprecated values: "tls" was replaced with "start_tls" and "ssl" was
+ # replaced with "simple_tls".
+ #
+ encryption: 'plain'
+
+ # Enables SSL certificate verification if encryption method is
+ # "start_tls" or "simple_tls". (Defaults to false for backward-
+ # compatibility)
+ verify_certificates: false
+
+ # Specifies the path to a file containing a PEM-format CA certificate,
+ # e.g. if you need to use an internal CA.
+ #
+ # Example: '/etc/ca.pem'
+ #
+ ca_file: ''
+
+ # Specifies the SSL version for OpenSSL to use, if the OpenSSL default
+ # is not appropriate.
+ #
+ # Example: 'TLSv1_1'
+ #
+ ssl_version: ''
+
# Set a timeout, in seconds, for LDAP queries. This helps avoid blocking
# a request if the LDAP server becomes unresponsive.
# A value of 0 means there is no timeout.
@@ -116,8 +144,8 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server
#
# Note: GitLab does not support omniauth-ldap's custom filter syntax.
#
- # Below an example for get only specific users
- # Example: '(&(objectclass=user)(|(samaccountname=momo)(samaccountname=toto)))'
+ # Example for getting only specific users:
+ # '(&(objectclass=user)(|(samaccountname=momo)(samaccountname=toto)))'
#
user_filter: ''
@@ -228,9 +256,14 @@ Tip: If you want to limit access to the nested members of an Active Directory
group you can use the following syntax:
```
-(memberOf=CN=My Group,DC=Example,DC=com)
+(memberOf:1.2.840.113556.1.4.1941:=CN=My Group,DC=Example,DC=com)
```
+Find more information about this "LDAP_MATCHING_RULE_IN_CHAIN" filter at
+https://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx. Support for
+nested members in the user filter should not be confused with
+[group sync nested groups support (EE only)](https://docs.gitlab.com/ee/administration/auth/ldap-ee.html#supported-ldap-group-types-attributes).
+
Please note that GitLab does not support the custom filter syntax used by
omniauth-ldap.
@@ -245,6 +278,19 @@ In other words, if an existing GitLab user wants to enable LDAP sign-in for
themselves, they should check that their GitLab email address matches their
LDAP email address, and then sign into GitLab via their LDAP credentials.
+## Encryption
+
+### TLS Server Authentication
+
+There are two encryption methods, `simple_tls` and `start_tls`.
+
+For either encryption method, if setting `validate_certificates: false`, TLS
+encryption is established with the LDAP server before any LDAP-protocol data is
+exchanged but no validation of the LDAP server's SSL certificate is performed.
+
+>**Note**: Before GitLab 9.5, `validate_certificates: false` is the default if
+unspecified.
+
## Limitations
### TLS Client Authentication
@@ -254,14 +300,6 @@ You should disable anonymous LDAP authentication and enable simple or SASL
authentication. The TLS client authentication setting in your LDAP server cannot
be mandatory and clients cannot be authenticated with the TLS protocol.
-### TLS Server Authentication
-
-Not supported by GitLab's configuration options.
-When setting `method: ssl`, the underlying authentication method used by
-`omniauth-ldap` is `simple_tls`. This method establishes TLS encryption with
-the LDAP server before any LDAP-protocol data is exchanged but no validation of
-the LDAP server's SSL certificate is performed.
-
## Troubleshooting
### Debug LDAP user filter with ldapsearch
@@ -301,9 +339,9 @@ tree and traverse it.
### Connection Refused
If you are getting 'Connection Refused' errors when trying to connect to the
-LDAP server please double-check the LDAP `port` and `method` settings used by
-GitLab. Common combinations are `method: 'plain'` and `port: 389`, OR
-`method: 'ssl'` and `port: 636`.
+LDAP server please double-check the LDAP `port` and `encryption` settings used by
+GitLab. Common combinations are `encryption: 'plain'` and `port: 389`, OR
+`encryption: 'simple_tls'` and `port: 636`.
### Troubleshooting
diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md
index f707039827b..57e54815b68 100644
--- a/doc/administration/container_registry.md
+++ b/doc/administration/container_registry.md
@@ -1,10 +1,7 @@
# GitLab Container Registry administration
-> [Introduced][ce-4040] in GitLab 8.8.
-
----
-
> **Notes:**
+- [Introduced][ce-4040] in GitLab 8.8.
- Container Registry manifest `v1` support was added in GitLab 8.9 to support
Docker versions earlier than 1.10.
- This document is about the admin guide. To learn how to use GitLab Container
@@ -115,7 +112,7 @@ GitLab from source respectively.
>**Note:**
Be careful to choose a port different than the one that Registry listens to (`5000` by default),
-otherwise you will run into conflicts .
+otherwise you will run into conflicts.
---
@@ -468,23 +465,42 @@ on how to achieve that.
## Disable Container Registry but use GitLab as an auth endpoint
-You can disable the embedded Container Registry to use an external one, but
-still use GitLab as an auth endpoint.
-
**Omnibus GitLab**
+
+You can use GitLab as an auth endpoint and use a non-bundled Container Registry.
+
1. Open `/etc/gitlab/gitlab.rb` and set necessary configurations:
```ruby
- registry['enable'] = false
gitlab_rails['registry_enabled'] = true
gitlab_rails['registry_host'] = "registry.gitlab.example.com"
gitlab_rails['registry_port'] = "5005"
gitlab_rails['registry_api_url'] = "http://localhost:5000"
- gitlab_rails['registry_key_path'] = "/var/opt/gitlab/gitlab-rails/certificate.key"
gitlab_rails['registry_path'] = "/var/opt/gitlab/gitlab-rails/shared/registry"
gitlab_rails['registry_issuer'] = "omnibus-gitlab-issuer"
```
+1. A certificate keypair is required for GitLab and the Container Registry to
+ communicate securely. By default omnibus-gitlab will generate one keypair,
+ which is saved to `/var/opt/gitlab/gitlab-rails/etc/gitlab-registry.key`.
+ When using an non-bundled Container Registry, you will need to supply a
+ custom certificate key. To do that, add the following to
+ `/etc/gitlab/gitlab.rb`
+
+ ```ruby
+ gitlab_rails['registry_key_path'] = "/custom/path/to/registry-key.key"
+ # registry['internal_key'] should contain the contents of the custom key
+ # file. Line breaks in the key file should be marked using `\n` character
+ # Example:
+ registry['internal_key'] = "---BEGIN RSA PRIVATE KEY---\nMIIEpQIBAA\n"
+ ```
+
+ **Note:** The file specified at `registry_key_path` gets populated with the
+ content specified by `internal_key`, each time reconfigure is executed. If
+ no file is specified, omnibus-gitlab will default it to
+ `/var/opt/gitlab/gitlab-rails/etc/gitlab-registry.key` and will populate
+ it.
+
1. Save the file and [reconfigure GitLab][] for the changes to take effect.
**Installations from source**
@@ -514,8 +530,8 @@ configurable in future releases.
## Configure Container Registry notifications
-You can configure the Container Registry to send webhook notifications in
-response to events happening within the registry.
+You can configure the Container Registry to send webhook notifications in
+response to events happening within the registry.
Read more about the Container Registry notifications config options in the
[Docker Registry notifications documentation][notifications-config].
@@ -568,12 +584,25 @@ notifications:
backoff: 1000
```
-## Changelog
+## Using self-signed certificates with Container Registry
+
+If you're using a self-signed certificate with your Container Registry, you
+might encounter issues during the CI jobs like the following:
+
+```
+Error response from daemon: Get registry.example.com/v1/users/: x509: certificate signed by unknown authority
+```
-**GitLab 8.8 ([source docs][8-8-docs])**
+The Docker daemon running the command expects a cert signed by a recognized CA,
+thus the error above.
-- GitLab Container Registry feature was introduced.
+While GitLab doesn't support using self-signed certificates with Container
+Registry out of the box, it is possible to make it work if you follow
+[Docker's documentation][docker-insecure]. You may find some additional
+information in [issue 18239][ce-18239].
+[ce-18239]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18239
+[docker-insecure]: https://docs.docker.com/registry/insecure/#using-self-signed-certificates
[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure
[restart gitlab]: restart_gitlab.md#installations-from-source
[wildcard certificate]: https://en.wikipedia.org/wiki/Wildcard_certificate
@@ -589,4 +618,4 @@ notifications:
[existing-domain]: #configure-container-registry-under-an-existing-gitlab-domain
[new-domain]: #configure-container-registry-under-its-own-domain
[notifications-config]: https://docs.docker.com/registry/notifications/
-[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications \ No newline at end of file
+[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications
diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md
index 76029b30dd8..9bcd13a52f7 100644
--- a/doc/administration/environment_variables.md
+++ b/doc/administration/environment_variables.md
@@ -13,6 +13,7 @@ override certain values.
Variable | Type | Description
-------- | ---- | -----------
+`GITLAB_CDN_HOST` | string | Sets the hostname for a CDN to serve static assets (e.g. `mycdnsubdomain.fictional-cdn.com`)
`GITLAB_ROOT_PASSWORD` | string | Sets the password for the `root` user on installation
`GITLAB_HOST` | string | The full URL of the GitLab server (including `http://` or `https://`)
`RAILS_ENV` | string | The Rails environment; can be one of `production`, `development`, `staging` or `test`
@@ -20,7 +21,6 @@ Variable | Type | Description
`GITLAB_EMAIL_FROM` | string | The e-mail address used in the "From" field in e-mails sent by GitLab
`GITLAB_EMAIL_DISPLAY_NAME` | string | The name used in the "From" field in e-mails sent by GitLab
`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab
-`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab
`GITLAB_EMAIL_SUBJECT_SUFFIX` | string | The e-mail subject suffix used in e-mails sent by GitLab
`GITLAB_UNICORN_MEMORY_MIN` | integer | The minimum memory threshold (in bytes) for the Unicorn worker killer
`GITLAB_UNICORN_MEMORY_MAX` | integer | The maximum memory threshold (in bytes) for the Unicorn worker killer
@@ -59,6 +59,9 @@ to the naming scheme `GITLAB_#{name in 1_settings.rb in upper case}`.
## Omnibus configuration
+To set environment variables, follow [these
+instructions](https://docs.gitlab.com/omnibus/settings/environment-variables.html).
+
It's possible to preconfigure the GitLab docker image by adding the environment
variable `GITLAB_OMNIBUS_CONFIG` to the `docker run` command.
For more information see the ['preconfigure-docker-container' section in the Omnibus documentation](http://docs.gitlab.com/omnibus/docker/#preconfigure-docker-container).
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index 6c6942a7bfe..5732b6a1ca4 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -2,8 +2,7 @@
[Gitaly](https://gitlab.com/gitlab-org/gitaly) (introduced in GitLab
9.0) is a service that provides high-level RPC access to Git
-repositories. As of GitLab 9.1 it is still an optional component with
-limited scope.
+repositories. Gitaly is a mandatory component in GitLab 9.4 and newer.
GitLab components that access Git repositories (gitlab-rails,
gitlab-shell, gitlab-workhorse) act as clients to Gitaly. End users do
@@ -33,36 +32,155 @@ prometheus_listen_addr = "localhost:9236"
Changes to `/home/git/gitaly/config.toml` are applied when you run `service
gitlab restart`.
-## Configuring GitLab to not use Gitaly
+## Running Gitaly on its own server
-Gitaly is still an optional component in GitLab 9.0. This means you
-can choose to not use it.
+> This is an optional way to deploy Gitaly which can benefit GitLab
+installations that are larger than a single machine. Most
+installations will be better served with the default configuration
+used by Omnibus and the GitLab source installation guide.
-In Omnibus you can make the following change in
-`/etc/gitlab/gitlab.rb` and reconfigure. This will both disable the
-Gitaly service and configure the rest of GitLab not to use it.
+Starting with GitLab 9.4 it is possible to run Gitaly on a different
+server from the rest of the application. This can improve performance
+when running GitLab with its repositories stored on an NFS server.
+
+At the moment (GitLab 9.4) Gitaly is not yet a replacement for NFS
+because some parts of GitLab still bypass Gitaly when accessing Git
+repositories. If you choose to deploy Gitaly on your NFS server you
+must still also mount your Git shares on your GitLab application
+servers.
+
+Gitaly network traffic is unencrypted so you should use a firewall to
+restrict access to your Gitaly server.
+
+Below we describe how to configure a Gitaly server at address
+`gitaly.internal:9999` with secret token `abc123secret`. We assume
+your GitLab installation has two repository storages, `default` and
+`storage1`.
+
+### Client side token configuration
+
+Start by configuring a token on the client side.
+
+Omnibus installations:
```ruby
-gitaly['enable'] = false
+# /etc/gitlab/gitlab.rb
+gitlab_rails['gitaly_token'] = 'abc123secret'
+```
+
+Source installations:
+
+```yaml
+# /home/git/gitlab/config/gitlab.yml
+gitlab:
+ gitaly:
+ token: 'abc123secret'
+```
+
+You need to reconfigure (Omnibus) or restart (source) for these
+changes to be picked up.
+
+### Gitaly server configuration
+
+Next, on the Gitaly server, we need to configure storage paths, enable
+the network listener and configure the token.
+
+Note: if you want to reduce the risk of downtime when you enable
+authentication you can temporarily disable enforcement, see [the
+documentation on configuring Gitaly
+authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication)
+.
+
+In most or all cases the storage paths below end in `/repositories`. Check the
+directory layout on your Gitaly server to be sure.
+
+Omnibus installations:
+
+```ruby
+# /etc/gitlab/gitlab.rb
+gitaly['listen_addr'] = '0.0.0.0:9999'
+gitaly['auth_token'] = 'abc123secret'
+gitaly['storage'] = [
+ { 'name' => 'default', 'path' => '/path/to/default/repositories' },
+ { 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' },
+]
+```
+
+Source installations:
+
+```toml
+# /home/git/gitaly/config.toml
+listen_addr = '0.0.0.0:9999'
+
+[auth]
+token = 'abc123secret'
+
+[[storage]
+name = 'default'
+path = '/path/to/default/repositories'
+
+[[storage]]
+name = 'storage1'
+path = '/path/to/storage1/repositories'
```
-In source installations, edit `/home/git/gitlab/config/gitlab.yml` and
-make sure `enabled` in the `gitaly` section is set to 'false'. This
-does not disable the Gitaly service in your init script; it only
-prevents it from being used.
+Again, reconfigure (Omnibus) or restart (source).
+
+### Converting clients to use the Gitaly server
-Apply the change with `service gitlab restart`.
+Now as the final step update the client machines to switch from using
+their local Gitaly service to the new Gitaly server you just
+configured. This is a risky step because if there is any sort of
+network, firewall, or name resolution problem preventing your GitLab
+server from reaching the Gitaly server then all Gitaly requests will
+fail.
+
+We assume that your Gitaly server can be reached at
+`gitaly.internal:9999` from your GitLab server, and that your GitLab
+NFS shares are mounted at `/mnt/gitlab/default` and
+`/mnt/gitlab/storage1` respectively.
+
+Omnibus installations:
+
+```ruby
+# /etc/gitlab/gitlab.rb
+git_data_dirs({
+ { 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' } },
+ { 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' } },
+})
+
+gitlab_rails['gitaly_token'] = 'abc123secret'
+```
+
+Source installations:
```yaml
+# /home/git/gitlab/config/gitlab.yml
+gitlab:
+ repositories:
+ storages:
+ default:
+ path: /mnt/gitlab/default/repositories
+ gitaly_address: tcp://gitlab.internal:9999
+ storage1:
+ path: /mnt/gitlab/storage1/repositories
+ gitaly_address: tcp://gitlab.internal:9999
+
gitaly:
- enabled: false
+ token: 'abc123secret'
```
+Now reconfigure (Omnibus) or restart (source). When you tail the
+Gitaly logs on your Gitaly server (`sudo gitlab-ctl tail gitaly` or
+`tail -f /home/git/gitlab/log/gitaly.log`) you should see requests
+coming in. One sure way to trigger a Gitaly request is to clone a
+repository from your GitLab server over HTTP.
+
## Disabling or enabling the Gitaly service
-Be careful: if you disable Gitaly without instructing the rest of your
-GitLab installation not to use Gitaly, you may end up with errors
-because GitLab tries to access a service that is not running.
+If you are running Gitaly [as a remote
+service](#running-gitaly-on-its-own-server) you may want to disable
+the local Gitaly service that runs on your Gitlab server by default.
To disable the Gitaly service in your Omnibus installation, add the
following line to `/etc/gitlab/gitlab.rb`:
@@ -81,4 +199,4 @@ following to `/etc/default/gitlab`:
gitaly_enabled=false
```
-When you run `service gitlab restart` Gitaly will be disabled. \ No newline at end of file
+When you run `service gitlab restart` Gitaly will be disabled.
diff --git a/doc/administration/high_availability/README.md b/doc/administration/high_availability/README.md
index d5a5aef7ec0..4d3be0ab8f6 100644
--- a/doc/administration/high_availability/README.md
+++ b/doc/administration/high_availability/README.md
@@ -5,6 +5,20 @@ The solution you choose will be based on the level of scalability and
availability you require. The easiest solutions are scalable, but not necessarily
highly available.
+GitLab provides a service that is usually essential to most organizations: it
+enables people to collaborate on code in a timely fashion. Any downtime should
+therefore be short and planned. Luckily, GitLab provides a solid setup even on
+a single server without special measures. Due to the distributed nature
+of Git, developers can still commit code locally even when GitLab is not
+available. However, some GitLab features such as the issue tracker and
+Continuous Integration are not available when GitLab is down.
+
+**Keep in mind that all Highly Available solutions come with a trade-off between
+cost/complexity and uptime**. The more uptime you want, the more complex the
+solution. And the more complex the solution, the more work is involved in
+setting up and maintaining it. High availability is not free and every HA
+solution should balance the costs against the benefits.
+
## Architecture
There are two kinds of setups:
@@ -37,6 +51,10 @@ Block Device) to keep all data in sync. DRBD requires a low latency link to
remain in sync. It is not advisable to attempt to run DRBD between data centers
or in different cloud availability zones.
+> **Note:** GitLab recommends against choosing this HA method because of the
+ complexity of managing DRBD and crafting automatic failover. This is
+ *compatible* with GitLab, but not officially *supported*.
+
Components/Servers Required: 2 servers/virtual machines (one active/one passive)
![Active/Passive HA Diagram](../img/high_availability/active-passive-diagram.png)
diff --git a/doc/administration/high_availability/database.md b/doc/administration/high_availability/database.md
index c22b1af8bfb..ca6d8d2de67 100644
--- a/doc/administration/high_availability/database.md
+++ b/doc/administration/high_availability/database.md
@@ -27,7 +27,7 @@ If you use a cloud-managed service, or provide your own PostgreSQL:
steps on the download page.
1. Create/edit `/etc/gitlab/gitlab.rb` and use the following configuration.
Be sure to change the `external_url` to match your eventual GitLab front-end
- URL.
+ URL. If there is a directive listed below that you do not see in the configuration, be sure to add it.
```ruby
external_url 'https://gitlab.example.com'
@@ -39,6 +39,8 @@ If you use a cloud-managed service, or provide your own PostgreSQL:
unicorn['enable'] = false
sidekiq['enable'] = false
redis['enable'] = false
+ prometheus['enable'] = false
+ gitaly['enable'] = false
gitlab_workhorse['enable'] = false
mailroom['enable'] = false
@@ -95,9 +97,12 @@ If you use a cloud-managed service, or provide your own PostgreSQL:
Enter new password:
Enter it again:
```
-
-1. Enable the `pg_trgm` extension:
+1. Exit from editing `template1` prompt by typing `\q` and Enter.
+1. Enable the `pg_trgm` extension within the `gitlabhq_production` database:
+
```
+ gitlab-psql -d gitlabhq_production
+
CREATE EXTENSION pg_trgm;
# Output:
diff --git a/doc/administration/high_availability/gitlab.md b/doc/administration/high_availability/gitlab.md
index 137fed35a73..42666357faf 100644
--- a/doc/administration/high_availability/gitlab.md
+++ b/doc/administration/high_availability/gitlab.md
@@ -5,7 +5,7 @@ configure the GitLab application server(s) now. Complete the steps below
for each GitLab application server in your environment.
> **Note:** There is some additional configuration near the bottom for
- secondary GitLab application servers. It's important to read and understand
+ additional GitLab application servers. It's important to read and understand
these additional steps before proceeding with GitLab installation.
1. If necessary, install the NFS client utility packages using the following
@@ -70,10 +70,16 @@ for each GitLab application server in your environment.
gitlab_rails['redis_host'] = '10.1.0.6' # IP/hostname of Redis server
gitlab_rails['redis_password'] = 'Redis Password'
```
+
+ > **Note:** To maintain uniformity of links across HA clusters, the `external_url`
+ on the first application server as well as the additional application
+ servers should point to the external url that users will use to access GitLab.
+ In a typical HA setup, this will be the url of the load balancer which will
+ route traffic to all GitLab application servers in the HA cluster.
1. Run `sudo gitlab-ctl reconfigure` to compile the configuration.
-## Primary GitLab application server
+## First GitLab application server
As a final step, run the setup rake task on the first GitLab application server.
It is not necessary to run this on additional application servers.
@@ -89,10 +95,10 @@ It is not necessary to run this on additional application servers.
[Nginx documentation](http://docs.gitlab.com/omnibus/settings/nginx.html#enable-https)
for more information.
-## Additional configuration for secondary GitLab application servers
+## Extra configuration for additional GitLab application servers
-Secondary GitLab servers (servers configured **after** the first GitLab server)
-need some additional configuration.
+Additional GitLab servers (servers configured **after** the first GitLab server)
+need some extra configuration.
1. Configure shared secrets. These values can be obtained from the primary
GitLab server in `/etc/gitlab/gitlab-secrets.json`. Add these to
diff --git a/doc/administration/high_availability/load_balancer.md b/doc/administration/high_availability/load_balancer.md
index d9ca74ca1a3..359de0efadb 100644
--- a/doc/administration/high_availability/load_balancer.md
+++ b/doc/administration/high_availability/load_balancer.md
@@ -13,7 +13,7 @@ you need to use with GitLab.
| LB Port | Backend Port | Protocol |
| ------- | ------------ | --------------- |
| 80 | 80 | HTTP [^1] |
-| 443 | 443 | HTTPS [^1] [^2] |
+| 443 | 443 | TCP or HTTPS [^1] [^2] |
| 22 | 22 | TCP |
## GitLab Pages Ports
diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md
index c5125dc6d5a..e09ccaba08c 100644
--- a/doc/administration/high_availability/nfs.md
+++ b/doc/administration/high_availability/nfs.md
@@ -1,6 +1,11 @@
# NFS
-## Required NFS Server features
+You can view information and options set for each of the mounted NFS file
+systems by running `sudo nfsstat -m`.
+
+## NFS Server features
+
+### Required features
**File locking**: GitLab **requires** advisory file locking, which is only
supported natively in NFS version 4. NFSv3 also supports locking as long as
@@ -25,9 +30,37 @@ options:
circumstances it could lead to data loss if a failure occurs before data has
synced.
+## AWS Elastic File System
+
+GitLab does not recommend using AWS Elastic File System (EFS).
+
+Customers and users have reported that AWS EFS does not perform well for GitLab's
+use-case. There are several issues that can cause problems. For these reasons
+GitLab does not recommend using EFS with GitLab.
+
+- EFS bases allowed IOPS on volume size. The larger the volume, the more IOPS
+ are allocated. For smaller volumes, users may experience decent performance
+ for a period of time due to 'Burst Credits'. Over a period of weeks to months
+ credits may run out and performance will bottom out.
+- To keep "Burst Credits" available, it may be necessary to provision more space
+ with 'dummy data'. However, this may get expensive.
+- Another option to maintain "Burst Credits" is to use FS Cache on the server so
+ that AWS doesn't always have to go into EFS to access files.
+- For larger volumes, allocated IOPS may not be the problem. Workloads where
+ many small files are written in a serialized manner are not well-suited for EFS.
+ EBS with an NFS server on top will perform much better.
+
+In addition, avoid storing GitLab log files (e.g. those in `/var/log/gitlab`)
+because this will also affect performance. We recommend that the log files be
+stored on a local volume.
+
+For more details on another person's experience with EFS, see
+[Amazon's Elastic File System: Burst Credits](https://www.rawkode.io/2017/04/amazons-elastic-file-system-burst-credits/)
+
## NFS Client mount options
-Below is an example of an NFS mount point we use on GitLab.com:
+Below is an example of an NFS mount point defined in `/etc/fstab` we use on
+GitLab.com:
```
10.1.1.1:/var/opt/gitlab/git-data /var/opt/gitlab/git-data nfs4 defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md
index 4638a9c9782..0e92f7c5a34 100644
--- a/doc/administration/high_availability/redis.md
+++ b/doc/administration/high_availability/redis.md
@@ -42,10 +42,10 @@ instances run in different machines. If you fail to provision the machines in
that specific way, any issue with the shared environment can bring your entire
setup down.
-It is OK to run a Sentinel along with a master or slave Redis instance.
-No more than one Sentinel in the same machine though.
+It is OK to run a Sentinel alongside of a master or slave Redis instance.
+There should be no more than one Sentinel on the same machine though.
-You also need to take in consideration the underlying network topology,
+You also need to take into consideration the underlying network topology,
making sure you have redundant connectivity between Redis / Sentinel and
GitLab instances, otherwise the networks will become a single point of
failure.
@@ -113,7 +113,7 @@ the Omnibus GitLab package in `5` **independent** machines, both with
### Redis setup overview
You must have at least `3` Redis servers: `1` Master, `2` Slaves, and they
-need to be each in a independent machine (see explanation above).
+need to each be on independent machines (see explanation above).
You can have additional Redis nodes, that will help survive a situation
where more nodes goes down. Whenever there is only `2` nodes online, a failover
@@ -232,7 +232,7 @@ Pick the one that suits your needs.
This is the section where we install and setup the new Redis instances.
>**Notes:**
-- We assume that you install GitLab and all HA components from scratch. If you
+- We assume that you have installed GitLab and all HA components from scratch. If you
already have it installed and running, read how to
[switch from a single-machine installation to Redis HA](#switching-from-an-existing-single-machine-installation-to-redis-ha).
- Redis nodes (both master and slaves) will need the same password defined in
@@ -245,10 +245,9 @@ The prerequisites for a HA Redis setup are the following:
1. Provision the minimum required number of instances as specified in the
[recommended setup](#recommended-setup) section.
-1. **Do NOT** install Redis or Redis Sentinel in the same machines your
- GitLab application is running on. You can however opt in to install Redis
- and Sentinel in the same machine (each in independent ones is recommended
- though).
+1. We **Do not** recommend installing Redis or Redis Sentinel in the same machines your
+ GitLab application is running on as this weakens your HA configuration. You can however opt in to install Redis
+ and Sentinel in the same machine.
1. All Redis nodes must be able to talk to each other and accept incoming
connections over Redis (`6379`) and Sentinel (`26379`) ports (unless you
change the default ones).
diff --git a/doc/administration/high_availability/redis_source.md b/doc/administration/high_availability/redis_source.md
index 3629772b8af..8b7a515a076 100644
--- a/doc/administration/high_availability/redis_source.md
+++ b/doc/administration/high_availability/redis_source.md
@@ -4,6 +4,11 @@ This is the documentation for configuring a Highly Available Redis setup when
you have installed Redis all by yourself and not using the bundled one that
comes with the Omnibus packages.
+Note also that you may elect to override all references to
+`/home/git/gitlab/config/resque.yml` in accordance with the advanced Redis
+settings outlined in
+[Configuration Files Documentation](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/README.md).
+
We cannot stress enough the importance of reading the
[Overview section](redis.md#overview) of the Omnibus Redis HA as it provides
some invaluable information to the configuration of Redis. Please proceed to
@@ -364,3 +369,4 @@ When in doubt, please read [Redis Sentinel documentation](http://redis.io/topics
[downloads]: https://about.gitlab.com/downloads
[restart]: ../restart_gitlab.md#installations-from-source
[it]: https://gitlab.com/gitlab-org/gitlab-ce/uploads/c4cc8cd353604bd80315f9384035ff9e/The_Internet_IT_Crowd.png
+[resque]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/resque.yml.example
diff --git a/doc/administration/img/failing_storage.png b/doc/administration/img/failing_storage.png
new file mode 100644
index 00000000000..82b393a58b2
--- /dev/null
+++ b/doc/administration/img/failing_storage.png
Binary files differ
diff --git a/doc/administration/integration/terminal.md b/doc/administration/integration/terminal.md
index 3b5ee86b68b..91e844c7b42 100644
--- a/doc/administration/integration/terminal.md
+++ b/doc/administration/integration/terminal.md
@@ -32,7 +32,7 @@ In brief:
As web terminals use WebSockets, every HTTP/HTTPS reverse proxy in front of
Workhorse needs to be configured to pass the `Connection` and `Upgrade` headers
-through to the next one in the chain. If you installed Gitlab using Omnibus, or
+through to the next one in the chain. If you installed GitLab using Omnibus, or
from source, starting with GitLab 8.15, this should be done by the default
configuration, so there's no need for you to do anything.
@@ -58,7 +58,7 @@ document for more details.
If you'd like to disable web terminal support in GitLab, just stop passing
the `Connection` and `Upgrade` hop-by-hop headers in the *first* HTTP reverse
proxy in the chain. For most users, this will be the NGINX server bundled with
-Omnibus Gitlab, in which case, you need to:
+Omnibus GitLab, in which case, you need to:
* Find the `nginx['proxy_set_headers']` section of your `gitlab.rb` file
* Ensure the whole block is uncommented, and then comment out or remove the
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index 7b0610ae414..3587696225c 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -46,7 +46,10 @@ To disable artifacts site-wide, follow the steps below.
After a successful job, GitLab Runner uploads an archive containing the job
artifacts to GitLab.
-To change the location where the artifacts are stored, follow the steps below.
+### Using local storage
+
+To change the location where the artifacts are stored locally, follow the steps
+below.
---
@@ -82,6 +85,49 @@ _The artifacts are stored by default in
1. Save the file and [restart GitLab][] for the changes to take effect.
+### Using object storage
+
+In [GitLab Enterprise Edition Premium][eep] you can use an object storage like
+AWS S3 to store the artifacts.
+
+[Learn how to use the object storage option.][ee-os]
+
+## Expiring artifacts
+
+If an expiry date is used for the artifacts, they are marked for deletion
+right after that date passes. Artifacts are cleaned up by the
+`expire_build_artifacts_worker` cron job which is run by Sidekiq every hour at
+50 minutes (`50 * * * *`).
+
+To change the default schedule on which the artifacts are expired, follow the
+steps below.
+
+---
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and comment out or add the following line
+
+ ```ruby
+ gitlab_rails['expire_build_artifacts_worker_cron'] = "50 * * * *"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ expire_build_artifacts_worker:
+ cron: "50 * * * *"
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
## Set the maximum file size of the artifacts
Provided the artifacts are enabled, you can change the maximum file size of the
@@ -112,3 +158,5 @@ memory and disk I/O.
[reconfigure gitlab]: restart_gitlab.md "How to restart GitLab"
[restart gitlab]: restart_gitlab.md "How to restart GitLab"
[gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository"
+[ee-os]: https://docs.gitlab.com/ee/administration/job_artifacts.html#using-object-storage
+[eep]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition Premium"
diff --git a/doc/administration/logs.md b/doc/administration/logs.md
index 4b8d5c5cc87..76e071dc673 100644
--- a/doc/administration/logs.md
+++ b/doc/administration/logs.md
@@ -9,6 +9,33 @@ documentation](http://docs.gitlab.com/ee/administration/audit_events.html)
System log files are typically plain text in a standard log file format.
This guide talks about how to read and use these system log files.
+## `production_json.log`
+
+This file lives in `/var/log/gitlab/gitlab-rails/production_json.log` for
+Omnibus GitLab packages or in `/home/git/gitlab/log/production_json.log` for
+installations from source. (When Gitlab is running in an environment
+other than production, the corresponding logfile is shown here.)
+
+It contains a structured log for Rails controller requests received from
+GitLab, thanks to [Lograge](https://github.com/roidrage/lograge/). Note that
+requests from the API [are not yet logged to this
+file](https://gitlab.com/gitlab-org/gitlab-ce/issues/36189).
+
+Each line contains a JSON line that can be ingested by Elasticsearch, Splunk, etc. For example:
+
+```json
+{"method":"GET","path":"/gitlab/gitlab-ce/issues/1234","format":"html","controller":"Projects::IssuesController","action":"show","status":200,"duration":229.03,"view":174.07,"db":13.24,"time":"2017-08-08T20:15:54.821Z","params":{"namespace_id":"gitlab","project_id":"gitlab-ce","id":"1234"},"remote_ip":"18.245.0.1","user_id":1,"username":"admin"}
+```
+
+In this example, you can see this was a GET request for a specific issue. Notice each line also contains performance data:
+
+1. `duration`: the total time taken to retrieve the request
+2. `view`: total time taken inside the Rails views
+3. `db`: total time to retrieve data from the database
+
+In addition, the log contains the IP address from which the request originated
+(`remote_ip`) as well as the user's ID (`user_id`), and username (`username`).
+
## `production.log`
This file lives in `/var/log/gitlab/gitlab-rails/production.log` for
diff --git a/doc/administration/monitoring/ip_whitelist.md b/doc/administration/monitoring/ip_whitelist.md
new file mode 100644
index 00000000000..ad2773de132
--- /dev/null
+++ b/doc/administration/monitoring/ip_whitelist.md
@@ -0,0 +1,39 @@
+# IP whitelist
+
+> Introduced in GitLab 9.4.
+
+GitLab provides some [monitoring endpoints] that provide health check information
+when probed.
+
+To control access to those endpoints via IP whitelisting, you can add single
+hosts or use IP ranges:
+
+**For Omnibus installations**
+
+1. Open `/etc/gitlab/gitlab.rb` and add or uncomment the following:
+
+ ```ruby
+ gitlab_rails['monitoring_whitelist'] = ['127.0.0.0/8', '192.168.0.1']
+ ```
+
+1. Save the file and [reconfigure] GitLab for the changes to take effect.
+
+---
+
+**For installations from source**
+
+1. Edit `config/gitlab.yml`:
+
+ ```yaml
+ monitoring:
+ # by default only local IPs are allowed to access monitoring resources
+ ip_whitelist:
+ - 127.0.0.0/8
+ - 192.168.0.1
+ ```
+
+1. Save the file and [restart] GitLab for the changes to take effect.
+
+[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure
+[restart]: ../restart_gitlab.md#installations-from-source
+[monitoring endpoints]: ../../user/admin_area/monitoring/health_check.md
diff --git a/doc/administration/monitoring/performance/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png
new file mode 100644
index 00000000000..b3c6bc474e3
--- /dev/null
+++ b/doc/administration/monitoring/performance/img/performance_bar.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png b/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png
new file mode 100644
index 00000000000..2d64ef8c5fc
--- /dev/null
+++ b/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png b/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png
new file mode 100644
index 00000000000..7868e2c46d1
--- /dev/null
+++ b/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png b/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png
new file mode 100644
index 00000000000..372ae021f6b
--- /dev/null
+++ b/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md
new file mode 100644
index 00000000000..ee680c7b258
--- /dev/null
+++ b/doc/administration/monitoring/performance/performance_bar.md
@@ -0,0 +1,35 @@
+# Performance Bar
+
+A Performance Bar can be displayed, to dig into the performance of a page. When
+activated, it looks as follows:
+
+![Performance Bar](img/performance_bar.png)
+
+It allows you to:
+
+- see the current host serving the page
+- see the timing of the page (backend, frontend)
+- the number of DB queries, the time it took, and the detail of these queries
+![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png)
+- the number of calls to Redis, and the time it took
+- the number of background jobs created by Sidekiq, and the time it took
+- the number of Ruby GC calls, and the time it took
+- profile the code used to generate the page, line by line
+![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png)
+
+## Enable the Performance Bar via the Admin panel
+
+GitLab Performance Bar is disabled by default. To enable it for a given group,
+navigate to the Admin area in **Settings > Profiling - Performance Bar**
+(`/admin/application_settings`).
+
+The only required setting you need to set is the full path of the group that
+will be allowed to display the Performance Bar.
+Make sure _Enable the Performance Bar_ is checked and hit
+**Save** to save the changes.
+
+---
+
+![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png)
+
+---
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
new file mode 100644
index 00000000000..6baae20d16a
--- /dev/null
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -0,0 +1,69 @@
+# GitLab Prometheus metrics
+
+>**Note:**
+Available since [Omnibus GitLab 9.3][29118]. Currently experimental. For
+installations from source you'll have to configure it yourself.
+
+To enable the GitLab Prometheus metrics:
+
+1. Log into GitLab as an administrator, and go to the Admin area.
+1. Click on the gear, then click on Settings.
+1. Find the `Metrics - Prometheus` section, and click `Enable Prometheus Metrics`
+1. [Restart GitLab][restart] for the changes to take effect
+
+## Collecting the metrics
+
+GitLab monitors its own internal service metrics, and makes them available at the
+`/-/metrics` endpoint. Unlike other [Prometheus] exporters, in order to access
+it, the client IP needs to be [included in a whitelist][whitelist].
+
+Currently the embedded Prometheus server is not automatically configured to
+collect metrics from this endpoint. We recommend setting up another Prometheus
+server, because the embedded server configuration is overwritten once every
+[reconfigure of GitLab][reconfigure]. In the future this will not be required.
+
+## Metrics available
+
+In this experimental phase, only a few metrics are available:
+
+| Metric | Type | Since | Description |
+|:--------------------------------- |:--------- |:----- |:----------- |
+| db_ping_timeout | Gauge | 9.4 | Whether or not the last database ping timed out |
+| db_ping_success | Gauge | 9.4 | Whether or not the last database ping succeeded |
+| db_ping_latency_seconds | Gauge | 9.4 | Round trip time of the database ping |
+| filesystem_access_latency_seconds | Gauge | 9.4 | Latency in accessing a specific filesystem |
+| filesystem_accessible | Gauge | 9.4 | Whether or not a specific filesystem is accessible |
+| filesystem_write_latency_seconds | Gauge | 9.4 | Write latency of a specific filesystem |
+| filesystem_writable | Gauge | 9.4 | Whether or not the filesystem is writable |
+| filesystem_read_latency_seconds | Gauge | 9.4 | Read latency of a specific filesystem |
+| filesystem_readable | Gauge | 9.4 | Whether or not the filesystem is readable |
+| http_requests_total | Counter | 9.4 | Rack request count |
+| http_request_duration_seconds | Histogram | 9.4 | HTTP response time from rack middleware |
+| pipelines_created_total | Counter | 9.4 | Counter of pipelines created |
+| rack_uncaught_errors_total | Counter | 9.4 | Rack connections handling uncaught errors count |
+| redis_ping_timeout | Gauge | 9.4 | Whether or not the last redis ping timed out |
+| redis_ping_success | Gauge | 9.4 | Whether or not the last redis ping succeeded |
+| redis_ping_latency_seconds | Gauge | 9.4 | Round trip time of the redis ping |
+| user_session_logins_total | Counter | 9.4 | Counter of how many users have logged in |
+
+## Metrics shared directory
+
+GitLab's Prometheus client requires a directory to store metrics data shared between multi-process services.
+Those files are shared among all instances running under Unicorn server.
+The directory needs to be accessible to all running Unicorn's processes otherwise
+metrics will not function correctly.
+
+For best performance its advisable that this directory will be located in `tmpfs`.
+
+Its location is configured using environment variable `prometheus_multiproc_dir`.
+
+If GitLab is installed using Omnibus and `tmpfs` is available then metrics
+directory will be automatically configured.
+
+[↠Back to the main Prometheus page](index.md)
+
+[29118]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29118
+[Prometheus]: https://prometheus.io
+[restart]: ../../restart_gitlab.md#omnibus-gitlab-restart
+[whitelist]: ../ip_whitelist.md
+[reconfigure]: ../../restart_gitlab.md#omnibus-gitlab-reconfigure
diff --git a/doc/administration/monitoring/prometheus/gitlab_monitor_exporter.md b/doc/administration/monitoring/prometheus/gitlab_monitor_exporter.md
index edb9c911aac..f68b03d1ade 100644
--- a/doc/administration/monitoring/prometheus/gitlab_monitor_exporter.md
+++ b/doc/administration/monitoring/prometheus/gitlab_monitor_exporter.md
@@ -4,7 +4,7 @@
Available since [Omnibus GitLab 8.17][1132]. For installations from source
you'll have to install and configure it yourself.
-The [GitLab monitor exporter] allows you to measure various GitLab metrics.
+The [GitLab monitor exporter] allows you to measure various GitLab metrics, pulled from Redis and the database.
To enable the GitLab monitor exporter:
diff --git a/doc/administration/monitoring/prometheus/index.md b/doc/administration/monitoring/prometheus/index.md
index b2445d1c0e5..f43c89dad87 100644
--- a/doc/administration/monitoring/prometheus/index.md
+++ b/doc/administration/monitoring/prometheus/index.md
@@ -95,8 +95,9 @@ Sample Prometheus queries:
## Configuring Prometheus to monitor Kubernetes
> Introduced in GitLab 9.0.
+> Pod monitoring introduced in GitLab 9.4.
-If your GitLab server is running within Kubernetes, Prometheus will collect metrics from the Nodes in the cluster including performance data on each container. This is particularly helpful if your CI/CD environments run in the same cluster, as you can use the [Prometheus project integration][] to monitor them.
+If your GitLab server is running within Kubernetes, Prometheus will collect metrics from the Nodes and [annotated Pods](https://prometheus.io/docs/operating/configuration/#<kubernetes_sd_config>) in the cluster, including performance data on each container. This is particularly helpful if your CI/CD environments run in the same cluster, as you can use the [Prometheus project integration][] to monitor them.
To disable the monitoring of Kubernetes:
@@ -110,6 +111,14 @@ To disable the monitoring of Kubernetes:
1. Save the file and [reconfigure GitLab][reconfigure] for the changes to
take effect
+## GitLab Prometheus metrics
+
+> Introduced as an experimental feature in GitLab 9.3.
+
+GitLab monitors its own internal service metrics, and makes them available at the `/-/metrics` endpoint. Unlike other exporters, this endpoint requires authentication as it is available on the same URL and port as user traffic.
+
+[âž” Read more about the GitLab Metrics.](gitlab_metrics.md)
+
## Prometheus exporters
There are a number of libraries and servers which help in exporting existing
@@ -143,7 +152,7 @@ The Postgres exporter allows you to measure various PostgreSQL metrics.
### GitLab monitor exporter
-The GitLab monitor exporter allows you to measure various GitLab metrics.
+The GitLab monitor exporter allows you to measure various GitLab metrics, pulled from Redis and the database.
[âž” Read more about the GitLab monitor exporter.](gitlab_monitor_exporter.md)
diff --git a/doc/administration/operations/cleaning_up_redis_sessions.md b/doc/administration/operations/cleaning_up_redis_sessions.md
index 93521e976d5..3a35aff8366 100644
--- a/doc/administration/operations/cleaning_up_redis_sessions.md
+++ b/doc/administration/operations/cleaning_up_redis_sessions.md
@@ -15,6 +15,12 @@ prefixed with 'session:gitlab:', so they would look like
'session:gitlab:976aa289e2189b17d7ef525a6702ace9'. Below we describe how to
remove the keys in the old format.
+**Note:** the instructions below must be modified in accordance with your
+configuration settings if you have used the advanced Redis
+settings outlined in
+[Configuration Files Documentation](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/README.md).
+
+
First we define a shell function with the proper Redis connection details.
```
diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md
index affb4d17861..04c70c3644e 100644
--- a/doc/administration/raketasks/github_import.md
+++ b/doc/administration/raketasks/github_import.md
@@ -3,7 +3,7 @@
>**Note:**
>
> - [Introduced][ce-10308] in GitLab 9.1.
-> - You need a personal access token in order to retrieve and import GitHub
+> - You need a personal access token in order to retrieve and import GitHub
> projects. You can get it from: https://github.com/settings/tokens
> - You also need to pass an username as the second argument to the rake task
> which will become the owner of the project.
@@ -19,7 +19,7 @@ bundle exec rake import:github[access_token,root,foo/bar] RAILS_ENV=production
```
In this case, `access_token` is your GitHub personal access token, `root`
-is your GitLab username, and `foo/bar` is the new GitLab namespace/project that
+is your GitLab username, and `foo/bar` is the new GitLab namespace/project that
will get created from your GitHub project. Subgroups are also possible: `foo/foo/bar`.
diff --git a/doc/administration/reply_by_email_postfix_setup.md b/doc/administration/reply_by_email_postfix_setup.md
index 3b8c716eff5..a1bb3851951 100644
--- a/doc/administration/reply_by_email_postfix_setup.md
+++ b/doc/administration/reply_by_email_postfix_setup.md
@@ -177,6 +177,20 @@ Courier, which we will install later to add IMAP authentication, requires mailbo
```sh
sudo apt-get install courier-imap
```
+
+ And start `imapd`:
+ ```sh
+ imapd start
+ ```
+
+1. The courier-authdaemon isn't started after installation. Without it, imap authentication will fail:
+ ```sh
+ sudo service courier-authdaemon start
+ ```
+ You can also configure courier-authdaemon to start on boot:
+ ```sh
+ sudo systemctl enable courier-authdaemon
+ ```
## Configure Postfix to receive email from the internet
diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md
index 55a45119525..624a908b3a3 100644
--- a/doc/administration/repository_storage_paths.md
+++ b/doc/administration/repository_storage_paths.md
@@ -60,7 +60,7 @@ respectively.
path: /mnt/cephfs/repositories
```
-1. [Restart GitLab] for the changes to take effect.
+1. [Restart GitLab][restart-gitlab] for the changes to take effect.
>**Note:**
The [`gitlab_shell: repos_path` entry][repospath] in `gitlab.yml` will be
@@ -97,9 +97,80 @@ be stored via the **Application Settings** in the Admin area.
Beginning with GitLab 8.13.4, multiple paths can be chosen. New projects will be
randomly placed on one of the selected paths.
+## Handling failing repository storage
+
+> [Introduced][ce-11449] in GitLab 9.5.
+
+When GitLab detects access to the repositories storage fails repeatedly, it can
+gracefully prevent attempts to access the storage. This might be useful when
+the repositories are stored somewhere on the network.
+
+The configuration could look as follows:
+
+**For Omnibus installations**
+
+1. Edit `/etc/gitlab/gitlab.rb`:
+
+ ```ruby
+ git_data_dirs({
+ "default" => {
+ "path" => "/mnt/nfs-01/git-data",
+ "failure_count_threshold" => 10,
+ "failure_wait_time" => 30,
+ "failure_reset_time" => 1800,
+ "storage_timeout" => 5
+ }
+ })
+ ```
+
+1. Save the file and [reconfigure GitLab][reconfigure-gitlab] for the changes to take effect.
+
+---
+
+**For installations from source**
+
+1. Edit `config/gitlab.yml`:
+
+ ```yaml
+ repositories:
+ storages: # You must have at least a `default` storage path.
+ default:
+ path: /home/git/repositories/
+ failure_count_threshold: 10 # number of failures before stopping attempts
+ failure_wait_time: 30 # Seconds after last access failure before trying again
+ failure_reset_time: 1800 # Time in seconds to expire failures
+ storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt
+ ```
+
+1. Save the file and [restart GitLab][restart-gitlab] for the changes to take effect.
+
+
+**`failure_count_threshold`:** The number of failures of after which GitLab will
+completely prevent access to the storage. The number of failures can be reset in
+the admin interface: `https://gitlab.example.com/admin/health_check` or using the
+[api](../api/repository_storage_health.md) to allow access to the storage again.
+
+**`failure_wait_time`:** When access to a storage fails. GitLab will prevent
+access to the storage for the time specified here. This allows the filesystem to
+recover without.
+
+**`failure_reset_time`:** The time in seconds GitLab will keep failure
+information. When no failures occur during this time, information about the
+mount is reset.
+
+**`storage_timeout`:** The time in seconds GitLab will try to access storage.
+After this time a timeout error will be raised.
+
+When storage failures occur, this will be visible in the admin interface like this:
+
+![failing storage](img/failing_storage.png)
+
+To allow access to all storages, click the `Reset git storage health information` button.
+
[ce-4578]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4578
-[restart gitlab]: restart_gitlab.md#installations-from-source
-[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure
+[restart-gitlab]: restart_gitlab.md#installations-from-source
+[reconfigure-gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure
[backups]: ../raketasks/backup_restore.md
[raketask]: https://gitlab.com/gitlab-org/gitlab-ce/blob/033e5423a2594e08a7ebcd2379bd2331f4c39032/lib/backup/repository.rb#L54-56
[repospath]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-9-stable/config/gitlab.yml.example#L457
+[ce-11449]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11449
diff --git a/doc/api/README.md b/doc/api/README.md
index d444ce94573..8acb2145f1a 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -11,10 +11,14 @@ following locations:
- [Award Emoji](award_emoji.md)
- [Branches](branches.md)
- [Broadcast Messages](broadcast_messages.md)
-- [Build Variables](build_variables.md)
+- [Project-level Variables](project_level_variables.md)
+- [Group-level Variables](group_level_variables.md)
- [Commits](commits.md)
- [Deployments](deployments.md)
- [Deploy Keys](deploy_keys.md)
+- [Environments](environments.md)
+- [Events](events.md)
+- [Feature flags](features.md)
- [Gitignores templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
@@ -26,17 +30,20 @@ following locations:
- [Keys](keys.md)
- [Labels](labels.md)
- [Merge Requests](merge_requests.md)
-- [Milestones](milestones.md)
-- [Open source license templates](templates/licenses.md)
+- [Project milestones](milestones.md)
+- [Group milestones](group_milestones.md)
- [Namespaces](namespaces.md)
- [Notes](notes.md) (comments)
- [Notification settings](notification_settings.md)
+- [Open source license templates](templates/licenses.md)
- [Pipelines](pipelines.md)
- [Pipeline Triggers](pipeline_triggers.md)
+- [Pipeline Schedules](pipeline_schedules.md)
- [Projects](projects.md) including setting Webhooks
- [Project Access Requests](access_requests.md)
- [Project Members](members.md)
- [Project Snippets](project_snippets.md)
+- [Protected Branches](protected_branches.md)
- [Repositories](repositories.md)
- [Repository Files](repository_files.md)
- [Runners](runners.md)
@@ -52,18 +59,67 @@ following locations:
- [V3 to V4](v3_to_v4.md)
- [Version](version.md)
-### Internal CI API
-
The following documentation is for the [internal CI API](ci/README.md):
- [Builds](ci/builds.md)
- [Runners](ci/runners.md)
+## Road to GraphQL
+
+Going forward, we will start on moving to
+[GraphQL](http://graphql.org/learn/best-practices/) and deprecate the use of
+controller-specific endpoints. GraphQL has a number of benefits:
+
+1. We avoid having to maintain two different APIs.
+2. Callers of the API can request only what they need.
+3. It is versioned by default.
+
+It will co-exist with the current v4 REST API. If we have a v5 API, this should
+be a compatibility layer on top of GraphQL.
+
+## Basic usage
+
+API requests should be prefixed with `api` and the API version. The API version
+is defined in [`lib/api.rb`][lib-api-url]. For example, the root of the v4 API
+is at `/api/v4`.
+
+For endpoints that require [authentication](#authentication), you need to pass
+a `private_token` parameter via query string or header. If passed as a header,
+the header name must be `PRIVATE-TOKEN` (uppercase and with a dash instead of
+an underscore).
+
+Example of a valid API request:
+
+```
+GET /projects?private_token=9koXpg98eAheJpvBs5tK
+```
+
+Example of a valid API request using cURL and authentication via header:
+
+```shell
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects"
+```
+
+Example of a valid API request using cURL and authentication via a query string:
+
+```shell
+curl "https://gitlab.example.com/api/v4/projects?private_token=9koXpg98eAheJpvBs5tK"
+```
+
+The API uses JSON to serialize data. You don't need to specify `.json` at the
+end of an API URL.
+
## Authentication
-All API requests require authentication via a session cookie or token. There are
-three types of tokens available: private tokens, OAuth 2 tokens, and personal
-access tokens.
+Most API requests require authentication via a session cookie or token. For
+those cases where it is not required, this will be mentioned in the documentation
+for each individual endpoint. For example, the [`/projects/:id` endpoint](projects.md).
+
+There are three types of access tokens available:
+
+1. [OAuth2 tokens](#oauth2-tokens)
+1. [Private tokens](#private-tokens)
+1. [Personal access tokens](#personal-access-tokens)
If authentication information is invalid or omitted, an error message will be
returned with status code `401`:
@@ -74,20 +130,13 @@ returned with status code `401`:
}
```
-### Session Cookie
+### Session cookie
When signing in to GitLab as an ordinary user, a `_gitlab_session` cookie is
set. The API will use this cookie for authentication if it is present, but using
the API to generate a new session cookie is currently not supported.
-### Private Tokens
-
-You need to pass a `private_token` parameter via query string or header. If passed as a
-header, the header name must be `PRIVATE-TOKEN` (uppercase and with a dash instead of
-an underscore). You can find or reset your private token in your account page
-(`/profile/account`).
-
-### OAuth 2 Tokens
+### OAuth2 tokens
You can use an OAuth 2 token to authenticate with the API by passing it either in the
`access_token` parameter or in the `Authorization` header.
@@ -100,30 +149,31 @@ curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api
Read more about [GitLab as an OAuth2 client](oauth2.md).
-### Personal Access Tokens
+### Private tokens
-> [Introduced][ce-3749] in GitLab 8.8.
+Private tokens provide full access to the GitLab API. Anyone with access to
+them can interact with GitLab as if they were you. You can find or reset your
+private token in your account page (`/profile/account`).
-You can create as many personal access tokens as you like from your GitLab
-profile (`/profile/personal_access_tokens`); perhaps one for each application
-that needs access to the GitLab API.
+For examples of usage, [read the basic usage section](#basic-usage).
-Once you have your token, pass it to the API using either the `private_token`
-parameter or the `PRIVATE-TOKEN` header.
+### Personal access tokens
-> [Introduced][ce-5951] in GitLab 8.15.
+Instead of using your private token which grants full access to your account,
+personal access tokens could be a better fit because of their granular
+permissions.
-Personal Access Tokens can be created with one or more scopes that allow various actions
-that a given token can perform. Although there are only two scopes available at the
-moment – `read_user` and `api` – the groundwork has been laid to add more scopes easily.
+Once you have your token, pass it to the API using either the `private_token`
+parameter or the `PRIVATE-TOKEN` header. For examples of usage,
+[read the basic usage section](#basic-usage).
-At any time you can revoke any personal access token by just clicking **Revoke**.
+[Read more about personal access tokens.][pat]
### Impersonation tokens
> [Introduced][ce-9099] in GitLab 9.0. Needs admin permissions.
-Impersonation tokens are a type of [Personal Access Token](#personal-access-tokens)
+Impersonation tokens are a type of [personal access token][pat]
that can only be created by an admin for a specific user.
They are a better alternative to using the user's password/private token
@@ -132,9 +182,11 @@ or private token, since the password/token can change over time. Impersonation
tokens are a great fit if you want to build applications or tools which
authenticate with the API as a specific user.
-For more information about the usage please refer to the
+For more information, refer to the
[users API](users.md#retrieve-user-impersonation-tokens) docs.
+For examples of usage, [read the basic usage section](#basic-usage).
+
### Sudo
> Needs admin permissions.
@@ -187,26 +239,6 @@ GET /projects?private_token=9koXpg98eAheJpvBs5tK&sudo=23
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "SUDO: 23" "https://gitlab.example.com/api/v4/projects"
```
-## Basic Usage
-
-API requests should be prefixed with `api` and the API version. The API version
-is defined in [`lib/api.rb`][lib-api-url].
-
-Example of a valid API request:
-
-```
-GET /projects?private_token=9koXpg98eAheJpvBs5tK
-```
-
-Example of a valid API request using cURL and authentication via header:
-
-```shell
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects"
-```
-
-The API uses JSON to serialize data. You don't need to specify `.json` at the
-end of an API URL.
-
## Status codes
The API is designed to return different status codes according to context and
@@ -311,7 +343,18 @@ URL-encoded.
For example, `/` is represented by `%2F`:
```
-/api/v4/projects/diaspora%2Fdiaspora
+GET /api/v4/projects/diaspora%2Fdiaspora
+```
+
+## Branches & tags name encoding
+
+If your branch or tag contains a `/`, make sure the branch/tag name is
+URL-encoded.
+
+For example, `/` is represented by `%2F`:
+
+```
+GET /api/v4/projects/1/branches/my%2Fbranch/commits
```
## `id` vs `iid`
@@ -419,3 +462,4 @@ programming languages. Visit the [GitLab website] for a complete list.
[ce-3749]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749
[ce-5951]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951
[ce-9099]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9099
+[pat]: ../user/profile/personal_access_tokens.md
diff --git a/doc/api/access_requests.md b/doc/api/access_requests.md
index 21de7d18632..603fa4a8194 100644
--- a/doc/api/access_requests.md
+++ b/doc/api/access_requests.md
@@ -1,4 +1,4 @@
-# Group and project access requests
+# Group and project access requests API
>**Note:** This feature was introduced in GitLab 8.11
diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md
index 5f3adcc397a..d6924741ee4 100644
--- a/doc/api/award_emoji.md
+++ b/doc/api/award_emoji.md
@@ -1,4 +1,4 @@
-# Award Emoji
+# Award Emoji API
> [Introduced][ce-4575] in GitLab 8.9, Snippet support in 8.12
diff --git a/doc/api/boards.md b/doc/api/boards.md
index 17d2be0ee16..69c47abc806 100644
--- a/doc/api/boards.md
+++ b/doc/api/boards.md
@@ -1,4 +1,4 @@
-# Boards
+# Issue Boards API
Every API call to boards must be authenticated.
diff --git a/doc/api/branches.md b/doc/api/branches.md
index 5717215deb6..80744258acb 100644
--- a/doc/api/branches.md
+++ b/doc/api/branches.md
@@ -1,4 +1,4 @@
-# Branches
+# Branches API
## List repository branches
@@ -95,6 +95,8 @@ Example response:
## Protect repository branch
+>**Note:** This API endpoint is deprecated in favor of `POST /projects/:id/protected_branches`.
+
Protects a single project repository branch. This is an idempotent function,
protecting an already protected repository branch still returns a `200 OK`
status code.
@@ -143,6 +145,8 @@ Example response:
## Unprotect repository branch
+>**Note:** This API endpoint is deprecated in favor of `DELETE /projects/:id/protected_branches/:name`
+
Unprotects a single project repository branch. This is an idempotent function,
unprotecting an already unprotected repository branch still returns a `200 OK`
status code.
@@ -251,6 +255,8 @@ curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gi
Will delete all branches that are merged into the project's default branch.
+Protected branches will not be deleted as part of this operation.
+
```
DELETE /projects/:id/repository/merged_branches
```
diff --git a/doc/api/broadcast_messages.md b/doc/api/broadcast_messages.md
index ad254e3515e..a8a248a17f4 100644
--- a/doc/api/broadcast_messages.md
+++ b/doc/api/broadcast_messages.md
@@ -1,4 +1,4 @@
-# Broadcast Messages
+# Broadcast Messages API
> **Note:** This feature was introduced in GitLab 8.12.
diff --git a/doc/api/build_variables.md b/doc/api/build_variables.md
deleted file mode 100644
index 9218902e84a..00000000000
--- a/doc/api/build_variables.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Build Variables
-
-## List project variables
-
-Get list of a project's build variables.
-
-```
-GET /projects/:id/variables
-```
-
-| Attribute | Type | required | Description |
-|-----------|---------|----------|---------------------|
-| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-
-```
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables"
-```
-
-```json
-[
- {
- "key": "TEST_VARIABLE_1",
- "value": "TEST_1"
- },
- {
- "key": "TEST_VARIABLE_2",
- "value": "TEST_2"
- }
-]
-```
-
-## Show variable details
-
-Get the details of a project's specific build variable.
-
-```
-GET /projects/:id/variables/:key
-```
-
-| Attribute | Type | required | Description |
-|-----------|---------|----------|-----------------------|
-| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `key` | string | yes | The `key` of a variable |
-
-```
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/TEST_VARIABLE_1"
-```
-
-```json
-{
- "key": "TEST_VARIABLE_1",
- "value": "TEST_1"
-}
-```
-
-## Create variable
-
-Create a new build variable.
-
-```
-POST /projects/:id/variables
-```
-
-| Attribute | Type | required | Description |
-|-----------|---------|----------|-----------------------|
-| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
-| `value` | string | yes | The `value` of a variable |
-
-```
-curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables" --form "key=NEW_VARIABLE" --form "value=new value"
-```
-
-```json
-{
- "key": "NEW_VARIABLE",
- "value": "new value"
-}
-```
-
-## Update variable
-
-Update a project's build variable.
-
-```
-PUT /projects/:id/variables/:key
-```
-
-| Attribute | Type | required | Description |
-|-----------|---------|----------|-------------------------|
-| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `key` | string | yes | The `key` of a variable |
-| `value` | string | yes | The `value` of a variable |
-
-```
-curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/NEW_VARIABLE" --form "value=updated value"
-```
-
-```json
-{
- "key": "NEW_VARIABLE",
- "value": "updated value"
-}
-```
-
-## Remove variable
-
-Remove a project's build variable.
-
-```
-DELETE /projects/:id/variables/:key
-```
-
-| Attribute | Type | required | Description |
-|-----------|---------|----------|-------------------------|
-| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `key` | string | yes | The `key` of a variable |
-
-```
-curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/VARIABLE_1"
-```
diff --git a/doc/api/ci/lint.md b/doc/api/ci/lint.md
index 74def207816..e4a6dc809b1 100644
--- a/doc/api/ci/lint.md
+++ b/doc/api/ci/lint.md
@@ -1,4 +1,4 @@
-# Validate the .gitlab-ci.yml
+# Validate the .gitlab-ci.yml (API)
> [Introduced][ce-5953] in GitLab 8.12.
@@ -47,3 +47,5 @@ Example responses:
"error": "content is missing"
}
```
+
+[ce-5953]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5953
diff --git a/doc/api/ci/runners.md b/doc/api/ci/runners.md
index 16028d1f124..342c039dad8 100644
--- a/doc/api/ci/runners.md
+++ b/doc/api/ci/runners.md
@@ -1,4 +1,4 @@
-# Runners API
+# Register and Delete Runners API
API used by Runners to register and delete themselves.
diff --git a/doc/api/commits.md b/doc/api/commits.md
index 9cb58dd3ae9..2a78553782f 100644
--- a/doc/api/commits.md
+++ b/doc/api/commits.md
@@ -69,8 +69,9 @@ POST /projects/:id/repository/commits
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
-| `branch` | string | yes | The name of a branch |
+| `branch` | string | yes | Name of the branch to commit into. To create a new branch, also provide `start_branch`. |
| `commit_message` | string | yes | Commit message |
+| `start_branch` | string | no | Name of the branch to start the new commit from |
| `actions[]` | array | yes | An array of action hashes to commit as a batch. See the next table for what attributes it can take. |
| `author_email` | string | no | Specify the commit author's email address |
| `author_name` | string | no | Specify the commit author's name |
@@ -97,7 +98,7 @@ PAYLOAD=$(cat << 'JSON'
},
{
"action": "delete",
- "file_path": "foo/bar2",
+ "file_path": "foo/bar2"
},
{
"action": "move",
diff --git a/doc/api/deploy_key_multiple_projects.md b/doc/api/deploy_key_multiple_projects.md
index f94dbfa4059..127f9a196de 100644
--- a/doc/api/deploy_key_multiple_projects.md
+++ b/doc/api/deploy_key_multiple_projects.md
@@ -1,4 +1,4 @@
-# Adding deploy keys to multiple projects
+# Adding deploy keys to multiple projects via API
If you want to easily add the same deploy key to multiple projects in the same
group, this can be achieved quite easily with the API.
diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md
index c3fe7f84ef2..4fa800ecb9c 100644
--- a/doc/api/deploy_keys.md
+++ b/doc/api/deploy_keys.md
@@ -1,4 +1,4 @@
-# Deploy Keys
+# Deploy Keys API
## List all deploy keys
diff --git a/doc/api/enviroments.md b/doc/api/enviroments.md
deleted file mode 100644
index 49930f01945..00000000000
--- a/doc/api/enviroments.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# Environments
-
-## List environments
-
-Get all environments for a given project.
-
-```
-GET /projects/:id/environments
-```
-
-| Attribute | Type | Required | Description |
-| --------- | ------- | -------- | --------------------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-
-```bash
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/environments
-```
-
-Example response:
-
-```json
-[
- {
- "id": 1,
- "name": "review/fix-foo",
- "slug": "review-fix-foo-dfjre3",
- "external_url": "https://review-fix-foo-dfjre3.example.gitlab.com"
- }
-]
-```
-
-## Create a new environment
-
-Creates a new environment with the given name and external_url.
-
-It returns `201` if the environment was successfully created, `400` for wrong parameters.
-
-```
-POST /projects/:id/environment
-```
-
-| Attribute | Type | Required | Description |
-| ------------- | ------- | -------- | ---------------------------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `name` | string | yes | The name of the environment |
-| `external_url` | string | no | Place to link to for this environment |
-
-```bash
-curl --data "name=deploy&external_url=https://deploy.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments"
-```
-
-Example response:
-
-```json
-{
- "id": 1,
- "name": "deploy",
- "slug": "deploy",
- "external_url": "https://deploy.example.gitlab.com"
-}
-```
-
-## Edit an existing environment
-
-Updates an existing environment's name and/or external_url.
-
-It returns `200` if the environment was successfully updated. In case of an error, a status code `400` is returned.
-
-```
-PUT /projects/:id/environments/:environments_id
-```
-
-| Attribute | Type | Required | Description |
-| --------------- | ------- | --------------------------------- | ------------------------------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `environment_id` | integer | yes | The ID of the environment | The ID of the environment |
-| `name` | string | no | The new name of the environment |
-| `external_url` | string | no | The new external_url |
-
-```bash
-curl --request PUT --data "name=staging&external_url=https://staging.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments/1"
-```
-
-Example response:
-
-```json
-{
- "id": 1,
- "name": "staging",
- "slug": "staging",
- "external_url": "https://staging.example.gitlab.com"
-}
-```
-
-## Delete an environment
-
-It returns `200` if the environment was successfully deleted, and `404` if the environment does not exist.
-
-```
-DELETE /projects/:id/environments/:environment_id
-```
-
-| Attribute | Type | Required | Description |
-| --------- | ------- | -------- | --------------------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `environment_id` | integer | yes | The ID of the environment |
-
-```bash
-curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments/1"
-```
-
-## Stop an environment
-
-It returns `200` if the environment was successfully stopped, and `404` if the environment does not exist.
-
-```
-POST /projects/:id/environments/:environment_id/stop
-```
-
-| Attribute | Type | Required | Description |
-| --------- | ------- | -------- | --------------------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `environment_id` | integer | yes | The ID of the environment |
-
-```bash
-curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environments/1/stop"
-```
-
-Example response:
-
-```json
-{
- "id": 1,
- "name": "deploy",
- "slug": "deploy",
- "external_url": "https://deploy.example.gitlab.com"
-}
-```
diff --git a/doc/api/environments.md b/doc/api/environments.md
new file mode 100644
index 00000000000..5ca766bf87d
--- /dev/null
+++ b/doc/api/environments.md
@@ -0,0 +1,138 @@
+# Environments API
+
+## List environments
+
+Get all environments for a given project.
+
+```
+GET /projects/:id/environments
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | --------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/environments
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "name": "review/fix-foo",
+ "slug": "review-fix-foo-dfjre3",
+ "external_url": "https://review-fix-foo-dfjre3.example.gitlab.com"
+ }
+]
+```
+
+## Create a new environment
+
+Creates a new environment with the given name and external_url.
+
+It returns `201` if the environment was successfully created, `400` for wrong parameters.
+
+```
+POST /projects/:id/environment
+```
+
+| Attribute | Type | Required | Description |
+| ------------- | ------- | -------- | ---------------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the environment |
+| `external_url` | string | no | Place to link to for this environment |
+
+```bash
+curl --data "name=deploy&external_url=https://deploy.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "name": "deploy",
+ "slug": "deploy",
+ "external_url": "https://deploy.example.gitlab.com"
+}
+```
+
+## Edit an existing environment
+
+Updates an existing environment's name and/or external_url.
+
+It returns `200` if the environment was successfully updated. In case of an error, a status code `400` is returned.
+
+```
+PUT /projects/:id/environments/:environments_id
+```
+
+| Attribute | Type | Required | Description |
+| --------------- | ------- | --------------------------------- | ------------------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `environment_id` | integer | yes | The ID of the environment | The ID of the environment |
+| `name` | string | no | The new name of the environment |
+| `external_url` | string | no | The new external_url |
+
+```bash
+curl --request PUT --data "name=staging&external_url=https://staging.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments/1"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "name": "staging",
+ "slug": "staging",
+ "external_url": "https://staging.example.gitlab.com"
+}
+```
+
+## Delete an environment
+
+It returns `200` if the environment was successfully deleted, and `404` if the environment does not exist.
+
+```
+DELETE /projects/:id/environments/:environment_id
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | --------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `environment_id` | integer | yes | The ID of the environment |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/environments/1"
+```
+
+## Stop an environment
+
+It returns `200` if the environment was successfully stopped, and `404` if the environment does not exist.
+
+```
+POST /projects/:id/environments/:environment_id/stop
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | --------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `environment_id` | integer | yes | The ID of the environment |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environments/1/stop"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "name": "deploy",
+ "slug": "deploy",
+ "external_url": "https://deploy.example.gitlab.com"
+}
+```
diff --git a/doc/api/events.md b/doc/api/events.md
new file mode 100644
index 00000000000..129af0afa35
--- /dev/null
+++ b/doc/api/events.md
@@ -0,0 +1,364 @@
+# Events
+
+## Filter parameters
+
+### Action Types
+
+Available action types for the `action` parameter are:
+
+- `created`
+- `updated`
+- `closed`
+- `reopened`
+- `pushed`
+- `commented`
+- `merged`
+- `joined`
+- `left`
+- `destroyed`
+- `expired`
+
+Note that these options are downcased.
+
+### Target Types
+
+Available target types for the `target_type` parameter are:
+
+- `issue`
+- `milestone`
+- `merge_request`
+- `note`
+- `project`
+- `snippet`
+- `user`
+
+Note that these options are downcased.
+
+### Date formatting
+
+Dates for the `before` and `after` parameters should be supplied in the following format:
+
+```
+YYYY-MM-DD
+```
+
+## List currently authenticated user's events
+
+>**Note:** This endpoint was introduced in GitLab 9.3.
+
+Get a list of events for the authenticated user.
+
+```
+GET /events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+Example request:
+
+```
+curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01
+```
+
+Example response:
+
+```json
+[
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":160,
+ "target_type":"Issue",
+ "author_id":25,
+ "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.",
+ "created_at":"2017-02-09T10:43:19.667Z",
+ "author":{
+ "name":"User 3",
+ "username":"user3",
+ "id":25,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/user3"
+ },
+ "author_username":"user3"
+ },
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":159,
+ "target_type":"Issue",
+ "author_id":21,
+ "target_title":"Nostrum enim non et sed optio illo deleniti non.",
+ "created_at":"2017-02-09T10:43:19.426Z",
+ "author":{
+ "name":"Test User",
+ "username":"ted",
+ "id":21,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/ted"
+ },
+ "author_username":"ted"
+ }
+]
+```
+
+### Get user contribution events
+
+>**Note:** Documentation was formerly located in the [Users API pages][users-api].
+
+Get the contribution events for the specified user, sorted from newest to oldest.
+
+```
+GET /users/:id/events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID or Username of the user |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events
+```
+
+Example response:
+
+```json
+[
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "closed",
+ "target_id": 830,
+ "target_type": "Issue",
+ "author_id": 1,
+ "target_title": "Public project search field",
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "pushed",
+ "target_id": null,
+ "target_type": null,
+ "author_id": 1,
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "john",
+ "push_data": {
+ "commit_count": 1,
+ "action": "pushed",
+ "ref_type": "branch",
+ "commit_from": "50d4420237a9de7be1304607147aec22e4a14af7",
+ "commit_to": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
+ "ref": "master",
+ "commit_title": "Add simple search to projects in public area"
+ },
+ "target_title": null
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "closed",
+ "target_id": 840,
+ "target_type": "Issue",
+ "author_id": 1,
+ "target_title": "Finish & merge Code search PR",
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ },
+ {
+ "title": null,
+ "project_id": 15,
+ "action_name": "commented on",
+ "target_id": 1312,
+ "target_type": "Note",
+ "author_id": 1,
+ "target_title": null,
+ "created_at": "2015-12-04T10:33:58.089Z",
+ "note": {
+ "id": 1312,
+ "body": "What an awesome day!",
+ "attachment": null,
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2015-12-04T10:33:56.698Z",
+ "system": false,
+ "noteable_id": 377,
+ "noteable_type": "Issue"
+ },
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ }
+]
+```
+
+## List a Project's visible events
+
+>**Note:** This endpoint has been around longer than the others. Documentation was formerly located in the [Projects API pages][projects-api].
+
+Get a list of visible events for a particular project.
+
+```
+GET /:project_id/events
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `action` | string | no | Include only events of a particular [action type][action-types] |
+| `target_type` | string | no | Include only events of a particular [target type][target-types] |
+| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] |
+| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] |
+| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` |
+
+Example request:
+
+```
+curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:project_id/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01
+```
+
+Example response:
+
+```json
+[
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":160,
+ "target_iid":160,
+ "target_type":"Issue",
+ "author_id":25,
+ "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.",
+ "created_at":"2017-02-09T10:43:19.667Z",
+ "author":{
+ "name":"User 3",
+ "username":"user3",
+ "id":25,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/user3"
+ },
+ "author_username":"user3"
+ },
+ {
+ "title":null,
+ "project_id":1,
+ "action_name":"opened",
+ "target_id":159,
+ "target_iid":159,
+ "target_type":"Issue",
+ "author_id":21,
+ "target_title":"Nostrum enim non et sed optio illo deleniti non.",
+ "created_at":"2017-02-09T10:43:19.426Z",
+ "author":{
+ "name":"Test User",
+ "username":"ted",
+ "id":21,
+ "state":"active",
+ "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon",
+ "web_url":"https://gitlab.example.com/ted"
+ },
+ "author_username":"ted"
+ },
+ {
+ "title": null,
+ "project_id": 1,
+ "action_name": "commented on",
+ "target_id": 1312,
+ "target_iid": 1312,
+ "target_type": "Note",
+ "author_id": 1,
+ "data": null,
+ "target_title": null,
+ "created_at": "2015-12-04T10:33:58.089Z",
+ "note": {
+ "id": 1312,
+ "body": "What an awesome day!",
+ "attachment": null,
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2015-12-04T10:33:56.698Z",
+ "system": false,
+ "noteable_id": 377,
+ "noteable_type": "Issue",
+ "noteable_iid": 377
+ },
+ "author": {
+ "name": "Dmitriy Zaporozhets",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_username": "root"
+ }
+]
+```
+
+[target-types]: #target-types "Target Type parameter"
+[action-types]: #action-types "Action Type parameter"
+[date-formatting]: #date-formatting "Date Formatting guidance"
+[projects-api]: projects.md "Projects API pages"
+[users-api]: users.md "Users API pages"
diff --git a/doc/api/features.md b/doc/api/features.md
new file mode 100644
index 00000000000..6861dbf00a2
--- /dev/null
+++ b/doc/api/features.md
@@ -0,0 +1,88 @@
+# Features flags API
+
+All methods require administrator authorization.
+
+Notice that currently the API only supports boolean and percentage-of-time gate
+values.
+
+## List all features
+
+Get a list of all persisted features, with its gate values.
+
+```
+GET /features
+```
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features
+```
+
+Example response:
+
+```json
+[
+ {
+ "name": "experimental_feature",
+ "state": "off",
+ "gates": [
+ {
+ "key": "boolean",
+ "value": false
+ }
+ ]
+ },
+ {
+ "name": "new_library",
+ "state": "on",
+ "gates": [
+ {
+ "key": "boolean",
+ "value": true
+ }
+ ]
+ }
+]
+```
+
+## Set or create a feature
+
+Set a feature's gate value. If a feature with the given name doesn't exist yet
+it will be created. The value can be a boolean, or an integer to indicate
+percentage of time.
+
+```
+POST /features/:name
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `name` | string | yes | Name of the feature to create or update |
+| `value` | integer/string | yes | `true` or `false` to enable/disable, or an integer for percentage of time |
+| `feature_group` | string | no | A Feature group name |
+| `user` | string | no | A GitLab username |
+
+Note that you can enable or disable a feature for both a `feature_group` and a
+`user` with a single API call.
+
+```bash
+curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library
+```
+
+Example response:
+
+```json
+{
+ "name": "new_library",
+ "state": "conditional",
+ "gates": [
+ {
+ "key": "boolean",
+ "value": false
+ },
+ {
+ "key": "percentage_of_time",
+ "value": 30
+ }
+ ]
+}
+```
diff --git a/doc/api/group_level_variables.md b/doc/api/group_level_variables.md
new file mode 100644
index 00000000000..e19be7b35c4
--- /dev/null
+++ b/doc/api/group_level_variables.md
@@ -0,0 +1,125 @@
+# Group-level Variables API
+
+## List group variables
+
+Get list of a group's variables.
+
+```
+GET /groups/:id/variables
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/1/variables"
+```
+
+```json
+[
+ {
+ "key": "TEST_VARIABLE_1",
+ "value": "TEST_1"
+ },
+ {
+ "key": "TEST_VARIABLE_2",
+ "value": "TEST_2"
+ }
+]
+```
+
+## Show variable details
+
+Get the details of a group's specific variable.
+
+```
+GET /groups/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|-----------------------|
+| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/1/variables/TEST_VARIABLE_1"
+```
+
+```json
+{
+ "key": "TEST_VARIABLE_1",
+ "value": "TEST_1"
+}
+```
+
+## Create variable
+
+Create a new variable.
+
+```
+POST /groups/:id/variables
+```
+
+| Attribute | Type | required | Description |
+|-------------|---------|----------|-----------------------|
+| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
+| `value` | string | yes | The `value` of a variable |
+| `protected` | boolean | no | Whether the variable is protected |
+
+```
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/1/variables" --form "key=NEW_VARIABLE" --form "value=new value"
+```
+
+```json
+{
+ "key": "NEW_VARIABLE",
+ "value": "new value",
+ "protected": false
+}
+```
+
+## Update variable
+
+Update a group's variable.
+
+```
+PUT /groups/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-------------|---------|----------|-------------------------|
+| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+| `value` | string | yes | The `value` of a variable |
+| `protected` | boolean | no | Whether the variable is protected |
+
+```
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/1/variables/NEW_VARIABLE" --form "value=updated value"
+```
+
+```json
+{
+ "key": "NEW_VARIABLE",
+ "value": "updated value",
+ "protected": true
+}
+```
+
+## Remove variable
+
+Remove a group's variable.
+
+```
+DELETE /groups/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|-------------------------|
+| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+
+```
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/1/variables/VARIABLE_1"
+```
diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md
new file mode 100644
index 00000000000..dbfc7529125
--- /dev/null
+++ b/doc/api/group_milestones.md
@@ -0,0 +1,120 @@
+# Group milestones API
+
+## List group milestones
+
+Returns a list of group milestones.
+
+```
+GET /groups/:id/milestones
+GET /groups/:id/milestones?iids[]=42
+GET /groups/:id/milestones?iids[]=42&iids[]=43
+GET /groups/:id/milestones?state=active
+GET /groups/:id/milestones?state=closed
+GET /groups/:id/milestones?search=version
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `iids[]` | Array[integer] | optional | Return only the milestones having the given `iid` |
+| `state` | string | optional | Return only `active` or `closed` milestones` |
+| `search` | string | optional | Return only milestones with a title or description matching the provided string |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/milestones
+```
+
+Example Response:
+
+```json
+[
+ {
+ "id": 12,
+ "iid": 3,
+ "group_id": 16,
+ "title": "10.0",
+ "description": "Version",
+ "due_date": "2013-11-29",
+ "start_date": "2013-11-10",
+ "state": "active",
+ "updated_at": "2013-10-02T09:24:18Z",
+ "created_at": "2013-10-02T09:24:18Z"
+ }
+]
+```
+
+
+## Get single milestone
+
+Gets a single group milestone.
+
+```
+GET /groups/:id/milestones/:milestone_id
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
+- `milestone_id` (required) - The ID of the group milestone
+
+## Create new milestone
+
+Creates a new group milestone.
+
+```
+POST /groups/:id/milestones
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
+- `title` (required) - The title of an milestone
+- `description` (optional) - The description of the milestone
+- `due_date` (optional) - The due date of the milestone
+- `start_date` (optional) - The start date of the milestone
+
+## Edit milestone
+
+Updates an existing group milestone.
+
+```
+PUT /groups/:id/milestones/:milestone_id
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
+- `milestone_id` (required) - The ID of a group milestone
+- `title` (optional) - The title of a milestone
+- `description` (optional) - The description of a milestone
+- `due_date` (optional) - The due date of the milestone
+- `start_date` (optional) - The start date of the milestone
+- `state_event` (optional) - The state event of the milestone (close|activate)
+
+## Get all issues assigned to a single milestone
+
+Gets all issues assigned to a single group milestone.
+
+```
+GET /groups/:id/milestones/:milestone_id/issues
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
+- `milestone_id` (required) - The ID of a group milestone
+
+## Get all merge requests assigned to a single milestone
+
+Gets all merge requests assigned to a single group milestone.
+
+```
+GET /groups/:id/milestones/:milestone_id/merge_requests
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
+- `milestone_id` (required) - The ID of a group milestone
diff --git a/doc/api/groups.md b/doc/api/groups.md
index bc61bfec9b9..2b3d8e125c8 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -1,4 +1,4 @@
-# Groups
+# Groups API
## List groups
diff --git a/doc/api/issues.md b/doc/api/issues.md
index 6c10b5ab0e7..f30ed08d0fa 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -1,4 +1,4 @@
-# Issues
+# Issues API
Every API call to issues must be authenticated.
@@ -14,7 +14,9 @@ Read more on [pagination](README.md#pagination).
## List issues
-Get all issues created by the authenticated user.
+Get all issues the authenticated user has access to. By default it
+returns only issues created by the current user. To get all issues,
+use parameter `scope=all`.
```
GET /issues
@@ -26,7 +28,8 @@ GET /issues?labels=foo,bar&state=opened
GET /issues?milestone=1.0.0
GET /issues?milestone=1.0.0&state=opened
GET /issues?iids[]=42&iids[]=43
-GET /issues?search=issue+title+or+description
+GET /issues?author_id=5
+GET /issues?assignee_id=5
```
| Attribute | Type | Required | Description |
@@ -34,9 +37,12 @@ GET /issues?search=issue+title+or+description
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `milestone` | string | no | The milestone title |
-| `iids` | Array[integer] | no | Return only the issues having the given `iid` |
-| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
-| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
+| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
+| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
+| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search issues against their `title` and `description` |
```bash
@@ -70,6 +76,14 @@ Example response:
"updated_at" : "2016-01-04T15:31:39.996Z"
},
"project_id" : 1,
+ "assignees" : [{
+ "state" : "active",
+ "id" : 1,
+ "name" : "Administrator",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root"
+ }],
"assignee" : {
"state" : "active",
"id" : 1,
@@ -92,6 +106,8 @@ Example response:
]
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## List group issues
Get a list of a group's issues.
@@ -107,6 +123,8 @@ GET /groups/:id/issues?milestone=1.0.0
GET /groups/:id/issues?milestone=1.0.0&state=opened
GET /groups/:id/issues?iids[]=42&iids[]=43
GET /groups/:id/issues?search=issue+title+or+description
+GET /groups/:id/issues?author_id=5
+GET /groups/:id/issues?assignee_id=5
```
| Attribute | Type | Required | Description |
@@ -114,11 +132,14 @@ GET /groups/:id/issues?search=issue+title+or+description
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
-| `iids` | Array[integer] | no | Return only the issues having the given `iid` |
+| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
| `milestone` | string | no | The milestone title |
-| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
-| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
-| `search` | string | no | Search group issues against their `title` and `description` |
+| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
+| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
+| `search` | string | no | Search group issues against their `title` and `description` |
```bash
@@ -153,6 +174,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed",
"iid" : 1,
+ "assignees" : [{
+ "avatar_url" : null,
+ "web_url" : "https://gitlab.example.com/lennie",
+ "state" : "active",
+ "username" : "lennie",
+ "id" : 9,
+ "name" : "Dr. Luella Kovacek"
+ }],
"assignee" : {
"avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie",
@@ -174,6 +203,8 @@ Example response:
]
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## List project issues
Get a list of a project's issues.
@@ -189,19 +220,25 @@ GET /projects/:id/issues?milestone=1.0.0
GET /projects/:id/issues?milestone=1.0.0&state=opened
GET /projects/:id/issues?iids[]=42&iids[]=43
GET /projects/:id/issues?search=issue+title+or+description
+GET /projects/:id/issues?author_id=5
+GET /projects/:id/issues?assignee_id=5
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `iids` | Array[integer] | no | Return only the milestone having the given `iid` |
+| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `milestone` | string | no | The milestone title |
-| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
-| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
-| `search` | string | no | Search project issues against their `title` and `description` |
-
+| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
+| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
+| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
+| `search` | string | no | Search project issues against their `title` and `description` |
+| `created_after` | datetime | no | Return issues created after the given time (inclusive) |
+| `created_before` | datetime | no | Return issues created before the given time (inclusive) |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues
@@ -235,6 +272,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed",
"iid" : 1,
+ "assignees" : [{
+ "avatar_url" : null,
+ "web_url" : "https://gitlab.example.com/lennie",
+ "state" : "active",
+ "username" : "lennie",
+ "id" : 9,
+ "name" : "Dr. Luella Kovacek"
+ }],
"assignee" : {
"avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie",
@@ -256,6 +301,8 @@ Example response:
]
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Single issue
Get a single project issue.
@@ -300,6 +347,14 @@ Example response:
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
"state" : "closed",
"iid" : 1,
+ "assignees" : [{
+ "avatar_url" : null,
+ "web_url" : "https://gitlab.example.com/lennie",
+ "state" : "active",
+ "username" : "lennie",
+ "id" : 9,
+ "name" : "Dr. Luella Kovacek"
+ }],
"assignee" : {
"avatar_url" : null,
"web_url" : "https://gitlab.example.com/lennie",
@@ -317,10 +372,18 @@ Example response:
"user_notes_count": 1,
"due_date": null,
"web_url": "http://example.com/example/example/issues/1",
- "confidential": false
+ "confidential": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects/1/issues/2",
+ "notes": "http://example.com/api/v4/projects/1/issues/2/notes",
+ "award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
+ "project": "http://example.com/api/v4/projects/1"
+ }
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## New issue
Creates a new project issue.
@@ -329,13 +392,13 @@ Creates a new project issue.
POST /projects/:id/issues
```
-| Attribute | Type | Required | Description |
-|-------------------------------------------|---------|----------|--------------|
+| Attribute | Type | Required | Description |
+|-------------------------------------------|----------------|----------|--------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `title` | string | yes | The title of an issue |
| `description` | string | no | The description of an issue |
| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. |
-| `assignee_id` | integer | no | The ID of a user to assign issue |
+| `assignee_ids` | Array[integer] | no | The ID of the users to assign issue |
| `milestone_id` | integer | no | The ID of a milestone to assign issue |
| `labels` | string | no | Comma-separated label names for an issue |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) |
@@ -357,6 +420,7 @@ Example response:
"iid" : 14,
"title" : "Issues with auth",
"state" : "opened",
+ "assignees" : [],
"assignee" : null,
"labels" : [
"bug"
@@ -376,10 +440,18 @@ Example response:
"user_notes_count": 0,
"due_date": null,
"web_url": "http://example.com/example/example/issues/14",
- "confidential": false
+ "confidential": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects/1/issues/2",
+ "notes": "http://example.com/api/v4/projects/1/issues/2/notes",
+ "award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
+ "project": "http://example.com/api/v4/projects/1"
+ }
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Edit issue
Updates an existing project issue. This call is also used to mark an issue as
@@ -396,7 +468,7 @@ PUT /projects/:id/issues/:issue_iid
| `title` | string | no | The title of an issue |
| `description` | string | no | The description of an issue |
| `confidential` | boolean | no | Updates an issue to be confidential |
-| `assignee_id` | integer | no | The ID of a user to assign the issue to |
+| `assignee_ids` | Array[integer] | no | The ID of the users to assign the issue to |
| `milestone_id` | integer | no | The ID of a milestone to assign the issue to |
| `labels` | string | no | Comma-separated label names for an issue |
| `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it |
@@ -430,16 +502,25 @@ Example response:
"bug"
],
"id" : 85,
+ "assignees" : [],
"assignee" : null,
"milestone" : null,
"subscribed" : true,
"user_notes_count": 0,
"due_date": "2016-07-22",
"web_url": "http://example.com/example/example/issues/15",
- "confidential": false
+ "confidential": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects/1/issues/2",
+ "notes": "http://example.com/api/v4/projects/1/issues/2/notes",
+ "award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
+ "project": "http://example.com/api/v4/projects/1"
+ }
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Delete an issue
Only for admins and project owners. Soft deletes the issue in question.
@@ -494,6 +575,14 @@ Example response:
"updated_at": "2016-04-07T12:20:17.596Z",
"labels": [],
"milestone": null,
+ "assignees": [{
+ "name": "Miss Monserrate Beier",
+ "username": "axel.block",
+ "id": 12,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/axel.block"
+ }],
"assignee": {
"name": "Miss Monserrate Beier",
"username": "axel.block",
@@ -512,10 +601,18 @@ Example response:
},
"due_date": null,
"web_url": "http://example.com/example/example/issues/11",
- "confidential": false
+ "confidential": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects/1/issues/2",
+ "notes": "http://example.com/api/v4/projects/1/issues/2/notes",
+ "award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
+ "project": "http://example.com/api/v4/projects/1"
+ }
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Subscribe to an issue
Subscribes the authenticated user to an issue to receive notifications.
@@ -549,6 +646,14 @@ Example response:
"updated_at": "2016-04-07T12:20:17.596Z",
"labels": [],
"milestone": null,
+ "assignees": [{
+ "name": "Miss Monserrate Beier",
+ "username": "axel.block",
+ "id": 12,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/axel.block"
+ }],
"assignee": {
"name": "Miss Monserrate Beier",
"username": "axel.block",
@@ -567,10 +672,18 @@ Example response:
},
"due_date": null,
"web_url": "http://example.com/example/example/issues/11",
- "confidential": false
+ "confidential": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects/1/issues/2",
+ "notes": "http://example.com/api/v4/projects/1/issues/2/notes",
+ "award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
+ "project": "http://example.com/api/v4/projects/1"
+ }
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Unsubscribe from an issue
Unsubscribes the authenticated user from the issue to not receive notifications
@@ -652,6 +765,14 @@ Example response:
"updated_at": "2016-06-17T07:47:33.832Z",
"due_date": null
},
+ "assignees": [{
+ "name": "Jarret O'Keefe",
+ "username": "francisca",
+ "id": 14,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/francisca"
+ }],
"assignee": {
"name": "Jarret O'Keefe",
"username": "francisca",
@@ -683,6 +804,8 @@ Example response:
}
```
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Set a time estimate for an issue
Sets an estimated time of work for this issue.
@@ -887,3 +1010,32 @@ Example response:
## Comments on issues
Comments are done via the [notes](notes.md) resource.
+
+## Get user agent details
+
+Available only for admins.
+
+```
+GET /projects/:id/issues/:issue_iid/user_agent_detail
+```
+
+| Attribute | Type | Required | Description |
+|-------------|---------|----------|--------------------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `issue_iid` | integer | yes | The internal ID of a project's issue |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/user_agent_detail
+```
+
+Example response:
+
+```json
+{
+ "user_agent": "AppleWebKit/537.36",
+ "ip_address": "127.0.0.1",
+ "akismet_submitted": false
+}
+```
+
+[ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index 404da3dc603..297115e94ac 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -11,7 +11,7 @@ GET /projects/:id/jobs
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided |
+| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided |
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/jobs?scope[]=pending&scope[]=running'
@@ -125,7 +125,7 @@ GET /projects/:id/pipelines/:pipeline_id/jobs
|---------------|--------------------------------|----------|----------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `pipeline_id` | integer | yes | The ID of a pipeline |
-| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided |
+| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided |
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/pipelines/6/jobs?scope[]=pending&scope[]=running'
diff --git a/doc/api/keys.md b/doc/api/keys.md
index 3ace1040f38..376ac27df3a 100644
--- a/doc/api/keys.md
+++ b/doc/api/keys.md
@@ -1,4 +1,4 @@
-# Keys
+# Keys API
## Get SSH key with user by ID of an SSH key
diff --git a/doc/api/labels.md b/doc/api/labels.md
index 778348ea371..ec93cf50e7a 100644
--- a/doc/api/labels.md
+++ b/doc/api/labels.md
@@ -1,4 +1,4 @@
-# Labels
+# Labels API
## List labels
diff --git a/doc/api/members.md b/doc/api/members.md
index 3c661284f11..3234f833eae 100644
--- a/doc/api/members.md
+++ b/doc/api/members.md
@@ -1,4 +1,4 @@
-# Group and project members
+# Group and project members API
**Valid access levels**
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index dde855b2bd4..802e5362d70 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -1,7 +1,104 @@
-# Merge requests
+# Merge requests API
+
+Every API call to merge requests must be authenticated.
## List merge requests
+> [Introduced][ce-13060] in GitLab 9.5.
+
+Get all merge requests the authenticated user has access to. By
+default it returns only merge requests created by the current user. To
+get all merge requests, use parameter `scope=all`.
+
+The `state` parameter can be used to get only merge requests with a
+given state (`opened`, `closed`, or `merged`) or all of them (`all`).
+The pagination parameters `page` and `per_page` can be used to
+restrict the list of merge requests.
+
+```
+GET /merge_requests
+GET /merge_requests?state=opened
+GET /merge_requests?state=all
+GET /merge_requests?milestone=release
+GET /merge_requests?labels=bug,reproduced
+GET /merge_requests?author_id=5
+GET /merge_requests?scope=assigned-to-me
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`|
+| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
+| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
+| `milestone` | string | no | Return merge requests for a specific milestone |
+| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
+| `labels` | string | no | Return merge requests matching a comma separated list of labels |
+| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) |
+| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) |
+| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` |
+| `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` |
+| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` |
+
+```json
+[
+ {
+ "id": 1,
+ "iid": 1,
+ "target_branch": "master",
+ "source_branch": "test1",
+ "project_id": 3,
+ "title": "test1",
+ "state": "opened",
+ "upvotes": 0,
+ "downvotes": 0,
+ "author": {
+ "id": 1,
+ "username": "admin",
+ "email": "admin@example.com",
+ "name": "Administrator",
+ "state": "active",
+ "created_at": "2012-04-29T08:46:00Z"
+ },
+ "assignee": {
+ "id": 1,
+ "username": "admin",
+ "email": "admin@example.com",
+ "name": "Administrator",
+ "state": "active",
+ "created_at": "2012-04-29T08:46:00Z"
+ },
+ "source_project_id": 2,
+ "target_project_id": 3,
+ "labels": [ ],
+ "description": "fixed login page css paddings",
+ "work_in_progress": false,
+ "milestone": {
+ "id": 5,
+ "iid": 1,
+ "project_id": 3,
+ "title": "v2.0",
+ "description": "Assumenda aut placeat expedita exercitationem labore sunt enim earum.",
+ "state": "closed",
+ "created_at": "2015-02-02T19:49:26.013Z",
+ "updated_at": "2015-02-02T19:49:26.013Z",
+ "due_date": null
+ },
+ "merge_when_pipeline_succeeds": true,
+ "merge_status": "can_be_merged",
+ "sha": "8888888888888888888888888888888888888888",
+ "merge_commit_sha": null,
+ "user_notes_count": 1,
+ "should_remove_source_branch": true,
+ "force_remove_source_branch": false,
+ "web_url": "http://example.com/example/example/merge_requests/1"
+ }
+]
+```
+
+## List project merge requests
+
Get all merge requests for this project.
The `state` parameter can be used to get only merge requests with a given state (`opened`, `closed`, or `merged`) or all of them (`all`).
The pagination parameters `page` and `per_page` can be used to restrict the list of merge requests.
@@ -20,12 +117,18 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
-| `iids` | Array[integer] | no | Return the request having the given `iid` |
+| `iids[]` | Array[integer] | no | Return the request having the given `iid` |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`|
| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone |
+| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels |
+| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) |
+| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) |
+| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ |
+| `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
+| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
```json
[
@@ -1162,3 +1265,5 @@ Example response:
"total_time_spent": 3600
}
```
+
+[ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060
diff --git a/doc/api/milestones.md b/doc/api/milestones.md
index 7640eeb8d00..84930f0bdc9 100644
--- a/doc/api/milestones.md
+++ b/doc/api/milestones.md
@@ -1,4 +1,4 @@
-# Milestones
+# Milestones API
## List project milestones
@@ -6,7 +6,7 @@ Returns a list of project milestones.
```
GET /projects/:id/milestones
-GET /projects/:id/milestones?iids=42
+GET /projects/:id/milestones?iids[]=42
GET /projects/:id/milestones?iids[]=42&iids[]=43
GET /projects/:id/milestones?state=active
GET /projects/:id/milestones?state=closed
@@ -18,7 +18,7 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `iids` | Array[integer] | optional | Return only the milestones having the given `iids` |
+| `iids[]` | Array[integer] | optional | Return only the milestones having the given `iid` |
| `state` | string | optional | Return only `active` or `closed` milestones` |
| `search` | string | optional | Return only milestones with a title or description matching the provided string |
diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md
index eef06d5f324..8133251dffe 100644
--- a/doc/api/namespaces.md
+++ b/doc/api/namespaces.md
@@ -1,4 +1,4 @@
-# Namespaces
+# Namespaces API
Usernames and groupnames fall under a special category called namespaces.
@@ -29,22 +29,30 @@ Example response:
{
"id": 1,
"path": "user1",
- "kind": "user"
+ "kind": "user",
+ "full_path": "user1"
},
{
"id": 2,
"path": "group1",
- "kind": "group"
+ "kind": "group",
+ "full_path": "group1",
+ "parent_id": "null",
+ "members_count_with_descendants": 2
},
{
"id": 3,
"path": "bar",
"kind": "group",
"full_path": "foo/bar",
+ "parent_id": "9",
+ "members_count_with_descendants": 5
}
]
```
+**Note**: `members_count_with_descendants` are presented only for group masters/owners.
+
## Search for namespace
Get all namespaces that match a string in their name or path.
@@ -72,6 +80,8 @@ Example response:
"path": "twitter",
"kind": "group",
"full_path": "twitter",
+ "parent_id": "null",
+ "members_count_with_descendants": 2
}
]
```
diff --git a/doc/api/notes.md b/doc/api/notes.md
index b71fea5fc9f..e627369e17b 100644
--- a/doc/api/notes.md
+++ b/doc/api/notes.md
@@ -1,4 +1,4 @@
-# Notes
+# Notes API
Notes are comments on snippets, issues or merge requests.
@@ -35,7 +35,8 @@ Parameters:
"updated_at": "2013-10-02T10:22:45Z",
"system": true,
"noteable_id": 377,
- "noteable_type": "Issue"
+ "noteable_type": "Issue",
+ "noteable_iid": 377
},
{
"id": 305,
@@ -53,7 +54,8 @@ Parameters:
"updated_at": "2013-10-02T09:56:03Z",
"system": true,
"noteable_id": 121,
- "noteable_type": "Issue"
+ "noteable_type": "Issue",
+ "noteable_iid": 121
}
]
```
@@ -267,7 +269,8 @@ Parameters:
"updated_at": "2013-10-02T08:57:14Z",
"system": false,
"noteable_id": 2,
- "noteable_type": "MergeRequest"
+ "noteable_type": "MergeRequest",
+ "noteable_iid": 2
}
```
diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md
index 43047917f77..3a2c398e355 100644
--- a/doc/api/notification_settings.md
+++ b/doc/api/notification_settings.md
@@ -1,4 +1,4 @@
-# Notification settings
+# Notification settings API
>**Note:** This feature was [introduced][ce-5632] in GitLab 8.12.
diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md
index 46fe64d382e..77bb7a55d8c 100644
--- a/doc/api/oauth2.md
+++ b/doc/api/oauth2.md
@@ -1,59 +1,55 @@
# GitLab as an OAuth2 provider
-This document covers using the OAuth2 protocol to access GitLab.
+This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow other services access Gitlab resources on user's behalf.
-If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [Oauth2 provider documentation](../integration/oauth_provider.md).
+If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [OAuth2 provider](../integration/oauth_provider.md)
+documentation.
-OAuth2 is a protocol that enables us to authenticate a user without requiring them to give their password to a third-party.
+This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper).
-This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper)
+## Supported OAuth2 Flows
-## Web Application Flow
+Gitlab currently supports following authorization flows:
-This is the most common type of flow and is used by server-side clients that wish to access GitLab on a user's behalf.
+* *Web Application Flow* - Most secure and common type of flow, designed for the applications with secure server-side.
+* *Implicit Flow* - This flow is designed for user-agent only apps (e.g. single page web application running on GitLab Pages).
+* *Resource Owner Password Credentials Flow* - To be used **only** for securely hosted, first-party services.
->**Note:**
-This flow **should not** be used for client-side clients as you would leak your `client_secret`. Client-side clients should use the Implicit Grant (which is currently unsupported).
+Please refer to [OAuth RFC](https://tools.ietf.org/html/rfc6749) to find out in details how all those flows work and pick the right one for your use case.
-For more detailed information, check out the [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1)
+Both *web application* and *implicit* flows require `application` to be registered first via `/profile/applications` page
+in your user's account. During registration, by enabling proper scopes you can limit the range of resources which the `application` can access. Upon creation
+you'll obtain `application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**.
-In the following sections you will be introduced to the three steps needed for this flow.
+>**Important:** OAuth specification advises sending `state` parameter with each request to `/oauth/authorize`. We highly recommended to send a unique
+value with each request and validate it against the one in redirect request. This is important to prevent [CSRF attacks]. The `state` param really should
+have been a requirement in the standard!
-### 1. Registering the client
+In the following sections you will find detailed instructions on how to obtain authorization with each flow.
-First, you should create an application (`/profile/applications`) in your user's account.
-Each application gets a unique App ID and App Secret parameters.
+### Web Application Flow
->**Note:**
-**You should not share/leak your App ID or App Secret.**
+Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) for a detailed flow description
-### 2. Requesting authorization
+#### 1. Requesting authorization code
-To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint:
+To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint with following GET parameters:
```
-https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=your_unique_state_hash
+https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH
```
-This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided.
+This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect will
+include the GET `code` parameter, for example:
-The redirect will include the GET `code` parameter, for example:
-
-```
-http://myapp.com/oauth/redirect?code=1234567890&state=your_unique_state_hash
-```
+`http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH`
You should then use the `code` to request an access token.
->**Important:**
-It is highly recommended that you send a `state` value with the request to `/oauth/authorize` and
-validate that value is returned and matches in the redirect request.
-This is important to prevent [CSRF attacks](http://www.oauthsecurity.com/#user-content-authorization-code-flow),
-`state` really should have been a requirement in the standard!
-
-### 3. Requesting the access token
+#### 2. Requesting access token
-Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, we are using Ruby's `rest-client`:
+Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example,
+we are using Ruby's `rest-client`:
```
parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI'
@@ -72,28 +68,40 @@ The `redirect_uri` must match the `redirect_uri` used in the original authorizat
You can now make requests to the API with the access token returned.
-### Use the access token to access the API
-The access token allows you to make requests to the API on a behalf of a user.
+### Implicit Grant
+
+Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.2) for a detailed flow description.
+
+Unlike the web flow, the client receives an `access token` immediately as a result of the authorization request. The flow does not use client secret
+or authorization code because all of the application code and storage is easily accessible, therefore __secrets__ can leak easily.
+
+>**Important:** Avoid using this flow for applications that store data outside of the Gitlab instance. If you do, make sure to verify `application id`
+associated with access token before granting access to the data
+(see [/oauth/token/info](https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo)).
+
+
+#### 1. Requesting access token
+
+To request the access token, you should redirect the user to the `/oauth/authorize` endpoint using `token` response type:
```
-GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN
+https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=token&state=YOUR_UNIQUE_STATE_HASH
```
-Or you can put the token to the Authorization header:
+This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect
+will include a fragment with `access_token` as well as token details in GET parameters, for example:
```
-curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user
+http://myapp.com/oauth/redirect#access_token=ABCDExyz123&state=YOUR_UNIQUE_STATE_HASH&token_type=bearer&expires_in=3600
```
-## Resource Owner Password Credentials
+### Resource Owner Password Credentials
-## Deprecation Notice
+Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.3) for a detailed flow description.
-1. Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication turned on.
-2. These users can access the API using [personal access tokens] instead.
-
----
+> **Deprecation notice:** Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication
+turned on. These users can access the API using [personal access tokens] instead.
In this flow, a token is requested in exchange for the resource owner credentials (username and password).
The credentials should only be used when there is a high degree of trust between the resource owner and the client (e.g. the
@@ -101,12 +109,16 @@ client is part of the device operating system or a highly privileged application
available (such as an authorization code).
>**Important:**
-Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] are a better choice.
+Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens]
+are a better choice.
Even though this grant type requires direct client access to the resource owner credentials, the resource owner credentials are used
for a single request and are exchanged for an access token. This grant type can eliminate the need for the client to store the
resource owner credentials for future use, by exchanging the credentials with a long-lived access token or refresh token.
-You can do POST request to `/oauth/token` with parameters:
+
+#### 1. Requesting access token
+
+POST request to `/oauth/token` with parameters:
```
{
@@ -134,4 +146,18 @@ access_token = client.password.get_token('user@example.com', 'secret')
puts access_token.token
```
-[personal access tokens]: ./README.md#personal-access-tokens \ No newline at end of file
+## Access Gitlab API with `access token`
+
+The `access token` allows you to make requests to the API on a behalf of a user. You can pass the token either as GET parameter
+```
+GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN
+```
+
+or you can put the token to the Authorization header:
+
+```
+curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user
+```
+
+[personal access tokens]: ../user/profile/personal_access_tokens.md
+[CSRF attacks]: http://www.oauthsecurity.com/#user-content-authorization-code-flow \ No newline at end of file
diff --git a/doc/api/pipeline_schedules.md b/doc/api/pipeline_schedules.md
new file mode 100644
index 00000000000..433654c18cc
--- /dev/null
+++ b/doc/api/pipeline_schedules.md
@@ -0,0 +1,273 @@
+# Pipeline schedules
+
+You can read more about [pipeline schedules](../user/project/pipelines/schedules.md).
+
+## Get all pipeline schedules
+
+Get a list of the pipeline schedules of a project.
+
+```
+GET /projects/:id/pipeline_schedules
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `scope` | string | no | The scope of pipeline schedules, one of: `active`, `inactive` |
+
+```sh
+curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules"
+```
+
+```json
+[
+ {
+ "id": 13,
+ "description": "Test schedule pipeline",
+ "ref": "master",
+ "cron": "* * * * *",
+ "cron_timezone": "Asia/Tokyo",
+ "next_run_at": "2017-05-19T13:41:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:31:08.849Z",
+ "updated_at": "2017-05-19T13:40:17.727Z",
+ "owner": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/root"
+ }
+ }
+]
+```
+
+## Get a single pipeline schedule
+
+Get the pipeline schedule of a project.
+
+```
+GET /projects/:id/pipeline_schedules/:pipeline_schedule_id
+```
+
+| Attribute | Type | required | Description |
+|--------------|---------|----------|--------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
+
+```sh
+curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13"
+```
+
+```json
+{
+ "id": 13,
+ "description": "Test schedule pipeline",
+ "ref": "master",
+ "cron": "* * * * *",
+ "cron_timezone": "Asia/Tokyo",
+ "next_run_at": "2017-05-19T13:41:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:31:08.849Z",
+ "updated_at": "2017-05-19T13:40:17.727Z",
+ "last_pipeline": {
+ "id": 332,
+ "sha": "0e788619d0b5ec17388dffb973ecd505946156db",
+ "ref": "master",
+ "status": "pending"
+ },
+ "owner": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/root"
+ }
+}
+```
+
+## Create a new pipeline schedule
+
+Create a new pipeline schedule of a project.
+
+```
+POST /projects/:id/pipeline_schedules
+```
+
+| Attribute | Type | required | Description |
+|---------------|---------|----------|--------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `description` | string | yes | The description of pipeline schedule |
+| `ref` | string | yes | The branch/tag name will be triggered |
+| `cron ` | string | yes | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) |
+| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) (default: `'UTC'`) |
+| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially (default: `true`) |
+
+```sh
+curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form description="Build packages" --form ref="master" --form cron="0 1 * * 5" --form cron_timezone="UTC" --form active="true" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules"
+```
+
+```json
+{
+ "id": 14,
+ "description": "Build packages",
+ "ref": "master",
+ "cron": "0 1 * * 5",
+ "cron_timezone": "UTC",
+ "next_run_at": "2017-05-26T01:00:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:43:08.169Z",
+ "updated_at": "2017-05-19T13:43:08.169Z",
+ "last_pipeline": null,
+ "owner": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/root"
+ }
+}
+```
+
+## Edit a pipeline schedule
+
+Updates the pipeline schedule of a project. Once the update is done, it will be rescheduled automatically.
+
+```
+PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id
+```
+
+| Attribute | Type | required | Description |
+|---------------|---------|----------|--------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
+| `description` | string | no | The description of pipeline schedule |
+| `ref` | string | no | The branch/tag name will be triggered |
+| `cron ` | string | no | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) |
+| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) or `TZInfo::Timezone` (e.g. `America/Los_Angeles`) |
+| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially. |
+
+```sh
+curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form cron="0 2 * * *" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13"
+```
+
+```json
+{
+ "id": 13,
+ "description": "Test schedule pipeline",
+ "ref": "master",
+ "cron": "0 2 * * *",
+ "cron_timezone": "Asia/Tokyo",
+ "next_run_at": "2017-05-19T17:00:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:31:08.849Z",
+ "updated_at": "2017-05-19T13:44:16.135Z",
+ "last_pipeline": {
+ "id": 332,
+ "sha": "0e788619d0b5ec17388dffb973ecd505946156db",
+ "ref": "master",
+ "status": "pending"
+ },
+ "owner": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/root"
+ }
+}
+```
+
+## Take ownership of a pipeline schedule
+
+Update the owner of the pipeline schedule of a project.
+
+```
+POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership
+```
+
+| Attribute | Type | required | Description |
+|---------------|---------|----------|--------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
+
+```sh
+curl --request POST --header "PRIVATE-TOKEN: hf2CvZXB9w8Uc5pZKpSB" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/take_ownership"
+```
+
+```json
+{
+ "id": 13,
+ "description": "Test schedule pipeline",
+ "ref": "master",
+ "cron": "0 2 * * *",
+ "cron_timezone": "Asia/Tokyo",
+ "next_run_at": "2017-05-19T17:00:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:31:08.849Z",
+ "updated_at": "2017-05-19T13:46:37.468Z",
+ "last_pipeline": {
+ "id": 332,
+ "sha": "0e788619d0b5ec17388dffb973ecd505946156db",
+ "ref": "master",
+ "status": "pending"
+ },
+ "owner": {
+ "name": "shinya",
+ "username": "maeda",
+ "id": 50,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/maeda"
+ }
+}
+```
+
+## Delete a pipeline schedule
+
+Delete the pipeline schedule of a project.
+
+```
+DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id
+```
+
+| Attribute | Type | required | Description |
+|----------------|---------|----------|--------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
+
+```sh
+curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13"
+```
+
+```json
+{
+ "id": 13,
+ "description": "Test schedule pipeline",
+ "ref": "master",
+ "cron": "0 2 * * *",
+ "cron_timezone": "Asia/Tokyo",
+ "next_run_at": "2017-05-19T17:00:00.000Z",
+ "active": true,
+ "created_at": "2017-05-19T13:31:08.849Z",
+ "updated_at": "2017-05-19T13:46:37.468Z",
+ "last_pipeline": {
+ "id": 332,
+ "sha": "0e788619d0b5ec17388dffb973ecd505946156db",
+ "ref": "master",
+ "status": "pending"
+ },
+ "owner": {
+ "name": "shinya",
+ "username": "maeda",
+ "id": 50,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon",
+ "web_url": "https://gitlab.example.com/maeda"
+ }
+}
+```
diff --git a/doc/api/pipeline_triggers.md b/doc/api/pipeline_triggers.md
index d639e8a0991..9030ae32d17 100644
--- a/doc/api/pipeline_triggers.md
+++ b/doc/api/pipeline_triggers.md
@@ -1,4 +1,4 @@
-# Pipeline triggers
+# Pipeline triggers API
You can read more about [triggering pipelines through the API](../ci/triggers/README.md).
diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md
index 732ad8da4ac..890945cfc7e 100644
--- a/doc/api/pipelines.md
+++ b/doc/api/pipelines.md
@@ -11,6 +11,14 @@ GET /projects/:id/pipelines
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `scope` | string | no | The scope of pipelines, one of: `running`, `pending`, `finished`, `branches`, `tags` |
+| `status` | string | no | The status of pipelines, one of: `running`, `pending`, `success`, `failed`, `canceled`, `skipped` |
+| `ref` | string | no | The ref of pipelines |
+| `yaml_errors`| boolean | no | Returns pipelines with invalid configurations |
+| `name`| string | no | The name of the user who triggered pipelines |
+| `username`| string | no | The username of the user who triggered pipelines |
+| `order_by`| string | no | Order pipelines by `id`, `status`, `ref`, or `user_id` (default: `id`) |
+| `sort` | string | no | Sort pipelines in `asc` or `desc` order (default: `desc`) |
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/pipelines"
diff --git a/doc/api/project_level_variables.md b/doc/api/project_level_variables.md
new file mode 100644
index 00000000000..82ac0b09027
--- /dev/null
+++ b/doc/api/project_level_variables.md
@@ -0,0 +1,125 @@
+# Project-level Variables API
+
+## List project variables
+
+Get list of a project's variables.
+
+```
+GET /projects/:id/variables
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables"
+```
+
+```json
+[
+ {
+ "key": "TEST_VARIABLE_1",
+ "value": "TEST_1"
+ },
+ {
+ "key": "TEST_VARIABLE_2",
+ "value": "TEST_2"
+ }
+]
+```
+
+## Show variable details
+
+Get the details of a project's specific variable.
+
+```
+GET /projects/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|-----------------------|
+| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/TEST_VARIABLE_1"
+```
+
+```json
+{
+ "key": "TEST_VARIABLE_1",
+ "value": "TEST_1"
+}
+```
+
+## Create variable
+
+Create a new variable.
+
+```
+POST /projects/:id/variables
+```
+
+| Attribute | Type | required | Description |
+|-------------|---------|----------|-----------------------|
+| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
+| `value` | string | yes | The `value` of a variable |
+| `protected` | boolean | no | Whether the variable is protected |
+
+```
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables" --form "key=NEW_VARIABLE" --form "value=new value"
+```
+
+```json
+{
+ "key": "NEW_VARIABLE",
+ "value": "new value",
+ "protected": false
+}
+```
+
+## Update variable
+
+Update a project's variable.
+
+```
+PUT /projects/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-------------|---------|----------|-------------------------|
+| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+| `value` | string | yes | The `value` of a variable |
+| `protected` | boolean | no | Whether the variable is protected |
+
+```
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/NEW_VARIABLE" --form "value=updated value"
+```
+
+```json
+{
+ "key": "NEW_VARIABLE",
+ "value": "updated value",
+ "protected": true
+}
+```
+
+## Remove variable
+
+Remove a project's variable.
+
+```
+DELETE /projects/:id/variables/:key
+```
+
+| Attribute | Type | required | Description |
+|-----------|---------|----------|-------------------------|
+| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `key` | string | yes | The `key` of a variable |
+
+```
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/VARIABLE_1"
+```
diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md
index ff379473961..d74398c6e65 100644
--- a/doc/api/project_snippets.md
+++ b/doc/api/project_snippets.md
@@ -43,6 +43,7 @@ Parameters:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -70,6 +71,7 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `title` (required) - The title of a snippet
- `file_name` (required) - The name of a snippet file
+- `description` (optional) - The description of a snippet
- `code` (required) - The content of a snippet
- `visibility` (required) - The snippet's visibility
@@ -87,6 +89,7 @@ Parameters:
- `snippet_id` (required) - The ID of a project's snippet
- `title` (optional) - The title of a snippet
- `file_name` (optional) - The name of a snippet file
+- `description` (optional) - The description of a snippet
- `code` (optional) - The content of a snippet
- `visibility` (optional) - The snippet's visibility
@@ -116,3 +119,35 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `snippet_id` (required) - The ID of a project's snippet
+
+## Get user agent details
+
+> **Notes:**
+> [Introduced][ce-29508] in GitLab 9.4.
+
+
+Available only for admins.
+
+```
+GET /projects/:id/snippets/:snippet_id/user_agent_detail
+```
+
+| Attribute | Type | Required | Description |
+|-------------|---------|----------|--------------------------------------|
+| `id` | Integer | yes | The ID of a snippet |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/snippets/1/user_agent_detail
+```
+
+Example response:
+
+```json
+{
+ "user_agent": "AppleWebKit/537.36",
+ "ip_address": "127.0.0.1",
+ "akismet_submitted": false
+}
+```
+
+[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 51de4fef7ff..d3f8e509612 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -1,12 +1,11 @@
-# Projects
-
+# Projects API
### Project visibility level
-Project in GitLab has be either private, internal or public.
-You can determine it by `visibility` field in project.
+Project in GitLab can be either private, internal or public.
+This is determined by the `visibility` field in the project.
-Constants for project visibility levels are next:
+Values for the project visibility level are:
* `private`:
Project access must be granted explicitly for each user.
@@ -17,11 +16,9 @@ Constants for project visibility levels are next:
* `public`:
The project can be cloned without any authentication.
-
-
## List projects
-Get a list of visible projects for authenticated user. When being accessed without authentication, all public projects are returned.
+Get a list of visible projects for authenticated user. When accessed without authentication, only public projects are returned.
```
GET /projects
@@ -40,6 +37,185 @@ Parameters:
| `owned` | boolean | no | Limit by projects owned by the current user |
| `membership` | boolean | no | Limit by projects that the current user is a member of |
| `starred` | boolean | no | Limit by projects starred by the current user |
+| `statistics` | boolean | no | Include project statistics |
+| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
+| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
+
+```json
+[
+ {
+ "id": 4,
+ "description": null,
+ "default_branch": "master",
+ "visibility": "private",
+ "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git",
+ "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git",
+ "web_url": "http://example.com/diaspora/diaspora-client",
+ "tag_list": [
+ "example",
+ "disapora client"
+ ],
+ "owner": {
+ "id": 3,
+ "name": "Diaspora",
+ "created_at": "2013-09-30T13:46:02Z"
+ },
+ "name": "Diaspora Client",
+ "name_with_namespace": "Diaspora / Diaspora Client",
+ "path": "diaspora-client",
+ "path_with_namespace": "diaspora/diaspora-client",
+ "issues_enabled": true,
+ "open_issues_count": 1,
+ "merge_requests_enabled": true,
+ "jobs_enabled": true,
+ "wiki_enabled": true,
+ "snippets_enabled": false,
+ "container_registry_enabled": false,
+ "created_at": "2013-09-30T13:46:02Z",
+ "last_activity_at": "2013-09-30T13:46:02Z",
+ "creator_id": 3,
+ "namespace": {
+ "id": 3,
+ "name": "Diaspora",
+ "path": "diaspora",
+ "kind": "group",
+ "full_path": "diaspora"
+ },
+ "import_status": "none",
+ "archived": false,
+ "avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png",
+ "shared_runners_enabled": true,
+ "forks_count": 0,
+ "star_count": 0,
+ "runners_token": "b8547b1dc37721d05889db52fa2f02",
+ "public_jobs": true,
+ "shared_with_groups": [],
+ "only_allow_merge_if_pipeline_succeeds": false,
+ "only_allow_merge_if_all_discussions_are_resolved": false,
+ "request_access_enabled": false,
+ "statistics": {
+ "commit_count": 37,
+ "storage_size": 1038090,
+ "repository_size": 1038090,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ },
+ },
+ {
+ "id": 6,
+ "description": null,
+ "default_branch": "master",
+ "visibility": "private",
+ "ssh_url_to_repo": "git@example.com:brightbox/puppet.git",
+ "http_url_to_repo": "http://example.com/brightbox/puppet.git",
+ "web_url": "http://example.com/brightbox/puppet",
+ "tag_list": [
+ "example",
+ "puppet"
+ ],
+ "owner": {
+ "id": 4,
+ "name": "Brightbox",
+ "created_at": "2013-09-30T13:46:02Z"
+ },
+ "name": "Puppet",
+ "name_with_namespace": "Brightbox / Puppet",
+ "path": "puppet",
+ "path_with_namespace": "brightbox/puppet",
+ "issues_enabled": true,
+ "open_issues_count": 1,
+ "merge_requests_enabled": true,
+ "jobs_enabled": true,
+ "wiki_enabled": true,
+ "snippets_enabled": false,
+ "container_registry_enabled": false,
+ "created_at": "2013-09-30T13:46:02Z",
+ "last_activity_at": "2013-09-30T13:46:02Z",
+ "creator_id": 3,
+ "namespace": {
+ "id": 4,
+ "name": "Brightbox",
+ "path": "brightbox",
+ "kind": "group",
+ "full_path": "brightbox"
+ },
+ "import_status": "none",
+ "import_error": null,
+ "permissions": {
+ "project_access": {
+ "access_level": 10,
+ "notification_level": 3
+ },
+ "group_access": {
+ "access_level": 50,
+ "notification_level": 3
+ }
+ },
+ "archived": false,
+ "avatar_url": null,
+ "shared_runners_enabled": true,
+ "forks_count": 0,
+ "star_count": 0,
+ "runners_token": "b8547b1dc37721d05889db52fa2f02",
+ "public_jobs": true,
+ "shared_with_groups": [],
+ "only_allow_merge_if_pipeline_succeeds": false,
+ "only_allow_merge_if_all_discussions_are_resolved": false,
+ "request_access_enabled": false,
+ "statistics": {
+ "commit_count": 12,
+ "storage_size": 2066080,
+ "repository_size": 2066080,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
+ }
+]
+```
+
+### List a user's projects
+
+Get a list of visible projects for the given user. When accessed without authentication, only public projects are returned.
+
+```
+GET /users/:user_id/projects
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `user_id` | string | yes | The ID or username of the user |
+| `archived` | boolean | no | Limit by archived status |
+| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` |
+| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` |
+| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` |
+| `search` | string | no | Return list of projects matching the search criteria |
+| `simple` | boolean | no | Return only the ID, URL, name, and path of each project |
+| `owned` | boolean | no | Limit by projects owned by the current user |
+| `membership` | boolean | no | Limit by projects that the current user is a member of |
+| `starred` | boolean | no | Limit by projects starred by the current user |
+| `statistics` | boolean | no | Include project statistics |
+| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
+| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
```json
[
@@ -81,6 +257,7 @@ Parameters:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": false,
"avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -91,7 +268,23 @@ Parameters:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "statistics": {
+ "commit_count": 37,
+ "storage_size": 1038090,
+ "repository_size": 1038090,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
},
{
"id": 6,
@@ -131,6 +324,8 @@ Parameters:
"kind": "group",
"full_path": "brightbox"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -151,7 +346,23 @@ Parameters:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "statistics": {
+ "commit_count": 12,
+ "storage_size": 2066080,
+ "repository_size": 2066080,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
]
```
@@ -170,6 +381,7 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `statistics` | boolean | no | Include project statistics |
```json
{
@@ -210,6 +422,8 @@ Parameters:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -241,7 +455,24 @@ Parameters:
],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "printing_merge_requests_link_enabled": true,
+ "request_access_enabled": false,
+ "statistics": {
+ "commit_count": 37,
+ "storage_size": 1038090,
+ "repository_size": 1038090,
+ "lfs_objects_size": 0,
+ "job_artifacts_size": 0
+ },
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
```
@@ -283,143 +514,7 @@ GET /projects/:id/users
### Get project events
-Get the events for the specified project sorted from newest to oldest. This
-endpoint can be accessed without authentication if the project is publicly
-accessible.
-
-```
-GET /projects/:id/events
-```
-
-Parameters:
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
-
-```json
-[
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 830,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Public project search field",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "opened",
- "target_id": null,
- "target_type": null,
- "author_id": 1,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "john",
- "data": {
- "before": "50d4420237a9de7be1304607147aec22e4a14af7",
- "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "ref": "refs/heads/master",
- "user_id": 1,
- "user_name": "Dmitriy Zaporozhets",
- "repository": {
- "name": "gitlabhq",
- "url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
- "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
- "homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
- },
- "commits": [
- {
- "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "message": "Add simple search to projects in public area",
- "timestamp": "2013-05-13T18:18:08+00:00",
- "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "email": "dmitriy.zaporozhets@gmail.com"
- }
- }
- ],
- "total_commits_count": 1
- },
- "target_title": null
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 840,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Finish & merge Code search PR",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "commented on",
- "target_id": 1312,
- "target_type": "Note",
- "author_id": 1,
- "data": null,
- "target_title": null,
- "created_at": "2015-12-04T10:33:58.089Z",
- "note": {
- "id": 1312,
- "body": "What an awesome day!",
- "attachment": null,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "created_at": "2015-12-04T10:33:56.698Z",
- "system": false,
- "noteable_id": 377,
- "noteable_type": "Issue"
- },
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- }
-]
-```
+Please refer to the [Events API documentation](events.md#list-a-projects-visible-events)
### Create project
@@ -444,13 +539,17 @@ Parameters:
| `snippets_enabled` | boolean | no | Enable snippets for this project |
| `container_registry_enabled` | boolean | no | Enable container registry for this project |
| `shared_runners_enabled` | boolean | no | Enable shared runners for this project |
-| `visibility` | String | no | See [project visibility level](#project-visibility-level) |
+| `visibility` | string | no | See [project visibility level](#project-visibility-level) |
| `import_url` | string | no | URL to import repository from |
| `public_jobs` | boolean | no | If `true`, jobs can be viewed by non-project-members |
| `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs |
| `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved |
| `lfs_enabled` | boolean | no | Enable LFS |
| `request_access_enabled` | boolean | no | Allow users to request member access |
+| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project |
+| `avatar` | mixed | no | Image file for avatar of the project |
+| `printing_merge_request_link_enabled` | boolean | no | Show link to create/view merge request when pushing from the command line |
+| `ci_config_path` | string | no | The path to CI config file |
### Create project for user
@@ -484,6 +583,10 @@ Parameters:
| `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved |
| `lfs_enabled` | boolean | no | Enable LFS |
| `request_access_enabled` | boolean | no | Allow users to request member access |
+| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project |
+| `avatar` | mixed | no | Image file for avatar of the project |
+| `printing_merge_request_link_enabled` | boolean | no | Show link to create/view merge request when pushing from the command line |
+| `ci_config_path` | string | no | The path to CI config file |
### Edit project
@@ -516,11 +619,16 @@ Parameters:
| `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved |
| `lfs_enabled` | boolean | no | Enable LFS |
| `request_access_enabled` | boolean | no | Allow users to request member access |
+| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project |
+| `avatar` | mixed | no | Image file for avatar of the project |
+| `ci_config_path` | string | no | The path to CI config file |
### Fork project
Forks a project into the user namespace of the authenticated user or the one provided.
+The forking operation for a project is asynchronous and is completed in a background job. The request will return immediately. To determine whether the fork of the project has completed, query the `import_status` for the new project.
+
```
POST /projects/:id/fork
```
@@ -586,6 +694,7 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": true,
"avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -595,7 +704,16 @@ Example response:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
```
@@ -651,6 +769,7 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
"archived": true,
"avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png",
"shared_runners_enabled": true,
@@ -660,7 +779,16 @@ Example response:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
```
@@ -722,6 +850,8 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -742,7 +872,16 @@ Example response:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
```
@@ -804,6 +943,8 @@ Example response:
"kind": "group",
"full_path": "diaspora"
},
+ "import_status": "none",
+ "import_error": null,
"permissions": {
"project_access": {
"access_level": 10,
@@ -824,7 +965,16 @@ Example response:
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
- "request_access_enabled": false
+ "request_access_enabled": false,
+ "_links": {
+ "self": "http://example.com/api/v4/projects",
+ "issues": "http://example.com/api/v4/projects/1/issues",
+ "merge_requests": "http://example.com/api/v4/projects/1/merge_requests",
+ "repo_branches": "http://example.com/api/v4/projects/1/repository_branches",
+ "labels": "http://example.com/api/v4/projects/1/labels",
+ "events": "http://example.com/api/v4/projects/1/events",
+ "members": "http://example.com/api/v4/projects/1/members"
+ }
}
```
@@ -1188,17 +1338,21 @@ endpoint can be accessed without authentication if the project is publicly
accessible.
```
-GET /projects/search/:query
+GET /projects
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `query` | string | yes | A string contained in the project name |
+| `search` | string | yes | A string contained in the project name |
| `order_by` | string | no | Return requests ordered by `id`, `name`, `created_at` or `last_activity_at` fields |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order |
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects?search=test
+```
+
## Start the Housekeeping task for a Project
>**Note:** This feature was introduced in GitLab 9.0
diff --git a/doc/api/protected_branches.md b/doc/api/protected_branches.md
new file mode 100644
index 00000000000..10faa95d7e8
--- /dev/null
+++ b/doc/api/protected_branches.md
@@ -0,0 +1,145 @@
+# Protected branches API
+
+>**Note:** This feature was introduced in GitLab 9.5
+
+**Valid access levels**
+
+The access levels are defined in the `ProtectedBranchAccess::ALLOWED_ACCESS_LEVELS` constant. Currently, these levels are recognized:
+```
+0 => No access
+30 => Developer access
+40 => Master access
+```
+
+## List protected branches
+
+Gets a list of protected branches from a project.
+
+```
+GET /projects/:id/protected_branches
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_branches'
+```
+
+Example response:
+
+```json
+[
+ {
+ "name": "master",
+ "push_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Masters"
+ }
+ ],
+ "merge_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Masters"
+ }
+ ]
+ },
+ ...
+]
+```
+
+## Get a single protected branch or wildcard protected branch
+
+Gets a single protected branch or wildcard protected branch.
+
+```
+GET /projects/:id/protected_branches/:name
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the branch or wildcard |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_branches/master'
+```
+
+Example response:
+
+```json
+{
+ "name": "master",
+ "push_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Masters"
+ }
+ ],
+ "merge_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Masters"
+ }
+ ]
+}
+```
+
+## Protect repository branches
+
+Protects a single repository branch or several project repository
+branches using a wildcard protected branch.
+
+```
+POST /projects/:id/protected_branches
+```
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_branches?name=*-stable&push_access_level=30&merge_access_level=30'
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the branch or wildcard |
+| `push_access_level` | string | no | Access levels allowed to push (defaults: `40`, master access level) |
+| `merge_access_level` | string | no | Access levels allowed to merge (defaults: `40`, master access level) |
+
+Example response:
+
+```json
+{
+ "name": "*-stable",
+ "push_access_levels": [
+ {
+ "access_level": 30,
+ "access_level_description": "Developers + Masters"
+ }
+ ],
+ "merge_access_levels": [
+ {
+ "access_level": 30,
+ "access_level_description": "Developers + Masters"
+ }
+ ]
+}
+```
+
+## Unprotect repository branches
+
+Unprotects the given protected branch or wildcard protected branch.
+
+```
+DELETE /projects/:id/protected_branches/:name
+```
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_branches/*-stable'
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the branch |
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index 859cbd63831..bccef924375 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -1,4 +1,4 @@
-# Repositories
+# Repositories API
## List repository tree
diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md
index aec91abd390..c517a38a8ba 100644
--- a/doc/api/repository_files.md
+++ b/doc/api/repository_files.md
@@ -1,4 +1,4 @@
-# Repository files
+# Repository files API
**CRUD for repository files**
@@ -61,7 +61,7 @@ POST /projects/:id/repository/files/:file_path
```
```bash
-curl --request POST --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/app%2Fprojectrb%2E?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20content&commit_message=create%20a%20new%20file'
+curl --request POST --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fprojectrb%2E?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20content&commit_message=create%20a%20new%20file'
```
Example response:
@@ -76,7 +76,8 @@ Example response:
Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
-- `branch` (required) - The name of branch
+- `branch` (required) - Name of the branch
+- `start_branch` (optional) - Name of the branch to start the new commit from
- `encoding` (optional) - Change encoding to 'base64'. Default is text.
- `author_email` (optional) - Specify the commit author's email address
- `author_name` (optional) - Specify the commit author's name
@@ -90,7 +91,7 @@ PUT /projects/:id/repository/files/:file_path
```
```bash
-curl --request PUT --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/app%2Fproject%2Erb?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20other%20content&commit_message=update%20file'
+curl --request PUT --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fproject%2Erb?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20other%20content&commit_message=update%20file'
```
Example response:
@@ -105,12 +106,14 @@ Example response:
Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
-- `branch` (required) - The name of branch
+- `branch` (required) - Name of the branch
+- `start_branch` (optional) - Name of the branch to start the new commit from
- `encoding` (optional) - Change encoding to 'base64'. Default is text.
- `author_email` (optional) - Specify the commit author's email address
- `author_name` (optional) - Specify the commit author's name
- `content` (required) - New file content
- `commit_message` (required) - Commit message
+- `last_commit_id` (optional) - Last known file commit id
If the commit fails for any reason we return a 400 error with a non-specific
error message. Possible causes for a failed commit include:
@@ -128,7 +131,7 @@ DELETE /projects/:id/repository/files/:file_path
```
```bash
-curl --request DELETE --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/app%2Fproject%2Erb?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file'
+curl --request DELETE --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fproject%2Erb?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file'
```
Example response:
@@ -143,7 +146,8 @@ Example response:
Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
-- `branch` (required) - The name of branch
+- `branch` (required) - Name of the branch
+- `start_branch` (optional) - Name of the branch to start the new commit from
- `author_email` (optional) - Specify the commit author's email address
- `author_name` (optional) - Specify the commit author's name
- `commit_message` (required) - Commit message
diff --git a/doc/api/repository_storage_health.md b/doc/api/repository_storage_health.md
new file mode 100644
index 00000000000..e0c0315c2d7
--- /dev/null
+++ b/doc/api/repository_storage_health.md
@@ -0,0 +1,74 @@
+# Circuitbreaker API
+
+> [Introduced][ce-11449] in GitLab 9.5.
+
+The Circuitbreaker API is only accessible to administrators. All requests by
+guests will respond with `401 Unauthorized`, and all requests by normal users
+will respond with `403 Forbidden`.
+
+## Repository Storages
+
+### Get all storage information
+
+Returns of all currently configured storages and their health information.
+
+```
+GET /circuit_breakers/repository_storage
+```
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/circuit_breakers/repository_storage
+```
+
+```json
+[
+ {
+ "storage_name": "default",
+ "failing_on_hosts": [],
+ "total_failures": 0
+ },
+ {
+ "storage_name": "broken",
+ "failing_on_hosts": [
+ "web01", "worker01"
+ ],
+ "total_failures": 1
+ }
+]
+```
+
+### Get failing storages
+
+This returns a list of all currently failing storages.
+
+```
+GET /circuit_breakers/repository_storage/failing
+```
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/circuit_breakers/repository_storage/failing
+```
+
+```json
+[
+ {
+ "storage_name":"broken",
+ "failing_on_hosts":["web01", "worker01"],
+ "total_failures":2
+ }
+]
+```
+
+## Reset failing storage information
+
+Use this remove all failing storage information and allow access to the storage again.
+
+```
+DELETE /circuit_breakers/repository_storage
+```
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/circuit_breakers/repository_storage
+```
+
+[ce-11449]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11449
diff --git a/doc/api/services.md b/doc/api/services.md
index 0f42c256099..49b87a4228c 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -1,4 +1,4 @@
-# Services
+# Services API
## Asana
@@ -516,7 +516,7 @@ Example response:
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"pipeline_events": true,
"properties": {
"token": "9koXpg98eAheJpvBs5tK"
diff --git a/doc/api/session.md b/doc/api/session.md
index 056cc32597c..f79eac11689 100644
--- a/doc/api/session.md
+++ b/doc/api/session.md
@@ -1,11 +1,9 @@
-# Session
+# Session API
-## Deprecation Notice
-
-1. Starting in GitLab 8.11, this feature has been *disabled* for users with two-factor authentication turned on.
-2. These users can access the API using [personal access tokens] instead.
-
----
+>**Deprecation notice:**
+Starting in GitLab 8.11, this feature has been **disabled** for users with
+[two-factor authentication][2fa] turned on. These users can access the API
+using [personal access tokens] instead.
You can login with both GitLab and LDAP credentials in order to obtain the
private token.
@@ -52,4 +50,5 @@ Example response:
}
```
-[personal access tokens]: ./README.md#personal-access-tokens
+[2fa]: ../user/profile/account/two_factor_authentication.md
+[personal access tokens]: ../user/profile/personal_access_tokens.md
diff --git a/doc/api/settings.md b/doc/api/settings.md
index d99695ca986..94a9f8265fb 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -1,4 +1,4 @@
-# Application settings
+# Application settings API
These API calls allow you to read and modify GitLab instance application
settings as appear in `/admin/application_settings`. You have to be an
@@ -25,7 +25,7 @@ Example response:
"id" : 1,
"default_branch_protection" : 2,
"restricted_visibility_levels" : [],
- "signin_enabled" : true,
+ "password_authentication_enabled" : true,
"after_sign_out_path" : null,
"max_attachment_size" : 10,
"user_oauth_applications" : true,
@@ -42,7 +42,6 @@ Example response:
"gravatar_enabled" : true,
"sign_in_text" : null,
"container_registry_token_expire_delay": 5,
- "repository_storage": "default",
"repository_storages": ["default"],
"koding_enabled": false,
"koding_url": null,
@@ -63,7 +62,7 @@ PUT /application/settings
| --------- | ---- | :------: | ----------- |
| `default_projects_limit` | integer | no | Project limit per user. Default is `100000` |
| `signup_enabled` | boolean | no | Enable registration. Default is `true`. |
-| `signin_enabled` | boolean | no | Enable login via a GitLab account. Default is `true`. |
+| `password_authentication_enabled` | boolean | no | Enable authentication via a GitLab account password. Default is `true`. |
| `gravatar_enabled` | boolean | no | Enable Gravatar |
| `sign_in_text` | string | no | Text on login page |
| `home_page_url` | string | no | Redirect to this URL when not logged in |
@@ -81,7 +80,6 @@ PUT /application/settings
| `after_sign_out_path` | string | no | Where to redirect users after logout |
| `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes |
| `repository_storages` | array of strings | no | A list of names of enabled storage paths, taken from `gitlab.yml`. New projects will be created in one of these stores, chosen at random. |
-| `repository_storage` | string | no | The first entry in `repository_storages`. Deprecated, but retained for compatibility reasons |
| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. |
| `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. |
| `koding_url` | string | yes (if `koding_enabled` is `true`) | The Koding instance URL for integration. |
@@ -102,7 +100,7 @@ Example response:
"id": 1,
"default_projects_limit": 100000,
"signup_enabled": true,
- "signin_enabled": true,
+ "password_authentication_enabled": true,
"gravatar_enabled": true,
"sign_in_text": "",
"created_at": "2015-06-12T15:51:55.432Z",
@@ -121,7 +119,7 @@ Example response:
"user_oauth_applications": true,
"after_sign_out_path": "",
"container_registry_token_expire_delay": 5,
- "repository_storage": "default",
+ "repository_storages": ["default"],
"koding_enabled": false,
"koding_url": null,
"plantuml_enabled": false,
diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md
index ea10a26bcd0..b9500916cf2 100644
--- a/doc/api/sidekiq_metrics.md
+++ b/doc/api/sidekiq_metrics.md
@@ -1,4 +1,4 @@
-# Sidekiq Metrics
+# Sidekiq Metrics API
>**Note:** This endpoint is only available on GitLab 8.9 and above.
diff --git a/doc/api/snippets.md b/doc/api/snippets.md
index e09d930698e..fdafbfb5b9e 100644
--- a/doc/api/snippets.md
+++ b/doc/api/snippets.md
@@ -1,4 +1,4 @@
-# Snippets
+# Snippets API
> [Introduced][ce-6373] in GitLab 8.15.
@@ -48,6 +48,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -73,16 +74,17 @@ POST /snippets
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `title` | String | yes | The title of a snippet |
-| `file_name` | String | yes | The name of a snippet file |
-| `content` | String | yes | The content of a snippet |
-| `visibility` | String | yes | The snippet's visibility |
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `title` | String | yes | The title of a snippet |
+| `file_name` | String | yes | The name of a snippet file |
+| `content` | String | yes | The content of a snippet |
+| `description` | String | no | The description of a snippet |
+| `visibility` | String | no | The snippet's visibility |
``` bash
-curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
+curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
```
Example response:
@@ -92,6 +94,7 @@ Example response:
"id": 1,
"title": "This is a snippet",
"file_name": "test.txt",
+ "description": "Hello World snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -117,13 +120,14 @@ PUT /snippets/:id
Parameters:
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | Integer | yes | The ID of a snippet |
-| `title` | String | no | The title of a snippet |
-| `file_name` | String | no | The name of a snippet file |
-| `content` | String | no | The content of a snippet |
-| `visibility` | String | no | The snippet's visibility |
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | Integer | yes | The ID of a snippet |
+| `title` | String | no | The title of a snippet |
+| `file_name` | String | no | The name of a snippet file |
+| `description` | String | no | The description of a snippet |
+| `content` | String | no | The content of a snippet |
+| `visibility` | String | no | The snippet's visibility |
``` bash
@@ -137,6 +141,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
+ "description": "description of snippet",
"author": {
"id": 1,
"username": "john_smith",
@@ -229,3 +234,35 @@ Example response:
}
]
```
+
+## Get user agent details
+
+> **Notes:**
+> [Introduced][ce-29508] in GitLab 9.4.
+
+
+Available only for admins.
+
+```
+GET /snippets/:id/user_agent_detail
+```
+
+| Attribute | Type | Required | Description |
+|-------------|---------|----------|--------------------------------------|
+| `id` | Integer | yes | The ID of a snippet |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/1/user_agent_detail
+```
+
+Example response:
+
+```json
+{
+ "user_agent": "AppleWebKit/537.36",
+ "ip_address": "127.0.0.1",
+ "akismet_submitted": false
+}
+```
+
+[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508
diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md
index bad380794c1..9750475f0a6 100644
--- a/doc/api/system_hooks.md
+++ b/doc/api/system_hooks.md
@@ -1,4 +1,4 @@
-# System hooks
+# System hooks API
All methods require administrator authorization.
diff --git a/doc/api/tags.md b/doc/api/tags.md
index 0f6c4e6794e..32fe5eea692 100644
--- a/doc/api/tags.md
+++ b/doc/api/tags.md
@@ -1,4 +1,4 @@
-# Tags
+# Tags API
## List project repository tags
@@ -18,17 +18,20 @@ Parameters:
[
{
"commit": {
+ "id": "2695effb5807a22ff3d138d593fd856244e155e7",
+ "short_id": "2695effb",
+ "title": "Initial commit",
+ "created_at": "2017-07-26T11:08:53.000+02:00",
+ "parent_ids": [
+ "2a4b78934375d7f53875269ffd4f45fd83a84ebe"
+ ],
+ "message": "Initial commit",
"author_name": "John Smith",
"author_email": "john@example.com",
"authored_date": "2012-05-28T04:42:42-07:00",
- "committed_date": "2012-05-28T04:42:42-07:00",
"committer_name": "Jack Smith",
"committer_email": "jack@example.com",
- "id": "2695effb5807a22ff3d138d593fd856244e155e7",
- "message": "Initial commit",
- "parent_ids": [
- "2a4b78934375d7f53875269ffd4f45fd83a84ebe"
- ]
+ "committed_date": "2012-05-28T04:42:42-07:00"
},
"release": {
"tag_name": "1.0.0",
@@ -68,16 +71,19 @@ Example Response:
"message": null,
"commit": {
"id": "60a8ff033665e1207714d6670fcd7b65304ec02f",
- "message": "v5.0.0\n",
+ "short_id": "60a8ff03",
+ "title": "Initial commit",
+ "created_at": "2017-07-26T11:08:53.000+02:00",
"parent_ids": [
"f61c062ff8bcbdb00e0a1b3317a91aed6ceee06b"
],
- "authored_date": "2015-02-01T21:56:31.000+01:00",
+ "message": "v5.0.0\n",
"author_name": "Arthur Verschaeve",
"author_email": "contact@arthurverschaeve.be",
- "committed_date": "2015-02-01T21:56:31.000+01:00",
+ "authored_date": "2015-02-01T21:56:31.000+01:00",
"committer_name": "Arthur Verschaeve",
- "committer_email": "contact@arthurverschaeve.be"
+ "committer_email": "contact@arthurverschaeve.be",
+ "committed_date": "2015-02-01T21:56:31.000+01:00"
},
"release": null
}
@@ -102,17 +108,20 @@ Parameters:
```json
{
"commit": {
+ "id": "2695effb5807a22ff3d138d593fd856244e155e7",
+ "short_id": "2695effb",
+ "title": "Initial commit",
+ "created_at": "2017-07-26T11:08:53.000+02:00",
+ "parent_ids": [
+ "2a4b78934375d7f53875269ffd4f45fd83a84ebe"
+ ],
+ "message": "Initial commit",
"author_name": "John Smith",
"author_email": "john@example.com",
"authored_date": "2012-05-28T04:42:42-07:00",
- "committed_date": "2012-05-28T04:42:42-07:00",
"committer_name": "Jack Smith",
"committer_email": "jack@example.com",
- "id": "2695effb5807a22ff3d138d593fd856244e155e7",
- "message": "Initial commit",
- "parent_ids": [
- "2a4b78934375d7f53875269ffd4f45fd83a84ebe"
- ]
+ "committed_date": "2012-05-28T04:42:42-07:00"
},
"release": {
"tag_name": "1.0.0",
diff --git a/doc/api/templates/gitignores.md b/doc/api/templates/gitignores.md
index 3f2f4ed54e0..d3f5c88ca90 100644
--- a/doc/api/templates/gitignores.md
+++ b/doc/api/templates/gitignores.md
@@ -1,4 +1,4 @@
-# Gitignores
+# Gitignores API
## List gitignore templates
diff --git a/doc/api/templates/gitlab_ci_ymls.md b/doc/api/templates/gitlab_ci_ymls.md
index 27e8973da58..bdb128fc336 100644
--- a/doc/api/templates/gitlab_ci_ymls.md
+++ b/doc/api/templates/gitlab_ci_ymls.md
@@ -1,4 +1,4 @@
-# GitLab CI YMLs
+# GitLab CI YMLs API
## List GitLab CI YML templates
diff --git a/doc/api/templates/licenses.md b/doc/api/templates/licenses.md
index 33018f0c53f..8d1006e08c5 100644
--- a/doc/api/templates/licenses.md
+++ b/doc/api/templates/licenses.md
@@ -1,4 +1,4 @@
-# Licenses
+# Licenses API
## List license templates
diff --git a/doc/api/todos.md b/doc/api/todos.md
index 77667a57195..dd4c737b729 100644
--- a/doc/api/todos.md
+++ b/doc/api/todos.md
@@ -1,4 +1,4 @@
-# Todos
+# Todos API
> [Introduced][ce-3188] in GitLab 8.10.
diff --git a/doc/api/users.md b/doc/api/users.md
index 86027bcc05c..57a13eb477d 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -1,4 +1,4 @@
-# Users
+# Users API
## List users
@@ -62,6 +62,7 @@ GET /users
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/index.jpg",
"web_url": "http://localhost:3000/john_smith",
"created_at": "2012-05-23T08:00:58Z",
+ "is_admin": false,
"bio": null,
"location": null,
"skype": "",
@@ -94,6 +95,7 @@ GET /users
"avatar_url": "http://localhost:3000/uploads/user/avatar/2/index.jpg",
"web_url": "http://localhost:3000/jack_smith",
"created_at": "2012-05-23T08:01:01Z",
+ "is_admin": false,
"bio": null,
"location": null,
"skype": "",
@@ -144,6 +146,12 @@ GET /users?extern_uid=1234567&provider=github
You can search for users who are external with: `/users?external=true`
+You can search users by creation date time range with:
+
+```
+GET /users?created_before=2001-01-02T00:00:00.060Z&created_after=1999-01-02T00:00:00.060
+```
+
## Single user
Get a single user.
@@ -197,6 +205,7 @@ Parameters:
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/index.jpg",
"web_url": "http://localhost:3000/john_smith",
"created_at": "2012-05-23T08:00:58Z",
+ "is_admin": false,
"bio": null,
"location": null,
"skype": "",
@@ -232,25 +241,26 @@ POST /users
Parameters:
-- `email` (required) - Email
-- `password` (optional) - Password
-- `reset_password` (optional) - Send user password reset link - true or false(default)
-- `username` (required) - Username
-- `name` (required) - Name
-- `skype` (optional) - Skype ID
-- `linkedin` (optional) - LinkedIn
-- `twitter` (optional) - Twitter account
-- `website_url` (optional) - Website URL
-- `organization` (optional) - Organization name
-- `projects_limit` (optional) - Number of projects user can create
-- `extern_uid` (optional) - External UID
-- `provider` (optional) - External provider name
-- `bio` (optional) - User's biography
-- `location` (optional) - User's location
-- `admin` (optional) - User is admin - true or false (default)
-- `can_create_group` (optional) - User can create groups - true or false
-- `confirm` (optional) - Require confirmation - true (default) or false
-- `external` (optional) - Flags the user as external - true or false(default)
+- `email` (required) - Email
+- `password` (optional) - Password
+- `reset_password` (optional) - Send user password reset link - true or false(default)
+- `username` (required) - Username
+- `name` (required) - Name
+- `skype` (optional) - Skype ID
+- `linkedin` (optional) - LinkedIn
+- `twitter` (optional) - Twitter account
+- `website_url` (optional) - Website URL
+- `organization` (optional) - Organization name
+- `projects_limit` (optional) - Number of projects user can create
+- `extern_uid` (optional) - External UID
+- `provider` (optional) - External provider name
+- `bio` (optional) - User's biography
+- `location` (optional) - User's location
+- `admin` (optional) - User is admin - true or false (default)
+- `can_create_group` (optional) - User can create groups - true or false
+- `skip_confirmation` (optional) - Skip confirmation - true or false (default)
+- `external` (optional) - Flags the user as external - true or false(default)
+- `avatar` (optional) - Image file for user's avatar
## User modification
@@ -279,6 +289,7 @@ Parameters:
- `admin` (optional) - User is admin - true or false (default)
- `can_create_group` (optional) - User can create groups - true or false
- `external` (optional) - Flags the user as external - true or false(default)
+- `avatar` (optional) - Image file for user's avatar
On password update, user will be forced to change it upon next login.
Note, at the moment this method does only return a `404` error,
@@ -300,6 +311,9 @@ DELETE /users/:id
Parameters:
- `id` (required) - The ID of the user
+- `hard_delete` (optional) - If true, contributions that would usually be
+ [moved to the ghost user](../user/profile/account/delete_account.md#associated-records)
+ will be deleted instead, as well as groups owned solely by this user.
## User
@@ -350,7 +364,7 @@ GET /user
Parameters:
-- `sudo` (required) - the ID of a user
+- `sudo` (optional) - the ID of a user to make the call in their place
```
GET /user
@@ -698,147 +712,8 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or
### Get user contribution events
-Get the contribution events for the specified user, sorted from newest to oldest.
+Please refer to the [Events API documentation](events.md#get-user-contribution-events)
-```
-GET /users/:id/events
-```
-
-Parameters:
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer | yes | The ID of the user |
-
-```bash
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events
-```
-
-Example response:
-
-```json
-[
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 830,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Public project search field",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "opened",
- "target_id": null,
- "target_type": null,
- "author_id": 1,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "john",
- "data": {
- "before": "50d4420237a9de7be1304607147aec22e4a14af7",
- "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "ref": "refs/heads/master",
- "user_id": 1,
- "user_name": "Dmitriy Zaporozhets",
- "repository": {
- "name": "gitlabhq",
- "url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
- "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
- "homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
- },
- "commits": [
- {
- "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "message": "Add simple search to projects in public area",
- "timestamp": "2013-05-13T18:18:08+00:00",
- "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "email": "dmitriy.zaporozhets@gmail.com"
- }
- }
- ],
- "total_commits_count": 1
- },
- "target_title": null
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "closed",
- "target_id": 840,
- "target_type": "Issue",
- "author_id": 1,
- "data": null,
- "target_title": "Finish & merge Code search PR",
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- },
- {
- "title": null,
- "project_id": 15,
- "action_name": "commented on",
- "target_id": 1312,
- "target_type": "Note",
- "author_id": 1,
- "data": null,
- "target_title": null,
- "created_at": "2015-12-04T10:33:58.089Z",
- "note": {
- "id": 1312,
- "body": "What an awesome day!",
- "attachment": null,
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "created_at": "2015-12-04T10:33:56.698Z",
- "system": false,
- "noteable_id": 377,
- "noteable_type": "Issue"
- },
- "author": {
- "name": "Dmitriy Zaporozhets",
- "username": "root",
- "id": 1,
- "state": "active",
- "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
- "web_url": "http://localhost:3000/root"
- },
- "author_username": "root"
- }
-]
-```
## Get all impersonation tokens of a user
@@ -940,7 +815,7 @@ Example response:
It creates a new impersonation token. Note that only administrators can do this.
You are only able to create impersonation tokens to impersonate the user and perform
-both API calls and Git reads and writes. The user will not see these tokens in his profile
+both API calls and Git reads and writes. The user will not see these tokens in their profile
settings page.
```
diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md
index 8e002fe0022..9835fab7c98 100644
--- a/doc/api/v3_to_v4.md
+++ b/doc/api/v3_to_v4.md
@@ -1,8 +1,12 @@
-# V3 to V4 version
+# API V3 to API V4
Since GitLab 9.0, API V4 is the preferred version to be used.
-V3 will remain working until at least GitLab 9.3. The V3 API documentation is still [available](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/doc/api/README.md).
+API V3 will be unsupported from GitLab 9.5, to be released on August
+22, 2017. It will be removed in GitLab 9.5 or later. In the meantime, we advise
+you to make any necessary changes to applications that use V3. The V3 API
+documentation is still
+[available](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/doc/api/README.md).
Below are the changes made between V3 and V4.
diff --git a/doc/articles/artifactory_and_gitlab/index.md b/doc/articles/artifactory_and_gitlab/index.md
new file mode 100644
index 00000000000..c64851bad2b
--- /dev/null
+++ b/doc/articles/artifactory_and_gitlab/index.md
@@ -0,0 +1,278 @@
+# How to deploy Maven projects to Artifactory with GitLab CI/CD
+
+> **Article [Type](../../development/writing_documentation.md#types-of-technical-articles):** tutorial ||
+> **Level:** intermediary ||
+> **Author:** [Fabio Busatto](https://gitlab.com/bikebilly) ||
+> **Publication date:** 2017-08-15
+
+## Introduction
+
+In this article, we will show how you can leverage the power of [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/)
+to build a [Maven](https://maven.apache.org/) project, deploy it to [Artifactory](https://www.jfrog.com/artifactory/), and then use it from another Maven application as a dependency.
+
+You'll create two different projects:
+
+- `simple-maven-dep`: the app built and deployed to Artifactory (available at https://gitlab.com/gitlab-examples/maven/simple-maven-dep)
+- `simple-maven-app`: the app using the previous one as a dependency (available at https://gitlab.com/gitlab-examples/maven/simple-maven-app)
+
+We assume that you already have a GitLab account on [GitLab.com](https://gitlab.com/), and that you know the basic usage of Git and [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/).
+We also assume that an Artifactory instance is available and reachable from the internet, and that you have valid credentials to deploy on it.
+
+## Create the simple Maven dependency
+
+First of all, you need an application to work with: in this specific case we will
+use a simple one, but it could be any Maven application. This will be the
+dependency you want to package and deploy to Artifactory, in order to be
+available to other projects.
+
+### Prepare the dependency application
+
+For this article you'll use a Maven app that can be cloned from our example
+project:
+
+1. Log in to your GitLab account
+1. Create a new project by selecting **Import project from âž” Repo by URL**
+1. Add the following URL:
+
+ ```
+ https://gitlab.com/gitlab-examples/maven/simple-maven-dep.git
+ ```
+1. Click **Create project**
+
+This application is nothing more than a basic class with a stub for a JUnit based test suite.
+It exposes a method called `hello` that accepts a string as input, and prints a hello message on the screen.
+
+The project structure is really simple, and you should consider these two resources:
+
+- `pom.xml`: project object model (POM) configuration file
+- `src/main/java/com/example/dep/Dep.java`: source of our application
+
+### Configure the Artifactory deployment
+
+The application is ready to use, but you need some additional steps to deploy it to Artifactory:
+
+1. Log in to Artifactory with your user's credentials.
+1. From the main screen, click on the `libs-release-local` item in the **Set Me Up** panel.
+1. Copy to clipboard the configuration snippet under the **Deploy** paragraph.
+1. Change the `url` value in order to have it configurable via secret variables.
+1. Copy the snippet in the `pom.xml` file for your project, just after the
+ `dependencies` section. The snippet should look like this:
+
+ ```xml
+ <distributionManagement>
+ <repository>
+ <id>central</id>
+ <name>83d43b5afeb5-releases</name>
+ <url>${env.MAVEN_REPO_URL}/libs-release-local</url>
+ </repository>
+ </distributionManagement>
+ ```
+
+Another step you need to do before you can deploy the dependency to Artifactory
+is to configure the authentication data. It is a simple task, but Maven requires
+it to stay in a file called `settings.xml` that has to be in the `.m2` subdirectory
+in the user's homedir.
+
+Since you want to use GitLab Runner to automatically deploy the application, you
+should create the file in the project's home directory and set a command line
+parameter in `.gitlab-ci.yml` to use the custom location instead of the default one:
+
+1. Create a folder called `.m2` in the root of your repository
+1. Create a file called `settings.xml` in the `.m2` folder
+1. Copy the following content into a `settings.xml` file:
+
+ ```xml
+ <settings xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd"
+ xmlns="http://maven.apache.org/SETTINGS/1.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <servers>
+ <server>
+ <id>central</id>
+ <username>${env.MAVEN_REPO_USER}</username>
+ <password>${env.MAVEN_REPO_PASS}</password>
+ </server>
+ </servers>
+ </settings>
+ ```
+
+ Username and password will be replaced by the correct values using secret variables.
+
+### Configure GitLab CI/CD for `simple-maven-dep`
+
+Now it's time we set up [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/) to automatically build, test and deploy the dependency!
+
+GitLab CI/CD uses a file in the root of the repo, named `.gitlab-ci.yml`, to read the definitions for jobs
+that will be executed by the configured GitLab Runners. You can read more about this file in the [GitLab Documentation](https://docs.gitlab.com/ee/ci/yaml/).
+
+First of all, remember to set up secret variables for your deployment. Navigate to your project's **Settings > CI/CD** page
+and add the following secret variables (replace them with your current values, of course):
+
+- **MAVEN_REPO_URL**: `http://artifactory.example.com:8081/artifactory` (your Artifactory URL)
+- **MAVEN_REPO_USER**: `gitlab` (your Artifactory username)
+- **MAVEN_REPO_PASS**: `AKCp2WXr3G61Xjz1PLmYa3arm3yfBozPxSta4taP3SeNu2HPXYa7FhNYosnndFNNgoEds8BCS` (your Artifactory Encrypted Password)
+
+Now it's time to define jobs in `.gitlab-ci.yml` and push it to the repo:
+
+```yaml
+image: maven:latest
+
+variables:
+ MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode"
+ MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
+
+cache:
+ paths:
+ - .m2/repository/
+ - target/
+
+build:
+ stage: build
+ script:
+ - mvn $MAVEN_CLI_OPTS compile
+
+test:
+ stage: test
+ script:
+ - mvn $MAVEN_CLI_OPTS test
+
+deploy:
+ stage: deploy
+ script:
+ - mvn $MAVEN_CLI_OPTS deploy
+ only:
+ - master
+```
+
+GitLab Runner will use the latest [Maven Docker image](https://hub.docker.com/_/maven/), which already contains all the tools and the dependencies you need to manage the project,
+in order to run the jobs.
+
+Environment variables are set to instruct Maven to use the `homedir` of the repo instead of the user's home when searching for configuration and dependencies.
+
+Caching the `.m2/repository folder` (where all the Maven files are stored), and the `target` folder (where our application will be created), is useful for speeding up the process
+by running all Maven phases in a sequential order, therefore, executing `mvn test` will automatically run `mvn compile` if necessary.
+
+Both `build` and `test` jobs leverage the `mvn` command to compile the application and to test it as defined in the test suite that is part of the application.
+
+Deploy to Artifactory is done as defined by the secret variables we have just set up.
+The deployment occurs only if we're pushing or merging to `master` branch, so that the development versions are tested but not published.
+
+Done! Now you have all the changes in the GitLab repo, and a pipeline has already been started for this commit. In the **Pipelines** tab you can see what's happening.
+If the deployment has been successful, the deploy job log will output:
+
+```
+[INFO] ------------------------------------------------------------------------
+[INFO] BUILD SUCCESS
+[INFO] ------------------------------------------------------------------------
+[INFO] Total time: 1.983 s
+```
+
+>**Note**:
+the `mvn` command downloads a lot of files from the internet, so you'll see a lot of extra activity in the log the first time you run it.
+
+Yay! You did it! Checking in Artifactory will confirm that you have a new artifact available in the `libs-release-local` repo.
+
+## Create the main Maven application
+
+Now that you have the dependency available on Artifactory, it's time to use it!
+Let's see how we can have it as a dependency to our main application.
+
+### Prepare the main application
+
+We'll use again a Maven app that can be cloned from our example project:
+
+1. Create a new project by selecting **Import project from âž” Repo by URL**
+1. Add the following URL:
+
+ ```
+ https://gitlab.com/gitlab-examples/maven/simple-maven-app.git
+ ```
+1. Click **Create project**
+
+This one is a simple app as well. If you look at the `src/main/java/com/example/app/App.java`
+file you can see that it imports the `com.example.dep.Dep` class and calls the `hello` method passing `GitLab` as a parameter.
+
+Since Maven doesn't know how to resolve the dependency, you need to modify the configuration:
+
+1. Go back to Artifactory
+1. Browse the `libs-release-local` repository
+1. Select the `simple-maven-dep-1.0.jar` file
+1. Find the configuration snippet from the **Dependency Declaration** section of the main panel
+1. Copy the snippet in the `dependencies` section of the `pom.xml` file.
+ The snippet should look like this:
+
+ ```xml
+ <dependency>
+ <groupId>com.example.dep</groupId>
+ <artifactId>simple-maven-dep</artifactId>
+ <version>1.0</version>
+ </dependency>
+ ```
+
+### Configure the Artifactory repository location
+
+At this point you defined the dependency for the application, but you still miss where you can find the required files.
+You need to create a `.m2/settings.xml` file as you did for the dependency project, and let Maven know the location using environment variables.
+
+Here is how you can get the content of the file directly from Artifactory:
+
+1. From the main screen, click on the `libs-release-local` item in the **Set Me Up** panel
+1. Click on **Generate Maven Settings**
+1. Click on **Generate Settings**
+1. Copy to clipboard the configuration file
+1. Save the file as `.m2/settings.xml` in your repo
+
+Now you are ready to use the Artifactory repository to resolve dependencies and use `simple-maven-dep` in your main application!
+
+### Configure GitLab CI/CD for `simple-maven-app`
+
+You need a last step to have everything in place: configure the `.gitlab-ci.yml` file for this project, as you already did for `simple-maven-dep`.
+
+You want to leverage [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/) to automatically build, test and run your awesome application,
+and see if you can get the greeting as expected!
+
+All you need to do is to add the following `.gitlab-ci.yml` to the repo:
+
+```yaml
+image: maven:latest
+
+stages:
+ - build
+ - test
+ - run
+
+variables:
+ MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode"
+ MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
+
+cache:
+ paths:
+ - .m2/repository/
+ - target/
+
+build:
+ stage: build
+ script:
+ - mvn $MAVEN_CLI_OPTS compile
+
+test:
+ stage: test
+ script:
+ - mvn $MAVEN_CLI_OPTS test
+
+run:
+ stage: run
+ script:
+ - mvn $MAVEN_CLI_OPTS package
+ - mvn $MAVEN_CLI_OPTS exec:java -Dexec.mainClass="com.example.app.App"
+```
+
+It is very similar to the configuration used for `simple-maven-dep`, but instead of the `deploy` job there is a `run` job.
+Probably something that you don't want to use in real projects, but here it is useful to see the application executed automatically.
+
+And that's it! In the `run` job output log you will find a friendly hello to GitLab!
+
+## Conclusion
+
+In this article we covered the basic steps to use an Artifactory Maven repository to automatically publish and consume artifacts.
+
+A similar approach could be used to interact with any other Maven compatible Binary Repository Manager.
+Obviously, you can improve these examples, optimizing the `.gitlab-ci.yml` file to better suit your needs, and adapting to your workflow.
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png b/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png
new file mode 100644
index 00000000000..11ce324f938
--- /dev/null
+++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png
Binary files differ
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif b/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif
new file mode 100644
index 00000000000..a6727a3d85f
--- /dev/null
+++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif
Binary files differ
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif b/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif
new file mode 100644
index 00000000000..36e6085259f
--- /dev/null
+++ b/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif
Binary files differ
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md
new file mode 100644
index 00000000000..25a24bc1d32
--- /dev/null
+++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md
@@ -0,0 +1,267 @@
+# How to configure LDAP with GitLab CE
+
+> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** admin guide ||
+> **Level:** intermediary ||
+> **Author:** [Chris Wilson](https://gitlab.com/MrChrisW) ||
+> **Publication date:** 2017-05-03
+
+## Introduction
+
+Managing a large number of users in GitLab can become a burden for system administrators. As an organization grows so do user accounts. Keeping these user accounts in sync across multiple enterprise applications often becomes a time consuming task.
+
+In this guide we will focus on configuring GitLab with Active Directory. [Active Directory](https://en.wikipedia.org/wiki/Active_Directory) is a popular LDAP compatible directory service provided by Microsoft, included in all modern Windows Server operating systems.
+
+GitLab has supported LDAP integration since [version 2.2](https://about.gitlab.com/2012/02/22/gitlab-version-2-2/). With GitLab LDAP [group syncing](#group-syncing-ee) being added to GitLab Enterprise Edition in [version 6.0](https://about.gitlab.com/2013/08/20/gitlab-6-dot-0-released/). LDAP integration has become one of the most popular features in GitLab.
+
+## Getting started
+
+### Choosing an LDAP Server
+
+The main reason organizations choose to utilize a LDAP server is to keep the entire organization's user base consolidated into a central repository. Users can access multiple applications and systems across the IT environment using a single login. Because LDAP is an open, vendor-neutral, industry standard application protocol, the number of applications using LDAP authentication continues to increase.
+
+There are many commercial and open source [directory servers](https://en.wikipedia.org/wiki/Directory_service#LDAP_implementations) that support the LDAP protocol. Deciding on the right directory server highly depends on the existing IT environment in which the server will be integrated with.
+
+For example, [Active Directory](https://technet.microsoft.com/en-us/library/hh831484(v=ws.11).aspx) is generally favored in a primarily Windows environment, as this allows quick integration with existing services. Other popular directory services include:
+
+- [Oracle Internet Directory](http://www.oracle.com/technetwork/middleware/id-mgmt/overview/index-082035.html)
+- [OpenLDAP](http://www.openldap.org/)
+- [389 Directory](http://directory.fedoraproject.org/)
+- [OpenDJ](https://forgerock.org/opendj/)
+- [ApacheDS](https://directory.apache.org/)
+
+> GitLab uses the [Net::LDAP](https://rubygems.org/gems/net-ldap) library under the hood. This means it supports all [IETF](https://tools.ietf.org/html/rfc2251) compliant LDAPv3 servers.
+
+### Active Directory (AD)
+
+We won't cover the installation and configuration of Windows Server or Active Directory Domain Services in this tutorial. There are a number of resources online to guide you through this process:
+
+- Install Windows Server 2012 - (_technet.microsoft.com_) - [Installing Windows Server 2012 ](https://technet.microsoft.com/en-us/library/jj134246(v=ws.11).aspx)
+
+- Install Active Directory Domain Services (AD DS) (_technet.microsoft.com_)- [Install Active Directory Domain Services](https://technet.microsoft.com/windows-server-docs/identity/ad-ds/deploy/install-active-directory-domain-services--level-100-#BKMK_PS)
+
+> **Shortcut:** You can quickly install AD DS via PowerShell using
+`Install-WindowsFeature AD-Domain-Services -IncludeManagementTools`
+
+### Creating an AD **OU** structure
+
+Configuring organizational units (**OU**s) is an important part of setting up Active Directory. **OU**s form the base for an entire organizational structure. Using GitLab as an example we have designed the **OU** structure below using the geographic **OU** model. In the Geographic Model we separate **OU**s for different geographic regions.
+
+| GitLab **OU** Design | GitLab AD Structure |
+| :----------------------------: | :------------------------------: |
+| ![GitLab OU Design][gitlab_ou] | ![GitLab AD Structure][ldap_ou] |
+
+[gitlab_ou]: img/gitlab_ou.png
+[ldap_ou]: img/ldap_ou.gif
+
+Using PowerShell you can output the **OU** structure as a table (_all names are examples only_):
+
+```ps
+Get-ADObject -LDAPFilter "(objectClass=*)" -SearchBase 'OU=GitLab INT,DC=GitLab,DC=org' -Properties CanonicalName | Format-Table Name,CanonicalName -A
+```
+
+```
+OU CanonicalName
+---- -------------
+GitLab INT GitLab.org/GitLab INT
+United States GitLab.org/GitLab INT/United States
+Developers GitLab.org/GitLab INT/United States/Developers
+Gary Johnson GitLab.org/GitLab INT/United States/Developers/Gary Johnson
+Ellis Matthews GitLab.org/GitLab INT/United States/Developers/Ellis Matthews
+William Collins GitLab.org/GitLab INT/United States/Developers/William Collins
+People Ops GitLab.org/GitLab INT/United States/People Ops
+Margaret Baker GitLab.org/GitLab INT/United States/People Ops/Margaret Baker
+Libby Hartzler GitLab.org/GitLab INT/United States/People Ops/Libby Hartzler
+Victoria Ryles GitLab.org/GitLab INT/United States/People Ops/Victoria Ryles
+The Netherlands GitLab.org/GitLab INT/The Netherlands
+Developers GitLab.org/GitLab INT/The Netherlands/Developers
+John Doe GitLab.org/GitLab INT/The Netherlands/Developers/John Doe
+Jon Mealy GitLab.org/GitLab INT/The Netherlands/Developers/Jon Mealy
+Jane Weingarten GitLab.org/GitLab INT/The Netherlands/Developers/Jane Weingarten
+Production GitLab.org/GitLab INT/The Netherlands/Production
+Sarah Konopka GitLab.org/GitLab INT/The Netherlands/Production/Sarah Konopka
+Cynthia Bruno GitLab.org/GitLab INT/The Netherlands/Production/Cynthia Bruno
+David George GitLab.org/GitLab INT/The Netherlands/Production/David George
+United Kingdom GitLab.org/GitLab INT/United Kingdom
+Developers GitLab.org/GitLab INT/United Kingdom/Developers
+Leroy Fox GitLab.org/GitLab INT/United Kingdom/Developers/Leroy Fox
+Christopher Alley GitLab.org/GitLab INT/United Kingdom/Developers/Christopher Alley
+Norris Morita GitLab.org/GitLab INT/United Kingdom/Developers/Norris Morita
+Support GitLab.org/GitLab INT/United Kingdom/Support
+Laura Stanley GitLab.org/GitLab INT/United Kingdom/Support/Laura Stanley
+Nikki Schuman GitLab.org/GitLab INT/United Kingdom/Support/Nikki Schuman
+Harriet Butcher GitLab.org/GitLab INT/United Kingdom/Support/Harriet Butcher
+Global Groups GitLab.org/GitLab INT/Global Groups
+DevelopersNL GitLab.org/GitLab INT/Global Groups/DevelopersNL
+DevelopersUK GitLab.org/GitLab INT/Global Groups/DevelopersUK
+DevelopersUS GitLab.org/GitLab INT/Global Groups/DevelopersUS
+ProductionNL GitLab.org/GitLab INT/Global Groups/ProductionNL
+SupportUK GitLab.org/GitLab INT/Global Groups/SupportUK
+People Ops US GitLab.org/GitLab INT/Global Groups/People Ops US
+Global Admins GitLab.org/GitLab INT/Global Groups/Global Admins
+```
+
+> See [more information](https://technet.microsoft.com/en-us/library/ff730967.aspx) on searching Active Directory with Windows PowerShell from [The Scripting Guys](https://technet.microsoft.com/en-us/scriptcenter/dd901334.aspx)
+
+## GitLab LDAP configuration
+
+The initial configuration of LDAP in GitLab requires changes to the `gitlab.rb` configuration file. Below is an example of a complete configuration using an Active Directory.
+
+The two Active Directory specific values are `active_directory: true` and `uid: 'sAMAccountName'`. `sAMAccountName` is an attribute returned by Active Directory used for GitLab usernames. See the example output from `ldapsearch` for a full list of attributes a "person" object (user) has in **AD** - [`ldapsearch` example](#using-ldapsearch-unix)
+
+> Both group_base and admin_group configuration options are only available in GitLab Enterprise Edition. See [GitLab EE - LDAP Features](#gitlab-enterprise-edition---ldap-features)
+
+### Example `gitlab.rb` LDAP
+
+```
+gitlab_rails['ldap_enabled'] = true
+gitlab_rails['ldap_servers'] = {
+'main' => {
+ 'label' => 'GitLab AD',
+ 'host' => 'ad.example.org',
+ 'port' => 636,
+ 'uid' => 'sAMAccountName',
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'bind_dn' => 'CN=GitLabSRV,CN=Users,DC=GitLab,DC=org',
+ 'password' => 'Password1',
+ 'active_directory' => true,
+ 'base' => 'OU=GitLab INT,DC=GitLab,DC=org',
+ 'group_base' => 'OU=Global Groups,OU=GitLab INT,DC=GitLab,DC=org',
+ 'admin_group' => 'Global Admins'
+ }
+}
+```
+
+> **Note:** Remember to run `gitlab-ctl reconfigure` after modifying `gitlab.rb`
+
+## Security improvements (LDAPS)
+
+Security is an important aspect when deploying an LDAP server. By default, LDAP traffic is transmitted unsecured. LDAP can be secured using SSL/TLS called LDAPS, or commonly "LDAP over SSL".
+
+Securing LDAP (enabling LDAPS) on Windows Server 2012 involves installing a valid SSL certificate. For full details see Microsoft's guide [How to enable LDAP over SSL with a third-party certification authority](https://support.microsoft.com/en-us/help/321051/how-to-enable-ldap-over-ssl-with-a-third-party-certification-authority)
+
+> By default a LDAP service listens for connections on TCP and UDP port 389. LDAPS (LDAP over SSL) listens on port 636
+
+### Testing you AD server
+
+#### Using **AdFind** (Windows)
+
+You can use the [`AdFind`](https://social.technet.microsoft.com/wiki/contents/articles/7535.adfind-command-examples.aspx) utility (on Windows based systems) to test that your LDAP server is accessible and authentication is working correctly. This is a freeware utility built by [Joe Richards](http://www.joeware.net/freetools/tools/adfind/index.htm).
+
+**Return all objects**
+
+You can use the filter `objectclass=*` to return all directory objects.
+
+```sh
+adfind -h ad.example.org:636 -ssl -u "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -up Password1 -b "OU=GitLab INT,DC=GitLab,DC=org" -f (objectClass=*)
+```
+
+**Return single object using filter**
+
+You can also retrieve a single object by **specifying** the object name or full **DN**. In this example we specify the object name only `CN=Leroy Fox`.
+
+```sh
+adfind -h ad.example.org:636 -ssl -u "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -up Password1 -b "OU=GitLab INT,DC=GitLab,DC=org" -f (&(objectcategory=person)(CN=Leroy Fox))â€
+```
+
+#### Using **ldapsearch** (Unix)
+
+You can use the `ldapsearch` utility (on Unix based systems) to test that your LDAP server is accessible and authentication is working correctly. This utility is included in the [`ldap-utils`](https://wiki.debian.org/LDAP/LDAPUtils) package.
+
+**Return all objects**
+
+You can use the filter `objectclass=*` to return all directory objects.
+
+```sh
+ldapsearch -D "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" \
+-w Password1 -p 636 -h ad.example.org \
+-b "OU=GitLab INT,DC=GitLab,DC=org" -Z \
+-s sub "(objectclass=*)"
+```
+
+**Return single object using filter**
+
+You can also retrieve a single object by **specifying** the object name or full **DN**. In this example we specify the object name only `CN=Leroy Fox`.
+
+```sh
+ldapsearch -D "CN=GitLabSRV,CN=Users,DC=GitLab,DC=org" -w Password1 -p 389 -h ad.example.org -b "OU=GitLab INT,DC=GitLab,DC=org" -Z -s sub "CN=Leroy Fox"
+```
+
+**Full output of `ldapsearch` command:** - Filtering for _CN=Leroy Fox_
+
+```
+# LDAPv3
+# base <OU=GitLab INT,DC=GitLab,DC=org> with scope subtree
+# filter: CN=Leroy Fox
+# requesting: ALL
+#
+
+# Leroy Fox, Developers, United Kingdom, GitLab INT, GitLab.org
+dn: CN=Leroy Fox,OU=Developers,OU=United Kingdom,OU=GitLab INT,DC=GitLab,DC=or
+ g
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: user
+cn: Leroy Fox
+sn: Fox
+givenName: Leroy
+distinguishedName: CN=Leroy Fox,OU=Developers,OU=United Kingdom,OU=GitLab INT,
+ DC=GitLab,DC=org
+instanceType: 4
+whenCreated: 20170210030500.0Z
+whenChanged: 20170213050128.0Z
+displayName: Leroy Fox
+uSNCreated: 16790
+memberOf: CN=DevelopersUK,OU=Global Groups,OU=GitLab INT,DC=GitLab,DC=org
+uSNChanged: 20812
+name: Leroy Fox
+objectGUID:: rBCAo6NR6E6vfSKgzcUILg==
+userAccountControl: 512
+badPwdCount: 0
+codePage: 0
+countryCode: 0
+badPasswordTime: 0
+lastLogoff: 0
+lastLogon: 0
+pwdLastSet: 131311695009850084
+primaryGroupID: 513
+objectSid:: AQUAAAAAAAUVAAAA9GMAb7tdJZvsATf7ZwQAAA==
+accountExpires: 9223372036854775807
+logonCount: 0
+sAMAccountName: Leroyf
+sAMAccountType: 805306368
+userPrincipalName: Leroyf@GitLab.org
+objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=GitLab,DC=org
+dSCorePropagationData: 16010101000000.0Z
+lastLogonTimestamp: 131314356887754250
+
+# search result
+search: 2
+result: 0 Success
+
+# numResponses: 2
+# numEntries: 1
+```
+
+## Basic user authentication
+
+After configuring LDAP, basic authentication will be available. Users can then login using their directory credentials. An extra tab is added to the GitLab login screen for the configured LDAP server (e.g "**GitLab AD**").
+
+![GitLab OU Structure](img/user_auth.gif)
+
+Users that are removed from the LDAP base group (e.g `OU=GitLab INT,DC=GitLab,DC=org`) will be **blocked** in GitLab. [More information](../../administration/auth/ldap.md#security) on LDAP security.
+
+If `allow_username_or_email_login` is enabled in the LDAP configuration, GitLab will ignore everything after the first '@' in the LDAP username used on login. Example: The username `jon.doe@example.com` is converted to `jon.doe` when authenticating with the LDAP server. Disable this setting if you use `userPrincipalName` as the `uid`.
+
+## LDAP extended features on GitLab EE
+
+With [GitLab Enterprise Edition (EE)](https://about.gitlab.com/gitlab-ee/), besides everything we just described, you'll
+have extended functionalities with LDAP, such as:
+
+- Group sync
+- Group permissions
+- Updating user permissions
+- Multiple LDAP servers
+
+Read through the article on [LDAP for GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) for an overview.
diff --git a/doc/articles/how_to_install_git/index.md b/doc/articles/how_to_install_git/index.md
new file mode 100644
index 00000000000..37b60501ce2
--- /dev/null
+++ b/doc/articles/how_to_install_git/index.md
@@ -0,0 +1,66 @@
+# Installing Git
+
+> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** user guide ||
+> **Level:** beginner ||
+> **Author:** [Sean Packham](https://gitlab.com/SeanPackham) ||
+> **Publication date:** 2017-05-15
+
+To begin contributing to GitLab projects
+you will need to install the Git client on your computer.
+This article will show you how to install Git on macOS, Ubuntu Linux and Windows.
+
+## Install Git on macOS using the Homebrew package manager
+
+Although it is easy to use the version of Git shipped with macOS
+or install the latest version of Git on macOS by downloading it from the project website,
+we recommend installing it via Homebrew to get access to
+an extensive selection of dependancy managed libraries and applications.
+
+If you are sure you don't need access to any additional development libraries
+or don't have approximately 15gb of available disk space for Xcode and Homebrew
+use one of the the aforementioned methods.
+
+### Installing Xcode
+
+Xcode is needed by Homebrew to build dependencies.
+You can install [XCode](https://developer.apple.com/xcode/)
+through the macOS App Store.
+
+### Installing Homebrew
+
+Once Xcode is installed browse to the [Homebrew website](http://brew.sh/index.html)
+for the official Homebrew installation instructions.
+
+### Installing Git via Homebrew
+
+With Homebrew installed you are now ready to install Git.
+Open a Terminal and enter in the following command:
+
+```bash
+brew install git
+```
+
+Congratulations you should now have Git installed via Homebrew.
+Next read our article on [adding an SSH key to GitLab](../../ssh/README.md).
+
+## Install Git on Ubuntu Linux
+
+On Ubuntu and other Linux operating systems
+it is recommended to use the built in package manager to install Git.
+
+Open a Terminal and enter in the following commands
+to install the latest Git from the official Git maintained package archives:
+
+```bash
+sudo apt-add-repository ppa:git-core/ppa
+sudo apt-get update
+sudo apt-get install git
+```
+
+Congratulations you should now have Git installed via the Ubuntu package manager.
+Next read our article on [adding an SSH key to GitLab](../../ssh/README.md).
+
+## Installing Git on Windows from the Git website
+
+Browse to the [Git website](https://git-scm.com/) and download and install Git for Windows.
+Next read our article on [adding an SSH key to GitLab](../../ssh/README.md).
diff --git a/doc/articles/index.md b/doc/articles/index.md
index 67eab36bf2c..1aa65504852 100644
--- a/doc/articles/index.md
+++ b/doc/articles/index.md
@@ -7,10 +7,109 @@ to provide the community with guidance on specific processes to achieve certain
They are written by members of the GitLab Team and by
[Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/).
+Part of the articles listed below link to the [GitLab Blog](https://about.gitlab.com/blog/),
+where they were originally published.
+
+## Authentication
+
+Explore GitLab's supported [authentications methods](../topics/authentication/index.md):
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| **LDAP** |
+| [How to configure LDAP with GitLab CE](how_to_configure_ldap_gitlab_ce/index.md)| Admin guide | 2017-05-03 |
+| [How to configure LDAP with GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) | Admin guide | 2017-05-03 |
+
+## Build, test, and deploy with GitLab CI/CD
+
+Build, test, and deploy the software you develop with [GitLab CI/CD](../ci/README.md):
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md) | Tutorial | 2017-08-15 |
+| [Making CI Easier with GitLab](https://about.gitlab.com/2017/07/13/making-ci-easier-with-gitlab/) | Concepts | 2017-07-13 |
+| [Dockerizing GitLab Review Apps](https://about.gitlab.com/2017/07/11/dockerizing-review-apps/) | Concepts | 2017-07-11 |
+| [Continuous Integration: From Jenkins to GitLab Using Docker](https://about.gitlab.com/2017/07/27/docker-my-precious/) | Concepts | 2017-07-27 |
+| [Continuous Delivery of a Spring Boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/) | Tutorial | 2016-12-14 |
+| [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/) | Tutorial | 2016-11-30 |
+| [Automated Debian Package Build with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/) | Tutorial | 2016-10-12 |
+| [Building an Elixir Release into a Docker image using GitLab CI](https://about.gitlab.com/2016/08/11/building-an-elixir-release-into-docker-image-using-gitlab-ci-part-1/) | Tutorial | 2016-08-11 |
+| [Continuous Delivery with GitLab and Convox](https://about.gitlab.com/2016/06/09/continuous-delivery-with-gitlab-and-convox/) | Technical overview | 2016-06-09 |
+| [GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/) | Technical overview | 2016-05-23 |
+| [How to use GitLab CI and MacStadium to build your macOS or iOS projects](https://about.gitlab.com/2017/05/15/how-to-use-macstadium-and-gitlab-ci-to-build-your-macos-or-ios-projects/) | Technical overview | 2017-05-15 |
+| [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) | Tutorial | 2016-03-10 |
+
+## Git
+
+Learn how to use [Git with GitLab](../topics/git/index.md):
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| [Numerous _undo_ possibilities in Git](numerous_undo_possibilities_in_git/index.md) | Tutorial | 2017-08-17 |
+| [Why Git is Worth the Learning Curve](https://about.gitlab.com/2017/05/17/learning-curve-is-the-biggest-challenge-developers-face-with-git/) | Concepts | 2017-05-17 |
+| [How to install Git](how_to_install_git/index.md) | Tutorial | 2017-05-15 |
+| [Getting Started with Git LFS](https://about.gitlab.com/2017/01/30/getting-started-with-git-lfs-tutorial/) | Tutorial | 2017-01-30 |
+| [Git Tips & Tricks](https://about.gitlab.com/2016/12/08/git-tips-and-tricks/) | Technical overview | 2016-12-08 |
+
## GitLab Pages
-- **GitLab Pages from A to Z**
- - [Part 1: Static sites and GitLab Pages domains](../user/project/pages/getting_started_part_one.md)
- - [Part 2: Quick start guide - Setting up GitLab Pages](../user/project/pages/getting_started_part_two.md)
- - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](../user/project/pages/getting_started_part_three.md)
- - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](../user/project/pages/getting_started_part_four.md)
+Learn how to deploy a static website with [GitLab Pages](../user/project/pages/index.md#getting-started):
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| **Series: GitLab Pages from A to Z:** |
+| [- Part 1: Static sites and GitLab Pages domains](../user/project/pages/getting_started_part_one.md)| User guide | 2017-02-22 |
+| [- Part 2: Quick start guide - Setting up GitLab Pages](../user/project/pages/getting_started_part_two.md)| User guide | 2017-02-22 |
+| [- Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](../user/project/pages/getting_started_part_three.md)| User guide | 2017-02-22 |
+| [- Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](../user/project/pages/getting_started_part_four.md)| User guide | 2017-02-22 |
+| [Setting up GitLab Pages with CloudFlare Certificates](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/) | Tutorial | 2017-02-07 |
+| [Building a new GitLab Docs site with Nanoc, GitLab CI, and GitLab Pages](https://about.gitlab.com/2016/12/07/building-a-new-gitlab-docs-site-with-nanoc-gitlab-ci-and-gitlab-pages/) | Tutorial | 2016-12-07 |
+| [Publish Code Coverage Report with GitLab Pages](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/) | Tutorial | 2016-11-03 |
+| [GitLab CI: Deployment & Environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) | Tutorial | 2016-08-26 |
+| [Posting to your GitLab Pages blog from iOS](https://about.gitlab.com/2016/08/19/posting-to-your-gitlab-pages-blog-from-ios/) | Tutorial | 2016-08-19 |
+| **Series: Static Site Generator:** |
+| [- Part 1: Dynamic vs Static Websites](https://about.gitlab.com/2016/06/03/ssg-overview-gitlab-pages-part-1-dynamic-x-static/) | Tutorial | 2016-06-03 |
+| [- Part 2: Modern Static Site Generators](https://about.gitlab.com/2016/06/10/ssg-overview-gitlab-pages-part-2/) | Tutorial | 2016-06-10 |
+| [- Part 3: Build any SSG site with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/) | Tutorial | 2016-06-17 |
+| [Securing your GitLab Pages with TLS and Let's Encrypt](https://about.gitlab.com/2016/04/11/tutorial-securing-your-gitlab-pages-with-tls-and-letsencrypt/) | Tutorial | 2016-04-11 |
+| [Hosting on GitLab.com with GitLab Pages](https://about.gitlab.com/2016/04/07/gitlab-pages-setup/) | Tutorial | 2016-04-07 |
+
+## Install and maintain GitLab
+
+Install, upgrade, integrate, migrate to GitLab:
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| [Video Tutorial: Idea to Production on Google Container Engine (GKE)](https://about.gitlab.com/2017/01/23/video-tutorial-idea-to-production-on-google-container-engine-gke/) | Tutorial | 2017-01-23 |
+| [How to Setup a GitLab Instance on Microsoft Azure](https://about.gitlab.com/2016/07/13/how-to-setup-a-gitlab-instance-on-microsoft-azure/) | Tutorial | 2016-07-13 |
+| [Get started with OpenShift Origin 3 and GitLab](openshift_and_gitlab/index.md) | Tutorial | 2016-06-28 |
+| [Getting started with GitLab and DigitalOcean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/) | Tutorial | 2016-04-27 |
+
+## Software development
+
+Explore the best of GitLab's software development's capabilities:
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| [Making CI Easier with GitLab](https://about.gitlab.com/2017/07/13/making-ci-easier-with-gitlab/) | Concepts | 2017-07-13 |
+| [From 2/3 of the Self-Hosted Git Market, to the Next-Generation CI System, to Auto DevOps](https://about.gitlab.com/2017/06/29/whats-next-for-gitlab-ci/)| Concepts | 2017-06-29 |
+| [Fast and Natural Continuous Integration with GitLab CI](https://about.gitlab.com/2017/05/22/fast-and-natural-continuous-integration-with-gitlab-ci/) | Concepts | 2017-05-22 |
+| [Demo: Auto-Deploy from GitLab to an OpenShift Container Cluster](https://about.gitlab.com/2017/05/16/devops-containers-gitlab-openshift/) | Technical overview | 2017-05-16 |
+| [Demo: GitLab Service Desk](https://about.gitlab.com/2017/05/09/demo-service-desk/) | Feature highlight | 2017-05-09 |
+| [Demo: Mapping Work Versus Time, With Burndown Charts](https://about.gitlab.com/2017/04/25/mapping-work-to-do-versus-time-with-burndown-charts/) | Feature highlight | 2017-04-25 |
+| [Demo: Cloud Native Development with GitLab](https://about.gitlab.com/2017/04/18/cloud-native-demo/) | Feature highlight | 2017-04-18 |
+| [Demo: Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/) | Feature highlight | 2017-03-17 |
+| [In 13 minutes from Kubernetes to a complete application development tool](https://about.gitlab.com/2016/11/14/idea-to-production/) | Technical overview | 2016-11-14 |
+| [GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/) | Technical overview | 2016-10-25 |
+| [Trends in Version Control Land: Microservices](https://about.gitlab.com/2016/08/16/trends-in-version-control-land-microservices/) | Concepts | 2016-08-16 |
+| [Continuous Integration, Delivery, and Deployment with GitLab](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/) | Concepts | 2016-08-05 |
+| [Trends in Version Control Land: Innersourcing](https://about.gitlab.com/2016/07/07/trends-version-control-innersourcing/) | Concepts | 2016-07-07 |
+| [Tutorial: It's all connected in GitLab](https://about.gitlab.com/2016/03/08/gitlab-tutorial-its-all-connected/) | Technical overview | 2016-03-08 |
+
+## Technologies
+
+| Article title | Category | Publishing date |
+| :------------ | :------: | --------------: |
+| [Why we are not leaving the cloud](https://about.gitlab.com/2017/03/02/why-we-are-not-leaving-the-cloud/) | Concepts | 2017-03-02 |
+| [Why We Chose Vue.js](https://about.gitlab.com/2016/10/20/why-we-chose-vue/) | Concepts | 2016-10-20 |
+| [Markdown Kramdown Tips & Tricks](https://about.gitlab.com/2016/07/19/markdown-kramdown-tips-and-tricks/) | Technical overview | 2016-07-19 |
diff --git a/doc/articles/numerous_undo_possibilities_in_git/img/branching.png b/doc/articles/numerous_undo_possibilities_in_git/img/branching.png
new file mode 100644
index 00000000000..9a80c211c99
--- /dev/null
+++ b/doc/articles/numerous_undo_possibilities_in_git/img/branching.png
Binary files differ
diff --git a/doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.png b/doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.png
new file mode 100644
index 00000000000..ac7ea9ecddc
--- /dev/null
+++ b/doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.png
Binary files differ
diff --git a/doc/articles/numerous_undo_possibilities_in_git/img/revert.png b/doc/articles/numerous_undo_possibilities_in_git/img/revert.png
new file mode 100644
index 00000000000..13b3a35ca45
--- /dev/null
+++ b/doc/articles/numerous_undo_possibilities_in_git/img/revert.png
Binary files differ
diff --git a/doc/articles/numerous_undo_possibilities_in_git/index.md b/doc/articles/numerous_undo_possibilities_in_git/index.md
new file mode 100644
index 00000000000..9f1239b8f88
--- /dev/null
+++ b/doc/articles/numerous_undo_possibilities_in_git/index.md
@@ -0,0 +1,497 @@
+# Numerous undo possibilities in Git
+
+> **Article [Type](../../development/writing_documentation.md#types-of-technical-articles):** tutorial ||
+> **Level:** intermediary ||
+> **Author:** [Crt Mori](https://gitlab.com/Letme) ||
+> **Publication date:** 2017/08/17
+
+## Introduction
+
+In this tutorial, we will show you different ways of undoing your work in Git, for which
+we will assume you have a basic working knowledge of. Check GitLab's
+[Git documentation](../../topics/git/index.md#git-documentation) for reference.
+Also, we will only provide some general info of the commands, which is enough
+to get you started for the easy cases/examples, but for anything more advanced please refer to the [Git book](https://git-scm.com/book/en/v2).
+
+We will explain a few different techniques to undo your changes based on the stage
+of the change in your current development. Also, keep in mind that [nothing in
+Git is really deleted.][git-autoclean-ref]
+This means that until Git automatically cleans detached commits (which cannot be
+accessed by branch or tag) it will be possible to view them with `git reflog` command
+and access them with direct commit-id. Read more about _[redoing the undo](#redoing-the-undo)_ on the section below.
+
+This guide is organized depending on the [stage of development][git-basics]
+where you want to undo your changes from and if they were shared with other developers
+or not. Because Git is tracking changes a created or edited file is in the unstaged state
+(if created it is untracked by Git). After you add it to a repository (`git add`) you put
+a file into the **staged** state, which is then committed (`git commit`) to your
+local repository. After that, file can be shared with other developers (`git push`).
+Here's what we'll cover in this tutorial:
+
+ - [Undo local changes](#undo-local-changes) which were not pushed to remote repository
+
+ - Before you commit, in both unstaged and staged state
+ - After you committed
+
+ - Undo changes after they are pushed to remote repository
+
+ - [Without history modification](#undo-remote-changes-without-changing-history) (preferred way)
+ - [With history modification](#undo-remote-changes-with-modifying-history) (requires
+ coordination with team and force pushes).
+
+ - [Usecases when modifying history is generally acceptable](#where-modifying-history-is-generally-acceptable)
+ - [How to modify history](#how-modifying-history-is-done)
+ - [How to remove sensitive information from repository](#deleting-sensitive-information-from-commits)
+
+
+### Branching strategy
+
+[Git][git-official] is a de-centralized version control system, which means that beside regular
+versioning of the whole repository, it has possibilities to exchange changes
+with other repositories. To avoid chaos with
+[multiple sources of truth][git-distributed], various
+development workflows have to be followed, and it depends on your internal
+workflow how certain changes or commits can be undone or changed.
+[GitLab Flow][gitlab-flow] provides a good
+balance between developers clashing with each other while
+developing the same feature and cooperating seamlessly, but it does not enable
+joined development of the same feature by multiple developers by default.
+When multiple developers develop the same feature on the same branch, clashing
+with every synchronization is unavoidable, but a proper or chosen Git Workflow will
+prevent that anything is lost or out of sync when feature is complete. You can also
+read through this blog post on [Git Tips & Tricks][gitlab-git-tips-n-tricks]
+to learn how to easily **do** things in Git.
+
+
+## Undo local changes
+
+Until you push your changes to any remote repository, they will only affect you.
+That broadens your options on how to handle undoing them. Still, local changes
+can be on various stages and each stage has a different approach on how to tackle them.
+
+
+### Unstaged local changes (before you commit)
+
+When a change is made, but it is not added to the staged tree, Git itself
+proposes a solution to discard changes to certain file.
+
+Suppose you edited a file to change the content using your favorite editor:
+
+```shell
+vim <file>
+```
+
+Since you did not `git add <file>` to staging, it should be under unstaged files (or
+untracked if file was created). You can confirm that with:
+
+```shell
+$ git status
+On branch master
+Your branch is up-to-date with 'origin/master'.
+Changes not staged for commit:
+ (use "git add <file>..." to update what will be committed)
+ (use "git checkout -- <file>..." to discard changes in working directory)
+
+ modified: <file>
+no changes added to commit (use "git add" and/or "git commit -a")
+```
+
+At this point there are 3 options to undo the local changes you have:
+
+ - Discard all local changes, but save them for possible re-use [later](#quickly-save-local-changes)
+
+ ```shell
+ git stash
+ ```
+
+ - Discarding local changes (permanently) to a file
+
+ ```shell
+ git checkout -- <file>
+ ```
+
+ - Discard all local changes to all files permanently
+
+ ```shell
+ git reset --hard
+ ```
+
+
+Before executing `git reset --hard`, keep in mind that there is also a way to
+just temporary store the changes without committing them using `git stash`.
+This command resets the changes to all files, but it also saves them in case
+you would like to apply them at some later time. You can read more about it in
+[section below](#quickly-save-local-changes).
+
+### Quickly save local changes
+
+You are working on a feature when a boss drops by with an urgent task. Since your
+feature is not complete, but you need to swap to another branch, you can use
+`git stash` to save what you had done, swap to another branch, commit, push,
+test, then get back to previous feature branch, do `git stash pop` and continue
+where you left.
+
+The example above shows that discarding all changes is not always a preferred option,
+but Git provides a way to save them for later, while resetting the repository to state without
+them. This is achieved by Git stashing command `git stash`, which in fact saves your
+current work and runs `git reset --hard`, but it also has various
+additional options like:
+
+ - `git stash save`, which enables including temporary commit message, which will help you identify changes, among with other options
+ - `git stash list`, which lists all previously stashed commits (yes, there can be more) that were not `pop`ed
+ - `git stash pop`, which redoes previously stashed changes and removes them from stashed list
+ - `git stash apply`, which redoes previously stashed changes, but keeps them on stashed list
+
+### Staged local changes (before you commit)
+
+Let's say you have added some files to staging, but you want to remove them from the
+current commit, yet you want to retain those changes - just move them outside
+of the staging tree. You also have an option to discard all changes with
+`git reset --hard` or think about `git stash` [as described earlier.](#quickly-save-local-changes)
+
+Lets start the example by editing a file, with your favorite editor, to change the
+content and add it to staging
+
+```
+vim <file>
+git add <file>
+```
+
+The file is now added to staging as confirmed by `git status` command:
+
+```shell
+$ git status
+On branch master
+Your branch is up-to-date with 'origin/master'.
+Changes to be committed:
+ (use "git reset HEAD <file>..." to unstage)
+
+ new file: <file>
+```
+
+Now you have 4 options to undo your changes:
+
+ - Unstage the file to current commit (HEAD)
+
+ ```shell
+ git reset HEAD <file>
+ ```
+
+ - Unstage everything - retain changes
+
+ ```shell
+ git reset
+ ```
+
+ - Discard all local changes, but save them for [later](#quickly-save-local-changes)
+
+ ```shell
+ git stash
+ ```
+
+ - Discard everything permanently
+
+ ```shell
+ git reset --hard
+ ```
+
+## Committed local changes
+
+Once you commit, your changes are recorded by the version control system.
+Because you haven't pushed to your remote repository yet, your changes are
+still not public (or shared with other developers). At this point, undoing
+things is a lot easier, we have quite some workaround options. Once you push
+your code, you'll have less options to troubleshoot your work.
+
+### Without modifying history
+
+Through the development process some of the previously committed changes do not
+fit anymore in the end solution, or are source of the bugs. Once you find the
+commit which triggered bug, or once you have a faulty commit, you can simply
+revert it with `git revert commit-id`. This command inverts (swaps) the additions and
+deletions in that commit, so that it does not modify history. Retaining history
+can be helpful in future to notice that some changes have been tried
+unsuccessfully in the past.
+
+In our example we will assume there are commits `A`,`B`,`C`,`D`,`E` committed in this order: `A-B-C-D-E`,
+and `B` is the commit you want to undo. There are many different ways to identify commit
+`B` as bad, one of them is to pass a range to `git bisect` command. The provided range includes
+last known good commit (we assume `A`) and first known bad commit (where bug was detected - we will assume `E`).
+
+```shell
+git bisect A..E
+```
+
+Bisect will provide us with commit-id of the middle commit to test, and then guide us
+through simple bisection process. You can read more about it [in official Git Tools][git-debug]
+In our example we will end up with commit `B`, that introduced bug/error. We have
+4 options on how to remove it (or part of it) from our repository.
+
+- Undo (swap additions and deletions) changes introduced by commit `B`.
+
+ ```shell
+ git revert commit-B-id
+ ```
+
+- Undo changes on a single file or directory from commit `B`, but retain them in the staged state
+
+ ```shell
+ git checkout commit-B-id <file>
+ ```
+
+- Undo changes on a single file or directory from commit `B`, but retain them in the unstaged state
+
+ ```shell
+ git reset commit-B-id <file>
+ ```
+
+ - There is one command we also must not forget: **creating a new branch**
+ from the point where changes are not applicable or where the development has hit a
+ dead end. For example you have done commits `A-B-C-D` on your feature-branch
+ and then you figure `C` and `D` are wrong. At this point you either reset to `B`
+ and do commit `F` (which will cause problems with pushing and if forced pushed also with other developers)
+ since branch now looks `A-B-F`, which clashes with what other developers have locally (you will
+ [change history](#with-history-modification)), or you simply checkout commit `B` create
+ a new branch and do commit `F`. In the last case, everyone else can still do their work while you
+ have your new way to get it right and merge it back in later. Alternatively, with GitLab,
+ you can [cherry-pick](../../user/project/merge_requests/cherry_pick_changes.md#cherry-picking-a-commit)
+ that commit into a new merge request.
+
+ ![Create a new branch to avoid clashing](img/branching.png)
+
+ ```shell
+ git checkout commit-B-id
+ git checkout -b new-path-of-feature
+ # Create <commit F>
+ git commit -a
+ ```
+
+### With history modification
+
+There is one command for history modification and that is `git rebase`. Command
+provides interactive mode (`-i` flag) which enables you to:
+
+ - **reword** commit messages (there is also `git commit --amend` for editing
+ last commit message)
+ - **edit** the commit content (changes introduced by commit) and message
+ - **squash** multiple commits into a single one, and have a custom or aggregated
+ commit message
+ - **drop** commits - simply delete them
+ - and few more options
+
+Let us check few examples. Again there are commits `A-B-C-D` where you want to
+delete commit `B`.
+
+- Rebase the range from current commit D to A:
+
+ ```shell
+ git rebase -i A
+ ```
+
+- Command opens your favorite editor where you write `drop` in front of commit
+ `B`, but you leave default `pick` with all other commits. Save and exit the
+ editor to perform a rebase. Remember: if you want to cancel delete whole
+ file content before saving and exiting the editor
+
+In case you want to modify something introduced in commit `B`.
+
+- Rebase the range from current commit D to A:
+
+ ```shell
+ git rebase -i A
+ ```
+
+- Command opens your favorite text editor where you write `edit` in front of commit
+ `B`, but leave default `pick` with all other commits. Save and exit the editor to
+ perform a rebase
+
+- Now do your edits and commit changes:
+
+ ```shell
+ git commit -a
+ ```
+
+You can find some more examples in [below section where we explain how to modify
+history](#how-modifying-history-is-done)
+
+
+### Redoing the Undo
+
+Sometimes you realize that the changes you undid were useful and you want them
+back. Well because of first paragraph you are in luck. Command `git reflog`
+enables you to *recall* detached local commits by referencing or applying them
+via commit-id. Although, do not expect to see really old commits in reflog, because
+Git regularly [cleans the commits which are *unreachable* by branches or tags][git-autoclean-ref].
+
+To view repository history and to track older commits you can use below command:
+
+```shell
+$ git reflog show
+
+# Example output:
+b673187 HEAD@{4}: merge 6e43d5987921bde189640cc1e37661f7f75c9c0b: Merge made by the 'recursive' strategy.
+eb37e74 HEAD@{5}: rebase -i (finish): returning to refs/heads/master
+eb37e74 HEAD@{6}: rebase -i (pick): Commit C
+97436c6 HEAD@{7}: rebase -i (start): checkout 97436c6eec6396c63856c19b6a96372705b08b1b
+...
+88f1867 HEAD@{12}: commit: Commit D
+97436c6 HEAD@{13}: checkout: moving from 97436c6eec6396c63856c19b6a96372705b08b1b to test
+97436c6 HEAD@{14}: checkout: moving from master to 97436c6
+05cc326 HEAD@{15}: commit: Commit C
+6e43d59 HEAD@{16}: commit: Commit B
+```
+
+Output of command shows repository history. In first column there is commit-id,
+in following column, number next to `HEAD` indicates how many commits ago something
+was made, after that indicator of action that was made (commit, rebase, merge, ...)
+and then on end description of that action.
+
+## Undo remote changes without changing history
+
+This topic is roughly same as modifying committed local changes without modifying
+history. **It should be the preferred way of undoing changes on any remote repository
+or public branch.** Keep in mind that branching is the best solution when you want
+to retain the history of faulty development, yet start anew from certain point. Branching
+enables you to include the existing changes in new development (by merging) and
+it also provides a clear timeline and development structure.
+
+![Use revert to keep branch flowing](img/revert.png)
+
+If you want to revert changes introduced in certain `commit-id` you can simply
+revert that `commit-id` (swap additions and deletions) in newly created commit:
+You can do this with
+
+```shell
+git revert commit-id
+```
+
+or creating a new branch:
+
+```shell
+git checkout commit-id
+git checkout -b new-path-of-feature
+```
+
+## Undo remote changes with modifying history
+
+This is useful when you want to *hide* certain things - like secret keys,
+passwords, SSH keys, etc. It is and should not be used to hide mistakes, as
+it will make it harder to debug in case there are some other bugs. The main
+reason for this is that you loose the real development progress. **Also keep in
+mind that, even with modified history, commits are just detached and can still be
+accessed through commit-id** - at least until all repositories perform
+the cleanup of detached commits (happens automatically).
+
+![Modifying history causes problems on remote branch](img/rebase_reset.png)
+
+### Where modifying history is generally acceptable
+
+Modified history breaks the development chain of other developers, as changed
+history does not have matching commits'ids. For that reason it should not
+be used on any public branch or on branch that *might* be used by other
+developers. When contributing to big open source repositories (e.g. [GitLab CE][gitlab-ce]),
+it is acceptable to *squash* commits into a single one, to present
+a nicer history of your contribution.
+Keep in mind that this also removes the comments attached to certain commits
+in merge requests, so if you need to retain traceability in GitLab, then
+modifying history is not acceptable.
+A feature-branch of a merge request is a public branch and might be used by
+other developers, but project process and rules might allow or require
+you to use `git rebase` (command that changes history) to reduce number of
+displayed commits on target branch after reviews are done (for example
+GitLab). There is a `git merge --squash` command which does exactly that
+(squashes commits on feature-branch to a single commit on target branch
+at merge).
+
+>**Note:**
+Never modify the commit history of `master` or shared branch
+
+### How modifying history is done
+
+After you know what you want to modify (how far in history or how which range of
+old commits), use `git rebase -i commit-id`. This command will then display all the commits from
+current version to chosen commit-id and allow modification, squashing, deletion
+of that commits.
+
+```shell
+$ git rebase -i commit1-id..commit3-id
+pick <commit1-id> <commit1-commit-message>
+pick <commit2-id> <commit2-commit-message>
+pick <commit3-id> <commit3-commit-message>
+
+# Rebase commit1-id..commit3-id onto <commit4-id> (3 command(s))
+#
+# Commands:
+# p, pick = use commit
+# r, reword = use commit, but edit the commit message
+# e, edit = use commit, but stop for amending
+# s, squash = use commit, but meld into previous commit
+# f, fixup = like "squash", but discard this commit's log message
+# x, exec = run command (the rest of the line) using shell
+# d, drop = remove commit
+#
+# These lines can be re-ordered; they are executed from top to bottom.
+#
+# If you remove a line here THAT COMMIT WILL BE LOST.
+#
+# However, if you remove everything, the rebase will be aborted.
+#
+# Note that empty commits are commented out
+```
+
+>**Note:**
+It is important to notice that comment from the output clearly states that, if
+you decide to abort, then do not just close your editor (as that will in-fact
+modify history), but remove all uncommented lines and save.
+
+That is one of the reasons why `git rebase` should be used carefully on
+shared and remote branches. But don't worry, there will be nothing broken until
+you push back to the remote repository (so you can freely explore the
+different outcomes locally).
+
+```shell
+# Modify history from commit-id to HEAD (current commit)
+git rebase -i commit-id
+```
+
+### Deleting sensitive information from commits
+
+Git also enables you to delete sensitive information from your past commits and
+it does modify history in the progress. That is why we have included it in this
+section and not as a standalone topic. To do so, you should run the
+`git filter-branch`, which enables you to rewrite history with
+[certain filters][git-filters-manual].
+This command uses rebase to modify history and if you want to remove certain
+file from history altogether use:
+
+```shell
+git filter-branch --tree-filter 'rm filename' HEAD
+```
+
+Since `git filter-branch` command might be slow on big repositories, there are
+tools that can use some of Git specifics to enable faster execution of common
+tasks (which is exactly what removing sensitive information file is about).
+An alternative is [BFG Repo-cleaner][bfg-repo-cleaner]. Keep in mind that these
+tools are faster because they do not provide a same fully feature set as `git filter-branch`
+does, but focus on specific usecases.
+
+## Conclusion
+
+There are various options of undoing your work with any version control system, but
+because of de-centralized nature of Git, these options are multiplied (or limited)
+depending on the stage of your process. Git also enables rewriting history, but that
+should be avoided as it might cause problems when multiple developers are
+contributing to the same codebase.
+
+<!-- Identifiers, in alphabetical order -->
+
+[bfg-repo-cleaner]: https://rtyley.github.io/bfg-repo-cleaner/
+[git-autoclean-ref]: https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery
+[git-basics]: https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository
+[git-debug]: https://git-scm.com/book/en/v2/Git-Tools-Debugging-with-Git
+[git-distributed]: https://git-scm.com/about/distributed
+[git-filters-manual]: https://git-scm.com/docs/git-filter-branch#_options
+[git-official]: https://git-scm.com/
+[gitlab-ce]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria
+[gitlab-flow]: https://about.gitlab.com/2014/09/29/gitlab-flow/
+[gitlab-git-tips-n-tricks]: https://about.gitlab.com/2016/12/08/git-tips-and-tricks/
diff --git a/doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png b/doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png
new file mode 100644
index 00000000000..fcad4e59ae3
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/add-to-project.png b/doc/articles/openshift_and_gitlab/img/add-to-project.png
new file mode 100644
index 00000000000..bd915a229f6
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/add-to-project.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/create-project-ui.png b/doc/articles/openshift_and_gitlab/img/create-project-ui.png
new file mode 100644
index 00000000000..e72866f252a
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/create-project-ui.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-logs.png b/doc/articles/openshift_and_gitlab/img/gitlab-logs.png
new file mode 100644
index 00000000000..1e24080c7df
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/gitlab-logs.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-overview.png b/doc/articles/openshift_and_gitlab/img/gitlab-overview.png
new file mode 100644
index 00000000000..3c5df0ea101
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/gitlab-overview.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-running.png b/doc/articles/openshift_and_gitlab/img/gitlab-running.png
new file mode 100644
index 00000000000..c7db691cb30
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/gitlab-running.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-scale.png b/doc/articles/openshift_and_gitlab/img/gitlab-scale.png
new file mode 100644
index 00000000000..4903c7d7498
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/gitlab-scale.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-settings.png b/doc/articles/openshift_and_gitlab/img/gitlab-settings.png
new file mode 100644
index 00000000000..db4360ffef0
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/gitlab-settings.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/no-resources.png b/doc/articles/openshift_and_gitlab/img/no-resources.png
new file mode 100644
index 00000000000..480fb766468
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/no-resources.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/openshift-infra-project.png b/doc/articles/openshift_and_gitlab/img/openshift-infra-project.png
new file mode 100644
index 00000000000..8b9f85aa341
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/openshift-infra-project.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/pods-overview.png b/doc/articles/openshift_and_gitlab/img/pods-overview.png
new file mode 100644
index 00000000000..e1cf08bd217
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/pods-overview.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/rc-name.png b/doc/articles/openshift_and_gitlab/img/rc-name.png
new file mode 100644
index 00000000000..889e34adbec
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/rc-name.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/running-pods.png b/doc/articles/openshift_and_gitlab/img/running-pods.png
new file mode 100644
index 00000000000..3fd4e56662f
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/running-pods.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/storage-volumes.png b/doc/articles/openshift_and_gitlab/img/storage-volumes.png
new file mode 100644
index 00000000000..ae1e5381faa
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/storage-volumes.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/web-console.png b/doc/articles/openshift_and_gitlab/img/web-console.png
new file mode 100644
index 00000000000..aa1425d4f94
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/img/web-console.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/index.md b/doc/articles/openshift_and_gitlab/index.md
new file mode 100644
index 00000000000..c0bbcfe2a8a
--- /dev/null
+++ b/doc/articles/openshift_and_gitlab/index.md
@@ -0,0 +1,510 @@
+# Getting started with OpenShift Origin 3 and GitLab
+
+> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial ||
+> **Level:** intermediary ||
+> **Author:** [Achilleas Pipinellis](https://gitlab.com/axil) ||
+> **Publication date:** 2016-06-28
+
+## Introduction
+
+[OpenShift Origin][openshift] is an open source container application
+platform created by [RedHat], based on [kubernetes] and [Docker]. That means
+you can host your own PaaS for free and almost with no hassle.
+
+In this tutorial, we will see how to deploy GitLab in OpenShift using GitLab's
+official Docker image while getting familiar with the web interface and CLI
+tools that will help us achieve our goal.
+
+---
+
+## Prerequisites
+
+OpenShift 3 is not yet deployed on RedHat's offered Online platform ([openshift.com]),
+so in order to test it, we will use an [all-in-one Virtualbox image][vm] that is
+offered by the OpenShift developers and managed by Vagrant. If you haven't done
+already, go ahead and install the following components as they are essential to
+test OpenShift easily:
+
+- [VirtualBox]
+- [Vagrant]
+- [OpenShift Client][oc] (`oc` for short)
+
+It is also important to mention that for the purposes of this tutorial, the
+latest Origin release is used:
+
+- **oc** `v1.3.0` (must be [installed][oc-gh] locally on your computer)
+- **openshift** `v1.3.0` (is pre-installed in the [VM image][vm-new])
+- **kubernetes** `v1.3.0` (is pre-installed in the [VM image][vm-new])
+
+>**Note:**
+If you intend to deploy GitLab on a production OpenShift cluster, there are some
+limitations to bare in mind. Read on the [limitations](#current-limitations)
+section for more information and follow the linked links for the relevant
+discussions.
+
+Now that you have all batteries, let's see how easy it is to test OpenShift
+on your computer.
+
+## Getting familiar with OpenShift Origin
+
+The environment we are about to use is based on CentOS 7 which comes with all
+the tools needed pre-installed: Docker, kubernetes, OpenShift, etcd.
+
+### Test OpenShift using Vagrant
+
+As of this writing, the all-in-one VM is at version 1.3, and that's
+what we will use in this tutorial.
+
+In short:
+
+1. Open a terminal and in a new directory run:
+ ```sh
+ vagrant init openshift/origin-all-in-one
+ ```
+1. This will generate a Vagrantfile based on the all-in-one VM image
+1. In the same directory where you generated the Vagrantfile
+ enter:
+
+ ```sh
+ vagrant up
+ ```
+
+This will download the VirtualBox image and fire up the VM with some preconfigured
+values as you can see in the Vagrantfile. As you may have noticed, you need
+plenty of RAM (5GB in our example), so make sure you have enough.
+
+Now that OpenShift is setup, let's see how the web console looks like.
+
+### Explore the OpenShift web console
+
+Once Vagrant finishes its thing with the VM, you will be presented with a
+message which has some important information. One of them is the IP address
+of the deployed OpenShift platform and in particular <https://10.2.2.2:8443/console/>.
+Open this link with your browser and accept the self-signed certificate in
+order to proceed.
+
+Let's login as admin with username/password `admin/admin`. This is what the
+landing page looks like:
+
+![openshift web console](img/web-console.png)
+
+You can see that a number of [projects] are already created for testing purposes.
+
+If you head over the `openshift-infra` project, a number of services with their
+respective pods are there to explore.
+
+![openshift web console](img/openshift-infra-project.png)
+
+We are not going to explore the whole interface, but if you want to learn about
+the key concepts of OpenShift, read the [core concepts reference][core] in the
+official documentation.
+
+### Explore the OpenShift CLI
+
+OpenShift Client (`oc`), is a powerful CLI tool that talks to the OpenShift API
+and performs pretty much everything you can do from the web UI and much more.
+
+Assuming you have [installed][oc] it, let's explore some of its main
+functionalities.
+
+Let's first see the version of `oc`:
+
+```sh
+$ oc version
+
+oc v1.3.0
+kubernetes v1.3.0+52492b4
+```
+
+With `oc help` you can see the top level arguments you can run with `oc` and
+interact with your cluster, kubernetes, run applications, create projects and
+much more.
+
+Let's login to the all-in-one VM and see how to achieve the same results like
+when we visited the web console earlier. The username/password for the
+administrator user is `admin/admin`. There is also a test user with username/
+password `user/user`, with limited access. Let's login as admin for the moment:
+
+```sh
+$ oc login https://10.2.2.2:8443
+
+Authentication required for https://10.2.2.2:8443 (openshift)
+Username: admin
+Password:
+Login successful.
+
+You have access to the following projects and can switch between them with 'oc project <projectname>':
+
+ * cockpit
+ * default (current)
+ * delete
+ * openshift
+ * openshift-infra
+ * sample
+
+Using project "default".
+```
+
+Switch to the `openshift-infra` project with:
+
+```sh
+oc project openshift-infra
+```
+
+And finally, see its status:
+
+```sh
+oc status
+```
+
+The last command should spit a bunch of information about the statuses of the
+pods and the services, which if you look closely is what we encountered in the
+second image when we explored the web console.
+
+You can always read more about `oc` in the [OpenShift CLI documentation][oc].
+
+### Troubleshooting the all-in-one VM
+
+Using the all-in-one VM gives you the ability to test OpenShift whenever you
+want. That means you get to play with it, shutdown the VM, and pick up where
+you left off.
+
+Sometimes though, you may encounter some issues, like OpenShift not running
+when booting up the VM. The web UI may not responding or you may see issues
+when trying to login with `oc`, like:
+
+```
+The connection to the server 10.2.2.2:8443 was refused - did you specify the right host or port?
+```
+
+In that case, the OpenShift service might not be running, so in order to fix it:
+
+1. SSH into the VM by going to the directory where the Vagrantfile is and then
+ run:
+
+ ```sh
+ vagrant ssh
+ ```
+
+1. Run `systemctl` and verify by the output that the `openshift` service is not
+ running (it will be in red color). If that's the case start the service with:
+
+ ```sh
+ sudo systemctl start openshift
+ ```
+
+1. Verify the service is up with:
+
+ ```sh
+ systemctl status openshift -l
+ ```
+
+Now you will be able to login using `oc` (like we did before) and visit the web
+console.
+
+## Deploy GitLab
+
+Now that you got a taste of what OpenShift looks like, let's deploy GitLab!
+
+### Create a new project
+
+First, we will create a new project to host our application. You can do this
+either by running the CLI client:
+
+```bash
+$ oc new-project gitlab
+```
+
+or by using the web interface:
+
+![Create a new project from the UI](img/create-project-ui.png)
+
+If you used the command line, `oc` automatically uses the new project and you
+can see its status with:
+
+```sh
+$ oc status
+
+In project gitlab on server https://10.2.2.2:8443
+
+You have no services, deployment configs, or build configs.
+Run 'oc new-app' to create an application.
+```
+
+If you visit the web console, you can now see `gitlab` listed in the projects list.
+
+The next step is to import the OpenShift template for GitLab.
+
+### Import the template
+
+The [template][templates] is basically a JSON file which describes a set of
+related object definitions to be created together, as well as a set of
+parameters for those objects.
+
+The template for GitLab resides in the Omnibus GitLab repository under the
+docker directory. Let's download it locally with `wget`:
+
+```bash
+wget https://gitlab.com/gitlab-org/omnibus-gitlab/raw/master/docker/openshift-template.json
+```
+
+And then let's import it in OpenShift:
+
+```bash
+oc create -f openshift-template.json -n openshift
+```
+
+>**Note:**
+The `-n openshift` namespace flag is a trick to make the template available to all
+projects. If you recall from when we created the `gitlab` project, `oc` switched
+to it automatically, and that can be verified by the `oc status` command. If
+you omit the namespace flag, the application will be available only to the
+current project, in our case `gitlab`. The `openshift` namespace is a global
+one that the administrators should use if they want the application to be
+available to all users.
+
+We are now ready to finally deploy GitLab!
+
+### Create a new application
+
+The next step is to use the template we previously imported. Head over to the
+`gitlab` project and hit the **Add to Project** button.
+
+![Add to project](img/add-to-project.png)
+
+This will bring you to the catalog where you can find all the pre-defined
+applications ready to deploy with the click of a button. Search for `gitlab`
+and you will see the previously imported template:
+
+![Add GitLab to project](img/add-gitlab-to-project.png)
+
+Select it, and in the following screen you will be presented with the predefined
+values used with the GitLab template:
+
+![GitLab settings](img/gitlab-settings.png)
+
+Notice at the top that there are three resources to be created with this
+template:
+
+- `gitlab-ce`
+- `gitlab-ce-redis`
+- `gitlab-ce-postgresql`
+
+While PostgreSQL and Redis are bundled in Omnibus GitLab, the template is using
+separate images as you can see from [this line][line] in the template.
+
+The predefined values have been calculated for the purposes of testing out
+GitLab in the all-in-one VM. You don't need to change anything here, hit
+**Create** to start the deployment.
+
+If you are deploying to production you will want to change the **GitLab instance
+hostname** and use greater values for the volume sizes. If you don't provide a
+password for PostgreSQL, it will be created automatically.
+
+>**Note:**
+The `gitlab.apps.10.2.2.2.xip.io` hostname that is used by default will
+resolve to the host with IP `10.2.2.2` which is the IP our VM uses. It is a
+trick to have distinct FQDNs pointing to services that are on our local network.
+Read more on how this works in <http://xip.io>.
+
+Now that we configured this, let's see how to manage and scale GitLab.
+
+## Manage and scale GitLab
+
+Setting up GitLab for the first time might take a while depending on your
+internet connection and the resources you have attached to the all-in-one VM.
+GitLab's docker image is quite big (~500MB), so you'll have to wait until
+it's downloaded and configured before you use it.
+
+### Watch while GitLab gets deployed
+
+Navigate to the `gitlab` project at **Overview**. You can notice that the
+deployment is in progress by the orange color. The Docker images are being
+downloaded and soon they will be up and running.
+
+![GitLab overview](img/gitlab-overview.png)
+
+Switch to the **Browse > Pods** and you will eventually see all 3 pods in a
+running status. Remember the 3 resources that were to be created when we first
+created the GitLab app? This is where you can see them in action.
+
+![Running pods](img/running-pods.png)
+
+You can see GitLab being reconfigured by taking look at the logs in realtime.
+Click on `gitlab-ce-2-j7ioe` (your ID will be different) and go to the **Logs**
+tab.
+
+![GitLab logs](img/gitlab-logs.png)
+
+At a point you should see a _**gitlab Reconfigured!**_ message in the logs.
+Navigate back to the **Overview** and hopefully all pods will be up and running.
+
+![GitLab running](img/gitlab-running.png)
+
+Congratulations! You can now navigate to your new shinny GitLab instance by
+visiting <http://gitlab.apps.10.2.2.2.xip.io> where you will be asked to
+change the root user password. Login using `root` as username and providing the
+password you just set, and start using GitLab!
+
+### Scale GitLab with the push of a button
+
+If you reach to a point where your GitLab instance could benefit from a boost
+of resources, you'd be happy to know that you can scale up with the push of a
+button.
+
+In the **Overview** page just click the up arrow button in the pod where
+GitLab is. The change is instant and you can see the number of [replicas] now
+running scaled to 2.
+
+![GitLab scale](img/gitlab-scale.png)
+
+Upping the GitLab pods is actually like adding new application servers to your
+cluster. You can see how that would work if you didn't use GitLab with
+OpenShift by following the [HA documentation][ha] for the application servers.
+
+Bare in mind that you may need more resources (CPU, RAM, disk space) when you
+scale up. If a pod is in pending state for too long, you can navigate to
+**Browse > Events** and see the reason and message of the state.
+
+![No resources](img/no-resources.png)
+
+### Scale GitLab using the `oc` CLI
+
+Using `oc` is super easy to scale up the replicas of a pod. You may want to
+skim through the [basic CLI operations][basic-cli] to get a taste how the CLI
+commands are used. Pay extra attention to the object types as we will use some
+of them and their abbreviated versions below.
+
+In order to scale up, we need to find out the name of the replication controller.
+Let's see how to do that using the following steps.
+
+1. Make sure you are in the `gitlab` project:
+
+ ```sh
+ oc project gitlab
+ ```
+
+1. See what services are used for this project:
+
+ ```sh
+ oc get svc
+ ```
+
+ The output will be similar to:
+
+ ```
+ NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+ gitlab-ce 172.30.243.177 <none> 22/TCP,80/TCP 5d
+ gitlab-ce-postgresql 172.30.116.75 <none> 5432/TCP 5d
+ gitlab-ce-redis 172.30.105.88 <none> 6379/TCP 5d
+ ```
+
+1. We need to see the replication controllers of the `gitlab-ce` service.
+ Get a detailed view of the current ones:
+
+ ```sh
+ oc describe rc gitlab-ce
+ ```
+
+ This will return a large detailed list of the current replication controllers.
+ Search for the name of the GitLab controller, usually `gitlab-ce-1` or if
+ that failed at some point and you spawned another one, it will be named
+ `gitlab-ce-2`.
+
+1. Scale GitLab using the previous information:
+
+ ```sh
+ oc scale --replicas=2 replicationcontrollers gitlab-ce-2
+ ```
+
+1. Get the new replicas number to make sure scaling worked:
+
+ ```sh
+ oc get rc gitlab-ce-2
+ ```
+
+ which will return something like:
+
+ ```
+ NAME DESIRED CURRENT AGE
+ gitlab-ce-2 2 2 5d
+ ```
+
+And that's it! We successfully scaled the replicas to 2 using the CLI.
+
+As always, you can find the name of the controller using the web console. Just
+click on the service you are interested in and you will see the details in the
+right sidebar.
+
+![Replication controller name](img/rc-name.png)
+
+### Autoscaling GitLab
+
+In case you were wondering whether there is an option to autoscale a pod based
+on the resources of your server, the answer is yes, of course there is.
+
+We will not expand on this matter, but feel free to read the documentation on
+OpenShift's website about [autoscaling].
+
+## Current limitations
+
+As stated in the [all-in-one VM][vm] page:
+
+> By default, OpenShift will not allow a container to run as root or even a
+non-random container assigned userid. Most Docker images in the Dockerhub do not
+follow this best practice and instead run as root.
+
+The all-in-one VM we are using has this security turned off so it will not
+bother us. In any case, it is something to keep in mind when deploying GitLab
+on a production cluster.
+
+In order to deploy GitLab on a production cluster, you will need to assign the
+GitLab service account to the `anyuid` Security Context.
+
+1. Edit the Security Context:
+ ```sh
+ oc edit scc anyuid
+ ```
+
+1. Add `system:serviceaccount:<project>:gitlab-ce-user` to the `users` section.
+ If you changed the Application Name from the default the user will
+ will be `<app-name>-user` instead of `gitlab-ce-user`
+
+1. Save and exit the editor
+
+## Conclusion
+
+By now, you should have an understanding of the basic OpenShift Origin concepts
+and a sense of how things work using the web console or the CLI.
+
+GitLab was hard to install in previous versions of OpenShift,
+but now that belongs to the past. Upload a template, create a project, add an
+application and you are done. You are ready to login to your new GitLab instance.
+
+And remember that in this tutorial we just scratched the surface of what Origin
+is capable of. As always, you can refer to the detailed
+[documentation][openshift-docs] to learn more about deploying your own OpenShift
+PaaS and managing your applications with the ease of containers.
+
+[RedHat]: https://www.redhat.com/en "RedHat website"
+[openshift]: https://www.openshift.org "OpenShift Origin website"
+[vm]: https://www.openshift.org/vm/ "OpenShift All-in-one VM"
+[vm-new]: https://atlas.hashicorp.com/openshift/boxes/origin-all-in-one "Official OpenShift Vagrant box on Atlas"
+[template]: https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/docker/openshift-template.json "OpenShift template for GitLab"
+[openshift.com]: https://openshift.com "OpenShift Online"
+[kubernetes]: http://kubernetes.io/ "Kubernetes website"
+[Docker]: https://www.docker.com "Docker website"
+[oc]: https://docs.openshift.org/latest/cli_reference/get_started_cli.html "Documentation - oc CLI documentation"
+[VirtualBox]: https://www.virtualbox.org/wiki/Downloads "VirtualBox downloads"
+[Vagrant]: https://www.vagrantup.com/downloads.html "Vagrant downloads"
+[projects]: https://docs.openshift.org/latest/dev_guide/projects.html "Documentation - Projects overview"
+[core]: https://docs.openshift.org/latest/architecture/core_concepts/index.html "Documentation - Core concepts of OpenShift Origin"
+[templates]: https://docs.openshift.org/latest/architecture/core_concepts/templates.html "Documentation - OpenShift templates"
+[old-post]: https://blog.openshift.com/deploy-gitlab-openshift/ "Old post - Deploy GitLab on OpenShift"
+[line]: https://gitlab.com/gitlab-org/omnibus-gitlab/blob/658c065c8d022ce858dd63eaeeadb0b2ddc8deea/docker/openshift-template.json#L239 "GitLab - OpenShift template"
+[oc-gh]: https://github.com/openshift/origin/releases/tag/v1.3.0 "Openshift 1.3.0 release on GitHub"
+[ha]: http://docs.gitlab.com/ce/administration/high_availability/gitlab.html "Documentation - GitLab High Availability"
+[replicas]: https://docs.openshift.org/latest/architecture/core_concepts/deployments.html#replication-controllers "Documentation - Replication controller"
+[autoscaling]: https://docs.openshift.org/latest/dev_guide/pod_autoscaling.html "Documentation - Autoscale"
+[basic-cli]: https://docs.openshift.org/latest/cli_reference/basic_cli_operations.html "Documentation - Basic CLI operations"
+[openshift-docs]: https://docs.openshift.org "OpenShift documentation"
diff --git a/doc/ci/README.md b/doc/ci/README.md
index c4f9a3cb573..c722d895f42 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -1,6 +1,6 @@
# GitLab Continuous Integration (GitLab CI)
-![CI/CD pipeline graph](img/cicd_pipeline_infograph.png)
+![Pipeline graph](img/cicd_pipeline_infograph.png)
The benefits of Continuous Integration are huge when automation plays an
integral part of your workflow. GitLab comes with built-in Continuous
@@ -23,6 +23,7 @@ The first steps towards your GitLab CI journey.
- [Setting up GitLab Runner For Continuous Integration](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/)
- [GitLab CI: Deployment & environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/)
- **Videos:**
+ - [Demo (Streamed live on Jul 17, 2017): GitLab CI/CD Deep Dive](https://youtu.be/pBe4t1CD8Fc?t=195)
- [Demo (March, 2017): how to get started using CI/CD with GitLab](https://about.gitlab.com/2017/03/13/ci-cd-demo/)
- [Webcast (April, 2016): getting started with CI in GitLab](https://about.gitlab.com/2016/04/20/webcast-recording-and-slides-introduction-to-ci-in-gitlab/)
- **Third-party videos:**
@@ -66,7 +67,8 @@ learn how to leverage its potential even more.
submodules are involved
- [Auto deploy](autodeploy/index.md)
- [Use SSH keys in your build environment](ssh_keys/README.md)
-- [Trigger jobs through the GitLab API](triggers/README.md)
+- [Trigger pipelines through the GitLab API](triggers/README.md)
+- [Trigger pipelines on a schedule](../user/project/pipelines/schedules.md)
## Review Apps
@@ -85,7 +87,7 @@ You can change the default behavior of GitLab CI in your whole GitLab instance
as well as in each project.
- **Project specific**
- - [CI/CD pipelines settings](../user/project/pipelines/settings.md)
+ - [Pipelines settings](../user/project/pipelines/settings.md)
- [Learn how to enable or disable GitLab CI](enable_or_disable_ci.md)
- **Affecting the whole GitLab instance**
- [Continuous Integration admin settings](../user/admin_area/settings/continuous_integration.md)
@@ -108,7 +110,9 @@ Here is an collection of tutorials and guides on setting up your CI pipeline.
- [Scala](examples/test-scala-application.md)
- [Phoenix](examples/test-phoenix-application.md)
- [Run PHP Composer & NPM scripts then deploy them to a staging server](examples/deployment/composer-npm-deploy.md)
-- **Blog posts**
+ - [Analyze code quality with the Code Climate CLI](examples/code_climate.md)
+- **Articles**
+ - [How to deploy Maven projects to Artifactory with GitLab CI/CD](../articles/artifactory_and_gitlab/index.md)
- [Automated Debian packaging](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
- [Spring boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/)
- [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
diff --git a/doc/ci/api/README.md b/doc/ci/api/README.md
index 4ca8d92d7cc..98f37935427 100644
--- a/doc/ci/api/README.md
+++ b/doc/ci/api/README.md
@@ -1,3 +1 @@
-# GitLab CI API
-
This document was moved to a [new location](../../api/ci/README.md).
diff --git a/doc/ci/api/builds.md b/doc/ci/api/builds.md
index f5bd3181c02..0563a367609 100644
--- a/doc/ci/api/builds.md
+++ b/doc/ci/api/builds.md
@@ -1,3 +1 @@
-# Builds API
-
This document was moved to a [new location](../../api/ci/builds.md).
diff --git a/doc/ci/api/runners.md b/doc/ci/api/runners.md
index b14ea99db76..1027363851c 100644
--- a/doc/ci/api/runners.md
+++ b/doc/ci/api/runners.md
@@ -1,3 +1 @@
-# Runners API
-
This document was moved to a [new location](../../api/ci/runners.md).
diff --git a/doc/ci/autodeploy/img/auto_monitoring.png b/doc/ci/autodeploy/img/auto_monitoring.png
new file mode 100644
index 00000000000..5661b50841b
--- /dev/null
+++ b/doc/ci/autodeploy/img/auto_monitoring.png
Binary files differ
diff --git a/doc/ci/autodeploy/index.md b/doc/ci/autodeploy/index.md
index 9fa2b2c4969..a714689ebd5 100644
--- a/doc/ci/autodeploy/index.md
+++ b/doc/ci/autodeploy/index.md
@@ -69,3 +69,28 @@ PostgreSQL provisioning can be disabled by setting the variable `DISABLE_POSTGRE
[review-app]: ../review_apps/index.md
[container-registry]: https://docs.gitlab.com/ce/user/project/container_registry.html
[postgresql]: https://www.postgresql.org/
+
+## Auto Monitoring
+
+> Introduced in [GitLab 9.5](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13438).
+
+Apps auto-deployed using one the [Kubernetes templates](#supported-templates) can also be automatically monitored for:
+
+* Response Metrics: latency, throughput, error rate
+* System Metrics: CPU utilization, memory utilization
+
+Metrics are gathered from [nginx-ingress](../../user/project/integrations/prometheus_library/nginx_ingress.md) and [Kubernetes](../../user/project/integrations/prometheus_library/kubernetes.md).
+
+To view the metrics, open the [Monitoring dashboard for a deployed environment](../environments.md#monitoring-environments).
+
+![Auto Metrics](img/auto_monitoring.png)
+
+### Configuring Auto Monitoring
+
+If GitLab has been deployed using the [omnibus-gitlab](../../install/kubernetes/gitlab_omnibus.md) Helm chart, no configuration is required.
+
+If you have installed GitLab using a different method:
+
+1. [Deploy Prometheus](../../user/project/integrations/prometheus.md#configuring-your-own-prometheus-server-within-kubernetes) into your Kubernetes cluster
+1. If you would like response metrics, ensure you are running at least version 0.9.0 of NGINX Ingress and [enable Prometheus metrics](https://github.com/kubernetes/ingress/blob/master/examples/customization/custom-vts-metrics/nginx/nginx-vts-metrics-conf.yaml).
+1. Finally, [annotate](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) the NGINX Ingress deployment to be scraped by Prometheus using `prometheus.io/scrape: "true"` and `prometheus.io/port: "10254"`.
diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md
index ffa0831290a..f7c2a0ef0ca 100644
--- a/doc/ci/docker/using_docker_build.md
+++ b/doc/ci/docker/using_docker_build.md
@@ -37,7 +37,7 @@ GitLab Runner then executes job scripts as the `gitlab-runner` user.
```bash
sudo gitlab-ci-multi-runner register -n \
- --url https://gitlab.com/ci \
+ --url https://gitlab.com/ \
--registration-token REGISTRATION_TOKEN \
--executor shell \
--description "My Runner"
@@ -94,7 +94,7 @@ In order to do that, follow the steps:
```bash
sudo gitlab-ci-multi-runner register -n \
- --url https://gitlab.com/ci \
+ --url https://gitlab.com/ \
--registration-token REGISTRATION_TOKEN \
--executor docker \
--description "My Docker Runner" \
@@ -112,7 +112,7 @@ In order to do that, follow the steps:
```
[[runners]]
- url = "https://gitlab.com/ci"
+ url = "https://gitlab.com/"
token = TOKEN
executor = "docker"
[runners.docker]
@@ -179,7 +179,7 @@ In order to do that, follow the steps:
```bash
sudo gitlab-ci-multi-runner register -n \
- --url https://gitlab.com/ci \
+ --url https://gitlab.com/ \
--registration-token REGISTRATION_TOKEN \
--executor docker \
--description "My Docker Runner" \
@@ -197,7 +197,7 @@ In order to do that, follow the steps:
```
[[runners]]
- url = "https://gitlab.com/ci"
+ url = "https://gitlab.com/"
token = REGISTRATION_TOKEN
executor = "docker"
[runners.docker]
@@ -282,9 +282,9 @@ which can be avoided if a different driver is used, for example `overlay`.
> **Notes:**
- This feature requires GitLab 8.8 and GitLab Runner 1.2.
-- Starting from GitLab 8.12, if you have 2FA enabled in your account, you need
- to pass a personal access token instead of your password in order to login to
- GitLab's Container Registry.
+- Starting from GitLab 8.12, if you have [2FA] enabled in your account, you need
+ to pass a [personal access token][pat] instead of your password in order to
+ login to GitLab's Container Registry.
Once you've built a Docker image, you can push it up to the built-in
[GitLab Container Registry](../../user/project/container_registry.md). For example,
@@ -409,3 +409,5 @@ Some things you should be aware of when using the Container Registry:
[docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/
[docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
+[2fa]: ../../user/profile/account/two_factor_authentication.md
+[pat]: ../../user/profile/personal_access_tokens.md
diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md
index f025a7e3496..dc5313c5597 100644
--- a/doc/ci/docker/using_docker_images.md
+++ b/doc/ci/docker/using_docker_images.md
@@ -1,4 +1,4 @@
-# Using Docker Images
+# Using Docker images
GitLab CI in conjunction with [GitLab Runner](../runners/README.md) can use
[Docker Engine](https://www.docker.com/) to test and build any application.
@@ -17,14 +17,16 @@ can also run on your workstation. The added benefit is that you can test all
the commands that we will explore later from your shell, rather than having to
test them on a dedicated CI server.
-## Register docker runner
+## Register Docker Runner
-To use GitLab Runner with docker you need to register a new runner to use the
-`docker` executor:
+To use GitLab Runner with Docker you need to [register a new Runner][register]
+to use the `docker` executor.
+
+A one-line example can be seen below:
```bash
-gitlab-ci-multi-runner register \
- --url "https://gitlab.com/" \
+sudo gitlab-runner register \
+ --url "https://gitlab.example.com/" \
--registration-token "PROJECT_REGISTRATION_TOKEN" \
--description "docker-ruby-2.1" \
--executor "docker" \
@@ -33,26 +35,26 @@ gitlab-ci-multi-runner register \
--docker-mysql latest
```
-The registered runner will use the `ruby:2.1` docker image and will run two
+The registered runner will use the `ruby:2.1` Docker image and will run two
services, `postgres:latest` and `mysql:latest`, both of which will be
accessible during the build process.
## What is an image
-The `image` keyword is the name of the docker image the docker executor
-will run to perform the CI tasks.
+The `image` keyword is the name of the Docker image the Docker executor
+will run to perform the CI tasks.
-By default the executor will only pull images from [Docker Hub][hub],
+By default, the executor will only pull images from [Docker Hub][hub],
but this can be configured in the `gitlab-runner/config.toml` by setting
-the [docker pull policy][] to allow using local images.
+the [Docker pull policy][] to allow using local images.
For more information about images and Docker Hub please read
the [Docker Fundamentals][] documentation.
## What is a service
-The `services` keyword defines just another docker image that is run during
-your job and is linked to the docker image that the `image` keyword defines.
+The `services` keyword defines just another Docker image that is run during
+your job and is linked to the Docker image that the `image` keyword defines.
This allows you to access the service image during build time.
The service image can run any application, but the most common use case is to
@@ -60,6 +62,11 @@ run a database container, eg. `mysql`. It's easier and faster to use an
existing image and run it as an additional container than install `mysql` every
time the project is built.
+You are not limited to have only database services. You can add as many
+services you need to `.gitlab-ci.yml` or manually modify `config.toml`.
+Any image found at [Docker Hub][hub] or your private Container Registry can be
+used as a service.
+
You can see some widely used services examples in the relevant documentation of
[CI services examples](../services/README.md).
@@ -73,22 +80,49 @@ then be used to create a container that is linked to the job container.
The service container for MySQL will be accessible under the hostname `mysql`.
So, in order to access your database service you have to connect to the host
-named `mysql` instead of a socket or `localhost`.
+named `mysql` instead of a socket or `localhost`. Read more in [accessing the
+services](#accessing-the-services).
-## Overwrite image and services
+### Accessing the services
-See [How to use other images as services](#how-to-use-other-images-as-services).
+Let's say that you need a Wordpress instance to test some API integration with
+your application.
-## How to use other images as services
+You can then use for example the [tutum/wordpress][] image in your
+`.gitlab-ci.yml`:
-You are not limited to have only database services. You can add as many
-services you need to `.gitlab-ci.yml` or manually modify `config.toml`.
-Any image found at [Docker Hub][hub] can be used as a service.
+```yaml
+services:
+- tutum/wordpress:latest
+```
-## Define image and services from `.gitlab-ci.yml`
+If you don't [specify a service alias](#available-settings-for-services-entry),
+when the job is run, `tutum/wordpress` will be started and you will have
+access to it from your build container under two hostnames to choose from:
+
+- `tutum-wordpress`
+- `tutum__wordpress`
+
+>**Note:**
+Hostnames with underscores are not RFC valid and may cause problems in 3rd party
+applications.
+
+The default aliases for the service's hostname are created from its image name
+following these rules:
+
+- Everything after the colon (`:`) is stripped
+- Slash (`/`) is replaced with double underscores (`__`) and the primary alias
+ is created
+- Slash (`/`) is replaced with a single dash (`-`) and the secondary alias is
+ created (requires GitLab Runner v1.1.0 or higher)
+
+To override the default behavior, you can
+[specify a service alias](#available-settings-for-services).
+
+## Define `image` and `services` from `.gitlab-ci.yml`
You can simply define an image that will be used for all jobs and a list of
-services that you want to use during build time.
+services that you want to use during build time:
```yaml
image: ruby:2.2
@@ -125,6 +159,209 @@ test:2.2:
- bundle exec rake spec
```
+Or you can pass some [extended configuration options](#extended-docker-configuration-options)
+for `image` and `services`:
+
+```yaml
+image:
+ name: ruby:2.2
+ entrypoint: ["/bin/bash"]
+
+services:
+- name: my-postgres:9.4
+ alias: db-postgres
+ entrypoint: ["/usr/local/bin/db-postgres"]
+ command: ["start"]
+
+before_script:
+- bundle install
+
+test:
+ script:
+ - bundle exec rake spec
+```
+
+## Extended Docker configuration options
+
+> Introduced in GitLab and GitLab Runner 9.4.
+
+When configuring the `image` or `services` entries, you can use a string or a map as
+options:
+
+- when using a string as an option, it must be the full name of the image to use
+ (including the Registry part if you want to download the image from a Registry
+ other than Docker Hub)
+- when using a map as an option, then it must contain at least the `name`
+ option, which is the same name of the image as used for the string setting
+
+For example, the following two definitions are equal:
+
+1. Using a string as an option to `image` and `services`:
+
+ ```yaml
+ image: "registry.example.com/my/image:latest"
+
+ services:
+ - postgresql:9.4
+ - redis:latest
+ ```
+
+1. Using a map as an option to `image` and `services`. The use of `image:name` is
+ required:
+
+ ```yaml
+ image:
+ name: "registry.example.com/my/image:latest"
+
+ services:
+ - name: postgresql:9.4
+ - name: redis:latest
+ ```
+
+### Available settings for `image`
+
+> Introduced in GitLab and GitLab Runner 9.4.
+
+| Setting | Required | GitLab version | Description |
+|------------|----------|----------------| ----------- |
+| `name` | yes, when used with any other option | 9.4 |Full name of the image that should be used. It should contain the Registry part if needed. |
+| `entrypoint` | no | 9.4 |Command or script that should be executed as the container's entrypoint. It will be translated to Docker's `--entrypoint` option while creating the container. The syntax is similar to [`Dockerfile`'s `ENTRYPOINT`][entrypoint] directive, where each shell token is a separate string in the array. |
+
+### Available settings for `services`
+
+> Introduced in GitLab and GitLab Runner 9.4.
+
+| Setting | Required | GitLab version | Description |
+|------------|----------|----------------| ----------- |
+| `name` | yes, when used with any other option | 9.4 | Full name of the image that should be used. It should contain the Registry part if needed. |
+| `entrypoint` | no | 9.4 |Command or script that should be executed as the container's entrypoint. It will be translated to Docker's `--entrypoint` option while creating the container. The syntax is similar to [`Dockerfile`'s `ENTRYPOINT`][entrypoint] directive, where each shell token is a separate string in the array. |
+| `command` | no | 9.4 |Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to [`Dockerfile`'s `CMD`][cmd] directive, where each shell token is a separate string in the array. |
+| `alias` | no | 9.4 |Additional alias that can be used to access the service from the job's container. Read [Accessing the services](#accessing-the-services) for more information. |
+
+### Starting multiple services from the same image
+
+> Introduced in GitLab and GitLab Runner 9.4. Read more about the [extended
+configuration options](#extended-docker-configuration-options).
+
+Before the new extended Docker configuration options, the following configuration
+would not work properly:
+
+```yaml
+services:
+- mysql:latest
+- mysql:latest
+```
+
+The Runner would start two containers using the `mysql:latest` image, but both
+of them would be added to the job's container with the `mysql` alias based on
+the [default hostname naming](#accessing-the-services). This would end with one
+of the services not being accessible.
+
+After the new extended Docker configuration options, the above example would
+look like:
+
+```yaml
+services:
+- name: mysql:latest
+ alias: mysql-1
+- name: mysql:latest
+ alias: mysql-2
+```
+
+The Runner will still start two containers using the `mysql:latest` image,
+but now each of them will also be accessible with the alias configured
+in `.gitlab-ci.yml` file.
+
+### Setting a command for the service
+
+> Introduced in GitLab and GitLab Runner 9.4. Read more about the [extended
+configuration options](#extended-docker-configuration-options).
+
+Let's assume you have a `super/sql:latest` image with some SQL database
+inside it and you would like to use it as a service for your job. Let's also
+assume that this image doesn't start the database process while starting
+the container and the user needs to manually use `/usr/bin/super-sql run` as
+a command to start the database.
+
+Before the new extended Docker configuration options, you would need to create
+your own image based on the `super/sql:latest` image, add the default command,
+and then use it in job's configuration, like:
+
+```Dockerfile
+# my-super-sql:latest image's Dockerfile
+
+FROM super/sql:latest
+CMD ["/usr/bin/super-sql", "run"]
+```
+
+```yaml
+# .gitlab-ci.yml
+
+services:
+- my-super-sql:latest
+```
+
+After the new extended Docker configuration options, you can now simply
+set a `command` in `.gitlab-ci.yml`, like:
+
+```yaml
+# .gitlab-ci.yml
+
+services:
+- name: super/sql:latest
+ command: ["/usr/bin/super-sql", "run"]
+```
+
+As you can see, the syntax of `command` is similar to [Dockerfile's `CMD`][cmd].
+
+### Overriding the entrypoint of an image
+
+> Introduced in GitLab and GitLab Runner 9.4. Read more about the [extended
+configuration options](#extended-docker-configuration-options).
+
+Let's assume you have a `super/sql:experimental` image with some SQL database
+inside it and you would like to use it as a base image for your job because you
+want to execute some tests with this database binary. Let's also assume that
+this image is configured with `/usr/bin/super-sql run` as an entrypoint. That
+means, that when starting the container without additional options, it will run
+the database's process, while Runner expects that the image will have no
+entrypoint or at least will start with a shell as its entrypoint.
+
+Previously we would need to create our own image based on the
+`super/sql:experimental` image, set the entrypoint to a shell, and then use
+it in job's configuration, e.g.:
+
+Before the new extended Docker configuration options, you would need to create
+your own image based on the `super/sql:experimental` image, set the entrypoint
+to a shell and then use it in job's configuration, like:
+
+```Dockerfile
+# my-super-sql:experimental image's Dockerfile
+
+FROM super/sql:experimental
+ENTRYPOINT ["/bin/sh"]
+```
+
+```yaml
+# .gitlab-ci.yml
+
+image: my-super-sql:experimental
+```
+
+After the new extended Docker configuration options, you can now simply
+set an `entrypoint` in `.gitlab-ci.yml`, like:
+
+```yaml
+# .gitlab-ci.yml
+
+image:
+ name: super/sql:experimental
+ entrypoint: ["/bin/sh"]
+```
+
+As you can see the syntax of `entrypoint` is similar to
+[Dockerfile's `ENTRYPOINT`][entrypoint].
+
## Define image and services in `config.toml`
Look for the `[runners.docker]` section:
@@ -138,49 +375,60 @@ Look for the `[runners.docker]` section:
The image and services defined this way will be added to all job run by
that runner.
-## Define an image from a private Docker registry
+## Define an image from a private Container Registry
-Starting with GitLab Runner 0.6.0, you are able to define images located to
-private registries that could also require authentication.
+> **Notes:**
+- This feature requires GitLab Runner **1.8** or higher
+- For GitLab Runner versions **>= 0.6, <1.8** there was a partial
+ support for using private registries, which required manual configuration
+ of credentials on runner's host. We recommend to upgrade your Runner to
+ at least version **1.8** if you want to use private registries.
+- If the repository is private you need to authenticate your GitLab Runner in the
+ registry. Learn more about how [GitLab Runner works in this case][runner-priv-reg].
-All you have to do is be explicit on the image definition in `.gitlab-ci.yml`.
+As an example, let's assume that you want to use the `registry.example.com/private/image:latest`
+image which is private and requires you to login into a private container registry.
+To configure access for `registry.example.com`, follow these steps:
-```yaml
-image: my.registry.tld:5000/namepace/image:tag
-```
+1. Do a `docker login` on your computer:
-In the example above, GitLab Runner will look at `my.registry.tld:5000` for the
-image `namespace/image:tag`.
+ ```bash
+ docker login registry.example.com --username my_username --password my_password
+ ```
-If the repository is private you need to authenticate your GitLab Runner in the
-registry. Learn how to do that on
-[GitLab Runner's documentation][runner-priv-reg].
+1. Copy the content of `~/.docker/config.json`
+1. Create a [secret variable] `DOCKER_AUTH_CONFIG` with the content of the
+ Docker configuration file as the value:
-## Accessing the services
+ ```json
+ {
+ "auths": {
+ "registry.example.com": {
+ "auth": "bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQ="
+ }
+ }
+ }
+ ```
-Let's say that you need a Wordpress instance to test some API integration with
-your application.
+1. Do a `docker logout` on your computer if you don't need access to the
+ registry from it:
-You can then use for example the [tutum/wordpress][] image in your
-`.gitlab-ci.yml`:
-
-```yaml
-services:
-- tutum/wordpress:latest
-```
+ ```bash
+ docker logout registry.example.com
+ ```
-When the job is run, `tutum/wordpress` will be started and you will have
-access to it from your build container under the hostnames `tutum-wordpress`
-(requires GitLab Runner v1.1.0 or newer) and `tutum__wordpress`.
+1. You can now use any private image from `registry.example.com` defined in
+ `image` and/or `services` in your [`.gitlab-ci.yml` file][yaml-priv-reg]:
-*Note: hostname with underscores is not RFC valid and may cause problems in 3rd party applications.*
+ ```yaml
+ image: my.registry.tld:5000/namespace/image:tag
+ ```
-The alias hostnames for the service are made from the image name following these
-rules:
+ In the example above, GitLab Runner will look at `my.registry.tld:5000` for the
+ image `namespace/image:tag`.
-1. Everything after `:` is stripped
-2. Slash (`/`) is replaced with double underscores (`__`) - primary alias
-3. Slash (`/`) is replaced with dash (`-`) - secondary alias, requires GitLab Runner v1.1.0 or newer
+You can add configuration for as many registries as you want, adding more
+registries to the `"auths"` hash as described above.
## Configuring services
@@ -208,7 +456,7 @@ See the specific documentation for
## How Docker integration works
-Below is a high level overview of the steps performed by docker during job
+Below is a high level overview of the steps performed by Docker during job
time.
1. Create any service container: `mysql`, `postgresql`, `mongodb`, `redis`.
@@ -225,7 +473,7 @@ time.
## How to debug a job locally
*Note: The following commands are run without root privileges. You should be
-able to run docker with your regular user account.*
+able to run Docker with your regular user account.*
First start with creating a file named `build_script`:
@@ -283,4 +531,8 @@ creation.
[tutum/wordpress]: https://hub.docker.com/r/tutum/wordpress/
[postgres-hub]: https://hub.docker.com/r/_/postgres/
[mysql-hub]: https://hub.docker.com/r/_/mysql/
-[runner-priv-reg]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry
+[runner-priv-reg]: http://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry
+[secret variable]: ../variables/README.md#secret-variables
+[entrypoint]: https://docs.docker.com/engine/reference/builder/#entrypoint
+[cmd]: https://docs.docker.com/engine/reference/builder/#cmd
+[register]: https://docs.gitlab.com/runner/register/
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index b28f3e13eae..28b27921f8b 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -94,6 +94,12 @@ the name given in `.gitlab-ci.yml` (with any variables expanded), while the
second is a "cleaned-up" version of the name, suitable for use in URLs, DNS,
etc.
+>**Note:**
+Starting with GitLab 9.3, the environment URL is exposed to the Runner via
+`$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if
+the URL was not defined there, the external URL from the environment would be
+used.
+
To sum up, with the above `.gitlab-ci.yml` we have achieved that:
- All branches will run the `test` and `build` jobs.
@@ -442,7 +448,8 @@ and/or `production`) you can see this information in the merge request itself.
![Environment URLs in merge request](img/environments_link_url_mr.png)
-### Go directly from source files to public pages on the environment
+### <a name="route-map"></a>Go directly from source files to public pages on the environment
+
> Introduced in GitLab 8.17.
@@ -590,6 +597,35 @@ exist, you should see something like:
![Environment groups](img/environments_dynamic_groups.png)
+## Monitoring environments
+
+>**Notes:**
+>
+- For the monitor dashboard to appear, you need to:
+ - Have enabled the [Prometheus integration][prom]
+ - Configured Prometheus to collect at least one [supported metric](../user/project/integrations/prometheus_library/metrics.md)
+- With GitLab 9.2, all deployments to an environment are shown directly on the
+ monitoring dashboard
+
+If you have enabled [Prometheus for monitoring system and response metrics](https://docs.gitlab.com/ee/user/project/integrations/prometheus.html), you can monitor the performance behavior of your app running in each environment.
+
+Once configured, GitLab will attempt to retrieve [supported performance metrics](https://docs.gitlab.com/ee/user/project/integrations/prometheus_library/metrics.html) for any
+environment which has had a successful deployment. If monitoring data was
+successfully retrieved, a Monitoring button will appear on the environment's
+detail page.
+
+![Environment Detail with Metrics](img/prometheus_environment_detail_with_metrics.png)
+
+Clicking on the Monitoring button will display a new page, showing up to the last
+8 hours of performance data. It may take a minute or two for data to appear
+after initial deployment.
+
+All deployments to an environment are shown directly on the monitoring dashboard
+which allows easy correlation between any changes in performance and a new
+version of the app, all without leaving GitLab.
+
+![Monitoring dashboard](img/environments_monitoring.png)
+
## Checkout deployments locally
Since 8.13, a reference in the git repository is saved for each deployment, so
@@ -631,3 +667,5 @@ Below are some links you may find interesting:
[gitlab-flow]: ../workflow/gitlab_flow.md
[gitlab runner]: https://docs.gitlab.com/runner/
[git-strategy]: yaml/README.md#git-strategy
+[kube]: ../user/project/integrations/kubernetes.md
+[prom]: ../user/project/integrations/prometheus.md
diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md
index 33c27b39a8a..2458cb959ab 100644
--- a/doc/ci/examples/README.md
+++ b/doc/ci/examples/README.md
@@ -55,6 +55,7 @@ Apart from those, here is an collection of tutorials and guides on setting up yo
- [Using `dpl` as deployment tool](deployment/README.md)
- [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples)
- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
+- [Analyze code quality with the Code Climate CLI](code_climate.md)
- **Articles:**
- [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md
new file mode 100644
index 00000000000..5659a8c2a2a
--- /dev/null
+++ b/doc/ci/examples/code_climate.md
@@ -0,0 +1,34 @@
+# Analyze project code quality with Code Climate CLI
+
+This example shows how to run [Code Climate CLI][cli] on your code by using
+GitLab CI and Docker.
+
+First, you need GitLab Runner with [docker-in-docker executor][dind].
+
+Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`:
+
+```yaml
+codeclimate:
+ image: docker:latest
+ variables:
+ DOCKER_DRIVER: overlay
+ services:
+ - docker:dind
+ script:
+ - docker pull codeclimate/codeclimate
+ - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate init
+ - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > codeclimate.json
+ artifacts:
+ paths: [codeclimate.json]
+```
+
+This will create a `codeclimate` job in your CI pipeline and will allow you to
+download and analyze the report artifact in JSON format.
+
+For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically
+extracted and shown right in the merge request widget. [Learn more on code quality
+diffs in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html).
+
+[cli]: https://github.com/codeclimate/codeclimate
+[dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor
+[ee]: https://about.gitlab.com/gitlab-ee/
diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md
index 7b0995597c4..e80e246c5dd 100644
--- a/doc/ci/examples/deployment/README.md
+++ b/doc/ci/examples/deployment/README.md
@@ -111,7 +111,7 @@ We also use two secure variables:
## Storing API keys
Secure Variables can added by going to your project's
-**Settings âž” CI/CD Pipelines âž” Secret variables**. The variables that are defined
+**Settings âž” Pipelines âž” Secret variables**. The variables that are defined
in the project settings are sent along with the build script to the Runner.
The secure variables are stored out of the repository. Never store secrets in
your project's `.gitlab-ci.yml`. It is also important that the secret's value
diff --git a/doc/ci/examples/deployment/composer-npm-deploy.md b/doc/ci/examples/deployment/composer-npm-deploy.md
index 8b0d8a003fd..b9f0485290e 100644
--- a/doc/ci/examples/deployment/composer-npm-deploy.md
+++ b/doc/ci/examples/deployment/composer-npm-deploy.md
@@ -20,12 +20,12 @@ before_script:
- php -r "unlink('composer-setup.php');"
```
-This will make sure we have all requirements ready. Next, we want to run `composer update` to fetch all PHP dependencies and `npm install` to load node packages, then run the `npm` script. We need to append them into `before_script` section:
+This will make sure we have all requirements ready. Next, we want to run `composer install` to fetch all PHP dependencies and `npm install` to load node packages, then run the `npm` script. We need to append them into `before_script` section:
```yaml
before_script:
# ...
- - php composer.phar update
+ - php composer.phar install
- npm install
- npm run deploy
```
@@ -133,7 +133,7 @@ before_script:
- php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
- php composer-setup.php
- php -r "unlink('composer-setup.php');"
- - php composer.phar update
+ - php composer.phar install
- npm install
- npm run deploy
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
diff --git a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md
index e4d3970deac..73aebaf6d7f 100644
--- a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md
+++ b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md
@@ -55,11 +55,11 @@ You can do this through the [Dashboard](https://dashboard.heroku.com/).
### Create runner
First install [Docker Engine](https://docs.docker.com/installation/).
To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner).
-You can use public runners available on `gitlab.com/ci`, but you can register your own:
+You can use public runners available on `gitlab.com`, but you can register your own:
```
gitlab-ci-multi-runner register \
--non-interactive \
- --url "https://gitlab.com/ci/" \
+ --url "https://gitlab.com/" \
--registration-token "PROJECT_REGISTRATION_TOKEN" \
--description "python-3.5" \
--executor "docker" \
diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md
index 42f15a27f12..6fa64a67e82 100644
--- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md
+++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md
@@ -50,11 +50,11 @@ You can do this through the [Dashboard](https://dashboard.heroku.com/).
### Create runner
First install [Docker Engine](https://docs.docker.com/installation/).
To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner).
-You can use public runners available on `gitlab.com/ci`, but you can register your own:
+You can use public runners available on `gitlab.com`, but you can register your own:
```
gitlab-ci-multi-runner register \
--non-interactive \
- --url "https://gitlab.com/ci/" \
+ --url "https://gitlab.com/" \
--registration-token "PROJECT_REGISTRATION_TOKEN" \
--description "ruby-2.2" \
--executor "docker" \
diff --git a/doc/ci/examples/test-scala-application.md b/doc/ci/examples/test-scala-application.md
index 01c13941c21..09d83c33f95 100644
--- a/doc/ci/examples/test-scala-application.md
+++ b/doc/ci/examples/test-scala-application.md
@@ -54,7 +54,7 @@ You can use other versions of Scala and SBT by defining them in
## Display test coverage in job
Add the `Coverage was \[\d+.\d+\%\]` regular expression in the
-**Settings âž” CI/CD Pipelines âž” Coverage report** project setting to
+**Settings âž” Pipelines âž” Coverage report** project setting to
retrieve the [test coverage] rate from the build trace and have it
displayed with your jobs.
diff --git a/doc/ci/img/environments_monitoring.png b/doc/ci/img/environments_monitoring.png
new file mode 100644
index 00000000000..d9c46ea4c95
--- /dev/null
+++ b/doc/ci/img/environments_monitoring.png
Binary files differ
diff --git a/doc/ci/img/pipelines_grouped.png b/doc/ci/img/pipelines_grouped.png
new file mode 100644
index 00000000000..06f52e03320
--- /dev/null
+++ b/doc/ci/img/pipelines_grouped.png
Binary files differ
diff --git a/doc/ci/img/pipelines_index.png b/doc/ci/img/pipelines_index.png
new file mode 100644
index 00000000000..3b522a9c5e4
--- /dev/null
+++ b/doc/ci/img/pipelines_index.png
Binary files differ
diff --git a/doc/ci/img/pipelines_mini_graph.png b/doc/ci/img/pipelines_mini_graph.png
new file mode 100644
index 00000000000..042c8ffeef5
--- /dev/null
+++ b/doc/ci/img/pipelines_mini_graph.png
Binary files differ
diff --git a/doc/ci/img/pipelines_mini_graph_simple.png b/doc/ci/img/pipelines_mini_graph_simple.png
new file mode 100644
index 00000000000..eb36c09b2d4
--- /dev/null
+++ b/doc/ci/img/pipelines_mini_graph_simple.png
Binary files differ
diff --git a/doc/ci/img/pipelines_mini_graph_sorting.png b/doc/ci/img/pipelines_mini_graph_sorting.png
new file mode 100644
index 00000000000..3a4e5453360
--- /dev/null
+++ b/doc/ci/img/pipelines_mini_graph_sorting.png
Binary files differ
diff --git a/doc/user/project/integrations/img/prometheus_environment_detail_with_metrics.png b/doc/ci/img/prometheus_environment_detail_with_metrics.png
index 214b10624a9..214b10624a9 100644
--- a/doc/user/project/integrations/img/prometheus_environment_detail_with_metrics.png
+++ b/doc/ci/img/prometheus_environment_detail_with_metrics.png
Binary files differ
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index db92a4b0d80..5a2b61fb0cb 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -1,7 +1,6 @@
# Introduction to pipelines and jobs
->**Note:**
-Introduced in GitLab 8.8.
+> Introduced in GitLab 8.8.
## Pipelines
@@ -9,11 +8,17 @@ A pipeline is a group of [jobs][] that get executed in [stages][](batches).
All of the jobs in a stage are executed in parallel (if there are enough
concurrent [Runners]), and if they all succeed, the pipeline moves on to the
next stage. If one of the jobs fails, the next stage is not (usually)
-executed.
+executed. You can access the pipelines page in your project's **Pipelines** tab.
+
+In the following image you can see that the pipeline consists of four stages
+(`build`, `test`, `staging`, `production`) each one having one or more jobs.
+
+>**Note:**
+GitLab capitalizes the stages' names when shown in the [pipeline graphs](#pipeline-graphs).
![Pipelines example](img/pipelines.png)
-## Types of Pipelines
+## Types of pipelines
There are three types of pipelines that often use the single shorthand of "pipeline". People often talk about them as if each one is "the" pipeline, but really, they're just pieces of a single, comprehensive pipeline.
@@ -23,7 +28,7 @@ There are three types of pipelines that often use the single shorthand of "pipel
2. **Deploy Pipeline**: Deploy stage(s) defined in `.gitlab-ci.yml` The flow of deploying code to servers through various stages: e.g. development to staging to production
3. **Project Pipeline**: Cross-project CI dependencies [triggered via API][triggers], particularly for micro-services, but also for complicated build dependencies: e.g. api -> front-end, ce/ee -> omnibus.
-## Development Workflows
+## Development workflows
Pipelines accommodate several development workflows:
@@ -45,18 +50,141 @@ confused with a `build` job or `build` stage.
Pipelines are defined in `.gitlab-ci.yml` by specifying [jobs] that run in
[stages].
-See full [documentation](yaml/README.md#jobs).
+See the reference [documentation for jobs](yaml/README.md#jobs).
## Seeing pipeline status
-You can find the current and historical pipeline runs under **Pipelines** for
-your project.
+You can find the current and historical pipeline runs under your project's
+**Pipelines** tab. Clicking on a pipeline will show the jobs that were run for
+that pipeline.
+
+![Pipelines index page](img/pipelines_index.png)
## Seeing job status
-Clicking on a pipeline will show the jobs that were run for that pipeline.
+When you visit a single pipeline you can see the related jobs for that pipeline.
Clicking on an individual job will show you its job trace, and allow you to
-cancel the job, retry it, or erase the job trace.
+cancel the job, retry it, or erase the job trace.
+
+![Pipelines example](img/pipelines.png)
+
+## Pipeline graphs
+
+> [Introduced][ce-5742] in GitLab 8.11.
+
+Pipelines can be complex structures with many sequential and parallel jobs.
+To make it a little easier to see what is going on, you can view a graph
+of a single pipeline and its status.
+
+A pipeline graph can be shown in two different ways depending on what page you
+are on.
+
+---
+
+The regular pipeline graph that shows the names of the jobs of each stage can
+be found when you are on a [single pipeline page](#seeing-pipeline-status).
+
+![Pipelines example](img/pipelines.png)
+
+Then, there is the pipeline mini graph which takes less space and can give you a
+quick glance if all jobs pass or something failed. The pipeline mini graph can
+be found when you visit:
+
+- the pipelines index page
+- a single commit page
+- a merge request page
+
+That way, you can see all related jobs for a single commit and the net result
+of each stage of your pipeline. This allows you to quickly see what failed and
+fix it. Stages in pipeline mini graphs are collapsible. Hover your mouse over
+them and click to expand their jobs.
+
+| **Mini graph** | **Mini graph expanded** |
+| :------------: | :---------------------: |
+| ![Pipelines mini graph](img/pipelines_mini_graph_simple.png) | ![Pipelines mini graph extended](img/pipelines_mini_graph.png) |
+
+### Grouping similar jobs in the pipeline graph
+
+> [Introduced][ce-6242] in GitLab 8.12.
+
+If you have many similar jobs, your pipeline graph becomes very long and hard
+to read. For that reason, similar jobs can automatically be grouped together.
+If the job names are formatted in certain ways, they will be collapsed into
+a single group in regular pipeline graphs (not the mini graphs).
+You'll know when a pipeline has grouped jobs if you don't see the retry or
+cancel button inside them. Hovering over them will show the number of grouped
+jobs. Click to expand them.
+
+![Grouped pipelines](img/pipelines_grouped.png)
+
+The basic requirements is that there are two numbers separated with one of
+the following (you can even use them interchangeably):
+
+- a space
+- a backslash (`/`)
+- a colon (`:`)
+
+>**Note:**
+More specifically, [it uses][regexp] this regular expression: `\d+[\s:\/\\]+\d+\s*`.
+
+The jobs will be ordered by comparing those two numbers from left to right. You
+usually want the first to be the index and the second the total.
+
+For example, the following jobs will be grouped under a job named `test`:
+
+- `test 0 3` => `test`
+- `test 1 3` => `test`
+- `test 2 3` => `test`
+
+The following jobs will be grouped under a job named `test ruby`:
+
+- `test 1:2 ruby` => `test ruby`
+- `test 2:2 ruby` => `test ruby`
+
+The following jobs will be grouped under a job named `test ruby` as well:
+
+- `1/3 test ruby` => `test ruby`
+- `2/3 test ruby` => `test ruby`
+- `3/3 test ruby` => `test ruby`
+
+### Manual actions from the pipeline graph
+
+> [Introduced][ce-7931] in GitLab 8.15.
+
+[Manual actions][manual] allow you to require manual interaction before moving
+forward with a particular job in CI. Your entire pipeline can run automatically,
+but the actual [deploy to production][env-manual] will require a click.
+
+You can do this straight from the pipeline graph. Just click on the play button
+to execute that particular job. For example, in the image below, the `production`
+stage has a job with a manual action.
+
+![Pipelines example](img/pipelines.png)
+
+### Ordering of jobs in pipeline graphs
+
+**Regular pipeline graph**
+
+In the single pipeline page, jobs are sorted by name.
+
+**Mini pipeline graph**
+
+> [Introduced][ce-9760] in GitLab 9.0.
+
+In the pipeline mini graphs, the jobs are sorted first by severity and then
+by name. The order of severity is:
+
+- failed
+- warning
+- pending
+- running
+- manual
+- canceled
+- success
+- skipped
+- created
+
+![Pipeline mini graph sorting](img/pipelines_mini_graph_sorting.png)
## How the pipeline duration is calculated
@@ -96,7 +224,14 @@ respective link in the [Pipelines settings] page.
[jobs]: #jobs
[jobs-yaml]: yaml/README.md#jobs
+[manual]: yaml/README.md#manual
+[env-manual]: environments.md#manually-deploying-to-environments
[stages]: yaml/README.md#stages
[runners]: runners/README.html
[pipelines settings]: ../user/project/pipelines/settings.md
[triggers]: triggers/README.md
+[ce-5742]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5742
+[ce-6242]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6242
+[ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931
+[ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760
+[regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99
diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md
index 30f209f80eb..88e53ff40e8 100644
--- a/doc/ci/quick_start/README.md
+++ b/doc/ci/quick_start/README.md
@@ -155,7 +155,7 @@ Find more information about different Runners in the
[Runners](../runners/README.md) documentation.
You can find whether any Runners are assigned to your project by going to
-**Settings âž” Runners**. Setting up a Runner is easy and straightforward. The
+**Settings âž” Pipelines**. Setting up a Runner is easy and straightforward. The
official Runner supported by GitLab is written in Go and its documentation
can be found at <https://docs.gitlab.com/runner/>.
@@ -168,7 +168,7 @@ Follow the links above to set up your own Runner or use a Shared Runner as
described in the next section.
Once the Runner has been set up, you should see it on the Runners page of your
-project, following **Settings âž” Runners**.
+project, following **Settings âž” Pipelines**.
![Activated runners](img/runners_activated.png)
@@ -181,7 +181,7 @@ These are special virtual machines that run on GitLab's infrastructure and can
build any project.
To enable the **Shared Runners** you have to go to your project's
-**Settings âž” Runners** and click **Enable shared runners**.
+**Settings âž” Pipelines** and click **Enable shared runners**.
[Read more on Shared Runners](../runners/README.md).
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 1bd1ee93ac5..76d746155eb 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -1,124 +1,168 @@
# Runners
-In GitLab CI, Runners run your [yaml](../yaml/README.md).
-A Runner is an isolated (virtual) machine that picks up jobs
-through the coordinator API of GitLab CI.
+In GitLab CI, Runners run the code defined in [`.gitlab-ci.yml`](../yaml/README.md).
+They are isolated (virtual) machines that pick up jobs through the coordinator
+API of GitLab CI.
A Runner can be specific to a certain project or serve any project
in GitLab CI. A Runner that serves all projects is called a shared Runner.
-Ideally, GitLab Runner should not be installed on the same machine as GitLab.
+Ideally, the GitLab Runner should not be installed on the same machine as GitLab.
Read the [requirements documentation](../../install/requirements.md#gitlab-runner)
for more information.
-## Shared vs. Specific Runners
-
-A Runner that is specific only runs for the specified project. A shared Runner
-can run jobs for every project that has enabled the option **Allow shared Runners**.
-
-**Shared Runners** are useful for jobs that have similar requirements,
-between multiple projects. Rather than having multiple Runners idling for
-many projects, you can have a single or a small number of Runners that handle
-multiple projects. This makes it easier to maintain and update Runners.
-
-**Specific Runners** are useful for jobs that have special requirements or for
-projects with a specific demand. If a job has certain requirements, you can set
-up the specific Runner with this in mind, while not having to do this for all
-Runners. For example, if you want to deploy a certain project, you can setup
-a specific Runner to have the right credentials for this.
-
-Projects with high demand of CI activity can also benefit from using specific Runners.
-By having dedicated Runners you are guaranteed that the Runner is not being held
-up by another project's jobs.
+## Shared vs specific Runners
+
+After [installing the Runner][install], you can either register it as shared or
+specific. You can only register a shared Runner if you have admin access to
+the GitLab instance. The main differences between a shared and a specific Runner
+are:
+
+- **Shared Runners** are useful for jobs that have similar requirements,
+ between multiple projects. Rather than having multiple Runners idling for
+ many projects, you can have a single or a small number of Runners that handle
+ multiple projects. This makes it easier to maintain and update them.
+ Shared Runners process jobs using a [fair usage queue](#how-shared-runners-pick-jobs).
+ In contrast to specific Runners that use a FIFO queue, this prevents
+ cases where projects create hundreds of jobs which can lead to eating all
+ available shared Runners resources.
+- **Specific Runners** are useful for jobs that have special requirements or for
+ projects with a specific demand. If a job has certain requirements, you can set
+ up the specific Runner with this in mind, while not having to do this for all
+ Runners. For example, if you want to deploy a certain project, you can setup
+ a specific Runner to have the right credentials for this. The [usage of tags](#using-tags)
+ may be useful in this case. Specific Runners process jobs using a [FIFO] queue.
+
+A Runner that is specific only runs for the specified project(s). A shared Runner
+can run jobs for every project that has enabled the option **Allow shared Runners**
+under **Settings âž” Pipelines**.
+
+Projects with high demand of CI activity can also benefit from using specific
+Runners. By having dedicated Runners you are guaranteed that the Runner is not
+being held up by another project's jobs.
You can set up a specific Runner to be used by multiple projects. The difference
with a shared Runner is that you have to enable each project explicitly for
the Runner to be able to run its jobs.
Specific Runners do not get shared with forked projects automatically.
-A fork does copy the CI settings (jobs, allow shared, etc) of the cloned repository.
-
-# Creating and Registering a Runner
-
-There are several ways to create a Runner. Only after creation, upon
-registration its status as Shared or Specific is determined.
-
-[See the documentation for](https://docs.gitlab.com/runner/install)
-the different methods of installing a Runner instance.
+A fork does copy the CI settings (jobs, allow shared, etc) of the cloned
+repository.
-After installing the Runner, you can either register it as `Shared` or as `Specific`.
-You can only register a Shared Runner if you have admin access to the GitLab instance.
+## Registering a shared Runner
-## Registering a Shared Runner
+You can only register a shared Runner if you are an admin of the GitLab instance.
-You can only register a shared Runner if you are an admin on the linked
-GitLab instance.
+1. Grab the shared-Runner token on the `admin/runners` page
-Grab the shared-Runner token on the `admin/runners` page of your GitLab CI
-instance.
+ ![Shared Runners admin area](img/shared_runners_admin.png)
-![shared token](shared_runner.png)
+1. [Register the Runner][register]
-Now simply register the Runner as any Runner:
+Shared Runners are enabled by default as of GitLab 8.2, but can be disabled
+with the **Disable shared Runners** button which is present under each project's
+**Settings âž” Pipelines** page. Previous versions of GitLab defaulted shared
+Runners to disabled.
-```
-sudo gitlab-ci-multi-runner register
-```
-
-Shared Runners are enabled by default as of GitLab 8.2, but can be disabled with the
-`DISABLE SHARED RUNNERS` button. Previous versions of GitLab defaulted shared Runners to
-disabled.
-
-## Registering a Specific Runner
+## Registering a specific Runner
Registering a specific can be done in two ways:
1. Creating a Runner with the project registration token
1. Converting a shared Runner into a specific Runner (one-way, admin only)
-There are several ways to create a Runner instance. The steps below only
-concern registering the Runner on GitLab CI.
-
-### Registering a Specific Runner with a Project Registration token
+### Registering a specific Runner with a project registration token
To create a specific Runner without having admin rights to the GitLab instance,
-visit the project you want to make the Runner work for in GitLab CI.
+visit the project you want to make the Runner work for in GitLab:
-Click on the Runner tab and use the registration token you find there to
-setup a specific Runner for this project.
+1. Go to **Settings âž” Pipelines** to obtain the token
+1. [Register the Runner][register]
-![project Runners in GitLab CI](project_specific.png)
+### Making an existing shared Runner specific
-To register the Runner, run the command below and follow instructions:
+If you are an admin on your GitLab instance, you can turn any shared Runner into
+a specific one, but not the other way around. Keep in mind that this is a one
+way transition.
-```
-sudo gitlab-ci-multi-runner register
-```
+1. Go to the Runners in the admin area **Overview âž” Runners** (`/admin/runners`)
+ and find your Runner
+1. Enable any projects under **Restrict projects for this Runner** to be used
+ with the Runner
-### Lock a specific Runner from being enabled for other projects
+From now on, the shared Runner will be specific to those projects.
+
+## Locking a specific Runner from being enabled for other projects
You can configure a Runner to assign it exclusively to a project. When a
Runner is locked this way, it can no longer be enabled for other projects.
-This setting is available on each Runner in *Project Settings* > *Runners*.
+This setting can be enabled the first time you [register a Runner][register] and
+can be changed afterwards under each Runner's settings.
+
+To lock/unlock a Runner:
+
+1. Visit your project's **Settings âž” Pipelines**
+1. Find the Runner you wish to lock/unlock and make sure it's enabled
+1. Click the pencil button
+1. Check the **Lock to current projects** option
+1. Click **Save changes** for the changes to take effect
-### Making an existing Shared Runner Specific
+## How shared Runners pick jobs
-If you are an admin on your GitLab instance,
-you can make any shared Runner a specific Runner, _but you can not
-make a specific Runner a shared Runner_.
+Shared Runners abide to a process queue we call fair usage. The fair usage
+algorithm tries to assign jobs to shared Runners from projects that have the
+lowest number of jobs currently running on shared Runners.
-To make a shared Runner specific, go to the Runner page (`/admin/runners`)
-and find your Runner. Add any projects on the left to make this Runner
-run exclusively for these projects, therefore making it a specific Runner.
+**Example 1**
-![making a shared Runner specific](shared_to_specific_admin.png)
+We have following jobs in queue:
-## Using Shared Runners Effectively
+- Job 1 for Project 1
+- Job 2 for Project 1
+- Job 3 for Project 1
+- Job 4 for Project 2
+- Job 5 for Project 2
+- Job 6 for Project 3
+
+With the fair usage algorithm jobs are assigned in following order:
+
+1. Job 1 is chosen first, because it has the lowest job number from projects with no running jobs (i.e. all projects)
+1. Job 4 is next, because 4 is now the lowest job number from projects with no running jobs (Project 1 has a job running)
+1. Job 6 is next, because 6 is now the lowest job number from projects with no running jobs (Projects 1 and 2 have jobs running)
+1. Job 2 is next, because, of projects with the lowest number of jobs running (each has 1), it is the lowest job number
+1. Job 5 is next, because Project 1 now has 2 jobs running, and between Projects 2 and 3, Job 5 is the lowest remaining job number
+1. Lastly we choose Job 3... because it's the only job left
+
+---
+
+**Example 2**
+
+We have following jobs in queue:
+
+- Job 1 for project 1
+- Job 2 for project 1
+- Job 3 for project 1
+- Job 4 for project 2
+- Job 5 for project 2
+- Job 6 for project 3
+
+With the fair usage algorithm jobs are assigned in following order:
+
+1. Job 1 is chosen first, because it has the lowest job number from projects with no running jobs (i.e. all projects)
+1. We finish job 1
+1. Job 2 is next, because, having finished Job 1, all projects have 0 jobs running again, and 2 is the lowest available job number
+1. Job 4 is next, because with Project 1 running a job, 4 is the lowest number from projects running no jobs (Projects 2 and 3)
+1. We finish job 4
+1. Job 5 is next, because having finished Job 4, Project 2 has no jobs running again
+1. Job 6 is next, because Project 3 is the only project left with no running jobs
+1. Lastly we choose Job 3... because, again, it's the only job left (who says 1 is the loneliest number?)
+
+## Using shared Runners effectively
If you are planning to use shared Runners, there are several things you
should keep in mind.
-### Use Tags
+### Using tags
You must setup a Runner to be able to run all the different types of jobs
that it may encounter on the projects it's shared over. This would be
@@ -130,17 +174,27 @@ shared Runners will only run the jobs they are equipped to run.
For instance, at GitLab we have Runners tagged with "rails" if they contain
the appropriate dependencies to run Rails test suites.
-### Prevent Runner with tags from picking jobs without tags
+### Preventing Runners with tags from picking jobs without tags
You can configure a Runner to prevent it from picking jobs with tags when
-the Runner does not have tags assigned. This setting is available on each
-Runner in *Project Settings* > *Runners*.
+the Runner does not have tags assigned. This setting can be enabled the first
+time you [register a Runner][register] and can be changed afterwards under
+each Runner's settings.
+
+To make a Runner pick tagged/untagged jobs:
+
+1. Visit your project's **Settings âž” Pipelines**
+1. Find the Runner you wish and make sure it's enabled
+1. Click the pencil button
+1. Check the **Run untagged jobs** option
+1. Click **Save changes** for the changes to take effect
### Be careful with sensitive information
If you can run a job on a Runner, you can get access to any code it runs
and get the token of the Runner. With shared Runners, this means that anyone
-that runs jobs on the Runner, can access anyone else's code that runs on the Runner.
+that runs jobs on the Runner, can access anyone else's code that runs on the
+Runner.
In addition, because you can get access to the Runner token, it is possible
to create a clone of a Runner and submit false jobs, for example.
@@ -160,3 +214,7 @@ project.
Mentioned briefly earlier, but the following things of Runners can be exploited.
We're always looking for contributions that can mitigate these
[Security Considerations](https://docs.gitlab.com/runner/security/).
+
+[install]: http://docs.gitlab.com/runner/install/
+[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
+[register]: http://docs.gitlab.com/runner/register/
diff --git a/doc/ci/runners/img/shared_runners_admin.png b/doc/ci/runners/img/shared_runners_admin.png
new file mode 100644
index 00000000000..e049b339b36
--- /dev/null
+++ b/doc/ci/runners/img/shared_runners_admin.png
Binary files differ
diff --git a/doc/ci/runners/project_specific.png b/doc/ci/runners/project_specific.png
deleted file mode 100644
index c812defa67b..00000000000
--- a/doc/ci/runners/project_specific.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/runners/shared_runner.png b/doc/ci/runners/shared_runner.png
deleted file mode 100644
index 31574a17764..00000000000
--- a/doc/ci/runners/shared_runner.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md
index befaa06e918..cf25a8b618f 100644
--- a/doc/ci/ssh_keys/README.md
+++ b/doc/ci/ssh_keys/README.md
@@ -34,9 +34,9 @@ instructions to [generate an SSH key](../../ssh/README.md). Do not add a
passphrase to the SSH key, or the `before_script` will prompt for it.
Then, create a new **Secret Variable** in your project settings on GitLab
-following **Settings > Variables**. As **Key** add the name `SSH_PRIVATE_KEY`
-and in the **Value** field paste the content of your _private_ key that you
-created earlier.
+following **Settings > Pipelines** and look for the "Secret Variables" section.
+As **Key** add the name `SSH_PRIVATE_KEY` and in the **Value** field paste the
+content of your _private_ key that you created earlier.
It is also good practice to check the server's own public key to make sure you
are not being targeted by a man-in-the-middle attack. To do this, add another
diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md
index 5f611314d09..7ec7136d8c6 100644
--- a/doc/ci/triggers/README.md
+++ b/doc/ci/triggers/README.md
@@ -1,18 +1,25 @@
-# Triggering jobs through the API
+# Triggering pipelines through the API
-> **Note**:
+> **Notes**:
- [Introduced][ci-229] in GitLab CE 7.14.
- GitLab 8.12 has a completely redesigned job permissions system. Read all
about the [new model and its implications](../../user/project/new_ci_build_permissions_model.md#job-triggers).
-- GitLab 9.0 introduced a trigger ownership to solve permission problems.
-Triggers can be used to force a rebuild of a specific `ref` (branch or tag)
-with an API call.
+Triggers can be used to force a pipeline rerun of a specific `ref` (branch or
+tag) with an API call.
+
+## Authentication tokens
+
+The following methods of authentication are supported.
+
+### Trigger token
-## Add a trigger
+A unique trigger token can be obtained when [adding a new trigger](#adding-a-new-trigger).
+
+## Adding a new trigger
You can add a new trigger by going to your project's
-**Settings âž” CI/CD Pipelines âž” Triggers**. The **Add trigger** button will
+**Settings âž” Pipelines** under **Triggers**. The **Add trigger** button will
create a new token which you can then use to trigger a rerun of this
particular project's pipeline.
@@ -22,7 +29,10 @@ overview of the time the triggers were last used.
![Triggers page overview](img/triggers_page.png)
-## Take ownership
+## Taking ownership of a trigger
+
+> **Note**:
+GitLab 9.0 introduced a trigger ownership to solve permission problems.
Each created trigger when run will impersonate their associated user including
their access to projects and their project permissions.
@@ -30,26 +40,20 @@ their access to projects and their project permissions.
You can take ownership of existing triggers by clicking *Take ownership*.
From now on the trigger will be run as you.
-## Legacy triggers
-
-Old triggers, created before 9.0 will be marked as Legacy. Triggers with
-the legacy label do not have an associated user and only have access
-to the current project.
-
-Legacy trigger are considered deprecated and will be removed
-with one of the future versions of GitLab.
-
-## Revoke a trigger
+## Revoking a trigger
You can revoke a trigger any time by going at your project's
-**Settings > Triggers** and hitting the **Revoke** button. The action is
-irreversible.
+**Settings âž” Pipelines** under **Triggers** and hitting the **Revoke** button.
+The action is irreversible.
-## Trigger a pipeline
+## Triggering a pipeline
-> **Note**:
-Valid refs are only the branches and tags. If you pass a commit SHA as a ref,
-it will not trigger a job.
+> **Notes**:
+- Valid refs are only the branches and tags. If you pass a commit SHA as a ref,
+ it will not trigger a job.
+- If your project is public, passing the token in plain text is probably not the
+ wisest idea, so you might want to use a
+ [secret variable](../variables/README.md#secret-variables) for that purpose.
To trigger a job you need to send a `POST` request to GitLab's API endpoint:
@@ -57,11 +61,11 @@ To trigger a job you need to send a `POST` request to GitLab's API endpoint:
POST /projects/:id/trigger/pipeline
```
-The required parameters are the trigger's `token` and the Git `ref` on which
-the trigger will be performed. Valid refs are the branch and the tag. The `:id`
-of a project can be found by [querying the API](../../api/projects.md)
-or by visiting the **CI/CD Pipelines** settings page which provides
-self-explanatory examples.
+The required parameters are the [trigger's `token`](#authentication-tokens)
+and the Git `ref` on which the trigger will be performed. Valid refs are the
+branch and the tag. The `:id` of a project can be found by
+[querying the API](../../api/projects.md) or by visiting the **Pipelines**
+settings page which provides self-explanatory examples.
When a rerun of a pipeline is triggered, the information is exposed in GitLab's
UI under the **Jobs** page and the jobs are marked as triggered 'by API'.
@@ -78,46 +82,7 @@ below.
---
-See the [Examples](#examples) section for more details on how to actually
-trigger a rebuild.
-
-## Trigger a pipeline from webhook
-
-> Introduced in GitLab 8.14.
-
-To trigger a job from webhook of another project you need to add the following
-webhook url for Push and Tag push events:
-
-```
-https://gitlab.example.com/api/v4/projects/:id/ref/:ref/trigger/pipeline?token=TOKEN
-```
-
-> **Note**:
-- `ref` should be passed as part of url in order to take precedence over `ref`
- from webhook body that designates the branchref that fired the trigger in the source repository.
-- `ref` should be url encoded if contains slashes.
-
-## Pass job variables to a trigger
-
-You can pass any number of arbitrary variables in the trigger API call and they
-will be available in GitLab CI so that they can be used in your `.gitlab-ci.yml`
-file. The parameter is of the form:
-
-```
-variables[key]=value
-```
-
-This information is also exposed in the UI.
-
-![Job variables in UI](img/trigger_variables.png)
-
----
-
-See the [Examples](#examples) section below for more details.
-
-## Examples
-
-Using cURL you can trigger a rebuild with minimal effort, for example:
+By using cURL you can trigger a pipeline rerun with minimal effort, for example:
```bash
curl --request POST \
@@ -135,8 +100,6 @@ curl --request POST \
"https://gitlab.example.com/api/v4/projects/9/trigger/pipeline?token=TOKEN&ref=master"
```
-### Triggering a pipeline within `.gitlab-ci.yml`
-
You can also benefit by using triggers in your `.gitlab-ci.yml`. Let's say that
you have two projects, A and B, and you want to trigger a rebuild on the `master`
branch of project B whenever a tag on project A is created. This is the job you
@@ -156,14 +119,37 @@ Now, whenever a new tag is pushed on project A, the job will run and the
`stage: deploy` ensures that this job will run only after all jobs with
`stage: test` complete successfully.
-_**Note:** If your project is public, passing the token in plain text is
-probably not the wisest idea, so you might want to use a
-[secure variable](../variables/README.md#user-defined-variables-secure-variables)
-for that purpose._
+## Triggering a pipeline from a webhook
+
+> **Notes**:
+- Introduced in GitLab 8.14.
+- `ref` should be passed as part of the URL in order to take precedence over
+ `ref` from the webhook body that designates the branch ref that fired the
+ trigger in the source repository.
+- `ref` should be URL-encoded if it contains slashes.
+
+To trigger a job from a webhook of another project you need to add the following
+webhook URL for Push and Tag events (change the project ID, ref and token):
-### Making use of trigger variables
+```
+https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN
+```
-Using trigger variables can be proven useful for a variety of reasons.
+## Making use of trigger variables
+
+You can pass any number of arbitrary variables in the trigger API call and they
+will be available in GitLab CI so that they can be used in your `.gitlab-ci.yml`
+file. The parameter is of the form:
+
+```
+variables[key]=value
+```
+
+This information is also exposed in the UI.
+
+![Job variables in UI](img/trigger_variables.png)
+
+Using trigger variables can be proven useful for a variety of reasons:
* Identifiable jobs. Since the variable is exposed in the UI you can know
why the rebuild was triggered if you pass a variable that explains the
@@ -208,15 +194,11 @@ curl --request POST \
https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
-### Using webhook to trigger job
-
-You can add the following webhook to another project in order to trigger a job:
+## Using cron to trigger nightly pipelines
-```
-https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN&variables[UPLOAD_TO_S3]=true
-```
-
-### Using cron to trigger nightly jobs
+>**Note:**
+The following behavior can also be achieved through GitLab's UI with
+[pipeline schedules](../../user/project/pipelines/schedules.md).
Whether you craft a script or just run cURL directly, you can trigger jobs
in conjunction with cron. The example below triggers a job on the `master`
@@ -226,32 +208,18 @@ branch of project with ID `9` every night at `00:30`:
30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
-[ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229
-
-## Using scheduled triggers
-
-> [Introduced][ci-10533] in GitLab CE 9.1 as experimental.
-
-In order to schedule a trigger, navigate to your project's **Settings âž” CI/CD Pipelines âž” Triggers** and edit an existing trigger token.
-
-![Triggers Schedule edit](img/trigger_schedule_edit.png)
-
-To set up a scheduled trigger:
-
-1. Check the **Schedule trigger (experimental)** checkbox
-1. Enter a cron value for the frequency of the trigger ([learn more about cron notation](http://www.nncron.ru/help/EN/working/cron-format.htm))
-1. Enter the timezone of the cron trigger ([see a list of timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones))
-1. Enter the branch or tag that the trigger will target
-1. Hit **Save trigger** for the changes to take effect
-
-![Triggers Schedule create](img/trigger_schedule_create.png)
-
-You can check a next execution date of the scheduled trigger, which is automatically calculated by a server.
+## Legacy triggers
-![Triggers Schedule create](img/trigger_schedule_updated_next_run_at.png)
+Old triggers, created before GitLab 9.0 will be marked as legacy.
-> **Notes**:
-- Those triggers won't be executed precicely. Because scheduled triggers are handled by Sidekiq, which runs according to its interval. For exmaple, if you set a trigger to be executed every minute (`* * * * *`) and the Sidekiq worker performs 00:00 and 12:00 o'clock every day (`0 */12 * * *`), then your trigger will be executed only 00:00 and 12:00 o'clock every day. To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker` value in `config/gitlab.yml` and restart GitLab. The Sidekiq worker's configuration on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185).
-- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler).
+Triggers with the legacy label do not have an associated user and only have
+access to the current project. They are considered deprecated and will be
+removed with one of the future versions of GitLab. You are advised to
+[take ownership](#taking-ownership) of any legacy triggers.
-[ci-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
+[ee-2017]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2017
+[ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229
+[ee]: https://about.gitlab.com/gitlab-ee/
+[variables]: ../variables/README.md
+[predef]: ../variables/README.md#predefined-variables-environment-variables
+[registry]: ../../user/project/container_registry.md
diff --git a/doc/ci/triggers/img/trigger_schedule_create.png b/doc/ci/triggers/img/trigger_schedule_create.png
deleted file mode 100644
index 3cfdc00b7a7..00000000000
--- a/doc/ci/triggers/img/trigger_schedule_create.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/triggers/img/trigger_schedule_edit.png b/doc/ci/triggers/img/trigger_schedule_edit.png
deleted file mode 100644
index 647eac0a5d0..00000000000
--- a/doc/ci/triggers/img/trigger_schedule_edit.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png b/doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png
deleted file mode 100644
index 71d08d04c37..00000000000
--- a/doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/triggers/img/triggers_page.png b/doc/ci/triggers/img/triggers_page.png
index eafd8519a23..7dc8f91cf7e 100644
--- a/doc/ci/triggers/img/triggers_page.png
+++ b/doc/ci/triggers/img/triggers_page.png
Binary files differ
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 045d3821f66..22e7f6879ed 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -9,8 +9,9 @@ and a list of **user-defined variables**.
The variables can be overwritten and they take precedence over each other in
this order:
-1. [Trigger variables][triggers] (take precedence over all)
-1. [Secret variables](#secret-variables)
+1. [Trigger variables][triggers] or [scheduled pipeline variables](../../user/project/pipelines/schedules.md#making-use-of-scheduled-pipeline-variables) (take precedence over all)
+1. Project-level [secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables)
+1. Group-level [secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables)
1. YAML-defined [job-level variables](../yaml/README.md#job-variables)
1. YAML-defined [global variables](../yaml/README.md#variables)
1. [Deployment variables](#deployment-variables)
@@ -37,12 +38,14 @@ future GitLab releases.**
|-------------------------------- |--------|--------|-------------|
| **CI** | all | 0.4 | Mark that job is executed in CI environment |
| **CI_COMMIT_REF_NAME** | 9.0 | all | The branch or tag name for which project is built |
-| **CI_COMMIT_REF_SLUG** | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. |
+| **CI_COMMIT_REF_SLUG** | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. |
| **CI_COMMIT_SHA** | 9.0 | all | The commit revision for which project is built |
| **CI_COMMIT_TAG** | 9.0 | 0.5 | The commit tag name. Present only when building tags. |
+| **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` |
| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled |
| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job |
| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. |
+| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job |
| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally |
| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started |
| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` |
@@ -56,9 +59,10 @@ future GitLab releases.**
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run |
| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally |
-| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built |
+| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) |
| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built |
| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name |
+| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. |
| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project |
| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry |
| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project |
@@ -118,11 +122,11 @@ The YAML-defined variables are also set to all created
tune them.
Variables can be defined at a global level, but also at a job level. To turn off
-global defined variables in your job, define an empty array:
+global defined variables in your job, define an empty hash:
```yaml
job_name:
- variables: []
+ variables: {}
```
You are able to use other variables inside your variable definition (or escape them with `$$`):
@@ -139,23 +143,44 @@ script:
>**Notes:**
- This feature requires GitLab Runner 0.4.0 or higher.
+- Group-level secret variables added in GitLab 9.4.
- Be aware that secret variables are not masked, and their values can be shown
in the job logs if explicitly asked to do so. If your project is public or
internal, you can set the pipelines private from your project's Pipelines
settings. Follow the discussion in issue [#13784][ce-13784] for masking the
secret variables.
-GitLab CI allows you to define per-project **secret variables** that are set in
-the build environment. The secret variables are stored out of the repository
-(`.gitlab-ci.yml`) and are securely passed to GitLab Runner making them
-available in the build environment. It's the recommended method to use for
-storing things like passwords, secret keys and credentials.
+GitLab CI allows you to define per-project or per-group **secret variables**
+that are set in the build environment. The secret variables are stored out of
+the repository (`.gitlab-ci.yml`) and are securely passed to GitLab Runner
+making them available in the build environment. It's the recommended method to
+use for storing things like passwords, secret keys and credentials.
-Secret variables can be added by going to your project's
-**Settings âž” CI/CD Pipelines**, then finding the section called
-**Secret Variables**.
+Project-level secret variables can be added by going to your project's
+**Settings âž” Pipelines**, then finding the section called **Secret variables**.
-Once you set them, they will be available for all subsequent jobs.
+Likewise, group-level secret variables can be added by going to your group's
+**Settings âž” Pipelines**, then finding the section called **Secret variables**.
+Any variables of [subgroups] will be inherited recursively.
+
+Once you set them, they will be available for all subsequent pipelines. You can also
+[protect your variables](#protected-secret-variables).
+
+### Protected secret variables
+
+>**Notes:**
+This feature requires GitLab 9.3 or higher.
+
+Secret variables could be protected. Whenever a secret variable is
+protected, it would only be securely passed to pipelines running on the
+[protected branches] or [protected tags]. The other pipelines would not get any
+protected variables.
+
+Protected variables can be added by going to your project's
+**Settings âž” Pipelines**, then finding the section called
+**Secret variables**, and check *Protected*.
+
+Once you set them, they will be available for all subsequent pipelines.
## Deployment variables
@@ -327,20 +352,45 @@ All variables are set as environment variables in the build environment, and
they are accessible with normal methods that are used to access such variables.
In most cases `bash` or `sh` is used to execute the job script.
-To access the variables (predefined and user-defined) in a `bash`/`sh` environment,
-prefix the variable name with the dollar sign (`$`):
+To access environment variables, use the syntax for your Runner's [shell][shellexecutors].
+
+| Shell | Usage |
+|----------------------|-----------------|
+| bash/sh | `$variable` |
+| windows batch | `%variable%` |
+| PowerShell | `$env:variable` |
+
+To access environment variables in bash, prefix the variable name with (`$`):
+```yaml
+job_name:
+ script:
+ - echo $CI_JOB_ID
```
+
+To access environment variables in **Windows Batch**, surround the variable
+with (`%`):
+
+```yaml
job_name:
script:
- - echo $CI_job_ID
+ - echo %CI_JOB_ID%
+```
+
+To access environment variables in a **Windows PowerShell** environment, prefix
+the variable name with (`$env:`):
+
+```yaml
+job_name:
+ script:
+ - echo $env:CI_JOB_ID
```
You can also list all environment variables with the `export` command,
but be aware that this will also expose the values of all the secret variables
you set, in the job log:
-```
+```yaml
job_name:
script:
- export
@@ -382,6 +432,12 @@ export CI_REGISTRY_PASSWORD="longalfanumstring"
```
[ce-13784]: https://gitlab.com/gitlab-org/gitlab-ce/issues/13784
+[eep]: https://about.gitlab.com/gitlab-ee/ "Available only in GitLab Enterprise Edition Premium"
+[envs]: ../environments.md
+[protected branches]: ../../user/project/protected_branches.md
+[protected tags]: ../../user/project/protected_tags.md
[runner]: https://docs.gitlab.com/runner/
+[shellexecutors]: https://docs.gitlab.com/runner/executors/
[triggered]: ../triggers/README.md
[triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger
+[subgroups]: ../../user/group/subgroups/index.md
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index ad3ebd144df..1869782fe6e 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -147,6 +147,10 @@ variables:
DATABASE_URL: "postgres://postgres@postgres/my_database"
```
+>**Note:**
+Integers (as well as strings) are legal both for variable's name and value.
+Floats are not legal and cannot be used.
+
These variables can be later used in all executed commands and scripts.
The YAML-defined variables are also set to all created service containers,
thus allowing to fine tune them. Variables can be also defined on a
@@ -162,7 +166,11 @@ which can be set in GitLab's UI.
### cache
-> Introduced in GitLab Runner v0.7.0.
+>
+**Notes:**
+- Introduced in GitLab Runner v0.7.0.
+- Prior to GitLab 9.2, caches were restored after artifacts.
+- From GitLab 9.2, caches are restored before artifacts.
`cache` is used to specify a list of files and directories which should be
cached between jobs. You can only use paths that are within the project
@@ -289,6 +297,62 @@ cache:
untracked: true
```
+If you use **Windows PowerShell** to run your shell scripts you need to replace
+`$` with `$env:`:
+
+```yaml
+cache:
+ key: "$env:CI_JOB_STAGE/$env:CI_COMMIT_REF_NAME"
+ untracked: true
+```
+
+### cache:policy
+
+> Introduced in GitLab 9.4.
+
+The default behaviour of a caching job is to download the files at the start of
+execution, and to re-upload them at the end. This allows any changes made by the
+job to be persisted for future runs, and is known as the `pull-push` cache
+policy.
+
+If you know the job doesn't alter the cached files, you can skip the upload step
+by setting `policy: pull` in the job specification. Typically, this would be
+twinned with an ordinary cache job at an earlier stage to ensure the cache
+is updated from time to time:
+
+```yaml
+stages:
+ - setup
+ - test
+
+prepare:
+ stage: setup
+ cache:
+ key: gems
+ paths:
+ - vendor/bundle
+ script:
+ - bundle install --deployment
+
+rspec:
+ stage: test
+ cache:
+ key: gems
+ paths:
+ - vendor/bundle
+ policy: pull
+ script:
+ - bundle exec rspec ...
+```
+
+This helps to speed up job execution and reduce load on the cache server,
+especially when you have a large number of cache-using jobs executing in
+parallel.
+
+Additionally, if you have a job that unconditionally recreates the cache without
+reference to its previous contents, you can use `policy: push` in that job to
+skip the download step.
+
## Jobs
`.gitlab-ci.yml` allows you to specify an unlimited number of jobs. Each job
@@ -331,6 +395,7 @@ job_name:
| after_script | no | Override a set of commands that are executed after job |
| environment | no | Defines a name of environment to which deployment is done by this job |
| coverage | no | Define code coverage settings for a given job |
+| retry | no | Define how many times a job can be auto-retried in case of a failure |
### script
@@ -376,12 +441,25 @@ There are a few rules that apply to the usage of refs policy:
* `only` and `except` are inclusive. If both `only` and `except` are defined
in a job specification, the ref is filtered by `only` and `except`.
* `only` and `except` allow the use of regular expressions.
-* `only` and `except` allow the use of special keywords: `branches`, `tags`, and `triggers`.
* `only` and `except` allow to specify a repository path to filter jobs for
forks.
+In addition, `only` and `except` allow the use of special keywords:
+
+| **Value** | **Description** |
+| --------- | ---------------- |
+| `branches` | When a branch is pushed. |
+| `tags` | When a tag is pushed. |
+| `api` | When pipeline has been triggered by a second pipelines API (not triggers API). |
+| `external` | When using CI services other than GitLab. |
+| `pipelines` | For multi-project triggers, created using the API with `CI_JOB_TOKEN`. |
+| `pushes` | Pipeline is triggered by a `git push` by the user. |
+| `schedules` | For [scheduled pipelines][schedules]. |
+| `triggers` | For pipelines created using a trigger token. |
+| `web` | For pipelines created using **Run pipeline** button in GitLab UI (under your project's **Pipelines**). |
+
In the example below, `job` will run only for refs that start with `issue-`,
-whereas all branches will be skipped.
+whereas all branches will be skipped:
```yaml
job:
@@ -394,7 +472,7 @@ job:
```
In this example, `job` will run only for refs that are tagged, or if a build is
-explicitly requested via an API trigger.
+explicitly requested via an API trigger or a [Pipeline Schedule][schedules]:
```yaml
job:
@@ -402,6 +480,7 @@ job:
only:
- tags
- triggers
+ - schedules
```
The repository path can be used to have jobs executed only for the parent
@@ -426,11 +505,11 @@ but allows you to define job-specific variables.
When the `variables` keyword is used on a job level, it overrides the global YAML
job variables and predefined ones. To turn off global defined variables
-in your job, define an empty array:
+in your job, define an empty hash:
```yaml
job_name:
- variables: []
+ variables: {}
```
Job variables priority is defined in the [variables documentation][variables].
@@ -553,6 +632,8 @@ The above script will:
#### Manual actions
> Introduced in GitLab 8.10.
+> Blocking manual actions were introduced in GitLab 9.0
+> Protected actions were introduced in GitLab 9.2
Manual actions are a special type of job that are not executed automatically;
they need to be explicitly started by a user. Manual actions can be started
@@ -578,7 +659,10 @@ Optional manual actions have `allow_failure: true` set by default.
**Statuses of optional actions do not contribute to overall pipeline status.**
-> Blocking manual actions were introduced in GitLab 9.0
+**Manual actions are considered to be write actions, so permissions for
+protected branches are used when user wants to trigger an action. In other
+words, in order to trigger a manual action assigned to a branch that the
+pipeline is running for, user needs to have ability to merge to this branch.**
### environment
@@ -764,6 +848,8 @@ as Review Apps. You can see a simple example using Review Apps at
**Notes:**
- Introduced in GitLab Runner v0.7.0 for non-Windows platforms.
- Windows support was added in GitLab Runner v.1.0.0.
+- Prior to GitLab 9.2, caches were restored after artifacts.
+- From GitLab 9.2, caches are restored before artifacts.
- Currently not all executors are supported.
- Job artifacts are only collected for successful jobs by default.
@@ -894,6 +980,16 @@ job:
untracked: true
```
+If you use **Windows PowerShell** to run your shell scripts you need to replace
+`$` with `$env:`:
+
+```yaml
+job:
+ artifacts:
+ name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME"
+ untracked: true
+```
+
#### artifacts:when
> Introduced in GitLab 8.9 and GitLab Runner v1.3.0.
@@ -1046,9 +1142,33 @@ A simple example:
```yaml
job1:
+ script: rspec
coverage: '/Code coverage: \d+\.\d+/'
```
+### retry
+
+**Notes:**
+- [Introduced][ce-3442] in GitLab 9.5.
+
+`retry` allows you to configure how many times a job is going to be retried in
+case of a failure.
+
+When a job fails, and has `retry` configured it is going to be processed again
+up to the amount of times specified by the `retry` keyword.
+
+If `retry` is set to 2, and a job succeeds in a second run (first retry), it won't be retried
+again. `retry` value has to be a positive integer, equal or larger than 0, but
+lower or equal to 2 (two retries maximum, three runs in total).
+
+A simple example:
+
+```yaml
+test:
+ script: rspec
+ retry: 2
+```
+
## Git Strategy
> Introduced in GitLab 8.9 as an experimental feature. May change or be removed
@@ -1090,6 +1210,36 @@ variables:
GIT_STRATEGY: none
```
+## Git Checkout
+
+> Introduced in GitLab Runner 9.3
+
+The `GIT_CHECKOUT` variable can be used when the `GIT_STRATEGY` is set to either
+`clone` or `fetch` to specify whether a `git checkout` should be run. If not
+specified, it defaults to true. Like `GIT_STRATEGY`, it can be set in either the
+global [`variables`](#variables) section or the [`variables`](#job-variables)
+section for individual jobs.
+
+If set to `false`, the Runner will:
+
+- when doing `fetch` - update the repository and leave working copy on
+ the current revision,
+- when doing `clone` - clone the repository and leave working copy on the
+ default branch.
+
+Having this setting set to `true` will mean that for both `clone` and `fetch`
+strategies the Runner will checkout the working copy to a revision related
+to the CI pipeline:
+
+```yaml
+variables:
+ GIT_STRATEGY: clone
+ GIT_CHECKOUT: false
+script:
+ - git checkout master
+ - git merge $CI_BUILD_REF_NAME
+```
+
## Git Submodule Strategy
> Requires GitLab Runner v1.10+.
@@ -1147,7 +1297,7 @@ Example:
```yaml
variables:
- GET_SOURCES_ATTEMPTS: "3"
+ GET_SOURCES_ATTEMPTS: 3
```
You can set them in the global [`variables`](#variables) section or the
@@ -1393,3 +1543,5 @@ CI with various languages.
[variables]: ../variables/README.md
[ce-7983]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7983
[ce-7447]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7447
+[ce-3442]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3442
+[schedules]: ../../user/project/pipelines/schedules.md
diff --git a/doc/customization/libravatar.md b/doc/customization/libravatar.md
index c46ce2ee203..9bd22d3966d 100644
--- a/doc/customization/libravatar.md
+++ b/doc/customization/libravatar.md
@@ -16,7 +16,7 @@ the configuration options as follows:
```yml
gravatar:
enabled: true
- # gravatar URLs: possible placeholders: %{hash} %{size} %{email}
+ # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username}
plain_url: "http://cdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon"
```
@@ -25,7 +25,7 @@ the configuration options as follows:
```yml
gravatar:
enabled: true
- # gravatar URLs: possible placeholders: %{hash} %{size} %{email}
+ # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username}
ssl_url: "https://seccdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon"
```
diff --git a/doc/development/README.md b/doc/development/README.md
index 77bb0263374..dd150421b65 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -41,12 +41,29 @@
- [Shell commands](shell_commands.md) in the GitLab codebase
- [Sidekiq debugging](sidekiq_debugging.md)
- [Object state models](object_state_models.md)
+- [Building a package for testing purposes](build_test_package.md)
+- [Manage feature flags](feature_flags.md)
## Databases
+- [Merge Request Checklist](database_merge_request_checklist.md)
- [What requires downtime?](what_requires_downtime.md)
- [Adding database indexes](adding_database_indexes.md)
- [Post Deployment Migrations](post_deployment_migrations.md)
+- [Foreign Keys & Associations](foreign_keys.md)
+- [Serializing Data](serializing_data.md)
+- [Polymorphic Associations](polymorphic_associations.md)
+- [Single Table Inheritance](single_table_inheritance.md)
+- [Background Migrations](background_migrations.md)
+- [Storing SHA1 Hashes As Binary](sha1_as_binary.md)
+- [Iterating Tables In Batches](iterating_tables_in_batches.md)
+- [Ordering Table Columns](ordering_table_columns.md)
+- [Verifying Database Capabilities](verifying_database_capabilities.md)
+- [Hash Indexes](hash_indexes.md)
+
+## i18n
+
+- [Internationalization for GitLab](i18n_guide.md)
## Compliance
diff --git a/doc/development/architecture.md b/doc/development/architecture.md
index 4eb7a8eee48..54029e00507 100644
--- a/doc/development/architecture.md
+++ b/doc/development/architecture.md
@@ -4,7 +4,7 @@
There are two editions of GitLab: [Enterprise Edition](https://about.gitlab.com/gitlab-ee/) (EE) and [Community Edition](https://about.gitlab.com/gitlab-ce/) (CE). GitLab CE is delivered via git from the [gitlabhq repository](https://gitlab.com/gitlab-org/gitlab-ce/tree/master). New versions of GitLab are released in stable branches and the master branch is for bleeding edge development.
-EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/subscribers/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme.
+EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/gitlab-org/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme.
Both EE and CE require some add-on components called gitlab-shell and Gitaly. These components are available from the [gitlab-shell](https://gitlab.com/gitlab-org/gitlab-shell/tree/master) and [gitaly](https://gitlab.com/gitlab-org/gitaly/tree/master) repositories respectively. New versions are usually tags but staying on the master branch will give you the latest stable version. New releases are generally around the same time as GitLab CE releases with exception for informal security updates deemed critical.
@@ -12,7 +12,7 @@ Both EE and CE require some add-on components called gitlab-shell and Gitaly. Th
You can imagine GitLab as a physical office.
-**The repositories** are the goods GitLab handling.
+**The repositories** are the goods GitLab handles.
They can be stored in a warehouse.
This can be either a hard disk, or something more complex, such as a NFS filesystem;
@@ -54,7 +54,7 @@ To serve repositories over SSH there's an add-on application called gitlab-shell
### Components
-![GitLab Diagram Overview](gitlab_architecture_diagram.png)
+<img src="https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/pub?w=987&amp;h=797">
_[edit diagram (for GitLab team members only)](https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/edit)_
@@ -66,7 +66,9 @@ When serving repositories over HTTP/HTTPS GitLab utilizes the GitLab API to reso
The add-on component gitlab-shell serves repositories over SSH. It manages the SSH keys within `/home/git/.ssh/authorized_keys` which should not be manually edited. gitlab-shell accesses the bare repositories through Gitaly to serve git objects and communicates with redis to submit jobs to Sidekiq for GitLab to process. gitlab-shell queries the GitLab API to determine authorization and access.
-Gitaly executes git operations from gitlab-shell and Workhorse, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files)
+Gitaly executes git operations from gitlab-shell and the GitLab web app, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files).
+
+You may also be interested in the [production architecture of GitLab.com](https://about.gitlab.com/handbook/infrastructure/production-architecture/).
### Installation Folder Summary
@@ -192,3 +194,7 @@ bundle exec rake gitlab:check RAILS_ENV=production
```
Note: It is recommended to log into the `git` user using `sudo -i -u git` or `sudo su - git`. While the sudo commands provided by gitlabhq work in Ubuntu they do not always work in RHEL.
+
+## GitLab.com
+
+We've also detailed [our architecture of GitLab.com](https://about.gitlab.com/handbook/infrastructure/production-architecture/) but this is probably over the top unless you have millions of users.
diff --git a/doc/development/background_migrations.md b/doc/development/background_migrations.md
new file mode 100644
index 00000000000..f83a60e49e8
--- /dev/null
+++ b/doc/development/background_migrations.md
@@ -0,0 +1,259 @@
+# Background Migrations
+
+Background migrations can be used to perform data migrations that would
+otherwise take a very long time (hours, days, years, etc) to complete. For
+example, you can use background migrations to migrate data so that instead of
+storing data in a single JSON column the data is stored in a separate table.
+
+## When To Use Background Migrations
+
+>**Note:**
+When adding background migrations _you must_ make sure they are announced in the
+monthly release post along with an estimate of how long it will take to complete
+the migrations.
+
+In the vast majority of cases you will want to use a regular Rails migration
+instead. Background migrations should _only_ be used when migrating _data_ in
+tables that have so many rows this process would take hours when performed in a
+regular Rails migration.
+
+Background migrations _may not_ be used to perform schema migrations, they
+should only be used for data migrations.
+
+Some examples where background migrations can be useful:
+
+* Migrating events from one table to multiple separate tables.
+* Populating one column based on JSON stored in another column.
+* Migrating data that depends on the output of exernal services (e.g. an API).
+
+## Isolation
+
+Background migrations must be isolated and can not use application code (e.g.
+models defined in `app/models`). Since these migrations can take a long time to
+run it's possible for new versions to be deployed while they are still running.
+
+It's also possible for different migrations to be executed at the same time.
+This means that different background migrations should not migrate data in a
+way that would cause conflicts.
+
+## Idempotence
+
+Background migrations are executed in a context of a Sidekiq process.
+Usual Sidekiq rules apply, especially the rule that jobs should be small
+and idempotent.
+
+See [Sidekiq best practices guidelines](https://github.com/mperham/sidekiq/wiki/Best-Practices)
+for more details.
+
+Make sure that in case that your migration job is going to be retried data
+integrity is guarateed.
+
+## How It Works
+
+Background migrations are simple classes that define a `perform` method. A
+Sidekiq worker will then execute such a class, passing any arguments to it. All
+migration classes must be defined in the namespace
+`Gitlab::BackgroundMigration`, the files should be placed in the directory
+`lib/gitlab/background_migration/`.
+
+## Scheduling
+
+Scheduling a migration can be done in either a regular migration or a
+post-deployment migration. To do so, simply use the following code while
+replacing the class name and arguments with whatever values are necessary for
+your migration:
+
+```ruby
+BackgroundMigrationWorker.perform_async('BackgroundMigrationClassName', [arg1, arg2, ...])
+```
+
+Usually it's better to enqueue jobs in bulk, for this you can use
+`BackgroundMigrationWorker.perform_bulk`:
+
+```ruby
+BackgroundMigrationWorker.perform_bulk(
+ [['BackgroundMigrationClassName', [1]],
+ ['BackgroundMigrationClassName', [2]]]
+)
+```
+
+You'll also need to make sure that newly created data is either migrated, or
+saved in both the old and new version upon creation. For complex and time
+consuming migrations it's best to schedule a background job using an
+`after_create` hook so this doesn't affect response timings. The same applies to
+updates. Removals in turn can be handled by simply defining foreign keys with
+cascading deletes.
+
+If you would like to schedule jobs in bulk with a delay, you can use
+`BackgroundMigrationWorker.perform_bulk_in`:
+
+```ruby
+jobs = [['BackgroundMigrationClassName', [1]],
+ ['BackgroundMigrationClassName', [2]]]
+
+BackgroundMigrationWorker.perform_bulk_in(5.minutes, jobs)
+```
+
+## Cleaning Up
+
+>**Note:**
+Cleaning up any remaining background migrations _must_ be done in either a major
+or minor release, you _must not_ do this in a patch release.
+
+Because background migrations can take a long time you can't immediately clean
+things up after scheduling them. For example, you can't drop a column that's
+used in the migration process as this would cause jobs to fail. This means that
+you'll need to add a separate _post deployment_ migration in a future release
+that finishes any remaining jobs before cleaning things up (e.g. removing a
+column).
+
+As an example, say you want to migrate the data from column `foo` (containing a
+big JSON blob) to column `bar` (containing a string). The process for this would
+roughly be as follows:
+
+1. Release A:
+ 1. Create a migration class that perform the migration for a row with a given ID.
+ 1. Deploy the code for this release, this should include some code that will
+ schedule jobs for newly created data (e.g. using an `after_create` hook).
+ 1. Schedule jobs for all existing rows in a post-deployment migration. It's
+ possible some newly created rows may be scheduled twice so your migration
+ should take care of this.
+1. Release B:
+ 1. Deploy code so that the application starts using the new column and stops
+ scheduling jobs for newly created data.
+ 1. In a post-deployment migration you'll need to ensure no jobs remain. To do
+ so you can use `Gitlab::BackgroundMigration.steal` to process any remaining
+ jobs before continueing.
+ 1. Remove the old column.
+
+## Example
+
+To explain all this, let's use the following example: the table `services` has a
+field called `properties` which is stored in JSON. For all rows you want to
+extract the `url` key from this JSON object and store it in the `services.url`
+column. There are millions of services and parsing JSON is slow, thus you can't
+do this in a regular migration.
+
+To do this using a background migration we'll start with defining our migration
+class:
+
+```ruby
+class Gitlab::BackgroundMigration::ExtractServicesUrl
+ class Service < ActiveRecord::Base
+ self.table_name = 'services'
+ end
+
+ def perform(service_id)
+ # A row may be removed between scheduling and starting of a job, thus we
+ # need to make sure the data is still present before doing any work.
+ service = Service.select(:properties).find_by(id: service_id)
+
+ return unless service
+
+ begin
+ json = JSON.load(service.properties)
+ rescue JSON::ParserError
+ # If the JSON is invalid we don't want to keep the job around forever,
+ # instead we'll just leave the "url" field to whatever the default value
+ # is.
+ return
+ end
+
+ service.update(url: json['url']) if json['url']
+ end
+end
+```
+
+Next we'll need to adjust our code so we schedule the above migration for newly
+created and updated services. We can do this using something along the lines of
+the following:
+
+```ruby
+class Service < ActiveRecord::Base
+ after_commit :schedule_service_migration, on: :update
+ after_commit :schedule_service_migration, on: :create
+
+ def schedule_service_migration
+ BackgroundMigrationWorker.perform_async('ExtractServicesUrl', [id])
+ end
+end
+```
+
+We're using `after_commit` here to ensure the Sidekiq job is not scheduled
+before the transaction completes as doing so can lead to race conditions where
+the changes are not yet visible to the worker.
+
+Next we'll need a post-deployment migration that schedules the migration for
+existing data. Since we're dealing with a lot of rows we'll schedule jobs in
+batches instead of doing this one by one:
+
+```ruby
+class ScheduleExtractServicesUrl < ActiveRecord::Migration
+ disable_ddl_transaction!
+
+ class Service < ActiveRecord::Base
+ self.table_name = 'services'
+ end
+
+ def up
+ Service.select(:id).in_batches do |relation|
+ jobs = relation.pluck(:id).map do |id|
+ ['ExtractServicesUrl', [id]]
+ end
+
+ BackgroundMigrationWorker.perform_bulk(jobs)
+ end
+ end
+
+ def down
+ end
+end
+```
+
+Once deployed our application will continue using the data as before but at the
+same time will ensure that both existing and new data is migrated.
+
+In the next release we can remove the `after_commit` hooks and related code. We
+will also need to add a post-deployment migration that consumes any remaining
+jobs. Such a migration would look like this:
+
+```ruby
+class ConsumeRemainingExtractServicesUrlJobs < ActiveRecord::Migration
+ disable_ddl_transaction!
+
+ def up
+ Gitlab::BackgroundMigration.steal('ExtractServicesUrl')
+ end
+
+ def down
+ end
+end
+```
+
+This migration will then process any jobs for the ExtractServicesUrl migration
+and continue once all jobs have been processed. Once done you can safely remove
+the `services.properties` column.
+
+## Testing
+
+It is required to write tests for background migrations' scheduling migration
+(either a regular migration or a post deployment migration), background
+migration itself and a cleanup migration. You can use the `:migration` RSpec
+tag when testing a regular / post deployment migration.
+See [README][migrations-readme].
+
+When you do that, keep in mind that `before` and `after` RSpec hooks are going
+to migrate you database down and up, which can result in other background
+migrations being called. That means that using `spy` test doubles with
+`have_received` is encouraged, instead of using regular test doubles, because
+your expectations defined in a `it` block can conflict with what is being
+called in RSpec hooks. See [gitlab-org/gitlab-ce#35351][issue-rspec-hooks]
+for more details.
+
+## Best practices
+
+1. Make sure that background migration jobs are idempotent.
+1. Make sure that tests you write are not false positives.
+
+[migrations-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/migrations/README.md
+[issue-rspec-hooks]: https://gitlab.com/gitlab-org/gitlab-ce/issues/35351
diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md
new file mode 100644
index 00000000000..439d228baef
--- /dev/null
+++ b/doc/development/build_test_package.md
@@ -0,0 +1,39 @@
+# Building a package for testing
+
+While developing a new feature or modifying an existing one, it is helpful if an
+installable package (or a docker image) containing those changes is available
+for testing. For this very purpose, a manual job is provided in the GitLab CI/CD
+pipeline that can be used to trigger a pipeline in the omnibus-gitlab repository
+that will create
+1. A deb package for Ubuntu 16.04, available as a build artifact, and
+2. A docker image, which is pushed to [Omnibus GitLab's container
+registry](https://gitlab.com/gitlab-org/omnibus-gitlab/container_registry)
+(images titled `gitlab-ce` and `gitlab-ee` respectively and image tag is the
+commit which triggered the pipeline).
+
+When you push a commit to either the gitlab-ce or gitlab-ee project, the
+pipeline for that commit will have a `build-package` manual action you can
+trigger.
+
+![Manual actions](img/trigger_ss1.png)
+
+![Build package manual action](img/trigger_ss2.png)
+
+## Specifying versions of components
+
+If you want to create a package from a specific branch, commit or tag of any of
+the GitLab components (like GitLab Workhorse, Gitaly, GitLab Pages, etc.), you
+can specify the branch name, commit sha or tag in the component's respective
+`*_VERSION` file. For example, if you want to build a package that uses the
+branch `0-1-stable`, modify the content of `GITALY_SERVER_VERSION` to
+`0-1-stable` and push the commit. This will create a manual job that can be
+used to trigger the build.
+
+## Specifying the branch in omnibus-gitlab repository
+
+In scenarios where a configuration change is to be introduced and omnibus-gitlab
+repository already has the necessary changes in a specific branch, you can build
+a package against that branch through an environment variable named
+`OMNIBUS_BRANCH`. To do this, specify that environment variable with the name of
+the branch as value in `.gitlab-ci.yml` and push a commit. This will create a
+manual job that can be used to trigger the build.
diff --git a/doc/development/changelog.md b/doc/development/changelog.md
index ce39a379a0e..f869938fe11 100644
--- a/doc/development/changelog.md
+++ b/doc/development/changelog.md
@@ -15,11 +15,14 @@ following format:
title: "Going through change[log]s"
merge_request: 1972
author: Ozzy Osbourne
+type: added
```
The `merge_request` value is a reference to a merge request that adds this
entry, and the `author` key is used to give attribution to community
contributors. **Both are optional**.
+The `type` field maps the category of the change,
+valid options are: added, fixed, changed, deprecated, removed, security, other. **Type field is mandatory**.
Community contributors and core team members are encouraged to add their name to
the `author` field. GitLab team members **should not**.
@@ -94,6 +97,19 @@ Its simplest usage is to provide the value for `title`:
$ bin/changelog 'Hey DZ, I added a feature to GitLab!'
```
+At this point the script would ask you to select the category of the change (mapped to the `type` field in the entry):
+
+```text
+>> Please specify the category of your change:
+1. New feature
+2. Bug fix
+3. Feature change
+4. New deprecation
+5. Feature removal
+6. Security fix
+7. Other
+```
+
The entry filename is based on the name of the current Git branch. If you run
the command above on a branch called `feature/hey-dz`, it will generate a
`changelogs/unreleased/feature-hey-dz.yml` file.
@@ -106,26 +122,29 @@ create changelogs/unreleased/my-feature.yml
title: Hey DZ, I added a feature to GitLab!
merge_request:
author:
+type:
```
If you're working on the GitLab EE repository, the entry will be added to
`changelogs/unreleased-ee/` instead.
#### Arguments
-| Argument | Shorthand | Purpose |
-| ----------------- | --------- | --------------------------------------------- |
-| [`--amend`] | | Amend the previous commit |
-| [`--force`] | `-f` | Overwrite an existing entry |
-| [`--merge-request`] | `-m` | Set merge request ID |
-| [`--dry-run`] | `-n` | Don't actually write anything, just print |
-| [`--git-username`] | `-u` | Use Git user.name configuration as the author |
-| [`--help`] | `-h` | Print help message |
+| Argument | Shorthand | Purpose |
+| ----------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
+| [`--amend`] | | Amend the previous commit |
+| [`--force`] | `-f` | Overwrite an existing entry |
+| [`--merge-request`] | `-m` | Set merge request ID |
+| [`--dry-run`] | `-n` | Don't actually write anything, just print |
+| [`--git-username`] | `-u` | Use Git user.name configuration as the author |
+| [`--type`] | `-t` | The category of the change, valid options are: added, fixed, changed, deprecated, removed, security, other |
+| [`--help`] | `-h` | Print help message |
[`--amend`]: #-amend
[`--force`]: #-force-or-f
[`--merge-request`]: #-merge-request-or-m
[`--dry-run`]: #-dry-run-or-n
[`--git-username`]: #-git-username-or-u
+[`--type`]: #-type-or-t
[`--help`]: #-help
##### `--amend`
@@ -147,6 +166,7 @@ create changelogs/unreleased/feature-hey-dz.yml
title: Added an awesome new feature to GitLab
merge_request:
author:
+type:
```
##### `--force` or `-f`
@@ -164,6 +184,7 @@ create changelogs/unreleased/feature-hey-dz.yml
title: Hey DZ, I added a feature to GitLab!
merge_request: 1983
author:
+type:
```
##### `--merge-request` or `-m`
@@ -178,6 +199,7 @@ create changelogs/unreleased/feature-hey-dz.yml
title: Hey DZ, I added a feature to GitLab!
merge_request: 1983
author:
+type:
```
##### `--dry-run` or `-n`
@@ -192,6 +214,7 @@ create changelogs/unreleased/feature-hey-dz.yml
title: Added an awesome new feature to GitLab
merge_request:
author:
+type:
$ ls changelogs/unreleased/
```
@@ -211,6 +234,21 @@ create changelogs/unreleased/feature-hey-dz.yml
title: Hey DZ, I added a feature to GitLab!
merge_request:
author: Jane Doe
+type:
+```
+
+##### `--type` or `-t`
+
+Use the **`--type`** or **`-t`** argument to provide the `type` value:
+
+```text
+$ bin/changelog 'Hey DZ, I added a feature to GitLab!' -t added
+create changelogs/unreleased/feature-hey-dz.yml
+---
+title: Hey DZ, I added a feature to GitLab!
+merge_request:
+author:
+type: added
```
### History and Reasoning
diff --git a/doc/development/code_review.md b/doc/development/code_review.md
index 819578404b6..64a89976300 100644
--- a/doc/development/code_review.md
+++ b/doc/development/code_review.md
@@ -1,5 +1,29 @@
# Code Review Guidelines
+## Getting your merge request reviewed, approved, and merged
+
+There are a few rules to get your merge request accepted:
+
+1. Your merge request should only be **merged by a [maintainer][team]**.
+ 1. If your merge request includes only backend changes [^1], it must be
+ **approved by a [backend maintainer][projects]**.
+ 1. If your merge request includes only frontend changes [^1], it must be
+ **approved by a [frontend maintainer][projects]**.
+ 1. If your merge request includes frontend and backend changes [^1], it must
+ be **approved by a [frontend and a backend maintainer][projects]**.
+ 1. If your merge request includes a new dependency or a filesystem change, it must
+ be **approved by a [Build team member][team]**. See [how to work with the Build team][build handbook] for more details.
+1. To lower the amount of merge requests maintainers need to review, you can
+ ask or assign any [reviewers][projects] for a first review.
+ 1. If you need some guidance (e.g. it's your first merge request), feel free
+ to ask one of the [Merge request coaches][team].
+ 1. The reviewer will assign the merge request to a maintainer once the
+ reviewer is satisfied with the state of the merge request.
+
+For more guidance, see [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md).
+
+## Best practices
+
This guide contains advice and best practices for performing code review, and
having your code reviewed.
@@ -10,9 +34,9 @@ code is effective, understandable, and maintainable.
Any developer can, and is encouraged to, perform code review on merge requests
of colleagues and contributors. However, the final decision to accept a merge
request is up to one the project's maintainers, denoted on the
-[team page](https://about.gitlab.com/team).
+[engineering projects][projects].
-## Everyone
+### Everyone
- Accept that many programming decisions are opinions. Discuss tradeoffs, which
you prefer, and reach a resolution quickly.
@@ -31,8 +55,11 @@ request is up to one the project's maintainers, denoted on the
- Consider one-on-one chats or video calls if there are too many "I didn't
understand" or "Alternative solution:" comments. Post a follow-up comment
summarizing one-on-one discussion.
+- If you ask a question to a specific person, always start the comment by
+ mentioning them; this will ensure they see it if their notification level is
+ set to "mentioned" and other people will understand they don't have to respond.
-## Having your code reviewed
+### Having your code reviewed
Please keep in mind that code review is a process that can take multiple
iterations, and reviewers may spot things later that they may not have seen the
@@ -50,11 +77,12 @@ first time.
- Extract unrelated changes and refactorings into future merge requests/issues.
- Seek to understand the reviewer's perspective.
- Try to respond to every comment.
+- Let the reviewer select the "Resolve discussion" buttons.
- Push commits based on earlier rounds of feedback as isolated commits to the
branch. Do not squash until the branch is ready to merge. Reviewers should be
able to read individual updates based on their earlier feedback.
-## Reviewing code
+### Reviewing code
Understand why the change is necessary (fixes a bug, improves the user
experience, refactors the existing code). Then:
@@ -69,12 +97,19 @@ experience, refactors the existing code). Then:
someone else would be confused by it as well.
- After a round of line notes, it can be helpful to post a summary note such as
"LGTM :thumbsup:", or "Just a couple things to address."
+- Assign the merge request to the author if changes are required following your
+ review.
+- Set the milestone before merging a merge request.
- Avoid accepting a merge request before the job succeeds. Of course, "Merge
When Pipeline Succeeds" (MWPS) is fine.
- If you set the MR to "Merge When Pipeline Succeeds", you should take over
subsequent revisions for anything that would be spotted after that.
+- Consider using the [Squash and
+ merge][squash-and-merge] feature when the merge request has a lot of commits.
+
+[squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge
-## The right balance
+### The right balance
One of the most difficult things during code review is finding the right
balance in how deep the reviewer can interfere with the code created by a
@@ -100,7 +135,56 @@ reviewee.
tomorrow. When you are not able to find the right balance, ask other people
about their opinion.
-## Credits
+### GitLab-specific concerns
+
+GitLab is used in a lot of places. Many users use
+our [Omnibus packages](https://about.gitlab.com/installation/), but some use
+the [Docker images](https://docs.gitlab.com/omnibus/docker/), some are
+[installed from source](https://docs.gitlab.com/ce/install/installation.html),
+and there are other installation methods available. GitLab.com itself is a large
+Enterprise Edition instance. This has some implications:
+
+1. **Query changes** should be tested to ensure that they don't result in worse
+ performance at the scale of GitLab.com:
+ 1. Generating large quantities of data locally can help.
+ 2. Asking for query plans from GitLab.com is the most reliable way to validate
+ these.
+2. **Database migrations** must be:
+ 1. Reversible.
+ 2. Performant at the scale of GitLab.com - ask a maintainer to test the
+ migration on the staging environment if you aren't sure.
+ 3. Categorised correctly:
+ - Regular migrations run before the new code is running on the instance.
+ - [Post-deployment migrations](post_deployment_migrations.md) run _after_
+ the new code is deployed, when the instance is configured to do that.
+ - [Background migrations](background_migrations.md) run in Sidekiq, and
+ should only be done for migrations that would take an extreme amount of
+ time at GitLab.com scale.
+3. **Sidekiq workers**
+ [cannot change in a backwards-incompatible way](sidekiq_style_guide.md#removing-or-renaming-queues):
+ 1. Sidekiq queues are not drained before a deploy happens, so there will be
+ workers in the queue from the previous version of GitLab.
+ 2. If you need to change a method signature, try to do so across two releases,
+ and accept both the old and new arguments in the first of those.
+ 3. Similarly, if you need to remove a worker, stop it from being scheduled in
+ one release, then remove it in the next. This will allow existing jobs to
+ execute.
+ 4. Don't forget, not every instance will upgrade to every intermediate version
+ (some people may go from X.1.0 to X.10.0, or even try bigger upgrades!), so
+ try to be liberal in accepting the old format if it is cheap to do so.
+4. **Cached values** may persist across releases. If you are changing the type a
+ cached value returns (say, from a string or nil to an array), change the
+ cache key at the same time.
+5. **Settings** should be added as a
+ [last resort](https://about.gitlab.com/handbook/product/#convention-over-configuration).
+ If you're adding a new setting in `gitlab.yml`:
+ 1. Try to avoid that, and add to `ApplicationSetting` instead.
+ 2. Ensure that it is also
+ [added to Omnibus](https://docs.gitlab.com/omnibus/settings/gitlab.yml.html#adding-a-new-setting-to-gitlab-yml).
+6. **Filesystem access** can be slow, so try to avoid
+ [shared files](shared_files.md) when an alternative solution is available.
+
+### Credits
Largely based on the [thoughtbot code review guide].
@@ -109,3 +193,7 @@ Largely based on the [thoughtbot code review guide].
---
[Return to Development documentation](README.md)
+
+[projects]: https://about.gitlab.com/handbook/engineering/projects/
+[team]: https://about.gitlab.com/team/
+[build handbook]: https://about.gitlab.com/handbook/build/handbook/build#how-to-work-with-build
diff --git a/doc/development/database_merge_request_checklist.md b/doc/development/database_merge_request_checklist.md
new file mode 100644
index 00000000000..75c395b61ef
--- /dev/null
+++ b/doc/development/database_merge_request_checklist.md
@@ -0,0 +1,15 @@
+# Merge Request Checklist
+
+When creating a merge request that performs database related changes (schema
+changes, adjusting queries to optimise performance, etc) you should use the
+merge request template called "Database Changes". This template contains a
+checklist of steps to follow to make sure the changes are up to snuff.
+
+To use the checklist, create a new merge request and click on the "Choose a
+template" dropdown, then click "Database Changes".
+
+An example of this checklist can be found at
+https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12463.
+
+The source code of the checklist can be found in at
+https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab/merge_request_templates/Database%20Changes.md
diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md
index 1e81905c081..90d1d9657b9 100644
--- a/doc/development/doc_styleguide.md
+++ b/doc/development/doc_styleguide.md
@@ -105,8 +105,8 @@ merge request.
considered beta or experimental, put this info in a note, not in the heading.
- When introducing a new document, be careful for the headings to be
grammatically and syntactically correct. It is advised to mention one or all
- of the following GitLab members for a review: `@axil`, `@rspeicher`, `@marcia`,
- `@SeanPackham`. This is to ensure that no document with wrong heading is going
+ of the following GitLab members for a review: `@axil`, `@rspeicher`, `@marcia`.
+ This is to ensure that no document with wrong heading is going
live without an audit, thus preventing dead links and redirection issues when
corrected
- Leave exactly one newline after a heading
@@ -198,10 +198,17 @@ You can combine one or more of the following:
the `.md` document that you're working on is located. Always prepend their
names with the name of the document that they will be included in. For
example, if there is a document called `twitter.md`, then a valid image name
- could be `twitter_login_screen.png`.
+ could be `twitter_login_screen.png`. [**Exception**: images for
+ [articles](writing_documentation.md#technical-articles) should be
+ put in a directory called `img` underneath `/articles/article_title/img/`, therefore,
+ there's no need to prepend the document name to their filenames.]
- Images should have a specific, non-generic name that will differentiate them.
- Keep all file names in lower case.
- Consider using PNG images instead of JPEG.
+- Compress all images with <https://tinypng.com/> or similar tool.
+- Compress gifs with <https://ezgif.com/optimize> or similar toll.
+- Images should be used (only when necessary) to _illustrate_ the description
+of a process, not to _replace_ it.
Inside the document:
@@ -381,8 +388,8 @@ the style below as a guide:
1. Save the file and [restart] GitLab for the changes to take effect.
-[reconfigure]: path/to/administration/gitlab_restart.md#omnibus-gitlab-reconfigure
-[restart]: path/to/administration/gitlab_restart.md#installations-from-source
+[reconfigure]: path/to/administration/restart_gitlab.md#omnibus-gitlab-reconfigure
+[restart]: path/to/administration/restart_gitlab.md#installations-from-source
````
In this case:
diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md
index 8f0b6b21953..112ff3419d9 100644
--- a/doc/development/fe_guide/droplab/droplab.md
+++ b/doc/development/fe_guide/droplab/droplab.md
@@ -183,6 +183,8 @@ For example,
either by a mouse click or by enter key selection.
* The `droplab-item-active` css class is added to items that have been selected
using arrow key navigation.
+* You can add the `droplab-item-ignore` css class to any item that you do not want to be selectable. For example,
+an `<li class="divider"></li>` list divider element that should not be interactive.
## Internal events
diff --git a/doc/development/fe_guide/droplab/plugins/input_setter.md b/doc/development/fe_guide/droplab/plugins/input_setter.md
index a549603c20d..94f3c8254a8 100644
--- a/doc/development/fe_guide/droplab/plugins/input_setter.md
+++ b/doc/development/fe_guide/droplab/plugins/input_setter.md
@@ -1,6 +1,6 @@
# InputSetter
-`InputSetter` is a plugin that allows for udating DOM out of the scope of droplab when a list item is clicked.
+`InputSetter` is a plugin that allows for updating DOM out of the scope of droplab when a list item is clicked.
## Usage
diff --git a/doc/development/fe_guide/img/testing_triangle.png b/doc/development/fe_guide/img/testing_triangle.png
new file mode 100644
index 00000000000..7a9a848c2ee
--- /dev/null
+++ b/doc/development/fe_guide/img/testing_triangle.png
Binary files differ
diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md
index a08694fb66a..64bcb4a0257 100644
--- a/doc/development/fe_guide/index.md
+++ b/doc/development/fe_guide/index.md
@@ -45,15 +45,11 @@ should be `new-feature`.
branch from `new-feature`, let's call it `new-feature-step-2` and repeat the process done before.
```shell
-* master
-|\
-| * new-feature
-| |\
-| | * new-feature-step-1
-| |\
-| | * new-feature-step-2
-| |\
-| | * new-feature-step-3
+master
+└─ new-feature
+ ├─ new-feature-step-1
+ ├─ new-feature-step-2
+ └─ new-feature-step-3
```
**Tips**
diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md
index 2ddcbe13afa..14ac1133cc0 100644
--- a/doc/development/fe_guide/performance.md
+++ b/doc/development/fe_guide/performance.md
@@ -23,6 +23,19 @@ controlled by the server.
1. The backend code will most likely be using etags. You do not and should not check for status
`304 Not Modified`. The browser will transform it for you.
+### Lazy Loading
+
+To improve the time to first render we are using lazy loading for images. This works by setting
+the actual image source on the `data-src` attribute. After the HTML is rendered and JavaScript is loaded,
+the value of `data-src` will be moved to `src` automatically if the image is in the current viewport.
+
+* Prepare images in HTML for lazy loading by renaming the `src` attribute to `data-src` AND adding the class `lazy`
+* If you are using the Rails `image_tag` helper, all images will be lazy-loaded by default unless `lazy: false` is provided.
+
+If you are asynchronously adding content which contains lazy images then you need to call the function
+`gl.lazyLoader.searchLazyImages()` which will search for lazy images and load them if needed.
+But in general it should be handled automatically through a `MutationObserver` in the lazy loading function.
+
## Reducing Asset Footprint
### Page-specific JavaScript
diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md
index 1d2b0558948..9c72fda0229 100644
--- a/doc/development/fe_guide/style_guide_js.md
+++ b/doc/development/fe_guide/style_guide_js.md
@@ -11,207 +11,223 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
#### ESlint
-- **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules.
-
-- **Never Ever EVER** disable eslint globally for a file
+1. **Never** disable eslint rules unless you have a good reason.
+You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */`
+at the top, but legacy files are a special case. Any time you develop a new feature or
+refactor an existing one, you should abide by the eslint rules.
+1. **Never Ever EVER** disable eslint globally for a file
```javascript
- // bad
- /* eslint-disable */
+ // bad
+ /* eslint-disable */
- // better
- /* eslint-disable some-rule, some-other-rule */
+ // better
+ /* eslint-disable some-rule, some-other-rule */
- // best
- // nothing :)
+ // best
+ // nothing :)
```
-- If you do need to disable a rule for a single violation, try to do it as locally as possible
-
+1. If you do need to disable a rule for a single violation, try to do it as locally as possible
```javascript
- // bad
- /* eslint-disable no-new */
+ // bad
+ /* eslint-disable no-new */
- import Foo from 'foo';
+ import Foo from 'foo';
- new Foo();
+ new Foo();
- // better
- import Foo from 'foo';
+ // better
+ import Foo from 'foo';
- // eslint-disable-next-line no-new
- new Foo();
+ // eslint-disable-next-line no-new
+ new Foo();
```
+1. There are few rules that we need to disable due to technical debt. Which are:
+ 1. [no-new][eslint-new]
+ 1. [class-methods-use-this][eslint-this]
-- When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code.
-
+1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script,
+followed by any global declarations, then a blank newline prior to any imports or code.
```javascript
- // bad
- /* global Foo */
- /* eslint-disable no-new */
- import Bar from './bar';
+ // bad
+ /* global Foo */
+ /* eslint-disable no-new */
+ import Bar from './bar';
- // good
- /* eslint-disable no-new */
- /* global Foo */
+ // good
+ /* eslint-disable no-new */
+ /* global Foo */
- import Bar from './bar';
+ import Bar from './bar';
```
-- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead.
-
-- When declaring multiple globals, always use one `/* global [name] */` line per variable.
+1. **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead.
+1. When declaring multiple globals, always use one `/* global [name] */` line per variable.
```javascript
- // bad
- /* globals Flash, Cookies, jQuery */
+ // bad
+ /* globals Flash, Cookies, jQuery */
- // good
- /* global Flash */
- /* global Cookies */
- /* global jQuery */
+ // good
+ /* global Flash */
+ /* global Cookies */
+ /* global jQuery */
```
-
-- Use up to 3 parameters for a function or class. If you need more accept an Object instead.
+1. Use up to 3 parameters for a function or class. If you need more accept an Object instead.
```javascript
- // bad
- fn(p1, p2, p3, p4) {}
+ // bad
+ fn(p1, p2, p3, p4) {}
- // good
- fn(options) {}
+ // good
+ fn(options) {}
```
#### Modules, Imports, and Exports
-- Use ES module syntax to import modules
-
+1. Use ES module syntax to import modules
```javascript
- // bad
- require('foo');
+ // bad
+ require('foo');
- // good
- import Foo from 'foo';
+ // good
+ import Foo from 'foo';
- // bad
- module.exports = Foo;
+ // bad
+ module.exports = Foo;
- // good
- export default Foo;
+ // good
+ export default Foo;
```
-- Relative paths
+1. Relative paths: when importing a module in the same directory, a child
+directory, or an immediate parent directory prefer relative paths. When
+importing a module which is two or more levels up, prefer either `~/` or `ee/`
+.
- Unless you are writing a test, always reference other scripts using relative paths instead of `~`
+In **app/assets/javascripts/my-feature/subdir**:
- In **app/assets/javascripts**:
- ```javascript
- // bad
- import Foo from '~/foo'
+``` javascript
+// bad
+import Foo from '~/my-feature/foo';
+import Bar from '~/my-feature/subdir/bar';
+import Bin from '~/my-feature/subdir/lib/bin';
- // good
- import Foo from '../foo';
- ```
+// good
+import Foo from '../foo';
+import Bar from './bar';
+import Bin from './lib/bin';
+```
- In **spec/javascripts**:
- ```javascript
- // bad
- import Foo from '../../app/assets/javascripts/foo'
+In **spec/javascripts**:
- // good
- import Foo from '~/foo';
- ```
+``` javascript
+// bad
+import Foo from '../../app/assets/javascripts/my-feature/foo';
+
+// good
+import Foo from '~/my-feature/foo';
+```
-- Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code.
+When referencing an **EE component**:
-- Avoid adding to the global namespace.
+``` javascript
+// bad
+import Foo from '../../../../../ee/app/assets/javascripts/my-feature/ee-foo';
+// good
+import Foo from 'ee/my-feature/foo';
+```
+
+1. Avoid using IIFE. Although we have a lot of examples of files which wrap their
+contents in IIFEs (immediately-invoked function expressions),
+this is no longer necessary after the transition from Sprockets to webpack.
+Do not use them anymore and feel free to remove them when refactoring legacy code.
+
+1. Avoid adding to the global namespace.
```javascript
- // bad
- window.MyClass = class { /* ... */ };
+ // bad
+ window.MyClass = class { /* ... */ };
- // good
- export default class MyClass { /* ... */ }
+ // good
+ export default class MyClass { /* ... */ }
```
-- Side effects are forbidden in any script which contains exports
-
+1. Side effects are forbidden in any script which contains exports
```javascript
- // bad
- export default class MyClass { /* ... */ }
+ // bad
+ export default class MyClass { /* ... */ }
- document.addEventListener("DOMContentLoaded", function(event) {
- new MyClass();
- }
+ document.addEventListener("DOMContentLoaded", function(event) {
+ new MyClass();
+ }
```
#### Data Mutation and Pure functions
-- Strive to write many small pure functions, and minimize where mutations occur.
-
+1. Strive to write many small pure functions, and minimize where mutations occur.
```javascript
- // bad
- const values = {foo: 1};
+ // bad
+ const values = {foo: 1};
- function impureFunction(items) {
- const bar = 1;
+ function impureFunction(items) {
+ const bar = 1;
- items.foo = items.a * bar + 2;
+ items.foo = items.a * bar + 2;
- return items.a;
- }
+ return items.a;
+ }
- const c = impureFunction(values);
+ const c = impureFunction(values);
- // good
- var values = {foo: 1};
+ // good
+ var values = {foo: 1};
- function pureFunction (foo) {
- var bar = 1;
+ function pureFunction (foo) {
+ var bar = 1;
- foo = foo * bar + 2;
+ foo = foo * bar + 2;
- return foo;
- }
+ return foo;
+ }
- var c = pureFunction(values.foo);
+ var c = pureFunction(values.foo);
```
-- Avoid constructors with side-effects
+1. Avoid constructors with side-effects
-- Prefer `.map`, `.reduce` or `.filter` over `.forEach`
+1. Prefer `.map`, `.reduce` or `.filter` over `.forEach`
A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`,
`.reduce` or `.filter`
-
```javascript
- const users = [ { name: 'Foo' }, { name: 'Bar' } ];
+ const users = [ { name: 'Foo' }, { name: 'Bar' } ];
- // bad
- users.forEach((user, index) => {
- user.id = index;
- });
+ // bad
+ users.forEach((user, index) => {
+ user.id = index;
+ });
- // good
- const usersWithId = users.map((user, index) => {
- return Object.assign({}, user, { id: index });
- });
+ // good
+ const usersWithId = users.map((user, index) => {
+ return Object.assign({}, user, { id: index });
+ });
```
#### Parse Strings into Numbers
-- `parseInt()` is preferable over `Number()` or `+`
-
+1. `parseInt()` is preferable over `Number()` or `+`
```javascript
- // bad
- +'10' // 10
+ // bad
+ +'10' // 10
- // good
- Number('10') // 10
+ // good
+ Number('10') // 10
- // better
- parseInt('10', 10);
+ // better
+ parseInt('10', 10);
```
#### CSS classes used for JavaScript
-- If the class is being used in Javascript it needs to be prepend with `js-`
+1. If the class is being used in Javascript it needs to be prepend with `js-`
```html
// bad
<button class="add-user">
@@ -226,238 +242,298 @@ A forEach will cause side effects, it will be mutating the array being iterated.
### Vue.js
-
#### Basic Rules
-- Only include one Vue.js component per file.
-- Export components as plain objects:
-
+1. The service has it's own file
+1. The store has it's own file
+1. Use a function in the bundle file to instantiate the Vue component:
```javascript
- export default {
- template: `<h1>I'm a component</h1>
- }
- ```
+ // bad
+ class {
+ init() {
+ new Component({})
+ }
+ }
-#### Naming
-- **Extensions**: Use `.vue` extension for Vue components.
-- **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances:
+ // good
+ document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#element',
+ components: {
+ componentName
+ },
+ render: createElement => createElement('component-name'),
+ }));
+ ```
+1. Don not use a singleton for the service or the store
```javascript
- // bad
- import cardBoard from 'cardBoard';
+ // bad
+ class Store {
+ constructor() {
+ if (!this.prototype.singleton) {
+ // do something
+ }
+ }
+ }
- // good
- import CardBoard from 'cardBoard'
+ // good
+ class Store {
+ constructor() {
+ // do something
+ }
+ }
+ ```
- // bad
- components: {
- CardBoard: CardBoard
- };
+#### Naming
+1. **Extensions**: Use `.vue` extension for Vue components.
+1. **Reference Naming**: Use camelCase for their instances:
+ ```javascript
+ // good
+ import cardBoard from 'cardBoard'
- // good
- components: {
- cardBoard: CardBoard
- };
+ components: {
+ cardBoard:
+ };
```
-- **Props Naming:**
-- Avoid using DOM component prop names.
-- Use kebab-case instead of camelCase to provide props in templates.
-
+1. **Props Naming:** Avoid using DOM component prop names.
+1. **Props Naming:** Use kebab-case instead of camelCase to provide props in templates.
```javascript
- // bad
- <component class="btn">
+ // bad
+ <component class="btn">
- // good
- <component css-class="btn">
+ // good
+ <component css-class="btn">
- // bad
- <component myProp="prop" />
+ // bad
+ <component myProp="prop" />
- // good
- <component my-prop="prop" />
-```
+ // good
+ <component my-prop="prop" />
+ ```
#### Alignment
-- Follow these alignment styles for the template method:
-
+1. Follow these alignment styles for the template method:
```javascript
- // bad
- <component v-if="bar"
- param="baz" />
+ // bad
+ <component v-if="bar"
+ param="baz" />
- <button class="btn">Click me</button>
+ <button class="btn">Click me</button>
- // good
- <component
- v-if="bar"
- param="baz"
- />
+ // good
+ <component
+ v-if="bar"
+ param="baz"
+ />
- <button class="btn">
- Click me
- </button>
+ <button class="btn">
+ Click me
+ </button>
- // if props fit in one line then keep it on the same line
- <component bar="bar" />
+ // if props fit in one line then keep it on the same line
+ <component bar="bar" />
```
#### Quotes
-- Always use double quotes `"` inside templates and single quotes `'` for all other JS.
-
+1. Always use double quotes `"` inside templates and single quotes `'` for all other JS.
```javascript
- // bad
- template: `
- <button :class='style'>Button</button>
- `
-
- // good
- template: `
- <button :class="style">Button</button>
- `
+ // bad
+ template: `
+ <button :class='style'>Button</button>
+ `
+
+ // good
+ template: `
+ <button :class="style">Button</button>
+ `
```
#### Props
-- Props should be declared as an object
-
+1. Props should be declared as an object
```javascript
- // bad
- props: ['foo']
-
- // good
- props: {
- foo: {
- type: String,
- required: false,
- default: 'bar'
+ // bad
+ props: ['foo']
+
+ // good
+ props: {
+ foo: {
+ type: String,
+ required: false,
+ default: 'bar'
+ }
}
- }
```
-- Required key should always be provided when declaring a prop
-
+1. Required key should always be provided when declaring a prop
```javascript
- // bad
- props: {
- foo: {
- type: String,
+ // bad
+ props: {
+ foo: {
+ type: String,
+ }
}
- }
-
- // good
- props: {
- foo: {
- type: String,
- required: false,
- default: 'bar'
+
+ // good
+ props: {
+ foo: {
+ type: String,
+ required: false,
+ default: 'bar'
+ }
}
- }
```
-- Default key should always be provided if the prop is not required:
-
+1. Default key should always be provided if the prop is not required:
```javascript
- // bad
- props: {
- foo: {
- type: String,
- required: false,
+ // bad
+ props: {
+ foo: {
+ type: String,
+ required: false,
+ }
}
- }
-
- // good
- props: {
- foo: {
- type: String,
- required: false,
- default: 'bar'
+
+ // good
+ props: {
+ foo: {
+ type: String,
+ required: false,
+ default: 'bar'
+ }
}
- }
- // good
- props: {
- foo: {
- type: String,
- required: true
+ // good
+ props: {
+ foo: {
+ type: String,
+ required: true
+ }
}
- }
```
#### Data
-- `data` method should always be a function
+1. `data` method should always be a function
```javascript
- // bad
- data: {
- foo: 'foo'
- }
-
- // good
- data() {
- return {
+ // bad
+ data: {
foo: 'foo'
- };
- }
+ }
+
+ // good
+ data() {
+ return {
+ foo: 'foo'
+ };
+ }
```
#### Directives
-- Shorthand `@` is preferable over `v-on`
-
+1. Shorthand `@` is preferable over `v-on`
```javascript
- // bad
- <component v-on:click="eventHandler"/>
+ // bad
+ <component v-on:click="eventHandler"/>
- // good
- <component @click="eventHandler"/>
+ // good
+ <component @click="eventHandler"/>
```
-- Shorthand `:` is preferable over `v-bind`
-
+1. Shorthand `:` is preferable over `v-bind`
```javascript
- // bad
- <component v-bind:class="btn"/>
+ // bad
+ <component v-bind:class="btn"/>
- // good
- <component :class="btn"/>
+ // good
+ <component :class="btn"/>
```
#### Closing tags
-- Prefer self closing component tags
-
+1. Prefer self closing component tags
```javascript
- // bad
- <component></component>
+ // bad
+ <component></component>
- // good
- <component />
+ // good
+ <component />
```
#### Ordering
-- Order for a Vue Component:
+1. Order for a Vue Component:
1. `name`
- 2. `props`
- 3. `data`
- 4. `components`
- 5. `computedProps`
- 6. `methods`
- 7. lifecycle methods
- 1. `beforeCreate`
- 2. `created`
- 3. `beforeMount`
- 4. `mounted`
- 5. `beforeUpdate`
- 6. `updated`
- 7. `activated`
- 8. `deactivated`
- 9. `beforeDestroy`
- 10. `destroyed`
- 8. `template`
+ 1. `props`
+ 1. `mixins`
+ 1. `directives`
+ 1. `data`
+ 1. `components`
+ 1. `computedProps`
+ 1. `methods`
+ 1. `beforeCreate`
+ 1. `created`
+ 1. `beforeMount`
+ 1. `mounted`
+ 1. `beforeUpdate`
+ 1. `updated`
+ 1. `activated`
+ 1. `deactivated`
+ 1. `beforeDestroy`
+ 1. `destroyed`
+
+#### Vue and Boostrap
+1. Tooltips: Do not rely on `has-tooltip` class name for Vue components
+ ```javascript
+ // bad
+ <span
+ class="has-tooltip"
+ title="Some tooltip text">
+ Text
+ </span>
+
+ // good
+ <span
+ v-tooltip
+ title="Some tooltip text">
+ Text
+ </span>
+ ```
+
+1. Tooltips: When using a tooltip, include the tooltip directive, `./app/assets/javascripts/vue_shared/directives/tooltip.js`
+
+1. Don't change `data-original-title`.
+ ```javascript
+ // bad
+ <span data-original-title="tooltip text">Foo</span>
+
+ // good
+ <span title="tooltip text">Foo</span>
+
+ $('span').tooltip('fixTitle');
+ ```
+### The Javascript/Vue Accord
+The goal of this accord is to make sure we are all on the same page.
+
+1. When writing Vue, you may not use jQuery in your application.
+1.1 If you need to grab data from the DOM, you may query the DOM 1 time while bootstrapping your application to grab data attributes using `dataset`. You can do this without jQuery.
+1.2 You may use a jQuery dependency in Vue.js following [this example from the docs](https://vuejs.org/v2/examples/select2.html).
+1.3 If an outside jQuery Event needs to be listen to inside the Vue application, you may use jQuery event listeners.
+1.4 We will avoid adding new jQuery events when they are not required. Instead of adding new jQuery events take a look at [different methods to do the same task](https://vuejs.org/v2/api/#vm-emit).
+
+1. You may query the `window` object 1 time, while bootstrapping your application for application specific data (e.g. `scrollTo` is ok to access anytime). Do this access during the bootstrapping of your application.
+
+1. You may have a temporary but immediate need to create technical debt by writing code that does not follow our standards, to be refactored later. Maintainers need to be ok with the tech debt in the first place. An issue should be created for that tech debt to evaluate it further and discuss. In the coming months you should fix that tech debt, with it's priority to be determined by maintainers.
+
+1. When creating tech debt you must write the tests for that code before hand and those tests may not be rewritten. e.g. jQuery tests rewritten to Vue tests.
+
+1. You may choose to use VueX as a centralized state management. If you choose not to use VueX, you must use the *store pattern* which can be found in the [Vue.js documentation](https://vuejs.org/v2/guide/state-management.html#Simple-State-Management-from-Scratch).
+1. Once you have chosen a centralized state management solution you must use it for your entire application. i.e. Don't mix and match your state management solutions.
## SCSS
- [SCSS](style_guide_scss.md)
[airbnb-js-style-guide]: https://github.com/airbnb/javascript
[eslintrc]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.eslintrc
+[eslint-this]: http://eslint.org/docs/rules/class-methods-use-this
+[eslint-new]: http://eslint.org/docs/rules/no-new
diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md
index 157c13352ca..867c83f1e72 100644
--- a/doc/development/fe_guide/testing.md
+++ b/doc/development/fe_guide/testing.md
@@ -1,11 +1,20 @@
# Frontend Testing
-There are two types of tests you'll encounter while developing frontend code
-at GitLab. We use Karma and Jasmine for JavaScript unit testing, and RSpec
-feature tests with Capybara for integration testing.
+There are two types of test suites you'll encounter while developing frontend code
+at GitLab. We use Karma and Jasmine for JavaScript unit and integration testing, and RSpec
+feature tests with Capybara for e2e (end-to-end) integration testing.
-Feature tests need to be written for all new features. Regression tests ought
-to be written for all bug fixes to prevent them from recurring in the future.
+Unit and feature tests need to be written for all new features.
+Most of the time, you should use rspec for your feature tests.
+There are cases where the behaviour you are testing is not worth the time spent running the full application,
+for example, if you are testing styling, animation, edge cases or small actions that don't involve the backend,
+you should write an integration test using Jasmine.
+
+![Testing priority triangle](img/testing_triangle.png)
+
+_This diagram demonstrates the relative priority of each test type we use_
+
+Regression tests should be written for bug fixes to prevent them from recurring in the future.
See [the Testing Standards and Style Guidelines](../testing.md)
for more information on general testing practices at GitLab.
@@ -13,10 +22,12 @@ for more information on general testing practices at GitLab.
## Karma test suite
GitLab uses the [Karma][karma] test runner with [Jasmine][jasmine] as its test
-framework for our JavaScript unit tests. For tests that rely on DOM
-manipulation, we generate HTML files using RSpec suites (see `spec/javascripts/fixtures/*.rb` for examples).
+framework for our JavaScript unit and integration tests. For integration tests,
+we generate HTML files using RSpec (see `spec/javascripts/fixtures/*.rb` for examples).
Some fixtures are still HAML templates that are translated to HTML files using the same mechanism (see `static_fixtures.rb`).
-Those will be migrated over time.
+Adding these static fixtures should be avoided as they are harder to keep up to date with real views.
+The existing static fixtures will be migrated over time.
+Please see [gitlab-org/gitlab-ce#24753](https://gitlab.com/gitlab-org/gitlab-ce/issues/24753) to track our progress.
Fixtures are served during testing by the [jasmine-jquery][jasmine-jquery] plugin.
JavaScript tests live in `spec/javascripts/`, matching the folder structure
@@ -28,7 +39,107 @@ browser and you will not have access to certain APIs, such as
[`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification),
which will have to be stubbed.
-### Writing tests
+### Best practice
+
+#### Naming unit tests
+
+When writing describe test blocks to test specific functions/methods,
+please use the method name as the describe block name.
+
+```javascript
+// Good
+describe('methodName', () => {
+ it('passes', () => {
+ expect(true).toEqual(true);
+ });
+});
+
+// Bad
+describe('#methodName', () => {
+ it('passes', () => {
+ expect(true).toEqual(true);
+ });
+});
+
+// Bad
+describe('.methodName', () => {
+ it('passes', () => {
+ expect(true).toEqual(true);
+ });
+});
+```
+#### Testing Promises
+
+When testing Promises you should always make sure that the test is asynchronous and rejections are handled.
+Your Promise chain should therefore end with a call of the `done` callback and `done.fail` in case an error occurred.
+
+```javascript
+// Good
+it('tests a promise', (done) => {
+ promise
+ .then((data) => {
+ expect(data).toBe(asExpected);
+ })
+ .then(done)
+ .catch(done.fail);
+});
+
+// Good
+it('tests a promise rejection', (done) => {
+ promise
+ .then(done.fail)
+ .catch((error) => {
+ expect(error).toBe(expectedError);
+ })
+ .then(done)
+ .catch(done.fail);
+});
+
+// Bad (missing done callback)
+it('tests a promise', () => {
+ promise
+ .then((data) => {
+ expect(data).toBe(asExpected);
+ })
+});
+
+// Bad (missing catch)
+it('tests a promise', (done) => {
+ promise
+ .then((data) => {
+ expect(data).toBe(asExpected);
+ })
+ .then(done)
+});
+
+// Bad (use done.fail in asynchronous tests)
+it('tests a promise', (done) => {
+ promise
+ .then((data) => {
+ expect(data).toBe(asExpected);
+ })
+ .then(done)
+ .catch(fail)
+});
+
+// Bad (missing catch)
+it('tests a promise rejection', (done) => {
+ promise
+ .catch((error) => {
+ expect(error).toBe(expectedError);
+ })
+ .then(done)
+});
+```
+
+#### Stubbing
+
+For unit tests, you should stub methods that are unrelated to the current unit you are testing.
+If you need to use a prototype method, instantiate an instance of the class and call it there instead of mocking the instance completely.
+
+For integration tests, you should stub methods that will effect the stability of the test if they
+execute their original behaviour. i.e. Network requests.
+
### Vue.js unit tests
See this [section][vue-test].
diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md
index 73d2ffc1bdc..0742b202807 100644
--- a/doc/development/fe_guide/vue.md
+++ b/doc/development/fe_guide/vue.md
@@ -112,7 +112,50 @@ Vue Resource should only be imported in the service file.
Vue.use(VueResource);
```
-### CSRF token
+#### Vue-resource gotchas
+#### Headers
+Headers are being parsed into a plain object in an interceptor.
+In Vue-resource 1.x `headers` object was changed into an `Headers` object. In order to not change all old code, an interceptor was added.
+
+If you need to write a unit test that takes the headers in consideration, you need to include an interceptor to parse the headers after your test interceptor.
+You can see an example in `spec/javascripts/environments/environment_spec.js`:
+ ```javascript
+ import { headersInterceptor } from './helpers/vue_resource_helper';
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(myInterceptor);
+ Vue.http.interceptors.push(headersInterceptor);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, myInterceptor);
+ Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
+ });
+ ```
+
+#### `.json()`
+When making a request to the server, you will most likely need to access the body of the response.
+Use `.json()` to convert. Because `.json()` returns a Promise the follwoing structure should be used:
+
+ ```javascript
+ service.get('url')
+ .then(resp => resp.json())
+ .then((data) => {
+ this.store.storeData(data);
+ })
+ .catch(() => new Flash('Something went wrong'));
+ ```
+
+When using `Poll` (`app/assets/javascripts/lib/utils/poll.js`), the `successCallback` needs to handle `.json()` as a Promise:
+ ```javascript
+ successCallback: (response) => {
+ return response.json().then((data) => {
+ // handle the response
+ });
+ }
+ ```
+
+#### CSRF token
We use a Vue Resource interceptor to manage the CSRF token.
`app/assets/javascripts/vue_shared/vue_resource_interceptor.js` holds all our common interceptors.
Note: You don't need to load `app/assets/javascripts/vue_shared/vue_resource_interceptor.js`
@@ -126,13 +169,13 @@ The following example shows an application:
// store.js
export default class Store {
- /**
+ /**
* This is where we will iniatialize the state of our data.
* Usually in a small SPA you don't need any options when starting the store. In the case you do
* need guarantee it's an Object and it's documented.
- *
- * @param {Object} options
- */
+ *
+ * @param {Object} options
+ */
constructor(options) {
this.options = options;
@@ -205,14 +248,14 @@ import Store from 'store';
import Service from 'service';
import TodoComponent from 'todoComponent';
export default {
- /**
+ /**
* Although most data belongs in the store, each component it's own state.
* We want to show a loading spinner while we are fetching the todos, this state belong
* in the component.
*
* We need to access the store methods through all methods of our component.
* We need to access the state of our store.
- */
+ */
data() {
const store = new Store();
@@ -387,38 +430,56 @@ describe('Todos App', () => {
});
});
```
+#### Test the component's output
+The main return value of a Vue component is the rendered output. In order to test the component we
+need to test the rendered output. [Vue][vue-test] guide's to unit test show us exactly that:
+
### Stubbing API responses
[Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with
the response we need:
-```javascript
- // Mock the service to return data
- const interceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([{
- title: 'This is a todo',
- body: 'This is the text'
- }]), {
- status: 200,
- }));
- };
+ ```javascript
+ // Mock the service to return data
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify([{
+ title: 'This is a todo',
+ body: 'This is the text'
+ }]), {
+ status: 200,
+ }));
+ };
- beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- });
+ beforeEach(() => {
+ Vue.http.interceptors.push(interceptor);
+ });
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
- });
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ });
- it('should do something', (done) => {
- setTimeout(() => {
- // Test received data
- done();
- }, 0);
- });
-```
+ it('should do something', (done) => {
+ setTimeout(() => {
+ // Test received data
+ done();
+ }, 0);
+ });
+ ```
+1. Headers interceptor
+Refer to [this section](vue.md#headers)
+
+1. Use `$.mount()` to mount the component
+
+```javascript
+// bad
+new Component({
+ el: document.createElement('div')
+});
+
+// good
+new Component().$mount();
+```
[vue-docs]: http://vuejs.org/guide/index.html
[issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards
@@ -429,5 +490,6 @@ the response we need:
[one-way-data-flow]: https://vuejs.org/v2/guide/components.html#One-Way-Data-Flow
[vue-resource-repo]: https://github.com/pagekit/vue-resource
[vue-resource-interceptor]: https://github.com/pagekit/vue-resource/blob/develop/docs/http.md#interceptors
+[vue-test]: https://vuejs.org/v2/guide/unit-testing.html
[issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6
[flux]: https://facebook.github.io/flux
diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md
new file mode 100644
index 00000000000..59e8a087e02
--- /dev/null
+++ b/doc/development/feature_flags.md
@@ -0,0 +1,21 @@
+# Manage feature flags
+
+Starting from GitLab 9.3 we support feature flags via
+[Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature`
+class (defined in `lib/feature.rb`) in your code to get, set and list feature
+flags.
+
+During runtime you can set the values for the gates via the
+[features API](../api/features.md) (accessible to admins only).
+
+## Feature groups
+
+Starting from GitLab 9.4 we support feature groups via
+[Flipper groups](https://github.com/jnunemaker/flipper/blob/v0.10.2/docs/Gates.md#2-group).
+
+Feature groups must be defined statically in `lib/feature.rb` (in the
+`.register_feature_groups` method), but their implementation can obviously be
+dynamic (querying the DB etc.).
+
+Once defined in `lib/feature.rb`, you will be able to activate a
+feature for a given feature group via the [`feature_group` param of the features API](../api/features.md#set-or-create-a-feature)
diff --git a/doc/development/foreign_keys.md b/doc/development/foreign_keys.md
new file mode 100644
index 00000000000..0ab0deb156f
--- /dev/null
+++ b/doc/development/foreign_keys.md
@@ -0,0 +1,63 @@
+# Foreign Keys & Associations
+
+When adding an association to a model you must also add a foreign key. For
+example, say you have the following model:
+
+```ruby
+class User < ActiveRecord::Base
+ has_many :posts
+end
+```
+
+Here you will need to add a foreign key on column `posts.user_id`. This ensures
+that data consistency is enforced on database level. Foreign keys also mean that
+the database can very quickly remove associated data (e.g. when removing a
+user), instead of Rails having to do this.
+
+## Adding Foreign Keys In Migrations
+
+Foreign keys can be added concurrently using `add_concurrent_foreign_key` as
+defined in `Gitlab::Database::MigrationHelpers`. See the [Migration Style
+Guide](migration_style_guide.md) for more information.
+
+Keep in mind that you can only safely add foreign keys to existing tables after
+you have removed any orphaned rows. The method `add_concurrent_foreign_key`
+does not take care of this so you'll need to do so manually.
+
+## Cascading Deletes
+
+Every foreign key must define an `ON DELETE` clause, and in 99% of the cases
+this should be set to `CASCADE`.
+
+## Indexes
+
+When adding a foreign key in PostgreSQL the column is not indexed automatically,
+thus you must also add a concurrent index. Not doing so will result in cascading
+deletes being very slow.
+
+## Dependent Removals
+
+Don't define options such as `dependent: :destroy` or `dependent: :delete` when
+defining an association. Defining these options means Rails will handle the
+removal of data, instead of letting the database handle this in the most
+efficient way possible.
+
+In other words, this is bad and should be avoided at all costs:
+
+```ruby
+class User < ActiveRecord::Base
+ has_many :posts, dependent: :destroy
+end
+```
+
+Should you truly have a need for this it should be approved by a database
+specialist first.
+
+You should also not define any `before_destroy` or `after_destroy` callbacks on
+your models _unless_ absolutely required and only when approved by database
+specialists. For example, if each row in a table has a corresponding file on a
+file system it may be tempting to add a `after_destroy` hook. This however
+introduces non database logic to a model, and means we can no longer rely on
+foreign keys to remove the data as this would result in the filesystem data
+being left behind. In such a case you should use a service class instead that
+takes care of removing non database data.
diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md
index 565d4b33457..c2ca8966a3f 100644
--- a/doc/development/gotchas.md
+++ b/doc/development/gotchas.md
@@ -3,35 +3,6 @@
The purpose of this guide is to document potential "gotchas" that contributors
might encounter or should avoid during development of GitLab CE and EE.
-## Do not `describe` symbols
-
-Consider the following model spec:
-
-```ruby
-require 'rails_helper'
-
-describe User do
- describe :to_param do
- it 'converts the username to a param' do
- user = described_class.new(username: 'John Smith')
-
- expect(user.to_param).to eq 'john-smith'
- end
- end
-end
-```
-
-When run, this spec doesn't do what we might expect:
-
-```sh
-spec/models/user_spec.rb|6 error| Failure/Error: u = described_class.new NoMethodError: undefined method `new' for :to_param:Symbol
-```
-
-### Solution
-
-Except for the top-level `describe` block, always provide a String argument to
-`describe`.
-
## Do not assert against the absolute value of a sequence-generated attribute
Consider the following factory:
diff --git a/doc/development/hash_indexes.md b/doc/development/hash_indexes.md
new file mode 100644
index 00000000000..e6c1b3590b1
--- /dev/null
+++ b/doc/development/hash_indexes.md
@@ -0,0 +1,20 @@
+# Hash Indexes
+
+Both PostgreSQL and MySQL support hash indexes besides the regular btree
+indexes. Hash indexes however are to be avoided at all costs. While they may
+_sometimes_ provide better performance the cost of rehashing can be very high.
+More importantly: at least until PostgreSQL 10.0 hash indexes are not
+WAL-logged, meaning they are not replicated to any replicas. From the PostgreSQL
+documentation:
+
+> Hash index operations are not presently WAL-logged, so hash indexes might need
+> to be rebuilt with REINDEX after a database crash if there were unwritten
+> changes. Also, changes to hash indexes are not replicated over streaming or
+> file-based replication after the initial base backup, so they give wrong
+> answers to queries that subsequently use them. For these reasons, hash index
+> use is presently discouraged.
+
+RuboCop is configured to register an offence when it detects the use of a hash
+index.
+
+Instead of using hash indexes you should use regular btree indexes.
diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md
new file mode 100644
index 00000000000..756535e28bc
--- /dev/null
+++ b/doc/development/i18n_guide.md
@@ -0,0 +1,256 @@
+# Internationalization for GitLab
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10669) in GitLab 9.2.
+
+For working with internationalization (i18n) we use
+[GNU gettext](https://www.gnu.org/software/gettext/) given it's the most used
+tool for this task and we have a lot of applications that will help us to work
+with it.
+
+## Setting up GitLab Development Kit (GDK)
+
+In order to be able to work on the [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce) project we must download and
+configure it through [GDK](https://gitlab.com/gitlab-org/gitlab-development-kit), we can do it by following this [guide](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/set-up-gdk.md).
+
+Once we have the GitLab project ready we can start working on the
+translation of the project.
+
+## Tools
+
+We use a couple of gems:
+
+1. [`gettext_i18n_rails`](https://github.com/grosser/gettext_i18n_rails): this
+ gem allow us to translate content from models, views and controllers. Also
+ it gives us access to the following raketasks:
+ - `rake gettext:find`: Parses almost all the files from the
+ Rails application looking for content that has been marked for
+ translation. Finally, it updates the PO files with the new content that
+ it has found.
+ - `rake gettext:pack`: Processes the PO files and generates the
+ MO files that are binary and are finally used by the application.
+
+1. [`gettext_i18n_rails_js`](https://github.com/webhippie/gettext_i18n_rails_js):
+ this gem is useful to make the translations available in JavaScript. It
+ provides the following raketask:
+ - `rake gettext:po_to_json`: Reads the contents from the PO files and
+ generates JSON files containing all the available translations.
+
+1. PO editor: there are multiple applications that can help us to work with PO
+ files, a good option is [Poedit](https://poedit.net/download) which is
+ available for macOS, GNU/Linux and Windows.
+
+## Preparing a page for translation
+
+We basically have 4 types of files:
+
+1. Ruby files: basically Models and Controllers.
+1. HAML files: these are the view files.
+1. ERB files: used for email templates.
+1. JavaScript files: we mostly need to work with VUE JS templates.
+
+### Ruby files
+
+If there is a method or variable that works with a raw string, for instance:
+
+```ruby
+def hello
+ "Hello world!"
+end
+```
+
+Or:
+
+```ruby
+hello = "Hello world!"
+```
+
+You can easily mark that content for translation with:
+
+```ruby
+def hello
+ _("Hello world!")
+end
+```
+
+Or:
+
+```ruby
+hello = _("Hello world!")
+```
+
+### HAML files
+
+Given the following content in HAML:
+
+```haml
+%h1 Hello world!
+```
+
+You can mark that content for translation with:
+
+```haml
+%h1= _("Hello world!")
+```
+
+### ERB files
+
+Given the following content in ERB:
+
+```erb
+<h1>Hello world!</h1>
+```
+
+You can mark that content for translation with:
+
+```erb
+<h1><%= _("Hello world!") %></h1>
+```
+
+### JavaScript files
+
+In JavaScript we added the `__()` (double underscore parenthesis) function
+for translations.
+
+### Updating the PO files with the new content
+
+Now that the new content is marked for translation, we need to update the PO
+files with the following command:
+
+```sh
+bundle exec rake gettext:find
+```
+
+This command will update the `locale/**/gitlab.edit.po` file with the
+new content that the parser has found.
+
+New translations will be added with their default content and will be marked
+fuzzy. To use the translation, look for the `#, fuzzy` mention in `gitlab.edit.po`
+and remove it.
+
+We need to make sure we remove the `fuzzy` translations before generating the
+`locale/**/gitlab.po` file. When they aren't removed, the resulting `.po` will
+be treated as a binary file which could overwrite translations that were merged
+before the new translations.
+
+When we are just preparing a page to be translated, but not actually adding any
+translations. There's no need to generate `.po` files.
+
+Translations that aren't used in the source code anymore will be marked with
+`~#`; these can be removed to keep our translation files clutter-free.
+
+## Working with special content
+
+### Interpolation
+
+- In Ruby/HAML:
+
+ ```ruby
+ _("Hello %{name}") % { name: 'Joe' }
+ ```
+
+- In JavaScript: Not supported at this moment.
+
+### Plurals
+
+- In Ruby/HAML:
+
+ ```ruby
+ n_('Apple', 'Apples', 3) => 'Apples'
+ ```
+
+ Using interpolation:
+ ```ruby
+ n_("There is a mouse.", "There are %d mice.", size) % size
+ ```
+
+- In JavaScript:
+
+ ```js
+ n__('Apple', 'Apples', 3) => 'Apples'
+ ```
+
+ Using interpolation:
+
+ ```js
+ n__('Last day', 'Last %d days', 30) => 'Last 30 days'
+ ```
+
+### Namespaces
+
+Sometimes you need to add some context to the text that you want to translate
+(if the word occurs in a sentence and/or the word is ambiguous).
+
+- In Ruby/HAML:
+
+ ```ruby
+ s_('OpenedNDaysAgo|Opened')
+ ```
+
+ In case the translation is not found it will return `Opened`.
+
+- In JavaScript:
+
+ ```js
+ s__('OpenedNDaysAgo|Opened')
+ ```
+
+### Just marking content for parsing
+
+Sometimes there are some dynamic translations that can't be found by the
+parser when running `bundle exec rake gettext:find`. For these scenarios you can
+use the [`_N` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind).
+
+There is also and alternative method to [translate messages from validation errors](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#option-a).
+
+## Adding a new language
+
+Let's suppose you want to add translations for a new language, let's say French.
+
+1. The first step is to register the new language in `lib/gitlab/i18n.rb`:
+
+ ```ruby
+ ...
+ AVAILABLE_LANGUAGES = {
+ ...,
+ 'fr' => 'Français'
+ }.freeze
+ ...
+ ```
+
+1. Next, you need to add the language:
+
+ ```sh
+ bundle exec rake gettext:add_language[fr]
+ ```
+
+ If you want to add a new language for a specific region, the command is similar,
+ you just need to separate the region with an underscore (`_`). For example:
+
+ ```sh
+ bundle exec rake gettext:add_language[en_GB]
+ ```
+
+ Please note that you need to specify the region part in capitals.
+
+1. Now that the language is added, a new directory has been created under the
+ path: `locale/fr/`. You can now start using your PO editor to edit the PO file
+ located in: `locale/fr/gitlab.edit.po`.
+
+1. After you're done updating the translations, you need to process the PO files
+ in order to generate the binary MO files and finally update the JSON files
+ containing the translations:
+
+ ```sh
+ bundle exec rake gettext:compile
+ ```
+
+1. In order to see the translated content we need to change our preferred language
+ which can be found under the user's **Settings** (`/profile`).
+
+1. After checking that the changes are ok, you can proceed to commit the new files.
+ For example:
+
+ ```sh
+ git add locale/fr/ app/assets/javascripts/locale/fr/
+ git commit -m "Add French translations for Cycle Analytics page"
+ ```
diff --git a/doc/development/img/trigger_ss1.png b/doc/development/img/trigger_ss1.png
new file mode 100644
index 00000000000..ccff1009a25
--- /dev/null
+++ b/doc/development/img/trigger_ss1.png
Binary files differ
diff --git a/doc/development/img/trigger_ss2.png b/doc/development/img/trigger_ss2.png
new file mode 100644
index 00000000000..94dfd048793
--- /dev/null
+++ b/doc/development/img/trigger_ss2.png
Binary files differ
diff --git a/doc/development/iterating_tables_in_batches.md b/doc/development/iterating_tables_in_batches.md
new file mode 100644
index 00000000000..590c8cbba2d
--- /dev/null
+++ b/doc/development/iterating_tables_in_batches.md
@@ -0,0 +1,37 @@
+# Iterating Tables In Batches
+
+Rails provides a method called `in_batches` that can be used to iterate over
+rows in batches. For example:
+
+```ruby
+User.in_batches(of: 10) do |relation|
+ relation.update_all(updated_at: Time.now)
+end
+```
+
+Unfortunately this method is implemented in a way that is not very efficient,
+both query and memory usage wise.
+
+To work around this you can include the `EachBatch` module into your models,
+then use the `each_batch` class method. For example:
+
+```ruby
+class User < ActiveRecord::Base
+ include EachBatch
+end
+
+User.each_batch(of: 10) do |relation|
+ relation.update_all(updated_at: Time.now)
+end
+```
+
+This will end up producing queries such as:
+
+```
+User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
+ (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)
+```
+
+The API of this method is similar to `in_batches`, though it doesn't support
+all of the arguments that `in_batches` supports. You should always use
+`each_batch` _unless_ you have a specific need for `in_batches`.
diff --git a/doc/development/limit_ee_conflicts.md b/doc/development/limit_ee_conflicts.md
index 51b4b398f2c..899be9eae4b 100644
--- a/doc/development/limit_ee_conflicts.md
+++ b/doc/development/limit_ee_conflicts.md
@@ -166,8 +166,8 @@ For instance this kind of thing:
= render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea',
placeholder: "Write a comment or drag your files here...",
- supports_slash_commands: !issuable.persisted?
- = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
+ supports_quick_actions: !issuable.persisted?
+ = render 'projects/notes/hints', supports_quick_actions: !issuable.persisted?
.clearfix
.error-alert
- if issuable.is_a?(Issue)
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index 3e8b709c18f..9b8ab5da74e 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -35,12 +35,11 @@ Please don't depend on GitLab-specific code since it can change in future
versions. If needed copy-paste GitLab code into the migration to make it forward
compatible.
-## Commit Guidelines
+## Schema Changes
-Each migration **must** be added in its own commit with a descriptive commit
-message. If a commit adds a migration it _should only_ include the migration and
-any corresponding changes to `db/schema.rb`. This makes it easy to revert a
-database migration without accidentally reverting other changes.
+Migrations that make changes to the database schema (e.g. adding a column) can
+only be added in the monthly release, patch releases may only contain data
+migrations _unless_ schema changes are absolutely required to solve a problem.
## Downtime Tagging
@@ -122,7 +121,7 @@ limit can vary from installation to installation. As a result it's recommended
you do not use more than 32 threads in a single migration. Usually 4-8 threads
should be more than enough.
-## Removing indices
+## Removing indexes
When removing an index make sure to use the method `remove_concurrent_index` instead
of the regular `remove_index` method. The `remove_concurrent_index` method
@@ -142,7 +141,7 @@ class MyMigration < ActiveRecord::Migration
end
```
-## Adding indices
+## Adding indexes
If you need to add a unique index please keep in mind there is the possibility
of existing duplicates being present in the database. This means that should
@@ -222,6 +221,41 @@ add_column_with_default(:projects, :foo, :integer, default: 10, limit: 8)
add_column(:projects, :foo, :integer, default: 10, limit: 8)
```
+## Timestamp column type
+
+By default, Rails uses the `timestamp` data type that stores timestamp data without timezone information.
+The `timestamp` data type is used by calling either the `add_timestamps` or the `timestamps` method.
+Also Rails converts the `:datetime` data type to the `timestamp` one.
+
+Example:
+
+```ruby
+# timestamps
+create_table :users do |t|
+ t.timestamps
+end
+
+# add_timestamps
+def up
+ add_timestamps :users
+end
+
+# :datetime
+def up
+ add_column :users, :last_sign_in, :datetime
+end
+```
+
+Instead of using these methods one should use the following methods to store timestamps with timezones:
+
+* `add_timestamps_with_timezone`
+* `timestamps_with_timezone`
+
+This ensures all timestamps have a time zone specified. This in turn means existing timestamps won't
+suddenly use a different timezone when the system's timezone changes. It also makes it very clear which
+timezone was used in the first place.
+
+
## Testing
Make sure that your migration works with MySQL and PostgreSQL with data. An
@@ -270,3 +304,28 @@ end
When doing so be sure to explicitly set the model's table name so it's not
derived from the class name or namespace.
+
+### Renaming reserved paths
+
+When a new route for projects is introduced that could conflict with any
+existing records. The path for this records should be renamed, and the
+related data should be moved on disk.
+
+Since we had to do this a few times already, there are now some helpers to help
+with this.
+
+To use this you can include `Gitlab::Database::RenameReservedPathsMigration::V1`
+in your migration. This will provide 3 methods which you can pass one or more
+paths that need to be rejected.
+
+**`rename_root_paths`**: This will rename the path of all _namespaces_ with the
+given name that don't have a `parent_id`.
+
+**`rename_child_paths`**: This will rename the path of all _namespaces_ with the
+given name that have a `parent_id`.
+
+**`rename_wildcard_paths`**: This will rename the path of all _projects_, and all
+_namespaces_ that have a `project_id`.
+
+The `path` column for these rows will be renamed to their previous value followed
+by an integer. For example: `users` would turn into `users0`
diff --git a/doc/development/ordering_table_columns.md b/doc/development/ordering_table_columns.md
new file mode 100644
index 00000000000..249e70c7b0e
--- /dev/null
+++ b/doc/development/ordering_table_columns.md
@@ -0,0 +1,127 @@
+# Ordering Table Columns
+
+Similar to C structures the space of a table is influenced by the order of
+columns. This is because the size of columns is aligned depending on the type of
+the column. Take the following column order for example:
+
+* id (integer, 4 bytes)
+* name (text, variable)
+* user_id (integer, 4 bytes)
+
+Integers are aligned to the word size. This means that on a 64 bit platform the
+actual size of each column would be: 8 bytes, variable, 8 bytes. This means that
+each row will require at least 16 bytes for the two integers, and a variable
+amount for the text field. If a table has a few rows this is not an issue, but
+once you start storing millions of rows you can save space by using a different
+order. For the above example a more ideal column order would be the following:
+
+* id (integer, 4 bytes)
+* user_id (integer, 4 bytes)
+* name (text, variable)
+
+In this setup the `id` and `user_id` columns can be packed together, which means
+we only need 8 bytes to store _both_ of them. This in turn each row will require
+8 bytes less of space.
+
+For GitLab we require that columns of new tables are ordered based to use the
+least amount of space. An easy way of doing this is to order them based on the
+type size in descending order with variable sizes (string and text columns for
+example) at the end.
+
+## Type Sizes
+
+While the PostgreSQL docuemntation
+(https://www.postgresql.org/docs/current/static/datatype.html) contains plenty
+of information we will list the sizes of common types here so it's easier to
+look them up. Here "word" refers to the word size, which is 4 bytes for a 32
+bits platform and 8 bytes for a 64 bits platform.
+
+| Type | Size | Aligned To |
+|:-----------------|:-------------------------------------|:-----------|
+| smallint | 2 bytes | 1 word |
+| integer | 4 bytes | 1 word |
+| bigint | 8 bytes | 8 bytes |
+| real | 4 bytes | 1 word |
+| double precision | 8 bytes | 8 bytes |
+| boolean | 1 byte | not needed |
+| text / string | variable, 1 byte plus the data | 1 word |
+| bytea | variable, 1 or 4 bytes plus the data | 1 word |
+| timestamp | 8 bytes | 8 bytes |
+| timestamptz | 8 bytes | 8 bytes |
+| date | 4 bytes | 1 word |
+
+A "variable" size means the actual size depends on the value being stored. If
+PostgreSQL determines this can be embedded directly into a row it may do so, but
+for very large values it will store the data externally and store a pointer (of
+1 word in size) in the column. Because of this variable sized columns should
+always be at the end of a table.
+
+## Real Example
+
+Let's use the "events" table as an example, which currently has the following
+layout:
+
+| Column | Type | Size |
+|:------------|:----------------------------|:---------|
+| id | integer | 4 bytes |
+| target_type | character varying | variable |
+| target_id | integer | 4 bytes |
+| title | character varying | variable |
+| data | text | variable |
+| project_id | integer | 4 bytes |
+| created_at | timestamp without time zone | 8 bytes |
+| updated_at | timestamp without time zone | 8 bytes |
+| action | integer | 4 bytes |
+| author_id | integer | 4 bytes |
+
+After adding padding to align the columns this would translate to columns being
+divided into fixed size chunks as follows:
+
+| Chunk Size | Columns |
+|:-----------|:------------------|
+| 8 bytes | id |
+| variable | target_type |
+| 8 bytes | target_id |
+| variable | title |
+| variable | data |
+| 8 bytes | project_id |
+| 8 bytes | created_at |
+| 8 bytes | updated_at |
+| 8 bytes | action, author_id |
+
+This means that excluding the variable sized data we need at least 48 bytes per
+row.
+
+We can optimise this by using the following column order instead:
+
+| Column | Type | Size |
+|:------------|:----------------------------|:---------|
+| created_at | timestamp without time zone | 8 bytes |
+| updated_at | timestamp without time zone | 8 bytes |
+| id | integer | 4 bytes |
+| target_id | integer | 4 bytes |
+| project_id | integer | 4 bytes |
+| action | integer | 4 bytes |
+| author_id | integer | 4 bytes |
+| target_type | character varying | variable |
+| title | character varying | variable |
+| data | text | variable |
+
+This would produce the following chunks:
+
+| Chunk Size | Columns |
+|:-----------|:-------------------|
+| 8 bytes | created_at |
+| 8 bytes | updated_at |
+| 8 bytes | id, target_id |
+| 8 bytes | project_id, action |
+| 8 bytes | author_id |
+| variable | target_type |
+| variable | title |
+| variable | data |
+
+Here we only need 40 bytes per row excluding the variable sized data. 8 bytes
+being saved may not sound like much, but for tables as large as the "events"
+table it does begin to matter. For example, when storing 80 000 000 rows this
+translates to a space saving of at least 610 MB: all by just changing the order
+of a few columns.
diff --git a/doc/development/policies.md b/doc/development/policies.md
new file mode 100644
index 00000000000..62141356f59
--- /dev/null
+++ b/doc/development/policies.md
@@ -0,0 +1,116 @@
+# `DeclarativePolicy` framework
+
+The DeclarativePolicy framework is designed to assist in performance of policy checks, and to enable ease of extension for EE. The DSL code in `app/policies` is what `Ability.allowed?` uses to check whether a particular action is allowed on a subject.
+
+The policy used is based on the subject's class name - so `Ability.allowed?(user, :some_ability, project)` will create a `ProjectPolicy` and check permissions on that.
+
+## Managing Permission Rules
+
+Permissions are broken into two parts: `conditions` and `rules`. Conditions are boolean expressions that can access the database and the environment, while rules are statically configured combinations of expressions and other rules that enable or prevent certain abilities. For an ability to be allowed, it must be enabled by at least one rule, and not prevented by any.
+
+
+### Conditions
+
+Conditions are defined by the `condition` method, and are given a name and a block. The block will be executed in the context of the policy object - so it can access `@user` and `@subject`, as well as call any methods defined on the policy. Note that `@user` may be nil (in the anonymous case), but `@subject` is guaranteed to be a real instance of the subject class.
+
+``` ruby
+class FooPolicy < BasePolicy
+ condition(:is_public) do
+ # @subject guaranteed to be an instance of Foo
+ @subject.public?
+ end
+
+ # instance methods can be called from the condition as well
+ condition(:thing) { check_thing }
+
+ def check_thing
+ # ...
+ end
+end
+```
+
+When you define a condition, a predicate method is defined on the policy to check whether that condition passes - so in the above example, an instance of `FooPolicy` will also respond to `#is_public?` and `#thing?`.
+
+Conditions are cached according to their scope. Scope and ordering will be covered later.
+
+### Rules
+
+A `rule` is a logical combination of conditions and other rules, that are configured to enable or prevent certain abilities. It is important to note that the rule configuration is static - a rule's logic cannot touch the database or know about `@user` or `@subject`. This allows us to cache only at the condition level. Rules are specified through the `rule` method, which takes a block of DSL configuration, and returns an object that responds to `#enable` or `#prevent`:
+
+``` ruby
+class FooPolicy < BasePolicy
+ # ...
+
+ rule { is_public }.enable :read
+ rule { thing }.prevent :read
+
+ # equivalently,
+ rule { is_public }.policy do
+ enable :read
+ end
+
+ rule { ~thing }.policy do
+ prevent :read
+ end
+end
+```
+
+Within the rule DSL, you can use:
+
+* A regular word mentions a condition by name - a rule that is in effect when that condition is truthy.
+* `~` indicates negation
+* `&` and `|` are logical combinations, also available as `all?(...)` and `any?(...)`
+* `can?(:other_ability)` delegates to the rules that apply to `:other_ability`. Note that this is distinct from the instance method `can?`, which can check dynamically - this only configures a delegation to another ability.
+
+## Scores, Order, Performance
+
+To see how the rules get evaluated into a judgment, it is useful in a console to use `policy.debug(:some_ability)`. This will print the rules in the order they are evaluated.
+
+When a policy is asked whether a particular ability is allowed (`policy.allowed?(:some_ability)`), it does not necessarily have to compute all the conditions on the policy. First, only the rules relevant to that particular ability are selected. Then, the execution model takes advantage of short-circuiting, and attempts to sort rules based on a heuristic of how expensive they will be to calculate. The sorting is dynamic and cache-aware, so that previously calculated conditions will be considered first, before computing other conditions.
+
+## Scope
+
+Sometimes, a condition will only use data from `@user` or only from `@subject`. In this case, we want to change the scope of the caching, so that we don't recalculate conditions unnecessarily. For example, given:
+
+``` ruby
+class FooPolicy < BasePolicy
+ condition(:expensive_condition) { @subject.expensive_query? }
+
+ rule { expensive_condition }.enable :some_ability
+end
+```
+
+Naively, if we call `Ability.can?(user1, :some_ability, foo)` and `Ability.can?(user2, :some_ability, foo)`, we would have to calculate the condition twice - since they are for different users. But if we use the `scope: :subject` option:
+
+``` ruby
+ condition(:expensive_condition, scope: :subject) { @subject.expensive_query? }
+```
+
+then the result of the condition will be cached globally only based on the subject - so it will not be calculated repeatedly for different users. Similarly, `scope: :user` will cache only based on the user.
+
+**DANGER**: If you use a `:scope` option when the condition actually uses data from
+both user and subject (including a simple anonymous check!) your result will be cached at too global of a scope and will result in cache bugs.
+
+Sometimes we are checking permissions for a lot of users for one subject, or a lot of subjects for one user. In this case, we want to set a *preferred scope* - i.e. tell the system that we prefer rules that can be cached on the repeated parameter. For example, in `Ability.users_that_can_read_project`:
+
+``` ruby
+def users_that_can_read_project(users, project)
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_project, project) }
+ end
+end
+```
+
+This will, for example, prefer checking `project.public?` to checking `user.admin?`.
+
+## Delegation
+
+Delegation is the inclusion of rules from another policy, on a different subject. For example,
+
+``` ruby
+class FooPolicy < BasePolicy
+ delegate { @subject.project }
+end
+```
+
+will include all rules from `ProjectPolicy`. The delegated conditions will be evaluated with the correct delegated subject, and will be sorted along with the regular rules in the policy. Note that only the relevant rules for a particular ability will actually be considered.
diff --git a/doc/development/polymorphic_associations.md b/doc/development/polymorphic_associations.md
new file mode 100644
index 00000000000..d63b9fb115f
--- /dev/null
+++ b/doc/development/polymorphic_associations.md
@@ -0,0 +1,146 @@
+# Polymorphic Associations
+
+**Summary:** always use separate tables instead of polymorphic associations.
+
+Rails makes it possible to define so called "polymorphic associations". This
+usually works by adding two columns to a table: a target type column, and a
+target id. For example, at the time of writing we have such a setup for
+`members` with the following columns:
+
+* `source_type`: a string defining the model to use, can be either `Project` or
+ `Namespace`.
+* `source_id`: the ID of the row to retrieve based on `source_type`. For
+ example, when `source_type` is `Project` then `source_id` will contain a
+ project ID.
+
+While such a setup may appear to be useful, it comes with many drawbacks; enough
+that you should avoid this at all costs.
+
+## Space Wasted
+
+Because this setup relies on string values to determine the model to use it will
+end up wasting a lot of space. For example, for `Project` and `Namespace` the
+maximum size is 9 bytes, plus 1 extra byte for every string when using
+PostgreSQL. While this may only be 10 bytes per row, given enough tables and
+rows using such a setup we can end up wasting quite a bit of disk space and
+memory (for any indexes).
+
+## Indexes
+
+Because our associations are broken up into two columns this may result in
+requiring composite indexes for queries to be performed efficiently. While
+composite indexes are not wrong at all, they can be tricky to set up as the
+ordering of columns in these indexes is important to ensure optimal performance.
+
+## Consistency
+
+One really big problem with polymorphic associations is being unable to enforce
+data consistency on the database level using foreign keys. For consistency to be
+enforced on the database level one would have to write their own foreign key
+logic to support polymorphic associations.
+
+Enforcing consistency on the database level is absolutely crucial for
+maintaining a healthy environment, and thus is another reason to avoid
+polymorphic associations.
+
+## Query Overhead
+
+When using polymorphic associations you always need to filter using both
+columns. For example, you may end up writing a query like this:
+
+```sql
+SELECT *
+FROM members
+WHERE source_type = 'Project'
+AND source_id = 13083;
+```
+
+Here PostgreSQL can perform the query quite efficiently if both columns are
+indexed, but as the query gets more complex it may not be able to use these
+indexes efficiently.
+
+## Mixed Responsibilities
+
+Similar to functions and classes a table should have a single responsibility:
+storing data with a certain set of pre-defined columns. When using polymorphic
+associations you are instead storing different types of data (possibly with
+different columns set) in the same table.
+
+## The Solution
+
+Fortunately there is a very simple solution to these problems: simply use a
+separate table for every type you would otherwise store in the same table. Using
+a separate table allows you to use everything a database may provide to ensure
+consistency and query data efficiently, without any additional application logic
+being necessary.
+
+Let's say you have a `members` table storing both approved and pending members,
+for both projects and groups, and the pending state is determined by the column
+`requested_at` being set or not. Schema wise such a setup can lead to various
+columns only being set for certain rows, wasting space. It's also possible that
+certain indexes will only be set for certain rows, again wasting space. Finally,
+querying such a table requires less than ideal queries. For example:
+
+```sql
+SELECT *
+FROM members
+WHERE requested_at IS NULL
+AND source_type = 'GroupMember'
+AND source_id = 4
+```
+
+Instead such a table should be broken up into separate tables. For example, you
+may end up with 4 tables in this case:
+
+* project_members
+* group_members
+* pending_project_members
+* pending_group_members
+
+This makes querying data trivial. For example, to get the members of a group
+you'd run:
+
+```sql
+SELECT *
+FROM group_members
+WHERE group_id = 4
+```
+
+To get all the pending members of a group in turn you'd run:
+
+```sql
+SELECT *
+FROM pending_group_members
+WHERE group_id = 4
+```
+
+If you want to get both you can use a UNION, though you need to be explicit
+about what columns you want to SELECT as otherwise the result set will use the
+columns of the first query. For example:
+
+```sql
+SELECT id, 'Group' AS target_type, group_id AS target_id
+FROM group_members
+
+UNION ALL
+
+SELECT id, 'Project' AS target_type, project_id AS target_id
+FROM project_members
+```
+
+The above example is perhaps a bit silly, but it shows that there's nothing
+stopping you from merging the data together and presenting it on the same page.
+Selecting columns explicitly can also speed up queries as the database has to do
+less work to get the data (compared to selecting all columns, even ones you're
+not using).
+
+Our schema also becomes easier. No longer do we need to both store and index the
+`source_type` column, we can define foreign keys easily, and we don't need to
+filter rows using the `IS NULL` condition.
+
+To summarize: using separate tables allows us to use foreign keys effectively,
+create indexes only where necessary, conserve space, query data more
+efficiently, and scale these tables more easily (e.g. by storing them on
+separate disks). A nice side effect of this is that code can also become easier
+as you won't end up with a single model having to handle different kinds of
+data.
diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md
index ec9e4dcc59d..bfd80aab6a4 100644
--- a/doc/development/rake_tasks.md
+++ b/doc/development/rake_tasks.md
@@ -12,6 +12,56 @@ The `setup` task is a alias for `gitlab:setup`.
This tasks calls `db:reset` to create the database, calls `add_limits_mysql` that adds limits to the database schema in case of a MySQL database and finally it calls `db:seed_fu` to seed the database.
Note: `db:setup` calls `db:seed` but this does nothing.
+### Automation
+
+If you're very sure that you want to **wipe the current database** and refill
+seeds, you could:
+
+``` shell
+echo 'yes' | bundle exec rake setup
+```
+
+To save you from answering `yes` manually.
+
+### Discard stdout
+
+Since the script would print a lot of information, it could be slowing down
+your terminal, and it would generate more than 20G logs if you just redirect
+it to a file. If we don't care about the output, we could just redirect it to
+`/dev/null`:
+
+``` shell
+echo 'yes' | bundle exec rake setup > /dev/null
+```
+
+Note that since you can't see the questions from stdout, you might just want
+to `echo 'yes'` to keep it running. It would still print the errors on stderr
+so no worries about missing errors.
+
+### Notes for MySQL
+
+Since the seeds would contain various UTF-8 characters, such as emojis or so,
+we'll need to make sure that we're using `utf8mb4` for all the encoding
+settings and `utf8mb4_unicode_ci` for collation. Please check
+[MySQL utf8mb4 support](../install/database_mysql.md#mysql-utf8mb4-support)
+
+Make sure that `config/database.yml` has `encoding: utf8mb4`, too.
+
+Next, we'll need to update the schema to make the indices fit:
+
+``` shell
+sed -i 's/limit: 255/limit: 191/g' db/schema.rb
+```
+
+Then run the setup script:
+
+``` shell
+bundle exec rake setup
+```
+
+To make sure that indices still fit. You could find great details in:
+[How to support full Unicode in MySQL databases](https://mathiasbynens.be/notes/mysql-utf8mb4)
+
## Run tests
In order to run the test you can use the following commands:
@@ -31,16 +81,26 @@ files it can find, also the ones in `/tmp`
To run a single test file you can use:
-- `bundle exec rspec spec/controllers/commit_controller_spec.rb` for a rspec test
-- `bundle exec spinach features/project/issues/milestones.feature` for a spinach test
+- `bin/rspec spec/controllers/commit_controller_spec.rb` for a rspec test
+- `bin/spinach features/project/issues/milestones.feature` for a spinach test
To run several tests inside one directory:
-- `bundle exec rspec spec/requests/api/` for the rspec tests if you want to test API only
-- `bundle exec spinach features/profile/` for the spinach tests if you want to test only profile pages
+- `bin/rspec spec/requests/api/` for the rspec tests if you want to test API only
+- `bin/spinach features/profile/` for the spinach tests if you want to test only profile pages
+
+### Speed-up tests, rake tasks, and migrations
+
+[Spring](https://github.com/rails/spring) is a Rails application preloader. It
+speeds up development by keeping your application running in the background so
+you don't need to boot it every time you run a test, rake task or migration.
+
+If you want to use it, you'll need to export the `ENABLE_SPRING` environment
+variable to `1`:
-If you want to use [Spring](https://github.com/rails/spring) set
-`ENABLE_SPRING=1` in your environment.
+```
+export ENABLE_SPRING=1
+```
## Compile Frontend Assets
@@ -86,3 +146,20 @@ If new emoji are added, the spritesheet may change size. To compensate for
such changes, first generate the `emoji.png` spritesheet with the above Rake
task, then check the dimensions of the new spritesheet and update the
`SPRITESHEET_WIDTH` and `SPRITESHEET_HEIGHT` constants accordingly.
+
+## Updating project templates
+
+Starting a project from a template needs this project to be exported. On a
+up to date master branch with run:
+
+```
+gdk run db
+# In a new terminal window
+bundle exec rake gitlab:update_project_templates
+git checkout -b update-project-templates
+git add vendor/project_templates
+git commit
+git push -u origin update-project-templates
+```
+
+Now create a merge request and merge that to master.
diff --git a/doc/development/serializing_data.md b/doc/development/serializing_data.md
new file mode 100644
index 00000000000..37332c20147
--- /dev/null
+++ b/doc/development/serializing_data.md
@@ -0,0 +1,85 @@
+# Serializing Data
+
+**Summary:** don't store serialized data in the database, use separate columns
+and/or tables instead. This includes storing of comma separated values as a
+string.
+
+Rails makes it possible to store serialized data in JSON, YAML or other formats.
+Such a field can be defined as follows:
+
+```ruby
+class Issue < ActiveRecord::Model
+ serialize :custom_fields
+end
+```
+
+While it may be tempting to store serialized data in the database there are many
+problems with this. This document will outline these problems and provide an
+alternative.
+
+## Serialized Data Is Less Powerful
+
+When using a relational database you have the ability to query individual
+fields, change the schema, index data and so forth. When you use serialized data
+all of that becomes either very difficult or downright impossible. While
+PostgreSQL does offer the ability to query JSON fields it is mostly meant for
+very specialized use cases, and not for more general use. If you use YAML in
+turn there's no way to query the data at all.
+
+## Waste Of Space
+
+Storing serialized data such as JSON or YAML will end up wasting a lot of space.
+This is because these formats often include additional characters (e.g. double
+quotes or newlines) besides the data that you are storing.
+
+## Difficult To Manage
+
+There comes a time where you will need to add a new field to the serialized
+data, or change an existing one. Using serialized data this becomes difficult
+and very time consuming as the only way of doing so is to re-write all the
+stored values. To do so you would have to:
+
+1. Retrieve the data
+1. Parse it into a Ruby structure
+1. Mutate it
+1. Serialize it back to a String
+1. Store it in the database
+
+On the other hand, if one were to use regular columns adding a column would be
+as easy as this:
+
+```sql
+ALTER TABLE table_name ADD COLUMN column_name type;
+```
+
+Such a query would take very little to no time and would immediately apply to
+all rows, without having to re-write large JSON or YAML structures.
+
+Finally, there comes a time when the JSON or YAML structure is no longer
+sufficient and you need to migrate away from it. When storing only a few rows
+this may not be a problem, but when storing millions of rows such a migration
+can easily take hours or even days to complete.
+
+## Relational Databases Are Not Document Stores
+
+When storing data as JSON or YAML you're essentially using your database as if
+it were a document store (e.g. MongoDB), except you're not using any of the
+powerful features provided by a typical RDBMS _nor_ are you using any of the
+features provided by a typical document store (e.g. the ability to index fields
+of documents with variable fields). In other words, it's a waste.
+
+## Consistent Fields
+
+One argument sometimes made in favour of serialized data is having to store
+widely varying fields and values. Sometimes this is truly the case, and then
+perhaps it might make sense to use serialized data. However, in 99% of the cases
+the fields and types stored tend to be the same for every row. Even if there is
+a slight difference you can still use separate columns and just not set the ones
+you don't need.
+
+## The Solution
+
+The solution is very simple: just use separate columns and/or separate tables.
+This will allow you to use all the features provided by your database, it will
+make it easier to manage and migrate the data, you'll conserve space, you can
+index the data efficiently and so forth.
diff --git a/doc/development/sha1_as_binary.md b/doc/development/sha1_as_binary.md
new file mode 100644
index 00000000000..3151cc29bbc
--- /dev/null
+++ b/doc/development/sha1_as_binary.md
@@ -0,0 +1,36 @@
+# Storing SHA1 Hashes As Binary
+
+Storing SHA1 hashes as strings is not very space efficient. A SHA1 as a string
+requires at least 40 bytes, an additional byte to store the encoding, and
+perhaps more space depending on the internals of PostgreSQL and MySQL.
+
+On the other hand, if one were to store a SHA1 as binary one would only need 20
+bytes for the actual SHA1, and 1 or 4 bytes of additional space (again depending
+on database internals). This means that in the best case scenario we can reduce
+the space usage by 50%.
+
+To make this easier to work with you can include the concern `ShaAttribute` into
+a model and define a SHA attribute using the `sha_attribute` class method. For
+example:
+
+```ruby
+class Commit < ActiveRecord::Base
+ include ShaAttribute
+
+ sha_attribute :sha
+end
+```
+
+This allows you to use the value of the `sha` attribute as if it were a string,
+while storing it as binary. This means that you can do something like this,
+without having to worry about converting data to the right binary format:
+
+```ruby
+commit = Commit.find_by(sha: '88c60307bd1f215095834f09a1a5cb18701ac8ad')
+commit.sha = '971604de4cfa324d91c41650fabc129420c8d1cc'
+commit.save
+```
+
+There is however one requirement: the column used to store the SHA has _must_ be
+a binary type. For Rails this means you need to use the `:binary` type instead
+of `:text` or `:string`.
diff --git a/doc/development/sidekiq_style_guide.md b/doc/development/sidekiq_style_guide.md
index e3a20f29a09..1e9fdbc65e2 100644
--- a/doc/development/sidekiq_style_guide.md
+++ b/doc/development/sidekiq_style_guide.md
@@ -36,3 +36,10 @@ slow jobs blocking work (even for different jobs) on the shared queue.
Each Sidekiq worker must be tested using RSpec, just like any other class. These
tests should be placed in `spec/workers`.
+
+## Removing or renaming queues
+
+Try to avoid renaming or removing queues in minor and patch releases.
+During online update instance can have pending jobs and removing the queue can
+lead to those jobs being stuck forever. If you can't write migration for those
+Sidekiq jobs, please consider doing rename or remove queue in major release only. \ No newline at end of file
diff --git a/doc/development/single_table_inheritance.md b/doc/development/single_table_inheritance.md
new file mode 100644
index 00000000000..27c3c4f3199
--- /dev/null
+++ b/doc/development/single_table_inheritance.md
@@ -0,0 +1,18 @@
+# Single Table Inheritance
+
+**Summary:** don't use Single Table Inheritance (STI), use separate tables
+instead.
+
+Rails makes it possible to have multiple models stored in the same table and map
+these rows to the correct models using a `type` column. This can be used to for
+example store two different types of SSH keys in the same table.
+
+While tempting to use one should avoid this at all costs for the same reasons as
+outlined in the document ["Polymorphic Associations"](polymorphic_associations.md).
+
+## Solution
+
+The solution is very simple: just use a separate table for every type you'd
+otherwise store in the same table. For example, instead of having a `keys` table
+with `type` set to either `Key` or `DeployKey` you'd have two separate tables:
+`keys` and `deploy_keys`.
diff --git a/doc/development/sql.md b/doc/development/sql.md
index 23fd7604957..974b1d99dff 100644
--- a/doc/development/sql.md
+++ b/doc/development/sql.md
@@ -216,4 +216,30 @@ exact same results. This also means there's no need to add an index on
`created_at` to ensure consistent performance as `id` is already indexed by
default.
+## Use WHERE EXISTS instead of WHERE IN
+
+While `WHERE IN` and `WHERE EXISTS` can be used to produce the same data it is
+recommended to use `WHERE EXISTS` whenever possible. While in many cases
+PostgreSQL can optimise `WHERE IN` quite well there are also many cases where
+`WHERE EXISTS` will perform (much) better.
+
+In Rails you have to use this by creating SQL fragments:
+
+```ruby
+Project.where('EXISTS (?)', User.select(1).where('projects.creator_id = users.id AND users.foo = X'))
+```
+
+This would then produce a query along the lines of the following:
+
+```sql
+SELECT *
+FROM projects
+WHERE EXISTS (
+ SELECT 1
+ FROM users
+ WHERE projects.creator_id = users.id
+ AND users.foo = X
+)
+```
+
[gin-index]: http://www.postgresql.org/docs/current/static/gin.html
diff --git a/doc/development/testing.md b/doc/development/testing.md
index 9b0b9808827..efd56484b12 100644
--- a/doc/development/testing.md
+++ b/doc/development/testing.md
@@ -25,7 +25,7 @@ records should use stubs/doubles as much as possible.
| --------- | ---------- | -------------- | ----- |
| `app/finders/` | `spec/finders/` | RSpec | |
| `app/helpers/` | `spec/helpers/` | RSpec | |
-| `app/db/{post_,}migrate/` | `spec/migrations/` | RSpec | |
+| `app/db/{post_,}migrate/` | `spec/migrations/` | RSpec | More details at [`spec/migrations/README.md`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/migrations/README.md). |
| `app/policies/` | `spec/policies/` | RSpec | |
| `app/presenters/` | `spec/presenters/` | RSpec | |
| `app/routing/` | `spec/routing/` | RSpec | |
@@ -157,8 +157,9 @@ trade-off:
- Unit tests are usually cheap, and you should consider them like the basement
of your house: you need them to be confident that your code is behaving
- correctly. However if you run only unit tests without integration / system tests, you might [miss] the [big] [picture]!
-- Integration tests are a bit more expensive, but don't abuse them. A feature test
+ correctly. However if you run only unit tests without integration / system
+ tests, you might [miss] the [big] [picture]!
+- Integration tests are a bit more expensive, but don't abuse them. A system test
is often better than an integration test that is stubbing a lot of internals.
- System tests are expensive (compared to unit tests), even more if they require
a JavaScript driver. Make sure to follow the guidelines in the [Speed](#test-speed)
@@ -188,24 +189,34 @@ Please consult the [dedicated "Frontend testing" guide](./fe_guide/testing.md).
### General Guidelines
- Use a single, top-level `describe ClassName` block.
-- Use `described_class` instead of repeating the class name being described.
- Use `.method` to describe class methods and `#method` to describe instance
methods.
- Use `context` to test branching logic.
-- Use multi-line `do...end` blocks for `before` and `after`, even when it would
- fit on a single line.
-- Don't `describe` symbols (see [Gotchas](gotchas.md#dont-describe-symbols)).
- Don't assert against the absolute value of a sequence-generated attribute (see [Gotchas](gotchas.md#dont-assert-against-the-absolute-value-of-a-sequence-generated-attribute)).
-- Don't supply the `:each` argument to hooks since it's the default.
-- Prefer `not_to` to `to_not` (_this is enforced by Rubocop_).
- Try to match the ordering of tests to the ordering within the class.
- Try to follow the [Four-Phase Test][four-phase-test] pattern, using newlines
to separate phases.
-- Try to use `Gitlab.config.gitlab.host` rather than hard coding `'localhost'`
+- Use `Gitlab.config.gitlab.host` rather than hard coding `'localhost'`
+- Don't assert against the absolute value of a sequence-generated attribute (see
+ [Gotchas](gotchas.md#dont-assert-against-the-absolute-value-of-a-sequence-generated-attribute)).
+- Don't supply the `:each` argument to hooks since it's the default.
- On `before` and `after` hooks, prefer it scoped to `:context` over `:all`
[four-phase-test]: https://robots.thoughtbot.com/four-phase-test
+### Automatic retries and flaky tests detection
+
+On our CI, we use [rspec-retry] to automatically retry a failing example a few
+times (see [`spec/spec_helper.rb`] for the precise retries count).
+
+We also use a home-made `RspecFlaky::Listener` listener which records flaky
+examples in a JSON report file on `master` (`retrieve-tests-metadata` and `update-tests-metadata` jobs), and warns when a new flaky example
+is detected in any other branch (`flaky-examples-check` job). In the future, the
+`flaky-examples-check` job will not be allowed to fail.
+
+[rspec-retry]: https://github.com/NoRedInk/rspec-retry
+[`spec/spec_helper.rb`]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/spec_helper.rb
+
### `let` variables
GitLab's RSpec suite has made extensive use of `let` variables to reduce
@@ -268,6 +279,43 @@ end
- Avoid scenario titles that add no information, such as "successfully".
- Avoid scenario titles that repeat the feature title.
+### Table-based / Parameterized tests
+
+This style of testing is used to exercise one piece of code with a comprehensive
+range of inputs. By specifying the test case once, alongside a table of inputs
+and the expected output for each, your tests can be made easier to read and more
+compact.
+
+We use the [rspec-parameterized](https://github.com/tomykaira/rspec-parameterized)
+gem. A short example, using the table syntax and checking Ruby equality for a
+range of inputs, might look like this:
+
+```ruby
+describe "#==" do
+ using Rspec::Parameterized::TableSyntax
+
+ let(:project1) { create(:project) }
+ let(:project2) { create(:project) }
+ where(:a, :b, :result) do
+ 1 | 1 | true
+ 1 | 2 | false
+ true | true | true
+ true | false | false
+ project1 | project1 | true
+ project2 | project2 | true
+ project 1 | project2 | false
+ end
+
+ with_them do
+ it { expect(a == b).to eq(result) }
+
+ it 'is isomorphic' do
+ expect(b == a).to eq(result)
+ end
+ end
+end
+```
+
### Matchers
Custom matchers should be created to clarify the intent and/or hide the
@@ -276,6 +324,15 @@ complexity of RSpec expectations.They should be placed under
a certain type of specs only (e.g. features, requests etc.) but shouldn't be if
they apply to multiple type of specs.
+#### have_gitlab_http_status
+
+Prefer `have_gitlab_http_status` over `have_http_status` because the former
+could also show the response body whenever the status mismatched. This would
+be very useful whenever some tests start breaking and we would love to know
+why without editing the source and rerun the tests.
+
+This is especially useful whenever it's showing 500 internal server error.
+
### Shared contexts
All shared contexts should be be placed under `spec/support/shared_contexts/`.
@@ -426,8 +483,6 @@ Here are some things to keep in mind regarding test performance:
- `FactoryGirl.build(...)` and `.build_stubbed` are faster than `.create`.
- Don't `create` an object when `build`, `build_stubbed`, `attributes_for`,
`spy`, or `double` will do. Database persistence is slow!
-- Use `create(:empty_project)` instead of `create(:project)` when you don't need
- the underlying Git repository. Filesystem operations are slow!
- Don't mark a feature as requiring JavaScript (through `@javascript` in
Spinach or `:js` in RSpec) unless it's _actually_ required for the test
to be valid. Headless browser testing is slow!
@@ -478,6 +533,11 @@ slowest test files and try to improve them.
run the suite against MySQL for tags, `master`, and any branch that includes
`mysql` in the name.
- On EE, the test suite always runs both PostgreSQL and MySQL.
+- Rails logging to `log/test.log` is disabled by default in CI [for
+ performance reasons][logging]. To override this setting, provide the
+ `RAILS_ENABLE_TEST_LOG` environment variable.
+
+[logging]: https://jtway.co/speed-up-your-rails-test-suite-by-6-in-1-line-13fedb869ec4
## Spinach (feature) tests
diff --git a/doc/development/ux_guide/basics.md b/doc/development/ux_guide/basics.md
index 259b214bd59..a436e9b1948 100644
--- a/doc/development/ux_guide/basics.md
+++ b/doc/development/ux_guide/basics.md
@@ -22,7 +22,7 @@ GitLab's main typeface used throughout the UI is **Source Sans Pro**. We support
### Monospace typeface
-This is the typeface used for code blocks. GitLab uses the OS default font.
+This is the typeface used for code blocks and references to commits, branches, and tags (`.commit-sha` or `.ref-name`). GitLab uses the OS default font.
- **Menlo** (Mac)
- **Consolas** (Windows)
- **Liberation Mono** (Linux)
diff --git a/doc/development/ux_guide/copy.md b/doc/development/ux_guide/copy.md
index 794c8eb6bfe..12e8d0a31bb 100644
--- a/doc/development/ux_guide/copy.md
+++ b/doc/development/ux_guide/copy.md
@@ -106,6 +106,14 @@ When using verbs or adjectives:
* If the context clearly refers to the object, use them alone. Example: `Edit` or `Closed`
* If the context isn’t clear enough, use them with the object. Example: `Edit issue` or `Closed issues`
+### Search
+
+| Term | Use |
+| ---- | --- |
+| Search | When using all metadata to add criteria that match/don't match. Search can also affect ordering, by ranking best results. |
+| Filter | When taking a single criteria that removes items within a list that match/don't match. Filters do not affect ordering. |
+| Sort | Orders a list based on a single or grouped criteria |
+
### Projects and Groups
| Term | Use | :no_entry_sign: Don't |
diff --git a/doc/development/verifying_database_capabilities.md b/doc/development/verifying_database_capabilities.md
new file mode 100644
index 00000000000..cc6d62957e3
--- /dev/null
+++ b/doc/development/verifying_database_capabilities.md
@@ -0,0 +1,26 @@
+# Verifying Database Capabilities
+
+Sometimes certain bits of code may only work on a certain database and/or
+version. While we try to avoid such code as much as possible sometimes it is
+necessary to add database (version) specific behaviour.
+
+To facilitate this we have the following methods that you can use:
+
+* `Gitlab::Database.postgresql?`: returns `true` if PostgreSQL is being used
+* `Gitlab::Database.mysql?`: returns `true` if MySQL is being used
+* `Gitlab::Database.version`: returns the PostgreSQL version number as a string
+ in the format `X.Y.Z`. This method does not work for MySQL
+
+This allows you to write code such as:
+
+```ruby
+if Gitlab::Database.postgresql?
+ if Gitlab::Database.version.to_f >= 9.6
+ run_really_fast_query
+ else
+ run_fast_query
+ end
+else
+ run_query
+end
+```
diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md
index 8da6ad684f5..c4830322fa8 100644
--- a/doc/development/what_requires_downtime.md
+++ b/doc/development/what_requires_downtime.md
@@ -139,6 +139,8 @@ Adding or removing a NOT NULL clause (or another constraint) can typically be
done without requiring downtime. However, this does require that any application
changes are deployed _first_. Thus, changing the constraints of a column should
happen in a post-deployment migration.
+NOTE: Avoid using `change_column` as it produces inefficient query because it re-defines
+the whole column type. For example, to add a NOT NULL constraint, prefer `change_column_null `
## Changing Column Types
diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md
index 166a10293c3..eac9ec2a470 100644
--- a/doc/development/writing_documentation.md
+++ b/doc/development/writing_documentation.md
@@ -52,11 +52,13 @@ Every **Technical Article** contains, in the very beginning, a blockquote with t
- A reference to the **type of article** (user guide, admin guide, tech overview, tutorial)
- A reference to the **knowledge level** expected from the reader to be able to follow through (beginner, intermediate, advanced)
- A reference to the **author's name** and **GitLab.com handle**
+- A reference of the **publication date**
```md
-> **Type:** tutorial ||
+> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial ||
> **Level:** intermediary ||
-> **Author:** [Name Surname](https://gitlab.com/username)
+> **Author:** [Name Surname](https://gitlab.com/username) ||
+> **Publication date:** AAAA/MM/DD
```
#### Technical Articles - Writing Method
@@ -70,3 +72,34 @@ All the docs follow the same [styleguide](doc_styleguide.md).
### Markdown
Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, but there's an [open discussion](https://gitlab.com/gitlab-com/gitlab-docs/issues/50) for implementing Kramdown in the near future.
+
+## Testing
+
+We try to treat documentation as code, thus have implemented some testing.
+Currently, the following tests are in place:
+
+1. `docs lint`: Check that all internal (relative) links work correctly and
+ that all cURL examples in API docs use the full switches.
+
+If your contribution contains **only** documentation changes, you can speed up
+the CI process by following some branch naming conventions. You have three
+choices:
+
+| Branch name | Valid example |
+| ----------- | ------------- |
+| Starting with `docs/` | `docs/update-api-issues` |
+| Starting with `docs-` | `docs-update-api-issues` |
+| Ending in `-docs` | `123-update-api-issues-docs` |
+
+If your branch name matches any of the above, it will run only the docs
+tests. If it doesn't, the whole test suite will run (including docs).
+
+---
+
+When you submit a merge request to GitLab Community Edition (CE), there is an
+additional job called `rake ee_compat_check` that runs against Enterprise
+Edition (EE) and checks if your changes can apply cleanly to the EE codebase.
+If that job fails, read the instructions in the job log for what to do next.
+Contributors do not need to submit their changes to EE, GitLab Inc. employees
+on the other hand need to make sure that their changes apply cleanly to both
+CE and EE.
diff --git a/doc/downgrade_ee_to_ce/README.md b/doc/downgrade_ee_to_ce/README.md
index fe4b6d73771..75bae324585 100644
--- a/doc/downgrade_ee_to_ce/README.md
+++ b/doc/downgrade_ee_to_ce/README.md
@@ -46,6 +46,19 @@ $ sudo gitlab-rails runner "Service.where(type: ['JenkinsService', 'JenkinsDepre
$ bundle exec rails runner "Service.where(type: ['JenkinsService', 'JenkinsDeprecatedService']).delete_all" production
```
+### Secret variables environment scopes
+
+If you're using this feature and there are variables sharing the same
+key, but they have different scopes in a project, then you might want to
+revisit the environment scope setting for those variables.
+
+In CE, environment scopes are completely ignored, therefore you could
+accidentally get a variable which you're not expecting for a particular
+environment. Make sure that you have the right variables in this case.
+
+Data is completely preserved, so you could always upgrade back to EE and
+restore the behavior if you leave it alone.
+
## Downgrade to CE
After performing the above mentioned steps, you are now ready to downgrade your
diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md
index d7e3aa35bdd..3d893ba53dd 100644
--- a/doc/gitlab-basics/README.md
+++ b/doc/gitlab-basics/README.md
@@ -6,10 +6,10 @@ Step-by-step guides on the basics of working with Git and GitLab.
- [Start using Git on the command line](start-using-git.md)
- [Create and add your SSH Keys](create-your-ssh-keys.md)
- [Create a project](create-project.md)
-- [Create a group](create-group.md)
+- [Create a group](../user/group/index.md#create-a-new-group)
- [Create a branch](create-branch.md)
- [Fork a project](fork-project.md)
- [Add a file](add-file.md)
- [Add an image](add-image.md)
-- [Create an issue](create-issue.md)
+- [Create an issue](../user/project/issues/create_new_issue.md)
- [Create a merge request](add-merge-request.md)
diff --git a/doc/gitlab-basics/create-group.md b/doc/gitlab-basics/create-group.md
index b4889bb8818..985a52d88f5 100644
--- a/doc/gitlab-basics/create-group.md
+++ b/doc/gitlab-basics/create-group.md
@@ -1,50 +1,2 @@
-# How to create a group in GitLab
-Your projects in GitLab can be organized in 2 different ways:
-under your own namespace for single projects, such as `your-name/project-1` or
-under groups.
-
-If you organize your projects under a group, it works like a folder. You can
-manage your group members' permissions and access to the projects.
-
----
-
-To create a group:
-
-1. Expand the left sidebar by clicking the three bars at the upper left corner
- and then navigate to **Groups**.
-
- ![Go to groups](img/create_new_group_sidebar.png)
-
-1. Once in your groups dashboard, click on **New group**.
-
- ![Create new group information](img/create_new_group_info.png)
-
-1. Fill out the needed information:
-
- 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.
- 1. Choose the [visibility level](../public_access/public_access.md).
-
-1. Finally, click the **Create group** button.
-
-## Add a new project to a group
-
-There are 2 different ways to add a new project to a group:
-
-- Select a group and then click on the **New project** button.
-
- ![New project](img/create_new_project_from_group.png)
-
- You can then continue on [creating a project](create-project.md).
-
-- While you are [creating a project](create-project.md), select a group namespace
- you've already created from the dropdown menu.
-
- ![Select group](img/select_group_dropdown.png)
+This document was moved to [another location](../user/group/index.md#create-a-new-group).
diff --git a/doc/gitlab-basics/create-issue.md b/doc/gitlab-basics/create-issue.md
index 13e5a738c89..abb163dbf18 100644
--- a/doc/gitlab-basics/create-issue.md
+++ b/doc/gitlab-basics/create-issue.md
@@ -1,30 +1,2 @@
-# How to create an Issue in GitLab
-The issue tracker is a good place to add things that need to be improved or
-solved in a project.
-
----
-
-1. Go to the project where you'd like to create the issue and navigate to the
- **Issues** tab on top.
-
- ![Issues](img/project_navbar.png)
-
-1. Click on the **New issue** button on the right side of your screen.
-
- ![New issue](img/new_issue_button.png)
-
-1. At the very minimum, add a title and a description to your issue.
- You may assign it to a user, add a milestone or add labels (all optional).
-
- ![Issue title and description](img/new_issue_page.png)
-
-1. When ready, click on **Submit issue**.
-
----
-
-Your Issue will now be added to the issue tracker of the project you opened it
-at and will be ready to be reviewed. You can comment on it and mention the
-people involved. You can also link issues to the merge requests where the issues
-are solved. To do this, you can use an
-[issue closing pattern](../user/project/issues/automatic_issue_closing.md).
+This document was moved to [another location](../user/project/issues/index.md#new-issue).
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index 1c549844ee1..b4b77a2f94b 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -1,24 +1,40 @@
# How to create a project in GitLab
-There are two ways to create a new project in GitLab.
+>**Notes:**
+- For a list of words that are not allowed to be used as project names see the
+ [reserved names][reserved].
-1. While in your dashboard, you can create a new project using the **New project**
- green button or you can use the cross icon in the upper right corner next to
- your avatar which is always visible.
+1. In your dashboard, click the green **New project** button or use the plus
+ icon in the upper right corner of the navigation bar.
![Create a project](img/create_new_project_button.png)
-1. From there you can see several options.
+1. This opens the **New project** page.
![Project information](img/create_new_project_info.png)
-1. Fill out the information:
-
- 1. "Project name" is the name of your project (you can't use special characters,
- but you can use spaces, hyphens, underscores or even emojis).
- 1. The "Project description" is optional and will be shown in your project's
- dashboard so others can briefly understand what your project is about.
- 1. Select a [visibility level](../public_access/public_access.md).
- 1. You can also [import your existing projects](../workflow/importing/README.md).
-
-1. Finally, click **Create project**.
+1. Provide the following information:
+ - Enter the name of your project in the **Project name** field. You can't use
+ special characters, but you can use spaces, hyphens, underscores or even
+ emoji.
+ - If you have a project in a different repository, you can [import it] by
+ clicking an **Import project from** button provided this is enabled in
+ your GitLab instance. Ask your administrator if not.
+ - The **Project description (optional)** field enables you to enter a
+ description for your project's dashboard, which will help others
+ understand what your project is about. Though it's not required, it's a good
+ idea to fill this in.
+ - Changing the **Visibility Level** modifies the project's
+ [viewing and access rights](../public_access/public_access.md) for users.
+
+1. Click **Create project**.
+
+## From a template
+
+To kickstart your development GitLab projects can be started from a template.
+For example, one of the templates included is Ruby on Rails. When filling out the
+form for new projects, click the 'Ruby on Rails' button. During project creation,
+this will import a Ruby on Rails template with GitLab CI preconfigured.
+
+[import it]: ../workflow/importing/README.md
+[reserved]: ../user/reserved_names.md
diff --git a/doc/gitlab-basics/img/create_new_group_sidebar.png b/doc/gitlab-basics/img/create_new_group_sidebar.png
deleted file mode 100644
index fa88d1d51c0..00000000000
--- a/doc/gitlab-basics/img/create_new_group_sidebar.png
+++ /dev/null
Binary files differ
diff --git a/doc/gitlab-basics/img/create_new_project_button.png b/doc/gitlab-basics/img/create_new_project_button.png
index 8d7a69e55ed..567f104880f 100644
--- a/doc/gitlab-basics/img/create_new_project_button.png
+++ b/doc/gitlab-basics/img/create_new_project_button.png
Binary files differ
diff --git a/doc/install/README.md b/doc/install/README.md
index 58cc7d312fd..bc831a37735 100644
--- a/doc/install/README.md
+++ b/doc/install/README.md
@@ -18,8 +18,8 @@ the hardware requirements.
Useful for unsupported systems like *BSD. For an overview of the directory
structure, read the [structure documentation](structure.md).
- [Docker](https://docs.gitlab.com/omnibus/docker/) - Install GitLab using Docker.
-- [Installation on Google Cloud Platform](google_cloud_platform/index.md) - Install
- GitLab on Google Cloud Platform using our official image.
+- [Installing in Kubernetes](kubernetes/index.md) - Install GitLab into a Kubernetes
+ Cluster using our official Helm Chart Repository.
- Testing only! [DigitalOcean and Docker Machine](digitaloceandocker.md) -
Quickly test any version of GitLab on DigitalOcean using Docker Machine.
diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md
index da2dac23c6a..bc75dc1447e 100644
--- a/doc/install/database_mysql.md
+++ b/doc/install/database_mysql.md
@@ -39,11 +39,14 @@ mysql> SET storage_engine=INNODB;
# If you have MySQL < 5.7.7 and want to enable utf8mb4 character set support with your GitLab install, you must set the following NOW:
mysql> SET GLOBAL innodb_file_per_table=1, innodb_file_format=Barracuda, innodb_large_prefix=1;
+# If you use MySQL with replication, or just have MySQL configured with binary logging, you need to run the following to allow the use of `TRIGGER`:
+mysql> SET GLOBAL log_bin_trust_function_creators = 1;
+
# Create the GitLab production database
mysql> CREATE DATABASE IF NOT EXISTS `gitlabhq_production` DEFAULT CHARACTER SET `utf8` COLLATE `utf8_general_ci`;
# Grant the GitLab user necessary permissions on the database
-mysql> GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER, LOCK TABLES, REFERENCES ON `gitlabhq_production`.* TO 'git'@'localhost';
+mysql> GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER, LOCK TABLES, REFERENCES, TRIGGER ON `gitlabhq_production`.* TO 'git'@'localhost';
# Quit the database session
mysql> \q
@@ -60,7 +63,15 @@ mysql> \q
```
You are done installing the database for now and can go back to the rest of the installation.
-Please proceed to the rest of the installation before running through the utf8mb4 support section.
+Please proceed to the rest of the installation **before** running through the steps below.
+
+### `log_bin_trust_function_creators`
+
+If you use MySQL with replication, or just have MySQL configured with binary logging, all of your MySQL servers will need to have `log_bin_trust_function_creators` enabled to allow the use of `TRIGGER` in migrations. You have already set this global variable in the steps above, but to make it persistent, add the following to your `my.cnf` file:
+
+```
+log_bin_trust_function_creators=1
+```
### MySQL utf8mb4 support
@@ -281,5 +292,5 @@ GitLab database to `longtext` columns, which can persist values of up to 4GB
Details can be found in the [PostgreSQL][postgres-text-type] and
[MySQL][mysql-text-types] manuals.
-[postgres-text-type]: http://www.postgresql.org/docs/9.1/static/datatype-character.html
+[postgres-text-type]: http://www.postgresql.org/docs/9.2/static/datatype-character.html
[mysql-text-types]: http://dev.mysql.com/doc/refman/5.7/en/string-type-overview.html
diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md
index 26506111548..c6b767fff02 100644
--- a/doc/install/google_cloud_platform/index.md
+++ b/doc/install/google_cloud_platform/index.md
@@ -5,6 +5,10 @@
The fastest way to get started on [Google Cloud Platform (GCP)][gcp] is through
the [Google Cloud Launcher][launcher] program.
+GitLab's official Google Launcher apps:
+1. [GitLab Community Edition](https://console.cloud.google.com/launcher/details/gitlab-public/gitlab-community-edition?project=gitlab-public)
+2. [GitLab Enterprise Edition](https://console.cloud.google.com/launcher/details/gitlab-public/gitlab-enterprise-edition?project=gitlab-public)
+
## Prerequisites
There are only two prerequisites in order to install GitLab on GCP:
diff --git a/doc/install/installation.md b/doc/install/installation.md
index b6bbc2a0af6..66eb7675896 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -64,7 +64,10 @@ up-to-date and install it.
Install the required packages (needed to compile Ruby and native extensions to Ruby gems):
- sudo apt-get install -y build-essential zlib1g-dev libyaml-dev libssl-dev libgdbm-dev libreadline-dev libncurses5-dev libffi-dev curl openssh-server checkinstall libxml2-dev libxslt-dev libcurl4-openssl-dev libicu-dev logrotate python-docutils pkg-config cmake
+ sudo apt-get install -y build-essential zlib1g-dev libyaml-dev libssl-dev libgdbm-dev libre2-dev libreadline-dev libncurses5-dev libffi-dev curl openssh-server checkinstall libxml2-dev libxslt-dev libcurl4-openssl-dev libicu-dev logrotate python-docutils pkg-config cmake
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
If you want to use Kerberos for user authentication, then install libkrb5-dev:
@@ -77,7 +80,7 @@ Make sure you have the right version of Git installed
# Install Git
sudo apt-get install -y git-core
- # Make sure Git is version 2.8.4 or higher
+ # Make sure Git is version 2.13.0 or higher
git --version
Is the system packaged Git too old? Remove it and compile from source.
@@ -109,14 +112,19 @@ Then select 'Internet Site' and press enter to confirm the hostname.
## 2. Ruby
-**Note:** The current supported Ruby version is 2.3.x. GitLab 9.0 dropped support
-for Ruby 2.1.x.
+The Ruby interpreter is required to run GitLab.
+
+**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped
+support for Ruby 2.1.x.
The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab
in production, frequently leads to hard to diagnose problems. For example,
GitLab Shell is called from OpenSSH, and having a version manager can prevent
pushing and pulling over SSH. Version managers are not supported and we strongly
-advise everyone to follow the instructions below to use a system Ruby.
+advise everyone to follow the instructions below to use a system Ruby.
+
+Linux distributions generally have older versions of Ruby available, so these
+instructions are designed to install Ruby from the official source code.
Remove the old Ruby 1.8 if present:
@@ -132,26 +140,25 @@ Download Ruby and compile it:
make
sudo make install
-Install the Bundler Gem:
+Then install the Bundler Gem:
sudo gem install bundler --no-ri --no-rdoc
## 3. Go
-Since GitLab 8.0, Git HTTP requests are handled by gitlab-workhorse (formerly
-gitlab-git-http-server). This is a small daemon written in Go. To install
-gitlab-workhorse we need a Go compiler. The instructions below assume you
-use 64-bit Linux. You can find downloads for other platforms at the [Go download
+Since GitLab 8.0, GitLab has several daemons written in Go. To install
+GitLab we need a Go compiler. The instructions below assume you use 64-bit
+Linux. You can find downloads for other platforms at the [Go download
page](https://golang.org/dl).
# Remove former Go installation folder
sudo rm -rf /usr/local/go
- curl --remote-name --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz
- echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
- sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz
+ curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+ echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
- rm go1.5.3.linux-amd64.tar.gz
+ rm go1.8.3.linux-amd64.tar.gz
## 4. Node
@@ -161,11 +168,13 @@ In many distros the versions provided by the official package repositories
are out of date, so we'll need to install through the following commands:
# install node v7.x
- curl --location https://deb.nodesource.com/setup_7.x | bash -
+ curl --location https://deb.nodesource.com/setup_7.x | sudo bash -
sudo apt-get install -y nodejs
- # install yarn
- curl --location https://yarnpkg.com/install.sh | bash -
+ curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+ sudo apt-get update
+ sudo apt-get install yarn
Visit the official websites for [node](https://nodejs.org/en/download/package-manager/) and [yarn](https://yarnpkg.com/en/docs/install/) if you have any trouble with these steps.
@@ -180,7 +189,8 @@ Create a `git` user for GitLab:
We recommend using a PostgreSQL database. For MySQL check the
[MySQL setup guide](database_mysql.md).
-> **Note**: because we need to make use of extensions you need at least pgsql 9.1.
+> **Note**: because we need to make use of extensions and concurrent index removal,
+you need at least PostgreSQL 9.2.
1. Install the database packages:
@@ -289,9 +299,9 @@ sudo usermod -aG redis git
### Clone the Source
# Clone GitLab repository
- sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-1-stable gitlab
+ sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-5-stable gitlab
-**Note:** You can change `9-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
+**Note:** You can change `9-5-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
### Configure It
@@ -415,6 +425,12 @@ GitLab Shell is an SSH access and repository management software developed speci
**Note:** Make sure your hostname can be resolved on the machine itself by either a proper DNS record or an additional line in /etc/hosts ("127.0.0.1 hostname"). This might be necessary for example if you set up GitLab behind a reverse proxy. If the hostname cannot be resolved, the final installation check will fail with "Check GitLab API access: FAILED. code: 401" and pushing commits will be rejected with "[remote rejected] master -> master (hook declined)".
+**Note:** GitLab Shell application startup time can be greatly reduced by disabling RubyGems. This can be done in several manners:
+
+* Export `RUBYOPT=--disable-gems` environment variable for the processes
+* Compile Ruby with `configure --disable-rubygems` to disable RubyGems by default. Not recommened for system-wide Ruby.
+* Omnibus GitLab [replaces the *shebang* line of the `gitlab-shell/bin/*` scripts](https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/1707)
+
### Install gitlab-workhorse
GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). The
@@ -423,6 +439,11 @@ which is the recommended location.
sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production
+You can specify a different Git repository by providing it as an extra paramter:
+
+ sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse,https://example.com/gitlab-workhorse.git]" RAILS_ENV=production
+
+
### Initialize Database and Activate Advanced Features
sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production
@@ -459,13 +480,15 @@ Make GitLab start on boot:
### Install Gitaly
-As of GitLab 9.1 Gitaly is an **optional** component. Its
-configuration is still changing regularly. It is OK to wait
-with setting up Gitaly until you upgrade to GitLab 9.2 or later.
-
# Fetch Gitaly source with Git and compile with Go
sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production
+You can specify a different Git repository by providing it as an extra paramter:
+
+ sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly,https://example.com/gitaly.git]" RAILS_ENV=production
+
+Next, make sure gitaly configured:
+
# Restrict Gitaly socket access
sudo chmod 0700 /home/git/gitlab/tmp/sockets/private
sudo chown git /home/git/gitlab/tmp/sockets/private
@@ -474,16 +497,6 @@ with setting up Gitaly until you upgrade to GitLab 9.2 or later.
cd /home/git/gitaly
sudo -u git -H editor config.toml
- # Enable Gitaly in the init script
- echo 'gitaly_enabled=true' | sudo tee -a /etc/default/gitlab
-
-Next, edit `/home/git/gitlab/config/gitlab.yml` and make sure `enabled: true` in
-the `gitaly:` section is uncommented.
-
- # <- gitlab.yml indentation starts here
- gitaly:
- enabled: true
-
For more information about configuring Gitaly see
[doc/administration/gitaly](../administration/gitaly).
@@ -497,6 +510,12 @@ Check if GitLab and its environment are configured correctly:
sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
+
+### Compile GetText PO files
+
+ sudo -u git -H bundle exec rake gettext:pack RAILS_ENV=production
+ sudo -u git -H bundle exec rake gettext:po_to_json RAILS_ENV=production
+
### Compile Assets
sudo -u git -H yarn install --production --pure-lockfile
diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md
new file mode 100644
index 00000000000..0fad181f59e
--- /dev/null
+++ b/doc/install/kubernetes/gitlab_chart.md
@@ -0,0 +1,476 @@
+# GitLab Helm Chart
+> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
+
+> Officially supported cloud providers are Google Container Service and Azure Container Service.
+
+The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster.
+
+This chart includes the following:
+
+- Deployment using the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce) or [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee) container image
+- ConfigMap containing the `gitlab.rb` contents that configure [Omnibus GitLab](https://docs.gitlab.com/omnibus/settings/configuration.html#configuration-options)
+- Persistent Volume Claims for Data, Config, Logs, and Registry Storage
+- A Kubernetes service
+- Optional Redis deployment using the [Redis Chart](https://github.com/kubernetes/charts/tree/master/stable/redis) (defaults to enabled)
+- Optional PostgreSQL deployment using the [PostgreSQL Chart](https://github.com/kubernetes/charts/tree/master/stable/postgresql) (defaults to enabled)
+- Optional Ingress (defaults to disabled)
+
+## Prerequisites
+
+- _At least_ 3 GB of RAM available on your cluster, in chunks of 1 GB. 41GB of storage and 2 CPU are also required.
+- Kubernetes 1.4+ with Beta APIs enabled
+- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure
+- The ability to point a DNS entry or URL at your GitLab install
+- The `kubectl` CLI installed locally and authenticated for the cluster
+- The Helm Client installed locally
+- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
+- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository)
+
+## Configuring GitLab
+
+Create a `values.yaml` file for your GitLab configuration. See the
+[Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md)
+for information on how your values file will override the defaults.
+
+The default configuration can always be [found in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab/values.yaml), in the chart repository.
+
+### Required configuration
+
+In order for GitLab to function, your config file **must** specify the following:
+
+- An `externalUrl` that GitLab will be reachable at.
+
+### Choosing GitLab Edition
+
+The Helm chart defaults to installing GitLab CE. This can be controlled by setting the `edition` variable in your values.
+
+Setting `edition` to GitLab Enterprise Edition (EE) in your `values.yaml`
+
+```yaml
+edition: EE
+
+externalUrl: 'http://gitlab.example.com'
+```
+
+### Choosing a different GitLab release version
+
+The version of GitLab installed is based on the `edition` setting (see [section](#choosing-gitlab-edition) above), and
+the value of the corresponding helm setting: `ceImage` or `eeImage`.
+
+```yaml
+## GitLab Edition
+## ref: https://about.gitlab.com/products/
+## - CE - Community Edition
+## - EE - Enterprise Edition - (requires license issued by GitLab Inc)
+##
+edition: CE
+
+## GitLab CE image
+## ref: https://hub.docker.com/r/gitlab/gitlab-ce/tags/
+##
+ceImage: gitlab/gitlab-ce:9.1.2-ce.0
+
+## GitLab EE image
+## ref: https://hub.docker.com/r/gitlab/gitlab-ee/tags/
+##
+eeImage: gitlab/gitlab-ee:9.1.2-ee.0
+```
+
+The different images can be found in the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce/tags/) and [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee/tags/)
+repositories on Docker Hub
+
+> **Note:**
+There is no guarantee that other release versions of GitLab, other than what are
+used by default in the chart, will be supported by a chart install.
+
+
+### Custom Omnibus GitLab configuration
+
+In addition to the configuration options provided for GitLab in the Helm Chart, you can also pass any custom configuration
+that is valid for the [Omnibus GitLab Configuration](https://docs.gitlab.com/omnibus/settings/configuration.html).
+
+The setting to pass these values in is `omnibusConfigRuby`. It accepts any valid
+Ruby code that could used in the Omnibus `/etc/gitlab/gitlab.rb` file. In
+Kubernetes, the contents will be stored in a ConfigMap.
+
+Example setting:
+
+```yaml
+omnibusConfigRuby: |
+ unicorn['worker_processes'] = 2;
+ gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"];
+```
+
+### Persistent storage
+
+By default, persistent storage is enabled for GitLab and the charts it depends
+on (Redis and PostgreSQL).
+
+Components can have their claim size set from your `values.yaml`, and each
+component allows you to optionally configure the `storageClass` variable so you
+can take advantage of faster drives on your cloud provider.
+
+Basic configuration:
+
+```yaml
+## Enable persistence using Persistent Volume Claims
+## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/
+## ref: https://docs.gitlab.com/ce/install/requirements.html#storage
+##
+persistence:
+ ## This volume persists generated configuration files, keys, and certs.
+ ##
+ gitlabEtc:
+ enabled: true
+ size: 1Gi
+ ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass>
+ ## Default: volume.alpha.kubernetes.io/storage-class: default
+ ##
+ # storageClass:
+ accessMode: ReadWriteOnce
+ ## This volume is used to store git data and other project files.
+ ## ref: https://docs.gitlab.com/omnibus/settings/configuration.html#storing-git-data-in-an-alternative-directory
+ ##
+ gitlabData:
+ enabled: true
+ size: 10Gi
+ ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass>
+ ## Default: volume.alpha.kubernetes.io/storage-class: default
+ ##
+ # storageClass:
+ accessMode: ReadWriteOnce
+ gitlabRegistry:
+ enabled: true
+ size: 10Gi
+ ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass>
+ ## Default: volume.alpha.kubernetes.io/storage-class: default
+ ##
+ # storageClass:
+
+ postgresql:
+ persistence:
+ # storageClass:
+ size: 10Gi
+ ## Configuration values for the Redis dependency.
+ ## ref: https://github.com/kubernetes/charts/blob/master/stable/redis/README.md
+ ##
+ redis:
+ persistence:
+ # storageClass:
+ size: 10Gi
+```
+
+>**Note:**
+You can make use of faster SSD drives by adding a [StorageClass] to your cluster
+and using the `storageClass` setting in the above config to the name of
+your new storage class.
+
+### Routing
+
+By default, the GitLab chart uses a service type of `LoadBalancer` which will
+result in the GitLab service being exposed externally using your cloud provider's
+load balancer.
+
+This field is configurable in your `values.yml` by setting the top-level
+`serviceType` field. See the [Service documentation][kube-srv] for more
+information on the possible values.
+
+#### Ingress routing
+
+Optionally, you can enable the Chart's ingress for use by an ingress controller
+deployed in your cluster.
+
+To enable the ingress, edit its section in your `values.yaml`:
+
+```yaml
+ingress:
+ ## If true, gitlab Ingress will be created
+ ##
+ enabled: true
+
+ ## gitlab Ingress hostnames
+ ## Must be provided if Ingress is enabled
+ ##
+ hosts:
+ - gitlab.example.com
+
+ ## gitlab Ingress annotations
+ ##
+ annotations:
+ kubernetes.io/ingress.class: nginx
+```
+
+You must also provide the list of hosts that the ingress will use. In order for
+you ingress controller to work with the GitLab Ingress, you will need to specify
+its class in an annotation.
+
+>**Note:**
+The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that.
+Setting up an Ingress controller can be done by installing the `nginx-ingress` helm chart. But be sure
+to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md).
+>**Note:**
+If you would like to use the Registry, you will also need to ensure your Ingress supports a [sufficiently large request size](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size).
+
+#### Preserving Source IPs
+
+If you are using the `LoadBalancer` serviceType you may run into issues where user IP addresses in the GitLab
+logs, and used in abuse throttling are not accurate. This is due to how Kubernetes uses source NATing on cluster nodes without endpoints.
+
+See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) for more information.
+
+To fix this you can add the following service annotation to your `values.yaml`
+
+```yaml
+## For minikube, set this to NodePort, elsewhere use LoadBalancer
+## ref: http://kubernetes.io/docs/user-guide/services/#publishing-services---service-types
+##
+serviceType: LoadBalancer
+
+## Optional annotations for gitlab service.
+serviceAnnotations:
+ service.beta.kubernetes.io/external-traffic: "OnlyLocal"
+```
+
+>**Note:**
+If you are using the ingress routing, you will likely also need to specify the annotation on the service for the ingress
+controller. For `nginx-ingress` you can check the
+[configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration)
+on how to add the annotation to the `controller.service.annotations` array.
+
+>**Note:**
+When using the `nginx-ingress` controller on Google Container Engine (GKE), and using the `external-traffic` annotation,
+you will need to additionally set the `controller.kind` to be DaemonSet. Otherwise only pods running on the same node
+as the nginx controller will be able to reach GitLab. This may result in pods within your cluster not being able to reach GitLab.
+See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) and
+[nginx-ingress configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration)
+for more information.
+
+### External database
+
+You can configure the GitLab Helm chart to connect to an external PostgreSQL
+database.
+
+>**Note:**
+This is currently our recommended approach for a Production setup.
+
+To use an external database, in your `values.yaml`, disable the included
+PostgreSQL dependency, then configure access to your database:
+
+```yaml
+dbHost: "<reachable postgres hostname>"
+dbPassword: "<password for the user with access to the db>"
+dbUsername: "<user with read/write access to the database>"
+dbDatabase: "<database name on postgres to connect to for GitLab>"
+
+postgresql:
+ # Sets whether the PostgreSQL helm chart is used as a dependency
+ enabled: false
+```
+
+Be sure to check the GitLab documentation on how to
+[configure the external database](../requirements.md#postgresql-requirements)
+
+You can also configure the chart to use an external Redis server, but this is
+not required for basic production use:
+
+```yaml
+dbHost: "<reachable redis hostname>"
+dbPassword: "<password>"
+
+redis:
+ # Sets whether the Redis helm chart is used as a dependency
+ enabled: false
+```
+
+### Sending email
+
+By default, the GitLab container will not be able to send email from your cluster.
+In order to send email, you should configure SMTP settings in the
+`omnibusConfigRuby` section, as per the [GitLab Omnibus documentation](https://docs.gitlab.com/omnibus/settings/smtp.html).
+
+>**Note:**
+Some cloud providers restrict emails being sent out on SMTP, so you will have
+to use a SMTP service that is supported by your provider. See this
+[Google Cloud Platform page](https://cloud.google.com/compute/docs/tutorials/sending-mail/)
+as and example.
+
+Here is an example configuration for Mailgun SMTP support:
+
+```yaml
+omnibusConfigRuby: |
+ # This is example config of what you may already have in your omnibusConfigRuby object
+ unicorn['worker_processes'] = 2;
+ gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"];
+
+ # SMTP settings
+ gitlab_rails['smtp_enable'] = true
+ gitlab_rails['smtp_address'] = "smtp.mailgun.org"
+ gitlab_rails['smtp_port'] = 2525 # High port needed for Google Cloud
+ gitlab_rails['smtp_authentication'] = "plain"
+ gitlab_rails['smtp_enable_starttls_auto'] = false
+ gitlab_rails['smtp_user_name'] = "postmaster@mg.your-mail-domain"
+ gitlab_rails['smtp_password'] = "you-password"
+ gitlab_rails['smtp_domain'] = "mg.your-mail-domain"
+```
+
+### HTTPS configuration
+
+To setup HTTPS access to your GitLab server, first you need to configure the
+chart to use the [ingress](#ingress-routing).
+
+GitLab's config should be updated to support [proxied SSL](https://docs.gitlab.com/omnibus/settings/nginx.html#supporting-proxied-ssl).
+
+In addition to having a Ingress Controller deployed and the basic ingress
+settings configured, you will also need to specify in the ingress settings
+which hosts to use HTTPS for.
+
+Make sure `externalUrl` now includes `https://` instead of `http://` in its
+value, and update the `omnibusConfigRuby` section:
+
+```yaml
+externalUrl: 'https://gitlab.example.com'
+
+omnibusConfigRuby: |
+ # This is example config of what you may already have in your omnibusConfigRuby object
+ unicorn['worker_processes'] = 2;
+ gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"];
+
+ # These are the settings needed to support proxied SSL
+ nginx['listen_port'] = 80
+ nginx['listen_https'] = false
+ nginx['proxy_set_headers'] = {
+ "X-Forwarded-Proto" => "https",
+ "X-Forwarded-Ssl" => "on"
+ }
+
+ingress:
+ enabled: true
+ annotations:
+ kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support
+
+ hosts:
+ - gitlab.example.com
+
+ ## gitlab Ingress TLS configuration
+ ## Secrets must be created in the namespace, and is not done for you in this chart
+ ##
+ tls:
+ - secretName: gitlab-tls
+ hosts:
+ - gitlab.example.com
+```
+
+You will need to create the named secret in your cluster, specifying the private
+and public certificate pair using the format outlined in the
+[ingress documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#tls).
+
+Alternatively, you can use the `kubernetes.io/tls-acme` annotation, and install
+the `kube-lego` chart to your cluster to have Let's Encrypt issue your
+certificate. See the [kube-lego documentation](https://github.com/kubernetes/charts/blob/master/stable/kube-lego/README.md)
+for more information.
+
+### Enabling the GitLab Container Registry
+
+The GitLab Registry is disabled by default but can be enabled by providing an
+external URL for it in the configuration. In order for the Registry to be easily
+used by GitLab CI and your Kubernetes cluster, you will need to set it up with
+a TLS certificate, so these examples will include the ingress settings for that
+as well. See the [HTTPS Configuration section](#https-configuration)
+for more explanation on some of these settings.
+
+Example config:
+
+```yaml
+externalUrl: 'https://gitlab.example.com'
+
+omnibusConfigRuby: |
+ # This is example config of what you may already have in your omnibusConfigRuby object
+ unicorn['worker_processes'] = 2;
+ gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"];
+
+ registry_external_url 'https://registry.example.com';
+
+ # These are the settings needed to support proxied SSL
+ nginx['listen_port'] = 80
+ nginx['listen_https'] = false
+ nginx['proxy_set_headers'] = {
+ "X-Forwarded-Proto" => "https",
+ "X-Forwarded-Ssl" => "on"
+ }
+ registry_nginx['listen_port'] = 80
+ registry_nginx['listen_https'] = false
+ registry_nginx['proxy_set_headers'] = {
+ "X-Forwarded-Proto" => "https",
+ "X-Forwarded-Ssl" => "on"
+ }
+
+ingress:
+ enabled: true
+ annotations:
+ kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support
+
+ hosts:
+ - gitlab.example.com
+ - registry.example.com
+
+ ## gitlab Ingress TLS configuration
+ ## Secrets must be created in the namespace, and is not done for you in this chart
+ ##
+ tls:
+ - secretName: gitlab-tls
+ hosts:
+ - gitlab.example.com
+ - registry.example.com
+```
+
+## Installing GitLab using the Helm Chart
+> You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically restart. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage.
+
+Once you [have configured](#configuration) GitLab in your `values.yml` file,
+run the following:
+
+```bash
+helm install --namespace <NAMESPACE> --name gitlab -f <CONFIG_VALUES_FILE> gitlab/gitlab
+```
+
+where:
+
+- `<NAMESPACE>` is the Kubernetes namespace where you want to install GitLab.
+- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom
+ configuration. See the [Configuration](#configuration) section to create it.
+
+## Updating GitLab using the Helm Chart
+
+Once your GitLab Chart is installed, configuration changes and chart updates
+should we done using `helm upgrade`
+
+```bash
+helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab
+```
+
+where:
+
+- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed.
+- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom
+ [configuration] (#configuration).
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing) we called it `gitlab`.
+
+## Uninstalling GitLab using the Helm Chart
+
+To uninstall the GitLab Chart, run the following:
+
+```bash
+helm delete --namespace <NAMESPACE> <RELEASE-NAME>
+```
+
+where:
+
+- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed.
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing) we called it `gitlab`.
+
+[kube-srv]: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services---service-types
+[storageclass]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/#storageclasses
diff --git a/doc/install/kubernetes/gitlab_omnibus.md b/doc/install/kubernetes/gitlab_omnibus.md
new file mode 100644
index 00000000000..bd3a85272d0
--- /dev/null
+++ b/doc/install/kubernetes/gitlab_omnibus.md
@@ -0,0 +1,171 @@
+# GitLab-Omnibus Helm Chart
+> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
+
+> Officially supported cloud providers are Google Container Service and Azure Container Service.
+
+This work is based partially on: https://github.com/lwolf/kubernetes-gitlab/. GitLab would like to thank Sergey Nuzhdin for his work.
+
+## Introduction
+
+This chart provides an easy way to get started with GitLab, provisioning an installation with nearly all functionality enabled. SSL is automatically provisioned as well via [Let's Encrypt](https://letsencrypt.org/).
+
+The deployment includes:
+
+- A [GitLab Omnibus](https://docs.gitlab.com/omnibus/) Pod, including Mattermost, Container Registry, and Prometheus
+- An auto-scaling [GitLab Runner](https://docs.gitlab.com/runner/) using the Kubernetes executor
+- [Redis](https://github.com/kubernetes/charts/tree/master/stable/redis)
+- [PostgreSQL](https://github.com/kubernetes/charts/tree/master/stable/postgresql)
+- [NGINX Ingress](https://github.com/kubernetes/charts/tree/master/stable/nginx-ingress)
+- Persistent Volume Claims for Data, Registry, Postgres, and Redis
+
+A video demonstration of GitLab utilizing this chart [is available](https://about.gitlab.com/handbook/sales/demo/).
+
+Terms:
+
+- Google Cloud Platform (**GCP**)
+- Google Container Engine (**GKE**)
+- Azure Container Service (**ACS**)
+- Kubernetes (**k8s**)
+
+## Prerequisites
+
+- _At least_ 4 GB of RAM available on your cluster, in chunks of 1 GB. 41GB of storage and 2 CPU are also required.
+- Kubernetes 1.4+ with Beta APIs enabled
+- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure
+- An [external IP address](#networking-prerequisites)
+- A [wildcard DNS entry](#networking-prerequisites), which resolves to the external IP address
+- The `kubectl` CLI installed locally and authenticated for the cluster
+- The Helm Client installed locally
+- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
+- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository)
+
+### Networking Prerequisites
+
+This chart configures a GitLab server and Kubernetes cluster which can support dynamic [Review Apps](https://docs.gitlab.com/ee/ci/review_apps/index.html), as well as services like the integrated [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html) and [Mattermost](https://docs.gitlab.com/omnibus/gitlab-mattermost/).
+
+To support the GitLab services and dynamic environments, a wildcard DNS entry is required which resolves to the external Load Balancer IP.
+
+To provision an external IP on GCP and Azure, simply request a new address from the Networking section. Ensure that the region matches the region your container cluster is created in. Note, it is important that the IP is not assigned at this point in time. It will be automatically assigned once the Helm chart is installed, and assigned to the Load Balancer.
+
+Now that an external IP address has been allocated, ensure that the wildcard DNS entry you would like to use resolves to this IP. Please consult the documentation for your DNS service for more information on creating DNS records.
+
+## Configuring and Installing GitLab
+
+For most installations, only two parameters are required:
+- `baseIP`: the desired [external IP address](#networking-prerequisites)
+- `baseDomain`: the [base domain](#networking-prerequisites) with the wildcard host entry resolving to the `baseIP`. For example, `mycompany.io`.
+
+Other common configuration options:
+- `gitlab`: Choose the [desired edition](https://about.gitlab.com/products), either `ee` or `ce`. `ce` is the default.
+- `gitlabEELicense`: For Enterprise Edition, the [license](https://docs.gitlab.com/ee/user/admin_area/license.html) can be installed directly via the Chart
+- `provider`: Optimizes the deployment for a cloud provider. The default is `gke` for GCP, with `acs` also supported for Azure.
+- `legoEmail`: Email address to use when requesting new SSL certificates from Let's Encrypt
+
+For additional configuration options, consult the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-omnibus/values.yaml).
+
+These settings can either be passed directly on the command line:
+```bash
+helm install --name gitlab --set baseDomain=gitlab.io,baseIP=1.1.1.1,gitlab=ee,gitlabEELicense=$LICENSE,legoEmail=email@gitlab.com gitlab/gitlab-omnibus
+```
+
+or within a YAML file:
+```bash
+helm install --name gitlab -f values.yaml gitlab/gitlab-omnibus
+```
+
+> **Note:**
+If you are using a machine type with support for less than 4 attached disks, like an Azure trial, you should disable dedicated storage for [Postgres and Redis](#persistent-storage).
+
+### Choosing a different GitLab release version
+
+The version of GitLab installed is based on the `gitlab` setting (see [section](#choosing-gitlab-edition) above), and
+the value of the corresponding helm setting: `gitlabCEImage` or `gitabEEImage`.
+
+```yaml
+gitlab: CE
+gitlabCEImage: gitlab/gitlab-ce:9.1.2-ce.0
+gitlabEEImage: gitlab/gitlab-ee:9.1.2-ee.0
+```
+
+The different images can be found in the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce/tags/) and [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee/tags/)
+repositories on Docker Hub.
+
+> **Note:**
+There is no guarantee that other release versions of GitLab, other than what are
+used by default in the chart, will be supported by a chart install.
+
+### Persistent storage
+
+By default, persistent storage is enabled for GitLab and the charts it depends
+on (Redis and PostgreSQL).
+
+Components can have their claim size set from your `values.yaml`, along with whether to provision separate storage for Postgres and Redis.
+
+Basic configuration:
+
+```yaml
+redisImage: redis:3.2.10
+redisDedicatedStorage: true
+redisStorageSize: 5Gi
+postgresImage: postgres:9.6.3
+# If you disable postgresDedicatedStorage, you should consider bumping up gitlabRailsStorageSize
+postgresDedicatedStorage: true
+postgresStorageSize: 30Gi
+gitlabRailsStorageSize: 30Gi
+gitlabRegistryStorageSize: 30Gi
+gitlabConfigStorageSize: 1Gi
+```
+
+### Routing and SSL
+
+Ingress routing and SSL are automatically configured within this Chart. An NGINX ingress is provisioned and configured, and will route traffic to any service. SSL certificates are automatically created and configured by [kube-lego](https://github.com/kubernetes/charts/tree/master/stable/kube-lego).
+
+> **Note:**
+Let's Encrypt limits a single TLD to five certificate requests within a single week. This means that common DNS wildcard services like [xip.io](http://xip.io) and [nip.io](http://nip.io) are unlikely to work.
+
+## Installing GitLab using the Helm Chart
+> You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically restart. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage.
+
+Once you have reviewed the [configuration settings](#configuring-and-installing-gitlab) and [added the Helm repository](index.md#add-the-gitlab-helm-repository), you can install the chart. We recommending saving your configuration options in a `values.yaml` file for easier upgrades in the future.
+
+For example:
+```bash
+helm install --name gitlab -f values.yaml gitlab/gitlab-omnibus
+```
+
+or passing them on the command line:
+```bash
+helm install --name gitlab --set baseDomain=gitlab.io,baseIP=1.1.1.1,gitlab=ee,gitlabEELicense=$LICENSE,legoEmail=email@gitlab.com gitlab/gitlab-omnibus
+```
+
+## Updating GitLab using the Helm Chart
+
+Once your GitLab Chart is installed, configuration changes and chart updates
+should we done using `helm upgrade`
+
+```bash
+helm upgrade -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab
+```
+
+where:
+
+- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom
+ [configuration] (#configuring-and-installing-gitlab).
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing-gitlab-using-the-helm-chart) we called it `gitlab`.
+
+## Uninstalling GitLab using the Helm Chart
+
+To uninstall the GitLab Chart, run the following:
+
+```bash
+helm delete <RELEASE-NAME>
+```
+
+where:
+
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing) we called it `gitlab`.
+
+[kube-srv]: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services---service-types
+[storageclass]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/#storageclasses
diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md
new file mode 100644
index 00000000000..b0fe91d6337
--- /dev/null
+++ b/doc/install/kubernetes/gitlab_runner_chart.md
@@ -0,0 +1,231 @@
+# GitLab Runner Helm Chart
+> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
+
+> Officially supported cloud providers are Google Container Service and Azure Container Service.
+
+The `gitlab-runner` Helm chart deploys a GitLab Runner instance into your
+Kubernetes cluster.
+
+This chart configures the Runner to:
+
+- Run using the GitLab Runner [Kubernetes executor](https://docs.gitlab.com/runner/install/kubernetes.html)
+- For each new job it receives from [GitLab CI](https://about.gitlab.com/features/gitlab-ci-cd/), it will provision a
+ new pod within the specified namespace to run it.
+
+## Prerequisites
+
+- Your GitLab Server's API is reachable from the cluster
+- Kubernetes 1.4+ with Beta APIs enabled
+- The `kubectl` CLI installed locally and authenticated for the cluster
+- The Helm Client installed locally
+- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
+- The GitLab Helm Repo added to your Helm Client. See [Adding GitLab Helm Repo](index.md#add-the-gitlab-helm-repository)
+
+## Configuring GitLab Runner using the Helm Chart
+
+Create a `values.yaml` file for your GitLab Runner configuration. See [Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md)
+for information on how your values file will override the defaults.
+
+The default configuration can always be found in the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository.
+
+### Required configuration
+
+In order for GitLab Runner to function, your config file **must** specify the following:
+
+ - `gitlabURL` - the GitLab Server URL (with protocol) to register the runner against
+ - `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be
+ retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information.
+
+### Other configuration
+
+The rest of the configuration is [documented in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository.
+
+Here is a snippet of the important settings:
+
+```yaml
+## The GitLab Server URL (with protocol) that want to register the runner against
+## ref: https://docs.gitlab.com/runner/commands/README.html#gitlab-runner-register
+##
+gitlabURL: http://gitlab.your-domain.com/
+
+## The Registration Token for adding new Runners to the GitLab Server. This must
+## be retreived from your GitLab Instance.
+## ref: https://docs.gitlab.com/ce/ci/runners/README.html#creating-and-registering-a-runner
+##
+runnerRegistrationToken: ""
+
+## Set the certsSecretName in order to pass custom certficates for GitLab Runner to use
+## Provide resource name for a Kubernetes Secret Object in the same namespace,
+## this is used to populate the /etc/gitlab-runner/certs directory
+## ref: https://docs.gitlab.com/runner/configuration/tls-self-signed.html#supported-options-for-self-signed-certificates
+##
+#certsSecretName:
+
+## Configure the maximum number of concurrent jobs
+## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section
+##
+concurrent: 10
+
+## Defines in seconds how often to check GitLab for a new builds
+## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section
+##
+checkInterval: 30
+
+## Configuration for the Pods that that the runner launches for each new job
+##
+runners:
+ ## Default container image to use for builds when none is specified
+ ##
+ image: ubuntu:16.04
+
+ ## Run all containers with the privileged flag enabled
+ ## This will allow the docker:dind image to run if you need to run Docker
+ ## commands. Please read the docs before turning this on:
+ ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind
+ ##
+ privileged: false
+
+ ## Namespace to run Kubernetes jobs in (defaults to 'default')
+ ##
+ # namespace:
+
+ ## Build Container specific configuration
+ ##
+ builds:
+ # cpuLimit: 200m
+ # memoryLimit: 256Mi
+ cpuRequests: 100m
+ memoryRequests: 128Mi
+
+ ## Service Container specific configuration
+ ##
+ services:
+ # cpuLimit: 200m
+ # memoryLimit: 256Mi
+ cpuRequests: 100m
+ memoryRequests: 128Mi
+
+ ## Helper Container specific configuration
+ ##
+ helpers:
+ # cpuLimit: 200m
+ # memoryLimit: 256Mi
+ cpuRequests: 100m
+ memoryRequests: 128Mi
+
+```
+
+### Running Docker-in-Docker containers with GitLab Runners
+
+See [Running Privileged Containers for the Runners](#running-privileged-containers-for-the-runners) for how to enable it,
+and the [GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds) on running dind.
+
+### Running privileged containers for the Runners
+
+You can tell the GitLab Runner to run using privileged containers. You may need
+this enabled if you need to use the Docker executable within your GitLab CI jobs.
+
+This comes with several risks that you can read about in the
+[GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds).
+
+If you are okay with the risks, and your GitLab CI Runner instance is registered
+against a specific project in GitLab that you trust the CI jobs of, you can
+enable privileged mode in `values.yaml`:
+
+```yaml
+runners:
+ ## Run all containers with the privileged flag enabled
+ ## This will allow the docker:dind image to run if you need to run Docker
+ ## commands. Please read the docs before turning this on:
+ ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind
+ ##
+ privileged: true
+```
+
+### Providing a custom certificate for accessing GitLab
+
+You can provide a [Kubernetes Secret](https://kubernetes.io/docs/concepts/configuration/secret/)
+to the GitLab Runner Helm Chart, which will be used to populate the container's
+`/etc/gitlab-runner/certs` directory.
+
+Each key name in the Secret will be used as a filename in the directory, with the
+file content being the value associated with the key.
+
+More information on how GitLab Runner uses these certificates can be found in the
+[Runner Documentation](https://docs.gitlab.com/runner/configuration/tls-self-signed.html#supported-options-for-self-signed-certificates).
+
+ - The key/file name used should be in the format `<gitlab-hostname>.crt`. For example: `gitlab.your-domain.com.crt`.
+ - Any intermediate certificates need to be concatenated to your server certificate in the same file.
+ - The hostname used should be the one the certificate is registered for.
+
+The GitLab Runner Helm Chart does not create a secret for you. In order to create
+the secret, you can prepare your certificate on you local machine, and then run
+the `kubectl create secret` command from the directory with the certificate
+
+```bash
+kubectl
+ --namespace <NAMESPACE>
+ create secret generic <SECRET_NAME>
+ --from-file=<CERTFICATE_FILENAME>
+```
+
+- `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner.
+- `<SECRET_NAME>` is the Kubernetes Secret resource name. For example: `gitlab-domain-cert`
+- `<CERTFICATE_FILENAME>` is the filename for the certificate in your current directory that will be imported into the secret
+
+You then need to provide the secret's name to the GitLab Runner chart.
+
+Add the following to your `values.yaml`
+
+```yaml
+## Set the certsSecretName in order to pass custom certficates for GitLab Runner to use
+## Provide resource name for a Kubernetes Secret Object in the same namespace,
+## this is used to populate the /etc/gitlab-runner/certs directory
+## ref: https://docs.gitlab.com/runner/configuration/tls-self-signed.html#supported-options-for-self-signed-certificates
+##
+certsSecretName: <SECRET NAME>
+```
+
+- `<SECRET_NAME>` is the Kubernetes Secret resource name. For example: `gitlab-domain-cert`
+
+## Installing GitLab Runner using the Helm Chart
+
+Once you [have configured](#configuration) GitLab Runner in your `values.yml` file,
+run the following:
+
+```bash
+helm install --namespace <NAMESPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner
+```
+
+- `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner.
+- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the
+ [Configuration](#configuration) section to create it.
+
+## Updating GitLab Runner using the Helm Chart
+
+Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade`
+
+```bash
+helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner
+```
+
+Where:
+- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed
+- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the
+ [Configuration](#configuration) section to create it.
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing) we called it `gitlab-runner`.
+
+## Uninstalling GitLab Runner using the Helm Chart
+
+To uninstall the GitLab Runner Chart, run the following:
+
+```bash
+helm delete --namespace <NAMESPACE> <RELEASE-NAME>
+```
+
+where:
+
+- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed
+- `<RELEASE-NAME>` is the name you gave the chart when installing it.
+ In the [Install section](#installing) we called it `gitlab-runner`.
diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md
new file mode 100644
index 00000000000..3608aa6b2d6
--- /dev/null
+++ b/doc/install/kubernetes/index.md
@@ -0,0 +1,46 @@
+# Installing GitLab on Kubernetes
+> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
+
+> Officially supported cloud providers are Google Container Service and Azure Container Service.
+
+The easiest method to deploy GitLab in [Kubernetes](https://kubernetes.io/) is
+to take advantage of the official GitLab Helm charts. [Helm] is a package
+management tool for Kubernetes, allowing apps to be easily managed via their
+Charts. A [Chart] is a detailed description of the application including how it
+should be deployed, upgraded, and configured.
+
+The GitLab Helm repository is located at https://charts.gitlab.io.
+You can report any issues related to GitLab's Helm Charts at
+https://gitlab.com/charts/charts.gitlab.io/issues.
+Contributions and improvements are also very welcome.
+
+## Prerequisites
+
+To use the charts, the Helm tool must be installed and initialized. The best
+place to start is by reviewing the [Helm Quick Start Guide][helm-quick].
+
+## Add the GitLab Helm repository
+
+Once Helm has been installed, the GitLab chart repository must be added:
+
+```bash
+helm repo add gitlab https://charts.gitlab.io
+```
+
+After adding the repository, Helm must be re-initialized:
+
+```bash
+helm init
+```
+
+## Using the GitLab Helm Charts
+
+GitLab makes available three Helm Charts: an easy to use bundled chart, and a specific chart for GitLab itself and the Runner.
+
+- [gitlab-omnibus](gitlab_omnibus.md): The easiest way to get started. Includes everything needed to run GitLab, including: a Runner, Container Registry, automatic SSL, and an Ingress.
+- [gitlab](gitlab_chart.md): Just the GitLab service, with optional Postgres and Redis.
+- [gitlab-runner](gitlab_runner_chart.md): GitLab Runner, to process CI jobs.
+
+[chart]: https://github.com/kubernetes/charts
+[helm-quick]: https://github.com/kubernetes/helm/blob/master/docs/quickstart.md
+[helm]: https://github.com/kubernetes/helm/blob/master/README.md
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 35586091f74..175dfc62096 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -69,7 +69,7 @@ so keep in mind that you need at least 4GB available before running GitLab. With
less memory GitLab will give strange errors during the reconfigure run and 500
errors during usage.
-- 1GB RAM + 3GB of swap is the absolute minimum but we strongly **advise against** this amount of memory. See the unicorn worker section below for more advice.
+- 1GB RAM + 3GB of swap is the absolute minimum but we strongly **advise against** this amount of memory. See the [unicorn worker section below](#unicorn-workers) for more advice.
- 2GB RAM + 2GB swap supports up to 100 users but it will be very slow
- **4GB RAM** is the **recommended** memory size for all installations and supports up to 100 users
- 8GB RAM supports up to 1,000 users
@@ -86,51 +86,42 @@ if your available memory changes.
Notice: The 25 workers of Sidekiq will show up as separate processes in your process overview (such as top or htop) but they share the same RAM allocation since Sidekiq is a multithreaded application. Please see the section below about Unicorn workers for information about many you need of those.
-## GitLab Runner
-
-We strongly advise against installing GitLab Runner on the same machine you plan
-to install GitLab on. Depending on how you decide to configure GitLab Runner and
-what tools you use to exercise your application in the CI environment, GitLab
-Runner can consume significant amount of available memory.
-
-Memory consumption calculations, that are available above, will not be valid if
-you decide to run GitLab Runner and the GitLab Rails application on the same
-machine.
-
-It is also not safe to install everything on a single machine, because of the
-[security reasons] - especially when you plan to use shell executor with GitLab
-Runner.
-
-We recommend using a separate machine for each GitLab Runner, if you plan to
-use the CI features.
-
-[security reasons]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/security/index.md
-
-## Unicorn Workers
-
-It's possible to increase the amount of unicorn workers and this will usually help to reduce the response time of the applications and increase the ability to handle parallel requests.
+## Database
-For most instances we recommend using: CPU cores + 1 = unicorn workers.
-So for a machine with 2 cores, 3 unicorn workers is ideal.
+The server running the database should have _at least_ 5-10 GB of storage
+available, though the exact requirements depend on the size of the GitLab
+installation (e.g. the number of users, projects, etc).
-For all machines that have 2GB and up we recommend a minimum of three unicorn workers.
-If you have a 1GB machine we recommend to configure only two Unicorn workers to prevent excessive swapping.
+We currently support the following databases:
-To change the Unicorn workers when you have the Omnibus package please see [the Unicorn settings in the Omnibus GitLab documentation](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/unicorn.md#unicorn-settings).
+- PostgreSQL (highly recommended)
+- MySQL/MariaDB (strongly discouraged, not all GitLab features are supported, no support for [MySQL/MariaDB GTID](https://mariadb.com/kb/en/mariadb/gtid/))
-## Database
+We highly recommend the use of PostgreSQL instead of MySQL/MariaDB as not all
+features of GitLab work with MySQL/MariaDB:
-We currently support the following databases:
+1. MySQL support for subgroups was [dropped with GitLab 9.3][post].
+ See [issue #30472][30472] for more information.
+1. GitLab Geo does [not support MySQL](https://docs.gitlab.com/ee/gitlab-geo/database.html#mysql-replication).
+1. [Zero downtime migrations][zero] do not work with MySQL
+1. GitLab [optimizes the loading of dashboard events](https://gitlab.com/gitlab-org/gitlab-ce/issues/31806) using [PostgreSQL LATERAL JOINs](https://blog.heapanalytics.com/postgresqls-powerful-new-join-type-lateral/).
+1. In general, SQL optimized for PostgreSQL may run much slower in MySQL due to
+ differences in query planners. For example, subqueries that work well in PostgreSQL
+ may not be [performant in MySQL](https://dev.mysql.com/doc/refman/5.7/en/optimizing-subqueries.html)
+1. We expect this list to grow over time.
-- PostgreSQL (recommended)
-- MySQL/MariaDB
+Existing users using GitLab with MySQL/MariaDB are advised to
+[migrate to PostgreSQL](../update/mysql_to_postgresql.md) instead.
-If you want to run the database separately, expect a size of about 1 MB per user.
+[30472]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30472
+[zero]: ../update/README.md#upgrading-without-downtime
+[post]: https://about.gitlab.com/2017/06/22/gitlab-9-3-released/#dropping-support-for-subgroups-in-mysql
### PostgreSQL Requirements
-As of GitLab 9.0, PostgreSQL 9.6 is recommended. Lower versions of PostgreSQL
-may work but primary testing and developement takes place using PostgreSQL 9.6.
+As of GitLab 9.3, PostgreSQL 9.2 or newer is required, and earlier versions are
+not supported. We highly recommend users to use at least PostgreSQL 9.6 as this
+is the PostgreSQL version used for development and testing.
Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every
GitLab database. This extension can be enabled (using a PostgreSQL super user)
@@ -143,6 +134,18 @@ CREATE EXTENSION pg_trgm;
On some systems you may need to install an additional package (e.g.
`postgresql-contrib`) for this extension to become available.
+## Unicorn Workers
+
+It's possible to increase the amount of unicorn workers and this will usually help to reduce the response time of the applications and increase the ability to handle parallel requests.
+
+For most instances we recommend using: CPU cores + 1 = unicorn workers.
+So for a machine with 2 cores, 3 unicorn workers is ideal.
+
+For all machines that have 2GB and up we recommend a minimum of three unicorn workers.
+If you have a 1GB machine we recommend to configure only two Unicorn workers to prevent excessive swapping.
+
+To change the Unicorn workers when you have the Omnibus package please see [the Unicorn settings in the Omnibus GitLab documentation](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/unicorn.md#unicorn-settings).
+
## Redis and Sidekiq
Redis stores all user sessions and the background task queue.
@@ -161,8 +164,28 @@ default settings.
If you would like to disable Prometheus and it's exporters or read more information
about it, check the [Prometheus documentation](../administration/monitoring/prometheus/index.md).
+## GitLab Runner
+
+We strongly advise against installing GitLab Runner on the same machine you plan
+to install GitLab on. Depending on how you decide to configure GitLab Runner and
+what tools you use to exercise your application in the CI environment, GitLab
+Runner can consume significant amount of available memory.
+
+Memory consumption calculations, that are available above, will not be valid if
+you decide to run GitLab Runner and the GitLab Rails application on the same
+machine.
+
+It is also not safe to install everything on a single machine, because of the
+[security reasons] - especially when you plan to use shell executor with GitLab
+Runner.
+
+We recommend using a separate machine for each GitLab Runner, if you plan to
+use the CI features.
+
+[security reasons]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/security/index.md
+
## Supported web browsers
We support the current and the previous major release of Firefox, Chrome/Chromium, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11).
-Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. \ No newline at end of file
+Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
diff --git a/doc/integration/README.md b/doc/integration/README.md
index e56e58498a6..d70b9a7f54b 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -5,19 +5,23 @@ trackers and external authentication.
See the documentation below for details on how to configure these services.
-- [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker
+- [Akismet](akismet.md) Configure Akismet to stop spam
+- [Auth0 OmniAuth](auth0.md) Enable the Auth0 OmniAuth provider
+- [Bitbucket](bitbucket.md) Import projects from Bitbucket.org and login to your GitLab instance with your
+Bitbucket.org account
+- [CAS](cas.md) Configure GitLab to sign in using CAS
- [External issue tracker](external-issue-tracker.md) Redmine, JIRA, etc.
+- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
+- [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker
+- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
- [LDAP](ldap.md) Set up sign in via LDAP
-- [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID
-- [SAML](saml.md) Configure GitLab as a SAML 2.0 Service Provider
-- [CAS](cas.md) Configure GitLab to sign in using CAS
- [OAuth2 provider](oauth_provider.md) OAuth2 application creation
+- [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID
- [OpenID Connect](openid_connect_provider.md) Use GitLab as an identity provider
-- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
-- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
-- [Akismet](akismet.md) Configure Akismet to stop spam
-- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
- [PlantUML](../administration/integration/plantuml.md) Configure PlantUML to use diagrams in AsciiDoc documents.
+- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
+- [SAML](saml.md) Configure GitLab as a SAML 2.0 Service Provider
+- [Trello](trello_power_up.md) Integrate Trello with GitLab
> GitLab Enterprise Edition contains [advanced Jenkins support][jenkins].
diff --git a/doc/integration/chat_commands.md b/doc/integration/chat_commands.md
index 4b0084678d9..2856992ee25 100644
--- a/doc/integration/chat_commands.md
+++ b/doc/integration/chat_commands.md
@@ -1,14 +1 @@
-# Chat Commands
-
-Chat commands allow user to perform common operations on GitLab right from there chat client.
-Right now both Mattermost and Slack are supported.
-
-## Available commands
-
-The trigger is configurable, but for the sake of this example, we'll use `/trigger`
-
-* `/trigger help` - Displays all available commands for this user
-* `/trigger issue new <title> <shift+return> <description>` - creates a new issue on the project
-* `/trigger issue show <id>` - Shows the issue with the given ID, if you've got access
-* `/trigger issue search <query>` - Shows a maximum of 5 items matching the query
-* `/trigger deploy <from> to <to>` - Deploy from an environment to another
+This document was moved to [integration/slash_commands.md](slash_commands.md).
diff --git a/doc/integration/external-issue-tracker.md b/doc/integration/external-issue-tracker.md
index 265c891cf83..372e1909330 100644
--- a/doc/integration/external-issue-tracker.md
+++ b/doc/integration/external-issue-tracker.md
@@ -4,10 +4,11 @@ GitLab has a great issue tracker but you can also use an external one such as
Jira, Redmine, or Bugzilla. Issue trackers are configurable per GitLab project and allow
you to do the following:
-- the **Issues** link on the GitLab project pages takes you to the appropriate
- issue index of the external tracker
-- clicking **New issue** on the project dashboard creates a new issue on the
- external tracker
+- you can reference these external issues inside GitLab interface
+ (merge requests, commits, comments) and they will be automatically converted
+ into links
+
+You can have enabled both external and internal GitLab issue trackers in parallel. The **Issues** link always opens the internal issue tracker and in case the internal issue tracker is disabled the link is not visible in the menu.
## Configuration
diff --git a/doc/integration/github.md b/doc/integration/github.md
index 4b0d33334bd..b0d67db8b59 100644
--- a/doc/integration/github.md
+++ b/doc/integration/github.md
@@ -103,12 +103,59 @@ GitHub will generate an application ID and secret key for you to use.
1. Save the configuration file.
-1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you
+1. [Reconfigure GitLab][] or [restart GitLab][] for the changes to take effect if you
installed GitLab via Omnibus or from source respectively.
On the sign in page there should now be a GitHub icon below the regular sign in form.
Click the icon to begin the authentication process. GitHub will ask the user to sign in and authorize the GitLab application.
If everything goes well the user will be returned to GitLab and will be signed in.
-[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure
+### GitHub Enterprise with Self-Signed Certificate
+
+If you are attempting to import projects from GitHub Enterprise with a self-signed
+certificate and the imports are failing, you will need to disable SSL verification.
+It should be disabled by adding `verify_ssl` to `false` in the provider configuration
+and changing the global Git `sslVerify` option to `false` in the GitLab server.
+
+For omnibus package:
+
+```ruby
+ gitlab_rails['omniauth_providers'] = [
+ {
+ "name" => "github",
+ "app_id" => "YOUR_APP_ID",
+ "app_secret" => "YOUR_APP_SECRET",
+ "url" => "https://github.com/",
+ "verify_ssl" => false,
+ "args" => { "scope" => "user:email" }
+ }
+ ]
+```
+
+You will also need to disable Git SSL verification on the server hosting GitLab.
+
+```ruby
+omnibus_gitconfig['system'] = { "http" => ["sslVerify = false"] }
+```
+
+For installation from source:
+
+```
+ - { name: 'github', app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ url: "https://github.example.com/",
+ verify_ssl: false,
+ args: { scope: 'user:email' } }
+```
+
+You will also need to disable Git SSL verification on the server hosting GitLab.
+
+```
+$ git config --global http.sslVerify false
+```
+
+For the changes to take effect, [reconfigure Gitlab] if you installed
+via Omnibus, or [restart GitLab] if you installed from source.
+
+[reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure
[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source
diff --git a/doc/integration/google.md b/doc/integration/google.md
index 1e7ad90c5a8..d5b523e6dc0 100644
--- a/doc/integration/google.md
+++ b/doc/integration/google.md
@@ -72,6 +72,21 @@ To enable the Google OAuth2 OmniAuth provider you must register your application
1. Change 'YOUR_APP_SECRET' to the client secret from the Google Developer page from step 10.
+1. Make sure that you configure GitLab to use an FQDN as Google will not accept raw IP addresses.
+
+ For Omnibus packages:
+
+ ```ruby
+ external_url 'https://gitlab.example.com'
+ ```
+
+ For installations from source:
+
+ ```yaml
+ gitlab:
+ host: https://gitlab.example.com
+ ```
+
1. Save the configuration file.
1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you
diff --git a/doc/integration/img/enable_trello_powerup.png b/doc/integration/img/enable_trello_powerup.png
new file mode 100644
index 00000000000..65d01f1c38c
--- /dev/null
+++ b/doc/integration/img/enable_trello_powerup.png
Binary files differ
diff --git a/doc/integration/img/trello_card_with_gitlab_powerup.png b/doc/integration/img/trello_card_with_gitlab_powerup.png
new file mode 100644
index 00000000000..2965dc35855
--- /dev/null
+++ b/doc/integration/img/trello_card_with_gitlab_powerup.png
Binary files differ
diff --git a/doc/integration/oauth_provider.md b/doc/integration/oauth_provider.md
index af8a1c4e5ed..8ba2e8731c8 100644
--- a/doc/integration/oauth_provider.md
+++ b/doc/integration/oauth_provider.md
@@ -63,6 +63,9 @@ it from the admin area.
![OAuth admin_applications](img/oauth_provider_admin_application.png)
+You're also able to mark an application as _trusted_ when creating it through the admin area. By doing that,
+the user authorization step is automatically skipped for this application.
+
---
## Authorized applications
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 2277aa827b7..b5b245c626f 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -201,6 +201,9 @@ Please keep in mind that every sign in attempt will be redirected to the SAML se
so you will not be able to sign in using local credentials. Make sure that at least one
of the SAML users has admin permissions.
+You may also bypass the auto signin feature by browsing to
+https://gitlab.example.com/users/sign_in?auto_sign_in=false.
+
### `attribute_statements`
>**Note:**
diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md
new file mode 100644
index 00000000000..aa52b5415cf
--- /dev/null
+++ b/doc/integration/slash_commands.md
@@ -0,0 +1,33 @@
+# Slash Commands
+
+Slash commands in Mattermost and Slack allow you to control GitLab and view GitLab content right inside your chat client, without having to leave it. For Slack, this requires a [project service configuration](../user/project/integrations/slack_slash_commands.md). Simply type the command as a message in your chat client to activate it.
+
+Commands are scoped to a project, with a trigger term that is specified during configuration.
+
+We suggest you use the project name as the trigger term for simplicity and clarity.
+
+Taking the trigger term as `project-name`, the commands are:
+
+
+| Command | Effect |
+| ------- | ------ |
+| `/project-name help` | Shows all available slash commands |
+| `/project-name issue new <title> <shift+return> <description>` | Creates a new issue with title `<title>` and description `<description>` |
+| `/project-name issue show <id>` | Shows the issue with id `<id>` |
+| `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` |
+| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment |
+
+## Issue commands
+
+It is possible to create new issue, display issue details and search up to 5 issues.
+
+## Deploy command
+
+In order to deploy to an environment, GitLab will try to find a deployment
+manual action in the pipeline.
+
+If there is only one action for a given environment, it is going to be triggered.
+If there is more than one action defined, GitLab will try to find an action
+which name equals the environment name we want to deploy to.
+
+Command will return an error when no matching action has been found.
diff --git a/doc/integration/trello_power_up.md b/doc/integration/trello_power_up.md
new file mode 100644
index 00000000000..d264486a872
--- /dev/null
+++ b/doc/integration/trello_power_up.md
@@ -0,0 +1,42 @@
+# Trello Power-Up
+
+GitLab's Trello Power-Up enables you to seamlessly attach
+GitLab **merge requests** to Trello cards.
+
+![GitLab Trello PowerUp - Trello card](img/trello_card_with_gitlab_powerup.png)
+
+## Configuring the Power-Up
+
+In order to get started, you will need to configure your Power-Up.
+
+In Trello:
+
+1. Go to your Trello board
+1. Select `Power-Ups` to see a listing of all the available Power-Ups
+1. Look for a row that says `GitLab` and select the `Enable` button
+1. Select the `Settings` (gear) icon
+1. In the popup menu, select `Authorize Account`
+
+In this popup, fill in your `API URL` and `Personal Access Token`. After that, you will be able to attach any merge request to any Trello card on your selected Trello board.
+
+## What is my API URL?
+
+Your API URL should be your GitLab instance URL with `/api/v4` appended in the end of the URL.
+For example, if your GitLab instance URL is `https://gitlab.com`, your API URL would be `https://gitlab.com/api/v4`.
+If your instance's URL is `https://example.com`, your API URL will be `https://example.com/api/v4`.
+
+![configure GitLab Trello PowerUp in Trello](img/enable_trello_powerup.png)
+
+## What is my Personal Access Token?
+
+Your GitLab's personal access token will enable your GitLab account to be accessed
+from Trello.
+
+> Find it in GitLab by clicking on your avatar (upright corner), from which you access
+your user **Settings** > **Access Tokens**.
+
+Learn more about generating a personal access token in the
+[Personal Access Token Documentation][personal-access-token-documentation].
+Don't forget to check the API scope checkbox!
+
+[personal-access-token-documentation]: ../user/profile/personal_access_tokens.html
diff --git a/doc/intro/README.md b/doc/intro/README.md
index d52b180a076..7485912d1a2 100644
--- a/doc/intro/README.md
+++ b/doc/intro/README.md
@@ -11,7 +11,7 @@ Create projects and groups.
Create issues, labels, milestones, cast your vote, and review issues.
-- [Create a new issue](../gitlab-basics/create-issue.md)
+- [Create a new issue](../user/project/issues/index.md#new-issue)
- [Assign labels to issues](../user/project/labels.md)
- [Use milestones as an overview of your project's tracker](../user/project/milestones/index.md)
- [Use voting to express your like/dislike to issues and merge requests](../workflow/award_emoji.md)
diff --git a/doc/profile/README.md b/doc/profile/README.md
index aed64ac1228..fda6d85a84c 100644
--- a/doc/profile/README.md
+++ b/doc/profile/README.md
@@ -1,5 +1 @@
-# Profile settings
-
-- [Preferences](../user/profile/preferences.md)
-- [Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md)
-- [Deleting your account](../user/profile/account/delete_account.md)
+This document was moved to [user/profile/account](../user/profile/index.md).
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index 5be6053b76e..10f5ab3370d 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -5,9 +5,9 @@
An application data backup creates an archive file that contains the database,
all repositories and all attachments.
-You can only restore a backup to **exactly the same version** of GitLab on which
-it was created. The best way to migrate your repositories from one server to
-another is through backup restore.
+You can only restore a backup to **exactly the same version and type (CE/EE)**
+of GitLab on which it was created. The best way to migrate your repositories
+from one server to another is through backup restore.
## Backup
@@ -133,7 +133,7 @@ It uses the [Fog library](http://fog.io/) to perform the upload.
In the example below we use Amazon S3 for storage, but Fog also lets you use
[other storage providers](http://fog.io/storage/). GitLab
[imports cloud drivers](https://gitlab.com/gitlab-org/gitlab-ce/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88)
-for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is
+for AWS, Google, OpenStack Swift, Rackspace and Aliyun as well. A local driver is
[also available](#uploading-to-locally-mounted-shares).
For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`:
@@ -270,6 +270,15 @@ For installations from source:
remote_directory: 'gitlab_backups'
```
+### Specifying a custom directory for backups
+
+If you want to group your backups you can pass a `DIRECTORY` environment variable:
+
+```
+sudo gitlab-rake gitlab:backup:create DIRECTORY=daily
+sudo gitlab-rake gitlab:backup:create DIRECTORY=weekly
+```
+
### Backup archive permissions
The backup archives created by GitLab (`1393513186_2014_02_27_gitlab_backup.tar`)
@@ -369,8 +378,8 @@ The [restore prerequisites section](#restore-prerequisites) includes crucial
information. Make sure to read and test the whole restore process at least once
before attempting to perform it in a production environment.
-You can only restore a backup to **exactly the same version** of GitLab that
-you created it on, for example 9.1.0.
+You can only restore a backup to **exactly the same version and type (CE/EE)** of
+GitLab that you created it on, for example CE 9.1.0.
### Restore prerequisites
@@ -441,8 +450,8 @@ Deleting tmp directories...[DONE]
This procedure assumes that:
-- You have installed the **exact same version** of GitLab Omnibus with which the
- backup was created.
+- You have installed the **exact same version and type (CE/EE)** of GitLab
+ Omnibus with which the backup was created.
- You have run `sudo gitlab-ctl reconfigure` at least once.
- GitLab is running. If not, start it using `sudo gitlab-ctl start`.
diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md
index 044b104f5c2..3ae46019daf 100644
--- a/doc/raketasks/user_management.md
+++ b/doc/raketasks/user_management.md
@@ -71,6 +71,85 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users
bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production
```
+## Rotate Two-factor Authentication (2FA) encryption key
+
+GitLab stores the secret data enabling 2FA to work in an encrypted database
+column. The encryption key for this data is known as `otp_key_base`, and is
+stored in `config/secrets.yml`.
+
+
+If that file is leaked, but the individual 2FA secrets have not, it's possible
+to re-encrypt those secrets with a new encryption key. This allows you to change
+the leaked key without forcing all users to change their 2FA details.
+
+First, look up the old key. This is in the `config/secrets.yml` file, but
+**make sure you're working with the production section**. The line you're
+interested in will look like this:
+
+```yaml
+production:
+ otp_key_base: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
+```
+
+Next, generate a new secret:
+
+```
+# omnibus-gitlab
+sudo gitlab-rake secret
+
+# installation from source
+bundle exec rake secret RAILS_ENV=production
+```
+
+Now you need to stop the GitLab server, back up the existing secrets file and
+update the database:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo cp config/secrets.yml config/secrets.yml.bak
+sudo gitlab-rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key>
+
+# installation from source
+sudo /etc/init.d/gitlab stop
+cp config/secrets.yml config/secrets.yml.bak
+bundle exec rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> RAILS_ENV=production
+```
+
+The `<old key>` value can be read from `config/secrets.yml`; `<new key>` was
+generated earlier. The **encrypted** values for the user 2FA secrets will be
+written to the specified `filename` - you can use this to rollback in case of
+error.
+
+Finally, change `config/secrets.yml` to set `otp_key_base` to `<new key>` and
+restart. Again, make sure you're operating in the **production** section.
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+```
+
+If there are any problems (perhaps using the wrong value for `old_key`), you can
+restore your backup of `config/secrets.yml` and rollback the changes:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo gitlab-rake gitlab:two_factor:rotate_key:rollback filename=backup.csv
+sudo cp config/secrets.yml.bak config/secrets.yml
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+bundle exec rake gitlab:two_factor:rotate_key:rollback filename=backup.csv RAILS_ENV=production
+cp config/secrets.yml.bak config/secrets.yml
+sudo /etc/init.d/gitlab start
+
+```
+
## Clear authentication tokens for all users. Important! Data loss!
Clear authentication tokens for all users in the GitLab database. This
diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md
index ad5ffc84473..0399ebec86a 100644
--- a/doc/system_hooks/system_hooks.md
+++ b/doc/system_hooks/system_hooks.md
@@ -31,7 +31,7 @@ X-Gitlab-Event: System Hook
"path": "storecloud",
"path_with_namespace": "jsmith/storecloud",
"project_id": 74,
- "project_visibility": "private",
+ "project_visibility": "private"
}
```
@@ -48,7 +48,7 @@ X-Gitlab-Event: System Hook
"path": "underscore",
"path_with_namespace": "jsmith/underscore",
"project_id": 73,
- "project_visibility": "internal",
+ "project_visibility": "internal"
}
```
@@ -66,7 +66,7 @@ X-Gitlab-Event: System Hook
"owner_name": "John Smith",
"owner_email": "johnsmith@gmail.com",
"project_visibility": "internal",
- "old_path_with_namespace": "jsmith/overscore",
+ "old_path_with_namespace": "jsmith/overscore"
}
```
@@ -84,7 +84,7 @@ X-Gitlab-Event: System Hook
"owner_name": "John Smith",
"owner_email": "johnsmith@gmail.com",
"project_visibility": "internal",
- "old_path_with_namespace": "jsmith/overscore",
+ "old_path_with_namespace": "jsmith/overscore"
}
```
@@ -101,7 +101,7 @@ X-Gitlab-Event: System Hook
"path": "storecloud",
"path_with_namespace": "jsmith/storecloud",
"project_id": 74,
- "project_visibility": "private",
+ "project_visibility": "private"
}
```
@@ -121,7 +121,7 @@ X-Gitlab-Event: System Hook
"user_name": "John Smith",
"user_username": "johnsmith",
"user_id": 41,
- "project_visibility": "private",
+ "project_visibility": "private"
}
```
@@ -141,7 +141,7 @@ X-Gitlab-Event: System Hook
"user_name": "John Smith",
"user_username": "johnsmith",
"user_id": 41,
- "project_visibility": "private",
+ "project_visibility": "private"
}
```
@@ -266,7 +266,8 @@ X-Gitlab-Event: System Hook
## Push events
-Triggered when you push to the repository except when pushing tags.
+Triggered when you push to the repository, except when pushing tags.
+It generates one event per modified branch.
**Request header**:
@@ -332,6 +333,7 @@ X-Gitlab-Event: System Hook
## Tag events
Triggered when you create (or delete) tags to the repository.
+It generates one event per modified tag.
**Request header**:
@@ -381,3 +383,49 @@ X-Gitlab-Event: System Hook
"total_commits_count": 0
}
```
+## Repository Update events
+
+Triggered only once when you push to the repository (including tags).
+
+**Request header**:
+
+```
+X-Gitlab-Event: System Hook
+```
+
+**Request body:**
+
+```json
+{
+ "event_name": "repository_update",
+ "user_id": 1,
+ "user_name": "John Smith",
+ "user_email": "admin@example.com",
+ "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80",
+ "project_id": 1,
+ "project": {
+ "name":"Example",
+ "description":"",
+ "web_url":"http://example.com/jsmith/example",
+ "avatar_url":null,
+ "git_ssh_url":"git@example.com:jsmith/example.git",
+ "git_http_url":"http://example.com/jsmith/example.git",
+ "namespace":"Jsmith",
+ "visibility_level":0,
+ "path_with_namespace":"jsmith/example",
+ "default_branch":"master",
+ "homepage":"http://example.com/jsmith/example",
+ "url":"git@example.com:jsmith/example.git",
+ "ssh_url":"git@example.com:jsmith/example.git",
+ "http_url":"http://example.com/jsmith/example.git",
+ },
+ "changes": [
+ {
+ "before":"8205ea8d81ce0c6b90fbe8280d118cc9fdad6130",
+ "after":"4045ea7a3df38697b3730a20fb73c8bed8a3e69e",
+ "ref":"refs/heads/master"
+ }
+ ],
+ "refs":["refs/heads/master"]
+}
+```
diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md
index eafd2fd9d04..0c0d482499a 100644
--- a/doc/topics/authentication/index.md
+++ b/doc/topics/authentication/index.md
@@ -18,6 +18,8 @@ This page gathers all the resources for the topic **Authentication** within GitL
- [LDAP (Enterprise Edition)](https://docs.gitlab.com/ee/administration/auth/ldap-ee.html)
- [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa)
- **Articles:**
+ - [How to Configure LDAP with GitLab CE](../../articles/how_to_configure_ldap_gitlab_ce/index.md)
+ - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/)
- [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/)
- [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html)
- **Integrations:**
diff --git a/doc/topics/git/index.md b/doc/topics/git/index.md
index d13066c9015..df56f031970 100644
--- a/doc/topics/git/index.md
+++ b/doc/topics/git/index.md
@@ -22,6 +22,8 @@ We've gathered some resources to help you to get the best from Git with GitLab.
- [Cherry-picking a commit](../../user/project/merge_requests/cherry_pick_changes.md#cherry-picking-a-commit)
- [Squashing commits](../../workflow/gitlab_flow.md#squashing-commits-with-rebase)
- **Articles:**
+ - [Numerous _undo_ possibilities in Git](../../articles/numerous_undo_possibilities_in_git/index.md)
+ - [How to install Git](../../articles/how_to_install_git/index.md)
- [Git Tips & Tricks](https://about.gitlab.com/2016/12/08/git-tips-and-tricks/)
- [Eight Tips to help you work better with Git](https://about.gitlab.com/2015/02/19/8-tips-to-help-you-work-better-with-git/)
- **Presentations:**
diff --git a/doc/university/README.md b/doc/university/README.md
index c1661f0b52b..170582bcd0c 100644
--- a/doc/university/README.md
+++ b/doc/university/README.md
@@ -65,6 +65,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project
1. [Using Innersourcing to Improve Collaboration](https://about.gitlab.com/2014/09/05/innersourcing-using-the-open-source-workflow-to-improve-collaboration-within-an-organization/)
1. [The Software Development Market and GitLab - Video](https://www.youtube.com/watch?v=sXlhgPK1NTY&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=6) - [Slides](https://docs.google.com/presentation/d/1vCU-NbZWz8NTNK8Vu3y4zGMAHb5DpC8PE5mHtw1PWfI/edit)
1. [The GitLab Book Club](bookclub/index.md)
+1. [GitLab Resources](https://about.gitlab.com/resources/)
#### 1.7 Community and Support
@@ -121,6 +122,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project
1. [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
1. [IBM: Continuous Delivery vs Continuous Deployment - Video](https://www.youtube.com/watch?v=igwFj8PPSnw)
1. [Amazon: Transition to Continuous Delivery - Video](https://www.youtube.com/watch?v=esEFaY0FDKc)
+2. [TechBeacon: Doing continuous delivery? Focus first on reducing release cycle times](https://techbeacon.com/doing-continuous-delivery-focus-first-reducing-release-cycle-times)
1. See **[Integrations](#integrations)** for integrations with other CI services.
#### 2.4. Workflow
diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md
index 591d1524061..9544de41b9a 100644
--- a/doc/university/glossary/README.md
+++ b/doc/university/glossary/README.md
@@ -1,4 +1,3 @@
-
## What is the Glossary
This contains a simplified list and definitions of some of the terms that you will encounter in your day to day activities when working with GitLab.
@@ -10,7 +9,7 @@ User authentication by combination of 2 different steps during login. This allow
### Access Levels
-Process of selective restriction to create, view, modify or delete a resource based on a set of assigned permissions. See [GitLab's Permission Guidelines](../../permissions/permissions.md
+Process of selective restriction to create, view, modify or delete a resource based on a set of assigned permissions. See [GitLab's Permission Guidelines](../../user/permissions.md)
### Active Directory (AD)
diff --git a/doc/university/high-availability/aws/README.md b/doc/university/high-availability/aws/README.md
index 088f1cd7290..6b8f3cd3d1d 100644
--- a/doc/university/high-availability/aws/README.md
+++ b/doc/university/high-availability/aws/README.md
@@ -159,19 +159,21 @@ subnet and security group and
***
-## Elastic File System
+## Network File System
-This new AWS offering allows us to create a file system accessible by

-EC2 instances within a VPC. Choose our VPC and the subnets will be
-
automatically configured assuming we don't need to set explicit IPs.
-The
next section allows us to add tags and choose between General
-Purpose or
Max I/O which is a good option when being accessed by a
-large number of
EC2 instances.
+GitLab requires a shared filesystem such as NFS. The file share(s) will be
+mounted on all application servers. There are a variety of ways to build an
+NFS server on AWS.
-

![Elastic File System](img/elastic-file-system.png)
+One option is to use a third-party AMI that offers NFS as a service. A [search
+for 'NFS' in the AWS Marketplace](https://aws.amazon.com/marketplace/search/results?x=0&y=0&searchTerms=NFS&page=1&ref_=nav_search_box)
+shows options such as NetApp, SoftNAS and others.
-To actually mount and install the NFS client we'll use the User Data
-section when adding our Launch Configuration.
+Another option is to build a simple NFS server using a vanilla Linux server backed
+by AWS Elastic Block Storage (EBS).
+
+> **Note:** GitLab does not recommend using AWS Elastic File System (EFS). See
+ details in [High Availability NFS documentation](../../../administration/high_availability/nfs.md#aws-elastic-file-system)
***
diff --git a/doc/university/process/README.md b/doc/university/process/README.md
index 7ff53c2cc3f..04f2d52514f 100644
--- a/doc/university/process/README.md
+++ b/doc/university/process/README.md
@@ -27,4 +27,4 @@ please submit a merge request to add an upcoming class, assign to
1. Please upload any video recordings to our Youtube channel. We prefer them to
be public, if needed they can be unlisted but if so they should be linked from
this page.
-1. Please create a merge request and assign to [SeanPackham](https://gitlab.com/u/SeanPackham).
+1. Please create a merge request and assign to [Erica](https://gitlab.com/u/Erica).
diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md
index e5e3cd395df..e538983e603 100644
--- a/doc/update/8.10-to-8.11.md
+++ b/doc/update/8.10-to-8.11.md
@@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc
### 4. Get latest code
```bash
+cd /home/git/gitlab
+
sudo -u git -H git fetch --all
sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
```
diff --git a/doc/update/8.11-to-8.12.md b/doc/update/8.11-to-8.12.md
index d6b3b0ffa5a..604166beb56 100644
--- a/doc/update/8.11-to-8.12.md
+++ b/doc/update/8.11-to-8.12.md
@@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc
### 4. Get latest code
```bash
+cd /home/git/gitlab
+
sudo -u git -H git fetch --all
sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
```
diff --git a/doc/update/8.12-to-8.13.md b/doc/update/8.12-to-8.13.md
index ed0e668d854..d83965131f5 100644
--- a/doc/update/8.12-to-8.13.md
+++ b/doc/update/8.12-to-8.13.md
@@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc
### 4. Get latest code
```bash
+cd /home/git/gitlab
+
sudo -u git -H git fetch --all
sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
```
diff --git a/doc/update/8.13-to-8.14.md b/doc/update/8.13-to-8.14.md
index aa1c659717e..aaadcec8ac0 100644
--- a/doc/update/8.13-to-8.14.md
+++ b/doc/update/8.13-to-8.14.md
@@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc
### 4. Get latest code
```bash
+cd /home/git/gitlab
+
sudo -u git -H git fetch --all
sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
```
diff --git a/doc/update/8.17-to-9.0.md b/doc/update/8.17-to-9.0.md
index 6308317b1f2..2abc57da1a0 100644
--- a/doc/update/8.17-to-9.0.md
+++ b/doc/update/8.17-to-9.0.md
@@ -65,7 +65,10 @@ Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
JavaScript dependencies.
```bash
-curl --location https://yarnpkg.com/install.sh | bash -
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
```
More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
@@ -261,6 +264,16 @@ sudo systemctl daemon-reload
### 9. Install libs, migrations, etc.
+GitLab 9.0.11 [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24570)
+a dependency on on the `re2` regular expression library. To install this dependency:
+
+```bash
+sudo apt-get install libre2-dev
+```
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
+
```bash
cd /home/git/gitlab
diff --git a/doc/update/8.9-to-8.10.md b/doc/update/8.9-to-8.10.md
index d6b2f11d49a..42132f690d8 100644
--- a/doc/update/8.9-to-8.10.md
+++ b/doc/update/8.9-to-8.10.md
@@ -156,7 +156,7 @@ See [smtp_settings.rb.sample] as an example.
Ensure you're still up-to-date with the latest init script changes:
sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
-
+
For Ubuntu 16.04.1 LTS:
sudo systemctl daemon-reload
diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md
index 2d597894517..3fd1d023d2a 100644
--- a/doc/update/9.0-to-9.1.md
+++ b/doc/update/9.0-to-9.1.md
@@ -65,7 +65,10 @@ Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
JavaScript dependencies.
```bash
-curl --location https://yarnpkg.com/install.sh | bash -
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
```
More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
@@ -261,6 +264,16 @@ sudo systemctl daemon-reload
### 9. Install libs, migrations, etc.
+GitLab 9.1.8 [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24570)
+a dependency on on the `re2` regular expression library. To install this dependency:
+
+```bash
+sudo apt-get install libre2-dev
+```
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
+
```bash
cd /home/git/gitlab
diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md
new file mode 100644
index 00000000000..5f7a616cc7d
--- /dev/null
+++ b/doc/update/9.1-to-9.2.md
@@ -0,0 +1,326 @@
+# From 9.1 to 9.2
+
+Make sure you view this update guide from the tag (version) of GitLab you would
+like to install. In most cases this should be the highest numbered production
+tag (without rc in it). You can select the tag in the version dropdown at the
+top left corner of GitLab (below the menu bar).
+
+If the highest number stable branch is unclear please check the
+[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation
+guide links by version.
+
+### 1. Stop server
+
+```bash
+sudo service gitlab stop
+```
+
+### 2. Backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
+```
+
+### 3. Update Ruby
+
+NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be
+sure to upgrade your interpreter if necessary.
+
+You can check which version you are running with `ruby -v`.
+
+Download and compile Ruby:
+
+```bash
+mkdir /tmp/ruby && cd /tmp/ruby
+curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz
+echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz
+cd ruby-2.3.3
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Update Node
+
+GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and
+it has a minimum requirement of node v4.3.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v4.3.0` you will need to update to a newer version. You
+can find instructions to install from community maintained packages or compile
+from source at the nodejs.org website.
+
+<https://nodejs.org/en/download/>
+
+
+Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
+JavaScript dependencies.
+
+```bash
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
+```
+
+More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
+
+### 5. Update Go
+
+NOTE: GitLab 9.2 and higher only supports Go 1.8.3 and dropped support for Go
+1.5.x through 1.7.x. Be sure to upgrade your installation if necessary.
+
+You can check which version you are running with `go version`.
+
+Download and install Go:
+
+```bash
+# Remove former Go installation folder
+sudo rm -rf /usr/local/go
+
+curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
+sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
+rm go1.8.3.linux-amd64.tar.gz
+```
+
+### 6. Get latest code
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git fetch --all
+sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
+sudo -u git -H git checkout -- locale
+```
+
+For GitLab Community Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-2-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-2-stable-ee
+```
+
+### 7. Update gitlab-shell
+
+```bash
+cd /home/git/gitlab-shell
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
+sudo -u git -H bin/compile
+```
+
+### 8. Update gitlab-workhorse
+
+Install and compile gitlab-workhorse. GitLab-Workhorse uses
+[GNU Make](https://www.gnu.org/software/make/).
+If you are not using Linux you may have to run `gmake` instead of
+`make` below.
+
+```bash
+cd /home/git/gitlab-workhorse
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
+sudo -u git -H make
+```
+
+### 9. Update configuration files
+
+#### New configuration options for `gitlab.yml`
+
+There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-1-stable:config/gitlab.yml.example origin/9-2-stable:config/gitlab.yml.example
+```
+
+#### Nginx configuration
+
+Ensure you're still up-to-date with the latest NGINX configuration changes:
+
+```sh
+cd /home/git/gitlab
+
+# For HTTPS configurations
+git diff origin/9-1-stable:lib/support/nginx/gitlab-ssl origin/9-2-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/9-1-stable:lib/support/nginx/gitlab origin/9-2-stable:lib/support/nginx/gitlab
+```
+
+If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx
+configuration as GitLab application no longer handles setting it.
+
+If you are using Apache instead of NGINX please see the updated [Apache templates].
+Also note that because Apache does not support upstreams behind Unix sockets you
+will need to let gitlab-workhorse listen on a TCP port. You can do this
+via [/etc/default/gitlab].
+
+[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache
+[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example#L38
+
+#### SMTP configuration
+
+If you're installing from source and use SMTP to deliver mail, you will need to add the following line
+to config/initializers/smtp_settings.rb:
+
+```ruby
+ActionMailer::Base.delivery_method = :smtp
+```
+
+See [smtp_settings.rb.sample] as an example.
+
+[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-1-stable/config/initializers/smtp_settings.rb.sample#L13
+
+#### Init script
+
+There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-1-stable:lib/support/init.d/gitlab.default.example origin/9-2-stable:lib/support/init.d/gitlab.default.example
+```
+
+Ensure you're still up-to-date with the latest init script changes:
+
+```bash
+cd /home/git/gitlab
+
+sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
+```
+
+For Ubuntu 16.04.1 LTS:
+
+```bash
+sudo systemctl daemon-reload
+```
+
+### 10. Install libs, migrations, etc.
+
+GitLab 9.2.8 [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24570)
+a dependency on on the `re2` regular expression library. To install this dependency:
+
+```bash
+sudo apt-get install libre2-dev
+```
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
+
+```bash
+cd /home/git/gitlab
+
+# MySQL installations (note: the line below states '--without postgres')
+sudo -u git -H bundle install --without postgres development test --deployment
+
+# PostgreSQL installations (note: the line below states '--without mysql')
+sudo -u git -H bundle install --without mysql development test --deployment
+
+# Optional: clean up old gems
+sudo -u git -H bundle clean
+
+# Run database migrations
+sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
+
+# Compile GetText PO files
+
+sudo -u git -H bundle exec rake gettext:pack RAILS_ENV=production
+sudo -u git -H bundle exec rake gettext:po_to_json RAILS_ENV=production
+
+# Update node dependencies and recompile assets
+sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production
+
+# Clean up cache
+sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
+```
+
+**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
+
+### 11. Optional: install Gitaly
+
+Gitaly is still an optional component of GitLab. If you want to save time
+during your 9.2 upgrade **you can skip this step**.
+
+If you have not yet set up Gitaly then follow [Gitaly section of the installation
+guide](../install/installation.md#install-gitaly).
+
+#### Compile Gitaly
+
+```shell
+cd /home/git/gitaly
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
+sudo -u git -H make
+```
+
+### 12. Start application
+
+```bash
+sudo service gitlab start
+sudo service nginx restart
+```
+
+### 13. Check application status
+
+Check if GitLab and its environment are configured correctly:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
+```
+
+To make sure you didn't miss anything run a more thorough check:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
+```
+
+If all items are green, then congratulations, the upgrade is complete!
+
+## Things went south? Revert to previous version (9.1)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 9.0 to 9.1](9.0-to-9.1.md), except for the
+database migration (the backup is already migrated to the previous version).
+
+### 2. Restore from the backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
+```
+
+If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above.
+
+[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md
new file mode 100644
index 00000000000..9d0b0da7edb
--- /dev/null
+++ b/doc/update/9.2-to-9.3.md
@@ -0,0 +1,344 @@
+# From 9.2 to 9.3
+
+Make sure you view this update guide from the tag (version) of GitLab you would
+like to install. In most cases this should be the highest numbered production
+tag (without rc in it). You can select the tag in the version dropdown at the
+top left corner of GitLab (below the menu bar).
+
+If the highest number stable branch is unclear please check the
+[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation
+guide links by version.
+
+### 1. Stop server
+
+```bash
+sudo service gitlab stop
+```
+
+### 2. Backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
+```
+
+### 3. Update Ruby
+
+NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be
+sure to upgrade your interpreter if necessary.
+
+You can check which version you are running with `ruby -v`.
+
+Download and compile Ruby:
+
+```bash
+mkdir /tmp/ruby && cd /tmp/ruby
+curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz
+echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz
+cd ruby-2.3.3
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Update Node
+
+GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and
+it has a minimum requirement of node v4.3.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v4.3.0` you will need to update to a newer version. You
+can find instructions to install from community maintained packages or compile
+from source at the nodejs.org website.
+
+<https://nodejs.org/en/download/>
+
+
+Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
+JavaScript dependencies.
+
+```bash
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
+```
+
+More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
+
+### 5. Update Go
+
+NOTE: GitLab 9.2 and higher only supports Go 1.8.3 and dropped support for Go
+1.5.x through 1.7.x. Be sure to upgrade your installation if necessary.
+
+You can check which version you are running with `go version`.
+
+Download and install Go:
+
+```bash
+# Remove former Go installation folder
+sudo rm -rf /usr/local/go
+
+curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
+sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
+rm go1.8.3.linux-amd64.tar.gz
+```
+
+### 6. Get latest code
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git fetch --all
+sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
+sudo -u git -H git checkout -- locale
+```
+
+For GitLab Community Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-3-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-3-stable-ee
+```
+
+### 7. Update gitlab-shell
+
+```bash
+cd /home/git/gitlab-shell
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
+sudo -u git -H bin/compile
+```
+
+### 8. Update gitlab-workhorse
+
+Install and compile gitlab-workhorse. GitLab-Workhorse uses
+[GNU Make](https://www.gnu.org/software/make/).
+If you are not using Linux you may have to run `gmake` instead of
+`make` below.
+
+```bash
+cd /home/git/gitlab-workhorse
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
+sudo -u git -H make
+```
+
+### 9. Update Gitaly
+
+If you have not yet set up Gitaly then follow [Gitaly section of the installation
+guide](../install/installation.md#install-gitaly).
+
+#### Compile Gitaly
+
+```shell
+cd /home/git/gitaly
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
+sudo -u git -H make
+```
+
+### 10. Update MySQL permissions
+
+If you are using MySQL you need to grant the GitLab user the necessary
+permissions on the database:
+
+```bash
+mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';"
+```
+
+If you use MySQL with replication, or just have MySQL configured with binary logging,
+you will need to also run the following on all of your MySQL servers:
+
+```bash
+mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;"
+```
+
+You can make this setting permanent by adding it to your `my.cnf`:
+
+```
+log_bin_trust_function_creators=1
+```
+
+### 11. Update configuration files
+
+#### New configuration options for `gitlab.yml`
+
+There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-2-stable:config/gitlab.yml.example origin/9-3-stable:config/gitlab.yml.example
+```
+
+#### Nginx configuration
+
+Ensure you're still up-to-date with the latest NGINX configuration changes:
+
+```sh
+cd /home/git/gitlab
+
+# For HTTPS configurations
+git diff origin/9-2-stable:lib/support/nginx/gitlab-ssl origin/9-3-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/9-2-stable:lib/support/nginx/gitlab origin/9-3-stable:lib/support/nginx/gitlab
+```
+
+If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx
+configuration as GitLab application no longer handles setting it.
+
+If you are using Apache instead of NGINX please see the updated [Apache templates].
+Also note that because Apache does not support upstreams behind Unix sockets you
+will need to let gitlab-workhorse listen on a TCP port. You can do this
+via [/etc/default/gitlab].
+
+[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache
+[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/lib/support/init.d/gitlab.default.example#L38
+
+#### SMTP configuration
+
+If you're installing from source and use SMTP to deliver mail, you will need to add the following line
+to config/initializers/smtp_settings.rb:
+
+```ruby
+ActionMailer::Base.delivery_method = :smtp
+```
+
+See [smtp_settings.rb.sample] as an example.
+
+[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/initializers/smtp_settings.rb.sample#L13
+
+#### Init script
+
+There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-2-stable:lib/support/init.d/gitlab.default.example origin/9-3-stable:lib/support/init.d/gitlab.default.example
+```
+
+Ensure you're still up-to-date with the latest init script changes:
+
+```bash
+cd /home/git/gitlab
+
+sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
+```
+
+For Ubuntu 16.04.1 LTS:
+
+```bash
+sudo systemctl daemon-reload
+```
+
+### 12. Install libs, migrations, etc.
+
+GitLab 9.3.8 [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24570)
+a dependency on on the `re2` regular expression library. To install this dependency:
+
+```bash
+sudo apt-get install libre2-dev
+```
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
+
+```bash
+cd /home/git/gitlab
+
+# MySQL installations (note: the line below states '--without postgres')
+sudo -u git -H bundle install --without postgres development test --deployment
+
+# PostgreSQL installations (note: the line below states '--without mysql')
+sudo -u git -H bundle install --without mysql development test --deployment
+
+# Optional: clean up old gems
+sudo -u git -H bundle clean
+
+# Run database migrations
+sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
+
+# Compile GetText PO files
+
+sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production
+
+# Update node dependencies and recompile assets
+sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production
+
+# Clean up cache
+sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
+```
+
+**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
+
+### 13. Start application
+
+```bash
+sudo service gitlab start
+sudo service nginx restart
+```
+
+### 14. Check application status
+
+Check if GitLab and its environment are configured correctly:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
+```
+
+To make sure you didn't miss anything run a more thorough check:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
+```
+
+If all items are green, then congratulations, the upgrade is complete!
+
+## Things went south? Revert to previous version (9.2)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 9.1 to 9.2](9.1-to-9.2.md), except for the
+database migration (the backup is already migrated to the previous version).
+
+### 2. Restore from the backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
+```
+
+If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above.
+
+[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/9.3-to-9.4.md b/doc/update/9.3-to-9.4.md
new file mode 100644
index 00000000000..9ee01bc9c51
--- /dev/null
+++ b/doc/update/9.3-to-9.4.md
@@ -0,0 +1,357 @@
+# From 9.3 to 9.4
+
+Make sure you view this update guide from the tag (version) of GitLab you would
+like to install. In most cases this should be the highest numbered production
+tag (without rc in it). You can select the tag in the version dropdown at the
+top left corner of GitLab (below the menu bar).
+
+If the highest number stable branch is unclear please check the
+[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation
+guide links by version.
+
+### 1. Stop server
+
+```bash
+sudo service gitlab stop
+```
+
+### 2. Backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
+```
+
+### 3. Update Ruby
+
+NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be
+sure to upgrade your interpreter if necessary.
+
+You can check which version you are running with `ruby -v`.
+
+Download and compile Ruby:
+
+```bash
+mkdir /tmp/ruby && cd /tmp/ruby
+curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz
+echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz
+cd ruby-2.3.3
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Update Node
+
+GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and
+it has a minimum requirement of node v4.3.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v4.3.0` you will need to update to a newer version. You
+can find instructions to install from community maintained packages or compile
+from source at the nodejs.org website.
+
+<https://nodejs.org/en/download/>
+
+
+Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
+JavaScript dependencies.
+
+```bash
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
+```
+
+More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
+
+### 5. Update Go
+
+NOTE: GitLab 9.2 and higher only supports Go 1.8.3 and dropped support for Go
+1.5.x through 1.7.x. Be sure to upgrade your installation if necessary.
+
+You can check which version you are running with `go version`.
+
+Download and install Go:
+
+```bash
+# Remove former Go installation folder
+sudo rm -rf /usr/local/go
+
+curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
+sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
+rm go1.8.3.linux-amd64.tar.gz
+```
+
+### 6. Get latest code
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git fetch --all
+sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
+sudo -u git -H git checkout -- locale
+```
+
+For GitLab Community Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-4-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-4-stable-ee
+```
+
+### 7. Update gitlab-shell
+
+```bash
+cd /home/git/gitlab-shell
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
+sudo -u git -H bin/compile
+```
+
+### 8. Update gitlab-workhorse
+
+Install and compile gitlab-workhorse. GitLab-Workhorse uses
+[GNU Make](https://www.gnu.org/software/make/).
+If you are not using Linux you may have to run `gmake` instead of
+`make` below.
+
+```bash
+cd /home/git/gitlab-workhorse
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
+sudo -u git -H make
+```
+
+### 9. Update Gitaly
+
+If you have not yet set up Gitaly then follow [Gitaly section of the installation
+guide](../install/installation.md#install-gitaly).
+
+As of GitLab 9.4, Gitaly is a mandatory component of GitLab.
+
+#### Check Gitaly configuration
+
+Due to a bug in the `rake gitlab:gitaly:install` script your Gitaly
+configuration file may contain syntax errors. The block name
+`[[storages]]`, which may occur more than once in your `config.toml`
+file, should be `[[storage]]` instead.
+
+```shell
+sudo -u git -H sed -i.pre-9.4 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml
+```
+
+#### Compile Gitaly
+
+```shell
+cd /home/git/gitaly
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
+sudo -u git -H make
+```
+
+### 10. Update MySQL permissions
+
+If you are using MySQL you need to grant the GitLab user the necessary
+permissions on the database:
+
+```bash
+mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';"
+```
+
+If you use MySQL with replication, or just have MySQL configured with binary logging,
+you will need to also run the following on all of your MySQL servers:
+
+```bash
+mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;"
+```
+
+You can make this setting permanent by adding it to your `my.cnf`:
+
+```
+log_bin_trust_function_creators=1
+```
+
+### 11. Update configuration files
+
+#### New configuration options for `gitlab.yml`
+
+There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-3-stable:config/gitlab.yml.example origin/9-4-stable:config/gitlab.yml.example
+```
+
+#### Nginx configuration
+
+Ensure you're still up-to-date with the latest NGINX configuration changes:
+
+```sh
+cd /home/git/gitlab
+
+# For HTTPS configurations
+git diff origin/9-3-stable:lib/support/nginx/gitlab-ssl origin/9-4-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/9-3-stable:lib/support/nginx/gitlab origin/9-4-stable:lib/support/nginx/gitlab
+```
+
+If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx
+configuration as GitLab application no longer handles setting it.
+
+If you are using Apache instead of NGINX please see the updated [Apache templates].
+Also note that because Apache does not support upstreams behind Unix sockets you
+will need to let gitlab-workhorse listen on a TCP port. You can do this
+via [/etc/default/gitlab].
+
+[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache
+[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-4-stable/lib/support/init.d/gitlab.default.example#L38
+
+#### SMTP configuration
+
+If you're installing from source and use SMTP to deliver mail, you will need to add the following line
+to config/initializers/smtp_settings.rb:
+
+```ruby
+ActionMailer::Base.delivery_method = :smtp
+```
+
+See [smtp_settings.rb.sample] as an example.
+
+[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/config/initializers/smtp_settings.rb.sample#L13
+
+#### Init script
+
+There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-3-stable:lib/support/init.d/gitlab.default.example origin/9-4-stable:lib/support/init.d/gitlab.default.example
+```
+
+Ensure you're still up-to-date with the latest init script changes:
+
+```bash
+cd /home/git/gitlab
+
+sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
+```
+
+For Ubuntu 16.04.1 LTS:
+
+```bash
+sudo systemctl daemon-reload
+```
+
+### 12. Install libs, migrations, etc.
+
+GitLab 9.4 [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/24570)
+a dependency on on the `re2` regular expression library. To install this dependency:
+
+```bash
+sudo apt-get install libre2-dev
+```
+
+Ubuntu 14.04 (Trusty Tahr) doesn't have the `libre2-dev` package available, but
+you can [install re2 manually](https://github.com/google/re2/wiki/Install).
+
+```bash
+cd /home/git/gitlab
+
+# MySQL installations (note: the line below states '--without postgres')
+sudo -u git -H bundle install --without postgres development test --deployment
+
+# PostgreSQL installations (note: the line below states '--without mysql')
+sudo -u git -H bundle install --without mysql development test --deployment
+
+# Optional: clean up old gems
+sudo -u git -H bundle clean
+
+# Run database migrations
+sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
+
+# Compile GetText PO files
+
+sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production
+
+# Update node dependencies and recompile assets
+sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production
+
+# Clean up cache
+sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
+```
+
+**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
+
+### 13. Start application
+
+```bash
+sudo service gitlab start
+sudo service nginx restart
+```
+
+### 14. Check application status
+
+Check if GitLab and its environment are configured correctly:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
+```
+
+To make sure you didn't miss anything run a more thorough check:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
+```
+
+If all items are green, then congratulations, the upgrade is complete!
+
+## Things went south? Revert to previous version (9.3)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 9.2 to 9.3](9.2-to-9.3.md), except for the
+database migration (the backup is already migrated to the previous version).
+
+### 2. Restore from the backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
+```
+
+If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above.
+
+[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-4-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-4-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/9.4-to-9.5.md b/doc/update/9.4-to-9.5.md
new file mode 100644
index 00000000000..1b5a15589af
--- /dev/null
+++ b/doc/update/9.4-to-9.5.md
@@ -0,0 +1,356 @@
+# From 9.4 to 9.5
+
+Make sure you view this update guide from the tag (version) of GitLab you would
+like to install. In most cases this should be the highest numbered production
+tag (without rc in it). You can select the tag in the version dropdown at the
+top left corner of GitLab (below the menu bar).
+
+If the highest number stable branch is unclear please check the
+[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation
+guide links by version.
+
+### 1. Stop server
+
+```bash
+sudo service gitlab stop
+```
+
+### 2. Backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
+```
+
+### 3. Update Ruby
+
+NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be
+sure to upgrade your interpreter if necessary.
+
+You can check which version you are running with `ruby -v`.
+
+Download and compile Ruby:
+
+```bash
+mkdir /tmp/ruby && cd /tmp/ruby
+curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz
+echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz
+cd ruby-2.3.3
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Update Node
+
+GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and
+it has a minimum requirement of node v4.3.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v4.3.0` you will need to update to a newer version. You
+can find instructions to install from community maintained packages or compile
+from source at the nodejs.org website.
+
+<https://nodejs.org/en/download/>
+
+
+Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
+JavaScript dependencies.
+
+```bash
+curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt-get update
+sudo apt-get install yarn
+```
+
+More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
+
+### 5. Update Go
+
+NOTE: GitLab 9.2 and higher only supports Go 1.8.3 and dropped support for Go
+1.5.x through 1.7.x. Be sure to upgrade your installation if necessary.
+
+You can check which version you are running with `go version`.
+
+Download and install Go:
+
+```bash
+# Remove former Go installation folder
+sudo rm -rf /usr/local/go
+
+curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
+echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
+ sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
+sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
+rm go1.8.3.linux-amd64.tar.gz
+```
+
+### 6. Get latest code
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git fetch --all
+sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
+sudo -u git -H git checkout -- locale
+```
+
+For GitLab Community Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-5-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 9-5-stable-ee
+```
+
+### 7. Update gitlab-shell
+
+```bash
+cd /home/git/gitlab-shell
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
+sudo -u git -H bin/compile
+```
+
+### 8. Update gitlab-workhorse
+
+Install and compile gitlab-workhorse. GitLab-Workhorse uses
+[GNU Make](https://www.gnu.org/software/make/).
+If you are not using Linux you may have to run `gmake` instead of
+`make` below.
+
+```bash
+cd /home/git/gitlab-workhorse
+
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
+sudo -u git -H make
+```
+
+### 9. Update Gitaly
+
+#### New Gitaly configuration options required
+
+In order to function Gitaly needs some additional configuration information. Below we assume you installed Gitaly in `/home/git/gitaly` and GitLab Shell in `/home/git/gitlab-shell'.
+
+```shell
+echo '
+[gitaly-ruby]
+dir = "/home/git/gitaly/ruby"
+
+[gitlab-shell]
+dir = "/home/git/gitlab-shell"
+' | sudo -u git tee -a /home/git/gitaly/config.toml
+```
+
+#### Check Gitaly configuration
+
+Due to a bug in the `rake gitlab:gitaly:install` script your Gitaly
+configuration file may contain syntax errors. The block name
+`[[storages]]`, which may occur more than once in your `config.toml`
+file, should be `[[storage]]` instead.
+
+```shell
+sudo -u git -H sed -i.pre-9.5 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml
+```
+
+#### Compile Gitaly
+
+```shell
+cd /home/git/gitaly
+sudo -u git -H git fetch --all --tags
+sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
+sudo -u git -H make
+```
+
+### 10. Update MySQL permissions
+
+If you are using MySQL you need to grant the GitLab user the necessary
+permissions on the database:
+
+```bash
+mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';"
+```
+
+If you use MySQL with replication, or just have MySQL configured with binary logging,
+you will need to also run the following on all of your MySQL servers:
+
+```bash
+mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;"
+```
+
+You can make this setting permanent by adding it to your `my.cnf`:
+
+```
+log_bin_trust_function_creators=1
+```
+
+### 11. Update configuration files
+
+#### New configuration options for `gitlab.yml`
+
+There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-4-stable:config/gitlab.yml.example origin/9-5-stable:config/gitlab.yml.example
+```
+
+#### Nginx configuration
+
+Ensure you're still up-to-date with the latest NGINX configuration changes:
+
+```sh
+cd /home/git/gitlab
+
+# For HTTPS configurations
+git diff origin/9-4-stable:lib/support/nginx/gitlab-ssl origin/9-5-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/9-4-stable:lib/support/nginx/gitlab origin/9-5-stable:lib/support/nginx/gitlab
+```
+
+If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx
+configuration as GitLab application no longer handles setting it.
+
+If you are using Apache instead of NGINX please see the updated [Apache templates].
+Also note that because Apache does not support upstreams behind Unix sockets you
+will need to let gitlab-workhorse listen on a TCP port. You can do this
+via [/etc/default/gitlab].
+
+[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache
+[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-5-stable/lib/support/init.d/gitlab.default.example#L38
+
+#### SMTP configuration
+
+If you're installing from source and use SMTP to deliver mail, you will need to add the following line
+to config/initializers/smtp_settings.rb:
+
+```ruby
+ActionMailer::Base.delivery_method = :smtp
+```
+
+See [smtp_settings.rb.sample] as an example.
+
+[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-4-stable/config/initializers/smtp_settings.rb.sample#L13
+
+#### Init script
+
+There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`:
+
+```sh
+cd /home/git/gitlab
+
+git diff origin/9-4-stable:lib/support/init.d/gitlab.default.example origin/9-5-stable:lib/support/init.d/gitlab.default.example
+```
+
+Ensure you're still up-to-date with the latest init script changes:
+
+```bash
+cd /home/git/gitlab
+
+sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
+```
+
+For Ubuntu 16.04.1 LTS:
+
+```bash
+sudo systemctl daemon-reload
+```
+
+### 12. Install libs, migrations, etc.
+
+```bash
+cd /home/git/gitlab
+
+# MySQL installations (note: the line below states '--without postgres')
+sudo -u git -H bundle install --without postgres development test --deployment
+
+# PostgreSQL installations (note: the line below states '--without mysql')
+sudo -u git -H bundle install --without mysql development test --deployment
+
+# Optional: clean up old gems
+sudo -u git -H bundle clean
+
+# Run database migrations
+sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
+
+# Compile GetText PO files
+
+sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production
+
+# Update node dependencies and recompile assets
+sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production
+
+# Clean up cache
+sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
+```
+
+**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
+
+### 13. Start application
+
+```bash
+sudo service gitlab start
+sudo service nginx restart
+```
+
+### 14. Check application status
+
+Check if GitLab and its environment are configured correctly:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
+```
+
+To make sure you didn't miss anything run a more thorough check:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
+```
+
+If all items are green, then congratulations, the upgrade is complete!
+
+## Things went south? Revert to previous version (9.4)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 9.3 to 9.4](9.3-to-9.4.md), except for the
+database migration (the backup is already migrated to the previous version).
+
+### 2. Restore from the backup
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
+```
+
+If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above.
+
+[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-5-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-5-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/README.md b/doc/update/README.md
index d024a809f24..c98e20686e0 100644
--- a/doc/update/README.md
+++ b/doc/update/README.md
@@ -11,22 +11,6 @@ There are currently 3 official ways to install GitLab:
Based on your installation, choose a section below that fits your needs.
----
-
-<!-- START doctoc generated TOC please keep comment here to allow auto update -->
-<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
-**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
-
-- [Omnibus Packages](#omnibus-packages)
-- [Installation from source](#installation-from-source)
-- [Installation using Docker](#installation-using-docker)
-- [Upgrading between editions](#upgrading-between-editions)
- - [Community to Enterprise Edition](#community-to-enterprise-edition)
- - [Enterprise to Community Edition](#enterprise-to-community-edition)
-- [Miscellaneous](#miscellaneous)
-
-<!-- END doctoc generated TOC please keep comment here to allow auto update -->
-
## Omnibus Packages
- The [Omnibus update guide](http://docs.gitlab.com/omnibus/update/README.html)
@@ -50,17 +34,67 @@ update them are in [a separate document][omnidocker].
## Upgrading without downtime
-Starting with GitLab 9.1.0 it's possible to upgrade to a newer major, minor, or patch version of GitLab
-without having to take your GitLab instance offline. However, for this to work
-there are the following requirements:
+Starting with GitLab 9.1.0 it's possible to upgrade to a newer major, minor, or
+patch version of GitLab without having to take your GitLab instance offline.
+However, for this to work there are the following requirements:
-1. You can only upgrade 1 minor release at a time. So from 9.1 to 9.2, not to 9.3.
-2. You have to be on the most recent patch release. For example, if 9.1.15 is the last
- release of 9.1 then you can safely upgrade from that version to any 9.2.x version.
- However, if you are running 9.1.14 you first need to upgrade to 9.1.15.
+1. You can only upgrade 1 minor release at a time. So from 9.1 to 9.2, not to
+ 9.3.
2. You have to use [post-deployment
migrations](../development/post_deployment_migrations.md).
-3. You are using PostgreSQL. If you are using MySQL please look at the release post to see if downtime is required.
+3. You are using PostgreSQL. If you are using MySQL please look at the release
+ post to see if downtime is required.
+
+Most of the time you can safely upgrade from a patch release to the next minor
+release if the patch release is not the latest. For example, upgrading from
+9.1.1 to 9.2.0 should be safe even if 9.1.2 has been released. We do recommend
+you check the release posts of any releases between your current and target
+version just in case they include any migrations that may require you to upgrade
+1 release at a time.
+
+Some releases may also include so called "background migrations". These
+migrations are performed in the background by Sidekiq and are often used for
+migrating data. Background migrations are only added in the monthly releases.
+
+Certain major/minor releases may require a set of background migrations to be
+finished. To guarantee this such a release will process any remaining jobs
+before continuing the upgrading procedure. While this won't require downtime
+(if the above conditions are met) we recommend users to keep at least 1 week
+between upgrading major/minor releases, allowing the background migrations to
+finish. The time necessary to complete these migrations can be reduced by
+increasing the number of Sidekiq workers that can process jobs in the
+`background_migration` queue.
+
+As a rule of thumb, any database smaller than 10 GB won't take too much time to
+upgrade; perhaps an hour at most per minor release. Larger databases however may
+require more time, but this is highly dependent on the size of the database and
+the migrations that are being performed.
+
+### Examples
+
+To help explain this, let's look at some examples.
+
+**Example 1:** You are running a large GitLab installation using version 9.4.2,
+which is the latest patch release of 9.4. When GitLab 9.5.0 is released this
+installation can be safely upgraded to 9.5.0 without requiring downtime if the
+requirements mentioned above are met. You can also skip 9.5.0 and upgrade to
+9.5.1 once it's released, but you **can not** upgrade straight to 9.6.0; you
+_have_ to first upgrade to a 9.5.x release.
+
+**Example 2:** You are running a large GitLab installation using version 9.4.2,
+which is the latest patch release of 9.4. GitLab 9.5 includes some background
+migrations, and 10.0 will require these to be completed (processing any
+remaining jobs for you). Skipping 9.5 is not possible without downtime, and due
+to the background migrations would require potentially hours of downtime
+depending on how long it takes for the background migrations to complete. To
+work around this you will have to upgrade to 9.5.x first, then wait at least a
+week before upgrading to 10.0.
+
+**Example 3:** You use MySQL as the database for GitLab. Any upgrade to a new
+major/minor release will require downtime. If a release includes any background
+migrations this could potentially lead to hours of downtime, depending on the
+size of your database. To work around this you will have to use PostgreSQL and
+meet the other online upgrade requirements mentioned above.
## Upgrading between editions
diff --git a/doc/update/patch_versions.md b/doc/update/patch_versions.md
index f69d567eeb7..30107360446 100644
--- a/doc/update/patch_versions.md
+++ b/doc/update/patch_versions.md
@@ -35,7 +35,7 @@ current version with `cat VERSION`).
cd /home/git/gitlab
sudo -u git -H git fetch --all
-sudo -u git -H git checkout -- Gemfile.lock db/schema.rb
+sudo -u git -H git checkout -- Gemfile.lock db/schema.rb locale
sudo -u git -H git checkout LATEST_TAG -b LATEST_TAG
```
@@ -56,6 +56,12 @@ sudo -u git -H bundle clean
# Run database migrations
sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
+# Compile GetText PO files
+# Internationalization was added in `v9.2.0` so these commands are only
+# required for versions equal or major to it.
+sudo -u git -H bundle exec rake gettext:pack RAILS_ENV=production
+sudo -u git -H bundle exec rake gettext:po_to_json RAILS_ENV=production
+
# Clean up assets and cache
sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile cache:clear RAILS_ENV=production NODE_ENV=production
```
@@ -75,6 +81,7 @@ cd /home/git/gitlab-shell
sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`
+sudo -u git -H sh -c 'if [ -x bin/compile ]; then bin/compile; fi'
```
### 6. Start application
diff --git a/doc/update/upgrader.md b/doc/update/upgrader.md
index 5fa39ef1b0a..eb7f14a96d5 100644
--- a/doc/update/upgrader.md
+++ b/doc/update/upgrader.md
@@ -60,6 +60,7 @@ GitLab Shell might be outdated, running the commands below ensures you're using
cd /home/git/gitlab-shell
sudo -u git -H git fetch
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`
+sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi'
```
## One line upgrade command
@@ -78,6 +79,7 @@ cd /home/git/gitlab; \
cd /home/git/gitlab-shell; \
sudo -u git -H git fetch; \
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`; \
+ sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi'; \
cd /home/git/gitlab; \
sudo service gitlab start; \
sudo service nginx restart; \
diff --git a/doc/user/admin_area/monitoring/convdev.md b/doc/user/admin_area/monitoring/convdev.md
new file mode 100644
index 00000000000..3d93c7557a4
--- /dev/null
+++ b/doc/user/admin_area/monitoring/convdev.md
@@ -0,0 +1,29 @@
+# Conversational Development Index
+
+> [Introduced][ce-30469] in GitLab 9.3.
+
+Conversational Development Index (ConvDev) gives you an overview of your entire
+instance's feature usage, from idea to production. It looks at your usage in the
+past 30 days, averaged over the number of active users in that time period. It also
+provides a lead score per feature, which is calculated based on GitLab's analysis
+of top performing instances, based on [usage ping data][ping] that GitLab has
+collected. Your score is compared to the lead score, expressed as a percentage.
+The overall index score is an average over all your feature scores.
+
+![ConvDev index](img/convdev_index.png)
+
+The page also provides helpful links to articles and GitLab docs, to help you
+improve your scores.
+
+Your GitLab instance's usage ping must be activated in order to use this feature.
+Usage ping data is aggregated on GitLab's servers for analysis. Your usage
+information is **not sent** to any other GitLab instances.
+
+If you have just started using GitLab, it may take a few weeks for data to be
+collected before this feature is available.
+
+This feature is accessible only to a system admin, at
+**Admin area > Monitoring > ConvDev Index**.
+
+[ce-30469]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30469
+[ping]: ../settings/usage_statistics.md#usage-ping
diff --git a/doc/user/admin_area/monitoring/health_check.md b/doc/user/admin_area/monitoring/health_check.md
index a4935f66cbd..70934f9960a 100644
--- a/doc/user/admin_area/monitoring/health_check.md
+++ b/doc/user/admin_area/monitoring/health_check.md
@@ -5,6 +5,8 @@
- The `health_check` endpoint was [introduced][ce-3888] in GitLab 8.8 and will
be deprecated in GitLab 9.1. Read more in the [old behavior](#old-behavior)
section.
+ - [Access token](#access-token) has been deprecated in GitLab 9.4
+ in favor of [IP whitelist](#ip-whitelist)
GitLab provides liveness and readiness probes to indicate service health and
reachability to required services. These probes report on the status of the
@@ -12,97 +14,101 @@ database connection, Redis connection, and access to the filesystem. These
endpoints [can be provided to schedulers like Kubernetes][kubernetes] to hold
traffic until the system is ready or restart the container as needed.
-## Access Token
+## IP whitelist
-An access token needs to be provided while accessing the probe endpoints. The current
-accepted token can be found under the **Admin area âž” Monitoring âž” Health check**
-(`admin/health_check`) page of your GitLab instance.
+To access monitoring resources, the client IP needs to be included in a whitelist.
-![access token](img/health_check_token.png)
+[Read how to add IPs to a whitelist for the monitoring endpoints.][admin].
-The access token can be passed as a URL parameter:
+## Using the endpoint
-```
-https://gitlab.example.com/-/readiness?token=ACCESS_TOKEN
-```
+With default whitelist settings, the probes can be accessed from localhost:
+
+- `http://localhost/-/readiness`
+- `http://localhost/-/liveness`
-which will then provide a report of system health in JSON format:
+which will then provide a report of system health in JSON format.
+
+Readiness example output:
```
{
- "db_check": {
- "status": "ok"
- },
- "redis_check": {
- "status": "ok"
- },
- "fs_shards_check": {
- "status": "ok",
- "labels": {
- "shard": "default"
- }
- }
+ "queues_check" : {
+ "status" : "ok"
+ },
+ "redis_check" : {
+ "status" : "ok"
+ },
+ "shared_state_check" : {
+ "status" : "ok"
+ },
+ "fs_shards_check" : {
+ "labels" : {
+ "shard" : "default"
+ },
+ "status" : "ok"
+ },
+ "db_check" : {
+ "status" : "ok"
+ },
+ "cache_check" : {
+ "status" : "ok"
+ }
}
```
-## Using the Endpoint
-
-Once you have the access token, the probes can be accessed:
+Liveness example output:
-- `https://gitlab.example.com/-/readiness?token=ACCESS_TOKEN`
-- `https://gitlab.example.com/-/liveness?token=ACCESS_TOKEN`
+```
+{
+ "fs_shards_check" : {
+ "status" : "ok"
+ },
+ "cache_check" : {
+ "status" : "ok"
+ },
+ "db_check" : {
+ "status" : "ok"
+ },
+ "redis_check" : {
+ "status" : "ok"
+ },
+ "queues_check" : {
+ "status" : "ok"
+ },
+ "shared_state_check" : {
+ "status" : "ok"
+ }
+}
+```
## Status
On failure, the endpoint will return a `500` HTTP status code. On success, the endpoint
will return a valid successful HTTP status code, and a `success` message.
-## Old behavior
-
->**Notes:**
- - Liveness and readiness probes were [introduced][ce-10416] in GitLab 9.1.
- - The `health_check` endpoint was [introduced][ce-3888] in GitLab 8.8 and will
- be deprecated in GitLab 9.1. Read more in the [old behavior](#old-behavior)
- section.
-
-GitLab provides a health check endpoint for uptime monitoring on the `health_check` web
-endpoint. The health check reports on the overall system status based on the status of
-the database connection, the state of the database migrations, and the ability to write
-and access the cache. This endpoint can be provided to uptime monitoring services like
-[Pingdom][pingdom], [Nagios][nagios-health], and [NewRelic][newrelic-health].
+## Access token (Deprecated)
-Once you have the [access token](#access-token), health information can be
-retrieved as plain text, JSON, or XML using the `health_check` endpoint:
+>**Note:**
+Access token has been deprecated in GitLab 9.4
+in favor of [IP whitelist](#ip-whitelist)
-- `https://gitlab.example.com/health_check?token=ACCESS_TOKEN`
-- `https://gitlab.example.com/health_check.json?token=ACCESS_TOKEN`
-- `https://gitlab.example.com/health_check.xml?token=ACCESS_TOKEN`
-
-You can also ask for the status of specific services:
-
-- `https://gitlab.example.com/health_check/cache.json?token=ACCESS_TOKEN`
-- `https://gitlab.example.com/health_check/database.json?token=ACCESS_TOKEN`
-- `https://gitlab.example.com/health_check/migrations.json?token=ACCESS_TOKEN`
-
-For example, the JSON output of the following health check:
+An access token needs to be provided while accessing the probe endpoints. The current
+accepted token can be found under the **Admin area âž” Monitoring âž” Health check**
+(`admin/health_check`) page of your GitLab instance.
-```bash
-curl --header "TOKEN: ACCESS_TOKEN" https://gitlab.example.com/health_check.json
-```
+![access token](img/health_check_token.png)
-would be like:
+The access token can be passed as a URL parameter:
```
-{"healthy":true,"message":"success"}
+https://gitlab.example.com/-/readiness?token=ACCESS_TOKEN
```
-On failure, the endpoint will return a `500` HTTP status code. On success, the endpoint
-will return a valid successful HTTP status code, and a `success` message. Ideally your
-uptime monitoring should look for the success message.
-
-[ce-10416]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3888
+[ce-10416]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10416
[ce-3888]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3888
[pingdom]: https://www.pingdom.com
[nagios-health]: https://nagios-plugins.org/doc/man/check_http.html
[newrelic-health]: https://docs.newrelic.com/docs/alerts/alert-policies/downtime-alerts/availability-monitoring
[kubernetes]: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/
+[admin]: ../../../administration/monitoring/ip_whitelist.md
diff --git a/doc/user/admin_area/monitoring/img/convdev_index.png b/doc/user/admin_area/monitoring/img/convdev_index.png
new file mode 100644
index 00000000000..4e47ff2228d
--- /dev/null
+++ b/doc/user/admin_area/monitoring/img/convdev_index.png
Binary files differ
diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md
index 733e70ca9bf..d874688cc29 100644
--- a/doc/user/admin_area/settings/usage_statistics.md
+++ b/doc/user/admin_area/settings/usage_statistics.md
@@ -3,13 +3,14 @@
GitLab Inc. will periodically collect information about your instance in order
to perform various actions.
-All statistics are opt-out, you can disable them from the admin panel.
+All statistics are opt-out, you can enable/disable them from the admin panel
+under **Admin area > Settings > Usage statistics**.
## Version check
GitLab can inform you when an update is available and the importance of it.
-No information other than the GitLab version and the instance's domain name
+No information other than the GitLab version and the instance's hostname (through the HTTP referer)
are collected.
In the **Overview** tab you can see if your GitLab version is up to date. There
@@ -28,74 +29,37 @@ for all signed in users.
[were added][ee-735] in GitLab Enterprise Edition
8.12. [Moved to GitLab Community Edition][ce-23361] in 9.1.
-GitLab Inc. can collect non-sensitive information about how GitLab users
-use their GitLab instance upon the activation of a ping feature
-located in the admin panel (`/admin/application_settings`).
-
-You can see the **exact** JSON payload that your instance sends to GitLab
-in the "Usage statistics" section of the admin panel.
-
-Nothing qualitative is collected. Only quantitative. That means no project
-names, author names, comment bodies, names of labels, etc.
-
-The usage ping is sent in order for GitLab Inc. to have a better understanding
-of how our users use our product, and to be more data-driven when creating or
-changing features.
-
-The total number of the following is sent back to GitLab Inc.:
-
-- Comments
-- Groups
-- Users
-- Projects
-- Issues
-- Labels
-- CI builds
-- Snippets
-- Milestones
-- Todos
-- Pushes
-- Merge requests
-- Environments
-- Triggers
-- Deploy keys
-- Pages
-- Project Services
-- Projects using the Prometheus service
-- Issue Boards
-- CI Runners
-- Deployments
-- Geo Nodes
-- LDAP Groups
-- LDAP Keys
-- LDAP Users
-- LFS objects
-- Protected branches
-- Releases
-- Remote mirrors
-- Uploads
-- Web hooks
-
-Also, we track if you've installed Mattermost with GitLab.
-For example: `"mattermost_enabled":true"`.
-
-More data will be added over time. The goal of this ping is to be as light as
-possible, so it won't have any performance impact on your installation when
-the calculation is made.
+GitLab sends a weekly payload containing usage data to GitLab Inc. The usage
+ping uses high-level data to help our product, support, and sales teams. It does
+not send any project names, usernames, or any other specific data. The
+information from the usage ping is not anonymous, it is linked to the hostname
+of the instance.
+
+You can view the exact JSON payload in the administration panel.
### Deactivate the usage ping
-By default, usage ping is opt-out. If you want to deactivate this feature, go to
+The usage ping is opt-out. If you want to deactivate this feature, go to
the Settings page of your administration panel and uncheck the Usage ping
checkbox.
-## Privacy policy
+To disable the usage ping and prevent it from being configured in future through
+the administration panel, Omnibus installs can set the following in
+[`gitlab.rb`](https://docs.gitlab.com/omnibus/settings/configuration.html#configuration-options):
+
+```ruby
+gitlab_rails['usage_ping_enabled'] = false
+```
-GitLab Inc. does **not** collect any sensitive information, like project names
-or the content of the comments. GitLab Inc. does not disclose or otherwise make
-available any of the data collected on a customer specific basis.
+And source installs can set the following in `gitlab.yml`:
-Read more about this in the [Privacy policy](https://about.gitlab.com/privacy).
+```yaml
+production: &base
+ # ...
+ gitlab:
+ # ...
+ usage_ping_enabled: false
+```
[ee-557]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/557
[ee-735]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/735
diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md
index 59e343ebe51..8b1d299484c 100644
--- a/doc/user/discussions/index.md
+++ b/doc/user/discussions/index.md
@@ -10,7 +10,7 @@ You can leave a comment in the following places:
- commits
- commit diffs
-The comment area supports [Markdown] and [slash commands]. One can edit their
+The comment area supports [Markdown] and [quick actions]. One can edit their
own comment at any time, and anyone with [Master access level][permissions] or
higher can also edit a comment made by someone else.
@@ -146,5 +146,5 @@ comments in greater detail.
[discussion-view]: img/discussion_view.png
[discussions-resolved]: img/discussions_resolved.png
[markdown]: ../markdown.md
-[slash commands]: ../project/slash_commands.md
+[quick actions]: ../project/quick_actions.md
[permissions]: ../permissions.md
diff --git a/doc/workflow/groups/access_requests_management.png b/doc/user/group/img/access_requests_management.png
index 36deaa89a70..36deaa89a70 100644
--- a/doc/workflow/groups/access_requests_management.png
+++ b/doc/user/group/img/access_requests_management.png
Binary files differ
diff --git a/doc/user/group/img/add_new_members.png b/doc/user/group/img/add_new_members.png
new file mode 100644
index 00000000000..53f5596de23
--- /dev/null
+++ b/doc/user/group/img/add_new_members.png
Binary files differ
diff --git a/doc/gitlab-basics/img/create_new_group_info.png b/doc/user/group/img/create_new_group_info.png
index 8d2501d9f7a..8d2501d9f7a 100644
--- a/doc/gitlab-basics/img/create_new_group_info.png
+++ b/doc/user/group/img/create_new_group_info.png
Binary files differ
diff --git a/doc/gitlab-basics/img/create_new_project_from_group.png b/doc/user/group/img/create_new_project_from_group.png
index c35234660db..c35234660db 100644
--- a/doc/gitlab-basics/img/create_new_project_from_group.png
+++ b/doc/user/group/img/create_new_project_from_group.png
Binary files differ
diff --git a/doc/user/group/img/group_settings.png b/doc/user/group/img/group_settings.png
new file mode 100644
index 00000000000..629cd0729aa
--- /dev/null
+++ b/doc/user/group/img/group_settings.png
Binary files differ
diff --git a/doc/user/group/img/groups.png b/doc/user/group/img/groups.png
new file mode 100644
index 00000000000..6211f999d5e
--- /dev/null
+++ b/doc/user/group/img/groups.png
Binary files differ
diff --git a/doc/user/group/img/membership_lock.png b/doc/user/group/img/membership_lock.png
new file mode 100644
index 00000000000..d31fbb43375
--- /dev/null
+++ b/doc/user/group/img/membership_lock.png
Binary files differ
diff --git a/doc/workflow/groups/new_group_form.png b/doc/user/group/img/new_group_form.png
index 91727ab5336..91727ab5336 100644
--- a/doc/workflow/groups/new_group_form.png
+++ b/doc/user/group/img/new_group_form.png
Binary files differ
diff --git a/doc/user/group/img/new_group_from_groups.png b/doc/user/group/img/new_group_from_groups.png
new file mode 100644
index 00000000000..baf34244cb2
--- /dev/null
+++ b/doc/user/group/img/new_group_from_groups.png
Binary files differ
diff --git a/doc/user/group/img/new_group_from_other_pages.png b/doc/user/group/img/new_group_from_other_pages.png
new file mode 100644
index 00000000000..014a7088af2
--- /dev/null
+++ b/doc/user/group/img/new_group_from_other_pages.png
Binary files differ
diff --git a/doc/workflow/groups/request_access_button.png b/doc/user/group/img/request_access_button.png
index f1aae6afed7..f1aae6afed7 100644
--- a/doc/workflow/groups/request_access_button.png
+++ b/doc/user/group/img/request_access_button.png
Binary files differ
diff --git a/doc/gitlab-basics/img/select_group_dropdown.png b/doc/user/group/img/select_group_dropdown.png
index 68fc950304c..68fc950304c 100644
--- a/doc/gitlab-basics/img/select_group_dropdown.png
+++ b/doc/user/group/img/select_group_dropdown.png
Binary files differ
diff --git a/doc/user/group/img/share_with_group_lock.png b/doc/user/group/img/share_with_group_lock.png
new file mode 100644
index 00000000000..8df41bf9465
--- /dev/null
+++ b/doc/user/group/img/share_with_group_lock.png
Binary files differ
diff --git a/doc/user/group/img/transfer_project_to_other_group.png b/doc/user/group/img/transfer_project_to_other_group.png
new file mode 100644
index 00000000000..042c002f83f
--- /dev/null
+++ b/doc/user/group/img/transfer_project_to_other_group.png
Binary files differ
diff --git a/doc/workflow/groups/withdraw_access_request_button.png b/doc/user/group/img/withdraw_access_request_button.png
index c5d8ef6c04f..c5d8ef6c04f 100644
--- a/doc/workflow/groups/withdraw_access_request_button.png
+++ b/doc/user/group/img/withdraw_access_request_button.png
Binary files differ
diff --git a/doc/user/group/index.md b/doc/user/group/index.md
new file mode 100644
index 00000000000..9e168e830e5
--- /dev/null
+++ b/doc/user/group/index.md
@@ -0,0 +1,221 @@
+# Groups
+
+With GitLab Groups you can assemble related projects together
+and grant members access to several projects at once.
+
+Groups can also be nested in [subgroups](subgroups/index.md).
+
+Find your groups by expanding the left menu and clicking **Groups**:
+
+![GitLab Groups](img/groups.png)
+
+The Groups page displays all groups you are a member of, how many projects it holds,
+how many members it has, the group visibility, and, if you have enough permissions,
+a link to the group settings. By clicking the last button you can leave that group.
+
+## Use cases
+
+You can create groups for numerous reasons. To name a few:
+
+- Organize related projects under the same [namespace](#namespaces), add members to that
+group and grant access to all their projects at once
+- Create a group, include members of your team, and make it easier to
+`@mention` all the team at once in issues and merge requests
+ - Create a group for your company members, and create [subgroups](subgroups/index.md)
+ for each individual team. Let's say you create a group called `company-team`, and among others,
+ you created subgroups in this group for each individual team `backend-team`,
+ `frontend-team`, and `production-team`:
+ 1. When you start a new implementation from an issue, you add a comment:
+ _"`@company-team`, let's do it! `@company-team/backend-team` you're good to go!"_
+ 1. When your backend team needs help from frontend, they add a comment:
+ _"`@company-team/frontend-team` could you help us here please?"_
+ 1. When the frontend team completes their implementation, they comment:
+ _"`@company-team/backend-team`, it's done! Let's ship it `@company-team/production-team`!"_
+
+## Namespaces
+
+In GitLab, a namespace is a unique name to be used as a user name, a group name, or a subgroup name.
+
+- `http://gitlab.example.com/username`
+- `http://gitlab.example.com/groupname`
+- `http://gitlab.example.com/groupname/subgroup_name`
+
+For example, consider a user called John:
+
+1. John creates his account on GitLab.com with the username `john`;
+his profile will be accessed under `https://gitlab.example.com/john`
+1. John creates a group for his team with the groupname `john-team`;
+his group and its projects will be accessed under `https://gitlab.example.com/john-team`
+1. John creates a subgroup of `john-team` with the subgroup name `marketing`;
+his subgroup and its projects will be accessed under `https://gitlab.example.com/john-team/marketing`
+
+By doing so:
+
+- Any team member mentions John with `@john`
+- John mentions everyone from his team with `@john-team`
+- John mentions only his marketing team with `@john-team/marketing`
+
+## Create a new group
+
+> **Notes:**
+- For a list of words that are not allowed to be used as group names see the
+ [reserved names][reserved].
+
+You can create a group in GitLab from:
+
+1. The Groups page: expand the left menu, click **Groups**, and click the green button **New group**:
+
+ ![new group from groups page](img/new_group_from_groups.png)
+
+1. Elsewhere: expand the `plus` sign button on the top navbar and choose **New group**:
+
+ ![new group from elsewhere](img/new_group_from_other_pages.png)
+
+Add the following information:
+
+![new group info](img/create_new_group_info.png)
+
+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 an avatar for your project.
+1. Choose the [visibility level](../../public_access/public_access.md).
+
+## Add users to a group
+
+Add members to a group by navigating to the group's dashboard, and clicking **Members**:
+
+![add members to group](img/add_new_members.png)
+
+Select the [permission level][permissions] and add the new member. You can also set the expiring
+date for that user, from which they will no longer have access to your group.
+
+One of the benefits of putting multiple projects in one group is that you can
+give a user to access to all projects in the group with one action.
+
+Consider we have a group with two projects:
+
+- On the **Group Members** page we can now add a new user to the group.
+- Now because this user is a **Developer** member of the group, he automatically
+gets **Developer** access to **all projects** within that group.
+
+If necessary, you can increase the access level of an individual user for a specific project,
+by adding them again as a new member to the project with the new permission levels.
+
+## Request access to a group
+
+As a group owner you can enable or disable non members to request access to
+your group. Go to the group settings and click on **Allow users to request access**.
+
+As a user, you can request to be a member of a group. Go to the group you'd
+like to be a member of, and click the **Request Access** button on the right
+side of your screen.
+
+![Request access button](img/request_access_button.png)
+
+---
+
+Group owners and masters will be notified of your request and will be able to approve or
+decline it on the members page.
+
+![Manage access requests](img/access_requests_management.png)
+
+---
+
+If you change your mind before your request is approved, just click the
+**Withdraw Access Request** button.
+
+![Withdraw access request button](img/withdraw_access_request_button.png)
+
+## Add projects to a group
+
+There are two different ways to add a new project to a group:
+
+- Select a group and then click on the **New project** button.
+
+ ![New project](img/create_new_project_from_group.png)
+
+ You can then continue on [creating a project](../../gitlab-basics/create-project.md).
+
+- While you are creating a project, select a group namespace
+ you've already created from the dropdown menu.
+
+ ![Select group](img/select_group_dropdown.png)
+
+## Transfer an existing project into a group
+
+You can transfer an existing project into a group as long as you have at least **Master** [permissions][permissions] to that group
+and if you are an **Owner** of the project.
+
+![Transfer a project to a new namespace](img/transfer_project_to_other_group.png)
+
+Find this option under your project's settings.
+
+GitLab administrators can use the admin interface to move any project to any namespace if needed.
+
+## Sharing a project with a group
+
+You can [share your projects with a group](../project/members/share_project_with_groups.md)
+and give your group members access to the project all at once.
+
+Alternatively, with [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/),
+you can [lock the sharing with group feature](#share-with-group-lock-ees-eep).
+
+## Manage group memberships via LDAP
+
+In GitLab Enterprise Edition it is possible to manage GitLab group memberships using LDAP groups.
+See [the GitLab Enterprise Edition documentation](../../integration/ldap.md) for more information.
+
+## Group settings
+
+Once you have created a group, you can manage its settings by navigating to
+the group's dashboard, and clicking **Settings**.
+
+![group settings](img/group_settings.png)
+
+### General settings
+
+Besides giving you the option to edit any settings you've previously
+set when [creating the group](#create-a-new-group), you can also
+access further configurations for your group.
+
+#### Enforce 2FA to group members
+
+Add a secury layer to your group by
+[enforcing two-factor authentication (2FA)](../../security/two_factor_authentication.md#enforcing-2fa-for-all-users-in-a-group)
+to all group members.
+
+#### Member Lock (EES/EEP)
+
+Available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/),
+with **Member Lock** it is possible to lock membership in project to the
+level of members in group.
+
+Learn more about [Member Lock](https://docs.gitlab.com/ee/user/group/index.html#member-lock-ees-eep).
+
+#### Share with group lock (EES/EEP)
+
+In [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/)
+it is possible to prevent projects in a group from [sharing
+a project with another group](../project/members/share_project_with_groups.md).
+This allows for tighter control over project access.
+
+Learn more about [Share with group lock](https://docs.gitlab.com/ee/user/group/index.html#share-with-group-lock-ees-eep).
+
+### Advanced settings
+
+- **Projects**: view all projects within that group, add members to each project,
+access each project's settings, and remove any project from the same screen.
+- **Webhooks**: configure [webhooks](../project/integrations/webhooks.md)
+and [push rules](https://docs.gitlab.com/ee/push_rules/push_rules.html#push-rules) to your group (Push Rules is available in [GitLab Enteprise Edition Starter][ee].)
+- **Audit Events**: view [Audit Events](https://docs.gitlab.com/ee/administration/audit_events.html#audit-events)
+for the group (GitLab admins only, available in [GitLab Enterprise Edition Starter][ee]).
+- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group
+
+[permissions]: ../permissions.md#permissions
+[ee]: https://about.gitlab.com/products/
+[reserved]: ../reserved_names.md
diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md
index ce5da07c61a..d2478aea4bd 100644
--- a/doc/user/group/subgroups/index.md
+++ b/doc/user/group/subgroups/index.md
@@ -1,6 +1,9 @@
# Subgroups
-> [Introduced][ce-2772] in GitLab 9.0.
+>**Notes:**
+- [Introduced][ce-2772] in GitLab 9.0.
+- Not available when using MySQL as external database (support removed in
+ GitLab 9.3 [due to performance reasons][issue]).
With subgroups (aka nested groups or hierarchical groups) you can have
up to 20 levels of nested groups, which among other things can help you to:
@@ -13,6 +16,15 @@ up to 20 levels of nested groups, which among other things can help you to:
- **Make it easier to manage people and control visibility.** Give people
different [permissions][] depending on their group [membership](#membership).
+## Database Requirements
+
+Nested groups are only supported when you use PostgreSQL. Supporting nested
+groups on MySQL in an efficient way is not possible due to MySQL's limitations.
+See the following links for more information:
+
+* <https://gitlab.com/gitlab-org/gitlab-ce/issues/30472>
+* <https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10885>
+
## Overview
A group can have many subgroups inside it, and at the same time a group can have
@@ -71,8 +83,7 @@ structure.
- You need to be an Owner of a group in order to be able to create
a subgroup. For more information check the [permissions table][permissions].
- For a list of words that are not allowed to be used as group names see the
- [`namespace_validator.rb` file][reserved] under the `RESERVED` and
- `WILDCARD_ROUTES` lists.
+ [reserved names][reserved].
To create a subgroup:
@@ -161,4 +172,5 @@ Here's a list of what you can't do with subgroups:
[ce-2772]: https://gitlab.com/gitlab-org/gitlab-ce/issues/2772
[permissions]: ../../permissions.md#group
-[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/validators/namespace_validator.rb
+[reserved]: ../../reserved_names.md
+[issue]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30472#note_27747600
diff --git a/doc/user/img/gitlab_snippet.png b/doc/user/img/gitlab_snippet.png
new file mode 100644
index 00000000000..718347fc2d4
--- /dev/null
+++ b/doc/user/img/gitlab_snippet.png
Binary files differ
diff --git a/doc/user/index.md b/doc/user/index.md
new file mode 100644
index 00000000000..d664fd62754
--- /dev/null
+++ b/doc/user/index.md
@@ -0,0 +1,152 @@
+# User documentation
+
+Welcome to GitLab! We're glad to have you here!
+
+As a GitLab user you'll have access to all the features
+your [subscription](https://about.gitlab.com/products/)
+includes, except [GitLab administrator](../README.md#administrator-documentation)
+settings, unless you have admin privileges to install, configure,
+and upgrade your GitLab instance.
+
+For GitLab.com, admin privileges are restricted to the GitLab team.
+
+If you run your own GitLab instance and are looking for the administration settings,
+please refer to the [administration](../README.md#administrator-documentation)
+documentation.
+
+## Overview
+
+GitLab is a fully integrated software development platform that enables you
+and your team to work cohesively, faster, transparently, and effectively,
+since the discussion of a new idea until taking that idea to production all
+all the way through, from within the same platform.
+
+Please check this page for an overview on [GitLab's features](https://about.gitlab.com/features/).
+
+## Use cases
+
+GitLab is a git-based platforms that integrates a great number of essential tools for software development and deployment, and project management:
+
+- Code hosting in repositories with version control
+- Track proposals for new implementations, bug reports, and feedback with a
+fully featured [Issue Tracker](project/issues/index.md#issue-tracker)
+- Organize and prioritize with [Issue Boards](project/issues/index.md#issue-boards)
+- Code review in [Merge Requests](project/merge_requests/index.md) with live-preview changes per
+branch with [Review Apps](../ci/review_apps/index.md)
+- Build, test and deploy with built-in [Continuous Integration](../ci/README.md)
+- Deploy your personal and professional static websites with [GitLab Pages](project/pages/index.md)
+- Integrate with Docker with [GitLab Container Registry](project/container_registry.md)
+- Track the development lifecycle with [GitLab Cycle Analytics](project/cycle_analytics.md)
+
+With GitLab Enterprise Edition, you can also:
+
+- Provide support with [Service Desk](https://docs.gitlab.com/ee/user/project/service_desk.html)
+- Improve collaboration with
+[Merge Request Approvals](https://docs.gitlab.com/ee/user/project/merge_requests/index.html#merge-request-approvals),
+[Multiple Assignees for Issues](https://docs.gitlab.com/ee/user/project/issues/multiple_assignees_for_issues.html),
+and [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards)
+- Create formal relashionships between issues with [Related Issues](https://docs.gitlab.com/ee/user/project/issues/related_issues.html)
+- Use [Burndown Charts](https://docs.gitlab.com/ee/user/project/milestones/burndown_charts.html) to track progress during a sprint or while working on a new version of their software.
+- Leverage [Elasticsearch](https://docs.gitlab.com/ee/integration/elasticsearch.html) with [Advanced Global Search](https://docs.gitlab.com/ee/user/search/advanced_global_search.html) and [Advanced Syntax Search](https://docs.gitlab.com/ee/user/search/advanced_search_syntax.html) for faster, more advanced code search across your entire GitLab instance
+- [Authenticate users with Kerberos](https://docs.gitlab.com/ee/integration/kerberos.html)
+- [Mirror a repository](https://docs.gitlab.com/ee/workflow/repository_mirroring.html) from elsewhere on your local server.
+- [Export issues as CSV](https://docs.gitlab.com/ee/user/project/issues/csv_export.html)
+- View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html)
+- [Lock files](https://docs.gitlab.com/ee/user/project/file_lock.html) to prevent conflicts
+- View of the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html)
+- Leverage your continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html)
+
+You can also [integrate](project/integrations/project_services.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, JIRA, and a lot more.
+
+### Articles
+
+For a complete workflow use case please check [GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario).
+
+For more use cases please check our [Technical Articles](../articles/index.md).
+
+## Projects
+
+In GitLab, you can create [projects](project/index.md) for numerous reasons, such as, host
+your code, use it as an issue tracker, collaborate on code, and continuously
+build, test, and deploy your app with built-in GitLab CI/CD. Or, you can do
+it all at once, from one single project.
+
+- [Repositories](project/repository/index.md): Host your codebase in
+repositories with version control and as part of a fully integrated platform.
+- [Issues](project/issues/index.md): Explore the best of GitLab Issues' features.
+- [Merge Requests](project/merge_requests/index.md): Collaborate on code,
+reviews, live preview changes per branch, and request approvals with Merge Requests.
+- [Milestones](project/milestones/index.md): Work on multiple issues and merge
+requests towards the same target date with Milestones.
+
+## GitLab CI/CD
+
+Use built-in [GitLab CI/CD](../ci/README.md) to test, build, and deploy your applications
+directly from GitLab. No third-party integrations needed.
+
+- [GitLab Auto Deploy](../ci/autodeploy/index.md): Deploy your application out-of-the-box with GitLab Auto Deploy.
+- [Review Apps](../ci/review_apps/index.md): Live-preview the changes introduced by a merge request with Review Apps.
+- [GitLab Pages](project/pages/index.md): Publish your static site directly from
+GitLab with Gitlab Pages. You can build, test, and deploy any Static Site Generator with Pages.
+- [GitLab Container Registry](project/container_registry.md): Build and deploy Docker
+images with Container Registry.
+
+## Account
+
+There is a lot you can customize and configure
+to enjoy the best of GitLab.
+
+- [Settings](profile/index.md): Manage your user settings to change your personal info,
+personal access tokens, authorized applications, etc.
+- [Authentication](../topics/authentication/index.md): Read through the authentication
+methods available in GitLab.
+- [Permissions](permissions.md): Learn the different set of permissions levels for each
+user type (guest, reporter, developer, master, owner).
+
+## Groups
+
+With GitLab [Groups](group/index.md) you can assemble related projects together
+and grant members access to several projects at once.
+
+Groups can also be nested in [subgroups](group/subgroups/index.md).
+
+## Discussions
+
+In GitLab, you can comment and mention collaborators in issues,
+merge requests, code snippets, and commits.
+
+When performing inline reviews to implementations
+to your codebase through merge requests you can
+gather feedback through [resolvable discussions](discussions/index.md#resolvable-discussions).
+
+## Todos
+
+Never forget to reply to your collaborators. [GitLab Todos](../workflow/todos.md)
+are a tool for working faster and more effectively with your team,
+by listing all user or group mentions, as well as issues and merge
+requests you're assigned to.
+
+## Snippets
+
+[Snippets](snippets.md) are code blocks that you want to store in GitLab, from which
+you have quick access to. You can also gather feedback on them through
+[discussions](#discussions).
+
+## Integrations
+
+[Integrate GitLab](../integration/README.md) with your preferred tool,
+such as Trello, JIRA, etc.
+
+## Webhooks
+
+Configure [webhooks](project/integrations/webhooks.html) to listen for
+specific events like pushes, issues or merge requests. GitLab will send a
+POST request with data to the webhook URL.
+
+## API
+
+Automate GitLab via [API](../api/README.html).
+
+## Git and GitLab
+
+Learn what is [Git](../topics/git/index.md) and its best practices.
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index 0d29b471d52..b42b8f0a525 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -248,7 +248,7 @@ GFM will recognize the following:
| `~123` | label by ID |
| `~bug` | one-word label by name |
| `~"feature request"` | multi-word label by name |
-| `%123` | milestone by ID |
+| `%123` | project milestone by ID |
| `%v1.23` | one-word milestone by name |
| `%"release candidate"` | multi-word milestone by name |
| `9ba12248` | specific commit |
@@ -262,7 +262,7 @@ GFM also recognizes certain cross-project references:
|:----------------------------------------|:------------------------|
| `namespace/project#123` | issue |
| `namespace/project!123` | merge request |
-| `namespace/project%123` | milestone |
+| `namespace/project%123` | project milestone |
| `namespace/project$123` | snippet |
| `namespace/project@9ba12248` | specific commit |
| `namespace/project@9ba12248...b19a04f5` | commit range comparison |
@@ -274,7 +274,7 @@ It also has a shorthand version to reference other projects from the same namesp
|:------------------------------|:------------------------|
| `project#123` | issue |
| `project!123` | merge request |
-| `project%123` | milestone |
+| `project%123` | project milestone |
| `project$123` | snippet |
| `project@9ba12248` | specific commit |
| `project@9ba12248...b19a04f5` | commit range comparison |
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 637967510f3..dcf210e1085 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -5,17 +5,17 @@ particular group or project. If a user is both in a group's project and the
project itself, the highest permission level is used.
On public and internal projects the Guest role is not enforced. All users will
-be able to create issues, leave comments, and pull or download the project code.
+be able to create issues, leave comments, and clone or download the project code.
-When a member leaves the team the all assigned Issues and Merge Requests
+When a member leaves the team all the assigned [Issues](project/issues/index.md) and [Merge Requests](project/merge_requests/index.md)
will be unassigned automatically.
-GitLab administrators receive all permissions.
+GitLab [administrators](../README.md#administrator-documentation) receive all permissions.
-To add or import a user, you can follow the [project users and members
-documentation](../workflow/add-user/add-user.md).
+To add or import a user, you can follow the
+[project members documentation](../user/project/members/index.md).
-## Project
+## Project members permissions
The following table depicts the various user permission levels in a project.
@@ -75,7 +75,58 @@ The following table depicts the various user permission levels in a project.
| Remove protected branches [^3] | | | | | |
| Remove pages | | | | | ✓ |
-## Group
+## Project features permissions
+
+### Wiki and issues
+
+Project features like wiki and issues can be hidden from users depending on
+which visibility level you select on project settings.
+
+- Disabled: disabled for everyone
+- Only team members: only team members will see even if your project is public or internal
+- Everyone with access: everyone can see depending on your project visibility level
+
+### Protected branches
+
+To prevent people from messing with history or pushing code without
+review, we've created protected branches. Read through the documentation on
+[protected branches](project/protected_branches.md)
+to learn more.
+
+Additionally, you can allow or forbid users with Master and/or
+Developer permissions to push to a protected branch. Read through the documentation on
+[Allowed to Merge and Allowed to Push settings](project/protected_branches.md#using-the-allowed-to-merge-and-allowed-to-push-settings)
+to learn more.
+
+### Cycle Analytics permissions
+
+Find the current permissions on the Cycle Analytics dashboard on
+the [documentation on Cycle Analytics permissions](project/cycle_analytics.md#permissions).
+
+### Issue Board permissions
+
+Developers and users with higher permission level can use all
+the functionality of the Issue Board, that is create/delete lists
+and drag issues around. Read though the
+[documentation on Issue Boards permissions](project/issue_board.md#permissions)
+to learn more.
+
+### File Locking permissions (EEP)
+
+The user that locks a file or directory is the only one that can edit and push their changes back to the repository where the locked objects are located.
+
+Read through the documentation on [permissions for File Locking](https://docs.gitlab.com/ee/user/project/file_lock.html#permissions-on-file-locking) to learn more.
+
+File Locking is available in
+[GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) only.
+
+### Confidential Issues permissions
+
+Confidential issues can be accessed by reporters and higher permission levels,
+as well as by guest users that create a confidential issue. To learn more,
+read through the documentation on [permissions and access to confidential issues](project/issues/confidential_issues.md#permissions-and-access-to-confidential-issues).
+
+## Group members permissions
Any user can remove themselves from a group, unless they are the last Owner of
the group. The following table depicts the various user permission levels in a
@@ -89,8 +140,18 @@ group.
| Create project in group | | | | ✓ | ✓ |
| Manage group members | | | | | ✓ |
| Remove group | | | | | ✓ |
+| Manage group labels | | ✓ | ✓ | ✓ | ✓ |
+
+### Subgroup permissions
+
+When you add a member to a subgroup, they inherit the membership and
+permission level from the parent group. This model allows access to
+nested groups if you have membership in one of its parents.
-## External Users
+To learn more, read through the documentation on
+[subgroups memberships](group/subgroups/index.md#membership).
+
+## External users permissions
In cases where it is desired that a user has access only to some internal or
private projects, there is the option of creating **External Users**. This
@@ -114,26 +175,17 @@ will find the option to flag the user as external.
By default new users are not set as external users. This behavior can be changed
by an administrator under **Admin > Application Settings**.
-## Project features
-
-Project features like wiki and issues can be hidden from users depending on
-which visibility level you select on project settings.
-
-- Disabled: disabled for everyone
-- Only team members: only team members will see even if your project is public or internal
-- Everyone with access: everyone can see depending on your project visibility level
-
-## GitLab CI
+## GitLab CI/CD permissions
-GitLab CI permissions rely on the role the user has in GitLab. There are four
-permission levels it total:
+GitLab CI/CD permissions rely on the role the user has in GitLab. There are four
+permission levels in total:
- admin
- master
- developer
- guest/reporter
-The admin user can perform any action on GitLab CI in scope of the GitLab
+The admin user can perform any action on GitLab CI/CD in scope of the GitLab
instance and project. In addition, all admins can use the admin interface under
`/admin/runners`.
@@ -149,7 +201,7 @@ instance and project. In addition, all admins can use the admin interface under
| See events in the system | | | | ✓ |
| Admin interface | | | | ✓ |
-### Jobs permissions
+### Job permissions
>**Note:**
GitLab 8.12 has a completely redesigned job permissions system.
@@ -173,8 +225,28 @@ users:
| Push container images to current project | | ✓ | ✓ | ✓ |
| Push container images to other projects | | | | |
+### New CI job permissions model
+
+GitLab 8.12 has a completely redesigned job permissions system. To learn more,
+read through the documentation on the [new CI/CD permissions model](project/new_ci_build_permissions_model.md#new-ci-job-permissions-model).
+
+## LDAP users permissions
+
+Since GitLab 8.15, LDAP user permissions can now be manually overridden by an admin user.
+Read through the documentation on [LDAP users permissions](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/index.html#updating-user-permissions-new-feature) to learn more.
+
+## Auditor users permissions (EEP)
+
+An Auditor user should be able to access all projects and groups of a GitLab instance
+with the permissions described on the documentation on [auditor users permissions](https://docs.gitlab.com/ee/administration/auditor_users.html#permissions-and-restrictions-of-an-auditor-user).
+
+Auditor users are available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/)
+only.
+
+----
+
[^1]: Guest users can only view the confidential issues they created themselves
-[^2]: If **Public pipelines** is enabled in **Project Settings > CI/CD Pipelines**
+[^2]: If **Public pipelines** is enabled in **Project Settings > Pipelines**
[^3]: Not allowed for Guest, Reporter, Developer, Master, or Owner
[^4]: Only if user is not external one.
[^5]: Only if user is a member of the project.
diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md
index 505248536c8..e7596f5c577 100644
--- a/doc/user/profile/account/delete_account.md
+++ b/doc/user/profile/account/delete_account.md
@@ -1,25 +1,35 @@
# Deleting a User Account
- As a user, you can delete your own account by navigating to **Settings** > **Account** and selecting **Delete account**
-- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Remvoe user**
+- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Remove user**
## Associated Records
-> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467].
+> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award
+ emoji, notes, and abuse reports in [GitLab 9.1][ce-10467].
+ Hard deletion from abuse reports and spam logs was introduced in
+ [GitLab 9.1][ce-10273], and from the API in [GitLab 9.3][ce-11853].
-When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted:
+When a user account is deleted, not all associated records are deleted with it.
+Here's a list of things that will not be deleted:
- Issues that the user created
- Merge requests that the user created
- Notes that the user created
- Abuse reports that the user reported
-- Award emoji that the user craeted
+- Award emoji that the user created
+Instead of being deleted, these records will be moved to a system-wide
+"Ghost User", whose sole purpose is to act as a container for such records.
-Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records.
-
+When a user is deleted from an abuse report or spam log, these associated
+records are not ghosted and will be removed, along with any groups the user
+is a sole owner of. Administrators can also request this behaviour when
+deleting users from the [API](../../../api/users.md#user-deletion) or the
+admin area.
[ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393
+[ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273
[ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467
-
+[ce-11853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11853
diff --git a/doc/user/profile/account/index.md b/doc/user/profile/account/index.md
index 764354e1e96..06667bfc5f1 100644
--- a/doc/user/profile/account/index.md
+++ b/doc/user/profile/account/index.md
@@ -1,5 +1,2 @@
-# Profile settings
-## Account
-
-Set up [two-factor authentication](two_factor_authentication.md).
+This document was moved to [../index.md#profile-settings](../index.md#profile-settings).
diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md
index fb69d934ae1..590c3f862fb 100644
--- a/doc/user/profile/account/two_factor_authentication.md
+++ b/doc/user/profile/account/two_factor_authentication.md
@@ -125,23 +125,14 @@ applications and U2F devices.
## Personal access tokens
When 2FA is enabled, you can no longer use your normal account password to
-authenticate with Git over HTTPS on the command line, you must use a personal
-access token instead.
-
-1. Log in to your GitLab account.
-1. Go to your **Profile Settings**.
-1. Go to **Access Tokens**.
-1. Choose a name and expiry date for the token.
-1. Click on **Create Personal Access Token**.
-1. Save the personal access token somewhere safe.
-
-When using Git over HTTPS on the command line, enter the personal access token
-into the password field.
+authenticate with Git over HTTPS on the command line or when using
+[GitLab's API][api], you must use a [personal access token][pat] instead.
## Recovery options
To disable two-factor authentication on your account (for example, if you
have lost your code generation device) you can:
+
* [Use a saved recovery code](#use-a-saved-recovery-code)
* [Generate new recovery codes using SSH](#generate-new-recovery-codes-using-ssh)
* [Ask a GitLab administrator to disable two-factor authentication on your account](#ask-a-gitlab-administrator-to-disable-two-factor-authentication-on-your-account)
@@ -154,8 +145,9 @@ codes. If you saved these codes, you can use one of them to sign in.
To use a recovery code, enter your username/email and password on the GitLab
sign-in page. When prompted for a two-factor code, enter the recovery code.
-> **Note:** Once you use a recovery code, you cannot re-use it. You can still
- use the other recovery codes you saved.
+>**Note:**
+Once you use a recovery code, you cannot re-use it. You can still use the other
+recovery codes you saved.
### Generate new recovery codes using SSH
@@ -190,11 +182,14 @@ a new set of recovery codes with SSH.
two-factor code. Then, visit your Profile Settings and add a new device
so you do not lose access to your account again.
```
-3. Go to the GitLab sign-in page and enter your username/email and password. When prompted for a two-factor code, enter one of the recovery codes obtained
-from the command-line output.
-> **Note:** After signing in, visit your **Profile Settings -> Account** immediately to set up two-factor authentication with a new
- device.
+3. Go to the GitLab sign-in page and enter your username/email and password.
+ When prompted for a two-factor code, enter one of the recovery codes obtained
+ from the command-line output.
+
+>**Note:**
+After signing in, visit your **Profile settings > Account** immediately to set
+up two-factor authentication with a new device.
### Ask a GitLab administrator to disable two-factor authentication on your account
@@ -206,23 +201,23 @@ Sign in and re-enable two-factor authentication as soon as possible.
## Note to GitLab administrators
- You need to take special care to that 2FA keeps working after
-[restoring a GitLab backup](../../../raketasks/backup_restore.md).
-
+ [restoring a GitLab backup](../../../raketasks/backup_restore.md).
- To ensure 2FA authorizes correctly with TOTP server, you may want to ensure
-your GitLab server's time is synchronized via a service like NTP. Otherwise,
-you may have cases where authorization always fails because of time differences.
-
-[Google Authenticator]: https://support.google.com/accounts/answer/1066447?hl=en
-[FreeOTP]: https://freeotp.github.io/
-[YubiKey]: https://www.yubico.com/products/yubikey-hardware/
-
+ your GitLab server's time is synchronized via a service like NTP. Otherwise,
+ you may have cases where authorization always fails because of time differences.
- The GitLab U2F implementation does _not_ work when the GitLab instance is accessed from
-multiple hostnames, or FQDNs. Each U2F registration is linked to the _current hostname_ at
-the time of registration, and cannot be used for other hostnames/FQDNs.
+ multiple hostnames, or FQDNs. Each U2F registration is linked to the _current hostname_ at
+ the time of registration, and cannot be used for other hostnames/FQDNs.
For example, if a user is trying to access a GitLab instance from `first.host.xyz` and `second.host.xyz`:
- The user logs in via `first.host.xyz` and registers their U2F key.
- The user logs out and attempts to log in via `first.host.xyz` - U2F authentication suceeds.
- - The user logs out and attempts to log in via `second.host.xyz` - U2F authentication fails, because
+ - The user logs out and attempts to log in via `second.host.xyz` - U2F authentication fails, because
the U2F key has only been registered on `first.host.xyz`.
+
+[Google Authenticator]: https://support.google.com/accounts/answer/1066447?hl=en
+[FreeOTP]: https://freeotp.github.io/
+[YubiKey]: https://www.yubico.com/products/yubikey-hardware/
+[api]: ../../../api/README.md
+[pat]: ../personal_access_tokens.md
diff --git a/doc/user/profile/img/personal_access_tokens.png b/doc/user/profile/img/personal_access_tokens.png
new file mode 100644
index 00000000000..6aa63dbe342
--- /dev/null
+++ b/doc/user/profile/img/personal_access_tokens.png
Binary files differ
diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md
new file mode 100644
index 00000000000..7d25970fcb1
--- /dev/null
+++ b/doc/user/profile/index.md
@@ -0,0 +1,47 @@
+# User account
+
+When logged into their GitLab account, users can customize their
+experience according to the best approach to their cases.
+
+## Username
+
+Your `username` is a unique [`namespace`](../group/index.md#namespaces)
+related to your user ID.
+
+You can change your `username` from your
+[profile settings](#profile-settings). To avoid breaking
+paths when you change your `username`, we suggest you follow
+[this procedure from the GitLab Team Handbook](https://about.gitlab.com/handbook/tools-and-tips/#how-to-change-your-username-at-gitlabcom).
+
+## User profile
+
+Your profile is available from the up-right corner menu bar (user's avatar) > **Profile**,
+or from `https://example.gitlab.com/username`.
+
+On your profile page, you will see the following information:
+
+- Personal information
+- Activity stream: see your activity streamline and the history of your contributions
+- Groups: [groups](../group/index.md) you're a member of
+- Contributed projects: [projects](../project/index.md) you contributed to
+- Personal projects: your personal projects (respecting the project's visibility level)
+- Snippets: your personal code [snippets](../snippets.md#personal-snippets)
+
+## Profile settings
+
+You can edit your account settings by navigating from the up-right corner menu bar
+(user's avatar) > **Settings**, or visiting `https://example.gitlab.com/profile`.
+
+From there, you can:
+
+- Update your personal information
+- Manage [private tokens](../../api/README.md#private-tokens), email tokens, [2FA](account/two_factor_authentication.md)
+- Change your username and [delete your account](account/delete_account.md)
+- Manage applications that can
+[use GitLab as an OAuth provider](../../integration/oauth_provider.md#introduction-to-oauth)
+- Manage [personal access tokens](personal_access_tokens.md) to access your account via API and authorized applications
+- Add and delete emails linked to your account
+- Manage [SSH keys](../../ssh/README.md#ssh) to access your account via SSH
+- Manage your [preferences](preferences.md#syntax-highlighting-theme)
+to customize your own GitLab experience
+- Acess your audit log, a security log of important events involving your account
diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md
new file mode 100644
index 00000000000..f28c034e74c
--- /dev/null
+++ b/doc/user/profile/personal_access_tokens.md
@@ -0,0 +1,60 @@
+# Personal access tokens
+
+> [Introduced][ce-3749] in GitLab 8.8.
+
+Personal access tokens are useful if you need access to the [GitLab API][api].
+Instead of using your private token which grants full access to your account,
+personal access tokens could be a better fit because of their
+[granular permissions](#limiting-scopes-of-a-personal-access-token).
+
+You can also use them to authenticate against Git over HTTP. They are the only
+accepted method of authentication when you have
+[Two-Factor Authentication (2FA)][2fa] enabled.
+
+Once you have your token, [pass it to the API][usage] using either the
+`private_token` parameter or the `PRIVATE-TOKEN` header.
+
+The expiration of personal access tokens happens on the date you define,
+at midnight UTC.
+
+## Creating a personal access token
+
+You can create as many personal access tokens as you like from your GitLab
+profile.
+
+1. Log in to your GitLab account.
+1. Go to your **Profile settings**.
+1. Go to **Access tokens**.
+1. Choose a name and optionally an expiry date for the token.
+1. Choose the [desired scopes](#limiting-scopes-of-a-personal-access-token).
+1. Click on **Create personal access token**.
+1. Save the personal access token somewhere safe. Once you leave or refresh
+ the page, you won't be able to access it again.
+
+![Personal access tokens page](img/personal_access_tokens.png)
+
+## Revoking a personal access token
+
+At any time, you can revoke any personal access token by just clicking the
+respective **Revoke** button under the 'Active personal access tokens' area.
+
+## Limiting scopes of a personal access token
+
+Personal access tokens can be created with one or more scopes that allow various
+actions that a given token can perform. The available scopes are depicted in
+the following table.
+
+| Scope | Description |
+| ----- | ----------- |
+|`read_user` | Allows access to the read-only endpoints under `/users`. Essentially, any of the `GET` requests in the [Users API][users] are allowed ([introduced][ce-5951] in GitLab 8.15). |
+| `api` | Grants complete access to the API (read/write) ([introduced][ce-5951] in GitLab 8.15). Required for accessing Git repositories over HTTP when 2FA is enabled. |
+| `read_registry` | Allows to read [container registry] images if a project is private and authorization is required ([introduced][ce-11845] in GitLab 9.3). |
+
+[2fa]: ../account/two_factor_authentication.md
+[api]: ../../api/README.md
+[ce-3749]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749
+[ce-5951]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951
+[ce-11845]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11845
+[container registry]: ../project/container_registry.md
+[users]: ../../api/users.md
+[usage]: ../../api/README.md#basic-usage
diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md
index e5038835027..f2ad42f21fd 100644
--- a/doc/user/profile/preferences.md
+++ b/doc/user/profile/preferences.md
@@ -50,15 +50,15 @@ You have 6 options here that you can use for your default dashboard view:
- Your groups
- Your [Todos]
-### Default project view
+### Project home page content
-The default project view settings allows you to choose what content you want to
-see on a project's landing page.
+The project home page content setting allows you to choose what content you want to
+see on a project’s home page.
You can choose between 2 options:
- Show the files and the readme (default)
-- Show the project's activity
+- Show the project’s activity
[rouge]: http://rouge.jneen.net/ "Rouge website"
[todos]: ../../workflow/todos.md
diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md
index 6a2ca7fb428..629d69d8aea 100644
--- a/doc/user/project/container_registry.md
+++ b/doc/user/project/container_registry.md
@@ -8,8 +8,8 @@
Registry across your GitLab instance, visit the
[administrator documentation](../../administration/container_registry.md).
- Starting from GitLab 8.12, if you have 2FA enabled in your account, you need
- to pass a personal access token instead of your password in order to login to
- GitLab's Container Registry.
+ to pass a [personal access token][pat] instead of your password in order to
+ login to GitLab's Container Registry.
- Multiple level image names support was added in GitLab 9.1
With the Docker Container Registry integrated into GitLab, every project can
@@ -39,6 +39,14 @@ You can read more about Docker Registry at https://docs.docker.com/registry/intr
## Build and push images
+>**Notes:**
+- Moving or renaming existing container registry repositories is not supported
+once you have pushed images because the images are signed, and the
+signature includes the repository name.
+- To move or rename a repository with a container registry you will have to
+delete all existing images.
+
+
If you visit the **Registry** link under your project's menu, you can see the
explicit instructions to login to the Container Registry using your GitLab
credentials.
@@ -95,8 +103,6 @@ and click **Registry** in the project menu.
This view will show you all tags in your project and will easily allow you to
delete them.
-![Container Registry panel](img/container_registry_panel.png)
-
## Build and push images using GitLab CI
> **Note:**
@@ -106,12 +112,13 @@ Make sure that your GitLab Runner is configured to allow building Docker images
following the [Using Docker Build](../../ci/docker/using_docker_build.md)
and [Using the GitLab Container Registry documentation](../../ci/docker/using_docker_build.md#using-the-gitlab-container-registry).
-## Limitations
+## Using with private projects
+
+> [Introduced][ce-11845] in GitLab 9.3.
-In order to use a container image from your private project as an `image:` in
-your `.gitlab-ci.yml`, you have to follow the
-[Using a private Docker Registry][private-docker]
-documentation. This workflow will be simplified in the future.
+If a project is private, credentials will need to be provided for authorization.
+The preferred way to do this, is by using [personal access tokens][pat].
+The minimal scope needed is `read_registry`.
## Troubleshooting the GitLab Container Registry
@@ -256,5 +263,6 @@ The solution: check the [IAM permissions again](https://docs.docker.com/registry
Once the right permissions were set, the error will go away.
[ce-4040]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4040
+[ce-11845]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11845
[docker-docs]: https://docs.docker.com/engine/userguide/intro/
-[private-docker]: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry
+[pat]: ../profile/personal_access_tokens.md
diff --git a/doc/user/project/img/container_registry_panel.png b/doc/user/project/img/container_registry_panel.png
deleted file mode 100644
index e4c9ecbb25b..00000000000
--- a/doc/user/project/img/container_registry_panel.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/issue_board.png b/doc/user/project/img/issue_board.png
index b636cb294b8..cf7f519f783 100644
--- a/doc/user/project/img/issue_board.png
+++ b/doc/user/project/img/issue_board.png
Binary files differ
diff --git a/doc/user/project/img/issue_board_add_list.png b/doc/user/project/img/issue_board_add_list.png
index cdfc466d23f..973d9f7cde4 100644
--- a/doc/user/project/img/issue_board_add_list.png
+++ b/doc/user/project/img/issue_board_add_list.png
Binary files differ
diff --git a/doc/user/project/img/issue_board_move_issue_card_list.png b/doc/user/project/img/issue_board_move_issue_card_list.png
new file mode 100644
index 00000000000..c6b17ada40e
--- /dev/null
+++ b/doc/user/project/img/issue_board_move_issue_card_list.png
Binary files differ
diff --git a/doc/user/project/img/issue_board_welcome_message.png b/doc/user/project/img/issue_board_welcome_message.png
index 5318e6ea4a9..127b9b08cc7 100644
--- a/doc/user/project/img/issue_board_welcome_message.png
+++ b/doc/user/project/img/issue_board_welcome_message.png
Binary files differ
diff --git a/doc/user/project/img/issue_boards_add_issues_modal.png b/doc/user/project/img/issue_boards_add_issues_modal.png
index 33049dce74f..bedaf724a15 100644
--- a/doc/user/project/img/issue_boards_add_issues_modal.png
+++ b/doc/user/project/img/issue_boards_add_issues_modal.png
Binary files differ
diff --git a/doc/user/project/img/project_settings_list.png b/doc/user/project/img/project_settings_list.png
deleted file mode 100644
index 0bb761b45c9..00000000000
--- a/doc/user/project/img/project_settings_list.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/protected_branches_delete.png b/doc/user/project/img/protected_branches_delete.png
new file mode 100644
index 00000000000..cfdfe6c6c29
--- /dev/null
+++ b/doc/user/project/img/protected_branches_delete.png
Binary files differ
diff --git a/doc/user/project/import/bitbucket.md b/doc/user/project/import/bitbucket.md
new file mode 100644
index 00000000000..b22c7db0047
--- /dev/null
+++ b/doc/user/project/import/bitbucket.md
@@ -0,0 +1,62 @@
+# Import your project from Bitbucket to GitLab
+
+Import your projects from Bitbucket to GitLab with minimal effort.
+
+## Overview
+
+>**Note:**
+The [Bitbucket integration][bb-import] must be first enabled in order to be
+able to import your projects from Bitbucket. Ask your GitLab administrator
+to enable this if not already.
+
+- At its current state, the Bitbucket importer can import:
+ - the repository description (GitLab 7.7+)
+ - the Git repository data (GitLab 7.7+)
+ - the issues (GitLab 7.7+)
+ - the issue comments (GitLab 8.15+)
+ - the pull requests (GitLab 8.4+)
+ - the pull request comments (GitLab 8.15+)
+ - the milestones (GitLab 8.15+)
+ - the wiki (GitLab 8.15+)
+- References to pull requests and issues are preserved (GitLab 8.7+)
+- Repository public access is retained. If a repository is private in Bitbucket
+ it will be created as private in GitLab as well.
+
+
+## How it works
+
+When issues/pull requests are being imported, the Bitbucket importer tries to find
+the Bitbucket author/assignee in GitLab's database using the Bitbucket ID. For this
+to work, the Bitbucket author/assignee should have signed in beforehand in GitLab
+and **associated their Bitbucket account**. If the user is not
+found in GitLab's database, the project creator (most of the times the current
+user that started the import process) is set as the author, but a reference on
+the issue about the original Bitbucket author is kept.
+
+The importer will create any new namespaces (groups) if they don't exist or in
+the case the namespace is taken, the repository will be imported under the user's
+namespace that started the import process.
+
+## Importing your Bitbucket repositories
+
+1. Sign in to GitLab and go to your dashboard.
+1. Click on **New project**.
+
+ ![New project in GitLab](img/bitbucket_import_new_project.png)
+
+1. Click on the "Bitbucket" button
+
+ ![Bitbucket](img/import_projects_from_new_project_page.png)
+
+1. Grant GitLab access to your Bitbucket account
+
+ ![Grant access](img/bitbucket_import_grant_access.png)
+
+1. Click on the projects that you'd like to import or **Import all projects**.
+ You can also select the namespace under which each project will be
+ imported.
+
+ ![Import projects](img/bitbucket_import_select_project.png)
+
+[bb-import]: ../../../integration/bitbucket.md
+[social sign-in]: ../../profile/account/social_sign_in.md
diff --git a/doc/user/project/import/fogbugz.md b/doc/user/project/import/fogbugz.md
new file mode 100644
index 00000000000..17222c53675
--- /dev/null
+++ b/doc/user/project/import/fogbugz.md
@@ -0,0 +1,28 @@
+# Import your project from FogBugz to GitLab
+
+It only takes a few simple steps to import your project from FogBugz.
+The importer will import all of your cases and comments with original case
+numbers and timestamps. You will also have the opportunity to map FogBugz
+users to GitLab users.
+
+1. From your GitLab dashboard click 'New project'
+1. Click on the 'FogBugz' button
+
+ ![FogBugz](img/fogbugz_import_select_fogbogz.png)
+
+1. Enter your FogBugz URL, email address, and password.
+
+ ![Login](img/fogbugz_import_login.png)
+
+1. Create mapping from FogBugz users to GitLab users.
+
+ ![User Map](img/fogbugz_import_user_map.png)
+
+1. Select the projects you wish to import by clicking the Import buttons
+
+ ![Import Project](img/fogbugz_import_select_project.png)
+
+1. Once the import has finished click the link to take you to the project
+dashboard. Follow the directions to push your existing repository.
+
+ ![Finished](img/fogbugz_import_finished.png)
diff --git a/doc/user/project/import/gitea.md b/doc/user/project/import/gitea.md
new file mode 100644
index 00000000000..f5746a0fb31
--- /dev/null
+++ b/doc/user/project/import/gitea.md
@@ -0,0 +1,77 @@
+# Import your project from Gitea to GitLab
+
+Import your projects from Gitea to GitLab with minimal effort.
+
+## Overview
+
+>**Note:**
+This requires Gitea `v1.0.0` or newer.
+
+- At its current state, Gitea importer can import:
+ - the repository description (GitLab 8.15+)
+ - the Git repository data (GitLab 8.15+)
+ - the issues (GitLab 8.15+)
+ - the pull requests (GitLab 8.15+)
+ - the milestones (GitLab 8.15+)
+ - the labels (GitLab 8.15+)
+- Repository public access is retained. If a repository is private in Gitea
+ it will be created as private in GitLab as well.
+
+## How it works
+
+Since Gitea is currently not an OAuth provider, author/assignee cannot be mapped
+to users in your GitLab's instance. This means that the project creator (most of
+the times the current user that started the import process) is set as the author,
+but a reference on the issue about the original Gitea author is kept.
+
+The importer will create any new namespaces (groups) if they don't exist or in
+the case the namespace is taken, the repository will be imported under the user's
+namespace that started the import process.
+
+## Importing your Gitea repositories
+
+The importer page is visible when you create a new project.
+
+![New project page on GitLab](img/import_projects_from_new_project_page.png)
+
+Click on the **Gitea** link and the import authorization process will start.
+
+![New Gitea project import](img/import_projects_from_gitea_new_import.png)
+
+### Authorize access to your repositories using a personal access token
+
+With this method, you will perform a one-off authorization with Gitea to grant
+GitLab access your repositories:
+
+1. Go to <https://you-gitea-instance/user/settings/applications> (replace
+ `you-gitea-instance` with the host of your Gitea instance).
+1. Click **Generate New Token**.
+1. Enter a token description.
+1. Click **Generate Token**.
+1. Copy the token hash.
+1. Go back to GitLab and provide the token to the Gitea importer.
+1. Hit the **List Your Gitea Repositories** button and wait while GitLab reads
+ your repositories' information. Once done, you'll be taken to the importer
+ page to select the repositories to import.
+
+### Select which repositories to import
+
+After you've authorized access to your Gitea repositories, you will be
+redirected to the Gitea importer page.
+
+From there, you can see the import statuses of your Gitea repositories.
+
+- Those that are being imported will show a _started_ status,
+- those already successfully imported will be green with a _done_ status,
+- whereas those that are not yet imported will have an **Import** button on the
+ right side of the table.
+
+If you want, you can import all your Gitea projects in one go by hitting
+**Import all projects** in the upper left corner.
+
+![Gitea importer page](img/import_projects_from_github_importer.png)
+
+---
+
+You can also choose a different name for the project and a different namespace,
+if you have the privileges to do so.
diff --git a/doc/user/project/import/github.md b/doc/user/project/import/github.md
new file mode 100644
index 00000000000..016f98966e3
--- /dev/null
+++ b/doc/user/project/import/github.md
@@ -0,0 +1,122 @@
+# Import your project from GitHub to GitLab
+
+Import your projects from GitHub to GitLab with minimal effort.
+
+## Overview
+
+>**Note:**
+If you are an administrator you can enable the [GitHub integration][gh-import]
+in your GitLab instance sitewide. This configuration is optional, users will
+still be able to import their GitHub repositories with a
+[personal access token][gh-token].
+
+>**Note:**
+Administrators of a GitLab instance (Community or Enterprise Edition) can also
+use the [GitHub rake task][gh-rake] to import projects from GitHub without the
+constrains of a Sidekiq worker.
+
+- At its current state, GitHub importer can import:
+ - the repository description (GitLab 7.7+)
+ - the Git repository data (GitLab 7.7+)
+ - the issues (GitLab 7.7+)
+ - the pull requests (GitLab 8.4+)
+ - the wiki pages (GitLab 8.4+)
+ - the milestones (GitLab 8.7+)
+ - the labels (GitLab 8.7+)
+ - the release note descriptions (GitLab 8.12+)
+- References to pull requests and issues are preserved (GitLab 8.7+)
+- Repository public access is retained. If a repository is private in GitHub
+ it will be created as private in GitLab as well.
+
+## How it works
+
+When issues/pull requests are being imported, the GitHub importer tries to find
+the GitHub author/assignee in GitLab's database using the GitHub ID. For this
+to work, the GitHub author/assignee should have signed in beforehand in GitLab
+and **associated their GitHub account**. If the user is not
+found in GitLab's database, the project creator (most of the times the current
+user that started the import process) is set as the author, but a reference on
+the issue about the original GitHub author is kept.
+
+The importer will create any new namespaces (groups) if they don't exist or in
+the case the namespace is taken, the repository will be imported under the user's
+namespace that started the import process.
+
+## Importing your GitHub repositories
+
+The importer page is visible when you create a new project.
+
+![New project page on GitLab](img/import_projects_from_new_project_page.png)
+
+Click on the **GitHub** link and the import authorization process will start.
+There are two ways to authorize access to your GitHub repositories:
+
+1. [Using the GitHub integration][gh-integration] (if it's enabled by your
+ GitLab administrator). This is the preferred way as it's possible to
+ preserve the GitHub authors/assignees. Read more in the [How it works](#how-it-works)
+ section.
+1. [Using a personal access token][gh-token] provided by GitHub.
+
+![Select authentication method](img/import_projects_from_github_select_auth_method.png)
+
+### Authorize access to your repositories using the GitHub integration
+
+If the [GitHub integration][gh-import] is enabled by your GitLab administrator,
+you can use it instead of the personal access token.
+
+1. First you may want to connect your GitHub account to GitLab in order for
+ the username mapping to be correct.
+1. Once you connect GitHub, click the **List your GitHub repositories** button
+ and you will be redirected to GitHub for permission to access your projects.
+1. After accepting, you'll be automatically redirected to the importer.
+
+You can now go on and [select which repositories to import](#select-which-repositories-to-import).
+
+### Authorize access to your repositories using a personal access token
+
+>**Note:**
+For a proper author/assignee mapping for issues and pull requests, the
+[GitHub integration][gh-integration] should be used instead of the
+[personal access token][gh-token]. If the GitHub integration is enabled by your
+GitLab administrator, it should be the preferred method to import your repositories.
+Read more in the [How it works](#how-it-works) section.
+
+If you are not using the GitHub integration, you can still perform a one-off
+authorization with GitHub to grant GitLab access your repositories:
+
+1. Go to <https://github.com/settings/tokens/new>.
+1. Enter a token description.
+1. Check the `repo` scope.
+1. Click **Generate token**.
+1. Copy the token hash.
+1. Go back to GitLab and provide the token to the GitHub importer.
+1. Hit the **List Your GitHub Repositories** button and wait while GitLab reads
+ your repositories' information. Once done, you'll be taken to the importer
+ page to select the repositories to import.
+
+### Select which repositories to import
+
+After you've authorized access to your GitHub repositories, you will be
+redirected to the GitHub importer page.
+
+From there, you can see the import statuses of your GitHub repositories.
+
+- Those that are being imported will show a _started_ status,
+- those already successfully imported will be green with a _done_ status,
+- whereas those that are not yet imported will have an **Import** button on the
+ right side of the table.
+
+If you want, you can import all your GitHub projects in one go by hitting
+**Import all projects** in the upper left corner.
+
+![GitHub importer page](img/import_projects_from_github_importer.png)
+
+---
+
+You can also choose a different name for the project and a different namespace,
+if you have the privileges to do so.
+
+[gh-import]: ../../../integration/github.md "GitHub integration"
+[gh-rake]: ../../../administration/raketasks/github_import.md "GitHub rake task"
+[gh-integration]: #authorize-access-to-your-repositories-using-the-github-integration
+[gh-token]: #authorize-access-to-your-repositories-using-a-personal-access-token
diff --git a/doc/user/project/import/gitlab_com.md b/doc/user/project/import/gitlab_com.md
new file mode 100644
index 00000000000..3b37da67a5b
--- /dev/null
+++ b/doc/user/project/import/gitlab_com.md
@@ -0,0 +1,20 @@
+# Project importing from GitLab.com to your private GitLab instance
+
+You can import your existing GitLab.com projects to your GitLab instance. But keep in mind that it is possible only if
+GitLab support is enabled on your GitLab instance.
+You can read more about GitLab support [here](http://docs.gitlab.com/ce/integration/gitlab.html)
+To get to the importer page you need to go to "New project" page.
+
+>**Note:**
+If you are interested in importing Wiki and Merge Request data to your new
+instance, you'll need to follow the instructions for [project export](../settings/import_export.md)
+
+![New project page](img/gitlab_new_project_page.png)
+
+Click on the "Import projects from GitLab.com" link and you will be redirected to GitLab.com
+for permission to access your projects. After accepting, you'll be automatically redirected to the importer.
+
+![Importer page](img/gitlab_importer.png)
+
+To import a project, you can simple click "Import". The importer will import your repository and issues.
+Once the importer is done, a new GitLab project will be created with your imported data.
diff --git a/doc/workflow/importing/img/bitbucket_import_grant_access.png b/doc/user/project/import/img/bitbucket_import_grant_access.png
index 429904e621d..429904e621d 100644
--- a/doc/workflow/importing/img/bitbucket_import_grant_access.png
+++ b/doc/user/project/import/img/bitbucket_import_grant_access.png
Binary files differ
diff --git a/doc/workflow/importing/img/bitbucket_import_new_project.png b/doc/user/project/import/img/bitbucket_import_new_project.png
index 8ed528c2f09..8ed528c2f09 100644
--- a/doc/workflow/importing/img/bitbucket_import_new_project.png
+++ b/doc/user/project/import/img/bitbucket_import_new_project.png
Binary files differ
diff --git a/doc/workflow/importing/img/bitbucket_import_select_project.png b/doc/user/project/import/img/bitbucket_import_select_project.png
index 1bca6166ec8..1bca6166ec8 100644
--- a/doc/workflow/importing/img/bitbucket_import_select_project.png
+++ b/doc/user/project/import/img/bitbucket_import_select_project.png
Binary files differ
diff --git a/doc/workflow/importing/fogbugz_importer/fogbugz_import_finished.png b/doc/user/project/import/img/fogbugz_import_finished.png
index 62c5c86c9b3..62c5c86c9b3 100644
--- a/doc/workflow/importing/fogbugz_importer/fogbugz_import_finished.png
+++ b/doc/user/project/import/img/fogbugz_import_finished.png
Binary files differ
diff --git a/doc/workflow/importing/fogbugz_importer/fogbugz_import_login.png b/doc/user/project/import/img/fogbugz_import_login.png
index 96bce70b74d..96bce70b74d 100644
--- a/doc/workflow/importing/fogbugz_importer/fogbugz_import_login.png
+++ b/doc/user/project/import/img/fogbugz_import_login.png
Binary files differ
diff --git a/doc/workflow/importing/fogbugz_importer/fogbugz_import_select_fogbogz.png b/doc/user/project/import/img/fogbugz_import_select_fogbogz.png
index b26c652e382..b26c652e382 100644
--- a/doc/workflow/importing/fogbugz_importer/fogbugz_import_select_fogbogz.png
+++ b/doc/user/project/import/img/fogbugz_import_select_fogbogz.png
Binary files differ
diff --git a/doc/workflow/importing/fogbugz_importer/fogbugz_import_select_project.png b/doc/user/project/import/img/fogbugz_import_select_project.png
index ccc82f9d4cd..ccc82f9d4cd 100644
--- a/doc/workflow/importing/fogbugz_importer/fogbugz_import_select_project.png
+++ b/doc/user/project/import/img/fogbugz_import_select_project.png
Binary files differ
diff --git a/doc/workflow/importing/fogbugz_importer/fogbugz_import_user_map.png b/doc/user/project/import/img/fogbugz_import_user_map.png
index 28ff55a8d89..28ff55a8d89 100644
--- a/doc/workflow/importing/fogbugz_importer/fogbugz_import_user_map.png
+++ b/doc/user/project/import/img/fogbugz_import_user_map.png
Binary files differ
diff --git a/doc/workflow/importing/gitlab_importer/importer.png b/doc/user/project/import/img/gitlab_importer.png
index 27d42eb492e..27d42eb492e 100644
--- a/doc/workflow/importing/gitlab_importer/importer.png
+++ b/doc/user/project/import/img/gitlab_importer.png
Binary files differ
diff --git a/doc/workflow/importing/gitlab_importer/new_project_page.png b/doc/user/project/import/img/gitlab_new_project_page.png
index c673724f436..c673724f436 100644
--- a/doc/workflow/importing/gitlab_importer/new_project_page.png
+++ b/doc/user/project/import/img/gitlab_new_project_page.png
Binary files differ
diff --git a/doc/workflow/importing/img/import_projects_from_gitea_new_import.png b/doc/user/project/import/img/import_projects_from_gitea_new_import.png
index a3f603cbd0a..a3f603cbd0a 100644
--- a/doc/workflow/importing/img/import_projects_from_gitea_new_import.png
+++ b/doc/user/project/import/img/import_projects_from_gitea_new_import.png
Binary files differ
diff --git a/doc/workflow/importing/img/import_projects_from_github_importer.png b/doc/user/project/import/img/import_projects_from_github_importer.png
index d8effaf6075..d8effaf6075 100644
--- a/doc/workflow/importing/img/import_projects_from_github_importer.png
+++ b/doc/user/project/import/img/import_projects_from_github_importer.png
Binary files differ
diff --git a/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png b/doc/user/project/import/img/import_projects_from_github_select_auth_method.png
index 1ccb38a815e..1ccb38a815e 100644
--- a/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png
+++ b/doc/user/project/import/img/import_projects_from_github_select_auth_method.png
Binary files differ
diff --git a/doc/workflow/importing/img/import_projects_from_new_project_page.png b/doc/user/project/import/img/import_projects_from_new_project_page.png
index 97ca30b2087..97ca30b2087 100644
--- a/doc/workflow/importing/img/import_projects_from_new_project_page.png
+++ b/doc/user/project/import/img/import_projects_from_new_project_page.png
Binary files differ
diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md
new file mode 100644
index 00000000000..06e8c81ef06
--- /dev/null
+++ b/doc/user/project/import/index.md
@@ -0,0 +1,20 @@
+# Migrating projects to a GitLab instance
+
+1. [From Bitbucket.org](bitbucket.md)
+1. [From GitHub.com of GitHub Enterprise](github.md)
+1. [From GitLab.com](gitlab_com.md)
+1. [From FogBugz](fogbugz.md)
+1. [From Gitea](gitea.md)
+1. [From SVN](svn.md)
+
+In addition to the specific migration documentation above, you can import any
+Git repository via HTTP from the New Project page. Be aware that if the
+repository is too large the import can timeout.
+
+## Migrating from self-hosted GitLab to GitLab.com
+
+You can copy your repos by changing the remote and pushing to the new server,
+but issues and merge requests can't be imported.
+
+If you want to retain all metadata like issues and merge requests, you can use
+the [import/export feature](../settings/import_export.md).
diff --git a/doc/user/project/import/svn.md b/doc/user/project/import/svn.md
new file mode 100644
index 00000000000..7a3628a39d7
--- /dev/null
+++ b/doc/user/project/import/svn.md
@@ -0,0 +1,183 @@
+# Migrating from SVN to GitLab
+
+Subversion (SVN) is a central version control system (VCS) while
+Git is a distributed version control system. There are some major differences
+between the two, for more information consult your favorite search engine.
+
+## Overview
+
+There are two approaches to SVN to Git migration:
+
+1. [Git/SVN Mirror](#smooth-migration-with-a-gitsvn-mirror-using-subgit) which:
+ - Makes the GitLab repository to mirror the SVN project.
+ - Git and SVN repositories are kept in sync; you can use either one.
+ - Smoothens the migration process and allows to manage migration risks.
+
+1. [Cut over migration](#cut-over-migration-with-svn2git) which:
+ - Translates and imports the existing data and history from SVN to Git.
+ - Is a fire and forget approach, good for smaller teams.
+
+## Smooth migration with a Git/SVN mirror using SubGit
+
+[SubGit](https://subgit.com) is a tool for a smooth, stress-free SVN to Git
+migration. It creates a writable Git mirror of a local or remote Subversion
+repository and that way you can use both Subversion and Git as long as you like.
+It requires access to your GitLab server as it talks with the Git repositories
+directly in a filesystem level.
+
+### SubGit prerequisites
+
+1. Install Oracle JRE 1.8 or newer. On Debian-based Linux distributions you can
+ follow [this article](http://www.webupd8.org/2012/09/install-oracle-java-8-in-ubuntu-via-ppa.html).
+1. Download SubGit from https://subgit.com/download/.
+1. Unpack the downloaded SubGit zip archive to the `/opt` directory. The `subgit`
+ command will be available at `/opt/subgit-VERSION/bin/subgit`.
+
+### SubGit configuration
+
+The first step to mirror you SVN repository in GitLab is to create a new empty
+project which will be used as a mirror. For Omnibus installations the path to
+the repository will be located at
+`/var/opt/gitlab/git-data/repositories/USER/REPO.git` by default. For
+installations from source, the default repository directory will be
+`/home/git/repositories/USER/REPO.git`. For convenience, assign this path to a
+variable:
+
+```
+GIT_REPO_PATH=/var/opt/gitlab/git-data/repositories/USER/REPOS.git
+```
+
+SubGit will keep this repository in sync with a remote SVN project. For
+convenience, assign your remote SVN project URL to a variable:
+
+```
+SVN_PROJECT_URL=http://svn.company.com/repos/project
+```
+
+Next you need to run SubGit to set up a Git/SVN mirror. Make sure the following
+`subgit` command is ran on behalf of the same user that keeps ownership of
+GitLab Git repositories (by default `git`):
+
+```
+subgit configure --layout auto $SVN_PROJECT_URL $GIT_REPO_PATH
+```
+
+Adjust authors and branches mappings, if necessary. Open with your favorite
+text editor:
+
+```
+edit $GIT_REPO_PATH/subgit/authors.txt
+edit $GIT_REPO_PATH/subgit/config
+```
+
+For more information regarding the SubGit configuration options, refer to
+[SubGit's documentation](https://subgit.com/documentation.html) website.
+
+### Initial translation
+
+Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the
+initial translation of existing SVN revisions into the Git repository:
+
+```
+subgit install $GIT_REPO_PATH
+```
+
+After the initial translation is completed, the Git repository and the SVN
+project will be kept in sync by `subgit` - new Git commits will be translated to
+SVN revisions and new SVN revisions will be translated to Git commits. Mirror
+works transparently and does not require any special commands.
+
+If you would prefer to perform one-time cut over migration with `subgit`, use
+the `import` command instead of `install`:
+
+```
+subgit import $GIT_REPO_PATH
+```
+
+### SubGit licensing
+
+Running SubGit in a mirror mode requires a
+[registration](https://subgit.com/pricing.html). Registration is free for open
+source, academic and startup projects.
+
+We're currently working on deeper GitLab/SubGit integration. You may track our
+progress at [this issue](https://gitlab.com/gitlab-org/gitlab-ee/issues/990).
+
+### SubGit support
+
+For any questions related to SVN to GitLab migration with SubGit, you can
+contact the SubGit team directly at [support@subgit.com](mailto:support@subgit.com).
+
+## Cut over migration with svn2git
+
+If you are currently using an SVN repository, you can migrate the repository
+to Git and GitLab. We recommend a hard cut over - run the migration command once
+and then have all developers start using the new GitLab repository immediately.
+Otherwise, it's hard to keep changing in sync in both directions. The conversion
+process should be run on a local workstation.
+
+Install `svn2git`. On all systems you can install as a Ruby gem if you already
+have Ruby and Git installed.
+
+```bash
+sudo gem install svn2git
+```
+
+On Debian-based Linux distributions you can install the native packages:
+
+```bash
+sudo apt-get install git-core git-svn ruby
+```
+
+Optionally, prepare an authors file so `svn2git` can map SVN authors to Git authors.
+If you choose not to create the authors file then commits will not be attributed
+to the correct GitLab user. Some users may not consider this a big issue while
+others will want to ensure they complete this step. If you choose to map authors
+you will be required to map every author that is present on changes in the SVN
+repository. If you don't, the conversion will fail and you will have to update
+the author file accordingly. The following command will search through the
+repository and output a list of authors.
+
+```bash
+svn log --quiet | grep -E "r[0-9]+ \| .+ \|" | cut -d'|' -f2 | sed 's/ //g' | sort | uniq
+```
+
+Use the output from the last command to construct the authors file.
+Create a file called `authors.txt` and add one mapping per line.
+
+```
+janedoe = Jane Doe <janedoe@example.com>
+johndoe = John Doe <johndoe@example.com>
+```
+
+If your SVN repository is in the standard format (trunk, branches, tags,
+not nested) the conversion is simple. For a non-standard repository see
+[svn2git documentation](https://github.com/nirvdrum/svn2git). The following
+command will checkout the repository and do the conversion in the current
+working directory. Be sure to create a new directory for each repository before
+running the `svn2git` command. The conversion process will take some time.
+
+```bash
+svn2git https://svn.example.com/path/to/repo --authors /path/to/authors.txt
+```
+
+If your SVN repository requires a username and password add the
+`--username <username>` and `--password <password` flags to the above command.
+`svn2git` also supports excluding certain file paths, branches, tags, etc. See
+[svn2git documentation](https://github.com/nirvdrum/svn2git) or run
+`svn2git --help` for full documentation on all of the available options.
+
+Create a new GitLab project, where you will eventually push your converted code.
+Copy the SSH or HTTP(S) repository URL from the project page. Add the GitLab
+repository as a Git remote and push all the changes. This will push all commits,
+branches and tags.
+
+```bash
+git remote add origin git@gitlab.com:<group>/<project>.git
+git push --all origin
+git push --tags origin
+```
+
+## Contribute to this guide
+We welcome all contributions that would expand this guide with instructions on
+how to migrate from SVN and other version control systems.
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
new file mode 100644
index 00000000000..ba6ac2797b3
--- /dev/null
+++ b/doc/user/project/index.md
@@ -0,0 +1,111 @@
+# Projects
+
+In GitLab, you can create projects for hosting
+your codebase, use it as an issue tracker, collaborate on code, and continuously
+build, test, and deploy your app with built-in GitLab CI/CD.
+
+Your projects can be [available](../../public_access/public_access.md)
+publicly, internally, or privately, at your choice. GitLab does not limit
+the number of private projects you create.
+
+## Project's features
+
+When you create a project in GitLab, you'll have access to a large number of
+[features](https://about.gitlab.com/features/):
+
+**Issues and merge requests:**
+
+- [Issue tracker](issues/index.md): Discuss implementations with your team within issues
+ - [Issue Boards](issue_board.md): Organize and prioritize your workflow
+ - [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards) (**EES/EEP**): Allow your teams to create their own workflows (Issue Boards) for the same project
+- [Repositories](repository/index.md): Host your code in a fully
+integrated platform
+ - [Protected branches](protected_branches.md): Prevent collaborators
+ from messing with history or pushing code without review
+ - [Protected tags](protected_tags.md): Control over who has
+ permission to create tags, and prevent accidental update or deletion
+- [Merge Requests](merge_requests/index.md): Apply your branching
+strategy and get reviewed by your team
+ - [Merge Request Approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) (**EES/EEP**): Ask for approval before
+ implementing a change
+ - [Fix merge conflicts from the UI](merge_requests/resolve_conflicts.md):
+ Your Git diff tool right from GitLab's UI
+ - [Review Apps](../../ci/review_apps/index.md): Live preview the results
+ of the changes proposed in a merge request in a per-branch basis
+- [Labels](labels.md): Organize issues and merge requests by labels
+- [Time Tracking](../../workflow/time_tracking.md): Track estimate time
+and time spent on
+ the conclusion of an issue or merge request
+- [Milestones](milestones/index.md): Work towards a target date
+- [Description templates](description_templates.md): Define context-specific
+templates for issue and merge request description fields for your project
+- [Slash commands (quick actions)](quick_actions.md): Textual shortcuts for
+common actions on issues or merge requests
+
+**GitLab CI/CD:**
+
+- [GitLab CI/CD](../../ci/README.md): GitLab's built-in [Continuous Integration, Delivery, and Deployment](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/) tool
+ - [Container Registry](container_registry.md): Build and push Docker
+ images out-of-the-box
+ - [Auto Deploy](../../ci/autodeploy/index.md): Configure GitLab CI/CD
+ to automatically set up your app's deployment
+ - [Enable and disable GitLab CI](../../ci/enable_or_disable_ci.md)
+ - [Pipelines](../../ci/pipelines.md#pipelines): Configure and visualize
+ your GitLab CI/CD pipelines from the UI
+ - [Scheduled Pipelines](pipelines/schedules.md): Schedule a pipeline
+ to start at a chosen time
+ - [Pipeline Graphs](../../ci/pipelines.md#pipeline-graphs): View your
+ entire pipeline from the UI
+ - [Job artifacts](pipelines/job_artifacts.md): Define,
+ browse, and download job artifacts
+ - [Pipeline settings](pipelines/settings.md): Set up Git strategy (choose the default way your repository is fetched from GitLab in a job),
+ timeout (defines the maximum amount of time in minutes that a job is able run), custom path for `.gitlab-ci.yml`, test coverage parsing, pipeline's visibility, and much more
+- [GitLab Pages](pages/index.md): Build, test, and deploy your static
+website with GitLab Pages
+
+**Other features:**
+
+- [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
+- [Koding integration](koding.md) (not available on GitLab.com): Integrate
+with Koding to have access to a web terminal right from the GitLab UI
+- [Syntax highlighting](highlighting.md): An alternative to customize
+your code blocks, overriding GitLab's default choice of language
+
+### Project's integrations
+
+[Integrate your project](integrations/index.md) with Jira, Mattermost,
+Kubernetes, Slack, and a lot more.
+
+## New project
+
+Learn how to [create a new project](../../gitlab-basics/create-project.md) in GitLab.
+
+### Fork a project
+
+You can [fork a project](../../gitlab-basics/fork-project.md) in order to:
+
+- Collaborate on code by forking a project and creating a merge request
+from your fork to the upstream project
+- Fork a sample project to work on the top of that
+
+## Import or export a project
+
+- [Import a project](import/index.md) from:
+ - [GitHub to GitLab](import/github.md)
+ - [BitBucket to GitLab](import/bitbucket.md)
+ - [Gitea to GitLab](import/gitea.md)
+ - [FogBugz to GitLab](import/fogbugz.md)
+- [Export a project from GitLab](settings/import_export.md#exporting-a-project-and-its-data)
+- [Importing and exporting projects between GitLab instances](settings/import_export.md)
+
+## Project's members
+
+Learn how to [add members to your projects](members/index.md).
+
+### Leave a project
+
+**Leave project** will only display on the project's dashboard
+when a project is part of a group (under a
+[group namespace](../group/index.md#namespaces)).
+If you choose to leave a project you will no longer be a project
+member, therefore, unable to contribute.
diff --git a/doc/user/project/integrations/bugzilla.md b/doc/user/project/integrations/bugzilla.md
index 0b219e84478..ba2adc1afda 100644
--- a/doc/user/project/integrations/bugzilla.md
+++ b/doc/user/project/integrations/bugzilla.md
@@ -16,3 +16,16 @@ Once you have configured and enabled Bugzilla:
- the **Issues** link on the GitLab project pages takes you to the appropriate
Bugzilla product page
- clicking **New issue** on the project dashboard takes you to Bugzilla for entering a new issue
+
+## Referencing issues in Bugzilla
+
+Issues in Bugzilla can be referenced in two alternative ways:
+1. `#<ID>` where `<ID>` is a number (example `#143`).
+2. `<PROJECT>-<ID>` where `<PROJECT>` starts with a capital letter which is
+ then followed by capital letters, numbers or underscores, and `<ID>` is
+ a number (example `API_32-143`).
+
+We suggest using the longer format if you have both internal and external issue trackers enabled. If you use the shorter format and an issue with the same ID exists in the internal issue tracker the internal issue will be linked.
+
+Please note that `<PROJECT>` part is ignored and links always point to the
+address specified in `issues_url`.
diff --git a/doc/user/project/integrations/img/accessing_integrations.png b/doc/user/project/integrations/img/accessing_integrations.png
deleted file mode 100644
index 3b941f64998..00000000000
--- a/doc/user/project/integrations/img/accessing_integrations.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/img/jira_service_page.png b/doc/user/project/integrations/img/jira_service_page.png
index c74351b57b8..63aa0e99a50 100644
--- a/doc/user/project/integrations/img/jira_service_page.png
+++ b/doc/user/project/integrations/img/jira_service_page.png
Binary files differ
diff --git a/doc/user/project/integrations/img/merge_request_performance.png b/doc/user/project/integrations/img/merge_request_performance.png
new file mode 100644
index 00000000000..eba6515a6ae
--- /dev/null
+++ b/doc/user/project/integrations/img/merge_request_performance.png
Binary files differ
diff --git a/doc/user/project/integrations/img/webhook_logs.png b/doc/user/project/integrations/img/webhook_logs.png
new file mode 100755
index 00000000000..917068d9398
--- /dev/null
+++ b/doc/user/project/integrations/img/webhook_logs.png
Binary files differ
diff --git a/doc/user/project/integrations/img/webhook_testing.png b/doc/user/project/integrations/img/webhook_testing.png
new file mode 100644
index 00000000000..176dcec9d8a
--- /dev/null
+++ b/doc/user/project/integrations/img/webhook_testing.png
Binary files differ
diff --git a/doc/user/project/integrations/index.md b/doc/user/project/integrations/index.md
index 99093ebaed5..e384ed57de9 100644
--- a/doc/user/project/integrations/index.md
+++ b/doc/user/project/integrations/index.md
@@ -1,10 +1,8 @@
# Project integrations
-You can find the available integrations under the **Integrations** page by
-navigating to the cog icon in the upper right corner of your project. You need
-to have at least [master permission][permissions] on the project.
-
-![Accessing the integrations](img/accessing_integrations.png)
+You can find the available integrations under your project's
+**Settings âž” Integrations** page. You need to have at least
+[master permission][permissions] on the project.
## Project services
diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md
index f611029afdc..93aec56f8dc 100644
--- a/doc/user/project/integrations/jira.md
+++ b/doc/user/project/integrations/jira.md
@@ -1,18 +1,27 @@
# GitLab JIRA integration
-GitLab can be configured to interact with JIRA. Configuration happens via
-user name and password. Connecting to a JIRA server via CAS is not possible.
+GitLab can be configured to interact with [JIRA], a project management platform.
-Each project can be configured to connect to a different JIRA instance, see the
-[configuration](#configuration) section. If you have one JIRA instance you can
-pre-fill the settings page with a default template. To configure the template
-see the [Services Templates][services-templates] document.
+Once your GitLab project is connected to JIRA, you can reference and close the
+issues in JIRA directly from GitLab.
-Once the project is connected to JIRA, you can reference and close the issues
-in JIRA directly from GitLab.
+For a use case, check out this article of [How and why to integrate GitLab with
+JIRA](https://www.programmableweb.com/news/how-and-why-to-integrate-gitlab-jira/how-to/2017/04/25).
## Configuration
+Each GitLab project can be configured to connect to a different JIRA instance. That
+means one GitLab project maps to _all_ JIRA projects in that JIRA instance once
+the configuration is set up. Therefore, you don't have to explicitly associate
+one GitLab project to any JIRA project. Once the configuration is set up, any JIRA
+projects in the JIRA instance are already mapped to the GitLab project.
+
+If you have one JIRA instance you can pre-fill the settings page with a default
+template, see the [Services Templates][services-templates] docs.
+
+Configuration happens via user name and password. Connecting to a JIRA server
+via CAS is not possible.
+
In order to enable the JIRA service in GitLab, you need to first configure the
project in JIRA and then enter the correct values in GitLab.
@@ -76,7 +85,7 @@ We have split this stage in steps so it is easier to follow.
![JIRA add user to group](img/jira_add_user_to_group.png)
----
+ ---
The JIRA configuration is over. Write down the new JIRA username and its
password as they will be needed when configuring GitLab in the next section.
@@ -97,14 +106,14 @@ in the table below.
| Field | Description |
| ----- | ----------- |
-| `URL` | The base URL to the JIRA project which is being linked to this GitLab project. E.g., `https://jira.example.com`. |
-| `Project key` | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. |
+| `Web URL` | The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., `https://jira.example.com`. |
+| `JIRA API URL` | The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., `https://jira-api.example.com`. |
| `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)). **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** |
+| `Transition ID` | 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.
+with all JIRA projects in your JIRA instance.
![JIRA service page](img/jira_service_page.png)
@@ -212,3 +221,4 @@ your project needs to close a ticket.
[services-templates]: services_templates.md
[jira-repo-old-docs]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-13-stable/doc/project_services/jira.md
+[jira]: https://www.atlassian.com/software/jira
diff --git a/doc/user/project/integrations/kubernetes.md b/doc/user/project/integrations/kubernetes.md
index 73fa83d72a8..f4000523938 100644
--- a/doc/user/project/integrations/kubernetes.md
+++ b/doc/user/project/integrations/kubernetes.md
@@ -19,10 +19,10 @@ of your project and select the **Kubernetes** service to configure it.
The Kubernetes service takes the following arguments:
-1. Kubernetes namespace
1. API URL
-1. Service token
1. Custom CA bundle
+1. Kubernetes namespace
+1. Service token
The API URL is the URL that GitLab uses to access the Kubernetes API. Kubernetes
exposes several APIs - we want the "base" URL that is common to all of them,
@@ -55,6 +55,7 @@ GitLab CI build environment:
- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path
to a file containing PEM data.
- `KUBE_CA_PEM` (deprecated)- only if a custom CA bundle was specified. Raw PEM data.
+- `KUBECONFIG` - Path to a file containing kubeconfig for this deployment. CA bundle would be embedded if specified.
## Web terminals
diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md
index 96c91093d7d..51989ccaaea 100644
--- a/doc/user/project/integrations/project_services.md
+++ b/doc/user/project/integrations/project_services.md
@@ -6,18 +6,13 @@ functionality to GitLab.
## Accessing the project services
-You can find the available services under the **Integrations** page in your
-project's settings.
+You can find the available services under your project's
+**Settings âž” Integrations** page.
-1. Navigate to the cog icon in the upper right corner of your project. You need
- to have at least [master permission][permissions] on the project.
+There are more than 20 services to integrate with. Click on the one that you
+want to configure.
- ![Accessing the services](img/accessing_integrations.png)
-
-1. There are more than 20 services to integrate with. Click on the one that you
- want to configure.
-
- ![Project services list](img/project_services.png)
+ ![Project services list](img/project_services.png)
Below, you will find a list of the currently supported ones accompanied with
comprehensive documentation.
@@ -49,8 +44,8 @@ Click on the service links to see further configuration instructions and details
| [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost |
| [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors |
| Pipelines emails | Email the pipeline status to a list of recipients |
-| [Slack Notifications](slack.md) | Receive event notifications in Slack |
-| [Slack slash commands](slack_slash_commands.md) | Slack chat and ChatOps slash commands |
+| [Slack Notifications](slack.md) | Send GitLab events (e.g. issue created) to Slack as notifications |
+| [Slack slash commands](slack_slash_commands.md) | Use slash commands in Slack to control GitLab |
| PivotalTracker | Project Management Software (Source Commits Endpoint) |
| [Prometheus](prometheus.md) | Monitor the performance of your deployed apps |
| Pushover | Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop |
diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md
index a74014b6b2f..5fefb3b69c4 100644
--- a/doc/user/project/integrations/prometheus.md
+++ b/doc/user/project/integrations/prometheus.md
@@ -17,34 +17,30 @@ the settings page with a default template. To configure the template, see the
Integration with Prometheus requires the following:
1. GitLab 9.0 or higher
-1. Your app must be deployed on [Kubernetes][]
-1. Prometheus must be configured to collect Kubernetes metrics
+1. Prometheus must be configured to collect one of the [supported metrics](prometheus_library/metrics.md)
1. Each metric must be have a label to indicate the environment
-1. GitLab must have network connectivity to the Prometheus sever
+1. GitLab must have network connectivity to the Prometheus server
-There are a few steps necessary to set up integration between Prometheus and
-GitLab.
+## Getting started with Prometheus monitoring
-## Configuring Prometheus to collect Kubernetes metrics
+Depending on your deployment and where you have located your GitLab server, there are a few options to get started with Prometheus monitoring.
-In order for Prometheus to collect Kubernetes metrics, you first must have a
-Prometheus server up and running. You have two options here:
+* If both GitLab and your applications are installed in the same Kubernetes cluster, you can leverage the [bundled Prometheus server within GitLab](#configuring-omnibus-gitlab-prometheus-to-monitor-kubernetes).
+* If your applications are deployed on Kubernetes, but GitLab is not in the same cluster, then you can [configure a Prometheus server in your Kubernetes cluster](#configuring-your-own-prometheus-server-within-kubernetes).
+* If your applications are not running in Kubernetes, [get started with Prometheus](#getting-started-with-prometheus-outside-of-kubernetes).
-- If you installed Omnibus GitLab inside of Kubernetes, you can simply use the
- [bundled version of Prometheus][promgldocs]. In that case, follow the info in the
- [Omnibus GitLab section](#configuring-omnibus-gitlab-prometheus-to-monitor-kubernetes)
- below.
-- If you are using GitLab.com or installed GitLab outside of Kubernetes, you
- will likely need to run a Prometheus server within the Kubernetes cluster.
- Once installed, the easiest way to monitor Kubernetes is to simply use
- Prometheus' support for [Kubernetes Service Discovery][prometheus-k8s-sd].
- In that case, follow the instructions on
- [configuring your own Prometheus server within Kubernetes](#configuring-your-own-prometheus-server-within-kubernetes).
+### Getting started with Prometheus outside of Kubernetes
-### Configuring Omnibus GitLab Prometheus to monitor Kubernetes
+Installing and configuring Prometheus to monitor applications is fairly straight forward.
+
+1. [Install Prometheus](https://prometheus.io/docs/introduction/install/)
+1. Set up one of the [supported monitoring targets](prometheus_library/metrics.md)
+1. Configure the Prometheus server to [collect their metrics](https://prometheus.io/docs/operating/configuration/#scrape_config)
+
+### Configuring Omnibus GitLab Prometheus to monitor Kubernetes deployments
With Omnibus GitLab running inside of Kubernetes, you can leverage the bundled
-version of Prometheus to collect the required metrics.
+version of Prometheus to collect the supported metrics. Once enabled, Prometheus will automatically begin monitoring Kubernetes Nodes and any [annotated Pods](https://prometheus.io/docs/operating/configuration/#<kubernetes_sd_config>).
1. Read how to configure the bundled Prometheus server in the
[Administration guide][gitlab-prometheus-k8s-monitor].
@@ -73,7 +69,7 @@ kubectl apply -f path/to/prometheus.yml
Once deployed, you should see the Prometheus service, deployment, and
pod start within the `prometheus` namespace. The server will begin to collect
metrics from each Kubernetes Node in the cluster, based on the configuration
-provided in the template.
+provided in the template. It will also attempt to collect metrics from any Kubernetes Pods that have been [annotated for Prometheus](https://prometheus.io/docs/operating/configuration/#pod).
Since GitLab is not running within Kubernetes, the template provides external
network access via a `NodePort` running on `30090`. This method allows access
@@ -132,42 +128,34 @@ to integrate with.
![Configure Prometheus Service](img/prometheus_service_configuration.png)
-## Metrics and Labels
-
-GitLab retrieves performance data from two metrics, `container_cpu_usage_seconds_total`
-and `container_memory_usage_bytes`. These metrics are collected from the
-Kubernetes pods via Prometheus, and report CPU and Memory utilization of each
-container or Pod running in the cluster.
+## Monitoring CI/CD Environments
-In order to isolate and only display relevant metrics for a given environment
-however, GitLab needs a method to detect which pods are associated. To do that,
-GitLab will specifically request metrics that have an `environment` tag that
-matches the [$CI_ENVIRONMENT_SLUG][ci-environment-slug].
+Once configured, GitLab will attempt to retrieve performance metrics for any
+environment which has had a successful deployment.
-If you are using [GitLab Auto-Deploy][autodeploy] and one of the methods of
-configuring Prometheus above, the `environment` will be automatically added.
+GitLab will automatically scan the Prometheus server for known metrics and attempt to identify the metrics for a particular environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html).
-### GitLab Prometheus queries
+[Learn more about monitoring environments.](../../../ci/environments.md#monitoring-environments)
-The queries utilized by GitLab are shown in the following table.
+## Determining the performance impact of a merge
-| Metric | Query |
-| ------ | ----- |
-| Average Memory (MB) | `(sum(container_memory_usage_bytes{container_name!="POD",environment="$CI_ENVIRONMENT_SLUG"}) / count(container_memory_usage_bytes{container_name!="POD",environment="$CI_ENVIRONMENT_SLUG"})) /1024/1024` |
-| Average CPU Utilization (%) | `sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="$CI_ENVIRONMENT_SLUG"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="$CI_ENVIRONMENT_SLUG"}) * 100` |
+> [Introduced][ce-10408] in GitLab 9.2.
+> GitLab 9.3 added the [numeric comparison](https://gitlab.com/gitlab-org/gitlab-ce/issues/27439) of the 30 minute averages.
+> Requires [Kubernetes](prometheus_library/kubernetes.md) metrics
-## Monitoring CI/CD Environments
+Developers can view theperformance impact of their changes within the merge
+request workflow. When a source branch has been deployed to an environment, a sparkline and numeric comparison of the average memory consumption will appear. On the sparkline, a dot
+indicates when the current changes were deployed, with up to 30 minutes of
+performance data displayed before and after. The comparison shows the difference between the 30 minute average before and after the deployment. This information is updated after
+each commit has been deployed.
-Once configured, GitLab will attempt to retrieve performance metrics for any
-environment which has had a successful deployment. If monitoring data was
-successfully retrieved, a Monitoring button will appear on the environment's
-detail page.
+Once merged and the target branch has been redeployed, the metrics will switch
+to show the new environments this revision has been deployed to.
-![Environment Detail with Metrics](img/prometheus_environment_detail_with_metrics.png)
+Performance data will be available for the duration it is persisted on the
+Prometheus server.
-Clicking on the Monitoring button will display a new page, showing up to the last
-8 hours of performance data. It may take a minute or two for data to appear
-after initial deployment.
+![Merge Request with Performance Impact](img/merge_request_performance.png)
## Troubleshooting
@@ -181,12 +169,14 @@ If the "Attempting to load performance data" screen continues to appear, it coul
[autodeploy]: ../../../ci/autodeploy/index.md
[kubernetes]: https://kubernetes.io
+[kube]: ./kubernetes.md
[prometheus-k8s-sd]: https://prometheus.io/docs/operating/configuration/#<kubernetes_sd_config>
[prometheus]: https://prometheus.io
[gitlab-prometheus-k8s-monitor]: ../../../administration/monitoring/prometheus/index.md#configuring-prometheus-to-monitor-kubernetes
[prometheus-docker-image]: https://hub.docker.com/r/prom/prometheus/
[prometheus-yml]:samples/prometheus.yml
[gitlab.com-ip-range]: https://gitlab.com/gitlab-com/infrastructure/issues/434
-[ci-environment-slug]: https://docs.gitlab.com/ce/ci/variables/#predefined-variables-environment-variables
+[ci-environment-slug]: ../../../ci/variables/#predefined-variables-environment-variables
[ce-8935]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935
+[ce-10408]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10408
[promgldocs]: ../../../administration/monitoring/prometheus/index.md
diff --git a/doc/user/project/integrations/prometheus_library/cloudwatch.md b/doc/user/project/integrations/prometheus_library/cloudwatch.md
new file mode 100644
index 00000000000..cc5cee36d28
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/cloudwatch.md
@@ -0,0 +1,25 @@
+# Monitoring AWS Resources
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12621) in GitLab 9.4
+
+GitLab has support for automatically detecting and monitoring AWS resources, starting with the [Elastic Load Balancer](https://aws.amazon.com/elasticloadbalancing/). This is provided by leveraging the official [Cloudwatch exporter](https://github.com/prometheus/cloudwatch_exporter), which translates [Cloudwatch metrics](https://aws.amazon.com/cloudwatch/) into a Prometheus readable form.
+
+## Metrics supported
+
+| Name | Query |
+| ---- | ----- |
+| Throughput (req/sec) | sum(aws_elb_request_count_sum{%{environment_filter}}) / 60 |
+| Latency (ms) | avg(aws_elb_latency_average{%{environment_filter}}) * 1000 |
+| HTTP Error Rate (%) | sum(aws_elb_httpcode_backend_5_xx_sum{%{environment_filter}}) / sum(aws_elb_request_count_sum{%{environment_filter}}) |
+
+## Configuring Prometheus to monitor for Cloudwatch metrics
+
+To get started with Cloudwatch monitoring, you should install and configure the [Cloudwatch exporter](https://github.com/hnlq715/nginx-vts-exporter) which retrieves and parses the specified Cloudwatch metrics and translates them into a Prometheus monitoring endpoint.
+
+Right now, the only AWS resource supported is the Elastic Load Balancer, whose Cloudwatch metrics can be found [here](http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-cloudwatch-metrics.html).
+
+A sample Cloudwatch Exporter configuration file, configured for basic AWS ELB monitoring, is [available for download](../samples/cloudwatch.yml).
+
+## Specifying the Environment label
+
+In order to isolate and only display relevant metrics for a given environment
+however, GitLab needs a method to detect which labels are associated. To do this, GitLab will [look for an `environment` label](metrics.md#identifying-environments).
diff --git a/doc/user/project/integrations/prometheus_library/haproxy.md b/doc/user/project/integrations/prometheus_library/haproxy.md
new file mode 100644
index 00000000000..f2939f047a3
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/haproxy.md
@@ -0,0 +1,20 @@
+# Monitoring HAProxy
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12621) in GitLab 9.4
+
+GitLab has support for automatically detecting and monitoring HAProxy. This is provided by leveraging the [HAProxy Exporter](https://github.com/prometheus/haproxy_exporter), which translates HAProxy statistics into a Prometheus readable form.
+
+## Metrics supported
+
+| Name | Query |
+| ---- | ----- |
+| Throughput (req/sec) | sum(rate(haproxy_frontend_http_requests_total{%{environment_filter}}[2m])) |
+| HTTP Error Rate (%) | sum(rate(haproxy_frontend_http_requests_total{code="5xx",%{environment_filter}}[2m])) / sum(rate(haproxy_frontend_http_requests_total{%{environment_filter}}[2m])) |
+
+## Configuring Prometheus to monitor for HAProxy metrics
+
+To get started with NGINX monitoring, you should install and configure the [HAProxy exporter](https://github.com/prometheus/haproxy_exporter) which parses these statistics and translates them into a Prometheus monitoring endpoint.
+
+## Specifying the Environment label
+
+In order to isolate and only display relevant metrics for a given environment
+however, GitLab needs a method to detect which labels are associated. To do this, GitLab will [look for an `environment` label](metrics.md#identifying-environments).
diff --git a/doc/user/project/integrations/prometheus_library/kubernetes.md b/doc/user/project/integrations/prometheus_library/kubernetes.md
new file mode 100644
index 00000000000..eb8cd821ddc
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/kubernetes.md
@@ -0,0 +1,26 @@
+# Monitoring Kubernetes
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935) in GitLab 9.0
+
+GitLab has support for automatically detecting and monitoring Kubernetes metrics. Kubernetes exposes Node level metrics out of the box via the built-in [Prometheus metrics support in cAdvisor](https://github.com/google/cadvisor). No additional services or exporters are needed.
+
+## Metrics supported
+
+| Name | Query |
+| ---- | ----- |
+| Average Memory Usage (MB) | (sum(container_memory_usage_bytes{container_name!="POD",%{environment_filter}}) / count(container_memory_usage_bytes{container_name!="POD",%{environment_filter}})) /1024/1024 |
+| Average CPU Utilization (%) | sum(rate(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}) * 100 |
+
+## Configuring Prometheus to monitor for Kubernetes node metrics
+
+In order for Prometheus to collect Kubernetes metrics, you first must have a
+Prometheus server up and running. You have two options here:
+
+- If you have an Omnibus based GitLab installation within your Kubernetes cluster, you can leverage the bundled Prometheus server to [monitor Kubernetes](../../../../administration/monitoring/prometheus/index.md#configuring-prometheus-to-monitor-kubernetes).
+- To configure your own Prometheus server, you can follow the [Prometheus documentation](https://prometheus.io/docs/introduction/overview/) or [our guide](../../../../administration/monitoring/prometheus/index.md#configuring-your-own-prometheus-server-within-kubernetes).
+
+## Specifying the Environment label
+
+In order to isolate and only display relevant metrics for a given environment
+however, GitLab needs a method to detect which labels are associated. To do this, GitLab will [look for an `environment` label](metrics.md#identifying-environments).
+
+If you are using [GitLab Auto-Deploy][autodeploy] and one of the two [provided Kubernetes monitoring solutions](../prometheus.md#getting-started-with-prometheus-monitoring), the `environment` label will be automatically added.
diff --git a/doc/user/project/integrations/prometheus_library/metrics.md b/doc/user/project/integrations/prometheus_library/metrics.md
new file mode 100644
index 00000000000..f09ecf9ff2d
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/metrics.md
@@ -0,0 +1,24 @@
+# Prometheus Metrics library
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935) in GitLab 9.0
+
+GitLab offers automatic detection of select [Prometheus exporters](https://prometheus.io/docs/instrumenting/exporters/). Currently supported exporters are:
+* [Kubernetes](kubernetes.md)
+* [NGINX](nginx.md)
+* [NGINX Ingress Controller](nginx_ingress.md)
+* [HAProxy](haproxy.md)
+* [Amazon Cloud Watch](cloudwatch.md)
+
+We have tried to surface the most important metrics for each exporter, and will be continuing to add support for additional exporters in future releases. If you would like to add support for other official exporters, [contributions](#adding-to-the-library) are welcome.
+
+## Identifying Environments
+
+GitLab retrieves performance data from the configured Prometheus server, and attempts to identifying the presence of known metrics. Once identified, GitLab then needs to be able to map the data to a particular environment.
+
+In order to isolate and only display relevant metrics for a given environment, GitLab needs a method to detect which labels are associated. To do that,
+GitLab uses the defined queries and fills in the environment specific variables. Typically this involves looking for the [$CI_ENVIRONMENT_SLUG](https://docs.gitlab.com/ee/ci/variables/#predefined-variables-environment-variables), but may also include other information such as the project's Kubernetes namespace. Each search query is defined in the [exporter specific documentation](#prometheus-metrics-library).
+
+## Adding to the library
+
+We strive to support the 2-4 most important metrics for each common system service that supports Prometheus. If you are looking for support for a particular exporter which has not yet been added to the library, additions can be made [to the `additional_metrics.yml`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/prometheus/additional_metrics.yml) file.
+
+> Note: The library is only for monitoring public, common, system services which all customers can benefit from. Support for monitoring [customer proprietary metrics](https://gitlab.com/gitlab-org/gitlab-ee/issues/2273) will be added in a subsequent release.
diff --git a/doc/user/project/integrations/prometheus_library/nginx.md b/doc/user/project/integrations/prometheus_library/nginx.md
new file mode 100644
index 00000000000..12e3321f5f3
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/nginx.md
@@ -0,0 +1,23 @@
+# Monitoring NGINX
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12621) in GitLab 9.4
+
+GitLab has support for automatically detecting and monitoring NGINX. This is provided by leveraging the [NGINX VTS exporter](https://github.com/hnlq715/nginx-vts-exporter), which translates [VTS statistics](https://github.com/vozlt/nginx-module-vts) into a Prometheus readable form.
+
+## Metrics supported
+
+| Name | Query |
+| ---- | ----- |
+| Throughput (req/sec) | sum(rate(nginx_requests_total{server_zone!="*", server_zone!="_", %{environment_filter}}[2m])) |
+| Latency (ms) | avg(nginx_upstream_response_msecs_avg{%{environment_filter}}) |
+| HTTP Error Rate (HTTP Errors / sec) | rate(nginx_responses_total{status_code="5xx", %{environment_filter}}[2m])) |
+
+## Configuring Prometheus to monitor for NGINX metrics
+
+To get started with NGINX monitoring, you should first enable the [VTS statistics](https://github.com/vozlt/nginx-module-vts)) module for your NGINX server. This will capture and display statistics in an HTML readable form. Next, you should install and configure the [NGINX VTS exporter](https://github.com/hnlq715/nginx-vts-exporter) which parses these statistics and translates them into a Prometheus monitoring endpoint.
+
+If you are using NGINX as your Kubernetes ingress, there is [upcoming direct support](https://github.com/kubernetes/ingress/pull/423) for enabling Prometheus monitoring in the 0.9.0 release.
+
+## Specifying the Environment label
+
+In order to isolate and only display relevant metrics for a given environment
+however, GitLab needs a method to detect which labels are associated. To do this, GitLab will [look for an `environment` label](metrics.md#identifying-environments).
diff --git a/doc/user/project/integrations/prometheus_library/nginx_ingress.md b/doc/user/project/integrations/prometheus_library/nginx_ingress.md
new file mode 100644
index 00000000000..84ee8bc45e5
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_library/nginx_ingress.md
@@ -0,0 +1,25 @@
+# Monitoring NGINX Ingress Controller
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13438) in GitLab 9.5
+
+GitLab has support for automatically detecting and monitoring the Kubernetes NGINX ingress controller. This is provided by leveraging the built in Prometheus metrics included in [version 0.9.0](https://github.com/kubernetes/ingress/blob/master/controllers/nginx/Changelog.md#09-beta1) of the ingress.
+
+## Metrics supported
+
+| Name | Query |
+| ---- | ----- |
+| Throughput (req/sec) | sum(rate(nginx_upstream_requests_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) |
+| Latency (ms) | avg(nginx_upstream_response_msecs_avg{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}) |
+| HTTP Error Rate (HTTP Errors / sec) | sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) |
+
+## Configuring Prometheus to monitor for NGINX ingress metrics
+
+The easiest way to get started is to use at least version 0.9.0 of [NGINX ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx). If you are using NGINX as your Kubernetes ingress, there is [direct support](https://github.com/kubernetes/ingress/pull/423) for enabling Prometheus monitoring in the 0.9.0 release.
+
+If you have deployed with the [gitlab-omnibus](https://docs.gitlab.com/ee/install/kubernetes/gitlab_omnibus.md) Helm chart, these metrics will be automatically enabled and annotated for Prometheus monitoring.
+
+## Specifying the Environment label
+
+In order to isolate and only display relevant metrics for a given environment
+however, GitLab needs a method to detect which labels are associated. To do this, GitLab will search metrics with appropriate labels. In this case, the `upstream` label must be of the form `<Kubernetes Namespace>-<CI_ENVIRONMENT_SLUG>-*`.
+
+If you have used [Auto Deploy](https://docs.gitlab.com/ee/ci/autodeploy/index.html) to deploy your app, this format will be used automatically and metrics will be detected with no action on your part.
diff --git a/doc/user/project/integrations/redmine.md b/doc/user/project/integrations/redmine.md
index 89c0312d3c2..cf92465da53 100644
--- a/doc/user/project/integrations/redmine.md
+++ b/doc/user/project/integrations/redmine.md
@@ -21,3 +21,16 @@ Once you have configured and enabled Redmine:
As an example, below is a configuration for a project named gitlab-ci.
![Redmine configuration](img/redmine_configuration.png)
+
+## Referencing issues in Redmine
+
+Issues in Redmine can be referenced in two alternative ways:
+1. `#<ID>` where `<ID>` is a number (example `#143`)
+2. `<PROJECT>-<ID>` where `<PROJECT>` starts with a capital letter which is
+ then followed by capital letters, numbers or underscores, and `<ID>` is
+ a number (example `API_32-143`).
+
+We suggest using the longer format if you have both internal and external issue trackers enabled. If you use the shorter format and an issue with the same ID exists in the internal issue tracker the internal issue will be linked.
+
+Please note that `<PROJECT>` part is ignored and links always point to the
+address specified in `issues_url`.
diff --git a/doc/user/project/integrations/samples/cloudwatch.yml b/doc/user/project/integrations/samples/cloudwatch.yml
new file mode 100644
index 00000000000..d9b58f52c32
--- /dev/null
+++ b/doc/user/project/integrations/samples/cloudwatch.yml
@@ -0,0 +1,26 @@
+region: us-east-1
+ metrics:
+ - aws_namespace: AWS/ELB
+ aws_metric_name: RequestCount
+ aws_dimensions: [AvailabilityZone, LoadBalancerName]
+ aws_dimension_select:
+ LoadBalancerName: [gitlab-ha-lb]
+ aws_statistics: [Sum]
+ - aws_namespace: AWS/ELB
+ aws_metric_name: Latency
+ aws_dimensions: [AvailabilityZone, LoadBalancerName]
+ aws_dimension_select:
+ LoadBalancerName: [gitlab-ha-lb]
+ aws_statistics: [Average]
+ - aws_namespace: AWS/ELB
+ aws_metric_name: HTTPCode_Backend_2XX
+ aws_dimensions: [AvailabilityZone, LoadBalancerName]
+ aws_dimension_select:
+ LoadBalancerName: [gitlab-ha-lb]
+ aws_statistics: [Sum]
+ - aws_namespace: AWS/ELB
+ aws_metric_name: HTTPCode_Backend_5XX
+ aws_dimensions: [AvailabilityZone, LoadBalancerName]
+ aws_dimension_select:
+ LoadBalancerName: [gitlab-ha-lb]
+ aws_statistics: [Sum]
diff --git a/doc/user/project/integrations/samples/prometheus.yml b/doc/user/project/integrations/samples/prometheus.yml
index 01bbcaffe1e..30b59e172a1 100644
--- a/doc/user/project/integrations/samples/prometheus.yml
+++ b/doc/user/project/integrations/samples/prometheus.yml
@@ -24,6 +24,44 @@ data:
target_label: environment
regex: (.+)-.+-.+
replacement: $1
+ - job_name: kubernetes-pods
+ tls_config:
+ ca_file: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
+ insecure_skip_verify: true
+ bearer_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token"
+ kubernetes_sd_configs:
+ - role: pod
+ api_server: https://kubernetes.default.svc:443
+ tls_config:
+ ca_file: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
+ bearer_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token"
+ relabel_configs:
+ - source_labels:
+ - __meta_kubernetes_pod_annotation_prometheus_io_scrape
+ action: keep
+ regex: 'true'
+ - source_labels:
+ - __meta_kubernetes_pod_annotation_prometheus_io_path
+ action: replace
+ target_label: __metrics_path__
+ regex: "(.+)"
+ - source_labels:
+ - __address__
+ - __meta_kubernetes_pod_annotation_prometheus_io_port
+ action: replace
+ regex: "([^:]+)(?::[0-9]+)?;([0-9]+)"
+ replacement: "$1:$2"
+ target_label: __address__
+ - action: labelmap
+ regex: __meta_kubernetes_pod_label_(.+)
+ - source_labels:
+ - __meta_kubernetes_namespace
+ action: replace
+ target_label: kubernetes_namespace
+ - source_labels:
+ - __meta_kubernetes_pod_name
+ action: replace
+ target_label: kubernetes_pod_name
---
apiVersion: v1
kind: Service
diff --git a/doc/user/project/integrations/slack.md b/doc/user/project/integrations/slack.md
index e8b238351ca..af4ca35a215 100644
--- a/doc/user/project/integrations/slack.md
+++ b/doc/user/project/integrations/slack.md
@@ -1,51 +1,26 @@
# Slack Notifications Service
-## On Slack
+The Slack Notifications Service allows your GitLab project to send events (e.g. issue created) to your existing Slack team as notifications. This requires configurations in both Slack and GitLab.
-To enable Slack integration you must create an incoming webhook integration on
-Slack:
+> Note: You can also use Slack slash commands to control GitLab inside Slack. This is the separately configured [Slack slash commands](slack_slash_commands.md).
-1. [Sign in to Slack](https://slack.com/signin)
-1. Visit [Incoming WebHooks](https://my.slack.com/services/new/incoming-webhook/)
-1. Choose the channel name you want to send notifications to.
-1. Click **Add Incoming WebHooks Integration**
-1. Copy the **Webhook URL**, we'll need this later for GitLab.
+## Slack Configuration
-## On GitLab
+1. Sign in to your Slack team and [start a new Incoming WebHooks configuration](https://my.slack.com/services/new/incoming-webhook/).
+1. Select the Slack channel where notifications will be sent to by default. Click the **Add Incoming WebHooks integration** button to add the configuration.
+1. Copy the **Webhook URL**, which we'll use later in the GitLab configuration.
-After you set up Slack, it's time to set up GitLab.
+## GitLab Configuration
-Navigate to the [Integrations page](project_services.md#accessing-the-project-services)
-and select the **Slack notifications** service to configure it.
-There, you will see a checkbox with the following events that can be triggered:
+1. Navigate to the [Integrations page](project_services.md#accessing-the-project-services) in your project's settings, i.e. **Project > Settings > Integrations**.
+1. Select the **Slack notifications** project service to configure it.
+1. Check the **Active** checkbox to turn on the service.
+1. Check the checkboxes corresponding to the GitLab events you want to send to Slack as a notification.
+1. For each event, optionally enter the Slack channel where you want to send the event. (Do _not_ include the `#` symbol.) If left empty, the event will be sent to the default channel that you configured in the Slack Configuration step.
+1. Paste the **Webhook URL** that you copied from the Slack Configuration step.
+1. Optionally customize the Slack bot username that will be sending the notifications.
+1. Configure the remaining options and click `Save changes`.
-- Push
-- Issue
-- Confidential issue
-- Merge request
-- Note
-- Tag push
-- Pipeline
-- Wiki page
+Your Slack team will now start receiving GitLab event notifications as configured.
-Below each of these event checkboxes, you have an input field to enter
-which Slack channel you want to send that event message. Enter your preferred channel name **without** the hash sign (`#`).
-
-At the end, fill in your Slack details:
-
-| Field | Description |
-| ----- | ----------- |
-| **Webhook** | The [incoming webhook URL][slackhook] which you have to setup on Slack. |
-| **Username** | Optional username which can be on messages sent to Slack. Fill this in if you want to change the username of the bot. |
-| **Notify only broken pipelines** | If you choose to enable the **Pipeline** event and you want to be only notified about failed pipelines. |
-
-After you are all done, click **Save changes** for the changes to take effect.
-
->**Note:**
-You can set "branch,pushed,Compare changes" as highlight words on your Slack
-profile settings, so that you can be aware of new commits when somebody pushes
-them.
-
-![Slack configuration](img/slack_configuration.png)
-
-[slackhook]: https://my.slack.com/services/new/incoming-webhook
+![Slack configuration](img/slack_configuration.png) \ No newline at end of file
diff --git a/doc/user/project/integrations/slack_slash_commands.md b/doc/user/project/integrations/slack_slash_commands.md
index 56f1ba7311e..c267da69bb3 100644
--- a/doc/user/project/integrations/slack_slash_commands.md
+++ b/doc/user/project/integrations/slack_slash_commands.md
@@ -2,23 +2,22 @@
> Introduced in GitLab 8.15
-Slack commands give users an extra interface to perform common operations
-from the chat environment. This allows one to, for example, create an issue as
-soon as the idea was discussed in chat.
-For all available commands try the help subcommand, for example: `/gitlab help`,
-all review the [full list of commands](../../../integration/chat_commands.md).
+Slack slash commands allow you to control GitLab and view content right inside Slack, without having to leave it. This requires configurations in both Slack and GitLab.
-## Prerequisites
-
-A [team](https://get.slack.help/hc/en-us/articles/217608418-Creating-a-team) in
-Slack should be created beforehand, GitLab cannot create it for you.
+> Note: GitLab can also send events (e.g. issue created) to Slack as notifications. This is the separately configured [Slack Notifications Service](slack.md).
## Configuration
-Go to your project's [Integrations page](project_services.md#accessing-the-project-services)
-and select the **Slack slash commands** service to configure it.
+1. Slack slash commands are scoped to a project. Navigate to the [Integrations page](project_services.md#accessing-the-project-services) in your project's settings, i.e. **Project > Settings > Integrations**.
+1. Select the **Slack slash commands** project service to configure it. This page contains required information to complete the configuration in Slack. Leave this browser tab open.
+1. Open a new browser tab and sign in to your Slack team. [Start a new Slash Commands integration](https://my.slack.com/services/new/slash-commands).
+1. Enter a trigger term. We suggest you use the project name. Click **Add Slash Command Integration**.
+1. Complete the rest of the fields in the Slack configuration page using information from the GitLab browser tab. In particular, the URL needs to be copied and pasted. Click **Save Integration** to complete the configuration in Slack.
+1. While still on the Slack configuration page, copy the **token**. Go back to the GitLab browser tab and paste in the **token**.
+1. Check the **Active** checkbox and click **Save changes** to complete the configuration in GitLab.
![Slack setup instructions](img/slack_setup.png)
-Once you've followed the instructions, mark the service as active and insert the token
-you've received from Slack. After saving the service you are good to go!
+## Usage
+
+You can now use the [Slack slash commands](../../../integration/slash_commands.md).
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index dbdc93a77a8..47eb0b34f66 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -14,11 +14,8 @@ to the webhook URL.
Webhooks can be used to update an external issue tracker, trigger CI jobs,
update a backup mirror, or even deploy to your production server.
-Navigate to the webhooks page by going to the **Integrations** page from your
-project's settings which can be found under the wheel icon in the upper right
-corner.
-
-![Accessing the integrations](img/accessing_integrations.png)
+Navigate to the webhooks page by going to your project's
+**Settings âž” Integrations**.
## Webhook endpoint tips
@@ -74,6 +71,7 @@ X-Gitlab-Event: Push Hook
"checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"user_id": 4,
"user_name": "John Smith",
+ "user_username": "jsmith",
"user_email": "john@example.com",
"user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80",
"project_id": 15,
@@ -232,6 +230,7 @@ X-Gitlab-Event: Issue Hook
"object_attributes": {
"id": 301,
"title": "New API: create/update/delete file",
+ "assignee_ids": [51],
"assignee_id": 51,
"author_id": 51,
"project_id": 14,
@@ -246,6 +245,11 @@ X-Gitlab-Event: Issue Hook
"url": "http://example.com/diaspora/issues/23",
"action": "open"
},
+ "assignees": [{
+ "name": "User1",
+ "username": "user1",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+ }],
"assignee": {
"name": "User1",
"username": "user1",
@@ -265,6 +269,9 @@ X-Gitlab-Event: Issue Hook
}]
}
```
+
+**Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only.
+
### Comment events
Triggered when a new comment is made on commits, merge requests, issues, and code snippets.
@@ -431,7 +438,6 @@ X-Gitlab-Event: Note Hook
"iid": 1,
"description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.",
"position": 0,
- "locked_at": null,
"source":{
"name":"Gitlab Test",
"description":"Aut reprehenderit ut est.",
@@ -544,6 +550,7 @@ X-Gitlab-Event: Note Hook
"issue": {
"id": 92,
"title": "test",
+ "assignee_ids": [],
"assignee_id": null,
"author_id": 1,
"project_id": 5,
@@ -559,6 +566,8 @@ X-Gitlab-Event: Note Hook
}
```
+**Note**: `assignee_id` field is deprecated and now shows the first assignee only.
+
#### Comment on code snippet
**Request header**:
@@ -726,7 +735,7 @@ X-Gitlab-Event: Merge Request Hook
### Wiki Page events
-Triggered when a wiki page is created, edited or deleted.
+Triggered when a wiki page is created, updated or deleted.
**Request Header**:
@@ -1004,6 +1013,29 @@ X-Gitlab-Event: Build Hook
}
```
+## Testing webhooks
+
+You can trigger the webhook manually. Sample data from the project will be used.Sample data will take from the project.
+> For example: for triggering `Push Events` your project should have at least one commit.
+
+![Webhook testing](img/webhook_testing.png)
+
+## Troubleshoot webhooks
+
+Gitlab stores each perform of the webhook.
+You can find records for last 2 days in "Recent Deliveries" section on the edit page of each webhook.
+
+![Recent deliveries](img/webhook_logs.png)
+
+In this section you can see HTTP status code (green for 200-299 codes, red for the others, `internal error` for failed deliveries ), triggered event, a time when the event was called, elapsed time of the request.
+
+If you need more information about execution, you can click `View details` link.
+On this page, you can see data that GitLab sends (request headers and body) and data that it received (response headers and body).
+
+From this page, you can repeat delivery with the same data by clicking `Resend Request` button.
+
+>**Note:** If URL or secret token of the webhook were updated, data will be delivered to the new address.
+
## Example webhook receiver
If you want to see GitLab's webhooks in action for testing purposes you can use
@@ -1030,7 +1062,7 @@ Pick an unused port (e.g. 8000) and start the script: `ruby print_http_body.rb
8000`. Then add your server as a webhook receiver in GitLab as
`http://my.host:8000/`.
-When you press 'Test Hook' in GitLab, you should see something like this in the
+When you press 'Test' in GitLab, you should see something like this in the
console:
```
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index 5aa8337b75d..e2cc67726e0 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -1,8 +1,7 @@
-# Issue board
+# Issue Board
->**Notes:**
-- [Introduced][ce-5554] in GitLab 8.11.
-- The Backlog column was replaced by the **Add issues** button in GitLab 8.17.
+>**Note:**
+[Introduced][ce-5554] in [GitLab 8.11](https://about.gitlab.com/2016/08/22/gitlab-8-11-released/#issue-board).
The GitLab Issue Board is a software project management tool used to plan,
organize, and visualize a workflow for a feature or product release.
@@ -15,12 +14,65 @@ Other interesting links:
## Overview
-The Issue Board builds on GitLab's existing issue tracking functionality and
+The Issue Board builds on GitLab's existing
+[issue tracking functionality](issues/index.md#issue-tracker) and
leverages the power of [labels] by utilizing them as lists of the scrum board.
-With the Issue Board you can have a different view of your issues while also
+With the Issue Board you can have a different view of your issues while
maintaining the same filtering and sorting abilities you see across the
-issue tracker.
+issue tracker. An Issue Board is based on its project's label structure, therefore, it
+applies the same descriptive labels to indicate placement on the board, keeping
+consistency throughout the entire development lifecycle.
+
+An Issue Board shows you what issues your team is working on, who is assigned to each,
+and where in the workflow those issues are.
+
+You create issues, host code, perform reviews, build, test,
+and deploy from one single platform. Issue Boards help you to visualize
+and manage the entire process _in_ GitLab.
+
+With [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards), available
+only in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/),
+you go even further, as you can not only keep yourself and your project
+organized from a broader perspective with one Issue Board per project,
+but also allow your team members to organize their own workflow by creating
+multiple Issue Boards within the same project.
+
+## Use cases
+
+GitLab Workflow allows you to discuss proposals in issues, categorize them
+with labels, and from there organize and prioritize them with Issue Boards.
+
+For example, let's consider this simplified development workflow:
+
+1. You have a repository hosting your app's codebase
+and your team actively contributing to code
+1. Your **backend** team starts working a new
+implementation, gathers feedback and approval, and pass it over to **frontend**
+1. When frontend is complete, the new feature is deployed to **staging** to be tested
+1. When successful, it is deployed to **production**
+
+If we have the labels "**backend**", "**frontend**", "**staging**", and
+"**production**", and an Issue Board with a list for each, we can:
+
+- Visualize the entire flow of implementations since the
+beginning of the development lifecycle until deployed to production
+- Prioritize the issues in a list by moving them vertically
+- Move issues between lists to organize them according to the labels you've set
+- Add multiple issues to lists in the board by selecting one or more existing issues
+
+![issue card moving](img/issue_board_move_issue_card_list.png)
+
+> **Notes:**
+>
+>- For a broader use case, please check the blog post
+[GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario).
+>
+>- For a real use case, please check why
+[Codepen decided to adopt Issue Boards](https://about.gitlab.com/2017/01/27/codepen-welcome-to-gitlab/#project-management-everything-in-one-place)
+to improve their workflow with [multiple boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards).
+
+## Issue Board terminology
Below is a table of the definitions used for GitLab's Issue Board.
@@ -31,10 +83,11 @@ Below is a table of the definitions used for GitLab's Issue Board.
| **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. You can re-order cards within a list. |
There are two types of lists, the ones you create based on your labels, and
-one default:
+two defaults:
- Label list: a list based on a label. It shows all opened issues with that label.
-- **Done** (default): shows all closed issues. Always appears on the very right.
+- **Backlog** (default): shows all open issues that does not belong to one of lists. Always appears on the very left.
+- **Closed** (default): shows all closed issues. Always appears on the very right.
![GitLab Issue Board](img/issue_board.png)
@@ -56,7 +109,7 @@ In short, here's a list of actions you can take in an Issue Board:
If you are not able to perform one or more of the things above, make sure you
have the right [permissions](#permissions).
-## First time using the issue board
+## First time using the Issue Board
The first time you navigate to your Issue Board, you will be presented with
a default list (**Done**) and a welcoming message that gives
@@ -97,7 +150,7 @@ list view that is removed. You can always add it back later if you need.
## Adding issues to a list
You can add issues to a list by clicking the **Add issues** button that is
-present in the upper right corner of the issue board. This will open up a modal
+present in the upper right corner of the Issue Board. This will open up a modal
window where you can see all the issues that do not belong to any list.
Select one or more issues by clicking on the cards and then click **Add issues**
diff --git a/doc/user/project/issues/closing_issues.md b/doc/user/project/issues/closing_issues.md
new file mode 100644
index 00000000000..dcfa5ff59b2
--- /dev/null
+++ b/doc/user/project/issues/closing_issues.md
@@ -0,0 +1,59 @@
+# Closing Issues
+
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
+## Directly
+
+Whenever you decide that's no longer need for that issue,
+close the issue using the close button:
+
+![close issue - button](img/button_close_issue.png)
+
+## Via Merge Request
+
+When a merge request resolves the discussion over an issue, you can
+make it close that issue(s) when merged.
+
+All you need is to use a [keyword](automatic_issue_closing.md)
+accompanying the issue number, add to the description of that MR.
+
+In this example, the keyword "closes" prefixing the issue number will create a relationship
+in such a way that the merge request will close the issue when merged.
+
+Mentioning various issues in the same line also works for this purpose:
+
+```md
+Closes #333, #444, #555 and #666
+```
+
+If the issue is in a different repository rather then the MR's,
+add the full URL for that issue(s):
+
+```md
+Closes #333, #444, and https://gitlab.com/<username>/<projectname>/issues/<xxx>
+```
+
+All the following keywords will produce the same behaviour:
+
+- Close, Closes, Closed, Closing, close, closes, closed, closing
+- Fix, Fixes, Fixed, Fixing, fix, fixes, fixed, fixing
+- Resolve, Resolves, Resolved, Resolving, resolve, resolves, resolved, resolving
+
+![merge request closing issue when merged](img/merge_request_closes_issue.png)
+
+If you use any other word before the issue number, the issue and the MR will
+link to each other, but the MR will NOT close the issue(s) when merged.
+
+![mention issues in MRs - closing and related](img/closing_and_related_issues.png)
+
+## From the Issue Board
+
+You can close an issue from [Issue Boards](../issue_board.md) by draging an issue card
+from its list and dropping into **Closed**.
+
+![close issue from the Issue Board](img/close_issue_from_board.gif)
+
+## Customizing the issue closing patern
+
+Alternatively, a GitLab **administrator** can
+[customize the issue closing patern](../../../administration/issue_closing_pattern.md).
diff --git a/doc/user/project/issues/create_new_issue.md b/doc/user/project/issues/create_new_issue.md
new file mode 100644
index 00000000000..9af088374a1
--- /dev/null
+++ b/doc/user/project/issues/create_new_issue.md
@@ -0,0 +1,38 @@
+# Create a new Issue
+
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
+When you create a new issue, you'll be prompted to fill in
+the information illustrated on the image below.
+
+![New issue from the issues list](img/new_issue.png)
+
+Read through the [issues functionalities documentation](issues_functionalities.md#issues-functionalities)
+to understand these fields one by one.
+
+## New issue from the Issue Tracker
+
+Navigate to your **Project's Dashboard** > **Issues** > **New Issue** to create a new issue:
+
+![New issue from the issue list view](img/new_issue_from_tracker_list.png)
+
+## New issue from an opened issue
+
+From an **opened issue** in your project, click **New Issue** to create a new
+issue in the same project:
+
+![New issue from an open issue](img/new_issue_from_open_issue.png)
+
+## New issue from the project's dashboard
+
+From your **Project's Dashboard**, click the plus sign (**+**) to open a dropdown
+menu with a few options. Select **New Issue** to create an issue in that project:
+
+![New issue from a project's dashboard](img/new_issue_from_projects_dashboard.png)
+
+## New issue from the Issue Board
+
+From an Issue Board, create a new issue by clicking on the plus sign (**+**) on the top of a list.
+It opens a new issue for that project labeled after its respective list.
+
+![From the issue board](img/new_issue_from_issue_board.png)
diff --git a/doc/user/project/issues/crosslinking_issues.md b/doc/user/project/issues/crosslinking_issues.md
new file mode 100644
index 00000000000..5cc7ea383ae
--- /dev/null
+++ b/doc/user/project/issues/crosslinking_issues.md
@@ -0,0 +1,63 @@
+# Crosslinking Issues
+
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
+## From Commit Messages
+
+Every time you mention an issue in your commit message, you're creating
+a relationship between the two stages of the development workflow: the
+issue itself and the first commit related to that issue.
+
+If the issue and the code you're committing are both in the same project,
+you simply add `#xxx` to the commit message, where `xxx` is the issue number.
+If they are not in the same project, you can add the full URL to the issue
+(`https://gitlab.com/<username>/<projectname>/issues/<xxx>`).
+
+```shell
+git commit -m "this is my commit message. Ref #xxx"
+```
+
+or
+
+```shell
+git commit -m "this is my commit message. Related to https://gitlab.com/<username>/<projectname>/issues/<xxx>"
+```
+
+Of course, you can replace `gitlab.com` with the URL of your own GitLab instance.
+
+**Note:** Linking your first commit to your issue is going to be relevant
+for tracking your process far ahead with
+[GitLab Cycle Analytics](https://about.gitlab.com/features/cycle-analytics/)).
+It will measure the time taken for planning the implementation of that issue,
+which is the time between creating an issue and making the first commit.
+
+## From Related Issues
+
+Mentioning related issues in merge requests and other issues is useful
+for your team members and collaborators to know that there are opened
+issues around that same idea.
+
+You do that as explained above, when
+[mentioning an issue from a commit message](#from-commit-messages).
+
+When mentioning the issue "A" in a issue "B", the issue "A" will also
+display a notification in its tracker. The same is valid for mentioning
+issues in merge requests.
+
+![issue mentioned in issue](img/mention_in_issue.png)
+
+## From Merge Requests
+
+Mentioning issues in merge request comments work exactly the same way
+they do for [related issues](#from-related-issues).
+
+When you mention an issue in a merge request description, you can either
+[close the issue as soon as the merge request is merged](closing_issues.md#via-merge-request),
+or simply link both issue and merge request as described in the
+[closing issues documentation](closing_issues.md#from-related-issues).
+
+![issue mentioned in MR](img/mention_in_merge_request.png)
+
+### Close an issue by merging a merge request
+
+To [close an issue when a merge request is merged](closing_issues.md#via-merge-request), use the [automatic issue closing patern](automatic_issue_closing.md).
diff --git a/doc/user/project/issues/due_dates.md b/doc/user/project/issues/due_dates.md
index b516d47ffa3..e0c405353ce 100644
--- a/doc/user/project/issues/due_dates.md
+++ b/doc/user/project/issues/due_dates.md
@@ -2,6 +2,8 @@
> [Introduced][ce-3614] in GitLab 8.7.
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
Due dates can be used in issues to keep track of deadlines and make sure
features are shipped on time. Due dates require at least [Reporter permissions][permissions]
to be able to edit them. On the contrary, they can be seen by everybody.
@@ -22,8 +24,8 @@ Changes are saved immediately.
## Making use of due dates
-Issues that have a due date can be distinctively seen in the issues index page
-with a calendar icon next to them. Issues where the date is past due will have
+Issues that have a due date can be distinctively seen in the issue tracker
+displaying a date next to them. Issues where the date is overdue will have
the icon and the date colored red. You can sort issues by those that are
_Due soon_ or _Due later_ from the dropdown menu in the right.
diff --git a/doc/user/project/issues/img/button_close_issue.png b/doc/user/project/issues/img/button_close_issue.png
new file mode 100755
index 00000000000..8fb2e23f58a
--- /dev/null
+++ b/doc/user/project/issues/img/button_close_issue.png
Binary files differ
diff --git a/doc/user/project/issues/img/close_issue_from_board.gif b/doc/user/project/issues/img/close_issue_from_board.gif
new file mode 100644
index 00000000000..4814b42687b
--- /dev/null
+++ b/doc/user/project/issues/img/close_issue_from_board.gif
Binary files differ
diff --git a/doc/user/project/issues/img/closing_and_related_issues.png b/doc/user/project/issues/img/closing_and_related_issues.png
new file mode 100755
index 00000000000..c6543e85fdb
--- /dev/null
+++ b/doc/user/project/issues/img/closing_and_related_issues.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_create.png b/doc/user/project/issues/img/confidential_issues_create.png
index d259255599d..0a141eb39f8 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_create.png
+++ b/doc/user/project/issues/img/confidential_issues_create.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_index_page.png b/doc/user/project/issues/img/confidential_issues_index_page.png
index 042461e2451..e4b492a2769 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_index_page.png
+++ b/doc/user/project/issues/img/confidential_issues_index_page.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_issue_page.png b/doc/user/project/issues/img/confidential_issues_issue_page.png
index b3568e9303a..f04ec8ff32b 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_issue_page.png
+++ b/doc/user/project/issues/img/confidential_issues_issue_page.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_search_guest.png b/doc/user/project/issues/img/confidential_issues_search_guest.png
index b85de90b4d5..dc1b4ba8ad7 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_search_guest.png
+++ b/doc/user/project/issues/img/confidential_issues_search_guest.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_search_master.png b/doc/user/project/issues/img/confidential_issues_search_master.png
index bf2b9428875..fc01f4da9db 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_search_master.png
+++ b/doc/user/project/issues/img/confidential_issues_search_master.png
Binary files differ
diff --git a/doc/user/project/issues/img/confidential_issues_system_notes.png b/doc/user/project/issues/img/confidential_issues_system_notes.png
index 4005f9350f7..82e0dd8e85e 100644..100755
--- a/doc/user/project/issues/img/confidential_issues_system_notes.png
+++ b/doc/user/project/issues/img/confidential_issues_system_notes.png
Binary files differ
diff --git a/doc/user/project/issues/img/due_dates_create.png b/doc/user/project/issues/img/due_dates_create.png
index d2fe1172bab..ece35d44213 100644..100755
--- a/doc/user/project/issues/img/due_dates_create.png
+++ b/doc/user/project/issues/img/due_dates_create.png
Binary files differ
diff --git a/doc/user/project/issues/img/due_dates_edit_sidebar.png b/doc/user/project/issues/img/due_dates_edit_sidebar.png
index 6b37150e7db..d1c7d1eb7e9 100644..100755
--- a/doc/user/project/issues/img/due_dates_edit_sidebar.png
+++ b/doc/user/project/issues/img/due_dates_edit_sidebar.png
Binary files differ
diff --git a/doc/user/project/issues/img/due_dates_issues_index_page.png b/doc/user/project/issues/img/due_dates_issues_index_page.png
index defcd5eca39..94679436b32 100644..100755
--- a/doc/user/project/issues/img/due_dates_issues_index_page.png
+++ b/doc/user/project/issues/img/due_dates_issues_index_page.png
Binary files differ
diff --git a/doc/user/project/issues/img/due_dates_todos.png b/doc/user/project/issues/img/due_dates_todos.png
index 92c9fd4021b..4c124c97f67 100644..100755
--- a/doc/user/project/issues/img/due_dates_todos.png
+++ b/doc/user/project/issues/img/due_dates_todos.png
Binary files differ
diff --git a/doc/user/project/issues/img/issue_board.png b/doc/user/project/issues/img/issue_board.png
new file mode 100755
index 00000000000..1759b28a9ef
--- /dev/null
+++ b/doc/user/project/issues/img/issue_board.png
Binary files differ
diff --git a/doc/user/project/issues/img/issue_template.png b/doc/user/project/issues/img/issue_template.png
new file mode 100755
index 00000000000..c63229a4af2
--- /dev/null
+++ b/doc/user/project/issues/img/issue_template.png
Binary files differ
diff --git a/doc/user/project/issues/img/issue_tracker.png b/doc/user/project/issues/img/issue_tracker.png
new file mode 100755
index 00000000000..ab25cb64d13
--- /dev/null
+++ b/doc/user/project/issues/img/issue_tracker.png
Binary files differ
diff --git a/doc/user/project/issues/img/issues_main_view.png b/doc/user/project/issues/img/issues_main_view.png
new file mode 100644
index 00000000000..4faa42e40ee
--- /dev/null
+++ b/doc/user/project/issues/img/issues_main_view.png
Binary files differ
diff --git a/doc/user/project/issues/img/issues_main_view_numbered.jpg b/doc/user/project/issues/img/issues_main_view_numbered.jpg
new file mode 100644
index 00000000000..4b5d7fba459
--- /dev/null
+++ b/doc/user/project/issues/img/issues_main_view_numbered.jpg
Binary files differ
diff --git a/doc/user/project/issues/img/mention_in_issue.png b/doc/user/project/issues/img/mention_in_issue.png
new file mode 100755
index 00000000000..c762a812138
--- /dev/null
+++ b/doc/user/project/issues/img/mention_in_issue.png
Binary files differ
diff --git a/doc/user/project/issues/img/mention_in_merge_request.png b/doc/user/project/issues/img/mention_in_merge_request.png
new file mode 100755
index 00000000000..681e086d6e0
--- /dev/null
+++ b/doc/user/project/issues/img/mention_in_merge_request.png
Binary files differ
diff --git a/doc/user/project/issues/img/merge_request_closes_issue.png b/doc/user/project/issues/img/merge_request_closes_issue.png
new file mode 100755
index 00000000000..6fd27738843
--- /dev/null
+++ b/doc/user/project/issues/img/merge_request_closes_issue.png
Binary files differ
diff --git a/doc/user/project/issues/img/new_issue.png b/doc/user/project/issues/img/new_issue.png
new file mode 100755
index 00000000000..e72ac49d6b9
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue.png
Binary files differ
diff --git a/doc/user/project/issues/img/new_issue_from_issue_board.png b/doc/user/project/issues/img/new_issue_from_issue_board.png
new file mode 100755
index 00000000000..9c2b3ff50fa
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue_from_issue_board.png
Binary files differ
diff --git a/doc/user/project/issues/img/new_issue_from_open_issue.png b/doc/user/project/issues/img/new_issue_from_open_issue.png
new file mode 100755
index 00000000000..2aed5372830
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue_from_open_issue.png
Binary files differ
diff --git a/doc/user/project/issues/img/new_issue_from_projects_dashboard.png b/doc/user/project/issues/img/new_issue_from_projects_dashboard.png
new file mode 100755
index 00000000000..cddf36b7457
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue_from_projects_dashboard.png
Binary files differ
diff --git a/doc/user/project/issues/img/new_issue_from_tracker_list.png b/doc/user/project/issues/img/new_issue_from_tracker_list.png
new file mode 100755
index 00000000000..7e5413f0b7d
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue_from_tracker_list.png
Binary files differ
diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md
new file mode 100644
index 00000000000..1f78849a92c
--- /dev/null
+++ b/doc/user/project/issues/index.md
@@ -0,0 +1,125 @@
+# Issues
+
+The GitLab Issue Tracker is an advanced and complete tool
+for tracking the evolution of a new idea or the process
+of solving a problem.
+
+It allows you, your team, and your collaborators to share
+and discuss proposals before and while implementing them.
+
+Issues and the GitLab Issue Tracker are available in all
+[GitLab Products](https://about.gitlab.com/products/) as
+part of the [GitLab Workflow](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/).
+
+## Use cases
+
+Issues can have endless applications. Just to exemplify, these are
+some cases for which creating issues are most used:
+
+- Discussing the implementation of a new idea
+- Submitting feature proposals
+- Asking questions
+- Reporting bugs and malfunction
+- Obtaining support
+- Elaborating new code implementations
+
+See also the blog post "[Always start a discussion with an issue](https://about.gitlab.com/2016/03/03/start-with-an-issue/)".
+
+### Keep private things private
+
+For instance, let's assume you have a public project but want to start a discussion on something
+you don't want to be public. With [Confidential Issues](#confidential-issues),
+you can discuss private matters among the project members, and still keep
+your project public, open to collaboration.
+
+### Streamline collaboration
+
+With [Multiple Assignees for Issues](https://docs.gitlab.com/ee/user/project/issues/multiple_assignees_for_issues.html),
+available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/)
+you can streamline collaboration and allow shared responsibilities to be clearly displayed.
+All assignees are shown across your workflows and receive notifications (as they
+would as single assignees), simplifying communication and ownership.
+
+### Consistent collaboration
+
+Create [issue templates](#issue-templates) to make collaboration consistent and
+containing all information you need. For example, you can create a template
+for feature proposals and another one for bug reports.
+
+## Issue Tracker
+
+The issue tracker is the collection of opened and closed issues created in a project.
+
+![Issue tracker](img/issue_tracker.png)
+
+Find the issue tracker by navigating to your **Project's Dashboard** > **Issues**.
+
+## GitLab Issues Functionalities
+
+The image bellow illustrates how an issue looks like:
+
+![Issue view](img/issues_main_view.png)
+
+Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md).
+
+## New issue
+
+Read through the [documentation on creating issues](create_new_issue.md).
+
+## Closing issues
+
+Learn distinct ways to [close issues](closing_issues.md) in GitLab.
+
+## Create a merge request from an issue
+
+Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request).
+
+## Search for an issue
+
+Learn how to [find an issue](../../search/index.md) by searching for and filtering them.
+
+## Advanced features
+
+### Confidential Issues
+
+Whenever you want to keep the discussion presented in a
+issue within your team only, you can make that
+[issue confidential](confidential_issues.md). Even if your project
+is public, that issue will be preserved. The browser will
+respond with a 404 error whenever someone who is not a project
+member with at least [Reporter level](../../permissions.md#project) tries to
+access that issue's URL.
+
+Learn more about them on the [confidential issues documentation](confidential_issues.md).
+
+### Issue templates
+
+Create templates for every new issue. They will be available from
+the dropdown menu **Choose a template** when you create a new issue:
+
+![issue template](img/issue_template.png)
+
+Learn more about them on the [issue templates documentation](../../project/description_templates.md#creating-issue-templates).
+
+### Crosslinking issues
+
+Learn more about [crosslinking](crosslinking_issues.md) issues and merge requests.
+
+### Issue Board
+
+The [GitLab Issue Board](https://about.gitlab.com/features/issueboard/) is a way to
+enhance your workflow by organizing and prioritizing issues in GitLab.
+
+![Issue board](img/issue_board.png)
+
+Find GitLab Issue Boards by navigating to your **Project's Dashboard** > **Issues** > **Board**.
+
+Read through the documentation for [Issue Boards](../issue_board.md)
+to find out more about this feature.
+
+With [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/), you can also
+create various boards per project with [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards).
+
+### Issue's API
+
+Read through the [API documentation](../../../api/issues.md).
diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md
new file mode 100644
index 00000000000..074b2c19c43
--- /dev/null
+++ b/doc/user/project/issues/issues_functionalities.md
@@ -0,0 +1,172 @@
+# GitLab Issues Functionalities
+
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
+## Issues Functionalities
+
+The image bellow illustrates how an issue looks like:
+
+![Issue view](img/issues_main_view_numbered.jpg)
+
+You can find all the information on that issue on one screen.
+
+### Issue screen
+
+An issue starts with its status (open or closed), followed by its author,
+and includes many other functionalities, numbered on the image above to
+explain what they mean, one by one.
+
+Many of the elements of the issue screen refresh automatically, such as the title and description, when they are changed by another user.
+Comments and system notes also appear automatically in response to various actions and content updates.
+
+#### 1. New Issue, close issue, edit
+
+- New issue: create a new issue in the same project
+- Close issue: close this issue
+- Edit: edit the same fields available when you create an issue.
+
+#### 2. Todos
+
+- Add todo: add that issue to your [GitLab Todo](../../../workflow/todos.html) list
+- Mark done: mark that issue as done (reflects on the Todo list)
+
+#### 3. Assignee
+
+Whenever someone starts to work on an issue, it can be assigned
+to that person. The assignee can be changed as much as needed.
+The idea is that the assignee is responsible for that issue until
+it's reassigned to someone else to take it from there.
+
+> **Tip:**
+if a user is not member of that project, it can only be
+assigned to them if they created the issue themselves.
+
+##### 3.1. Multiple Assignees (EES/EEP)
+
+Multiple Assignees are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/).
+
+Often multiple people likely work on the same issue together,
+which can especially be difficult to track in large teams
+where there is shared ownership of an issue.
+
+In GitLab Enterprise Edition, you can also select multiple assignees
+to an issue.
+
+Learn more on the [Multiple Assignees documentation](https://docs.gitlab.com/ee/user/project/issues/multiple_assignees_for_issues.html).
+
+#### 4. Milestone
+
+- Select a [milestone](../milestones/index.md) to attribute that issue to.
+
+#### 5. Time Tracking
+
+- Estimate time: add an estimate time in which the issue will be implemented
+- Spend: add the time spent on the implementation of that issue
+
+> **Note:**
+Both estimate and spend times are set via [GitLab Quick Actions](../quick_actions.md).
+
+Learn more on the [Time Tracking documentation](../../../workflow/time_tracking.md).
+
+#### 6. Due date
+
+When you work on a tight schedule, and it's important to
+have a way to setup a deadline for implementations and for solving
+problems. This can be facilitated by the [due date](due_dates.md)). Due dates
+can be changed as many times as needed.
+
+#### 7. Labels
+
+Categorize issues by giving them [labels](../labels.md). They help to
+organize team's workflows, once they enable you to work with the
+[GitLab Issue Board](index.md#gitlab-issue-board).
+
+Group Labels, which allow you to use the same labels per
+group of projects, can be also given to issues. They work exactly the same,
+but they are immediately available to all projects in the group.
+
+> **Tip:**
+if the label doesn't exist yet, when you click **Edit**, it opens a dropdown menu from which you can select **Create new label**.
+
+#### 8. Weight (EES/EEP)
+
+Issue Weights are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/).
+
+- Attribute a weight (in a 0 to 9 range) to that issue. Easy to complete
+should weight 1 and very hard to complete should weight 9.
+
+Learn more on the [Issue Weight documentation](https://docs.gitlab.com/ee/workflow/issue_weight.html).
+
+#### 9. Participants
+
+- People involved in that issue (mentioned in the description or in the [discussion](../../discussions/index.md)).
+
+#### 10. Notifications
+
+- Subscribe: if you are not a participant of the discussion on that issue, but
+want to receive notifications on each new input, subscribe to it.
+- Unsubscribe: if you are receiving notifications on that issue but no
+longer want to receive them, unsubscribe to it.
+
+Read more on the [notifications documentation](../../../workflow/notifications.md#issue-merge-request-events).
+
+#### 11. Reference
+
+- A quick "copy to clipboard" button to that issue's reference, `foo/bar#xxx`, where `foo` is the `username` or `groupname`, `bar`
+is the `project-name`, and `xxx` is the issue number.
+
+#### 12. Title and description
+
+- Title: a plain text title describing the issue's subject.
+- Description: a text field which fully supports [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm).
+
+#### 13. @mentions
+
+- Mentions: you can either `@mention` a user or a group present in your
+GitLab instance and they will be notified via todos and email, unless that
+person has disabled all notifications in their profile settings.
+
+To change your [notification settings](../../../workflow/notifications.md) navigate to
+**Profile Settings** > **Notifications** > **Global notification level**
+and choose your preferences from the dropdown menu.
+
+> **Tip:**
+Avoid mentioning `@all` in issues and merge requests,
+as it sends an email notification
+to all the members of that project's group, which can be
+interpreted as spam.
+
+#### 14. Related Merge Requests
+
+- Any merge requests mentioned in that issue's description
+or in the issue thread.
+
+#### 15. Award emoji
+
+- Award an emoji to that issue.
+
+> **Tip:**
+Posting "+1" as comments in threads spam all
+participants of that issue. Awarding an emoji is a way to let them
+know you like it without spamming them.
+
+#### 16. Thread
+
+- Comments: collaborate to that issue by posting comments in its thread.
+These text fields also fully support
+[GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm).
+
+#### 17. Comment, start a discusion, or comment and close
+
+Once you wrote your comment, you can either:
+
+- Click "Comment" and your comment will be published.
+- Click "Start discussion": start a thread within that issue's thread to discuss specific points.
+- Click "Comment and close issue": post your comment and close that issue in one click.
+
+#### 18. New Merge Request
+
+- Create a new merge request (with a new source branch named after the issue) in one action.
+The merge request will automatically close that issue as soon as merged.
+- Optionally, you can just create a [new branch](../repository/web_editor.md#create-a-new-branch-from-an-issue)
+named after that issue.
diff --git a/doc/user/project/koding.md b/doc/user/project/koding.md
index c56a1efe3c2..455e2ee47b4 100644
--- a/doc/user/project/koding.md
+++ b/doc/user/project/koding.md
@@ -1,4 +1,4 @@
-# Koding & GitLab
+# Koding integration
> [Introduced][ce-5909] in GitLab 8.11.
diff --git a/doc/workflow/add-user/img/access_requests_management.png b/doc/user/project/members/img/access_requests_management.png
index 3693bed869b..3693bed869b 100644
--- a/doc/workflow/add-user/img/access_requests_management.png
+++ b/doc/user/project/members/img/access_requests_management.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_new_user_to_project_settings.png b/doc/user/project/members/img/add_new_user_to_project_settings.png
index 40db600455f..40db600455f 100644
--- a/doc/workflow/add-user/img/add_new_user_to_project_settings.png
+++ b/doc/user/project/members/img/add_new_user_to_project_settings.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_email_accept.png b/doc/user/project/members/img/add_user_email_accept.png
index 763b3ff463d..763b3ff463d 100644
--- a/doc/workflow/add-user/img/add_user_email_accept.png
+++ b/doc/user/project/members/img/add_user_email_accept.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_email_ready.png b/doc/user/project/members/img/add_user_email_ready.png
index 0066eb3427b..0066eb3427b 100644
--- a/doc/workflow/add-user/img/add_user_email_ready.png
+++ b/doc/user/project/members/img/add_user_email_ready.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_email_search.png b/doc/user/project/members/img/add_user_email_search.png
index 66bcd6aad80..66bcd6aad80 100644
--- a/doc/workflow/add-user/img/add_user_email_search.png
+++ b/doc/user/project/members/img/add_user_email_search.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_give_permissions.png b/doc/user/project/members/img/add_user_give_permissions.png
index 376a3eefccc..376a3eefccc 100644
--- a/doc/workflow/add-user/img/add_user_give_permissions.png
+++ b/doc/user/project/members/img/add_user_give_permissions.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_import_members_from_another_project.png b/doc/user/project/members/img/add_user_import_members_from_another_project.png
index 0c32001098e..0c32001098e 100644
--- a/doc/workflow/add-user/img/add_user_import_members_from_another_project.png
+++ b/doc/user/project/members/img/add_user_import_members_from_another_project.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_imported_members.png b/doc/user/project/members/img/add_user_imported_members.png
index 51fd7688890..51fd7688890 100644
--- a/doc/workflow/add-user/img/add_user_imported_members.png
+++ b/doc/user/project/members/img/add_user_imported_members.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_list_members.png b/doc/user/project/members/img/add_user_list_members.png
index e0fa404288d..e0fa404288d 100644
--- a/doc/workflow/add-user/img/add_user_list_members.png
+++ b/doc/user/project/members/img/add_user_list_members.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_members_menu.png b/doc/user/project/members/img/add_user_members_menu.png
index 8e61d15fe65..8e61d15fe65 100644
--- a/doc/workflow/add-user/img/add_user_members_menu.png
+++ b/doc/user/project/members/img/add_user_members_menu.png
Binary files differ
diff --git a/doc/workflow/add-user/img/add_user_search_people.png b/doc/user/project/members/img/add_user_search_people.png
index 41767a9167c..41767a9167c 100644
--- a/doc/workflow/add-user/img/add_user_search_people.png
+++ b/doc/user/project/members/img/add_user_search_people.png
Binary files differ
diff --git a/doc/workflow/groups/max_access_level.png b/doc/user/project/members/img/max_access_level.png
index 63f33f9d91d..63f33f9d91d 100644
--- a/doc/workflow/groups/max_access_level.png
+++ b/doc/user/project/members/img/max_access_level.png
Binary files differ
diff --git a/doc/workflow/groups/other_group_sees_shared_project.png b/doc/user/project/members/img/other_group_sees_shared_project.png
index 67af27043eb..67af27043eb 100644
--- a/doc/workflow/groups/other_group_sees_shared_project.png
+++ b/doc/user/project/members/img/other_group_sees_shared_project.png
Binary files differ
diff --git a/doc/workflow/add-user/img/request_access_button.png b/doc/user/project/members/img/request_access_button.png
index 608baccb0ca..608baccb0ca 100644
--- a/doc/workflow/add-user/img/request_access_button.png
+++ b/doc/user/project/members/img/request_access_button.png
Binary files differ
diff --git a/doc/workflow/groups/share_project_with_groups.png b/doc/user/project/members/img/share_project_with_groups.png
index 3cb4796f9f7..3cb4796f9f7 100644
--- a/doc/workflow/groups/share_project_with_groups.png
+++ b/doc/user/project/members/img/share_project_with_groups.png
Binary files differ
diff --git a/doc/workflow/add-user/img/withdraw_access_request_button.png b/doc/user/project/members/img/withdraw_access_request_button.png
index 6edd786b151..6edd786b151 100644
--- a/doc/workflow/add-user/img/withdraw_access_request_button.png
+++ b/doc/user/project/members/img/withdraw_access_request_button.png
Binary files differ
diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md
new file mode 100644
index 00000000000..b8dd96087f1
--- /dev/null
+++ b/doc/user/project/members/index.md
@@ -0,0 +1,116 @@
+# Project's members
+
+You can manage the groups and users and their access levels in all of your
+projects. You can also personalize the access level you give each user,
+per-project.
+
+You should have `master` or `owner` [permissions](../../permissions.md) to add
+or import a new user to your project.
+
+To view, edit, add, and remove project's members, go to your
+project's **Settings > Members**.
+
+---
+
+## Add a user
+
+Right next to **People**, start typing the name or username of the user you
+want to add.
+
+![Search for people](img/add_user_search_people.png)
+
+---
+
+Select the user and the [permission level](../../user/permissions.md)
+that you'd like to give the user. Note that you can select more than one user.
+
+![Give user permissions](img/add_user_give_permissions.png)
+
+---
+
+Once done, hit **Add users to project** and they will be immediately added to
+your project with the permissions you gave them above.
+
+![List members](img/add_user_list_members.png)
+
+---
+
+From there on, you can either remove an existing user or change their access
+level to the project.
+
+## Import users from another project
+
+You can import another project's users in your own project by hitting the
+**Import members** button on the upper right corner of the **Members** menu.
+
+In the dropdown menu, you can see only the projects you are Master on.
+
+![Import members from another project](img/add_user_import_members_from_another_project.png)
+
+---
+
+Select the one you want and hit **Import project members**. A flash message
+notifying you that the import was successful will appear, and the new members
+are now in the project's members list. Notice that the permissions that they
+had on the project you imported from are retained.
+
+![Members list of new members](img/add_user_imported_members.png)
+
+---
+
+## Invite people using their e-mail address
+
+If a user you want to give access to doesn't have an account on your GitLab
+instance, you can invite them just by typing their e-mail address in the
+user search field.
+
+![Invite user by mail](img/add_user_email_search.png)
+
+---
+
+As you can imagine, you can mix inviting multiple people and adding existing
+GitLab users to the project.
+
+![Invite user by mail ready to submit](img/add_user_email_ready.png)
+
+---
+
+Once done, hit **Add users to project** and watch that there is a new member
+with the e-mail address we used above. From there on, you can resend the
+invitation, change their access level or even delete them.
+
+![Invite user members list](img/add_user_email_accept.png)
+
+---
+
+Once the user accepts the invitation, they will be prompted to create a new
+GitLab account using the same e-mail address the invitation was sent to.
+
+## Request access to a project
+
+As a project owner you can enable or disable non members to request access to
+your project. Go to the project settings and click on **Allow users to request access**.
+
+As a user, you can request to be a member of a project. Go to the project you'd
+like to be a member of, and click the **Request Access** button on the right
+side of your screen.
+
+![Request access button](img/request_access_button.png)
+
+---
+
+Project owners & masters will be notified of your request and will be able to approve or
+decline it on the members page.
+
+![Manage access requests](img/access_requests_management.png)
+
+---
+
+If you change your mind before your request is approved, just click the
+**Withdraw Access Request** button.
+
+![Withdraw access request button](img/withdraw_access_request_button.png)
+
+## Share project with group
+
+Alternatively, you can [share a project with an entire group](share_project_with_groups.md) instead of adding users one by one.
diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md
new file mode 100644
index 00000000000..4c1ddcdcba8
--- /dev/null
+++ b/doc/user/project/members/share_project_with_groups.md
@@ -0,0 +1,41 @@
+# Share Projects with other Groups
+
+You can share projects with other [groups](../../group/index.md). This makes it
+possible to add a group of users to a project with a single action.
+
+## Groups as collections of users
+
+Groups are used primarily to [create collections of projects](../user/group/index.md), but you can also
+take advantage of the fact that groups define collections of _users_, namely the group
+members.
+
+## Sharing a project with a group of users
+
+The primary mechanism to give a group of users, say 'Engineering', access to a project,
+say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project
+Acme'. But what if 'Project Acme' already belongs to another group, say 'Open Source'?
+This is where the group sharing feature can be of use.
+
+To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section.
+
+![The 'Groups' section in the project settings screen](img/share_project_with_groups.png)
+
+Now you can add the 'Engineering' group with the maximum access level of your choice.
+After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard.
+
+!['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project.png)
+
+## Maximum access level
+
+!['Project Acme' is shared with 'Engineering' with a maximum access level of 'Developer'](img/max_access_level.png)
+
+In the screenshot above, the maximum access level of 'Developer' for members from 'Engineering' means that users with higher access levels in 'Engineering' ('Master' or 'Owner') will only have 'Developer' access to 'Project Acme'.
+
+## Share project with group lock (EES/EEP)
+
+In [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/)
+it is possible to prevent projects in a group from [sharing
+a project with another group](../members/share_project_with_groups.md).
+This allows for tighter control over project access.
+
+Learn more about [Share with group lock](https://docs.gitlab.com/ee/user/group/index.html#share-with-group-lock-ees-eep).
diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md
index 954454f7e7a..9bdf2a998d3 100644
--- a/doc/user/project/merge_requests/index.md
+++ b/doc/user/project/merge_requests/index.md
@@ -3,6 +3,59 @@
Merge requests allow you to exchange changes you made to source code and
collaborate with other people on the same project.
+## Overview
+
+A Merge Request (**MR**) is the basis of GitLab as a code collaboration
+and version control platform.
+Is it simple as the name implies: a _request_ to _merge_ one branch into another.
+
+With GitLab merge requests, you can:
+
+- Compare the changes between two [branches](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell#_git_branching)
+- [Review and discuss](../../discussions/index.md#discussions) the proposed modifications inline
+- Live preview the changes when [Review Apps](../../../ci/review_apps/index.md) is configured for your project
+- Build, test, and deploy your code in a per-branch basis with built-in [GitLab CI/CD](../../../ci/README.md)
+- Prevent the merge request from being merged before it's ready with [WIP MRs](#work-in-progress-merge-requests)
+- View the deployment process through [Pipeline Graphs](../../../ci/pipelines.md#pipeline-graphs)
+- [Automatically close the issue(s)](../../project/issues/closing_issues.md#via-merge-request) that originated the implementation proposed in the merge request
+- Assign it to any registered user, and change the assignee how many times you need
+- Assign a [milestone](../../project/milestones/index.md) and track the development of a broader implementation
+- Organize your issues and merge requests consistently throughout the project with [labels](../../project/labels.md)
+- Add a time estimation and the time spent with that merge request with [Time Tracking](../../../workflow/time_tracking.html#time-tracking)
+- [Resolve merge conflicts from the UI](#resolve-conflicts)
+
+With **[GitLab Enterprise Edition][ee]**, you can also:
+
+- View the deployment process across projects with [Multi-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html#multi-project-pipeline-graphs) (available only in GitLab Enterprise Edition Premium)
+- Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab Enterprise Edition Starter)
+- Enable [fast-forward merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/fast_forward_merge.html) (available in GitLab Enterprise Edition Starter)
+- [Squash and merge](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html) for a cleaner commit history (available in GitLab Enterprise Edition Starter)
+- Enable [semi-linear history merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/index.html#semi-linear-history-merge-requests) as another security layer to guarantee the pipeline is passing in the target branch (available in GitLab Enterprise Edition Starter)
+- Analise the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Enterprise Edition Starter)
+
+## Use cases
+
+A. Consider you are a software developer working in a team:
+
+1. You checkout a new branch, and submit your changes through a merge request
+1. You gather feedback from your team
+1. You work on the implementation optimizing code with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Enterprise Edition Starter)
+1. You build and test your changes with GitLab CI/CD
+1. You request the approval from your manager
+1. Your manager pushes a commit with his final review, [approves the merge request](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html), and set it to [merge when pipeline succeeds](#merge-when-pipeline-succeeds) (Merge Request Approvals are available in GitLab Enterprise Edition Starter)
+1. Your changes get deployed to production with [manual actions](../../../ci/yaml/README.md#manual-actions) for GitLab CI/CD
+1. Your implementations were successfully shipped to your customer
+
+B. Consider you're a web developer writing a webpage for your company's:
+
+1. You checkout a new branch, and submit a new page through a merge request
+1. You gather feedback from your reviewers
+1. Your changes are previewed with [Review Apps](../../../ci/review_apps/index.md)
+1. You request your web designers for their implementation
+1. You request the [approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your manager (available in GitLab Enterprise Edition Starter)
+1. Once approved, your merge request is [squashed and merged](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html), and [deployed to staging with GitLab Pages](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) (Squash and Merge is available in GitLab Enterprise Edition Starter)
+1. Your production team [cherry picks](#cherry-pick-changes) the merge commit into production
+
## Authorization for merge requests
There are two main ways to have a merge request flow with GitLab:
@@ -79,6 +132,16 @@ specific commit page.
You can append `?w=1` while on the diffs page of a merge request to ignore any
whitespace changes.
+## Live preview with Review Apps
+
+If you configured [Review Apps](https://about.gitlab.com/features/review-apps/) for your project,
+you can preview the changes submitted to a feature-branch through a merge request
+in a per-branch basis. No need to checkout the branch, install and preview locally;
+all your changes will be available to preview by anyone with the Review Apps link.
+
+[Read more about Review Apps.](../../../ci/review_apps/index.md)
+
+
## Tips
Here are some tips that will help you be more efficient with merge requests in
@@ -167,3 +230,4 @@ git checkout origin/merge-requests/1
```
[protected branches]: ../protected_branches.md
+[ee]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition"
diff --git a/doc/user/project/milestones/img/progress.png b/doc/user/project/milestones/img/progress.png
new file mode 100644
index 00000000000..c85aecca729
--- /dev/null
+++ b/doc/user/project/milestones/img/progress.png
Binary files differ
diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md
index a43a42a8fe8..876b98a4dc5 100644
--- a/doc/user/project/milestones/index.md
+++ b/doc/user/project/milestones/index.md
@@ -21,14 +21,15 @@ Once you fill in all the details, hit the **Create milestone** button.
>**Note:**
You need [Master permissions](../../permissions.md) in order to create a milestone.
-You can create a milestone for several projects in the same group simultaneously.
-On the group's **Issues âž” Milestones** page, you will be able to see the status
-of that milestone across all of the selected projects. To create a new milestone
-for selected projects in the group, click the **New milestone** button. The
-form is the same as when creating a milestone for a specific project with the
-addition of the selection of the projects you want to inherit this milestone.
+You can create a milestone for a group that will be shared across group projects.
+On the group's **Issues âž” Milestones** page, you will be able to see the state
+of that milestone and the issues/merge requests count that it shares across the group projects. To create a new milestone click the **New milestone** button. The form is the same as when creating a milestone for a specific project which you can find in the previous item.
-![Creating a group milestone](img/milestone_group_create.png)
+In addition to that you will be able to filter issues or merge requests by group milestones in all projects that belongs to the milestone group.
+
+## Milestone promotion
+
+You will be able to promote a project milestone to a group milestone [in the future](https://gitlab.com/gitlab-org/gitlab-ce/issues/35833).
## Special milestone filters
@@ -44,3 +45,16 @@ special options available when filtering by milestone:
* **Started** - show issues or merge requests from any milestone with a start
date less than today. Note that this can return results from several
milestones in the same project.
+
+## Milestone progress statistics
+
+Milestone statistics can be viewed in the milestone sidebar. The milestone percentage statistic
+is calculated as; closed and merged merge requests plus all closed issues divided by
+total merge requests and issues.
+
+![Milestone statistics](img/progress.png)
+
+## Quick actions
+
+[Quick actions](../quick_actions.md) are available for assigning and removing
+project and group milestones.
diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md
index f846736028f..271adee7da1 100644
--- a/doc/user/project/new_ci_build_permissions_model.md
+++ b/doc/user/project/new_ci_build_permissions_model.md
@@ -89,7 +89,7 @@ to steal the tokens of other jobs.
## Pipeline triggers
-Since 9.0 [pipelnie triggers][triggers] do support the new permission model.
+Since 9.0 [pipeline triggers][triggers] do support the new permission model.
The new triggers do impersonate their associated user including their access
to projects and their project permissions. To migrate trigger to use new permisison
model use **Take ownership**.
@@ -100,7 +100,7 @@ In versions before GitLab 8.12, all CI jobs would use the CI Runner's token
to checkout project sources.
The project's Runner's token was a token that you could find under the
-project's **Settings > CI/CD Pipelines** and was limited to access only that
+project's **Settings > Pipelines** and was limited to access only that
project.
It could be used for registering new specific Runners assigned to the project
and to checkout project sources.
@@ -212,9 +212,9 @@ Container Registries for private projects.
access token created explicitly for this purpose). This issue is resolved with
latest changes in GitLab Runner 1.8 which receives GitLab credentials with
build data.
-- Starting with GitLab 8.12, if you have 2FA enabled in your account, you need
- to pass a personal access token instead of your password in order to login to
- GitLab's Container Registry.
+- Starting from GitLab 8.12, if you have [2FA] enabled in your account, you need
+ to pass a [personal access token][pat] instead of your password in order to
+ login to GitLab's Container Registry.
Your jobs can access all container images that you would normally have access
to. The only implication is that you can push to the Container Registry of the
@@ -239,3 +239,5 @@ test:
[update-docs]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update
[workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse
[jobenv]: ../../ci/variables/README.md#predefined-variables-environment-variables
+[2fa]: ../profile/account/two_factor_authentication.md
+[pat]: ../profile/personal_access_tokens.md
diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md
index 50767095aa0..bd0cb437924 100644
--- a/doc/user/project/pages/getting_started_part_four.md
+++ b/doc/user/project/pages/getting_started_part_four.md
@@ -1,8 +1,9 @@
# GitLab Pages from A to Z: Part 4
-> **Type**: user guide ||
+> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
> **Level**: intermediate ||
-> **Author**: [Marcia Ramos](https://gitlab.com/marcia)
+> **Author**: [Marcia Ramos](https://gitlab.com/marcia) ||
+> **Publication date:** 2017/02/22
- [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md)
- [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md)
diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md
index e92549aa0df..46fa4378fe7 100644
--- a/doc/user/project/pages/getting_started_part_one.md
+++ b/doc/user/project/pages/getting_started_part_one.md
@@ -1,15 +1,16 @@
# GitLab Pages from A to Z: Part 1
-> **Type**: user guide ||
+> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
> **Level**: beginner ||
-> **Author**: [Marcia Ramos](https://gitlab.com/marcia)
+> **Author**: [Marcia Ramos](https://gitlab.com/marcia) ||
+> **Publication date:** 2017/02/22
- **Part 1: Static sites and GitLab Pages domains**
- [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md)
- [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md)
- [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md)
-## GitLab Pages form A to Z
+## GitLab Pages from A to Z
This is a comprehensive guide, made for those who want to
publish a website with GitLab Pages but aren't familiar with
@@ -40,7 +41,7 @@ server up and running for your GitLab instance.
Before we begin, let's understand a few concepts first.
-### Static sites
+## Static sites
GitLab Pages only supports static websites, meaning,
your output files must be HTML, CSS, and JavaScript only.
@@ -50,14 +51,14 @@ CSS, and JS, or use a [Static Site Generator (SSG)](https://www.staticgen.com/)
to simplify your code and build the static site for you,
which is highly recommendable and much faster than hardcoding.
-#### Further Reading
+### Further reading
- Read through this technical overview on [Static versus Dynamic Websites](https://about.gitlab.com/2016/06/03/ssg-overview-gitlab-pages-part-1-dynamic-x-static/)
- Understand [how modern Static Site Generators work](https://about.gitlab.com/2016/06/10/ssg-overview-gitlab-pages-part-2/) and what you can add to your static site
- You can use [any SSG with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/)
- Fork an [example project](https://gitlab.com/pages) to build your website based upon
-### GitLab Pages domain
+## GitLab Pages domain
If you set up a GitLab Pages project on GitLab.com,
it will automatically be accessible under a
@@ -72,9 +73,9 @@ Pages wildcard domain. This guide is valid for any GitLab instance,
you just need to replace Pages wildcard domain on GitLab.com
(`*.gitlab.io`) with your own.
-#### Practical examples
+### Practical examples
-**Project Websites:**
+#### Project Websites
- You created a project called `blog` under your username `john`,
therefore your project URL is `https://gitlab.com/john/blog/`.
@@ -86,16 +87,21 @@ URL is `https://gitlab.com/websites/blog/`. Once you enable
GitLab Pages for this project, the site will live under
`https://websites.gitlab.io/blog/`.
-**User and Group Websites:**
+#### User and Group Websites
- Under your username, `john`, you created a project called
`john.gitlab.io`. Your project URL will be `https://gitlab.com/john/john.gitlab.io`.
Once you enable GitLab Pages for your project, your website
will be published under `https://john.gitlab.io`.
- Under your group `websites`, you created a project called
-`websites.gitlab.io`. your project's URL will be `https://gitlab.com/websites/websites.gitlab.io`. Once you enable GitLab Pages for your project,
+`websites.gitlab.io`. your project's URL will be `https://gitlab.com/websites/websites.gitlab.io`.
+Once you enable GitLab Pages for your project,
your website will be published under `https://websites.gitlab.io`.
+>**Note:**
+GitLab Pages [does **not** support subgroups](../../group/subgroups/index.md#limitations).
+You can only create the highest level group website.
+
**General example:**
- On GitLab.com, a project site will always be available under
diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md
index 80f16e43e20..53fd1786cfa 100644
--- a/doc/user/project/pages/getting_started_part_three.md
+++ b/doc/user/project/pages/getting_started_part_three.md
@@ -1,8 +1,9 @@
# GitLab Pages from A to Z: Part 3
-> **Type**: user guide ||
+> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
> **Level**: beginner ||
-> **Author**: [Marcia Ramos](https://gitlab.com/marcia)
+> **Author**: [Marcia Ramos](https://gitlab.com/marcia) ||
+> **Publication date:** 2017/02/22
- [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md)
- [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md)
diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md
index 578ad13f5df..64de0463dad 100644
--- a/doc/user/project/pages/getting_started_part_two.md
+++ b/doc/user/project/pages/getting_started_part_two.md
@@ -1,8 +1,9 @@
# GitLab Pages from A to Z: Part 2
-> **Type**: user guide ||
+> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
> **Level**: beginner ||
-> **Author**: [Marcia Ramos](https://gitlab.com/marcia)
+> **Author**: [Marcia Ramos](https://gitlab.com/marcia) ||
+> **Publication date:** 2017/02/22
- [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md)
- **Part 2: Quick start guide - Setting up GitLab Pages**
@@ -56,7 +57,7 @@ created for the steps below.
![remove fork relashionship](img/remove_fork_relashionship.png)
-1. Enable Shared Runners for your fork: navigate to your **Project**'s **Settings** > **CI/CD Pipelines**
+1. Enable Shared Runners for your fork: navigate to your **Project**'s **Settings** > **Pipelines**
1. Trigger a build (push a change to any file)
1. As soon as the build passes, your website will have been deployed with GitLab Pages. Your website URL will be available under your **Project**'s **Settings** > **Pages**
diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md
index deaceabb7c5..9ecf7a3a8e7 100644
--- a/doc/user/project/pages/introduction.md
+++ b/doc/user/project/pages/introduction.md
@@ -398,6 +398,9 @@ don't redirect HTTP to HTTPS.
[rfc]: https://tools.ietf.org/html/rfc2818#section-3.1 "HTTP Over TLS RFC"
+GitLab Pages [does **not** support subgroups](../../group/subgroups/index.md#limitations).
+You can only create the highest level group website.
+
## Redirects in GitLab Pages
Since you cannot use any custom server configuration files, like `.htaccess` or
diff --git a/doc/user/project/pipelines/img/pipeline_schedule_variables.png b/doc/user/project/pipelines/img/pipeline_schedule_variables.png
new file mode 100644
index 00000000000..47a0c6f3697
--- /dev/null
+++ b/doc/user/project/pipelines/img/pipeline_schedule_variables.png
Binary files differ
diff --git a/doc/user/project/pipelines/img/pipeline_schedules_list.png b/doc/user/project/pipelines/img/pipeline_schedules_list.png
new file mode 100644
index 00000000000..50d9d184b05
--- /dev/null
+++ b/doc/user/project/pipelines/img/pipeline_schedules_list.png
Binary files differ
diff --git a/doc/user/project/pipelines/img/pipeline_schedules_new_form.png b/doc/user/project/pipelines/img/pipeline_schedules_new_form.png
new file mode 100644
index 00000000000..5a0e5965992
--- /dev/null
+++ b/doc/user/project/pipelines/img/pipeline_schedules_new_form.png
Binary files differ
diff --git a/doc/user/project/pipelines/img/pipeline_schedules_ownership.png b/doc/user/project/pipelines/img/pipeline_schedules_ownership.png
new file mode 100644
index 00000000000..31ed83abb4d
--- /dev/null
+++ b/doc/user/project/pipelines/img/pipeline_schedules_ownership.png
Binary files differ
diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md
index 5ce99843301..e853bfff444 100644
--- a/doc/user/project/pipelines/job_artifacts.md
+++ b/doc/user/project/pipelines/job_artifacts.md
@@ -12,7 +12,7 @@
to GitLab using GitLab Runner version 1.0 and up. It will not be possible to
browse old artifacts already uploaded to GitLab.
>- This is the user documentation. For the administration guide see
- [administration/job_artifacts.md](../../../administration/job_artifacts.md).
+ [administration/job_artifacts](../../../administration/job_artifacts.md).
Artifacts is a list of files and directories which are attached to a job
after it completes successfully. This feature is enabled by default in all
@@ -29,21 +29,31 @@ pdf:
artifacts:
paths:
- mycv.pdf
+ expire_in: 1 week
```
A job named `pdf` calls the `xelatex` command in order to build a pdf file from
the latex source file `mycv.tex`. We then define the `artifacts` paths which in
turn are defined with the `paths` keyword. All paths to files and directories
-are relative to the repository that was cloned during the build.
+are relative to the repository that was cloned during the build. These uploaded
+artifacts will be kept in GitLab for 1 week as defined by the `expire_in`
+definition. You have the option to keep the artifacts from expiring via the
+[web interface](#browsing-job-artifacts). If you don't define an expiry date,
+the artifacts will be kept forever.
-For more examples on artifacts, follow the artifacts reference in
-[`.gitlab-ci.yml` documentation](../../../ci/yaml/README.md#artifacts).
+For more examples on artifacts, follow the [artifacts reference in
+`.gitlab-ci.yml`](../../../ci/yaml/README.md#artifacts).
## Browsing job artifacts
-After a job finishes, if you visit the job's specific page, you can see
-that there are two buttons. One is for downloading the artifacts archive and
-the other for browsing its contents.
+>**Note:**
+With GitLab 9.2, PDFs, images, videos and other formats can be previewed
+directly in the job artifacts browser without the need to download them.
+
+After a job finishes, if you visit the job's specific page, there are three
+buttons. You can download the artifacts archive or browse its contents, whereas
+the **Keep** button appears only if you have set an [expiry date] to the
+artifacts in case you changed your mind and want to keep them.
![Job artifacts browser button](img/job_artifacts_browser_button.png)
@@ -99,7 +109,7 @@ https://example.com/<namespace>/<project>/builds/artifacts/<ref>/download?job=<j
To download a single file from the artifacts use the following URL:
```
-https://example.com/<namespace>/<project>/builds/artifacts/<ref>/file/<path_to_file>?job=<job_name>
+https://example.com/<namespace>/<project>/builds/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
```
For example, to download the latest artifacts of the job named `coverage` of
@@ -114,7 +124,7 @@ To download the file `coverage/index.html` from the same
artifacts use the following URL:
```
-https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/file/coverage/index.html?job=coverage
+https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/raw/coverage/index.html?job=coverage
```
There is also a URL to browse the latest job artifacts:
@@ -141,3 +151,5 @@ information in the UI.
![Latest artifacts button](img/job_latest_artifacts_browser.png)
+
+[expiry date]: ../../../ci/yaml/README.md#artifacts-expire_in
diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md
new file mode 100644
index 00000000000..9ad15a12c3c
--- /dev/null
+++ b/doc/user/project/pipelines/schedules.md
@@ -0,0 +1,93 @@
+# Pipeline Schedules
+
+> **Notes**:
+- This feature was introduced in 9.1 as [Trigger Schedule][ce-10533].
+- In 9.2, the feature was [renamed to Pipeline Schedule][ce-10853].
+- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler).
+
+Pipeline schedules can be used to run pipelines only once, or for example every
+month on the 22nd for a certain branch.
+
+## Using Pipeline schedules
+
+In order to schedule a pipeline:
+
+1. Navigate to your project's **Pipelines âž” Schedules** and click the
+ **New Schedule** button.
+1. Fill in the form
+1. Hit **Save pipeline schedule** for the changes to take effect.
+
+![New Schedule Form](img/pipeline_schedules_new_form.png)
+
+>**Attention:**
+The pipelines won't be executed precisely, because schedules are handled by
+Sidekiq, which runs according to its interval.
+See [advanced admin configuration](#advanced-admin-configuration) for more
+information.
+
+In the **Schedules** index page you can see a list of the pipelines that are
+scheduled to run. The next run is automatically calculated by the server GitLab
+is installed on.
+
+![Schedules list](img/pipeline_schedules_list.png)
+
+### Making use of scheduled pipeline variables
+
+> [Introduced][ce-12328] in GitLab 9.4.
+
+You can pass any number of arbitrary variables and they will be available in
+GitLab CI so that they can be used in your `.gitlab-ci.yml` file.
+
+![Scheduled pipeline variables](img/pipeline_schedule_variables.png)
+
+## Using only and except
+
+To configure that a job can be executed only when the pipeline has been
+scheduled (or the opposite), you can use
+[only and except](../../../ci/yaml/README.md#only-and-except) configuration keywords.
+
+```
+job:on-schedule:
+ only:
+ - schedules
+ script:
+ - make world
+
+job:
+ except:
+ - schedules
+ script:
+ - make build
+```
+
+## Taking ownership
+
+Pipelines are executed as a user, who owns a schedule. This influences what
+projects and other resources the pipeline has access to. If a user does not own
+a pipeline, you can take ownership by clicking the **Take ownership** button.
+The next time a pipeline is scheduled, your credentials will be used.
+
+![Schedules list](img/pipeline_schedules_ownership.png)
+
+>**Note:**
+When the owner of the schedule doesn't have the ability to create pipelines
+anymore, due to e.g., being blocked or removed from the project, or lacking
+the permission to run on protected branches or tags. When this happened, the
+schedule is deactivated. Another user can take ownership and activate it, so
+the schedule can be run again.
+
+## Advanced admin configuration
+
+The pipelines won't be executed precisely, because schedules are handled by
+Sidekiq, which runs according to its interval. For example, if you set a
+schedule to create a pipeline every minute (`* * * * *`) and the Sidekiq worker
+runs on 00:00 and 12:00 every day (`0 */12 * * *`), only 2 pipelines will be
+created per day. To change the Sidekiq worker's frequency, you have to edit the
+`pipeline_schedule_worker_cron` value in your `gitlab.rb` and restart GitLab.
+For GitLab.com, you can check the [dedicated settings page][settings]. If you
+don't have admin access to the server, ask your administrator.
+
+[ce-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
+[ce-10853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10853
+[ce-12328]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12328
+[settings]: https://about.gitlab.com/gitlab-com/settings/#cron-jobs
diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md
index 88246e22391..3ff5a08d72c 100644
--- a/doc/user/project/pipelines/settings.md
+++ b/doc/user/project/pipelines/settings.md
@@ -1,12 +1,7 @@
-# CI/CD pipelines settings
+# Pipelines settings
-To reach the pipelines settings:
-
-1. Navigate to your project and click the cog icon in the upper right corner.
-
- ![Project settings menu](../img/project_settings_list.png)
-
-1. Select **CI/CD Pipelines** from the menu.
+To reach the pipelines settings navigate to your project's
+**Settings âž” Pipelines**.
The following settings can be configured per project.
@@ -32,6 +27,22 @@ The default value is 60 minutes. Decrease the time limit if you want to impose
a hard limit on your jobs' running time or increase it otherwise. In any case,
if the job surpasses the threshold, it is marked as failed.
+## Custom CI config path
+
+> - [Introduced][ce-12509] in GitLab 9.4.
+
+By default we look for the `.gitlab-ci.yml` file in the project's root
+directory. If you require a different location **within** the repository,
+you can set a custom filepath that will be used to lookup the config file,
+this filepath should be **relative** to the root.
+
+Here are some valid examples:
+
+> * .gitlab-ci.yml
+> * .my-custom-file.yml
+> * my/path/.gitlab-ci.yml
+> * my/path/.my-custom-file.yml
+
## Test coverage parsing
If you use test coverage in your code, GitLab can capture its output in the
@@ -64,8 +75,8 @@ pipelines** checkbox and save the changes.
> [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),
+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
@@ -120,3 +131,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
+[ce-12509]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12509
diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md
index f7a686d2ccf..0570d9f471f 100644
--- a/doc/user/project/protected_branches.md
+++ b/doc/user/project/protected_branches.md
@@ -27,11 +27,8 @@ See the [Changelog](#changelog) section for changes over time.
To protect a branch, you need to have at least Master permission level. Note
that the `master` branch is protected by default.
-1. Navigate to the main page of the project.
-1. In the upper right corner, click the settings wheel and select **Protected branches**.
-
- ![Project settings list](img/project_settings_list.png)
-
+1. Navigate to your project's **Settings âž” Repository**
+1. Scroll to find the **Protected branches** section.
1. From the **Branch** dropdown menu, select the branch you want to protect and
click **Protect**. In the screenshot below, we chose the `develop` branch.
@@ -97,8 +94,33 @@ all matching branches:
![Protected branch matches](img/protected_branches_matches.png)
+## Deleting a protected branch
+
+> [Introduced][ce-21393] in GitLab 9.3.
+
+From time to time, it may be required to delete or clean up branches that are
+protected.
+
+User with [Master permissions][perm] and up can manually delete protected
+branches via GitLab's web interface:
+
+1. Visit **Repository > Branches**
+1. Click on the delete icon next to the branch you wish to delete
+1. In order to prevent accidental deletion, an additional confirmation is
+ required
+
+ ![Delete protected branches](img/protected_branches_delete.png)
+
+Deleting a protected branch is only allowed via the web interface, not via Git.
+This means that you can't accidentally delete a protected branch from your
+command line or a Git client application.
+
## Changelog
+**9.2**
+
+- Allow deletion of protected branches via the web interface [gitlab-org/gitlab-ce#21393][ce-21393]
+
**8.11**
- Allow creating protected branches that can't be pushed to [gitlab-org/gitlab-ce!5081][ce-5081]
@@ -113,4 +135,6 @@ all matching branches:
[ce-4665]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4665 "Allow specifying protected branches using wildcards"
[ce-4892]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4892 "Allow developers to merge into a protected branch without having push access"
[ce-5081]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081 "Allow creating protected branches that can't be pushed to"
+[ce-21393]: https://gitlab.com/gitlab-org/gitlab-ce/issues/21393
[ee-restrict]: http://docs.gitlab.com/ee/user/project/protected_branches.html#restricting-push-and-merge-access-to-certain-users
+[perm]: ../permissions.md
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
new file mode 100644
index 00000000000..ce4dd4e99d5
--- /dev/null
+++ b/doc/user/project/quick_actions.md
@@ -0,0 +1,40 @@
+# GitLab quick actions
+
+Quick actions are textual shortcuts for common actions on issues or merge
+requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
+You can enter these commands while creating a new issue or merge request, and
+in comments. Each command should be on a separate line in order to be properly
+detected and executed. The commands are removed from the issue, merge request or
+comment body before it is saved and will not be visible to anyone else.
+
+Below is a list of all of the available commands and descriptions about what they
+do.
+
+| Command | Action |
+|:---------------------------|:-------------|
+| `/close` | Close the issue or merge request |
+| `/reopen` | Reopen the issue or merge request |
+| `/merge` | Merge (when pipeline succeeds) |
+| `/title <New title>` | Change title |
+| `/assign @username` | Assign |
+| `/unassign` | Remove assignee |
+| `/milestone %milestone` | Set milestone |
+| `/remove_milestone` | Remove milestone |
+| `/label ~foo ~"bar baz"` | Add label(s) |
+| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) |
+| `/relabel ~foo ~"bar baz"` | Replace all label(s) |
+| `/todo` | Add a todo |
+| `/done` | Mark todo as done |
+| `/subscribe` | Subscribe |
+| `/unsubscribe` | Unsubscribe |
+| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
+| `/remove_due_date` | Remove due date |
+| `/wip` | Toggle the Work In Progress status |
+| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
+| `/remove_estimate` | Remove estimated time |
+| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or subtract spent time |
+| `/remove_time_spent` | Remove time spent |
+| `/target_branch <Branch Name>` | Set target branch for current merge request |
+| `/award :emoji:` | Toggle award for :emoji: |
+| `/board_move ~column` | Move issue to column on the board |
+| `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue |
diff --git a/doc/user/project/repository/branches/img/delete_merged_branches.png b/doc/user/project/repository/branches/img/delete_merged_branches.png
new file mode 100644
index 00000000000..1856a624f74
--- /dev/null
+++ b/doc/user/project/repository/branches/img/delete_merged_branches.png
Binary files differ
diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md
new file mode 100644
index 00000000000..1948627ee79
--- /dev/null
+++ b/doc/user/project/repository/branches/index.md
@@ -0,0 +1,17 @@
+# Branches
+
+## Delete merged branches
+
+> [Introduced][ce-6449] in GitLab 8.14.
+
+![Delete merged branches](img/delete_merged_branches.png)
+
+This feature allows merged branches to be deleted in bulk. Only branches that
+have been merged and [are not protected][protected] will be deleted as part of
+this operation.
+
+It's particularly useful to clean up old branches that were not deleting
+automatically when a merge request was merged.
+
+[ce-6449]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6449 "Add button to delete all merged branches"
+[protected]: ../../protected_branches.md
diff --git a/doc/user/project/repository/img/compare_branches.png b/doc/user/project/repository/img/compare_branches.png
new file mode 100755
index 00000000000..353bd72ef4e
--- /dev/null
+++ b/doc/user/project/repository/img/compare_branches.png
Binary files differ
diff --git a/doc/user/project/repository/img/contributors_graph.png b/doc/user/project/repository/img/contributors_graph.png
new file mode 100755
index 00000000000..c31da7aa1ff
--- /dev/null
+++ b/doc/user/project/repository/img/contributors_graph.png
Binary files differ
diff --git a/doc/user/project/repository/img/repo_graph.png b/doc/user/project/repository/img/repo_graph.png
new file mode 100755
index 00000000000..28da8ad9589
--- /dev/null
+++ b/doc/user/project/repository/img/repo_graph.png
Binary files differ
diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md
new file mode 100644
index 00000000000..5e5ae880518
--- /dev/null
+++ b/doc/user/project/repository/index.md
@@ -0,0 +1,156 @@
+# Repository
+
+A [repository](https://git-scm.com/book/en/v2/Git-Basics-Getting-a-Git-Repository)
+is what you use to store your codebase in GitLab and change it with version control.
+A repository is part of a [project](../index.md), which has a lot of other features.
+
+## Create a repository
+
+To create a new repository, all you need to do is
+[create a new project](../../../gitlab-basics/create-project.md).
+
+Once you create a new project, you can add new files via UI
+(read the section below) or via command line.
+To add files from the command line, follow the instructions that will
+be presented on the screen when you create a new project, or read
+through them in the [command line basics](../../../gitlab-basics/start-using-git.md)
+documentation.
+
+> **Important:**
+For security reasons, when using the command line, we strongly recommend
+you to [connect with GitLab via SSH](../../../ssh/README.md).
+
+## Files
+
+## Create and edit files
+
+Host your codebase in GitLab repositories by pushing your files to GitLab.
+You can either use the user interface (UI), or connect your local computer
+with GitLab [through the command line](../../../gitlab-basics/command-line-commands.md#start-working-on-your-project).
+
+To configure [GitLab CI/CD](../../../ci/README.md) to build, test, and deploy
+you code, add a file called [.`gitlab-ci.yml`](../../../ci/quick_start/README.md)
+to your repository's root.
+
+**From the user interface:**
+
+GitLab's UI allows you to perform lots of Git commands without having to
+touch the command line. Even if you use the command line regularly, sometimes
+it's easier to do so [via GitLab UI](web_editor.md):
+
+- [Create a file](web_editor.md#create-a-file)
+- [Upload a file](web_editor.md#upload-a-file)
+- [File templates](web_editor.md#template-dropdowns)
+- [Create a directory](web_editor.md#create-a-directory)
+- [Start a merge request](web_editor.md#tips)
+
+**From the command line:**
+
+To get started with the command line, please read through the
+[command line basics documentation](../../../gitlab-basics/command-line-commands.md).
+
+### Find files
+
+Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository.
+
+## Branches
+
+When you submit changes in a new branch, you create a new version
+of that project's file tree. Your branch contains all the changes
+you are presenting, which are detected by Git line by line.
+
+To continue your workflow, once you pushed your changes to a new branch,
+you can create a [merge request](../merge_requests/index.md), perform
+inline code review, and [discuss](../../discussions/index.md)
+your implementation with your team.
+You can live preview changes submitted to a new branch with
+[Review Apps](../../../ci/review_apps/index.md).
+
+With [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/)
+subscriptions, you can also request
+[approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals) from your managers.
+
+To create, delete, and branches via GitLab's UI:
+
+- [Create a branch](web_editor.md#create-a-new-branch)
+- [Protected branches](../protected_branches.md#protected-branches)
+- [Delete merged branches](branches/index.md#delete-merged-branches)
+
+Alternatively, you can use the
+[command line](../../../gitlab-basics/start-using-git.md#create-a-branch).
+
+To learn more about branching strategies read through the
+[GitLab Flow](../../../university/training/gitlab_flow.md) documentation.
+
+## Commits
+
+When you [commit your changes](https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository),
+you are introducing those changes to your branch.
+Via command line, you can commit multiple times before pushing.
+
+- **Commit message:**
+A commit message is important to identity what is being changed and,
+more importantly, why. In GitLab, you can add keywords to the commit
+message that will perform one of the actions below:
+ - **Trigger a GitLab CI/CD pipeline:**
+ If you have your project configured with [GitLab CI/CD](../../../ci/README.md),
+ you will trigger a pipeline per push, not per commit.
+ - **Skip pipelines:**
+ You can add to you commit message the keyword
+ [`[ci skip]`](../../../ci/yaml/README.html#skipping-jobs)
+ and GitLab CI will skip that pipeline.
+ - **Cross-link issues and merge requests:**
+ [Cross-linking](../issues/crosslinking_issues.md#from-commit-messages)
+ is great to keep track of what's is somehow related in your workflow.
+ If you mention an issue or a merge request in a commit message, they will be shown
+ on their respective thread.
+- **Cherry-pick a commit:**
+In GitLab, you can
+[cherry-pick a commit](../merge_requests/cherry_pick_changes.md#cherry-picking-a-commit)
+right from the UI.
+- **Revert a commit:**
+Easily [revert a commit](../merge_requests/revert_changes.md#reverting-a-commit)
+from the UI to a selected branch.
+
+## Repository size
+
+In GitLab.com, your repository size limit it 10GB. For other instances,
+the repository size is limited by your system administrators.
+
+You can also [reduce a repository size using Git](reducing_the_repo_size_using_git.md).
+
+## Contributors
+
+All the contributors to your codebase are displayed under your project's **Settings > Contributors**.
+
+They are ordered from the collaborator with the greatest number
+of commits to the fewest, and displayed on a nice graph:
+
+![contributors to code](img/contributors_graph.png)
+
+## Repository graph
+
+The repository graph displays visually the Git flow strategy used in that repository:
+
+![repository Git flow](img/repo_graph.png)
+
+Find it under your project's **Repository > Graph**.
+
+## Compare
+
+Select branches to compare and view the changes inline:
+
+![compare branches](img/compare_branches.png)
+
+Find it under your project's **Repository > Compare**.
+
+## Locked files (EEP)
+
+Lock your files to prevent any conflicting changes.
+
+[File Locking](https://docs.gitlab.com/ee/user/project/file_lock.html) is available only in
+[GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/).
+
+## Repository's API
+
+You can access your repos via [repository API](../../../api/repositories.md).
diff --git a/doc/user/project/repository/reducing_the_repo_size_using_git.md b/doc/user/project/repository/reducing_the_repo_size_using_git.md
new file mode 100644
index 00000000000..08805a4dc99
--- /dev/null
+++ b/doc/user/project/repository/reducing_the_repo_size_using_git.md
@@ -0,0 +1,81 @@
+# Reducing the repository size using Git
+
+A GitLab Entrerprise Edition administrator can set a [repository size limit][admin-repo-size]
+which will prevent you to exceed it.
+
+When a project has reached its size limit, you will not be able to push to it,
+create a new merge request, or merge existing ones. You will still be able to
+create new issues, and clone the project though. Uploading LFS objects will
+also be denied.
+
+In order to lift these restrictions, the administrator of the GitLab instance
+needs to increase the limit on the particular project that exceeded it or you
+need to instruct Git to rewrite changes.
+
+If you exceed the repository size limit, your first thought might be to remove
+some data, make a new commit and push back to the repository. Unfortunately,
+it's not so easy and that workflow won't work. Deleting files in a commit doesn't
+actually reduce the size of the repo since the earlier commits and blobs are
+still around. What you need to do is rewrite history with Git's
+[`filter-branch` option][gitscm].
+
+Note that even with that method, until `git gc` runs on the GitLab side, the
+"removed" commits and blobs will still be around. And if a commit was ever
+included in an MR, or if a build was run for a commit, or if a user commented
+on it, it will be kept around too. So, in these cases the size will not decrease.
+
+The only fool proof way to actually decrease the repository size is to prune all
+the unneeded stuff locally, and then create a new project on GitLab and start
+using that instead.
+
+With that being said, you can try reducing your repository size with the
+following method.
+
+## Using `git filter-branch` to purge files
+
+>
+**Warning:**
+Make sure to first make a copy of your repository since rewriting history will
+purge the files and information you are about to delete. Also make sure to
+inform any collaborators to not use `pull` after your changes, but use `rebase`.
+
+1. Navigate to your repository:
+
+ ```
+ cd my_repository/
+ ```
+
+1. Change to the branch you want to remove the big file from:
+
+ ```
+ git checkout master
+ ```
+
+1. Use `filter-branch` to remove the big file:
+
+ ```
+ git filter-branch --force --tree-filter 'rm -f path/to/big_file.mpg' HEAD
+ ```
+
+1. Instruct Git to purge the unwanted data:
+
+ ```
+ git reflog expire --expire=now --all && git gc --prune=now --aggressive
+ ```
+
+1. Lastly, force push to the repository:
+
+ ```
+ git push --force origin master
+ ```
+
+Your repository should now be below the size limit.
+
+>**Note:**
+As an alternative to `filter-branch`, you can use the `bfg` tool with a
+command like: `bfg --delete-files path/to/big_file.mpg`. Read the
+[BFG Repo-Cleaner][bfg] documentation for more information.
+
+[admin-repo-size]: https://docs.gitlab.com/ee/user/admin_area/settings/account_and_limit_settings.html#repository-size-limit
+[bfg]: https://rtyley.github.io/bfg-repo-cleaner/
+[gitscm]: https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History#The-Nuclear-Option:-filter-branch
diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md
index 7a4f9f408f1..97cca3007b1 100644
--- a/doc/user/project/settings/import_export.md
+++ b/doc/user/project/settings/import_export.md
@@ -9,6 +9,9 @@
> application settings (`/admin/application_settings`) under 'Import sources'.
> Ask your administrator if you don't see the **GitLab export** button when
> creating a new project.
+> - Starting with GitLab 10.0, administrators can disable the project export option
+> on the GitLab instance in application settings (`/admin/application_settings`)
+> under 'Visibility and Access Controls'.
> - You can find some useful raketasks if you are an administrator in the
> [import_export](../../../administration/raketasks/project_import_export.md)
> raketask.
@@ -27,13 +30,15 @@ with all their related data and be moved into a new GitLab instance.
| GitLab version | Import/Export version |
| -------- | -------- |
-| 8.17.0 to current | 0.1.6 |
-| 8.13.0 | 0.1.5 |
-| 8.12.0 | 0.1.4 |
-| 8.10.3 | 0.1.3 |
-| 8.10.0 | 0.1.2 |
-| 8.9.5 | 0.1.1 |
-| 8.9.0 | 0.1.0 |
+| 9.4.0 to current | 0.1.8 |
+| 9.2.0 | 0.1.7 |
+| 8.17.0 | 0.1.6 |
+| 8.13.0 | 0.1.5 |
+| 8.12.0 | 0.1.4 |
+| 8.10.3 | 0.1.3 |
+| 8.10.0 | 0.1.2 |
+| 8.9.5 | 0.1.1 |
+| 8.9.0 | 0.1.0 |
> The table reflects what GitLab version we updated the Import/Export version at.
> For instance, 8.10.3 and 8.11 will have the same Import/Export version (0.1.3)
diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md
index 45176fde9db..e9103a3f49c 100644
--- a/doc/user/project/slash_commands.md
+++ b/doc/user/project/slash_commands.md
@@ -1,38 +1 @@
-# GitLab slash commands
-
-Slash commands are textual shortcuts for common actions on issues or merge
-requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
-You can enter these commands while creating a new issue or merge request, and
-in comments. Each command should be on a separate line in order to be properly
-detected and executed. The commands are removed from the issue, merge request or
-comment body before it is saved and will not be visible to anyone else.
-
-Below is a list of all of the available commands and descriptions about what they
-do.
-
-| Command | Action |
-|:---------------------------|:-------------|
-| `/close` | Close the issue or merge request |
-| `/reopen` | Reopen the issue or merge request |
-| `/merge` | Merge (when pipeline succeeds) |
-| `/title <New title>` | Change title |
-| `/assign @username` | Assign |
-| `/unassign` | Remove assignee |
-| `/milestone %milestone` | Set milestone |
-| `/remove_milestone` | Remove milestone |
-| `/label ~foo ~"bar baz"` | Add label(s) |
-| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) |
-| `/relabel ~foo ~"bar baz"` | Replace all label(s) |
-| `/todo` | Add a todo |
-| `/done` | Mark todo as done |
-| `/subscribe` | Subscribe |
-| `/unsubscribe` | Unsubscribe |
-| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
-| `/remove_due_date` | Remove due date |
-| `/wip` | Toggle the Work In Progress status |
-| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
-| `/remove_estimate` | Remove estimated time |
-| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or subtract spent time |
-| `/remove_time_spent` | Remove time spent |
-| `/target_branch <Branch Name>` | Set target branch for current merge request |
-| `/award :emoji:` | Toggle award for :emoji: |
+This document was moved to [user/project/quick_actions.md](quick_actions.md).
diff --git a/doc/user/project/wiki/img/wiki_create_home_page.png b/doc/user/project/wiki/img/wiki_create_home_page.png
new file mode 100644
index 00000000000..f50f564034c
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_create_home_page.png
Binary files differ
diff --git a/doc/user/project/wiki/img/wiki_create_new_page.png b/doc/user/project/wiki/img/wiki_create_new_page.png
new file mode 100644
index 00000000000..c19124a8923
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_create_new_page.png
Binary files differ
diff --git a/doc/user/project/wiki/img/wiki_create_new_page_modal.png b/doc/user/project/wiki/img/wiki_create_new_page_modal.png
new file mode 100644
index 00000000000..ece437967dc
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_create_new_page_modal.png
Binary files differ
diff --git a/doc/user/project/wiki/img/wiki_page_history.png b/doc/user/project/wiki/img/wiki_page_history.png
new file mode 100644
index 00000000000..0e6af1b468d
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_page_history.png
Binary files differ
diff --git a/doc/user/project/wiki/img/wiki_sidebar.png b/doc/user/project/wiki/img/wiki_sidebar.png
new file mode 100644
index 00000000000..59814e2a06e
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_sidebar.png
Binary files differ
diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md
new file mode 100644
index 00000000000..e9ee1abc6c1
--- /dev/null
+++ b/doc/user/project/wiki/index.md
@@ -0,0 +1,97 @@
+# Wiki
+
+A separate system for documentation called Wiki, is built right into each
+GitLab project. It is enabled by default on all new projects and you can find
+it under **Wiki** in your project.
+
+Wikis are very convenient if you don't want to keep you documentation in your
+repository, but you do want to keep it in the same project where your code
+resides.
+
+You can create Wiki pages in the web interface or
+[locally using Git](#adding-and-editing-wiki-pages-locally) since every Wiki is
+a separate Git repository.
+
+>**Note:**
+A [permission level][permissions] of **Guest** is needed to view a Wiki and
+**Developer** is needed to create and edit Wiki pages.
+
+## First time creating the Home page
+
+The first time you visit a Wiki, you will be directed to create the Home page.
+The Home page is necessary to be created since it serves as the landing page
+when viewing a Wiki. You only have to fill in the **Content** section and click
+**Create page**. You can always edit it later, so go ahead and write a welcome
+message.
+
+![New home page](img/wiki_create_home_page.png)
+
+## Creating a new wiki page
+
+Create a new page by clicking the **New page** button that can be found
+in all wiki pages. You will be asked to fill in the page name from which GitLab
+will create the path to the page. You can specify a full path for the new file
+and any missing directories will be created automatically.
+
+![New page modal](img/wiki_create_new_page_modal.png)
+
+Once you enter the page name, it's time to fill in its content. GitLab wikis
+support Markdown, RDoc and AsciiDoc. For Markdown based pages, all the
+[Markdown features](../../markdown.md) are supported and for links there is
+some [wiki specific](../../markdown.md#wiki-specific-markdown) behavior.
+
+>**Note:**
+The wiki is based on a Git repository and contains only text files. Uploading
+files via the web interface will upload them in GitLab itself, and they will
+not be available if you clone the wiki repo locally.
+
+In the web interface the commit message is optional, but the GitLab Wiki is
+based on Git and needs a commit message, so one will be created for you if you
+do not enter one.
+
+When you're ready, click the **Create page** and the new page will be created.
+
+![New page](img/wiki_create_new_page.png)
+
+## Editing a wiki page
+
+To edit a page, simply click on the **Edit** button. From there on, you can
+change its content. When done, click **Save changes** for the changes to take
+effect.
+
+## Deleting a wiki page
+
+You can find the **Delete** button only when editing a page. Click on it and
+confirm you want the page to be deleted.
+
+## Viewing a list of all created wiki pages
+
+Every wiki has a sidebar from which a short list of the created pages can be
+found. The list is ordered alphabetically.
+
+![Wiki sidebar](img/wiki_sidebar.png)
+
+If you have many pages, not all will be listed in the sidebar. Click on
+**More pages** to see all of them.
+
+## Viewing the history of a wiki page
+
+The changes of a wiki page over time are recorded in the wiki's Git repository,
+and you can view them by clicking the **Page history** button.
+
+From the history page you can see the revision of the page (Git commit SHA), its
+author, the commit message, when it was last updated and the page markup format.
+To see how a previous version of the page looked like, click on a revision
+number.
+
+![Wiki page history](img/wiki_page_history.png)
+
+## Adding and editing wiki pages locally
+
+Since wikis are based on Git repositories, you can clone them locally and edit
+them like you would do with every other Git repository.
+
+On the right sidebar, click on **Clone repository** and follow the on-screen
+instructions.
+
+[permissions]: ../../permissions.md
diff --git a/doc/user/reserved_names.md b/doc/user/reserved_names.md
new file mode 100644
index 00000000000..50ec99be48b
--- /dev/null
+++ b/doc/user/reserved_names.md
@@ -0,0 +1,109 @@
+# Reserved project and group names
+
+Not all project & group names are allowed because they would conflict with
+existing routes used by GitLab.
+
+For a list of words that are not allowed to be used as group or project names, see the
+[`path_regex.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `PROJECT_WILDCARD_ROUTES` and `GROUP_ROUTES` lists:
+- `TOP_LEVEL_ROUTES`: are names that are reserved as usernames or top level groups
+- `PROJECT_WILDCARD_ROUTES`: are names that are reserved for child groups or projects.
+- `GROUP_ROUTES`: are names that are reserved for all groups or projects.
+
+## Reserved project names
+
+It is currently not possible to create a project with the following names:
+
+- -
+- badges
+- blame
+- blob
+- builds
+- commits
+- create
+- create_dir
+- edit
+- environments/folders
+- files
+- find_file
+- gitlab-lfs/objects
+- info/lfs/objects
+- new
+- preview
+- raw
+- refs
+- tree
+- update
+- wikis
+
+## Reserved group names
+
+Currently the following names are reserved as top level groups:
+
+- 503.html
+- -
+- .well-known
+- 404.html
+- 422.html
+- 500.html
+- 502.html
+- abuse_reports
+- admin
+- api
+- apple-touch-icon-precomposed.png
+- apple-touch-icon.png
+- files
+- assets
+- autocomplete
+- ci
+- dashboard
+- deploy.html
+- explore
+- favicon.ico
+- groups
+- header_logo_dark.png
+- header_logo_light.png
+- health_check
+- help
+- import
+- invites
+- jwt
+- koding
+- notification_settings
+- oauth
+- profile
+- projects
+- public
+- robots.txt
+- s
+- search
+- sent_notifications
+- slash-command-logo.png
+- snippets
+- u
+- unicorn_test
+- unsubscribes
+- uploads
+- users
+
+These group names are unavailable as subgroup names:
+
+- -
+- activity
+- analytics
+- audit_events
+- avatar
+- edit
+- group_members
+- hooks
+- issues
+- labels
+- ldap
+- ldap_group_links
+- merge_requests
+- milestones
+- notification_setting
+- pipeline_quota
+- projects
+- subgroups
+
+[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/path_regex.rb
diff --git a/doc/user/search/img/filter_issues_project.gif b/doc/user/search/img/filter_issues_project.gif
deleted file mode 100644
index d547588be5d..00000000000
--- a/doc/user/search/img/filter_issues_project.gif
+++ /dev/null
Binary files differ
diff --git a/doc/user/search/img/group_issues_filter.png b/doc/user/search/img/group_issues_filter.png
new file mode 100644
index 00000000000..45eced79b99
--- /dev/null
+++ b/doc/user/search/img/group_issues_filter.png
Binary files differ
diff --git a/doc/user/search/img/issue_search_filter.png b/doc/user/search/img/issue_search_filter.png
new file mode 100644
index 00000000000..f357abd6bac
--- /dev/null
+++ b/doc/user/search/img/issue_search_filter.png
Binary files differ
diff --git a/doc/user/search/index.md b/doc/user/search/index.md
index 45f443819ec..79f34fd29ba 100644
--- a/doc/user/search/index.md
+++ b/doc/user/search/index.md
@@ -34,18 +34,30 @@ a project's **Issues** tab, and click on the field **Search or filter results...
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)
+![filter issues in a project](img/issue_search_filter.png)
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.
-## Search History
+## Issues and merge requests per group
+
+Similar to **Issues and merge requests per project**, you can also search for issues
+within a group. Navigate to a group's **Issues** tab and query search results in
+the same way as you do for projects.
+
+![filter issues in a group](img/group_issues_filter.png)
+
+## Search history
You can view recent searches by clicking on the little arrow-clock icon, which is to the left of the search input. Click the search entry to run that search again. This feature is available for issues and merge requests. Searches are stored locally in your browser.
![search history](img/search_history.gif)
+## Removing search filters
+
+Individual filters can be removed by clicking on the filter's (x) button or backspacing. The entire search filter can be cleared by clicking on the search box's (x) button.
+
### Shortcut
You'll also find a shortcut on the search field on the top-right of the project's dashboard to
diff --git a/doc/user/snippets.md b/doc/user/snippets.md
index 417360e08ac..2170b079f62 100644
--- a/doc/user/snippets.md
+++ b/doc/user/snippets.md
@@ -2,11 +2,21 @@
Snippets are little bits of code or text.
+![GitLab Snippet](img/gitlab_snippet.png)
+
There are 2 types of snippets - project snippets and personal snippets.
+## Comments
+
+With GitLab Snippets you engage in a conversation about that piece of code,
+facilitating the collaboration among users.
+
+> **Note:**
+Comments on snippets was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/12910) in [GitLab Community Edition 9.2](https://about.gitlab.com/2017/05/22/gitlab-9-2-released/#comments-for-personal-snippets).
+
## Project snippets
-Project snippets are always related to a specific project - see [Project features](../workflow/project_features.md) for more information.
+Project snippets are always related to a specific project - see [Project's features](project/index.md#project-39-s-features) for more information.
## Personal snippets
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 604c7d5cefb..673e08287a3 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -6,7 +6,7 @@
- [Description templates](../user/project/description_templates.md)
- [Feature branch workflow](workflow.md)
- [GitLab Flow](gitlab_flow.md)
-- [Groups](groups.md)
+- [Groups](../user/group/index.md)
- Issues - The GitLab Issue Tracker is an advanced and complete tool for
tracking the evolution of a new idea or the process of solving a problem.
- [Confidential issues](../user/project/issues/confidential_issues.md)
@@ -16,14 +16,13 @@
- [File finder](file_finder.md)
- [Labels](../user/project/labels.md)
- [Notification emails](notifications.md)
-- [Project Features](project_features.md)
+- [Projects](../user/project/index.md)
- [Project forking workflow](forking_workflow.md)
-- [Project users](add-user/add-user.md)
+- [Project users](../user/project/members/index.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)
+- [Quick Actions](../user/project/quick_actions.md)
+- [Sharing projects with groups](../user/project/members/share_project_with_groups.md)
- [Time tracking](time_tracking.md)
- [Web Editor](../user/project/repository/web_editor.md)
- [Releases](releases.md)
diff --git a/doc/workflow/add-user/add-user.md b/doc/workflow/add-user/add-user.md
index e541111d7b3..35cc080d2b7 100644
--- a/doc/workflow/add-user/add-user.md
+++ b/doc/workflow/add-user/add-user.md
@@ -1,114 +1 @@
-# Project users
-
-You can manage the groups and users and their access levels in all of your
-projects. You can also personalize the access level you give each user,
-per-project.
-
-You should have `master` or `owner` permissions to add or import a new user
-to your project.
-
-The first step to add or import a user, go to your project and click on
-**Members** in the drop-down menu on the right side of your screen.
-
-![Members](img/add_user_members_menu.png)
-
----
-
-## Add a user
-
-Right next to **People**, start typing the name or username of the user you
-want to add.
-
-![Search for people](img/add_user_search_people.png)
-
----
-
-Select the user and the [permission level](../../user/permissions.md)
-that you'd like to give the user. Note that you can select more than one user.
-
-![Give user permissions](img/add_user_give_permissions.png)
-
----
-
-Once done, hit **Add users to project** and they will be immediately added to
-your project with the permissions you gave them above.
-
-![List members](img/add_user_list_members.png)
-
----
-
-From there on, you can either remove an existing user or change their access
-level to the project.
-
-## Import users from another project
-
-You can import another project's users in your own project by hitting the
-**Import members** button on the upper right corner of the **Members** menu.
-
-In the dropdown menu, you can see only the projects you are Master on.
-
-![Import members from another project](img/add_user_import_members_from_another_project.png)
-
----
-
-Select the one you want and hit **Import project members**. A flash message
-notifying you that the import was successful will appear, and the new members
-are now in the project's members list. Notice that the permissions that they
-had on the project you imported from are retained.
-
-![Members list of new members](img/add_user_imported_members.png)
-
----
-
-## Invite people using their e-mail address
-
-If a user you want to give access to doesn't have an account on your GitLab
-instance, you can invite them just by typing their e-mail address in the
-user search field.
-
-![Invite user by mail](img/add_user_email_search.png)
-
----
-
-As you can imagine, you can mix inviting multiple people and adding existing
-GitLab users to the project.
-
-![Invite user by mail ready to submit](img/add_user_email_ready.png)
-
----
-
-Once done, hit **Add users to project** and watch that there is a new member
-with the e-mail address we used above. From there on, you can resend the
-invitation, change their access level or even delete them.
-
-![Invite user members list](img/add_user_email_accept.png)
-
----
-
-Once the user accepts the invitation, they will be prompted to create a new
-GitLab account using the same e-mail address the invitation was sent to.
-
-## Request access to a project
-
-As a project owner you can enable or disable non members to request access to
-your project. Go to the project settings and click on **Allow users to request access**.
-
-As a user, you can request to be a member of a project. Go to the project you'd
-like to be a member of, and click the **Request Access** button on the right
-side of your screen.
-
-![Request access button](img/request_access_button.png)
-
----
-
-Project owners & masters will be notified of your request and will be able to approve or
-decline it on the members page.
-
-![Manage access requests](img/access_requests_management.png)
-
----
-
-If you change your mind before your request is approved, just click the
-**Withdraw Access Request** button.
-
-![Withdraw access request button](img/withdraw_access_request_button.png)
+This document was moved to [../../user/project/members/index.md](../../user/project/members/index.md)
diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md
index 1b172b21f3d..9d466ae1971 100644
--- a/doc/workflow/gitlab_flow.md
+++ b/doc/workflow/gitlab_flow.md
@@ -67,7 +67,7 @@ With GitLab flow we offer additional guidance for these questions.
![Master branch and production branch with arrow that indicate deployments](production_branch.png)
GitHub flow does assume you are able to deploy to production every time you merge a feature branch.
-This is possible for SaaS applications but there are many cases where this is not possible.
+This is possible for e.g. SaaS applications, but there are many cases where this is not possible.
One would be a situation where you are not in control of the exact release moment, for example an iOS application that needs to pass App Store validation.
Another example is when you have deployment windows (workdays from 10am to 4pm when the operations team is at full capacity) but you also merge code at other times.
In these cases you can make a production branch that reflects the deployed code.
@@ -91,7 +91,6 @@ This workflow where commits only flow downstream ensures that everything has bee
If you need to cherry-pick a commit with a hotfix it is common to develop it on a feature branch and merge it into master with a merge request, do not delete the feature branch.
If master is good to go (it should be if you are practicing [continuous delivery](http://martinfowler.com/bliki/ContinuousDelivery.html)) you then merge it to the other branches.
If this is not possible because more manual testing is required you can send merge requests from the feature branch to the downstream branches.
-An 'extreme' version of environment branches are setting up an environment for each feature branch as done by [Teatro](https://teatro.io/).
## Release branches with GitLab flow
@@ -134,7 +133,7 @@ If the assigned person does not feel comfortable they can close the merge reques
In GitLab it is common to protect the long-lived branches (e.g. the master branch) so that normal developers [can't modify these protected branches](http://docs.gitlab.com/ce/permissions/permissions.html).
So if you want to merge it into a protected branch you assign it to someone with master authorizations.
-## Issues with GitLab flow
+## Issue tracking with GitLab flow
![Merge request with the branch name 15-require-a-password-to-change-it and assignee field shown](merge_request.png)
@@ -173,9 +172,9 @@ It is possible that one feature branch solves more than one issue.
![Merge request showing the linked issues that will be closed](close_issue_mr.png)
-Linking to the issue can happen by mentioning them from commit messages (fixes #14, closes #67, etc.) or from the merge request description.
-In GitLab this creates a comment in the issue that the merge requests mentions the issue.
-And the merge request shows the linked issues.
+Linking to issues can happen by mentioning them in commit messages (fixes #14, closes #67, etc.) or in the merge request description.
+GitLab then creates links to the mentioned issues and creates comments in the corresponding issues linking back to the merge request.
+
These issues are closed once code is merged into the default branch.
If you only want to make the reference without closing the issue you can also just mention it: "Duck typing is preferred. #12".
@@ -300,7 +299,7 @@ If there are no merge conflicts and the feature branches are short lived the ris
If there are merge conflicts you merge the master branch into the feature branch and the CI server will rerun the tests.
If you have long lived feature branches that last for more than a few days you should make your issues smaller.
-## Merging in other code
+## Working with feature branches
![Shell output showing git pull output](git_pull.png)
diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png
new file mode 100644
index 00000000000..e525083918b
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png
new file mode 100644
index 00000000000..8e26d98f1b0
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png
new file mode 100644
index 00000000000..f715c46adc3
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png b/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png
new file mode 100644
index 00000000000..16ec2d031ae
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png b/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png
new file mode 100644
index 00000000000..22565cf7c7e
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png b/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png
new file mode 100644
index 00000000000..1778b2ddf2b
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png
Binary files differ
diff --git a/doc/workflow/gpg_signed_commits/index.md b/doc/workflow/gpg_signed_commits/index.md
new file mode 100644
index 00000000000..7d5762d2b9d
--- /dev/null
+++ b/doc/workflow/gpg_signed_commits/index.md
@@ -0,0 +1,84 @@
+# Signing commits with GPG
+
+## Getting started
+
+- [Git Tools - Signing Your Work](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)
+- [Git Tools - Signing Your Work: GPG introduction](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work#_gpg_introduction)
+- [Git Tools - Signing Your Work: Signing commits](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work#_signing_commits)
+
+## How GitLab handles GPG
+
+GitLab uses its own keyring to verify the GPG signature. It does not access any
+public key server.
+
+In order to have a commit verified on GitLab the corresponding public key needs
+to be uploaded to GitLab.
+
+For a signature to be verified two prerequisites need to be met:
+
+1. The public key needs to be added to GitLab
+1. One of the emails in the GPG key matches your **primary** email
+
+## Add a GPG key
+
+1. On the upper right corner, click on your avatar and go to your **Settings**.
+
+ ![Settings dropdown](../../gitlab-basics/img/profile_settings.png)
+
+1. Navigate to the **GPG keys** tab.
+
+ ![GPG Keys](img/profile_settings_gpg_keys.png)
+
+1. Paste your **public** key in the 'Key' box.
+
+ ![Paste GPG public key](img/profile_settings_gpg_keys_paste_pub.png)
+
+1. Finally, click on **Add key** to add it to GitLab. You will be able to see
+ its fingerprint, the corresponding email address and creation date.
+
+ ![GPG key single page](img/profile_settings_gpg_keys_single_key.png)
+
+>**Note:**
+Once you add a key, you cannot edit it, only remove it. In case the paste
+didn't work, you will have to remove the offending key and re-add it.
+
+## Remove a GPG key
+
+1. On the upper right corner, click on your avatar and go to your **Settings**.
+
+1. Navigate to the **GPG keys** tab.
+
+1. Click on the trash icon besides the GPG key you want to delete.
+
+>**Note:**
+Removing a key **does not unverify** already signed commits. Commits that were
+verified by using this key will stay verified. Only unpushed commits will stay
+unverified once you remove this key.
+
+## Revoke a GPG key
+
+1. On the upper right corner, click on your avatar and go to your **Settings**.
+
+1. Navigate to the **GPG keys** tab.
+
+1. Click on **Revoke** besides the GPG key you want to delete.
+
+>**Note:**
+Revoking a key **unverifies** already signed commits. Commits that were
+verified by using this key will change to an unverified state. Future commits
+will also stay unverified once you revoke this key. This action should be used
+in case your key has been compromised.
+
+## Verifying commits
+
+1. Within a project navigate to the **Commits** tag. Signed commits will show a
+ badge containing either "Verified" or "Unverified", depending on the
+ verification status of the GPG signature.
+
+ ![Signed and unsigned commits](img/project_signed_and_unsigned_commits.png)
+
+1. By clicking on the GPG badge details of the signature are displayed.
+
+ ![Signed commit with verified signature](img/project_signed_commit_verified_signature.png)
+
+ ![Signed commit with verified signature](img/project_signed_commit_unverified_signature.png)
diff --git a/doc/workflow/groups.md b/doc/workflow/groups.md
index 1cb3c940f00..06eec1ed928 100644
--- a/doc/workflow/groups.md
+++ b/doc/workflow/groups.md
@@ -1,96 +1,2 @@
-# GitLab Groups
-GitLab groups allow you to group projects into directories and give users access to several projects at once.
-
-When you create a new project in GitLab, the default namespace for the project is the personal namespace associated with your GitLab user.
-In this document we will see how to create groups, put projects in groups and manage who can access the projects in a group.
-
-## Creating groups
-
-You can create a group by going to the 'Groups' tab of the GitLab dashboard and clicking the 'New group' button.
-
-![Click the 'New group' button in the 'Groups' tab](groups/new_group_button.png)
-
-Next, enter the path and name (required) and the optional description and group avatar.
-
-![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.
-
-![Group dashboard](groups/group_dashboard.png)
-
-You can use the 'New project' button to add a project to the new group.
-
-## Transferring an existing project into a group
-
-You can transfer an existing project into a group you own from the project settings page. The option to transfer a project is only available if you are the Owner of the project.
-First scroll down to the 'Dangerous settings' and click 'Show them to me'.
-Now you can pick any of the groups you manage as the new namespace for the group.
-
-![Transfer a project to a new namespace](groups/transfer_project.png)
-
-GitLab administrators can use the admin interface to move any project to any namespace if needed.
-
-## Adding users to a group
-
-One of the benefits of putting multiple projects in one group is that you can give a user to access to all projects in the group with one action.
-
-Suppose we have a group with two projects.
-
-![Group with two projects](groups/group_with_two_projects.png)
-
-On the 'Group Members' page we can now add a new user Barry to the group.
-
-![Add user Barry to the group](groups/add_member_to_group.png)
-
-Now because Barry is a 'Developer' member of the 'Open Source' group, he automatically gets 'Developer' access to all projects in the 'Open Source' group.
-
-![Barry has 'Developer' access to GitLab CI](groups/project_members_via_group.png)
-
-If necessary, you can increase the access level of an individual user for a specific project, by adding them as a Member to the project.
-
-![Barry effectively has 'Master' access to GitLab CI now](groups/override_access_level.png)
-
-## Requesting access to a group
-
-As a group owner you can enable or disable non members to request access to
-your group. Go to the group settings and click on **Allow users to request access**.
-
-As a user, you can request to be a member of a group. Go to the group you'd
-like to be a member of, and click the **Request Access** button on the right
-side of your screen.
-
-![Request access button](groups/request_access_button.png)
-
----
-
-Group owners & masters will be notified of your request and will be able to approve or
-decline it on the members page.
-
-![Manage access requests](groups/access_requests_management.png)
-
----
-
-If you change your mind before your request is approved, just click the
-**Withdraw Access Request** button.
-
-![Withdraw access request button](groups/withdraw_access_request_button.png)
-
-## Managing group memberships via LDAP
-
-In GitLab Enterprise Edition it is possible to manage GitLab group memberships using LDAP groups.
-See [the GitLab Enterprise Edition documentation](http://docs.gitlab.com/ee/integration/ldap.html) for more information.
-
-## Allowing only admins to create groups
-
-By default, any GitLab user can create new groups.
-This ability can be disabled for individual users from the admin panel.
-It is also possible to configure GitLab so that new users default to not being able to create groups:
-
-```
-# For omnibus-gitlab, put the following in /etc/gitlab/gitlab.rb
-gitlab_rails['gitlab_default_can_create_group'] = false
-
-# For installations from source, uncomment the 'default_can_create_group'
-# line in /home/git/gitlab/config/gitlab.yml
-```
+This document was moved to [another location](../user/group/index.md).
diff --git a/doc/workflow/groups/add_member_to_group.png b/doc/workflow/groups/add_member_to_group.png
deleted file mode 100644
index a10d5032bb0..00000000000
--- a/doc/workflow/groups/add_member_to_group.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/group_dashboard.png b/doc/workflow/groups/group_dashboard.png
deleted file mode 100644
index a5829f25808..00000000000
--- a/doc/workflow/groups/group_dashboard.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/group_with_two_projects.png b/doc/workflow/groups/group_with_two_projects.png
deleted file mode 100644
index 76d0a1b8ab2..00000000000
--- a/doc/workflow/groups/group_with_two_projects.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/new_group_button.png b/doc/workflow/groups/new_group_button.png
deleted file mode 100644
index 7155d6280bd..00000000000
--- a/doc/workflow/groups/new_group_button.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/override_access_level.png b/doc/workflow/groups/override_access_level.png
deleted file mode 100644
index 2b3e9a49842..00000000000
--- a/doc/workflow/groups/override_access_level.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/project_members_via_group.png b/doc/workflow/groups/project_members_via_group.png
deleted file mode 100644
index 878c9a03ac9..00000000000
--- a/doc/workflow/groups/project_members_via_group.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/groups/transfer_project.png b/doc/workflow/groups/transfer_project.png
deleted file mode 100644
index 52161817f11..00000000000
--- a/doc/workflow/groups/transfer_project.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/notifications/settings.png b/doc/workflow/img/notification_global_settings.png
index 8a5494d16a8..8a5494d16a8 100644
--- a/doc/workflow/notifications/settings.png
+++ b/doc/workflow/img/notification_global_settings.png
Binary files differ
diff --git a/doc/workflow/img/notification_group_settings.png b/doc/workflow/img/notification_group_settings.png
new file mode 100644
index 00000000000..fc096f46901
--- /dev/null
+++ b/doc/workflow/img/notification_group_settings.png
Binary files differ
diff --git a/doc/workflow/img/notification_project_settings.png b/doc/workflow/img/notification_project_settings.png
new file mode 100644
index 00000000000..006432f65c9
--- /dev/null
+++ b/doc/workflow/img/notification_project_settings.png
Binary files differ
diff --git a/doc/workflow/img/todos_icon.png b/doc/workflow/img/todos_icon.png
index 1ed16b09669..9fee4337a75 100644
--- a/doc/workflow/img/todos_icon.png
+++ b/doc/workflow/img/todos_icon.png
Binary files differ
diff --git a/doc/workflow/img/todos_index.png b/doc/workflow/img/todos_index.png
index 902a5aa6bd3..99c1575d157 100644
--- a/doc/workflow/img/todos_index.png
+++ b/doc/workflow/img/todos_index.png
Binary files differ
diff --git a/doc/workflow/importing/README.md b/doc/workflow/importing/README.md
index 2d91bee0e94..f753708ad89 100644
--- a/doc/workflow/importing/README.md
+++ b/doc/workflow/importing/README.md
@@ -1,17 +1 @@
-# Migrating projects to a GitLab instance
-
-1. [Bitbucket](import_projects_from_bitbucket.md)
-1. [GitHub](import_projects_from_github.md)
-1. [GitLab.com](import_projects_from_gitlab_com.md)
-1. [FogBugz](import_projects_from_fogbugz.md)
-1. [Gitea](import_projects_from_gitea.md)
-1. [SVN](migrating_from_svn.md)
-
-In addition to the specific migration documentation above, you can import any
-Git repository via HTTP from the New Project page. Be aware that if the
-repository is too large the import can timeout.
-
-### Migrating from self-hosted GitLab to GitLab.com
-
-You can copy your repos by changing the remote and pushing to the new server;
-but issues and merge requests can't be imported.
+This document was moved to a [new location](../../user/project/import/index.md).
diff --git a/doc/workflow/importing/import_projects_from_bitbucket.md b/doc/workflow/importing/import_projects_from_bitbucket.md
index f3c636ed1d5..248c3990372 100644
--- a/doc/workflow/importing/import_projects_from_bitbucket.md
+++ b/doc/workflow/importing/import_projects_from_bitbucket.md
@@ -1,62 +1 @@
-# Import your project from Bitbucket to GitLab
-
-Import your projects from Bitbucket to GitLab with minimal effort.
-
-## Overview
-
->**Note:**
-The [Bitbucket integration][bb-import] must be first enabled in order to be
-able to import your projects from Bitbucket. Ask your GitLab administrator
-to enable this if not already.
-
-- At its current state, the Bitbucket importer can import:
- - the repository description (GitLab 7.7+)
- - the Git repository data (GitLab 7.7+)
- - the issues (GitLab 7.7+)
- - the issue comments (GitLab 8.15+)
- - the pull requests (GitLab 8.4+)
- - the pull request comments (GitLab 8.15+)
- - the milestones (GitLab 8.15+)
- - the wiki (GitLab 8.15+)
-- References to pull requests and issues are preserved (GitLab 8.7+)
-- Repository public access is retained. If a repository is private in Bitbucket
- it will be created as private in GitLab as well.
-
-
-## How it works
-
-When issues/pull requests are being imported, the Bitbucket importer tries to find
-the Bitbucket author/assignee in GitLab's database using the Bitbucket ID. For this
-to work, the Bitbucket author/assignee should have signed in beforehand in GitLab
-and **associated their Bitbucket account**. If the user is not
-found in GitLab's database, the project creator (most of the times the current
-user that started the import process) is set as the author, but a reference on
-the issue about the original Bitbucket author is kept.
-
-The importer will create any new namespaces (groups) if they don't exist or in
-the case the namespace is taken, the repository will be imported under the user's
-namespace that started the import process.
-
-## Importing your Bitbucket repositories
-
-1. Sign in to GitLab and go to your dashboard.
-1. Click on **New project**.
-
- ![New project in GitLab](img/bitbucket_import_new_project.png)
-
-1. Click on the "Bitbucket" button
-
- ![Bitbucket](img/import_projects_from_new_project_page.png)
-
-1. Grant GitLab access to your Bitbucket account
-
- ![Grant access](img/bitbucket_import_grant_access.png)
-
-1. Click on the projects that you'd like to import or **Import all projects**.
- You can also select the namespace under which each project will be
- imported.
-
- ![Import projects](img/bitbucket_import_select_project.png)
-
-[bb-import]: ../../integration/bitbucket.md
-[social sign-in]: ../../user/profile/account/social_sign_in.md
+This document was moved to a [new location](../../user/project/import/bitbucket.md).
diff --git a/doc/workflow/importing/import_projects_from_fogbugz.md b/doc/workflow/importing/import_projects_from_fogbugz.md
index 71af0f9ea44..050746e2b4d 100644
--- a/doc/workflow/importing/import_projects_from_fogbugz.md
+++ b/doc/workflow/importing/import_projects_from_fogbugz.md
@@ -1,29 +1 @@
-# Import your project from FogBugz to GitLab
-
-It only takes a few simple steps to import your project from FogBugz.
-The importer will import all of your cases and comments with original case
-numbers and timestamps. You will also have the opportunity to map FogBugz
-users to GitLab users.
-
-* From your GitLab dashboard click 'New project'
-
-* Click on the 'FogBugz' button
-
-![FogBugz](fogbugz_importer/fogbugz_import_select_fogbogz.png)
-
-* Enter your FogBugz URL, email address, and password.
-
-![Login](fogbugz_importer/fogbugz_import_login.png)
-
-* Create mapping from FogBugz users to GitLab users.
-
-![User Map](fogbugz_importer/fogbugz_import_user_map.png)
-
-* Select the projects you wish to import by clicking the Import buttons
-
-![Import Project](fogbugz_importer/fogbugz_import_select_project.png)
-
-* Once the import has finished click the link to take you to the project
-dashboard. Follow the directions to push your existing repository.
-
-![Finished](fogbugz_importer/fogbugz_import_finished.png)
+This document was moved to a [new location](../../user/project/import/fogbugz.md).
diff --git a/doc/workflow/importing/import_projects_from_gitea.md b/doc/workflow/importing/import_projects_from_gitea.md
index f5746a0fb31..cb90c490b0f 100644
--- a/doc/workflow/importing/import_projects_from_gitea.md
+++ b/doc/workflow/importing/import_projects_from_gitea.md
@@ -1,77 +1 @@
-# Import your project from Gitea to GitLab
-
-Import your projects from Gitea to GitLab with minimal effort.
-
-## Overview
-
->**Note:**
-This requires Gitea `v1.0.0` or newer.
-
-- At its current state, Gitea importer can import:
- - the repository description (GitLab 8.15+)
- - the Git repository data (GitLab 8.15+)
- - the issues (GitLab 8.15+)
- - the pull requests (GitLab 8.15+)
- - the milestones (GitLab 8.15+)
- - the labels (GitLab 8.15+)
-- Repository public access is retained. If a repository is private in Gitea
- it will be created as private in GitLab as well.
-
-## How it works
-
-Since Gitea is currently not an OAuth provider, author/assignee cannot be mapped
-to users in your GitLab's instance. This means that the project creator (most of
-the times the current user that started the import process) is set as the author,
-but a reference on the issue about the original Gitea author is kept.
-
-The importer will create any new namespaces (groups) if they don't exist or in
-the case the namespace is taken, the repository will be imported under the user's
-namespace that started the import process.
-
-## Importing your Gitea repositories
-
-The importer page is visible when you create a new project.
-
-![New project page on GitLab](img/import_projects_from_new_project_page.png)
-
-Click on the **Gitea** link and the import authorization process will start.
-
-![New Gitea project import](img/import_projects_from_gitea_new_import.png)
-
-### Authorize access to your repositories using a personal access token
-
-With this method, you will perform a one-off authorization with Gitea to grant
-GitLab access your repositories:
-
-1. Go to <https://you-gitea-instance/user/settings/applications> (replace
- `you-gitea-instance` with the host of your Gitea instance).
-1. Click **Generate New Token**.
-1. Enter a token description.
-1. Click **Generate Token**.
-1. Copy the token hash.
-1. Go back to GitLab and provide the token to the Gitea importer.
-1. Hit the **List Your Gitea Repositories** button and wait while GitLab reads
- your repositories' information. Once done, you'll be taken to the importer
- page to select the repositories to import.
-
-### Select which repositories to import
-
-After you've authorized access to your Gitea repositories, you will be
-redirected to the Gitea importer page.
-
-From there, you can see the import statuses of your Gitea repositories.
-
-- Those that are being imported will show a _started_ status,
-- those already successfully imported will be green with a _done_ status,
-- whereas those that are not yet imported will have an **Import** button on the
- right side of the table.
-
-If you want, you can import all your Gitea projects in one go by hitting
-**Import all projects** in the upper left corner.
-
-![Gitea importer page](img/import_projects_from_github_importer.png)
-
----
-
-You can also choose a different name for the project and a different namespace,
-if you have the privileges to do so.
+This document was moved to a [new location](../../user/project/import/gitea.md).
diff --git a/doc/workflow/importing/import_projects_from_github.md b/doc/workflow/importing/import_projects_from_github.md
index 8ed1d98d05b..13639feaa04 100644
--- a/doc/workflow/importing/import_projects_from_github.md
+++ b/doc/workflow/importing/import_projects_from_github.md
@@ -1,122 +1 @@
-# Import your project from GitHub to GitLab
-
-Import your projects from GitHub to GitLab with minimal effort.
-
-## Overview
-
->**Note:**
-If you are an administrator you can enable the [GitHub integration][gh-import]
-in your GitLab instance sitewide. This configuration is optional, users will
-still be able to import their GitHub repositories with a
-[personal access token][gh-token].
-
->**Note:**
-Administrators of a GitLab instance (Community or Enterprise Edition) can also
-use the [GitHub rake task][gh-rake] to import projects from GitHub without the
-constrains of a Sidekiq worker.
-
-- At its current state, GitHub importer can import:
- - the repository description (GitLab 7.7+)
- - the Git repository data (GitLab 7.7+)
- - the issues (GitLab 7.7+)
- - the pull requests (GitLab 8.4+)
- - the wiki pages (GitLab 8.4+)
- - the milestones (GitLab 8.7+)
- - the labels (GitLab 8.7+)
- - the release note descriptions (GitLab 8.12+)
-- References to pull requests and issues are preserved (GitLab 8.7+)
-- Repository public access is retained. If a repository is private in GitHub
- it will be created as private in GitLab as well.
-
-## How it works
-
-When issues/pull requests are being imported, the GitHub importer tries to find
-the GitHub author/assignee in GitLab's database using the GitHub ID. For this
-to work, the GitHub author/assignee should have signed in beforehand in GitLab
-and **associated their GitHub account**. If the user is not
-found in GitLab's database, the project creator (most of the times the current
-user that started the import process) is set as the author, but a reference on
-the issue about the original GitHub author is kept.
-
-The importer will create any new namespaces (groups) if they don't exist or in
-the case the namespace is taken, the repository will be imported under the user's
-namespace that started the import process.
-
-## Importing your GitHub repositories
-
-The importer page is visible when you create a new project.
-
-![New project page on GitLab](img/import_projects_from_new_project_page.png)
-
-Click on the **GitHub** link and the import authorization process will start.
-There are two ways to authorize access to your GitHub repositories:
-
-1. [Using the GitHub integration][gh-integration] (if it's enabled by your
- GitLab administrator). This is the preferred way as it's possible to
- preserve the GitHub authors/assignees. Read more in the [How it works](#how-it-works)
- section.
-1. [Using a personal access token][gh-token] provided by GitHub.
-
-![Select authentication method](img/import_projects_from_github_select_auth_method.png)
-
-### Authorize access to your repositories using the GitHub integration
-
-If the [GitHub integration][gh-import] is enabled by your GitLab administrator,
-you can use it instead of the personal access token.
-
-1. First you may want to connect your GitHub account to GitLab in order for
- the username mapping to be correct.
-1. Once you connect GitHub, click the **List your GitHub repositories** button
- and you will be redirected to GitHub for permission to access your projects.
-1. After accepting, you'll be automatically redirected to the importer.
-
-You can now go on and [select which repositories to import](#select-which-repositories-to-import).
-
-### Authorize access to your repositories using a personal access token
-
->**Note:**
-For a proper author/assignee mapping for issues and pull requests, the
-[GitHub integration][gh-integration] should be used instead of the
-[personal access token][gh-token]. If the GitHub integration is enabled by your
-GitLab administrator, it should be the preferred method to import your repositories.
-Read more in the [How it works](#how-it-works) section.
-
-If you are not using the GitHub integration, you can still perform a one-off
-authorization with GitHub to grant GitLab access your repositories:
-
-1. Go to <https://github.com/settings/tokens/new>.
-1. Enter a token description.
-1. Check the `repo` scope.
-1. Click **Generate token**.
-1. Copy the token hash.
-1. Go back to GitLab and provide the token to the GitHub importer.
-1. Hit the **List Your GitHub Repositories** button and wait while GitLab reads
- your repositories' information. Once done, you'll be taken to the importer
- page to select the repositories to import.
-
-### Select which repositories to import
-
-After you've authorized access to your GitHub repositories, you will be
-redirected to the GitHub importer page.
-
-From there, you can see the import statuses of your GitHub repositories.
-
-- Those that are being imported will show a _started_ status,
-- those already successfully imported will be green with a _done_ status,
-- whereas those that are not yet imported will have an **Import** button on the
- right side of the table.
-
-If you want, you can import all your GitHub projects in one go by hitting
-**Import all projects** in the upper left corner.
-
-![GitHub importer page](img/import_projects_from_github_importer.png)
-
----
-
-You can also choose a different name for the project and a different namespace,
-if you have the privileges to do so.
-
-[gh-import]: ../../integration/github.md "GitHub integration"
-[gh-rake]: ../../administration/raketasks/github_import.md "GitHub rake task"
-[gh-integration]: #authorize-access-to-your-repositories-using-the-github-integration
-[gh-token]: #authorize-access-to-your-repositories-using-a-personal-access-token
+This document was moved to a [new location](../../user/project/import/github.md).
diff --git a/doc/workflow/importing/import_projects_from_gitlab_com.md b/doc/workflow/importing/import_projects_from_gitlab_com.md
index b27125a44de..df4c180919a 100644
--- a/doc/workflow/importing/import_projects_from_gitlab_com.md
+++ b/doc/workflow/importing/import_projects_from_gitlab_com.md
@@ -1,21 +1 @@
-# Project importing from GitLab.com to your private GitLab instance
-
-You can import your existing GitLab.com projects to your GitLab instance. But keep in mind that it is possible only if
-GitLab support is enabled on your GitLab instance.
-You can read more about GitLab support [here](http://docs.gitlab.com/ce/integration/gitlab.html)
-To get to the importer page you need to go to "New project" page.
-
->**Note:**
-If you are interested in importing Wiki and Merge Request data to your new instance, you'll need to follow the instructions for [project export](../../user/project/settings/import_export.md)
-
-![New project page](gitlab_importer/new_project_page.png)
-
-Click on the "Import projects from GitLab.com" link and you will be redirected to GitLab.com
-for permission to access your projects. After accepting, you'll be automatically redirected to the importer.
-
-
-![Importer page](gitlab_importer/importer.png)
-
-
-To import a project, you can simple click "Import". The importer will import your repository and issues.
-Once the importer is done, a new GitLab project will be created with your imported data. \ No newline at end of file
+This document was moved to a [new location](../../user/project/import/gitlab_com.md).
diff --git a/doc/workflow/importing/migrating_from_svn.md b/doc/workflow/importing/migrating_from_svn.md
index 7a3628a39d7..81df3fbcdbb 100644
--- a/doc/workflow/importing/migrating_from_svn.md
+++ b/doc/workflow/importing/migrating_from_svn.md
@@ -1,183 +1 @@
-# Migrating from SVN to GitLab
-
-Subversion (SVN) is a central version control system (VCS) while
-Git is a distributed version control system. There are some major differences
-between the two, for more information consult your favorite search engine.
-
-## Overview
-
-There are two approaches to SVN to Git migration:
-
-1. [Git/SVN Mirror](#smooth-migration-with-a-gitsvn-mirror-using-subgit) which:
- - Makes the GitLab repository to mirror the SVN project.
- - Git and SVN repositories are kept in sync; you can use either one.
- - Smoothens the migration process and allows to manage migration risks.
-
-1. [Cut over migration](#cut-over-migration-with-svn2git) which:
- - Translates and imports the existing data and history from SVN to Git.
- - Is a fire and forget approach, good for smaller teams.
-
-## Smooth migration with a Git/SVN mirror using SubGit
-
-[SubGit](https://subgit.com) is a tool for a smooth, stress-free SVN to Git
-migration. It creates a writable Git mirror of a local or remote Subversion
-repository and that way you can use both Subversion and Git as long as you like.
-It requires access to your GitLab server as it talks with the Git repositories
-directly in a filesystem level.
-
-### SubGit prerequisites
-
-1. Install Oracle JRE 1.8 or newer. On Debian-based Linux distributions you can
- follow [this article](http://www.webupd8.org/2012/09/install-oracle-java-8-in-ubuntu-via-ppa.html).
-1. Download SubGit from https://subgit.com/download/.
-1. Unpack the downloaded SubGit zip archive to the `/opt` directory. The `subgit`
- command will be available at `/opt/subgit-VERSION/bin/subgit`.
-
-### SubGit configuration
-
-The first step to mirror you SVN repository in GitLab is to create a new empty
-project which will be used as a mirror. For Omnibus installations the path to
-the repository will be located at
-`/var/opt/gitlab/git-data/repositories/USER/REPO.git` by default. For
-installations from source, the default repository directory will be
-`/home/git/repositories/USER/REPO.git`. For convenience, assign this path to a
-variable:
-
-```
-GIT_REPO_PATH=/var/opt/gitlab/git-data/repositories/USER/REPOS.git
-```
-
-SubGit will keep this repository in sync with a remote SVN project. For
-convenience, assign your remote SVN project URL to a variable:
-
-```
-SVN_PROJECT_URL=http://svn.company.com/repos/project
-```
-
-Next you need to run SubGit to set up a Git/SVN mirror. Make sure the following
-`subgit` command is ran on behalf of the same user that keeps ownership of
-GitLab Git repositories (by default `git`):
-
-```
-subgit configure --layout auto $SVN_PROJECT_URL $GIT_REPO_PATH
-```
-
-Adjust authors and branches mappings, if necessary. Open with your favorite
-text editor:
-
-```
-edit $GIT_REPO_PATH/subgit/authors.txt
-edit $GIT_REPO_PATH/subgit/config
-```
-
-For more information regarding the SubGit configuration options, refer to
-[SubGit's documentation](https://subgit.com/documentation.html) website.
-
-### Initial translation
-
-Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the
-initial translation of existing SVN revisions into the Git repository:
-
-```
-subgit install $GIT_REPO_PATH
-```
-
-After the initial translation is completed, the Git repository and the SVN
-project will be kept in sync by `subgit` - new Git commits will be translated to
-SVN revisions and new SVN revisions will be translated to Git commits. Mirror
-works transparently and does not require any special commands.
-
-If you would prefer to perform one-time cut over migration with `subgit`, use
-the `import` command instead of `install`:
-
-```
-subgit import $GIT_REPO_PATH
-```
-
-### SubGit licensing
-
-Running SubGit in a mirror mode requires a
-[registration](https://subgit.com/pricing.html). Registration is free for open
-source, academic and startup projects.
-
-We're currently working on deeper GitLab/SubGit integration. You may track our
-progress at [this issue](https://gitlab.com/gitlab-org/gitlab-ee/issues/990).
-
-### SubGit support
-
-For any questions related to SVN to GitLab migration with SubGit, you can
-contact the SubGit team directly at [support@subgit.com](mailto:support@subgit.com).
-
-## Cut over migration with svn2git
-
-If you are currently using an SVN repository, you can migrate the repository
-to Git and GitLab. We recommend a hard cut over - run the migration command once
-and then have all developers start using the new GitLab repository immediately.
-Otherwise, it's hard to keep changing in sync in both directions. The conversion
-process should be run on a local workstation.
-
-Install `svn2git`. On all systems you can install as a Ruby gem if you already
-have Ruby and Git installed.
-
-```bash
-sudo gem install svn2git
-```
-
-On Debian-based Linux distributions you can install the native packages:
-
-```bash
-sudo apt-get install git-core git-svn ruby
-```
-
-Optionally, prepare an authors file so `svn2git` can map SVN authors to Git authors.
-If you choose not to create the authors file then commits will not be attributed
-to the correct GitLab user. Some users may not consider this a big issue while
-others will want to ensure they complete this step. If you choose to map authors
-you will be required to map every author that is present on changes in the SVN
-repository. If you don't, the conversion will fail and you will have to update
-the author file accordingly. The following command will search through the
-repository and output a list of authors.
-
-```bash
-svn log --quiet | grep -E "r[0-9]+ \| .+ \|" | cut -d'|' -f2 | sed 's/ //g' | sort | uniq
-```
-
-Use the output from the last command to construct the authors file.
-Create a file called `authors.txt` and add one mapping per line.
-
-```
-janedoe = Jane Doe <janedoe@example.com>
-johndoe = John Doe <johndoe@example.com>
-```
-
-If your SVN repository is in the standard format (trunk, branches, tags,
-not nested) the conversion is simple. For a non-standard repository see
-[svn2git documentation](https://github.com/nirvdrum/svn2git). The following
-command will checkout the repository and do the conversion in the current
-working directory. Be sure to create a new directory for each repository before
-running the `svn2git` command. The conversion process will take some time.
-
-```bash
-svn2git https://svn.example.com/path/to/repo --authors /path/to/authors.txt
-```
-
-If your SVN repository requires a username and password add the
-`--username <username>` and `--password <password` flags to the above command.
-`svn2git` also supports excluding certain file paths, branches, tags, etc. See
-[svn2git documentation](https://github.com/nirvdrum/svn2git) or run
-`svn2git --help` for full documentation on all of the available options.
-
-Create a new GitLab project, where you will eventually push your converted code.
-Copy the SSH or HTTP(S) repository URL from the project page. Add the GitLab
-repository as a Git remote and push all the changes. This will push all commits,
-branches and tags.
-
-```bash
-git remote add origin git@gitlab.com:<group>/<project>.git
-git push --all origin
-git push --tags origin
-```
-
-## Contribute to this guide
-We welcome all contributions that would expand this guide with instructions on
-how to migrate from SVN and other version control systems.
+This document was moved to a [new location](../../user/project/import/svn.md).
diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md
index 3a6773909d6..d768b73286d 100644
--- a/doc/workflow/lfs/lfs_administration.md
+++ b/doc/workflow/lfs/lfs_administration.md
@@ -50,7 +50,7 @@ and [projects APIs](../../api/projects.md).
* Currently, storing GitLab Git LFS objects on a non-local storage (like S3 buckets)
is not supported
-* Currently, removing LFS objects from GitLab Git LFS storage is not supported
+* Support for removing unreferenced LFS objects was added in 8.14 onwards.
* LFS authentications via SSH was added with GitLab 8.12
* Only compatible with the GitLFS client versions 1.1.0 and up, or 1.0.2.
* The storage statistics currently count each LFS object multiple times for
diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md
index e91d36987a9..3e2e7d0f7b6 100644
--- a/doc/workflow/notifications.md
+++ b/doc/workflow/notifications.md
@@ -6,7 +6,7 @@ GitLab has a notification system in place to notify a user of events that are im
You can find notification settings under the user profile.
-![notification settings](notifications/settings.png)
+![notification settings](img/notification_global_settings.png)
Notification settings are divided into three groups:
@@ -32,19 +32,23 @@ anything that is set at Global Settings.
#### Group Settings
+![notification settings](img/notification_group_settings.png)
+
Group Settings are taking precedence over Global Settings but are on a level below Project Settings.
This means that you can set a different level of notifications per group while still being able
to have a finer level setting per project.
Organization like this is suitable for users that belong to different groups but don't have the
same need for being notified for every group they are member of.
-These settings can be configured on group page or user profile notifications dropdown.
+These settings can be configured on group page under the name of the group. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown.
#### Project Settings
+![notification settings](img/notification_project_settings.png)
+
Project Settings are at the top level and any setting placed at this level will take precedence of any
other setting.
This is suitable for users that have different needs for notifications per project basis.
-These settings can be configured on project page or user profile notifications dropdown.
+These settings can be configured on project page under the name of the project. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown.
## Notification events
diff --git a/doc/workflow/project_features.md b/doc/workflow/project_features.md
index f19e7df8c9a..feb88712f5a 100644
--- a/doc/workflow/project_features.md
+++ b/doc/workflow/project_features.md
@@ -1,43 +1 @@
-# Project features
-
-When in a Project -> Settings, you will find Features on the bottom of the page that you can toggle.
-
-Below you will find a more elaborate explanation of each of these.
-
-## Issues
-
-Issues is a really powerful, but lightweight issue tracking system.
-
-You can make tickets, assign them to people, file them under milestones, order them with labels and have discussion in them.
-
-They integrate deeply into GitLab and are easily referenced from anywhere by using `#` and the issue number.
-
-## Merge Requests
-
-Using a merge request, you can review and discuss code before it is merged in the branch of your code.
-
-As with issues, it can be assigned; people, issues, etc. can be referenced; milestones attached.
-
-We see it as an integral part of working together on code and couldn't work without it.
-
-## Wiki
-
-This is a separate system for documentation, built right into GitLab.
-
-It is source controlled and is very convenient if you don't want to keep you documentation in your source code, but you do want to keep it in your GitLab project.
-
-## Snippets
-
-Snippets are little bits of code or text.
-
-This is a nice place to put code or text that is used semi-regularly within the project, but does not belong in source control.
-
-For example, a specific config file that is used by the team that is only valid for the people that work on the code.
-
-## Git LFS
-
->**Note:** Project-specific LFS setting was added on 8.12 and is available only to admins.
-
-Git Large File Storage allows you to easily manage large binary files with Git.
-With this setting admins can better control which projects are allowed to use
-LFS.
+This document was moved to [../user/project/index.md](../user/project/index.md)
diff --git a/doc/workflow/share_projects_with_other_groups.md b/doc/workflow/share_projects_with_other_groups.md
index 8e50cb03e63..2eb4d24958a 100644
--- a/doc/workflow/share_projects_with_other_groups.md
+++ b/doc/workflow/share_projects_with_other_groups.md
@@ -1,32 +1 @@
-# Share Projects with other Groups
-
-You can share projects with other groups. This makes it possible to add a group of users
-to a project with a single action.
-
-## Groups as collections of users
-
-Groups are used primarily to [create collections of projects](groups.md), but you can also
-take advantage of the fact that groups define collections of _users_, namely the group
-members.
-
-## Sharing a project with a group of users
-
-The primary mechanism to give a group of users, say 'Engineering', access to a project,
-say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project
-Acme'. But what if 'Project Acme' already belongs to another group, say 'Open Source'?
-This is where the group sharing feature can be of use.
-
-To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section.
-
-![The 'Groups' section in the project settings screen](groups/share_project_with_groups.png)
-
-Now you can add the 'Engineering' group with the maximum access level of your choice.
-After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard.
-
-!['Project Acme' is listed as a shared project for 'Engineering'](groups/other_group_sees_shared_project.png)
-
-## Maximum access level
-
-!['Project Acme' is shared with 'Engineering' with a maximum access level of 'Developer'](groups/max_access_level.png)
-
-In the screenshot above, the maximum access level of 'Developer' for members from 'Engineering' means that users with higher access levels in 'Engineering' ('Master' or 'Owner') will only have 'Developer' access to 'Project Acme'.
+This document was moved to [../user/project/members/share_project_with_groups.md](../user/project/members/share_project_with_groups.md)
diff --git a/doc/workflow/share_with_group.md b/doc/workflow/share_with_group.md
index 3b7690973cb..2eb4d24958a 100644
--- a/doc/workflow/share_with_group.md
+++ b/doc/workflow/share_with_group.md
@@ -1,13 +1 @@
-# Sharing a project with a group
-
-If you want to share a single project in a group with another group,
-you can do so easily. By setting the permission you can quickly
-give a select group of users access to a project in a restricted manner.
-
-In a project go to the project settings -> groups.
-
-Now you can select a group that you want to share this project with and with
-which maximum access level. Users in that group are able to access this project
-with their set group access level, up to the maximum level that you've set.
-
-![Share a project with a group](share_with_group.png)
+This document was moved to [../user/project/members/share_project_with_groups.md](../user/project/members/share_project_with_groups.md)
diff --git a/doc/workflow/shortcuts.md b/doc/workflow/shortcuts.md
index c5b7488be69..87416008e98 100644
--- a/doc/workflow/shortcuts.md
+++ b/doc/workflow/shortcuts.md
@@ -6,7 +6,10 @@ You can see GitLab's keyboard shortcuts by using 'shift + ?'
| Keyboard Shortcut | Description |
| ----------------- | ----------- |
+| <kbd>n</kbd> | Main navigation |
| <kbd>s</kbd> | Focus search |
+| <kbd>f</kbd> | Focus filter |
+| <kbd>p b</kbd> | Show/hide the Performance Bar |
| <kbd>?</kbd> | Show/hide this dialog |
| <kbd>⌘</kbd> + <kbd>shift</kbd> + <kbd>p</kbd> | Toggle markdown preview |
| <kbd>↑</kbd> | Edit last comment (when focused on an empty textarea) |
diff --git a/doc/workflow/time_tracking.md b/doc/workflow/time_tracking.md
index de12994c516..bfe87bb2ceb 100644
--- a/doc/workflow/time_tracking.md
+++ b/doc/workflow/time_tracking.md
@@ -21,13 +21,13 @@ below.
## How to enter data
-Time Tracking uses two [slash commands] that GitLab introduced with this new
+Time Tracking uses two [quick actions] that GitLab introduced with this new
feature: `/spend` and `/estimate`.
-Slash commands can be used in the body of an issue or a merge request, but also
+Quick actions can be used in the body of an issue or a merge request, but also
in a comment in both an issue or a merge request.
-Below is an example of how you can use those new slash commands inside a comment.
+Below is an example of how you can use those new quick actions inside a comment.
![Time tracking example in a comment](time-tracking/time-tracking-example.png)
@@ -70,4 +70,4 @@ The following time units are available:
Default conversion rates are 1w = 5d and 1d = 8h.
[landing]: https://about.gitlab.com/features/time-tracking
-[slash-commands]: ../user/project/slash_commands.md
+[quick actions]: ../user/project/quick_actions.md
diff --git a/features/dashboard/dashboard.feature b/features/dashboard/dashboard.feature
deleted file mode 100644
index 1af4d46dec9..00000000000
--- a/features/dashboard/dashboard.feature
+++ /dev/null
@@ -1,70 +0,0 @@
-@dashboard
-Feature: Dashboard
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And project "Shop" has push event
- And project "Shop" has CI enabled
- And project "Shop" has CI build
- And project "Shop" has labels: "bug", "feature", "enhancement"
- And project "Shop" has issue: "bug report"
- And I visit dashboard page
-
- Scenario: I should see projects list
- Then I should see "New Project" link
- Then I should see "Shop" project link
- Then I should see "Shop" project CI status
-
- @javascript
- Scenario: I should see activity list
- And I visit dashboard activity page
- Then I should see project "Shop" activity feed
-
- Scenario: I should see groups list
- Given I have group with projects
- And I visit dashboard page
- Then I should see groups list
-
- @javascript
- Scenario: I should see last push widget
- Then I should see last push widget
- And I click "Create Merge Request" link
- Then I see prefilled new Merge Request page
-
- @javascript
- Scenario: Sorting Issues
- Given I visit dashboard issues page
- And I sort the list by "Oldest updated"
- And I visit dashboard activity page
- And I visit dashboard issues page
- Then The list should be sorted by "Oldest updated"
-
- @javascript
- Scenario: Filtering Issues by label
- Given project "Shop" has issue "Bugfix1" with label "feature"
- When I visit dashboard issues page
- And I filter the list by label "feature"
- Then I should see "Bugfix1" in issues list
-
- @javascript
- Scenario: Visiting Project's issues after sorting
- Given I visit dashboard issues page
- And I sort the list by "Oldest updated"
- And I visit project "Shop" issues page
- Then The list should be sorted by "Oldest updated"
-
- @javascript
- Scenario: Sorting Merge Requests
- Given I visit dashboard merge requests page
- And I sort the list by "Oldest updated"
- And I visit dashboard activity page
- And I visit dashboard merge requests page
- Then The list should be sorted by "Oldest updated"
-
- @javascript
- Scenario: Visiting Project's merge requests after sorting
- Given project "Shop" has a "Bugfix MR" merge request open
- And I visit dashboard merge requests page
- And I sort the list by "Oldest updated"
- And I visit project "Shop" merge requests page
- Then The list should be sorted by "Oldest updated"
diff --git a/features/dashboard/event_filters.feature b/features/dashboard/event_filters.feature
deleted file mode 100644
index 8c3ff64164f..00000000000
--- a/features/dashboard/event_filters.feature
+++ /dev/null
@@ -1,58 +0,0 @@
-@dashboard
-Feature: Event Filters
- Background:
- Given I sign in as a user
- And I own a project
- And this project has push event
- And this project has new member event
- And this project has merge request event
- And I visit dashboard activity page
-
- @javascript
- Scenario: I should see all events
- Then I should see push event
- And I should see new member event
- And I should see merge request event
-
- @javascript
- Scenario: I should see only pushed events
- When I click "push" event filter
- Then I should see push event
- And I should not see new member event
- And I should not see merge request event
-
- @javascript
- Scenario: I should see only joined events
- When I click "team" event filter
- Then I should see new member event
- And I should not see push event
- And I should not see merge request event
-
- @javascript
- Scenario: I should see only merged events
- When I click "merge" event filter
- Then I should see merge request event
- And I should not see push event
- And I should not see new member event
-
- @javascript
- Scenario: I should see only selected events while page reloaded
- When I click "push" event filter
- And I visit dashboard activity page
- Then I should see push event
- And I should not see new member event
- When I click "team" event filter
- And I visit dashboard activity page
- Then I should not see push event
- And I should see new member event
- And I should not see merge request event
- When I click "push" event filter
- And I visit dashboard activity page
- Then I should see push event
- And I should not see new member event
- And I should not see merge request event
- When I click "merge" event filter
- And I visit dashboard activity page
- Then I should see merge request event
- And I should not see push event
- And I should not see new member event
diff --git a/features/dashboard/merge_requests.feature b/features/dashboard/merge_requests.feature
deleted file mode 100644
index 4a2c997d707..00000000000
--- a/features/dashboard/merge_requests.feature
+++ /dev/null
@@ -1,21 +0,0 @@
-@dashboard
-Feature: Dashboard Merge Requests
- Background:
- Given I sign in as a user
- And I have authored merge requests
- And I have assigned merge requests
- And I have other merge requests
- And I visit dashboard merge requests page
-
- Scenario: I should see assigned merge_requests
- Then I should see merge requests assigned to me
-
- @javascript
- Scenario: I should see authored merge_requests
- When I click "Authored by me" link
- Then I should see merge requests authored by me
-
- @javascript
- Scenario: I should see all merge_requests
- When I click "All" link
- Then I should see all merge requests
diff --git a/features/dashboard/new_project.feature b/features/dashboard/new_project.feature
deleted file mode 100644
index 046e2815d4e..00000000000
--- a/features/dashboard/new_project.feature
+++ /dev/null
@@ -1,30 +0,0 @@
-@dashboard
-Feature: New Project
-Background:
- Given I sign in as a user
- And I own project "Shop"
- And I visit dashboard page
- And I click "New project" link
-
- @javascript
- Scenario: I should see New Projects page
- Then I see "New Project" page
- Then I see all possible import options
-
- @javascript
- Scenario: I should see instructions on how to import from Git URL
- Given I see "New Project" page
- When I click on "Repo by URL"
- Then I see instructions on how to import from Git URL
-
- @javascript
- Scenario: I should see instructions on how to import from GitHub
- Given I see "New Project" page
- When I click on "Import project from GitHub"
- Then I am redirected to the GitHub import page
-
- @javascript
- Scenario: I should see Google Code import page
- Given I see "New Project" page
- When I click on "Google Code"
- Then I redirected to Google Code import page
diff --git a/features/dashboard/starred_projects.feature b/features/dashboard/starred_projects.feature
deleted file mode 100644
index 9dfd2fbab9c..00000000000
--- a/features/dashboard/starred_projects.feature
+++ /dev/null
@@ -1,12 +0,0 @@
-@dashboard
-Feature: Dashboard Starred Projects
- Background:
- Given I sign in as a user
- And public project "Community"
- And I starred project "Community"
- And I own project "Shop"
- And I visit dashboard starred projects page
-
- Scenario: I should see projects list
- Then I should see project "Community"
- And I should not see project "Shop"
diff --git a/features/dashboard/todos.feature b/features/dashboard/todos.feature
deleted file mode 100644
index 0b23bbb7951..00000000000
--- a/features/dashboard/todos.feature
+++ /dev/null
@@ -1,28 +0,0 @@
-@dashboard
-Feature: Dashboard Todos
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And "John Doe" is a developer of project "Shop"
- And "Mary Jane" is a developer of project "Shop"
- And "Mary Jane" owns private project "Enterprise"
- And I am a developer of project "Enterprise"
- And I have todos
- And I visit dashboard todos page
-
- @javascript
- Scenario: I mark todos as done
- Then I should see todos assigned to me
- And I mark the todo as done
- Then I should see the todo marked as done
-
- @javascript
- Scenario: I mark all todos as done
- Then I should see todos assigned to me
- And I mark all todos as done
- Then I should see all todos marked as done
-
- @javascript
- Scenario: I click on a todo row
- Given I click on the todo
- Then I should be directed to the corresponding page
diff --git a/features/group/members.feature b/features/group/members.feature
index e539f6a1273..49a44f57cbb 100644
--- a/features/group/members.feature
+++ b/features/group/members.feature
@@ -4,65 +4,6 @@ Feature: Group Members
And "John Doe" is owner of group "Owned"
And "John Doe" is guest of group "Guest"
- # Leave
-
- @javascript
- Scenario: Owner should be able to remove himself from group if he is not the last owner
- Given "Mary Jane" is owner of group "Owned"
- When I visit group "Owned" members page
- Then I should see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
- When I click on the "Remove User From Group" button for "John Doe"
- And I visit group "Owned" members page
- Then I should not see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
-
- @javascript
- Scenario: Owner should not be able to remove himself from group if he is the last owner
- Given "Mary Jane" is guest of group "Owned"
- When I visit group "Owned" members page
- Then I should see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
- Then I should not see the "Remove User From Group" button for "John Doe"
-
- @javascript
- Scenario: Guest should be able to remove himself from group
- Given "Mary Jane" is guest of group "Guest"
- When I visit group "Guest" members page
- Then I should see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
- When I click on the "Remove User From Group" button for "John Doe"
- When I visit group "Guest" members page
- Then I should not see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
-
- @javascript
- Scenario: Guest should be able to remove himself from group even if he is the only user in the group
- When I visit group "Guest" members page
- Then I should see user "John Doe" in team list
- When I click on the "Remove User From Group" button for "John Doe"
- When I visit group "Guest" members page
- Then I should not see user "John Doe" in team list
-
- # Remove others
-
- Scenario: Owner should be able to remove other users from group
- Given "Mary Jane" is owner of group "Owned"
- When I visit group "Owned" members page
- Then I should see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
- When I click on the "Remove User From Group" button for "Mary Jane"
- When I visit group "Owned" members page
- Then I should see user "John Doe" in team list
- Then I should not see user "Mary Jane" in team list
-
- Scenario: Guest should not be able to remove other users from group
- Given "Mary Jane" is guest of group "Guest"
- When I visit group "Guest" members page
- Then I should see user "John Doe" in team list
- Then I should see user "Mary Jane" in team list
- Then I should not see the "Remove User From Group" button for "Mary Jane"
-
Scenario: Search member by name
Given "Mary Jane" is guest of group "Guest"
And I visit group "Guest" members page
diff --git a/features/group/milestones.feature b/features/group/milestones.feature
index d6c05df9840..2211acfee20 100644
--- a/features/group/milestones.feature
+++ b/features/group/milestones.feature
@@ -22,12 +22,12 @@ Feature: Group Milestones
Then I should see group milestone with descriptions and expiry date
And I should see group milestone with all issues and MRs assigned to that milestone
- Scenario: Create multiple milestones with one form
+ Scenario: Create group milestones
Given I visit group "Owned" milestones page
And I click new milestone button
And I fill milestone name
When I press create mileston button
- Then milestone in each project should be created
+ Then group milestone should be created
Scenario: I should see Issues listed with labels
Given Group has projects with milestones
@@ -38,6 +38,7 @@ Feature: Group Milestones
And I should see the "feature" label
And I should see the project name in the Issue row
+ @javascript
Scenario: I should see the Labels tab
Given Group has projects with milestones
When I visit group "Owned" page
diff --git a/features/profile/active_tab.feature b/features/profile/active_tab.feature
index 788b7895d72..21d7d6c3800 100644
--- a/features/profile/active_tab.feature
+++ b/features/profile/active_tab.feature
@@ -23,7 +23,7 @@ Feature: Profile Active Tab
Then the active main tab should be Preferences
And no other main tabs should be active
- Scenario: On Profile Audit Log
- Given I visit Audit Log page
- Then the active main tab should be Audit Log
+ Scenario: On Profile Authentication log
+ Given I visit Authentication log page
+ Then the active main tab should be Authentication log
And no other main tabs should be active
diff --git a/features/profile/notifications.feature b/features/profile/notifications.feature
deleted file mode 100644
index ef8743932f5..00000000000
--- a/features/profile/notifications.feature
+++ /dev/null
@@ -1,15 +0,0 @@
-@profile
-Feature: Profile Notifications
- Background:
- Given I sign in as a user
- And I own project "Shop"
-
- Scenario: I visit notifications tab
- When I visit profile notifications page
- Then I should see global notifications settings
-
- @javascript
- Scenario: I edit Project Notifications
- Given I visit profile notifications page
- When I select Mention setting from dropdown
- Then I should see Notification saved message
diff --git a/features/profile/profile.feature b/features/profile/profile.feature
index 70f47c97173..3263d3e212b 100644
--- a/features/profile/profile.feature
+++ b/features/profile/profile.feature
@@ -63,7 +63,7 @@ Feature: Profile
Given I logout
And I sign in via the UI
And I have activity
- When I visit Audit Log page
+ When I visit Authentication log page
Then I should see my activity
Scenario: I visit my user page
diff --git a/features/project/active_tab.feature b/features/project/active_tab.feature
index 0d6f7350181..3ea0aab5a67 100644
--- a/features/project/active_tab.feature
+++ b/features/project/active_tab.feature
@@ -31,6 +31,11 @@ Feature: Project Active Tab
Then the active main tab should be Wiki
And no other main tabs should be active
+ Scenario: On Project Members
+ Given I visit my project's members page
+ Then the active main tab should be Members
+ And no other main tabs should be active
+
# Sub Tabs: Home
Scenario: On Project Home/Show
@@ -63,19 +68,6 @@ Feature: Project Active Tab
And no other sub tabs should be active
And the active main tab should be Settings
- Scenario: On Project Settings/Pages
- Given I visit my project's settings page
- And I click the "Pages" tab
- Then the active sub tab should be Pages
- And no other sub tabs should be active
- And the active main tab should be Settings
-
- Scenario: On Project Members
- Given I visit my project's members page
- Then the active sub tab should be Members
- And no other sub tabs should be active
- And the active main tab should be Settings
-
# Sub Tabs: Repository
Scenario: On Project Repository/Files
diff --git a/features/project/badges/build.feature b/features/project/badges/build.feature
deleted file mode 100644
index bcf80ed620e..00000000000
--- a/features/project/badges/build.feature
+++ /dev/null
@@ -1,27 +0,0 @@
-Feature: Project Badges Build
- Background:
- Given I sign in as a user
- And I own a project
- And project has CI enabled
- And project has a recent build
-
- Scenario: I want to see a badge for successfully built project
- Given recent build is successful
- When I display builds badge for a master branch
- Then I should see a build success badge
-
- Scenario: I want to see a badge for project with failed builds
- Given recent build failed
- When I display builds badge for a master branch
- Then I should see a build failed badge
-
- Scenario: I want to see a badge for project with running builds
- Given recent build is successful
- And project has another build that is running
- When I display builds badge for a master branch
- Then I should see a build running badge
-
- Scenario: I want to see a fresh badge on each request
- Given recent build is successful
- When I display builds badge for a master branch
- Then I should see a badge that has not been cached
diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature
index 52dc15f2eb6..5abc24949cf 100644
--- a/features/project/builds/artifacts.feature
+++ b/features/project/builds/artifacts.feature
@@ -17,6 +17,7 @@ Feature: Project Builds Artifacts
When I visit recent build details page
And I click artifacts browse button
Then I should see content of artifacts archive
+ And I should see the build header
Scenario: I browse subdirectory of build artifacts
Given recent build has artifacts available
@@ -25,6 +26,7 @@ Feature: Project Builds Artifacts
And I click artifacts browse button
And I click link to subdirectory within build artifacts
Then I should see content of subdirectory within artifacts archive
+ And I should see the directory name in the breadcrumb
Scenario: I browse directory with UTF-8 characters in name
Given recent build has artifacts available
@@ -44,13 +46,14 @@ Feature: Project Builds Artifacts
And I navigate to parent directory of directory with invalid name
Then I should not see directory with invalid name on the list
+ @javascript
Scenario: I download a single file from build artifacts
Given recent build has artifacts available
And recent build has artifacts metadata available
When I visit recent build details page
And I click artifacts browse button
And I click a link to file within build artifacts
- Then download of a file extracted from build artifacts should start
+ Then I see a download link
@javascript
Scenario: I click on a row in an artifacts table
diff --git a/features/project/builds/permissions.feature b/features/project/builds/permissions.feature
index 3c7f72335d9..db15968db06 100644
--- a/features/project/builds/permissions.feature
+++ b/features/project/builds/permissions.feature
@@ -27,6 +27,7 @@ Feature: Project Builds Permissions
When I visit project builds page
Then page status code should be 404
+ @javascript
Scenario: I try to visit build details of internal project with access to builds
Given The project is internal
And public access for builds is enabled
diff --git a/features/project/builds/summary.feature b/features/project/builds/summary.feature
index 550ebccf0d7..3bf15b0cf87 100644
--- a/features/project/builds/summary.feature
+++ b/features/project/builds/summary.feature
@@ -6,16 +6,19 @@ Feature: Project Builds Summary
And project has coverage enabled
And project has a recent build
+ @javascript
Scenario: I browse build details page
When I visit recent build details page
Then I see details of a build
And I see build trace
+ @javascript
Scenario: I browse project builds page
When I visit project builds page
Then I see coverage
Then I see button to CI Lint
+ @javascript
Scenario: I erase a build
Given recent build is successful
And recent build has a build trace
diff --git a/features/project/commits/revert.feature b/features/project/commits/revert.feature
index 7a2effafe03..7ee1d717d80 100644
--- a/features/project/commits/revert.feature
+++ b/features/project/commits/revert.feature
@@ -5,12 +5,14 @@ Feature: Revert Commits
And I own a project
And I visit my project's commits page
+ @javascript
Scenario: I revert a commit
Given I click on commit link
And I click on the revert button
And I revert the changes directly
Then I should see the revert commit notice
+ @javascript
Scenario: I revert a commit that was previously reverted
Given I click on commit link
And I click on the revert button
@@ -21,6 +23,7 @@ Feature: Revert Commits
And I revert the changes directly
Then I should see a revert error
+ @javascript
Scenario: I revert a commit in a new merge request
Given I click on commit link
And I click on the revert button
diff --git a/features/project/create.feature b/features/project/create.feature
deleted file mode 100644
index 67336d73bf7..00000000000
--- a/features/project/create.feature
+++ /dev/null
@@ -1,14 +0,0 @@
-@project-create
-Feature: Project Create
- In order to get access to project sections
- A user with ability to create a project
- Should be able to create a new one
-
- @javascript
- Scenario: User create a project
- Given I sign in as a user
- And I have an ssh key
- When I visit new project page
- And fill project form with valid data
- Then I should see project page
- And I should see empty project instructions
diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature
index 960b4100ee5..6f1ed9ff5b6 100644
--- a/features/project/deploy_keys.feature
+++ b/features/project/deploy_keys.feature
@@ -3,28 +3,33 @@ Feature: Project Deploy Keys
Given I sign in as a user
And I own project "Shop"
+ @javascript
Scenario: I should see deploy keys list
Given project has deploy key
When I visit project deploy keys page
Then I should see project deploy key
+ @javascript
Scenario: I should see project deploy keys
Given other projects have deploy keys
When I visit project deploy keys page
Then I should see other project deploy key
And I should only see the same deploy key once
+ @javascript
Scenario: I should see public deploy keys
Given public deploy key exists
When I visit project deploy keys page
Then I should see public deploy key
+ @javascript
Scenario: I add new deploy key
Given I visit project deploy keys page
And I submit new deploy key
Then I should be on deploy keys page
And I should see newly created deploy key
+ @javascript
Scenario: I attach other project deploy key to project
Given other projects have deploy keys
And I visit project deploy keys page
@@ -32,6 +37,7 @@ Feature: Project Deploy Keys
Then I should be on deploy keys page
And I should see newly created deploy key
+ @javascript
Scenario: I attach public deploy key to project
Given public deploy key exists
And I visit project deploy keys page
diff --git a/features/project/hooks.feature b/features/project/hooks.feature
deleted file mode 100644
index 627738004c4..00000000000
--- a/features/project/hooks.feature
+++ /dev/null
@@ -1,37 +0,0 @@
-Feature: Project Hooks
- Background:
- Given I sign in as a user
- And I own project "Shop"
-
- Scenario: I should see hook list
- Given project has hook
- When I visit project hooks page
- Then I should see project hook
-
- Scenario: I add new hook
- Given I visit project hooks page
- When I submit new hook
- Then I should see newly created hook
-
- Scenario: I add new hook with SSL verification enabled
- Given I visit project hooks page
- When I submit new hook with SSL verification enabled
- Then I should see newly created hook with SSL verification enabled
-
- Scenario: I test hook
- Given project has hook
- And I visit project hooks page
- When I click test hook button
- Then hook should be triggered
-
- Scenario: I test a hook on empty project
- Given I own empty project with hook
- And I visit project hooks page
- When I click test hook button
- Then I should see hook error message
-
- Scenario: I test a hook on down URL
- Given project has hook
- And I visit project hooks page
- When I click test hook button with invalid URL
- Then I should see hook service down error message
diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature
index 4dee0cd23dc..4f905674d8c 100644
--- a/features/project/issues/issues.feature
+++ b/features/project/issues/issues.feature
@@ -12,11 +12,13 @@ Feature: Project Issues
Given I should see "Release 0.4" in issues
And I should not see "Release 0.3" in issues
+ @javascript
Scenario: I should see closed issues
Given I click link "Closed"
Then I should see "Release 0.3" in issues
And I should not see "Release 0.4" in issues
+ @javascript
Scenario: I should see all issues
Given I click link "All"
Then I should see "Release 0.3" in issues
@@ -82,6 +84,7 @@ Feature: Project Issues
# Markdown
+ @javascript
Scenario: Headers inside the description should have ids generated for them.
Given I visit issue page "Release 0.4"
Then Header "Description header" should have correct id and link
diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature
index bcde497553b..0ebeded7fc5 100644
--- a/features/project/merge_requests.feature
+++ b/features/project/merge_requests.feature
@@ -26,41 +26,49 @@ Feature: Project Merge Requests
When I visit project "Shop" merge requests page
Then I should see "feature_conflict" branch
+ @javascript
Scenario: I should not see the numbers of diverged commits if the branch is rebased on the target
Given project "Shop" have "Bug NS-07" open merge request with rebased branch
When I visit merge request page "Bug NS-07"
Then I should not see the diverged commits count
+ @javascript
Scenario: I should see the numbers of diverged commits if the branch diverged from the target
Given project "Shop" have "Bug NS-08" open merge request with diverged branch
When I visit merge request page "Bug NS-08"
Then I should see the diverged commits count
+ @javascript
Scenario: I should see rejected merge requests
Given I click link "Closed"
Then I should see "Feature NS-03" in merge requests
And I should not see "Bug NS-04" in merge requests
+ @javascript
Scenario: I should see all merge requests
Given I click link "All"
Then I should see "Feature NS-03" in merge requests
And I should see "Bug NS-04" in merge requests
+ @javascript
Scenario: I visit an open merge request page
Given I click link "Bug NS-04"
Then I should see merge request "Bug NS-04"
+ @javascript
Scenario: I visit a merged merge request page
Given project "Shop" have "Feature NS-05" merged merge request
And I click link "Merged"
And I click link "Feature NS-05"
Then I should see merge request "Feature NS-05"
+ @javascript
Scenario: I close merge request page
Given I click link "Bug NS-04"
And I click link "Close"
Then I should see closed merge request "Bug NS-04"
+ @javascript
Scenario: I reopen merge request page
Given I click link "Bug NS-04"
And I click link "Close"
@@ -176,6 +184,7 @@ Feature: Project Merge Requests
# Markdown
+ @javascript
Scenario: Headers inside the description should have ids generated for them.
When I visit merge request page "Bug NS-04"
Then Header "Description header" should have correct id and link
diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature
index 330ec8ae0fe..2ab1c19f452 100644
--- a/features/project/merge_requests/accept.feature
+++ b/features/project/merge_requests/accept.feature
@@ -7,7 +7,7 @@ Feature: Project Merge Requests Acceptance
@javascript
Scenario: Accepting the Merge Request and removing the source branch
Given I am on the Merge Request detail page
- When I click on "Remove source branch" option
+ When I check the "Remove source branch" option
And I click on Accept Merge Request
Then I should see merge request merged
And I should not see the Remove Source Branch button
@@ -15,7 +15,7 @@ Feature: Project Merge Requests Acceptance
@javascript
Scenario: Accepting the Merge Request when URL has an anchor
Given I am on the Merge Request detail with note anchor page
- When I click on "Remove source branch" option
+ When I check the "Remove source branch" option
And I click on Accept Merge Request
Then I should see merge request merged
And I should not see the Remove Source Branch button
diff --git a/features/project/milestone.feature b/features/project/milestone.feature
index 713f0f3b979..5e7b211fa27 100644
--- a/features/project/milestone.feature
+++ b/features/project/milestone.feature
@@ -7,14 +7,6 @@ Feature: Project Milestone
And milestone has issue "Bugfix1" with labels: "bug", "feature"
And milestone has issue "Bugfix2" with labels: "bug", "enhancement"
-
- @javascript
- Scenario: Listing issues from issues tab
- Given I visit project "Shop" milestones page
- And I click link "v2.2"
- Then I should see the labels "bug", "enhancement" and "feature"
- And I should see the "bug" label listed only once
-
@javascript
Scenario: Listing labels from labels tab
Given I visit project "Shop" milestones page
diff --git a/features/project/pages.feature b/features/project/pages.feature
index 87d88348d09..56e47287b5c 100644
--- a/features/project/pages.feature
+++ b/features/project/pages.feature
@@ -3,10 +3,15 @@ Feature: Project Pages
Given I sign in as a user
And I own a project
- Scenario: Pages are disabled
+ Scenario: I cannot navigate to Pages settings if pages enabled
Given pages are disabled
- When I visit the Project Pages
- Then I should see that GitLab Pages are disabled
+ And I visit my project's settings page
+ Then I should not see the "Pages" tab
+
+ Scenario: I can navigate to Pages settings if pages enabled
+ Given pages are enabled
+ And I visit my project's settings page
+ Then I should see the "Pages" tab
Scenario: I can see the pages usage if not deployed
Given pages are enabled
diff --git a/features/project/project.feature b/features/project/project.feature
index aa22401c88e..23817ef3ac9 100644
--- a/features/project/project.feature
+++ b/features/project/project.feature
@@ -18,6 +18,7 @@ Feature: Project
Then I should see the default project avatar
And I should not see the "Remove avatar" button
+ @javascript
Scenario: I should have readme on page
And I visit project "Shop" page
Then I should see project "Shop" README
diff --git a/features/project/service.feature b/features/project/service.feature
index cce5f58adec..54f07ebca92 100644
--- a/features/project/service.feature
+++ b/features/project/service.feature
@@ -11,77 +11,77 @@ Feature: Project Services
When I visit project "Shop" services page
And I click hipchat service link
And I fill hipchat settings
- Then I should see hipchat service settings saved
+ Then I should see the Hipchat success message
Scenario: Activate hipchat service with custom server
When I visit project "Shop" services page
And I click hipchat service link
And I fill hipchat settings with custom server
- Then I should see hipchat service settings with custom server saved
+ Then I should see the Hipchat success message
Scenario: Activate pivotaltracker service
When I visit project "Shop" services page
And I click pivotaltracker service link
And I fill pivotaltracker settings
- Then I should see pivotaltracker service settings saved
+ Then I should see the Pivotaltracker success message
Scenario: Activate Flowdock service
When I visit project "Shop" services page
And I click Flowdock service link
And I fill Flowdock settings
- Then I should see Flowdock service settings saved
+ Then I should see the Flowdock success message
Scenario: Activate Assembla service
When I visit project "Shop" services page
And I click Assembla service link
And I fill Assembla settings
- Then I should see Assembla service settings saved
+ Then I should see the Assembla success message
Scenario: Activate Slack notifications service
When I visit project "Shop" services page
And I click Slack notifications service link
And I fill Slack notifications settings
- Then I should see Slack Notifications service settings saved
+ Then I should see the Slack notifications success message
Scenario: Activate Pushover service
When I visit project "Shop" services page
And I click Pushover service link
And I fill Pushover settings
- Then I should see Pushover service settings saved
+ Then I should see the Pushover success message
Scenario: Activate email on push service
When I visit project "Shop" services page
And I click email on push service link
And I fill email on push settings
- Then I should see email on push service settings saved
+ Then I should see the Emails on push success message
Scenario: Activate JIRA service
When I visit project "Shop" services page
And I click jira service link
And I fill jira settings
- Then I should see jira service settings saved
+ Then I should see the JIRA success message
Scenario: Activate Irker (IRC Gateway) service
When I visit project "Shop" services page
And I click Irker service link
And I fill Irker settings
- Then I should see Irker service settings saved
+ Then I should see the Irker success message
Scenario: Activate Atlassian Bamboo CI service
When I visit project "Shop" services page
And I click Atlassian Bamboo CI service link
And I fill Atlassian Bamboo CI settings
- Then I should see Atlassian Bamboo CI service settings saved
+ Then I should see the Bamboo success message
And I should see empty field Change Password
Scenario: Activate jetBrains TeamCity CI service
When I visit project "Shop" services page
And I click jetBrains TeamCity CI service link
And I fill jetBrains TeamCity CI settings
- Then I should see jetBrains TeamCity CI service settings saved
+ Then I should see the JetBrains success message
Scenario: Activate Asana service
When I visit project "Shop" services page
And I click Asana service link
And I fill Asana settings
- Then I should see Asana service settings saved
+ Then I should see the Asana success message
diff --git a/features/project/snippets.feature b/features/project/snippets.feature
index 3c51ea56585..50bc4c93df3 100644
--- a/features/project/snippets.feature
+++ b/features/project/snippets.feature
@@ -11,6 +11,7 @@ Feature: Project Snippets
Then I should see "Snippet one" in snippets
And I should not see "Snippet two" in snippets
+ @javascript
Scenario: I create new project snippet
Given I click link "New snippet"
And I submit new snippet "Snippet three"
diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature
deleted file mode 100644
index d81bc9802bc..00000000000
--- a/features/project/source/browse_files.feature
+++ /dev/null
@@ -1,332 +0,0 @@
-Feature: Project Source Browse Files
- Background:
- Given I sign in as a user
- And I own project "Shop"
- Given I visit project source page
-
- Scenario: I browse files from master branch
- Then I should see files from repository
-
- Scenario: I browse files for specific ref
- Given I visit project source page for "6d39438"
- Then I should see files from repository for "6d39438"
-
- Scenario: I browse file content
- Given I click on ".gitignore" file in repo
- Then I should see its content
-
- Scenario: I browse raw file
- Given I visit blob file from repo
- And I click link "Raw"
- Then I should see raw file content
-
- Scenario: I can create file
- Given I click on "New file" link in repo
- Then I can see new file page
-
- Scenario: I can create file when I don't have write access
- Given I don't have write access
- And I click on "New file" link in repo
- Then I should see a notice about a new fork having been created
- Then I can see new file page
-
- @javascript
- Scenario: I can create and commit file
- Given I click on "New file" link in repo
- And I edit code
- And I fill the new file name
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the new file
- And I should see its new content
-
- @javascript
- Scenario: I can create and commit file when I don't have write access
- Given I don't have write access
- And I click on "New file" link in repo
- And I edit code
- And I fill the new file name
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the fork's new merge request page
- And I can see the new commit message
-
- @javascript
- Scenario: I can create and commit file with new lines at the end of file
- Given I click on "New file" link in repo
- And I edit code with new lines at end of file
- And I fill the new file name
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the new file
- And I click button "Edit"
- And I should see its content with new lines preserved at end of file
-
- @javascript
- Scenario: I can create and commit file and specify new branch
- Given I click on "New file" link in repo
- And I edit code
- And I fill the new file name
- And I fill the commit message
- And I fill the new branch name
- And I click on "Commit changes"
- Then I am redirected to the new merge request page
- When I click on "Changes" tab
- And I should see its new content
-
- @javascript
- Scenario: I can upload file and commit
- Given I click on "Upload file" link in repo
- And I upload a new text file
- And I fill the upload file commit message
- And I fill the new branch name
- And I click on "Upload file"
- Then I can see the new commit message
- And I am redirected to the new merge request page
- When I click on "Changes" tab
- Then I can see the new text file
-
- @javascript
- Scenario: I can upload file and commit when I don't have write access
- Given I don't have write access
- And I click on "Upload file" link in repo
- Then I should see a notice about a new fork having been created
- When I click on "Upload file" link in repo
- And I upload a new text file
- And I fill the upload file commit message
- And I click on "Upload file"
- Then I can see the new commit message
- And I am redirected to the fork's new merge request page
- When I click on "Changes" tab
- Then I can see the new text file
-
- @javascript
- Scenario: I can replace file and commit
- Given I click on ".gitignore" file in repo
- And I see the ".gitignore"
- And I click on "Replace"
- And I replace it with a text file
- And I fill the replace file commit message
- And I click on "Replace file"
- Then I can see the new text file
- And I can see the replacement commit message
-
- @javascript
- Scenario: I can replace file and commit when I don't have write access
- Given I don't have write access
- And I click on ".gitignore" file in repo
- And I see the ".gitignore"
- And I click on "Replace"
- Then I should see a Fork/Cancel combo
- And I click button "Fork"
- Then I should see a notice about a new fork having been created
- When I click on "Replace"
- And I replace it with a text file
- And I fill the replace file commit message
- And I click on "Replace file"
- And I can see the replacement commit message
- And I am redirected to the fork's new merge request page
- When I click on "Changes" tab
- Then I can see the new text file
-
- @javascript
- Scenario: If I enter an illegal file name I see an error message
- Given I click on "New file" link in repo
- And I fill the new file name with an illegal name
- And I edit code
- And I fill the commit message
- And I click on "Commit changes"
- Then I am on the new file page
- And I see "Path can contain only..."
-
- @javascript
- Scenario: I can create file with a directory name
- Given I click on "New file" link in repo
- And I fill the new file name with a new directory
- And I edit code
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the new file with directory
- And I should see its new content
-
- @javascript
- Scenario: I can edit file
- Given I click on ".gitignore" file in repo
- And I click button "Edit"
- Then I can edit code
-
- @javascript
- Scenario: I can edit file when I don't have write access
- Given I don't have write access
- And I click on ".gitignore" file in repo
- And I click button "Edit"
- Then I should see a Fork/Cancel combo
- And I click button "Fork"
- Then I should see a notice about a new fork having been created
- And I can edit code
-
- Scenario: If the file is binary the edit link is hidden
- Given I visit a binary file in the repo
- Then I cannot see the edit button
-
- @javascript
- Scenario: I can edit and commit file
- Given I click on ".gitignore" file in repo
- And I click button "Edit"
- And I edit code
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the ".gitignore"
- And I should see its new content
-
- @javascript
- Scenario: I can edit and commit file when I don't have write access
- Given I don't have write access
- And I click on ".gitignore" file in repo
- And I click button "Edit"
- Then I should see a Fork/Cancel combo
- And I click button "Fork"
- And I edit code
- And I fill the commit message
- And I click on "Commit changes"
- Then I am redirected to the fork's new merge request page
- And I can see the new commit message
-
- @javascript
- Scenario: I can edit and commit file to new branch
- Given I click on ".gitignore" file in repo
- And I click button "Edit"
- And I edit code
- And I fill the commit message
- And I fill the new branch name
- And I click on "Commit changes"
- Then I am redirected to the new merge request page
- Then I click on "Changes" tab
- And I should see its new content
-
- @javascript @wip
- Scenario: If I don't change the content of the file I see an error message
- Given I click on ".gitignore" file in repo
- And I click button "edit"
- And I fill the commit message
- And I click on "Commit changes"
- # Test fails because carriage returns are added to the file.
- Then I am on the ".gitignore" edit file page
- And I see a commit error message
-
- @javascript
- Scenario: I can create directory in repo
- When I click on "New directory" link in repo
- And I fill the new directory name
- And I fill the commit message
- And I fill the new branch name
- And I click on "Create directory"
- Then I am redirected to the new merge request page
-
- @javascript
- Scenario: I can create directory in repo when I don't have write access
- Given I don't have write access
- When I click on "New directory" link in repo
- Then I should see a notice about a new fork having been created
- When I click on "New directory" link in repo
- And I fill the new directory name
- And I fill the commit message
- And I click on "Create directory"
- Then I am redirected to the fork's new merge request page
-
- @javascript
- Scenario: I attempt to create an existing directory
- When I click on "New directory" link in repo
- And I fill an existing directory name
- And I fill the commit message
- And I click on "Create directory"
- Then I see "Unable to create directory"
- And I am redirected to the root directory
-
- @javascript
- Scenario: I can see editing preview
- Given I click on ".gitignore" file in repo
- And I click button "Edit"
- And I edit code
- And I click link "Diff"
- Then I see diff
-
- @javascript
- Scenario: I can delete file and commit
- Given I click on ".gitignore" file in repo
- And I see the ".gitignore"
- And I click on "Delete"
- And I fill the commit message
- And I click on "Delete file"
- Then I am redirected to the files URL
- And I don't see the ".gitignore"
-
- @javascript
- Scenario: I can delete file and commit when I don't have write access
- Given I don't have write access
- And I click on ".gitignore" file in repo
- And I see the ".gitignore"
- And I click on "Delete"
- Then I should see a Fork/Cancel combo
- And I click button "Fork"
- Then I should see a notice about a new fork having been created
- When I click on "Delete"
- And I fill the commit message
- And I click on "Delete file"
- Then I am redirected to the fork's new merge request page
- And I can see the new commit message
-
- Scenario: I can browse directory with Browse Dir
- Given I click on files directory
- And I click on History link
- Then I see Browse dir link
-
- Scenario: I can browse file with Browse File
- Given I click on readme file
- And I click on History link
- Then I see Browse file link
-
- Scenario: I can browse code with Browse Code
- Given I click on History link
- Then I see Browse code link
-
- # Permalink
-
- Scenario: I click on the permalink link from a branch ref
- Given I click on ".gitignore" file in repo
- And I click on Permalink
- Then I am redirected to the permalink URL
-
- Scenario: I don't see the permalink link from a SHA ref
- Given I visit project source page for "6d394385cf567f80a8fd85055db1ab4c5295806f"
- And I click on ".gitignore" file in repo
- Then I don't see the permalink link
-
- @javascript
- Scenario: I browse code with single quotes in the ref
- Given I switch ref to 'test'
- And I see the ref 'test' has been selected
- And I visit the 'test' tree
- Then I see the commit data
-
- @javascript
- Scenario: I browse code with a leading dot in the directory
- Given I switch ref to fix
- And I visit the fix tree
- Then I see the commit data for a directory with a leading dot
-
- Scenario: I browse LFS object
- Given I click on "files/lfs/lfs_object.iso" file in repo
- Then I should see download link and object size
- And I should not see lfs pointer details
- And I should see buttons for allowed commands
-
- @javascript
- Scenario: I preview an SVG file
- Given I click on "Upload file" link in repo
- And I upload a new SVG file
- And I fill the upload file commit message
- And I fill the new branch name
- And I click on "Upload file"
- Given I visit the SVG file
- Then I can see the new rendered SVG image
diff --git a/features/project/source/markdown_render.feature b/features/project/source/markdown_render.feature
index ecbd721c281..fe4466ad241 100644
--- a/features/project/source/markdown_render.feature
+++ b/features/project/source/markdown_render.feature
@@ -6,59 +6,69 @@ Feature: Project Source Markdown Render
# Tree README
+ @javascript
Scenario: Tree view should have correct links in README
Given I go directory which contains README file
And I click on a relative link in README
Then I should see the correct markdown
+ @javascript
Scenario: I browse files from markdown branch
Then I should see files from repository in markdown
And I should see rendered README which contains correct links
And I click on Gitlab API in README
Then I should see correct document rendered
+ @javascript
Scenario: I view README in markdown branch
Then I should see files from repository in markdown
And I should see rendered README which contains correct links
And I click on Rake tasks in README
Then I should see correct directory rendered
+ @javascript
Scenario: I view README in markdown branch to see reference links to directory
Then I should see files from repository in markdown
And I should see rendered README which contains correct links
And I click on GitLab API doc directory in README
Then I should see correct doc/api directory rendered
+ @javascript
Scenario: I view README in markdown branch to see reference links to file
Then I should see files from repository in markdown
And I should see rendered README which contains correct links
And I click on Maintenance in README
Then I should see correct maintenance file rendered
+ @javascript
Scenario: README headers should have header links
Then I should see rendered README which contains correct links
And Header "Application details" should have correct id and link
# Blob
+ @javascript
Scenario: I navigate to doc directory to view documentation in markdown
And I navigate to the doc/api/README
And I see correct file rendered
And I click on users in doc/api/README
Then I should see the correct document file
+ @javascript
Scenario: I navigate to doc directory to view user doc in markdown
And I navigate to the doc/api/README
And I see correct file rendered
And I click on raketasks in doc/api/README
Then I should see correct directory rendered
+ @javascript
Scenario: I navigate to doc directory to view user doc in markdown
And I navigate to the doc/api/README
And Header "GitLab API" should have correct id and link
# Markdown branch
+ @javascript
Scenario: I browse files from markdown branch
When I visit markdown branch
Then I should see files from repository in markdown branch
@@ -66,6 +76,7 @@ Feature: Project Source Markdown Render
And I click on Gitlab API in README
Then I should see correct document rendered for markdown branch
+ @javascript
Scenario: I browse directory from markdown branch
When I visit markdown branch
Then I should see files from repository in markdown branch
@@ -73,6 +84,7 @@ Feature: Project Source Markdown Render
And I click on Rake tasks in README
Then I should see correct directory rendered for markdown branch
+ @javascript
Scenario: I navigate to doc directory to view documentation in markdown branch
When I visit markdown branch
And I navigate to the doc/api/README
@@ -80,6 +92,7 @@ Feature: Project Source Markdown Render
And I click on users in doc/api/README
Then I should see the users document file in markdown branch
+ @javascript
Scenario: I navigate to doc directory to view user doc in markdown branch
When I visit markdown branch
And I navigate to the doc/api/README
@@ -87,6 +100,7 @@ Feature: Project Source Markdown Render
And I click on raketasks in doc/api/README
Then I should see correct directory rendered for markdown branch
+ @javascript
Scenario: Tree markdown links view empty urls should have correct urls
When I visit markdown branch
Then The link with text "empty" should have url "tree/markdown"
@@ -99,6 +113,7 @@ Feature: Project Source Markdown Render
# "ID" means "#id" on the tests below, because we are unable to escape the hash sign.
# which Spinach interprets as the start of a comment.
+ @javascript
Scenario: All markdown links with ids should have correct urls
When I visit markdown branch
Then The link with text "ID" should have url "tree/markdownID"
diff --git a/features/search.feature b/features/search.feature
index 818ef436db6..f894b6b84a1 100644
--- a/features/search.feature
+++ b/features/search.feature
@@ -9,6 +9,7 @@ Feature: Search
Given I search for "Sho"
Then I should see "Shop" project link
+ @javascript
Scenario: I should see issues I am looking for
And project has issues
When I search for "Foo"
@@ -16,6 +17,7 @@ Feature: Search
Then I should see "Foo" link in the search results
And I should not see "Bar" link in the search results
+ @javascript
Scenario: I should see merge requests I am looking for
And project has merge requests
When I search for "Foo"
@@ -23,6 +25,7 @@ Feature: Search
Then I should see "Foo" link in the search results
And I should not see "Bar" link in the search results
+ @javascript
Scenario: I should see milestones I am looking for
And project has milestones
When I search for "Foo"
@@ -78,6 +81,7 @@ Feature: Search
And I search for "Sho"
Then I should see "Shop" project link
+ @javascript
Scenario: I logout and should see issues I am looking for
Given project "Shop" is public
And I logout directly
diff --git a/features/snippets/snippets.feature b/features/snippets/snippets.feature
deleted file mode 100644
index e15d7c79342..00000000000
--- a/features/snippets/snippets.feature
+++ /dev/null
@@ -1,39 +0,0 @@
-@snippets
-Feature: Snippets
- Background:
- Given I sign in as a user
- And I have public "Personal snippet one" snippet
- And I have private "Personal snippet private" snippet
-
- Scenario: I create new snippet
- Given I visit new snippet page
- And I submit new snippet "Personal snippet three"
- Then I should see snippet "Personal snippet three"
-
- Scenario: I update "Personal snippet one"
- Given I visit snippet page "Personal snippet one"
- And I click link "Edit"
- And I submit new title "Personal snippet new title"
- Then I should see "Personal snippet new title"
-
- Scenario: Set "Personal snippet one" public
- Given I visit snippet page "Personal snippet one"
- And I click link "Edit"
- And I uncheck "Private" checkbox
- Then I should see "Personal snippet one" public
-
- Scenario: I destroy "Personal snippet one"
- Given I visit snippet page "Personal snippet one"
- And I click link "Delete"
- Then I should not see "Personal snippet one" in snippets
-
- Scenario: I create new internal snippet
- Given I logout directly
- And I sign in as an admin
- Then I visit new snippet page
- And I submit new internal snippet
- Then I visit snippet page "Internal personal snippet one"
- And I logout directly
- Then I sign in as a user
- Given I visit new snippet page
- Then I visit snippet page "Internal personal snippet one"
diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb
deleted file mode 100644
index c715c85c43c..00000000000
--- a/features/steps/dashboard/dashboard.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-class Spinach::Features::Dashboard < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
- include SharedIssuable
-
- step 'I should see "New Project" link' do
- expect(page).to have_link "New project"
- end
-
- step 'I should see "Shop" project link' do
- expect(page).to have_link "Shop"
- end
-
- step 'I should see "Shop" project CI status' do
- expect(page).to have_link "Commit: skipped"
- end
-
- step 'I should see last push widget' do
- expect(page).to have_content "You pushed to fix"
- expect(page).to have_link "Create merge request"
- end
-
- step 'I click "Create merge request" link' do
- click_link "Create merge request"
- end
-
- step 'I see prefilled new Merge Request page' do
- expect(page).to have_selector('.merge-request-form')
- expect(current_path).to eq new_namespace_project_merge_request_path(@project.namespace, @project)
- expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s
- expect(find("input#merge_request_source_branch").value).to eq "fix"
- expect(find("input#merge_request_target_branch").value).to eq "master"
- end
-
- step 'I have group with projects' do
- @group = create(:group)
- @project = create(:empty_project, namespace: @group)
- @event = create(:closed_issue_event, project: @project)
-
- @project.team << [current_user, :master]
- end
-
- step 'I should see projects list' do
- @user.authorized_projects.all.each do |project|
- expect(page).to have_link project.name_with_namespace
- end
- end
-
- step 'I should see groups list' do
- Group.all.each do |group|
- expect(page).to have_link group.name
- end
- end
-
- step 'group has a projects that does not belongs to me' do
- @forbidden_project1 = create(:empty_project, group: @group)
- @forbidden_project2 = create(:empty_project, group: @group)
- end
-
- step 'I should see 1 project at group list' do
- expect(find('span.last_activity/span')).to have_content('1')
- end
-
- step 'I filter the list by label "feature"' do
- page.within ".labels-filter" do
- find('.dropdown').click
- click_link "feature"
- end
- end
-
- step 'I should see "Bugfix1" in issues list' do
- page.within "ul.content-list" do
- expect(page).to have_content "Bugfix1"
- end
- end
-
- step 'project "Shop" has issue "Bugfix1" with label "feature"' do
- project = Project.find_by(name: "Shop")
- issue = create(:issue, title: "Bugfix1", project: project, assignee: current_user)
- issue.labels << project.labels.find_by(title: 'feature')
- end
-end
diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb
deleted file mode 100644
index ca3cd0ecc4e..00000000000
--- a/features/steps/dashboard/event_filters.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-class Spinach::Features::EventFilters < Spinach::FeatureSteps
- include WaitForAjax
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'I should see push event' do
- expect(page).to have_selector('span.pushed')
- end
-
- step 'I should not see push event' do
- expect(page).not_to have_selector('span.pushed')
- end
-
- step 'I should see new member event' do
- expect(page).to have_selector('span.joined')
- end
-
- step 'I should not see new member event' do
- expect(page).not_to have_selector('span.joined')
- end
-
- step 'I should see merge request event' do
- expect(page).to have_selector('span.accepted')
- end
-
- step 'I should not see merge request event' do
- expect(page).not_to have_selector('span.accepted')
- end
-
- step 'this project has push event' do
- data = {
- before: Gitlab::Git::BLANK_SHA,
- after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e",
- ref: "refs/heads/new_design",
- user_id: @user.id,
- user_name: @user.name,
- repository: {
- name: @project.name,
- url: "localhost/rubinius",
- description: "",
- homepage: "localhost/rubinius",
- private: true
- }
- }
-
- @event = Event.create(
- project: @project,
- action: Event::PUSHED,
- data: data,
- author_id: @user.id
- )
- end
-
- step 'this project has new member event' do
- user = create(:user, { name: "John Doe" })
- Event.create(
- project: @project,
- author_id: user.id,
- action: Event::JOINED
- )
- end
-
- step 'this project has merge request event' do
- merge_request = create :merge_request, author: @user, source_project: @project, target_project: @project
- Event.create(
- project: @project,
- action: Event::MERGED,
- target_id: merge_request.id,
- target_type: "MergeRequest",
- author_id: @user.id
- )
- end
-
- When 'I click "push" event filter' do
- wait_for_ajax
- click_link("Push events")
- wait_for_ajax
- end
-
- When 'I click "team" event filter' do
- wait_for_ajax
- click_link("Team")
- wait_for_ajax
- end
-
- When 'I click "merge" event filter' do
- wait_for_ajax
- click_link("Merge events")
- wait_for_ajax
- end
-end
diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb
deleted file mode 100644
index 909ffec3646..00000000000
--- a/features/steps/dashboard/merge_requests.rb
+++ /dev/null
@@ -1,121 +0,0 @@
-class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include Select2Helper
-
- step 'I should see merge requests assigned to me' do
- should_see(assigned_merge_request)
- should_see(assigned_merge_request_from_fork)
- should_not_see(authored_merge_request)
- should_not_see(authored_merge_request_from_fork)
- should_not_see(other_merge_request)
- end
-
- step 'I should see merge requests authored by me' do
- should_see(authored_merge_request)
- should_see(authored_merge_request_from_fork)
- should_not_see(assigned_merge_request)
- should_not_see(assigned_merge_request_from_fork)
- should_not_see(other_merge_request)
- end
-
- step 'I should see all merge requests' do
- should_see(authored_merge_request)
- should_see(assigned_merge_request)
- should_see(other_merge_request)
- end
-
- step 'I have authored merge requests' do
- authored_merge_request
- authored_merge_request_from_fork
- end
-
- step 'I have assigned merge requests' do
- assigned_merge_request
- assigned_merge_request_from_fork
- end
-
- step 'I have other merge requests' do
- other_merge_request
- end
-
- step 'I click "Authored by me" link' do
- find("#assignee_id").set("")
- find(".js-author-search", match: :first).click
- find(".dropdown-menu-author li a", match: :first, text: current_user.to_reference).click
- end
-
- step 'I click "All" link' do
- find(".js-author-search").click
- expect(page).to have_selector(".dropdown-menu-author li a")
- find(".dropdown-menu-author li a", match: :first).click
- expect(page).not_to have_selector(".dropdown-menu-author li a")
-
- find(".js-assignee-search").click
- expect(page).to have_selector(".dropdown-menu-assignee li a")
- find(".dropdown-menu-assignee li a", match: :first).click
- expect(page).not_to have_selector(".dropdown-menu-assignee li a")
- end
-
- def should_see(merge_request)
- expect(page).to have_content(merge_request.title[0..10])
- end
-
- def should_not_see(merge_request)
- expect(page).not_to have_content(merge_request.title[0..10])
- end
-
- def assigned_merge_request
- @assigned_merge_request ||= create :merge_request,
- assignee: current_user,
- target_project: project,
- source_project: project
- end
-
- def authored_merge_request
- @authored_merge_request ||= create :merge_request,
- source_branch: 'markdown',
- author: current_user,
- target_project: project,
- source_project: project
- end
-
- def other_merge_request
- @other_merge_request ||= create :merge_request,
- source_branch: 'fix',
- target_project: project,
- source_project: project
- end
-
- def authored_merge_request_from_fork
- @authored_merge_request_from_fork ||= create :merge_request,
- source_branch: 'feature_conflict',
- author: current_user,
- target_project: public_project,
- source_project: forked_project
- end
-
- def assigned_merge_request_from_fork
- @assigned_merge_request_from_fork ||= create :merge_request,
- source_branch: 'markdown',
- assignee: current_user,
- target_project: public_project,
- source_project: forked_project
- end
-
- def project
- @project ||= begin
- project = create(:project, :repository)
- project.team << [current_user, :master]
- project
- end
- end
-
- def public_project
- @public_project ||= create(:project, :public, :repository)
- end
-
- def forked_project
- @forked_project ||= Projects::ForkService.new(public_project, current_user).execute
- end
-end
diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb
deleted file mode 100644
index 4fb16d3bb57..00000000000
--- a/features/steps/dashboard/new_project.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-class Spinach::Features::NewProject < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'I click "New project" link' do
- page.within('.content') do
- click_link "New project"
- end
- end
-
- step 'I see "New Project" page' do
- expect(page).to have_content('Project path')
- expect(page).to have_content('Project name')
- end
-
- step 'I see all possible import options' do
- expect(page).to have_link('GitHub')
- expect(page).to have_link('Bitbucket')
- expect(page).to have_link('GitLab.com')
- expect(page).to have_link('Google Code')
- expect(page).to have_button('Repo by URL')
- expect(page).to have_link('GitLab export')
- end
-
- step 'I click on "Import project from GitHub"' do
- first('.import_github').click
- end
-
- step 'I am redirected to the GitHub import page' do
- expect(page).to have_content('Import Projects from GitHub')
- expect(current_path).to eq new_import_github_path
- end
-
- step 'I click on "Repo by URL"' do
- first('.import_git').click
- end
-
- step 'I see instructions on how to import from Git URL' do
- git_import_instructions = first('.js-toggle-content')
- expect(git_import_instructions).to be_visible
- expect(git_import_instructions).to have_content "Git repository URL"
- end
-
- step 'I click on "Google Code"' do
- first('.import_google_code').click
- end
-
- step 'I redirected to Google Code import page' do
- expect(page).to have_content('Import projects from Google Code')
- expect(current_path).to eq new_import_google_code_path
- end
-end
diff --git a/features/steps/dashboard/starred_projects.rb b/features/steps/dashboard/starred_projects.rb
deleted file mode 100644
index c33813e550b..00000000000
--- a/features/steps/dashboard/starred_projects.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-class Spinach::Features::DashboardStarredProjects < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'I starred project "Community"' do
- current_user.toggle_star(Project.find_by(name: 'Community'))
- end
-
- step 'I should not see project "Shop"' do
- page.within '.projects-list' do
- expect(page).not_to have_content('Shop')
- end
- end
-end
diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb
deleted file mode 100644
index 3225e19995b..00000000000
--- a/features/steps/dashboard/todos.rb
+++ /dev/null
@@ -1,191 +0,0 @@
-class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
- include SharedUser
- include WaitForAjax
-
- step '"John Doe" is a developer of project "Shop"' do
- project.team << [john_doe, :developer]
- end
-
- step 'I am a developer of project "Enterprise"' do
- enterprise.team << [current_user, :developer]
- end
-
- step '"Mary Jane" is a developer of project "Shop"' do
- project.team << [john_doe, :developer]
- end
-
- step 'I have todos' do
- create(:todo, user: current_user, project: project, author: mary_jane, target: issue, action: Todo::MENTIONED)
- create(:todo, user: current_user, project: project, author: john_doe, target: issue, action: Todo::ASSIGNED)
- note = create(:note, author: john_doe, noteable: issue, note: "#{current_user.to_reference} Wdyt?", project: project)
- create(:todo, user: current_user, project: project, author: john_doe, target: issue, action: Todo::MENTIONED, note: note)
- create(:todo, user: current_user, project: project, author: john_doe, target: merge_request, action: Todo::ASSIGNED)
- end
-
- step 'I should see todos assigned to me' do
- merge_request_reference = merge_request.to_reference(full: true)
- issue_reference = issue.to_reference(full: true)
-
- page.within('.todos-count') { expect(page).to have_content '4' }
- expect(page).to have_content 'To do 4'
- expect(page).to have_content 'Done 0'
-
- expect(page).to have_link project.name_with_namespace
- should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title)
- should_see_todo(2, "John Doe mentioned you on issue #{issue_reference}", "#{current_user.to_reference} Wdyt?")
- should_see_todo(3, "John Doe assigned you issue #{issue_reference}", issue.title)
- should_see_todo(4, "Mary Jane mentioned you on issue #{issue_reference}", issue.title)
- end
-
- step 'I mark the todo as done' do
- page.within('.todo:nth-child(1)') do
- click_link 'Done'
- end
-
- page.within('.todos-count') { expect(page).to have_content '3' }
- expect(page).to have_content 'To do 3'
- expect(page).to have_content 'Done 1'
- should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_reversible)
- end
-
- step 'I mark all todos as done' do
- merge_request_reference = merge_request.to_reference(full: true)
- issue_reference = issue.to_reference(full: true)
-
- click_link 'Mark all as done'
-
- page.within('.todos-count') { expect(page).to have_content '0' }
- expect(page).to have_content 'To do 0'
- expect(page).to have_content 'Done 4'
- expect(page).to have_content "You're all done!"
- expect('.prepend-top-default').not_to have_link project.name_with_namespace
- should_not_see_todo "John Doe assigned you merge request #{merge_request_reference}"
- should_not_see_todo "John Doe mentioned you on issue #{issue_reference}"
- should_not_see_todo "John Doe assigned you issue #{issue_reference}"
- should_not_see_todo "Mary Jane mentioned you on issue #{issue_reference}"
- end
-
- step 'I should see the todo marked as done' do
- click_link 'Done 1'
-
- expect(page).to have_link project.name_with_namespace
- should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_irreversible)
- end
-
- step 'I should see all todos marked as done' do
- merge_request_reference = merge_request.to_reference(full: true)
- issue_reference = issue.to_reference(full: true)
-
- click_link 'Done 4'
-
- expect(page).to have_link project.name_with_namespace
- should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title, state: :done_irreversible)
- should_see_todo(2, "John Doe mentioned you on issue #{issue_reference}", "#{current_user.to_reference} Wdyt?", state: :done_irreversible)
- should_see_todo(3, "John Doe assigned you issue #{issue_reference}", issue.title, state: :done_irreversible)
- should_see_todo(4, "Mary Jane mentioned you on issue #{issue_reference}", issue.title, state: :done_irreversible)
- end
-
- step 'I filter by "Enterprise"' do
- click_button 'Project'
- page.within '.dropdown-menu-project' do
- click_link enterprise.name_with_namespace
- end
- end
-
- step 'I filter by "John Doe"' do
- click_button 'Author'
- page.within '.dropdown-menu-author' do
- click_link john_doe.username
- end
- end
-
- step 'I filter by "Issue"' do
- click_button 'Type'
- page.within '.dropdown-menu-type' do
- click_link 'Issue'
- end
- end
-
- step 'I filter by "Mentioned"' do
- click_button 'Action'
- page.within '.dropdown-menu-action' do
- click_link 'Mentioned'
- end
- end
-
- step 'I should not see todos' do
- expect(page).to have_content "You're all done!"
- end
-
- step 'I should not see todos related to "Mary Jane" in the list' do
- should_not_see_todo "Mary Jane mentioned you on issue #{issue.to_reference(full: true)}"
- end
-
- step 'I should not see todos related to "Merge Requests" in the list' do
- should_not_see_todo "John Doe assigned you merge request #{merge_request.to_reference(full: true)}"
- end
-
- step 'I should not see todos related to "Assignments" in the list' do
- should_not_see_todo "John Doe assigned you merge request #{merge_request.to_reference(full: true)}"
- should_not_see_todo "John Doe assigned you issue #{issue.to_reference(full: true)}"
- end
-
- step 'I click on the todo' do
- find('.todo:nth-child(1)').click
- end
-
- step 'I should be directed to the corresponding page' do
- page.should have_css('.identifier', text: 'Merge Request !1')
- # Merge request page loads and issues a number of Ajax requests
- wait_for_ajax
- end
-
- def should_see_todo(position, title, body, state: :pending)
- page.within(".todo:nth-child(#{position})") do
- expect(page).to have_content title
- expect(page).to have_content body
-
- if state == :pending
- expect(page).to have_link 'Done'
- elsif state == :done_reversible
- expect(page).to have_link 'Undo'
- elsif state == :done_irreversible
- expect(page).not_to have_link 'Undo'
- expect(page).not_to have_link 'Done'
- else
- raise 'Invalid state given, valid states: :pending, :done_reversible, :done_irreversible'
- end
- end
- end
-
- def should_not_see_todo(title)
- expect(page).not_to have_visible_content title
- end
-
- def have_visible_content(text)
- have_css('*', text: text, visible: true)
- end
-
- def john_doe
- @john_doe ||= user_exists("John Doe", { username: "john_doe" })
- end
-
- def mary_jane
- @mary_jane ||= user_exists("Mary Jane", { username: "mary_jane" })
- end
-
- def enterprise
- @enterprise ||= Project.find_by(name: 'Enterprise')
- end
-
- def issue
- @issue ||= create(:issue, assignee: current_user, project: project)
- end
-
- def merge_request
- @merge_request ||= create(:merge_request, assignee: current_user, source_project: project)
- end
-end
diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb
index 7dc33ab5683..f1288c15084 100644
--- a/features/steps/explore/projects.rb
+++ b/features/steps/explore/projects.rb
@@ -49,7 +49,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
step 'I should see an http link to the repository' do
project = Project.find_by(name: 'Community')
- expect(page).to have_field('project_clone', with: project.http_url_to_repo(@user))
+ expect(page).to have_field('project_clone', with: project.http_url_to_repo)
end
step 'I should see an ssh link to the repository' do
@@ -66,7 +66,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
title: "New feature",
project: public_project
)
- visit namespace_project_issues_path(public_project.namespace, public_project)
+ visit project_issues_path(public_project)
end
step 'I should see list of issues for "Community" project' do
@@ -84,7 +84,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
title: "New internal feature",
project: internal_project
)
- visit namespace_project_issues_path(internal_project.namespace, internal_project)
+ visit project_issues_path(internal_project)
end
step 'I should see list of issues for "Internal" project' do
@@ -94,14 +94,14 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
end
step 'I visit "Community" merge requests page' do
- visit namespace_project_merge_requests_path(public_project.namespace, public_project)
+ visit project_merge_requests_path(public_project)
end
step 'project "Community" has "Bug fix" open merge request' do
create(:merge_request,
title: "Bug fix for public project",
source_project: public_project,
- target_project: public_project,
+ target_project: public_project
)
end
@@ -111,7 +111,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
end
step 'I visit "Internal" merge requests page' do
- visit namespace_project_merge_requests_path(internal_project.namespace, internal_project)
+ visit project_merge_requests_path(internal_project)
end
step 'project "Internal" has "Feature implemented" open merge request' do
diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb
index b04a7015d4e..0ab1012660c 100644
--- a/features/steps/group/members.rb
+++ b/features/steps/group/members.rb
@@ -1,5 +1,5 @@
class Spinach::Features::GroupMembers < Spinach::FeatureSteps
- include WaitForAjax
+ include WaitForRequests
include SharedAuthentication
include SharedPaths
include SharedGroup
@@ -58,7 +58,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
click_link 'Developer'
end
- wait_for_ajax
+ wait_for_requests
end
end
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
index f8f5e3f2382..f6559b6be2f 100644
--- a/features/steps/group/milestones.rb
+++ b/features/steps/group/milestones.rb
@@ -1,4 +1,5 @@
class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
+ include WaitForRequests
include SharedAuthentication
include SharedPaths
include SharedGroup
@@ -38,7 +39,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
expect(page).to have_content('Milestone GL-113')
expect(page).to have_content('Issues 3 Open: 3 Closed: 0')
issue = Milestone.find_by(name: 'GL-113').issues.first
- expect(page).to have_link(issue.title, href: namespace_project_issue_path(issue.project.namespace, issue.project, issue))
+ expect(page).to have_link(issue.title, href: project_issue_path(issue.project, issue))
end
step 'I fill milestone name' do
@@ -53,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
click_button "Create milestone"
end
- step 'milestone in each project should be created' do
+ step 'group milestone should be created' do
group = Group.find_by(name: 'Owned')
- expect(page).to have_content "Milestone v2.9.0"
- expect(group.projects).to be_present
-
- group.projects.each do |project|
- expect(page).to have_content project.name
- end
+ expect(page).to have_content group.milestones.find_by_title('v2.9.0').title
end
step 'I should see the "bug" label' do
@@ -90,6 +86,8 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
end
step 'I should see the list of labels' do
+ wait_for_requests
+
page.within('#tab-labels') do
expect(page).to have_content 'bug'
expect(page).to have_content 'feature'
@@ -102,7 +100,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
group = owned_group
%w(gitlabhq gitlab-ci cookbook-gitlab).each do |path|
- project = create(:empty_project, path: path, group: group)
+ project = create(:project, path: path, group: group)
milestone = create :milestone, title: "Version 7.2", project: project
create(:label, project: project, title: 'bug')
@@ -110,7 +108,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
create :issue,
project: project,
- assignee: current_user,
+ assignees: [current_user],
author: current_user,
milestone: milestone
@@ -122,7 +120,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
issue = create :issue,
project: project,
- assignee: current_user,
+ assignees: [current_user],
author: current_user,
milestone: milestone
diff --git a/features/steps/groups.rb b/features/steps/groups.rb
index 4dc87dc4d9c..a2d9a0332e0 100644
--- a/features/steps/groups.rb
+++ b/features/steps/groups.rb
@@ -5,7 +5,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
include SharedUser
step 'I should see group "Owned"' do
- expect(page).to have_content '@owned'
+ expect(page).to have_content 'Owned'
end
step 'I am a signed out user' do
@@ -15,7 +15,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'Group "Owned" has a public project "Public-project"' do
group = owned_group
- @project = create :empty_project, :public,
+ @project = create :project, :public,
group: group,
name: "Public-project"
end
@@ -61,7 +61,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'project from group "Owned" has issues assigned to me' do
create :issue,
project: project,
- assignee: current_user,
+ assignees: [current_user],
author: current_user
end
@@ -81,7 +81,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'I should see new group "Owned" avatar' do
expect(owned_group.avatar).to be_instance_of AvatarUploader
- expect(owned_group.avatar.url).to eq "/uploads/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif"
+ expect(owned_group.avatar.url).to eq "/uploads/-/system/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
@@ -109,7 +109,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'Group "Owned" has archived project' do
group = Group.find_by(name: 'Owned')
- @archived_project = create(:empty_project, :archived, namespace: group, path: "archived-project")
+ @archived_project = create(:project, :archived, namespace: group, path: "archived-project")
end
step 'I should see "archived" label' do
@@ -123,7 +123,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'the archived project have some issues' do
create :issue,
project: @archived_project,
- assignee: current_user,
+ assignees: [current_user],
author: current_user
end
diff --git a/features/steps/profile/active_tab.rb b/features/steps/profile/active_tab.rb
index 4724a326277..069d4e6a23d 100644
--- a/features/steps/profile/active_tab.rb
+++ b/features/steps/profile/active_tab.rb
@@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps
ensure_active_main_tab('Preferences')
end
- step 'the active main tab should be Audit Log' do
- ensure_active_main_tab('Audit Log')
+ step 'the active main tab should be Authentication log' do
+ ensure_active_main_tab('Authentication log')
end
end
diff --git a/features/steps/profile/emails.rb b/features/steps/profile/emails.rb
index 10ebe705365..4f44f932a6d 100644
--- a/features/steps/profile/emails.rb
+++ b/features/steps/profile/emails.rb
@@ -28,7 +28,7 @@ class Spinach::Features::ProfileEmails < Spinach::FeatureSteps
expect(email).to be_nil
expect(page).not_to have_content("my@email.com")
end
-
+
step 'I click link "Remove" for "my@email.com"' do
# there should only be one remove button at this time
click_link "Remove"
diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb
index 24cfbaad7fe..4b88cb5e27f 100644
--- a/features/steps/profile/profile.rb
+++ b/features/steps/profile/profile.rb
@@ -36,7 +36,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I should see new avatar' do
expect(@user.avatar).to be_instance_of AvatarUploader
- expect(@user.avatar.url).to eq "/uploads/user/avatar/#{@user.id}/banana_sample.gif"
+ expect(@user.avatar.url).to eq "/uploads/-/system/user/avatar/#{@user.id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb
index 4befd49ac81..5cd9bd38c9d 100644
--- a/features/steps/project/active_tab.rb
+++ b/features/steps/project/active_tab.rb
@@ -39,12 +39,6 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
end
- step 'I click the "Pages" tab' do
- page.within '.sub-nav' do
- click_link('Pages')
- end
- end
-
step 'I click the "Activity" tab' do
page.within '.sub-nav' do
click_link('Activity')
diff --git a/features/steps/project/archived.rb b/features/steps/project/archived.rb
index b6f1d417e21..e4847180be9 100644
--- a/features/steps/project/archived.rb
+++ b/features/steps/project/archived.rb
@@ -15,7 +15,7 @@ class Spinach::Features::ProjectArchived < Spinach::FeatureSteps
When 'I visit project "Forum" page' do
project = Project.find_by(name: "Forum")
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I should not see "Archived"' do
diff --git a/features/steps/project/badges/build.rb b/features/steps/project/badges/build.rb
deleted file mode 100644
index 96c59322f9b..00000000000
--- a/features/steps/project/badges/build.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-class Spinach::Features::ProjectBadgesBuild < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedProject
- include SharedBuilds
- include RepoHelpers
-
- step 'I display builds badge for a master branch' do
- visit build_namespace_project_badges_path(@project.namespace, @project, ref: :master, format: :svg)
- end
-
- step 'I should see a build success badge' do
- expect_badge('success')
- end
-
- step 'I should see a build failed badge' do
- expect_badge('failed')
- end
-
- step 'I should see a build running badge' do
- expect_badge('running')
- end
-
- step 'I should see a badge that has not been cached' do
- expect(page.response_headers['Cache-Control']).to include 'no-cache'
- end
-
- def expect_badge(status)
- svg = Nokogiri::XML.parse(page.body)
- expect(page.response_headers['Content-Type']).to include('image/svg+xml')
- expect(svg.at(%Q{text:contains("#{status}")})).to be_truthy
- end
-end
diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb
index be0f6eee55a..4b72355b125 100644
--- a/features/steps/project/builds/artifacts.rb
+++ b/features/steps/project/builds/artifacts.rb
@@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
include SharedProject
include SharedBuilds
include RepoHelpers
+ include WaitForRequests
step 'I click artifacts download button' do
click_link 'Download'
@@ -22,6 +23,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
end
end
+ step 'I should see the build header' do
+ page.within('.build-header') do
+ expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for #{@pipeline.short_sha}"
+ end
+ end
+
step 'I click link to subdirectory within build artifacts' do
page.within('.tree-table') { click_link 'other_artifacts_0.1.2' }
end
@@ -34,6 +41,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
end
end
+ step 'I should see the directory name in the breadcrumb' do
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_content 'other_artifacts_0.1.2'
+ end
+ end
+
step 'recent build artifacts contain directory with UTF-8 characters' do
# metadata fixture contains relevant directory
end
@@ -66,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
step 'I click a link to file within build artifacts' do
page.within('.tree-table') { find_link('ci_artifacts.txt').click }
+ wait_for_requests
end
- step 'download of a file extracted from build artifacts should start' do
- send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER]
-
- expect(send_data).to start_with('artifacts-entry:')
-
- base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
- params = JSON.parse(Base64.urlsafe_decode64(base64_params))
-
- expect(params.keys).to eq(%w(Archive Entry))
- expect(params['Archive']).to end_with('build_artifacts.zip')
- expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
+ step 'I see a download link' do
+ expect(page).to have_link 'download it'
end
step 'I click a first row within build artifacts table' do
diff --git a/features/steps/project/builds/summary.rb b/features/steps/project/builds/summary.rb
index 229e5d7cdf4..20a5c873ecd 100644
--- a/features/steps/project/builds/summary.rb
+++ b/features/steps/project/builds/summary.rb
@@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps
step 'I see button to CI Lint' do
page.within('.nav-controls') do
ci_lint_tool_link = page.find_link('CI lint')
- expect(ci_lint_tool_link[:href]).to eq ci_lint_path
+ expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
end
end
diff --git a/features/steps/project/commits/commits.rb b/features/steps/project/commits/commits.rb
index de737cdc823..305fff37c41 100644
--- a/features/steps/project/commits/commits.rb
+++ b/features/steps/project/commits/commits.rb
@@ -21,7 +21,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
expect(response_headers['Content-Type']).to have_content("application/atom+xml")
expect(body).to have_selector("title", text: "#{@project.name}:master commits")
expect(body).to have_selector("author email", text: commit.author_email)
- expect(body).to have_selector("entry summary", text: commit.description[0..10].delete("\r"))
+ expect(body).to have_selector("entry summary", text: commit.description[0..10].delete("\r\n"))
end
step 'I click on tag link' do
@@ -33,7 +33,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
end
step 'I click on commit link' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_commit.id)
+ visit project_commit_path(@project, sample_commit.id)
end
step 'I see commit info' do
@@ -73,7 +73,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
end
step 'I visit commits list page for feature branch' do
- visit namespace_project_commits_path(@project.namespace, @project, 'feature', { limit: 5 })
+ visit project_commits_path(@project, 'feature', { limit: 5 })
end
step 'I see feature branch commits' do
@@ -119,7 +119,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
step 'I should see button to the merge request' do
merge_request = MergeRequest.find_by(title: 'Feature')
- expect(page).to have_link "View open merge request", href: namespace_project_merge_request_path(@project.namespace, @project, merge_request)
+ expect(page).to have_link "View open merge request", href: project_merge_request_path(@project, merge_request)
end
step 'I see breadcrumb links' do
@@ -135,7 +135,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
end
step 'I visit a commit with an image that changed' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_image_commit.id)
+ visit project_commit_path(@project, sample_image_commit.id)
end
step 'The diff links to both the previous and current image' do
diff --git a/features/steps/project/commits/revert.rb b/features/steps/project/commits/revert.rb
index c9746407344..ebfa7a878bb 100644
--- a/features/steps/project/commits/revert.rb
+++ b/features/steps/project/commits/revert.rb
@@ -6,10 +6,11 @@ class Spinach::Features::RevertCommits < Spinach::FeatureSteps
include RepoHelpers
step 'I click on commit link' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_commit.id)
+ visit project_commit_path(@project, sample_commit.id)
end
step 'I click on the revert button' do
+ find(".header-action-buttons .dropdown").click
find("a[href='#modal-revert-commit']").click
end
diff --git a/features/steps/project/commits/user_lookup.rb b/features/steps/project/commits/user_lookup.rb
index 2d43be5a386..4599e0d032a 100644
--- a/features/steps/project/commits/user_lookup.rb
+++ b/features/steps/project/commits/user_lookup.rb
@@ -4,11 +4,11 @@ class Spinach::Features::ProjectCommitsUserLookup < Spinach::FeatureSteps
include SharedPaths
step 'I click on commit link' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_commit.id)
+ visit project_commit_path(@project, sample_commit.id)
end
step 'I click on another commit link' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_commit.parent_id)
+ visit project_commit_path(@project, sample_commit.parent_id)
end
step 'I have user with primary email' do
diff --git a/features/steps/project/create.rb b/features/steps/project/create.rb
index 5f5f806df36..60fa232672e 100644
--- a/features/steps/project/create.rb
+++ b/features/steps/project/create.rb
@@ -5,12 +5,14 @@ class Spinach::Features::ProjectCreate < Spinach::FeatureSteps
step 'fill project form with valid data' do
fill_in 'project_path', with: 'Empty'
- click_button "Create project"
+ page.within '#content-body' do
+ click_button "Create project"
+ end
end
step 'I should see project page' do
expect(page).to have_content "Empty"
- expect(current_path).to eq namespace_project_path(Project.last.namespace, Project.last)
+ expect(current_path).to eq project_path(Project.last)
end
step 'I should see empty project instructions' do
diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb
index ec59a2c094e..b4403becb0d 100644
--- a/features/steps/project/deploy_keys.rb
+++ b/features/steps/project/deploy_keys.rb
@@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end
step 'I should see project deploy key' do
- page.within '.deploy-keys' do
+ page.within(find('.deploy-keys')) do
expect(page).to have_content deploy_key.title
end
end
step 'I should see other project deploy key' do
- page.within '.deploy-keys' do
+ page.within(find('.deploy-keys')) do
expect(page).to have_content other_deploy_key.title
end
end
step 'I should see public deploy key' do
- page.within '.deploy-keys' do
+ page.within(find('.deploy-keys')) do
expect(page).to have_content public_deploy_key.title
end
end
@@ -36,27 +36,28 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end
step 'I should be on deploy keys page' do
- expect(current_path).to eq namespace_project_settings_repository_path(@project.namespace, @project)
+ expect(current_path).to eq project_settings_repository_path(@project)
end
step 'I should see newly created deploy key' do
- page.within '.deploy-keys' do
+ @project.reload
+ page.within(find('.deploy-keys')) do
expect(page).to have_content(deploy_key.title)
end
end
step 'other projects have deploy keys' do
- @second_project = create(:empty_project, namespace: create(:group))
+ @second_project = create(:project, namespace: create(:group))
@second_project.team << [current_user, :master]
create(:deploy_keys_project, project: @second_project)
- @third_project = create(:empty_project, namespace: create(:group))
+ @third_project = create(:project, namespace: create(:group))
@third_project.team << [current_user, :master]
create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
end
step 'I should only see the same deploy key once' do
- page.within '.deploy-keys' do
+ page.within(find('.deploy-keys')) do
expect(page).to have_selector('ul li', count: 1)
end
end
@@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end
step 'I click attach deploy key' do
- page.within '.deploy-keys' do
- click_link 'Enable'
+ page.within(find('.deploy-keys')) do
+ click_button 'Enable'
+ expect(page).not_to have_selector('.fa-spinner')
end
end
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
index 7591e7d5612..dd4dff7f7a9 100644
--- a/features/steps/project/fork.rb
+++ b/features/steps/project/fork.rb
@@ -5,7 +5,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
step 'I click link "Fork"' do
expect(page).to have_content "Shop"
- click_link "Fork project"
+ click_link "Fork"
end
step 'I am a member of project "Shop"' do
@@ -42,7 +42,9 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I click link "New merge request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I should see the new merge request page for my namespace' do
@@ -51,7 +53,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
step 'I visit the forks page of the "Shop" project' do
@project = Project.where(name: 'Shop').last
- visit namespace_project_forks_path(@project.namespace, @project)
+ visit project_forks_path(@project)
end
step 'I should see my fork on the list' do
diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb
index 8081b764be6..420ac8a695a 100644
--- a/features/steps/project/forked_merge_requests.rb
+++ b/features/steps/project/forked_merge_requests.rb
@@ -4,6 +4,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
include SharedNote
include SharedPaths
include Select2Helper
+ include WaitForRequests
step 'I am a member of project "Shop"' do
@project = ::Project.find_by(name: "Shop")
@@ -16,21 +17,25 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
end
step 'I click link "New Merge Request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I should see merge request "Merge Request On Forked Project"' do
expect(@project.merge_requests.size).to be >= 1
@merge_request = @project.merge_requests.last
- expect(current_path).to eq namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ expect(current_path).to eq project_merge_request_path(@project, @merge_request)
expect(@merge_request.title).to eq "Merge Request On Forked Project"
expect(@merge_request.source_project).to eq @forked_project
expect(@merge_request.source_branch).to eq "fix"
expect(@merge_request.target_branch).to eq "master"
- expect(page).to have_content @forked_project.path_with_namespace
- expect(page).to have_content @project.path_with_namespace
+ expect(page).to have_content @forked_project.full_path
+ expect(page).to have_content @project.full_path
expect(page).to have_content @merge_request.source_branch
expect(page).to have_content @merge_request.target_branch
+
+ wait_for_requests
end
step 'I fill out a "Merge Request On Forked Project" merge request' do
@@ -38,12 +43,13 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
expect(page).to have_content('Target branch')
first('.js-source-project').click
- first('.dropdown-source-project a', text: @forked_project.path_with_namespace)
+ first('.dropdown-source-project a', text: @forked_project.full_path)
first('.js-target-project').click
- first('.dropdown-target-project a', text: @project.path_with_namespace)
+ first('.dropdown-target-project a', text: @project.full_path)
first('.js-source-branch').click
+ wait_for_requests
first('.dropdown-source-branch .dropdown-content a', text: 'fix').click
click_button "Compare branches and continue"
@@ -59,31 +65,6 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
click_button "Submit merge request"
end
- step 'I follow the target commit link' do
- commit = @project.repository.commit
- click_link commit.short_id(8)
- end
-
- step 'I should see the commit under the forked from project' do
- commit = @project.repository.commit
- expect(page).to have_content(commit.message)
- end
-
- step 'I click "Create Merge Request on fork" link' do
- click_link "Create Merge Request on fork"
- end
-
- step 'I see prefilled new Merge Request page for the forked project' do
- expect(current_path).to eq new_namespace_project_merge_request_path(@forked_project.namespace, @forked_project)
- expect(find("#merge_request_source_project_id").value).to eq @forked_project.id.to_s
- expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s
- expect(find("#merge_request_source_branch").value).to have_content "new_design"
- expect(find("#merge_request_target_branch").value).to have_content "master"
- expect(find("#merge_request_title").value).to eq "New Design"
- verify_commit_link(".mr_target_commit", @project)
- verify_commit_link(".mr_source_commit", @forked_project)
- end
-
step 'I update the merge request title' do
fill_in "merge_request_title", with: "An Edited Forked Merge Request"
end
@@ -96,12 +77,12 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
expect(page).to have_content "An Edited Forked Merge Request"
expect(@project.merge_requests.size).to be >= 1
@merge_request = @project.merge_requests.last
- expect(current_path).to eq namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ expect(current_path).to eq project_merge_request_path(@project, @merge_request)
expect(@merge_request.source_project).to eq @forked_project
expect(@merge_request.source_branch).to eq "fix"
expect(@merge_request.target_branch).to eq "master"
- expect(page).to have_content @forked_project.path_with_namespace
- expect(page).to have_content @project.path_with_namespace
+ expect(page).to have_content @forked_project.full_path
+ expect(page).to have_content @project.full_path
expect(page).to have_content @merge_request.source_branch
expect(page).to have_content @merge_request.target_branch
end
@@ -116,7 +97,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
end
step 'I see the edit page prefilled for "Merge Request On Forked Project"' do
- expect(current_path).to eq edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ expect(current_path).to eq edit_project_merge_request_path(@project, @merge_request)
expect(page).to have_content "Edit merge request #{@merge_request.to_reference}"
expect(find("#merge_request_title").value).to eq "Merge Request On Forked Project"
end
@@ -152,10 +133,4 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
expect(page).to have_content @project.users.first.name
end
end
-
- # Verify a link is generated against the correct project
- def verify_commit_link(container_div, container_project)
- # This should force a wait for the javascript to execute
- expect(find(:div, container_div).find(".commit_short_id")['href']).to have_content "#{container_project.path_with_namespace}/commit"
- end
end
diff --git a/features/steps/project/graph.rb b/features/steps/project/graph.rb
index 176d04d721c..e78e25318a6 100644
--- a/features/steps/project/graph.rb
+++ b/features/steps/project/graph.rb
@@ -7,19 +7,19 @@ class Spinach::Features::ProjectGraph < Spinach::FeatureSteps
end
When 'I visit project "Shop" graph page' do
- visit namespace_project_graph_path(project.namespace, project, "master")
+ visit project_graph_path(project, "master")
end
step 'I visit project "Shop" commits graph page' do
- visit commits_namespace_project_graph_path(project.namespace, project, "master")
+ visit commits_project_graph_path(project, "master")
end
step 'I visit project "Shop" languages graph page' do
- visit languages_namespace_project_graph_path(project.namespace, project, "master")
+ visit languages_project_graph_path(project, "master")
end
step 'I visit project "Shop" chart page' do
- visit charts_namespace_project_graph_path(project.namespace, project, "master")
+ visit charts_project_graph_path(project, "master")
end
step 'page should have languages graphs' do
@@ -33,7 +33,7 @@ class Spinach::Features::ProjectGraph < Spinach::FeatureSteps
end
step 'I visit project "Shop" CI graph page' do
- visit ci_namespace_project_graph_path(project.namespace, project, 'master')
+ visit ci_project_graph_path(project, 'master')
end
step 'page should have CI graphs' do
diff --git a/features/steps/project/hooks.rb b/features/steps/project/hooks.rb
deleted file mode 100644
index 945d58a6458..00000000000
--- a/features/steps/project/hooks.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-require 'webmock'
-
-class Spinach::Features::ProjectHooks < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedProject
- include SharedPaths
- include RSpec::Matchers
- include RSpec::Mocks::ExampleMethods
- include WebMock::API
-
- step 'project has hook' do
- @hook = create(:project_hook, project: current_project)
- end
-
- step 'I own empty project with hook' do
- @project = create(:empty_project,
- name: 'Empty Project', namespace: @user.namespace)
- @hook = create(:project_hook, project: current_project)
- end
-
- step 'I should see project hook' do
- expect(page).to have_content @hook.url
- end
-
- step 'I submit new hook' do
- @url = 'http://example.org/1'
- fill_in "hook_url", with: @url
- expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1)
- end
-
- step 'I submit new hook with SSL verification enabled' do
- @url = 'http://example.org/2'
- fill_in "hook_url", with: @url
- check "hook_enable_ssl_verification"
- expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1)
- end
-
- step 'I should see newly created hook' do
- expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project)
- expect(page).to have_content(@url)
- end
-
- step 'I should see newly created hook with SSL verification enabled' do
- expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project)
- expect(page).to have_content(@url)
- expect(page).to have_content("SSL Verification: enabled")
- end
-
- step 'I click test hook button' do
- stub_request(:post, @hook.url).to_return(status: 200)
- click_link 'Test'
- end
-
- step 'I click test hook button with invalid URL' do
- stub_request(:post, @hook.url).to_raise(SocketError)
- click_link 'Test'
- end
-
- step 'hook should be triggered' do
- expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project)
- expect(page).to have_selector '.flash-notice',
- text: 'Hook executed successfully: HTTP 200'
- end
-
- step 'I should see hook error message' do
- expect(page).to have_selector '.flash-alert',
- text: 'Hook execution failed. '\
- 'Ensure the project has commits.'
- end
-
- step 'I should see hook service down error message' do
- expect(page).to have_selector '.flash-alert',
- text: 'Hook execution failed: Exception from'
- end
-end
diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb
index dfd0bc13305..bbd284b4633 100644
--- a/features/steps/project/issues/award_emoji.rb
+++ b/features/steps/project/issues/award_emoji.rb
@@ -5,7 +5,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
include Select2Helper
step 'I visit "Bugfix" issue page' do
- visit namespace_project_issue_path(@project.namespace, @project, @issue)
+ visit project_issue_path(@project, @issue)
end
step 'I click the thumbsup award Emoji' do
@@ -34,8 +34,8 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
page.within '.awards' do
expect do
page.find('.js-emoji-btn.active').click
- sleep 0.3
- end.to change{ page.all(".award-control.js-emoji-btn").size }.from(3).to(2)
+ wait_for_requests
+ end.to change { page.all(".award-control.js-emoji-btn").size }.from(3).to(2)
end
end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 637e6568267..2deef9036d3 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -28,7 +28,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "Closed"' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end
step 'I click button "Unsubscribe"' do
@@ -44,7 +44,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "All"' do
- click_link "All"
+ find('.issues-state-filters [data-state="all"] span', text: 'All').click
# Waits for load
expect(find('.issues-state-filters > .active')).to have_content 'All'
end
@@ -62,7 +62,9 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New issue"' do
- page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
+ page.within '#content-body' do
+ page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
+ end
end
step 'I click "author" dropdown' do
@@ -245,7 +247,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
When 'I visit empty project page' do
project = Project.find_by(name: 'Empty Project')
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I see empty project details with ssh clone info' do
@@ -257,12 +259,12 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
When "I visit project \"Community\" issues page" do
project = Project.find_by(name: 'Community')
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
When "I visit empty project's issues page" do
project = Project.find_by(name: 'Empty Project')
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
step 'I leave a comment with code block' do
diff --git a/features/steps/project/issues/labels.rb b/features/steps/project/issues/labels.rb
index 2828e41f731..dac18c537ac 100644
--- a/features/steps/project/issues/labels.rb
+++ b/features/steps/project/issues/labels.rb
@@ -4,7 +4,7 @@ class Spinach::Features::ProjectIssuesLabels < Spinach::FeatureSteps
include SharedPaths
step 'I visit \'bug\' label edit page' do
- visit edit_namespace_project_label_path(project.namespace, project, bug_label)
+ visit edit_project_label_path(project, bug_label)
end
step 'I remove label \'bug\'' do
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index a06b2f2911f..7254fbc2e4e 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -7,14 +7,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
include SharedMarkdown
include SharedDiffNote
include SharedUser
- include WaitForAjax
+ include WaitForRequests
after do
- wait_for_ajax if javascript_test?
+ wait_for_requests if javascript_test?
end
step 'I click link "New Merge Request"' do
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
end
step 'I click link "Bug NS-04"' do
@@ -26,42 +28,44 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "All"' do
- click_link "All"
+ find('.issues-state-filters [data-state="all"] span', text: 'All').click
# Waits for load
expect(find('.issues-state-filters > .active')).to have_content 'All'
end
step 'I click link "Merged"' do
- click_link "Merged"
+ find('#state-merged').trigger('click')
end
step 'I click link "Closed"' do
- page.within('.issues-state-filters') do
- click_link "Closed"
- end
+ find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end
step 'I should see merge request "Wiki Feature"' do
page.within '.merge-request' do
expect(page).to have_content "Wiki Feature"
end
+ wait_for_requests
end
step 'I should see closed merge request "Bug NS-04"' do
expect(page).to have_content "Bug NS-04"
expect(page).to have_content "Closed by"
+ wait_for_requests
end
step 'I should see merge request "Bug NS-04"' do
expect(page).to have_content "Bug NS-04"
+ wait_for_requests
end
step 'I should see merge request "Feature NS-05"' do
expect(page).to have_content "Feature NS-05"
+ wait_for_requests
end
step 'I should not see "master" branch' do
- expect(find('.merge-request-info')).not_to have_content "master"
+ expect(find('.issuable-info')).not_to have_content "master"
end
step 'I should see "feature_conflict" branch' do
@@ -94,7 +98,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I click button "Unsubscribe"' do
click_on "Unsubscribe"
- wait_for_ajax
+ wait_for_requests
end
step 'I click link "Close"' do
@@ -252,7 +256,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I switch to the merge request\'s comments tab' do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
step 'I click on the commit in the merge request' do
@@ -320,13 +324,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I delete the comment "Line is wrong" on diff' do
page.within('.diff-file:nth-of-type(5) .note') do
+ find('.more-actions').click
+ find('.more-actions .dropdown-menu li', match: :first)
+
find('.js-note-delete').click
end
end
step 'I click on the Discussion tab' do
page.within '.merge-request-tabs' do
- click_link 'Discussion'
+ find('.notes-tab').trigger('click')
end
# Waits for load
@@ -348,7 +355,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
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
+ wait_for_requests
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
@@ -358,10 +365,12 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I should see a badge of "1" next to the discussion link' do
expect_discussion_badge_to_have_counter("1")
+ wait_for_requests
end
step 'I should see a badge of "0" next to the discussion link' do
expect_discussion_badge_to_have_counter("0")
+ wait_for_requests
end
step 'I should see a discussion has started on commit diff' do
@@ -369,6 +378,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit"
page.should have_content sample_commit.line_code_path
page.should have_content "Line is wrong"
+ wait_for_requests
end
end
@@ -376,16 +386,17 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
page.within(".notes .discussion") do
page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit"
page.should have_content "One comment to rule them all"
+ wait_for_requests
end
end
step 'merge request is mergeable' do
- expect(page).to have_button 'Accept merge request'
+ expect(page).to have_button 'Merge'
end
step 'I modify merge commit message' do
click_button "Modify commit message"
- fill_in 'commit_message', with: 'wow such merge'
+ fill_in 'Commit message', with: 'wow such merge'
end
step 'merge request "Bug NS-05" is mergeable' do
@@ -394,24 +405,26 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I accept this merge request' do
page.within '.mr-state-widget' do
- click_button "Accept merge request"
+ click_button "Merge"
end
end
step 'I should see merged request' do
page.within '.status-box' do
expect(page).to have_content "Merged"
+ wait_for_requests
end
end
step 'I click link "Reopen"' do
- first(:css, '.reopen-mr-link').click
+ first(:css, '.reopen-mr-link').trigger('click')
end
step 'I should see reopened merge request "Bug NS-04"' do
page.within '.status-box' do
expect(page).to have_content "Open"
end
+ wait_for_requests
end
step 'I click link "Hide inline discussion" of the third file' do
@@ -435,6 +448,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I should see a comment like "Line is wrong" in the third file' do
page.within '.files>div:nth-child(3) .note-body > .note-text' do
expect(page).to have_visible_content "Line is wrong"
+ wait_for_requests
end
end
@@ -458,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
click_button "Comment"
end
+ wait_for_requests
+
page.within ".files>div:nth-child(2) .note-body > .note-text" do
expect(page).to have_content "Line is correct"
end
@@ -470,6 +486,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in "note_note", with: "Line is wrong on here"
click_button "Comment"
end
+
+ wait_for_requests
end
step 'I should still see a comment like "Line is correct" in the second file' do
@@ -498,6 +516,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I should see comments on the side-by-side diff page' do
page.within '.files>div:nth-child(2) .parallel .note-body > .note-text' do
expect(page).to have_visible_content "Line is correct"
+ wait_for_requests
end
end
@@ -521,7 +540,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step 'I should see new target branch changes' do
expect(page).to have_content 'Request to merge fix into feature'
expect(page).to have_content 'changed target branch from merge-test to feature'
- wait_for_ajax
+ wait_for_requests
end
step 'I click on "Email Patches"' do
@@ -539,7 +558,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
step '"Bug NS-05" has CI status' do
project = merge_request.source_project
project.enable_ci
- pipeline = create :ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch
+
+ pipeline =
+ create(:ci_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ head_pipeline_of: merge_request)
+
create :ci_build, pipeline: pipeline
end
@@ -553,12 +579,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
page.within ".mr-source-target" do
expect(page).to have_content /([0-9]+ commits behind)/
end
+
+ wait_for_requests
end
step 'I should not see the diverged commits count' do
page.within ".mr-source-target" do
expect(page).not_to have_content /([0-9]+ commit[s]? behind)/
end
+
+ wait_for_requests
end
def merge_request
@@ -574,6 +604,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in "note_note", with: message
click_button "Comment"
end
+
+ wait_for_requests
+
page.within(".notes_holder", visible: true) do
expect(page).to have_content message
end
diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb
index 7521a9439e3..3c640e3512a 100644
--- a/features/steps/project/merge_requests/acceptance.rb
+++ b/features/steps/project/merge_requests/acceptance.rb
@@ -1,7 +1,6 @@
class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
include LoginHelpers
- include GitlabRoutingHelper
- include WaitForAjax
+ include WaitForRequests
step 'I am on the Merge Request detail page' do
visit merge_request_path(@merge_request)
@@ -11,28 +10,32 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
visit merge_request_path(@merge_request, anchor: 'note_123')
end
- step 'I click on "Remove source branch" option' do
+ step 'I uncheck the "Remove source branch" option' do
+ uncheck('Remove source branch')
+ end
+
+ step 'I check the "Remove source branch" option' do
check('Remove source branch')
end
step 'I click on Accept Merge Request' do
- click_button('Accept merge request')
+ click_button('Merge')
end
step 'I should see the Remove Source Branch button' do
- expect(page).to have_link('Remove source branch')
+ expect(page).to have_selector('.js-remove-branch-button')
- # Wait for AJAX requests to complete so they don't blow up if they are
+ # Wait for View Resource requests to complete so they don't blow up if they are
# only handled after `DatabaseCleaner` has already run
- wait_for_ajax
+ wait_for_requests
end
step 'I should not see the Remove Source Branch button' do
- expect(page).not_to have_link('Remove source branch')
+ expect(page).not_to have_selector('.js-remove-branch-button')
- # Wait for AJAX requests to complete so they don't blow up if they are
+ # Wait for View Resource requests to complete so they don't blow up if they are
# only handled after `DatabaseCleaner` has already run
- wait_for_ajax
+ wait_for_requests
end
step 'There is an open Merge Request' do
diff --git a/features/steps/project/merge_requests/revert.rb b/features/steps/project/merge_requests/revert.rb
index 1149c1c2426..25ccf5ab180 100644
--- a/features/steps/project/merge_requests/revert.rb
+++ b/features/steps/project/merge_requests/revert.rb
@@ -1,6 +1,6 @@
class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
include LoginHelpers
- include GitlabRoutingHelper
+ include WaitForRequests
step 'I click on the revert button' do
find("a[href='#modal-revert-commit']").click
@@ -15,6 +15,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
step 'I should see the revert merge request notice' do
page.should have_content('The merge request has been successfully reverted.')
+ wait_for_requests
end
step 'I should not see the revert button' do
@@ -26,7 +27,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
end
step 'I click on Accept Merge Request' do
- click_button('Accept merge request')
+ click_button('Merge')
end
step 'I am signed in as a developer of the project' do
diff --git a/features/steps/project/network_graph.rb b/features/steps/project/network_graph.rb
index 370e46265c7..ba98d861e7b 100644
--- a/features/steps/project/network_graph.rb
+++ b/features/steps/project/network_graph.rb
@@ -12,11 +12,11 @@ class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps
Network::Graph.stub(max_count: 10)
@project = Project.find_by(name: "Shop")
- visit namespace_project_network_path(@project.namespace, @project, "master")
+ visit project_network_path(@project, "master")
end
step "I visit project network page on branch 'test'" do
- visit namespace_project_network_path(@project.namespace, @project, "'test'")
+ visit project_network_path(@project, "'test'")
end
step 'page should select "master" in select box' do
diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb
index 4045955a8b9..275fb4fc010 100644
--- a/features/steps/project/pages.rb
+++ b/features/steps/project/pages.rb
@@ -15,19 +15,27 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps
end
step 'I visit the Project Pages' do
- visit namespace_project_pages_path(@project.namespace, @project)
- end
-
- step 'I should see that GitLab Pages are disabled' do
- expect(page).to have_content('GitLab Pages are disabled')
+ visit project_pages_path(@project)
end
step 'I should see the usage of GitLab Pages' do
expect(page).to have_content('Configure pages')
end
+ step 'I should see the "Pages" tab' do
+ page.within '.sub-nav' do
+ expect(page).to have_link('Pages')
+ end
+ end
+
+ step 'I should not see the "Pages" tab' do
+ page.within '.sub-nav' do
+ expect(page).not_to have_link('Pages')
+ end
+ end
+
step 'pages are deployed' do
- pipeline = @project.ensure_pipeline('HEAD', @project.commit('HEAD').sha)
+ pipeline = @project.pipelines.create(ref: 'HEAD', sha: @project.commit('HEAD').sha)
build = build(:ci_build,
project: @project,
pipeline: pipeline,
@@ -67,7 +75,7 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps
end
step 'I visit add a new Pages Domain' do
- visit new_namespace_project_pages_domain_path(@project.namespace, @project)
+ visit new_project_pages_domain_path(@project)
end
step 'I fill the domain' do
diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb
index 280d70925f7..0a89c1baf20 100644
--- a/features/steps/project/project.rb
+++ b/features/steps/project/project.rb
@@ -2,6 +2,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps
include SharedAuthentication
include SharedProject
include SharedPaths
+ include WaitForRequests
step 'change project settings' do
fill_in 'project_name_edit', with: 'NewName'
@@ -9,7 +10,9 @@ class Spinach::Features::Project < Spinach::FeatureSteps
end
step 'I save project' do
- click_button 'Save changes'
+ page.within '.general-settings' do
+ click_button 'Save changes'
+ end
end
step 'I should see project with new settings' do
@@ -30,14 +33,16 @@ class Spinach::Features::Project < Spinach::FeatureSteps
:project_avatar,
File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
)
- click_button 'Save changes'
+ page.within '.general-settings' do
+ click_button 'Save changes'
+ end
@project.reload
end
step 'I should see new project avatar' do
expect(@project.avatar).to be_instance_of AvatarUploader
url = @project.avatar.url
- expect(url).to eq "/uploads/project/avatar/#{@project.id}/banana_sample.gif"
+ expect(url).to eq "/uploads/-/system/project/avatar/#{@project.id}/banana_sample.gif"
end
step 'I should see the "Remove avatar" button' do
@@ -49,7 +54,9 @@ class Spinach::Features::Project < Spinach::FeatureSteps
:project_avatar,
File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
)
- click_button 'Save changes'
+ page.within '.general-settings' do
+ click_button 'Save changes'
+ end
@project.reload
end
@@ -68,7 +75,9 @@ class Spinach::Features::Project < Spinach::FeatureSteps
step 'change project default branch' do
select 'fix', from: 'project_default_branch'
- click_button 'Save changes'
+ page.within '.general-settings' do
+ click_button 'Save changes'
+ end
end
step 'I should see project default branch changed' do
@@ -86,6 +95,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps
end
step 'I should see project "Shop" README' do
+ wait_for_requests
page.within('.readme-holder') do
expect(page).to have_content 'testme'
end
diff --git a/features/steps/project/project_group_links.rb b/features/steps/project/project_group_links.rb
index 739a85e5fa4..47ee31786a6 100644
--- a/features/steps/project/project_group_links.rb
+++ b/features/steps/project/project_group_links.rb
@@ -5,18 +5,19 @@ class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps
include Select2Helper
step 'I should see project already shared with group "Ops"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).to have_content "Ops"
end
end
step 'I should see project is not shared with group "Market"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).not_to have_content "Market"
end
end
step 'I select group "Market" for share' do
+ click_link 'Share with group'
group = Group.find_by(path: 'market')
select2(group.id, from: "#link_group_id")
select "Master", from: 'link_group_access'
@@ -24,7 +25,7 @@ class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps
end
step 'I should see project is shared with group "Market"' do
- page.within '.enabled-groups' do
+ page.within '.project-members-groups' do
expect(page).to have_content "Market"
end
end
@@ -41,7 +42,7 @@ class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps
end
step 'I visit project group links page' do
- visit namespace_project_group_links_path(project.namespace, project)
+ visit project_group_links_path(project)
end
def project
diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb
index 1864b3a2b52..a7d3352b8c4 100644
--- a/features/steps/project/project_milestone.rb
+++ b/features/steps/project/project_milestone.rb
@@ -2,6 +2,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps
include SharedAuthentication
include SharedProject
include SharedPaths
+ include WaitForRequests
step 'milestone has issue "Bugfix1" with labels: "bug", "feature"' do
project = Project.find_by(name: "Shop")
@@ -34,6 +35,8 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps
end
step 'I should see the labels "bug", "enhancement" and "feature"' do
+ wait_for_requests
+
page.within('#tab-issues') do
expect(page).to have_content 'bug'
expect(page).to have_content 'enhancement'
diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb
index 92936f27c20..53a2463af53 100644
--- a/features/steps/project/redirects.rb
+++ b/features/steps/project/redirects.rb
@@ -4,16 +4,16 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
include SharedProject
step 'public project "Community"' do
- create(:empty_project, :public, name: 'Community')
+ create(:project, :public, name: 'Community')
end
step 'private project "Enterprise"' do
- create(:empty_project, :private, name: 'Enterprise')
+ create(:project, :private, name: 'Enterprise')
end
step 'I visit project "Community" page' do
project = Project.find_by(name: 'Community')
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I should see project "Community" home page' do
@@ -25,12 +25,12 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
step 'I visit project "Enterprise" page' do
project = Project.find_by(name: 'Enterprise')
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I visit project "CommunityDoesNotExist" page' do
project = Project.find_by(name: 'Community')
- visit namespace_project_path(project.namespace, project) + 'DoesNotExist'
+ visit project_path(project) + 'DoesNotExist'
end
step 'I click on "Sign In"' do
@@ -47,7 +47,7 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
step 'I should be redirected to "Community" page' do
project = Project.find_by(name: 'Community')
- expect(current_path).to eq "/#{project.path_with_namespace}"
+ expect(current_path).to eq "/#{project.full_path}"
expect(status_code).to eq 200
end
@@ -61,7 +61,7 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
step 'I should be redirected to "Enterprise" page' do
project = Project.find_by(name: 'Enterprise')
- expect(current_path).to eq "/#{project.path_with_namespace}"
+ expect(current_path).to eq "/#{project.full_path}"
expect(status_code).to eq 200
end
end
diff --git a/features/steps/project/services.rb b/features/steps/project/services.rb
index 772b07d0ad8..7e2a357f6b2 100644
--- a/features/steps/project/services.rb
+++ b/features/steps/project/services.rb
@@ -4,7 +4,7 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
include SharedPaths
step 'I visit project "Shop" services page' do
- visit namespace_project_settings_integrations_path(@project.namespace, @project)
+ visit project_settings_integrations_path(@project)
end
step 'I should see list of available services' do
@@ -34,8 +34,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see hipchat service settings saved' do
- expect(find_field('Room').value).to eq 'gitlab'
+ step 'I should see the Hipchat success message' do
+ expect(page).to have_content 'HipChat activated.'
end
step 'I fill hipchat settings with custom server' do
@@ -46,10 +46,6 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see hipchat service settings with custom server saved' do
- expect(find_field('Server').value).to eq 'https://chat.example.com'
- end
-
step 'I click pivotaltracker service link' do
click_link 'PivotalTracker'
end
@@ -60,8 +56,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see pivotaltracker service settings saved' do
- expect(find_field('Token').value).to eq 'verySecret'
+ step 'I should see the Pivotaltracker success message' do
+ expect(page).to have_content 'PivotalTracker activated.'
end
step 'I click Flowdock service link' do
@@ -74,8 +70,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Flowdock service settings saved' do
- expect(find_field('Token').value).to eq 'verySecret'
+ step 'I should see the Flowdock success message' do
+ expect(page).to have_content 'Flowdock activated.'
end
step 'I click Assembla service link' do
@@ -88,8 +84,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Assembla service settings saved' do
- expect(find_field('Token').value).to eq 'verySecret'
+ step 'I should see the Assembla success message' do
+ expect(page).to have_content 'Assembla activated.'
end
step 'I click Asana service link' do
@@ -103,9 +99,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Asana service settings saved' do
- expect(find_field('Api key').value).to eq 'verySecret'
- expect(find_field('Restrict to branch').value).to eq 'master'
+ step 'I should see the Asana success message' do
+ expect(page).to have_content 'Asana activated.'
end
step 'I click email on push service link' do
@@ -113,12 +108,13 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
end
step 'I fill email on push settings' do
+ check 'Active'
fill_in 'Recipients', with: 'qa@company.name'
click_button 'Save'
end
- step 'I should see email on push service settings saved' do
- expect(find_field('Recipients').value).to eq 'qa@company.name'
+ step 'I should see the Emails on push success message' do
+ expect(page).to have_content 'Emails on push activated.'
end
step 'I click Irker service link' do
@@ -132,9 +128,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Irker service settings saved' do
- expect(find_field('Recipients').value).to eq 'irc://chat.freenode.net/#commits'
- expect(find_field('Colorize messages').value).to eq '1'
+ step 'I should see the Irker success message' do
+ expect(page).to have_content 'Irker (IRC gateway) activated.'
end
step 'I click Slack notifications service link' do
@@ -147,8 +142,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Slack Notifications service settings saved' do
- expect(find_field('Webhook').value).to eq 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685'
+ step 'I should see the Slack notifications success message' do
+ expect(page).to have_content 'Slack notifications activated.'
end
step 'I click Pushover service link' do
@@ -165,12 +160,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Pushover service settings saved' do
- expect(find_field('Api key').value).to eq 'verySecret'
- expect(find_field('User key').value).to eq 'verySecret'
- expect(find_field('Device').value).to eq 'myDevice'
- expect(find_field('Priority').find('option[selected]').value).to eq '1'
- expect(find_field('Sound').find('option[selected]').value).to eq 'bike'
+ step 'I should see the Pushover success message' do
+ expect(page).to have_content 'Pushover activated.'
end
step 'I click jira service link' do
@@ -178,17 +169,17 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
end
step 'I fill jira settings' do
- fill_in 'URL', with: 'http://jira.example'
+ check 'Active'
+
+ fill_in 'Web URL', with: 'http://jira.example'
+ fill_in 'JIRA API URL', with: 'http://jira.example/api'
fill_in 'Username', with: 'gitlab'
fill_in 'Password', with: 'gitlab'
- fill_in 'Project Key', with: 'GITLAB'
click_button 'Save'
end
- step 'I should see jira service settings saved' do
- expect(find_field('URL').value).to eq 'http://jira.example'
- expect(find_field('Username').value).to eq 'gitlab'
- expect(find_field('Project Key').value).to eq 'GITLAB'
+ step 'I should see the JIRA success message' do
+ expect(page).to have_content 'JIRA activated.'
end
step 'I click Atlassian Bamboo CI service link' do
@@ -204,14 +195,14 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see Atlassian Bamboo CI service settings saved' do
- expect(find_field('Bamboo url').value).to eq 'http://bamboo.example.com'
- expect(find_field('Build key').value).to eq 'KEY'
- expect(find_field('Username').value).to eq 'user'
+ step 'I should see the Bamboo success message' do
+ expect(page).to have_content 'Atlassian Bamboo CI activated.'
end
step 'I should see empty field Change Password' do
- expect(find_field('Change Password').value).to be_nil
+ click_link 'Atlassian Bamboo CI'
+
+ expect(find_field('Enter new password').value).to be_nil
end
step 'I click JetBrains TeamCity CI service link' do
@@ -227,9 +218,7 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps
click_button 'Save'
end
- step 'I should see JetBrains TeamCity CI service settings saved' do
- expect(find_field('Teamcity url').value).to eq 'http://teamcity.example.com'
- expect(find_field('Build type').value).to eq 'GitlabTest_Build'
- expect(find_field('Username').value).to eq 'user'
+ step 'I should see the JetBrains success message' do
+ expect(page).to have_content 'JetBrains TeamCity CI activated.'
end
end
diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb
index a3bebfa4b71..b0407d3f07d 100644
--- a/features/steps/project/snippets.rb
+++ b/features/steps/project/snippets.rb
@@ -3,6 +3,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
include SharedProject
include SharedNote
include SharedPaths
+ include WaitForRequests
step 'project "Shop" have "Snippet one" snippet' do
create(:project_snippet,
@@ -22,7 +23,9 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I click link "New snippet"' do
- first(:link, "New snippet").click
+ page.within '#content-body' do
+ first(:link, "New snippet").click
+ end
end
step 'I click link "Snippet one"' do
@@ -55,9 +58,10 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
fill_in "project_snippet_title", with: "Snippet three"
fill_in "project_snippet_file_name", with: "my_snippet.rb"
page.within('.file-editor') do
- find(:xpath, "//input[@id='project_snippet_content']").set 'Content of snippet three'
+ find('.ace_editor').native.send_keys 'Content of snippet three'
end
click_button "Create snippet"
+ wait_for_requests
end
step 'I should see snippet "Snippet three"' do
@@ -79,6 +83,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
fill_in "note_note", with: "Good snippet!"
click_button "Comment"
end
+ wait_for_requests
end
step 'I should see comment "Good snippet!"' do
@@ -86,7 +91,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I visit snippet page "Snippet one"' do
- visit namespace_project_snippet_path(project.namespace, project, project_snippet)
+ visit project_snippet_path(project, project_snippet)
end
def project_snippet
diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb
index b4741f06d1b..621cae5d80d 100644
--- a/features/steps/project/source/browse_files.rb
+++ b/features/steps/project/source/browse_files.rb
@@ -4,11 +4,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
include SharedProject
include SharedPaths
include RepoHelpers
+ include WaitForRequests
step "I don't have write access" do
@project = create(:project, :repository, name: "Other Project", path: "other-project")
@project.team << [@user, :reporter]
- visit namespace_project_tree_path(@project.namespace, @project, root_ref)
+ visit project_tree_path(@project, root_ref)
end
step 'I should see files from repository' do
@@ -18,7 +19,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I should see files from repository for "6d39438"' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "6d39438")
+ expect(current_path).to eq project_tree_path(@project, "6d39438")
expect(page).to have_content ".gitignore"
expect(page).to have_content "LICENSE"
end
@@ -36,10 +37,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I should see its content' do
+ wait_for_requests
expect(page).to have_content old_gitignore_content
end
step 'I should see its new content' do
+ wait_for_requests
expect(page).to have_content new_gitignore_content
end
@@ -86,14 +89,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I fill the new branch name' do
- first('button.js-target-branch', visible: true).click
- find('.create-new-branch', visible: true).click
- find('#new_branch_name', visible: true).set('new_branch_name')
- find('.js-new-branch-btn', visible: true).click
- end
-
- step 'I fill the new file name with an illegal name' do
- fill_in :file_name, with: 'Spaces Not Allowed'
+ fill_in :branch_name, with: 'new_branch_name', visible: true
end
step 'I fill the new file name with a new directory' do
@@ -240,16 +236,16 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I am redirected to the files URL' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, 'master')
+ expect(current_path).to eq project_tree_path(@project, 'master')
end
step 'I am redirected to the ".gitignore"' do
- expect(current_path).to eq(namespace_project_blob_path(@project.namespace, @project, 'master/.gitignore'))
+ expect(current_path).to eq(project_blob_path(@project, 'master/.gitignore'))
end
step 'I am redirected to the permalink URL' do
expect(current_path).to(
- eq(namespace_project_blob_path(@project.namespace, @project,
+ eq(project_blob_path(@project,
@project.repository.commit.sha +
'/.gitignore'))
)
@@ -257,26 +253,26 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step 'I am redirected to the new file' do
expect(current_path).to eq(
- namespace_project_blob_path(@project.namespace, @project, 'master/' + new_file_name))
+ project_blob_path(@project, 'master/' + new_file_name))
end
step 'I am redirected to the new file with directory' do
expect(current_path).to eq(
- namespace_project_blob_path(@project.namespace, @project, 'master/' + new_file_name_with_directory))
+ project_blob_path(@project, 'master/' + new_file_name_with_directory))
end
step 'I am redirected to the new merge request page' do
- expect(current_path).to eq(new_namespace_project_merge_request_path(@project.namespace, @project))
+ expect(current_path).to eq(project_new_merge_request_path(@project))
end
step "I am redirected to the fork's new merge request page" do
fork = @user.fork_of(@project)
- expect(current_path).to eq(new_namespace_project_merge_request_path(fork.namespace, fork))
+ expect(current_path).to eq(project_new_merge_request_path(fork))
end
step 'I am redirected to the root directory' do
expect(current_path).to eq(
- namespace_project_tree_path(@project.namespace, @project, 'master'))
+ project_tree_path(@project, 'master'))
end
step "I don't see the permalink link" do
@@ -327,11 +323,11 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step "I visit the 'test' tree" do
- visit namespace_project_tree_path(@project.namespace, @project, "'test'")
+ visit project_tree_path(@project, "'test'")
end
step "I visit the fix tree" do
- visit namespace_project_tree_path(@project.namespace, @project, "fix/.testdir")
+ visit project_tree_path(@project, "fix/.testdir")
end
step 'I see the commit data' do
@@ -346,7 +342,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step 'I click on "files/lfs/lfs_object.iso" file in repo' do
allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true)
- visit namespace_project_tree_path(@project.namespace, @project, "lfs")
+ visit project_tree_path(@project, "lfs")
click_link 'files'
click_link "lfs"
click_link "lfs_object.iso"
@@ -364,7 +360,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step 'I should see buttons for allowed commands' do
page.within '.content' do
- expect(page).to have_link 'Open raw'
+ expect(page).to have_link 'Download'
expect(page).to have_content 'History'
expect(page).to have_content 'Permalink'
expect(page).not_to have_content 'Edit'
@@ -389,7 +385,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I visit the SVG file' do
- visit namespace_project_blob_path(@project.namespace, @project, 'new_branch_name/logo_sample.svg')
+ visit project_blob_path(@project, 'new_branch_name/logo_sample.svg')
end
step 'I can see the new rendered SVG image' do
diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb
index 0f0827f0477..243a0f54f7f 100644
--- a/features/steps/project/source/markdown_render.rb
+++ b/features/steps/project/source/markdown_render.rb
@@ -5,6 +5,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
include SharedAuthentication
include SharedPaths
include SharedMarkdown
+ include WaitForRequests
step 'I own project "Delta"' do
@project = ::Project.find_by(name: "Delta")
@@ -13,7 +14,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see files from repository in markdown' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown")
+ expect(current_path).to eq project_tree_path(@project, "markdown")
expect(page).to have_content "README.md"
expect(page).to have_content "CHANGELOG"
end
@@ -33,7 +34,8 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see correct document rendered' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/README.md")
+ wait_for_requests
expect(page).to have_content "All API requests require authentication"
end
@@ -42,7 +44,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see correct directory rendered' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown/doc/raketasks")
+ expect(current_path).to eq project_tree_path(@project, "markdown/doc/raketasks")
expect(page).to have_content "backup_restore.md"
expect(page).to have_content "maintenance.md"
end
@@ -52,7 +54,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see correct doc/api directory rendered' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown/doc/api")
+ expect(current_path).to eq project_tree_path(@project, "markdown/doc/api")
expect(page).to have_content "README.md"
expect(page).to have_content "users.md"
end
@@ -62,7 +64,8 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see correct maintenance file rendered' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/raketasks/maintenance.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/raketasks/maintenance.md")
+ wait_for_requests
expect(page).to have_content "bundle exec rake gitlab:env:info RAILS_ENV=production"
end
@@ -87,13 +90,16 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
click_link "api"
end
+ wait_for_requests
+
page.within '.tree-table' do
click_link "README.md"
end
end
step 'I see correct file rendered' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/README.md")
+ wait_for_requests
expect(page).to have_content "Contents"
expect(page).to have_link "Users"
expect(page).to have_link "Rake tasks"
@@ -104,7 +110,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see the correct document file' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/users.md")
expect(page).to have_content "Get a list of users."
end
@@ -115,100 +121,105 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
# Markdown branch
When 'I visit markdown branch' do
- visit namespace_project_tree_path(@project.namespace, @project, "markdown")
+ visit project_tree_path(@project, "markdown")
+ wait_for_requests
end
When 'I visit markdown branch "README.md" blob' do
- visit namespace_project_blob_path(@project.namespace, @project, "markdown/README.md")
+ visit project_blob_path(@project, "markdown/README.md")
end
When 'I visit markdown branch "d" tree' do
- visit namespace_project_tree_path(@project.namespace, @project, "markdown/d")
+ visit project_tree_path(@project, "markdown/d")
end
When 'I visit markdown branch "d/README.md" blob' do
- visit namespace_project_blob_path(@project.namespace, @project, "markdown/d/README.md")
+ visit project_blob_path(@project, "markdown/d/README.md")
end
step 'I should see files from repository in markdown branch' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown")
+ expect(current_path).to eq project_tree_path(@project, "markdown")
expect(page).to have_content "README.md"
expect(page).to have_content "CHANGELOG"
end
step 'I see correct file rendered in markdown branch' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/README.md")
+ wait_for_requests
expect(page).to have_content "Contents"
expect(page).to have_link "Users"
expect(page).to have_link "Rake tasks"
end
step 'I should see correct document rendered for markdown branch' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/README.md")
+ wait_for_requests
expect(page).to have_content "All API requests require authentication"
end
step 'I should see correct directory rendered for markdown branch' do
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown/doc/raketasks")
+ expect(current_path).to eq project_tree_path(@project, "markdown/doc/raketasks")
expect(page).to have_content "backup_restore.md"
expect(page).to have_content "maintenance.md"
end
step 'I should see the users document file in markdown branch' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/users.md")
expect(page).to have_content "Get a list of users."
end
# Expected link contents
step 'The link with text "empty" should have url "tree/markdown"' do
- find('a', text: /^empty$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown")
+ wait_for_requests
+ find('a', text: /^empty$/)['href'] == current_host + project_tree_path(@project, "markdown")
end
step 'The link with text "empty" should have url "blob/markdown/README.md"' do
- find('a', text: /^empty$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md")
+ find('a', text: /^empty$/)['href'] == current_host + project_blob_path(@project, "markdown/README.md")
end
step 'The link with text "empty" should have url "tree/markdown/d"' do
- find('a', text: /^empty$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown/d")
+ find('a', text: /^empty$/)['href'] == current_host + project_tree_path(@project, "markdown/d")
end
step 'The link with text "empty" should have '\
'url "blob/markdown/d/README.md"' do
- find('a', text: /^empty$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/d/README.md")
+ find('a', text: /^empty$/)['href'] == current_host + project_blob_path(@project, "markdown/d/README.md")
end
step 'The link with text "ID" should have url "tree/markdownID"' do
- find('a', text: /^#id$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown") + '#id'
+ find('a', text: /^#id$/)['href'] == current_host + project_tree_path(@project, "markdown") + '#id'
end
step 'The link with text "/ID" should have url "tree/markdownID"' do
- find('a', text: /^\/#id$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown") + '#id'
+ find('a', text: /^\/#id$/)['href'] == current_host + project_tree_path(@project, "markdown") + '#id'
end
step 'The link with text "README.mdID" '\
'should have url "blob/markdown/README.mdID"' do
- find('a', text: /^README.md#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id'
+ find('a', text: /^README.md#id$/)['href'] == current_host + project_blob_path(@project, "markdown/README.md") + '#id'
end
step 'The link with text "d/README.mdID" should have '\
'url "blob/markdown/d/README.mdID"' do
- find('a', text: /^d\/README.md#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "d/markdown/README.md") + '#id'
+ find('a', text: /^d\/README.md#id$/)['href'] == current_host + project_blob_path(@project, "d/markdown/README.md") + '#id'
end
step 'The link with text "ID" should have url "blob/markdown/README.mdID"' do
- find('a', text: /^#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id'
+ wait_for_requests
+ find('a', text: /^#id$/)['href'] == current_host + project_blob_path(@project, "markdown/README.md") + '#id'
end
step 'The link with text "/ID" should have url "blob/markdown/README.mdID"' do
- find('a', text: /^\/#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id'
+ find('a', text: /^\/#id$/)['href'] == current_host + project_blob_path(@project, "markdown/README.md") + '#id'
end
# Wiki
step 'I go to wiki page' do
click_link "Wiki"
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "home")
+ expect(current_path).to eq project_wiki_path(@project, "home")
end
step 'I add various links to the wiki page' do
@@ -220,7 +231,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'Wiki page should have added links' do
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "home")
+ expect(current_path).to eq project_wiki_path(@project, "home")
expect(page).to have_content "test GitLab API doc Rake tasks"
end
@@ -241,7 +252,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I see new wiki page named test' do
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "test")
+ expect(current_path).to eq project_wiki_path(@project, "test")
page.within(:css, ".nav-text") do
expect(page).to have_content "Test"
@@ -250,8 +261,8 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
When 'I go back to wiki page home' do
- visit namespace_project_wiki_path(@project.namespace, @project, "home")
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "home")
+ visit project_wiki_path(@project, "home")
+ expect(current_path).to eq project_wiki_path(@project, "home")
end
step 'I click on GitLab API doc link' do
@@ -259,7 +270,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I see Gitlab API document' do
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "api")
+ expect(current_path).to eq project_wiki_path(@project, "api")
page.within(:css, ".nav-text") do
expect(page).to have_content "Create"
@@ -272,7 +283,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I see Rake tasks directory' do
- expect(current_path).to eq namespace_project_wiki_path(@project.namespace, @project, "raketasks")
+ expect(current_path).to eq project_wiki_path(@project, "raketasks")
page.within(:css, ".nav-text") do
expect(page).to have_content "Create"
@@ -281,8 +292,8 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I go directory which contains README file' do
- visit namespace_project_tree_path(@project.namespace, @project, "markdown/doc/api")
- expect(current_path).to eq namespace_project_tree_path(@project.namespace, @project, "markdown/doc/api")
+ visit project_tree_path(@project, "markdown/doc/api")
+ expect(current_path).to eq project_tree_path(@project, "markdown/doc/api")
end
step 'I click on a relative link in README' do
@@ -290,11 +301,13 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'I should see the correct markdown' do
- expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md")
+ expect(current_path).to eq project_blob_path(@project, "markdown/doc/api/users.md")
+ wait_for_requests
expect(page).to have_content "List users"
end
step 'Header "Application details" should have correct id and link' do
+ wait_for_requests
header_should_have_correct_id_and_link(2, 'Application details', 'application-details')
end
diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb
index ff4c9deee2a..5c4025ace34 100644
--- a/features/steps/project/team_management.rb
+++ b/features/steps/project/team_management.rb
@@ -34,7 +34,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
end
step 'I own project "Website"' do
- @project = create(:empty_project, name: "Website", namespace: @user.namespace)
+ @project = create(:project, name: "Website", namespace: @user.namespace)
@project.team << [@user, :master]
end
@@ -68,7 +68,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
step 'I share project with group "OpenSource"' do
project = Project.find_by(name: 'Shop')
os_group = create(:group, name: 'OpenSource')
- create(:empty_project, group: os_group)
+ create(:project, group: os_group)
@os_user1 = create(:user)
@os_user2 = create(:user)
os_group.add_owner(@os_user1)
diff --git a/features/steps/project/wiki.rb b/features/steps/project/wiki.rb
index 517c257d892..855757e34b3 100644
--- a/features/steps/project/wiki.rb
+++ b/features/steps/project/wiki.rb
@@ -11,7 +11,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'I should be redirected back to the Edit Home Wiki page' do
- expect(current_path).to eq namespace_project_wiki_path(project.namespace, project, :home)
+ expect(current_path).to eq project_wiki_path(project, :home)
end
step 'I create the Wiki Home page' do
@@ -42,7 +42,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'I browse to that Wiki page' do
- visit namespace_project_wiki_path(project.namespace, project, @page)
+ visit project_wiki_path(project, @page)
end
step 'I click on the Edit button' do
@@ -59,11 +59,11 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'I should be redirected back to that Wiki page' do
- expect(current_path).to eq namespace_project_wiki_path(project.namespace, project, @page)
+ expect(current_path).to eq project_wiki_path(project, @page)
end
step 'That page has two revisions' do
- @page.update("new content", :markdown, "second commit")
+ @page.update(content: "new content", message: "second commit")
end
step 'I click the History button' do
@@ -95,7 +95,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'I browse to wiki page with images' do
- visit namespace_project_wiki_path(project.namespace, project, @wiki_page)
+ visit project_wiki_path(project, @wiki_page)
end
step 'I click on existing image link' do
@@ -114,7 +114,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'Image should be shown on the page' do
- expect(page).to have_xpath("//img[@src=\"image.jpg\"]")
+ expect(page).to have_xpath("//img[@data-src=\"image.jpg\"]")
end
step 'I click on image link' do
@@ -142,7 +142,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'I should see non-escaped link in the pages list' do
- expect(page).to have_xpath("//a[@href='/#{project.path_with_namespace}/wikis/one/two/three-test']")
+ expect(page).to have_xpath("//a[@href='/#{project.full_path}/wikis/one/two/three-test']")
end
step 'I edit the Wiki page with a path' do
diff --git a/features/steps/search.rb b/features/steps/search.rb
index f885baf8453..16c4a5ab2e4 100644
--- a/features/steps/search.rb
+++ b/features/steps/search.rb
@@ -10,12 +10,12 @@ class Spinach::Features::Search < Spinach::FeatureSteps
step 'I search for "Foo"' do
fill_in "dashboard_search", with: "Foo"
- click_button "Search"
+ find('.btn-search').trigger('click')
end
step 'I search for "rspec"' do
fill_in "dashboard_search", with: "rspec"
- click_button "Search"
+ find('.btn-search').trigger('click')
end
step 'I search for "rspec" on project page' do
@@ -25,7 +25,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps
step 'I search for "Wiki content"' do
fill_in "dashboard_search", with: "content"
- click_button "Search"
+ find('.btn-search').trigger('click')
end
step 'I click "Issues" link' do
@@ -35,7 +35,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps
end
step 'I click project "Shop" link' do
- click_button 'Project'
+ find('.js-search-project-dropdown').trigger('click')
page.within '.project-filter' do
click_link project.name_with_namespace
end
diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb
index 8bae80a8707..af5db05e9e8 100644
--- a/features/steps/shared/active_tab.rb
+++ b/features/steps/shared/active_tab.rb
@@ -1,9 +1,9 @@
module SharedActiveTab
include Spinach::DSL
- include WaitForAjax
+ include WaitForRequests
after do
- wait_for_ajax if javascript_test?
+ wait_for_requests if javascript_test?
end
def ensure_active_main_tab(content)
diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb
index 5549fc25525..3b4c98ec00d 100644
--- a/features/steps/shared/builds.rb
+++ b/features/steps/shared/builds.rb
@@ -27,11 +27,11 @@ module SharedBuilds
end
step 'I visit recent build details page' do
- visit namespace_project_build_path(@project.namespace, @project, @build)
+ visit project_job_path(@project, @build)
end
step 'I visit project builds page' do
- visit namespace_project_builds_path(@project.namespace, @project)
+ visit project_jobs_path(@project)
end
step 'recent build has artifacts available' do
@@ -56,7 +56,7 @@ module SharedBuilds
end
step 'I access artifacts download page' do
- visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
+ visit download_project_job_artifacts_path(@project, @build)
end
step 'I see details of a build' do
diff --git a/features/steps/shared/diff_note.rb b/features/steps/shared/diff_note.rb
index 071aa2e3eff..2c59ec5bb06 100644
--- a/features/steps/shared/diff_note.rb
+++ b/features/steps/shared/diff_note.rb
@@ -1,10 +1,10 @@
module SharedDiffNote
include Spinach::DSL
include RepoHelpers
- include WaitForAjax
+ include WaitForRequests
after do
- wait_for_ajax if javascript_test?
+ wait_for_requests if javascript_test?
end
step 'I cancel the diff comment' do
@@ -232,7 +232,7 @@ module SharedDiffNote
end
def click_parallel_diff_line(code, line_type)
- find(".line_content.parallel.#{line_type}[data-line-code='#{code}']").trigger 'mouseover'
+ find(".line_holder.parallel .diff-line-num[id='#{code}']").trigger 'mouseover'
find(".line_holder.parallel button[data-line-code='#{code}']").trigger 'click'
end
end
diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb
index 3d9cedf5c2d..7c842ba88fb 100644
--- a/features/steps/shared/issuable.rb
+++ b/features/steps/shared/issuable.rb
@@ -51,22 +51,22 @@ module SharedIssuable
step 'I visit issue page "Enterprise issue"' do
issue = Issue.find_by(title: 'Enterprise issue')
- visit namespace_project_issue_path(issue.project.namespace, issue.project, issue)
+ visit project_issue_path(issue.project, issue)
end
step 'I visit merge request page "Enterprise fix"' do
mr = MergeRequest.find_by(title: 'Enterprise fix')
- visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr)
+ visit project_merge_request_path(mr.target_project, mr)
end
step 'I visit issue page "Community issue"' do
issue = Issue.find_by(title: 'Community issue')
- visit namespace_project_issue_path(issue.project.namespace, issue.project, issue)
+ visit project_issue_path(issue.project, issue)
end
step 'I visit issue page "Community fix"' do
mr = MergeRequest.find_by(title: 'Community fix')
- visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr)
+ visit project_merge_request_path(mr.target_project, mr)
end
step 'I should not see any related merge requests' do
diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb
index 875d27d9383..c2bec2a6320 100644
--- a/features/steps/shared/markdown.rb
+++ b/features/steps/shared/markdown.rb
@@ -3,7 +3,7 @@ module SharedMarkdown
def header_should_have_correct_id_and_link(level, text, id, parent = ".wiki")
node = find("#{parent} h#{level} a#user-content-#{id}")
- expect(node[:href]).to eq "##{id}"
+ expect(node[:href]).to end_with "##{id}"
# Work around a weird Capybara behavior where calling `parent` on a node
# returns the whole document, not the node's actual parent element
@@ -30,7 +30,7 @@ module SharedMarkdown
end
step 'I should see the Markdown write tab' do
- expect(find('.gfm-form')).to have_css('.js-md-write-button', visible: true)
+ expect(first('.gfm-form')).to have_link('Write', visible: true)
end
step 'I should see the Markdown preview' do
@@ -49,9 +49,9 @@ module SharedMarkdown
end
step 'I preview a description text like "Bug fixed :smile:"' do
- page.within('.gfm-form') do
+ page.within(first('.gfm-form')) do
fill_in 'Description', with: 'Bug fixed :smile:'
- find('.js-md-preview-button').click
+ click_link 'Preview'
end
end
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 7885cc7ab77..492da38355c 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -1,14 +1,19 @@
module SharedNote
include Spinach::DSL
- include WaitForAjax
+ include WaitForRequests
after do
- wait_for_ajax if javascript_test?
+ wait_for_requests if javascript_test?
end
step 'I delete a comment' do
page.within('.main-notes-list') do
- find('.note').hover
+ note = find('.note')
+ note.hover
+
+ find('.more-actions').click
+ find('.more-actions .dropdown-menu li', match: :first)
+
find(".js-note-delete").click
end
end
@@ -24,6 +29,8 @@ module SharedNote
fill_in "note[note]", with: "XML attached"
click_button "Comment"
end
+
+ wait_for_requests
end
step 'I preview a comment text like "Bug fixed :smile:"' do
@@ -37,6 +44,8 @@ module SharedNote
page.within(".js-main-target-form") do
click_button "Comment"
end
+
+ wait_for_requests
end
step 'I write a comment like ":+1: Nice"' do
@@ -123,7 +132,7 @@ module SharedNote
click_button "Comment"
end
- wait_for_ajax
+ wait_for_requests
end
step 'The comment with the header should not have an ID' do
@@ -135,8 +144,10 @@ module SharedNote
step 'I edit the last comment with a +1' do
page.within(".main-notes-list") do
- find(".note").hover
- find('.js-note-edit').click
+ note = find('.note')
+ note.hover
+
+ note.find('.js-note-edit').click
end
page.within(".current-note-edit-form") do
diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb
index d5b3bb34d7a..830263fd038 100644
--- a/features/steps/shared/paths.rb
+++ b/features/steps/shared/paths.rb
@@ -2,6 +2,7 @@ module SharedPaths
include Spinach::DSL
include RepoHelpers
include DashboardHelper
+ include WaitForRequests
step 'I visit new project page' do
visit new_project_path
@@ -111,10 +112,6 @@ module SharedPaths
visit dashboard_groups_path
end
- step 'I visit dashboard todos page' do
- visit dashboard_todos_path
- end
-
step 'I should be redirected to the dashboard groups page' do
expect(current_path).to eq dashboard_groups_path
end
@@ -151,7 +148,7 @@ module SharedPaths
visit profile_preferences_path
end
- step 'I visit Audit Log page' do
+ step 'I visit Authentication log page' do
visit audit_log_profile_path
end
@@ -204,67 +201,67 @@ module SharedPaths
# ----------------------------------------
step "I visit my project's home page" do
- visit namespace_project_path(@project.namespace, @project)
+ visit project_path(@project)
end
step "I visit my project's settings page" do
- visit edit_namespace_project_path(@project.namespace, @project)
+ visit edit_project_path(@project)
end
step "I visit my project's files page" do
- visit namespace_project_tree_path(@project.namespace, @project, root_ref)
+ visit project_tree_path(@project, root_ref)
end
step 'I visit a binary file in the repo' do
- visit namespace_project_blob_path(@project.namespace, @project,
+ visit project_blob_path(@project,
File.join(root_ref, 'files/images/logo-black.png'))
end
step "I visit my project's commits page" do
- visit namespace_project_commits_path(@project.namespace, @project, root_ref, { limit: 5 })
+ visit project_commits_path(@project, root_ref, { limit: 5 })
end
step "I visit my project's commits page for a specific path" do
- visit namespace_project_commits_path(@project.namespace, @project, root_ref + "/app/models/project.rb", { limit: 5 })
+ visit project_commits_path(@project, root_ref + "/app/models/project.rb", { limit: 5 })
end
step 'I visit my project\'s commits stats page' do
- visit stats_namespace_project_repository_path(@project.namespace, @project)
+ visit stats_project_repository_path(@project)
end
step "I visit my project's graph page" do
# Stub Graph max_size to speed up test (10 commits vs. 650)
Network::Graph.stub(max_count: 10)
- visit namespace_project_network_path(@project.namespace, @project, root_ref)
+ visit project_network_path(@project, root_ref)
end
step "I visit my project's issues page" do
- visit namespace_project_issues_path(@project.namespace, @project)
+ visit project_issues_path(@project)
end
step "I visit my project's merge requests page" do
- visit namespace_project_merge_requests_path(@project.namespace, @project)
+ visit project_merge_requests_path(@project)
end
step "I visit my project's members page" do
- visit namespace_project_project_members_path(@project.namespace, @project)
+ visit project_project_members_path(@project)
end
step "I visit my project's wiki page" do
- visit namespace_project_wiki_path(@project.namespace, @project, :home)
+ visit project_wiki_path(@project, :home)
end
step 'I visit project hooks page' do
- visit namespace_project_settings_integrations_path(@project.namespace, @project)
+ visit project_settings_integrations_path(@project)
end
step 'I visit project deploy keys page' do
- visit namespace_project_deploy_keys_path(@project.namespace, @project)
+ visit project_deploy_keys_path(@project)
end
step 'I visit project find file page' do
- visit namespace_project_find_file_path(@project.namespace, @project, root_ref)
+ visit project_find_file_path(@project, root_ref)
end
# ----------------------------------------
@@ -272,148 +269,153 @@ module SharedPaths
# ----------------------------------------
step 'I visit project "Shop" page' do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I visit project "Shop" activity page' do
- visit activity_namespace_project_path(project.namespace, project)
+ visit activity_project_path(project)
end
step 'I visit project "Forked Shop" merge requests page' do
- visit namespace_project_merge_requests_path(@forked_project.namespace, @forked_project)
+ visit project_merge_requests_path(@forked_project)
end
step 'I visit edit project "Shop" page' do
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
end
step 'I visit project branches page' do
- visit namespace_project_branches_path(@project.namespace, @project)
+ visit project_branches_path(@project)
end
step 'I visit project protected branches page' do
- visit namespace_project_protected_branches_path(@project.namespace, @project)
+ visit project_protected_branches_path(@project)
end
step 'I visit compare refs page' do
- visit namespace_project_compare_index_path(@project.namespace, @project)
+ visit project_compare_index_path(@project)
end
step 'I visit project commits page' do
- visit namespace_project_commits_path(@project.namespace, @project, root_ref, { limit: 5 })
+ visit project_commits_path(@project, root_ref, { limit: 5 })
end
step 'I visit project commits page for stable branch' do
- visit namespace_project_commits_path(@project.namespace, @project, 'stable', { limit: 5 })
+ visit project_commits_path(@project, 'stable', { limit: 5 })
end
step 'I visit project source page' do
- visit namespace_project_tree_path(@project.namespace, @project, root_ref)
+ visit project_tree_path(@project, root_ref)
end
step 'I visit blob file from repo' do
- visit namespace_project_blob_path(@project.namespace, @project, File.join(sample_commit.id, sample_blob.path))
+ visit project_blob_path(@project, File.join(sample_commit.id, sample_blob.path))
end
step 'I visit ".gitignore" file in repo' do
- visit namespace_project_blob_path(@project.namespace, @project, File.join(root_ref, '.gitignore'))
+ visit project_blob_path(@project, File.join(root_ref, '.gitignore'))
end
step 'I am on the new file page' do
- expect(current_path).to eq(namespace_project_create_blob_path(@project.namespace, @project, root_ref))
+ expect(current_path).to eq(project_create_blob_path(@project, root_ref))
end
step 'I am on the ".gitignore" edit file page' do
expect(current_path).to eq(
- namespace_project_edit_blob_path(@project.namespace, @project, File.join(root_ref, '.gitignore')))
+ project_edit_blob_path(@project, File.join(root_ref, '.gitignore')))
end
step 'I visit project source page for "6d39438"' do
- visit namespace_project_tree_path(@project.namespace, @project, "6d39438")
+ visit project_tree_path(@project, "6d39438")
end
step 'I visit project source page for' \
' "6d394385cf567f80a8fd85055db1ab4c5295806f"' do
- visit namespace_project_tree_path(@project.namespace, @project,
+ visit project_tree_path(@project,
'6d394385cf567f80a8fd85055db1ab4c5295806f')
end
step 'I visit project tags page' do
- visit namespace_project_tags_path(@project.namespace, @project)
+ visit project_tags_path(@project)
end
step 'I visit project commit page' do
- visit namespace_project_commit_path(@project.namespace, @project, sample_commit.id)
+ visit project_commit_path(@project, sample_commit.id)
end
step 'I visit project "Shop" issues page' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
step 'I visit issue page "Release 0.4"' do
issue = Issue.find_by(title: "Release 0.4")
- visit namespace_project_issue_path(issue.project.namespace, issue.project, issue)
+ visit project_issue_path(issue.project, issue)
end
step 'I visit project "Shop" labels page' do
project = Project.find_by(name: 'Shop')
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
end
step 'I visit project "Forum" labels page' do
project = Project.find_by(name: 'Forum')
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
end
step 'I visit project "Shop" new label page' do
project = Project.find_by(name: 'Shop')
- visit new_namespace_project_label_path(project.namespace, project)
+ visit new_project_label_path(project)
end
step 'I visit project "Forum" new label page' do
project = Project.find_by(name: 'Forum')
- visit new_namespace_project_label_path(project.namespace, project)
+ visit new_project_label_path(project)
end
step 'I visit merge request page "Bug NS-04"' do
visit merge_request_path("Bug NS-04")
+ wait_for_requests
end
step 'I visit merge request page "Bug NS-05"' do
visit merge_request_path("Bug NS-05")
+ wait_for_requests
end
step 'I visit merge request page "Bug NS-07"' do
visit merge_request_path("Bug NS-07")
+ wait_for_requests
end
step 'I visit merge request page "Bug NS-08"' do
visit merge_request_path("Bug NS-08")
+ wait_for_requests
end
step 'I visit merge request page "Bug CO-01"' do
mr = MergeRequest.find_by(title: "Bug CO-01")
- visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr)
+ visit project_merge_request_path(mr.target_project, mr)
+ wait_for_requests
end
step 'I visit project "Shop" merge requests page' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
step 'I visit forked project "Shop" merge requests page' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
step 'I visit project "Shop" milestones page' do
- visit namespace_project_milestones_path(project.namespace, project)
+ visit project_milestones_path(project)
end
step 'I visit project "Shop" team page' do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
end
step 'I visit project wiki page' do
- visit namespace_project_wiki_path(@project.namespace, @project, :home)
+ visit project_wiki_path(@project, :home)
end
# ----------------------------------------
@@ -422,22 +424,22 @@ module SharedPaths
step 'I visit project "Community" page' do
project = Project.find_by(name: "Community")
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I visit project "Community" source page' do
project = Project.find_by(name: 'Community')
- visit namespace_project_tree_path(project.namespace, project, root_ref)
+ visit project_tree_path(project, root_ref)
end
step 'I visit project "Internal" page' do
project = Project.find_by(name: "Internal")
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I visit project "Enterprise" page' do
project = Project.find_by(name: "Enterprise")
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
# ----------------------------------------
@@ -446,7 +448,7 @@ module SharedPaths
step "I visit empty project page" do
project = Project.find_by(name: "Empty Public Project")
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step "I should not see command line instructions" do
@@ -478,17 +480,13 @@ module SharedPaths
# ----------------------------------------
step 'I visit project "Shop" snippets page' do
- visit namespace_project_snippets_path(project.namespace, project)
+ visit project_snippets_path(project)
end
step 'I visit snippets page' do
visit explore_snippets_path
end
- step 'I visit new snippet page' do
- visit new_snippet_path
- end
-
def root_ref
@project.repository.root_ref
end
@@ -499,7 +497,7 @@ module SharedPaths
def merge_request_path(title)
mr = MergeRequest.find_by(title: title)
- namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr)
+ project_merge_request_path(mr.target_project, mr)
end
# ----------------------------------------
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index 15625e045f5..605c9a3ab71 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -54,45 +54,31 @@ module SharedProject
# Create an empty project without caring about the name
step 'I own an empty project' do
- @project = create(:empty_project,
+ @project = create(:project,
name: 'Empty Project', namespace: @user.namespace)
@project.team << [@user, :master]
end
step 'I visit my empty project page' do
project = Project.find_by(name: 'Empty Project')
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'I visit project "Shop" activity page' do
project = Project.find_by(name: 'Shop')
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
step 'project "Shop" has push event' do
@project = Project.find_by(name: "Shop")
+ @event = create(:push_event, project: @project, author: @user)
- data = {
- before: Gitlab::Git::BLANK_SHA,
- after: "6d394385cf567f80a8fd85055db1ab4c5295806f",
- ref: "refs/heads/fix",
- user_id: @user.id,
- user_name: @user.name,
- repository: {
- name: @project.name,
- url: "localhost/rubinius",
- description: "",
- homepage: "localhost/rubinius",
- private: true
- }
- }
-
- @event = Event.create(
- project: @project,
- action: Event::PUSHED,
- data: data,
- author_id: @user.id
- )
+ create(:push_event_payload,
+ event: @event,
+ action: :created,
+ commit_to: '6d394385cf567f80a8fd85055db1ab4c5295806f',
+ ref: 'fix',
+ commit_count: 1)
end
step 'I should see project "Shop" activity feed' do
@@ -101,9 +87,9 @@ module SharedProject
end
step 'I should see project settings' do
- expect(current_path).to eq edit_namespace_project_path(@project.namespace, @project)
+ expect(current_path).to eq edit_project_path(@project)
expect(page).to have_content("Project name")
- expect(page).to have_content("Sharing & Permissions")
+ expect(page).to have_content("Sharing and permissions")
end
def current_project
@@ -239,11 +225,6 @@ module SharedProject
create(:label, project: project, title: 'enhancement')
end
- step 'project "Shop" has issue: "bug report"' do
- project = Project.find_by(name: "Shop")
- create(:issue, project: project, title: "bug report")
- end
-
step 'project "Shop" has CI enabled' do
project = Project.find_by(name: "Shop")
project.enable_ci
@@ -256,9 +237,9 @@ module SharedProject
end
step 'I should see last commit with CI status' do
- page.within ".project-last-commit" do
+ page.within ".blob-commit-info" do
expect(page).to have_content(project.commit.sha[0..6])
- expect(page).to have_content("skipped")
+ expect(page).to have_link("Commit: skipped")
end
end
@@ -281,7 +262,7 @@ module SharedProject
def user_owns_project(user_name:, project_name:, visibility: :private)
user = user_exists(user_name, username: user_name.gsub(/\s/, '').underscore)
project = Project.find_by(name: project_name)
- project ||= create(:empty_project, visibility, name: project_name, namespace: user.namespace)
+ project ||= create(:project, visibility, name: project_name, namespace: user.namespace)
project.team << [user, :master]
end
end
diff --git a/features/steps/shared/project_tab.rb b/features/steps/shared/project_tab.rb
index 0cb9229dbae..901f7f76ee9 100644
--- a/features/steps/shared/project_tab.rb
+++ b/features/steps/shared/project_tab.rb
@@ -32,6 +32,10 @@ module SharedProjectTab
ensure_active_main_tab('Wiki')
end
+ step 'the active main tab should be Members' do
+ ensure_active_main_tab('Members')
+ end
+
step 'the active main tab should be Settings' do
ensure_active_main_tab('Settings')
end
diff --git a/features/steps/shared/snippet.rb b/features/steps/shared/snippet.rb
deleted file mode 100644
index bb596c1620a..00000000000
--- a/features/steps/shared/snippet.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-module SharedSnippet
- include Spinach::DSL
-
- step 'I have public "Personal snippet one" snippet' do
- create(:personal_snippet,
- title: "Personal snippet one",
- content: "Test content",
- file_name: "snippet.rb",
- visibility_level: Snippet::PUBLIC,
- author: current_user)
- end
-
- step 'I have private "Personal snippet private" snippet' do
- create(:personal_snippet,
- title: "Personal snippet private",
- content: "Provate content",
- file_name: "private_snippet.rb",
- visibility_level: Snippet::PRIVATE,
- author: current_user)
- end
-
- step 'I have internal "Personal snippet internal" snippet' do
- create(:personal_snippet,
- title: "Personal snippet internal",
- content: "Provate content",
- file_name: "internal_snippet.rb",
- visibility_level: Snippet::INTERNAL,
- author: current_user)
- end
-
- step 'I have a public many lined snippet' do
- create(:personal_snippet,
- title: 'Many lined snippet',
- content: <<-END.gsub(/^\s+\|/, ''),
- |line one
- |line two
- |line three
- |line four
- |line five
- |line six
- |line seven
- |line eight
- |line nine
- |line ten
- |line eleven
- |line twelve
- |line thirteen
- |line fourteen
- END
- file_name: 'many_lined_snippet.rb',
- visibility_level: Snippet::PUBLIC,
- author: current_user)
- end
-
- step 'There is public "Personal snippet one" snippet' do
- create(:personal_snippet,
- title: "Personal snippet one",
- content: "Test content",
- file_name: "snippet.rb",
- visibility_level: Snippet::PUBLIC,
- author: create(:user))
- end
-end
diff --git a/features/steps/snippets/snippets.rb b/features/steps/snippets/snippets.rb
deleted file mode 100644
index 19366b11071..00000000000
--- a/features/steps/snippets/snippets.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-class Spinach::Features::Snippets < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
- include SharedSnippet
-
- step 'I click link "Personal snippet one"' do
- click_link "Personal snippet one"
- end
-
- step 'I should not see "Personal snippet one" in snippets' do
- expect(page).not_to have_content "Personal snippet one"
- end
-
- step 'I click link "Edit"' do
- page.within ".detail-page-header" do
- first(:link, "Edit").click
- end
- end
-
- step 'I click link "Delete"' do
- first(:link, "Delete").click
- end
-
- step 'I submit new snippet "Personal snippet three"' do
- fill_in "personal_snippet_title", with: "Personal snippet three"
- fill_in "personal_snippet_file_name", with: "my_snippet.rb"
- page.within('.file-editor') do
- find(:xpath, "//input[@id='personal_snippet_content']").set 'Content of snippet three'
- end
- click_button "Create snippet"
- end
-
- step 'I submit new internal snippet' do
- fill_in "personal_snippet_title", with: "Internal personal snippet one"
- fill_in "personal_snippet_file_name", with: "my_snippet.rb"
- choose 'personal_snippet_visibility_level_10'
-
- page.within('.file-editor') do
- find(:xpath, "//input[@id='personal_snippet_content']").set 'Content of internal snippet'
- end
-
- click_button "Create snippet"
- end
-
- step 'I should see snippet "Personal snippet three"' do
- expect(page).to have_content "Personal snippet three"
- expect(page).to have_content "Content of snippet three"
- end
-
- step 'I submit new title "Personal snippet new title"' do
- fill_in "personal_snippet_title", with: "Personal snippet new title"
- click_button "Save"
- end
-
- step 'I should see "Personal snippet new title"' do
- expect(page).to have_content "Personal snippet new title"
- end
-
- step 'I uncheck "Private" checkbox' do
- choose "Internal"
- click_button "Save"
- end
-
- step 'I should see "Personal snippet one" public' do
- expect(page).to have_no_xpath("//i[@class='public-snippet']")
- end
-
- step 'I visit snippet page "Personal snippet one"' do
- visit snippet_path(snippet)
- end
-
- step 'I visit snippet page "Internal personal snippet one"' do
- visit snippet_path(internal_snippet)
- end
-
- def snippet
- @snippet ||= PersonalSnippet.find_by!(title: "Personal snippet one")
- end
-
- def internal_snippet
- @snippet ||= PersonalSnippet.find_by!(title: "Internal personal snippet one")
- end
-end
diff --git a/features/steps/user.rb b/features/steps/user.rb
index 271c9b097d4..59385a6ab59 100644
--- a/features/steps/user.rb
+++ b/features/steps/user.rb
@@ -38,6 +38,6 @@ class Spinach::Features::User < Spinach::FeatureSteps
end
def contributed_project
- @contributed_project ||= create(:empty_project, :public)
+ @contributed_project ||= create(:project, :public)
end
end
diff --git a/features/support/capybara.rb b/features/support/capybara.rb
index 6da8aaac6cb..f4691647d4b 100644
--- a/features/support/capybara.rb
+++ b/features/support/capybara.rb
@@ -11,8 +11,10 @@ Capybara.register_driver :poltergeist do |app|
js_errors: true,
timeout: timeout,
window_size: [1366, 768],
+ url_whitelist: %w[localhost 127.0.0.1],
+ url_blacklist: %w[.mp4 .png .gif .avi .bmp .jpg .jpeg],
phantomjs_options: [
- '--load-images=no'
+ '--load-images=yes'
]
)
end
diff --git a/features/support/env.rb b/features/support/env.rb
index 06c804b1db7..608d988755c 100644
--- a/features/support/env.rb
+++ b/features/support/env.rb
@@ -10,7 +10,7 @@ if ENV['CI']
Knapsack::Adapters::SpinachAdapter.bind
end
-%w(select2_helper test_env repo_helpers wait_for_ajax sidekiq).each do |f|
+%w(select2_helper test_env repo_helpers wait_for_requests sidekiq).each do |f|
require Rails.root.join('spec', 'support', f)
end
@@ -28,6 +28,14 @@ Spinach.hooks.before_run do
TestEnv.disable_pre_receive
include FactoryGirl::Syntax::Methods
+ include GitlabRoutingHelper
+end
+
+Spinach.hooks.after_scenario do |scenario_data, step_definitions|
+ if scenario_data.tags.include?('javascript')
+ include WaitForRequests
+ block_and_wait_for_requests_complete
+ end
end
module StdoutReporterWithScenarioLocation
diff --git a/fixtures/emojis/digests.json b/fixtures/emojis/digests.json
index 3cbc4702dac..589cff165f3 100644
--- a/fixtures/emojis/digests.json
+++ b/fixtures/emojis/digests.json
@@ -2,10746 +2,12537 @@
"100": {
"category": "symbols",
"moji": "💯",
+ "description": "hundred points symbol",
"unicodeVersion": "6.0",
"digest": "add3bd7d06b6dd445788b277f8c9e5dcf42a54d3ec8b7fb9e7a39695dd95d094"
},
"1234": {
"category": "symbols",
"moji": "🔢",
+ "description": "input symbol for numbers",
"unicodeVersion": "6.0",
"digest": "c5ac5c8147f5bfd644fad6b470432bba86ffc7bcee04a0e0d277cd1ca485207f"
},
"8ball": {
"category": "activity",
"moji": "🎱",
+ "description": "billiards",
"unicodeVersion": "6.0",
"digest": "a6e6855775b66c505adee65926a264103ebddf2e2d963db7c009b4fec3a24178"
},
"a": {
"category": "symbols",
"moji": "🅰",
+ "description": "negative squared latin capital letter a",
"unicodeVersion": "6.0",
"digest": "bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc"
},
"ab": {
"category": "symbols",
"moji": "🆎",
+ "description": "negative squared ab",
"unicodeVersion": "6.0",
"digest": "67430fe5fce981160e2ea9052962e49f264322d3abfc2828fbc311b6cdf67ae8"
},
"abc": {
"category": "symbols",
"moji": "🔤",
+ "description": "input symbol for latin letters",
"unicodeVersion": "6.0",
"digest": "282c817ee3414d77a74b815962c33dd9fe71fabaea8c7a9cec466100fbe32187"
},
"abcd": {
"category": "symbols",
"moji": "🔡",
+ "description": "input symbol for latin small letters",
"unicodeVersion": "6.0",
"digest": "686728c759f4683c64762ee4eda0a91bf2041f0ae4f358aacf6c09bf51892eff"
},
"accept": {
"category": "symbols",
"moji": "🉑",
+ "description": "circled ideograph accept",
"unicodeVersion": "6.0",
"digest": "7208d34c761f10a7fd28f98e25535eba13ff91a64442fc282a98bb77722614f1"
},
"aerial_tramway": {
"category": "travel",
"moji": "🚡",
+ "description": "aerial tramway",
"unicodeVersion": "6.0",
"digest": "98df666f34370fc34ce280d84bba5a7e617f733fbbfe66caa424b2afa6ab6777"
},
"airplane": {
"category": "travel",
"moji": "✈",
+ "description": "airplane",
"unicodeVersion": "1.1",
"digest": "cc12cf259ef88e57717620cd2bd5aa6a02a8631ee532a3bde24bee78edc5de33"
},
"airplane_arriving": {
"category": "travel",
"moji": "🛬",
+ "description": "airplane arriving",
"unicodeVersion": "7.0",
"digest": "80d5b4675f91c4cff06d146d795a065b0ce2a74557df4d9e3314e3d3b5c4ae82"
},
"airplane_departure": {
"category": "travel",
"moji": "🛫",
+ "description": "airplane departure",
"unicodeVersion": "7.0",
"digest": "5544eace06b8e1b6ea91940e893e013d33d6b166e14e6d128a87f2cd2de88332"
},
"airplane_small": {
"category": "travel",
"moji": "🛩",
+ "description": "small airplane",
"unicodeVersion": "7.0",
"digest": "1a2e07abbbe90d05cee7ff8dd52f443d595ccb38959f3089fe016b77e5d6de7d"
},
"alarm_clock": {
"category": "objects",
"moji": "â°",
+ "description": "alarm clock",
"unicodeVersion": "6.0",
"digest": "fef05a3cd1cddbeca4de8091b94bddb93790b03fa213da86c0eec420f8c49599"
},
"alembic": {
"category": "objects",
"moji": "âš—",
+ "description": "alembic",
"unicodeVersion": "4.1",
"digest": "c94b2a4bf24ccf4db27a22c9725cfe900f4a99ec49ef2411d67952bcb2ca1bfb"
},
"alien": {
"category": "people",
"moji": "👽",
+ "description": "extraterrestrial alien",
"unicodeVersion": "6.0",
"digest": "856ba98202b244c13a5ee3014a6f7ad592d8c119a30d79e4fc790b74b0e321f7"
},
"ambulance": {
"category": "travel",
"moji": "🚑",
+ "description": "ambulance",
"unicodeVersion": "6.0",
"digest": "d9b3c1873de496a4554e715342c72290fb69a9c6766d7885f38bfe9491d052da"
},
"amphora": {
"category": "objects",
"moji": "ðŸº",
+ "description": "amphora",
"unicodeVersion": "8.0",
"digest": "4015f907b649b5e348502cc0e3685ed184e180dca5cc81c43ec516e14df127bf"
},
"anchor": {
"category": "travel",
"moji": "âš“",
+ "description": "anchor",
"unicodeVersion": "4.1",
"digest": "2b29b34ef896ebab70016301e3d1880209bbc3c5a5b8d832e43afff9b17ad792"
},
"angel": {
"category": "people",
"moji": "👼",
+ "description": "baby angel",
"unicodeVersion": "6.0",
"digest": "db75c2460aaf9cd07cb41fe22c8a6079f3667ffe612a71611358720e2b5512a4"
},
"angel_tone1": {
"category": "people",
"moji": "👼ðŸ»",
+ "description": "baby angel tone 1",
"unicodeVersion": "8.0",
"digest": "5871a622469b96296365adaf77d83167759692124c20e5a6e062a525af33472a"
},
"angel_tone2": {
"category": "people",
"moji": "👼ðŸ¼",
+ "description": "baby angel tone 2",
"unicodeVersion": "8.0",
"digest": "f5993198a5d9daf39e761c783461f07bca237f4e9b739ac300bb8ca001a69a1a"
},
"angel_tone3": {
"category": "people",
"moji": "👼ðŸ½",
+ "description": "baby angel tone 3",
"unicodeVersion": "8.0",
"digest": "f0c97a7c4354626267d6ab0f388e4297ad255ab9b061f9c68fbcaa0abfc52783"
},
"angel_tone4": {
"category": "people",
"moji": "👼ðŸ¾",
+ "description": "baby angel tone 4",
"unicodeVersion": "8.0",
"digest": "6e5dc724c1939d1b0d1a91343662b5bd61ced7709c97802977145ffab6a1f7ac"
},
"angel_tone5": {
"category": "people",
"moji": "👼ðŸ¿",
+ "description": "baby angel tone 5",
"unicodeVersion": "8.0",
"digest": "52186e1de350c27d25d6010edf44f64a30338b65912ca178429fbcfbd88113c2"
},
"anger": {
"category": "symbols",
"moji": "💢",
+ "description": "anger symbol",
"unicodeVersion": "6.0",
"digest": "332493913891aa0eda2743b4bb16c4682400f249998bf34eb292246c9009e17f"
},
"anger_right": {
"category": "symbols",
"moji": "🗯",
+ "description": "right anger bubble",
"unicodeVersion": "7.0",
"digest": "8b049511ef3b1b28325841e2f87c60773eaf2f65cabba58d8b0ec3de9b10c0ae"
},
"angry": {
"category": "people",
"moji": "😠",
+ "description": "angry face",
"unicodeVersion": "6.0",
"digest": "7e09e7e821f511606341fb5ce4011a8ed9809766ab86b7983ffa6ea352b39ec1"
},
"ant": {
"category": "nature",
"moji": "ðŸœ",
+ "description": "ant",
"unicodeVersion": "6.0",
"digest": "929abeaff7ba21ab71cd1ab798af7a6b611e3b3ce1af80cede09a116b223e442"
},
"apple": {
"category": "food",
"moji": "ðŸŽ",
+ "description": "red apple",
"unicodeVersion": "6.0",
"digest": "2a1b85ce57e3d236ae7777dcf332ec37d03bfd7b19806521a353bc532083224d"
},
"aquarius": {
"category": "symbols",
"moji": "â™’",
+ "description": "aquarius",
"unicodeVersion": "1.1",
"digest": "fdc42cd41b0dace5eae6baba3143f1e40295d48a29e7103a5bba1d84a056c39d"
},
"aries": {
"category": "symbols",
"moji": "♈",
+ "description": "aries",
"unicodeVersion": "1.1",
"digest": "deb135debcde0a98f40361a84ab64d57c18b5b445cd2f4199e8936f052899737"
},
"arrow_backward": {
"category": "symbols",
"moji": "â—€",
+ "description": "black left-pointing triangle",
"unicodeVersion": "1.1",
"digest": "e162ac82e90d1e925d479fa5c45b9340e0a53287be04e43cbbb2a89c7e7e45e4"
},
"arrow_double_down": {
"category": "symbols",
"moji": "â¬",
+ "description": "black down-pointing double triangle",
"unicodeVersion": "6.0",
"digest": "03ca890b05338d40972c7a056d672df620a203c6ca52ff3ff530f1a710905507"
},
"arrow_double_up": {
"category": "symbols",
"moji": "â«",
+ "description": "black up-pointing double triangle",
"unicodeVersion": "6.0",
"digest": "e753f05bce993d62d5dc79e33c441ced059381b6ce21fa3ea4200f1b3236e59d"
},
"arrow_down": {
"category": "symbols",
"moji": "⬇",
+ "description": "downwards black arrow",
"unicodeVersion": "4.0",
"digest": "9bf1bd2ea652ca9321087de58c7a112ea04c35676a6ee0766154183f8b95af6c"
},
"arrow_down_small": {
"category": "symbols",
"moji": "🔽",
+ "description": "down-pointing small red triangle",
"unicodeVersion": "6.0",
"digest": "7766198bc60cf59d6cdaeeaa700c2282bfff2f0fdeb22cf4581ca284b87a3bb7"
},
"arrow_forward": {
"category": "symbols",
"moji": "â–¶",
+ "description": "black right-pointing triangle",
"unicodeVersion": "1.1",
"digest": "db77d9accd1e02224f5d612f79cd691e6befdf22063475204836be6572510fb7"
},
"arrow_heading_down": {
"category": "symbols",
"moji": "⤵",
+ "description": "arrow pointing rightwards then curving downwards",
"unicodeVersion": "3.2",
"digest": "f5396069c8f63c13e6c3e0ecd34267c932451309ade9c1171d410563153bf909"
},
"arrow_heading_up": {
"category": "symbols",
"moji": "⤴",
+ "description": "arrow pointing rightwards then curving upwards",
"unicodeVersion": "3.2",
"digest": "1cad71923fa3df24cf543cae4ce775b0f74936f2edd685fd86a7525c41a14568"
},
"arrow_left": {
"category": "symbols",
"moji": "⬅",
+ "description": "leftwards black arrow",
"unicodeVersion": "4.0",
"digest": "b629bb3dbe161ef89cfcfced0c7968a68e44a019ad509132987e4973bdc874e7"
},
"arrow_lower_left": {
"category": "symbols",
"moji": "↙",
+ "description": "south west arrow",
"unicodeVersion": "1.1",
"digest": "879136ba0e24e6bf3be70118abcb716d71bd74f7b62347bc052b6533c0ea534d"
},
"arrow_lower_right": {
"category": "symbols",
"moji": "↘",
+ "description": "south east arrow",
"unicodeVersion": "1.1",
"digest": "86d52ac9b961991e3aaa6a9f9b5ace4db6ffd1b5c171c09c23b516473b55066d"
},
"arrow_right": {
"category": "symbols",
"moji": "âž¡",
+ "description": "black rightwards arrow",
"unicodeVersion": "1.1",
"digest": "45f26a1cbb0f00ed3609b39da52e9d9e896a77e361c4c8036b1bf8038171bd49"
},
"arrow_right_hook": {
"category": "symbols",
"moji": "↪",
+ "description": "rightwards arrow with hook",
"unicodeVersion": "1.1",
"digest": "4f452679c71bcea4fc4a701c55156fef3ddc1ebbc70570bedfc9d3a029637ab1"
},
"arrow_up": {
"category": "symbols",
"moji": "⬆",
+ "description": "upwards black arrow",
"unicodeVersion": "4.0",
"digest": "982b988ef6651d8a71867ba7c87f640f62dd0eeb0b7c358f5a5c37e8fe507b8b"
},
"arrow_up_down": {
"category": "symbols",
"moji": "↕",
+ "description": "up down arrow",
"unicodeVersion": "1.1",
"digest": "645ed8fb6646f49bfd95af1752336deacdadbe5cba13904023a704288f3b0e2c"
},
"arrow_up_small": {
"category": "symbols",
"moji": "🔼",
+ "description": "up-pointing small red triangle",
"unicodeVersion": "6.0",
"digest": "4a8c5789c13a852517e639e7a62c2d331464e6fb0358985aa97c1515e97b5e8b"
},
"arrow_upper_left": {
"category": "symbols",
"moji": "↖",
+ "description": "north west arrow",
"unicodeVersion": "1.1",
"digest": "79026f828d6ceb7c55a9542770962ba6dcd08203995f6ceeb70333a12307d376"
},
"arrow_upper_right": {
"category": "symbols",
"moji": "↗",
+ "description": "north east arrow",
"unicodeVersion": "1.1",
"digest": "7e0f33dfbe65628991c170130d366a3e2cedaf8862ddfcaf3960f395d3da1926"
},
"arrows_clockwise": {
"category": "symbols",
"moji": "🔃",
+ "description": "clockwise downwards and upwards open circle arrows",
"unicodeVersion": "6.0",
"digest": "88669679977f7157f0acaa9d6a1b77ccf84d25eb78c5bc8afcde38d3635e7144"
},
"arrows_counterclockwise": {
"category": "symbols",
"moji": "🔄",
+ "description": "anticlockwise downwards and upwards open circle ar",
"unicodeVersion": "6.0",
"digest": "a2c6a6d3643c128aee3304cd03bb3d7cfe4d35d3ba825bc9c1142d7832b4426e"
},
"art": {
"category": "activity",
"moji": "🎨",
+ "description": "artist palette",
"unicodeVersion": "6.0",
"digest": "b6bc6c4bfb594aadcbb641d006031867678504764bbe0ab84e7b08567a9498da"
},
"articulated_lorry": {
"category": "travel",
"moji": "🚛",
+ "description": "articulated lorry",
"unicodeVersion": "6.0",
"digest": "c115e6613ebd718268aa31d265e017138b9fb58bbb8201eb3f40de2380e460aa"
},
"asterisk": {
"category": "symbols",
"moji": "*⃣",
+ "description": "keycap asterisk",
"unicodeVersion": "3.0",
"digest": "33d92093f2914448d5a939cf62e8ee3e32931923abdef5f0210e8a8150fa312d"
},
"astonished": {
"category": "people",
"moji": "😲",
+ "description": "astonished face",
"unicodeVersion": "6.0",
"digest": "f8531bdda5070d10492709085f4ff652b8be9be6458758940358b9fc594a1f14"
},
"athletic_shoe": {
"category": "people",
"moji": "👟",
+ "description": "athletic shoe",
"unicodeVersion": "6.0",
"digest": "1f90dc390e0dea679085465b7f9e786dfd7dd56a3b219987144ed37ab1e9bf95"
},
"atm": {
"category": "symbols",
"moji": "ðŸ§",
+ "description": "automated teller machine",
"unicodeVersion": "6.0",
"digest": "7d3ce6a6afb4951546883404b8e36904179f88f1aa533706cf7bf0bbe0d6fd3c"
},
"atom": {
"category": "symbols",
"moji": "âš›",
+ "description": "atom symbol",
"unicodeVersion": "4.1",
"digest": "6b6bb83b00707a314e46ff8eefbda40978a291ec7881caba1b1ee273f49c1368"
},
"avocado": {
"category": "food",
"moji": "🥑",
+ "description": "avocado",
"unicodeVersion": "9.0",
"digest": "bc1fb203d63b18985598400925de24050bb192afda1cbf0813f85cb139869eff"
},
"b": {
"category": "symbols",
"moji": "🅱",
+ "description": "negative squared latin capital letter b",
"unicodeVersion": "6.0",
"digest": "722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf"
},
"baby": {
"category": "people",
"moji": "👶",
+ "description": "baby",
"unicodeVersion": "6.0",
"digest": "219ae5a571aaf90c060956cd1c56dcc27708c827cecdca3ba1122058a3c4847b"
},
"baby_bottle": {
"category": "food",
"moji": "ðŸ¼",
+ "description": "baby bottle",
"unicodeVersion": "6.0",
"digest": "4fb71689e9d634e8d1699cf454a71e43f2b5b1a5dbab0bf186626934fdf5b782"
},
"baby_chick": {
"category": "nature",
"moji": "ðŸ¤",
+ "description": "baby chick",
"unicodeVersion": "6.0",
"digest": "14119874e9b5548028dfb9cc593a541efc1d075ac839a565b92e0c3253cffe7e"
},
"baby_symbol": {
"category": "symbols",
"moji": "🚼",
+ "description": "baby symbol",
"unicodeVersion": "6.0",
"digest": "fb4db66868cda45ea3879ffc2ff4f763c56d2d889ae0ab17fe171129ede02f98"
},
"baby_tone1": {
"category": "people",
"moji": "👶ðŸ»",
+ "description": "baby tone 1",
"unicodeVersion": "8.0",
"digest": "cd3faf223a298c34e05d469d9d0db08438d97df7fd82c0973f8a9e07d553f5b1"
},
"baby_tone2": {
"category": "people",
"moji": "👶ðŸ¼",
+ "description": "baby tone 2",
"unicodeVersion": "8.0",
"digest": "5b4539e22e0dd726c27eb8af2357f9240a52aed3f710f3234571cff029cc6198"
},
"baby_tone3": {
"category": "people",
"moji": "👶ðŸ½",
+ "description": "baby tone 3",
"unicodeVersion": "8.0",
"digest": "720e740e1ac63c6372269132b1fb6e07a6b91f5c808cc3adef59f0b4500e5e72"
},
"baby_tone4": {
"category": "people",
"moji": "👶ðŸ¾",
+ "description": "baby tone 4",
"unicodeVersion": "8.0",
"digest": "5e43b69c509bd526ad6f081764578c30b6f3285fb7442222e05ccf62e53bfb64"
},
"baby_tone5": {
"category": "people",
"moji": "👶ðŸ¿",
+ "description": "baby tone 5",
"unicodeVersion": "8.0",
"digest": "85bba6e0940ccfb99999fe124e815f9dd340d00a5568e13967b02245a62dbf54"
},
"back": {
"category": "symbols",
"moji": "🔙",
+ "description": "back with leftwards arrow above",
"unicodeVersion": "6.0",
"digest": "083e4e48b51092c28efb4532e840e1091b5d4b685c6e0f221aa0228f061cd91e"
},
"bacon": {
"category": "food",
"moji": "🥓",
+ "description": "bacon",
"unicodeVersion": "9.0",
"digest": "18ad3817f1f88a69706db5727a58e763dde6c21a2d4f184c3d728c32dc5fa05a"
},
"badminton": {
"category": "activity",
"moji": "ðŸ¸",
+ "description": "badminton racquet",
"unicodeVersion": "8.0",
"digest": "353eb7ee93decd9fe0072e4d78a5618d5e2d9e77a6e4de9fe171870d75e02a66"
},
"baggage_claim": {
"category": "symbols",
"moji": "🛄",
+ "description": "baggage claim",
"unicodeVersion": "6.0",
"digest": "7d6bceca92c266da6d2b91dfcf244546fc11022e039e7da8e6888c1696bb2186"
},
"balloon": {
"category": "objects",
"moji": "🎈",
+ "description": "balloon",
"unicodeVersion": "6.0",
"digest": "65760aedc1503b426927cff78c24449d563843a274961d962718fa9638375d54"
},
"ballot_box": {
"category": "objects",
"moji": "🗳",
+ "description": "ballot box with ballot",
"unicodeVersion": "7.0",
"digest": "4175a56eca5c6458574a681e109b1403fbb143cf27f69ae6c1917650f3e08892"
},
"ballot_box_with_check": {
"category": "symbols",
"moji": "☑",
+ "description": "ballot box with check",
"unicodeVersion": "1.1",
"digest": "c98d6f3588dd87e2f318bbfe6c646399a905450edfd814edae4e5b1bddef2134"
},
"bamboo": {
"category": "nature",
"moji": "ðŸŽ",
+ "description": "pine decoration",
"unicodeVersion": "6.0",
"digest": "e4ee65088df43d7081b1ce6fd996f66f3e0accd88840855c47a98a22997823dd"
},
"banana": {
"category": "food",
"moji": "ðŸŒ",
+ "description": "banana",
"unicodeVersion": "6.0",
"digest": "f9e8ff910c282c20a8907ff64926b5de4ee250529a1ed718fb33302e6fff8dd9"
},
"bangbang": {
"category": "symbols",
"moji": "‼",
+ "description": "double exclamation mark",
"unicodeVersion": "1.1",
"digest": "76536fee63fe964a3f3839d309b1f45028fb0c43f4d1eeee495f17e1532b4def"
},
"bank": {
"category": "travel",
"moji": "ðŸ¦",
+ "description": "bank",
"unicodeVersion": "6.0",
"digest": "f5d2976bf6d521638ccacc74be06bd4abfeab06c5d898a9d245edad45a5b6306"
},
"bar_chart": {
"category": "objects",
"moji": "📊",
+ "description": "bar chart",
"unicodeVersion": "6.0",
"digest": "65a328a1b2d7a5332dd4d93f4dbca13d976f0a505b00835c3fc458e394804240"
},
"barber": {
"category": "objects",
"moji": "💈",
+ "description": "barber pole",
"unicodeVersion": "6.0",
"digest": "5e8053d3bb3765a8632fd1cbfe21163f74ed79f6be377eb9603eaaf883d8dc46"
},
"baseball": {
"category": "activity",
"moji": "âš¾",
+ "description": "baseball",
"unicodeVersion": "5.2",
"digest": "46ac16f8b5455b942f6dbff9483a6fd277721e6719d2731573baabd21c44b34f"
},
"basketball": {
"category": "activity",
"moji": "ðŸ€",
+ "description": "basketball and hoop",
"unicodeVersion": "6.0",
"digest": "cc83e2aea8fcd2e9a5789e1932ee3766c40843c142fd3565c4e77dafb21ec7d7"
},
"basketball_player": {
"category": "activity",
"moji": "⛹",
+ "description": "person with ball",
"unicodeVersion": "5.2",
"digest": "793ba53c95e8def769383b612037bc9b9bceecaf1e0430c50a4cc128ad18d9b9"
},
"basketball_player_tone1": {
"category": "activity",
"moji": "⛹ðŸ»",
+ "description": "person with ball tone 1",
"unicodeVersion": "8.0",
"digest": "2a06522b971e68ee5b8777a58253009b548f4da2fb723c638acb3d7b04edba8f"
},
"basketball_player_tone2": {
"category": "activity",
"moji": "⛹ðŸ¼",
+ "description": "person with ball tone 2",
"unicodeVersion": "8.0",
"digest": "ecc0e44ab9bc478ba45a055fd69a3a38377b917aac5047963fe80ff8ae5fd8e3"
},
"basketball_player_tone3": {
"category": "activity",
"moji": "⛹ðŸ½",
+ "description": "person with ball tone 3",
"unicodeVersion": "8.0",
"digest": "2d38f1851c685d29532c042461d7b5b996e5f04f0ed54857c66073c62a99ceac"
},
"basketball_player_tone4": {
"category": "activity",
"moji": "⛹ðŸ¾",
+ "description": "person with ball tone 4",
"unicodeVersion": "8.0",
"digest": "09e957c6e9ffc196415f28073aa261feba8efba0bdc694dc08f8f7cd1f88f720"
},
"basketball_player_tone5": {
"category": "activity",
"moji": "⛹ðŸ¿",
+ "description": "person with ball tone 5",
"unicodeVersion": "8.0",
"digest": "c631cefc5d2a0a31bdb9f0a0d97ea68b1c6928e565468998403034644572a0b0"
},
"bat": {
"category": "nature",
"moji": "🦇",
+ "description": "bat",
"unicodeVersion": "9.0",
"digest": "8fc19e0d7d6f80906bdbc06d616a810de66180d96cf28070a53fa61b88904535"
},
"bath": {
"category": "activity",
"moji": "🛀",
+ "description": "bath",
"unicodeVersion": "6.0",
"digest": "33b371832f90aad50baf5296f3ad4cc081c319b279f989c74409903d8568e917"
},
"bath_tone1": {
"category": "activity",
"moji": "🛀ðŸ»",
+ "description": "bath tone 1",
"unicodeVersion": "8.0",
"digest": "7ae2989e47788ba71359d52da68feec95aaff68a77d5a6556957df1617af8536"
},
"bath_tone2": {
"category": "activity",
"moji": "🛀ðŸ¼",
+ "description": "bath tone 2",
"unicodeVersion": "8.0",
"digest": "2e86f8edad54d15a7094cd52160cbe51d10aa1750cfb0b3b58e93533f070e327"
},
"bath_tone3": {
"category": "activity",
"moji": "🛀ðŸ½",
+ "description": "bath tone 3",
"unicodeVersion": "8.0",
"digest": "654c0cd083a67ff330a38d07352876d265390e5399e5352598d64a6c7e5eeba7"
},
"bath_tone4": {
"category": "activity",
"moji": "🛀ðŸ¾",
+ "description": "bath tone 4",
"unicodeVersion": "8.0",
"digest": "adad88c6830f31c4b5be194d1987d6aadf4adf45e4cb7f2e4657f0d20c0d663a"
},
"bath_tone5": {
"category": "activity",
"moji": "🛀ðŸ¿",
+ "description": "bath tone 5",
"unicodeVersion": "8.0",
"digest": "952c4c9bf24e001e23a33ebf97bd92969cd9143e28ce93f9aafc708a8f966903"
},
"bathtub": {
"category": "objects",
"moji": "ðŸ›",
+ "description": "bathtub",
"unicodeVersion": "6.0",
"digest": "844dffb87ef872594195069b0d0df27c3fe51f3967ccbc8b2df811a086dd483a"
},
"battery": {
"category": "objects",
"moji": "🔋",
+ "description": "battery",
"unicodeVersion": "6.0",
"digest": "949ae06648667fb13d9121a6dfdd03bf8692794b28c36e9a8e8ac4515664449a"
},
"beach": {
"category": "travel",
"moji": "ðŸ–",
+ "description": "beach with umbrella",
"unicodeVersion": "7.0",
"digest": "37fa2158977d470186caaa1aa06669b6dc5026ba49a0c44c5255541f8e974e26"
},
"beach_umbrella": {
"category": "objects",
"moji": "â›±",
+ "description": "umbrella on ground",
"unicodeVersion": "5.2",
"digest": "d045f1de10038b9fb1eaa2529b2f80b7e3be1cff503efcc2d680663d1fbbc18f"
},
"bear": {
"category": "nature",
"moji": "ðŸ»",
+ "description": "bear face",
"unicodeVersion": "6.0",
"digest": "a4b9066eaa5681e6af06e596a96a5217037460ffc3b013e8db4d34d762413246"
},
"bed": {
"category": "objects",
"moji": "ðŸ›",
+ "description": "bed",
"unicodeVersion": "7.0",
"digest": "08f6e20db51b1fb650b390a0a3074938646772f3fcee8c295d47742e44fe1e30"
},
"bee": {
"category": "nature",
"moji": "ðŸ",
+ "description": "honeybee",
"unicodeVersion": "6.0",
"digest": "5beb9a1650681b4adf69999d4808231c38f41a3ec693480b807cda86f964c570"
},
"beer": {
"category": "food",
"moji": "ðŸº",
+ "description": "beer mug",
"unicodeVersion": "6.0",
"digest": "69e227104976548ee0f37375fe1526fd65ef0a328d2d92db2feb1edfd7032bd4"
},
"beers": {
"category": "food",
"moji": "ðŸ»",
+ "description": "clinking beer mugs",
"unicodeVersion": "6.0",
"digest": "db8b32d93bf6d161a3b027e55651d8f51231b13928b3610987ef62bb634d7501"
},
"beetle": {
"category": "nature",
"moji": "ðŸž",
+ "description": "lady beetle",
"unicodeVersion": "6.0",
"digest": "5aaa428e3f63f7cd1696839ab05be03fa0cd0cbed30a05c36cb270da330c3849"
},
"beginner": {
"category": "symbols",
"moji": "🔰",
+ "description": "japanese symbol for beginner",
"unicodeVersion": "6.0",
"digest": "2de4fdf92f182c42b12b7527034eaf767d996848b61f31ee69167728411ca0b1"
},
"bell": {
"category": "symbols",
"moji": "🔔",
+ "description": "bell",
"unicodeVersion": "6.0",
"digest": "18d419417746ead408072b78fe2edb6314cdb49492873966fa9f9f06be09899b"
},
"bellhop": {
"category": "objects",
"moji": "🛎",
+ "description": "bellhop bell",
"unicodeVersion": "7.0",
"digest": "b8187bc4059f6a0924a47fe3f6c07f656bed0334bbcbfa1e89f800fe6594ff08"
},
"bento": {
"category": "food",
"moji": "ðŸ±",
+ "description": "bento box",
"unicodeVersion": "6.0",
"digest": "d46d4f681c5da7f7678b51be3445454a8ed18d917e132ae79077f05310e485f1"
},
"bicyclist": {
"category": "activity",
"moji": "🚴",
+ "description": "bicyclist",
"unicodeVersion": "6.0",
"digest": "3302147b6b47c16adb97d78b7b761a1ca80e6d0b41d0b60f4da338d2f55f968b"
},
"bicyclist_tone1": {
"category": "activity",
"moji": "🚴ðŸ»",
+ "description": "bicyclist tone 1",
"unicodeVersion": "8.0",
"digest": "27eaae0eb61f5e7b3cd9faf02c042d6643a368051a7c9d7da4e0fb9802d39242"
},
"bicyclist_tone2": {
"category": "activity",
"moji": "🚴ðŸ¼",
+ "description": "bicyclist tone 2",
"unicodeVersion": "8.0",
"digest": "39ee9e1071700da7079ad0146bf5711c3a222991eeca8b29b72a65677604444d"
},
"bicyclist_tone3": {
"category": "activity",
"moji": "🚴ðŸ½",
+ "description": "bicyclist tone 3",
"unicodeVersion": "8.0",
"digest": "03e1d2c4232c896147a9d4bf43becd61edbb5c84fc7193ecea474c0f9fb36817"
},
"bicyclist_tone4": {
"category": "activity",
"moji": "🚴ðŸ¾",
+ "description": "bicyclist tone 4",
"unicodeVersion": "8.0",
"digest": "61393d9c4805be0379d86dd5bec9a1b02314433ab36cfd85bb48dfd073746617"
},
"bicyclist_tone5": {
"category": "activity",
"moji": "🚴ðŸ¿",
+ "description": "bicyclist tone 5",
"unicodeVersion": "8.0",
"digest": "2b46d5f8303e5710dbf5db3a4edc9d88a032fe123fe79158024c9f51df5458c6"
},
"bike": {
"category": "travel",
"moji": "🚲",
+ "description": "bicycle",
"unicodeVersion": "6.0",
"digest": "b41daa7c549d483e2336186a28baaa8ecb11986f490c0c54c793c44900c8f652"
},
"bikini": {
"category": "people",
"moji": "👙",
+ "description": "bikini",
"unicodeVersion": "6.0",
"digest": "07fe156f64673818d69ce3bf03950ca59e3b5d346e45ca541da4078ab791f5ae"
},
"biohazard": {
"category": "symbols",
"moji": "☣",
+ "description": "biohazard sign",
"unicodeVersion": "1.1",
"digest": "96163e31f0b8dc5a59772133ede9cc2f40f94330d0b15e3d044b28747e2be788"
},
"bird": {
"category": "nature",
"moji": "ðŸ¦",
+ "description": "bird",
"unicodeVersion": "6.0",
"digest": "f916eaf8f271b3767ade9eabb69594c0479f45472d471cabaf59f6e965c161e0"
},
"birthday": {
"category": "food",
"moji": "🎂",
+ "description": "birthday cake",
"unicodeVersion": "6.0",
"digest": "89e7c4c598ebee8ec8ab11ebe4ccc6defb7c4d2987ee2379a19b3b59827dd98a"
},
"black_circle": {
"category": "symbols",
"moji": "âš«",
+ "description": "medium black circle",
"unicodeVersion": "4.1",
"digest": "c2ba672994ad0f99d7fdc449f3fee45a2dca68a58f9fe95825b38465a30ef44e"
},
"black_heart": {
"category": "symbols",
"moji": "🖤",
+ "description": "black heart",
"unicodeVersion": "9.0",
"digest": "f334679168d6dd7328c28e9ae3cb2b1fca0e9c2777938d586bfe623db2a688b9"
},
"black_joker": {
"category": "symbols",
"moji": "ðŸƒ",
+ "description": "playing card black joker",
"unicodeVersion": "6.0",
"digest": "d004b25f186494d5b2c65204caa9daecd749c840a0bea5718735e18109e5394d"
},
"black_large_square": {
"category": "symbols",
"moji": "⬛",
+ "description": "black large square",
"unicodeVersion": "5.1",
"digest": "cbd90dcbc2f674eafa53820548b5263c18c9845ab39937f085e85aca0aebb479"
},
"black_medium_small_square": {
"category": "symbols",
"moji": "â—¾",
+ "description": "black medium small square",
"unicodeVersion": "3.2",
"digest": "ab38363c2e862b8f67c719397a09a18e1ef996eec190691fdf769f5cfb209660"
},
"black_medium_square": {
"category": "symbols",
"moji": "â—¼",
+ "description": "black medium square",
"unicodeVersion": "3.2",
"digest": "c9ffa87c37e8ee65fadcf755176949901aec7367e02abb85e63cad60cd922116"
},
"black_nib": {
"category": "objects",
"moji": "✒",
+ "description": "black nib",
"unicodeVersion": "1.1",
"digest": "58fb23b1155102970eaa23765e7d529a21e8e545e076ec1158bf11b4de5f51a8"
},
"black_small_square": {
"category": "symbols",
"moji": "â–ª",
+ "description": "black small square",
"unicodeVersion": "1.1",
"digest": "f69be6de578fffce5a3e60eda690104b2ef6a855c630040104fb760a02ff1aef"
},
"black_square_button": {
"category": "symbols",
"moji": "🔲",
+ "description": "black square button",
"unicodeVersion": "6.0",
"digest": "9d818fcd08ed38cd0bbbcfd83e665aa29b3761c0d8b9806d8954d36785e267a8"
},
"blossom": {
"category": "nature",
"moji": "🌼",
+ "description": "blossom",
"unicodeVersion": "6.0",
"digest": "e8cf369d4e4cdb4eccc2ebcbb35439b0344221115701daae642e58dff8544922"
},
"blowfish": {
"category": "nature",
"moji": "ðŸ¡",
+ "description": "blowfish",
"unicodeVersion": "6.0",
"digest": "e706849ed00f08a82312381c76f6f9ba6cc261fbf87a839c85e7dd54138f9dc3"
},
"blue_book": {
"category": "objects",
"moji": "📘",
+ "description": "blue book",
"unicodeVersion": "6.0",
"digest": "4c845748fe890516b32981b0b62bf3e8e9d906840c2060179f4f844100780615"
},
"blue_car": {
"category": "travel",
"moji": "🚙",
+ "description": "recreational vehicle",
"unicodeVersion": "6.0",
"digest": "eca91934eb5481726cfd897b1ed5eac306e14d02499fbe49316aaec6c72b6707"
},
"blue_heart": {
"category": "symbols",
"moji": "💙",
+ "description": "blue heart",
"unicodeVersion": "6.0",
"digest": "2caa0c8d18538cc871c6fe328a52f71e1df8aabf4d1cc2f5324b261d1b8cb99a"
},
"blush": {
"category": "people",
"moji": "😊",
+ "description": "smiling face with smiling eyes",
"unicodeVersion": "6.0",
"digest": "3bfe8d603cfa39999c164779f666d39bbc507f124ba80233ee72da7b3b0c0457"
},
"boar": {
"category": "nature",
"moji": "ðŸ—",
+ "description": "boar",
"unicodeVersion": "6.0",
"digest": "c9d67479cace427ac3c30460fcffa1bf9a8e5262c0390962405dbbe6bf830fa6"
},
"bomb": {
"category": "objects",
"moji": "💣",
+ "description": "bomb",
"unicodeVersion": "6.0",
"digest": "0155559abc4084f80e9b0b2a2091b8710ddd6369993b7fdd0685f4f8c2fd7e6c"
},
"book": {
"category": "objects",
"moji": "📖",
+ "description": "open book",
"unicodeVersion": "6.0",
"digest": "9d912a9d1bb10dc7f2645b345ed09e90461e83df0de275acb806f1f75cef1fcf"
},
"bookmark": {
"category": "objects",
"moji": "🔖",
+ "description": "bookmark",
"unicodeVersion": "6.0",
"digest": "5705e3108259d6900649157843c50e22d0086c3630b291d3f942da1a736e3e3d"
},
"bookmark_tabs": {
"category": "objects",
"moji": "📑",
+ "description": "bookmark tabs",
"unicodeVersion": "6.0",
"digest": "c8fc7c9f3f82e1ccc97fc591345fdd88b09eec0fca428d8d4632a121cf1bc39a"
},
"books": {
"category": "objects",
"moji": "📚",
+ "description": "books",
"unicodeVersion": "6.0",
"digest": "cbcf55d39dd05d26ef7350bc51e0e2f064f78bb8f59d407b516d63f68558f8e4"
},
"boom": {
"category": "nature",
"moji": "💥",
+ "description": "collision symbol",
"unicodeVersion": "6.0",
"digest": "f5400e9583f7f997cd2385f21379f6229424a9b221445bc8f36c0bb64bdb3168"
},
"boot": {
"category": "people",
"moji": "👢",
+ "description": "womans boots",
"unicodeVersion": "6.0",
"digest": "b4706ff35909a6fb759a3b8a797e90cb67ffc60e4853386a7d89ace9693a9364"
},
"bouquet": {
"category": "nature",
"moji": "ðŸ’",
+ "description": "bouquet",
"unicodeVersion": "6.0",
"digest": "b93751a27b40f6185a22b3e8b413f0fe09b6010d1057c672e1a23088e0b8286f"
},
"bow": {
"category": "people",
"moji": "🙇",
+ "description": "person bowing deeply",
"unicodeVersion": "6.0",
"digest": "33cd6da4d408f18d98bebc6a277dea8b914150e32ee472586ce3f1eb814462bd"
},
"bow_and_arrow": {
"category": "activity",
"moji": "ðŸ¹",
+ "description": "bow and arrow",
"unicodeVersion": "8.0",
"digest": "051b4d50ab21a68b8583a6313ec183e3e1e96f493b0f4541fbb888f0b95fdd4d"
},
"bow_tone1": {
"category": "people",
"moji": "🙇ðŸ»",
+ "description": "person bowing deeply tone 1",
"unicodeVersion": "8.0",
"digest": "995c8400ad60d5adc66c9ae5e3c0ecf56c48b478ad79418d45b6289933d25bdd"
},
"bow_tone2": {
"category": "people",
"moji": "🙇ðŸ¼",
+ "description": "person bowing deeply tone 2",
"unicodeVersion": "8.0",
"digest": "af89eec2fccda99d9bdd373b2345595882fee1c0a15d29af9028089e20255325"
},
"bow_tone3": {
"category": "people",
"moji": "🙇ðŸ½",
+ "description": "person bowing deeply tone 3",
"unicodeVersion": "8.0",
"digest": "015d8122abdf2d0caa03815545f50fb7a71e05dacd46aaa133cc9ace5192f266"
},
"bow_tone4": {
"category": "people",
"moji": "🙇ðŸ¾",
+ "description": "person bowing deeply tone 4",
"unicodeVersion": "8.0",
"digest": "e8409096a795b775def654d36aeccb8eb91e83d7d1b32145cd73fd0b7b9e885c"
},
"bow_tone5": {
"category": "people",
"moji": "🙇ðŸ¿",
+ "description": "person bowing deeply tone 5",
"unicodeVersion": "8.0",
"digest": "d87042cde8dbad9fb1a91a2ec60116e27b4a76388b5779d771a0bbae12a2814d"
},
"bowling": {
"category": "activity",
"moji": "🎳",
+ "description": "bowling",
"unicodeVersion": "6.0",
"digest": "737f2cdfa4ac964baade585a39771b18080bd5e9b55c8661d3518f468f344662"
},
"boxing_glove": {
"category": "activity",
"moji": "🥊",
+ "description": "boxing glove",
"unicodeVersion": "9.0",
"digest": "c914b2ce45f20afad66ad6f0d1b0750c4469e4f48b686dfc4aad1ec8d289c563"
},
"boy": {
"category": "people",
"moji": "👦",
+ "description": "boy",
"unicodeVersion": "6.0",
"digest": "7bc0173d8c88f3f12d41f213f7a3a9f5ebf65efad610fd5a2a31935128a6a6c1"
},
"boy_tone1": {
"category": "people",
"moji": "👦ðŸ»",
+ "description": "boy tone 1",
"unicodeVersion": "8.0",
"digest": "c0e2f0483715b239fe145b0056566f7a3a722319d9a87c1e66733dff1916a19f"
},
"boy_tone2": {
"category": "people",
"moji": "👦ðŸ¼",
+ "description": "boy tone 2",
"unicodeVersion": "8.0",
"digest": "0001d0bd1ff4dbd898604ba965b4039d09667d955bc0349301b992f9ab6dd7fd"
},
"boy_tone3": {
"category": "people",
"moji": "👦ðŸ½",
+ "description": "boy tone 3",
"unicodeVersion": "8.0",
"digest": "e0f08755955fd2e0bd1c5d5e84429b2a234b24a744bb50bb9f1148495b2b29f9"
},
"boy_tone4": {
"category": "people",
"moji": "👦ðŸ¾",
+ "description": "boy tone 4",
"unicodeVersion": "8.0",
"digest": "04b6bfee58a26b1ce2e5b403504a7033aaf395f03f5cd23e824f32c90c395fe6"
},
"boy_tone5": {
"category": "people",
"moji": "👦ðŸ¿",
+ "description": "boy tone 5",
"unicodeVersion": "8.0",
"digest": "0f76e97237203950da36c737dcc6f56dcd6c123401a8c817a0636376c7f38ef5"
},
"bread": {
"category": "food",
"moji": "ðŸž",
+ "description": "bread",
"unicodeVersion": "6.0",
"digest": "81739830f16f33e6a1dd7cc17c25df207846062bb5167bb8abed7fdd49268b86"
},
"bride_with_veil": {
"category": "people",
"moji": "👰",
+ "description": "bride with veil",
"unicodeVersion": "6.0",
"digest": "8e24bd91c3f564cf6148f2b3b4a7d692c11dd059e76a13331fdfb04ae060ea70"
},
"bride_with_veil_tone1": {
"category": "people",
"moji": "👰ðŸ»",
+ "description": "bride with veil tone 1",
"unicodeVersion": "8.0",
"digest": "0bd2f16f72586f50e768b14b9b353f2e98ccbb2581a568c33b06be56e70ca063"
},
"bride_with_veil_tone2": {
"category": "people",
"moji": "👰ðŸ¼",
+ "description": "bride with veil tone 2",
"unicodeVersion": "8.0",
"digest": "e5463f811b2075754f0718b891757cd2e81071edf7af2215581227e1aad1d068"
},
"bride_with_veil_tone3": {
"category": "people",
"moji": "👰ðŸ½",
+ "description": "bride with veil tone 3",
"unicodeVersion": "8.0",
"digest": "e5a053a26f7ccebae7eb12f638be5ed80f77b744708d783eab2eb8aa091cf516"
},
"bride_with_veil_tone4": {
"category": "people",
"moji": "👰ðŸ¾",
+ "description": "bride with veil tone 4",
"unicodeVersion": "8.0",
"digest": "410e23825e4401460946dc67a618bd3ace6e1a7c07dd88580a2349423685261f"
},
"bride_with_veil_tone5": {
"category": "people",
"moji": "👰ðŸ¿",
+ "description": "bride with veil tone 5",
"unicodeVersion": "8.0",
"digest": "454e87e5a74e13e5b4993541231516fbbe6dbe9f990e1a6f3f4a744d7d4c1615"
},
"bridge_at_night": {
"category": "travel",
"moji": "🌉",
+ "description": "bridge at night",
"unicodeVersion": "6.0",
"digest": "9d3cda5a59e27e3c90939f1ddbe7e998b3ea4fcacfa1467dea0edf39613c2d7f"
},
"briefcase": {
"category": "people",
"moji": "💼",
+ "description": "briefcase",
"unicodeVersion": "6.0",
"digest": "9d00d6a92632aaadc71b017f448c883b27eb31a7554ebb51f7e3a9841f0f7f2b"
},
"broken_heart": {
"category": "symbols",
"moji": "💔",
+ "description": "broken heart",
"unicodeVersion": "6.0",
"digest": "c7ca53f444d72e596af46b61ffbc9e7c18a645020c22691e44f967db98dbf853"
},
"bug": {
"category": "nature",
"moji": "ðŸ›",
+ "description": "bug",
"unicodeVersion": "6.0",
"digest": "0dccb1d5eb91769377b4c5b310f007b60f54a5c48ba9e467b3a06898a4831b90"
},
"bulb": {
"category": "objects",
"moji": "💡",
+ "description": "electric light bulb",
"unicodeVersion": "6.0",
"digest": "ccdaa2dfde5a88a347035a94b9d4d86cfc335ce0a73292423f5788a4bd21a5a8"
},
"bullettrain_front": {
"category": "travel",
"moji": "🚅",
+ "description": "high-speed train with bullet nose",
"unicodeVersion": "6.0",
"digest": "5195a6a6d23f28e1aa5ebac6ede0f6c6a8b7ff33a9edf034814f227fe976177a"
},
"bullettrain_side": {
"category": "travel",
"moji": "🚄",
+ "description": "high-speed train",
"unicodeVersion": "6.0",
"digest": "96e74842e919716b7bbbab57339bfd70f099a9bcb4710dffd7c80cf38a7bbff7"
},
"burrito": {
"category": "food",
"moji": "🌯",
+ "description": "burrito",
"unicodeVersion": "8.0",
"digest": "b2cf81f1efdf87e674461f73f67cd4b58a5f695e65598d0dd3899f2597da43cf"
},
"bus": {
"category": "travel",
"moji": "🚌",
+ "description": "bus",
"unicodeVersion": "6.0",
"digest": "192850b762edad21ac8770df38b9cae6d2bc1697a838462f3e36066bfb4eee50"
},
"busstop": {
"category": "travel",
"moji": "ðŸš",
+ "description": "bus stop",
"unicodeVersion": "6.0",
"digest": "adabb1ec36402b33feb636eae3656e5a8b51ff1071bcb14125d8ab80d6d12d2a"
},
"bust_in_silhouette": {
"category": "people",
"moji": "👤",
+ "description": "bust in silhouette",
"unicodeVersion": "6.0",
"digest": "277ae43301f1e49e0be03c8e52f0dc7b70c67f9d146bca0a14172e0098f115e6"
},
"busts_in_silhouette": {
"category": "people",
"moji": "👥",
+ "description": "busts in silhouette",
"unicodeVersion": "6.0",
"digest": "7fee96f1b68bb2c6002e47f2ed13c06baa6a3168441b9aca572db7ec45612f7b"
},
"butterfly": {
"category": "nature",
"moji": "🦋",
+ "description": "butterfly",
"unicodeVersion": "9.0",
"digest": "a91b6598c17b44a8dc8935a1d99e25f4483ea41470cdd2da343039a9eec29ef1"
},
"cactus": {
"category": "nature",
"moji": "🌵",
+ "description": "cactus",
"unicodeVersion": "6.0",
"digest": "2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd"
},
"cake": {
"category": "food",
"moji": "ðŸ°",
+ "description": "shortcake",
"unicodeVersion": "6.0",
"digest": "b928902df8084210d51c1da36f9119164a325393c391b28cd8ea914e0b95c17b"
},
"calendar": {
"category": "objects",
"moji": "📆",
+ "description": "tear-off calendar",
"unicodeVersion": "6.0",
"digest": "9d990be27778daab041a3583edbd8f83fc8957e42a3aec729c0e2e224a8d05e3"
},
"calendar_spiral": {
"category": "objects",
"moji": "🗓",
+ "description": "spiral calendar pad",
"unicodeVersion": "7.0",
"digest": "441a0750eade7ce33e28e58bec76958990c412b68409fcdde59ebad1f25361bb"
},
"call_me": {
"category": "people",
"moji": "🤙",
+ "description": "call me hand",
"unicodeVersion": "9.0",
"digest": "83d2ed96dcb8b4adf4f4d030ffd07e25ca16351e1a4fbefdf9f46f5ca496a55f"
},
"call_me_tone1": {
"category": "people",
"moji": "🤙ðŸ»",
+ "description": "call me hand tone 1",
"unicodeVersion": "9.0",
"digest": "4a5748efa83e7294e8338b8795d4d315ff1cd31ead6759004d0eb330e50de8cd"
},
"call_me_tone2": {
"category": "people",
"moji": "🤙ðŸ¼",
+ "description": "call me hand tone 2",
"unicodeVersion": "9.0",
"digest": "54feaa6e3c5789ae6e15622127f0e0213234b4b886e1588ce95814348b1f1519"
},
"call_me_tone3": {
"category": "people",
"moji": "🤙ðŸ½",
+ "description": "call me hand tone 3",
"unicodeVersion": "9.0",
"digest": "57e949b951e14843b712dab5a828f915ee255f5bb973db33946aab4057427419"
},
"call_me_tone4": {
"category": "people",
"moji": "🤙ðŸ¾",
+ "description": "call me hand tone 4",
"unicodeVersion": "9.0",
"digest": "f7787e933978a09c7b8ab8d3b1e1ab395aaae998c455e93bb3db24a4c8a60fe0"
},
"call_me_tone5": {
"category": "people",
"moji": "🤙ðŸ¿",
+ "description": "call me hand tone 5",
"unicodeVersion": "9.0",
"digest": "1fdb7d833d000b117d20d48142d3026a61cc9c8b712ebb498fa66bf75c74d7a5"
},
"calling": {
"category": "objects",
"moji": "📲",
+ "description": "mobile phone with rightwards arrow at left",
"unicodeVersion": "6.0",
"digest": "acf668c75c11c36686005788266524a972fa1c5bcf666ff3403d909edc5cee91"
},
"camel": {
"category": "nature",
"moji": "ðŸ«",
+ "description": "bactrian camel",
"unicodeVersion": "6.0",
"digest": "5f927927a7ab1277d0dc8b8211436957968b1e11365a8bf535e9bb94f92c5631"
},
"camera": {
"category": "objects",
"moji": "📷",
+ "description": "camera",
"unicodeVersion": "6.0",
"digest": "fde03e396822a36cd6ae756ede885b945a074395264162731ca5db47a3b39d80"
},
"camera_with_flash": {
"category": "objects",
"moji": "📸",
+ "description": "camera with flash",
"unicodeVersion": "7.0",
"digest": "9afd380208187780f00244c45d4db6c5ea1ea088d4a1bd8fc92a8f3877149750"
},
"camping": {
"category": "travel",
"moji": "ðŸ•",
+ "description": "camping",
"unicodeVersion": "7.0",
"digest": "a42a4ff9521affa72db7b0f01da169b4cb6afb9db1c5dfad47dd4c507bfc30d9"
},
"cancer": {
"category": "symbols",
"moji": "♋",
+ "description": "cancer",
"unicodeVersion": "1.1",
"digest": "528c6f21df99a756b553d93a7f395b0f662b30a323affd05f0cedee8ff7b41d6"
},
"candle": {
"category": "objects",
"moji": "🕯",
+ "description": "candle",
"unicodeVersion": "7.0",
"digest": "211c04dc3a91b071c284d4180ed09f9d3320e3fd6ba8a9fddd0677bc97fd12cb"
},
"candy": {
"category": "food",
"moji": "ðŸ¬",
+ "description": "candy",
"unicodeVersion": "6.0",
"digest": "9cff4538918f60f770fceb96e964f5dc3ce31fd08ddd2ab3bfdf2981bfa74100"
},
"canoe": {
"category": "travel",
"moji": "🛶",
+ "description": "canoe",
"unicodeVersion": "9.0",
"digest": "56ca308cc2ad4827468cf58c4ccf6ef6b3382835a91e935540a2b973e01d2572"
},
"capital_abcd": {
"category": "symbols",
"moji": "🔠",
+ "description": "input symbol for latin capital letters",
"unicodeVersion": "6.0",
"digest": "a416d0b3f564037b680f801fb773b6eaf67225e2cbbfd2cb8a5db0de044321fa"
},
"capricorn": {
"category": "symbols",
"moji": "♑",
+ "description": "capricorn",
"unicodeVersion": "1.1",
"digest": "f11abad102603737b55486fe2ea4d01f28b203394bcd84f19a7948156e6c4b96"
},
"card_box": {
"category": "objects",
"moji": "🗃",
+ "description": "card file box",
"unicodeVersion": "7.0",
"digest": "7a6199d562f30e02ed31094de6aebeb99eae8ac156f6910463dfed73256f4c9a"
},
"card_index": {
"category": "objects",
"moji": "📇",
+ "description": "card index",
"unicodeVersion": "6.0",
"digest": "86e187e0a72ca5d00207d6ef34d66ce15046848a831c2b5184fb840c5332a2a8"
},
"carousel_horse": {
"category": "travel",
"moji": "🎠",
+ "description": "carousel horse",
"unicodeVersion": "6.0",
"digest": "c0e7059efc39a64233f774c02ddb1ab51888fff180f906ce13a6e4f9509672fe"
},
"carrot": {
"category": "food",
"moji": "🥕",
+ "description": "carrot",
"unicodeVersion": "9.0",
"digest": "3a6fd98b63ee73d982a9cdacb08cf7b4014368cde8ffce6056b7df25a5a472b1"
},
"cartwheel": {
"category": "activity",
"moji": "🤸",
+ "description": "person doing cartwheel",
"unicodeVersion": "9.0",
"digest": "d78de3435e0b04a9b1a1048ae12e63e3248f9ace3a0db4d3bda584f22af18863"
},
"cartwheel_tone1": {
"category": "activity",
"moji": "🤸ðŸ»",
+ "description": "person doing cartwheel tone 1",
"unicodeVersion": "9.0",
"digest": "39a49781a269bb40d8efc8fd73c973b00fb2e192850ea6073062b5dea0cd5b74"
},
"cartwheel_tone2": {
"category": "activity",
"moji": "🤸ðŸ¼",
+ "description": "person doing cartwheel tone 2",
"unicodeVersion": "9.0",
"digest": "6231eb35be45457fd648f8f4b79983f03705c9d983a18067f7e6d9ae47bc1958"
},
"cartwheel_tone3": {
"category": "activity",
"moji": "🤸ðŸ½",
+ "description": "person doing cartwheel tone 3",
"unicodeVersion": "9.0",
"digest": "ca483c78cc823811a8c279c501d9b283e4c990dafc5995ad40e68ecb0af554df"
},
"cartwheel_tone4": {
"category": "activity",
"moji": "🤸ðŸ¾,",
+ "description": "person doing cartwheel tone 4",
"unicodeVersion": "9.0",
"digest": "8253afb672431c84e498014c30babb00b9284bec773009e79f7f06aa7108643e"
},
"cartwheel_tone5": {
"category": "activity",
"moji": "🤸ðŸ¿",
+ "description": "person doing cartwheel tone 5",
"unicodeVersion": "9.0",
"digest": "6fd92baff57c38b3adb6753d9e7e547e762971a8872fd3f1e71c6aaf0b1d3ab9"
},
"cat": {
"category": "nature",
"moji": "ðŸ±",
+ "description": "cat face",
"unicodeVersion": "6.0",
"digest": "e52d0d3a205a0ba99094717e171a7f572b713a0e21b276ffa4a826596fe5cafc"
},
"cat2": {
"category": "nature",
"moji": "ðŸˆ",
+ "description": "cat",
"unicodeVersion": "6.0",
"digest": "46aa67a99f782935932c77b8de93287142297abe52928c173191cf55bb8f4339"
},
"cd": {
"category": "objects",
"moji": "💿",
+ "description": "optical disc",
"unicodeVersion": "6.0",
"digest": "16363d8a34b873c12df6354b99f575cae3d80e0d27100ed7eea70f0310953c7b"
},
"chains": {
"category": "objects",
"moji": "⛓",
+ "description": "chains",
"unicodeVersion": "5.2",
"digest": "3884cdbc6f2b433062af06f942552e563231c24727a2f10fa280b3bb7aa614e2"
},
"champagne": {
"category": "food",
"moji": "ðŸ¾",
+ "description": "bottle with popping cork",
"unicodeVersion": "8.0",
"digest": "9e6e8987f30a37ae0f3d7dab2f5eeb50aa32b4f31402b29315eb2994afc72457"
},
"champagne_glass": {
"category": "food",
"moji": "🥂",
+ "description": "clinking glasses",
"unicodeVersion": "9.0",
"digest": "5a2e4773f7eb126a00122cbfa4dc535da51ce00e0bf0d8d6ff8bab8b3365f8d2"
},
"chart": {
"category": "symbols",
"moji": "💹",
+ "description": "chart with upwards trend and yen sign",
"unicodeVersion": "6.0",
"digest": "a092dbc08f925b028286b2b495a5f59033b8537a586a694f46f4c1e7c3a1e27f"
},
"chart_with_downwards_trend": {
"category": "objects",
"moji": "📉",
+ "description": "chart with downwards trend",
"unicodeVersion": "6.0",
"digest": "5db7ccbc37665736a9c0b2f50247dcc09e404ec37f39db45b7b8b9464172a18c"
},
"chart_with_upwards_trend": {
"category": "objects",
"moji": "📈",
+ "description": "chart with upwards trend",
"unicodeVersion": "6.0",
"digest": "bc4ea250b102fe5c09847e471478aff065ad3df755d9717896d38d887d9c6733"
},
"checkered_flag": {
"category": "travel",
"moji": "ðŸ",
+ "description": "chequered flag",
"unicodeVersion": "6.0",
"digest": "0e77180e0cf9fc87e755a5a42cf23aec6bf30931db41331311e97ba0be178b78"
},
"cheese": {
"category": "food",
"moji": "🧀",
+ "description": "cheese wedge",
"unicodeVersion": "8.0",
"digest": "50a6cb906c2120e2bbc0e22105924262007cfe1554d7b02b8cc84b6adedc6a0b"
},
"cherries": {
"category": "food",
"moji": "ðŸ’",
+ "description": "cherries",
"unicodeVersion": "6.0",
"digest": "13b8db9e7e6eec8509aa80c762966e1bf3538fcb1ac3d6eab18ee4da1528cf84"
},
"cherry_blossom": {
"category": "nature",
"moji": "🌸",
+ "description": "cherry blossom",
"unicodeVersion": "6.0",
"digest": "af3083f5f8dd94936113f2e16caba5aec7a774d5589aa08bf5de82a2d278cc66"
},
"chestnut": {
"category": "nature",
"moji": "🌰",
+ "description": "chestnut",
"unicodeVersion": "6.0",
"digest": "9f85b79b207a69ab81ab88dcef04954000965b039b4cf57de5f1b381745ab98b"
},
"chicken": {
"category": "nature",
"moji": "ðŸ”",
+ "description": "chicken",
"unicodeVersion": "6.0",
"digest": "57ceb4459d183740009caac6ebed089d2f1e12f67c138e1be1d0f992313c0ac4"
},
"children_crossing": {
"category": "symbols",
"moji": "🚸",
+ "description": "children crossing",
"unicodeVersion": "6.0",
"digest": "0ded7d9aca0161e8ef8e2858c3c198e70e4badc7105ac3a6886e06975de19106"
},
"chipmunk": {
"category": "nature",
"moji": "ðŸ¿",
+ "description": "chipmunk",
"unicodeVersion": "7.0",
"digest": "5b0dc1a859163097727ba2ba5ffca38b0a54d925eebb089977d28d0b4d917a3f"
},
"chocolate_bar": {
"category": "food",
"moji": "ðŸ«",
+ "description": "chocolate bar",
"unicodeVersion": "6.0",
"digest": "dd273e5050488acaf885f8a18b6e2b3901f69c5b39fa6465fb60621783d4109a"
},
"christmas_tree": {
"category": "nature",
"moji": "🎄",
+ "description": "christmas tree",
"unicodeVersion": "6.0",
"digest": "ce60cbe2ebbe8057be8edea2392455fedd2bcda64a0a831f6a1942028af7e747"
},
"church": {
"category": "travel",
"moji": "⛪",
+ "description": "church",
"unicodeVersion": "5.2",
"digest": "2c328456528f7336e59443e20ec3ab22fe71f1fccb1dd50d0ad68eb206937557"
},
"cinema": {
"category": "symbols",
"moji": "🎦",
+ "description": "cinema",
"unicodeVersion": "6.0",
"digest": "4c26dcdc76f93dbc2a1dc49ed4e132b8e8f2b7cdc1acf5e09b3dfd99430d97cd"
},
"circus_tent": {
"category": "activity",
"moji": "🎪",
+ "description": "circus tent",
"unicodeVersion": "6.0",
"digest": "fec5f2a06222be8be549178b29720343cc00145177ec387ca4e6f3432481fe77"
},
"city_dusk": {
"category": "travel",
"moji": "🌆",
+ "description": "cityscape at dusk",
"unicodeVersion": "6.0",
"digest": "bba345e949dcc51f5f018220f000223797970c82ead2ab9c822f9dc0847aa155"
},
"city_sunset": {
"category": "travel",
"moji": "🌇",
+ "description": "sunset over buildings",
"unicodeVersion": "6.0",
"digest": "a846df1a4c7c778f8e1729804aece86eb29d2fcb95dc39eaaf2aae1897f3dcc7"
},
"cityscape": {
"category": "travel",
"moji": "ðŸ™",
+ "description": "cityscape",
"unicodeVersion": "7.0",
"digest": "ee360be7514c4bfb0d539dd28f3b2031ebcef04e850723ec0685fb54bd8e6d5f"
},
"cl": {
"category": "symbols",
"moji": "🆑",
+ "description": "squared cl",
"unicodeVersion": "6.0",
"digest": "fcec2855dbad9fda11d6e2802bc0dcaabab0b5be233508f5e439f156f07602c1"
},
"clap": {
"category": "people",
"moji": "ðŸ‘",
+ "description": "clapping hands sign",
"unicodeVersion": "6.0",
"digest": "a1860ce7812a9f6fb55e45761e1b79a2f8f0620eb04f80748a38420889d58a2a"
},
"clap_tone1": {
"category": "people",
"moji": "ðŸ‘ðŸ»",
+ "description": "clapping hands sign tone 1",
"unicodeVersion": "8.0",
"digest": "18a7022e08223fb2109af5a9b9a5b4f47dc870ce4453f4987d2d0b729ef54586"
},
"clap_tone2": {
"category": "people",
"moji": "ðŸ‘ðŸ¼",
+ "description": "clapping hands sign tone 2",
"unicodeVersion": "8.0",
"digest": "5954c8658b15e755d2018d8674df84d38e22ffededc4d726c6a33b709f71426a"
},
"clap_tone3": {
"category": "people",
"moji": "ðŸ‘ðŸ½",
+ "description": "clapping hands sign tone 3",
"unicodeVersion": "8.0",
"digest": "22639b6bd3c53784a2f855d6db7bdf31621519f19dfc29a6bc310eee6421f742"
},
"clap_tone4": {
"category": "people",
"moji": "ðŸ‘ðŸ¾",
+ "description": "clapping hands sign tone 4",
"unicodeVersion": "8.0",
"digest": "e55248dc163d1bbd118b50cd8767750ead86d082151febbc0a75b32d63abceec"
},
"clap_tone5": {
"category": "people",
"moji": "ðŸ‘ðŸ¿",
+ "description": "clapping hands sign tone 5",
"unicodeVersion": "8.0",
"digest": "76046b8157dabbe048a07fc318122456020c9c980fc1b8ab76802330e07b3b53"
},
"clapper": {
"category": "activity",
"moji": "🎬",
+ "description": "clapper board",
"unicodeVersion": "6.0",
"digest": "8149752a0e3e8abede2d433d1afab6d217877d0c76adb1e2845a0142c0cdcbaa"
},
"classical_building": {
"category": "travel",
"moji": "ðŸ›",
+ "description": "classical building",
"unicodeVersion": "7.0",
"digest": "9ee0d00c43d6e22b6a3ddea67619737270cc7e9294797a19c7c60d5f92aa44fa"
},
"clipboard": {
"category": "objects",
"moji": "📋",
+ "description": "clipboard",
"unicodeVersion": "6.0",
"digest": "bdd7f7d973c714e59d2903d401a876e6018794c7987c9ca57108c137c5edc25f"
},
"clock": {
"category": "objects",
"moji": "🕰",
+ "description": "mantlepiece clock",
"unicodeVersion": "7.0",
"digest": "302835eab2637db799acf69b3d795571ef3432251267050db0704f2954e8b190"
},
"clock1": {
"category": "symbols",
"moji": "ðŸ•",
+ "description": "clock face one oclock",
"unicodeVersion": "6.0",
"digest": "1778eec07ce061c9393e5abee5ca83b24e1ce61d8a75fa2e39efcb31aa160395"
},
"clock10": {
"category": "symbols",
"moji": "🕙",
+ "description": "clock face ten oclock",
"unicodeVersion": "6.0",
"digest": "601fc12ea5280a54c2e69dbb685f454e4165fe771756ed6f89016e29e683a24f"
},
"clock1030": {
"category": "symbols",
"moji": "🕥",
+ "description": "clock face ten-thirty",
"unicodeVersion": "6.0",
"digest": "4fd155f08f797542d52cff4b0aa3ca9f080f37a41c301b82f90ff6d4693c890e"
},
"clock11": {
"category": "symbols",
"moji": "🕚",
+ "description": "clock face eleven oclock",
"unicodeVersion": "6.0",
"digest": "5c79dc812e812e8a01993ea633b323d654ce3a7ea258692781a4896e4ad2017e"
},
"clock1130": {
"category": "symbols",
"moji": "🕦",
+ "description": "clock face eleven-thirty",
"unicodeVersion": "6.0",
"digest": "41497ee2020ee5ac9aa5f9b07560f7afca7c422b04214449cfc5cea9f020f52e"
},
"clock12": {
"category": "symbols",
"moji": "🕛",
+ "description": "clock face twelve oclock",
"unicodeVersion": "6.0",
"digest": "046bb7ffa5f5d27c2e3411ba543484d9dabb8ebf6d6e7a7e9bfb088c1813500c"
},
"clock1230": {
"category": "symbols",
"moji": "🕧",
+ "description": "clock face twelve-thirty",
"unicodeVersion": "6.0",
"digest": "bbfe9db5a2043aaba19a7a2a0185c7efcebf1e8c9263b8233f75b53c4825f0f4"
},
"clock130": {
"category": "symbols",
"moji": "🕜",
+ "description": "clock face one-thirty",
"unicodeVersion": "6.0",
"digest": "8662cb395ee680c2781123305c4c8ce8c0df9565c2c942668940be540cc0c094"
},
"clock2": {
"category": "symbols",
"moji": "🕑",
+ "description": "clock face two oclock",
"unicodeVersion": "6.0",
"digest": "42f7429748b612dce7de77221cbbc710655811f7bb23e2a986c36e6d662f0ec4"
},
"clock230": {
"category": "symbols",
"moji": "ðŸ•",
+ "description": "clock face two-thirty",
"unicodeVersion": "6.0",
"digest": "e710b6ef14227cd240ea3e2a867c8ef45b5c060adf3cb30ba9077c2351fe6677"
},
"clock3": {
"category": "symbols",
"moji": "🕒",
+ "description": "clock face three oclock",
"unicodeVersion": "6.0",
"digest": "7340d465b398a378211dff9ec806db579d061206fd6fc238623d070cfe0a55ce"
},
"clock330": {
"category": "symbols",
"moji": "🕞",
+ "description": "clock face three-thirty",
"unicodeVersion": "6.0",
"digest": "7aa4a15cc8de04ed3bdeb0f8a54a7915065f2809a07054e002d89926c9766831"
},
"clock4": {
"category": "symbols",
"moji": "🕓",
+ "description": "clock face four oclock",
"unicodeVersion": "6.0",
"digest": "36fd88e81ad488b0ec49a911a838693281573fa14736ae4a6dd1c40a4ff69bb1"
},
"clock430": {
"category": "symbols",
"moji": "🕟",
+ "description": "clock face four-thirty",
"unicodeVersion": "6.0",
"digest": "7bd5dd71e89d95dcf18b9e8c1fe2a353a7da3b69aadb8dda80ee9bafb05da58d"
},
"clock5": {
"category": "symbols",
"moji": "🕔",
+ "description": "clock face five oclock",
"unicodeVersion": "6.0",
"digest": "aa406409e56a0bfd8c850e44efe45fd190ffd7bf7061e934ed7928dfbdfc9eba"
},
"clock530": {
"category": "symbols",
"moji": "🕠",
+ "description": "clock face five-thirty",
"unicodeVersion": "6.0",
"digest": "25dd3bcc53ddd98eeea498d7dbd4c306ef39dd033f15909063388a0800febf41"
},
"clock6": {
"category": "symbols",
"moji": "🕕",
+ "description": "clock face six oclock",
"unicodeVersion": "6.0",
"digest": "0a321eaf1bc5db8436bbadac66c45ba257fc98ad4c7569ce3fc6602c824b6d7c"
},
"clock630": {
"category": "symbols",
"moji": "🕡",
+ "description": "clock face six-thirty",
"unicodeVersion": "6.0",
"digest": "55a4c5a665fdd38a724e9357a93c55401fcd5f1b13078c25754bd70c3fc4ccec"
},
"clock7": {
"category": "symbols",
"moji": "🕖",
+ "description": "clock face seven oclock",
"unicodeVersion": "6.0",
"digest": "6154306545716e865da0ec537ee4f22bfe6c7294502a64a2dcf425c587d0e2a2"
},
"clock730": {
"category": "symbols",
"moji": "🕢",
+ "description": "clock face seven-thirty",
"unicodeVersion": "6.0",
"digest": "6925654de642e50f84661f94364a96c87757d73fffe766aacbf4bbd70130547b"
},
"clock8": {
"category": "symbols",
"moji": "🕗",
+ "description": "clock face eight oclock",
"unicodeVersion": "6.0",
"digest": "9be2d189c7ea56d39fd259f84853d753c1cf33e64f8ed57f86f822d9ae23a1ee"
},
"clock830": {
"category": "symbols",
"moji": "🕣",
+ "description": "clock face eight-thirty",
"unicodeVersion": "6.0",
"digest": "16878613c0000d2f558c88d080551f424a8bd9df1358e0f931dd25c3da68f2d9"
},
"clock9": {
"category": "symbols",
"moji": "🕘",
+ "description": "clock face nine oclock",
"unicodeVersion": "6.0",
"digest": "1d1e7e3c9d085ffa5b7c0f3d9fd394b734f16ae3b60df09af50fe6c8d4f3c8bb"
},
"clock930": {
"category": "symbols",
"moji": "🕤",
+ "description": "clock face nine-thirty",
"unicodeVersion": "6.0",
"digest": "9fdef6a4939315c017b165e1dbac7710fb335df8c309be3fe2a011ef7fc28d74"
},
"closed_book": {
"category": "objects",
"moji": "📕",
+ "description": "closed book",
"unicodeVersion": "6.0",
"digest": "b18288629d201bfdfc5d66ec47df89809d00642b15732757e6a04789f36a7d9f"
},
"closed_lock_with_key": {
"category": "objects",
"moji": "ðŸ”",
+ "description": "closed lock with key",
"unicodeVersion": "6.0",
"digest": "e39adfe9b30973bca16472c2b7e6462b064a93b9d452aa48edd74c727641a83d"
},
"closed_umbrella": {
"category": "people",
"moji": "🌂",
+ "description": "closed umbrella",
"unicodeVersion": "6.0",
"digest": "2cc0592c74601f7439e88c3c1ec4f05e3459608ef1ea6558c5824ed7c3889727"
},
"cloud": {
"category": "nature",
"moji": "â˜",
+ "description": "cloud",
"unicodeVersion": "1.1",
"digest": "5b3a19718dfa8a381929665afdc2284464d24020c8dd0caff4dad465a1f536ba"
},
"cloud_lightning": {
"category": "nature",
"moji": "🌩",
+ "description": "cloud with lightning",
"unicodeVersion": "7.0",
"digest": "2b32f6d87726df2935ad81870879ccec30ce9b4fd5861d1a6317f9eca2f013d9"
},
"cloud_rain": {
"category": "nature",
"moji": "🌧",
+ "description": "cloud with rain",
"unicodeVersion": "7.0",
"digest": "1e1e8bc59e168e1d2e72bf11f2d43cb578cbf0a5f1daf383bba5c56fb750ee71"
},
"cloud_snow": {
"category": "nature",
"moji": "🌨",
+ "description": "cloud with snow",
"unicodeVersion": "7.0",
"digest": "2d364f859b83e684213e8eece1640208d80a8de0a49d0fc8e0e24c5a8493a3b1"
},
"cloud_tornado": {
"category": "nature",
"moji": "🌪",
+ "description": "cloud with tornado",
"unicodeVersion": "7.0",
"digest": "7cbed2343c280ba3996082b3d0fb9d8cd57d6e62fe6c9ecb159f46b4a2e49151"
},
"clown": {
"category": "people",
"moji": "🤡",
+ "description": "clown face",
"unicodeVersion": "9.0",
"digest": "eea95687caabc9e808514c2450ba599e5e24ef47923dbec86f5297a64438e2e5"
},
"clubs": {
"category": "symbols",
"moji": "♣",
+ "description": "black club suit",
"unicodeVersion": "1.1",
"digest": "b8cf72ecd8568ced077b475d94788fb282bdb06d25031b5d54dd63e25effb138"
},
"cocktail": {
"category": "food",
"moji": "ðŸ¸",
+ "description": "cocktail glass",
"unicodeVersion": "6.0",
"digest": "3792def2cde885cf32167f04904d3b0b788388e8af410c63e4cd31550feba775"
},
"coffee": {
"category": "food",
"moji": "☕",
+ "description": "hot beverage",
"unicodeVersion": "4.0",
"digest": "0d29615a7a67d3aafa257b909bb915dc74fa8f854acb0d9a2c29e94eedf80326"
},
"coffin": {
"category": "objects",
"moji": "âš°",
+ "description": "coffin",
"unicodeVersion": "4.1",
"digest": "78eccc1aad2a822649fba8503d4d30354bef367c4271193c40ddb692308f9db8"
},
"cold_sweat": {
"category": "people",
"moji": "😰",
+ "description": "face with open mouth and cold sweat",
"unicodeVersion": "6.0",
"digest": "f53aab523ed3fa2224a16881d263fb5e039f163380f92feb2c63c20f9b14dcd2"
},
"comet": {
"category": "nature",
"moji": "☄",
+ "description": "comet",
"unicodeVersion": "1.1",
"digest": "40ce93e55c6e57a88d80670b37171190bd5ffc87b7078891d8de5b15795385c5"
},
"compression": {
"category": "objects",
"moji": "🗜",
+ "description": "compression",
"unicodeVersion": "7.0",
"digest": "c8841f7afb5345f1c31da116a7fb41d07232ea58d3f7f1a75c5890aa1a80bfd6"
},
"computer": {
"category": "objects",
"moji": "💻",
+ "description": "personal computer",
"unicodeVersion": "6.0",
"digest": "c970ce76b5607434895b0407bdaa93140f887930781a17dd7dcf16f711451d93"
},
"confetti_ball": {
"category": "objects",
"moji": "🎊",
+ "description": "confetti ball",
"unicodeVersion": "6.0",
"digest": "a638b16f1acdbcf69edf760161b1bd7ff1fd5426c5b1203ad9d294dcc0701f10"
},
"confounded": {
"category": "people",
"moji": "😖",
+ "description": "confounded face",
"unicodeVersion": "6.0",
"digest": "e2ff3b4df65d00c1ca9ae0cb379f959ea2cecefb3d676d4f8c2c5f2c103da4f6"
},
"confused": {
"category": "people",
"moji": "😕",
+ "description": "confused face",
"unicodeVersion": "6.1",
"digest": "118d7f830ec08a3ac4b798eebb77a989b8c142f2588727181be4a2548e3c4f06"
},
"congratulations": {
"category": "symbols",
"moji": "㊗",
+ "description": "circled ideograph congratulation",
"unicodeVersion": "1.1",
"digest": "02fd1338c54fe5f9a0fd861f23c56edc1d39bcd3140b68f0f626f9e2494d2d1c"
},
"construction": {
"category": "travel",
"moji": "🚧",
+ "description": "construction sign",
"unicodeVersion": "6.0",
"digest": "c3a0401331111b9eda1206bee5f322db80b0870547d307b10dcac1314e4078c8"
},
"construction_site": {
"category": "travel",
"moji": "ðŸ—",
+ "description": "building construction",
"unicodeVersion": "7.0",
"digest": "c611f0a5de10f000a0756935f226845c7292f19ff5581d1f7a7554316338bbcb"
},
"construction_worker": {
"category": "people",
"moji": "👷",
+ "description": "construction worker",
"unicodeVersion": "6.0",
"digest": "8c094733987e7c4da8d3aa4588b530ae07042bd70cf337b1fd412a70ee8f0ed6"
},
"construction_worker_tone1": {
"category": "people",
"moji": "👷ðŸ»",
+ "description": "construction worker tone 1",
"unicodeVersion": "8.0",
"digest": "fcd927405fef4486105cd3aff62155467d21cebbc013924d4b52b717b566602b"
},
"construction_worker_tone2": {
"category": "people",
"moji": "👷ðŸ¼",
+ "description": "construction worker tone 2",
"unicodeVersion": "8.0",
"digest": "d1ec773828936c703dd6e334e696dc3cf7c34c0a8ec691564a384b735cdeaaba"
},
"construction_worker_tone3": {
"category": "people",
"moji": "👷ðŸ½",
+ "description": "construction worker tone 3",
"unicodeVersion": "8.0",
"digest": "37c114d6879b9b32b800b0d4cf770dcbe04d1455698130ecd709a0cb9dea880b"
},
"construction_worker_tone4": {
"category": "people",
"moji": "👷ðŸ¾",
+ "description": "construction worker tone 4",
"unicodeVersion": "8.0",
"digest": "5264996c1bedb6061a0dfdddce233d863bf308d27127ad152b63bfd983162cf7"
},
"construction_worker_tone5": {
"category": "people",
"moji": "👷ðŸ¿",
+ "description": "construction worker tone 5",
"unicodeVersion": "8.0",
"digest": "87051aec81fd5dfd4dc44ff0411a528ee08253e9494d37efa550694e28dde6d3"
},
"control_knobs": {
"category": "objects",
"moji": "🎛",
+ "description": "control knobs",
"unicodeVersion": "7.0",
"digest": "0d7f33ff7acc1cc3a81e6a786ff007df20da145e3070f338505dfed5100e9fcb"
},
"convenience_store": {
"category": "travel",
"moji": "ðŸª",
+ "description": "convenience store",
"unicodeVersion": "6.0",
"digest": "975dcf9b8e9e3fb1e29574b41300b9d96fd64703b3c18ff52f9f1875d1cf1b52"
},
"cookie": {
"category": "food",
"moji": "ðŸª",
+ "description": "cookie",
"unicodeVersion": "6.0",
"digest": "4bed3522bd50091ac5b68ca760661eb484d7f1b9c9d564d2097bd812b7f28ae4"
},
"cooking": {
"category": "food",
"moji": "ðŸ³",
+ "description": "cooking",
"unicodeVersion": "6.0",
"digest": "563ffd6cae381ce1e318cdacc54e70040d6a01a50d0db8aeb50edbbe413eac58"
},
"cool": {
"category": "symbols",
"moji": "🆒",
+ "description": "squared cool",
"unicodeVersion": "6.0",
"digest": "5739a37341c782a4736adfce804e12776ae33081098a3d052d8ae9a64b4d22d1"
},
"cop": {
"category": "people",
"moji": "👮",
+ "description": "police officer",
"unicodeVersion": "6.0",
"digest": "78996521bbe231d03ebea355226d8a1515f47cde7b2fbeca1037e7b7e5133466"
},
"cop_tone1": {
"category": "people",
"moji": "👮ðŸ»",
+ "description": "police officer tone 1",
"unicodeVersion": "8.0",
"digest": "8a38cd107f5f4c0b821ac43f32df5dc57facaf39fbafb98483ec00fd7df41baf"
},
"cop_tone2": {
"category": "people",
"moji": "👮ðŸ¼",
+ "description": "police officer tone 2",
"unicodeVersion": "8.0",
"digest": "8ab8ab086f3ff82aa4bf4760c3c822846ec2696c41d21dffdac12d5afbe398b7"
},
"cop_tone3": {
"category": "people",
"moji": "👮ðŸ½",
+ "description": "police officer tone 3",
"unicodeVersion": "8.0",
"digest": "fce710a99fd44a7c8af3ea01b2007e46d3ff38d7a0dff1ef26d6f893ede7e6d2"
},
"cop_tone4": {
"category": "people",
"moji": "👮ðŸ¾",
+ "description": "police officer tone 4",
"unicodeVersion": "8.0",
"digest": "3017dd73ef475379911c5e6c79bd0f9f533dbbc5057bce6a11244faa12996ba0"
},
"cop_tone5": {
"category": "people",
"moji": "👮ðŸ¿",
+ "description": "police officer tone 5",
"unicodeVersion": "8.0",
"digest": "a3b8807b3f2a8d6ee9bcec0339355bda486e8c930f727139f5447a4b046a6307"
},
"copyright": {
"category": "symbols",
"moji": "©",
+ "description": "copyright sign",
"unicodeVersion": "1.1",
"digest": "cc28663cdd3f8333d9bb57b511348cde4e51bda19cf0629dccb05c8fc425e079"
},
"corn": {
"category": "food",
"moji": "🌽",
+ "description": "ear of maize",
"unicodeVersion": "6.0",
"digest": "a099a0b291fa758690e6ee6c762b9ade9a0e3350a707c52d968dfffbcc467de5"
},
"couch": {
"category": "objects",
"moji": "🛋",
+ "description": "couch and lamp",
"unicodeVersion": "7.0",
"digest": "84cd734dbaa7f9f519438036d687e7a53217130779bc3de30258f163521b9474"
},
"couple": {
"category": "people",
"moji": "👫",
+ "description": "man and woman holding hands",
"unicodeVersion": "6.0",
"digest": "c897ba76e24e2f43a4aa261c2754800a8473f43c7ce53f9909a6af2c4897732a"
},
"couple_mm": {
"category": "people",
"moji": "👨â€â¤ï¸â€ðŸ‘¨",
+ "description": "couple (man,man)",
"unicodeVersion": "6.0",
"digest": "c812471d35d46e12270653039a907d1dfa2dea0defd65596283e5b8e03cea803"
},
"couple_with_heart": {
"category": "people",
"moji": "💑",
+ "description": "couple with heart",
"unicodeVersion": "6.0",
"digest": "420bfa81bad10365550c77a98e1c07eb00d03663fe7b610fab1aca8a0a9d201b"
},
"couple_ww": {
"category": "people",
"moji": "👩â€â¤ï¸â€ðŸ‘©",
+ "description": "couple (woman,woman)",
"unicodeVersion": "6.0",
"digest": "7ac49153a612d63302299eee996308b7dcafa0a152473dab679215036fe6567e"
},
"couplekiss": {
"category": "people",
"moji": "ðŸ’",
+ "description": "kiss",
"unicodeVersion": "6.0",
"digest": "1acfef9d375c4c1deb235babd856b0f90ad4f3194751694cb6abb44f00f29e42"
},
"cow": {
"category": "nature",
"moji": "ðŸ®",
+ "description": "cow face",
"unicodeVersion": "6.0",
"digest": "d71c854ff8b343ee24b8c2b9d56c7cb3fc6fa1a6dc0d7a137841b9f646e6d71b"
},
"cow2": {
"category": "nature",
"moji": "ðŸ„",
+ "description": "cow",
"unicodeVersion": "6.0",
"digest": "e7a5131d7dee0f3356814b0ac1ea8ff280b12a7b580181e20ddb0b7eeb7e7339"
},
"cowboy": {
"category": "people",
"moji": "🤠",
+ "description": "face with cowboy hat",
"unicodeVersion": "9.0",
"digest": "1aabf23f6b95a9b772fdb8eb45b8ec93584a5357f9131c6eabc9d1b83fe67e89"
},
"crab": {
"category": "nature",
"moji": "🦀",
+ "description": "crab",
"unicodeVersion": "8.0",
"digest": "e6be16699fdb5d87f42f28f6cc141a44b7ffd834ecdd536813c4b5b86d3fc4a5"
},
"crayon": {
"category": "objects",
"moji": "ðŸ–",
+ "description": "lower left crayon",
"unicodeVersion": "7.0",
"digest": "b180d6afa4777861222a4228164ce284230fe90c589f52ffa9351bac777e901a"
},
"credit_card": {
"category": "objects",
"moji": "💳",
+ "description": "credit card",
"unicodeVersion": "6.0",
"digest": "808cd120fd3738eb2be1f6c6c029d98387b0e03fca7d1451e8fbf9c5ab3f643f"
},
"crescent_moon": {
"category": "nature",
"moji": "🌙",
+ "description": "crescent moon",
"unicodeVersion": "6.0",
"digest": "042e7e01e6e88b97a763b7cc41e2a2b3fe68a649bacf4a090cd28fc653baf640"
},
"cricket": {
"category": "activity",
"moji": "ðŸ",
+ "description": "cricket bat and ball",
"unicodeVersion": "8.0",
"digest": "4c4559d0b4efe24cc248fa57f413541307992e519d0cb9fb8828637ac2f4cc16"
},
"crocodile": {
"category": "nature",
"moji": "ðŸŠ",
+ "description": "crocodile",
"unicodeVersion": "6.0",
"digest": "59cb4164c50b6bc9ae311ce6f7610467c1aaafa848b5fff7614f064715f91992"
},
"croissant": {
"category": "food",
"moji": "ðŸ¥",
+ "description": "croissant",
"unicodeVersion": "9.0",
"digest": "b751e287157a1e276617a841a5b5f7f1208ca226cfd8fa947f144390b65a5e16"
},
"cross": {
"category": "symbols",
"moji": "âœ",
+ "description": "latin cross",
"unicodeVersion": "1.1",
"digest": "a6b07c838fb75ef2ebefa2df6005e8d784753239ec03c37695a13e3b1954d653"
},
"crossed_flags": {
"category": "objects",
"moji": "🎌",
+ "description": "crossed flags",
"unicodeVersion": "6.0",
"digest": "2841c671075e6f1a79c61c2d716423159fb0bc0786e3fb0049697766533bf262"
},
"crossed_swords": {
"category": "objects",
"moji": "âš”",
+ "description": "crossed swords",
"unicodeVersion": "4.1",
"digest": "3771a5b26b514236521ce44e15f7730fa9148c6a782b9b600ab870a1f7de6f9f"
},
"crown": {
"category": "people",
"moji": "👑",
+ "description": "crown",
"unicodeVersion": "6.0",
"digest": "6741e58d8f823194e0a3484ac1563e20d9e0b44c1bc46d82444dfffa092cdfc7"
},
"cruise_ship": {
"category": "travel",
"moji": "🛳",
+ "description": "passenger ship",
"unicodeVersion": "7.0",
"digest": "2b7b62db5d118a632673564099e3405ea6d61ea9b8e123b5a2aaf011bb2a54a4"
},
"cry": {
"category": "people",
"moji": "😢",
+ "description": "crying face",
"unicodeVersion": "6.0",
"digest": "fc3307ec4fe75539770c1123a0e8e721d9e021009a502655132f68d7cc453816"
},
"crying_cat_face": {
"category": "people",
"moji": "😿",
+ "description": "crying cat face",
"unicodeVersion": "6.0",
"digest": "4942c24935c22babdcb8af41d2c0a7588356b6b674bc238902e2f10ad03e2c5b"
},
"crystal_ball": {
"category": "objects",
"moji": "🔮",
+ "description": "crystal ball",
"unicodeVersion": "6.0",
"digest": "05f73b30b1e5b0fc66fb5dc6caddd2d547ee7b9d2f97513dc908ba1a2e352e30"
},
"cucumber": {
"category": "food",
"moji": "🥒",
+ "description": "cucumber",
"unicodeVersion": "9.0",
"digest": "d1196e23f2f155ef5c1330f8497f40957a7357cb177127f457c5c471f0a23727"
},
"cupid": {
"category": "symbols",
"moji": "💘",
+ "description": "heart with arrow",
"unicodeVersion": "6.0",
"digest": "246e71f44c6ebc2e4f887e25438e4f894e8cc92e06069e711b893ff391abb658"
},
"curly_loop": {
"category": "symbols",
"moji": "âž°",
+ "description": "curly loop",
"unicodeVersion": "6.0",
"digest": "9e4eb98d6597888f91208080c6a79824adb432ea34f46c85da26cb630bd1cc73"
},
"currency_exchange": {
"category": "symbols",
"moji": "💱",
+ "description": "currency exchange",
"unicodeVersion": "6.0",
"digest": "b85377265b9876888969aa42b65bba0be523a370175baf226f20131e535af554"
},
"curry": {
"category": "food",
"moji": "ðŸ›",
+ "description": "curry and rice",
"unicodeVersion": "6.0",
"digest": "a01c0a713662817720b485f7739f57e61afc025f5c43792f4de961c94f92f31e"
},
"custard": {
"category": "food",
"moji": "ðŸ®",
+ "description": "custard",
"unicodeVersion": "6.0",
"digest": "85c2b9ac904134a6c3587eb0a0806f2ab4282c5ed5c79d41734f3203998f757e"
},
"customs": {
"category": "symbols",
"moji": "🛃",
+ "description": "customs",
"unicodeVersion": "6.0",
"digest": "eb2546e1e617d4c1a1f614318af5e5dacf3e8d9479ffa08108977defa83ded32"
},
"cyclone": {
"category": "symbols",
"moji": "🌀",
+ "description": "cyclone",
"unicodeVersion": "6.0",
"digest": "7a0f8564d76adf2d0ed272f56dc0d01fb7b557852e0ca797e73f5472b8630bf3"
},
"dagger": {
"category": "objects",
"moji": "🗡",
+ "description": "dagger knife",
"unicodeVersion": "7.0",
"digest": "35a179168198d03295e626cc27d3b92d30a732c55a2ca75d7a11a0fbed414772"
},
"dancer": {
"category": "people",
"moji": "💃",
+ "description": "dancer",
"unicodeVersion": "6.0",
"digest": "66ffa86827e85acae4aa870c0859fe3a9dad03d21ff4bc800b61c95c902a8a90"
},
"dancer_tone1": {
"category": "people",
"moji": "💃ðŸ»",
+ "description": "dancer tone 1",
"unicodeVersion": "8.0",
"digest": "bdbee740addc890e369d3469a3585eb0d1e4fbc7e04dd6f6aca762d8aeee6a8c"
},
"dancer_tone2": {
"category": "people",
"moji": "💃ðŸ¼",
+ "description": "dancer tone 2",
"unicodeVersion": "8.0",
"digest": "9f7b4c627241eaa2def9717a5286a423f0b9c1b044dd9ea4442a76f1858d14a4"
},
"dancer_tone3": {
"category": "people",
"moji": "💃ðŸ½",
+ "description": "dancer tone 3",
"unicodeVersion": "8.0",
"digest": "a6bd49a377ce6c2004bf126b6f66d0b21d8c14103c2add7b10f12ed9e1c2d302"
},
"dancer_tone4": {
"category": "people",
"moji": "💃ðŸ¾",
+ "description": "dancer tone 4",
"unicodeVersion": "8.0",
"digest": "4ec2a7629c01b0e9006b5cda4deae3bf297ce3b71d18063f93eeb5c14be19a1a"
},
"dancer_tone5": {
"category": "people",
"moji": "💃ðŸ¿",
+ "description": "dancer tone 5",
"unicodeVersion": "8.0",
"digest": "2b48e3a6b366c6f55f73b816e6fb03c39e9890f586f7e9c9043cf0c013d9cdd5"
},
"dancers": {
"category": "people",
"moji": "👯",
+ "description": "woman with bunny ears",
"unicodeVersion": "6.0",
"digest": "12be66ed19d232bb387270f40bece68bd0cb2342b318f6c9bb8b49c64ff7d0ad"
},
"dango": {
"category": "food",
"moji": "ðŸ¡",
+ "description": "dango",
"unicodeVersion": "6.0",
"digest": "34e8cd153c50f2d725abe8934c35c96a3ab533f0cc5fbb1e1474eafad1dc1fc2"
},
"dark_sunglasses": {
"category": "people",
"moji": "🕶",
+ "description": "dark sunglasses",
"unicodeVersion": "7.0",
"digest": "d0a735ad5bf0ece00af2a21abf950b89292ebd8ca6e28b1dbb1368252fb44afe"
},
"dart": {
"category": "activity",
"moji": "🎯",
+ "description": "direct hit",
"unicodeVersion": "6.0",
"digest": "998642f06a875905e0a6bf30963c025baff1cf55b8e76884b9119f2d71188b0c"
},
"dash": {
"category": "nature",
"moji": "💨",
+ "description": "dash symbol",
"unicodeVersion": "6.0",
"digest": "f7aae7d3887c67d76f3329c2dc9e6807dc580a4b07ab35599c7805e41823a345"
},
"date": {
"category": "objects",
"moji": "📅",
+ "description": "calendar",
"unicodeVersion": "6.0",
"digest": "d0b695e4a7cfbbe71b4fbebf345b66ca98f0cf1c751362928e54c23ca78d4c7b"
},
"deciduous_tree": {
"category": "nature",
"moji": "🌳",
+ "description": "deciduous tree",
"unicodeVersion": "6.0",
"digest": "3c70f1a77f2754f41c830e88d43b7d53c14311d64626ded164aa9ac7d2695790"
},
"deer": {
"category": "nature",
"moji": "🦌",
+ "description": "deer",
"unicodeVersion": "9.0",
"digest": "7f4302ca68fd121ee73be48d0a0a0fb9e7e2741071a491ad2b7b0eab9f11ad25"
},
"department_store": {
"category": "travel",
"moji": "ðŸ¬",
+ "description": "department store",
"unicodeVersion": "6.0",
"digest": "4be910d2efe74d8ce2c1f41d7753c8873579faca83fcf779a4887d8ab9e5923b"
},
"desert": {
"category": "travel",
"moji": "ðŸœ",
+ "description": "desert",
"unicodeVersion": "7.0",
"digest": "d4b1a11c5130debe042df6cc2b3389f15c68a5cb32dc1b3a82b78f733d0c9e4e"
},
"desktop": {
"category": "objects",
"moji": "🖥",
+ "description": "desktop computer",
"unicodeVersion": "7.0",
"digest": "cde5bfb6c71bb7d663808a3561b24cb5b5560f95f510b40f81250cac1b21933e"
},
"diamond_shape_with_a_dot_inside": {
"category": "symbols",
"moji": "💠",
+ "description": "diamond shape with a dot inside",
"unicodeVersion": "6.0",
"digest": "e91323577ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3"
},
"diamonds": {
"category": "symbols",
"moji": "♦",
+ "description": "black diamond suit",
"unicodeVersion": "1.1",
"digest": "bf3d9a020afe8aa226db73590bc193a9c2c3e6e642edd2445c5960c3e67cf153"
},
"disappointed": {
"category": "people",
"moji": "😞",
+ "description": "disappointed face",
"unicodeVersion": "6.0",
"digest": "c0f406c6beea0fd1328adefc097d04aa16b72f7a5afa0867967d8ea25d72db17"
},
"disappointed_relieved": {
"category": "people",
"moji": "😥",
+ "description": "disappointed but relieved face",
"unicodeVersion": "6.0",
"digest": "c826f5dd4f2f7e5289d720851d4826ab8284d915606c1b152ab229b7fadbba14"
},
"dividers": {
"category": "objects",
"moji": "🗂",
+ "description": "card index dividers",
"unicodeVersion": "7.0",
"digest": "4b2c653b18cf0fa31f1f0ac94a6fbd214ea0d1b0a90a450ab6e169906fc5764f"
},
"dizzy": {
"category": "nature",
"moji": "💫",
+ "description": "dizzy symbol",
"unicodeVersion": "6.0",
"digest": "d577545c2de42389695447c6ebbfef895f30f0fda84eef45684f9bf4a9c27ff1"
},
"dizzy_face": {
"category": "people",
"moji": "😵",
+ "description": "dizzy face",
"unicodeVersion": "6.0",
"digest": "7b3aeaffb4e15ccf633b91dda4a44847a1eb28d78ce58b4d171b20a771bde414"
},
"do_not_litter": {
"category": "symbols",
"moji": "🚯",
+ "description": "do not litter symbol",
"unicodeVersion": "6.0",
"digest": "98b07fbbcdb438d1b8a755869fa2de8e180a77fce359ec830eb46d38ec3e67cb"
},
"dog": {
"category": "nature",
"moji": "ðŸ¶",
+ "description": "dog face",
"unicodeVersion": "6.0",
"digest": "3b31ce067b13e463284ce85536512cb1f8cd8b52fe73659f69971d0d6c1dfc11"
},
"dog2": {
"category": "nature",
"moji": "ðŸ•",
+ "description": "dog",
"unicodeVersion": "6.0",
"digest": "0a8901bce5ed994533ff84299b2a1364de28d872c9f9510d3426a83e8a9d2e34"
},
"dollar": {
"category": "objects",
"moji": "💵",
+ "description": "banknote with dollar sign",
"unicodeVersion": "6.0",
"digest": "52438e38867aedc021740bb41f9ba336e75a50faa148419412a01d75d8c93155"
},
"dolls": {
"category": "objects",
"moji": "🎎",
+ "description": "japanese dolls",
"unicodeVersion": "6.0",
"digest": "a687184e9a0915deef44bb3cacfb19d3f3f19cf2c110f1da90191dd567333c57"
},
"dolphin": {
"category": "nature",
"moji": "ðŸ¬",
+ "description": "dolphin",
"unicodeVersion": "6.0",
"digest": "0b7ee08f4236232ca533ed3a3023d28020d36f178efaec5ce8b0e13a84778512"
},
"door": {
"category": "objects",
"moji": "🚪",
+ "description": "door",
"unicodeVersion": "6.0",
"digest": "984a9ca88852ebdb539e0c385d9c6ffe5010e9189bc372a3d00f5c8d44c8e6f5"
},
"doughnut": {
"category": "food",
"moji": "ðŸ©",
+ "description": "doughnut",
"unicodeVersion": "6.0",
"digest": "27634587e6a53807baa32157bb06b0e115c8ad8aefebba7ebb0b65a084170e3a"
},
"dove": {
"category": "nature",
"moji": "🕊",
+ "description": "dove of peace",
"unicodeVersion": "7.0",
"digest": "7c665f8594ffa53e72b01647e9d27360fb87d52d02fe9f20fc5fda08f9797dc3"
},
"dragon": {
"category": "nature",
"moji": "ðŸ‰",
+ "description": "dragon",
"unicodeVersion": "6.0",
"digest": "2abcb3d945d848e34ffc76203b29ef26df7458856166fffd155611f7bbe72652"
},
"dragon_face": {
"category": "nature",
"moji": "ðŸ²",
+ "description": "dragon face",
"unicodeVersion": "6.0",
"digest": "0030548931b931e3b51f26cf660394aee36499e688ba83ce9cfccb635dcd4d54"
},
"dress": {
"category": "people",
"moji": "👗",
+ "description": "dress",
"unicodeVersion": "6.0",
"digest": "96ceba928fb356f7c0ae99bf22552321f08a65d5f1c0340ab89641219ad366ad"
},
"dromedary_camel": {
"category": "nature",
"moji": "ðŸª",
+ "description": "dromedary camel",
"unicodeVersion": "6.0",
"digest": "e06ef69c29f0fb12481727c0b4124e700572d3d7955e173279320f43f286518d"
},
"drooling_face": {
"category": "people",
"moji": "🤤",
+ "description": "drooling face",
"unicodeVersion": "9.0",
"digest": "5203cb05cd266d7a7c929ab40364ad68571d380d9c7ff93a8d6d55261abaa1ba"
},
"droplet": {
"category": "nature",
"moji": "💧",
+ "description": "droplet",
"unicodeVersion": "6.0",
"digest": "6475b4a4460a672c436a68f282ac97fb31e2934db4b80620063ee816159aa7c3"
},
"drum": {
"category": "activity",
"moji": "ðŸ¥",
+ "description": "drum with drumsticks",
"unicodeVersion": "9.0",
"digest": "0d0639980b1a5dcbf1c3e7ef47263fb6543b871242c58452a8c2f642525d9dd8"
},
"duck": {
"category": "nature",
"moji": "🦆",
+ "description": "duck",
"unicodeVersion": "9.0",
"digest": "8f8373798a7727368b32328e7a9a349727a949e7391ddd243b6456141a4f7e94"
},
"dvd": {
"category": "objects",
"moji": "📀",
+ "description": "dvd",
"unicodeVersion": "6.0",
"digest": "3b7903285d91277181c26fdc9df857761bbac509d352e320c2519ea3b132704f"
},
"e-mail": {
"category": "objects",
"moji": "📧",
+ "description": "e-mail symbol",
"unicodeVersion": "6.0",
"digest": "39b5a57a2376e4a1137e381be02a1775bd580e0371438f5297a401ea634f1830"
},
"eagle": {
"category": "nature",
"moji": "🦅",
+ "description": "eagle",
"unicodeVersion": "9.0",
"digest": "b44fd4f61b83c5114358a272343ac9b0eabbc70847f739bbdbf8aae3ade5bc1d"
},
"ear": {
"category": "people",
"moji": "👂",
+ "description": "ear",
"unicodeVersion": "6.0",
"digest": "4fdeb5a46e69311ecfd09c5b45c9018c24b625e28475cca8fa516b086ef952f8"
},
"ear_of_rice": {
"category": "nature",
"moji": "🌾",
+ "description": "ear of rice",
"unicodeVersion": "6.0",
"digest": "2997c340c2b333d6ba9b73f94ff1a1881735fe0cc4f0c72d7719b305499fc425"
},
"ear_tone1": {
"category": "people",
"moji": "👂ðŸ»",
+ "description": "ear tone 1",
"unicodeVersion": "8.0",
"digest": "5ca759b8569a377a4e63e30d94b585b9f76d15348a8a0c1ba19fdc522790615e"
},
"ear_tone2": {
"category": "people",
"moji": "👂ðŸ¼",
+ "description": "ear tone 2",
"unicodeVersion": "8.0",
"digest": "12aafb3ef2cfcdc892b2877c2e24920620f0f77f850e12afbfe55eadce9e37df"
},
"ear_tone3": {
"category": "people",
"moji": "👂ðŸ½",
+ "description": "ear tone 3",
"unicodeVersion": "8.0",
"digest": "f4d28d9f72cf116ac92d80061eb84c918d6523bf53b2ad526f5457aba487d527"
},
"ear_tone4": {
"category": "people",
"moji": "👂ðŸ¾",
+ "description": "ear tone 4",
"unicodeVersion": "8.0",
"digest": "eaa9453670f7e3adc6ec6934ee70efc9bf60fe6c99c5804b7ba9e3804aec65de"
},
"ear_tone5": {
"category": "people",
"moji": "👂ðŸ¿",
+ "description": "ear tone 5",
"unicodeVersion": "8.0",
"digest": "54bd0782419489556b80e9e0d15b05df74757aa4e04ba565f45c20d3dd60e3f1"
},
"earth_africa": {
"category": "nature",
"moji": "ðŸŒ",
+ "description": "earth globe europe-africa",
"unicodeVersion": "6.0",
"digest": "c691a6f591f5a07b268fd64efe113e81cec8d5963ad83ced2537422343ff7ecf"
},
"earth_americas": {
"category": "nature",
"moji": "🌎",
+ "description": "earth globe americas",
"unicodeVersion": "6.0",
"digest": "a9c60cf8341ff59a9cc1a715b7144af734fcd28915a8e003a31ebf2abf9aedb1"
},
"earth_asia": {
"category": "nature",
"moji": "ðŸŒ",
+ "description": "earth globe asia-australia",
"unicodeVersion": "6.0",
"digest": "ee2beb61fb8c87279161c5a8c4ad17bb71ce790123f8fa33522941d027e060a5"
},
"egg": {
"category": "food",
"moji": "🥚",
+ "description": "egg",
"unicodeVersion": "9.0",
"digest": "72b9c841af784e7cbccbbe48ba833df5cecdd284397c199cab079872e879d92f"
},
"eggplant": {
"category": "food",
"moji": "ðŸ†",
+ "description": "aubergine",
"unicodeVersion": "6.0",
"digest": "ec0a460e0cf0e615f51279677594a899672e1b4ecd9396e17a8cfa2a3efe5238"
},
"eight": {
"category": "symbols",
"moji": "8ï¸âƒ£",
+ "description": "keycap digit eight",
"unicodeVersion": "3.0",
"digest": "57ff905033a32747690adba6486d12b09eb4d45de556f4e1ab6fb04e1fb861a8"
},
"eight_pointed_black_star": {
"category": "symbols",
"moji": "✴",
+ "description": "eight pointed black star",
"unicodeVersion": "1.1",
"digest": "7bf11f6e28591e3d0625296aaabf4ecb75c982e425abf3049339e93494acc17e"
},
"eight_spoked_asterisk": {
"category": "symbols",
"moji": "✳",
+ "description": "eight spoked asterisk",
"unicodeVersion": "1.1",
"digest": "bb0758e7cc0e357285937671a91489bd32ce9d248eecdcc9c275a53a66325b26"
},
"eject": {
"category": "symbols",
"moji": "â",
+ "description": "eject symbol",
"unicodeVersion": "4.0",
"digest": "eeb0cd23ead0c965e307de517a6805265f0c780c3e454e64bc4c1425dfe7548e"
},
"electric_plug": {
"category": "objects",
"moji": "🔌",
+ "description": "electric plug",
"unicodeVersion": "6.0",
"digest": "b10ce87af86fa4f4022572ceb5ecd73bea867347a86832a7ea248364b0aad8d0"
},
"elephant": {
"category": "nature",
"moji": "ðŸ˜",
+ "description": "elephant",
"unicodeVersion": "6.0",
"digest": "b7750f4b013fbd28ac5330e1694ef4d3b4a9c6fc7b807879db0c24b035a16c29"
},
"end": {
"category": "symbols",
"moji": "🔚",
+ "description": "end with leftwards arrow above",
"unicodeVersion": "6.0",
"digest": "dd93aee6986eb637a8b58f234da47568b88525599f73246e322af030351997a2"
},
"envelope": {
"category": "objects",
"moji": "✉",
+ "description": "envelope",
"unicodeVersion": "1.1",
"digest": "f5a512022a2f5280f372ff39c22cbda815f698710ca66f8f8c4d08418f98ca78"
},
"envelope_with_arrow": {
"category": "objects",
"moji": "📩",
+ "description": "envelope with downwards arrow above",
"unicodeVersion": "6.0",
"digest": "f8643212e6a94f58ccf2bcedc54c5fda8ebeab274f4a8803f253de5f50ddb1d6"
},
"euro": {
"category": "objects",
"moji": "💶",
+ "description": "banknote with euro sign",
"unicodeVersion": "6.0",
"digest": "3af3e223e8f26468a94f6f5c17198432656e8d20b3bab31566c2b5a86e717df4"
},
"european_castle": {
"category": "travel",
"moji": "ðŸ°",
+ "description": "european castle",
"unicodeVersion": "6.0",
"digest": "21082d0be7e3b2794e59ff0170da0cfe42a9b734cf02704603e3b52ff48202ba"
},
"european_post_office": {
"category": "travel",
"moji": "ðŸ¤",
+ "description": "european post office",
"unicodeVersion": "6.0",
"digest": "02b4c7602939f0cb9cb2b4e05996bcdb6bd93cf8025c2ea02db8cbe13ca397d0"
},
"evergreen_tree": {
"category": "nature",
"moji": "🌲",
+ "description": "evergreen tree",
"unicodeVersion": "6.0",
"digest": "74b226098e66c0a94a92e0f22b9d631736e12dca72c34182c9d0ba56aa593172"
},
"exclamation": {
"category": "symbols",
"moji": "â—",
+ "description": "heavy exclamation mark symbol",
"unicodeVersion": "5.2",
"digest": "45b87ae4593656d7da49ff5645fb6a2a18d582553295358da9f09f1ae8272445"
},
"expressionless": {
"category": "people",
"moji": "😑",
+ "description": "expressionless face",
"unicodeVersion": "6.1",
"digest": "34e2a1c8121f4f0bc4ce33d226d8cc1a4ebf5260746df2b23e29eef24ee9372e"
},
"eye": {
"category": "people",
"moji": "ðŸ‘",
+ "description": "eye",
"unicodeVersion": "7.0",
"digest": "79ecff79c2edee630e72725b54e67ee2e96d24ca03fef2954a56a09c0a2227f8"
},
"eye_in_speech_bubble": {
"category": "symbols",
"moji": "ðŸ‘â€ðŸ—¨",
+ "description": "eye in speech bubble",
"unicodeVersion": "7.0",
"digest": "c0050c026c2a3060723cab2df2603c1c7da7ed81faedb9ebe16cd89721928a55"
},
"eyeglasses": {
"category": "people",
"moji": "👓",
+ "description": "eyeglasses",
"unicodeVersion": "6.0",
"digest": "d4a9585d6c43ef514a97c45c64607162e775a45544821f1470c6f8f25b93ab81"
},
"eyes": {
"category": "people",
"moji": "👀",
+ "description": "eyes",
"unicodeVersion": "6.0",
"digest": "1d5cae0b9b2e51e1de54295685d7f0c72ee794e2e6335a95b1d056c7e77260e8"
},
"face_palm": {
"category": "people",
"moji": "🤦",
+ "description": "face palm",
"unicodeVersion": "9.0",
"digest": "4ec873048b34b1bb34430724cf28e4bee6c0a9eee88ce39b9d1565047dc92420"
},
"face_palm_tone1": {
"category": "people",
"moji": "🤦ðŸ»",
+ "description": "face palm tone 1",
"unicodeVersion": "9.0",
"digest": "e93ef92b4c01dbea6c400e708e23dd36da92ccfbf5eb4f177b3b20c3a46bdc19"
},
"face_palm_tone2": {
"category": "people",
"moji": "🤦ðŸ¼",
+ "description": "face palm tone 2",
"unicodeVersion": "9.0",
"digest": "22c8bf9fd9fa2ed9dca7a6397ed00ba6cfe9aeef2b0fb7b516ee4dda0df050ea"
},
"face_palm_tone3": {
"category": "people",
"moji": "🤦ðŸ½",
+ "description": "face palm tone 3",
"unicodeVersion": "9.0",
"digest": "c0b8bb9d2423e6787b6bdf1ca5a13f52853e4f48a9a1af0f2d4af1364fff022e"
},
"face_palm_tone4": {
"category": "people",
"moji": "🤦ðŸ¾",
+ "description": "face palm tone 4",
"unicodeVersion": "9.0",
"digest": "f522ab186adcbb4549ea2c03500cdd7a86add548e43ebf7a54d58cc24deea072"
},
"face_palm_tone5": {
"category": "people",
"moji": "🤦ðŸ¿",
+ "description": "face palm tone 5",
"unicodeVersion": "9.0",
"digest": "363507ae7178b5ec583635f47bcab10c897346f48b85d8759b1004c32cd8ad65"
},
"factory": {
"category": "travel",
"moji": "ðŸ­",
+ "description": "factory",
"unicodeVersion": "6.0",
"digest": "c7aeb61ed8b0ac5c91d5197c73f1e2bb801921c22a76bb82c7659d990680dcb0"
},
"fallen_leaf": {
"category": "nature",
"moji": "ðŸ‚",
+ "description": "fallen leaf",
"unicodeVersion": "6.0",
"digest": "81fce04231d48db0e55f3697f930e9a7e3306bed5e35f1234e98c40a24ac5626"
},
"family": {
"category": "people",
"moji": "👪",
+ "description": "family",
"unicodeVersion": "6.0",
"digest": "06f2ce63768ffe43b3d9b2a9660b34d043f37b3c91610dd62343ba21df8ecbe5"
},
"family_mmb": {
"category": "people",
"moji": "👨â€ðŸ‘¨â€ðŸ‘¦",
+ "description": "family (man,man,boy)",
"unicodeVersion": "6.0",
"digest": "41a18405be796699a7eb7c36ab6f7d898e322749997f45387377acf5bb16a50f"
},
"family_mmbb": {
"category": "people",
"moji": "👨â€ðŸ‘¨â€ðŸ‘¦â€ðŸ‘¦",
+ "description": "family (man,man,boy,boy)",
"unicodeVersion": "6.0",
"digest": "87255d1d18c6971c8c083c818e598424c1bd717eed892478b7e9516639dbfb45"
},
"family_mmg": {
"category": "people",
"moji": "👨â€ðŸ‘¨â€ðŸ‘§",
+ "description": "family (man,man,girl)",
"unicodeVersion": "6.0",
"digest": "a132b1b8f10b318d8e23aee15dab4caa14528aeb3c89966d4bcc25fb54af72ad"
},
"family_mmgb": {
"category": "people",
"moji": "👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘¦",
+ "description": "family (man,man,girl,boy)",
"unicodeVersion": "6.0",
"digest": "eb2bc1966df406aaf38ce5a58db9324162799cdacf31f74f40e6384807a8efc2"
},
"family_mmgg": {
"category": "people",
"moji": "👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘§",
+ "description": "family (man,man,girl,girl)",
"unicodeVersion": "6.0",
"digest": "24f3d60f98fbd6b687f7cacfb629390b90509a754036e5439ae5294759c0606b"
},
"family_mwbb": {
"category": "people",
"moji": "👨â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦",
+ "description": "family (man,woman,boy,boy)",
"unicodeVersion": "6.0",
"digest": "2f77692bcb9275c4df501b64a18401dcaf8c68b21f26fbdad59b1feab0c98fd1"
},
"family_mwg": {
"category": "people",
"moji": "👨â€ðŸ‘©â€ðŸ‘§",
+ "description": "family (man,woman,girl)",
"unicodeVersion": "6.0",
"digest": "1a976d13127665d9386cebfdb24e5572dc499bda484c0ee05585886edc616130"
},
"family_mwgb": {
"category": "people",
"moji": "👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦",
+ "description": "family (man,woman,girl,boy)",
"unicodeVersion": "6.0",
"digest": "960ec2cbac13ef208e73644cd36711b83e6c070c36950f834f3669812839b7f8"
},
"family_mwgg": {
"category": "people",
"moji": "👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§",
+ "description": "family (man,woman,girl,girl)",
"unicodeVersion": "6.0",
"digest": "8353b03dfa5c24aba75a0abdfdac01603f593819d54b4c7f2f88aafb31da0c6a"
},
"family_wwb": {
"category": "people",
"moji": "👩â€ðŸ‘©â€ðŸ‘¦",
+ "description": "family (woman,woman,boy)",
"unicodeVersion": "6.0",
"digest": "07a5dd397718c553573689f6512f386729c13a12d5dc78be47c06405769cd98a"
},
"family_wwbb": {
"category": "people",
"moji": "👩â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦",
+ "description": "family (woman,woman,boy,boy)",
"unicodeVersion": "6.0",
"digest": "b627f460f1da0d47b0b662402940b2b77c9538d380d05436dfca4b456c50c939"
},
"family_wwg": {
"category": "people",
"moji": "👩â€ðŸ‘©â€ðŸ‘§",
+ "description": "family (woman,woman,girl)",
"unicodeVersion": "6.0",
"digest": "2d6f373bed53f1028f0fbe9caf036465a351f37b9e00fca7d722cc5a1984f251"
},
"family_wwgb": {
"category": "people",
"moji": "👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦",
+ "description": "family (woman,woman,girl,boy)",
"unicodeVersion": "6.0",
"digest": "72be5c85e1621f73d6794edd6e428febdb366b9e4c816f7829897fd1ab34642b"
},
"family_wwgg": {
"category": "people",
"moji": "👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§",
+ "description": "family (woman,woman,girl,girl)",
"unicodeVersion": "6.0",
"digest": "c39e0916069460d2d9741bddf58e76f5d6a09254cba0eeb262345adf8630bc32"
},
"fast_forward": {
"category": "symbols",
"moji": "â©",
+ "description": "black right-pointing double triangle",
"unicodeVersion": "6.0",
"digest": "e7d2d8085cfd406c2b096e8dd147dd3722290a5727b1f7df185989526a2335ec"
},
"fax": {
"category": "objects",
"moji": "📠",
+ "description": "fax machine",
"unicodeVersion": "6.0",
"digest": "ff85ffa440c5379c9b138ebe2d7912d6098da3b37a051b80442d5557b7f993b0"
},
"fearful": {
"category": "people",
"moji": "😨",
+ "description": "fearful face",
"unicodeVersion": "6.0",
"digest": "b72bdf7d075d5c4e38bbd8512fb45fda2e85c9c8732a47e67575ae9f2ed4c5df"
},
"feet": {
"category": "nature",
"moji": "ðŸ¾",
+ "description": "paw prints",
"unicodeVersion": "6.0",
"digest": "45aca538d3a9831a0c7de491e5656c17705c07b8f4ac8e85254656b608976016"
},
"fencer": {
"category": "activity",
"moji": "🤺",
+ "description": "fencer",
"unicodeVersion": "9.0",
"digest": "5db00fa456af9f6c7cb88d300579dd63e426bcb97ad25486b664aff25c688e21"
},
"ferris_wheel": {
"category": "travel",
"moji": "🎡",
+ "description": "ferris wheel",
"unicodeVersion": "6.0",
"digest": "24b4551b7b79a2a5fd73de61542f2b444f896a52030c5f29791c8fcfcc28b95c"
},
"ferry": {
"category": "travel",
"moji": "â›´",
+ "description": "ferry",
"unicodeVersion": "5.2",
"digest": "5002a72af2e3c4cef9a36ad5987aeed7d99f96bfd13e56f78957315ec7e749a3"
},
"field_hockey": {
"category": "activity",
"moji": "ðŸ‘",
+ "description": "field hockey stick and ball",
"unicodeVersion": "8.0",
"digest": "4ee091d96161ba719ab8fd6f2b03f96d902a6f22cffe0563b930618bb8ac2b67"
},
"file_cabinet": {
"category": "objects",
"moji": "🗄",
+ "description": "file cabinet",
"unicodeVersion": "7.0",
"digest": "92914147bf93e6d64271ff99d217a18a9850a367d08a5f9f458ecf9311a5bbe9"
},
"file_folder": {
"category": "objects",
"moji": "ðŸ“",
+ "description": "file folder",
"unicodeVersion": "6.0",
"digest": "62a42a929267cfbfdb795ead381c9657c343458bc5fca95ea8a0ab892c61d4f6"
},
"film_frames": {
"category": "objects",
"moji": "🎞",
+ "description": "film frames",
"unicodeVersion": "7.0",
"digest": "4da212148cadb9c4ea91e60d2d8316e38cea99ef4f14afc023711dd7c54ade5a"
},
"fingers_crossed": {
"category": "people",
"moji": "🤞",
+ "description": "hand with first and index finger crossed",
"unicodeVersion": "9.0",
"digest": "a5c797ead191b9712e185083266b455cdf09f6a34c10f8c51aa145e6073427e1"
},
"fingers_crossed_tone1": {
"category": "people",
"moji": "🤞ðŸ»",
+ "description": "hand with index and middle fingers crossed tone 1",
"unicodeVersion": "9.0",
"digest": "db56d47bf887f2d8459a3aaba23f15c0087234ae5a54125052e7046e034a4988"
},
"fingers_crossed_tone2": {
"category": "people",
"moji": "🤞ðŸ¼",
+ "description": "hand with index and middle fingers crossed tone 2",
"unicodeVersion": "9.0",
"digest": "19f1bcca3991db7ed2037278c0baab6cd7f12aeaf2e0074de402c4d9e45c1899"
},
"fingers_crossed_tone3": {
"category": "people",
"moji": "🤞ðŸ½",
+ "description": "hand with index and middle fingers crossed tone 3",
"unicodeVersion": "9.0",
"digest": "895a3314f6a310f31f7e728bcca20ff834fbfac62ce00e27e3ea5ad0dfc1ba35"
},
"fingers_crossed_tone4": {
"category": "people",
"moji": "🤞ðŸ¾",
+ "description": "hand with index and middle fingers crossed tone 4",
"unicodeVersion": "9.0",
"digest": "fcb5c4de2001d23a5df1b8702624d134b7f94e93e2dcc8adf6c1033c77722b0e"
},
"fingers_crossed_tone5": {
"category": "people",
"moji": "🤞ðŸ¿",
+ "description": "hand with index and middle fingers crossed tone 5",
"unicodeVersion": "9.0",
"digest": "50132c78d530b048c21be4e788b446872a79b3b3a91009db12f4021c44c8469d"
},
"fire": {
"category": "nature",
"moji": "🔥",
+ "description": "fire",
"unicodeVersion": "6.0",
"digest": "b3e67c913903d900f5e50e7e7e4d7e9370bb6ceedfbee548be39e4c9e4b69416"
},
"fire_engine": {
"category": "travel",
"moji": "🚒",
+ "description": "fire engine",
"unicodeVersion": "6.0",
"digest": "c3a518f27d625e3b62dffa227eb82764bf0a147f10ec0e7f4f43f3f96751af20"
},
"fireworks": {
"category": "travel",
"moji": "🎆",
+ "description": "fireworks",
"unicodeVersion": "6.0",
"digest": "b62ae08a00c0cc6eba8f9666c8fd9946ce57c3cfc01fe99542a8690a4a566a65"
},
"first_place": {
"category": "activity",
"moji": "🥇",
+ "description": "first place medal",
"unicodeVersion": "9.0",
"digest": "e3de5d9f14f05544dbee5965cc2baa20e7b417a488c8a18598979038860fd901"
},
"first_quarter_moon": {
"category": "nature",
"moji": "🌓",
+ "description": "first quarter moon symbol",
"unicodeVersion": "6.0",
"digest": "a207ce93084448622a4a5c49c85c566a9fda6be7337c86a013eeb713fe47fd29"
},
"first_quarter_moon_with_face": {
"category": "nature",
"moji": "🌛",
+ "description": "first quarter moon with face",
"unicodeVersion": "6.0",
"digest": "1d1f54a5075f2311bcc017c44898b9d8c58edc13b298d58c238fff9ab8ee2ef3"
},
"fish": {
"category": "nature",
"moji": "ðŸŸ",
+ "description": "fish",
"unicodeVersion": "6.0",
"digest": "8f62f08fbeaf39694c19816b5c7d4f292017fe5bf9f8dd7e40f1630f5f83b28b"
},
"fish_cake": {
"category": "food",
"moji": "ðŸ¥",
+ "description": "fish cake with swirl design",
"unicodeVersion": "6.0",
"digest": "5a6ca2100c8830927b22afa6f1d2fc821f5692cd23507fe5a776f6e085cbbfb2"
},
"fishing_pole_and_fish": {
"category": "activity",
"moji": "🎣",
+ "description": "fishing pole and fish",
"unicodeVersion": "6.0",
"digest": "f8fb84eccceec88321b0a2a46f732ecfc378f787c19c27ac1327735f1ca9a48b"
},
"fist": {
"category": "people",
"moji": "✊",
+ "description": "raised fist",
"unicodeVersion": "6.0",
"digest": "557f96d85615b8d78436bc67266115bfc8556c97c14f7909dfda1cf134e8344f"
},
"fist_tone1": {
"category": "people",
"moji": "✊ðŸ»",
+ "description": "raised fist tone 1",
"unicodeVersion": "8.0",
"digest": "6c1b946f9e01abc39b5085e24e8b6077fc0e34188e8daa30c6a3adddd387413e"
},
"fist_tone2": {
"category": "people",
"moji": "✊ðŸ¼",
+ "description": "raised fist tone 2",
"unicodeVersion": "8.0",
"digest": "e9b9e1ec638dca4d5e1519bca7338f58cce2f2a282ee4c3581e8643166fc415f"
},
"fist_tone3": {
"category": "people",
"moji": "✊ðŸ½",
+ "description": "raised fist tone 3",
"unicodeVersion": "8.0",
"digest": "8c14d24055c143960b3d2a27fe23c55d2d3ac5f84f87e4e876616235e8698c7f"
},
"fist_tone4": {
"category": "people",
"moji": "✊ðŸ¾",
+ "description": "raised fist tone 4",
"unicodeVersion": "8.0",
"digest": "923f034f481e952e6e5d1664588f99f79bd5416d4197b0ade6621f2669ce5765"
},
"fist_tone5": {
"category": "people",
"moji": "✊ðŸ¿",
+ "description": "raised fist tone 5",
"unicodeVersion": "8.0",
"digest": "d691d2902216080916a29047e07d7a5bf2aed07e062067ca9d01cbf6fdf48c8d"
},
"five": {
"category": "symbols",
"moji": "5ï¸âƒ£",
+ "description": "keycap digit five",
"unicodeVersion": "3.0",
"digest": "8f03f62fdbf744ae49c8a60fbf715ebfccbd6b62d91148e0923907006f3c2726"
},
"flag_ac": {
"category": "flags",
"moji": "🇦🇨",
+ "description": "ascension",
"unicodeVersion": "6.0",
"digest": "2e5c08535dc8ea96422d56a36b4fffc0b3bd2a13f2ab0d8dbd0e3a29bf3fc40c"
},
"flag_ad": {
"category": "flags",
"moji": "🇦🇩",
+ "description": "andorra",
"unicodeVersion": "6.0",
"digest": "184fdcf790b8e2fd851b2b2b32f8636c595dd289734d12dc01ae4aa177e2043a"
},
"flag_ae": {
"category": "flags",
"moji": "🇦🇪",
+ "description": "the united arab emirates",
"unicodeVersion": "6.0",
"digest": "4a3257a9ce118e97567e76280f24d60fb555f1bada2eb26a2442a47f9398d21e"
},
"flag_af": {
"category": "flags",
"moji": "🇦🇫",
+ "description": "afghanistan",
"unicodeVersion": "6.0",
"digest": "0f6c719cac7ab3140694f6b580787ecdbf503e38f16de7ec5803f7d06a088ec3"
},
"flag_ag": {
"category": "flags",
"moji": "🇦🇬",
+ "description": "antigua and barbuda",
"unicodeVersion": "6.0",
"digest": "92bf5a0e74564739862e9ba79331ffa656b7bae2ace0fc8dfd288984e4d510d4"
},
"flag_ai": {
"category": "flags",
"moji": "🇦🇮",
+ "description": "anguilla",
"unicodeVersion": "6.0",
"digest": "aeaadc7ffafd8a1e01fdabc69d35f725d5f737b4c284a36191d96729f4e66e8f"
},
"flag_al": {
"category": "flags",
"moji": "🇦🇱",
+ "description": "albania",
"unicodeVersion": "6.0",
"digest": "5ce7866d214d18c5f3438d480d14e77d104c4de679f0fdfca8cf0a44ce48eeea"
},
"flag_am": {
"category": "flags",
"moji": "🇦🇲",
+ "description": "armenia",
"unicodeVersion": "6.0",
"digest": "b40f5705f0cf9ef0fa7ffff0b371c4099319001ce79f894c317912f4dc5de4c8"
},
"flag_ao": {
"category": "flags",
"moji": "🇦🇴",
+ "description": "angola",
"unicodeVersion": "6.0",
"digest": "eab6fbc1824d6e3cd152e8ec1d82e1beaebe02b53b35c6f7a883b8548af02f3a"
},
"flag_aq": {
"category": "flags",
"moji": "🇦🇶",
+ "description": "antarctica",
"unicodeVersion": "6.0",
"digest": "367f6677a683a5f0e7248ab3a8f46d06ba146a0fd75004c70bac0e913147cdaa"
},
"flag_ar": {
"category": "flags",
"moji": "🇦🇷",
+ "description": "argentina",
"unicodeVersion": "6.0",
"digest": "f0dc466b3216957f2679d7208c2d7cf288448b0739b9270a7c5fa717577bdf25"
},
"flag_as": {
"category": "flags",
"moji": "🇦🇸",
+ "description": "american samoa",
"unicodeVersion": "6.0",
"digest": "fcb7a865c7763c63b23485cc27207b99a3a8492e83d5b5ee2df259a9f68f77d6"
},
"flag_at": {
"category": "flags",
"moji": "🇦🇹",
+ "description": "austria",
"unicodeVersion": "6.0",
"digest": "1d3d58e9abc034f9a093a94716eddf9811d54dfaf27969fd322b3809fac70217"
},
"flag_au": {
"category": "flags",
"moji": "🇦🇺",
+ "description": "australia",
"unicodeVersion": "6.0",
"digest": "789563b64c71a5ad49078d335dc166ef614edb56d1e401885d32fb191c198fbd"
},
"flag_aw": {
"category": "flags",
"moji": "🇦🇼",
+ "description": "aruba",
"unicodeVersion": "6.0",
"digest": "1504dc3fd8457b44fdf75c15e136dc46a13e8342d1f98949728cdc1238843e0c"
},
"flag_ax": {
"category": "flags",
"moji": "🇦🇽",
+ "description": "Ã¥land islands",
"unicodeVersion": "6.0",
"digest": "e96fa3525f3be25016a4cf8428261735f3ed5fc9fe5b827b461746a3f08877bf"
},
"flag_az": {
"category": "flags",
"moji": "🇦🇿",
+ "description": "azerbaijan",
"unicodeVersion": "6.0",
"digest": "12c366ac2c38b91314fb29056e09fa6e7417766cebde3045859cdb127549f4a2"
},
"flag_ba": {
"category": "flags",
"moji": "🇧🇦",
+ "description": "bosnia and herzegovina",
"unicodeVersion": "6.0",
"digest": "0819ea3901510ac20c7f10e67e5f6c818210f17a362c1d12e299c41feb07f828"
},
"flag_bb": {
"category": "flags",
"moji": "🇧🇧",
+ "description": "barbados",
"unicodeVersion": "6.0",
"digest": "cf32778a272ed6cbc8e783b59befd9b204009c69c61a425e148d867808b7fab9"
},
"flag_bd": {
"category": "flags",
"moji": "🇧🇩",
+ "description": "bangladesh",
"unicodeVersion": "6.0",
"digest": "e6ed186644a874588e879513aec92f8107220dcdd14c766dee61f266ce045665"
},
"flag_be": {
"category": "flags",
"moji": "🇧🇪",
+ "description": "belgium",
"unicodeVersion": "6.0",
"digest": "4d941011d15d9f6e755d6f7694884758baf17ac0691bf5d63700f8d6dbcdb948"
},
"flag_bf": {
"category": "flags",
"moji": "🇧🇫",
+ "description": "burkina faso",
"unicodeVersion": "6.0",
"digest": "fcc57dbda9a86f725f558b6c6309484c97e65f1644aae4f9fb5e642681f6c2e0"
},
"flag_bg": {
"category": "flags",
"moji": "🇧🇬",
+ "description": "bulgaria",
"unicodeVersion": "6.0",
"digest": "816c47ed96c36c90723da150645902ea8ba18b44757fdd776c7b3542cfecfb18"
},
"flag_bh": {
"category": "flags",
"moji": "🇧🇭",
+ "description": "bahrain",
"unicodeVersion": "6.0",
"digest": "2cd5c21775a6e73f59d08c9ee0cedf4e8241e562eab939573501d47681987737"
},
"flag_bi": {
"category": "flags",
"moji": "🇧🇮",
+ "description": "burundi",
"unicodeVersion": "6.0",
"digest": "2da82acbec5518360633c1b0b56d55a79b67237f67d92af5e5cd75a2f3bd550e"
},
"flag_bj": {
"category": "flags",
"moji": "🇧🇯",
+ "description": "benin",
"unicodeVersion": "6.0",
"digest": "8fe8c34651eb4e28ab395261a5b72b6f37579535ed676d15de131914e19c0436"
},
"flag_bl": {
"category": "flags",
"moji": "🇧🇱",
+ "description": "saint barthélemy",
"unicodeVersion": "6.0",
"digest": "d37f2a215ee7ef5b5ab62d2a0c87e90553b17c6ee310f803a71e9fd72db880e7"
},
"flag_black": {
"category": "objects",
"moji": "ðŸ´",
+ "description": "waving black flag",
"unicodeVersion": "6.0",
"digest": "3740bfc9bcb3b46b697b8b7c47ab2c3e95eca9dbcba12f2bf98a01302704f203"
},
"flag_bm": {
"category": "flags",
"moji": "🇧🇲",
+ "description": "bermuda",
"unicodeVersion": "6.0",
"digest": "ccd21655573f3c955d616c5c7b1eac2be1d4772ff611648d6713ba55d9e4aa9b"
},
"flag_bn": {
"category": "flags",
"moji": "🇧🇳",
+ "description": "brunei",
"unicodeVersion": "6.0",
"digest": "54330c3d7a37392e69098c213fd8c78f3faab4e7e5909c039188110422514228"
},
"flag_bo": {
"category": "flags",
"moji": "🇧🇴",
+ "description": "bolivia",
"unicodeVersion": "6.0",
"digest": "32aff973b26f4f91ca19dddd7861b564da43cfbee87603d8c004f1111342366c"
},
"flag_bq": {
"category": "flags",
"moji": "🇧🇶",
+ "description": "caribbean netherlands",
"unicodeVersion": "6.0",
"digest": "b1ebc959c43f706ca430d8633d9efaa9c60133871506b5f030b730cfb4c19e6f"
},
"flag_br": {
"category": "flags",
"moji": "🇧🇷",
+ "description": "brazil",
"unicodeVersion": "6.0",
"digest": "64fb154d71fa34ff4838bc405f3e58a4102cf0cb49ca4b06fc3c7a6bf39671f0"
},
"flag_bs": {
"category": "flags",
"moji": "🇧🇸",
+ "description": "the bahamas",
"unicodeVersion": "6.0",
"digest": "c4b07e5f652ab06ece95d3774ce8b1399a935f8a28d440cb13cc8bd0b9728ed5"
},
"flag_bt": {
"category": "flags",
"moji": "🇧🇹",
+ "description": "bhutan",
"unicodeVersion": "6.0",
"digest": "901ddbd999dd89a87c1e1208b1470cb4e604a9bc023d0cbcdee64e1bc54079ba"
},
"flag_bv": {
"category": "flags",
"moji": "🇧🇻",
+ "description": "bouvet island",
"unicodeVersion": "6.0",
"digest": "bbf6daa6174c6fbbbf541c8274f31b6757c3a16007c2687015ea041fd1e2c6b6"
},
"flag_bw": {
"category": "flags",
"moji": "🇧🇼",
+ "description": "botswana",
"unicodeVersion": "6.0",
"digest": "05aa351bc04dc0fe2669441ab500e000d48b1f0d7ad9e885c7abfb898aa0eb3f"
},
"flag_by": {
"category": "flags",
"moji": "🇧🇾",
+ "description": "belarus",
"unicodeVersion": "6.0",
"digest": "6eda3b87336ecf0aae4963986d86b916a055d8268c70520303288f235a93b0d9"
},
"flag_bz": {
"category": "flags",
"moji": "🇧🇿",
+ "description": "belize",
"unicodeVersion": "6.0",
"digest": "d76ed945b1408558a30a99b8eed6712de968fc49fba1721b5660b8f48087e45a"
},
"flag_ca": {
"category": "flags",
"moji": "🇨🇦",
+ "description": "canada",
"unicodeVersion": "6.0",
"digest": "2fd036047d89751c05de5577909b58347883bc89c3b7d90bec28ad4770a98ecd"
},
"flag_cc": {
"category": "flags",
"moji": "🇨🇨",
+ "description": "cocos (keeling) islands",
"unicodeVersion": "6.0",
"digest": "837ba181a01c71f05d438d205efaaee99f93b2370c97b13e6132f99860323e36"
},
"flag_cd": {
"category": "flags",
"moji": "🇨🇩",
+ "description": "the democratic republic of the congo",
"unicodeVersion": "6.0",
"digest": "318689274b4b3b58aed7fc1654127499a9da69bff1b83e592e86e69d167ce16f"
},
"flag_cf": {
"category": "flags",
"moji": "🇨🇫",
+ "description": "central african republic",
"unicodeVersion": "6.0",
"digest": "06d6042849d3b7b217c2b18ba787aae449e8c7d2537e2e5974744ec196062228"
},
"flag_cg": {
"category": "flags",
"moji": "🇨🇬",
+ "description": "the republic of the congo",
"unicodeVersion": "6.0",
"digest": "09f45d2dcb5a24d8349ef86e7405cc29ef3d65a908c0bff3221c3b4546547813"
},
"flag_ch": {
"category": "flags",
"moji": "🇨🇭",
+ "description": "switzerland",
"unicodeVersion": "6.0",
"digest": "53d6d35aeeebb0b4b1ad858dc3691e649ac73d30b3be76f96d5fe9605fa99386"
},
"flag_ci": {
"category": "flags",
"moji": "🇨🇮",
+ "description": "cote d'ivoire",
"unicodeVersion": "6.0",
"digest": "7d85a0c314b7397c9397a54ce2f3a4dc5f40d0234e586dbd8a541a8666f0f51e"
},
"flag_ck": {
"category": "flags",
"moji": "🇨🇰",
+ "description": "cook islands",
"unicodeVersion": "6.0",
"digest": "c1aa105fe106ed09ed59a596859a0ce4e65a415c59f63df51961491cb947b136"
},
"flag_cl": {
"category": "flags",
"moji": "🇨🇱",
+ "description": "chile",
"unicodeVersion": "6.0",
"digest": "0fffdad0d892f5c08aaa332af1ed2c228583d89a43190e979a3c3cb020d5a723"
},
"flag_cm": {
"category": "flags",
"moji": "🇨🇲",
+ "description": "cameroon",
"unicodeVersion": "6.0",
"digest": "e9f55e41a1fd2735a82ad7a7ac39326a944cb20423ffba3608ac53a46036caad"
},
"flag_cn": {
"category": "flags",
"moji": "🇨🇳",
+ "description": "china",
"unicodeVersion": "6.0",
"digest": "e2c8fee7e3bd51b13d6083d5bf344abe6b9b642e3cbb099d38b4ce341c99d890"
},
"flag_co": {
"category": "flags",
"moji": "🇨🇴",
+ "description": "colombia",
"unicodeVersion": "6.0",
"digest": "51c60d0979bf8342eaff7cda9faf4b0dfab38efaf5ddf3717eb8f0e2a595b15f"
},
"flag_cp": {
"category": "flags",
"moji": "🇨🇵",
+ "description": "clipperton island",
"unicodeVersion": "6.0",
"digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9"
},
"flag_cr": {
"category": "flags",
"moji": "🇨🇷",
+ "description": "costa rica",
"unicodeVersion": "6.0",
"digest": "907905971b219e617a34eef4839b0bd08d98f3480e2631bce523120dcef95196"
},
"flag_cu": {
"category": "flags",
"moji": "🇨🇺",
+ "description": "cuba",
"unicodeVersion": "6.0",
"digest": "d88cea729dc9dbbbcadac0409ec561995f061b2280577c01c6c6b37de347f150"
},
"flag_cv": {
"category": "flags",
"moji": "🇨🇻",
+ "description": "cape verde",
"unicodeVersion": "6.0",
"digest": "5ce97944adfce09e96387e6f872256482ac99ccbc60017c4d58ddd15b6fb67a7"
},
"flag_cw": {
"category": "flags",
"moji": "🇨🇼",
+ "description": "curaçao",
"unicodeVersion": "6.0",
"digest": "a6fc31bd66ddc2ee8e7bde3aeabfe1c4ad00c9688abae234a541cc1236d68c1b"
},
"flag_cx": {
"category": "flags",
"moji": "🇨🇽",
+ "description": "christmas island",
"unicodeVersion": "6.0",
"digest": "1261b32bfa22fa1441f5390ff499ac6b921d7ac59cc8acda3deb3a2beb4fb345"
},
"flag_cy": {
"category": "flags",
"moji": "🇨🇾",
+ "description": "cyprus",
"unicodeVersion": "6.0",
"digest": "82b1baa05ecffa0ea1f9a83b518163cbd7910985a21955740520bb16b7bb624f"
},
"flag_cz": {
"category": "flags",
"moji": "🇨🇿",
+ "description": "the czech republic",
"unicodeVersion": "6.0",
"digest": "a169b18968992a52299b67c24fba495e84de28dec2ebb947a08e0d615ac54a5a"
},
"flag_de": {
"category": "flags",
"moji": "🇩🇪",
+ "description": "germany",
"unicodeVersion": "6.0",
"digest": "99d1906944966a188c72ae592362ed907e2a0bfe95263955c34a0941507b30c1"
},
"flag_dg": {
"category": "flags",
"moji": "🇩🇬",
+ "description": "diego garcia",
"unicodeVersion": "6.0",
"digest": "dd45e1afe792fca57d4161434bf611bcb7170072d63e4a27fb9dcd6e8912621e"
},
"flag_dj": {
"category": "flags",
"moji": "🇩🇯",
+ "description": "djibouti",
"unicodeVersion": "6.0",
"digest": "e90ba4e98fca71ff0ca5e65c28b911cc52f043428f375d8f954ecbd3b0c8f4dd"
},
"flag_dk": {
"category": "flags",
"moji": "🇩🇰",
+ "description": "denmark",
"unicodeVersion": "6.0",
"digest": "65b3b5f31935a4969d81fedbb8279c7ad32da454d15c5eafcceba5d140927c77"
},
"flag_dm": {
"category": "flags",
"moji": "🇩🇲",
+ "description": "dominica",
"unicodeVersion": "6.0",
"digest": "f6225ded6d2cfd6c182ab1a53b8c49dc9df195df11eb7ff27b15f5d3721ba0eb"
},
"flag_do": {
"category": "flags",
"moji": "🇩🇴",
+ "description": "the dominican republic",
"unicodeVersion": "6.0",
"digest": "dc2ad6856cebbe47c5bd7f5dcf087e4f680d396b2d49440a9b71f0ad49fb8102"
},
"flag_dz": {
"category": "flags",
"moji": "🇩🇿",
+ "description": "algeria",
"unicodeVersion": "6.0",
"digest": "ea69fffc4d545f9c0fcef6768257501952955ba4d274c9b81843229a1265c5ed"
},
"flag_ea": {
"category": "flags",
"moji": "🇪🇦",
+ "description": "ceuta, melilla",
"unicodeVersion": "6.0",
"digest": "e63bfe15428c481dd23b569e7aaf0a76106e58a946995b4415a81097ecd53b7d"
},
"flag_ec": {
"category": "flags",
"moji": "🇪🇨",
+ "description": "ecuador",
"unicodeVersion": "6.0",
"digest": "0cdabf85cd567047fda1d9a4508220cab829943a7c542c315078db0aac33edac"
},
"flag_ee": {
"category": "flags",
"moji": "🇪🇪",
+ "description": "estonia",
"unicodeVersion": "6.0",
"digest": "6dc4e3377e8e2af3ff40cf940a914bc7840980b4a14e7da86954343f2b1025fe"
},
"flag_eg": {
"category": "flags",
"moji": "🇪🇬",
+ "description": "egypt",
"unicodeVersion": "6.0",
"digest": "2ed6bc056015694d75993eb5ee3c1850921d5630681207b04dfbdb982ab346a2"
},
"flag_eh": {
"category": "flags",
"moji": "🇪🇭",
+ "description": "western sahara",
"unicodeVersion": "6.0",
"digest": "72adb55943e4df99c00843c65463718609d937480f73dcf4a4451d46b9967a5e"
},
"flag_er": {
"category": "flags",
"moji": "🇪🇷",
+ "description": "eritrea",
"unicodeVersion": "6.0",
"digest": "3fa59331eb5300c8c1f7b1f1bc15cfcfe688da6fa4a79341854598086a44eebc"
},
"flag_es": {
"category": "flags",
"moji": "🇪🇸",
+ "description": "spain",
"unicodeVersion": "6.0",
"digest": "1fa1d5cb0a7e8b14aaec758b2e7bf49cdf8f3d09bbcc7dfd589053a432eeae25"
},
"flag_et": {
"category": "flags",
"moji": "🇪🇹",
+ "description": "ethiopia",
"unicodeVersion": "6.0",
"digest": "72771decfb214394e4beb594e848ea590c3615800adbba24b5df4c5db6ee9617"
},
"flag_eu": {
"category": "flags",
"moji": "🇪🇺",
+ "description": "european union",
"unicodeVersion": "6.0",
"digest": "4bfa1b2ef23764ead5ef7899806f93e13fd29a09c75e61431579a4116c836aa4"
},
"flag_fi": {
"category": "flags",
"moji": "🇫🇮",
+ "description": "finland",
"unicodeVersion": "6.0",
"digest": "d0208cdd5b153a2865f9f674179c62871d4675abb0fb639fba88fcd62553f54e"
},
"flag_fj": {
"category": "flags",
"moji": "🇫🇯",
+ "description": "fiji",
"unicodeVersion": "6.0",
"digest": "6c5ec41114af3846b093a418f6e2b5ff7a83cb72cecde75a7dc62e8cb6dcfe45"
},
"flag_fk": {
"category": "flags",
"moji": "🇫🇰",
+ "description": "falkland islands",
"unicodeVersion": "6.0",
"digest": "c69ad641d53785deff5c3934b7dcfcd3dc32ffc31b6d3e799d0555b03c23fc15"
},
"flag_fm": {
"category": "flags",
"moji": "🇫🇲",
+ "description": "micronesia",
"unicodeVersion": "6.0",
"digest": "1e29fb06b273f253c23a9e4aa8ff84bfe22cffb5fa158a0c6f4cdeabe0216990"
},
"flag_fo": {
"category": "flags",
"moji": "🇫🇴",
+ "description": "faroe islands",
"unicodeVersion": "6.0",
"digest": "f4907d2f606f4f9d3bef06c6d38e8e88f2a148197b1573668866431a007afc2e"
},
"flag_fr": {
"category": "flags",
"moji": "🇫🇷",
+ "description": "france",
"unicodeVersion": "6.0",
"digest": "5a1308ab3cbf6bffcab12588cf3325151a6c72990db7408c2b8605d89f94ed6e"
},
"flag_ga": {
"category": "flags",
"moji": "🇬🇦",
+ "description": "gabon",
"unicodeVersion": "6.0",
"digest": "ddc32dee2976507be878ec3d3d2408632ca21bc434cd9f58db4f6ac9774a2db5"
},
"flag_gb": {
"category": "flags",
"moji": "🇬🇧",
+ "description": "great britain",
"unicodeVersion": "6.0",
"digest": "6b3bb254d134870b02cb066b06e206f652638a915c84b8649ceb30ec67fbebde"
},
"flag_gd": {
"category": "flags",
"moji": "🇬🇩",
+ "description": "grenada",
"unicodeVersion": "6.0",
"digest": "b6a210541ca22d816405f2a7d0d5241dc4d5488c8a36e15bd1e3063f9c41327f"
},
"flag_ge": {
"category": "flags",
"moji": "🇬🇪",
+ "description": "georgia",
"unicodeVersion": "6.0",
"digest": "e9a5035b7a46b925737e7f7b0ae2419cc4af0e980fbee5bd916edeef13823367"
},
"flag_gf": {
"category": "flags",
"moji": "🇬🇫",
+ "description": "french guiana",
"unicodeVersion": "6.0",
"digest": "ce1bcd8c303897c1c22c5994182f21240b4aa635f0d7ce9944f76cbdbf0e4956"
},
"flag_gg": {
"category": "flags",
"moji": "🇬🇬",
+ "description": "guernsey",
"unicodeVersion": "6.0",
"digest": "a435aab3609533ab2d68acd97deba844bfb0fc27b2adac68668223011f23ae5d"
},
"flag_gh": {
"category": "flags",
"moji": "🇬🇭",
+ "description": "ghana",
"unicodeVersion": "6.0",
"digest": "7cad43b40f69b9b00cc1b38036789ce774fd3d597c89f0bf38433847ea69be26"
},
"flag_gi": {
"category": "flags",
"moji": "🇬🇮",
+ "description": "gibraltar",
"unicodeVersion": "6.0",
"digest": "70e9b17d18bf3e0e4d03f4f824323a57909416e4082ca9d8a0796a6959de4f07"
},
"flag_gl": {
"category": "flags",
"moji": "🇬🇱",
+ "description": "greenland",
"unicodeVersion": "6.0",
"digest": "1963d8cca1c1f06b7536b7fb8f5a4782ac0bb05afdf6e481101bce45c58cdd4b"
},
"flag_gm": {
"category": "flags",
"moji": "🇬🇲",
+ "description": "the gambia",
"unicodeVersion": "6.0",
"digest": "6c776a8daa3f4daa2597b0025aec06fc0a53aed262e845d4da3897cd7a89c6a1"
},
"flag_gn": {
"category": "flags",
"moji": "🇬🇳",
+ "description": "guinea",
"unicodeVersion": "6.0",
"digest": "134cf7c839370d171ae80a72e5d18d32ea1967df19c191d1a4ea446d649e9558"
},
"flag_gp": {
"category": "flags",
"moji": "🇬🇵",
+ "description": "guadeloupe",
"unicodeVersion": "6.0",
"digest": "be3e906b039ba4884053c78f4f14de9aa87c5573860ccb69ec766068ae3887c2"
},
"flag_gq": {
"category": "flags",
"moji": "🇬🇶",
+ "description": "equatorial guinea",
"unicodeVersion": "6.0",
"digest": "d476059c4ab41f5a1ef88583087362a5bc57cede930126f37041d1546564ab70"
},
"flag_gr": {
"category": "flags",
"moji": "🇬🇷",
+ "description": "greece",
"unicodeVersion": "6.0",
"digest": "b9fa9304647aaa08167a07858bb18d778dcc399375f86f580b8d4244794678bc"
},
"flag_gs": {
"category": "flags",
"moji": "🇬🇸",
+ "description": "south georgia",
"unicodeVersion": "6.0",
"digest": "de33fbef6e294eb7af36e5b94d8ff573b354a4ff1ebdccf50ca528b86ed601d9"
},
"flag_gt": {
"category": "flags",
"moji": "🇬🇹",
+ "description": "guatemala",
"unicodeVersion": "6.0",
"digest": "4160843e5d642df597c8423eb8e3b74deafe304f3d141c8a4d2fc07509e44832"
},
"flag_gu": {
"category": "flags",
"moji": "🇬🇺",
+ "description": "guam",
"unicodeVersion": "6.0",
"digest": "3b0cb257ba5b1c3e15d9102410c5f7418da03372e91ce90513de25b9f45283e3"
},
"flag_gw": {
"category": "flags",
"moji": "🇬🇼",
+ "description": "guinea-bissau",
"unicodeVersion": "6.0",
"digest": "bdf07a8f93c0f0a573af5f5361be404a3ba65b729c1a4c05b7632c03d85efc72"
},
"flag_gy": {
"category": "flags",
"moji": "🇬🇾",
+ "description": "guyana",
"unicodeVersion": "6.0",
"digest": "b47d8c98b747556f827ad0d1169264eb68ecaf9d2fb76595e8c31866361cbfc6"
},
"flag_hk": {
"category": "flags",
"moji": "🇭🇰",
+ "description": "hong kong",
"unicodeVersion": "6.0",
"digest": "8e5a54b2e4bd4f5182085299b9648062463da05d535cf0e46a7d9c58eaeb171f"
},
"flag_hm": {
"category": "flags",
"moji": "🇭🇲",
+ "description": "heard island and mcdonald islands",
"unicodeVersion": "6.0",
"digest": "63c3e080c5e82a72c6d4cf5997ac823dc02184719ec59aadea6dd41b127abf22"
},
"flag_hn": {
"category": "flags",
"moji": "🇭🇳",
+ "description": "honduras",
"unicodeVersion": "6.0",
"digest": "87c1d160db810b5ed208fb33add54f96c17b0f08d87b81f6f09429abf6ec93ac"
},
"flag_hr": {
"category": "flags",
"moji": "🇭🇷",
+ "description": "croatia",
"unicodeVersion": "6.0",
"digest": "8b68112f79baea38565673acf4f1cb90675a5829ff17e4cf9415c928b62aed88"
},
"flag_ht": {
"category": "flags",
"moji": "🇭🇹",
+ "description": "haiti",
"unicodeVersion": "6.0",
"digest": "05dbd548c310ef1ebd1724aa85d821f8320106b16ddbf1f6442ea37e4407d5e1"
},
"flag_hu": {
"category": "flags",
"moji": "🇭🇺",
+ "description": "hungary",
"unicodeVersion": "6.0",
"digest": "5079f3d6f1459e6df8dda5c19d2367ead8f5a755b8874ac999bae58e3c9f47a7"
},
"flag_ic": {
"category": "flags",
"moji": "🇮🇨",
+ "description": "canary islands",
"unicodeVersion": "6.0",
"digest": "8dcb18c4b75a60867a68d2f6edbf81e782aafb4b9a0404c8081f872dfe71e432"
},
"flag_id": {
"category": "flags",
"moji": "🇮🇩",
+ "description": "indonesia",
"unicodeVersion": "6.0",
"digest": "1b0eb69a158ed3afe24be448d44751f95dcc5cbc7d1393a5753293f16ef0a66c"
},
"flag_ie": {
"category": "flags",
"moji": "🇮🇪",
+ "description": "ireland",
"unicodeVersion": "6.0",
"digest": "5fc8c101ad7296224455f72f73c335aa4f676023b68645bafaf69087f69af390"
},
"flag_il": {
"category": "flags",
"moji": "🇮🇱",
+ "description": "israel",
"unicodeVersion": "6.0",
"digest": "5aea4207415b7615dcdd69413705aefda700aefd0d27010cd0a0a338d879d9b8"
},
"flag_im": {
"category": "flags",
"moji": "🇮🇲",
+ "description": "isle of man",
"unicodeVersion": "6.0",
"digest": "1ee9b3a5f1a52fc6d8369bfd81995fc0567e7a61deacd013701b3ec5fd64502e"
},
"flag_in": {
"category": "flags",
"moji": "🇮🇳",
+ "description": "india",
"unicodeVersion": "6.0",
"digest": "202ede502f34d55d180726ac2f29141c6875516f1b3e7ee99f266b16c2fe4bfd"
},
"flag_io": {
"category": "flags",
"moji": "🇮🇴",
+ "description": "british indian ocean territory",
"unicodeVersion": "6.0",
"digest": "dd45e1afe792fca57d4161434bf611bcb7170072d63e4a27fb9dcd6e8912621e"
},
"flag_iq": {
"category": "flags",
"moji": "🇮🇶",
+ "description": "iraq",
"unicodeVersion": "6.0",
"digest": "bef294772b5ffccd6c061c19d60af66f61b248d78705faf347ade9ebfca2b46d"
},
"flag_ir": {
"category": "flags",
"moji": "🇮🇷",
+ "description": "iran",
"unicodeVersion": "6.0",
"digest": "d4faca93577a5546330ab6a09252307e19fb420d89912c0b48ceb90bf409d48e"
},
"flag_is": {
"category": "flags",
"moji": "🇮🇸",
+ "description": "iceland",
"unicodeVersion": "6.0",
"digest": "b2fc04226b274009b4d99d92bcb72b255b534b6fd4b76d82dce1575ad975a456"
},
"flag_it": {
"category": "flags",
"moji": "🇮🇹",
+ "description": "italy",
"unicodeVersion": "6.0",
"digest": "735760f193855d55460a0fb93dad55ff67253cab63176eceb90b9bde1faead1e"
},
"flag_je": {
"category": "flags",
"moji": "🇯🇪",
+ "description": "jersey",
"unicodeVersion": "6.0",
"digest": "671a487a60571d928d2abaf306d0a9ba50239ec54ada14ea29a9a99df658d3cc"
},
"flag_jm": {
"category": "flags",
"moji": "🇯🇲",
+ "description": "jamaica",
"unicodeVersion": "6.0",
"digest": "fb9047199d030b78fc0dcfc58d9b524fdb929238d922809da88147b7cebf4211"
},
"flag_jo": {
"category": "flags",
"moji": "🇯🇴",
+ "description": "jordan",
"unicodeVersion": "6.0",
"digest": "19f7d536d0293ebf3db49e05a158097cbde467115ef96523a0553808fd0b4178"
},
"flag_jp": {
"category": "flags",
"moji": "🇯🇵",
+ "description": "japan",
"unicodeVersion": "6.0",
"digest": "51e971f777fe481ca9f7e077ecb2ce252c3cc0086b76384e7b965cdc337f3f9e"
},
"flag_ke": {
"category": "flags",
"moji": "🇰🇪",
+ "description": "kenya",
"unicodeVersion": "6.0",
"digest": "0cec8f068548cfd3e7a20c10af84f97ca415fd6f8ab8b50783bf982e77d7260e"
},
"flag_kg": {
"category": "flags",
"moji": "🇰🇬",
+ "description": "kyrgyzstan",
"unicodeVersion": "6.0",
"digest": "5803ea6ab028261923fd7570c670a50518c6f462a2fb4d463531b12c3e382e6f"
},
"flag_kh": {
"category": "flags",
"moji": "🇰🇭",
+ "description": "cambodia",
"unicodeVersion": "6.0",
"digest": "287d357afe47179853fd485fb102834ead145598ed892664fc62d245cac16080"
},
"flag_ki": {
"category": "flags",
"moji": "🇰🇮",
+ "description": "kiribati",
"unicodeVersion": "6.0",
"digest": "ae4aee0d9cd7a21d4e250d45a484f5f641acdab3d79b437337b25fe34a0b49b0"
},
"flag_km": {
"category": "flags",
"moji": "🇰🇲",
+ "description": "the comoros",
"unicodeVersion": "6.0",
"digest": "2d1730acbf5421fd02bd5483e26a86d82ec2fa99f0ff75bfd728a9df7914ad3b"
},
"flag_kn": {
"category": "flags",
"moji": "🇰🇳",
+ "description": "saint kitts and nevis",
"unicodeVersion": "6.0",
"digest": "b9ed979db9c6d243b00f61f19a9ec0f2c2390b2e5cace5ad61d9371dc8c670ac"
},
"flag_kp": {
"category": "flags",
"moji": "🇰🇵",
+ "description": "north korea",
"unicodeVersion": "6.0",
"digest": "1bab0b9cab8028a95ce7231ad8d88ebcd31601cfa321284bba017ead47f6c729"
},
"flag_kr": {
"category": "flags",
"moji": "🇰🇷",
+ "description": "korea",
"unicodeVersion": "6.0",
"digest": "33be8c09ebe273e203aa703cc827d52a6d9bf1699f5445bba13a77af2df45fa6"
},
"flag_kw": {
"category": "flags",
"moji": "🇰🇼",
+ "description": "kuwait",
"unicodeVersion": "6.0",
"digest": "04d901a92ea55b13dc4983a9e3adb52dc89c9f3decee86fd06022aa902678b6d"
},
"flag_ky": {
"category": "flags",
"moji": "🇰🇾",
+ "description": "cayman islands",
"unicodeVersion": "6.0",
"digest": "10f4d02f33cadd34da89de71a3b763809bad480cd9ae9d2ec000db026bd94cd1"
},
"flag_kz": {
"category": "flags",
"moji": "🇰🇿",
+ "description": "kazakhstan",
"unicodeVersion": "6.0",
"digest": "dfaff69a78cf635f7fad41bd5bdcc8003298454708a6178ba7348b1b40c360c1"
},
"flag_la": {
"category": "flags",
"moji": "🇱🇦",
+ "description": "laos",
"unicodeVersion": "6.0",
"digest": "4fcfbdc694cf99ae3f832500cdcdedb88c444b6df88bc9b7141f4f26ba3d5bfd"
},
"flag_lb": {
"category": "flags",
"moji": "🇱🇧",
+ "description": "lebanon",
"unicodeVersion": "6.0",
"digest": "af4b1f784bea0ec7a712495491dffbd1152cc857a99fd433f76bfeb313819a62"
},
"flag_lc": {
"category": "flags",
"moji": "🇱🇨",
+ "description": "saint lucia",
"unicodeVersion": "6.0",
"digest": "40784aa558b75d07ae499c004e2cc5d0b2efdfc3e5be705b5a9f6b70d681c396"
},
"flag_li": {
"category": "flags",
"moji": "🇱🇮",
+ "description": "liechtenstein",
"unicodeVersion": "6.0",
"digest": "c4eb4c43f457ce60ff9d046adb512c1d3462203403eeb595bff3ebc010ed6633"
},
"flag_lk": {
"category": "flags",
"moji": "🇱🇰",
+ "description": "sri lanka",
"unicodeVersion": "6.0",
"digest": "a5285cdfdc3715fa3941f5f0eb03dc425969eaaf22c719c27ab4418628d09bc5"
},
"flag_lr": {
"category": "flags",
"moji": "🇱🇷",
+ "description": "liberia",
"unicodeVersion": "6.0",
"digest": "ed04334264953b4da570db8c392b99d2fab4e0b7efc2331427016c6a08e818be"
},
"flag_ls": {
"category": "flags",
"moji": "🇱🇸",
+ "description": "lesotho",
"unicodeVersion": "6.0",
"digest": "cd56022106d027317cc9bf4c848758cf29ffe277ce71fdb9c1cf89ac4fd6e6db"
},
"flag_lt": {
"category": "flags",
"moji": "🇱🇹",
+ "description": "lithuania",
"unicodeVersion": "6.0",
"digest": "3c4395b068e421100fd97a102f170cb8d5c093885eef7cb40d3faff4f4e47fe9"
},
"flag_lu": {
"category": "flags",
"moji": "🇱🇺",
+ "description": "luxembourg",
"unicodeVersion": "6.0",
"digest": "df15a2c47eecad17e0cc169bdf0d31c6a51eb22de7ca4e70d2431359a33f930d"
},
"flag_lv": {
"category": "flags",
"moji": "🇱🇻",
+ "description": "latvia",
"unicodeVersion": "6.0",
"digest": "9b53c6ce23287935200da8ca8a8af78013a4b1572f9821e7e1724cbad248e7e2"
},
"flag_ly": {
"category": "flags",
"moji": "🇱🇾",
+ "description": "libya",
"unicodeVersion": "6.0",
"digest": "42efa9f3526ef006d6723fa17538a98ab9556ae25f14df1b06d21361bf7e1a44"
},
"flag_ma": {
"category": "flags",
"moji": "🇲🇦",
+ "description": "morocco",
"unicodeVersion": "6.0",
"digest": "96c07296cfd7aa1cb642faed8ace26744105b81ca880157a4ef4caee0befe26e"
},
"flag_mc": {
"category": "flags",
"moji": "🇲🇨",
+ "description": "monaco",
"unicodeVersion": "6.0",
"digest": "6b44608842fe849ae2b4bae5eb87ccd436459a427051dfda25080196273d4b9f"
},
"flag_md": {
"category": "flags",
"moji": "🇲🇩",
+ "description": "moldova",
"unicodeVersion": "6.0",
"digest": "78c7b01c698873a9129d52ba38b3eb4cfc683ef2ae10b7b922b17c07f1c938c8"
},
"flag_me": {
"category": "flags",
"moji": "🇲🇪",
+ "description": "montenegro",
"unicodeVersion": "6.0",
"digest": "01aa0f9df89302edc4ae319b5dd78069ba8807c3f38cc7bfe01bff67c8efd416"
},
"flag_mf": {
"category": "flags",
"moji": "🇲🇫",
+ "description": "saint martin",
"unicodeVersion": "6.0",
"digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9"
},
"flag_mg": {
"category": "flags",
"moji": "🇲🇬",
+ "description": "madagascar",
"unicodeVersion": "6.0",
"digest": "56ebcd2a2e144d656d3b38a62595138fe6e50f9c1144f70b0a120cce7a72eb5b"
},
"flag_mh": {
"category": "flags",
"moji": "🇲🇭",
+ "description": "the marshall islands",
"unicodeVersion": "6.0",
"digest": "008660adc4c2e4d04830498988184d1ef8a372a6c085da369a94ee6b820dbbb7"
},
"flag_mk": {
"category": "flags",
"moji": "🇲🇰",
+ "description": "macedonia",
"unicodeVersion": "6.0",
"digest": "f3c4c5106ace81c21fc0c6a7cc5c5e04e9453468fbc6ccbc851bb8dd61ff237f"
},
"flag_ml": {
"category": "flags",
"moji": "🇲🇱",
+ "description": "mali",
"unicodeVersion": "6.0",
"digest": "e70a6b30e46adc2e19684308a848fef2c3ad76e2cac4bb493ee3270ad39f9d1b"
},
"flag_mm": {
"category": "flags",
"moji": "🇲🇲",
+ "description": "myanmar",
"unicodeVersion": "6.0",
"digest": "720f5d38887202ba049cd5a46c183679be6a01f169d99e6e656c73b515793a7d"
},
"flag_mn": {
"category": "flags",
"moji": "🇲🇳",
+ "description": "mongolia",
"unicodeVersion": "6.0",
"digest": "5f0fd6fcb2ed73a5a6d9396c3703612503c1f16283bbb4e9362a1c8324b762ad"
},
"flag_mo": {
"category": "flags",
"moji": "🇲🇴",
+ "description": "macau",
"unicodeVersion": "6.0",
"digest": "fc2a9e7323867cf195f551e59afdab778c56b84c96af28c20207c9870caa2c39"
},
"flag_mp": {
"category": "flags",
"moji": "🇲🇵",
+ "description": "northern mariana islands",
"unicodeVersion": "6.0",
"digest": "ddce3be9d72914240c42e1b97ea97af01016d0a3879999cb0e447552682c06ba"
},
"flag_mq": {
"category": "flags",
"moji": "🇲🇶",
+ "description": "martinique",
"unicodeVersion": "6.0",
"digest": "888f455b1322d6fb83dc9f469f5505fea3dd6ece77d17d0d7345319c3ebcec0e"
},
"flag_mr": {
"category": "flags",
"moji": "🇲🇷",
+ "description": "mauritania",
"unicodeVersion": "6.0",
"digest": "72621914c92dd9c9f3ac9973ee3589583bfe42b841cdd35f47af75e2f629726c"
},
"flag_ms": {
"category": "flags",
"moji": "🇲🇸",
+ "description": "montserrat",
"unicodeVersion": "6.0",
"digest": "5944996295132f41ec55261ff7927518bd47aec95d274a6ff257c357b43657bc"
},
"flag_mt": {
"category": "flags",
"moji": "🇲🇹",
+ "description": "malta",
"unicodeVersion": "6.0",
"digest": "95f0550e8823441a4e69b26c540baea94f3ddcc282100fd0239021c00df0b469"
},
"flag_mu": {
"category": "flags",
"moji": "🇲🇺",
+ "description": "mauritius",
"unicodeVersion": "6.0",
"digest": "5fda78a6df0ea7f5cac5fb4c8fd68529c14c5e15bac4e0b167493cb6ac459253"
},
"flag_mv": {
"category": "flags",
"moji": "🇲🇻",
+ "description": "maldives",
"unicodeVersion": "6.0",
"digest": "f75c8f6fd3a68f2944a04c833c649d4b576997f491100cf3f3160fe77117fabb"
},
"flag_mw": {
"category": "flags",
"moji": "🇲🇼",
+ "description": "malawi",
"unicodeVersion": "6.0",
"digest": "d46b484a97e5b90b6b259f8de1712b553f93f0dfb6391209200358bb9429ebf5"
},
"flag_mx": {
"category": "flags",
"moji": "🇲🇽",
+ "description": "mexico",
"unicodeVersion": "6.0",
"digest": "dc57c10307fc0aa09bd7fcd25ee0fca561f3b382276faa8432a927c1baea53fd"
},
"flag_my": {
"category": "flags",
"moji": "🇲🇾",
+ "description": "malaysia",
"unicodeVersion": "6.0",
"digest": "15ca00660a1eb0096fdaa00b85a7b95fcf192bf2ee4781ba72c36d2d2cb015ef"
},
"flag_mz": {
"category": "flags",
"moji": "🇲🇿",
+ "description": "mozambique",
"unicodeVersion": "6.0",
"digest": "0c8605a9319dcf86672a833b4c4d6acea5f6aa25a3f8e1dfac78fbf7c452ba97"
},
"flag_na": {
"category": "flags",
"moji": "🇳🇦",
+ "description": "namibia",
"unicodeVersion": "6.0",
"digest": "e63cde5ee49d3ada1e33d2ab15dc24fbb129b90d65b6fd1d7c07455f71a53601"
},
"flag_nc": {
"category": "flags",
"moji": "🇳🇨",
+ "description": "new caledonia",
"unicodeVersion": "6.0",
"digest": "a4a350ce7404ba7bdda9a341e7a48fcfe16312be4964b1bd6eed7115acd2e329"
},
"flag_ne": {
"category": "flags",
"moji": "🇳🇪",
+ "description": "niger",
"unicodeVersion": "6.0",
"digest": "6b32483b4445bc52855509f618c570b9c9606de5649e4878b71b44ff2acbc9fd"
},
"flag_nf": {
"category": "flags",
"moji": "🇳🇫",
+ "description": "norfolk island",
"unicodeVersion": "6.0",
"digest": "96b1ec33acbd2b1ffe42703c11a2a633b036e6779849b0e6fa8f399167820584"
},
"flag_ng": {
"category": "flags",
"moji": "🇳🇬",
+ "description": "nigeria",
"unicodeVersion": "6.0",
"digest": "f97d0630cbfa5e75440251df7529a67b58c22598643390cbeea82fb04a1cd956"
},
"flag_ni": {
"category": "flags",
"moji": "🇳🇮",
+ "description": "nicaragua",
"unicodeVersion": "6.0",
"digest": "c52fb5f9134122a91defa75425be2c6b3c909e051d546244e0e7bdf5f9ee1710"
},
"flag_nl": {
"category": "flags",
"moji": "🇳🇱",
+ "description": "the netherlands",
"unicodeVersion": "6.0",
"digest": "b8918f9c0c92513aa0ec6ba6cee5448270168cbe6f0a970fb06e7ceb9f52ec71"
},
"flag_no": {
"category": "flags",
"moji": "🇳🇴",
+ "description": "norway",
"unicodeVersion": "6.0",
"digest": "05ce84095f8d93407d611b39d8b6a67fd9f11df6cfab7a185bcb4eec186d85ef"
},
"flag_np": {
"category": "flags",
"moji": "🇳🇵",
+ "description": "nepal",
"unicodeVersion": "6.0",
"digest": "cc41c2f97ec2b38fe5781d553792f6aab5d37cc3be02586f361fe89d12683bee"
},
"flag_nr": {
"category": "flags",
"moji": "🇳🇷",
+ "description": "nauru",
"unicodeVersion": "6.0",
"digest": "7837edf59ec33a25380d76afea5f04cfcab4f17df4e33fca0dcaacb517c5cbec"
},
"flag_nu": {
"category": "flags",
"moji": "🇳🇺",
+ "description": "niue",
"unicodeVersion": "6.0",
"digest": "fd9ab45c6f32bc4da47542392e5beba73ddac302a4a9a00e6deedc913a4c087d"
},
"flag_nz": {
"category": "flags",
"moji": "🇳🇿",
+ "description": "new zealand",
"unicodeVersion": "6.0",
"digest": "0719830dcca400cefb30ce399bb03f49dd84c9a98f7d6a28270f9278e2a7af75"
},
"flag_om": {
"category": "flags",
"moji": "🇴🇲",
+ "description": "oman",
"unicodeVersion": "6.0",
"digest": "3f9039becd52e3454fdf7611cdb0d7fb1196e053eea29ef87daab6c21a94f1ee"
},
"flag_pa": {
"category": "flags",
"moji": "🇵🇦",
+ "description": "panama",
"unicodeVersion": "6.0",
"digest": "1adf0e5d4084e072aa44bd9978829e77546e0be75785e9be69f92e326bd714a7"
},
"flag_pe": {
"category": "flags",
"moji": "🇵🇪",
+ "description": "peru",
"unicodeVersion": "6.0",
"digest": "f8a4e257676f4ab8962ffe5509b8417777a8be2f0e9dc7735d3e014ff221aab1"
},
"flag_pf": {
"category": "flags",
"moji": "🇵🇫",
+ "description": "french polynesia",
"unicodeVersion": "6.0",
"digest": "1ace6cc71d130cdf09246297740a911f14828c322e35330cc548ca5975015c23"
},
"flag_pg": {
"category": "flags",
"moji": "🇵🇬",
+ "description": "papua new guinea",
"unicodeVersion": "6.0",
"digest": "9c37719d9f51ef31fec0f898d38e522b4253cd00344408e3f660132514efddb7"
},
"flag_ph": {
"category": "flags",
"moji": "🇵🇭",
+ "description": "the philippines",
"unicodeVersion": "6.0",
"digest": "f1af628cf6d1d290cedef3d564b2386e2d6f14ba4426d3fefc0312cb8772e517"
},
"flag_pk": {
"category": "flags",
"moji": "🇵🇰",
+ "description": "pakistan",
"unicodeVersion": "6.0",
"digest": "61c77f73d2a10a5acb289fadfe0d25d1a1c343e1223bd802099ff4e0e9356521"
},
"flag_pl": {
"category": "flags",
"moji": "🇵🇱",
+ "description": "poland",
"unicodeVersion": "6.0",
"digest": "38c2c8618446e1f72cf983ab33e736d943f0db7c4cce52a187299e8cec2ea895"
},
"flag_pm": {
"category": "flags",
"moji": "🇵🇲",
+ "description": "saint pierre and miquelon",
"unicodeVersion": "6.0",
"digest": "656be9ea1a79c3885a759c7ce353d338345a198d7939556949affaf5490cb644"
},
"flag_pn": {
"category": "flags",
"moji": "🇵🇳",
+ "description": "pitcairn",
"unicodeVersion": "6.0",
"digest": "2792260d8087ab0253b1214c1420f0160ab2eef9afe7315f9e7ff0b87cd15d72"
},
"flag_pr": {
"category": "flags",
"moji": "🇵🇷",
+ "description": "puerto rico",
"unicodeVersion": "6.0",
"digest": "c4cfa1f2201dcda9de310a8247e6ce32d2798ae426a14dd70a9ebb00a2804d46"
},
"flag_ps": {
"category": "flags",
"moji": "🇵🇸",
+ "description": "palestinian authority",
"unicodeVersion": "6.0",
"digest": "197f2ec6294bf0ee4a08cf2f2d1e237ee867c98b3085454a3f42abc955eeb289"
},
"flag_pt": {
"category": "flags",
"moji": "🇵🇹",
+ "description": "portugal",
"unicodeVersion": "6.0",
"digest": "86a50827963756b5bf471ed9df5b3f2a2058b4c5d778a303414b6b0556e2082b"
},
"flag_pw": {
"category": "flags",
"moji": "🇵🇼",
+ "description": "palau",
"unicodeVersion": "6.0",
"digest": "a6321c47a0cd188fbfdf3b55f17a7170c63080d28d50e4f5463eb1ee09af2412"
},
"flag_py": {
"category": "flags",
"moji": "🇵🇾",
+ "description": "paraguay",
"unicodeVersion": "6.0",
"digest": "1a169e8d8703c510c5a2265b57dbed2f811b03ec375bcb341ab4cd0b100a9dd6"
},
"flag_qa": {
"category": "flags",
"moji": "🇶🇦",
+ "description": "qatar",
"unicodeVersion": "6.0",
"digest": "de6283965cd98a244b7fa6288174f9ff0d8feb497f191f2e4ab3b690138a3d5d"
},
"flag_re": {
"category": "flags",
"moji": "🇷🇪",
+ "description": "réunion",
"unicodeVersion": "6.0",
"digest": "260e1b97abc1562e5a73d7e53652ffed8059fc9b1c969741c466f48ec6ab0e80"
},
"flag_ro": {
"category": "flags",
"moji": "🇷🇴",
+ "description": "romania",
"unicodeVersion": "6.0",
"digest": "6d648e03955fa2a9fd2bad6f60ec96d3e20ee57f5855f3721a4d4e0c8e99f95c"
},
"flag_rs": {
"category": "flags",
"moji": "🇷🇸",
+ "description": "serbia",
"unicodeVersion": "6.0",
"digest": "95cd5e197ed364e403eeb7f1d18a83487d89166910ba8119ea994e5e19d6a7ee"
},
"flag_ru": {
"category": "flags",
"moji": "🇷🇺",
+ "description": "russia",
"unicodeVersion": "6.0",
"digest": "a4a81617a59d9eaf3c526431ca6f90ed334a7c1f516bf70cbd3f1fdc6e6103d7"
},
"flag_rw": {
"category": "flags",
"moji": "🇷🇼",
+ "description": "rwanda",
"unicodeVersion": "6.0",
"digest": "7a369f60db0876ffef111c319a3e8c9eaed620c875c51b98ed9ad5207b836dca"
},
"flag_sa": {
"category": "flags",
"moji": "🇸🇦",
+ "description": "saudi arabia",
"unicodeVersion": "6.0",
"digest": "b249fbfd7ed415943f60bbd841965cf721979f960ccbe09396aebac1eca913d7"
},
"flag_sb": {
"category": "flags",
"moji": "🇸🇧",
+ "description": "the solomon islands",
"unicodeVersion": "6.0",
"digest": "526b411260024ea7b6ea6c47f2549345c6cc6960e9a29bfa9aaec0772664d2dc"
},
"flag_sc": {
"category": "flags",
"moji": "🇸🇨",
+ "description": "the seychelles",
"unicodeVersion": "6.0",
"digest": "d036b0d068745926120eaf746fa2e4433306e2e14c6b540d0cd6265e34471056"
},
"flag_sd": {
"category": "flags",
"moji": "🇸🇩",
+ "description": "sudan",
"unicodeVersion": "6.0",
"digest": "889615bdb9b1f9c59c5f83ed4d22d54a0ed5dd5de263e729c58544cb06c55885"
},
"flag_se": {
"category": "flags",
"moji": "🇸🇪",
+ "description": "sweden",
"unicodeVersion": "6.0",
"digest": "f471d80cfff340960a752c8c152ed4fb482df2a3712b0a56dfab31b9b806926a"
},
"flag_sg": {
"category": "flags",
"moji": "🇸🇬",
+ "description": "singapore",
"unicodeVersion": "6.0",
"digest": "82f58a09f98593cc87e545f7e5c03d2aedaf82e54e73f71f58c18e994c3085ac"
},
"flag_sh": {
"category": "flags",
"moji": "🇸🇭",
+ "description": "saint helena",
"unicodeVersion": "6.0",
"digest": "53914b1fa8c1b4f30bae6c1f6717f138fb4dbf482c3e20e33f7aea4ecfc0438d"
},
"flag_si": {
"category": "flags",
"moji": "🇸🇮",
+ "description": "slovenia",
"unicodeVersion": "6.0",
"digest": "65d491daa69f9a11cec9ccc4df3a669f12ef95a5c312137776d4472719940ba3"
},
"flag_sj": {
"category": "flags",
"moji": "🇸🇯",
+ "description": "svalbard and jan mayen",
"unicodeVersion": "6.0",
"digest": "bbf6daa6174c6fbbbf541c8274f31b6757c3a16007c2687015ea041fd1e2c6b6"
},
"flag_sk": {
"category": "flags",
"moji": "🇸🇰",
+ "description": "slovakia",
"unicodeVersion": "6.0",
"digest": "d4fd03eca5bd3c9fb324ee04fae37c9a2d852bac8335369e3e720ef9b98fff36"
},
"flag_sl": {
"category": "flags",
"moji": "🇸🇱",
+ "description": "sierra leone",
"unicodeVersion": "6.0",
"digest": "1455c98c11c248623d82be5484ab1c4dcd1dae449adc393eb1aa2d8c74aa3f02"
},
"flag_sm": {
"category": "flags",
"moji": "🇸🇲",
+ "description": "san marino",
"unicodeVersion": "6.0",
"digest": "daec5864ac50c625d7bf49d6c1a170a094cf0d1b9a0bdf62a62406e7ec500a94"
},
"flag_sn": {
"category": "flags",
"moji": "🇸🇳",
+ "description": "senegal",
"unicodeVersion": "6.0",
"digest": "4e4d43c467e5eb84c70f535f37f4f468319bd4b06c6ec3db3b54f69efdafd334"
},
"flag_so": {
"category": "flags",
"moji": "🇸🇴",
+ "description": "somalia",
"unicodeVersion": "6.0",
"digest": "c1434dca361563a8e3ba88f1ad19c3f6c9cbb8f3ebc17ce128fde2351ff67d0c"
},
"flag_sr": {
"category": "flags",
"moji": "🇸🇷",
+ "description": "suriname",
"unicodeVersion": "6.0",
"digest": "f3c6bfee2a052f03d56ba917b88595450cef111ffa9e92c7f39ef8c3c3bd12d1"
},
"flag_ss": {
"category": "flags",
"moji": "🇸🇸",
+ "description": "south sudan",
"unicodeVersion": "6.0",
"digest": "c0ed7e4f41206f5363e8ebdc6c3f28080e2f07d99e6fb73c1f6226d83310e69d"
},
"flag_st": {
"category": "flags",
"moji": "🇸🇹",
+ "description": "sao tome and principe",
"unicodeVersion": "6.0",
"digest": "b022ae5d6885e28c6e9c83c17dd0c24c731d4f3d5773c49051768cdd4df51330"
},
"flag_sv": {
"category": "flags",
"moji": "🇸🇻",
+ "description": "el salvador",
"unicodeVersion": "6.0",
"digest": "5bafdd04d243ee3f3998f4ec0a3d03ff5a3975e771b1f94f89d7713193d7a242"
},
"flag_sx": {
"category": "flags",
"moji": "🇸🇽",
+ "description": "sint maarten",
"unicodeVersion": "6.0",
"digest": "fb92e9f514bcc2f7abbd4e146edde50f030c940c833f184618cbb48e56af22bd"
},
"flag_sy": {
"category": "flags",
"moji": "🇸🇾",
+ "description": "syria",
"unicodeVersion": "6.0",
"digest": "ee330da644d4ce1fdba98be5eaab5054aed8d91a34ab617199a4b2b77f62a10b"
},
"flag_sz": {
"category": "flags",
"moji": "🇸🇿",
+ "description": "swaziland",
"unicodeVersion": "6.0",
"digest": "7fe0c7429efd9682cc39e57f4bba8d1491d301643ba999d57c4e1bc37517ed64"
},
"flag_ta": {
"category": "flags",
"moji": "🇹🇦",
+ "description": "tristan da cunha",
"unicodeVersion": "6.0",
"digest": "b47e245a2708072a4dbaf190c9606baa4daf02e51627eeae6f20c3b4c95024c0"
},
"flag_tc": {
"category": "flags",
"moji": "🇹🇨",
+ "description": "turks and caicos islands",
"unicodeVersion": "6.0",
"digest": "18cfff14c2503b9d24c91c668583d4a14efb17657d800eca86ae49b547c9da5c"
},
"flag_td": {
"category": "flags",
"moji": "🇹🇩",
+ "description": "chad",
"unicodeVersion": "6.0",
"digest": "73d1db3365736915c4cdf9ba9343d9fd78962203b60334e8f3724d4b330b17db"
},
"flag_tf": {
"category": "flags",
"moji": "🇹🇫",
+ "description": "french southern territories",
"unicodeVersion": "6.0",
"digest": "3bffeb4bc9ceb9cbb150de88e957b6e46509862ca7d616d5693124af084eb435"
},
"flag_tg": {
"category": "flags",
"moji": "🇹🇬",
+ "description": "togo",
"unicodeVersion": "6.0",
"digest": "eb13a0e85baf73326f3ae3bc75e8406eca42000d7e42b0641120e64c0ab7ebaa"
},
"flag_th": {
"category": "flags",
"moji": "🇹🇭",
+ "description": "thailand",
"unicodeVersion": "6.0",
"digest": "a4e42efa4bb94e90f3a92ae9ce14affaacd3a142c1e0da40d8cc839500e771fd"
},
"flag_tj": {
"category": "flags",
"moji": "🇹🇯",
+ "description": "tajikistan",
"unicodeVersion": "6.0",
"digest": "ff926fa3e86e095683a61c4754355a5b4dd0ecb74393306bd791d130fd1a909d"
},
"flag_tk": {
"category": "flags",
"moji": "🇹🇰",
+ "description": "tokelau",
"unicodeVersion": "6.0",
"digest": "3fa732d457ded6c83cd5f73d934f64c4e687eb0cde7c157d2fdcdccaf3b5fb52"
},
"flag_tl": {
"category": "flags",
"moji": "🇹🇱",
+ "description": "east timor",
"unicodeVersion": "6.0",
"digest": "0ec2a4d22fb832060693089e518bbe370a4e13bfc28748f110fc13726409f473"
},
"flag_tm": {
"category": "flags",
"moji": "🇹🇲",
+ "description": "turkmenistan",
"unicodeVersion": "6.0",
"digest": "b4724aa7ad13352f16a0936e61cbb85f0bd147583fc66597aff7e8ee7cf19c21"
},
"flag_tn": {
"category": "flags",
"moji": "🇹🇳",
+ "description": "tunisia",
"unicodeVersion": "6.0",
"digest": "5ab308ffdde40f504d6ee080817bbddbe4f3f4ddb71f508c75e0144a8c8044d9"
},
"flag_to": {
"category": "flags",
"moji": "🇹🇴",
+ "description": "tonga",
"unicodeVersion": "6.0",
"digest": "75b7e7198fa42f87986882b8ca251a229afcaa0a1188ae7b9f5ece87dc31a723"
},
"flag_tr": {
"category": "flags",
"moji": "🇹🇷",
+ "description": "turkey",
"unicodeVersion": "6.0",
"digest": "9cc48a8f8fa9c17c1627272f68d4740da0e7ce17a2cf8c6b5c08cc9b95e1390c"
},
"flag_tt": {
"category": "flags",
"moji": "🇹🇹",
+ "description": "trinidad and tobago",
"unicodeVersion": "6.0",
"digest": "f9e63543121bb3cd2e41bc7b0c2c4ba662bc1cc0520b79fc4e201ec6456fdf59"
},
"flag_tv": {
"category": "flags",
"moji": "🇹🇻",
+ "description": "tuvalu",
"unicodeVersion": "6.0",
"digest": "6431e5f06cc7995ae7208c429ecf39339b545854cb6d6b7447f465fe53614dfc"
},
"flag_tw": {
"category": "flags",
"moji": "🇹🇼",
+ "description": "the republic of china",
"unicodeVersion": "6.0",
"digest": "8395ab3c6a595023b006518a5345ac3612f2893d3a8f011b7e5802414236b03c"
},
"flag_tz": {
"category": "flags",
"moji": "🇹🇿",
+ "description": "tanzania",
"unicodeVersion": "6.0",
"digest": "716181733cd9ac7a8f51a9a64bc5d21020e8112f6768e8c49c4d651a3ee0b8a4"
},
"flag_ua": {
"category": "flags",
"moji": "🇺🇦",
+ "description": "ukraine",
"unicodeVersion": "6.0",
"digest": "304570736345e28734f5ff84a2b0481c2bb00bf29d9892bd749b57dec7741e30"
},
"flag_ug": {
"category": "flags",
"moji": "🇺🇬",
+ "description": "uganda",
"unicodeVersion": "6.0",
"digest": "a1bafb74c54ee8c92cb025b55aebdb6081eec3fda6a7f86f2ee14d1b801a8e9c"
},
"flag_um": {
"category": "flags",
"moji": "🇺🇲",
+ "description": "united states minor outlying islands",
"unicodeVersion": "6.0",
"digest": "b3c9ac72211f481f50cde09e10b92aa03b1ea90abf85418e60a35b84963273ee"
},
"flag_us": {
"category": "flags",
"moji": "🇺🇸",
+ "description": "united states",
"unicodeVersion": "6.0",
"digest": "da79f9af0a188178a82e7dc3a62298fa416f4cfbcae432838df1abebca5c0d63"
},
"flag_uy": {
"category": "flags",
"moji": "🇺🇾",
+ "description": "uruguay",
"unicodeVersion": "6.0",
"digest": "8348e901d775722497ee911c9c9b4bd767710760c507630a67ecb6d47cc646c7"
},
"flag_uz": {
"category": "flags",
"moji": "🇺🇿",
+ "description": "uzbekistan",
"unicodeVersion": "6.0",
"digest": "2a1dc1e9469e01c58ea91f545ef3fe0bdfe5544a73a80407f8960d01b1e5db5c"
},
"flag_va": {
"category": "flags",
"moji": "🇻🇦",
+ "description": "the vatican city",
"unicodeVersion": "6.0",
"digest": "0e8134ec94bff032bfc63b0b08587d5298c9b7f31edd5a5b35633ae911434e61"
},
"flag_vc": {
"category": "flags",
"moji": "🇻🇨",
+ "description": "saint vincent and the grenadines",
"unicodeVersion": "6.0",
"digest": "e0290e1be72c8939ee6c398f00a107703b21b97d91b9bf465e553ffbf00304a7"
},
"flag_ve": {
"category": "flags",
"moji": "🇻🇪",
+ "description": "venezuela",
"unicodeVersion": "6.0",
"digest": "76a6a6c2353def1f984d1a6980831e63f3aea5af2201b574197834e7c203d57a"
},
"flag_vg": {
"category": "flags",
"moji": "🇻🇬",
+ "description": "british virgin islands",
"unicodeVersion": "6.0",
"digest": "56fc9317b8dd62cccc60010819f8b895dd4569a9b06368a9250f815c39177b8a"
},
"flag_vi": {
"category": "flags",
"moji": "🇻🇮",
+ "description": "u.s. virgin islands",
"unicodeVersion": "6.0",
"digest": "2526a3e13b8ccd301f0763580430898c227bd209e3ce482c7951140b28948375"
},
"flag_vn": {
"category": "flags",
"moji": "🇻🇳",
+ "description": "vietnam",
"unicodeVersion": "6.0",
"digest": "0cf6b9896bbe4da8ed7718d0abfd56cef1a8321e26f89d3ad1b48488eaffb7a5"
},
"flag_vu": {
"category": "flags",
"moji": "🇻🇺",
+ "description": "vanuatu",
"unicodeVersion": "6.0",
"digest": "9dfa282ce1aafc62beacab76e1fc19a141c8bdeaa30898f69b083067b775d362"
},
"flag_wf": {
"category": "flags",
"moji": "🇼🇫",
+ "description": "wallis and futuna",
"unicodeVersion": "6.0",
"digest": "a0124683aa88cd7da886da70c65796c5ad84eb3751e356e9b2aa8ac249cf0bf9"
},
"flag_white": {
"category": "objects",
"moji": "ðŸ³",
+ "description": "waving white flag",
"unicodeVersion": "6.0",
"digest": "d9be4b7ceb8309c48f88cfd07a9f7ce6758ea6e620e73293cf14baec03ca381c"
},
"flag_ws": {
"category": "flags",
"moji": "🇼🇸",
+ "description": "samoa",
"unicodeVersion": "6.0",
"digest": "53addd0dc304a3c8893389ed227986ef2431828b8c071926aa09f9efd815b649"
},
"flag_xk": {
"category": "flags",
"moji": "🇽🇰",
+ "description": "kosovo",
"unicodeVersion": "6.0",
"digest": "eba1a832e489e1c2734e773e685df5d128271fa5559d23c060e68be067bf6469"
},
"flag_ye": {
"category": "flags",
"moji": "🇾🇪",
+ "description": "yemen",
"unicodeVersion": "6.0",
"digest": "edfa14266785042b6d5fe0f64fafa630b16a3ee7d010501de7cc8554c959afb0"
},
"flag_yt": {
"category": "flags",
"moji": "🇾🇹",
+ "description": "mayotte",
"unicodeVersion": "6.0",
"digest": "472ebc676b5d31dec2ac5e02ce69014a3dd94609d30a95f39f3a752f49c85e8b"
},
"flag_za": {
"category": "flags",
"moji": "🇿🇦",
+ "description": "south africa",
"unicodeVersion": "6.0",
"digest": "dad162942a43392b4cff6929bd5cbf58c382a03dbc0e552f03c07ad2d8ff08ce"
},
"flag_zm": {
"category": "flags",
"moji": "🇿🇲",
+ "description": "zambia",
"unicodeVersion": "6.0",
"digest": "1521ecaf1d1fdc8c15f0c96a6b04e6d4050f26f943a826b3d3d661f6ded6d438"
},
"flag_zw": {
"category": "flags",
"moji": "🇿🇼",
+ "description": "zimbabwe",
"unicodeVersion": "6.0",
"digest": "46d05b597c5c77c8e2dc7bd6d8dd62ebca01bc9c9dc9915dafe694ca56402825"
},
"flags": {
"category": "objects",
"moji": "ðŸŽ",
+ "description": "carp streamer",
"unicodeVersion": "6.0",
"digest": "f860aa4df587cf140c3e9735bbd101e9fd5a1bfcea42e420d85ac0a9877fa21d"
},
"flashlight": {
"category": "objects",
"moji": "🔦",
+ "description": "electric torch",
"unicodeVersion": "6.0",
"digest": "e929bbe76e0fd2dc5bd6476858a0bbc717fd21467710435d35d80efb38033d73"
},
"fleur-de-lis": {
"category": "symbols",
"moji": "⚜",
+ "description": "fleur-de-lis",
"unicodeVersion": "4.1",
"digest": "ebf49007f367dc05580e9dab942e93e9dda12fa1dc2caa410ac7f8d8cd55d2a3"
},
"floppy_disk": {
"category": "objects",
"moji": "💾",
+ "description": "floppy disk",
"unicodeVersion": "6.0",
"digest": "4ee0b5bba41b9e301ed125d3ee1c263bef171ca499e6e1b89276b09af2bc03a0"
},
"flower_playing_cards": {
"category": "symbols",
"moji": "🎴",
+ "description": "flower playing cards",
"unicodeVersion": "6.0",
"digest": "edba47c2e3051b2c7effd98794ec977174052782edcb491daec82a2b0d853869"
},
"flushed": {
"category": "people",
"moji": "😳",
+ "description": "flushed face",
"unicodeVersion": "6.0",
"digest": "e759d46bab92af5494d78b6c712c06568759afe397e7828ca0a0de1e3eab0165"
},
"fog": {
"category": "nature",
"moji": "🌫",
+ "description": "fog",
"unicodeVersion": "7.0",
"digest": "0cbd4733961d30fe0f40f95dd1f37254aebbef26f82dd18ad2000e799eb2898e"
},
"foggy": {
"category": "travel",
"moji": "ðŸŒ",
+ "description": "foggy",
"unicodeVersion": "6.0",
"digest": "bc3631a4e9e8473b92e842008937add2cd9ffad5b7d772ce759fb5ff6c0e3dca"
},
"football": {
"category": "activity",
"moji": "ðŸˆ",
+ "description": "american football",
"unicodeVersion": "6.0",
"digest": "ebd790471c3a28d3077818e3b31d915ffe443e06e299bc5cf0dd2534d080634c"
},
"footprints": {
"category": "people",
"moji": "👣",
+ "description": "footprints",
"unicodeVersion": "6.0",
"digest": "85bbf2bc0ae8e6259d83a06f513600095d7fcfc44372670f5b2405d380b78811"
},
"fork_and_knife": {
"category": "food",
"moji": "ðŸ´",
+ "description": "fork and knife",
"unicodeVersion": "6.0",
"digest": "f228accd36ddccb4ec636207c19d7185191ec79723b780a1bd5c3d00a4b1ef3b"
},
"fork_knife_plate": {
"category": "food",
"moji": "ðŸ½",
+ "description": "fork and knife with plate",
"unicodeVersion": "7.0",
"digest": "ec6be99dac8efd3d145807fa60d2b6d8f6d3c02cb95552b55cc0fac39a4db48e"
},
"fountain": {
"category": "travel",
"moji": "⛲",
+ "description": "fountain",
"unicodeVersion": "5.2",
"digest": "87043f9256e1d4615159307fcfd21bf6ae2aba0bada7de2bd50d7d6f2ab82395"
},
"four": {
"category": "symbols",
"moji": "4ï¸âƒ£",
+ "description": "keycap digit four",
"unicodeVersion": "3.0",
"digest": "c2c82a966bbb599aae557d930a4fc42604f2081aa45528872f5caf4942ee79d9"
},
"four_leaf_clover": {
"category": "nature",
"moji": "ðŸ€",
+ "description": "four leaf clover",
"unicodeVersion": "6.0",
"digest": "ebee16e86bc9be843dfc72ab5372fb462f06be4486b5b25d7d4cac9b2c8b01c8"
},
"fox": {
"category": "nature",
"moji": "🦊",
+ "description": "fox face",
"unicodeVersion": "9.0",
"digest": "e9903cb0396f7e49bdd2c384b38e614c13bfa576b3ecc1ec7b9819e4a40d91d1"
},
"frame_photo": {
"category": "objects",
"moji": "🖼",
+ "description": "frame with picture",
"unicodeVersion": "7.0",
"digest": "d5074f748a15055ec1fb812c1e5e169e6e3cc73c522c54be1359b0e26c0fc75c"
},
"free": {
"category": "symbols",
"moji": "🆓",
+ "description": "squared free",
"unicodeVersion": "6.0",
"digest": "9973522457158362fc5bdd7da858e6371e28a8403d1ef9e4b6427195c7f72cfa"
},
"french_bread": {
"category": "food",
"moji": "🥖",
+ "description": "baguette bread",
"unicodeVersion": "9.0",
"digest": "47518a4312f57207b8e8c38188d4a2bd8b16830a885cfcf2d281cfab50c1bc6e"
},
"fried_shrimp": {
"category": "food",
"moji": "ðŸ¤",
+ "description": "fried shrimp",
"unicodeVersion": "6.0",
"digest": "0792bdc4484852de970c8f43bc3a1a339dc0e48090ec77d6de97cbfcdd17f9e1"
},
"fries": {
"category": "food",
"moji": "ðŸŸ",
+ "description": "french fries",
"unicodeVersion": "6.0",
"digest": "47915aea67251d358d91a0e4dc3dcc347155336007d6b931a192be72a743b4e9"
},
"frog": {
"category": "nature",
"moji": "ðŸ¸",
+ "description": "frog face",
"unicodeVersion": "6.0",
"digest": "d024b2ce771df64040534fb0906737d18b562bc3578dee62c2f25ec03c7caffd"
},
"frowning": {
"category": "people",
"moji": "😦",
+ "description": "frowning face with open mouth",
"unicodeVersion": "6.1",
"digest": "c01af48537b0011d313d8f65103e1401fce4f5c0269c68e0e9806926c59acc44"
},
"frowning2": {
"category": "people",
"moji": "☹",
+ "description": "white frowning face",
"unicodeVersion": "1.1",
"digest": "6568ee393b950c852d440112e86908c456b89fb7780e27778c5fcec168373fbf"
},
"fuelpump": {
"category": "travel",
"moji": "⛽",
+ "description": "fuel pump",
"unicodeVersion": "5.2",
"digest": "105e736469f19911b8bab4ab6d29f949ded4b061b54e3dd763726577d6453095"
},
"full_moon": {
"category": "nature",
"moji": "🌕",
+ "description": "full moon symbol",
"unicodeVersion": "6.0",
"digest": "aaa87f4676a5aaa29c1b721a3b582e89db6c1f35a25c52e4b480bd193ef39c43"
},
"full_moon_with_face": {
"category": "nature",
"moji": "ðŸŒ",
+ "description": "full moon with face",
"unicodeVersion": "6.0",
"digest": "05c4b9c339fcdf81ae67027641522baa99c370d87873ff4c8133b8349e627e33"
},
"game_die": {
"category": "activity",
"moji": "🎲",
+ "description": "game die",
"unicodeVersion": "6.0",
"digest": "00d19ce8e21dba2cdfeb18709fa8741f3af9d6207f81d5657b68e05e64f105a8"
},
"gear": {
"category": "objects",
"moji": "âš™",
+ "description": "gear",
"unicodeVersion": "4.1",
"digest": "c5ba354c0f7a36dce95477091984e352ecc59af8c9f26a94ad8e296dc042b9de"
},
"gem": {
"category": "objects",
"moji": "💎",
+ "description": "gem stone",
"unicodeVersion": "6.0",
"digest": "180e66f19d9285e02d0a5e859722c608206826e80323942b9938fc49d44973b1"
},
"gemini": {
"category": "symbols",
"moji": "♊",
+ "description": "gemini",
"unicodeVersion": "1.1",
"digest": "278239c598d490a110f1f3f52fc3b85259be8e76034b38228ef3f68d7ddd8cdd"
},
"ghost": {
"category": "people",
"moji": "👻",
+ "description": "ghost",
"unicodeVersion": "6.0",
"digest": "80d528fcf8ef9198631527547e43a608a4332a799f9e5550b8318dec67c9c4d2"
},
"gift": {
"category": "objects",
"moji": "ðŸŽ",
+ "description": "wrapped present",
"unicodeVersion": "6.0",
"digest": "4061a84a59f0300473299678c43e533341eb965db09597fffc6e221fd7b77376"
},
"gift_heart": {
"category": "symbols",
"moji": "ðŸ’",
+ "description": "heart with ribbon",
"unicodeVersion": "6.0",
"digest": "5420199b515b9b32c964a3c19d87e07461639e3068a939dae26c6436335c0cee"
},
"girl": {
"category": "people",
"moji": "👧",
+ "description": "girl",
"unicodeVersion": "6.0",
"digest": "8d2d0b72a91e6e44921b71030ffc4c89c0f50f1364787784afe1e7e568cf1bc6"
},
"girl_tone1": {
"category": "people",
"moji": "👧ðŸ»",
+ "description": "girl tone 1",
"unicodeVersion": "8.0",
"digest": "bda12a6b38994a578ee65166bbdd93ea04df4101697b52ed236de8d687df09de"
},
"girl_tone2": {
"category": "people",
"moji": "👧ðŸ¼",
+ "description": "girl tone 2",
"unicodeVersion": "8.0",
"digest": "de7a0925c30b7181a289f71b1a849c1b7751ee8c104e8f2029bd9c2fe3f91c64"
},
"girl_tone3": {
"category": "people",
"moji": "👧ðŸ½",
+ "description": "girl tone 3",
"unicodeVersion": "8.0",
"digest": "e41272816db0e642d003dce7cb262e1593a592251f46729f7830f4515149e1f2"
},
"girl_tone4": {
"category": "people",
"moji": "👧ðŸ¾",
+ "description": "girl tone 4",
"unicodeVersion": "8.0",
"digest": "8d6a4513ecbf08408c0ecc5336767777a2216f7a19437faf9e51f65101822469"
},
"girl_tone5": {
"category": "people",
"moji": "👧ðŸ¿",
+ "description": "girl tone 5",
"unicodeVersion": "8.0",
"digest": "f55e4b16a41b6f5e3c817a301420360ba4486e4e82e1092a56a3e3cc4069087d"
},
"globe_with_meridians": {
"category": "symbols",
"moji": "ðŸŒ",
+ "description": "globe with meridians",
"unicodeVersion": "6.0",
"digest": "725bebeb3c09a9e3701ebe49e672dcfbf2b73575e05f0821263511577b013b75"
},
"goal": {
"category": "activity",
"moji": "🥅",
+ "description": "goal net",
"unicodeVersion": "9.0",
"digest": "7088c432f276ff6f447dc0d431b9062b394fb401de1072fe59ca56267bfd6717"
},
"goat": {
"category": "nature",
"moji": "ðŸ",
+ "description": "goat",
"unicodeVersion": "6.0",
"digest": "d07e384d08529ddcaddd2710f2ad913e5665dc15d5f99c28e16dadd245a111e8"
},
"golf": {
"category": "activity",
"moji": "⛳",
+ "description": "flag in hole",
"unicodeVersion": "5.2",
"digest": "eed79364754eec97855e3c7b584f347ae139d9ddb4eb7fb66c00867610b8f1c1"
},
"golfer": {
"category": "activity",
"moji": "ðŸŒ",
+ "description": "golfer",
"unicodeVersion": "7.0",
"digest": "7d7ecc6e226596f646030a4109c2b0001ef0cc690e4863e450bf5d29e7a90344"
},
"gorilla": {
"category": "nature",
"moji": "ðŸ¦",
+ "description": "gorilla",
"unicodeVersion": "9.0",
"digest": "4a564dc14f8ae5450d094f6410ec7f099a7f07dc5254b6395f44a35527bdb4b7"
},
"grapes": {
"category": "food",
"moji": "ðŸ‡",
+ "description": "grapes",
"unicodeVersion": "6.0",
"digest": "74d1a09ab411234a84d025a2e717e7ec5791bc02aad29853896d21c0f0283c50"
},
"green_apple": {
"category": "food",
"moji": "ðŸ",
+ "description": "green apple",
"unicodeVersion": "6.0",
"digest": "457490e9b2b20894f50768262d63f1021717079da104d4847076b3fa779e9a21"
},
"green_book": {
"category": "objects",
"moji": "📗",
+ "description": "green book",
"unicodeVersion": "6.0",
"digest": "370f635b200efe5e4a9f17da58bd22500e258e61d17795cef375f19c9a45468f"
},
"green_heart": {
"category": "symbols",
"moji": "💚",
+ "description": "green heart",
"unicodeVersion": "6.0",
"digest": "f71e30416d9019873f2ed38ef375c48386424ff60b5a07b89b15dc9e0a3970f9"
},
"grey_exclamation": {
"category": "symbols",
"moji": "â•",
+ "description": "white exclamation mark ornament",
"unicodeVersion": "6.0",
"digest": "2fa1d356e12c17cc4025e43afb6c3070385f677102a35223302fda46c47a9b03"
},
"grey_question": {
"category": "symbols",
"moji": "â”",
+ "description": "white question mark ornament",
"unicodeVersion": "6.0",
"digest": "e1035bcbf0f66d238ef478ba451f5cf2c51627fbf101ed03bad3b2bf38db8aa2"
},
"grimacing": {
"category": "people",
"moji": "😬",
+ "description": "grimacing face",
"unicodeVersion": "6.1",
"digest": "2cedad13b8b2a1d4385ca6fa88a251eb7757a4c65dd6d362267864a01247846b"
},
"grin": {
"category": "people",
"moji": "ðŸ˜",
+ "description": "grinning face with smiling eyes",
"unicodeVersion": "6.0",
"digest": "634b2f37e32e57ed6edc7f371993a92e34137dd21ba393de5227cfbbe2422815"
},
"grinning": {
"category": "people",
"moji": "😀",
+ "description": "grinning face",
"unicodeVersion": "6.1",
"digest": "cef76aa41771db9fd1d6bd9b4233c22c1fb1931494af54cab29e6347ed9b678d"
},
"guardsman": {
"category": "people",
"moji": "💂",
+ "description": "guardsman",
"unicodeVersion": "6.0",
"digest": "17bc7fad6b8c8dbd015bb709380d129f8b8e1e971062d15e6ab0b2e63e500564"
},
"guardsman_tone1": {
"category": "people",
"moji": "💂ðŸ»",
+ "description": "guardsman tone 1",
"unicodeVersion": "8.0",
"digest": "c531ecb101bdf9ce1db18e1567882e6db927410237100b0a2492a1401860246e"
},
"guardsman_tone2": {
"category": "people",
"moji": "💂ðŸ¼",
+ "description": "guardsman tone 2",
"unicodeVersion": "8.0",
"digest": "602168c5204af0f1de8b4aa5863b192ef20c19d263999377aa5eb60f98311732"
},
"guardsman_tone3": {
"category": "people",
"moji": "💂ðŸ½",
+ "description": "guardsman tone 3",
"unicodeVersion": "8.0",
"digest": "d0a85de46dd02c7bd6cb14bff0f22d2db9083d4b171a8806c83363b49f3dd9ef"
},
"guardsman_tone4": {
"category": "people",
"moji": "💂ðŸ¾",
+ "description": "guardsman tone 4",
"unicodeVersion": "8.0",
"digest": "1c9d4d72b6b50bdac8271613b6d2a38340ec2067bc344e8ee2a3c863fd5c23a1"
},
"guardsman_tone5": {
"category": "people",
"moji": "💂ðŸ¿",
+ "description": "guardsman tone 5",
"unicodeVersion": "8.0",
"digest": "9899a796d01842e495d716fbe737a16d85724f7d3e23f50807ec2bc70f057318"
},
"guitar": {
"category": "activity",
"moji": "🎸",
+ "description": "guitar",
"unicodeVersion": "6.0",
"digest": "a1027ceae4dd3ea270740587c9d373329e5677e375c9e00af6ae3275e0b67500"
},
"gun": {
"category": "objects",
"moji": "🔫",
+ "description": "pistol",
"unicodeVersion": "6.0",
"digest": "fc12b577df2283e7b336f23774f9cfe5b79f1d26ddd28a64a560519b28d94ca5"
},
"haircut": {
"category": "people",
"moji": "💇",
+ "description": "haircut",
"unicodeVersion": "6.0",
"digest": "b243a04f5ca889accd45e7abe095ac5caa92274ed95103f5966a36b415fff412"
},
"haircut_tone1": {
"category": "people",
"moji": "💇ðŸ»",
+ "description": "haircut tone 1",
"unicodeVersion": "8.0",
"digest": "a58d0cff1427b80dfd7a9ea5267b4a181e9faaac6a51a0165db522f668b4cf91"
},
"haircut_tone2": {
"category": "people",
"moji": "💇ðŸ¼",
+ "description": "haircut tone 2",
"unicodeVersion": "8.0",
"digest": "675083ff40001405f8de99268477d50dd8594ff6ca40ddfd442dd42ad76e8216"
},
"haircut_tone3": {
"category": "people",
"moji": "💇ðŸ½",
+ "description": "haircut tone 3",
"unicodeVersion": "8.0",
"digest": "70d7581e49c315a3771dd61a3713229886db32aaaeb3af078a69cc042f809150"
},
"haircut_tone4": {
"category": "people",
"moji": "💇ðŸ¾",
+ "description": "haircut tone 4",
"unicodeVersion": "8.0",
"digest": "ec5e3e909eb3bc375ef9cc0fe0e0f90b33f44f273ada91ccf415bbc43b8ffbfc"
},
"haircut_tone5": {
"category": "people",
"moji": "💇ðŸ¿",
+ "description": "haircut tone 5",
"unicodeVersion": "8.0",
"digest": "7c89739ee458546a808fded7f96d9354c47a76883ebb262d5f5abeafd021260e"
},
"hamburger": {
"category": "food",
"moji": "ðŸ”",
+ "description": "hamburger",
"unicodeVersion": "6.0",
"digest": "48204235238bd89d3a69f319f65135102f3d6b181eec241d4d86b302bbffa9bf"
},
"hammer": {
"category": "objects",
"moji": "🔨",
+ "description": "hammer",
"unicodeVersion": "6.0",
"digest": "d0e7830539d935fcd82820c4e0c1d724f0756dfc83a51171fe0f4b36b69fac42"
},
"hammer_pick": {
"category": "objects",
"moji": "âš’",
+ "description": "hammer and pick",
"unicodeVersion": "4.1",
"digest": "aa0445f43bca58d17afa7f3577632ca7775f5a28336385b3020b268b15b18142"
},
"hamster": {
"category": "nature",
"moji": "ðŸ¹",
+ "description": "hamster face",
"unicodeVersion": "6.0",
"digest": "a7e7582e8b1bccd5b7df27ccb05e353a3f0e39bdeb40877732706b9d74a70de1"
},
"hand_splayed": {
"category": "people",
"moji": "ðŸ–",
+ "description": "raised hand with fingers splayed",
"unicodeVersion": "7.0",
"digest": "c51a30cb7e575d29ffed16780a6c95ae3f300b8ac523012f4a6e116d68c1fd15"
},
"hand_splayed_tone1": {
"category": "people",
"moji": "ðŸ–ðŸ»",
+ "description": "raised hand with fingers splayed tone 1",
"unicodeVersion": "8.0",
"digest": "c31fb44a982ed8808e1c311ec1b0b9c5afcb47f16bb1fc731dc483adf8f0d049"
},
"hand_splayed_tone2": {
"category": "people",
"moji": "ðŸ–ðŸ¼",
+ "description": "raised hand with fingers splayed tone 2",
"unicodeVersion": "8.0",
"digest": "56a236881184e9ffad54613fa08a67368c432af738f5254fb1cd87b20368acdf"
},
"hand_splayed_tone3": {
"category": "people",
"moji": "ðŸ–ðŸ½",
+ "description": "raised hand with fingers splayed tone 3",
"unicodeVersion": "8.0",
"digest": "9242ca97dfd2bbc1947228f6535029afb31f8feb72c14ff4b7f2deea30217425"
},
"hand_splayed_tone4": {
"category": "people",
"moji": "ðŸ–ðŸ¾",
+ "description": "raised hand with fingers splayed tone 4",
"unicodeVersion": "8.0",
"digest": "43348d9fd3d43b3c45cebaf663bf181bcad3b6df841a5aeed838180db2cdd481"
},
"hand_splayed_tone5": {
"category": "people",
"moji": "ðŸ–ðŸ¿",
+ "description": "raised hand with fingers splayed tone 5",
"unicodeVersion": "8.0",
"digest": "4b3a0aba7829772fec09f26d6facc19a2f822d2998015297b18b5cab85190ee2"
},
"handbag": {
"category": "people",
"moji": "👜",
+ "description": "handbag",
"unicodeVersion": "6.0",
"digest": "45410a3eed0c2e3f68748d7649fa9e33a90f4e80d5291206bdd0b40380c6da45"
},
"handball": {
"category": "activity",
"moji": "🤾",
+ "description": "handball",
"unicodeVersion": "9.0",
"digest": "94ceb28024eb3259d8b137cafd7438773e717fbc04f5da810f85e43ca0fa9e00"
},
"handball_tone1": {
"category": "activity",
"moji": "🤾ðŸ»",
+ "description": "handball tone 1",
"unicodeVersion": "9.0",
"digest": "8bec4de0d05c80e335e44d65598d186ca92696977353c9fd9c2a5efa122cb842"
},
"handball_tone2": {
"category": "activity",
"moji": "🤾ðŸ¼",
+ "description": "handball tone 2",
"unicodeVersion": "9.0",
"digest": "2ff4131e1e2f089b315d8e176c9348877c26c2bd03706fb75d41bc61bc99bf93"
},
"handball_tone3": {
"category": "activity",
"moji": "🤾ðŸ½",
+ "description": "handball tone 3",
"unicodeVersion": "9.0",
"digest": "224a71f94dd37d3729325d11412334667a81422e21f6d7c008730ff350f51a80"
},
"handball_tone4": {
"category": "activity",
"moji": "🤾ðŸ¾",
+ "description": "handball tone 4",
"unicodeVersion": "9.0",
"digest": "a5f7a9db790565981bad2d0d9e09554c8c509a8179b4705a418300d58a7894b4"
},
"handball_tone5": {
"category": "activity",
"moji": "🤾ðŸ¿",
+ "description": "handball tone 5",
"unicodeVersion": "9.0",
"digest": "00404572d4683f2e8e8a494aa733e96fbec1723634d0a8cb8d75f2829a789d27"
},
"handshake": {
"category": "people",
"moji": "ðŸ¤",
+ "description": "handshake",
"unicodeVersion": "9.0",
"digest": "cb4b08b70560908f96bda0aecd2f4c966bea180f9b7200e4c81d342dc8d36087"
},
"handshake_tone1": {
"category": "people",
"moji": "ðŸ¤ðŸ»",
+ "description": "handshake tone 1",
"unicodeVersion": "9.0",
"digest": "40470e224683ba375ed8698c0cbd560556be5a8898237ddf504377a3a7e89ff0"
},
"handshake_tone2": {
"category": "people",
"moji": "ðŸ¤ðŸ¼",
+ "description": "handshake tone 2",
"unicodeVersion": "9.0",
"digest": "77ed378243bf682f1f4f1a8caeabcbedf772f54631cc40ea46c099e46a499b18"
},
"handshake_tone3": {
"category": "people",
"moji": "ðŸ¤ðŸ½",
+ "description": "handshake tone 3",
"unicodeVersion": "9.0",
"digest": "81b95050f0878b617f5d2640e34031c26a0072e46ca5a688eb4356e48bc74c92"
},
"handshake_tone4": {
"category": "people",
"moji": "ðŸ¤ðŸ¾",
+ "description": "handshake tone 4",
"unicodeVersion": "9.0",
"digest": "74919a6f026fbbd0ccdbdbd4288d1b2ef3bda8930e9142c07736db4a7f3ef345"
},
"handshake_tone5": {
"category": "people",
"moji": "ðŸ¤ðŸ¿",
+ "description": "handshake tone 5",
"unicodeVersion": "9.0",
"digest": "a30d662bfad0074ca7e32cf6f7229b643b636c4beaec496777eb7e1d5b6fc470"
},
"hash": {
"category": "symbols",
"moji": "#⃣",
+ "description": "number sign",
"unicodeVersion": "3.0",
"digest": "01c8b577953010bff0c20f797c2c96ab5d98d4e6ac179c4895a78f34ea904655"
},
"hatched_chick": {
"category": "nature",
"moji": "ðŸ¥",
+ "description": "front-facing baby chick",
"unicodeVersion": "6.0",
"digest": "006571b9e9e839ec9fcb1a911b935c8ca71eb8bcdce9775bee6a2a4c7c927277"
},
"hatching_chick": {
"category": "nature",
"moji": "ðŸ£",
+ "description": "hatching chick",
"unicodeVersion": "6.0",
"digest": "fd7f69fa186407f80de59dec5116e318325a5743ee0e8bba1db541f1e57e7f74"
},
"head_bandage": {
"category": "people",
"moji": "🤕",
+ "description": "face with head-bandage",
"unicodeVersion": "8.0",
"digest": "d09019a73e203b38cc43729a96163147de88e09eab8adb073888e55366854c72"
},
"headphones": {
"category": "activity",
"moji": "🎧",
+ "description": "headphone",
"unicodeVersion": "6.0",
"digest": "34f9d5598158d5d6f978a5ea5c5aa9948bb2990625565a3afad7710f864fbe2f"
},
"hear_no_evil": {
"category": "nature",
"moji": "🙉",
+ "description": "hear-no-evil monkey",
"unicodeVersion": "6.0",
"digest": "53b030b6d6f4ed1a734fa7d48b46f42eb1b2b01653202c1838b742082f08c4bf"
},
"heart": {
"category": "symbols",
"moji": "â¤",
+ "description": "heavy black heart",
"unicodeVersion": "1.1",
"digest": "92be652ec3e50c6e7393440b5d52b88a367f98a28dffe12660095ed3253aa6c0"
},
"heart_decoration": {
"category": "symbols",
"moji": "💟",
+ "description": "heart decoration",
"unicodeVersion": "6.0",
"digest": "6ec5bbf3aa75c6f43eb3dc05e9204366936e8b6b4219310bacdc2fc45f51e245"
},
"heart_exclamation": {
"category": "symbols",
"moji": "â£",
+ "description": "heavy heart exclamation mark ornament",
"unicodeVersion": "1.1",
"digest": "5985ea4d82232a2a07052a59db268aed9ac943895d0c82f637595bb5386329a6"
},
"heart_eyes": {
"category": "people",
"moji": "ðŸ˜",
+ "description": "smiling face with heart-shaped eyes",
"unicodeVersion": "6.0",
"digest": "0eff616517a6252ec89d47d9b4ad85589bcf2bdc7f490578934350acb84b2fcc"
},
"heart_eyes_cat": {
"category": "people",
"moji": "😻",
+ "description": "smiling cat face with heart-shaped eyes",
"unicodeVersion": "6.0",
"digest": "8a1f28b97d661ca4cff5ee13889ca61b5fa745ccb590e80832b7d7701df101d6"
},
"heartbeat": {
"category": "symbols",
"moji": "💓",
+ "description": "beating heart",
"unicodeVersion": "6.0",
"digest": "c9ec024943439d476df6f5ec3a6b30508365a7af3427671a80de3ef2f4f95ffe"
},
"heartpulse": {
"category": "symbols",
"moji": "💗",
+ "description": "growing heart",
"unicodeVersion": "6.0",
"digest": "281d8aebfea37db5b7fe82d9115be167006881fe29ab64a5b09ac92ac27a2309"
},
"hearts": {
"category": "symbols",
"moji": "♥",
+ "description": "black heart suit",
"unicodeVersion": "1.1",
"digest": "271429d12c40be921897005b7bdd08f9518960af1e1e6f56bb0060f1f183651e"
},
"heavy_check_mark": {
"category": "symbols",
"moji": "✔",
+ "description": "heavy check mark",
"unicodeVersion": "1.1",
"digest": "e347728e1290eb9e7b0742d628e2fd124fc049e0774f8a6ddf8e5286e7318718"
},
"heavy_division_sign": {
"category": "symbols",
"moji": "âž—",
+ "description": "heavy division sign",
"unicodeVersion": "6.0",
"digest": "c1e8c40f0788f140b1c5fcb81ed9b5ce1bcfa5988bb8140ed2808e9cb7e0d651"
},
"heavy_dollar_sign": {
"category": "symbols",
"moji": "💲",
+ "description": "heavy dollar sign",
"unicodeVersion": "6.0",
"digest": "7cdeef38348654b93d566e01a48973281cb404a63d0b75b3bad51032887f3f55"
},
"heavy_minus_sign": {
"category": "symbols",
"moji": "âž–",
+ "description": "heavy minus sign",
"unicodeVersion": "6.0",
"digest": "e5335cc6b22abdce49a6127c34269b65a4a6643ddd3253d9baac425089143e7d"
},
"heavy_multiplication_x": {
"category": "symbols",
"moji": "✖",
+ "description": "heavy multiplication x",
"unicodeVersion": "1.1",
"digest": "64bbe9e9716a922e405d2f6d3b6d803863a53fac80ff8cd775899971046cb1ca"
},
"heavy_plus_sign": {
"category": "symbols",
"moji": "âž•",
+ "description": "heavy plus sign",
"unicodeVersion": "6.0",
"digest": "d0d8ade2020ceb252205180b85c66e665856e6cb505518d395b9913b0b24b746"
},
"helicopter": {
"category": "travel",
"moji": "ðŸš",
+ "description": "helicopter",
"unicodeVersion": "6.0",
"digest": "4bd6fd13650fbe3a19cfffeffe6c21b1cda74bd6af64c5dc5999185e35444bc3"
},
"helmet_with_cross": {
"category": "people",
"moji": "⛑",
+ "description": "helmet with white cross",
"unicodeVersion": "5.2",
"digest": "8286107391d44b9cd7fce5dc83bfdebbcdcf5a8214c46a8990732ec40263ed77"
},
"herb": {
"category": "nature",
"moji": "🌿",
+ "description": "herb",
"unicodeVersion": "6.0",
"digest": "9fe8ed65515ede59d0926dcf98f14e2498785e1965610aa0dd56eca9b4bedad9"
},
"hibiscus": {
"category": "nature",
"moji": "🌺",
+ "description": "hibiscus",
"unicodeVersion": "6.0",
"digest": "c442e8eacbd8727bd154bd39692a9a2a03ea2f674b9670ad8361f78a038afe49"
},
"high_brightness": {
"category": "symbols",
"moji": "🔆",
+ "description": "high brightness symbol",
"unicodeVersion": "6.0",
"digest": "35ced42426dcfd5214c2c6c577dce84bb708156433945e6b6adaff7ea530cc57"
},
"high_heel": {
"category": "people",
"moji": "👠",
+ "description": "high-heeled shoe",
"unicodeVersion": "6.0",
"digest": "1e7c7aba50eb1d02cf1d9aa372caca741a6005cf47f68dfa75b7310c3cb18f05"
},
"hockey": {
"category": "activity",
"moji": "ðŸ’",
+ "description": "ice hockey stick and puck",
"unicodeVersion": "8.0",
"digest": "2d00fb17baa617e799db8e9b1771cc365bb4545c7633df0123e66e1a6e2ed25d"
},
"hole": {
"category": "objects",
"moji": "🕳",
+ "description": "hole",
"unicodeVersion": "7.0",
"digest": "8b5539f6f24f09d5d68ffd56be5aa2a8a2f753a8dfbf64892fb02c8f2703e920"
},
"homes": {
"category": "travel",
"moji": "ðŸ˜",
+ "description": "house buildings",
"unicodeVersion": "7.0",
"digest": "cd512f2b4ce747325607d47da48e083dbfe38a44b85b2522bc372bd105afd25f"
},
"honey_pot": {
"category": "food",
"moji": "ðŸ¯",
+ "description": "honey pot",
"unicodeVersion": "6.0",
"digest": "f6eec8c32fbd1b461446dc6c5d5031c43e6ee9685dc9b1ea1b839114e48c4eee"
},
"horse": {
"category": "nature",
"moji": "ðŸ´",
+ "description": "horse face",
"unicodeVersion": "6.0",
"digest": "e377649a9549835770a2a721a92570f699255f88efa646029638eb8ec5f10e3d"
},
"horse_racing": {
"category": "activity",
"moji": "ðŸ‡",
+ "description": "horse racing",
"unicodeVersion": "6.0",
"digest": "3b98e94e9c028ad85b9a750cc61db5ee3ac23cf5ad9243ea3e996b1f772bad54"
},
"horse_racing_tone1": {
"category": "activity",
"moji": "ðŸ‡ðŸ»",
+ "description": "horse racing tone 1",
"unicodeVersion": "8.0",
"digest": "382d8e4502ed34fc1bbf1779ce483bc2e22b83f89c91746c11a5d7aea656d446"
},
"horse_racing_tone2": {
"category": "activity",
"moji": "ðŸ‡ðŸ¼",
+ "description": "horse racing tone 2",
"unicodeVersion": "8.0",
"digest": "198df9973b492ea63e5cfc210dd9591750ccce04a6380adc1dc5b4cb0462a8cd"
},
"horse_racing_tone3": {
"category": "activity",
"moji": "ðŸ‡ðŸ½",
+ "description": "horse racing tone 3",
"unicodeVersion": "8.0",
"digest": "a67f95fc92c366750ebad3c4db92982893d67a5ed78163c8cc809ac40d2ab9a3"
},
"horse_racing_tone4": {
"category": "activity",
"moji": "ðŸ‡ðŸ¾",
+ "description": "horse racing tone 4",
"unicodeVersion": "8.0",
"digest": "986b1706c4a3395b58a8ae3b7609ffdd4424dfefcbf26c88c8085f4f6379734e"
},
"horse_racing_tone5": {
"category": "activity",
"moji": "ðŸ‡ðŸ¿",
+ "description": "horse racing tone 5",
"unicodeVersion": "8.0",
"digest": "66656b5e3d0f43f16f983f9db6214b07aac73b143eeff6475782f98aa5b9ba53"
},
"hospital": {
"category": "travel",
"moji": "ðŸ¥",
+ "description": "hospital",
"unicodeVersion": "6.0",
"digest": "034573e76df444f5b0eb7aff3a4103e4b49a1813869155ab3ae29a6fc0c6c8a2"
},
"hot_pepper": {
"category": "food",
"moji": "🌶",
+ "description": "hot pepper",
"unicodeVersion": "7.0",
"digest": "0b05777d42698196a10db17d04030175b1dfa772d06288f71d666d5f8d3fddbc"
},
"hotdog": {
"category": "food",
"moji": "🌭",
+ "description": "hot dog",
"unicodeVersion": "8.0",
"digest": "7a25bbd1a7531fd34a22c654c0931d9e74bea2bbe7baa9f9cbd88f43baa79fb5"
},
"hotel": {
"category": "travel",
"moji": "ðŸ¨",
+ "description": "hotel",
"unicodeVersion": "6.0",
"digest": "2d78e0ad4cfb0caad778c7de49fefd6e8356afe902a43e3f1c40bceb6b0be422"
},
"hotsprings": {
"category": "symbols",
"moji": "♨",
+ "description": "hot springs",
"unicodeVersion": "1.1",
"digest": "4c10c3a974b44693e8cbe91365c8b8d7f14f62db234cc516b6e54c08a6bacaed"
},
"hourglass": {
"category": "objects",
"moji": "⌛",
+ "description": "hourglass",
"unicodeVersion": "1.1",
"digest": "f0bae8392aaf6f75a83f5d8914936b8650665b24ba1b232fa546b71545dd9acd"
},
"hourglass_flowing_sand": {
"category": "objects",
"moji": "â³",
+ "description": "hourglass with flowing sand",
"unicodeVersion": "6.0",
"digest": "2d077729f40fc04007a933e97356bd511cbd8be76b8c55962ca3fa0d8b828e23"
},
"house": {
"category": "travel",
"moji": "ðŸ ",
+ "description": "house building",
"unicodeVersion": "6.0",
"digest": "b4ac25979fbe161ada0d2a75769aa7552d2371d37d78cddba4ffdc7f076d3279"
},
"house_abandoned": {
"category": "travel",
"moji": "ðŸš",
+ "description": "derelict house building",
"unicodeVersion": "7.0",
"digest": "6e1a58533fbfe88a0eb03668c9f17c5c654a6cc7734ed798d4a885400f823610"
},
"house_with_garden": {
"category": "travel",
"moji": "ðŸ¡",
+ "description": "house with garden",
"unicodeVersion": "6.0",
"digest": "817463f23ec0a849393ba75c333e822b4d253cd4db998c127e90d1b924f35d20"
},
"hugging": {
"category": "people",
"moji": "🤗",
+ "description": "hugging face",
"unicodeVersion": "8.0",
"digest": "69810a98b1247e1f1e496aa757e428189ef5cc086764fabd8189cf1eef82234f"
},
"hushed": {
"category": "people",
"moji": "😯",
+ "description": "hushed face",
"unicodeVersion": "6.1",
"digest": "22586107f7399eff64538a52929dade152633aa268fc5ec4e6fe1c0e00a7bd89"
},
"ice_cream": {
"category": "food",
"moji": "ðŸ¨",
+ "description": "ice cream",
"unicodeVersion": "6.0",
"digest": "d1a8e685f2ecf83dead28733859e369d6ce120a2669cdab97dc4423547d472ac"
},
"ice_skate": {
"category": "activity",
"moji": "⛸",
+ "description": "ice skate",
"unicodeVersion": "5.2",
"digest": "41ef65c143bc068868fa64080ffd447d91aa3fe2a39e69ecaa97022820af4dcd"
},
"icecream": {
"category": "food",
"moji": "ðŸ¦",
+ "description": "soft ice cream",
"unicodeVersion": "6.0",
"digest": "22cfe17b80cbd2a0377ee90da45bd40d33533c914b2639d363fbb1f00714e194"
},
"id": {
"category": "symbols",
"moji": "🆔",
+ "description": "squared id",
"unicodeVersion": "6.0",
"digest": "bcf0922e083821d3be7951893084ea0d72a0110ef0b20d11dfec24dd70633893"
},
"ideograph_advantage": {
"category": "symbols",
"moji": "ðŸ‰",
+ "description": "circled ideograph advantage",
"unicodeVersion": "6.0",
"digest": "0b6bf59f63fda1afa92d652814a778a056c3f4abdd9cf3f6796068bd71783051"
},
"imp": {
"category": "people",
"moji": "👿",
+ "description": "imp",
"unicodeVersion": "6.0",
"digest": "52598cf2441988f875ccb4e479637baefc679e3ca64e9a6400e56488b0fde811"
},
"inbox_tray": {
"category": "objects",
"moji": "📥",
+ "description": "inbox tray",
"unicodeVersion": "6.0",
"digest": "d5d9497022b5318fcfbfdfcd56df9c65dd8f4a4cb5e6283ca260836df57da301"
},
"incoming_envelope": {
"category": "objects",
"moji": "📨",
+ "description": "incoming envelope",
"unicodeVersion": "6.0",
"digest": "310b7bdcca93452fe10c72c03d0aafa12b98e5d3408896d275d06d3693812c7a"
},
"information_desk_person": {
"category": "people",
"moji": "ðŸ’",
+ "description": "information desk person",
"unicodeVersion": "6.0",
"digest": "9f12a4a58a650e8e1d3836ef857003c3ccd42ad4203a2479eb95100bf6559064"
},
"information_desk_person_tone1": {
"category": "people",
"moji": "ðŸ’ðŸ»",
+ "description": "information desk person tone 1",
"unicodeVersion": "8.0",
"digest": "6674f2e059eff7cfd7fd6abc800da37c4f1087feb4ff26c9e4e31aa29fdf9921"
},
"information_desk_person_tone2": {
"category": "people",
"moji": "ðŸ’ðŸ¼",
+ "description": "information desk person tone 2",
"unicodeVersion": "8.0",
"digest": "9983412ecd130b7e9cfb078167016c06fd043b6f9f3c26d21733ca3f059fd109"
},
"information_desk_person_tone3": {
"category": "people",
"moji": "ðŸ’ðŸ½",
+ "description": "information desk person tone 3",
"unicodeVersion": "8.0",
"digest": "d8907bf47af5722127afca8fc0da587eab33044a6c60a94890983deb8d6f7a66"
},
"information_desk_person_tone4": {
"category": "people",
"moji": "ðŸ’ðŸ¾",
+ "description": "information desk person tone 4",
"unicodeVersion": "8.0",
"digest": "3be086d4edfe9ca8e4a364b4e8d09b81b5b594b5eeb9ffdf6370179fb3118658"
},
"information_desk_person_tone5": {
"category": "people",
"moji": "ðŸ’ðŸ¿",
+ "description": "information desk person tone 5",
"unicodeVersion": "8.0",
"digest": "2fde4e98dd11c5c29c89cad7cbb7bd2d5077dfad07913b20e01955b2d0dfad40"
},
"information_source": {
"category": "symbols",
"moji": "ℹ",
+ "description": "information source",
"unicodeVersion": "3.0",
"digest": "b6bf3cce86d42c2e3c46470baab4af01e900b8ae337b605c3da07c3eba671269"
},
"innocent": {
"category": "people",
"moji": "😇",
+ "description": "smiling face with halo",
"unicodeVersion": "6.0",
"digest": "20f8d856bc3e46f4b1173cea05d4577e1c61f06b2daba46e57db90f4066bb428"
},
"interrobang": {
"category": "symbols",
"moji": "â‰",
+ "description": "exclamation question mark",
"unicodeVersion": "3.0",
"digest": "92a2d5b4c0bd6714e402f6f12fe19774cb41d081b5e9c23c415ce794224d8117"
},
"iphone": {
"category": "objects",
"moji": "📱",
+ "description": "mobile phone",
"unicodeVersion": "6.0",
"digest": "1ebc54215713cd4bf1c1e50770999f2512bb4fea29e37d0bb3a8aa2460ff875d"
},
"island": {
"category": "travel",
"moji": "ðŸ",
+ "description": "desert island",
"unicodeVersion": "7.0",
"digest": "7f9eb5c0cd865762f7a0f187e09c1be442de7010e7c2e113d56aae998597c90d"
},
"izakaya_lantern": {
"category": "objects",
"moji": "ðŸ®",
+ "description": "izakaya lantern",
"unicodeVersion": "6.0",
"digest": "fbdc290e666d43d0776a73b955c26df4518692b35e72742e073705fc4ca2ae88"
},
"jack_o_lantern": {
"category": "nature",
"moji": "🎃",
+ "description": "jack-o-lantern",
"unicodeVersion": "6.0",
"digest": "78d666c2e80f64bfb6796f53e5ba4960a83ec36192110e8661031bee2b5e370a"
},
"japan": {
"category": "travel",
"moji": "🗾",
+ "description": "silhouette of japan",
"unicodeVersion": "6.0",
"digest": "e7d9d6ebf9047fdd3c52e074ba259659c6d8e51a6abae3cdb8d6cf6dbf9a93fe"
},
"japanese_castle": {
"category": "travel",
"moji": "ðŸ¯",
+ "description": "japanese castle",
"unicodeVersion": "6.0",
"digest": "938ae132c403330288223b88d28c19a47224d4f254fbc2366ecef73d9633112c"
},
"japanese_goblin": {
"category": "people",
"moji": "👺",
+ "description": "japanese goblin",
"unicodeVersion": "6.0",
"digest": "63d4bcf58b9d0c29612994432aad2ae35819fdd2890674e60a2f1d51601b742e"
},
"japanese_ogre": {
"category": "people",
"moji": "👹",
+ "description": "japanese ogre",
"unicodeVersion": "6.0",
"digest": "434ceedd102e7dcbc07e086811673dd63659ddf8c3ec4d029a3d759a0abfcbdb"
},
"jeans": {
"category": "people",
"moji": "👖",
+ "description": "jeans",
"unicodeVersion": "6.0",
"digest": "f986ad32e419cca81c995f8371f0189d1490172a97ebbeac60054a1af08949c5"
},
"joy": {
"category": "people",
"moji": "😂",
+ "description": "face with tears of joy",
"unicodeVersion": "6.0",
"digest": "75d7a05043523d290c46d3b313b19ed3c95271f1110bcf234cf13d4273625b08"
},
"joy_cat": {
"category": "people",
"moji": "😹",
+ "description": "cat face with tears of joy",
"unicodeVersion": "6.0",
"digest": "a65c999604147e5e20170fcb14f80a1ff0a633f991492e1f790b2ad4caec7b7e"
},
"joystick": {
"category": "objects",
"moji": "🕹",
+ "description": "joystick",
"unicodeVersion": "7.0",
"digest": "671ee588f397a96f27056a67e6a06d6e8d22c2109ec57b2859badb5fec9cf8dd"
},
"juggling": {
"category": "activity",
"moji": "🤹",
+ "description": "juggling",
"unicodeVersion": "9.0",
"digest": "1f5dafa78de8b37f3df88fdf3084d2380666bd74ab2f449754d8724f6f8dbfa5"
},
"juggling_tone1": {
"category": "activity",
"moji": "🤹ðŸ»",
+ "description": "juggling tone 1",
"unicodeVersion": "9.0",
"digest": "b0b4d020148c896be69c28b08e3c486f6db270d138c7ccf4be362b29eb99878d"
},
"juggling_tone2": {
"category": "activity",
"moji": "🤹ðŸ¼",
+ "description": "juggling tone 2",
"unicodeVersion": "9.0",
"digest": "cfe0c1649b2fdca03673e0e64f3a7d06d4bd49b8954c769aeb7eb88b70ec99f4"
},
"juggling_tone3": {
"category": "activity",
"moji": "🤹ðŸ½",
+ "description": "juggling tone 3",
"unicodeVersion": "9.0",
"digest": "7f87022722008bb265abe245e8157dc7a61944f5da62b3cf86f26ee1b3bdef63"
},
"juggling_tone4": {
"category": "activity",
"moji": "🤹ðŸ¾",
+ "description": "juggling tone 4",
"unicodeVersion": "9.0",
"digest": "1f00da8c05582c95501cc6c3fe5ce0f9bfbc16789dcee59844a8fe7831198583"
},
"juggling_tone5": {
"category": "activity",
"moji": "🤹ðŸ¿",
+ "description": "juggling tone 5",
"unicodeVersion": "9.0",
"digest": "a195bf734788eb7961c00dbc05255a49da8b9d5042fada29b26cc20393d3ce52"
},
"kaaba": {
"category": "travel",
"moji": "🕋",
+ "description": "kaaba",
"unicodeVersion": "8.0",
"digest": "a4618782f9583f077bd383965f1c91b9985a949bb7b6cec7af22914e7f5e9ab6"
},
"key": {
"category": "objects",
"moji": "🔑",
+ "description": "key",
"unicodeVersion": "6.0",
"digest": "66719fa77a50a0827c8d47237e2704c03e38186e6fef80627a765473b2294c2e"
},
"key2": {
"category": "objects",
"moji": "ðŸ—",
+ "description": "old key",
"unicodeVersion": "7.0",
"digest": "f57240a014a9da5da3d4d98c17d0a55e0ff2e5f2d22731d2fc867105cff54c6e"
},
"keyboard": {
"category": "objects",
"moji": "⌨",
+ "description": "keyboard",
"unicodeVersion": "1.1",
"digest": "34da8ff62ca964142f9281b80123dbba74deaac8d77fa61758c30cfb36c31386"
},
"kimono": {
"category": "people",
"moji": "👘",
+ "description": "kimono",
"unicodeVersion": "6.0",
"digest": "637182590e256c8fb74ce4c0565f5180c07f06e3bdebf30138ed3259b209c27f"
},
"kiss": {
"category": "people",
"moji": "💋",
+ "description": "kiss mark",
"unicodeVersion": "6.0",
"digest": "62f9b9ffcb01558cd5bb829344a1d1d399511663ff5235405c1f786c9416a94d"
},
"kiss_mm": {
"category": "people",
"moji": "👨â€â¤ï¸â€ðŸ’‹â€ðŸ‘¨",
+ "description": "kiss (man,man)",
"unicodeVersion": "6.0",
"digest": "6b0ae32ecb7ec0f0f43dc7a1350711185cce114c52752395f364ddbfb4f1fff4"
},
"kiss_ww": {
"category": "people",
"moji": "👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘©",
+ "description": "kiss (woman,woman)",
"unicodeVersion": "6.0",
"digest": "6de420cf752e706b1b7e9522b1b9be62eda069cb028c8fd587caf39f6a142e6a"
},
"kissing": {
"category": "people",
"moji": "😗",
+ "description": "kissing face",
"unicodeVersion": "6.1",
"digest": "b4a505f9e3d7fbd0ac60111f0e678cf425a5fd1abc65a3e9db59ae4abcfb8e85"
},
"kissing_cat": {
"category": "people",
"moji": "😽",
+ "description": "kissing cat face with closed eyes",
"unicodeVersion": "6.0",
"digest": "a00431bf10601db4998e78433279167e52cbd36aed885399482529d5cdab8636"
},
"kissing_closed_eyes": {
"category": "people",
"moji": "😚",
+ "description": "kissing face with closed eyes",
"unicodeVersion": "6.0",
"digest": "ae474db7daf80fe0b82ae1f2a11672cfcd9f9126e100f6e6d4b8a0d135dce39d"
},
"kissing_heart": {
"category": "people",
"moji": "😘",
+ "description": "face throwing a kiss",
"unicodeVersion": "6.0",
"digest": "bce372573bd3b347b555c1cd22087e03e650df73c8e0284ab668bf6633251632"
},
"kissing_smiling_eyes": {
"category": "people",
"moji": "😙",
+ "description": "kissing face with smiling eyes",
"unicodeVersion": "6.1",
"digest": "f0f8636cb1a02b93cc72ce1b194b890fca823d91e35926b889be3ecfae79207f"
},
"kiwi": {
"category": "food",
"moji": "ðŸ¥",
+ "description": "kiwifruit",
"unicodeVersion": "9.0",
"digest": "70a3a05f333d9455d2da12eed970bc3baae416286848fed8e5dd31b5be0819be"
},
"knife": {
"category": "objects",
"moji": "🔪",
+ "description": "hocho",
"unicodeVersion": "6.0",
"digest": "e6189e4843c6e80875b4952fcddb0c858f7c6039b9214bbec6a261a1358425df"
},
"koala": {
"category": "nature",
"moji": "ðŸ¨",
+ "description": "koala",
"unicodeVersion": "6.0",
"digest": "c58f7e0abae42c2218a85efed0e04151df67187815bebca7f3db6f435e0dab4d"
},
"koko": {
"category": "symbols",
"moji": "ðŸˆ",
+ "description": "squared katakana koko",
"unicodeVersion": "6.0",
"digest": "5f45eb49bbf298e1fadedfe6cccc297850fcaaa4535e4cc911d48d979af55807"
},
"label": {
"category": "objects",
"moji": "ðŸ·",
+ "description": "label",
"unicodeVersion": "7.0",
"digest": "9550ed50cedbc56eb1bd22a8a0809d837048a33d6e2e6e7d65c50d95fa05a85d"
},
"large_blue_circle": {
"category": "symbols",
"moji": "🔵",
+ "description": "large blue circle",
"unicodeVersion": "6.0",
"digest": "0df3fb3b09a6269459a3d9a1fe78db572190a948680844cfe758f53b6a482ff4"
},
"large_blue_diamond": {
"category": "symbols",
"moji": "🔷",
+ "description": "large blue diamond",
"unicodeVersion": "6.0",
"digest": "7f646b4e9de2788ed09e45f72cb512c269dda4989029b39bf9a2556659321651"
},
"large_orange_diamond": {
"category": "symbols",
"moji": "🔶",
+ "description": "large orange diamond",
"unicodeVersion": "6.0",
"digest": "80ae005ef9d79190c777f00de0993f8b3cb783f7051d76e971640c8c0827c338"
},
"last_quarter_moon": {
"category": "nature",
"moji": "🌗",
+ "description": "last quarter moon symbol",
"unicodeVersion": "6.0",
"digest": "3d1f276607c685d50f4b70d00a57750a57ad9ad84256dafd2dc8eef8c72300c3"
},
"last_quarter_moon_with_face": {
"category": "nature",
"moji": "🌜",
+ "description": "last quarter moon with face",
"unicodeVersion": "6.0",
"digest": "d516825ba52dc67f5a01433fb9df2aa77742d38efde4225983ebc4882cbdfe5d"
},
"laughing": {
"category": "people",
"moji": "😆",
+ "description": "smiling face with open mouth and tightly-closed ey",
"unicodeVersion": "6.0",
"digest": "e9ea994b39650740c4961f070ed492d86b3acf6e6a830a6dadaa3a6872e81b81"
},
"leaves": {
"category": "nature",
"moji": "ðŸƒ",
+ "description": "leaf fluttering in wind",
"unicodeVersion": "6.0",
"digest": "56a7a0e767a6f214d340d1b5989efd99fec52c6aa306ec5c3328e32234a1631b"
},
"ledger": {
"category": "objects",
"moji": "📒",
+ "description": "ledger",
"unicodeVersion": "6.0",
"digest": "e58cb714353e96a2891a5d97910ff79660e637af909b81c49c919d3735db55b4"
},
"left_facing_fist": {
"category": "people",
"moji": "🤛",
+ "description": "left-facing fist",
"unicodeVersion": "9.0",
"digest": "7861be485beefae0de341df2f21576666e22f63511a033e785752f30c07291da"
},
"left_facing_fist_tone1": {
"category": "people",
"moji": "🤛ðŸ»",
+ "description": "left facing fist tone 1",
"unicodeVersion": "9.0",
"digest": "2e4c4dd96b0e4b46fe0f9ce5666344d266d0f17a8544cbae73d96638d1955296"
},
"left_facing_fist_tone2": {
"category": "people",
"moji": "🤛ðŸ¼",
+ "description": "left facing fist tone 2",
"unicodeVersion": "9.0",
"digest": "b96a63a801175ce98a75f0edad7b5574251a3fbbd894d8ab3f21aeeda366cc13"
},
"left_facing_fist_tone3": {
"category": "people",
"moji": "🤛ðŸ½",
+ "description": "left facing fist tone 3",
"unicodeVersion": "9.0",
"digest": "99df84635513c2ebfef24df1bd3705233e02149eef788c7b82ca0548df6f6ea5"
},
"left_facing_fist_tone4": {
"category": "people",
"moji": "🤛ðŸ¾",
+ "description": "left facing fist tone 4",
"unicodeVersion": "9.0",
"digest": "68954842ca725aec0aa39bce4aa81aad17ac30f5f298561dfa411feb07414cd3"
},
"left_facing_fist_tone5": {
"category": "people",
"moji": "🤛ðŸ¿",
+ "description": "left facing fist tone 5",
"unicodeVersion": "9.0",
"digest": "a419b33fae82612dc860ff48950c0547a1642d4f0c94b6547324440837d3bb21"
},
"left_luggage": {
"category": "symbols",
"moji": "🛅",
+ "description": "left luggage",
"unicodeVersion": "6.0",
"digest": "6625077767a51163ea20cbc299f3c13fd5ccf1b5ce365ee702ef1fef6be3dadf"
},
"left_right_arrow": {
"category": "symbols",
"moji": "↔",
+ "description": "left right arrow",
"unicodeVersion": "1.1",
"digest": "560fcf1b794eb0d5269c73b3f8da57540cbb8a6f1a9af7a9d10b202252247e34"
},
"leftwards_arrow_with_hook": {
"category": "symbols",
"moji": "↩",
+ "description": "leftwards arrow with hook",
"unicodeVersion": "1.1",
"digest": "504714c5559b1bd35aa469be83069a923d1a25f364cac08c10df0195749e7b26"
},
"lemon": {
"category": "food",
"moji": "ðŸ‹",
+ "description": "lemon",
"unicodeVersion": "6.0",
"digest": "ccca25bb6ac47770dba3aaf75144128f9a73299061969b25a35ad1733dcde5fe"
},
"leo": {
"category": "symbols",
"moji": "♌",
+ "description": "leo",
"unicodeVersion": "1.1",
"digest": "f2ed930e279699962f189e0cac519cc29d339b3e82debfdc90c5b0935a7543bb"
},
"leopard": {
"category": "nature",
"moji": "ðŸ†",
+ "description": "leopard",
"unicodeVersion": "6.0",
"digest": "d4a8964b6f2cdf6ddf074d0f1f2f65783a1a43eb4af426905fad0e60899939c7"
},
"level_slider": {
"category": "objects",
"moji": "🎚",
+ "description": "level slider",
"unicodeVersion": "7.0",
"digest": "48842324f54d971ebf548a89a82ac7f29e235702081c91b477b1a92d427290e7"
},
"levitate": {
"category": "activity",
"moji": "🕴",
+ "description": "man in business suit levitating",
"unicodeVersion": "7.0",
"digest": "453c24bf2544ed3ef3c710a7fabbd5fdace4dc65cddd377274d30d921523b50b"
},
"libra": {
"category": "symbols",
"moji": "♎",
+ "description": "libra",
"unicodeVersion": "1.1",
"digest": "e330ba05bb449db074bc23d1514246ca5e249110f44ddb5804e5510eef6deac1"
},
"lifter": {
"category": "activity",
"moji": "ðŸ‹",
+ "description": "weight lifter",
"unicodeVersion": "7.0",
"digest": "d6c94a32eb863d14a2a01add8ab95040f42a55d9e3f90641a0fe143d58127558"
},
"lifter_tone1": {
"category": "activity",
"moji": "ðŸ‹ðŸ»",
+ "description": "weight lifter tone 1",
"unicodeVersion": "8.0",
"digest": "870acf2f554fce360b58d3e98b4c0558d7ec7775587776c0f9d40c6fb1bdacf9"
},
"lifter_tone2": {
"category": "activity",
"moji": "ðŸ‹ðŸ¼",
+ "description": "weight lifter tone 2",
"unicodeVersion": "8.0",
"digest": "1a7ece8512e42241cdd95c85ccc509bc0ff9c7c6ffaff2be343c77f417a27576"
},
"lifter_tone3": {
"category": "activity",
"moji": "ðŸ‹ðŸ½",
+ "description": "weight lifter tone 3",
"unicodeVersion": "8.0",
"digest": "4bc633ee82a0fb59feba379fb6901a489e4ac849d758f9c8e7a1a0a26eaa380c"
},
"lifter_tone4": {
"category": "activity",
"moji": "ðŸ‹ðŸ¾",
+ "description": "weight lifter tone 4",
"unicodeVersion": "8.0",
"digest": "d086fe5577b5ba80676f2224d886f8ebe4588314f429f12a34c52c971ed71b5c"
},
"lifter_tone5": {
"category": "activity",
"moji": "ðŸ‹ðŸ¿",
+ "description": "weight lifter tone 5",
"unicodeVersion": "8.0",
"digest": "79b0edf6ce1fd024dd7f458e322ad8588af0b789a04cc1cf38380dc8b9c76f55"
},
"light_rail": {
"category": "travel",
"moji": "🚈",
+ "description": "light rail",
"unicodeVersion": "6.0",
"digest": "2f30b23a738371690b2f00d96ddb5ceb90a1442b5478754626a3dfa263ed2fc1"
},
"link": {
"category": "objects",
"moji": "🔗",
+ "description": "link symbol",
"unicodeVersion": "6.0",
"digest": "7bf567aabd1fc38b3d70422f9db3a13b50950cf6207e70962c9938827c196ccb"
},
"lion_face": {
"category": "nature",
"moji": "ðŸ¦",
+ "description": "lion face",
"unicodeVersion": "8.0",
"digest": "dd24f2668e973ec973e97dc111f59a2cc14e9b608387401191dd53368d28d4fa"
},
"lips": {
"category": "people",
"moji": "👄",
+ "description": "mouth",
"unicodeVersion": "6.0",
"digest": "8740d8086525c7a836d64625a6915cc1c59af69ba143456dbb59e0179276895e"
},
"lipstick": {
"category": "people",
"moji": "💄",
+ "description": "lipstick",
"unicodeVersion": "6.0",
"digest": "751dcb22706a796033b13a2ccb94304236ec13207ad4d011e02d230ae33ab5c1"
},
"lizard": {
"category": "nature",
"moji": "🦎",
+ "description": "lizard",
"unicodeVersion": "9.0",
"digest": "fb9191f9eab58b8403d4c4626ccbb14ba05c1f6944011751a8edcc4dd03c66e6"
},
"lock": {
"category": "objects",
"moji": "🔒",
+ "description": "lock",
"unicodeVersion": "6.0",
"digest": "043b4fc0b8c79d47a07d91308e628e1ac262aea6c1ec05e6b84bf7bcdf89dc83"
},
"lock_with_ink_pen": {
"category": "objects",
"moji": "ðŸ”",
+ "description": "lock with ink pen",
"unicodeVersion": "6.0",
"digest": "7b5e959b26cf7296c7b230fc2be9feb9e38391c5001951a019d16b169a71aba9"
},
"lollipop": {
"category": "food",
"moji": "ðŸ­",
+ "description": "lollipop",
"unicodeVersion": "6.0",
"digest": "17b6a0df47ec758a2f9c087b46a6902cee344d39407ef4c321e408505cbb72ca"
},
"loop": {
"category": "symbols",
"moji": "âž¿",
+ "description": "double curly loop",
"unicodeVersion": "6.0",
"digest": "9f20ecc34b3c871789ba7d0712aa31e7a74b6c1558ac8bea385bc40590056726"
},
"loud_sound": {
"category": "symbols",
"moji": "🔊",
+ "description": "speaker with three sound waves",
"unicodeVersion": "6.0",
"digest": "64b12db9ddd8adf74a9fc2bd83c7979ea865113347f7ce8666e9ccf5019e715f"
},
"loudspeaker": {
"category": "symbols",
"moji": "📢",
+ "description": "public address loudspeaker",
"unicodeVersion": "6.0",
"digest": "1e1f35d16dd2898ebaa6f2b2868203df6e09c8a70df069c92d6d1b5cb2ac0976"
},
"love_hotel": {
"category": "travel",
"moji": "ðŸ©",
+ "description": "love hotel",
"unicodeVersion": "6.0",
"digest": "ff8966a50fd47a216855488eb09a367d231fea21f49e7e5325191d32fb494473"
},
"love_letter": {
"category": "objects",
"moji": "💌",
+ "description": "love letter",
"unicodeVersion": "6.0",
"digest": "037261c8ca4d72f7205e51664591696da2ae7ceb19f1c1c9f6123da5a5979d29"
},
"low_brightness": {
"category": "symbols",
"moji": "🔅",
+ "description": "low brightness symbol",
"unicodeVersion": "6.0",
"digest": "a065d00a416e297c168b0a675cafcf492fedf94865cb21801a1be5a3914593d4"
},
"lying_face": {
"category": "people",
"moji": "🤥",
+ "description": "lying face",
"unicodeVersion": "9.0",
"digest": "ce836170165e1b70938273f289c02c2106873cd9ab5472dbcd487c2f9f53f13d"
},
"m": {
"category": "symbols",
"moji": "â“‚",
+ "description": "circled latin capital letter m",
"unicodeVersion": "1.1",
"digest": "54588ac2b7fcd53a96f17124e9de69b617613fcd5af9ad2930a094cb795bb9f4"
},
"mag": {
"category": "objects",
"moji": "ðŸ”",
+ "description": "left-pointing magnifying glass",
"unicodeVersion": "6.0",
"digest": "a6e31a2efa7d9427aaa30b45d9f4181ee55c44be08aea2df165a86e0e6d9eaa1"
},
"mag_right": {
"category": "objects",
"moji": "🔎",
+ "description": "right-pointing magnifying glass",
"unicodeVersion": "6.0",
"digest": "c7d8ceeb05db261e5eaab31dc4da432d0d5592a2ed71e526c5a542daa230bbaf"
},
"mahjong": {
"category": "symbols",
"moji": "🀄",
+ "description": "mahjong tile red dragon",
"unicodeVersion": "5.1",
"digest": "755d69f988434ce1c17531a8b7ac92ead6f5607c2635a22f10e0ad70f09fc3e6"
},
"mailbox": {
"category": "objects",
"moji": "📫",
+ "description": "closed mailbox with raised flag",
"unicodeVersion": "6.0",
"digest": "2069091be90a530a43ef29d5ec7688c351bf4d5b08d63a0d20d72b67d639ec62"
},
"mailbox_closed": {
"category": "objects",
"moji": "📪",
+ "description": "closed mailbox with lowered flag",
"unicodeVersion": "6.0",
"digest": "d88d65bfebb8216535fd055c69f319564b2cf0b0901820f8312f581864557ed4"
},
"mailbox_with_mail": {
"category": "objects",
"moji": "📬",
+ "description": "open mailbox with raised flag",
"unicodeVersion": "6.0",
"digest": "69e966b4659128991a70c6a2dd4d647551bedb91bdf5ce688958686bbec56381"
},
"mailbox_with_no_mail": {
"category": "objects",
"moji": "📭",
+ "description": "open mailbox with lowered flag",
"unicodeVersion": "6.0",
"digest": "9e92d8ee88f660ce56da61077c80ec26c5d8f54ebd2306c4cfa16f6c1b981f83"
},
"man": {
"category": "people",
"moji": "👨",
+ "description": "man",
"unicodeVersion": "6.0",
"digest": "42b882d2c6aa095f1afcf901203838d95c1908bdc725519779186b9c33c728d7"
},
"man_dancing": {
"category": "people",
"moji": "🕺",
+ "description": "man dancing",
"unicodeVersion": "9.0",
"digest": "9f632ee0c886d5f03c61e5f3a27668262c0cc2693b857a91c23c1e5ea3785b9e"
},
"man_dancing_tone1": {
"category": "activity",
"moji": "🕺ðŸ»",
+ "description": "man dancing tone 1",
"unicodeVersion": "9.0",
"digest": "6c56a16cb105bcdd97472645b3a351cebdbb1132cbfd18b0118f289db5fbe741"
},
"man_dancing_tone2": {
"category": "activity",
"moji": "🕺ðŸ¼",
+ "description": "man dancing tone 2",
"unicodeVersion": "9.0",
"digest": "ed7e78c14d205a03fdd5581e5213add69a55e13b4cbaf76a6d5a0d6c80f53327"
},
"man_dancing_tone3": {
"category": "activity",
"moji": "🕺ðŸ½",
+ "description": "man dancing tone 3",
"unicodeVersion": "9.0",
"digest": "13b45403e11800163406206eedeb8b579cc83eca2f60246be97e099164387bc8"
},
"man_dancing_tone4": {
"category": "activity",
"moji": "🕺ðŸ¾",
+ "description": "man dancing tone 4",
"unicodeVersion": "9.0",
"digest": "f6feb1b0b83565fadcdd1a8737d3daa08893e919547d2a06de899160162d9c4a"
},
"man_dancing_tone5": {
"category": "activity",
"moji": "🕺ðŸ¿",
+ "description": "man dancing tone 5",
"unicodeVersion": "9.0",
"digest": "fe20a9ed9ba991653b4d0683de347ed7c226a5d75610307584a2ddd6fcd1e3f2"
},
"man_in_tuxedo": {
"category": "people",
"moji": "🤵",
+ "description": "man in tuxedo",
"unicodeVersion": "9.0",
"digest": "4d451a971dfefedc4830ba78e19b123f250e09ae65baddccdc56c0f8aa3a9b50"
},
"man_in_tuxedo_tone1": {
"category": "people",
"moji": "🤵ðŸ»",
+ "description": "man in tuxedo tone 1",
"unicodeVersion": "9.0",
"digest": "2814833334fb211ae2ecb1fb5964e9752282d0fb4d7f3477de5dd2a4f812a793"
},
"man_in_tuxedo_tone2": {
"category": "people",
"moji": "🤵ðŸ¼",
+ "description": "man in tuxedo tone 2",
"unicodeVersion": "9.0",
"digest": "cd1bab9ee0e2335d3cd99d51216cccdc4fc3c2cf20129b8b7e11a51a77258f68"
},
"man_in_tuxedo_tone3": {
"category": "people",
"moji": "🤵ðŸ½",
+ "description": "man in tuxedo tone 3",
"unicodeVersion": "9.0",
"digest": "f387775f925fe60b9f3e7cad63a55d4d196ddd41658029a70440d14c17cb99f9"
},
"man_in_tuxedo_tone4": {
"category": "people",
"moji": "🤵ðŸ¾",
+ "description": "man in tuxedo tone 4",
"unicodeVersion": "9.0",
"digest": "08debd7a573d1201aee8a2f281ef7cb638d4a2a096222150391f36963f07c622"
},
"man_in_tuxedo_tone5": {
"category": "people",
"moji": "🤵ðŸ¿",
+ "description": "man in tuxedo tone 5",
"unicodeVersion": "9.0",
"digest": "e3b10e0619f0911cf9b665a265f4ef829b8f6ba6e9c3a021d0539a27e315f8fe"
},
"man_tone1": {
"category": "people",
"moji": "👨ðŸ»",
+ "description": "man tone 1",
"unicodeVersion": "8.0",
"digest": "7053e265fa7d2594de54a6c5d06c21795b9a7dfb36a1c5594ca43c4c6cc56504"
},
"man_tone2": {
"category": "people",
"moji": "👨ðŸ¼",
+ "description": "man tone 2",
"unicodeVersion": "8.0",
"digest": "7ebc64de40d3ac60fb761be5cf94f53fa10b4f03fb66add46c90f5d98eaf71eb"
},
"man_tone3": {
"category": "people",
"moji": "👨ðŸ½",
+ "description": "man tone 3",
"unicodeVersion": "8.0",
"digest": "77ceef4d3740ed4751acb83dd45b6b754cf625c522c6757309cd4d61202d7149"
},
"man_tone4": {
"category": "people",
"moji": "👨ðŸ¾",
+ "description": "man tone 4",
"unicodeVersion": "8.0",
"digest": "41e6037c393f61cca61b9a81b27ed14a95d75fe380e3a00153c33a371a836ffd"
},
"man_tone5": {
"category": "people",
"moji": "👨ðŸ¿",
+ "description": "man tone 5",
"unicodeVersion": "8.0",
"digest": "a8cebfd39a5b9c79af7cc37f205e1135376056fee287af967c9f55d415572d99"
},
"man_with_gua_pi_mao": {
"category": "people",
"moji": "👲",
+ "description": "man with gua pi mao",
"unicodeVersion": "6.0",
"digest": "3dae285e900c69986a48db0fa89d4f371a49f38608059cdae52be098030c5ac4"
},
"man_with_gua_pi_mao_tone1": {
"category": "people",
"moji": "👲ðŸ»",
+ "description": "man with gua pi mao tone 1",
"unicodeVersion": "8.0",
"digest": "35404d8e266920c78edd9e7143fb052b42f65242a5698494c4f4365e9183cc67"
},
"man_with_gua_pi_mao_tone2": {
"category": "people",
"moji": "👲ðŸ¼",
+ "description": "man with gua pi mao tone 2",
"unicodeVersion": "8.0",
"digest": "82d4f968665a93c7543372c8a1eeb0f25d0ea6842d5e518bd91c226c6c3ab8c2"
},
"man_with_gua_pi_mao_tone3": {
"category": "people",
"moji": "👲ðŸ½",
+ "description": "man with gua pi mao tone 3",
"unicodeVersion": "8.0",
"digest": "f44159f0c672b9b833449382896180e799abf574f5b3c6cd9541caa992fa18ce"
},
"man_with_gua_pi_mao_tone4": {
"category": "people",
"moji": "👲ðŸ¾",
+ "description": "man with gua pi mao tone 4",
"unicodeVersion": "8.0",
"digest": "c79060188f9461ca34eaa225b7682d8c410883609509fb731c992db69bfeeb50"
},
"man_with_gua_pi_mao_tone5": {
"category": "people",
"moji": "👲ðŸ¿",
+ "description": "man with gua pi mao tone 5",
"unicodeVersion": "8.0",
"digest": "de9e4acdb10f7abddeeabc0b48d91139fc8b544a601c530db811f099991b0d38"
},
"man_with_turban": {
"category": "people",
"moji": "👳",
+ "description": "man with turban",
"unicodeVersion": "6.0",
"digest": "db72c944e93983f38d00e3e936ebb5b243c6069f1f1236d46f6a9f1beb8d6634"
},
"man_with_turban_tone1": {
"category": "people",
"moji": "👳ðŸ»",
+ "description": "man with turban tone 1",
"unicodeVersion": "8.0",
"digest": "b6d7489c4cd151af09fff48b62c54c336303e14866e6ef38f94cd834b085d09e"
},
"man_with_turban_tone2": {
"category": "people",
"moji": "👳ðŸ¼",
+ "description": "man with turban tone 2",
"unicodeVersion": "8.0",
"digest": "7854ef973c21847f452d7e78e5c460ea300e12b539ce92c69dabe8f1bf3a4382"
},
"man_with_turban_tone3": {
"category": "people",
"moji": "👳ðŸ½",
+ "description": "man with turban tone 3",
"unicodeVersion": "8.0",
"digest": "1dbd9bd78f5263cbadee7d0d5754c14cfbc914f7329e25fbd97d9f5b8ce0737e"
},
"man_with_turban_tone4": {
"category": "people",
"moji": "👳ðŸ¾",
+ "description": "man with turban tone 4",
"unicodeVersion": "8.0",
"digest": "4f4804da4a7c98ad4f9db3ae3eaf674c8977c638e73414e33ef1f65098e413a3"
},
"man_with_turban_tone5": {
"category": "people",
"moji": "👳ðŸ¿",
+ "description": "man with turban tone 5",
"unicodeVersion": "8.0",
"digest": "240282aa346ef9b1d0d475ea93a02597697f0f56f086305879b532b0b933210a"
},
"mans_shoe": {
"category": "people",
"moji": "👞",
+ "description": "mans shoe",
"unicodeVersion": "6.0",
"digest": "f53fe74abd9906cd3e2dd7e7bddbe1feb9f8f7be28b807fabe452f1f60ca1b84"
},
"map": {
"category": "objects",
"moji": "🗺",
+ "description": "world map",
"unicodeVersion": "7.0",
"digest": "84f496a062b5c3ae1e8013506175a69036038c8130891bcf780a69ce7fcbe4de"
},
"maple_leaf": {
"category": "nature",
"moji": "ðŸ",
+ "description": "maple leaf",
"unicodeVersion": "6.0",
"digest": "72629a205e33f89337815ad7e51bb5c73947d1a9f98afe5072bdf4846827ae72"
},
"martial_arts_uniform": {
"category": "activity",
"moji": "🥋",
+ "description": "martial arts uniform",
"unicodeVersion": "9.0",
"digest": "a1ae797b31081425b388ab31efc635d8eb73a40980fd0fae4708aa5313e2a964"
},
"mask": {
"category": "people",
"moji": "😷",
+ "description": "face with medical mask",
"unicodeVersion": "6.0",
"digest": "1b58af9ae599308aabf41bbd38f599fa896bd9fe5df7a40be9f2dc7e0e230600"
},
"massage": {
"category": "people",
"moji": "💆",
+ "description": "face massage",
"unicodeVersion": "6.0",
"digest": "6ee48b4d8cec0bf31e11d7803ad9fc1f909457c8c00cb320b5671395af3c170c"
},
"massage_tone1": {
"category": "people",
"moji": "💆ðŸ»",
+ "description": "face massage tone 1",
"unicodeVersion": "8.0",
"digest": "9da162c2f39628156b87db986a6ada59372a9e9a6b3f0488d21c9e65ec3309bb"
},
"massage_tone2": {
"category": "people",
"moji": "💆ðŸ¼",
+ "description": "face massage tone 2",
"unicodeVersion": "8.0",
"digest": "ac259188549b5b429b8c4929e1da2314859e8857ee49720551467aedfcc96567"
},
"massage_tone3": {
"category": "people",
"moji": "💆ðŸ½",
+ "description": "face massage tone 3",
"unicodeVersion": "8.0",
"digest": "cfd9c105b6debc10448f172afcb20d4192899f7ae5aa8af54c834153a5466364"
},
"massage_tone4": {
"category": "people",
"moji": "💆ðŸ¾",
+ "description": "face massage tone 4",
"unicodeVersion": "8.0",
"digest": "38ab715c621c58454f3cb09153a96380118cf082568554b6edc5f83fb62e9297"
},
"massage_tone5": {
"category": "people",
"moji": "💆ðŸ¿",
+ "description": "face massage tone 5",
"unicodeVersion": "8.0",
"digest": "32480457734121b0c83e9be6d693ae379c95535f43f963c0c2f0f20434ee12c6"
},
"meat_on_bone": {
"category": "food",
"moji": "ðŸ–",
+ "description": "meat on bone",
"unicodeVersion": "6.0",
"digest": "d71a8e0b118d5e6ca60690793ce9649afb78e707fcbd7be890a75564c94434fd"
},
"medal": {
"category": "activity",
"moji": "ðŸ…",
+ "description": "sports medal",
"unicodeVersion": "7.0",
"digest": "9600cbe57e08da090c60629bcafd2821c87322e738c2454f8e883ceb756e7391"
},
"mega": {
"category": "symbols",
"moji": "📣",
+ "description": "cheering megaphone",
"unicodeVersion": "6.0",
"digest": "4b1def6b5b051c5045514063f0ac006222ad81fbfe56d840e14bb950713e331b"
},
"melon": {
"category": "food",
"moji": "ðŸˆ",
+ "description": "melon",
"unicodeVersion": "6.0",
"digest": "0cdd663e6f2129808856cdf0746e6571b62aac641f224adb553baf3bb63ba3bd"
},
"menorah": {
"category": "symbols",
"moji": "🕎",
+ "description": "menorah with nine branches",
"unicodeVersion": "8.0",
"digest": "49fca8c3bc00ea69653ee2f8d4e21e561856ba39716c13e9d107db3e805a2997"
},
"mens": {
"category": "symbols",
"moji": "🚹",
+ "description": "mens symbol",
"unicodeVersion": "6.0",
"digest": "7d92292586ee12a5d1a557c37da4d14708dc3ce701cf32d3280dcc83d91e5df8"
},
"metal": {
"category": "people",
"moji": "🤘",
+ "description": "sign of the horns",
"unicodeVersion": "8.0",
"digest": "ffb750caf187f5d821c990108e2699ac3e216492bcff6ee543f4a7aa55b9fd29"
},
"metal_tone1": {
"category": "people",
"moji": "🤘ðŸ»",
+ "description": "sign of the horns tone 1",
"unicodeVersion": "8.0",
"digest": "5505f0b0340f9ba572db8897e40adf598cfa784686ad5ee360a7351bf44ddc1d"
},
"metal_tone2": {
"category": "people",
"moji": "🤘ðŸ¼",
+ "description": "sign of the horns tone 2",
"unicodeVersion": "8.0",
"digest": "8f9eee3ad5fc7eeeb30118d16d27467b16fd87297e0ecf02656db77e701f5aeb"
},
"metal_tone3": {
"category": "people",
"moji": "🤘ðŸ½",
+ "description": "sign of the horns tone 3",
"unicodeVersion": "8.0",
"digest": "8270a7ecf5eb11431a07ef04cc476c2651ac8aacb0d4768e5cb69355f8a5e84e"
},
"metal_tone4": {
"category": "people",
"moji": "🤘ðŸ¾",
+ "description": "sign of the horns tone 4",
"unicodeVersion": "8.0",
"digest": "f24f7b137dd6c7899dc0a8794204bbde7ad43ec1e63b419c90dd70a8b77871e8"
},
"metal_tone5": {
"category": "people",
"moji": "🤘ðŸ¿",
+ "description": "sign of the horns tone 5",
"unicodeVersion": "8.0",
"digest": "07b0726a632653b980df775f460cd3fe1ea8d4a7b0b46fe29e089b66579482d2"
},
"metro": {
"category": "travel",
"moji": "🚇",
+ "description": "metro",
"unicodeVersion": "6.0",
"digest": "b380247b61b5e2ca1b9b70fabff65907b2c3a5191a14b169ae094af94659b9b1"
},
"microphone": {
"category": "activity",
"moji": "🎤",
+ "description": "microphone",
"unicodeVersion": "6.0",
"digest": "9ef4fc2e40d5391c4bb2d30f34f59662cff7cbb1b04341c9dac210d0e21b44ae"
},
"microphone2": {
"category": "objects",
"moji": "🎙",
+ "description": "studio microphone",
"unicodeVersion": "7.0",
"digest": "8a30464d51f7f101335778444c43270ac0679900f49463e6556682d9db1cb4dc"
},
"microscope": {
"category": "objects",
"moji": "🔬",
+ "description": "microscope",
"unicodeVersion": "6.0",
"digest": "4ca4322c6ba99b8c15acdb8b605f84f87398769e504b262b134c1f3868b2692f"
},
"middle_finger": {
"category": "people",
"moji": "🖕",
+ "description": "reversed hand with middle finger extended",
"unicodeVersion": "7.0",
"digest": "0c3f1cc0ec7323f6d19508ad22fa90050845f7b5cc83f599ab2cacb89cf5dd0e"
},
"middle_finger_tone1": {
"category": "people",
"moji": "🖕ðŸ»",
+ "description": "reversed hand with middle finger extended tone 1",
"unicodeVersion": "8.0",
"digest": "4ebecf1058a3059aaa826eaad39c1a791120f115f65dde6d6ae32fc5561f60f7"
},
"middle_finger_tone2": {
"category": "people",
"moji": "🖕ðŸ¼",
+ "description": "reversed hand with middle finger extended tone 2",
"unicodeVersion": "8.0",
"digest": "85ff506a08c38663c2dfa2e3a90584c02a36aa3dda33af47cdb49834bf9baf83"
},
"middle_finger_tone3": {
"category": "people",
"moji": "🖕ðŸ½",
+ "description": "reversed hand with middle finger extended tone 3",
"unicodeVersion": "8.0",
"digest": "cac697ff5207bf8a4e091912f3127f4e73c88ef69b5c6561d1d7b12ed60be8f1"
},
"middle_finger_tone4": {
"category": "people",
"moji": "🖕ðŸ¾",
+ "description": "reversed hand with middle finger extended tone 4",
"unicodeVersion": "8.0",
"digest": "9324a5a4e3986b798ad8c61f31c18fb507ca7a4abfd6e9ae1408b80b185bf8c7"
},
"middle_finger_tone5": {
"category": "people",
"moji": "🖕ðŸ¿",
+ "description": "reversed hand with middle finger extended tone 5",
"unicodeVersion": "8.0",
"digest": "078f917cd4d8be08a880724e9400449980d92740ccbee4a57f5046a9cf7f6575"
},
"military_medal": {
"category": "activity",
"moji": "🎖",
+ "description": "military medal",
"unicodeVersion": "7.0",
"digest": "5da18351dc14b66cfc070148c83b7c8e67e6b1e3f515ae501133c38ee5c28d3d"
},
"milk": {
"category": "food",
"moji": "🥛",
+ "description": "glass of milk",
"unicodeVersion": "9.0",
"digest": "38b28ea40399601fabc95bac5eaaf5a9e4e25548ec80325bd5069395ea884f85"
},
"milky_way": {
"category": "travel",
"moji": "🌌",
+ "description": "milky way",
"unicodeVersion": "6.0",
"digest": "17405ff31d94b13a1fb0adcda204b8adb95ca340bc3980d9ad9f42ba1e366e7d"
},
"minibus": {
"category": "travel",
"moji": "ðŸš",
+ "description": "minibus",
"unicodeVersion": "6.0",
"digest": "08ccb4b1bf397b7c9aed901e2b5dcdd6cb8ca5c5487ef26775bb3120f7b92524"
},
"minidisc": {
"category": "objects",
"moji": "💽",
+ "description": "minidisc",
"unicodeVersion": "6.0",
"digest": "bebf82c0b91ef66321e7ae7a0abf322e59b2f7d8e6fbf9a94243210c00229c59"
},
"mobile_phone_off": {
"category": "symbols",
"moji": "📴",
+ "description": "mobile phone off",
"unicodeVersion": "6.0",
"digest": "6f9d8d6a32fc998f5d8144a5ff7e2ad00de37ad464cd97285e7c72efb09a1feb"
},
"money_mouth": {
"category": "people",
"moji": "🤑",
+ "description": "money-mouth face",
"unicodeVersion": "8.0",
"digest": "5a43973dadf48a89201b1816fea9972c5cfe501a26fe457b6f7eee0a6362018e"
},
"money_with_wings": {
"category": "objects",
"moji": "💸",
+ "description": "money with wings",
"unicodeVersion": "6.0",
"digest": "15fcf0595021374ba091ca00efdb4167770da4d421eab930964108545f4edab9"
},
"moneybag": {
"category": "objects",
"moji": "💰",
+ "description": "money bag",
"unicodeVersion": "6.0",
"digest": "02d708e2f603b0df6f6c169b5c49b3452e1c02e7d72e96f228b73d0b0a20bff4"
},
"monkey": {
"category": "nature",
"moji": "ðŸ’",
+ "description": "monkey",
"unicodeVersion": "6.0",
"digest": "3588a544d6d9e9995b45d60327a1a42002fa1faa4d48224b140facd249af1c67"
},
"monkey_face": {
"category": "nature",
"moji": "ðŸµ",
+ "description": "monkey face",
"unicodeVersion": "6.0",
"digest": "9e263ef5ca42bb76d1b1d1e3cbf020bcf05023a6e9f91301d30c9eb406363a2a"
},
"monorail": {
"category": "travel",
"moji": "ðŸš",
+ "description": "monorail",
"unicodeVersion": "6.0",
"digest": "2c9f185babcb4001fcef2b8dfc4a32126729843084d0076c3e3ccdc845ab23ad"
},
"mortar_board": {
"category": "people",
"moji": "🎓",
+ "description": "graduation cap",
"unicodeVersion": "6.0",
"digest": "d7fbe41d4b340d3564e484aec46a22c9613521414b2ba6eece2180db4d23e410"
},
"mosque": {
"category": "travel",
"moji": "🕌",
+ "description": "mosque",
"unicodeVersion": "8.0",
"digest": "5f3d3de7feac953a70a318113531c2857d760a516c3d8d6f42d2a3b3b67ed196"
},
"motor_scooter": {
"category": "travel",
"moji": "🛵",
+ "description": "motor scooter",
"unicodeVersion": "9.0",
"digest": "e2dc7c981744a71f46858bd0858ff91af704ac06425ed80377bc3b119e57c872"
},
"motorboat": {
"category": "travel",
"moji": "🛥",
+ "description": "motorboat",
"unicodeVersion": "7.0",
"digest": "81c156643528c5a94a12d6d478e52a019f5a4e3eb58ee365cdd9d2361a7fdb01"
},
"motorcycle": {
"category": "travel",
"moji": "ðŸ",
+ "description": "racing motorcycle",
"unicodeVersion": "7.0",
"digest": "354aa8157732184ad50eff9330f7a8915309dc9b7893cc308226adb429311a62"
},
"motorway": {
"category": "travel",
"moji": "🛣",
+ "description": "motorway",
"unicodeVersion": "7.0",
"digest": "148c3c13c7c4565453d16e504e0d4b8d007e4f2cad1ab56b1b51fefe39162d17"
},
"mount_fuji": {
"category": "travel",
"moji": "🗻",
+ "description": "mount fuji",
"unicodeVersion": "6.0",
"digest": "f8093b9dba62b22c6c88f137be88b2fd3971c560714db15ec053cf697a3820bc"
},
"mountain": {
"category": "travel",
"moji": "â›°",
+ "description": "mountain",
"unicodeVersion": "5.2",
"digest": "07423804ad79da68f140948d29df193f5d5343b7b2c23758c086697c4d3a50da"
},
"mountain_bicyclist": {
"category": "activity",
"moji": "🚵",
+ "description": "mountain bicyclist",
"unicodeVersion": "6.0",
"digest": "91084b6c887cb7e34f3d7ec30656ecb82c36cc987f53a6c83ccb4c6f7950f96a"
},
"mountain_bicyclist_tone1": {
"category": "activity",
"moji": "🚵ðŸ»",
+ "description": "mountain bicyclist tone 1",
"unicodeVersion": "8.0",
"digest": "5d57fcfad61bca26c3e8965eb57602a1993a3117ebdda0f24569af730310ab6e"
},
"mountain_bicyclist_tone2": {
"category": "activity",
"moji": "🚵ðŸ¼",
+ "description": "mountain bicyclist tone 2",
"unicodeVersion": "8.0",
"digest": "c0da7fb85d99aa01a665f64063cd7e2d994f8a16d3f6fbf52df5d471e771a98a"
},
"mountain_bicyclist_tone3": {
"category": "activity",
"moji": "🚵ðŸ½",
+ "description": "mountain bicyclist tone 3",
"unicodeVersion": "8.0",
"digest": "b099e7ee84eae44ebc99023fa06bdf37ffa0d69767c7c0163a89f7ced2a26765"
},
"mountain_bicyclist_tone4": {
"category": "activity",
"moji": "🚵ðŸ¾",
+ "description": "mountain bicyclist tone 4",
"unicodeVersion": "8.0",
"digest": "9d09f7b3899ea44e736f237a161ef8d5170dccfa162a872c59532ceaf65ee007"
},
"mountain_bicyclist_tone5": {
"category": "activity",
"moji": "🚵ðŸ¿",
+ "description": "mountain bicyclist tone 5",
"unicodeVersion": "8.0",
"digest": "71e374981d955056748a60c6d1820b45e9688a156b55318b4ea54a3a67ca801c"
},
"mountain_cableway": {
"category": "travel",
"moji": "🚠",
+ "description": "mountain cableway",
"unicodeVersion": "6.0",
"digest": "e261c3292758b1c0063c5a0d0c7f5c9803306d2265e08677027e1210506ced94"
},
"mountain_railway": {
"category": "travel",
"moji": "🚞",
+ "description": "mountain railway",
"unicodeVersion": "6.0",
"digest": "b0987f8f391b3cbc7a56b9b8945ebfca240e01d12f8fd163877ebebe51d6b277"
},
"mountain_snow": {
"category": "travel",
"moji": "ðŸ”",
+ "description": "snow capped mountain",
"unicodeVersion": "7.0",
"digest": "49aac2b851aa6f2bd2ca641efa8060f93e89395357f49d211658d46f5a2b0189"
},
"mouse": {
"category": "nature",
"moji": "ðŸ­",
+ "description": "mouse face",
"unicodeVersion": "6.0",
"digest": "007dd108507b45224f7a1fad3c1de6ecc75f38d71fc142744611eb13555f5eff"
},
"mouse2": {
"category": "nature",
"moji": "ðŸ",
+ "description": "mouse",
"unicodeVersion": "6.0",
"digest": "f3ed37b639b7c16aae49502bd423f9fdeabaf15bc6f0f74063954b189e176b5d"
},
"mouse_three_button": {
"category": "objects",
"moji": "🖱",
+ "description": "three button mouse",
"unicodeVersion": "7.0",
"digest": "3724341ac5ad0d01027ef1575db64f1db7619f590ca6ada960d1f2c18dc7fc6a"
},
"movie_camera": {
"category": "objects",
"moji": "🎥",
+ "description": "movie camera",
"unicodeVersion": "6.0",
"digest": "f7e285eda35b4431c07951e071643ddc34147cd76640e0d516fbfd11208346e9"
},
"moyai": {
"category": "objects",
"moji": "🗿",
+ "description": "moyai",
"unicodeVersion": "6.0",
"digest": "2c1d0662c95928936e6b9ab5a40c6110ff1cea5339f2803c7b63aabc76115afb"
},
"mrs_claus": {
"category": "people",
"moji": "🤶",
+ "description": "mother christmas",
"unicodeVersion": "9.0",
"digest": "1f72f586ca75bd7ebb4150cdcc8199a930c32fa4b81510cb8d200f1b3ddd4076"
},
"mrs_claus_tone1": {
"category": "people",
"moji": "🤶ðŸ»",
+ "description": "mother christmas tone 1",
"unicodeVersion": "9.0",
"digest": "244596919e0fed050203cf9e040899de323d7821235929f175852439927bd129"
},
"mrs_claus_tone2": {
"category": "people",
"moji": "🤶ðŸ¼",
+ "description": "mother christmas tone 2",
"unicodeVersion": "9.0",
"digest": "8cde96e8521f3a90262a7f5f8a2989a9590d9a02cda2c37e92335dc05975c18d"
},
"mrs_claus_tone3": {
"category": "people",
"moji": "🤶ðŸ½",
+ "description": "mother christmas tone 3",
"unicodeVersion": "9.0",
"digest": "c39cd4346d4581799dd0e9a6447c91a954a75747bf2682c8e4d79c3b0fcf7405"
},
"mrs_claus_tone4": {
"category": "people",
"moji": "🤶ðŸ¾",
+ "description": "mother christmas tone 4",
"unicodeVersion": "9.0",
"digest": "84c85cf54559ea2d78d196fee96149a249af4f959b78e223a0ec4fb72abdbcab"
},
"mrs_claus_tone5": {
"category": "people",
"moji": "🤶ðŸ¿",
+ "description": "mother christmas tone 5",
"unicodeVersion": "9.0",
"digest": "ce26c0e0645713b17e7497d9f2d0484cc5477564dae99320cabf04d160d3b2ff"
},
"muscle": {
"category": "people",
"moji": "💪",
+ "description": "flexed biceps",
"unicodeVersion": "6.0",
"digest": "e4ce52757b2b7982e2516e0e8bf2e2253617cc9f3e6178f1887c61c9039461ba"
},
"muscle_tone1": {
"category": "people",
"moji": "💪ðŸ»",
+ "description": "flexed biceps tone 1",
"unicodeVersion": "8.0",
"digest": "4a2fa226a05bb847b62cdd163eb6c2d514d3c2330a727991cf550c0d32b0e818"
},
"muscle_tone2": {
"category": "people",
"moji": "💪ðŸ¼",
+ "description": "flexed biceps tone 2",
"unicodeVersion": "8.0",
"digest": "a8d5ecce335c782ca5f5e55763c06cfefa1c16c24cd6602237cf125d4ff95e47"
},
"muscle_tone3": {
"category": "people",
"moji": "💪ðŸ½",
+ "description": "flexed biceps tone 3",
"unicodeVersion": "8.0",
"digest": "070354b443faec3969663b770545fc4cf5ec75148557b2b9d6fc82ab22b43bd1"
},
"muscle_tone4": {
"category": "people",
"moji": "💪ðŸ¾",
+ "description": "flexed biceps tone 4",
"unicodeVersion": "8.0",
"digest": "8eafcdb6a607aeafa673c257df0d2a1b20f00fc0868d811babcbe784490a0dd3"
},
"muscle_tone5": {
"category": "people",
"moji": "💪ðŸ¿",
+ "description": "flexed biceps tone 5",
"unicodeVersion": "8.0",
"digest": "85a1e2b5c89907694240e9c5b9d876a741fa7ba38918c5718273e289cbc40efe"
},
"mushroom": {
"category": "nature",
"moji": "ðŸ„",
+ "description": "mushroom",
"unicodeVersion": "6.0",
"digest": "aaca8cf7c5cfa4487b5fef365a231f98be4bbf041197fc022161bcc8ce6f57c8"
},
"musical_keyboard": {
"category": "activity",
"moji": "🎹",
+ "description": "musical keyboard",
"unicodeVersion": "6.0",
"digest": "fb0a726728900377d76d94aac9c94dce29107e8e3f1dcb0599d95bce7169b492"
},
"musical_note": {
"category": "symbols",
"moji": "🎵",
+ "description": "musical note",
"unicodeVersion": "6.0",
"digest": "41288e79b4070bb980281d0e0d1c14d8b144b4aedb2eaadb9f2bebcb4ef892b4"
},
"musical_score": {
"category": "activity",
"moji": "🎼",
+ "description": "musical score",
"unicodeVersion": "6.0",
"digest": "f0f91b9fa4a2bff7a5a1a11afa6f31cfe7e5fa8b0d6f3cce904b781a28ed0277"
},
"mute": {
"category": "symbols",
"moji": "🔇",
+ "description": "speaker with cancellation stroke",
"unicodeVersion": "6.0",
"digest": "def277da49d744b55c7cdde269a15aa05315898f615e721ee7e9205d7b8030d6"
},
"nail_care": {
"category": "people",
"moji": "💅",
+ "description": "nail polish",
"unicodeVersion": "6.0",
"digest": "48b33b1dbbd25b4f34ab2ca07bb99ddaaaa741990142c5623310f76b78c076f9"
},
"nail_care_tone1": {
"category": "people",
"moji": "💅ðŸ»",
+ "description": "nail polish tone 1",
"unicodeVersion": "8.0",
"digest": "a9ac92a34f407e7dd7c71377e6275e66657f7f42e4b911c540d1a66a02d92ac5"
},
"nail_care_tone2": {
"category": "people",
"moji": "💅ðŸ¼",
+ "description": "nail polish tone 2",
"unicodeVersion": "8.0",
"digest": "f295ec85980aaa75818fad619c3d25042146ecbbf361db9e9bb96e7bc202bc73"
},
"nail_care_tone3": {
"category": "people",
"moji": "💅ðŸ½",
+ "description": "nail polish tone 3",
"unicodeVersion": "8.0",
"digest": "02ec373052a250977298bae85262177910126cc10de9480f1afa328ac2f65a95"
},
"nail_care_tone4": {
"category": "people",
"moji": "💅ðŸ¾",
+ "description": "nail polish tone 4",
"unicodeVersion": "8.0",
"digest": "f3d95390ab59caedfda66122bbd0acf3aabedc142fc48352d68900766a7e6f5c"
},
"nail_care_tone5": {
"category": "people",
"moji": "💅ðŸ¿",
+ "description": "nail polish tone 5",
"unicodeVersion": "8.0",
"digest": "009423c97f2aafd24fb8c7c485c58b30bbf9ae6797cc14b80d472b207327b518"
},
"name_badge": {
"category": "symbols",
"moji": "📛",
+ "description": "name badge",
"unicodeVersion": "6.0",
"digest": "f9f6a4895ff0be8fb2ccc7ad195b94e9650f742f66ead999e90724cfb77af628"
},
"nauseated_face": {
"category": "people",
"moji": "🤢",
+ "description": "nauseated face",
"unicodeVersion": "9.0",
"digest": "f8471cf4720948d8246ec9d30e29783e819f90e3cfe8b1ba628671a1aad1a91c"
},
"necktie": {
"category": "people",
"moji": "👔",
+ "description": "necktie",
"unicodeVersion": "6.0",
"digest": "01bb18dc8bfe787daa9613b5d09988cd5a065449ef906099ce3cb308c8a7da68"
},
"negative_squared_cross_mark": {
"category": "symbols",
"moji": "âŽ",
+ "description": "negative squared cross mark",
"unicodeVersion": "6.0",
"digest": "1cdaf4abc9adafa089c91c2e33a24e9e647aea0f857e767941a899a16ec53b74"
},
"nerd": {
"category": "people",
"moji": "🤓",
+ "description": "nerd face",
"unicodeVersion": "8.0",
"digest": "9e5f3c93db25cf1d0f9d6e6bd2993161afec6c30573ba3fe85e13b8c84483d66"
},
"neutral_face": {
"category": "people",
"moji": "ðŸ˜",
+ "description": "neutral face",
"unicodeVersion": "6.0",
"digest": "7449430a60619956573e9dc80834045296f2b99853737b6c7794c785ff53d64e"
},
"new": {
"category": "symbols",
"moji": "🆕",
+ "description": "squared new",
"unicodeVersion": "6.0",
"digest": "e20bc3e9f40726afd0cfb7268d02f1e1a07343364fd08b252d59f38de067bf06"
},
"new_moon": {
"category": "nature",
"moji": "🌑",
+ "description": "new moon symbol",
"unicodeVersion": "6.0",
"digest": "dbfc5dcae34b45f15ff767e297cba3a12cb83f3b542db8cfc8dbd9669e0df46c"
},
"new_moon_with_face": {
"category": "nature",
"moji": "🌚",
+ "description": "new moon with face",
"unicodeVersion": "6.0",
"digest": "c66d347d2222ac8d77d323a07699aff6b168328648db4f885b1ed0e2831fd59b"
},
"newspaper": {
"category": "objects",
"moji": "📰",
+ "description": "newspaper",
"unicodeVersion": "6.0",
"digest": "c05e986d9cdac11afa30c6a21a72572ddf50fc64e87ae0c4e0ad57ffe70acc5c"
},
"newspaper2": {
"category": "objects",
"moji": "🗞",
+ "description": "rolled-up newspaper",
"unicodeVersion": "7.0",
"digest": "63db7bcf51effc73e5124392740736383774a4bcfbc1156cf55599504760883d"
},
"ng": {
"category": "symbols",
"moji": "🆖",
+ "description": "squared ng",
"unicodeVersion": "6.0",
"digest": "34d5a11c70f48ea719e602908534f446b192622e775d4160f0e1ec52c342a35c"
},
"night_with_stars": {
"category": "travel",
"moji": "🌃",
+ "description": "night with stars",
"unicodeVersion": "6.0",
"digest": "39d9c079be80ee6ce1667531be528a2aa7f8bd46c7b6c2a6ee279d9a207c84a4"
},
"nine": {
"category": "symbols",
"moji": "9ï¸âƒ£",
+ "description": "keycap digit nine",
"unicodeVersion": "3.0",
"digest": "8bb40750eda8506ef877c9a3b8e2039d26f20eef345742f635740574a7e8daa6"
},
"no_bell": {
"category": "symbols",
"moji": "🔕",
+ "description": "bell with cancellation stroke",
"unicodeVersion": "6.0",
"digest": "6542a9a5656c79c153f8c37f12d48f677c89b02ed0989ae37fa5e51ce6895422"
},
"no_bicycles": {
"category": "symbols",
"moji": "🚳",
+ "description": "no bicycles",
"unicodeVersion": "6.0",
"digest": "af71c183545da2ff4c05609f9d572edb64b63ccba7c6a4b208d271558aa92b0a"
},
"no_entry": {
"category": "symbols",
"moji": "â›”",
+ "description": "no entry",
"unicodeVersion": "5.2",
"digest": "dc0bac1ed9ab8e9af143f0fce5043fe68f7f46bd80856cdec95d20c3999b637d"
},
"no_entry_sign": {
"category": "symbols",
"moji": "🚫",
+ "description": "no entry sign",
"unicodeVersion": "6.0",
"digest": "2c1fceef23b62effca68e0e087b8f020125d25b98d61492b1540055d1914fdc3"
},
"no_good": {
"category": "people",
"moji": "🙅",
+ "description": "face with no good gesture",
"unicodeVersion": "6.0",
"digest": "6eb970b104389be5d18657d7c04be5149958c26855c52ea68574af852c5f85c4"
},
"no_good_tone1": {
"category": "people",
"moji": "🙅ðŸ»",
+ "description": "face with no good gesture tone 1",
"unicodeVersion": "8.0",
"digest": "c20a24a1e536240b4dcf90ecb530796de621d7ba1fb9e3fa0f849d048c509c03"
},
"no_good_tone2": {
"category": "people",
"moji": "🙅ðŸ¼",
+ "description": "face with no good gesture tone 2",
"unicodeVersion": "8.0",
"digest": "f31a4628c1f2e6a39288fda8eb19c9ec89983e3726e17a09384d9ecc13ef0b4c"
},
"no_good_tone3": {
"category": "people",
"moji": "🙅ðŸ½",
+ "description": "face with no good gesture tone 3",
"unicodeVersion": "8.0",
"digest": "959dec1bfdaf37b20a86ab2bcbdbacd3179c87b163042377d966eab47564c0fb"
},
"no_good_tone4": {
"category": "people",
"moji": "🙅ðŸ¾",
+ "description": "face with no good gesture tone 4",
"unicodeVersion": "8.0",
"digest": "efd931f0080adf2e04129c83a8b24fda0ae7a9fa7c4b463686c0b99023620db8"
},
"no_good_tone5": {
"category": "people",
"moji": "🙅ðŸ¿",
+ "description": "face with no good gesture tone 5",
"unicodeVersion": "8.0",
"digest": "f35df2b26af9baef47c1f8cc97a1b28a58aa7fcb2a13fdac7b2d9189f1e40105"
},
"no_mobile_phones": {
"category": "symbols",
"moji": "📵",
+ "description": "no mobile phones",
"unicodeVersion": "6.0",
"digest": "a472decd6ac7f9777961c09e00458746b2c04965585e3bee4556be3968e55bcd"
},
"no_mouth": {
"category": "people",
"moji": "😶",
+ "description": "face without mouth",
"unicodeVersion": "6.0",
"digest": "72dda8b1e3ad4b05d9b095f9bd05e95d7ba013906c68914976a4554e8edf5866"
},
"no_pedestrians": {
"category": "symbols",
"moji": "🚷",
+ "description": "no pedestrians",
"unicodeVersion": "6.0",
"digest": "062b4a71b338fe09775e465bfba8ac04efbb3640330e8cabe88f3af62b0f4225"
},
"no_smoking": {
"category": "symbols",
"moji": "🚭",
+ "description": "no smoking symbol",
"unicodeVersion": "6.0",
"digest": "ae2ebb331f79f6074091c0ee9cd69fce16d5e12a131d18973fc05520097e14ee"
},
"non-potable_water": {
"category": "symbols",
"moji": "🚱",
+ "description": "non-potable water symbol",
"unicodeVersion": "6.0",
"digest": "32eba0a99b498133c2e4450036f768d3dccaaf5b50adc9ad988757adc777a6a1"
},
"nose": {
"category": "people",
"moji": "👃",
+ "description": "nose",
"unicodeVersion": "6.0",
"digest": "9f800e24658ea3cebe1144d5d808cf13a88261f1a7f1f81a10d03b3d9d00e541"
},
"nose_tone1": {
"category": "people",
"moji": "👃ðŸ»",
+ "description": "nose tone 1",
"unicodeVersion": "8.0",
"digest": "a2d0af22284b1d264eb780943b8360f463996a5c9c9584b8473edf8d442d9173"
},
"nose_tone2": {
"category": "people",
"moji": "👃ðŸ¼",
+ "description": "nose tone 2",
"unicodeVersion": "8.0",
"digest": "244dcaa8540024cf521f29f36bd48f933bf82f4833e35e6fa0abf113022038f3"
},
"nose_tone3": {
"category": "people",
"moji": "👃ðŸ½",
+ "description": "nose tone 3",
"unicodeVersion": "8.0",
"digest": "c935b64866f0d49da52035aa09f36ff56d238eb7f5b92205386451056e8ea74f"
},
"nose_tone4": {
"category": "people",
"moji": "👃ðŸ¾",
+ "description": "nose tone 4",
"unicodeVersion": "8.0",
"digest": "a87e95fd9319c49e66b6dea0e57319d0ed9921b8d94df037767bf3d5dc7c94f3"
},
"nose_tone5": {
"category": "people",
"moji": "👃ðŸ¿",
+ "description": "nose tone 5",
"unicodeVersion": "8.0",
"digest": "1e0f9842e0f8ad5805eabd3f35a6038b7a2e49d566a1f5c17271f9cdf467ca60"
},
"notebook": {
"category": "objects",
"moji": "📓",
+ "description": "notebook",
"unicodeVersion": "6.0",
"digest": "fc679d3728f86073d1607a926885dd8b0261132f5c4a0322f1e46ea9f95c8cb8"
},
"notebook_with_decorative_cover": {
"category": "objects",
"moji": "📔",
+ "description": "notebook with decorative cover",
"unicodeVersion": "6.0",
"digest": "d822eda4b49cbfa399b36f134c1a0b8dcfdd27ed89f12c50bc18f6f0a9aa56ef"
},
"notepad_spiral": {
"category": "objects",
"moji": "🗒",
+ "description": "spiral note pad",
"unicodeVersion": "7.0",
"digest": "c6a8e16aa62474cef13e5659fddb4afc57e3f79635e32e6020edbee2b5b50f18"
},
"notes": {
"category": "symbols",
"moji": "🎶",
+ "description": "multiple musical notes",
"unicodeVersion": "6.0",
"digest": "98467e0adc134d45676ef1c6c459e5853a9db50c8a6e91b6aec7d449aa737f48"
},
"nut_and_bolt": {
"category": "objects",
"moji": "🔩",
+ "description": "nut and bolt",
"unicodeVersion": "6.0",
"digest": "a77bd72f29a7302195dcec240174b15586de79e3204258e3fb401a6ea90563b3"
},
"o": {
"category": "symbols",
"moji": "â­•",
+ "description": "heavy large circle",
"unicodeVersion": "5.2",
"digest": "2387e5fd9ae4c2972d40298d32319b8fa55c50dbfc1c04c5c36088213e6951dd"
},
"o2": {
"category": "symbols",
"moji": "🅾",
+ "description": "negative squared latin capital letter o",
"unicodeVersion": "6.0",
"digest": "6a9ccb0bf394e4d05ffda19327cee18f7b9ed80367fc7f41c93da9bb7efab0bf"
},
"ocean": {
"category": "nature",
"moji": "🌊",
+ "description": "water wave",
"unicodeVersion": "6.0",
"digest": "1a9ca9848d4fb75852addfc10bf84eccf7caa5339714b90e3de4cb6f2518465e"
},
"octagonal_sign": {
"category": "symbols",
"moji": "🛑",
+ "description": "octagonal sign",
"unicodeVersion": "9.0",
"digest": "9f6927048e1f9da57f89d1ae1eb86fa4ab7abdbabca756a738a799e948d0b3f9"
},
"octopus": {
"category": "nature",
"moji": "ðŸ™",
+ "description": "octopus",
"unicodeVersion": "6.0",
"digest": "0fcc65c12f4b29ea75a8c4823d20838a7e6db6978fdcb536943072aa1460bc59"
},
"oden": {
"category": "food",
"moji": "ðŸ¢",
+ "description": "oden",
"unicodeVersion": "6.0",
"digest": "089974cb13a0bef6a245fc73029c5ed5153fd4caae0177b835f779e32200b8aa"
},
"office": {
"category": "travel",
"moji": "ðŸ¢",
+ "description": "office building",
"unicodeVersion": "6.0",
"digest": "3633a2e91036362e273eef4e0cfbdbbb4cb1208afe2cfa110ebef7b78109a66f"
},
"oil": {
"category": "objects",
"moji": "🛢",
+ "description": "oil drum",
"unicodeVersion": "7.0",
"digest": "00b94d33bcc9b9e8a5d4bd6e7f7e2fced9497ce05919edd5e58eafbc011c2caa"
},
"ok": {
"category": "symbols",
"moji": "🆗",
+ "description": "squared ok",
"unicodeVersion": "6.0",
"digest": "5f320f9b96e98a2f17ebe240daff9b9fd2ae0727cd6c8e4633b1744356e89365"
},
"ok_hand": {
"category": "people",
"moji": "👌",
+ "description": "ok hand sign",
"unicodeVersion": "6.0",
"digest": "d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d"
},
"ok_hand_tone1": {
"category": "people",
"moji": "👌ðŸ»",
+ "description": "ok hand sign tone 1",
"unicodeVersion": "8.0",
"digest": "ef1508efcf483b09807554fe0e451c2948224f9deb85463e8e0dad6875b54012"
},
"ok_hand_tone2": {
"category": "people",
"moji": "👌ðŸ¼",
+ "description": "ok hand sign tone 2",
"unicodeVersion": "8.0",
"digest": "1215a101a082fd8e04c5d2f7e3c59d0f480cb0bedd79aeab5d36676bfe760088"
},
"ok_hand_tone3": {
"category": "people",
"moji": "👌ðŸ½",
+ "description": "ok hand sign tone 3",
"unicodeVersion": "8.0",
"digest": "6fe0ed9fb42e86bb2bed4cb37b2acacacda1471fb1ee845ad55e54fb0897fbf4"
},
"ok_hand_tone4": {
"category": "people",
"moji": "👌ðŸ¾",
+ "description": "ok hand sign tone 4",
"unicodeVersion": "8.0",
"digest": "bfb9041c49d95e901a667264abaf9b398f6c4aa8b52bf5191c122db20c13c020"
},
"ok_hand_tone5": {
"category": "people",
"moji": "👌ðŸ¿",
+ "description": "ok hand sign tone 5",
"unicodeVersion": "8.0",
"digest": "1c218dc04d698da2cbdd7bea1ca3f845f9b386e967b7247c52f4b0f6ec8f5320"
},
"ok_woman": {
"category": "people",
"moji": "🙆",
+ "description": "face with ok gesture",
"unicodeVersion": "6.0",
"digest": "3f8bd4ce2c4497155d697e5a71ebdc9339f65633d07fa9a7903e1bd76cfa4ba1"
},
"ok_woman_tone1": {
"category": "people",
"moji": "🙆ðŸ»",
+ "description": "face with ok gesture tone1",
"unicodeVersion": "8.0",
"digest": "1660cd904ccd2ecdc6f4ba00527f7d4ec8c33f3c6183344616f97badae4c3730"
},
"ok_woman_tone2": {
"category": "people",
"moji": "🙆ðŸ¼",
+ "description": "face with ok gesture tone2",
"unicodeVersion": "8.0",
"digest": "7ba5fddd1e141424fac6778894dfc5af28e125839c58937c69496f99cd2c4002"
},
"ok_woman_tone3": {
"category": "people",
"moji": "🙆ðŸ½",
+ "description": "face with ok gesture tone3",
"unicodeVersion": "8.0",
"digest": "1d972b8377c52f598406f59ab1e5be41aaf8f027e1fefba3deda66312ccd6a9b"
},
"ok_woman_tone4": {
"category": "people",
"moji": "🙆ðŸ¾",
+ "description": "face with ok gesture tone4",
"unicodeVersion": "8.0",
"digest": "a176328d8f53503aa743448968afd21d72ffd3510555526a3fb38d6b30ee7c15"
},
"ok_woman_tone5": {
"category": "people",
"moji": "🙆ðŸ¿",
+ "description": "face with ok gesture tone5",
"unicodeVersion": "8.0",
"digest": "13cfc1b589c57e81f768ee07a14b737cafc71407a7eb0956728b2ec4b1df14c4"
},
"older_man": {
"category": "people",
"moji": "👴",
+ "description": "older man",
"unicodeVersion": "6.0",
"digest": "4c0462b199bf26181c9e4d2d4cb878a32b0294566941212efc67362d0645f948"
},
"older_man_tone1": {
"category": "people",
"moji": "👴ðŸ»",
+ "description": "older man tone 1",
"unicodeVersion": "8.0",
"digest": "99baa083f78cb01166d0a928d0b53682be14be04c29fc17bef14aac1a73a61e6"
},
"older_man_tone2": {
"category": "people",
"moji": "👴ðŸ¼",
+ "description": "older man tone 2",
"unicodeVersion": "8.0",
"digest": "5b4ce713e8820ba517fe92c25f3b93e6a6bf3704d1f982c461d5f31fc02b9d3d"
},
"older_man_tone3": {
"category": "people",
"moji": "👴ðŸ½",
+ "description": "older man tone 3",
"unicodeVersion": "8.0",
"digest": "0eff72b3226c3a703c635798ee84129a695c896fa011fe1adbc105312eecc083"
},
"older_man_tone4": {
"category": "people",
"moji": "👴ðŸ¾",
+ "description": "older man tone 4",
"unicodeVersion": "8.0",
"digest": "ad9ba82b0c5d3b171b0639ee4265370dbddff5e0eeb70729db122659bb8c8f84"
},
"older_man_tone5": {
"category": "people",
"moji": "👴ðŸ¿",
+ "description": "older man tone 5",
"unicodeVersion": "8.0",
"digest": "5eb0a7467cc40e75752e11fd5126b275863dc037557a0d0d3b24b681e00c2386"
},
"older_woman": {
"category": "people",
"moji": "👵",
+ "description": "older woman",
"unicodeVersion": "6.0",
"digest": "c261fdf3b01e0c7d949e177144531add5895197fbadf1acbba8eb17d18766bf6"
},
"older_woman_tone1": {
"category": "people",
"moji": "👵ðŸ»",
+ "description": "older woman tone 1",
"unicodeVersion": "8.0",
"digest": "1f2bb9e42270a58194498254da27ac2b7a50edaa771b90ee194ccd6d24660c62"
},
"older_woman_tone2": {
"category": "people",
"moji": "👵ðŸ¼",
+ "description": "older woman tone 2",
"unicodeVersion": "8.0",
"digest": "2e28198e9b7ac08c55980677ed66655fd899e157f14184958bebd87fcd714940"
},
"older_woman_tone3": {
"category": "people",
"moji": "👵ðŸ½",
+ "description": "older woman tone 3",
"unicodeVersion": "8.0",
"digest": "c968be0170f7e0c65d4f796337034cfb1daba897884da6fad85635ab5b6edf67"
},
"older_woman_tone4": {
"category": "people",
"moji": "👵ðŸ¾",
+ "description": "older woman tone 4",
"unicodeVersion": "8.0",
"digest": "3596a6fa9a643bf79255afcd29657b03850df8499db9669b92ce013af908af44"
},
"older_woman_tone5": {
"category": "people",
"moji": "👵ðŸ¿",
+ "description": "older woman tone 5",
"unicodeVersion": "8.0",
"digest": "c8998cb3dbd15e22bd1d6dad613d109ce371d9ffca3657e1a8afe5aeb30c1275"
},
"om_symbol": {
"category": "symbols",
"moji": "🕉",
+ "description": "om symbol",
"unicodeVersion": "7.0",
"digest": "5ead73bea546ba9ba6da522f7280cc289c75ff5467742bdba31f92d0e1b3f4e6"
},
"on": {
"category": "symbols",
"moji": "🔛",
+ "description": "on with exclamation mark with left right arrow abo",
"unicodeVersion": "6.0",
"digest": "9cc61a6b31a30c32dab594191bf23f91e341c4105384ab22158a6d43e6364631"
},
"oncoming_automobile": {
"category": "travel",
"moji": "🚘",
+ "description": "oncoming automobile",
"unicodeVersion": "6.0",
"digest": "557c9cacdc3f95215d4f7a6f097a2baa7c007cb9c519492a6717077af4ca6b56"
},
"oncoming_bus": {
"category": "travel",
"moji": "ðŸš",
+ "description": "oncoming bus",
"unicodeVersion": "6.0",
"digest": "059f28ce6bfb337e107db5982cbd2004844450ef20b4a54b9ca3cb738360ab05"
},
"oncoming_police_car": {
"category": "travel",
"moji": "🚔",
+ "description": "oncoming police car",
"unicodeVersion": "6.0",
"digest": "aee79306a0d129cfc1980f58db80391eb46d2d7d5f814bf431414dc7680cab72"
},
"oncoming_taxi": {
"category": "travel",
"moji": "🚖",
+ "description": "oncoming taxi",
"unicodeVersion": "6.0",
"digest": "84351489fc86d980b8d3eb9ec4e81120fe700b3ac01346daebe2b7aeb9607a55"
},
"one": {
"category": "symbols",
"moji": "1ï¸âƒ£",
+ "description": "keycap digit one",
"unicodeVersion": "3.0",
"digest": "d5d3fff04e68a114ff6464ee06fc831f3f381713045165f62a88d5e8215c195b"
},
"open_file_folder": {
"category": "objects",
"moji": "📂",
+ "description": "open file folder",
"unicodeVersion": "6.0",
"digest": "96cfc322ee4903ae8cec07604811742245fd7d14f00bb70276d39d29c48bed28"
},
"open_hands": {
"category": "people",
"moji": "ðŸ‘",
+ "description": "open hands sign",
"unicodeVersion": "6.0",
"digest": "a6c131da2040b48103cea14f280e728675da50fa448d2b3f3438fcbb5bf5596a"
},
"open_hands_tone1": {
"category": "people",
"moji": "ðŸ‘ðŸ»",
+ "description": "open hands sign tone 1",
"unicodeVersion": "8.0",
"digest": "867128dff2fa9b860c10c6b792f989f0c057928783696062378f834c0ef89d85"
},
"open_hands_tone2": {
"category": "people",
"moji": "ðŸ‘ðŸ¼",
+ "description": "open hands sign tone 2",
"unicodeVersion": "8.0",
"digest": "487ff2745b03d49bb3b1d0acd86ba530fd8cc3f467ca3fa504f88f0ef1cbbc01"
},
"open_hands_tone3": {
"category": "people",
"moji": "ðŸ‘ðŸ½",
+ "description": "open hands sign tone 3",
"unicodeVersion": "8.0",
"digest": "cb8cddc8b8661f874ac9478289d16cc41406b947bb87f3363df518a588a53e16"
},
"open_hands_tone4": {
"category": "people",
"moji": "ðŸ‘ðŸ¾",
+ "description": "open hands sign tone 4",
"unicodeVersion": "8.0",
"digest": "17dcc2c07230846a769f3c79ce618a757c88b9b58c95c6c5b2d7f968814d447d"
},
"open_hands_tone5": {
"category": "people",
"moji": "ðŸ‘ðŸ¿",
+ "description": "open hands sign tone 5",
"unicodeVersion": "8.0",
"digest": "36b2493d67c84cea4f3f85a3088c6abcfd35cf99f7aeaeedfafa420ee878e3d2"
},
"open_mouth": {
"category": "people",
"moji": "😮",
+ "description": "face with open mouth",
"unicodeVersion": "6.1",
"digest": "1906c5100ae0c8326ca5c4f9422976958a38dadd8d77724d68538a25d9623035"
},
"ophiuchus": {
"category": "symbols",
"moji": "⛎",
+ "description": "ophiuchus",
"unicodeVersion": "6.0",
"digest": "6112e2a1656b1cb8bd9a8b0dfa6cbf66d30cae671710a9ef75c821de344aab2b"
},
"orange_book": {
"category": "objects",
"moji": "📙",
+ "description": "orange book",
"unicodeVersion": "6.0",
"digest": "41141b08d2beceded21a94795431603c47fd7d42a3a472a2aa8b2bb25fa87ebf"
},
"orthodox_cross": {
"category": "symbols",
"moji": "☦",
+ "description": "orthodox cross",
"unicodeVersion": "1.1",
"digest": "c16372102f0169dd6d32eb2b27a633aaee74e4e0fddcf723c15ad97f9dc6075c"
},
"outbox_tray": {
"category": "objects",
"moji": "📤",
+ "description": "outbox tray",
"unicodeVersion": "6.0",
"digest": "e47cb481a0ffcb39996f32fd313e19b362a91d8dda15ffca48ac23a3b5bb5baf"
},
"owl": {
"category": "nature",
"moji": "🦉",
+ "description": "owl",
"unicodeVersion": "9.0",
"digest": "f62ec1ad23ad9038966eea8d8b79660ac212f291af2e89bcdb0fdc683caf41e5"
},
"ox": {
"category": "nature",
"moji": "ðŸ‚",
+ "description": "ox",
"unicodeVersion": "6.0",
"digest": "d13bc60552190bb9936bf32d681bdc742439b702a09cfc62137ea09a98624aed"
},
"package": {
"category": "objects",
"moji": "📦",
+ "description": "package",
"unicodeVersion": "6.0",
"digest": "e82bf5accebb65136e897c15607eef635fb79fd7b2d8c8e19a9eb00b6786918c"
},
"page_facing_up": {
"category": "objects",
"moji": "📄",
+ "description": "page facing up",
"unicodeVersion": "6.0",
"digest": "3884868bdcb2f29615b09a13a30385cbc5269379094a54b5a7e8a5f4e8ce905a"
},
"page_with_curl": {
"category": "objects",
"moji": "📃",
+ "description": "page with curl",
"unicodeVersion": "6.0",
"digest": "3d6257670189f841ad1fa45415c34feb2433b2cb35bb435c4ee122ce89b39669"
},
"pager": {
"category": "objects",
"moji": "📟",
+ "description": "pager",
"unicodeVersion": "6.0",
"digest": "e21c756cc1c58ebc1b37ebcd38e22a25b31e2e81306c6f18285d6a7671f9eb12"
},
"paintbrush": {
"category": "objects",
"moji": "🖌",
+ "description": "lower left paintbrush",
"unicodeVersion": "7.0",
"digest": "fc0da7a25b726b8be9dd6467953e27293d2313a21eeff21424c2a19be614fff2"
},
"palm_tree": {
"category": "nature",
"moji": "🌴",
+ "description": "palm tree",
"unicodeVersion": "6.0",
"digest": "90fedafd62fe0abf51325174d0f293ebb9a4794913b9ba93b12f2d0119056df1"
},
"pancakes": {
"category": "food",
"moji": "🥞",
+ "description": "pancakes",
"unicodeVersion": "9.0",
"digest": "5256b4832431e8a88555796b1a9726f12d909a26fb2bdc3a0abff76412c45903"
},
"panda_face": {
"category": "nature",
"moji": "ðŸ¼",
+ "description": "panda face",
"unicodeVersion": "6.0",
"digest": "56a4b84abe983bd6569be1b81ac5e43071015fd308389a16b92231310ae56a5b"
},
"paperclip": {
"category": "objects",
"moji": "📎",
+ "description": "paperclip",
"unicodeVersion": "6.0",
"digest": "d1e2ce94a12b7e8b7a9bba49e47ddc7432ec0288545d3b6817c7a499e806e3f0"
},
"paperclips": {
"category": "objects",
"moji": "🖇",
+ "description": "linked paperclips",
"unicodeVersion": "7.0",
"digest": "70cefa0d0777f070e393e9f95c24146fe2dd627f30fa3845baa19310d9291fe2"
},
"park": {
"category": "travel",
"moji": "ðŸž",
+ "description": "national park",
"unicodeVersion": "7.0",
"digest": "444dce8014e0817ddd756c36a38adfbbf7ae4c6aa509e4cae291828f0716d5e7"
},
"parking": {
"category": "symbols",
"moji": "🅿",
+ "description": "negative squared latin capital letter p",
"unicodeVersion": "5.2",
"digest": "9f1da460a7dd58b26beab8cf701be2691fb812208fbc941c71daa35be1507c2f"
},
"part_alternation_mark": {
"category": "symbols",
"moji": "〽",
+ "description": "part alternation mark",
"unicodeVersion": "3.2",
"digest": "956da19353bb38fd4dfe0ab5360679a9035d566858fb5de62887b85c75fb8eef"
},
"partly_sunny": {
"category": "nature",
"moji": "â›…",
+ "description": "sun behind cloud",
"unicodeVersion": "5.2",
"digest": "8fb9a6d2caf9e0cce58447762f0dfd6aa0b581b2e83fea6411348e0cbc8cf3c4"
},
"passport_control": {
"category": "symbols",
"moji": "🛂",
+ "description": "passport control",
"unicodeVersion": "6.0",
"digest": "d9be6eed2c90e1c89171c42d70a06485fdf86a4c68833371832cc1f6897fadd0"
},
"pause_button": {
"category": "symbols",
"moji": "â¸",
+ "description": "double vertical bar",
"unicodeVersion": "7.0",
"digest": "143221d99e82399ed7824b6c5e185700896492058b65c04e4c668291de78b203"
},
"peace": {
"category": "symbols",
"moji": "☮",
+ "description": "peace symbol",
"unicodeVersion": "1.1",
"digest": "65181429e373c1f0507bbd98425c1bec0c042d648fb285a392460cbce60f44d4"
},
"peach": {
"category": "food",
"moji": "ðŸ‘",
+ "description": "peach",
"unicodeVersion": "6.0",
"digest": "768d1f4f29e1e06aff5abb29043be83087ded16427ce6a2d0f682814e665e311"
},
"peanuts": {
"category": "food",
"moji": "🥜",
+ "description": "peanuts",
"unicodeVersion": "9.0",
"digest": "e2384846b6e4a6c3a56e991ebb749cb68b330ac00a9e9d888b2c39105ff7ff5d"
},
"pear": {
"category": "food",
"moji": "ðŸ",
+ "description": "pear",
"unicodeVersion": "6.0",
"digest": "b7c9cf90bb979649b863d2f4132f1b51f6f8107d42e08fb8b4033fea32844948"
},
"pen_ballpoint": {
"category": "objects",
"moji": "🖊",
+ "description": "lower left ballpoint pen",
"unicodeVersion": "7.0",
"digest": "aacb20b220f26704e10303deeea33be0eec2d3811dcba7795902ca44b6ae9876"
},
"pen_fountain": {
"category": "objects",
"moji": "🖋",
+ "description": "lower left fountain pen",
"unicodeVersion": "7.0",
"digest": "3619913eab2b6291f518b40481bb3eca0820d68b0a1b3c11fb6a69c62b75a626"
},
"pencil": {
"category": "objects",
"moji": "ðŸ“",
+ "description": "memo",
"unicodeVersion": "6.0",
"digest": "accbc3f1439b7faa4411e502385f78a16c8e71851f71fc13582753291ffb507c"
},
"pencil2": {
"category": "objects",
"moji": "âœ",
+ "description": "pencil",
"unicodeVersion": "1.1",
"digest": "9ca1b56b5726f472b1f1b23050ed163e213916dac379d22e38e4c8358fe871e0"
},
"penguin": {
"category": "nature",
"moji": "ðŸ§",
+ "description": "penguin",
"unicodeVersion": "6.0",
"digest": "a1800ab931d6dc84a9c89bfab2c815198025c276d952509c55b18dd20bd9d316"
},
"pensive": {
"category": "people",
"moji": "😔",
+ "description": "pensive face",
"unicodeVersion": "6.0",
"digest": "d237deff9f5ead8a0b281b7e5c6f4b82e98cc30c80c86c22c3fdc6160090b2f2"
},
"performing_arts": {
"category": "activity",
"moji": "🎭",
+ "description": "performing arts",
"unicodeVersion": "6.0",
"digest": "d7c7bc9213e308ca26286cbbd8012e656b0f9b00293758faf1bfccc4c5ceabed"
},
"persevere": {
"category": "people",
"moji": "😣",
+ "description": "persevering face",
"unicodeVersion": "6.0",
"digest": "c361509c9b8663af19a02a1ffff61b1b0d0b4bd75d693ce3d406b0ca1bde1ca0"
},
"person_frowning": {
"category": "people",
"moji": "ðŸ™",
+ "description": "person frowning",
"unicodeVersion": "6.0",
"digest": "b37be8bd95f21a6860ad3f171b8086125ab37331b382d87bcdb4cd684800546b"
},
"person_frowning_tone1": {
"category": "people",
"moji": "ðŸ™ðŸ»",
+ "description": "person frowning tone 1",
"unicodeVersion": "8.0",
"digest": "3d5e78a367f9673baed2a86bc11cf04fd44394aadb65291fa51ade8dca318427"
},
"person_frowning_tone2": {
"category": "people",
"moji": "ðŸ™ðŸ¼",
+ "description": "person frowning tone 2",
"unicodeVersion": "8.0",
"digest": "7456c414c65ad6b6f11855f68a2eedc18113526f86862c4373202397cb1bed2c"
},
"person_frowning_tone3": {
"category": "people",
"moji": "ðŸ™ðŸ½",
+ "description": "person frowning tone 3",
"unicodeVersion": "8.0",
"digest": "c86cf2d6951f1e6a7c786a74caaf68a777cf00e88023e23849d4383f864ae437"
},
"person_frowning_tone4": {
"category": "people",
"moji": "ðŸ™ðŸ¾",
+ "description": "person frowning tone 4",
"unicodeVersion": "8.0",
"digest": "944e96ced645ced8db6bb50120c7e37ed46b6960d595cbfe964c81803efa83aa"
},
"person_frowning_tone5": {
"category": "people",
"moji": "ðŸ™ðŸ¿",
+ "description": "person frowning tone 5",
"unicodeVersion": "8.0",
"digest": "4bd0ea571be6ef9f0493784ef0d12d5e47bc2d6ac610fb42c450bf3d87fb2948"
},
"person_with_blond_hair": {
"category": "people",
"moji": "👱",
+ "description": "person with blond hair",
"unicodeVersion": "6.0",
"digest": "a7f94ede2e43308108c2260d83fc10121dda09a67f94a0a840e6d7bba7fd5616"
},
"person_with_blond_hair_tone1": {
"category": "people",
"moji": "👱ðŸ»",
+ "description": "person with blond hair tone 1",
"unicodeVersion": "8.0",
"digest": "00a116357a7878554c83e5bade4bddfa9cfabf76a229efa19cbb58e0d216219c"
},
"person_with_blond_hair_tone2": {
"category": "people",
"moji": "👱ðŸ¼",
+ "description": "person with blond hair tone 2",
"unicodeVersion": "8.0",
"digest": "df509ebe92ed3138b9d5bd4645eff4b13f77f714cf62bb949c59eff1adc00019"
},
"person_with_blond_hair_tone3": {
"category": "people",
"moji": "👱ðŸ½",
+ "description": "person with blond hair tone 3",
"unicodeVersion": "8.0",
"digest": "6f328513f440a0c8cd1dc44596a5028fd8f306bdaf57c1e6f3aa94a3aa262b3c"
},
"person_with_blond_hair_tone4": {
"category": "people",
"moji": "👱ðŸ¾",
+ "description": "person with blond hair tone 4",
"unicodeVersion": "8.0",
"digest": "32df1a577815b009696643ad80d063cc97b35d54add6d4e5517fc936f6da9ee8"
},
"person_with_blond_hair_tone5": {
"category": "people",
"moji": "👱ðŸ¿",
+ "description": "person with blond hair tone 5",
"unicodeVersion": "8.0",
"digest": "2e270bb39187d8e36a33f4aa4d6045308189595fafc157cf7993e82d7ce93442"
},
"person_with_pouting_face": {
"category": "people",
"moji": "🙎",
+ "description": "person with pouting face",
"unicodeVersion": "6.0",
"digest": "57e9a6e5f82121516dc189173f2a63b218f726cd51014e24a18c2bdfeeec3a0b"
},
"person_with_pouting_face_tone1": {
"category": "people",
"moji": "🙎ðŸ»",
+ "description": "person with pouting face tone1",
"unicodeVersion": "8.0",
"digest": "d10dadb1ac03fc2e221eff77b4c47935dc0b4fe897af3de30461e7226c3b4bbc"
},
"person_with_pouting_face_tone2": {
"category": "people",
"moji": "🙎ðŸ¼",
+ "description": "person with pouting face tone2",
"unicodeVersion": "8.0",
"digest": "efface531537ab934b3b96985210a2dac88de812e82e804d6ec12174e536d1cc"
},
"person_with_pouting_face_tone3": {
"category": "people",
"moji": "🙎ðŸ½",
+ "description": "person with pouting face tone3",
"unicodeVersion": "8.0",
"digest": "7ff26ece237216b949bfa96d16bd12cfd248c6fd3e4ed89aa6c735c09eafaeff"
},
"person_with_pouting_face_tone4": {
"category": "people",
"moji": "🙎ðŸ¾",
+ "description": "person with pouting face tone4",
"unicodeVersion": "8.0",
"digest": "045c04105df41d94ff4942133c7394e42ff35ef76c4ccb711497ab77ae6219f2"
},
"person_with_pouting_face_tone5": {
"category": "people",
"moji": "🙎ðŸ¿",
+ "description": "person with pouting face tone5",
"unicodeVersion": "8.0",
"digest": "783ee37f146fcf61d38af5009f5823cf6526fe99ed891979f454016bce9dd4ba"
},
"pick": {
"category": "objects",
"moji": "â›",
+ "description": "pick",
"unicodeVersion": "5.2",
"digest": "7f0ec5445b4d5c66cf46e2a7332946cce34bd70e9929ac7a119251a7f57f555d"
},
"pig": {
"category": "nature",
"moji": "ðŸ·",
+ "description": "pig face",
"unicodeVersion": "6.0",
"digest": "51362570ab36805c8f67622ee4543e38811f8abb20f732a1af2ffbff2d63d042"
},
"pig2": {
"category": "nature",
"moji": "ðŸ–",
+ "description": "pig",
"unicodeVersion": "6.0",
"digest": "67010e255f28061b9d9210bcdab6edc072642ad134122a1d0c7e3a6b1795a45b"
},
"pig_nose": {
"category": "nature",
"moji": "ðŸ½",
+ "description": "pig nose",
"unicodeVersion": "6.0",
"digest": "0b21cac238bf4910939fbea9bed35552378c1b605a3867d7b85c1556dbda22a9"
},
"pill": {
"category": "objects",
"moji": "💊",
+ "description": "pill",
"unicodeVersion": "6.0",
"digest": "cb00be361aaba6dbcf8da58bd20b76221dd75031362ecae99496b088ed413a7f"
},
"pineapple": {
"category": "food",
"moji": "ðŸ",
+ "description": "pineapple",
"unicodeVersion": "6.0",
"digest": "621d4d4c52b59e566c2e29ed7845c8bd2d1da0946577527342097808d170dd70"
},
"ping_pong": {
"category": "activity",
"moji": "ðŸ“",
+ "description": "table tennis paddle and ball",
"unicodeVersion": "8.0",
"digest": "943a858bd054c81a08a08951f8351c27c8009b85a9359729c7362868298b58e1"
},
"pisces": {
"category": "symbols",
"moji": "♓",
+ "description": "pisces",
"unicodeVersion": "1.1",
"digest": "453c3915122a4b6b32867056d2447be48675a84469145c88d52f8007fcb0861a"
},
"pizza": {
"category": "food",
"moji": "ðŸ•",
+ "description": "slice of pizza",
"unicodeVersion": "6.0",
"digest": "169bc6c1e1d7fdab1b8bf2eab0eeec4f9a7ae08b7b9b38f33b0b0c642e72053a"
},
"place_of_worship": {
"category": "symbols",
"moji": "ðŸ›",
+ "description": "place of worship",
"unicodeVersion": "8.0",
"digest": "daf271d36a38ee8c0f8b9de84c128ab8b25a5b7df8f107308d0353c961f2c644"
},
"play_pause": {
"category": "symbols",
"moji": "â¯",
+ "description": "black right-pointing double triangle with double vertical bar",
"unicodeVersion": "6.0",
"digest": "af1498f34a3d6e0da8bbd26ebaa447e697e2df08c8eb255437cf7905c93f8c42"
},
"point_down": {
"category": "people",
"moji": "👇",
+ "description": "white down pointing backhand index",
"unicodeVersion": "6.0",
"digest": "4ecdb3f31c16dc38113b8854ec1a7884613b688a185ebdf967eab9a81018f76d"
},
"point_down_tone1": {
"category": "people",
"moji": "👇ðŸ»",
+ "description": "white down pointing backhand index tone 1",
"unicodeVersion": "8.0",
"digest": "c74a7c94367cddbfa840542dc0924adeb0d108be0c7fde8c25fb95d69115d283"
},
"point_down_tone2": {
"category": "people",
"moji": "👇ðŸ¼",
+ "description": "white down pointing backhand index tone 2",
"unicodeVersion": "8.0",
"digest": "dc4bda0726d85418b974addb42738f437fbb9cf16e5815cdbab3859c4ada6cae"
},
"point_down_tone3": {
"category": "people",
"moji": "👇ðŸ½",
+ "description": "white down pointing backhand index tone 3",
"unicodeVersion": "8.0",
"digest": "e460f81a501376d2f0ed1d45e358c5ed03ba049e8f466e4298afb4f3ca6d24dc"
},
"point_down_tone4": {
"category": "people",
"moji": "👇ðŸ¾",
+ "description": "white down pointing backhand index tone 4",
"unicodeVersion": "8.0",
"digest": "4bc91cd771f24e0f897a9d8b18f323fec9a82da0fc2429c4a7e4e6a9d885a0a3"
},
"point_down_tone5": {
"category": "people",
"moji": "👇ðŸ¿",
+ "description": "white down pointing backhand index tone 5",
"unicodeVersion": "8.0",
"digest": "7e47c6bc73250f36dc7ae1c1c09e7b41f30647b9d0ff703a53a75cc046b5057d"
},
"point_left": {
"category": "people",
"moji": "👈",
+ "description": "white left pointing backhand index",
"unicodeVersion": "6.0",
"digest": "b5a7e864a0016afbadb3bec41f51ecf8c4af73cc20462e1a08b357f90bca6879"
},
"point_left_tone1": {
"category": "people",
"moji": "👈ðŸ»",
+ "description": "white left pointing backhand index tone 1",
"unicodeVersion": "8.0",
"digest": "9f1868272a10a2b738c065be5d30241643324550cfd47baf01c7a09060e66d31"
},
"point_left_tone2": {
"category": "people",
"moji": "👈ðŸ¼",
+ "description": "white left pointing backhand index tone 2",
"unicodeVersion": "8.0",
"digest": "bf0d58c68178a2c2c01d4a6235a1a66b90073cea170f9f6fe2668b6dd68424f7"
},
"point_left_tone3": {
"category": "people",
"moji": "👈ðŸ½",
+ "description": "white left pointing backhand index tone 3",
"unicodeVersion": "8.0",
"digest": "34d28c97bc8f9d111d14e328153c4298fc32cf18e39e20aacaec17846645ed90"
},
"point_left_tone4": {
"category": "people",
"moji": "👈ðŸ¾",
+ "description": "white left pointing backhand index tone 4",
"unicodeVersion": "8.0",
"digest": "c40c8436316915d516c53bb1c98a469528cefd98baa719be7e748c4608cbbcc9"
},
"point_left_tone5": {
"category": "people",
"moji": "👈ðŸ¿",
+ "description": "white left pointing backhand index tone 5",
"unicodeVersion": "8.0",
"digest": "c410fe32e4ce0ded74845a54b86090e59e5820d457837b16e175b36cc71ecb46"
},
"point_right": {
"category": "people",
"moji": "👉",
+ "description": "white right pointing backhand index",
"unicodeVersion": "6.0",
"digest": "44d9251ab41f2f48c2250c44a47f92b3476a71f13fbbbfb637547db837fd5a49"
},
"point_right_tone1": {
"category": "people",
"moji": "👉ðŸ»",
+ "description": "white right pointing backhand index tone 1",
"unicodeVersion": "8.0",
"digest": "9fcce259eb81c0b52ec7796b98a1653194e3a9021a1d338df1dbbab7522fc406"
},
"point_right_tone2": {
"category": "people",
"moji": "👉ðŸ¼",
+ "description": "white right pointing backhand index tone 2",
"unicodeVersion": "8.0",
"digest": "9d00a0b1cfc435674dc56065b3d28d28839196977504cf20581205351d8708f2"
},
"point_right_tone3": {
"category": "people",
"moji": "👉ðŸ½",
+ "description": "white right pointing backhand index tone 3",
"unicodeVersion": "8.0",
"digest": "e3026a70630ba73d76892a055a80cac2f78d509faddce737f802d2abefa074ba"
},
"point_right_tone4": {
"category": "people",
"moji": "👉ðŸ¾",
+ "description": "white right pointing backhand index tone 4",
"unicodeVersion": "8.0",
"digest": "ea508fde90561460361773b4e1b8e80874667b19ac115926206e7c592587cb76"
},
"point_right_tone5": {
"category": "people",
"moji": "👉ðŸ¿",
+ "description": "white right pointing backhand index tone 5",
"unicodeVersion": "8.0",
"digest": "d59cdb2864eb2929941ecd233f8b8afcddc30fbd4594e5f9acf6386ae06ac12c"
},
"point_up": {
"category": "people",
"moji": "â˜",
+ "description": "white up pointing index",
"unicodeVersion": "1.1",
"digest": "b69ff4f650989709f2185822d278c7773672bd9eb4a625da80f3038a2b9ce42b"
},
"point_up_2": {
"category": "people",
"moji": "👆",
+ "description": "white up pointing backhand index",
"unicodeVersion": "6.0",
"digest": "e83cd9eff2af5125a25f5a306c3ee3cfea240add683b5c36a86a994a8d8c805c"
},
"point_up_2_tone1": {
"category": "people",
"moji": "👆ðŸ»",
+ "description": "white up pointing backhand index tone 1",
"unicodeVersion": "8.0",
"digest": "b02ec3e7e04a83bfb769cffb951cbf32aa78e56fa5a51c097f9326df9e08ed33"
},
"point_up_2_tone2": {
"category": "people",
"moji": "👆ðŸ¼",
+ "description": "white up pointing backhand index tone 2",
"unicodeVersion": "8.0",
"digest": "32994b85c8b4a1383ca985ebc3382be88866cea1ff1315adfb71fb05e992a232"
},
"point_up_2_tone3": {
"category": "people",
"moji": "👆ðŸ½",
+ "description": "white up pointing backhand index tone 3",
"unicodeVersion": "8.0",
"digest": "9e263bcfb82ada34ff85291f36e64e66b86760fb11a4e0c554e801644d417d6d"
},
"point_up_2_tone4": {
"category": "people",
"moji": "👆ðŸ¾",
+ "description": "white up pointing backhand index tone 4",
"unicodeVersion": "8.0",
"digest": "3edc92130a0851ac7b5236772ce7918d088689221df287098688e1ed5b3ff181"
},
"point_up_2_tone5": {
"category": "people",
"moji": "👆ðŸ¿",
+ "description": "white up pointing backhand index tone 5",
"unicodeVersion": "8.0",
"digest": "cabb3b7da9290840ef59d0c8b22625bdb2e94842f01b0a575ccbc348f3069d77"
},
"point_up_tone1": {
"category": "people",
"moji": "â˜ðŸ»",
+ "description": "white up pointing index tone 1",
"unicodeVersion": "8.0",
"digest": "e496fda349072f8b321ceb7a251175f7244c3076661f5ede48ea75ba1acf8339"
},
"point_up_tone2": {
"category": "people",
"moji": "â˜ðŸ¼",
+ "description": "white up pointing index tone 2",
"unicodeVersion": "8.0",
"digest": "5a8081323f3baa67e6431e21e16a36559b339f5175d586644e34947f738dd07a"
},
"point_up_tone3": {
"category": "people",
"moji": "â˜ðŸ½",
+ "description": "white up pointing index tone 3",
"unicodeVersion": "8.0",
"digest": "07bf0cea812eb226b443334e026e13d1ec23e013478f4af862a3919703107842"
},
"point_up_tone4": {
"category": "people",
"moji": "â˜ðŸ¾",
+ "description": "white up pointing index tone 4",
"unicodeVersion": "8.0",
"digest": "1fbbd71433108143ee157d0fdadd183f7f013bafa96f0dd93b181e1fd5fd4af2"
},
"point_up_tone5": {
"category": "people",
"moji": "â˜ðŸ¿",
+ "description": "white up pointing index tone 5",
"unicodeVersion": "8.0",
"digest": "ad068ef32df32f8297955490a9a90590a0f93ed5702a052cd0d8f6484c6cc679"
},
"police_car": {
"category": "travel",
"moji": "🚓",
+ "description": "police car",
"unicodeVersion": "6.0",
"digest": "0909be1bd615ae331a7cce71e16dee3ca663c721d5170072c593cb7c76f9f661"
},
"poodle": {
"category": "nature",
"moji": "ðŸ©",
+ "description": "poodle",
"unicodeVersion": "6.0",
"digest": "f1742fdf3fd26a8a5cfeaba57026518dacaad364cbd03344c4000a35af13e47a"
},
"poop": {
"category": "people",
"moji": "💩",
+ "description": "pile of poo",
"unicodeVersion": "6.0",
"digest": "857a61c872138d359a7fe8257bb26118afa49d75186eca2addb415d07c92b3ec"
},
"popcorn": {
"category": "food",
"moji": "ðŸ¿",
+ "description": "popcorn",
"unicodeVersion": "8.0",
"digest": "684f1b7ef34ea7ca933aed41569bc6595a19ef0d546a1b7b9e69f8335540b323"
},
"post_office": {
"category": "travel",
"moji": "ðŸ£",
+ "description": "japanese post office",
"unicodeVersion": "6.0",
"digest": "54398ee396c1314a7993b1cb1cba264946b5c9d5a7dbb43fd67286854d1d1a0f"
},
"postal_horn": {
"category": "objects",
"moji": "📯",
+ "description": "postal horn",
"unicodeVersion": "6.0",
"digest": "0ea12f44f3bae9a14bde3b37361b48bd738d2f613bb1b53a9204959b70e643f8"
},
"postbox": {
"category": "objects",
"moji": "📮",
+ "description": "postbox",
"unicodeVersion": "6.0",
"digest": "bbc424ae8d46de380d7023a43ea064002fd614657d00330d3503275827ac87e2"
},
"potable_water": {
"category": "symbols",
"moji": "🚰",
+ "description": "potable water symbol",
"unicodeVersion": "6.0",
"digest": "dbe80d9637837377cc2a290da2e895f81a3108cc18b049e3d87212402c1c2098"
},
"potato": {
"category": "food",
"moji": "🥔",
+ "description": "potato",
"unicodeVersion": "9.0",
"digest": "a56a69f36f3a0793f278726d92c0cea2960554f3062ef1a0904526a04511d8e1"
},
"pouch": {
"category": "people",
"moji": "ðŸ‘",
+ "description": "pouch",
"unicodeVersion": "6.0",
"digest": "9f012b90310b4a072b6a8fa2c64def087b5f7ffffaafc36e1856ba943a170351"
},
"poultry_leg": {
"category": "food",
"moji": "ðŸ—",
+ "description": "poultry leg",
"unicodeVersion": "6.0",
"digest": "1445ec4f5e68a19e5a84e5537dca8190d62409070c954d112e6097f1a6b7f054"
},
"pound": {
"category": "objects",
"moji": "💷",
+ "description": "banknote with pound sign",
"unicodeVersion": "6.0",
"digest": "eb11b83eb52adb0a15e69a3bc15788a2dc7825dedee81ac3af84963c9dd517b5"
},
"pouting_cat": {
"category": "people",
"moji": "😾",
+ "description": "pouting cat face",
"unicodeVersion": "6.0",
"digest": "8822abedf3499cf98278d7eeea0764d1100ec25cad71b4b2e877f9346f8c8138"
},
"pray": {
"category": "people",
"moji": "ðŸ™",
+ "description": "person with folded hands",
"unicodeVersion": "6.0",
"digest": "735b79dab34ac2cf81fd42fdcd7eb1f13c24655e5e343816d5764896c03edeea"
},
"pray_tone1": {
"category": "people",
"moji": "ðŸ™ðŸ»",
+ "description": "person with folded hands tone 1",
"unicodeVersion": "8.0",
"digest": "e8b6103450215e8566797f150978355e297deade4eb47a6371f7a7bc558fed9d"
},
"pray_tone2": {
"category": "people",
"moji": "ðŸ™ðŸ¼",
+ "description": "person with folded hands tone 2",
"unicodeVersion": "8.0",
"digest": "ee8baacd95d7e8dbad8a1f2d9a12e36c98f3d518db5d3b117d0a18290815e62b"
},
"pray_tone3": {
"category": "people",
"moji": "ðŸ™ðŸ½",
+ "description": "person with folded hands tone 3",
"unicodeVersion": "8.0",
"digest": "ae8c0caa9aca0a6c44069e76a7535c961d0284cd701812f76bbd2bd79ce2bd53"
},
"pray_tone4": {
"category": "people",
"moji": "ðŸ™ðŸ¾",
+ "description": "person with folded hands tone 4",
"unicodeVersion": "8.0",
"digest": "64f7b3178b8cd6f6a877ed583539eefe068fa87a0dd658fdcd58c8bc809f7e17"
},
"pray_tone5": {
"category": "people",
"moji": "ðŸ™ðŸ¿",
+ "description": "person with folded hands tone 5",
"unicodeVersion": "8.0",
"digest": "5bc8cdce937ac06779c87021423efcec4f602aa4a39dba90b00de81033005332"
},
"prayer_beads": {
"category": "objects",
"moji": "📿",
+ "description": "prayer beads",
"unicodeVersion": "8.0",
"digest": "80177091264430cbcf7c994fbe5ee17319d1a58d933636cc752a54dafcf98a05"
},
"pregnant_woman": {
"category": "people",
"moji": "🤰",
+ "description": "pregnant woman",
"unicodeVersion": "9.0",
"digest": "49abb86409103338bdb6ae43c13a78ca2dc9cd158a26df35eadd0da3c84a4352"
},
"pregnant_woman_tone1": {
"category": "people",
"moji": "🤰ðŸ»",
+ "description": "pregnant woman tone 1",
"unicodeVersion": "9.0",
"digest": "5a9f8ed2b631ecf8af111803a5c11f4c156435a5293cb50329c7b98697c8da25"
},
"pregnant_woman_tone2": {
"category": "people",
"moji": "🤰ðŸ¼",
+ "description": "pregnant woman tone 2",
"unicodeVersion": "9.0",
"digest": "279a2eafff603b11629c955b05f5bd3d7da9a271d4fb3f02e9ccd457b8d2d815"
},
"pregnant_woman_tone3": {
"category": "people",
"moji": "🤰ðŸ½",
+ "description": "pregnant woman tone 3",
"unicodeVersion": "9.0",
"digest": "93bb63ec2312db315e3f0065520b715cc413ac0fd65538ec9b5cd97df2a42b20"
},
"pregnant_woman_tone4": {
"category": "people",
"moji": "🤰ðŸ¾",
+ "description": "pregnant woman tone 4",
"unicodeVersion": "9.0",
"digest": "b8dc3dcec894bfd832a249459b10850f8786b6778d8887a677d1291865623da2"
},
"pregnant_woman_tone5": {
"category": "people",
"moji": "🤰ðŸ¿",
+ "description": "pregnant woman tone 5",
"unicodeVersion": "9.0",
"digest": "73ee432752f81980f353a7f9b9f7a5ece62512dca08e15c1876b89227face21c"
},
"prince": {
"category": "people",
"moji": "🤴",
+ "description": "prince",
"unicodeVersion": "9.0",
"digest": "34a0e0625f0a9825d3674192d6233b6cae4d8130451293df09f91a6a4165869c"
},
"prince_tone1": {
"category": "people",
"moji": "🤴ðŸ»",
+ "description": "prince tone 1",
"unicodeVersion": "9.0",
"digest": "ccecdfeccb2ab1fceceae14f3fba875c8c7099785a4c40131c08a697b5b675fc"
},
"prince_tone2": {
"category": "people",
"moji": "🤴ðŸ¼",
+ "description": "prince tone 2",
"unicodeVersion": "9.0",
"digest": "c373fd3e0c1798415e3d8d88fab6c98c1bbdedcbe6f52f3a3899f6e2124a768d"
},
"prince_tone3": {
"category": "people",
"moji": "🤴ðŸ½",
+ "description": "prince tone 3",
"unicodeVersion": "9.0",
"digest": "71d15695ca954d55aa69d3c753c7d31a8ba5329713a8ddbc90dafc11e524c4ef"
},
"prince_tone4": {
"category": "people",
"moji": "🤴ðŸ¾",
+ "description": "prince tone 4",
"unicodeVersion": "9.0",
"digest": "08f6cb32424f15cc3aaf83c31a5dac7c01a6be2f37ea8f13aed579ce6fb4db19"
},
"prince_tone5": {
"category": "people",
"moji": "🤴ðŸ¿",
+ "description": "prince tone 5",
"unicodeVersion": "9.0",
"digest": "77d521148efa33fa4d3409693d050fecfd948411e807327484f174e289834649"
},
"princess": {
"category": "people",
"moji": "👸",
+ "description": "princess",
"unicodeVersion": "6.0",
"digest": "efabd28480a843c735f0868734da2f9ce28133933b02ab07b645498f494f3f80"
},
"princess_tone1": {
"category": "people",
"moji": "👸ðŸ»",
+ "description": "princess tone 1",
"unicodeVersion": "8.0",
"digest": "52b88b99ba64f82e8f36e2a1827c85145e4fcd6863478c2345fe9fa9e8901cdf"
},
"princess_tone2": {
"category": "people",
"moji": "👸ðŸ¼",
+ "description": "princess tone 2",
"unicodeVersion": "8.0",
"digest": "7e44289404693668f20e681fcdc2e516512d54a69c627eedae958f69dfe6eea9"
},
"princess_tone3": {
"category": "people",
"moji": "👸ðŸ½",
+ "description": "princess tone 3",
"unicodeVersion": "8.0",
"digest": "96c9a9857348d7a1a8be899c50d55b352b9a9fd5c65e4777bfa199fe7929d41c"
},
"princess_tone4": {
"category": "people",
"moji": "👸ðŸ¾",
+ "description": "princess tone 4",
"unicodeVersion": "8.0",
"digest": "67696f96be60f2a36598072172d2db197d007e6c1ac3acef526a5ce6d59bf3f7"
},
"princess_tone5": {
"category": "people",
"moji": "👸ðŸ¿",
+ "description": "princess tone 5",
"unicodeVersion": "8.0",
"digest": "007f624e2fad91bb57ce32ecd35213a796d71807f3b12f3f1575bf50e6a50eeb"
},
"printer": {
"category": "objects",
"moji": "🖨",
+ "description": "printer",
"unicodeVersion": "7.0",
"digest": "5e5307e3dc7ec4e16c9978fb00934c99c4adefca7d32732a244d1f2de71ce6f8"
},
"projector": {
"category": "objects",
"moji": "📽",
+ "description": "film projector",
"unicodeVersion": "7.0",
"digest": "7f8e1fdb89584849a56ee34c62cab808af48b7bd4823467d090af4657a2e0420"
},
"punch": {
"category": "people",
"moji": "👊",
+ "description": "fisted hand sign",
"unicodeVersion": "6.0",
"digest": "c7e7edf6d64f755db3f02874354f08337b3971aff329476d19ac946e0b421329"
},
"punch_tone1": {
"category": "people",
"moji": "👊ðŸ»",
+ "description": "fisted hand sign tone 1",
"unicodeVersion": "8.0",
"digest": "c9ba508b0c36041047473782acfedab5af40dd7946b33daf4d8d54c726e06a11"
},
"punch_tone2": {
"category": "people",
"moji": "👊ðŸ¼",
+ "description": "fisted hand sign tone 2",
"unicodeVersion": "8.0",
"digest": "d53011cd2f3334c7b3fffdfe1e2b8cc1c832c74306e1ac6d03f954a1309d7d0b"
},
"punch_tone3": {
"category": "people",
"moji": "👊ðŸ½",
+ "description": "fisted hand sign tone 3",
"unicodeVersion": "8.0",
"digest": "f7522347094e0130ed8e304678106574dbd7dd2b6b3aeb4d8a7a0fef880920b2"
},
"punch_tone4": {
"category": "people",
"moji": "👊ðŸ¾",
+ "description": "fisted hand sign tone 4",
"unicodeVersion": "8.0",
"digest": "3e62bdd426f3e6ff175ce3b8dd6f6d3998d9c1506128defa96b528b455295b47"
},
"punch_tone5": {
"category": "people",
"moji": "👊ðŸ¿",
+ "description": "fisted hand sign tone 5",
"unicodeVersion": "8.0",
"digest": "7d9bff777dc4ec41ac132b1252fa08cf92a398c8dc146c4a5327b45d568982d8"
},
"purple_heart": {
"category": "symbols",
"moji": "💜",
+ "description": "purple heart",
"unicodeVersion": "6.0",
"digest": "a6bf01de806525942be480e45a4b2879f91df8129b78a1b8734d4f917bcab773"
},
"purse": {
"category": "people",
"moji": "👛",
+ "description": "purse",
"unicodeVersion": "6.0",
"digest": "2b785f36e01875d66cfda2192c8c53606e7224a7c869a4826b62cb61613d60c8"
},
"pushpin": {
"category": "objects",
"moji": "📌",
+ "description": "pushpin",
"unicodeVersion": "6.0",
"digest": "c3f7d7008be6bab8dc02284d4d759abf7aafbb3dbbe3a53f0f5b2ff685af88f8"
},
"put_litter_in_its_place": {
"category": "symbols",
"moji": "🚮",
+ "description": "put litter in its place symbol",
"unicodeVersion": "6.0",
"digest": "f52a57d6f1bada7b6e6b9a6458597d70cb701c01e1120d8cb1d7ff65e01d405c"
},
"question": {
"category": "symbols",
"moji": "â“",
+ "description": "black question mark ornament",
"unicodeVersion": "6.0",
"digest": "40050a1fd29bed321fd601d13dc33de5d6084121f1d873b29bde9dc3d823a310"
},
"rabbit": {
"category": "nature",
"moji": "ðŸ°",
+ "description": "rabbit face",
"unicodeVersion": "6.0",
"digest": "678ad953a7ab8f618c59051449a67c965d1f04f42dd6f6669adaf3fadebd080c"
},
"rabbit2": {
"category": "nature",
"moji": "ðŸ‡",
+ "description": "rabbit",
"unicodeVersion": "6.0",
"digest": "19b1f5108292472434cc7a49efac4ea9275779735c7aeb0f15c36021d5998ca0"
},
"race_car": {
"category": "travel",
"moji": "ðŸŽ",
+ "description": "racing car",
"unicodeVersion": "7.0",
"digest": "46f4814259d3d17ff35c04110e73e5327aee99f4711cd459ca1ee951508da3a6"
},
"racehorse": {
"category": "nature",
"moji": "ðŸŽ",
+ "description": "horse",
"unicodeVersion": "6.0",
"digest": "a57b7aca35347ada8225eeee06b70cfd040484104963b4df56ea8fec690576b0"
},
"radio": {
"category": "objects",
"moji": "📻",
+ "description": "radio",
"unicodeVersion": "6.0",
"digest": "9245951dd779cdd141089891b15a90d3999a6358acf1fc296aa505100f812108"
},
"radio_button": {
"category": "symbols",
"moji": "🔘",
+ "description": "radio button",
"unicodeVersion": "6.0",
"digest": "565bec59198df2592e96564c6e314d3cde33c47b453db1bec6c5d027b5cb4fd9"
},
"radioactive": {
"category": "symbols",
"moji": "☢",
+ "description": "radioactive sign",
"unicodeVersion": "1.1",
"digest": "0ed6634057824e0cfd10b2533753e3632b0624341a7eac8d9835706480335581"
},
"rage": {
"category": "people",
"moji": "😡",
+ "description": "pouting face",
"unicodeVersion": "6.0",
"digest": "d97ba6bd08eec46dbc7199f530c945b73a87a878e35397b0a3e4f2b45039e89e"
},
"railway_car": {
"category": "travel",
"moji": "🚃",
+ "description": "railway car",
"unicodeVersion": "6.0",
"digest": "2cddc08d555e7fc24e312c3d255ed013fbf9cd2974a6918369c32554049ba2be"
},
"railway_track": {
"category": "travel",
"moji": "🛤",
+ "description": "railway track",
"unicodeVersion": "7.0",
"digest": "0da351b6d4e75c6beeaef1225e151d9580d4b5c41dfa1cf192715bf3cec981d7"
},
"rainbow": {
"category": "travel",
"moji": "🌈",
+ "description": "rainbow",
"unicodeVersion": "6.0",
"digest": "a93aceb54e965f35e397e8c8716b1831614933308d026012d5464ee42783ed4d"
},
"raised_back_of_hand": {
"category": "people",
"moji": "🤚",
+ "description": "raised back of hand",
"unicodeVersion": "9.0",
"digest": "20973a697e826625deba5ee3c4f25eb5e1737f2e860ac6fe4ee4d0e0c84b5e12"
},
"raised_back_of_hand_tone1": {
"category": "people",
"moji": "🤚ðŸ»",
+ "description": "raised back of hand tone 1",
"unicodeVersion": "9.0",
"digest": "06af5941255ca69d10d99d0a512bbda6141a296453835dbccf259ce0afe1dd3d"
},
"raised_back_of_hand_tone2": {
"category": "people",
"moji": "🤚ðŸ¼",
+ "description": "raised back of hand tone 2",
"unicodeVersion": "9.0",
"digest": "429ed19555c9e5197b729b3e7bd8013346551051cb0b3fbc8a4372717c9a027d"
},
"raised_back_of_hand_tone3": {
"category": "people",
"moji": "🤚ðŸ½",
+ "description": "raised back of hand tone 3",
"unicodeVersion": "9.0",
"digest": "487a1c3f19e77c99b520ec073de2acc4a9e585b739a84b3989f7de85d2c2045c"
},
"raised_back_of_hand_tone4": {
"category": "people",
"moji": "🤚ðŸ¾",
+ "description": "raised back of hand tone 4",
"unicodeVersion": "9.0",
"digest": "154254d8500c55ec3de698be4a352f9bcf06e2950cabc4eabaccad0f39a1e1e9"
},
"raised_back_of_hand_tone5": {
"category": "people",
"moji": "🤚ðŸ¿",
+ "description": "raised back of hand tone 5",
"unicodeVersion": "9.0",
"digest": "6e9c0855ecd5f14adca5e5862427c3d39ffcf86f7ddd3aaa1fefc3cefc7483c8"
},
"raised_hand": {
"category": "people",
"moji": "✋",
+ "description": "raised hand",
"unicodeVersion": "6.0",
"digest": "5cf11be683aea985d5ba51fbd44722c2327311bfe26b61c3d441c90f5d5a195a"
},
"raised_hand_tone1": {
"category": "people",
"moji": "✋ðŸ»",
+ "description": "raised hand tone 1",
"unicodeVersion": "8.0",
"digest": "865afca29b57577fed8fe8c2be57b74254a008c8cf34194680be2759239b5f5d"
},
"raised_hand_tone2": {
"category": "people",
"moji": "✋ðŸ¼",
+ "description": "raised hand tone 2",
"unicodeVersion": "8.0",
"digest": "832169a0b626a682a58a3b998f68413657b4962c1fab05f1fdc2668e82727210"
},
"raised_hand_tone3": {
"category": "people",
"moji": "✋ðŸ½",
+ "description": "raised hand tone 3",
"unicodeVersion": "8.0",
"digest": "3959a873ad7671de82c615c4ed840b011e67baafb2bab7dd16859608d3e83cb1"
},
"raised_hand_tone4": {
"category": "people",
"moji": "✋ðŸ¾",
+ "description": "raised hand tone 4",
"unicodeVersion": "8.0",
"digest": "db542f65d076ccf3dbfca27cb7c2f135a8bf7a487a81a04873e70172bdfcd579"
},
"raised_hand_tone5": {
"category": "people",
"moji": "✋ðŸ¿",
+ "description": "raised hand tone 5",
"unicodeVersion": "8.0",
"digest": "88ca884d14baaae48df21d75c22d82fb15bdc395e42026f5ca34cd65e5ae8674"
},
"raised_hands": {
"category": "people",
"moji": "🙌",
+ "description": "person raising both hands in celebration",
"unicodeVersion": "6.0",
"digest": "2ee73466a3f5079e542857fe6f5497e9f87753a81854985ce3356a8d3da1d8b8"
},
"raised_hands_tone1": {
"category": "people",
"moji": "🙌ðŸ»",
+ "description": "person raising both hands in celebration tone 1",
"unicodeVersion": "8.0",
"digest": "43e73c60f040a66374b8ec98f3629a90d13ae9f472446ed7676cd5573e824f4b"
},
"raised_hands_tone2": {
"category": "people",
"moji": "🙌ðŸ¼",
+ "description": "person raising both hands in celebration tone 2",
"unicodeVersion": "8.0",
"digest": "fcc5255bb2b06dc82d6878e74cf34e8ce118c70004a06d39a980683772b98c52"
},
"raised_hands_tone3": {
"category": "people",
"moji": "🙌ðŸ½",
+ "description": "person raising both hands in celebration tone 3",
"unicodeVersion": "8.0",
"digest": "3ee3e0aafef486e766a166935e8147fb75a7329cfebc96dec876cc45e83a8754"
},
"raised_hands_tone4": {
"category": "people",
"moji": "🙌ðŸ¾",
+ "description": "person raising both hands in celebration tone 4",
"unicodeVersion": "8.0",
"digest": "78a8cbf6b2b85be4d6b18f0ff6a77f197963117955725fb7e57e0441effb928f"
},
"raised_hands_tone5": {
"category": "people",
"moji": "🙌ðŸ¿",
+ "description": "person raising both hands in celebration tone 5",
"unicodeVersion": "8.0",
"digest": "2a5ed7334a17172db0cd820a559e7f75df40ec44de6c25d194c76e1b58c634cb"
},
"raising_hand": {
"category": "people",
"moji": "🙋",
+ "description": "happy person raising one hand",
"unicodeVersion": "6.0",
"digest": "512750b00704f1ccefd3c757743540b785ad7670dbbe4a2c4dca8d93e6701920"
},
"raising_hand_tone1": {
"category": "people",
"moji": "🙋ðŸ»",
+ "description": "happy person raising one hand tone1",
"unicodeVersion": "8.0",
"digest": "2897722f091c273dd3714cff7423c2475bc3070416c28014ca03322b9ece48bc"
},
"raising_hand_tone2": {
"category": "people",
"moji": "🙋ðŸ¼",
+ "description": "happy person raising one hand tone2",
"unicodeVersion": "8.0",
"digest": "59199b334b3845911382c1f29bd7c0d5ef9d2486417345e265b166ead7d3e1c1"
},
"raising_hand_tone3": {
"category": "people",
"moji": "🙋ðŸ½",
+ "description": "happy person raising one hand tone3",
"unicodeVersion": "8.0",
"digest": "f95b338d5efcf14ef12f415a2c1bba93df48628ddc94f34f70c31e1b3c2e1d28"
},
"raising_hand_tone4": {
"category": "people",
"moji": "🙋ðŸ¾",
+ "description": "happy person raising one hand tone4",
"unicodeVersion": "8.0",
"digest": "951ddbfdb57d5a60551b59b3d0f7ca00a64912f4a101a73afaebd68445cd6cec"
},
"raising_hand_tone5": {
"category": "people",
"moji": "🙋ðŸ¿",
+ "description": "happy person raising one hand tone5",
"unicodeVersion": "8.0",
"digest": "9370f93704d8f89ca6dc946715eab5e7dba82bf04dd68c00f5c0abb8bc16371e"
},
"ram": {
"category": "nature",
"moji": "ðŸ",
+ "description": "ram",
"unicodeVersion": "6.0",
"digest": "2875ab28e1018b39062aeb0c5ce488c48a98f13e9f2364470a0a700b126604f2"
},
"ramen": {
"category": "food",
"moji": "ðŸœ",
+ "description": "steaming bowl",
"unicodeVersion": "6.0",
"digest": "425662a49c4c13577c0de8d45d004e5ba204aaadbaabae62a5c283ecd7a9a2c5"
},
"rat": {
"category": "nature",
"moji": "ðŸ€",
+ "description": "rat",
"unicodeVersion": "6.0",
"digest": "14380d65498c6ce037c02a93bca2b24f25a368d85278d6015b8c9f7cd261f8e2"
},
"record_button": {
"category": "symbols",
"moji": "âº",
+ "description": "black circle for record",
"unicodeVersion": "7.0",
"digest": "92be12161ba206bb2e06a39131711c7b17368d55b4aae0b48f0ac5b6b1cde76b"
},
"recycle": {
"category": "symbols",
"moji": "â™»",
+ "description": "black universal recycling symbol",
"unicodeVersion": "3.2",
"digest": "c377e8537367b05b5de9be860a0fcabd7aed2bf4ba146eefc423671a21530369"
},
"red_car": {
"category": "travel",
"moji": "🚗",
+ "description": "automobile",
"unicodeVersion": "6.0",
"digest": "8a99832a195263c0e922af53d52dea37aa3e07032b3c2a1977f8527b4a144b9c"
},
"red_circle": {
"category": "symbols",
"moji": "🔴",
+ "description": "large red circle",
"unicodeVersion": "6.0",
"digest": "9dcf0132f6f2cc81702f0e3b15b37984e8439796705bf98f68ba449b3dfa5307"
},
"registered": {
"category": "symbols",
"moji": "®",
+ "description": "registered sign",
"unicodeVersion": "1.1",
"digest": "9661b1df529ecb752d130820c55c403e5de263748eb02f7fea327818bc282d94"
},
"relaxed": {
"category": "people",
"moji": "☺",
+ "description": "white smiling face",
"unicodeVersion": "1.1",
"digest": "2d5aed4fb8504c6d6660ef8d3bfe0cc053dcd6099c2f53748c202dc970c639bc"
},
"relieved": {
"category": "people",
"moji": "😌",
+ "description": "relieved face",
"unicodeVersion": "6.0",
"digest": "b4ce2ba6c220d887fe5e333c05ed773df9b6df0ac456879fd8f5103ff68604a5"
},
"reminder_ribbon": {
"category": "activity",
"moji": "🎗",
+ "description": "reminder ribbon",
"unicodeVersion": "7.0",
"digest": "c3de2a7c9350b77a0b86c0dcce9dcd9953ea8a97aa1e7aed149755924742f54d"
},
"repeat": {
"category": "symbols",
"moji": "ðŸ”",
+ "description": "clockwise rightwards and leftwards open circle arr",
"unicodeVersion": "6.0",
"digest": "b9512d508613ed0eb3181eb1030f7f6fd6b994476ecdfa308733c6df975fb99e"
},
"repeat_one": {
"category": "symbols",
"moji": "🔂",
+ "description": "clockwise rightwards and leftwards open circle arr",
"unicodeVersion": "6.0",
"digest": "53409cf24dd4bb0d7b50ae359f15d06b87b7f4a292ed5c3a09652fa421a90bf2"
},
"restroom": {
"category": "symbols",
"moji": "🚻",
+ "description": "restroom",
"unicodeVersion": "6.0",
"digest": "2e7a1bfc9a9d49b0272230a91db7369e24d54bf1de8e683d36b85f1d8c037f77"
},
"revolving_hearts": {
"category": "symbols",
"moji": "💞",
+ "description": "revolving hearts",
"unicodeVersion": "6.0",
"digest": "c43d3197cb4cf06659f643638f6c4e91a2889e0f6531b7d81ea826c2a8b784fc"
},
"rewind": {
"category": "symbols",
"moji": "âª",
+ "description": "black left-pointing double triangle",
"unicodeVersion": "6.0",
"digest": "d20c918c1e528ff0947312738501ca9a6fb6ff4016aad07db7a8125d00fd65cd"
},
"rhino": {
"category": "nature",
"moji": "ðŸ¦",
+ "description": "rhinoceros",
"unicodeVersion": "9.0",
"digest": "163fa3acd78eead72c431a1f48b8465a6d45272a9169560e456d30b4df93dc6b"
},
"ribbon": {
"category": "objects",
"moji": "🎀",
+ "description": "ribbon",
"unicodeVersion": "6.0",
"digest": "74315fe907f9f0203afe139cd4552aa442eecfa2a64fac12db3e1292fc5a8828"
},
"rice": {
"category": "food",
"moji": "ðŸš",
+ "description": "cooked rice",
"unicodeVersion": "6.0",
"digest": "f544f12606de59d28739798003f14ebd8869856add8e24496ec5dda3e131daf4"
},
"rice_ball": {
"category": "food",
"moji": "ðŸ™",
+ "description": "rice ball",
"unicodeVersion": "6.0",
"digest": "2cba6f5364cd366859bc8948897b65fc97b225ea7973d9be3b24aba388fed8e8"
},
"rice_cracker": {
"category": "food",
"moji": "ðŸ˜",
+ "description": "rice cracker",
"unicodeVersion": "6.0",
"digest": "ac0f805d41d4f322154c1968bd3ce3e9aabcd39d908182e52fd7d28458dbef92"
},
"rice_scene": {
"category": "travel",
"moji": "🎑",
+ "description": "moon viewing ceremony",
"unicodeVersion": "6.0",
"digest": "b942a06d3da0570aca59bab0af57cd8c16863934f12a38f70339fd0a36f675f5"
},
"right_facing_fist": {
"category": "people",
"moji": "🤜",
+ "description": "right-facing fist",
"unicodeVersion": "9.0",
"digest": "f815d1cc0c0345ddcc8886ae9c133582d7dc779732ac9b93dde1ab4fdd3b251d"
},
"right_facing_fist_tone1": {
"category": "people",
"moji": "🤜ðŸ»",
+ "description": "right facing fist tone 1",
"unicodeVersion": "9.0",
"digest": "0f9269b70cf68071d97389e059a2bdacffd73f2afd2ce6cfd7447bb1a4e9abbb"
},
"right_facing_fist_tone2": {
"category": "people",
"moji": "🤜ðŸ¼",
+ "description": "right facing fist tone 2",
"unicodeVersion": "9.0",
"digest": "32a9833db853972e49e65aa227fb0512c57362da190aa1cc40e1d64f238e837e"
},
"right_facing_fist_tone3": {
"category": "people",
"moji": "🤜ðŸ½",
+ "description": "right facing fist tone 3",
"unicodeVersion": "9.0",
"digest": "be4706f8bb088411f5cbbf9065a0ae5b773c97456bd975c2b6789765657847b9"
},
"right_facing_fist_tone4": {
"category": "people",
"moji": "🤜ðŸ¾",
+ "description": "right facing fist tone 4",
"unicodeVersion": "9.0",
"digest": "1680862891a9d85c4b6f76232a80e2ef7428bcec93087c86eae2efaba9c6a3f7"
},
"right_facing_fist_tone5": {
"category": "people",
"moji": "🤜ðŸ¿",
+ "description": "right facing fist tone 5",
"unicodeVersion": "9.0",
"digest": "388715a4bc2178c52bbb3bc2729f57be50acab5d751784c9f3220e86c6b1fbcc"
},
"ring": {
"category": "people",
"moji": "ðŸ’",
+ "description": "ring",
"unicodeVersion": "6.0",
"digest": "b5322907222797b5e1786209cda88513e76cd397a40f0a7da24847245c95ef9d"
},
"robot": {
"category": "people",
"moji": "🤖",
+ "description": "robot face",
"unicodeVersion": "8.0",
"digest": "4d788e6ec89279588b036fca6b17f5a981291681df8f90306ecf5c039de40848"
},
"rocket": {
"category": "travel",
"moji": "🚀",
+ "description": "rocket",
"unicodeVersion": "6.0",
"digest": "b82e68a95aa89a6de344d6e256fef86a848ebc91de560b043b3e1f7fd072d57d"
},
"rofl": {
"category": "people",
"moji": "🤣",
+ "description": "rolling on the floor laughing",
"unicodeVersion": "9.0",
"digest": "f4f99ba2ac67b97338f904f9384ff03fb832a2e427bf6e74611bf5fee45f1f48"
},
"roller_coaster": {
"category": "travel",
"moji": "🎢",
+ "description": "roller coaster",
"unicodeVersion": "6.0",
"digest": "a65e9ace1d7900499777af1225995f17af90a398bb414764c20b6e09a8c23a2c"
},
"rolling_eyes": {
"category": "people",
"moji": "🙄",
+ "description": "face with rolling eyes",
"unicodeVersion": "8.0",
"digest": "23dea8100da488a05721a4e82823eb438393b0ea762211c9ecef011d127aa1b7"
},
"rooster": {
"category": "nature",
"moji": "ðŸ“",
+ "description": "rooster",
"unicodeVersion": "6.0",
"digest": "2b90c5cf6fa46da13eb77285443d600afcea0c48bd1d215d60167e7dc510da5d"
},
"rose": {
"category": "nature",
"moji": "🌹",
+ "description": "rose",
"unicodeVersion": "6.0",
"digest": "73799e459dba188de4de704605d824242feeb65d587c5bf9109acf528d037146"
},
"rosette": {
"category": "activity",
"moji": "ðŸµ",
+ "description": "rosette",
"unicodeVersion": "7.0",
"digest": "2537def4deef422d4e669b28b1a0675259306ab38601019df3ec3482b14e52d5"
},
"rotating_light": {
"category": "travel",
"moji": "🚨",
+ "description": "police cars revolving light",
"unicodeVersion": "6.0",
"digest": "91fcdb85a752ae904d335a978c7e7936aed4c75d414b35219b5a74430e51555f"
},
"round_pushpin": {
"category": "objects",
"moji": "ðŸ“",
+ "description": "round pushpin",
"unicodeVersion": "6.0",
"digest": "8ffca77bbdc6f1f726daf3abd6eff338a5ad1aa9b09dbbd8782c1e7ef5452f30"
},
"rowboat": {
"category": "activity",
"moji": "🚣",
+ "description": "rowboat",
"unicodeVersion": "6.0",
"digest": "83715d83a061926d4ad3bb569b21f5d337e3ebd4c9bcdfe493e661c12adc0a16"
},
"rowboat_tone1": {
"category": "activity",
"moji": "🚣ðŸ»",
+ "description": "rowboat tone 1",
"unicodeVersion": "8.0",
"digest": "e279ac816442c0876fba1f42c700b80f2fb6de671e1a8a9e9d11b71eed5c58e8"
},
"rowboat_tone2": {
"category": "activity",
"moji": "🚣ðŸ¼",
+ "description": "rowboat tone 2",
"unicodeVersion": "8.0",
"digest": "6a48eba352ed4971d26498b6c622e5772389c89c5205ed02acde8e995dddcc3b"
},
"rowboat_tone3": {
"category": "activity",
"moji": "🚣ðŸ½",
+ "description": "rowboat tone 3",
"unicodeVersion": "8.0",
"digest": "875948f6d8354ebd95ce9a66fde30f06a8366dcd89d5ca3e660845f8801e9305"
},
"rowboat_tone4": {
"category": "activity",
"moji": "🚣ðŸ¾",
+ "description": "rowboat tone 4",
"unicodeVersion": "8.0",
"digest": "8c7ac7346b0020d0ff5e2f4a1efb1b7785eac637f17556663ec33e2335083f0a"
},
"rowboat_tone5": {
"category": "activity",
"moji": "🚣ðŸ¿",
+ "description": "rowboat tone 5",
"unicodeVersion": "8.0",
"digest": "a399dbb647892b22323e0bf17bc36a9b5f1708ebedf9ba525233ee7b9d48339a"
},
"rugby_football": {
"category": "activity",
"moji": "ðŸ‰",
+ "description": "rugby football",
"unicodeVersion": "6.0",
"digest": "cc6f00ade3e0bbb7899e7bfb138b57216dd66de26d7967d5ffa501f382ed09f4"
},
"runner": {
"category": "people",
"moji": "ðŸƒ",
+ "description": "runner",
"unicodeVersion": "6.0",
"digest": "e9af7b591be60ade2049dbada0f062ba2d3e17f02bec76cbd34ce68854a2a10c"
},
"runner_tone1": {
"category": "people",
"moji": "ðŸƒðŸ»",
+ "description": "runner tone 1",
"unicodeVersion": "8.0",
"digest": "21091cbb09c558712ecf63548bf28b7995df42bdb85235088799a517800e52f5"
},
"runner_tone2": {
"category": "people",
"moji": "ðŸƒðŸ¼",
+ "description": "runner tone 2",
"unicodeVersion": "8.0",
"digest": "1fe3d194f675a46fe67799394192e66c407dd81163363692c5e7da32ddb9af2b"
},
"runner_tone3": {
"category": "people",
"moji": "ðŸƒðŸ½",
+ "description": "runner tone 3",
"unicodeVersion": "8.0",
"digest": "8cea1bf4ef3be71f42dc5bae978d5b7a197a3851543225349ef0dda29a370537"
},
"runner_tone4": {
"category": "people",
"moji": "ðŸƒðŸ¾",
+ "description": "runner tone 4",
"unicodeVersion": "8.0",
"digest": "c33f0b8b5a71d295fb6ba322e79446964a8eca9e4573efd591e4273808b088a0"
},
"runner_tone5": {
"category": "people",
"moji": "ðŸƒðŸ¿",
+ "description": "runner tone 5",
"unicodeVersion": "8.0",
"digest": "9f59e6dd0fdf2f17bceb41f5c355b4e6f3c8bb8cbd8af0992f0b5630ff8892e8"
},
"running_shirt_with_sash": {
"category": "activity",
"moji": "🎽",
+ "description": "running shirt with sash",
"unicodeVersion": "6.0",
"digest": "7542307d3595aca45e8ccae66b6e58b6e92870144b738263d5379ec6dc992b76"
},
"sa": {
"category": "symbols",
"moji": "🈂",
+ "description": "squared katakana sa",
"unicodeVersion": "6.0",
"digest": "6042bcabd1516ef3847d695aba22851c49421244432d256e24eba04e8a223dab"
},
"sagittarius": {
"category": "symbols",
"moji": "â™",
+ "description": "sagittarius",
"unicodeVersion": "1.1",
"digest": "a02593e025023f2e82a01c587a8c0bbb1eff88cbcabf535a1558413eb32ed1d5"
},
"sailboat": {
"category": "travel",
"moji": "⛵",
+ "description": "sailboat",
"unicodeVersion": "5.2",
"digest": "c95ef4dc939cbdcb757ef3cd90331310e8c0a426add8cc800bae2540148a3195"
},
"sake": {
"category": "food",
"moji": "ðŸ¶",
+ "description": "sake bottle and cup",
"unicodeVersion": "6.0",
"digest": "0a786075f3d9da48ae91afccf6ae0d097888da9509d354ee1d3cb99afcc88fe4"
},
"salad": {
"category": "food",
"moji": "🥗",
+ "description": "green salad",
"unicodeVersion": "9.0",
"digest": "fe321487ab847abe670e68a83f1d9e096129741c689c769ee7de4a65aeac29f8"
},
"sandal": {
"category": "people",
"moji": "👡",
+ "description": "womans sandal",
"unicodeVersion": "6.0",
"digest": "03c3077cb4bd900934f9bdf921165b465e5cc9a6bee53e45a091411bceb8892d"
},
"santa": {
"category": "people",
"moji": "🎅",
+ "description": "father christmas",
"unicodeVersion": "6.0",
"digest": "178513e3d815917e59958870f5885b3414b43a16b8056980c863a468dfe00179"
},
"santa_tone1": {
"category": "people",
"moji": "🎅ðŸ»",
+ "description": "father christmas tone 1",
"unicodeVersion": "8.0",
"digest": "bf900bbc19bbd329229add9326e28e8197b69d6ddceb69f42162b0200fde5d16"
},
"santa_tone2": {
"category": "people",
"moji": "🎅ðŸ¼",
+ "description": "father christmas tone 2",
"unicodeVersion": "8.0",
"digest": "7340f2171adab97198e3eecac8b0d84c4c2a41f84606301a0d10e9fe655c93d1"
},
"santa_tone3": {
"category": "people",
"moji": "🎅ðŸ½",
+ "description": "father christmas tone 3",
"unicodeVersion": "8.0",
"digest": "7368ab75454ec28d8f7d6baef6ad69b5278445a9f50753f6624731bffde32054"
},
"santa_tone4": {
"category": "people",
"moji": "🎅ðŸ¾",
+ "description": "father christmas tone 4",
"unicodeVersion": "8.0",
"digest": "0ee60188353e0ee7772079c192bebbc6d49e74e63906f840c66da4eb35f4f245"
},
"santa_tone5": {
"category": "people",
"moji": "🎅ðŸ¿",
+ "description": "father christmas tone 5",
"unicodeVersion": "8.0",
"digest": "e4378a0cc5d21e9b9fe6e35c32d1ebc6fb8c2e1c09554cd096aeaefd3a6eb511"
},
"satellite": {
"category": "objects",
"moji": "📡",
+ "description": "satellite antenna",
"unicodeVersion": "6.0",
"digest": "c9d63118dcb445856917bb080460ab695cc78e715dcbba30ba18dffa9e906b27"
},
"satellite_orbital": {
"category": "travel",
"moji": "🛰",
+ "description": "satellite",
"unicodeVersion": "7.0",
"digest": "beb2f50e7f2b010e76bed9daa95d7329a93c783d3ebc4f0b797dd721c5e3d32d"
},
"saxophone": {
"category": "activity",
"moji": "🎷",
+ "description": "saxophone",
"unicodeVersion": "6.0",
"digest": "dfd138634f6702a3b89b5a2a50016720eef3f800b0d1d8c9fe097808c9491e96"
},
"scales": {
"category": "objects",
"moji": "âš–",
+ "description": "scales",
"unicodeVersion": "4.1",
"digest": "2280c026f16c6b92e0daa00bc14e718770f8d231c571ab439bde84d837cf31cc"
},
"school": {
"category": "travel",
"moji": "ðŸ«",
+ "description": "school",
"unicodeVersion": "6.0",
"digest": "af198b068a86ccad3daec4c6873e6b4735086c1ecbb3848182e70bae9aa3ee24"
},
"school_satchel": {
"category": "people",
"moji": "🎒",
+ "description": "school satchel",
"unicodeVersion": "6.0",
"digest": "f670ae8aea67eb9d8aaa0bf2748c1cc3e503dcc1dbe999133afcdf21af046b24"
},
"scissors": {
"category": "objects",
"moji": "✂",
+ "description": "black scissors",
"unicodeVersion": "1.1",
"digest": "95225be28f05d8b5a6b6e6bf58d973f61f183ad4fef55a558dc1b810796b85c8"
},
"scooter": {
"category": "travel",
"moji": "🛴",
+ "description": "scooter",
"unicodeVersion": "9.0",
"digest": "4a7db148880398db75e059711cb53edefb6b8fa9d442009f52856b887ab1dde4"
},
"scorpion": {
"category": "nature",
"moji": "🦂",
+ "description": "scorpion",
"unicodeVersion": "8.0",
"digest": "d41119d1ea5daf727c17dbea7dadec1718c72fc9f98ae88252161df5fde0938a"
},
"scorpius": {
"category": "symbols",
"moji": "â™",
+ "description": "scorpius",
"unicodeVersion": "1.1",
"digest": "a36404b408814c2ecb8fa8b61f5c5432dfcf54cae8c09cc67b8d0fadf7cbdc03"
},
"scream": {
"category": "people",
"moji": "😱",
+ "description": "face screaming in fear",
"unicodeVersion": "6.0",
"digest": "916e4903a4b694da4b00f190f872a4e100e7736b7a2e6171fa1636f46bf646e6"
},
"scream_cat": {
"category": "people",
"moji": "🙀",
+ "description": "weary cat face",
"unicodeVersion": "6.0",
"digest": "f1d3a6ff538064e7d5e0321bbc33aba44e8da703dc1894ef1403c0cd6d63d781"
},
"scroll": {
"category": "objects",
"moji": "📜",
+ "description": "scroll",
"unicodeVersion": "6.0",
"digest": "9b2cb00860bcc2d20017cafb2ed9681b6232dc07273d489d75d53ce29e4ba3ab"
},
"seat": {
"category": "travel",
"moji": "💺",
+ "description": "seat",
"unicodeVersion": "6.0",
"digest": "ae68d86fc2a07cae332451b23bd1ceba3f6526a6c56d8c1089777fa4632850e1"
},
"second_place": {
"category": "activity",
"moji": "🥈",
+ "description": "second place medal",
"unicodeVersion": "9.0",
"digest": "9e2336fc16e532829b55380252f94655b58817d47c909fc2570002c5b06b9c40"
},
"secret": {
"category": "symbols",
"moji": "㊙",
+ "description": "circled ideograph secret",
"unicodeVersion": "1.1",
"digest": "1d0b9adde2657f41421b135962de20820cf4b4eb0204044f9859522ab9d211b0"
},
"see_no_evil": {
"category": "nature",
"moji": "🙈",
+ "description": "see-no-evil monkey",
"unicodeVersion": "6.0",
"digest": "3ff66d2e84b36d071d0a34f8e41cfd620a56b83131474ea50ed7803b635551ed"
},
"seedling": {
"category": "nature",
"moji": "🌱",
+ "description": "seedling",
"unicodeVersion": "6.0",
"digest": "c0ec5e6d20e1afdc4e78eeddb1301c8b708ad6278e7287a4e4e825417c858e75"
},
"selfie": {
"category": "people",
"moji": "🤳",
+ "description": "selfie",
"unicodeVersion": "9.0",
"digest": "2a1bc9f18ad4d6fb893d91c88ef1b2d9bd063dc2bb1a4b08c248c30f52545d4e"
},
"selfie_tone1": {
"category": "people",
"moji": "🤳ðŸ»",
+ "description": "selfie tone 1",
"unicodeVersion": "9.0",
"digest": "26dc212ffed30c276bd6a66a72bc4513e68098a2205fb4ca5b51ccfa1de5b544"
},
"selfie_tone2": {
"category": "people",
"moji": "🤳ðŸ¼",
+ "description": "selfie tone 2",
"unicodeVersion": "9.0",
"digest": "71eceaefda46e3521f374f76693e7fa8f215067498067900080e2925ca94d7de"
},
"selfie_tone3": {
"category": "people",
"moji": "🤳ðŸ½",
+ "description": "selfie tone 3",
"unicodeVersion": "9.0",
"digest": "53eabbd4f6b8ebbd2f7af7bf5cd64309c4039ac1c5b2180290a547deaafcebdf"
},
"selfie_tone4": {
"category": "people",
"moji": "🤳ðŸ¾",
+ "description": "selfie tone 4",
"unicodeVersion": "9.0",
"digest": "0baad378b09652b99c5d458db2e03b4db14a1557db4ea0969806a0ca1d33d40c"
},
"selfie_tone5": {
"category": "people",
"moji": "🤳ðŸ¿",
+ "description": "selfie tone 5",
"unicodeVersion": "9.0",
"digest": "9a07608f34ec4dad48764a855f83f3965709d7b2fd2342e6dc9ed61f23f4adfd"
},
"seven": {
"category": "symbols",
"moji": "7ï¸âƒ£",
+ "description": "keycap digit seven",
"unicodeVersion": "3.0",
"digest": "ae85172d2c76c44afb4e3b45d277d400abb2dc895244b9abfbd1dac1cd7c53c2"
},
"shallow_pan_of_food": {
"category": "food",
"moji": "🥘",
+ "description": "shallow pan of food",
"unicodeVersion": "9.0",
"digest": "7c7ad9d5d3f7226427d310b5853e8257fad899febe58dcbc5adb4677964f5c6d"
},
"shamrock": {
"category": "nature",
"moji": "☘",
+ "description": "shamrock",
"unicodeVersion": "4.1",
"digest": "68ed70c26e04a818439a1742d2da6bc169edd02db86b6e6f8014b651f3235488"
},
"shark": {
"category": "nature",
"moji": "🦈",
+ "description": "shark",
"unicodeVersion": "9.0",
"digest": "23a2364b6356e7bbb84c138e9cf58e2c68cd8caabb337a0c4d365ce87bf5d2da"
},
"shaved_ice": {
"category": "food",
"moji": "ðŸ§",
+ "description": "shaved ice",
"unicodeVersion": "6.0",
"digest": "54048e77268b7548d03088517bf8558d11324db901ca57f9bec93f1873663a74"
},
"sheep": {
"category": "nature",
"moji": "ðŸ‘",
+ "description": "sheep",
"unicodeVersion": "6.0",
"digest": "c867c8e6e51768f1f51f4fe5abd3fbd5c1d69b01a3cb48b5fb94b6e2338a271c"
},
"shell": {
"category": "nature",
"moji": "ðŸš",
+ "description": "spiral shell",
"unicodeVersion": "6.0",
"digest": "8983652d33ad6ab91195518cecb5a268a1c0ae603d271f0ddd756ff50058ddb3"
},
"shield": {
"category": "objects",
"moji": "🛡",
+ "description": "shield",
"unicodeVersion": "7.0",
"digest": "763d0a56a62c51c730ccb0fbea38ab597cbf41a85ab968198e6ec35630d50aa5"
},
"shinto_shrine": {
"category": "travel",
"moji": "⛩",
+ "description": "shinto shrine",
"unicodeVersion": "5.2",
"digest": "38a6d756c5aa9703510afa5076d75192f7814bbb6632394d4b8253d9ceda7f8c"
},
"ship": {
"category": "travel",
"moji": "🚢",
+ "description": "ship",
"unicodeVersion": "6.0",
"digest": "79c680845892a3e81ec6af2160ee07c29147155943e5daba6c76d04252014c20"
},
"shirt": {
"category": "people",
"moji": "👕",
+ "description": "t-shirt",
"unicodeVersion": "6.0",
"digest": "46c7253e15d7cac03699ddb1550fbb7565bbe487310f7e218c0583aa69f9d3c5"
},
"shopping_bags": {
"category": "objects",
"moji": "ðŸ›",
+ "description": "shopping bags",
"unicodeVersion": "7.0",
"digest": "95a3f03c675207bb1354270d02a630c204455c47b3edca23c48523a40cf3ea3b"
},
"shopping_cart": {
"category": "objects",
"moji": "🛒",
+ "description": "shopping trolley",
"unicodeVersion": "9.0",
"digest": "4599b63f6861cdb4d8272cac84435c24c1d4d6a73c66d51e04a1cd14a1d333e6"
},
"shower": {
"category": "objects",
"moji": "🚿",
+ "description": "shower",
"unicodeVersion": "6.0",
"digest": "6b3c767c0eb472d4861c6c3cc2735a5e2c09681872ef42a11dc89f3c80b9da01"
},
"shrimp": {
"category": "nature",
"moji": "ðŸ¦",
+ "description": "shrimp",
"unicodeVersion": "9.0",
"digest": "b3651f3be3767125076a013fe903854f5b456a8afae865cb219cf528e0f44caa"
},
"shrug": {
"category": "people",
"moji": "🤷",
+ "description": "shrug",
"unicodeVersion": "9.0",
"digest": "6e264243cc3b6e396069dea4357a958bdcd4081cb1af0ed6aa47235bef88cf27"
},
"shrug_tone1": {
"category": "people",
"moji": "🤷ðŸ»",
+ "description": "shrug tone 1",
"unicodeVersion": "9.0",
"digest": "0567b9fd95c8a857914003a5465a500ca79c8111811d45b865021b1b1d92d0b1"
},
"shrug_tone2": {
"category": "people",
"moji": "🤷ðŸ¼",
+ "description": "shrug tone 2",
"unicodeVersion": "9.0",
"digest": "1557c2f5e3d4599c806d74c0b78afcca940678787534b6862bb89a20601bac8a"
},
"shrug_tone3": {
"category": "people",
"moji": "🤷ðŸ½",
+ "description": "shrug tone 3",
"unicodeVersion": "9.0",
"digest": "f02754541a7bf74ba7eebe6c27daf1e3e1dac25172c35b8ba45641e278dfda3d"
},
"shrug_tone4": {
"category": "people",
"moji": "🤷ðŸ¾",
+ "description": "shrug tone 4",
"unicodeVersion": "9.0",
"digest": "2b5121164cb5f4e253d8fb31f6445cf8afaf30dba41732edc511440cdb78d15c"
},
"shrug_tone5": {
"category": "people",
"moji": "🤷ðŸ¿",
+ "description": "shrug tone 5",
"unicodeVersion": "9.0",
"digest": "62d99a26bbad479f574f66208c41b9960cd41fb9d79d3a13fbdaa44682077115"
},
"signal_strength": {
"category": "symbols",
"moji": "📶",
+ "description": "antenna with bars",
"unicodeVersion": "6.0",
"digest": "2c6f04ba4ecd2d2d423e19eb52cfbfd253f4db6e0908d91c1af4ea6192597447"
},
"six": {
"category": "symbols",
"moji": "6ï¸âƒ£",
+ "description": "keycap digit six",
"unicodeVersion": "3.0",
"digest": "cede9324261208d0fd5d00fcdfc0df0331944bd9cff4f40b30a582a641526c1c"
},
"six_pointed_star": {
"category": "symbols",
"moji": "🔯",
+ "description": "six pointed star with middle dot",
"unicodeVersion": "6.0",
"digest": "9203e3b4f08af439ae0bfb6a7b29a02dceb027b6c2dc5463b524dfd314cbff4e"
},
"ski": {
"category": "activity",
"moji": "🎿",
+ "description": "ski and ski boot",
"unicodeVersion": "6.0",
"digest": "80f0ca8660ba373fef823af9e98e148c4ddb1e217eb6d0a0ea2bae2288b57570"
},
"skier": {
"category": "activity",
"moji": "â›·",
+ "description": "skier",
"unicodeVersion": "5.2",
"digest": "4fff0aa155367f551a59aed9657b8afa159173882b25db9cd8434293d1eed76d"
},
"skull": {
"category": "people",
"moji": "💀",
+ "description": "skull",
"unicodeVersion": "6.0",
"digest": "cdd2031164281bf2b0083df4479651d96bc16d11e44bac4deaf402a9c0d6f40a"
},
"skull_crossbones": {
"category": "objects",
"moji": "☠",
+ "description": "skull and crossbones",
"unicodeVersion": "1.1",
"digest": "ae764ba21a1fcc4409f4cc9e75a261d70b87548f64158dbd3451374ad5724123"
},
"sleeping": {
"category": "people",
"moji": "😴",
+ "description": "sleeping face",
"unicodeVersion": "6.1",
"digest": "1050a011509b56735c9f30a6fccc876256e2a4546dc6052e518151c8aca4b526"
},
"sleeping_accommodation": {
"category": "objects",
"moji": "🛌",
+ "description": "sleeping accommodation",
"unicodeVersion": "7.0",
"digest": "2ce42c027d1d0947abc403c359fd668a7bc44f5ead2582e97f3db7dd4e22e5d5"
},
"sleepy": {
"category": "people",
"moji": "😪",
+ "description": "sleepy face",
"unicodeVersion": "6.0",
"digest": "2ee9bb1f72ef99e0e33095ec2bbf7a58ffea0ff7d40b840f4cdba57be9de74b0"
},
"slight_frown": {
"category": "people",
"moji": "ðŸ™",
+ "description": "slightly frowning face",
"unicodeVersion": "7.0",
"digest": "d71d564a6c2d366a8e28a78ef4e07d387a77037fe8c99aa0ea1571299dc490c9"
},
"slight_smile": {
"category": "people",
"moji": "🙂",
+ "description": "slightly smiling face",
"unicodeVersion": "7.0",
"digest": "10f4b66a755f5c78762a330f20d1866e4a22f3f1d495161d758d3bab8d2f36fe"
},
"slot_machine": {
"category": "activity",
"moji": "🎰",
+ "description": "slot machine",
"unicodeVersion": "6.0",
"digest": "914184788f8cd865cd074dca25c22acee31f5498117bd9a6e78cae67e6601652"
},
"small_blue_diamond": {
"category": "symbols",
"moji": "🔹",
+ "description": "small blue diamond",
"unicodeVersion": "6.0",
"digest": "0b56d8e6b5ddf1f49fcc76e45e5fb2ee9f99ae6ffe682c26eaea4d9b7faac36c"
},
"small_orange_diamond": {
"category": "symbols",
"moji": "🔸",
+ "description": "small orange diamond",
"unicodeVersion": "6.0",
"digest": "a2235830550e289c1608f2dcf5ede48f5c1a0eff45570699c39708c9677ab950"
},
"small_red_triangle": {
"category": "symbols",
"moji": "🔺",
+ "description": "up-pointing red triangle",
"unicodeVersion": "6.0",
"digest": "8c2985c4e9ce42d2f3b35539b879bc36206c5ef749f39fbd1eac51bd2676e1e5"
},
"small_red_triangle_down": {
"category": "symbols",
"moji": "🔻",
+ "description": "down-pointing red triangle",
"unicodeVersion": "6.0",
"digest": "46bd328df2fbf5d0597596bbf00d2d5f6e0c65bcb8f3fb325df8ba0c25e445b5"
},
"smile": {
"category": "people",
"moji": "😄",
+ "description": "smiling face with open mouth and smiling eyes",
"unicodeVersion": "6.0",
"digest": "14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14"
},
"smile_cat": {
"category": "people",
"moji": "😸",
+ "description": "grinning cat face with smiling eyes",
"unicodeVersion": "6.0",
"digest": "c35b76d6df100edb4022d762f47abfeb9f5e70886960c1d25908bd5d57ccb47e"
},
"smiley": {
"category": "people",
"moji": "😃",
+ "description": "smiling face with open mouth",
"unicodeVersion": "6.0",
"digest": "a89f31eb9d814636852517a7f4eadec59195e2ac2cc9f8d124f1a1cc0f775b4a"
},
"smiley_cat": {
"category": "people",
"moji": "😺",
+ "description": "smiling cat face with open mouth",
"unicodeVersion": "6.0",
"digest": "3e66a113c5e3e73fb94be29084cb27986b6bdb0e78ab44785bf2a35a550e71bf"
},
"smiling_imp": {
"category": "people",
"moji": "😈",
+ "description": "smiling face with horns",
"unicodeVersion": "6.0",
"digest": "3e02131d16525938f6facc7e097365dec7e13c8a0049a3be35fc29c80cc291b3"
},
"smirk": {
"category": "people",
"moji": "ðŸ˜",
+ "description": "smirking face",
"unicodeVersion": "6.0",
"digest": "3c180d46f5574d6fca3bb68eb02517da60b7008843cb3e90f2f9620d0c8ee943"
},
"smirk_cat": {
"category": "people",
"moji": "😼",
+ "description": "cat face with wry smile",
"unicodeVersion": "6.0",
"digest": "0683c7f73e1f65984e91313607d7cca21d99acd4b2e9932f00e0fffd0ce90742"
},
"smoking": {
"category": "objects",
"moji": "🚬",
+ "description": "smoking symbol",
"unicodeVersion": "6.0",
"digest": "baa9cb444bf0fe5c74358f981b19bc9e5c0415ced7f042baf93642282476ea61"
},
"snail": {
"category": "nature",
"moji": "ðŸŒ",
+ "description": "snail",
"unicodeVersion": "6.0",
"digest": "5733bf3672ae4b2b3e090fa670aeac70dcbcc04ca5b13abc8c8e53b8b3d4ff33"
},
"snake": {
"category": "nature",
"moji": "ðŸ",
+ "description": "snake",
"unicodeVersion": "6.0",
"digest": "18da2d97c771149ef5454dd23470e900903a62ab93f9e2ce301aad5a8181d773"
},
"sneezing_face": {
"category": "people",
"moji": "🤧",
+ "description": "sneezing face",
"unicodeVersion": "9.0",
"digest": "c20ef571dc7e35572fe3c18b7845aefc89af083ea925c48a29de3b7387af6e17"
},
"snowboarder": {
"category": "activity",
"moji": "ðŸ‚",
+ "description": "snowboarder",
"unicodeVersion": "6.0",
"digest": "c6e074139b851aa53b1ba6464d84da14b3da7412fc44c6c196a8469d76915c19"
},
"snowflake": {
"category": "nature",
"moji": "â„",
+ "description": "snowflake",
"unicodeVersion": "1.1",
"digest": "6556c918e181df01ba849e76c43972d5310439971e5d8fc2409d112c05bf0028"
},
"snowman": {
"category": "nature",
"moji": "⛄",
+ "description": "snowman without snow",
"unicodeVersion": "5.2",
"digest": "6137456b2335e88e09c1859615eb22bb636355ef438f7a3949ad2f3d54478dd3"
},
"snowman2": {
"category": "nature",
"moji": "☃",
+ "description": "snowman",
"unicodeVersion": "1.1",
"digest": "33ec75c22a13c81fa3c6b24a77ac1a08dc0dbe70b3716cf17b6702014d8a63fe"
},
"sob": {
"category": "people",
"moji": "😭",
+ "description": "loudly crying face",
"unicodeVersion": "6.0",
"digest": "d1ed4b31861f9f9fd4e9c95a9c17530e2320a1b4cad6ececb1545ce25d65e4ce"
},
"soccer": {
"category": "activity",
"moji": "âš½",
+ "description": "soccer ball",
"unicodeVersion": "5.2",
"digest": "6a3f2e6a9a0b64c3fbf8705995792091daf386a4112dba75507a1f556f662f84"
},
"soon": {
"category": "symbols",
"moji": "🔜",
+ "description": "soon with rightwards arrow above",
"unicodeVersion": "6.0",
"digest": "a49d1bcfbac3e6ccc05b9a9863eff74b0eb8b4d4b22b8b0f7b2787fcba1c73cc"
},
"sos": {
"category": "symbols",
"moji": "🆘",
+ "description": "squared sos",
"unicodeVersion": "6.0",
"digest": "2fa7e0274383aeed6019eb9177e778d7aab8b88575b078b0ffeb77cd18df14b3"
},
"sound": {
"category": "symbols",
"moji": "🔉",
+ "description": "speaker with one sound wave",
"unicodeVersion": "6.0",
"digest": "faaca7b315b2495cbc381468580d25f1d11362441c35bb43d8a914f2ec8202d2"
},
"space_invader": {
"category": "activity",
"moji": "👾",
+ "description": "alien monster",
"unicodeVersion": "6.0",
"digest": "e75379cb5063f9a8861d762ad1886097c1697fbb61f2e4e8f531047955a4a2dd"
},
"spades": {
"category": "symbols",
"moji": "â™ ",
+ "description": "black spade suit",
"unicodeVersion": "1.1",
"digest": "2c4d20f6a4893cfc62498d3f1f8f67577f39ed09f3e6682d8cb9cd8f365d30da"
},
"spaghetti": {
"category": "food",
"moji": "ðŸ",
+ "description": "spaghetti",
"unicodeVersion": "6.0",
"digest": "6d3451dc0faa1913539edb99261448f51735f269b61193c53dfe63466c0191e8"
},
"sparkle": {
"category": "symbols",
"moji": "â‡",
+ "description": "sparkle",
"unicodeVersion": "1.1",
"digest": "7131163cd6c2f879110c86e9f068c33cf580f7c4b619449c41851fe6083402ee"
},
"sparkler": {
"category": "travel",
"moji": "🎇",
+ "description": "firework sparkler",
"unicodeVersion": "6.0",
"digest": "88539ed8a13bd66e0c265c0913bd3ec2ddc4d95484323595713beb102221a1f6"
},
"sparkles": {
"category": "nature",
"moji": "✨",
+ "description": "sparkles",
"unicodeVersion": "6.0",
"digest": "cf84d16b1c0a381d5a7ae79031872747c9a6887eab6e92cc4a10a4b8600ef506"
},
"sparkling_heart": {
"category": "symbols",
"moji": "💖",
+ "description": "sparkling heart",
"unicodeVersion": "6.0",
"digest": "b80b1ddef83b6528b309a194f6f2faf5acab603daeb9254523efc2b941bcb6d2"
},
"speak_no_evil": {
"category": "nature",
"moji": "🙊",
+ "description": "speak-no-evil monkey",
"unicodeVersion": "6.0",
"digest": "d2d7cfb4d471928a496bdc146890adc8422a68500b68115630b24c125d18e81f"
},
"speaker": {
"category": "symbols",
"moji": "🔈",
+ "description": "speaker",
"unicodeVersion": "6.0",
"digest": "dbca5f7181728d2ad67ff76fd566ffbdf53e333e7eeed341f54668bd47969413"
},
"speaking_head": {
"category": "people",
"moji": "🗣",
+ "description": "speaking head in silhouette",
"unicodeVersion": "7.0",
"digest": "4be1af79b4506c00af4df64663413bcbae195dab0bc63c5011feb8f9663ed544"
},
"speech_balloon": {
"category": "symbols",
"moji": "💬",
+ "description": "speech balloon",
"unicodeVersion": "6.0",
"digest": "817100d9979456e7d2f253ac22e13b7a2302dc1590566214915b003e403c53ca"
},
"speedboat": {
"category": "travel",
"moji": "🚤",
+ "description": "speedboat",
"unicodeVersion": "6.0",
"digest": "a523b2320f0b24be1e9fdbc1ff828e28d8fd9a64d51e5888ab453ef0bc9f0576"
},
"spider": {
"category": "nature",
"moji": "🕷",
+ "description": "spider",
"unicodeVersion": "7.0",
"digest": "8411eac0c1b80926fd93cc1d6423e00b05d04c485b79ee232da8f1714e899a37"
},
"spider_web": {
"category": "nature",
"moji": "🕸",
+ "description": "spider web",
"unicodeVersion": "7.0",
"digest": "2434bdfbe56dcc4a43699dd59b638af431486b52fb1d6d685451f3b231b2be23"
},
"spoon": {
"category": "food",
"moji": "🥄",
+ "description": "spoon",
"unicodeVersion": "9.0",
"digest": "4fa31d59e5bffd2c45a8e01fcd5652e78a5691cbfa744e69882bc67173ddea05"
},
"spy": {
"category": "people",
"moji": "🕵",
+ "description": "sleuth or spy",
"unicodeVersion": "7.0",
"digest": "99fe3cdeff934726ee5855b0e401bf32570084aaad4eb10df837fd410ca742aa"
},
"spy_tone1": {
"category": "people",
"moji": "🕵ðŸ»",
+ "description": "sleuth or spy tone 1",
"unicodeVersion": "8.0",
"digest": "1720a99064061c43c7647b6bd517efa2ee2621b355a644adfb347d62849366a2"
},
"spy_tone2": {
"category": "people",
"moji": "🕵ðŸ¼",
+ "description": "sleuth or spy tone 2",
"unicodeVersion": "8.0",
"digest": "23ff0026723f2b5a46fbfb55e24c4a4a33af2bd96808b3ea3af76aae99965d68"
},
"spy_tone3": {
"category": "people",
"moji": "🕵ðŸ½",
+ "description": "sleuth or spy tone 3",
"unicodeVersion": "8.0",
"digest": "1d0cb3d54fb61e4763a4f0642ef32094bdd40832be0d42799ce9ba69773616df"
},
"spy_tone4": {
"category": "people",
"moji": "🕵ðŸ¾",
+ "description": "sleuth or spy tone 4",
"unicodeVersion": "8.0",
"digest": "e36a4b52df6cb954fab9d9128111f1301c6d46bdeacf51993ffb5bb354cd0ad3"
},
"spy_tone5": {
"category": "people",
"moji": "🕵ðŸ¿",
+ "description": "sleuth or spy tone 5",
"unicodeVersion": "8.0",
"digest": "ffc6fefd9a537124ebf0a9ddf387414dce1291335026064644f6cf9315591129"
},
"squid": {
"category": "nature",
"moji": "🦑",
+ "description": "squid",
"unicodeVersion": "9.0",
"digest": "65a1b318c2c506b9d26cfd8282a5cf9922109595c8d12e92c3f7481ac7c08c49"
},
"stadium": {
"category": "travel",
"moji": "ðŸŸ",
+ "description": "stadium",
"unicodeVersion": "7.0",
"digest": "73bf955e767ba1518c9c92b2ba59a2aa1ec4b018652dffd97bcd74832a33789f"
},
"star": {
"category": "nature",
"moji": "â­",
+ "description": "white medium star",
"unicodeVersion": "5.1",
"digest": "d78e5c1b78caed103e100150c10b08a9ca3ee30c243943d6fc3cc08f422122e9"
},
"star2": {
"category": "nature",
"moji": "🌟",
+ "description": "glowing star",
"unicodeVersion": "6.0",
"digest": "f91ac4afe3f5d4a52847ae8b4a9704b591e00399aebba553d150d7e34ee939fa"
},
"star_and_crescent": {
"category": "symbols",
"moji": "☪",
+ "description": "star and crescent",
"unicodeVersion": "1.1",
"digest": "1bf3d29e50034f5e7c0dccff0a3a533b74bfa9b489e357b2739a473311f1332a"
},
"star_of_david": {
"category": "symbols",
"moji": "✡",
+ "description": "star of david",
"unicodeVersion": "1.1",
"digest": "28a0bd0eeac9d0835ceb8425d72c2472464e863dd09b76a0ddc1c08cf1986402"
},
"stars": {
"category": "travel",
"moji": "🌠",
+ "description": "shooting star",
"unicodeVersion": "6.0",
"digest": "837d9045316b8fb5e533457eac61241534f641eb78d8cb75f688f80fb8e8a7f0"
},
"station": {
"category": "travel",
"moji": "🚉",
+ "description": "station",
"unicodeVersion": "6.0",
"digest": "27a163ac0aea4ed247a121cae826eafc475977c68b0d888e9405bea14326ff56"
},
"statue_of_liberty": {
"category": "travel",
"moji": "🗽",
+ "description": "statue of liberty",
"unicodeVersion": "6.0",
"digest": "f5a43599ab3f24ed3a78a745e06e2ac3e33107a292386ad81c67935ee5b22493"
},
"steam_locomotive": {
"category": "travel",
"moji": "🚂",
+ "description": "steam locomotive",
"unicodeVersion": "6.0",
"digest": "52ad0073f37b978faf3884fb193046f2b0614e1557bbcc9de1b020e42aff2dba"
},
"stew": {
"category": "food",
"moji": "ðŸ²",
+ "description": "pot of food",
"unicodeVersion": "6.0",
"digest": "c16f61236db314ad8d9f2dd241ec1e15c8d64e5872cce93ec4d0996490dd39df"
},
"stop_button": {
"category": "symbols",
"moji": "â¹",
+ "description": "black square for stop",
"unicodeVersion": "7.0",
"digest": "83f9d0da3ad845fef41b4e8336815d30e9c8f042ab2a8340894ade2f428fc98a"
},
"stopwatch": {
"category": "objects",
"moji": "â±",
+ "description": "stopwatch",
"unicodeVersion": "6.0",
"digest": "9b6b9491a24d8ab4f896eb876da7973f028bd5e7c51a3767ba7e61bb6fbb2be0"
},
"straight_ruler": {
"category": "objects",
"moji": "ðŸ“",
+ "description": "straight ruler",
"unicodeVersion": "6.0",
"digest": "cee31101767bd3f961363599924dc3790675d05a1285a8396428d2f91771c111"
},
"strawberry": {
"category": "food",
"moji": "ðŸ“",
+ "description": "strawberry",
"unicodeVersion": "6.0",
"digest": "5750a15e12f21259286ddbc3a8222a385b3b97a9f368897f42dd000060343174"
},
"stuck_out_tongue": {
"category": "people",
"moji": "😛",
+ "description": "face with stuck-out tongue",
"unicodeVersion": "6.1",
"digest": "92dc42980a6dfdd7204fc874a762d6a0bbf0fdbfb5a7c0698fca04782e99fde6"
},
"stuck_out_tongue_closed_eyes": {
"category": "people",
"moji": "ðŸ˜",
+ "description": "face with stuck-out tongue and tightly-closed eyes",
"unicodeVersion": "6.0",
"digest": "434d25ac24cad7ba699eae876a25d9a99b584449cca50b124bf6aa7f20a83d51"
},
"stuck_out_tongue_winking_eye": {
"category": "people",
"moji": "😜",
+ "description": "face with stuck-out tongue and winking eye",
"unicodeVersion": "6.0",
"digest": "dbacd6428a2a2933212e6a4dc0c7f302177fb23b963626ccb26f27f91737f03d"
},
"stuffed_flatbread": {
"category": "food",
"moji": "🥙",
+ "description": "stuffed flatbread",
"unicodeVersion": "9.0",
"digest": "9f841f2520640d69be4f20a3199023d5811842b28556b5e1152e5ec11f0fda07"
},
"sun_with_face": {
"category": "nature",
"moji": "🌞",
+ "description": "sun with face",
"unicodeVersion": "6.0",
"digest": "7256ff5263006c64c03f1eb66e3ddb56d67d785d65dacc37aa886d0cd4be63be"
},
"sunflower": {
"category": "nature",
"moji": "🌻",
+ "description": "sunflower",
"unicodeVersion": "6.0",
"digest": "27d1161f50f932a6b26c404cf2e8f7083683ed0f2382d62b7472acccaa6eb695"
},
"sunglasses": {
"category": "people",
"moji": "😎",
+ "description": "smiling face with sunglasses",
"unicodeVersion": "6.0",
"digest": "966684382e5c59e98319e4c0ea7c304c61c2638ad5408faa49ce2c83c4416757"
},
"sunny": {
"category": "nature",
"moji": "☀",
+ "description": "black sun with rays",
"unicodeVersion": "1.1",
"digest": "460fea4cbbdd1595450c1033a2ee5de7fea2e2f147822efa49f7e204812415aa"
},
"sunrise": {
"category": "travel",
"moji": "🌅",
+ "description": "sunrise",
"unicodeVersion": "6.0",
"digest": "7718a49636b0cdd1862ed67c7a9d6e72f471c2591ff0d912485b1be55d1ea115"
},
"sunrise_over_mountains": {
"category": "travel",
"moji": "🌄",
+ "description": "sunrise over mountains",
"unicodeVersion": "6.0",
"digest": "743d0701cdbe2a814962363813c3153d3c5e62c3e410349f56d49dbb9581f356"
},
"surfer": {
"category": "activity",
"moji": "ðŸ„",
+ "description": "surfer",
"unicodeVersion": "6.0",
"digest": "bb440775e9213430942015c37db8de58b5a561ee971b2a0f3993fc3f1d2554d4"
},
"surfer_tone1": {
"category": "activity",
"moji": "ðŸ„ðŸ»",
+ "description": "surfer tone 1",
"unicodeVersion": "8.0",
"digest": "a4937b030aca30b68bb644f37cf63c38aebce3c00b57d1c8a0ffe596b57d2f1e"
},
"surfer_tone2": {
"category": "activity",
"moji": "ðŸ„ðŸ¼",
+ "description": "surfer tone 2",
"unicodeVersion": "8.0",
"digest": "1c2a954a9c5284dedf0327d6f3c954c9fdd3953b848076d298874775ad8bf0a3"
},
"surfer_tone3": {
"category": "activity",
"moji": "ðŸ„ðŸ½",
+ "description": "surfer tone 3",
"unicodeVersion": "8.0",
"digest": "418a3408b9ab026124f067c8597b500217e56bc28d9844a29eea5eee6f604ff8"
},
"surfer_tone4": {
"category": "activity",
"moji": "ðŸ„ðŸ¾",
+ "description": "surfer tone 4",
"unicodeVersion": "8.0",
"digest": "530870b9ac9f4d45ff750e264feb90b44fb93ca2852f323987b06f5f12fb5a4d"
},
"surfer_tone5": {
"category": "activity",
"moji": "ðŸ„ðŸ¿",
+ "description": "surfer tone 5",
"unicodeVersion": "8.0",
"digest": "40e11b1ae652cfd085d083377f1da24160065ed1b67403c6fa4655e6e44169ec"
},
"sushi": {
"category": "food",
"moji": "ðŸ£",
+ "description": "sushi",
"unicodeVersion": "6.0",
"digest": "b924c621236ca3284b349b0509ae1043f2fc2c7f6d67615716f9717ada78c992"
},
"suspension_railway": {
"category": "travel",
"moji": "🚟",
+ "description": "suspension railway",
"unicodeVersion": "6.0",
"digest": "cd3d21da79864f0c018b863e82fb0561fff3c5e3c065303cfcb89c3663d638ba"
},
"sweat": {
"category": "people",
"moji": "😓",
+ "description": "face with cold sweat",
"unicodeVersion": "6.0",
"digest": "1aa771479aa1ac5eeea4bafbe93ebd85a0f692f6d869034f31e25b689c2e264d"
},
"sweat_drops": {
"category": "nature",
"moji": "💦",
+ "description": "splashing sweat symbol",
"unicodeVersion": "6.0",
"digest": "b575b85415bc9852cf6415d417ebf799167fde03c6819ebcaa24ae1b3dde8dab"
},
"sweat_smile": {
"category": "people",
"moji": "😅",
+ "description": "smiling face with open mouth and cold sweat",
"unicodeVersion": "6.0",
"digest": "171b0d0845d46c33bedb6d3b39fb1ff366e22ba90685eedabebd91bb2b0680de"
},
"sweet_potato": {
"category": "food",
"moji": "ðŸ ",
+ "description": "roasted sweet potato",
"unicodeVersion": "6.0",
"digest": "4b91920f0b87d42763313bc476f4c821a74e4c12dc1c92165a859dddeaaf8844"
},
"swimmer": {
"category": "activity",
"moji": "ðŸŠ",
+ "description": "swimmer",
"unicodeVersion": "6.0",
"digest": "2c4ed4a51aad99d9957ae11a219d5164db9748fc3a65002c6085a9f15adfa9e2"
},
"swimmer_tone1": {
"category": "activity",
"moji": "ðŸŠðŸ»",
+ "description": "swimmer tone 1",
"unicodeVersion": "8.0",
"digest": "48588f129ee4af52ca2e0f4594213391978601087cd607896b2f979ca077284b"
},
"swimmer_tone2": {
"category": "activity",
"moji": "ðŸŠðŸ¼",
+ "description": "swimmer tone 2",
"unicodeVersion": "8.0",
"digest": "fff209448524bd1ef4d6decabf6c1ead94c8d3d5b1bfb5e54f20cc8e139232fc"
},
"swimmer_tone3": {
"category": "activity",
"moji": "ðŸŠðŸ½",
+ "description": "swimmer tone 3",
"unicodeVersion": "8.0",
"digest": "2003932cb2cf4ae9a10b23338bf375a9293fb18c0ecf91bdfae73be6eebb3800"
},
"swimmer_tone4": {
"category": "activity",
"moji": "ðŸŠðŸ¾",
+ "description": "swimmer tone 4",
"unicodeVersion": "8.0",
"digest": "20b4bff9baa1c694ad98067dde834c56092f023b9664bec382c2e512232bd480"
},
"swimmer_tone5": {
"category": "activity",
"moji": "ðŸŠðŸ¿",
+ "description": "swimmer tone 5",
"unicodeVersion": "8.0",
"digest": "0ff8eb57c2be8e80a1bc6ba75b8d9ffb9bd8d3be636150c4c03399ec1886f218"
},
"symbols": {
"category": "symbols",
"moji": "🔣",
+ "description": "input symbol for symbols",
"unicodeVersion": "6.0",
"digest": "2a2a79816c4d0751a0d73586eec5e63b410653d3c85cc968906bf1fc03d89b94"
},
"synagogue": {
"category": "travel",
"moji": "ðŸ•",
+ "description": "synagogue",
"unicodeVersion": "8.0",
"digest": "98569cdd7c61528963b67b7891dfa46025c5e810cbb22ee18ddb3bd85de2da69"
},
"syringe": {
"category": "objects",
"moji": "💉",
+ "description": "syringe",
"unicodeVersion": "6.0",
"digest": "e1538e645ccc571227c994b71b3d1be2c4d072d8bd9c944a42ff4a11c91a34a6"
},
"taco": {
"category": "food",
"moji": "🌮",
+ "description": "taco",
"unicodeVersion": "8.0",
"digest": "e1e45aefdb7445faeae75c3831df6a3d6f2590fcdd48a20d847593c246df613b"
},
"tada": {
"category": "objects",
"moji": "🎉",
+ "description": "party popper",
"unicodeVersion": "6.0",
"digest": "1d2e6cbb2a3244240bc70209715d2213d1efee2e370cccfbcc046c333ae2d650"
},
"tanabata_tree": {
"category": "nature",
"moji": "🎋",
+ "description": "tanabata tree",
"unicodeVersion": "6.0",
"digest": "592f2907ffc1b914390e1a106c15120ff3607e99192158b94d237975647c5540"
},
"tangerine": {
"category": "food",
"moji": "ðŸŠ",
+ "description": "tangerine",
"unicodeVersion": "6.0",
"digest": "40c9ddcde1b0bcfaeb466629a87825eb8c2037835720cbee5e2fda04be3c8d0a"
},
"taurus": {
"category": "symbols",
"moji": "♉",
+ "description": "taurus",
"unicodeVersion": "1.1",
"digest": "21cf24cb6410ab6596e2df8b3e242cc07f9dbb247eabc00c590fe184b373d068"
},
"taxi": {
"category": "travel",
"moji": "🚕",
+ "description": "taxi",
"unicodeVersion": "6.0",
"digest": "c546cc743831cfbf0c15452767cf2a4faf3775066797e997ae7c1fcbe4eca479"
},
"tea": {
"category": "food",
"moji": "ðŸµ",
+ "description": "teacup without handle",
"unicodeVersion": "6.0",
"digest": "00e3f1e389fa58c4fcd8c53ebbf83d25872f4315845ab1984b35410ae65553d9"
},
"telephone": {
"category": "objects",
"moji": "☎",
+ "description": "black telephone",
"unicodeVersion": "1.1",
"digest": "3a53851e641f8ad938ce3597b1afca2ea63c9314ff81f62563b99937496a13d7"
},
"telephone_receiver": {
"category": "objects",
"moji": "📞",
+ "description": "telephone receiver",
"unicodeVersion": "6.0",
"digest": "1614d67f3d8814b0d75f39d55f9149e4d28ef57b343498625e62fcfff8365046"
},
"telescope": {
"category": "objects",
"moji": "🔭",
+ "description": "telescope",
"unicodeVersion": "6.0",
"digest": "4adf40387870276c4f59fb050d441023e8dac784365b6a8c0282fb519780b495"
},
"ten": {
"category": "symbols",
"moji": "🔟",
+ "description": "keycap ten",
"unicodeVersion": "6.0",
"digest": "c7c9491021740d2c17edddb856f79579b0b943d8dc85a2f48dbaac84f35b8a40"
},
"tennis": {
"category": "activity",
"moji": "🎾",
+ "description": "tennis racquet and ball",
"unicodeVersion": "6.0",
"digest": "dc1600b4d8dce3d26259eb0d1c6ab042566565e3c1f2c96112210f1550a716fd"
},
"tent": {
"category": "travel",
"moji": "⛺",
+ "description": "tent",
"unicodeVersion": "5.2",
"digest": "30d9b17ac3219d4970ddf54d7c1a288b0ae50f7f3b82ed232c0b1b19ef585662"
},
"thermometer": {
"category": "objects",
"moji": "🌡",
+ "description": "thermometer",
"unicodeVersion": "7.0",
"digest": "66616babbcaef256d7b652796c760e8e893cb950c073348a408fe70904f80f25"
},
"thermometer_face": {
"category": "people",
"moji": "🤒",
+ "description": "face with thermometer",
"unicodeVersion": "8.0",
"digest": "ac2b5caddd128563711a9dcc7f690cf210f684d5e8b64b09c0431d6902437126"
},
"thinking": {
"category": "people",
"moji": "🤔",
+ "description": "thinking face",
"unicodeVersion": "8.0",
"digest": "4f0b84e5ab8a650cafb166e93688f0e9b31b9ade22a91035261ac90490edb9d3"
},
"third_place": {
"category": "activity",
"moji": "🥉",
+ "description": "third place medal",
"unicodeVersion": "9.0",
"digest": "27c9bcba44ad95bee30882cc0722e8b0a798206306655dd648e884447ed26808"
},
"thought_balloon": {
"category": "symbols",
"moji": "💭",
+ "description": "thought balloon",
"unicodeVersion": "6.0",
"digest": "bf59624560c333561d636aedf2c8827089e275895cf434974daaabb3d5cea46e"
},
"three": {
"category": "symbols",
"moji": "3ï¸âƒ£",
+ "description": "keycap digit three",
"unicodeVersion": "3.0",
"digest": "d3f85828787799c769655c38a519cad0743ab799ab276c7606e6e6894cc442e6"
},
"thumbsdown": {
"category": "people",
"moji": "👎",
+ "description": "thumbs down sign",
"unicodeVersion": "6.0",
"digest": "5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61"
},
"thumbsdown_tone1": {
"category": "people",
"moji": "👎ðŸ»",
+ "description": "thumbs down sign tone 1",
"unicodeVersion": "8.0",
"digest": "3c2853491473fd7ae2d1b5415a425cc390d26a8754446f8736c1360e4cb18ba3"
},
"thumbsdown_tone2": {
"category": "people",
"moji": "👎ðŸ¼",
+ "description": "thumbs down sign tone 2",
"unicodeVersion": "8.0",
"digest": "4e0f8f86a06b69e423df8d93f41ec393f12800633acc82c4cb6dff64ca0d8507"
},
"thumbsdown_tone3": {
"category": "people",
"moji": "👎ðŸ½",
+ "description": "thumbs down sign tone 3",
"unicodeVersion": "8.0",
"digest": "e08fa35575f59978612d4330bbc35313eca9c4dfa04f4212626abc700819effe"
},
"thumbsdown_tone4": {
"category": "people",
"moji": "👎ðŸ¾",
+ "description": "thumbs down sign tone 4",
"unicodeVersion": "8.0",
"digest": "7c6d118d20d5add8ca003e4a53e42685a1f9436b872ed10d79f67ad418fb2a44"
},
"thumbsdown_tone5": {
"category": "people",
"moji": "👎ðŸ¿",
+ "description": "thumbs down sign tone 5",
"unicodeVersion": "8.0",
"digest": "8697c4a4ee4d6669dc2d47aa97699c42012ca59b80818ad6845878b37b4a9c58"
},
"thumbsup": {
"category": "people",
"moji": "ðŸ‘",
+ "description": "thumbs up sign",
"unicodeVersion": "6.0",
"digest": "59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61"
},
"thumbsup_tone1": {
"category": "people",
"moji": "ðŸ‘ðŸ»",
+ "description": "thumbs up sign tone 1",
"unicodeVersion": "8.0",
"digest": "f57e6c525e8830779ea5026590eec3ca10869dc438a0c779734b617d04f28d21"
},
"thumbsup_tone2": {
"category": "people",
"moji": "ðŸ‘ðŸ¼",
+ "description": "thumbs up sign tone 2",
"unicodeVersion": "8.0",
"digest": "980eeeb1d8f5d79dae35c7ff81a576e980aa13a440d07b10e32e98ed34cbf7f1"
},
"thumbsup_tone3": {
"category": "people",
"moji": "ðŸ‘ðŸ½",
+ "description": "thumbs up sign tone 3",
"unicodeVersion": "8.0",
"digest": "b3881060569e56e1dd75ca7960feab0e58ae51f440458781948d65d461116b4e"
},
"thumbsup_tone4": {
"category": "people",
"moji": "ðŸ‘ðŸ¾",
+ "description": "thumbs up sign tone 4",
"unicodeVersion": "8.0",
"digest": "86fbe2c95414bce5e38fb5c33da31305d7942fca2c9c79168dcffdbd895e9ad6"
},
"thumbsup_tone5": {
"category": "people",
"moji": "ðŸ‘ðŸ¿",
+ "description": "thumbs up sign tone 5",
"unicodeVersion": "8.0",
"digest": "49fa63ff725c746a18649df16c8fab69bad88bbb564884df79d1d15f553b7343"
},
"thunder_cloud_rain": {
"category": "nature",
"moji": "⛈",
+ "description": "thunder cloud and rain",
"unicodeVersion": "5.2",
"digest": "dacc20b4f6b68e5834aa1b8391afa5e83b5e6eb28e2d2174d3a68186a770506d"
},
"ticket": {
"category": "activity",
"moji": "🎫",
+ "description": "ticket",
"unicodeVersion": "6.0",
"digest": "b4326fe7761940216e6c76ee2928110a6b37bf913da9d694e96557e7c7c10420"
},
"tickets": {
"category": "activity",
"moji": "🎟",
+ "description": "admission tickets",
"unicodeVersion": "7.0",
"digest": "fb73358c3697c04fcfde6a1e705b1c3b47635b93b9cadfe31d5657566c7d190a"
},
"tiger": {
"category": "nature",
"moji": "ðŸ¯",
+ "description": "tiger face",
"unicodeVersion": "6.0",
"digest": "e139531e6c930bc46242dc0ed274661229de026b5419d8ea8f99fdb0f8a719ab"
},
"tiger2": {
"category": "nature",
"moji": "ðŸ…",
+ "description": "tiger",
"unicodeVersion": "6.0",
"digest": "f930cc8714198310d9b0edca6baff243ac5a3320f75fadb56fa5acc6fe34ff24"
},
"timer": {
"category": "objects",
"moji": "â²",
+ "description": "timer clock",
"unicodeVersion": "6.0",
"digest": "69b33f219523d89d81cbbc070ad7e528711e4b34e124a50acb12a0280a34d0b0"
},
"tired_face": {
"category": "people",
"moji": "😫",
+ "description": "tired face",
"unicodeVersion": "6.0",
"digest": "775739bc9324517e614878ca0960d793df97775feeb62b14dbfb311a42a21802"
},
"tm": {
"category": "symbols",
"moji": "â„¢",
+ "description": "trade mark sign",
"unicodeVersion": "1.1",
"digest": "7d9fafdb72d91860478fc185719f289f359eab2c368a132cb936a269e2ab6a24"
},
"toilet": {
"category": "objects",
"moji": "🚽",
+ "description": "toilet",
"unicodeVersion": "6.0",
"digest": "0d1b0dd0078f51104e8632a0726e1b3f075561a1ffa8a2546602de15798415d0"
},
"tokyo_tower": {
"category": "travel",
"moji": "🗼",
+ "description": "tokyo tower",
"unicodeVersion": "6.0",
"digest": "73eaf6fd59d16396673afef620c6d928857d5cf616e95a40eaf2861686e0956a"
},
"tomato": {
"category": "food",
"moji": "ðŸ…",
+ "description": "tomato",
"unicodeVersion": "6.0",
"digest": "d092d8ad381d542e59b6a82b4f1ef0d10fc1ed48460952375c6c5c6258cea111"
},
"tone1": {
"category": "modifier",
"moji": "ðŸ»",
+ "description": "emoji modifier Fitzpatrick type-1-2",
"unicodeVersion": "8.0",
"digest": "5c62003a098b774c068be45d658db3c0dd38483c0871f7c8ae293bc1222c4f0c"
},
"tone2": {
"category": "modifier",
"moji": "ðŸ¼",
+ "description": "emoji modifier Fitzpatrick type-3",
"unicodeVersion": "8.0",
"digest": "3c636ecbc4e58c7a360f2338daaf44e7da598fd07e0ba1514bb5c0f83fc8819f"
},
"tone3": {
"category": "modifier",
"moji": "ðŸ½",
+ "description": "emoji modifier Fitzpatrick type-4",
"unicodeVersion": "8.0",
"digest": "398a1e5441b64c9c2d033bbc01d7a8d90b4db30ea9f30e28f0a9120c72a48df8"
},
"tone4": {
"category": "modifier",
"moji": "ðŸ¾",
+ "description": "emoji modifier Fitzpatrick type-5",
"unicodeVersion": "8.0",
"digest": "ff4a12195aeb7494c785b81266efad8cd60c8022c407a0fc032a02e8b83216b3"
},
"tone5": {
"category": "modifier",
"moji": "ðŸ¿",
+ "description": "emoji modifier Fitzpatrick type-6",
"unicodeVersion": "8.0",
"digest": "9e9f0125b5d57011b7456c84719e6be6cf71d06c1b198081d0937c0979164a81"
},
"tongue": {
"category": "people",
"moji": "👅",
+ "description": "tongue",
"unicodeVersion": "6.0",
"digest": "286e9d2583c371431d6fc979dd4ab48981676da26baada51a846657a3654c19b"
},
"tools": {
"category": "objects",
"moji": "🛠",
+ "description": "hammer and wrench",
"unicodeVersion": "7.0",
"digest": "bf08d60dedc06de73d04dab05703bb8ad81989c72b5035d1a07821e51096f158"
},
"top": {
"category": "symbols",
"moji": "ðŸ”",
+ "description": "top with upwards arrow above",
"unicodeVersion": "6.0",
"digest": "c9a9f25b17db014e76b6be54aa07ef89bb18f8adb41b3199d180a559ff1d9ea5"
},
"tophat": {
"category": "people",
"moji": "🎩",
+ "description": "top hat",
"unicodeVersion": "6.0",
"digest": "43a45dfb5d6b57a63a0491f4e3ec780774c0301b53ed39a303a0bd803d16ed71"
},
"track_next": {
"category": "symbols",
"moji": "â­",
+ "description": "black right-pointing double triangle with vertical bar",
"unicodeVersion": "6.0",
"digest": "88592ef6c720a32aeb752322fb4c794bf5110a72408e21e898630452115c731c"
},
"track_previous": {
"category": "symbols",
"moji": "â®",
+ "description": "black left-pointing double triangle with vertical bar",
"unicodeVersion": "6.0",
"digest": "98c1b3d643768d94857fb762f6d26cfb87282b449a67792242e8b7068643ac87"
},
"trackball": {
"category": "objects",
"moji": "🖲",
+ "description": "trackball",
"unicodeVersion": "7.0",
"digest": "32a819a3129429f797ad434d0c40e263dc236808e34878c599ed2304b43702f5"
},
"tractor": {
"category": "travel",
"moji": "🚜",
+ "description": "tractor",
"unicodeVersion": "6.0",
"digest": "5e4686290f1a4c9953ae208340b7d276f25b3b2197a43e52469aeb6450e93997"
},
"traffic_light": {
"category": "travel",
"moji": "🚥",
+ "description": "horizontal traffic light",
"unicodeVersion": "6.0",
"digest": "d96aacade33d1ad3e0414f8a920513010f36eb7e5889774251c1d91148917ead"
},
"train": {
"category": "travel",
"moji": "🚋",
+ "description": "Tram Car",
"unicodeVersion": "6.0",
"digest": "7423d17e131df7aadaa350b5d39dcbce3b28de331ff8b6703a3b2d0093963f4b"
},
"train2": {
"category": "travel",
"moji": "🚆",
+ "description": "train",
"unicodeVersion": "6.0",
"digest": "06e65d549e771632f3c64287a38ba67236f9800ccb6a23c3b592bc010e24e122"
},
"tram": {
"category": "travel",
"moji": "🚊",
+ "description": "tram",
"unicodeVersion": "6.0",
"digest": "21a7699f1a94f06dcb4d1e896448b98a4205f8efe902a8ac169a5005d11ab100"
},
"triangular_flag_on_post": {
"category": "objects",
"moji": "🚩",
+ "description": "triangular flag on post",
"unicodeVersion": "6.0",
"digest": "1f5ce3828a42f5b1717bac1521d0502cf7081ad9f15e8ed292c1a65f0d1386da"
},
"triangular_ruler": {
"category": "objects",
"moji": "ðŸ“",
+ "description": "triangular ruler",
"unicodeVersion": "6.0",
"digest": "a0367dcf663ec934f1fc7c88bfaccc02b229a896f60930a66bb02241c933e501"
},
"trident": {
"category": "symbols",
"moji": "🔱",
+ "description": "trident emblem",
"unicodeVersion": "6.0",
"digest": "ee45920845d3b35c2e45b934cf30ce97bfe2f24c5d72ef1ac6e0842e52b50fc1"
},
"triumph": {
"category": "people",
"moji": "😤",
+ "description": "face with look of triumph",
"unicodeVersion": "6.0",
"digest": "4aa44b8e1682c1269624a359f4b0bf613553683b883d947561ab169d7f85da0f"
},
"trolleybus": {
"category": "travel",
"moji": "🚎",
+ "description": "trolleybus",
"unicodeVersion": "6.0",
"digest": "f610b4fd1123f06778a8e3bb8f738d5b0079aeb0b0926b6a63268c0dd0ee03ed"
},
"trophy": {
"category": "activity",
"moji": "ðŸ†",
+ "description": "trophy",
"unicodeVersion": "6.0",
"digest": "50cfbedac18bf0fa5dec727643e15ec47f64068944b536e97518ee3be4f08006"
},
"tropical_drink": {
"category": "food",
"moji": "ðŸ¹",
+ "description": "tropical drink",
"unicodeVersion": "6.0",
"digest": "54144fce60d650f426b1edf09e47c70b2762222398c1fe40231881f074603a69"
},
"tropical_fish": {
"category": "nature",
"moji": "ðŸ ",
+ "description": "tropical fish",
"unicodeVersion": "6.0",
"digest": "fd92100aaa9328da35e6090388824921b9726b474d1432a926d2cf9c45ad6528"
},
"truck": {
"category": "travel",
"moji": "🚚",
+ "description": "delivery truck",
"unicodeVersion": "6.0",
"digest": "0d1571e58e900abc453df0ff683fe7acb5906ecbdd52ab35b7101074359faf18"
},
"trumpet": {
"category": "activity",
"moji": "🎺",
+ "description": "trumpet",
"unicodeVersion": "6.0",
"digest": "cea3614c309f5573f328f4603120dbe930016a35f0dfa400b0d968fe9fff2d55"
},
"tulip": {
"category": "nature",
"moji": "🌷",
+ "description": "tulip",
"unicodeVersion": "6.0",
"digest": "e744e8dbbdc6b126bd5b15aad56b524191de5a604189f4ab6d96730dfef4d086"
},
"tumbler_glass": {
"category": "food",
"moji": "🥃",
+ "description": "tumbler glass",
"unicodeVersion": "9.0",
"digest": "7a38658274b9ff28836725a1dbfad49b8fa3af5ec8385e629db6bfdc7d93907a"
},
"turkey": {
"category": "nature",
"moji": "🦃",
+ "description": "turkey",
"unicodeVersion": "8.0",
"digest": "bf5daef15716b66636a5fdb6d059420521443c0603e2d56bd7c99c791a7285f4"
},
"turtle": {
"category": "nature",
"moji": "ðŸ¢",
+ "description": "turtle",
"unicodeVersion": "6.0",
"digest": "588c35fb42c9502a908e9805517d4cc8c4ba4e74c9beed4035779fea1efe14f8"
},
"tv": {
"category": "objects",
"moji": "📺",
+ "description": "television",
"unicodeVersion": "6.0",
"digest": "1279f3f3955a58dbbf74e248fc914b0bdba9c4c6b6a5176e9d12bf2750ecfeb4"
},
"twisted_rightwards_arrows": {
"category": "symbols",
"moji": "🔀",
+ "description": "twisted rightwards arrows",
"unicodeVersion": "6.0",
"digest": "fed07eebc2cf0d977ca0826bbd80defafbbcf118508444148f47b58949ebe27c"
},
"two": {
"category": "symbols",
"moji": "2ï¸âƒ£",
+ "description": "keycap digit two",
"unicodeVersion": "3.0",
"digest": "b346f51f6523b02ebcbd753256804e2f9cc1574c96aa634362bf9401dac2c661"
},
"two_hearts": {
"category": "symbols",
"moji": "💕",
+ "description": "two hearts",
"unicodeVersion": "6.0",
"digest": "6ded120a59aed790b441ec8fbbdea6f5cbfb4fa48e9e4b224cc29c9fde2d2e4c"
},
"two_men_holding_hands": {
"category": "people",
"moji": "👬",
+ "description": "two men holding hands",
"unicodeVersion": "6.0",
"digest": "bfcf9e20a67d00262cdf6e85f1acd545dda91f2e370d68bfd41ce02f232a2987"
},
"two_women_holding_hands": {
"category": "people",
"moji": "👭",
+ "description": "two women holding hands",
"unicodeVersion": "6.0",
"digest": "9d9d2b37a7f8e16fde1468dd8b5645003ea81ae4bf8bcf68471e2381845dd0dd"
},
"u5272": {
"category": "symbols",
"moji": "🈹",
+ "description": "squared cjk unified ideograph-5272",
"unicodeVersion": "6.0",
"digest": "01e6cb8f74ea3c19fdade59c2d13d158b90dc6b4b293421b2014b7478bf20870"
},
"u5408": {
"category": "symbols",
"moji": "🈴",
+ "description": "squared cjk unified ideograph-5408",
"unicodeVersion": "6.0",
"digest": "084cdbd5436670ea4dc22010e269c1ab7b0432897b8675301e69120374bcdd14"
},
"u55b6": {
"category": "symbols",
"moji": "🈺",
+ "description": "squared cjk unified ideograph-55b6",
"unicodeVersion": "6.0",
"digest": "c1017023d20d4aae78d59342dd3bfc5282716ea0601d9a8c2476335cbf7a2e12"
},
"u6307": {
"category": "symbols",
"moji": "🈯",
+ "description": "squared cjk unified ideograph-6307",
"unicodeVersion": "5.2",
"digest": "f459b092b974f459db1fb9cc13617a448b2e4f2b4dc46cc316d8c46af6e7d8bd"
},
"u6708": {
"category": "symbols",
"moji": "🈷",
+ "description": "squared cjk unified ideograph-6708",
"unicodeVersion": "6.0",
"digest": "928815abf5b30f92efe5168de0c7e6cf8c17899a03e358ab42f42667e0a4a04c"
},
"u6709": {
"category": "symbols",
"moji": "🈶",
+ "description": "squared cjk unified ideograph-6709",
"unicodeVersion": "6.0",
"digest": "f63a48ee06c892d24acec8b5634c021658d2ebde67a42d8faa86f27804a9f26d"
},
"u6e80": {
"category": "symbols",
"moji": "🈵",
+ "description": "squared cjk unified ideograph-6e80",
"unicodeVersion": "6.0",
"digest": "489181d90a5e43068459530673a153e4af04fdad8514ec341ff7afbcfd366c3b"
},
"u7121": {
"category": "symbols",
"moji": "🈚",
+ "description": "squared cjk unified ideograph-7121",
"unicodeVersion": "5.2",
"digest": "9c50fd2ba14221affd2dcd3746322c2137dd75458493f4d385b544eb5bd8d6cd"
},
"u7533": {
"category": "symbols",
"moji": "🈸",
+ "description": "squared cjk unified ideograph-7533",
"unicodeVersion": "6.0",
"digest": "2b05819b380a2ea47cc5fde8fcce3d53922fd223d6f5bd83d696d44175b69f18"
},
"u7981": {
"category": "symbols",
"moji": "🈲",
+ "description": "squared cjk unified ideograph-7981",
"unicodeVersion": "6.0",
"digest": "adbe12601b22972003ddebcb0bd1532b979aa9c78bfdc147511854b5014eabc0"
},
"u7a7a": {
"category": "symbols",
"moji": "🈳",
+ "description": "squared cjk unified ideograph-7a7a",
"unicodeVersion": "6.0",
"digest": "b9ee0ec7bb0b86c3eb73d4dbbb91848c427bf356ae30a263b9b44bd9bd784482"
},
"umbrella": {
"category": "nature",
"moji": "☔",
+ "description": "umbrella with rain drops",
"unicodeVersion": "4.0",
"digest": "0328a2f48b7df47905e2655460e524c0794ef12d3d7c32a049a10892d5662f77"
},
"umbrella2": {
"category": "nature",
"moji": "☂",
+ "description": "umbrella",
"unicodeVersion": "1.1",
"digest": "2f6a58110dc590480a822a3ffa2b5bc86f295e0c994a4a632837d25d4cf9fc58"
},
"unamused": {
"category": "people",
"moji": "😒",
+ "description": "unamused face",
"unicodeVersion": "6.0",
"digest": "0d597088e3e7880918d0166e5c69243b18fe64afa31685c39bfdbc71494aa132"
},
"underage": {
"category": "symbols",
"moji": "🔞",
+ "description": "no one under eighteen symbol",
"unicodeVersion": "6.0",
"digest": "b6b194614ca714ac2b1c2c17b75fe5922c7fdadb3d1157ba89ab2a5d03494a67"
},
"unicorn": {
"category": "nature",
"moji": "🦄",
+ "description": "unicorn face",
"unicodeVersion": "8.0",
"digest": "f71bb485a7c208e999dd45f2b36d7b7d517898c0627947926b05aa28603804ca"
},
"unlock": {
"category": "objects",
"moji": "🔓",
+ "description": "open lock",
"unicodeVersion": "6.0",
"digest": "9554ef3a6a315938b873e77970d9b0212e61f13c6cc36e4f17f87acc930a9a53"
},
"up": {
"category": "symbols",
"moji": "🆙",
+ "description": "squared up with exclamation mark",
"unicodeVersion": "6.0",
"digest": "ff2554ccf08c7208b38794c5fa3d9a93a46ff191a49401195d8f740846121906"
},
"upside_down": {
"category": "people",
"moji": "🙃",
+ "description": "upside-down face",
"unicodeVersion": "8.0",
"digest": "5129121f0a28f5b334268c28565de26a5907559568deca11de6ec620b097dfe1"
},
"urn": {
"category": "objects",
"moji": "âš±",
+ "description": "funeral urn",
"unicodeVersion": "4.1",
"digest": "9bebf589eed8dd361f6a03cd1b325078f2cd0e82270ef63a7dd1b6aee08cd1e6"
},
"v": {
"category": "people",
"moji": "✌",
+ "description": "victory hand",
"unicodeVersion": "1.1",
"digest": "9825bf440df289a8edf8ede494e8c778dc63c95f967f4d7bbea3245cf4f558ec"
},
"v_tone1": {
"category": "people",
"moji": "✌ðŸ»",
+ "description": "victory hand tone 1",
"unicodeVersion": "8.0",
"digest": "76e358250d9ca519b60b8d7b6a32900700d784433dcc609e9442254a410f6e37"
},
"v_tone2": {
"category": "people",
"moji": "✌ðŸ¼",
+ "description": "victory hand tone 2",
"unicodeVersion": "8.0",
"digest": "4081b674be8416136022523fa9f29ec70a0f7e3aa05ca13152606609f3fd003c"
},
"v_tone3": {
"category": "people",
"moji": "✌ðŸ½",
+ "description": "victory hand tone 3",
"unicodeVersion": "8.0",
"digest": "b6afb3a4c78384280610b953592d378241c75597a82aa6d16c86a993f8d8f3b0"
},
"v_tone4": {
"category": "people",
"moji": "✌ðŸ¾",
+ "description": "victory hand tone 4",
"unicodeVersion": "8.0",
"digest": "7ddc3cdd0138da2c8d7f6d8257ffdb8801496043e8a2395f93b0663447ac7fce"
},
"v_tone5": {
"category": "people",
"moji": "✌ðŸ¿",
+ "description": "victory hand tone 5",
"unicodeVersion": "8.0",
"digest": "a85dc5c589f0d1cf32f8bfa5c82e5c11c40b35439636914686a2f06f7359f539"
},
"vertical_traffic_light": {
"category": "travel",
"moji": "🚦",
+ "description": "vertical traffic light",
"unicodeVersion": "6.0",
"digest": "8cfd49a8f96b15a8313ef855f2e234ea3fa58332e68896dea34760740de9f020"
},
"vhs": {
"category": "objects",
"moji": "📼",
+ "description": "videocassette",
"unicodeVersion": "6.0",
"digest": "3fb1acaf25805cf86f8d40ee2c17cf25da587b7ca93b931167ab43fce041eee8"
},
"vibration_mode": {
"category": "symbols",
"moji": "📳",
+ "description": "vibration mode",
"unicodeVersion": "6.0",
"digest": "c9a8899222f46fe51dd8cee3e59f77c48268f0b7cfae2bcb34a791213acb1755"
},
"video_camera": {
"category": "objects",
"moji": "📹",
+ "description": "video camera",
"unicodeVersion": "6.0",
"digest": "62e56f26c286a7964ef1021f0f23fcb4b38cdcfb5b5af569b472340c412c619a"
},
"video_game": {
"category": "activity",
"moji": "🎮",
+ "description": "video game",
"unicodeVersion": "6.0",
"digest": "2787e302aa9e6fd7e9dc382c9bc7f5fbf244ef4940e08a4f9e80d33324f3032e"
},
"violin": {
"category": "activity",
"moji": "🎻",
+ "description": "violin",
"unicodeVersion": "6.0",
"digest": "1e69d531ce2b5d5bf1dd9470187dbbe76f479d14428834b6a9e2bf5296dc0ec9"
},
"virgo": {
"category": "symbols",
"moji": "â™",
+ "description": "virgo",
"unicodeVersion": "1.1",
"digest": "0f75e9c228bc467fd0cec0f93f0e087c943bc5fb1d945fb0d4de53d07718388e"
},
"volcano": {
"category": "travel",
"moji": "🌋",
+ "description": "volcano",
"unicodeVersion": "6.0",
"digest": "41c92ef88ca533df342a0ebe59d2b676873bfa944c3988495b8a96060a9b8e16"
},
"volleyball": {
"category": "activity",
"moji": "ðŸ",
+ "description": "volleyball",
"unicodeVersion": "8.0",
"digest": "774a83357f7aee890b4d4383236f0a90946dbd7c86aaabadc5753dcc9b4c9d69"
},
"vs": {
"category": "symbols",
"moji": "🆚",
+ "description": "squared vs",
"unicodeVersion": "6.0",
"digest": "ac943e4c737459c2e1adbac8b71d3fdaebb704dbaf5713012e7a77beb09db1ef"
},
"vulcan": {
"category": "people",
"moji": "🖖",
+ "description": "raised hand with part between middle and ring fingers",
"unicodeVersion": "7.0",
"digest": "b4d409a0b019e7b06333cefd15ea46cb54aef5132d86e8ba361c1c3b911fe265"
},
"vulcan_tone1": {
"category": "people",
"moji": "🖖ðŸ»",
+ "description": "raised hand with part between middle and ring fingers tone 1",
"unicodeVersion": "8.0",
"digest": "cc6072c85031b5081995f98a57f09ab177168318f69a51f3acc63251760499a4"
},
"vulcan_tone2": {
"category": "people",
"moji": "🖖ðŸ¼",
+ "description": "raised hand with part between middle and ring fingers tone 2",
"unicodeVersion": "8.0",
"digest": "858bd5a1ac91dc4d7735f57ba4dd69d39138aa6dac1c80cfc05de30a59a5bc33"
},
"vulcan_tone3": {
"category": "people",
"moji": "🖖ðŸ½",
+ "description": "raised hand with part between middle and ring fingers tone 3",
"unicodeVersion": "8.0",
"digest": "2f74b6f3eab2a75063591b66f1c7350af0d23153e1427af91de20c48a5f4a54a"
},
"vulcan_tone4": {
"category": "people",
"moji": "🖖ðŸ¾",
+ "description": "raised hand with part between middle and ring fingers tone 4",
"unicodeVersion": "8.0",
"digest": "87cf8b87d3610f742857a9704b658462df32b4924d8f1ddba26f761e738c4e11"
},
"vulcan_tone5": {
"category": "people",
"moji": "🖖ðŸ¿",
+ "description": "raised hand with part between middle and ring fingers tone 5",
"unicodeVersion": "8.0",
"digest": "11e9ff62f2385edeb477dbf66c63734536531def5771daf80b66a3425ac71493"
},
"walking": {
"category": "people",
"moji": "🚶",
+ "description": "pedestrian",
"unicodeVersion": "6.0",
"digest": "ae77471fe1e8a734d11711cdb589f64347c35d6ee2fc10f6db16ac550c0557fa"
},
"walking_tone1": {
"category": "people",
"moji": "🚶ðŸ»",
+ "description": "pedestrian tone 1",
"unicodeVersion": "8.0",
"digest": "3de871c234e1340ccf95338df7babd94d175cfcb17a57b5a74d950e0a31f03b1"
},
"walking_tone2": {
"category": "people",
"moji": "🚶ðŸ¼",
+ "description": "pedestrian tone 2",
"unicodeVersion": "8.0",
"digest": "620eb7bfb753a331a5822b02bdaf08d8dde7b573efd210287a3d3dfdd84a40b9"
},
"walking_tone3": {
"category": "people",
"moji": "🚶ðŸ½",
+ "description": "pedestrian tone 3",
"unicodeVersion": "8.0",
"digest": "ff39545acc2256006128f8c186433c28052b8c9aaec46fe06f25cff02c71f6b8"
},
"walking_tone4": {
"category": "people",
"moji": "🚶ðŸ¾",
+ "description": "pedestrian tone 4",
"unicodeVersion": "8.0",
"digest": "a9499d142392977a9b9e54fb957952359e9bdffce7ec2f1e8320523d185fb066"
},
"walking_tone5": {
"category": "people",
"moji": "🚶ðŸ¿",
+ "description": "pedestrian tone 5",
"unicodeVersion": "8.0",
"digest": "b47a4c48ce40298f842f454fc1abccae70f69725d73ee2c80e4018f4c4065d7d"
},
"waning_crescent_moon": {
"category": "nature",
"moji": "🌘",
+ "description": "waning crescent moon symbol",
"unicodeVersion": "6.0",
"digest": "2ec7896eefcf821e0ea013556a17af59e997503662c07f080d0a84ab13ef4cf1"
},
"waning_gibbous_moon": {
"category": "nature",
"moji": "🌖",
+ "description": "waning gibbous moon symbol",
"unicodeVersion": "6.0",
"digest": "ce2f5aca8fccdacaaf174d10da4e493e853e4608cc4d159aa3081d108a8b58d5"
},
"warning": {
"category": "symbols",
"moji": "âš ",
+ "description": "warning sign",
"unicodeVersion": "4.0",
"digest": "745f1d203958f42bf37ecb5909cd0819934e300308ba0ff20964c8c203092f90"
},
"wastebasket": {
"category": "objects",
"moji": "🗑",
+ "description": "wastebasket",
"unicodeVersion": "7.0",
"digest": "221a1b6d9975051038d9d97e18a16556cdf4254a6bca4c29bf1c51f306c79f2a"
},
"watch": {
"category": "objects",
"moji": "⌚",
+ "description": "watch",
"unicodeVersion": "1.1",
"digest": "acc0c96751404a789b3085f10425cf34f942185215df459515d2439cde3efc6b"
},
"water_buffalo": {
"category": "nature",
"moji": "ðŸƒ",
+ "description": "water buffalo",
"unicodeVersion": "6.0",
"digest": "ba6a840d4f57f8f9f3e9f29b8a030faf02a3a3d912e3e31b067616b2ac48a3d1"
},
"water_polo": {
"category": "activity",
"moji": "🤽",
+ "description": "water polo",
"unicodeVersion": "9.0",
"digest": "fc77e1d2a84a9f4cf0cf19c1ea10cf137cf0940b9103a523121eda87677ad148"
},
"water_polo_tone1": {
"category": "activity",
"moji": "🤽ðŸ»",
+ "description": "water polo tone 1",
"unicodeVersion": "9.0",
"digest": "3be28384edd29ada8109f07720d601a9d5866ed63e6234efe9ee1a194ed5d0c5"
},
"water_polo_tone2": {
"category": "activity",
"moji": "🤽ðŸ¼",
+ "description": "water polo tone 2",
"unicodeVersion": "9.0",
"digest": "afcd3f28c6719f869ca79a6fd1ccade2ea976ade844fbc1081fc72865bcb652f"
},
"water_polo_tone3": {
"category": "activity",
"moji": "🤽ðŸ½",
+ "description": "water polo tone 3",
"unicodeVersion": "9.0",
"digest": "d19481c9b82d9413e99c2652e020fd763f2b54408dedaffec8dfe80973ded407"
},
"water_polo_tone4": {
"category": "activity",
"moji": "🤽ðŸ¾",
+ "description": "water polo tone 4",
"unicodeVersion": "9.0",
"digest": "375972d882b627e8d525e632e58b30346fc3e01858d7d08d62a9d3bf8132bbc7"
},
"water_polo_tone5": {
"category": "activity",
"moji": "🤽ðŸ¿",
+ "description": "water polo tone 5",
"unicodeVersion": "9.0",
"digest": "a8e1ced1c5382a8147a1d1801a133cada9a0e52e41de6272e56c3c1f426f6048"
},
"watermelon": {
"category": "food",
"moji": "ðŸ‰",
+ "description": "watermelon",
"unicodeVersion": "6.0",
"digest": "42a3821d2e4dd595c93f5db7a5c70b7af486b8f0ddd3b9d26bc4e743a88e699a"
},
"wave": {
"category": "people",
"moji": "👋",
+ "description": "waving hand sign",
"unicodeVersion": "6.0",
"digest": "cddbd764d471604446cbaca91f77f6c4119d1cfc2c856732ca0eaac4593cb736"
},
"wave_tone1": {
"category": "people",
"moji": "👋ðŸ»",
+ "description": "waving hand sign tone 1",
"unicodeVersion": "8.0",
"digest": "cf40797437ddf68ec0275f337e6aac4bed81e28da7636d56c9f817ddf8e2b30a"
},
"wave_tone2": {
"category": "people",
"moji": "👋ðŸ¼",
+ "description": "waving hand sign tone 2",
"unicodeVersion": "8.0",
"digest": "12c8a3e82c03ee35a734c642be482ba2d9d5948dacf91ec1fda243316dd4a0d0"
},
"wave_tone3": {
"category": "people",
"moji": "👋ðŸ½",
+ "description": "waving hand sign tone 3",
"unicodeVersion": "8.0",
"digest": "ebcaef43e21b475f76de811d4f4d1a67d9393973b57b03876e02164345a2ba4a"
},
"wave_tone4": {
"category": "people",
"moji": "👋ðŸ¾",
+ "description": "waving hand sign tone 4",
"unicodeVersion": "8.0",
"digest": "7df7b70cf76766836ba146c3d91b6104930c384450cf2688426e60c1c06a1fc8"
},
"wave_tone5": {
"category": "people",
"moji": "👋ðŸ¿",
+ "description": "waving hand sign tone 5",
"unicodeVersion": "8.0",
"digest": "8dfdba6aeff5d7dfd807467d431a137547726b34d021f1a5a0b74e155d270ea7"
},
"wavy_dash": {
"category": "symbols",
"moji": "〰",
+ "description": "wavy dash",
"unicodeVersion": "1.1",
"digest": "7b1968474f01d12fd09a1f2572282927138d9e9d6a3642de4bf68af80a8c3738"
},
"waxing_crescent_moon": {
"category": "nature",
"moji": "🌒",
+ "description": "waxing crescent moon symbol",
"unicodeVersion": "6.0",
"digest": "852d7e55a19074d061fa3aa80d6b1e7e87a9280bdf44d94bbdbbe6d59178b1be"
},
"waxing_gibbous_moon": {
"category": "nature",
"moji": "🌔",
+ "description": "waxing gibbous moon symbol",
"unicodeVersion": "6.0",
"digest": "a3a1c7cc72521a3f74929789a90e1c35d81ac86e21225c9f844d718d8940e3b3"
},
"wc": {
"category": "symbols",
"moji": "🚾",
+ "description": "water closet",
"unicodeVersion": "6.0",
"digest": "4b95d54e0b53e4b705277917653503b32d6a143c2eaf6c547bc8e01c2dc23659"
},
"weary": {
"category": "people",
"moji": "😩",
+ "description": "weary face",
"unicodeVersion": "6.0",
"digest": "3528f85540996cd5b562efe5421c495fc1bb414dc797bc20062783ae1b730847"
},
"wedding": {
"category": "travel",
"moji": "💒",
+ "description": "wedding",
"unicodeVersion": "6.0",
"digest": "980f3522cc4c19c3096e668032ea2cd19e7900cdc4b73bbb1c9b4c4d28dc78af"
},
"whale": {
"category": "nature",
"moji": "ðŸ³",
+ "description": "spouting whale",
"unicodeVersion": "6.0",
"digest": "6368fe4bc4a7f68aa2bd5386686a5f1b159feacbec16d59515f2b6e5d01adfbd"
},
"whale2": {
"category": "nature",
"moji": "ðŸ‹",
+ "description": "whale",
"unicodeVersion": "6.0",
"digest": "ccd3edf88167965f2abc18631ffb80e2532f728da35bc0c11144376685da18e8"
},
"wheel_of_dharma": {
"category": "symbols",
"moji": "☸",
+ "description": "wheel of dharma",
"unicodeVersion": "1.1",
"digest": "4a0a13fcd507b9621686c8090bf340aa8770c064e0e3eb576fbae1229000d6da"
},
"wheelchair": {
"category": "symbols",
"moji": "♿",
+ "description": "wheelchair symbol",
"unicodeVersion": "4.1",
"digest": "f5250f2b4b5b4ffe6a6f77d30865c3f5d7173fc91aee547869589b2a96da91c8"
},
"white_check_mark": {
"category": "symbols",
"moji": "✅",
+ "description": "white heavy check mark",
"unicodeVersion": "6.0",
"digest": "45eb17bde6e503f22c8579d6e4d507ad6557a15f9eaad14aa716ec9ba1540876"
},
"white_circle": {
"category": "symbols",
"moji": "⚪",
+ "description": "medium white circle",
"unicodeVersion": "4.1",
"digest": "2e7323fa4d1e3929e529d49210a0b82a043eae4f7c95128ec86b98c46fdb0e7c"
},
"white_flower": {
"category": "symbols",
"moji": "💮",
+ "description": "white flower",
"unicodeVersion": "6.0",
"digest": "ace093b310eeefdecf4a4bdaf4fbcbb568457b0191ac80778a466ac5f3f4025a"
},
"white_large_square": {
"category": "symbols",
"moji": "⬜",
+ "description": "white large square",
"unicodeVersion": "5.1",
"digest": "0db6957ee9ff7325b534b730fc05345a63d4ed9060f0f816807d0dcf004baa3e"
},
"white_medium_small_square": {
"category": "symbols",
"moji": "â—½",
+ "description": "white medium small square",
"unicodeVersion": "3.2",
"digest": "d79689981a7b38211c60a025a81e44fd39ac6ea4062e227cae3aab8f51572cd4"
},
"white_medium_square": {
"category": "symbols",
"moji": "â—»",
+ "description": "white medium square",
"unicodeVersion": "3.2",
"digest": "6c4ce26d3f69667219f29ea18b04f3e79373024426275f25936e09a683e9a4fc"
},
"white_small_square": {
"category": "symbols",
"moji": "â–«",
+ "description": "white small square",
"unicodeVersion": "1.1",
"digest": "ae0d35a6bbba4592b89b2f0f1f2d183efb2f93cf2a2136c0c195aab72f0bb1c8"
},
"white_square_button": {
"category": "symbols",
"moji": "🔳",
+ "description": "white square button",
"unicodeVersion": "6.0",
"digest": "797f3d9e44e88e940ffc118e52d0f709eec2ef14b13bdf873ad4b0c96cc0b042"
},
"white_sun_cloud": {
"category": "nature",
"moji": "🌥",
+ "description": "white sun behind cloud",
"unicodeVersion": "7.0",
"digest": "0e714038bb0a5b091dd4ad8829c5c72dece493e09da6d56ceadcd0b68e1c0fd5"
},
"white_sun_rain_cloud": {
"category": "nature",
"moji": "🌦",
+ "description": "white sun behind cloud with rain",
"unicodeVersion": "7.0",
"digest": "82fb2a91d43c7c511afed216e12f98e32aef4475e7f3c7ccc0f39732d2f7d5e5"
},
"white_sun_small_cloud": {
"category": "nature",
"moji": "🌤",
+ "description": "white sun with small cloud",
"unicodeVersion": "7.0",
"digest": "0a6164cdadf2413555b7ef47b95f823f5a010f36d2dacfb1a38335a0f59e9601"
},
"wilted_rose": {
"category": "nature",
"moji": "🥀",
+ "description": "wilted flower",
"unicodeVersion": "9.0",
"digest": "2c9e01ab9a61d057c71478b09ba7d82ae08f4a5a1c2212b7ad562b74f616677f"
},
"wind_blowing_face": {
"category": "nature",
"moji": "🌬",
+ "description": "wind blowing face",
"unicodeVersion": "7.0",
"digest": "e4f63149cbc8829118571f6a93487b96d26665fc15d17d578cca4e5c752cd54f"
},
"wind_chime": {
"category": "objects",
"moji": "ðŸŽ",
+ "description": "wind chime",
"unicodeVersion": "6.0",
"digest": "1b1b212fbd74a9edc62aee7ffab9bcf91d3a9f69bffb2be4b7fd527914c14ced"
},
"wine_glass": {
"category": "food",
"moji": "ðŸ·",
+ "description": "wine glass",
"unicodeVersion": "6.0",
"digest": "d99107d6809386bc5e219aa58ee4930d27b7c3a6d2b10deb9f523df369f766d1"
},
"wink": {
"category": "people",
"moji": "😉",
+ "description": "winking face",
"unicodeVersion": "6.0",
"digest": "56e29994a47335a901d0c98fa141d26faae8f647a860517bd3615fa980921885"
},
"wolf": {
"category": "nature",
"moji": "ðŸº",
+ "description": "wolf face",
"unicodeVersion": "6.0",
"digest": "4a983f5ec8ec0872fcde7890e17605b1229064e5e194b6fca1c4259068d1caed"
},
"woman": {
"category": "people",
"moji": "👩",
+ "description": "woman",
"unicodeVersion": "6.0",
"digest": "a06a22a48eeb3aeb885321358fe234e97797ed33be17f52d232ce2830cfbcd97"
},
"woman_tone1": {
"category": "people",
"moji": "👩ðŸ»",
+ "description": "woman tone 1",
"unicodeVersion": "8.0",
"digest": "c2e4b135c1dac6a0b002569a6ccd9d098f6cb18481c68b5d9115e11241a0978d"
},
"woman_tone2": {
"category": "people",
"moji": "👩ðŸ¼",
+ "description": "woman tone 2",
"unicodeVersion": "8.0",
"digest": "4848e650051214a53c4cd9f6d3d94158f77f65ecb34f891789de34ee0a713006"
},
"woman_tone3": {
"category": "people",
"moji": "👩ðŸ½",
+ "description": "woman tone 3",
"unicodeVersion": "8.0",
"digest": "b6f751ad47da019cdfb9d6d78f9610adb92120abf204c30df79a9150b57dbdee"
},
"woman_tone4": {
"category": "people",
"moji": "👩ðŸ¾",
+ "description": "woman tone 4",
"unicodeVersion": "8.0",
"digest": "fd27d3a669dc34313fbfe518df7dc2ded3ade5dde695f8d773afe87bf8a8b0d4"
},
"woman_tone5": {
"category": "people",
"moji": "👩ðŸ¿",
+ "description": "woman tone 5",
"unicodeVersion": "8.0",
"digest": "9ae9b14dfff40fa60a565d89479727feeba4fd6ffea9acb353a81b14aba751d4"
},
"womans_clothes": {
"category": "people",
"moji": "👚",
+ "description": "womans clothes",
"unicodeVersion": "6.0",
"digest": "d12a27810780fe5cd8118ed4587e0c4e70dbe9bcd014c6866fe6a8c9c7c55698"
},
"womans_hat": {
"category": "people",
"moji": "👒",
+ "description": "womans hat",
"unicodeVersion": "6.0",
"digest": "52a0255b3483085bd125d39b74516ab6a81003964f44995c2fac821e7ff93086"
},
"womens": {
"category": "symbols",
"moji": "🚺",
+ "description": "womens symbol",
"unicodeVersion": "6.0",
"digest": "7e38964006f8b28dfa2b3e9b2b16553bb50c18a63455f556b0bff35ee172137e"
},
"worried": {
"category": "people",
"moji": "😟",
+ "description": "worried face",
"unicodeVersion": "6.1",
"digest": "5a073985e1344bc34201ef94a491f7f2b946f5828c9fdbc57eeb2dcd87ac3a6b"
},
"wrench": {
"category": "objects",
"moji": "🔧",
+ "description": "wrench",
"unicodeVersion": "6.0",
"digest": "81aae53bc892035b905bf3ec5b442a8ecc95027c5fa9eb51b7c3e7d8fad3f3f4"
},
"wrestlers": {
"category": "activity",
"moji": "🤼",
+ "description": "wrestlers",
"unicodeVersion": "9.0",
"digest": "9be983f3f9438f3ab8f6b643a958371d1e710c6d78e728f3465141811f05c2d5"
},
"wrestlers_tone1": {
"category": "activity",
"moji": "🤼ðŸ»",
+ "description": "wrestlers tone 1",
"unicodeVersion": "9.0",
"digest": "60461f83bfc93ce59dd027eab4782b7f206a7b142719fa72f301e047dc83a5d9"
},
"wrestlers_tone2": {
"category": "activity",
"moji": "🤼ðŸ¼",
+ "description": "wrestlers tone 2",
"unicodeVersion": "9.0",
"digest": "67ad93c86e6c58d552c18e7a0105cc81fd9bb0474da51f788eba2e4c14b4a636"
},
"wrestlers_tone3": {
"category": "activity",
"moji": "🤼ðŸ½",
+ "description": "wrestlers tone 3",
"unicodeVersion": "9.0",
"digest": "6bfd06c4435cabf2def153912040e05bf8db424fa383148ddda6d0ce8a8a3349"
},
"wrestlers_tone4": {
"category": "activity",
"moji": "🤼ðŸ¾",
+ "description": "wrestlers tone 4",
"unicodeVersion": "9.0",
"digest": "597312678834c4d288c238482879856d5eba4620deb1eaef495f428e2ba5f2a5"
},
"wrestlers_tone5": {
"category": "activity",
"moji": "🤼ðŸ¿",
+ "description": "wrestlers tone 5",
"unicodeVersion": "9.0",
"digest": "d6aebdf1e44fd825b9a5b3716aefbc53f4b4dbb73cb2a628c0f2994ebfd34614"
},
"writing_hand": {
"category": "people",
"moji": "âœ",
+ "description": "writing hand",
"unicodeVersion": "1.1",
"digest": "110517ae4da5587e8b0662881658e27da4120bfacec54734fd6657831d4d782f"
},
"writing_hand_tone1": {
"category": "people",
"moji": "âœðŸ»",
+ "description": "writing hand tone 1",
"unicodeVersion": "8.0",
"digest": "2c7e2108e1990490b681343c1b01b4183d4f18fbdef792f113b2f87595e0dad0"
},
"writing_hand_tone2": {
"category": "people",
"moji": "âœðŸ¼",
+ "description": "writing hand tone 2",
"unicodeVersion": "8.0",
"digest": "87ec8d44f472d301adbcbd50d8c852b609e46584057f59cc1527401db363c1bf"
},
"writing_hand_tone3": {
"category": "people",
"moji": "âœðŸ½",
+ "description": "writing hand tone 3",
"unicodeVersion": "8.0",
"digest": "4a48ddef91f7264e8fa9cca223554db22b3a2e3153e94b88d146644ea6dd661e"
},
"writing_hand_tone4": {
"category": "people",
"moji": "âœðŸ¾",
+ "description": "writing hand tone 4",
"unicodeVersion": "8.0",
"digest": "e5254564a1f91e42ee59f359d8cd26f52abdc04dca8f3b37cb2f140cb7f71390"
},
"writing_hand_tone5": {
"category": "people",
"moji": "âœðŸ¿",
+ "description": "writing hand tone 5",
"unicodeVersion": "8.0",
"digest": "61299bf86d83d323ca3e6052c535ae66c6f7b3d9866a37db0464223b8bc28523"
},
"x": {
"category": "symbols",
"moji": "âŒ",
+ "description": "cross mark",
"unicodeVersion": "6.0",
"digest": "3e5a7918e31ddefdf1ce73972365e2f0bfd2917d6a450c1a278c108349c9425d"
},
"yellow_heart": {
"category": "symbols",
"moji": "💛",
+ "description": "yellow heart",
"unicodeVersion": "6.0",
"digest": "a1098f2f04c29754cc9974324508386787d4d803b57cf691d42de414cb2679d6"
},
"yen": {
"category": "objects",
"moji": "💴",
+ "description": "banknote with yen sign",
"unicodeVersion": "6.0",
"digest": "944daaeb3f6369c807c0e63b106cee1360040f7800a70c0d942a992f25a55da7"
},
"yin_yang": {
"category": "symbols",
"moji": "☯",
+ "description": "yin yang",
"unicodeVersion": "1.1",
"digest": "5ee8d13dacf41306a09237bfcff6abeef110331b40eb7d6e80600628c1327545"
},
"yum": {
"category": "people",
"moji": "😋",
+ "description": "face savouring delicious food",
"unicodeVersion": "6.0",
"digest": "31a89088c21bd7a74a3a26d731a907d1bc49436300a9f9c55248703cf7ef44c7"
},
"zap": {
"category": "nature",
"moji": "âš¡",
+ "description": "high voltage sign",
"unicodeVersion": "4.0",
"digest": "9f8144ae6f866129aea41bbf694b0c858ef9352a139969e57cd8db73385f52c3"
},
"zero": {
"category": "symbols",
"moji": "0ï¸âƒ£",
+ "description": "keycap digit zero",
"unicodeVersion": "3.0",
"digest": "1b27b5c904defadbdd28ace67a6be5c277ff043297db7cd9f672bbf84e37fa1a"
},
"zipper_mouth": {
"category": "people",
"moji": "ðŸ¤",
+ "description": "zipper-mouth face",
"unicodeVersion": "8.0",
"digest": "81bee5aa1202dfd5a4c7badb71ec0e44b8f75c2cbef94e6fd35c593d8770ae43"
},
"zzz": {
"category": "people",
"moji": "💤",
+ "description": "sleeping symbol",
"unicodeVersion": "6.0",
"digest": "b3313d0c44a59fa9d4ce9f7eb4d07ff71dfc8bb01798154250f27cdcf3c693b5"
}
diff --git a/lib/after_commit_queue.rb b/lib/after_commit_queue.rb
new file mode 100644
index 00000000000..b67575a3ac2
--- /dev/null
+++ b/lib/after_commit_queue.rb
@@ -0,0 +1,30 @@
+module AfterCommitQueue
+ extend ActiveSupport::Concern
+
+ included do
+ after_commit :_run_after_commit_queue
+ after_rollback :_clear_after_commit_queue
+ end
+
+ def run_after_commit(method = nil, &block)
+ _after_commit_queue << proc { self.send(method) } if method
+ _after_commit_queue << block if block
+ true
+ end
+
+ protected
+
+ def _run_after_commit_queue
+ while action = _after_commit_queue.pop
+ self.instance_eval(&action)
+ end
+ end
+
+ def _after_commit_queue
+ @after_commit_queue ||= []
+ end
+
+ def _clear_after_commit_queue
+ _after_commit_queue.clear
+ end
+end
diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb
index a5c9f0b509c..cdacf9839e5 100644
--- a/lib/api/access_requests.rb
+++ b/lib/api/access_requests.rb
@@ -68,8 +68,9 @@ module API
delete ":id/access_requests/:user_id" do
source = find_source(source_type, params[:id])
- ::Members::DestroyService.new(source, current_user, params).
- execute(:requesters)
+ status 204
+ ::Members::DestroyService.new(source, current_user, params)
+ .execute(:requesters)
end
end
end
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 1bf20f76ad6..94df543853b 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -2,10 +2,14 @@ module API
class API < Grape::API
include APIGuard
+ allow_access_with_scope :api
+ prefix :api
+
version %w(v3 v4), using: :path
version 'v3', using: :path do
helpers ::API::V3::Helpers
+ helpers ::API::Helpers::CommonHelpers
mount ::API::V3::AwardEmoji
mount ::API::V3::Boards
@@ -43,7 +47,10 @@ module API
mount ::API::V3::Variables
end
- before { allow_access_with_scope :api }
+ before { header['X-Frame-Options'] = 'SAMEORIGIN' }
+
+ # The locale is set to the current user's locale when `current_user` is loaded
+ after { Gitlab::I18n.use_default_locale }
rescue_from Gitlab::Access::AccessDeniedError do
rack_response({ 'message' => '403 Forbidden' }.to_json, 403)
@@ -77,6 +84,10 @@ module API
# Ensure the namespace is right, otherwise we might load Grape::API::Helpers
helpers ::SentryHelper
helpers ::API::Helpers
+ helpers ::API::Helpers::CommonHelpers
+
+ NO_SLASH_URL_PART_REGEX = %r{[^/]+}
+ PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
# Keep in alphabetical order
mount ::API::AccessRequests
@@ -84,11 +95,14 @@ module API
mount ::API::Boards
mount ::API::Branches
mount ::API::BroadcastMessages
+ mount ::API::CircuitBreakers
mount ::API::Commits
mount ::API::CommitStatuses
mount ::API::DeployKeys
mount ::API::Deployments
mount ::API::Environments
+ mount ::API::Events
+ mount ::API::Features
mount ::API::Files
mount ::API::Groups
mount ::API::Internal
@@ -100,14 +114,17 @@ module API
mount ::API::Members
mount ::API::MergeRequestDiffs
mount ::API::MergeRequests
- mount ::API::Milestones
+ mount ::API::ProjectMilestones
+ mount ::API::GroupMilestones
mount ::API::Namespaces
mount ::API::Notes
mount ::API::NotificationSettings
mount ::API::Pipelines
+ mount ::API::PipelineSchedules
mount ::API::ProjectHooks
mount ::API::Projects
mount ::API::ProjectSnippets
+ mount ::API::ProtectedBranches
mount ::API::Repositories
mount ::API::Runner
mount ::API::Runners
@@ -124,6 +141,7 @@ module API
mount ::API::Triggers
mount ::API::Users
mount ::API::Variables
+ mount ::API::GroupVariables
mount ::API::Version
route :any, '*path' do
diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb
index 9fcf04efa38..c4c0fdda665 100644
--- a/lib/api/api_guard.rb
+++ b/lib/api/api_guard.rb
@@ -23,6 +23,23 @@ module API
install_error_responders(base)
end
+ class_methods do
+ # Set the authorization scope(s) allowed for an API endpoint.
+ #
+ # A call to this method maps the given scope(s) to the current API
+ # endpoint class. If this method is called multiple times on the same class,
+ # the scopes are all aggregated.
+ def allow_access_with_scope(scopes, options = {})
+ Array(scopes).each do |scope|
+ allowed_scopes << Scope.new(scope, options)
+ end
+ end
+
+ def allowed_scopes
+ @scopes ||= []
+ end
+ end
+
# Helper Methods for Grape Endpoint
module HelperMethods
# Invokes the doorkeeper guard.
@@ -47,7 +64,7 @@ module API
access_token = find_access_token
return nil unless access_token
- case AccessTokenValidationService.new(access_token).validate(scopes: scopes)
+ case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
when AccessTokenValidationService::INSUFFICIENT_SCOPE
raise InsufficientScopeError.new(scopes)
@@ -74,18 +91,6 @@ module API
@current_user
end
- # Set the authorization scope(s) allowed for the current request.
- #
- # Note: A call to this method adds to any previous scopes in place. This is done because
- # `Grape` callbacks run from the outside-in: the top-level callback (API::API) runs first, then
- # the next-level callback (API::API::Users, for example) runs. All these scopes are valid for the
- # given endpoint (GET `/api/users` is accessible by the `api` and `read_user` scopes), and so they
- # need to be stored.
- def allow_access_with_scope(*scopes)
- @scopes ||= []
- @scopes.concat(scopes.map(&:to_s))
- end
-
private
def find_user_by_authentication_token(token_string)
@@ -96,7 +101,7 @@ module API
access_token = PersonalAccessToken.active.find_by_token(token_string)
return unless access_token
- if AccessTokenValidationService.new(access_token).include_any_scope?(scopes)
+ if AccessTokenValidationService.new(access_token, request: request).include_any_scope?(scopes)
User.find(access_token.user_id)
end
end
@@ -117,7 +122,7 @@ module API
error_classes = [MissingTokenError, TokenNotFoundError,
ExpiredError, RevokedError, InsufficientScopeError]
- base.send :rescue_from, *error_classes, oauth2_bearer_token_error_handler
+ base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend
end
def oauth2_bearer_token_error_handler
diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb
index 56f19f89642..5a028fc9d0b 100644
--- a/lib/api/award_emoji.rb
+++ b/lib/api/award_emoji.rb
@@ -88,6 +88,7 @@ module API
unauthorized! unless award.user == current_user || current_user.admin?
+ status 204
award.destroy
end
end
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index f35084a582a..d3dbf941298 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -4,19 +4,21 @@ module API
class Branches < Grape::API
include PaginationParams
+ BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(branch: API::NO_SLASH_URL_PART_REGEX)
+
before { authorize! :download_code, user_project }
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a project repository branches' do
success Entities::RepoBranch
end
params do
use :pagination
end
- get ":id/repository/branches" do
+ get ':id/repository/branches' do
branches = ::Kaminari.paginate_array(user_project.repository.branches.sort_by(&:name))
present paginate(branches), with: Entities::RepoBranch, project: user_project
@@ -28,13 +30,14 @@ module API
params do
requires :branch, type: String, desc: 'The name of the branch'
end
- get ':id/repository/branches/:branch', requirements: { branch: /.+/ } do
+ get ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
branch = user_project.repository.find_branch(params[:branch])
not_found!("Branch") unless branch
present branch, with: Entities::RepoBranch, project: user_project
end
+ # Note: This API will be deprecated in favor of the protected branches API.
# Note: The internal data model moved from `developers_can_{merge,push}` to `allowed_to_{merge,push}`
# in `gitlab-org/gitlab-ce!5081`. The API interface has not been changed (to maintain compatibility),
# but it works with the changed data model to infer `developers_can_merge` and `developers_can_push`.
@@ -46,7 +49,7 @@ module API
optional :developers_can_push, type: Boolean, desc: 'Flag if developers can push to that branch'
optional :developers_can_merge, type: Boolean, desc: 'Flag if developers can merge to that branch'
end
- put ':id/repository/branches/:branch/protect', requirements: { branch: /.+/ } do
+ put ':id/repository/branches/:branch/protect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_admin_project
branch = user_project.repository.find_branch(params[:branch])
@@ -63,9 +66,9 @@ module API
service_args = [user_project, current_user, protected_branch_params]
protected_branch = if protected_branch
- ProtectedBranches::ApiUpdateService.new(*service_args).execute(protected_branch)
+ ::ProtectedBranches::ApiUpdateService.new(*service_args).execute(protected_branch)
else
- ProtectedBranches::ApiCreateService.new(*service_args).execute
+ ::ProtectedBranches::ApiCreateService.new(*service_args).execute
end
if protected_branch.valid?
@@ -75,13 +78,14 @@ module API
end
end
+ # Note: This API will be deprecated in favor of the protected branches API.
desc 'Unprotect a single branch' do
success Entities::RepoBranch
end
params do
requires :branch, type: String, desc: 'The name of the branch'
end
- put ':id/repository/branches/:branch/unprotect', requirements: { branch: /.+/ } do
+ put ':id/repository/branches/:branch/unprotect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_admin_project
branch = user_project.repository.find_branch(params[:branch])
@@ -99,11 +103,11 @@ module API
requires :branch, type: String, desc: 'The name of the branch'
requires :ref, type: String, desc: 'Create branch from commit sha or existing branch'
end
- post ":id/repository/branches" do
+ post ':id/repository/branches' do
authorize_push_project
- result = CreateBranchService.new(user_project, current_user).
- execute(params[:branch], params[:ref])
+ result = CreateBranchService.new(user_project, current_user)
+ .execute(params[:branch], params[:ref])
if result[:status] == :success
present result[:branch],
@@ -118,11 +122,11 @@ module API
params do
requires :branch, type: String, desc: 'The name of the branch'
end
- delete ":id/repository/branches/:branch", requirements: { branch: /.+/ } do
+ delete ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
authorize_push_project
- result = DeleteBranchService.new(user_project, current_user).
- execute(params[:branch])
+ result = DeleteBranchService.new(user_project, current_user)
+ .execute(params[:branch])
if result[:status] != :success
render_api_error!(result[:message], result[:return_code])
@@ -130,7 +134,7 @@ module API
end
desc 'Delete all merged branches'
- delete ":id/repository/merged_branches" do
+ delete ':id/repository/merged_branches' do
DeleteMergedBranchesService.new(user_project, current_user).async_execute
accepted!
diff --git a/lib/api/broadcast_messages.rb b/lib/api/broadcast_messages.rb
index 395c401203c..9980aec4752 100644
--- a/lib/api/broadcast_messages.rb
+++ b/lib/api/broadcast_messages.rb
@@ -91,6 +91,7 @@ module API
delete ':id' do
message = find_message
+ status 204
message.destroy
end
end
diff --git a/lib/api/circuit_breakers.rb b/lib/api/circuit_breakers.rb
new file mode 100644
index 00000000000..118883f5ea5
--- /dev/null
+++ b/lib/api/circuit_breakers.rb
@@ -0,0 +1,50 @@
+module API
+ class CircuitBreakers < Grape::API
+ before { authenticated_as_admin! }
+
+ resource :circuit_breakers do
+ params do
+ requires :type,
+ type: String,
+ desc: "The type of circuitbreaker",
+ values: ['repository_storage']
+ end
+ resource ':type' do
+ namespace '', requirements: { type: 'repository_storage' } do
+ helpers do
+ def failing_storage_health
+ @failing_storage_health ||= Gitlab::Git::Storage::Health.for_failing_storages
+ end
+
+ def storage_health
+ @failing_storage_health ||= Gitlab::Git::Storage::Health.for_all_storages
+ end
+ end
+
+ desc 'Get all failing git storages' do
+ detail 'This feature was introduced in GitLab 9.5'
+ success Entities::RepositoryStorageHealth
+ end
+ get do
+ present storage_health, with: Entities::RepositoryStorageHealth
+ end
+
+ desc 'Get all failing git storages' do
+ detail 'This feature was introduced in GitLab 9.5'
+ success Entities::RepositoryStorageHealth
+ end
+ get 'failing' do
+ present failing_storage_health, with: Entities::RepositoryStorageHealth
+ end
+
+ desc 'Reset all storage failures and open circuitbreaker' do
+ detail 'This feature was introduced in GitLab 9.5'
+ end
+ delete do
+ Gitlab::Git::Storage::CircuitBreaker.reset_all!
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb
index 827a38d33da..485b680cd5f 100644
--- a/lib/api/commit_statuses.rb
+++ b/lib/api/commit_statuses.rb
@@ -68,7 +68,14 @@ module API
name = params[:name] || params[:context] || 'default'
- pipeline = @project.ensure_pipeline(ref, commit.sha, current_user)
+ pipeline = @project.pipeline_for(ref, commit.sha)
+ unless pipeline
+ pipeline = @project.pipelines.create!(
+ source: :external,
+ sha: commit.sha,
+ ref: ref,
+ user: current_user)
+ end
status = GenericCommitStatus.running_or_pending.find_or_initialize_by(
project: @project,
@@ -101,6 +108,9 @@ module API
render_api_error!('invalid state', 400)
end
+ MergeRequest.where(source_project: @project, source_branch: ref)
+ .update_all(head_pipeline_id: pipeline) if pipeline.latest?
+
present status, with: Entities::CommitStatus
rescue StateMachines::InvalidTransition => e
render_api_error!(e.message, 400)
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 621b9dcecd9..ea78737288a 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -4,13 +4,14 @@ module API
class Commits < Grape::API
include PaginationParams
- before { authenticate! }
+ COMMIT_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: API::NO_SLASH_URL_PART_REGEX)
+
before { authorize! :download_code, user_project }
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a project repository commits' do
success Entities::RepoCommit
end
@@ -21,7 +22,7 @@ module API
optional :path, type: String, desc: 'The file path'
use :pagination
end
- get ":id/repository/commits" do
+ get ':id/repository/commits' do
path = params[:path]
before = params[:until]
after = params[:since]
@@ -53,21 +54,24 @@ module API
detail 'This feature was introduced in GitLab 8.13'
end
params do
- requires :branch, type: String, desc: 'The name of branch'
+ requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.'
requires :commit_message, type: String, desc: 'Commit message'
requires :actions, type: Array[Hash], desc: 'Actions to perform in commit'
+ optional :start_branch, type: String, desc: 'Name of the branch to start the new commit from'
optional :author_email, type: String, desc: 'Author email for commit'
optional :author_name, type: String, desc: 'Author name for commit'
end
- post ":id/repository/commits" do
+ post ':id/repository/commits' do
authorize! :push_code, user_project
- attrs = declared_params.merge(start_branch: declared_params[:branch], branch_name: declared_params[:branch])
+ attrs = declared_params
+ attrs[:branch_name] = attrs.delete(:branch)
+ attrs[:start_branch] ||= attrs[:branch_name]
result = ::Files::MultiService.new(user_project, current_user, attrs).execute
if result[:status] == :success
- commit_detail = user_project.repository.commits(result[:result], limit: 1).first
+ commit_detail = user_project.repository.commit(result[:result])
present commit_detail, with: Entities::RepoCommitDetail
else
render_api_error!(result[:message], 400)
@@ -76,42 +80,42 @@ module API
desc 'Get a specific commit of a project' do
success Entities::RepoCommitDetail
- failure [[404, 'Not Found']]
+ failure [[404, 'Commit Not Found']]
end
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha" do
+ get ':id/repository/commits/:sha', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
- not_found! "Commit" unless commit
+ not_found! 'Commit' unless commit
present commit, with: Entities::RepoCommitDetail
end
desc 'Get the diff for a specific commit of a project' do
- failure [[404, 'Not Found']]
+ failure [[404, 'Commit Not Found']]
end
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha/diff" do
+ get ':id/repository/commits/:sha/diff', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
- not_found! "Commit" unless commit
+ not_found! 'Commit' unless commit
commit.raw_diffs.to_a
end
desc "Get a commit's comments" do
success Entities::CommitNote
- failure [[404, 'Not Found']]
+ failure [[404, 'Commit Not Found']]
end
params do
use :pagination
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/comments' do
+ get ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -125,10 +129,10 @@ module API
success Entities::RepoCommit
end
params do
- requires :sha, type: String, desc: 'A commit sha to be cherry picked'
+ requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag to be cherry picked'
requires :branch, type: String, desc: 'The name of the branch'
end
- post ':id/repository/commits/:sha/cherry_pick' do
+ post ':id/repository/commits/:sha/cherry_pick', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
commit = user_project.commit(params[:sha])
@@ -157,7 +161,7 @@ module API
success Entities::CommitNote
end
params do
- requires :sha, type: String, regexp: /\A\h{6,40}\z/, desc: "The commit's SHA"
+ requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag on which to post a comment'
requires :note, type: String, desc: 'The text of the comment'
optional :path, type: String, desc: 'The file path'
given :path do
@@ -165,7 +169,7 @@ module API
requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
end
end
- post ':id/repository/commits/:sha/comments' do
+ post ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -176,7 +180,7 @@ module API
}
if params[:path]
- commit.raw_diffs(all_diffs: true).each do |diff|
+ commit.raw_diffs(limits: false).each do |diff|
next unless diff.new_path == params[:path]
lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line)
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index 8a54f7f3f05..42e7c1486b0 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -76,6 +76,27 @@ module API
end
end
+ desc 'Update an existing deploy key for a project' do
+ success Entities::SSHKey
+ end
+ params do
+ requires :key_id, type: Integer, desc: 'The ID of the deploy key'
+ optional :title, type: String, desc: 'The name of the deploy key'
+ optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository"
+ at_least_one_of :title, :can_push
+ end
+ put ":id/deploy_keys/:key_id" do
+ key = DeployKey.find(params.delete(:key_id))
+
+ authorize!(:update_deploy_key, key)
+
+ if key.update_attributes(declared_params(include_missing: false))
+ present key, with: Entities::SSHKey
+ else
+ render_validation_error!(key)
+ end
+ end
+
desc 'Enable a deploy key for a project' do
detail 'This feature was added in GitLab 8.11'
success Entities::SSHKey
@@ -104,6 +125,7 @@ module API
key = user_project.deploy_keys_projects.find_by(deploy_key_id: params[:key_id])
not_found!('Deploy Key') unless key
+ status 204
key.destroy
end
end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 6d6ccefe877..e8dd61e493f 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -5,7 +5,10 @@ module API
end
class UserBasic < UserSafe
- expose :id, :state, :avatar_url
+ expose :id, :state
+ expose :avatar_url do |user, options|
+ user.avatar_url(only_path: false)
+ end
expose :web_url do |user, options|
Gitlab::Routing.url_helpers.user_url(user)
@@ -40,31 +43,27 @@ module API
expose :external
end
- class UserWithPrivateDetails < UserPublic
- expose :private_token
+ class UserWithAdmin < UserPublic
expose :admin?, as: :is_admin
end
+ class UserWithPrivateDetails < UserWithAdmin
+ expose :private_token
+ end
+
class Email < Grape::Entity
expose :id, :email
end
class Hook < Grape::Entity
- expose :id, :url, :created_at, :push_events, :tag_push_events
+ expose :id, :url, :created_at, :push_events, :tag_push_events, :repository_update_events
expose :enable_ssl_verification
end
class ProjectHook < Hook
expose :project_id, :issues_events, :merge_requests_events
expose :note_events, :pipeline_events, :wiki_page_events
- expose :build_events, as: :job_events
- end
-
- class BasicProjectDetails < Grape::Entity
- expose :id
- expose :http_url_to_repo, :web_url
- expose :name, :name_with_namespace
- expose :path, :path_with_namespace
+ expose :job_events
end
class SharedGroup < Grape::Entity
@@ -75,13 +74,51 @@ module API
expose :group_access, as: :group_access_level
end
- class Project < Grape::Entity
+ class BasicProjectDetails < Grape::Entity
expose :id, :description, :default_branch, :tag_list
- expose :archived?, as: :archived
- expose :visibility, :ssh_url_to_repo, :http_url_to_repo, :web_url
- expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group }
+ expose :ssh_url_to_repo, :http_url_to_repo, :web_url
expose :name, :name_with_namespace
expose :path, :path_with_namespace
+ expose :star_count, :forks_count
+ expose :created_at, :last_activity_at
+ end
+
+ class Project < BasicProjectDetails
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ expose :_links do
+ expose :self do |project|
+ expose_url(api_v4_projects_path(id: project.id))
+ end
+
+ expose :issues, if: -> (*args) { issues_available?(*args) } do |project|
+ expose_url(api_v4_projects_issues_path(id: project.id))
+ end
+
+ expose :merge_requests, if: -> (*args) { mrs_available?(*args) } do |project|
+ expose_url(api_v4_projects_merge_requests_path(id: project.id))
+ end
+
+ expose :repo_branches do |project|
+ expose_url(api_v4_projects_repository_branches_path(id: project.id))
+ end
+
+ expose :labels do |project|
+ expose_url(api_v4_projects_labels_path(id: project.id))
+ end
+
+ expose :events do |project|
+ expose_url(api_v4_projects_events_path(id: project.id))
+ end
+
+ expose :members do |project|
+ expose_url(api_v4_projects_members_path(id: project.id))
+ end
+ end
+
+ expose :archived?, as: :archived
+ expose :visibility
+ expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group }
expose :container_registry_enabled
# Expose old field names with the new permissions methods to keep API compatible
@@ -91,23 +128,27 @@ module API
expose(:jobs_enabled) { |project, options| project.feature_available?(:builds, options[:current_user]) }
expose(:snippets_enabled) { |project, options| project.feature_available?(:snippets, options[:current_user]) }
- expose :created_at, :last_activity_at
expose :shared_runners_enabled
expose :lfs_enabled?, as: :lfs_enabled
expose :creator_id
expose :namespace, using: 'API::Entities::Namespace'
- expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? }
- expose :avatar_url
- expose :star_count, :forks_count
- expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? }
+ expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda { |project, options| project.forked? }
+ expose :import_status
+ expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] }
+ expose :avatar_url do |user, options|
+ user.avatar_url(only_path: false)
+ end
+ expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) }
expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] }
expose :public_builds, as: :public_jobs
+ expose :ci_config_path
expose :shared_with_groups do |project, options|
SharedGroup.represent(project.project_group_links.all, options)
end
expose :only_allow_merge_if_pipeline_succeeds
expose :request_access_enabled
expose :only_allow_merge_if_all_discussions_are_resolved
+ expose :printing_merge_request_link_enabled
expose :statistics, using: 'API::Entities::ProjectStatistics', if: :statistics
end
@@ -141,11 +182,16 @@ module API
class Group < Grape::Entity
expose :id, :name, :path, :description, :visibility
expose :lfs_enabled?, as: :lfs_enabled
- expose :avatar_url
+ expose :avatar_url do |user, options|
+ user.avatar_url(only_path: false)
+ end
expose :web_url
expose :request_access_enabled
expose :full_name, :full_path
- expose :parent_id
+
+ if ::Group.supports_nested_groups?
+ expose :parent_id
+ end
expose :statistics, if: :statistics do
with_options format_with: -> (value) { value.to_i } do
@@ -191,7 +237,7 @@ module API
end
expose :protected do |repo_branch, options|
- ProtectedBranch.protected?(options[:project], repo_branch.name)
+ ::ProtectedBranch.protected?(options[:project], repo_branch.name)
end
expose :developers_can_push do |repo_branch, options|
@@ -214,7 +260,7 @@ module API
end
class ProjectSnippet < Grape::Entity
- expose :id, :title, :file_name
+ expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
@@ -224,7 +270,7 @@ module API
end
class PersonalSnippet < Grape::Entity
- expose :id, :title, :file_name
+ expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
@@ -238,17 +284,37 @@ module API
class ProjectEntity < Grape::Entity
expose :id, :iid
- expose(:project_id) { |entity| entity.project.id }
+ expose(:project_id) { |entity| entity&.project.try(:id) }
expose :title, :description
expose :state, :created_at, :updated_at
end
class RepoDiff < Grape::Entity
expose :old_path, :new_path, :a_mode, :b_mode, :diff
- expose :new_file, :renamed_file, :deleted_file
+ expose :new_file?, as: :new_file
+ expose :renamed_file?, as: :renamed_file
+ expose :deleted_file?, as: :deleted_file
+ end
+
+ class ProtectedRefAccess < Grape::Entity
+ expose :access_level
+ expose :access_level_description do |protected_ref_access|
+ protected_ref_access.humanize
+ end
end
- class Milestone < ProjectEntity
+ class ProtectedBranch < Grape::Entity
+ expose :name
+ expose :push_access_levels, using: Entities::ProtectedRefAccess
+ expose :merge_access_levels, using: Entities::ProtectedRefAccess
+ end
+
+ class Milestone < Grape::Entity
+ expose :id, :iid
+ expose :project_id, if: -> (entity, options) { entity&.project_id }
+ expose :group_id, if: -> (entity, options) { entity&.group_id }
+ expose :title, :description
+ expose :state, :created_at, :updated_at
expose :due_date
expose :start_date
end
@@ -256,7 +322,11 @@ module API
class IssueBasic < ProjectEntity
expose :label_names, as: :labels
expose :milestone, using: Entities::Milestone
- expose :assignee, :author, using: Entities::UserBasic
+ expose :assignees, :author, using: Entities::UserBasic
+
+ expose :assignee, using: ::API::Entities::UserBasic do |issue, options|
+ issue.assignees.first
+ end
expose :user_notes_count
expose :upvotes, :downvotes
@@ -269,6 +339,26 @@ module API
end
class Issue < IssueBasic
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ expose :_links do
+ expose :self do |issue|
+ expose_url(api_v4_project_issue_path(id: issue.project_id, issue_iid: issue.iid))
+ end
+
+ expose :notes do |issue|
+ expose_url(api_v4_projects_issues_notes_path(id: issue.project_id, noteable_id: issue.iid))
+ end
+
+ expose :award_emoji do |issue|
+ expose_url(api_v4_projects_issues_award_emoji_path(id: issue.project_id, issue_iid: issue.iid))
+ end
+
+ expose :project do |issue|
+ expose_url(api_v4_projects_path(id: issue.project_id))
+ end
+ end
+
expose :subscribed do |issue, options|
issue.subscribed?(options[:current_user], options[:project] || issue.project)
end
@@ -286,12 +376,35 @@ module API
expose :id
end
+ class MergeRequestSimple < ProjectEntity
+ expose :title
+ expose :web_url do |merge_request, options|
+ Gitlab::UrlBuilder.build(merge_request)
+ end
+ end
+
class MergeRequestBasic < ProjectEntity
expose :target_branch, :source_branch
- expose :upvotes, :downvotes
+ expose :upvotes do |merge_request, options|
+ if options[:issuable_metadata]
+ options[:issuable_metadata][merge_request.id].upvotes
+ else
+ merge_request.upvotes
+ end
+ end
+ expose :downvotes do |merge_request, options|
+ if options[:issuable_metadata]
+ options[:issuable_metadata][merge_request.id].downvotes
+ else
+ merge_request.downvotes
+ end
+ end
expose :author, :assignee, using: Entities::UserBasic
expose :source_project_id, :target_project_id
- expose :label_names, as: :labels
+ expose :labels do |merge_request, options|
+ # Avoids an N+1 query since labels are preloaded
+ merge_request.labels.map(&:title).sort
+ end
expose :work_in_progress?, as: :work_in_progress
expose :milestone, using: Entities::Milestone
expose :merge_when_pipeline_succeeds
@@ -315,7 +428,7 @@ module API
class MergeRequestChanges < MergeRequest
expose :diffs, as: :changes, using: Entities::RepoDiff do |compare, _|
- compare.raw_diffs(all_diffs: true).to_a
+ compare.raw_diffs(limits: false).to_a
end
end
@@ -328,7 +441,7 @@ module API
expose :commits, using: Entities::RepoCommit
expose :diffs, using: Entities::RepoDiff do |compare, _|
- compare.raw_diffs(all_diffs: true).to_a
+ compare.raw_diffs(limits: false).to_a
end
end
@@ -341,6 +454,9 @@ module API
end
class Note < Grape::Entity
+ # Only Issue and MergeRequest have iid
+ NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze
+
expose :id
expose :note, as: :body
expose :attachment_identifier, as: :attachment
@@ -348,6 +464,9 @@ module API
expose :created_at, :updated_at
expose :system?, as: :system
expose :noteable_id, :noteable_type
+
+ # Avoid N+1 queries as much as possible
+ expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) }
end
class AwardEmoji < Grape::Entity
@@ -378,14 +497,24 @@ module API
expose :author, using: Entities::UserBasic
end
+ class PushEventPayload < Grape::Entity
+ expose :commit_count, :action, :ref_type, :commit_from, :commit_to
+ expose :ref, :commit_title
+ end
+
class Event < Grape::Entity
expose :title, :project_id, :action_name
- expose :target_id, :target_type, :author_id
- expose :data, :target_title
+ expose :target_id, :target_iid, :target_type, :author_id
+ expose :target_title
expose :created_at
expose :note, using: Entities::Note, if: ->(event, options) { event.note? }
expose :author, using: Entities::UserBasic, if: ->(event, options) { event.author }
+ expose :push_event_payload,
+ as: :push_data,
+ using: PushEventPayload,
+ if: -> (event, _) { event.push? }
+
expose :author_username do |event, options|
event.author&.username
end
@@ -412,8 +541,9 @@ module API
target_url = "namespace_project_#{target_type}_url"
target_anchor = "note_#{todo.note_id}" if todo.note_id?
- Gitlab::Application.routes.url_helpers.public_send(target_url,
- todo.project.namespace, todo.project, todo.target, anchor: target_anchor)
+ Gitlab::Routing
+ .url_helpers
+ .public_send(target_url, todo.project.namespace, todo.project, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
end
expose :body
@@ -422,7 +552,15 @@ module API
end
class Namespace < Grape::Entity
- expose :id, :name, :path, :kind, :full_path
+ expose :id, :name, :path, :kind, :full_path, :parent_id
+
+ expose :members_count_with_descendants, if: -> (namespace, opts) { expose_members_count_with_descendants?(namespace, opts) } do |namespace, _|
+ namespace.users_with_descendants.count
+ end
+
+ def expose_members_count_with_descendants?(namespace, opts)
+ namespace.kind == 'group' && Ability.allowed?(opts[:current_user], :admin_group, namespace)
+ end
end
class MemberAccess < Grape::Entity
@@ -459,12 +597,12 @@ module API
expose :id, :title, :created_at, :updated_at, :active
expose :push_events, :issues_events, :merge_requests_events
expose :tag_push_events, :note_events, :pipeline_events
- expose :build_events, as: :job_events
+ expose :job_events
# Expose serialized properties
expose :properties do |service, options|
- field_names = service.fields.
- select { |field| options[:include_passwords] || field[:type] != 'password' }.
- map { |field| field[:name] }
+ field_names = service.fields
+ .select { |field| options[:include_passwords] || field[:type] != 'password' }
+ .map { |field| field[:name] }
service.properties.slice(*field_names)
end
end
@@ -472,12 +610,20 @@ module API
class ProjectWithAccess < Project
expose :permissions do
expose :project_access, using: Entities::ProjectAccess do |project, options|
- project.project_members.find_by(user_id: options[:current_user].id)
+ if options.key?(:project_members)
+ (options[:project_members] || []).find { |member| member.source_id == project.id }
+ else
+ project.project_members.find_by(user_id: options[:current_user].id)
+ end
end
expose :group_access, using: Entities::GroupAccess do |project, options|
if project.group
- project.group.group_members.find_by(user_id: options[:current_user].id)
+ if options.key?(:group_members)
+ (options[:group_members] || []).find { |member| member.source_id == project.namespace_id }
+ else
+ project.group.group_members.find_by(user_id: options[:current_user].id)
+ end
end
end
end
@@ -532,7 +678,7 @@ module API
end
expose :diffs, using: Entities::RepoDiff do |compare, options|
- compare.diffs(all_diffs: true).to_a
+ compare.diffs(limits: false).to_a
end
expose :compare_timeout do |compare, options|
@@ -552,39 +698,14 @@ module API
class ApplicationSetting < Grape::Entity
expose :id
- expose :default_projects_limit
- expose :signup_enabled
- expose :signin_enabled
- expose :gravatar_enabled
- expose :sign_in_text
- expose :after_sign_up_text
- expose :created_at
- expose :updated_at
- expose :home_page_url
- expose :default_branch_protection
+ expose(*::ApplicationSettingsHelper.visible_attributes)
expose(:restricted_visibility_levels) do |setting, _options|
setting.restricted_visibility_levels.map { |level| Gitlab::VisibilityLevel.string_level(level) }
end
- expose :max_attachment_size
- expose :session_expire_delay
expose(:default_project_visibility) { |setting, _options| Gitlab::VisibilityLevel.string_level(setting.default_project_visibility) }
expose(:default_snippet_visibility) { |setting, _options| Gitlab::VisibilityLevel.string_level(setting.default_snippet_visibility) }
expose(:default_group_visibility) { |setting, _options| Gitlab::VisibilityLevel.string_level(setting.default_group_visibility) }
- expose :default_artifacts_expire_in
- expose :domain_whitelist
- expose :domain_blacklist_enabled
- expose :domain_blacklist
- expose :user_oauth_applications
- expose :after_sign_out_path
- expose :container_registry_token_expire_delay
- expose :repository_storage
- expose :repository_storages
- expose :koding_enabled
- expose :koding_url
- expose :plantuml_enabled
- expose :plantuml_url
- expose :terminal_max_session_time
- expose :polling_interval_multiplier
+ expose :password_authentication_enabled, as: :signin_enabled
end
class Release < Grape::Entity
@@ -595,7 +716,7 @@ module API
class RepoTag < Grape::Entity
expose :name, :message
- expose :commit do |repo_tag, options|
+ expose :commit, using: Entities::RepoCommit do |repo_tag, options|
options[:project].repository.commit(repo_tag.dereferenced_target)
end
@@ -659,6 +780,7 @@ module API
class Variable < Grape::Entity
expose :key, :value
+ expose :protected?, as: :protected
end
class Pipeline < PipelineBasic
@@ -670,6 +792,17 @@ module API
expose :coverage
end
+ class PipelineSchedule < Grape::Entity
+ expose :id
+ expose :description, :ref, :cron, :cron_timezone, :next_run_at, :active
+ expose :created_at, :updated_at
+ expose :owner, using: Entities::UserBasic
+ end
+
+ class PipelineScheduleDetails < PipelineSchedule
+ expose :last_pipeline, using: Entities::PipelineBasic
+ end
+
class EnvironmentBasic < Grape::Entity
expose :id, :name, :slug, :external_url
end
@@ -726,6 +859,28 @@ module API
expose :impersonation
end
+ class FeatureGate < Grape::Entity
+ expose :key
+ expose :value
+ end
+
+ class Feature < Grape::Entity
+ expose :name
+ expose :state
+ expose :gates, using: FeatureGate do |model|
+ model.gates.map do |gate|
+ value = model.gate_values[gate.key]
+
+ # By default all gate values are populated. Only show relevant ones.
+ if (value.is_a?(Integer) && value.zero?) || (value.is_a?(Set) && value.empty?)
+ next
+ end
+
+ { key: gate.key, value: value }
+ end.compact
+ end
+ end
+
module JobRequest
class JobInfo < Grape::Entity
expose :name, :stage
@@ -752,7 +907,11 @@ module API
end
class Image < Grape::Entity
- expose :name
+ expose :name, :entrypoint
+ end
+
+ class Service < Image
+ expose :alias, :command
end
class Artifacts < Grape::Entity
@@ -760,7 +919,7 @@ module API
end
class Cache < Grape::Entity
- expose :key, :untracked, :paths
+ expose :key, :untracked, :paths, :policy
end
class Credentials < Grape::Entity
@@ -796,12 +955,24 @@ module API
expose :variables
expose :steps, using: Step
expose :image, using: Image
- expose :services, using: Image
+ expose :services, using: Service
expose :artifacts, using: Artifacts
expose :cache, using: Cache
expose :credentials, using: Credentials
expose :dependencies, using: Dependency
end
end
+
+ class UserAgentDetail < Grape::Entity
+ expose :user_agent
+ expose :ip_address
+ expose :submitted, as: :akismet_submitted
+ end
+
+ class RepositoryStorageHealth < Grape::Entity
+ expose :storage_name
+ expose :failing_on_hosts
+ expose :total_failures
+ end
end
end
diff --git a/lib/api/environments.rb b/lib/api/environments.rb
index 945771d46f3..c774a5c6685 100644
--- a/lib/api/environments.rb
+++ b/lib/api/environments.rb
@@ -79,6 +79,7 @@ module API
environment = user_project.environments.find(params[:environment_id])
+ status 204
environment.destroy
end
diff --git a/lib/api/events.rb b/lib/api/events.rb
new file mode 100644
index 00000000000..dabdf579119
--- /dev/null
+++ b/lib/api/events.rb
@@ -0,0 +1,86 @@
+module API
+ class Events < Grape::API
+ include PaginationParams
+
+ helpers do
+ params :event_filter_params do
+ optional :action, type: String, values: Event.actions, desc: 'Event action to filter on'
+ optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on'
+ optional :before, type: Date, desc: 'Include only events created before this date'
+ optional :after, type: Date, desc: 'Include only events created after this date'
+ end
+
+ params :sort_params do
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return events sorted in ascending and descending order'
+ end
+
+ def present_events(events)
+ events = events.reorder(created_at: params[:sort])
+
+ present paginate(events), with: Entities::Event
+ end
+ end
+
+ resource :events do
+ desc "List currently authenticated user's events" do
+ detail 'This feature was introduced in GitLab 9.3.'
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get do
+ authenticate!
+
+ events = EventsFinder.new(params.merge(source: current_user, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID or Username of the user'
+ end
+ resource :users do
+ desc 'Get the contribution events of a specified user' do
+ detail 'This feature was introduced in GitLab 8.13.'
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get ':id/events' do
+ user = find_user(params[:id])
+ not_found!('User') unless user
+
+ events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: { id: %r{[^/]+} } do
+ desc "List a Project's visible events" do
+ success Entities::Event
+ end
+ params do
+ use :pagination
+ use :event_filter_params
+ use :sort_params
+ end
+ get ":id/events" do
+ events = EventsFinder.new(params.merge(source: user_project, current_user: current_user)).execute.preload(:author, :target)
+
+ present_events(events)
+ end
+ end
+ end
+end
diff --git a/lib/api/features.rb b/lib/api/features.rb
new file mode 100644
index 00000000000..9385c6ca174
--- /dev/null
+++ b/lib/api/features.rb
@@ -0,0 +1,70 @@
+module API
+ class Features < Grape::API
+ before { authenticated_as_admin! }
+
+ helpers do
+ def gate_value(params)
+ case params[:value]
+ when 'true'
+ true
+ when '0', 'false'
+ false
+ else
+ params[:value].to_i
+ end
+ end
+
+ def gate_targets(params)
+ targets = []
+ targets << Feature.group(params[:feature_group]) if params[:feature_group]
+ targets << User.find_by_username(params[:user]) if params[:user]
+
+ targets
+ end
+ end
+
+ resource :features do
+ desc 'Get a list of all features' do
+ success Entities::Feature
+ end
+ get do
+ features = Feature.all
+
+ present features, with: Entities::Feature, current_user: current_user
+ end
+
+ desc 'Set the gate value for the given feature' do
+ success Entities::Feature
+ end
+ params do
+ requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time'
+ optional :feature_group, type: String, desc: 'A Feature group name'
+ optional :user, type: String, desc: 'A GitLab username'
+ end
+ post ':name' do
+ feature = Feature.get(params[:name])
+ targets = gate_targets(params)
+ value = gate_value(params)
+
+ case value
+ when true
+ if targets.present?
+ targets.each { |target| feature.enable(target) }
+ else
+ feature.enable
+ end
+ when false
+ if targets.present?
+ targets.each { |target| feature.disable(target) }
+ else
+ feature.disable
+ end
+ else
+ feature.enable_percentage_of_time(value)
+ end
+
+ present feature, with: Entities::Feature, current_user: current_user
+ end
+ end
+ end
+end
diff --git a/lib/api/files.rb b/lib/api/files.rb
index e6ea12c5ab7..e2ac7142bc4 100644
--- a/lib/api/files.rb
+++ b/lib/api/files.rb
@@ -1,16 +1,20 @@
module API
class Files < Grape::API
+ # Prevents returning plain/text responses for files with .txt extension
+ after_validation { content_type "application/json" }
+
helpers do
def commit_params(attrs)
{
file_path: attrs[:file_path],
- start_branch: attrs[:branch],
+ start_branch: attrs[:start_branch] || attrs[:branch],
branch_name: attrs[:branch],
commit_message: attrs[:commit_message],
file_content: attrs[:content],
file_content_encoding: attrs[:encoding],
author_email: attrs[:author_email],
- author_name: attrs[:author_name]
+ author_name: attrs[:author_name],
+ last_commit_sha: attrs[:last_commit_id]
}
end
@@ -24,7 +28,7 @@ module API
@blob = @repo.blob_at(@commit.sha, params[:file_path])
not_found!('File') unless @blob
- @blob.load_all_data!(@repo)
+ @blob.load_all_data!
end
def commit_response(attrs)
@@ -36,8 +40,9 @@ module API
params :simple_file_params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
- requires :branch, type: String, desc: 'The name of branch'
- requires :commit_message, type: String, desc: 'Commit Message'
+ requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.'
+ requires :commit_message, type: String, desc: 'Commit message'
+ optional :start_branch, type: String, desc: 'Name of the branch to start the new commit from'
optional :author_email, type: String, desc: 'The email of the author'
optional :author_name, type: String, desc: 'The name of the author'
end
@@ -46,6 +51,7 @@ module API
use :simple_file_params
requires :content, type: String, desc: 'File content'
optional :encoding, type: String, values: %w[base64], desc: 'File encoding'
+ optional :last_commit_id, type: String, desc: 'Last known commit id for this file'
end
end
@@ -111,7 +117,12 @@ module API
authorize! :push_code, user_project
file_params = declared_params(include_missing: false)
- result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute
+
+ begin
+ result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute
+ rescue ::Files::UpdateService::FileChangedError => e
+ render_api_error!(e.message, 400)
+ end
if result[:status] == :success
status(200)
diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb
new file mode 100644
index 00000000000..b85eb59dc0a
--- /dev/null
+++ b/lib/api/group_milestones.rb
@@ -0,0 +1,85 @@
+module API
+ class GroupMilestones < Grape::API
+ include MilestoneResponses
+ include PaginationParams
+
+ before do
+ authenticate!
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ end
+ resource :groups, requirements: { id: %r{[^/]+} } do
+ desc 'Get a list of group milestones' do
+ success Entities::Milestone
+ end
+ params do
+ use :list_params
+ end
+ get ":id/milestones" do
+ list_milestones_for(user_group)
+ end
+
+ desc 'Get a single group milestone' do
+ success Entities::Milestone
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a group milestone'
+ end
+ get ":id/milestones/:milestone_id" do
+ authorize! :read_group, user_group
+
+ get_milestone_for(user_group)
+ end
+
+ desc 'Create a new group milestone' do
+ success Entities::Milestone
+ end
+ params do
+ requires :title, type: String, desc: 'The title of the milestone'
+ use :optional_params
+ end
+ post ":id/milestones" do
+ authorize! :admin_milestones, user_group
+
+ create_milestone_for(user_group)
+ end
+
+ desc 'Update an existing group milestone' do
+ success Entities::Milestone
+ end
+ params do
+ use :update_params
+ end
+ put ":id/milestones/:milestone_id" do
+ authorize! :admin_milestones, user_group
+
+ update_milestone_for(user_group)
+ end
+
+ desc 'Get all issues for a single group milestone' do
+ success Entities::IssueBasic
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a group milestone'
+ use :pagination
+ end
+ get ":id/milestones/:milestone_id/issues" do
+ milestone_issuables_for(user_group, :issue)
+ end
+
+ desc 'Get all merge requests for a single group milestone' do
+ detail 'This feature was introduced in GitLab 9.'
+ success Entities::MergeRequestBasic
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a group milestone'
+ use :pagination
+ end
+ get ':id/milestones/:milestone_id/merge_requests' do
+ milestone_issuables_for(user_group, :merge_request)
+ end
+ end
+ end
+end
diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb
new file mode 100644
index 00000000000..f64da4ab77b
--- /dev/null
+++ b/lib/api/group_variables.rb
@@ -0,0 +1,96 @@
+module API
+ class GroupVariables < Grape::API
+ include PaginationParams
+
+ before { authenticate! }
+ before { authorize! :admin_build, user_group }
+
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ end
+
+ resource :groups, requirements: { id: %r{[^/]+} } do
+ desc 'Get group-level variables' do
+ success Entities::Variable
+ end
+ params do
+ use :pagination
+ end
+ get ':id/variables' do
+ variables = user_group.variables
+ present paginate(variables), with: Entities::Variable
+ end
+
+ desc 'Get a specific variable from a group' do
+ success Entities::Variable
+ end
+ params do
+ requires :key, type: String, desc: 'The key of the variable'
+ end
+ get ':id/variables/:key' do
+ key = params[:key]
+ variable = user_group.variables.find_by(key: key)
+
+ return not_found!('GroupVariable') unless variable
+
+ present variable, with: Entities::Variable
+ end
+
+ desc 'Create a new variable in a group' do
+ success Entities::Variable
+ end
+ params do
+ requires :key, type: String, desc: 'The key of the variable'
+ requires :value, type: String, desc: 'The value of the variable'
+ optional :protected, type: String, desc: 'Whether the variable is protected'
+ end
+ post ':id/variables' do
+ variable_params = declared_params(include_missing: false)
+
+ variable = user_group.variables.create(variable_params)
+
+ if variable.valid?
+ present variable, with: Entities::Variable
+ else
+ render_validation_error!(variable)
+ end
+ end
+
+ desc 'Update an existing variable from a group' do
+ success Entities::Variable
+ end
+ params do
+ optional :key, type: String, desc: 'The key of the variable'
+ optional :value, type: String, desc: 'The value of the variable'
+ optional :protected, type: String, desc: 'Whether the variable is protected'
+ end
+ put ':id/variables/:key' do
+ variable = user_group.variables.find_by(key: params[:key])
+
+ return not_found!('GroupVariable') unless variable
+
+ variable_params = declared_params(include_missing: false).except(:key)
+
+ if variable.update(variable_params)
+ present variable, with: Entities::Variable
+ else
+ render_validation_error!(variable)
+ end
+ end
+
+ desc 'Delete an existing variable from a group' do
+ success Entities::Variable
+ end
+ params do
+ requires :key, type: String, desc: 'The key of the variable'
+ end
+ delete ':id/variables/:key' do
+ variable = user_group.variables.find_by(key: params[:key])
+ not_found!('GroupVariable') unless variable
+
+ status 204
+ variable.destroy
+ end
+ end
+ end
+end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 09d105f6b4c..49c3b2278c7 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -24,7 +24,7 @@ module API
def present_groups(groups, options = {})
options = options.reverse_merge(
with: Entities::Group,
- current_user: current_user,
+ current_user: current_user
)
groups = groups.with_statistics if options[:statistics]
@@ -52,7 +52,7 @@ module API
elsif current_user.admin
Group.all
elsif params[:all_available]
- GroupsFinder.new.execute(current_user)
+ GroupsFinder.new(current_user).execute
else
current_user.groups
end
@@ -70,7 +70,11 @@ module API
params do
requires :name, type: String, desc: 'The name of the group'
requires :path, type: String, desc: 'The path of the group'
- optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group'
+
+ if ::Group.supports_nested_groups?
+ optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group'
+ end
+
use :optional_params
end
post do
@@ -79,7 +83,7 @@ module API
group = ::Groups::CreateService.new(current_user, declared_params(include_missing: false)).execute
if group.persisted?
- present group, with: Entities::Group, current_user: current_user
+ present group, with: Entities::GroupDetail, current_user: current_user
else
render_api_error!("Failed to save group #{group.errors.messages}", 400)
end
@@ -97,8 +101,6 @@ module API
optional :name, type: String, desc: 'The name of the group'
optional :path, type: String, desc: 'The path of the group'
use :optional_params
- at_least_one_of :name, :path, :description, :visibility,
- :lfs_enabled, :request_access_enabled
end
put ':id' do
group = find_group!(params[:id])
@@ -123,6 +125,8 @@ module API
delete ":id" do
group = find_group!(params[:id])
authorize! :admin_group, group
+
+ status 204
::Groups::DestroyService.new(group, current_user).execute
end
@@ -147,8 +151,8 @@ module API
end
get ":id/projects" do
group = find_group!(params[:id])
- projects = GroupProjectsFinder.new(group: group, current_user: current_user).execute
- projects = filter_projects(projects)
+ projects = GroupProjectsFinder.new(group: group, current_user: current_user, params: project_finder_params).execute
+ projects = reorder_projects(projects)
entity = params[:simple] ? Entities::BasicProjectDetails : Entities::Project
present paginate(projects), with: entity, current_user: current_user
end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 86bf567fe69..b56fd2388b3 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -16,6 +16,8 @@ module API
@current_user = initial_current_user
+ Gitlab::I18n.locale = @current_user&.preferred_language
+
sudo!
@current_user
@@ -25,6 +27,10 @@ module API
initial_current_user != current_user
end
+ def user_group
+ @group ||= find_group!(params[:id])
+ end
+
def user_project
@project ||= find_project!(params[:id])
end
@@ -91,8 +97,8 @@ module API
end
def find_project_snippet(id)
- finder_params = { filter: :by_project, project: user_project }
- SnippetsFinder.new.execute(current_user, finder_params).find(id)
+ finder_params = { project: user_project }
+ SnippetsFinder.new(current_user, finder_params).execute.find(id)
end
def find_merge_request_with_access(iid, access_level = :read_merge_request)
@@ -158,7 +164,7 @@ module API
params_hash = custom_params || params
attrs = {}
keys.each do |key|
- if params_hash[key].present? || (params_hash.has_key?(key) && params_hash[key] == false)
+ if params_hash[key].present? || (params_hash.key?(key) && params_hash[key] == false)
attrs[key] = params_hash[key]
end
end
@@ -251,41 +257,40 @@ module API
message << " " << trace.join("\n ")
API.logger.add Logger::FATAL, message
- rack_response({ 'message' => '500 Internal Server Error' }.to_json, 500)
- end
-
- # project helpers
-
- def filter_projects(projects)
- if params[:membership]
- projects = projects.merge(current_user.authorized_projects)
- end
- if params[:owned]
- projects = projects.merge(current_user.owned_projects)
- end
-
- if params[:starred]
- projects = projects.merge(current_user.starred_projects)
- end
+ response_message =
+ if Rails.env.test?
+ message
+ else
+ '500 Internal Server Error'
+ end
- if params[:search].present?
- projects = projects.search(params[:search])
- end
+ rack_response({ 'message' => response_message }.to_json, 500)
+ end
- if params[:visibility].present?
- projects = projects.search_by_visibility(params[:visibility])
- end
+ # project helpers
- projects = projects.where(archived: params[:archived])
+ def reorder_projects(projects)
projects.reorder(params[:order_by] => params[:sort])
end
+ def project_finder_params
+ finder_params = {}
+ finder_params[:owned] = true if params[:owned].present?
+ finder_params[:non_public] = true if params[:membership].present?
+ finder_params[:starred] = true if params[:starred].present?
+ finder_params[:visibility_level] = Gitlab::VisibilityLevel.level_value(params[:visibility]) if params[:visibility]
+ finder_params[:archived] = params[:archived]
+ finder_params[:search] = params[:search] if params[:search]
+ finder_params[:user] = params.delete(:user) if params[:user]
+ finder_params
+ end
+
# file helpers
def uploaded_file(field, uploads_path)
if params[field]
- bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename)
+ bad_request!("#{field} is not a file") unless params[field][:filename]
return params[field]
end
@@ -301,7 +306,7 @@ module API
UploadedFile.new(
file_path,
params["#{field}.name"],
- params["#{field}.type"] || 'application/octet-stream',
+ params["#{field}.type"] || 'application/octet-stream'
)
end
@@ -321,6 +326,16 @@ module API
end
end
+ def present_artifacts!(artifacts_file)
+ return not_found! unless artifacts_file.exists?
+
+ if artifacts_file.file_storage?
+ present_file!(artifacts_file.path, artifacts_file.filename)
+ else
+ redirect_to(artifacts_file.url)
+ end
+ end
+
private
def private_token
@@ -331,19 +346,21 @@ module API
env['warden']
end
+ # Check if the request is GET/HEAD, or if CSRF token is valid.
+ def verified_request?
+ Gitlab::RequestForgeryProtection.verified?(env)
+ end
+
# Check the Rails session for valid authentication details
- #
- # Until CSRF protection is added to the API, disallow this method for
- # state-changing endpoints
def find_user_from_warden
- warden.try(:authenticate) if %w[GET HEAD].include?(env['REQUEST_METHOD'])
+ warden.try(:authenticate) if verified_request?
end
def initial_current_user
return @initial_current_user if defined?(@initial_current_user)
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
- @initial_current_user ||= find_user_by_private_token(scopes: @scopes)
- @initial_current_user ||= doorkeeper_guard(scopes: @scopes)
+ @initial_current_user ||= find_user_by_private_token(scopes: scopes_registered_for_endpoint)
+ @initial_current_user ||= doorkeeper_guard(scopes: scopes_registered_for_endpoint)
@initial_current_user ||= find_user_from_warden
unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed?
@@ -407,5 +424,22 @@ module API
exception.status == 500
end
+
+ # An array of scopes that were registered (using `allow_access_with_scope`)
+ # for the current endpoint class. It also returns scopes registered on
+ # `API::API`, since these are meant to apply to all API routes.
+ def scopes_registered_for_endpoint
+ @scopes_registered_for_endpoint ||=
+ begin
+ endpoint_classes = [options[:for].presence, ::API::API].compact
+ endpoint_classes.reduce([]) do |memo, endpoint|
+ if endpoint.respond_to?(:allowed_scopes)
+ memo.concat(endpoint.allowed_scopes)
+ else
+ memo
+ end
+ end
+ end
+ end
end
end
diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb
new file mode 100644
index 00000000000..322624c6092
--- /dev/null
+++ b/lib/api/helpers/common_helpers.rb
@@ -0,0 +1,13 @@
+module API
+ module Helpers
+ module CommonHelpers
+ def convert_parameters_from_legacy_format(params)
+ params.tap do |params|
+ if params[:assignee_id].present?
+ params[:assignee_ids] = [params.delete(:assignee_id)]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb
index 718f936a1fc..ecb79317093 100644
--- a/lib/api/helpers/internal_helpers.rb
+++ b/lib/api/helpers/internal_helpers.rb
@@ -1,48 +1,23 @@
module API
module Helpers
module InternalHelpers
- # Project paths may be any of the following:
- # * /repository/storage/path/namespace/project
- # * /namespace/project
- # * namespace/project
- #
- # In addition, they may have a '.git' extension and multiple namespaces
- #
- # Transform all these cases to 'namespace/project'
- def clean_project_path(project_path, storages = Gitlab.config.repositories.storages.values)
- project_path = project_path.sub(/\.git\z/, '')
-
- storages.each do |storage|
- storage_path = File.expand_path(storage['path'])
-
- if project_path.start_with?(storage_path)
- project_path = project_path.sub(storage_path, '')
- break
- end
- end
-
- project_path.sub(/\A\//, '')
- end
-
- def project_path
- @project_path ||= clean_project_path(params[:project])
- end
+ SSH_GITALY_FEATURES = {
+ 'git-receive-pack' => :ssh_receive_pack,
+ 'git-upload-pack' => :ssh_upload_pack
+ }.freeze
def wiki?
- @wiki ||= project_path.end_with?('.wiki') &&
- !Project.find_by_full_path(project_path)
+ set_project unless defined?(@wiki)
+ @wiki
end
def project
- @project ||= begin
- # Check for *.wiki repositories.
- # Strip out the .wiki from the pathname before finding the
- # project. This applies the correct project permissions to
- # the wiki repository as well.
- project_path.chomp!('.wiki') if wiki?
+ set_project unless defined?(@project)
+ @project
+ end
- Project.find_by_full_path(project_path)
- end
+ def redirected_path
+ @redirected_path
end
def ssh_authentication_abilities
@@ -66,6 +41,51 @@ module API
::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action])
end
+
+ private
+
+ def set_project
+ if params[:gl_repository]
+ @project, @wiki = Gitlab::GlRepository.parse(params[:gl_repository])
+ @redirected_path = nil
+ else
+ @project, @wiki, @redirected_path = Gitlab::RepoPath.parse(params[:project])
+ end
+ end
+
+ # Project id to pass between components that don't share/don't have
+ # access to the same filesystem mounts
+ def gl_repository
+ Gitlab::GlRepository.gl_repository(project, wiki?)
+ end
+
+ # Return the repository depending on whether we want the wiki or the
+ # regular repository
+ def repository
+ if wiki?
+ project.wiki.repository
+ else
+ project.repository
+ end
+ end
+
+ # Return the repository full path so that gitlab-shell has it when
+ # handling ssh commands
+ def repository_path
+ repository.path_to_repo
+ end
+
+ # Return the Gitaly Address if it is enabled
+ def gitaly_payload(action)
+ feature = SSH_GITALY_FEATURES[action]
+ return unless feature && Gitlab::GitalyClient.feature_enabled?(feature)
+
+ {
+ repository: repository.gitaly_repository,
+ address: Gitlab::GitalyClient.address(project.repository_storage),
+ token: Gitlab::GitalyClient.token(project.repository_storage)
+ }
+ end
end
end
end
diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb
index d9cae1501f8..a50ea0b52aa 100644
--- a/lib/api/helpers/members_helpers.rb
+++ b/lib/api/helpers/members_helpers.rb
@@ -1,8 +1,10 @@
+# rubocop:disable GitlabSecurity/PublicSend
+
module API
module Helpers
module MembersHelpers
def find_source(source_type, id)
- public_send("find_#{source_type}!", id)
+ public_send("find_#{source_type}!", id) # rubocop:disable GitlabSecurity/PublicSend
end
def authorize_admin_source!(source_type, source)
diff --git a/lib/api/helpers/related_resources_helpers.rb b/lib/api/helpers/related_resources_helpers.rb
new file mode 100644
index 00000000000..1f677529b07
--- /dev/null
+++ b/lib/api/helpers/related_resources_helpers.rb
@@ -0,0 +1,28 @@
+module API
+ module Helpers
+ module RelatedResourcesHelpers
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ def issues_available?(project, options)
+ available?(:issues, project, options[:current_user])
+ end
+
+ def mrs_available?(project, options)
+ available?(:merge_requests, project, options[:current_user])
+ end
+
+ def expose_url(path)
+ url_options = Gitlab::Application.routes.default_url_options
+ protocol, host, port = url_options.slice(:protocol, :host, :port).values
+
+ URI::HTTP.build(scheme: protocol, host: host, port: port, path: path).to_s
+ end
+
+ private
+
+ def available?(feature, project, current_user)
+ project.feature_available?(feature, current_user)
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb
index 1369b021ea4..f8645e364ce 100644
--- a/lib/api/helpers/runner.rb
+++ b/lib/api/helpers/runner.rb
@@ -46,7 +46,8 @@ module API
yield if block_given?
- forbidden!('Project has been deleted!') unless job.project
+ project = job.project
+ forbidden!('Project has been deleted!') if project.nil? || project.pending_delete?
forbidden!('Job has been erased!') if job.erased?
end
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index ebed26dd178..8b007869dc3 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -32,27 +32,24 @@ module API
actor.update_last_used_at if actor.is_a?(Key)
- access_checker = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
- access_status = access_checker
- .new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities)
- .check(params[:action], params[:changes])
-
- response = { status: access_status.status, message: access_status.message }
-
- if access_status.status
- log_user_activity(actor)
-
- # Return the repository full path so that gitlab-shell has it when
- # handling ssh commands
- response[:repository_path] =
- if wiki?
- project.wiki.repository.path_to_repo
- else
- project.repository.path_to_repo
- end
+ access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
+ access_checker = access_checker_klass
+ .new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities, redirected_path: redirected_path)
+
+ begin
+ access_checker.check(params[:action], params[:changes])
+ rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e
+ return { status: false, message: e.message }
end
- response
+ log_user_activity(actor)
+
+ {
+ status: true,
+ gl_repository: gl_repository,
+ repository_path: repository_path,
+ gitaly: gitaly_payload(params[:action])
+ }
end
post "/lfs_authenticate" do
@@ -75,23 +72,36 @@ module API
end
#
- # Discover user by ssh key
+ # Discover user by ssh key or user id
#
get "/discover" do
- key = Key.find(params[:key_id])
- present key.user, with: Entities::UserSafe
+ if params[:key_id]
+ key = Key.find(params[:key_id])
+ user = key.user
+ elsif params[:user_id]
+ user = User.find_by(id: params[:user_id])
+ end
+ present user, with: Entities::UserSafe
end
get "/check" do
{
api_version: API.version,
gitlab_version: Gitlab::VERSION,
- gitlab_rev: Gitlab::REVISION,
+ gitlab_rev: Gitlab::REVISION
}
end
+ get "/broadcast_messages" do
+ if messages = BroadcastMessage.current
+ present messages, with: Entities::BroadcastMessage
+ else
+ []
+ end
+ end
+
get "/broadcast_message" do
- if message = BroadcastMessage.current
+ if message = BroadcastMessage.current&.last
present message, with: Entities::BroadcastMessage
else
{}
@@ -123,8 +133,11 @@ module API
return { success: false, message: 'Two-factor authentication is not enabled for this user' }
end
- codes = user.generate_otp_backup_codes!
- user.save!
+ codes = nil
+
+ ::Users::UpdateService.new(user).execute! do |user|
+ codes = user.generate_otp_backup_codes!
+ end
{ success: true, recovery_codes: codes }
end
@@ -132,16 +145,15 @@ module API
post "/notify_post_receive" do
status 200
- return unless Gitlab::GitalyClient.enabled?
-
- relative_path = Gitlab::RepoPath.strip_storage_path(params[:repo_path])
- project = Project.find_by_full_path(relative_path.sub(/\.(git|wiki)\z/, ''))
-
- begin
- Gitlab::GitalyClient::Notifications.new(project.repository).post_receive
- rescue GRPC::Unavailable => e
- render_api_error!(e, 500)
- end
+ # TODO: Re-enable when Gitaly is processing the post-receive notification
+ # return unless Gitlab::GitalyClient.enabled?
+ #
+ # begin
+ # repository = wiki? ? project.wiki.repository : project.repository
+ # Gitlab::GitalyClient::NotificationService.new(repository.raw_repository).post_receive
+ # rescue GRPC::Unavailable => e
+ # render_api_error!(e, 500)
+ # end
end
end
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 522f0f3be92..4cec1145f3a 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -27,12 +27,19 @@ module API
optional :milestone, type: String, desc: 'Return issues for a specific milestone'
optional :iids, type: Array[Integer], desc: 'The IID array of issues'
optional :search, type: String, desc: 'Search issues for text present in the title or description'
+ optional :created_after, type: DateTime, desc: 'Return issues created after the specified time'
+ optional :created_before, type: DateTime, desc: 'Return issues created before the specified time'
+ optional :author_id, type: Integer, desc: 'Return issues which are authored by the user with the given ID'
+ optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID'
+ optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
+ desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`'
use :pagination
end
params :issue_params_ce do
optional :description, type: String, desc: 'The description of an issue'
- optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue'
+ optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue'
+ optional :assignee_id, type: Integer, desc: '[Deprecated] The ID of a user to assign issue'
optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue'
optional :labels, type: String, desc: 'Comma-separated list of label names'
optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY'
@@ -52,9 +59,11 @@ module API
optional :state, type: String, values: %w[opened closed all], default: 'all',
desc: 'Return opened, closed, or all issues'
use :issues_params
+ optional :scope, type: String, values: %w[created-by-me assigned-to-me all], default: 'created-by-me',
+ desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`'
end
get do
- issues = find_issues(scope: 'authored')
+ issues = find_issues
present paginate(issues), with: Entities::IssueBasic, current_user: current_user
end
@@ -109,7 +118,7 @@ module API
params do
requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue'
end
- get ":id/issues/:issue_iid" do
+ get ":id/issues/:issue_iid", as: :api_v4_project_issue do
issue = find_project_issue(params[:issue_iid])
present issue, with: Entities::Issue, current_user: current_user, project: user_project
end
@@ -135,6 +144,8 @@ module API
issue_params = declared_params(include_missing: false)
+ issue_params = convert_parameters_from_legacy_format(issue_params)
+
issue = ::Issues::CreateService.new(user_project,
current_user,
issue_params.merge(request: request, api: true)).execute
@@ -159,7 +170,7 @@ module API
desc: 'Date time when the issue was updated. Available only for admins and project owners.'
optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue'
use :issue_params
- at_least_one_of :title, :description, :assignee_id, :milestone_id,
+ at_least_one_of :title, :description, :assignee_ids, :assignee_id, :milestone_id,
:labels, :created_at, :due_date, :confidential, :state_event
end
put ':id/issues/:issue_iid' do
@@ -173,6 +184,8 @@ module API
update_params = declared_params(include_missing: false).merge(request: request, api: true)
+ update_params = convert_parameters_from_legacy_format(update_params)
+
issue = ::Issues::UpdateService.new(user_project,
current_user,
update_params).execute(issue)
@@ -217,6 +230,7 @@ module API
not_found!('Issue') unless issue
authorize!(:destroy_issue, issue)
+ status 204
issue.destroy
end
@@ -234,6 +248,22 @@ module API
present paginate(merge_requests), with: Entities::MergeRequestBasic, current_user: current_user, project: user_project
end
+
+ desc 'Get the user agent details for an issue' do
+ success Entities::UserAgentDetail
+ end
+ params do
+ requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue'
+ end
+ get ":id/issues/:issue_iid/user_agent_detail" do
+ authenticated_as_admin!
+
+ issue = find_project_issue(params[:issue_iid])
+
+ return not_found!('UserAgentDetail') unless issue.user_agent_detail
+
+ present issue.user_agent_detail, with: Entities::UserAgentDetail
+ end
end
end
end
diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb
index 288b03d940c..a40018b214e 100644
--- a/lib/api/jobs.rb
+++ b/lib/api/jobs.rb
@@ -16,9 +16,9 @@ module API
case scope
when String
[scope]
- when Hashie::Mash
+ when ::Hash
scope.values
- when Hashie::Array
+ when ::Array
scope
else
['unknown']
@@ -132,6 +132,7 @@ module API
authorize_update_builds!
build = get_build!(params[:job_id])
+ authorize!(:update_build, build)
build.cancel
@@ -148,6 +149,7 @@ module API
authorize_update_builds!
build = get_build!(params[:job_id])
+ authorize!(:update_build, build)
return forbidden!('Job is not retryable') unless build.retryable?
build = Ci::Build.retry(build, current_user)
@@ -165,6 +167,7 @@ module API
authorize_update_builds!
build = get_build!(params[:job_id])
+ authorize!(:update_build, build)
return forbidden!('Job is not erasable!') unless build.erasable?
build.erase(erased_by: current_user)
@@ -181,6 +184,7 @@ module API
authorize_update_builds!
build = get_build!(params[:job_id])
+ authorize!(:update_build, build)
return not_found!(build) unless build.artifacts?
build.keep_artifacts!
@@ -201,6 +205,7 @@ module API
build = get_build!(params[:job_id])
+ authorize!(:update_build, build)
bad_request!("Unplayable Job") unless build.playable?
build.play(current_user)
@@ -211,22 +216,12 @@ module API
end
helpers do
- def get_build(id)
+ def find_build(id)
user_project.builds.find_by(id: id.to_i)
end
def get_build!(id)
- get_build(id) || not_found!
- end
-
- def present_artifacts!(artifacts_file)
- if !artifacts_file.file_storage?
- redirect_to(build.artifacts_file.url)
- elsif artifacts_file.exists?
- present_file!(artifacts_file.path, artifacts_file.filename)
- else
- not_found!
- end
+ find_build(id) || not_found!
end
def filter_builds(builds, scope)
diff --git a/lib/api/labels.rb b/lib/api/labels.rb
index 20b25529d0c..4520c98d951 100644
--- a/lib/api/labels.rb
+++ b/lib/api/labels.rb
@@ -56,6 +56,7 @@ module API
label = user_project.labels.find_by(title: params[:name])
not_found!('Label') unless label
+ status 204
label.destroy
end
diff --git a/lib/api/members.rb b/lib/api/members.rb
index c200e46a328..bb970b7cd54 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -96,6 +96,7 @@ module API
# Ensure that memeber exists
source.members.find_by!(user_id: params[:user_id])
+ status 204
::Members::DestroyService.new(source, current_user, declared_params).execute
end
end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index e5793fbc5cb..8810d4e441d 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -4,6 +4,71 @@ module API
before { authenticate! }
+ helpers ::Gitlab::IssuableMetadata
+
+ helpers do
+ def find_merge_requests(args = {})
+ args = params.merge(args)
+
+ args[:milestone_title] = args.delete(:milestone)
+ args[:label_name] = args.delete(:labels)
+
+ merge_requests = MergeRequestsFinder.new(current_user, args).execute
+ .reorder(args[:order_by] => args[:sort])
+ merge_requests = paginate(merge_requests)
+ .preload(:target_project)
+
+ return merge_requests if args[:view] == 'simple'
+
+ merge_requests
+ .preload(:notes, :author, :assignee, :milestone, :merge_request_diff, :labels)
+ end
+
+ params :merge_requests_params do
+ optional :state, type: String, values: %w[opened closed merged all], default: 'all',
+ desc: 'Return opened, closed, merged, or all merge requests'
+ optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at',
+ desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.'
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return merge requests sorted in `asc` or `desc` order.'
+ optional :milestone, type: String, desc: 'Return merge requests for a specific milestone'
+ optional :labels, type: String, desc: 'Comma-separated list of label names'
+ optional :created_after, type: DateTime, desc: 'Return merge requests created after the specified time'
+ optional :created_before, type: DateTime, desc: 'Return merge requests created before the specified time'
+ optional :view, type: String, values: %w[simple], desc: 'If simple, returns the `iid`, URL, title, description, and basic state of merge request'
+ optional :author_id, type: Integer, desc: 'Return merge requests which are authored by the user with the given ID'
+ optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID'
+ optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
+ desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`'
+ use :pagination
+ end
+ end
+
+ resource :merge_requests do
+ desc 'List merge requests' do
+ success Entities::MergeRequestBasic
+ end
+ params do
+ use :merge_requests_params
+ optional :scope, type: String, values: %w[created-by-me assigned-to-me all], default: 'created-by-me',
+ desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`'
+ end
+ get do
+ merge_requests = find_merge_requests
+
+ options = { with: Entities::MergeRequestBasic,
+ current_user: current_user }
+
+ if params[:view] == 'simple'
+ options[:with] = Entities::MergeRequestSimple
+ else
+ options[:issuable_metadata] = issuable_meta_data(merge_requests, 'MergeRequest')
+ end
+
+ present merge_requests, options
+ end
+ end
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
@@ -20,30 +85,13 @@ module API
error!(errors[:validate_fork], 422)
elsif errors[:validate_branches].any?
conflict!(errors[:validate_branches])
+ elsif errors[:base].any?
+ error!(errors[:base], 422)
end
render_api_error!(errors, 400)
end
- def issue_entity(project)
- if project.has_external_issue_tracker?
- Entities::ExternalIssue
- else
- Entities::IssueBasic
- end
- end
-
- def find_merge_requests(args = {})
- args = params.merge(args)
-
- args[:milestone_title] = args.delete(:milestone)
- args[:label_name] = args.delete(:labels)
-
- merge_requests = MergeRequestsFinder.new(current_user, args).execute.inc_notes_with_associations
-
- merge_requests.reorder(args[:order_by] => args[:sort])
- end
-
params :optional_params_ce do
optional :description, type: String, desc: 'The description of the merge request'
optional :assignee_id, type: Integer, desc: 'The ID of a user to assign the merge request'
@@ -61,23 +109,25 @@ module API
success Entities::MergeRequestBasic
end
params do
- optional :state, type: String, values: %w[opened closed merged all], default: 'all',
- desc: 'Return opened, closed, merged, or all merge requests'
- optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at',
- desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.'
- optional :sort, type: String, values: %w[asc desc], default: 'desc',
- desc: 'Return merge requests sorted in `asc` or `desc` order.'
+ use :merge_requests_params
optional :iids, type: Array[Integer], desc: 'The IID array of merge requests'
- optional :milestone, type: String, desc: 'Return merge requests for a specific milestone'
- optional :labels, type: String, desc: 'Comma-separated list of label names'
- use :pagination
end
get ":id/merge_requests" do
authorize! :read_merge_request, user_project
merge_requests = find_merge_requests(project_id: user_project.id)
- present paginate(merge_requests), with: Entities::MergeRequestBasic, current_user: current_user, project: user_project
+ options = { with: Entities::MergeRequestBasic,
+ current_user: current_user,
+ project: user_project }
+
+ if params[:view] == 'simple'
+ options[:with] = Entities::MergeRequestSimple
+ else
+ options[:issuable_metadata] = issuable_meta_data(merge_requests, 'MergeRequest')
+ end
+
+ present merge_requests, options
end
desc 'Create a merge request' do
@@ -95,7 +145,7 @@ module API
authorize! :create_merge_request, user_project
mr_params = declared_params(include_missing: false)
- mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present?
+ mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch)
merge_request = ::MergeRequests::CreateService.new(user_project, current_user, mr_params).execute
@@ -114,6 +164,7 @@ module API
merge_request = find_project_merge_request(params[:merge_request_iid])
authorize!(:destroy_merge_request, merge_request)
+ status 204
merge_request.destroy
end
@@ -254,7 +305,14 @@ module API
get ':id/merge_requests/:merge_request_iid/closes_issues' do
merge_request = find_merge_request_with_access(params[:merge_request_iid])
issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
- present paginate(issues), with: issue_entity(user_project), current_user: current_user
+ issues = paginate(issues)
+
+ external_issues, internal_issues = issues.partition { |issue| issue.is_a?(ExternalIssue) }
+
+ data = Entities::IssueBasic.represent(internal_issues, current_user: current_user)
+ data += Entities::ExternalIssue.represent(external_issues, current_user: current_user)
+
+ data.as_json
end
end
end
diff --git a/lib/api/milestone_responses.rb b/lib/api/milestone_responses.rb
new file mode 100644
index 00000000000..ef09d9505d2
--- /dev/null
+++ b/lib/api/milestone_responses.rb
@@ -0,0 +1,98 @@
+module API
+ module MilestoneResponses
+ extend ActiveSupport::Concern
+
+ included do
+ helpers do
+ params :optional_params do
+ optional :description, type: String, desc: 'The description of the milestone'
+ optional :due_date, type: String, desc: 'The due date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
+ optional :start_date, type: String, desc: 'The start date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
+ end
+
+ params :list_params do
+ optional :state, type: String, values: %w[active closed all], default: 'all',
+ desc: 'Return "active", "closed", or "all" milestones'
+ optional :iids, type: Array[Integer], desc: 'The IIDs of the milestones'
+ optional :search, type: String, desc: 'The search criteria for the title or description of the milestone'
+ use :pagination
+ end
+
+ params :update_params do
+ requires :milestone_id, type: Integer, desc: 'The milestone ID number'
+ optional :title, type: String, desc: 'The title of the milestone'
+ optional :state_event, type: String, values: %w[close activate],
+ desc: 'The state event of the milestone '
+ use :optional_params
+ at_least_one_of :title, :description, :due_date, :state_event
+ end
+
+ def list_milestones_for(parent)
+ milestones = parent.milestones
+ milestones = Milestone.filter_by_state(milestones, params[:state])
+ milestones = filter_by_iid(milestones, params[:iids]) if params[:iids].present?
+ milestones = filter_by_search(milestones, params[:search]) if params[:search]
+
+ present paginate(milestones), with: Entities::Milestone
+ end
+
+ def get_milestone_for(parent)
+ milestone = parent.milestones.find(params[:milestone_id])
+ present milestone, with: Entities::Milestone
+ end
+
+ def create_milestone_for(parent)
+ milestone = ::Milestones::CreateService.new(parent, current_user, declared_params).execute
+
+ if milestone.valid?
+ present milestone, with: Entities::Milestone
+ else
+ render_api_error!("Failed to create milestone #{milestone.errors.messages}", 400)
+ end
+ end
+
+ def update_milestone_for(parent)
+ milestone = parent.milestones.find(params.delete(:milestone_id))
+
+ milestone_params = declared_params(include_missing: false)
+ milestone = ::Milestones::UpdateService.new(parent, current_user, milestone_params).execute(milestone)
+
+ if milestone.valid?
+ present milestone, with: Entities::Milestone
+ else
+ render_api_error!("Failed to update milestone #{milestone.errors.messages}", 400)
+ end
+ end
+
+ def milestone_issuables_for(parent, type)
+ milestone = parent.milestones.find(params[:milestone_id])
+
+ finder_klass, entity = get_finder_and_entity(type)
+
+ params = build_finder_params(milestone, parent)
+
+ issuables = finder_klass.new(current_user, params).execute
+ present paginate(issuables), with: entity, current_user: current_user
+ end
+
+ def build_finder_params(milestone, parent)
+ finder_params = { milestone_title: milestone.title, sort: 'label_priority' }
+
+ if parent.is_a?(Group)
+ finder_params.merge(group_id: parent.id)
+ else
+ finder_params.merge(project_id: parent.id)
+ end
+ end
+
+ def get_finder_and_entity(type)
+ if type == :issue
+ [IssuesFinder, Entities::IssueBasic]
+ else
+ [MergeRequestsFinder, Entities::MergeRequestBasic]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/milestones.rb b/lib/api/milestones.rb
deleted file mode 100644
index a3ea619a2fb..00000000000
--- a/lib/api/milestones.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-module API
- class Milestones < Grape::API
- include PaginationParams
-
- before { authenticate! }
-
- helpers do
- def filter_milestones_state(milestones, state)
- case state
- when 'active' then milestones.active
- when 'closed' then milestones.closed
- else milestones
- end
- end
-
- params :optional_params do
- optional :description, type: String, desc: 'The description of the milestone'
- optional :due_date, type: String, desc: 'The due date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
- optional :start_date, type: String, desc: 'The start date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
- end
- end
-
- params do
- requires :id, type: String, desc: 'The ID of a project'
- end
- resource :projects, requirements: { id: %r{[^/]+} } do
- desc 'Get a list of project milestones' do
- success Entities::Milestone
- end
- params do
- optional :state, type: String, values: %w[active closed all], default: 'all',
- desc: 'Return "active", "closed", or "all" milestones'
- optional :iids, type: Array[Integer], desc: 'The IIDs of the milestones'
- optional :search, type: String, desc: 'The search criteria for the title or description of the milestone'
- use :pagination
- end
- get ":id/milestones" do
- authorize! :read_milestone, user_project
-
- milestones = user_project.milestones
- milestones = filter_milestones_state(milestones, params[:state])
- milestones = filter_by_iid(milestones, params[:iids]) if params[:iids].present?
- milestones = filter_by_search(milestones, params[:search]) if params[:search]
-
- present paginate(milestones), with: Entities::Milestone
- end
-
- desc 'Get a single project milestone' do
- success Entities::Milestone
- end
- params do
- requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
- end
- get ":id/milestones/:milestone_id" do
- authorize! :read_milestone, user_project
-
- milestone = user_project.milestones.find(params[:milestone_id])
- present milestone, with: Entities::Milestone
- end
-
- desc 'Create a new project milestone' do
- success Entities::Milestone
- end
- params do
- requires :title, type: String, desc: 'The title of the milestone'
- use :optional_params
- end
- post ":id/milestones" do
- authorize! :admin_milestone, user_project
-
- milestone = ::Milestones::CreateService.new(user_project, current_user, declared_params).execute
-
- if milestone.valid?
- present milestone, with: Entities::Milestone
- else
- render_api_error!("Failed to create milestone #{milestone.errors.messages}", 400)
- end
- end
-
- desc 'Update an existing project milestone' do
- success Entities::Milestone
- end
- params do
- requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
- optional :title, type: String, desc: 'The title of the milestone'
- optional :state_event, type: String, values: %w[close activate],
- desc: 'The state event of the milestone '
- use :optional_params
- at_least_one_of :title, :description, :due_date, :state_event
- end
- put ":id/milestones/:milestone_id" do
- authorize! :admin_milestone, user_project
- milestone = user_project.milestones.find(params.delete(:milestone_id))
-
- milestone_params = declared_params(include_missing: false)
- milestone = ::Milestones::UpdateService.new(user_project, current_user, milestone_params).execute(milestone)
-
- if milestone.valid?
- present milestone, with: Entities::Milestone
- else
- render_api_error!("Failed to update milestone #{milestone.errors.messages}", 400)
- end
- end
-
- desc 'Get all issues for a single project milestone' do
- success Entities::IssueBasic
- end
- params do
- requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
- use :pagination
- end
- get ":id/milestones/:milestone_id/issues" do
- authorize! :read_milestone, user_project
-
- milestone = user_project.milestones.find(params[:milestone_id])
-
- finder_params = {
- project_id: user_project.id,
- milestone_title: milestone.title,
- sort: 'position_asc'
- }
-
- issues = IssuesFinder.new(current_user, finder_params).execute
- present paginate(issues), with: Entities::IssueBasic, current_user: current_user, project: user_project
- end
-
- desc 'Get all merge requests for a single project milestone' do
- detail 'This feature was introduced in GitLab 9.'
- success Entities::MergeRequestBasic
- end
- params do
- requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
- use :pagination
- end
- get ':id/milestones/:milestone_id/merge_requests' do
- authorize! :read_milestone, user_project
-
- milestone = user_project.milestones.find(params[:milestone_id])
-
- finder_params = {
- project_id: user_project.id,
- milestone_title: milestone.title,
- sort: 'position_asc'
- }
-
- merge_requests = MergeRequestsFinder.new(current_user, finder_params).execute
- present paginate(merge_requests),
- with: Entities::MergeRequestBasic,
- current_user: current_user,
- project: user_project
- end
- end
- end
-end
diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb
index 30761cb9b55..f1eaff6b0eb 100644
--- a/lib/api/namespaces.rb
+++ b/lib/api/namespaces.rb
@@ -17,7 +17,7 @@ module API
namespaces = namespaces.search(params[:search]) if params[:search].present?
- present paginate(namespaces), with: Entities::Namespace
+ present paginate(namespaces), with: Entities::Namespace, current_user: current_user
end
end
end
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index e281e3230fd..4e4e473994b 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -33,8 +33,8 @@ module API
# paginate() only works with a relation. This could lead to a
# mismatch between the pagination headers info and the actual notes
# array returned, but this is really a edge-case.
- paginate(noteable.notes).
- reject { |n| n.cross_reference_not_visible_for?(current_user) }
+ paginate(noteable.notes)
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
present notes, with: Entities::Note
else
not_found!("Notes")
@@ -131,6 +131,7 @@ module API
note = user_project.notes.find(params[:note_id])
authorize! :admin_note, note
+ status 204
::Notes::DestroyService.new(user_project, current_user).execute(note)
end
end
@@ -138,7 +139,7 @@ module API
helpers do
def find_project_noteable(noteables_str, noteable_id)
- public_send("find_project_#{noteables_str.singularize}", noteable_id)
+ public_send("find_project_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend
end
def noteable_read_ability_name(noteable)
diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb
index 992ea5dc24d..5d113c94b22 100644
--- a/lib/api/notification_settings.rb
+++ b/lib/api/notification_settings.rb
@@ -34,7 +34,10 @@ module API
notification_setting.transaction do
new_notification_email = params.delete(:notification_email)
- current_user.update(notification_email: new_notification_email) if new_notification_email
+ if new_notification_email
+ ::Users::UpdateService.new(current_user, notification_email: new_notification_email).execute
+ end
+
notification_setting.update(declared_params(include_missing: false))
end
rescue ArgumentError => e # catch level enum error
diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb
new file mode 100644
index 00000000000..dbeaf9e17ef
--- /dev/null
+++ b/lib/api/pipeline_schedules.rb
@@ -0,0 +1,134 @@
+module API
+ class PipelineSchedules < Grape::API
+ include PaginationParams
+
+ before { authenticate! }
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: { id: %r{[^/]+} } do
+ desc 'Get all pipeline schedules' do
+ success Entities::PipelineSchedule
+ end
+ params do
+ use :pagination
+ optional :scope, type: String, values: %w[active inactive],
+ desc: 'The scope of pipeline schedules'
+ end
+ get ':id/pipeline_schedules' do
+ authorize! :read_pipeline_schedule, user_project
+
+ schedules = PipelineSchedulesFinder.new(user_project).execute(scope: params[:scope])
+ .preload([:owner, :last_pipeline])
+ present paginate(schedules), with: Entities::PipelineSchedule
+ end
+
+ desc 'Get a single pipeline schedule' do
+ success Entities::PipelineScheduleDetails
+ end
+ params do
+ requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
+ end
+ get ':id/pipeline_schedules/:pipeline_schedule_id' do
+ authorize! :read_pipeline_schedule, user_project
+
+ not_found!('PipelineSchedule') unless pipeline_schedule
+
+ present pipeline_schedule, with: Entities::PipelineScheduleDetails
+ end
+
+ desc 'Create a new pipeline schedule' do
+ success Entities::PipelineScheduleDetails
+ end
+ params do
+ requires :description, type: String, desc: 'The description of pipeline schedule'
+ requires :ref, type: String, desc: 'The branch/tag name will be triggered'
+ requires :cron, type: String, desc: 'The cron'
+ optional :cron_timezone, type: String, default: 'UTC', desc: 'The timezone'
+ optional :active, type: Boolean, default: true, desc: 'The activation of pipeline schedule'
+ end
+ post ':id/pipeline_schedules' do
+ authorize! :create_pipeline_schedule, user_project
+
+ pipeline_schedule = Ci::CreatePipelineScheduleService
+ .new(user_project, current_user, declared_params(include_missing: false))
+ .execute
+
+ if pipeline_schedule.persisted?
+ present pipeline_schedule, with: Entities::PipelineScheduleDetails
+ else
+ render_validation_error!(pipeline_schedule)
+ end
+ end
+
+ desc 'Edit a pipeline schedule' do
+ success Entities::PipelineScheduleDetails
+ end
+ params do
+ requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
+ optional :description, type: String, desc: 'The description of pipeline schedule'
+ optional :ref, type: String, desc: 'The branch/tag name will be triggered'
+ optional :cron, type: String, desc: 'The cron'
+ optional :cron_timezone, type: String, desc: 'The timezone'
+ optional :active, type: Boolean, desc: 'The activation of pipeline schedule'
+ end
+ put ':id/pipeline_schedules/:pipeline_schedule_id' do
+ authorize! :read_pipeline_schedule, user_project
+
+ not_found!('PipelineSchedule') unless pipeline_schedule
+ authorize! :update_pipeline_schedule, pipeline_schedule
+
+ if pipeline_schedule.update(declared_params(include_missing: false))
+ present pipeline_schedule, with: Entities::PipelineScheduleDetails
+ else
+ render_validation_error!(pipeline_schedule)
+ end
+ end
+
+ desc 'Take ownership of a pipeline schedule' do
+ success Entities::PipelineScheduleDetails
+ end
+ params do
+ requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
+ end
+ post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do
+ authorize! :read_pipeline_schedule, user_project
+
+ not_found!('PipelineSchedule') unless pipeline_schedule
+ authorize! :update_pipeline_schedule, pipeline_schedule
+
+ if pipeline_schedule.own!(current_user)
+ present pipeline_schedule, with: Entities::PipelineScheduleDetails
+ else
+ render_validation_error!(pipeline_schedule)
+ end
+ end
+
+ desc 'Delete a pipeline schedule' do
+ success Entities::PipelineScheduleDetails
+ end
+ params do
+ requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
+ end
+ delete ':id/pipeline_schedules/:pipeline_schedule_id' do
+ authorize! :read_pipeline_schedule, user_project
+
+ not_found!('PipelineSchedule') unless pipeline_schedule
+ authorize! :admin_pipeline_schedule, pipeline_schedule
+
+ status :accepted
+ present pipeline_schedule.destroy, with: Entities::PipelineScheduleDetails
+ end
+ end
+
+ helpers do
+ def pipeline_schedule
+ @pipeline_schedule ||=
+ user_project.pipeline_schedules
+ .preload(:owner, :last_pipeline)
+ .find_by(id: params.delete(:pipeline_schedule_id))
+ end
+ end
+ end
+end
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index 754c3d85a04..e505cae3992 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -14,13 +14,23 @@ module API
end
params do
use :pagination
- optional :scope, type: String, values: %w(running branches tags),
- desc: 'Either running, branches, or tags'
+ optional :scope, type: String, values: %w[running pending finished branches tags],
+ desc: 'The scope of pipelines'
+ optional :status, type: String, values: HasStatus::AVAILABLE_STATUSES,
+ desc: 'The status of pipelines'
+ optional :ref, type: String, desc: 'The ref of pipelines'
+ optional :yaml_errors, type: Boolean, desc: 'Returns pipelines with invalid configurations'
+ optional :name, type: String, desc: 'The name of the user who triggered pipelines'
+ optional :username, type: String, desc: 'The username of the user who triggered pipelines'
+ optional :order_by, type: String, values: PipelinesFinder::ALLOWED_INDEXED_COLUMNS, default: 'id',
+ desc: 'Order pipelines'
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Sort pipelines'
end
get ':id/pipelines' do
authorize! :read_pipeline, user_project
- pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope])
+ pipelines = PipelinesFinder.new(user_project, params).execute
present paginate(pipelines), with: Entities::PipelineBasic
end
@@ -37,7 +47,7 @@ module API
new_pipeline = Ci::CreatePipelineService.new(user_project,
current_user,
declared_params(include_missing: false))
- .execute(ignore_skip_ci: true, save_on_errors: false)
+ .execute(:api, ignore_skip_ci: true, save_on_errors: false)
if new_pipeline.persisted?
present new_pipeline, with: Entities::Pipeline
else
diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb
index 87dfd1573a4..649dd891f56 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -54,7 +54,6 @@ module API
end
post ":id/hooks" do
hook_params = declared_params(include_missing: false)
- hook_params[:build_events] = hook_params.delete(:job_events) { false }
hook = user_project.hooks.new(hook_params)
@@ -78,7 +77,6 @@ module API
hook = user_project.hooks.find(params.delete(:hook_id))
update_params = declared_params(include_missing: false)
- update_params[:build_events] = update_params.delete(:job_events) if update_params[:job_events]
if hook.update_attributes(update_params)
present hook, with: Entities::ProjectHook
@@ -98,6 +96,7 @@ module API
delete ":id/hooks/:hook_id" do
hook = user_project.hooks.find(params.delete(:hook_id))
+ status 204
hook.destroy
end
end
diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb
new file mode 100644
index 00000000000..451998c726a
--- /dev/null
+++ b/lib/api/project_milestones.rb
@@ -0,0 +1,91 @@
+module API
+ class ProjectMilestones < Grape::API
+ include PaginationParams
+ include MilestoneResponses
+
+ before do
+ authenticate!
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: { id: %r{[^/]+} } do
+ desc 'Get a list of project milestones' do
+ success Entities::Milestone
+ end
+ params do
+ use :list_params
+ end
+ get ":id/milestones" do
+ authorize! :read_milestone, user_project
+
+ list_milestones_for(user_project)
+ end
+
+ desc 'Get a single project milestone' do
+ success Entities::Milestone
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
+ end
+ get ":id/milestones/:milestone_id" do
+ authorize! :read_milestone, user_project
+
+ get_milestone_for(user_project)
+ end
+
+ desc 'Create a new project milestone' do
+ success Entities::Milestone
+ end
+ params do
+ requires :title, type: String, desc: 'The title of the milestone'
+ use :optional_params
+ end
+ post ":id/milestones" do
+ authorize! :admin_milestone, user_project
+
+ create_milestone_for(user_project)
+ end
+
+ desc 'Update an existing project milestone' do
+ success Entities::Milestone
+ end
+ params do
+ use :update_params
+ end
+ put ":id/milestones/:milestone_id" do
+ authorize! :admin_milestone, user_project
+
+ update_milestone_for(user_project)
+ end
+
+ desc 'Get all issues for a single project milestone' do
+ success Entities::IssueBasic
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
+ use :pagination
+ end
+ get ":id/milestones/:milestone_id/issues" do
+ authorize! :read_milestone, user_project
+
+ milestone_issuables_for(user_project, :issue)
+ end
+
+ desc 'Get all merge requests for a single project milestone' do
+ detail 'This feature was introduced in GitLab 9.'
+ success Entities::MergeRequestBasic
+ end
+ params do
+ requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
+ use :pagination
+ end
+ get ':id/milestones/:milestone_id/merge_requests' do
+ authorize! :read_milestone, user_project
+
+ milestone_issuables_for(user_project, :merge_request)
+ end
+ end
+ end
+end
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index cfee38a9baf..f3d905b0068 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -17,8 +17,7 @@ module API
end
def snippets_for_current_user
- finder_params = { filter: :by_project, project: user_project }
- SnippetsFinder.new.execute(current_user, finder_params)
+ SnippetsFinder.new(current_user, project: user_project).execute
end
end
@@ -50,6 +49,7 @@ module API
requires :title, type: String, desc: 'The title of the snippet'
requires :file_name, type: String, desc: 'The file name of the snippet'
requires :code, type: String, desc: 'The content of the snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
requires :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
@@ -78,6 +78,7 @@ module API
optional :title, type: String, desc: 'The title of the snippet'
optional :file_name, type: String, desc: 'The file name of the snippet'
optional :code, type: String, desc: 'The content of the snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
@@ -115,6 +116,7 @@ module API
not_found!('Snippet') unless snippet
authorize! :admin_project_snippet, snippet
+ status 204
snippet.destroy
end
@@ -130,6 +132,22 @@ module API
content_type 'text/plain'
present snippet.content
end
+
+ desc 'Get the user agent details for a project snippet' do
+ success Entities::UserAgentDetail
+ end
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
+ get ":id/snippets/:snippet_id/user_agent_detail" do
+ authenticated_as_admin!
+
+ snippet = Snippet.find_by!(id: params[:id])
+
+ return not_found!('UserAgentDetail') unless snippet.user_agent_detail
+
+ present snippet.user_agent_detail, with: Entities::UserAgentDetail
+ end
end
end
end
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index db4b31b55bc..15c3832b032 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -1,3 +1,5 @@
+require_dependency 'declarative_policy'
+
module API
# Projects API
class Projects < Grape::API
@@ -8,6 +10,7 @@ module API
helpers do
params :optional_params_ce do
optional :description, type: String, desc: 'The description of the project'
+ optional :ci_config_path, type: String, desc: 'The path to CI config file. Defaults to `.gitlab-ci.yml`'
optional :issues_enabled, type: Boolean, desc: 'Flag indication if the issue tracker is enabled'
optional :merge_requests_enabled, type: Boolean, desc: 'Flag indication if merge requests are enabled'
optional :wiki_enabled, type: Boolean, desc: 'Flag indication if the wiki is enabled'
@@ -21,74 +24,107 @@ module API
optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access'
optional :only_allow_merge_if_pipeline_succeeds, type: Boolean, desc: 'Only allow to merge if builds succeed'
optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all discussions are resolved'
+ optional :tag_list, type: Array[String], desc: 'The list of tags for a project'
+ optional :avatar, type: File, desc: 'Avatar image for project'
+ optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line'
end
params :optional_params do
use :optional_params_ce
end
- end
- resource :projects do
- helpers do
- params :collection_params do
- use :sort_params
- use :filter_params
- use :pagination
-
- optional :simple, type: Boolean, default: false,
- desc: 'Return only the ID, URL, name, and path of each project'
- end
+ params :statistics_params do
+ optional :statistics, type: Boolean, default: false, desc: 'Include project statistics'
+ end
- params :sort_params do
- optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at],
- default: 'created_at', desc: 'Return projects ordered by field'
- optional :sort, type: String, values: %w[asc desc], default: 'desc',
- desc: 'Return projects sorted in ascending and descending order'
- end
+ params :collection_params do
+ use :sort_params
+ use :filter_params
+ use :pagination
- params :filter_params do
- optional :archived, type: Boolean, default: false, desc: 'Limit by archived status'
- optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values,
- desc: 'Limit by visibility'
- optional :search, type: String, desc: 'Return list of projects matching the search criteria'
- optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user'
- optional :starred, type: Boolean, default: false, desc: 'Limit by starred status'
- optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of'
- end
+ optional :simple, type: Boolean, default: false,
+ desc: 'Return only the ID, URL, name, and path of each project'
+ end
- params :statistics_params do
- optional :statistics, type: Boolean, default: false, desc: 'Include project statistics'
- end
+ params :sort_params do
+ optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at],
+ default: 'created_at', desc: 'Return projects ordered by field'
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return projects sorted in ascending and descending order'
+ end
- params :create_params do
- optional :namespace_id, type: Integer, desc: 'Namespace ID for the new project. Default to the user namespace.'
- optional :import_url, type: String, desc: 'URL from which the project is imported'
- end
+ params :filter_params do
+ optional :archived, type: Boolean, default: false, desc: 'Limit by archived status'
+ optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values,
+ desc: 'Limit by visibility'
+ optional :search, type: String, desc: 'Return list of projects matching the search criteria'
+ optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user'
+ optional :starred, type: Boolean, default: false, desc: 'Limit by starred status'
+ optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of'
+ optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature'
+ optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature'
+ end
- def present_projects(projects, options = {})
- options = options.reverse_merge(
- with: Entities::Project,
- current_user: current_user,
- simple: params[:simple],
- )
+ params :create_params do
+ optional :namespace_id, type: Integer, desc: 'Namespace ID for the new project. Default to the user namespace.'
+ optional :import_url, type: String, desc: 'URL from which the project is imported'
+ end
- projects = filter_projects(projects)
- projects = projects.with_statistics if options[:statistics]
- options[:with] = Entities::BasicProjectDetails if options[:simple]
+ def present_projects(options = {})
+ projects = ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute
+ projects = reorder_projects(projects)
+ projects = projects.with_statistics if params[:statistics]
+ projects = projects.with_issues_enabled if params[:with_issues_enabled]
+ projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
- present paginate(projects), options
+ if current_user
+ projects = projects.includes(:route, :taggings, namespace: :route)
+ project_members = current_user.project_members
+ group_members = current_user.group_members
end
+
+ options = options.reverse_merge(
+ with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails,
+ statistics: params[:statistics],
+ project_members: project_members,
+ group_members: group_members,
+ current_user: current_user
+ )
+ options[:with] = Entities::BasicProjectDetails if params[:simple]
+
+ present paginate(projects), options
+ end
+ end
+
+ resource :users, requirements: { user_id: %r{[^/]+} } do
+ desc 'Get a user projects' do
+ success Entities::BasicProjectDetails
end
+ params do
+ requires :user_id, type: String, desc: 'The ID or username of the user'
+ use :collection_params
+ use :statistics_params
+ end
+ get ":user_id/projects" do
+ user = find_user(params[:user_id])
+ not_found!('User') unless user
+
+ params[:user] = user
+ present_projects
+ end
+ end
+
+ resource :projects do
desc 'Get a list of visible projects for authenticated user' do
success Entities::BasicProjectDetails
end
params do
use :collection_params
+ use :statistics_params
end
get do
- entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails
- present_projects ProjectsFinder.new(current_user: current_user).execute, with: entity, statistics: params[:statistics]
+ present_projects
end
desc 'Create new project' do
@@ -103,7 +139,7 @@ module API
end
post do
attrs = declared_params(include_missing: false)
- attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
+ attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled)
project = ::Projects::CreateService.new(current_user, attrs).execute
if project.saved?
@@ -123,6 +159,7 @@ module API
params do
requires :name, type: String, desc: 'The name of the project'
requires :user_id, type: Integer, desc: 'The ID of a user'
+ optional :path, type: String, desc: 'The path of the repository'
optional :default_branch, type: String, desc: 'The default branch of the project'
use :optional_params
use :create_params
@@ -151,20 +188,13 @@ module API
desc 'Get a single project' do
success Entities::ProjectWithAccess
end
+ params do
+ use :statistics_params
+ end
get ":id" do
entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails
present user_project, with: entity, current_user: current_user,
- user_can_admin_project: can?(current_user, :admin_project, user_project)
- end
-
- desc 'Get events for a single project' do
- success Entities::Event
- end
- params do
- use :pagination
- end
- get ":id/events" do
- present paginate(user_project.events.recent), with: Entities::Event
+ user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics]
end
desc 'Fork new project for the current user or provided namespace.' do
@@ -217,12 +247,14 @@ module API
:only_allow_merge_if_all_discussions_are_resolved,
:only_allow_merge_if_pipeline_succeeds,
:path,
+ :printing_merge_request_link_enabled,
:public_builds,
:request_access_enabled,
:shared_runners_enabled,
:snippets_enabled,
+ :tag_list,
:visibility,
- :wiki_enabled,
+ :wiki_enabled
]
optional :name, type: String, desc: 'The name of the project'
optional :default_branch, type: String, desc: 'The default branch of the project'
@@ -237,7 +269,7 @@ module API
authorize! :rename_project, user_project if attrs[:name].present?
authorize! :change_visibility_level, user_project if attrs[:visibility].present?
- attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
+ attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled)
result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute
@@ -319,6 +351,8 @@ module API
if user_project.forked_from_project.nil?
user_project.create_forked_project_link(forked_to_project_id: user_project.id, forked_from_project_id: forked_from_project.id)
+
+ ::Projects::ForksCountService.new(forked_from_project).refresh_cache
else
render_api_error!("Project already forked", 409)
end
@@ -329,6 +363,7 @@ module API
authorize! :remove_fork_project, user_project
if user_project.forked?
+ status 204
user_project.forked_project_link.destroy
else
not_modified!
@@ -373,6 +408,7 @@ module API
link = user_project.project_group_links.find_by(group_id: params[:group_id])
not_found!('Group Link') unless link
+ status 204
link.destroy
end
@@ -381,7 +417,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded'
end
post ":id/uploads" do
- ::Projects::UploadService.new(user_project, params[:file]).execute
+ UploadService.new(user_project, params[:file]).execute
end
desc 'Get the users list of a project' do
@@ -392,7 +428,7 @@ module API
use :pagination
end
get ':id/users' do
- users = user_project.team.users
+ users = DeclarativePolicy.subject_scope { user_project.team.users }
users = users.search(params[:search]) if params[:search].present?
present paginate(users), with: Entities::UserBasic
diff --git a/lib/api/protected_branches.rb b/lib/api/protected_branches.rb
new file mode 100644
index 00000000000..dccf4fa27a7
--- /dev/null
+++ b/lib/api/protected_branches.rb
@@ -0,0 +1,85 @@
+module API
+ class ProtectedBranches < Grape::API
+ include PaginationParams
+
+ BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(branch: API::NO_SLASH_URL_PART_REGEX)
+
+ before { authorize_admin_project }
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc "Get a project's protected branches" do
+ success Entities::ProtectedBranch
+ end
+ params do
+ use :pagination
+ end
+ get ':id/protected_branches' do
+ protected_branches = user_project.protected_branches.preload(:push_access_levels, :merge_access_levels)
+
+ present paginate(protected_branches), with: Entities::ProtectedBranch, project: user_project
+ end
+
+ desc 'Get a single protected branch' do
+ success Entities::ProtectedBranch
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the branch or wildcard'
+ end
+ get ':id/protected_branches/:name', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
+ protected_branch = user_project.protected_branches.find_by!(name: params[:name])
+
+ present protected_branch, with: Entities::ProtectedBranch, project: user_project
+ end
+
+ desc 'Protect a single branch or wildcard' do
+ success Entities::ProtectedBranch
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the protected branch'
+ optional :push_access_level, type: Integer, default: Gitlab::Access::MASTER,
+ values: ProtectedBranchAccess::ALLOWED_ACCESS_LEVELS,
+ desc: 'Access levels allowed to push (defaults: `40`, master access level)'
+ optional :merge_access_level, type: Integer, default: Gitlab::Access::MASTER,
+ values: ProtectedBranchAccess::ALLOWED_ACCESS_LEVELS,
+ desc: 'Access levels allowed to merge (defaults: `40`, master access level)'
+ end
+ post ':id/protected_branches' do
+ protected_branch = user_project.protected_branches.find_by(name: params[:name])
+ if protected_branch
+ conflict!("Protected branch '#{params[:name]}' already exists")
+ end
+
+ protected_branch_params = {
+ name: params[:name],
+ push_access_levels_attributes: [{ access_level: params[:push_access_level] }],
+ merge_access_levels_attributes: [{ access_level: params[:merge_access_level] }]
+ }
+
+ service_args = [user_project, current_user, protected_branch_params]
+
+ protected_branch = ::ProtectedBranches::CreateService.new(*service_args).execute
+
+ if protected_branch.persisted?
+ present protected_branch, with: Entities::ProtectedBranch, project: user_project
+ else
+ render_api_error!(protected_branch.errors.full_messages, 422)
+ end
+ end
+
+ desc 'Unprotect a single branch'
+ params do
+ requires :name, type: String, desc: 'The name of the protected branch'
+ end
+ delete ':id/protected_branches/:name', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
+ protected_branch = user_project.protected_branches.find_by!(name: params[:name])
+
+ protected_branch.destroy
+
+ status 204
+ end
+ end
+ end
+end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 8f16e532ecb..14d2bff9cb5 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -85,7 +85,7 @@ module API
optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded'
optional :format, type: String, desc: 'The archive format'
end
- get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do
+ get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do
begin
send_git_archive user_project.repository, ref: params[:sha], format: params[:format]
rescue
diff --git a/lib/api/runner.rb b/lib/api/runner.rb
index 6fbb02cb3aa..88fc62d33df 100644
--- a/lib/api/runner.rb
+++ b/lib/api/runner.rb
@@ -45,6 +45,7 @@ module API
end
delete '/' do
authenticate_runner!
+ status 204
Ci::Runner.find_by_token(params[:token]).destroy
end
@@ -89,7 +90,7 @@ module API
if result.valid?
if result.build
Gitlab::Metrics.add_event(:build_found,
- project: result.build.project.path_with_namespace)
+ project: result.build.project.full_path)
present result.build, with: Entities::JobRequest::Response
else
Gitlab::Metrics.add_event(:build_not_found)
@@ -118,7 +119,7 @@ module API
job.trace.set(params[:trace]) if params[:trace]
Gitlab::Metrics.add_event(:update_build,
- project: job.project.path_with_namespace)
+ project: job.project.full_path)
case params[:state].to_s
when 'success'
@@ -141,7 +142,7 @@ module API
patch '/:id/trace' do
job = authenticate_job!
- error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range')
+ error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
content_range = request.headers['Content-Range']
content_range = content_range.split('-')
@@ -241,16 +242,7 @@ module API
get '/:id/artifacts' do
job = authenticate_job!
- artifacts_file = job.artifacts_file
- unless artifacts_file.file_storage?
- return redirect_to job.artifacts_file.url
- end
-
- unless artifacts_file.exists?
- not_found!
- end
-
- present_file!(artifacts_file.path, artifacts_file.filename)
+ present_artifacts!(job.artifacts_file)
end
end
end
diff --git a/lib/api/runners.rb b/lib/api/runners.rb
index db6c7c59092..31f940fe96b 100644
--- a/lib/api/runners.rb
+++ b/lib/api/runners.rb
@@ -79,6 +79,7 @@ module API
runner = get_runner(params[:id])
authenticate_delete_runner!(runner)
+ status 204
runner.destroy!
end
end
@@ -134,6 +135,7 @@ module API
runner = runner_project.runner
forbidden!("Only one project associated with the runner. Please remove the runner instead") if runner.projects.count == 1
+ status 204
runner_project.destroy
end
end
@@ -151,7 +153,7 @@ module API
render_api_error!('Scope contains invalid value', 400)
end
- runners.send(scope)
+ runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
end
def get_runner(id)
diff --git a/lib/api/scope.rb b/lib/api/scope.rb
new file mode 100644
index 00000000000..d5165b2e482
--- /dev/null
+++ b/lib/api/scope.rb
@@ -0,0 +1,23 @@
+# Encapsulate a scope used for authorization, such as `api`, or `read_user`
+module API
+ class Scope
+ attr_reader :name, :if
+
+ def initialize(name, options = {})
+ @name = name.to_sym
+ @if = options[:if]
+ end
+
+ # Are the `scopes` passed in sufficient to adequately authorize the passed
+ # request for the scope represented by the current instance of this class?
+ def sufficient?(scopes, request)
+ scopes.include?(self.name) && verify_if_condition(request)
+ end
+
+ private
+
+ def verify_if_condition(request)
+ self.if.nil? || self.if.call(request)
+ end
+ end
+end
diff --git a/lib/api/services.rb b/lib/api/services.rb
index 23ef62c2258..843c05ae32e 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -304,13 +304,13 @@ module API
required: true,
name: :url,
type: String,
- desc: 'The URL to the JIRA project which is being linked to this GitLab project, e.g., https://jira.example.com'
+ desc: 'The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., https://jira.example.com'
},
{
- required: true,
- name: :project_key,
+ required: false,
+ name: :api_url,
type: String,
- desc: 'The short identifier for your JIRA project, all uppercase, e.g., PROJ'
+ desc: 'The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., https://jira-api.example.com'
},
{
required: false,
@@ -356,7 +356,7 @@ module API
name: :ca_pem,
type: String,
desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)'
- },
+ }
],
'mattermost-slash-commands' => [
{
@@ -559,7 +559,7 @@ module API
SlackService,
MattermostService,
MicrosoftTeamsService,
- TeamcityService,
+ TeamcityService
]
if Rails.env.development?
@@ -577,7 +577,7 @@ module API
service_classes += [
MockCiService,
MockDeploymentService,
- MockMonitoringService,
+ MockMonitoringService
]
end
@@ -679,7 +679,7 @@ module API
trigger_services.each do |service_slug, settings|
helpers do
- def chat_command_service(project, service_slug, params)
+ def slash_command_service(project, service_slug, params)
project.services.active.where(template: false).find do |service|
service.try(:token) == params[:token] && service.to_param == service_slug.underscore
end
@@ -704,7 +704,7 @@ module API
# This is not accurate, but done to prevent leakage of the project names
not_found!('Service') unless project
- service = chat_command_service(project, service_slug, params)
+ service = slash_command_service(project, service_slug, params)
result = service.try(:trigger, params)
if result
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index d01c7f2703b..667ba468ce6 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -20,55 +20,6 @@ module API
success Entities::ApplicationSetting
end
params do
- # CE
- at_least_one_of_ce = [
- :admin_notification_email,
- :after_sign_out_path,
- :after_sign_up_text,
- :akismet_enabled,
- :container_registry_token_expire_delay,
- :default_artifacts_expire_in,
- :default_branch_protection,
- :default_group_visibility,
- :default_project_visibility,
- :default_projects_limit,
- :default_snippet_visibility,
- :disabled_oauth_sign_in_sources,
- :domain_blacklist_enabled,
- :domain_whitelist,
- :email_author_in_body,
- :enabled_git_access_protocol,
- :gravatar_enabled,
- :help_page_text,
- :home_page_url,
- :housekeeping_enabled,
- :html_emails_enabled,
- :import_sources,
- :koding_enabled,
- :max_artifacts_size,
- :max_attachment_size,
- :max_pages_size,
- :metrics_enabled,
- :plantuml_enabled,
- :polling_interval_multiplier,
- :recaptcha_enabled,
- :repository_checks_enabled,
- :repository_storage,
- :require_two_factor_authentication,
- :restricted_visibility_levels,
- :send_user_confirmation_email,
- :sentry_enabled,
- :session_expire_delay,
- :shared_runners_enabled,
- :sidekiq_throttling_enabled,
- :sign_in_text,
- :signin_enabled,
- :signup_enabled,
- :terminal_max_session_time,
- :user_default_external,
- :user_oauth_applications,
- :version_check_enabled
- ]
optional :default_branch_protection, type: Integer, values: [0, 1, 2], desc: 'Determine if developers can push to master'
optional :default_project_visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The default project visibility'
optional :default_snippet_visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The default snippet visibility'
@@ -78,6 +29,7 @@ module API
desc: 'Enabled sources for code import during project creation. OmniAuth must be configured for GitHub, Bitbucket, and GitLab.com'
optional :disabled_oauth_sign_in_sources, type: Array[String], desc: 'Disable certain OAuth sign-in sources'
optional :enabled_git_access_protocol, type: String, values: %w[ssh http nil], desc: 'Allow only the selected protocols to be used for Git access.'
+ optional :project_export_enabled, type: Boolean, desc: 'Enable project export'
optional :gravatar_enabled, type: Boolean, desc: 'Flag indicating if the Gravatar service is enabled'
optional :default_projects_limit, type: Integer, desc: 'The maximum number of personal projects'
optional :max_attachment_size, type: Integer, desc: 'Maximum attachment size in MB'
@@ -92,7 +44,9 @@ module API
requires :domain_blacklist, type: String, desc: 'Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com'
end
optional :after_sign_up_text, type: String, desc: 'Text shown after sign up'
- optional :signin_enabled, type: Boolean, desc: 'Flag indicating if sign in is enabled'
+ optional :password_authentication_enabled, type: Boolean, desc: 'Flag indicating if password authentication is enabled'
+ optional :signin_enabled, type: Boolean, desc: 'Flag indicating if password authentication is enabled'
+ mutually_exclusive :password_authentication_enabled, :signin_enabled
optional :require_two_factor_authentication, type: Boolean, desc: 'Require all users to setup Two-factor authentication'
given require_two_factor_authentication: ->(val) { val } do
requires :two_factor_grace_period, type: Integer, desc: 'Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication'
@@ -100,7 +54,9 @@ module API
optional :home_page_url, type: String, desc: 'We will redirect non-logged in users to this page'
optional :after_sign_out_path, type: String, desc: 'We will redirect users to this page after they sign out'
optional :sign_in_text, type: String, desc: 'The sign in text of the GitLab application'
+ optional :help_page_hide_commercial_content, type: Boolean, desc: 'Hide marketing-related entries from help'
optional :help_page_text, type: String, desc: 'Custom text displayed on the help page'
+ optional :help_page_support_url, type: String, desc: 'Alternate support URL for help page'
optional :shared_runners_enabled, type: Boolean, desc: 'Enable shared runners for new projects'
given shared_runners_enabled: ->(val) { val } do
requires :shared_runners_text, type: String, desc: 'Shared runners text '
@@ -109,6 +65,7 @@ module API
optional :default_artifacts_expire_in, type: String, desc: "Set the default expiration time for each job's artifacts"
optional :max_pages_size, type: Integer, desc: 'Maximum size of pages in MB'
optional :container_registry_token_expire_delay, type: Integer, desc: 'Authorization token duration (minutes)'
+ optional :prometheus_metrics_enabled, type: Boolean, desc: 'Enable Prometheus metrics'
optional :metrics_enabled, type: Boolean, desc: 'Enable the InfluxDB metrics'
given metrics_enabled: ->(val) { val } do
requires :metrics_host, type: String, desc: 'The InfluxDB host'
@@ -138,7 +95,11 @@ module API
given sentry_enabled: ->(val) { val } do
requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name'
end
- optional :repository_storage, type: String, desc: 'Storage paths for new projects'
+ optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/'
+ given clientside_sentry_enabled: ->(val) { val } do
+ requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name'
+ end
+ optional :repository_storages, type: Array[String], desc: 'Storage paths for new projects'
optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues."
optional :koding_enabled, type: Boolean, desc: 'Enable Koding'
given koding_enabled: ->(val) { val } do
@@ -161,11 +122,16 @@ module API
optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.'
optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.'
- at_least_one_of(*at_least_one_of_ce)
+ optional(*::ApplicationSettingsHelper.visible_attributes)
+ at_least_one_of(*::ApplicationSettingsHelper.visible_attributes)
end
put "application/settings" do
attrs = declared_params(include_missing: false)
+ if attrs.has_key?(:signin_enabled)
+ attrs[:password_authentication_enabled] = attrs.delete(:signin_enabled)
+ end
+
if current_settings.update_attributes(attrs)
present current_settings, with: Entities::ApplicationSetting
else
diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb
index b93fdc62808..35ece56c65c 100644
--- a/lib/api/snippets.rb
+++ b/lib/api/snippets.rb
@@ -8,11 +8,11 @@ module API
resource :snippets do
helpers do
def snippets_for_current_user
- SnippetsFinder.new.execute(current_user, filter: :by_user, user: current_user)
+ SnippetsFinder.new(current_user, author: current_user).execute
end
def public_snippets
- SnippetsFinder.new.execute(current_user, filter: :public)
+ SnippetsFinder.new(current_user, visibility: Snippet::PUBLIC).execute
end
end
@@ -58,6 +58,7 @@ module API
requires :title, type: String, desc: 'The title of a snippet'
requires :file_name, type: String, desc: 'The name of a snippet file'
requires :content, type: String, desc: 'The content of a snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
default: 'internal',
@@ -85,6 +86,7 @@ module API
optional :title, type: String, desc: 'The title of a snippet'
optional :file_name, type: String, desc: 'The name of a snippet file'
optional :content, type: String, desc: 'The content of a snippet'
+ optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
@@ -121,6 +123,7 @@ module API
authorize! :destroy_personal_snippet, snippet
+ status 204
snippet.destroy
end
@@ -138,6 +141,22 @@ module API
content_type 'text/plain'
present snippet.content
end
+
+ desc 'Get the user agent details for a snippet' do
+ success Entities::UserAgentDetail
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of a snippet'
+ end
+ get ":id/user_agent_detail" do
+ authenticated_as_admin!
+
+ snippet = Snippet.find_by!(id: params[:id])
+
+ return not_found!('UserAgentDetail') unless snippet.user_agent_detail
+
+ present snippet.user_agent_detail, with: Entities::UserAgentDetail
+ end
end
end
end
diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb
index dbe54d3cd31..91567909998 100644
--- a/lib/api/subscriptions.rb
+++ b/lib/api/subscriptions.rb
@@ -5,7 +5,7 @@ module API
subscribable_types = {
'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) },
'issues' => proc { |id| find_project_issue(id) },
- 'labels' => proc { |id| find_project_label(id) },
+ 'labels' => proc { |id| find_project_label(id) }
}
params do
diff --git a/lib/api/system_hooks.rb b/lib/api/system_hooks.rb
index ed7b23b474a..c0179037440 100644
--- a/lib/api/system_hooks.rb
+++ b/lib/api/system_hooks.rb
@@ -66,6 +66,7 @@ module API
hook = SystemHook.find_by(id: params[:id])
not_found!('System hook') unless hook
+ status 204
hook.destroy
end
end
diff --git a/lib/api/tags.rb b/lib/api/tags.rb
index c7b1efe0bfa..1333747cced 100644
--- a/lib/api/tags.rb
+++ b/lib/api/tags.rb
@@ -2,19 +2,21 @@ module API
class Tags < Grape::API
include PaginationParams
+ TAG_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX)
+
before { authorize! :download_code, user_project }
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a project repository tags' do
success Entities::RepoTag
end
params do
use :pagination
end
- get ":id/repository/tags" do
+ get ':id/repository/tags' do
tags = ::Kaminari.paginate_array(user_project.repository.tags.sort_by(&:name).reverse)
present paginate(tags), with: Entities::RepoTag, project: user_project
end
@@ -25,7 +27,7 @@ module API
params do
requires :tag_name, type: String, desc: 'The name of the tag'
end
- get ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do
+ get ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do
tag = user_project.repository.find_tag(params[:tag_name])
not_found!('Tag') unless tag
@@ -44,8 +46,8 @@ module API
post ':id/repository/tags' do
authorize_push_project
- result = ::Tags::CreateService.new(user_project, current_user).
- execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
+ result = ::Tags::CreateService.new(user_project, current_user)
+ .execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
if result[:status] == :success
present result[:tag],
@@ -60,11 +62,11 @@ module API
params do
requires :tag_name, type: String, desc: 'The name of the tag'
end
- delete ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do
+ delete ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do
authorize_push_project
- result = ::Tags::DestroyService.new(user_project, current_user).
- execute(params[:tag_name])
+ result = ::Tags::DestroyService.new(user_project, current_user)
+ .execute(params[:tag_name])
if result[:status] != :success
render_api_error!(result[:message], result[:return_code])
@@ -78,11 +80,11 @@ module API
requires :tag_name, type: String, desc: 'The name of the tag'
requires :description, type: String, desc: 'Release notes with markdown support'
end
- post ':id/repository/tags/:tag_name/release', requirements: { tag_name: /.+/ } do
+ post ':id/repository/tags/:tag_name/release', requirements: TAG_ENDPOINT_REQUIREMENTS do
authorize_push_project
- result = CreateReleaseService.new(user_project, current_user).
- execute(params[:tag_name], params[:description])
+ result = CreateReleaseService.new(user_project, current_user)
+ .execute(params[:tag_name], params[:description])
if result[:status] == :success
present result[:release], with: Entities::Release
@@ -98,11 +100,11 @@ module API
requires :tag_name, type: String, desc: 'The name of the tag'
requires :description, type: String, desc: 'Release notes with markdown support'
end
- put ':id/repository/tags/:tag_name/release', requirements: { tag_name: /.+/ } do
+ put ':id/repository/tags/:tag_name/release', requirements: TAG_ENDPOINT_REQUIREMENTS do
authorize_push_project
- result = UpdateReleaseService.new(user_project, current_user).
- execute(params[:tag_name], params[:description])
+ result = UpdateReleaseService.new(user_project, current_user)
+ .execute(params[:tag_name], params[:description])
if result[:status] == :success
present result[:release], with: Entities::Release
diff --git a/lib/api/templates.rb b/lib/api/templates.rb
index 0fc13b35d5b..f70bc0622b7 100644
--- a/lib/api/templates.rb
+++ b/lib/api/templates.rb
@@ -57,7 +57,7 @@ module API
end
get "templates/licenses" do
options = {
- featured: declared(params).popular.present? ? true : nil
+ featured: declared(params)[:popular].present? ? true : nil
}
licences = ::Kaminari.paginate_array(Licensee::License.all(options))
present paginate(licences), with: Entities::RepoLicense
@@ -71,7 +71,7 @@ module API
requires :name, type: String, desc: 'The name of the template'
end
get "templates/licenses/:name", requirements: { name: /[\w\.-]+/ } do
- not_found!('License') unless Licensee::License.find(declared(params).name)
+ not_found!('License') unless Licensee::License.find(declared(params)[:name])
template = parsed_license_template
@@ -102,7 +102,7 @@ module API
requires :name, type: String, desc: 'The name of the template'
end
get "templates/#{template_type}/:name" do
- new_template = klass.find(declared(params).name)
+ new_template = klass.find(declared(params)[:name])
render_response(template_type, new_template)
end
diff --git a/lib/api/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb
index 05b4b490e27..df4632346dd 100644
--- a/lib/api/time_tracking_endpoints.rb
+++ b/lib/api/time_tracking_endpoints.rb
@@ -5,7 +5,7 @@ module API
included do
helpers do
def issuable_name
- declared_params.has_key?(:issue_iid) ? 'issue' : 'merge_request'
+ declared_params.key?(:issue_iid) ? 'issue' : 'merge_request'
end
def issuable_key
diff --git a/lib/api/todos.rb b/lib/api/todos.rb
index d1f7e364029..55191169dd4 100644
--- a/lib/api/todos.rb
+++ b/lib/api/todos.rb
@@ -59,10 +59,10 @@ module API
requires :id, type: Integer, desc: 'The ID of the todo being marked as done'
end
post ':id/mark_as_done' do
- todo = current_user.todos.find(params[:id])
- TodoService.new.mark_todos_as_done([todo], current_user)
+ TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
+ todo = Todo.find(params[:id])
- present todo.reload, with: Entities::Todo, current_user: current_user
+ present todo, with: Entities::Todo, current_user: current_user
end
desc 'Mark all todos as done'
diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb
index a9f2ca2608e..edfdb63d183 100644
--- a/lib/api/triggers.rb
+++ b/lib/api/triggers.rb
@@ -15,24 +15,22 @@ module API
optional :variables, type: Hash, desc: 'The list of variables to be injected into build'
end
post ":id/(ref/:ref/)trigger/pipeline", requirements: { ref: /.+/ } do
- project = find_project(params[:id])
- trigger = Ci::Trigger.find_by_token(params[:token].to_s)
- not_found! unless project && trigger
- unauthorized! unless trigger.project == project
-
# validate variables
- variables = params[:variables].to_h
- unless variables.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
+ params[:variables] = params[:variables].to_h
+ unless params[:variables].all? { |key, value| key.is_a?(String) && value.is_a?(String) }
render_api_error!('variables needs to be a map of key-valued strings', 400)
end
- # create request and trigger builds
- trigger_request = Ci::CreateTriggerRequestService.new.execute(project, trigger, params[:ref].to_s, variables)
- if trigger_request
- present trigger_request.pipeline, with: Entities::Pipeline
+ project = find_project(params[:id])
+ not_found! unless project
+
+ result = Ci::PipelineTriggerService.new(project, nil, params).execute
+ not_found! unless result
+
+ if result[:http_status]
+ render_api_error!(result[:message], result[:http_status])
else
- errors = 'No pipeline created'
- render_api_error!(errors, 400)
+ present result[:pipeline], with: Entities::Pipeline
end
end
@@ -142,6 +140,7 @@ module API
trigger = user_project.triggers.find(params.delete(:trigger_id))
return not_found!('Trigger') unless trigger
+ status 204
trigger.destroy
end
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 40acaebf670..e2019d6d512 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -1,13 +1,15 @@
module API
class Users < Grape::API
include PaginationParams
+ include APIGuard
- before do
- allow_access_with_scope :read_user if request.get?
- authenticate!
- end
+ allow_access_with_scope :read_user, if: -> (request) { request.get? }
resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do
+ before do
+ authenticate_non_get!
+ end
+
helpers do
def find_user(params)
id = params[:user_id] || params[:id]
@@ -29,6 +31,7 @@ module API
optional :can_create_group, type: Boolean, desc: 'Flag indicating the user can create groups'
optional :skip_confirmation, type: Boolean, default: false, desc: 'Flag indicating the account is confirmed'
optional :external, type: Boolean, desc: 'Flag indicating the user is an external user'
+ optional :avatar, type: File, desc: 'Avatar image for user'
all_or_none_of :extern_uid, :provider
end
end
@@ -45,49 +48,48 @@ module API
optional :active, type: Boolean, default: false, desc: 'Filters only active users'
optional :external, type: Boolean, default: false, desc: 'Filters only external users'
optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users'
+ optional :created_after, type: DateTime, desc: 'Return users created after the specified time'
+ optional :created_before, type: DateTime, desc: 'Return users created before the specified time'
all_or_none_of :extern_uid, :provider
use :pagination
end
get do
- unless can?(current_user, :read_users_list)
- render_api_error!("Not authorized.", 403)
+ authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?)
+
+ unless current_user&.admin?
+ params.except!(:created_after, :created_before)
end
- authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?)
+ users = UsersFinder.new(current_user, params).execute
- users = User.all
- users = User.where(username: params[:username]) if params[:username]
- users = users.active if params[:active]
- users = users.search(params[:search]) if params[:search].present?
- users = users.blocked if params[:blocked]
+ authorized = can?(current_user, :read_users_list)
- if current_user.admin?
- users = users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) if params[:extern_uid] && params[:provider]
- users = users.external if params[:external]
- end
+ # When `current_user` is not present, require that the `username`
+ # parameter is passed, to prevent an unauthenticated user from accessing
+ # a list of all the users on the GitLab instance. `UsersFinder` performs
+ # an exact match on the `username` parameter, so we are guaranteed to
+ # get either 0 or 1 `users` here.
+ authorized &&= params[:username].present? if current_user.blank?
+
+ forbidden!("Not authorized to access /api/v4/users") unless authorized
- entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic
+ entity = current_user&.admin? ? Entities::UserWithAdmin : Entities::UserBasic
present paginate(users), with: entity
end
desc 'Get a single user' do
- success Entities::UserBasic
+ success Entities::User
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
end
get ":id" do
user = User.find_by(id: params[:id])
- not_found!('User') unless user
+ not_found!('User') unless user && can?(current_user, :read_user, user)
- if current_user && current_user.admin?
- present user, with: Entities::UserPublic
- elsif can?(current_user, :read_user, user)
- present user, with: Entities::User
- else
- render_api_error!("User not found.", 404)
- end
+ opts = current_user&.admin? ? { with: Entities::UserWithAdmin } : {}
+ present user, opts
end
desc 'Create a user. Available only for admins.' do
@@ -106,18 +108,18 @@ module API
authenticated_as_admin!
params = declared_params(include_missing: false)
- user = ::Users::CreateService.new(current_user, params).execute
+ user = ::Users::CreateService.new(current_user, params).execute(skip_authorization: true)
if user.persisted?
present user, with: Entities::UserPublic
else
- conflict!('Email has already been taken') if User.
- where(email: user.email).
- count > 0
+ conflict!('Email has already been taken') if User
+ .where(email: user.email)
+ .count > 0
- conflict!('Username has already been taken') if User.
- where(username: user.username).
- count > 0
+ conflict!('Username has already been taken') if User
+ .where(username: user.username)
+ .count > 0
render_validation_error!(user)
end
@@ -133,10 +135,6 @@ module API
optional :name, type: String, desc: 'The name of the user'
optional :username, type: String, desc: 'The username of the user'
use :optional_attributes
- at_least_one_of :email, :password, :name, :username, :skype, :linkedin,
- :twitter, :website_url, :organization, :projects_limit,
- :extern_uid, :provider, :bio, :location, :admin,
- :can_create_group, :confirm, :external
end
put ":id" do
authenticated_as_admin!
@@ -145,12 +143,12 @@ module API
not_found!('User') unless user
conflict!('Email has already been taken') if params[:email] &&
- User.where(email: params[:email]).
- where.not(id: user.id).count > 0
+ User.where(email: params[:email])
+ .where.not(id: user.id).count > 0
conflict!('Username has already been taken') if params[:username] &&
- User.where(username: params[:username]).
- where.not(id: user.id).count > 0
+ User.where(username: params[:username])
+ .where.not(id: user.id).count > 0
user_params = declared_params(include_missing: false)
identity_attrs = user_params.slice(:provider, :extern_uid)
@@ -168,7 +166,9 @@ module API
user_params[:password_expires_at] = Time.now if user_params[:password].present?
- if user.update_attributes(user_params.except(:extern_uid, :provider))
+ result = ::Users::UpdateService.new(user, user_params.except(:extern_uid, :provider)).execute
+
+ if result[:status] == :success
present user, with: Entities::UserPublic
else
render_validation_error!(user)
@@ -230,6 +230,7 @@ module API
key = user.keys.find_by(id: params[:key_id])
not_found!('Key') unless key
+ status 204
key.destroy
end
@@ -246,9 +247,9 @@ module API
user = User.find_by(id: params.delete(:id))
not_found!('User') unless user
- email = user.emails.new(declared_params(include_missing: false))
+ email = Emails::CreateService.new(user, declared_params(include_missing: false)).execute
- if email.save
+ if email.errors.blank?
NotificationService.new.new_email(email)
present email, with: Entities::Email
else
@@ -286,8 +287,7 @@ module API
email = user.emails.find_by(id: params[:email_id])
not_found!('Email') unless email
- email.destroy
- user.update_secondary_emails!
+ Emails::DestroyService.new(user, email: email.email).execute
end
desc 'Delete a user. Available only for admins.' do
@@ -295,13 +295,15 @@ module API
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
+ optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions"
end
delete ":id" do
authenticated_as_admin!
user = User.find_by(id: params[:id])
not_found!('User') unless user
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ status 204
+ user.delete_async(deleted_by: current_user, params: params)
end
desc 'Block a user. Available only for admins.'
@@ -336,27 +338,6 @@ module API
end
end
- desc 'Get the contribution events of a specified user' do
- detail 'This feature was introduced in GitLab 8.13.'
- success Entities::Event
- end
- params do
- requires :id, type: Integer, desc: 'The ID of the user'
- use :pagination
- end
- get ':id/events' do
- user = User.find_by(id: params[:id])
- not_found!('User') unless user
-
- events = user.events.
- merge(ProjectsFinder.new(current_user: current_user).execute).
- references(:project).
- with_associations.
- recent
-
- present paginate(events), with: Entities::Event
- end
-
params do
requires :user_id, type: Integer, desc: 'The ID of the user'
end
@@ -422,6 +403,7 @@ module API
requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token'
end
delete ':impersonation_token_id' do
+ status 204
find_impersonation_token.revoke!
end
end
@@ -429,11 +411,24 @@ module API
end
resource :user do
+ before do
+ authenticate!
+ end
+
desc 'Get the currently authenticated user' do
success Entities::UserPublic
end
get do
- present current_user, with: sudo? ? Entities::UserWithPrivateDetails : Entities::UserPublic
+ entity =
+ if sudo?
+ Entities::UserWithPrivateDetails
+ elsif current_user.admin?
+ Entities::UserWithAdmin
+ else
+ Entities::UserPublic
+ end
+
+ present current_user, with: entity
end
desc "Get the currently authenticated user's SSH keys" do
@@ -486,6 +481,7 @@ module API
key = current_user.keys.find_by(id: params[:key_id])
not_found!('Key') unless key
+ status 204
key.destroy
end
@@ -519,9 +515,9 @@ module API
requires :email, type: String, desc: 'The new email'
end
post "emails" do
- email = current_user.emails.new(declared_params)
+ email = Emails::CreateService.new(current_user, declared_params).execute
- if email.save
+ if email.errors.blank?
NotificationService.new.new_email(email)
present email, with: Entities::Email
else
@@ -537,8 +533,8 @@ module API
email = current_user.emails.find_by(id: params[:email_id])
not_found!('Email') unless email
- email.destroy
- current_user.update_secondary_emails!
+ status 204
+ Emails::DestroyService.new(current_user, email: email.email).execute
end
desc 'Get a list of user activities'
@@ -549,9 +545,9 @@ module API
get "activities" do
authenticated_as_admin!
- activities = User.
- where(User.arel_table[:last_activity_on].gteq(params[:from])).
- reorder(last_activity_on: :asc)
+ activities = User
+ .where(User.arel_table[:last_activity_on].gteq(params[:from]))
+ .reorder(last_activity_on: :asc)
present paginate(activities), with: Entities::UserActivity
end
diff --git a/lib/api/v3/branches.rb b/lib/api/v3/branches.rb
index 0a877b960f6..81b13249892 100644
--- a/lib/api/v3/branches.rb
+++ b/lib/api/v3/branches.rb
@@ -26,8 +26,8 @@ module API
delete ":id/repository/branches/:branch", requirements: { branch: /.+/ } do
authorize_push_project
- result = DeleteBranchService.new(user_project, current_user).
- execute(params[:branch])
+ result = DeleteBranchService.new(user_project, current_user)
+ .execute(params[:branch])
if result[:status] == :success
status(200)
@@ -55,8 +55,8 @@ module API
end
post ":id/repository/branches" do
authorize_push_project
- result = CreateBranchService.new(user_project, current_user).
- execute(params[:branch_name], params[:ref])
+ result = CreateBranchService.new(user_project, current_user)
+ .execute(params[:branch_name], params[:ref])
if result[:status] == :success
present result[:branch],
diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb
index 4dd03cdf24b..c189d486f50 100644
--- a/lib/api/v3/builds.rb
+++ b/lib/api/v3/builds.rb
@@ -16,7 +16,7 @@ module API
coerce_with: ->(scope) {
if scope.is_a?(String)
[scope]
- elsif scope.is_a?(Hashie::Mash)
+ elsif scope.is_a?(::Hash)
scope.values
else
['unknown']
@@ -134,6 +134,7 @@ module API
authorize_update_builds!
build = get_build!(params[:build_id])
+ authorize!(:update_build, build)
build.cancel
@@ -150,6 +151,7 @@ module API
authorize_update_builds!
build = get_build!(params[:build_id])
+ authorize!(:update_build, build)
return forbidden!('Build is not retryable') unless build.retryable?
build = Ci::Build.retry(build, current_user)
@@ -167,6 +169,7 @@ module API
authorize_update_builds!
build = get_build!(params[:build_id])
+ authorize!(:update_build, build)
return forbidden!('Build is not erasable!') unless build.erasable?
build.erase(erased_by: current_user)
@@ -183,6 +186,7 @@ module API
authorize_update_builds!
build = get_build!(params[:build_id])
+ authorize!(:update_build, build)
return not_found!(build) unless build.artifacts?
build.keep_artifacts!
@@ -202,7 +206,7 @@ module API
authorize_read_builds!
build = get_build!(params[:build_id])
-
+ authorize!(:update_build, build)
bad_request!("Unplayable Job") unless build.playable?
build.play(current_user)
@@ -213,22 +217,12 @@ module API
end
helpers do
- def get_build(id)
+ def find_build(id)
user_project.builds.find_by(id: id.to_i)
end
def get_build!(id)
- get_build(id) || not_found!
- end
-
- def present_artifacts!(artifacts_file)
- if !artifacts_file.file_storage?
- redirect_to(build.artifacts_file.url)
- elsif artifacts_file.exists?
- present_file!(artifacts_file.path, artifacts_file.filename)
- else
- not_found!
- end
+ find_build(id) || not_found!
end
def filter_builds(builds, scope)
diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb
index 674de592f0a..5936f4700aa 100644
--- a/lib/api/v3/commits.rb
+++ b/lib/api/v3/commits.rb
@@ -167,7 +167,7 @@ module API
}
if params[:path]
- commit.raw_diffs(all_diffs: true).each do |diff|
+ commit.raw_diffs(limits: false).each do |diff|
next unless diff.new_path == params[:path]
lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line)
diff --git a/lib/api/v3/deploy_keys.rb b/lib/api/v3/deploy_keys.rb
index bbb174b6003..b90e2061da3 100644
--- a/lib/api/v3/deploy_keys.rb
+++ b/lib/api/v3/deploy_keys.rb
@@ -41,6 +41,7 @@ module API
params do
requires :key, type: String, desc: 'The new deploy key'
requires :title, type: String, desc: 'The name of the deploy key'
+ optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository"
end
post ":id/#{path}" do
params[:key].strip!
diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb
index 832b4bdeb4f..a9a35f2a4bd 100644
--- a/lib/api/v3/entities.rb
+++ b/lib/api/v3/entities.rb
@@ -25,14 +25,24 @@ module API
expose(:downvote?) { |note| false }
end
+ class PushEventPayload < Grape::Entity
+ expose :commit_count, :action, :ref_type, :commit_from, :commit_to
+ expose :ref, :commit_title
+ end
+
class Event < Grape::Entity
expose :title, :project_id, :action_name
expose :target_id, :target_type, :author_id
- expose :data, :target_title
+ expose :target_title
expose :created_at
expose :note, using: Entities::Note, if: ->(event, options) { event.note? }
expose :author, using: ::API::Entities::UserBasic, if: ->(event, options) { event.author }
+ expose :push_event_payload,
+ as: :push_data,
+ using: PushEventPayload,
+ if: -> (event, _) { event.push? }
+
expose :author_username do |event, options|
event.author&.username
end
@@ -68,8 +78,10 @@ module API
expose :lfs_enabled?, as: :lfs_enabled
expose :creator_id
expose :namespace, using: 'API::Entities::Namespace'
- expose :forked_from_project, using: ::API::Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? }
- expose :avatar_url
+ expose :forked_from_project, using: ::API::Entities::BasicProjectDetails, if: lambda { |project, options| project.forked? }
+ expose :avatar_url do |user, options|
+ user.avatar_url(only_path: false)
+ end
expose :star_count, :forks_count
expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? }
expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] }
@@ -129,11 +141,16 @@ module API
class Group < Grape::Entity
expose :id, :name, :path, :description, :visibility_level
expose :lfs_enabled?, as: :lfs_enabled
- expose :avatar_url
+ expose :avatar_url do |user, options|
+ user.avatar_url(only_path: false)
+ end
expose :web_url
expose :request_access_enabled
expose :full_name, :full_path
- expose :parent_id
+
+ if ::Group.supports_nested_groups?
+ expose :parent_id
+ end
expose :statistics, if: :statistics do
with_options format_with: -> (value) { value.to_i } do
@@ -154,7 +171,8 @@ module API
expose :id
expose :default_projects_limit
expose :signup_enabled
- expose :signin_enabled
+ expose :password_authentication_enabled
+ expose :password_authentication_enabled, as: :signin_enabled
expose :gravatar_enabled
expose :sign_in_text
expose :after_sign_up_text
@@ -219,7 +237,7 @@ module API
class MergeRequestChanges < MergeRequest
expose :diffs, as: :changes, using: ::API::Entities::RepoDiff do |compare, _|
- compare.raw_diffs(all_diffs: true).to_a
+ compare.raw_diffs(limits: false).to_a
end
end
@@ -234,19 +252,57 @@ module API
class ProjectService < Grape::Entity
expose :id, :title, :created_at, :updated_at, :active
expose :push_events, :issues_events, :merge_requests_events
- expose :tag_push_events, :note_events, :build_events, :pipeline_events
+ expose :tag_push_events, :note_events, :pipeline_events
+ expose :job_events, as: :build_events
# Expose serialized properties
expose :properties do |service, options|
- field_names = service.fields.
- select { |field| options[:include_passwords] || field[:type] != 'password' }.
- map { |field| field[:name] }
+ field_names = service.fields
+ .select { |field| options[:include_passwords] || field[:type] != 'password' }
+ .map { |field| field[:name] }
service.properties.slice(*field_names)
end
end
class ProjectHook < ::API::Entities::Hook
expose :project_id, :issues_events, :merge_requests_events
- expose :note_events, :build_events, :pipeline_events, :wiki_page_events
+ expose :note_events, :pipeline_events, :wiki_page_events
+ expose :job_events, as: :build_events
+ end
+
+ class ProjectEntity < Grape::Entity
+ expose :id, :iid
+ expose(:project_id) { |entity| entity&.project.try(:id) }
+ expose :title, :description
+ expose :state, :created_at, :updated_at
+ end
+
+ class IssueBasic < ProjectEntity
+ expose :label_names, as: :labels
+ expose :milestone, using: ::API::Entities::Milestone
+ expose :assignees, :author, using: ::API::Entities::UserBasic
+
+ expose :assignee, using: ::API::Entities::UserBasic do |issue, options|
+ issue.assignees.first
+ end
+
+ expose :user_notes_count
+ expose :upvotes, :downvotes
+ expose :due_date
+ expose :confidential
+
+ expose :web_url do |issue, options|
+ Gitlab::UrlBuilder.build(issue)
+ end
+ end
+
+ class Issue < IssueBasic
+ unexpose :assignees
+ expose :assignee do |issue, options|
+ ::API::Entities::UserBasic.represent(issue.assignees.first, options)
+ end
+ expose :subscribed do |issue, options|
+ issue.subscribed?(options[:current_user], options[:project] || issue.project)
+ end
end
end
end
diff --git a/lib/api/v3/files.rb b/lib/api/v3/files.rb
index c76acc86504..7b4b3448b6d 100644
--- a/lib/api/v3/files.rb
+++ b/lib/api/v3/files.rb
@@ -56,7 +56,7 @@ module API
blob = repo.blob_at(commit.sha, params[:file_path])
not_found!('File') unless blob
- blob.load_all_data!(repo)
+ blob.load_all_data!
status(200)
{
diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb
index 63d464b926b..2c52d21fa1c 100644
--- a/lib/api/v3/groups.rb
+++ b/lib/api/v3/groups.rb
@@ -20,7 +20,7 @@ module API
def present_groups(groups, options = {})
options = options.reverse_merge(
with: Entities::Group,
- current_user: current_user,
+ current_user: current_user
)
groups = groups.with_statistics if options[:statistics]
@@ -45,7 +45,7 @@ module API
groups = if current_user.admin
Group.all
elsif params[:all_available]
- GroupsFinder.new.execute(current_user)
+ GroupsFinder.new(current_user).execute
else
current_user.groups
end
@@ -74,7 +74,11 @@ module API
params do
requires :name, type: String, desc: 'The name of the group'
requires :path, type: String, desc: 'The path of the group'
- optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group'
+
+ if ::Group.supports_nested_groups?
+ optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group'
+ end
+
use :optional_params
end
post do
diff --git a/lib/api/v3/helpers.rb b/lib/api/v3/helpers.rb
index 0f234d4cdad..4e63aa01c1a 100644
--- a/lib/api/v3/helpers.rb
+++ b/lib/api/v3/helpers.rb
@@ -14,6 +14,36 @@ module API
authorize! access_level, merge_request
merge_request
end
+
+ # project helpers
+
+ def filter_projects(projects)
+ if params[:membership]
+ projects = projects.merge(current_user.authorized_projects)
+ end
+
+ if params[:owned]
+ projects = projects.merge(current_user.owned_projects)
+ end
+
+ if params[:starred]
+ projects = projects.merge(current_user.starred_projects)
+ end
+
+ if params[:search].present?
+ projects = projects.search(params[:search])
+ end
+
+ if params[:visibility].present?
+ projects = projects.where(visibility_level: Gitlab::VisibilityLevel.level_value(params[:visibility]))
+ end
+
+ unless params[:archived].nil?
+ projects = projects.where(archived: to_boolean(params[:archived]))
+ end
+
+ projects.reorder(params[:order_by] => params[:sort])
+ end
end
end
end
diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb
index 715083fc4f8..cb371fdbab8 100644
--- a/lib/api/v3/issues.rb
+++ b/lib/api/v3/issues.rb
@@ -8,6 +8,7 @@ module API
helpers do
def find_issues(args = {})
args = params.merge(args)
+ args = convert_parameters_from_legacy_format(args)
args.delete(:id)
args[:milestone_title] = args.delete(:milestone)
@@ -51,7 +52,7 @@ module API
resource :issues do
desc "Get currently authenticated user's issues" do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
optional :state, type: String, values: %w[opened closed all], default: 'all',
@@ -61,7 +62,7 @@ module API
get do
issues = find_issues(scope: 'authored')
- present paginate(issues), with: ::API::Entities::Issue, current_user: current_user
+ present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user
end
end
@@ -70,7 +71,7 @@ module API
end
resource :groups, requirements: { id: %r{[^/]+} } do
desc 'Get a list of group issues' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
optional :state, type: String, values: %w[opened closed all], default: 'all',
@@ -82,7 +83,7 @@ module API
issues = find_issues(group_id: group.id, match_all_labels: true)
- present paginate(issues), with: ::API::Entities::Issue, current_user: current_user
+ present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user
end
end
@@ -94,7 +95,7 @@ module API
desc 'Get a list of project issues' do
detail 'iid filter is deprecated have been removed on V4'
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
optional :state, type: String, values: %w[opened closed all], default: 'all',
@@ -107,22 +108,22 @@ module API
issues = find_issues(project_id: project.id)
- present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end
desc 'Get a single project issue' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue'
end
get ":id/issues/:issue_id" do
issue = find_project_issue(params[:issue_id])
- present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end
desc 'Create a new project issue' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
requires :title, type: String, desc: 'The title of an issue'
@@ -140,6 +141,7 @@ module API
issue_params = declared_params(include_missing: false)
issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions))
+ issue_params = convert_parameters_from_legacy_format(issue_params)
issue = ::Issues::CreateService.new(user_project,
current_user,
@@ -147,14 +149,14 @@ module API
render_spam_error! if issue.spam?
if issue.valid?
- present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
end
end
desc 'Update an existing issue' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue'
@@ -176,6 +178,7 @@ module API
end
update_params = declared_params(include_missing: false).merge(request: request, api: true)
+ update_params = convert_parameters_from_legacy_format(update_params)
issue = ::Issues::UpdateService.new(user_project,
current_user,
@@ -184,14 +187,14 @@ module API
render_spam_error! if issue.spam?
if issue.valid?
- present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
end
end
desc 'Move an existing issue' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
requires :issue_id, type: Integer, desc: 'The ID of a project issue'
@@ -206,7 +209,7 @@ module API
begin
issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project)
- present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
rescue ::Issues::MoveService::MoveError => error
render_api_error!(error.message, 400)
end
diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb
index 3077240e650..b6b7254ae29 100644
--- a/lib/api/v3/merge_requests.rb
+++ b/lib/api/v3/merge_requests.rb
@@ -23,6 +23,8 @@ module API
error!(errors[:validate_fork], 422)
elsif errors[:validate_branches].any?
conflict!(errors[:validate_branches])
+ elsif errors[:base].any?
+ error!(errors[:base], 422)
end
render_api_error!(errors, 400)
@@ -32,7 +34,7 @@ module API
if project.has_external_issue_tracker?
::API::Entities::ExternalIssue
else
- ::API::Entities::Issue
+ ::API::V3::Entities::Issue
end
end
diff --git a/lib/api/v3/milestones.rb b/lib/api/v3/milestones.rb
index be90cec4afc..4c7061d4939 100644
--- a/lib/api/v3/milestones.rb
+++ b/lib/api/v3/milestones.rb
@@ -39,7 +39,7 @@ module API
end
desc 'Get all issues for a single project milestone' do
- success ::API::Entities::Issue
+ success ::API::V3::Entities::Issue
end
params do
requires :milestone_id, type: Integer, desc: 'The ID of a project milestone'
@@ -56,7 +56,7 @@ module API
}
issues = IssuesFinder.new(current_user, finder_params).execute
- present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project
+ present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project
end
end
end
diff --git a/lib/api/v3/notes.rb b/lib/api/v3/notes.rb
index 009ec5c6bbd..d49772b92f2 100644
--- a/lib/api/v3/notes.rb
+++ b/lib/api/v3/notes.rb
@@ -22,7 +22,7 @@ module API
use :pagination
end
get ":id/#{noteables_str}/:noteable_id/notes" do
- noteable = user_project.send(noteables_str.to_sym).find(params[:noteable_id])
+ noteable = user_project.public_send(noteables_str.to_sym).find(params[:noteable_id]) # rubocop:disable GitlabSecurity/PublicSend
if can?(current_user, noteable_read_ability_name(noteable), noteable)
# We exclude notes that are cross-references and that cannot be viewed
@@ -34,8 +34,8 @@ module API
# paginate() only works with a relation. This could lead to a
# mismatch between the pagination headers info and the actual notes
# array returned, but this is really a edge-case.
- paginate(noteable.notes).
- reject { |n| n.cross_reference_not_visible_for?(current_user) }
+ paginate(noteable.notes)
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
present notes, with: ::API::V3::Entities::Note
else
not_found!("Notes")
@@ -50,7 +50,7 @@ module API
requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
end
get ":id/#{noteables_str}/:noteable_id/notes/:note_id" do
- noteable = user_project.send(noteables_str.to_sym).find(params[:noteable_id])
+ noteable = user_project.public_send(noteables_str.to_sym).find(params[:noteable_id]) # rubocop:disable GitlabSecurity/PublicSend
note = noteable.notes.find(params[:note_id])
can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user)
@@ -76,7 +76,7 @@ module API
noteable_id: params[:noteable_id]
}
- noteable = user_project.send(noteables_str.to_sym).find(params[:noteable_id])
+ noteable = user_project.public_send(noteables_str.to_sym).find(params[:noteable_id]) # rubocop:disable GitlabSecurity/PublicSend
if can?(current_user, noteable_read_ability_name(noteable), noteable)
if params[:created_at] && (current_user.admin? || user_project.owner == current_user)
diff --git a/lib/api/v3/pipelines.rb b/lib/api/v3/pipelines.rb
index 82827249244..c48cbd2b765 100644
--- a/lib/api/v3/pipelines.rb
+++ b/lib/api/v3/pipelines.rb
@@ -21,7 +21,7 @@ module API
get ':id/pipelines' do
authorize! :read_pipeline, user_project
- pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope])
+ pipelines = PipelinesFinder.new(user_project, scope: params[:scope]).execute
present paginate(pipelines), with: ::API::Entities::Pipeline
end
end
diff --git a/lib/api/v3/project_hooks.rb b/lib/api/v3/project_hooks.rb
index 94614bfc8b6..51014591a93 100644
--- a/lib/api/v3/project_hooks.rb
+++ b/lib/api/v3/project_hooks.rb
@@ -56,7 +56,9 @@ module API
use :project_hook_properties
end
post ":id/hooks" do
- hook = user_project.hooks.new(declared_params(include_missing: false))
+ attrs = declared_params(include_missing: false)
+ attrs[:job_events] = attrs.delete(:build_events) if attrs.key?(:build_events)
+ hook = user_project.hooks.new(attrs)
if hook.save
present hook, with: ::API::V3::Entities::ProjectHook
@@ -77,7 +79,9 @@ module API
put ":id/hooks/:hook_id" do
hook = user_project.hooks.find(params.delete(:hook_id))
- if hook.update_attributes(declared_params(include_missing: false))
+ attrs = declared_params(include_missing: false)
+ attrs[:job_events] = attrs.delete(:build_events) if attrs.key?(:build_events)
+ if hook.update_attributes(attrs)
present hook, with: ::API::V3::Entities::ProjectHook
else
error!("Invalid url given", 422) if hook.errors[:url].present?
diff --git a/lib/api/v3/project_snippets.rb b/lib/api/v3/project_snippets.rb
index fc065a22d74..c41fee32610 100644
--- a/lib/api/v3/project_snippets.rb
+++ b/lib/api/v3/project_snippets.rb
@@ -18,8 +18,7 @@ module API
end
def snippets_for_current_user
- finder_params = { filter: :by_project, project: user_project }
- SnippetsFinder.new.execute(current_user, finder_params)
+ SnippetsFinder.new(current_user, project: user_project).execute
end
end
diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb
index ba9748ada59..449876c10d9 100644
--- a/lib/api/v3/projects.rb
+++ b/lib/api/v3/projects.rb
@@ -44,7 +44,7 @@ module API
end
def set_only_allow_merge_if_pipeline_succeeds!
- if params.has_key?(:only_allow_merge_if_build_succeeds)
+ if params.key?(:only_allow_merge_if_build_succeeds)
params[:only_allow_merge_if_pipeline_succeeds] = params.delete(:only_allow_merge_if_build_succeeds)
end
end
@@ -69,7 +69,7 @@ module API
end
params :filter_params do
- optional :archived, type: Boolean, default: false, desc: 'Limit by archived status'
+ optional :archived, type: Boolean, default: nil, desc: 'Limit by archived status'
optional :visibility, type: String, values: %w[public internal private],
desc: 'Limit by visibility'
optional :search, type: String, desc: 'Return list of authorized projects matching the search criteria'
@@ -88,7 +88,7 @@ module API
options = options.reverse_merge(
with: ::API::V3::Entities::Project,
current_user: current_user,
- simple: params[:simple],
+ simple: params[:simple]
)
projects = filter_projects(projects)
@@ -147,7 +147,7 @@ module API
get '/starred' do
authenticate!
- present_projects current_user.viewable_starred_projects
+ present_projects ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
end
desc 'Get all projects for admin user' do
@@ -388,6 +388,8 @@ module API
if user_project.forked_from_project.nil?
user_project.create_forked_project_link(forked_to_project_id: user_project.id, forked_from_project_id: forked_from_project.id)
+
+ ::Projects::ForksCountService.new(forked_from_project).refresh_cache
else
render_api_error!("Project already forked", 409)
end
@@ -452,7 +454,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded'
end
post ":id/uploads" do
- ::Projects::UploadService.new(user_project, params[:file]).execute
+ UploadService.new(user_project, params[:file]).execute
end
desc 'Get the users list of a project' do
diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb
index e4d14bc8168..0eaa0de2eef 100644
--- a/lib/api/v3/repositories.rb
+++ b/lib/api/v3/repositories.rb
@@ -72,7 +72,7 @@ module API
optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded'
optional :format, type: String, desc: 'The archive format'
end
- get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do
+ get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do
begin
send_git_archive user_project.repository, ref: params[:sha], format: params[:format]
rescue
diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb
index 61629a04174..2d13d6fabfd 100644
--- a/lib/api/v3/services.rb
+++ b/lib/api/v3/services.rb
@@ -377,7 +377,7 @@ module API
name: :ca_pem,
type: String,
desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)'
- },
+ }
],
'mattermost-slash-commands' => [
{
@@ -608,7 +608,7 @@ module API
trigger_services.each do |service_slug, settings|
helpers do
- def chat_command_service(project, service_slug, params)
+ def slash_command_service(project, service_slug, params)
project.services.active.where(template: false).find do |service|
service.try(:token) == params[:token] && service.to_param == service_slug.underscore
end
@@ -633,7 +633,7 @@ module API
# This is not accurate, but done to prevent leakage of the project names
not_found!('Service') unless project
- service = chat_command_service(project, service_slug, params)
+ service = slash_command_service(project, service_slug, params)
result = service.try(:trigger, params)
if result
diff --git a/lib/api/v3/settings.rb b/lib/api/v3/settings.rb
index 748d6b97d4f..202011cfcbe 100644
--- a/lib/api/v3/settings.rb
+++ b/lib/api/v3/settings.rb
@@ -44,7 +44,9 @@ module API
requires :domain_blacklist, type: String, desc: 'Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com'
end
optional :after_sign_up_text, type: String, desc: 'Text shown after sign up'
- optional :signin_enabled, type: Boolean, desc: 'Flag indicating if sign in is enabled'
+ optional :password_authentication_enabled, type: Boolean, desc: 'Flag indicating if password authentication is enabled'
+ optional :signin_enabled, type: Boolean, desc: 'Flag indicating if password authentication is enabled'
+ mutually_exclusive :password_authentication_enabled, :signin_enabled
optional :require_two_factor_authentication, type: Boolean, desc: 'Require all users to setup Two-factor authentication'
given require_two_factor_authentication: ->(val) { val } do
requires :two_factor_grace_period, type: Integer, desc: 'Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication'
@@ -116,7 +118,7 @@ module API
:max_attachment_size, :session_expire_delay, :disabled_oauth_sign_in_sources,
:user_oauth_applications, :user_default_external, :signup_enabled,
:send_user_confirmation_email, :domain_whitelist, :domain_blacklist_enabled,
- :after_sign_up_text, :signin_enabled, :require_two_factor_authentication,
+ :after_sign_up_text, :password_authentication_enabled, :signin_enabled, :require_two_factor_authentication,
:home_page_url, :after_sign_out_path, :sign_in_text, :help_page_text,
:shared_runners_enabled, :max_artifacts_size, :max_pages_size, :container_registry_token_expire_delay,
:metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled,
@@ -126,7 +128,13 @@ module API
:housekeeping_enabled, :terminal_max_session_time
end
put "application/settings" do
- if current_settings.update_attributes(declared_params(include_missing: false))
+ attrs = declared_params(include_missing: false)
+
+ if attrs.has_key?(:signin_enabled)
+ attrs[:password_authentication_enabled] = attrs.delete(:signin_enabled)
+ end
+
+ if current_settings.update_attributes(attrs)
present current_settings, with: Entities::ApplicationSetting
else
render_validation_error!(current_settings)
diff --git a/lib/api/v3/snippets.rb b/lib/api/v3/snippets.rb
index 07dac7e9904..0762fc02d70 100644
--- a/lib/api/v3/snippets.rb
+++ b/lib/api/v3/snippets.rb
@@ -8,11 +8,11 @@ module API
resource :snippets do
helpers do
def snippets_for_current_user
- SnippetsFinder.new.execute(current_user, filter: :by_user, user: current_user)
+ SnippetsFinder.new(current_user, author: current_user).execute
end
def public_snippets
- SnippetsFinder.new.execute(current_user, filter: :public)
+ SnippetsFinder.new(current_user, visibility: Snippet::PUBLIC).execute
end
end
diff --git a/lib/api/v3/subscriptions.rb b/lib/api/v3/subscriptions.rb
index 068750ec077..690768db82f 100644
--- a/lib/api/v3/subscriptions.rb
+++ b/lib/api/v3/subscriptions.rb
@@ -7,7 +7,7 @@ module API
'merge_request' => proc { |id| find_merge_request_with_access(id, :update_merge_request) },
'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) },
'issues' => proc { |id| find_project_issue(id) },
- 'labels' => proc { |id| find_project_label(id) },
+ 'labels' => proc { |id| find_project_label(id) }
}
params do
diff --git a/lib/api/v3/tags.rb b/lib/api/v3/tags.rb
index c2541de2f50..7e5875cd030 100644
--- a/lib/api/v3/tags.rb
+++ b/lib/api/v3/tags.rb
@@ -22,8 +22,8 @@ module API
delete ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do
authorize_push_project
- result = ::Tags::DestroyService.new(user_project, current_user).
- execute(params[:tag_name])
+ result = ::Tags::DestroyService.new(user_project, current_user)
+ .execute(params[:tag_name])
if result[:status] == :success
status(200)
diff --git a/lib/api/v3/templates.rb b/lib/api/v3/templates.rb
index 4c577a8d2b7..2a2fb59045c 100644
--- a/lib/api/v3/templates.rb
+++ b/lib/api/v3/templates.rb
@@ -59,7 +59,7 @@ module API
end
get route do
options = {
- featured: declared(params).popular.present? ? true : nil
+ featured: declared(params)[:popular].present? ? true : nil
}
present Licensee::License.all(options), with: ::API::Entities::RepoLicense
end
@@ -76,7 +76,7 @@ module API
requires :name, type: String, desc: 'The name of the template'
end
get route, requirements: { name: /[\w\.-]+/ } do
- not_found!('License') unless Licensee::License.find(declared(params).name)
+ not_found!('License') unless Licensee::License.find(declared(params)[:name])
template = parsed_license_template
@@ -111,7 +111,7 @@ module API
requires :name, type: String, desc: 'The name of the template'
end
get route do
- new_template = klass.find(declared(params).name)
+ new_template = klass.find(declared(params)[:name])
render_response(template_type, new_template)
end
diff --git a/lib/api/v3/time_tracking_endpoints.rb b/lib/api/v3/time_tracking_endpoints.rb
index 81ae4e8137d..d5b90e435ba 100644
--- a/lib/api/v3/time_tracking_endpoints.rb
+++ b/lib/api/v3/time_tracking_endpoints.rb
@@ -6,7 +6,7 @@ module API
included do
helpers do
def issuable_name
- declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request'
+ declared_params.key?(:issue_id) ? 'issue' : 'merge_request'
end
def issuable_key
diff --git a/lib/api/v3/todos.rb b/lib/api/v3/todos.rb
index e3b311d61cd..2f2cf259987 100644
--- a/lib/api/v3/todos.rb
+++ b/lib/api/v3/todos.rb
@@ -11,10 +11,10 @@ module API
requires :id, type: Integer, desc: 'The ID of the todo being marked as done'
end
delete ':id' do
- todo = current_user.todos.find(params[:id])
- TodoService.new.mark_todos_as_done([todo], current_user)
+ TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
+ todo = Todo.find(params[:id])
- present todo.reload, with: ::API::Entities::Todo, current_user: current_user
+ present todo, with: ::API::Entities::Todo, current_user: current_user
end
desc 'Mark all todos as done'
diff --git a/lib/api/v3/triggers.rb b/lib/api/v3/triggers.rb
index a23d6b6b48c..e9d4c35307b 100644
--- a/lib/api/v3/triggers.rb
+++ b/lib/api/v3/triggers.rb
@@ -28,12 +28,13 @@ module API
end
# create request and trigger builds
- trigger_request = Ci::CreateTriggerRequestService.new.execute(project, trigger, params[:ref].to_s, variables)
- if trigger_request
- present trigger_request, with: ::API::V3::Entities::TriggerRequest
+ result = Ci::CreateTriggerRequestService.execute(project, trigger, params[:ref].to_s, variables)
+ pipeline = result.pipeline
+
+ if pipeline.persisted?
+ present result.trigger_request, with: ::API::V3::Entities::TriggerRequest
else
- errors = 'No builds created'
- render_api_error!(errors, 400)
+ render_validation_error!(pipeline)
end
end
diff --git a/lib/api/v3/users.rb b/lib/api/v3/users.rb
index f4cda3b2eba..cf106f2552d 100644
--- a/lib/api/v3/users.rb
+++ b/lib/api/v3/users.rb
@@ -2,9 +2,11 @@ module API
module V3
class Users < Grape::API
include PaginationParams
+ include APIGuard
+
+ allow_access_with_scope :read_user, if: -> (request) { request.get? }
before do
- allow_access_with_scope :read_user if request.get?
authenticate!
end
@@ -50,13 +52,13 @@ module API
if user.persisted?
present user, with: ::API::Entities::UserPublic
else
- conflict!('Email has already been taken') if User.
- where(email: user.email).
- count > 0
+ conflict!('Email has already been taken') if User
+ .where(email: user.email)
+ .count > 0
- conflict!('Username has already been taken') if User.
- where(username: user.username).
- count > 0
+ conflict!('Username has already been taken') if User
+ .where(username: user.username)
+ .count > 0
render_validation_error!(user)
end
@@ -137,11 +139,11 @@ module API
user = User.find_by(id: params[:id])
not_found!('User') unless user
- events = user.events.
- merge(ProjectsFinder.new(current_user: current_user).execute).
- references(:project).
- with_associations.
- recent
+ events = user.events
+ .merge(ProjectsFinder.new(current_user: current_user).execute)
+ .references(:project)
+ .with_associations
+ .recent
present paginate(events), with: ::API::V3::Entities::Event
end
diff --git a/lib/api/variables.rb b/lib/api/variables.rb
index 5acde41551b..7c0fdd3d1be 100644
--- a/lib/api/variables.rb
+++ b/lib/api/variables.rb
@@ -42,9 +42,12 @@ module API
params do
requires :key, type: String, desc: 'The key of the variable'
requires :value, type: String, desc: 'The value of the variable'
+ optional :protected, type: String, desc: 'Whether the variable is protected'
end
post ':id/variables' do
- variable = user_project.variables.create(declared(params, include_parent_namespaces: false).to_h)
+ variable_params = declared_params(include_missing: false)
+
+ variable = user_project.variables.create(variable_params)
if variable.valid?
present variable, with: Entities::Variable
@@ -59,13 +62,16 @@ module API
params do
optional :key, type: String, desc: 'The key of the variable'
optional :value, type: String, desc: 'The value of the variable'
+ optional :protected, type: String, desc: 'Whether the variable is protected'
end
put ':id/variables/:key' do
variable = user_project.variables.find_by(key: params[:key])
return not_found!('Variable') unless variable
- if variable.update(value: params[:value])
+ variable_params = declared_params(include_missing: false).except(:key)
+
+ if variable.update(variable_params)
present variable, with: Entities::Variable
else
render_validation_error!(variable)
@@ -82,6 +88,7 @@ module API
variable = user_project.variables.find_by(key: params[:key])
not_found!('Variable') unless variable
+ status 204
variable.destroy
end
end
diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb
index 51fa3867e67..1f4bda6f588 100644
--- a/lib/backup/artifacts.rb
+++ b/lib/backup/artifacts.rb
@@ -3,7 +3,7 @@ require 'backup/files'
module Backup
class Artifacts < Files
def initialize
- super('artifacts', ArtifactUploader.artifacts_path)
+ super('artifacts', ArtifactUploader.local_artifacts_store)
end
def create_files_dir
diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb
index 330cd963626..b9a573d3542 100644
--- a/lib/backup/manager.rb
+++ b/lib/backup/manager.rb
@@ -8,18 +8,9 @@ module Backup
# Make sure there is a connection
ActiveRecord::Base.connection.reconnect!
- # saving additional informations
- s = {}
- s[:db_version] = "#{ActiveRecord::Migrator.current_version}"
- s[:backup_created_at] = Time.now
- s[:gitlab_version] = Gitlab::VERSION
- s[:tar_version] = tar_version
- s[:skipped] = ENV["SKIP"]
- tar_file = "#{s[:backup_created_at].strftime('%s_%Y_%m_%d_')}#{s[:gitlab_version]}#{FILE_NAME_SUFFIX}"
-
Dir.chdir(backup_path) do
File.open("#{backup_path}/backup_information.yml", "w+") do |file|
- file << s.to_yaml.gsub(/^---\n/, '')
+ file << backup_information.to_yaml.gsub(/^---\n/, '')
end
# create archive
@@ -33,11 +24,11 @@ module Backup
abort 'Backup failed'
end
- upload(tar_file)
+ upload
end
end
- def upload(tar_file)
+ def upload
$progress.print "Uploading backup archive to remote storage #{remote_directory} ... "
connection_settings = Gitlab.config.backup.upload.connection
@@ -48,7 +39,7 @@ module Backup
directory = connect_to_remote_directory(connection_settings)
- if directory.files.create(key: tar_file, body: File.open(tar_file), public: false,
+ if directory.files.create(key: remote_target, body: File.open(tar_file), public: false,
multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
encryption: Gitlab.config.backup.upload.encryption,
storage_class: Gitlab.config.backup.upload.storage_class)
@@ -84,7 +75,11 @@ module Backup
Dir.chdir(backup_path) do
backup_file_list.each do |file|
- next unless file =~ /(\d+)(?:_\d{4}_\d{2}_\d{2})?_gitlab_backup\.tar/
+ # For backward compatibility, there are 3 names the backups can have:
+ # - 1495527122_gitlab_backup.tar
+ # - 1495527068_2017_05_23_gitlab_backup.tar
+ # - 1495527097_2017_05_23_9.3.0-pre_gitlab_backup.tar
+ next unless file =~ /(\d+)(?:_\d{4}_\d{2}_\d{2}(_\d+\.\d+\.\d+.*)?)?_gitlab_backup\.tar$/
timestamp = $1.to_i
@@ -173,7 +168,8 @@ module Backup
end
def connect_to_remote_directory(connection_settings)
- connection = ::Fog::Storage.new(connection_settings)
+ # our settings use string keys, but Fog expects symbols
+ connection = ::Fog::Storage.new(connection_settings.symbolize_keys)
# We only attempt to create the directory for local backups. For AWS
# and other cloud providers, we cannot guarantee the user will have
@@ -189,16 +185,24 @@ module Backup
Gitlab.config.backup.upload.remote_directory
end
+ def remote_target
+ if ENV['DIRECTORY']
+ File.join(ENV['DIRECTORY'], tar_file)
+ else
+ tar_file
+ end
+ end
+
def backup_contents
folders_to_backup + archives_to_backup + ["backup_information.yml"]
end
def archives_to_backup
- ARCHIVES_TO_BACKUP.map{ |name| (name + ".tar.gz") unless skipped?(name) }.compact
+ ARCHIVES_TO_BACKUP.map { |name| (name + ".tar.gz") unless skipped?(name) }.compact
end
def folders_to_backup
- FOLDERS_TO_BACKUP.reject{ |name| skipped?(name) }
+ FOLDERS_TO_BACKUP.reject { |name| skipped?(name) }
end
def disabled_features
@@ -210,5 +214,19 @@ module Backup
def settings
@settings ||= YAML.load_file("backup_information.yml")
end
+
+ def tar_file
+ @tar_file ||= "#{backup_information[:backup_created_at].strftime('%s_%Y_%m_%d_')}#{backup_information[:gitlab_version]}#{FILE_NAME_SUFFIX}"
+ end
+
+ def backup_information
+ @backup_information ||= {
+ db_version: ActiveRecord::Migrator.current_version.to_s,
+ backup_created_at: Time.now,
+ gitlab_version: Gitlab::VERSION,
+ tar_version: tar_version,
+ skipped: ENV["SKIP"]
+ }
+ end
end
end
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
index 6b29600a751..88821ae56e0 100644
--- a/lib/backup/repository.rb
+++ b/lib/backup/repository.rb
@@ -7,15 +7,15 @@ module Backup
prepare
Project.find_each(batch_size: 1000) do |project|
- $progress.print " * #{project.path_with_namespace} ... "
+ progress.print " * #{project.full_path} ... "
path_to_project_repo = path_to_repo(project)
path_to_project_bundle = path_to_bundle(project)
# Create namespace dir if missing
FileUtils.mkdir_p(File.join(backup_repos_path, project.namespace.full_path)) if project.namespace
- if project.empty_repo?
- $progress.puts "[SKIPPED]".color(:cyan)
+ if empty_repo?(project)
+ progress.puts "[SKIPPED]".color(:cyan)
else
in_path(path_to_project_repo) do |dir|
FileUtils.mkdir_p(path_to_tars(project))
@@ -23,10 +23,7 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
unless status.zero?
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -34,12 +31,9 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts "[DONE]".color(:green)
+ progress.puts "[DONE]".color(:green)
else
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -48,19 +42,16 @@ module Backup
path_to_wiki_bundle = path_to_bundle(wiki)
if File.exist?(path_to_wiki_repo)
- $progress.print " * #{wiki.path_with_namespace} ... "
- if wiki.repository.empty?
- $progress.puts " [SKIPPED]".color(:cyan)
+ progress.print " * #{wiki.full_path} ... "
+ if empty_repo?(wiki)
+ progress.puts " [SKIPPED]".color(:cyan)
else
cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path_to_wiki_repo} bundle create #{path_to_wiki_bundle} --all)
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
- puts " [FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Backup failed'
+ progress_warn(wiki, cmd.join(' '), output)
end
end
end
@@ -80,11 +71,11 @@ module Backup
end
Project.find_each(batch_size: 1000) do |project|
- $progress.print " * #{project.path_with_namespace} ... "
+ progress.print " * #{project.full_path} ... "
path_to_project_repo = path_to_repo(project)
path_to_project_bundle = path_to_bundle(project)
- project.ensure_dir_exist
+ project.ensure_storage_path_exist
cmd = if File.exist?(path_to_project_bundle)
%W(#{Gitlab.config.git.bin_path} clone --bare #{path_to_project_bundle} #{path_to_project_repo})
@@ -94,12 +85,9 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts "[DONE]".color(:green)
+ progress.puts "[DONE]".color(:green)
else
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
in_path(path_to_tars(project)) do |dir|
@@ -107,10 +95,7 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
unless status.zero?
- puts "[FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
@@ -119,7 +104,7 @@ module Backup
path_to_wiki_bundle = path_to_bundle(wiki)
if File.exist?(path_to_wiki_bundle)
- $progress.print " * #{wiki.path_with_namespace} ... "
+ progress.print " * #{wiki.full_path} ... "
# If a wiki bundle exists, first remove the empty repo
# that was initialized with ProjectWiki.new() and then
@@ -129,22 +114,19 @@ module Backup
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
- puts " [FAILED]".color(:red)
- puts "failed: #{cmd.join(' ')}"
- puts output
- abort 'Restore failed'
+ progress_warn(project, cmd.join(' '), output)
end
end
end
- $progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow)
+ progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow)
cmd = %W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args
output, status = Gitlab::Popen.popen(cmd)
if status.zero?
- $progress.puts " [DONE]".color(:green)
+ progress.puts " [DONE]".color(:green)
else
puts " [FAILED]".color(:red)
puts "failed: #{cmd}"
@@ -160,11 +142,11 @@ module Backup
end
def path_to_bundle(project)
- File.join(backup_repos_path, project.path_with_namespace + '.bundle')
+ File.join(backup_repos_path, project.disk_path + '.bundle')
end
def path_to_tars(project, dir = nil)
- path = File.join(backup_repos_path, project.path_with_namespace)
+ path = File.join(backup_repos_path, project.disk_path)
if dir
File.join(path, "#{dir}.tar")
@@ -201,8 +183,26 @@ module Backup
private
+ def progress_warn(project, cmd, output)
+ progress.puts "[WARNING] Executing #{cmd}".color(:orange)
+ progress.puts "Ignoring error on #{project.full_path} - #{output}".color(:orange)
+ end
+
+ def empty_repo?(project_or_wiki)
+ project_or_wiki.repository.expire_exists_cache # protect backups from stale cache
+ project_or_wiki.repository.empty_repo?
+ rescue => e
+ progress.puts "Ignoring repository error and continuing backing up project: #{project_or_wiki.full_path} - #{e.message}".color(:orange)
+
+ false
+ end
+
def repository_storage_paths_args
Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
end
+
+ def progress
+ $progress
+ end
end
end
diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb
index 8bc2dd18bda..ef4578aabd6 100644
--- a/lib/banzai/filter/abstract_reference_filter.rb
+++ b/lib/banzai/filter/abstract_reference_filter.rb
@@ -54,42 +54,42 @@ module Banzai
self.class.references_in(*args, &block)
end
+ # Implement in child class
+ # Example: project.merge_requests.find
def find_object(project, id)
- # Implement in child class
- # Example: project.merge_requests.find
end
- def find_object_cached(project, id)
- if RequestStore.active?
- cache = find_objects_cache[object_class][project.id]
+ # Override if the link reference pattern produces a different ID (global
+ # ID vs internal ID, for instance) to the regular reference pattern.
+ def find_object_from_link(project, id)
+ find_object(project, id)
+ end
- get_or_set_cache(cache, id) { find_object(project, id) }
- else
+ # Implement in child class
+ # Example: project_merge_request_url
+ def url_for_object(object, project)
+ end
+
+ def find_object_cached(project, id)
+ cached_call(:banzai_find_object, id, path: [object_class, project.id]) do
find_object(project, id)
end
end
- def project_from_ref_cached(ref)
- if RequestStore.active?
- cache = project_refs_cache
-
- get_or_set_cache(cache, ref) { project_from_ref(ref) }
- else
- project_from_ref(ref)
+ def find_object_from_link_cached(project, id)
+ cached_call(:banzai_find_object_from_link, id, path: [object_class, project.id]) do
+ find_object_from_link(project, id)
end
end
- def url_for_object(object, project)
- # Implement in child class
- # Example: project_merge_request_url
+ def project_from_ref_cached(ref)
+ cached_call(:banzai_project_refs, ref) do
+ project_from_ref(ref)
+ end
end
def url_for_object_cached(object, project)
- if RequestStore.active?
- cache = url_for_object_cache[object_class][project.id]
-
- get_or_set_cache(cache, object) { url_for_object(object, project) }
- else
+ cached_call(:banzai_url_for_object, object, path: [object_class, project.id]) do
url_for_object(object, project)
end
end
@@ -120,7 +120,7 @@ module Banzai
if link == inner_html && inner_html =~ /\A#{link_pattern}/
replace_link_node_with_text(node, link) do
- object_link_filter(inner_html, link_pattern)
+ object_link_filter(inner_html, link_pattern, link_reference: true)
end
next
@@ -128,7 +128,7 @@ module Banzai
if link =~ /\A#{link_pattern}\z/
replace_link_node_with_href(node, link) do
- object_link_filter(link, link_pattern, link_content: inner_html)
+ object_link_filter(link, link_pattern, link_content: inner_html, link_reference: true)
end
next
@@ -146,15 +146,26 @@ module Banzai
# text - String text to replace references in.
# pattern - Reference pattern to match against.
# link_content - Original content of the link being replaced.
+ # link_reference - True if this was using the link reference pattern,
+ # false otherwise.
#
# Returns a String with references replaced with links. All links
# have `gfm` and `gfm-OBJECT_NAME` class names attached for styling.
- def object_link_filter(text, pattern, link_content: nil)
+ def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches|
project_path = full_project_path(namespace_ref, project_ref)
project = project_from_ref_cached(project_path)
- if project && object = find_object_cached(project, id)
+ if project
+ object =
+ if link_reference
+ find_object_from_link_cached(project, id)
+ else
+ find_object_cached(project, id)
+ end
+ end
+
+ if object
title = object_link_title(object)
klass = reference_class(object_sym)
@@ -216,12 +227,7 @@ module Banzai
@references_per_project ||= begin
refs = Hash.new { |hash, key| hash[key] = Set.new }
- regex =
- if uses_reference_pattern?
- Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
- else
- object_class.link_reference_pattern
- end
+ regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
nodes.each do |node|
node.to_html.scan(regex) do
@@ -264,7 +270,7 @@ module Banzai
found = []
projects.each do |project|
- ref = project.path_with_namespace
+ ref = project.full_path
get_or_set_cache(cache, ref) { project }
found << ref
end
@@ -282,7 +288,7 @@ module Banzai
end
def current_project_path
- @current_project_path ||= project.path_with_namespace
+ @current_project_path ||= project.full_path
end
def current_project_namespace_path
@@ -302,15 +308,17 @@ module Banzai
RequestStore[:banzai_project_refs] ||= {}
end
- def find_objects_cache
- RequestStore[:banzai_find_objects_cache] ||= Hash.new do |hash, key|
- hash[key] = Hash.new { |h, k| h[k] = {} }
- end
- end
+ def cached_call(request_store_key, cache_key, path: [])
+ if RequestStore.active?
+ cache = RequestStore[request_store_key] ||= Hash.new do |hash, key|
+ hash[key] = Hash.new { |h, k| h[k] = {} }
+ end
- def url_for_object_cache
- RequestStore[:banzai_url_for_object] ||= Hash.new do |hash, key|
- hash[key] = Hash.new { |h, k| h[k] = {} }
+ cache = cache.dig(*path) if path.any?
+
+ get_or_set_cache(cache, cache_key) { yield }
+ else
+ yield
end
end
@@ -323,14 +331,6 @@ module Banzai
value
end
end
-
- # There might be special cases like filters
- # that should ignore reference pattern
- # eg: IssueReferenceFilter when using a external issues tracker
- # In those cases this method should be overridden on the filter subclass
- def uses_reference_pattern?
- true
- end
end
end
end
diff --git a/lib/banzai/filter/ascii_doc_post_processing_filter.rb b/lib/banzai/filter/ascii_doc_post_processing_filter.rb
new file mode 100644
index 00000000000..c9fcf057c5f
--- /dev/null
+++ b/lib/banzai/filter/ascii_doc_post_processing_filter.rb
@@ -0,0 +1,13 @@
+module Banzai
+ module Filter
+ class AsciiDocPostProcessingFilter < HTML::Pipeline::Filter
+ def call
+ doc.search('[data-math-style]').each do |node|
+ node.set_attribute('class', 'code math js-render-math')
+ end
+
+ doc
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/commit_range_reference_filter.rb b/lib/banzai/filter/commit_range_reference_filter.rb
index eaacb9591b1..21bcb1c5ca8 100644
--- a/lib/banzai/filter/commit_range_reference_filter.rb
+++ b/lib/banzai/filter/commit_range_reference_filter.rb
@@ -30,7 +30,7 @@ module Banzai
def url_for_object(range, project)
h = Gitlab::Routing.url_helpers
- h.namespace_project_compare_url(project.namespace, project,
+ h.project_compare_url(project,
range.to_param.merge(only_path: context[:only_path]))
end
diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb
index 69c06117eda..714e0319025 100644
--- a/lib/banzai/filter/commit_reference_filter.rb
+++ b/lib/banzai/filter/commit_reference_filter.rb
@@ -24,7 +24,7 @@ module Banzai
def url_for_object(commit, project)
h = Gitlab::Routing.url_helpers
- h.namespace_project_commit_url(project.namespace, project, commit,
+ h.project_commit_url(project, commit,
only_path: context[:only_path])
end
diff --git a/lib/banzai/filter/external_issue_reference_filter.rb b/lib/banzai/filter/external_issue_reference_filter.rb
index dce4de3ceaf..ed01a72ff9f 100644
--- a/lib/banzai/filter/external_issue_reference_filter.rb
+++ b/lib/banzai/filter/external_issue_reference_filter.rb
@@ -3,6 +3,8 @@ module Banzai
# HTML filter that replaces external issue tracker references with links.
# References are ignored if the project doesn't use an external issue
# tracker.
+ #
+ # This filter does not support cross-project references.
class ExternalIssueReferenceFilter < ReferenceFilter
self.reference_type = :external_issue
@@ -87,16 +89,16 @@ module Banzai
end
def issue_reference_pattern
- external_issues_cached(:issue_reference_pattern)
+ external_issues_cached(:external_issue_reference_pattern)
end
private
def external_issues_cached(attribute)
- return project.public_send(attribute) unless RequestStore.active?
+ return project.public_send(attribute) unless RequestStore.active? # rubocop:disable GitlabSecurity/PublicSend
cached_attributes = RequestStore[:banzai_external_issues_tracker_attributes] ||= Hash.new { |h, k| h[k] = {} }
- cached_attributes[project.id][attribute] = project.public_send(attribute) if cached_attributes[project.id][attribute].nil?
+ cached_attributes[project.id][attribute] = project.public_send(attribute) if cached_attributes[project.id][attribute].nil? # rubocop:disable GitlabSecurity/PublicSend
cached_attributes[project.id][attribute]
end
end
diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb
index d67d466bce8..d6327ef31cb 100644
--- a/lib/banzai/filter/external_link_filter.rb
+++ b/lib/banzai/filter/external_link_filter.rb
@@ -2,16 +2,17 @@ module Banzai
module Filter
# HTML Filter to modify the attributes of external links
class ExternalLinkFilter < HTML::Pipeline::Filter
+ SCHEMES = ['http', 'https', nil].freeze
+
def call
links.each do |node|
- href = href_to_lowercase_scheme(node["href"].to_s)
+ uri = uri(node['href'].to_s)
+ next unless uri
- unless node["href"].to_s == href
- node.set_attribute('href', href)
- end
+ node.set_attribute('href', uri.to_s)
- if href =~ %r{\A(https?:)?//[^/]} && external_url?(href)
- node.set_attribute('rel', 'nofollow noreferrer')
+ if SCHEMES.include?(uri.scheme) && external_url?(uri)
+ node.set_attribute('rel', 'nofollow noreferrer noopener')
node.set_attribute('target', '_blank')
end
end
@@ -21,27 +22,26 @@ module Banzai
private
+ def uri(href)
+ URI.parse(href)
+ rescue URI::Error
+ nil
+ end
+
def links
query = 'descendant-or-self::a[@href and not(@href = "")]'
doc.xpath(query)
end
- def href_to_lowercase_scheme(href)
- scheme_match = href.match(/\A(\w+):\/\//)
-
- if scheme_match
- scheme_match.to_s.downcase + scheme_match.post_match
- else
- href
- end
- end
+ def external_url?(uri)
+ # Relative URLs miss a hostname
+ return false unless uri.hostname
- def external_url?(url)
- !url.start_with?(internal_url)
+ uri.hostname != internal_url.hostname
end
def internal_url
- @internal_url ||= Gitlab.config.gitlab.url
+ @internal_url ||= URI.parse(Gitlab.config.gitlab.url)
end
end
end
diff --git a/lib/banzai/filter/gollum_tags_filter.rb b/lib/banzai/filter/gollum_tags_filter.rb
index 0ea4eeaed5b..2e259904673 100644
--- a/lib/banzai/filter/gollum_tags_filter.rb
+++ b/lib/banzai/filter/gollum_tags_filter.rb
@@ -118,7 +118,7 @@ module Banzai
end
if path
- content_tag(:img, nil, src: path, class: 'gfm')
+ content_tag(:img, nil, data: { src: path }, class: 'gfm')
end
end
diff --git a/lib/banzai/filter/image_lazy_load_filter.rb b/lib/banzai/filter/image_lazy_load_filter.rb
new file mode 100644
index 00000000000..bcb4f332267
--- /dev/null
+++ b/lib/banzai/filter/image_lazy_load_filter.rb
@@ -0,0 +1,16 @@
+module Banzai
+ module Filter
+ # HTML filter that moves the value of the src attribute to the data-src attribute so it can be lazy loaded
+ class ImageLazyLoadFilter < HTML::Pipeline::Filter
+ def call
+ doc.xpath('descendant-or-self::img').each do |img|
+ img['class'] ||= '' << 'lazy'
+ img['data-src'] = img['src']
+ img['src'] = LazyImageTagHelper.placeholder_image
+ end
+
+ doc
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/image_link_filter.rb b/lib/banzai/filter/image_link_filter.rb
index 123c92fd250..f318c425962 100644
--- a/lib/banzai/filter/image_link_filter.rb
+++ b/lib/banzai/filter/image_link_filter.rb
@@ -10,7 +10,7 @@ module Banzai
link = doc.document.create_element(
'a',
class: 'no-attachment-icon',
- href: img['src'],
+ href: img['data-src'] || img['src'],
target: '_blank',
rel: 'noopener noreferrer'
)
diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb
index 044d18ff824..ce1ab977d3b 100644
--- a/lib/banzai/filter/issue_reference_filter.rb
+++ b/lib/banzai/filter/issue_reference_filter.rb
@@ -15,16 +15,12 @@ module Banzai
Issue
end
- def uses_reference_pattern?
- context[:project].default_issues_tracker?
- end
-
def find_object(project, iid)
issues_per_project[project][iid]
end
def url_for_object(issue, project)
- IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path])
+ IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path], internal: true)
end
def project_from_ref(ref)
@@ -38,13 +34,7 @@ module Banzai
projects_per_reference.each do |path, project|
issue_ids = references_per_project[path]
-
- issues =
- if project.default_issues_tracker?
- project.issues.where(iid: issue_ids.to_a)
- else
- issue_ids.map { |id| ExternalIssue.new(id, project) }
- end
+ issues = project.issues.where(iid: issue_ids.to_a)
issues.each do |issue|
hash[project][issue.iid.to_i] = issue
@@ -55,26 +45,6 @@ module Banzai
end
end
- def object_link_title(object)
- if object.is_a?(ExternalIssue)
- "Issue in #{object.project.external_issue_tracker.title}"
- else
- super
- end
- end
-
- def data_attributes_for(text, project, object, link: false)
- if object.is_a?(ExternalIssue)
- data_attribute(
- project: project.id,
- external_issue: object.id,
- reference_type: ExternalIssueReferenceFilter.reference_type
- )
- else
- super
- end
- end
-
def projects_relation_for_paths(paths)
super(paths).includes(:gitlab_issue_tracker_service)
end
diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb
index a605dea149e..5364984c9d3 100644
--- a/lib/banzai/filter/label_reference_filter.rb
+++ b/lib/banzai/filter/label_reference_filter.rb
@@ -61,8 +61,7 @@ module Banzai
def url_for_object(label, project)
h = Gitlab::Routing.url_helpers
- h.namespace_project_issues_url(project.namespace, project, label_name: label.name,
- only_path: context[:only_path])
+ h.project_issues_url(project, label_name: label.name, only_path: context[:only_path])
end
def object_link_text(object, matches)
diff --git a/lib/banzai/filter/merge_request_reference_filter.rb b/lib/banzai/filter/merge_request_reference_filter.rb
index 3888acf935e..0eab865ac04 100644
--- a/lib/banzai/filter/merge_request_reference_filter.rb
+++ b/lib/banzai/filter/merge_request_reference_filter.rb
@@ -17,7 +17,7 @@ module Banzai
def url_for_object(mr, project)
h = Gitlab::Routing.url_helpers
- h.namespace_project_merge_request_url(project.namespace, project, mr,
+ h.project_merge_request_url(project, mr,
only_path: context[:only_path])
end
diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb
index f12014e191f..4fc5f211e84 100644
--- a/lib/banzai/filter/milestone_reference_filter.rb
+++ b/lib/banzai/filter/milestone_reference_filter.rb
@@ -8,8 +8,15 @@ module Banzai
Milestone
end
+ # Links to project milestones contain the IID, but when we're handling
+ # 'regular' references, we need to use the global ID to disambiguate
+ # between group and project milestones.
def find_object(project, id)
- project.milestones.find_by(iid: id)
+ find_milestone_with_finder(project, id: id)
+ end
+
+ def find_object_from_link(project, iid)
+ find_milestone_with_finder(project, iid: iid)
end
def references_in(text, pattern = Milestone.reference_pattern)
@@ -22,7 +29,7 @@ module Banzai
milestone = find_milestone($~[:project], $~[:namespace], $~[:milestone_iid], $~[:milestone_name])
if milestone
- yield match, milestone.iid, $~[:project], $~[:namespace], $~
+ yield match, milestone.id, $~[:project], $~[:namespace], $~
else
match
end
@@ -36,7 +43,8 @@ module Banzai
return unless project
milestone_params = milestone_params(milestone_id, milestone_name)
- project.milestones.find_by(milestone_params)
+
+ find_milestone_with_finder(project, milestone_params)
end
def milestone_params(iid, name)
@@ -47,15 +55,27 @@ module Banzai
end
end
+ def find_milestone_with_finder(project, params)
+ finder_params = { project_ids: [project.id], order: nil }
+
+ # We don't support IID lookups for group milestones, because IIDs can
+ # clash between group and project milestones.
+ if project.group && !params[:iid]
+ finder_params[:group_ids] = [project.group.id]
+ end
+
+ MilestonesFinder.new(finder_params).execute.find_by(params)
+ end
+
def url_for_object(milestone, project)
- h = Gitlab::Routing.url_helpers
- h.namespace_project_milestone_url(project.namespace, project, milestone,
- only_path: context[:only_path])
+ Gitlab::Routing
+ .url_helpers
+ .milestone_url(milestone, only_path: context[:only_path])
end
def object_link_text(object, matches)
milestone_link = escape_once(super)
- reference = object.project.to_reference(project)
+ reference = object.project&.to_reference(project)
if reference.present?
"#{milestone_link} <i>in #{reference}</i>".html_safe
diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb
index 6640168bfa2..a6f8650ed3d 100644
--- a/lib/banzai/filter/reference_filter.rb
+++ b/lib/banzai/filter/reference_filter.rb
@@ -30,6 +30,8 @@ module Banzai
attributes = attributes.reject { |_, v| v.nil? }
attributes[:reference_type] ||= self.class.reference_type
+ attributes[:container] ||= 'body'
+ attributes[:placement] ||= 'bottom'
attributes.delete(:original) if context[:no_original_data]
attributes.map do |key, value|
%Q(data-#{key.to_s.dasherize}="#{escape_once(value)}")
diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb
index 9e23c8f8c55..758f15c8a67 100644
--- a/lib/banzai/filter/relative_link_filter.rb
+++ b/lib/banzai/filter/relative_link_filter.rb
@@ -22,6 +22,7 @@ module Banzai
doc.css('img, video').each do |el|
process_link_attr el.attribute('src')
+ process_link_attr el.attribute('data-src')
end
doc
@@ -50,7 +51,7 @@ module Banzai
uri.path = [
relative_url_root,
- context[:project].path_with_namespace,
+ context[:project].full_path,
uri_type(file_path),
Addressable::URI.escape(ref),
Addressable::URI.escape(file_path)
diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb
index 522217deae4..2d6e8ffc90f 100644
--- a/lib/banzai/filter/sanitization_filter.rb
+++ b/lib/banzai/filter/sanitization_filter.rb
@@ -31,6 +31,10 @@ module Banzai
# Allow span elements
whitelist[:elements].push('span')
+ # Allow data-math-style attribute in order to support LaTeX formatting
+ whitelist[:attributes]['code'] = %w(data-math-style)
+ whitelist[:attributes]['pre'] = %w(data-math-style)
+
# Allow html5 details/summary elements
whitelist[:elements].push('details')
whitelist[:elements].push('summary')
diff --git a/lib/banzai/filter/snippet_reference_filter.rb b/lib/banzai/filter/snippet_reference_filter.rb
index 212a0bbf2a0..134a192c22b 100644
--- a/lib/banzai/filter/snippet_reference_filter.rb
+++ b/lib/banzai/filter/snippet_reference_filter.rb
@@ -17,7 +17,7 @@ module Banzai
def url_for_object(snippet, project)
h = Gitlab::Routing.url_helpers
- h.namespace_project_snippet_url(project.namespace, project, snippet,
+ h.project_snippet_url(project, snippet,
only_path: context[:only_path])
end
end
diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb
index 45bb66dc99f..09844931be5 100644
--- a/lib/banzai/filter/upload_link_filter.rb
+++ b/lib/banzai/filter/upload_link_filter.rb
@@ -28,7 +28,7 @@ module Banzai
end
def build_url(uri)
- File.join(Gitlab.config.gitlab.url, project.path_with_namespace, uri)
+ File.join(Gitlab.config.gitlab.url, project.full_path, uri)
end
def project
diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb
index a798927823f..f3356d6c51e 100644
--- a/lib/banzai/filter/user_reference_filter.rb
+++ b/lib/banzai/filter/user_reference_filter.rb
@@ -107,7 +107,7 @@ module Banzai
if author && !project.team.member?(author)
link_content
else
- url = urls.namespace_project_url(project.namespace, project,
+ url = urls.project_url(project,
only_path: context[:only_path])
data = data_attribute(project: project.id, author: author.try(:id))
diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb
index 002a3341ccd..2196a92474c 100644
--- a/lib/banzai/object_renderer.rb
+++ b/lib/banzai/object_renderer.rb
@@ -37,7 +37,7 @@ module Banzai
objects.each_with_index do |object, index|
redacted_data = redacted[index]
- object.__send__("redacted_#{attribute}_html=", redacted_data[:document].to_html.html_safe)
+ object.__send__("redacted_#{attribute}_html=", redacted_data[:document].to_html.html_safe) # rubocop:disable GitlabSecurity/PublicSend
object.user_visible_reference_count = redacted_data[:visible_reference_count]
end
end
diff --git a/lib/banzai/pipeline/ascii_doc_pipeline.rb b/lib/banzai/pipeline/ascii_doc_pipeline.rb
new file mode 100644
index 00000000000..1048b927cd3
--- /dev/null
+++ b/lib/banzai/pipeline/ascii_doc_pipeline.rb
@@ -0,0 +1,14 @@
+module Banzai
+ module Pipeline
+ class AsciiDocPipeline < BasePipeline
+ def self.filters
+ FilterArray[
+ Filter::SanitizationFilter,
+ Filter::ExternalLinkFilter,
+ Filter::PlantumlFilter,
+ Filter::AsciiDocPostProcessingFilter
+ ]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/pipeline/base_pipeline.rb b/lib/banzai/pipeline/base_pipeline.rb
index 321fd5bbe14..3ae3bed570d 100644
--- a/lib/banzai/pipeline/base_pipeline.rb
+++ b/lib/banzai/pipeline/base_pipeline.rb
@@ -18,7 +18,7 @@ module Banzai
define_method(meth) do |text, context|
context = transform_context(context)
- html_pipeline.send(meth, text, context)
+ html_pipeline.__send__(meth, text, context) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index bd4d1aa9ff8..3208abfc538 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -16,6 +16,7 @@ module Banzai
Filter::MathFilter,
Filter::UploadLinkFilter,
Filter::VideoLinkFilter,
+ Filter::ImageLazyLoadFilter,
Filter::ImageLinkFilter,
Filter::EmojiFilter,
Filter::TableOfContentsFilter,
diff --git a/lib/banzai/pipeline/markup_pipeline.rb b/lib/banzai/pipeline/markup_pipeline.rb
new file mode 100644
index 00000000000..c56d908009f
--- /dev/null
+++ b/lib/banzai/pipeline/markup_pipeline.rb
@@ -0,0 +1,13 @@
+module Banzai
+ module Pipeline
+ class MarkupPipeline < BasePipeline
+ def self.filters
+ @filters ||= FilterArray[
+ Filter::SanitizationFilter,
+ Filter::ExternalLinkFilter,
+ Filter::PlantumlFilter
+ ]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/reference_extractor.rb b/lib/banzai/reference_extractor.rb
index 8e3b0c4db79..7e6357f8a00 100644
--- a/lib/banzai/reference_extractor.rb
+++ b/lib/banzai/reference_extractor.rb
@@ -10,8 +10,8 @@ module Banzai
end
def references(type, project, current_user = nil)
- processor = Banzai::ReferenceParser[type].
- new(project, current_user)
+ processor = Banzai::ReferenceParser[type]
+ .new(project, current_user)
processor.process(html_documents)
end
diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb
index c2503fa2adc..279fca8d043 100644
--- a/lib/banzai/reference_parser/base_parser.rb
+++ b/lib/banzai/reference_parser/base_parser.rb
@@ -62,7 +62,7 @@ module Banzai
nodes.select do |node|
if node.has_attribute?(project_attr)
- can_read_reference?(user, projects[node])
+ can_read_reference?(user, projects[node], node)
else
true
end
@@ -163,14 +163,15 @@ module Banzai
# been queried the object is returned from the cache.
def collection_objects_for_ids(collection, ids)
if RequestStore.active?
+ ids = ids.map(&:to_i)
cache = collection_cache[collection_cache_key(collection)]
- to_query = ids.map(&:to_i) - cache.keys
+ to_query = ids - cache.keys
unless to_query.empty?
collection.where(id: to_query).each { |row| cache[row.id] = row }
end
- cache.values
+ cache.values_at(*ids).compact
else
collection.where(id: ids)
end
@@ -230,7 +231,7 @@ module Banzai
# see reference comments.
# Override this method on subclasses
# to check if user can read resource
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
raise NotImplementedError
end
diff --git a/lib/banzai/reference_parser/commit_parser.rb b/lib/banzai/reference_parser/commit_parser.rb
index 8c54a041cb8..30dc87248b4 100644
--- a/lib/banzai/reference_parser/commit_parser.rb
+++ b/lib/banzai/reference_parser/commit_parser.rb
@@ -32,7 +32,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :download_code, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/commit_range_parser.rb b/lib/banzai/reference_parser/commit_range_parser.rb
index 0878b6afba3..a50e6f8ef8f 100644
--- a/lib/banzai/reference_parser/commit_range_parser.rb
+++ b/lib/banzai/reference_parser/commit_range_parser.rb
@@ -36,7 +36,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :download_code, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/external_issue_parser.rb b/lib/banzai/reference_parser/external_issue_parser.rb
index 6e7b7669578..1802cd04854 100644
--- a/lib/banzai/reference_parser/external_issue_parser.rb
+++ b/lib/banzai/reference_parser/external_issue_parser.rb
@@ -21,10 +21,14 @@ module Banzai
gather_attributes_per_project(nodes, self.class.data_attribute)
end
- private
+ # we extract only external issue trackers references here, we don't extract cross-project references,
+ # so we don't need to do anything here.
+ def can_read_reference?(user, ref_project, node)
+ true
+ end
- def can_read_reference?(user, ref_project)
- can?(user, :read_issue, ref_project)
+ def nodes_visible_to_user(user, nodes)
+ nodes
end
end
end
diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb
index e02b360924a..a65bbe23958 100644
--- a/lib/banzai/reference_parser/issue_parser.rb
+++ b/lib/banzai/reference_parser/issue_parser.rb
@@ -4,13 +4,10 @@ module Banzai
self.reference_type = :issue
def nodes_visible_to_user(user, nodes)
- # It is not possible to check access rights for external issue trackers
- return nodes if project && project.external_issue_tracker
-
issues = issues_for_nodes(nodes)
- readable_issues = Ability.
- issues_readable_by_user(issues.values, user).to_set
+ readable_issues = Ability
+ .issues_readable_by_user(issues.values, user).to_set
nodes.select do |node|
readable_issues.include?(issues[node])
@@ -28,7 +25,7 @@ module Banzai
nodes,
Issue.all.includes(
:author,
- :assignee,
+ :assignees,
{
# These associations are primarily used for checking permissions.
# Eager loading these ensures we don't end up running dozens of
diff --git a/lib/banzai/reference_parser/label_parser.rb b/lib/banzai/reference_parser/label_parser.rb
index aa76c64ac5f..30e2a012f09 100644
--- a/lib/banzai/reference_parser/label_parser.rb
+++ b/lib/banzai/reference_parser/label_parser.rb
@@ -9,7 +9,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_label, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb
index 84a28b33d7c..75cbc7fdac4 100644
--- a/lib/banzai/reference_parser/merge_request_parser.rb
+++ b/lib/banzai/reference_parser/merge_request_parser.rb
@@ -33,12 +33,17 @@ module Banzai
{ namespace: :owner },
{ group: [:owners, :group_members] },
:invited_groups,
- :project_members
+ :project_members,
+ :project_feature
]
}),
self.class.data_attribute
)
end
+
+ def can_read_reference?(user, ref_project, node)
+ can?(user, :read_merge_request, ref_project)
+ end
end
end
end
diff --git a/lib/banzai/reference_parser/milestone_parser.rb b/lib/banzai/reference_parser/milestone_parser.rb
index d3968d6b229..68675abe22a 100644
--- a/lib/banzai/reference_parser/milestone_parser.rb
+++ b/lib/banzai/reference_parser/milestone_parser.rb
@@ -9,7 +9,7 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_milestone, ref_project)
end
end
diff --git a/lib/banzai/reference_parser/snippet_parser.rb b/lib/banzai/reference_parser/snippet_parser.rb
index 63b592137bb..3ade168b566 100644
--- a/lib/banzai/reference_parser/snippet_parser.rb
+++ b/lib/banzai/reference_parser/snippet_parser.rb
@@ -9,8 +9,8 @@ module Banzai
private
- def can_read_reference?(user, ref_project)
- can?(user, :read_project_snippet, ref_project)
+ def can_read_reference?(user, ref_project, node)
+ can?(user, :read_project_snippet, referenced_by([node]).first)
end
end
end
diff --git a/lib/banzai/reference_parser/user_parser.rb b/lib/banzai/reference_parser/user_parser.rb
index 09b66cbd8fb..4d336068861 100644
--- a/lib/banzai/reference_parser/user_parser.rb
+++ b/lib/banzai/reference_parser/user_parser.rb
@@ -99,11 +99,11 @@ module Banzai
def find_users_for_projects(ids)
return [] if ids.empty?
- collection_objects_for_ids(Project, ids).
- flat_map { |p| p.team.members.to_a }
+ collection_objects_for_ids(Project, ids)
+ .flat_map { |p| p.team.members.to_a }
end
- def can_read_reference?(user, ref_project)
+ def can_read_reference?(user, ref_project, node)
can?(user, :read_project, ref_project)
end
end
diff --git a/lib/banzai/renderer.rb b/lib/banzai/renderer.rb
index c7801cb5baf..95d82d17658 100644
--- a/lib/banzai/renderer.rb
+++ b/lib/banzai/renderer.rb
@@ -43,7 +43,7 @@ module Banzai
# Same as +render_field+, but without consulting or updating the cache field
def self.cacheless_render_field(object, field, options = {})
- text = object.__send__(field)
+ text = object.__send__(field) # rubocop:disable GitlabSecurity/PublicSend
context = object.banzai_render_context(field).merge(options)
cacheless_render(text, context)
@@ -132,6 +132,8 @@ module Banzai
end
def self.cacheless_render(text, context = {})
+ return text.to_s unless text.present?
+
Gitlab::Metrics.measure(:banzai_cacheless_render) do
result = render_result(text, context)
@@ -154,7 +156,7 @@ module Banzai
# method.
def self.full_cache_multi_key(cache_key, pipeline_name)
return unless cache_key
- Rails.cache.send(:expanded_key, full_cache_key(cache_key, pipeline_name))
+ Rails.cache.__send__(:expanded_key, full_cache_key(cache_key, pipeline_name)) # rubocop:disable GitlabSecurity/PublicSend
end
# GitLab EE needs to disable updates on GET requests in Geo
diff --git a/lib/bitbucket/collection.rb b/lib/bitbucket/collection.rb
index 3a9379ff680..a78495dbf5e 100644
--- a/lib/bitbucket/collection.rb
+++ b/lib/bitbucket/collection.rb
@@ -13,7 +13,7 @@ module Bitbucket
def method_missing(method, *args)
return super unless self.respond_to?(method)
- self.send(method, *args) do |item|
+ self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
block_given? ? yield(item) : item
end
end
diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb
index 4f8efe03bae..c52acbc3ddc 100644
--- a/lib/bitbucket/representation/pull_request_comment.rb
+++ b/lib/bitbucket/representation/pull_request_comment.rb
@@ -22,11 +22,11 @@ module Bitbucket
end
def inline?
- raw.has_key?('inline')
+ raw.key?('inline')
end
def has_parent?
- raw.has_key?('parent')
+ raw.key?('parent')
end
private
diff --git a/lib/ci/ansi2html.rb b/lib/ci/ansi2html.rb
index b439b0ee29b..b9e9f9f7f4a 100644
--- a/lib/ci/ansi2html.rb
+++ b/lib/ci/ansi2html.rb
@@ -20,7 +20,7 @@ module Ci
italic: 0x02,
underline: 0x04,
conceal: 0x08,
- cross: 0x10,
+ cross: 0x10
}.freeze
def self.convert(ansi, state = nil)
@@ -208,7 +208,7 @@ module Ci
return unless command = stack.shift()
if self.respond_to?("on_#{command}", true)
- self.send("on_#{command}", stack)
+ self.__send__("on_#{command}", stack) # rubocop:disable GitlabSecurity/PublicSend
end
evaluate_command_stack(stack)
@@ -254,7 +254,7 @@ module Ci
def state
state = STATE_PARAMS.inject({}) do |h, param|
- h[param] = send(param)
+ h[param] = send(param) # rubocop:disable GitlabSecurity/PublicSend
h
end
Base64.urlsafe_encode64(state.to_json)
@@ -266,7 +266,7 @@ module Ci
return if state[:offset].to_i > stream.size
STATE_PARAMS.each do |param|
- send("#{param}=".to_sym, state[param])
+ send("#{param}=".to_sym, state[param]) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb
index 67b269b330c..79058c02ce5 100644
--- a/lib/ci/api/builds.rb
+++ b/lib/ci/api/builds.rb
@@ -29,7 +29,7 @@ module Ci
if result.valid?
if result.build
Gitlab::Metrics.add_event(:build_found,
- project: result.build.project.path_with_namespace)
+ project: result.build.project.full_path)
present result.build, with: Entities::BuildDetails
else
@@ -64,7 +64,7 @@ module Ci
build.trace.set(params[:trace]) if params[:trace]
Gitlab::Metrics.add_event(:update_build,
- project: build.project.path_with_namespace)
+ project: build.project.full_path)
case params[:state].to_s
when 'success'
@@ -88,7 +88,7 @@ module Ci
patch ":id/trace.txt" do
build = authenticate_build!
- error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range')
+ error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
content_range = request.headers['Content-Range']
content_range = content_range.split('-')
@@ -187,14 +187,14 @@ module Ci
build = authenticate_build!
artifacts_file = build.artifacts_file
- unless artifacts_file.file_storage?
- return redirect_to build.artifacts_file.url
- end
-
unless artifacts_file.exists?
not_found!
end
+ unless artifacts_file.file_storage?
+ return redirect_to build.artifacts_file.url
+ end
+
present_file!(artifacts_file.path, artifacts_file.filename)
end
diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb
index 792ff628b09..31f66dd5a58 100644
--- a/lib/ci/api/entities.rb
+++ b/lib/ci/api/entities.rb
@@ -45,7 +45,21 @@ module Ci
expose :artifacts_expire_at, if: ->(build, _) { build.artifacts? }
expose :options do |model|
- model.options
+ # This part ensures that output of old API is still the same after adding support
+ # for extended docker configuration options, used by new API
+ #
+ # I'm leaving this here, not in the model, because it should be removed at the same time
+ # when old API will be removed (planned for August 2017).
+ model.options.dup.tap do |options|
+ options[:image] = options[:image][:name] if options[:image].is_a?(Hash)
+ options[:services]&.map! do |service|
+ if service.is_a?(Hash)
+ service[:name]
+ else
+ service
+ end
+ end
+ end
end
expose :timeout do |model|
diff --git a/lib/ci/api/helpers.rb b/lib/ci/api/helpers.rb
index 5109dc9670f..a40b6ab6c9f 100644
--- a/lib/ci/api/helpers.rb
+++ b/lib/ci/api/helpers.rb
@@ -28,7 +28,8 @@ module Ci
yield if block_given?
- forbidden!('Project has been deleted!') unless build.project
+ project = build.project
+ forbidden!('Project has been deleted!') if project.nil? || project.pending_delete?
forbidden!('Build has been erased!') if build.erased?
end
diff --git a/lib/ci/api/triggers.rb b/lib/ci/api/triggers.rb
index 6e622601680..6225203f223 100644
--- a/lib/ci/api/triggers.rb
+++ b/lib/ci/api/triggers.rb
@@ -24,12 +24,13 @@ module Ci
end
# create request and trigger builds
- trigger_request = Ci::CreateTriggerRequestService.new.execute(project, trigger, params[:ref], variables)
- if trigger_request
- present trigger_request, with: Entities::TriggerRequest
+ result = Ci::CreateTriggerRequestService.execute(project, trigger, params[:ref], variables)
+ pipeline = result.pipeline
+
+ if pipeline.persisted?
+ present result.trigger_request, with: Entities::TriggerRequest
else
- errors = 'No builds created'
- render_api_error!(errors, 400)
+ render_validation_error!(pipeline)
end
end
end
diff --git a/lib/ci/charts.rb b/lib/ci/charts.rb
index 3decc3b1a26..76a69bf8a83 100644
--- a/lib/ci/charts.rb
+++ b/lib/ci/charts.rb
@@ -2,10 +2,10 @@ module Ci
module Charts
module DailyInterval
def grouped_count(query)
- query.
- group("DATE(#{Ci::Build.table_name}.created_at)").
- count(:created_at).
- transform_keys { |date| date.strftime(@format) }
+ query
+ .group("DATE(#{Ci::Pipeline.table_name}.created_at)")
+ .count(:created_at)
+ .transform_keys { |date| date.strftime(@format) }
end
def interval_step
@@ -16,14 +16,14 @@ module Ci
module MonthlyInterval
def grouped_count(query)
if Gitlab::Database.postgresql?
- query.
- group("to_char(#{Ci::Build.table_name}.created_at, '01 Month YYYY')").
- count(:created_at).
- transform_keys(&:squish)
+ query
+ .group("to_char(#{Ci::Pipeline.table_name}.created_at, '01 Month YYYY')")
+ .count(:created_at)
+ .transform_keys(&:squish)
else
- query.
- group("DATE_FORMAT(#{Ci::Build.table_name}.created_at, '01 %M %Y')").
- count(:created_at)
+ query
+ .group("DATE_FORMAT(#{Ci::Pipeline.table_name}.created_at, '01 %M %Y')")
+ .count(:created_at)
end
end
@@ -33,21 +33,21 @@ module Ci
end
class Chart
- attr_reader :labels, :total, :success, :project, :build_times
+ attr_reader :labels, :total, :success, :project, :pipeline_times
def initialize(project)
@labels = []
@total = []
@success = []
- @build_times = []
+ @pipeline_times = []
@project = project
collect
end
def collect
- query = project.builds.
- where("? > #{Ci::Build.table_name}.created_at AND #{Ci::Build.table_name}.created_at > ?", @to, @from)
+ query = project.pipelines
+ .where("? > #{Ci::Pipeline.table_name}.created_at AND #{Ci::Pipeline.table_name}.created_at > ?", @to, @from) # rubocop:disable GitlabSecurity/SqlInjection
totals_count = grouped_count(query)
success_count = grouped_count(query.success)
@@ -101,14 +101,14 @@ module Ci
end
end
- class BuildTime < Chart
+ class PipelineTime < Chart
def collect
commits = project.pipelines.last(30)
commits.each do |commit|
@labels << commit.short_sha
duration = commit.duration || 0
- @build_times << (duration / 60)
+ @pipeline_times << (duration / 60)
end
end
end
diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb
index 15a461a16dd..3a4911b23b0 100644
--- a/lib/ci/gitlab_ci_yaml_processor.rb
+++ b/lib/ci/gitlab_ci_yaml_processor.rb
@@ -20,26 +20,26 @@ module Ci
raise ValidationError, e.message
end
- def jobs_for_ref(ref, tag = false, trigger_request = nil)
+ def jobs_for_ref(ref, tag = false, source = nil)
@jobs.select do |_, job|
- process?(job[:only], job[:except], ref, tag, trigger_request)
+ process?(job[:only], job[:except], ref, tag, source)
end
end
- def jobs_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil)
- jobs_for_ref(ref, tag, trigger_request).select do |_, job|
+ def jobs_for_stage_and_ref(stage, ref, tag = false, source = nil)
+ jobs_for_ref(ref, tag, source).select do |_, job|
job[:stage] == stage
end
end
- def builds_for_ref(ref, tag = false, trigger_request = nil)
- jobs_for_ref(ref, tag, trigger_request).map do |name, _|
+ def builds_for_ref(ref, tag = false, source = nil)
+ jobs_for_ref(ref, tag, source).map do |name, _|
build_attributes(name)
end
end
- def builds_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil)
- jobs_for_stage_and_ref(stage, ref, tag, trigger_request).map do |name, _|
+ def builds_for_stage_and_ref(stage, ref, tag = false, source = nil)
+ jobs_for_stage_and_ref(stage, ref, tag, source).map do |name, _|
build_attributes(name)
end
end
@@ -50,10 +50,21 @@ module Ci
end
end
+ def stage_seeds(pipeline)
+ seeds = @stages.uniq.map do |stage|
+ builds = builds_for_stage_and_ref(
+ stage, pipeline.ref, pipeline.tag?, pipeline.source)
+
+ Gitlab::Ci::Stage::Seed.new(pipeline, stage, builds) if builds.any?
+ end
+
+ seeds.compact
+ end
+
def build_attributes(name)
job = @jobs[name.to_sym] || {}
- {
- stage_idx: @stages.index(job[:stage]),
+
+ { stage_idx: @stages.index(job[:stage]),
stage: job[:stage],
commands: job[:commands],
tag_list: job[:tags] || [],
@@ -69,10 +80,12 @@ module Ci
artifacts: job[:artifacts],
cache: job[:cache],
dependencies: job[:dependencies],
+ before_script: job[:before_script],
+ script: job[:script],
after_script: job[:after_script],
environment: job[:environment],
- }.compact
- }
+ retry: job[:retry]
+ }.compact }
end
def self.validation_message(content)
@@ -181,30 +194,35 @@ module Ci
end
end
- def process?(only_params, except_params, ref, tag, trigger_request)
+ def process?(only_params, except_params, ref, tag, source)
if only_params.present?
- return false unless matching?(only_params, ref, tag, trigger_request)
+ return false unless matching?(only_params, ref, tag, source)
end
if except_params.present?
- return false if matching?(except_params, ref, tag, trigger_request)
+ return false if matching?(except_params, ref, tag, source)
end
true
end
- def matching?(patterns, ref, tag, trigger_request)
+ def matching?(patterns, ref, tag, source)
patterns.any? do |pattern|
- match_ref?(pattern, ref, tag, trigger_request)
+ pattern, path = pattern.split('@', 2)
+ matches_path?(path) && matches_pattern?(pattern, ref, tag, source)
end
end
- def match_ref?(pattern, ref, tag, trigger_request)
- pattern, path = pattern.split('@', 2)
- return false if path && path != self.path
+ def matches_path?(path)
+ return true unless path
+
+ path == self.path
+ end
+
+ def matches_pattern?(pattern, ref, tag, source)
return true if tag && pattern == 'tags'
return true if !tag && pattern == 'branches'
- return true if trigger_request.present? && pattern == 'triggers'
+ return true if source_to_pattern(source) == pattern
if pattern.first == "/" && pattern.last == "/"
Regexp.new(pattern[1...-1]) =~ ref
@@ -212,5 +230,13 @@ module Ci
pattern == ref
end
end
+
+ def source_to_pattern(source)
+ if %w[api external web].include?(source)
+ source
+ else
+ source&.pluralize
+ end
+ end
end
end
diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb
index bae4db1ca4d..6fc1d56d7a0 100644
--- a/lib/constraints/group_url_constrainer.rb
+++ b/lib/constraints/group_url_constrainer.rb
@@ -1,17 +1,9 @@
class GroupUrlConstrainer
def matches?(request)
- id = request.params[:id]
+ full_path = request.params[:group_id] || request.params[:id]
- return false unless valid?(id)
+ return false unless DynamicPathValidator.valid_group_path?(full_path)
- Group.find_by_full_path(id).present?
- end
-
- private
-
- def valid?(id)
- id.split('/').all? do |namespace|
- NamespaceValidator.valid?(namespace)
- end
+ Group.find_by_full_path(full_path, follow_redirects: request.get?).present?
end
end
diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb
index a10b4657d7d..5bef29eb1da 100644
--- a/lib/constraints/project_url_constrainer.rb
+++ b/lib/constraints/project_url_constrainer.rb
@@ -2,12 +2,12 @@ class ProjectUrlConstrainer
def matches?(request)
namespace_path = request.params[:namespace_id]
project_path = request.params[:project_id] || request.params[:id]
- full_path = namespace_path + '/' + project_path
+ full_path = [namespace_path, project_path].join('/')
- unless ProjectPathValidator.valid?(project_path)
- return false
- end
+ return false unless DynamicPathValidator.valid_project_path?(full_path)
- Project.find_by_full_path(full_path).present?
+ # We intentionally allow SELECT(*) here so result of this query can be used
+ # as cache for further Project.find_by_full_path calls within request
+ Project.find_by_full_path(full_path, follow_redirects: request.get?).present?
end
end
diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb
index 9ab5bcb12ff..d16ae7f3f40 100644
--- a/lib/constraints/user_url_constrainer.rb
+++ b/lib/constraints/user_url_constrainer.rb
@@ -1,5 +1,9 @@
class UserUrlConstrainer
def matches?(request)
- User.find_by_username(request.params[:username]).present?
+ full_path = request.params[:username]
+
+ return false unless DynamicPathValidator.valid_user_path?(full_path)
+
+ User.find_by_full_path(full_path, follow_redirects: request.get?).present?
end
end
diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb
index 7f5f6d9ddb6..c7263f302ab 100644
--- a/lib/container_registry/client.rb
+++ b/lib/container_registry/client.rb
@@ -75,10 +75,7 @@ module ContainerRegistry
def redirect_response(location)
return unless location
- # We explicitly remove authorization token
- faraday_blob.get(location) do |req|
- req['Authorization'] = ''
- end
+ faraday_redirect.get(location)
end
def faraday
@@ -93,5 +90,14 @@ module ContainerRegistry
initialize_connection(conn, @options)
end
end
+
+ # Create a new request to make sure the Authorization header is not inserted
+ # via the Faraday middleware
+ def faraday_redirect
+ @faraday_redirect ||= Faraday.new(@base_uri) do |conn|
+ conn.request :json
+ conn.adapter :net_http
+ end
+ end
end
end
diff --git a/lib/declarative_policy.rb b/lib/declarative_policy.rb
new file mode 100644
index 00000000000..ae65653645b
--- /dev/null
+++ b/lib/declarative_policy.rb
@@ -0,0 +1,94 @@
+require_dependency 'declarative_policy/cache'
+require_dependency 'declarative_policy/condition'
+require_dependency 'declarative_policy/dsl'
+require_dependency 'declarative_policy/preferred_scope'
+require_dependency 'declarative_policy/rule'
+require_dependency 'declarative_policy/runner'
+require_dependency 'declarative_policy/step'
+
+require_dependency 'declarative_policy/base'
+
+require 'thread'
+
+module DeclarativePolicy
+ CLASS_CACHE_MUTEX = Mutex.new
+ CLASS_CACHE_IVAR = :@__DeclarativePolicy_CLASS_CACHE
+
+ class << self
+ def policy_for(user, subject, opts = {})
+ cache = opts[:cache] || {}
+ key = Cache.policy_key(user, subject)
+
+ cache[key] ||= class_for(subject).new(user, subject, opts)
+ end
+
+ def class_for(subject)
+ return GlobalPolicy if subject == :global
+ return NilPolicy if subject.nil?
+
+ subject = find_delegate(subject)
+
+ policy_class = class_for_class(subject.class)
+ raise "no policy for #{subject.class.name}" if policy_class.nil?
+ policy_class
+ end
+
+ def has_policy?(subject)
+ !class_for_class(subject.class).nil?
+ end
+
+ private
+
+ # This method is heavily cached because there are a lot of anonymous
+ # modules in play in a typical rails app, and #name performs quite
+ # slowly for anonymous classes and modules.
+ #
+ # See https://bugs.ruby-lang.org/issues/11119
+ #
+ # if the above bug is resolved, this caching could likely be removed.
+ def class_for_class(subject_class)
+ unless subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
+ CLASS_CACHE_MUTEX.synchronize do
+ # re-check in case of a race
+ break if subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
+
+ policy_class = compute_class_for_class(subject_class)
+ subject_class.instance_variable_set(CLASS_CACHE_IVAR, policy_class)
+ end
+ end
+
+ subject_class.instance_variable_get(CLASS_CACHE_IVAR)
+ end
+
+ def compute_class_for_class(subject_class)
+ subject_class.ancestors.each do |klass|
+ next unless klass.name
+
+ begin
+ policy_class = "#{klass.name}Policy".constantize
+
+ # NOTE: the < operator here tests whether policy_class
+ # inherits from Base. We can't use #is_a? because that
+ # tests for *instances*, not *subclasses*.
+ return policy_class if policy_class < Base
+ rescue NameError
+ nil
+ end
+ end
+
+ nil
+ end
+
+ def find_delegate(subject)
+ seen = Set.new
+
+ while subject.respond_to?(:declarative_policy_delegate)
+ raise ArgumentError, "circular delegations" if seen.include?(subject.object_id)
+ seen << subject.object_id
+ subject = subject.declarative_policy_delegate
+ end
+
+ subject
+ end
+ end
+end
diff --git a/lib/declarative_policy/base.rb b/lib/declarative_policy/base.rb
new file mode 100644
index 00000000000..b028169f500
--- /dev/null
+++ b/lib/declarative_policy/base.rb
@@ -0,0 +1,329 @@
+module DeclarativePolicy
+ class Base
+ # A map of ability => list of rules together with :enable
+ # or :prevent actions. Used to look up which rules apply to
+ # a given ability. See Base.ability_map
+ class AbilityMap
+ attr_reader :map
+ def initialize(map = {})
+ @map = map
+ end
+
+ # This merge behavior is different than regular hashes - if both
+ # share a key, the values at that key are concatenated, rather than
+ # overridden.
+ def merge(other)
+ conflict_proc = proc { |key, my_val, other_val| my_val + other_val }
+ AbilityMap.new(@map.merge(other.map, &conflict_proc))
+ end
+
+ def actions(key)
+ @map[key] ||= []
+ end
+
+ def enable(key, rule)
+ actions(key) << [:enable, rule]
+ end
+
+ def prevent(key, rule)
+ actions(key) << [:prevent, rule]
+ end
+ end
+
+ class << self
+ # The `own_ability_map` vs `ability_map` distinction is used so that
+ # the data structure is properly inherited - with subclasses recursively
+ # merging their parent class.
+ #
+ # This pattern is also used for conditions, global_actions, and delegations.
+ def ability_map
+ if self == Base
+ own_ability_map
+ else
+ superclass.ability_map.merge(own_ability_map)
+ end
+ end
+
+ def own_ability_map
+ @own_ability_map ||= AbilityMap.new
+ end
+
+ # an inheritable map of conditions, by name
+ def conditions
+ if self == Base
+ own_conditions
+ else
+ superclass.conditions.merge(own_conditions)
+ end
+ end
+
+ def own_conditions
+ @own_conditions ||= {}
+ end
+
+ # a list of global actions, generated by `prevent_all`. these aren't
+ # stored in `ability_map` because they aren't indexed by a particular
+ # ability.
+ def global_actions
+ if self == Base
+ own_global_actions
+ else
+ superclass.global_actions + own_global_actions
+ end
+ end
+
+ def own_global_actions
+ @own_global_actions ||= []
+ end
+
+ # an inheritable map of delegations, indexed by name (which may be
+ # autogenerated)
+ def delegations
+ if self == Base
+ own_delegations
+ else
+ superclass.delegations.merge(own_delegations)
+ end
+ end
+
+ def own_delegations
+ @own_delegations ||= {}
+ end
+
+ # all the [rule, action] pairs that apply to a particular ability.
+ # we combine the specific ones looked up in ability_map with the global
+ # ones.
+ def configuration_for(ability)
+ ability_map.actions(ability) + global_actions
+ end
+
+ ### declaration methods ###
+
+ def delegate(name = nil, &delegation_block)
+ if name.nil?
+ @delegate_name_counter ||= 0
+ @delegate_name_counter += 1
+ name = :"anonymous_#{@delegate_name_counter}"
+ end
+
+ name = name.to_sym
+
+ if delegation_block.nil?
+ delegation_block = proc { @subject.__send__(name) } # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ own_delegations[name] = delegation_block
+ end
+
+ # Declares a rule, constructed using RuleDsl, and returns
+ # a PolicyDsl which is used for registering the rule with
+ # this class. PolicyDsl will call back into Base.enable_when,
+ # Base.prevent_when, and Base.prevent_all_when.
+ def rule(&b)
+ rule = RuleDsl.new(self).instance_eval(&b)
+ PolicyDsl.new(self, rule)
+ end
+
+ # A hash in which to store calls to `desc` and `with_scope`, etc.
+ def last_options
+ @last_options ||= {}.with_indifferent_access
+ end
+
+ # retrieve and zero out the previously set options (used in .condition)
+ def last_options!
+ last_options.tap { @last_options = nil }
+ end
+
+ # Declare a description for the following condition. Currently unused,
+ # but opens the potential for explaining to users why they were or were
+ # not able to do something.
+ def desc(description)
+ last_options[:description] = description
+ end
+
+ def with_options(opts = {})
+ last_options.merge!(opts)
+ end
+
+ def with_scope(scope)
+ with_options scope: scope
+ end
+
+ def with_score(score)
+ with_options score: score
+ end
+
+ # Declares a condition. It gets stored in `own_conditions`, and generates
+ # a query method based on the condition's name.
+ def condition(name, opts = {}, &value)
+ name = name.to_sym
+
+ opts = last_options!.merge(opts)
+ opts[:context_key] ||= self.name
+
+ condition = Condition.new(name, opts, &value)
+
+ self.own_conditions[name] = condition
+
+ define_method(:"#{name}?") { condition(name).pass? }
+ end
+
+ # These next three methods are mainly called from PolicyDsl,
+ # and are responsible for "inverting" the relationship between
+ # an ability and a rule. We store in `ability_map` a map of
+ # abilities to rules that affect them, together with a
+ # symbol indicating :prevent or :enable.
+ def enable_when(abilities, rule)
+ abilities.each { |a| own_ability_map.enable(a, rule) }
+ end
+
+ def prevent_when(abilities, rule)
+ abilities.each { |a| own_ability_map.prevent(a, rule) }
+ end
+
+ # we store global prevents (from `prevent_all`) separately,
+ # so that they can be combined into every decision made.
+ def prevent_all_when(rule)
+ own_global_actions << [:prevent, rule]
+ end
+ end
+
+ # A policy object contains a specific user and subject on which
+ # to compute abilities. For this reason it's sometimes called
+ # "context" within the framework.
+ #
+ # It also stores a reference to the cache, so it can be used
+ # to cache computations by e.g. ManifestCondition.
+ attr_reader :user, :subject, :cache
+ def initialize(user, subject, opts = {})
+ @user = user
+ @subject = subject
+ @cache = opts[:cache] || {}
+ end
+
+ # helper for checking abilities on this and other subjects
+ # for the current user.
+ def can?(ability, new_subject = :_self)
+ return allowed?(ability) if new_subject == :_self
+
+ policy_for(new_subject).allowed?(ability)
+ end
+
+ # This is the main entry point for permission checks. It constructs
+ # or looks up a Runner for the given ability and asks it if it passes.
+ def allowed?(*abilities)
+ abilities.all? { |a| runner(a).pass? }
+ end
+
+ # The inverse of #allowed?, used mainly in specs.
+ def disallowed?(*abilities)
+ abilities.all? { |a| !runner(a).pass? }
+ end
+
+ # computes the given ability and prints a helpful debugging output
+ # showing which
+ def debug(ability, *a)
+ runner(ability).debug(*a)
+ end
+
+ desc "Unknown user"
+ condition(:anonymous, scope: :user, score: 0) { @user.nil? }
+
+ desc "By default"
+ condition(:default, scope: :global, score: 0) { true }
+
+ def repr
+ subject_repr =
+ if @subject.respond_to?(:id)
+ "#{@subject.class.name}/#{@subject.id}"
+ else
+ @subject.inspect
+ end
+
+ user_repr =
+ if @user
+ @user.to_reference
+ else
+ "<anonymous>"
+ end
+
+ "(#{user_repr} : #{subject_repr})"
+ end
+
+ def inspect
+ "#<#{self.class.name} #{repr}>"
+ end
+
+ # returns a Runner for the given ability, capable of computing whether
+ # the ability is allowed. Runners are cached on the policy (which itself
+ # is cached on @cache), and caches its result. This is how we perform caching
+ # at the ability level.
+ def runner(ability)
+ ability = ability.to_sym
+ @runners ||= {}
+ @runners[ability] ||=
+ begin
+ delegated_runners = delegated_policies.values.compact.map { |p| p.runner(ability) }
+ own_runner = Runner.new(own_steps(ability))
+ delegated_runners.inject(own_runner, &:merge_runner)
+ end
+ end
+
+ # Helpers for caching. Used by ManifestCondition in performing condition
+ # computation.
+ #
+ # NOTE we can't use ||= here because the value might be the
+ # boolean `false`
+ def cache(key, &b)
+ return @cache[key] if cached?(key)
+ @cache[key] = yield
+ end
+
+ def cached?(key)
+ !@cache[key].nil?
+ end
+
+ # returns a ManifestCondition capable of computing itself. The computation
+ # will use our own @cache.
+ def condition(name)
+ name = name.to_sym
+ @_conditions ||= {}
+ @_conditions[name] ||=
+ begin
+ raise "invalid condition #{name}" unless self.class.conditions.key?(name)
+ ManifestCondition.new(self.class.conditions[name], self)
+ end
+ end
+
+ # used in specs - returns true if there is no possible way for any action
+ # to be allowed, determined only by the global :prevent_all rules.
+ def banned?
+ global_steps = self.class.global_actions.map { |(action, rule)| Step.new(self, rule, action) }
+ !Runner.new(global_steps).pass?
+ end
+
+ # A list of other policies that we've delegated to (see `Base.delegate`)
+ def delegated_policies
+ @delegated_policies ||= self.class.delegations.transform_values do |block|
+ new_subject = instance_eval(&block)
+
+ # never delegate to nil, as that would immediately prevent_all
+ next if new_subject.nil?
+
+ policy_for(new_subject)
+ end
+ end
+
+ def policy_for(other_subject)
+ DeclarativePolicy.policy_for(@user, other_subject, cache: @cache)
+ end
+
+ protected
+
+ # constructs steps that come from this policy and not from any delegations
+ def own_steps(ability)
+ rules = self.class.configuration_for(ability)
+ rules.map { |(action, rule)| Step.new(self, rule, action) }
+ end
+ end
+end
diff --git a/lib/declarative_policy/cache.rb b/lib/declarative_policy/cache.rb
new file mode 100644
index 00000000000..0804edba016
--- /dev/null
+++ b/lib/declarative_policy/cache.rb
@@ -0,0 +1,35 @@
+module DeclarativePolicy
+ module Cache
+ class << self
+ def user_key(user)
+ return '<anonymous>' if user.nil?
+ id_for(user)
+ end
+
+ def policy_key(user, subject)
+ u = user_key(user)
+ s = subject_key(subject)
+ "/dp/policy/#{u}/#{s}"
+ end
+
+ def subject_key(subject)
+ return '<nil>' if subject.nil?
+ return subject.inspect if subject.is_a?(Symbol)
+ "#{subject.class.name}:#{id_for(subject)}"
+ end
+
+ private
+
+ def id_for(obj)
+ id =
+ begin
+ obj.id
+ rescue NoMethodError
+ nil
+ end
+
+ id || "##{obj.object_id}"
+ end
+ end
+ end
+end
diff --git a/lib/declarative_policy/condition.rb b/lib/declarative_policy/condition.rb
new file mode 100644
index 00000000000..51c4a8b2bbe
--- /dev/null
+++ b/lib/declarative_policy/condition.rb
@@ -0,0 +1,103 @@
+module DeclarativePolicy
+ # A Condition is the data structure that is created by the
+ # `condition` declaration on DeclarativePolicy::Base. It is
+ # more or less just a struct of the data passed to that
+ # declaration. It holds on to the block to be instance_eval'd
+ # on a context (instance of Base) later, via #compute.
+ class Condition
+ attr_reader :name, :description, :scope
+ attr_reader :manual_score
+ attr_reader :context_key
+ def initialize(name, opts = {}, &compute)
+ @name = name
+ @compute = compute
+ @scope = opts.fetch(:scope, :normal)
+ @description = opts.delete(:description)
+ @context_key = opts[:context_key]
+ @manual_score = opts.fetch(:score, nil)
+ end
+
+ def compute(context)
+ !!context.instance_eval(&@compute)
+ end
+
+ def key
+ "#{@context_key}/#{@name}"
+ end
+ end
+
+ # In contrast to a Condition, a ManifestCondition contains
+ # a Condition and a context object, and is capable of calculating
+ # a result itself. This is the return value of Base#condition.
+ class ManifestCondition
+ def initialize(condition, context)
+ @condition = condition
+ @context = context
+ end
+
+ # The main entry point - does this condition pass? We reach into
+ # the context's cache here so that we can share in the global
+ # cache (often RequestStore or similar).
+ def pass?
+ @context.cache(cache_key) { @condition.compute(@context) }
+ end
+
+ # Whether we've already computed this condition.
+ def cached?
+ @context.cached?(cache_key)
+ end
+
+ # This is used to score Rule::Condition. See Rule::Condition#score
+ # and Runner#steps_by_score for how scores are used.
+ #
+ # The number here is intended to represent, abstractly, how
+ # expensive it would be to calculate this condition.
+ #
+ # See #cache_key for info about @condition.scope.
+ def score
+ # If we've been cached, no computation is necessary.
+ return 0 if cached?
+
+ # Use the override from condition(score: ...) if present
+ return @condition.manual_score if @condition.manual_score
+
+ # Global scope rules are cheap due to max cache sharing
+ return 2 if @condition.scope == :global
+
+ # "Normal" rules can't share caches with any other policies
+ return 16 if @condition.scope == :normal
+
+ # otherwise, we're :user or :subject scope, so it's 4 if
+ # the caller has declared a preference
+ return 4 if @condition.scope == DeclarativePolicy.preferred_scope
+
+ # and 8 for all other :user or :subject scope conditions.
+ 8
+ end
+
+ private
+
+ # This method controls the caching for the condition. This is where
+ # the condition(scope: ...) option comes into play. Notice that
+ # depending on the scope, we may cache only by the user or only by
+ # the subject, resulting in sharing across different policy objects.
+ def cache_key
+ @cache_key ||=
+ case @condition.scope
+ when :normal then "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}"
+ when :user then "/dp/condition/#{@condition.key}/#{user_key}"
+ when :subject then "/dp/condition/#{@condition.key}/#{subject_key}"
+ when :global then "/dp/condition/#{@condition.key}"
+ else raise 'invalid scope'
+ end
+ end
+
+ def user_key
+ Cache.user_key(@context.user)
+ end
+
+ def subject_key
+ Cache.subject_key(@context.subject)
+ end
+ end
+end
diff --git a/lib/declarative_policy/dsl.rb b/lib/declarative_policy/dsl.rb
new file mode 100644
index 00000000000..6ba1e7a3c5c
--- /dev/null
+++ b/lib/declarative_policy/dsl.rb
@@ -0,0 +1,103 @@
+module DeclarativePolicy
+ # The DSL evaluation context inside rule { ... } blocks.
+ # Responsible for creating and combining Rule objects.
+ #
+ # See Base.rule
+ class RuleDsl
+ def initialize(context_class)
+ @context_class = context_class
+ end
+
+ def can?(ability)
+ Rule::Ability.new(ability)
+ end
+
+ def all?(*rules)
+ Rule::And.make(rules)
+ end
+
+ def any?(*rules)
+ Rule::Or.make(rules)
+ end
+
+ def none?(*rules)
+ ~Rule::Or.new(rules)
+ end
+
+ def cond(condition)
+ Rule::Condition.new(condition)
+ end
+
+ def delegate(delegate_name, condition)
+ Rule::DelegatedCondition.new(delegate_name, condition)
+ end
+
+ def method_missing(m, *a, &b)
+ return super unless a.size == 0 && !block_given?
+
+ if @context_class.delegations.key?(m)
+ DelegateDsl.new(self, m)
+ else
+ cond(m.to_sym)
+ end
+ end
+ end
+
+ # Used when the name of a delegate is mentioned in
+ # the rule DSL.
+ class DelegateDsl
+ def initialize(rule_dsl, delegate_name)
+ @rule_dsl = rule_dsl
+ @delegate_name = delegate_name
+ end
+
+ def method_missing(m, *a, &b)
+ return super unless a.size == 0 && !block_given?
+
+ @rule_dsl.delegate(@delegate_name, m)
+ end
+ end
+
+ # The return value of a rule { ... } declaration.
+ # Can call back to register rules with the containing
+ # Policy class (context_class here). See Base.rule
+ #
+ # Note that the #policy method just performs an #instance_eval,
+ # which is useful for multiple #enable or #prevent callse.
+ #
+ # Also provides a #method_missing proxy to the context
+ # class's class methods, so that helper methods can be
+ # defined and used in a #policy { ... } block.
+ class PolicyDsl
+ def initialize(context_class, rule)
+ @context_class = context_class
+ @rule = rule
+ end
+
+ def policy(&b)
+ instance_eval(&b)
+ end
+
+ def enable(*abilities)
+ @context_class.enable_when(abilities, @rule)
+ end
+
+ def prevent(*abilities)
+ @context_class.prevent_when(abilities, @rule)
+ end
+
+ def prevent_all
+ @context_class.prevent_all_when(@rule)
+ end
+
+ def method_missing(m, *a, &b)
+ return super unless @context_class.respond_to?(m)
+
+ @context_class.__send__(m, *a, &b) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def respond_to_missing?(m)
+ @context_class.respond_to?(m) || super
+ end
+ end
+end
diff --git a/lib/declarative_policy/preferred_scope.rb b/lib/declarative_policy/preferred_scope.rb
new file mode 100644
index 00000000000..b0754098149
--- /dev/null
+++ b/lib/declarative_policy/preferred_scope.rb
@@ -0,0 +1,28 @@
+module DeclarativePolicy
+ PREFERRED_SCOPE_KEY = :"DeclarativePolicy.preferred_scope"
+
+ class << self
+ def with_preferred_scope(scope, &b)
+ Thread.current[PREFERRED_SCOPE_KEY], old_scope = scope, Thread.current[PREFERRED_SCOPE_KEY]
+ yield
+ ensure
+ Thread.current[PREFERRED_SCOPE_KEY] = old_scope
+ end
+
+ def preferred_scope
+ Thread.current[PREFERRED_SCOPE_KEY]
+ end
+
+ def user_scope(&b)
+ with_preferred_scope(:user, &b)
+ end
+
+ def subject_scope(&b)
+ with_preferred_scope(:subject, &b)
+ end
+
+ def preferred_scope=(scope)
+ Thread.current[PREFERRED_SCOPE_KEY] = scope
+ end
+ end
+end
diff --git a/lib/declarative_policy/rule.rb b/lib/declarative_policy/rule.rb
new file mode 100644
index 00000000000..bfcec241489
--- /dev/null
+++ b/lib/declarative_policy/rule.rb
@@ -0,0 +1,301 @@
+module DeclarativePolicy
+ module Rule
+ # A Rule is the object that results from the `rule` declaration,
+ # usually built using the DSL in `RuleDsl`. It is a basic logical
+ # combination of building blocks, and is capable of deciding,
+ # given a context (instance of DeclarativePolicy::Base) whether it
+ # passes or not. Note that this decision doesn't by itself know
+ # how that affects the actual ability decision - for that, a
+ # `Step` is used.
+ class Base
+ def self.make(*a)
+ new(*a).simplify
+ end
+
+ # true or false whether this rule passes.
+ # `context` is a policy - an instance of
+ # DeclarativePolicy::Base.
+ def pass?(context)
+ raise 'abstract'
+ end
+
+ # same as #pass? except refuses to do any I/O,
+ # returning nil if the result is not yet cached.
+ # used for accurately scoring And/Or
+ def cached_pass?(context)
+ raise 'abstract'
+ end
+
+ # abstractly, how long would it take to compute
+ # this rule? lower-scored rules are tried first.
+ def score(context)
+ raise 'abstract'
+ end
+
+ # unwrap double negatives and nested and/or
+ def simplify
+ self
+ end
+
+ # convenience combination methods
+ def or(other)
+ Or.make([self, other])
+ end
+
+ def and(other)
+ And.make([self, other])
+ end
+
+ def negate
+ Not.make(self)
+ end
+
+ alias_method :|, :or
+ alias_method :&, :and
+ alias_method :~@, :negate
+
+ def inspect
+ "#<Rule #{repr}>"
+ end
+ end
+
+ # A rule that checks a condition. This is the
+ # type of rule that results from a basic bareword
+ # in the rule dsl (see RuleDsl#method_missing).
+ class Condition < Base
+ def initialize(name)
+ @name = name
+ end
+
+ # we delegate scoring to the condition. See
+ # ManifestCondition#score.
+ def score(context)
+ context.condition(@name).score
+ end
+
+ # Let the ManifestCondition from the context
+ # decide whether we pass.
+ def pass?(context)
+ context.condition(@name).pass?
+ end
+
+ # returns nil unless it's already cached
+ def cached_pass?(context)
+ condition = context.condition(@name)
+ return nil unless condition.cached?
+ condition.pass?
+ end
+
+ def description(context)
+ context.class.conditions[@name].description
+ end
+
+ def repr
+ @name.to_s
+ end
+ end
+
+ # A rule constructed from DelegateDsl - using a condition from a
+ # delegated policy.
+ class DelegatedCondition < Base
+ # Internal use only - this is rescued each time it's raised.
+ MissingDelegate = Class.new(StandardError)
+
+ def initialize(delegate_name, name)
+ @delegate_name = delegate_name
+ @name = name
+ end
+
+ def delegated_context(context)
+ policy = context.delegated_policies[@delegate_name]
+ raise MissingDelegate if policy.nil?
+ policy
+ end
+
+ def score(context)
+ delegated_context(context).condition(@name).score
+ rescue MissingDelegate
+ 0
+ end
+
+ def cached_pass?(context)
+ condition = delegated_context(context).condition(@name)
+ return nil unless condition.cached?
+ condition.pass?
+ rescue MissingDelegate
+ false
+ end
+
+ def pass?(context)
+ delegated_context(context).condition(@name).pass?
+ rescue MissingDelegate
+ false
+ end
+
+ def repr
+ "#{@delegate_name}.#{@name}"
+ end
+ end
+
+ # A rule constructed from RuleDsl#can?. Computes a different ability
+ # on the same subject.
+ class Ability < Base
+ attr_reader :ability
+ def initialize(ability)
+ @ability = ability
+ end
+
+ # We ask the ability's runner for a score
+ def score(context)
+ context.runner(@ability).score
+ end
+
+ def pass?(context)
+ context.allowed?(@ability)
+ end
+
+ def cached_pass?(context)
+ runner = context.runner(@ability)
+ return nil unless runner.cached?
+ runner.pass?
+ end
+
+ def description(context)
+ "User can #{@ability.inspect}"
+ end
+
+ def repr
+ "can?(#{@ability.inspect})"
+ end
+ end
+
+ # Logical `and`, containing a list of rules. Only passes
+ # if all of them do.
+ class And < Base
+ attr_reader :rules
+ def initialize(rules)
+ @rules = rules
+ end
+
+ def simplify
+ simplified_rules = @rules.flat_map do |rule|
+ simplified = rule.simplify
+ case simplified
+ when And then simplified.rules
+ else [simplified]
+ end
+ end
+
+ And.new(simplified_rules)
+ end
+
+ def score(context)
+ return 0 unless cached_pass?(context).nil?
+
+ # note that cached rules will have score 0 anyways.
+ @rules.map { |r| r.score(context) }.inject(0, :+)
+ end
+
+ def pass?(context)
+ # try to find a cached answer before
+ # checking in order
+ cached = cached_pass?(context)
+ return cached unless cached.nil?
+
+ @rules.all? { |r| r.pass?(context) }
+ end
+
+ def cached_pass?(context)
+ passes = @rules.map { |r| r.cached_pass?(context) }
+ return false if passes.any? { |p| p == false }
+ return true if passes.all? { |p| p == true }
+
+ nil
+ end
+
+ def repr
+ "all?(#{rules.map(&:repr).join(', ')})"
+ end
+ end
+
+ # Logical `or`. Mirrors And.
+ class Or < Base
+ attr_reader :rules
+ def initialize(rules)
+ @rules = rules
+ end
+
+ def pass?(context)
+ cached = cached_pass?(context)
+ return cached unless cached.nil?
+
+ @rules.any? { |r| r.pass?(context) }
+ end
+
+ def simplify
+ simplified_rules = @rules.flat_map do |rule|
+ simplified = rule.simplify
+ case simplified
+ when Or then simplified.rules
+ else [simplified]
+ end
+ end
+
+ Or.new(simplified_rules)
+ end
+
+ def cached_pass?(context)
+ passes = @rules.map { |r| r.cached_pass?(context) }
+ return true if passes.any? { |p| p == true }
+ return false if passes.all? { |p| p == false }
+
+ nil
+ end
+
+ def score(context)
+ return 0 unless cached_pass?(context).nil?
+ @rules.map { |r| r.score(context) }.inject(0, :+)
+ end
+
+ def repr
+ "any?(#{@rules.map(&:repr).join(', ')})"
+ end
+ end
+
+ class Not < Base
+ attr_reader :rule
+ def initialize(rule)
+ @rule = rule
+ end
+
+ def simplify
+ case @rule
+ when And then Or.new(@rule.rules.map(&:negate)).simplify
+ when Or then And.new(@rule.rules.map(&:negate)).simplify
+ when Not then @rule.rule.simplify
+ else Not.new(@rule.simplify)
+ end
+ end
+
+ def pass?(context)
+ !@rule.pass?(context)
+ end
+
+ def cached_pass?(context)
+ case @rule.cached_pass?(context)
+ when nil then nil
+ when true then false
+ when false then true
+ end
+ end
+
+ def score(context)
+ @rule.score(context)
+ end
+
+ def repr
+ "~#{@rule.repr}"
+ end
+ end
+ end
+end
diff --git a/lib/declarative_policy/runner.rb b/lib/declarative_policy/runner.rb
new file mode 100644
index 00000000000..56afd1f1392
--- /dev/null
+++ b/lib/declarative_policy/runner.rb
@@ -0,0 +1,183 @@
+module DeclarativePolicy
+ class Runner
+ class State
+ def initialize
+ @enabled = false
+ @prevented = false
+ end
+
+ def enable!
+ @enabled = true
+ end
+
+ def enabled?
+ @enabled
+ end
+
+ def prevent!
+ @prevented = true
+ end
+
+ def prevented?
+ @prevented
+ end
+
+ def pass?
+ !prevented? && enabled?
+ end
+ end
+
+ # a Runner contains a list of Steps to be run.
+ attr_reader :steps
+ def initialize(steps)
+ @steps = steps
+ end
+
+ # We make sure only to run any given Runner once,
+ # and just continue to use the resulting @state
+ # that's left behind.
+ def cached?
+ !!@state
+ end
+
+ # used by Rule::Ability. See #steps_by_score
+ def score
+ return 0 if cached?
+ steps.map(&:score).inject(0, :+)
+ end
+
+ def merge_runner(other)
+ Runner.new(@steps + other.steps)
+ end
+
+ # The main entry point, called for making an ability decision.
+ # See #run and DeclarativePolicy::Base#can?
+ def pass?
+ run unless cached?
+
+ @state.pass?
+ end
+
+ # see DeclarativePolicy::Base#debug
+ def debug(out = $stderr)
+ run(out)
+ end
+
+ private
+
+ def flatten_steps!
+ @steps = @steps.flat_map { |s| s.flattened(@steps) }
+ end
+
+ # This method implements the semantic of "one enable and no prevents".
+ # It relies on #steps_by_score for the main loop, and updates @state
+ # with the result of the step.
+ def run(debug = nil)
+ @state = State.new
+
+ steps_by_score do |step, score|
+ return if !debug && @state.prevented?
+
+ passed = nil
+ case step.action
+ when :enable then
+ # we only check :enable actions if they have a chance of
+ # changing the outcome - if no other rule has enabled or
+ # prevented.
+ unless @state.enabled? || @state.prevented?
+ passed = step.pass?
+ @state.enable! if passed
+ end
+
+ debug << inspect_step(step, score, passed) if debug
+ when :prevent then
+ # we only check :prevent actions if the state hasn't already
+ # been prevented.
+ unless @state.prevented?
+ passed = step.pass?
+ @state.prevent! if passed
+ end
+
+ debug << inspect_step(step, score, passed) if debug
+ else raise "invalid action #{step.action.inspect}"
+ end
+ end
+
+ @state
+ end
+
+ # This is the core spot where all those `#score` methods matter.
+ # It is critcal for performance to run steps in the correct order,
+ # so that we don't compute expensive conditions (potentially n times
+ # if we're called on, say, a large list of users).
+ #
+ # In order to determine the cheapest step to run next, we rely on
+ # Step#score, which returns a numerical rating of how expensive
+ # it would be to calculate - the lower the better. It would be
+ # easy enough to statically sort by these scores, but we can do
+ # a little better - the scores are cache-aware (conditions that
+ # are already in the cache have score 0), which means that running
+ # a step can actually change the scores of other steps.
+ #
+ # So! The way we sort here involves re-scoring at every step. This
+ # is by necessity quadratic, but most of the time the number of steps
+ # will be low. But just in case, if the number of steps exceeds 50,
+ # we print a warning and fall back to a static sort.
+ #
+ # For each step, we yield the step object along with the computed score
+ # for debugging purposes.
+ def steps_by_score(&b)
+ flatten_steps!
+
+ if @steps.size > 50
+ warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort"
+
+ @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)|
+ yield step, score
+ end
+
+ return
+ end
+
+ steps = Set.new(@steps)
+ remaining_enablers = steps.count { |s| s.enable? }
+
+ loop do
+ return if steps.empty?
+
+ # if the permission hasn't yet been enabled and we only have
+ # prevent steps left, we short-circuit the state here
+ @state.prevent! if !@state.enabled? && remaining_enablers == 0
+
+ lowest_score = Float::INFINITY
+ next_step = nil
+
+ steps.each do |step|
+ score = step.score
+ if score < lowest_score
+ next_step = step
+ lowest_score = score
+ end
+ end
+
+ steps.delete(next_step)
+
+ remaining_enablers -= 1 if next_step.enable?
+
+ yield next_step, lowest_score
+ end
+ end
+
+ # Formatter for debugging output.
+ def inspect_step(step, original_score, passed)
+ symbol =
+ case passed
+ when true then '+'
+ when false then '-'
+ when nil then ' '
+ end
+
+ "#{symbol} [#{original_score.to_i}] #{step.repr}\n"
+ end
+ end
+end
diff --git a/lib/declarative_policy/step.rb b/lib/declarative_policy/step.rb
new file mode 100644
index 00000000000..3469fe9f991
--- /dev/null
+++ b/lib/declarative_policy/step.rb
@@ -0,0 +1,86 @@
+module DeclarativePolicy
+ # This object represents one step in the runtime decision of whether
+ # an ability is allowed. It contains a Rule and a context (instance
+ # of DeclarativePolicy::Base), which contains the user, the subject,
+ # and the cache. It also contains an "action", which is the symbol
+ # :prevent or :enable.
+ class Step
+ attr_reader :context, :rule, :action
+ def initialize(context, rule, action)
+ @context = context
+ @rule = rule
+ @action = action
+ end
+
+ # In the flattening process, duplicate steps may be generated in the
+ # same rule. This allows us to eliminate those (see Runner#steps_by_score
+ # and note its use of a Set)
+ def ==(other)
+ @context == other.context && @rule == other.rule && @action == other.action
+ end
+
+ # In the runner, steps are sorted dynamically by score, so that
+ # we are sure to compute them in close to the optimal order.
+ #
+ # See also Rule#score, ManifestCondition#score, and Runner#steps_by_score.
+ def score
+ # we slightly prefer the preventative actions
+ # since they are more likely to short-circuit
+ case @action
+ when :prevent
+ @rule.score(@context) * (7.0 / 8)
+ when :enable
+ @rule.score(@context)
+ end
+ end
+
+ def with_action(action)
+ Step.new(@context, @rule, action)
+ end
+
+ def enable?
+ @action == :enable
+ end
+
+ def prevent?
+ @action == :prevent
+ end
+
+ # This rather complex method allows us to split rules into parts so that
+ # they can be sorted independently for better optimization
+ def flattened(roots)
+ case @rule
+ when Rule::Or
+ # A single `Or` step is the same as each of its elements as separate steps
+ @rule.rules.flat_map { |r| Step.new(@context, r, @action).flattened(roots) }
+ when Rule::Ability
+ # This looks like a weird micro-optimization but it buys us quite a lot
+ # in some cases. If we depend on an Ability (i.e. a `can?(...)` rule),
+ # and that ability *only* has :enable actions (modulo some actions that
+ # we already have taken care of), then its rules can be safely inlined.
+ steps = @context.runner(@rule.ability).steps.reject { |s| roots.include?(s) }
+
+ if steps.all?(&:enable?)
+ # in the case that we are a :prevent step, each inlined step becomes
+ # an independent :prevent, even though it was an :enable in its initial
+ # context.
+ steps.map! { |s| s.with_action(:prevent) } if prevent?
+
+ steps.flat_map { |s| s.flattened(roots) }
+ else
+ [self]
+ end
+ else
+ [self]
+ end
+ end
+
+ def pass?
+ @rule.pass?(@context)
+ end
+
+ def repr
+ "#{@action} when #{@rule.repr} (#{@context.repr})"
+ end
+ end
+end
diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb
index dd864eea3fa..721ed97bb6b 100644
--- a/lib/extracts_path.rb
+++ b/lib/extracts_path.rb
@@ -126,8 +126,7 @@ module ExtractsPath
raise InvalidPathError unless @commit
@hex_path = Digest::SHA1.hexdigest(@path)
- @logs_path = logs_file_namespace_project_ref_path(@project.namespace,
- @project, @ref, @path)
+ @logs_path = logs_file_project_ref_path(@project, @ref, @path)
rescue RuntimeError, NoMethodError, InvalidPathError
render_404
diff --git a/lib/feature.rb b/lib/feature.rb
new file mode 100644
index 00000000000..4bd29aed687
--- /dev/null
+++ b/lib/feature.rb
@@ -0,0 +1,67 @@
+require 'flipper/adapters/active_record'
+
+class Feature
+ # Classes to override flipper table names
+ class FlipperFeature < Flipper::Adapters::ActiveRecord::Feature
+ # Using `self.table_name` won't work. ActiveRecord bug?
+ superclass.table_name = 'features'
+ end
+
+ class FlipperGate < Flipper::Adapters::ActiveRecord::Gate
+ superclass.table_name = 'feature_gates'
+ end
+
+ class << self
+ delegate :group, to: :flipper
+
+ def all
+ flipper.features.to_a
+ end
+
+ def get(key)
+ flipper.feature(key)
+ end
+
+ def persisted?(feature)
+ # Flipper creates on-memory features when asked for a not-yet-created one.
+ # If we want to check if a feature has been actually set, we look for it
+ # on the persisted features list.
+ all.map(&:name).include?(feature.name)
+ end
+
+ def enabled?(key, thing = nil)
+ get(key).enabled?(thing)
+ end
+
+ def enable(key, thing = true)
+ get(key).enable(thing)
+ end
+
+ def disable(key, thing = false)
+ get(key).disable(thing)
+ end
+
+ def enable_group(key, group)
+ get(key).enable_group(group)
+ end
+
+ def disable_group(key, group)
+ get(key).disable_group(group)
+ end
+
+ def flipper
+ @flipper ||= begin
+ adapter = Flipper::Adapters::ActiveRecord.new(
+ feature_class: FlipperFeature, gate_class: FlipperGate)
+
+ Flipper.new(adapter)
+ end
+ end
+
+ # This method is called from config/initializers/flipper.rb and can be used
+ # to register Flipper groups.
+ # See https://docs.gitlab.com/ee/development/feature_flags.html#feature-groups
+ def register_feature_groups
+ end
+ end
+end
diff --git a/lib/file_size_validator.rb b/lib/file_size_validator.rb
index eb19ab45ac3..de391de9059 100644
--- a/lib/file_size_validator.rb
+++ b/lib/file_size_validator.rb
@@ -44,13 +44,13 @@ class FileSizeValidator < ActiveModel::EachValidator
when Integer
check_value
when Symbol
- record.send(check_value)
+ record.public_send(check_value) # rubocop:disable GitlabSecurity/PublicSend
end
value ||= [] if key == :maximum
value_size = value.size
- next if value_size.send(validity_check, check_value)
+ next if value_size.public_send(validity_check, check_value) # rubocop:disable GitlabSecurity/PublicSend
errors_options = options.except(*RESERVED_OPTIONS)
errors_options[:file_size] = help.number_to_human_size check_value
diff --git a/lib/file_streamer.rb b/lib/file_streamer.rb
deleted file mode 100644
index 4e3c6d3c773..00000000000
--- a/lib/file_streamer.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-class FileStreamer #:nodoc:
- attr_reader :to_path
-
- def initialize(path)
- @to_path = path
- end
-
- # Stream the file's contents if Rack::Sendfile isn't present.
- def each
- File.open(to_path, 'rb') do |file|
- while chunk = file.read(16384)
- yield chunk
- end
- end
- end
-end
diff --git a/lib/github/client.rb b/lib/github/client.rb
index e65d908d232..9c476df7d46 100644
--- a/lib/github/client.rb
+++ b/lib/github/client.rb
@@ -1,13 +1,16 @@
module Github
class Client
+ TIMEOUT = 60
+
attr_reader :connection, :rate_limit
def initialize(options)
- @connection = Faraday.new(url: options.fetch(:url)) do |faraday|
- faraday.options.open_timeout = options.fetch(:timeout, 60)
- faraday.options.timeout = options.fetch(:timeout, 60)
+ @connection = Faraday.new(url: options.fetch(:url, root_endpoint)) do |faraday|
+ faraday.options.open_timeout = options.fetch(:timeout, TIMEOUT)
+ faraday.options.timeout = options.fetch(:timeout, TIMEOUT)
faraday.authorization 'token', options.fetch(:token)
faraday.adapter :net_http
+ faraday.ssl.verify = verify_ssl
end
@rate_limit = RateLimit.new(connection)
@@ -19,5 +22,32 @@ module Github
Github::Response.new(connection.get(url, query))
end
+
+ private
+
+ def root_endpoint
+ custom_endpoint || github_endpoint
+ end
+
+ def custom_endpoint
+ github_omniauth_provider.dig('args', 'client_options', 'site')
+ end
+
+ def verify_ssl
+ # If there is no config, we're connecting to github.com
+ # and we should verify ssl.
+ github_omniauth_provider.fetch('verify_ssl', true)
+ end
+
+ def github_endpoint
+ OmniAuth::Strategies::GitHub.default_options[:client_options][:site]
+ end
+
+ def github_omniauth_provider
+ @github_omniauth_provider ||=
+ Gitlab.config.omniauth.providers
+ .find { |provider| provider.name == 'github' }
+ .to_h
+ end
end
end
diff --git a/lib/github/import.rb b/lib/github/import.rb
index d49761fd6c6..4cc01593ef4 100644
--- a/lib/github/import.rb
+++ b/lib/github/import.rb
@@ -1,4 +1,5 @@
require_relative 'error'
+
module Github
class Import
include Gitlab::ShellAdapter
@@ -6,6 +7,7 @@ module Github
class MergeRequest < ::MergeRequest
self.table_name = 'merge_requests'
+ self.reset_callbacks :create
self.reset_callbacks :save
self.reset_callbacks :commit
self.reset_callbacks :update
@@ -16,6 +18,7 @@ module Github
self.table_name = 'issues'
self.reset_callbacks :save
+ self.reset_callbacks :create
self.reset_callbacks :commit
self.reset_callbacks :update
self.reset_callbacks :validate
@@ -38,13 +41,16 @@ module Github
self.reset_callbacks :validate
end
- attr_reader :project, :repository, :repo, :options, :errors, :cached, :verbose
+ attr_reader :project, :repository, :repo, :repo_url, :wiki_url,
+ :options, :errors, :cached, :verbose
- def initialize(project, options)
+ def initialize(project, options = {})
@project = project
@repository = project.repository
@repo = project.import_source
- @options = options
+ @repo_url = project.import_url
+ @wiki_url = project.import_url.sub(/\.git\z/, '.wiki.git')
+ @options = options.reverse_merge(token: project.import_data&.credentials&.fetch(:user))
@verbose = options.fetch(:verbose, false)
@cached = Hash.new { |hash, key| hash[key] = Hash.new }
@errors = []
@@ -62,6 +68,8 @@ module Github
fetch_pull_requests
puts 'Fetching issues...'.color(:aqua) if verbose
fetch_issues
+ puts 'Fetching releases...'.color(:aqua) if verbose
+ fetch_releases
puts 'Cloning wiki repository...'.color(:aqua) if verbose
fetch_wiki_repository
puts 'Expiring repository cache...'.color(:aqua) if verbose
@@ -69,6 +77,7 @@ module Github
true
rescue Github::RepositoryFetchError
+ expire_repository_cache
false
ensure
keep_track_of_errors
@@ -78,23 +87,21 @@ module Github
def fetch_repository
begin
- project.create_repository unless project.repository.exists?
- project.repository.add_remote('github', "https://{options.fetch(:token)}@github.com/#{repo}.git")
+ project.ensure_repository
+ project.repository.add_remote('github', repo_url)
project.repository.set_remote_as_mirror('github')
project.repository.fetch_remote('github', forced: true)
- rescue Gitlab::Shell::Error => e
- error(:project, "https://github.com/#{repo}.git", e.message)
+ rescue Gitlab::Git::Repository::NoRepository, Gitlab::Shell::Error => e
+ error(:project, repo_url, e.message)
raise Github::RepositoryFetchError
end
end
def fetch_wiki_repository
- wiki_url = "https://{options.fetch(:token)}@github.com/#{repo}.wiki.git"
- wiki_path = "#{project.path_with_namespace}.wiki"
+ return if project.wiki.repository_exists?
- unless project.wiki.repository_exists?
- gitlab_shell.import_repository(project.repository_storage_path, wiki_path, wiki_url)
- end
+ wiki_path = "#{project.disk_path}.wiki"
+ gitlab_shell.import_repository(project.repository_storage_path, wiki_path, wiki_url)
rescue Gitlab::Shell::Error => e
# GitHub error message when the wiki repo has not been created,
# this means that repo has wiki enabled, but have no pages. So,
@@ -169,7 +176,7 @@ module Github
next unless merge_request.new_record? && pull_request.valid?
begin
- restore_branches(pull_request)
+ pull_request.restore_branches!
author_id = user_id(pull_request.author, project.creator_id)
description = format_description(pull_request.description, pull_request.author)
@@ -205,7 +212,7 @@ module Github
rescue => e
error(:pull_request, pull_request.url, e.message)
ensure
- clean_up_restored_branches(pull_request)
+ pull_request.remove_restored_branches!
end
end
@@ -245,7 +252,7 @@ module Github
issue.label_ids = label_ids(representation.labels)
issue.milestone_id = milestone_id(representation.milestone)
issue.author_id = author_id
- issue.assignee_id = user_id(representation.assignee)
+ issue.assignee_ids = [user_id(representation.assignee)]
issue.created_at = representation.created_at
issue.updated_at = representation.updated_at
issue.save!(validate: false)
@@ -306,7 +313,7 @@ module Github
next unless representation.valid?
release = ::Release.find_or_initialize_by(project_id: project.id, tag: representation.tag)
- next unless relese.new_record?
+ next unless release.new_record?
begin
release.description = representation.description
@@ -322,32 +329,6 @@ module Github
end
end
- def restore_branches(pull_request)
- restore_source_branch(pull_request) unless pull_request.source_branch_exists?
- restore_target_branch(pull_request) unless pull_request.target_branch_exists?
- end
-
- def restore_source_branch(pull_request)
- repository.create_branch(pull_request.source_branch_name, pull_request.source_branch_sha)
- end
-
- def restore_target_branch(pull_request)
- repository.create_branch(pull_request.target_branch_name, pull_request.target_branch_sha)
- end
-
- def remove_branch(name)
- repository.delete_branch(name)
- rescue Rugged::ReferenceError
- errors << { type: :branch, url: nil, error: "Could not clean up restored branch: #{name}" }
- end
-
- def clean_up_restored_branches(pull_request)
- return if pull_request.opened?
-
- remove_branch(pull_request.source_branch_name) unless pull_request.source_branch_exists?
- remove_branch(pull_request.target_branch_name) unless pull_request.target_branch_exists?
- end
-
def label_ids(labels)
labels.map { |attrs| cached[:label_ids][attrs.fetch('name')] }.compact
end
@@ -360,7 +341,7 @@ module Github
def user_id(user, fallback_id = nil)
return unless user.present?
- return cached[:user_ids][user.id] if cached[:user_ids].key?(user.id)
+ return cached[:user_ids][user.id] if cached[:user_ids][user.id].present?
gitlab_user_id = user_id_by_external_uid(user.id) || user_id_by_email(user.email)
@@ -390,7 +371,7 @@ module Github
end
def expire_repository_cache
- repository.expire_content_cache
+ repository.expire_content_cache if project.repository_exists?
end
def keep_track_of_errors
diff --git a/lib/github/representation/branch.rb b/lib/github/representation/branch.rb
index d1dac6944f0..c6fa928d565 100644
--- a/lib/github/representation/branch.rb
+++ b/lib/github/representation/branch.rb
@@ -26,13 +26,25 @@ module Github
end
def exists?
- branch_exists? && commit_exists?
+ @exists ||= branch_exists? && commit_exists?
end
def valid?
sha.present? && ref.present?
end
+ def restore!(name)
+ repository.create_branch(name, sha)
+ rescue Gitlab::Git::Repository::InvalidRef => e
+ Rails.logger.error("#{self.class.name}: Could not restore branch #{name}: #{e}")
+ end
+
+ def remove!(name)
+ repository.delete_branch(name)
+ rescue Rugged::ReferenceError => e
+ Rails.logger.error("#{self.class.name}: Could not remove branch #{name}: #{e}")
+ end
+
private
def branch_exists?
diff --git a/lib/github/representation/pull_request.rb b/lib/github/representation/pull_request.rb
index ac9c8283b4b..55461097e8a 100644
--- a/lib/github/representation/pull_request.rb
+++ b/lib/github/representation/pull_request.rb
@@ -1,8 +1,6 @@
module Github
module Representation
class PullRequest < Representation::Issuable
- attr_reader :project
-
delegate :user, :repo, :ref, :sha, to: :source_branch, prefix: true
delegate :user, :exists?, :repo, :ref, :sha, :short_sha, to: :target_branch, prefix: true
@@ -10,10 +8,6 @@ module Github
project
end
- def source_branch_exists?
- !cross_project? && source_branch.exists?
- end
-
def source_branch_name
@source_branch_name ||=
if cross_project? || !source_branch_exists?
@@ -23,6 +17,12 @@ module Github
end
end
+ def source_branch_exists?
+ return @source_branch_exists if defined?(@source_branch_exists)
+
+ @source_branch_exists = !cross_project? && source_branch.exists?
+ end
+
def target_project
project
end
@@ -31,6 +31,10 @@ module Github
@target_branch_name ||= target_branch_exists? ? target_branch_ref : target_branch_name_prefixed
end
+ def target_branch_exists?
+ @target_branch_exists ||= target_branch.exists?
+ end
+
def state
return 'merged' if raw['state'] == 'closed' && raw['merged_at'].present?
return 'closed' if raw['state'] == 'closed'
@@ -46,6 +50,18 @@ module Github
source_branch.valid? && target_branch.valid?
end
+ def restore_branches!
+ restore_source_branch!
+ restore_target_branch!
+ end
+
+ def remove_restored_branches!
+ return if opened?
+
+ remove_source_branch!
+ remove_target_branch!
+ end
+
private
def project
@@ -73,6 +89,32 @@ module Github
source_branch_repo.id != target_branch_repo.id
end
+
+ def restore_source_branch!
+ return if source_branch_exists?
+
+ source_branch.restore!(source_branch_name)
+ end
+
+ def restore_target_branch!
+ return if target_branch_exists?
+
+ target_branch.restore!(target_branch_name)
+ end
+
+ def remove_source_branch!
+ # We should remove the source/target branches only if they were
+ # restored. Otherwise, we'll remove branches like 'master' that
+ # target_branch_exists? returns true. In other words, we need
+ # to clean up only the restored branches that (source|target)_branch_exists?
+ # returns false for the first time it has been called, because of
+ # this that is important to memoize these values.
+ source_branch.remove!(source_branch_name) unless source_branch_exists?
+ end
+
+ def remove_target_branch!
+ target_branch.remove!(target_branch_name) unless target_branch_exists?
+ end
end
end
end
diff --git a/lib/gitlab.rb b/lib/gitlab.rb
index c3064163e07..11f7c8b9510 100644
--- a/lib/gitlab.rb
+++ b/lib/gitlab.rb
@@ -1,9 +1,11 @@
require_dependency 'gitlab/git'
module Gitlab
+ COM_URL = 'https://gitlab.com'.freeze
+
def self.com?
# Check `staging?` as well to keep parity with gitlab.com
- Gitlab.config.gitlab.url == 'https://gitlab.com' || staging?
+ Gitlab.config.gitlab.url == COM_URL || staging?
end
def self.staging?
diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb
index 8c28009b9c6..4714ab18cc1 100644
--- a/lib/gitlab/access.rb
+++ b/lib/gitlab/access.rb
@@ -32,7 +32,7 @@ module Gitlab
"Guest" => GUEST,
"Reporter" => REPORTER,
"Developer" => DEVELOPER,
- "Master" => MASTER,
+ "Master" => MASTER
}
end
@@ -47,7 +47,7 @@ module Gitlab
guest: GUEST,
reporter: REPORTER,
developer: DEVELOPER,
- master: MASTER,
+ master: MASTER
}
end
@@ -60,7 +60,7 @@ module Gitlab
"Not protected: Both developers and masters can push new commits, force push, or delete the branch." => PROTECTION_NONE,
"Protected against pushes: Developers cannot push new commits, but are allowed to accept merge requests to the branch." => PROTECTION_DEV_CAN_MERGE,
"Partially protected: Developers can push new commits, but cannot force push or delete the branch. Masters can do all of those." => PROTECTION_DEV_CAN_PUSH,
- "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL,
+ "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL
}
end
diff --git a/lib/gitlab/allowable.rb b/lib/gitlab/allowable.rb
index e4f7cad2b79..45c2b01dd8f 100644
--- a/lib/gitlab/allowable.rb
+++ b/lib/gitlab/allowable.rb
@@ -1,7 +1,7 @@
module Gitlab
module Allowable
- def can?(user, action, subject = :global)
- Ability.allowed?(user, action, subject)
+ def can?(*args)
+ Ability.allowed?(*args)
end
end
end
diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb
index fba80c7132e..3d41ac76406 100644
--- a/lib/gitlab/asciidoc.rb
+++ b/lib/gitlab/asciidoc.rb
@@ -15,26 +15,25 @@ module Gitlab
#
# input - the source text in Asciidoc format
#
- def self.render(input)
+ def self.render(input, context)
asciidoc_opts = { safe: :secure,
backend: :gitlab_html5,
attributes: DEFAULT_ADOC_ATTRS }
+ context[:pipeline] = :ascii_doc
+
plantuml_setup
html = ::Asciidoctor.convert(input, asciidoc_opts)
-
- filter = Banzai::Filter::SanitizationFilter.new(html)
- html = filter.call.to_s
-
+ html = Banzai.render(html, context)
html.html_safe
end
def self.plantuml_setup
Asciidoctor::PlantUml.configure do |conf|
- conf.url = ApplicationSetting.current.plantuml_url
- conf.svg_enable = ApplicationSetting.current.plantuml_enabled
- conf.png_enable = ApplicationSetting.current.plantuml_enabled
+ conf.url = current_application_settings.plantuml_url
+ conf.svg_enable = current_application_settings.plantuml_enabled
+ conf.png_enable = current_application_settings.plantuml_enabled
conf.txt_enable = false
end
end
@@ -47,13 +46,13 @@ module Gitlab
def stem(node)
return super unless node.style.to_sym == :latexmath
- %(<pre#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="display"><code>#{node.content}</code></pre>)
+ %(<pre#{id_attribute(node)} data-math-style="display"><code>#{node.content}</code></pre>)
end
def inline_quoted(node)
return super unless node.type.to_sym == :latexmath
- %(<code#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="inline">#{node.text}</code>)
+ %(<code#{id_attribute(node)} data-math-style="inline">#{node.text}</code>)
end
private
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index ea918b23a63..8cb4060cd97 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -2,6 +2,8 @@ module Gitlab
module Auth
MissingPersonalTokenError = Class.new(StandardError)
+ REGISTRY_SCOPES = [:read_registry].freeze
+
# Scopes used for GitLab API access
API_SCOPES = [:api, :read_user].freeze
@@ -11,8 +13,10 @@ module Gitlab
# Default scopes for OAuth applications that don't define their own
DEFAULT_SCOPES = [:api].freeze
+ AVAILABLE_SCOPES = (API_SCOPES + REGISTRY_SCOPES).freeze
+
# Other available scopes
- OPTIONAL_SCOPES = (API_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze
+ OPTIONAL_SCOPES = (AVAILABLE_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze
class << self
def find_for_git_client(login, password, project:, ip:)
@@ -26,17 +30,28 @@ module Gitlab
build_access_token_check(login, password) ||
lfs_token_check(login, password) ||
oauth_access_token_check(login, password) ||
- user_with_password_for_git(login, password) ||
personal_access_token_check(password) ||
+ user_with_password_for_git(login, password) ||
Gitlab::Auth::Result.new
rate_limit!(ip, success: result.success?, login: login)
Gitlab::Auth::UniqueIpsLimiter.limit_user!(result.actor)
- result
+ return result if result.success? || current_application_settings.password_authentication_enabled? || Gitlab::LDAP::Config.enabled?
+
+ # If sign-in is disabled and LDAP is not configured, recommend a
+ # personal access token on failed auth attempts
+ raise Gitlab::Auth::MissingPersonalTokenError
end
def find_with_user_password(login, password)
+ # Avoid resource intensive login checks if password is not provided
+ return unless password.present?
+
+ # Nothing to do here if internal auth is disabled and LDAP is
+ # not configured
+ return unless current_application_settings.password_authentication_enabled? || Gitlab::LDAP::Config.enabled?
+
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login)
@@ -44,7 +59,7 @@ module Gitlab
# LDAP users are only authenticated via LDAP
if user.nil? || user.ldap_user?
# Second chance - try LDAP authentication
- return nil unless Gitlab::LDAP::Config.enabled?
+ return unless Gitlab::LDAP::Config.enabled?
Gitlab::LDAP::Authentication.login(login, password)
else
@@ -86,7 +101,7 @@ module Gitlab
if Service.available_services_names.include?(underscored_service)
# We treat underscored_service as a trusted input because it is included
# in the Service.available_services_names whitelist.
- service = project.public_send("#{underscored_service}_service")
+ service = project.public_send("#{underscored_service}_service") # rubocop:disable GitlabSecurity/PublicSend
if service && service.activated? && service.valid_token?(password)
Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)
@@ -106,6 +121,7 @@ module Gitlab
def oauth_access_token_check(login, password)
if login == "oauth2" && password.present?
token = Doorkeeper::AccessToken.by_token(password)
+
if valid_oauth_token?(token)
user = User.find_by(id: token.resource_owner_id)
Gitlab::Auth::Result.new(user, nil, :oauth, full_authentication_abilities)
@@ -118,17 +134,23 @@ module Gitlab
token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password)
- if token && valid_api_token?(token)
- Gitlab::Auth::Result.new(token.user, nil, :personal_token, full_authentication_abilities)
+ if token && valid_scoped_token?(token, AVAILABLE_SCOPES)
+ Gitlab::Auth::Result.new(token.user, nil, :personal_token, abilities_for_scope(token.scopes))
end
end
def valid_oauth_token?(token)
- token && token.accessible? && valid_api_token?(token)
+ token && token.accessible? && valid_scoped_token?(token, [:api])
+ end
+
+ def valid_scoped_token?(token, scopes)
+ AccessTokenValidationService.new(token).include_any_scope?(scopes)
end
- def valid_api_token?(token)
- AccessTokenValidationService.new(token).include_any_scope?(['api'])
+ def abilities_for_scope(scopes)
+ scopes.map do |scope|
+ self.public_send(:"#{scope}_scope_authentication_abilities") # rubocop:disable GitlabSecurity/PublicSend
+ end.flatten.uniq
end
def lfs_token_check(login, password)
@@ -196,9 +218,20 @@ module Gitlab
def full_authentication_abilities
read_authentication_abilities + [
:push_code,
- :create_container_image
+ :create_container_image,
+ :admin_container_image
]
end
+ alias_method :api_scope_authentication_abilities, :full_authentication_abilities
+
+ def read_registry_scope_authentication_abilities
+ [:read_container_image]
+ end
+
+ # The currently used auth method doesn't allow any actions for this scope
+ def read_user_scope_authentication_abilities
+ []
+ end
end
end
end
diff --git a/lib/gitlab/auth/ip_rate_limiter.rb b/lib/gitlab/auth/ip_rate_limiter.rb
index 1089bc9f89e..e6173d45af3 100644
--- a/lib/gitlab/auth/ip_rate_limiter.rb
+++ b/lib/gitlab/auth/ip_rate_limiter.rb
@@ -11,11 +11,11 @@ module Gitlab
def enabled?
config.enabled
end
-
+
def reset!
Rack::Attack::Allow2Ban.reset(ip, config)
end
-
+
def register_fail!
# Allow2Ban.filter will return false if this IP has not failed too often yet
@banned = Rack::Attack::Allow2Ban.filter(ip, config) do
@@ -23,17 +23,17 @@ module Gitlab
ip_can_be_banned?
end
end
-
+
def banned?
@banned
end
-
+
private
-
+
def config
Gitlab.config.rack_attack.git_basic_auth
end
-
+
def ip_can_be_banned?
config.ip_whitelist.exclude?(ip)
end
diff --git a/lib/gitlab/auth/result.rb b/lib/gitlab/auth/result.rb
index 39b86c61a18..75451cf8aa9 100644
--- a/lib/gitlab/auth/result.rb
+++ b/lib/gitlab/auth/result.rb
@@ -15,6 +15,10 @@ module Gitlab
def success?
actor.present? || type == :ci
end
+
+ def failed?
+ !success?
+ end
end
end
end
diff --git a/lib/gitlab/auth/unique_ips_limiter.rb b/lib/gitlab/auth/unique_ips_limiter.rb
index bf2239ca150..baa1f802d8a 100644
--- a/lib/gitlab/auth/unique_ips_limiter.rb
+++ b/lib/gitlab/auth/unique_ips_limiter.rb
@@ -27,7 +27,7 @@ module Gitlab
time = Time.now.utc.to_i
key = "#{USER_UNIQUE_IPS_PREFIX}:#{user_id}"
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
unique_ips_count = nil
redis.multi do |r|
r.zadd(key, time, ip)
diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb
new file mode 100644
index 00000000000..d3f66877672
--- /dev/null
+++ b/lib/gitlab/background_migration.rb
@@ -0,0 +1,52 @@
+module Gitlab
+ module BackgroundMigration
+ def self.queue
+ @queue ||= BackgroundMigrationWorker.sidekiq_options['queue']
+ end
+
+ # Begins stealing jobs from the background migrations queue, blocking the
+ # caller until all jobs have been completed.
+ #
+ # When a migration raises a StandardError is is going to be retries up to
+ # three times, for example, to recover from a deadlock.
+ #
+ # When Exception is being raised, it enqueues the migration again, and
+ # re-raises the exception.
+ #
+ # steal_class - The name of the class for which to steal jobs.
+ def self.steal(steal_class)
+ enqueued = Sidekiq::Queue.new(self.queue)
+ scheduled = Sidekiq::ScheduledSet.new
+
+ [scheduled, enqueued].each do |queue|
+ queue.each do |job|
+ migration_class, migration_args = job.args
+
+ next unless job.queue == self.queue
+ next unless migration_class == steal_class
+
+ begin
+ perform(migration_class, migration_args) if job.delete
+ rescue Exception # rubocop:disable Lint/RescueException
+ BackgroundMigrationWorker # enqueue this migration again
+ .perform_async(migration_class, migration_args)
+
+ raise
+ end
+ end
+ end
+ end
+
+ ##
+ # Performs a background migration.
+ #
+ # class_name - The name of the background migration class as defined in the
+ # Gitlab::BackgroundMigration namespace.
+ #
+ # arguments - The arguments to pass to the background migration's "perform"
+ # method.
+ def self.perform(class_name, arguments)
+ const_get(class_name).new.perform(*arguments)
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/.gitkeep b/lib/gitlab/background_migration/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/lib/gitlab/background_migration/.gitkeep
diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
new file mode 100644
index 00000000000..310a69a4bd4
--- /dev/null
+++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
@@ -0,0 +1,109 @@
+module Gitlab
+ module BackgroundMigration
+ class DeserializeMergeRequestDiffsAndCommits
+ attr_reader :diff_ids, :commit_rows, :file_rows
+
+ class MergeRequestDiff < ActiveRecord::Base
+ self.table_name = 'merge_request_diffs'
+ end
+
+ BUFFER_ROWS = 1000
+
+ def perform(start_id, stop_id)
+ merge_request_diffs = MergeRequestDiff
+ .select(:id, :st_commits, :st_diffs)
+ .where('st_commits IS NOT NULL OR st_diffs IS NOT NULL')
+ .where(id: start_id..stop_id)
+
+ reset_buffers!
+
+ merge_request_diffs.each do |merge_request_diff|
+ commits, files = single_diff_rows(merge_request_diff)
+
+ diff_ids << merge_request_diff.id
+ commit_rows.concat(commits)
+ file_rows.concat(files)
+
+ if diff_ids.length > BUFFER_ROWS ||
+ commit_rows.length > BUFFER_ROWS ||
+ file_rows.length > BUFFER_ROWS
+
+ flush_buffers!
+ end
+ end
+
+ flush_buffers!
+ end
+
+ private
+
+ def reset_buffers!
+ @diff_ids = []
+ @commit_rows = []
+ @file_rows = []
+ end
+
+ def flush_buffers!
+ if diff_ids.any?
+ MergeRequestDiff.transaction do
+ Gitlab::Database.bulk_insert('merge_request_diff_commits', commit_rows)
+ Gitlab::Database.bulk_insert('merge_request_diff_files', file_rows)
+
+ MergeRequestDiff.where(id: diff_ids).update_all(st_commits: nil, st_diffs: nil)
+ end
+ end
+
+ reset_buffers!
+ end
+
+ def single_diff_rows(merge_request_diff)
+ sha_attribute = Gitlab::Database::ShaAttribute.new
+ commits = YAML.load(merge_request_diff.st_commits) rescue []
+
+ commit_rows = commits.map.with_index do |commit, index|
+ commit_hash = commit.to_hash.with_indifferent_access.except(:parent_ids)
+ sha = commit_hash.delete(:id)
+
+ commit_hash.merge(
+ merge_request_diff_id: merge_request_diff.id,
+ relative_order: index,
+ sha: sha_attribute.type_cast_for_database(sha)
+ )
+ end
+
+ diffs = YAML.load(merge_request_diff.st_diffs) rescue []
+ diffs = [] unless valid_raw_diffs?(diffs)
+
+ file_rows = diffs.map.with_index do |diff, index|
+ diff_hash = diff.to_hash.with_indifferent_access.merge(
+ binary: false,
+ merge_request_diff_id: merge_request_diff.id,
+ relative_order: index
+ )
+
+ # Compatibility with old diffs created with Psych.
+ diff_hash.tap do |hash|
+ diff_text = hash[:diff]
+
+ hash[:too_large] = !!hash[:too_large]
+
+ if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?
+ hash[:binary] = true
+ hash[:diff] = [diff_text].pack('m0')
+ end
+ end
+ end
+
+ [commit_rows, file_rows]
+ end
+
+ # Unlike MergeRequestDiff#valid_raw_diff?, don't count Rugged objects as
+ # valid, because we don't render them usefully anyway.
+ def valid_raw_diffs?(diffs)
+ return false unless diffs.respond_to?(:each)
+
+ diffs.all? { |diff| diff.is_a?(Hash) }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/migrate_build_stage_id_reference.rb b/lib/gitlab/background_migration/migrate_build_stage_id_reference.rb
new file mode 100644
index 00000000000..91540127ea9
--- /dev/null
+++ b/lib/gitlab/background_migration/migrate_build_stage_id_reference.rb
@@ -0,0 +1,19 @@
+module Gitlab
+ module BackgroundMigration
+ class MigrateBuildStageIdReference
+ def perform(start_id, stop_id)
+ sql = <<-SQL.strip_heredoc
+ UPDATE ci_builds
+ SET stage_id =
+ (SELECT id FROM ci_stages
+ WHERE ci_stages.pipeline_id = ci_builds.commit_id
+ AND ci_stages.name = ci_builds.stage)
+ WHERE ci_builds.id BETWEEN #{start_id.to_i} AND #{stop_id.to_i}
+ AND ci_builds.stage_id IS NULL
+ SQL
+
+ ActiveRecord::Base.connection.execute(sql)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb b/lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb
new file mode 100644
index 00000000000..432f7c3e706
--- /dev/null
+++ b/lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb
@@ -0,0 +1,176 @@
+module Gitlab
+ module BackgroundMigration
+ # Class that migrates events for the new push event payloads setup. All
+ # events are copied to a shadow table, and push events will also have a row
+ # created in the push_event_payloads table.
+ class MigrateEventsToPushEventPayloads
+ class Event < ActiveRecord::Base
+ self.table_name = 'events'
+
+ serialize :data
+
+ BLANK_REF = ('0' * 40).freeze
+ TAG_REF_PREFIX = 'refs/tags/'.freeze
+ MAX_INDEX = 69
+ PUSHED = 5
+
+ def push_event?
+ action == PUSHED && data.present?
+ end
+
+ def commit_title
+ commit = commits.last
+
+ return nil unless commit && commit[:message]
+
+ index = commit[:message].index("\n")
+ message = index ? commit[:message][0..index] : commit[:message]
+
+ message.strip.truncate(70)
+ end
+
+ def commit_from_sha
+ if create?
+ nil
+ else
+ data[:before]
+ end
+ end
+
+ def commit_to_sha
+ if remove?
+ nil
+ else
+ data[:after]
+ end
+ end
+
+ def data
+ super || {}
+ end
+
+ def commits
+ data[:commits] || []
+ end
+
+ def commit_count
+ data[:total_commits_count] || 0
+ end
+
+ def ref
+ data[:ref]
+ end
+
+ def trimmed_ref_name
+ if ref_type == :tag
+ ref[10..-1]
+ else
+ ref[11..-1]
+ end
+ end
+
+ def create?
+ data[:before] == BLANK_REF
+ end
+
+ def remove?
+ data[:after] == BLANK_REF
+ end
+
+ def push_action
+ if create?
+ :created
+ elsif remove?
+ :removed
+ else
+ :pushed
+ end
+ end
+
+ def ref_type
+ if ref.start_with?(TAG_REF_PREFIX)
+ :tag
+ else
+ :branch
+ end
+ end
+ end
+
+ class EventForMigration < ActiveRecord::Base
+ self.table_name = 'events_for_migration'
+ end
+
+ class PushEventPayload < ActiveRecord::Base
+ self.table_name = 'push_event_payloads'
+
+ enum action: {
+ created: 0,
+ removed: 1,
+ pushed: 2
+ }
+
+ enum ref_type: {
+ branch: 0,
+ tag: 1
+ }
+ end
+
+ # start_id - The start ID of the range of events to process
+ # end_id - The end ID of the range to process.
+ def perform(start_id, end_id)
+ return unless migrate?
+
+ find_events(start_id, end_id).each { |event| process_event(event) }
+ end
+
+ def process_event(event)
+ replicate_event(event)
+ create_push_event_payload(event) if event.push_event?
+ end
+
+ def replicate_event(event)
+ new_attributes = event.attributes
+ .with_indifferent_access.except(:title, :data)
+
+ EventForMigration.create!(new_attributes)
+ rescue ActiveRecord::InvalidForeignKey
+ # A foreign key error means the associated event was removed. In this
+ # case we'll just skip migrating the event.
+ end
+
+ def create_push_event_payload(event)
+ commit_from = pack(event.commit_from_sha)
+ commit_to = pack(event.commit_to_sha)
+
+ PushEventPayload.create!(
+ event_id: event.id,
+ commit_count: event.commit_count,
+ ref_type: event.ref_type,
+ action: event.push_action,
+ commit_from: commit_from,
+ commit_to: commit_to,
+ ref: event.trimmed_ref_name,
+ commit_title: event.commit_title
+ )
+ rescue ActiveRecord::InvalidForeignKey
+ # A foreign key error means the associated event was removed. In this
+ # case we'll just skip migrating the event.
+ end
+
+ def find_events(start_id, end_id)
+ Event
+ .where('NOT EXISTS (SELECT true FROM events_for_migration WHERE events_for_migration.id = events.id)')
+ .where(id: start_id..end_id)
+ end
+
+ def migrate?
+ Event.table_exists? && PushEventPayload.table_exists? &&
+ EventForMigration.table_exists?
+ end
+
+ def pack(value)
+ value ? [value].pack('H*') : nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder.rb b/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder.rb
new file mode 100644
index 00000000000..0881244ed49
--- /dev/null
+++ b/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module BackgroundMigration
+ class MigrateSystemUploadsToNewFolder
+ include Gitlab::Database::MigrationHelpers
+ attr_reader :old_folder, :new_folder
+
+ class Upload < ActiveRecord::Base
+ self.table_name = 'uploads'
+ include EachBatch
+ end
+
+ def perform(old_folder, new_folder)
+ replace_sql = replace_sql(uploads[:path], old_folder, new_folder)
+ affected_uploads = Upload.where(uploads[:path].matches("#{old_folder}%"))
+
+ affected_uploads.each_batch do |batch|
+ batch.update_all("path = #{replace_sql}")
+ end
+ end
+
+ def uploads
+ Arel::Table.new('uploads')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/move_personal_snippet_files.rb b/lib/gitlab/background_migration/move_personal_snippet_files.rb
new file mode 100644
index 00000000000..07cec96bcc3
--- /dev/null
+++ b/lib/gitlab/background_migration/move_personal_snippet_files.rb
@@ -0,0 +1,79 @@
+module Gitlab
+ module BackgroundMigration
+ class MovePersonalSnippetFiles
+ delegate :select_all, :execute, :quote_string, to: :connection
+
+ def perform(relative_source, relative_destination)
+ @source_relative_location = relative_source
+ @destination_relative_location = relative_destination
+
+ move_personal_snippet_files
+ end
+
+ def move_personal_snippet_files
+ query = "SELECT uploads.path, uploads.model_id FROM uploads "\
+ "INNER JOIN snippets ON snippets.id = uploads.model_id WHERE uploader = 'PersonalFileUploader'"
+ select_all(query).each do |upload|
+ secret = upload['path'].split('/')[0]
+ file_name = upload['path'].split('/')[1]
+
+ move_file(upload['model_id'], secret, file_name)
+ update_markdown(upload['model_id'], secret, file_name)
+ end
+ end
+
+ def move_file(snippet_id, secret, file_name)
+ source_dir = File.join(base_directory, @source_relative_location, snippet_id.to_s, secret)
+ destination_dir = File.join(base_directory, @destination_relative_location, snippet_id.to_s, secret)
+
+ source_file_path = File.join(source_dir, file_name)
+ destination_file_path = File.join(destination_dir, file_name)
+
+ unless File.exist?(source_file_path)
+ say "Source file `#{source_file_path}` doesn't exist. Skipping."
+ return
+ end
+
+ say "Moving file #{source_file_path} -> #{destination_file_path}"
+
+ FileUtils.mkdir_p(destination_dir)
+ FileUtils.move(source_file_path, destination_file_path)
+ end
+
+ def update_markdown(snippet_id, secret, file_name)
+ source_markdown_path = File.join(@source_relative_location, snippet_id.to_s, secret, file_name)
+ destination_markdown_path = File.join(@destination_relative_location, snippet_id.to_s, secret, file_name)
+
+ source_markdown = "](#{source_markdown_path})"
+ destination_markdown = "](#{destination_markdown_path})"
+ quoted_source = quote_string(source_markdown)
+ quoted_destination = quote_string(destination_markdown)
+
+ execute("UPDATE snippets "\
+ "SET description = replace(snippets.description, '#{quoted_source}', '#{quoted_destination}'), description_html = NULL "\
+ "WHERE id = #{snippet_id}")
+
+ query = "SELECT id, note FROM notes WHERE noteable_id = #{snippet_id} "\
+ "AND noteable_type = 'Snippet' AND note IS NOT NULL"
+ select_all(query).each do |note|
+ text = note['note'].gsub(source_markdown, destination_markdown)
+ quoted_text = quote_string(text)
+
+ execute("UPDATE notes SET note = '#{quoted_text}', note_html = NULL WHERE id = #{note['id']}")
+ end
+ end
+
+ def base_directory
+ File.join(Rails.root, 'public')
+ end
+
+ def connection
+ ActiveRecord::Base.connection
+ end
+
+ def say(message)
+ Rails.logger.debug(message)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb
deleted file mode 100644
index f87a7b7942e..00000000000
--- a/lib/gitlab/badge/build/metadata.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-module Gitlab
- module Badge
- module Build
- ##
- # Class that describes build badge metadata
- #
- class Metadata < Badge::Metadata
- def initialize(badge)
- @project = badge.project
- @ref = badge.ref
- end
-
- def title
- 'build status'
- end
-
- def image_url
- build_namespace_project_badges_url(@project.namespace,
- @project, @ref, format: :svg)
- end
-
- def link_url
- namespace_project_commits_url(@project.namespace, @project, id: @ref)
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/badge/build/status.rb b/lib/gitlab/badge/build/status.rb
deleted file mode 100644
index b762d85b6e5..00000000000
--- a/lib/gitlab/badge/build/status.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-module Gitlab
- module Badge
- module Build
- ##
- # Build status badge
- #
- class Status < Badge::Base
- attr_reader :project, :ref
-
- def initialize(project, ref)
- @project = project
- @ref = ref
-
- @sha = @project.commit(@ref).try(:sha)
- end
-
- def entity
- 'build'
- end
-
- def status
- @project.pipelines
- .where(sha: @sha)
- .latest_status(@ref) || 'unknown'
- end
-
- def metadata
- @metadata ||= Build::Metadata.new(self)
- end
-
- def template
- @template ||= Build::Template.new(self)
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb
deleted file mode 100644
index bc0e0cd441d..00000000000
--- a/lib/gitlab/badge/build/template.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-module Gitlab
- module Badge
- module Build
- ##
- # Class that represents a build badge template.
- #
- # Template object will be passed to badge.svg.erb template.
- #
- class Template < Badge::Template
- STATUS_COLOR = {
- success: '#4c1',
- failed: '#e05d44',
- running: '#dfb317',
- pending: '#dfb317',
- canceled: '#9f9f9f',
- skipped: '#9f9f9f',
- unknown: '#9f9f9f'
- }.freeze
-
- def initialize(badge)
- @entity = badge.entity
- @status = badge.status
- end
-
- def key_text
- @entity.to_s
- end
-
- def value_text
- @status.to_s
- end
-
- def key_width
- 38
- end
-
- def value_width
- 54
- end
-
- def value_color
- STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown]
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/badge/coverage/metadata.rb b/lib/gitlab/badge/coverage/metadata.rb
index 53588185622..e898f5d790e 100644
--- a/lib/gitlab/badge/coverage/metadata.rb
+++ b/lib/gitlab/badge/coverage/metadata.rb
@@ -16,13 +16,11 @@ module Gitlab
end
def image_url
- coverage_namespace_project_badges_url(@project.namespace,
- @project, @ref,
- format: :svg)
+ coverage_project_badges_url(@project, @ref, format: :svg)
end
def link_url
- namespace_project_commits_url(@project.namespace, @project, id: @ref)
+ project_commits_url(@project, @ref)
end
end
end
diff --git a/lib/gitlab/badge/metadata.rb b/lib/gitlab/badge/metadata.rb
index 4a049ef758d..8ad6f3cb986 100644
--- a/lib/gitlab/badge/metadata.rb
+++ b/lib/gitlab/badge/metadata.rb
@@ -4,7 +4,7 @@ module Gitlab
# Abstract class for badge metadata
#
class Metadata
- include Gitlab::Application.routes.url_helpers
+ include Gitlab::Routing
include ActionView::Helpers::AssetTagHelper
include ActionView::Helpers::UrlHelper
diff --git a/lib/gitlab/badge/pipeline/metadata.rb b/lib/gitlab/badge/pipeline/metadata.rb
new file mode 100644
index 00000000000..db1e9f8cfb8
--- /dev/null
+++ b/lib/gitlab/badge/pipeline/metadata.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Badge
+ module Pipeline
+ ##
+ # Class that describes pipeline badge metadata
+ #
+ class Metadata < Badge::Metadata
+ def initialize(badge)
+ @project = badge.project
+ @ref = badge.ref
+ end
+
+ def title
+ 'pipeline status'
+ end
+
+ def image_url
+ pipeline_project_badges_url(@project, @ref, format: :svg)
+ end
+
+ def link_url
+ project_commits_url(@project, id: @ref)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/badge/pipeline/status.rb b/lib/gitlab/badge/pipeline/status.rb
new file mode 100644
index 00000000000..5fee7a93475
--- /dev/null
+++ b/lib/gitlab/badge/pipeline/status.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module Badge
+ module Pipeline
+ ##
+ # Pipeline status badge
+ #
+ class Status < Badge::Base
+ attr_reader :project, :ref
+
+ def initialize(project, ref)
+ @project = project
+ @ref = ref
+
+ @sha = @project.commit(@ref).try(:sha)
+ end
+
+ def entity
+ 'pipeline'
+ end
+
+ def status
+ @project.pipelines
+ .where(sha: @sha)
+ .latest_status(@ref) || 'unknown'
+ end
+
+ def metadata
+ @metadata ||= Pipeline::Metadata.new(self)
+ end
+
+ def template
+ @template ||= Pipeline::Template.new(self)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/badge/pipeline/template.rb b/lib/gitlab/badge/pipeline/template.rb
new file mode 100644
index 00000000000..e09db32262d
--- /dev/null
+++ b/lib/gitlab/badge/pipeline/template.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ module Badge
+ module Pipeline
+ ##
+ # Class that represents a pipeline badge template.
+ #
+ # Template object will be passed to badge.svg.erb template.
+ #
+ class Template < Badge::Template
+ STATUS_RENAME = { 'success' => 'passed' }.freeze
+ STATUS_COLOR = {
+ success: '#4c1',
+ failed: '#e05d44',
+ running: '#dfb317',
+ pending: '#dfb317',
+ canceled: '#9f9f9f',
+ skipped: '#9f9f9f',
+ unknown: '#9f9f9f'
+ }.freeze
+
+ def initialize(badge)
+ @entity = badge.entity
+ @status = badge.status
+ end
+
+ def key_text
+ @entity.to_s
+ end
+
+ def value_text
+ STATUS_RENAME[@status.to_s] || @status.to_s
+ end
+
+ def key_width
+ 62
+ end
+
+ def value_width
+ 54
+ end
+
+ def value_color
+ STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb
index 5a6d9ae99a0..28bbf3b384e 100644
--- a/lib/gitlab/bitbucket_import/importer.rb
+++ b/lib/gitlab/bitbucket_import/importer.rb
@@ -61,7 +61,7 @@ module Gitlab
def import_wiki
return if project.wiki.repository_exists?
- path_with_namespace = "#{project.path_with_namespace}.wiki"
+ path_with_namespace = "#{project.full_path}.wiki"
import_url = project.import_url.sub(/\.git\z/, ".git/wiki")
gitlab_shell.import_repository(project.repository_storage_path, path_with_namespace, import_url)
rescue StandardError => e
diff --git a/lib/gitlab/blame.rb b/lib/gitlab/blame.rb
index d62bc50ce78..169aac79854 100644
--- a/lib/gitlab/blame.rb
+++ b/lib/gitlab/blame.rb
@@ -40,7 +40,7 @@ module Gitlab
end
def highlighted_lines
- @blob.load_all_data!(repository)
+ @blob.load_all_data!
@highlighted_lines ||=
Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: repository).lines
end
diff --git a/lib/gitlab/cache/ci/project_pipeline_status.rb b/lib/gitlab/cache/ci/project_pipeline_status.rb
index 4fc9a075edc..dba37892863 100644
--- a/lib/gitlab/cache/ci/project_pipeline_status.rb
+++ b/lib/gitlab/cache/ci/project_pipeline_status.rb
@@ -23,7 +23,7 @@ module Gitlab
end
def self.cached_results_for_projects(projects)
- result = Gitlab::Redis.with do |redis|
+ result = Gitlab::Redis::Cache.with do |redis|
redis.multi do
projects.each do |project|
cache_key = cache_key_for_project(project)
@@ -50,8 +50,8 @@ module Gitlab
ref: pipeline.ref
}
- new(pipeline.project, pipeline_info: pipeline_info).
- store_in_cache_if_needed
+ new(pipeline.project, pipeline_info: pipeline_info)
+ .store_in_cache_if_needed
end
def initialize(project, pipeline_info: {}, loaded_from_cache: nil)
@@ -100,19 +100,19 @@ module Gitlab
end
def load_from_cache
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Cache.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|
+ Gitlab::Redis::Cache.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|
+ Gitlab::Redis::Cache.with do |redis|
redis.del(cache_key)
end
end
@@ -120,7 +120,7 @@ module Gitlab
def has_cache?
return self.loaded unless self.loaded.nil?
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.exists(cache_key)
end
end
diff --git a/lib/gitlab/cache/request_cache.rb b/lib/gitlab/cache/request_cache.rb
new file mode 100644
index 00000000000..754a45c3257
--- /dev/null
+++ b/lib/gitlab/cache/request_cache.rb
@@ -0,0 +1,94 @@
+module Gitlab
+ module Cache
+ # This module provides a simple way to cache values in RequestStore,
+ # and the cache key would be based on the class name, method name,
+ # optionally customized instance level values, optionally customized
+ # method level values, and optional method arguments.
+ #
+ # A simple example:
+ #
+ # class UserAccess
+ # extend Gitlab::Cache::RequestCache
+ #
+ # request_cache_key do
+ # [user&.id, project&.id]
+ # end
+ #
+ # request_cache def can_push_to_branch?(ref)
+ # # ...
+ # end
+ # end
+ #
+ # This way, the result of `can_push_to_branch?` would be cached in
+ # `RequestStore.store` based on the cache key. If RequestStore is not
+ # currently active, then it would be stored in a hash saved in an
+ # instance variable, so the cache logic would be the same.
+ # Here's another example using customized method level values:
+ #
+ # class Commit
+ # extend Gitlab::Cache::RequestCache
+ #
+ # def author
+ # User.find_by_any_email(author_email.downcase)
+ # end
+ # request_cache(:author) { author_email.downcase }
+ # end
+ #
+ # So that we could have different strategies for different methods
+ #
+ module RequestCache
+ def self.extended(klass)
+ return if klass < self
+
+ extension = Module.new
+ klass.const_set(:RequestCacheExtension, extension)
+ klass.prepend(extension)
+ end
+
+ def request_cache_key(&block)
+ if block_given?
+ @request_cache_key = block
+ else
+ @request_cache_key
+ end
+ end
+
+ def request_cache(method_name, &method_key_block)
+ const_get(:RequestCacheExtension).module_eval do
+ cache_key_method_name = "#{method_name}_cache_key"
+
+ define_method(method_name) do |*args|
+ store =
+ if RequestStore.active?
+ RequestStore.store
+ else
+ ivar_name = # ! and ? cannot be used as ivar name
+ "@cache_#{method_name.to_s.tr('!?', "\u2605\u2606")}"
+
+ instance_variable_get(ivar_name) ||
+ instance_variable_set(ivar_name, {})
+ end
+
+ key = __send__(cache_key_method_name, args) # rubocop:disable GitlabSecurity/PublicSend
+
+ store.fetch(key) { store[key] = super(*args) }
+ end
+
+ define_method(cache_key_method_name) do |args|
+ klass = self.class
+
+ instance_key = instance_exec(&klass.request_cache_key) if
+ klass.request_cache_key
+
+ method_key = instance_exec(&method_key_block) if method_key_block
+
+ [klass.name, method_name, *instance_key, *method_key, *args]
+ .join(':')
+ end
+
+ private cache_key_method_name
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/base_command.rb b/lib/gitlab/chat_commands/base_command.rb
deleted file mode 100644
index 25da8474e95..00000000000
--- a/lib/gitlab/chat_commands/base_command.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-module Gitlab
- module ChatCommands
- class BaseCommand
- QUERY_LIMIT = 5
-
- def self.match(_text)
- raise NotImplementedError
- end
-
- def self.help_message
- raise NotImplementedError
- end
-
- def self.available?(_project)
- raise NotImplementedError
- end
-
- def self.allowed?(_user, _ability)
- true
- end
-
- def self.can?(object, action, subject)
- Ability.allowed?(object, action, subject)
- end
-
- def execute(_)
- raise NotImplementedError
- end
-
- def collection
- raise NotImplementedError
- end
-
- attr_accessor :project, :current_user, :params
-
- def initialize(project, user, params = {})
- @project, @current_user, @params = project, user, params.dup
- end
-
- private
-
- def find_by_iid(iid)
- collection.find_by(iid: iid)
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb
deleted file mode 100644
index f34ed0f4cf2..00000000000
--- a/lib/gitlab/chat_commands/command.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-module Gitlab
- module ChatCommands
- class Command < BaseCommand
- COMMANDS = [
- Gitlab::ChatCommands::IssueShow,
- Gitlab::ChatCommands::IssueNew,
- Gitlab::ChatCommands::IssueSearch,
- Gitlab::ChatCommands::Deploy,
- ].freeze
-
- def execute
- command, match = match_command
-
- if command
- if command.allowed?(project, current_user)
- command.new(project, current_user, params).execute(match)
- else
- Gitlab::ChatCommands::Presenters::Access.new.access_denied
- end
- else
- Gitlab::ChatCommands::Help.new(project, current_user, params).execute(available_commands, params[:text])
- end
- end
-
- def match_command
- match = nil
- service =
- available_commands.find do |klass|
- match = klass.match(params[:text])
- end
-
- [service, match]
- end
-
- private
-
- def available_commands
- COMMANDS.select do |klass|
- klass.available?(project)
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/deploy.rb b/lib/gitlab/chat_commands/deploy.rb
deleted file mode 100644
index 458d90f84e8..00000000000
--- a/lib/gitlab/chat_commands/deploy.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-module Gitlab
- module ChatCommands
- class Deploy < BaseCommand
- def self.match(text)
- /\Adeploy\s+(?<from>\S+.*)\s+to+\s+(?<to>\S+.*)\z/.match(text)
- end
-
- def self.help_message
- 'deploy <environment> to <target-environment>'
- end
-
- def self.available?(project)
- project.builds_enabled?
- end
-
- def self.allowed?(project, user)
- can?(user, :create_deployment, project)
- end
-
- def execute(match)
- from = match[:from]
- to = match[:to]
-
- actions = find_actions(from, to)
-
- if actions.none?
- Gitlab::ChatCommands::Presenters::Deploy.new(nil).no_actions
- elsif actions.one?
- action = play!(from, to, actions.first)
- Gitlab::ChatCommands::Presenters::Deploy.new(action).present(from, to)
- else
- Gitlab::ChatCommands::Presenters::Deploy.new(actions).too_many_actions
- end
- end
-
- private
-
- def play!(from, to, action)
- action.play(current_user)
- end
-
- def find_actions(from, to)
- environment = project.environments.find_by(name: from)
- return [] unless environment
-
- environment.actions_for(to).select(&:starts_environment?)
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/help.rb b/lib/gitlab/chat_commands/help.rb
deleted file mode 100644
index 6c0e4d304a4..00000000000
--- a/lib/gitlab/chat_commands/help.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-module Gitlab
- module ChatCommands
- class Help < BaseCommand
- # This class has to be used last, as it always matches. It has to match
- # because other commands were not triggered and we want to show the help
- # command
- def self.match(_text)
- true
- end
-
- def self.help_message
- 'help'
- end
-
- def self.allowed?(_project, _user)
- true
- end
-
- def execute(commands, text)
- Gitlab::ChatCommands::Presenters::Help.new(commands).present(trigger, text)
- end
-
- def trigger
- params[:command]
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/issue_command.rb b/lib/gitlab/chat_commands/issue_command.rb
deleted file mode 100644
index 84de3e44c70..00000000000
--- a/lib/gitlab/chat_commands/issue_command.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module Gitlab
- module ChatCommands
- class IssueCommand < BaseCommand
- def self.available?(project)
- project.issues_enabled? && project.default_issues_tracker?
- end
-
- def collection
- IssuesFinder.new(current_user, project_id: project.id).execute
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/issue_new.rb b/lib/gitlab/chat_commands/issue_new.rb
deleted file mode 100644
index 016054ecd46..00000000000
--- a/lib/gitlab/chat_commands/issue_new.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-module Gitlab
- module ChatCommands
- class IssueNew < IssueCommand
- def self.match(text)
- # we can not match \n with the dot by passing the m modifier as than
- # the title and description are not seperated
- /\Aissue\s+(new|create)\s+(?<title>[^\n]*)\n*(?<description>(.|\n)*)/.match(text)
- end
-
- def self.help_message
- 'issue new <title> *`⇧ Shift`*+*`↵ Enter`* <description>'
- end
-
- def self.allowed?(project, user)
- can?(user, :create_issue, project)
- end
-
- def execute(match)
- title = match[:title]
- description = match[:description].to_s.rstrip
-
- issue = create_issue(title: title, description: description)
-
- if issue.persisted?
- presenter(issue).present
- else
- presenter(issue).display_errors
- end
- end
-
- private
-
- def create_issue(title:, description:)
- Issues::CreateService.new(project, current_user, title: title, description: description).execute
- end
-
- def presenter(issue)
- Gitlab::ChatCommands::Presenters::IssueNew.new(issue)
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/issue_search.rb b/lib/gitlab/chat_commands/issue_search.rb
deleted file mode 100644
index 3491b53093e..00000000000
--- a/lib/gitlab/chat_commands/issue_search.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module Gitlab
- module ChatCommands
- class IssueSearch < IssueCommand
- def self.match(text)
- /\Aissue\s+search\s+(?<query>.*)/.match(text)
- end
-
- def self.help_message
- "issue search <your query>"
- end
-
- def execute(match)
- issues = collection.search(match[:query]).limit(QUERY_LIMIT)
-
- if issues.present?
- Presenters::IssueSearch.new(issues).present
- else
- Presenters::Access.new(issues).not_found
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/issue_show.rb b/lib/gitlab/chat_commands/issue_show.rb
deleted file mode 100644
index d6013f4d10c..00000000000
--- a/lib/gitlab/chat_commands/issue_show.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module Gitlab
- module ChatCommands
- class IssueShow < IssueCommand
- def self.match(text)
- /\Aissue\s+show\s+#{Issue.reference_prefix}?(?<iid>\d+)/.match(text)
- end
-
- def self.help_message
- "issue show <id>"
- end
-
- def execute(match)
- issue = find_by_iid(match[:iid])
-
- if issue
- Gitlab::ChatCommands::Presenters::IssueShow.new(issue).present
- else
- Gitlab::ChatCommands::Presenters::Access.new.not_found
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/access.rb b/lib/gitlab/chat_commands/presenters/access.rb
deleted file mode 100644
index 92f4fa17f78..00000000000
--- a/lib/gitlab/chat_commands/presenters/access.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class Access < Presenters::Base
- def access_denied
- ephemeral_response(text: "Whoops! This action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
- end
-
- def not_found
- ephemeral_response(text: "404 not found! GitLab couldn't find what you were looking for! :boom:")
- end
-
- def authorize
- message =
- if @resource
- ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})."
- else
- ":sweat_smile: Couldn't identify you, nor can I autorize you!"
- end
-
- ephemeral_response(text: message)
- end
-
- def unknown_command(commands)
- ephemeral_response(text: help_message(trigger))
- end
-
- private
-
- def help_message(trigger)
- header_with_list("Command not found, these are the commands you can use", full_commands(trigger))
- end
-
- def full_commands(trigger)
- @resource.map { |command| "#{trigger} #{command.help_message}" }
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/base.rb b/lib/gitlab/chat_commands/presenters/base.rb
deleted file mode 100644
index 2700a5a2ad5..00000000000
--- a/lib/gitlab/chat_commands/presenters/base.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class Base
- include Gitlab::Routing.url_helpers
-
- def initialize(resource = nil)
- @resource = resource
- end
-
- def display_errors
- message = header_with_list("The action was not successful, because:", @resource.errors.full_messages)
-
- ephemeral_response(text: message)
- end
-
- private
-
- def header_with_list(header, items)
- message = [header]
-
- items.each do |item|
- message << "- #{item}"
- end
-
- message.join("\n")
- end
-
- def ephemeral_response(message)
- response = {
- response_type: :ephemeral,
- status: 200
- }.merge(message)
-
- format_response(response)
- end
-
- def in_channel_response(message)
- response = {
- response_type: :in_channel,
- status: 200
- }.merge(message)
-
- format_response(response)
- end
-
- def format_response(response)
- response[:text] = format(response[:text]) if response.has_key?(:text)
-
- if response.has_key?(:attachments)
- response[:attachments].each do |attachment|
- attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext]
- attachment[:text] = format(attachment[:text]) if attachment[:text]
- end
- end
-
- response
- end
-
- # Convert Markdown to slacks format
- def format(string)
- Slack::Notifier::LinkFormatter.format(string)
- end
-
- def resource_url
- url_for(
- [
- @resource.project.namespace.becomes(Namespace),
- @resource.project,
- @resource
- ]
- )
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/deploy.rb b/lib/gitlab/chat_commands/presenters/deploy.rb
deleted file mode 100644
index 863d0bf99ca..00000000000
--- a/lib/gitlab/chat_commands/presenters/deploy.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class Deploy < Presenters::Base
- def present(from, to)
- message = "Deployment started from #{from} to #{to}. [Follow its progress](#{resource_url})."
-
- in_channel_response(text: message)
- end
-
- def no_actions
- ephemeral_response(text: "No action found to be executed")
- end
-
- def too_many_actions
- ephemeral_response(text: "Too many actions defined")
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/help.rb b/lib/gitlab/chat_commands/presenters/help.rb
deleted file mode 100644
index cd47b7f4c6a..00000000000
--- a/lib/gitlab/chat_commands/presenters/help.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class Help < Presenters::Base
- def present(trigger, text)
- ephemeral_response(text: help_message(trigger, text))
- end
-
- private
-
- def help_message(trigger, text)
- return "No commands available :thinking_face:" unless @resource.present?
-
- if text.start_with?('help')
- header_with_list("Available commands", full_commands(trigger))
- else
- header_with_list("Unknown command, these commands are available", full_commands(trigger))
- end
- end
-
- def full_commands(trigger)
- @resource.map { |command| "#{trigger} #{command.help_message}" }
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb
deleted file mode 100644
index 054f7f4be0c..00000000000
--- a/lib/gitlab/chat_commands/presenters/issue_base.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- module IssueBase
- def color(issuable)
- issuable.open? ? '#38ae67' : '#d22852'
- end
-
- def status_text(issuable)
- issuable.open? ? 'Open' : 'Closed'
- end
-
- def project
- @resource.project
- end
-
- def author
- @resource.author
- end
-
- def fields
- [
- {
- title: "Assignee",
- value: @resource.assignee ? @resource.assignee.name : "_None_",
- short: true
- },
- {
- title: "Milestone",
- value: @resource.milestone ? @resource.milestone.title : "_None_",
- short: true
- },
- {
- title: "Labels",
- value: @resource.labels.any? ? @resource.label_names.join(', ') : "_None_",
- short: true
- }
- ]
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/issue_new.rb b/lib/gitlab/chat_commands/presenters/issue_new.rb
deleted file mode 100644
index 3674ba25641..00000000000
--- a/lib/gitlab/chat_commands/presenters/issue_new.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class IssueNew < Presenters::Base
- include Presenters::IssueBase
-
- def present
- in_channel_response(new_issue)
- end
-
- private
-
- def new_issue
- {
- attachments: [
- {
- title: "#{@resource.title} · #{@resource.to_reference}",
- title_link: resource_url,
- author_name: author.name,
- author_icon: author.avatar_url,
- fallback: "New issue #{@resource.to_reference}: #{@resource.title}",
- pretext: pretext,
- color: color(@resource),
- fields: fields,
- mrkdwn_in: [
- :title,
- :pretext,
- :text,
- :fields
- ]
- }
- ]
- }
- end
-
- def pretext
- "I created an issue on #{author_profile_link}'s behalf: **#{@resource.to_reference}** in #{project_link}"
- end
-
- def project_link
- "[#{project.name_with_namespace}](#{project.web_url})"
- end
-
- def author_profile_link
- "[#{author.to_reference}](#{url_for(author)})"
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/issue_search.rb b/lib/gitlab/chat_commands/presenters/issue_search.rb
deleted file mode 100644
index 73788cf9662..00000000000
--- a/lib/gitlab/chat_commands/presenters/issue_search.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class IssueSearch < Presenters::Base
- include Presenters::IssueBase
-
- def present
- text = if @resource.count >= 5
- "Here are the first 5 issues I found:"
- elsif @resource.one?
- "Here is the only issue I found:"
- else
- "Here are the #{@resource.count} issues I found:"
- end
-
- ephemeral_response(text: text, attachments: attachments)
- end
-
- private
-
- def attachments
- @resource.map do |issue|
- url = "[#{issue.to_reference}](#{url_for([namespace, project, issue])})"
-
- {
- color: color(issue),
- fallback: "#{issue.to_reference} #{issue.title}",
- text: "#{url} · #{issue.title} (#{status_text(issue)})",
-
- mrkdwn_in: [
- :text
- ]
- }
- end
- end
-
- def project
- @project ||= @resource.first.project
- end
-
- def namespace
- @namespace ||= project.namespace.becomes(Namespace)
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/presenters/issue_show.rb b/lib/gitlab/chat_commands/presenters/issue_show.rb
deleted file mode 100644
index bd784ad241e..00000000000
--- a/lib/gitlab/chat_commands/presenters/issue_show.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-module Gitlab
- module ChatCommands
- module Presenters
- class IssueShow < Presenters::Base
- include Presenters::IssueBase
-
- def present
- if @resource.confidential?
- ephemeral_response(show_issue)
- else
- in_channel_response(show_issue)
- end
- end
-
- private
-
- def show_issue
- {
- attachments: [
- {
- title: "#{@resource.title} · #{@resource.to_reference}",
- title_link: resource_url,
- author_name: author.name,
- author_icon: author.avatar_url,
- fallback: "Issue #{@resource.to_reference}: #{@resource.title}",
- pretext: pretext,
- text: text,
- color: color(@resource),
- fields: fields,
- mrkdwn_in: [
- :pretext,
- :text,
- :fields
- ]
- }
- ]
- }
- end
-
- def text
- message = "**#{status_text(@resource)}**"
-
- if @resource.upvotes.zero? && @resource.downvotes.zero? && @resource.user_notes_count.zero?
- return message
- end
-
- message << " · "
- message << ":+1: #{@resource.upvotes} " unless @resource.upvotes.zero?
- message << ":-1: #{@resource.downvotes} " unless @resource.downvotes.zero?
- message << ":speech_balloon: #{@resource.user_notes_count}" unless @resource.user_notes_count.zero?
-
- message
- end
-
- def pretext
- "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}"
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/chat_commands/result.rb b/lib/gitlab/chat_commands/result.rb
deleted file mode 100644
index 324d7ef43a3..00000000000
--- a/lib/gitlab/chat_commands/result.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module Gitlab
- module ChatCommands
- Result = Struct.new(:type, :message)
- end
-end
diff --git a/lib/gitlab/chat_name_token.rb b/lib/gitlab/chat_name_token.rb
index 1b081aa9b1d..e63e5437331 100644
--- a/lib/gitlab/chat_name_token.rb
+++ b/lib/gitlab/chat_name_token.rb
@@ -12,23 +12,23 @@ module Gitlab
end
def get
- Gitlab::Redis.with do |redis|
- data = redis.get(redis_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ data = redis.get(redis_shared_state_key)
JSON.parse(data, symbolize_names: true) if data
end
end
def store!(params)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
params = params.to_json
- redis.set(redis_key, params, ex: EXPIRY_TIME)
+ redis.set(redis_shared_state_key, params, ex: EXPIRY_TIME)
token
end
end
def delete
- Gitlab::Redis.with do |redis|
- redis.del(redis_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.del(redis_shared_state_key)
end
end
@@ -38,7 +38,7 @@ module Gitlab
Devise.friendly_token(TOKEN_LENGTH)
end
- def redis_key
+ def redis_shared_state_key
"gitlab:chat_names:#{token}"
end
end
diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb
index 8793b20aa35..b6805230348 100644
--- a/lib/gitlab/checks/change_access.rb
+++ b/lib/gitlab/checks/change_access.rb
@@ -1,7 +1,20 @@
module Gitlab
module Checks
class ChangeAccess
- # protocol is currently used only in EE
+ ERROR_MESSAGES = {
+ push_code: 'You are not allowed to push code to this project.',
+ delete_default_branch: 'The default branch of a project cannot be deleted.',
+ force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.',
+ non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.',
+ non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.',
+ merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.',
+ push_protected_branch: 'You are not allowed to push code to protected branches on this project.',
+ change_existing_tags: 'You are not allowed to change existing tags on this project.',
+ update_protected_tag: 'Protected tags cannot be updated.',
+ delete_protected_tag: 'Protected tags cannot be deleted.',
+ create_protected_tag: 'You are not allowed to create this tag as it is protected.'
+ }.freeze
+
attr_reader :user_access, :project, :skip_authorization, :protocol
def initialize(
@@ -18,74 +31,87 @@ module Gitlab
end
def exec
- error = push_checks || tag_checks || protected_branch_checks
+ return true if skip_authorization
- if error
- GitAccessStatus.new(false, error)
- else
- GitAccessStatus.new(true)
- end
+ push_checks
+ branch_checks
+ tag_checks
+
+ true
end
protected
- def protected_branch_checks
- return if skip_authorization
+ def push_checks
+ if user_access.cannot_do_action?(:push_code)
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code]
+ end
+ end
+
+ def branch_checks
return unless @branch_name
+
+ if deletion? && @branch_name == project.default_branch
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch]
+ end
+
+ protected_branch_checks
+ end
+
+ def protected_branch_checks
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 deletion?
- return "You are not allowed to delete protected branches from this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch]
end
+ if deletion?
+ protected_branch_deletion_checks
+ else
+ protected_branch_push_checks
+ end
+ end
+
+ def protected_branch_deletion_checks
+ unless user_access.can_delete_branch?(@branch_name)
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch]
+ end
+
+ unless protocol == 'web'
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch]
+ end
+ end
+
+ def protected_branch_push_checks
if matching_merge_request?
- if user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name)
- return
- else
- "You are not allowed to merge code into protected branches on this project."
+ unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name)
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch]
end
else
- if user_access.can_push_to_branch?(@branch_name)
- return
- else
- "You are not allowed to push code to protected branches on this project."
+ unless user_access.can_push_to_branch?(@branch_name)
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch]
end
end
end
def tag_checks
- return if skip_authorization
-
return unless @tag_name
if tag_exists? && user_access.cannot_do_action?(:admin_project)
- return "You are not allowed to change existing tags on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags]
end
protected_tag_checks
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?
+ return unless ProtectedTag.protected?(project, @tag_name)
- 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
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update?
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion?
- def push_checks
- return if skip_authorization
-
- if user_access.cannot_do_action?(:push_code)
- "You are not allowed to push code to this project."
+ unless user_access.can_create_tag?(@tag_name)
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag]
end
end
diff --git a/lib/gitlab/checks/force_push.rb b/lib/gitlab/checks/force_push.rb
index 1e73f89158d..714464fd5e7 100644
--- a/lib/gitlab/checks/force_push.rb
+++ b/lib/gitlab/checks/force_push.rb
@@ -5,12 +5,19 @@ module Gitlab
return false if project.empty_repo?
# Created or deleted branch
- if Gitlab::Git.blank_ref?(oldrev) || Gitlab::Git.blank_ref?(newrev)
- false
- else
- Gitlab::Git::RevList.new(
- path_to_repo: project.repository.path_to_repo,
- oldrev: oldrev, newrev: newrev).missed_ref.present?
+ return false if Gitlab::Git.blank_ref?(oldrev) || Gitlab::Git.blank_ref?(newrev)
+
+ GitalyClient.migrate(:force_push) do |is_enabled|
+ if is_enabled
+ !project
+ .repository
+ .gitaly_commit_client
+ .is_ancestor(oldrev, newrev)
+ else
+ Gitlab::Git::RevList.new(
+ path_to_repo: project.repository.path_to_repo,
+ oldrev: oldrev, newrev: newrev).missed_ref.present?
+ end
end
end
end
diff --git a/lib/gitlab/ci/build/artifacts/metadata.rb b/lib/gitlab/ci/build/artifacts/metadata.rb
index a375ccbece0..a788fb3fcbc 100644
--- a/lib/gitlab/ci/build/artifacts/metadata.rb
+++ b/lib/gitlab/ci/build/artifacts/metadata.rb
@@ -60,7 +60,7 @@ module Gitlab
begin
path = read_string(gz).force_encoding('UTF-8')
meta = read_string(gz).force_encoding('UTF-8')
-
+
next unless path.valid_encoding? && meta.valid_encoding?
next unless path =~ match_pattern
next if path =~ INVALID_PATH_PATTERN
diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb
index 6f799c2f031..2e073334abc 100644
--- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb
+++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb
@@ -37,6 +37,12 @@ module Gitlab
!directory?
end
+ def blob
+ return unless file?
+
+ @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil)
+ end
+
def has_parent?
nodes > 0
end
diff --git a/lib/gitlab/ci/build/image.rb b/lib/gitlab/ci/build/image.rb
index c62aeb60fa9..b88b2e36d53 100644
--- a/lib/gitlab/ci/build/image.rb
+++ b/lib/gitlab/ci/build/image.rb
@@ -2,7 +2,7 @@ module Gitlab
module Ci
module Build
class Image
- attr_reader :name
+ attr_reader :alias, :command, :entrypoint, :name
class << self
def from_image(job)
@@ -21,7 +21,14 @@ module Gitlab
end
def initialize(image)
- @name = image
+ if image.is_a?(String)
+ @name = image
+ elsif image.is_a?(Hash)
+ @alias = image[:alias]
+ @command = image[:command]
+ @entrypoint = image[:entrypoint]
+ @name = image[:name]
+ end
end
def valid?
diff --git a/lib/gitlab/ci/build/step.rb b/lib/gitlab/ci/build/step.rb
index ee034d9cc56..411f67f8ce7 100644
--- a/lib/gitlab/ci/build/step.rb
+++ b/lib/gitlab/ci/build/step.rb
@@ -12,7 +12,8 @@ module Gitlab
class << self
def from_commands(job)
self.new(:script).tap do |step|
- step.script = job.commands.split("\n")
+ step.script = job.options[:before_script].to_a + job.options[:script].to_a
+ step.script = job.commands.split("\n") if step.script.empty?
step.timeout = job.timeout
step.when = WHEN_ON_SUCCESS
end
diff --git a/lib/gitlab/ci/config/entry/cache.rb b/lib/gitlab/ci/config/entry/cache.rb
index f074df9c7a1..d7e09acbbf3 100644
--- a/lib/gitlab/ci/config/entry/cache.rb
+++ b/lib/gitlab/ci/config/entry/cache.rb
@@ -7,11 +7,14 @@ module Gitlab
#
class Cache < Node
include Configurable
+ include Attributable
- ALLOWED_KEYS = %i[key untracked paths].freeze
+ ALLOWED_KEYS = %i[key untracked paths policy].freeze
+ DEFAULT_POLICY = 'pull-push'.freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
+ validates :policy, inclusion: { in: %w[pull-push push pull], message: 'should be pull-push, push, or pull' }, allow_blank: true
end
entry :key, Entry::Key,
@@ -25,8 +28,15 @@ module Gitlab
helpers :key
+ attributes :policy
+
def value
- super.merge(key: key_value)
+ result = super
+
+ result[:key] = key_value
+ result[:policy] = policy || DEFAULT_POLICY
+
+ result
end
end
end
diff --git a/lib/gitlab/ci/config/entry/image.rb b/lib/gitlab/ci/config/entry/image.rb
index b5050257688..6555c589173 100644
--- a/lib/gitlab/ci/config/entry/image.rb
+++ b/lib/gitlab/ci/config/entry/image.rb
@@ -8,8 +8,36 @@ module Gitlab
class Image < Node
include Validatable
+ ALLOWED_KEYS = %i[name entrypoint].freeze
+
validations do
- validates :config, type: String
+ validates :config, hash_or_string: true
+ validates :config, allowed_keys: ALLOWED_KEYS
+
+ validates :name, type: String, presence: true
+ validates :entrypoint, array_of_strings: true, allow_nil: true
+ end
+
+ def hash?
+ @config.is_a?(Hash)
+ end
+
+ def string?
+ @config.is_a?(String)
+ end
+
+ def name
+ value[:name]
+ end
+
+ def entrypoint
+ value[:entrypoint]
+ end
+
+ def value
+ return { name: @config } if string?
+ return @config if hash?
+ {}
end
end
end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 176301bcca1..32f5c6ab142 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -11,7 +11,7 @@ module Gitlab
ALLOWED_KEYS = %i[tags script only except type image services allow_failure
type stage when artifacts cache dependencies before_script
- after_script variables environment coverage].freeze
+ after_script variables environment coverage retry].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
@@ -23,6 +23,9 @@ module Gitlab
with_options allow_nil: true do
validates :tags, array_of_strings: true
validates :allow_failure, boolean: true
+ validates :retry, numericality: { only_integer: true,
+ greater_than_or_equal_to: 0,
+ less_than_or_equal_to: 2 }
validates :when,
inclusion: { in: %w[on_success on_failure always manual],
message: 'should be on_success, on_failure, ' \
@@ -76,9 +79,9 @@ module Gitlab
helpers :before_script, :script, :stage, :type, :after_script,
:cache, :image, :services, :only, :except, :variables,
- :artifacts, :commands, :environment, :coverage
+ :artifacts, :commands, :environment, :coverage, :retry
- attributes :script, :tags, :allow_failure, :when, :dependencies
+ attributes :script, :tags, :allow_failure, :when, :dependencies, :retry
def compose!(deps = nil)
super do
@@ -142,6 +145,7 @@ module Gitlab
environment: environment_defined? ? environment_value : nil,
environment_name: environment_defined? ? environment_value[:name] : nil,
coverage: coverage_defined? ? coverage_value : nil,
+ retry: retry_defined? ? retry_value.to_i : nil,
artifacts: artifacts_value,
after_script: after_script_value,
ignore: ignored? }
diff --git a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb
index 9b9a0a8125a..a78a85397bd 100644
--- a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb
+++ b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb
@@ -21,7 +21,13 @@ module Gitlab
def validate_variables(variables)
variables.is_a?(Hash) &&
- variables.all? { |key, value| validate_string(key) && validate_string(value) }
+ variables.flatten.all? do |value|
+ validate_string(value) || validate_integer(value)
+ end
+ end
+
+ def validate_integer(value)
+ value.is_a?(Integer)
end
def validate_string(value)
diff --git a/lib/gitlab/ci/config/entry/service.rb b/lib/gitlab/ci/config/entry/service.rb
new file mode 100644
index 00000000000..3e2ebcff31a
--- /dev/null
+++ b/lib/gitlab/ci/config/entry/service.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Ci
+ class Config
+ module Entry
+ ##
+ # Entry that represents a configuration of Docker service.
+ #
+ class Service < Image
+ include Validatable
+
+ ALLOWED_KEYS = %i[name entrypoint command alias].freeze
+
+ validations do
+ validates :config, hash_or_string: true
+ validates :config, allowed_keys: ALLOWED_KEYS
+
+ validates :name, type: String, presence: true
+ validates :entrypoint, array_of_strings: true, allow_nil: true
+ validates :command, array_of_strings: true, allow_nil: true
+ validates :alias, type: String, allow_nil: true
+ end
+
+ def alias
+ value[:alias]
+ end
+
+ def command
+ value[:command]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/entry/services.rb b/lib/gitlab/ci/config/entry/services.rb
index 84f8ab780f5..0066894e069 100644
--- a/lib/gitlab/ci/config/entry/services.rb
+++ b/lib/gitlab/ci/config/entry/services.rb
@@ -9,7 +9,30 @@ module Gitlab
include Validatable
validations do
- validates :config, array_of_strings: true
+ validates :config, type: Array
+ end
+
+ def compose!(deps = nil)
+ super do
+ @entries = []
+ @config.each do |config|
+ @entries << Entry::Factory.new(Entry::Service)
+ .value(config || {})
+ .create!
+ end
+
+ @entries.each do |entry|
+ entry.compose!(deps)
+ end
+ end
+ end
+
+ def value
+ @entries.map(&:value)
+ end
+
+ def descendants
+ @entries
end
end
end
diff --git a/lib/gitlab/ci/config/entry/validators.rb b/lib/gitlab/ci/config/entry/validators.rb
index bd7428b1272..b2ca3c881e4 100644
--- a/lib/gitlab/ci/config/entry/validators.rb
+++ b/lib/gitlab/ci/config/entry/validators.rb
@@ -44,6 +44,14 @@ module Gitlab
end
end
+ class HashOrStringValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ unless value.is_a?(Hash) || value.is_a?(String)
+ record.errors.add(attribute, 'should be a hash or a string')
+ end
+ end
+ end
+
class KeyValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb
index c3b0e651c3a..8acab605c91 100644
--- a/lib/gitlab/ci/config/entry/variables.rb
+++ b/lib/gitlab/ci/config/entry/variables.rb
@@ -15,6 +15,10 @@ module Gitlab
def self.default
{}
end
+
+ def value
+ Hash[@config.map { |key, value| [key.to_s, value.to_s] }]
+ end
end
end
end
diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb
index a3cc350ef22..551483d0aaa 100644
--- a/lib/gitlab/ci/cron_parser.rb
+++ b/lib/gitlab/ci/cron_parser.rb
@@ -6,12 +6,12 @@ module Gitlab
def initialize(cron, cron_timezone = 'UTC')
@cron = cron
- @cron_timezone = cron_timezone
+ @cron_timezone = ActiveSupport::TimeZone.find_tzinfo(cron_timezone).name
end
def next_time_from(time)
@cron_line ||= try_parse_cron(@cron, @cron_timezone)
- @cron_line.next_time(time).in_time_zone(Time.zone) if @cron_line.present?
+ @cron_line.next_time(time).utc.in_time_zone(Time.zone) if @cron_line.present?
end
def cron_valid?
@@ -24,8 +24,23 @@ module Gitlab
private
+ # NOTE:
+ # cron_timezone can only accept timezones listed in TZInfo::Timezone.
+ # Aliases of Timezones from ActiveSupport::TimeZone are NOT accepted,
+ # because Rufus::Scheduler only supports TZInfo::Timezone.
+ #
+ # For example, those codes have the same effect.
+ # Time.zone = 'Pacific Time (US & Canada)' (ActiveSupport::TimeZone)
+ # Time.zone = 'America/Los_Angeles' (TZInfo::Timezone)
+ #
+ # However, try_parse_cron only accepts the latter format.
+ # try_parse_cron('* * * * *', 'Pacific Time (US & Canada)') -> Doesn't work
+ # try_parse_cron('* * * * *', 'America/Los_Angeles') -> Works
+ # If you want to know more, please take a look
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/values/time_zone.rb
def try_parse_cron(cron, cron_timezone)
- Rufus::Scheduler.parse("#{cron} #{cron_timezone}")
+ cron_line = Rufus::Scheduler.parse("#{cron} #{cron_timezone}")
+ cron_line if cron_line.is_a?(Rufus::Scheduler::CronLine)
rescue
# noop
end
diff --git a/lib/gitlab/ci/pipeline_duration.rb b/lib/gitlab/ci/pipeline_duration.rb
index a210e76acaa..3208cc2bef6 100644
--- a/lib/gitlab/ci/pipeline_duration.rb
+++ b/lib/gitlab/ci/pipeline_duration.rb
@@ -87,8 +87,8 @@ module Gitlab
def from_pipeline(pipeline)
status = %w[success failed running canceled]
- builds = pipeline.builds.latest.
- where(status: status).where.not(started_at: nil).order(:started_at)
+ builds = pipeline.builds.latest
+ .where(status: status).where.not(started_at: nil).order(:started_at)
from_builds(builds)
end
diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb
new file mode 100644
index 00000000000..f81f9347b4d
--- /dev/null
+++ b/lib/gitlab/ci/stage/seed.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ module Ci
+ module Stage
+ class Seed
+ attr_reader :pipeline
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline, stage, jobs)
+ @pipeline = pipeline
+ @stage = { name: stage }
+ @jobs = jobs.to_a.dup
+ end
+
+ def user=(current_user)
+ @jobs.map! do |attributes|
+ attributes.merge(user: current_user)
+ end
+ end
+
+ def stage
+ @stage.merge(project: project)
+ end
+
+ def builds
+ trigger = pipeline.trigger_requests.first
+
+ @jobs.map do |attributes|
+ attributes.merge(project: project,
+ ref: pipeline.ref,
+ tag: pipeline.tag,
+ trigger_request: trigger)
+ end
+ end
+
+ def create!
+ pipeline.stages.create!(stage).tap do |stage|
+ builds_attributes = builds.map do |attributes|
+ attributes.merge(stage_id: stage.id)
+ end
+
+ pipeline.builds.create!(builds_attributes).each do |build|
+ yield build if block_given?
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/action.rb b/lib/gitlab/ci/status/build/action.rb
new file mode 100644
index 00000000000..45fd0d4aa07
--- /dev/null
+++ b/lib/gitlab/ci/status/build/action.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Action < Status::Extended
+ def label
+ if has_action?
+ @status.label
+ else
+ "#{@status.label} (not allowed)"
+ end
+ end
+
+ def self.matches?(build, user)
+ build.action?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb
index 67bbc3c4849..8ad3e57e59d 100644
--- a/lib/gitlab/ci/status/build/cancelable.rb
+++ b/lib/gitlab/ci/status/build/cancelable.rb
@@ -2,9 +2,7 @@ module Gitlab
module Ci
module Status
module Build
- class Cancelable < SimpleDelegator
- include Status::Extended
-
+ class Cancelable < Status::Extended
def has_action?
can?(user, :update_build, subject)
end
@@ -14,9 +12,7 @@ module Gitlab
end
def action_path
- cancel_namespace_project_build_path(subject.project.namespace,
- subject.project,
- subject)
+ cancel_project_job_path(subject.project, subject)
end
def action_method
diff --git a/lib/gitlab/ci/status/build/common.rb b/lib/gitlab/ci/status/build/common.rb
index 3fec2c5d4db..c0c7c7f5b5d 100644
--- a/lib/gitlab/ci/status/build/common.rb
+++ b/lib/gitlab/ci/status/build/common.rb
@@ -8,9 +8,7 @@ module Gitlab
end
def details_path
- namespace_project_build_path(subject.project.namespace,
- subject.project,
- subject)
+ project_job_path(subject.project, subject)
end
end
end
diff --git a/lib/gitlab/ci/status/build/factory.rb b/lib/gitlab/ci/status/build/factory.rb
index 38ac6edc9f1..c852d607373 100644
--- a/lib/gitlab/ci/status/build/factory.rb
+++ b/lib/gitlab/ci/status/build/factory.rb
@@ -8,7 +8,8 @@ module Gitlab
Status::Build::Retryable],
[Status::Build::FailedAllowed,
Status::Build::Play,
- Status::Build::Stop]]
+ Status::Build::Stop],
+ [Status::Build::Action]]
end
def self.common_helpers
diff --git a/lib/gitlab/ci/status/build/failed_allowed.rb b/lib/gitlab/ci/status/build/failed_allowed.rb
index 807afe24bd5..e42d3574357 100644
--- a/lib/gitlab/ci/status/build/failed_allowed.rb
+++ b/lib/gitlab/ci/status/build/failed_allowed.rb
@@ -2,9 +2,7 @@ module Gitlab
module Ci
module Status
module Build
- class FailedAllowed < SimpleDelegator
- include Status::Extended
-
+ class FailedAllowed < Status::Extended
def label
'failed (allowed to fail)'
end
diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb
index 3495b8d0448..c7726543599 100644
--- a/lib/gitlab/ci/status/build/play.rb
+++ b/lib/gitlab/ci/status/build/play.rb
@@ -2,9 +2,7 @@ module Gitlab
module Ci
module Status
module Build
- class Play < SimpleDelegator
- include Status::Extended
-
+ class Play < Status::Extended
def label
'manual play action'
end
@@ -22,9 +20,7 @@ module Gitlab
end
def action_path
- play_namespace_project_build_path(subject.project.namespace,
- subject.project,
- subject)
+ play_project_job_path(subject.project, subject)
end
def action_method
diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb
index 6b362af7634..8c8fdc56d75 100644
--- a/lib/gitlab/ci/status/build/retryable.rb
+++ b/lib/gitlab/ci/status/build/retryable.rb
@@ -2,9 +2,7 @@ module Gitlab
module Ci
module Status
module Build
- class Retryable < SimpleDelegator
- include Status::Extended
-
+ class Retryable < Status::Extended
def has_action?
can?(user, :update_build, subject)
end
@@ -18,9 +16,7 @@ module Gitlab
end
def action_path
- retry_namespace_project_build_path(subject.project.namespace,
- subject.project,
- subject)
+ retry_project_job_path(subject.project, subject)
end
def action_method
diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb
index e8530f2aaae..d464738deaf 100644
--- a/lib/gitlab/ci/status/build/stop.rb
+++ b/lib/gitlab/ci/status/build/stop.rb
@@ -2,9 +2,7 @@ module Gitlab
module Ci
module Status
module Build
- class Stop < SimpleDelegator
- include Status::Extended
-
+ class Stop < Status::Extended
def label
'manual stop action'
end
@@ -22,9 +20,7 @@ module Gitlab
end
def action_path
- play_namespace_project_build_path(subject.project.namespace,
- subject.project,
- subject)
+ play_project_job_path(subject.project, subject)
end
def action_method
diff --git a/lib/gitlab/ci/status/canceled.rb b/lib/gitlab/ci/status/canceled.rb
index 97c121ce7b9..e5fdc1f8136 100644
--- a/lib/gitlab/ci/status/canceled.rb
+++ b/lib/gitlab/ci/status/canceled.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Canceled < Status::Core
def text
- 'canceled'
+ s_('CiStatusText|canceled')
end
def label
- 'canceled'
+ s_('CiStatusLabel|canceled')
end
def icon
diff --git a/lib/gitlab/ci/status/created.rb b/lib/gitlab/ci/status/created.rb
index 0721bf6ec7c..d188bd286a6 100644
--- a/lib/gitlab/ci/status/created.rb
+++ b/lib/gitlab/ci/status/created.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Created < Status::Core
def text
- 'created'
+ s_('CiStatusText|created')
end
def label
- 'created'
+ s_('CiStatusLabel|created')
end
def icon
diff --git a/lib/gitlab/ci/status/extended.rb b/lib/gitlab/ci/status/extended.rb
index d367c9bda69..1e8101f8949 100644
--- a/lib/gitlab/ci/status/extended.rb
+++ b/lib/gitlab/ci/status/extended.rb
@@ -1,13 +1,13 @@
module Gitlab
module Ci
module Status
- module Extended
- extend ActiveSupport::Concern
+ class Extended < SimpleDelegator
+ def initialize(status)
+ super(@status = status)
+ end
- class_methods do
- def matches?(_subject, _user)
- raise NotImplementedError
- end
+ def self.matches?(_subject, _user)
+ raise NotImplementedError
end
end
end
diff --git a/lib/gitlab/ci/status/external/common.rb b/lib/gitlab/ci/status/external/common.rb
index 4969a350862..9307545b5b1 100644
--- a/lib/gitlab/ci/status/external/common.rb
+++ b/lib/gitlab/ci/status/external/common.rb
@@ -3,6 +3,10 @@ module Gitlab
module Status
module External
module Common
+ def label
+ subject.description
+ end
+
def has_details?
subject.target_url.present? &&
can?(user, :read_commit_status, subject)
diff --git a/lib/gitlab/ci/status/failed.rb b/lib/gitlab/ci/status/failed.rb
index cb75e9383a8..38e45714c22 100644
--- a/lib/gitlab/ci/status/failed.rb
+++ b/lib/gitlab/ci/status/failed.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Failed < Status::Core
def text
- 'failed'
+ s_('CiStatusText|failed')
end
def label
- 'failed'
+ s_('CiStatusLabel|failed')
end
def icon
diff --git a/lib/gitlab/ci/status/group/common.rb b/lib/gitlab/ci/status/group/common.rb
new file mode 100644
index 00000000000..cfd4329a923
--- /dev/null
+++ b/lib/gitlab/ci/status/group/common.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Status
+ module Group
+ module Common
+ def has_details?
+ false
+ end
+
+ def details_path
+ nil
+ end
+
+ def has_action?
+ false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/group/factory.rb b/lib/gitlab/ci/status/group/factory.rb
new file mode 100644
index 00000000000..d118116cfc3
--- /dev/null
+++ b/lib/gitlab/ci/status/group/factory.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module Ci
+ module Status
+ module Group
+ class Factory < Status::Factory
+ def self.common_helpers
+ Status::Group::Common
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/manual.rb b/lib/gitlab/ci/status/manual.rb
index f8f6c2903ba..a4a7edadac9 100644
--- a/lib/gitlab/ci/status/manual.rb
+++ b/lib/gitlab/ci/status/manual.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Manual < Status::Core
def text
- 'manual'
+ s_('CiStatusText|manual')
end
def label
- 'manual action'
+ s_('CiStatusLabel|manual action')
end
def icon
diff --git a/lib/gitlab/ci/status/pending.rb b/lib/gitlab/ci/status/pending.rb
index f40cc1314dc..5164260b861 100644
--- a/lib/gitlab/ci/status/pending.rb
+++ b/lib/gitlab/ci/status/pending.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Pending < Status::Core
def text
- 'pending'
+ s_('CiStatusText|pending')
end
def label
- 'pending'
+ s_('CiStatusLabel|pending')
end
def icon
diff --git a/lib/gitlab/ci/status/pipeline/blocked.rb b/lib/gitlab/ci/status/pipeline/blocked.rb
index a250c3fcb41..bf7e484ee9b 100644
--- a/lib/gitlab/ci/status/pipeline/blocked.rb
+++ b/lib/gitlab/ci/status/pipeline/blocked.rb
@@ -2,15 +2,13 @@ module Gitlab
module Ci
module Status
module Pipeline
- class Blocked < SimpleDelegator
- include Status::Extended
-
+ class Blocked < Status::Extended
def text
- 'blocked'
+ s_('CiStatusText|blocked')
end
def label
- 'waiting for manual action'
+ s_('CiStatusLabel|waiting for manual action')
end
def self.matches?(pipeline, user)
diff --git a/lib/gitlab/ci/status/pipeline/common.rb b/lib/gitlab/ci/status/pipeline/common.rb
index 76bfd18bf40..61bb07beb0f 100644
--- a/lib/gitlab/ci/status/pipeline/common.rb
+++ b/lib/gitlab/ci/status/pipeline/common.rb
@@ -8,9 +8,7 @@ module Gitlab
end
def details_path
- namespace_project_pipeline_path(subject.project.namespace,
- subject.project,
- subject)
+ project_pipeline_path(subject.project, subject)
end
def has_action?
diff --git a/lib/gitlab/ci/status/running.rb b/lib/gitlab/ci/status/running.rb
index 1237cd47dc8..993937e98ca 100644
--- a/lib/gitlab/ci/status/running.rb
+++ b/lib/gitlab/ci/status/running.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Running < Status::Core
def text
- 'running'
+ s_('CiStatus|running')
end
def label
- 'running'
+ s_('CiStatus|running')
end
def icon
diff --git a/lib/gitlab/ci/status/skipped.rb b/lib/gitlab/ci/status/skipped.rb
index 28005d91503..0c942920b02 100644
--- a/lib/gitlab/ci/status/skipped.rb
+++ b/lib/gitlab/ci/status/skipped.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Skipped < Status::Core
def text
- 'skipped'
+ s_('CiStatusText|skipped')
end
def label
- 'skipped'
+ s_('CiStatusLabel|skipped')
end
def icon
diff --git a/lib/gitlab/ci/status/stage/common.rb b/lib/gitlab/ci/status/stage/common.rb
index 7852f492e1d..bc99d925347 100644
--- a/lib/gitlab/ci/status/stage/common.rb
+++ b/lib/gitlab/ci/status/stage/common.rb
@@ -8,10 +8,7 @@ module Gitlab
end
def details_path
- namespace_project_pipeline_path(subject.project.namespace,
- subject.project,
- subject.pipeline,
- anchor: subject.name)
+ project_pipeline_path(subject.project, subject.pipeline, anchor: subject.name)
end
def has_action?
diff --git a/lib/gitlab/ci/status/success.rb b/lib/gitlab/ci/status/success.rb
index 88f7758a270..d7af98857b0 100644
--- a/lib/gitlab/ci/status/success.rb
+++ b/lib/gitlab/ci/status/success.rb
@@ -3,11 +3,11 @@ module Gitlab
module Status
class Success < Status::Core
def text
- 'passed'
+ s_('CiStatusText|passed')
end
def label
- 'passed'
+ s_('CiStatusLabel|passed')
end
def icon
diff --git a/lib/gitlab/ci/status/success_warning.rb b/lib/gitlab/ci/status/success_warning.rb
index d4cdab6957a..4d7d82e04cf 100644
--- a/lib/gitlab/ci/status/success_warning.rb
+++ b/lib/gitlab/ci/status/success_warning.rb
@@ -5,15 +5,13 @@ module Gitlab
# Extended status used when pipeline or stage passed conditionally.
# This means that failed jobs that are allowed to fail were present.
#
- class SuccessWarning < SimpleDelegator
- include Status::Extended
-
+ class SuccessWarning < Status::Extended
def text
- 'passed'
+ s_('CiStatusText|passed')
end
def label
- 'passed with warnings'
+ s_('CiStatusLabel|passed with warnings')
end
def icon
diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb
index fa462cbe095..8503ecf8700 100644
--- a/lib/gitlab/ci/trace/stream.rb
+++ b/lib/gitlab/ci/trace/stream.rb
@@ -67,14 +67,15 @@ module Gitlab
def extract_coverage(regex)
return unless valid?
- return unless regex
+ return unless regex.present?
- regex = Regexp.new(regex)
+ regex = Gitlab::UntrustedRegexp.new(regex)
match = ""
- stream.each_line do |line|
- matches = line.scan(regex)
+ reverse_line do |line|
+ line.chomp!
+ matches = regex.scan(line)
next unless matches.is_a?(Array)
next if matches.empty?
@@ -86,34 +87,39 @@ module Gitlab
nil
rescue
# if bad regex or something goes wrong we dont want to interrupt transition
- # so we just silentrly ignore error for now
+ # so we just silently ignore error for now
end
private
- def read_last_lines(last_lines)
- chunks = []
- pos = lines = 0
- max = stream.size
-
- # We want an extra line to make sure fist line has full contents
- while lines <= last_lines && pos < max
- pos += BUFFER_SIZE
-
- buf =
- if pos <= max
- stream.seek(-pos, IO::SEEK_END)
- stream.read(BUFFER_SIZE)
- else # Reached the head, read only left
- stream.seek(0)
- stream.read(BUFFER_SIZE - (pos - max))
- end
-
- lines += buf.count("\n")
- chunks.unshift(buf)
+ def read_last_lines(limit)
+ to_enum(:reverse_line).first(limit).reverse.join
+ end
+
+ def reverse_line
+ stream.seek(0, IO::SEEK_END)
+ debris = ''
+
+ until (buf = read_backward(BUFFER_SIZE)).empty?
+ buf += debris
+ debris, *lines = buf.each_line.to_a
+ lines.reverse_each do |line|
+ yield(line.force_encoding('UTF-8'))
+ end
end
- chunks.join.lines.last(last_lines).join
+ yield(debris.force_encoding('UTF-8')) unless debris.empty?
+ end
+
+ def read_backward(length)
+ cur_offset = stream.tell
+ start = cur_offset - length
+ start = 0 if start < 0
+
+ stream.seek(start, IO::SEEK_SET)
+ stream.read(cur_offset - start).tap do
+ stream.seek(start, IO::SEEK_SET)
+ end
end
end
end
diff --git a/lib/gitlab/ci_access.rb b/lib/gitlab/ci_access.rb
new file mode 100644
index 00000000000..def1373d8cf
--- /dev/null
+++ b/lib/gitlab/ci_access.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ # For backwards compatibility, generic CI (which is a build without a user) is
+ # allowed to :build_download_code without any other checks.
+ class CiAccess
+ def can_do_action?(action)
+ action == :build_download_code
+ end
+ end
+end
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
index 75a213ef752..98dfe900044 100644
--- a/lib/gitlab/conflict/file.rb
+++ b/lib/gitlab/conflict/file.rb
@@ -1,7 +1,7 @@
module Gitlab
module Conflict
class File
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
include IconsHelper
MissingResolution = Class.new(ResolutionError)
@@ -205,9 +205,7 @@ module Gitlab
old_path: their_path,
new_path: our_path,
blob_icon: file_type_icon_class('file', our_mode, our_path),
- blob_path: namespace_project_blob_path(merge_request.project.namespace,
- merge_request.project,
- ::File.join(merge_request.diff_refs.head_sha, our_path))
+ blob_path: project_blob_path(merge_request.project, ::File.join(merge_request.diff_refs.head_sha, our_path))
}
json_hash.tap do |json_hash|
@@ -223,11 +221,10 @@ module Gitlab
end
def content_path
- conflict_for_path_namespace_project_merge_request_path(merge_request.project.namespace,
- merge_request.project,
- merge_request,
- old_path: their_path,
- new_path: our_path)
+ conflict_for_path_project_merge_request_path(merge_request.project,
+ merge_request,
+ old_path: their_path,
+ new_path: our_path)
end
# Don't try to print merge_request or repository.
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
index 990b719ecfd..d671867e7c7 100644
--- a/lib/gitlab/conflict/file_collection.rb
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -3,16 +3,33 @@ module Gitlab
class FileCollection
ConflictSideMissing = Class.new(StandardError)
- attr_reader :merge_request, :our_commit, :their_commit
+ attr_reader :merge_request, :our_commit, :their_commit, :project
- def initialize(merge_request)
- @merge_request = merge_request
- @our_commit = merge_request.source_branch_head.raw.raw_commit
- @their_commit = merge_request.target_branch_head.raw.raw_commit
- end
+ delegate :repository, to: :project
+
+ class << self
+ # We can only write when getting the merge index from the source
+ # project, because we will write to that project. We don't use this all
+ # the time because this fetches a ref into the source project, which
+ # isn't needed for reading.
+ def for_resolution(merge_request)
+ project = merge_request.source_project
+
+ new(merge_request, project).tap do |file_collection|
+ project
+ .repository
+ .with_repo_branch_commit(merge_request.target_project.repository, merge_request.target_branch) do
+
+ yield file_collection
+ end
+ end
+ end
- def repository
- merge_request.project.repository
+ # We don't need to do `with_repo_branch_commit` here, because the target
+ # project always fetches source refs when creating merge request diffs.
+ def read_only(merge_request)
+ new(merge_request, merge_request.target_project)
+ end
end
def merge_index
@@ -55,6 +72,15 @@ Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branc
#{conflict_filenames.join("\n")}
EOM
end
+
+ private
+
+ def initialize(merge_request, project)
+ @merge_request = merge_request
+ @our_commit = merge_request.source_branch_head.raw.rugged_commit
+ @their_commit = merge_request.target_branch_head.raw.rugged_commit
+ @project = project
+ end
end
end
end
diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb
index 15992b77680..0735243e021 100644
--- a/lib/gitlab/contributions_calendar.rb
+++ b/lib/gitlab/contributions_calendar.rb
@@ -16,27 +16,27 @@ module Gitlab
# Can't use Event.contributions here because we need to check 3 different
# project_features for the (currently) 3 different contribution types
date_from = 1.year.ago
- repo_events = event_counts(date_from, :repository).
- having(action: Event::PUSHED)
- issue_events = event_counts(date_from, :issues).
- having(action: [Event::CREATED, Event::CLOSED], target_type: "Issue")
- mr_events = event_counts(date_from, :merge_requests).
- having(action: [Event::MERGED, Event::CREATED, Event::CLOSED], target_type: "MergeRequest")
- note_events = event_counts(date_from, :merge_requests).
- having(action: [Event::COMMENTED], target_type: "Note")
+ repo_events = event_counts(date_from, :repository)
+ .having(action: Event::PUSHED)
+ issue_events = event_counts(date_from, :issues)
+ .having(action: [Event::CREATED, Event::CLOSED], target_type: "Issue")
+ mr_events = event_counts(date_from, :merge_requests)
+ .having(action: [Event::MERGED, Event::CREATED, Event::CLOSED], target_type: "MergeRequest")
+ note_events = event_counts(date_from, :merge_requests)
+ .having(action: [Event::COMMENTED], target_type: "Note")
union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events])
events = Event.find_by_sql(union.to_sql).map(&:attributes)
- @activity_events = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities|
+ @activity_dates = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities|
activities[event["date"]] += event["total_amount"]
end
end
def events_by_date(date)
- events = Event.contributions.where(author_id: contributor.id).
- where(created_at: date.beginning_of_day..date.end_of_day).
- where(project_id: projects)
+ events = Event.contributions.where(author_id: contributor.id)
+ .where(created_at: date.beginning_of_day..date.end_of_day)
+ .where(project_id: projects)
# Use visible_to_user? instead of the complicated logic in activity_dates
# because we're only viewing the events for a single day.
@@ -48,7 +48,7 @@ module Gitlab
end
def starting_month
- Date.today.month
+ Date.current.month
end
private
@@ -60,20 +60,26 @@ module Gitlab
# use IN(project_ids...) instead. It's the intersection of two users so
# the list will be (relatively) short
@contributed_project_ids ||= projects.uniq.pluck(:id)
- authed_projects = Project.where(id: @contributed_project_ids).
- with_feature_available_for_user(feature, current_user).
- reorder(nil).
- select(:id)
+ authed_projects = Project.where(id: @contributed_project_ids)
+ .with_feature_available_for_user(feature, current_user)
+ .reorder(nil)
+ .select(:id)
- conditions = t[:created_at].gteq(date_from.beginning_of_day).
- and(t[:created_at].lteq(Date.today.end_of_day)).
- and(t[:author_id].eq(contributor.id))
+ conditions = t[:created_at].gteq(date_from.beginning_of_day)
+ .and(t[:created_at].lteq(Date.current.end_of_day))
+ .and(t[:author_id].eq(contributor.id))
- Event.reorder(nil).
- select(t[:project_id], t[:target_type], t[:action], 'date(created_at) AS date', 'count(id) as total_amount').
- group(t[:project_id], t[:target_type], t[:action], 'date(created_at)').
- where(conditions).
- having(t[:project_id].in(Arel::Nodes::SqlLiteral.new(authed_projects.to_sql)))
+ date_interval = if Gitlab::Database.postgresql?
+ "INTERVAL '#{Time.zone.now.utc_offset} seconds'"
+ else
+ "INTERVAL #{Time.zone.now.utc_offset} SECOND"
+ end
+
+ Event.reorder(nil)
+ .select(t[:project_id], t[:target_type], t[:action], "date(created_at + #{date_interval}) AS date", 'count(id) as total_amount')
+ .group(t[:project_id], t[:target_type], t[:action], "date(created_at + #{date_interval})")
+ .where(conditions)
+ .having(t[:project_id].in(Arel::Nodes::SqlLiteral.new(authed_projects.to_sql)))
end
end
end
diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb
index 82576d197fe..7fa02f3d7b3 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -8,39 +8,56 @@ module Gitlab
end
end
+ delegate :sidekiq_throttling_enabled?, to: :current_application_settings
+
+ def fake_application_settings(defaults = ::ApplicationSetting.defaults)
+ FakeApplicationSettings.new(defaults)
+ end
+
+ private
+
def ensure_application_settings!
- return fake_application_settings unless connect_to_db?
+ return in_memory_application_settings if ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
+
+ cached_application_settings || uncached_application_settings
+ end
- unless ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
- begin
- settings = ::ApplicationSetting.current
+ def cached_application_settings
+ begin
+ ::ApplicationSetting.cached
+ rescue ::Redis::BaseError, ::Errno::ENOENT, ::Errno::EADDRNOTAVAIL
# In case Redis isn't running or the Redis UNIX socket file is not available
- rescue ::Redis::BaseError, ::Errno::ENOENT
- settings = ::ApplicationSetting.last
- end
+ end
+ end
- settings ||= ::ApplicationSetting.create_from_defaults unless ActiveRecord::Migrator.needs_migration?
+ def uncached_application_settings
+ return fake_application_settings unless connect_to_db?
+
+ db_settings = ::ApplicationSetting.current
+
+ # If there are pending migrations, it's possible there are columns that
+ # need to be added to the application settings. To prevent Rake tasks
+ # and other callers from failing, use any loaded settings and return
+ # defaults for missing columns.
+ if ActiveRecord::Migrator.needs_migration?
+ defaults = ::ApplicationSetting.defaults
+ defaults.merge!(db_settings.attributes.symbolize_keys) if db_settings.present?
+ return fake_application_settings(defaults)
end
- settings || in_memory_application_settings
- end
+ return db_settings if db_settings.present?
- delegate :sidekiq_throttling_enabled?, to: :current_application_settings
+ ::ApplicationSetting.create_from_defaults || in_memory_application_settings
+ end
def in_memory_application_settings
@in_memory_application_settings ||= ::ApplicationSetting.new(::ApplicationSetting.defaults)
- # In case migrations the application_settings table is not created yet,
- # we fallback to a simple OpenStruct
rescue ActiveRecord::StatementInvalid, ActiveRecord::UnknownAttributeError
+ # In case migrations the application_settings table is not created yet,
+ # we fallback to a simple OpenStruct
fake_application_settings
end
- def fake_application_settings
- OpenStruct.new(::ApplicationSetting.defaults)
- end
-
- private
-
def connect_to_db?
# When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
active_db_connection = ActiveRecord::Base.connection.active? rescue false
diff --git a/lib/gitlab/cycle_analytics/base_query.rb b/lib/gitlab/cycle_analytics/base_query.rb
index d560dca45c8..58729d3ced8 100644
--- a/lib/gitlab/cycle_analytics/base_query.rb
+++ b/lib/gitlab/cycle_analytics/base_query.rb
@@ -12,17 +12,17 @@ module Gitlab
end
def stage_query
- query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])).
- join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])).
- where(issue_table[:project_id].eq(@project.id)).
- where(issue_table[:deleted_at].eq(nil)).
- where(issue_table[:created_at].gteq(@options[:from]))
+ query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id]))
+ .join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id]))
+ .where(issue_table[:project_id].eq(@project.id))
+ .where(issue_table[:deleted_at].eq(nil))
+ .where(issue_table[:created_at].gteq(@options[:from]))
# Load merge_requests
- query = query.join(mr_table, Arel::Nodes::OuterJoin).
- on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])).
- join(mr_metrics_table).
- on(mr_table[:id].eq(mr_metrics_table[:merge_request_id]))
+ query = query.join(mr_table, Arel::Nodes::OuterJoin)
+ .on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id]))
+ .join(mr_metrics_table)
+ .on(mr_table[:id].eq(mr_metrics_table[:merge_request_id]))
query
end
diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb
index 559e3939da6..cac31ea8cff 100644
--- a/lib/gitlab/cycle_analytics/base_stage.rb
+++ b/lib/gitlab/cycle_analytics/base_stage.rb
@@ -17,7 +17,7 @@ module Gitlab
end
def title
- name.to_s.capitalize
+ raise NotImplementedError.new("Expected #{self.name} to implement title")
end
def median
diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb
index 1e52b6614a1..5f9dc9a4303 100644
--- a/lib/gitlab/cycle_analytics/code_stage.rb
+++ b/lib/gitlab/cycle_analytics/code_stage.rb
@@ -13,12 +13,16 @@ module Gitlab
:code
end
+ def title
+ s_('CycleAnalyticsStage|Code')
+ end
+
def legend
- "Related Merge Requests"
+ _("Related Merge Requests")
end
def description
- "Time until first merge request"
+ _("Time until first merge request")
end
end
end
diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb
index 213994988a5..7b03811efb2 100644
--- a/lib/gitlab/cycle_analytics/issue_stage.rb
+++ b/lib/gitlab/cycle_analytics/issue_stage.rb
@@ -14,12 +14,16 @@ module Gitlab
:issue
end
+ def title
+ s_('CycleAnalyticsStage|Issue')
+ end
+
def legend
- "Related Issues"
+ _("Related Issues")
end
def description
- "Time before an issue gets scheduled"
+ _("Time before an issue gets scheduled")
end
end
end
diff --git a/lib/gitlab/cycle_analytics/metrics_tables.rb b/lib/gitlab/cycle_analytics/metrics_tables.rb
index 9d25ef078e8..f5d08c0b658 100644
--- a/lib/gitlab/cycle_analytics/metrics_tables.rb
+++ b/lib/gitlab/cycle_analytics/metrics_tables.rb
@@ -13,6 +13,10 @@ module Gitlab
MergeRequestDiff.arel_table
end
+ def mr_diff_commits_table
+ MergeRequestDiffCommit.arel_table
+ end
+
def mr_closing_issues_table
MergeRequestsClosingIssues.arel_table
end
diff --git a/lib/gitlab/cycle_analytics/permissions.rb b/lib/gitlab/cycle_analytics/permissions.rb
index bef3b95ff1b..1e11e84a9cb 100644
--- a/lib/gitlab/cycle_analytics/permissions.rb
+++ b/lib/gitlab/cycle_analytics/permissions.rb
@@ -7,7 +7,7 @@ module Gitlab
test: :read_build,
review: :read_merge_request,
staging: :read_build,
- production: :read_issue,
+ production: :read_issue
}.freeze
def self.get(*args)
diff --git a/lib/gitlab/cycle_analytics/plan_event_fetcher.rb b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb
index 7d342a2d2cb..2479b4a7706 100644
--- a/lib/gitlab/cycle_analytics/plan_event_fetcher.rb
+++ b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb
@@ -2,40 +2,59 @@ module Gitlab
module CycleAnalytics
class PlanEventFetcher < BaseEventFetcher
def initialize(*args)
- @projections = [mr_diff_table[:st_commits].as('commits'),
+ @projections = [mr_diff_table[:id],
+ mr_diff_table[:st_commits],
issue_metrics_table[:first_mentioned_in_commit_at]]
super(*args)
end
def events_query
- base_query.join(mr_diff_table).on(mr_diff_table[:merge_request_id].eq(mr_table[:id]))
+ base_query
+ .join(mr_diff_table)
+ .on(mr_diff_table[:merge_request_id].eq(mr_table[:id]))
super
end
private
+ def merge_request_diff_commits
+ @merge_request_diff_commits ||=
+ MergeRequestDiffCommit
+ .where(merge_request_diff_id: event_result.map { |event| event['id'] })
+ .group_by(&:merge_request_diff_id)
+ end
+
def serialize(event)
- st_commit = first_time_reference_commit(event.delete('commits'), event)
+ commit = first_time_reference_commit(event)
- return unless st_commit
+ return unless commit
- serialize_commit(event, st_commit, query)
+ serialize_commit(event, commit, query)
end
- def first_time_reference_commit(commits, event)
+ def first_time_reference_commit(event)
+ return nil unless event && merge_request_diff_commits
+
+ commits =
+ if event['st_commits'].present?
+ YAML.load(event['st_commits'])
+ else
+ merge_request_diff_commits[event['id'].to_i]
+ end
+
return nil if commits.blank?
- YAML.load(commits).find do |commit|
+ commits.find do |commit|
next unless commit[:committed_date] && event['first_mentioned_in_commit_at']
commit[:committed_date].to_i == DateTime.parse(event['first_mentioned_in_commit_at'].to_s).to_i
end
end
- def serialize_commit(event, st_commit, query)
- commit = Commit.new(Gitlab::Git::Commit.new(st_commit), @project)
+ def serialize_commit(event, commit, query)
+ commit = Commit.from_hash(commit.to_hash, @project)
AnalyticsCommitSerializer.new(project: @project, total_time: event['total_time']).represent(commit)
end
diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb
index 45d51d30ccc..1a0afb56b4f 100644
--- a/lib/gitlab/cycle_analytics/plan_stage.rb
+++ b/lib/gitlab/cycle_analytics/plan_stage.rb
@@ -14,12 +14,16 @@ module Gitlab
:plan
end
+ def title
+ s_('CycleAnalyticsStage|Plan')
+ end
+
def legend
- "Related Commits"
+ _("Related Commits")
end
def description
- "Time before an issue starts implementation"
+ _("Time before an issue starts implementation")
end
end
end
diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb
index 9f387a02945..0fa8a65cb99 100644
--- a/lib/gitlab/cycle_analytics/production_stage.rb
+++ b/lib/gitlab/cycle_analytics/production_stage.rb
@@ -15,12 +15,16 @@ module Gitlab
:production
end
+ def title
+ s_('CycleAnalyticsStage|Production')
+ end
+
def legend
- "Related Issues"
+ _("Related Issues")
end
def description
- "From issue creation until deploy to production"
+ _("From issue creation until deploy to production")
end
def query
diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb
index 4744be834de..cfbbdc43fd9 100644
--- a/lib/gitlab/cycle_analytics/review_stage.rb
+++ b/lib/gitlab/cycle_analytics/review_stage.rb
@@ -13,12 +13,16 @@ module Gitlab
:review
end
+ def title
+ s_('CycleAnalyticsStage|Review')
+ end
+
def legend
- "Relative Merged Requests"
+ _("Related Merged Requests")
end
def description
- "Time between merge request creation and merge/close"
+ _("Time between merge request creation and merge/close")
end
end
end
diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb
index 3cdbe04fbaf..d5684bb9201 100644
--- a/lib/gitlab/cycle_analytics/staging_stage.rb
+++ b/lib/gitlab/cycle_analytics/staging_stage.rb
@@ -14,12 +14,16 @@ module Gitlab
:staging
end
+ def title
+ s_('CycleAnalyticsStage|Staging')
+ end
+
def legend
- "Relative Deployed Builds"
+ _("Related Deployed Jobs")
end
def description
- "From merge request merge until deploy to production"
+ _("From merge request merge until deploy to production")
end
end
end
diff --git a/lib/gitlab/cycle_analytics/summary/base.rb b/lib/gitlab/cycle_analytics/summary/base.rb
index 43fa3795e5c..a917ddccac7 100644
--- a/lib/gitlab/cycle_analytics/summary/base.rb
+++ b/lib/gitlab/cycle_analytics/summary/base.rb
@@ -8,7 +8,7 @@ module Gitlab
end
def title
- self.class.name.demodulize
+ raise NotImplementedError.new("Expected #{self.name} to implement title")
end
def value
diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb
index 7b8faa4d854..bea78862757 100644
--- a/lib/gitlab/cycle_analytics/summary/commit.rb
+++ b/lib/gitlab/cycle_analytics/summary/commit.rb
@@ -2,6 +2,10 @@ module Gitlab
module CycleAnalytics
module Summary
class Commit < Base
+ def title
+ n_('Commit', 'Commits', value)
+ end
+
def value
@value ||= count_commits
end
diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb
index 06032e9200e..099d798aac6 100644
--- a/lib/gitlab/cycle_analytics/summary/deploy.rb
+++ b/lib/gitlab/cycle_analytics/summary/deploy.rb
@@ -2,6 +2,10 @@ module Gitlab
module CycleAnalytics
module Summary
class Deploy < Base
+ def title
+ n_('Deploy', 'Deploys', value)
+ end
+
def value
@value ||= @project.deployments.where("created_at > ?", @from).count
end
diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb
index 008468f24b9..9bbf7a2685f 100644
--- a/lib/gitlab/cycle_analytics/summary/issue.rb
+++ b/lib/gitlab/cycle_analytics/summary/issue.rb
@@ -9,7 +9,7 @@ module Gitlab
end
def title
- 'New Issue'
+ n_('New Issue', 'New Issues', value)
end
def value
diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb
index e96943833bc..2b5f72bef89 100644
--- a/lib/gitlab/cycle_analytics/test_stage.rb
+++ b/lib/gitlab/cycle_analytics/test_stage.rb
@@ -13,12 +13,16 @@ module Gitlab
:test
end
+ def title
+ s_('CycleAnalyticsStage|Test')
+ end
+
def legend
- "Relative Builds Trigger by Commits"
+ _("Related Jobs")
end
def description
- "Total test time for all commits/merges"
+ _("Total test time for all commits/merges")
end
def stage_query
diff --git a/lib/gitlab/daemon.rb b/lib/gitlab/daemon.rb
new file mode 100644
index 00000000000..dfd17e35707
--- /dev/null
+++ b/lib/gitlab/daemon.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ class Daemon
+ def self.initialize_instance(*args)
+ raise "#{name} singleton instance already initialized" if @instance
+ @instance = new(*args)
+ Kernel.at_exit(&@instance.method(:stop))
+ @instance
+ end
+
+ def self.instance
+ @instance ||= initialize_instance
+ end
+
+ attr_reader :thread
+
+ def thread?
+ !thread.nil?
+ end
+
+ def initialize
+ @mutex = Mutex.new
+ end
+
+ def enabled?
+ true
+ end
+
+ def start
+ return unless enabled?
+
+ @mutex.synchronize do
+ return thread if thread?
+
+ @thread = Thread.new { start_working }
+ end
+ end
+
+ def stop
+ @mutex.synchronize do
+ return unless thread?
+
+ stop_working
+
+ if thread
+ thread.wakeup if thread.alive?
+ thread.join
+ @thread = nil
+ end
+ end
+ end
+
+ private
+
+ def start_working
+ raise NotImplementedError
+ end
+
+ def stop_working
+ # no-ops
+ end
+ end
+end
diff --git a/lib/gitlab/data_builder/build.rb b/lib/gitlab/data_builder/build.rb
index f78106f5b10..8e74e18a311 100644
--- a/lib/gitlab/data_builder/build.rb
+++ b/lib/gitlab/data_builder/build.rb
@@ -36,7 +36,7 @@ module Gitlab
user: {
id: user.try(:id),
name: user.try(:name),
- email: user.try(:email),
+ email: user.try(:email)
},
commit: {
@@ -49,7 +49,7 @@ module Gitlab
status: commit.status,
duration: commit.duration,
started_at: commit.started_at,
- finished_at: commit.finished_at,
+ finished_at: commit.finished_at
},
repository: {
@@ -60,7 +60,7 @@ module Gitlab
git_http_url: project.http_url_to_repo,
git_ssh_url: project.ssh_url_to_repo,
visibility_level: project.visibility_level
- },
+ }
}
data
diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb
index 182a30fd74d..e47fb85b5ee 100644
--- a/lib/gitlab/data_builder/pipeline.rb
+++ b/lib/gitlab/data_builder/pipeline.rb
@@ -22,7 +22,7 @@ module Gitlab
sha: pipeline.sha,
before_sha: pipeline.before_sha,
status: pipeline.status,
- stages: pipeline.stages_name,
+ stages: pipeline.stages_names,
created_at: pipeline.created_at,
finished_at: pipeline.finished_at,
duration: pipeline.duration
diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb
index 1ff34553f0a..5c5f507d44d 100644
--- a/lib/gitlab/data_builder/push.rb
+++ b/lib/gitlab/data_builder/push.rb
@@ -11,6 +11,7 @@ module Gitlab
# ref: String,
# user_id: String,
# user_name: String,
+ # user_username: String,
# user_email: String
# project_id: String,
# repository: {
@@ -23,11 +24,11 @@ module Gitlab
# total_commits_count: Fixnum
# }
#
- def build(project, user, oldrev, newrev, ref, commits = [], message = nil)
+ def build(project, user, oldrev, newrev, ref, commits = [], message = nil, commits_count: nil)
commits = Array(commits)
# Total commits count
- commits_count = commits.size
+ commits_count ||= commits.size
# Get latest 20 commits ASC
commits_limited = commits.last(20)
@@ -51,6 +52,7 @@ module Gitlab
message: message,
user_id: user.id,
user_name: user.name,
+ user_username: user.username,
user_email: user.email,
user_avatar: user.avatar_url,
project_id: project.id,
@@ -72,6 +74,8 @@ module Gitlab
build(project, user, commits.last&.id, commits.first&.id, ref, commits)
end
+ private
+
def checkout_sha(repository, newrev, ref)
# Checkout sha is nil when we remove branch or tag
return if Gitlab::Git.blank_ref?(newrev)
diff --git a/lib/gitlab/data_builder/repository.rb b/lib/gitlab/data_builder/repository.rb
new file mode 100644
index 00000000000..b42dc052949
--- /dev/null
+++ b/lib/gitlab/data_builder/repository.rb
@@ -0,0 +1,35 @@
+module Gitlab
+ module DataBuilder
+ module Repository
+ extend self
+
+ # Produce a hash of post-receive data
+ def update(project, user, changes, refs)
+ {
+ event_name: 'repository_update',
+
+ user_id: user.id,
+ user_name: user.name,
+ user_email: user.email,
+ user_avatar: user.avatar_url,
+
+ project_id: project.id,
+ project: project.hook_attrs,
+
+ changes: changes,
+
+ refs: refs
+ }
+ end
+
+ # Produce a hash of partial data for a single change
+ def single_change(oldrev, newrev, ref)
+ {
+ before: oldrev,
+ after: newrev,
+ ref: ref
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/data_builder/wiki_page.rb b/lib/gitlab/data_builder/wiki_page.rb
new file mode 100644
index 00000000000..226974b698c
--- /dev/null
+++ b/lib/gitlab/data_builder/wiki_page.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module DataBuilder
+ module WikiPage
+ extend self
+
+ def build(wiki_page, user, action)
+ wiki = wiki_page.wiki
+
+ {
+ object_kind: wiki_page.class.name.underscore,
+ user: user.hook_attrs,
+ project: wiki.project.hook_attrs,
+ wiki: wiki.hook_attrs,
+ object_attributes: wiki_page.hook_attrs.merge(
+ url: Gitlab::UrlBuilder.build(wiki_page),
+ action: action
+ )
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index d0bd1299671..e001d25e7b7 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -25,6 +25,10 @@ module Gitlab
database_version.match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
end
+ def self.join_lateral_supported?
+ postgresql? && version.to_f >= 9.3
+ end
+
def self.nulls_last_order(field, direction = 'ASC')
order = "#{field} #{direction}"
@@ -83,6 +87,22 @@ module Gitlab
end
end
+ def self.bulk_insert(table, rows)
+ return if rows.empty?
+
+ keys = rows.first.keys
+ columns = keys.map { |key| connection.quote_column_name(key) }
+
+ tuples = rows.map do |row|
+ row.values_at(*keys).map { |value| connection.quote(value) }
+ end
+
+ connection.execute <<-EOF
+ INSERT INTO #{table} (#{columns.join(', ')})
+ VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
+ EOF
+ end
+
# pool_size - The size of the DB pool.
# host - An optional host name to use instead of the default one.
def self.create_connection_pool(pool_size, host = nil)
diff --git a/lib/gitlab/database/median.rb b/lib/gitlab/database/median.rb
index 23890e5f493..059054ac9ff 100644
--- a/lib/gitlab/database/median.rb
+++ b/lib/gitlab/database/median.rb
@@ -29,10 +29,10 @@ module Gitlab
end
def mysql_median_datetime_sql(arel_table, query_so_far, column_sym)
- query = arel_table.
- from(arel_table.project(Arel.sql('*')).order(arel_table[column_sym]).as(arel_table.table_name)).
- project(average([arel_table[column_sym]], 'median')).
- where(
+ query = arel_table
+ .from(arel_table.project(Arel.sql('*')).order(arel_table[column_sym]).as(arel_table.table_name))
+ .project(average([arel_table[column_sym]], 'median'))
+ .where(
Arel::Nodes::Between.new(
Arel.sql("(select @row_id := @row_id + 1)"),
Arel::Nodes::And.new(
@@ -67,8 +67,8 @@ module Gitlab
cte_table = Arel::Table.new("ordered_records")
cte = Arel::Nodes::As.new(
cte_table,
- arel_table.
- project(
+ arel_table
+ .project(
arel_table[column_sym].as(column_sym.to_s),
Arel::Nodes::Over.new(Arel::Nodes::NamedFunction.new("row_number", []),
Arel::Nodes::Window.new.order(arel_table[column_sym])).as('row_id'),
@@ -79,8 +79,8 @@ module Gitlab
# From the CTE, select either the middle row or the middle two rows (this is accomplished
# by 'where cte.row_id between cte.ct / 2.0 AND cte.ct / 2.0 + 1'). Find the average of the
# selected rows, and this is the median value.
- cte_table.project(average([extract_epoch(cte_table[column_sym])], "median")).
- where(
+ cte_table.project(average([extract_epoch(cte_table[column_sym])], "median"))
+ .where(
Arel::Nodes::Between.new(
cte_table[:row_id],
Arel::Nodes::And.new(
@@ -88,9 +88,9 @@ module Gitlab
(cte_table[:ct] / Arel.sql('2.0') + 1)]
)
)
- ).
- with(query_so_far, cte).
- to_sql
+ )
+ .with(query_so_far, cte)
+ .to_sql
end
private
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 6dabbe0264c..69ca9aa596b 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -1,6 +1,39 @@
module Gitlab
module Database
module MigrationHelpers
+ # Adds `created_at` and `updated_at` columns with timezone information.
+ #
+ # This method is an improved version of Rails' built-in method `add_timestamps`.
+ #
+ # Available options are:
+ # default - The default value for the column.
+ # null - When set to `true` the column will allow NULL values.
+ # The default is to not allow NULL values.
+ def add_timestamps_with_timezone(table_name, options = {})
+ options[:null] = false if options[:null].nil?
+
+ [:created_at, :updated_at].each do |column_name|
+ if options[:default] && transaction_open?
+ raise '`add_timestamps_with_timezone` with default value cannot be run inside a transaction. ' \
+ 'You can disable transactions by calling `disable_ddl_transaction!` ' \
+ 'in the body of your migration class'
+ end
+
+ # If default value is presented, use `add_column_with_default` method instead.
+ if options[:default]
+ add_column_with_default(
+ table_name,
+ column_name,
+ :datetime_with_timezone,
+ default: options[:default],
+ allow_null: options[:null]
+ )
+ else
+ add_column(table_name, column_name, :datetime_with_timezone, options)
+ end
+ end
+ end
+
# Creates a new index, concurrently when supported
#
# On PostgreSQL this method creates an index concurrently, on MySQL this
@@ -42,7 +75,7 @@ module Gitlab
'in the body of your migration class'
end
- if Database.postgresql?
+ if supports_drop_index_concurrently?
options = options.merge({ algorithm: :concurrently })
disable_statement_timeout
end
@@ -50,6 +83,39 @@ module Gitlab
remove_index(table_name, options.merge({ column: column_name }))
end
+ # Removes an existing index, concurrently when supported
+ #
+ # On PostgreSQL this method removes an index concurrently.
+ #
+ # Example:
+ #
+ # remove_concurrent_index :users, "index_X_by_Y"
+ #
+ # See Rails' `remove_index` for more info on the available arguments.
+ def remove_concurrent_index_by_name(table_name, index_name, options = {})
+ if transaction_open?
+ raise 'remove_concurrent_index_by_name can not be run inside a transaction, ' \
+ 'you can disable transactions by calling disable_ddl_transaction! ' \
+ 'in the body of your migration class'
+ end
+
+ if supports_drop_index_concurrently?
+ options = options.merge({ algorithm: :concurrently })
+ disable_statement_timeout
+ end
+
+ remove_index(table_name, options.merge({ name: index_name }))
+ end
+
+ # Only available on Postgresql >= 9.2
+ def supports_drop_index_concurrently?
+ return false unless Database.postgresql?
+
+ version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
+
+ version >= 90200
+ end
+
# Adds a foreign key with only minimal locking on the tables involved.
#
# This method only requires minimal locking when using PostgreSQL. When
@@ -74,6 +140,8 @@ module Gitlab
return add_foreign_key(source, target,
column: column,
on_delete: on_delete)
+ else
+ on_delete = 'SET NULL' if on_delete == :nullify
end
disable_statement_timeout
@@ -89,7 +157,7 @@ module Gitlab
ADD CONSTRAINT #{key_name}
FOREIGN KEY (#{column})
REFERENCES #{target} (id)
- #{on_delete ? "ON DELETE #{on_delete}" : ''}
+ #{on_delete ? "ON DELETE #{on_delete.upcase}" : ''}
NOT VALID;
EOF
@@ -156,6 +224,12 @@ module Gitlab
#
# rubocop: disable Metrics/AbcSize
def update_column_in_batches(table, column, value)
+ if transaction_open?
+ raise 'update_column_in_batches can not be run inside a transaction, ' \
+ 'you can disable transactions by calling disable_ddl_transaction! ' \
+ 'in the body of your migration class'
+ end
+
table = Arel::Table.new(table)
count_arel = table.project(Arel.star.count.as('count'))
@@ -167,25 +241,31 @@ module Gitlab
# Update in batches of 5% until we run out of any rows to update.
batch_size = ((total / 100.0) * 5.0).ceil
+ max_size = 1000
+
+ # The upper limit is 1000 to ensure we don't lock too many rows. For
+ # example, for "merge_requests" even 1% of the table is around 35 000
+ # rows for GitLab.com.
+ batch_size = max_size if batch_size > max_size
start_arel = table.project(table[:id]).order(table[:id].asc).take(1)
start_arel = yield table, start_arel if block_given?
start_id = exec_query(start_arel.to_sql).to_hash.first['id'].to_i
loop do
- stop_arel = table.project(table[:id]).
- where(table[:id].gteq(start_id)).
- order(table[:id].asc).
- take(1).
- skip(batch_size)
+ stop_arel = table.project(table[:id])
+ .where(table[:id].gteq(start_id))
+ .order(table[:id].asc)
+ .take(1)
+ .skip(batch_size)
stop_arel = yield table, stop_arel if block_given?
stop_row = exec_query(stop_arel.to_sql).to_hash.first
- update_arel = Arel::UpdateManager.new(ActiveRecord::Base).
- table(table).
- set([[table[column], value]]).
- where(table[:id].gteq(start_id))
+ update_arel = Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(table)
+ .set([[table[column], value]])
+ .where(table[:id].gteq(start_id))
if stop_row
stop_id = stop_row['id'].to_i
@@ -278,6 +358,19 @@ module Gitlab
raise 'rename_column_concurrently can not be run inside a transaction'
end
+ old_col = column_for(table, old)
+ new_type = type || old_col.type
+
+ add_column(table, new, new_type,
+ limit: old_col.limit,
+ precision: old_col.precision,
+ scale: old_col.scale)
+
+ # We set the default value _after_ adding the column so we don't end up
+ # updating any existing data with the default value. This isn't
+ # necessary since we copy over old values further down.
+ change_column_default(table, new, old_col.default) if old_col.default
+
trigger_name = rename_trigger_name(table, old, new)
quoted_table = quote_table_name(table)
quoted_old = quote_column_name(old)
@@ -291,18 +384,10 @@ module Gitlab
quoted_old, quoted_new)
end
- old_col = column_for(table, old)
- new_type = type || old_col.type
-
- add_column(table, new, new_type,
- limit: old_col.limit,
- default: old_col.default,
- null: old_col.null,
- precision: old_col.precision,
- scale: old_col.scale)
-
update_column_in_batches(table, new, Arel::Table.new(table)[old])
+ change_column_null(table, new, false) unless old_col.null
+
copy_indexes(table, old, new)
copy_foreign_keys(table, old, new)
end
@@ -498,6 +583,29 @@ module Gitlab
columns(table).find { |column| column.name == name }
end
+
+ # This will replace the first occurance of a string in a column with
+ # the replacement
+ # On postgresql we can use `regexp_replace` for that.
+ # On mysql we find the location of the pattern, and overwrite it
+ # with the replacement
+ def replace_sql(column, pattern, replacement)
+ quoted_pattern = Arel::Nodes::Quoted.new(pattern.to_s)
+ quoted_replacement = Arel::Nodes::Quoted.new(replacement.to_s)
+
+ if Database.mysql?
+ locate = Arel::Nodes::NamedFunction
+ .new('locate', [quoted_pattern, column])
+ insert_in_place = Arel::Nodes::NamedFunction
+ .new('insert', [column, locate, pattern.size, quoted_replacement])
+
+ Arel::Nodes::SqlLiteral.new(insert_in_place.to_sql)
+ else
+ replace = Arel::Nodes::NamedFunction
+ .new("regexp_replace", [column, quoted_pattern, quoted_replacement])
+ Arel::Nodes::SqlLiteral.new(replace.to_sql)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb
new file mode 100644
index 00000000000..f333ff22300
--- /dev/null
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb
@@ -0,0 +1,40 @@
+# This module can be included in migrations to make it easier to rename paths
+# of `Namespace` & `Project` models certain paths would become `reserved`.
+#
+# If the way things are stored on the filesystem related to namespaces and
+# projects ever changes. Don't update this module, or anything nested in `V1`,
+# since it needs to keep functioning for all migrations using it using the state
+# that the data is in at the time. Instead, create a `V2` module that implements
+# the new way of reserving paths.
+module Gitlab
+ module Database
+ module RenameReservedPathsMigration
+ module V1
+ def self.included(kls)
+ kls.include(MigrationHelpers)
+ end
+
+ def rename_wildcard_paths(one_or_more_paths)
+ rename_child_paths(one_or_more_paths)
+ paths = Array(one_or_more_paths)
+ RenameProjects.new(paths, self).rename_projects
+ end
+
+ def rename_child_paths(one_or_more_paths)
+ paths = Array(one_or_more_paths)
+ RenameNamespaces.new(paths, self).rename_namespaces(type: :child)
+ end
+
+ def rename_root_paths(paths)
+ paths = Array(paths)
+ RenameNamespaces.new(paths, self).rename_namespaces(type: :top_level)
+ end
+
+ def revert_renames
+ RenameProjects.new([], self).revert_renames
+ RenameNamespaces.new([], self).revert_renames
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb
new file mode 100644
index 00000000000..5481024db8e
--- /dev/null
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb
@@ -0,0 +1,84 @@
+module Gitlab
+ module Database
+ module RenameReservedPathsMigration
+ module V1
+ module MigrationClasses
+ module Routable
+ def full_path
+ if route && route.path.present?
+ @full_path ||= route.path
+ else
+ update_route if persisted?
+
+ build_full_path
+ end
+ end
+
+ def build_full_path
+ if parent && path
+ parent.full_path + '/' + path
+ else
+ path
+ end
+ end
+
+ def update_route
+ prepare_route
+ route.save
+ end
+
+ def prepare_route
+ route || build_route(source: self)
+ route.path = build_full_path
+ @full_path = nil
+ end
+ end
+
+ class Namespace < ActiveRecord::Base
+ include MigrationClasses::Routable
+ self.table_name = 'namespaces'
+ belongs_to :parent,
+ class_name: "#{MigrationClasses.name}::Namespace"
+ has_one :route, as: :source
+ has_many :children,
+ class_name: "#{MigrationClasses.name}::Namespace",
+ foreign_key: :parent_id
+
+ # Overridden to have the correct `source_type` for the `route` relation
+ def self.name
+ 'Namespace'
+ end
+
+ def kind
+ type == 'Group' ? 'group' : 'user'
+ end
+ end
+
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+ end
+
+ class Route < ActiveRecord::Base
+ self.table_name = 'routes'
+ belongs_to :source, polymorphic: true
+ end
+
+ class Project < ActiveRecord::Base
+ include MigrationClasses::Routable
+ has_one :route, as: :source
+ self.table_name = 'projects'
+
+ def repository_storage_path
+ Gitlab.config.repositories.storages[repository_storage]['path']
+ end
+
+ # Overridden to have the correct `source_type` for the `route` relation
+ def self.name
+ 'Project'
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb
new file mode 100644
index 00000000000..1a697396ff1
--- /dev/null
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb
@@ -0,0 +1,198 @@
+module Gitlab
+ module Database
+ module RenameReservedPathsMigration
+ module V1
+ class RenameBase
+ attr_reader :paths, :migration
+
+ delegate :update_column_in_batches,
+ :execute,
+ :replace_sql,
+ :quote_string,
+ :say,
+ to: :migration
+
+ def initialize(paths, migration)
+ @paths = paths
+ @migration = migration
+ end
+
+ def path_patterns
+ @path_patterns ||= paths.flat_map { |path| ["%/#{path}", path] }
+ end
+
+ def rename_path_for_routable(routable)
+ old_path = routable.path
+ old_full_path = routable.full_path
+ # Only remove the last occurrence of the path name to get the parent namespace path
+ namespace_path = remove_last_occurrence(old_full_path, old_path)
+ new_path = rename_path(namespace_path, old_path)
+ new_full_path = join_routable_path(namespace_path, new_path)
+
+ perform_rename(routable, old_full_path, new_full_path)
+
+ [old_full_path, new_full_path]
+ end
+
+ def perform_rename(routable, old_full_path, new_full_path)
+ # skips callbacks & validations
+ new_path = new_full_path.split('/').last
+ routable.class.where(id: routable)
+ .update_all(path: new_path)
+
+ rename_routes(old_full_path, new_full_path)
+ end
+
+ def rename_routes(old_full_path, new_full_path)
+ routes = Route.arel_table
+
+ quoted_old_full_path = quote_string(old_full_path)
+ quoted_old_wildcard_path = quote_string("#{old_full_path}/%")
+
+ filter = if Database.mysql?
+ "lower(routes.path) = lower('#{quoted_old_full_path}') "\
+ "OR routes.path LIKE '#{quoted_old_wildcard_path}'"
+ else
+ "routes.id IN "\
+ "( SELECT routes.id FROM routes WHERE lower(routes.path) = lower('#{quoted_old_full_path}') "\
+ "UNION SELECT routes.id FROM routes WHERE routes.path ILIKE '#{quoted_old_wildcard_path}' )"
+ end
+
+ replace_statement = replace_sql(Route.arel_table[:path],
+ old_full_path,
+ new_full_path)
+
+ update = Arel::UpdateManager.new(ActiveRecord::Base)
+ .table(routes)
+ .set([[routes[:path], replace_statement]])
+ .where(Arel::Nodes::SqlLiteral.new(filter))
+
+ execute(update.to_sql)
+ end
+
+ def rename_path(namespace_path, path_was)
+ counter = 0
+ path = "#{path_was}#{counter}"
+
+ while route_exists?(join_routable_path(namespace_path, path))
+ counter += 1
+ path = "#{path_was}#{counter}"
+ end
+
+ path
+ end
+
+ def remove_last_occurrence(string, pattern)
+ string.reverse.sub(pattern.reverse, "").reverse
+ end
+
+ def join_routable_path(namespace_path, top_level)
+ if namespace_path.present?
+ File.join(namespace_path, top_level)
+ else
+ top_level
+ end
+ end
+
+ def route_exists?(full_path)
+ MigrationClasses::Route.where(Route.arel_table[:path].matches(full_path)).any?
+ end
+
+ def move_pages(old_path, new_path)
+ move_folders(pages_dir, old_path, new_path)
+ end
+
+ def move_uploads(old_path, new_path)
+ return unless file_storage?
+
+ move_folders(uploads_dir, old_path, new_path)
+ end
+
+ def move_folders(directory, old_relative_path, new_relative_path)
+ old_path = File.join(directory, old_relative_path)
+ unless File.directory?(old_path)
+ say "#{old_path} doesn't exist, skipping"
+ return
+ end
+
+ new_path = File.join(directory, new_relative_path)
+ FileUtils.mv(old_path, new_path)
+ end
+
+ def remove_cached_html_for_projects(project_ids)
+ project_ids.each do |project_id|
+ update_column_in_batches(:projects, :description_html, nil) do |table, query|
+ query.where(table[:id].eq(project_id))
+ end
+
+ update_column_in_batches(:issues, :description_html, nil) do |table, query|
+ query.where(table[:project_id].eq(project_id))
+ end
+
+ update_column_in_batches(:merge_requests, :description_html, nil) do |table, query|
+ query.where(table[:target_project_id].eq(project_id))
+ end
+
+ update_column_in_batches(:notes, :note_html, nil) do |table, query|
+ query.where(table[:project_id].eq(project_id))
+ end
+
+ update_column_in_batches(:milestones, :description_html, nil) do |table, query|
+ query.where(table[:project_id].eq(project_id))
+ end
+ end
+ end
+
+ def track_rename(type, old_path, new_path)
+ key = redis_key_for_type(type)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.lpush(key, [old_path, new_path].to_json)
+ redis.expire(key, 2.weeks.to_i)
+ end
+ say "tracked rename: #{key}: #{old_path} -> #{new_path}"
+ end
+
+ def reverts_for_type(type)
+ key = redis_key_for_type(type)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ failed_reverts = []
+
+ while rename_info = redis.lpop(key)
+ path_before_rename, path_after_rename = JSON.parse(rename_info)
+ say "renaming #{type} from #{path_after_rename} back to #{path_before_rename}"
+ begin
+ yield(path_before_rename, path_after_rename)
+ rescue StandardError => e
+ failed_reverts << rename_info
+ say "Renaming #{type} from #{path_after_rename} back to "\
+ "#{path_before_rename} failed. Review the error and try "\
+ "again by running the `down` action. \n"\
+ "#{e.message}: \n #{e.backtrace.join("\n")}"
+ end
+ end
+
+ failed_reverts.each { |rename_info| redis.lpush(key, rename_info) }
+ end
+ end
+
+ def redis_key_for_type(type)
+ "rename:#{migration.name}:#{type}"
+ end
+
+ def file_storage?
+ CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File
+ end
+
+ def uploads_dir
+ File.join(CarrierWave.root, "uploads")
+ end
+
+ def pages_dir
+ Settings.pages.path
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb
new file mode 100644
index 00000000000..05b86f32ce2
--- /dev/null
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb
@@ -0,0 +1,101 @@
+module Gitlab
+ module Database
+ module RenameReservedPathsMigration
+ module V1
+ class RenameNamespaces < RenameBase
+ include Gitlab::ShellAdapter
+
+ def rename_namespaces(type:)
+ namespaces_for_paths(type: type).each do |namespace|
+ rename_namespace(namespace)
+ end
+ end
+
+ def namespaces_for_paths(type:)
+ namespaces = case type
+ when :child
+ MigrationClasses::Namespace.where.not(parent_id: nil)
+ when :top_level
+ MigrationClasses::Namespace.where(parent_id: nil)
+ end
+ with_paths = MigrationClasses::Route.arel_table[:path]
+ .matches_any(path_patterns)
+ namespaces.joins(:route).where(with_paths)
+ end
+
+ def rename_namespace(namespace)
+ old_full_path, new_full_path = rename_path_for_routable(namespace)
+
+ track_rename('namespace', old_full_path, new_full_path)
+
+ rename_namespace_dependencies(namespace, old_full_path, new_full_path)
+ end
+
+ def rename_namespace_dependencies(namespace, old_full_path, new_full_path)
+ move_repositories(namespace, old_full_path, new_full_path)
+ move_uploads(old_full_path, new_full_path)
+ move_pages(old_full_path, new_full_path)
+ rename_user(old_full_path, new_full_path) if namespace.kind == 'user'
+ remove_cached_html_for_projects(projects_for_namespace(namespace).map(&:id))
+ end
+
+ def revert_renames
+ reverts_for_type('namespace') do |path_before_rename, current_path|
+ matches_path = MigrationClasses::Route.arel_table[:path].matches(current_path)
+ namespace = MigrationClasses::Namespace.joins(:route)
+ .where(matches_path).first&.becomes(MigrationClasses::Namespace)
+
+ if namespace
+ perform_rename(namespace, current_path, path_before_rename)
+
+ rename_namespace_dependencies(namespace, current_path, path_before_rename)
+ else
+ say "Couldn't rename namespace from #{current_path} back to #{path_before_rename}, "\
+ "namespace was renamed, or no longer exists at the expected path"
+ end
+ end
+ end
+
+ def rename_user(old_username, new_username)
+ MigrationClasses::User.where(username: old_username)
+ .update_all(username: new_username)
+ end
+
+ def move_repositories(namespace, old_full_path, new_full_path)
+ repo_paths_for_namespace(namespace).each do |repository_storage_path|
+ # Ensure old directory exists before moving it
+ gitlab_shell.add_namespace(repository_storage_path, old_full_path)
+
+ unless gitlab_shell.mv_namespace(repository_storage_path, old_full_path, new_full_path)
+ message = "Exception moving path #{repository_storage_path} \
+ from #{old_full_path} to #{new_full_path}"
+ Rails.logger.error message
+ end
+ end
+ end
+
+ def repo_paths_for_namespace(namespace)
+ projects_for_namespace(namespace).distinct.select(:repository_storage)
+ .map(&:repository_storage_path)
+ end
+
+ def projects_for_namespace(namespace)
+ namespace_ids = child_ids_for_parent(namespace, ids: [namespace.id])
+ namespace_or_children = MigrationClasses::Project
+ .arel_table[:namespace_id]
+ .in(namespace_ids)
+ MigrationClasses::Project.where(namespace_or_children)
+ end
+
+ def child_ids_for_parent(namespace, ids: [])
+ namespace.children.each do |child|
+ ids << child.id
+ child_ids_for_parent(child, ids: ids) if child.children.any?
+ end
+ ids
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb
new file mode 100644
index 00000000000..75a75f61953
--- /dev/null
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb
@@ -0,0 +1,70 @@
+module Gitlab
+ module Database
+ module RenameReservedPathsMigration
+ module V1
+ class RenameProjects < RenameBase
+ include Gitlab::ShellAdapter
+
+ def rename_projects
+ projects_for_paths.each do |project|
+ rename_project(project)
+ end
+
+ remove_cached_html_for_projects(projects_for_paths.map(&:id))
+ end
+
+ def rename_project(project)
+ old_full_path, new_full_path = rename_path_for_routable(project)
+
+ track_rename('project', old_full_path, new_full_path)
+
+ move_project_folders(project, old_full_path, new_full_path)
+ end
+
+ def move_project_folders(project, old_full_path, new_full_path)
+ move_repository(project, old_full_path, new_full_path)
+ move_repository(project, "#{old_full_path}.wiki", "#{new_full_path}.wiki")
+ move_uploads(old_full_path, new_full_path)
+ move_pages(old_full_path, new_full_path)
+ end
+
+ def revert_renames
+ reverts_for_type('project') do |path_before_rename, current_path|
+ matches_path = MigrationClasses::Route.arel_table[:path].matches(current_path)
+ project = MigrationClasses::Project.joins(:route)
+ .where(matches_path).first
+
+ if project
+ perform_rename(project, current_path, path_before_rename)
+
+ move_project_folders(project, current_path, path_before_rename)
+ else
+ say "Couldn't rename project from #{current_path} back to "\
+ "#{path_before_rename}, project was renamed or no longer "\
+ "exists at the expected path."
+
+ end
+ end
+ end
+
+ def move_repository(project, old_path, new_path)
+ unless gitlab_shell.mv_repository(project.repository_storage_path,
+ old_path,
+ new_path)
+ Rails.logger.error "Error moving #{old_path} to #{new_path}"
+ end
+ end
+
+ def projects_for_paths
+ return @projects_for_paths if @projects_for_paths
+
+ with_paths = MigrationClasses::Route.arel_table[:path]
+ .matches_any(path_patterns)
+
+ @projects_for_paths = MigrationClasses::Project.joins(:route).where(with_paths)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/sha_attribute.rb b/lib/gitlab/database/sha_attribute.rb
new file mode 100644
index 00000000000..d9400e04b83
--- /dev/null
+++ b/lib/gitlab/database/sha_attribute.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Database
+ BINARY_TYPE = if Gitlab::Database.postgresql?
+ # PostgreSQL defines its own class with slightly different
+ # behaviour from the default Binary type.
+ ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Bytea
+ else
+ ActiveRecord::Type::Binary
+ end
+
+ # Class for casting binary data to hexadecimal SHA1 hashes (and vice-versa).
+ #
+ # Using ShaAttribute allows you to store SHA1 values as binary while still
+ # using them as if they were stored as string values. This gives you the
+ # ease of use of string values, but without the storage overhead.
+ class ShaAttribute < BINARY_TYPE
+ PACK_FORMAT = 'H*'.freeze
+
+ # Casts binary data to a SHA1 in hexadecimal.
+ def type_cast_from_database(value)
+ value = super
+
+ value ? value.unpack(PACK_FORMAT)[0] : nil
+ end
+
+ # Casts a SHA1 in hexadecimal to the proper binary format.
+ def type_cast_for_database(value)
+ arg = value ? [value].pack(PACK_FORMAT) : nil
+
+ super(arg)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker.rb b/lib/gitlab/dependency_linker.rb
new file mode 100644
index 00000000000..3192bf6f667
--- /dev/null
+++ b/lib/gitlab/dependency_linker.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module DependencyLinker
+ LINKERS = [
+ GemfileLinker,
+ GemspecLinker,
+ PackageJsonLinker,
+ ComposerJsonLinker,
+ PodfileLinker,
+ PodspecLinker,
+ PodspecJsonLinker,
+ CartfileLinker,
+ GodepsJsonLinker,
+ RequirementsTxtLinker
+ ].freeze
+
+ def self.linker(blob_name)
+ LINKERS.find { |linker| linker.support?(blob_name) }
+ end
+
+ def self.link(blob_name, plain_text, highlighted_text)
+ linker = linker(blob_name)
+ return highlighted_text unless linker
+
+ linker.link(plain_text, highlighted_text)
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb
new file mode 100644
index 00000000000..d2360583741
--- /dev/null
+++ b/lib/gitlab/dependency_linker/base_linker.rb
@@ -0,0 +1,86 @@
+module Gitlab
+ module DependencyLinker
+ class BaseLinker
+ URL_REGEX = %r{https?://[^'" ]+}.freeze
+ REPO_REGEX = %r{[^/'" ]+/[^/'" ]+}.freeze
+
+ class_attribute :file_type
+
+ def self.support?(blob_name)
+ Gitlab::FileDetector.type_of(blob_name) == file_type
+ end
+
+ def self.link(*args)
+ new(*args).link
+ end
+
+ attr_accessor :plain_text, :highlighted_text
+
+ def initialize(plain_text, highlighted_text)
+ @plain_text = plain_text
+ @highlighted_text = highlighted_text
+ end
+
+ def link
+ link_dependencies
+
+ highlighted_lines.join.html_safe
+ end
+
+ private
+
+ def link_dependencies
+ raise NotImplementedError
+ end
+
+ def license_url(name)
+ Licensee::License.find(name)&.url
+ end
+
+ def github_url(name)
+ "https://github.com/#{name}"
+ end
+
+ def link_tag(name, url)
+ %{<a href="#{ERB::Util.html_escape_once(url)}" rel="nofollow noreferrer noopener" target="_blank">#{ERB::Util.html_escape_once(name)}</a>}
+ end
+
+ # Links package names based on regex.
+ #
+ # Example:
+ # link_regex(/(github:|:github =>)\s*['"](?<name>[^'"]+)['"]/)
+ # # Will link `user/repo` in `github: "user/repo"` or `:github => "user/repo"`
+ def link_regex(regex, &url_proc)
+ highlighted_lines.map!.with_index do |rich_line, i|
+ marker = StringRegexMarker.new(plain_lines[i].chomp, rich_line.html_safe)
+
+ marker.mark(regex, group: :name) do |text, left:, right:|
+ url = yield(text)
+ url ? link_tag(text, url) : text
+ end
+ end
+ end
+
+ def plain_lines
+ @plain_lines ||= plain_text.lines
+ end
+
+ def highlighted_lines
+ @highlighted_lines ||= highlighted_text.lines
+ end
+
+ def regexp_for_value(value, default: /[^'" ]+/)
+ case value
+ when Array
+ Regexp.union(value.map { |v| regexp_for_value(v, default: default) })
+ when String
+ Regexp.escape(value)
+ when Regexp
+ value
+ else
+ default
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/cartfile_linker.rb b/lib/gitlab/dependency_linker/cartfile_linker.rb
new file mode 100644
index 00000000000..4f69f2c4ab2
--- /dev/null
+++ b/lib/gitlab/dependency_linker/cartfile_linker.rb
@@ -0,0 +1,14 @@
+module Gitlab
+ module DependencyLinker
+ class CartfileLinker < MethodLinker
+ self.file_type = :cartfile
+
+ private
+
+ def link_dependencies
+ link_method_call('github', REPO_REGEX, &method(:github_url))
+ link_method_call(%w[github git binary], URL_REGEX, &:itself)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/cocoapods.rb b/lib/gitlab/dependency_linker/cocoapods.rb
new file mode 100644
index 00000000000..2fbde7da1b4
--- /dev/null
+++ b/lib/gitlab/dependency_linker/cocoapods.rb
@@ -0,0 +1,10 @@
+module Gitlab
+ module DependencyLinker
+ module Cocoapods
+ def package_url(name)
+ package = name.split("/", 2).first
+ "https://cocoapods.org/pods/#{package}"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/composer_json_linker.rb b/lib/gitlab/dependency_linker/composer_json_linker.rb
new file mode 100644
index 00000000000..0245bf4077a
--- /dev/null
+++ b/lib/gitlab/dependency_linker/composer_json_linker.rb
@@ -0,0 +1,18 @@
+module Gitlab
+ module DependencyLinker
+ class ComposerJsonLinker < PackageJsonLinker
+ self.file_type = :composer_json
+
+ private
+
+ def link_packages
+ link_packages_at_key("require", &method(:package_url))
+ link_packages_at_key("require-dev", &method(:package_url))
+ end
+
+ def package_url(name)
+ "https://packagist.org/packages/#{name}" if name =~ %r{\A#{REPO_REGEX}\z}
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/gemfile_linker.rb b/lib/gitlab/dependency_linker/gemfile_linker.rb
new file mode 100644
index 00000000000..d034ea67387
--- /dev/null
+++ b/lib/gitlab/dependency_linker/gemfile_linker.rb
@@ -0,0 +1,32 @@
+module Gitlab
+ module DependencyLinker
+ class GemfileLinker < MethodLinker
+ self.file_type = :gemfile
+
+ private
+
+ def link_dependencies
+ link_urls
+ link_packages
+ end
+
+ def link_urls
+ # Link `github: "user/repo"` to https://github.com/user/repo
+ link_regex(/(github:|:github\s*=>)\s*['"](?<name>[^'"]+)['"]/, &method(:github_url))
+
+ # Link `git: "https://gitlab.example.com/user/repo"` to https://gitlab.example.com/user/repo
+ link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself)
+
+ # Link `source "https://rubygems.org"` to https://rubygems.org
+ link_method_call('source', URL_REGEX, &:itself)
+ end
+
+ def link_packages
+ # Link `gem "package_name"` to https://rubygems.org/gems/package_name
+ link_method_call('gem') do |name|
+ "https://rubygems.org/gems/#{name}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/gemspec_linker.rb b/lib/gitlab/dependency_linker/gemspec_linker.rb
new file mode 100644
index 00000000000..f1783ee2ab4
--- /dev/null
+++ b/lib/gitlab/dependency_linker/gemspec_linker.rb
@@ -0,0 +1,18 @@
+module Gitlab
+ module DependencyLinker
+ class GemspecLinker < MethodLinker
+ self.file_type = :gemspec
+
+ private
+
+ def link_dependencies
+ link_method_call('homepage', URL_REGEX, &:itself)
+ link_method_call('license', &method(:license_url))
+
+ link_method_call(%w[name add_dependency add_runtime_dependency add_development_dependency]) do |name|
+ "https://rubygems.org/gems/#{name}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/godeps_json_linker.rb b/lib/gitlab/dependency_linker/godeps_json_linker.rb
new file mode 100644
index 00000000000..fe091baee6d
--- /dev/null
+++ b/lib/gitlab/dependency_linker/godeps_json_linker.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module DependencyLinker
+ class GodepsJsonLinker < JsonLinker
+ NESTED_REPO_REGEX = %r{([^/]+/)+[^/]+?}.freeze
+
+ self.file_type = :godeps_json
+
+ private
+
+ def link_dependencies
+ link_json('ImportPath') do |path|
+ case path
+ when %r{\A(?<repo>gitlab\.com/#{NESTED_REPO_REGEX})\.git/(?<path>.+)\z},
+ %r{\A(?<repo>git(lab|hub)\.com/#{REPO_REGEX})/(?<path>.+)\z}
+
+ "https://#{$~[:repo]}/tree/master/#{$~[:path]}"
+ when /\Agolang\.org/
+ "https://godoc.org/#{path}"
+ else
+ "https://#{path}"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/json_linker.rb b/lib/gitlab/dependency_linker/json_linker.rb
new file mode 100644
index 00000000000..a8ef25233d8
--- /dev/null
+++ b/lib/gitlab/dependency_linker/json_linker.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module DependencyLinker
+ class JsonLinker < BaseLinker
+ def link
+ return highlighted_text unless json
+
+ super
+ end
+
+ private
+
+ # Links package names in a JSON key or values.
+ #
+ # Example:
+ # link_json('name')
+ # # Will link `package` in `"name": "package"`
+ #
+ # link_json('name', 'specific_package')
+ # # Will link `specific_package` in `"name": "specific_package"`
+ #
+ # link_json('name', /[^\/]+\/[^\/]+/)
+ # # Will link `user/repo` in `"name": "user/repo"`, but not `"name": "package"`
+ #
+ # link_json('specific_package', '1.0.1', link: :key)
+ # # Will link `specific_package` in `"specific_package": "1.0.1"`
+ def link_json(key, value = nil, link: :value, &url_proc)
+ key = regexp_for_value(key, default: /[^" ]+/)
+ value = regexp_for_value(value, default: /[^" ]+/)
+
+ if link == :value
+ value = /(?<name>#{value})/
+ else
+ key = /(?<name>#{key})/
+ end
+
+ link_regex(/"#{key}":\s*"#{value}"/, &url_proc)
+ end
+
+ def json
+ @json ||= JSON.parse(plain_text) rescue nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/method_linker.rb b/lib/gitlab/dependency_linker/method_linker.rb
new file mode 100644
index 00000000000..0ffa2a83c93
--- /dev/null
+++ b/lib/gitlab/dependency_linker/method_linker.rb
@@ -0,0 +1,39 @@
+module Gitlab
+ module DependencyLinker
+ class MethodLinker < BaseLinker
+ private
+
+ # Links package names in a method call or assignment string argument.
+ #
+ # Example:
+ # link_method_call('gem')
+ # # Will link `package` in `gem "package"`, `gem("package")` and `gem = "package"`
+ #
+ # link_method_call('gem', 'specific_package')
+ # # Will link `specific_package` in `gem "specific_package"`
+ #
+ # link_method_call('github', /[^\/"]+\/[^\/"]+/)
+ # # Will link `user/repo` in `github "user/repo"`, but not `github "package"`
+ #
+ # link_method_call(%w[add_dependency add_development_dependency])
+ # # Will link `spec.add_dependency "package"` and `spec.add_development_dependency "package"`
+ #
+ # link_method_call('name')
+ # # Will link `package` in `self.name = "package"`
+ def link_method_call(method_name, value = nil, &url_proc)
+ method_name = regexp_for_value(method_name)
+ value = regexp_for_value(value)
+
+ regex = %r{
+ #{method_name} # Method name
+ \s* # Whitespace
+ [(=]? # Opening brace or equals sign
+ \s* # Whitespace
+ ['"](?<name>#{value})['"] # Package name in quotes
+ }x
+
+ link_regex(regex, &url_proc)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/package_json_linker.rb b/lib/gitlab/dependency_linker/package_json_linker.rb
new file mode 100644
index 00000000000..330c95f0880
--- /dev/null
+++ b/lib/gitlab/dependency_linker/package_json_linker.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module DependencyLinker
+ class PackageJsonLinker < JsonLinker
+ self.file_type = :package_json
+
+ private
+
+ def link_dependencies
+ link_json('name', json["name"], &method(:package_url))
+ link_json('license', &method(:license_url))
+ link_json(%w[homepage url], URL_REGEX, &:itself)
+
+ link_packages
+ end
+
+ def link_packages
+ link_packages_at_key("dependencies", &method(:package_url))
+ link_packages_at_key("devDependencies", &method(:package_url))
+ end
+
+ def link_packages_at_key(key, &url_proc)
+ dependencies = json[key]
+ return unless dependencies
+
+ dependencies.each do |name, version|
+ link_json(name, version, link: :key, &url_proc)
+
+ link_json(name) do |value|
+ case value
+ when /\A#{URL_REGEX}\z/
+ value
+ when /\A#{REPO_REGEX}\z/
+ github_url(value)
+ end
+ end
+ end
+ end
+
+ def package_url(name)
+ "https://npmjs.com/package/#{name}"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/podfile_linker.rb b/lib/gitlab/dependency_linker/podfile_linker.rb
new file mode 100644
index 00000000000..60ad166ea17
--- /dev/null
+++ b/lib/gitlab/dependency_linker/podfile_linker.rb
@@ -0,0 +1,15 @@
+module Gitlab
+ module DependencyLinker
+ class PodfileLinker < GemfileLinker
+ include Cocoapods
+
+ self.file_type = :podfile
+
+ private
+
+ def link_packages
+ link_method_call('pod', &method(:package_url))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/podspec_json_linker.rb b/lib/gitlab/dependency_linker/podspec_json_linker.rb
new file mode 100644
index 00000000000..d82237ed3f1
--- /dev/null
+++ b/lib/gitlab/dependency_linker/podspec_json_linker.rb
@@ -0,0 +1,32 @@
+module Gitlab
+ module DependencyLinker
+ class PodspecJsonLinker < JsonLinker
+ include Cocoapods
+
+ self.file_type = :podspec_json
+
+ private
+
+ def link_dependencies
+ link_json('name', json["name"], &method(:package_url))
+ link_json('license', &method(:license_url))
+ link_json(%w[homepage git], URL_REGEX, &:itself)
+
+ link_packages_at_key("dependencies", &method(:package_url))
+
+ json["subspecs"]&.each do |subspec|
+ link_packages_at_key("dependencies", subspec, &method(:package_url))
+ end
+ end
+
+ def link_packages_at_key(key, root = json, &url_proc)
+ dependencies = root[key]
+ return unless dependencies
+
+ dependencies.each do |name, _|
+ link_regex(/"(?<name>#{Regexp.escape(name)})":\s*\[/, &url_proc)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/podspec_linker.rb b/lib/gitlab/dependency_linker/podspec_linker.rb
new file mode 100644
index 00000000000..a52c7a02439
--- /dev/null
+++ b/lib/gitlab/dependency_linker/podspec_linker.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module DependencyLinker
+ class PodspecLinker < MethodLinker
+ include Cocoapods
+
+ STRING_REGEX = /['"](?<name>[^'"]+)['"]/.freeze
+
+ self.file_type = :podspec
+
+ private
+
+ def link_dependencies
+ link_method_call('homepage', URL_REGEX, &:itself)
+
+ link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself)
+
+ link_method_call('license', &method(:license_url))
+ link_regex(/license\s*=\s*\{\s*(type:|:type\s*=>)\s*#{STRING_REGEX}/, &method(:license_url))
+
+ link_method_call(%w[name dependency], &method(:package_url))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/dependency_linker/requirements_txt_linker.rb b/lib/gitlab/dependency_linker/requirements_txt_linker.rb
new file mode 100644
index 00000000000..9c9620bc36a
--- /dev/null
+++ b/lib/gitlab/dependency_linker/requirements_txt_linker.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module DependencyLinker
+ class RequirementsTxtLinker < BaseLinker
+ self.file_type = :requirements_txt
+
+ private
+
+ def link_dependencies
+ link_regex(/^(?<name>(?![a-z+]+:)[^#.-][^ ><=~!;\[]+)/) do |name|
+ "https://pypi.python.org/pypi/#{name}"
+ end
+
+ link_regex(%r{^(?<name>https?://[^ ]+)}, &:itself)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/devise_failure.rb b/lib/gitlab/devise_failure.rb
deleted file mode 100644
index a78fde9d782..00000000000
--- a/lib/gitlab/devise_failure.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module Gitlab
- class DeviseFailure < Devise::FailureApp
- protected
-
- # Override `Devise::FailureApp#request_format` to handle a special case
- #
- # This tells Devise to handle an unauthenticated `.zip` request as an HTML
- # request (i.e., redirect to sign in).
- #
- # Otherwise, Devise would respond with a 401 Unauthorized with
- # `Content-Type: application/zip` and a response body in plaintext, and the
- # browser would freak out.
- #
- # See https://gitlab.com/gitlab-org/gitlab-ce/issues/12944
- def request_format
- if request.format == :zip
- Mime::Type.lookup_by_extension(:html).ref
- else
- super
- end
- end
- end
-end
diff --git a/lib/gitlab/diff/diff_refs.rb b/lib/gitlab/diff/diff_refs.rb
index 7948782aecc..371cbe04b9b 100644
--- a/lib/gitlab/diff/diff_refs.rb
+++ b/lib/gitlab/diff/diff_refs.rb
@@ -37,6 +37,16 @@ module Gitlab
def complete?
start_sha && head_sha
end
+
+ def compare_in(project)
+ # We're at the initial commit, so just get that as we can't compare to anything.
+ if Gitlab::Git.blank_ref?(start_sha)
+ project.commit(head_sha)
+ else
+ straight = start_sha == base_sha
+ CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index c6bf25b5874..6d7de52cb80 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -1,16 +1,30 @@
module Gitlab
module Diff
class File
- attr_reader :diff, :repository, :diff_refs
-
- delegate :new_file, :deleted_file, :renamed_file,
- :old_path, :new_path, :a_mode, :b_mode,
- :submodule?, :too_large?, :collapsed?, to: :diff, prefix: false
-
- def initialize(diff, repository:, diff_refs: nil)
+ attr_reader :diff, :repository, :diff_refs, :fallback_diff_refs
+
+ delegate :new_file?, :deleted_file?, :renamed_file?,
+ :old_path, :new_path, :a_mode, :b_mode, :mode_changed?,
+ :submodule?, :expanded?, :too_large?, :collapsed?, :line_count, to: :diff, prefix: false
+
+ # Finding a viewer for a diff file happens based only on extension and whether the
+ # diff file blobs are binary or text, which means 1 diff file should only be matched by 1 viewer,
+ # and the order of these viewers doesn't really matter.
+ #
+ # However, when the diff file blobs are LFS pointers, we cannot know for sure whether the
+ # file being pointed to is binary or text. In this case, we match only on
+ # extension, preferring binary viewers over text ones if both exist, since the
+ # large files referred to in "Large File Storage" are much more likely to be
+ # binary than text.
+ RICH_VIEWERS = [
+ DiffViewer::Image
+ ].sort_by { |v| v.binary? ? 0 : 1 }.freeze
+
+ def initialize(diff, repository:, diff_refs: nil, fallback_diff_refs: nil)
@diff = diff
@repository = repository
@diff_refs = diff_refs
+ @fallback_diff_refs = fallback_diff_refs
end
def position(line)
@@ -49,24 +63,54 @@ module Gitlab
line_code(line) if line
end
- def content_commit
- return unless diff_refs
+ def old_sha
+ diff_refs&.base_sha
+ end
- repository.commit(deleted_file ? old_ref : new_ref)
+ def new_sha
+ diff_refs&.head_sha
end
- def old_content_commit
- return unless diff_refs
+ def new_content_sha
+ return if deleted_file?
+ return @new_content_sha if defined?(@new_content_sha)
+
+ refs = diff_refs || fallback_diff_refs
+ @new_content_sha = refs&.head_sha
+ end
- repository.commit(old_ref)
+ def old_content_sha
+ return if new_file?
+ return @old_content_sha if defined?(@old_content_sha)
+
+ refs = diff_refs || fallback_diff_refs
+ @old_content_sha = refs&.base_sha
end
- def old_ref
- diff_refs.try(:base_sha)
+ def new_blob
+ return @new_blob if defined?(@new_blob)
+
+ sha = new_content_sha
+ return @new_blob = nil unless sha
+
+ @new_blob = repository.blob_at(sha, file_path)
+ end
+
+ def old_blob
+ return @old_blob if defined?(@old_blob)
+
+ sha = old_content_sha
+ return @old_blob = nil unless sha
+
+ @old_blob = repository.blob_at(sha, old_path)
end
- def new_ref
- diff_refs.try(:head_sha)
+ def content_sha
+ new_content_sha || old_content_sha
+ end
+
+ def blob
+ new_blob || old_blob
end
attr_writer :highlighted_diff_lines
@@ -85,10 +129,6 @@ module Gitlab
@parallel_diff_lines ||= Gitlab::Diff::ParallelDiff.new(self).parallelize
end
- def mode_changed?
- a_mode && b_mode && a_mode != b_mode
- end
-
def raw_diff
diff.diff.to_s
end
@@ -117,20 +157,114 @@ module Gitlab
diff_lines.count(&:removed?)
end
- def old_blob(commit = old_content_commit)
- return unless commit
+ def file_identifier
+ "#{file_path}-#{new_file?}-#{deleted_file?}-#{renamed_file?}"
+ end
- repository.blob_at(commit.id, old_path)
+ def diffable?
+ repository.attributes(file_path).fetch('diff') { true }
end
- def blob(commit = content_commit)
- return unless commit
+ def binary?
+ old_blob&.binary? || new_blob&.binary?
+ end
- repository.blob_at(commit.id, file_path)
+ def text?
+ !binary?
end
- def file_identifier
- "#{file_path}-#{new_file}-#{deleted_file}-#{renamed_file}"
+ def external_storage_error?
+ old_blob&.external_storage_error? || new_blob&.external_storage_error?
+ end
+
+ def stored_externally?
+ old_blob&.stored_externally? || new_blob&.stored_externally?
+ end
+
+ def external_storage
+ old_blob&.external_storage || new_blob&.external_storage
+ end
+
+ def content_changed?
+ old_blob && new_blob && old_blob.id != new_blob.id
+ end
+
+ def different_type?
+ old_blob && new_blob && old_blob.binary? != new_blob.binary?
+ end
+
+ def size
+ [old_blob&.size, new_blob&.size].compact.sum
+ end
+
+ def raw_size
+ [old_blob&.raw_size, new_blob&.raw_size].compact.sum
+ end
+
+ def raw_binary?
+ old_blob&.raw_binary? || new_blob&.raw_binary?
+ end
+
+ def raw_text?
+ !raw_binary? && !different_type?
+ end
+
+ def simple_viewer
+ @simple_viewer ||= simple_viewer_class.new(self)
+ end
+
+ def rich_viewer
+ return @rich_viewer if defined?(@rich_viewer)
+
+ @rich_viewer = rich_viewer_class&.new(self)
+ end
+
+ def rendered_as_text?(ignore_errors: true)
+ simple_viewer.is_a?(DiffViewer::Text) && (ignore_errors || simple_viewer.render_error.nil?)
+ end
+
+ private
+
+ def simple_viewer_class
+ return DiffViewer::NotDiffable unless diffable?
+
+ if content_changed?
+ if raw_text?
+ DiffViewer::Text
+ else
+ DiffViewer::NoPreview
+ end
+ elsif new_file?
+ if raw_text?
+ DiffViewer::Text
+ else
+ DiffViewer::Added
+ end
+ elsif deleted_file?
+ if raw_text?
+ DiffViewer::Text
+ else
+ DiffViewer::Deleted
+ end
+ elsif renamed_file?
+ DiffViewer::Renamed
+ elsif mode_changed?
+ DiffViewer::ModeChanged
+ end
+ end
+
+ def rich_viewer_class
+ viewer_class_from(RICH_VIEWERS)
+ end
+
+ def viewer_class_from(classes)
+ return unless diffable?
+ return if different_type? || external_storage_error?
+ return unless new_file? || deleted_file? || content_changed?
+
+ verify_binary = !stored_externally?
+
+ classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) }
end
end
end
diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb
index 2b9fc65b985..a6007ebf531 100644
--- a/lib/gitlab/diff/file_collection/base.rb
+++ b/lib/gitlab/diff/file_collection/base.rb
@@ -2,32 +2,41 @@ module Gitlab
module Diff
module FileCollection
class Base
- attr_reader :project, :diff_options, :diff_view, :diff_refs
+ attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs
delegate :count, :size, :real_size, to: :diff_files
def self.default_options
- ::Commit.max_diff_options.merge(ignore_whitespace_change: false, no_collapse: false)
+ ::Commit.max_diff_options.merge(ignore_whitespace_change: false, expanded: false)
end
- def initialize(diffable, project:, diff_options: nil, diff_refs: nil)
+ def initialize(diffable, project:, diff_options: nil, diff_refs: nil, fallback_diff_refs: nil)
diff_options = self.class.default_options.merge(diff_options || {})
- @diffable = diffable
- @diffs = diffable.raw_diffs(diff_options)
- @project = project
+ @diffable = diffable
+ @diffs = diffable.raw_diffs(diff_options)
+ @project = project
@diff_options = diff_options
- @diff_refs = diff_refs
+ @diff_refs = diff_refs
+ @fallback_diff_refs = fallback_diff_refs
end
def diff_files
@diff_files ||= @diffs.decorate! { |diff| decorate_diff!(diff) }
end
+ def diff_file_with_old_path(old_path)
+ diff_files.find { |diff_file| diff_file.old_path == old_path }
+ end
+
+ def diff_file_with_new_path(new_path)
+ diff_files.find { |diff_file| diff_file.new_path == new_path }
+ end
+
private
def decorate_diff!(diff)
- Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs)
+ Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs, fallback_diff_refs: fallback_diff_refs)
end
end
end
diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb
index 0bd226ef050..fcda1fe2233 100644
--- a/lib/gitlab/diff/file_collection/merge_request_diff.rb
+++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb
@@ -8,7 +8,8 @@ module Gitlab
super(merge_request_diff,
project: merge_request_diff.project,
diff_options: diff_options,
- diff_refs: merge_request_diff.diff_refs)
+ diff_refs: merge_request_diff.diff_refs,
+ fallback_diff_refs: merge_request_diff.fallback_diff_refs)
end
def diff_files
@@ -65,10 +66,7 @@ module Gitlab
end
def cacheable?(diff_file)
- @merge_request_diff.present? &&
- diff_file.blob &&
- diff_file.blob.text? &&
- @project.repository.diffable?(diff_file.blob)
+ @merge_request_diff.present? && diff_file.text? && diff_file.diffable?
end
def cache_key
diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb
index 7db896522a9..b669ee5b799 100644
--- a/lib/gitlab/diff/highlight.rb
+++ b/lib/gitlab/diff/highlight.rb
@@ -3,7 +3,7 @@ module Gitlab
class Highlight
attr_reader :diff_file, :diff_lines, :raw_lines, :repository
- delegate :old_path, :new_path, :old_ref, :new_ref, to: :diff_file, prefix: :diff
+ delegate :old_path, :new_path, :old_sha, :new_sha, to: :diff_file, prefix: :diff
def initialize(diff_lines, repository: nil)
@repository = repository
@@ -42,9 +42,9 @@ module Gitlab
rich_line =
if diff_line.unchanged? || diff_line.added?
- new_lines[diff_line.new_pos - 1]
+ new_lines[diff_line.new_pos - 1]&.html_safe
elsif diff_line.removed?
- old_lines[diff_line.old_pos - 1]
+ old_lines[diff_line.old_pos - 1]&.html_safe
end
# Only update text if line is found. This will prevent
@@ -60,13 +60,18 @@ module Gitlab
end
def old_lines
- return unless diff_file
- @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_ref, diff_old_path)
+ @old_lines ||= highlighted_blob_lines(diff_file.old_blob)
end
def new_lines
- return unless diff_file
- @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_ref, diff_new_path)
+ @new_lines ||= highlighted_blob_lines(diff_file.new_blob)
+ end
+
+ def highlighted_blob_lines(blob)
+ return [] unless blob
+
+ blob.load_all_data!
+ Gitlab::Highlight.highlight(blob.path, blob.data, repository: repository).lines
end
end
end
diff --git a/lib/gitlab/diff/inline_diff_markdown_marker.rb b/lib/gitlab/diff/inline_diff_markdown_marker.rb
new file mode 100644
index 00000000000..c2a2eb15931
--- /dev/null
+++ b/lib/gitlab/diff/inline_diff_markdown_marker.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module Diff
+ class InlineDiffMarkdownMarker < Gitlab::StringRangeMarker
+ MARKDOWN_SYMBOLS = {
+ addition: "+",
+ deletion: "-"
+ }.freeze
+
+ def mark(line_inline_diffs, mode: nil)
+ super(line_inline_diffs) do |text, left:, right:|
+ symbol = MARKDOWN_SYMBOLS[mode]
+ "{#{symbol}#{text}#{symbol}}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/diff/inline_diff_marker.rb b/lib/gitlab/diff/inline_diff_marker.rb
index 736933b1c4b..919965100ae 100644
--- a/lib/gitlab/diff/inline_diff_marker.rb
+++ b/lib/gitlab/diff/inline_diff_marker.rb
@@ -1,137 +1,21 @@
module Gitlab
module Diff
- class InlineDiffMarker
- MARKDOWN_SYMBOLS = {
- addition: "+",
- deletion: "-"
- }.freeze
-
- attr_accessor :raw_line, :rich_line
-
- def initialize(raw_line, rich_line = raw_line)
- @raw_line = raw_line
- @rich_line = ERB::Util.html_escape(rich_line)
- end
-
- def mark(line_inline_diffs, mode: nil, markdown: false)
- return rich_line unless line_inline_diffs
-
- marker_ranges = []
- line_inline_diffs.each do |inline_diff_range|
- # Map the inline-diff range based on the raw line to character positions in the rich line
- inline_diff_positions = position_mapping[inline_diff_range].flatten
- # Turn the array of character positions into ranges
- marker_ranges.concat(collapse_ranges(inline_diff_positions))
- end
-
- offset = 0
-
- # Mark each range
- marker_ranges.each_with_index do |range, index|
- before_content =
- if markdown
- "{#{MARKDOWN_SYMBOLS[mode]}"
- else
- "<span class='#{html_class_names(marker_ranges, mode, index)}'>"
- end
- after_content =
- if markdown
- "#{MARKDOWN_SYMBOLS[mode]}}"
- else
- "</span>"
- end
- offset = insert_around_range(rich_line, range, before_content, after_content, offset)
+ class InlineDiffMarker < Gitlab::StringRangeMarker
+ def mark(line_inline_diffs, mode: nil)
+ super(line_inline_diffs) do |text, left:, right:|
+ %{<span class="#{html_class_names(left, right, mode)}">#{text}</span>}
end
-
- rich_line.html_safe
end
private
- def html_class_names(marker_ranges, mode, index)
+ def html_class_names(left, right, mode)
class_names = ["idiff"]
- class_names << "left" if index == 0
- class_names << "right" if index == marker_ranges.length - 1
+ class_names << "left" if left
+ class_names << "right" if right
class_names << mode if mode
class_names.join(" ")
end
-
- # Mapping of character positions in the raw line, to the rich (highlighted) line
- def position_mapping
- @position_mapping ||= begin
- mapping = []
- rich_pos = 0
- (0..raw_line.length).each do |raw_pos|
- rich_char = rich_line[rich_pos]
-
- # The raw and rich lines are the same except for HTML tags,
- # so skip over any `<...>` segment
- while rich_char == '<'
- until rich_char == '>'
- rich_pos += 1
- rich_char = rich_line[rich_pos]
- end
-
- rich_pos += 1
- rich_char = rich_line[rich_pos]
- end
-
- # multi-char HTML entities in the rich line correspond to a single character in the raw line
- if rich_char == '&'
- multichar_mapping = [rich_pos]
- until rich_char == ';'
- rich_pos += 1
- multichar_mapping << rich_pos
- rich_char = rich_line[rich_pos]
- end
-
- mapping[raw_pos] = multichar_mapping
- else
- mapping[raw_pos] = rich_pos
- end
-
- rich_pos += 1
- end
-
- mapping
- end
- end
-
- # Takes an array of integers, and returns an array of ranges covering the same integers
- def collapse_ranges(positions)
- return [] if positions.empty?
- ranges = []
-
- start = prev = positions[0]
- range = start..prev
- positions[1..-1].each do |pos|
- if pos == prev + 1
- range = start..pos
- prev = pos
- else
- ranges << range
- start = prev = pos
- range = start..prev
- end
- end
- ranges << range
-
- ranges
- end
-
- # Inserts tags around the characters identified by the given range
- def insert_around_range(text, range, before, after, offset = 0)
- # Just to be sure
- return offset if offset + range.end + 1 > text.length
-
- text.insert(offset + range.begin, before)
- offset += before.length
-
- text.insert(offset + range.end + 1, after)
- offset += after.length
-
- offset
- end
end
end
end
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index 0a15c6d9358..0603141e441 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -21,7 +21,7 @@ module Gitlab
def to_hash
hash = {}
- serialize_keys.each { |key| hash[key] = send(key) }
+ serialize_keys.each { |key| hash[key] = send(key) } # rubocop:disable GitlabSecurity/PublicSend
hash
end
@@ -42,11 +42,19 @@ module Gitlab
end
def added?
- type == 'new' || type == 'new-nonewline'
+ %w[new new-nonewline].include?(type)
end
def removed?
- type == 'old' || type == 'old-nonewline'
+ %w[old old-nonewline].include?(type)
+ end
+
+ def meta?
+ %w[match new-nonewline old-nonewline].include?(type)
+ end
+
+ def discussable?
+ !meta?
end
def rich_text
@@ -55,10 +63,6 @@ module Gitlab
@rich_text
end
- def meta?
- type == 'match'
- end
-
def as_json(opts = nil)
{
type: type,
diff --git a/lib/gitlab/diff/line_mapper.rb b/lib/gitlab/diff/line_mapper.rb
index 576a761423e..cf71d47df8e 100644
--- a/lib/gitlab/diff/line_mapper.rb
+++ b/lib/gitlab/diff/line_mapper.rb
@@ -38,7 +38,7 @@ module Gitlab
# - The first diff line with a higher line number, if it falls between diff contexts
# - The last known diff line, if it falls after the last diff context
diff_line = diff_lines.find do |diff_line|
- diff_from_line = diff_line.send(from)
+ diff_from_line = diff_line.public_send(from) # rubocop:disable GitlabSecurity/PublicSend
diff_from_line && diff_from_line >= from_line
end
diff_line ||= diff_lines.last
@@ -47,8 +47,8 @@ module Gitlab
# mapped line number is the same as the specified line number.
return from_line unless diff_line
- diff_from_line = diff_line.send(from)
- diff_to_line = diff_line.send(to)
+ diff_from_line = diff_line.public_send(from) # rubocop:disable GitlabSecurity/PublicSend
+ diff_to_line = diff_line.public_send(to) # rubocop:disable GitlabSecurity/PublicSend
# If the line was removed, there is no mapped line number.
return unless diff_to_line
diff --git a/lib/gitlab/diff/parallel_diff.rb b/lib/gitlab/diff/parallel_diff.rb
index 481536a380b..0cb26fa45c8 100644
--- a/lib/gitlab/diff/parallel_diff.rb
+++ b/lib/gitlab/diff/parallel_diff.rb
@@ -14,16 +14,7 @@ module Gitlab
lines = []
highlighted_diff_lines = diff_file.highlighted_diff_lines
highlighted_diff_lines.each do |line|
- if line.meta? || line.unchanged?
- # line in the right panel is the same as in the left one
- lines << {
- left: line,
- right: line
- }
-
- free_right_index = nil
- i += 1
- elsif line.removed?
+ if line.removed?
lines << {
left: line,
right: nil
@@ -51,6 +42,15 @@ module Gitlab
free_right_index = nil
i += 1
end
+ elsif line.meta? || line.unchanged?
+ # line in the right panel is the same as in the left one
+ lines << {
+ left: line,
+ right: line
+ }
+
+ free_right_index = nil
+ i += 1
end
end
diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb
index fc728123c97..f80afb20f0c 100644
--- a/lib/gitlab/diff/position.rb
+++ b/lib/gitlab/diff/position.rb
@@ -12,20 +12,26 @@ module Gitlab
attr_reader :head_sha
def initialize(attrs = {})
+ if diff_file = attrs[:diff_file]
+ attrs[:diff_refs] = diff_file.diff_refs
+ attrs[:old_path] = diff_file.old_path
+ attrs[:new_path] = diff_file.new_path
+ end
+
+ if diff_refs = attrs[:diff_refs]
+ attrs[:base_sha] = diff_refs.base_sha
+ attrs[:start_sha] = diff_refs.start_sha
+ attrs[:head_sha] = diff_refs.head_sha
+ end
+
@old_path = attrs[:old_path]
@new_path = attrs[:new_path]
+ @base_sha = attrs[:base_sha]
+ @start_sha = attrs[:start_sha]
+ @head_sha = attrs[:head_sha]
+
@old_line = attrs[:old_line]
@new_line = attrs[:new_line]
-
- if attrs[:diff_refs]
- @base_sha = attrs[:diff_refs].base_sha
- @start_sha = attrs[:diff_refs].start_sha
- @head_sha = attrs[:diff_refs].head_sha
- else
- @base_sha = attrs[:base_sha]
- @start_sha = attrs[:start_sha]
- @head_sha = attrs[:head_sha]
- end
end
# `Gitlab::Diff::Position` objects are stored as serialized attributes in
@@ -129,33 +135,19 @@ module Gitlab
end
def diff_line(repository)
- @diff_line ||= diff_file(repository).line_for_position(self)
+ @diff_line ||= diff_file(repository)&.line_for_position(self)
end
def line_code(repository)
- @line_code ||= diff_file(repository).line_code_for_position(self)
+ @line_code ||= diff_file(repository)&.line_code_for_position(self)
end
private
def find_diff_file(repository)
- # We're at the initial commit, so just get that as we can't compare to anything.
- compare =
- if Gitlab::Git.blank_ref?(start_sha)
- Gitlab::Git::Commit.find(repository.raw_repository, head_sha)
- else
- Gitlab::Git::Compare.new(
- repository.raw_repository,
- start_sha,
- head_sha
- )
- end
-
- diff = compare.diffs(paths: paths).first
-
- return unless diff
+ return unless diff_refs.complete?
- Gitlab::Diff::File.new(diff, repository: repository, diff_refs: diff_refs)
+ diff_refs.compare_in(repository.project).diffs(paths: paths, expanded: true).diff_files.first
end
end
end
diff --git a/lib/gitlab/diff/position_tracer.rb b/lib/gitlab/diff/position_tracer.rb
index c7542a8fabc..b68a1636814 100644
--- a/lib/gitlab/diff/position_tracer.rb
+++ b/lib/gitlab/diff/position_tracer.rb
@@ -3,21 +3,21 @@
module Gitlab
module Diff
class PositionTracer
- attr_accessor :repository
+ attr_accessor :project
attr_accessor :old_diff_refs
attr_accessor :new_diff_refs
attr_accessor :paths
- def initialize(repository:, old_diff_refs:, new_diff_refs:, paths: nil)
- @repository = repository
+ def initialize(project:, old_diff_refs:, new_diff_refs:, paths: nil)
+ @project = project
@old_diff_refs = old_diff_refs
@new_diff_refs = new_diff_refs
@paths = paths
end
- def trace(old_position)
- return unless old_diff_refs.complete? && new_diff_refs.complete?
- return unless old_position.diff_refs == old_diff_refs
+ def trace(ab_position)
+ return unless old_diff_refs&.complete? && new_diff_refs&.complete?
+ return unless ab_position.diff_refs == old_diff_refs
# Suppose we have an MR with source branch `feature` and target branch `master`.
# When the MR was created, the head of `master` was commit A, and the
@@ -44,14 +44,16 @@ module Gitlab
#
# For diff notes for diff A->B, the position looks like this:
# Position
- # base_sha - ID of commit A
+ # start_sha - ID of commit A
# head_sha - ID of commit B
+ # base_sha - ID of base commit of A and B
# old_path - path as of A (nil if file was newly created)
# new_path - path as of B (nil if file was deleted)
# old_line - line number as of A (nil if file was newly created)
# new_line - line number as of B (nil if file was deleted)
#
- # We can easily update `base_sha` and `head_sha` to hold the IDs of commits C and D,
+ # We can easily update `start_sha` and `head_sha` to hold the IDs of
+ # commits C and D, and can trivially determine `base_sha` based on those,
# but need to find the paths and line numbers as of C and D.
#
# If the file was unchanged or newly created in A->B, the path as of D can be found
@@ -68,107 +70,161 @@ module Gitlab
# by generating diff A->C ("base to base"), finding the diff file with
# `diff_file.old_path == position.old_path`, and taking `diff_file.new_path`.
# The path as of D can be found by taking diff C->D, finding the diff file
- # with that same `old_path` and taking `diff_file.new_path`.
+ # with `old_path` set to that `diff_file.new_path` and taking `diff_file.new_path`.
# The line number as of C can be found by using the LineMapper on diff A->C
# and providing the line number as of A.
# The line number as of D can be found by using the LineMapper on diff C->D
# and providing the line number as of C.
- results = nil
- results ||= trace_added_line(old_position) if old_position.added? || old_position.unchanged?
- results ||= trace_removed_line(old_position) if old_position.removed? || old_position.unchanged?
-
- return unless results
-
- file_diff, old_line, new_line = results
-
- new_position = Position.new(
- old_path: file_diff.old_path,
- new_path: file_diff.new_path,
- head_sha: new_diff_refs.head_sha,
- start_sha: new_diff_refs.start_sha,
- base_sha: new_diff_refs.base_sha,
- old_line: old_line,
- new_line: new_line
- )
-
- # If a position is found, but is not actually contained in the diff, for example
- # because it was an unchanged line in the context of a change that was undone,
- # we cannot return this as a successful trace.
- return unless new_position.diff_line(repository)
-
- new_position
+ if ab_position.added?
+ trace_added_line(ab_position)
+ elsif ab_position.removed?
+ trace_removed_line(ab_position)
+ else # unchanged
+ trace_unchanged_line(ab_position)
+ end
end
private
- def trace_added_line(old_position)
- file_path = old_position.new_path
-
- return unless diff_head_to_head
-
- file_head_to_head = diff_head_to_head.find { |diff_file| diff_file.old_path == file_path }
-
- file_path = file_head_to_head.new_path if file_head_to_head
-
- new_line = LineMapper.new(file_head_to_head).old_to_new(old_position.new_line)
-
- return unless new_line
-
- file_diff = new_diffs.find { |diff_file| diff_file.new_path == file_path }
- return unless file_diff
-
- old_line = LineMapper.new(file_diff).new_to_old(new_line)
-
- [file_diff, old_line, new_line]
+ def trace_added_line(ab_position)
+ b_path = ab_position.new_path
+ b_line = ab_position.new_line
+
+ bd_diff = bd_diffs.diff_file_with_old_path(b_path)
+
+ d_path = bd_diff&.new_path || b_path
+ d_line = LineMapper.new(bd_diff).old_to_new(b_line)
+
+ if d_line
+ cd_diff = cd_diffs.diff_file_with_new_path(d_path)
+
+ c_path = cd_diff&.old_path || d_path
+ c_line = LineMapper.new(cd_diff).new_to_old(d_line)
+
+ if c_line
+ # If the line is still in D but also in C, it has turned from an
+ # added line into an unchanged one.
+ new_position = position(cd_diff, c_line, d_line)
+ if valid_position?(new_position)
+ # If the line is still in the MR, we don't treat this as outdated.
+ { position: new_position, outdated: false }
+ else
+ # If the line is no longer in the MR, we unfortunately cannot show
+ # the current state on the CD diff, so we treat it as outdated.
+ ac_diff = ac_diffs.diff_file_with_new_path(c_path)
+
+ { position: position(ac_diff, nil, c_line), outdated: true }
+ end
+ else
+ # If the line is still in D and not in C, it is still added.
+ { position: position(cd_diff, nil, d_line), outdated: false }
+ end
+ else
+ # If the line is no longer in D, it has been removed from the MR.
+ { position: position(bd_diff, b_line, nil), outdated: true }
+ end
end
- def trace_removed_line(old_position)
- file_path = old_position.old_path
+ def trace_removed_line(ab_position)
+ a_path = ab_position.old_path
+ a_line = ab_position.old_line
- return unless diff_base_to_base
+ ac_diff = ac_diffs.diff_file_with_old_path(a_path)
- file_base_to_base = diff_base_to_base.find { |diff_file| diff_file.old_path == file_path }
+ c_path = ac_diff&.new_path || a_path
+ c_line = LineMapper.new(ac_diff).old_to_new(a_line)
- file_path = file_base_to_base.old_path if file_base_to_base
+ if c_line
+ cd_diff = cd_diffs.diff_file_with_old_path(c_path)
- old_line = LineMapper.new(file_base_to_base).old_to_new(old_position.old_line)
+ d_path = cd_diff&.new_path || c_path
+ d_line = LineMapper.new(cd_diff).old_to_new(c_line)
- return unless old_line
+ if d_line
+ # If the line is still in C but also in D, it has turned from a
+ # removed line into an unchanged one.
+ bd_diff = bd_diffs.diff_file_with_new_path(d_path)
- file_diff = new_diffs.find { |diff_file| diff_file.old_path == file_path }
- return unless file_diff
-
- new_line = LineMapper.new(file_diff).old_to_new(old_line)
+ { position: position(bd_diff, nil, d_line), outdated: true }
+ else
+ # If the line is still in C and not in D, it is still removed.
+ { position: position(cd_diff, c_line, nil), outdated: false }
+ end
+ else
+ # If the line is no longer in C, it has been removed outside of the MR.
+ { position: position(ac_diff, a_line, nil), outdated: true }
+ end
+ end
- [file_diff, old_line, new_line]
+ def trace_unchanged_line(ab_position)
+ a_path = ab_position.old_path
+ a_line = ab_position.old_line
+ b_path = ab_position.new_path
+ b_line = ab_position.new_line
+
+ ac_diff = ac_diffs.diff_file_with_old_path(a_path)
+
+ c_path = ac_diff&.new_path || a_path
+ c_line = LineMapper.new(ac_diff).old_to_new(a_line)
+
+ bd_diff = bd_diffs.diff_file_with_old_path(b_path)
+
+ d_line = LineMapper.new(bd_diff).old_to_new(b_line)
+
+ cd_diff = cd_diffs.diff_file_with_old_path(c_path)
+
+ if c_line && d_line
+ # If the line is still in C and D, it is still unchanged.
+ new_position = position(cd_diff, c_line, d_line)
+ if valid_position?(new_position)
+ # If the line is still in the MR, we don't treat this as outdated.
+ { position: new_position, outdated: false }
+ else
+ # If the line is no longer in the MR, we unfortunately cannot show
+ # the current state on the CD diff or any change on the BD diff,
+ # so we treat it as outdated.
+ { position: nil, outdated: true }
+ end
+ elsif d_line # && !c_line
+ # If the line is still in D but no longer in C, it has turned from
+ # an unchanged line into an added one.
+ # We don't treat this as outdated since the line is still in the MR.
+ { position: position(cd_diff, nil, d_line), outdated: false }
+ else # !d_line && (c_line || !c_line)
+ # If the line is no longer in D, it has turned from an unchanged line
+ # into a removed one.
+ { position: position(bd_diff, b_line, nil), outdated: true }
+ end
end
- def diff_base_to_base
- @diff_base_to_base ||= diff_files(old_diff_refs.base_sha || old_diff_refs.start_sha, new_diff_refs.base_sha || new_diff_refs.start_sha)
+ def ac_diffs
+ @ac_diffs ||= compare(
+ old_diff_refs.base_sha || old_diff_refs.start_sha,
+ new_diff_refs.base_sha || new_diff_refs.start_sha,
+ straight: true
+ )
end
- def diff_head_to_head
- @diff_head_to_head ||= diff_files(old_diff_refs.head_sha, new_diff_refs.head_sha)
+ def bd_diffs
+ @bd_diffs ||= compare(old_diff_refs.head_sha, new_diff_refs.head_sha, straight: true)
end
- def new_diffs
- @new_diffs ||= diff_files(new_diff_refs.start_sha, new_diff_refs.head_sha, use_base: true)
+ def cd_diffs
+ @cd_diffs ||= compare(new_diff_refs.start_sha, new_diff_refs.head_sha)
end
- def diff_files(start_sha, head_sha, use_base: false)
- base_sha = self.repository.merge_base(start_sha, head_sha) || start_sha
+ def compare(start_sha, head_sha, straight: false)
+ compare = CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
+ compare.diffs(paths: paths, expanded: true)
+ end
- diffs = self.repository.raw_repository.diff(
- use_base ? base_sha : start_sha,
- head_sha,
- {},
- *paths
- )
+ def position(diff_file, old_line, new_line)
+ Position.new(diff_file: diff_file, old_line: old_line, new_line: new_line)
+ end
- diffs.decorate! do |diff|
- Gitlab::Diff::File.new(diff, repository: self.repository)
- end
+ def valid_position?(position)
+ !!position.diff_line(project.repository)
end
end
end
diff --git a/lib/gitlab/downtime_check.rb b/lib/gitlab/downtime_check.rb
index ab9537ed7d7..941244694e2 100644
--- a/lib/gitlab/downtime_check.rb
+++ b/lib/gitlab/downtime_check.rb
@@ -50,8 +50,8 @@ module Gitlab
# Returns the class for the given migration file path.
def class_for_migration_file(path)
- File.basename(path, File.extname(path)).split('_', 2).last.camelize.
- constantize
+ File.basename(path, File.extname(path)).split('_', 2).last.camelize
+ .constantize
end
# Returns true if the given migration can be performed without downtime.
diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb
index 496ee0bdcb0..abd401224d8 100644
--- a/lib/gitlab/ee_compat_check.rb
+++ b/lib/gitlab/ee_compat_check.rb
@@ -76,7 +76,7 @@ module Gitlab
step(
"Generating the patch against origin/master in #{patch_path}",
- %w[git format-patch origin/master --stdout]
+ %w[git diff --binary origin/master...HEAD]
) do |output, status|
throw(:halt_check, :ko) unless status.zero?
@@ -98,10 +98,11 @@ module Gitlab
if status.zero?
@ee_branch_found = ee_branch_prefix
- else
- _, status = step("Fetching origin/#{ee_branch_suffix}", %W[git fetch origin #{ee_branch_suffix}])
+ return
end
+ _, status = step("Fetching origin/#{ee_branch_suffix}", %W[git fetch origin #{ee_branch_suffix}])
+
if status.zero?
@ee_branch_found = ee_branch_suffix
else
@@ -131,10 +132,20 @@ module Gitlab
def check_patch(patch_path)
step("Checking out master", %w[git checkout master])
step("Resetting to latest master", %w[git reset --hard origin/master])
+ step("Fetching CE/#{ce_branch}", %W[git fetch #{CE_REPO} #{ce_branch}])
step(
"Checking if #{patch_path} applies cleanly to EE/master",
- %W[git apply --check --3way #{patch_path}]
+ # Don't use --check here because it can result in a 0-exit status even
+ # though the patch doesn't apply cleanly, e.g.:
+ # > git apply --check --3way foo.patch
+ # error: patch failed: lib/gitlab/ee_compat_check.rb:74
+ # Falling back to three-way merge...
+ # Applied patch to 'lib/gitlab/ee_compat_check.rb' with conflicts.
+ # > echo $?
+ # 0
+ %W[git apply --3way #{patch_path}]
) do |output, status|
+ puts output
unless status.zero?
@failed_files = output.lines.reduce([]) do |memo, line|
if line.start_with?('error: patch failed:')
@@ -147,6 +158,7 @@ module Gitlab
status = 0 if failed_files.empty?
end
+ command(%w[git reset --hard])
status
end
end
@@ -170,8 +182,6 @@ module Gitlab
end
def find_merge_base_with_master(branch:)
- return if merge_base_found?
-
# Start with (Math.exp(3).to_i = 20) until (Math.exp(6).to_i = 403)
# In total we go (20 + 54 + 148 + 403 = 625) commits deeper
depth = 20
@@ -226,6 +236,10 @@ module Gitlab
branch_name.parameterize << '.patch'
end
+ def patch_url
+ "https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/#{ENV['CI_JOB_ID']}/artifacts/raw/ee_compat_check/patches/#{ce_patch_name}"
+ end
+
def step(desc, cmd = nil)
puts "\n=> #{desc}\n"
@@ -292,14 +306,11 @@ module Gitlab
2. Apply your branch's patch to EE
- # In the CE repo
- $ git fetch origin master
- $ git format-patch origin/master --stdout > #{ce_branch}.patch
-
# In the EE repo
$ git fetch origin master
$ git checkout -b #{ee_branch_prefix} origin/master
- $ git apply --3way path/to/#{ce_branch}.patch
+ $ wget #{patch_url}
+ $ git apply --3way #{ce_patch_name}
At this point you might have conflicts such as:
@@ -310,12 +321,23 @@ module Gitlab
Resolve them, stage the changes and commit them.
+ If the patch couldn't be applied cleanly, use the following command:
+
+ # In the EE repo
+ $ git apply --reject #{ce_patch_name}
+
+ This option makes git apply the parts of the patch that are applicable,
+ and leave the rejected hunks in corresponding `.rej` files.
+ You can then resolve the conflicts highlighted in `.rej` by
+ manually applying the correct diff from the `.rej` file to the file with conflicts.
+ When finished, you can delete the `.rej` files and commit your changes.
+
âš ï¸ Don't forget to push your branch to gitlab-ee:
# In the EE repo
$ git push origin #{ee_branch_prefix}
- âš ï¸ Also, don't forget to create a new merge request on gitlab-ce and
+ âš ï¸ Also, don't forget to create a new merge request on gitlab-ee and
cross-link it with the CE merge request.
Once this is done, you can retry this failed build, and it should pass.
diff --git a/lib/gitlab/email/attachment_uploader.rb b/lib/gitlab/email/attachment_uploader.rb
index 32cece8316b..83440ae227d 100644
--- a/lib/gitlab/email/attachment_uploader.rb
+++ b/lib/gitlab/email/attachment_uploader.rb
@@ -21,7 +21,7 @@ module Gitlab
content_type: attachment.content_type
}
- link = ::Projects::UploadService.new(project, file).execute
+ link = UploadService.new(project, file).execute
attachments << link if link
ensure
tmp.close!
diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb
index e7f91607e7e..a616a80e8f5 100644
--- a/lib/gitlab/email/handler/create_issue_handler.rb
+++ b/lib/gitlab/email/handler/create_issue_handler.rb
@@ -37,7 +37,7 @@ module Gitlab
end
def metrics_params
- super.merge(project: project)
+ super.merge(project: project&.full_path)
end
private
diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb
index 31bb775c357..8eea33b9ab5 100644
--- a/lib/gitlab/email/handler/create_note_handler.rb
+++ b/lib/gitlab/email/handler/create_note_handler.rb
@@ -15,7 +15,6 @@ module Gitlab
def execute
raise SentNotificationNotFoundError unless sent_notification
- raise AutoGeneratedEmailError if mail.header.to_s =~ /auto-(generated|replied)/
validate_permission!(:create_note)
@@ -29,7 +28,7 @@ module Gitlab
end
def metrics_params
- super.merge(project: project)
+ super.merge(project: project&.full_path)
end
private
diff --git a/lib/gitlab/email/handler/unsubscribe_handler.rb b/lib/gitlab/email/handler/unsubscribe_handler.rb
index df70a063330..5894384da5d 100644
--- a/lib/gitlab/email/handler/unsubscribe_handler.rb
+++ b/lib/gitlab/email/handler/unsubscribe_handler.rb
@@ -20,7 +20,7 @@ module Gitlab
end
def metrics_params
- super.merge(project: project)
+ super.merge(project: project&.full_path)
end
private
diff --git a/lib/gitlab/email/html_parser.rb b/lib/gitlab/email/html_parser.rb
index a4ca62bfc41..50559a48973 100644
--- a/lib/gitlab/email/html_parser.rb
+++ b/lib/gitlab/email/html_parser.rb
@@ -17,6 +17,13 @@ module Gitlab
def filter_replies!
document.xpath('//blockquote').each(&:remove)
document.xpath('//table').each(&:remove)
+
+ # bogus links with no href are sometimes added by outlook,
+ # and can result in Html2Text adding extra square brackets
+ # to the text, so we unwrap them here.
+ document.xpath('//a[not(@href)]').each do |link|
+ link.replace(link.children)
+ end
end
def filtered_html
diff --git a/lib/gitlab/email/message/repository_push.rb b/lib/gitlab/email/message/repository_push.rb
index 6c69cd9e6a9..cd9d3a6483f 100644
--- a/lib/gitlab/email/message/repository_push.rb
+++ b/lib/gitlab/email/message/repository_push.rb
@@ -4,7 +4,7 @@ module Gitlab
class RepositoryPush
attr_reader :author_id, :ref, :action
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
include DiffHelper
delegate :namespace, :name_with_namespace, to: :project, prefix: :project
@@ -42,7 +42,7 @@ module Gitlab
return unless compare
# This diff is more moderated in number of files and lines
- @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, no_collapse: true).diff_files
+ @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, expanded: true).diff_files
end
def diffs_count
@@ -96,20 +96,13 @@ module Gitlab
def target_url
if @action == :push && commits
if commits.length > 1
- namespace_project_compare_url(project_namespace,
- project,
- from: compare.start_commit,
- to: compare.head_commit)
+ project_compare_url(project, from: compare.start_commit, to: compare.head_commit)
else
- namespace_project_commit_url(project_namespace,
- project,
- commits.first)
+ project_commit_url(project, commits.first)
end
else
unless @action == :delete
- namespace_project_tree_url(project_namespace,
- project,
- ref_name)
+ project_tree_url(project, ref_name)
end
end
end
@@ -124,7 +117,7 @@ module Gitlab
def subject
subject_text = '[Git]'
- subject_text << "[#{project.path_with_namespace}]"
+ subject_text << "[#{project.full_path}]"
subject_text << "[#{ref_name}]" if @action == :push
subject_text << ' '
diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb
index 419d56a51e0..c8f4591d060 100644
--- a/lib/gitlab/email/receiver.rb
+++ b/lib/gitlab/email/receiver.rb
@@ -26,6 +26,9 @@ module Gitlab
raise EmptyEmailError if @raw.blank?
mail = build_mail
+
+ ignore_auto_submitted!(mail)
+
mail_key = extract_mail_key(mail)
handler = Handler.for(mail, mail_key)
@@ -57,9 +60,8 @@ module Gitlab
end
def key_from_additional_headers(mail)
- references = ensure_references_array(mail.references)
-
- find_key_from_references(references)
+ find_key_from_references(mail) ||
+ find_key_from_delivered_to_header(mail)
end
def ensure_references_array(references)
@@ -70,15 +72,34 @@ module Gitlab
# Handle emails from clients which append with commas,
# example clients are Microsoft exchange and iOS app
Gitlab::IncomingEmail.scan_fallback_references(references)
+ when nil
+ []
end
end
- def find_key_from_references(references)
- references.find do |mail_id|
+ def find_key_from_references(mail)
+ ensure_references_array(mail.references).find do |mail_id|
key = Gitlab::IncomingEmail.key_from_fallback_message_id(mail_id)
break key if key
end
end
+
+ def find_key_from_delivered_to_header(mail)
+ Array(mail[:delivered_to]).find do |header|
+ key = Gitlab::IncomingEmail.key_from_address(header.value)
+ break key if key
+ end
+ end
+
+ def ignore_auto_submitted!(mail)
+ # Mail::Header#[] is case-insensitive
+ auto_submitted = mail.header['Auto-Submitted']&.value
+
+ # Mail::Field#value would strip leading and trailing whitespace
+ raise AutoGeneratedEmailError if
+ # See also https://tools.ietf.org/html/rfc3834
+ auto_submitted && auto_submitted != 'no'
+ end
end
end
end
diff --git a/lib/gitlab/emoji.rb b/lib/gitlab/emoji.rb
index a16d9fc2265..e3e36b35ce9 100644
--- a/lib/gitlab/emoji.rb
+++ b/lib/gitlab/emoji.rb
@@ -54,7 +54,7 @@ module Gitlab
unicode_version: emoji_unicode_version(emoji_name)
}
- ActionController::Base.helpers.content_tag('gl-emoji', emoji_info['moji'], data: data)
+ ActionController::Base.helpers.content_tag('gl-emoji', emoji_info['moji'], title: emoji_info['description'], data: data)
end
end
end
diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb
new file mode 100644
index 00000000000..8ddc91e341d
--- /dev/null
+++ b/lib/gitlab/encoding_helper.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ module EncodingHelper
+ extend self
+
+ # This threshold is carefully tweaked to prevent usage of encodings detected
+ # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low,
+ # we're better off sticking with utf8 encoding.
+ # Reason: git diff can return strings with invalid utf8 byte sequences if it
+ # truncates a diff in the middle of a multibyte character. In this case
+ # CharlockHolmes will try to guess the encoding and will likely suggest an
+ # obscure encoding with low confidence.
+ # There is a lot more info with this merge request:
+ # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193
+ ENCODING_CONFIDENCE_THRESHOLD = 50
+
+ def encode!(message)
+ return nil unless message.respond_to? :force_encoding
+
+ # if message is utf-8 encoding, just return it
+ message.force_encoding("UTF-8")
+ return message if message.valid_encoding?
+
+ # return message if message type is binary
+ detect = CharlockHolmes::EncodingDetector.detect(message)
+ return message.force_encoding("BINARY") if detect && detect[:type] == :binary
+
+ # force detected encoding if we have sufficient confidence.
+ if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD
+ message.force_encoding(detect[:encoding])
+ end
+
+ # encode and clean the bad chars
+ message.replace clean(message)
+ rescue
+ encoding = detect ? detect[:encoding] : "unknown"
+ "--broken encoding: #{encoding}"
+ end
+
+ def encode_utf8(message)
+ detect = CharlockHolmes::EncodingDetector.detect(message)
+ if detect && detect[:encoding]
+ begin
+ CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8')
+ rescue ArgumentError => e
+ Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}")
+
+ ''
+ end
+ else
+ clean(message)
+ end
+ end
+
+ private
+
+ def clean(message)
+ message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "")
+ .encode("UTF-8")
+ .gsub("\0".encode("UTF-8"), "")
+ end
+ end
+end
diff --git a/lib/gitlab/environment.rb b/lib/gitlab/environment.rb
new file mode 100644
index 00000000000..5e0dd6e7859
--- /dev/null
+++ b/lib/gitlab/environment.rb
@@ -0,0 +1,7 @@
+module Gitlab
+ module Environment
+ def self.hostname
+ @hostname ||= ENV['HOSTNAME'] || Socket.gethostname
+ end
+ end
+end
diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb
index 270d67dd50c..1d6f5bb5e1c 100644
--- a/lib/gitlab/etag_caching/middleware.rb
+++ b/lib/gitlab/etag_caching/middleware.rb
@@ -6,12 +6,13 @@ module Gitlab
end
def call(env)
- route = Gitlab::EtagCaching::Router.match(env)
+ request = Rack::Request.new(env)
+ route = Gitlab::EtagCaching::Router.match(request.path_info)
return @app.call(env) unless route
track_event(:etag_caching_middleware_used, route)
- etag, cached_value_present = get_etag(env)
+ etag, cached_value_present = get_etag(request)
if_none_match = env['HTTP_IF_NONE_MATCH']
if if_none_match == etag
@@ -27,8 +28,8 @@ module Gitlab
private
- def get_etag(env)
- cache_key = env['PATH_INFO']
+ def get_etag(request)
+ cache_key = request.path
store = Gitlab::EtagCaching::Store.new
current_value = store.get(cache_key)
cached_value_present = current_value.present?
diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb
index f6e4f279c06..75167a6b088 100644
--- a/lib/gitlab/etag_caching/router.rb
+++ b/lib/gitlab/etag_caching/router.rb
@@ -2,37 +2,59 @@ module Gitlab
module EtagCaching
class Router
Route = Struct.new(:regexp, :name)
+ # We enable an ETag for every request matching the regex.
+ # To match a regex the path needs to match the following:
+ # - Don't contain a reserved word (expect for the words used in the
+ # regex itself)
+ # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route
+ # - Ending in `issues/id`/realtime_changes` for the `issue_title` route
+ USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes
+ commit pipelines merge_requests builds
+ new environments].freeze
+ RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
+ RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
- RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|')
ROUTES = [
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z),
'issue_notes'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/realtime_changes\z),
'issue_title'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/commit/\S+/pipelines\.json\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/commit/\S+/pipelines\.json\z),
'commit_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/new\.json\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/new\.json\z),
'new_merge_request_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/\d+/pipelines\.json\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/\d+/pipelines\.json\z),
'merge_request_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines\.json\z),
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z),
'project_pipelines'
+ ),
+ Gitlab::EtagCaching::Router::Route.new(
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z),
+ 'project_pipeline'
+ ),
+ Gitlab::EtagCaching::Router::Route.new(
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z),
+ 'project_build'
+ ),
+ Gitlab::EtagCaching::Router::Route.new(
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
+ 'environments'
)
].freeze
- def self.match(env)
- ROUTES.find { |route| route.regexp.match(env['PATH_INFO']) }
+ def self.match(path)
+ ROUTES.find { |route| route.regexp.match(path) }
end
end
end
diff --git a/lib/gitlab/etag_caching/store.rb b/lib/gitlab/etag_caching/store.rb
index 0039fc01c8f..21172ff8d93 100644
--- a/lib/gitlab/etag_caching/store.rb
+++ b/lib/gitlab/etag_caching/store.rb
@@ -2,17 +2,17 @@ module Gitlab
module EtagCaching
class Store
EXPIRY_TIME = 20.minutes
- REDIS_NAMESPACE = 'etag:'.freeze
+ SHARED_STATE_NAMESPACE = 'etag:'.freeze
def get(key)
- Gitlab::Redis.with { |redis| redis.get(redis_key(key)) }
+ Gitlab::Redis::SharedState.with { |redis| redis.get(redis_shared_state_key(key)) }
end
def touch(key, only_if_missing: false)
etag = generate_etag
- Gitlab::Redis.with do |redis|
- redis.set(redis_key(key), etag, ex: EXPIRY_TIME, nx: only_if_missing)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(redis_shared_state_key(key), etag, ex: EXPIRY_TIME, nx: only_if_missing)
end
etag
@@ -24,8 +24,10 @@ module Gitlab
SecureRandom.hex
end
- def redis_key(key)
- "#{REDIS_NAMESPACE}#{key}"
+ def redis_shared_state_key(key)
+ raise 'Invalid key' if !Rails.env.production? && !Gitlab::EtagCaching::Router.match(key)
+
+ "#{SHARED_STATE_NAMESPACE}#{key}"
end
end
end
diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb
index 62ddd45785d..3784f6c4947 100644
--- a/lib/gitlab/exclusive_lease.rb
+++ b/lib/gitlab/exclusive_lease.rb
@@ -10,25 +10,33 @@ module Gitlab
# ExclusiveLease.
#
class ExclusiveLease
- LUA_CANCEL_SCRIPT = <<-EOS.freeze
+ LUA_CANCEL_SCRIPT = <<~EOS.freeze
local key, uuid = KEYS[1], ARGV[1]
if redis.call("get", key) == uuid then
redis.call("del", key)
end
EOS
+ LUA_RENEW_SCRIPT = <<~EOS.freeze
+ local key, uuid, ttl = KEYS[1], ARGV[1], ARGV[2]
+ if redis.call("get", key) == uuid then
+ redis.call("expire", key, ttl)
+ return uuid
+ end
+ EOS
+
def self.cancel(key, uuid)
- Gitlab::Redis.with do |redis|
- redis.eval(LUA_CANCEL_SCRIPT, keys: [redis_key(key)], argv: [uuid])
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.eval(LUA_CANCEL_SCRIPT, keys: [redis_shared_state_key(key)], argv: [uuid])
end
end
- def self.redis_key(key)
+ def self.redis_shared_state_key(key)
"gitlab:exclusive_lease:#{key}"
end
def initialize(key, timeout:)
- @redis_key = self.class.redis_key(key)
+ @redis_shared_state_key = self.class.redis_shared_state_key(key)
@timeout = timeout
@uuid = SecureRandom.uuid
end
@@ -37,15 +45,24 @@ module Gitlab
# false if the lease is already taken.
def try_obtain
# Performing a single SET is atomic
- Gitlab::Redis.with do |redis|
- redis.set(@redis_key, @uuid, nx: true, ex: @timeout) && @uuid
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(@redis_shared_state_key, @uuid, nx: true, ex: @timeout) && @uuid
+ end
+ end
+
+ # Try to renew an existing lease. Return lease UUID on success,
+ # false if the lease is taken by a different UUID or inexistent.
+ def renew
+ Gitlab::Redis::SharedState.with do |redis|
+ result = redis.eval(LUA_RENEW_SCRIPT, keys: [@redis_shared_state_key], argv: [@uuid, @timeout])
+ result == @uuid
end
end
# Returns true if the key for this lease is set.
def exists?
- Gitlab::Redis.with do |redis|
- redis.exists(@redis_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.exists(@redis_shared_state_key)
end
end
end
diff --git a/lib/gitlab/fake_application_settings.rb b/lib/gitlab/fake_application_settings.rb
new file mode 100644
index 00000000000..bb14a8cd9e7
--- /dev/null
+++ b/lib/gitlab/fake_application_settings.rb
@@ -0,0 +1,27 @@
+# This class extends an OpenStruct object by adding predicate methods to mimic
+# ActiveRecord access. We rely on the initial values being true or false to
+# determine whether to define a predicate method because for a newly-added
+# column that has not been migrated yet, there is no way to determine the
+# column type without parsing db/schema.rb.
+module Gitlab
+ class FakeApplicationSettings < OpenStruct
+ def initialize(options = {})
+ super
+
+ FakeApplicationSettings.define_predicate_methods(options)
+ end
+
+ # Mimic ActiveRecord predicate methods for boolean values
+ def self.define_predicate_methods(options)
+ options.each do |key, value|
+ next if key.to_s.end_with?('?')
+ next unless [true, false].include?(value)
+
+ define_method "#{key}?" do
+ actual_key = key.to_s.chomp('?')
+ self[actual_key]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb
index c9ca4cadd1c..a8cb7fc3fe7 100644
--- a/lib/gitlab/file_detector.rb
+++ b/lib/gitlab/file_detector.rb
@@ -5,15 +5,33 @@ module Gitlab
# a README or a CONTRIBUTING file.
module FileDetector
PATTERNS = {
+ # Project files
readme: /\Areadme/i,
changelog: /\A(changelog|history|changes|news)/i,
license: /\A(licen[sc]e|copying)(\..+|\z)/i,
contributing: /\Acontributing/i,
version: 'version',
+ avatar: /\Alogo\.(png|jpg|gif)\z/,
+
+ # Configuration files
gitignore: '.gitignore',
koding: '.koding.yml',
gitlab_ci: '.gitlab-ci.yml',
- avatar: /\Alogo\.(png|jpg|gif)\z/
+ route_map: 'route-map.yml',
+
+ # Dependency files
+ cartfile: /\ACartfile/,
+ composer_json: 'composer.json',
+ gemfile: /\A(Gemfile|gems\.rb)\z/,
+ gemfile_lock: 'Gemfile.lock',
+ gemspec: /\.gemspec\z/,
+ godeps_json: 'Godeps.json',
+ package_json: 'package.json',
+ podfile: 'Podfile',
+ podspec_json: /\.podspec\.json\z/,
+ podspec: /\.podspec\z/,
+ requirements_txt: /requirements\.txt\z/,
+ yarn_lock: 'yarn.lock'
}.freeze
# Returns an Array of file types based on the given paths.
diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb
new file mode 100644
index 00000000000..093d9ed8092
--- /dev/null
+++ b/lib/gitlab/file_finder.rb
@@ -0,0 +1,32 @@
+# This class finds files in a repository by name and content
+# the result is joined and sorted by file name
+module Gitlab
+ class FileFinder
+ BATCH_SIZE = 100
+
+ attr_reader :project, :ref
+
+ def initialize(project, ref)
+ @project = project
+ @ref = ref
+ end
+
+ def find(query)
+ blobs = project.repository.search_files_by_content(query, ref).first(BATCH_SIZE)
+ found_file_names = Set.new
+
+ results = blobs.map do |blob|
+ blob = Gitlab::ProjectSearchResults.parse_search_result(blob)
+ found_file_names << blob.filename
+
+ [blob.filename, blob]
+ end
+
+ project.repository.search_files_by_name(query, ref).first(BATCH_SIZE).each do |filename|
+ results << [filename, OpenStruct.new(ref: ref)] unless found_file_names.include?(filename)
+ end
+
+ results.sort_by(&:first)
+ end
+ end
+end
diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb
index 222bcdcbf9c..3dcee681c72 100644
--- a/lib/gitlab/fogbugz_import/importer.rb
+++ b/lib/gitlab/fogbugz_import/importer.rb
@@ -122,15 +122,15 @@ module Gitlab
author_id = user_info(bug['ixPersonOpenedBy'])[:gitlab_id] || project.creator_id
issue = Issue.create!(
- iid: bug['ixBug'],
- project_id: project.id,
- title: bug['sTitle'],
- description: body,
- author_id: author_id,
- assignee_id: assignee_id,
- state: bug['fOpen'] == 'true' ? 'opened' : 'closed',
- created_at: date,
- updated_at: DateTime.parse(bug['dtLastUpdated'])
+ iid: bug['ixBug'],
+ project_id: project.id,
+ title: bug['sTitle'],
+ description: body,
+ author_id: author_id,
+ assignee_ids: [assignee_id],
+ state: bug['fOpen'] == 'true' ? 'opened' : 'closed',
+ created_at: date,
+ updated_at: DateTime.parse(bug['dtLastUpdated'])
)
issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true)
diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb
index 936606152e9..b6449f27034 100644
--- a/lib/gitlab/git.rb
+++ b/lib/gitlab/git.rb
@@ -7,8 +7,10 @@ module Gitlab
CommandError = Class.new(StandardError)
class << self
+ include Gitlab::EncodingHelper
+
def ref_name(ref)
- ref.sub(/\Arefs\/(tags|heads)\//, '')
+ encode! ref.sub(/\Arefs\/(tags|heads|remotes)\//, '')
end
def branch_name(ref)
diff --git a/lib/gitlab/git/attributes.rb b/lib/gitlab/git/attributes.rb
index 42140ecc993..2d20cd473a7 100644
--- a/lib/gitlab/git/attributes.rb
+++ b/lib/gitlab/git/attributes.rb
@@ -1,3 +1,8 @@
+# Gitaly note: JV: not sure what to make of this class. Why does it use
+# the full disk path of the repository to look up attributes This is
+# problematic in Gitaly, because Gitaly hides the full disk path to the
+# repository from gitlab-ce.
+
module Gitlab
module Git
# Class for parsing Git attribute files and extracting the attributes for
diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb
index 58193391926..31effdba292 100644
--- a/lib/gitlab/git/blame.rb
+++ b/lib/gitlab/git/blame.rb
@@ -1,7 +1,7 @@
module Gitlab
module Git
class Blame
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
attr_reader :lines, :blames
@@ -16,7 +16,7 @@ module Gitlab
def each
@blames.each do |blame|
yield(
- Gitlab::Git::Commit.new(blame.commit),
+ Gitlab::Git::Commit.new(@repo, blame.commit),
blame.line
)
end
@@ -25,13 +25,28 @@ module Gitlab
private
def load_blame
- cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path})
- # Read in binary mode to ensure ASCII-8BIT
- raw_output = IO.popen(cmd, 'rb') {|io| io.read }
+ raw_output = @repo.gitaly_migrate(:blame) do |is_enabled|
+ if is_enabled
+ load_blame_by_gitaly
+ else
+ load_blame_by_shelling_out
+ end
+ end
+
output = encode_utf8(raw_output)
process_raw_blame output
end
+ def load_blame_by_gitaly
+ @repo.gitaly_commit_client.raw_blame(@sha, @path)
+ end
+
+ def load_blame_by_shelling_out
+ cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path})
+ # Read in binary mode to ensure ASCII-8BIT
+ IO.popen(cmd, 'rb') {|io| io.read }
+ end
+
def process_raw_blame(output)
lines, final = [], []
info, commits = {}, {}
diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb
index 98fd4e78126..7780f4e4d4f 100644
--- a/lib/gitlab/git/blob.rb
+++ b/lib/gitlab/git/blob.rb
@@ -1,8 +1,10 @@
+# Gitaly note: JV: seems to be completely migrated (behind feature flags).
+
module Gitlab
module Git
class Blob
include Linguist::BlobHelper
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
# This number is the maximum amount of data that we want to display to
# the user. We load as much as we can for encoding detection
@@ -14,44 +16,57 @@ module Gitlab
class << self
def find(repository, sha, path)
- commit = repository.lookup(sha)
- root_tree = commit.tree
-
- blob_entry = find_entry_by_path(repository, root_tree.oid, path)
-
- return nil unless blob_entry
+ Gitlab::GitalyClient.migrate(:project_raw_show) do |is_enabled|
+ if is_enabled
+ find_by_gitaly(repository, sha, path)
+ else
+ find_by_rugged(repository, sha, path, limit: MAX_DATA_DISPLAY_SIZE)
+ end
+ end
+ end
- if blob_entry[:type] == :commit
- submodule_blob(blob_entry, path, sha)
- else
- blob = repository.lookup(blob_entry[:oid])
+ def raw(repository, sha)
+ Gitlab::GitalyClient.migrate(:git_blob_raw) do |is_enabled|
+ if is_enabled
+ Gitlab::GitalyClient::BlobService.new(repository).get_blob(oid: sha, limit: MAX_DATA_DISPLAY_SIZE)
+ else
+ blob = repository.lookup(sha)
- if blob
new(
id: blob.oid,
- name: blob_entry[:name],
size: blob.size,
data: blob.content(MAX_DATA_DISPLAY_SIZE),
- mode: blob_entry[:filemode].to_s(8),
- path: path,
- commit_id: sha,
binary: blob.binary?
)
end
end
end
- def raw(repository, sha)
- blob = repository.lookup(sha)
+ def binary?(data)
+ # EncodingDetector checks the first 1024 * 1024 bytes for NUL byte, libgit2 checks
+ # only the first 8000 (https://github.com/libgit2/libgit2/blob/2ed855a9e8f9af211e7274021c2264e600c0f86b/src/filter.h#L15),
+ # which is what we use below to keep a consistent behavior.
+ detect = CharlockHolmes::EncodingDetector.new(8000).detect(data)
+ detect && detect[:type] == :binary
+ end
- new(
- id: blob.oid,
- size: blob.size,
- data: blob.content(MAX_DATA_DISPLAY_SIZE),
- binary: blob.binary?
- )
+ # Returns an array of Blob instances, specified in blob_references as
+ # [[commit_sha, path], [commit_sha, path], ...]. If blob_size_limit < 0 then the
+ # full blob contents are returned. If blob_size_limit >= 0 then each blob will
+ # contain no more than limit bytes in its data attribute.
+ #
+ # Keep in mind that this method may allocate a lot of memory. It is up
+ # to the caller to limit the number of blobs and blob_size_limit.
+ #
+ def batch(repository, blob_references, blob_size_limit: nil)
+ blob_size_limit ||= MAX_DATA_DISPLAY_SIZE
+ blob_references.map do |sha, path|
+ find_by_rugged(repository, sha, path, limit: blob_size_limit)
+ end
end
+ private
+
# Recursive search of blob id by path
#
# Ex.
@@ -88,16 +103,77 @@ module Gitlab
new(
id: blob_entry[:oid],
name: blob_entry[:name],
+ size: 0,
data: '',
path: path,
- commit_id: sha,
+ commit_id: sha
)
end
+
+ def find_by_gitaly(repository, sha, path)
+ path = path.sub(/\A\/*/, '')
+ path = '/' if path.empty?
+ name = File.basename(path)
+ entry = Gitlab::GitalyClient::CommitService.new(repository).tree_entry(sha, path, MAX_DATA_DISPLAY_SIZE)
+ return unless entry
+
+ case entry.type
+ when :COMMIT
+ new(
+ id: entry.oid,
+ name: name,
+ size: 0,
+ data: '',
+ path: path,
+ commit_id: sha
+ )
+ when :BLOB
+ new(
+ id: entry.oid,
+ name: name,
+ size: entry.size,
+ data: entry.data.dup,
+ mode: entry.mode.to_s(8),
+ path: path,
+ commit_id: sha,
+ binary: binary?(entry.data)
+ )
+ end
+ end
+
+ def find_by_rugged(repository, sha, path, limit:)
+ commit = repository.lookup(sha)
+ root_tree = commit.tree
+
+ blob_entry = find_entry_by_path(repository, root_tree.oid, path)
+
+ return nil unless blob_entry
+
+ if blob_entry[:type] == :commit
+ submodule_blob(blob_entry, path, sha)
+ else
+ blob = repository.lookup(blob_entry[:oid])
+
+ if blob
+ new(
+ id: blob.oid,
+ name: blob_entry[:name],
+ size: blob.size,
+ # Rugged::Blob#content is expensive; don't call it if we don't have to.
+ data: limit.zero? ? '' : blob.content(limit),
+ mode: blob_entry[:filemode].to_s(8),
+ path: path,
+ commit_id: sha,
+ binary: blob.binary?
+ )
+ end
+ end
+ end
end
def initialize(options)
%w(id name path size data mode commit_id binary).each do |key|
- self.send("#{key}=", options[key.to_sym])
+ self.__send__("#{key}=", options[key.to_sym]) # rubocop:disable GitlabSecurity/PublicSend
end
@loaded_all_data = false
@@ -109,10 +185,6 @@ module Gitlab
@binary.nil? ? super : @binary == true
end
- def empty?
- !data || data == ''
- end
-
def data
encode! @data
end
@@ -123,15 +195,33 @@ module Gitlab
return if @data == '' # don't mess with submodule blobs
return @data if @loaded_all_data
+ Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled|
+ @data = begin
+ if is_enabled
+ Gitlab::GitalyClient::BlobService.new(repository).get_blob(oid: id, limit: -1).data
+ else
+ repository.lookup(id).content
+ end
+ end
+ end
+
@loaded_all_data = true
- @data = repository.lookup(id).content
@loaded_size = @data.bytesize
+ @binary = nil
end
def name
encode! @name
end
+ def path
+ encode! @path
+ end
+
+ def truncated?
+ size && (size > loaded_size)
+ end
+
# Valid LFS object pointer is a text file consisting of
# version
# oid
@@ -159,10 +249,14 @@ module Gitlab
nil
end
- def truncated?
- size && (size > loaded_size)
+ def external_storage
+ return unless lfs_pointer?
+
+ :lfs
end
+ alias_method :external_size, :lfs_size
+
private
def has_lfs_version_key?
diff --git a/lib/gitlab/git/blob_snippet.rb b/lib/gitlab/git/blob_snippet.rb
index d7975f88aaa..68116e775c6 100644
--- a/lib/gitlab/git/blob_snippet.rb
+++ b/lib/gitlab/git/blob_snippet.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
class BlobSnippet
diff --git a/lib/gitlab/git/branch.rb b/lib/gitlab/git/branch.rb
index 586380da94a..c53882787f1 100644
--- a/lib/gitlab/git/branch.rb
+++ b/lib/gitlab/git/branch.rb
@@ -1,6 +1,11 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
class Branch < Ref
+ def initialize(repository, name, target, target_commit)
+ super(repository, name, target, target_commit)
+ end
end
end
end
diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb
index 3a73697dc5d..a499bbc6266 100644
--- a/lib/gitlab/git/commit.rb
+++ b/lib/gitlab/git/commit.rb
@@ -2,9 +2,9 @@
module Gitlab
module Git
class Commit
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
- attr_accessor :raw_commit, :head, :refs
+ attr_accessor :raw_commit, :head
SERIALIZE_KEYS = [
:id, :message, :parent_ids,
@@ -14,18 +14,12 @@ module Gitlab
attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator
- delegate :tree, to: :raw_commit
+ delegate :tree, to: :rugged_commit
def ==(other)
return false unless other.is_a?(Gitlab::Git::Commit)
- methods = [:message, :parent_ids, :authored_date, :author_name,
- :author_email, :committed_date, :committer_name,
- :committer_email]
-
- methods.all? do |method|
- send(method) == other.send(method)
- end
+ id && id == other.id
end
class << self
@@ -44,7 +38,7 @@ module Gitlab
repo = options.delete(:repo)
raise 'Gitlab::Git::Repository is required' unless repo.respond_to?(:log)
- repo.log(options).map { |c| decorate(c) }
+ repo.log(options)
end
# Get single commit
@@ -54,19 +48,31 @@ module Gitlab
#
# Commit.find(repo, 'master')
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/321
def find(repo, commit_id = "HEAD")
- return decorate(commit_id) if commit_id.is_a?(Rugged::Commit)
+ # Already a commit?
+ return commit_id if commit_id.is_a?(Gitlab::Git::Commit)
- obj = if commit_id.is_a?(String)
- repo.rev_parse_target(commit_id)
- else
- Gitlab::Git::Ref.dereference_object(commit_id)
- end
+ # A rugged reference?
+ commit_id = Gitlab::Git::Ref.dereference_object(commit_id)
+ return decorate(repo, commit_id) if commit_id.is_a?(Rugged::Commit)
- return nil unless obj.is_a?(Rugged::Commit)
+ # Some weird thing?
+ return nil unless commit_id.is_a?(String)
- decorate(obj)
- rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError, Gitlab::Git::Repository::NoRepository
+ commit = repo.gitaly_migrate(:find_commit) do |is_enabled|
+ if is_enabled
+ repo.gitaly_commit_client.find_commit(commit_id)
+ else
+ obj = repo.rev_parse_target(commit_id)
+
+ obj.is_a?(Rugged::Commit) ? obj : nil
+ end
+ end
+
+ decorate(repo, commit) if commit
+ rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError,
+ Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository
nil
end
@@ -102,53 +108,135 @@ module Gitlab
# Commit.between(repo, '29eda46b', 'master')
#
def between(repo, base, head)
- repo.commits_between(base, head).map do |commit|
- decorate(commit)
+ Gitlab::GitalyClient.migrate(:commits_between) do |is_enabled|
+ if is_enabled
+ repo.gitaly_commit_client.between(base, head)
+ else
+ repo.rugged_commits_between(base, head).map { |c| decorate(repo, c) }
+ end
end
rescue Rugged::ReferenceError
[]
end
- # Delegate Repository#find_commits
+ # Returns commits collection
+ #
+ # Ex.
+ # Commit.find_all(
+ # repo,
+ # ref: 'master',
+ # max_count: 10,
+ # skip: 5,
+ # order: :date
+ # )
+ #
+ # +options+ is a Hash of optional arguments to git
+ # :ref is the ref from which to begin (SHA1 or name)
+ # :max_count is the maximum number of commits to fetch
+ # :skip is the number of commits to skip
+ # :order is the commits order and allowed value is :none (default), :date,
+ # :topo, or any combination of them (in an array). Commit ordering types
+ # are documented here:
+ # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant)
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/326
def find_all(repo, options = {})
- repo.find_commits(options)
+ Gitlab::GitalyClient.migrate(:find_all_commits) do |is_enabled|
+ if is_enabled
+ find_all_by_gitaly(repo, options)
+ else
+ find_all_by_rugged(repo, options)
+ end
+ end
end
- def decorate(commit, ref = nil)
- Gitlab::Git::Commit.new(commit, ref)
+ def find_all_by_rugged(repo, options = {})
+ actual_options = options.dup
+
+ allowed_options = [:ref, :max_count, :skip, :order]
+
+ actual_options.keep_if do |key|
+ allowed_options.include?(key)
+ end
+
+ default_options = { skip: 0 }
+ actual_options = default_options.merge(actual_options)
+
+ rugged = repo.rugged
+ walker = Rugged::Walker.new(rugged)
+
+ if actual_options[:ref]
+ walker.push(rugged.rev_parse_oid(actual_options[:ref]))
+ else
+ rugged.references.each("refs/heads/*") do |ref|
+ walker.push(ref.target_id)
+ end
+ end
+
+ walker.sorting(rugged_sort_type(actual_options[:order]))
+
+ commits = []
+ offset = actual_options[:skip]
+ limit = actual_options[:max_count]
+ walker.each(offset: offset, limit: limit) do |commit|
+ commits.push(decorate(repo, commit))
+ end
+
+ walker.reset
+
+ commits
+ rescue Rugged::OdbError
+ []
end
- # Returns a diff object for the changes introduced by +rugged_commit+.
- # If +rugged_commit+ doesn't have a parent, then the diff is between
- # this commit and an empty repo. See Repository#diff for the keys
- # allowed in the +options+ hash.
- def diff_from_parent(rugged_commit, options = {})
- options ||= {}
- break_rewrites = options[:break_rewrites]
- actual_options = Gitlab::Git::Diff.filter_diff_options(options)
-
- diff = if rugged_commit.parents.empty?
- rugged_commit.diff(actual_options.merge(reverse: true))
- else
- rugged_commit.parents[0].diff(rugged_commit, actual_options)
- end
-
- diff.find_similar!(break_rewrites: break_rewrites)
- diff
+ def find_all_by_gitaly(repo, options = {})
+ Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
+ end
+
+ def decorate(repository, commit, ref = nil)
+ Gitlab::Git::Commit.new(repository, commit, ref)
+ end
+
+ # Returns the `Rugged` sorting type constant for one or more given
+ # sort types. Valid keys are `:none`, `:topo`, and `:date`, or an array
+ # containing more than one of them. `:date` uses a combination of date and
+ # topological sorting to closer mimic git's native ordering.
+ def rugged_sort_type(sort_type)
+ @rugged_sort_types ||= {
+ none: Rugged::SORT_NONE,
+ topo: Rugged::SORT_TOPO,
+ date: Rugged::SORT_DATE | Rugged::SORT_TOPO
+ }
+
+ @rugged_sort_types.fetch(sort_type, Rugged::SORT_NONE)
+ end
+
+ def shas_with_signatures(repository, shas)
+ shas.select do |sha|
+ begin
+ Rugged::Commit.extract_signature(repository.rugged, sha)
+ rescue Rugged::OdbError
+ false
+ end
+ end
end
end
- def initialize(raw_commit, head = nil)
+ def initialize(repository, raw_commit, head = nil)
raise "Nil as raw commit passed" unless raw_commit
- if raw_commit.is_a?(Hash)
+ case raw_commit
+ when Hash
init_from_hash(raw_commit)
- elsif raw_commit.is_a?(Rugged::Commit)
+ when Rugged::Commit
init_from_rugged(raw_commit)
+ when Gitaly::GitCommit
+ init_from_gitaly(raw_commit)
else
raise "Invalid raw commit type: #{raw_commit.class}"
end
+ @repository = repository
@head = head
end
@@ -180,16 +268,53 @@ module Gitlab
# Shows the diff between the commit's parent and the commit.
#
# Cuts out the header and stats from #to_patch and returns only the diff.
- def to_diff(options = {})
- diff_from_parent(options).patch
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/324
+ def to_diff
+ rugged_diff_from_parent.patch
end
# Returns a diff object for the changes from this commit's first parent.
# If there is no parent, then the diff is between this commit and an
- # empty repo. See Repository#diff for keys allowed in the +options+
+ # empty repo. See Repository#diff for keys allowed in the +options+
# hash.
def diff_from_parent(options = {})
- Commit.diff_from_parent(raw_commit, options)
+ Gitlab::GitalyClient.migrate(:commit_raw_diffs) do |is_enabled|
+ if is_enabled
+ @repository.gitaly_commit_client.diff_from_parent(self, options)
+ else
+ rugged_diff_from_parent(options)
+ end
+ end
+ end
+
+ def rugged_diff_from_parent(options = {})
+ options ||= {}
+ break_rewrites = options[:break_rewrites]
+ actual_options = Gitlab::Git::Diff.filter_diff_options(options)
+
+ diff = if rugged_commit.parents.empty?
+ rugged_commit.diff(actual_options.merge(reverse: true))
+ else
+ rugged_commit.parents[0].diff(rugged_commit, actual_options)
+ end
+
+ diff.find_similar!(break_rewrites: break_rewrites)
+ diff
+ end
+
+ def deltas
+ @deltas ||= begin
+ deltas = Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled|
+ if is_enabled
+ @repository.gitaly_commit_client.commit_deltas(self)
+ else
+ rugged_diff_from_parent.each_delta
+ end
+ end
+
+ deltas.map { |delta| Gitlab::Git::Diff.new(delta) }
+ end
end
def has_zero_stats?
@@ -204,7 +329,7 @@ module Gitlab
def to_hash
serialize_keys.map.with_object({}) do |key, hash|
- hash[key] = send(key)
+ hash[key] = send(key) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -217,7 +342,7 @@ module Gitlab
end
def parents
- raw_commit.parents.map { |c| Gitlab::Git::Commit.new(c) }
+ parent_ids.map { |oid| self.class.find(@repository, oid) }.compact
end
def stats
@@ -226,9 +351,9 @@ module Gitlab
def to_patch(options = {})
begin
- raw_commit.to_mbox(options)
+ rugged_commit.to_mbox(options)
rescue Rugged::InvalidError => ex
- if ex.message =~ /Commit \w+ is a merge commit/
+ if ex.message =~ /commit \w+ is a merge commit/i
'Patch format is not currently supported for merge commits.'
end
end
@@ -274,13 +399,21 @@ module Gitlab
encode! @committer_email
end
+ def rugged_commit
+ @rugged_commit ||= if raw_commit.is_a?(Rugged::Commit)
+ raw_commit
+ else
+ @repository.rev_parse_target(id)
+ end
+ end
+
private
def init_from_hash(hash)
raw_commit = hash.symbolize_keys
serialize_keys.each do |key|
- send("#{key}=", raw_commit[key])
+ send("#{key}=", raw_commit[key]) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -300,6 +433,22 @@ module Gitlab
@parent_ids = commit.parents.map(&:oid)
end
+ def init_from_gitaly(commit)
+ @raw_commit = commit
+ @id = commit.id
+ # TODO: Once gitaly "takes over" Rugged consider separating the
+ # subject from the message to make it clearer when there's one
+ # available but not the other.
+ @message = (commit.body.presence || commit.subject).dup
+ @authored_date = Time.at(commit.author.date.seconds).utc
+ @author_name = commit.author.name.dup
+ @author_email = commit.author.email.dup
+ @committed_date = Time.at(commit.committer.date.seconds).utc
+ @committer_name = commit.committer.name.dup
+ @committer_email = commit.committer.email.dup
+ @parent_ids = commit.parent_ids
+ end
+
def serialize_keys
SERIALIZE_KEYS
end
diff --git a/lib/gitlab/git/commit_stats.rb b/lib/gitlab/git/commit_stats.rb
index e9118bbed0e..00acb4763e9 100644
--- a/lib/gitlab/git/commit_stats.rb
+++ b/lib/gitlab/git/commit_stats.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: 1 RPC, migration in progress.
+
# Gitlab::Git::CommitStats counts the additions, deletions, and total changes
# in a commit.
module Gitlab
@@ -6,13 +8,15 @@ module Gitlab
attr_reader :id, :additions, :deletions, :total
# Instantiate a CommitStats object
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/323
def initialize(commit)
@id = commit.id
@additions = 0
@deletions = 0
@total = 0
- diff = commit.diff_from_parent
+ diff = commit.rugged_diff_from_parent
diff.each_patch do |p|
# TODO: Use the new Rugged convenience methods when they're released
diff --git a/lib/gitlab/git/compare.rb b/lib/gitlab/git/compare.rb
index 696a2acd5e3..7cb842256d0 100644
--- a/lib/gitlab/git/compare.rb
+++ b/lib/gitlab/git/compare.rb
@@ -1,9 +1,11 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
class Compare
attr_reader :head, :base, :straight
- def initialize(repository, base, head, straight = false)
+ def initialize(repository, base, head, straight: false)
@repository = repository
@straight = straight
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index 019be151353..ce3d65062e8 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -1,9 +1,11 @@
+# Gitaly note: JV: needs RPC for Gitlab::Git::Diff.between.
+
# Gitlab::Git::Diff is a wrapper around native Rugged::Diff object
module Gitlab
module Git
class Diff
TimeoutError = Class.new(StandardError)
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
# Diff properties
attr_accessor :old_path, :new_path, :a_mode, :b_mode, :diff
@@ -11,15 +13,52 @@ module Gitlab
# Stats properties
attr_accessor :new_file, :renamed_file, :deleted_file
- attr_accessor :too_large
+ alias_method :new_file?, :new_file
+ alias_method :deleted_file?, :deleted_file
+ alias_method :renamed_file?, :renamed_file
+
+ attr_accessor :expanded
+ attr_writer :too_large
- # The maximum size of a diff to display.
- DIFF_SIZE_LIMIT = 102400 # 100 KB
+ alias_method :expanded?, :expanded
- # The maximum size before a diff is collapsed.
- DIFF_COLLAPSE_LIMIT = 10240 # 10 KB
+ SERIALIZE_KEYS = %i(diff new_path old_path a_mode b_mode new_file renamed_file deleted_file too_large).freeze
class << self
+ # The maximum size of a diff to display.
+ def size_limit
+ if RequestStore.active?
+ RequestStore['gitlab_git_diff_size_limit'] ||= find_size_limit
+ else
+ find_size_limit
+ end
+ end
+
+ # The maximum size before a diff is collapsed.
+ def collapse_limit
+ if RequestStore.active?
+ RequestStore['gitlab_git_diff_collapse_limit'] ||= find_collapse_limit
+ else
+ find_collapse_limit
+ end
+ end
+
+ def find_size_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 200.kilobytes
+ else
+ 100.kilobytes
+ end
+ end
+
+ def find_collapse_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 100.kilobytes
+ else
+ 10.kilobytes
+ end
+ end
+
def between(repo, head, base, options = {}, *paths)
straight = options.delete(:straight) || false
@@ -44,111 +83,17 @@ module Gitlab
# Return a copy of the +options+ hash containing only keys that can be
# passed to Rugged. Allowed options are:
#
- # :max_size ::
- # An integer specifying the maximum byte size of a file before a it
- # will be treated as binary. The default value is 512MB.
- #
- # :context_lines ::
- # The number of unchanged lines that define the boundary of a hunk
- # (and to display before and after the actual changes). The default is
- # 3.
- #
- # :interhunk_lines ::
- # The maximum number of unchanged lines between hunk boundaries before
- # the hunks will be merged into a one. The default is 0.
- #
- # :old_prefix ::
- # The virtual "directory" to prefix to old filenames in hunk headers.
- # The default is "a".
- #
- # :new_prefix ::
- # The virtual "directory" to prefix to new filenames in hunk headers.
- # The default is "b".
- #
- # :reverse ::
- # If true, the sides of the diff will be reversed.
- #
- # :force_text ::
- # If true, all files will be treated as text, disabling binary
- # attributes & detection.
- #
- # :ignore_whitespace ::
- # If true, all whitespace will be ignored.
- #
# :ignore_whitespace_change ::
# If true, changes in amount of whitespace will be ignored.
#
- # :ignore_whitespace_eol ::
- # If true, whitespace at end of line will be ignored.
- #
- # :ignore_submodules ::
- # if true, submodules will be excluded from the diff completely.
- #
- # :patience ::
- # If true, the "patience diff" algorithm will be used (currenlty
- # unimplemented).
- #
- # :include_ignored ::
- # If true, ignored files will be included in the diff.
- #
- # :include_untracked ::
- # If true, untracked files will be included in the diff.
- #
- # :include_unmodified ::
- # If true, unmodified files will be included in the diff.
- #
- # :recurse_untracked_dirs ::
- # Even if +:include_untracked+ is true, untracked directories will
- # only be marked with a single entry in the diff. If this flag is set
- # to true, all files under ignored directories will be included in the
- # diff, too.
- #
# :disable_pathspec_match ::
# If true, the given +*paths+ will be applied as exact matches,
# instead of as fnmatch patterns.
#
- # :deltas_are_icase ::
- # If true, filename comparisons will be made with case-insensitivity.
- #
- # :include_untracked_content ::
- # if true, untracked content will be contained in the the diff patch
- # text.
- #
- # :skip_binary_check ::
- # If true, diff deltas will be generated without spending time on
- # binary detection. This is useful to improve performance in cases
- # where the actual file content difference is not needed.
- #
- # :include_typechange ::
- # If true, type changes for files will not be interpreted as deletion
- # of the "old file" and addition of the "new file", but will generate
- # typechange records.
- #
- # :include_typechange_trees ::
- # Even if +:include_typechange+ is true, blob -> tree changes will
- # still usually be handled as a deletion of the blob. If this flag is
- # set to true, blob -> tree changes will be marked as typechanges.
- #
- # :ignore_filemode ::
- # If true, file mode changes will be ignored.
- #
- # :recurse_ignored_dirs ::
- # Even if +:include_ignored+ is true, ignored directories will only be
- # marked with a single entry in the diff. If this flag is set to true,
- # all files under ignored directories will be included in the diff,
- # too.
def filter_diff_options(options, default_options = {})
- allowed_options = [:max_size, :context_lines, :interhunk_lines,
- :old_prefix, :new_prefix, :reverse, :force_text,
- :ignore_whitespace, :ignore_whitespace_change,
- :ignore_whitespace_eol, :ignore_submodules,
- :patience, :include_ignored, :include_untracked,
- :include_unmodified, :recurse_untracked_dirs,
- :disable_pathspec_match, :deltas_are_icase,
- :include_untracked_content, :skip_binary_check,
- :include_typechange, :include_typechange_trees,
- :ignore_filemode, :recurse_ignored_dirs, :paths,
- :max_files, :max_lines, :all_diffs, :no_collapse]
+ allowed_options = [:ignore_whitespace_change,
+ :disable_pathspec_match, :paths,
+ :max_files, :max_lines, :limits, :expanded]
if default_options
actual_defaults = default_options.dup
@@ -173,16 +118,20 @@ module Gitlab
end
end
- def initialize(raw_diff, collapse: false)
+ def initialize(raw_diff, expanded: true)
+ @expanded = expanded
+
case raw_diff
when Hash
init_from_hash(raw_diff)
- prune_diff_if_eligible(collapse)
+ prune_diff_if_eligible
when Rugged::Patch, Rugged::Diff::Delta
- init_from_rugged(raw_diff, collapse: collapse)
- when Gitaly::CommitDiffResponse
+ init_from_rugged(raw_diff)
+ when Gitlab::GitalyClient::Diff
+ init_from_gitaly(raw_diff)
+ prune_diff_if_eligible
+ when Gitaly::CommitDelta
init_from_gitaly(raw_diff)
- prune_diff_if_eligible(collapse)
when nil
raise "Nil as raw diff passed"
else
@@ -190,22 +139,20 @@ module Gitlab
end
end
- def serialize_keys
- @serialize_keys ||= %i(diff new_path old_path a_mode b_mode new_file renamed_file deleted_file too_large)
- end
-
def to_hash
hash = {}
- keys = serialize_keys
-
- keys.each do |key|
- hash[key] = send(key)
+ SERIALIZE_KEYS.each do |key|
+ hash[key] = send(key) # rubocop:disable GitlabSecurity/PublicSend
end
hash
end
+ def mode_changed?
+ a_mode && b_mode && a_mode != b_mode
+ end
+
def submodule?
a_mode == '160000' || b_mode == '160000'
end
@@ -216,17 +163,16 @@ module Gitlab
def too_large?
if @too_large.nil?
- @too_large = @diff.bytesize >= DIFF_SIZE_LIMIT
+ @too_large = @diff.bytesize >= self.class.size_limit
else
@too_large
end
end
- def collapsible?
- @diff.bytesize >= DIFF_COLLAPSE_LIMIT
- end
+ # This is used by `to_hash` and `init_from_hash`.
+ alias_method :too_large, :too_large?
- def prune_large_diff!
+ def too_large!
@diff = ''
@line_count = 0
@too_large = true
@@ -234,10 +180,11 @@ module Gitlab
def collapsed?
return @collapsed if defined?(@collapsed)
- false
+
+ @collapsed = !expanded && @diff.bytesize >= self.class.collapse_limit
end
- def prune_collapsed_diff!
+ def collapse!
@diff = ''
@line_count = 0
@collapsed = true
@@ -245,9 +192,9 @@ module Gitlab
private
- def init_from_rugged(rugged, collapse: false)
+ def init_from_rugged(rugged)
if rugged.is_a?(Rugged::Patch)
- init_from_rugged_patch(rugged, collapse: collapse)
+ init_from_rugged_patch(rugged)
d = rugged.delta
else
d = rugged
@@ -262,10 +209,10 @@ module Gitlab
@deleted_file = d.deleted?
end
- def init_from_rugged_patch(patch, collapse: false)
+ def init_from_rugged_patch(patch)
# Don't bother initializing diffs that are too large. If a diff is
# binary we're not going to display anything so we skip the size check.
- return if !patch.delta.binary? && prune_large_patch(patch, collapse)
+ return if !patch.delta.binary? && prune_large_patch(patch)
@diff = encode!(strip_diff_headers(patch.to_s))
end
@@ -273,45 +220,50 @@ module Gitlab
def init_from_hash(hash)
raw_diff = hash.symbolize_keys
- serialize_keys.each do |key|
- send(:"#{key}=", raw_diff[key.to_sym])
+ SERIALIZE_KEYS.each do |key|
+ send(:"#{key}=", raw_diff[key.to_sym]) # rubocop:disable GitlabSecurity/PublicSend
end
end
- def init_from_gitaly(diff_msg)
- @diff = diff_msg.raw_chunks.join
- @new_path = encode!(diff_msg.to_path.dup)
- @old_path = encode!(diff_msg.from_path.dup)
- @a_mode = diff_msg.old_mode.to_s(8)
- @b_mode = diff_msg.new_mode.to_s(8)
- @new_file = diff_msg.from_id == BLANK_SHA
- @renamed_file = diff_msg.from_path != diff_msg.to_path
- @deleted_file = diff_msg.to_id == BLANK_SHA
+ def init_from_gitaly(diff)
+ @diff = encode!(diff.patch) if diff.respond_to?(:patch)
+ @new_path = encode!(diff.to_path.dup)
+ @old_path = encode!(diff.from_path.dup)
+ @a_mode = diff.old_mode.to_s(8)
+ @b_mode = diff.new_mode.to_s(8)
+ @new_file = diff.from_id == BLANK_SHA
+ @renamed_file = diff.from_path != diff.to_path
+ @deleted_file = diff.to_id == BLANK_SHA
+
+ collapse! if diff.respond_to?(:collapsed) && diff.collapsed
end
- def prune_diff_if_eligible(collapse = false)
- prune_large_diff! if too_large?
- prune_collapsed_diff! if collapse && collapsible?
+ def prune_diff_if_eligible
+ if too_large?
+ too_large!
+ elsif collapsed?
+ collapse!
+ end
end
# If the patch surpasses any of the diff limits it calls the appropiate
# prune method and returns true. Otherwise returns false.
- def prune_large_patch(patch, collapse)
+ def prune_large_patch(patch)
size = 0
patch.each_hunk do |hunk|
hunk.each_line do |line|
size += line.content.bytesize
- if size >= DIFF_SIZE_LIMIT
- prune_large_diff!
+ if size >= self.class.size_limit
+ too_large!
return true
end
end
end
- if collapse && size >= DIFF_COLLAPSE_LIMIT
- prune_collapsed_diff!
+ if !expanded && size >= self.class.collapse_limit
+ collapse!
return true
end
diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb
index 4e45ec7c174..6a601561c2a 100644
--- a/lib/gitlab/git/diff_collection.rb
+++ b/lib/gitlab/git/diff_collection.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
class DiffCollection
@@ -5,39 +7,58 @@ module Gitlab
DEFAULT_LIMITS = { max_files: 100, max_lines: 5000 }.freeze
+ attr_reader :limits
+
+ delegate :max_files, :max_lines, :max_bytes, :safe_max_files, :safe_max_lines, :safe_max_bytes, to: :limits
+
+ def self.collection_limits(options = {})
+ limits = {}
+ limits[:max_files] = options.fetch(:max_files, DEFAULT_LIMITS[:max_files])
+ limits[:max_lines] = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines])
+ limits[:max_bytes] = limits[:max_files] * 5.kilobytes # Average 5 KB per file
+ limits[:safe_max_files] = [limits[:max_files], DEFAULT_LIMITS[:max_files]].min
+ limits[:safe_max_lines] = [limits[:max_lines], DEFAULT_LIMITS[:max_lines]].min
+ limits[:safe_max_bytes] = limits[:safe_max_files] * 5.kilobytes # Average 5 KB per file
+
+ OpenStruct.new(limits)
+ end
+
def initialize(iterator, options = {})
@iterator = iterator
- @max_files = options.fetch(:max_files, DEFAULT_LIMITS[:max_files])
- @max_lines = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines])
- @max_bytes = @max_files * 5120 # Average 5 KB per file
- @safe_max_files = [@max_files, DEFAULT_LIMITS[:max_files]].min
- @safe_max_lines = [@max_lines, DEFAULT_LIMITS[:max_lines]].min
- @safe_max_bytes = @safe_max_files * 5120 # Average 5 KB per file
- @all_diffs = !!options.fetch(:all_diffs, false)
- @no_collapse = !!options.fetch(:no_collapse, true)
- @deltas_only = !!options.fetch(:deltas_only, false)
+ @limits = self.class.collection_limits(options)
+ @enforce_limits = !!options.fetch(:limits, true)
+ @expanded = !!options.fetch(:expanded, true)
@line_count = 0
@byte_count = 0
@overflow = false
+ @empty = true
@array = Array.new
end
def each(&block)
- if @populated
- # @iterator.each is slower than just iterating the array in place
- @array.each(&block)
- elsif @deltas_only
- each_delta(&block)
- else
- Gitlab::GitalyClient.migrate(:commit_raw_diffs) do
- each_patch(&block)
+ @array.each(&block)
+
+ return if @overflow
+ return if @iterator.nil?
+
+ Gitlab::GitalyClient.migrate(:commit_raw_diffs) do |is_enabled|
+ if is_enabled && @iterator.is_a?(Gitlab::GitalyClient::DiffStitcher)
+ each_gitaly_patch(&block)
+ else
+ each_rugged_patch(&block)
end
end
+
+ @populated = true
+
+ # Allow iterator to be garbage-collected. It cannot be reused anyway.
+ @iterator = nil
end
def empty?
- !@iterator.any?
+ any? # Make sure the iterator has been exercised
+ @empty
end
def overflow?
@@ -63,60 +84,63 @@ module Gitlab
collection = each_with_index do |element, i|
@array[i] = yield(element)
end
- @populated = true
collection
end
+ alias_method :to_ary, :to_a
+
private
def populate!
return if @populated
each { nil } # force a loop through all diffs
- @populated = true
nil
end
def over_safe_limits?(files)
- files >= @safe_max_files || @line_count > @safe_max_lines || @byte_count >= @safe_max_bytes
+ files >= safe_max_files || @line_count > safe_max_lines || @byte_count >= safe_max_bytes
end
- def each_delta
- @iterator.each_delta.with_index do |delta, i|
- diff = Gitlab::Git::Diff.new(delta)
+ def each_gitaly_patch
+ i = @array.length
+
+ @iterator.each do |raw|
+ diff = Gitlab::Git::Diff.new(raw, expanded: !@enforce_limits || @expanded)
+
+ if raw.overflow_marker
+ @overflow = true
+ break
+ end
yield @array[i] = diff
+ i += 1
end
end
- def each_patch
- @iterator.each_with_index do |raw, i|
- # First yield cached Diff instances from @array
- if @array[i]
- yield @array[i]
- next
- end
+ def each_rugged_patch
+ i = @array.length
- # We have exhausted @array, time to create new Diff instances or stop.
- break if @overflow
+ @iterator.each do |raw|
+ @empty = false
- if !@all_diffs && i >= @max_files
+ if @enforce_limits && i >= max_files
@overflow = true
break
end
- collapse = !@all_diffs && !@no_collapse
+ expanded = !@enforce_limits || @expanded
- diff = Gitlab::Git::Diff.new(raw, collapse: collapse)
+ diff = Gitlab::Git::Diff.new(raw, expanded: expanded)
- if collapse && over_safe_limits?(i)
- diff.prune_collapsed_diff!
+ if !expanded && over_safe_limits?(i) && diff.line_count > 0
+ diff.collapse!
end
@line_count += diff.line_count
@byte_count += diff.diff.bytesize
- if !@all_diffs && (@line_count >= @max_lines || @byte_count >= @max_bytes)
+ if @enforce_limits && (@line_count >= max_lines || @byte_count >= max_bytes)
# This last Diff instance pushes us over the lines limit. We stop and
# discard it.
@overflow = true
@@ -124,6 +148,7 @@ module Gitlab
end
yield @array[i] = diff
+ i += 1
end
end
end
diff --git a/lib/gitlab/git/encoding_helper.rb b/lib/gitlab/git/encoding_helper.rb
deleted file mode 100644
index f918074cb14..00000000000
--- a/lib/gitlab/git/encoding_helper.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-module Gitlab
- module Git
- module EncodingHelper
- extend self
-
- # This threshold is carefully tweaked to prevent usage of encodings detected
- # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low,
- # we're better off sticking with utf8 encoding.
- # Reason: git diff can return strings with invalid utf8 byte sequences if it
- # truncates a diff in the middle of a multibyte character. In this case
- # CharlockHolmes will try to guess the encoding and will likely suggest an
- # obscure encoding with low confidence.
- # There is a lot more info with this merge request:
- # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193
- ENCODING_CONFIDENCE_THRESHOLD = 40
-
- def encode!(message)
- return nil unless message.respond_to? :force_encoding
-
- # if message is utf-8 encoding, just return it
- message.force_encoding("UTF-8")
- return message if message.valid_encoding?
-
- # return message if message type is binary
- detect = CharlockHolmes::EncodingDetector.detect(message)
- return message.force_encoding("BINARY") if detect && detect[:type] == :binary
-
- # force detected encoding if we have sufficient confidence.
- if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD
- message.force_encoding(detect[:encoding])
- end
-
- # encode and clean the bad chars
- message.replace clean(message)
- rescue
- encoding = detect ? detect[:encoding] : "unknown"
- "--broken encoding: #{encoding}"
- end
-
- def encode_utf8(message)
- detect = CharlockHolmes::EncodingDetector.detect(message)
- if detect
- begin
- CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8')
- rescue ArgumentError => e
- Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}")
-
- ''
- end
- else
- clean(message)
- end
- end
-
- private
-
- def clean(message)
- message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "")
- .encode("UTF-8")
- .gsub("\0".encode("UTF-8"), "")
- end
- end
- end
-end
diff --git a/lib/gitlab/git/env.rb b/lib/gitlab/git/env.rb
index 0fdc57ec954..f80193ac553 100644
--- a/lib/gitlab/git/env.rb
+++ b/lib/gitlab/git/env.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
# Ephemeral (per request) storage for environment variables that some Git
diff --git a/lib/gitlab/git/gitmodules_parser.rb b/lib/gitlab/git/gitmodules_parser.rb
new file mode 100644
index 00000000000..4a43b9b444d
--- /dev/null
+++ b/lib/gitlab/git/gitmodules_parser.rb
@@ -0,0 +1,79 @@
+# Gitaly note: JV: no RPC's here.
+
+module Gitlab
+ module Git
+ class GitmodulesParser
+ def initialize(content)
+ @content = content
+ end
+
+ # Parses the contents of a .gitmodules file and returns a hash of
+ # submodule information, indexed by path.
+ def parse
+ reindex_by_path(get_submodules_by_name)
+ end
+
+ private
+
+ class State
+ def initialize
+ @result = {}
+ @current_submodule = nil
+ end
+
+ def start_section(section)
+ # In some .gitmodules files (e.g. nodegit's), a header
+ # with the same name appears multiple times; we want to
+ # accumulate the configs across these
+ @current_submodule = @result[section] || { 'name' => section }
+ @result[section] = @current_submodule
+ end
+
+ def set_attribute(attr, value)
+ @current_submodule[attr] = value
+ end
+
+ def section_started?
+ !@current_submodule.nil?
+ end
+
+ def submodules_by_name
+ @result
+ end
+ end
+
+ def get_submodules_by_name
+ iterator = State.new
+
+ @content.split("\n").each_with_object(iterator) do |text, iterator|
+ next if text =~ /^\s*#/
+
+ if text =~ /\A\[submodule "(?<name>[^"]+)"\]\z/
+ iterator.start_section($~[:name])
+ else
+ next unless iterator.section_started?
+
+ next unless text =~ /\A\s*(?<key>\w+)\s*=\s*(?<value>.*)\z/
+
+ value = $~[:value].chomp
+ iterator.set_attribute($~[:key], value)
+ end
+ end
+
+ iterator.submodules_by_name
+ end
+
+ def reindex_by_path(submodules_by_name)
+ # Convert from an indexed by name to an array indexed by path
+ # If a submodule doesn't have a path, it is considered bogus
+ # and is ignored
+ submodules_by_name.each_with_object({}) do |(name, data), results|
+ path = data.delete 'path'
+ next unless path
+
+ results[path] = data
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/hook.rb b/lib/gitlab/git/hook.rb
index bd90d24a2ec..8f0c377ef4f 100644
--- a/lib/gitlab/git/hook.rb
+++ b/lib/gitlab/git/hook.rb
@@ -1,12 +1,17 @@
+# Gitaly note: JV: looks like this is only used by GitHooksService in
+# app/services. We shouldn't bother migrating this until we know how
+# GitHooksService will be migrated.
+
module Gitlab
module Git
class Hook
GL_PROTOCOL = 'web'.freeze
attr_reader :name, :repo_path, :path
- def initialize(name, repo_path)
+ def initialize(name, project)
@name = name
- @repo_path = repo_path
+ @project = project
+ @repo_path = project.repository.path
@path = File.join(repo_path.strip, 'hooks', name)
end
@@ -38,7 +43,8 @@ module Gitlab
vars = {
'GL_ID' => gl_id,
'PWD' => repo_path,
- 'GL_PROTOCOL' => GL_PROTOCOL
+ 'GL_PROTOCOL' => GL_PROTOCOL,
+ 'GL_REPOSITORY' => Gitlab::GlRepository.gl_repository(@project, false)
}
options = {
diff --git a/lib/gitlab/git/index.rb b/lib/gitlab/git/index.rb
index 1add037fa5f..db532600d1b 100644
--- a/lib/gitlab/git/index.rb
+++ b/lib/gitlab/git/index.rb
@@ -1,3 +1,7 @@
+# Gitaly note: JV: When the time comes I think we will want to copy this
+# class into Gitaly. None of its methods look like they should be RPC's.
+# The RPC's will be at a higher level.
+
module Gitlab
module Git
class Index
@@ -110,10 +114,6 @@ module Gitlab
if segment == '..'
raise IndexError, 'Path cannot include directory traversal'
end
-
- unless segment =~ Gitlab::Regex.file_name_regex
- raise IndexError, "Path #{Gitlab::Regex.file_name_regex_message}"
- end
end
pathname.to_s
diff --git a/lib/gitlab/git/path_helper.rb b/lib/gitlab/git/path_helper.rb
index 0148cd8df05..42c80aabd0a 100644
--- a/lib/gitlab/git/path_helper.rb
+++ b/lib/gitlab/git/path_helper.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
class PathHelper
diff --git a/lib/gitlab/git/popen.rb b/lib/gitlab/git/popen.rb
index df9ca3ee5ac..25fa62ce4bd 100644
--- a/lib/gitlab/git/popen.rb
+++ b/lib/gitlab/git/popen.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
require 'open3'
module Gitlab
diff --git a/lib/gitlab/git/ref.rb b/lib/gitlab/git/ref.rb
index 37ef6836742..372ce005b94 100644
--- a/lib/gitlab/git/ref.rb
+++ b/lib/gitlab/git/ref.rb
@@ -1,7 +1,9 @@
+# Gitaly note: JV: probably no RPC's here (just one interaction with Rugged).
+
module Gitlab
module Git
class Ref
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
# Branch or tag name
# without "refs/tags|heads" prefix
@@ -24,16 +26,16 @@ module Gitlab
str.gsub(/\Arefs\/heads\//, '')
end
+ # Gitaly: this method will probably be migrated indirectly via its call sites.
def self.dereference_object(object)
object = object.target while object.is_a?(Rugged::Tag::Annotation)
object
end
- def initialize(repository, name, target)
- encode! name
- @name = name.gsub(/\Arefs\/(tags|heads)\//, '')
- @dereferenced_target = Gitlab::Git::Commit.find(repository, target)
+ def initialize(repository, name, target, derefenced_target)
+ @name = Gitlab::Git.ref_name(name)
+ @dereferenced_target = derefenced_target
@target = if target.respond_to?(:oid)
target.oid
elsif target.respond_to?(:name)
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 452dba7971d..1d5ca68137a 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -18,6 +18,28 @@ module Gitlab
InvalidBlobName = Class.new(StandardError)
InvalidRef = Class.new(StandardError)
+ class << self
+ # Unlike `new`, `create` takes the storage path, not the storage name
+ def create(storage_path, name, bare: true, symlink_hooks_to: nil)
+ repo_path = File.join(storage_path, name)
+ repo_path += '.git' unless repo_path.end_with?('.git')
+
+ FileUtils.mkdir_p(repo_path, mode: 0770)
+
+ # Equivalent to `git --git-path=#{repo_path} init [--bare]`
+ repo = Rugged::Repository.init_at(repo_path, bare)
+ repo.close
+
+ if symlink_hooks_to.present?
+ hooks_path = File.join(repo_path, 'hooks')
+ FileUtils.rm_rf(hooks_path)
+ FileUtils.ln_s(symlink_hooks_to, hooks_path)
+ end
+
+ true
+ end
+ end
+
# Full path to repo
attr_reader :path
@@ -27,13 +49,15 @@ module Gitlab
# Rugged repo object
attr_reader :rugged
+ attr_reader :storage
+
# 'path' must be the path to a _bare_ git repository, e.g.
# /path/to/my-repo.git
- def initialize(repository_storage, relative_path)
- @repository_storage = repository_storage
+ def initialize(storage, relative_path)
+ @storage = storage
@relative_path = relative_path
- storage_path = Gitlab.config.repositories.storages[@repository_storage]['path']
+ storage_path = Gitlab.config.repositories.storages[@storage]['path']
@path = File.join(storage_path, @relative_path)
@name = @relative_path.split("/").last
@attributes = Gitlab::Git::Attributes.new(path)
@@ -43,57 +67,52 @@ module Gitlab
:bare?,
to: :rugged
+ delegate :exists?, to: :gitaly_repository_client
+
# Default branch in the repository
def root_ref
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved
- # @root_ref ||= Gitlab::GitalyClient.migrate(:root_ref) do |is_enabled|
- # if is_enabled
- # gitaly_ref_client.default_branch_name
- # else
- @root_ref ||= discover_default_branch
- # end
- # end
- rescue GRPC::BadStatus => e
- raise CommandError.new(e)
- end
-
- # Alias to old method for compatibility
- def raw
- rugged
+ @root_ref ||= gitaly_migrate(:root_ref) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.default_branch_name
+ else
+ discover_default_branch
+ end
+ end
end
def rugged
- @rugged ||= Rugged::Repository.new(path, alternates: alternate_object_directories)
+ @rugged ||= circuit_breaker.perform do
+ Rugged::Repository.new(path, alternates: alternate_object_directories)
+ end
rescue Rugged::RepositoryError, Rugged::OSError
raise NoRepository.new('no repository for such path')
end
+ def circuit_breaker
+ @circuit_breaker ||= Gitlab::Git::Storage::CircuitBreaker.for_storage(storage)
+ end
+
# Returns an Array of branch names
# sorted by name ASC
def branch_names
- # Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled|
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved
- # if is_enabled
- # gitaly_ref_client.branch_names
- # else
- branches.map(&:name)
- # end
- # end
- rescue GRPC::BadStatus => e
- raise CommandError.new(e)
+ gitaly_migrate(:branch_names) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.branch_names
+ else
+ branches.map(&:name)
+ end
+ end
end
# Returns an Array of Branches
def branches
- rugged.branches.map do |rugged_ref|
- begin
- Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target)
- rescue Rugged::ReferenceError
- # Omit invalid branch
+ gitaly_migrate(:branches) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.branches
+ else
+ branches_filter
end
- end.compact.sort_by(&:name)
+ end
end
def reload_rugged
@@ -111,58 +130,74 @@ module Gitlab
reload_rugged if force_reload
rugged_ref = rugged.branches[name]
- Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target) if rugged_ref
+ if rugged_ref
+ target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target)
+ Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit)
+ end
end
- def local_branches
- rugged.branches.each(:local).map do |branch|
- Gitlab::Git::Branch.new(self, branch.name, branch.target)
+ def local_branches(sort_by: nil)
+ gitaly_migrate(:local_branches) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.local_branches(sort_by: sort_by)
+ else
+ branches_filter(filter: :local, sort_by: sort_by)
+ end
end
end
# Returns the number of valid branches
def branch_count
- rugged.branches.count do |ref|
- begin
- ref.name && ref.target # ensures the branch is valid
+ gitaly_migrate(:branch_names) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.count_branch_names
+ else
+ rugged.branches.count do |ref|
+ begin
+ ref.name && ref.target # ensures the branch is valid
- true
- rescue Rugged::ReferenceError
- false
+ true
+ rescue Rugged::ReferenceError
+ false
+ end
+ end
+ end
+ end
+ end
+
+ # Returns the number of valid tags
+ def tag_count
+ gitaly_migrate(:tag_names) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.count_tag_names
+ else
+ rugged.tags.count
end
end
end
# Returns an Array of tag names
def tag_names
- # Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled|
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitaly/issues/179 is resolved
- # if is_enabled
- # gitaly_ref_client.tag_names
- # else
- rugged.tags.map { |t| t.name }
- # end
- # end
- rescue GRPC::BadStatus => e
- raise CommandError.new(e)
+ gitaly_migrate(:tag_names) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.tag_names
+ else
+ rugged.tags.map { |t| t.name }
+ end
+ end
end
# Returns an Array of Tags
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/390
def tags
- rugged.references.each("refs/tags/*").map do |ref|
- message = nil
-
- if ref.target.is_a?(Rugged::Tag::Annotation)
- tag_message = ref.target.message
-
- if tag_message.respond_to?(:chomp)
- message = tag_message.chomp
- end
+ gitaly_migrate(:tags) do |is_enabled|
+ if is_enabled
+ tags_from_gitaly
+ else
+ tags_from_rugged
end
-
- Gitlab::Git::Tag.new(self, ref.name, ref.target, message)
- end.sort_by(&:name)
+ end
end
# Returns true if the given tag exists
@@ -191,21 +226,10 @@ module Gitlab
branch_names + tag_names
end
- # Deprecated. Will be removed in 5.2
- def heads
- rugged.references.each("refs/heads/*").map do |head|
- Gitlab::Git::Ref.new(self, head.name, head.target)
- end.sort_by(&:name)
- end
-
def has_commits?
!empty?
end
- def repo_exists?
- !!rugged
- end
-
# Discovers the default branch based on the repository's available branches
#
# - If no branches are present, returns nil
@@ -253,7 +277,7 @@ module Gitlab
'RepoPath' => path,
'ArchivePrefix' => prefix,
'ArchivePath' => archive_file_path(prefix, storage_path, format),
- 'CommitId' => commit.id,
+ 'CommitId' => commit.id
}
end
@@ -280,30 +304,15 @@ module Gitlab
# Return repo size in megabytes
def size
- size = popen(%w(du -sk), path).first.strip.to_i
- (size.to_f / 1024).round(2)
- end
-
- # Returns an array of BlobSnippets for files at the specified +ref+ that
- # contain the +query+ string.
- def search_files(query, ref = nil)
- greps = []
- ref ||= root_ref
-
- populated_index(ref).each do |entry|
- # Discard submodules
- next if submodule?(entry)
-
- blob = Gitlab::Git::Blob.raw(self, entry[:oid])
-
- # Skip binary files
- next if blob.data.encoding == Encoding::ASCII_8BIT
-
- blob.load_all_data!(self)
- greps += build_greps(blob.data, query, ref, entry[:path])
+ size = gitaly_migrate(:repository_size) do |is_enabled|
+ if is_enabled
+ size_by_gitaly
+ else
+ size_by_shelling_out
+ end
end
- greps
+ (size.to_f / 1024).round(2)
end
# Use the Rugged Walker API to build an array of commits.
@@ -317,6 +326,7 @@ module Gitlab
# after: Time.new(2016, 4, 21, 14, 32, 10)
# )
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/446
def log(options)
default_options = {
limit: 10,
@@ -332,6 +342,12 @@ module Gitlab
options = default_options.merge(options)
options[:limit] ||= 0
options[:offset] ||= 0
+
+ raw_log(options).map { |c| Commit.decorate(self, c) }
+ end
+
+ # Used in gitaly-ruby
+ def raw_log(options)
actual_ref = options[:ref] || root_ref
begin
sha = sha_from_ref(actual_ref)
@@ -347,66 +363,14 @@ module Gitlab
end
end
- def log_using_shell?(options)
- options[:path].present? ||
- options[:disable_walk] ||
- options[:skip_merges] ||
- options[:after] ||
- options[:before]
- end
-
- def log_by_walk(sha, options)
- walk_options = {
- show: sha,
- sort: Rugged::SORT_NONE,
- limit: options[:limit],
- offset: options[:offset]
- }
- Rugged::Walker.walk(rugged, walk_options).to_a
- end
-
- def log_by_shell(sha, options)
- limit = options[:limit].to_i
- offset = options[:offset].to_i
- use_follow_flag = options[:follow] && options[:path].present?
-
- # We will perform the offset in Ruby because --follow doesn't play well with --skip.
- # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/3574#note_3040520
- offset_in_ruby = use_follow_flag && options[:offset].present?
- limit += offset if offset_in_ruby
-
- cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} log]
- cmd << "--max-count=#{limit}"
- cmd << '--format=%H'
- cmd << "--skip=#{offset}" unless offset_in_ruby
- cmd << '--follow' if use_follow_flag
- cmd << '--no-merges' if options[:skip_merges]
- cmd << "--after=#{options[:after].iso8601}" if options[:after]
- cmd << "--before=#{options[:before].iso8601}" if options[:before]
- cmd << sha
-
- # :path can be a string or an array of strings
- if options[:path].present?
- cmd << '--'
- cmd += Array(options[:path])
- end
-
- raw_output = IO.popen(cmd) { |io| io.read }
- lines = offset_in_ruby ? raw_output.lines.drop(offset) : raw_output.lines
-
- lines.map! { |c| Rugged::Commit.new(rugged, c.strip) }
- end
-
def count_commits(options)
- cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} rev-list]
- cmd << "--after=#{options[:after].iso8601}" if options[:after]
- cmd << "--before=#{options[:before].iso8601}" if options[:before]
- cmd += %W[--count #{options[:ref]}]
- cmd += %W[-- #{options[:path]}] if options[:path].present?
-
- raw_output = IO.popen(cmd) { |io| io.read }
-
- raw_output.to_i
+ gitaly_migrate(:count_commits) do |is_enabled|
+ if is_enabled
+ count_commits_by_gitaly(options)
+ else
+ count_commits_by_shelling_out(options)
+ end
+ end
end
def sha_from_ref(ref)
@@ -423,7 +387,9 @@ module Gitlab
# Return a collection of Rugged::Commits between the two revspec arguments.
# See http://git-scm.com/docs/git-rev-parse.html#_specifying_revisions for
# a detailed list of valid arguments.
- def commits_between(from, to)
+ #
+ # Gitaly note: JV: to be deprecated in favor of Commit.between
+ def rugged_commits_between(from, to)
walker = Rugged::Walker.new(rugged)
walker.sorting(Rugged::SORT_NONE | Rugged::SORT_REVERSE)
@@ -441,7 +407,7 @@ module Gitlab
# Counts the amount of commits between `from` and `to`.
def count_commits_between(from, to)
- commits_between(from, to).size
+ Commit.between(self, from, to).size
end
# Returns the SHA of the most recent common ancestor of +from+ and +to+
@@ -449,9 +415,16 @@ module Gitlab
rugged.merge_base(from, to)
end
+ # Gitaly note: JV: check gitlab-ee before removing this method.
+ def rugged_is_ancestor?(ancestor_id, descendant_id)
+ return false if ancestor_id.nil? || descendant_id.nil?
+
+ merge_base_commit(ancestor_id, descendant_id) == ancestor_id
+ end
+
# Returns true is +from+ is direct ancestor to +to+, otherwise false
def is_ancestor?(from, to)
- Gitlab::GitalyClient::Commit.is_ancestor(self, from, to)
+ gitaly_commit_client.is_ancestor(from, to)
end
# Return an array of Diff objects that represent the diff
@@ -464,82 +437,19 @@ module Gitlab
# Returns a RefName for a given SHA
def ref_name_for_sha(ref_path, sha)
- # NOTE: This feature is intentionally disabled until
- # https://gitlab.com/gitlab-org/gitaly/issues/180 is resolved
- # Gitlab::GitalyClient.migrate(:find_ref_name) do |is_enabled|
- # if is_enabled
- # gitaly_ref_client.find_ref_name(sha, ref_path)
- # else
- args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
-
- # Not found -> ["", 0]
- # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
- Gitlab::Popen.popen(args, @path).first.split.last
- # end
- # end
- end
-
- # Returns commits collection
- #
- # Ex.
- # repo.find_commits(
- # ref: 'master',
- # max_count: 10,
- # skip: 5,
- # order: :date
- # )
- #
- # +options+ is a Hash of optional arguments to git
- # :ref is the ref from which to begin (SHA1 or name)
- # :contains is the commit contained by the refs from which to begin (SHA1 or name)
- # :max_count is the maximum number of commits to fetch
- # :skip is the number of commits to skip
- # :order is the commits order and allowed value is :none (default), :date, or :topo
- # commit ordering types are documented here:
- # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant)
- #
- def find_commits(options = {})
- actual_options = options.dup
-
- allowed_options = [:ref, :max_count, :skip, :contains, :order]
-
- actual_options.keep_if do |key|
- allowed_options.include?(key)
- end
-
- default_options = { skip: 0 }
- actual_options = default_options.merge(actual_options)
+ raise ArgumentError, "sha can't be empty" unless sha.present?
- walker = Rugged::Walker.new(rugged)
+ gitaly_migrate(:find_ref_name) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.find_ref_name(sha, ref_path)
+ else
+ args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
- if actual_options[:ref]
- walker.push(rugged.rev_parse_oid(actual_options[:ref]))
- elsif actual_options[:contains]
- branches_contains(actual_options[:contains]).each do |branch|
- walker.push(branch.target_id)
- end
- else
- rugged.references.each("refs/heads/*") do |ref|
- walker.push(ref.target_id)
+ # Not found -> ["", 0]
+ # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
+ Gitlab::Popen.popen(args, @path).first.split.last
end
end
-
- sort_type = rugged_sort_type(actual_options[:order])
- walker.sorting(sort_type)
-
- commits = []
- offset = actual_options[:skip]
- limit = actual_options[:max_count]
- walker.each(offset: offset, limit: limit) do |commit|
- gitlab_commit = Gitlab::Git::Commit.decorate(commit)
- commits.push(gitlab_commit)
- end
-
- walker.reset
-
- commits
- rescue Rugged::OdbError
- []
end
# Returns branch names collection that contains the special commit(SHA1
@@ -597,56 +507,43 @@ module Gitlab
rugged.rev_parse(oid_or_ref_name)
end
- # Return hash with submodules info for this repository
+ # Returns url for submodule
#
# Ex.
- # {
- # "rack" => {
- # "id" => "c67be4624545b4263184c4a0e8f887efd0a66320",
- # "path" => "rack",
- # "url" => "git://github.com/chneukirchen/rack.git"
- # },
- # "encoding" => {
- # "id" => ....
- # }
- # }
- #
- def submodules(ref)
- commit = rev_parse_target(ref)
- return {} unless commit
-
- begin
- content = blob_content(commit, ".gitmodules")
- rescue InvalidBlobName
- return {}
+ # @repository.submodule_url_for('master', 'rack')
+ # # => git@localhost:rack.git
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/329
+ def submodule_url_for(ref, path)
+ Gitlab::GitalyClient.migrate(:submodule_url_for) do |is_enabled|
+ if is_enabled
+ gitaly_submodule_url_for(ref, path)
+ else
+ if submodules(ref).any?
+ submodule = submodules(ref)[path]
+ submodule['url'] if submodule
+ end
+ end
end
-
- parse_gitmodules(commit, content)
end
# Return total commits count accessible from passed ref
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/330
def commit_count(ref)
- walker = Rugged::Walker.new(rugged)
- walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE)
- oid = rugged.rev_parse_oid(ref)
- walker.push(oid)
- walker.count
+ gitaly_migrate(:commit_count) do |is_enabled|
+ if is_enabled
+ gitaly_commit_client.commit_count(ref)
+ else
+ walker = Rugged::Walker.new(rugged)
+ walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE)
+ oid = rugged.rev_parse_oid(ref)
+ walker.push(oid)
+ walker.count
+ end
+ end
end
- # Sets HEAD to the commit specified by +ref+; +ref+ can be a branch or
- # tag name or a commit SHA. Valid +reset_type+ values are:
- #
- # [:soft]
- # the head will be moved to the commit.
- # [:mixed]
- # will trigger a +:soft+ reset, plus the index will be replaced
- # with the content of the commit tree.
- # [:hard]
- # will trigger a +:mixed+ reset and the working directory will be
- # replaced with the content of the index. (Untracked and ignored files
- # will be left alone)
- delegate :reset, to: :rugged
-
# Mimic the `git clean` command and recursively delete untracked files.
# Valid keys that can be passed in the +options+ hash are:
#
@@ -671,154 +568,6 @@ module Gitlab
# TODO: implement this method
end
- # Check out the specified ref. Valid options are:
- #
- # :b - Create a new branch at +start_point+ and set HEAD to the new
- # branch.
- #
- # * These options are passed to the Rugged::Repository#checkout method:
- #
- # :progress ::
- # A callback that will be executed for checkout progress notifications.
- # Up to 3 parameters are passed on each execution:
- #
- # - The path to the last updated file (or +nil+ on the very first
- # invocation).
- # - The number of completed checkout steps.
- # - The number of total checkout steps to be performed.
- #
- # :notify ::
- # A callback that will be executed for each checkout notification
- # types specified with +:notify_flags+. Up to 5 parameters are passed
- # on each execution:
- #
- # - An array containing the +:notify_flags+ that caused the callback
- # execution.
- # - The path of the current file.
- # - A hash describing the baseline blob (or +nil+ if it does not
- # exist).
- # - A hash describing the target blob (or +nil+ if it does not exist).
- # - A hash describing the workdir blob (or +nil+ if it does not
- # exist).
- #
- # :strategy ::
- # A single symbol or an array of symbols representing the strategies
- # to use when performing the checkout. Possible values are:
- #
- # :none ::
- # Perform a dry run (default).
- #
- # :safe ::
- # Allow safe updates that cannot overwrite uncommitted data.
- #
- # :safe_create ::
- # Allow safe updates plus creation of missing files.
- #
- # :force ::
- # Allow all updates to force working directory to look like index.
- #
- # :allow_conflicts ::
- # Allow checkout to make safe updates even if conflicts are found.
- #
- # :remove_untracked ::
- # Remove untracked files not in index (that are not ignored).
- #
- # :remove_ignored ::
- # Remove ignored files not in index.
- #
- # :update_only ::
- # Only update existing files, don't create new ones.
- #
- # :dont_update_index ::
- # Normally checkout updates index entries as it goes; this stops
- # that.
- #
- # :no_refresh ::
- # Don't refresh index/config/etc before doing checkout.
- #
- # :disable_pathspec_match ::
- # Treat pathspec as simple list of exact match file paths.
- #
- # :skip_locked_directories ::
- # Ignore directories in use, they will be left empty.
- #
- # :skip_unmerged ::
- # Allow checkout to skip unmerged files (NOT IMPLEMENTED).
- #
- # :use_ours ::
- # For unmerged files, checkout stage 2 from index (NOT IMPLEMENTED).
- #
- # :use_theirs ::
- # For unmerged files, checkout stage 3 from index (NOT IMPLEMENTED).
- #
- # :update_submodules ::
- # Recursively checkout submodules with same options (NOT
- # IMPLEMENTED).
- #
- # :update_submodules_if_changed ::
- # Recursively checkout submodules if HEAD moved in super repo (NOT
- # IMPLEMENTED).
- #
- # :disable_filters ::
- # If +true+, filters like CRLF line conversion will be disabled.
- #
- # :dir_mode ::
- # Mode for newly created directories. Default: +0755+.
- #
- # :file_mode ::
- # Mode for newly created files. Default: +0755+ or +0644+.
- #
- # :file_open_flags ::
- # Mode for opening files. Default:
- # <code>IO::CREAT | IO::TRUNC | IO::WRONLY</code>.
- #
- # :notify_flags ::
- # A single symbol or an array of symbols representing the cases in
- # which the +:notify+ callback should be invoked. Possible values are:
- #
- # :none ::
- # Do not invoke the +:notify+ callback (default).
- #
- # :conflict ::
- # Invoke the callback for conflicting paths.
- #
- # :dirty ::
- # Invoke the callback for "dirty" files, i.e. those that do not need
- # an update but no longer match the baseline.
- #
- # :updated ::
- # Invoke the callback for any file that was changed.
- #
- # :untracked ::
- # Invoke the callback for untracked files.
- #
- # :ignored ::
- # Invoke the callback for ignored files.
- #
- # :all ::
- # Invoke the callback for all these cases.
- #
- # :paths ::
- # A glob string or an array of glob strings specifying which paths
- # should be taken into account for the checkout operation. +nil+ will
- # match all files. Default: +nil+.
- #
- # :baseline ::
- # A Rugged::Tree that represents the current, expected contents of the
- # workdir. Default: +HEAD+.
- #
- # :target_directory ::
- # A path to an alternative workdir directory in which the checkout
- # should be performed.
- def checkout(ref, options = {}, start_point = "HEAD")
- if options[:b]
- rugged.branches.create(ref, start_point)
- options.delete(:b)
- end
- default_options = { strategy: [:recreate_missing, :safe] }
- rugged.checkout(ref, default_options.merge(options))
- end
-
# Delete the specified branch from the repository
def delete_branch(branch_name)
rugged.branches.delete(branch_name)
@@ -831,7 +580,8 @@ module Gitlab
# create_branch("other-feature", "master")
def create_branch(ref, start_point = "HEAD")
rugged_ref = rugged.branches.create(ref, start_point)
- Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target)
+ target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target)
+ Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit)
rescue Rugged::ReferenceError => e
raise InvalidRef.new("Branch #{ref} already exists") if e.to_s =~ /'refs\/heads\/#{ref}'/
raise InvalidRef.new("Invalid reference #{start_point}")
@@ -871,27 +621,6 @@ module Gitlab
rugged.remotes[remote_name].push(refspecs)
end
- # Merge the +source_name+ branch into the +target_name+ branch. This is
- # equivalent to `git merge --no_ff +source_name+`, since a merge commit
- # is always created.
- def merge(source_name, target_name, options = {})
- our_commit = rugged.branches[target_name].target
- their_commit = rugged.branches[source_name].target
-
- raise "Invalid merge target" if our_commit.nil?
- raise "Invalid merge source" if their_commit.nil?
-
- merge_index = rugged.merge_commits(our_commit, their_commit)
- return false if merge_index.conflicts?
-
- actual_options = options.merge(
- parents: [our_commit, their_commit],
- tree: merge_index.write_tree(rugged),
- update_ref: "refs/heads/#{target_name}"
- )
- Rugged::Commit.create(rugged, actual_options)
- end
-
AUTOCRLF_VALUES = {
"true" => true,
"false" => false,
@@ -911,32 +640,18 @@ module Gitlab
# Ex.
# repo.ls_files('master')
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/327
def ls_files(ref)
- actual_ref = ref || root_ref
-
- begin
- sha_from_ref(actual_ref)
- rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError
- # Return an empty array if the ref wasn't found
- return []
- end
-
- cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} ls-tree)
- cmd += %w(-r)
- cmd += %w(--full-tree)
- cmd += %w(--full-name)
- cmd += %W(-- #{actual_ref})
-
- raw_output = IO.popen(cmd, &:read).split("\n").map do |f|
- stuff, path = f.split("\t")
- _mode, type, _sha = stuff.split(" ")
- path if type == "blob"
- # Contain only blob type
+ gitaly_migrate(:ls_files) do |is_enabled|
+ if is_enabled
+ gitaly_ls_files(ref)
+ else
+ git_ls_files(ref)
+ end
end
-
- raw_output.compact
end
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/328
def copy_gitattributes(ref)
begin
commit = lookup(ref)
@@ -967,11 +682,6 @@ module Gitlab
end
end
- # Checks if the blob should be diffable according to its attributes
- def diffable?(blob)
- attributes(blob.path).fetch('diff') { blob.text? }
- end
-
# Returns the Git attributes for the given file path.
#
# See `Gitlab::Git::Attributes` for more information.
@@ -979,16 +689,157 @@ module Gitlab
@attributes.attributes(path)
end
+ def languages(ref = nil)
+ Gitlab::GitalyClient.migrate(:commit_languages) do |is_enabled|
+ if is_enabled
+ gitaly_commit_client.languages(ref)
+ else
+ ref ||= rugged.head.target_id
+ languages = Linguist::Repository.new(rugged, ref).languages
+ total = languages.map(&:last).sum
+
+ languages = languages.map do |language|
+ name, share = language
+ color = Linguist::Language[name].color || "##{Digest::SHA256.hexdigest(name)[0...6]}"
+ {
+ value: (share.to_f * 100 / total).round(2),
+ label: name,
+ color: color,
+ highlight: color
+ }
+ end
+
+ languages.sort do |x, y|
+ y[:value] <=> x[:value]
+ end
+ end
+ end
+ end
+
def gitaly_repository
- Gitlab::GitalyClient::Util.repository(@repository_storage, @relative_path)
+ Gitlab::GitalyClient::Util.repository(@storage, @relative_path)
+ end
+
+ def gitaly_ref_client
+ @gitaly_ref_client ||= Gitlab::GitalyClient::RefService.new(self)
+ end
+
+ def gitaly_commit_client
+ @gitaly_commit_client ||= Gitlab::GitalyClient::CommitService.new(self)
end
- def gitaly_channel
- Gitlab::GitalyClient.get_channel(@repository_storage)
+ def gitaly_repository_client
+ @gitaly_repository_client ||= Gitlab::GitalyClient::RepositoryService.new(self)
+ end
+
+ def gitaly_migrate(method, &block)
+ Gitlab::GitalyClient.migrate(method, &block)
+ rescue GRPC::NotFound => e
+ raise NoRepository.new(e)
+ rescue GRPC::BadStatus => e
+ raise CommandError.new(e)
end
private
+ # Gitaly note: JV: Trying to get rid of the 'filter' option so we can implement this with 'git'.
+ def branches_filter(filter: nil, sort_by: nil)
+ branches = rugged.branches.each(filter).map do |rugged_ref|
+ begin
+ target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target)
+ Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit)
+ rescue Rugged::ReferenceError
+ # Omit invalid branch
+ end
+ end.compact
+
+ sort_branches(branches, sort_by)
+ end
+
+ def log_using_shell?(options)
+ options[:path].present? ||
+ options[:disable_walk] ||
+ options[:skip_merges] ||
+ options[:after] ||
+ options[:before]
+ end
+
+ def log_by_walk(sha, options)
+ walk_options = {
+ show: sha,
+ sort: Rugged::SORT_NONE,
+ limit: options[:limit],
+ offset: options[:offset]
+ }
+ Rugged::Walker.walk(rugged, walk_options).to_a
+ end
+
+ # Gitaly note: JV: although #log_by_shell shells out to Git I think the
+ # complexity is such that we should migrate it as Ruby before trying to
+ # do it in Go.
+ def log_by_shell(sha, options)
+ limit = options[:limit].to_i
+ offset = options[:offset].to_i
+ use_follow_flag = options[:follow] && options[:path].present?
+
+ # We will perform the offset in Ruby because --follow doesn't play well with --skip.
+ # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/3574#note_3040520
+ offset_in_ruby = use_follow_flag && options[:offset].present?
+ limit += offset if offset_in_ruby
+
+ cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} log]
+ cmd << "--max-count=#{limit}"
+ cmd << '--format=%H'
+ cmd << "--skip=#{offset}" unless offset_in_ruby
+ cmd << '--follow' if use_follow_flag
+ cmd << '--no-merges' if options[:skip_merges]
+ cmd << "--after=#{options[:after].iso8601}" if options[:after]
+ cmd << "--before=#{options[:before].iso8601}" if options[:before]
+ cmd << sha
+
+ # :path can be a string or an array of strings
+ if options[:path].present?
+ cmd << '--'
+ cmd += Array(options[:path])
+ end
+
+ raw_output = IO.popen(cmd) { |io| io.read }
+ lines = offset_in_ruby ? raw_output.lines.drop(offset) : raw_output.lines
+
+ lines.map! { |c| Rugged::Commit.new(rugged, c.strip) }
+ end
+
+ # We are trying to deprecate this method because it does a lot of work
+ # but it seems to be used only to look up submodule URL's.
+ # https://gitlab.com/gitlab-org/gitaly/issues/329
+ def submodules(ref)
+ commit = rev_parse_target(ref)
+ return {} unless commit
+
+ begin
+ content = blob_content(commit, ".gitmodules")
+ rescue InvalidBlobName
+ return {}
+ end
+
+ parser = GitmodulesParser.new(content)
+ fill_submodule_ids(commit, parser.parse)
+ end
+
+ def gitaly_submodule_url_for(ref, path)
+ # We don't care about the contents so 1 byte is enough. Can't request 0 bytes, 0 means unlimited.
+ commit_object = gitaly_commit_client.tree_entry(ref, path, 1)
+
+ return unless commit_object && commit_object.type == :COMMIT
+
+ gitmodules = gitaly_commit_client.tree_entry(ref, '.gitmodules', Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
+ return unless gitmodules
+
+ found_module = GitmodulesParser.new(gitmodules.data).parse[path]
+
+ found_module && found_module['url']
+ end
+
def alternate_object_directories
Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES).compact
end
@@ -1012,74 +863,19 @@ module Gitlab
end
end
- # Parses the contents of a .gitmodules file and returns a hash of
- # submodule information.
- def parse_gitmodules(commit, content)
- results = {}
-
- current = ""
- content.split("\n").each do |txt|
- if txt =~ /^\s*\[/
- current = txt.match(/(?<=").*(?=")/)[0]
- results[current] = {}
- else
- next unless results[current]
- match_data = txt.match(/(\w+)\s*=\s*(.*)/)
- next unless match_data
- target = match_data[2].chomp
- results[current][match_data[1]] = target
-
- if match_data[1] == "path"
- begin
- results[current]["id"] = blob_content(commit, target)
- rescue InvalidBlobName
- results.delete(current)
- end
- end
+ # Fill in the 'id' field of a submodule hash from its values
+ # as-of +commit+. Return a Hash consisting only of entries
+ # from the submodule hash for which the 'id' field is filled.
+ def fill_submodule_ids(commit, submodule_data)
+ submodule_data.each do |path, data|
+ id = begin
+ blob_content(commit, path)
+ rescue InvalidBlobName
+ nil
end
+ data['id'] = id
end
-
- results
- end
-
- # Returns true if +commit+ introduced changes to +path+, using commit
- # trees to make that determination. Uses the history simplification
- # rules that `git log` uses by default, where a commit is omitted if it
- # is TREESAME to any parent.
- #
- # If the +follow+ option is true and the file specified by +path+ was
- # renamed, then the path value is set to the old path.
- def commit_touches_path?(commit, path, follow, walker)
- entry = tree_entry(commit, path)
-
- if commit.parents.empty?
- # This is the root commit, return true if it has +path+ in its tree
- return !entry.nil?
- end
-
- num_treesame = 0
- commit.parents.each do |parent|
- parent_entry = tree_entry(parent, path)
-
- # Only follow the first TREESAME parent for merge commits
- if num_treesame > 0
- walker.hide(parent)
- next
- end
-
- if entry.nil? && parent_entry.nil?
- num_treesame += 1
- elsif entry && parent_entry && entry[:oid] == parent_entry[:oid]
- num_treesame += 1
- end
- end
-
- case num_treesame
- when 0
- detect_rename(commit, commit.parents.first, path) if follow
- true
- else false
- end
+ submodule_data.select { |path, data| data['id'] }
end
# Find the entry for +path+ in the tree for +commit+
@@ -1095,7 +891,12 @@ module Gitlab
elsif tmp_entry.nil?
return nil
else
- tmp_entry = rugged.lookup(tmp_entry[:oid])
+ begin
+ tmp_entry = rugged.lookup(tmp_entry[:oid])
+ rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError
+ return nil
+ end
+
return nil unless tmp_entry.type == :tree
tmp_entry = tmp_entry[dir]
end
@@ -1104,185 +905,112 @@ module Gitlab
tmp_entry
end
- # Compare +commit+ and +parent+ for +path+. If +path+ is a file and was
- # renamed in +commit+, then set +path+ to the old filename.
- def detect_rename(commit, parent, path)
- diff = parent.diff(commit, paths: [path], disable_pathspec_match: true)
-
- # If +path+ is a filename, not a directory, then we should only have
- # one delta. We don't need to follow renames for directories.
- return nil if diff.each_delta.count > 1
+ # Return the Rugged patches for the diff between +from+ and +to+.
+ def diff_patches(from, to, options = {}, *paths)
+ options ||= {}
+ break_rewrites = options[:break_rewrites]
+ actual_options = Gitlab::Git::Diff.filter_diff_options(options.merge(paths: paths))
- delta = diff.each_delta.first
- if delta.added?
- full_diff = parent.diff(commit)
- full_diff.find_similar!
+ diff = rugged.diff(from, to, actual_options)
+ diff.find_similar!(break_rewrites: break_rewrites)
+ diff.each_patch
+ end
- full_diff.each_delta do |full_delta|
- if full_delta.renamed? && path == full_delta.new_file[:path]
- # Look for the old path in ancestors
- path.replace(full_delta.old_file[:path])
- end
+ def sort_branches(branches, sort_by)
+ case sort_by
+ when 'name'
+ branches.sort_by(&:name)
+ when 'updated_desc'
+ branches.sort do |a, b|
+ b.dereferenced_target.committed_date <=> a.dereferenced_target.committed_date
+ end
+ when 'updated_asc'
+ branches.sort do |a, b|
+ a.dereferenced_target.committed_date <=> b.dereferenced_target.committed_date
end
+ else
+ branches
end
end
- def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n))
- git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive)
-
- # Put files into a directory before archiving
- prefix = "#{archive_name(treeish)}/"
- git_archive_cmd << "--prefix=#{prefix}"
-
- # Format defaults to tar
- git_archive_cmd << "--format=#{format}" if format
-
- git_archive_cmd += %W(-- #{treeish})
-
- open(filename, 'w') do |file|
- # Create a pipe to act as the '|' in 'git archive ... | gzip'
- pipe_rd, pipe_wr = IO.pipe
+ def tags_from_rugged
+ rugged.references.each("refs/tags/*").map do |ref|
+ message = nil
- # Get the compression process ready to accept data from the read end
- # of the pipe
- compress_pid = spawn(*nice(compress_cmd), in: pipe_rd, out: file)
- # The read end belongs to the compression process now; we should
- # close our file descriptor for it.
- pipe_rd.close
+ if ref.target.is_a?(Rugged::Tag::Annotation)
+ tag_message = ref.target.message
- # Start 'git archive' and tell it to write into the write end of the
- # pipe.
- git_archive_pid = spawn(*nice(git_archive_cmd), out: pipe_wr)
- # The write end belongs to 'git archive' now; close it.
- pipe_wr.close
+ if tag_message.respond_to?(:chomp)
+ message = tag_message.chomp
+ end
+ end
- # When 'git archive' and the compression process are finished, we are
- # done.
- Process.waitpid(git_archive_pid)
- raise "#{git_archive_cmd.join(' ')} failed" unless $?.success?
- Process.waitpid(compress_pid)
- raise "#{compress_cmd.join(' ')} failed" unless $?.success?
- end
+ target_commit = Gitlab::Git::Commit.find(self, ref.target)
+ Gitlab::Git::Tag.new(self, ref.name, ref.target, target_commit, message)
+ end.sort_by(&:name)
end
- def nice(cmd)
- nice_cmd = %w(nice -n 20)
- unless unsupported_platform?
- nice_cmd += %w(ionice -c 2 -n 7)
- end
- nice_cmd + cmd
- end
-
- def unsupported_platform?
- %w[darwin freebsd solaris].map { |platform| RUBY_PLATFORM.include?(platform) }.any?
- end
-
- # Returns true if the index entry has the special file mode that denotes
- # a submodule.
- def submodule?(index_entry)
- index_entry[:mode] == 57344
- end
-
- # Return a Rugged::Index that has read from the tree at +ref_name+
- def populated_index(ref_name)
- commit = rev_parse_target(ref_name)
- index = rugged.index
- index.read_tree(commit.tree)
- index
- end
-
- # Return an array of BlobSnippets for lines in +file_contents+ that match
- # +query+
- def build_greps(file_contents, query, ref, filename)
- # The file_contents string is potentially huge so we make sure to loop
- # through it one line at a time. This gives Ruby the chance to GC lines
- # we are not interested in.
- #
- # We need to do a little extra work because we are not looking for just
- # the lines that matches the query, but also for the context
- # (surrounding lines). We will use Enumerable#each_cons to efficiently
- # loop through the lines while keeping surrounding lines on hand.
- #
- # First, we turn "foo\nbar\nbaz" into
- # [
- # [nil, -3], [nil, -2], [nil, -1],
- # ['foo', 0], ['bar', 1], ['baz', 3],
- # [nil, 4], [nil, 5], [nil, 6]
- # ]
- lines_with_index = Enumerator.new do |yielder|
- # Yield fake 'before' lines for the first line of file_contents
- (-SEARCH_CONTEXT_LINES..-1).each do |i|
- yielder.yield [nil, i]
- end
-
- # Yield the actual file contents
- count = 0
- file_contents.each_line do |line|
- line.chomp!
- yielder.yield [line, count]
- count += 1
- end
+ def last_commit_for_path_by_rugged(sha, path)
+ sha = last_commit_id_for_path(sha, path)
+ commit(sha)
+ end
- # Yield fake 'after' lines for the last line of file_contents
- (count + 1..count + SEARCH_CONTEXT_LINES).each do |i|
- yielder.yield [nil, i]
- end
- end
+ def tags_from_gitaly
+ gitaly_ref_client.tags
+ end
- greps = []
+ def size_by_shelling_out
+ popen(%w(du -sk), path).first.strip.to_i
+ end
- # Loop through consecutive blocks of lines with indexes
- lines_with_index.each_cons(2 * SEARCH_CONTEXT_LINES + 1) do |line_block|
- # Get the 'middle' line and index from the block
- line, _ = line_block[SEARCH_CONTEXT_LINES]
+ def size_by_gitaly
+ gitaly_repository_client.repository_size
+ end
- next unless line && line.match(/#{Regexp.escape(query)}/i)
+ def count_commits_by_gitaly(options)
+ gitaly_commit_client.commit_count(options[:ref], options)
+ end
- # Yay, 'line' contains a match!
- # Get an array with just the context lines (no indexes)
- match_with_context = line_block.map(&:first)
- # Remove 'nil' lines in case we are close to the first or last line
- match_with_context.compact!
+ def count_commits_by_shelling_out(options)
+ cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} rev-list]
+ cmd << "--after=#{options[:after].iso8601}" if options[:after]
+ cmd << "--before=#{options[:before].iso8601}" if options[:before]
+ cmd += %W[--count #{options[:ref]}]
+ cmd += %W[-- #{options[:path]}] if options[:path].present?
- # Get the line number (1-indexed) of the first context line
- first_context_line_number = line_block[0][1] + 1
+ raw_output = IO.popen(cmd) { |io| io.read }
- greps << Gitlab::Git::BlobSnippet.new(
- ref,
- match_with_context,
- first_context_line_number,
- filename
- )
- end
+ raw_output.to_i
+ end
- greps
+ def gitaly_ls_files(ref)
+ gitaly_commit_client.ls_files(ref)
end
- # Return the Rugged patches for the diff between +from+ and +to+.
- def diff_patches(from, to, options = {}, *paths)
- options ||= {}
- break_rewrites = options[:break_rewrites]
- actual_options = Gitlab::Git::Diff.filter_diff_options(options.merge(paths: paths))
+ def git_ls_files(ref)
+ actual_ref = ref || root_ref
- diff = rugged.diff(from, to, actual_options)
- diff.find_similar!(break_rewrites: break_rewrites)
- diff.each_patch
- end
+ begin
+ sha_from_ref(actual_ref)
+ rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError
+ # Return an empty array if the ref wasn't found
+ return []
+ end
- def gitaly_ref_client
- @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(self)
- end
+ cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} ls-tree)
+ cmd += %w(-r)
+ cmd += %w(--full-tree)
+ cmd += %w(--full-name)
+ cmd += %W(-- #{actual_ref})
- # Returns the `Rugged` sorting type constant for a given
- # sort type key. Valid keys are `:none`, `:topo`, and `:date`
- def rugged_sort_type(key)
- @rugged_sort_types ||= {
- none: Rugged::SORT_NONE,
- topo: Rugged::SORT_TOPO,
- date: Rugged::SORT_DATE
- }
+ raw_output = IO.popen(cmd, &:read).split("\n").map do |f|
+ stuff, path = f.split("\t")
+ _mode, type, _sha = stuff.split(" ")
+ path if type == "blob"
+ # Contain only blob type
+ end
- @rugged_sort_types.fetch(key, Rugged::SORT_NONE)
+ raw_output.compact
end
end
end
diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb
index a16b0ed76f4..2b5785a1f08 100644
--- a/lib/gitlab/git/rev_list.rb
+++ b/lib/gitlab/git/rev_list.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: will probably be migrated indirectly by migrating the call sites.
+
module Gitlab
module Git
class RevList
@@ -15,6 +17,8 @@ module Gitlab
end
# This methods returns an array of missed references
+ #
+ # Should become obsolete after https://gitlab.com/gitlab-org/gitaly/issues/348.
def missed_ref
execute([*base_args, '--max-count=1', oldrev, "^#{newrev}"])
end
diff --git a/lib/gitlab/git/storage.rb b/lib/gitlab/git/storage.rb
new file mode 100644
index 00000000000..e28be4b8a38
--- /dev/null
+++ b/lib/gitlab/git/storage.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Git
+ module Storage
+ class Inaccessible < StandardError
+ attr_reader :retry_after
+
+ def initialize(message = nil, retry_after = nil)
+ super(message)
+ @retry_after = retry_after
+ end
+ end
+
+ CircuitOpen = Class.new(Inaccessible)
+
+ REDIS_KEY_PREFIX = 'storage_accessible:'.freeze
+
+ def self.redis
+ Gitlab::Redis::SharedState
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb
new file mode 100644
index 00000000000..9ea9367d4b7
--- /dev/null
+++ b/lib/gitlab/git/storage/circuit_breaker.rb
@@ -0,0 +1,144 @@
+module Gitlab
+ module Git
+ module Storage
+ class CircuitBreaker
+ FailureInfo = Struct.new(:last_failure, :failure_count)
+
+ attr_reader :storage,
+ :hostname,
+ :storage_path,
+ :failure_count_threshold,
+ :failure_wait_time,
+ :failure_reset_time,
+ :storage_timeout
+
+ delegate :last_failure, :failure_count, to: :failure_info
+
+ def self.reset_all!
+ pattern = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}*"
+
+ Gitlab::Git::Storage.redis.with do |redis|
+ all_storage_keys = redis.keys(pattern)
+ redis.del(*all_storage_keys) unless all_storage_keys.empty?
+ end
+
+ RequestStore.delete(:circuitbreaker_cache)
+ end
+
+ def self.for_storage(storage)
+ cached_circuitbreakers = RequestStore.fetch(:circuitbreaker_cache) do
+ Hash.new do |hash, storage_name|
+ hash[storage_name] = new(storage_name)
+ end
+ end
+
+ cached_circuitbreakers[storage]
+ end
+
+ def initialize(storage, hostname = Gitlab::Environment.hostname)
+ @storage = storage
+ @hostname = hostname
+
+ config = Gitlab.config.repositories.storages[@storage]
+ @storage_path = config['path']
+ @failure_count_threshold = config['failure_count_threshold']
+ @failure_wait_time = config['failure_wait_time']
+ @failure_reset_time = config['failure_reset_time']
+ @storage_timeout = config['storage_timeout']
+ end
+
+ def perform
+ return yield unless Feature.enabled?('git_storage_circuit_breaker')
+
+ check_storage_accessible!
+
+ yield
+ end
+
+ def circuit_broken?
+ return false if no_failures?
+
+ recent_failure = last_failure > failure_wait_time.seconds.ago
+ too_many_failures = failure_count > failure_count_threshold
+
+ recent_failure || too_many_failures
+ end
+
+ # Memoizing the `storage_available` call means we only do it once per
+ # request when the storage is available.
+ #
+ # When the storage appears not available, and the memoized value is `false`
+ # we might want to try again.
+ def storage_available?
+ return @storage_available if @storage_available
+
+ if @storage_available = Gitlab::Git::Storage::ForkedStorageCheck
+ .storage_available?(storage_path, storage_timeout)
+ track_storage_accessible
+ else
+ track_storage_inaccessible
+ end
+
+ @storage_available
+ end
+
+ def check_storage_accessible!
+ if circuit_broken?
+ raise Gitlab::Git::Storage::CircuitOpen.new("Circuit for #{storage} is broken", failure_wait_time)
+ end
+
+ unless storage_available?
+ raise Gitlab::Git::Storage::Inaccessible.new("#{storage} not accessible", failure_wait_time)
+ end
+ end
+
+ def no_failures?
+ last_failure.blank? && failure_count == 0
+ end
+
+ def track_storage_inaccessible
+ @failure_info = FailureInfo.new(Time.now, failure_count + 1)
+
+ Gitlab::Git::Storage.redis.with do |redis|
+ redis.pipelined do
+ redis.hset(cache_key, :last_failure, last_failure.to_i)
+ redis.hincrby(cache_key, :failure_count, 1)
+ redis.expire(cache_key, failure_reset_time)
+ end
+ end
+ end
+
+ def track_storage_accessible
+ return if no_failures?
+
+ @failure_info = FailureInfo.new(nil, 0)
+
+ Gitlab::Git::Storage.redis.with do |redis|
+ redis.pipelined do
+ redis.hset(cache_key, :last_failure, nil)
+ redis.hset(cache_key, :failure_count, 0)
+ end
+ end
+ end
+
+ def failure_info
+ @failure_info ||= get_failure_info
+ end
+
+ def get_failure_info
+ last_failure, failure_count = Gitlab::Git::Storage.redis.with do |redis|
+ redis.hmget(cache_key, :last_failure, :failure_count)
+ end
+
+ last_failure = Time.at(last_failure.to_i) if last_failure.present?
+
+ FailureInfo.new(last_failure, failure_count.to_i)
+ end
+
+ def cache_key
+ @cache_key ||= "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage}:#{hostname}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/storage/forked_storage_check.rb b/lib/gitlab/git/storage/forked_storage_check.rb
new file mode 100644
index 00000000000..91d8241f17b
--- /dev/null
+++ b/lib/gitlab/git/storage/forked_storage_check.rb
@@ -0,0 +1,55 @@
+module Gitlab
+ module Git
+ module Storage
+ module ForkedStorageCheck
+ extend self
+
+ def storage_available?(path, timeout_seconds = 5)
+ status = timeout_check(path, timeout_seconds)
+
+ status.success?
+ end
+
+ def timeout_check(path, timeout_seconds)
+ filesystem_check_pid = check_filesystem_in_process(path)
+
+ deadline = timeout_seconds.seconds.from_now.utc
+ wait_time = 0.01
+ status = nil
+
+ while status.nil?
+ if deadline > Time.now.utc
+ sleep(wait_time)
+ _pid, status = Process.wait2(filesystem_check_pid, Process::WNOHANG)
+ else
+ Process.kill('KILL', filesystem_check_pid)
+ # Blocking wait, so we are sure the process is gone before continuing
+ _pid, status = Process.wait2(filesystem_check_pid)
+ end
+ end
+
+ status
+ end
+
+ # This will spawn a new 2 processes to do the check:
+ # The outer child (waiter) will spawn another child process (stater).
+ #
+ # The stater is the process is performing the actual filesystem check
+ # the check might hang if the filesystem is acting up.
+ # In this case we will send a `KILL` to the waiter, which will still
+ # be responsive while the stater is hanging.
+ def check_filesystem_in_process(path)
+ spawn('ruby', '-e', ruby_check, path, [:out, :err] => '/dev/null')
+ end
+
+ def ruby_check
+ <<~RUBY_FILESYSTEM_CHECK
+ inner_pid = fork { File.stat(ARGV.first) }
+ Process.waitpid(inner_pid)
+ exit $?.exitstatus
+ RUBY_FILESYSTEM_CHECK
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/storage/health.rb b/lib/gitlab/git/storage/health.rb
new file mode 100644
index 00000000000..2d723147f4f
--- /dev/null
+++ b/lib/gitlab/git/storage/health.rb
@@ -0,0 +1,91 @@
+module Gitlab
+ module Git
+ module Storage
+ class Health
+ attr_reader :storage_name, :info
+
+ def self.pattern_for_storage(storage_name)
+ "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage_name}:*"
+ end
+
+ def self.for_all_storages
+ storage_names = Gitlab.config.repositories.storages.keys
+ results_per_storage = nil
+
+ Gitlab::Git::Storage.redis.with do |redis|
+ keys_per_storage = all_keys_for_storages(storage_names, redis)
+ results_per_storage = load_for_keys(keys_per_storage, redis)
+ end
+
+ results_per_storage.map do |name, info|
+ info.each { |i| i[:failure_count] = i[:failure_count].value.to_i }
+ new(name, info)
+ end
+ end
+
+ def self.all_keys_for_storages(storage_names, redis)
+ keys_per_storage = {}
+
+ redis.pipelined do
+ storage_names.each do |storage_name|
+ pattern = pattern_for_storage(storage_name)
+
+ keys_per_storage[storage_name] = redis.keys(pattern)
+ end
+ end
+
+ keys_per_storage
+ end
+
+ def self.load_for_keys(keys_per_storage, redis)
+ info_for_keys = {}
+
+ redis.pipelined do
+ keys_per_storage.each do |storage_name, keys_future|
+ info_for_storage = keys_future.value.map do |key|
+ { name: key, failure_count: redis.hget(key, :failure_count) }
+ end
+
+ info_for_keys[storage_name] = info_for_storage
+ end
+ end
+
+ info_for_keys
+ end
+
+ def self.for_failing_storages
+ for_all_storages.select(&:failing?)
+ end
+
+ def initialize(storage_name, info)
+ @storage_name = storage_name
+ @info = info
+ end
+
+ def failing_info
+ @failing_info ||= info.select { |info_for_host| info_for_host[:failure_count] > 0 }
+ end
+
+ def failing?
+ failing_info.any?
+ end
+
+ def failing_on_hosts
+ @failing_on_hosts ||= failing_info.map do |info_for_host|
+ info_for_host[:name].split(':').last
+ end
+ end
+
+ def failing_circuit_breakers
+ @failing_circuit_breakers ||= failing_on_hosts.map do |hostname|
+ CircuitBreaker.new(storage_name, hostname)
+ end
+ end
+
+ def total_failures
+ @total_failures ||= failing_info.sum { |info_for_host| info_for_host[:failure_count] }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/tag.rb b/lib/gitlab/git/tag.rb
index b5342c3d310..bc4e160dce9 100644
--- a/lib/gitlab/git/tag.rb
+++ b/lib/gitlab/git/tag.rb
@@ -1,10 +1,12 @@
+# Gitaly note: JV: no RPC's here.
+#
module Gitlab
module Git
class Tag < Ref
attr_reader :object_sha
- def initialize(repository, name, target, message = nil)
- super(repository, name, target)
+ def initialize(repository, name, target, target_commit, message = nil)
+ super(repository, name, target, target_commit)
@message = message
end
diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb
index b722d8a9f56..b54962a4456 100644
--- a/lib/gitlab/git/tree.rb
+++ b/lib/gitlab/git/tree.rb
@@ -1,7 +1,9 @@
+# Gitaly note: JV: needs 1 RPC, migration is in progress.
+
module Gitlab
module Git
class Tree
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
attr_accessor :id, :root_id, :name, :path, :type,
:mode, :commit_id, :submodule_url
@@ -10,36 +12,23 @@ module Gitlab
# Get list of tree objects
# for repository based on commit sha and path
# Uses rugged for raw objects
+ #
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/320
def where(repository, sha, path = nil)
path = nil if path == '' || path == '/'
- commit = repository.lookup(sha)
- root_tree = commit.tree
-
- tree = if path
- id = find_id_by_path(repository, root_tree.oid, path)
- if id
- repository.lookup(id)
- else
- []
- end
- else
- root_tree
- end
-
- tree.map do |entry|
- new(
- id: entry[:oid],
- root_id: root_tree.oid,
- name: entry[:name],
- type: entry[:type],
- mode: entry[:filemode].to_s(8),
- path: path ? File.join(path, entry[:name]) : entry[:name],
- commit_id: sha,
- )
+ Gitlab::GitalyClient.migrate(:tree_entries) do |is_enabled|
+ if is_enabled
+ client = Gitlab::GitalyClient::CommitService.new(repository)
+ client.tree_entries(repository, sha, path)
+ else
+ tree_entries_from_rugged(repository, sha, path)
+ end
end
end
+ private
+
# Recursive search of tree id for path
#
# Ex.
@@ -68,11 +57,39 @@ module Gitlab
entry[:oid]
end
end
+
+ def tree_entries_from_rugged(repository, sha, path)
+ commit = repository.lookup(sha)
+ root_tree = commit.tree
+
+ tree = if path
+ id = find_id_by_path(repository, root_tree.oid, path)
+ if id
+ repository.lookup(id)
+ else
+ []
+ end
+ else
+ root_tree
+ end
+
+ tree.map do |entry|
+ new(
+ id: entry[:oid],
+ root_id: root_tree.oid,
+ name: entry[:name],
+ type: entry[:type],
+ mode: entry[:filemode].to_s(8),
+ path: path ? File.join(path, entry[:name]) : entry[:name],
+ commit_id: sha
+ )
+ end
+ end
end
def initialize(options)
%w(id root_id name path type mode commit_id).each do |key|
- self.send("#{key}=", options[key.to_sym])
+ self.send("#{key}=", options[key.to_sym]) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -80,6 +97,10 @@ module Gitlab
encode! @name
end
+ def path
+ encode! @path
+ end
+
def dir?
type == :tree
end
diff --git a/lib/gitlab/git/util.rb b/lib/gitlab/git/util.rb
index 7973da2e8f8..4708f22dcb3 100644
--- a/lib/gitlab/git/util.rb
+++ b/lib/gitlab/git/util.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: no RPC's here.
+
module Gitlab
module Git
module Util
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 99724db8da2..0b62911958d 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -3,33 +3,41 @@
module Gitlab
class GitAccess
UnauthorizedError = Class.new(StandardError)
+ NotFoundError = Class.new(StandardError)
ERROR_MESSAGES = {
upload: 'You are not allowed to upload code for this project.',
download: 'You are not allowed to download code from this project.',
deploy_key_upload:
'This deploy key does not have write access to this project.',
- no_repo: 'A repository for this project does not exist yet.'
+ no_repo: 'A repository for this project does not exist yet.',
+ project_not_found: 'The project you were looking for could not be found.',
+ account_blocked: 'Your account has been blocked.',
+ command_not_allowed: "The command you're trying to execute is not allowed.",
+ upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.',
+ receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.'
}.freeze
DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze
PUSH_COMMANDS = %w{ git-receive-pack }.freeze
ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS
- attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities
+ attr_reader :actor, :project, :protocol, :authentication_abilities, :redirected_path
- def initialize(actor, project, protocol, authentication_abilities:)
+ def initialize(actor, project, protocol, authentication_abilities:, redirected_path: nil)
@actor = actor
@project = project
@protocol = protocol
+ @redirected_path = redirected_path
@authentication_abilities = authentication_abilities
- @user_access = UserAccess.new(user, project: project)
end
def check(cmd, changes)
check_protocol!
check_active_user!
check_project_accessibility!
+ check_project_moved!
+ check_command_disabled!(cmd)
check_command_existence!(cmd)
check_repository_existence!
@@ -40,9 +48,7 @@ module Gitlab
check_push_access!(changes)
end
- build_status_object(true)
- rescue UnauthorizedError => ex
- build_status_object(false, ex.message)
+ true
end
def guest_can_download_code?
@@ -73,19 +79,54 @@ module Gitlab
return if deploy_key?
if user && !user_access.allowed?
- raise UnauthorizedError, "Your account has been blocked."
+ raise UnauthorizedError, ERROR_MESSAGES[:account_blocked]
end
end
def check_project_accessibility!
if project.blank? || !can_read_project?
- raise UnauthorizedError, 'The project you were looking for could not be found.'
+ raise NotFoundError, ERROR_MESSAGES[:project_not_found]
+ end
+ end
+
+ def check_project_moved!
+ if redirected_path
+ url = protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo
+ message = <<-MESSAGE.strip_heredoc
+ Project '#{redirected_path}' was moved to '#{project.full_path}'.
+
+ Please update your Git remote and try again:
+
+ git remote set-url origin #{url}
+ MESSAGE
+
+ raise NotFoundError, message
+ end
+ end
+
+ def check_command_disabled!(cmd)
+ if upload_pack?(cmd)
+ check_upload_pack_disabled!
+ elsif receive_pack?(cmd)
+ check_receive_pack_disabled!
+ end
+ end
+
+ def check_upload_pack_disabled!
+ if http? && upload_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:upload_pack_disabled_over_http]
+ end
+ end
+
+ def check_receive_pack_disabled!
+ if http? && receive_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:receive_pack_disabled_over_http]
end
end
def check_command_existence!(cmd)
unless ALL_COMMANDS.include?(cmd)
- raise UnauthorizedError, "The command you're trying to execute is not allowed."
+ raise UnauthorizedError, ERROR_MESSAGES[:command_not_allowed]
end
end
@@ -138,11 +179,9 @@ module Gitlab
# Iterate over all changes to find if user allowed all of them to be applied
changes_list.each do |change|
- status = check_single_change_access(change)
- unless status.allowed?
- # If user does not have access to make at least one change - cancel all push
- raise UnauthorizedError, status.message
- end
+ # If user does not have access to make at least one change, cancel all
+ # push by allowing the exception to bubble up
+ check_single_change_access(change)
end
end
@@ -168,14 +207,40 @@ module Gitlab
actor.is_a?(DeployKey)
end
+ def ci?
+ actor == :ci
+ end
+
def can_read_project?
- if deploy_key
+ if deploy_key?
deploy_key.has_access_to?(project)
elsif user
user.can?(:read_project, project)
+ elsif ci?
+ true # allow CI (build without a user) for backwards compatibility
end || Guest.can?(:read_project, project)
end
+ def http?
+ protocol == 'http'
+ end
+
+ def upload_pack?(command)
+ command == 'git-upload-pack'
+ end
+
+ def receive_pack?(command)
+ command == 'git-receive-pack'
+ end
+
+ def upload_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.upload_pack
+ end
+
+ def receive_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.receive_pack
+ end
+
protected
def user
@@ -185,15 +250,19 @@ module Gitlab
case actor
when User
actor
- when DeployKey
- nil
when Key
- actor.user
+ actor.user unless actor.is_a?(DeployKey)
+ when :ci
+ nil
end
end
- def build_status_object(status, message = '')
- Gitlab::GitAccessStatus.new(status, message)
+ def user_access
+ @user_access ||= if ci?
+ CiAccess.new
+ else
+ UserAccess.new(user, project: project)
+ end
end
end
end
diff --git a/lib/gitlab/git_access_status.rb b/lib/gitlab/git_access_status.rb
deleted file mode 100644
index 09bb01be694..00000000000
--- a/lib/gitlab/git_access_status.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module Gitlab
- class GitAccessStatus
- attr_accessor :status, :message
- alias_method :allowed?, :status
-
- def initialize(status, message = '')
- @status = status
- @message = message
- end
-
- def to_json(opts = nil)
- { status: @status, message: @message }.to_json(opts)
- end
- end
-end
diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb
index 67eaa5e088d..1fe5155c093 100644
--- a/lib/gitlab/git_access_wiki.rb
+++ b/lib/gitlab/git_access_wiki.rb
@@ -1,5 +1,9 @@
module Gitlab
class GitAccessWiki < GitAccess
+ ERROR_MESSAGES = {
+ write_to_wiki: "You are not allowed to write to this project's wiki."
+ }.freeze
+
def guest_can_download_code?
Guest.can?(:download_wiki_code, project)
end
@@ -9,11 +13,11 @@ module Gitlab
end
def check_single_change_access(change)
- if user_access.can_do_action?(:create_wiki)
- build_status_object(true)
- else
- build_status_object(false, "You are not allowed to write to this project's wiki.")
+ unless user_access.can_do_action?(:create_wiki)
+ raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki]
end
+
+ true
end
end
end
diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb
index 6babea144c7..742118b76a8 100644
--- a/lib/gitlab/git_post_receive.rb
+++ b/lib/gitlab/git_post_receive.rb
@@ -1,43 +1,30 @@
module Gitlab
class GitPostReceive
include Gitlab::Identifier
- attr_reader :repo_path, :identifier, :changes, :project
+ attr_reader :project, :identifier, :changes
- def initialize(repo_path, identifier, changes)
- repo_path.gsub!(/\.git\z/, '')
- repo_path.gsub!(/\A\//, '')
-
- @repo_path = repo_path
+ def initialize(project, identifier, changes)
+ @project = project
@identifier = identifier
@changes = deserialize_changes(changes)
-
- retrieve_project_and_type
- end
-
- def wiki?
- @type == :wiki
- end
-
- def regular_project?
- @type == :project
end
def identify(revision)
super(identifier, project, revision)
end
- private
+ def changes_refs
+ return enum_for(:changes_refs) unless block_given?
- def retrieve_project_and_type
- @type = :project
- @project = Project.find_by_full_path(@repo_path)
+ changes.each do |change|
+ oldrev, newrev, ref = change.strip.split(' ')
- if @repo_path.end_with?('.wiki') && !@project
- @type = :wiki
- @project = Project.find_by_full_path(@repo_path.gsub(/\.wiki\z/, ''))
+ yield oldrev, newrev, ref
end
end
+ private
+
def deserialize_changes(changes)
changes = utf8_encode_changes(changes)
changes.lines
diff --git a/lib/gitlab/git_ref_validator.rb b/lib/gitlab/git_ref_validator.rb
index 0e87ee30c98..a3c6b21a6a1 100644
--- a/lib/gitlab/git_ref_validator.rb
+++ b/lib/gitlab/git_ref_validator.rb
@@ -1,3 +1,5 @@
+# Gitaly note: JV: does not need to be migrated, works without a repo.
+
module Gitlab
module GitRefValidator
extend self
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index c69676a1dac..9a5f4f598b2 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -1,71 +1,93 @@
+require 'base64'
+
require 'gitaly'
module Gitlab
module GitalyClient
+ module MigrationStatus
+ DISABLED = 1
+ OPT_IN = 2
+ OPT_OUT = 3
+ end
+
SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze
- # This function is not thread-safe because it updates Hashes in instance variables.
- def self.configure_channels
- @addresses = {}
- @channels = {}
- Gitlab.config.repositories.storages.each do |name, params|
- address = params['gitaly_address']
- unless address.present?
- raise "storage #{name.inspect} is missing a gitaly_address"
- end
+ MUTEX = Mutex.new
+ private_constant :MUTEX
- unless URI(address).scheme.in?(%w(tcp unix))
- raise "Unsupported Gitaly address: #{address.inspect} does not use URL scheme 'tcp' or 'unix'"
+ def self.stub(name, storage)
+ MUTEX.synchronize do
+ @stubs ||= {}
+ @stubs[storage] ||= {}
+ @stubs[storage][name] ||= begin
+ klass = Gitaly.const_get(name.to_s.camelcase.to_sym).const_get(:Stub)
+ addr = address(storage)
+ addr = addr.sub(%r{^tcp://}, '') if URI(addr).scheme == 'tcp'
+ klass.new(addr, :this_channel_is_insecure)
end
-
- @addresses[name] = address
- @channels[name] = new_channel(address)
end
end
- def self.new_channel(address)
- address = address.sub(%r{^tcp://}, '') if URI(address).scheme == 'tcp'
- # NOTE: When Gitaly runs on a Unix socket, permissions are
- # handled using the file system and no additional authentication is
- # required (therefore the :this_channel_is_insecure flag)
- # TODO: Add authentication support when Gitaly is running on a TCP socket.
- GRPC::Core::Channel.new(address, {}, :this_channel_is_insecure)
+ def self.clear_stubs!
+ MUTEX.synchronize do
+ @stubs = nil
+ end
end
- def self.get_channel(storage)
- if !Rails.env.production? && @channels.nil?
- # In development mode the Rails auto-loader may reset the instance
- # variables of this class. What we do here is not thread-safe. In normal
- # circumstances (including production) these instance variables have
- # been initialized from config/initializers.
- configure_channels
+ def self.address(storage)
+ params = Gitlab.config.repositories.storages[storage]
+ raise "storage not found: #{storage.inspect}" if params.nil?
+
+ address = params['gitaly_address']
+ unless address.present?
+ raise "storage #{storage.inspect} is missing a gitaly_address"
end
- @channels[storage]
+ unless URI(address).scheme.in?(%w(tcp unix))
+ raise "Unsupported Gitaly address: #{address.inspect} does not use URL scheme 'tcp' or 'unix'"
+ end
+
+ address
end
- def self.get_address(storage)
- if !Rails.env.production? && @addresses.nil?
- # In development mode the Rails auto-loader may reset the instance
- # variables of this class. What we do here is not thread-safe. In normal
- # circumstances (including development) these instance variables have
- # been initialized from config/initializers.
- configure_channels
- end
+ # All Gitaly RPC call sites should use GitalyClient.call. This method
+ # makes sure that per-request authentication headers are set.
+ def self.call(storage, service, rpc, request)
+ metadata = request_metadata(storage)
+ metadata = yield(metadata) if block_given?
+ stub(service, storage).__send__(rpc, request, metadata) # rubocop:disable GitlabSecurity/PublicSend
+ end
- @addresses[storage]
+ def self.request_metadata(storage)
+ encoded_token = Base64.strict_encode64(token(storage).to_s)
+ { metadata: { 'authorization' => "Bearer #{encoded_token}" } }
end
- def self.enabled?
- Gitlab.config.gitaly.enabled
+ def self.token(storage)
+ params = Gitlab.config.repositories.storages[storage]
+ raise "storage not found: #{storage.inspect}" if params.nil?
+
+ params['gitaly_token'].presence || Gitlab.config.gitaly['token']
end
- def self.feature_enabled?(feature)
- enabled? && ENV["GITALY_#{feature.upcase}"] == '1'
+ def self.feature_enabled?(feature, status: MigrationStatus::OPT_IN)
+ return false if status == MigrationStatus::DISABLED
+
+ feature = Feature.get("gitaly_#{feature}")
+
+ # If the feature hasn't been set, turn it on if it's opt-out
+ return status == MigrationStatus::OPT_OUT unless Feature.persisted?(feature)
+
+ if feature.percentage_of_time_value > 0
+ # Probabilistically enable this feature
+ return Random.rand() * 100 < feature.percentage_of_time_value
+ end
+
+ feature.enabled?
end
- def self.migrate(feature)
- is_enabled = feature_enabled?(feature)
+ def self.migrate(feature, status: MigrationStatus::OPT_IN)
+ is_enabled = feature_enabled?(feature, status: status)
metric_name = feature.to_s
metric_name += "_gitaly" if is_enabled
@@ -78,5 +100,9 @@ module Gitlab
path = Rails.root.join(SERVER_VERSION_FILE)
path.read.chomp
end
+
+ def self.encode(s)
+ s.dup.force_encoding(Encoding::ASCII_8BIT)
+ end
end
end
diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb
new file mode 100644
index 00000000000..a250eb75bd4
--- /dev/null
+++ b/lib/gitlab/gitaly_client/blob_service.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module GitalyClient
+ class BlobService
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ end
+
+ def get_blob(oid:, limit:)
+ request = Gitaly::GetBlobRequest.new(
+ repository: @gitaly_repo,
+ oid: oid,
+ limit: limit
+ )
+ response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_blob, request)
+
+ data = ''
+ blob = nil
+ response.each do |msg|
+ if blob.nil?
+ blob = msg
+ end
+
+ data << msg.data
+ end
+
+ return nil if blob.oid.blank?
+
+ Gitlab::Git::Blob.new(
+ id: blob.oid,
+ size: blob.size,
+ data: data,
+ binary: Gitlab::Git::Blob.binary?(data)
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb
deleted file mode 100644
index b7f39f3ef0b..00000000000
--- a/lib/gitlab/gitaly_client/commit.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-module Gitlab
- module GitalyClient
- class Commit
- # The ID of empty tree.
- # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012
- EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze
-
- class << self
- def diff_from_parent(commit, options = {})
- 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: gitaly_repo,
- left_commit_id: parent_id,
- right_commit_id: commit.id
- )
-
- Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options)
- end
-
- def is_ancestor(repository, ancestor_id, child_id)
- gitaly_repo = repository.gitaly_repository
- stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: repository.gitaly_channel)
- request = Gitaly::CommitIsAncestorRequest.new(
- repository: gitaly_repo,
- ancestor_id: ancestor_id,
- child_id: child_id
- )
-
- stub.commit_is_ancestor(request).value
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb
new file mode 100644
index 00000000000..b36e81278d6
--- /dev/null
+++ b/lib/gitlab/gitaly_client/commit_service.rb
@@ -0,0 +1,219 @@
+module Gitlab
+ module GitalyClient
+ class CommitService
+ # The ID of empty tree.
+ # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012
+ EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze
+
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ @repository = repository
+ end
+
+ def ls_files(revision)
+ request = Gitaly::ListFilesRequest.new(
+ repository: @gitaly_repo,
+ revision: GitalyClient.encode(revision)
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :list_files, request)
+ response.flat_map do |msg|
+ msg.paths.map { |d| d.dup.force_encoding(Encoding::UTF_8) }
+ end
+ end
+
+ def is_ancestor(ancestor_id, child_id)
+ request = Gitaly::CommitIsAncestorRequest.new(
+ repository: @gitaly_repo,
+ ancestor_id: ancestor_id,
+ child_id: child_id
+ )
+
+ GitalyClient.call(@repository.storage, :commit_service, :commit_is_ancestor, request).value
+ end
+
+ def diff_from_parent(commit, options = {})
+ request_params = commit_diff_request_params(commit, options)
+ request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false)
+ request_params[:enforce_limits] = options.fetch(:limits, true)
+ request_params[:collapse_diffs] = request_params[:enforce_limits] || !options.fetch(:expanded, true)
+ request_params.merge!(Gitlab::Git::DiffCollection.collection_limits(options).to_h)
+
+ request = Gitaly::CommitDiffRequest.new(request_params)
+ response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request)
+ GitalyClient::DiffStitcher.new(response)
+ end
+
+ def commit_deltas(commit)
+ request = Gitaly::CommitDeltaRequest.new(commit_diff_request_params(commit))
+ response = GitalyClient.call(@repository.storage, :diff_service, :commit_delta, request)
+
+ response.flat_map { |msg| msg.deltas }
+ end
+
+ def tree_entry(ref, path, limit = nil)
+ request = Gitaly::TreeEntryRequest.new(
+ repository: @gitaly_repo,
+ revision: ref,
+ path: GitalyClient.encode(path),
+ limit: limit.to_i
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :tree_entry, request)
+
+ entry = nil
+ data = ''
+ response.each do |msg|
+ if entry.nil?
+ entry = msg
+
+ break unless entry.type == :BLOB
+ end
+
+ data << msg.data
+ end
+ entry.data = data
+
+ entry unless entry.oid.blank?
+ end
+
+ def tree_entries(repository, revision, path)
+ request = Gitaly::GetTreeEntriesRequest.new(
+ repository: @gitaly_repo,
+ revision: revision,
+ path: path.presence || '.'
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :get_tree_entries, request)
+
+ response.flat_map do |message|
+ message.entries.map do |gitaly_tree_entry|
+ entry_path = gitaly_tree_entry.path.dup
+ Gitlab::Git::Tree.new(
+ id: gitaly_tree_entry.oid,
+ root_id: gitaly_tree_entry.root_oid,
+ type: gitaly_tree_entry.type.downcase,
+ mode: gitaly_tree_entry.mode.to_s(8),
+ name: File.basename(entry_path),
+ path: entry_path,
+ commit_id: gitaly_tree_entry.commit_oid
+ )
+ end
+ end
+ end
+
+ def commit_count(ref, options = {})
+ request = Gitaly::CountCommitsRequest.new(
+ repository: @gitaly_repo,
+ revision: ref
+ )
+ request.after = Google::Protobuf::Timestamp.new(seconds: options[:after].to_i) if options[:after].present?
+ request.before = Google::Protobuf::Timestamp.new(seconds: options[:before].to_i) if options[:before].present?
+ request.path = options[:path] if options[:path].present?
+
+ GitalyClient.call(@repository.storage, :commit_service, :count_commits, request).count
+ end
+
+ def last_commit_for_path(revision, path)
+ request = Gitaly::LastCommitForPathRequest.new(
+ repository: @gitaly_repo,
+ revision: GitalyClient.encode(revision),
+ path: GitalyClient.encode(path.to_s)
+ )
+
+ gitaly_commit = GitalyClient.call(@repository.storage, :commit_service, :last_commit_for_path, request).commit
+ return unless gitaly_commit
+
+ Gitlab::Git::Commit.new(@repository, gitaly_commit)
+ end
+
+ def between(from, to)
+ request = Gitaly::CommitsBetweenRequest.new(
+ repository: @gitaly_repo,
+ from: from,
+ to: to
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :commits_between, request)
+ consume_commits_response(response)
+ end
+
+ def find_all_commits(opts = {})
+ request = Gitaly::FindAllCommitsRequest.new(
+ repository: @gitaly_repo,
+ revision: opts[:ref].to_s,
+ max_count: opts[:max_count].to_i,
+ skip: opts[:skip].to_i
+ )
+ request.order = opts[:order].upcase if opts[:order].present?
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :find_all_commits, request)
+ consume_commits_response(response)
+ end
+
+ def commits_by_message(query, revision: '', path: '', limit: 1000, offset: 0)
+ request = Gitaly::CommitsByMessageRequest.new(
+ repository: @gitaly_repo,
+ query: query,
+ revision: revision.to_s.force_encoding(Encoding::ASCII_8BIT),
+ path: path.to_s.force_encoding(Encoding::ASCII_8BIT),
+ limit: limit.to_i,
+ offset: offset.to_i
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :commits_by_message, request)
+ consume_commits_response(response)
+ end
+
+ def languages(ref = nil)
+ request = Gitaly::CommitLanguagesRequest.new(repository: @gitaly_repo, revision: ref || '')
+ response = GitalyClient.call(@repository.storage, :commit_service, :commit_languages, request)
+
+ response.languages.map { |l| { value: l.share.round(2), label: l.name, color: l.color, highlight: l.color } }
+ end
+
+ def raw_blame(revision, path)
+ request = Gitaly::RawBlameRequest.new(
+ repository: @gitaly_repo,
+ revision: revision,
+ path: path
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :raw_blame, request)
+ response.reduce("") { |memo, msg| memo << msg.data }
+ end
+
+ def find_commit(revision)
+ request = Gitaly::FindCommitRequest.new(
+ repository: @gitaly_repo,
+ revision: GitalyClient.encode(revision)
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request)
+
+ response.commit
+ end
+
+ private
+
+ def commit_diff_request_params(commit, options = {})
+ parent_id = commit.parent_ids.first || EMPTY_TREE_ID
+
+ {
+ repository: @gitaly_repo,
+ left_commit_id: parent_id,
+ right_commit_id: commit.id,
+ paths: options.fetch(:paths, [])
+ }
+ end
+
+ def consume_commits_response(response)
+ response.flat_map do |message|
+ message.commits.map do |gitaly_commit|
+ Gitlab::Git::Commit.new(@repository, gitaly_commit)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/diff.rb b/lib/gitlab/gitaly_client/diff.rb
new file mode 100644
index 00000000000..54df6304865
--- /dev/null
+++ b/lib/gitlab/gitaly_client/diff.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module GitalyClient
+ class Diff
+ FIELDS = %i(from_path to_path old_mode new_mode from_id to_id patch overflow_marker collapsed).freeze
+
+ attr_accessor(*FIELDS)
+
+ def initialize(params)
+ params.each do |key, val|
+ public_send(:"#{key}=", val) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def ==(other)
+ FIELDS.all? do |field|
+ public_send(field) == other.public_send(field) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/diff_stitcher.rb b/lib/gitlab/gitaly_client/diff_stitcher.rb
new file mode 100644
index 00000000000..65d81dc5d46
--- /dev/null
+++ b/lib/gitlab/gitaly_client/diff_stitcher.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module GitalyClient
+ class DiffStitcher
+ include Enumerable
+
+ def initialize(rpc_response)
+ @rpc_response = rpc_response
+ end
+
+ def each
+ current_diff = nil
+
+ @rpc_response.each do |diff_msg|
+ if current_diff.nil?
+ diff_params = diff_msg.to_h.slice(*GitalyClient::Diff::FIELDS)
+ # gRPC uses frozen strings by default, and we need to have an unfrozen string as it
+ # gets processed further down the line. So we unfreeze the first chunk of the patch
+ # in case it's the only chunk we receive for this diff.
+ diff_params[:patch] = diff_msg.raw_patch_data.dup
+
+ current_diff = GitalyClient::Diff.new(diff_params)
+ else
+ current_diff.patch += diff_msg.raw_patch_data
+ end
+
+ if diff_msg.end_of_patch
+ yield current_diff
+ current_diff = nil
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/notification_service.rb b/lib/gitlab/gitaly_client/notification_service.rb
new file mode 100644
index 00000000000..326e6f7dafc
--- /dev/null
+++ b/lib/gitlab/gitaly_client/notification_service.rb
@@ -0,0 +1,20 @@
+module Gitlab
+ module GitalyClient
+ class NotificationService
+ # 'repository' is a Gitlab::Git::Repository
+ def initialize(repository)
+ @gitaly_repo = repository.gitaly_repository
+ @storage = repository.storage
+ end
+
+ def post_receive
+ GitalyClient.call(
+ @storage,
+ :notification_service,
+ :post_receive,
+ Gitaly::PostReceiveRequest.new(repository: @gitaly_repo)
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/notifications.rb b/lib/gitlab/gitaly_client/notifications.rb
deleted file mode 100644
index a94a54883db..00000000000
--- a/lib/gitlab/gitaly_client/notifications.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-module Gitlab
- module GitalyClient
- class Notifications
- attr_accessor :stub
-
- # '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: @gitaly_repo)
- @stub.post_receive(request)
- end
- end
- end
-end
diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb
deleted file mode 100644
index d3c0743db4e..00000000000
--- a/lib/gitlab/gitaly_client/ref.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-module Gitlab
- module GitalyClient
- class Ref
- attr_accessor :stub
-
- # '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: @gitaly_repo)
- stub.find_default_branch_name(request).name.gsub(/^refs\/heads\//, '')
- end
-
- def branch_names
- 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: @gitaly_repo)
- consume_refs_response(stub.find_all_tag_names(request), prefix: 'refs/tags/')
- end
-
- def find_ref_name(commit_id, ref_prefix)
- request = Gitaly::FindRefNameRequest.new(
- repository: @repository,
- commit_id: commit_id,
- prefix: ref_prefix
- )
-
- stub.find_ref_name(request).name
- end
-
- private
-
- def consume_refs_response(response, prefix:)
- response.flat_map do |r|
- r.names.map { |name| name.sub(/\A#{Regexp.escape(prefix)}/, '') }
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb
new file mode 100644
index 00000000000..919fb68b8c7
--- /dev/null
+++ b/lib/gitlab/gitaly_client/ref_service.rb
@@ -0,0 +1,146 @@
+module Gitlab
+ module GitalyClient
+ class RefService
+ include Gitlab::EncodingHelper
+
+ # 'repository' is a Gitlab::Git::Repository
+ def initialize(repository)
+ @repository = repository
+ @gitaly_repo = repository.gitaly_repository
+ @storage = repository.storage
+ end
+
+ def branches
+ request = Gitaly::FindAllBranchesRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :ref_service, :find_all_branches, request)
+
+ response.flat_map do |message|
+ message.branches.map do |branch|
+ target_commit = Gitlab::Git::Commit.decorate(@repository, branch.target)
+ Gitlab::Git::Branch.new(@repository, branch.name, branch.target.id, target_commit)
+ end
+ end
+ end
+
+ def default_branch_name
+ request = Gitaly::FindDefaultBranchNameRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :ref_service, :find_default_branch_name, request)
+ Gitlab::Git.branch_name(response.name)
+ end
+
+ def branch_names
+ request = Gitaly::FindAllBranchNamesRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :ref_service, :find_all_branch_names, request)
+ consume_refs_response(response) { |name| Gitlab::Git.branch_name(name) }
+ end
+
+ def tag_names
+ request = Gitaly::FindAllTagNamesRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :ref_service, :find_all_tag_names, request)
+ consume_refs_response(response) { |name| Gitlab::Git.tag_name(name) }
+ end
+
+ def find_ref_name(commit_id, ref_prefix)
+ request = Gitaly::FindRefNameRequest.new(
+ repository: @gitaly_repo,
+ commit_id: commit_id,
+ prefix: ref_prefix
+ )
+ encode!(GitalyClient.call(@storage, :ref_service, :find_ref_name, request).name.dup)
+ end
+
+ def count_tag_names
+ tag_names.count
+ end
+
+ def count_branch_names
+ branch_names.count
+ end
+
+ def local_branches(sort_by: nil)
+ request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo)
+ request.sort_by = sort_by_param(sort_by) if sort_by
+ response = GitalyClient.call(@storage, :ref_service, :find_local_branches, request)
+ consume_branches_response(response)
+ end
+
+ def tags
+ request = Gitaly::FindAllTagsRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :ref_service, :find_all_tags, request)
+ consume_tags_response(response)
+ end
+
+ private
+
+ def consume_refs_response(response)
+ response.flat_map { |message| message.names.map { |name| yield(name) } }
+ end
+
+ def sort_by_param(sort_by)
+ sort_by = 'name' if sort_by == 'name_asc'
+
+ enum_value = Gitaly::FindLocalBranchesRequest::SortBy.resolve(sort_by.upcase.to_sym)
+ raise ArgumentError, "Invalid sort_by key `#{sort_by}`" unless enum_value
+ enum_value
+ end
+
+ def consume_branches_response(response)
+ response.flat_map do |message|
+ message.branches.map do |gitaly_branch|
+ Gitlab::Git::Branch.new(
+ @repository,
+ encode!(gitaly_branch.name.dup),
+ gitaly_branch.commit_id,
+ commit_from_local_branches_response(gitaly_branch)
+ )
+ end
+ end
+ end
+
+ def consume_tags_response(response)
+ response.flat_map do |message|
+ message.tags.map do |gitaly_tag|
+ if gitaly_tag.target_commit.present?
+ gitaly_commit = Gitlab::Git::Commit.decorate(@repository, gitaly_tag.target_commit)
+ end
+
+ Gitlab::Git::Tag.new(
+ @repository,
+ encode!(gitaly_tag.name.dup),
+ gitaly_tag.id,
+ gitaly_commit,
+ encode!(gitaly_tag.message.chomp)
+ )
+ end
+ end
+ end
+
+ def commit_from_local_branches_response(response)
+ # Git messages have no encoding enforcements. However, in the UI we only
+ # handle UTF-8, so basically we cross our fingers that the message force
+ # encoded to UTF-8 is readable.
+ message = response.commit_subject.dup.force_encoding('UTF-8')
+
+ # NOTE: For ease of parsing in Gitaly, we have only the subject of
+ # the commit and not the full message. This is ok, since all the
+ # code that uses `local_branches` only cares at most about the
+ # commit message.
+ # TODO: Once gitaly "takes over" Rugged consider separating the
+ # subject from the message to make it clearer when there's one
+ # available but not the other.
+ hash = {
+ id: response.commit_id,
+ message: message,
+ authored_date: Time.at(response.commit_author.date.seconds),
+ author_name: response.commit_author.name.dup,
+ author_email: response.commit_author.email.dup,
+ committed_date: Time.at(response.commit_committer.date.seconds),
+ committer_name: response.commit_committer.name.dup,
+ committer_email: response.commit_committer.email.dup
+ }
+
+ Gitlab::Git::Commit.decorate(@repository, hash)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb
new file mode 100644
index 00000000000..6ad97e62941
--- /dev/null
+++ b/lib/gitlab/gitaly_client/repository_service.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module GitalyClient
+ class RepositoryService
+ def initialize(repository)
+ @repository = repository
+ @gitaly_repo = repository.gitaly_repository
+ @storage = repository.storage
+ end
+
+ def exists?
+ request = Gitaly::RepositoryExistsRequest.new(repository: @gitaly_repo)
+
+ GitalyClient.call(@storage, :repository_service, :repository_exists, request).exists
+ end
+
+ def garbage_collect(create_bitmap)
+ request = Gitaly::GarbageCollectRequest.new(repository: @gitaly_repo, create_bitmap: create_bitmap)
+ GitalyClient.call(@storage, :repository_service, :garbage_collect, request)
+ end
+
+ def repack_full(create_bitmap)
+ request = Gitaly::RepackFullRequest.new(repository: @gitaly_repo, create_bitmap: create_bitmap)
+ GitalyClient.call(@storage, :repository_service, :repack_full, request)
+ end
+
+ def repack_incremental
+ request = Gitaly::RepackIncrementalRequest.new(repository: @gitaly_repo)
+ GitalyClient.call(@storage, :repository_service, :repack_incremental, request)
+ end
+
+ def repository_size
+ request = Gitaly::RepositorySizeRequest.new(repository: @gitaly_repo)
+ GitalyClient.call(@storage, :repository_service, :repository_size, request).size
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb
index 4acd297f5cb..8fc937496af 100644
--- a/lib/gitlab/gitaly_client/util.rb
+++ b/lib/gitlab/gitaly_client/util.rb
@@ -4,9 +4,10 @@ module Gitlab
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,
+ git_object_directory: Gitlab::Git::Env['GIT_OBJECT_DIRECTORY'].to_s,
+ git_alternate_object_directories: Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES'])
)
end
end
diff --git a/lib/gitlab/github_import/base_formatter.rb b/lib/gitlab/github_import/base_formatter.rb
index 8c80791e7c9..f330041cc00 100644
--- a/lib/gitlab/github_import/base_formatter.rb
+++ b/lib/gitlab/github_import/base_formatter.rb
@@ -11,7 +11,9 @@ module Gitlab
end
def create!
- project.public_send(project_association).find_or_create_by!(find_condition) do |record|
+ association = project.public_send(project_association) # rubocop:disable GitlabSecurity/PublicSend
+
+ association.find_or_create_by!(find_condition) do |record|
record.attributes = attributes
end
end
diff --git a/lib/gitlab/github_import/client.rb b/lib/gitlab/github_import/client.rb
index 7dbeec5b010..0550f9695bd 100644
--- a/lib/gitlab/github_import/client.rb
+++ b/lib/gitlab/github_import/client.rb
@@ -120,7 +120,7 @@ module Gitlab
def request(method, *args, &block)
sleep rate_limit_sleep_time if rate_limit_exceed?
- data = api.send(method, *args)
+ data = api.__send__(method, *args) # rubocop:disable GitlabSecurity/PublicSend
return data unless data.is_a?(Array)
last_response = api.last_response
diff --git a/lib/gitlab/github_import/importer.rb b/lib/gitlab/github_import/importer.rb
index a8c0b47e786..373062b354b 100644
--- a/lib/gitlab/github_import/importer.rb
+++ b/lib/gitlab/github_import/importer.rb
@@ -254,7 +254,7 @@ module Gitlab
def import_wiki
unless project.wiki.repository_exists?
wiki = WikiFormatter.new(project)
- gitlab_shell.import_repository(project.repository_storage_path, wiki.path_with_namespace, wiki.import_url)
+ gitlab_shell.import_repository(project.repository_storage_path, wiki.disk_path, wiki.import_url)
end
rescue Gitlab::Shell::Error => e
# GitHub error message when the wiki repo has not been created,
@@ -289,7 +289,7 @@ module Gitlab
opts.last[:page] = current_page(resource_type)
- client.public_send(resource_type, *opts) do |resources|
+ client.public_send(resource_type, *opts) do |resources| # rubocop:disable GitlabSecurity/PublicSend
yield resources
increment_page(resource_type)
end
diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb
index 6f5ac4dac0d..977cd0423ba 100644
--- a/lib/gitlab/github_import/issue_formatter.rb
+++ b/lib/gitlab/github_import/issue_formatter.rb
@@ -10,7 +10,7 @@ module Gitlab
description: description,
state: state,
author_id: author_id,
- assignee_id: assignee_id,
+ assignee_ids: Array(assignee_id),
created_at: raw_data.created_at,
updated_at: raw_data.updated_at
}
diff --git a/lib/gitlab/github_import/wiki_formatter.rb b/lib/gitlab/github_import/wiki_formatter.rb
index 6c592ff469c..0396122eeb9 100644
--- a/lib/gitlab/github_import/wiki_formatter.rb
+++ b/lib/gitlab/github_import/wiki_formatter.rb
@@ -7,8 +7,8 @@ module Gitlab
@project = project
end
- def path_with_namespace
- "#{project.path_with_namespace}.wiki"
+ def disk_path
+ "#{project.disk_path}.wiki"
end
def import_url
diff --git a/lib/gitlab/gitlab_import/client.rb b/lib/gitlab/gitlab_import/client.rb
index 86fb6c51765..f1007daab5d 100644
--- a/lib/gitlab/gitlab_import/client.rb
+++ b/lib/gitlab/gitlab_import/client.rb
@@ -71,7 +71,7 @@ module Gitlab
end
def config
- Gitlab.config.omniauth.providers.find{|provider| provider.name == "gitlab"}
+ Gitlab.config.omniauth.providers.find {|provider| provider.name == "gitlab"}
end
def gitlab_options
diff --git a/lib/gitlab/gl_repository.rb b/lib/gitlab/gl_repository.rb
new file mode 100644
index 00000000000..07c0abcce23
--- /dev/null
+++ b/lib/gitlab/gl_repository.rb
@@ -0,0 +1,20 @@
+module Gitlab
+ module GlRepository
+ def self.gl_repository(project, is_wiki)
+ "#{is_wiki ? 'wiki' : 'project'}-#{project.id}"
+ end
+
+ def self.parse(gl_repository)
+ match_data = /\A(project|wiki)-([1-9][0-9]*)\z/.match(gl_repository)
+ unless match_data
+ raise ArgumentError, "Invalid GL Repository \"#{gl_repository}\""
+ end
+
+ type, id = match_data.captures
+ project = Project.find_by(id: id)
+ wiki = type == 'wiki'
+
+ [project, wiki]
+ end
+ end
+end
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 5ab84266b7d..2d1ae6a5925 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -1,20 +1,30 @@
+# rubocop:disable Metrics/AbcSize
+
module Gitlab
module GonHelper
+ include WebpackHelper
+
def add_gon_variables
gon.api_version = 'v4'
gon.default_avatar_url = URI.join(Gitlab.config.gitlab.url, ActionController::Base.helpers.image_path('no_avatar.png')).to_s
gon.max_file_size = current_application_settings.max_attachment_size
gon.asset_host = ActionController::Base.asset_host
+ gon.webpack_public_path = webpack_public_path
gon.relative_url_root = Gitlab.config.gitlab.relative_url_root
gon.shortcuts_path = help_page_path('shortcuts')
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css')
gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js')
+ gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled
+ gon.gitlab_url = Gitlab.config.gitlab.url
+ gon.revision = Gitlab::REVISION
+ gon.gitlab_logo = ActionController::Base.helpers.asset_path('gitlab_logo.png')
if current_user
gon.current_user_id = current_user.id
gon.current_username = current_user.username
gon.current_user_fullname = current_user.name
+ gon.current_user_avatar_url = current_user.avatar_url
end
end
end
diff --git a/lib/gitlab/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb
index 890bd9a3554..b1dbf554e41 100644
--- a/lib/gitlab/google_code_import/client.rb
+++ b/lib/gitlab/google_code_import/client.rb
@@ -14,7 +14,7 @@ module Gitlab
end
def valid?
- raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.has_key?("projects")
+ raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.key?("projects")
end
def repos
diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb
index 5ca3e6a95ca..ab38c0c3e34 100644
--- a/lib/gitlab/google_code_import/importer.rb
+++ b/lib/gitlab/google_code_import/importer.rb
@@ -95,7 +95,7 @@ module Gitlab
labels = import_issue_labels(raw_issue)
assignee_id = nil
- if raw_issue.has_key?("owner")
+ if raw_issue.key?("owner")
username = user_map[raw_issue["owner"]["name"]]
if username.start_with?("@")
@@ -108,13 +108,13 @@ module Gitlab
end
issue = Issue.create!(
- iid: raw_issue['id'],
- project_id: project.id,
- title: raw_issue['title'],
- description: body,
- author_id: project.creator_id,
- assignee_id: assignee_id,
- state: raw_issue['state'] == 'closed' ? 'closed' : 'opened'
+ iid: raw_issue['id'],
+ project_id: project.id,
+ title: raw_issue['title'],
+ description: body,
+ author_id: project.creator_id,
+ assignee_ids: [assignee_id],
+ state: raw_issue['state'] == 'closed' ? 'closed' : 'opened'
)
issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true)
@@ -144,7 +144,7 @@ module Gitlab
def import_issue_comments(issue, comments)
Note.transaction do
while raw_comment = comments.shift
- next if raw_comment.has_key?("deletedBy")
+ next if raw_comment.key?("deletedBy")
content = format_content(raw_comment["content"])
updates = format_updates(raw_comment["updates"])
@@ -235,15 +235,15 @@ module Gitlab
def format_updates(raw_updates)
updates = []
- if raw_updates.has_key?("status")
+ if raw_updates.key?("status")
updates << "*Status: #{raw_updates["status"]}*"
end
- if raw_updates.has_key?("owner")
+ if raw_updates.key?("owner")
updates << "*Owner: #{user_map[raw_updates["owner"]]}*"
end
- if raw_updates.has_key?("cc")
+ if raw_updates.key?("cc")
cc = raw_updates["cc"].map do |l|
deleted = l.start_with?("-")
l = l[1..-1] if deleted
@@ -255,7 +255,7 @@ module Gitlab
updates << "*Cc: #{cc.join(", ")}*"
end
- if raw_updates.has_key?("labels")
+ if raw_updates.key?("labels")
labels = raw_updates["labels"].map do |l|
deleted = l.start_with?("-")
l = l[1..-1] if deleted
@@ -267,11 +267,11 @@ module Gitlab
updates << "*Labels: #{labels.join(", ")}*"
end
- if raw_updates.has_key?("mergedInto")
+ if raw_updates.key?("mergedInto")
updates << "*Merged into: ##{raw_updates["mergedInto"]}*"
end
- if raw_updates.has_key?("blockedOn")
+ if raw_updates.key?("blockedOn")
blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on|
format_blocking_updates(raw_blocked_on)
end
@@ -279,7 +279,7 @@ module Gitlab
updates << "*Blocked on: #{blocked_ons.join(", ")}*"
end
- if raw_updates.has_key?("blocking")
+ if raw_updates.key?("blocking")
blockings = raw_updates["blocking"].map do |raw_blocked_on|
format_blocking_updates(raw_blocked_on)
end
diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb
new file mode 100644
index 00000000000..45e9f9d65ae
--- /dev/null
+++ b/lib/gitlab/gpg.rb
@@ -0,0 +1,80 @@
+module Gitlab
+ module Gpg
+ extend self
+
+ MUTEX = Mutex.new
+
+ module CurrentKeyChain
+ extend self
+
+ def add(key)
+ GPGME::Key.import(key)
+ end
+
+ def fingerprints_from_key(key)
+ import = GPGME::Key.import(key)
+
+ return [] if import.imported == 0
+
+ import.imports.map(&:fingerprint)
+ end
+ end
+
+ def fingerprints_from_key(key)
+ using_tmp_keychain do
+ CurrentKeyChain.fingerprints_from_key(key)
+ end
+ end
+
+ def primary_keyids_from_key(key)
+ using_tmp_keychain do
+ fingerprints = CurrentKeyChain.fingerprints_from_key(key)
+
+ GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid }
+ end
+ end
+
+ def user_infos_from_key(key)
+ using_tmp_keychain do
+ fingerprints = CurrentKeyChain.fingerprints_from_key(key)
+
+ GPGME::Key.find(:public, fingerprints).flat_map do |raw_key|
+ raw_key.uids.map { |uid| { name: uid.name, email: uid.email } }
+ end
+ end
+ end
+
+ # Allows thread safe switching of temporary keychain files
+ #
+ # 1. The current thread may use nesting of temporary keychain
+ # 2. Another thread needs to wait for the lock to be released
+ def using_tmp_keychain(&block)
+ if MUTEX.locked? && MUTEX.owned?
+ optimistic_using_tmp_keychain(&block)
+ else
+ MUTEX.synchronize do
+ optimistic_using_tmp_keychain(&block)
+ end
+ end
+ end
+
+ # 1. Returns the custom home directory if one has been set by calling
+ # `GPGME::Engine.home_dir=`
+ # 2. Returns the default home directory otherwise
+ def current_home_dir
+ GPGME::Engine.info.first.home_dir || GPGME::Engine.dirinfo('homedir')
+ end
+
+ private
+
+ def optimistic_using_tmp_keychain
+ previous_dir = current_home_dir
+ Dir.mktmpdir do |dir|
+ GPGME::Engine.home_dir = dir
+ yield
+ end
+ ensure
+ GPGME::Engine.home_dir = previous_dir
+ end
+ end
+end
diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb
new file mode 100644
index 00000000000..606c7576f70
--- /dev/null
+++ b/lib/gitlab/gpg/commit.rb
@@ -0,0 +1,97 @@
+module Gitlab
+ module Gpg
+ class Commit
+ def self.for_commit(commit)
+ new(commit.project, commit.sha)
+ end
+
+ def initialize(project, sha)
+ @project = project
+ @sha = sha
+
+ @signature_text, @signed_text =
+ begin
+ Rugged::Commit.extract_signature(project.repository.rugged, sha)
+ rescue Rugged::OdbError
+ nil
+ end
+ end
+
+ def has_signature?
+ !!(@signature_text && @signed_text)
+ end
+
+ def signature
+ return unless has_signature?
+
+ return @signature if @signature
+
+ cached_signature = GpgSignature.find_by(commit_sha: @sha)
+ return @signature = cached_signature if cached_signature.present?
+
+ @signature = create_cached_signature!
+ end
+
+ def update_signature!(cached_signature)
+ using_keychain do |gpg_key|
+ cached_signature.update_attributes!(attributes(gpg_key))
+ end
+
+ @signature = cached_signature
+ end
+
+ private
+
+ def using_keychain
+ Gitlab::Gpg.using_tmp_keychain do
+ # first we need to get the keyid from the signature to query the gpg
+ # key belonging to the keyid.
+ # This way we can add the key to the temporary keychain and extract
+ # the proper signature.
+ gpg_key = GpgKey.find_by(primary_keyid: verified_signature.fingerprint)
+
+ if gpg_key
+ Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key)
+ @verified_signature = nil
+ end
+
+ yield gpg_key
+ end
+ end
+
+ def verified_signature
+ @verified_signature ||= GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature|
+ break verified_signature
+ end
+ end
+
+ def create_cached_signature!
+ using_keychain do |gpg_key|
+ GpgSignature.create!(attributes(gpg_key))
+ end
+ end
+
+ def attributes(gpg_key)
+ user_infos = user_infos(gpg_key)
+
+ {
+ commit_sha: @sha,
+ project: @project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint,
+ gpg_key_user_name: user_infos[:name],
+ gpg_key_user_email: user_infos[:email],
+ valid_signature: gpg_signature_valid_signature_value(gpg_key)
+ }
+ end
+
+ def gpg_signature_valid_signature_value(gpg_key)
+ !!(gpg_key && gpg_key.verified? && verified_signature.valid?)
+ end
+
+ def user_infos(gpg_key)
+ gpg_key&.verified_user_infos&.first || gpg_key&.user_infos&.first || {}
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb
new file mode 100644
index 00000000000..a525ee7a9ee
--- /dev/null
+++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module Gpg
+ class InvalidGpgSignatureUpdater
+ def initialize(gpg_key)
+ @gpg_key = gpg_key
+ end
+
+ def run
+ GpgSignature
+ .select(:id, :commit_sha, :project_id)
+ .where('gpg_key_id IS NULL OR valid_signature = ?', false)
+ .where(gpg_key_primary_keyid: @gpg_key.primary_keyid)
+ .find_each { |sig| sig.gpg_commit.update_signature!(sig) }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb
new file mode 100644
index 00000000000..5a31e56cb30
--- /dev/null
+++ b/lib/gitlab/group_hierarchy.rb
@@ -0,0 +1,111 @@
+module Gitlab
+ # Retrieving of parent or child groups based on a base ActiveRecord relation.
+ #
+ # This class uses recursive CTEs and as a result will only work on PostgreSQL.
+ class GroupHierarchy
+ attr_reader :ancestors_base, :descendants_base, :model
+
+ # ancestors_base - An instance of ActiveRecord::Relation for which to
+ # get parent groups.
+ # descendants_base - An instance of ActiveRecord::Relation for which to
+ # get child groups. If omitted, ancestors_base is used.
+ def initialize(ancestors_base, descendants_base = ancestors_base)
+ raise ArgumentError.new("Model of ancestors_base does not match model of descendants_base") if ancestors_base.model != descendants_base.model
+
+ @ancestors_base = ancestors_base
+ @descendants_base = descendants_base
+ @model = ancestors_base.model
+ end
+
+ # Returns a relation that includes the ancestors_base set of groups
+ # and all their ancestors (recursively).
+ def base_and_ancestors
+ return ancestors_base unless Group.supports_nested_groups?
+
+ base_and_ancestors_cte.apply_to(model.all)
+ end
+
+ # Returns a relation that includes the descendants_base set of groups
+ # and all their descendants (recursively).
+ def base_and_descendants
+ return descendants_base unless Group.supports_nested_groups?
+
+ base_and_descendants_cte.apply_to(model.all)
+ end
+
+ # Returns a relation that includes the base groups, their ancestors,
+ # and the descendants of the base groups.
+ #
+ # The resulting query will roughly look like the following:
+ #
+ # WITH RECURSIVE ancestors AS ( ... ),
+ # descendants AS ( ... )
+ # SELECT *
+ # FROM (
+ # SELECT *
+ # FROM ancestors namespaces
+ #
+ # UNION
+ #
+ # SELECT *
+ # FROM descendants namespaces
+ # ) groups;
+ #
+ # Using this approach allows us to further add criteria to the relation with
+ # Rails thinking it's selecting data the usual way.
+ #
+ # If nested groups are not supported, ancestors_base is returned.
+ def all_groups
+ return ancestors_base unless Group.supports_nested_groups?
+
+ ancestors = base_and_ancestors_cte
+ descendants = base_and_descendants_cte
+
+ ancestors_table = ancestors.alias_to(groups_table)
+ descendants_table = descendants.alias_to(groups_table)
+
+ union = SQL::Union.new([model.unscoped.from(ancestors_table),
+ model.unscoped.from(descendants_table)])
+
+ model
+ .unscoped
+ .with
+ .recursive(ancestors.to_arel, descendants.to_arel)
+ .from("(#{union.to_sql}) #{model.table_name}")
+ end
+
+ private
+
+ def base_and_ancestors_cte
+ cte = SQL::RecursiveCTE.new(:base_and_ancestors)
+
+ cte << ancestors_base.except(:order)
+
+ # Recursively get all the ancestors of the base set.
+ cte << model
+ .from([groups_table, cte.table])
+ .where(groups_table[:id].eq(cte.table[:parent_id]))
+ .except(:order)
+
+ cte
+ end
+
+ def base_and_descendants_cte
+ cte = SQL::RecursiveCTE.new(:base_and_descendants)
+
+ cte << descendants_base.except(:order)
+
+ # Recursively get all the descendants of the base set.
+ cte << model
+ .from([groups_table, cte.table])
+ .where(groups_table[:parent_id].eq(cte.table[:id]))
+ .except(:order)
+
+ cte
+ end
+
+ def groups_table
+ model.arel_table
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/base_abstract_check.rb b/lib/gitlab/health_checks/base_abstract_check.rb
index 7de6d4d9367..8b365dab185 100644
--- a/lib/gitlab/health_checks/base_abstract_check.rb
+++ b/lib/gitlab/health_checks/base_abstract_check.rb
@@ -27,10 +27,10 @@ module Gitlab
Metric.new(name, value, labels)
end
- def with_timing(proc)
+ def with_timing
start = Time.now
- result = proc.call
- yield result, Time.now.to_f - start.to_f
+ result = yield
+ [result, Time.now.to_f - start.to_f]
end
def catch_timeout(seconds, &block)
diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb
index df962d203b7..eef97f54962 100644
--- a/lib/gitlab/health_checks/fs_shards_check.rb
+++ b/lib/gitlab/health_checks/fs_shards_check.rb
@@ -2,56 +2,58 @@ module Gitlab
module HealthChecks
class FsShardsCheck
extend BaseAbstractCheck
+ RANDOM_STRING = SecureRandom.hex(1000).freeze
+ COMMAND_TIMEOUT = '1'.freeze
+ TIMEOUT_EXECUTABLE = 'timeout'.freeze
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)
+ if !storage_circuitbreaker_test(storage_name)
+ HealthChecks::Result.new(false, 'circuitbreaker tripped', shard: storage_name)
+ elsif !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)
+ with_temp_file(storage_name) do |tmp_file_path|
+ if !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
+ end
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)
+ storage_stat_metrics(storage_name),
+ storage_write_metrics(storage_name),
+ storage_read_metrics(storage_name),
+ storage_circuitbreaker_metrics(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
+ def operation_metrics(ok_metric, latency_metric, **labels)
+ result, elapsed = yield
+ [
+ metric(latency_metric, elapsed, **labels),
+ metric(ok_metric, result ? 1 : 0, **labels)
+ ]
rescue RuntimeError => ex
- Rails.logger("unexpected error #{ex} when checking #{ok_metric}")
+ Rails.logger.error("unexpected error #{ex} when checking #{ok_metric}")
[metric(ok_metric, 0, **labels)]
end
@@ -63,53 +65,100 @@ module Gitlab
@storage_paths ||= Gitlab.config.repositories.storages
end
- def with_timeout(args)
- %w{timeout 1}.concat(args)
+ def exec_with_timeout(cmd_args, *args, &block)
+ Gitlab::Popen.popen([TIMEOUT_EXECUTABLE, COMMAND_TIMEOUT].concat(cmd_args), *args, &block)
end
- def tmp_file_path(storage_name)
- Dir::Tmpname.create(%w(fs_shards_check +deleted), path(storage_name)) { |path| path }
+ def with_temp_file(storage_name)
+ temp_file_path = Dir::Tmpname.create(%w(fs_shards_check +deleted), storage_path(storage_name)) { |path| path }
+ yield temp_file_path
+ ensure
+ delete_test_file(temp_file_path)
end
- def path(storage_name)
+ def storage_path(storage_name)
storages_paths&.dig(storage_name, 'path')
end
+ # All below test methods use shell commands to perform actions on storage volumes.
+ # In case a storage volume have connectivity problems causing pure Ruby IO operation to wait indefinitely,
+ # we can rely on shell commands to be terminated once `timeout` kills them.
+ #
+ # However we also fallback to pure Ruby file operations in case a specific shell command is missing
+ # so we are still able to perform healthchecks and gather metrics from such system.
+
+ def delete_test_file(tmp_path)
+ _, status = exec_with_timeout(%W{ rm -f #{tmp_path} })
+ status.zero?
+ rescue Errno::ENOENT
+ File.delete(tmp_path) rescue Errno::ENOENT
+ end
+
def storage_stat_test(storage_name)
- stat_path = File.join(path(storage_name), '.')
+ stat_path = File.join(storage_path(storage_name), '.')
begin
- _, status = Gitlab::Popen.popen(with_timeout(%W{ stat #{stat_path} }))
- status == 0
+ _, status = exec_with_timeout(%W{ stat #{stat_path} })
+ status.zero?
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|
+ _, status = exec_with_timeout(%W{ tee #{tmp_path} }) do |stdin|
stdin.write(RANDOM_STRING)
end
- status == 0
+ status.zero?
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|
+ _, status = exec_with_timeout(%W{ diff #{tmp_path} - }) do |stdin|
stdin.write(RANDOM_STRING)
end
- status == 0
+ status.zero?
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
+ def storage_circuitbreaker_test(storage_name)
+ Gitlab::Git::Storage::CircuitBreaker.new(storage_name).perform { "OK" }
+ rescue Gitlab::Git::Storage::Inaccessible
+ nil
+ end
+
+ def storage_stat_metrics(storage_name)
+ operation_metrics(:filesystem_accessible, :filesystem_access_latency_seconds, shard: storage_name) do
+ with_timing { storage_stat_test(storage_name) }
+ end
+ end
+
+ def storage_write_metrics(storage_name)
+ operation_metrics(:filesystem_writable, :filesystem_write_latency_seconds, shard: storage_name) do
+ with_temp_file(storage_name) do |tmp_file_path|
+ with_timing { storage_write_test(tmp_file_path) }
+ end
+ end
+ end
+
+ def storage_read_metrics(storage_name)
+ operation_metrics(:filesystem_readable, :filesystem_read_latency_seconds, shard: storage_name) do
+ with_temp_file(storage_name) do |tmp_file_path|
+ storage_write_test(tmp_file_path) # writes data used by read test
+ with_timing { storage_read_test(tmp_file_path) }
+ end
+ end
+ end
+
+ def storage_circuitbreaker_metrics(storage_name)
+ operation_metrics(:filesystem_circuitbreaker,
+ :filesystem_circuitbreaker_latency_seconds,
+ shard: storage_name) do
+ with_timing { storage_circuitbreaker_test(storage_name) }
+ end
end
end
end
diff --git a/lib/gitlab/health_checks/prometheus_text_format.rb b/lib/gitlab/health_checks/prometheus_text_format.rb
new file mode 100644
index 00000000000..b3c759b4730
--- /dev/null
+++ b/lib/gitlab/health_checks/prometheus_text_format.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module HealthChecks
+ class PrometheusTextFormat
+ def marshal(metrics)
+ "#{metrics_with_type_declarations(metrics).join("\n")}\n"
+ end
+
+ private
+
+ def metrics_with_type_declarations(metrics)
+ type_declaration_added = {}
+
+ metrics.flat_map do |metric|
+ metric_lines = []
+
+ unless type_declaration_added.key?(metric.name)
+ type_declaration_added[metric.name] = true
+ metric_lines << metric_type_declaration(metric)
+ end
+
+ metric_lines << metric_text(metric)
+ end
+ end
+
+ def metric_type_declaration(metric)
+ "# TYPE #{metric.name} gauge"
+ end
+
+ def metric_text(metric)
+ labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
+
+ if labels.empty?
+ "#{metric.name} #{metric.value}"
+ else
+ "#{metric.name}{#{labels}} #{metric.value}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/redis/cache_check.rb b/lib/gitlab/health_checks/redis/cache_check.rb
new file mode 100644
index 00000000000..a28658d42d4
--- /dev/null
+++ b/lib/gitlab/health_checks/redis/cache_check.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module HealthChecks
+ module Redis
+ class CacheCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ def check_up
+ check
+ end
+
+ private
+
+ def metric_prefix
+ 'redis_cache_ping'
+ end
+
+ def is_successful?(result)
+ result == 'PONG'
+ end
+
+ def check
+ catch_timeout 10.seconds do
+ Gitlab::Redis::Cache.with(&:ping)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/redis/queues_check.rb b/lib/gitlab/health_checks/redis/queues_check.rb
new file mode 100644
index 00000000000..f97d50d3947
--- /dev/null
+++ b/lib/gitlab/health_checks/redis/queues_check.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module HealthChecks
+ module Redis
+ class QueuesCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ def check_up
+ check
+ end
+
+ private
+
+ def metric_prefix
+ 'redis_queues_ping'
+ end
+
+ def is_successful?(result)
+ result == 'PONG'
+ end
+
+ def check
+ catch_timeout 10.seconds do
+ Gitlab::Redis::Queues.with(&:ping)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/redis/redis_check.rb b/lib/gitlab/health_checks/redis/redis_check.rb
new file mode 100644
index 00000000000..fe4e3c4a3ab
--- /dev/null
+++ b/lib/gitlab/health_checks/redis/redis_check.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module HealthChecks
+ module Redis
+ class RedisCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ private
+
+ def metric_prefix
+ 'redis_ping'
+ end
+
+ def is_successful?(result)
+ result == 'PONG'
+ end
+
+ def check
+ ::Gitlab::HealthChecks::Redis::CacheCheck.check_up &&
+ ::Gitlab::HealthChecks::Redis::QueuesCheck.check_up &&
+ ::Gitlab::HealthChecks::Redis::SharedStateCheck.check_up
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/redis/shared_state_check.rb b/lib/gitlab/health_checks/redis/shared_state_check.rb
new file mode 100644
index 00000000000..e3244392902
--- /dev/null
+++ b/lib/gitlab/health_checks/redis/shared_state_check.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module HealthChecks
+ module Redis
+ class SharedStateCheck
+ extend SimpleAbstractCheck
+
+ class << self
+ def check_up
+ check
+ end
+
+ private
+
+ def metric_prefix
+ 'redis_shared_state_ping'
+ end
+
+ def is_successful?(result)
+ result == 'PONG'
+ end
+
+ def check
+ catch_timeout 10.seconds do
+ Gitlab::Redis::SharedState.with(&:ping)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/redis_check.rb b/lib/gitlab/health_checks/redis_check.rb
deleted file mode 100644
index 57bbe5b3ad0..00000000000
--- a/lib/gitlab/health_checks/redis_check.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-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/simple_abstract_check.rb b/lib/gitlab/health_checks/simple_abstract_check.rb
index fbe1645c1b1..f5026171ba4 100644
--- a/lib/gitlab/health_checks/simple_abstract_check.rb
+++ b/lib/gitlab/health_checks/simple_abstract_check.rb
@@ -15,14 +15,13 @@ module Gitlab
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
+ result, elapsed = with_timing(&method(:check))
+ 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_seconds", elapsed)
+ ]
end
private
diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb
index d787d5db4a0..5408a1a6838 100644
--- a/lib/gitlab/highlight.rb
+++ b/lib/gitlab/highlight.rb
@@ -1,17 +1,11 @@
module Gitlab
class Highlight
def self.highlight(blob_name, blob_content, repository: nil, plain: false)
- new(blob_name, blob_content, repository: repository).
- highlight(blob_content, continue: false, plain: plain)
+ new(blob_name, blob_content, repository: repository)
+ .highlight(blob_content, continue: false, plain: plain)
end
- def self.highlight_lines(repository, ref, file_name)
- blob = repository.blob_at(ref, file_name)
- return [] unless blob
-
- blob.load_all_data!(repository)
- highlight(file_name, blob.data, repository: repository).lines.map!(&:html_safe)
- end
+ attr_reader :blob_name
def initialize(blob_name, blob_content, repository: nil)
@formatter = Rouge::Formatters::HTMLGitlab
@@ -21,16 +15,9 @@ module Gitlab
end
def highlight(text, continue: true, plain: false)
- if plain
- hl_lexer = Rouge::Lexers::PlainText
- continue = false
- else
- hl_lexer = self.lexer
- end
-
- @formatter.format(hl_lexer.lex(text, continue: continue), tag: hl_lexer.tag).html_safe
- rescue
- @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe
+ highlighted_text = highlight_text(text, continue: continue, plain: plain)
+ highlighted_text = link_dependencies(text, highlighted_text) if blob_name
+ highlighted_text
end
def lexer
@@ -50,5 +37,27 @@ module Gitlab
Rouge::Lexer.find_fancy(language_name)
end
+
+ def highlight_text(text, continue: true, plain: false)
+ if plain
+ highlight_plain(text)
+ else
+ highlight_rich(text, continue: continue)
+ end
+ end
+
+ def highlight_plain(text)
+ @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe
+ end
+
+ def highlight_rich(text, continue: true)
+ @formatter.format(lexer.lex(text, continue: continue), tag: lexer.tag).html_safe
+ rescue
+ highlight_plain(text)
+ end
+
+ def link_dependencies(text, highlighted_text)
+ Gitlab::DependencyLinker.link(blob_name, text, highlighted_text)
+ end
end
end
diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb
new file mode 100644
index 00000000000..5d106b5c075
--- /dev/null
+++ b/lib/gitlab/i18n.rb
@@ -0,0 +1,59 @@
+module Gitlab
+ module I18n
+ extend self
+
+ AVAILABLE_LANGUAGES = {
+ 'en' => 'English',
+ 'es' => 'Español',
+ 'de' => 'Deutsch',
+ 'fr' => 'Français',
+ 'pt_BR' => 'Português (Brasil)',
+ 'zh_CN' => '简体中文',
+ 'zh_HK' => 'ç¹é«”中文 (香港)',
+ 'zh_TW' => 'ç¹é«”中文 (臺ç£)',
+ 'bg' => 'българÑки',
+ 'ru' => 'РуÑÑкий',
+ 'eo' => 'Esperanto',
+ 'it' => 'Italiano',
+ 'uk' => 'УкраїнÑька',
+ 'ja' => '日本語',
+ 'ko' => '한국어'
+ }.freeze
+
+ def available_locales
+ AVAILABLE_LANGUAGES.keys
+ end
+
+ def locale
+ FastGettext.locale
+ end
+
+ def locale=(locale_string)
+ requested_locale = locale_string || ::I18n.default_locale
+ new_locale = FastGettext.set_locale(requested_locale)
+ ::I18n.locale = new_locale
+ end
+
+ def use_default_locale
+ FastGettext.set_locale(::I18n.default_locale)
+ ::I18n.locale = ::I18n.default_locale
+ end
+
+ def with_locale(locale_string)
+ original_locale = locale
+
+ self.locale = locale_string
+ yield
+ ensure
+ self.locale = original_locale
+ end
+
+ def with_user_locale(user, &block)
+ with_locale(user&.preferred_language, &block)
+ end
+
+ def with_default_locale(&block)
+ with_locale(::I18n.default_locale, &block)
+ end
+ end
+end
diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb
index 8b327cfc226..3470a09eaf0 100644
--- a/lib/gitlab/import_export.rb
+++ b/lib/gitlab/import_export.rb
@@ -3,7 +3,7 @@ module Gitlab
extend self
# For every version update, the version history in import_export.md has to be kept up to date.
- VERSION = '0.1.6'.freeze
+ VERSION = '0.1.8'.freeze
FILENAME_LIMIT = 50
def export_path(relative_path:)
diff --git a/lib/gitlab/import_export/attributes_finder.rb b/lib/gitlab/import_export/attributes_finder.rb
index d230de781d5..56042ddecbf 100644
--- a/lib/gitlab/import_export/attributes_finder.rb
+++ b/lib/gitlab/import_export/attributes_finder.rb
@@ -1,7 +1,6 @@
module Gitlab
module ImportExport
class AttributesFinder
-
def initialize(included_attributes:, excluded_attributes:, methods:)
@included_attributes = included_attributes || {}
@excluded_attributes = excluded_attributes || {}
diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb
index ffd17118c91..989342389bc 100644
--- a/lib/gitlab/import_export/file_importer.rb
+++ b/lib/gitlab/import_export/file_importer.rb
@@ -47,12 +47,16 @@ module Gitlab
end
def remove_symlinks!
- Dir["#{@shared.export_path}/**/*"].each do |path|
+ extracted_files.each do |path|
FileUtils.rm(path) if File.lstat(path).symlink?
end
true
end
+
+ def extracted_files
+ Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| f =~ /.*\/\.{1,2}$/ }
+ end
end
end
end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index 899a6567768..9d9ebcb389a 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -3,18 +3,22 @@ project_tree:
- labels:
:priorities
- milestones:
- - :events
+ - events:
+ - :push_event_payload
- issues:
- - :events
+ - events:
+ - :push_event_payload
- :timelogs
- notes:
- :author
- - :events
+ - events:
+ - :push_event_payload
- label_links:
- label:
:priorities
- milestone:
- - :events
+ - events:
+ - :push_event_payload
- snippets:
- :award_emoji
- notes:
@@ -25,23 +29,29 @@ project_tree:
- merge_requests:
- notes:
- :author
- - :events
- - :merge_request_diff
- - :events
+ - events:
+ - :push_event_payload
+ - merge_request_diff:
+ - :merge_request_diff_commits
+ - :merge_request_diff_files
+ - events:
+ - :push_event_payload
- :timelogs
- label_links:
- label:
:priorities
- milestone:
- - :events
+ - events:
+ - :push_event_payload
- pipelines:
- notes:
- :author
- - :events
+ - events:
+ - :push_event_payload
+ - :stages
- :statuses
- - triggers:
- - :trigger_schedule
- - :deploy_keys
+ - :triggers
+ - :pipeline_schedules
- :services
- :hooks
- protected_branches:
@@ -53,10 +63,6 @@ project_tree:
# Only include the following attributes for the models specified.
included_attributes:
- project:
- - :description
- - :visibility_level
- - :archived
user:
- :id
- :email
@@ -66,19 +72,51 @@ included_attributes:
# Do not include the following attributes for the models specified.
excluded_attributes:
+ project:
+ - :name
+ - :path
+ - :namespace_id
+ - :creator_id
+ - :import_url
+ - :import_status
+ - :avatar
+ - :import_type
+ - :import_source
+ - :import_error
+ - :mirror
+ - :runners_token
+ - :repository_storage
+ - :repository_read_only
+ - :lfs_enabled
+ - :import_jid
+ - :created_at
+ - :updated_at
+ - :import_jid
+ - :import_jid
+ - :id
+ - :star_count
+ - :last_activity_at
+ - :last_repository_updated_at
+ - :last_repository_check_at
snippets:
- :expired_at
merge_request_diff:
- :st_diffs
+ merge_request_diff_files:
+ - :diff
issues:
- :milestone_id
merge_requests:
- :milestone_id
+ - :ref_fetched
+ - :merge_jid
award_emoji:
- :awardable_id
statuses:
- :trace
- :token
+ push_event_payload:
+ - :event_id
methods:
labels:
@@ -87,10 +125,13 @@ methods:
- :type
statuses:
- :type
- - :gl_project_id
services:
- :type
merge_request_diff:
- :utf8_st_diffs
+ merge_request_diff_files:
+ - :utf8_diff
merge_requests:
- :diff_head_sha
+ project:
+ - :description_html
diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb
index 48c09dafcb6..b48f63bcd7e 100644
--- a/lib/gitlab/import_export/json_hash_builder.rb
+++ b/lib/gitlab/import_export/json_hash_builder.rb
@@ -83,7 +83,9 @@ module Gitlab
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def add_model_value(current_key, value, json_config_hash)
- @attributes_finder.parse(value) { |hash| value = { value => hash } }
+ @attributes_finder.parse(value) do |hash|
+ value = { value => hash } unless value.is_a?(Hash)
+ end
add_to_array(current_key, json_config_hash, value)
end
diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb
index 2e349b5f9a9..cbc8d170936 100644
--- a/lib/gitlab/import_export/project_tree_restorer.rb
+++ b/lib/gitlab/import_export/project_tree_restorer.rb
@@ -1,6 +1,9 @@
module Gitlab
module ImportExport
class ProjectTreeRestorer
+ # Relations which cannot have both group_id and project_id at the same time
+ RESTRICT_PROJECT_AND_GROUP = %i(milestones).freeze
+
def initialize(user:, shared:, project:)
@path = File.join(shared.export_path, 'project.json')
@user = user
@@ -71,14 +74,14 @@ module Gitlab
def restore_project
return @project unless @tree_hash
- @project.update(project_params)
+ @project.update_columns(project_params)
@project
end
def project_params
@tree_hash.reject do |key, value|
# return params that are not 1 to many or 1 to 1 relations
- value.is_a?(Array) || key == key.singularize
+ value.respond_to?(:each) && !Project.column_names.include?(key)
end
end
@@ -118,9 +121,11 @@ module Gitlab
end
def create_relation(relation, relation_hash_list)
+ relation_type = relation.to_sym
+
relation_array = [relation_hash_list].flatten.map do |relation_hash|
- Gitlab::ImportExport::RelationFactory.create(relation_sym: relation.to_sym,
- relation_hash: parsed_relation_hash(relation_hash),
+ Gitlab::ImportExport::RelationFactory.create(relation_sym: relation_type,
+ relation_hash: parsed_relation_hash(relation_hash, relation_type),
members_mapper: members_mapper,
user: @user,
project: restored_project)
@@ -129,8 +134,16 @@ module Gitlab
relation_hash_list.is_a?(Array) ? relation_array : relation_array.first
end
- def parsed_relation_hash(relation_hash)
- relation_hash.merge!('group_id' => restored_project.group.try(:id), 'project_id' => restored_project.id)
+ def parsed_relation_hash(relation_hash, relation_type)
+ if RESTRICT_PROJECT_AND_GROUP.include?(relation_type)
+ params = {}
+ params['group_id'] = restored_project.group.try(:id) if relation_hash['group_id']
+ params['project_id'] = restored_project.id if relation_hash['project_id']
+ else
+ params = { 'group_id' => restored_project.group.try(:id), 'project_id' => restored_project.id }
+ end
+
+ relation_hash.merge(params)
end
end
end
diff --git a/lib/gitlab/import_export/reader.rb b/lib/gitlab/import_export/reader.rb
index a1e7159fe42..eb7f5120592 100644
--- a/lib/gitlab/import_export/reader.rb
+++ b/lib/gitlab/import_export/reader.rb
@@ -15,7 +15,10 @@ module Gitlab
# Outputs a hash in the format described here: http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html
# for outputting a project in JSON format, including its relations and sub relations.
def project_tree
- @attributes_finder.find_included(:project).merge(include: build_hash(@tree))
+ attributes = @attributes_finder.find(:project)
+ project_attributes = attributes.is_a?(Hash) ? attributes[:project] : {}
+
+ project_attributes.merge(include: build_hash(@tree))
rescue => e
@shared.error(e)
false
diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb
index 4a54e7ef2e7..20580459046 100644
--- a/lib/gitlab/import_export/relation_factory.rb
+++ b/lib/gitlab/import_export/relation_factory.rb
@@ -3,9 +3,10 @@ module Gitlab
class RelationFactory
OVERRIDES = { snippets: :project_snippets,
pipelines: 'Ci::Pipeline',
+ stages: 'Ci::Stage',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
- trigger_schedule: 'Ci::TriggerSchedule',
+ pipeline_schedules: 'Ci::PipelineSchedule',
builds: 'Ci::Build',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
@@ -15,7 +16,7 @@ module Gitlab
priorities: :label_priorities,
label: :project_label }.freeze
- USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id merge_user_id resolved_by_id].freeze
+ USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze
PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze
@@ -70,6 +71,7 @@ module Gitlab
@relation_hash['data'].deep_symbolize_keys! if @relation_name == :events && @relation_hash['data']
set_st_diff_commits if @relation_name == :merge_request_diff
+ set_diff if @relation_name == :merge_request_diff_files
end
def update_user_references
@@ -201,6 +203,10 @@ module Gitlab
HashUtil.deep_symbolize_array_with_date!(@relation_hash['st_commits'])
end
+ def set_diff
+ @relation_hash['diff'] = @relation_hash.delete('utf8_diff')
+ end
+
def existing_or_new_object
# Only find existing records to avoid mapping tables such as milestones
# Otherwise always create the record, skipping the extra SELECT clause.
diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb
index c824d3ea9fc..32ca2809b2f 100644
--- a/lib/gitlab/import_export/repo_restorer.rb
+++ b/lib/gitlab/import_export/repo_restorer.rb
@@ -13,7 +13,7 @@ module Gitlab
def restore
return true unless File.exist?(@path_to_bundle)
- gitlab_shell.import_repository(@project.repository_storage_path, @project.path_with_namespace, @path_to_bundle)
+ gitlab_shell.import_repository(@project.repository_storage_path, @project.disk_path, @path_to_bundle)
rescue => e
@shared.error(e)
false
diff --git a/lib/gitlab/import_export/uploads_saver.rb b/lib/gitlab/import_export/uploads_saver.rb
index 62a2553675c..f9ae5079d7c 100644
--- a/lib/gitlab/import_export/uploads_saver.rb
+++ b/lib/gitlab/import_export/uploads_saver.rb
@@ -24,6 +24,7 @@ module Gitlab
end
def uploads_path
+ # TODO: decide what to do with uploads. We will use UUIDs here too?
File.join(Rails.root.join('public/uploads'), @project.path_with_namespace)
end
end
diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb
index 52276cbcd9a..5404dc11a87 100644
--- a/lib/gitlab/import_sources.rb
+++ b/lib/gitlab/import_sources.rb
@@ -8,7 +8,7 @@ module Gitlab
ImportSource = Struct.new(:name, :title, :importer)
ImportTable = [
- ImportSource.new('github', 'GitHub', Gitlab::GithubImport::Importer),
+ ImportSource.new('github', 'GitHub', Github::Import),
ImportSource.new('bitbucket', 'Bitbucket', Gitlab::BitbucketImport::Importer),
ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer),
ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer),
diff --git a/lib/gitlab/issuable_metadata.rb b/lib/gitlab/issuable_metadata.rb
new file mode 100644
index 00000000000..977c05910d3
--- /dev/null
+++ b/lib/gitlab/issuable_metadata.rb
@@ -0,0 +1,36 @@
+module Gitlab
+ module IssuableMetadata
+ def issuable_meta_data(issuable_collection, collection_type)
+ # map has to be used here since using pluck or select will
+ # throw an error when ordering issuables by priority which inserts
+ # a new order into the collection.
+ # We cannot use reorder to not mess up the paginated collection.
+ issuable_ids = issuable_collection.map(&:id)
+
+ return {} if issuable_ids.empty?
+
+ issuable_note_count = ::Note.count_for_collection(issuable_ids, collection_type)
+ issuable_votes_count = ::AwardEmoji.votes_for_collection(issuable_ids, collection_type)
+ issuable_merge_requests_count =
+ if collection_type == 'Issue'
+ ::MergeRequestsClosingIssues.count_for_collection(issuable_ids)
+ else
+ []
+ end
+
+ issuable_ids.each_with_object({}) do |id, issuable_meta|
+ downvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? }
+ upvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? }
+ notes = issuable_note_count.find { |notes| notes.noteable_id == id }
+ merge_requests = issuable_merge_requests_count.find { |mr| mr.first == id }
+
+ issuable_meta[id] = ::Issuable::IssuableMeta.new(
+ upvotes.try(:count).to_i,
+ downvotes.try(:count).to_i,
+ notes.try(:count).to_i,
+ merge_requests.try(:last).to_i
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/job_waiter.rb b/lib/gitlab/job_waiter.rb
index 8db91d25a4b..208f0e1bbea 100644
--- a/lib/gitlab/job_waiter.rb
+++ b/lib/gitlab/job_waiter.rb
@@ -14,7 +14,7 @@ module Gitlab
# timeout - The maximum amount of seconds to block the caller for. This
# ensures we don't indefinitely block a caller in case a job takes
# long to process, or is never processed.
- def wait(timeout = 60)
+ def wait(timeout = 10)
start = Time.current
while (Time.current - start) <= timeout
diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb
index b75ae512d92..d9a79f7c291 100644
--- a/lib/gitlab/key_fingerprint.rb
+++ b/lib/gitlab/key_fingerprint.rb
@@ -1,55 +1,48 @@
module Gitlab
class KeyFingerprint
- include Gitlab::Popen
+ attr_reader :key, :ssh_key
- attr_accessor :key
+ # Unqualified MD5 fingerprint for compatibility
+ delegate :fingerprint, to: :ssh_key, allow_nil: true
def initialize(key)
@key = key
- end
-
- def fingerprint
- cmd_status = 0
- cmd_output = ''
-
- Tempfile.open('gitlab_key_file') do |file|
- file.puts key
- file.rewind
-
- cmd = []
- cmd.push('ssh-keygen')
- cmd.push('-E', 'md5') if explicit_fingerprint_algorithm?
- cmd.push('-lf', file.path)
-
- cmd_output, cmd_status = popen(cmd, '/tmp')
- end
-
- return nil unless cmd_status.zero?
- # 16 hex bytes separated by ':', optionally starting with "MD5:"
- fingerprint_matches = cmd_output.match(/(MD5:)?(?<fingerprint>(\h{2}:){15}\h{2})/)
- return nil unless fingerprint_matches
-
- fingerprint_matches[:fingerprint]
+ @ssh_key =
+ begin
+ Net::SSH::KeyFactory.load_data_public_key(key)
+ rescue Net::SSH::Exception, NotImplementedError
+ end
end
- private
-
- def explicit_fingerprint_algorithm?
- # OpenSSH 6.8 introduces a new default output format for fingerprints.
- # Check the version and decide which command to use.
-
- version_output, version_status = popen(%w(ssh -V))
- return false unless version_status.zero?
+ def valid?
+ ssh_key.present?
+ end
- version_matches = version_output.match(/OpenSSH_(?<major>\d+)\.(?<minor>\d+)/)
- return false unless version_matches
+ def type
+ return unless valid?
- version_info = Gitlab::VersionInfo.new(version_matches[:major].to_i, version_matches[:minor].to_i)
+ parts = ssh_key.ssh_type.split('-')
+ parts.shift if parts[0] == 'ssh'
- required_version_info = Gitlab::VersionInfo.new(6, 8)
+ parts[0].upcase
+ end
- version_info >= required_version_info
+ def bits
+ return unless valid?
+
+ case type
+ when 'RSA'
+ ssh_key.n.num_bits
+ when 'DSS', 'DSA'
+ ssh_key.p.num_bits
+ when 'ECDSA'
+ ssh_key.group.order.num_bits
+ when 'ED25519'
+ 256
+ else
+ raise "Unsupported key type: #{type}"
+ end
end
end
end
diff --git a/lib/gitlab/kubernetes.rb b/lib/gitlab/kubernetes.rb
index 3a7af363548..cdbdfa10d0e 100644
--- a/lib/gitlab/kubernetes.rb
+++ b/lib/gitlab/kubernetes.rb
@@ -8,13 +8,13 @@ module Gitlab
)
# Filters an array of pods (as returned by the kubernetes API) by their labels
- def filter_pods(pods, labels = {})
- pods.select do |pod|
- metadata = pod.fetch("metadata", {})
- pod_labels = metadata.fetch("labels", nil)
- next unless pod_labels
+ def filter_by_label(items, labels = {})
+ items.select do |item|
+ metadata = item.fetch("metadata", {})
+ item_labels = metadata.fetch("labels", nil)
+ next unless item_labels
- labels.all? { |k, v| pod_labels[k.to_s] == v }
+ labels.all? { |k, v| item_labels[k.to_s] == v }
end
end
@@ -38,7 +38,7 @@ module Gitlab
url: container_exec_url(api_url, namespace, pod_name, container["name"]),
subprotocols: ['channel.k8s.io'],
headers: Hash.new { |h, k| h[k] = [] },
- created_at: created_at,
+ created_at: created_at
}
end
end
@@ -64,7 +64,7 @@ module Gitlab
tty: true,
stdin: true,
stdout: true,
- stderr: true,
+ stderr: true
}.to_query + '&' + EXEC_COMMAND
case url.scheme
@@ -76,5 +76,44 @@ module Gitlab
url.to_s
end
+
+ def to_kubeconfig(url:, namespace:, token:, ca_pem: nil)
+ config = {
+ apiVersion: 'v1',
+ clusters: [
+ name: 'gitlab-deploy',
+ cluster: {
+ server: url
+ }
+ ],
+ contexts: [
+ name: 'gitlab-deploy',
+ context: {
+ cluster: 'gitlab-deploy',
+ namespace: namespace,
+ user: 'gitlab-deploy'
+ }
+ ],
+ 'current-context': 'gitlab-deploy',
+ kind: 'Config',
+ users: [
+ {
+ name: 'gitlab-deploy',
+ user: { token: token }
+ }
+ ]
+ }
+
+ kubeconfig_embed_ca_pem(config, ca_pem) if ca_pem
+
+ config.deep_stringify_keys
+ end
+
+ private
+
+ def kubeconfig_embed_ca_pem(config, ca_pem)
+ cluster = config.dig(:clusters, 0, :cluster)
+ cluster[:'certificate-authority-data'] = Base64.encode64(ca_pem)
+ end
end
end
diff --git a/lib/gitlab/lazy.rb b/lib/gitlab/lazy.rb
index 2a659ae4c74..99594577141 100644
--- a/lib/gitlab/lazy.rb
+++ b/lib/gitlab/lazy.rb
@@ -16,7 +16,7 @@ module Gitlab
def method_missing(name, *args, &block)
__evaluate__
- @result.__send__(name, *args, &block)
+ @result.__send__(name, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def respond_to_missing?(name, include_private = false)
diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb
index 54a5b1d31cd..fb68627dedf 100644
--- a/lib/gitlab/ldap/access.rb
+++ b/lib/gitlab/ldap/access.rb
@@ -16,8 +16,8 @@ module Gitlab
def self.allowed?(user)
self.open(user) do |access|
if access.allowed?
- user.last_credential_check_at = Time.now
- user.save
+ Users::UpdateService.new(user, last_credential_check_at: Time.now).execute
+
true
else
false
diff --git a/lib/gitlab/ldap/adapter.rb b/lib/gitlab/ldap/adapter.rb
index 7b05290e5cc..8867a91c244 100644
--- a/lib/gitlab/ldap/adapter.rb
+++ b/lib/gitlab/ldap/adapter.rb
@@ -101,7 +101,7 @@ module Gitlab
end
def user_attributes
- %W(#{config.uid} cn mail dn)
+ %W(#{config.uid} cn dn) + config.attributes['username'] + config.attributes['email']
end
end
end
diff --git a/lib/gitlab/ldap/auth_hash.rb b/lib/gitlab/ldap/auth_hash.rb
index 95378e5a769..4fbc5fa5262 100644
--- a/lib/gitlab/ldap/auth_hash.rb
+++ b/lib/gitlab/ldap/auth_hash.rb
@@ -17,7 +17,7 @@ module Gitlab
value = value.first if value
break if value.present?
end
-
+
return super unless value
Gitlab::Utils.force_utf8(value)
diff --git a/lib/gitlab/ldap/authentication.rb b/lib/gitlab/ldap/authentication.rb
index 4745311402c..ed1de73f8c6 100644
--- a/lib/gitlab/ldap/authentication.rb
+++ b/lib/gitlab/ldap/authentication.rb
@@ -42,7 +42,7 @@ module Gitlab
end
def adapter
- OmniAuth::LDAP::Adaptor.new(config.options.symbolize_keys)
+ OmniAuth::LDAP::Adaptor.new(config.omniauth_options)
end
def config
diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb
index 46deea3cc9f..c8f19cd52d5 100644
--- a/lib/gitlab/ldap/config.rb
+++ b/lib/gitlab/ldap/config.rb
@@ -2,6 +2,12 @@
module Gitlab
module LDAP
class Config
+ NET_LDAP_ENCRYPTION_METHOD = {
+ simple_tls: :simple_tls,
+ start_tls: :start_tls,
+ plain: nil
+ }.freeze
+
attr_accessor :provider, :options
def self.enabled?
@@ -12,6 +18,12 @@ module Gitlab
Gitlab.config.ldap.servers.values
end
+ def self.available_servers
+ return [] unless enabled?
+
+ Array.wrap(servers.first)
+ end
+
def self.providers
servers.map { |server| server['provider_name'] }
end
@@ -39,7 +51,7 @@ module Gitlab
def adapter_options
opts = base_options.merge(
- encryption: encryption,
+ encryption: encryption_options
)
opts.merge!(auth_options) if has_auth?
@@ -50,9 +62,10 @@ module Gitlab
def omniauth_options
opts = base_options.merge(
base: base,
- method: options['method'],
+ encryption: options['encryption'],
filter: omniauth_user_filter,
- name_proc: name_proc
+ name_proc: name_proc,
+ disable_verify_certificates: !options['verify_certificates']
)
if has_auth?
@@ -62,6 +75,9 @@ module Gitlab
)
end
+ opts[:ca_file] = options['ca_file'] if options['ca_file'].present?
+ opts[:ssl_version] = options['ssl_version'] if options['ssl_version'].present?
+
opts
end
@@ -157,15 +173,37 @@ module Gitlab
base_config.servers.values.find { |server| server['provider_name'] == provider }
end
- def encryption
- case options['method'].to_s
- when 'ssl'
- :simple_tls
- when 'tls'
- :start_tls
- else
- nil
- end
+ def encryption_options
+ method = translate_method(options['encryption'])
+ return nil unless method
+
+ {
+ method: method,
+ tls_options: tls_options(method)
+ }
+ end
+
+ def translate_method(method_from_config)
+ NET_LDAP_ENCRYPTION_METHOD[method_from_config.to_sym]
+ end
+
+ def tls_options(method)
+ return { verify_mode: OpenSSL::SSL::VERIFY_NONE } unless method
+
+ opts = if options['verify_certificates']
+ OpenSSL::SSL::SSLContext::DEFAULT_PARAMS
+ else
+ # It is important to explicitly set verify_mode for two reasons:
+ # 1. The behavior of OpenSSL is undefined when verify_mode is not set.
+ # 2. The net-ldap gem implementation verifies the certificate hostname
+ # unless verify_mode is set to VERIFY_NONE.
+ { verify_mode: OpenSSL::SSL::VERIFY_NONE }
+ end
+
+ opts[:ca_file] = options['ca_file'] if options['ca_file'].present?
+ opts[:ssl_version] = options['ssl_version'] if options['ssl_version'].present?
+
+ opts
end
def auth_options
diff --git a/lib/gitlab/ldap/person.rb b/lib/gitlab/ldap/person.rb
index 43eb73250b7..e138b466a34 100644
--- a/lib/gitlab/ldap/person.rb
+++ b/lib/gitlab/ldap/person.rb
@@ -32,7 +32,7 @@ module Gitlab
end
def uid
- entry.send(config.uid).first
+ entry.public_send(config.uid).first # rubocop:disable GitlabSecurity/PublicSend
end
def username
@@ -65,7 +65,7 @@ module Gitlab
return nil unless selected_attr
- entry.public_send(selected_attr)
+ entry.public_send(selected_attr) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb
index 2d5e47a6f3b..39180dc17d9 100644
--- a/lib/gitlab/ldap/user.rb
+++ b/lib/gitlab/ldap/user.rb
@@ -10,9 +10,9 @@ module Gitlab
class << self
def find_by_uid_and_provider(uid, provider)
# LDAP distinguished name is case-insensitive
- identity = ::Identity.
- where(provider: provider).
- iwhere(extern_uid: uid).last
+ identity = ::Identity
+ .where(provider: provider)
+ .iwhere(extern_uid: uid).last
identity && identity.user
end
end
@@ -41,11 +41,6 @@ module Gitlab
def update_user_attributes
if persisted?
- if auth_hash.has_email?
- gl_user.skip_reconfirmation!
- gl_user.email = auth_hash.email
- end
-
# find_or_initialize_by doesn't update `gl_user.identities`, and isn't autosaved.
identity = gl_user.identities.find { |identity| identity.provider == auth_hash.provider }
identity ||= gl_user.identities.build(provider: auth_hash.provider)
@@ -55,10 +50,6 @@ module Gitlab
# For an existing identity with no change in DN, this line changes nothing.
identity.extern_uid = auth_hash.uid
end
-
- gl_user.ldap_email = auth_hash.has_email?
-
- gl_user
end
def changed?
@@ -69,6 +60,10 @@ module Gitlab
ldap_config.block_auto_created_users
end
+ def sync_email_from_provider?
+ true
+ end
+
def allowed?
Gitlab::LDAP::Access.allowed?(gl_user)
end
diff --git a/lib/gitlab/lfs_token.rb b/lib/gitlab/lfs_token.rb
index 5f67e97fa2a..8e57ba831c5 100644
--- a/lib/gitlab/lfs_token.rb
+++ b/lib/gitlab/lfs_token.rb
@@ -18,10 +18,10 @@ module Gitlab
end
def token
- Gitlab::Redis.with do |redis|
- token = redis.get(redis_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ token = redis.get(redis_shared_state_key)
token ||= Devise.friendly_token(TOKEN_LENGTH)
- redis.set(redis_key, token, ex: EXPIRY_TIME)
+ redis.set(redis_shared_state_key, token, ex: EXPIRY_TIME)
token
end
@@ -41,7 +41,7 @@ module Gitlab
private
- def redis_key
+ def redis_shared_state_key
"gitlab:lfs_token:#{actor.class.name.underscore}_#{actor.id}" if actor
end
end
diff --git a/lib/gitlab/mail_room.rb b/lib/gitlab/mail_room.rb
index 3503fac40e8..9f432673a6e 100644
--- a/lib/gitlab/mail_room.rb
+++ b/lib/gitlab/mail_room.rb
@@ -1,6 +1,6 @@
require 'yaml'
require 'json'
-require_relative 'redis' unless defined?(Gitlab::Redis)
+require_relative 'redis/queues' unless defined?(Gitlab::Redis::Queues)
module Gitlab
module MailRoom
@@ -34,11 +34,11 @@ module Gitlab
config[:idle_timeout] = 60 if config[:idle_timeout].nil?
if config[:enabled] && config[:address]
- gitlab_redis = Gitlab::Redis.new(rails_env)
- config[:redis_url] = gitlab_redis.url
+ gitlab_redis_queues = Gitlab::Redis::Queues.new(rails_env)
+ config[:redis_url] = gitlab_redis_queues.url
- if gitlab_redis.sentinels?
- config[:sentinels] = gitlab_redis.sentinels
+ if gitlab_redis_queues.sentinels?
+ config[:sentinels] = gitlab_redis_queues.sentinels
end
end
diff --git a/lib/gitlab/markdown/pipeline.rb b/lib/gitlab/markdown/pipeline.rb
index 699d8b9fc07..306923902e0 100644
--- a/lib/gitlab/markdown/pipeline.rb
+++ b/lib/gitlab/markdown/pipeline.rb
@@ -23,7 +23,7 @@ module Gitlab
define_method(meth) do |text, context|
context = transform_context(context)
- html_pipeline.send(meth, text, context)
+ html_pipeline.__send__(meth, text, context) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
index c6dfa4ad9bd..4779755bb22 100644
--- a/lib/gitlab/metrics.rb
+++ b/lib/gitlab/metrics.rb
@@ -1,158 +1,10 @@
module Gitlab
module Metrics
- extend Gitlab::CurrentSettings
-
- RAILS_ROOT = Rails.root.to_s
- METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s
- PATH_REGEX = /^#{RAILS_ROOT}\/?/
-
- def self.settings
- @settings ||= {
- enabled: current_application_settings[:metrics_enabled],
- pool_size: current_application_settings[:metrics_pool_size],
- timeout: current_application_settings[:metrics_timeout],
- method_call_threshold: current_application_settings[:metrics_method_call_threshold],
- host: current_application_settings[:metrics_host],
- port: current_application_settings[:metrics_port],
- sample_interval: current_application_settings[:metrics_sample_interval] || 15,
- packet_size: current_application_settings[:metrics_packet_size] || 1
- }
- end
+ extend Gitlab::Metrics::InfluxDb
+ extend Gitlab::Metrics::Prometheus
def self.enabled?
- settings[:enabled] || false
- end
-
- def self.mri?
- RUBY_ENGINE == 'ruby'
- end
-
- def self.method_call_threshold
- # This is memoized since this method is called for every instrumented
- # method. Loading data from an external cache on every method call slows
- # things down too much.
- @method_call_threshold ||= settings[:method_call_threshold]
- end
-
- def self.pool
- @pool
- end
-
- def self.submit_metrics(metrics)
- prepared = prepare_metrics(metrics)
-
- pool.with do |connection|
- prepared.each_slice(settings[:packet_size]) do |slice|
- begin
- connection.write_points(slice)
- rescue StandardError
- end
- end
- end
- end
-
- def self.prepare_metrics(metrics)
- metrics.map do |hash|
- new_hash = hash.symbolize_keys
-
- new_hash[:tags].each do |key, value|
- if value.blank?
- new_hash[:tags].delete(key)
- else
- new_hash[:tags][key] = escape_value(value)
- end
- end
-
- new_hash
- end
- end
-
- def self.escape_value(value)
- value.to_s.gsub('=', '\\=')
- end
-
- # Measures the execution time of a block.
- #
- # Example:
- #
- # Gitlab::Metrics.measure(:find_by_username_duration) do
- # User.find_by_username(some_username)
- # end
- #
- # name - The name of the field to store the execution time in.
- #
- # Returns the value yielded by the supplied block.
- def self.measure(name)
- trans = current_transaction
-
- return yield unless trans
-
- real_start = Time.now.to_f
- cpu_start = System.cpu_time
-
- retval = yield
-
- cpu_stop = System.cpu_time
- real_stop = Time.now.to_f
-
- real_time = (real_stop - real_start) * 1000.0
- cpu_time = cpu_stop - cpu_start
-
- trans.increment("#{name}_real_time", real_time)
- trans.increment("#{name}_cpu_time", cpu_time)
- trans.increment("#{name}_call_count", 1)
-
- retval
- end
-
- # Adds a tag to the current transaction (if any)
- #
- # name - The name of the tag to add.
- # value - The value of the tag.
- def self.tag_transaction(name, value)
- trans = current_transaction
-
- trans&.add_tag(name, value)
- end
-
- # Sets the action of the current transaction (if any)
- #
- # action - The name of the action.
- def self.action=(action)
- trans = current_transaction
-
- trans&.action = action
- end
-
- # Tracks an event.
- #
- # See `Gitlab::Metrics::Transaction#add_event` for more details.
- def self.add_event(*args)
- trans = current_transaction
-
- trans&.add_event(*args)
- end
-
- # Returns the prefix to use for the name of a series.
- def self.series_prefix
- @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
- end
-
- # Allow access from other metrics related middlewares
- def self.current_transaction
- Transaction.current
- end
-
- # When enabled this should be set before being used as the usual pattern
- # "@foo ||= bar" is _not_ thread-safe.
- if enabled?
- @pool = ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do
- host = settings[:host]
- port = settings[:port]
-
- InfluxDB::Client.
- new(udp: { host: host, port: port })
- end
+ influx_metrics_enabled? || prometheus_metrics_enabled?
end
end
end
diff --git a/lib/gitlab/metrics/base_sampler.rb b/lib/gitlab/metrics/base_sampler.rb
new file mode 100644
index 00000000000..716d20bb91a
--- /dev/null
+++ b/lib/gitlab/metrics/base_sampler.rb
@@ -0,0 +1,63 @@
+require 'logger'
+module Gitlab
+ module Metrics
+ class BaseSampler < Daemon
+ # interval - The sampling interval in seconds.
+ def initialize(interval)
+ interval_half = interval.to_f / 2
+
+ @interval = interval
+ @interval_steps = (-interval_half..interval_half).step(0.1).to_a
+
+ super()
+ end
+
+ def safe_sample
+ sample
+ rescue => e
+ Rails.logger.warn("#{self.class}: #{e}, stopping")
+ stop
+ end
+
+ def sample
+ raise NotImplementedError
+ end
+
+ # Returns the sleep interval with a random adjustment.
+ #
+ # The random adjustment is put in place to ensure we:
+ #
+ # 1. Don't generate samples at the exact same interval every time (thus
+ # potentially missing anything that happens in between samples).
+ # 2. Don't sample data at the same interval two times in a row.
+ def sleep_interval
+ while (step = @interval_steps.sample)
+ if step != @last_step
+ @last_step = step
+
+ return @interval + @last_step
+ end
+ end
+ end
+
+ private
+
+ attr_reader :running
+
+ def start_working
+ @running = true
+ sleep(sleep_interval)
+
+ while running
+ safe_sample
+
+ sleep(sleep_interval)
+ end
+ end
+
+ def stop_working
+ @running = false
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb
new file mode 100644
index 00000000000..d7c56463aac
--- /dev/null
+++ b/lib/gitlab/metrics/influx_db.rb
@@ -0,0 +1,170 @@
+module Gitlab
+ module Metrics
+ module InfluxDb
+ extend Gitlab::CurrentSettings
+ extend self
+
+ MUTEX = Mutex.new
+ private_constant :MUTEX
+
+ def influx_metrics_enabled?
+ settings[:enabled] || false
+ end
+
+ RAILS_ROOT = Rails.root.to_s
+ METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s
+ PATH_REGEX = /^#{RAILS_ROOT}\/?/
+
+ def settings
+ @settings ||= {
+ enabled: current_application_settings[:metrics_enabled],
+ pool_size: current_application_settings[:metrics_pool_size],
+ timeout: current_application_settings[:metrics_timeout],
+ method_call_threshold: current_application_settings[:metrics_method_call_threshold],
+ host: current_application_settings[:metrics_host],
+ port: current_application_settings[:metrics_port],
+ sample_interval: current_application_settings[:metrics_sample_interval] || 15,
+ packet_size: current_application_settings[:metrics_packet_size] || 1
+ }
+ end
+
+ def mri?
+ RUBY_ENGINE == 'ruby'
+ end
+
+ def method_call_threshold
+ # This is memoized since this method is called for every instrumented
+ # method. Loading data from an external cache on every method call slows
+ # things down too much.
+ @method_call_threshold ||= settings[:method_call_threshold]
+ end
+
+ def submit_metrics(metrics)
+ prepared = prepare_metrics(metrics)
+
+ pool&.with do |connection|
+ prepared.each_slice(settings[:packet_size]) do |slice|
+ begin
+ connection.write_points(slice)
+ rescue StandardError
+ end
+ end
+ end
+ rescue Errno::EADDRNOTAVAIL, SocketError => ex
+ Gitlab::EnvironmentLogger.error('Cannot resolve InfluxDB address. GitLab Performance Monitoring will not work.')
+ Gitlab::EnvironmentLogger.error(ex)
+ end
+
+ def prepare_metrics(metrics)
+ metrics.map do |hash|
+ new_hash = hash.symbolize_keys
+
+ new_hash[:tags].each do |key, value|
+ if value.blank?
+ new_hash[:tags].delete(key)
+ else
+ new_hash[:tags][key] = escape_value(value)
+ end
+ end
+
+ new_hash
+ end
+ end
+
+ def escape_value(value)
+ value.to_s.gsub('=', '\\=')
+ end
+
+ # Measures the execution time of a block.
+ #
+ # Example:
+ #
+ # Gitlab::Metrics.measure(:find_by_username_duration) do
+ # User.find_by_username(some_username)
+ # end
+ #
+ # name - The name of the field to store the execution time in.
+ #
+ # Returns the value yielded by the supplied block.
+ def measure(name)
+ trans = current_transaction
+
+ return yield unless trans
+
+ real_start = Time.now.to_f
+ cpu_start = System.cpu_time
+
+ retval = yield
+
+ cpu_stop = System.cpu_time
+ real_stop = Time.now.to_f
+
+ real_time = (real_stop - real_start) * 1000.0
+ cpu_time = cpu_stop - cpu_start
+
+ trans.increment("#{name}_real_time", real_time)
+ trans.increment("#{name}_cpu_time", cpu_time)
+ trans.increment("#{name}_call_count", 1)
+
+ retval
+ end
+
+ # Adds a tag to the current transaction (if any)
+ #
+ # name - The name of the tag to add.
+ # value - The value of the tag.
+ def tag_transaction(name, value)
+ trans = current_transaction
+
+ trans&.add_tag(name, value)
+ end
+
+ # Sets the action of the current transaction (if any)
+ #
+ # action - The name of the action.
+ def action=(action)
+ trans = current_transaction
+
+ trans&.action = action
+ end
+
+ # Tracks an event.
+ #
+ # See `Gitlab::Metrics::Transaction#add_event` for more details.
+ def add_event(*args)
+ trans = current_transaction
+
+ trans&.add_event(*args)
+ end
+
+ # Returns the prefix to use for the name of a series.
+ def series_prefix
+ @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
+ end
+
+ # Allow access from other metrics related middlewares
+ def current_transaction
+ Transaction.current
+ end
+
+ # When enabled this should be set before being used as the usual pattern
+ # "@foo ||= bar" is _not_ thread-safe.
+ def pool
+ if influx_metrics_enabled?
+ if @pool.nil?
+ MUTEX.synchronize do
+ @pool ||= ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do
+ host = settings[:host]
+ port = settings[:port]
+
+ InfluxDB::Client
+ .new(udp: { host: host, port: port })
+ end
+ end
+ end
+ @pool
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/influx_sampler.rb b/lib/gitlab/metrics/influx_sampler.rb
new file mode 100644
index 00000000000..6db1dd755b7
--- /dev/null
+++ b/lib/gitlab/metrics/influx_sampler.rb
@@ -0,0 +1,101 @@
+module Gitlab
+ module Metrics
+ # Class that sends certain metrics to InfluxDB at a specific interval.
+ #
+ # This class is used to gather statistics that can't be directly associated
+ # with a transaction such as system memory usage, garbage collection
+ # statistics, etc.
+ class InfluxSampler < BaseSampler
+ # interval - The sampling interval in seconds.
+ def initialize(interval = Metrics.settings[:sample_interval])
+ super(interval)
+ @last_step = nil
+
+ @metrics = []
+
+ @last_minor_gc = Delta.new(GC.stat[:minor_gc_count])
+ @last_major_gc = Delta.new(GC.stat[:major_gc_count])
+
+ if Gitlab::Metrics.mri?
+ require 'allocations'
+
+ Allocations.start
+ end
+ end
+
+ def sample
+ sample_memory_usage
+ sample_file_descriptors
+ sample_objects
+ sample_gc
+
+ flush
+ ensure
+ GC::Profiler.clear
+ @metrics.clear
+ end
+
+ def flush
+ Metrics.submit_metrics(@metrics.map(&:to_hash))
+ end
+
+ def sample_memory_usage
+ add_metric('memory_usage', value: System.memory_usage)
+ end
+
+ def sample_file_descriptors
+ add_metric('file_descriptors', value: System.file_descriptor_count)
+ end
+
+ if Metrics.mri?
+ def sample_objects
+ sample = Allocations.to_hash
+ counts = sample.each_with_object({}) do |(klass, count), hash|
+ name = klass.name
+
+ next unless name
+
+ hash[name] = count
+ end
+
+ # Symbols aren't allocated so we'll need to add those manually.
+ counts['Symbol'] = Symbol.all_symbols.length
+
+ counts.each do |name, count|
+ add_metric('object_counts', { count: count }, type: name)
+ end
+ end
+ else
+ def sample_objects
+ end
+ end
+
+ def sample_gc
+ time = GC::Profiler.total_time * 1000.0
+ stats = GC.stat.merge(total_time: time)
+
+ # We want the difference of GC runs compared to the last sample, not the
+ # total amount since the process started.
+ stats[:minor_gc_count] =
+ @last_minor_gc.compared_with(stats[:minor_gc_count])
+
+ stats[:major_gc_count] =
+ @last_major_gc.compared_with(stats[:major_gc_count])
+
+ stats[:count] = stats[:minor_gc_count] + stats[:major_gc_count]
+
+ add_metric('gc_statistics', stats)
+ end
+
+ def add_metric(series, values, tags = {})
+ prefix = sidekiq? ? 'sidekiq_' : 'rails_'
+
+ @metrics << Metric.new("#{prefix}#{series}", values, tags)
+ end
+
+ def sidekiq?
+ Sidekiq.server?
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/null_metric.rb b/lib/gitlab/metrics/null_metric.rb
new file mode 100644
index 00000000000..3b5a2907195
--- /dev/null
+++ b/lib/gitlab/metrics/null_metric.rb
@@ -0,0 +1,10 @@
+module Gitlab
+ module Metrics
+ # Mocks ::Prometheus::Client::Metric and all derived metrics
+ class NullMetric
+ def method_missing(name, *args, &block)
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/prometheus.rb b/lib/gitlab/metrics/prometheus.rb
new file mode 100644
index 00000000000..460dab47276
--- /dev/null
+++ b/lib/gitlab/metrics/prometheus.rb
@@ -0,0 +1,57 @@
+require 'prometheus/client'
+
+module Gitlab
+ module Metrics
+ module Prometheus
+ include Gitlab::CurrentSettings
+
+ def metrics_folder_present?
+ multiprocess_files_dir = ::Prometheus::Client.configuration.multiprocess_files_dir
+
+ multiprocess_files_dir &&
+ ::Dir.exist?(multiprocess_files_dir) &&
+ ::File.writable?(multiprocess_files_dir)
+ end
+
+ def prometheus_metrics_enabled?
+ return @prometheus_metrics_enabled if defined?(@prometheus_metrics_enabled)
+
+ @prometheus_metrics_enabled = prometheus_metrics_enabled_unmemoized
+ end
+
+ def registry
+ @registry ||= ::Prometheus::Client.registry
+ end
+
+ def counter(name, docstring, base_labels = {})
+ provide_metric(name) || registry.counter(name, docstring, base_labels)
+ end
+
+ def summary(name, docstring, base_labels = {})
+ provide_metric(name) || registry.summary(name, docstring, base_labels)
+ end
+
+ def gauge(name, docstring, base_labels = {}, multiprocess_mode = :all)
+ provide_metric(name) || registry.gauge(name, docstring, base_labels, multiprocess_mode)
+ end
+
+ def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS)
+ provide_metric(name) || registry.histogram(name, docstring, base_labels, buckets)
+ end
+
+ def provide_metric(name)
+ if prometheus_metrics_enabled?
+ registry.get(name)
+ else
+ NullMetric.new
+ end
+ end
+
+ private
+
+ def prometheus_metrics_enabled_unmemoized
+ metrics_folder_present? && current_application_settings[:prometheus_metrics_enabled] || false
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/requests_rack_middleware.rb b/lib/gitlab/metrics/requests_rack_middleware.rb
new file mode 100644
index 00000000000..0dc19f31d03
--- /dev/null
+++ b/lib/gitlab/metrics/requests_rack_middleware.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module Metrics
+ class RequestsRackMiddleware
+ def initialize(app)
+ @app = app
+ end
+
+ def self.http_request_total
+ @http_request_total ||= Gitlab::Metrics.counter(:http_requests_total, 'Request count')
+ end
+
+ def self.rack_uncaught_errors_count
+ @rack_uncaught_errors_count ||= Gitlab::Metrics.counter(:rack_uncaught_errors_total, 'Request handling uncaught errors count')
+ end
+
+ def self.http_request_duration_seconds
+ @http_request_duration_seconds ||= Gitlab::Metrics.histogram(:http_request_duration_seconds, 'Request handling execution time',
+ {}, [0.05, 0.1, 0.25, 0.5, 0.7, 1, 2.5, 5, 10, 25])
+ end
+
+ def call(env)
+ method = env['REQUEST_METHOD'].downcase
+ started = Time.now.to_f
+ begin
+ RequestsRackMiddleware.http_request_total.increment(method: method)
+
+ status, headers, body = @app.call(env)
+
+ elapsed = Time.now.to_f - started
+ RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status }, elapsed)
+
+ [status, headers, body]
+ rescue
+ RequestsRackMiddleware.rack_uncaught_errors_count.increment
+ raise
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/sampler.rb b/lib/gitlab/metrics/sampler.rb
deleted file mode 100644
index 0000450d9bb..00000000000
--- a/lib/gitlab/metrics/sampler.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-module Gitlab
- module Metrics
- # Class that sends certain metrics to InfluxDB at a specific interval.
- #
- # This class is used to gather statistics that can't be directly associated
- # with a transaction such as system memory usage, garbage collection
- # statistics, etc.
- class Sampler
- # interval - The sampling interval in seconds.
- def initialize(interval = Metrics.settings[:sample_interval])
- interval_half = interval.to_f / 2
-
- @interval = interval
- @interval_steps = (-interval_half..interval_half).step(0.1).to_a
- @last_step = nil
-
- @metrics = []
-
- @last_minor_gc = Delta.new(GC.stat[:minor_gc_count])
- @last_major_gc = Delta.new(GC.stat[:major_gc_count])
-
- if Gitlab::Metrics.mri?
- require 'allocations'
-
- Allocations.start
- end
- end
-
- def start
- Thread.new do
- Thread.current.abort_on_exception = true
-
- loop do
- sleep(sleep_interval)
-
- sample
- end
- end
- end
-
- def sample
- sample_memory_usage
- sample_file_descriptors
- sample_objects
- sample_gc
-
- flush
- ensure
- GC::Profiler.clear
- @metrics.clear
- end
-
- def flush
- Metrics.submit_metrics(@metrics.map(&:to_hash))
- end
-
- def sample_memory_usage
- add_metric('memory_usage', value: System.memory_usage)
- end
-
- def sample_file_descriptors
- add_metric('file_descriptors', value: System.file_descriptor_count)
- end
-
- if Metrics.mri?
- def sample_objects
- sample = Allocations.to_hash
- counts = sample.each_with_object({}) do |(klass, count), hash|
- name = klass.name
-
- next unless name
-
- hash[name] = count
- end
-
- # Symbols aren't allocated so we'll need to add those manually.
- counts['Symbol'] = Symbol.all_symbols.length
-
- counts.each do |name, count|
- add_metric('object_counts', { count: count }, type: name)
- end
- end
- else
- def sample_objects
- end
- end
-
- def sample_gc
- time = GC::Profiler.total_time * 1000.0
- stats = GC.stat.merge(total_time: time)
-
- # We want the difference of GC runs compared to the last sample, not the
- # total amount since the process started.
- stats[:minor_gc_count] =
- @last_minor_gc.compared_with(stats[:minor_gc_count])
-
- stats[:major_gc_count] =
- @last_major_gc.compared_with(stats[:major_gc_count])
-
- stats[:count] = stats[:minor_gc_count] + stats[:major_gc_count]
-
- add_metric('gc_statistics', stats)
- end
-
- def add_metric(series, values, tags = {})
- prefix = sidekiq? ? 'sidekiq_' : 'rails_'
-
- @metrics << Metric.new("#{prefix}#{series}", values, tags)
- end
-
- def sidekiq?
- Sidekiq.server?
- end
-
- # Returns the sleep interval with a random adjustment.
- #
- # The random adjustment is put in place to ensure we:
- #
- # 1. Don't generate samples at the exact same interval every time (thus
- # potentially missing anything that happens in between samples).
- # 2. Don't sample data at the same interval two times in a row.
- def sleep_interval
- while step = @interval_steps.sample
- if step != @last_step
- @last_step = step
-
- return @interval + @last_step
- end
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/metrics/sidekiq_metrics_exporter.rb b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb
new file mode 100644
index 00000000000..5980a4ded2b
--- /dev/null
+++ b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb
@@ -0,0 +1,39 @@
+require 'webrick'
+require 'prometheus/client/rack/exporter'
+
+module Gitlab
+ module Metrics
+ class SidekiqMetricsExporter < Daemon
+ def enabled?
+ Gitlab::Metrics.metrics_folder_present? && settings.enabled
+ end
+
+ def settings
+ Settings.monitoring.sidekiq_exporter
+ end
+
+ private
+
+ attr_reader :server
+
+ def start_working
+ @server = ::WEBrick::HTTPServer.new(Port: settings.port, BindAddress: settings.address)
+ server.mount "/", Rack::Handler::WEBrick, rack_app
+ server.start
+ end
+
+ def stop_working
+ server.shutdown
+ @server = nil
+ end
+
+ def rack_app
+ Rack::Builder.app do
+ use Rack::Deflater
+ use ::Prometheus::Client::Rack::Exporter
+ run -> (env) { [404, {}, ['']] }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb
index 3aaebb3e9c3..aba3e0df382 100644
--- a/lib/gitlab/metrics/system.rb
+++ b/lib/gitlab/metrics/system.rb
@@ -34,13 +34,13 @@ module Gitlab
# THREAD_CPUTIME is not supported on OS X
if Process.const_defined?(:CLOCK_THREAD_CPUTIME_ID)
def self.cpu_time
- Process.
- clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :millisecond)
+ Process
+ .clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :millisecond)
end
else
def self.cpu_time
- Process.
- clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :millisecond)
+ Process
+ .clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :millisecond)
end
end
diff --git a/lib/gitlab/metrics/unicorn_sampler.rb b/lib/gitlab/metrics/unicorn_sampler.rb
new file mode 100644
index 00000000000..f6987252039
--- /dev/null
+++ b/lib/gitlab/metrics/unicorn_sampler.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ module Metrics
+ class UnicornSampler < BaseSampler
+ def initialize(interval)
+ super(interval)
+ end
+
+ def unicorn_active_connections
+ @unicorn_active_connections ||= Gitlab::Metrics.gauge(:unicorn_active_connections, 'Unicorn active connections', {}, :max)
+ end
+
+ def unicorn_queued_connections
+ @unicorn_queued_connections ||= Gitlab::Metrics.gauge(:unicorn_queued_connections, 'Unicorn queued connections', {}, :max)
+ end
+
+ def enabled?
+ # Raindrops::Linux.tcp_listener_stats is only present on Linux
+ unicorn_with_listeners? && Raindrops::Linux.respond_to?(:tcp_listener_stats)
+ end
+
+ def sample
+ Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats|
+ unicorn_active_connections.set({ type: 'tcp', address: addr }, stats.active)
+ unicorn_queued_connections.set({ type: 'tcp', address: addr }, stats.queued)
+ end
+
+ Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats|
+ unicorn_active_connections.set({ type: 'unix', address: addr }, stats.active)
+ unicorn_queued_connections.set({ type: 'unix', address: addr }, stats.queued)
+ end
+ end
+
+ private
+
+ def tcp_listeners
+ @tcp_listeners ||= Unicorn.listener_names.grep(%r{\A[^/]+:\d+\z})
+ end
+
+ def unix_listeners
+ @unix_listeners ||= Unicorn.listener_names - tcp_listeners
+ end
+
+ def unicorn_with_listeners?
+ defined?(Unicorn) && Unicorn.listener_names.any?
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/middleware/rails_queue_duration.rb b/lib/gitlab/middleware/rails_queue_duration.rb
index 5d2d7d0026c..63c3372da51 100644
--- a/lib/gitlab/middleware/rails_queue_duration.rb
+++ b/lib/gitlab/middleware/rails_queue_duration.rb
@@ -8,7 +8,7 @@ module Gitlab
def initialize(app)
@app = app
end
-
+
def call(env)
trans = Gitlab::Metrics.current_transaction
proxy_start = env['HTTP_GITLAB_WORKHORSE_PROXY_START'].presence
diff --git a/lib/gitlab/middleware/webpack_proxy.rb b/lib/gitlab/middleware/webpack_proxy.rb
index 6105d165810..6aecf63231f 100644
--- a/lib/gitlab/middleware/webpack_proxy.rb
+++ b/lib/gitlab/middleware/webpack_proxy.rb
@@ -1,6 +1,7 @@
# This Rack middleware is intended to proxy the webpack assets directory to the
# webpack-dev-server. It is only intended for use in development.
+# :nocov:
module Gitlab
module Middleware
class WebpackProxy < Rack::Proxy
@@ -22,3 +23,4 @@ module Gitlab
end
end
end
+# :nocov:
diff --git a/lib/gitlab/o_auth/provider.rb b/lib/gitlab/o_auth/provider.rb
index 9ad7a38d505..ac9d66c836d 100644
--- a/lib/gitlab/o_auth/provider.rb
+++ b/lib/gitlab/o_auth/provider.rb
@@ -22,7 +22,11 @@ module Gitlab
def self.config_for(name)
name = name.to_s
if ldap_provider?(name)
- Gitlab::LDAP::Config.new(name).options
+ if Gitlab::LDAP::Config.valid_provider?(name)
+ Gitlab::LDAP::Config.new(name).options
+ else
+ nil
+ end
else
Gitlab.config.omniauth.providers.find { |provider| provider.name == name }
end
diff --git a/lib/gitlab/o_auth/session.rb b/lib/gitlab/o_auth/session.rb
index f33bfd0bd0e..30739f2a2c5 100644
--- a/lib/gitlab/o_auth/session.rb
+++ b/lib/gitlab/o_auth/session.rb
@@ -1,3 +1,4 @@
+# :nocov:
module Gitlab
module OAuth
module Session
@@ -15,3 +16,4 @@ module Gitlab
end
end
end
+# :nocov:
diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb
index afd24b4dcc5..e8330917e91 100644
--- a/lib/gitlab/o_auth/user.rb
+++ b/lib/gitlab/o_auth/user.rb
@@ -12,6 +12,7 @@ module Gitlab
def initialize(auth_hash)
self.auth_hash = auth_hash
+ update_email
end
def persisted?
@@ -31,7 +32,7 @@ module Gitlab
block_after_save = needs_blocking?
- gl_user.save!
+ Users::UpdateService.new(gl_user).execute!
gl_user.block if block_after_save
@@ -100,14 +101,18 @@ module Gitlab
# Look for a corresponding person with same uid in any of the configured LDAP providers
Gitlab::LDAP::Config.providers.each do |provider|
adapter = Gitlab::LDAP::Adapter.new(provider)
- @ldap_person = Gitlab::LDAP::Person.find_by_uid(auth_hash.uid, adapter)
- # The `uid` might actually be a DN. Try it next.
- @ldap_person ||= Gitlab::LDAP::Person.find_by_dn(auth_hash.uid, adapter)
+ @ldap_person = find_ldap_person(auth_hash, adapter)
break if @ldap_person
end
@ldap_person
end
+ def find_ldap_person(auth_hash, adapter)
+ by_uid = Gitlab::LDAP::Person.find_by_uid(auth_hash.uid, adapter)
+ # The `uid` might actually be a DN. Try it next.
+ by_uid || Gitlab::LDAP::Person.find_by_dn(auth_hash.uid, adapter)
+ end
+
def ldap_config
Gitlab::LDAP::Config.new(ldap_person.provider) if ldap_person
end
@@ -161,12 +166,17 @@ module Gitlab
username ||= auth_hash.username
email ||= auth_hash.email
+ valid_username = ::Namespace.clean_path(username)
+
+ uniquify = Uniquify.new
+ valid_username = uniquify.string(valid_username) { |s| !DynamicPathValidator.valid_user_path?(s) }
+
name = auth_hash.name
- name = ::Namespace.clean_path(username) if name.strip.empty?
+ name = valid_username if name.strip.empty?
{
name: name,
- username: ::Namespace.clean_path(username),
+ username: valid_username,
email: email,
password: auth_hash.password,
password_confirmation: auth_hash.password,
@@ -174,6 +184,22 @@ module Gitlab
}
end
+ def sync_email_from_provider?
+ auth_hash.provider.to_s == Gitlab.config.omniauth.sync_email_from_provider.to_s
+ end
+
+ def update_email
+ if auth_hash.has_email? && sync_email_from_provider?
+ if persisted?
+ gl_user.skip_reconfirmation!
+ gl_user.email = auth_hash.email
+ end
+
+ gl_user.external_email = true
+ gl_user.email_provider = auth_hash.provider
+ end
+ end
+
def log
Gitlab::AppLogger
end
diff --git a/lib/gitlab/other_markup.rb b/lib/gitlab/other_markup.rb
index c2adc9aa10b..fc3f21233dd 100644
--- a/lib/gitlab/other_markup.rb
+++ b/lib/gitlab/other_markup.rb
@@ -5,12 +5,12 @@ module Gitlab
#
# input - the source text in a markup format
#
- def self.render(file_name, input)
- html = GitHub::Markup.render(file_name, input).
- force_encoding(input.encoding)
+ def self.render(file_name, input, context)
+ html = GitHub::Markup.render(file_name, input)
+ .force_encoding(input.encoding)
+ context[:pipeline] = :markup
- filter = Banzai::Filter::SanitizationFilter.new(html)
- html = filter.call.to_s
+ html = Banzai.render(html, context)
html.html_safe
end
diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb
new file mode 100644
index 00000000000..22332474945
--- /dev/null
+++ b/lib/gitlab/otp_key_rotator.rb
@@ -0,0 +1,87 @@
+module Gitlab
+ # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute.
+ #
+ # When +otp_key_base+ is changed, it invalidates the current encrypted values
+ # of User#otp_secret. This class can be used to decrypt all the values with
+ # the old key, encrypt them with the new key, and and update the database
+ # with the new values.
+ #
+ # For persistence between runs, a CSV file is used with the following columns:
+ #
+ # user_id, old_value, new_value
+ #
+ # Only the encrypted values are stored in this file.
+ #
+ # As users may have their 2FA settings changed at any time, this is only
+ # guaranteed to be safe if run offline.
+ class OtpKeyRotator
+ HEADERS = %w[user_id old_value new_value].freeze
+
+ attr_reader :filename
+
+ # Create a new rotator. +filename+ is used to store values by +calculate!+,
+ # and to update the database with new and old values in +apply!+ and
+ # +rollback!+, respectively.
+ def initialize(filename)
+ @filename = filename
+ end
+
+ def rotate!(old_key:, new_key:)
+ old_key ||= Gitlab::Application.secrets.otp_key_base
+
+ raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key
+ raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64
+
+ write_csv do |csv|
+ ActiveRecord::Base.transaction do
+ User.with_two_factor.in_batches do |relation| # rubocop: disable Cop/InBatches
+ rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
+ rows.each do |row|
+ user = %i[id ciphertext iv salt].zip(row).to_h
+ new_value = reencrypt(user, old_key, new_key)
+
+ User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value)
+ csv << [user[:id], user[:ciphertext], new_value]
+ end
+ end
+ end
+ end
+ end
+
+ def rollback!
+ ActiveRecord::Base.transaction do
+ CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row|
+ User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value'])
+ end
+ end
+ end
+
+ private
+
+ attr_reader :old_key, :new_key
+
+ def otp_secret_settings
+ @otp_secret_settings ||= User.encrypted_attributes[:otp_secret]
+ end
+
+ def reencrypt(user, old_key, new_key)
+ original = user[:ciphertext].unpack("m").join
+ opts = {
+ iv: user[:iv].unpack("m").join,
+ salt: user[:salt].unpack("m").join,
+ algorithm: otp_secret_settings[:algorithm],
+ insecure_mode: otp_secret_settings[:insecure_mode]
+ }
+
+ decrypted = Encryptor.decrypt(original, opts.merge(key: old_key))
+ encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key))
+ [encrypted].pack("m")
+ end
+
+ def write_csv(&blk)
+ File.open(filename, "w") do |file|
+ yield CSV.new(file, headers: HEADERS, write_headers: false)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
new file mode 100644
index 00000000000..894bd5efae5
--- /dev/null
+++ b/lib/gitlab/path_regex.rb
@@ -0,0 +1,266 @@
+module Gitlab
+ module PathRegex
+ extend self
+
+ # All routes that appear on the top level must be listed here.
+ # This will make sure that groups cannot be created with these names
+ # as these routes would be masked by the paths already in place.
+ #
+ # Example:
+ # /api/api-project
+ #
+ # the path `api` shouldn't be allowed because it would be masked by `api/*`
+ #
+ TOP_LEVEL_ROUTES = %w[
+ -
+ .well-known
+ 404.html
+ 422.html
+ 500.html
+ 502.html
+ 503.html
+ abuse_reports
+ admin
+ api
+ apple-touch-icon-precomposed.png
+ apple-touch-icon.png
+ assets
+ autocomplete
+ ci
+ dashboard
+ deploy.html
+ explore
+ favicon.ico
+ files
+ groups
+ health_check
+ help
+ import
+ invites
+ jwt
+ koding
+ notification_settings
+ oauth
+ profile
+ projects
+ public
+ robots.txt
+ s
+ search
+ sent_notifications
+ slash-command-logo.png
+ snippets
+ u
+ unicorn_test
+ unsubscribes
+ uploads
+ users
+ ].freeze
+
+ # This list should contain all words following `/*namespace_id/:project_id` in
+ # routes that contain a second wildcard.
+ #
+ # Example:
+ # /*namespace_id/:project_id/badges/*ref/build
+ #
+ # If `badges` was allowed as a project/group name, we would not be able to access the
+ # `badges` route for those projects:
+ #
+ # Consider a namespace with path `foo/bar` and a project called `badges`.
+ # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg`
+ #
+ # When accessing this path the route would be matched to the `badges` path
+ # with the following params:
+ # - namespace_id: `foo`
+ # - project_id: `bar`
+ # - ref: `badges/master`
+ #
+ # Failing to find the project, this would result in a 404.
+ #
+ # By rejecting `badges` the router can _count_ on the fact that `badges` will
+ # be preceded by the `namespace/project`.
+ PROJECT_WILDCARD_ROUTES = %w[
+ -
+ badges
+ blame
+ blob
+ builds
+ commits
+ create
+ create_dir
+ edit
+ environments/folders
+ files
+ find_file
+ gitlab-lfs/objects
+ info/lfs/objects
+ new
+ preview
+ raw
+ refs
+ tree
+ update
+ wikis
+ ].freeze
+
+ # These are all the paths that follow `/groups/*id/ or `/groups/*group_id`
+ # We need to reject these because we have a `/groups/*id` page that is the same
+ # as the `/*id`.
+ #
+ # If we would allow a subgroup to be created with the name `activity` then
+ # this group would not be accessible through `/groups/parent/activity` since
+ # this would map to the activity-page of its parent.
+ GROUP_ROUTES = %w[
+ -
+ activity
+ analytics
+ audit_events
+ avatar
+ edit
+ group_members
+ hooks
+ issues
+ labels
+ ldap
+ ldap_group_links
+ merge_requests
+ milestones
+ notification_setting
+ pipeline_quota
+ projects
+ subgroups
+ ].freeze
+
+ ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
+ ILLEGAL_GROUP_PATH_WORDS = (PROJECT_WILDCARD_ROUTES | GROUP_ROUTES).freeze
+
+ # The namespace regex is used in JavaScript to validate usernames in the "Register" form. However, Javascript
+ # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`.
+ # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to
+ # allow non-regex validations, etc), `NAMESPACE_FORMAT_REGEX_JS` serves as a Javascript-compatible version of
+ # `NAMESPACE_FORMAT_REGEX`, with the negative lookbehind assertion removed. This means that the client-side validation
+ # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation.
+ PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze
+ NAMESPACE_FORMAT_REGEX_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze
+
+ NO_SUFFIX_REGEX = /(?<!\.git|\.atom)/.freeze
+ NAMESPACE_FORMAT_REGEX = /(?:#{NAMESPACE_FORMAT_REGEX_JS})#{NO_SUFFIX_REGEX}/.freeze
+ PROJECT_PATH_FORMAT_REGEX = /(?:#{PATH_REGEX_STR})#{NO_SUFFIX_REGEX}/.freeze
+ FULL_NAMESPACE_FORMAT_REGEX = %r{(#{NAMESPACE_FORMAT_REGEX}/)*#{NAMESPACE_FORMAT_REGEX}}.freeze
+
+ def root_namespace_route_regex
+ @root_namespace_route_regex ||= begin
+ illegal_words = Regexp.new(Regexp.union(TOP_LEVEL_ROUTES).source, Regexp::IGNORECASE)
+
+ single_line_regexp %r{
+ (?!(#{illegal_words})/)
+ #{NAMESPACE_FORMAT_REGEX}
+ }x
+ end
+ end
+
+ def full_namespace_route_regex
+ @full_namespace_route_regex ||= begin
+ illegal_words = Regexp.new(Regexp.union(ILLEGAL_GROUP_PATH_WORDS).source, Regexp::IGNORECASE)
+
+ single_line_regexp %r{
+ #{root_namespace_route_regex}
+ (?:
+ /
+ (?!#{illegal_words}/)
+ #{NAMESPACE_FORMAT_REGEX}
+ )*
+ }x
+ end
+ end
+
+ def project_route_regex
+ @project_route_regex ||= begin
+ illegal_words = Regexp.new(Regexp.union(ILLEGAL_PROJECT_PATH_WORDS).source, Regexp::IGNORECASE)
+
+ single_line_regexp %r{
+ (?!(#{illegal_words})/)
+ #{PROJECT_PATH_FORMAT_REGEX}
+ }x
+ end
+ end
+
+ def project_git_route_regex
+ @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze
+ end
+
+ def root_namespace_path_regex
+ @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z}
+ end
+
+ def full_namespace_path_regex
+ @full_namespace_path_regex ||= %r{\A#{full_namespace_route_regex}/\z}
+ end
+
+ def project_path_regex
+ @project_path_regex ||= %r{\A#{project_route_regex}/\z}
+ end
+
+ def full_project_path_regex
+ @full_project_path_regex ||= %r{\A#{full_namespace_route_regex}/#{project_route_regex}/\z}
+ end
+
+ def full_namespace_format_regex
+ @namespace_format_regex ||= /A#{FULL_NAMESPACE_FORMAT_REGEX}\z/.freeze
+ end
+
+ def namespace_format_regex
+ @namespace_format_regex ||= /\A#{NAMESPACE_FORMAT_REGEX}\z/.freeze
+ end
+
+ def namespace_format_message
+ "can contain only letters, digits, '_', '-' and '.'. " \
+ "Cannot start with '-' or end in '.', '.git' or '.atom'." \
+ end
+
+ def project_path_format_regex
+ @project_path_format_regex ||= /\A#{PROJECT_PATH_FORMAT_REGEX}\z/.freeze
+ end
+
+ def project_path_format_message
+ "can contain only letters, digits, '_', '-' and '.'. " \
+ "Cannot start with '-', end in '.git' or end in '.atom'" \
+ end
+
+ def archive_formats_regex
+ # |zip|tar| tar.gz | tar.bz2 |
+ @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze
+ end
+
+ def git_reference_regex
+ # Valid git ref regex, see:
+ # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
+
+ @git_reference_regex ||= single_line_regexp %r{
+ (?!
+ (?# doesn't begins with)
+ \/| (?# rule #6)
+ (?# doesn't contain)
+ .*(?:
+ [\/.]\.| (?# rule #1,3)
+ \/\/| (?# rule #6)
+ @\{| (?# rule #8)
+ \\ (?# rule #9)
+ )
+ )
+ [^\000-\040\177~^:?*\[]+ (?# rule #4-5)
+ (?# doesn't end with)
+ (?<!\.lock) (?# rule #1)
+ (?<![\/.]) (?# rule #6-7)
+ }x
+ end
+
+ private
+
+ def single_line_regexp(regex)
+ # Turns a multiline extended regexp into a single line one,
+ # beacuse `rake routes` breaks on multiline regexes.
+ Regexp.new(regex.source.gsub(/\(\?#.+?\)/, '').gsub(/\s*/, ''), regex.options ^ Regexp::EXTENDED).freeze
+ end
+ end
+end
diff --git a/lib/gitlab/performance_bar.rb b/lib/gitlab/performance_bar.rb
new file mode 100644
index 00000000000..56112ec2301
--- /dev/null
+++ b/lib/gitlab/performance_bar.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module PerformanceBar
+ include Gitlab::CurrentSettings
+
+ ALLOWED_USER_IDS_KEY = 'performance_bar_allowed_user_ids:v2'.freeze
+ EXPIRY_TIME = 5.minutes
+
+ def self.enabled?(user = nil)
+ return false unless user && allowed_group_id
+
+ allowed_user_ids.include?(user.id)
+ end
+
+ def self.allowed_group_id
+ current_application_settings.performance_bar_allowed_group_id
+ end
+
+ def self.allowed_user_ids
+ Rails.cache.fetch(ALLOWED_USER_IDS_KEY, expires_in: EXPIRY_TIME) do
+ group = Group.find_by_id(allowed_group_id)
+
+ if group
+ GroupMembersFinder.new(group).execute.pluck(:user_id)
+ else
+ []
+ end
+ end
+ end
+
+ def self.expire_allowed_user_ids_cache
+ Rails.cache.delete(ALLOWED_USER_IDS_KEY)
+ end
+ end
+end
diff --git a/lib/gitlab/performance_bar/peek_query_tracker.rb b/lib/gitlab/performance_bar/peek_query_tracker.rb
new file mode 100644
index 00000000000..67fee8c227d
--- /dev/null
+++ b/lib/gitlab/performance_bar/peek_query_tracker.rb
@@ -0,0 +1,46 @@
+# Inspired by https://github.com/peek/peek-pg/blob/master/lib/peek/views/pg.rb
+# PEEK_DB_CLIENT is a constant set in config/initializers/peek.rb
+module Gitlab
+ module PerformanceBar
+ module PeekQueryTracker
+ def sorted_queries
+ PEEK_DB_CLIENT.query_details
+ .sort { |a, b| b[:duration] <=> a[:duration] }
+ end
+
+ def results
+ super.merge(queries: sorted_queries)
+ end
+
+ private
+
+ def setup_subscribers
+ super
+
+ # Reset each counter when a new request starts
+ before_request do
+ PEEK_DB_CLIENT.query_details = []
+ end
+
+ subscribe('sql.active_record') do |_, start, finish, _, data|
+ if RequestStore.active? && RequestStore.store[:peek_enabled]
+ # data[:cached] is only available starting from Rails 5.1.0
+ # https://github.com/rails/rails/blob/v5.1.0/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb#L113
+ # Before that, data[:name] was set to 'CACHE'
+ # https://github.com/rails/rails/blob/v4.2.9/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb#L80
+ unless data.fetch(:cached, data[:name] == 'CACHE')
+ track_query(data[:sql].strip, data[:binds], start, finish)
+ end
+ end
+ end
+ end
+
+ def track_query(raw_query, bindings, start, finish)
+ query = Gitlab::Sherlock::Query.new(raw_query, start, finish)
+ query_info = { duration: query.duration.round(3), sql: query.formatted_query }
+
+ PEEK_DB_CLIENT.query_details << query_info
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/project_authorizations/with_nested_groups.rb b/lib/gitlab/project_authorizations/with_nested_groups.rb
new file mode 100644
index 00000000000..15b8beacf60
--- /dev/null
+++ b/lib/gitlab/project_authorizations/with_nested_groups.rb
@@ -0,0 +1,125 @@
+module Gitlab
+ module ProjectAuthorizations
+ # Calculating new project authorizations when supporting nested groups.
+ #
+ # This class relies on Common Table Expressions to efficiently get all data,
+ # including data for nested groups. As a result this class can only be used
+ # on PostgreSQL.
+ class WithNestedGroups
+ attr_reader :user
+
+ # user - The User object for which to calculate the authorizations.
+ def initialize(user)
+ @user = user
+ end
+
+ def calculate
+ cte = recursive_cte
+ cte_alias = cte.table.alias(Group.table_name)
+ projects = Project.arel_table
+ links = ProjectGroupLink.arel_table
+
+ relations = [
+ # The project a user has direct access to.
+ user.projects.select_for_project_authorization,
+
+ # The personal projects of the user.
+ user.personal_projects.select_as_master_for_project_authorization,
+
+ # Projects that belong directly to any of the groups the user has
+ # access to.
+ Namespace
+ .unscoped
+ .select([alias_as_column(projects[:id], 'project_id'),
+ cte_alias[:access_level]])
+ .from(cte_alias)
+ .joins(:projects),
+
+ # Projects shared with any of the namespaces the user has access to.
+ Namespace
+ .unscoped
+ .select([links[:project_id],
+ least(cte_alias[:access_level],
+ links[:group_access],
+ 'access_level')])
+ .from(cte_alias)
+ .joins('INNER JOIN project_group_links ON project_group_links.group_id = namespaces.id')
+ .joins('INNER JOIN projects ON projects.id = project_group_links.project_id')
+ .joins('INNER JOIN namespaces p_ns ON p_ns.id = projects.namespace_id')
+ .where('p_ns.share_with_group_lock IS FALSE')
+ ]
+
+ union = Gitlab::SQL::Union.new(relations)
+
+ ProjectAuthorization
+ .unscoped
+ .with
+ .recursive(cte.to_arel)
+ .select_from_union(union)
+ end
+
+ private
+
+ # Builds a recursive CTE that gets all the groups the current user has
+ # access to, including any nested groups.
+ def recursive_cte
+ cte = Gitlab::SQL::RecursiveCTE.new(:namespaces_cte)
+ members = Member.arel_table
+ namespaces = Namespace.arel_table
+
+ # Namespaces the user is a member of.
+ cte << user.groups
+ .select([namespaces[:id], members[:access_level]])
+ .except(:order)
+
+ # Sub groups of any groups the user is a member of.
+ cte << Group.select([namespaces[:id],
+ greatest(members[:access_level],
+ cte.table[:access_level], 'access_level')])
+ .joins(join_cte(cte))
+ .joins(join_members)
+ .except(:order)
+
+ cte
+ end
+
+ # Builds a LEFT JOIN to join optional memberships onto the CTE.
+ def join_members
+ members = Member.arel_table
+ namespaces = Namespace.arel_table
+
+ cond = members[:source_id]
+ .eq(namespaces[:id])
+ .and(members[:source_type].eq('Namespace'))
+ .and(members[:requested_at].eq(nil))
+ .and(members[:user_id].eq(user.id))
+
+ Arel::Nodes::OuterJoin.new(members, Arel::Nodes::On.new(cond))
+ end
+
+ # Builds an INNER JOIN to join namespaces onto the CTE.
+ def join_cte(cte)
+ namespaces = Namespace.arel_table
+ cond = cte.table[:id].eq(namespaces[:parent_id])
+
+ Arel::Nodes::InnerJoin.new(cte.table, Arel::Nodes::On.new(cond))
+ end
+
+ def greatest(left, right, column_alias)
+ sql_function('GREATEST', [left, right], column_alias)
+ end
+
+ def least(left, right, column_alias)
+ sql_function('LEAST', [left, right], column_alias)
+ end
+
+ def sql_function(name, args, column_alias)
+ alias_as_column(Arel::Nodes::NamedFunction.new(name, args), column_alias)
+ end
+
+ def alias_as_column(value, alias_to)
+ Arel::Nodes::As.new(value, Arel::Nodes::SqlLiteral.new(alias_to))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/project_authorizations/without_nested_groups.rb b/lib/gitlab/project_authorizations/without_nested_groups.rb
new file mode 100644
index 00000000000..ad87540e6c2
--- /dev/null
+++ b/lib/gitlab/project_authorizations/without_nested_groups.rb
@@ -0,0 +1,35 @@
+module Gitlab
+ module ProjectAuthorizations
+ # Calculating new project authorizations when not supporting nested groups.
+ class WithoutNestedGroups
+ attr_reader :user
+
+ # user - The User object for which to calculate the authorizations.
+ def initialize(user)
+ @user = user
+ end
+
+ def calculate
+ relations = [
+ # Projects the user is a direct member of
+ user.projects.select_for_project_authorization,
+
+ # Personal projects
+ user.personal_projects.select_as_master_for_project_authorization,
+
+ # Projects of groups the user is a member of
+ user.groups_projects.select_for_project_authorization,
+
+ # Projects shared with groups the user is a member of
+ user.groups.joins(:shared_projects).select_for_project_authorization
+ ]
+
+ union = Gitlab::SQL::Union.new(relations)
+
+ ProjectAuthorization
+ .unscoped
+ .select_from_union(union)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb
index 0b8959f2fb9..561aa9e162c 100644
--- a/lib/gitlab/project_search_results.rb
+++ b/lib/gitlab/project_search_results.rb
@@ -82,26 +82,14 @@ module Gitlab
private
def blobs
- @blobs ||= begin
- blobs = project.repository.search_files_by_content(query, repository_ref).first(100)
- found_file_names = Set.new
-
- results = blobs.map do |blob|
- blob = self.class.parse_search_result(blob)
- found_file_names << blob.filename
-
- [blob.filename, blob]
- end
-
- project.repository.search_files_by_name(query, repository_ref).first(100).each do |filename|
- results << [filename, nil] unless found_file_names.include?(filename)
- end
+ return [] unless Ability.allowed?(@current_user, :download_code, @project)
- results.sort_by(&:first)
- end
+ @blobs ||= Gitlab::FileFinder.new(project, repository_ref).find(query)
end
def wiki_blobs
+ return [] unless Ability.allowed?(@current_user, :read_wiki, @project)
+
@wiki_blobs ||= begin
if project.wiki_enabled? && query.present?
project_wiki = ProjectWiki.new(project)
diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb
new file mode 100644
index 00000000000..732fbf68dad
--- /dev/null
+++ b/lib/gitlab/project_template.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ class ProjectTemplate
+ attr_reader :title, :name
+
+ def initialize(name, title)
+ @name, @title = name, title
+ end
+
+ alias_method :logo, :name
+
+ def file
+ archive_path.open
+ end
+
+ def archive_path
+ Rails.root.join("vendor/project_templates/#{name}.tar.gz")
+ end
+
+ def clone_url
+ "https://gitlab.com/gitlab-org/project-templates/#{name}.git"
+ end
+
+ def ==(other)
+ name == other.name && title == other.title
+ end
+
+ TEMPLATES_TABLE = [
+ ProjectTemplate.new('rails', 'Ruby on Rails'),
+ ProjectTemplate.new('spring', 'Spring'),
+ ProjectTemplate.new('express', 'NodeJS Express')
+ ].freeze
+
+ class << self
+ def all
+ TEMPLATES_TABLE
+ end
+
+ def find(name)
+ all.find { |template| template.name == name.to_s }
+ end
+
+ def archive_directory
+ Rails.root.join("vendor_directory/project_templates")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb
deleted file mode 100644
index 62239779454..00000000000
--- a/lib/gitlab/prometheus.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-module Gitlab
- PrometheusError = Class.new(StandardError)
-
- # Helper methods to interact with Prometheus network services & resources
- class Prometheus
- attr_reader :api_url
-
- def initialize(api_url:)
- @api_url = api_url
- end
-
- def ping
- json_api_get('query', query: '1')
- end
-
- def query(query)
- get_result('vector') do
- json_api_get('query', query: query)
- end
- end
-
- def query_range(query, start: 8.hours.ago)
- get_result('matrix') do
- json_api_get('query_range',
- query: query,
- start: start.to_f,
- end: Time.now.utc.to_f,
- step: 1.minute.to_i)
- end
- end
-
- private
-
- def json_api_get(type, args = {})
- get(join_api_url(type, args))
- rescue Errno::ECONNREFUSED
- raise PrometheusError, 'Connection refused'
- end
-
- def join_api_url(type, args = {})
- url = URI.parse(api_url)
- rescue URI::Error
- raise PrometheusError, "Invalid API URL: #{api_url}"
- else
- url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/')
- url.query = args.to_query
-
- url.to_s
- end
-
- def get(url)
- handle_response(HTTParty.get(url))
- end
-
- def handle_response(response)
- if response.code == 200 && response['status'] == 'success'
- response['data'] || {}
- elsif response.code == 400
- raise PrometheusError, response['error'] || 'Bad data received'
- else
- raise PrometheusError, "#{response.code} - #{response.body}"
- end
- end
-
- def get_result(expected_type)
- data = yield
- data['result'] if data['resultType'] == expected_type
- end
- end
-end
diff --git a/lib/gitlab/prometheus/additional_metrics_parser.rb b/lib/gitlab/prometheus/additional_metrics_parser.rb
new file mode 100644
index 00000000000..cb95daf2260
--- /dev/null
+++ b/lib/gitlab/prometheus/additional_metrics_parser.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Prometheus
+ module AdditionalMetricsParser
+ extend self
+
+ def load_groups_from_yaml
+ additional_metrics_raw.map(&method(:group_from_entry))
+ end
+
+ private
+
+ def validate!(obj)
+ raise ParsingError.new(obj.errors.full_messages.join('\n')) unless obj.valid?
+ end
+
+ def group_from_entry(entry)
+ entry[:name] = entry.delete(:group)
+ entry[:metrics]&.map! do |entry|
+ Metric.new(entry).tap(&method(:validate!))
+ end
+
+ MetricGroup.new(entry).tap(&method(:validate!))
+ end
+
+ def additional_metrics_raw
+ load_yaml_file&.map(&:deep_symbolize_keys).freeze
+ end
+
+ def load_yaml_file
+ @loaded_yaml_file ||= YAML.load_file(Rails.root.join('config/prometheus/additional_metrics.yml'))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/metric.rb b/lib/gitlab/prometheus/metric.rb
new file mode 100644
index 00000000000..f54b2c6aaff
--- /dev/null
+++ b/lib/gitlab/prometheus/metric.rb
@@ -0,0 +1,16 @@
+module Gitlab
+ module Prometheus
+ class Metric
+ include ActiveModel::Model
+
+ attr_accessor :title, :required_metrics, :weight, :y_label, :queries
+
+ validates :title, :required_metrics, :weight, :y_label, :queries, presence: true
+
+ def initialize(params = {})
+ super(params)
+ @y_label ||= 'Values'
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/metric_group.rb b/lib/gitlab/prometheus/metric_group.rb
new file mode 100644
index 00000000000..729fef34b35
--- /dev/null
+++ b/lib/gitlab/prometheus/metric_group.rb
@@ -0,0 +1,14 @@
+module Gitlab
+ module Prometheus
+ class MetricGroup
+ include ActiveModel::Model
+
+ attr_accessor :name, :priority, :metrics
+ validates :name, :priority, :metrics, presence: true
+
+ def self.all
+ AdditionalMetricsParser.load_groups_from_yaml
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/parsing_error.rb b/lib/gitlab/prometheus/parsing_error.rb
new file mode 100644
index 00000000000..49cc0e16080
--- /dev/null
+++ b/lib/gitlab/prometheus/parsing_error.rb
@@ -0,0 +1,5 @@
+module Gitlab
+ module Prometheus
+ ParsingError = Class.new(StandardError)
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
new file mode 100644
index 00000000000..69d055c901c
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class AdditionalMetricsDeploymentQuery < BaseQuery
+ include QueryAdditionalMetrics
+
+ def query(deployment_id)
+ Deployment.find_by(id: deployment_id).try do |deployment|
+ query_metrics(
+ common_query_context(
+ deployment.environment,
+ timeframe_start: (deployment.created_at - 30.minutes).to_f,
+ timeframe_end: (deployment.created_at + 30.minutes).to_f
+ )
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
new file mode 100644
index 00000000000..32fe8201a8d
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class AdditionalMetricsEnvironmentQuery < BaseQuery
+ include QueryAdditionalMetrics
+
+ def query(environment_id)
+ ::Environment.find_by(id: environment_id).try do |environment|
+ query_metrics(
+ common_query_context(environment, timeframe_start: 8.hours.ago.to_f, timeframe_end: Time.now.to_f)
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/base_query.rb b/lib/gitlab/prometheus/queries/base_query.rb
new file mode 100644
index 00000000000..c60828165bd
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/base_query.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class BaseQuery
+ attr_accessor :client
+ delegate :query_range, :query, :label_values, :series, to: :client, prefix: true
+
+ def raw_memory_usage_query(environment_slug)
+ %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20}
+ end
+
+ def raw_cpu_usage_query(environment_slug)
+ %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
+ end
+
+ def initialize(client)
+ @client = client
+ end
+
+ def query(*args)
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb
new file mode 100644
index 00000000000..170f483540e
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/deployment_query.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class DeploymentQuery < BaseQuery
+ def query(deployment_id)
+ Deployment.find_by(id: deployment_id).try do |deployment|
+ environment_slug = deployment.environment.slug
+
+ memory_query = raw_memory_usage_query(environment_slug)
+ memory_avg_query = %{avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}[30m]))}
+ cpu_query = raw_cpu_usage_query(environment_slug)
+ cpu_avg_query = %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[30m])) * 100}
+
+ timeframe_start = (deployment.created_at - 30.minutes).to_f
+ timeframe_end = (deployment.created_at + 30.minutes).to_f
+
+ {
+ memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
+ memory_before: client_query(memory_avg_query, time: deployment.created_at.to_f),
+ memory_after: client_query(memory_avg_query, time: timeframe_end),
+
+ cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
+ cpu_before: client_query(cpu_avg_query, time: deployment.created_at.to_f),
+ cpu_after: client_query(cpu_avg_query, time: timeframe_end)
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/environment_query.rb b/lib/gitlab/prometheus/queries/environment_query.rb
new file mode 100644
index 00000000000..1d17d3cfd56
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/environment_query.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class EnvironmentQuery < BaseQuery
+ def query(environment_id)
+ ::Environment.find_by(id: environment_id).try do |environment|
+ environment_slug = environment.slug
+ timeframe_start = 8.hours.ago.to_f
+ timeframe_end = Time.now.to_f
+
+ memory_query = raw_memory_usage_query(environment_slug)
+ cpu_query = raw_cpu_usage_query(environment_slug)
+
+ {
+ memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end),
+ memory_current: client_query(memory_query, time: timeframe_end),
+ cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
+ cpu_current: client_query(cpu_query, time: timeframe_end)
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/matched_metrics_query.rb b/lib/gitlab/prometheus/queries/matched_metrics_query.rb
new file mode 100644
index 00000000000..d4894c87f8d
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/matched_metrics_query.rb
@@ -0,0 +1,80 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class MatchedMetricsQuery < BaseQuery
+ MAX_QUERY_ITEMS = 40.freeze
+
+ def query
+ groups_data.map do |group, data|
+ {
+ group: group.name,
+ priority: group.priority,
+ active_metrics: data[:active_metrics],
+ metrics_missing_requirements: data[:metrics_missing_requirements]
+ }
+ end
+ end
+
+ private
+
+ def groups_data
+ metrics_groups = groups_with_active_metrics(Gitlab::Prometheus::MetricGroup.all)
+ lookup = active_series_lookup(metrics_groups)
+
+ groups = {}
+
+ metrics_groups.each do |group|
+ groups[group] ||= { active_metrics: 0, metrics_missing_requirements: 0 }
+ active_metrics = group.metrics.count { |metric| metric.required_metrics.all?(&lookup.method(:has_key?)) }
+
+ groups[group][:active_metrics] += active_metrics
+ groups[group][:metrics_missing_requirements] += group.metrics.count - active_metrics
+ end
+
+ groups
+ end
+
+ def active_series_lookup(metric_groups)
+ timeframe_start = 8.hours.ago
+ timeframe_end = Time.now
+
+ series = metric_groups.flat_map(&:metrics).flat_map(&:required_metrics).uniq
+
+ lookup = series.each_slice(MAX_QUERY_ITEMS).flat_map do |batched_series|
+ client_series(*batched_series, start: timeframe_start, stop: timeframe_end)
+ .select(&method(:has_matching_label))
+ .map { |series_info| [series_info['__name__'], true] }
+ end
+ lookup.to_h
+ end
+
+ def has_matching_label(series_info)
+ series_info.key?('environment')
+ end
+
+ def available_metrics
+ @available_metrics ||= client_label_values || []
+ end
+
+ def filter_active_metrics(metric_group)
+ metric_group.metrics.select! do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ metric_group
+ end
+
+ def groups_with_active_metrics(metric_groups)
+ metric_groups.map(&method(:filter_active_metrics)).select { |group| group.metrics.any? }
+ end
+
+ def metrics_with_required_series(metric_groups)
+ metric_groups.flat_map do |group|
+ group.metrics.select do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus/queries/query_additional_metrics.rb b/lib/gitlab/prometheus/queries/query_additional_metrics.rb
new file mode 100644
index 00000000000..7ac6162b54d
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/query_additional_metrics.rb
@@ -0,0 +1,86 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ module QueryAdditionalMetrics
+ def query_metrics(query_context)
+ query_processor = method(:process_query).curry[query_context]
+
+ groups = matched_metrics.map do |group|
+ metrics = group.metrics.map do |metric|
+ {
+ title: metric.title,
+ weight: metric.weight,
+ y_label: metric.y_label,
+ queries: metric.queries.map(&query_processor).select(&method(:query_with_result))
+ }
+ end
+
+ {
+ group: group.name,
+ priority: group.priority,
+ metrics: metrics.select(&method(:metric_with_any_queries))
+ }
+ end
+
+ groups.select(&method(:group_with_any_metrics))
+ end
+
+ private
+
+ def metric_with_any_queries(metric)
+ metric[:queries]&.count&.> 0
+ end
+
+ def group_with_any_metrics(group)
+ group[:metrics]&.count&.> 0
+ end
+
+ def query_with_result(query)
+ query[:result]&.any? do |item|
+ item&.[](:values)&.any? || item&.[](:value)&.any?
+ end
+ end
+
+ def process_query(context, query)
+ query = query.dup
+ result =
+ if query.key?(:query_range)
+ query[:query_range] %= context
+ client_query_range(query[:query_range], start: context[:timeframe_start], stop: context[:timeframe_end])
+ else
+ query[:query] %= context
+ client_query(query[:query], time: context[:timeframe_end])
+ end
+
+ query[:result] = result&.map(&:deep_symbolize_keys)
+ query
+ end
+
+ def available_metrics
+ @available_metrics ||= client_label_values || []
+ end
+
+ def matched_metrics
+ result = Gitlab::Prometheus::MetricGroup.all.map do |group|
+ group.metrics.select! do |metric|
+ metric.required_metrics.all?(&available_metrics.method(:include?))
+ end
+ group
+ end
+
+ result.select { |group| group.metrics.any? }
+ end
+
+ def common_query_context(environment, timeframe_start:, timeframe_end:)
+ {
+ timeframe_start: timeframe_start,
+ timeframe_end: timeframe_end,
+ ci_environment_slug: environment.slug,
+ kube_namespace: environment.project.kubernetes_service&.actual_namespace || '',
+ environment_filter: %{container_name!="POD",environment="#{environment.slug}"}
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb
new file mode 100644
index 00000000000..aa94614bf18
--- /dev/null
+++ b/lib/gitlab/prometheus_client.rb
@@ -0,0 +1,84 @@
+module Gitlab
+ PrometheusError = Class.new(StandardError)
+
+ # Helper methods to interact with Prometheus network services & resources
+ class PrometheusClient
+ attr_reader :api_url
+
+ def initialize(api_url:)
+ @api_url = api_url
+ end
+
+ def ping
+ json_api_get('query', query: '1')
+ end
+
+ def query(query, time: Time.now)
+ get_result('vector') do
+ json_api_get('query', query: query, time: time.to_f)
+ end
+ end
+
+ def query_range(query, start: 8.hours.ago, stop: Time.now)
+ get_result('matrix') do
+ json_api_get('query_range',
+ query: query,
+ start: start.to_f,
+ end: stop.to_f,
+ step: 1.minute.to_i)
+ end
+ end
+
+ def label_values(name = '__name__')
+ json_api_get("label/#{name}/values")
+ end
+
+ def series(*matches, start: 8.hours.ago, stop: Time.now)
+ json_api_get('series', 'match': matches, start: start.to_f, end: stop.to_f)
+ end
+
+ private
+
+ def json_api_get(type, args = {})
+ get(join_api_url(type, args))
+ rescue Errno::ECONNREFUSED
+ raise PrometheusError, 'Connection refused'
+ end
+
+ def join_api_url(type, args = {})
+ url = URI.parse(api_url)
+ rescue URI::Error
+ raise PrometheusError, "Invalid API URL: #{api_url}"
+ else
+ url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/')
+ url.query = args.to_query
+
+ url.to_s
+ end
+
+ def get(url)
+ handle_response(HTTParty.get(url))
+ rescue SocketError
+ raise PrometheusError, "Can't connect to #{url}"
+ rescue OpenSSL::SSL::SSLError
+ raise PrometheusError, "#{url} contains invalid SSL data"
+ rescue HTTParty::Error
+ raise PrometheusError, "Network connection error"
+ end
+
+ def handle_response(response)
+ if response.code == 200 && response['status'] == 'success'
+ response['data'] || {}
+ elsif response.code == 400
+ raise PrometheusError, response['error'] || 'Bad data received'
+ else
+ raise PrometheusError, "#{response.code} - #{response.body}"
+ end
+ end
+
+ def get_result(expected_type)
+ data = yield
+ data['result'] if data['resultType'] == expected_type
+ end
+ end
+end
diff --git a/lib/gitlab/quick_actions/command_definition.rb b/lib/gitlab/quick_actions/command_definition.rb
new file mode 100644
index 00000000000..3937d9c153a
--- /dev/null
+++ b/lib/gitlab/quick_actions/command_definition.rb
@@ -0,0 +1,89 @@
+module Gitlab
+ module QuickActions
+ class CommandDefinition
+ attr_accessor :name, :aliases, :description, :explanation, :params,
+ :condition_block, :parse_params_block, :action_block
+
+ def initialize(name, attributes = {})
+ @name = name
+
+ @aliases = attributes[:aliases] || []
+ @description = attributes[:description] || ''
+ @explanation = attributes[:explanation] || ''
+ @params = attributes[:params] || []
+ @condition_block = attributes[:condition_block]
+ @parse_params_block = attributes[:parse_params_block]
+ @action_block = attributes[:action_block]
+ end
+
+ def all_names
+ [name, *aliases]
+ end
+
+ def noop?
+ action_block.nil?
+ end
+
+ def available?(opts)
+ return true unless condition_block
+
+ context = OpenStruct.new(opts)
+ context.instance_exec(&condition_block)
+ end
+
+ def explain(context, opts, arg)
+ return unless available?(opts)
+
+ if explanation.respond_to?(:call)
+ execute_block(explanation, context, arg)
+ else
+ explanation
+ end
+ end
+
+ def execute(context, opts, arg)
+ return if noop? || !available?(opts)
+
+ execute_block(action_block, context, arg)
+ end
+
+ def to_h(opts)
+ context = OpenStruct.new(opts)
+
+ desc = description
+ if desc.respond_to?(:call)
+ desc = context.instance_exec(&desc) rescue ''
+ end
+
+ prms = params
+ if prms.respond_to?(:call)
+ prms = Array(context.instance_exec(&prms)) rescue params
+ end
+
+ {
+ name: name,
+ aliases: aliases,
+ description: desc,
+ params: prms
+ }
+ end
+
+ private
+
+ def execute_block(block, context, arg)
+ if arg.present?
+ parsed = parse_params(arg, context)
+ context.instance_exec(parsed, &block)
+ elsif block.arity == 0
+ context.instance_exec(&block)
+ end
+ end
+
+ def parse_params(arg, context)
+ return arg unless parse_params_block
+
+ context.instance_exec(arg, &parse_params_block)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/quick_actions/dsl.rb b/lib/gitlab/quick_actions/dsl.rb
new file mode 100644
index 00000000000..536765305e1
--- /dev/null
+++ b/lib/gitlab/quick_actions/dsl.rb
@@ -0,0 +1,159 @@
+module Gitlab
+ module QuickActions
+ module Dsl
+ extend ActiveSupport::Concern
+
+ included do
+ cattr_accessor :command_definitions, instance_accessor: false do
+ []
+ end
+
+ cattr_accessor :command_definitions_by_name, instance_accessor: false do
+ {}
+ end
+ end
+
+ class_methods do
+ # Allows to give a description to the next quick action.
+ # This description is shown in the autocomplete menu.
+ # It accepts a block that will be evaluated with the context given to
+ # `CommandDefintion#to_h`.
+ #
+ # Example:
+ #
+ # desc do
+ # "This is a dynamic description for #{noteable.to_ability_name}"
+ # end
+ # command :command_key do |arguments|
+ # # Awesome code block
+ # end
+ def desc(text = '', &block)
+ @description = block_given? ? block : text
+ end
+
+ # Allows to define params for the next quick action.
+ # These params are shown in the autocomplete menu.
+ #
+ # Example:
+ #
+ # params "~label ~label2"
+ # command :command_key do |arguments|
+ # # Awesome code block
+ # end
+ def params(*params, &block)
+ @params = block_given? ? block : params
+ end
+
+ # Allows to give an explanation of what the command will do when
+ # executed. This explanation is shown when rendering the Markdown
+ # preview.
+ #
+ # Example:
+ #
+ # explanation do |arguments|
+ # "Adds label(s) #{arguments.join(' ')}"
+ # end
+ # command :command_key do |arguments|
+ # # Awesome code block
+ # end
+ def explanation(text = '', &block)
+ @explanation = block_given? ? block : text
+ end
+
+ # Allows to define conditions that must be met in order for the command
+ # to be returned by `.command_names` & `.command_definitions`.
+ # It accepts a block that will be evaluated with the context given to
+ # `CommandDefintion#to_h`.
+ #
+ # Example:
+ #
+ # condition do
+ # project.public?
+ # end
+ # command :command_key do |arguments|
+ # # Awesome code block
+ # end
+ def condition(&block)
+ @condition_block = block
+ end
+
+ # Allows to perform initial parsing of parameters. The result is passed
+ # both to `command` and `explanation` blocks, instead of the raw
+ # parameters.
+ # It accepts a block that will be evaluated with the context given to
+ # `CommandDefintion#to_h`.
+ #
+ # Example:
+ #
+ # parse_params do |raw|
+ # raw.strip
+ # end
+ # command :command_key do |parsed|
+ # # Awesome code block
+ # end
+ def parse_params(&block)
+ @parse_params_block = block
+ end
+
+ # Registers a new command which is recognizeable from body of email or
+ # comment.
+ # It accepts aliases and takes a block.
+ #
+ # Example:
+ #
+ # command :my_command, :alias_for_my_command do |arguments|
+ # # Awesome code block
+ # end
+ def command(*command_names, &block)
+ define_command(CommandDefinition, *command_names, &block)
+ end
+
+ # Registers a new substitution which is recognizable from body of email or
+ # comment.
+ # It accepts aliases and takes a block with the formatted content.
+ #
+ # Example:
+ #
+ # command :my_substitution, :alias_for_my_substitution do |text|
+ # "#{text} MY AWESOME SUBSTITUTION"
+ # end
+ def substitution(*substitution_names, &block)
+ define_command(SubstitutionDefinition, *substitution_names, &block)
+ end
+
+ def definition_by_name(name)
+ command_definitions_by_name[name.to_sym]
+ end
+
+ private
+
+ def define_command(klass, *command_names, &block)
+ name, *aliases = command_names
+
+ definition = klass.new(
+ name,
+ aliases: aliases,
+ description: @description,
+ explanation: @explanation,
+ params: @params,
+ condition_block: @condition_block,
+ parse_params_block: @parse_params_block,
+ action_block: block
+ )
+
+ self.command_definitions << definition
+
+ definition.all_names.each do |name|
+ self.command_definitions_by_name[name] = definition
+ end
+
+ @description = nil
+ @explanation = nil
+ @params = nil
+ @condition_block = nil
+ @parse_params_block = nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/quick_actions/extractor.rb b/lib/gitlab/quick_actions/extractor.rb
new file mode 100644
index 00000000000..3ebfa3bd4b8
--- /dev/null
+++ b/lib/gitlab/quick_actions/extractor.rb
@@ -0,0 +1,144 @@
+module Gitlab
+ module QuickActions
+ # This class takes an array of commands that should be extracted from a
+ # given text.
+ #
+ # ```
+ # extractor = Gitlab::QuickActions::Extractor.new([:open, :assign, :labels])
+ # ```
+ class Extractor
+ attr_reader :command_definitions
+
+ def initialize(command_definitions)
+ @command_definitions = command_definitions
+ end
+
+ # Extracts commands from content and return an array of commands.
+ # The array looks like the following:
+ # [
+ # ['command1'],
+ # ['command3', 'arg1 arg2'],
+ # ]
+ # The command and the arguments are stripped.
+ # The original command text is removed from the given `content`.
+ #
+ # Usage:
+ # ```
+ # extractor = Gitlab::QuickActions::Extractor.new([:open, :assign, :labels])
+ # msg = %(hello\n/labels ~foo ~"bar baz"\nworld)
+ # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']]
+ # msg #=> "hello\nworld"
+ # ```
+ def extract_commands(content, opts = {})
+ return [content, []] unless content
+
+ content = content.dup
+
+ commands = []
+
+ content.delete!("\r")
+ content.gsub!(commands_regex(opts)) do
+ if $~[:cmd]
+ commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
+ ''
+ else
+ $~[0]
+ end
+ end
+
+ content, commands = perform_substitutions(content, commands)
+
+ [content.strip, commands]
+ end
+
+ private
+
+ # Builds a regular expression to match known commands.
+ # First match group captures the command name and
+ # second match group captures its arguments.
+ #
+ # It looks something like:
+ #
+ # /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/
+ def commands_regex(opts)
+ names = command_names(opts).map(&:to_s)
+
+ @commands_regex ||= %r{
+ (?<code>
+ # Code blocks:
+ # ```
+ # Anything, including `/cmd arg` which are ignored by this filter
+ # ```
+
+ ^```
+ .+?
+ \n```$
+ )
+ |
+ (?<html>
+ # HTML block:
+ # <tag>
+ # Anything, including `/cmd arg` which are ignored by this filter
+ # </tag>
+
+ ^<[^>]+?>\n
+ .+?
+ \n<\/[^>]+?>$
+ )
+ |
+ (?<html>
+ # Quote block:
+ # >>>
+ # Anything, including `/cmd arg` which are ignored by this filter
+ # >>>
+
+ ^>>>
+ .+?
+ \n>>>$
+ )
+ |
+ (?:
+ # Command not in a blockquote, blockcode, or HTML tag:
+ # /close
+
+ ^\/
+ (?<cmd>#{Regexp.union(names)})
+ (?:
+ [ ]
+ (?<arg>[^\n]*)
+ )?
+ (?:\n|$)
+ )
+ }mx
+ end
+
+ def perform_substitutions(content, commands)
+ return unless content
+
+ substitution_definitions = self.command_definitions.select do |definition|
+ definition.is_a?(Gitlab::QuickActions::SubstitutionDefinition)
+ end
+
+ substitution_definitions.each do |substitution|
+ match_data = substitution.match(content)
+ if match_data
+ command = [substitution.name.to_s]
+ command << match_data[1] unless match_data[1].empty?
+ commands << command
+ end
+ content = substitution.perform_substitution(self, content)
+ end
+
+ [content, commands]
+ end
+
+ def command_names(opts)
+ command_definitions.flat_map do |command|
+ next if command.noop?
+
+ command.all_names
+ end.compact
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/quick_actions/substitution_definition.rb b/lib/gitlab/quick_actions/substitution_definition.rb
new file mode 100644
index 00000000000..032c49ed159
--- /dev/null
+++ b/lib/gitlab/quick_actions/substitution_definition.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module QuickActions
+ class SubstitutionDefinition < CommandDefinition
+ # noop?=>true means these won't get extracted or removed by Gitlab::QuickActions::Extractor#extract_commands
+ # QuickActions::InterpretService#perform_substitutions handles them separately
+ def noop?
+ true
+ end
+
+ def match(content)
+ content.match %r{^/#{all_names.join('|')} ?(.*)$}
+ end
+
+ def perform_substitution(context, content)
+ return unless content
+
+ all_names.each do |a_name|
+ content.gsub!(%r{/#{a_name} ?(.*)$}, execute_block(action_block, context, '\1'))
+ end
+ content
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/redis.rb b/lib/gitlab/redis.rb
deleted file mode 100644
index bc5370de32a..00000000000
--- a/lib/gitlab/redis.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-# This file should not have any direct dependency on Rails environment
-# please require all dependencies below:
-require 'active_support/core_ext/hash/keys'
-require 'active_support/core_ext/module/delegation'
-
-module Gitlab
- class Redis
- CACHE_NAMESPACE = 'cache:gitlab'.freeze
- SESSION_NAMESPACE = 'session:gitlab'.freeze
- SIDEKIQ_NAMESPACE = 'resque:gitlab'.freeze
- MAILROOM_NAMESPACE = 'mail_room:gitlab'.freeze
- DEFAULT_REDIS_URL = 'redis://localhost:6379'.freeze
-
- class << self
- delegate :params, :url, to: :new
-
- def with
- @pool ||= ConnectionPool.new(size: pool_size) { ::Redis.new(params) }
- @pool.with { |redis| yield redis }
- end
-
- def pool_size
- if Sidekiq.server?
- # the pool will be used in a multi-threaded context
- Sidekiq.options[:concurrency] + 5
- else
- # probably this is a Unicorn process, so single threaded
- 5
- end
- end
-
- def _raw_config
- return @_raw_config if defined?(@_raw_config)
-
- begin
- @_raw_config = ERB.new(File.read(config_file)).result.freeze
- rescue Errno::ENOENT
- @_raw_config = false
- end
-
- @_raw_config
- end
-
- def config_file
- ENV['GITLAB_REDIS_CONFIG_FILE'] || File.expand_path('../../config/resque.yml', __dir__)
- end
- end
-
- def initialize(rails_env = nil)
- @rails_env = rails_env || ::Rails.env
- end
-
- def params
- redis_store_options
- end
-
- def url
- raw_config_hash[:url]
- end
-
- def sentinels
- raw_config_hash[:sentinels]
- end
-
- def sentinels?
- sentinels && !sentinels.empty?
- end
-
- private
-
- def redis_store_options
- config = raw_config_hash
- redis_url = config.delete(:url)
- redis_uri = URI.parse(redis_url)
-
- if redis_uri.scheme == 'unix'
- # Redis::Store does not handle Unix sockets well, so let's do it for them
- config[:path] = redis_uri.path
- config
- else
- redis_hash = ::Redis::Store::Factory.extract_host_options_from_uri(redis_url)
- # order is important here, sentinels must be after the connection keys.
- # {url: ..., port: ..., sentinels: [...]}
- redis_hash.merge(config)
- end
- end
-
- def raw_config_hash
- config_data = fetch_config
-
- if config_data
- config_data.is_a?(String) ? { url: config_data } : config_data.deep_symbolize_keys
- else
- { url: DEFAULT_REDIS_URL }
- end
- end
-
- def fetch_config
- self.class._raw_config ? YAML.load(self.class._raw_config)[@rails_env] : false
- end
- end
-end
diff --git a/lib/gitlab/redis/cache.rb b/lib/gitlab/redis/cache.rb
new file mode 100644
index 00000000000..9bf019b72e6
--- /dev/null
+++ b/lib/gitlab/redis/cache.rb
@@ -0,0 +1,31 @@
+# please require all dependencies below:
+require_relative 'wrapper' unless defined?(::Gitlab::Redis::Wrapper)
+
+module Gitlab
+ module Redis
+ class Cache < ::Gitlab::Redis::Wrapper
+ CACHE_NAMESPACE = 'cache:gitlab'.freeze
+ DEFAULT_REDIS_CACHE_URL = 'redis://localhost:6380'.freeze
+ REDIS_CACHE_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_CACHE_CONFIG_FILE'.freeze
+
+ class << self
+ def default_url
+ DEFAULT_REDIS_CACHE_URL
+ end
+
+ def config_file_name
+ # if ENV set for this class, use it even if it points to a file does not exist
+ file_name = ENV[REDIS_CACHE_CONFIG_ENV_VAR_NAME]
+ return file_name unless file_name.nil?
+
+ # otherwise, if config files exists for this class, use it
+ file_name = config_file_path('redis.cache.yml')
+ return file_name if File.file?(file_name)
+
+ # this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/redis/queues.rb b/lib/gitlab/redis/queues.rb
new file mode 100644
index 00000000000..e1695aafbeb
--- /dev/null
+++ b/lib/gitlab/redis/queues.rb
@@ -0,0 +1,32 @@
+# please require all dependencies below:
+require_relative 'wrapper' unless defined?(::Gitlab::Redis::Wrapper)
+
+module Gitlab
+ module Redis
+ class Queues < ::Gitlab::Redis::Wrapper
+ SIDEKIQ_NAMESPACE = 'resque:gitlab'.freeze
+ MAILROOM_NAMESPACE = 'mail_room:gitlab'.freeze
+ DEFAULT_REDIS_QUEUES_URL = 'redis://localhost:6381'.freeze
+ REDIS_QUEUES_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_QUEUES_CONFIG_FILE'.freeze
+
+ class << self
+ def default_url
+ DEFAULT_REDIS_QUEUES_URL
+ end
+
+ def config_file_name
+ # if ENV set for this class, use it even if it points to a file does not exist
+ file_name = ENV[REDIS_QUEUES_CONFIG_ENV_VAR_NAME]
+ return file_name if file_name
+
+ # otherwise, if config files exists for this class, use it
+ file_name = config_file_path('redis.queues.yml')
+ return file_name if File.file?(file_name)
+
+ # this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/redis/shared_state.rb b/lib/gitlab/redis/shared_state.rb
new file mode 100644
index 00000000000..10bec7a90da
--- /dev/null
+++ b/lib/gitlab/redis/shared_state.rb
@@ -0,0 +1,31 @@
+# please require all dependencies below:
+require_relative 'wrapper' unless defined?(::Gitlab::Redis::Wrapper)
+
+module Gitlab
+ module Redis
+ class SharedState < ::Gitlab::Redis::Wrapper
+ SESSION_NAMESPACE = 'session:gitlab'.freeze
+ DEFAULT_REDIS_SHARED_STATE_URL = 'redis://localhost:6382'.freeze
+ REDIS_SHARED_STATE_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_SHARED_STATE_CONFIG_FILE'.freeze
+
+ class << self
+ def default_url
+ DEFAULT_REDIS_SHARED_STATE_URL
+ end
+
+ def config_file_name
+ # if ENV set for this class, use it even if it points to a file does not exist
+ file_name = ENV[REDIS_SHARED_STATE_CONFIG_ENV_VAR_NAME]
+ return file_name if file_name
+
+ # otherwise, if config files exists for this class, use it
+ file_name = config_file_path('redis.shared_state.yml')
+ return file_name if File.file?(file_name)
+
+ # this will force use of DEFAULT_REDIS_SHARED_STATE_URL when config file is absent
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb
new file mode 100644
index 00000000000..8ad06480575
--- /dev/null
+++ b/lib/gitlab/redis/wrapper.rb
@@ -0,0 +1,140 @@
+# This file should only be used by sub-classes, not directly by any clients of the sub-classes
+# please require all dependencies below:
+require 'active_support/core_ext/hash/keys'
+require 'active_support/core_ext/module/delegation'
+
+module Gitlab
+ module Redis
+ class Wrapper
+ DEFAULT_REDIS_URL = 'redis://localhost:6379'.freeze
+ REDIS_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_CONFIG_FILE'.freeze
+
+ class << self
+ delegate :params, :url, to: :new
+
+ def with
+ @pool ||= ConnectionPool.new(size: pool_size) { ::Redis.new(params) }
+ @pool.with { |redis| yield redis }
+ end
+
+ def pool_size
+ # heuristic constant 5 should be a config setting somewhere -- related to CPU count?
+ size = 5
+ if Sidekiq.server?
+ # the pool will be used in a multi-threaded context
+ size += Sidekiq.options[:concurrency]
+ end
+ size
+ end
+
+ def _raw_config
+ return @_raw_config if defined?(@_raw_config)
+
+ @_raw_config =
+ begin
+ if filename = config_file_name
+ ERB.new(File.read(filename)).result.freeze
+ else
+ false
+ end
+ rescue Errno::ENOENT
+ false
+ end
+ end
+
+ def default_url
+ DEFAULT_REDIS_URL
+ end
+
+ # Return the absolute path to a Rails configuration file
+ #
+ # We use this instead of `Rails.root` because for certain tasks
+ # utilizing these classes, `Rails` might not be available.
+ def config_file_path(filename)
+ File.expand_path("../../../config/#{filename}", __dir__)
+ end
+
+ def config_file_name
+ # if ENV set for wrapper class, use it even if it points to a file does not exist
+ file_name = ENV[REDIS_CONFIG_ENV_VAR_NAME]
+ return file_name unless file_name.nil?
+
+ # otherwise, if config files exists for wrapper class, use it
+ file_name = config_file_path('resque.yml')
+ return file_name if File.file?(file_name)
+
+ # nil will force use of DEFAULT_REDIS_URL when config file is absent
+ nil
+ end
+ end
+
+ def initialize(rails_env = nil)
+ @rails_env = rails_env || ::Rails.env
+ end
+
+ def params
+ redis_store_options
+ end
+
+ def url
+ raw_config_hash[:url]
+ end
+
+ def sentinels
+ raw_config_hash[:sentinels]
+ end
+
+ def sentinels?
+ sentinels && !sentinels.empty?
+ end
+
+ private
+
+ def redis_store_options
+ config = raw_config_hash
+ redis_url = config.delete(:url)
+ redis_uri = URI.parse(redis_url)
+
+ if redis_uri.scheme == 'unix'
+ # Redis::Store does not handle Unix sockets well, so let's do it for them
+ config[:path] = redis_uri.path
+ query = redis_uri.query
+ unless query.nil?
+ queries = CGI.parse(redis_uri.query)
+ db_numbers = queries["db"] if queries.key?("db")
+ config[:db] = db_numbers[0].to_i if db_numbers.any?
+ end
+ config
+ else
+ redis_hash = ::Redis::Store::Factory.extract_host_options_from_uri(redis_url)
+ # order is important here, sentinels must be after the connection keys.
+ # {url: ..., port: ..., sentinels: [...]}
+ redis_hash.merge(config)
+ end
+ end
+
+ def raw_config_hash
+ config_data = fetch_config
+
+ if config_data
+ config_data.is_a?(String) ? { url: config_data } : config_data.deep_symbolize_keys
+ else
+ { url: self.class.default_url }
+ end
+ end
+
+ def fetch_config
+ return false unless self.class._raw_config
+
+ yaml = YAML.load(self.class._raw_config)
+
+ # If the file has content but it's invalid YAML, `load` returns false
+ if yaml
+ yaml.fetch(@rails_env, false)
+ else
+ false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb
index 7668ecacc4b..bc836dcc08d 100644
--- a/lib/gitlab/reference_extractor.rb
+++ b/lib/gitlab/reference_extractor.rb
@@ -33,14 +33,19 @@ module Gitlab
def issues
if project && project.jira_tracker?
- @references[:external_issue] ||= references(:external_issue)
+ if project.issues_enabled?
+ @references[:all_issues] ||= references(:external_issue) + references(:issue)
+ else
+ @references[:external_issue] ||= references(:external_issue) +
+ references(:issue).select { |i| i.project_id != project.id }
+ end
else
@references[:issue] ||= references(:issue)
end
end
def all
- REFERABLES.each { |referable| send(referable.to_s.pluralize) }
+ REFERABLES.each { |referable| send(referable.to_s.pluralize) } # rubocop:disable GitlabSecurity/PublicSend
@references.values.flatten
end
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 08b061d5e31..1adc5ec952a 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -2,35 +2,6 @@ module Gitlab
module Regex
extend self
- # The namespace regex is used in Javascript to validate usernames in the "Register" form. However, Javascript
- # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`.
- # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to
- # allow non-regex validatiions, etc), `NAMESPACE_REGEX_STR_JS` serves as a Javascript-compatible version of
- # `NAMESPACE_REGEX_STR`, with the negative lookbehind assertion removed. This means that the client-side validation
- # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation.
- PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze
- NAMESPACE_REGEX_STR_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze
- NO_SUFFIX_REGEX_STR = '(?<!\.git|\.atom)'.freeze
- NAMESPACE_REGEX_STR = "(?:#{NAMESPACE_REGEX_STR_JS})#{NO_SUFFIX_REGEX_STR}".freeze
- PROJECT_REGEX_STR = "(?:#{PATH_REGEX_STR})#{NO_SUFFIX_REGEX_STR}".freeze
-
- # Same as NAMESPACE_REGEX_STR but allows `/` in the path.
- # So `group/subgroup` will match this regex but not NAMESPACE_REGEX_STR
- FULL_NAMESPACE_REGEX_STR = "(?:#{NAMESPACE_REGEX_STR}/)*#{NAMESPACE_REGEX_STR}".freeze
-
- def namespace_regex
- @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze
- end
-
- def namespace_route_regex
- @namespace_route_regex ||= /#{NAMESPACE_REGEX_STR}/.freeze
- end
-
- def namespace_regex_message
- "can contain only letters, digits, '_', '-' and '.'. " \
- "Cannot start with '-' or end in '.', '.git' or '.atom'." \
- end
-
def namespace_name_regex
@namespace_name_regex ||= /\A[\p{Alnum}\p{Pd}_\. ]*\z/.freeze
end
@@ -48,76 +19,33 @@ module Gitlab
"It must start with letter, digit, emoji or '_'."
end
- def project_path_regex
- @project_path_regex ||= /\A#{PROJECT_REGEX_STR}\z/.freeze
- end
-
- def project_route_regex
- @project_route_regex ||= /#{PROJECT_REGEX_STR}/.freeze
- end
-
- def project_git_route_regex
- @project_route_git_regex ||= /#{PATH_REGEX_STR}\.git/.freeze
- end
-
- def project_path_regex_message
- "can contain only letters, digits, '_', '-' and '.'. " \
- "Cannot start with '-', end in '.git' or end in '.atom'" \
- end
-
- def file_name_regex
- @file_name_regex ||= /\A[[[:alnum:]]_\-\.\@\+]*\z/.freeze
- end
-
- def file_name_regex_message
- "can contain only letters, digits, '_', '-', '@', '+' and '.'."
- end
-
- def archive_formats_regex
- # |zip|tar| tar.gz | tar.bz2 |
- @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze
- end
-
- def git_reference_regex
- # Valid git ref regex, see:
- # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
-
- @git_reference_regex ||= %r{
- (?!
- (?# doesn't begins with)
- \/| (?# rule #6)
- (?# doesn't contain)
- .*(?:
- [\/.]\.| (?# rule #1,3)
- \/\/| (?# rule #6)
- @\{| (?# rule #8)
- \\ (?# rule #9)
- )
- )
- [^\000-\040\177~^:?*\[]+ (?# rule #4-5)
- (?# doesn't end with)
- (?<!\.lock) (?# rule #1)
- (?<![\/.]) (?# rule #6-7)
- }x.freeze
- end
-
- def container_registry_reference_regex
- git_reference_regex
- end
-
##
- # Docker Distribution Registry 2.4.1 repository name rules
+ # Docker Distribution Registry repository / tag name rules
+ #
+ # See https://github.com/docker/distribution/blob/master/reference/regexp.go.
#
def container_repository_name_regex
@container_repository_regex ||= %r{\A[a-z0-9]+(?:[-._/][a-z0-9]+)*\Z}
end
+ ##
+ # We do not use regexp anchors here because these are not allowed when
+ # used as a routing constraint.
+ #
+ def container_registry_tag_regex
+ @container_registry_tag_regex ||= /[\w][\w.-]{0,127}/
+ end
+
+ def environment_name_regex_chars
+ 'a-zA-Z0-9_/\\$\\{\\}\\. -'
+ end
+
def environment_name_regex
- @environment_name_regex ||= /\A[a-zA-Z0-9_\\\/\${}. -]+\z/.freeze
+ @environment_name_regex ||= /\A[#{environment_name_regex_chars}]+\z/.freeze
end
def environment_name_regex_message
- "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.' and spaces"
+ "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces"
end
def kubernetes_namespace_regex
diff --git a/lib/gitlab/repo_path.rb b/lib/gitlab/repo_path.rb
index 4b1d828c45c..3591fa9145e 100644
--- a/lib/gitlab/repo_path.rb
+++ b/lib/gitlab/repo_path.rb
@@ -2,22 +2,42 @@ module Gitlab
module RepoPath
NotFoundError = Class.new(StandardError)
- def self.strip_storage_path(repo_path)
- result = nil
-
- Gitlab.config.repositories.storages.values.each do |params|
- storage_path = params['path']
- if repo_path.start_with?(storage_path)
- result = repo_path.sub(storage_path, '')
- break
- end
+ def self.parse(repo_path)
+ wiki = false
+ project_path = strip_storage_path(repo_path.sub(/\.git\z/, ''), fail_on_not_found: false)
+ project, was_redirected = find_project(project_path)
+
+ if project_path.end_with?('.wiki') && project.nil?
+ project, was_redirected = find_project(project_path.chomp('.wiki'))
+ wiki = true
+ end
+
+ redirected_path = project_path if was_redirected
+
+ [project, wiki, redirected_path]
+ end
+
+ def self.strip_storage_path(repo_path, fail_on_not_found: true)
+ result = repo_path
+
+ storage = Gitlab.config.repositories.storages.values.find do |params|
+ repo_path.start_with?(params['path'])
end
- if result.nil?
+ if storage
+ result = result.sub(storage['path'], '')
+ elsif fail_on_not_found
raise NotFoundError.new("No known storage path matches #{repo_path.inspect}")
end
result.sub(/\A\/*/, '')
end
+
+ def self.find_project(project_path)
+ project = Project.find_by_full_path(project_path, follow_redirects: true)
+ was_redirected = project && project.full_path.casecmp(project_path) != 0
+
+ [project, was_redirected]
+ end
end
end
diff --git a/lib/gitlab/request_forgery_protection.rb b/lib/gitlab/request_forgery_protection.rb
new file mode 100644
index 00000000000..ccfe0d6bed3
--- /dev/null
+++ b/lib/gitlab/request_forgery_protection.rb
@@ -0,0 +1,39 @@
+# A module to check CSRF tokens in requests.
+# It's used in API helpers and OmniAuth.
+# Usage: GitLab::RequestForgeryProtection.call(env)
+
+module Gitlab
+ module RequestForgeryProtection
+ class Controller < ActionController::Base
+ protect_from_forgery with: :exception
+
+ rescue_from ActionController::InvalidAuthenticityToken do |e|
+ logger.warn "This CSRF token verification failure is handled internally by `GitLab::RequestForgeryProtection`"
+ logger.warn "Unlike the logs may suggest, this does not result in an actual 422 response to the user"
+ logger.warn "For API requests, the only effect is that `current_user` will be `nil` for the duration of the request"
+
+ raise e
+ end
+
+ def index
+ head :ok
+ end
+ end
+
+ def self.app
+ @app ||= Controller.action(:index)
+ end
+
+ def self.call(env)
+ app.call(env)
+ end
+
+ def self.verified?(env)
+ call(env)
+
+ true
+ rescue ActionController::InvalidAuthenticityToken
+ false
+ end
+ end
+end
diff --git a/lib/gitlab/route_map.rb b/lib/gitlab/route_map.rb
index 36791fae60f..f3952657983 100644
--- a/lib/gitlab/route_map.rb
+++ b/lib/gitlab/route_map.rb
@@ -18,15 +18,19 @@ module Gitlab
mapping = @map.find { |mapping| mapping[:source] === path }
return unless mapping
- path.sub(mapping[:source], mapping[:public])
+ if mapping[:source].is_a?(String)
+ path.sub(mapping[:source], mapping[:public])
+ else
+ mapping[:source].replace(path, mapping[:public])
+ end
end
private
def parse_entry(entry)
raise FormatError, 'Route map entry is not a hash' unless entry.is_a?(Hash)
- raise FormatError, 'Route map entry does not have a source key' unless entry.has_key?('source')
- raise FormatError, 'Route map entry does not have a public key' unless entry.has_key?('public')
+ raise FormatError, 'Route map entry does not have a source key' unless entry.key?('source')
+ raise FormatError, 'Route map entry does not have a public key' unless entry.key?('public')
source_pattern = entry['source']
public_path = entry['public']
@@ -35,7 +39,7 @@ module Gitlab
source_pattern = source_pattern[1...-1].gsub('\/', '/')
begin
- source_pattern = /\A#{source_pattern}\z/
+ source_pattern = Gitlab::UntrustedRegexp.new('\A' + source_pattern + '\z')
rescue RegexpError => e
raise FormatError, "Route map entry source is not a valid regular expression: #{e}"
end
diff --git a/lib/gitlab/routing.rb b/lib/gitlab/routing.rb
index 632e2d87500..e57890f1143 100644
--- a/lib/gitlab/routing.rb
+++ b/lib/gitlab/routing.rb
@@ -2,10 +2,35 @@ module Gitlab
module Routing
extend ActiveSupport::Concern
+ mattr_accessor :_includers
+ self._includers = []
+
included do
+ Gitlab::Routing.includes_helpers(self)
+
include Gitlab::Routing.url_helpers
end
+ def self.includes_helpers(klass)
+ self._includers << klass
+ end
+
+ def self.add_helpers(mod)
+ url_helpers.include mod
+ url_helpers.extend mod
+
+ GitlabRoutingHelper.include mod
+ GitlabRoutingHelper.extend mod
+
+ app_url_helpers = Gitlab::Application.routes.named_routes.url_helpers_module
+ app_url_helpers.include mod
+ app_url_helpers.extend mod
+
+ _includers.each do |klass|
+ klass.include mod
+ end
+ end
+
# Returns the URL helpers Module.
#
# This method caches the output as Rails' "url_helpers" method creates an
diff --git a/lib/gitlab/seeder.rb b/lib/gitlab/seeder.rb
index 823f697f51c..f9ab9bd466f 100644
--- a/lib/gitlab/seeder.rb
+++ b/lib/gitlab/seeder.rb
@@ -1,3 +1,4 @@
+# :nocov:
module DeliverNever
def deliver_later
self
@@ -21,3 +22,4 @@ module Gitlab
end
end
end
+# :nocov:
diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb
index 117fc508135..2442c2ded3b 100644
--- a/lib/gitlab/sentry.rb
+++ b/lib/gitlab/sentry.rb
@@ -11,7 +11,7 @@ module Gitlab
Raven.user_context(
id: current_user.id,
email: current_user.email,
- username: current_user.username,
+ username: current_user.username
)
end
end
diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb
index 36a871e5bbc..280a9abf03e 100644
--- a/lib/gitlab/shell.rb
+++ b/lib/gitlab/shell.rb
@@ -1,7 +1,12 @@
+# Gitaly note: JV: two sets of straightforward RPC's. 1 Hard RPC: fork_repository.
+# SSH key operations are not part of Gitaly so will never be migrated.
+
require 'securerandom'
module Gitlab
class Shell
+ GITLAB_SHELL_ENV_VARS = %w(GIT_TERMINAL_PROMPT).freeze
+
Error = Class.new(StandardError)
KeyAdder = Struct.new(:io) do
@@ -30,8 +35,8 @@ module Gitlab
end
def version_required
- @version_required ||= File.read(Rails.root.
- join('GITLAB_SHELL_VERSION')).strip
+ @version_required ||= File.read(Rails.root
+ .join('GITLAB_SHELL_VERSION')).strip
end
def strip_key(key)
@@ -66,9 +71,12 @@ module Gitlab
# Ex.
# add_repository("/path/to/storage", "gitlab/gitlab-ci")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
def add_repository(storage, name)
- Gitlab::Utils.system_silent([gitlab_shell_projects_path,
- 'add-project', storage, "#{name}.git"])
+ Gitlab::Git::Repository.create(storage, name, bare: true, symlink_hooks_to: gitlab_shell_hooks_path)
+ rescue => err
+ Rails.logger.error("Failed to add repository #{storage}/#{name}: #{err}")
+ false
end
# Import repository
@@ -79,13 +87,13 @@ module Gitlab
# Ex.
# import_repository("/path/to/storage", "gitlab/gitlab-ci", "https://github.com/randx/six.git")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
def import_repository(storage, name, url)
# Timeout should be less than 900 ideally, to prevent the memory killer
# to silently kill the process without knowing we are timing out here.
- output, status = Popen.popen([gitlab_shell_projects_path, 'import-project',
- storage, "#{name}.git", url, '800'])
- raise Error, output unless status.zero?
- true
+ cmd = [gitlab_shell_projects_path, 'import-project',
+ storage, "#{name}.git", url, "#{Gitlab.config.gitlab_shell.git_timeout}"]
+ gitlab_shell_fast_execute_raise_error(cmd)
end
# Fetch remote for repository
@@ -98,14 +106,25 @@ module Gitlab
# Ex.
# fetch_remote("gitlab/gitlab-ci", "upstream")
#
- def fetch_remote(storage, name, remote, forced: false, no_tags: false)
- args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, '800']
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
+ def fetch_remote(storage, name, remote, ssh_auth: nil, forced: false, no_tags: false)
+ args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"]
args << '--force' if forced
args << '--no-tags' if no_tags
- output, status = Popen.popen(args)
- raise Error, output unless status.zero?
- true
+ vars = {}
+
+ if ssh_auth&.ssh_import?
+ if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
+ vars['GITLAB_SHELL_SSH_KEY'] = ssh_auth.ssh_private_key
+ end
+
+ if ssh_auth.ssh_known_hosts.present?
+ vars['GITLAB_SHELL_KNOWN_HOSTS'] = ssh_auth.ssh_known_hosts
+ end
+ end
+
+ gitlab_shell_fast_execute_raise_error(args, vars)
end
# Move repository
@@ -116,9 +135,10 @@ module Gitlab
# Ex.
# mv_repository("/path/to/storage", "gitlab/gitlab-ci", "randx/gitlab-ci-new")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
def mv_repository(storage, path, new_path)
- Gitlab::Utils.system_silent([gitlab_shell_projects_path, 'mv-project',
- storage, "#{path}.git", "#{new_path}.git"])
+ gitlab_shell_fast_execute([gitlab_shell_projects_path, 'mv-project',
+ storage, "#{path}.git", "#{new_path}.git"])
end
# Fork repository to new namespace
@@ -130,10 +150,11 @@ module Gitlab
# Ex.
# fork_repository("/path/to/forked_from/storage", "gitlab/gitlab-ci", "/path/to/forked_to/storage", "randx")
#
+ # Gitaly note: JV: not easy to migrate because this involves two Gitaly servers, not one.
def fork_repository(forked_from_storage, path, forked_to_storage, fork_namespace)
- Gitlab::Utils.system_silent([gitlab_shell_projects_path, 'fork-project',
- forked_from_storage, "#{path}.git", forked_to_storage,
- fork_namespace])
+ gitlab_shell_fast_execute([gitlab_shell_projects_path, 'fork-project',
+ forked_from_storage, "#{path}.git", forked_to_storage,
+ fork_namespace])
end
# Remove repository from file system
@@ -144,9 +165,10 @@ module Gitlab
# Ex.
# remove_repository("/path/to/storage", "gitlab/gitlab-ci")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
def remove_repository(storage, name)
- Gitlab::Utils.system_silent([gitlab_shell_projects_path,
- 'rm-project', storage, "#{name}.git"])
+ gitlab_shell_fast_execute([gitlab_shell_projects_path,
+ 'rm-project', storage, "#{name}.git"])
end
# Add new key to gitlab-shell
@@ -155,8 +177,8 @@ module Gitlab
# add_key("key-42", "sha-rsa ...")
#
def add_key(key_id, key_content)
- Gitlab::Utils.system_silent([gitlab_shell_keys_path,
- 'add-key', key_id, self.class.strip_key(key_content)])
+ gitlab_shell_fast_execute([gitlab_shell_keys_path,
+ 'add-key', key_id, self.class.strip_key(key_content)])
end
# Batch-add keys to authorized_keys
@@ -175,8 +197,10 @@ module Gitlab
# remove_key("key-342", "sha-rsa ...")
#
def remove_key(key_id, key_content)
- Gitlab::Utils.system_silent([gitlab_shell_keys_path,
- 'rm-key', key_id, key_content])
+ args = [gitlab_shell_keys_path, 'rm-key', key_id]
+ args << key_content if key_content
+
+ gitlab_shell_fast_execute(args)
end
# Remove all ssh keys from gitlab shell
@@ -185,7 +209,7 @@ module Gitlab
# remove_all_keys
#
def remove_all_keys
- Gitlab::Utils.system_silent([gitlab_shell_keys_path, 'clear'])
+ gitlab_shell_fast_execute([gitlab_shell_keys_path, 'clear'])
end
# Add empty directory for storing repositories
@@ -193,6 +217,7 @@ module Gitlab
# Ex.
# add_namespace("/path/to/storage", "gitlab")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def add_namespace(storage, name)
path = full_path(storage, name)
FileUtils.mkdir_p(path, mode: 0770) unless exists?(storage, name)
@@ -206,6 +231,7 @@ module Gitlab
# Ex.
# rm_namespace("/path/to/storage", "gitlab")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def rm_namespace(storage, name)
FileUtils.rm_r(full_path(storage, name), force: true)
end
@@ -215,6 +241,7 @@ module Gitlab
# Ex.
# mv_namespace("/path/to/storage", "gitlab", "gitlabhq")
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def mv_namespace(storage, old_name, new_name)
return false if exists?(storage, new_name) || !exists?(storage, old_name)
@@ -240,6 +267,7 @@ module Gitlab
# exists?(storage, 'gitlab')
# exists?(storage, 'gitlab/cookies.git')
#
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def exists?(storage, dir_name)
File.exist?(full_path(storage, dir_name))
end
@@ -247,7 +275,11 @@ module Gitlab
protected
def gitlab_shell_path
- Gitlab.config.gitlab_shell.path
+ File.expand_path(Gitlab.config.gitlab_shell.path)
+ end
+
+ def gitlab_shell_hooks_path
+ File.expand_path(Gitlab.config.gitlab_shell.hooks_path)
end
def gitlab_shell_user_home
@@ -267,5 +299,31 @@ module Gitlab
def gitlab_shell_keys_path
File.join(gitlab_shell_path, 'bin', 'gitlab-keys')
end
+
+ private
+
+ def gitlab_shell_fast_execute(cmd)
+ output, status = gitlab_shell_fast_execute_helper(cmd)
+
+ return true if status.zero?
+
+ Rails.logger.error("gitlab-shell failed with error #{status}: #{output}")
+ false
+ end
+
+ def gitlab_shell_fast_execute_raise_error(cmd, vars = {})
+ output, status = gitlab_shell_fast_execute_helper(cmd, vars)
+
+ raise Error, output unless status.zero?
+ true
+ end
+
+ def gitlab_shell_fast_execute_helper(cmd, vars = {})
+ vars.merge!(ENV.to_h.slice(*GITLAB_SHELL_ENV_VARS))
+
+ # Don't pass along the entire parent environment to prevent gitlab-shell
+ # from wasting I/O by searching through GEM_PATH
+ Bundler.with_original_env { Popen.popen(cmd, nil, vars) }
+ end
end
end
diff --git a/lib/gitlab/sherlock/line_profiler.rb b/lib/gitlab/sherlock/line_profiler.rb
index aa1468bff6b..b5f9d040047 100644
--- a/lib/gitlab/sherlock/line_profiler.rb
+++ b/lib/gitlab/sherlock/line_profiler.rb
@@ -77,8 +77,8 @@ module Gitlab
line_samples << LineSample.new(duration, events)
end
- samples << FileSample.
- new(file, line_samples, total_duration, total_events)
+ samples << FileSample
+ .new(file, line_samples, total_duration, total_events)
end
samples
diff --git a/lib/gitlab/sherlock/query.rb b/lib/gitlab/sherlock/query.rb
index 99e56e923eb..948bf5e6528 100644
--- a/lib/gitlab/sherlock/query.rb
+++ b/lib/gitlab/sherlock/query.rb
@@ -105,10 +105,10 @@ module Gitlab
end
def format_sql(query)
- query.each_line.
- map { |line| line.strip }.
- join("\n").
- gsub(PREFIX_NEWLINE) { "\n#{$1} " }
+ query.each_line
+ .map { |line| line.strip }
+ .join("\n")
+ .gsub(PREFIX_NEWLINE) { "\n#{$1} " }
end
end
end
diff --git a/lib/gitlab/sidekiq_status.rb b/lib/gitlab/sidekiq_status.rb
index ca8d3271541..a0a2769cf9e 100644
--- a/lib/gitlab/sidekiq_status.rb
+++ b/lib/gitlab/sidekiq_status.rb
@@ -90,9 +90,14 @@ module Gitlab
#
# Returns an array of completed JIDs
def self.completed_jids(job_ids)
- Sidekiq.redis do |redis|
- job_ids.reject { |jid| redis.exists(key_for(jid)) }
+ statuses = job_status(job_ids)
+
+ completed = []
+ job_ids.zip(statuses).each do |job_id, status|
+ completed << job_id unless status
end
+
+ completed
end
def self.key_for(jid)
diff --git a/lib/gitlab/slash_commands/base_command.rb b/lib/gitlab/slash_commands/base_command.rb
new file mode 100644
index 00000000000..cc3c9a50555
--- /dev/null
+++ b/lib/gitlab/slash_commands/base_command.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module SlashCommands
+ class BaseCommand
+ QUERY_LIMIT = 5
+
+ def self.match(_text)
+ raise NotImplementedError
+ end
+
+ def self.help_message
+ raise NotImplementedError
+ end
+
+ def self.available?(_project)
+ raise NotImplementedError
+ end
+
+ def self.allowed?(_user, _ability)
+ true
+ end
+
+ def self.can?(object, action, subject)
+ Ability.allowed?(object, action, subject)
+ end
+
+ def execute(_)
+ raise NotImplementedError
+ end
+
+ def collection
+ raise NotImplementedError
+ end
+
+ attr_accessor :project, :current_user, :params
+
+ def initialize(project, user, params = {})
+ @project, @current_user, @params = project, user, params.dup
+ end
+
+ private
+
+ def find_by_iid(iid)
+ collection.find_by(iid: iid)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb
new file mode 100644
index 00000000000..a78408b0519
--- /dev/null
+++ b/lib/gitlab/slash_commands/command.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module SlashCommands
+ class Command < BaseCommand
+ COMMANDS = [
+ Gitlab::SlashCommands::IssueShow,
+ Gitlab::SlashCommands::IssueNew,
+ Gitlab::SlashCommands::IssueSearch,
+ Gitlab::SlashCommands::Deploy
+ ].freeze
+
+ def execute
+ command, match = match_command
+
+ if command
+ if command.allowed?(project, current_user)
+ command.new(project, current_user, params).execute(match)
+ else
+ Gitlab::SlashCommands::Presenters::Access.new.access_denied
+ end
+ else
+ Gitlab::SlashCommands::Help.new(project, current_user, params).execute(available_commands, params[:text])
+ end
+ end
+
+ def match_command
+ match = nil
+ service =
+ available_commands.find do |klass|
+ match = klass.match(params[:text])
+ end
+
+ [service, match]
+ end
+
+ private
+
+ def available_commands
+ COMMANDS.select do |klass|
+ klass.available?(project)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb
deleted file mode 100644
index 60d35be2599..00000000000
--- a/lib/gitlab/slash_commands/command_definition.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-module Gitlab
- module SlashCommands
- class CommandDefinition
- attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block
-
- def initialize(name, attributes = {})
- @name = name
-
- @aliases = attributes[:aliases] || []
- @description = attributes[:description] || ''
- @params = attributes[:params] || []
- @condition_block = attributes[:condition_block]
- @action_block = attributes[:action_block]
- end
-
- def all_names
- [name, *aliases]
- end
-
- def noop?
- action_block.nil?
- end
-
- def available?(opts)
- return true unless condition_block
-
- context = OpenStruct.new(opts)
- context.instance_exec(&condition_block)
- end
-
- def execute(context, opts, arg)
- return if noop? || !available?(opts)
-
- if arg.present?
- context.instance_exec(arg, &action_block)
- elsif action_block.arity == 0
- context.instance_exec(&action_block)
- end
- end
-
- def to_h(opts)
- desc = description
- if desc.respond_to?(:call)
- context = OpenStruct.new(opts)
- desc = context.instance_exec(&desc) rescue ''
- end
-
- {
- name: name,
- aliases: aliases,
- description: desc,
- params: params
- }
- end
- end
- end
-end
diff --git a/lib/gitlab/slash_commands/deploy.rb b/lib/gitlab/slash_commands/deploy.rb
new file mode 100644
index 00000000000..93e00ab75a1
--- /dev/null
+++ b/lib/gitlab/slash_commands/deploy.rb
@@ -0,0 +1,55 @@
+module Gitlab
+ module SlashCommands
+ class Deploy < BaseCommand
+ def self.match(text)
+ /\Adeploy\s+(?<from>\S+.*)\s+to+\s+(?<to>\S+.*)\z/.match(text)
+ end
+
+ def self.help_message
+ 'deploy <environment> to <target-environment>'
+ end
+
+ def self.available?(project)
+ project.builds_enabled?
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :create_deployment, project)
+ end
+
+ def execute(match)
+ from = match[:from]
+ to = match[:to]
+
+ action = find_action(from, to)
+
+ if action.nil?
+ Gitlab::SlashCommands::Presenters::Deploy
+ .new(action).action_not_found
+ else
+ deployment = action.play(current_user)
+
+ Gitlab::SlashCommands::Presenters::Deploy
+ .new(deployment).present(from, to)
+ end
+ end
+
+ private
+
+ def find_action(from, to)
+ environment = project.environments.find_by(name: from)
+ return unless environment
+
+ actions = environment.actions_for(to).select do |action|
+ action.starts_environment?
+ end
+
+ if actions.many?
+ actions.find { |action| action.name == to.to_s }
+ else
+ actions.first
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb
deleted file mode 100644
index 50b0937d267..00000000000
--- a/lib/gitlab/slash_commands/dsl.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-module Gitlab
- module SlashCommands
- module Dsl
- extend ActiveSupport::Concern
-
- included do
- cattr_accessor :command_definitions, instance_accessor: false do
- []
- end
-
- cattr_accessor :command_definitions_by_name, instance_accessor: false do
- {}
- end
- end
-
- class_methods do
- # Allows to give a description to the next slash command.
- # This description is shown in the autocomplete menu.
- # It accepts a block that will be evaluated with the context given to
- # `CommandDefintion#to_h`.
- #
- # Example:
- #
- # desc do
- # "This is a dynamic description for #{noteable.to_ability_name}"
- # end
- # command :command_key do |arguments|
- # # Awesome code block
- # end
- def desc(text = '', &block)
- @description = block_given? ? block : text
- end
-
- # Allows to define params for the next slash command.
- # These params are shown in the autocomplete menu.
- #
- # Example:
- #
- # params "~label ~label2"
- # command :command_key do |arguments|
- # # Awesome code block
- # end
- def params(*params)
- @params = params
- end
-
- # Allows to define conditions that must be met in order for the command
- # to be returned by `.command_names` & `.command_definitions`.
- # It accepts a block that will be evaluated with the context given to
- # `CommandDefintion#to_h`.
- #
- # Example:
- #
- # condition do
- # project.public?
- # end
- # command :command_key do |arguments|
- # # Awesome code block
- # end
- def condition(&block)
- @condition_block = block
- end
-
- # Registers a new command which is recognizeable from body of email or
- # comment.
- # It accepts aliases and takes a block.
- #
- # Example:
- #
- # command :my_command, :alias_for_my_command do |arguments|
- # # Awesome code block
- # end
- def command(*command_names, &block)
- name, *aliases = command_names
-
- definition = CommandDefinition.new(
- name,
- aliases: aliases,
- description: @description,
- params: @params,
- condition_block: @condition_block,
- action_block: block
- )
-
- self.command_definitions << definition
-
- definition.all_names.each do |name|
- self.command_definitions_by_name[name] = definition
- end
-
- @description = nil
- @params = nil
- @condition_block = nil
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb
deleted file mode 100644
index 6dbb467d70d..00000000000
--- a/lib/gitlab/slash_commands/extractor.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-module Gitlab
- module SlashCommands
- # This class takes an array of commands that should be extracted from a
- # given text.
- #
- # ```
- # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
- # ```
- class Extractor
- attr_reader :command_definitions
-
- def initialize(command_definitions)
- @command_definitions = command_definitions
- end
-
- # Extracts commands from content and return an array of commands.
- # The array looks like the following:
- # [
- # ['command1'],
- # ['command3', 'arg1 arg2'],
- # ]
- # The command and the arguments are stripped.
- # The original command text is removed from the given `content`.
- #
- # Usage:
- # ```
- # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
- # msg = %(hello\n/labels ~foo ~"bar baz"\nworld)
- # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']]
- # msg #=> "hello\nworld"
- # ```
- def extract_commands(content, opts = {})
- return [content, []] unless content
-
- content = content.dup
-
- commands = []
-
- content.delete!("\r")
- content.gsub!(commands_regex(opts)) do
- if $~[:cmd]
- commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
- ''
- else
- $~[0]
- end
- end
-
- [content.strip, commands]
- end
-
- private
-
- # Builds a regular expression to match known commands.
- # First match group captures the command name and
- # second match group captures its arguments.
- #
- # It looks something like:
- #
- # /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/
- def commands_regex(opts)
- names = command_names(opts).map(&:to_s)
-
- @commands_regex ||= %r{
- (?<code>
- # Code blocks:
- # ```
- # Anything, including `/cmd arg` which are ignored by this filter
- # ```
-
- ^```
- .+?
- \n```$
- )
- |
- (?<html>
- # HTML block:
- # <tag>
- # Anything, including `/cmd arg` which are ignored by this filter
- # </tag>
-
- ^<[^>]+?>\n
- .+?
- \n<\/[^>]+?>$
- )
- |
- (?<html>
- # Quote block:
- # >>>
- # Anything, including `/cmd arg` which are ignored by this filter
- # >>>
-
- ^>>>
- .+?
- \n>>>$
- )
- |
- (?:
- # Command not in a blockquote, blockcode, or HTML tag:
- # /close
-
- ^\/
- (?<cmd>#{Regexp.union(names)})
- (?:
- [ ]
- (?<arg>[^\n]*)
- )?
- (?:\n|$)
- )
- }mx
- end
-
- def command_names(opts)
- command_definitions.flat_map do |command|
- next if command.noop?
-
- command.all_names
- end.compact
- end
- end
- end
-end
diff --git a/lib/gitlab/slash_commands/help.rb b/lib/gitlab/slash_commands/help.rb
new file mode 100644
index 00000000000..81f3707e03e
--- /dev/null
+++ b/lib/gitlab/slash_commands/help.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module SlashCommands
+ class Help < BaseCommand
+ # This class has to be used last, as it always matches. It has to match
+ # because other commands were not triggered and we want to show the help
+ # command
+ def self.match(_text)
+ true
+ end
+
+ def self.help_message
+ 'help'
+ end
+
+ def self.allowed?(_project, _user)
+ true
+ end
+
+ def execute(commands, text)
+ Gitlab::SlashCommands::Presenters::Help.new(commands).present(trigger, text)
+ end
+
+ def trigger
+ params[:command]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/issue_command.rb b/lib/gitlab/slash_commands/issue_command.rb
new file mode 100644
index 00000000000..3d96982b820
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_command.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module SlashCommands
+ class IssueCommand < BaseCommand
+ def self.available?(project)
+ project.issues_enabled?
+ end
+
+ def collection
+ IssuesFinder.new(current_user, project_id: project.id).execute
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/issue_new.rb b/lib/gitlab/slash_commands/issue_new.rb
new file mode 100644
index 00000000000..25f965e843d
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_new.rb
@@ -0,0 +1,42 @@
+module Gitlab
+ module SlashCommands
+ class IssueNew < IssueCommand
+ def self.match(text)
+ # we can not match \n with the dot by passing the m modifier as than
+ # the title and description are not seperated
+ /\Aissue\s+(new|create)\s+(?<title>[^\n]*)\n*(?<description>(.|\n)*)/.match(text)
+ end
+
+ def self.help_message
+ 'issue new <title> *`⇧ Shift`*+*`↵ Enter`* <description>'
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :create_issue, project)
+ end
+
+ def execute(match)
+ title = match[:title]
+ description = match[:description].to_s.rstrip
+
+ issue = create_issue(title: title, description: description)
+
+ if issue.persisted?
+ presenter(issue).present
+ else
+ presenter(issue).display_errors
+ end
+ end
+
+ private
+
+ def create_issue(title:, description:)
+ Issues::CreateService.new(project, current_user, title: title, description: description).execute
+ end
+
+ def presenter(issue)
+ Gitlab::SlashCommands::Presenters::IssueNew.new(issue)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/issue_search.rb b/lib/gitlab/slash_commands/issue_search.rb
new file mode 100644
index 00000000000..acba84b54b4
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_search.rb
@@ -0,0 +1,23 @@
+module Gitlab
+ module SlashCommands
+ class IssueSearch < IssueCommand
+ def self.match(text)
+ /\Aissue\s+search\s+(?<query>.*)/.match(text)
+ end
+
+ def self.help_message
+ "issue search <your query>"
+ end
+
+ def execute(match)
+ issues = collection.search(match[:query]).limit(QUERY_LIMIT)
+
+ if issues.present?
+ Presenters::IssueSearch.new(issues).present
+ else
+ Presenters::Access.new(issues).not_found
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/issue_show.rb b/lib/gitlab/slash_commands/issue_show.rb
new file mode 100644
index 00000000000..ffa5184e5cb
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_show.rb
@@ -0,0 +1,23 @@
+module Gitlab
+ module SlashCommands
+ class IssueShow < IssueCommand
+ def self.match(text)
+ /\Aissue\s+show\s+#{Issue.reference_prefix}?(?<iid>\d+)/.match(text)
+ end
+
+ def self.help_message
+ "issue show <id>"
+ end
+
+ def execute(match)
+ issue = find_by_iid(match[:iid])
+
+ if issue
+ Gitlab::SlashCommands::Presenters::IssueShow.new(issue).present
+ else
+ Gitlab::SlashCommands::Presenters::Access.new.not_found
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/access.rb b/lib/gitlab/slash_commands/presenters/access.rb
new file mode 100644
index 00000000000..1a817eb735b
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/access.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class Access < Presenters::Base
+ def access_denied
+ ephemeral_response(text: "Whoops! This action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
+ end
+
+ def not_found
+ ephemeral_response(text: "404 not found! GitLab couldn't find what you were looking for! :boom:")
+ end
+
+ def authorize
+ message =
+ if @resource
+ ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})."
+ else
+ ":sweat_smile: Couldn't identify you, nor can I autorize you!"
+ end
+
+ ephemeral_response(text: message)
+ end
+
+ def unknown_command(commands)
+ ephemeral_response(text: help_message(trigger))
+ end
+
+ private
+
+ def help_message(trigger)
+ header_with_list("Command not found, these are the commands you can use", full_commands(trigger))
+ end
+
+ def full_commands(trigger)
+ @resource.map { |command| "#{trigger} #{command.help_message}" }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/base.rb b/lib/gitlab/slash_commands/presenters/base.rb
new file mode 100644
index 00000000000..e13808a2720
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/base.rb
@@ -0,0 +1,77 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class Base
+ include Gitlab::Routing
+
+ def initialize(resource = nil)
+ @resource = resource
+ end
+
+ def display_errors
+ message = header_with_list("The action was not successful, because:", @resource.errors.full_messages)
+
+ ephemeral_response(text: message)
+ end
+
+ private
+
+ def header_with_list(header, items)
+ message = [header]
+
+ items.each do |item|
+ message << "- #{item}"
+ end
+
+ message.join("\n")
+ end
+
+ def ephemeral_response(message)
+ response = {
+ response_type: :ephemeral,
+ status: 200
+ }.merge(message)
+
+ format_response(response)
+ end
+
+ def in_channel_response(message)
+ response = {
+ response_type: :in_channel,
+ status: 200
+ }.merge(message)
+
+ format_response(response)
+ end
+
+ def format_response(response)
+ response[:text] = format(response[:text]) if response.key?(:text)
+
+ if response.key?(:attachments)
+ response[:attachments].each do |attachment|
+ attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext]
+ attachment[:text] = format(attachment[:text]) if attachment[:text]
+ end
+ end
+
+ response
+ end
+
+ # Convert Markdown to slacks format
+ def format(string)
+ Slack::Notifier::LinkFormatter.format(string)
+ end
+
+ def resource_url
+ url_for(
+ [
+ @resource.project.namespace.becomes(Namespace),
+ @resource.project,
+ @resource
+ ]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/deploy.rb b/lib/gitlab/slash_commands/presenters/deploy.rb
new file mode 100644
index 00000000000..ebae0f57f9b
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/deploy.rb
@@ -0,0 +1,18 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class Deploy < Presenters::Base
+ def present(from, to)
+ message = "Deployment started from #{from} to #{to}. " \
+ "[Follow its progress](#{resource_url})."
+
+ in_channel_response(text: message)
+ end
+
+ def action_not_found
+ ephemeral_response(text: "Couldn't find a deployment manual action.")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/help.rb b/lib/gitlab/slash_commands/presenters/help.rb
new file mode 100644
index 00000000000..ab855319077
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/help.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class Help < Presenters::Base
+ def present(trigger, text)
+ ephemeral_response(text: help_message(trigger, text))
+ end
+
+ private
+
+ def help_message(trigger, text)
+ return "No commands available :thinking_face:" unless @resource.present?
+
+ if text.start_with?('help')
+ header_with_list("Available commands", full_commands(trigger))
+ else
+ header_with_list("Unknown command, these commands are available", full_commands(trigger))
+ end
+ end
+
+ def full_commands(trigger)
+ @resource.map { |command| "#{trigger} #{command.help_message}" }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_base.rb b/lib/gitlab/slash_commands/presenters/issue_base.rb
new file mode 100644
index 00000000000..341f2aabdd0
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_base.rb
@@ -0,0 +1,43 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ module IssueBase
+ def color(issuable)
+ issuable.open? ? '#38ae67' : '#d22852'
+ end
+
+ def status_text(issuable)
+ issuable.open? ? 'Open' : 'Closed'
+ end
+
+ def project
+ @resource.project
+ end
+
+ def author
+ @resource.author
+ end
+
+ def fields
+ [
+ {
+ title: "Assignee",
+ value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_",
+ short: true
+ },
+ {
+ title: "Milestone",
+ value: @resource.milestone ? @resource.milestone.title : "_None_",
+ short: true
+ },
+ {
+ title: "Labels",
+ value: @resource.labels.any? ? @resource.label_names.join(', ') : "_None_",
+ short: true
+ }
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_new.rb b/lib/gitlab/slash_commands/presenters/issue_new.rb
new file mode 100644
index 00000000000..86490a39cc1
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_new.rb
@@ -0,0 +1,50 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class IssueNew < Presenters::Base
+ include Presenters::IssueBase
+
+ def present
+ in_channel_response(new_issue)
+ end
+
+ private
+
+ def new_issue
+ {
+ attachments: [
+ {
+ title: "#{@resource.title} · #{@resource.to_reference}",
+ title_link: resource_url,
+ author_name: author.name,
+ author_icon: author.avatar_url,
+ fallback: "New issue #{@resource.to_reference}: #{@resource.title}",
+ pretext: pretext,
+ color: color(@resource),
+ fields: fields,
+ mrkdwn_in: [
+ :title,
+ :pretext,
+ :text,
+ :fields
+ ]
+ }
+ ]
+ }
+ end
+
+ def pretext
+ "I created an issue on #{author_profile_link}'s behalf: **#{@resource.to_reference}** in #{project_link}"
+ end
+
+ def project_link
+ "[#{project.name_with_namespace}](#{project.web_url})"
+ end
+
+ def author_profile_link
+ "[#{author.to_reference}](#{url_for(author)})"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_search.rb b/lib/gitlab/slash_commands/presenters/issue_search.rb
new file mode 100644
index 00000000000..4e27d668685
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_search.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class IssueSearch < Presenters::Base
+ include Presenters::IssueBase
+
+ def present
+ text = if @resource.count >= 5
+ "Here are the first 5 issues I found:"
+ elsif @resource.one?
+ "Here is the only issue I found:"
+ else
+ "Here are the #{@resource.count} issues I found:"
+ end
+
+ ephemeral_response(text: text, attachments: attachments)
+ end
+
+ private
+
+ def attachments
+ @resource.map do |issue|
+ url = "[#{issue.to_reference}](#{url_for([namespace, project, issue])})"
+
+ {
+ color: color(issue),
+ fallback: "#{issue.to_reference} #{issue.title}",
+ text: "#{url} · #{issue.title} (#{status_text(issue)})",
+
+ mrkdwn_in: [
+ :text
+ ]
+ }
+ end
+ end
+
+ def project
+ @project ||= @resource.first.project
+ end
+
+ def namespace
+ @namespace ||= project.namespace.becomes(Namespace)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_show.rb b/lib/gitlab/slash_commands/presenters/issue_show.rb
new file mode 100644
index 00000000000..c99316df667
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_show.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class IssueShow < Presenters::Base
+ include Presenters::IssueBase
+
+ def present
+ if @resource.confidential?
+ ephemeral_response(show_issue)
+ else
+ in_channel_response(show_issue)
+ end
+ end
+
+ private
+
+ def show_issue
+ {
+ attachments: [
+ {
+ title: "#{@resource.title} · #{@resource.to_reference}",
+ title_link: resource_url,
+ author_name: author.name,
+ author_icon: author.avatar_url,
+ fallback: "Issue #{@resource.to_reference}: #{@resource.title}",
+ pretext: pretext,
+ text: text,
+ color: color(@resource),
+ fields: fields,
+ mrkdwn_in: [
+ :pretext,
+ :text,
+ :fields
+ ]
+ }
+ ]
+ }
+ end
+
+ def text
+ message = "**#{status_text(@resource)}**"
+
+ if @resource.upvotes.zero? && @resource.downvotes.zero? && @resource.user_notes_count.zero?
+ return message
+ end
+
+ message << " · "
+ message << ":+1: #{@resource.upvotes} " unless @resource.upvotes.zero?
+ message << ":-1: #{@resource.downvotes} " unless @resource.downvotes.zero?
+ message << ":speech_balloon: #{@resource.user_notes_count}" unless @resource.user_notes_count.zero?
+
+ message
+ end
+
+ def pretext
+ "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/result.rb b/lib/gitlab/slash_commands/result.rb
new file mode 100644
index 00000000000..7021b4b01b2
--- /dev/null
+++ b/lib/gitlab/slash_commands/result.rb
@@ -0,0 +1,5 @@
+module Gitlab
+ module SlashCommands
+ Result = Struct.new(:type, :message)
+ end
+end
diff --git a/lib/gitlab/sql/glob.rb b/lib/gitlab/sql/glob.rb
new file mode 100644
index 00000000000..5e89e12b2b1
--- /dev/null
+++ b/lib/gitlab/sql/glob.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module SQL
+ module Glob
+ extend self
+
+ # Convert a simple glob pattern with wildcard (*) to SQL LIKE pattern
+ # with SQL expression
+ def to_like(pattern)
+ <<~SQL
+ REPLACE(REPLACE(REPLACE(#{pattern},
+ #{q('%')}, #{q('\\%')}),
+ #{q('_')}, #{q('\\_')}),
+ #{q('*')}, #{q('%')})
+ SQL
+ end
+
+ def q(string)
+ ActiveRecord::Base.connection.quote(string)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/sql/recursive_cte.rb b/lib/gitlab/sql/recursive_cte.rb
new file mode 100644
index 00000000000..16ec002f139
--- /dev/null
+++ b/lib/gitlab/sql/recursive_cte.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ module SQL
+ # Class for easily building recursive CTE statements.
+ #
+ # Example:
+ #
+ # cte = RecursiveCTE.new(:my_cte_name)
+ # ns = Arel::Table.new(:namespaces)
+ #
+ # cte << Namespace.
+ # where(ns[:parent_id].eq(some_namespace_id))
+ #
+ # cte << Namespace.
+ # from([ns, cte.table]).
+ # where(ns[:parent_id].eq(cte.table[:id]))
+ #
+ # Namespace.with.
+ # recursive(cte.to_arel).
+ # from(cte.alias_to(ns))
+ class RecursiveCTE
+ attr_reader :table
+
+ # name - The name of the CTE as a String or Symbol.
+ def initialize(name)
+ @table = Arel::Table.new(name)
+ @queries = []
+ end
+
+ # Adds a query to the body of the CTE.
+ #
+ # relation - The relation object to add to the body of the CTE.
+ def <<(relation)
+ @queries << relation
+ end
+
+ # Returns the Arel relation for this CTE.
+ def to_arel
+ sql = Arel::Nodes::SqlLiteral.new(Union.new(@queries).to_sql)
+
+ Arel::Nodes::As.new(table, Arel::Nodes::Grouping.new(sql))
+ end
+
+ # Returns an "AS" statement that aliases the CTE name as the given table
+ # name. This allows one to trick ActiveRecord into thinking it's selecting
+ # from an actual table, when in reality it's selecting from a CTE.
+ #
+ # alias_table - The Arel table to use as the alias.
+ def alias_to(alias_table)
+ Arel::Nodes::As.new(table, alias_table)
+ end
+
+ # Applies the CTE to the given relation, returning a new one that will
+ # query from it.
+ def apply_to(relation)
+ relation.except(:where)
+ .with
+ .recursive(to_arel)
+ .from(alias_to(relation.model.arel_table))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/string_range_marker.rb b/lib/gitlab/string_range_marker.rb
new file mode 100644
index 00000000000..94fba0a221a
--- /dev/null
+++ b/lib/gitlab/string_range_marker.rb
@@ -0,0 +1,102 @@
+module Gitlab
+ class StringRangeMarker
+ attr_accessor :raw_line, :rich_line
+
+ def initialize(raw_line, rich_line = raw_line)
+ @raw_line = raw_line
+ @rich_line = ERB::Util.html_escape(rich_line)
+ end
+
+ def mark(marker_ranges)
+ return rich_line unless marker_ranges
+
+ rich_marker_ranges = []
+ marker_ranges.each do |range|
+ # Map the inline-diff range based on the raw line to character positions in the rich line
+ rich_positions = position_mapping[range].flatten
+ # Turn the array of character positions into ranges
+ rich_marker_ranges.concat(collapse_ranges(rich_positions))
+ end
+
+ offset = 0
+ # Mark each range
+ rich_marker_ranges.each_with_index do |range, i|
+ offset_range = (range.begin + offset)..(range.end + offset)
+ original_text = rich_line[offset_range]
+
+ text = yield(original_text, left: i == 0, right: i == rich_marker_ranges.length - 1)
+
+ rich_line[offset_range] = text
+
+ offset += text.length - original_text.length
+ end
+
+ rich_line.html_safe
+ end
+
+ private
+
+ # Mapping of character positions in the raw line, to the rich (highlighted) line
+ def position_mapping
+ @position_mapping ||= begin
+ mapping = []
+ rich_pos = 0
+ (0..raw_line.length).each do |raw_pos|
+ rich_char = rich_line[rich_pos]
+
+ # The raw and rich lines are the same except for HTML tags,
+ # so skip over any `<...>` segment
+ while rich_char == '<'
+ until rich_char == '>'
+ rich_pos += 1
+ rich_char = rich_line[rich_pos]
+ end
+
+ rich_pos += 1
+ rich_char = rich_line[rich_pos]
+ end
+
+ # multi-char HTML entities in the rich line correspond to a single character in the raw line
+ if rich_char == '&'
+ multichar_mapping = [rich_pos]
+ until rich_char == ';'
+ rich_pos += 1
+ multichar_mapping << rich_pos
+ rich_char = rich_line[rich_pos]
+ end
+
+ mapping[raw_pos] = multichar_mapping
+ else
+ mapping[raw_pos] = rich_pos
+ end
+
+ rich_pos += 1
+ end
+
+ mapping
+ end
+ end
+
+ # Takes an array of integers, and returns an array of ranges covering the same integers
+ def collapse_ranges(positions)
+ return [] if positions.empty?
+ ranges = []
+
+ start = prev = positions[0]
+ range = start..prev
+ positions[1..-1].each do |pos|
+ if pos == prev + 1
+ range = start..pos
+ prev = pos
+ else
+ ranges << range
+ start = prev = pos
+ range = start..prev
+ end
+ end
+ ranges << range
+
+ ranges
+ end
+ end
+end
diff --git a/lib/gitlab/string_regex_marker.rb b/lib/gitlab/string_regex_marker.rb
new file mode 100644
index 00000000000..7ebf1c0428c
--- /dev/null
+++ b/lib/gitlab/string_regex_marker.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ class StringRegexMarker < StringRangeMarker
+ def mark(regex, group: 0, &block)
+ regex_match = raw_line.match(regex)
+ return rich_line unless regex_match
+
+ begin_index, end_index = regex_match.offset(group)
+ name_range = begin_index..(end_index - 1)
+
+ super([name_range], &block)
+ end
+ end
+end
diff --git a/lib/gitlab/untrusted_regexp.rb b/lib/gitlab/untrusted_regexp.rb
new file mode 100644
index 00000000000..7ce2e9d636e
--- /dev/null
+++ b/lib/gitlab/untrusted_regexp.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ # An untrusted regular expression is any regexp containing patterns sourced
+ # from user input.
+ #
+ # Ruby's built-in regular expression library allows patterns which complete in
+ # exponential time, permitting denial-of-service attacks.
+ #
+ # Not all regular expression features are available in untrusted regexes, and
+ # there is a strict limit on total execution time. See the RE2 documentation
+ # at https://github.com/google/re2/wiki/Syntax for more details.
+ class UntrustedRegexp
+ delegate :===, to: :regexp
+
+ def initialize(pattern)
+ @regexp = RE2::Regexp.new(pattern, log_errors: false)
+
+ raise RegexpError.new(regexp.error) unless regexp.ok?
+ end
+
+ def replace_all(text, rewrite)
+ RE2.GlobalReplace(text, regexp, rewrite)
+ end
+
+ def scan(text)
+ matches = scan_regexp.scan(text).to_a
+ matches.map!(&:first) if regexp.number_of_capturing_groups.zero?
+ matches
+ end
+
+ def replace(text, rewrite)
+ RE2.Replace(text, regexp, rewrite)
+ end
+
+ private
+
+ attr_reader :regexp
+
+ # RE2 scan operates differently to Ruby scan when there are no capture
+ # groups, so work around it
+ def scan_regexp
+ @scan_regexp ||=
+ if regexp.number_of_capturing_groups.zero?
+ RE2::Regexp.new('(' + regexp.source + ')')
+ else
+ regexp
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/uploads_transfer.rb b/lib/gitlab/uploads_transfer.rb
index 7d0c47c5361..b5f41240529 100644
--- a/lib/gitlab/uploads_transfer.rb
+++ b/lib/gitlab/uploads_transfer.rb
@@ -1,7 +1,7 @@
module Gitlab
class UploadsTransfer < ProjectTransfer
def root_dir
- File.join(CarrierWave.root, GitlabUploader.base_dir)
+ File.join(CarrierWave.root, FileUploader.base_dir)
end
end
end
diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb
index 7e14a566696..fee1a127fd7 100644
--- a/lib/gitlab/url_blocker.rb
+++ b/lib/gitlab/url_blocker.rb
@@ -19,6 +19,8 @@ module Gitlab
return false if internal?(uri)
return true if blocked_port?(uri.port)
+ return true if blocked_user_or_hostname?(uri.user)
+ return true if blocked_user_or_hostname?(uri.hostname)
server_ips = Resolv.getaddresses(uri.hostname)
return true if (blocked_ips & server_ips).any?
@@ -37,6 +39,12 @@ module Gitlab
port < 1024 && !VALID_PORTS.include?(port)
end
+ def blocked_user_or_hostname?(value)
+ return false if value.blank?
+
+ value !~ /\A\p{Alnum}/
+ end
+
def internal?(uri)
internal_web?(uri) || internal_shell?(uri)
end
diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb
index ccb456bcc94..824e2d7251f 100644
--- a/lib/gitlab/url_builder.rb
+++ b/lib/gitlab/url_builder.rb
@@ -1,6 +1,6 @@
module Gitlab
class UrlBuilder
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
include GitlabRoutingHelper
include ActionView::RecordIdentifier
@@ -23,9 +23,9 @@ module Gitlab
when WikiPage
wiki_page_url
when ProjectSnippet
- project_snippet_url(object)
+ project_snippet_url(object.project, object)
when Snippet
- personal_snippet_url(object)
+ snippet_url(object)
else
raise NotImplementedError.new("No URL builder defined for #{object.class}")
end
@@ -52,21 +52,24 @@ module Gitlab
commit_url(id: object.commit_id, anchor: dom_id(object))
elsif object.for_issue?
- issue = Issue.find(object.noteable_id)
- issue_url(issue, anchor: dom_id(object))
+ issue_url(object.noteable, anchor: dom_id(object))
elsif object.for_merge_request?
- merge_request = MergeRequest.find(object.noteable_id)
- merge_request_url(merge_request, anchor: dom_id(object))
+ merge_request_url(object.noteable, anchor: dom_id(object))
elsif object.for_snippet?
- snippet = Snippet.find(object.noteable_id)
- project_snippet_url(snippet, anchor: dom_id(object))
+ snippet = object.noteable
+
+ if snippet.is_a?(PersonalSnippet)
+ snippet_url(snippet, anchor: dom_id(object))
+ else
+ project_snippet_url(snippet.project, snippet, anchor: dom_id(object))
+ end
end
end
def wiki_page_url
- namespace_project_wiki_url(object.wiki.project.namespace, object.wiki.project, object.slug)
+ project_wiki_url(object.wiki.project, object.slug)
end
end
end
diff --git a/lib/gitlab/url_sanitizer.rb b/lib/gitlab/url_sanitizer.rb
index 9ce13feb79a..c81dc7e30d0 100644
--- a/lib/gitlab/url_sanitizer.rb
+++ b/lib/gitlab/url_sanitizer.rb
@@ -18,12 +18,6 @@ module Gitlab
false
end
- def self.http_credentials_for_user(user)
- return {} unless user.respond_to?(:username)
-
- { user: user.username }
- end
-
def initialize(url, credentials: nil)
@url = Addressable::URI.parse(url.strip)
@credentials = credentials
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 6aca6db3123..748e0a29184 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -20,12 +20,15 @@ module Gitlab
counts: {
boards: Board.count,
ci_builds: ::Ci::Build.count,
- ci_pipelines: ::Ci::Pipeline.count,
+ ci_internal_pipelines: ::Ci::Pipeline.internal.count,
+ ci_external_pipelines: ::Ci::Pipeline.external.count,
ci_runners: ::Ci::Runner.count,
ci_triggers: ::Ci::Trigger.count,
+ ci_pipeline_schedules: ::Ci::PipelineSchedule.count,
deploy_keys: DeployKey.count,
deployments: Deployment.count,
- environments: Environment.count,
+ environments: ::Environment.count,
+ in_review_folder: ::Environment.in_review_folder.count,
groups: Group.count,
issues: Issue.count,
keys: Key.count,
@@ -36,21 +39,21 @@ module Gitlab
notes: Note.count,
pages_domains: PagesDomain.count,
projects: Project.count,
- projects_prometheus_active: PrometheusService.active.count,
+ projects_imported_from_github: Project.where(import_type: 'github').count,
protected_branches: ProtectedBranch.count,
releases: Release.count,
- services: Service.where(active: true).count,
snippets: Snippet.count,
todos: Todo.count,
uploads: Upload.count,
web_hooks: WebHook.count
- }
+ }.merge(services_usage)
}
end
def license_usage_data
usage_data = {
uuid: current_application_settings.uuid,
+ hostname: Gitlab.config.gitlab.host,
version: Gitlab::VERSION,
active_user_count: User.active.count,
recorded_at: Time.now,
@@ -60,6 +63,18 @@ module Gitlab
usage_data
end
+
+ def services_usage
+ types = {
+ JiraService: :projects_jira_active,
+ SlackService: :projects_slack_notifications_active,
+ SlackSlashCommandsService: :projects_slack_slash_active,
+ PrometheusService: :projects_prometheus_active
+ }
+
+ results = Service.unscoped.where(type: types.keys, active: true).group(:type).count
+ results.each_with_object({}) { |(key, value), response| response[types[key.to_sym]] = value }
+ end
end
end
end
diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb
index 54728e5ff0e..d9a5af09f08 100644
--- a/lib/gitlab/user_access.rb
+++ b/lib/gitlab/user_access.rb
@@ -1,5 +1,11 @@
module Gitlab
class UserAccess
+ extend Gitlab::Cache::RequestCache
+
+ request_cache_key do
+ [user&.id, project&.id]
+ end
+
attr_reader :user, :project
def initialize(user, project: nil)
@@ -28,35 +34,47 @@ module Gitlab
true
end
- def can_create_tag?(ref)
+ request_cache 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)
+ if protected?(ProtectedTag, project, ref)
+ protected_tag_accessible_to?(ref, action: :create)
else
user.can?(:push_code, project)
end
end
- def can_push_to_branch?(ref)
+ request_cache def can_delete_branch?(ref)
return false unless can_access_git?
- if ProtectedBranch.protected?(project, ref)
- return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user)
+ if protected?(ProtectedBranch, project, ref)
+ user.can?(:delete_protected_branch, project)
+ else
+ user.can?(:push_code, project)
+ end
+ end
- has_access = project.protected_branches.protected_ref_accessible_to?(ref, user, action: :push)
+ def can_update_branch?(ref)
+ can_push_to_branch?(ref) || can_merge_to_branch?(ref)
+ end
- has_access || !project.repository.branch_exists?(ref) && can_merge_to_branch?(ref)
+ request_cache def can_push_to_branch?(ref)
+ return false unless can_access_git?
+
+ if protected?(ProtectedBranch, project, ref)
+ return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user)
+
+ protected_branch_accessible_to?(ref, action: :push)
else
user.can?(:push_code, project)
end
end
- def can_merge_to_branch?(ref)
+ request_cache def can_merge_to_branch?(ref)
return false unless can_access_git?
- if ProtectedBranch.protected?(project, ref)
- project.protected_branches.protected_ref_accessible_to?(ref, user, action: :merge)
+ if protected?(ProtectedBranch, project, ref)
+ protected_branch_accessible_to?(ref, action: :merge)
else
user.can?(:push_code, project)
end
@@ -73,5 +91,23 @@ module Gitlab
def can_access_git?
user && user.can?(:access_git)
end
+
+ def protected_branch_accessible_to?(ref, action:)
+ ProtectedBranch.protected_ref_accessible_to?(
+ ref, user,
+ action: action,
+ protected_refs: project.protected_branches)
+ end
+
+ def protected_tag_accessible_to?(ref, action:)
+ ProtectedTag.protected_ref_accessible_to?(
+ ref, user,
+ action: action,
+ protected_refs: project.protected_tags)
+ end
+
+ request_cache def protected?(kind, project, ref)
+ kind.protected?(project, ref)
+ end
end
end
diff --git a/lib/gitlab/user_activities.rb b/lib/gitlab/user_activities.rb
index eb36ab9fded..125488536e1 100644
--- a/lib/gitlab/user_activities.rb
+++ b/lib/gitlab/user_activities.rb
@@ -6,13 +6,13 @@ module Gitlab
BATCH_SIZE = 500
def self.record(key, time = Time.now)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.hset(KEY, key, time.to_i)
end
end
def delete(*keys)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.hdel(KEY, keys)
end
end
@@ -21,7 +21,7 @@ module Gitlab
cursor = 0
loop do
cursor, pairs =
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.hscan(KEY, cursor, count: BATCH_SIZE)
end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index 4c395b4266e..9670c93759e 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -14,6 +14,19 @@ module Gitlab
str.force_encoding(Encoding::UTF_8)
end
+ # A slugified version of the string, suitable for inclusion in URLs and
+ # domain names. Rules:
+ #
+ # * Lowercased
+ # * Anything not matching [a-z0-9-] is replaced with a -
+ # * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
+ def slugify(str)
+ return str.downcase
+ .gsub(/[^a-z0-9]/, '-')[0..62]
+ .gsub(/(\A-+|-+\z)/, '')
+ end
+
def to_boolean(value)
return value if [true, false].include?(value)
return true if value =~ /^(true|t|yes|y|1|on)$/i
@@ -21,5 +34,13 @@ module Gitlab
nil
end
+
+ def boolean_to_yes_no(bool)
+ if bool
+ 'Yes'
+ else
+ 'No'
+ end
+ end
end
end
diff --git a/lib/gitlab/view/presenter/base.rb b/lib/gitlab/view/presenter/base.rb
index dbfe0941e4d..841fb681435 100644
--- a/lib/gitlab/view/presenter/base.rb
+++ b/lib/gitlab/view/presenter/base.rb
@@ -15,6 +15,11 @@ module Gitlab
super(user, action, overriden_subject || subject)
end
+ # delegate all #can? queries to the subject
+ def declarative_policy_delegate
+ subject
+ end
+
class_methods do
def presenter?
true
diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb
index 2e31f4462f9..c60bd91ea6e 100644
--- a/lib/gitlab/visibility_level.rb
+++ b/lib/gitlab/visibility_level.rb
@@ -13,18 +13,8 @@ module Gitlab
scope :public_and_internal_only, -> { where(visibility_level: [PUBLIC, INTERNAL] ) }
scope :non_public_only, -> { where.not(visibility_level: PUBLIC) }
- scope :public_to_user, -> (user) do
- if user
- if user.admin?
- all
- elsif !user.external?
- public_and_internal_only
- else
- public_only
- end
- else
- public_only
- end
+ scope :public_to_user, -> (user = nil) do
+ where(visibility_level: VisibilityLevel.levels_for_user(user))
end
end
@@ -35,15 +25,27 @@ module Gitlab
class << self
delegate :values, to: :options
+ def levels_for_user(user = nil)
+ return [PUBLIC] unless user
+
+ if user.full_private_access?
+ [PRIVATE, INTERNAL, PUBLIC]
+ elsif user.external?
+ [PUBLIC]
+ else
+ [INTERNAL, PUBLIC]
+ end
+ end
+
def string_values
string_options.keys
end
def options
{
- 'Private' => PRIVATE,
- 'Internal' => INTERNAL,
- 'Public' => PUBLIC
+ N_('VisibilityLevel|Private') => PRIVATE,
+ N_('VisibilityLevel|Internal') => INTERNAL,
+ N_('VisibilityLevel|Public') => PUBLIC
}
end
@@ -83,16 +85,16 @@ module Gitlab
end
def valid_level?(level)
- options.has_value?(level)
+ options.value?(level)
end
def level_name(level)
- level_name = 'Unknown'
+ level_name = N_('VisibilityLevel|Unknown')
options.each do |name, lvl|
level_name = name if lvl == level.to_i
end
- level_name
+ s_(level_name)
end
def level_value(level)
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index c551f939df1..a362a3a0bc6 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -16,30 +16,36 @@ module Gitlab
SECRET_LENGTH = 32
class << self
- def git_http_ok(repository, user, action)
+ def git_http_ok(repository, is_wiki, user, action)
+ project = repository.project
repo_path = repository.path_to_repo
params = {
GL_ID: Gitlab::GlId.gl_id(user),
- RepoPath: repo_path,
+ GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki),
+ RepoPath: repo_path
}
- if Gitlab.config.gitaly.enabled
- 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'
- # Disabled for now, see https://gitlab.com/gitlab-org/gitaly/issues/172
- false
- when 'git_upload_pack'
- Gitlab::GitalyClient.feature_enabled?(:post_upload_pack)
- when 'info_refs'
- true
- else
- raise "Unsupported action: #{action}"
- end
-
- params[:GitalyAddress] = address if feature_enabled
+ server = {
+ address: Gitlab::GitalyClient.address(project.repository_storage),
+ token: Gitlab::GitalyClient.token(project.repository_storage)
+ }
+ params[:Repository] = repository.gitaly_repository.to_h
+
+ feature_enabled = case action.to_s
+ when 'git_receive_pack'
+ Gitlab::GitalyClient.feature_enabled?(:post_receive_pack)
+ when 'git_upload_pack'
+ Gitlab::GitalyClient.feature_enabled?(
+ :post_upload_pack,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT
+ )
+ when 'info_refs'
+ true
+ else
+ raise "Unsupported action: #{action}"
+ end
+ if feature_enabled
+ params[:GitalyServer] = server
end
params
@@ -49,7 +55,7 @@ module Gitlab
{
StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload",
LfsOid: oid,
- LfsSize: size,
+ LfsSize: size
}
end
@@ -58,10 +64,21 @@ module Gitlab
end
def send_git_blob(repository, blob)
- params = {
- 'RepoPath' => repository.path_to_repo,
- 'BlobId' => blob.id,
- }
+ params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_raw_show)
+ {
+ 'GitalyServer' => gitaly_server_hash(repository),
+ 'GetBlobRequest' => {
+ repository: repository.gitaly_repository.to_h,
+ oid: blob.id,
+ limit: -1
+ }
+ }
+ else
+ {
+ 'RepoPath' => repository.path_to_repo,
+ 'BlobId' => blob.id
+ }
+ end
[
SEND_DATA_HEADER,
@@ -125,10 +142,10 @@ module Gitlab
'Subprotocols' => terminal[:subprotocols],
'Url' => terminal[:url],
'Header' => terminal[:headers],
- 'MaxSessionTime' => terminal[:max_session_time],
+ 'MaxSessionTime' => terminal[:max_session_time]
}
}
- details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.has_key?(:ca_pem)
+ details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.key?(:ca_pem)
details
end
@@ -163,7 +180,7 @@ module Gitlab
encoded_message,
secret,
true,
- { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' },
+ { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' }
)
end
@@ -172,7 +189,7 @@ module Gitlab
end
def set_key_and_notify(key, value, expire: nil, overwrite: true)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Queues.with do |redis|
result = redis.set(key, value, ex: expire, nx: !overwrite)
if result
redis.publish(NOTIFICATION_CHANNEL, "#{key}=#{value}")
@@ -188,6 +205,13 @@ module Gitlab
def encode(hash)
Base64.urlsafe_encode64(JSON.dump(hash))
end
+
+ def gitaly_server_hash(repository)
+ {
+ address: Gitlab::GitalyClient.address(repository.project.repository_storage),
+ token: Gitlab::GitalyClient.token(repository.project.repository_storage)
+ }
+ end
end
end
end
diff --git a/lib/haml_lint/inline_javascript.rb b/lib/haml_lint/inline_javascript.rb
new file mode 100644
index 00000000000..05668c69006
--- /dev/null
+++ b/lib/haml_lint/inline_javascript.rb
@@ -0,0 +1,16 @@
+unless Rails.env.production?
+ require 'haml_lint/haml_visitor'
+ require 'haml_lint/linter'
+ require 'haml_lint/linter_registry'
+
+ module HamlLint
+ class Linter::InlineJavaScript < Linter
+ include LinterRegistry
+
+ def visit_filter(node)
+ return unless node.filter_type == 'javascript'
+ record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)')
+ end
+ end
+ end
+end
diff --git a/lib/mattermost/client.rb b/lib/mattermost/client.rb
index 3d60618006c..d80cd7d2a4e 100644
--- a/lib/mattermost/client.rb
+++ b/lib/mattermost/client.rb
@@ -24,6 +24,10 @@ module Mattermost
json_response session.post(path, options)
end
+ def delete(session, path, options)
+ json_response session.delete(path, options)
+ end
+
def session_get(path, options = {})
with_session do |session|
get(session, path, options)
@@ -36,6 +40,12 @@ module Mattermost
end
end
+ def session_delete(path, options = {})
+ with_session do |session|
+ delete(session, path, options)
+ end
+ end
+
def json_response(response)
json_response = JSON.parse(response.body)
diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb
index 688a79c0441..ef08bd46e17 100644
--- a/lib/mattermost/session.rb
+++ b/lib/mattermost/session.rb
@@ -36,11 +36,12 @@ module Mattermost
def with_session
with_lease do
- raise Mattermost::NoSessionError unless create
+ create
begin
yield self
- rescue Errno::ECONNREFUSED
+ rescue Errno::ECONNREFUSED => e
+ Rails.logger.error(e.message + "\n" + e.backtrace.join("\n"))
raise Mattermost::NoSessionError
ensure
destroy
@@ -85,10 +86,12 @@ module Mattermost
private
def create
- return unless oauth_uri
- return unless token_uri
+ raise Mattermost::NoSessionError unless oauth_uri
+ raise Mattermost::NoSessionError unless token_uri
@token = request_token
+ raise Mattermost::NoSessionError unless @token
+
@headers = {
Authorization: "Bearer #{@token}"
}
@@ -106,11 +109,16 @@ module Mattermost
@oauth_uri = nil
response = get("/api/v3/oauth/gitlab/login", follow_redirects: false)
- return unless 300 <= response.code && response.code < 400
+ return unless (300...400) === response.code
redirect_uri = response.headers['location']
return unless redirect_uri
+ oauth_cookie = parse_cookie(response)
+ @headers = {
+ Cookie: oauth_cookie.to_cookie_string
+ }
+
@oauth_uri = URI.parse(redirect_uri)
end
@@ -124,7 +132,7 @@ module Mattermost
def request_token
response = get(token_uri, follow_redirects: false)
- if 200 <= response.code && response.code < 400
+ if (200...400) === response.code
response.headers['token']
end
end
@@ -156,5 +164,11 @@ module Mattermost
rescue Errno::ECONNREFUSED => e
raise Mattermost::ConnectionError.new(e.message)
end
+
+ def parse_cookie(response)
+ cookie_hash = CookieHash.new
+ response.get_fields('Set-Cookie').each { |c| cookie_hash.add_cookies(c) }
+ cookie_hash
+ end
end
end
diff --git a/lib/mattermost/team.rb b/lib/mattermost/team.rb
index 2cdbbdece16..b2511f3af1d 100644
--- a/lib/mattermost/team.rb
+++ b/lib/mattermost/team.rb
@@ -14,5 +14,12 @@ module Mattermost
type: type
}.to_json)
end
+
+ # The deletion is done async, so the response is fast.
+ # On the mattermost side, this triggers an soft deletion first, after which
+ # the actuall data is removed
+ def destroy(team_id:)
+ session_delete("/api/v4/teams/#{team_id}?permanent=true")
+ end
end
end
diff --git a/lib/omni_auth/request_forgery_protection.rb b/lib/omni_auth/request_forgery_protection.rb
deleted file mode 100644
index 69155131d8d..00000000000
--- a/lib/omni_auth/request_forgery_protection.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# Protects OmniAuth request phase against CSRF.
-
-module OmniAuth
- module RequestForgeryProtection
- class Controller < ActionController::Base
- protect_from_forgery with: :exception
-
- def index
- head :ok
- end
- end
-
- def self.app
- @app ||= Controller.action(:index)
- end
-
- def self.call(env)
- app.call(env)
- end
- end
-end
diff --git a/lib/peek/rblineprof/custom_controller_helpers.rb b/lib/peek/rblineprof/custom_controller_helpers.rb
new file mode 100644
index 00000000000..7cfe76b7b71
--- /dev/null
+++ b/lib/peek/rblineprof/custom_controller_helpers.rb
@@ -0,0 +1,122 @@
+module Peek
+ module Rblineprof
+ module CustomControllerHelpers
+ extend ActiveSupport::Concern
+
+ # This will become useless once https://github.com/peek/peek-rblineprof/pull/5
+ # is merged
+ def pygmentize(file_name, code, lexer = nil)
+ if lexer.present?
+ Gitlab::Highlight.highlight(file_name, code)
+ else
+ "<pre>#{Rack::Utils.escape_html(code)}</pre>"
+ end
+ end
+
+ # rubocop:disable all
+ def inject_rblineprof
+ ret = nil
+ profile = lineprof(rblineprof_profiler_regex) do
+ ret = yield
+ end
+
+ if response.content_type =~ %r|text/html|
+ sort = params[:lineprofiler_sort]
+ mode = params[:lineprofiler_mode] || 'cpu'
+ min = (params[:lineprofiler_min] || 5).to_i * 1000
+ summary = params[:lineprofiler_summary]
+
+ # Sort each file by the longest calculated time
+ per_file = profile.map do |file, lines|
+ total, child, excl, total_cpu, child_cpu, excl_cpu = lines[0]
+
+ wall = summary == 'exclusive' ? excl : total
+ cpu = summary == 'exclusive' ? excl_cpu : total_cpu
+ idle = summary == 'exclusive' ? (excl - excl_cpu) : (total - total_cpu)
+
+ [
+ file, lines,
+ wall, cpu, idle,
+ sort == 'idle' ? idle : sort == 'cpu' ? cpu : wall
+ ]
+ end.sort_by{ |a,b,c,d,e,f| -f }
+
+ output = "<div class='modal-dialog modal-full'><div class='modal-content'>"
+ output << "<div class='modal-header'>"
+ output << "<button class='close btn btn-link btn-sm' type='button' data-dismiss='modal'>X</button>"
+ output << "<h4>Line profiling: #{human_description(params[:lineprofiler])}</h4>"
+ output << "</div>"
+ output << "<div class='modal-body'>"
+
+ per_file.each do |file_name, lines, file_wall, file_cpu, file_idle, file_sort|
+ output << "<div class='peek-rblineprof-file'><div class='heading'>"
+
+ show_src = file_sort > min
+ tmpl = show_src ? "<a href='#' class='js-lineprof-file'>%s</a>" : "%s"
+
+ if mode == 'cpu'
+ output << sprintf("<span class='duration'>% 8.1fms + % 8.1fms</span> #{tmpl}", file_cpu / 1000.0, file_idle / 1000.0, file_name.sub(Rails.root.to_s + '/', ''))
+ else
+ output << sprintf("<span class='duration'>% 8.1fms</span> #{tmpl}", file_wall/1000.0, file_name.sub(Rails.root.to_s + '/', ''))
+ end
+
+ output << "</div>" # .heading
+
+ next unless show_src
+
+ output << "<div class='data'>"
+ code = []
+ times = []
+ File.readlines(file_name).each_with_index do |line, i|
+ code << line
+ wall, cpu, calls = lines[i + 1]
+
+ if calls && calls > 0
+ if mode == 'cpu'
+ idle = wall - cpu
+ times << sprintf("% 8.1fms + % 8.1fms (% 5d)", cpu / 1000.0, idle / 1000.0, calls)
+ else
+ times << sprintf("% 8.1fms (% 5d)", wall / 1000.0, calls)
+ end
+ else
+ times << ' '
+ end
+ end
+ output << "<pre class='duration'>#{times.join("\n")}</pre>"
+ # The following line was changed from
+ # https://github.com/peek/peek-rblineprof/blob/8d3b7a283a27de2f40abda45974516693d882258/lib/peek/rblineprof/controller_helpers.rb#L125
+ # This will become useless once https://github.com/peek/peek-rblineprof/pull/16
+ # is merged and is implemented.
+ output << "<pre class='code highlight white'>#{pygmentize(file_name, code.join, 'ruby')}</pre>"
+ output << "</div></div>" # .data then .peek-rblineprof-file
+ end
+
+ output << "</div></div></div>"
+
+ response.body += "<div class='modal' id='modal-peek-line-profile' tabindex=-1>#{output}</div>".html_safe
+ end
+
+ ret
+ end
+
+ private
+
+ def human_description(lineprofiler_param)
+ case lineprofiler_param
+ when 'app'
+ 'app/ & lib/'
+ when 'views'
+ 'app/view/'
+ when 'gems'
+ 'vendor/gems'
+ when 'all'
+ 'everything in Rails.root'
+ when 'stdlib'
+ 'everything in the Ruby standard library'
+ else
+ 'app/, config/, lib/, vendor/ & plugin/'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/rouge/lexers/math.rb b/lib/rouge/lexers/math.rb
index 80784adfd76..939b23a3421 100644
--- a/lib/rouge/lexers/math.rb
+++ b/lib/rouge/lexers/math.rb
@@ -1,21 +1,9 @@
module Rouge
module Lexers
- class Math < Lexer
+ class Math < PlainText
title "A passthrough lexer used for LaTeX input"
- desc "A boring lexer that doesn't highlight anything"
-
+ desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter"
tag 'math'
- mimetypes 'text/plain'
-
- default_options token: 'Text'
-
- def token
- @token ||= Token[option :token]
- end
-
- def stream_tokens(string, &b)
- yield self.token, string
- end
end
end
end
diff --git a/lib/rouge/lexers/plantuml.rb b/lib/rouge/lexers/plantuml.rb
index 7d5700b7f6d..63c461764fc 100644
--- a/lib/rouge/lexers/plantuml.rb
+++ b/lib/rouge/lexers/plantuml.rb
@@ -1,21 +1,9 @@
module Rouge
module Lexers
- class Plantuml < Lexer
+ class Plantuml < PlainText
title "A passthrough lexer used for PlantUML input"
- desc "A boring lexer that doesn't highlight anything"
-
+ desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter"
tag 'plantuml'
- mimetypes 'text/plain'
-
- default_options token: 'Text'
-
- def token
- @token ||= Token[option :token]
- end
-
- def stream_tokens(string, &b)
- yield self.token, string
- end
end
end
end
diff --git a/lib/rspec_flaky/example.rb b/lib/rspec_flaky/example.rb
new file mode 100644
index 00000000000..b6e790cbbab
--- /dev/null
+++ b/lib/rspec_flaky/example.rb
@@ -0,0 +1,46 @@
+module RspecFlaky
+ # This is a wrapper class for RSpec::Core::Example
+ class Example
+ delegate :status, :exception, to: :execution_result
+
+ def initialize(rspec_example)
+ @rspec_example = rspec_example.try(:example) || rspec_example
+ end
+
+ def uid
+ @uid ||= Digest::MD5.hexdigest("#{description}-#{file}")
+ end
+
+ def example_id
+ rspec_example.id
+ end
+
+ def file
+ metadata[:file_path]
+ end
+
+ def line
+ metadata[:line_number]
+ end
+
+ def description
+ metadata[:full_description]
+ end
+
+ def attempts
+ rspec_example.try(:attempts) || 1
+ end
+
+ private
+
+ attr_reader :rspec_example
+
+ def metadata
+ rspec_example.metadata
+ end
+
+ def execution_result
+ rspec_example.execution_result
+ end
+ end
+end
diff --git a/lib/rspec_flaky/flaky_example.rb b/lib/rspec_flaky/flaky_example.rb
new file mode 100644
index 00000000000..f81fb90e870
--- /dev/null
+++ b/lib/rspec_flaky/flaky_example.rb
@@ -0,0 +1,39 @@
+module RspecFlaky
+ # This represents a flaky RSpec example and is mainly meant to be saved in a JSON file
+ class FlakyExample < OpenStruct
+ def initialize(example)
+ if example.respond_to?(:example_id)
+ super(
+ example_id: example.example_id,
+ file: example.file,
+ line: example.line,
+ description: example.description,
+ last_attempts_count: example.attempts,
+ flaky_reports: 1)
+ else
+ super
+ end
+ end
+
+ def first_flaky_at
+ self[:first_flaky_at] || Time.now
+ end
+
+ def last_flaky_at
+ Time.now
+ end
+
+ def last_flaky_job
+ return unless ENV['CI_PROJECT_URL'] && ENV['CI_JOB_ID']
+
+ "#{ENV['CI_PROJECT_URL']}/-/jobs/#{ENV['CI_JOB_ID']}"
+ end
+
+ def to_h
+ super.merge(
+ first_flaky_at: first_flaky_at,
+ last_flaky_at: last_flaky_at,
+ last_flaky_job: last_flaky_job)
+ end
+ end
+end
diff --git a/lib/rspec_flaky/listener.rb b/lib/rspec_flaky/listener.rb
new file mode 100644
index 00000000000..ec2fbd9e36c
--- /dev/null
+++ b/lib/rspec_flaky/listener.rb
@@ -0,0 +1,75 @@
+require 'json'
+
+module RspecFlaky
+ class Listener
+ attr_reader :all_flaky_examples, :new_flaky_examples
+
+ def initialize
+ @new_flaky_examples = {}
+ @all_flaky_examples = init_all_flaky_examples
+ end
+
+ def example_passed(notification)
+ current_example = RspecFlaky::Example.new(notification.example)
+
+ return unless current_example.attempts > 1
+
+ flaky_example_hash = all_flaky_examples[current_example.uid]
+
+ all_flaky_examples[current_example.uid] =
+ if flaky_example_hash
+ FlakyExample.new(flaky_example_hash).tap do |ex|
+ ex.last_attempts_count = current_example.attempts
+ ex.flaky_reports += 1
+ end
+ else
+ FlakyExample.new(current_example).tap do |ex|
+ new_flaky_examples[current_example.uid] = ex
+ end
+ end
+ end
+
+ def dump_summary(_)
+ write_report_file(all_flaky_examples, all_flaky_examples_report_path)
+
+ if new_flaky_examples.any?
+ Rails.logger.warn "\nNew flaky examples detected:\n"
+ Rails.logger.warn JSON.pretty_generate(to_report(new_flaky_examples))
+
+ write_report_file(new_flaky_examples, new_flaky_examples_report_path)
+ end
+ end
+
+ def to_report(examples)
+ Hash[examples.map { |k, ex| [k, ex.to_h] }]
+ end
+
+ private
+
+ def init_all_flaky_examples
+ return {} unless File.exist?(all_flaky_examples_report_path)
+
+ all_flaky_examples = JSON.parse(File.read(all_flaky_examples_report_path))
+
+ Hash[(all_flaky_examples || {}).map { |k, ex| [k, FlakyExample.new(ex)] }]
+ end
+
+ def write_report_file(examples, file_path)
+ return unless ENV['FLAKY_RSPEC_GENERATE_REPORT'] == 'true'
+
+ report_path_dir = File.dirname(file_path)
+ FileUtils.mkdir_p(report_path_dir) unless Dir.exist?(report_path_dir)
+ File.write(file_path, JSON.pretty_generate(to_report(examples)))
+ end
+
+ def all_flaky_examples_report_path
+ @all_flaky_examples_report_path ||= ENV['ALL_FLAKY_RSPEC_REPORT_PATH'] ||
+ Rails.root.join("rspec_flaky/all-report.json")
+ end
+
+ def new_flaky_examples_report_path
+ @new_flaky_examples_report_path ||= ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] ||
+ Rails.root.join("rspec_flaky/new-report.json")
+ end
+ end
+end
diff --git a/lib/static_model.rb b/lib/static_model.rb
index 185921d8fbe..60e2dd82e4e 100644
--- a/lib/static_model.rb
+++ b/lib/static_model.rb
@@ -18,7 +18,7 @@ module StaticModel
#
# Pass it along if we respond to it.
def [](key)
- send(key) if respond_to?(key)
+ send(key) if respond_to?(key) # rubocop:disable GitlabSecurity/PublicSend
end
def to_param
diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab
index 6e351365de0..2f2de083dc0 100755
--- a/lib/support/init.d/gitlab
+++ b/lib/support/init.d/gitlab
@@ -48,7 +48,7 @@ gitlab_pages_pid_path="$pid_path/gitlab-pages.pid"
gitlab_pages_options="-pages-domain example.com -pages-root $app_root/shared/pages -listen-proxy 127.0.0.1:8090"
gitlab_pages_log="$app_root/log/gitlab-pages.log"
shell_path="/bin/bash"
-gitaly_enabled=false
+gitaly_enabled=true
gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd)
gitaly_pid_path="$pid_path/gitaly.pid"
gitaly_log="$app_root/log/gitaly.log"
@@ -291,7 +291,7 @@ start_gitlab() {
fi
if [ "$gitlab_workhorse_status" = "0" ]; then
- echo "The GitLab Workhorse is already running with pid $spid, not restarting"
+ echo "The GitLab Workhorse is already running with pid $hpid, not restarting"
else
# No need to remove a socket, gitlab-workhorse does this itself.
# Because gitlab-workhorse has multiple executables we need to fix
@@ -313,7 +313,7 @@ start_gitlab() {
if [ "$gitlab_pages_enabled" = true ]; then
if [ "$gitlab_pages_status" = "0" ]; then
- echo "The GitLab Pages is already running with pid $spid, not restarting"
+ echo "The GitLab Pages is already running with pid $gppid, not restarting"
else
$app_root/bin/daemon_with_pidfile $gitlab_pages_pid_path \
$gitlab_pages_dir/gitlab-pages $gitlab_pages_options \
@@ -421,7 +421,7 @@ print_status() {
fi
if [ "$gitlab_pages_enabled" = true ]; then
if [ "$gitlab_pages_status" = "0" ]; then
- echo "The GitLab Pages with pid $mpid is running."
+ echo "The GitLab Pages with pid $gppid is running."
else
printf "The GitLab Pages is \033[31mnot running\033[0m.\n"
fi
diff --git a/lib/support/init.d/gitlab.default.example b/lib/support/init.d/gitlab.default.example
index 9472c3c992f..295c79fccfc 100644
--- a/lib/support/init.d/gitlab.default.example
+++ b/lib/support/init.d/gitlab.default.example
@@ -86,5 +86,7 @@ mail_room_pid_path="$pid_path/mail_room.pid"
shell_path="/bin/bash"
# This variable controls whether the init script starts/stops Gitaly
-gitaly_enabled=false
+gitaly_enabled=true
+gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd)
+gitaly_pid_path="$pid_path/gitaly.pid"
gitaly_log="$app_root/log/gitaly.log"
diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab
index f25e66d54c8..54f51d9d633 100644
--- a/lib/support/nginx/gitlab
+++ b/lib/support/nginx/gitlab
@@ -25,6 +25,39 @@ map $http_upgrade $connection_upgrade_gitlab {
'' close;
}
+## NGINX 'combined' log format with filtered query strings
+log_format gitlab_access $remote_addr - $remote_user [$time_local] "$request_method $gitlab_filtered_request_uri $server_protocol" $status $body_bytes_sent "$gitlab_filtered_http_referer" "$http_user_agent";
+
+## Remove private_token from the request URI
+# In: /foo?private_token=unfiltered&authenticity_token=unfiltered&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=unfiltered&rss_token=unfiltered&...
+map $request_uri $gitlab_temp_request_uri_1 {
+ default $request_uri;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]private[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## Remove authenticity_token from the request URI
+# In: /foo?private_token=[FILTERED]&authenticity_token=unfiltered&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=unfiltered&...
+map $gitlab_temp_request_uri_1 $gitlab_temp_request_uri_2 {
+ default $gitlab_temp_request_uri_1;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]authenticity[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## Remove rss_token from the request URI
+# In: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=[FILTERED]&...
+map $gitlab_temp_request_uri_2 $gitlab_filtered_request_uri {
+ default $gitlab_temp_request_uri_2;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]rss[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## A version of the referer without the query string
+map $http_referer $gitlab_filtered_http_referer {
+ default $http_referer;
+ ~^(?<temp>.*)\? $temp;
+}
+
## Normal HTTP host
server {
## Either remove "default_server" from the listen line below,
@@ -46,7 +79,7 @@ server {
# set_real_ip_from YOUR_TRUSTED_ADDRESS; ## Replace this with something like 192.168.1.0/24
## Individual nginx logs for this GitLab vhost
- access_log /var/log/nginx/gitlab_access.log;
+ access_log /var/log/nginx/gitlab_access.log gitlab_access;
error_log /var/log/nginx/gitlab_error.log;
location / {
diff --git a/lib/support/nginx/gitlab-pages b/lib/support/nginx/gitlab-pages
index d9746c5c1aa..875c8bcbf3c 100644
--- a/lib/support/nginx/gitlab-pages
+++ b/lib/support/nginx/gitlab-pages
@@ -18,8 +18,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_cache off;
+
# The same address as passed to GitLab Pages: `-listen-proxy`
- proxy_pass http://localhost:8090/;
+ proxy_pass http://localhost:8090/;
}
# Define custom error pages
diff --git a/lib/support/nginx/gitlab-pages-ssl b/lib/support/nginx/gitlab-pages-ssl
index a1ccf266835..62ed482e2bf 100644
--- a/lib/support/nginx/gitlab-pages-ssl
+++ b/lib/support/nginx/gitlab-pages-ssl
@@ -67,8 +67,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_cache off;
+
# The same address as passed to GitLab Pages: `-listen-proxy`
- proxy_pass http://localhost:8090/;
+ proxy_pass http://localhost:8090/;
}
# Define custom error pages
diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl
index 2b40da18bab..ed8131ef24f 100644
--- a/lib/support/nginx/gitlab-ssl
+++ b/lib/support/nginx/gitlab-ssl
@@ -29,6 +29,41 @@ map $http_upgrade $connection_upgrade_gitlab_ssl {
'' close;
}
+
+## NGINX 'combined' log format with filtered query strings
+log_format gitlab_ssl_access $remote_addr - $remote_user [$time_local] "$request_method $gitlab_ssl_filtered_request_uri $server_protocol" $status $body_bytes_sent "$gitlab_ssl_filtered_http_referer" "$http_user_agent";
+
+## Remove private_token from the request URI
+# In: /foo?private_token=unfiltered&authenticity_token=unfiltered&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=unfiltered&rss_token=unfiltered&...
+map $request_uri $gitlab_ssl_temp_request_uri_1 {
+ default $request_uri;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]private[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## Remove authenticity_token from the request URI
+# In: /foo?private_token=[FILTERED]&authenticity_token=unfiltered&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=unfiltered&...
+map $gitlab_ssl_temp_request_uri_1 $gitlab_ssl_temp_request_uri_2 {
+ default $gitlab_ssl_temp_request_uri_1;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]authenticity[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## Remove rss_token from the request URI
+# In: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=unfiltered&...
+# Out: /foo?private_token=[FILTERED]&authenticity_token=[FILTERED]&rss_token=[FILTERED]&...
+map $gitlab_ssl_temp_request_uri_2 $gitlab_ssl_filtered_request_uri {
+ default $gitlab_ssl_temp_request_uri_2;
+ ~(?i)^(?<start>.*)(?<temp>[\?&]rss[\-_]token)=[^&]*(?<rest>.*)$ "$start$temp=[FILTERED]$rest";
+}
+
+## A version of the referer without the query string
+map $http_referer $gitlab_ssl_filtered_http_referer {
+ default $http_referer;
+ ~^(?<temp>.*)\? $temp;
+}
+
+
## Redirects all HTTP traffic to the HTTPS host
server {
## Either remove "default_server" from the listen line below,
@@ -40,7 +75,7 @@ server {
server_name YOUR_SERVER_FQDN; ## Replace this with something like gitlab.example.com
server_tokens off; ## Don't show the nginx version number, a security best practice
return 301 https://$http_host$request_uri;
- access_log /var/log/nginx/gitlab_access.log;
+ access_log /var/log/nginx/gitlab_access.log gitlab_ssl_access;
error_log /var/log/nginx/gitlab_error.log;
}
@@ -93,7 +128,7 @@ server {
# set_real_ip_from YOUR_TRUSTED_ADDRESS; ## Replace this with something like 192.168.1.0/24
## Individual nginx logs for this GitLab vhost
- access_log /var/log/nginx/gitlab_access.log;
+ access_log /var/log/nginx/gitlab_access.log gitlab_ssl_access;
error_log /var/log/nginx/gitlab_error.log;
location / {
diff --git a/lib/system_check.rb b/lib/system_check.rb
new file mode 100644
index 00000000000..466c39904fa
--- /dev/null
+++ b/lib/system_check.rb
@@ -0,0 +1,21 @@
+# Library to perform System Checks
+#
+# Every Check is implemented as its own class inherited from SystemCheck::BaseCheck
+# Execution coordination and boilerplate output is done by the SystemCheck::SimpleExecutor
+#
+# This structure decouples checks from Rake tasks and facilitates unit-testing
+module SystemCheck
+ # Executes a bunch of checks for specified component
+ #
+ # @param [String] component name of the component relative to the checks being executed
+ # @param [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order
+ def self.run(component, checks = [])
+ executor = SimpleExecutor.new(component)
+
+ checks.each do |check|
+ executor << check
+ end
+
+ executor.execute
+ end
+end
diff --git a/lib/system_check/app/active_users_check.rb b/lib/system_check/app/active_users_check.rb
new file mode 100644
index 00000000000..1d72c8d6903
--- /dev/null
+++ b/lib/system_check/app/active_users_check.rb
@@ -0,0 +1,17 @@
+module SystemCheck
+ module App
+ class ActiveUsersCheck < SystemCheck::BaseCheck
+ set_name 'Active users:'
+
+ def multi_check
+ active_users = User.active.count
+
+ if active_users > 0
+ $stdout.puts active_users.to_s.color(:green)
+ else
+ $stdout.puts active_users.to_s.color(:red)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/database_config_exists_check.rb b/lib/system_check/app/database_config_exists_check.rb
new file mode 100644
index 00000000000..d1fae192350
--- /dev/null
+++ b/lib/system_check/app/database_config_exists_check.rb
@@ -0,0 +1,25 @@
+module SystemCheck
+ module App
+ class DatabaseConfigExistsCheck < SystemCheck::BaseCheck
+ set_name 'Database config exists?'
+
+ def check?
+ database_config_file = Rails.root.join('config', 'database.yml')
+
+ File.exist?(database_config_file)
+ end
+
+ def show_error
+ try_fixing_it(
+ 'Copy config/database.yml.<your db> to config/database.yml',
+ 'Check that the information in config/database.yml is correct'
+ )
+ for_more_information(
+ 'doc/install/databases.md',
+ 'http://guides.rubyonrails.org/getting_started.html#configuring-a-database'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/git_config_check.rb b/lib/system_check/app/git_config_check.rb
new file mode 100644
index 00000000000..198867f7ac6
--- /dev/null
+++ b/lib/system_check/app/git_config_check.rb
@@ -0,0 +1,42 @@
+module SystemCheck
+ module App
+ class GitConfigCheck < SystemCheck::BaseCheck
+ OPTIONS = {
+ 'core.autocrlf' => 'input'
+ }.freeze
+
+ set_name 'Git configured correctly?'
+
+ def check?
+ correct_options = OPTIONS.map do |name, value|
+ run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value
+ end
+
+ correct_options.all?
+ end
+
+ # Tries to configure git itself
+ #
+ # Returns true if all subcommands were successful (according to their exit code)
+ # Returns false if any or all subcommands failed.
+ def repair!
+ return false unless is_gitlab_user?
+
+ command_success = OPTIONS.map do |name, value|
+ system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value}))
+ end
+
+ command_success.all?
+ end
+
+ def show_error
+ try_fixing_it(
+ sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{OPTIONS['core.autocrlf']}\"")
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/git_version_check.rb b/lib/system_check/app/git_version_check.rb
new file mode 100644
index 00000000000..c388682dfb4
--- /dev/null
+++ b/lib/system_check/app/git_version_check.rb
@@ -0,0 +1,29 @@
+module SystemCheck
+ module App
+ class GitVersionCheck < SystemCheck::BaseCheck
+ set_name -> { "Git version >= #{self.required_version} ?" }
+ set_check_pass -> { "yes (#{self.current_version})" }
+
+ def self.required_version
+ @required_version ||= Gitlab::VersionInfo.new(2, 7, 3)
+ end
+
+ def self.current_version
+ @current_version ||= Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version)))
+ end
+
+ def check?
+ self.class.current_version.valid? && self.class.required_version <= self.class.current_version
+ end
+
+ def show_error
+ $stdout.puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\""
+
+ try_fixing_it(
+ "Update your git to a version >= #{self.class.required_version} from #{self.class.current_version}"
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/gitlab_config_exists_check.rb b/lib/system_check/app/gitlab_config_exists_check.rb
new file mode 100644
index 00000000000..247aa0994e4
--- /dev/null
+++ b/lib/system_check/app/gitlab_config_exists_check.rb
@@ -0,0 +1,24 @@
+module SystemCheck
+ module App
+ class GitlabConfigExistsCheck < SystemCheck::BaseCheck
+ set_name 'GitLab config exists?'
+
+ def check?
+ gitlab_config_file = Rails.root.join('config', 'gitlab.yml')
+
+ File.exist?(gitlab_config_file)
+ end
+
+ def show_error
+ try_fixing_it(
+ 'Copy config/gitlab.yml.example to config/gitlab.yml',
+ 'Update config/gitlab.yml to match your setup'
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/gitlab_config_up_to_date_check.rb b/lib/system_check/app/gitlab_config_up_to_date_check.rb
new file mode 100644
index 00000000000..c609e48e133
--- /dev/null
+++ b/lib/system_check/app/gitlab_config_up_to_date_check.rb
@@ -0,0 +1,30 @@
+module SystemCheck
+ module App
+ class GitlabConfigUpToDateCheck < SystemCheck::BaseCheck
+ set_name 'GitLab config up to date?'
+ set_skip_reason "can't check because of previous errors"
+
+ def skip?
+ gitlab_config_file = Rails.root.join('config', 'gitlab.yml')
+ !File.exist?(gitlab_config_file)
+ end
+
+ def check?
+ # omniauth or ldap could have been deleted from the file
+ !Gitlab.config['git_host']
+ end
+
+ def show_error
+ try_fixing_it(
+ 'Back-up your config/gitlab.yml',
+ 'Copy config/gitlab.yml.example to config/gitlab.yml',
+ 'Update config/gitlab.yml to match your setup'
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/init_script_exists_check.rb b/lib/system_check/app/init_script_exists_check.rb
new file mode 100644
index 00000000000..d246e058e86
--- /dev/null
+++ b/lib/system_check/app/init_script_exists_check.rb
@@ -0,0 +1,27 @@
+module SystemCheck
+ module App
+ class InitScriptExistsCheck < SystemCheck::BaseCheck
+ set_name 'Init script exists?'
+ set_skip_reason 'skipped (omnibus-gitlab has no init script)'
+
+ def skip?
+ omnibus_gitlab?
+ end
+
+ def check?
+ script_path = '/etc/init.d/gitlab'
+ File.exist?(script_path)
+ end
+
+ def show_error
+ try_fixing_it(
+ 'Install the init script'
+ )
+ for_more_information(
+ see_installation_guide_section 'Install Init Script'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/init_script_up_to_date_check.rb b/lib/system_check/app/init_script_up_to_date_check.rb
new file mode 100644
index 00000000000..015c7ed1731
--- /dev/null
+++ b/lib/system_check/app/init_script_up_to_date_check.rb
@@ -0,0 +1,43 @@
+module SystemCheck
+ module App
+ class InitScriptUpToDateCheck < SystemCheck::BaseCheck
+ SCRIPT_PATH = '/etc/init.d/gitlab'.freeze
+
+ set_name 'Init script up-to-date?'
+ set_skip_reason 'skipped (omnibus-gitlab has no init script)'
+
+ def skip?
+ omnibus_gitlab?
+ end
+
+ def multi_check
+ recipe_path = Rails.root.join('lib/support/init.d/', 'gitlab')
+
+ unless File.exist?(SCRIPT_PATH)
+ $stdout.puts "can't check because of previous errors".color(:magenta)
+ return
+ end
+
+ recipe_content = File.read(recipe_path)
+ script_content = File.read(SCRIPT_PATH)
+
+ if recipe_content == script_content
+ $stdout.puts 'yes'.color(:green)
+ else
+ $stdout.puts 'no'.color(:red)
+ show_error
+ end
+ end
+
+ def show_error
+ try_fixing_it(
+ 'Re-download the init script'
+ )
+ for_more_information(
+ see_installation_guide_section 'Install Init Script'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/log_writable_check.rb b/lib/system_check/app/log_writable_check.rb
new file mode 100644
index 00000000000..3e0c436d6ee
--- /dev/null
+++ b/lib/system_check/app/log_writable_check.rb
@@ -0,0 +1,28 @@
+module SystemCheck
+ module App
+ class LogWritableCheck < SystemCheck::BaseCheck
+ set_name 'Log directory writable?'
+
+ def check?
+ File.writable?(log_path)
+ end
+
+ def show_error
+ try_fixing_it(
+ "sudo chown -R gitlab #{log_path}",
+ "sudo chmod -R u+rwX #{log_path}"
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+
+ private
+
+ def log_path
+ Rails.root.join('log')
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/migrations_are_up_check.rb b/lib/system_check/app/migrations_are_up_check.rb
new file mode 100644
index 00000000000..5eedbacce77
--- /dev/null
+++ b/lib/system_check/app/migrations_are_up_check.rb
@@ -0,0 +1,20 @@
+module SystemCheck
+ module App
+ class MigrationsAreUpCheck < SystemCheck::BaseCheck
+ set_name 'All migrations up?'
+
+ def check?
+ migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status))
+
+ migration_status !~ /down\s+\d{14}/
+ end
+
+ def show_error
+ try_fixing_it(
+ sudo_gitlab('bundle exec rake db:migrate RAILS_ENV=production')
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/orphaned_group_members_check.rb b/lib/system_check/app/orphaned_group_members_check.rb
new file mode 100644
index 00000000000..2b46d36fe51
--- /dev/null
+++ b/lib/system_check/app/orphaned_group_members_check.rb
@@ -0,0 +1,20 @@
+module SystemCheck
+ module App
+ class OrphanedGroupMembersCheck < SystemCheck::BaseCheck
+ set_name 'Database contains orphaned GroupMembers?'
+ set_check_pass 'no'
+ set_check_fail 'yes'
+
+ def check?
+ !GroupMember.where('user_id not in (select id from users)').exists?
+ end
+
+ def show_error
+ try_fixing_it(
+ 'You can delete the orphaned records using something along the lines of:',
+ sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'")
+ )
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/projects_have_namespace_check.rb b/lib/system_check/app/projects_have_namespace_check.rb
new file mode 100644
index 00000000000..a6ec9f7665c
--- /dev/null
+++ b/lib/system_check/app/projects_have_namespace_check.rb
@@ -0,0 +1,37 @@
+module SystemCheck
+ module App
+ class ProjectsHaveNamespaceCheck < SystemCheck::BaseCheck
+ set_name 'Projects have namespace:'
+ set_skip_reason "can't check, you have no projects"
+
+ def skip?
+ !Project.exists?
+ end
+
+ def multi_check
+ $stdout.puts ''
+
+ Project.find_each(batch_size: 100) do |project|
+ $stdout.print sanitized_message(project)
+
+ if project.namespace
+ $stdout.puts 'yes'.color(:green)
+ else
+ $stdout.puts 'no'.color(:red)
+ show_error
+ end
+ end
+ end
+
+ def show_error
+ try_fixing_it(
+ "Migrate global projects"
+ )
+ for_more_information(
+ "doc/update/5.4-to-6.0.md in section \"#global-projects\""
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/redis_version_check.rb b/lib/system_check/app/redis_version_check.rb
new file mode 100644
index 00000000000..a0610e73576
--- /dev/null
+++ b/lib/system_check/app/redis_version_check.rb
@@ -0,0 +1,25 @@
+module SystemCheck
+ module App
+ class RedisVersionCheck < SystemCheck::BaseCheck
+ MIN_REDIS_VERSION = '2.8.0'.freeze
+ set_name "Redis version >= #{MIN_REDIS_VERSION}?"
+
+ def check?
+ redis_version = run_command(%w(redis-cli --version))
+ redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/)
+
+ redis_version && (Gem::Version.new(redis_version[1]) > Gem::Version.new(MIN_REDIS_VERSION))
+ end
+
+ def show_error
+ try_fixing_it(
+ "Update your redis server to a version >= #{MIN_REDIS_VERSION}"
+ )
+ for_more_information(
+ 'gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/ruby_version_check.rb b/lib/system_check/app/ruby_version_check.rb
new file mode 100644
index 00000000000..fd82f5f8a4a
--- /dev/null
+++ b/lib/system_check/app/ruby_version_check.rb
@@ -0,0 +1,27 @@
+module SystemCheck
+ module App
+ class RubyVersionCheck < SystemCheck::BaseCheck
+ set_name -> { "Ruby version >= #{self.required_version} ?" }
+ set_check_pass -> { "yes (#{self.current_version})" }
+
+ def self.required_version
+ @required_version ||= Gitlab::VersionInfo.new(2, 3, 3)
+ end
+
+ def self.current_version
+ @current_version ||= Gitlab::VersionInfo.parse(run_command(%w(ruby --version)))
+ end
+
+ def check?
+ self.class.current_version.valid? && self.class.required_version <= self.class.current_version
+ end
+
+ def show_error
+ try_fixing_it(
+ "Update your ruby to a version >= #{self.class.required_version} from #{self.class.current_version}"
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/tmp_writable_check.rb b/lib/system_check/app/tmp_writable_check.rb
new file mode 100644
index 00000000000..99a75e57abf
--- /dev/null
+++ b/lib/system_check/app/tmp_writable_check.rb
@@ -0,0 +1,28 @@
+module SystemCheck
+ module App
+ class TmpWritableCheck < SystemCheck::BaseCheck
+ set_name 'Tmp directory writable?'
+
+ def check?
+ File.writable?(tmp_path)
+ end
+
+ def show_error
+ try_fixing_it(
+ "sudo chown -R gitlab #{tmp_path}",
+ "sudo chmod -R u+rwX #{tmp_path}"
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+
+ private
+
+ def tmp_path
+ Rails.root.join('tmp')
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/uploads_directory_exists_check.rb b/lib/system_check/app/uploads_directory_exists_check.rb
new file mode 100644
index 00000000000..7026d0ba075
--- /dev/null
+++ b/lib/system_check/app/uploads_directory_exists_check.rb
@@ -0,0 +1,21 @@
+module SystemCheck
+ module App
+ class UploadsDirectoryExistsCheck < SystemCheck::BaseCheck
+ set_name 'Uploads directory exists?'
+
+ def check?
+ File.directory?(Rails.root.join('public/uploads'))
+ end
+
+ def show_error
+ try_fixing_it(
+ "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads"
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/uploads_path_permission_check.rb b/lib/system_check/app/uploads_path_permission_check.rb
new file mode 100644
index 00000000000..7df6c060254
--- /dev/null
+++ b/lib/system_check/app/uploads_path_permission_check.rb
@@ -0,0 +1,36 @@
+module SystemCheck
+ module App
+ class UploadsPathPermissionCheck < SystemCheck::BaseCheck
+ set_name 'Uploads directory has correct permissions?'
+ set_skip_reason 'skipped (no uploads folder found)'
+
+ def skip?
+ !File.directory?(rails_uploads_path)
+ end
+
+ def check?
+ File.stat(uploads_fullpath).mode == 040700
+ end
+
+ def show_error
+ try_fixing_it(
+ "sudo chmod 700 #{uploads_fullpath}"
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+
+ private
+
+ def rails_uploads_path
+ Rails.root.join('public/uploads')
+ end
+
+ def uploads_fullpath
+ File.realpath(rails_uploads_path)
+ end
+ end
+ end
+end
diff --git a/lib/system_check/app/uploads_path_tmp_permission_check.rb b/lib/system_check/app/uploads_path_tmp_permission_check.rb
new file mode 100644
index 00000000000..b276a81eac1
--- /dev/null
+++ b/lib/system_check/app/uploads_path_tmp_permission_check.rb
@@ -0,0 +1,40 @@
+module SystemCheck
+ module App
+ class UploadsPathTmpPermissionCheck < SystemCheck::BaseCheck
+ set_name 'Uploads directory tmp has correct permissions?'
+ set_skip_reason 'skipped (no tmp uploads folder yet)'
+
+ def skip?
+ !File.directory?(uploads_fullpath) || !Dir.exist?(upload_path_tmp)
+ end
+
+ def check?
+ # If tmp upload dir has incorrect permissions, assume others do as well
+ # Verify drwx------ permissions
+ File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp)
+ end
+
+ def show_error
+ try_fixing_it(
+ "sudo chown -R #{gitlab_user} #{uploads_fullpath}",
+ "sudo find #{uploads_fullpath} -type f -exec chmod 0644 {} \\;",
+ "sudo find #{uploads_fullpath} -type d -not -path #{uploads_fullpath} -exec chmod 0700 {} \\;"
+ )
+ for_more_information(
+ see_installation_guide_section 'GitLab'
+ )
+ fix_and_rerun
+ end
+
+ private
+
+ def upload_path_tmp
+ File.join(uploads_fullpath, 'tmp')
+ end
+
+ def uploads_fullpath
+ File.realpath(Rails.root.join('public/uploads'))
+ end
+ end
+ end
+end
diff --git a/lib/system_check/base_check.rb b/lib/system_check/base_check.rb
new file mode 100644
index 00000000000..5dcb3f0886b
--- /dev/null
+++ b/lib/system_check/base_check.rb
@@ -0,0 +1,129 @@
+module SystemCheck
+ # Base class for Checks. You must inherit from here
+ # and implement the methods below when necessary
+ class BaseCheck
+ include ::SystemCheck::Helpers
+
+ # Define a custom term for when check passed
+ #
+ # @param [String] term used when check passed (default: 'yes')
+ def self.set_check_pass(term)
+ @check_pass = term
+ end
+
+ # Define a custom term for when check failed
+ #
+ # @param [String] term used when check failed (default: 'no')
+ def self.set_check_fail(term)
+ @check_fail = term
+ end
+
+ # Define the name of the SystemCheck that will be displayed during execution
+ #
+ # @param [String] name of the check
+ def self.set_name(name)
+ @name = name
+ end
+
+ # Define the reason why we skipped the SystemCheck
+ #
+ # This is only used if subclass implements `#skip?`
+ #
+ # @param [String] reason to be displayed
+ def self.set_skip_reason(reason)
+ @skip_reason = reason
+ end
+
+ # Term to be displayed when check passed
+ #
+ # @return [String] term when check passed ('yes' if not re-defined in a subclass)
+ def self.check_pass
+ call_or_return(@check_pass) || 'yes'
+ end
+
+ ## Term to be displayed when check failed
+ #
+ # @return [String] term when check failed ('no' if not re-defined in a subclass)
+ def self.check_fail
+ call_or_return(@check_fail) || 'no'
+ end
+
+ # Name of the SystemCheck defined by the subclass
+ #
+ # @return [String] the name
+ def self.display_name
+ call_or_return(@name) || self.name
+ end
+
+ # Skip reason defined by the subclass
+ #
+ # @return [String] the reason
+ def self.skip_reason
+ call_or_return(@skip_reason) || 'skipped'
+ end
+
+ # Does the check support automatically repair routine?
+ #
+ # @return [Boolean] whether check implemented `#repair!` method or not
+ def can_repair?
+ self.class.instance_methods(false).include?(:repair!)
+ end
+
+ def can_skip?
+ self.class.instance_methods(false).include?(:skip?)
+ end
+
+ def is_multi_check?
+ self.class.instance_methods(false).include?(:multi_check)
+ end
+
+ # Execute the check routine
+ #
+ # This is where you should implement the main logic that will return
+ # a boolean at the end
+ #
+ # You should not print any output to STDOUT here, use the specific methods instead
+ #
+ # @return [Boolean] whether check passed or failed
+ def check?
+ raise NotImplementedError
+ end
+
+ # Execute a custom check that cover multiple unities
+ #
+ # When using multi_check you have to provide the output yourself
+ def multi_check
+ raise NotImplementedError
+ end
+
+ # Prints troubleshooting instructions
+ #
+ # This is where you should print detailed information for any error found during #check?
+ #
+ # You may use helper methods to help format the output:
+ #
+ # @see #try_fixing_it
+ # @see #fix_and_rerun
+ # @see #for_more_infromation
+ def show_error
+ raise NotImplementedError
+ end
+
+ # When implemented by a subclass, will attempt to fix the issue automatically
+ def repair!
+ raise NotImplementedError
+ end
+
+ # When implemented by a subclass, will evaluate whether check should be skipped or not
+ #
+ # @return [Boolean] whether or not this check should be skipped
+ def skip?
+ raise NotImplementedError
+ end
+
+ def self.call_or_return(input)
+ input.respond_to?(:call) ? input.call : input
+ end
+ private_class_method :call_or_return
+ end
+end
diff --git a/lib/system_check/helpers.rb b/lib/system_check/helpers.rb
new file mode 100644
index 00000000000..c42ae4fe4c4
--- /dev/null
+++ b/lib/system_check/helpers.rb
@@ -0,0 +1,75 @@
+require 'tasks/gitlab/task_helpers'
+
+module SystemCheck
+ module Helpers
+ include ::Gitlab::TaskHelpers
+
+ # Display a message telling to fix and rerun the checks
+ def fix_and_rerun
+ $stdout.puts ' Please fix the error above and rerun the checks.'.color(:red)
+ end
+
+ # Display a formatted list of references (documentation or links) where to find more information
+ #
+ # @param [Array<String>] sources one or more references (documentation or links)
+ def for_more_information(*sources)
+ $stdout.puts ' For more information see:'.color(:blue)
+ sources.each do |source|
+ $stdout.puts " #{source}"
+ end
+ end
+
+ def see_installation_guide_section(section)
+ "doc/install/installation.md in section \"#{section}\""
+ end
+
+ # @deprecated This will no longer be used when all checks were executed using SystemCheck
+ def finished_checking(component)
+ $stdout.puts ''
+ $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}"
+ $stdout.puts ''
+ end
+
+ # @deprecated This will no longer be used when all checks were executed using SystemCheck
+ def start_checking(component)
+ $stdout.puts "Checking #{component.color(:yellow)} ..."
+ $stdout.puts ''
+ end
+
+ # Display a formatted list of instructions on how to fix the issue identified by the #check?
+ #
+ # @param [Array<String>] steps one or short sentences with help how to fix the issue
+ def try_fixing_it(*steps)
+ steps = steps.shift if steps.first.is_a?(Array)
+
+ $stdout.puts ' Try fixing it:'.color(:blue)
+ steps.each do |step|
+ $stdout.puts " #{step}"
+ end
+ end
+
+ def sanitized_message(project)
+ if should_sanitize?
+ "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... "
+ else
+ "#{project.name_with_namespace.color(:yellow)} ... "
+ end
+ end
+
+ def should_sanitize?
+ if ENV['SANITIZE'] == 'true'
+ true
+ else
+ false
+ end
+ end
+
+ def omnibus_gitlab?
+ Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails'
+ end
+
+ def sudo_gitlab(command)
+ "sudo -u #{gitlab_user} -H #{command}"
+ end
+ end
+end
diff --git a/lib/system_check/simple_executor.rb b/lib/system_check/simple_executor.rb
new file mode 100644
index 00000000000..e5986612908
--- /dev/null
+++ b/lib/system_check/simple_executor.rb
@@ -0,0 +1,101 @@
+module SystemCheck
+ # Simple Executor is current default executor for GitLab
+ # It is a simple port from display logic in the old check.rake
+ #
+ # There is no concurrency level and the output is progressively
+ # printed into the STDOUT
+ #
+ # @attr_reader [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order
+ # @attr_reader [String] component name of the component relative to the checks being executed
+ class SimpleExecutor
+ attr_reader :checks
+ attr_reader :component
+
+ # @param [String] component name of the component relative to the checks being executed
+ def initialize(component)
+ raise ArgumentError unless component.is_a? String
+
+ @component = component
+ @checks = Set.new
+ end
+
+ # Add a check to be executed
+ #
+ # @param [BaseCheck] check class
+ def <<(check)
+ raise ArgumentError unless check < BaseCheck
+ @checks << check
+ end
+
+ # Executes defined checks in the specified order and outputs confirmation or error information
+ def execute
+ start_checking(component)
+
+ @checks.each do |check|
+ run_check(check)
+ end
+
+ finished_checking(component)
+ end
+
+ # Executes a single check
+ #
+ # @param [SystemCheck::BaseCheck] check_klass
+ def run_check(check_klass)
+ $stdout.print "#{check_klass.display_name} ... "
+
+ check = check_klass.new
+
+ # When implements skip method, we run it first, and if true, skip the check
+ if check.can_skip? && check.skip?
+ $stdout.puts check_klass.skip_reason.color(:magenta)
+ return
+ end
+
+ # When implements a multi check, we don't control the output
+ if check.is_multi_check?
+ check.multi_check
+ return
+ end
+
+ if check.check?
+ $stdout.puts check_klass.check_pass.color(:green)
+ else
+ $stdout.puts check_klass.check_fail.color(:red)
+
+ if check.can_repair?
+ $stdout.print 'Trying to fix error automatically. ...'
+ if check.repair!
+ $stdout.puts 'Success'.color(:green)
+ return
+ else
+ $stdout.puts 'Failed'.color(:red)
+ end
+ end
+
+ check.show_error
+ end
+ rescue StandardError => e
+ $stdout.puts "Exception: #{e.message}".color(:red)
+ end
+
+ private
+
+ # Prints header content for the series of checks to be executed for this component
+ #
+ # @param [String] component name of the component relative to the checks being executed
+ def start_checking(component)
+ $stdout.puts "Checking #{component.color(:yellow)} ..."
+ $stdout.puts ''
+ end
+
+ # Prints footer content for the series of checks executed for this component
+ #
+ # @param [String] component name of the component relative to the checks being executed
+ def finished_checking(component)
+ $stdout.puts ''
+ $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}"
+ $stdout.puts ''
+ end
+ end
+end
diff --git a/lib/tasks/brakeman.rake b/lib/tasks/brakeman.rake
index 2301ec9b228..99b3168d9eb 100644
--- a/lib/tasks/brakeman.rake
+++ b/lib/tasks/brakeman.rake
@@ -2,7 +2,7 @@ desc 'Security check via brakeman'
task :brakeman do
# We get 0 warnings at level 'w3' but we would like to reach 'w2'. Merge
# requests are welcome!
- if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb -w3 -z))
+ if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb,app/controllers/unicorn_test_controller.rb -w3 -z))
puts 'Security check succeed'
else
puts 'Security check failed'
diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake
index 125a3d560d6..564aa141952 100644
--- a/lib/tasks/cache.rake
+++ b/lib/tasks/cache.rake
@@ -5,12 +5,12 @@ namespace :cache do
desc "GitLab | Clear redis cache"
task redis: :environment do
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
cursor = REDIS_SCAN_START_STOP
loop do
cursor, keys = redis.scan(
cursor,
- match: "#{Gitlab::Redis::CACHE_NAMESPACE}*",
+ match: "#{Gitlab::Redis::Cache::CACHE_NAMESPACE}*",
count: REDIS_CLEAR_BATCH_SIZE
)
diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake
index 5293f5af12d..87ca39b079b 100644
--- a/lib/tasks/gemojione.rake
+++ b/lib/tasks/gemojione.rake
@@ -19,8 +19,9 @@ namespace :gemojione do
entry = {
category: emoji_hash['category'],
moji: emoji_hash['moji'],
+ description: emoji_hash['description'],
unicodeVersion: Gitlab::Emoji.emoji_unicode_version(name),
- digest: hash_digest,
+ digest: hash_digest
}
resultant_emoji_map[name] = entry
diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake
new file mode 100644
index 00000000000..b48e4dce445
--- /dev/null
+++ b/lib/tasks/gettext.rake
@@ -0,0 +1,22 @@
+require "gettext_i18n_rails/tasks"
+
+namespace :gettext do
+ # Customize list of translatable files
+ # See: https://github.com/grosser/gettext_i18n_rails#customizing-list-of-translatable-files
+ def files_to_translate
+ folders = %W(app lib config #{locale_path}).join(',')
+ exts = %w(rb erb haml slim rhtml js jsx vue handlebars hbs mustache).join(',')
+
+ Dir.glob(
+ "{#{folders}}/**/*.{#{exts}}"
+ )
+ end
+
+ task :compile do
+ # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/33014#note_31218998
+ FileUtils.touch(File.join(Rails.root, 'locale/gitlab.pot'))
+
+ Rake::Task['gettext:pack'].invoke
+ Rake::Task['gettext:po_to_json'].invoke
+ end
+end
diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake
index 003d57adbbd..259a755d724 100644
--- a/lib/tasks/gitlab/assets.rake
+++ b/lib/tasks/gitlab/assets.rake
@@ -4,6 +4,7 @@ namespace :gitlab do
task compile: [
'yarn:check',
'rake:assets:precompile',
+ 'gettext:po_to_json',
'webpack:compile',
'fix_urls'
]
diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake
index f41c73154f5..1bd36bbe20a 100644
--- a/lib/tasks/gitlab/check.rake
+++ b/lib/tasks/gitlab/check.rake
@@ -1,5 +1,9 @@
+# Temporary hack, until we migrate all checks to SystemCheck format
+require 'system_check'
+require 'system_check/helpers'
+
namespace :gitlab do
- desc "GitLab | Check the configuration of GitLab and its environment"
+ desc 'GitLab | Check the configuration of GitLab and its environment'
task check: %w{gitlab:gitlab_shell:check
gitlab:sidekiq:check
gitlab:incoming_email:check
@@ -7,327 +11,32 @@ namespace :gitlab do
gitlab:app:check}
namespace :app do
- desc "GitLab | Check the configuration of the GitLab Rails app"
+ desc 'GitLab | Check the configuration of the GitLab Rails app'
task check: :environment do
warn_user_is_not_gitlab
- start_checking "GitLab"
-
- check_git_config
- check_database_config_exists
- check_migrations_are_up
- check_orphaned_group_members
- check_gitlab_config_exists
- check_gitlab_config_not_outdated
- check_log_writable
- check_tmp_writable
- check_uploads
- check_init_script_exists
- check_init_script_up_to_date
- check_projects_have_namespace
- check_redis_version
- check_ruby_version
- check_git_version
- check_active_users
-
- finished_checking "GitLab"
- end
-
- # Checks
- ########################
-
- def check_git_config
- print "Git configured with autocrlf=input? ... "
-
- options = {
- "core.autocrlf" => "input"
- }
-
- correct_options = options.map do |name, value|
- run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value
- end
- if correct_options.all?
- puts "yes".color(:green)
- else
- print "Trying to fix Git error automatically. ..."
-
- if auto_fix_git_config(options)
- puts "Success".color(:green)
- else
- puts "Failed".color(:red)
- try_fixing_it(
- sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{options["core.autocrlf"]}\"")
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- end
- end
- end
-
- def check_database_config_exists
- print "Database config exists? ... "
-
- database_config_file = Rails.root.join("config", "database.yml")
-
- if File.exist?(database_config_file)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Copy config/database.yml.<your db> to config/database.yml",
- "Check that the information in config/database.yml is correct"
- )
- for_more_information(
- see_database_guide,
- "http://guides.rubyonrails.org/getting_started.html#configuring-a-database"
- )
- fix_and_rerun
- end
- end
-
- def check_gitlab_config_exists
- print "GitLab config exists? ... "
-
- gitlab_config_file = Rails.root.join("config", "gitlab.yml")
-
- if File.exist?(gitlab_config_file)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Copy config/gitlab.yml.example to config/gitlab.yml",
- "Update config/gitlab.yml to match your setup"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- end
-
- def check_gitlab_config_not_outdated
- print "GitLab config outdated? ... "
-
- gitlab_config_file = Rails.root.join("config", "gitlab.yml")
- unless File.exist?(gitlab_config_file)
- puts "can't check because of previous errors".color(:magenta)
- end
-
- # omniauth or ldap could have been deleted from the file
- unless Gitlab.config['git_host']
- puts "no".color(:green)
- else
- puts "yes".color(:red)
- try_fixing_it(
- "Backup your config/gitlab.yml",
- "Copy config/gitlab.yml.example to config/gitlab.yml",
- "Update config/gitlab.yml to match your setup"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- end
-
- def check_init_script_exists
- print "Init script exists? ... "
-
- if omnibus_gitlab?
- puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta)
- return
- end
-
- script_path = "/etc/init.d/gitlab"
-
- if File.exist?(script_path)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Install the init script"
- )
- for_more_information(
- see_installation_guide_section "Install Init Script"
- )
- fix_and_rerun
- end
- end
-
- def check_init_script_up_to_date
- print "Init script up-to-date? ... "
-
- if omnibus_gitlab?
- puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta)
- return
- end
-
- recipe_path = Rails.root.join("lib/support/init.d/", "gitlab")
- script_path = "/etc/init.d/gitlab"
-
- unless File.exist?(script_path)
- puts "can't check because of previous errors".color(:magenta)
- return
- end
-
- recipe_content = File.read(recipe_path)
- script_content = File.read(script_path)
-
- if recipe_content == script_content
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Redownload the init script"
- )
- for_more_information(
- see_installation_guide_section "Install Init Script"
- )
- fix_and_rerun
- end
- end
-
- def check_migrations_are_up
- print "All migrations up? ... "
-
- migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status))
-
- unless migration_status =~ /down\s+\d{14}/
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- sudo_gitlab("bundle exec rake db:migrate RAILS_ENV=production")
- )
- fix_and_rerun
- end
- end
-
- def check_orphaned_group_members
- print "Database contains orphaned GroupMembers? ... "
- if GroupMember.where("user_id not in (select id from users)").count > 0
- puts "yes".color(:red)
- try_fixing_it(
- "You can delete the orphaned records using something along the lines of:",
- sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'")
- )
- else
- puts "no".color(:green)
- end
- end
-
- def check_log_writable
- print "Log directory writable? ... "
-
- log_path = Rails.root.join("log")
-
- if File.writable?(log_path)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "sudo chown -R gitlab #{log_path}",
- "sudo chmod -R u+rwX #{log_path}"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- end
-
- def check_tmp_writable
- print "Tmp directory writable? ... "
-
- tmp_path = Rails.root.join("tmp")
-
- if File.writable?(tmp_path)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "sudo chown -R gitlab #{tmp_path}",
- "sudo chmod -R u+rwX #{tmp_path}"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- end
-
- def check_uploads
- print "Uploads directory setup correctly? ... "
-
- unless File.directory?(Rails.root.join('public/uploads'))
- puts "no".color(:red)
- try_fixing_it(
- "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- return
- end
-
- upload_path = File.realpath(Rails.root.join('public/uploads'))
- upload_path_tmp = File.join(upload_path, 'tmp')
-
- if File.stat(upload_path).mode == 040700
- unless Dir.exist?(upload_path_tmp)
- puts 'skipped (no tmp uploads folder yet)'.color(:magenta)
- return
- end
-
- # If tmp upload dir has incorrect permissions, assume others do as well
- # Verify drwx------ permissions
- if File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp)
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "sudo chown -R #{gitlab_user} #{upload_path}",
- "sudo find #{upload_path} -type f -exec chmod 0644 {} \\;",
- "sudo find #{upload_path} -type d -not -path #{upload_path} -exec chmod 0700 {} \\;"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- else
- puts "no".color(:red)
- try_fixing_it(
- "sudo chmod 700 #{upload_path}"
- )
- for_more_information(
- see_installation_guide_section "GitLab"
- )
- fix_and_rerun
- end
- end
-
- def check_redis_version
- min_redis_version = "2.8.0"
- print "Redis version >= #{min_redis_version}? ... "
-
- redis_version = run_command(%w(redis-cli --version))
- redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/)
- if redis_version &&
- (Gem::Version.new(redis_version[1]) > Gem::Version.new(min_redis_version))
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Update your redis server to a version >= #{min_redis_version}"
- )
- for_more_information(
- "gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq"
- )
- fix_and_rerun
- end
+ checks = [
+ SystemCheck::App::GitConfigCheck,
+ SystemCheck::App::DatabaseConfigExistsCheck,
+ SystemCheck::App::MigrationsAreUpCheck,
+ SystemCheck::App::OrphanedGroupMembersCheck,
+ SystemCheck::App::GitlabConfigExistsCheck,
+ SystemCheck::App::GitlabConfigUpToDateCheck,
+ SystemCheck::App::LogWritableCheck,
+ SystemCheck::App::TmpWritableCheck,
+ SystemCheck::App::UploadsDirectoryExistsCheck,
+ SystemCheck::App::UploadsPathPermissionCheck,
+ SystemCheck::App::UploadsPathTmpPermissionCheck,
+ SystemCheck::App::InitScriptExistsCheck,
+ SystemCheck::App::InitScriptUpToDateCheck,
+ SystemCheck::App::ProjectsHaveNamespaceCheck,
+ SystemCheck::App::RedisVersionCheck,
+ SystemCheck::App::RubyVersionCheck,
+ SystemCheck::App::GitVersionCheck,
+ SystemCheck::App::ActiveUsersCheck
+ ]
+
+ SystemCheck.run('GitLab', checks)
end
end
@@ -513,33 +222,6 @@ namespace :gitlab do
end
end
- def check_projects_have_namespace
- print "projects have namespace: ... "
-
- unless Project.count > 0
- puts "can't check, you have no projects".color(:magenta)
- return
- end
- puts ""
-
- Project.find_each(batch_size: 100) do |project|
- print sanitized_message(project)
-
- if project.namespace
- puts "yes".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Migrate global projects"
- )
- for_more_information(
- "doc/update/5.4-to-6.0.md in section \"#global-projects\""
- )
- fix_and_rerun
- end
- end
- end
-
# Helper methods
########################
@@ -648,12 +330,9 @@ namespace :gitlab do
########################
def check_initd_configured_correctly
- print "Init.d configured correctly? ... "
+ return if omnibus_gitlab?
- if omnibus_gitlab?
- puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta)
- return
- end
+ print "Init.d configured correctly? ... "
path = "/etc/default/gitlab"
@@ -691,6 +370,8 @@ namespace :gitlab do
end
def check_mail_room_running
+ return if omnibus_gitlab?
+
print "MailRoom running? ... "
path = "/etc/default/gitlab"
@@ -834,7 +515,7 @@ namespace :gitlab do
repo_dirs = user.authorized_projects.map do |p|
File.join(
p.repository_storage_path,
- "#{p.path_with_namespace}.git"
+ "#{p.disk_path}.git"
)
end
@@ -848,55 +529,6 @@ namespace :gitlab do
# Helper methods
##########################
- def fix_and_rerun
- puts " Please fix the error above and rerun the checks.".color(:red)
- end
-
- def for_more_information(*sources)
- sources = sources.shift if sources.first.is_a?(Array)
-
- puts " For more information see:".color(:blue)
- sources.each do |source|
- puts " #{source}"
- end
- end
-
- def finished_checking(component)
- puts ""
- puts "Checking #{component.color(:yellow)} ... #{"Finished".color(:green)}"
- puts ""
- end
-
- def see_database_guide
- "doc/install/databases.md"
- end
-
- def see_installation_guide_section(section)
- "doc/install/installation.md in section \"#{section}\""
- end
-
- def sudo_gitlab(command)
- "sudo -u #{gitlab_user} -H #{command}"
- end
-
- def gitlab_user
- Gitlab.config.gitlab.user
- end
-
- def start_checking(component)
- puts "Checking #{component.color(:yellow)} ..."
- puts ""
- end
-
- def try_fixing_it(*steps)
- steps = steps.shift if steps.first.is_a?(Array)
-
- puts " Try fixing it:".color(:blue)
- steps.each do |step|
- puts " #{step}"
- end
- end
-
def check_gitlab_shell
required_version = Gitlab::VersionInfo.new(gitlab_shell_major_version, gitlab_shell_minor_version, gitlab_shell_patch_version)
current_version = Gitlab::VersionInfo.parse(gitlab_shell_version)
@@ -909,65 +541,6 @@ namespace :gitlab do
end
end
- def check_ruby_version
- required_version = Gitlab::VersionInfo.new(2, 1, 0)
- current_version = Gitlab::VersionInfo.parse(run_command(%w(ruby --version)))
-
- print "Ruby version >= #{required_version} ? ... "
-
- if current_version.valid? && required_version <= current_version
- puts "yes (#{current_version})".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Update your ruby to a version >= #{required_version} from #{current_version}"
- )
- fix_and_rerun
- end
- end
-
- def check_git_version
- required_version = Gitlab::VersionInfo.new(2, 7, 3)
- current_version = Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version)))
-
- puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\""
- print "Git version >= #{required_version} ? ... "
-
- if current_version.valid? && required_version <= current_version
- puts "yes (#{current_version})".color(:green)
- else
- puts "no".color(:red)
- try_fixing_it(
- "Update your git to a version >= #{required_version} from #{current_version}"
- )
- fix_and_rerun
- end
- end
-
- def check_active_users
- puts "Active users: #{User.active.count}"
- end
-
- def omnibus_gitlab?
- Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails'
- end
-
- def sanitized_message(project)
- if should_sanitize?
- "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... "
- else
- "#{project.name_with_namespace.color(:yellow)} ... "
- end
- end
-
- def should_sanitize?
- if ENV['SANITIZE'] == "true"
- true
- else
- false
- end
- end
-
def check_repo_integrity(repo_dir)
puts "\nChecking repo at #{repo_dir.color(:yellow)}"
diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake
index 5476438b8fa..139ab70e125 100644
--- a/lib/tasks/gitlab/db.rake
+++ b/lib/tasks/gitlab/db.rake
@@ -65,6 +65,7 @@ namespace :gitlab do
migrations = `git diff #{ref}.. --diff-filter=A --name-only -- db/migrate`.lines
.map { |file| Rails.root.join(file.strip).to_s }
.select { |file| File.file?(file) }
+ .select { |file| /\A[0-9]+.*\.rb\z/ =~ File.basename(file) }
Gitlab::DowntimeCheck.new.check_and_print(migrations)
end
diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake
index 046780481ba..08677a98fc1 100644
--- a/lib/tasks/gitlab/gitaly.rake
+++ b/lib/tasks/gitlab/gitaly.rake
@@ -1,25 +1,32 @@
namespace :gitlab do
namespace :gitaly do
desc "GitLab | Install or upgrade gitaly"
- task :install, [:dir] => :environment do |t, args|
+ task :install, [:dir, :repo] => :environment do |t, args|
require 'toml'
warn_user_is_not_gitlab
unless args.dir.present?
abort %(Please specify the directory where you want to install gitaly:\n rake "gitlab:gitaly:install[/home/git/gitaly]")
end
+ args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitaly.git')
version = Gitlab::GitalyClient.expected_server_version
- repo = 'https://gitlab.com/gitlab-org/gitaly.git'
- checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir)
+ checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir)
_, status = Gitlab::Popen.popen(%w[which gmake])
- command = status.zero? ? 'gmake' : 'make'
+ command = status.zero? ? ['gmake'] : ['make']
+
+ if Rails.env.test?
+ command += %W[BUNDLE_PATH=#{Bundler.bundle_path}]
+ end
Dir.chdir(args.dir) do
create_gitaly_configuration
- run_command!([command])
+ # In CI we run scripts/gitaly-test-build instead of this command
+ unless ENV['CI'].present?
+ Bundler.with_original_env { run_command!(%w[/usr/bin/env -u RUBYOPT -u BUNDLE_GEMFILE] + command) }
+ end
end
end
@@ -30,11 +37,9 @@ namespace :gitlab do
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)
+ # Exclude gitaly-ruby configuration because that depends on the gitaly
+ # installation directory.
+ puts gitaly_configuration_toml(gitaly_ruby: false)
end
private
@@ -42,10 +47,10 @@ namespace :gitlab do
# We cannot create config.toml files for all possible Gitaly configuations.
# For instance, if Gitaly is running on another machine then it makes no
# sense to write a config.toml file on the current machine. This method will
- # only write a config.toml file in the most common and simplest case: the
- # case where we have exactly one Gitaly process and we are sure it is
- # running locally because it uses a Unix socket.
- def create_gitaly_configuration
+ # only generate a configuration for the most common and simplest case: when
+ # we have exactly one Gitaly process and we are sure it is running locally
+ # because it uses a Unix socket.
+ def gitaly_configuration_toml(gitaly_ruby: true)
storages = []
address = nil
@@ -62,9 +67,16 @@ namespace :gitlab do
storages << { name: key, path: val['path'] }
end
+ config = { socket_path: address.sub(%r{\Aunix:}, ''), storage: storages }
+ config[:auth] = { token: 'secret' } if Rails.env.test?
+ config[:'gitaly-ruby'] = { dir: File.join(Dir.pwd, 'ruby') } if gitaly_ruby
+ config[:'gitlab-shell'] = { dir: Gitlab.config.gitlab_shell.path }
+ TOML.dump(config)
+ end
+ def create_gitaly_configuration
File.open("config.toml", "w") do |f|
- f.puts TOML.dump(socket_path: address.sub(%r{\Aunix:}, ''), storages: storages)
+ f.puts gitaly_configuration_toml
end
rescue ArgumentError => e
puts "Skipping config.toml generation:"
diff --git a/lib/tasks/gitlab/helpers.rake b/lib/tasks/gitlab/helpers.rake
index dd2d5861481..b0a24790c4a 100644
--- a/lib/tasks/gitlab/helpers.rake
+++ b/lib/tasks/gitlab/helpers.rake
@@ -4,5 +4,5 @@ require 'tasks/gitlab/task_helpers'
StateMachines::Machine.ignore_method_conflicts = true if ENV['CRON']
namespace :gitlab do
- include Gitlab::TaskHelpers
+ extend SystemCheck::Helpers
end
diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake
index a2a2db487b7..e9fb6a008b0 100644
--- a/lib/tasks/gitlab/info.rake
+++ b/lib/tasks/gitlab/info.rake
@@ -16,6 +16,8 @@ namespace :gitlab do
redis_version = run_and_match(%w(redis-cli --version), /redis-cli (\d+\.\d+\.\d+)/).to_a
# check Git version
git_version = run_and_match([Gitlab.config.git.bin_path, '--version'], /git version ([\d\.]+)/).to_a
+ # check Go version
+ go_version = run_and_match(%w(go version), /go version (.+)/).to_a
puts ""
puts "System information".color(:yellow)
@@ -30,6 +32,7 @@ namespace :gitlab do
puts "Redis Version:\t#{redis_version[1] || "unknown".color(:red)}"
puts "Git Version:\t#{git_version[1] || "unknown".color(:red)}"
puts "Sidekiq Version:#{Sidekiq::VERSION}"
+ puts "Go Version:\t#{go_version[1] || "unknown".color(:red)}"
# check database adapter
database_adapter = ActiveRecord::Base.connection.adapter_name.downcase
@@ -39,8 +42,7 @@ namespace :gitlab do
http_clone_url = project.http_url_to_repo
ssh_clone_url = project.ssh_url_to_repo
- omniauth_providers = Gitlab.config.omniauth.providers
- omniauth_providers.map! { |provider| provider['name'] }
+ omniauth_providers = Gitlab.config.omniauth.providers.map { |provider| provider['name'] }
puts ""
puts "GitLab information".color(:yellow)
diff --git a/lib/tasks/gitlab/list_repos.rake b/lib/tasks/gitlab/list_repos.rake
index ffcc76e5498..b732db9db6e 100644
--- a/lib/tasks/gitlab/list_repos.rake
+++ b/lib/tasks/gitlab/list_repos.rake
@@ -9,7 +9,7 @@ namespace :gitlab do
scope = scope.where('id IN (?) OR namespace_id in (?)', project_ids, namespace_ids)
end
scope.find_each do |project|
- base = File.join(project.repository_storage_path, project.path_with_namespace)
+ base = File.join(project.repository_storage_path, project.disk_path)
puts base + '.git'
puts base + '.wiki.git'
end
diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake
index 95687066819..42825f29e32 100644
--- a/lib/tasks/gitlab/shell.rake
+++ b/lib/tasks/gitlab/shell.rake
@@ -41,8 +41,14 @@ namespace :gitlab do
# Generate config.yml based on existing gitlab settings
File.open("config.yml", "w+") {|f| f.puts config.to_yaml}
- # Launch installation process
- system(*%w(bin/install) + repository_storage_paths_args)
+ [
+ %w(bin/install) + repository_storage_paths_args,
+ %w(bin/compile)
+ ].each do |cmd|
+ unless Kernel.system(*cmd)
+ raise "command failed: #{cmd.join(' ')}"
+ end
+ end
end
# (Re)create hooks
@@ -74,7 +80,7 @@ namespace :gitlab do
print '-'
else
if Gitlab::Shell.new.add_repository(project.repository_storage_path,
- project.path_with_namespace)
+ project.disk_path)
print '.'
else
print 'F'
diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb
index e3c9d3b491c..d85b810ac66 100644
--- a/lib/tasks/gitlab/task_helpers.rb
+++ b/lib/tasks/gitlab/task_helpers.rb
@@ -5,6 +5,8 @@ module Gitlab
TaskAbortedByUserError = Class.new(StandardError)
module TaskHelpers
+ extend self
+
# Ask if the user wants to continue
#
# Returns "yes" the user chose to continue
@@ -98,34 +100,30 @@ module Gitlab
end
end
+ def gitlab_user
+ Gitlab.config.gitlab.user
+ end
+
+ def is_gitlab_user?
+ return @is_gitlab_user unless @is_gitlab_user.nil?
+
+ current_user = run_command(%w(whoami)).chomp
+ @is_gitlab_user = current_user == gitlab_user
+ end
+
def warn_user_is_not_gitlab
- unless @warned_user_not_gitlab
- gitlab_user = Gitlab.config.gitlab.user
+ return if @warned_user_not_gitlab
+
+ unless is_gitlab_user?
current_user = run_command(%w(whoami)).chomp
- unless current_user == gitlab_user
- puts " Warning ".color(:black).background(:yellow)
- puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing."
- puts " Things may work\/fail for the wrong reasons."
- puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}."
- puts ""
- end
- @warned_user_not_gitlab = true
- end
- end
- # Tries to configure git itself
- #
- # Returns true if all subcommands were successfull (according to their exit code)
- # Returns false if any or all subcommands failed.
- def auto_fix_git_config(options)
- if !@warned_user_not_gitlab
- command_success = options.map do |name, value|
- system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value}))
- end
+ puts " Warning ".color(:black).background(:yellow)
+ puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing."
+ puts " Things may work\/fail for the wrong reasons."
+ puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}."
+ puts ""
- command_success.all?
- else
- false
+ @warned_user_not_gitlab = true
end
end
@@ -157,7 +155,6 @@ module Gitlab
clone_repo(repo, target_dir) unless Dir.exist?(target_dir)
checkout_version(version, target_dir)
- reset_to_version(version, target_dir)
end
def clone_repo(repo, target_dir)
@@ -165,12 +162,8 @@ module Gitlab
end
def checkout_version(version, target_dir)
- run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} fetch --quiet])
- run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} checkout --quiet #{version}])
- end
-
- def reset_to_version(version, target_dir)
- run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} reset --hard #{version}])
+ run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} fetch --quiet origin #{version}])
+ run_command!(%W[#{Gitlab.config.git.bin_path} -C #{target_dir} checkout -f --quiet FETCH_HEAD --])
end
end
end
diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake
index fc0ccc726ed..7728c485e8d 100644
--- a/lib/tasks/gitlab/two_factor.rake
+++ b/lib/tasks/gitlab/two_factor.rake
@@ -19,5 +19,21 @@ namespace :gitlab do
puts "There are currently no users with 2FA enabled.".color(:yellow)
end
end
+
+ namespace :rotate_key do
+ def rotator
+ @rotator ||= Gitlab::OtpKeyRotator.new(ENV['filename'])
+ end
+
+ desc "Encrypt user OTP secrets with a new encryption key"
+ task apply: :environment do |t, args|
+ rotator.rotate!(old_key: ENV['old_key'], new_key: ENV['new_key'])
+ end
+
+ desc "Rollback to secrets encrypted with the old encryption key"
+ task rollback: :environment do
+ rotator.rollback!
+ end
+ end
end
end
diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake
index 1b04e1350ed..f44abc2b81b 100644
--- a/lib/tasks/gitlab/update_templates.rake
+++ b/lib/tasks/gitlab/update_templates.rake
@@ -4,6 +4,60 @@ namespace :gitlab do
TEMPLATE_DATA.each { |template| update(template) }
end
+ desc "GitLab | Update project templates"
+ task :update_project_templates do
+ if Rails.env.production?
+ puts "This rake task is not meant fo production instances".red
+ exit(1)
+ end
+ admin = User.find_by(admin: true)
+
+ unless admin
+ puts "No admin user could be found".red
+ exit(1)
+ end
+
+ Gitlab::ProjectTemplate.all.each do |template|
+ params = {
+ import_url: template.clone_url,
+ namespace_id: admin.namespace.id,
+ path: template.name,
+ skip_wiki: true
+ }
+
+ puts "Creating project for #{template.title}"
+ project = Projects::CreateService.new(admin, params).execute
+
+ unless project.persisted?
+ puts project.errors.messages
+ exit(1)
+ end
+
+ loop do
+ if project.finished?
+ puts "Import finished for #{template.name}"
+ break
+ end
+
+ if project.failed?
+ puts "Failed to import from #{project_params[:import_url]}".red
+ exit(1)
+ end
+
+ puts "Waiting for the import to finish"
+
+ sleep(5)
+ project.reload
+ end
+
+ Projects::ImportExport::ExportService.new(project, admin).execute
+ FileUtils.cp(project.export_project_path, template.archive_path)
+ Projects::DestroyService.new(admin, project).execute
+ puts "Exported #{template.name}".green
+ end
+ puts "Done".green
+ end
+
def update(template)
sub_dir = template.repo_url.match(/([A-Za-z-]+)\.git\z/)[1]
dir = File.join(vendor_directory, sub_dir)
@@ -49,7 +103,7 @@ namespace :gitlab do
Template.new(
"https://gitlab.com/gitlab-org/Dockerfile.git",
/(\.{1,2}|LICENSE|CONTRIBUTING.md|\.Dockerfile)\z/
- ),
+ )
].freeze
def vendor_directory
diff --git a/lib/tasks/gitlab/workhorse.rake b/lib/tasks/gitlab/workhorse.rake
index a00b02188cf..e7ac0b5859f 100644
--- a/lib/tasks/gitlab/workhorse.rake
+++ b/lib/tasks/gitlab/workhorse.rake
@@ -1,16 +1,16 @@
namespace :gitlab do
namespace :workhorse do
desc "GitLab | Install or upgrade gitlab-workhorse"
- task :install, [:dir] => :environment do |t, args|
+ task :install, [:dir, :repo] => :environment do |t, args|
warn_user_is_not_gitlab
unless args.dir.present?
abort %(Please specify the directory where you want to install gitlab-workhorse:\n rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]")
end
+ args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitlab-workhorse.git')
version = Gitlab::Workhorse.version
- repo = 'https://gitlab.com/gitlab-org/gitlab-workhorse.git'
- checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir)
+ checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir)
_, status = Gitlab::Popen.popen(%w[which gmake])
command = status.zero? ? 'gmake' : 'make'
diff --git a/lib/tasks/haml-lint.rake b/lib/tasks/haml-lint.rake
index 609dfaa48e3..ad2d034b0b4 100644
--- a/lib/tasks/haml-lint.rake
+++ b/lib/tasks/haml-lint.rake
@@ -1,5 +1,6 @@
unless Rails.env.production?
require 'haml_lint/rake_task'
+ require 'haml_lint/inline_javascript'
HamlLint::RakeTask.new
end
diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
index bc76d7edc55..96b8f59242c 100644
--- a/lib/tasks/import.rake
+++ b/lib/tasks/import.rake
@@ -7,7 +7,7 @@ class GithubImport
end
def initialize(token, gitlab_username, project_path, extras)
- @options = { url: 'https://api.github.com', token: token, verbose: true }
+ @options = { token: token, verbose: true }
@project_path = project_path
@current_user = User.find_by_username(gitlab_username)
@github_repo = extras.empty? ? nil : extras.first
@@ -37,7 +37,7 @@ class GithubImport
end
def import!
- @project.import_start
+ @project.force_import_start
timings = Benchmark.measure do
Github::Import.new(@project, @options).execute
@@ -62,6 +62,7 @@ class GithubImport
visibility_level: visibility_level,
import_type: 'github',
import_source: @repo['full_name'],
+ import_url: @repo['clone_url'].sub('://', "://#{@options[:token]}@"),
skip_wiki: @repo['has_wiki']
).execute
end
diff --git a/lib/tasks/migrate/add_limits_mysql.rake b/lib/tasks/migrate/add_limits_mysql.rake
index 6ded519aff2..151f42a2222 100644
--- a/lib/tasks/migrate/add_limits_mysql.rake
+++ b/lib/tasks/migrate/add_limits_mysql.rake
@@ -1,7 +1,11 @@
require Rails.root.join('db/migrate/limits_to_mysql')
+require Rails.root.join('db/migrate/markdown_cache_limits_to_mysql')
+require Rails.root.join('db/migrate/merge_request_diff_file_limits_to_mysql')
desc "GitLab | Add limits to strings in mysql database"
task add_limits_mysql: :environment do
puts "Adding limits to schema.rb for mysql"
LimitsToMysql.new.up
+ MarkdownCacheLimitsToMysql.new.up
+ MergeRequestDiffFileLimitsToMysql.new.up
end
diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake
index 8938bc515f5..9cc986535e1 100644
--- a/lib/tasks/migrate/setup_postgresql.rake
+++ b/lib/tasks/migrate/setup_postgresql.rake
@@ -4,6 +4,8 @@ require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lowe
require Rails.root.join('db/migrate/20151008110232_add_users_lower_username_email_indexes')
require Rails.root.join('db/migrate/20161212142807_add_lower_path_index_to_routes')
require Rails.root.join('db/migrate/20170317203554_index_routes_path_for_like')
+require Rails.root.join('db/migrate/20170724214302_add_lower_path_index_to_redirect_routes')
+require Rails.root.join('db/migrate/20170503185032_index_redirect_routes_path_for_like')
desc 'GitLab | Sets up PostgreSQL'
task setup_postgresql: :environment do
@@ -11,4 +13,6 @@ task setup_postgresql: :environment do
AddUsersLowerUsernameEmailIndexes.new.up
AddLowerPathIndexToRoutes.new.up
IndexRoutesPathForLike.new.up
+ AddLowerPathIndexToRedirectRoutes.new.up
+ IndexRedirectRoutesPathForLike.new.up
end
diff --git a/lib/tasks/spec.rake b/lib/tasks/spec.rake
index 602c60be828..2eddcb3c777 100644
--- a/lib/tasks/spec.rake
+++ b/lib/tasks/spec.rake
@@ -60,7 +60,7 @@ desc "GitLab | Run specs"
task :spec do
cmds = [
%w(rake gitlab:setup),
- %w(rspec spec),
+ %w(rspec spec)
]
run_commands(cmds)
end
diff --git a/lib/tasks/tokens.rake b/lib/tasks/tokens.rake
index 95735f43802..ad1818ff1fa 100644
--- a/lib/tasks/tokens.rake
+++ b/lib/tasks/tokens.rake
@@ -11,6 +11,11 @@ namespace :tokens do
reset_all_users_token(:reset_incoming_email_token!)
end
+ desc "Reset all GitLab RSS tokens"
+ task reset_all_rss: :environment do
+ reset_all_users_token(:reset_rss_token!)
+ end
+
def reset_all_users_token(reset_token_method)
TmpUser.find_in_batches do |batch|
puts "Processing batch starting with user ID: #{batch.first.id}"
@@ -35,4 +40,9 @@ class TmpUser < ActiveRecord::Base
write_new_token(:incoming_email_token)
save!(validate: false)
end
+
+ def reset_rss_token!
+ write_new_token(:rss_token)
+ save!(validate: false)
+ end
end
diff --git a/lib/uploaded_file.rb b/lib/uploaded_file.rb
index 41dee5fdc06..4a3c40f88eb 100644
--- a/lib/uploaded_file.rb
+++ b/lib/uploaded_file.rb
@@ -27,7 +27,7 @@ class UploadedFile
alias_method :local_path, :path
def method_missing(method_name, *args, &block) #:nodoc:
- @tempfile.__send__(method_name, *args, &block)
+ @tempfile.__send__(method_name, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
def respond_to?(method_name, include_private = false) #:nodoc:
diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po
new file mode 100644
index 00000000000..5c531f0cd7d
--- /dev/null
+++ b/locale/bg/gitlab.po
@@ -0,0 +1,1249 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Lyubomir Vasilev <lyubomirv@abv.bg>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-03 04:43-0400\n"
+"Last-Translator: Lyubomir Vasilev <lyubomirv@abv.bg>\n"
+"Language-Team: Bulgarian (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: bg\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s подаване беше пропуÑнато, за да не Ñе натоварва ÑиÑтемата."
+msgstr[1] "%s Ð¿Ð¾Ð´Ð°Ð²Ð°Ð½Ð¸Ñ Ð±Ñха пропуÑнати, за да не Ñе натоварва ÑиÑтемата."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d подаване"
+msgstr[1] "%d подаваниÑ"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} подаде %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 Ñхема"
+msgstr[1] "%d Ñхеми"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Ðабор от графики отноÑно непрекъÑнатата интеграциÑ"
+
+msgid "About auto deploy"
+msgstr "ОтноÑно автоматичното внедрÑване"
+
+msgid "Active"
+msgstr "Ðктивно"
+
+msgid "Activity"
+msgstr "ДейноÑÑ‚"
+
+msgid "Add Changelog"
+msgstr "ДобавÑне на ÑпиÑък Ñ Ð¿Ñ€Ð¾Ð¼ÐµÐ½Ð¸"
+
+msgid "Add Contribution guide"
+msgstr "ДобавÑне на ръководÑтво за ÑътрудничеÑтво"
+
+msgid "Add License"
+msgstr "ДобавÑне на лиценз"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Добавете SSH ключ в профила Ñи, за да можете да изтеглÑте или изпращате "
+"промени чрез SSH."
+
+msgid "Add new directory"
+msgstr "ДобавÑне на нова папка"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Ðрхивиран проект! Хранилището е Ñамо за четене"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "ÐаиÑтина ли иÑкате да изтриете този план за Ñхема?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Прикачете файл чрез влачене и пуÑкане или %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Клон"
+msgstr[1] "Клонове"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"Клонът <strong>%{branch_name}</strong> беше Ñъздаден. За да наÑтроите "
+"автоматичното внедрÑване, изберете Yaml шаблон за GitLab CI и подайте "
+"промените Ñи. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "ТърÑете в клоновете"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Превключване на клона"
+
+msgid "Branches"
+msgstr "Клонове"
+
+msgid "Browse Directory"
+msgstr "Преглед на папката"
+
+msgid "Browse File"
+msgstr "Преглед на файла"
+
+msgid "Browse Files"
+msgstr "Преглед на файловете"
+
+msgid "Browse files"
+msgstr "Разглеждане на файловете"
+
+msgid "ByAuthor|by"
+msgstr "от"
+
+msgid "CI configuration"
+msgstr "ÐšÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ Ð½Ð° непрекъÑната интеграциÑ"
+
+msgid "Cancel"
+msgstr "Отказ"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Избиране в клона"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "ОтмÑна в клона"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Подбиране"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "ОтмÑна"
+
+msgid "Changelog"
+msgstr "СпиÑък Ñ Ð¿Ñ€Ð¾Ð¼ÐµÐ½Ð¸"
+
+msgid "Charts"
+msgstr "Графики"
+
+msgid "Cherry-pick this commit"
+msgstr "Подбиране на това подаване"
+
+msgid "Cherry-pick this merge request"
+msgstr "Подбиране на тази заÑвка за Ñливане"
+
+msgid "CiStatusLabel|canceled"
+msgstr "отказано"
+
+msgid "CiStatusLabel|created"
+msgstr "Ñъздадено"
+
+msgid "CiStatusLabel|failed"
+msgstr "неуÑпешно"
+
+msgid "CiStatusLabel|manual action"
+msgstr "ръчно дейÑтвие"
+
+msgid "CiStatusLabel|passed"
+msgstr "уÑпешно"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "уÑпешно, Ñ Ð¿Ñ€ÐµÐ´ÑƒÐ¿Ñ€ÐµÐ¶Ð´ÐµÐ½Ð¸Ñ"
+
+msgid "CiStatusLabel|pending"
+msgstr "на изчакване"
+
+msgid "CiStatusLabel|skipped"
+msgstr "пропуÑнато"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "чакане за ръчно дейÑтвие"
+
+msgid "CiStatusText|blocked"
+msgstr "блокирано"
+
+msgid "CiStatusText|canceled"
+msgstr "отказано"
+
+msgid "CiStatusText|created"
+msgstr "Ñъздадено"
+
+msgid "CiStatusText|failed"
+msgstr "неуÑпешно"
+
+msgid "CiStatusText|manual"
+msgstr "ръчно"
+
+msgid "CiStatusText|passed"
+msgstr "уÑпешно"
+
+msgid "CiStatusText|pending"
+msgstr "на изчакване"
+
+msgid "CiStatusText|skipped"
+msgstr "пропуÑнато"
+
+msgid "CiStatus|running"
+msgstr "протича в момента"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Подаване"
+msgstr[1] "ПодаваниÑ"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Времетраене на подаваниÑта в минути за поÑледните 30 подаваниÑ"
+
+msgid "Commit message"
+msgstr "Съобщение за подаването"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Подаване"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "ДобавÑне на „%{file_name}“"
+
+msgid "Commits"
+msgstr "ПодаваниÑ"
+
+msgid "Commits feed"
+msgstr "Поток от подаваниÑ"
+
+msgid "Commits|History"
+msgstr "ИÑториÑ"
+
+msgid "Committed by"
+msgstr "Подадено от"
+
+msgid "Compare"
+msgstr "Сравнение"
+
+msgid "Contribution guide"
+msgstr "РъководÑтво за ÑътрудничеÑтво"
+
+msgid "Contributors"
+msgstr "Сътрудници"
+
+msgid "Copy URL to clipboard"
+msgstr "Копиране на адреÑа в буфера за обмен"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Копиране на идентификатора на подаването в буфера за обмен"
+
+msgid "Create New Directory"
+msgstr "Създаване на нова папка"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Създайте Ñи личен жетон за доÑтъп в профила Ñи, за да можете да изтеглÑте и "
+"изпращате промени чрез %{protocol}."
+
+msgid "Create directory"
+msgstr "Създаване на папка"
+
+msgid "Create empty bare repository"
+msgstr "Създаване на празно хранилище"
+
+msgid "Create merge request"
+msgstr "Създаване на заÑвка за Ñливане"
+
+msgid "Create new..."
+msgstr "Създаване на нов…"
+
+msgid "CreateNewFork|Fork"
+msgstr "РазклонÑване"
+
+msgid "CreateTag|Tag"
+msgstr "Етикет"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "Ñи Ñъздадете личен жетон за доÑтъп"
+
+msgid "Cron Timezone"
+msgstr "ЧаÑова зона за „Cron“"
+
+msgid "Cron syntax"
+msgstr "СинтакÑÐ¸Ñ Ð½Ð° „Cron“"
+
+msgid "Custom notification events"
+msgstr "ПерÑонализирани ÑÑŠÐ±Ð¸Ñ‚Ð¸Ñ Ð·Ð° извеÑÑ‚Ñване"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"ПерÑонализираните нива на извеÑÑ‚Ñване Ñа Ñъщите като нивата за учаÑтие. С "
+"перÑонализираните нива на извеÑÑ‚Ñване ще можете да получавате и извеÑÑ‚Ð¸Ñ Ð·Ð° "
+"избрани ÑъбитиÑ. За да научите повече, прегледайте %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Ðнализ на циклите"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"Ðнализът на циклите дава общ поглед върху това колко време е нужно на една "
+"Ð¸Ð´ÐµÑ Ð´Ð° Ñе превърне в завършена функционалноÑÑ‚ в проекта."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Програмиране"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Проблем"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Планиране"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Издаване"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Преглед и одобрение"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Подготовка за издаване"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "ТеÑтване"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Задайте потребителÑки шаблон, използвайки ÑинтакÑиÑа на „Cron“"
+
+msgid "Delete"
+msgstr "Изтриване"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "ВнедрÑване"
+msgstr[1] "ВнедрÑваниÑ"
+
+msgid "Description"
+msgstr "ОпиÑание"
+
+msgid "Directory name"
+msgstr "Име на папката"
+
+msgid "Don't show again"
+msgstr "Да не Ñе показва повече"
+
+msgid "Download"
+msgstr "СвалÑне"
+
+msgid "Download tar"
+msgstr "СвалÑне във формат „tar“"
+
+msgid "Download tar.bz2"
+msgstr "СвалÑне във формат „tar.bz2“"
+
+msgid "Download tar.gz"
+msgstr "СвалÑне във формат „tar.gz“"
+
+msgid "Download zip"
+msgstr "СвалÑне във формат „zip“"
+
+msgid "DownloadArtifacts|Download"
+msgstr "СвалÑне"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Изпращане на кръпките по е-поща"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Обикновен файл Ñ Ñ€Ð°Ð·Ð»Ð¸ÐºÐ¸"
+
+msgid "DownloadSource|Download"
+msgstr "СвалÑне"
+
+msgid "Edit"
+msgstr "Редактиране"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Редактиране на плана %{id} за Ñхема"
+
+msgid "Every day (at 4:00am)"
+msgstr "Ð’Ñеки ден (в 4 ч. Ñутринта)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Ð’Ñеки меÑец (на 1-во чиÑло, в 4 ч. Ñутринта)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Ð’ÑÑка Ñедмица (в неделÑ, в 4 ч. Ñутринта)"
+
+msgid "Failed to change the owner"
+msgstr "СобÑтвеникът не може да бъде променен"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Планът за Ñхема не може да бъде премахнат"
+
+msgid "Files"
+msgstr "Файлове"
+
+msgid "Filter by commit message"
+msgstr "Филтриране по Ñъобщение"
+
+msgid "Find by path"
+msgstr "ТърÑене по път"
+
+msgid "Find file"
+msgstr "ТърÑене на файл"
+
+msgid "FirstPushedBy|First"
+msgstr "Първо"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "изпращане на промени от"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Разклонение"
+msgstr[1] "РазклонениÑ"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Разклонение на"
+
+msgid "From issue creation until deploy to production"
+msgstr "От Ñъздаването на проблема до внедрÑването в крайната верÑиÑ"
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+"От прилагането на заÑвката за Ñливане до внедрÑването в крайната верÑиÑ"
+
+msgid "Go to your fork"
+msgstr "Към Вашето разклонение"
+
+msgid "GoToYourFork|Fork"
+msgstr "Разклонение"
+
+msgid "Home"
+msgstr "Ðачало"
+
+msgid "Housekeeping successfully started"
+msgstr "ОÑвежаването започна уÑпешно"
+
+msgid "Import repository"
+msgstr "ВнаÑÑне на хранилище"
+
+msgid "Interval Pattern"
+msgstr "Шаблон за интервала"
+
+msgid "Introducing Cycle Analytics"
+msgstr "ПредÑтавÑме Ви анализа на циклите"
+
+msgid "Jobs for last month"
+msgstr "Задачи за поÑÐ»ÐµÐ´Ð½Ð¸Ñ Ð¼ÐµÑец"
+
+msgid "Jobs for last week"
+msgstr "Задачи за поÑледната Ñедмица"
+
+msgid "Jobs for last year"
+msgstr "Задачи за поÑледната година"
+
+msgid "LFSStatus|Disabled"
+msgstr "Изключено"
+
+msgid "LFSStatus|Enabled"
+msgstr "Включено"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "ПоÑÐ»ÐµÐ´Ð½Ð¸Ñ %d ден"
+msgstr[1] "ПоÑледните %d дни"
+
+msgid "Last Pipeline"
+msgstr "ПоÑледна Ñхема"
+
+msgid "Last Update"
+msgstr "ПоÑледна промÑна"
+
+msgid "Last commit"
+msgstr "ПоÑледно подаване"
+
+msgid "Learn more in the"
+msgstr "Ðаучете повече в"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "документациÑта отноÑно планирането на Ñхеми"
+
+msgid "Leave group"
+msgstr "ÐапуÑкане на групата"
+
+msgid "Leave project"
+msgstr "ÐапуÑкане на проекта"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Ограничено до показване на най-много %d Ñъбитие"
+msgstr[1] "Ограничено до показване на най-много %d ÑъбитиÑ"
+
+msgid "Median"
+msgstr "Медиана"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "добавите SSH ключ"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Ðов проблем"
+msgstr[1] "Ðови проблема"
+
+msgid "New Pipeline Schedule"
+msgstr "Ðов план за Ñхема"
+
+msgid "New branch"
+msgstr "Ðов клон"
+
+msgid "New directory"
+msgstr "Ðова папка"
+
+msgid "New file"
+msgstr "Ðов файл"
+
+msgid "New issue"
+msgstr "Ðов проблем"
+
+msgid "New merge request"
+msgstr "Ðова заÑвка за Ñливане"
+
+msgid "New schedule"
+msgstr "Ðов план"
+
+msgid "New snippet"
+msgstr "Ðов отрÑзък"
+
+msgid "New tag"
+msgstr "Ðов етикет"
+
+msgid "No repository"
+msgstr "ÐÑма хранилище"
+
+msgid "No schedules"
+msgstr "ÐÑма планове"
+
+msgid "Not available"
+msgstr "Ðе е налично"
+
+msgid "Not enough data"
+msgstr "ÐÑма доÑтатъчно данни"
+
+msgid "Notification events"
+msgstr "Ð¡ÑŠÐ±Ð¸Ñ‚Ð¸Ñ Ð·Ð° извеÑÑ‚Ñване"
+
+msgid "NotificationEvent|Close issue"
+msgstr "ЗатварÑне на проблем"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "ЗатварÑне на заÑвка за Ñливане"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "ÐеуÑпешно изпълнение на Ñхема"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Прилагане на заÑвка за Ñливане"
+
+msgid "NotificationEvent|New issue"
+msgstr "Ðов проблем"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Ðова заÑвка за Ñливане"
+
+msgid "NotificationEvent|New note"
+msgstr "Ðова бележка"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Преназначаване на проблем"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Преназначаване на заÑвка за Ñливане"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Повторно отварÑне на проблем"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "УÑпешно изпълнение на Ñхема"
+
+msgid "NotificationLevel|Custom"
+msgstr "ПерÑонализирани"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Изключени"
+
+msgid "NotificationLevel|Global"
+msgstr "Глобални"
+
+msgid "NotificationLevel|On mention"
+msgstr "При Ñпоменаване"
+
+msgid "NotificationLevel|Participate"
+msgstr "УчаÑтие"
+
+msgid "NotificationLevel|Watch"
+msgstr "Ðаблюдение"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Филтър"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Отворен"
+
+msgid "Options"
+msgstr "Опции"
+
+msgid "Owner"
+msgstr "СобÑтвеник"
+
+msgid "Pipeline"
+msgstr "Схема"
+
+msgid "Pipeline Health"
+msgstr "СъÑтоÑние"
+
+msgid "Pipeline Schedule"
+msgstr "План за Ñхема"
+
+msgid "Pipeline Schedules"
+msgstr "Планове за Ñхема"
+
+msgid "PipelineCharts|Failed:"
+msgstr "ÐеуÑпешни:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Обща ÑтатиÑтика"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Коефициент на уÑпех:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "УÑпешни:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Общо:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Включено"
+
+msgid "PipelineSchedules|Active"
+msgstr "Ðктивно"
+
+msgid "PipelineSchedules|All"
+msgstr "Ð’Ñички"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Ðеактивно"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Въведете ключ за променливата"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Въведете ÑтойноÑтта на променливата"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Следващо изпълнение"
+
+msgid "PipelineSchedules|None"
+msgstr "Ðищо"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Въведете кратко опиÑание за тази Ñхема"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Премахване на реда за променлива"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Поемане на ÑобÑтвеноÑтта"
+
+msgid "PipelineSchedules|Target"
+msgstr "Цел"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Променливи"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "ÑобÑтвен"
+
+msgid "Pipelines"
+msgstr "Схеми"
+
+msgid "Pipelines charts"
+msgstr "Графики за Ñхемите"
+
+msgid "Pipeline|all"
+msgstr "вÑички"
+
+msgid "Pipeline|success"
+msgstr "уÑпешни"
+
+msgid "Pipeline|with stage"
+msgstr "Ñ ÐµÑ‚Ð°Ð¿"
+
+msgid "Pipeline|with stages"
+msgstr "Ñ ÐµÑ‚Ð°Ð¿Ð¸"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Проектът „%{project_name}“ е добавен в опашката за изтриване."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Проектът „%{project_name}“ беше Ñъздаден уÑпешно."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Проектът „%{project_name}“ беше обновен уÑпешно."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Проектът „%{project_name}“ ще бъде изтрит."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+"ДоÑтъпът до проекта Ñ‚Ñ€Ñбва да бъде даван поотделно на вÑеки потребител."
+
+msgid "Project export could not be deleted."
+msgstr "ИзнеÑените данни на проекта не могат да бъдат изтрити."
+
+msgid "Project export has been deleted."
+msgstr "ИзнеÑените данни на проекта бÑха изтрити."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"Връзката към изнеÑените данни на проекта изгуби давноÑÑ‚. МолÑ, Ñъздайте нова "
+"от наÑтройките на проекта."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"ИзнаÑÑнето на проекта започна. Ще получите връзка към данните по е-поща."
+
+msgid "Project home"
+msgstr "Ðачална Ñтраница на проекта"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Изключено"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Ð’Ñеки Ñ Ð´Ð¾Ñтъп"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Само членовете на екипа"
+
+msgid "ProjectFileTree|Name"
+msgstr "Име"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Ðикога"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Етап"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Графика"
+
+msgid "Read more"
+msgstr "Прочетете повече"
+
+msgid "Readme"
+msgstr "ПрочетиМе"
+
+msgid "RefSwitcher|Branches"
+msgstr "Клонове"
+
+msgid "RefSwitcher|Tags"
+msgstr "Етикети"
+
+msgid "Related Commits"
+msgstr "Свързани подаваниÑ"
+
+msgid "Related Deployed Jobs"
+msgstr "Свързани внедрени задачи"
+
+msgid "Related Issues"
+msgstr "Свързани проблеми"
+
+msgid "Related Jobs"
+msgstr "Свързани задачи"
+
+msgid "Related Merge Requests"
+msgstr "Свързани заÑвки за Ñливане"
+
+msgid "Related Merged Requests"
+msgstr "Свързани приложени заÑвки за Ñливане"
+
+msgid "Remind later"
+msgstr "ÐапомнÑне по-къÑно"
+
+msgid "Remove project"
+msgstr "Премахване на проекта"
+
+msgid "Request Access"
+msgstr "ЗаÑвка за доÑтъп"
+
+msgid "Revert this commit"
+msgstr "ОтмÑна на това подаване"
+
+msgid "Revert this merge request"
+msgstr "ОтмÑна на тази заÑвка за Ñливане"
+
+msgid "Save pipeline schedule"
+msgstr "Запазване на плана за Ñхема"
+
+msgid "Schedule a new pipeline"
+msgstr "Създаване на нов план за Ñхема"
+
+msgid "Scheduling Pipelines"
+msgstr "Планиране на Ñхемите"
+
+msgid "Search branches and tags"
+msgstr "ТърÑете в клоновете и етикетите"
+
+msgid "Select Archive Format"
+msgstr "Изберете формата на архива"
+
+msgid "Select a timezone"
+msgstr "Изберете чаÑова зона"
+
+msgid "Select target branch"
+msgstr "Изберете целеви клон"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Задайте парола на профила Ñи, за да можете да изтеглÑте и изпращате промени "
+"чрез %{protocol}."
+
+msgid "Set up CI"
+msgstr "ÐаÑтройка на ÐИ"
+
+msgid "Set up Koding"
+msgstr "ÐаÑтройка на „Koding“"
+
+msgid "Set up auto deploy"
+msgstr "ÐаÑтройка на авт. внедрÑване"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "зададете парола"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Показване на %d Ñъбитие"
+msgstr[1] "Показване на %d ÑъбитиÑ"
+
+msgid "Source code"
+msgstr "Изходен код"
+
+msgid "StarProject|Star"
+msgstr "Звезда"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Създайте %{new_merge_request} Ñ Ñ‚ÐµÐ·Ð¸ промени"
+
+msgid "Switch branch/tag"
+msgstr "Преминаване към клон/етикет"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Етикет"
+msgstr[1] "Етикети"
+
+msgid "Tags"
+msgstr "Етикети"
+
+msgid "Target Branch"
+msgstr "Целеви клон"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Етапът на програмиране показва времето от първото подаване до Ñъздаването на "
+"заÑвката за Ñливане. Данните ще бъдат добавени тук автоматично Ñлед като "
+"бъде Ñъздадена първата заÑвка за Ñливане."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "СъвкупноÑтта от ÑÑŠÐ±Ð¸Ñ‚Ð¸Ñ Ð´Ð¾Ð±Ð°Ð²ÐµÐ½Ð¸ към данните Ñъбрани за този етап."
+
+msgid "The fork relationship has been removed."
+msgstr "Връзката на разклонение беше премахната."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"Етапът на проблемите показва колко е времето от Ñъздаването на проблем до "
+"определÑнето на целеви етап на проекта за него, или до добавÑнето му в "
+"ÑпиÑък на дъÑката за проблеми. Започнете да добавÑте проблеми, за да видите "
+"данните за този етап."
+
+msgid "The phase of the development lifecycle."
+msgstr "Етапът от цикъла на разработка"
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"Планът за Ñхемата ще изпълнÑва Ñхемите в бъдеще, периодично, за определени "
+"клонове или етикети. Тези планирани Ñхеми ще наÑледÑÑ‚ ограничениÑта на "
+"доÑтъпа до проекта на ÑÐ²ÑŠÑ€Ð·Ð°Ð½Ð¸Ñ Ñ Ñ‚ÑÑ… потребител."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"Етапът на планиране показва колко е времето от преходната Ñтъпка до "
+"изпращането на първото подаване. Това време ще бъде добавено автоматично "
+"Ñлед като изпратите първото Ñи подаване."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"Етапът на издаване показва общото време, което е нужно от Ñъздаването на "
+"проблем до внедрÑването на кода в крайната верÑиÑ. Данните ще бъдат добавени "
+"автоматично Ñлед като завършите един пълен цикъл и превърнете първата Ñи "
+"Ð¸Ð´ÐµÑ Ð² реалноÑÑ‚."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "Ð’Ñеки впиÑан потребител има доÑтъп до проекта."
+
+msgid "The project can be accessed without any authentication."
+msgstr "Ð’Ñеки може да има доÑтъп до проекта, без нужда от удоÑтоверÑване."
+
+msgid "The repository for this project does not exist."
+msgstr "Хранилището за този проект не ÑъщеÑтвува."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Етапът на преглед и одобрение показва времето от Ñъздаването на заÑвката за "
+"Ñливане до прилагането Ñ. Данните ще бъдат добавени автоматично Ñлед като "
+"приложите първата Ñи заÑвка за Ñливане."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Етапът на подготовка за издаване показва времето между прилагането на "
+"заÑвката за Ñливане и внедрÑването на кода в Ñредата на работещата крайна "
+"верÑиÑ. Данните ще бъдат добавени автоматично Ñлед като направите първото Ñи "
+"внедрÑване в крайната верÑиÑ."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"Етапът на теÑтване показва времето, което е нужно на „Gitlab CI“ да изпълни "
+"вÑÑка Ñхема от задачи за Ñвързаната заÑвка за Ñливане. Данните ще бъдат "
+"добавени автоматично Ñлед като приключи изпълнението на първата Ви Ñхема."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "Времето, което отнема вÑеки Ð·Ð°Ð¿Ð¸Ñ Ð¾Ñ‚ данни за ÑÑŠÐ¾Ñ‚Ð²ÐµÑ‚Ð½Ð¸Ñ ÐµÑ‚Ð°Ð¿."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"СтойноÑтта, коÑто Ñе намира в Ñредата на поÑледователноÑтта от наблюдавани "
+"данни. Ðапример: медианата на 3, 5 и 9 е 5, а медианата на 3, 5, 7 и 8 е "
+"(5+7)/2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Това означава, че нÑма да можете да изпращате код, докато не Ñъздадете "
+"празно хранилище или не внеÑете ÑъщеÑтвуващо такова."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Време преди един проблем да бъде планиран за работа"
+
+msgid "Time before an issue starts implementation"
+msgstr "Време преди работата по проблем да започне"
+
+msgid "Time between merge request creation and merge/close"
+msgstr ""
+"Време между Ñъздаване на заÑвка за Ñливане и прилагането/отхвърлÑнето Ñ"
+
+msgid "Time until first merge request"
+msgstr "Време преди първата заÑвка за Ñливане"
+
+msgid "Timeago|%s days ago"
+msgstr "преди %s дни"
+
+msgid "Timeago|%s days remaining"
+msgstr "оÑтават %s дни"
+
+msgid "Timeago|%s hours remaining"
+msgstr "оÑтават %s чаÑа"
+
+msgid "Timeago|%s minutes ago"
+msgstr "преди %s минути"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "оÑтават %s минути"
+
+msgid "Timeago|%s months ago"
+msgstr "преди %s меÑеца"
+
+msgid "Timeago|%s months remaining"
+msgstr "оÑтават %s меÑеца"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "оÑтават %s Ñекунди"
+
+msgid "Timeago|%s weeks ago"
+msgstr "преди %s Ñедмици"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "оÑтават %s Ñедмици"
+
+msgid "Timeago|%s years ago"
+msgstr "преди %s години"
+
+msgid "Timeago|%s years remaining"
+msgstr "оÑтават %s години"
+
+msgid "Timeago|1 day remaining"
+msgstr "оÑтава 1 ден"
+
+msgid "Timeago|1 hour remaining"
+msgstr "оÑтава 1 чаÑ"
+
+msgid "Timeago|1 minute remaining"
+msgstr "оÑтава 1 минута"
+
+msgid "Timeago|1 month remaining"
+msgstr "оÑтава 1 меÑец"
+
+msgid "Timeago|1 week remaining"
+msgstr "оÑтава 1 Ñедмица"
+
+msgid "Timeago|1 year remaining"
+msgstr "оÑтава 1 година"
+
+msgid "Timeago|Past due"
+msgstr "ПроÑрочено"
+
+msgid "Timeago|a day ago"
+msgstr "преди един ден"
+
+msgid "Timeago|a month ago"
+msgstr "преди един меÑец"
+
+msgid "Timeago|a week ago"
+msgstr "преди една Ñедмица"
+
+msgid "Timeago|a while"
+msgstr "преди извеÑтно време"
+
+msgid "Timeago|a year ago"
+msgstr "преди една година"
+
+msgid "Timeago|about %s hours ago"
+msgstr "преди около %s чаÑа"
+
+msgid "Timeago|about a minute ago"
+msgstr "преди около една минута"
+
+msgid "Timeago|about an hour ago"
+msgstr "преди около един чаÑ"
+
+msgid "Timeago|in %s days"
+msgstr "Ñлед %s дни"
+
+msgid "Timeago|in %s hours"
+msgstr "Ñлед %s чаÑа"
+
+msgid "Timeago|in %s minutes"
+msgstr "Ñлед %s минути"
+
+msgid "Timeago|in %s months"
+msgstr "Ñлед %s меÑеца"
+
+msgid "Timeago|in %s seconds"
+msgstr "Ñлед %s Ñекунди"
+
+msgid "Timeago|in %s weeks"
+msgstr "Ñлед %s Ñедмици"
+
+msgid "Timeago|in %s years"
+msgstr "Ñлед %s години"
+
+msgid "Timeago|in 1 day"
+msgstr "Ñлед 1 ден"
+
+msgid "Timeago|in 1 hour"
+msgstr "Ñлед 1 чаÑ"
+
+msgid "Timeago|in 1 minute"
+msgstr "Ñлед 1 минута"
+
+msgid "Timeago|in 1 month"
+msgstr "Ñлед 1 меÑец"
+
+msgid "Timeago|in 1 week"
+msgstr "Ñлед 1 Ñедмица"
+
+msgid "Timeago|in 1 year"
+msgstr "Ñлед 1 година"
+
+msgid "Timeago|less than a minute ago"
+msgstr "преди по-малко от минута"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "чаÑ"
+msgstr[1] "чаÑа"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "мин"
+msgstr[1] "мин"
+
+msgid "Time|s"
+msgstr "Ñек"
+
+msgid "Total Time"
+msgstr "Общо време"
+
+msgid "Total test time for all commits/merges"
+msgstr "Общо време за теÑтване на вÑички подаваниÑ/ÑливаниÑ"
+
+msgid "Unstar"
+msgstr "Без звезда"
+
+msgid "Upload New File"
+msgstr "Качване на нов файл"
+
+msgid "Upload file"
+msgstr "Качване на файл"
+
+msgid "UploadLink|click to upload"
+msgstr "щракнете за качване"
+
+msgid "Use your global notification setting"
+msgstr "Използване на глобалната Ви наÑтройка за извеÑтиÑта"
+
+msgid "View open merge request"
+msgstr "Преглед на отворената заÑвка за Ñливане"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Вътрешен"
+
+msgid "VisibilityLevel|Private"
+msgstr "ЧаÑтен"
+
+msgid "VisibilityLevel|Public"
+msgstr "Публичен"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "ÐеизвеÑтно"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "ИÑкате ли да видите данните? Помолете админиÑтратор за доÑтъп."
+
+msgid "We don't have enough data to show this stage."
+msgstr "ÐÑма доÑтатъчно данни за този етап."
+
+msgid "Withdraw Access Request"
+msgstr "ОттеглÑне на заÑвката за доÑтъп"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ðа път Ñте да премахнете „%{group_name}“. Ðко Ñ Ð¿Ñ€ÐµÐ¼Ð°Ñ…Ð½ÐµÑ‚Ðµ, групата ÐЕ може да бъде възÑтановена! ÐÐИСТИÐРли иÑкате това?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ðа път Ñте да премахнете „%{project_name_with_namespace}“. Ðко го премахнете, той ÐЕ може да бъде възÑтановен!"
+"ÐÐИСТИÐРли иÑкате това?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"Ðа път Ñте да премахнете връзката на разклонението към Ð¾Ñ€Ð¸Ð³Ð¸Ð½Ð°Ð»Ð½Ð¸Ñ Ð¿Ñ€Ð¾ÐµÐºÑ‚, "
+"„%{forked_from_project}“. ÐÐИСТИÐРли иÑкате това?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Ðа път Ñте да прехвърлите „%{project_name_with_namespace}“ към друг "
+"ÑобÑтвеник. ÐÐИСТИÐРли иÑкате това?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Можете да добавÑте файлове Ñамо когато Ñе намирате в клон"
+
+msgid "You have reached your project limit"
+msgstr "Ðе можете да Ñъздавате повече проекти"
+
+msgid "You must sign in to star a project"
+msgstr "ТрÑбва да Ñе впишете, за да отбележите проект ÑÑŠÑ Ð·Ð²ÐµÐ·Ð´Ð°"
+
+msgid "You need permission."
+msgstr "Ðуждаете Ñе от разрешение."
+
+msgid "You will not get any notifications via email"
+msgstr "ÐÑма да получавате никакви извеÑÑ‚Ð¸Ñ Ð¿Ð¾ е-поща"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Ще получавате извеÑÑ‚Ð¸Ñ Ñамо за ÑъбитиÑта, за които желаете"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "Ще получавате извеÑÑ‚Ð¸Ñ Ñамо за нещата, в които учаÑтвате"
+
+msgid "You will receive notifications for any activity"
+msgstr "Ще получавате извеÑÑ‚Ð¸Ñ Ð·Ð° вÑÑка дейноÑÑ‚"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "Ще получавате извеÑÑ‚Ð¸Ñ Ñамо за коментари, в които Ви @Ñпоменават"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"ÐÑма да можете да изтеглÑте или изпращате код в проекта чрез %{protocol}, "
+"докато не %{set_password_link} за профила Ñи"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"ÐÑма да можете да изтеглÑте или изпращате код в проекта чрез SSH, докато не "
+"%{add_ssh_key_link} в профила Ñи"
+
+msgid "Your name"
+msgstr "Вашето име"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "ден"
+msgstr[1] "дни"
+
+msgid "new merge request"
+msgstr "нова заÑвка за Ñливане"
+
+msgid "notification emails"
+msgstr "извеÑÑ‚Ð¸Ñ Ð¿Ð¾ е-поща"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "родител"
+msgstr[1] "родители"
+
diff --git a/locale/bg/gitlab.po.time_stamp b/locale/bg/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/bg/gitlab.po.time_stamp
diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po
new file mode 100644
index 00000000000..ea864091b10
--- /dev/null
+++ b/locale/de/gitlab.po
@@ -0,0 +1,303 @@
+# German translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-05-09 13:44+0200\n"
+"Language-Team: German\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: \n"
+"X-Generator: Poedit 2.0.1\n"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr "Von"
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Commit"
+msgstr[1] "Commits"
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Code"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Issue"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Planung"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Produktiv"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Review"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Staging"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Test"
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Deployment"
+msgstr[1] "Deployments"
+
+msgid "Description"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Filter"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr "Erster"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "gepusht von"
+
+msgid "From issue creation until deploy to production"
+msgstr "Vom Anlegen des Issues bis zum Produktivdeployment"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Vom Merge Request bis zum Produktivdeployment"
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr "Was sind Cycle Analytics?"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Letzter %d Tag"
+msgstr[1] "Letzten %d Tage"
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Eingeschränkt auf maximal %d Ereignis"
+msgstr[1] "Eingeschränkt auf maximal %d Ereignisse"
+
+msgid "Median"
+msgstr "Median"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Neues Issue"
+msgstr[1] "Neue Issues"
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr "Nicht verfügbar"
+
+msgid "Not enough data"
+msgstr "Nicht genügend Daten"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Erstellt"
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr "Pipeline Kennzahlen"
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Phase"
+
+msgid "Read more"
+msgstr "Mehr"
+
+msgid "Related Commits"
+msgstr "Zugehörige Commits"
+
+msgid "Related Deployed Jobs"
+msgstr "Zugehörige Deploymentjobs"
+
+msgid "Related Issues"
+msgstr "Zugehörige Issues"
+
+msgid "Related Jobs"
+msgstr "Zugehörige Jobs"
+
+msgid "Related Merge Requests"
+msgstr "Zugehörige Merge Requests"
+
+msgid "Related Merged Requests"
+msgstr "Zugehörige abgeschlossene Merge Requests"
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Zeige %d Ereignis"
+msgstr[1] "Zeige %d Ereignisse"
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "Ereignisse, die für diese Phase ausgewertet wurden."
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."
+
+msgid "The phase of the development lifecycle."
+msgstr "Die Phase im Entwicklungsprozess."
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Zeit bis ein Issue geplant wird"
+
+msgid "Time before an issue starts implementation"
+msgstr "Zeit bis die Implementierung für ein Issue beginnt"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"
+
+msgid "Time until first merge request"
+msgstr "Zeit bis zum ersten Merge Request"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "h"
+msgstr[1] "h"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "min"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Gesamtzeit"
+
+msgid "Total test time for all commits/merges"
+msgstr "Gesamte Testlaufzeit für alle Commits/Merges"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You need permission."
+msgstr "Sie benötigen Zugriffsrechte."
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "Tag"
+msgstr[1] "Tage"
diff --git a/locale/de/gitlab.po.time_stamp b/locale/de/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/de/gitlab.po.time_stamp
diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po
new file mode 100644
index 00000000000..0ac591d4927
--- /dev/null
+++ b/locale/en/gitlab.po
@@ -0,0 +1,1240 @@
+# English translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-04-12 22:36-0500\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: English\n"
+"Language: en\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural "%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr ""
+
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
+msgstr ""
+
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
+msgstr ""
+
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgstr ""
+
+msgid "%{storage_name}: failed storage access attempt on host:"
+msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "(checkout the %{link} for information on how to install it)."
+msgstr ""
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr ""
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again."
+msgstr ""
+
+msgid "Active"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Are you sure you want to discard your changes?"
+msgstr ""
+
+msgid "Are you sure you want to reset registration token?"
+msgstr ""
+
+msgid "Are you sure you want to reset the health check token?"
+msgstr ""
+
+msgid "Are you sure?"
+msgstr ""
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr ""
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "Browse Directory"
+msgstr ""
+
+msgid "Browse File"
+msgstr ""
+
+msgid "Browse Files"
+msgstr ""
+
+msgid "Browse files"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr ""
+
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Cancel edit"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr ""
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr ""
+
+msgid "ChangeTypeAction|Revert"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "Cherry-pick this commit"
+msgstr ""
+
+msgid "Cherry-pick this merge request"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr ""
+
+msgid "Commit message"
+msgstr ""
+
+msgid "CommitBoxTitle|Commit"
+msgstr ""
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits feed"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Committed by"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "Create new..."
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "CreateTag|Tag"
+msgstr ""
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cron syntax"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr ""
+
+msgid "CycleAnalyticsStage|Code"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Production"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Review"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Test"
+msgstr ""
+
+msgid "Define a custom pattern with cron syntax"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Description"
+msgstr ""
+
+msgid "Details"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Discard changes"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadCommit|Email Patches"
+msgstr ""
+
+msgid "DownloadCommit|Plain Diff"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Every day (at 4:00am)"
+msgstr ""
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr ""
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter by commit message"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr ""
+
+msgid "FirstPushedBy|pushed by"
+msgstr ""
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr ""
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+
+msgid "Git storage health information has been reset"
+msgstr ""
+
+msgid "GitLab Runner section"
+msgstr ""
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Health Check"
+msgstr ""
+
+msgid "Health information can be retrieved from the following endpoints. More information is available"
+msgstr ""
+
+msgid "HealthCheck|Access token is"
+msgstr ""
+
+msgid "HealthCheck|Healthy"
+msgstr ""
+
+msgid "HealthCheck|No Health Problems Detected"
+msgstr ""
+
+msgid "HealthCheck|Unhealthy"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Install a Runner compatible with GitLab CI"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr ""
+
+msgid "Jobs for last month"
+msgstr ""
+
+msgid "Jobs for last week"
+msgstr ""
+
+msgid "Jobs for last year"
+msgstr ""
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Learn more in the"
+msgstr ""
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Median"
+msgstr ""
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "More information is available|here"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New schedule"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr ""
+
+msgid "Not enough data"
+msgstr ""
+
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
+msgid "OfSearchInADropdown|Filter"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr ""
+
+msgid "Options"
+msgstr ""
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr ""
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineCharts|Failed:"
+msgstr ""
+
+msgid "PipelineCharts|Overall statistics"
+msgstr ""
+
+msgid "PipelineCharts|Success ratio:"
+msgstr ""
+
+msgid "PipelineCharts|Successful:"
+msgstr ""
+
+msgid "PipelineCharts|Total:"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Input variable key"
+msgstr ""
+
+msgid "PipelineSchedules|Input variable value"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "PipelineSchedules|Variables"
+msgstr ""
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr ""
+
+msgid "Pipelines"
+msgstr ""
+
+msgid "Pipelines charts"
+msgstr ""
+
+msgid "Pipeline|all"
+msgstr ""
+
+msgid "Pipeline|success"
+msgstr ""
+
+msgid "Pipeline|with stage"
+msgstr ""
+
+msgid "Pipeline|with stages"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project details"
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr ""
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
+msgid "Read more"
+msgstr ""
+
+msgid "Readme"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Related Commits"
+msgstr ""
+
+msgid "Related Deployed Jobs"
+msgstr ""
+
+msgid "Related Issues"
+msgstr ""
+
+msgid "Related Jobs"
+msgstr ""
+
+msgid "Related Merge Requests"
+msgstr ""
+
+msgid "Related Merged Requests"
+msgstr ""
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Repository"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Reset git storage health information"
+msgstr ""
+
+msgid "Reset health check access token"
+msgstr ""
+
+msgid "Reset runners registration token"
+msgstr ""
+
+msgid "Revert this commit"
+msgstr ""
+
+msgid "Revert this merge request"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Scheduling Pipelines"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select existing branch"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Source code"
+msgstr ""
+
+msgid "Specify the following URL during the Runner setup:"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr ""
+
+msgid "Start the Runner!"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr ""
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr ""
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr ""
+
+msgid "The phase of the development lifecycle."
+msgstr ""
+
+msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
+msgstr ""
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr ""
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr ""
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr ""
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr ""
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr ""
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr ""
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr ""
+
+msgid "There are problems accessing Git storage: "
+msgstr ""
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "Time before an issue gets scheduled"
+msgstr ""
+
+msgid "Time before an issue starts implementation"
+msgstr ""
+
+msgid "Time between merge request creation and merge/close"
+msgstr ""
+
+msgid "Time until first merge request"
+msgstr ""
+
+msgid "Timeago|%s days ago"
+msgstr ""
+
+msgid "Timeago|%s days remaining"
+msgstr ""
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+msgid "Timeago|1 day remaining"
+msgstr ""
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+msgid "Timeago|a day ago"
+msgstr ""
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+msgid "Timeago|in %s days"
+msgstr ""
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+msgid "Timeago|in 1 day"
+msgstr ""
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Time|s"
+msgstr ""
+
+msgid "Total Time"
+msgstr ""
+
+msgid "Total test time for all commits/merges"
+msgstr ""
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "UploadLink|click to upload"
+msgstr ""
+
+msgid "Use the following registration token during setup:"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "View open merge request"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
+msgid "VisibilityLevel|Unknown"
+msgstr ""
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr ""
+
+msgid "We don't have enough data to show this stage."
+msgstr ""
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need permission."
+msgstr ""
+
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
+msgstr ""
+
+msgid "Your name"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "new merge request"
+msgstr ""
+
+msgid "notification emails"
+msgstr ""
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] ""
+msgstr[1] ""
diff --git a/locale/en/gitlab.po.time_stamp b/locale/en/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/en/gitlab.po.time_stamp
diff --git a/locale/eo/gitlab.po b/locale/eo/gitlab.po
new file mode 100644
index 00000000000..94ae131186b
--- /dev/null
+++ b/locale/eo/gitlab.po
@@ -0,0 +1,1246 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Lyubomir Vasilev <lyubomirv@abv.bg>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-03 04:44-0400\n"
+"Last-Translator: Lyubomir Vasilev <lyubomirv@abv.bg>\n"
+"Language-Team: Esperanto (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: eo\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s enmetado estis transsaltita, por ne troÅarÄi la sistemon."
+msgstr[1] "%s enmetadoj estis transsaltitaj, por ne troÅarÄi la sistemon."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d enmetado"
+msgstr[1] "%d enmetadoj"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} enmetis %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 ĉenstablo"
+msgstr[1] "%d ĉenstabloj"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Aro da diagramoj pri la seninterrompa integrado"
+
+msgid "About auto deploy"
+msgstr "Pri la aÅ­tomata disponigado"
+
+msgid "Active"
+msgstr "Aktiva"
+
+msgid "Activity"
+msgstr "Aktiveco"
+
+msgid "Add Changelog"
+msgstr "Aldoni liston de ÅanÄoj"
+
+msgid "Add Contribution guide"
+msgstr "Aldoni gvidliniojn por kontribuado"
+
+msgid "Add License"
+msgstr "Aldoni rajtigilon"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Aldonu SSH-Ålosilon al via profilo por ebligi al vi eltiri kaj alpuÅi per "
+"SSH."
+
+msgid "Add new directory"
+msgstr "Aldoni novan dosierujon"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Arkivita projekto! La deponejo permesas nur legadon"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Ĉu vi certe volas forigi ĉi tiun ĉenstablan planon?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Alkroĉu dosieron per Åovmetado aÅ­ %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Branĉo"
+msgstr[1] "Branĉoj"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"La branĉo <strong>%{branch_name}</strong> estis kreita. Por agordi aŭtomatan "
+"disponigadon, bonvolu elekti Yaml-Åablonon por GitLab CI kaj enmeti viajn "
+"ÅanÄojn. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Serĉu branĉon"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Iri al branĉo"
+
+msgid "Branches"
+msgstr "Branĉoj"
+
+msgid "Browse Directory"
+msgstr "Foliumi dosierujon"
+
+msgid "Browse File"
+msgstr "Foliumi dosieron"
+
+msgid "Browse Files"
+msgstr "Foliumi dosierojn"
+
+msgid "Browse files"
+msgstr "Elekti dosierojn"
+
+msgid "ByAuthor|by"
+msgstr "de"
+
+msgid "CI configuration"
+msgstr "Agordoj de seninterrompa integrado"
+
+msgid "Cancel"
+msgstr "Nuligi"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Elekti en branĉon"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Malfari en branĉo"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Precize elekti"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Malfari"
+
+msgid "Changelog"
+msgstr "Listo de ÅanÄoj"
+
+msgid "Charts"
+msgstr "Diagramoj"
+
+msgid "Cherry-pick this commit"
+msgstr "Precize elekti ĉi tiun kunmetadon"
+
+msgid "Cherry-pick this merge request"
+msgstr "Precize elekti ĉi tiun peton pri kunfando"
+
+msgid "CiStatusLabel|canceled"
+msgstr "nuligita"
+
+msgid "CiStatusLabel|created"
+msgstr "kreita"
+
+msgid "CiStatusLabel|failed"
+msgstr "malsukcesa"
+
+msgid "CiStatusLabel|manual action"
+msgstr "mana ago"
+
+msgid "CiStatusLabel|passed"
+msgstr "sukcesa"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "sukcesa, kun avertoj"
+
+msgid "CiStatusLabel|pending"
+msgstr "okazonta"
+
+msgid "CiStatusLabel|skipped"
+msgstr "transsaltita"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "atendanta manan agon"
+
+msgid "CiStatusText|blocked"
+msgstr "blokita"
+
+msgid "CiStatusText|canceled"
+msgstr "nuligita"
+
+msgid "CiStatusText|created"
+msgstr "kreita"
+
+msgid "CiStatusText|failed"
+msgstr "malsukcesa"
+
+msgid "CiStatusText|manual"
+msgstr "mana"
+
+msgid "CiStatusText|passed"
+msgstr "sukcesa"
+
+msgid "CiStatusText|pending"
+msgstr "okazonta"
+
+msgid "CiStatusText|skipped"
+msgstr "transsaltita"
+
+msgid "CiStatus|running"
+msgstr "plenumiÄanta"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Enmetado"
+msgstr[1] "Enmetadoj"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "DaÅ­ro de la enmetadoj por la lastaj 30 enmetadoj"
+
+msgid "Commit message"
+msgstr "MesaÄo pri la enmetado"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Enmeti"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Aldoni „%{file_name}“"
+
+msgid "Commits"
+msgstr "Enmetadoj"
+
+msgid "Commits feed"
+msgstr "Fluo de enmetadoj"
+
+msgid "Commits|History"
+msgstr "Historio"
+
+msgid "Committed by"
+msgstr "Enmetita de"
+
+msgid "Compare"
+msgstr "Kompari"
+
+msgid "Contribution guide"
+msgstr "Gvidlinioj por kontribuado"
+
+msgid "Contributors"
+msgstr "Kontribuantoj"
+
+msgid "Copy URL to clipboard"
+msgstr "Kopii la adreson en la kopibufron"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Kopii la identigilon de la enmetado"
+
+msgid "Create New Directory"
+msgstr "Krei novan dosierujon"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Kreu propran atingoĵetonon en via konto por ebligi al vi eltiri kaj alpuÅi "
+"per %{protocol}."
+
+msgid "Create directory"
+msgstr "Krei dosierujon"
+
+msgid "Create empty bare repository"
+msgstr "Krei malplenan deponejon"
+
+msgid "Create merge request"
+msgstr "Krei peton pri kunfando"
+
+msgid "Create new..."
+msgstr "Krei novan…"
+
+msgid "CreateNewFork|Fork"
+msgstr "Disbranĉigi"
+
+msgid "CreateTag|Tag"
+msgstr "Etikedo"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "kreos propran atingoĵetonon"
+
+msgid "Cron Timezone"
+msgstr "Horzono por Cron"
+
+msgid "Cron syntax"
+msgstr "La sintakso de Cron"
+
+msgid "Custom notification events"
+msgstr "Propraj sciigaj eventoj"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"La propraj sciigaj niveloj estas la samaj kiel la niveloj de partoprenado. "
+"Uzante la proprajn sciigajn nivelojn, vi ricevos ankaÅ­ sciigojn por "
+"elektitaj de vi eventoj. Por lerni pli, bonvolu vidi %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Cikla analizo"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"La cikla analizo esploras kiom da tempo necesas por disvolvi ideon Äis Äi "
+"fariÄos realaĵo."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Programado"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Problemo"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Plano"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Eldonado"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Kontrolo"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Preparo por eldono"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Testado"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Difini propran Åablonon, uzante la sintakson de Cron"
+
+msgid "Delete"
+msgstr "Forigi"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Disponigado"
+msgstr[1] "Disponigadoj"
+
+msgid "Description"
+msgstr "Priskribo"
+
+msgid "Directory name"
+msgstr "Nomo de dosierujo"
+
+msgid "Don't show again"
+msgstr "Ne montru denove"
+
+msgid "Download"
+msgstr "ElÅuti"
+
+msgid "Download tar"
+msgstr "ElÅuti en formato „tar“"
+
+msgid "Download tar.bz2"
+msgstr "ElÅuti en formato „tar.bz2“"
+
+msgid "Download tar.gz"
+msgstr "ElÅuti en formato „tar.gz“"
+
+msgid "Download zip"
+msgstr "ElÅuti en formato „zip“"
+
+msgid "DownloadArtifacts|Download"
+msgstr "ElÅuti"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Sendi flikaĵojn per retpoÅto"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Normala dosiero kun diferencoj"
+
+msgid "DownloadSource|Download"
+msgstr "ElÅuti"
+
+msgid "Edit"
+msgstr "Redakti"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Redakti ĉenstablan planon %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Ĉiutage (je 4:00)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Ĉiumonate (en la 1a de la monato, je 4:00)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Ĉiusemajne (en dimanĉo, je 4:00)"
+
+msgid "Failed to change the owner"
+msgstr "Ne eblas ÅanÄi la posedanton"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Ne eblas forigi la ĉenstablan planon"
+
+msgid "Files"
+msgstr "Dosieroj"
+
+msgid "Filter by commit message"
+msgstr "Filtri per mesaÄo"
+
+msgid "Find by path"
+msgstr "Trovi per dosierindiko"
+
+msgid "Find file"
+msgstr "Trovi dosieron"
+
+msgid "FirstPushedBy|First"
+msgstr "Unue"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "alpuÅita de"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Disbranĉigo"
+msgstr[1] "Disbranĉigoj"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Disbranĉigita el"
+
+msgid "From issue creation until deploy to production"
+msgstr "De la kreado de la problemo Äis la disponigado en la publika versio"
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+"De la kunfandado de la peto pri kunfando Äis la disponigado en la publika "
+"versio"
+
+msgid "Go to your fork"
+msgstr "Al via disbranĉigo"
+
+msgid "GoToYourFork|Fork"
+msgstr "Disbranĉigo"
+
+msgid "Home"
+msgstr "Hejmo"
+
+msgid "Housekeeping successfully started"
+msgstr "La refreÅigo komenciÄis sukcese"
+
+msgid "Import repository"
+msgstr "Enporti deponejon"
+
+msgid "Interval Pattern"
+msgstr "Intervala Åablono"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Ni prezentas al vi la ciklan analizon"
+
+msgid "Jobs for last month"
+msgstr "Taskoj po la lasta monato"
+
+msgid "Jobs for last week"
+msgstr "Taskoj po la lasta semajno"
+
+msgid "Jobs for last year"
+msgstr "Taskoj po la lasta jaro"
+
+msgid "LFSStatus|Disabled"
+msgstr "MalÅaltita"
+
+msgid "LFSStatus|Enabled"
+msgstr "Åœaltita"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "La lasta %d tago"
+msgstr[1] "La lastaj %d tagoj"
+
+msgid "Last Pipeline"
+msgstr "Lasta ĉenstablo"
+
+msgid "Last Update"
+msgstr "Lasta Äisdatigo"
+
+msgid "Last commit"
+msgstr "Lasta enmetado"
+
+msgid "Learn more in the"
+msgstr "Lernu pli en la"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "dokumentado pri ĉenstablaj planoj"
+
+msgid "Leave group"
+msgstr "Forlasi la grupon"
+
+msgid "Leave project"
+msgstr "Forlasi la projekton"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limigita al montrado de ne pli ol %d evento"
+msgstr[1] "Limigita al montrado de ne pli ol %d eventoj"
+
+msgid "Median"
+msgstr "Mediano"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "aldonos SSH-Ålosilon"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nova problemo"
+msgstr[1] "Novaj problemoj"
+
+msgid "New Pipeline Schedule"
+msgstr "Nova ĉenstabla plano"
+
+msgid "New branch"
+msgstr "Nova branĉo"
+
+msgid "New directory"
+msgstr "Nova dosierujo"
+
+msgid "New file"
+msgstr "Nova dosiero"
+
+msgid "New issue"
+msgstr "Nova problemo"
+
+msgid "New merge request"
+msgstr "Nova peto pri kunfando"
+
+msgid "New schedule"
+msgstr "Nova plano"
+
+msgid "New snippet"
+msgstr "Nova kodaĵo"
+
+msgid "New tag"
+msgstr "Nova etikedo"
+
+msgid "No repository"
+msgstr "Ne estas deponejo"
+
+msgid "No schedules"
+msgstr "Ne estas planoj"
+
+msgid "Not available"
+msgstr "Ne disponebla"
+
+msgid "Not enough data"
+msgstr "Ne estas sufiĉe da datenoj"
+
+msgid "Notification events"
+msgstr "Sciigaj eventoj"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Fermi problemon"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Fermi peton pri kunfando"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Malsukcesa ĉenstablo"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Apliki peton pri kunfando"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nova problemo"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nova peto pri kunfando"
+
+msgid "NotificationEvent|New note"
+msgstr "Nova noto"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Reatribui problemon"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Reatribui peton pri kunfando"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Remalfermi problemon"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Sukcesa ĉenstablo"
+
+msgid "NotificationLevel|Custom"
+msgstr "Propraj"
+
+msgid "NotificationLevel|Disabled"
+msgstr "MalÅaltitaj"
+
+msgid "NotificationLevel|Global"
+msgstr "Äœeneralaj"
+
+msgid "NotificationLevel|On mention"
+msgstr "Ĉe mencio"
+
+msgid "NotificationLevel|Participate"
+msgstr "Partoprenado"
+
+msgid "NotificationLevel|Watch"
+msgstr "Rigardado"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtrilo"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Malfermita"
+
+msgid "Options"
+msgstr "Opcioj"
+
+msgid "Owner"
+msgstr "Posedanto"
+
+msgid "Pipeline"
+msgstr "Ĉenstablo"
+
+msgid "Pipeline Health"
+msgstr "Stato"
+
+msgid "Pipeline Schedule"
+msgstr "Ĉenstabla plano"
+
+msgid "Pipeline Schedules"
+msgstr "Ĉenstablaj planoj"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Malsukcesaj:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Äœenerala statistiko"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Proporcio de sukceso:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Sukcesaj:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Totalo:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Åœaltita"
+
+msgid "PipelineSchedules|Active"
+msgstr "Åœaltitaj"
+
+msgid "PipelineSchedules|All"
+msgstr "Ĉiuj"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "MalÅaltitaj"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Entajpu Ålosilon por la variablo"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Entajpu la valoron de la variablo"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Sekvanta plenumo"
+
+msgid "PipelineSchedules|None"
+msgstr "Nenio"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Entajpu mallongan priskribon pri ĉi tiu ĉenstablo"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Forigi la variablan linion"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Akiri posedon"
+
+msgid "PipelineSchedules|Target"
+msgstr "Celo"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variabloj"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Propra"
+
+msgid "Pipelines"
+msgstr "Ĉenstabloj"
+
+msgid "Pipelines charts"
+msgstr "Ĉenstablaj diagramoj"
+
+msgid "Pipeline|all"
+msgstr "ĉiuj"
+
+msgid "Pipeline|success"
+msgstr "sukcesaj"
+
+msgid "Pipeline|with stage"
+msgstr "kun etapo"
+
+msgid "Pipeline|with stages"
+msgstr "kun etapoj"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "La projekto „%{project_name}“ estis alvicigita por forigado."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "La projekto „%{project_name}“ estis sukcese kreita."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "La projekto „%{project_name}“ estis sukcese Äisdatigita."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "La projekto „%{project_name}“ estos forigita."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "Ĉiu uzanto devas akiri propran atingon al la projekto."
+
+msgid "Project export could not be deleted."
+msgstr "Ne eblas forigi la projektan elporton."
+
+msgid "Project export has been deleted."
+msgstr "La projekta elporto estis forigita."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"La ligilo por la projekta elporto eksvalidiÄis. Bonvolu krei novan elporton "
+"en la agordoj de la projekto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"La elporto de la projekto komenciÄis. Vi ricevos ligilon per retpoÅto por "
+"elÅuti la datenoj."
+
+msgid "Project home"
+msgstr "Hejmo de la projekto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "MalÅaltita"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Ĉiu, kiu havas atingon"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Nur skipanoj"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nomo"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Neniam"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapo"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Grafeo"
+
+msgid "Read more"
+msgstr "Legu pli"
+
+msgid "Readme"
+msgstr "LeguMin"
+
+msgid "RefSwitcher|Branches"
+msgstr "Branĉoj"
+
+msgid "RefSwitcher|Tags"
+msgstr "Etikedoj"
+
+msgid "Related Commits"
+msgstr "Rilataj enmetadoj"
+
+msgid "Related Deployed Jobs"
+msgstr "Rilataj disponigitaj taskoj"
+
+msgid "Related Issues"
+msgstr "Rilataj problemoj"
+
+msgid "Related Jobs"
+msgstr "Rilataj taskoj"
+
+msgid "Related Merge Requests"
+msgstr "Rilataj petoj pri kunfando"
+
+msgid "Related Merged Requests"
+msgstr "Rilataj aplikitaj petoj pri kunfando"
+
+msgid "Remind later"
+msgstr "Rememorigu denove"
+
+msgid "Remove project"
+msgstr "Forigi la projekton"
+
+msgid "Request Access"
+msgstr "Peti atingeblon"
+
+msgid "Revert this commit"
+msgstr "Malfari ĉi tiun enmetadon"
+
+msgid "Revert this merge request"
+msgstr "Malfari ĉi tiun peton pri kunfando"
+
+msgid "Save pipeline schedule"
+msgstr "Konservi ĉenstablan planon"
+
+msgid "Schedule a new pipeline"
+msgstr "Plani novan ĉenstablon"
+
+msgid "Scheduling Pipelines"
+msgstr "Planado de la ĉenstabloj"
+
+msgid "Search branches and tags"
+msgstr "Serĉu branĉon aŭ etikedon"
+
+msgid "Select Archive Format"
+msgstr "Elektu formaton de arkivo"
+
+msgid "Select a timezone"
+msgstr "Elektu horzonon"
+
+msgid "Select target branch"
+msgstr "Elektu celan branĉon"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Kreu pasvorton por via konto por ebligi al vi eltiri kaj alpuÅi per "
+"%{protocol}."
+
+msgid "Set up CI"
+msgstr "Agordi SI"
+
+msgid "Set up Koding"
+msgstr "Agordi „Koding“"
+
+msgid "Set up auto deploy"
+msgstr "Agordi aÅ­tomatan disponigadon"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "kreos pasvorton"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Estas montrata %d evento"
+msgstr[1] "Estas montrataj %d eventoj"
+
+msgid "Source code"
+msgstr "Kodo"
+
+msgid "StarProject|Star"
+msgstr "Steligi"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Kreu %{new_merge_request} kun ĉi tiuj ÅanÄoj"
+
+msgid "Switch branch/tag"
+msgstr "Iri al branĉo/etikedo"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Etikedo"
+msgstr[1] "Etikedoj"
+
+msgid "Tags"
+msgstr "Etikedoj"
+
+msgid "Target Branch"
+msgstr "Cela branĉo"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"La etapo de programado montras la tempon de la unua enmetado Äis la kreado "
+"de la peto pri kunfando. La datenoj aldoniÄos aÅ­tomate ĉi tie post kiam vi "
+"kreas la unuan peton pri kunfando."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr ""
+"La aro da eventoj, kiuj estas aldonitaj al la datenoj kolektitaj por la "
+"etapo."
+
+msgid "The fork relationship has been removed."
+msgstr "La rilato de disbranĉigo estis forigita."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"La etapo de la problemo montras kiom la tempo pasas de la kreado de problemo "
+"Äis la atribuado de la problemo al cela etapo de la projekto, aÅ­ al listo "
+"sur la problemtabulo. Komencu krei problemojn por vidi la datenojn por ĉi "
+"tiu etapo."
+
+msgid "The phase of the development lifecycle."
+msgstr "La etapo de la disvolva ciklo."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"La ĉenstabla plano plenumas ĉenstablojn en la estonteco, ripete, por "
+"difinitaj branĉoj aŭ etikedoj. Tiuj planitaj ĉenstabloj heredos la limigitan "
+"atingon al la projekto de la rilata uzanto."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"La etapo de la plano montras la tempon de la antaÅ­a Åtupo Äis la alpuÅado de "
+"via unua enmetado. Ĉi tiu tempo aldoniÄos aÅ­tomate post kiam vi alpuÅas la "
+"unuan enmetadon."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"La etapo de eldonado montras la tutan tempon de la kreado de problemo Äis la "
+"disponigado en la publika versio. La datenoj aldoniÄos aÅ­tomate post kiam vi "
+"kompletigos plenan ciklon de ideo Äis realaĵo."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "Ĉiu ensalutita uzanto havas atingon al la projekto"
+
+msgid "The project can be accessed without any authentication."
+msgstr "Ĉiu povas havi atingon al la projekto, sen ensaluti"
+
+msgid "The repository for this project does not exist."
+msgstr "La deponejo por ĉi tiu projekto ne ekzistas."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"La etapo de la kontrolo montras la tempon de la kreado de la peto pri "
+"kunfando Äis Äia aplikado. La datenoj aldoniÄos aÅ­tomate post kiam vi "
+"aplikos la unuan peton pri kunfando."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"La etapo de preparo por eldono montras la tempon inter la aplikado de la "
+"peto pri kunfando kaj la disponigado de la kodo en la publika versio. La "
+"datenoj aldoniÄos aÅ­tomate post kiam vi faros la unuan disponigadon en la "
+"publika versio."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"La etapo de testado montras kiom da tempo necesas al „GitLab CI“ por plenumi "
+"ĉiujn ĉenstablojn por la rilata peto pri kunfando. La datenoj aldoniÄos "
+"aÅ­tomate post kiam via unua ĉenstablo finiÄos."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "La tempo, kiu estas necesa por ĉiu dateno kolektita de la etapo."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"La valoro, kiu troviÄas en la mezo de aro da rigardataj valoroj. Ekzemple: "
+"inter 3, 5 kaj 9, la mediano estas 5. Inter 3, 5, 7 kaj 8, la mediano estas "
+"(5+7)/2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Ĉi tiu signifas, ke vi ne povos alpuÅi kodon, antaÅ­ ol vi kreos malplenan "
+"deponejon aÅ­ enportos jam ekzistantan."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tempo antaÅ­ problemo estas planita por ellabori"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tempo antaÅ­ la komenco de laboro super problemo"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Tempo inter la kreado de poeto pri kunfando kaj Äia aplikado/fermado"
+
+msgid "Time until first merge request"
+msgstr "Tempo Äis la unua peto pri kunfando"
+
+msgid "Timeago|%s days ago"
+msgstr "antaÅ­ %s tagoj"
+
+msgid "Timeago|%s days remaining"
+msgstr "restas %s tagoj"
+
+msgid "Timeago|%s hours remaining"
+msgstr "restas %s horoj"
+
+msgid "Timeago|%s minutes ago"
+msgstr "antaÅ­ %s minutoj"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "restas %s minutoj"
+
+msgid "Timeago|%s months ago"
+msgstr "antaÅ­ %s monatoj"
+
+msgid "Timeago|%s months remaining"
+msgstr "restas %s monatoj"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "restas %s sekundoj"
+
+msgid "Timeago|%s weeks ago"
+msgstr "antaÅ­ %s semajnoj"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "restas %s semajnoj"
+
+msgid "Timeago|%s years ago"
+msgstr "antaÅ­ %s jaroj"
+
+msgid "Timeago|%s years remaining"
+msgstr "restas %s jaroj"
+
+msgid "Timeago|1 day remaining"
+msgstr "restas 1 tago"
+
+msgid "Timeago|1 hour remaining"
+msgstr "restas 1 horo"
+
+msgid "Timeago|1 minute remaining"
+msgstr "restas 1 minuto"
+
+msgid "Timeago|1 month remaining"
+msgstr "restas 1 monato"
+
+msgid "Timeago|1 week remaining"
+msgstr "restas 1 semajno"
+
+msgid "Timeago|1 year remaining"
+msgstr "restas 1 jaro"
+
+msgid "Timeago|Past due"
+msgstr "MalfruiÄis"
+
+msgid "Timeago|a day ago"
+msgstr "antaÅ­ unu tago"
+
+msgid "Timeago|a month ago"
+msgstr "antaÅ­ unu monato"
+
+msgid "Timeago|a week ago"
+msgstr "antaÅ­ unu semajno"
+
+msgid "Timeago|a while"
+msgstr "antaÅ­ iom da tempo"
+
+msgid "Timeago|a year ago"
+msgstr "antaÅ­ unu jaro"
+
+msgid "Timeago|about %s hours ago"
+msgstr "antaŭ ĉirkaŭ %s horoj"
+
+msgid "Timeago|about a minute ago"
+msgstr "antaŭ ĉirkaŭ unu minuto"
+
+msgid "Timeago|about an hour ago"
+msgstr "antaŭ ĉirkaŭ unu horo"
+
+msgid "Timeago|in %s days"
+msgstr "post %s tagoj"
+
+msgid "Timeago|in %s hours"
+msgstr "post %s horoj"
+
+msgid "Timeago|in %s minutes"
+msgstr "post %s minutoj"
+
+msgid "Timeago|in %s months"
+msgstr "post %s monatoj"
+
+msgid "Timeago|in %s seconds"
+msgstr "post %s sekundoj"
+
+msgid "Timeago|in %s weeks"
+msgstr "post %s semajnoj"
+
+msgid "Timeago|in %s years"
+msgstr "post %s jaroj"
+
+msgid "Timeago|in 1 day"
+msgstr "post 1 tago"
+
+msgid "Timeago|in 1 hour"
+msgstr "post 1 horo"
+
+msgid "Timeago|in 1 minute"
+msgstr "post 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "post 1 monato"
+
+msgid "Timeago|in 1 week"
+msgstr "post 1 semajno"
+
+msgid "Timeago|in 1 year"
+msgstr "post 1 jaro"
+
+msgid "Timeago|less than a minute ago"
+msgstr "antaÅ­ malpli ol minuto"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "h"
+msgstr[1] "h"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "min"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Totala tempo"
+
+msgid "Total test time for all commits/merges"
+msgstr "Totala tempo por la testado de ĉiuj enmetadoj/kunfandoj"
+
+msgid "Unstar"
+msgstr "Malsteligi"
+
+msgid "Upload New File"
+msgstr "AlÅuti novan dosieron"
+
+msgid "Upload file"
+msgstr "AlÅuti dosieron"
+
+msgid "UploadLink|click to upload"
+msgstr "alklaku por alÅuti"
+
+msgid "Use your global notification setting"
+msgstr "Uzi vian Äeneralan agordon pri la sciigoj"
+
+msgid "View open merge request"
+msgstr "Vidi la malfermitan peton pri kunfando"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interna"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privata"
+
+msgid "VisibilityLevel|Public"
+msgstr "Publika"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Nekonata"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr ""
+"Ĉu vi volas vidi la datenojn? Bonvolu peti atingeblon de administranto."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Ne estas sufiĉe da datenoj por montri ĉi tiun etapon."
+
+msgid "Withdraw Access Request"
+msgstr "Nuligi la peton pri atingeblo"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Vi forigos „%{group_name}“. Oni NE POVAS malfari la forigon de grupo! Ĉu vi estas ABSOLUTE certa?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Vi forigos „%{project_name_with_namespace}“. Oni NE POVAS malfari la forigon de projekto! Ĉu vi estas ABSOLUTE certa?"
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "Vi forigos la rilaton de la disbranĉigo al la originala projekto, „%{forked_from_project}“. Ĉu vi estas ABSOLUTE certa?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Vi transigos „%{project_name_with_namespace}“ al alia posedanto. Ĉu vi estas "
+"ABSOLUTE certa?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Oni povas aldoni dosierojn nur kiam oni estas en branĉo"
+
+msgid "You have reached your project limit"
+msgstr "Vi ne povas krei pliajn projektojn"
+
+msgid "You must sign in to star a project"
+msgstr "Oni devas ensaluti por steligi projekton"
+
+msgid "You need permission."
+msgstr "VI bezonas permeson."
+
+msgid "You will not get any notifications via email"
+msgstr "VI ne ricevos sciigojn per retpoÅto"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Vi ricevos sciigojn nur por la eventoj elektitaj de vi"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "Vi ricevos sciigojn nur por la fadenoj, en kiuj vi partoprenis"
+
+msgid "You will receive notifications for any activity"
+msgstr "Vi ricevos sciigojn por ĉiu ago"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "Vi ricevos sciigojn nur por komentoj, en kiuj vi estas @menciita"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Vi ne povos eltiri aÅ­ alpuÅi kodon per %{protocol} antaÅ­ ol vi "
+"%{set_password_link} por via konto"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Vi ne povos eltiri aÅ­ alpuÅi kodon per SSH antaÅ­ ol vi %{add_ssh_key_link} "
+"al via profilo"
+
+msgid "Your name"
+msgstr "Via nomo"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "tago"
+msgstr[1] "tagoj"
+
+msgid "new merge request"
+msgstr "novan peton pri kunfando"
+
+msgid "notification emails"
+msgstr "sciigoj per retpoÅto"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "patro"
+msgstr[1] "patroj"
+
diff --git a/locale/eo/gitlab.po.time_stamp b/locale/eo/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/eo/gitlab.po.time_stamp
diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po
new file mode 100644
index 00000000000..e43fd5fea15
--- /dev/null
+++ b/locale/es/gitlab.po
@@ -0,0 +1,1136 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
+msgstr[1] "%d cambios"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural "%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s cambio adicional ha sido omitido para evitar problemas de rendimiento."
+msgstr[1] "%s cambios adicionales han sido omitidos para evitar problemas de rendimiento."
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} cambió %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Una colección de gráficos sobre Integración Continua"
+
+msgid "About auto deploy"
+msgstr "Acerca del auto despliegue"
+
+msgid "Active"
+msgstr "Activo"
+
+msgid "Activity"
+msgstr "Actividad"
+
+msgid "Add Changelog"
+msgstr "Agregar Changelog"
+
+msgid "Add Contribution guide"
+msgstr "Agregar guía de contribución"
+
+msgid "Add License"
+msgstr "Agregar Licencia"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH."
+
+msgid "Add new directory"
+msgstr "Agregar nuevo directorio"
+
+msgid "Archived project! Repository is read-only"
+msgstr "¡Proyecto archivado! El repositorio es de solo lectura"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "¿Estás seguro que deseas eliminar esta programación del pipeline?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Adjunte un archivo arrastrando &amp; soltando o %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Rama"
+msgstr[1] "Ramas"
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr "La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Buscar ramas"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Cambiar rama"
+
+msgid "Branches"
+msgstr "Ramas"
+
+msgid "Browse Directory"
+msgstr "Examinar directorio"
+
+msgid "Browse File"
+msgstr "Examinar archivo"
+
+msgid "Browse Files"
+msgstr "Examinar archivos"
+
+msgid "Browse files"
+msgstr "Examinar archivos"
+
+msgid "ByAuthor|by"
+msgstr "por"
+
+msgid "CI configuration"
+msgstr "Configuración de CI"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Escoger en la rama"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Revertir en la rama"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Revertir"
+
+msgid "Changelog"
+msgstr "Changelog"
+
+msgid "Charts"
+msgstr "Gráficos"
+
+msgid "Cherry-pick this commit"
+msgstr "Escoger este cambio"
+
+msgid "Cherry-pick this merge request"
+msgstr "Escoger esta solicitud de fusión"
+
+msgid "CiStatusLabel|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusLabel|created"
+msgstr "creado"
+
+msgid "CiStatusLabel|failed"
+msgstr "fallido"
+
+msgid "CiStatusLabel|manual action"
+msgstr "acción manual"
+
+msgid "CiStatusLabel|passed"
+msgstr "pasó"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "pasó con advertencias"
+
+msgid "CiStatusLabel|pending"
+msgstr "pendiente"
+
+msgid "CiStatusLabel|skipped"
+msgstr "omitido"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "esperando acción manual"
+
+msgid "CiStatusText|blocked"
+msgstr "bloqueado"
+
+msgid "CiStatusText|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusText|created"
+msgstr "creado"
+
+msgid "CiStatusText|failed"
+msgstr "fallado"
+
+msgid "CiStatusText|manual"
+msgstr "manual"
+
+msgid "CiStatusText|passed"
+msgstr "pasó"
+
+msgid "CiStatusText|pending"
+msgstr "pendiente"
+
+msgid "CiStatusText|skipped"
+msgstr "omitido"
+
+msgid "CiStatus|running"
+msgstr "en ejecución"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Cambio"
+msgstr[1] "Cambios"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Duración de los cambios en minutos para los últimos 30"
+
+msgid "Commit message"
+msgstr "Mensaje del cambio"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Cambio"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Agregar %{file_name}"
+
+msgid "Commits"
+msgstr "Cambios"
+
+msgid "Commits feed"
+msgstr "Feed de cambios"
+
+msgid "Commits|History"
+msgstr "Historial"
+
+msgid "Committed by"
+msgstr "Enviado por"
+
+msgid "Compare"
+msgstr "Comparar"
+
+msgid "Contribution guide"
+msgstr "Guía de contribución"
+
+msgid "Contributors"
+msgstr "Contribuidores"
+
+msgid "Copy URL to clipboard"
+msgstr "Copiar URL al portapapeles"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copiar SHA del cambio al portapapeles"
+
+msgid "Create New Directory"
+msgstr "Crear Nuevo Directorio"
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr "Crear un token de acceso personal en tu cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Create directory"
+msgstr "Crear directorio"
+
+msgid "Create empty bare repository"
+msgstr "Crear repositorio vacío"
+
+msgid "Create merge request"
+msgstr "Crear solicitud de fusión"
+
+msgid "Create new..."
+msgstr "Crear nuevo..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Bifurcar"
+
+msgid "CreateTag|Tag"
+msgstr "Etiqueta"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "crear un token de acceso personal"
+
+msgid "Cron Timezone"
+msgstr "Zona horaria del Cron"
+
+msgid "Cron syntax"
+msgstr "Sintaxis de Cron"
+
+msgid "Custom notification events"
+msgstr "Eventos de notificaciones personalizadas"
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Cycle Analytics"
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Código"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Incidencia"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Planificación"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Producción"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisión"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Puesta en escena"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Pruebas"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Definir un patrón personalizado con la sintaxis de cron"
+
+msgid "Delete"
+msgstr "Eliminar"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Despliegue"
+msgstr[1] "Despliegues"
+
+msgid "Description"
+msgstr "Descripción"
+
+msgid "Directory name"
+msgstr "Nombre del directorio"
+
+msgid "Don't show again"
+msgstr "No mostrar de nuevo"
+
+msgid "Download"
+msgstr "Descargar"
+
+msgid "Download tar"
+msgstr "Descargar tar"
+
+msgid "Download tar.bz2"
+msgstr "Descargar tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Descargar tar.gz"
+
+msgid "Download zip"
+msgstr "Descargar zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Descargar"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Parches por correo electrónico"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Diferencias en texto plano"
+
+msgid "DownloadSource|Download"
+msgstr "Descargar"
+
+msgid "Edit"
+msgstr "Editar"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Editar Programación del Pipeline %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Todos los días (a las 4:00 am)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Todos los meses (el día 1 a las 4:00 am)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Todas las semanas (domingos a las 4:00 am)"
+
+msgid "Failed to change the owner"
+msgstr "Error al cambiar el propietario"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Error al eliminar la programación del pipeline"
+
+msgid "Files"
+msgstr "Archivos"
+
+msgid "Filter by commit message"
+msgstr "Filtrar por mensaje del cambio"
+
+msgid "Find by path"
+msgstr "Buscar por ruta"
+
+msgid "Find file"
+msgstr "Buscar archivo"
+
+msgid "FirstPushedBy|First"
+msgstr "Primer"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "enviado por"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Bifurcación"
+msgstr[1] "Bifurcaciones"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Bifurcado de"
+
+msgid "From issue creation until deploy to production"
+msgstr "Desde la creación de la incidencia hasta el despliegue a producción"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción"
+
+msgid "Go to your fork"
+msgstr "Ir a tu bifurcación"
+
+msgid "GoToYourFork|Fork"
+msgstr "Bifurcación"
+
+msgid "Home"
+msgstr "Inicio"
+
+msgid "Housekeeping successfully started"
+msgstr "Servicio de limpieza iniciado con éxito"
+
+msgid "Import repository"
+msgstr "Importar repositorio"
+
+msgid "Interval Pattern"
+msgstr "Patrón de intervalo"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Introducción a Cycle Analytics"
+
+msgid "Jobs for last month"
+msgstr "Trabajos del mes pasado"
+
+msgid "Jobs for last week"
+msgstr "Trabajos de la semana pasada"
+
+msgid "Jobs for last year"
+msgstr "Trabajos del año pasado"
+
+msgid "LFSStatus|Disabled"
+msgstr "Deshabilitado"
+
+msgid "LFSStatus|Enabled"
+msgstr "Habilitado"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Último %d día"
+msgstr[1] "Últimos %d días"
+
+msgid "Last Pipeline"
+msgstr "Último Pipeline"
+
+msgid "Last Update"
+msgstr "Última actualización"
+
+msgid "Last commit"
+msgstr "Último cambio"
+
+msgid "Learn more in the"
+msgstr "Más información en la"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentación sobre la programación de pipelines"
+
+msgid "Leave group"
+msgstr "Abandonar grupo"
+
+msgid "Leave project"
+msgstr "Abandonar proyecto"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limitado a mostrar máximo %d evento"
+msgstr[1] "Limitado a mostrar máximo %d eventos"
+
+msgid "Median"
+msgstr "Mediana"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "agregar una clave SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nueva incidencia"
+msgstr[1] "Nuevas incidencias"
+
+msgid "New Pipeline Schedule"
+msgstr "Nueva Programación del Pipeline"
+
+msgid "New branch"
+msgstr "Nueva rama"
+
+msgid "New directory"
+msgstr "Nuevo directorio"
+
+msgid "New file"
+msgstr "Nuevo archivo"
+
+msgid "New issue"
+msgstr "Nueva incidencia"
+
+msgid "New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "New schedule"
+msgstr "Nueva programación"
+
+msgid "New snippet"
+msgstr "Nuevo fragmento de código"
+
+msgid "New tag"
+msgstr "Nueva etiqueta"
+
+msgid "No repository"
+msgstr "No hay repositorio"
+
+msgid "No schedules"
+msgstr "No hay programaciones"
+
+msgid "Not available"
+msgstr "No disponible"
+
+msgid "Not enough data"
+msgstr "No hay suficientes datos"
+
+msgid "Notification events"
+msgstr "Eventos de notificación"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Cerrar incidencia"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Cerrar solicitud de fusión"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Pipeline fallido"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Integrar solicitud de fusión"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nueva incidencia"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "NotificationEvent|New note"
+msgstr "Nueva nota"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Reasignar incidencia"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Reasignar solicitud de fusión"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Reabrir incidencia"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline exitoso"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personalizado"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Deshabilitado"
+
+msgid "NotificationLevel|Global"
+msgstr "Global"
+
+msgid "NotificationLevel|On mention"
+msgstr "Cuando me mencionan"
+
+msgid "NotificationLevel|Participate"
+msgstr "Participación"
+
+msgid "NotificationLevel|Watch"
+msgstr "Vigilancia"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtrar"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Abierto"
+
+msgid "Options"
+msgstr "Opciones"
+
+msgid "Owner"
+msgstr "Propietario"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Estado del Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Programación del Pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Programaciones de los Pipelines"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Fallidos:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Estadísticas generales"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Ratio de éxito"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Exitosos:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Total:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Activado"
+
+msgid "PipelineSchedules|Active"
+msgstr "Activos"
+
+msgid "PipelineSchedules|All"
+msgstr "Todos"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inactivos"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Ingrese nombre de clave"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Ingrese el valor de la variable"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Próxima Ejecución"
+
+msgid "PipelineSchedules|None"
+msgstr "Ninguno"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Proporcione una descripción breve para este pipeline"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Eliminar fila de variable"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Tomar posesión"
+
+msgid "PipelineSchedules|Target"
+msgstr "Destino"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variables"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personalizado"
+
+msgid "Pipelines"
+msgstr "Pipelines"
+
+msgid "Pipelines charts"
+msgstr "Gráficos de los pipelines"
+
+msgid "Pipeline|all"
+msgstr "todos"
+
+msgid "Pipeline|success"
+msgstr "exitósos"
+
+msgid "Pipeline|with stage"
+msgstr "con etapa"
+
+msgid "Pipeline|with stages"
+msgstr "con etapas"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Proyecto ‘%{project_name}’ en cola para eliminación."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Proyecto ‘%{project_name}’ será eliminado."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario."
+
+msgid "Project export could not be deleted."
+msgstr "No se pudo eliminar la exportación del proyecto."
+
+msgid "Project export has been deleted."
+msgstr "La exportación del proyecto ha sido eliminada."
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico."
+
+msgid "Project home"
+msgstr "Inicio del proyecto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Deshabilitada"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Todos con acceso"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Solo miembros del equipo"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nombre"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Nunca"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapa"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Historial gráfico"
+
+msgid "Read more"
+msgstr "Leer más"
+
+msgid "Readme"
+msgstr "Léeme"
+
+msgid "RefSwitcher|Branches"
+msgstr "Ramas"
+
+msgid "RefSwitcher|Tags"
+msgstr "Etiquetas"
+
+msgid "Related Commits"
+msgstr "Cambios Relacionados"
+
+msgid "Related Deployed Jobs"
+msgstr "Trabajos Desplegados Relacionados"
+
+msgid "Related Issues"
+msgstr "Incidencias Relacionadas"
+
+msgid "Related Jobs"
+msgstr "Trabajos Relacionados"
+
+msgid "Related Merge Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Related Merged Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Remind later"
+msgstr "Recordar después"
+
+msgid "Remove project"
+msgstr "Eliminar proyecto"
+
+msgid "Request Access"
+msgstr "Solicitar acceso"
+
+msgid "Revert this commit"
+msgstr "Revertir este cambio"
+
+msgid "Revert this merge request"
+msgstr "Revertir esta solicitud de fusión"
+
+msgid "Save pipeline schedule"
+msgstr "Guardar programación del pipeline"
+
+msgid "Schedule a new pipeline"
+msgstr "Programar un nuevo pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Programación de Pipelines"
+
+msgid "Search branches and tags"
+msgstr "Buscar ramas y etiquetas"
+
+msgid "Select Archive Format"
+msgstr "Seleccionar formato de archivo"
+
+msgid "Select a timezone"
+msgstr "Selecciona una zona horaria"
+
+msgid "Select target branch"
+msgstr "Selecciona una rama de destino"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Set up CI"
+msgstr "Configurar CI"
+
+msgid "Set up Koding"
+msgstr "Configurar Koding"
+
+msgid "Set up auto deploy"
+msgstr "Configurar auto despliegue"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "establecer una contraseña"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Mostrando %d evento"
+msgstr[1] "Mostrando %d eventos"
+
+msgid "Source code"
+msgstr "Código fuente"
+
+msgid "StarProject|Star"
+msgstr "Destacar"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Iniciar una %{new_merge_request} con estos cambios"
+
+msgid "Switch branch/tag"
+msgstr "Cambiar rama/etiqueta"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Etiqueta"
+msgstr[1] "Etiquetas"
+
+msgid "Tags"
+msgstr "Etiquetas"
+
+msgid "Target Branch"
+msgstr "Rama de destino"
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "La colección de eventos agregados a los datos recopilados para esa etapa."
+
+msgid "The fork relationship has been removed."
+msgstr "La relación con la bifurcación se ha eliminado."
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."
+
+msgid "The phase of the development lifecycle."
+msgstr "La etapa del ciclo de vida de desarrollo."
+
+msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
+msgstr "La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado."
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "El proyecto puede ser accedido por cualquier usuario conectado."
+
+msgid "The project can be accessed without any authentication."
+msgstr "El proyecto puede accederse sin ninguna autenticación."
+
+msgid "The repository for this project does not exist."
+msgstr "El repositorio para este proyecto no existe."
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa."
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tiempo antes de que una incidencia sea programada"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tiempo antes de que empieze la implementación de una incidencia"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"
+
+msgid "Time until first merge request"
+msgstr "Tiempo hasta la primera solicitud de fusión"
+
+msgid "Timeago|%s days ago"
+msgstr "hace %s días"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s días restantes"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s horas restantes"
+
+msgid "Timeago|%s minutes ago"
+msgstr "hace %s minutos"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s minutos restantes"
+
+msgid "Timeago|%s months ago"
+msgstr "hace %s meses"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s meses restantes"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s segundos restantes"
+
+msgid "Timeago|%s weeks ago"
+msgstr "hace %s semanas"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s semanas restantes"
+
+msgid "Timeago|%s years ago"
+msgstr "hace %s años"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s años restantes"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 día restante"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 hora restante"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 minuto restante"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 mes restante"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 semana restante"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 año restante"
+
+msgid "Timeago|Past due"
+msgstr "Atrasado"
+
+msgid "Timeago|a day ago"
+msgstr "hace un día"
+
+msgid "Timeago|a month ago"
+msgstr "hace un mes"
+
+msgid "Timeago|a week ago"
+msgstr "hace una semana"
+
+msgid "Timeago|a while"
+msgstr "hace un momento"
+
+msgid "Timeago|a year ago"
+msgstr "hace un año"
+
+msgid "Timeago|about %s hours ago"
+msgstr "hace alrededor de %s horas"
+
+msgid "Timeago|about a minute ago"
+msgstr "hace alrededor de 1 minuto"
+
+msgid "Timeago|about an hour ago"
+msgstr "hace alrededor de 1 hora"
+
+msgid "Timeago|in %s days"
+msgstr "en %s días"
+
+msgid "Timeago|in %s hours"
+msgstr "en %s horas"
+
+msgid "Timeago|in %s minutes"
+msgstr "en %s minutos"
+
+msgid "Timeago|in %s months"
+msgstr "en %s meses"
+
+msgid "Timeago|in %s seconds"
+msgstr "en %s segundos"
+
+msgid "Timeago|in %s weeks"
+msgstr "en %s semanas"
+
+msgid "Timeago|in %s years"
+msgstr "en %s años"
+
+msgid "Timeago|in 1 day"
+msgstr "en 1 día"
+
+msgid "Timeago|in 1 hour"
+msgstr "en 1 hora"
+
+msgid "Timeago|in 1 minute"
+msgstr "en 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "en 1 mes"
+
+msgid "Timeago|in 1 week"
+msgstr "en 1 semana"
+
+msgid "Timeago|in 1 year"
+msgstr "en 1 año"
+
+msgid "Timeago|less than a minute ago"
+msgstr "hace menos de 1 minuto"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "hr"
+msgstr[1] "hrs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tiempo Total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tiempo total de pruebas para todos los cambios o integraciones"
+
+msgid "Unstar"
+msgstr "No Destacar"
+
+msgid "Upload New File"
+msgstr "Subir nuevo archivo"
+
+msgid "Upload file"
+msgstr "Subir archivo"
+
+msgid "UploadLink|click to upload"
+msgstr "Hacer clic para subir"
+
+msgid "Use your global notification setting"
+msgstr "Utiliza tu configuración de notificación global"
+
+msgid "View open merge request"
+msgstr "Ver solicitud de fusión abierta"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interno"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privado"
+
+msgid "VisibilityLevel|Public"
+msgstr "Público"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Desconocido"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador."
+
+msgid "We don't have enough data to show this stage."
+msgstr "No hay suficientes datos para mostrar en esta etapa."
+
+msgid "Withdraw Access Request"
+msgstr "Retirar Solicitud de Acceso"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{group_name}. ¡El grupo eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Solo puedes agregar archivos cuando estás en una rama"
+
+msgid "You have reached your project limit"
+msgstr "Has alcanzado el límite de tu proyecto"
+
+msgid "You must sign in to star a project"
+msgstr "Debes iniciar sesión para destacar un proyecto"
+
+msgid "You need permission."
+msgstr "Necesitas permisos."
+
+msgid "You will not get any notifications via email"
+msgstr "No recibirás ninguna notificación por correo electrónico"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Solo recibirás notificaciones de los eventos que elijas"
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr "Solo recibirás notificaciones de los temas en los que has participado"
+
+msgid "You will receive notifications for any activity"
+msgstr "Recibirás notificaciones por cualquier actividad"
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr "Recibirás notificaciones solo para los comentarios en los que se te mencionó"
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta"
+
+msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
+msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil"
+
+msgid "Your name"
+msgstr "Tu nombre"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "día"
+msgstr[1] "días"
+
+msgid "new merge request"
+msgstr "nueva solicitud de fusión"
+
+msgid "notification emails"
+msgstr "correos electrónicos de notificación"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "padre"
+msgstr[1] "padres"
diff --git a/locale/es/gitlab.po.time_stamp b/locale/es/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/es/gitlab.po.time_stamp
diff --git a/locale/fr/gitlab.po b/locale/fr/gitlab.po
new file mode 100644
index 00000000000..83f31f7a3b2
--- /dev/null
+++ b/locale/fr/gitlab.po
@@ -0,0 +1,1266 @@
+# Dremor <egeorget@opmbx.org>, 2017. #zanata
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Rémy Coutable <remy@rymai.me>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: French (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-03 03:35-0400\n"
+"Last-Translator: Rémy Coutable <remy@rymai.me>\n"
+"Language: fr\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n > 1)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+"%s validation supplémentaire a été masquée afin d'éviter de créer de "
+"problèmes de performances."
+msgstr[1] ""
+"%s validations supplémentaires ont été masquées afin d'éviter de créer de "
+"problèmes de performances."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d validation"
+msgstr[1] "%d validations"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} a validé %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Un ensemble de graphiques concernant l’Intégration Continue (CI)"
+
+msgid "About auto deploy"
+msgstr "A propos de l'auto-déploiement"
+
+msgid "Active"
+msgstr "Actif"
+
+msgid "Activity"
+msgstr "Activité"
+
+msgid "Add Changelog"
+msgstr "Ajouter un journal des modifications"
+
+msgid "Add Contribution guide"
+msgstr "Ajouter un guide de contribution"
+
+msgid "Add License"
+msgstr "Ajouter une licence"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Ajoutez une clef SSH à votre profil pour pouvoir récupérer et pousser par "
+"SSH."
+
+msgid "Add new directory"
+msgstr "Ajouter un nouveau dossier"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Projet archivé ! Le dépôt est en lecture seule"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Êtes-vous sûr de vouloir supprimer ce pipeline programmé"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Attachez un fichier par glisser &amp; déposer ou %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Branche"
+msgstr[1] "Branches"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"La branche <strong>%{branch_name}</strong> a été crée. Pour mettre en place "
+"le déploiement automatisé, sélectionnez un modèle de fichier Yaml pour "
+"l'intégration continue (CI) de GitLab, et validez les modifications. "
+"%{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Rechercher la branche"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Changer de branche"
+
+msgid "Branches"
+msgstr "Branches"
+
+msgid "Browse Directory"
+msgstr "Parcourir le dossier"
+
+msgid "Browse File"
+msgstr "Parcourir le fichier"
+
+msgid "Browse Files"
+msgstr "Parcourir les fichiers"
+
+msgid "Browse files"
+msgstr "Parcourir les fichiers"
+
+msgid "ByAuthor|by"
+msgstr "par"
+
+msgid "CI configuration"
+msgstr "Configuration de l'intégration continue (CI)"
+
+msgid "Cancel"
+msgstr "Annuler"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Sélectionner dans la branche"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Annuler dans la branche"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Sélectionner"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Annuler"
+
+msgid "Changelog"
+msgstr "Journal des modifications"
+
+msgid "Charts"
+msgstr "Graphiques"
+
+msgid "Cherry-pick this commit"
+msgstr "Sélectionner cette validation"
+
+msgid "Cherry-pick this merge request"
+msgstr "Sélectionner cette demande de fusion"
+
+msgid "CiStatusLabel|canceled"
+msgstr "annulé"
+
+msgid "CiStatusLabel|created"
+msgstr "créé"
+
+msgid "CiStatusLabel|failed"
+msgstr "échoué"
+
+msgid "CiStatusLabel|manual action"
+msgstr "action manuelle"
+
+msgid "CiStatusLabel|passed"
+msgstr "passé"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "passé avec des avertissements"
+
+msgid "CiStatusLabel|pending"
+msgstr "en attente"
+
+msgid "CiStatusLabel|skipped"
+msgstr "ignoré"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "en attente d'action manuelle"
+
+msgid "CiStatusText|blocked"
+msgstr "bloqué"
+
+msgid "CiStatusText|canceled"
+msgstr "annulé "
+
+msgid "CiStatusText|created"
+msgstr "créé"
+
+msgid "CiStatusText|failed"
+msgstr "échoué"
+
+msgid "CiStatusText|manual"
+msgstr "manuel"
+
+msgid "CiStatusText|passed"
+msgstr "passé"
+
+msgid "CiStatusText|pending"
+msgstr "en attente"
+
+msgid "CiStatusText|skipped"
+msgstr "ignoré"
+
+msgid "CiStatus|running"
+msgstr "en cours"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Validation"
+msgstr[1] "Validations"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Durée des 30 derniers pipelines en minutes"
+
+msgid "Commit message"
+msgstr "Message de validation"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Validation"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Ajout de %{file_name}"
+
+msgid "Commits"
+msgstr "Validations"
+
+msgid "Commits feed"
+msgstr "Flux de validations"
+
+msgid "Commits|History"
+msgstr "Historique"
+
+msgid "Committed by"
+msgstr "Validé par"
+
+msgid "Compare"
+msgstr "Comparer"
+
+msgid "Contribution guide"
+msgstr "Guilde de contribution"
+
+msgid "Contributors"
+msgstr "Contributeurs"
+
+msgid "Copy URL to clipboard"
+msgstr "Copier l'URL dans le presse-papier"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copier le SHA de la validation"
+
+msgid "Create New Directory"
+msgstr "Créer un nouveau dossier"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Créer un jeton d’accès personnel pour votre compte afin de récupérer ou "
+"pousser par %{protocol}."
+
+msgid "Create directory"
+msgstr "Créer un dossier"
+
+msgid "Create empty bare repository"
+msgstr "Créer un dépôt vide"
+
+msgid "Create merge request"
+msgstr "Créer une demande de fusion"
+
+msgid "Create new..."
+msgstr "Créer nouveau..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Fourcher"
+
+msgid "CreateTag|Tag"
+msgstr "Étiquette"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "Créer un jeton d'accès personnel"
+
+msgid "Cron Timezone"
+msgstr "Fuseau horaire de Cron"
+
+msgid "Cron syntax"
+msgstr "Syntaxe Cron"
+
+msgid "Custom notification events"
+msgstr "Événements de notification personnalisés"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"Le niveau de notification Personnalisé est similaire au niveau Participation."
+" Cependant, il permet également de recevoir des notifications pour des "
+"événements sélectionnés. Pour plus d’information, vous pouvez consulter "
+"%{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Analyseur de cycle"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"L’analyseur de cycle permet d’avoir une vue d’ensemble du temps nécessaire "
+"pour aller d’une idée à sa mise en production pour votre projet."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Code"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Incident"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Planification"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Production"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Examen"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Pré-production"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Test"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Définir un schéma personnalisé avec une syntaxe Cron"
+
+msgid "Delete"
+msgstr "Supprimer"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Déploiement"
+msgstr[1] "Déploiements"
+
+msgid "Description"
+msgstr "Description"
+
+msgid "Directory name"
+msgstr "Nom du dossier"
+
+msgid "Don't show again"
+msgstr "Ne plus montrer"
+
+msgid "Download"
+msgstr "Télécharger"
+
+msgid "Download tar"
+msgstr "Télécharger tar"
+
+msgid "Download tar.bz2"
+msgstr "Télécharger tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Télécharger tar.gz"
+
+msgid "Download zip"
+msgstr "Télécharger zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Télécharger"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Patch email"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Diff simple"
+
+msgid "DownloadSource|Download"
+msgstr "Télécharger"
+
+msgid "Edit"
+msgstr "Éditer"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Éditer le pipeline programmé %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Chaque jour (à 4:00 du matin)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Chaque mois (le 1er à 4:00 du matin)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Chaque semaine (dimanche à 4:00 du matin)"
+
+msgid "Failed to change the owner"
+msgstr "Échec du changement de propriétaire"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Échec de la suppression du pipeline programmé"
+
+msgid "Files"
+msgstr "Fichiers"
+
+msgid "Filter by commit message"
+msgstr "Filtrer par message de validation"
+
+msgid "Find by path"
+msgstr "Rechercher par chemin"
+
+msgid "Find file"
+msgstr "Rechercher un fichier"
+
+msgid "FirstPushedBy|First"
+msgstr "En premier"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "poussé par"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Fourche"
+msgstr[1] "Fourches"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Fourché depuis"
+
+msgid "From issue creation until deploy to production"
+msgstr "Depuis la création de l'incident jusqu'au déploiement en production"
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+"Depuis la fusion de la demande de fusion jusqu'au déploiement en production"
+
+msgid "Go to your fork"
+msgstr "Aller à votre fourche"
+
+msgid "GoToYourFork|Fork"
+msgstr "Fourche"
+
+msgid "Home"
+msgstr "Accueil"
+
+msgid "Housekeeping successfully started"
+msgstr "Maintenance démarrée avec succès"
+
+msgid "Import repository"
+msgstr "Importer un dépôt"
+
+msgid "Interval Pattern"
+msgstr "Schéma d’intervalle"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Introduction à l'analyseur de cycle"
+
+msgid "Jobs for last month"
+msgstr "Tâches pour le mois dernier"
+
+msgid "Jobs for last week"
+msgstr "Tâches pour la semaine dernière"
+
+msgid "Jobs for last year"
+msgstr "Tâches pour l'année dernière"
+
+msgid "LFSStatus|Disabled"
+msgstr "Désactivé"
+
+msgid "LFSStatus|Enabled"
+msgstr "Activé"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Le dernier %d jour"
+msgstr[1] "Les derniers %d jours"
+
+msgid "Last Pipeline"
+msgstr "Dernier pipeline"
+
+msgid "Last Update"
+msgstr "Dernière mise à jour"
+
+msgid "Last commit"
+msgstr "Dernière validation"
+
+msgid "Learn more in the"
+msgstr "En apprendre plus dans le"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentation concernant la programmation de pipeline"
+
+msgid "Leave group"
+msgstr "Quitter le groupe"
+
+msgid "Leave project"
+msgstr "Quitter le projet"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limiter l'affichage au plus à %d évènement"
+msgstr[1] "Limiter l'affichage au plus à %d évènements"
+
+msgid "Median"
+msgstr "Médian"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "ajouter une clef SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nouvel incident"
+msgstr[1] "Nouveaux incidents"
+
+msgid "New Pipeline Schedule"
+msgstr "Nouveau pipeline programmé"
+
+msgid "New branch"
+msgstr "Nouvelle branche"
+
+msgid "New directory"
+msgstr "Nouveau dossier"
+
+msgid "New file"
+msgstr "Nouveau Fichier"
+
+msgid "New issue"
+msgstr "Nouvel incident"
+
+msgid "New merge request"
+msgstr "Nouvelle demande de fusion"
+
+msgid "New schedule"
+msgstr "Nouveau programme"
+
+msgid "New snippet"
+msgstr "Nouvel extrait de code"
+
+msgid "New tag"
+msgstr "Nouvelle étiquette"
+
+msgid "No repository"
+msgstr "Pas de dépôt"
+
+msgid "No schedules"
+msgstr "Aucun programme"
+
+msgid "Not available"
+msgstr "Indisponible"
+
+msgid "Not enough data"
+msgstr "Données insuffisantes"
+
+msgid "Notification events"
+msgstr "Événement de notifications"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Clore l'incident"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Clore la demande de fusion"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Pipeline échoué"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Fusionner le demande de fusion"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nouvel incident"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nouvelle demande de fusion"
+
+msgid "NotificationEvent|New note"
+msgstr "Nouvelle note"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Réassigner l'incident"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Réassigner la demande de fusion"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Ré-ouvrir l'incident"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline réussi"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personnalisé"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Désactivé"
+
+msgid "NotificationLevel|Global"
+msgstr "Global"
+
+msgid "NotificationLevel|On mention"
+msgstr "En cas de mention"
+
+msgid "NotificationLevel|Participate"
+msgstr "Participation"
+
+msgid "NotificationLevel|Watch"
+msgstr "Surveillé"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtre"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Ouvert"
+
+msgid "Options"
+msgstr "Options"
+
+msgid "Owner"
+msgstr "Propriétaire"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Santé du Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Programmation de pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Programmations de pipeline"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Échecs : "
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Statistiques générales"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Ratio de succès : "
+
+msgid "PipelineCharts|Successful:"
+msgstr "Succès :"
+
+msgid "PipelineCharts|Total:"
+msgstr "Total :"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Activé"
+
+msgid "PipelineSchedules|Active"
+msgstr "Actif"
+
+msgid "PipelineSchedules|All"
+msgstr "Tous"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inactif"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Nom de la variable"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Valeur de la variable"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Prochaine exécution"
+
+msgid "PipelineSchedules|None"
+msgstr "Aucune"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Indiquez une courte description"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Supprimer la variable"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "S’approprier"
+
+msgid "PipelineSchedules|Target"
+msgstr "Cible"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variables"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personnalisé"
+
+msgid "Pipelines"
+msgstr "Pipelines"
+
+msgid "Pipelines charts"
+msgstr "Graphique des pipelines"
+
+msgid "Pipeline|all"
+msgstr "Tous"
+
+msgid "Pipeline|success"
+msgstr "Succès"
+
+msgid "Pipeline|with stage"
+msgstr "avec l'étape"
+
+msgid "Pipeline|with stages"
+msgstr "avec les étapes"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Projet '%{project_name}' en attente de suppression."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Projet '%{project_name}' créé avec succès."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Projet '%{project_name}' mis à jour avec succès."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Le projet '%{project_name}' sera supprimé."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+"L’accès au projet doit être explicitement accordé à chaque utilisateur."
+
+msgid "Project export could not be deleted."
+msgstr "L'export du projet n'a pas pu être supprimé."
+
+msgid "Project export has been deleted."
+msgstr "L'export du projet a été supprimé."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"Le lien de l’export du projet a expiré. Merci de générer un nouvel export "
+"depuis les paramètres du projet."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"L'export du projet a débuté. Un lien de téléchargement sera envoyé par "
+"courriel."
+
+msgid "Project home"
+msgstr "Accueil du projet"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Désactivé"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Toute personne ayant accès"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Seulement les membres de l'équipe"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nom"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Jamais"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Étape"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Graphique "
+
+msgid "Read more"
+msgstr "Lire plus"
+
+msgid "Readme"
+msgstr "LisezMoi"
+
+msgid "RefSwitcher|Branches"
+msgstr "Branches"
+
+msgid "RefSwitcher|Tags"
+msgstr "Étiquettes"
+
+msgid "Related Commits"
+msgstr "Validations liés"
+
+msgid "Related Deployed Jobs"
+msgstr "Tâches de déploiement liés"
+
+msgid "Related Issues"
+msgstr "Incidents liés"
+
+msgid "Related Jobs"
+msgstr "Tâches liées"
+
+msgid "Related Merge Requests"
+msgstr "Demandes de fusion liées"
+
+msgid "Related Merged Requests"
+msgstr "Demandes fusionnées liées"
+
+msgid "Remind later"
+msgstr "Me le rappeler ultérieurement"
+
+msgid "Remove project"
+msgstr "Supprimer le projet"
+
+msgid "Request Access"
+msgstr "Demander l'accès"
+
+msgid "Revert this commit"
+msgstr "Annuler cette validation"
+
+msgid "Revert this merge request"
+msgstr "Annuler cette demande de fusion"
+
+msgid "Save pipeline schedule"
+msgstr "Sauvegarder le pipeline programmé"
+
+msgid "Schedule a new pipeline"
+msgstr "Programmer un nouveau pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Programmer des pipelines"
+
+msgid "Search branches and tags"
+msgstr "Rechercher dans les branches et les étiquettes"
+
+msgid "Select Archive Format"
+msgstr "Sélectionnez le format de l'archive"
+
+msgid "Select a timezone"
+msgstr "Sélectionnez un fuseau horaire"
+
+msgid "Select target branch"
+msgstr "Sélectionnez une branche cible"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Définissez un mot de passe pour votre compte pour pouvoir tirer ou pousser "
+"par %{protocol}."
+
+msgid "Set up CI"
+msgstr "Mettre en place l'intégration continue (CI)"
+
+msgid "Set up Koding"
+msgstr "Mettre en place Koding"
+
+msgid "Set up auto deploy"
+msgstr "Mettre en place l’auto-déploiement"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "définir un mot de passe"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Affichage de %d évènement"
+msgstr[1] "Affichage de %d évènements"
+
+msgid "Source code"
+msgstr "Code source"
+
+msgid "StarProject|Star"
+msgstr "S'abonner"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Créer une %{new_merge_request} avec ces changements"
+
+msgid "Switch branch/tag"
+msgstr "Changer de branche / d'étiquette"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Étiquette"
+msgstr[1] "Étiquettes"
+
+msgid "Tags"
+msgstr "Étiquettes"
+
+msgid "Target Branch"
+msgstr "Branche cible"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"L’étape de développement montre le temps entre la première validation et la "
+"création de la demande de fusion. Les données seront automatiquement "
+"ajoutées ici une fois que vous aurez créé votre première demande de fusion."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr ""
+"L’ensemble d’évènements ajoutés aux données récupérées pour cette étape."
+
+msgid "The fork relationship has been removed."
+msgstr "La relation de fourche a été supprimée."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"L'étape des incidents montre le temps nécessaire entre la création d'un "
+"incident et son assignation à un jalon, ou son ajout à une liste d'un "
+"tableau d'incidents. Débutez à créer des incidents pour voir des données "
+"pour cette étape."
+
+msgid "The phase of the development lifecycle."
+msgstr "Les étapes du cycle de développement."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"Les pipelines programmés exécutent des pipelines dans le futur, de façon "
+"répétée, pour les branches et étiquettes spécifiées. Ces pipelines "
+"programmés héritent d’un accès partiel au projet basé sur l’utilisateur qui "
+"leurs est associé."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"L’étape de planification montre le temps entre l’étape précédente et l’envoi "
+"de votre première validation. Ce temps sera automatiquement ajouté quand "
+"vous pousserez votre première validation."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"L’étape de mise en production montre le temps nécessaire entre la création "
+"d’un incident et le déploiement du code en production. Les données seront "
+"automatiquement ajoutées une fois que vous aurez complété le cycle complet, "
+"depuis l’idée jusqu’à la mise en production."
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+"Votre projet peut être accédé par n’importe quel utilisateur authentifié"
+
+msgid "The project can be accessed without any authentication."
+msgstr "Votre projet peut être accédé sans aucune authentification."
+
+msgid "The repository for this project does not exist."
+msgstr "Le dépôt pour ce projet n'existe pas."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"L’étape d’évaluation montre le temps entre la création de la demande de "
+"fusion et la fusion effective de celle-ci. Ces données seront "
+"automatiquement ajoutées après que vous ayez fusionné votre première demande "
+"de fusion."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"L’étape de pré-production indique le temps entre la fusion de la DF et le "
+"déploiement du code dans l’environnent de production. Les données seront "
+"automatiquement ajoutées une fois que vous déploierez en production pour la "
+"première fois."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"L’étape de test montre le temps que le CI de GitLab met pour exécuter chaque "
+"pipeline liés à la demande de fusion. Les données seront automatiquement "
+"ajoutées après que votre premier pipeline s’achèvera."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "Le temps pris par chaque entrée récoltée durant cette étape."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"La valeur située au point médian d’une série de valeur observée. C.à.d., "
+"entre 3, 5, 9, le médian est 5. Entre 3, 5, 7, 8, le médian est (5+7)/2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Cela signifie que vous ne pouvez pas pousser du code tant que vous ne créez "
+"pas un dépôt vide, ou importez une dépôt existant."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Temps avant qu’un incident ne soit planifié"
+
+msgid "Time before an issue starts implementation"
+msgstr "Temps avant que résolution ne débute"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Temps entre la création d'une demande de fusion et sa fusion/clôture"
+
+msgid "Time until first merge request"
+msgstr "Temps jusqu’à la première demande de fusion"
+
+msgid "Timeago|%s days ago"
+msgstr "Il y a %s jours"
+
+msgid "Timeago|%s days remaining"
+msgstr "Il reste %s jours"
+
+msgid "Timeago|%s hours remaining"
+msgstr "Il reste %s heures"
+
+msgid "Timeago|%s minutes ago"
+msgstr "Il y a %s minutes"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "Il reste %s minutes"
+
+msgid "Timeago|%s months ago"
+msgstr "Il y a %s mois"
+
+msgid "Timeago|%s months remaining"
+msgstr "Il reste %s mois"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "Il reste %s secondes"
+
+msgid "Timeago|%s weeks ago"
+msgstr "Il y a %s semaines"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "Il reste %s semaines"
+
+msgid "Timeago|%s years ago"
+msgstr "Il y a %s ans"
+
+msgid "Timeago|%s years remaining"
+msgstr "Il reste %s ans"
+
+msgid "Timeago|1 day remaining"
+msgstr "Il reste un jour"
+
+msgid "Timeago|1 hour remaining"
+msgstr "Il reste une heure"
+
+msgid "Timeago|1 minute remaining"
+msgstr "Il reste une minute"
+
+msgid "Timeago|1 month remaining"
+msgstr "Il reste un mois"
+
+msgid "Timeago|1 week remaining"
+msgstr "Il reste une semaine"
+
+msgid "Timeago|1 year remaining"
+msgstr "Il reste un an"
+
+msgid "Timeago|Past due"
+msgstr "En retard"
+
+msgid "Timeago|a day ago"
+msgstr "Il y a un jour"
+
+msgid "Timeago|a month ago"
+msgstr "Il y a un mois"
+
+msgid "Timeago|a week ago"
+msgstr "Il y a une semaine"
+
+msgid "Timeago|a while"
+msgstr "Il y a un moment"
+
+msgid "Timeago|a year ago"
+msgstr "Il y a un an"
+
+msgid "Timeago|about %s hours ago"
+msgstr "Il y a environ %s heures"
+
+msgid "Timeago|about a minute ago"
+msgstr "Il y a environ une minute"
+
+msgid "Timeago|about an hour ago"
+msgstr "Il y a environ une heure"
+
+msgid "Timeago|in %s days"
+msgstr "Dans %s jours"
+
+msgid "Timeago|in %s hours"
+msgstr "Dans %s heures"
+
+msgid "Timeago|in %s minutes"
+msgstr "Dans %s minutes"
+
+msgid "Timeago|in %s months"
+msgstr "Dans %s mois"
+
+msgid "Timeago|in %s seconds"
+msgstr "Dans %s secondes"
+
+msgid "Timeago|in %s weeks"
+msgstr "Dans %s semaines"
+
+msgid "Timeago|in %s years"
+msgstr "Dans %s années"
+
+msgid "Timeago|in 1 day"
+msgstr "Dans 1 jour"
+
+msgid "Timeago|in 1 hour"
+msgstr "Dans 1 heure"
+
+msgid "Timeago|in 1 minute"
+msgstr "Dans 1 minute"
+
+msgid "Timeago|in 1 month"
+msgstr "Dans 1 mois"
+
+msgid "Timeago|in 1 week"
+msgstr "Dans 1 semaine"
+
+msgid "Timeago|in 1 year"
+msgstr "Dans 1 an"
+
+msgid "Timeago|less than a minute ago"
+msgstr "il y a moins d'une minute"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "hr"
+msgstr[1] "hrs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Temps total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Temps total de test pour toutes les validations/fusions"
+
+msgid "Unstar"
+msgstr "Se désabonner"
+
+msgid "Upload New File"
+msgstr "Téléverser un nouveau fichier"
+
+msgid "Upload file"
+msgstr "Téléverser un fichier"
+
+msgid "UploadLink|click to upload"
+msgstr "Cliquez pour envoyer"
+
+msgid "Use your global notification setting"
+msgstr "Utiliser vos paramètres de notification globaux"
+
+msgid "View open merge request"
+msgstr "Afficher la demande de fusion"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interne"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privé"
+
+msgid "VisibilityLevel|Public"
+msgstr "Public"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Inconnu"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr ""
+"Vous voulez voir les données ? Merci de contacter un administrateur pour en "
+"obtenir l’accès."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Nous n'avons pas suffisamment de données pour afficher cette étape."
+
+msgid "Withdraw Access Request"
+msgstr "Retirer la demande d'accès"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Vous êtes sur le point de supprimer %{group_name}. Les groupes supprimés NE PEUVENT PAS être restaurés ! Êtes vous ABSOLUMENT sûr ?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Vous êtes sur le point de supprimer %{project_name_with_namespace}. Les projets supprimés NE PEUVENT PAS être restaurés ! Êtes vous ABSOLUMENT sûr ?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"Vous allez supprimer la relation de fourche avec le projet source "
+"%{forked_from_project}. Êtes-vous VRAIMENT sûr."
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Vous allez transférer %{project_name_with_namespace} à un nouveau "
+"propriétaire. Êtes vous VRAIMENT sûr ?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Vous ne pouvez ajouter de fichier que dans une branche"
+
+msgid "You have reached your project limit"
+msgstr "Vous avez atteint votre limite de projet"
+
+msgid "You must sign in to star a project"
+msgstr "Vous devez vous identifier pour vous abonner à un projet"
+
+msgid "You need permission."
+msgstr "Vous avez besoin d’une autorisation."
+
+msgid "You will not get any notifications via email"
+msgstr "Vous ne recevrez aucune notification par courriel"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+"Vous ne recevrez de notification que pour les évènements que vous aurez "
+"choisis"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr ""
+"Vous ne recevrez de notification que pour les sujets auxquels vous avez "
+"participé"
+
+msgid "You will receive notifications for any activity"
+msgstr "Vous recevrez des notifications pour n’importe quelles activités"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr ""
+"Vous ne recevrez de notifications que pour les commentaires où vous êtes "
+"@mentionné"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Vous ne pourrez pas récupérer ou pousser de code par %{protocol} tant que "
+"vous n'aurez pas %{set_password_link} pour votre compte"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Vous ne pourrez pas récupérer ou pousser de code par SSH tant que vous "
+"n’aurez pas %{add_ssh_key_link} dans votre profil"
+
+msgid "Your name"
+msgstr "Votre nom"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "jour"
+msgstr[1] "jours"
+
+msgid "new merge request"
+msgstr "nouvelle demande de fusion"
+
+msgid "notification emails"
+msgstr "courriels de notification"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "parent"
+msgstr[1] "parents"
+
diff --git a/locale/fr/gitlab.po.time_stamp b/locale/fr/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/fr/gitlab.po.time_stamp
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
new file mode 100644
index 00000000000..e60504e1395
--- /dev/null
+++ b/locale/gitlab.pot
@@ -0,0 +1,1137 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"PO-Revision-Date: 2017-07-13 12:07-0500\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural "%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr ""
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr ""
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Active"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Archived project! Repository is read-only"
+msgstr ""
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr ""
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr ""
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr ""
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr ""
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "Browse Directory"
+msgstr ""
+
+msgid "Browse File"
+msgstr ""
+
+msgid "Browse Files"
+msgstr ""
+
+msgid "Browse files"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr ""
+
+msgid "CI configuration"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr ""
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr ""
+
+msgid "ChangeTypeAction|Revert"
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "Cherry-pick this commit"
+msgstr ""
+
+msgid "Cherry-pick this merge request"
+msgstr ""
+
+msgid "CiStatusLabel|canceled"
+msgstr ""
+
+msgid "CiStatusLabel|created"
+msgstr ""
+
+msgid "CiStatusLabel|failed"
+msgstr ""
+
+msgid "CiStatusLabel|manual action"
+msgstr ""
+
+msgid "CiStatusLabel|passed"
+msgstr ""
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr ""
+
+msgid "CiStatusLabel|pending"
+msgstr ""
+
+msgid "CiStatusLabel|skipped"
+msgstr ""
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr ""
+
+msgid "CiStatusText|blocked"
+msgstr ""
+
+msgid "CiStatusText|canceled"
+msgstr ""
+
+msgid "CiStatusText|created"
+msgstr ""
+
+msgid "CiStatusText|failed"
+msgstr ""
+
+msgid "CiStatusText|manual"
+msgstr ""
+
+msgid "CiStatusText|passed"
+msgstr ""
+
+msgid "CiStatusText|pending"
+msgstr ""
+
+msgid "CiStatusText|skipped"
+msgstr ""
+
+msgid "CiStatus|running"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr ""
+
+msgid "Commit message"
+msgstr ""
+
+msgid "CommitBoxTitle|Commit"
+msgstr ""
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits feed"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Committed by"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty bare repository"
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "Create new..."
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "CreateTag|Tag"
+msgstr ""
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cron syntax"
+msgstr ""
+
+msgid "Custom notification events"
+msgstr ""
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr ""
+
+msgid "Cycle Analytics"
+msgstr ""
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr ""
+
+msgid "CycleAnalyticsStage|Code"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Production"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Review"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr ""
+
+msgid "CycleAnalyticsStage|Test"
+msgstr ""
+
+msgid "Define a custom pattern with cron syntax"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Description"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Download"
+msgstr ""
+
+msgid "Download tar"
+msgstr ""
+
+msgid "Download tar.bz2"
+msgstr ""
+
+msgid "Download tar.gz"
+msgstr ""
+
+msgid "Download zip"
+msgstr ""
+
+msgid "DownloadArtifacts|Download"
+msgstr ""
+
+msgid "DownloadCommit|Email Patches"
+msgstr ""
+
+msgid "DownloadCommit|Plain Diff"
+msgstr ""
+
+msgid "DownloadSource|Download"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Every day (at 4:00am)"
+msgstr ""
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr ""
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Filter by commit message"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr ""
+
+msgid "FirstPushedBy|pushed by"
+msgstr ""
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr ""
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Home"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr ""
+
+msgid "Jobs for last month"
+msgstr ""
+
+msgid "Jobs for last week"
+msgstr ""
+
+msgid "Jobs for last year"
+msgstr ""
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last Update"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Learn more in the"
+msgstr ""
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Median"
+msgstr ""
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New schedule"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "Not available"
+msgstr ""
+
+msgid "Not enough data"
+msgstr ""
+
+msgid "Notification events"
+msgstr ""
+
+msgid "NotificationEvent|Close issue"
+msgstr ""
+
+msgid "NotificationEvent|Close merge request"
+msgstr ""
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr ""
+
+msgid "NotificationEvent|Merge merge request"
+msgstr ""
+
+msgid "NotificationEvent|New issue"
+msgstr ""
+
+msgid "NotificationEvent|New merge request"
+msgstr ""
+
+msgid "NotificationEvent|New note"
+msgstr ""
+
+msgid "NotificationEvent|Reassign issue"
+msgstr ""
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr ""
+
+msgid "NotificationEvent|Reopen issue"
+msgstr ""
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr ""
+
+msgid "NotificationLevel|Custom"
+msgstr ""
+
+msgid "NotificationLevel|Disabled"
+msgstr ""
+
+msgid "NotificationLevel|Global"
+msgstr ""
+
+msgid "NotificationLevel|On mention"
+msgstr ""
+
+msgid "NotificationLevel|Participate"
+msgstr ""
+
+msgid "NotificationLevel|Watch"
+msgstr ""
+
+msgid "OfSearchInADropdown|Filter"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr ""
+
+msgid "Options"
+msgstr ""
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pipeline"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr ""
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "PipelineCharts|Failed:"
+msgstr ""
+
+msgid "PipelineCharts|Overall statistics"
+msgstr ""
+
+msgid "PipelineCharts|Success ratio:"
+msgstr ""
+
+msgid "PipelineCharts|Successful:"
+msgstr ""
+
+msgid "PipelineCharts|Total:"
+msgstr ""
+
+msgid "PipelineSchedules|Activated"
+msgstr ""
+
+msgid "PipelineSchedules|Active"
+msgstr ""
+
+msgid "PipelineSchedules|All"
+msgstr ""
+
+msgid "PipelineSchedules|Inactive"
+msgstr ""
+
+msgid "PipelineSchedules|Input variable key"
+msgstr ""
+
+msgid "PipelineSchedules|Input variable value"
+msgstr ""
+
+msgid "PipelineSchedules|Next Run"
+msgstr ""
+
+msgid "PipelineSchedules|None"
+msgstr ""
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr ""
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr ""
+
+msgid "PipelineSchedules|Take ownership"
+msgstr ""
+
+msgid "PipelineSchedules|Target"
+msgstr ""
+
+msgid "PipelineSchedules|Variables"
+msgstr ""
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr ""
+
+msgid "Pipelines"
+msgstr ""
+
+msgid "Pipelines charts"
+msgstr ""
+
+msgid "Pipeline|all"
+msgstr ""
+
+msgid "Pipeline|success"
+msgstr ""
+
+msgid "Pipeline|with stage"
+msgstr ""
+
+msgid "Pipeline|with stages"
+msgstr ""
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr ""
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr ""
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr ""
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project export could not be deleted."
+msgstr ""
+
+msgid "Project export has been deleted."
+msgstr ""
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr ""
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+
+msgid "Project home"
+msgstr ""
+
+msgid "ProjectFeature|Disabled"
+msgstr ""
+
+msgid "ProjectFeature|Everyone with access"
+msgstr ""
+
+msgid "ProjectFeature|Only team members"
+msgstr ""
+
+msgid "ProjectFileTree|Name"
+msgstr ""
+
+msgid "ProjectLastActivity|Never"
+msgstr ""
+
+msgid "ProjectLifecycle|Stage"
+msgstr ""
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr ""
+
+msgid "Read more"
+msgstr ""
+
+msgid "Readme"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Related Commits"
+msgstr ""
+
+msgid "Related Deployed Jobs"
+msgstr ""
+
+msgid "Related Issues"
+msgstr ""
+
+msgid "Related Jobs"
+msgstr ""
+
+msgid "Related Merge Requests"
+msgstr ""
+
+msgid "Related Merged Requests"
+msgstr ""
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Request Access"
+msgstr ""
+
+msgid "Revert this commit"
+msgstr ""
+
+msgid "Revert this merge request"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Scheduling Pipelines"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Set up CI"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up auto deploy"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Source code"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr ""
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr ""
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr ""
+
+msgid "The phase of the development lifecycle."
+msgstr ""
+
+msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
+msgstr ""
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr ""
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr ""
+
+msgid "The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+
+msgid "The repository for this project does not exist."
+msgstr ""
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr ""
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr ""
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr ""
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr ""
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr ""
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "Time before an issue gets scheduled"
+msgstr ""
+
+msgid "Time before an issue starts implementation"
+msgstr ""
+
+msgid "Time between merge request creation and merge/close"
+msgstr ""
+
+msgid "Time until first merge request"
+msgstr ""
+
+msgid "Timeago|%s days ago"
+msgstr ""
+
+msgid "Timeago|%s days remaining"
+msgstr ""
+
+msgid "Timeago|%s hours remaining"
+msgstr ""
+
+msgid "Timeago|%s minutes ago"
+msgstr ""
+
+msgid "Timeago|%s minutes remaining"
+msgstr ""
+
+msgid "Timeago|%s months ago"
+msgstr ""
+
+msgid "Timeago|%s months remaining"
+msgstr ""
+
+msgid "Timeago|%s seconds remaining"
+msgstr ""
+
+msgid "Timeago|%s weeks ago"
+msgstr ""
+
+msgid "Timeago|%s weeks remaining"
+msgstr ""
+
+msgid "Timeago|%s years ago"
+msgstr ""
+
+msgid "Timeago|%s years remaining"
+msgstr ""
+
+msgid "Timeago|1 day remaining"
+msgstr ""
+
+msgid "Timeago|1 hour remaining"
+msgstr ""
+
+msgid "Timeago|1 minute remaining"
+msgstr ""
+
+msgid "Timeago|1 month remaining"
+msgstr ""
+
+msgid "Timeago|1 week remaining"
+msgstr ""
+
+msgid "Timeago|1 year remaining"
+msgstr ""
+
+msgid "Timeago|Past due"
+msgstr ""
+
+msgid "Timeago|a day ago"
+msgstr ""
+
+msgid "Timeago|a month ago"
+msgstr ""
+
+msgid "Timeago|a week ago"
+msgstr ""
+
+msgid "Timeago|a while"
+msgstr ""
+
+msgid "Timeago|a year ago"
+msgstr ""
+
+msgid "Timeago|about %s hours ago"
+msgstr ""
+
+msgid "Timeago|about a minute ago"
+msgstr ""
+
+msgid "Timeago|about an hour ago"
+msgstr ""
+
+msgid "Timeago|in %s days"
+msgstr ""
+
+msgid "Timeago|in %s hours"
+msgstr ""
+
+msgid "Timeago|in %s minutes"
+msgstr ""
+
+msgid "Timeago|in %s months"
+msgstr ""
+
+msgid "Timeago|in %s seconds"
+msgstr ""
+
+msgid "Timeago|in %s weeks"
+msgstr ""
+
+msgid "Timeago|in %s years"
+msgstr ""
+
+msgid "Timeago|in 1 day"
+msgstr ""
+
+msgid "Timeago|in 1 hour"
+msgstr ""
+
+msgid "Timeago|in 1 minute"
+msgstr ""
+
+msgid "Timeago|in 1 month"
+msgstr ""
+
+msgid "Timeago|in 1 week"
+msgstr ""
+
+msgid "Timeago|in 1 year"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Time|s"
+msgstr ""
+
+msgid "Total Time"
+msgstr ""
+
+msgid "Total test time for all commits/merges"
+msgstr ""
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "UploadLink|click to upload"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "View open merge request"
+msgstr ""
+
+msgid "VisibilityLevel|Internal"
+msgstr ""
+
+msgid "VisibilityLevel|Private"
+msgstr ""
+
+msgid "VisibilityLevel|Public"
+msgstr ""
+
+msgid "VisibilityLevel|Unknown"
+msgstr ""
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr ""
+
+msgid "We don't have enough data to show this stage."
+msgstr ""
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need permission."
+msgstr ""
+
+msgid "You will not get any notifications via email"
+msgstr ""
+
+msgid "You will only receive notifications for the events you choose"
+msgstr ""
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr ""
+
+msgid "You will receive notifications for any activity"
+msgstr ""
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+msgstr ""
+
+msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
+msgstr ""
+
+msgid "Your name"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "new merge request"
+msgstr ""
+
+msgid "notification emails"
+msgstr ""
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] ""
+msgstr[1] ""
diff --git a/locale/it/gitlab.po b/locale/it/gitlab.po
new file mode 100644
index 00000000000..e719a3988e3
--- /dev/null
+++ b/locale/it/gitlab.po
@@ -0,0 +1,1254 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Paolo Falomo <info@paolofalomo.it>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: Italian (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-07 10:15-0400\n"
+"Last-Translator: Paolo Falomo <info@paolofalomo.it>\n"
+"Language: it\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+"%s commit aggiuntivo è stato omesso per evitare degradi di prestazioni negli "
+"issues."
+msgstr[1] ""
+"%s commit aggiuntivi sono stati omessi per evitare degradi di prestazioni "
+"negli issues."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d commit"
+msgstr[1] "%d commit"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} ha committato %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipeline"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Un insieme di grafici riguardo la Continuous Integration"
+
+msgid "About auto deploy"
+msgstr "Riguardo il rilascio automatico"
+
+msgid "Active"
+msgstr "Attivo"
+
+msgid "Activity"
+msgstr "Attività"
+
+msgid "Add Changelog"
+msgstr "Aggiungi Changelog"
+
+msgid "Add Contribution guide"
+msgstr "Aggiungi Guida per contribuire"
+
+msgid "Add License"
+msgstr "Aggiungi Licenza"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Aggiungi una chiave SSH al tuo profilo per eseguire pull o push tramite SSH"
+
+msgid "Add new directory"
+msgstr "Aggiungi una directory (cartella)"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Progetto archiviato! La Repository è sola-lettura"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Sei sicuro di voler cancellare questa pipeline programmata?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr ""
+"Aggiungi un file tramite trascina &amp; rilascia ( drag &amp; drop) o "
+"%{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Branch"
+msgstr[1] "Branches"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"La branch <strong>%{branch_name}</strong> è stata creata. Per impostare un "
+"rilascio automatico scegli un template CI di Gitlab e committa le tue "
+"modifiche %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Cerca branches"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Cambia branch"
+
+msgid "Branches"
+msgstr "Branches"
+
+msgid "Browse Directory"
+msgstr "Naviga direttori"
+
+msgid "Browse File"
+msgstr "Esplora File"
+
+msgid "Browse Files"
+msgstr "Esplora Files"
+
+msgid "Browse files"
+msgstr "Guarda i files"
+
+msgid "ByAuthor|by"
+msgstr "per"
+
+msgid "CI configuration"
+msgstr "Configurazione CI (Integrazione Continua)"
+
+msgid "Cancel"
+msgstr "Cancella"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Preleva nella branch"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Ripristina nella branch"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Ripristina"
+
+msgid "Changelog"
+msgstr "Changelog"
+
+msgid "Charts"
+msgstr "Grafici"
+
+msgid "Cherry-pick this commit"
+msgstr "Cherry-pick this commit"
+
+msgid "Cherry-pick this merge request"
+msgstr "Cherry-pick questa richiesta di merge"
+
+msgid "CiStatusLabel|canceled"
+msgstr "cancellato"
+
+msgid "CiStatusLabel|created"
+msgstr "creato"
+
+msgid "CiStatusLabel|failed"
+msgstr "fallito"
+
+msgid "CiStatusLabel|manual action"
+msgstr "azione manuale"
+
+msgid "CiStatusLabel|passed"
+msgstr "superata"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "superata con avvisi"
+
+msgid "CiStatusLabel|pending"
+msgstr "in coda"
+
+msgid "CiStatusLabel|skipped"
+msgstr "saltata"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "in attesa di azione manuale"
+
+msgid "CiStatusText|blocked"
+msgstr "bloccata"
+
+msgid "CiStatusText|canceled"
+msgstr "cancellata"
+
+msgid "CiStatusText|created"
+msgstr "creata"
+
+msgid "CiStatusText|failed"
+msgstr "fallita"
+
+msgid "CiStatusText|manual"
+msgstr "manuale"
+
+msgid "CiStatusText|passed"
+msgstr "superata"
+
+msgid "CiStatusText|pending"
+msgstr "in coda"
+
+msgid "CiStatusText|skipped"
+msgstr "saltata"
+
+msgid "CiStatus|running"
+msgstr "in corso"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Commit"
+msgstr[1] "Commits"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Durata del commit (in minuti) per gli ultimi 30 commit"
+
+msgid "Commit message"
+msgstr "Messaggio del commit"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Commit"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Aggiungi %{file_name}"
+
+msgid "Commits"
+msgstr "Commits"
+
+msgid "Commits feed"
+msgstr "Feed dei Commits"
+
+msgid "Commits|History"
+msgstr "Cronologia"
+
+msgid "Committed by"
+msgstr "Committato da "
+
+msgid "Compare"
+msgstr "Confronta"
+
+msgid "Contribution guide"
+msgstr "Guida per contribuire"
+
+msgid "Contributors"
+msgstr "Collaboratori"
+
+msgid "Copy URL to clipboard"
+msgstr "Copia URL negli appunti"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copia l'SHA del commit negli appunti"
+
+msgid "Create New Directory"
+msgstr "Crea una nuova cartella"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Creare un token di accesso sul tuo account per eseguire pull o push tramite "
+"%{protocol}"
+
+msgid "Create directory"
+msgstr "Crea cartella"
+
+msgid "Create empty bare repository"
+msgstr "Crea una repository vuota"
+
+msgid "Create merge request"
+msgstr "Crea una richiesta di merge"
+
+msgid "Create new..."
+msgstr "Crea nuovo..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Fork"
+
+msgid "CreateTag|Tag"
+msgstr "Tag"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "Crea token d'accesso personale"
+
+msgid "Cron Timezone"
+msgstr "Cron Timezone"
+
+msgid "Cron syntax"
+msgstr "Sintassi Cron"
+
+msgid "Custom notification events"
+msgstr "Eventi-Notifica personalizzati"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"I livelli di notifica personalizzati sono uguali a quelli di partecipazione. "
+"Con i livelli di notifica personalizzati riceverai anche notifiche per gli "
+"eventi da te scelti %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Statistiche Cicliche"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"L'Analisi Ciclica fornisce una panoramica sul tempo che trascorre tra l'idea "
+"ed il rilascio in produzione del tuo progetto"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Codice"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Issue"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Pianificazione"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Produzione"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisione"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Pre-rilascio"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Test"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Definisci un patter personalizzato mediante la sintassi cron"
+
+msgid "Delete"
+msgstr "Elimina"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Rilascio"
+msgstr[1] "Rilasci"
+
+msgid "Description"
+msgstr "Descrizione"
+
+msgid "Directory name"
+msgstr "Nome cartella"
+
+msgid "Don't show again"
+msgstr "Non mostrare più"
+
+msgid "Download"
+msgstr "Scarica"
+
+msgid "Download tar"
+msgstr "Scarica tar"
+
+msgid "Download tar.bz2"
+msgstr "Scarica tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Scarica tar.gz"
+
+msgid "Download zip"
+msgstr "Scarica zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Scarica"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Email Patches"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Differenze"
+
+msgid "DownloadSource|Download"
+msgstr "Scarica"
+
+msgid "Edit"
+msgstr "Modifica"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Cambia programmazione della pipeline %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Ogni giorno (alle 4 del mattino)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Ogni primo giorno del mese (alle 4 del mattino)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Ogni settimana (Di domenica alle 4 del mattino)"
+
+msgid "Failed to change the owner"
+msgstr "Impossibile cambiare owner"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Impossibile rimuovere la pipeline pianificata"
+
+msgid "Files"
+msgstr "Files"
+
+msgid "Filter by commit message"
+msgstr "Filtra per messaggio di commit"
+
+msgid "Find by path"
+msgstr "Trova in percorso"
+
+msgid "Find file"
+msgstr "Trova file"
+
+msgid "FirstPushedBy|First"
+msgstr "Primo"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "Push di"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Fork"
+msgstr[1] "Forks"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Fork da"
+
+msgid "From issue creation until deploy to production"
+msgstr "Dalla creazione di un issue fino al rilascio in produzione"
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+"Dalla richiesta di merge fino effettua il merge fino al rilascio in "
+"produzione"
+
+msgid "Go to your fork"
+msgstr "Vai il tuo fork"
+
+msgid "GoToYourFork|Fork"
+msgstr "Fork"
+
+msgid "Home"
+msgstr "Home"
+
+msgid "Housekeeping successfully started"
+msgstr "Housekeeping iniziato con successo"
+
+msgid "Import repository"
+msgstr "Importa repository"
+
+msgid "Interval Pattern"
+msgstr "Intervallo di Pattern"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Introduzione delle Analisi Cicliche"
+
+msgid "Jobs for last month"
+msgstr "Jobs dell'ultimo mese"
+
+msgid "Jobs for last week"
+msgstr "Jobs dell'ultima settimana"
+
+msgid "Jobs for last year"
+msgstr "Jobs dell'ultimo anno"
+
+msgid "LFSStatus|Disabled"
+msgstr "Disabilitato"
+
+msgid "LFSStatus|Enabled"
+msgstr "Abilitato"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "L'ultimo %d giorno"
+msgstr[1] "Gli ultimi %d giorni"
+
+msgid "Last Pipeline"
+msgstr "Ultima Pipeline"
+
+msgid "Last Update"
+msgstr "Ultimo Aggiornamento"
+
+msgid "Last commit"
+msgstr "Ultimo Commit"
+
+msgid "Learn more in the"
+msgstr "Leggi di più su"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentazione sulla pianificazione delle pipelines"
+
+msgid "Leave group"
+msgstr "Abbandona il gruppo"
+
+msgid "Leave project"
+msgstr "Abbandona il progetto"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limita visualizzazione %d d'evento"
+msgstr[1] "Limita visualizzazione %d di eventi"
+
+msgid "Median"
+msgstr "Mediano"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "aggiungi una chiave SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nuovo Issue"
+msgstr[1] "Nuovi Issues"
+
+msgid "New Pipeline Schedule"
+msgstr "Nuova pianificazione Pipeline"
+
+msgid "New branch"
+msgstr "Nuova Branch"
+
+msgid "New directory"
+msgstr "Nuova directory"
+
+msgid "New file"
+msgstr "Nuovo file"
+
+msgid "New issue"
+msgstr "Nuovo Issue"
+
+msgid "New merge request"
+msgstr "Nuova richiesta di merge"
+
+msgid "New schedule"
+msgstr "Nuova pianficazione"
+
+msgid "New snippet"
+msgstr "Nuovo snippet"
+
+msgid "New tag"
+msgstr "Nuovo tag"
+
+msgid "No repository"
+msgstr "Nessuna Repository"
+
+msgid "No schedules"
+msgstr "Nessuna pianificazione"
+
+msgid "Not available"
+msgstr "Non disponibile"
+
+msgid "Not enough data"
+msgstr "Dati insufficienti "
+
+msgid "Notification events"
+msgstr "Notifica eventi"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Chiudi issue"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Chiudi richiesta di merge"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Pipeline fallita"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Completa la richiesta di merge"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nuovo issue"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nuova richiesta di merge"
+
+msgid "NotificationEvent|New note"
+msgstr "Nuova nota"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Riassegna issue"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Riassegna richiesta di Merge"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Riapri issue"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline Completata"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personalizzato"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Disabilitato"
+
+msgid "NotificationLevel|Global"
+msgstr "Globale"
+
+msgid "NotificationLevel|On mention"
+msgstr "Se menzionato"
+
+msgid "NotificationLevel|Participate"
+msgstr "Partecipa"
+
+msgid "NotificationLevel|Watch"
+msgstr "Osserva"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtra"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Aperto"
+
+msgid "Options"
+msgstr "Opzioni"
+
+msgid "Owner"
+msgstr "Owner"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Stato della Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Pianificazione Pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Pianificazione multipla Pipeline"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Fallita:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Statistiche riassuntive"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Percentuale di successo"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Completata:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Totale:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Attivata"
+
+msgid "PipelineSchedules|Active"
+msgstr "Attiva"
+
+msgid "PipelineSchedules|All"
+msgstr "Tutto"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inattiva"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Chiave della variabile"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Valore della variabile"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Prossima esecuzione"
+
+msgid "PipelineSchedules|None"
+msgstr "Nessuna"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Fornisci una breve descrizione per questa pipeline"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Rimuovi riga della variabile"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Prendi possesso"
+
+msgid "PipelineSchedules|Target"
+msgstr "Target"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variabili"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personalizzato"
+
+msgid "Pipelines"
+msgstr "Pipeline"
+
+msgid "Pipelines charts"
+msgstr "Grafici pipeline"
+
+msgid "Pipeline|all"
+msgstr "tutto"
+
+msgid "Pipeline|success"
+msgstr "successo"
+
+msgid "Pipeline|with stage"
+msgstr "con stadio"
+
+msgid "Pipeline|with stages"
+msgstr "con più stadi"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Il Progetto '%{project_name}' in coda di eliminazione."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Il Progetto '%{project_name}' è stato creato con successo."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Il Progetto '%{project_name}' è stato aggiornato con successo."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Il Progetto '%{project_name}' verrà eliminato"
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "L'accesso al progetto dev'esser fornito esplicitamente ad ogni utente"
+
+msgid "Project export could not be deleted."
+msgstr "L'esportazione del progetto non può essere eliminata."
+
+msgid "Project export has been deleted."
+msgstr "L'esportazione del progetto è stata eliminata."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"Il link d'esportazione del progetto è scaduto. Genera una nuova esportazione "
+"dalle impostazioni del tuo progetto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"Esportazione del progetto iniziata. Un link di download sarà inviato via "
+"email."
+
+msgid "Project home"
+msgstr "Home di progetto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Disabilitato"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Chiunque con accesso"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Solo i membri del team"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nome"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Mai"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Stadio"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Grafico"
+
+msgid "Read more"
+msgstr "Vedi altro"
+
+msgid "Readme"
+msgstr "Leggimi"
+
+msgid "RefSwitcher|Branches"
+msgstr "Branches"
+
+msgid "RefSwitcher|Tags"
+msgstr "Tags"
+
+msgid "Related Commits"
+msgstr "Commit correlati"
+
+msgid "Related Deployed Jobs"
+msgstr "Attività di Rilascio Correlate"
+
+msgid "Related Issues"
+msgstr "Issues Correlati"
+
+msgid "Related Jobs"
+msgstr "Attività Correlate"
+
+msgid "Related Merge Requests"
+msgstr "Richieste di Merge Correlate"
+
+msgid "Related Merged Requests"
+msgstr "Richieste di Merge Completate Correlate"
+
+msgid "Remind later"
+msgstr "Ricordamelo più tardi"
+
+msgid "Remove project"
+msgstr "Rimuovi progetto"
+
+msgid "Request Access"
+msgstr "Richiedi accesso"
+
+msgid "Revert this commit"
+msgstr "Ripristina questo commit"
+
+msgid "Revert this merge request"
+msgstr "Ripristina questa richiesta di merge"
+
+msgid "Save pipeline schedule"
+msgstr "Salva pianificazione pipeline"
+
+msgid "Schedule a new pipeline"
+msgstr "Pianifica una nuova Pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Pianificazione pipelines"
+
+msgid "Search branches and tags"
+msgstr "Ricerca branches e tags"
+
+msgid "Select Archive Format"
+msgstr "Seleziona formato d'archivio"
+
+msgid "Select a timezone"
+msgstr "Seleziona una timezone"
+
+msgid "Select target branch"
+msgstr "Seleziona una branch di destinazione"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Establezca una contraseña en su cuenta para actualizar o enviar a través de "
+"%{protocol}."
+
+msgid "Set up CI"
+msgstr "Configura CI"
+
+msgid "Set up Koding"
+msgstr "Configura Koding"
+
+msgid "Set up auto deploy"
+msgstr "Configura il rilascio automatico"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "imposta una password"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Visualizza %d evento"
+msgstr[1] "Visualizza %d eventi"
+
+msgid "Source code"
+msgstr "Codice Sorgente"
+
+msgid "StarProject|Star"
+msgstr "Star"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "inizia una %{new_merge_request} con queste modifiche"
+
+msgid "Switch branch/tag"
+msgstr "Cambia branch/tag"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Tag"
+msgstr[1] "Tags"
+
+msgid "Tags"
+msgstr "Tags"
+
+msgid "Target Branch"
+msgstr "Branch di destinazione"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Lo stadio di programmazione mostra il tempo trascorso dal primo commit alla "
+"creazione di una richiesta di merge (MR). I dati saranno aggiunti una volta "
+"che avrai creato la prima richiesta di merge."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "L'insieme di eventi aggiunti ai dati raccolti per quello stadio."
+
+msgid "The fork relationship has been removed."
+msgstr "La relazione del fork è stata rimossa"
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"Lo stadio di Issue mostra il tempo che impiega un issue ad esser correlato "
+"ad una Milestone, o ad esser aggiunto ad una tua Lavagna. Inizia la "
+"creazione di problemi per visualizzare i dati in questo stadio."
+
+msgid "The phase of the development lifecycle."
+msgstr "Il ciclo vitale della fase di sviluppo."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"Le pipelines pianificate vengono eseguite nel futuro, ripetitivamente, per "
+"specifici tag o branch ed ereditano restrizioni di progetto basate "
+"sull'utente ad esse associato."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"Lo stadio di pianificazione mostra il tempo trascorso dal primo commit al "
+"suo step precedente. Questo periodo sarà disponibile automaticamente nel "
+"momento in cui farai il primo commit."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"Lo stadio di produzione mostra il tempo totale che trascorre tra la "
+"creazione di un issue il suo rilascio (inteso come codice) in produzione. "
+"Questo dato sarà disponibile automaticamente nel momento in cui avrai "
+"completato l'intero processo ideale del ciclo di produzione"
+
+msgid "The project can be accessed by any logged in user."
+msgstr "Qualunque utente autenticato può accedere a questo progetto."
+
+msgid "The project can be accessed without any authentication."
+msgstr ""
+"Chiunque può accedere a questo progetto (senza alcuna autenticazione)."
+
+msgid "The repository for this project does not exist."
+msgstr "La repository di questo progetto non esiste."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Lo stadio di revisione mostra il tempo tra una richiesta di merge al suo "
+"svolgimento effettivo. Questo dato sarà disponibile appena avrai completato "
+"una MR (Merger Request)"
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Lo stadio di pre-rilascio mostra il tempo che trascorre da una MR (Richiesta "
+"di Merge) completata al suo rilascio in ambiente di produzione. Questa "
+"informazione sarà disponibile dal tuo primo rilascio in produzione"
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"Lo stadio di test mostra il tempo che ogni Pipeline impiega per essere "
+"eseguita in ogni Richiesta di Merge correlata. L'informazione sarà "
+"disponibile automaticamente quando la tua prima Pipeline avrà finito d'esser "
+"eseguita."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr ""
+"Il tempo aggregato relativo eventi/data entry raccolto in quello stadio."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"Il valore falsato nel mezzo di una serie di dati osservati. ES: tra 3,5,9 il "
+"mediano è 5. Tra 3,5,7,8 il mediano è (5+7)/2 quindi 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Questo significa che non è possibile effettuare push di codice fino a che "
+"non crei una repository vuota o ne importi una esistente"
+
+msgid "Time before an issue gets scheduled"
+msgstr "Il tempo che impiega un issue per esser pianificato"
+
+msgid "Time before an issue starts implementation"
+msgstr "Il tempo che impiega un issue per esser implementato"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Il tempo tra la creazione di una richiesta di merge ed il merge/close"
+
+msgid "Time until first merge request"
+msgstr "Il tempo fino alla prima richiesta di merge"
+
+msgid "Timeago|%s days ago"
+msgstr "%s giorni fa"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s giorni rimanenti"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s ore rimanenti"
+
+msgid "Timeago|%s minutes ago"
+msgstr "%s minuti fa"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s minuti rimanenti"
+
+msgid "Timeago|%s months ago"
+msgstr "%s minuti fa"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s mesi rimanenti"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s secondi rimanenti"
+
+msgid "Timeago|%s weeks ago"
+msgstr "%s settimane fa"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s settimane rimanenti"
+
+msgid "Timeago|%s years ago"
+msgstr "%s anni fa"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s anni rimanenti"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 giorno rimanente"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 ora rimanente"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 minuto rimanente"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 mese rimanente"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 settimana rimanente"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 anno rimanente"
+
+msgid "Timeago|Past due"
+msgstr "Entro"
+
+msgid "Timeago|a day ago"
+msgstr "un giorno fa"
+
+msgid "Timeago|a month ago"
+msgstr "un mese fa"
+
+msgid "Timeago|a week ago"
+msgstr "una settimana fa"
+
+msgid "Timeago|a while"
+msgstr "poco fa"
+
+msgid "Timeago|a year ago"
+msgstr "un anno fa"
+
+msgid "Timeago|about %s hours ago"
+msgstr "circa %s ore fa"
+
+msgid "Timeago|about a minute ago"
+msgstr "circa un minuto fa"
+
+msgid "Timeago|about an hour ago"
+msgstr "circa un ora fa"
+
+msgid "Timeago|in %s days"
+msgstr "in %s giorni"
+
+msgid "Timeago|in %s hours"
+msgstr "in %s ore"
+
+msgid "Timeago|in %s minutes"
+msgstr "in %s minuti"
+
+msgid "Timeago|in %s months"
+msgstr "in %s mesi"
+
+msgid "Timeago|in %s seconds"
+msgstr "in %s secondi"
+
+msgid "Timeago|in %s weeks"
+msgstr "in %s settimane"
+
+msgid "Timeago|in %s years"
+msgstr "in %s anni"
+
+msgid "Timeago|in 1 day"
+msgstr "in 1 giorno"
+
+msgid "Timeago|in 1 hour"
+msgstr "in 1 ora"
+
+msgid "Timeago|in 1 minute"
+msgstr "in 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "in 1 mese"
+
+msgid "Timeago|in 1 week"
+msgstr "in 1 settimana"
+
+msgid "Timeago|in 1 year"
+msgstr "in 1 anno"
+
+msgid "Timeago|less than a minute ago"
+msgstr "meno di un minuto fa"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "hr"
+msgstr[1] "hr"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tempo Totale"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tempo totale di test per tutti i commits/merges"
+
+msgid "Unstar"
+msgstr "Unstar"
+
+msgid "Upload New File"
+msgstr "Carica un nuovo file"
+
+msgid "Upload file"
+msgstr "Carica file"
+
+msgid "UploadLink|click to upload"
+msgstr "clicca per caricare"
+
+msgid "Use your global notification setting"
+msgstr "Usa le tue impostazioni globali "
+
+msgid "View open merge request"
+msgstr "Mostra la richieste di merge aperte"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interno"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privato"
+
+msgid "VisibilityLevel|Public"
+msgstr "Pubblico"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Sconosciuto"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr ""
+"Vuoi visualizzare i dati? Richiedi l'accesso ad un amministratore, grazie."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Non ci sono sufficienti dati da mostrare su questo stadio"
+
+msgid "Withdraw Access Request"
+msgstr "Ritira richiesta d'accesso"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Stai per rimuovere il gruppo %{group_name}. I gruppi rimossi NON POSSONO esser ripristinati! Sei ASSOLUTAMENTE sicuro?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Stai per rimuovere %{project_name_with_namespace}. I progetti rimossi NON POSSONO essere ripristinati! Sei assolutamente sicuro?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"Stai per rimuovere la relazione con il progetto sorgente "
+"%{forked_from_project}. Sei ASSOLUTAMENTE sicuro?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Stai per trasferire %{project_name_with_namespace} ad un altro owner. Sei "
+"ASSOLUTAMENTE sicuro?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Puoi aggiungere files solo quando sei in una branch"
+
+msgid "You have reached your project limit"
+msgstr "Hai raggiunto il tuo limite di progetto"
+
+msgid "You must sign in to star a project"
+msgstr "Devi accedere per porre una star al progetto"
+
+msgid "You need permission."
+msgstr "Necessiti del permesso."
+
+msgid "You will not get any notifications via email"
+msgstr "Non riceverai alcuna notifica via email"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Riceverai notifiche solo per gli eventi che hai scelto"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "Riceverai notifiche solo per i threads a cui hai partecipato"
+
+msgid "You will receive notifications for any activity"
+msgstr "Riceverai notifiche per ogni attività"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "Riceverai notifiche solo per i commenti ai quale sei stato menzionato"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Non sarai in grado di eseguire pull o push di codice tramite %{protocol} "
+"fino a che %{set_password_link} nel tuo account."
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Non sarai in grado di effettuare push o pull tramite SSH fino a che "
+"%{add_ssh_key_link} al tuo profilo"
+
+msgid "Your name"
+msgstr "Il tuo nome"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "giorno"
+msgstr[1] "giorni"
+
+msgid "new merge request"
+msgstr "Nuova richiesta di merge"
+
+msgid "notification emails"
+msgstr "Notifiche via email"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "parent"
+msgstr[1] "parents"
+
diff --git a/locale/it/gitlab.po.time_stamp b/locale/it/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/it/gitlab.po.time_stamp
diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po
new file mode 100644
index 00000000000..bfa97aa21d7
--- /dev/null
+++ b/locale/ja/gitlab.po
@@ -0,0 +1,1195 @@
+# Arthur Charron <arthur.charron@hotmail.fr>, 2017. #zanata
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Kohei Ota <inductor@kela.jp>, 2017. #zanata
+# Taisuke Inoue <taisuke.inoue.jp@gmail.com>, 2017. #zanata
+# Takuya Noguchi <takninnovationresearch@gmail.com>, 2017. #zanata
+# YANO Tethurou <tetuyano+zana@gmail.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-06 11:23-0400\n"
+"Last-Translator: Taisuke Inoue <taisuke.inoue.jp@gmail.com>\n"
+"Language-Team: Japanese "Language-Team: Russian (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: ja\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "パフォーマンス低下をé¿ã‘ã‚‹ãŸã‚ %s 個ã®ã‚³ãƒŸãƒƒãƒˆã‚’çœç•¥ã—ã¾ã—ãŸã€‚"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d個ã®ã‚³ãƒŸãƒƒãƒˆ"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_timeago}ã«%{commit_author_link}ãŒã‚³ãƒŸãƒƒãƒˆã—ã¾ã—ãŸã€‚"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 個ã®ãƒ‘イプライン"
+msgstr[1] "%d 個ã®ãƒ‘イプライン"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "CIã«ã¤ã„ã¦ã®ã‚°ãƒ©ãƒ•"
+
+msgid "About auto deploy"
+msgstr "自動デプロイã«ã¤ã„ã¦"
+
+msgid "Active"
+msgstr "有効"
+
+msgid "Activity"
+msgstr "アクティビティー"
+
+msgid "Add Changelog"
+msgstr "変更履歴を追加"
+
+msgid "Add Contribution guide"
+msgstr "貢献者å‘ã‘ガイドを追加"
+
+msgid "Add License"
+msgstr "ライセンスを追加"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "SSHã§ãƒ—ルやプッシュã™ã‚‹å ´åˆã¯ã€ãƒ—ロフィールã«SSHéµã‚’追加ã—ã¦ãã ã•ã„。"
+
+msgid "Add new directory"
+msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’追加"
+
+msgid "Archived project! Repository is read-only"
+msgstr "アーカイブ済ã¿ãƒ—ロジェクトï¼ï¼ˆãƒ¬ãƒã‚¸ãƒˆãƒªãƒ¼ã¯èª­ã¿å–り専用ã§ã™ï¼‰"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "ã“ã®ãƒ‘イプラインスケジュールを削除ã—ã¾ã™ã‹ï¼Ÿ"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "ドラッグ&ドロップã¾ãŸã¯ %{upload_link} ã§ãƒ•ã‚¡ã‚¤ãƒ«ã‚’添付"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "ブランãƒ"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"<strong>%{branch_name}</strong> ブランãƒãŒä½œæˆã•ã‚Œã¾ã—ãŸã€‚自動デプロイを設定ã™ã‚‹ã«ã¯ã€GitLab CI Yaml "
+"テンプレートをé¸æŠžã—ã¦ã€å¤‰æ›´ã‚’コミットã—ã¦ãã ã•ã„。 %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "ブランãƒã‚’検索"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "ブランãƒã‚’切替"
+
+msgid "Branches"
+msgstr "ブランãƒ"
+
+msgid "Browse Directory"
+msgstr "ディレクトリを表示"
+
+msgid "Browse File"
+msgstr "ファイルを表示"
+
+msgid "Browse Files"
+msgstr "ファイルを表示"
+
+msgid "Browse files"
+msgstr "ファイルを表示"
+
+msgid "ByAuthor|by"
+msgstr "作者"
+
+msgid "CI configuration"
+msgstr "CI 設定"
+
+msgid "Cancel"
+msgstr "キャンセル"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "ピック先ブランãƒ:"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "リãƒãƒ¼ãƒˆå…ˆãƒ–ランãƒ:"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "ãƒã‚§ãƒªãƒ¼ãƒ”ック"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "リãƒãƒ¼ãƒˆ"
+
+msgid "Changelog"
+msgstr "変更履歴"
+
+msgid "Charts"
+msgstr "ãƒãƒ£ãƒ¼ãƒˆ"
+
+msgid "Cherry-pick this commit"
+msgstr "ã“ã®ã‚³ãƒŸãƒƒãƒˆã‚’ãƒã‚§ãƒªãƒ¼ãƒ”ック"
+
+msgid "Cherry-pick this merge request"
+msgstr "ã“ã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’ãƒã‚§ãƒªãƒ¼ãƒ”ック"
+
+msgid "CiStatusLabel|canceled"
+msgstr "キャンセル"
+
+msgid "CiStatusLabel|created"
+msgstr "作æˆæ¸ˆã¿"
+
+msgid "CiStatusLabel|failed"
+msgstr "失敗"
+
+msgid "CiStatusLabel|manual action"
+msgstr "手動実行"
+
+msgid "CiStatusLabel|passed"
+msgstr "æˆåŠŸ"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "æˆåŠŸï¼ˆè­¦å‘Šã‚り)"
+
+msgid "CiStatusLabel|pending"
+msgstr "開始待ã¡"
+
+msgid "CiStatusLabel|skipped"
+msgstr "スキップ済ã¿"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "手動実行待ã¡"
+
+msgid "CiStatusText|blocked"
+msgstr "ブロック"
+
+msgid "CiStatusText|canceled"
+msgstr "キャンセル"
+
+msgid "CiStatusText|created"
+msgstr "作æˆæ¸ˆã¿"
+
+msgid "CiStatusText|failed"
+msgstr "失敗"
+
+msgid "CiStatusText|manual"
+msgstr "手動"
+
+msgid "CiStatusText|passed"
+msgstr "æˆåŠŸ"
+
+msgid "CiStatusText|pending"
+msgstr "実行待ã¡"
+
+msgid "CiStatusText|skipped"
+msgstr "スキップ済ã¿"
+
+msgid "CiStatus|running"
+msgstr "実行中"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "コミット"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "ç›´è¿‘30コミットã®æ‰€è¦æ™‚é–“(分)"
+
+msgid "Commit message"
+msgstr "コミットメッセージ"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "コミット"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "%{file_name} を追加"
+
+msgid "Commits"
+msgstr "コミット"
+
+msgid "Commits feed"
+msgstr "コミットフィード"
+
+msgid "Commits|History"
+msgstr "履歴"
+
+msgid "Committed by"
+msgstr "コミット担当者: "
+
+msgid "Compare"
+msgstr "比較"
+
+msgid "Contribution guide"
+msgstr "貢献者å‘ã‘ガイド"
+
+msgid "Contributors"
+msgstr "貢献者"
+
+msgid "Copy URL to clipboard"
+msgstr "クリップボードã«URLをコピー"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "コミットã®SHAをクリップボードã«ã‚³ãƒ”ー"
+
+msgid "Create New Directory"
+msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’作æˆ"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr "%{protocol} ã§ãƒ—ッシュやプルã™ã‚‹ãŸã‚ã®ã‚ãªãŸå€‹äººç”¨ã‚¢ã‚¯ã‚»ã‚¹ãƒˆãƒ¼ã‚¯ãƒ³ã‚’作æˆ"
+
+msgid "Create directory"
+msgstr "ディレクトリを作æˆ"
+
+msgid "Create empty bare repository"
+msgstr "空ã®bareレãƒã‚¸ãƒˆãƒªãƒ¼ã‚’作æˆ"
+
+msgid "Create merge request"
+msgstr "マージリクエストを作æˆ"
+
+msgid "Create new..."
+msgstr "æ–°è¦ä½œæˆ"
+
+msgid "CreateNewFork|Fork"
+msgstr "フォーク"
+
+msgid "CreateTag|Tag"
+msgstr "ã‚¿ã‚°"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "個人用アクセストークンを作æˆ"
+
+msgid "Cron Timezone"
+msgstr "Cron ã®ã‚¿ã‚¤ãƒ ã‚¾ãƒ¼ãƒ³"
+
+msgid "Cron syntax"
+msgstr "Cron ã®æ§‹æ–‡"
+
+msgid "Custom notification events"
+msgstr "カスタム通知設定"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"\"カスタム\" ã®é€šçŸ¥ãƒ¬ãƒ™ãƒ«ã®åŸºæœ¬ã¯ \"å‚加\" "
+"ã¨åŒã˜ã§ã™ã€‚ã¾ãŸã€ã‚«ã‚¹ã‚¿ãƒ é€šçŸ¥ã«è¨­å®šã™ã‚‹ã“ã¨ã§é¸æŠžã—ãŸã‚«ã‚¹ã‚¿ãƒ ã‚¤ãƒ™ãƒ³ãƒˆã®é€šçŸ¥ã‚’å—ã‘å–ã‚‹ã“ã¨ã‚‚ã§ãã¾ã™ã€‚ã‚‚ã£ã¨è©³ã—ã知りãŸã„å ´åˆã¯ "
+"%{notification_link} を見ã¦ãã ã•ã„。"
+
+msgid "Cycle Analytics"
+msgstr "サイクル分æž"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"サイクル分æžã«ã‚ˆã‚Šã€ã‚ãªãŸã®ãƒ—ロジェクトãŒã‚¢ã‚¤ãƒ‡ã‚£ã‚¢ã®æ®µéšŽã‹ã‚‰ãƒ—ロダクション環境ã«ãƒªãƒªãƒ¼ã‚¹ã•ã‚Œã‚‹ã¾ã§ã©ã‚Œãらã„時間ãŒã‹ã‹ã£ãŸã‹ä¿¯çž°ã™ã‚‹ã“ã¨ãŒã§ãã¾ã™ã€‚"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "コード"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "課題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計画"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "プロダクション"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "レビュー"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "ステージング"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "テスト"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Cron 構文ã§ã‚«ã‚¹ã‚¿ãƒ ãªãƒ‘ターンを指定ã™ã‚‹"
+
+msgid "Delete"
+msgstr "削除"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "デプロイ"
+
+msgid "Description"
+msgstr "説明"
+
+msgid "Directory name"
+msgstr "ディレクトリå"
+
+msgid "Don't show again"
+msgstr "次回ã‹ã‚‰è¡¨ç¤ºã—ãªã„"
+
+msgid "Download"
+msgstr "ダウンロード"
+
+msgid "Download tar"
+msgstr "tarå½¢å¼ã§ãƒ€ã‚¦ãƒ³ãƒ­ãƒ¼ãƒ‰"
+
+msgid "Download tar.bz2"
+msgstr "tar.bz2å½¢å¼ã§ãƒ€ã‚¦ãƒ³ãƒ­ãƒ¼ãƒ‰"
+
+msgid "Download tar.gz"
+msgstr "tar.gzå½¢å¼ã§ãƒ€ã‚¦ãƒ³ãƒ­ãƒ¼ãƒ‰"
+
+msgid "Download zip"
+msgstr "zipå½¢å¼ã§ãƒ€ã‚¦ãƒ³ãƒ­ãƒ¼ãƒ‰"
+
+msgid "DownloadArtifacts|Download"
+msgstr "ダウンロード"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "パッãƒã‚’メールã§é€ä¿¡"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "プレーン差分"
+
+msgid "DownloadSource|Download"
+msgstr "ダウンロード"
+
+msgid "Edit"
+msgstr "編集"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "パイプラインスケジュール %{id} を編集"
+
+msgid "Every day (at 4:00am)"
+msgstr "毎日 (åˆå‰4:00)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "毎月 (1æ—¥ã®åˆå‰4:00)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "毎週 (日曜日ã®åˆå‰4:00)"
+
+msgid "Failed to change the owner"
+msgstr "オーナーを変更ã§ãã¾ã›ã‚“ã§ã—ãŸ"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "パイプラインスケジュールを削除ã§ãã¾ã›ã‚“ã§ã—ãŸ"
+
+msgid "Files"
+msgstr "ファイル"
+
+msgid "Filter by commit message"
+msgstr "コミットメッセージã§çµžã‚Šè¾¼ã¿"
+
+msgid "Find by path"
+msgstr "パスã§æ¤œç´¢"
+
+msgid "Find file"
+msgstr "ファイルを検索"
+
+msgid "FirstPushedBy|First"
+msgstr "åˆå›ž"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "プッシュã—ãŸäºº"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "フォーク"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "フォーク元"
+
+msgid "From issue creation until deploy to production"
+msgstr "課題ãŒç™»éŒ²ã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§"
+
+msgid "From merge request merge until deploy to production"
+msgstr "マージリクエストãŒãƒžãƒ¼ã‚¸ã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§"
+
+msgid "Go to your fork"
+msgstr "自分ã®ãƒ•ã‚©ãƒ¼ã‚¯ã¸ç§»å‹•"
+
+msgid "GoToYourFork|Fork"
+msgstr "フォーク"
+
+msgid "Home"
+msgstr "ホーム"
+
+msgid "Housekeeping successfully started"
+msgstr "ãƒã‚¦ã‚¹ã‚­ãƒ¼ãƒ”ングã¯æ­£å¸¸ã«èµ·å‹•ã—ã¾ã—ãŸã€‚"
+
+msgid "Import repository"
+msgstr "レãƒã‚¸ãƒˆãƒªãƒ¼ã‚’インãƒãƒ¼ãƒˆ"
+
+msgid "Interval Pattern"
+msgstr "é–“éš”ã®ãƒ‘ターン"
+
+msgid "Introducing Cycle Analytics"
+msgstr "サイクル分æžã®ã”紹介"
+
+msgid "Jobs for last month"
+msgstr "先月ã®ã‚¸ãƒ§ãƒ–"
+
+msgid "Jobs for last week"
+msgstr "先週ã®ã‚¸ãƒ§ãƒ–"
+
+msgid "Jobs for last year"
+msgstr "昨年ã®ã‚¸ãƒ§ãƒ–"
+
+msgid "LFSStatus|Disabled"
+msgstr "無効"
+
+msgid "LFSStatus|Enabled"
+msgstr "有効"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "éŽåŽ»%d日間"
+
+msgid "Last Pipeline"
+msgstr "最新パイプライン"
+
+msgid "Last Update"
+msgstr "最新アップデート"
+
+msgid "Last commit"
+msgstr "最新コミット"
+
+msgid "Learn more in the"
+msgstr "詳ã—ã見る:"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "詳ã—ãã¯ãƒ‘イプラインスケジュールã®ãƒ‰ã‚­ãƒ¥ãƒ¡ãƒ³ãƒˆã‚’å‚ç…§"
+
+msgid "Leave group"
+msgstr "グループを離脱"
+
+msgid "Leave project"
+msgstr "プロジェクトを離脱"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "イベント表示数を最大 %d 個ã«åˆ¶é™"
+
+msgid "Median"
+msgstr "中央値"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "SSH éµã‚’追加"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "æ–°è¦èª²é¡Œ"
+
+msgid "New Pipeline Schedule"
+msgstr "æ–°è¦ãƒ‘イプラインスケジュール"
+
+msgid "New branch"
+msgstr "æ–°è¦ãƒ–ランãƒ"
+
+msgid "New directory"
+msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒª"
+
+msgid "New file"
+msgstr "æ–°è¦ãƒ•ã‚¡ã‚¤ãƒ«"
+
+msgid "New issue"
+msgstr "æ–°è¦èª²é¡Œ"
+
+msgid "New merge request"
+msgstr "æ–°è¦ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+
+msgid "New schedule"
+msgstr "æ–°è¦ã‚¹ã‚±ã‚¸ãƒ¥ãƒ¼ãƒ«"
+
+msgid "New snippet"
+msgstr "æ–°è¦ã‚¹ãƒ‹ãƒšãƒƒãƒˆ"
+
+msgid "New tag"
+msgstr "æ–°è¦ã‚¿ã‚°"
+
+msgid "No repository"
+msgstr "レãƒã‚¸ãƒˆãƒªãƒ¼ã¯ã‚ã‚Šã¾ã›ã‚“"
+
+msgid "No schedules"
+msgstr "スケジュールãªã—"
+
+msgid "Not available"
+msgstr "利用ã§ãã¾ã›ã‚“"
+
+msgid "Not enough data"
+msgstr "データä¸è¶³"
+
+msgid "Notification events"
+msgstr "イベント通知"
+
+msgid "NotificationEvent|Close issue"
+msgstr "課題をクローズ"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "マージリクエストをクローズ"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "パイプラインã«å¤±æ•—"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "マージリクエストをマージ"
+
+msgid "NotificationEvent|New issue"
+msgstr "æ–°è¦èª²é¡Œ"
+
+msgid "NotificationEvent|New merge request"
+msgstr "æ–°è¦ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+
+msgid "NotificationEvent|New note"
+msgstr "æ–°è¦ãƒŽãƒ¼ãƒˆ"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "課題ã®æ‹…当者を変更"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "マージリクエスト担当者を変更"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "課題をå†ã‚ªãƒ¼ãƒ—ン"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "パイプラインæˆåŠŸ"
+
+msgid "NotificationLevel|Custom"
+msgstr "カスタム"
+
+msgid "NotificationLevel|Disabled"
+msgstr "無効"
+
+msgid "NotificationLevel|Global"
+msgstr "全体設定"
+
+msgid "NotificationLevel|On mention"
+msgstr "メンション時"
+
+msgid "NotificationLevel|Participate"
+msgstr "å‚加"
+
+msgid "NotificationLevel|Watch"
+msgstr "ã™ã¹ã¦é€šçŸ¥"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "フィルター"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "オープンã•ã‚ŒãŸã®ã¯"
+
+msgid "Options"
+msgstr "オプション"
+
+msgid "Owner"
+msgstr "オーナー"
+
+msgid "Pipeline"
+msgstr "パイプライン"
+
+msgid "Pipeline Health"
+msgstr "パイプラインã®é€²æ—状æ³"
+
+msgid "Pipeline Schedule"
+msgstr "パイプラインスケジュール"
+
+msgid "Pipeline Schedules"
+msgstr "パイプラインスケジュール"
+
+msgid "PipelineCharts|Failed:"
+msgstr "失敗:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "全体統計"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "æˆåŠŸæ¯”率:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "æˆåŠŸ:"
+
+msgid "PipelineCharts|Total:"
+msgstr "åˆè¨ˆ:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "アクティブ"
+
+msgid "PipelineSchedules|Active"
+msgstr "アクティブ"
+
+msgid "PipelineSchedules|All"
+msgstr "全件"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "無効"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "変数ã®åå‰ã‚’入力"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "変数ã®å€¤ã‚’入力"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "次ã®å®Ÿè¡Œ"
+
+msgid "PipelineSchedules|None"
+msgstr "ãªã—"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "ã“ã®ãƒ‘イプラインã«ã¤ã„ã¦ç°¡å˜ã«è¨˜è¿°ã—ã¦ãã ã•ã„。"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "変数を削除"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "権é™ã‚’å–å¾—ã™ã‚‹"
+
+msgid "PipelineSchedules|Target"
+msgstr "ターゲット"
+
+msgid "PipelineSchedules|Variables"
+msgstr "変数"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "カスタム"
+
+msgid "Pipelines"
+msgstr "パイプライン"
+
+msgid "Pipelines charts"
+msgstr "パイプラインãƒãƒ£ãƒ¼ãƒˆ"
+
+msgid "Pipeline|all"
+msgstr "全件"
+
+msgid "Pipeline|success"
+msgstr "æˆåŠŸ"
+
+msgid "Pipeline|with stage"
+msgstr "ステージã‚ã‚Š"
+
+msgid "Pipeline|with stages"
+msgstr "ステージã‚ã‚Š"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "'%{project_name}' プロジェクトã¯å‰Šé™¤å‡¦ç†å¾…ã¡ã§ã™ã€‚"
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "'%{project_name}' プロジェクトã¯æ­£å¸¸ã«ä½œæˆã•ã‚Œã¾ã—ãŸã€‚"
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "'%{project_name}' プロジェクトã¯æ­£å¸¸ã«æ›´æ–°ã•ã‚Œã¾ã—ãŸã€‚"
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "'%{project_name}' プロジェクトã¯å‰Šé™¤ã•ã‚Œã¾ã™ã€‚"
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "ユーザーã”ã¨ã«ãƒ—ロジェクトアクセスã®æ¨©é™ã‚’指定ã—ãªã‘ã‚Œã°ãªã‚Šã¾ã›ã‚“。"
+
+msgid "Project export could not be deleted."
+msgstr "プロジェクトã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆã‚’削除ã§ãã¾ã›ã‚“ã§ã—ãŸã€‚"
+
+msgid "Project export has been deleted."
+msgstr "プロジェクトã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆã‚’削除ã—ã¾ã—ãŸã€‚"
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr "プロジェクトã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆãƒªãƒ³ã‚¯ã¯æœŸé™åˆ‡ã‚Œã«ãªã‚Šã¾ã—ãŸã€‚プロジェクト設定ã«ã¦æ–°ã—ãエクスãƒãƒ¼ãƒˆãƒªãƒ³ã‚¯ã‚’作æˆã—ã¦ãã ã•ã„。"
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "プロジェクトã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆã‚’開始ã—ã¾ã—ãŸã€‚ダウンロードã®ãƒªãƒ³ã‚¯ã¯ãƒ¡ãƒ¼ãƒ«ã§é€ä¿¡ã—ã¾ã™"
+
+msgid "Project home"
+msgstr "プロジェクトホーム"
+
+msgid "ProjectFeature|Disabled"
+msgstr "無効"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "アクセス権é™ã‚’æŒã£ã¦ã„る人"
+
+msgid "ProjectFeature|Only team members"
+msgstr "ãƒãƒ¼ãƒ ãƒ¡ãƒ³ãƒãƒ¼ã®ã¿"
+
+msgid "ProjectFileTree|Name"
+msgstr "åå‰"
+
+msgid "ProjectLastActivity|Never"
+msgstr "記録ãªã—"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "ステージ"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "ãƒãƒƒãƒˆãƒ¯ãƒ¼ã‚¯ã‚°ãƒ©ãƒ•"
+
+msgid "Read more"
+msgstr "続ãを読む"
+
+msgid "Readme"
+msgstr "Readme"
+
+msgid "RefSwitcher|Branches"
+msgstr "ブランãƒ"
+
+msgid "RefSwitcher|Tags"
+msgstr "ã‚¿ã‚°"
+
+msgid "Related Commits"
+msgstr "関連ã™ã‚‹ã‚³ãƒŸãƒƒãƒˆ"
+
+msgid "Related Deployed Jobs"
+msgstr "関連ã™ã‚‹ãƒ‡ãƒ—ロイ済ジョブ"
+
+msgid "Related Issues"
+msgstr "関連ã™ã‚‹èª²é¡Œ"
+
+msgid "Related Jobs"
+msgstr "関連ã™ã‚‹ã‚¸ãƒ§ãƒ–"
+
+msgid "Related Merge Requests"
+msgstr "関連ã™ã‚‹ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+
+msgid "Related Merged Requests"
+msgstr "関連ã™ã‚‹ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+
+msgid "Remind later"
+msgstr "後ã§é€šçŸ¥"
+
+msgid "Remove project"
+msgstr "プロジェクトを削除"
+
+msgid "Request Access"
+msgstr "アクセス権é™ã‚’リクエストã™ã‚‹"
+
+msgid "Revert this commit"
+msgstr "ã“ã®ã‚³ãƒŸãƒƒãƒˆã‚’リãƒãƒ¼ãƒˆ"
+
+msgid "Revert this merge request"
+msgstr "ã“ã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’リãƒãƒ¼ãƒˆ"
+
+msgid "Save pipeline schedule"
+msgstr "パイプラインスケジュールをä¿å­˜"
+
+msgid "Schedule a new pipeline"
+msgstr "æ–°ã—ã„パイプラインã®ã‚¹ã‚±ã‚¸ãƒ¥ãƒ¼ãƒ«ã‚’作æˆ"
+
+msgid "Scheduling Pipelines"
+msgstr "パイプラインスケジューリング"
+
+msgid "Search branches and tags"
+msgstr "ブランãƒã¾ãŸã¯ã‚¿ã‚°ã‚’検索"
+
+msgid "Select Archive Format"
+msgstr "アーカイブã®ãƒ•ã‚©ãƒ¼ãƒžãƒƒãƒˆã‚’é¸æŠž"
+
+msgid "Select a timezone"
+msgstr "タイムゾーンをé¸æŠž"
+
+msgid "Select target branch"
+msgstr "ターゲットブランãƒã‚’é¸æŠž"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "%{protocol} プロコトル経由ã§ãƒ—ルã€ãƒ—ッシュã™ã‚‹ãŸã‚ã«ã‚¢ã‚«ã‚¦ãƒ³ãƒˆã®ãƒ‘スワードを設定。"
+
+msgid "Set up CI"
+msgstr "CI を設定"
+
+msgid "Set up Koding"
+msgstr "Koding を設定"
+
+msgid "Set up auto deploy"
+msgstr "自動デプロイを設定"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "パスワードを設定"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "%d ã®ã‚¤ãƒ™ãƒ³ãƒˆã‚’表示中"
+
+msgid "Source code"
+msgstr "ソースコード"
+
+msgid "StarProject|Star"
+msgstr "スターを付ã‘ã‚‹"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "ã“ã®å¤‰æ›´ã§ %{new_merge_request} を作æˆã™ã‚‹"
+
+msgid "Switch branch/tag"
+msgstr "ブランãƒãƒ»ã‚¿ã‚°åˆ‡ã‚Šæ›¿ãˆ"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "ã‚¿ã‚°"
+
+msgid "Tags"
+msgstr "ã‚¿ã‚°"
+
+msgid "Target Branch"
+msgstr "ターゲットブランãƒ"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"コーディングステージã§ã¯ã€æœ€åˆã®ã‚³ãƒŸãƒƒãƒˆã‹ã‚‰ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆãŒä½œæˆã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚ã“ã®ãƒ‡ãƒ¼ã‚¿ã¯æœ€åˆã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆãŒä½œæˆã•ã‚ŒãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã§è¨ˆæ¸¬ãƒ‡ãƒ¼ã‚¿ã«è¿½åŠ ã•ã‚ŒãŸã‚¤ãƒ™ãƒ³ãƒˆãƒªã‚¹ãƒˆ"
+
+msgid "The fork relationship has been removed."
+msgstr "フォークã®ãƒªãƒ¬ãƒ¼ã‚·ãƒ§ãƒ³ãŒå‰Šé™¤ã•ã‚Œã¾ã—ãŸã€‚"
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"課題ステージã§ã¯ã€èª²é¡ŒãŒç™»éŒ²ã•ã‚Œã¦ã‹ã‚‰ãƒžã‚¤ãƒ«ã‚¹ãƒˆãƒ¼ãƒ³ã«å‰²ã‚Šå½“ã¦ã‚‰ã‚Œã‚‹ã‹ã€èª²é¡Œãƒœãƒ¼ãƒ‰ã®ãƒªã‚¹ãƒˆã«è¿½åŠ ã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚ã“ã®ãƒªã‚¹ãƒˆã«è¡¨ç¤ºã™ã‚‹ã«ã¯èª²é¡Œã‚’最åˆã«ä½œæˆã—ã¦ãã ã•ã„。"
+
+msgid "The phase of the development lifecycle."
+msgstr "開発ライフサイクルã®æ®µéšŽ"
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"パイプラインスケジュールã¯æŒ‡å®šã®ãƒ–ランãƒã¾ãŸã¯ã‚¿ã‚°ã«å¯¾ã—ã¦è‡ªå‹•çš„ã«ãƒ‘イプラインを実行ã—ã¾ã™ã€‚計画済ã¿ãƒ‘イプラインã¯ãれらã®ç´ä»˜ã‘られãŸãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ãƒ—ロジェクトã¨åŒã˜æ¨©é™ã‚’継承ã—ã¾ã™ã€‚"
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"計画ステージã§ã¯ã€èª²é¡Œã‚¹ãƒ†ãƒ¼ã‚¸ã«ç™»éŒ²ã•ã‚Œã¦ã‹ã‚‰ãƒ—ッシュã•ã‚ŒãŸæœ€åˆã®ã‚³ãƒŸãƒƒãƒˆæ™‚刻ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚最åˆã®ã‚³ãƒŸãƒƒãƒˆãŒãƒ—ッシュã•ã‚Œã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"プロダクションステージã§ã¯ã€èª²é¡ŒãŒä½œæˆã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã¸ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚アイディアã®æ™‚点ã‹ã‚‰ãƒ—ロダクションã¾ã§ã®å…¨ã‚¹ãƒ†ãƒ¼ã‚¸ãŒå®Œäº†ã—ãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid "The project can be accessed by any logged in user."
+msgstr "プロジェクトã¯ã€ãƒ­ã‚°ã‚¤ãƒ³ãƒ¦ãƒ¼ã‚¶ãƒ¼ã§ã‚ã‚Œã°èª°ã§ã‚‚アクセスã§ãã¾ã™ã€‚"
+
+msgid "The project can be accessed without any authentication."
+msgstr "プロジェクトã¯ã€ãƒ­ã‚°ã‚¤ãƒ³ãªã—ã«èª°ã§ã‚‚アクセスã§ãã¾ã™ã€‚"
+
+msgid "The repository for this project does not exist."
+msgstr "ã“ã®ãƒ—ロジェクトã«ãƒ¬ãƒã‚¸ãƒˆãƒªãƒ¼ã¯ã‚ã‚Šã¾ã›ã‚“。"
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"レビューステージã¨ã¯ã€ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’作æˆã—ã¦ã‹ã‚‰ãƒžãƒ¼ã‚¸ã™ã‚‹ã¾ã§ã®æ™‚é–“ã§ã™ã€‚ã“ã®ãƒ‡ãƒ¼ã‚¿ã¯æœ€åˆã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆãŒãƒžãƒ¼ã‚¸ã•ã‚ŒãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"ステージングステージã§ã¯ã€ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆãŒãƒžãƒ¼ã‚¸ã•ã‚Œã¦ã‹ã‚‰ã‚³ãƒ¼ãƒ‰ãŒãƒ—ロダクション環境ã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚ã“ã®ãƒ‡ãƒ¼ã‚¿ã¯æœ€åˆã«ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã—ãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"テスティングステージã§ã¯ã€GitLab CI "
+"ãŒé–¢é€£ã™ã‚‹ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã®å„パイプラインを実行ã™ã‚‹æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚ã“ã®ãƒ‡ãƒ¼ã‚¿ã¯æœ€åˆã®ãƒ‘イプラインãŒå®Œäº†ã—ãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã«åŽé›†ã•ã‚ŒãŸãƒ‡ãƒ¼ã‚¿æ¯Žã®æ™‚é–“"
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"得られãŸä¸€é€£ã®ãƒ‡ãƒ¼ã‚¿ã‚’å°ã•ã„é †ã«ä¸¦ã¹ãŸã¨ãã«ä¸­å¤®ã«ä½ç½®ã™ã‚‹å€¤ã€‚例ãˆã°ã€3, 5, 9ã®ä¸­å¤®å€¤ã¯5。3, 5, 7, 8ã®ä¸­å¤®å€¤ã¯ (5+7)/2 = "
+"6。"
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr "空レãƒã‚¸ãƒˆãƒªãƒ¼ã‚’作æˆã¾ãŸã¯æ—¢å­˜ãƒ¬ãƒã‚¸ãƒˆãƒªãƒ¼ã‚’インãƒãƒ¼ãƒˆã‚’ã—ãªã‘ã‚Œã°ã€ã‚³ãƒ¼ãƒ‰ã®ãƒ—ッシュã¯ã§ãã¾ã›ã‚“。"
+
+msgid "Time before an issue gets scheduled"
+msgstr "課題ãŒè¨ˆç”»ã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
+
+msgid "Time before an issue starts implementation"
+msgstr "課題ã®å®Ÿè£…ãŒé–‹å§‹ã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "マージリクエストãŒä½œæˆã•ã‚Œã¦ã‹ã‚‰ãƒžãƒ¼ã‚¸ã¾ãŸã¯ã‚¯ãƒ­ãƒ¼ã‚ºã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
+
+msgid "Time until first merge request"
+msgstr "最åˆã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã¾ã§ã®æ™‚é–“"
+
+msgid "Timeago|%s days ago"
+msgstr "%sæ—¥å‰"
+
+msgid "Timeago|%s days remaining"
+msgstr "残り %s日間"
+
+msgid "Timeago|%s hours remaining"
+msgstr "残り %s時間"
+
+msgid "Timeago|%s minutes ago"
+msgstr "%s分å‰"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "残り %s分間"
+
+msgid "Timeago|%s months ago"
+msgstr "%sヶ月å‰"
+
+msgid "Timeago|%s months remaining"
+msgstr "残り %sヶ月"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "残り %s 秒"
+
+msgid "Timeago|%s weeks ago"
+msgstr "%s週間å‰"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "残り %s週間"
+
+msgid "Timeago|%s years ago"
+msgstr "%så¹´å‰"
+
+msgid "Timeago|%s years remaining"
+msgstr "残り %s年間"
+
+msgid "Timeago|1 day remaining"
+msgstr "残り 1日間"
+
+msgid "Timeago|1 hour remaining"
+msgstr "残り 1時間"
+
+msgid "Timeago|1 minute remaining"
+msgstr "残り 1分間"
+
+msgid "Timeago|1 month remaining"
+msgstr "残り 1ヶ月"
+
+msgid "Timeago|1 week remaining"
+msgstr "残り 1週間"
+
+msgid "Timeago|1 year remaining"
+msgstr "残り 1年間"
+
+msgid "Timeago|Past due"
+msgstr "期é™ã‚ªãƒ¼ãƒãƒ¼"
+
+msgid "Timeago|a day ago"
+msgstr "1æ—¥å‰"
+
+msgid "Timeago|a month ago"
+msgstr "1ヶ月å‰"
+
+msgid "Timeago|a week ago"
+msgstr "1週間å‰"
+
+msgid "Timeago|a while"
+msgstr "ã—ã°ã‚‰ãå‰"
+
+msgid "Timeago|a year ago"
+msgstr "1å¹´å‰"
+
+msgid "Timeago|about %s hours ago"
+msgstr "ç´„%s時間å‰"
+
+msgid "Timeago|about a minute ago"
+msgstr "ç´„1分間å‰"
+
+msgid "Timeago|about an hour ago"
+msgstr "ç´„1時間å‰"
+
+msgid "Timeago|in %s days"
+msgstr "%s日間以内"
+
+msgid "Timeago|in %s hours"
+msgstr "%s時間以内"
+
+msgid "Timeago|in %s minutes"
+msgstr "%s分間以内"
+
+msgid "Timeago|in %s months"
+msgstr "%sヶ月以内"
+
+msgid "Timeago|in %s seconds"
+msgstr "%s秒以内"
+
+msgid "Timeago|in %s weeks"
+msgstr "%s週間以内"
+
+msgid "Timeago|in %s years"
+msgstr "%s年間以内"
+
+msgid "Timeago|in 1 day"
+msgstr "1日以内"
+
+msgid "Timeago|in 1 hour"
+msgstr "1時間以内"
+
+msgid "Timeago|in 1 minute"
+msgstr "1分以内"
+
+msgid "Timeago|in 1 month"
+msgstr "1ヶ月以内"
+
+msgid "Timeago|in 1 week"
+msgstr "1週間以内"
+
+msgid "Timeago|in 1 year"
+msgstr "1年以内"
+
+msgid "Timeago|less than a minute ago"
+msgstr "1分未満"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "時間"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "åˆè¨ˆæ™‚é–“"
+
+msgid "Total test time for all commits/merges"
+msgstr "ã™ã¹ã¦ã®ã‚³ãƒŸãƒƒãƒˆ/マージã®åˆè¨ˆãƒ†ã‚¹ãƒˆæ™‚é–“"
+
+msgid "Unstar"
+msgstr "スターを外ã™"
+
+msgid "Upload New File"
+msgstr "æ–°è¦ãƒ•ã‚¡ã‚¤ãƒ«ã‚’アップロード"
+
+msgid "Upload file"
+msgstr "ファイルをアップロード"
+
+msgid "UploadLink|click to upload"
+msgstr "クリックã—ã¦ã‚¢ãƒƒãƒ—ロード"
+
+msgid "Use your global notification setting"
+msgstr "全体通知設定を利用"
+
+msgid "View open merge request"
+msgstr "オープンãªãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’表示"
+
+msgid "VisibilityLevel|Internal"
+msgstr "内部"
+
+msgid "VisibilityLevel|Private"
+msgstr "プライベート"
+
+msgid "VisibilityLevel|Public"
+msgstr "パブリック"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "ä¸æ˜Ž"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "ã“ã®ãƒ‡ãƒ¼ã‚¿ã‚’å‚ç…§ã—ãŸã„ã§ã™ã‹ï¼Ÿã‚¢ã‚¯ã‚»ã‚¹ã™ã‚‹ã«ã¯ç®¡ç†è€…ã«å•ã„åˆã‚ã›ã¦ãã ã•ã„。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "データä¸è¶³ã®ãŸã‚ã€ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã®è¡¨ç¤ºã¯ã§ãã¾ã›ã‚“。"
+
+msgid "Withdraw Access Request"
+msgstr "アクセスリクエストをå–り消ã™"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "%{group_name} グループを削除ã—よã†ã¨ã—ã¦ã„ã¾ã™ã€‚ 削除ã•ã‚ŒãŸã‚°ãƒ«ãƒ¼ãƒ—ã¯çµ¶å¯¾ã«å…ƒã«æˆ»ã›ã¾ã›ã‚“ï¼æœ¬å½“ã«ã‚ˆã‚ã—ã„ã§ã™ã‹ï¼Ÿ"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "%{project_name_with_namespace} プロジェクトを削除ã—よã†ã¨ã—ã¦ã„ã¾ã™ã€‚削除ã•ã‚ŒãŸãƒ—ロジェクトã¯çµ¶å¯¾ã«å…ƒã«ã¯æˆ»ã›ã¾ã›ã‚“ï¼æœ¬å½“ã«ã‚ˆã‚ã—ã„ã§ã™ã‹ï¼Ÿ"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "å…ƒã®ãƒ—ロジェクト (%{forked_from_project}) ã¨ã®ãƒªãƒ¬ãƒ¼ã‚·ãƒ§ãƒ³ã‚’削除ã—よã†ã¨ã—ã¦ã„ã¾ã™ã€‚本当ã«ã‚ˆã‚ã—ã„ã§ã™ã‹ï¼Ÿ"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr "%{project_name_with_namespace} プロジェクトを別ã®ã‚ªãƒ¼ãƒŠãƒ¼ã«ç§»è­²ã—よã†ã¨ã—ã¦ã„ã¾ã™ã€‚本当ã«ã‚ˆã‚ã—ã„ã§ã™ã‹ï¼Ÿ"
+
+msgid "You can only add files when you are on a branch"
+msgstr "ファイルを追加ã™ã‚‹ã«ã¯ã€ã©ã“ã‹ã®ãƒ–ランãƒã«ã„ãªã‘ã‚Œã°ã„ã‘ã¾ã›ã‚“"
+
+msgid "You have reached your project limit"
+msgstr "プロジェクト数ã®ä¸Šé™ã«é”ã—ã¦ã„ã¾ã™"
+
+msgid "You must sign in to star a project"
+msgstr "プロジェクトã«ã‚¹ã‚¿ãƒ¼ã‚’ã¤ã‘ãŸã„å ´åˆã¯ãƒ­ã‚°ã‚¤ãƒ³ã—ã¦ãã ã•ã„"
+
+msgid "You need permission."
+msgstr "権é™ãŒå¿…è¦ã§ã™"
+
+msgid "You will not get any notifications via email"
+msgstr "通知メールをé€ä¿¡ã—ã¾ã›ã‚“"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "é¸æŠžã—ãŸã‚¤ãƒ™ãƒ³ãƒˆã®ã¿é€šçŸ¥ã—ã¾ã™"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "å‚加ã—ãŸã‚¹ãƒ¬ãƒƒãƒ‰ã®ã¿é€šçŸ¥ã—ã¾ã™"
+
+msgid "You will receive notifications for any activity"
+msgstr "å…¨ã¦ã®ã‚¢ã‚¯ãƒ†ã‚£ãƒ“ティーを通知ã—ã¾ã™"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "ã‚ãªãŸãŒ @mentioned ã§ã‚³ãƒ¡ãƒ³ãƒˆã•ã‚ŒãŸæ™‚ã®ã¿é€šçŸ¥ã—ã¾ã™"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"%{set_password_link} ã§ã‚¢ã‚«ã‚¦ãƒ³ãƒˆã®ãƒ‘スワードãŒã‚»ãƒƒãƒˆã•ã‚Œã¦ã„ãªã„ã®ã§ã€ãƒ—ロジェクト㫠%{protocol} "
+"ã§ã‚½ãƒ¼ã‚¹ã‚³ãƒ¼ãƒ‰ã‚’プッシュã€ãƒ—ルã§ãã¾ã›ã‚“"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr "%{add_ssh_key_link} をプロファイルã«è¿½åŠ ã—ã¦ã„ãªã„ã®ã§ã€ãƒ—ロジェクトã«ã‚½ãƒ¼ã‚¹ã‚³ãƒ¼ãƒ‰ã‚’プッシュã€ãƒ—ルã§ãã¾ã›ã‚“"
+
+msgid "Your name"
+msgstr "åå‰"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "æ—¥"
+
+msgid "new merge request"
+msgstr "æ–°è¦ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+
+msgid "notification emails"
+msgstr "メール通知"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "親"
diff --git a/locale/ja/gitlab.po.time_stamp b/locale/ja/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/ja/gitlab.po.time_stamp
diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po
new file mode 100644
index 00000000000..340c8955d20
--- /dev/null
+++ b/locale/ko/gitlab.po
@@ -0,0 +1,1200 @@
+# Korean translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-08 08:32-0400\n"
+"Last-Translator: chang-ho,cha <changho.cha@gmail.com>\n"
+"Language-Team: Korean (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: ko\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Generator: Zanata 3.9.6\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d 커밋"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s 추가 ì»¤ë°‹ì€ ì„±ëŠ¥ ì´ìŠˆë¥¼ 방지하기 위해 ìƒëžµë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_timeago} ì— %{commit_author_link} ë‹˜ì´ ì»¤ë°‹í•˜ì˜€ìŠµë‹ˆë‹¤. "
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 파ì´í”„ë¼ì¸"
+msgstr[1] "%d 파ì´í”„ë¼ì¸"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "지ì†ì ì¸ í†µí•©ì— ê´€í•œ 그래프 모ìŒ"
+
+msgid "About auto deploy"
+msgstr "ìžë™ ë°°í¬ ì •ë³´"
+
+msgid "Active"
+msgstr "활성"
+
+msgid "Activity"
+msgstr "활ë™"
+
+msgid "Add Changelog"
+msgstr "변경 로그 추가"
+
+msgid "Add Contribution guide"
+msgstr "기여 ê°€ì´ë“œ 추가"
+
+msgid "Add License"
+msgstr "ë¼ì´ì„ ìŠ¤ 추가"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "í”„ë¡œí•„ì— SSH 키를 추가하여 SSH를 통해 Pull 하거나 Push합니다."
+
+msgid "Add new directory"
+msgstr "새 디렉토리 추가"
+
+msgid "Archived project! Repository is read-only"
+msgstr "프로ì íŠ¸ê°€ ë³´ê´€ë˜ì—ˆìŠµë‹ˆë‹¤! 저장소는 ì½ê¸°ë§Œ 가능합니다."
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "ì´ íŒŒì´í”„ë¼ì¸ ìŠ¤ì¼€ì¥´ì„ ì‚­ì œ 하시겠습니까?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "드래그 &amp; 드롭 ë˜ëŠ” %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "브랜치"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"<strong>%{branch_name}</strong> 브랜치가 ìƒì„±ë˜ì—ˆìŠµë‹ˆë‹¤. ìžë™ ë°°í¬ë¥¼ 설정하려면 GitLab CI Yaml "
+"í…œí”Œë¦¿ì„ ì„ íƒí•˜ê³  변경 ì‚¬í•­ì„ ì ìš©í•˜ì‹­ì‹œì˜¤. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "브랜치 검색"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "브랜치 변경"
+
+msgid "Branches"
+msgstr "브랜치"
+
+msgid "Browse Directory"
+msgstr "디렉토리 찾아보기"
+
+msgid "Browse File"
+msgstr "íŒŒì¼ ì°¾ì•„ë³´ê¸°"
+
+msgid "Browse Files"
+msgstr "íŒŒì¼ ì°¾ì•„ë³´ê¸°"
+
+msgid "Browse files"
+msgstr "íŒŒì¼ ì°¾ì•„ë³´ê¸°"
+
+msgid "ByAuthor|by"
+msgstr "작성ìž"
+
+msgid "CI configuration"
+msgstr "CI 설정"
+
+msgid "Cancel"
+msgstr "취소"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "브랜치ì—ì„œ Pick"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "브랜치ì—ì„œ Revert"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Revert"
+
+msgid "Changelog"
+msgstr "변경사항"
+
+msgid "Charts"
+msgstr "차트"
+
+msgid "Cherry-pick this commit"
+msgstr "ì´ ì»¤ë°‹ì„ Cherry-pick"
+
+msgid "Cherry-pick this merge request"
+msgstr "ì´ ë¨¸ì§€ 리퀘스트를 Cherry-pick"
+
+msgid "CiStatusLabel|canceled"
+msgstr "취소ë¨"
+
+msgid "CiStatusLabel|created"
+msgstr "ìƒì„±ë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid "CiStatusLabel|failed"
+msgstr "실패"
+
+msgid "CiStatusLabel|manual action"
+msgstr "ìˆ˜ë™ ì‹¤í–‰"
+
+msgid "CiStatusLabel|passed"
+msgstr "성공"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "경고와 함께 성공"
+
+msgid "CiStatusLabel|pending"
+msgstr "대기"
+
+msgid "CiStatusLabel|skipped"
+msgstr "건너 뜀"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "ìˆ˜ë™ ì‹¤í–‰ 대기 중"
+
+msgid "CiStatusText|blocked"
+msgstr "차단ë¨"
+
+msgid "CiStatusText|canceled"
+msgstr "취소ë¨"
+
+msgid "CiStatusText|created"
+msgstr "ìƒì„±ë¨"
+
+msgid "CiStatusText|failed"
+msgstr "실패"
+
+msgid "CiStatusText|manual"
+msgstr "매뉴얼"
+
+msgid "CiStatusText|passed"
+msgstr "성공"
+
+msgid "CiStatusText|pending"
+msgstr "보류 중"
+
+msgid "CiStatusText|skipped"
+msgstr "건너 뜀"
+
+msgid "CiStatus|running"
+msgstr "실행 중"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "커밋"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "최근 30 ê±´ì˜ ì»¤ë°‹ 소요시간 (분)"
+
+msgid "Commit message"
+msgstr "커밋 메시지"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "커밋"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "%{file_name} 추가"
+
+msgid "Commits"
+msgstr "커밋"
+
+msgid "Commits feed"
+msgstr "커밋 피드"
+
+msgid "Commits|History"
+msgstr "ì´ë ¥"
+
+msgid "Committed by"
+msgstr "커밋한 사용ìž"
+
+msgid "Compare"
+msgstr "비êµ"
+
+msgid "Contribution guide"
+msgstr "ê¸°ì—¬ì— ëŒ€í•œ 안내"
+
+msgid "Contributors"
+msgstr "기여해 주신 분들"
+
+msgid "Copy URL to clipboard"
+msgstr "URLì„ í´ë¦½ë³´ë“œì— 복사"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "ì»¤ë°‹ì˜ SHA를 í´ë¦½ë³´ë“œë¡œ 복사합니다"
+
+msgid "Create New Directory"
+msgstr "새 디렉토리 만들기"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr "%{protocol}ì„ (를) 통해 Pull 하거나 Push í•  ê°œì¸ ì•¡ì„¸ìŠ¤ 토í°ì„ 만드십시오."
+
+msgid "Create directory"
+msgstr "디렉토리 만들기"
+
+msgid "Create empty bare repository"
+msgstr "빈 bare 저장소 만들기"
+
+msgid "Create merge request"
+msgstr "머지 리퀘스트 만들기"
+
+msgid "Create new..."
+msgstr "새로 만들기 ..."
+
+msgid "CreateNewFork|Fork"
+msgstr "í¬í¬"
+
+msgid "CreateTag|Tag"
+msgstr "태그"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "ê°œì¸ ì•¡ì„¸ìŠ¤ í† í° ë§Œë“¤ê¸°"
+
+msgid "Cron Timezone"
+msgstr "Cron 시간대"
+
+msgid "Cron syntax"
+msgstr "í¬ë¡  구문"
+
+msgid "Custom notification events"
+msgstr "ì‚¬ìš©ìž ì •ì˜ ì•Œë¦¼ ì´ë²¤íŠ¸"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"ì‚¬ìš©ìž ì •ì˜ ì•Œë¦¼ ìˆ˜ì¤€ì€ ì°¸ì—¬ 수준과 ë™ì¼í•©ë‹ˆë‹¤. 맞춤 알림 ìˆ˜ì¤€ì„ ì‚¬ìš©í•˜ë©´ ì¼ë¶€ ì´ë²¤íŠ¸ì— 대한 ì•Œë¦¼ë„ ë°›ê²Œë©ë‹ˆë‹¤. ìžì„¸í•œ ë‚´ìš©ì€ "
+"%{notification_link}ì„ í™•ì¸í•˜ì‹­ì‹œì˜¤."
+
+msgid "Cycle Analytics"
+msgstr "Cycle Analytics"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "Cycle Analytics는 프로ì íŠ¸ì—ì„œ ì•„ì´ë””어를 프로ë•ì…˜ìœ¼ë¡œ 옮기는 ë° ê±¸ë¦¬ëŠ” ì‹œê°„ì„ ëŒ€ëžµì ìœ¼ë¡œ ë³´ì—¬ì¤ë‹ˆë‹¤."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "코드"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "ì´ìŠˆ"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "계íš"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "프로ë•ì…˜"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "리뷰"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "스테ì´ì§•"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "테스트"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "cron êµ¬ë¬¸ì„ ì‚¬ìš©í•˜ì—¬ ì‚¬ìš©ìž ì •ì˜ íŒ¨í„´ ì •ì˜"
+
+msgid "Delete"
+msgstr "삭제 "
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "ë°°í¬"
+
+msgid "Description"
+msgstr "설명"
+
+msgid "Directory name"
+msgstr "디렉토리 ì´ë¦„"
+
+msgid "Don't show again"
+msgstr "다시 표시하지 ì•ŠìŒ"
+
+msgid "Download"
+msgstr "다운로드"
+
+msgid "Download tar"
+msgstr "tar 다운로드"
+
+msgid "Download tar.bz2"
+msgstr "tar.bz2 다운로드"
+
+msgid "Download tar.gz"
+msgstr "tar.gz 다운로드"
+
+msgid "Download zip"
+msgstr "zip 다운로드"
+
+msgid "DownloadArtifacts|Download"
+msgstr "다운로드"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "ì´ë©”ì¼ íŒ¨ì¹˜"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Plain Diff"
+
+msgid "DownloadSource|Download"
+msgstr "다운로드"
+
+msgid "Edit"
+msgstr "편집"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "파ì´í”„ë¼ì¸ 스케줄 편집 %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "ë§¤ì¼ (오전 4ì‹œì—)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "매월 (1ì¼ ì˜¤ì „ 4ì‹œ)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "매주 (ì¼ìš”ì¼ ì˜¤ì „ 4ì‹œì—)"
+
+msgid "Failed to change the owner"
+msgstr "소유ìžë¥¼ 변경하지 못했습니다"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "파ì´í”„ë¼ì¸ ìŠ¤ì¼€ì¤„ì„ ì œê±°í•˜ì§€ 못했습니다."
+
+msgid "Files"
+msgstr "파ì¼"
+
+msgid "Filter by commit message"
+msgstr "커밋 메시지로 필터"
+
+msgid "Find by path"
+msgstr "경로로 찾기"
+
+msgid "Find file"
+msgstr "íŒŒì¼ ì°¾ê¸°"
+
+msgid "FirstPushedBy|First"
+msgstr "처ìŒ"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "푸시한 사용ìž"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "í¬í¬"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "í¬í¬í•œ 사용ìž"
+
+msgid "From issue creation until deploy to production"
+msgstr "ì´ìŠˆ ìƒì„±ì—ì„œ 프로ë•ì…˜ ë°°í¬ê¹Œì§€"
+
+msgid "From merge request merge until deploy to production"
+msgstr "머지 리퀘스트 머지ì—ì„œ 프로ë•ì…˜ í™˜ê²½ì— ë°°í¬ê¹Œì§€"
+
+msgid "Go to your fork"
+msgstr "ë‹¹ì‹ ì˜ í¬í¬ë¡œ ì´ë™í•˜ì„¸ìš”"
+
+msgid "GoToYourFork|Fork"
+msgstr "í¬í¬"
+
+msgid "Home"
+msgstr "홈"
+
+msgid "Housekeeping successfully started"
+msgstr "Housekeepingì´ ì„±ê³µì ìœ¼ë¡œ 시작ë˜ì—ˆìŠµë‹ˆë‹¤"
+
+msgid "Import repository"
+msgstr "저장소 가져 오기"
+
+msgid "Interval Pattern"
+msgstr "주기 패턴"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Cycle Analytics 소개"
+
+msgid "Jobs for last month"
+msgstr "지난달 Jobs"
+
+msgid "Jobs for last week"
+msgstr "지난주 Jobs"
+
+msgid "Jobs for last year"
+msgstr "지난해 Jobs"
+
+msgid "LFSStatus|Disabled"
+msgstr "Disabled"
+
+msgid "LFSStatus|Enabled"
+msgstr "Enabled"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "최근 %d ì¼"
+
+msgid "Last Pipeline"
+msgstr "최근 파ì´í”„ë¼ì¸"
+
+msgid "Last Update"
+msgstr "최근 ì—…ë°ì´íŠ¸:"
+
+msgid "Last commit"
+msgstr "최근 커밋"
+
+msgid "Learn more in the"
+msgstr "ë” ìžì„¸ížˆ 알아보기"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "파ì´í”„ë¼ì¸ 스케쥴 문서로부터 ë” ì•Œì•„ë³´ê¸°"
+
+msgid "Leave group"
+msgstr "그룹 떠나기"
+
+msgid "Leave project"
+msgstr "프로ì íŠ¸ì—ì„œ 나가기"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "최대 %d ì´ë²¤íŠ¸ 만 표시하는 것으로 제한ë©ë‹ˆë‹¤."
+
+msgid "Median"
+msgstr "중앙값"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "SSH 키 추가"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "새 ì´ìŠˆ"
+
+msgid "New Pipeline Schedule"
+msgstr "새로운 파ì´í”„ë¼ì¸ ì¼ì •"
+
+msgid "New branch"
+msgstr "새 브랜치"
+
+msgid "New directory"
+msgstr "새 디렉토리"
+
+msgid "New file"
+msgstr "새 파ì¼"
+
+msgid "New issue"
+msgstr "새 ì´ìŠˆ"
+
+msgid "New merge request"
+msgstr "새 머지 리퀘스트"
+
+msgid "New schedule"
+msgstr "새 ì¼ì •"
+
+msgid "New snippet"
+msgstr "새 스니펫"
+
+msgid "New tag"
+msgstr "새 태그 "
+
+msgid "No repository"
+msgstr "저장소 ì—†ìŒ"
+
+msgid "No schedules"
+msgstr "ì¼ì • ì—†ìŒ"
+
+msgid "Not available"
+msgstr "사용할 수 ì—†ìŒ"
+
+msgid "Not enough data"
+msgstr "ë°ì´í„°ê°€ 충분하지 않습니다."
+
+msgid "Notification events"
+msgstr "알림 ì´ë²¤íŠ¸"
+
+msgid "NotificationEvent|Close issue"
+msgstr "ì´ìŠˆ 닫기"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "머지 리퀘스트 닫기"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "실패한 파ì´í”„ë¼ì¸"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "머지 리퀘스트 머지하기"
+
+msgid "NotificationEvent|New issue"
+msgstr "새 ì´ìŠˆ"
+
+msgid "NotificationEvent|New merge request"
+msgstr "새 머지 리퀘스트"
+
+msgid "NotificationEvent|New note"
+msgstr "새 노트"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "ì´ìŠˆ 재지정"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "머지 리퀘스트 재 할당"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "ì´ìŠˆ 다시 열기"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "성공ì ì¸ 파ì´í”„ë¼ì¸"
+
+msgid "NotificationLevel|Custom"
+msgstr "커스텀"
+
+msgid "NotificationLevel|Disabled"
+msgstr "사용 안 함"
+
+msgid "NotificationLevel|Global"
+msgstr "글로벌"
+
+msgid "NotificationLevel|On mention"
+msgstr "언급"
+
+msgid "NotificationLevel|Participate"
+msgstr "참여"
+
+msgid "NotificationLevel|Watch"
+msgstr "Watch"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "í•„í„°"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "열린"
+
+msgid "Options"
+msgstr "옵션 "
+
+msgid "Owner"
+msgstr "소유ìž"
+
+msgid "Pipeline"
+msgstr "파ì´í”„ë¼ì¸"
+
+msgid "Pipeline Health"
+msgstr "파ì´í”„ë¼ì¸ ìƒíƒœ"
+
+msgid "Pipeline Schedule"
+msgstr "파ì´í”„ë¼ì¸ 스케쥴"
+
+msgid "Pipeline Schedules"
+msgstr "파ì´í”„ë¼ì¸ 스케쥴"
+
+msgid "PipelineCharts|Failed:"
+msgstr "실패 :"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "전체 통계"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "성공 비율 :"
+
+msgid "PipelineCharts|Successful:"
+msgstr "성공 :"
+
+msgid "PipelineCharts|Total:"
+msgstr "합계 :"
+
+msgid "PipelineSchedules|Activated"
+msgstr "활성화 ë¨"
+
+msgid "PipelineSchedules|Active"
+msgstr "활성"
+
+msgid "PipelineSchedules|All"
+msgstr "모ë‘"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "비활성"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "입력 변수 키"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "입력 변수 값"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "ë‹¤ìŒ ì‹¤í–‰"
+
+msgid "PipelineSchedules|None"
+msgstr "ì—†ìŒ"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "ì´ íŒŒì´í”„ë¼ì¸ì— 대한 간단한 설명 제공"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "변수 행 제거"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "소유권 가져 오기"
+
+msgid "PipelineSchedules|Target"
+msgstr "대ìƒ"
+
+msgid "PipelineSchedules|Variables"
+msgstr "변수"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "ì‚¬ìš©ìž ì •ì˜"
+
+msgid "Pipelines"
+msgstr "파ì´í”„ë¼ì¸"
+
+msgid "Pipelines charts"
+msgstr "파ì´í”„ë¼ì¸ 차트"
+
+msgid "Pipeline|all"
+msgstr "모ë‘"
+
+msgid "Pipeline|success"
+msgstr "성공"
+
+msgid "Pipeline|with stage"
+msgstr "스테ì´ì§•"
+
+msgid "Pipeline|with stages"
+msgstr "스테ì´ì§•"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "'%{project_name}'프로ì íŠ¸ê°€ ì‚­ì œ 처리 중입니다."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "'%{project_name}'프로ì íŠ¸ê°€ 성공ì ìœ¼ë¡œ ìƒì„±ë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "'%{project_name}'프로ì íŠ¸ê°€ 성공ì ìœ¼ë¡œ ì—…ë°ì´íŠ¸ë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "'%{project_name}'프로ì íŠ¸ê°€ ì‚­ì œë©ë‹ˆë‹¤."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "프로ì íŠ¸ 액세스는 ê° ì‚¬ìš©ìžì—게 명시ì ìœ¼ë¡œ 부여ë˜ì–´ì•¼í•©ë‹ˆë‹¤."
+
+msgid "Project export could not be deleted."
+msgstr "프로ì íŠ¸ 내보내기를 삭제할 수 없습니다."
+
+msgid "Project export has been deleted."
+msgstr "프로ì íŠ¸ 내보내기가 ì‚­ì œë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr "프로ì íŠ¸ 내보내기 ë§í¬ê°€ 만료ë˜ì—ˆìŠµë‹ˆë‹¤. 프로ì íŠ¸ 설정ì—ì„œ 새 내보내기를 ìƒì„±í•˜ì‹­ì‹œì˜¤."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "프로ì íŠ¸ 내보내기가 시작ë˜ì—ˆìŠµë‹ˆë‹¤. 다운로드 ë§í¬ëŠ” ì´ë©”ì¼ë¡œ 전송ë©ë‹ˆë‹¤."
+
+msgid "Project home"
+msgstr "프로ì íŠ¸ 홈"
+
+msgid "ProjectFeature|Disabled"
+msgstr "사용 안 함"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "ì ‘ê·¼ ê¶Œí•œì„ ê°€ì§„ 모든 ì´ë“¤"
+
+msgid "ProjectFeature|Only team members"
+msgstr "íŒ€ì› ë§Œ"
+
+msgid "ProjectFileTree|Name"
+msgstr "ì´ë¦„"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Never"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "스테ì´ì§•"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "그래프"
+
+msgid "Read more"
+msgstr "ë” ì½ê¸°"
+
+msgid "Readme"
+msgstr "Readme"
+
+msgid "RefSwitcher|Branches"
+msgstr "브랜치"
+
+msgid "RefSwitcher|Tags"
+msgstr "태그"
+
+msgid "Related Commits"
+msgstr "관련 커밋"
+
+msgid "Related Deployed Jobs"
+msgstr "관련 ë°°í¬ ëœ ìž‘ì—…"
+
+msgid "Related Issues"
+msgstr "관련 ì´ìŠˆ"
+
+msgid "Related Jobs"
+msgstr "관련 Jobs"
+
+msgid "Related Merge Requests"
+msgstr "관련 머지 리퀘스트"
+
+msgid "Related Merged Requests"
+msgstr "관련 머지 리퀘스트"
+
+msgid "Remind later"
+msgstr "ë‚˜ì¤‘ì— ë‹¤ì‹œ 알림"
+
+msgid "Remove project"
+msgstr "프로ì íŠ¸ ì‚­ì œ"
+
+msgid "Request Access"
+msgstr "액세스 요청"
+
+msgid "Revert this commit"
+msgstr "ì´ ì»¤ë°‹ ë˜ëŒë¦¬ê¸°"
+
+msgid "Revert this merge request"
+msgstr "ì´ ë¨¸ì§€ 리퀘스트 ë˜ëŒë¦¬ê¸°"
+
+msgid "Save pipeline schedule"
+msgstr "파ì´í”„ë¼ì¸ 스케줄 저장"
+
+msgid "Schedule a new pipeline"
+msgstr "새로운 파ì´í”„ë¼ì¸ 스케줄 잡기"
+
+msgid "Scheduling Pipelines"
+msgstr "파ì´í”„ë¼ì¸ 스케줄ë§"
+
+msgid "Search branches and tags"
+msgstr "브랜치 ë° íƒœê·¸ 검색"
+
+msgid "Select Archive Format"
+msgstr "ì•„ì¹´ì´ë¸Œ í¬ë§· ì„ íƒ"
+
+msgid "Select a timezone"
+msgstr "시간대 ì„ íƒ"
+
+msgid "Select target branch"
+msgstr "ëŒ€ìƒ ë¸Œëžœì¹˜ ì„ íƒ"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "%{protocol} í”„ë¡œí† ì½œì„ í†µí•´ Pull 하거나 Push하려면 ê³„ì •ì— íŒ¨ìŠ¤ì›Œë“œë¥¼ 설정하십시오."
+
+msgid "Set up CI"
+msgstr "CI 설정"
+
+msgid "Set up Koding"
+msgstr "Koding 설정"
+
+msgid "Set up auto deploy"
+msgstr "ìžë™ ë°°í¬ ì„¤ì •"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "패스워드 설정"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "%d ê°œì˜ ì´ë²¤íŠ¸ 표시 중"
+
+msgid "Source code"
+msgstr "소스 코드"
+
+msgid "StarProject|Star"
+msgstr "별표"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "ì´ ë³€ê²½ 사항으로 %{new_merge_request} ì„ ì‹œìž‘í•˜ì‹­ì‹œì˜¤."
+
+msgid "Switch branch/tag"
+msgstr "스위치 브랜치/태그"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "태그"
+
+msgid "Tags"
+msgstr "태그 "
+
+msgid "Target Branch"
+msgstr "ëŒ€ìƒ ë¸Œëžœì¹˜"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Coding Stage는 첫 번째 커밋ì—서부터 머지 리퀘스트 ìƒì„±ê¹Œì§€ì˜ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. 첫 번째 머지 ë¦¬í€˜ìŠ¤íŠ¸ì„ ìƒì„±í•˜ë©´ ë°ì´í„°ê°€ "
+"ìžë™ìœ¼ë¡œ ì—¬ê¸°ì— ì¶”ê°€ë©ë‹ˆë‹¤."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "해당 단계ì—ì„œ 수집 ëœ ë°ì´í„°ê°€ ì´ë²¤íŠ¸ 모ìŒì— 추가ë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid "The fork relationship has been removed."
+msgstr "í¬í¬ 관계가 제거ë˜ì—ˆìŠµë‹ˆë‹¤."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"ì´ìŠˆ 단계ì—는 ì´ìŠˆë¥¼ 작성하여 마ì¼ìŠ¤í†¤ìœ¼ë¡œ 지정하는 ë° ê±¸ë¦¬ëŠ” 시간 ë˜ëŠ” ì´ìŠˆ ë³´ë“œì˜ ëª©ë¡ì— ì´ìŠˆë¥¼ 추가하는 ì‹œê°„ì´ í‘œì‹œë©ë‹ˆë‹¤. ì´ "
+"ë‹¨ê³„ì˜ ë°ì´í„°ë¥¼ 보기 위해서는 ì´ìŠˆë¥¼ 먼저 작성해야 합니다."
+
+msgid "The phase of the development lifecycle."
+msgstr "개발 ìˆ˜ëª…ì£¼ê¸°ì˜ ë‹¨ê³„."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"파ì´í”„ë¼ì¸ ì¼ì •ì€ ë¯¸ëž˜ì— íŠ¹ì • 브랜치 ë˜ëŠ” íƒœê·¸ì— ëŒ€í•´ 반복ì ìœ¼ë¡œ 파ì´í”„ë¼ì¸ì„ 실행합니다. ì˜ˆì •ëœ íŒŒì´í”„ë¼ì¸ì€ 관련 사용ìžë¥¼ 기반으로 "
+"ì œí•œëœ í”„ë¡œì íŠ¸ 액세스 ê¶Œí•œì„ ìƒì†ë°›ìŠµë‹ˆë‹¤."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr "ê³„íš ë‹¨ê³„ì—서는 ì´ì „ 단계ì—ì„œ 첫 번째 커밋 ì‹œê°„ì´ í‘œì‹œë©ë‹ˆë‹¤. ì´ ì‹œê°„ì€ ì²« 번째 ì»¤ë°‹ì„ ëˆ„ë¥´ë©´ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"프로ë•ì…˜ 단계ì—서는 문제를 만들고 코드를 프로ë•ì…˜ 환경으로 ë°°í¬í•˜ëŠ” ë° ê±¸ë¦¬ëŠ” ì´ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. ìƒì‚°ì£¼ê¸°ì— 대한 완전한 ì•„ì´ë””어를 "
+"ì–»ì€ í›„ì—는 ë°ì´í„°ê°€ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "ì´ í”„ë¡œì íŠ¸ëŠ” ë¡œê·¸ì¸ í•œ 사용ìžê°€ë§Œ 액세스 í•  수 있습니다."
+
+msgid "The project can be accessed without any authentication."
+msgstr "ì´ í”„ë¡œì íŠ¸ëŠ” ì¸ì¦ì—†ì´ 액세스 í•  수 있습니다."
+
+msgid "The repository for this project does not exist."
+msgstr "ì´ í”„ë¡œì íŠ¸ì˜ 저장소가 존재하지 않습니다."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Review 단계ì—서는 머지 리퀘스트를 작성한 후 ë¨¸ì§€í•˜ê¸°ê¹Œì§€ì˜ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. ë°ì´í„°ëŠ” 첫 번째 머지 ë¦¬í€˜ìŠ¤íŠ¸ì„ ë¨¸ì§€ í•œ í›„ì— "
+"ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Staging 단계ì—서는 MR 머지과 프로ë•ì…˜ í™˜ê²½ì— ì½”ë“œ ë°°í¬ ì‚¬ì´ì˜ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. ë°ì´í„°ë¥¼ Production í™˜ê²½ì— ì²˜ìŒ "
+"ë°°í¬í•˜ë©´ ë°ì´í„°ê°€ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"테스트 단계ì—서는 GitLab CIê°€ 관련 머지 ë¦¬í€˜ìŠ¤íŠ¸ì„ ìœ„í•´ 모든 파ì´í”„ë¼ì¸ì„ 실행하는 ë° ê±¸ë¦¬ëŠ” ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. 첫 번째 "
+"파ì´í”„ë¼ì¸ ì‹¤í–‰ì´ ì™„ë£Œë˜ë©´ ë°ì´í„°ê°€ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "해당 단계ì—ì„œ 수집 í•œ ê° ë°ì´í„° ìž…ë ¥ì— ì†Œìš” ëœ ì‹œê°„"
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"ê°’ì€ ì¼ë ¨ì˜ 관측 ê°’ 중ì ì— 있습니다. 예를 들어, 3, 5, 9 사ì´ì˜ 중간 ê°’ì€ 5입니다. 3, 5, 7, 8 사ì´ì˜ 중간 ê°’ì€ (5 "
+"+ 7) / 2 = 6입니다."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr "즉, 빈 저장소를 만들거나 기존 저장소를 가져올 때까지 코드를 Push 할 수 없습니다."
+
+msgid "Time before an issue gets scheduled"
+msgstr "ì´ìŠˆê°€ 스케줄ë˜ê¸° ì „ì˜ ì‹œê°„"
+
+msgid "Time before an issue starts implementation"
+msgstr "ì´ìŠˆê°€ 구현ë˜ê¸° ì „ì˜ ì‹œê°„"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "머지 리퀘스트 ìƒì„±ê³¼ 머지 / 닫기 사ì´ì˜ 시간"
+
+msgid "Time until first merge request"
+msgstr "첫 번째 머지 ë¦¬í€˜ìŠ¤íŠ¸ê¹Œì§€ì˜ ì‹œê°„"
+
+msgid "Timeago|%s days ago"
+msgstr "%s ì¼ ì „"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s ì¼ ë‚¨ìŒ"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s 시간 남ìŒ"
+
+msgid "Timeago|%s minutes ago"
+msgstr "%s 분 전"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s 분 남ìŒ"
+
+msgid "Timeago|%s months ago"
+msgstr "%s 개월 전"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s 개월 남ìŒ"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s ì´ˆ 남ìŒ"
+
+msgid "Timeago|%s weeks ago"
+msgstr "%s 주 전"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s 주 남ìŒ"
+
+msgid "Timeago|%s years ago"
+msgstr "%s ë…„ ì „"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s ë…„ 남ìŒ"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 ì¼ ë‚¨ìŒ"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 시간 남ìŒ"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 분 남ìŒ"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 개월 남ìŒ"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 주 남ìŒ"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 ë…„ 남ìŒ"
+
+msgid "Timeago|Past due"
+msgstr "기한 초과"
+
+msgid "Timeago|a day ago"
+msgstr "1 ì¼ ì „"
+
+msgid "Timeago|a month ago"
+msgstr "1 달 전"
+
+msgid "Timeago|a week ago"
+msgstr "1 ì£¼ì¼ ì „"
+
+msgid "Timeago|a while"
+msgstr "잠시 전"
+
+msgid "Timeago|a year ago"
+msgstr "1 ë…„ ì „"
+
+msgid "Timeago|about %s hours ago"
+msgstr "약 %s 시간 전"
+
+msgid "Timeago|about a minute ago"
+msgstr "약 1 분 전"
+
+msgid "Timeago|about an hour ago"
+msgstr "약 1 시간 전"
+
+msgid "Timeago|in %s days"
+msgstr "%s ì¼ ì´ë‚´"
+
+msgid "Timeago|in %s hours"
+msgstr "%s 시간 ì´ë‚´"
+
+msgid "Timeago|in %s minutes"
+msgstr "%s 분 ì´ë‚´"
+
+msgid "Timeago|in %s months"
+msgstr "%s 개월 ì´ë‚´"
+
+msgid "Timeago|in %s seconds"
+msgstr "%s ì´ˆ ì´ë‚´"
+
+msgid "Timeago|in %s weeks"
+msgstr "%s 주 ì´ë‚´"
+
+msgid "Timeago|in %s years"
+msgstr "%s ë…„ ì´ë‚´"
+
+msgid "Timeago|in 1 day"
+msgstr "1 ì¼ ì´ë‚´"
+
+msgid "Timeago|in 1 hour"
+msgstr "1 시간 ì´ë‚´"
+
+msgid "Timeago|in 1 minute"
+msgstr "1 분 ì´ë‚´"
+
+msgid "Timeago|in 1 month"
+msgstr "1 개월 ì´ë‚´"
+
+msgid "Timeago|in 1 week"
+msgstr "1 ì£¼ì¼ ì´ë‚´"
+
+msgid "Timeago|in 1 year"
+msgstr "1 ë…„ ì´ë‚´"
+
+msgid "Timeago|less than a minute ago"
+msgstr "1 분미만"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "시간"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "분"
+
+msgid "Time|s"
+msgstr "ì´ˆ"
+
+msgid "Total Time"
+msgstr "시간 합계:"
+
+msgid "Total test time for all commits/merges"
+msgstr "모든 커밋 / ë¨¸ì§€ì˜ ì´ í…ŒìŠ¤íŠ¸ 시간"
+
+msgid "Unstar"
+msgstr "별표 제거"
+
+msgid "Upload New File"
+msgstr "새 íŒŒì¼ ì—…ë¡œë“œ"
+
+msgid "Upload file"
+msgstr "íŒŒì¼ ì—…ë¡œë“œ"
+
+msgid "UploadLink|click to upload"
+msgstr "업로드하려면 í´ë¦­í•˜ì‹­ì‹œì˜¤."
+
+msgid "Use your global notification setting"
+msgstr "전체 알림 설정 사용"
+
+msgid "View open merge request"
+msgstr "열린 머지 리퀘스트보기"
+
+msgid "VisibilityLevel|Internal"
+msgstr "내부"
+
+msgid "VisibilityLevel|Private"
+msgstr "Private"
+
+msgid "VisibilityLevel|Public"
+msgstr "Public"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "ì•Œ 수 ì—†ìŒ"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "ì´ ë°ì´í„°ë¥¼ ë³´ê³  싶ì€ê°€ìš”? 관리ìžì—게 액세스 ê¶Œí•œì„ ìš”ì²­í•˜ì„¸ìš”."
+
+msgid "We don't have enough data to show this stage."
+msgstr "ì´ ë‹¨ê³„ë¥¼ ë³´ì—¬ì£¼ê¸°ì— ì¶©ë¶„í•œ ë°ì´í„°ê°€ 없습니다."
+
+msgid "Withdraw Access Request"
+msgstr "액세스 요청 철회"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "%{group_name} ê·¸ë£¹ì„ ì œê±°í•˜ë ¤ê³ í•©ë‹ˆë‹¤. \"ì •ë§ë¡œ\" 확실합니까?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "%{project_name_with_namespace} 프로ì íŠ¸ë¥¼ 삭제하려고합니다. "ì‚­ì œëœ í”„ë¡œì íŠ¸ë¥¼ ë³µì› í•  수 없습니다! \"ì •ë§ë¡œ\" 확실합니까?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "í¬í¬ 관계를 소스 프로ì íŠ¸ %{forked_from_project}ì— ëŒ€í•´ 제거하려고합니다. \"ì •ë§ë¡œ\" 확실합니까?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr "%{project_name_with_namespace}ì„ ë‹¤ë¥¸ 소유ìžì—게 ì´ì „하려고합니다. \"ì •ë§ë¡œ\" 확실합니까?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "ë¸Œëžœì¹˜ì— ìžˆì„ ë•Œì—만 파ì¼ì„ 추가 í•  수 있습니다."
+
+msgid "You have reached your project limit"
+msgstr "프로ì íŠ¸ ìˆ«ìž í•œë„ì— ë„달했습니다."
+
+msgid "You must sign in to star a project"
+msgstr "프로ì íŠ¸ì— 별표를 표시하려면 ë¡œê·¸ì¸ í•´ì•¼ 합니다."
+
+msgid "You need permission."
+msgstr "ê¶Œí•œì´ í•„ìš”í•©ë‹ˆë‹¤."
+
+msgid "You will not get any notifications via email"
+msgstr "ì´ë©”ì¼ë¡œ ì•Œë¦¼ì„ ë°›ì§€ 않습니다."
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "ì„ íƒí•œ ì´ë²¤íŠ¸ì— 대한 알림만 받습니다."
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "참여한 ìŠ¤ë ˆë“œì— ëŒ€í•œ 알림만 받습니다."
+
+msgid "You will receive notifications for any activity"
+msgstr "모든 활ë™ì— 대한 ì•Œë¦¼ì„ ë°›ê²Œë©ë‹ˆë‹¤."
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "ë‹¹ì‹ ì€ ë‹¹ì‹ ì´ @mentioned í•œ ì½”ë©˜íŠ¸ì— ëŒ€í•´ì„œë§Œ 통지를 받게ë©ë‹ˆë‹¤."
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"ë‹¹ì‹ ì˜ ê³„ì •ì— %{set_password_link} ì„ í•˜ê¸° ì „ì—는 %{protocol} í”„ë¡œí† ì½œì„ í†µí•´ 프로ì íŠ¸ 코드를 Pull 하거나 "
+"Push 할 수 없습니다"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"ë‹¹ì‹ ì˜ í”„ë¡œí•„ì— %{add_ssh_key_link} 를 하기 ì „ì—는 SSH를 통해 프로ì íŠ¸ 코드를 Pull 하거나 Push í•  수 "
+"없습니다"
+
+msgid "Your name"
+msgstr "ê·€í•˜ì˜ ì´ë¦„"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "ì¼"
+
+msgid "new merge request"
+msgstr "새 머지 리퀘스트"
+
+msgid "notification emails"
+msgstr "알림 ì´ë©”ì¼"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "부모"
+
diff --git a/locale/ko/gitlab.po.time_stamp b/locale/ko/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/ko/gitlab.po.time_stamp
diff --git a/locale/pt_BR/gitlab.po b/locale/pt_BR/gitlab.po
new file mode 100644
index 00000000000..a2df8ea549c
--- /dev/null
+++ b/locale/pt_BR/gitlab.po
@@ -0,0 +1,1243 @@
+# Alexandre Alencar <alexandre.alencar@gmail.com>, 2017. #zanata
+# Fabio Beneditto <fabiobeneditto@gmail.com>, 2017. #zanata
+# Leandro Nunes dos Santos <leandronunes@gmail.com>, 2017. #zanata
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: Portuguese (Brazil) (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-03 11:29-0400\n"
+"Last-Translator: Alexandre Alencar <alexandre.alencar@gmail.com>\n"
+"Language: pt-BR\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+"%s commit adicional foi omitido para prevenir problemas de performance."
+msgstr[1] ""
+"%s commits adicionais foram omitidos para prevenir problemas de performance."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d commit"
+msgstr[1] "%d commits"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} fez commit %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Uma coleção de gráficos sobre Integração Contínua"
+
+msgid "About auto deploy"
+msgstr "Sobre o deploy automático"
+
+msgid "Active"
+msgstr "Ativo"
+
+msgid "Activity"
+msgstr "Atividade"
+
+msgid "Add Changelog"
+msgstr "Adicionar registro de mudanças"
+
+msgid "Add Contribution guide"
+msgstr "Adicionar Guia de contribuição"
+
+msgid "Add License"
+msgstr "Adicionar Licença"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "Adicionar chave SSH ao seu perfil para fazer pull ou push via SSH."
+
+msgid "Add new directory"
+msgstr "Adicionar novo diretório"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Projeto arquivado! O repositório é somente leitura"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Tem certeza que deseja excluir este agendamento de pipeline?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Para anexar arquivo, arraste e solte ou %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Branch"
+msgstr[1] "Branches"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"O branch <strong>%{branch_name}</strong> foi criado. Para configurar o "
+"deploy automático, selecione um modelo de Yaml do GitLab CI e commit suas "
+"mudanças. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Procurar por branches"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Mudar de branch"
+
+msgid "Branches"
+msgstr "Branches"
+
+msgid "Browse Directory"
+msgstr "Navegar no Diretório"
+
+msgid "Browse File"
+msgstr "Pesquisar Arquivo"
+
+msgid "Browse Files"
+msgstr "Pesquisar Arquivos"
+
+msgid "Browse files"
+msgstr "Navegar pelos arquivos"
+
+msgid "ByAuthor|by"
+msgstr "por"
+
+msgid "CI configuration"
+msgstr "Configuração da IC"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Pick para um branch"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Reverter no branch"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Reverter"
+
+msgid "Changelog"
+msgstr "Registro de mudanças"
+
+msgid "Charts"
+msgstr "Gráficos"
+
+msgid "Cherry-pick this commit"
+msgstr "Cherry-pick esse commit"
+
+msgid "Cherry-pick this merge request"
+msgstr "Cherry-pick esse merge request"
+
+msgid "CiStatusLabel|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusLabel|created"
+msgstr "criado"
+
+msgid "CiStatusLabel|failed"
+msgstr "falhou"
+
+msgid "CiStatusLabel|manual action"
+msgstr "ação manual"
+
+msgid "CiStatusLabel|passed"
+msgstr "passou"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "passou com avisos"
+
+msgid "CiStatusLabel|pending"
+msgstr "pendente"
+
+msgid "CiStatusLabel|skipped"
+msgstr "ignorado"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "aguardando ação manual"
+
+msgid "CiStatusText|blocked"
+msgstr "bloqueado"
+
+msgid "CiStatusText|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusText|created"
+msgstr "criado"
+
+msgid "CiStatusText|failed"
+msgstr "falhou"
+
+msgid "CiStatusText|manual"
+msgstr "manual"
+
+msgid "CiStatusText|passed"
+msgstr "passou"
+
+msgid "CiStatusText|pending"
+msgstr "pendente"
+
+msgid "CiStatusText|skipped"
+msgstr "ignorado"
+
+msgid "CiStatus|running"
+msgstr "executando"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Commit"
+msgstr[1] "Commits"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Duração do commit em minutos para os últimos 30 commits"
+
+msgid "Commit message"
+msgstr "Mensagem de commit"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Commit"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Adicionar %{file_name}"
+
+msgid "Commits"
+msgstr "Commits"
+
+msgid "Commits feed"
+msgstr "Feed de commits"
+
+msgid "Commits|History"
+msgstr "Histórico"
+
+msgid "Committed by"
+msgstr "Commit feito por"
+
+msgid "Compare"
+msgstr "Comparar"
+
+msgid "Contribution guide"
+msgstr "Guia de contribuição"
+
+msgid "Contributors"
+msgstr "Contribuidores"
+
+msgid "Copy URL to clipboard"
+msgstr "Copiar URL para área de transferência"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copiar SHA do commit para a área de transferência"
+
+msgid "Create New Directory"
+msgstr "Criar Novo Diretório"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Crie um token de acesso pessoal na sua conta para dar pull ou push via "
+"%{protocol}."
+
+msgid "Create directory"
+msgstr "Criar diretório"
+
+msgid "Create empty bare repository"
+msgstr "Criar repositório bruto vazio"
+
+msgid "Create merge request"
+msgstr "Criar merge request"
+
+msgid "Create new..."
+msgstr "Criar novo..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Fork"
+
+msgid "CreateTag|Tag"
+msgstr "Tag"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "criar um token de acesso pessoal"
+
+msgid "Cron Timezone"
+msgstr "Fuso horário do cron"
+
+msgid "Cron syntax"
+msgstr "Sintaxe do cron"
+
+msgid "Custom notification events"
+msgstr "Eventos de notificação personalizados"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"Níveis de notificação personalizados são equivalentes a níveis de "
+"participação. Com níveis de notificação personalizados você também será "
+"notificado sobre eventos selecionados. Para mais informações, visite "
+"%{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Análise de Ciclo"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"A Análise de Ciclo fornece uma visão geral de quanto tempo uma ideia demora "
+"para ir para produção em seu projeto."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Código"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Issue"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Plano"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Produção"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisão"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Homologação"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Teste"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Defina um padrão personalizado utilizando a sintaxe do cron"
+
+msgid "Delete"
+msgstr "Excluir"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Implantação"
+msgstr[1] "Implantações"
+
+msgid "Description"
+msgstr "Descrição"
+
+msgid "Directory name"
+msgstr "Nome do diretório"
+
+msgid "Don't show again"
+msgstr "Não exibir novamente"
+
+msgid "Download"
+msgstr "Baixar"
+
+msgid "Download tar"
+msgstr "Baixar tar"
+
+msgid "Download tar.bz2"
+msgstr "Baixar tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Baixar tar.gz"
+
+msgid "Download zip"
+msgstr "Baixar zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Baixar"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Email com as mudanças"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Arquivo de texto com as mudanças"
+
+msgid "DownloadSource|Download"
+msgstr "Baixar"
+
+msgid "Edit"
+msgstr "Alterar"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Alterar Agendamento do Pipeline %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Todos os dias (às 4:00)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Todos os meses (no dia primeiro às 4:00)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Toda semana (domingos às 4:00)"
+
+msgid "Failed to change the owner"
+msgstr "Erro ao alterar o proprietário"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Erro ao excluir o agendamento do pipeline"
+
+msgid "Files"
+msgstr "Arquivos"
+
+msgid "Filter by commit message"
+msgstr "Filtrar por mensagem de commit"
+
+msgid "Find by path"
+msgstr "Localizar por caminho"
+
+msgid "Find file"
+msgstr "Localizar arquivo"
+
+msgid "FirstPushedBy|First"
+msgstr "Primeiro"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "publicado por"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Fork"
+msgstr[1] "Forks"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Fork criado a partir de"
+
+msgid "From issue creation until deploy to production"
+msgstr "Da abertura de tarefas até a implantação para a produção"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Do merge request até a implantação em produção"
+
+msgid "Go to your fork"
+msgstr "Ir para seu fork"
+
+msgid "GoToYourFork|Fork"
+msgstr "Fork"
+
+msgid "Home"
+msgstr "Início"
+
+msgid "Housekeeping successfully started"
+msgstr "Manutenção iniciada com sucesso"
+
+msgid "Import repository"
+msgstr "Importar repositório"
+
+msgid "Interval Pattern"
+msgstr "Padrão de intervalo"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Apresentando a Análise de Ciclo"
+
+msgid "Jobs for last month"
+msgstr "Jobs no último mês"
+
+msgid "Jobs for last week"
+msgstr "Jobs na última semana"
+
+msgid "Jobs for last year"
+msgstr "Jobs no último ano"
+
+msgid "LFSStatus|Disabled"
+msgstr "Desabilitado"
+
+msgid "LFSStatus|Enabled"
+msgstr "Habilitado"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Último %d dia"
+msgstr[1] "Últimos %d dias"
+
+msgid "Last Pipeline"
+msgstr "Último Pipeline"
+
+msgid "Last Update"
+msgstr "Última Atualização"
+
+msgid "Last commit"
+msgstr "Último commit"
+
+msgid "Learn more in the"
+msgstr "Saiba mais em"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentação de agendamento de pipeline"
+
+msgid "Leave group"
+msgstr "Sair do grupo"
+
+msgid "Leave project"
+msgstr "Sair do projeto"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limitado a mostrar %d evento, no máximo"
+msgstr[1] "Limitado a mostrar %d eventos, no máximo"
+
+msgid "Median"
+msgstr "Mediana"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "adicione uma chave SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nova Issue"
+msgstr[1] "Novas Issues"
+
+msgid "New Pipeline Schedule"
+msgstr "Novo Agendamento de Pipeline"
+
+msgid "New branch"
+msgstr "Novo branch"
+
+msgid "New directory"
+msgstr "Novo diretório"
+
+msgid "New file"
+msgstr "Novo arquivo"
+
+msgid "New issue"
+msgstr "Nova issue"
+
+msgid "New merge request"
+msgstr "Novo merge request"
+
+msgid "New schedule"
+msgstr "Novo agendamento"
+
+msgid "New snippet"
+msgstr "Novo snippet"
+
+msgid "New tag"
+msgstr "Nova tag"
+
+msgid "No repository"
+msgstr "Nenhum repositório"
+
+msgid "No schedules"
+msgstr "Nenhum agendamento"
+
+msgid "Not available"
+msgstr "Não disponível"
+
+msgid "Not enough data"
+msgstr "Dados insuficientes"
+
+msgid "Notification events"
+msgstr "Eventos de notificação"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Fechar issue"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Fechar merge request"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Falha no pipeline"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Aceitar merge request"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nova issue"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Novo merge request"
+
+msgid "NotificationEvent|New note"
+msgstr "Novo comentário"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Reatribuir issue"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Reatribuir merge request"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Reabrir issue"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline bem sucedido"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personalizado"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Desabilitado"
+
+msgid "NotificationLevel|Global"
+msgstr "Global"
+
+msgid "NotificationLevel|On mention"
+msgstr "Quando mencionado"
+
+msgid "NotificationLevel|Participate"
+msgstr "Participar"
+
+msgid "NotificationLevel|Watch"
+msgstr "Observar"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtrar"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Aberto"
+
+msgid "Options"
+msgstr "Opções"
+
+msgid "Owner"
+msgstr "Proprietário"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Saúde da Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Agendamento da Pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Agendamentos da Pipeline"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Falhou:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Estatísticas gerais"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Taxa de sucesso:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Sucesso:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Total:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Ativado"
+
+msgid "PipelineSchedules|Active"
+msgstr "Ativo"
+
+msgid "PipelineSchedules|All"
+msgstr "Todos"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inativo"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Chave da variável de entrada"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Valor da variável de entrada"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Próxima Execução"
+
+msgid "PipelineSchedules|None"
+msgstr "Nenhum"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Digite uma descrição curta para esta pipeline"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Remova a linha da variável"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Tornar-se proprietário"
+
+msgid "PipelineSchedules|Target"
+msgstr "Destino"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variáveis"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personalizado"
+
+msgid "Pipelines"
+msgstr "Pipelines"
+
+msgid "Pipelines charts"
+msgstr "Gráficos de pipelines"
+
+msgid "Pipeline|all"
+msgstr "todos"
+
+msgid "Pipeline|success"
+msgstr "sucesso"
+
+msgid "Pipeline|with stage"
+msgstr "com etapa"
+
+msgid "Pipeline|with stages"
+msgstr "com etapas"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Projeto'%{project_name}' marcado para exclusão."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Projeto '%{project_name}' criado com sucesso."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Projeto '%{project_name}' atualizado com sucesso."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Projeto '%{project_name}' será excluído."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr ""
+"Acesso ao projeto deve ser concedido explicitamente para cada usuário."
+
+msgid "Project export could not be deleted."
+msgstr "A exportação do projeto não pôde ser excluída."
+
+msgid "Project export has been deleted."
+msgstr "Exportação do projeto excluída."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"O link para a exportação do projeto expirou. Favor gerar uma nova exportação "
+"a partir das configurações do projeto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"Exportação do projeto iniciada. Um link para baixá-la será enviado por email."
+""
+
+msgid "Project home"
+msgstr "Página inicial do projeto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Desabilitado"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Todos que possuem acesso"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Apenas membros do time"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nome"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Nunca"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapa"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Ãrvore"
+
+msgid "Read more"
+msgstr "Leia mais"
+
+msgid "Readme"
+msgstr "Leia-me"
+
+msgid "RefSwitcher|Branches"
+msgstr "Branches"
+
+msgid "RefSwitcher|Tags"
+msgstr "Tags"
+
+msgid "Related Commits"
+msgstr "Commits Relacionados"
+
+msgid "Related Deployed Jobs"
+msgstr "Tarefas Implantadas Relacionadas"
+
+msgid "Related Issues"
+msgstr "Issues Relacionadas"
+
+msgid "Related Jobs"
+msgstr "Tarefas Relacionadas"
+
+msgid "Related Merge Requests"
+msgstr "Merge Requests Relacionados"
+
+msgid "Related Merged Requests"
+msgstr "Merge Requests Relacionados"
+
+msgid "Remind later"
+msgstr "Lembrar mais tarde"
+
+msgid "Remove project"
+msgstr "Remover projeto"
+
+msgid "Request Access"
+msgstr "Solicitar acesso"
+
+msgid "Revert this commit"
+msgstr "Reverter este commit"
+
+msgid "Revert this merge request"
+msgstr "Reverter esse merge request"
+
+msgid "Save pipeline schedule"
+msgstr "Salvar agendamento da pipeline"
+
+msgid "Schedule a new pipeline"
+msgstr "Agendar nova pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Agendando pipelines"
+
+msgid "Search branches and tags"
+msgstr "Procurar branch e tags"
+
+msgid "Select Archive Format"
+msgstr "Selecionar Formato do Arquivo"
+
+msgid "Select a timezone"
+msgstr "Selecionar fuso horário"
+
+msgid "Select target branch"
+msgstr "Selecionar branch de destino"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Defina uma senha para sua conta para aceitar ou entregar código via "
+"%{protocol}."
+
+msgid "Set up CI"
+msgstr "Configurar CI"
+
+msgid "Set up Koding"
+msgstr "Configurar Koding"
+
+msgid "Set up auto deploy"
+msgstr "Configurar implantação automática"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "defina uma senha"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Mostrando %d evento"
+msgstr[1] "Mostrando %d eventos"
+
+msgid "Source code"
+msgstr "Código-fonte"
+
+msgid "StarProject|Star"
+msgstr "Marcar"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Iniciar um %{new_merge_request} a partir dessas alterações"
+
+msgid "Switch branch/tag"
+msgstr "Trocar branch/tag"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Tag"
+msgstr[1] "Tags"
+
+msgid "Tags"
+msgstr "Tags"
+
+msgid "Target Branch"
+msgstr "Branch de destino"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"A etapa de codificação mostra o tempo desde a entrega do primeiro commit até "
+"a criação do merge request. Os dados serão automaticamente adicionados aqui "
+"desde o momento de criação do merge request."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "A coleção de eventos adicionados aos dados coletados para essa etapa."
+
+msgid "The fork relationship has been removed."
+msgstr "O relacionamento como fork foi removido."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"A etapa de planejamento mostra o tempo que se leva desde a criação de uma "
+"issue até sua atribuição à um milestone, ou sua adição a uma lista no seu "
+"Issue Board. Comece a criar issues para ver dados para esta etapa."
+
+msgid "The phase of the development lifecycle."
+msgstr "A fase do ciclo de vida do desenvolvimento."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"O agendamento de pipeline executa pipelines no futuro, repetidamente, para "
+"branches ou tags específicas. Essas pipelines agendadas terão acesso "
+"limitado ao projeto baseado no seu usuário associado."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"A etapa de planejamento mostra o tempo do passo anterior até a publicação de "
+"seu primeiro conjunto de mudanças. Este tempo será adicionado "
+"automaticamente assim que você enviar seu primeiro conjunto de mudanças."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"A etapa de produção mostra o tempo total que leva entre criar uma issue e "
+"implantar o código em produção. Os dados serão adicionados automaticamente "
+"assim que você completar todo o ciclo de produção."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "O projeto pode ser acessado por qualquer usuário autenticado."
+
+msgid "The project can be accessed without any authentication."
+msgstr "O projeto pode ser acessado sem a necessidade de autenticação."
+
+msgid "The repository for this project does not exist."
+msgstr "Não existe repositório para este projeto."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"A etapa de revisão mostra o tempo de criação de uma solicitação de "
+"incorporação até sua aceitação. Os dados serão automaticamente adicionados "
+"depois que sua primeira solicitação de incorporação for aceita."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"A etapa de homologação mostra o tempo entre o aceite da solicitação de "
+"incorporação e a implantação do código no ambiente de produção. Os dados "
+"serão automaticamente adicionados depois que você implantar em produção pela "
+"primeira vez."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"A etapa de testes mostra o tempo que o GitLab CI leva para executar cada "
+"pipeline para a solicitação de incorporação associada. Os dados serão "
+"automaticamente adicionados após a conclusão do primeiro pipeline."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "O tempo necessário por cada entrada de dados reunida por essa etapa."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"O valor situado no ponto médio de uma série de valores observados. Ex., "
+"entre 3, 5, 9, a mediana é 5. Entre 3, 5, 7, 8, a mediana é (5+7)/2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Isto significa que você não pode entregar código até que crie um repositório "
+"vazio ou importe um existente."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tempo até que uma issue seja agendada"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tempo até que uma issue comece a ser implementado"
+
+msgid "Time between merge request creation and merge/close"
+msgstr ""
+"Tempo entre a criação da solicitação de incorporação e a aceitação/"
+"fechamento"
+
+msgid "Time until first merge request"
+msgstr "Tempo até a primeira solicitação de incorporação"
+
+msgid "Timeago|%s days ago"
+msgstr "há %s dias"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s dias restantes"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s horas restantes"
+
+msgid "Timeago|%s minutes ago"
+msgstr "há %s minutos"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s minutos restantes"
+
+msgid "Timeago|%s months ago"
+msgstr "há %s meses"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s meses restantes"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s segundos restantes"
+
+msgid "Timeago|%s weeks ago"
+msgstr "há %s semanas"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s semanas restantes"
+
+msgid "Timeago|%s years ago"
+msgstr "há %s anos"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s anos restantes"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 dia restante"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 hora restante"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 minuto restante"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 mês restante"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 semana restante"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 ano restante"
+
+msgid "Timeago|Past due"
+msgstr "Venceu"
+
+msgid "Timeago|a day ago"
+msgstr "há um dia"
+
+msgid "Timeago|a month ago"
+msgstr "há um mês"
+
+msgid "Timeago|a week ago"
+msgstr "há uma semana"
+
+msgid "Timeago|a while"
+msgstr "há algum tempo"
+
+msgid "Timeago|a year ago"
+msgstr "há um ano"
+
+msgid "Timeago|about %s hours ago"
+msgstr "há cerca de %s horas"
+
+msgid "Timeago|about a minute ago"
+msgstr "há cerca de um minuto"
+
+msgid "Timeago|about an hour ago"
+msgstr "há cerca de uma hora"
+
+msgid "Timeago|in %s days"
+msgstr "em %s dias"
+
+msgid "Timeago|in %s hours"
+msgstr "em %s horas"
+
+msgid "Timeago|in %s minutes"
+msgstr "em %s minutos"
+
+msgid "Timeago|in %s months"
+msgstr "em %s meses"
+
+msgid "Timeago|in %s seconds"
+msgstr "em %s segundos"
+
+msgid "Timeago|in %s weeks"
+msgstr "em %s semanas"
+
+msgid "Timeago|in %s years"
+msgstr "em %s anos"
+
+msgid "Timeago|in 1 day"
+msgstr "em 1 dia"
+
+msgid "Timeago|in 1 hour"
+msgstr "em 1 hora"
+
+msgid "Timeago|in 1 minute"
+msgstr "em 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "em 1 mês"
+
+msgid "Timeago|in 1 week"
+msgstr "em 1 semana"
+
+msgid "Timeago|in 1 year"
+msgstr "em 1 ano"
+
+msgid "Timeago|less than a minute ago"
+msgstr "há menos de um minuto"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "h"
+msgstr[1] "hs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tempo Total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tempo de teste total para todos os commits/merges"
+
+msgid "Unstar"
+msgstr "Desmarcar"
+
+msgid "Upload New File"
+msgstr "Enviar Novo Arquivo"
+
+msgid "Upload file"
+msgstr "Enviar arquivo"
+
+msgid "UploadLink|click to upload"
+msgstr "clique para fazer upload"
+
+msgid "Use your global notification setting"
+msgstr "Utilizar configuração de notificação global"
+
+msgid "View open merge request"
+msgstr "Ver merge request aberto"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interno"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privado"
+
+msgid "VisibilityLevel|Public"
+msgstr "Público"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Desconhecido"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Precisa visualizar os dados? Solicite acesso ao administrador."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Esta etapa não possui dados suficientes para exibição."
+
+msgid "Withdraw Access Request"
+msgstr "Remover Requisição de Acesso"
+
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Você vai remover %{group_name}. Grupos removidos NÃO PODEM ser restaurados! Você está ABSOLUTAMENTE certo?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Você irá remover %{project_name_with_namespace}. O projeto removido NÃO PODE ser restaurado! Tem certeza ABSOLUTA?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Você irá transferir %{project_name_with_namespace} para outro proprietário. "
+"Tem certeza ABSOLUTA?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Você somente pode adicionar arquivos quando estiver em um branch"
+
+msgid "You have reached your project limit"
+msgstr "Você atingiu o limite de seu projeto"
+
+msgid "You must sign in to star a project"
+msgstr "Você deve estar autenticado para marcar um projeto"
+
+msgid "You need permission."
+msgstr "Você precisa de permissão."
+
+msgid "You will not get any notifications via email"
+msgstr "Você não será notificado por email"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Você será notificado apenas sobre eventos selecionados"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "Você será notificado apenas sobre tópicos nos quais participou"
+
+msgid "You will receive notifications for any activity"
+msgstr "Você será notificado sobre qualquer atividade"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "Você será notificado apenas sobre comentários que te @mencionam"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Você não poderá fazer pull ou push via %{protocol} até que "
+"%{set_password_link} para sua conta"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Você não conseguirá fazer pull ou push no projeto via SSH até que adicione "
+"%{add_ssh_key_link} ao seu perfil"
+
+msgid "Your name"
+msgstr "Seu nome"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "dia"
+msgstr[1] "dias"
+
+msgid "new merge request"
+msgstr "novo merge request"
+
+msgid "notification emails"
+msgstr "emails de notificação"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "pai"
+msgstr[1] "pais"
+
diff --git a/locale/pt_BR/gitlab.po.time_stamp b/locale/pt_BR/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/pt_BR/gitlab.po.time_stamp
diff --git a/locale/ru/gitlab.po b/locale/ru/gitlab.po
new file mode 100644
index 00000000000..6661232850a
--- /dev/null
+++ b/locale/ru/gitlab.po
@@ -0,0 +1,1269 @@
+# SAS <Stepanov.sa@bashkortostan.ru>, 2017. #zanata
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: Russian (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-06 11:23-0400\n"
+"Last-Translator: Ðндрей П. <fenixnow33@gmail.com>\n"
+"Language: ru\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+"%s добавленный коммит был иÑключен Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ "
+"производительноÑтью."
+msgstr[1] ""
+"%s добавленные коммиты были иÑключены Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ "
+"производительноÑтью."
+msgstr[2] ""
+"%s добавленные коммиты были иÑключены Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ "
+"производительноÑтью."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d коммит"
+msgstr[1] "%d коммитов"
+msgstr[2] "%d коммитов"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} коммичено %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 конвейер"
+msgstr[1] "%d конвейеры"
+msgstr[2] "%d конвейеры"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Графики отноÑительно непрерывной интеграции"
+
+msgid "About auto deploy"
+msgstr "ÐвтоматичеÑкое развертывание"
+
+msgid "Active"
+msgstr "Ðктивный"
+
+msgid "Activity"
+msgstr "ÐктивноÑÑ‚ÑŒ"
+
+msgid "Add Changelog"
+msgstr "Добавить журнал изменений"
+
+msgid "Add Contribution guide"
+msgstr "Добавить руководÑтво"
+
+msgid "Add License"
+msgstr "Добавить лицензию"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Добавьте ключ SSH в Ñвой профиль, чтобы отправлÑÑ‚ÑŒ или получать код через "
+"SSH."
+
+msgid "Add new directory"
+msgstr "Добавить каталог"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Ðрхивный проект! Репозиторий доÑтупен только Ð´Ð»Ñ Ñ‡Ñ‚ÐµÐ½Ð¸Ñ"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Ð’Ñ‹ дейÑтвительно хотите удалить Ñто раÑпиÑание конвейера?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Приложить файл через drag &amp; drop или %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Ветка"
+msgstr[1] "Ветки"
+msgstr[2] "Ветки"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"Ветка <strong>%{branch_name}</strong> Ñоздана. Ð”Ð»Ñ Ð½Ð°Ñтройки автоматичеÑкого "
+"Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð²Ñ‹Ð±ÐµÑ€ÐµÑ‚Ðµ GitLab CI Yaml-шаблон и зафикÑируйте изменениÑ. "
+"%{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "ПоиÑк веток"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Переключить ветку"
+
+msgid "Branches"
+msgstr "Ветки"
+
+msgid "Browse Directory"
+msgstr "Обзор"
+
+msgid "Browse File"
+msgstr "ПроÑмотр файла"
+
+msgid "Browse Files"
+msgstr "ПроÑмотр файлов"
+
+msgid "Browse files"
+msgstr "ПроÑмотр файлов"
+
+msgid "ByAuthor|by"
+msgstr "по автору"
+
+msgid "CI configuration"
+msgstr "ÐаÑтройка CI"
+
+msgid "Cancel"
+msgstr "Отмена"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Выбрать в ветке"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Отменить в ветке"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Подобрать"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Отменить"
+
+msgid "Changelog"
+msgstr "Журнал изменений"
+
+msgid "Charts"
+msgstr "Диаграммы"
+
+msgid "Cherry-pick this commit"
+msgstr "Подобрать в Ñтом коммите"
+
+msgid "Cherry-pick this merge request"
+msgstr "Побрать в Ñтом запроÑе на ÑлиÑние"
+
+msgid "CiStatusLabel|canceled"
+msgstr "отменено"
+
+msgid "CiStatusLabel|created"
+msgstr "Ñоздано"
+
+msgid "CiStatusLabel|failed"
+msgstr "неудачно"
+
+msgid "CiStatusLabel|manual action"
+msgstr "ручное дейÑтвие"
+
+msgid "CiStatusLabel|passed"
+msgstr "пройдено"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "пройдено Ñ Ð¿Ñ€ÐµÐ´ÑƒÐ¿Ñ€ÐµÐ¶Ð´ÐµÐ½Ð¸Ñми"
+
+msgid "CiStatusLabel|pending"
+msgstr "в ожидании"
+
+msgid "CiStatusLabel|skipped"
+msgstr "пропущено"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "ожидание ручных дейÑтвий"
+
+msgid "CiStatusText|blocked"
+msgstr "блокировано"
+
+msgid "CiStatusText|canceled"
+msgstr "отменено"
+
+msgid "CiStatusText|created"
+msgstr "Ñоздано"
+
+msgid "CiStatusText|failed"
+msgstr "неудачно"
+
+msgid "CiStatusText|manual"
+msgstr "ручное"
+
+msgid "CiStatusText|passed"
+msgstr "пройдено"
+
+msgid "CiStatusText|pending"
+msgstr "в ожидании"
+
+msgid "CiStatusText|skipped"
+msgstr "пропущено"
+
+msgid "CiStatus|running"
+msgstr "выполнÑетÑÑ"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Коммит"
+msgstr[1] "Коммиты"
+msgstr[2] "Коммиты"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "ПродолжительноÑÑ‚ÑŒ поÑледних 30 фикÑаций(коммитов) в минутах"
+
+msgid "Commit message"
+msgstr "ОпиÑание коммита"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Коммит"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Добавлен %{file_name}"
+
+msgid "Commits"
+msgstr "Коммиты"
+
+msgid "Commits feed"
+msgstr "ФикÑировать подачу"
+
+msgid "Commits|History"
+msgstr "ИÑториÑ"
+
+msgid "Committed by"
+msgstr "ФикÑировано"
+
+msgid "Compare"
+msgstr "Сравнить"
+
+msgid "Contribution guide"
+msgstr "РуководÑтво учаÑтника"
+
+msgid "Contributors"
+msgstr "УчаÑтники"
+
+msgid "Copy URL to clipboard"
+msgstr "Копировать URL в буфер обмена"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Копировать SHA коммита в буфер обмена"
+
+msgid "Create New Directory"
+msgstr "Создать директорию"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Создать личный токен на аккаунте Ð´Ð»Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¸Ð»Ð¸ отправки через "
+"%{protocol}."
+
+msgid "Create directory"
+msgstr "Создать директорию"
+
+msgid "Create empty bare repository"
+msgstr "Создать пуÑтой репозиторий"
+
+msgid "Create merge request"
+msgstr "Создать Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° объединение"
+
+msgid "Create new..."
+msgstr "Ðовый"
+
+msgid "CreateNewFork|Fork"
+msgstr "Форк"
+
+msgid "CreateTag|Tag"
+msgstr "Тег"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "Ñоздать перÑональный токен доÑтупа"
+
+msgid "Cron Timezone"
+msgstr "Ð’Ñ€ÐµÐ¼ÐµÐ½Ð½Ð°Ñ Ð·Ð¾Ð½Ð° Cron"
+
+msgid "Cron syntax"
+msgstr "СинтакÑÐ¸Ñ Cron"
+
+msgid "Custom notification events"
+msgstr " ÐаÑтраиваемые ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ ÑобытиÑÑ…"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"ÐаÑтраиваемые уровни уведомлений аналогичны уровню уведомлений в "
+"ÑоответÑтвии Ñ ÑƒÑ‡Ð°Ñтием. С наÑтраиваемыми уровнÑми уведомлений вы также "
+"будете получать ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ выбранных ÑобытиÑÑ…. Чтобы узнать больше, "
+"поÑмотрите %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Цикл Ðналитик"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"Цикл Ðналитик дает предÑтавление о том, Ñколько времени требуетÑÑ, чтобы "
+"перейти от идеи к производÑтву в проекте."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "ÐапиÑание кода"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Обращение"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Планирование"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "ПроизводÑтво"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Контроль"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "ПоÑтановка"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "ТеÑтирование"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Определить наÑтраиваемый шаблон Ñ ÑинтакÑиÑом cron"
+
+msgid "Delete"
+msgstr "Удалить"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "РазмеÑтить"
+msgstr[1] "Размещение"
+msgstr[2] "Размещение"
+
+msgid "Description"
+msgstr "ОпиÑание"
+
+msgid "Directory name"
+msgstr "Каталог"
+
+msgid "Don't show again"
+msgstr "Ðе показывать Ñнова"
+
+msgid "Download"
+msgstr "Скачать"
+
+msgid "Download tar"
+msgstr "Скачать tar"
+
+msgid "Download tar.bz2"
+msgstr "Скачать tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Скачать tar.gz"
+
+msgid "Download zip"
+msgstr "Скачать zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Скачать"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Email-патчи"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "ПроÑтой Diff"
+
+msgid "DownloadSource|Download"
+msgstr "Скачать"
+
+msgid "Edit"
+msgstr "Редактировать"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Изменить раÑпиÑание конвейера %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Ежедневно (в 4:00)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "ЕжемеÑÑчно (каждое 1-е чиÑло в 4:00)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Еженедельно (по воÑкреÑениÑми в 4:00)"
+
+msgid "Failed to change the owner"
+msgstr "Ðе удалоÑÑŒ изменить владельца"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Ðе удалоÑÑŒ удалить раÑпиÑание конвейера"
+
+msgid "Files"
+msgstr "Файлы"
+
+msgid "Filter by commit message"
+msgstr "Фильтр по комментариÑми к коммитам"
+
+msgid "Find by path"
+msgstr "ПоиÑк по пути"
+
+msgid "Find file"
+msgstr "Ðайти файл"
+
+msgid "FirstPushedBy|First"
+msgstr "Первый"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "протолкнул"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Форк"
+msgstr[1] "Форки"
+msgstr[2] "Форки"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Форк от "
+
+msgid "From issue creation until deploy to production"
+msgstr "От ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñ‹ до Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð² рабочей Ñреде"
+
+msgid "From merge request merge until deploy to production"
+msgstr "От запроÑа на ÑлиÑние до Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð² рабочей Ñреде"
+
+msgid "Go to your fork"
+msgstr "Перейти к вашему форку"
+
+msgid "GoToYourFork|Fork"
+msgstr "Форк"
+
+msgid "Home"
+msgstr "ГлавнаÑ"
+
+msgid "Housekeeping successfully started"
+msgstr "ОчиÑтка уÑпешно запущена"
+
+msgid "Import repository"
+msgstr "Импорт репозиториÑ"
+
+msgid "Interval Pattern"
+msgstr "Шаблон интервала"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Внедрение Цикла Ðналитик"
+
+msgid "Jobs for last month"
+msgstr "Работы за прошлый меÑÑц"
+
+msgid "Jobs for last week"
+msgstr "Работы за прошлую неделю"
+
+msgid "Jobs for last year"
+msgstr "Работы за прошлый год"
+
+msgid "LFSStatus|Disabled"
+msgstr "Отключено"
+
+msgid "LFSStatus|Enabled"
+msgstr "Включено"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "ПоÑледний %d день"
+msgstr[1] "ПоÑледние %d дни"
+msgstr[2] "ПоÑледние %d дни"
+
+msgid "Last Pipeline"
+msgstr "ПоÑледний конвейер"
+
+msgid "Last Update"
+msgstr "ПоÑледнее обновление"
+
+msgid "Last commit"
+msgstr "ПоÑледний коммит"
+
+msgid "Learn more in the"
+msgstr "Узнайте больше в"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "Подробнее в|документации по раÑпиÑаниÑм конвейеров"
+
+msgid "Leave group"
+msgstr "Покинуть группу"
+
+msgid "Leave project"
+msgstr "Покинуть проект"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Ограничение %d ÑобытиÑ"
+msgstr[1] "Ограничение %d Ñобытий"
+msgstr[2] "Ограничение %d Ñобытий"
+
+msgid "Median"
+msgstr "Среднее"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "добавить ключ SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Обращение"
+msgstr[1] "ОбращениÑ"
+msgstr[2] "ОбращениÑ"
+
+msgid "New Pipeline Schedule"
+msgstr "Ðовое раÑпиÑание конвейера"
+
+msgid "New branch"
+msgstr "ÐÐ¾Ð²Ð°Ñ Ð²ÐµÑ‚ÐºÐ°"
+
+msgid "New directory"
+msgstr "ÐÐ¾Ð²Ð°Ñ Ð´Ð¸Ñ€ÐµÐºÑ‚Ð¾Ñ€Ð¸Ñ"
+
+msgid "New file"
+msgstr "Ðовый файл"
+
+msgid "New issue"
+msgstr "Ðовое обращение"
+
+msgid "New merge request"
+msgstr "Ðовый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° объединение"
+
+msgid "New schedule"
+msgstr "Ðовое раÑпиÑание"
+
+msgid "New snippet"
+msgstr "Ðовый Ñниппет"
+
+msgid "New tag"
+msgstr "Ðовый тег"
+
+msgid "No repository"
+msgstr "Ðет репозиториÑ"
+
+msgid "No schedules"
+msgstr "Ðет раÑпиÑаниÑ"
+
+msgid "Not available"
+msgstr "ÐедоÑтупно"
+
+msgid "Not enough data"
+msgstr "Ðет данных"
+
+msgid "Notification events"
+msgstr "Ð£Ð²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ ÑобытиÑÑ…"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Обращение закрыто"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° объединение закрыт"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Ðеудача в конвейере"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Объединить Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "NotificationEvent|New issue"
+msgstr "Ðовое обращение"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Ðовый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "NotificationEvent|New note"
+msgstr "ÐÐ¾Ð²Ð°Ñ Ð·Ð°Ð¼ÐµÑ‚ÐºÐ°"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Переназначить обращение"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Переназначить Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Переоткрыть обращение"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "УÑпешно в конвейере"
+
+msgid "NotificationLevel|Custom"
+msgstr "ÐаÑтраиваемый"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Отключено"
+
+msgid "NotificationLevel|Global"
+msgstr "Глобальный"
+
+msgid "NotificationLevel|On mention"
+msgstr "Упоминание"
+
+msgid "NotificationLevel|Participate"
+msgstr "УчаÑтие"
+
+msgid "NotificationLevel|Watch"
+msgstr "ОтÑлеживать"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Фильтр"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Открыто"
+
+msgid "Options"
+msgstr "ÐаÑтройки"
+
+msgid "Owner"
+msgstr "Владелец"
+
+msgid "Pipeline"
+msgstr "Конвейер"
+
+msgid "Pipeline Health"
+msgstr "Жизненный цикл конвейера"
+
+msgid "Pipeline Schedule"
+msgstr "РаÑпиÑание конвейера"
+
+msgid "Pipeline Schedules"
+msgstr "РаÑпиÑÐ°Ð½Ð¸Ñ ÐºÐ¾Ð½Ð²ÐµÐ¹ÐµÑ€Ð¾Ð²"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Ðеудача:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "СтатиÑтика"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "КоÑффициент уÑпеха:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "УÑпех:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Ð’Ñего:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Ðктивировано"
+
+msgid "PipelineSchedules|Active"
+msgstr "Ðктивно"
+
+msgid "PipelineSchedules|All"
+msgstr "Ð’Ñе"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Ðеактивно"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Ввод ключевой переменной"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Ð’Ñтавить значение"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Следующий запуÑк"
+
+msgid "PipelineSchedules|None"
+msgstr "ОтÑутÑтвует"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "ПредоÑтавьте краткое опиÑание Ñтого конвейера"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Удалить значение"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Стать владельцем"
+
+msgid "PipelineSchedules|Target"
+msgstr "Цель"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Переменные"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "ÐаÑтраиваемый"
+
+msgid "Pipelines"
+msgstr "Конвейер"
+
+msgid "Pipelines charts"
+msgstr "Диаграмма конвейера"
+
+msgid "Pipeline|all"
+msgstr "вÑе"
+
+msgid "Pipeline|success"
+msgstr "уÑпех"
+
+msgid "Pipeline|with stage"
+msgstr "Ñо Ñтадией"
+
+msgid "Pipeline|with stages"
+msgstr "Ñо ÑтадиÑми"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Проект '%{project_name}' добавлен в очередь на удаление."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Проект '%{project_name}' уÑпешно Ñоздан."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Проект '%{project_name}' уÑпешно обновлен."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Проект '%{project_name}' удален."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "ДоÑтуп к проекту должен предоÑтавлÑÑ‚ÑŒÑÑ Ñвно каждому пользователю."
+
+msgid "Project export could not be deleted."
+msgstr "Ðевозможно удалить ÑкÑпорт проекта."
+
+msgid "Project export has been deleted."
+msgstr "ЭкÑпорт проекта удален."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"ИÑтек Ñрок дейÑÑ‚Ð²Ð¸Ñ ÑÑылки на проект. Создайте новый ÑкÑпорт в ваших "
+"наÑтройках проекта."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"Ðачат ÑкÑпорт проекта. СÑылка Ð´Ð»Ñ ÑÐºÐ°Ñ‡Ð¸Ð²Ð°Ð½Ð¸Ñ Ð±ÑƒÐ´ÐµÑ‚ отправлена по Ñлектронной "
+"почте."
+
+msgid "Project home"
+msgstr "ДомашнÑÑ Ñтраница"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Отключено"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Ð’Ñе Ñ Ð´Ð¾Ñтупом"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Только члены команды"
+
+msgid "ProjectFileTree|Name"
+msgstr "Ðаименование"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Ðикогда"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Этап"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Граф"
+
+msgid "Read more"
+msgstr "Подробнее"
+
+msgid "Readme"
+msgstr "Readme"
+
+msgid "RefSwitcher|Branches"
+msgstr "Ветки"
+
+msgid "RefSwitcher|Tags"
+msgstr "Теги"
+
+msgid "Related Commits"
+msgstr "СвÑзанные коммиты"
+
+msgid "Related Deployed Jobs"
+msgstr "СвÑзанные задачи выгрузки"
+
+msgid "Related Issues"
+msgstr "СвÑзанные вопроÑÑ‹"
+
+msgid "Related Jobs"
+msgstr "СвÑзанные задачи"
+
+msgid "Related Merge Requests"
+msgstr "СвÑзанные запроÑÑ‹ на ÑлиÑние"
+
+msgid "Related Merged Requests"
+msgstr "СвÑзанные объединенные запроÑÑ‹"
+
+msgid "Remind later"
+msgstr "Ðапомнить позже"
+
+msgid "Remove project"
+msgstr "Удалить проект"
+
+msgid "Request Access"
+msgstr "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð´Ð¾Ñтупа"
+
+msgid "Revert this commit"
+msgstr "Отменить Ñто изменение"
+
+msgid "Revert this merge request"
+msgstr "Отменить Ñтот Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "Save pipeline schedule"
+msgstr "Сохранить раÑпиÑание конвейра"
+
+msgid "Schedule a new pipeline"
+msgstr "РаÑпиÑание нового конвейера"
+
+msgid "Scheduling Pipelines"
+msgstr "Планирование конвейеров"
+
+msgid "Search branches and tags"
+msgstr "Ðайти ветки и теги"
+
+msgid "Select Archive Format"
+msgstr "Выбрать формат архива"
+
+msgid "Select a timezone"
+msgstr "Выбор временной зоны"
+
+msgid "Select target branch"
+msgstr "Выбор целевой ветки"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"УÑтановите пароль в Ñвоем аккаунте, чтобы отправлÑÑ‚ÑŒ или получать код через "
+"%{protocol}."
+
+msgid "Set up CI"
+msgstr "ÐаÑтройка CI"
+
+msgid "Set up Koding"
+msgstr "ÐаÑтройка Koding"
+
+msgid "Set up auto deploy"
+msgstr "ÐаÑтройка автоматичеÑкого развертываниÑ"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "уÑтановить пароль"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Показано %d Ñобытие"
+msgstr[1] "Показано %d Ñобытий"
+msgstr[2] "Показано %d Ñобытий"
+
+msgid "Source code"
+msgstr "ИÑходный код"
+
+msgid "StarProject|Star"
+msgstr "Отметить"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Ðачать %{new_merge_request} Ñ Ñтих изменений"
+
+msgid "Switch branch/tag"
+msgstr "Переключить ветка/тег"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Тег"
+msgstr[1] "теги"
+msgstr[2] "Теги"
+
+msgid "Tags"
+msgstr "Теги"
+
+msgid "Target Branch"
+msgstr "Ветка"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Ðа Ñтапе напиÑÐ°Ð½Ð¸Ñ ÐºÐ¾Ð´Ð° показывает Ð²Ñ€ÐµÐ¼Ñ Ð¿ÐµÑ€Ð²Ð¾Ð³Ð¾ коммита до ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð·Ð°Ð¿Ñ€Ð¾Ñа "
+"на ÑлиÑние. Данные автоматичеÑки добавÑÑ‚ÑÑ Ð¿Ð¾Ñле того, как вы Ñоздать Ñвой "
+"первый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "ÐšÐ¾Ð»Ð»ÐµÐºÑ†Ð¸Ñ Ñобытий добавленных в данные Ñобранные Ð´Ð»Ñ Ñтого Ñтапа."
+
+msgid "The fork relationship has been removed."
+msgstr "СвÑзь форка удалена."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"Ð¡Ñ‚Ð°Ð´Ð¸Ñ Ð¾Ð±Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð²Ñ€ÐµÐ¼Ñ, которое потребуетÑÑ Ñ Ð¼Ð¾Ð¼ÐµÐ½Ñ‚Ð° ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð¾Ð±Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð´Ð¾ "
+"Ð½Ð°Ð·Ð½Ð°Ñ‡ÐµÐ½Ð¸Ñ Ð¾Ð±Ñ€Ð°Ñ‰ÐµÐ½Ð¸ÑŽ вехи, или Ð´Ð¾Ð±Ð°Ð²Ð»ÐµÐ½Ð¸Ñ Ð¾Ð±Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð² вашу доÑку обращений. "
+"Ðачните Ñоздавать обращениÑ, чтобы увидеть ÑÐ²ÐµÐ´ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ñтой Ñтадии. "
+
+msgid "The phase of the development lifecycle."
+msgstr "Фаза жизненного цикла разработки."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"РаÑпиÑание конвейеров запуÑкает в будущем неоднократно конвейеры, Ð´Ð»Ñ "
+"определенных ветвей или тегов. Запланированные конвейеры наÑледуют "
+"Ð¾Ð³Ñ€Ð°Ð½Ð¸Ñ‡ÐµÐ½Ð¸Ñ Ð½Ð° доÑтуп к проекту на оÑнове ÑвÑзанного Ñ Ð½Ð¸Ð¼Ð¸ пользователÑ."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"Ðа Ñтапе Ð¿Ð»Ð°Ð½Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ Ð¿Ð¾ÐºÐ°Ð·Ñ‹Ð²Ð°ÐµÑ‚ Ð²Ñ€ÐµÐ¼Ñ Ð¾Ñ‚ предыдущего шага до Ð¿Ñ€Ð¾Ñ‚Ð°Ð»ÐºÐ¸Ð²Ð°Ð½Ð¸Ñ "
+"первого коммита. ДобавлÑетÑÑ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑки, как только проталкиваете Ñвой "
+"первый коммит."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"ПроизводÑтвенный Ñтап показывает общее Ð²Ñ€ÐµÐ¼Ñ Ð¼ÐµÐ¶Ð´Ñƒ Ñозданием задачи и "
+"развертывание кода в производÑтвенной Ñреде. Данные будут автоматичеÑки "
+"добавлены поÑле полного Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð¸Ð´ÐµÐ¸ производÑтвенного цикла."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "ДоÑтуп к проекту возможен любым зарегиÑтрированным пользователем."
+
+msgid "The project can be accessed without any authentication."
+msgstr "ДоÑтуп к проекту возможен без какой-либо проверки подлинноÑти."
+
+msgid "The repository for this project does not exist."
+msgstr "Репозиторий Ð´Ð»Ñ Ñтого проекта не ÑущеÑтвует."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Этап обзора показывает Ð²Ñ€ÐµÐ¼Ñ Ð¾Ñ‚ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð·Ð°Ð¿Ñ€Ð¾Ñа ÑлиÑÐ½Ð¸Ñ Ð´Ð¾ его выполнениÑ. "
+"Данные будут автоматичеÑки добавлены поÑле Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð¿ÐµÑ€Ð²Ð¾Ð³Ð¾ запроÑа на "
+"ÑлиÑние."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Этап поÑтановки показывает Ð²Ñ€ÐµÐ¼Ñ Ð¼ÐµÐ¶Ð´Ñƒ ÑлиÑнием \"MR\" и развертыванием кода "
+"в производÑтвенной Ñреде. Данные будут автоматичеÑки добавлены поÑле "
+"Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð² производÑтве первый раз."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"Этап теÑÑ‚Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ Ð¿Ð¾ÐºÐ°Ð·Ñ‹Ð²Ð°ÐµÑ‚ времÑ, которое GitLab CI занимает Ð´Ð»Ñ Ð·Ð°Ð¿ÑƒÑка "
+"каждого конвейера Ð´Ð»Ñ ÑоответÑтвующего запроÑа на ÑлиÑние. Данные будут "
+"автоматичеÑки добавлены поÑле Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ñ€Ð°Ð±Ð¾Ñ‚Ñ‹ вашего первого конвейера."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "ВремÑ, затраченное каждым Ñлементом, Ñобранным на Ñтом Ñтапе."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"Среднее значение в Ñ€Ñду. Пример: между 3, 5, 9, Ñреднее 5, между 3, 5, 7, 8, "
+"Ñреднее (5+7)/2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Это означает, что вы не можете пушить код, пока не Ñоздадите пуÑтой "
+"репозиторий или не импортируете ÑущеÑтвующий."
+
+msgid "Time before an issue gets scheduled"
+msgstr " Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ начала Ð¿Ð¾Ð¿Ð°Ð´Ð°Ð½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñ‹ в планировщик"
+
+msgid "Time before an issue starts implementation"
+msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ начала работы над проблемой"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð¼ÐµÐ¶Ð´Ñƒ Ñозданием запроÑа ÑлиÑÐ½Ð¸Ñ Ð¸ ÑлиÑнием / закрытием"
+
+msgid "Time until first merge request"
+msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ первого запроÑа на ÑлиÑние"
+
+msgid "Timeago|%s days ago"
+msgstr "%s день назад"
+
+msgid "Timeago|%s days remaining"
+msgstr "ОÑталоÑÑŒ %s день"
+
+msgid "Timeago|%s hours remaining"
+msgstr "ОÑталоÑÑŒ %s чаÑов"
+
+msgid "Timeago|%s minutes ago"
+msgstr "%s минут назад"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "ОÑталоÑÑŒ %s минут"
+
+msgid "Timeago|%s months ago"
+msgstr "%s минут назад"
+
+msgid "Timeago|%s months remaining"
+msgstr "ОÑталоÑÑŒ %s меÑÑц"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "ОÑталоÑÑŒ %s Ñекунд(Ñ‹)"
+
+msgid "Timeago|%s weeks ago"
+msgstr "%s недели назад"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "ОÑталоÑÑŒ %s недели"
+
+msgid "Timeago|%s years ago"
+msgstr "%s год назад"
+
+msgid "Timeago|%s years remaining"
+msgstr "ОÑталоÑÑŒ %s год"
+
+msgid "Timeago|1 day remaining"
+msgstr "ОÑталÑÑ Ð´ÐµÐ½ÑŒ"
+
+msgid "Timeago|1 hour remaining"
+msgstr "ОÑталÑÑ Ñ‡Ð°Ñ"
+
+msgid "Timeago|1 minute remaining"
+msgstr "ОÑталаÑÑŒ одна минута"
+
+msgid "Timeago|1 month remaining"
+msgstr "ОÑталÑÑ Ð¼ÐµÑÑц"
+
+msgid "Timeago|1 week remaining"
+msgstr "ОÑталаÑÑŒ неделÑ"
+
+msgid "Timeago|1 year remaining"
+msgstr "ОÑталÑÑ Ð³Ð¾Ð´"
+
+msgid "Timeago|Past due"
+msgstr "ПроÑрочено"
+
+msgid "Timeago|a day ago"
+msgstr "день назад"
+
+msgid "Timeago|a month ago"
+msgstr "меÑÑц назад"
+
+msgid "Timeago|a week ago"
+msgstr "неделю назад"
+
+msgid "Timeago|a while"
+msgstr "какое-то времÑ"
+
+msgid "Timeago|a year ago"
+msgstr "год назад"
+
+msgid "Timeago|about %s hours ago"
+msgstr "около %s чаÑов назад"
+
+msgid "Timeago|about a minute ago"
+msgstr "около минуты назад"
+
+msgid "Timeago|about an hour ago"
+msgstr "около чаÑа назад"
+
+msgid "Timeago|in %s days"
+msgstr "через %s день"
+
+msgid "Timeago|in %s hours"
+msgstr "через %s чаÑ"
+
+msgid "Timeago|in %s minutes"
+msgstr "через %s минут"
+
+msgid "Timeago|in %s months"
+msgstr "через %s меÑÑц"
+
+msgid "Timeago|in %s seconds"
+msgstr "через %s Ñекунд(Ñ‹)"
+
+msgid "Timeago|in %s weeks"
+msgstr "через %s недели"
+
+msgid "Timeago|in %s years"
+msgstr "через %s год"
+
+msgid "Timeago|in 1 day"
+msgstr "через день"
+
+msgid "Timeago|in 1 hour"
+msgstr "через чаÑ"
+
+msgid "Timeago|in 1 minute"
+msgstr "через минуту"
+
+msgid "Timeago|in 1 month"
+msgstr "через меÑÑц"
+
+msgid "Timeago|in 1 week"
+msgstr "через неделю"
+
+msgid "Timeago|in 1 year"
+msgstr "через год"
+
+msgid "Timeago|less than a minute ago"
+msgstr "менее чем минуту назад"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "ч"
+msgstr[1] "ч"
+msgstr[2] "ч"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "мин"
+msgstr[1] "мин"
+msgstr[2] "мин"
+
+msgid "Time|s"
+msgstr "Ñ"
+
+msgid "Total Time"
+msgstr "Общее времÑ"
+
+msgid "Total test time for all commits/merges"
+msgstr "Общее Ð²Ñ€ÐµÐ¼Ñ Ñ‚ÐµÑÑ‚Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ Ñ„Ð¸ÐºÑаций/ÑлиÑний"
+
+msgid "Unstar"
+msgstr "СнÑÑ‚ÑŒ отметку"
+
+msgid "Upload New File"
+msgstr "Загрузить новый файл"
+
+msgid "Upload file"
+msgstr "Загрузить файл"
+
+msgid "UploadLink|click to upload"
+msgstr "кликните Ð´Ð»Ñ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ¸"
+
+msgid "Use your global notification setting"
+msgstr "ИÑпользуютÑÑ Ð³Ð»Ð¾Ð±Ð°Ð»ÑŒÐ½Ñ‹Ð¹ наÑтройки уведомлений"
+
+msgid "View open merge request"
+msgstr "ПроÑмотреть открытый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Ограниченный"
+
+msgid "VisibilityLevel|Private"
+msgstr "Приватный"
+
+msgid "VisibilityLevel|Public"
+msgstr "Публичный"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Ðе определен"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Хотите увидеть данные? ОбратитеÑÑŒ к админиÑтратору за доÑтупом."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Ð˜Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ Ð¿Ð¾ Ñтапу отÑутÑтвует."
+
+msgid "Withdraw Access Request"
+msgstr "Отменить Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð´Ð¾Ñтупа"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ð’Ñ‹ ÑобираетеÑÑŒ удалить %{group_name}. Удаленные группы ÐЕ МОГУТ быть воÑÑтановлены! Ð’Ñ‹ ÐБСОЛЮТÐО уверены?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ð’Ñ‹ хотите удалить %{project_name_with_namespace}. Удаленный проект ÐЕ МОЖЕТ быть воÑÑтановлен! Ð’Ñ‹ ÐБСОЛЮТÐО уверены?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"Ð’Ñ‹ ÑобираетеÑÑŒ удалить ÑвÑзь форка Ñ Ð¸Ñходным проектом "
+"%{forked_from_project}. Ð’Ñ‹ ÐБСОЛЮТÐО уверены?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Ð’Ñ‹ ÑобираетеÑÑŒ передать проект %{project_name_with_namespace} другому "
+"владельцу. Ð’Ñ‹ ÐБСОЛЮТÐО уверены?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Ð’Ñ‹ можете добавлÑÑ‚ÑŒ только файлы, когда находитеÑÑŒ в ветке"
+
+msgid "You have reached your project limit"
+msgstr "Ð’Ñ‹ доÑтигли Ð¾Ð³Ñ€Ð°Ð½Ð¸Ñ‡ÐµÐ½Ð¸Ñ Ð² вашем проекте"
+
+msgid "You must sign in to star a project"
+msgstr "Ðеобходимо войти, чтобы оценить проект"
+
+msgid "You need permission."
+msgstr "Вам нужно разрешение."
+
+msgid "You will not get any notifications via email"
+msgstr "Ð’Ñ‹ не получите никаких уведомлений по Ñлектронной почте"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Ð’Ñ‹ будете получать ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ñ‚Ð¾Ð»ÑŒÐºÐ¾ о выбранных вами ÑобытиÑÑ…"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr ""
+"Ð’Ñ‹ будете получать ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ñ‚Ð¾Ð»ÑŒÐºÐ¾ о тех тредах, в которых вы учаÑтвовали"
+
+msgid "You will receive notifications for any activity"
+msgstr "Ð’Ñ‹ будете получать ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ любых дейÑтвиÑÑ…"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr ""
+"Ð’Ñ‹ будете получать ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ñ‚Ð¾Ð»ÑŒÐºÐ¾ Ð´Ð»Ñ ÐºÐ¾Ð¼Ð¼ÐµÐ½Ñ‚Ð°Ñ€Ð¸ÐµÐ², в которых вы были "
+"@упомÑнуты"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Ð’Ñ‹ не Ñможете получать и отправлÑÑ‚ÑŒ код проекта через %{protocol} пока "
+"%{set_password_link} в ваш аккаунт"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Ð’Ñ‹ не Ñможете получать и отправлÑÑ‚ÑŒ код проекта через SSH пока "
+"%{add_ssh_key_link} в ваш профиль."
+
+msgid "Your name"
+msgstr "Ваше имÑ"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "день"
+msgstr[1] "дни"
+msgstr[2] "дни"
+
+msgid "new merge request"
+msgstr "новый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
+msgid "notification emails"
+msgstr "email Ð´Ð»Ñ ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ð¹"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "иÑточник"
+msgstr[1] "иÑточники"
+msgstr[2] "иÑточники"
+
diff --git a/locale/ru/gitlab.po.time_stamp b/locale/ru/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/ru/gitlab.po.time_stamp
diff --git a/locale/uk/gitlab.po b/locale/uk/gitlab.po
new file mode 100644
index 00000000000..0ac0499e315
--- /dev/null
+++ b/locale/uk/gitlab.po
@@ -0,0 +1,1264 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Ðндрей Витюк <andruwa13@gmail.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-13 12:07-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: Ukrainian (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-06 11:23-0400\n"
+"Last-Translator: Ðндрей Витюк <andruwa13@gmail.com>\n"
+"Language: uk\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] ""
+"%s доданий Комміт був виключений Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ з продуктивніÑÑ‚ÑŽ."
+msgstr[1] ""
+"%s доданих коммітів були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ з продуктивніÑÑ‚ÑŽ."
+msgstr[2] ""
+"%s доданих коммітів були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ з продуктивніÑÑ‚ÑŽ."
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d комміт"
+msgstr[1] "%d комміта"
+msgstr[2] "%d коммітів"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} комміт %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 конвеєр"
+msgstr[1] "%d конвеєра"
+msgstr[2] "%d конвеєрів"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Це набір графічних елементів Ð´Ð»Ñ Ð±ÐµÐ·Ð¿ÐµÑ€ÐµÑ€Ð²Ð½Ð¾Ñ— інтеграції"
+
+msgid "About auto deploy"
+msgstr "Про авто розгортаннÑ"
+
+msgid "Active"
+msgstr "Ðктивний"
+
+msgid "Activity"
+msgstr "ÐктивніÑÑ‚ÑŒ"
+
+msgid "Add Changelog"
+msgstr "Додати ÑпиÑок змін (Changelog)"
+
+msgid "Add Contribution guide"
+msgstr "Додати керівництво Ð´Ð»Ñ ÐºÐ¾Ð½Ñ‚Ñ€Ð¸Ð±â€™ÑŽÑ‚Ð¾Ñ€Ñ–Ð²"
+
+msgid "Add License"
+msgstr "Додати ліцензію"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr ""
+"Додати SSH ключа в Ñвій профіль, щоб мати можливіÑÑ‚ÑŒ завантажити чи "
+"надіÑлати зміни через SSH."
+
+msgid "Add new directory"
+msgstr "Додати новий каталог"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Заархівований проект! Репозиторій доÑтупний лише Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "Ви впевнені, що хочете видалити цей розклад Ð´Ð»Ñ ÐšÐ¾Ð½Ð²ÐµÑ”Ñ€Ð°?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Прикріпити файл за допомогою перетÑÐ³ÑƒÐ²Ð°Ð½Ð½Ñ Ð°Ð±Ð¾ %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Гілка"
+msgstr[1] "Гілки"
+msgstr[2] "Гілок"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"Гілка <strong>%{branch_name}</strong> Ñтворена. Ð”Ð»Ñ Ð½Ð°Ñтройки автоматичного "
+"Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð²Ð¸Ð±ÐµÑ€Ñ–Ñ‚ÑŒ GitLab CI Yaml-шаблон Ñ– закоммітьте зміни. "
+"%{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Пошук гілок"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Переключити гілку"
+
+msgid "Branches"
+msgstr "Гілки"
+
+msgid "Browse Directory"
+msgstr "ПереглÑнути каталог"
+
+msgid "Browse File"
+msgstr "ПереглÑнути файл"
+
+msgid "Browse Files"
+msgstr "ПереглÑд файлів"
+
+msgid "Browse files"
+msgstr "ПереглÑд файлів"
+
+msgid "ByAuthor|by"
+msgstr "від"
+
+msgid "CI configuration"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI"
+
+msgid "Cancel"
+msgstr "СкаÑувати"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Вибрати в гілці"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "СкаÑувати у гілці"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "СкаÑувати"
+
+msgid "Changelog"
+msgstr "СпиÑок змін (Changelog)"
+
+msgid "Charts"
+msgstr "Графіки"
+
+msgid "Cherry-pick this commit"
+msgstr "Cherry-pick в цьому комміті"
+
+msgid "Cherry-pick this merge request"
+msgstr "Cherry-pick в цьому запиті на злиттÑ"
+
+msgid "CiStatusLabel|canceled"
+msgstr "ÑкаÑовано"
+
+msgid "CiStatusLabel|created"
+msgstr "Ñтворено"
+
+msgid "CiStatusLabel|failed"
+msgstr "невдало"
+
+msgid "CiStatusLabel|manual action"
+msgstr "вручну"
+
+msgid "CiStatusLabel|passed"
+msgstr "виконано"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "виконано з попередженнÑми"
+
+msgid "CiStatusLabel|pending"
+msgstr "в очікуванні"
+
+msgid "CiStatusLabel|skipped"
+msgstr "пропущено"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "ÐžÑ‡Ñ–ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ñ€ÑƒÑ‡Ð½Ð¸Ñ… дій"
+
+msgid "CiStatusText|blocked"
+msgstr "заблоковано"
+
+msgid "CiStatusText|canceled"
+msgstr "ÑкаÑовано"
+
+msgid "CiStatusText|created"
+msgstr "Ñтворено"
+
+msgid "CiStatusText|failed"
+msgstr "невдало"
+
+msgid "CiStatusText|manual"
+msgstr "вручну"
+
+msgid "CiStatusText|passed"
+msgstr "виконано"
+
+msgid "CiStatusText|pending"
+msgstr "в очікуванні"
+
+msgid "CiStatusText|skipped"
+msgstr "пропущено"
+
+msgid "CiStatus|running"
+msgstr "виконуєтьÑÑ"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Комміт"
+msgstr[1] "Комміта"
+msgstr[2] "Коммітів"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "ТриваліÑÑ‚ÑŒ оÑтанніх 30 коммітів у хвилинах"
+
+msgid "Commit message"
+msgstr "Комміт повідомленнÑ"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Комміт"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Додати %{file_name}"
+
+msgid "Commits"
+msgstr "Комміти"
+
+msgid "Commits feed"
+msgstr "Канал коммітів"
+
+msgid "Commits|History"
+msgstr "ІÑторіÑ"
+
+msgid "Committed by"
+msgstr "Комміт від"
+
+msgid "Compare"
+msgstr "ПорівнÑти"
+
+msgid "Contribution guide"
+msgstr "Керівництво контриб’юторів"
+
+msgid "Contributors"
+msgstr "Контриб’ютори"
+
+msgid "Copy URL to clipboard"
+msgstr "Скопіювати URL в буфер обміну"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Скопіювати ідентифікатор в буфер обміну"
+
+msgid "Create New Directory"
+msgstr "Створити новий каталог"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr ""
+"Створити токен доÑтупу Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ аккауета, щоб відправлÑти або отримувати "
+"через %{protocol}."
+
+msgid "Create directory"
+msgstr "Створити каталог"
+
+msgid "Create empty bare repository"
+msgstr "Створити порожній репозиторій"
+
+msgid "Create merge request"
+msgstr "Створити запит на злиттÑ"
+
+msgid "Create new..."
+msgstr "Створити..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Форк"
+
+msgid "CreateTag|Tag"
+msgstr "Тег"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "Створити токен Ð´Ð»Ñ Ð¾ÑобиÑтого доÑтупу"
+
+msgid "Cron Timezone"
+msgstr "ЧаÑовий поÑÑ Cron"
+
+msgid "Cron syntax"
+msgstr "СинтакÑÐ¸Ñ Cron"
+
+msgid "Custom notification events"
+msgstr "КориÑтувацькі Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½ÑŒ про події"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"Спеціальні рівні Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ñпівпадають з рівнем учаÑÑ‚Ñ–. За допомогою "
+"Ñпеціальних рівнів Ñповіщень ви також отримуватимете ÑÐ¿Ð¾Ð²Ñ–Ñ‰ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾ вибрані "
+"події. Щоб дізнатиÑÑŒ більше, переглÑньте %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Ðналіз циклу"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr ""
+"Ðналітика циклу дає оглÑд того, Ñкільки чаÑу потрібно, щоб перейти від ідеї "
+"до виробництва у вашому проекті."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Код"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Проблема"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "ПлануваннÑ"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "ПРОД"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "ЗатвердженнÑ"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "ДЕВ"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "ТеÑтуваннÑ"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Визначте влаÑний шаблон за допомогою ÑинтакÑиÑу cron"
+
+msgid "Delete"
+msgstr "Видалити"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "РозгортаннÑ"
+msgstr[1] "РозгортаннÑ"
+msgstr[2] "Розгортань"
+
+msgid "Description"
+msgstr "ОпиÑ"
+
+msgid "Directory name"
+msgstr "Ім'Ñ ÐºÐ°Ñ‚Ð°Ð»Ð¾Ð³Ñƒ"
+
+msgid "Don't show again"
+msgstr "Ðе показувати знову"
+
+msgid "Download"
+msgstr "Завантажити"
+
+msgid "Download tar"
+msgstr "Завантажити tar"
+
+msgid "Download tar.bz2"
+msgstr "Завантажити tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Завантажити tar.gz"
+
+msgid "Download zip"
+msgstr "Завантажити zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Завантажити"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Email-патчи"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Plain Diff"
+
+msgid "DownloadSource|Download"
+msgstr "Завантажити"
+
+msgid "Edit"
+msgstr "Редагувати"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Редагувати Розклад Конвеєра %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Кожен день (в 4:00 ранку)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Кожен міÑÑць (1-го чиÑла о 4:00 ранку)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Ð©Ð¾Ñ‚Ð¸Ð¶Ð½Ñ (в неділю о 4:00 ранку)"
+
+msgid "Failed to change the owner"
+msgstr "Ðе вдалоÑÑ Ð·Ð¼Ñ–Ð½Ð¸Ñ‚Ð¸ влаÑника"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Ðе вдалоÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ розклад Конвеєра"
+
+msgid "Files"
+msgstr "Файли"
+
+msgid "Filter by commit message"
+msgstr "Фільтрувати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ ÐºÐ¾Ð¼Ð¼Ñ–Ñ‚Ñ–Ð²"
+
+msgid "Find by path"
+msgstr "Пошук по шлÑху"
+
+msgid "Find file"
+msgstr "Знайти файл"
+
+msgid "FirstPushedBy|First"
+msgstr "Перший"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "ÐадіÑлані зміни від"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Форк"
+msgstr[1] "Форки"
+msgstr[2] "Форків"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Форк від"
+
+msgid "From issue creation until deploy to production"
+msgstr "З моменту ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° ПРОД"
+
+msgid "From merge request merge until deploy to production"
+msgstr "З об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð¾ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° ПРОД"
+
+msgid "Go to your fork"
+msgstr "Перейти до вашого форку"
+
+msgid "GoToYourFork|Fork"
+msgstr "Форк"
+
+msgid "Home"
+msgstr "Головна"
+
+msgid "Housekeeping successfully started"
+msgstr "ÐžÑ‡Ð¸Ñ‰ÐµÐ½Ð½Ñ ÑƒÑпішно розпочато"
+
+msgid "Import repository"
+msgstr "Імпорт репозеторіÑ"
+
+msgid "Interval Pattern"
+msgstr "Шаблон інтервалу"
+
+msgid "Introducing Cycle Analytics"
+msgstr "ПредÑтавлÑємо аналітику циклу"
+
+msgid "Jobs for last month"
+msgstr "КількіÑÑ‚ÑŒ завдань за оÑтанній міÑÑць"
+
+msgid "Jobs for last week"
+msgstr "КількіÑÑ‚ÑŒ завдань за оÑтанній тиждень"
+
+msgid "Jobs for last year"
+msgstr "КількіÑÑ‚ÑŒ завдань за оÑтанній рік"
+
+msgid "LFSStatus|Disabled"
+msgstr "Вимкнено"
+
+msgid "LFSStatus|Enabled"
+msgstr "Увімкнено"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "ОÑтанній %d день"
+msgstr[1] "ОÑтанніх %d дні"
+msgstr[2] "ОÑтанніх %d днів"
+
+msgid "Last Pipeline"
+msgstr "ОÑтанній Конвеєр"
+
+msgid "Last Update"
+msgstr "ОÑтаннє оновленнÑ"
+
+msgid "Last commit"
+msgstr "ОÑтанній комміт"
+
+msgid "Learn more in the"
+msgstr "ДізнайтеÑÑŒ більше"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "Детальніше в документації по розкладами конвеєрів"
+
+msgid "Leave group"
+msgstr "Залишити групу"
+
+msgid "Leave project"
+msgstr "Залишити проект"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "ÐžÐ±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ %d події"
+msgstr[1] "ÐžÐ±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ %d подій"
+msgstr[2] "ÐžÐ±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ %d подій"
+
+msgid "Median"
+msgstr "Медіана"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "не додаÑте SSH ключ"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Ðова проблема"
+msgstr[1] "Ðові проблеми"
+msgstr[2] "Ðових проблем"
+
+msgid "New Pipeline Schedule"
+msgstr "Ðовий розклад Конвеєра"
+
+msgid "New branch"
+msgstr "Ðова гілка"
+
+msgid "New directory"
+msgstr "Ðовий каталог"
+
+msgid "New file"
+msgstr "Ðовий файл"
+
+msgid "New issue"
+msgstr "Ðова проблема"
+
+msgid "New merge request"
+msgstr "Ðовий запит на злиттÑ"
+
+msgid "New schedule"
+msgstr "Ðовий Розклад"
+
+msgid "New snippet"
+msgstr "Ðовий Ñніппет"
+
+msgid "New tag"
+msgstr "Ðовий тег"
+
+msgid "No repository"
+msgstr "Ðемає репозеторіÑ"
+
+msgid "No schedules"
+msgstr "немає Розкладів"
+
+msgid "Not available"
+msgstr "ÐедоÑтупний"
+
+msgid "Not enough data"
+msgstr "ÐедоÑтатньо даних"
+
+msgid "Notification events"
+msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾ події"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Проблема закрита"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Запит на об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð·Ð°ÐºÑ€Ð¸Ñ‚Ð¸Ð¹"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Ðевдача в конвеєрі"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Об'єднати запит на злиттÑ"
+
+msgid "NotificationEvent|New issue"
+msgstr "Ðова проблема"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Ðовий запит на злиттÑ"
+
+msgid "NotificationEvent|New note"
+msgstr "Ðова нотатка"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Перепризначити проблему"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Перепризначити запит на злиттÑ"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Повторне Ð²Ñ–Ð´ÐºÑ€Ð¸Ñ‚Ñ‚Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñƒ"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "УÑпішно в Конвеєрі"
+
+msgid "NotificationLevel|Custom"
+msgstr "ВлаÑні"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Вимкнено"
+
+msgid "NotificationLevel|Global"
+msgstr "Загальні"
+
+msgid "NotificationLevel|On mention"
+msgstr "Коли Ð²Ð°Ñ Ð·Ð³Ð°Ð´ÑƒÑŽÑ‚ÑŒ"
+
+msgid "NotificationLevel|Participate"
+msgstr "Берете учаÑÑ‚ÑŒ"
+
+msgid "NotificationLevel|Watch"
+msgstr "ВідÑтежувати"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Фільтр"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Відкрито"
+
+msgid "Options"
+msgstr "Параметри"
+
+msgid "Owner"
+msgstr "ВлаÑник"
+
+msgid "Pipeline"
+msgstr "Конвеєр"
+
+msgid "Pipeline Health"
+msgstr "Стан Конвеєра"
+
+msgid "Pipeline Schedule"
+msgstr "Розклад Конвеєра"
+
+msgid "Pipeline Schedules"
+msgstr "Розклади Конвеєрів"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Ðе вдалоÑÑ:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Загальна ÑтатиÑтика"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Коефіцієнт уÑпіху:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "УÑпішні:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Ð’Ñього:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Ðктивовано"
+
+msgid "PipelineSchedules|Active"
+msgstr "Ðктивні"
+
+msgid "PipelineSchedules|All"
+msgstr "Ð’ÑÑ–"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Ðеактивні"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Введіть ім'Ñ Ð·Ð¼Ñ–Ð½Ð½Ð¾Ñ—"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Вхідні Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð·Ð¼Ñ–Ð½Ð½Ð¸Ñ…"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "ÐаÑтупний запуÑк"
+
+msgid "PipelineSchedules|None"
+msgstr "Ðемає"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Задайте короткий Ð¾Ð¿Ð¸Ñ Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ Конвеєру"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Видалити змінні"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Стати влаÑником"
+
+msgid "PipelineSchedules|Target"
+msgstr "Ціль"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Змінні"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "ВлаÑні"
+
+msgid "Pipelines"
+msgstr "Конвеєри"
+
+msgid "Pipelines charts"
+msgstr "Чарти Конвеєрів"
+
+msgid "Pipeline|all"
+msgstr "вÑÑ–"
+
+msgid "Pipeline|success"
+msgstr "уÑпіх"
+
+msgid "Pipeline|with stage"
+msgstr "зі Ñтадією"
+
+msgid "Pipeline|with stages"
+msgstr "зі ÑтадіÑми"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Проект '%{project_name}' доданий в чергу на видаленнÑ."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Проект '%{project_name}' уÑпішно Ñтворений."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Проект '%{project_name}' уÑпішно оновлено."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Проект '%{project_name}' видалений."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "ДоÑтуп до проекту повинен надаватиÑÑ ÐºÐ¾Ð¶Ð½Ð¾Ð¼Ñƒ кориÑтувачеві."
+
+msgid "Project export could not be deleted."
+msgstr "Ðеможливо видалити екÑпорт проекту."
+
+msgid "Project export has been deleted."
+msgstr "ЕкÑпорт проекту видалений."
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr ""
+"ЗакінчивÑÑ Ñ‚ÐµÑ€Ð¼Ñ–Ð½ дії поÑÐ¸Ð»Ð°Ð½Ð½Ñ Ð½Ð° проект. Створіть новий екÑпорт в ваших "
+"наÑтройках проекту."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr ""
+"Розпочато екÑпорт проекту. ПоÑÐ¸Ð»Ð°Ð½Ð½Ñ Ð´Ð»Ñ ÑÐºÐ°Ñ‡ÑƒÐ²Ð°Ð½Ð½Ñ Ð±ÑƒÐ´Ðµ надіÑлана "
+"електронною поштою."
+
+msgid "Project home"
+msgstr "Ð”Ð¾Ð¼Ð°ÑˆÐ½Ñ Ñторінка проекту"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Вимкнено"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Ð’Ñе з доÑтупом"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Тільки члени команди"
+
+msgid "ProjectFileTree|Name"
+msgstr "Ім'Ñ"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Ðіколи"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Етап"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "ІÑторіÑ"
+
+msgid "Read more"
+msgstr "Докладніше"
+
+msgid "Readme"
+msgstr "Прочитай Мене"
+
+msgid "RefSwitcher|Branches"
+msgstr "Гілки"
+
+msgid "RefSwitcher|Tags"
+msgstr "Теги"
+
+msgid "Related Commits"
+msgstr "Пов'Ñзані Комміти"
+
+msgid "Related Deployed Jobs"
+msgstr "Пов’Ñзані розгорнуті задачі (Jobs)"
+
+msgid "Related Issues"
+msgstr "Пов’Ñзані Проблеми (Issues)"
+
+msgid "Related Jobs"
+msgstr "Пов’Ñзані Задачі (Jobs)"
+
+msgid "Related Merge Requests"
+msgstr "Пов'Ñзані запити на злиттÑ"
+
+msgid "Related Merged Requests"
+msgstr "Пов'Ñзані об'єднані запити"
+
+msgid "Remind later"
+msgstr "Ðагадати пізніше"
+
+msgid "Remove project"
+msgstr "Видалити проект"
+
+msgid "Request Access"
+msgstr "Запит доÑтупу"
+
+msgid "Revert this commit"
+msgstr "СкаÑувати цей комміт"
+
+msgid "Revert this merge request"
+msgstr "СкаÑувати цей запит на злиттÑ"
+
+msgid "Save pipeline schedule"
+msgstr "Зберегти Розклад Конвеєра"
+
+msgid "Schedule a new pipeline"
+msgstr "Розклад нового конвеєра"
+
+msgid "Scheduling Pipelines"
+msgstr "ÐŸÐ»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð½Ð²ÐµÑ”Ñ€Ñ–Ð²"
+
+msgid "Search branches and tags"
+msgstr "Пошук гілок та тегів"
+
+msgid "Select Archive Format"
+msgstr "Виберіть формат архіву"
+
+msgid "Select a timezone"
+msgstr "Вибрати чаÑовий поÑÑ"
+
+msgid "Select target branch"
+msgstr "Вибір цільової гілки"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+"Ð’Ñтановіть пароль Ñвого облікового запиÑу, щоб відправлÑти або отримувати "
+"код через %{protocol}."
+
+msgid "Set up CI"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI"
+
+msgid "Set up Koding"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Koding"
+
+msgid "Set up auto deploy"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ðµ розгортаннÑ"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "вÑтановити пароль"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Показано %d подію"
+msgstr[1] "Показано %d події"
+msgstr[2] "Показано %d подій"
+
+msgid "Source code"
+msgstr "Код"
+
+msgid "StarProject|Star"
+msgstr "ПідпиÑатиÑÑ"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Почати %{new_merge_request} з цих змін"
+
+msgid "Switch branch/tag"
+msgstr "тег"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Тег"
+msgstr[1] "Теги"
+msgstr[2] "Тегів"
+
+msgid "Tags"
+msgstr "Теги"
+
+msgid "Target Branch"
+msgstr "Цільова гілка"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"Ðа Ñтадії напиÑÐ°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ, показує Ñ‡Ð°Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ комміту до ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на "
+"об'єднаннÑ. Дані будуть автоматично додані піÑÐ»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ першого "
+"запиту на об'єднаннÑ."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "ÐšÐ¾Ð»ÐµÐºÑ†Ñ–Ñ Ð¿Ð¾Ð´Ñ–Ð¹ додана до даних, зібраних Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ етапу."
+
+msgid "The fork relationship has been removed."
+msgstr "Зв'Ñзок форка видалена."
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"Етап випуÑку показує, Ñкільки чаÑу потрібно від ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до "
+"приÑÐ²Ð¾Ñ”Ð½Ð½Ñ Ð²Ð¸Ð¿ÑƒÑку, або Ð´Ð¾Ð´Ð°Ð²Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ в вашу дошку проблем. Почніть "
+"Ñтворювати проблеми, щоб переглÑдати дані Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ етапу."
+
+msgid "The phase of the development lifecycle."
+msgstr "Фаза життєвого циклу розробки."
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"Розклад конвеєрів запуÑкає в майбутньому конвеєри, Ð´Ð»Ñ Ð¿ÐµÐ²Ð½Ð¸Ñ… гілок або "
+"тегів. Заплановані конвеєри уÑпадковують Ð¾Ð±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ Ð½Ð° доÑтуп до проекту на "
+"оÑнові пов'Ñзаного з ними кориÑтувача."
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr ""
+"Ðа етапі Ð¿Ð»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶Ð°Ñ”Ñ‚ÑŒÑÑ Ñ‡Ð°Ñ Ð²Ñ–Ð´ попереднього кроку до першого "
+"комміту. ДодаєтьÑÑ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ð¾, Ñк тільки відправитÑÑ Ð¿ÐµÑ€ÑˆÐ¸Ð¹ комміт."
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr ""
+"Ð¡Ñ‚Ð°Ð´Ñ–Ñ ÐŸÐ ÐžÐ”Ð°ÐºÑˆÐ¸Ð½ показує загальний Ñ‡Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм проблеми та "
+"розгортаннÑм коду у ПРОДакшині. Дані будуть автоматично додані піÑÐ»Ñ "
+"Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿Ð¾Ð²Ð½Ð¾Ñ— ідеї до ПРОДакшин циклу."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "ДоÑтуп до проекту можливий будь-Ñким зареєÑтрованим кориÑтувачем."
+
+msgid "The project can be accessed without any authentication."
+msgstr "ДоÑтуп до проекту можливий без будь-Ñкої перевірки автентичноÑÑ‚Ñ–."
+
+msgid "The repository for this project does not exist."
+msgstr "Репозиторій Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту не Ñ–Ñнує."
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ð¾Ð³Ð»Ñду показує Ñ‡Ð°Ñ Ð²Ñ–Ð´ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ про об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð´Ð¾ його "
+"виконаннÑ. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ запиту на "
+"злиттÑ."
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr ""
+"Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ð”Ð•Ð’ показує Ñ‡Ð°Ñ Ð¼Ñ–Ð¶ злиттÑм \"MR\" та розгортаннÑм коду у ПРОДакшин. "
+"Дані автоматично додаютьÑÑ Ð¿Ñ–ÑÐ»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ñƒ ПРОДакшин вперше."
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ñ‚ÐµÑÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾ÐºÐ°Ð·ÑƒÑ” чаÑ, Ñкий GitLab CI виконує Ð´Ð»Ñ Ð·Ð°Ð¿ÑƒÑку кожного "
+"конвеєра Ð´Ð»Ñ Ð²Ñ–Ð´Ð¿Ð¾Ð²Ñ–Ð´Ð½Ð¾Ð³Ð¾ запиту злиттÑ. Дані будуть автоматично додані "
+"піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ конвеєра."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "ЧаÑ, витрачений на кожен елемент, зібраний на цьому етапі."
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr ""
+"Середнє Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð² Ñ€Ñдку. Приклад: між 3, 5, 9, Ñередніми 5, між 3, 5, 7, 8, "
+"Ñередніми (5 + 7) / 2 = 6."
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr ""
+"Це означає, що ви не можете відправлÑти код, поки не Ñтворите порожній "
+"репозиторій або ÐЕ імпортуєте Ñ–Ñнуючий."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Ð§Ð°Ñ Ð´Ð¾ початку потраплÑÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ в планувальник"
+
+msgid "Time before an issue starts implementation"
+msgstr "Ð§Ð°Ñ Ð´Ð¾ початку роботи над проблемою"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Ð§Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм запиту Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ– злиттÑм або закриттÑм"
+
+msgid "Time until first merge request"
+msgstr "Ð§Ð°Ñ Ð´Ð¾ першого запиту на злиттÑ"
+
+msgid "Timeago|%s days ago"
+msgstr "%s днів тому"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s днів, що залишилиÑÑ"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s годин, що залишилиÑÑ"
+
+msgid "Timeago|%s minutes ago"
+msgstr "%s хвилин тому"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s хвилини залишитиÑÑ"
+
+msgid "Timeago|%s months ago"
+msgstr "%s міÑÑці(в) тому"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s міÑÑці(в), що залишилиÑÑ"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s Ñекунд, що залишаютьÑÑ"
+
+msgid "Timeago|%s weeks ago"
+msgstr "%s тижні(в) тому"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s тижні(в) залишилиÑÑ"
+
+msgid "Timeago|%s years ago"
+msgstr "%s років тому"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s роки, що залишилиÑÑ"
+
+msgid "Timeago|1 day remaining"
+msgstr "ЗалишивÑÑ 1 день"
+
+msgid "Timeago|1 hour remaining"
+msgstr "ЗалишилаÑÑŒ 1 година"
+
+msgid "Timeago|1 minute remaining"
+msgstr "ЗалишилаÑÑŒ 1 хвилина"
+
+msgid "Timeago|1 month remaining"
+msgstr "ЗалишивÑÑ 1 міÑÑць"
+
+msgid "Timeago|1 week remaining"
+msgstr "ЗалишивÑÑ 1 тиждень"
+
+msgid "Timeago|1 year remaining"
+msgstr "ЗалишивÑÑ 1 рік"
+
+msgid "Timeago|Past due"
+msgstr "ПроÑтрочені"
+
+msgid "Timeago|a day ago"
+msgstr "День тому"
+
+msgid "Timeago|a month ago"
+msgstr "міÑÑць тому"
+
+msgid "Timeago|a week ago"
+msgstr "тиждень тому"
+
+msgid "Timeago|a while"
+msgstr "деÑкий Ñ‡Ð°Ñ Ð½Ð°Ð·Ð°Ð´"
+
+msgid "Timeago|a year ago"
+msgstr "рік тому"
+
+msgid "Timeago|about %s hours ago"
+msgstr "Близько %s годин(и) тому"
+
+msgid "Timeago|about a minute ago"
+msgstr "Близько хвилини тому"
+
+msgid "Timeago|about an hour ago"
+msgstr "Близько години тому"
+
+msgid "Timeago|in %s days"
+msgstr "через %s дні(в)"
+
+msgid "Timeago|in %s hours"
+msgstr "через %s годин(и)"
+
+msgid "Timeago|in %s minutes"
+msgstr "через %s хвилин(и)"
+
+msgid "Timeago|in %s months"
+msgstr "через %s міÑÑці(в)"
+
+msgid "Timeago|in %s seconds"
+msgstr "через %s Ñекунд(и)"
+
+msgid "Timeago|in %s weeks"
+msgstr "через %s тижні(в)"
+
+msgid "Timeago|in %s years"
+msgstr "через %s роки(ів)"
+
+msgid "Timeago|in 1 day"
+msgstr "через 1 день"
+
+msgid "Timeago|in 1 hour"
+msgstr "через годину"
+
+msgid "Timeago|in 1 minute"
+msgstr "через хвилину"
+
+msgid "Timeago|in 1 month"
+msgstr "через міÑÑць"
+
+msgid "Timeago|in 1 week"
+msgstr "через тиждень"
+
+msgid "Timeago|in 1 year"
+msgstr "через рік"
+
+msgid "Timeago|less than a minute ago"
+msgstr "менше хвилини тому"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "година"
+msgstr[1] "години"
+msgstr[2] "годин"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "хвилина"
+msgstr[1] "хвилини"
+msgstr[2] "хвилин"
+
+msgid "Time|s"
+msgstr "Ñекунд(а)"
+
+msgid "Total Time"
+msgstr "Загальний чаÑ"
+
+msgid "Total test time for all commits/merges"
+msgstr "Загальний чаÑ, щоб перевірити вÑÑ– фікÑації/злиттÑ"
+
+msgid "Unstar"
+msgstr "ВідпиÑатиÑÑŒ"
+
+msgid "Upload New File"
+msgstr "Завантажити новий файл"
+
+msgid "Upload file"
+msgstr "Завантажити файл"
+
+msgid "UploadLink|click to upload"
+msgstr "ÐатиÑніть, щоб завантажити"
+
+msgid "Use your global notification setting"
+msgstr "ВикориÑтовуютьÑÑ Ð³Ð»Ð¾Ð±Ð°Ð»ÑŒÐ½Ñ– Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½ÑŒ"
+
+msgid "View open merge request"
+msgstr "ПереглÑд відкритих запитів на злиттÑ"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Внутрішній"
+
+msgid "VisibilityLevel|Private"
+msgstr "Приватний"
+
+msgid "VisibilityLevel|Public"
+msgstr "Публічний"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Ðевідомий"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "Хочете побачити дані? Будь лаÑка, попроÑить у адмініÑтратора доÑтуп."
+
+msgid "We don't have enough data to show this stage."
+msgstr "Ми не маємо доÑтатньо даних Ð´Ð»Ñ Ð¿Ð¾ÐºÐ°Ð·Ñƒ цього етапу."
+
+msgid "Withdraw Access Request"
+msgstr "СкаÑувати запит доÑтупу"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ви хочете видалити %{group_name}. Видалені групи ÐЕ МОЖÐРбуду відновити! Ви ÐБСОЛЮТÐО впевнені?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ви хочете видалити %{project_name_with_namespace}. Видалений проект ÐЕ МОЖЕ бути відновлений! Ви ÐБСОЛЮТÐО впевнені?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"Ви збираєтеÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ зв'Ñзок з форка з вихідним проектом "
+"%{forked_from_project}. Ви ÐБСОЛЮТÐО впевнені?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Ви збираєтеÑÑ Ð¿ÐµÑ€ÐµÐ´Ð°Ñ‚Ð¸ проект %{project_name_with_namespace} іншому влаÑнику."
+" Ви ÐБСОЛЮТÐО впевнені?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Ви можете додавати тільки файли, коли перебуваєте в гілці"
+
+msgid "You have reached your project limit"
+msgstr "Ви доÑÑгли Ð¾Ð±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ Ð² вашому проекті"
+
+msgid "You must sign in to star a project"
+msgstr "Ðеобхідно увійти, щоб оцінити проект"
+
+msgid "You need permission."
+msgstr "Вам потрібен дозвіл"
+
+msgid "You will not get any notifications via email"
+msgstr "Ви не отримаєте ніÑких повідомлень по електронній пошті"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Ви будете отримувати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ñ‚Ñ–Ð»ÑŒÐºÐ¸ про обрані вами події"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr ""
+"Ви будете отримувати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ñ‚Ñ–Ð»ÑŒÐºÐ¸ про тих темах, в Ñких ви брали "
+"учаÑÑ‚ÑŒ"
+
+msgid "You will receive notifications for any activity"
+msgstr "Ви будете отримувати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾ будь-Ñкі дії"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr ""
+"Ви будете отримувати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ñ‚Ñ–Ð»ÑŒÐºÐ¸ Ð´Ð»Ñ ÐºÐ¾Ð¼ÐµÐ½Ñ‚Ð°Ñ€Ñ–Ð², в Ñких ви були "
+"@згадані"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"Ви не зможете отримувати Ñ– відправлÑти код проекту через %{protocol} поки "
+"%{set_password_link} в ваш аккаунт"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr ""
+"Ви не зможете отримувати Ñ– відправлÑти код проекту через SSH поки "
+"%{add_ssh_key_link} в ваш профіль."
+
+msgid "Your name"
+msgstr "Ваше ім'Ñ"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "день"
+msgstr[1] "дні"
+msgstr[2] "днів"
+
+msgid "new merge request"
+msgstr "Ðовий запит на злиттÑ"
+
+msgid "notification emails"
+msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ ÐµÐ»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð¾ÑŽ поштою"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "джерело"
+msgstr[1] "джерела"
+msgstr[2] "джерел"
+
diff --git a/locale/uk/gitlab.po.time_stamp b/locale/uk/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/uk/gitlab.po.time_stamp
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
new file mode 100644
index 00000000000..a3d0027212c
--- /dev/null
+++ b/locale/zh_CN/gitlab.po
@@ -0,0 +1,1175 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Xiaogang Wen <xiaogang@gitlab.com>, 2017.
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-05 08:50-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-07-12 06:23-0400\n"
+"Last-Translator: Huang Tao <htve@outlook.com>\n"
+"Language-Team: Chinese (China) (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: zh-CN\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "为æ高页é¢åŠ è½½é€Ÿåº¦åŠæ€§èƒ½ï¼Œå·²çœç•¥äº† %s 次æ交。"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d 次æ交"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "ç”± %{commit_author_link} æ交于 %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 æ¡æµæ°´çº¿"
+msgstr[1] "%d æ¡æµæ°´çº¿"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "æŒç»­é›†æˆæ•°æ®å›¾"
+
+msgid "About auto deploy"
+msgstr "关于自动部署"
+
+msgid "Active"
+msgstr "å¯ç”¨"
+
+msgid "Activity"
+msgstr "活动"
+
+msgid "Add Changelog"
+msgstr "添加更新日志"
+
+msgid "Add Contribution guide"
+msgstr "添加贡献指å—"
+
+msgid "Add License"
+msgstr "添加许å¯è¯"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "新建一个用于推é€æˆ–拉å–çš„ SSH 秘钥到账å·ä¸­ã€‚"
+
+msgid "Add new directory"
+msgstr "添加目录"
+
+msgid "Archived project! Repository is read-only"
+msgstr "项目已归档ï¼å­˜å‚¨åº“为åªè¯»çŠ¶æ€"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "确定è¦åˆ é™¤æ­¤æµæ°´çº¿è®¡åˆ’å—?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "拖放文件到此处或者 %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "分支"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"已创建分支 <strong>%{branch_name}</strong> 。如需设置自动部署, 请选择åˆé€‚çš„ GitLab CI Yaml "
+"模æ¿å¹¶æ交更改。%{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "æœç´¢åˆ†æ”¯"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "切æ¢åˆ†æ”¯"
+
+msgid "Branches"
+msgstr "分支"
+
+msgid "Browse Directory"
+msgstr "æµè§ˆç›®å½•"
+
+msgid "Browse File"
+msgstr "æµè§ˆæ–‡ä»¶"
+
+msgid "Browse Files"
+msgstr "æµè§ˆæ–‡ä»¶"
+
+msgid "Browse files"
+msgstr "æµè§ˆæ–‡ä»¶"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr "CI é…ç½®"
+
+msgid "Cancel"
+msgstr "å–消"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "选择分支"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "还原分支"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "优选"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "还原"
+
+msgid "Changelog"
+msgstr "更新日志"
+
+msgid "Charts"
+msgstr "统计图"
+
+msgid "Cherry-pick this commit"
+msgstr "优选此æ交"
+
+msgid "Cherry-pick this merge request"
+msgstr "优选此åˆå¹¶è¯·æ±‚"
+
+msgid "CiStatusLabel|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusLabel|created"
+msgstr "已创建"
+
+msgid "CiStatusLabel|failed"
+msgstr "已失败"
+
+msgid "CiStatusLabel|manual action"
+msgstr "手动æ“作"
+
+msgid "CiStatusLabel|passed"
+msgstr "已通过"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "已通过但有警告"
+
+msgid "CiStatusLabel|pending"
+msgstr "等待中"
+
+msgid "CiStatusLabel|skipped"
+msgstr "已跳过"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "等待手动æ“作"
+
+msgid "CiStatusText|blocked"
+msgstr "已阻塞"
+
+msgid "CiStatusText|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusText|created"
+msgstr "已创建"
+
+msgid "CiStatusText|failed"
+msgstr "已失败"
+
+msgid "CiStatusText|manual"
+msgstr "手动æ“作"
+
+msgid "CiStatusText|passed"
+msgstr "已通过"
+
+msgid "CiStatusText|pending"
+msgstr "等待中"
+
+msgid "CiStatusText|skipped"
+msgstr "已跳过"
+
+msgid "CiStatus|running"
+msgstr "è¿è¡Œä¸­"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "æ交"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "最近30次æ交相应æŒç»­é›†æˆèŠ±è´¹çš„时间(分钟)"
+
+msgid "Commit message"
+msgstr "æ交信æ¯"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "æ交"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "添加 %{file_name}"
+
+msgid "Commits"
+msgstr "æ交"
+
+msgid "Commits feed"
+msgstr "æ交动æ€"
+
+msgid "Commits|History"
+msgstr "历å²"
+
+msgid "Committed by"
+msgstr "æ交者:"
+
+msgid "Compare"
+msgstr "比较"
+
+msgid "Contribution guide"
+msgstr "贡献指å—"
+
+msgid "Contributors"
+msgstr "贡献者"
+
+msgid "Copy URL to clipboard"
+msgstr "å¤åˆ¶ URL 到剪贴æ¿"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "å¤åˆ¶æ交 SHA 的值到剪贴æ¿"
+
+msgid "Create New Directory"
+msgstr "创建新目录"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr "在å¸æˆ·ä¸Šåˆ›å»ºä¸ªäººè®¿é—®ä»¤ç‰Œï¼Œä»¥é€šè¿‡ %{protocol} æ¥æ‹‰å–或推é€ã€‚"
+
+msgid "Create directory"
+msgstr "创建目录"
+
+msgid "Create empty bare repository"
+msgstr "创建空的存储库"
+
+msgid "Create merge request"
+msgstr "创建åˆå¹¶è¯·æ±‚"
+
+msgid "Create new..."
+msgstr "创建..."
+
+msgid "CreateNewFork|Fork"
+msgstr "派生"
+
+msgid "CreateTag|Tag"
+msgstr "标签"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "创建个人访问令牌"
+
+msgid "Cron Timezone"
+msgstr "Cron 时区"
+
+msgid "Cron syntax"
+msgstr "Cron 语法"
+
+msgid "Custom notification events"
+msgstr "自定义通知事件"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"自定义通知级别继承自å‚与级别。使用自定义通知级别,您会收到å‚与级别åŠé€‰å®šäº‹ä»¶çš„通知。想了解更多信æ¯ï¼Œè¯·æŸ¥çœ‹ %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "周期分æž"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "周期分æžæ¦‚述了项目从想法到产å“实现的å„阶段所需的时间。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "ç¼–ç "
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "议题"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "计划"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生产"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "评审"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "预å‘布"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "测试"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "使用 Cron 语法定义自定义模å¼"
+
+msgid "Delete"
+msgstr "删除"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr "æè¿°"
+
+msgid "Directory name"
+msgstr "目录å称"
+
+msgid "Don't show again"
+msgstr "ä¸å†æ˜¾ç¤º"
+
+msgid "Download"
+msgstr "下载"
+
+msgid "Download tar"
+msgstr "下载 tar"
+
+msgid "Download tar.bz2"
+msgstr "下载 tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "下载 tar.gz"
+
+msgid "Download zip"
+msgstr "下载 zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "下载"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "电å­é‚®ä»¶è¡¥ä¸"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "差异文件"
+
+msgid "DownloadSource|Download"
+msgstr "下载"
+
+msgid "Edit"
+msgstr "编辑"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "编辑 %{id} æµæ°´çº¿è®¡åˆ’"
+
+msgid "Every day (at 4:00am)"
+msgstr "æ¯æ—¥æ‰§è¡Œï¼ˆå‡Œæ™¨ 4 点)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "æ¯æœˆæ‰§è¡Œï¼ˆæ¯æœˆ 1 日凌晨 4 点)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "æ¯å‘¨æ‰§è¡Œï¼ˆå‘¨æ—¥å‡Œæ™¨ 4 点)"
+
+msgid "Failed to change the owner"
+msgstr "无法å˜æ›´æ‰€æœ‰è€…"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "无法删除æµæ°´çº¿è®¡åˆ’"
+
+msgid "Files"
+msgstr "文件"
+
+msgid "Filter by commit message"
+msgstr "按æ交消æ¯è¿‡æ»¤"
+
+msgid "Find by path"
+msgstr "按路径查找"
+
+msgid "Find file"
+msgstr "查找文件"
+
+msgid "FirstPushedBy|First"
+msgstr "首次推é€"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推é€è€…:"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "派生"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "派生自"
+
+msgid "From issue creation until deploy to production"
+msgstr "从创建议题到部署至生产环境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "从åˆå¹¶è¯·æ±‚被åˆå¹¶åŽåˆ°éƒ¨ç½²è‡³ç”Ÿäº§çŽ¯å¢ƒ"
+
+msgid "Go to your fork"
+msgstr "跳转到派生项目"
+
+msgid "GoToYourFork|Fork"
+msgstr "跳转到派生项目"
+
+msgid "Home"
+msgstr "首页"
+
+msgid "Housekeeping successfully started"
+msgstr "已开始维护"
+
+msgid "Import repository"
+msgstr "导入存储库"
+
+msgid "Interval Pattern"
+msgstr "循环周期"
+
+msgid "Introducing Cycle Analytics"
+msgstr "周期分æžç®€ä»‹"
+
+msgid "Jobs for last month"
+msgstr "上个月的作业"
+
+msgid "Jobs for last week"
+msgstr "上个星期的作业"
+
+msgid "Jobs for last year"
+msgstr "去年的作业"
+
+msgid "LFSStatus|Disabled"
+msgstr "åœç”¨"
+
+msgid "LFSStatus|Enabled"
+msgstr "å¯ç”¨"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最近 %d 天"
+
+msgid "Last Pipeline"
+msgstr "最新æµæ°´çº¿"
+
+msgid "Last Update"
+msgstr "最åŽæ›´æ–°"
+
+msgid "Last commit"
+msgstr "最åŽæ交"
+
+msgid "Learn more in the"
+msgstr "了解更多"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "æµæ°´çº¿è®¡åˆ’文档"
+
+msgid "Leave group"
+msgstr "退出群组"
+
+msgid "Leave project"
+msgstr "退出项目"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多显示 %d 个事件"
+
+msgid "Median"
+msgstr "中ä½æ•°"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "新建 SSH 公钥"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新建议题"
+
+msgid "New Pipeline Schedule"
+msgstr "创建æµæ°´çº¿è®¡åˆ’"
+
+msgid "New branch"
+msgstr "新建分支"
+
+msgid "New directory"
+msgstr "新建目录"
+
+msgid "New file"
+msgstr "新建文件"
+
+msgid "New issue"
+msgstr "新建议题"
+
+msgid "New merge request"
+msgstr "新建åˆå¹¶è¯·æ±‚"
+
+msgid "New schedule"
+msgstr "新建计划"
+
+msgid "New snippet"
+msgstr "新建代ç ç‰‡æ®µ"
+
+msgid "New tag"
+msgstr "新建标签"
+
+msgid "No repository"
+msgstr "没有存储库"
+
+msgid "No schedules"
+msgstr "没有计划"
+
+msgid "Not available"
+msgstr "æ•°æ®ä¸è¶³"
+
+msgid "Not enough data"
+msgstr "æ•°æ®ä¸è¶³"
+
+msgid "Notification events"
+msgstr "通知事件"
+
+msgid "NotificationEvent|Close issue"
+msgstr "关闭议题"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "关闭åˆå¹¶è¯·æ±‚"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "æµæ°´çº¿å¤±è´¥"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "åˆå¹¶è¯·æ±‚被åˆå¹¶"
+
+msgid "NotificationEvent|New issue"
+msgstr "新建议题"
+
+msgid "NotificationEvent|New merge request"
+msgstr "新建åˆå¹¶è¯·æ±‚"
+
+msgid "NotificationEvent|New note"
+msgstr "新建评论"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "é‡æ–°æŒ‡æ´¾è®®é¢˜"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "é‡æ–°æŒ‡æ´¾åˆå¹¶è¯·æ±‚"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "é‡å¯è®®é¢˜"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "æµæ°´çº¿æˆåŠŸå®Œæˆ"
+
+msgid "NotificationLevel|Custom"
+msgstr "自定义"
+
+msgid "NotificationLevel|Disabled"
+msgstr "åœç”¨"
+
+msgid "NotificationLevel|Global"
+msgstr "全局"
+
+msgid "NotificationLevel|On mention"
+msgstr "æåŠ"
+
+msgid "NotificationLevel|Participate"
+msgstr "å‚与"
+
+msgid "NotificationLevel|Watch"
+msgstr "关注"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "筛选"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "开始于"
+
+msgid "Options"
+msgstr "æ“作"
+
+msgid "Owner"
+msgstr "所有者"
+
+msgid "Pipeline"
+msgstr "æµæ°´çº¿"
+
+msgid "Pipeline Health"
+msgstr "æµæ°´çº¿å¥åº·æŒ‡æ ‡"
+
+msgid "Pipeline Schedule"
+msgstr "æµæ°´çº¿è®¡åˆ’"
+
+msgid "Pipeline Schedules"
+msgstr "æµæ°´çº¿è®¡åˆ’"
+
+msgid "PipelineCharts|Failed:"
+msgstr "失败:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "总体统计数æ®"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "æˆåŠŸçŽ‡ï¼š"
+
+msgid "PipelineCharts|Successful:"
+msgstr "æˆåŠŸï¼š"
+
+msgid "PipelineCharts|Total:"
+msgstr "总计:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "是å¦å¯ç”¨"
+
+msgid "PipelineSchedules|Active"
+msgstr "å·²å¯ç”¨"
+
+msgid "PipelineSchedules|All"
+msgstr "所有"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "未å¯ç”¨"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "输入å˜é‡å"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "输入å˜é‡å€¼"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "下次è¿è¡Œæ—¶é—´"
+
+msgid "PipelineSchedules|None"
+msgstr "æ— "
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "为此æµæ°´çº¿æ供简短æè¿°"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "删除å˜é‡"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "å–得所有æƒ"
+
+msgid "PipelineSchedules|Target"
+msgstr "目标"
+
+msgid "PipelineSchedules|Variables"
+msgstr "å˜é‡"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "自定义"
+
+msgid "Pipelines"
+msgstr "æµæ°´çº¿"
+
+msgid "Pipelines charts"
+msgstr "æµæ°´çº¿ç»Ÿè®¡å›¾"
+
+msgid "Pipeline|all"
+msgstr "所有"
+
+msgid "Pipeline|success"
+msgstr "æˆåŠŸ"
+
+msgid "Pipeline|with stage"
+msgstr "于阶段"
+
+msgid "Pipeline|with stages"
+msgstr "于阶段"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "项目 '%{project_name}' 已进入删除队列。"
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "项目 '%{project_name}' 已创建æˆåŠŸã€‚"
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "项目 '%{project_name}' 已更新完æˆã€‚"
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "项目 '%{project_name}' 将被删除。"
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "项目访问æƒé™å¿…须明确授æƒç»™æ¯ä¸ªç”¨æˆ·ã€‚"
+
+msgid "Project export could not be deleted."
+msgstr "无法删除项目导出。"
+
+msgid "Project export has been deleted."
+msgstr "项目导出已被删除。"
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr "项目导出链接已过期。请从项目设置中é‡æ–°ç”Ÿæˆé¡¹ç›®å¯¼å‡ºã€‚"
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "项目导出已开始。下载链接将通过电å­é‚®ä»¶å‘é€ã€‚"
+
+msgid "Project home"
+msgstr "项目首页"
+
+msgid "ProjectFeature|Disabled"
+msgstr "åœç”¨"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "任何对项目有访问æƒçš„人"
+
+msgid "ProjectFeature|Only team members"
+msgstr "åªé™å›¢é˜Ÿæˆå‘˜"
+
+msgid "ProjectFileTree|Name"
+msgstr "å称"
+
+msgid "ProjectLastActivity|Never"
+msgstr "从未"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "阶段"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "分支图"
+
+msgid "Read more"
+msgstr "了解更多"
+
+msgid "Readme"
+msgstr "自述文件"
+
+msgid "RefSwitcher|Branches"
+msgstr "分支"
+
+msgid "RefSwitcher|Tags"
+msgstr "标签"
+
+msgid "Related Commits"
+msgstr "相关的æ交"
+
+msgid "Related Deployed Jobs"
+msgstr "相关的部署作业"
+
+msgid "Related Issues"
+msgstr "相关的议题"
+
+msgid "Related Jobs"
+msgstr "相关的作业"
+
+msgid "Related Merge Requests"
+msgstr "相关的åˆå¹¶è¯·æ±‚"
+
+msgid "Related Merged Requests"
+msgstr "相关已åˆå¹¶çš„åˆå¹¶è¯·æ±‚"
+
+msgid "Remind later"
+msgstr "ç¨åŽæ醒"
+
+msgid "Remove project"
+msgstr "删除项目"
+
+msgid "Request Access"
+msgstr "申请æƒé™"
+
+msgid "Revert this commit"
+msgstr "还原此æ交"
+
+msgid "Revert this merge request"
+msgstr "还原此åˆå¹¶è¯·æ±‚"
+
+msgid "Save pipeline schedule"
+msgstr "ä¿å­˜æµæ°´çº¿è®¡åˆ’"
+
+msgid "Schedule a new pipeline"
+msgstr "新建æµæ°´çº¿è®¡åˆ’"
+
+msgid "Scheduling Pipelines"
+msgstr "æµæ°´çº¿è®¡åˆ’"
+
+msgid "Search branches and tags"
+msgstr "æœç´¢åˆ†æ”¯å’Œæ ‡ç­¾"
+
+msgid "Select Archive Format"
+msgstr "选择下载格å¼"
+
+msgid "Select a timezone"
+msgstr "选择时区"
+
+msgid "Select target branch"
+msgstr "选择目标分支"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "为账å·åˆ›å»ºä¸€ä¸ªç”¨äºŽæŽ¨é€æˆ–拉å–çš„ %{protocol} 密ç ã€‚"
+
+msgid "Set up CI"
+msgstr "设置 CI"
+
+msgid "Set up Koding"
+msgstr "设置 Koding"
+
+msgid "Set up auto deploy"
+msgstr "设置自动部署"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "设置密ç "
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "显示 %d 个事件"
+
+msgid "Source code"
+msgstr "æºä»£ç "
+
+msgid "StarProject|Star"
+msgstr "星标"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "由此更改 %{new_merge_request}"
+
+msgid "Switch branch/tag"
+msgstr "切æ¢åˆ†æ”¯/标签"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "标签"
+
+msgid "Tags"
+msgstr "标签"
+
+msgid "Target Branch"
+msgstr "目标分支"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr "ç¼–ç é˜¶æ®µæ¦‚述了从第一次æ交到创建åˆå¹¶è¯·æ±‚的时间。创建第一个åˆå¹¶è¯·æ±‚åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "与该阶段相关的事件集åˆã€‚"
+
+msgid "The fork relationship has been removed."
+msgstr "派生关系已被删除。"
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr "议题阶段概述了从创建议题到将议题添加到里程碑或议题看æ¿æ‰€èŠ±è´¹çš„时间。创建第一个议题åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„.。"
+
+msgid "The phase of the development lifecycle."
+msgstr "项目生命周期中的å„个阶段。"
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr "æµæ°´çº¿è®¡åˆ’会周期性é‡å¤è¿è¡ŒæŒ‡å®šåˆ†æ”¯æˆ–标签的æµæ°´çº¿ã€‚这些æµæ°´çº¿å°†æ ¹æ®å…¶å…³è”用户继承有é™çš„项目访问æƒé™ã€‚"
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr "计划阶段概述了从议题添加到日程到推é€é¦–次æ交的时间。当首次推é€æ交åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr "生产阶段概述了从创建一个议题到将代ç éƒ¨ç½²åˆ°ç”Ÿäº§çŽ¯å¢ƒçš„总时间。当完æˆæƒ³æ³•åˆ°éƒ¨ç½²ç”Ÿäº§çš„循环,数æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr "该项目å…许已登录的用户访问。"
+
+msgid "The project can be accessed without any authentication."
+msgstr "该项目å…许任何人访问。"
+
+msgid "The repository for this project does not exist."
+msgstr "此项目的存储库ä¸å­˜åœ¨ã€‚"
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr "评审阶段概述了从创建åˆå¹¶è¯·æ±‚到被åˆå¹¶çš„时间。当创建第一个åˆå¹¶è¯·æ±‚åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr "预å‘布阶段概述了从åˆå¹¶è¯·æ±‚被åˆå¹¶åˆ°éƒ¨ç½²è‡³ç”Ÿäº§çŽ¯å¢ƒçš„总时间。首次部署到生产环境åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr "测试阶段概述了 GitLab CI 为相关åˆå¹¶è¯·æ±‚è¿è¡Œæ¯ä¸ªæµæ°´çº¿æ‰€éœ€çš„时间。当第一个æµæ°´çº¿è¿è¡Œå®ŒæˆåŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "该阶段æ¯æ¡æ•°æ®æ‰€èŠ±çš„时间"
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr "中ä½æ•°æ˜¯ä¸€ä¸ªæ•°åˆ—中最中间的值。例如在 3ã€5ã€9 之间,中ä½æ•°æ˜¯ 5。在 3ã€5ã€7ã€8 之间,中ä½æ•°æ˜¯ (5 + 7)/ 2 = 6。"
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr "在创建一个空的存储库或导入现有存储库之å‰ï¼Œå°†æ— æ³•æŽ¨é€ä»£ç ã€‚"
+
+msgid "Time before an issue gets scheduled"
+msgstr "议题被列入日程表的时间"
+
+msgid "Time before an issue starts implementation"
+msgstr "开始进行编ç å‰çš„时间"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "从创建åˆå¹¶è¯·æ±‚到被åˆå¹¶æˆ–关闭的时间"
+
+msgid "Time until first merge request"
+msgstr "创建第一个åˆå¹¶è¯·æ±‚之å‰çš„时间"
+
+msgid "Timeago|%s days ago"
+msgstr " %s 天å‰"
+
+msgid "Timeago|%s days remaining"
+msgstr "剩余 %s 天"
+
+msgid "Timeago|%s hours remaining"
+msgstr "剩余 %s å°æ—¶"
+
+msgid "Timeago|%s minutes ago"
+msgstr " %s 分钟å‰"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "剩余 %s 分钟"
+
+msgid "Timeago|%s months ago"
+msgstr " %s 个月å‰"
+
+msgid "Timeago|%s months remaining"
+msgstr "剩余 %s 月"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "剩余 %s 秒"
+
+msgid "Timeago|%s weeks ago"
+msgstr " %s 星期å‰"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "剩余 %s 星期"
+
+msgid "Timeago|%s years ago"
+msgstr " %s å¹´å‰"
+
+msgid "Timeago|%s years remaining"
+msgstr "剩余 %s 年"
+
+msgid "Timeago|1 day remaining"
+msgstr "剩余 1 天"
+
+msgid "Timeago|1 hour remaining"
+msgstr "剩余 1 å°æ—¶"
+
+msgid "Timeago|1 minute remaining"
+msgstr "剩余 1 分钟"
+
+msgid "Timeago|1 month remaining"
+msgstr "剩余 1 个月"
+
+msgid "Timeago|1 week remaining"
+msgstr "剩余 1 星期"
+
+msgid "Timeago|1 year remaining"
+msgstr "剩余 1 年"
+
+msgid "Timeago|Past due"
+msgstr "逾期"
+
+msgid "Timeago|a day ago"
+msgstr " 1 天å‰"
+
+msgid "Timeago|a month ago"
+msgstr " 1 个月å‰"
+
+msgid "Timeago|a week ago"
+msgstr " 1 星期å‰"
+
+msgid "Timeago|a while"
+msgstr "刚刚"
+
+msgid "Timeago|a year ago"
+msgstr " 1 å¹´å‰"
+
+msgid "Timeago|about %s hours ago"
+msgstr "约 %s å°æ—¶å‰"
+
+msgid "Timeago|about a minute ago"
+msgstr "约 1 分钟å‰"
+
+msgid "Timeago|about an hour ago"
+msgstr "约 1 å°æ—¶å‰"
+
+msgid "Timeago|in %s days"
+msgstr " %s 天åŽ"
+
+msgid "Timeago|in %s hours"
+msgstr " %s å°æ—¶åŽ"
+
+msgid "Timeago|in %s minutes"
+msgstr " %s 分钟åŽ"
+
+msgid "Timeago|in %s months"
+msgstr " %s 个月åŽ"
+
+msgid "Timeago|in %s seconds"
+msgstr " %s 秒åŽ"
+
+msgid "Timeago|in %s weeks"
+msgstr " %s 星期åŽ"
+
+msgid "Timeago|in %s years"
+msgstr " %s å¹´åŽ"
+
+msgid "Timeago|in 1 day"
+msgstr " 1 天åŽ"
+
+msgid "Timeago|in 1 hour"
+msgstr " 1 å°æ—¶åŽ"
+
+msgid "Timeago|in 1 minute"
+msgstr " 1 分钟åŽ"
+
+msgid "Timeago|in 1 month"
+msgstr " 1 月åŽ"
+
+msgid "Timeago|in 1 week"
+msgstr " 1 星期åŽ"
+
+msgid "Timeago|in 1 year"
+msgstr " 1 å¹´åŽ"
+
+msgid "Timeago|less than a minute ago"
+msgstr "ä¸åˆ° 1 分钟å‰"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "å°æ—¶"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分钟"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "总时间"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有æ交和åˆå¹¶çš„总测试时间"
+
+msgid "Unstar"
+msgstr "å–消星标"
+
+msgid "Upload New File"
+msgstr "上传新文件"
+
+msgid "Upload file"
+msgstr "上传文件"
+
+msgid "UploadLink|click to upload"
+msgstr "点击上传"
+
+msgid "Use your global notification setting"
+msgstr "使用全局通知设置"
+
+msgid "View open merge request"
+msgstr "查看待处ç†çš„åˆå¹¶è¯·æ±‚"
+
+msgid "VisibilityLevel|Internal"
+msgstr "内部"
+
+msgid "VisibilityLevel|Private"
+msgstr "ç§æœ‰"
+
+msgid "VisibilityLevel|Public"
+msgstr "公开"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "未知"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "æƒé™ä¸è¶³ã€‚如需查看相关数æ®ï¼Œè¯·å‘管ç†å‘˜ç”³è¯·æƒé™ã€‚"
+
+msgid "We don't have enough data to show this stage."
+msgstr "该阶段的数æ®ä¸è¶³ï¼Œæ— æ³•æ˜¾ç¤ºã€‚"
+
+msgid "Withdraw Access Request"
+msgstr "å–消æƒé™ç”³è¯·"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°†åˆ é™¤ %{group_name}。已删除的群组无法æ¢å¤ï¼ç¡®å®šç»§ç»­å—?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°†è¦åˆ é™¤ %{project_name_with_namespace}。已删除的项目无法æ¢å¤ï¼ç¡®å®šç»§ç»­å—?"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "å³å°†åˆ é™¤ä¸Žæºé¡¹ç›® %{forked_from_project} 的派生关系。确定继续å—?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr "å³å°† %{project_name_with_namespace} 转移给å¦ä¸€ä¸ªæ‰€æœ‰è€…。确定继续å—?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "åªèƒ½åœ¨åˆ†æ”¯ä¸Šæ·»åŠ æ–‡ä»¶"
+
+msgid "You have reached your project limit"
+msgstr "您已达到项目数é‡é™åˆ¶"
+
+msgid "You must sign in to star a project"
+msgstr "必须登录æ‰èƒ½å¯¹é¡¹ç›®åŠ æ˜Ÿæ ‡"
+
+msgid "You need permission."
+msgstr "需è¦ç›¸å…³çš„æƒé™ã€‚"
+
+msgid "You will not get any notifications via email"
+msgstr "ä¸ä¼šæ”¶åˆ°ä»»ä½•é€šçŸ¥é‚®ä»¶"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "åªæŽ¥æ”¶é€‰æ‹©çš„事件通知"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "åªæŽ¥æ”¶å‚与的主题的通知"
+
+msgid "You will receive notifications for any activity"
+msgstr "接收所有活动的通知"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "åªæŽ¥æ”¶è¯„论中æåŠ(@)您的通知"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr "在账å·ä¸­ %{set_password_link} 之å‰å°†æ— æ³•é€šè¿‡ %{protocol} 拉å–或推é€ä»£ç ã€‚"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr "在账å·ä¸­ %{add_ssh_key_link} 之å‰å°†æ— æ³•é€šè¿‡ SSH 拉å–或推é€ä»£ç ã€‚"
+
+msgid "Your name"
+msgstr "您的åå­—"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "new merge request"
+msgstr "新建åˆå¹¶è¯·æ±‚"
+
+msgid "notification emails"
+msgstr "通知邮件"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "父级"
diff --git a/locale/zh_CN/gitlab.po.time_stamp b/locale/zh_CN/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_CN/gitlab.po.time_stamp
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
new file mode 100644
index 00000000000..f4d33862a36
--- /dev/null
+++ b/locale/zh_HK/gitlab.po
@@ -0,0 +1,1174 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-05 08:50-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-07-12 06:32-0400\n"
+"Last-Translator: Huang Tao <htve@outlook.com>\n"
+"Language-Team: Chinese (Hong Kong SAR China) (https://translate.zanata.org/project/view/GitLab)\n"
+"Language: zh-HK\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "為æ高é é¢åŠ è¼‰é€Ÿåº¦åŠæ€§èƒ½ï¼Œå·²çœç•¥äº† %s 次æ交。"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] " %d 次æ交"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "ç”± %{commit_author_link} æ交於 %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 æ¢æµæ°´ç·š"
+msgstr[1] "%d æ¢æµæ°´ç·š"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "相關æŒçºŒé›†æˆçš„圖åƒé›†åˆ"
+
+msgid "About auto deploy"
+msgstr "關於自動部署"
+
+msgid "Active"
+msgstr "啟用"
+
+msgid "Activity"
+msgstr "活動"
+
+msgid "Add Changelog"
+msgstr "添加更新日誌"
+
+msgid "Add Contribution guide"
+msgstr "添加貢ç»æŒ‡å—"
+
+msgid "Add License"
+msgstr "添加許å¯è­‰"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "新增壹個用於推é€æˆ–拉å–çš„ SSH 秘鑰到賬號中。"
+
+msgid "Add new directory"
+msgstr "添加新目錄"
+
+msgid "Archived project! Repository is read-only"
+msgstr "歸檔項目ï¼å­˜å„²åº«ç‚ºåªè®€"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "確定è¦åˆªé™¤æ­¤æµæ°´ç·šè¨ˆåŠƒå—Žï¼Ÿ"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "拖放文件到此處或者 %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "分支"
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"分支 <strong>%{branch_name}</strong> 已創建。如需設置自動部署, è«‹é¸æ“‡åˆé©çš„ GitLab CI Yaml "
+"模æ¿ä½µæ交更改。%{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "æœç´¢åˆ†æ”¯"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "切æ›åˆ†æ”¯"
+
+msgid "Branches"
+msgstr "分支"
+
+msgid "Browse Directory"
+msgstr "ç€è¦½ç›®éŒ„"
+
+msgid "Browse File"
+msgstr "ç€è¦½æ–‡ä»¶"
+
+msgid "Browse Files"
+msgstr "ç€è¦½æ–‡ä»¶"
+
+msgid "Browse files"
+msgstr "ç€è¦½æ–‡ä»¶"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr "CI é…ç½®"
+
+msgid "Cancel"
+msgstr "å–消"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "挑é¸åˆ°åˆ†æ”¯"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "還原分支"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "優é¸"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "還原"
+
+msgid "Changelog"
+msgstr "更新日誌"
+
+msgid "Charts"
+msgstr "統計圖"
+
+msgid "Cherry-pick this commit"
+msgstr "優é¸æ­¤æ交"
+
+msgid "Cherry-pick this merge request"
+msgstr "優é¸æ­¤åˆä½µè«‹æ±‚"
+
+msgid "CiStatusLabel|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusLabel|created"
+msgstr "已創建"
+
+msgid "CiStatusLabel|failed"
+msgstr "已失敗"
+
+msgid "CiStatusLabel|manual action"
+msgstr "手動æ“作"
+
+msgid "CiStatusLabel|passed"
+msgstr "已通éŽ"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "已通éŽä½†æœ‰è­¦å‘Š"
+
+msgid "CiStatusLabel|pending"
+msgstr "等待中"
+
+msgid "CiStatusLabel|skipped"
+msgstr "已跳éŽ"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "等待手動æ“作"
+
+msgid "CiStatusText|blocked"
+msgstr "已阻塞"
+
+msgid "CiStatusText|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusText|created"
+msgstr "已創建"
+
+msgid "CiStatusText|failed"
+msgstr "已失敗"
+
+msgid "CiStatusText|manual"
+msgstr "待手動"
+
+msgid "CiStatusText|passed"
+msgstr "已通éŽ"
+
+msgid "CiStatusText|pending"
+msgstr "等待中"
+
+msgid "CiStatusText|skipped"
+msgstr "已跳éŽ"
+
+msgid "CiStatus|running"
+msgstr "é‹è¡Œä¸­"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "æ交"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "最近30次æ交花費的時間(分é˜ï¼‰"
+
+msgid "Commit message"
+msgstr "æ交信æ¯"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "æ交"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "添加 %{file_name}"
+
+msgid "Commits"
+msgstr "æ交"
+
+msgid "Commits feed"
+msgstr "æ交動態"
+
+msgid "Commits|History"
+msgstr "æ­·å²"
+
+msgid "Committed by"
+msgstr "æ交者:"
+
+msgid "Compare"
+msgstr "比較"
+
+msgid "Contribution guide"
+msgstr "è²¢ç»æŒ‡å—"
+
+msgid "Contributors"
+msgstr "è²¢ç»è€…"
+
+msgid "Copy URL to clipboard"
+msgstr "複製URL到剪貼æ¿"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "複製æ交 SHA 到剪貼æ¿"
+
+msgid "Create New Directory"
+msgstr "創建新目錄"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr "在帳戶上創建個人訪å•ä»¤ç‰Œï¼Œä»¥é€šéŽ %{protocol} 來拉å–或推é€ã€‚"
+
+msgid "Create directory"
+msgstr "創建目錄"
+
+msgid "Create empty bare repository"
+msgstr "創建空的存儲庫"
+
+msgid "Create merge request"
+msgstr "創建åˆä½µè«‹æ±‚"
+
+msgid "Create new..."
+msgstr "創建..."
+
+msgid "CreateNewFork|Fork"
+msgstr "派生"
+
+msgid "CreateTag|Tag"
+msgstr "標籤"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "創建個人訪å•ä»¤ç‰Œ"
+
+msgid "Cron Timezone"
+msgstr "Cron 時å€"
+
+msgid "Cron syntax"
+msgstr "Cron 語法"
+
+msgid "Custom notification events"
+msgstr "自定義通知事件"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"自定義通知級別繼承自åƒèˆ‡ç´šåˆ¥ã€‚使用自定義通知級別,您會收到åƒèˆ‡ç´šåˆ¥åŠé¸å®šäº‹ä»¶çš„通知。想了解更多信æ¯ï¼Œè«‹æŸ¥çœ‹ %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "週期分æž"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "週期分æžæ¦‚述了項目從想法到產å“實ç¾çš„å„階段所需的時間。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "編碼"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生產"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "評審"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "é ç™¼å¸ƒ"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "使用 Cron 語法定義自定義模å¼"
+
+msgid "Delete"
+msgstr "刪除"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr "æè¿°"
+
+msgid "Directory name"
+msgstr "目錄å稱"
+
+msgid "Don't show again"
+msgstr "ä¸å†é¡¯ç¤º"
+
+msgid "Download"
+msgstr "下載"
+
+msgid "Download tar"
+msgstr "下載 tar"
+
+msgid "Download tar.bz2"
+msgstr "下載 tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "下載 tar.gz"
+
+msgid "Download zip"
+msgstr "下載 zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "下載"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "é›»å­éƒµä»¶è£œä¸"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "差異文件"
+
+msgid "DownloadSource|Download"
+msgstr "下載"
+
+msgid "Edit"
+msgstr "編輯"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "編輯 %{id} æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Every day (at 4:00am)"
+msgstr "æ¯æ—¥åŸ·è¡Œï¼ˆæ·©æ™¨ 4 點)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "æ¯æœˆåŸ·è¡Œï¼ˆæ¯æœˆ 1 日淩晨 4 點)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "æ¯é€±åŸ·è¡Œï¼ˆå‘¨æ—¥æ·©æ™¨ 4 點)"
+
+msgid "Failed to change the owner"
+msgstr "無法變更所有者"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "無法刪除æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Files"
+msgstr "文件"
+
+msgid "Filter by commit message"
+msgstr "按æ交消æ¯éŽæ¿¾"
+
+msgid "Find by path"
+msgstr "按路徑查找"
+
+msgid "Find file"
+msgstr "查找文件"
+
+msgid "FirstPushedBy|First"
+msgstr "首次推é€"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推é€è€…:"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "派生"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "派生自"
+
+msgid "From issue creation until deploy to production"
+msgstr "從創建議題到部署到生產環境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從åˆä½µè«‹æ±‚çš„åˆä½µåˆ°éƒ¨ç½²è‡³ç”Ÿç”¢ç’°å¢ƒ"
+
+msgid "Go to your fork"
+msgstr "跳轉到派生項目"
+
+msgid "GoToYourFork|Fork"
+msgstr "跳轉到派生項目"
+
+msgid "Home"
+msgstr "首é "
+
+msgid "Housekeeping successfully started"
+msgstr "已開始維護"
+
+msgid "Import repository"
+msgstr "導入存儲庫"
+
+msgid "Interval Pattern"
+msgstr "循環週期"
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分æžç°¡ä»‹"
+
+msgid "Jobs for last month"
+msgstr "上個月的作業"
+
+msgid "Jobs for last week"
+msgstr "上個星期的作業"
+
+msgid "Jobs for last year"
+msgstr "去年的作業"
+
+msgid "LFSStatus|Disabled"
+msgstr "åœç”¨"
+
+msgid "LFSStatus|Enabled"
+msgstr "啟用"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最近 %d 天"
+
+msgid "Last Pipeline"
+msgstr "最新æµæ°´ç·š"
+
+msgid "Last Update"
+msgstr "最後更新"
+
+msgid "Last commit"
+msgstr "最後æ交"
+
+msgid "Learn more in the"
+msgstr "了解更多"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "æµæ°´ç·šè¨ˆåŠƒæ–‡æª”"
+
+msgid "Leave group"
+msgstr "退出群組"
+
+msgid "Leave project"
+msgstr "退出項目"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多顯示 %d 個事件"
+
+msgid "Median"
+msgstr "中ä½æ•¸"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "添加壹個 SSH 公鑰"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新建議題"
+
+msgid "New Pipeline Schedule"
+msgstr "創建æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "New branch"
+msgstr "新增分支"
+
+msgid "New directory"
+msgstr "新增目錄"
+
+msgid "New file"
+msgstr "新增文件"
+
+msgid "New issue"
+msgstr "新議題"
+
+msgid "New merge request"
+msgstr "新增åˆä½µè«‹æ±‚"
+
+msgid "New schedule"
+msgstr "新增计划"
+
+msgid "New snippet"
+msgstr "新代碼片段"
+
+msgid "New tag"
+msgstr "新增標籤"
+
+msgid "No repository"
+msgstr "沒有存儲庫"
+
+msgid "No schedules"
+msgstr "沒有計劃"
+
+msgid "Not available"
+msgstr "ä¸å¯ç”¨"
+
+msgid "Not enough data"
+msgstr "數據ä¸è¶³"
+
+msgid "Notification events"
+msgstr "通知事件"
+
+msgid "NotificationEvent|Close issue"
+msgstr "關閉議題"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "關閉åˆä½µè«‹æ±‚"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "æµæ°´ç·šå¤±æ•—"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "åˆä½µè«‹æ±‚被åˆä½µ"
+
+msgid "NotificationEvent|New issue"
+msgstr "新增議題"
+
+msgid "NotificationEvent|New merge request"
+msgstr "æ–°åˆä½µè«‹æ±‚"
+
+msgid "NotificationEvent|New note"
+msgstr "新增評論"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "é‡æ–°æŒ‡æ´¾è­°é¡Œ"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "é‡æ–°æŒ‡æ´¾åˆä½µè«‹æ±‚"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "é‡å•Ÿè­°é¡Œ"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "æµæ°´ç·šæˆåŠŸå®Œæˆ"
+
+msgid "NotificationLevel|Custom"
+msgstr "自定義"
+
+msgid "NotificationLevel|Disabled"
+msgstr "åœç”¨"
+
+msgid "NotificationLevel|Global"
+msgstr "全局"
+
+msgid "NotificationLevel|On mention"
+msgstr "æåŠ"
+
+msgid "NotificationLevel|Participate"
+msgstr "åƒèˆ‡"
+
+msgid "NotificationLevel|Watch"
+msgstr "關注"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "篩é¸"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Options"
+msgstr "æ“作"
+
+msgid "Owner"
+msgstr "所有者"
+
+msgid "Pipeline"
+msgstr "æµæ°´ç·š"
+
+msgid "Pipeline Health"
+msgstr "æµæ°´ç·šå¥åº·æŒ‡æ¨™"
+
+msgid "Pipeline Schedule"
+msgstr "æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Pipeline Schedules"
+msgstr "æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "PipelineCharts|Failed:"
+msgstr "失敗:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "總體統計"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "æˆåŠŸçŽ‡ï¼š"
+
+msgid "PipelineCharts|Successful:"
+msgstr "æˆåŠŸï¼š"
+
+msgid "PipelineCharts|Total:"
+msgstr "總計:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "是å¦å•Ÿç”¨"
+
+msgid "PipelineSchedules|Active"
+msgstr "已啟用"
+
+msgid "PipelineSchedules|All"
+msgstr "所有"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "未啟用"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "輸入變é‡å"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "輸入變é‡å€¼"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "下次é‹è¡Œæ™‚é–“"
+
+msgid "PipelineSchedules|None"
+msgstr "ç„¡"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "為此æµæ°´ç·šæ供簡短æè¿°"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "刪除變é‡"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "å–得所有權"
+
+msgid "PipelineSchedules|Target"
+msgstr "目標"
+
+msgid "PipelineSchedules|Variables"
+msgstr "變é‡"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "自定義"
+
+msgid "Pipelines"
+msgstr "æµæ°´ç·š"
+
+msgid "Pipelines charts"
+msgstr "æµæ°´ç·šåœ–表"
+
+msgid "Pipeline|all"
+msgstr "所有"
+
+msgid "Pipeline|success"
+msgstr "æˆåŠŸ"
+
+msgid "Pipeline|with stage"
+msgstr "於階段"
+
+msgid "Pipeline|with stages"
+msgstr "於階段"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "項目 '%{project_name}' 已進入刪除隊列。"
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "é …ç›® '%{project_name}' 已創建æˆåŠŸã€‚"
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "é …ç›® '%{project_name}' 已更新完æˆã€‚"
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "項目 '%{project_name}' 將被刪除。"
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "項目訪å•æ¬Šé™å¿…須明確授權給æ¯å€‹ç”¨æˆ¶ã€‚"
+
+msgid "Project export could not be deleted."
+msgstr "無法刪除項目導出。"
+
+msgid "Project export has been deleted."
+msgstr "項目導出已被刪除。"
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr "項目導出éˆæŽ¥å·²éŽæœŸã€‚請從項目設置中é‡æ–°ç”Ÿæˆé …目導出。"
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "項目導出已開始。下載éˆæŽ¥å°‡é€šéŽé›»å­éƒµä»¶ç™¼é€ã€‚"
+
+msgid "Project home"
+msgstr "項目首é "
+
+msgid "ProjectFeature|Disabled"
+msgstr "åœç”¨"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "任何人都å¯è¨ªå•"
+
+msgid "ProjectFeature|Only team members"
+msgstr "åªé™åœ˜éšŠæˆå“¡"
+
+msgid "ProjectFileTree|Name"
+msgstr "å稱"
+
+msgid "ProjectLastActivity|Never"
+msgstr "從未"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "階段"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "分支圖"
+
+msgid "Read more"
+msgstr "了解更多"
+
+msgid "Readme"
+msgstr "自述文件"
+
+msgid "RefSwitcher|Branches"
+msgstr "分支"
+
+msgid "RefSwitcher|Tags"
+msgstr "標籤"
+
+msgid "Related Commits"
+msgstr "相關的æ交"
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題"
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的åˆä½µè«‹æ±‚"
+
+msgid "Related Merged Requests"
+msgstr "相關已åˆä½µçš„åˆä½µè«‹æ±‚"
+
+msgid "Remind later"
+msgstr "ç¨å¾Œæ醒"
+
+msgid "Remove project"
+msgstr "刪除項目"
+
+msgid "Request Access"
+msgstr "申請權é™"
+
+msgid "Revert this commit"
+msgstr "還原此æ交"
+
+msgid "Revert this merge request"
+msgstr "還原此åˆä½µè«‹æ±‚"
+
+msgid "Save pipeline schedule"
+msgstr "ä¿å­˜æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Schedule a new pipeline"
+msgstr "新建æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Scheduling Pipelines"
+msgstr "æµæ°´ç·šè¨ˆåŠƒ"
+
+msgid "Search branches and tags"
+msgstr "æœç´¢åˆ†æ”¯å’Œæ¨™ç±¤"
+
+msgid "Select Archive Format"
+msgstr "é¸æ“‡ä¸‹è¼‰æ ¼å¼"
+
+msgid "Select a timezone"
+msgstr "é¸æ“‡æ™‚å€"
+
+msgid "Select target branch"
+msgstr "é¸æ“‡ç›®æ¨™åˆ†æ”¯"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "為賬號添加壹個用於推é€æˆ–拉å–çš„ %{protocol} 密碼。"
+
+msgid "Set up CI"
+msgstr "設置 CI"
+
+msgid "Set up Koding"
+msgstr "設置 Koding"
+
+msgid "Set up auto deploy"
+msgstr "設置自動部署"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "設置密碼"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid "Source code"
+msgstr "æºä»£ç¢¼"
+
+msgid "StarProject|Star"
+msgstr "星標"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "由此更改 %{new_merge_request}"
+
+msgid "Switch branch/tag"
+msgstr "切æ›åˆ†æ”¯/標籤"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "標籤"
+
+msgid "Tags"
+msgstr "標籤"
+
+msgid "Target Branch"
+msgstr "目標分支"
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr "編碼階段概述了從第壹次æ交到創建åˆä½µè«‹æ±‚的時間。創建第壹個åˆä½µè«‹æ±‚後,數據將自動添加到此處。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "與該階段相關的事件。"
+
+msgid "The fork relationship has been removed."
+msgstr "派生關係已被刪除。"
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr "議題階段概述了從創建議題到將議題添加到è£ç¨‹ç¢‘或議題看æ¿æ‰€èŠ±è²»çš„時間。創建第壹個議題後,數據將自動添加到此處.。"
+
+msgid "The phase of the development lifecycle."
+msgstr "項目生命週期中的å„個階段。"
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr "æµæ°´ç·šè¨ˆåŠƒæœƒé€±æœŸæ€§é‡è¤‡é‹è¡ŒæŒ‡å®šåˆ†æ”¯æˆ–標籤的æµæ°´ç·šã€‚這些æµæ°´ç·šå°‡æ ¹æ“šå…¶é—œè¯ç”¨æˆ¶ç¹¼æ‰¿æœ‰é™çš„項目訪å•æ¬Šé™ã€‚"
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr "計劃階段概述了從議題添加到日程到推é€é¦–次æ交的時間。當首次推é€æ交後,數據將自動添加到此處。"
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完æˆå®Œæ•´çš„想法到部署生產,數據將自動添加到此處。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr "該項目å…許已登錄的用戶訪å•ã€‚"
+
+msgid "The project can be accessed without any authentication."
+msgstr "該項目å…許任何人訪å•ã€‚"
+
+msgid "The repository for this project does not exist."
+msgstr "此項目的存儲庫ä¸å­˜åœ¨ã€‚"
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr "評審階段概述了從創建åˆä½µè«‹æ±‚到åˆä½µçš„時間。當創建第壹個åˆä½µè«‹æ±‚後,數據將自動添加到此處。"
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr "é ç™¼å¸ƒéšŽæ®µæ¦‚述了åˆä½µè«‹æ±‚çš„åˆä½µåˆ°éƒ¨ç½²ä»£ç¢¼åˆ°ç”Ÿç”¢ç’°å¢ƒçš„總時間。當首次部署到生產環境後,數據將自動添加到此處。"
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr "測試階段概述了 GitLab CI 為相關åˆä½µè«‹æ±‚é‹è¡Œæ¯å€‹æµæ°´ç·šæ‰€éœ€çš„時間。當第壹個æµæ°´ç·šé‹è¡Œå®Œæˆå¾Œï¼Œæ•¸æ“šå°‡è‡ªå‹•æ·»åŠ åˆ°æ­¤è™•ã€‚"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "該階段æ¯æ¢æ•¸æ“šæ‰€èŠ±çš„時間"
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr "中ä½æ•¸æ˜¯å£¹å€‹æ•¸åˆ—中最中間的值。例如在 3ã€5ã€9 之間,中ä½æ•¸æ˜¯ 5。在 3ã€5ã€7ã€8 之間,中ä½æ•¸æ˜¯ (5 + 7)/ 2 = 6。"
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr "在創建壹個空的存儲庫或導入ç¾æœ‰å­˜å„²åº«ä¹‹å‰ï¼Œæ‚¨å°‡ç„¡æ³•æŽ¨é€ä»£ç¢¼ã€‚"
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "開始進行編碼å‰çš„時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "從創建åˆä½µè«‹æ±‚到被åˆä½µæˆ–關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "創建第壹個åˆä½µè«‹æ±‚之å‰çš„時間"
+
+msgid "Timeago|%s days ago"
+msgstr " %s 天å‰"
+
+msgid "Timeago|%s days remaining"
+msgstr "剩餘 %s 天"
+
+msgid "Timeago|%s hours remaining"
+msgstr "剩餘 %s å°æ™‚"
+
+msgid "Timeago|%s minutes ago"
+msgstr " %s 分é˜å‰"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "剩餘 %s 分é˜"
+
+msgid "Timeago|%s months ago"
+msgstr " %s 個月å‰"
+
+msgid "Timeago|%s months remaining"
+msgstr "剩餘 %s 月"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "剩餘 %s 秒"
+
+msgid "Timeago|%s weeks ago"
+msgstr " %s 星期å‰"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "剩餘 %s 星期"
+
+msgid "Timeago|%s years ago"
+msgstr " %s å¹´å‰"
+
+msgid "Timeago|%s years remaining"
+msgstr "剩餘 %s 年"
+
+msgid "Timeago|1 day remaining"
+msgstr "剩餘 1 天"
+
+msgid "Timeago|1 hour remaining"
+msgstr "剩餘 1 å°æ™‚"
+
+msgid "Timeago|1 minute remaining"
+msgstr "剩餘 1 分é˜"
+
+msgid "Timeago|1 month remaining"
+msgstr "剩餘 1 個月"
+
+msgid "Timeago|1 week remaining"
+msgstr "剩餘 1 星期"
+
+msgid "Timeago|1 year remaining"
+msgstr "剩餘 1 年"
+
+msgid "Timeago|Past due"
+msgstr "逾期"
+
+msgid "Timeago|a day ago"
+msgstr " 1 天å‰"
+
+msgid "Timeago|a month ago"
+msgstr " 1 個月å‰"
+
+msgid "Timeago|a week ago"
+msgstr " 1 星期å‰"
+
+msgid "Timeago|a while"
+msgstr " 剛剛"
+
+msgid "Timeago|a year ago"
+msgstr " 1 å¹´å‰"
+
+msgid "Timeago|about %s hours ago"
+msgstr "ç´„ %s å°æ™‚å‰"
+
+msgid "Timeago|about a minute ago"
+msgstr "ç´„ 1 分é˜å‰"
+
+msgid "Timeago|about an hour ago"
+msgstr "ç´„ 1 å°æ™‚å‰"
+
+msgid "Timeago|in %s days"
+msgstr " %s 天後"
+
+msgid "Timeago|in %s hours"
+msgstr " %s å°æ™‚後"
+
+msgid "Timeago|in %s minutes"
+msgstr " %s 分é˜å¾Œ"
+
+msgid "Timeago|in %s months"
+msgstr " %s 個月後"
+
+msgid "Timeago|in %s seconds"
+msgstr " %s 秒後"
+
+msgid "Timeago|in %s weeks"
+msgstr " %s 星期後"
+
+msgid "Timeago|in %s years"
+msgstr " %s 年後"
+
+msgid "Timeago|in 1 day"
+msgstr " 1 天後"
+
+msgid "Timeago|in 1 hour"
+msgstr " 1 å°æ™‚後"
+
+msgid "Timeago|in 1 minute"
+msgstr " 1 分é˜å¾Œ"
+
+msgid "Timeago|in 1 month"
+msgstr " 1 月後"
+
+msgid "Timeago|in 1 week"
+msgstr " 1 星期後"
+
+msgid "Timeago|in 1 year"
+msgstr " 1 年後"
+
+msgid "Timeago|less than a minute ago"
+msgstr "ä¸åˆ° 1 分é˜å‰"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "å°æ™‚"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分é˜"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有æ交和åˆä½µçš„總測試時間"
+
+msgid "Unstar"
+msgstr "å–消星標"
+
+msgid "Upload New File"
+msgstr "上傳新文件"
+
+msgid "Upload file"
+msgstr "上傳文件"
+
+msgid "UploadLink|click to upload"
+msgstr "點擊上傳"
+
+msgid "Use your global notification setting"
+msgstr "使用全局通知設置"
+
+msgid "View open merge request"
+msgstr "查看開啟的åˆä¸¦è«‹æ±‚"
+
+msgid "VisibilityLevel|Internal"
+msgstr "內部"
+
+msgid "VisibilityLevel|Private"
+msgstr "ç§æœ‰"
+
+msgid "VisibilityLevel|Public"
+msgstr "公開"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "未知"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權é™ä¸è¶³ã€‚如需查看相關數據,請å‘管ç†å“¡ç”³è«‹æ¬Šé™ã€‚"
+
+msgid "We don't have enough data to show this stage."
+msgstr "該階段的數據ä¸è¶³ï¼Œç„¡æ³•é¡¯ç¤ºã€‚"
+
+msgid "Withdraw Access Request"
+msgstr "å–消權é™ç”³è¯·"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°‡åˆªé™¤ %{group_name}。已刪除的群組無法æ¢å¾©ï¼ç¢ºå®šç¹¼çºŒå—Žï¼Ÿ"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°‡è¦åˆªé™¤ %{project_name_with_namespace}。已刪除的項目無法æ¢è¤‡ï¼ç¢ºå®šç¹¼çºŒå—Žï¼Ÿ"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "å³å°‡åˆªé™¤èˆ‡æºé …ç›® %{forked_from_project} 的派生關系。確定繼續嗎?"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr "å³å°‡ %{project_name_with_namespace} 轉義給å¦å£¹å€‹æ‰€æœ‰è€…。確定繼續嗎?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "åªèƒ½åœ¨åˆ†æ”¯ä¸Šæ·»åŠ æ–‡ä»¶"
+
+msgid "You have reached your project limit"
+msgstr "您已é”到項目數é‡é™åˆ¶"
+
+msgid "You must sign in to star a project"
+msgstr "必須登錄æ‰èƒ½å°é …目加星標"
+
+msgid "You need permission."
+msgstr "需è¦ç›¸é—œçš„權é™ã€‚"
+
+msgid "You will not get any notifications via email"
+msgstr "ä¸æœƒæ”¶åˆ°ä»»ä½•é€šçŸ¥éƒµä»¶"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "åªæŽ¥æ”¶æ‚¨é¸æ“‡çš„事件通知"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "åªæŽ¥æ”¶æ‚¨åƒèˆ‡çš„主題的通知"
+
+msgid "You will receive notifications for any activity"
+msgstr "接收所有活動的通知"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "åªæŽ¥æ”¶è©•è«–中æåŠ(@)您的通知"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr "在賬號上 %{set_password_link} 之å‰å°‡ç„¡æ³•é€šéŽ %{protocol} 拉å–或推é€ä»£ç¢¼ã€‚"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr "在賬號中 %{add_ssh_key_link} 之å‰å°‡ç„¡æ³•é€šéŽ SSH 拉å–或推é€ä»£ç¢¼ã€‚"
+
+msgid "Your name"
+msgstr "您的åå­—"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "new merge request"
+msgstr "新建åˆä½µè«‹æ±‚"
+
+msgid "notification emails"
+msgstr "通知郵件"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "父級"
diff --git a/locale/zh_HK/gitlab.po.time_stamp b/locale/zh_HK/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_HK/gitlab.po.time_stamp
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
new file mode 100644
index 00000000000..205d4712316
--- /dev/null
+++ b/locale/zh_TW/gitlab.po
@@ -0,0 +1,1188 @@
+# Huang Tao <htve@outlook.com>, 2017. #zanata
+# Hazel Yang <anonymous@domain.com>, 2017.
+# TzeKei Lee <anonymous@domain.com>, 2017.
+# Jerry Ho <a29988122@gmail.com>, 2017.
+# Lin Jen-Shin <godfat@godfat.org>, 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-07-05 08:50-0500\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language-Team: Chinese (Taiwan) (https://translate.zanata.org/project/view/GitLab)\n"
+"PO-Revision-Date: 2017-08-07 03:30-0400\n"
+"Last-Translator: Huang Tao <htve@outlook.com>\n"
+"Language: zh-TW\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural ""
+"%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "因效能考é‡ï¼Œä¸é¡¯ç¤º %s 個更動 (commit)。"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d 個更動 (commit)"
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} 在 %{commit_timeago} é€äº¤"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 æ¢æµæ°´ç·š"
+msgstr[1] "%d æ¢æµæ°´ç·š"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "æŒçºŒæ•´åˆ (CI) 相關的圖表"
+
+msgid "About auto deploy"
+msgstr "關於自動部署"
+
+msgid "Active"
+msgstr "啟用"
+
+msgid "Activity"
+msgstr "活動"
+
+msgid "Add Changelog"
+msgstr "新增更新日誌"
+
+msgid "Add Contribution guide"
+msgstr "新增å”作指å—"
+
+msgid "Add License"
+msgstr "新增授權æ¢æ¬¾"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "請先新增 SSH 金鑰到您的個人帳號,æ‰èƒ½ä½¿ç”¨ SSH 來上傳 (push) 或下載 (pull) 。"
+
+msgid "Add new directory"
+msgstr "新增目錄"
+
+msgid "Archived project! Repository is read-only"
+msgstr "此專案已å°å­˜ï¼æª”案庫 (repository) 為唯讀狀態"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "確定è¦åˆªé™¤æ­¤æµæ°´ç·š (pipeline) 排程嗎?"
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "拖放檔案到此處或者 %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "分支 (branch) "
+
+msgid ""
+"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, "
+"choose a GitLab CI Yaml template and commit your changes. "
+"%{link_to_autodeploy_doc}"
+msgstr ""
+"已建立分支 (branch) <strong>%{branch_name}</strong> 。如è¦è¨­å®šè‡ªå‹•éƒ¨ç½²ï¼Œ è«‹é¸æ“‡åˆé©çš„ GitLab CI "
+"Yaml 模æ¿ï¼Œç„¶å¾Œè¨˜å¾—è¦é€äº¤ (commit) 您的編輯內容。%{link_to_autodeploy_doc}\n"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "æœå°‹åˆ†æ”¯ (branches)"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "切æ›åˆ†æ”¯ (branch)"
+
+msgid "Branches"
+msgstr "分支 (branch) "
+
+msgid "Browse Directory"
+msgstr "ç€è¦½ç›®éŒ„"
+
+msgid "Browse File"
+msgstr "ç€è¦½æª”案"
+
+msgid "Browse Files"
+msgstr "ç€è¦½æª”案"
+
+msgid "Browse files"
+msgstr "ç€è¦½æª”案"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "CI configuration"
+msgstr "CI 組態"
+
+msgid "Cancel"
+msgstr "å–消"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "挑é¸åˆ°åˆ†æ”¯ (branch) "
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "還原分支 (branch) "
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "挑é¸"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "還原"
+
+msgid "Changelog"
+msgstr "更新日誌"
+
+msgid "Charts"
+msgstr "統計圖"
+
+msgid "Cherry-pick this commit"
+msgstr "挑é¸æ­¤æ›´å‹•è¨˜éŒ„ (commit) "
+
+msgid "Cherry-pick this merge request"
+msgstr "挑é¸æ­¤åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "CiStatusLabel|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusLabel|created"
+msgstr "已建立"
+
+msgid "CiStatusLabel|failed"
+msgstr "失敗"
+
+msgid "CiStatusLabel|manual action"
+msgstr "手動æ“作"
+
+msgid "CiStatusLabel|passed"
+msgstr "已通éŽ"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "通éŽï¼Œä½†æœ‰è­¦å‘Šè¨Šæ¯"
+
+msgid "CiStatusLabel|pending"
+msgstr "等待中"
+
+msgid "CiStatusLabel|skipped"
+msgstr "已跳éŽ"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "等待手動æ“作"
+
+msgid "CiStatusText|blocked"
+msgstr "已阻擋"
+
+msgid "CiStatusText|canceled"
+msgstr "å·²å–消"
+
+msgid "CiStatusText|created"
+msgstr "已建立"
+
+msgid "CiStatusText|failed"
+msgstr "失敗"
+
+msgid "CiStatusText|manual"
+msgstr "手動æ“作"
+
+msgid "CiStatusText|passed"
+msgstr "已通éŽ"
+
+msgid "CiStatusText|pending"
+msgstr "等待中"
+
+msgid "CiStatusText|skipped"
+msgstr "已跳éŽ"
+
+msgid "CiStatus|running"
+msgstr "執行中"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "更動記錄 (commit) "
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "最近 30 次更動花費的時間(分é˜ï¼‰"
+
+msgid "Commit message"
+msgstr "更動說明 (commit) "
+
+msgid "CommitBoxTitle|Commit"
+msgstr "é€äº¤"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "建立 %{file_name}"
+
+msgid "Commits"
+msgstr "更動記錄 (commit) "
+
+msgid "Commits feed"
+msgstr "æ›´å‹•æ‘˜è¦ (commit feed)"
+
+msgid "Commits|History"
+msgstr "éŽåŽ»æ›´å‹• (commit) "
+
+msgid "Committed by"
+msgstr "é€äº¤è€…為 "
+
+msgid "Compare"
+msgstr "比較"
+
+msgid "Contribution guide"
+msgstr "å”作指å—"
+
+msgid "Contributors"
+msgstr "å”作者"
+
+msgid "Copy URL to clipboard"
+msgstr "複製網å€åˆ°å‰ªè²¼ç°¿"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "複製更動記錄 (commit) 的 SHA 值到剪貼簿"
+
+msgid "Create New Directory"
+msgstr "建立新目錄"
+
+msgid ""
+"Create a personal access token on your account to pull or push via "
+"%{protocol}."
+msgstr "建立個人存å–憑證 (access token) 以使用 %{protocol} 來上傳 (push) 或下載 (pull) 。"
+
+msgid "Create directory"
+msgstr "建立目錄"
+
+msgid "Create empty bare repository"
+msgstr "建立一個新的 bare repository"
+
+msgid "Create merge request"
+msgstr "發出åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "Create new..."
+msgstr "建立..."
+
+msgid "CreateNewFork|Fork"
+msgstr "分支 (fork) "
+
+msgid "CreateTag|Tag"
+msgstr "建立標籤"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "建立個人存å–憑證 (access token)"
+
+msgid "Cron Timezone"
+msgstr "Cron 時å€"
+
+msgid "Cron syntax"
+msgstr "Cron 語法"
+
+msgid "Custom notification events"
+msgstr "自訂事件通知"
+
+msgid ""
+"Custom notification levels are the same as participating levels. With custom "
+"notification levels you will also receive notifications for select events. "
+"To find out more, check out %{notification_link}."
+msgstr ""
+"自訂通知層級相當於åƒèˆ‡åº¦è¨­å®šã€‚使用自訂通知層級,您å¯ä»¥åªæ”¶åˆ°ç‰¹å®šçš„事件通知。請åƒç…§ %{notification_link} 以ç²å¾—更多訊æ¯ã€‚"
+
+msgid "Cycle Analytics"
+msgstr "週期分æž"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "週期分æžè®“您å¯ä»¥æœ‰æ•ˆçš„é‡æ¸…專案從發想到產å“推出所花的時間長短。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "程å¼é–‹ç™¼"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題 (issue) "
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "營é‹"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "複閱"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "試營é‹"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "使用 Cron 語法自訂排程"
+
+msgid "Delete"
+msgstr "刪除"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "Description"
+msgstr "æè¿°"
+
+msgid "Directory name"
+msgstr "目錄å稱"
+
+msgid "Don't show again"
+msgstr "ä¸å†é¡¯ç¤º"
+
+msgid "Download"
+msgstr "下載"
+
+msgid "Download tar"
+msgstr "下載 tar"
+
+msgid "Download tar.bz2"
+msgstr "下載 tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "下載 tar.gz"
+
+msgid "Download zip"
+msgstr "下載 zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "下載"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "é›»å­éƒµä»¶ä¿®è£œæª”案 (patch)"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "差異檔 (diff)"
+
+msgid "DownloadSource|Download"
+msgstr "下載原始碼"
+
+msgid "Edit"
+msgstr "編輯"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "編輯 %{id} æµæ°´ç·š (pipeline) 排程"
+
+msgid "Every day (at 4:00am)"
+msgstr "æ¯æ—¥åŸ·è¡Œï¼ˆæ·©æ™¨å››é»žï¼‰"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "æ¯æœˆåŸ·è¡Œï¼ˆæ¯æœˆä¸€æ—¥æ·©æ™¨å››é»žï¼‰"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "æ¯é€±åŸ·è¡Œï¼ˆé€±æ—¥æ·©æ™¨ 四點)"
+
+msgid "Failed to change the owner"
+msgstr "無法變更所有權"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "無法刪除æµæ°´ç·š (pipeline) 排程"
+
+msgid "Files"
+msgstr "檔案"
+
+msgid "Filter by commit message"
+msgstr "以更動說明篩é¸"
+
+msgid "Find by path"
+msgstr "以路徑æœå°‹"
+
+msgid "Find file"
+msgstr "æœå°‹æª”案"
+
+msgid "FirstPushedBy|First"
+msgstr "é¦–æ¬¡æŽ¨é€ (push) "
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推é€è€… (push) :"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "分支 (fork) "
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "分支 (fork) 自"
+
+msgid "From issue creation until deploy to production"
+msgstr "從議題 (issue) 建立直到部署至營é‹ç’°å¢ƒ"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從請求被åˆä½µå¾Œ (merge request merged) 直到部署至營é‹ç’°å¢ƒ"
+
+msgid "Go to your fork"
+msgstr "å‰å¾€æ‚¨çš„分支 (fork) "
+
+msgid "GoToYourFork|Fork"
+msgstr "å‰å¾€æ‚¨çš„分支 (fork) "
+
+msgid "Home"
+msgstr "首é "
+
+msgid "Housekeeping successfully started"
+msgstr "已開始維護"
+
+msgid "Import repository"
+msgstr "匯入檔案庫 (repository)"
+
+msgid "Interval Pattern"
+msgstr "循環週期"
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分æžç°¡ä»‹"
+
+msgid "Jobs for last month"
+msgstr "上個月的任務 (job) "
+
+msgid "Jobs for last week"
+msgstr "上個星期的任務 (job) "
+
+msgid "Jobs for last year"
+msgstr "去年的任務 (job) "
+
+msgid "LFSStatus|Disabled"
+msgstr "åœç”¨"
+
+msgid "LFSStatus|Enabled"
+msgstr "啟用"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最近 %d 天"
+
+msgid "Last Pipeline"
+msgstr "最新æµæ°´ç·š (pipeline) "
+
+msgid "Last Update"
+msgstr "最後更新"
+
+msgid "Last commit"
+msgstr "最後更動記錄 (commit) "
+
+msgid "Learn more in the"
+msgstr "了解更多"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "æµæ°´ç·š (pipeline) 排程說明文件"
+
+msgid "Leave group"
+msgstr "退出群組"
+
+msgid "Leave project"
+msgstr "退出專案"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "é™åˆ¶æœ€å¤šé¡¯ç¤º %d 個事件"
+
+msgid "Median"
+msgstr "中ä½æ•¸"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "新增 SSH 金鑰"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "建立議題 (issue) "
+
+msgid "New Pipeline Schedule"
+msgstr "建立æµæ°´ç·š (pipeline) 排程"
+
+msgid "New branch"
+msgstr "新分支 (branch) "
+
+msgid "New directory"
+msgstr "新增目錄"
+
+msgid "New file"
+msgstr "新增檔案"
+
+msgid "New issue"
+msgstr "新增議題 (issue) "
+
+msgid "New merge request"
+msgstr "新增åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "New schedule"
+msgstr "新增排程"
+
+msgid "New snippet"
+msgstr "新文字片段"
+
+msgid "New tag"
+msgstr "新增標籤"
+
+msgid "No repository"
+msgstr "找ä¸åˆ°æª”案庫 (repository)"
+
+msgid "No schedules"
+msgstr "沒有排程"
+
+msgid "Not available"
+msgstr "無法使用"
+
+msgid "Not enough data"
+msgstr "資料ä¸è¶³"
+
+msgid "Notification events"
+msgstr "事件通知"
+
+msgid "NotificationEvent|Close issue"
+msgstr "關閉議題 (issue) "
+
+msgid "NotificationEvent|Close merge request"
+msgstr "關閉åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "æµæ°´ç·š (pipeline) 失敗"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "åˆä½µè«‹æ±‚ (merge request) 被åˆä½µ"
+
+msgid "NotificationEvent|New issue"
+msgstr "新增議題 (issue) "
+
+msgid "NotificationEvent|New merge request"
+msgstr "新增åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "NotificationEvent|New note"
+msgstr "新增評論"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "é‡æ–°æŒ‡æ´¾è­°é¡Œ (issue) "
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "é‡æ–°æŒ‡æ´¾åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "é‡å•Ÿè­°é¡Œ (issue)"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "æµæ°´ç·š (pipeline) æˆåŠŸå®Œæˆ"
+
+msgid "NotificationLevel|Custom"
+msgstr "自訂"
+
+msgid "NotificationLevel|Disabled"
+msgstr "åœç”¨"
+
+msgid "NotificationLevel|Global"
+msgstr "全域"
+
+msgid "NotificationLevel|On mention"
+msgstr "æåŠ"
+
+msgid "NotificationLevel|Participate"
+msgstr "åƒèˆ‡"
+
+msgid "NotificationLevel|Watch"
+msgstr "關注"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "篩é¸"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Options"
+msgstr "é¸é …"
+
+msgid "Owner"
+msgstr "所有權"
+
+msgid "Pipeline"
+msgstr "æµæ°´ç·š (pipeline) "
+
+msgid "Pipeline Health"
+msgstr "æµæ°´ç·š (pipeline) å¥åº·æŒ‡æ•¸"
+
+msgid "Pipeline Schedule"
+msgstr "æµæ°´ç·š (pipeline) 排程"
+
+msgid "Pipeline Schedules"
+msgstr "æµæ°´ç·š (pipeline) 排程"
+
+msgid "PipelineCharts|Failed:"
+msgstr "失敗:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "總體統計"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "æˆåŠŸæ¯”率:"
+
+msgid "PipelineCharts|Successful:"
+msgstr "æˆåŠŸï¼š"
+
+msgid "PipelineCharts|Total:"
+msgstr "總計:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "是å¦å•Ÿç”¨"
+
+msgid "PipelineSchedules|Active"
+msgstr "已啟用"
+
+msgid "PipelineSchedules|All"
+msgstr "所有"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "未啟用"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "變數å稱"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "變數值"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "下次執行時間"
+
+msgid "PipelineSchedules|None"
+msgstr "ç„¡"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "請簡單說明此æµæ°´ç·š (pipeline) "
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "刪除變數"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "å–得所有權"
+
+msgid "PipelineSchedules|Target"
+msgstr "目標"
+
+msgid "PipelineSchedules|Variables"
+msgstr "變數"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "自訂"
+
+msgid "Pipelines"
+msgstr "æµæ°´ç·š (pipeline) "
+
+msgid "Pipelines charts"
+msgstr "æµæ°´ç·š (pipeline) 圖表"
+
+msgid "Pipeline|all"
+msgstr "所有"
+
+msgid "Pipeline|success"
+msgstr "æˆåŠŸ"
+
+msgid "Pipeline|with stage"
+msgstr "於階段"
+
+msgid "Pipeline|with stages"
+msgstr "於階段"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "專案 '%{project_name}' 已加入刪除佇列。"
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "專案 '%{project_name}' 建立完æˆã€‚"
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "專案 '%{project_name}' 更新完æˆã€‚"
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "專案 '%{project_name}' 將被刪除。"
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "專案權é™å¿…須一一指派給æ¯å€‹ä½¿ç”¨è€…。"
+
+msgid "Project export could not be deleted."
+msgstr "匯出的專案無法被刪除。"
+
+msgid "Project export has been deleted."
+msgstr "匯出的專案已被刪除。"
+
+msgid ""
+"Project export link has expired. Please generate a new export from your "
+"project settings."
+msgstr "專案的匯出連çµå·²å¤±æ•ˆã€‚請到專案設定中產生新的連çµã€‚"
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "專案導出已開始。完æˆå¾Œä¸‹è¼‰é€£çµæœƒé€åˆ°æ‚¨çš„信箱。"
+
+msgid "Project home"
+msgstr "專案首é "
+
+msgid "ProjectFeature|Disabled"
+msgstr "åœç”¨"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "任何人都å¯å­˜å–"
+
+msgid "ProjectFeature|Only team members"
+msgstr "åªæœ‰åœ˜éšŠæˆå“¡å¯ä»¥å­˜å–"
+
+msgid "ProjectFileTree|Name"
+msgstr "å稱"
+
+msgid "ProjectLastActivity|Never"
+msgstr "從未"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "階段"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "分支圖"
+
+msgid "Read more"
+msgstr "瞭解更多"
+
+msgid "Readme"
+msgstr "說明檔"
+
+msgid "RefSwitcher|Branches"
+msgstr "分支 (branch) "
+
+msgid "RefSwitcher|Tags"
+msgstr "標籤"
+
+msgid "Related Commits"
+msgstr "相關的更動記錄 (commit) "
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題 (issue) "
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "Related Merged Requests"
+msgstr "相關已åˆä½µçš„請求"
+
+msgid "Remind later"
+msgstr "ç¨å¾Œæ醒"
+
+msgid "Remove project"
+msgstr "刪除專案"
+
+msgid "Request Access"
+msgstr "申請權é™"
+
+msgid "Revert this commit"
+msgstr "還原此更動記錄 (commit)"
+
+msgid "Revert this merge request"
+msgstr "還原此åˆä½µè«‹æ±‚ (merge request) "
+
+msgid "Save pipeline schedule"
+msgstr "儲存æµæ°´ç·š (pipeline) 排程"
+
+msgid "Schedule a new pipeline"
+msgstr "建立æµæ°´ç·š (pipeline) 排程"
+
+msgid "Scheduling Pipelines"
+msgstr "æµæ°´ç·š (pipeline) 排程"
+
+msgid "Search branches and tags"
+msgstr "æœå°‹åˆ†æ”¯ (branch) 和標籤"
+
+msgid "Select Archive Format"
+msgstr "é¸æ“‡ä¸‹è¼‰æ ¼å¼"
+
+msgid "Select a timezone"
+msgstr "é¸æ“‡æ™‚å€"
+
+msgid "Select target branch"
+msgstr "é¸æ“‡ç›®æ¨™åˆ†æ”¯ (branch) "
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "請先設定密碼,æ‰èƒ½ä½¿ç”¨ %{protocol} 來上傳 (push) 或下載 (pull) 。"
+
+msgid "Set up CI"
+msgstr "設定 CI"
+
+msgid "Set up Koding"
+msgstr "設定 Koding"
+
+msgid "Set up auto deploy"
+msgstr "設定自動部署"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "設定密碼"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid "Source code"
+msgstr "原始碼"
+
+msgid "StarProject|Star"
+msgstr "收è—"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "以這些改動建立一個新的 %{new_merge_request} "
+
+msgid "Switch branch/tag"
+msgstr "切æ›åˆ†æ”¯ (branch) 或標籤"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "標籤"
+
+msgid "Tags"
+msgstr "標籤"
+
+msgid "Target Branch"
+msgstr "目標分支 (branch) "
+
+msgid ""
+"The coding stage shows the time from the first commit to creating the merge "
+"request. The data will automatically be added here once you create your "
+"first merge request."
+msgstr ""
+"程å¼é–‹ç™¼éšŽæ®µé¡¯ç¤ºå¾žç¬¬ä¸€æ¬¡æ›´å‹•è¨˜éŒ„ (commit) 到建立åˆä½µè«‹æ±‚ (merge request) 的時間。建立第一個åˆä½µè«‹æ±‚後,資料將自動填入。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "該階段中的相關事件集åˆã€‚"
+
+msgid "The fork relationship has been removed."
+msgstr "åˆ†æ”¯èˆ‡ä¸»å¹¹é–“çš„é—œè¯ (fork relationship) 已被刪除。"
+
+msgid ""
+"The issue stage shows the time it takes from creating an issue to assigning "
+"the issue to a milestone, or add the issue to a list on your Issue Board. "
+"Begin creating issues to see data for this stage."
+msgstr ""
+"è­°é¡Œ (issue) éšŽæ®µé¡¯ç¤ºå¾žè­°é¡Œå»ºç«‹åˆ°è¨­å®šé‡Œç¨‹ç¢‘æ‰€èŠ±çš„æ™‚é–“ï¼Œæˆ–æ˜¯è­°é¡Œè¢«åˆ†é¡žåˆ°è­°é¡Œçœ‹æ¿ (issue board) "
+"中所花的時間。建立第一個議題後,資料將自動填入。"
+
+msgid "The phase of the development lifecycle."
+msgstr "專案開發週期的å„個階段。"
+
+msgid ""
+"The pipelines schedule runs pipelines in the future, repeatedly, for "
+"specific branches or tags. Those scheduled pipelines will inherit limited "
+"project access based on their associated user."
+msgstr ""
+"在指定了特定分支 (branch) 或標籤後,此處的æµæ°´ç·š (pipeline) 排程會ä¸æ–·åœ°é‡è¤‡åŸ·è¡Œã€‚æµæ°´ç·šæŽ’程的存å–權é™èˆ‡å°ˆæ¡ˆæœ¬èº«ç›¸åŒã€‚"
+
+msgid ""
+"The planning stage shows the time from the previous step to pushing your "
+"first commit. This time will be added automatically once you push your first "
+"commit."
+msgstr "計劃階段顯示從更動記錄 (commit) 被排程至第一個推é€çš„時間。第一次推é€ä¹‹å¾Œï¼Œè³‡æ–™å°‡è‡ªå‹•å¡«å…¥ã€‚"
+
+msgid ""
+"The production stage shows the total time it takes between creating an issue "
+"and deploying the code to production. The data will be automatically added "
+"once you have completed the full idea to production cycle."
+msgstr "營é‹éšŽæ®µé¡¯ç¤ºå¾žå»ºç«‹è­°é¡Œ (issue) 到部署程å¼ä¸Šç·šæ‰€èŠ±çš„時間。完æˆå¾žç™¼æƒ³åˆ°ä¸Šç·šçš„完整開發週期後,資料將自動填入。"
+
+msgid "The project can be accessed by any logged in user."
+msgstr "本專案å¯è®“任何已登入的使用者存å–"
+
+msgid "The project can be accessed without any authentication."
+msgstr "本專案å¯è®“任何人存å–"
+
+msgid "The repository for this project does not exist."
+msgstr "本專案沒有檔案庫 (repository) "
+
+msgid ""
+"The review stage shows the time from creating the merge request to merging "
+"it. The data will automatically be added after you merge your first merge "
+"request."
+msgstr ""
+"複閱階段顯示從åˆä½µè«‹æ±‚ (merge request) 建立後至被åˆä½µçš„時間。當建立第一個åˆä½µè«‹æ±‚ (merge request) 後,資料將自動填入。"
+
+msgid ""
+"The staging stage shows the time between merging the MR and deploying code "
+"to the production environment. The data will be automatically added once you "
+"deploy to production for the first time."
+msgstr "試營é‹æ®µé¡¯ç¤ºå¾žåˆä½µè«‹æ±‚ (merge request) 被åˆä½µå¾Œè‡³éƒ¨ç½²ç‡Ÿé‹çš„時間。當第一次部署營é‹å¾Œï¼Œè³‡æ–™å°‡è‡ªå‹•å¡«å…¥"
+
+msgid ""
+"The testing stage shows the time GitLab CI takes to run every pipeline for "
+"the related merge request. The data will automatically be added after your "
+"first pipeline finishes running."
+msgstr ""
+"測試階段顯示相關åˆä½µè«‹æ±‚ (merge request) çš„æµæ°´ç·š (pipeline) 所花的時間。當第一個æµæ°´ç·š (pipeline) "
+"執行完畢後,資料將自動填入。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "該階段中æ¯ä¸€å€‹è³‡æ–™é …目所花的時間。"
+
+msgid ""
+"The value lying at the midpoint of a series of observed values. E.g., "
+"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 ="
+" 6."
+msgstr "中ä½æ•¸æ˜¯ä¸€å€‹æ•¸åˆ—中最中間的值。例如在 3ã€5ã€9 之間,中ä½æ•¸æ˜¯ 5。在 3ã€5ã€7ã€8 之間,中ä½æ•¸æ˜¯ (5 + 7)/ 2 = 6。"
+
+msgid ""
+"This means you can not push code until you create an empty repository or "
+"import existing one."
+msgstr "這代表在您建立一個空的檔案庫 (repository) 或是匯入一個ç¾å­˜çš„檔案庫之å‰ï¼Œæ‚¨å°‡ç„¡æ³•ä¸Šå‚³æ›´æ–° (push) 。"
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題 (issue) 被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "議題 (issue) 等待開始實作的時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "åˆä½µè«‹æ±‚ (merge request) 從建立到被åˆä½µæˆ–是關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "第一個åˆä½µè«‹æ±‚ (merge request) 被建立å‰çš„時間"
+
+msgid "Timeago|%s days ago"
+msgstr " %s 天å‰"
+
+msgid "Timeago|%s days remaining"
+msgstr "剩下 %s 天"
+
+msgid "Timeago|%s hours remaining"
+msgstr "剩下 %s å°æ™‚"
+
+msgid "Timeago|%s minutes ago"
+msgstr " %s 分é˜å‰"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "剩下 %s 分é˜"
+
+msgid "Timeago|%s months ago"
+msgstr " %s 個月å‰"
+
+msgid "Timeago|%s months remaining"
+msgstr "剩下 %s 月"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "剩下 %s 秒"
+
+msgid "Timeago|%s weeks ago"
+msgstr " %s 週å‰"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "剩下 %s 週"
+
+msgid "Timeago|%s years ago"
+msgstr " %s å¹´å‰"
+
+msgid "Timeago|%s years remaining"
+msgstr "剩下 %s 年"
+
+msgid "Timeago|1 day remaining"
+msgstr "剩下 1 天"
+
+msgid "Timeago|1 hour remaining"
+msgstr "剩下 1 å°æ™‚"
+
+msgid "Timeago|1 minute remaining"
+msgstr "剩下 1 分é˜"
+
+msgid "Timeago|1 month remaining"
+msgstr "剩下 1 個月"
+
+msgid "Timeago|1 week remaining"
+msgstr "剩下 1 週"
+
+msgid "Timeago|1 year remaining"
+msgstr "剩下 1 年"
+
+msgid "Timeago|Past due"
+msgstr "逾期"
+
+msgid "Timeago|a day ago"
+msgstr " 1 天å‰"
+
+msgid "Timeago|a month ago"
+msgstr " 1 個月å‰"
+
+msgid "Timeago|a week ago"
+msgstr " 1 週å‰"
+
+msgid "Timeago|a while"
+msgstr "剛剛"
+
+msgid "Timeago|a year ago"
+msgstr " 1 å¹´å‰"
+
+msgid "Timeago|about %s hours ago"
+msgstr "ç´„ %s å°æ™‚å‰"
+
+msgid "Timeago|about a minute ago"
+msgstr "ç´„ 1 分é˜å‰"
+
+msgid "Timeago|about an hour ago"
+msgstr "ç´„ 1 å°æ™‚å‰"
+
+msgid "Timeago|in %s days"
+msgstr " %s 天後"
+
+msgid "Timeago|in %s hours"
+msgstr " %s å°æ™‚後"
+
+msgid "Timeago|in %s minutes"
+msgstr " %s 分é˜å¾Œ"
+
+msgid "Timeago|in %s months"
+msgstr " %s 個月後"
+
+msgid "Timeago|in %s seconds"
+msgstr " %s 秒後"
+
+msgid "Timeago|in %s weeks"
+msgstr " %s 週後"
+
+msgid "Timeago|in %s years"
+msgstr " %s 年後"
+
+msgid "Timeago|in 1 day"
+msgstr " 1 天後"
+
+msgid "Timeago|in 1 hour"
+msgstr " 1 å°æ™‚後"
+
+msgid "Timeago|in 1 minute"
+msgstr " 1 分é˜å¾Œ"
+
+msgid "Timeago|in 1 month"
+msgstr " 1 個月後"
+
+msgid "Timeago|in 1 week"
+msgstr " 1 週後"
+
+msgid "Timeago|in 1 year"
+msgstr " 1 年後"
+
+msgid "Timeago|less than a minute ago"
+msgstr "ä¸åˆ° 1 分é˜å‰"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "å°æ™‚"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分é˜"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "åˆä½µ (merge) 與更動記錄 (commit) 的總測試時間"
+
+msgid "Unstar"
+msgstr "å–消收è—"
+
+msgid "Upload New File"
+msgstr "上傳新檔案"
+
+msgid "Upload file"
+msgstr "上傳檔案"
+
+msgid "UploadLink|click to upload"
+msgstr "點擊上傳"
+
+msgid "Use your global notification setting"
+msgstr "使用全域通知設定"
+
+msgid "View open merge request"
+msgstr "查看此分支的åˆä½µè«‹æ±‚ (merge request)"
+
+msgid "VisibilityLevel|Internal"
+msgstr "內部"
+
+msgid "VisibilityLevel|Private"
+msgstr "ç§æœ‰"
+
+msgid "VisibilityLevel|Public"
+msgstr "公開"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "ä¸æ˜Ž"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權é™ä¸è¶³ã€‚如需查看相關資料,請å‘管ç†å“¡ç”³è«‹æ¬Šé™ã€‚"
+
+msgid "We don't have enough data to show this stage."
+msgstr "因該階段的資料ä¸è¶³è€Œç„¡æ³•é¡¯ç¤ºç›¸é—œè³‡è¨Š"
+
+msgid "Withdraw Access Request"
+msgstr "å–消權é™ç”³è«‹"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°‡è¦åˆªé™¤ %{group_name}。被刪除的群組完全無法救回來喔ï¼çœŸçš„「100%確定ã€è¦é€™éº¼åšå—Žï¼Ÿ"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å³å°‡è¦åˆªé™¤ %{project_name_with_namespace}。被刪除的專案完全無法救回來喔ï¼çœŸçš„「100%確定ã€è¦é€™éº¼åšå—Žï¼Ÿ"
+
+msgid ""
+"You are going to remove the fork relationship to source project "
+"%{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr ""
+"å°‡è¦åˆªé™¤æœ¬åˆ†æ”¯å°ˆæ¡ˆèˆ‡ä¸»å¹¹çš„æ‰€æœ‰é—œè¯ (fork relationship) 。 %{forked_from_project} "
+"真的「100%確定ã€è¦é€™éº¼åšå—Žï¼Ÿ"
+
+msgid ""
+"You are going to transfer %{project_name_with_namespace} to another owner. "
+"Are you ABSOLUTELY sure?"
+msgstr "å°‡è¦æŠŠ %{project_name_with_namespace} 的所有權轉移給å¦ä¸€å€‹äººã€‚真的「100%確定ã€è¦é€™éº¼åšå—Žï¼Ÿ"
+
+msgid "You can only add files when you are on a branch"
+msgstr "åªèƒ½åœ¨åˆ†æ”¯ (branch) 上建立檔案"
+
+msgid "You have reached your project limit"
+msgstr "您已é”到專案數é‡é™åˆ¶"
+
+msgid "You must sign in to star a project"
+msgstr "必須登入æ‰èƒ½æ”¶è—專案"
+
+msgid "You need permission."
+msgstr "需è¦æ¬Šé™æ‰èƒ½é€™éº¼åšã€‚"
+
+msgid "You will not get any notifications via email"
+msgstr "ä¸æœƒæ”¶åˆ°ä»»ä½•é€šçŸ¥éƒµä»¶"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "åªæŽ¥æ”¶æ‚¨é¸æ“‡çš„事件通知"
+
+msgid ""
+"You will only receive notifications for threads you have participated in"
+msgstr "åªæŽ¥æ”¶åƒèˆ‡ä¸»é¡Œçš„通知"
+
+msgid "You will receive notifications for any activity"
+msgstr "接收所有活動的通知"
+
+msgid ""
+"You will receive notifications only for comments in which you were "
+"@mentioned"
+msgstr "åªæŽ¥æ”¶è©•è«–中æåŠ(@)您的通知"
+
+msgid ""
+"You won't be able to pull or push project code via %{protocol} until you "
+"%{set_password_link} on your account"
+msgstr ""
+"在帳號上 %{set_password_link} 之å‰ï¼Œ 將無法使用 %{protocol} 上傳 (push) 或下載 (pull) 程å¼ç¢¼ã€‚"
+
+msgid ""
+"You won't be able to pull or push project code via SSH until you "
+"%{add_ssh_key_link} to your profile"
+msgstr "在個人帳號中 %{add_ssh_key_link} 之å‰ï¼Œ 將無法使用 SSH 上傳 (push) 或下載 (pull) 程å¼ç¢¼ã€‚"
+
+msgid "Your name"
+msgstr "您的åå­—"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
+
+msgid "new merge request"
+msgstr "建立åˆä½µè«‹æ±‚"
+
+msgid "notification emails"
+msgstr "通知信"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "上層"
diff --git a/locale/zh_TW/gitlab.po.time_stamp b/locale/zh_TW/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_TW/gitlab.po.time_stamp
diff --git a/package.json b/package.json
index f8c151ebd81..cbb9be3a27f 100644
--- a/package.json
+++ b/package.json
@@ -12,68 +12,81 @@
"webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js"
},
"dependencies": {
+ "axios": "^0.16.2",
"babel-core": "^6.22.1",
- "babel-loader": "^6.2.10",
+ "babel-eslint": "^7.2.1",
+ "babel-loader": "^7.1.1",
"babel-plugin-transform-define": "^1.2.0",
"babel-preset-latest": "^6.24.0",
"babel-preset-stage-2": "^6.22.0",
"bootstrap-sass": "^3.3.6",
"compression-webpack-plugin": "^0.3.2",
+ "copy-webpack-plugin": "^4.0.1",
"core-js": "^2.4.1",
+ "cropper": "^2.3.0",
"css-loader": "^0.28.0",
"d3": "^3.5.11",
+ "deckar01-task_list": "^2.0.0",
"document-register-element": "^1.3.0",
"dropzone": "^4.2.0",
"emoji-unicode-version": "^0.2.1",
"eslint-plugin-html": "^2.0.1",
+ "exports-loader": "^0.6.4",
"file-loader": "^0.11.1",
+ "imports-loader": "^0.7.1",
+ "jed": "^1.1.1",
"jquery": "^2.2.1",
"jquery-ujs": "^1.2.1",
"js-cookie": "^2.1.3",
"jszip": "^3.1.3",
"jszip-utils": "^0.0.2",
"marked": "^0.3.6",
+ "monaco-editor": "0.8.3",
"mousetrap": "^1.4.6",
+ "name-all-modules-plugin": "^1.0.1",
"pikaday": "^1.5.1",
"prismjs": "^1.6.0",
"raphael": "^2.2.7",
+ "raven-js": "^3.14.0",
"raw-loader": "^0.5.1",
"react-dev-utils": "^0.5.2",
"select2": "3.5.2-browserify",
- "stats-webpack-plugin": "^0.4.3",
+ "sql.js": "^0.4.0",
"three": "^0.84.0",
"three-orbit-controls": "^82.1.0",
"three-stl-loader": "^1.0.4",
"timeago.js": "^2.0.5",
"underscore": "^1.8.3",
+ "url-loader": "^0.5.8",
"visibilityjs": "^1.2.4",
"vue": "^2.2.6",
"vue-loader": "^11.3.4",
- "vue-resource": "^0.9.3",
+ "vue-resource": "^1.3.4",
"vue-template-compiler": "^2.2.6",
- "webpack": "^2.3.3",
- "webpack-bundle-analyzer": "^2.3.0"
+ "webpack": "^3.5.4",
+ "webpack-bundle-analyzer": "^2.8.2",
+ "webpack-stats-plugin": "^0.1.5"
},
"devDependencies": {
"babel-plugin-istanbul": "^4.0.0",
"eslint": "^3.10.1",
"eslint-config-airbnb-base": "^10.0.1",
- "eslint-import-resolver-webpack": "^0.8.1",
+ "eslint-import-resolver-webpack": "^0.8.3",
"eslint-plugin-filenames": "^1.1.0",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jasmine": "^2.1.0",
"eslint-plugin-promise": "^3.5.0",
"istanbul": "^0.4.5",
- "jasmine-core": "^2.5.2",
+ "jasmine-core": "^2.6.3",
"jasmine-jquery": "^2.1.1",
- "karma": "^1.4.1",
+ "karma": "^1.7.0",
+ "karma-chrome-launcher": "^2.1.1",
"karma-coverage-istanbul-reporter": "^0.2.0",
"karma-jasmine": "^1.1.0",
"karma-mocha-reporter": "^2.2.2",
- "karma-phantomjs-launcher": "^1.0.2",
"karma-sourcemap-loader": "^0.3.7",
- "karma-webpack": "^2.0.2",
+ "karma-webpack": "^2.0.4",
"nodemon": "^1.11.0",
- "webpack-dev-server": "^2.4.2"
+ "webpack-dev-server": "^2.6.1"
}
}
diff --git a/public/ci/favicon.ico b/public/ci/favicon.ico
deleted file mode 100644
index 9663d4d00b9..00000000000
--- a/public/ci/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/qa/Dockerfile b/qa/Dockerfile
index 72c82503542..f3a81a7e355 100644
--- a/qa/Dockerfile
+++ b/qa/Dockerfile
@@ -1,17 +1,29 @@
FROM ruby:2.3
LABEL maintainer "Grzegorz Bizon <grzegorz@gitlab.com>"
+ENV DEBIAN_FRONTEND noninteractive
-RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list && \
- apt-get update && apt-get install -y --force-yes \
- libqt5webkit5-dev qt5-qmake qt5-default build-essential xvfb git && \
- apt-get clean
+##
+# Update APT sources and install some dependencies
+#
+RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list
+RUN apt-get update && apt-get install -y wget git unzip xvfb
-WORKDIR /home/qa
+##
+# Install Google Chrome version with headless support
+#
+RUN curl -sS -L https://dl.google.com/linux/linux_signing_key.pub | apt-key add -
+RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
+RUN apt-get update -q && apt-get install -y google-chrome-stable && apt-get clean
-COPY ./Gemfile* ./
+##
+# Install chromedriver to make it work with Selenium
+#
+RUN wget -q https://chromedriver.storage.googleapis.com/$(wget -q -O - https://chromedriver.storage.googleapis.com/LATEST_RELEASE)/chromedriver_linux64.zip
+RUN unzip chromedriver_linux64.zip -d /usr/local/bin
+WORKDIR /home/qa
+COPY ./Gemfile* ./
RUN bundle install
-
COPY ./ ./
ENTRYPOINT ["bin/test"]
diff --git a/qa/Gemfile b/qa/Gemfile
index 6bfe25ba437..5d089a45934 100644
--- a/qa/Gemfile
+++ b/qa/Gemfile
@@ -2,6 +2,6 @@ source 'https://rubygems.org'
gem 'capybara', '~> 2.12.1'
gem 'capybara-screenshot', '~> 1.0.14'
-gem 'capybara-webkit', '~> 1.12.0'
gem 'rake', '~> 12.0.0'
gem 'rspec', '~> 3.5'
+gem 'selenium-webdriver', '~> 2.53'
diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock
index 6de2abff198..4dd71aa5010 100644
--- a/qa/Gemfile.lock
+++ b/qa/Gemfile.lock
@@ -16,7 +16,10 @@ GEM
capybara-webkit (1.12.0)
capybara (>= 2.3.0, < 2.13.0)
json
+ childprocess (0.7.0)
+ ffi (~> 1.0, >= 1.0.11)
diff-lcs (1.3)
+ ffi (1.9.18)
json (2.0.3)
launchy (2.4.3)
addressable (~> 2.3)
@@ -44,6 +47,12 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.5.0)
rspec-support (3.5.0)
+ rubyzip (1.2.1)
+ selenium-webdriver (2.53.4)
+ childprocess (~> 0.5)
+ rubyzip (~> 1.0)
+ websocket (~> 1.0)
+ websocket (1.2.4)
xpath (2.0.0)
nokogiri (~> 1.3)
@@ -56,6 +65,7 @@ DEPENDENCIES
capybara-webkit (~> 1.12.0)
rake (~> 12.0.0)
rspec (~> 3.5)
+ selenium-webdriver (~> 2.53)
BUNDLED WITH
1.14.6
diff --git a/qa/qa.rb b/qa/qa.rb
index 58cf615cc9f..db9d8c42fde 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -48,8 +48,14 @@ module QA
module Main
autoload :Entry, 'qa/page/main/entry'
autoload :Menu, 'qa/page/main/menu'
- autoload :Groups, 'qa/page/main/groups'
- autoload :Projects, 'qa/page/main/projects'
+ end
+
+ module Dashboard
+ autoload :Groups, 'qa/page/dashboard/groups'
+ end
+
+ module Group
+ autoload :Show, 'qa/page/group/show'
end
module Project
diff --git a/qa/qa/page/dashboard/groups.rb b/qa/qa/page/dashboard/groups.rb
new file mode 100644
index 00000000000..3690f40dcfe
--- /dev/null
+++ b/qa/qa/page/dashboard/groups.rb
@@ -0,0 +1,22 @@
+module QA
+ module Page
+ module Dashboard
+ class Groups < Page::Base
+ def prepare_test_namespace
+ if page.has_content?(Runtime::Namespace.name)
+ return click_link(Runtime::Namespace.name)
+ end
+
+ click_on 'New group'
+
+ fill_in 'group_path', with: Runtime::Namespace.name
+ fill_in 'group_description',
+ with: "QA test run at #{Runtime::Namespace.time}"
+ choose 'Private'
+
+ click_button 'Create group'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb
new file mode 100644
index 00000000000..296c311d7c6
--- /dev/null
+++ b/qa/qa/page/group/show.rb
@@ -0,0 +1,11 @@
+module QA
+ module Page
+ module Group
+ class Show < Page::Base
+ def go_to_new_project
+ click_link 'New Project'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/main/groups.rb b/qa/qa/page/main/groups.rb
deleted file mode 100644
index 169c5ebc967..00000000000
--- a/qa/qa/page/main/groups.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-module QA
- module Page
- module Main
- class Groups < Page::Base
- def prepare_test_namespace
- return if page.has_content?(Runtime::Namespace.name)
-
- click_on 'New group'
-
- fill_in 'group_path', with: Runtime::Namespace.name
- fill_in 'group_description',
- with: "QA test run at #{Runtime::Namespace.time}"
- choose 'Private'
-
- click_button 'Create group'
- end
- end
- end
- end
-end
diff --git a/qa/qa/page/main/projects.rb b/qa/qa/page/main/projects.rb
deleted file mode 100644
index 28d3a424022..00000000000
--- a/qa/qa/page/main/projects.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-module QA
- module Page
- module Main
- class Projects < Page::Base
- def go_to_new_project
- ##
- # There are 'New Project' and 'New project' buttons on the projects
- # page, so we can't use `click_on`.
- #
- button = find('a', text: /^new project$/i)
- button.click
- end
- end
- end
- end
-end
diff --git a/qa/qa/runtime/release.rb b/qa/qa/runtime/release.rb
index 4f83a773645..12e56404cf6 100644
--- a/qa/qa/runtime/release.rb
+++ b/qa/qa/runtime/release.rb
@@ -21,7 +21,7 @@ module QA
end
def self.method_missing(name, *args)
- self.new.strategy.public_send(name, *args)
+ self.new.strategy.public_send(name, *args) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/qa/qa/scenario/gitlab/project/create.rb b/qa/qa/scenario/gitlab/project/create.rb
index 38522714e64..b860701c304 100644
--- a/qa/qa/scenario/gitlab/project/create.rb
+++ b/qa/qa/scenario/gitlab/project/create.rb
@@ -13,9 +13,8 @@ module QA
def perform
Page::Main::Menu.act { go_to_groups }
- Page::Main::Groups.act { prepare_test_namespace }
- Page::Main::Menu.act { go_to_projects }
- Page::Main::Projects.act { go_to_new_project }
+ Page::Dashboard::Groups.act { prepare_test_namespace }
+ Page::Group::Show.act { go_to_new_project }
Page::Project::New.perform do |page|
page.choose_test_namespace
diff --git a/qa/qa/specs/config.rb b/qa/qa/specs/config.rb
index d72187fcd34..4dfdd6cd93c 100644
--- a/qa/qa/specs/config.rb
+++ b/qa/qa/specs/config.rb
@@ -1,7 +1,7 @@
require 'rspec/core'
require 'capybara/rspec'
-require 'capybara-webkit'
require 'capybara-screenshot/rspec'
+require 'selenium-webdriver'
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/LineLength
@@ -20,59 +20,52 @@ module QA
configure_rspec!
configure_capybara!
- configure_webkit!
end
def configure_rspec!
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
- # This option will default to `true` in RSpec 4. It makes the `description`
- # and `failure_message` of custom matchers include text for helper methods
- # defined using `chain`.
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
- # Prevents you from mocking or stubbing a method that does not exist on
- # a real object. This is generally recommended, and will default to
- # `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
- # Run specs in random order to surface order dependencies.
config.order = :random
Kernel.srand config.seed
-
- config.before(:all) do
- page.current_window.resize_to(1200, 1800)
- end
-
config.formatter = :documentation
config.color = true
end
end
def configure_capybara!
+ Capybara.register_driver :chrome do |app|
+ capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
+ 'chromeOptions' => {
+ 'binary' => '/usr/bin/google-chrome-stable',
+ 'args' => %w[headless no-sandbox disable-gpu window-size=1280,1024]
+ }
+ )
+
+ Capybara::Selenium::Driver
+ .new(app, browser: :chrome, desired_capabilities: capabilities)
+ end
+
+ Capybara::Screenshot.register_driver(:chrome) do |driver, path|
+ driver.browser.save_screenshot(path)
+ end
+
Capybara.configure do |config|
config.app_host = @address
- config.default_driver = :webkit
- config.javascript_driver = :webkit
+ config.default_driver = :chrome
+ config.javascript_driver = :chrome
config.default_max_wait_time = 4
# https://github.com/mattheworiordan/capybara-screenshot/issues/164
config.save_path = 'tmp'
end
end
-
- def configure_webkit!
- Capybara::Webkit.configure do |config|
- config.allow_url(@address)
- config.block_unknown_urls
- end
- rescue RuntimeError # rubocop:disable Lint/HandleExceptions
- # TODO, Webkit is already configured, this make this
- # configuration step idempotent, should be improved.
- end
end
end
end
diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb
index c07a3234673..64d06ef6558 100644
--- a/qa/spec/spec_helper.rb
+++ b/qa/spec/spec_helper.rb
@@ -12,7 +12,6 @@ RSpec.configure do |config|
config.shared_context_metadata_behavior = :apply_to_host_groups
config.disable_monkey_patching!
config.expose_dsl_globally = true
- config.warnings = true
config.profile_examples = 10
config.order = :random
Kernel.srand config.seed
diff --git a/rubocop/cop/active_record_dependent.rb b/rubocop/cop/active_record_dependent.rb
new file mode 100644
index 00000000000..8d15f150885
--- /dev/null
+++ b/rubocop/cop/active_record_dependent.rb
@@ -0,0 +1,26 @@
+require_relative '../model_helpers'
+
+module RuboCop
+ module Cop
+ # Cop that prevents the use of `dependent: ...` in ActiveRecord models.
+ class ActiveRecordDependent < RuboCop::Cop::Cop
+ include ModelHelpers
+
+ MSG = 'Do not use `dependent: to remove associated data, ' \
+ 'use foreign keys with cascading deletes instead'.freeze
+
+ METHOD_NAMES = [:has_many, :has_one, :belongs_to].freeze
+
+ def on_send(node)
+ return unless in_model?(node)
+ return unless METHOD_NAMES.include?(node.children[1])
+
+ node.children.last.each_node(:pair) do |pair|
+ key_name = pair.children[0].children[0]
+
+ add_offense(pair, :expression) if key_name == :dependent
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/active_record_serialize.rb b/rubocop/cop/active_record_serialize.rb
new file mode 100644
index 00000000000..204caf37f8b
--- /dev/null
+++ b/rubocop/cop/active_record_serialize.rb
@@ -0,0 +1,18 @@
+require_relative '../model_helpers'
+
+module RuboCop
+ module Cop
+ # Cop that prevents the use of `serialize` in ActiveRecord models.
+ class ActiveRecordSerialize < RuboCop::Cop::Cop
+ include ModelHelpers
+
+ MSG = 'Do not store serialized data in the database, use separate columns and/or tables instead'.freeze
+
+ def on_send(node)
+ return unless in_model?(node)
+
+ add_offense(node, :selector) if node.children[1] == :serialize
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/in_batches.rb b/rubocop/cop/in_batches.rb
new file mode 100644
index 00000000000..c0240187e66
--- /dev/null
+++ b/rubocop/cop/in_batches.rb
@@ -0,0 +1,16 @@
+require_relative '../model_helpers'
+
+module RuboCop
+ module Cop
+ # Cop that prevents the use of `in_batches`
+ class InBatches < RuboCop::Cop::Cop
+ MSG = 'Do not use `in_batches`, use `each_batch` from the EachBatch module instead'.freeze
+
+ def on_send(node)
+ return unless node.children[1] == :in_batches
+
+ add_offense(node, :selector)
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/add_column_with_default.rb b/rubocop/cop/migration/add_column_with_default.rb
deleted file mode 100644
index 54a920d4b49..00000000000
--- a/rubocop/cop/migration/add_column_with_default.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require_relative '../../migration_helpers'
-
-module RuboCop
- module Cop
- module Migration
- # Cop that checks if `add_column_with_default` is used with `up`/`down` methods
- # and not `change`.
- class AddColumnWithDefault < RuboCop::Cop::Cop
- include MigrationHelpers
-
- MSG = '`add_column_with_default` is not reversible so you must manually define ' \
- 'the `up` and `down` methods in your migration class, using `remove_column` in `down`'.freeze
-
- def on_send(node)
- return unless in_migration?(node)
-
- name = node.children[1]
-
- return unless name == :add_column_with_default
-
- node.each_ancestor(:def) do |def_node|
- next unless method_name(def_node) == :change
-
- add_offense(def_node, :name)
- end
- end
-
- def method_name(node)
- node.children.first
- end
- end
- end
- end
-end
diff --git a/rubocop/cop/migration/add_column_with_default_to_large_table.rb b/rubocop/cop/migration/add_column_with_default_to_large_table.rb
new file mode 100644
index 00000000000..fb363f95b56
--- /dev/null
+++ b/rubocop/cop/migration/add_column_with_default_to_large_table.rb
@@ -0,0 +1,55 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # This cop checks for `add_column_with_default` on a table that's been
+ # explicitly blacklisted because of its size.
+ #
+ # Even though this helper performs the update in batches to avoid
+ # downtime, using it with tables with millions of rows still causes a
+ # significant delay in the deploy process and is best avoided.
+ #
+ # See https://gitlab.com/gitlab-com/infrastructure/issues/1602 for more
+ # information.
+ class AddColumnWithDefaultToLargeTable < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'Using `add_column_with_default` on the `%s` table will take a ' \
+ 'long time to complete, and should be avoided unless absolutely ' \
+ 'necessary'.freeze
+
+ LARGE_TABLES = %i[
+ ci_pipelines
+ ci_builds
+ events
+ issues
+ merge_request_diff_files
+ merge_request_diffs
+ merge_requests
+ namespaces
+ notes
+ projects
+ routes
+ users
+ ].freeze
+
+ def_node_matcher :add_column_with_default?, <<~PATTERN
+ (send nil :add_column_with_default $(sym ...) ...)
+ PATTERN
+
+ def on_send(node)
+ return unless in_migration?(node)
+
+ matched = add_column_with_default?(node)
+ return unless matched
+
+ table = matched.to_a.first
+ return unless LARGE_TABLES.include?(table)
+
+ add_offense(node, :expression, format(MSG, table))
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/add_timestamps.rb b/rubocop/cop/migration/add_timestamps.rb
new file mode 100644
index 00000000000..08ddd91e54d
--- /dev/null
+++ b/rubocop/cop/migration/add_timestamps.rb
@@ -0,0 +1,25 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that checks if 'add_timestamps' method is called with timezone information.
+ class AddTimestamps < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'Do not use `add_timestamps`, use `add_timestamps_with_timezone` instead'.freeze
+
+ # Check methods.
+ def on_send(node)
+ return unless in_migration?(node)
+
+ add_offense(node, :selector) if method_name(node) == :add_timestamps
+ end
+
+ def method_name(node)
+ node.children[1]
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/datetime.rb b/rubocop/cop/migration/datetime.rb
new file mode 100644
index 00000000000..651935dd53e
--- /dev/null
+++ b/rubocop/cop/migration/datetime.rb
@@ -0,0 +1,36 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that checks if datetime data type is added with timezone information.
+ class Datetime < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'Do not use the `datetime` data type, use `datetime_with_timezone` instead'.freeze
+
+ # Check methods in table creation.
+ def on_def(node)
+ return unless in_migration?(node)
+
+ node.each_descendant(:send) do |send_node|
+ add_offense(send_node, :selector) if method_name(send_node) == :datetime
+ end
+ end
+
+ # Check methods.
+ def on_send(node)
+ return unless in_migration?(node)
+
+ node.each_descendant do |descendant|
+ add_offense(node, :expression) if descendant.type == :sym && descendant.children.last == :datetime
+ end
+ end
+
+ def method_name(node)
+ node.children[1]
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/hash_index.rb b/rubocop/cop/migration/hash_index.rb
new file mode 100644
index 00000000000..2cc59691d84
--- /dev/null
+++ b/rubocop/cop/migration/hash_index.rb
@@ -0,0 +1,51 @@
+require 'set'
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that prevents the use of hash indexes in database migrations
+ class HashIndex < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'hash indexes should be avoided at all costs since they are not ' \
+ 'recorded in the PostgreSQL WAL, you should use a btree index instead'.freeze
+
+ NAMES = Set.new([:add_index, :index, :add_concurrent_index]).freeze
+
+ def on_send(node)
+ return unless in_migration?(node)
+
+ name = node.children[1]
+
+ return unless NAMES.include?(name)
+
+ opts = node.children.last
+
+ return unless opts && opts.type == :hash
+
+ opts.each_node(:pair) do |pair|
+ next unless hash_key_type(pair) == :sym &&
+ hash_key_name(pair) == :using
+
+ if hash_key_value(pair).to_s == 'hash'
+ add_offense(pair, :expression)
+ end
+ end
+ end
+
+ def hash_key_type(pair)
+ pair.children[0].type
+ end
+
+ def hash_key_name(pair)
+ pair.children[0].children[0]
+ end
+
+ def hash_key_value(pair)
+ pair.children[1].children[0]
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/reversible_add_column_with_default.rb b/rubocop/cop/migration/reversible_add_column_with_default.rb
new file mode 100644
index 00000000000..f413f06f39b
--- /dev/null
+++ b/rubocop/cop/migration/reversible_add_column_with_default.rb
@@ -0,0 +1,35 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that checks if `add_column_with_default` is used with `up`/`down` methods
+ # and not `change`.
+ class ReversibleAddColumnWithDefault < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ def_node_matcher :add_column_with_default?, <<~PATTERN
+ (send nil :add_column_with_default $...)
+ PATTERN
+
+ def_node_matcher :defines_change?, <<~PATTERN
+ (def :change ...)
+ PATTERN
+
+ MSG = '`add_column_with_default` is not reversible so you must manually define ' \
+ 'the `up` and `down` methods in your migration class, using `remove_column` in `down`'.freeze
+
+ def on_send(node)
+ return unless in_migration?(node)
+ return unless add_column_with_default?(node)
+
+ node.each_ancestor(:def) do |def_node|
+ next unless defines_change?(def_node)
+
+ add_offense(def_node, :name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/safer_boolean_column.rb b/rubocop/cop/migration/safer_boolean_column.rb
new file mode 100644
index 00000000000..0335c25d85d
--- /dev/null
+++ b/rubocop/cop/migration/safer_boolean_column.rb
@@ -0,0 +1,94 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # This cop requires a default value and disallows nulls for boolean
+ # columns on small tables.
+ #
+ # In general, this prevents 3-state-booleans.
+ # https://robots.thoughtbot.com/avoid-the-threestate-boolean-problem
+ #
+ # In particular, for the `application_settings` table, this ensures that
+ # upgraded installations get a proper default for the new boolean setting.
+ # A developer might otherwise mistakenly assume that a value in
+ # `ApplicationSetting.defaults` is sufficient.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-ee/issues/2750 for more
+ # information.
+ class SaferBooleanColumn < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ DEFAULT_OFFENSE = 'Boolean columns on the `%s` table should have a default. You may wish to use `add_column_with_default`.'.freeze
+ NULL_OFFENSE = 'Boolean columns on the `%s` table should disallow nulls.'.freeze
+ DEFAULT_AND_NULL_OFFENSE = 'Boolean columns on the `%s` table should have a default and should disallow nulls. You may wish to use `add_column_with_default`.'.freeze
+
+ SMALL_TABLES = %i[
+ application_settings
+ ].freeze
+
+ def_node_matcher :add_column?, <<~PATTERN
+ (send nil :add_column $...)
+ PATTERN
+
+ def on_send(node)
+ return unless in_migration?(node)
+
+ matched = add_column?(node)
+
+ return unless matched
+
+ table, _, type = matched.to_a.take(3).map(&:children).map(&:first)
+ opts = matched[3]
+
+ return unless SMALL_TABLES.include?(table) && type == :boolean
+
+ no_default = no_default?(opts)
+ nulls_allowed = nulls_allowed?(opts)
+
+ offense = if no_default && nulls_allowed
+ DEFAULT_AND_NULL_OFFENSE
+ elsif no_default
+ DEFAULT_OFFENSE
+ elsif nulls_allowed
+ NULL_OFFENSE
+ end
+
+ add_offense(node, :expression, format(offense, table)) if offense
+ end
+
+ def no_default?(opts)
+ return true unless opts
+
+ each_hash_node_pair(opts) do |key, value|
+ return value == 'nil' if key == :default
+ end
+ end
+
+ def nulls_allowed?(opts)
+ return true unless opts
+
+ each_hash_node_pair(opts) do |key, value|
+ return value != 'false' if key == :null
+ end
+ end
+
+ def each_hash_node_pair(hash_node, &block)
+ hash_node.each_node(:pair) do |pair|
+ key = hash_pair_key(pair)
+ value = hash_pair_value(pair)
+ yield(key, value)
+ end
+ end
+
+ def hash_pair_key(pair)
+ pair.children[0].children[0]
+ end
+
+ def hash_pair_value(pair)
+ pair.children[1].source
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/timestamps.rb b/rubocop/cop/migration/timestamps.rb
new file mode 100644
index 00000000000..71a9420cc3b
--- /dev/null
+++ b/rubocop/cop/migration/timestamps.rb
@@ -0,0 +1,27 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that checks if 'timestamps' method is called with timezone information.
+ class Timestamps < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'Do not use `timestamps`, use `timestamps_with_timezone` instead'.freeze
+
+ # Check methods in table creation.
+ def on_def(node)
+ return unless in_migration?(node)
+
+ node.each_descendant(:send) do |send_node|
+ add_offense(send_node, :selector) if method_name(send_node) == :timestamps
+ end
+ end
+
+ def method_name(node)
+ node.children[1]
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/update_column_in_batches.rb b/rubocop/cop/migration/update_column_in_batches.rb
new file mode 100644
index 00000000000..3f886cbfea3
--- /dev/null
+++ b/rubocop/cop/migration/update_column_in_batches.rb
@@ -0,0 +1,43 @@
+require_relative '../../migration_helpers'
+
+module RuboCop
+ module Cop
+ module Migration
+ # Cop that checks if a spec file exists for any migration using
+ # `update_column_in_batches`.
+ class UpdateColumnInBatches < RuboCop::Cop::Cop
+ include MigrationHelpers
+
+ MSG = 'Migration running `update_column_in_batches` must have a spec file at' \
+ ' `%s`.'.freeze
+
+ def on_send(node)
+ return unless in_migration?(node)
+ return unless node.children[1] == :update_column_in_batches
+
+ spec_path = spec_filename(node)
+
+ unless File.exist?(File.expand_path(spec_path, rails_root))
+ add_offense(node, :expression, format(MSG, spec_path))
+ end
+ end
+
+ private
+
+ def spec_filename(node)
+ source_name = node.location.expression.source_buffer.name
+ path = Pathname.new(source_name).relative_path_from(rails_root)
+ dirname = File.dirname(path)
+ .sub(%r{\Adb/(migrate|post_migrate)}, 'spec/migrations')
+ filename = File.basename(source_name, '.rb').sub(%r{\A\d+_}, '')
+
+ File.join(dirname, "#{filename}_spec.rb")
+ end
+
+ def rails_root
+ Pathname.new(File.expand_path('../../..', __dir__))
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/polymorphic_associations.rb b/rubocop/cop/polymorphic_associations.rb
new file mode 100644
index 00000000000..7d554704550
--- /dev/null
+++ b/rubocop/cop/polymorphic_associations.rb
@@ -0,0 +1,23 @@
+require_relative '../model_helpers'
+
+module RuboCop
+ module Cop
+ # Cop that prevents the use of polymorphic associations
+ class PolymorphicAssociations < RuboCop::Cop::Cop
+ include ModelHelpers
+
+ MSG = 'Do not use polymorphic associations, use separate tables instead'.freeze
+
+ def on_send(node)
+ return unless in_model?(node)
+ return unless node.children[1] == :belongs_to
+
+ node.children.last.each_node(:pair) do |pair|
+ key_name = pair.children[0].children[0]
+
+ add_offense(pair, :expression) if key_name == :polymorphic
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/project_path_helper.rb b/rubocop/cop/project_path_helper.rb
new file mode 100644
index 00000000000..3e1ce71ac06
--- /dev/null
+++ b/rubocop/cop/project_path_helper.rb
@@ -0,0 +1,51 @@
+module RuboCop
+ module Cop
+ class ProjectPathHelper < RuboCop::Cop::Cop
+ MSG = 'Use short project path helpers without explicitly passing the namespace: ' \
+ '`foo_project_bar_path(project, bar)` instead of ' \
+ '`foo_namespace_project_bar_path(project.namespace, project, bar)`.'.freeze
+
+ METHOD_NAME_PATTERN = /\A([a-z_]+_)?namespace_project(?:_[a-z_]+)?_(?:url|path)\z/.freeze
+
+ def on_send(node)
+ return unless method_name(node).to_s =~ METHOD_NAME_PATTERN
+
+ namespace_expr, project_expr = arguments(node)
+ return unless namespace_expr && project_expr
+
+ return unless namespace_expr.type == :send
+ return unless method_name(namespace_expr) == :namespace
+ return unless receiver(namespace_expr) == project_expr
+
+ add_offense(node, :selector)
+ end
+
+ def autocorrect(node)
+ helper_name = method_name(node).to_s.sub('namespace_project', 'project')
+
+ arguments = arguments(node)
+ arguments.shift # Remove namespace argument
+
+ replacement = "#{helper_name}(#{arguments.map(&:source).join(', ')})"
+
+ lambda do |corrector|
+ corrector.replace(node.source_range, replacement)
+ end
+ end
+
+ private
+
+ def receiver(node)
+ node.children[0]
+ end
+
+ def method_name(node)
+ node.children[1]
+ end
+
+ def arguments(node)
+ node.children[2..-1]
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/redirect_with_status.rb b/rubocop/cop/redirect_with_status.rb
new file mode 100644
index 00000000000..36810642c88
--- /dev/null
+++ b/rubocop/cop/redirect_with_status.rb
@@ -0,0 +1,44 @@
+module RuboCop
+ module Cop
+ # This cop prevents usage of 'redirect_to' in actions 'destroy' without specifying 'status'.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/31840
+ class RedirectWithStatus < RuboCop::Cop::Cop
+ MSG = 'Do not use "redirect_to" without "status" in "destroy" action'.freeze
+
+ def on_def(node)
+ return unless in_controller?(node)
+ return unless destroy?(node) || destroy_all?(node)
+
+ node.each_descendant(:send) do |def_node|
+ next unless redirect_to?(def_node)
+
+ methods = []
+
+ def_node.children.last.each_node(:pair) do |pair|
+ methods << pair.children.first.children.first
+ end
+
+ add_offense(def_node, :selector) unless methods.include?(:status)
+ end
+ end
+
+ private
+
+ def in_controller?(node)
+ node.location.expression.source_buffer.name.end_with?('_controller.rb')
+ end
+
+ def destroy?(node)
+ node.children.first == :destroy
+ end
+
+ def destroy_all?(node)
+ node.children.first == :destroy_all
+ end
+
+ def redirect_to?(node)
+ node.children[1] == :redirect_to
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/rspec/single_line_hook.rb b/rubocop/cop/rspec/single_line_hook.rb
new file mode 100644
index 00000000000..be611054323
--- /dev/null
+++ b/rubocop/cop/rspec/single_line_hook.rb
@@ -0,0 +1,38 @@
+require 'rubocop-rspec'
+
+module RuboCop
+ module Cop
+ module RSpec
+ # This cop checks for single-line hook blocks
+ #
+ # @example
+ #
+ # # bad
+ # before { do_something }
+ # after(:each) { undo_something }
+ #
+ # # good
+ # before do
+ # do_something
+ # end
+ #
+ # after(:each) do
+ # undo_something
+ # end
+ class SingleLineHook < Cop
+ MESSAGE = "Don't use single-line hook blocks.".freeze
+
+ def_node_search :rspec_hook?, <<~PATTERN
+ (send nil {:after :around :before} ...)
+ PATTERN
+
+ def on_block(node)
+ return unless rspec_hook?(node)
+ return unless node.single_line?
+
+ add_offense(node, :expression, MESSAGE)
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb
index 3160a784a04..c3473771178 100644
--- a/rubocop/migration_helpers.rb
+++ b/rubocop/migration_helpers.rb
@@ -3,8 +3,9 @@ module RuboCop
module MigrationHelpers
# Returns true if the given node originated from the db/migrate directory.
def in_migration?(node)
- File.dirname(node.location.expression.source_buffer.name).
- end_with?('db/migrate')
+ dirname = File.dirname(node.location.expression.source_buffer.name)
+
+ dirname.end_with?('db/migrate', 'db/post_migrate')
end
end
end
diff --git a/rubocop/model_helpers.rb b/rubocop/model_helpers.rb
new file mode 100644
index 00000000000..309723dc34c
--- /dev/null
+++ b/rubocop/model_helpers.rb
@@ -0,0 +1,11 @@
+module RuboCop
+ module ModelHelpers
+ # Returns true if the given node originated from the models directory.
+ def in_model?(node)
+ path = node.location.expression.source_buffer.name
+ models_path = File.join(Dir.pwd, 'app', 'models')
+
+ path.start_with?(models_path)
+ end
+ end
+end
diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb
index d580aa6857a..1b6e8991a17 100644
--- a/rubocop/rubocop.rb
+++ b/rubocop/rubocop.rb
@@ -1,9 +1,23 @@
require_relative 'cop/custom_error_class'
require_relative 'cop/gem_fetcher'
+require_relative 'cop/active_record_serialize'
+require_relative 'cop/redirect_with_status'
+require_relative 'cop/polymorphic_associations'
+require_relative 'cop/project_path_helper'
+require_relative 'cop/active_record_dependent'
+require_relative 'cop/in_batches'
require_relative 'cop/migration/add_column'
-require_relative 'cop/migration/add_column_with_default'
+require_relative 'cop/migration/add_column_with_default_to_large_table'
require_relative 'cop/migration/add_concurrent_foreign_key'
require_relative 'cop/migration/add_concurrent_index'
require_relative 'cop/migration/add_index'
+require_relative 'cop/migration/add_timestamps'
+require_relative 'cop/migration/datetime'
+require_relative 'cop/migration/safer_boolean_column'
+require_relative 'cop/migration/hash_index'
require_relative 'cop/migration/remove_concurrent_index'
require_relative 'cop/migration/remove_index'
+require_relative 'cop/migration/reversible_add_column_with_default'
+require_relative 'cop/migration/timestamps'
+require_relative 'cop/migration/update_column_in_batches'
+require_relative 'cop/rspec/single_line_hook'
diff --git a/scripts/detect-new-flaky-examples b/scripts/detect-new-flaky-examples
new file mode 100755
index 00000000000..3bee4f9a34b
--- /dev/null
+++ b/scripts/detect-new-flaky-examples
@@ -0,0 +1,21 @@
+#!/usr/bin/env ruby
+
+require 'json'
+
+report_file = ARGV.shift
+unless report_file
+ puts 'usage: detect-new-flaky-examples <report-file>'
+ exit 1
+end
+
+puts "Loading #{report_file}..."
+report = JSON.parse(File.read(report_file))
+
+if report.any?
+ puts "New flaky examples were detected!\n"
+ puts JSON.pretty_generate(report)
+ exit 1
+else
+ puts "No new flaky examples detected.\n"
+ exit 0
+end
diff --git a/scripts/gitaly-test-build b/scripts/gitaly-test-build
new file mode 100755
index 00000000000..95d9fe0f176
--- /dev/null
+++ b/scripts/gitaly-test-build
@@ -0,0 +1,19 @@
+#!/usr/bin/env ruby
+
+require 'fileutils'
+
+# This script assumes tmp/tests/gitaly already contains the correct
+# Gitaly version. We just have to compile it and run its 'bundle
+# install'. We have this separate script for that because weird things
+# were happening in CI when we have a 'bundle exec' process that later
+# called 'bundle install' using a different Gemfile, as happens with
+# gitlab-ce and gitaly.
+
+dir = 'tmp/tests/gitaly'
+
+abort 'gitaly build failed' unless system('make', chdir: dir)
+
+# Make the 'gitaly' executable look newer than 'GITALY_SERVER_VERSION'.
+# Without this a gitaly executable created in the setup-test-env job
+# will look stale compared to GITALY_SERVER_VERSION.
+FileUtils.touch(File.join(dir, 'gitaly'), mtime: Time.now + (1 << 24))
diff --git a/scripts/gitaly-test-spawn b/scripts/gitaly-test-spawn
new file mode 100755
index 00000000000..dd603eec7f6
--- /dev/null
+++ b/scripts/gitaly-test-spawn
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+
+gitaly_dir = 'tmp/tests/gitaly'
+args = %W[#{gitaly_dir}/gitaly #{gitaly_dir}/config.toml]
+
+# Print the PID of the spawned process
+puts spawn(*args, [:out, :err] => 'log/gitaly-test.log')
diff --git a/scripts/lint-conflicts.sh b/scripts/lint-conflicts.sh
new file mode 100755
index 00000000000..f3877600c8c
--- /dev/null
+++ b/scripts/lint-conflicts.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+output=`git ls-files -z | grep -zvE '\.(rb|js|haml)$' | xargs -0n1 grep -HEn '^<<<<<<< '`
+echo $output
+test -z "$output"
diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh
index 62236ed539a..54c1ef3dfdd 100755
--- a/scripts/lint-doc.sh
+++ b/scripts/lint-doc.sh
@@ -21,4 +21,3 @@ fi
echo "✔ Linting passed"
exit 0
-
diff --git a/scripts/merge-reports b/scripts/merge-reports
index aad76bcc327..3a421f1f1fc 100755
--- a/scripts/merge-reports
+++ b/scripts/merge-reports
@@ -4,7 +4,7 @@ require 'json'
main_report_file = ARGV.shift
unless main_report_file
- puts 'usage: merge_reports <main-report> [extra reports...]'
+ puts 'usage: merge-reports <main-report> [extra reports...]'
exit 1
end
diff --git a/scripts/notify_slack.sh b/scripts/notify_slack.sh
deleted file mode 100755
index 6b3bc563c7a..00000000000
--- a/scripts/notify_slack.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/sh
-# Sends Slack notification ERROR_MSG to CHANNEL
-# An env. variable CI_SLACK_WEBHOOK_URL needs to be set.
-
-CHANNEL=$1
-ERROR_MSG=$2
-
-if [ -z "$CHANNEL" ] || [ -z "$ERROR_MSG" ] || [ -z "$CI_SLACK_WEBHOOK_URL" ]; then
- echo "Missing argument(s) - Use: $0 channel message"
- echo "and set CI_SLACK_WEBHOOK_URL environment variable."
-else
- curl -X POST --data-urlencode 'payload={"channel": "'"$CHANNEL"'", "username": "gitlab-ci", "text": "'"$ERROR_MSG"'", "icon_emoji": ":gitlab:"}' "$CI_SLACK_WEBHOOK_URL"
-fi \ No newline at end of file
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index fd173c0ba88..39806901274 100755..100644
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -1,14 +1,22 @@
-#!/bin/sh
-
. scripts/utils.sh
export SETUP_DB=${SETUP_DB:-true}
export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true}
export BUNDLE_INSTALL_FLAGS="--without production --jobs $(nproc) --path vendor --retry 3 --quiet"
+if [ "$USE_BUNDLE_INSTALL" != "false" ]; then
+ bundle install --clean $BUNDLE_INSTALL_FLAGS && bundle check
+fi
+
+# Only install knapsack after bundle install! Otherwise oddly some native
+# gems could not be found under some circumstance. No idea why, hours wasted.
+retry gem install knapsack
+
+cp config/gitlab.yml.example config/gitlab.yml
+
# Determine the database by looking at the job name.
-# For example, we'll get pg if the job is `rspec pg 19 20`
-export GITLAB_DATABASE=$(echo $CI_JOB_NAME | cut -f2 -d' ')
+# For example, we'll get pg if the job is `rspec-pg 19 20`
+export GITLAB_DATABASE=$(echo $CI_JOB_NAME | cut -f1 -d' ' | cut -f2 -d-)
# This would make the default database postgresql, and we could also use
# pg to mean postgresql.
@@ -29,15 +37,14 @@ fi
cp config/resque.yml.example config/resque.yml
sed -i 's/localhost/redis/g' config/resque.yml
-cp config/gitlab.yml.example config/gitlab.yml
+cp config/redis.cache.yml.example config/redis.cache.yml
+sed -i 's/localhost/redis/g' config/redis.cache.yml
-if [ "$USE_BUNDLE_INSTALL" != "false" ]; then
- retry bundle install --clean $BUNDLE_INSTALL_FLAGS && bundle check
-fi
+cp config/redis.queues.yml.example config/redis.queues.yml
+sed -i 's/localhost/redis/g' config/redis.queues.yml
-# Only install knapsack after bundle install! Otherwise oddly some native
-# gems could not be found under some circumstance. No idea why, hours wasted.
-retry gem install knapsack fog-aws mime-types
+cp config/redis.shared_state.yml.example config/redis.shared_state.yml
+sed -i 's/localhost/redis/g' config/redis.shared_state.yml
if [ "$SETUP_DB" != "false" ]; then
bundle exec rake db:drop db:create db:schema:load db:migrate
diff --git a/scripts/static-analysis b/scripts/static-analysis
new file mode 100755
index 00000000000..e4f80e8fc6f
--- /dev/null
+++ b/scripts/static-analysis
@@ -0,0 +1,41 @@
+#!/usr/bin/env ruby
+
+require ::File.expand_path('../lib/gitlab/popen', __dir__)
+
+tasks = [
+ %w[bundle exec bundle-audit check --update --ignore CVE-2016-4658 CVE-2017-5029],
+ %w[bundle exec rake config_lint],
+ %w[bundle exec rake flay],
+ %w[bundle exec rake haml_lint],
+ %w[bundle exec rake scss_lint],
+ %w[bundle exec rake brakeman],
+ %w[bundle exec license_finder],
+ %w[yarn run eslint],
+ %w[bundle exec rubocop --require rubocop-rspec],
+ %w[scripts/lint-conflicts.sh]
+]
+
+failed_tasks = tasks.reduce({}) do |failures, task|
+ output, status = Gitlab::Popen.popen(task)
+
+ puts "Running: #{task.join(' ')}"
+ puts output
+
+ failures[task.join(' ')] = output unless status.zero?
+
+ failures
+end
+
+if failed_tasks.empty?
+ puts 'All static analyses passed successfully.'
+else
+ puts "\n===================================================\n\n"
+ puts "Some static analyses failed:"
+
+ failed_tasks.each do |failed_task, output|
+ puts "\n**** #{failed_task} failed with the following error:\n\n"
+ puts output
+ end
+
+ exit 1
+end
diff --git a/scripts/trigger-build b/scripts/trigger-build
new file mode 100755
index 00000000000..dcda70d7ed8
--- /dev/null
+++ b/scripts/trigger-build
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+
+require 'net/http'
+require 'json'
+
+uri = URI('https://gitlab.com/api/v4/projects/20699/trigger/pipeline')
+params = {
+ "ref" => ENV["OMNIBUS_BRANCH"] || "master",
+ "token" => ENV["BUILD_TRIGGER_TOKEN"],
+ "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"],
+ "variables[ALTERNATIVE_SOURCES]" => true,
+ "variables[ee]" => ENV["EE_PACKAGE"] || "false"
+}
+
+Dir.glob("*_VERSION").each do |version_file|
+ params["variables[#{version_file}]"] = File.read(version_file).strip
+end
+
+res = Net::HTTP.post_form(uri, params)
+pipeline_id = JSON.parse(res.body)['id']
+
+unless pipeline_id.nil?
+ puts "Triggered pipeline can be found at https://gitlab.com/gitlab-org/omnibus-gitlab/pipelines/#{pipeline_id}"
+else
+ puts "Trigger failed. The response from trigger is: "
+ puts res.body
+end
diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb
index 7f4298db59f..6d8b9865dcb 100644
--- a/spec/bin/changelog_spec.rb
+++ b/spec/bin/changelog_spec.rb
@@ -4,58 +4,90 @@ load File.expand_path('../../bin/changelog', __dir__)
describe 'bin/changelog' do
describe ChangelogOptionParser do
- it 'parses --ammend' do
- options = described_class.parse(%w[foo bar --amend])
+ describe '.parse' do
+ it 'parses --amend' do
+ options = described_class.parse(%w[foo bar --amend])
- expect(options.amend).to eq true
- end
+ expect(options.amend).to eq true
+ end
- it 'parses --force and -f' do
- %w[--force -f].each do |flag|
- options = described_class.parse(%W[foo #{flag} bar])
+ it 'parses --force and -f' do
+ %w[--force -f].each do |flag|
+ options = described_class.parse(%W[foo #{flag} bar])
- expect(options.force).to eq true
+ expect(options.force).to eq true
+ end
end
- end
- it 'parses --merge-request and -m' do
- %w[--merge-request -m].each do |flag|
- options = described_class.parse(%W[foo #{flag} 1234 bar])
+ it 'parses --merge-request and -m' do
+ %w[--merge-request -m].each do |flag|
+ options = described_class.parse(%W[foo #{flag} 1234 bar])
- expect(options.merge_request).to eq 1234
+ expect(options.merge_request).to eq 1234
+ end
end
- end
- it 'parses --dry-run and -n' do
- %w[--dry-run -n].each do |flag|
- options = described_class.parse(%W[foo #{flag} bar])
+ it 'parses --dry-run and -n' do
+ %w[--dry-run -n].each do |flag|
+ options = described_class.parse(%W[foo #{flag} bar])
- expect(options.dry_run).to eq true
+ expect(options.dry_run).to eq true
+ end
end
- end
- it 'parses --git-username and -u' do
- allow(described_class).to receive(:git_user_name).and_return('Jane Doe')
+ it 'parses --git-username and -u' do
+ allow(described_class).to receive(:git_user_name).and_return('Jane Doe')
- %w[--git-username -u].each do |flag|
- options = described_class.parse(%W[foo #{flag} bar])
+ %w[--git-username -u].each do |flag|
+ options = described_class.parse(%W[foo #{flag} bar])
- expect(options.author).to eq 'Jane Doe'
+ expect(options.author).to eq 'Jane Doe'
+ end
end
- end
- it 'parses -h' do
- expect do
- $stdout = StringIO.new
+ it 'parses --type and -t' do
+ %w[--type -t].each do |flag|
+ options = described_class.parse(%W[foo #{flag} security])
+
+ expect(options.type).to eq 'security'
+ end
+ end
- described_class.parse(%w[foo -h bar])
- end.to raise_error(SystemExit)
+ it 'parses -h' do
+ expect do
+ expect { described_class.parse(%w[foo -h bar]) }.to output.to_stdout
+ end.to raise_error(SystemExit)
+ end
+
+ it 'assigns title' do
+ options = described_class.parse(%W[foo -m 1 bar\n -u baz\r\n --amend])
+
+ expect(options.title).to eq 'foo bar baz'
+ end
end
- it 'assigns title' do
- options = described_class.parse(%W[foo -m 1 bar\n -u baz\r\n --amend])
+ describe '.read_type' do
+ let(:type) { '1' }
- expect(options.title).to eq 'foo bar baz'
+ it 'reads type from $stdin' do
+ expect($stdin).to receive(:getc).and_return(type)
+ expect do
+ expect(described_class.read_type).to eq('added')
+ end.to output.to_stdout
+ end
+
+ context 'invalid type given' do
+ let(:type) { '99' }
+
+ it 'shows error message and exits the program' do
+ allow($stdin).to receive(:getc).and_return(type)
+ expect do
+ expect do
+ expect { described_class.read_type }.to raise_error(SystemExit)
+ end.to output("Invalid category index, please select an index between 1 and 7\n").to_stderr
+ end.to output.to_stdout
+ end
+ end
end
end
end
diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb
index 092048a6259..74634dac713 100644
--- a/spec/config/mail_room_spec.rb
+++ b/spec/config/mail_room_spec.rb
@@ -5,12 +5,12 @@ describe 'mail_room.yml' do
let(:mailroom_config_path) { 'config/mail_room.yml' }
let(:gitlab_config_path) { 'config/mail_room.yml' }
- let(:redis_config_path) { 'config/resque.yml' }
+ let(:queues_config_path) { 'config/redis.queues.yml' }
let(:configuration) do
vars = {
'MAIL_ROOM_GITLAB_CONFIG_FILE' => absolute_path(gitlab_config_path),
- 'GITLAB_REDIS_CONFIG_FILE' => absolute_path(redis_config_path)
+ 'GITLAB_REDIS_QUEUES_CONFIG_FILE' => absolute_path(queues_config_path)
}
cmd = "puts ERB.new(File.read(#{absolute_path(mailroom_config_path).inspect})).result"
@@ -20,13 +20,13 @@ describe 'mail_room.yml' do
YAML.load(output)
end
- before(:each) do
- stub_env('GITLAB_REDIS_CONFIG_FILE', absolute_path(redis_config_path))
- clear_redis_raw_config
+ before do
+ stub_env('GITLAB_REDIS_QUEUES_CONFIG_FILE', absolute_path(queues_config_path))
+ clear_queues_raw_config
end
- after(:each) do
- clear_redis_raw_config
+ after do
+ clear_queues_raw_config
end
context 'when incoming email is disabled' do
@@ -39,9 +39,9 @@ describe 'mail_room.yml' do
context 'when incoming email is enabled' do
let(:gitlab_config_path) { 'spec/fixtures/config/mail_room_enabled.yml' }
- let(:redis_config_path) { 'spec/fixtures/config/redis_new_format_host.yml' }
+ let(:queues_config_path) { 'spec/fixtures/config/redis_queues_new_format_host.yml' }
- let(:gitlab_redis) { Gitlab::Redis.new(Rails.env) }
+ let(:gitlab_redis_queues) { Gitlab::Redis::Queues.new(Rails.env) }
it 'contains the intended configuration' do
expect(configuration[:mailboxes].length).to eq(1)
@@ -56,8 +56,8 @@ describe 'mail_room.yml' do
expect(mailbox[:name]).to eq('inbox')
expect(mailbox[:idle_timeout]).to eq(60)
- redis_url = gitlab_redis.url
- sentinels = gitlab_redis.sentinels
+ redis_url = gitlab_redis_queues.url
+ sentinels = gitlab_redis_queues.sentinels
expect(mailbox[:delivery_options][:redis_url]).to be_present
expect(mailbox[:delivery_options][:redis_url]).to eq(redis_url)
@@ -73,8 +73,8 @@ describe 'mail_room.yml' do
end
end
- def clear_redis_raw_config
- Gitlab::Redis.remove_instance_variable(:@_raw_config)
+ def clear_queues_raw_config
+ Gitlab::Redis::Queues.remove_instance_variable(:@_raw_config)
rescue NameError
# raised if @_raw_config was not set; ignore
end
diff --git a/spec/controllers/abuse_reports_controller_spec.rb b/spec/controllers/abuse_reports_controller_spec.rb
index 80a418feb3e..ada011e7595 100644
--- a/spec/controllers/abuse_reports_controller_spec.rb
+++ b/spec/controllers/abuse_reports_controller_spec.rb
@@ -13,6 +13,31 @@ describe AbuseReportsController do
sign_in(reporter)
end
+ describe 'GET new' do
+ context 'when the user has already been deleted' do
+ it 'redirects the reporter to root_path' do
+ user_id = user.id
+ user.destroy
+
+ get :new, { user_id: user_id }
+
+ expect(response).to redirect_to root_path
+ expect(flash[:alert]).to eq('Cannot create the abuse report. The user has been deleted.')
+ end
+ end
+
+ context 'when the user has already been blocked' do
+ it 'redirects the reporter to the user\'s profile' do
+ user.block
+
+ get :new, { user_id: user.id }
+
+ expect(response).to redirect_to user
+ expect(flash[:alert]).to eq('Cannot create the abuse report. This user has been blocked.')
+ end
+ end
+ end
+
describe 'POST create' do
context 'with valid attributes' do
it 'saves the abuse report' do
diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb
index e311b8a63b2..7bd6c0e6117 100644
--- a/spec/controllers/admin/applications_controller_spec.rb
+++ b/spec/controllers/admin/applications_controller_spec.rb
@@ -28,13 +28,16 @@ describe Admin::ApplicationsController do
describe 'POST #create' do
it 'creates the application' do
+ create_params = attributes_for(:application, trusted: true)
+
expect do
- post :create, doorkeeper_application: attributes_for(:application)
+ post :create, doorkeeper_application: create_params
end.to change { Doorkeeper::Application.count }.by(1)
application = Doorkeeper::Application.last
expect(response).to redirect_to(admin_application_path(application))
+ expect(application).to have_attributes(create_params.except(:uid, :owner_type))
end
it 'renders the application form on errors' do
@@ -49,10 +52,12 @@ describe Admin::ApplicationsController do
describe 'PATCH #update' do
it 'updates the application' do
- patch :update, id: application.id, doorkeeper_application: { redirect_uri: 'http://example.com/' }
+ patch :update, id: application.id, doorkeeper_application: { redirect_uri: 'http://example.com/', trusted: true }
+
+ application.reload
expect(response).to redirect_to(admin_application_path(application))
- expect(application.reload.redirect_uri).to eq 'http://example.com/'
+ expect(application).to have_attributes(redirect_uri: 'http://example.com/', trusted: true)
end
it 'renders the application form on errors' do
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb
new file mode 100644
index 00000000000..6eb9f7867d5
--- /dev/null
+++ b/spec/controllers/admin/dashboard_controller_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Admin::DashboardController do
+ describe '#index' do
+ context 'with pending_delete projects' do
+ render_views
+
+ it 'does not retrieve projects that are pending deletion' do
+ sign_in(create(:admin))
+
+ project = create(:project)
+ pending_delete_project = create(:project, pending_delete: true)
+
+ get :index
+
+ expect(response.body).to match(project.name)
+ expect(response.body).not_to match(pending_delete_project.name)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb
index c29b2fe8946..0dad95e418f 100644
--- a/spec/controllers/admin/groups_controller_spec.rb
+++ b/spec/controllers/admin/groups_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Admin::GroupsController do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
let(:admin) { create(:admin) }
before do
@@ -36,6 +36,15 @@ describe Admin::GroupsController do
expect(group.users).to include group_user
end
+ it 'can add unlimited members' do
+ put :members_update, id: group,
+ user_ids: 1.upto(1000).to_a.join(','),
+ access_level: Gitlab::Access::GUEST
+
+ expect(response).to set_flash.to 'Users were successfully added.'
+ expect(response).to redirect_to(admin_group_path(group))
+ end
+
it 'adds no user to members' do
put :members_update, id: group,
user_ids: '',
diff --git a/spec/controllers/admin/health_check_controller_spec.rb b/spec/controllers/admin/health_check_controller_spec.rb
new file mode 100644
index 00000000000..0b8e0c8a065
--- /dev/null
+++ b/spec/controllers/admin/health_check_controller_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Admin::HealthCheckController, broken_storage: true do
+ let(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ end
+
+ describe 'GET show' do
+ it 'loads the git storage health information' do
+ get :show
+
+ expect(assigns[:failing_storage_statuses]).not_to be_nil
+ end
+ end
+
+ describe 'POST reset_storage_health' do
+ it 'resets all storage health information' do
+ expect(Gitlab::Git::Storage::CircuitBreaker).to receive(:reset_all!)
+
+ post :reset_storage_health
+ end
+ end
+end
diff --git a/spec/controllers/admin/hooks_controller_spec.rb b/spec/controllers/admin/hooks_controller_spec.rb
new file mode 100644
index 00000000000..1d1070e90f4
--- /dev/null
+++ b/spec/controllers/admin/hooks_controller_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Admin::HooksController do
+ let(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ end
+
+ describe 'POST #create' do
+ it 'sets all parameters' do
+ hook_params = {
+ enable_ssl_verification: true,
+ push_events: true,
+ tag_push_events: true,
+ repository_update_events: true,
+ token: "TEST TOKEN",
+ url: "http://example.com"
+ }
+
+ post :create, hook: hook_params
+
+ expect(response).to have_http_status(302)
+ expect(SystemHook.all.size).to eq(1)
+ expect(SystemHook.first).to have_attributes(hook_params)
+ end
+ end
+end
diff --git a/spec/controllers/admin/identities_controller_spec.rb b/spec/controllers/admin/identities_controller_spec.rb
index c131d22a30a..a29853bf8df 100644
--- a/spec/controllers/admin/identities_controller_spec.rb
+++ b/spec/controllers/admin/identities_controller_spec.rb
@@ -2,7 +2,10 @@ require 'spec_helper'
describe Admin::IdentitiesController do
let(:admin) { create(:admin) }
- before { sign_in(admin) }
+
+ before do
+ sign_in(admin)
+ end
describe 'UPDATE identity' do
let(:user) { create(:omniauth_user, provider: 'ldapmain', extern_uid: 'uid=myuser,ou=people,dc=example,dc=com') }
diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb
index 2c35d394b74..373260b3978 100644
--- a/spec/controllers/admin/projects_controller_spec.rb
+++ b/spec/controllers/admin/projects_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Admin::ProjectsController do
- let!(:project) { create(:empty_project, :public) }
+ let!(:project) { create(:project, :public) }
before do
sign_in(create(:admin))
@@ -12,12 +12,24 @@ describe Admin::ProjectsController do
it 'retrieves the project for the given visibility level' do
get :index, visibility_level: [Gitlab::VisibilityLevel::PUBLIC]
+
expect(response.body).to match(project.name)
end
it 'does not retrieve the project' do
get :index, visibility_level: [Gitlab::VisibilityLevel::INTERNAL]
+
expect(response.body).not_to match(project.name)
end
+
+ it 'does not respond with projects pending deletion' do
+ pending_delete_project = create(:project, pending_delete: true)
+
+ get :index
+
+ expect(response).to have_http_status(200)
+ expect(response.body).not_to match(pending_delete_project.name)
+ expect(response.body).to match(project.name)
+ end
end
end
diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb
index e5cdd52307e..249bd948847 100644
--- a/spec/controllers/admin/services_controller_spec.rb
+++ b/spec/controllers/admin/services_controller_spec.rb
@@ -3,10 +3,12 @@ require 'spec_helper'
describe Admin::ServicesController do
let(:admin) { create(:admin) }
- before { sign_in(admin) }
+ before do
+ sign_in(admin)
+ end
describe 'GET #edit' do
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
Service.available_services_names.each do |service_name|
context "#{service_name}" do
@@ -23,4 +25,36 @@ describe Admin::ServicesController do
end
end
end
+
+ describe "#update" do
+ let(:project) { create(:project) }
+ let!(:service) do
+ RedmineService.create(
+ project: project,
+ active: false,
+ template: true,
+ properties: {
+ project_url: 'http://abc',
+ issues_url: 'http://abc',
+ new_issue_url: 'http://abc'
+ }
+ )
+ end
+
+ it 'calls the propagation worker when service is active' do
+ expect(PropagateServiceTemplateWorker).to receive(:perform_async).with(service.id)
+
+ put :update, id: service.id, service: { active: true }
+
+ expect(response).to have_http_status(302)
+ end
+
+ it 'does not call the propagation worker when service is not active' do
+ expect(PropagateServiceTemplateWorker).not_to receive(:perform_async)
+
+ put :update, id: service.id, service: { properties: {} }
+
+ expect(response).to have_http_status(302)
+ end
+ end
end
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 2ab2ca1b667..3d21b695af4 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -9,16 +9,27 @@ describe Admin::UsersController do
end
describe 'DELETE #user with projects' do
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let!(:issue) { create(:issue, author: user) }
before do
project.team << [user, :developer]
end
- it 'deletes user' do
+ it 'deletes user and ghosts their contributions' do
delete :destroy, id: user.username, format: :json
+
+ expect(response).to have_http_status(200)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(issue.reload.author).to be_ghost
+ end
+
+ it 'deletes the user and their contributions when hard delete is specified' do
+ delete :destroy, id: user.username, hard_delete: true, format: :json
+
expect(response).to have_http_status(200)
- expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(Issue.exists?(issue.id)).to be_falsy
end
end
@@ -105,8 +116,8 @@ describe Admin::UsersController do
it 'displays an alert' do
go
- expect(flash[:notice]).
- to eq 'Two-factor Authentication has been disabled for this user'
+ expect(flash[:notice])
+ .to eq 'Two-factor Authentication has been disabled for this user'
end
def go
@@ -116,7 +127,7 @@ describe Admin::UsersController do
describe 'POST create' do
it 'creates the user' do
- expect{ post :create, user: attributes_for(:user) }.to change{ User.count }.by(1)
+ expect { post :create, user: attributes_for(:user) }.to change { User.count }.by(1)
end
it 'shows only one error message for an invalid email' do
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 760f33b09c1..331903a5543 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -4,7 +4,7 @@ describe ApplicationController do
let(:user) { create(:user) }
describe '#check_password_expiration' do
- let(:controller) { ApplicationController.new }
+ let(:controller) { described_class.new }
it 'redirects if the user is over their password expiry' do
user.password_expires_at = Time.new(2002)
@@ -30,11 +30,20 @@ describe ApplicationController do
expect(controller).not_to receive(:redirect_to)
controller.send(:check_password_expiration)
end
+
+ it 'does not redirect if the user is over their password expiry but sign-in is disabled' do
+ stub_application_setting(password_authentication_enabled: false)
+ user.password_expires_at = Time.new(2002)
+ allow(controller).to receive(:current_user).and_return(user)
+ expect(controller).not_to receive(:redirect_to)
+
+ controller.send(:check_password_expiration)
+ end
end
describe "#authenticate_user_from_token!" do
describe "authenticating a user from a private token" do
- controller(ApplicationController) do
+ controller(described_class) do
def index
render text: "authenticated"
end
@@ -66,7 +75,7 @@ describe ApplicationController do
end
describe "authenticating a user from a personal access token" do
- controller(ApplicationController) do
+ controller(described_class) do
def index
render text: 'authenticated'
end
@@ -99,6 +108,96 @@ describe ApplicationController do
end
end
+ describe 'rescue from Gitlab::Git::Storage::Inaccessible' do
+ controller(described_class) do
+ def index
+ raise Gitlab::Git::Storage::Inaccessible.new('broken', 100)
+ end
+ end
+
+ it 'renders a 503 when storage is not available' do
+ sign_in(create(:user))
+
+ get :index
+
+ expect(response.status).to eq(503)
+ end
+
+ it 'renders includes a Retry-After header' do
+ sign_in(create(:user))
+
+ get :index
+
+ expect(response.headers['Retry-After']).to eq(100)
+ end
+ end
+
+ describe 'response format' do
+ controller(described_class) do
+ def index
+ respond_to do |format|
+ format.json do
+ head :ok
+ end
+ end
+ end
+ end
+
+ context 'when format is handled' do
+ let(:requested_format) { :json }
+
+ it 'returns 200 response' do
+ get :index, private_token: user.private_token, format: requested_format
+
+ expect(response).to have_http_status 200
+ end
+ end
+
+ context 'when format is not handled' do
+ it 'returns 404 response' do
+ get :index, private_token: user.private_token
+
+ expect(response).to have_http_status 404
+ end
+ end
+ end
+
+ describe '#authenticate_user_from_rss_token' do
+ describe "authenticating a user from an RSS token" do
+ controller(described_class) do
+ def index
+ render text: 'authenticated'
+ end
+ end
+
+ context "when the 'rss_token' param is populated with the RSS token" do
+ context 'when the request format is atom' do
+ it "logs the user in" do
+ get :index, rss_token: user.rss_token, format: :atom
+ expect(response).to have_http_status 200
+ expect(response.body).to eq 'authenticated'
+ end
+ end
+
+ context 'when the request format is not atom' do
+ it "doesn't log the user in" do
+ get :index, rss_token: user.rss_token
+ expect(response.status).not_to have_http_status 200
+ expect(response.body).not_to eq 'authenticated'
+ end
+ end
+ end
+
+ context "when the 'rss_token' param is populated with an invalid RSS token" do
+ it "doesn't log the user" do
+ get :index, rss_token: "token"
+ expect(response.status).not_to eq 200
+ expect(response.body).not_to eq 'authenticated'
+ end
+ end
+ end
+ end
+
describe '#route_not_found' do
it 'renders 404 if authenticated' do
allow(controller).to receive(:current_user).and_return(user)
@@ -106,16 +205,15 @@ describe ApplicationController do
controller.send(:route_not_found)
end
- it 'does redirect to login page if not authenticated' do
+ it 'does redirect to login page via authenticate_user! if not authenticated' do
allow(controller).to receive(:current_user).and_return(nil)
- expect(controller).to receive(:redirect_to)
- expect(controller).to receive(:new_user_session_path)
+ expect(controller).to receive(:authenticate_user!)
controller.send(:route_not_found)
end
end
context 'two-factor authentication' do
- let(:controller) { ApplicationController.new }
+ let(:controller) { described_class.new }
describe '#check_two_factor_requirement' do
subject { controller.send :check_two_factor_requirement }
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index 7d2f6dd9d0a..2fbab1e4040 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
describe AutocompleteController do
- let!(:project) { create(:empty_project) }
- let!(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:user) { project.owner }
context 'GET users' do
let!(:user2) { create(:user) }
@@ -11,7 +11,6 @@ describe AutocompleteController do
context 'project members' do
before do
sign_in(user)
- project.add_master(user)
end
describe 'GET #users with project ID' do
@@ -19,11 +18,11 @@ describe AutocompleteController do
get(:users, project_id: project.id)
end
- let(:body) { JSON.parse(response.body) }
-
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
- it { expect(body.map { |u| u["username"] }).to include(user.username) }
+ it 'returns the project members' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ expect(json_response.map { |u| u["username"] }).to include(user.username)
+ end
end
describe 'GET #users with unknown project' do
@@ -39,20 +38,20 @@ describe AutocompleteController do
let(:group) { create(:group) }
before do
- sign_in(user)
group.add_owner(user)
+ sign_in(user)
end
- let(:body) { JSON.parse(response.body) }
-
describe 'GET #users with group ID' do
before do
get(:users, group_id: group.id)
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
- it { expect(body.first["username"]).to eq user.username }
+ it 'returns the group members' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ expect(json_response.first["username"]).to eq user.username
+ end
end
describe 'GET #users with unknown group ID' do
@@ -65,23 +64,22 @@ describe AutocompleteController do
end
context 'non-member login for public project' do
- let!(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public) }
before do
sign_in(non_member)
- project.add_master(user)
end
- let(:body) { JSON.parse(response.body) }
-
describe 'GET #users with project ID' do
before do
get(:users, project_id: project.id, current_user: true)
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 2 }
- it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) }
+ it 'returns the project members and non-members' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |u| u['username'] }).to include(user.username, non_member.username)
+ end
end
end
@@ -91,15 +89,39 @@ describe AutocompleteController do
get(:users)
end
- let(:body) { JSON.parse(response.body) }
+ it { expect(json_response).to be_kind_of(Array) }
+ it { expect(json_response.size).to eq User.count }
+ end
+
+ context 'user order' do
+ it 'shows exact matches first' do
+ reported_user = create(:user, username: 'reported_user', name: 'Doug')
+ user = create(:user, username: 'user', name: 'User')
+ user1 = create(:user, username: 'user1', name: 'Ian')
+
+ sign_in(user)
+ get(:users, search: 'user')
+
+ response_usernames = json_response.map { |user| user['username'] }
+
+ expect(response_usernames.take(3)).to match_array([user.username, reported_user.username, user1.username])
+ end
+ end
+
+ context 'limited users per page' do
+ let(:per_page) { 2 }
+
+ before do
+ sign_in(user)
+ get(:users, per_page: per_page)
+ end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq User.count }
+ it { expect(json_response).to be_kind_of(Array) }
+ it { expect(json_response.size).to eq(per_page) }
end
context 'unauthenticated user' do
let(:public_project) { create(:project, :public) }
- let(:body) { JSON.parse(response.body) }
describe 'GET #users with public project' do
before do
@@ -107,8 +129,8 @@ describe AutocompleteController do
get(:users, project_id: public_project.id)
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
+ it { expect(json_response).to be_kind_of(Array) }
+ it { expect(json_response.size).to eq 2 }
end
describe 'GET #users with project' do
@@ -141,8 +163,8 @@ describe AutocompleteController do
get(:users)
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 0 }
+ it { expect(json_response).to be_kind_of(Array) }
+ it { expect(json_response).to be_empty }
end
describe 'GET #users with todo filter' do
@@ -150,41 +172,50 @@ describe AutocompleteController do
get :users, todo_filter: true
expect(response.status).to eq 200
- expect(body).to be_kind_of(Array)
+ expect(json_response).to be_kind_of(Array)
end
end
end
context 'author of issuable included' do
- before do
- sign_in(user)
- end
+ context 'authenticated' do
+ before do
+ sign_in(user)
+ end
+
+ it 'includes the author' do
+ get(:users, author_id: non_member.id)
- let(:body) { JSON.parse(response.body) }
+ expect(json_response.first["username"]).to eq non_member.username
+ end
- it 'includes the author' do
- get(:users, author_id: non_member.id)
+ it 'rejects non existent user ids' do
+ get(:users, author_id: 99999)
- expect(body.first["username"]).to eq non_member.username
+ expect(json_response.collect { |u| u['id'] }).not_to include(99999)
+ end
end
- it 'rejects non existent user ids' do
- get(:users, author_id: 99999)
+ context 'without authenticating' do
+ it 'returns empty result' do
+ get(:users, author_id: non_member.id)
- expect(body.collect { |u| u['id'] }).not_to include(99999)
+ expect(json_response).to be_empty
+ end
end
end
context 'skip_users parameter included' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'skips the user IDs passed' do
get(:users, skip_users: [user, user2].map(&:id))
- other_user_ids = [non_member, project.owner, project.creator].map(&:id)
- response_user_ids = JSON.parse(response.body).map { |user| user['id'] }
+ response_user_ids = json_response.map { |user| user['id'] }
- expect(response_user_ids).to contain_exactly(*other_user_ids)
+ expect(response_user_ids).to contain_exactly(non_member.id)
end
end
end
@@ -208,17 +239,15 @@ describe AutocompleteController do
get(:projects, project_id: project.id)
end
- let(:body) { JSON.parse(response.body) }
+ it 'returns projects' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(2)
- it do
- expect(body).to be_kind_of(Array)
- expect(body.size).to eq 2
+ expect(json_response.first['id']).to eq(0)
+ expect(json_response.first['name_with_namespace']).to eq 'No project'
- expect(body.first['id']).to eq 0
- expect(body.first['name_with_namespace']).to eq 'No project'
-
- expect(body.last['id']).to eq authorized_project.id
- expect(body.last['name_with_namespace']).to eq authorized_project.name_with_namespace
+ expect(json_response.last['id']).to eq authorized_project.id
+ expect(json_response.last['name_with_namespace']).to eq authorized_project.name_with_namespace
end
end
end
@@ -234,14 +263,12 @@ describe AutocompleteController do
get(:projects, project_id: project.id, search: 'rugged')
end
- let(:body) { JSON.parse(response.body) }
-
- it do
- expect(body).to be_kind_of(Array)
- expect(body.size).to eq 2
+ it 'returns projects' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(2)
- expect(body.last['id']).to eq authorized_search_project.id
- expect(body.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace
+ expect(json_response.last['id']).to eq authorized_search_project.id
+ expect(json_response.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace
end
end
end
@@ -263,11 +290,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id)
end
- let(:body) { JSON.parse(response.body) }
-
- it do
- expect(body).to be_kind_of(Array)
- expect(body.size).to eq 3 # Of a total of 4
+ it 'returns projects' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq 3 # Of a total of 4
end
end
end
@@ -287,17 +312,15 @@ describe AutocompleteController do
get(:projects, project_id: project.id, offset_id: authorized_project.id)
end
- let(:body) { JSON.parse(response.body) }
-
- it do
- expect(body.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there
- expect(body.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either
+ it 'returns "No project"' do
+ expect(json_response.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there
+ expect(json_response.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either
end
end
end
context 'authorized projects without admin_issue ability' do
- before(:each) do
+ before do
authorized_project.add_guest(user)
expect(user.can?(:admin_issue, authorized_project)).to eq(false)
@@ -308,13 +331,10 @@ describe AutocompleteController do
get(:projects, project_id: project.id)
end
- let(:body) { JSON.parse(response.body) }
-
- it do
- expect(body).to be_kind_of(Array)
- expect(body.size).to eq 1 # 'No project'
-
- expect(body.first['id']).to eq 0
+ it 'returns a single "No project"' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1) # 'No project'
+ expect(json_response.first['id']).to eq 0
end
end
end
diff --git a/spec/controllers/blob_controller_spec.rb b/spec/controllers/blob_controller_spec.rb
deleted file mode 100644
index 44e011fd3a8..00000000000
--- a/spec/controllers/blob_controller_spec.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-require 'spec_helper'
-
-describe Projects::BlobController do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
-
- before do
- sign_in(user)
-
- project.team << [user, :master]
-
- allow(project).to receive(:branches).and_return(['master', 'foo/bar/baz'])
- allow(project).to receive(:tags).and_return(['v1.0.0', 'v2.0.0'])
- controller.instance_variable_set(:@project, project)
- end
-
- describe "GET show" do
- render_views
-
- before do
- get(:show,
- namespace_id: project.namespace,
- project_id: project,
- id: id)
- end
-
- context "valid branch, valid file" do
- let(:id) { 'master/README.md' }
- it { is_expected.to respond_with(:success) }
- end
-
- context "valid branch, invalid file" do
- let(:id) { 'master/invalid-path.rb' }
- it { is_expected.to respond_with(:not_found) }
- end
-
- context "invalid branch, valid file" do
- let(:id) { 'invalid-branch/README.md' }
- it { is_expected.to respond_with(:not_found) }
- end
-
- context "binary file" do
- let(:id) { 'binary-encoding/encoding/binary-1.bin' }
- it { is_expected.to respond_with(:success) }
- end
- end
-
- describe 'GET show with tree path' do
- render_views
-
- before do
- get(:show,
- namespace_id: project.namespace,
- project_id: project,
- id: id)
- controller.instance_variable_set(:@blob, nil)
- end
-
- context 'redirect to tree' do
- let(:id) { 'markdown/doc' }
- it 'redirects' do
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/markdown/doc")
- end
- end
- end
-end
diff --git a/spec/controllers/dashboard/labels_controller_spec.rb b/spec/controllers/dashboard/labels_controller_spec.rb
new file mode 100644
index 00000000000..a3bfb2f3a87
--- /dev/null
+++ b/spec/controllers/dashboard/labels_controller_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Dashboard::LabelsController do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let!(:label) { create(:label, project: project) }
+
+ before do
+ sign_in(user)
+ project.add_reporter(user)
+ end
+
+ describe "#index" do
+ let!(:unrelated_label) { create(:label, project: create(:project, :public)) }
+
+ it 'returns global labels for projects the user has a relationship with' do
+ get :index, format: :json
+
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ expect(json_response[0]["id"]).to be_nil
+ expect(json_response[0]["title"]).to eq(label.title)
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/milestones_controller_spec.rb b/spec/controllers/dashboard/milestones_controller_spec.rb
new file mode 100644
index 00000000000..2dcb67d50f4
--- /dev/null
+++ b/spec/controllers/dashboard/milestones_controller_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Dashboard::MilestonesController do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:project_milestone) { create(:milestone, project: project) }
+ let(:milestone) do
+ DashboardMilestone.build(
+ [project],
+ project_milestone.title
+ )
+ end
+ let(:issue) { create(:issue, project: project, milestone: project_milestone) }
+ let!(:label) { create(:label, project: project, title: 'Issue Label', issues: [issue]) }
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: project_milestone) }
+ let(:milestone_path) { dashboard_milestone_path(milestone.safe_title, title: milestone.title) }
+
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ it_behaves_like 'milestone tabs'
+
+ describe "#show" do
+ render_views
+
+ def view_milestone
+ get :show, id: milestone.safe_title, title: milestone.title
+ end
+
+ it 'shows milestone page' do
+ view_milestone
+
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb
index 762e90f4a16..c8c6b9f41bf 100644
--- a/spec/controllers/dashboard/todos_controller_spec.rb
+++ b/spec/controllers/dashboard/todos_controller_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Dashboard::TodosController do
let(:user) { create(:user) }
let(:author) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:todo_service) { TodoService.new }
before do
@@ -12,9 +12,39 @@ describe Dashboard::TodosController do
end
describe 'GET #index' do
+ context 'project authorization' do
+ it 'renders 404 when user does not have read access on given project' do
+ unauthorized_project = create(:project, :private)
+
+ get :index, project_id: unauthorized_project.id
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'renders 404 when given project does not exists' do
+ get :index, project_id: 999
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'renders 200 when filtering for "any project" todos' do
+ get :index, project_id: ''
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'renders 200 when user has access on given project' do
+ authorized_project = create(:project, :public)
+
+ get :index, project_id: authorized_project.id
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
context 'when using pagination' do
let(:last_page) { user.todos.page.total_pages }
- let!(:issues) { create_list(:issue, 2, project: project, assignee: user) }
+ let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) }
before do
issues.each { |issue| todo_service.new_issue(issue, user) }
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb
index 9dceeca168d..2845f258f6f 100644
--- a/spec/controllers/explore/projects_controller_spec.rb
+++ b/spec/controllers/explore/projects_controller_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
describe Explore::ProjectsController do
describe 'GET #trending' do
context 'sorting by update date' do
- let(:project1) { create(:empty_project, :public, updated_at: 3.days.ago) }
- let(:project2) { create(:empty_project, :public, updated_at: 1.day.ago) }
+ let(:project1) { create(:project, :public, updated_at: 3.days.ago) }
+ let(:project2) { create(:project, :public, updated_at: 1.day.ago) }
before do
create(:trending_project, project: project1)
diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb
index 60db0192dfd..cce53f6697c 100644
--- a/spec/controllers/groups/group_members_controller_spec.rb
+++ b/spec/controllers/groups/group_members_controller_spec.rb
@@ -16,10 +16,14 @@ describe Groups::GroupMembersController do
describe 'POST create' do
let(:group_user) { create(:user) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when user does not have enough rights' do
- before { group.add_developer(user) }
+ before do
+ group.add_developer(user)
+ end
it 'returns 403' do
post :create, group_id: group,
@@ -32,7 +36,9 @@ describe Groups::GroupMembersController do
end
context 'when user has enough rights' do
- before { group.add_owner(user) }
+ before do
+ group.add_owner(user)
+ end
it 'adds user to members' do
post :create, group_id: group,
@@ -59,7 +65,9 @@ describe Groups::GroupMembersController do
describe 'DELETE destroy' do
let(:member) { create(:group_member, :developer, group: group) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 403' do
@@ -71,7 +79,9 @@ describe Groups::GroupMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
- before { group.add_developer(user) }
+ before do
+ group.add_developer(user)
+ end
it 'returns 403' do
delete :destroy, group_id: group, id: member
@@ -82,7 +92,9 @@ describe Groups::GroupMembersController do
end
context 'when user has enough rights' do
- before { group.add_owner(user) }
+ before do
+ group.add_owner(user)
+ end
it '[HTML] removes user from members' do
delete :destroy, group_id: group, id: member
@@ -103,7 +115,9 @@ describe Groups::GroupMembersController do
end
describe 'DELETE leave' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 404' do
@@ -115,7 +129,9 @@ describe Groups::GroupMembersController do
context 'when member is found' do
context 'and is not an owner' do
- before { group.add_developer(user) }
+ before do
+ group.add_developer(user)
+ end
it 'removes user from members' do
delete :leave, group_id: group
@@ -124,10 +140,19 @@ describe Groups::GroupMembersController do
expect(response).to redirect_to(dashboard_groups_path)
expect(group.users).not_to include user
end
+
+ it 'supports json request' do
+ delete :leave, group_id: group, format: :json
+
+ expect(response).to have_http_status(200)
+ expect(json_response['notice']).to eq "You left the \"#{group.name}\" group."
+ end
end
context 'and is an owner' do
- before { group.add_owner(user) }
+ before do
+ group.add_owner(user)
+ end
it 'cannot removes himself from the group' do
delete :leave, group_id: group
@@ -137,7 +162,9 @@ describe Groups::GroupMembersController do
end
context 'and is a requester' do
- before { group.request_access(user) }
+ before do
+ group.request_access(user)
+ end
it 'removes user from members' do
delete :leave, group_id: group
@@ -152,7 +179,9 @@ describe Groups::GroupMembersController do
end
describe 'POST request_access' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'creates a new GroupMember that is not a team member' do
post :request_access, group_id: group
@@ -167,7 +196,9 @@ describe Groups::GroupMembersController do
describe 'POST approve_access_request' do
let(:member) { create(:group_member, :access_request, group: group) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 403' do
@@ -179,7 +210,9 @@ describe Groups::GroupMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
- before { group.add_developer(user) }
+ before do
+ group.add_developer(user)
+ end
it 'returns 403' do
post :approve_access_request, group_id: group, id: member
@@ -190,7 +223,9 @@ describe Groups::GroupMembersController do
end
context 'when user has enough rights' do
- before { group.add_owner(user) }
+ before do
+ group.add_owner(user)
+ end
it 'adds user to members' do
post :approve_access_request, group_id: group, id: member
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index 6e4b5f78e33..fbbc67f3ae0 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -2,32 +2,272 @@ require 'spec_helper'
describe Groups::MilestonesController do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, group: group) }
- let(:project2) { create(:empty_project, group: group) }
+ let!(:project) { create(:project, group: group) }
+ let!(:project2) { create(:project, group: group) }
let(:user) { create(:user) }
let(:title) { '肯定ä¸æ˜¯ä¸­æ–‡çš„问题' }
+ let(:milestone) do
+ project_milestone = create(:milestone, project: project)
+
+ GroupMilestone.build(
+ group,
+ [project],
+ project_milestone.title
+ )
+ end
+ let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) }
+
+ let(:milestone_params) do
+ {
+ title: title,
+ start_date: Date.today,
+ due_date: 1.month.from_now.to_date
+ }
+ end
before do
sign_in(user)
group.add_owner(user)
project.team << [user, :master]
- controller.instance_variable_set(:@group, group)
end
+ describe '#index' do
+ it 'shows group milestones page' do
+ get :index, group_id: group.to_param
+
+ expect(response).to have_http_status(200)
+ end
+
+ context 'as JSON' do
+ let!(:milestone) { create(:milestone, group: group, title: 'group milestone') }
+ let!(:legacy_milestone1) { create(:milestone, project: project, title: 'legacy') }
+ let!(:legacy_milestone2) { create(:milestone, project: project2, title: 'legacy') }
+
+ it 'lists legacy group milestones and group milestones' do
+ get :index, group_id: group.to_param, format: :json
+
+ milestones = JSON.parse(response.body)
+
+ expect(milestones.count).to eq(2)
+ expect(milestones.first["title"]).to eq("group milestone")
+ expect(milestones.second["title"]).to eq("legacy")
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq 'application/json'
+ end
+ end
+ end
+
+ describe '#show' do
+ let(:milestone1) { create(:milestone, project: project, title: 'legacy') }
+ let(:milestone2) { create(:milestone, project: project, title: 'legacy') }
+ let(:group_milestone) { create(:milestone, group: group) }
+
+ context 'when there is a title parameter' do
+ it 'searchs for a legacy group milestone' do
+ expect(GlobalMilestone).to receive(:build)
+ expect(Milestone).not_to receive(:find_by_iid)
+
+ get :show, group_id: group.to_param, id: title, title: milestone1.safe_title
+ end
+ end
+
+ context 'when there is not a title parameter' do
+ it 'searchs for a group milestone' do
+ expect(GlobalMilestone).not_to receive(:build)
+ expect(Milestone).to receive(:find_by_iid)
+
+ get :show, group_id: group.to_param, id: group_milestone.id
+ end
+ end
+ end
+
+ it_behaves_like 'milestone tabs'
+
describe "#create" do
it "creates group milestone with Chinese title" do
post :create,
- group_id: group.id,
- milestone: { project_ids: [project.id, project2.id], title: title }
+ group_id: group.to_param,
+ milestone: milestone_params
+
+ milestone = Milestone.find_by_title(title)
- expect(response).to redirect_to(group_milestone_path(group, title.to_slug.to_s, title: title))
- expect(Milestone.where(title: title).count).to eq(2)
+ expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
+ expect(milestone.group_id).to eq(group.id)
+ expect(milestone.due_date).to eq(milestone_params[:due_date])
+ expect(milestone.start_date).to eq(milestone_params[:start_date])
end
+ end
+
+ describe "#update" do
+ let(:milestone) { create(:milestone, group: group) }
+
+ it "updates group milestone" do
+ milestone_params[:title] = "title changed"
+
+ put :update,
+ id: milestone.iid,
+ group_id: group.to_param,
+ milestone: milestone_params
- it "redirects to new when there are no project ids" do
- post :create, group_id: group.id, milestone: { title: title, project_ids: [""] }
- expect(response).to render_template :new
- expect(assigns(:milestone).errors).not_to be_nil
+ milestone.reload
+ expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
+ expect(milestone.title).to eq("title changed")
end
+
+ context "legacy group milestones" do
+ let!(:milestone1) { create(:milestone, project: project, title: 'legacy milestone', description: "old description") }
+ let!(:milestone2) { create(:milestone, project: project2, title: 'legacy milestone', description: "old description") }
+
+ it "updates only group milestones state" do
+ milestone_params[:title] = "title changed"
+ milestone_params[:description] = "description changed"
+ milestone_params[:state_event] = "close"
+
+ put :update,
+ id: milestone1.title.to_slug.to_s,
+ group_id: group.to_param,
+ milestone: milestone_params,
+ title: milestone1.title
+
+ expect(response).to redirect_to(group_milestone_path(group, milestone1.safe_title, title: milestone1.title))
+
+ [milestone1, milestone2].each do |milestone|
+ milestone.reload
+ expect(milestone.title).to eq("legacy milestone")
+ expect(milestone.description).to eq("old description")
+ expect(milestone.state).to eq("closed")
+ end
+ end
+ end
+ end
+
+ describe '#ensure_canonical_path' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a GET request' do
+ context 'when requesting the canonical path' do
+ context 'non-show path' do
+ context 'with exactly matching casing' do
+ it 'does not redirect' do
+ get :index, group_id: group.to_param
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :index, group_id: group.to_param.upcase
+
+ expect(response).to redirect_to(group_milestones_path(group.to_param))
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+
+ context 'show path' do
+ context 'with exactly matching casing' do
+ it 'does not redirect' do
+ get :show, group_id: group.to_param, id: title
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :show, group_id: group.to_param.upcase, id: title
+
+ expect(response).to redirect_to(group_milestone_path(group.to_param, title))
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+
+ it 'redirects to the canonical path' do
+ get :merge_requests, group_id: redirect_route.path, id: title
+
+ expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+
+ context 'when the old group path is a substring of the scheme or host' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'http') }
+
+ it 'does not modify the requested host' do
+ get :merge_requests, group_id: redirect_route.path, id: title
+
+ expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+
+ context 'when the old group path is substring of groups' do
+ # I.e. /groups/oups should not become /grfoo/oups
+ let(:redirect_route) { group.redirect_routes.create(path: 'oups') }
+
+ it 'does not modify the /groups part of the path' do
+ get :merge_requests, group_id: redirect_route.path, id: title
+
+ expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+
+ context 'when the old group path is substring of groups plus the new path' do
+ # I.e. /groups/oups/oup should not become /grfoos
+ let(:redirect_route) { group.redirect_routes.create(path: 'oups/oup') }
+
+ it 'does not modify the /groups part of the path' do
+ get :merge_requests, group_id: redirect_route.path, id: title
+
+ expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+ end
+ end
+ end
+
+ context 'for a non-GET request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :create,
+ group_id: group.to_param,
+ milestone: { title: title }
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ post :create,
+ group_id: group.to_param,
+ milestone: { title: title }
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+
+ it 'returns not found' do
+ post :create,
+ group_id: redirect_route.path,
+ milestone: { title: title }
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ def group_moved_message(redirect_route, group)
+ "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
end
end
diff --git a/spec/controllers/groups/settings/ci_cd_controller_spec.rb b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
new file mode 100644
index 00000000000..2e0efb57c74
--- /dev/null
+++ b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Groups::Settings::CiCdController do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ group.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ it 'renders show with 200 status code' do
+ get :show, group_id: group
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template(:show)
+ end
+ end
+end
diff --git a/spec/controllers/groups/variables_controller_spec.rb b/spec/controllers/groups/variables_controller_spec.rb
new file mode 100644
index 00000000000..02f2fa46047
--- /dev/null
+++ b/spec/controllers/groups/variables_controller_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe Groups::VariablesController do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ group.add_master(user)
+ end
+
+ describe 'POST #create' do
+ context 'variable is valid' do
+ it 'shows a success flash message' do
+ post :create, group_id: group, variable: { key: "one", value: "two" }
+
+ expect(flash[:notice]).to include 'Variable was successfully created.'
+ expect(response).to redirect_to(group_settings_ci_cd_path(group))
+ end
+ end
+
+ context 'variable is invalid' do
+ it 'renders show' do
+ post :create, group_id: group, variable: { key: "..one", value: "two" }
+
+ expect(response).to render_template("groups/variables/show")
+ end
+ end
+ end
+
+ describe 'POST #update' do
+ let(:variable) { create(:ci_group_variable) }
+
+ context 'updating a variable with valid characters' do
+ before do
+ group.variables << variable
+ end
+
+ it 'shows a success flash message' do
+ post :update, group_id: group,
+ id: variable.id, variable: { key: variable.key, value: 'two' }
+
+ expect(flash[:notice]).to include 'Variable was successfully updated.'
+ expect(response).to redirect_to(group_variables_path(group))
+ end
+
+ it 'renders the action #show if the variable key is invalid' do
+ post :update, group_id: group,
+ id: variable.id, variable: { key: '?', value: variable.value }
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template :show
+ end
+ end
+ end
+end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index cad82a34fb0..c2ada8c8df7 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -2,8 +2,8 @@ require 'rails_helper'
describe GroupsController do
let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
describe 'GET #index' do
@@ -26,6 +26,42 @@ describe GroupsController do
end
end
+ describe 'GET #subgroups', :nested_groups do
+ let!(:public_subgroup) { create(:group, :public, parent: group) }
+ let!(:private_subgroup) { create(:group, :private, parent: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all subgroups' do
+ get :subgroups, id: group.to_param
+
+ expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private subgroups the user is member of' do
+ group_member.destroy!
+ private_subgroup.add_guest(user)
+
+ get :subgroups, id: group.to_param
+
+ expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public subgroups' do
+ get :subgroups, id: group.to_param
+
+ expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
+ end
+ end
+ end
+
describe 'GET #issues' do
let(:issue_1) { create(:issue, project: project) }
let(:issue_2) { create(:issue, project: project) }
@@ -33,7 +69,7 @@ describe GroupsController do
before do
create_list(:award_emoji, 3, awardable: issue_2)
create_list(:award_emoji, 2, awardable: issue_1)
- create_list(:award_emoji, 2, :downvote, awardable: issue_2,)
+ create_list(:award_emoji, 2, :downvote, awardable: issue_2)
sign_in(user)
end
@@ -81,7 +117,7 @@ describe GroupsController do
it 'returns 404' do
sign_in(create(:user))
- delete :destroy, id: group.path
+ delete :destroy, id: group.to_param
expect(response.status).to eq(404)
end
@@ -94,12 +130,12 @@ describe GroupsController do
it 'schedules a group destroy' do
Sidekiq::Testing.fake! do
- expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1)
+ expect { delete :destroy, id: group.to_param }.to change(GroupDestroyWorker.jobs, :size).by(1)
end
end
it 'redirects to the root path' do
- delete :destroy, id: group.path
+ delete :destroy, id: group.to_param
expect(response).to redirect_to(root_path)
end
@@ -111,7 +147,7 @@ describe GroupsController do
sign_in(user)
end
- it 'updates the path succesfully' do
+ it 'updates the path successfully' do
post :update, id: group.to_param, group: { path: 'new_path' }
expect(response).to have_http_status(302)
@@ -126,4 +162,201 @@ describe GroupsController do
expect(assigns(:group).path).not_to eq('new_path')
end
end
+
+ describe '#ensure_canonical_path' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a GET request' do
+ context 'when requesting groups at the root path' do
+ before do
+ allow(request).to receive(:original_fullpath).and_return("/#{group_full_path}")
+ get :show, id: group_full_path
+ end
+
+ context 'when requesting the canonical path with different casing' do
+ let(:group_full_path) { group.to_param.upcase }
+
+ it 'redirects to the correct casing' do
+ expect(response).to redirect_to(group)
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ let(:group_full_path) { redirect_route.path }
+
+ it 'redirects to the canonical path' do
+ expect(response).to redirect_to(group)
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+
+ context 'when the old group path is a substring of the scheme or host' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'http') }
+
+ it 'does not modify the requested host' do
+ expect(response).to redirect_to(group)
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+
+ context 'when the old group path is substring of groups' do
+ # I.e. /groups/oups should not become /grfoo/oups
+ let(:redirect_route) { group.redirect_routes.create(path: 'oups') }
+
+ it 'does not modify the /groups part of the path' do
+ expect(response).to redirect_to(group)
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+ end
+ end
+
+ context 'when requesting groups under the /groups path' do
+ context 'when requesting the canonical path' do
+ context 'non-show path' do
+ context 'with exactly matching casing' do
+ it 'does not redirect' do
+ get :issues, id: group.to_param
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :issues, id: group.to_param.upcase
+
+ expect(response).to redirect_to(issues_group_path(group.to_param))
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+
+ context 'show path' do
+ context 'with exactly matching casing' do
+ it 'does not redirect' do
+ get :show, id: group.to_param
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing at the root path' do
+ get :show, id: group.to_param.upcase
+
+ expect(response).to redirect_to(group)
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+
+ it 'redirects to the canonical path' do
+ get :issues, id: redirect_route.path
+
+ expect(response).to redirect_to(issues_group_path(group.to_param))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+
+ context 'when the old group path is a substring of the scheme or host' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'http') }
+
+ it 'does not modify the requested host' do
+ get :issues, id: redirect_route.path
+
+ expect(response).to redirect_to(issues_group_path(group.to_param))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+
+ context 'when the old group path is substring of groups' do
+ # I.e. /groups/oups should not become /grfoo/oups
+ let(:redirect_route) { group.redirect_routes.create(path: 'oups') }
+
+ it 'does not modify the /groups part of the path' do
+ get :issues, id: redirect_route.path
+
+ expect(response).to redirect_to(issues_group_path(group.to_param))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+
+ context 'when the old group path is substring of groups plus the new path' do
+ # I.e. /groups/oups/oup should not become /grfoos
+ let(:redirect_route) { group.redirect_routes.create(path: 'oups/oup') }
+
+ it 'does not modify the /groups part of the path' do
+ get :issues, id: redirect_route.path
+
+ expect(response).to redirect_to(issues_group_path(group.to_param))
+ expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group))
+ end
+ end
+ end
+ end
+ end
+
+ context 'for a POST request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+
+ it 'returns not found' do
+ post :update, id: redirect_route.path, group: { path: 'new_path' }
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ context 'for a DELETE request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ delete :destroy, id: group.to_param.upcase
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ delete :destroy, id: group.to_param.upcase
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+
+ it 'returns not found' do
+ delete :destroy, id: redirect_route.path
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+
+ def group_moved_message(redirect_route, group)
+ "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
diff --git a/spec/controllers/health_check_controller_spec.rb b/spec/controllers/health_check_controller_spec.rb
index 58c16cc57e6..03da6287774 100644
--- a/spec/controllers/health_check_controller_spec.rb
+++ b/spec/controllers/health_check_controller_spec.rb
@@ -3,52 +3,79 @@ require 'spec_helper'
describe HealthCheckController do
include StubENV
- let(:token) { current_application_settings.health_check_access_token }
let(:json_response) { JSON.parse(response.body) }
let(:xml_response) { Hash.from_xml(response.body)['hash'] }
+ let(:token) { current_application_settings.health_check_access_token }
+ let(:whitelisted_ip) { '127.0.0.1' }
+ let(:not_whitelisted_ip) { '127.0.0.2' }
before do
+ allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip])
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
end
describe 'GET #index' do
- context 'when services are up but NO access token' do
+ context 'when services are up but accessed from outside whitelisted ips' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip)
+ end
+
it 'returns a not found page' do
get :index
+
expect(response).to be_not_found
end
+
+ context 'when services are accessed with token' do
+ it 'supports passing the token in the header' do
+ request.headers['TOKEN'] = token
+
+ get :index
+
+ expect(response).to be_success
+ expect(response.content_type).to eq 'text/plain'
+ end
+
+ it 'supports passing the token in query params' do
+ get :index, token: token
+
+ expect(response).to be_success
+ expect(response.content_type).to eq 'text/plain'
+ end
+ end
end
- context 'when services are up and an access token is provided' do
- it 'supports passing the token in the header' do
- request.headers['TOKEN'] = token
- get :index
- expect(response).to be_success
- expect(response.content_type).to eq 'text/plain'
+ context 'when services are up and accessed from whitelisted ips' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
end
- it 'supports successful plaintest response' do
- get :index, token: token
+ it 'supports successful plaintext response' do
+ get :index
+
expect(response).to be_success
expect(response.content_type).to eq 'text/plain'
end
it 'supports successful json response' do
- get :index, token: token, format: :json
+ get :index, format: :json
+
expect(response).to be_success
expect(response.content_type).to eq 'application/json'
expect(json_response['healthy']).to be true
end
it 'supports successful xml response' do
- get :index, token: token, format: :xml
+ get :index, format: :xml
+
expect(response).to be_success
expect(response.content_type).to eq 'application/xml'
expect(xml_response['healthy']).to be true
end
it 'supports successful responses for specific checks' do
- get :index, token: token, checks: 'email', format: :json
+ get :index, checks: 'email', format: :json
+
expect(response).to be_success
expect(response.content_type).to eq 'application/json'
expect(json_response['healthy']).to be true
@@ -58,33 +85,29 @@ describe HealthCheckController do
context 'when a service is down but NO access token' do
it 'returns a not found page' do
get :index
+
expect(response).to be_not_found
end
end
- context 'when a service is down and an access token is provided' do
+ context 'when a service is down and an endpoint is accessed from whitelisted ip' do
before do
allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire')
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
end
- it 'supports passing the token in the header' do
- request.headers['TOKEN'] = token
+ it 'supports failure plaintext response' do
get :index
- expect(response).to have_http_status(500)
- expect(response.content_type).to eq 'text/plain'
- expect(response.body).to include('The server is on fire')
- end
- it 'supports failure plaintest response' do
- get :index, token: token
expect(response).to have_http_status(500)
expect(response.content_type).to eq 'text/plain'
expect(response.body).to include('The server is on fire')
end
it 'supports failure json response' do
- get :index, token: token, format: :json
+ get :index, format: :json
+
expect(response).to have_http_status(500)
expect(response.content_type).to eq 'application/json'
expect(json_response['healthy']).to be false
@@ -92,7 +115,8 @@ describe HealthCheckController do
end
it 'supports failure xml response' do
- get :index, token: token, format: :xml
+ get :index, format: :xml
+
expect(response).to have_http_status(500)
expect(response.content_type).to eq 'application/xml'
expect(xml_response['healthy']).to be false
@@ -100,7 +124,8 @@ describe HealthCheckController do
end
it 'supports failure responses for specific checks' do
- get :index, token: token, checks: 'email', format: :json
+ get :index, checks: 'email', format: :json
+
expect(response).to have_http_status(500)
expect(response.content_type).to eq 'application/json'
expect(json_response['healthy']).to be false
diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb
index b8b6e0c3a88..cc389e554ad 100644
--- a/spec/controllers/health_controller_spec.rb
+++ b/spec/controllers/health_controller_spec.rb
@@ -3,93 +3,119 @@ require 'spec_helper'
describe HealthController do
include StubENV
- let(:token) { current_application_settings.health_check_access_token }
let(:json_response) { JSON.parse(response.body) }
+ let(:token) { current_application_settings.health_check_access_token }
+ let(:whitelisted_ip) { '127.0.0.1' }
+ let(:not_whitelisted_ip) { '127.0.0.2' }
before do
+ allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip])
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
end
describe '#readiness' do
- context 'authorization token provided' do
- before do
- request.headers['TOKEN'] = token
- end
+ shared_context 'endpoint responding with readiness data' do
+ let(:request_params) { {} }
+
+ subject { get :readiness, request_params }
+
+ it 'responds with readiness checks data' do
+ subject
- 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['cache_check']['status']).to eq('ok')
+ expect(json_response['queues_check']['status']).to eq('ok')
+ expect(json_response['shared_state_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
+ context 'accessed from whitelisted ip' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
+ end
+
+ it_behaves_like 'endpoint responding with readiness data'
+ end
+
+ context 'accessed from not whitelisted ip' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip)
+ end
+
+ it 'responds with resource not found' do
get :readiness
+
expect(response.status).to eq(404)
end
+
+ context 'accessed with valid token' do
+ context 'token passed in request header' do
+ before do
+ request.headers['TOKEN'] = token
+ end
+
+ it_behaves_like 'endpoint responding with readiness data'
+ end
+ end
+
+ context 'token passed as URL param' do
+ it_behaves_like 'endpoint responding with readiness data' do
+ let(:request_params) { { token: token } }
+ end
+ end
end
end
describe '#liveness' do
- context 'authorization token provided' do
- before do
- request.headers['TOKEN'] = token
- end
+ shared_context 'endpoint responding with liveness data' do
+ subject { get :liveness }
+
+ it 'responds with liveness checks data' do
+ subject
- 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['cache_check']['status']).to eq('ok')
+ expect(json_response['queues_check']['status']).to eq('ok')
+ expect(json_response['shared_state_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)
+ context 'accessed from whitelisted ip' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
end
+
+ it_behaves_like 'endpoint responding with liveness data'
end
- end
- describe '#metrics' do
- context 'authorization token provided' do
+ context 'accessed from not whitelisted ip' do
before do
- request.headers['TOKEN'] = token
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip)
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 'responds with resource not found' do
+ get :liveness
- 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\.]+$/)
+ expect(response.status).to eq(404)
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 'accessed with valid token' do
+ context 'token passed in request header' do
+ before do
+ request.headers['TOKEN'] = token
+ end
- context 'without authorization token' do
- it 'returns proper response' do
- get :metrics
- expect(response.status).to eq(404)
+ it_behaves_like 'endpoint responding with liveness data'
+ end
+
+ context 'token passed as URL param' do
+ it_behaves_like 'endpoint responding with liveness data' do
+ subject { get :liveness, token: token }
+ end
+ end
end
end
end
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index 010e3180ea4..e8707760a5a 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -31,8 +31,8 @@ describe Import::BitbucketController do
expires_at: expires_at,
expires_in: expires_in,
refresh_token: refresh_token)
- allow_any_instance_of(OAuth2::Client).
- to receive(:get_token).and_return(access_token)
+ allow_any_instance_of(OAuth2::Client)
+ .to receive(:get_token).and_return(access_token)
stub_omniauth_provider('bitbucket')
get :callback
@@ -52,7 +52,7 @@ describe Import::BitbucketController do
end
it "assigns variables" do
- @project = create(:empty_project, import_type: 'bitbucket', creator_id: user.id)
+ @project = create(:project, import_type: 'bitbucket', creator_id: user.id)
allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo])
get :status
@@ -63,7 +63,7 @@ describe Import::BitbucketController do
end
it "does not show already added project" do
- @project = create(:empty_project, import_type: 'bitbucket', creator_id: user.id, import_source: 'asd/vim')
+ @project = create(:project, import_type: 'bitbucket', creator_id: user.id, import_source: 'asd/vim')
allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo])
get :status
@@ -93,9 +93,9 @@ describe Import::BitbucketController do
context "when the repository owner is the Bitbucket user" do
context "when the Bitbucket user and GitLab user's usernames match" do
it "takes the current user's namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -105,9 +105,9 @@ describe Import::BitbucketController do
let(:bitbucket_username) { "someone_else" }
it "takes the current user's namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -133,27 +133,26 @@ describe Import::BitbucketController do
end
context "when a namespace with the Bitbucket user's username already exists" do
- let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) }
+ let!(:existing_namespace) { create(:group, name: other_username) }
context "when the namespace is owned by the GitLab user" do
+ before do
+ existing_namespace.add_owner(user)
+ end
+
it "takes the existing namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, bitbucket_repo.name, existing_namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, existing_namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
end
context "when the namespace is not owned by the GitLab user" do
- before do
- existing_namespace.owner = create(:user)
- existing_namespace.save
- end
-
it "doesn't create a project" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- not_to receive(:new)
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .not_to receive(:new)
post :create, format: :js
end
@@ -163,16 +162,16 @@ describe Import::BitbucketController do
context "when a namespace with the Bitbucket user's username doesn't exist" do
context "when current user can create namespaces" do
it "creates the namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, format: :js }.to change(Namespace, :count).by(1)
end
it "takes the new namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, bitbucket_repo.name, an_instance_of(Group), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, an_instance_of(Group), user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -184,16 +183,16 @@ describe Import::BitbucketController do
end
it "doesn't create the namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, format: :js }.not_to change(Namespace, :count)
end
it "takes the current user's namespace" do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -202,14 +201,18 @@ describe Import::BitbucketController do
end
context 'user has chosen an existing nested namespace and name for the project' do
- let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
- let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) }
+ let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
let(:test_name) { 'test_name' }
+ before do
+ nested_namespace.add_owner(user)
+ end
+
it 'takes the selected namespace and name' do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, nested_namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, nested_namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :js }
end
@@ -219,26 +222,26 @@ describe Import::BitbucketController do
let(:test_name) { 'test_name' }
it 'takes the selected namespace and name' do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js } }
.to change { Namespace.count }.by(2)
end
it 'new namespace has the right parent' do
- allow(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
@@ -248,20 +251,20 @@ describe Import::BitbucketController do
context 'user has chosen existent and non-existent nested namespaces and name for the project' do
let(:test_name) { 'test_name' }
- let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
+ let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
it 'takes the selected namespace and name' do
- expect(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::BitbucketImport::ProjectCreator).
- to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, test_name, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js } }
.to change { Namespace.count }.by(2)
diff --git a/spec/controllers/import/fogbugz_controller_spec.rb b/spec/controllers/import/fogbugz_controller_spec.rb
index fffbc805335..5f0f6dea821 100644
--- a/spec/controllers/import/fogbugz_controller_spec.rb
+++ b/spec/controllers/import/fogbugz_controller_spec.rb
@@ -16,7 +16,7 @@ describe Import::FogbugzController do
end
it 'assigns variables' do
- @project = create(:empty_project, import_type: 'fogbugz', creator_id: user.id)
+ @project = create(:project, import_type: 'fogbugz', creator_id: user.id)
stub_client(repos: [@repo])
get :status
@@ -26,7 +26,7 @@ describe Import::FogbugzController do
end
it 'does not show already added project' do
- @project = create(:empty_project, import_type: 'fogbugz', creator_id: user.id, import_source: 'vim')
+ @project = create(:project, import_type: 'fogbugz', creator_id: user.id, import_source: 'vim')
stub_client(repos: [@repo])
get :status
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index 95696e14b6c..45c3fa075ef 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -21,10 +21,10 @@ describe Import::GithubController do
describe "GET callback" do
it "updates access token" do
token = "asdasd12345"
- allow_any_instance_of(Gitlab::GithubImport::Client).
- to receive(:get_token).and_return(token)
- allow_any_instance_of(Gitlab::GithubImport::Client).
- to receive(:github_options).and_return({})
+ allow_any_instance_of(Gitlab::GithubImport::Client)
+ .to receive(:get_token).and_return(token)
+ allow_any_instance_of(Gitlab::GithubImport::Client)
+ .to receive(:github_options).and_return({})
stub_omniauth_provider('github')
get :callback
diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb
index 2dbb89219d0..faf1e6f63ea 100644
--- a/spec/controllers/import/gitlab_controller_spec.rb
+++ b/spec/controllers/import/gitlab_controller_spec.rb
@@ -18,8 +18,8 @@ describe Import::GitlabController do
describe "GET callback" do
it "updates access token" do
- allow_any_instance_of(Gitlab::GitlabImport::Client).
- to receive(:get_token).and_return(token)
+ allow_any_instance_of(Gitlab::GitlabImport::Client)
+ .to receive(:get_token).and_return(token)
stub_omniauth_provider('gitlab')
get :callback
@@ -36,7 +36,7 @@ describe Import::GitlabController do
end
it "assigns variables" do
- @project = create(:empty_project, import_type: 'gitlab', creator_id: user.id)
+ @project = create(:project, import_type: 'gitlab', creator_id: user.id)
stub_client(projects: [@repo])
get :status
@@ -46,7 +46,7 @@ describe Import::GitlabController do
end
it "does not show already added project" do
- @project = create(:empty_project, import_type: 'gitlab', creator_id: user.id, import_source: 'asd/vim')
+ @project = create(:project, import_type: 'gitlab', creator_id: user.id, import_source: 'asd/vim')
stub_client(projects: [@repo])
get :status
@@ -78,9 +78,9 @@ describe Import::GitlabController do
context "when the repository owner is the GitLab.com user" do
context "when the GitLab.com user and GitLab server user's usernames match" do
it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -90,9 +90,9 @@ describe Import::GitlabController do
let(:gitlab_username) { "someone_else" }
it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -108,27 +108,26 @@ describe Import::GitlabController do
end
context "when a namespace with the GitLab.com user's username already exists" do
- let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) }
+ let!(:existing_namespace) { create(:group, name: other_username) }
context "when the namespace is owned by the GitLab server user" do
+ before do
+ existing_namespace.add_owner(user)
+ end
+
it "takes the existing namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, existing_namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, existing_namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
end
context "when the namespace is not owned by the GitLab server user" do
- before do
- existing_namespace.owner = create(:user)
- existing_namespace.save
- end
-
it "doesn't create a project" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- not_to receive(:new)
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .not_to receive(:new)
post :create, format: :js
end
@@ -138,16 +137,16 @@ describe Import::GitlabController do
context "when a namespace with the GitLab.com user's username doesn't exist" do
context "when current user can create namespaces" do
it "creates the namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, format: :js }.to change(Namespace, :count).by(1)
end
it "takes the new namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, an_instance_of(Group), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, an_instance_of(Group), user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -159,30 +158,34 @@ describe Import::GitlabController do
end
it "doesn't create the namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, format: :js }.not_to change(Namespace, :count)
end
it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, user.namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, format: :js
end
end
end
-
+
context 'user has chosen an existing nested namespace for the project' do
- let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
- let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) }
+ let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
+
+ before do
+ nested_namespace.add_owner(user)
+ end
it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, nested_namespace, user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, nested_namespace, user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: nested_namespace.full_path, format: :js }
end
@@ -192,26 +195,26 @@ describe Import::GitlabController do
let(:test_name) { 'test_name' }
it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/bar', format: :js } }
.to change { Namespace.count }.by(2)
end
it 'new namespace has the right parent' do
- allow(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', format: :js }
@@ -221,20 +224,20 @@ describe Import::GitlabController do
context 'user has chosen existent and non-existent nested namespaces and name for the project' do
let(:test_name) { 'test_name' }
- let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
+ let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ expect(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/foobar/bar', format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::GitlabImport::ProjectCreator).
- to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params).
- and_return(double(execute: true))
+ allow(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/foobar/bar', format: :js } }
.to change { Namespace.count }.by(2)
diff --git a/spec/controllers/import/google_code_controller_spec.rb b/spec/controllers/import/google_code_controller_spec.rb
index c96fb90f70e..4241db6e771 100644
--- a/spec/controllers/import/google_code_controller_spec.rb
+++ b/spec/controllers/import/google_code_controller_spec.rb
@@ -27,7 +27,7 @@ describe Import::GoogleCodeController do
end
it "assigns variables" do
- @project = create(:empty_project, import_type: 'google_code', creator_id: user.id)
+ @project = create(:project, import_type: 'google_code', creator_id: user.id)
stub_client(repos: [@repo], incompatible_repos: [])
get :status
@@ -38,7 +38,7 @@ describe Import::GoogleCodeController do
end
it "does not show already added project" do
- @project = create(:empty_project, import_type: 'google_code', creator_id: user.id, import_source: 'vim')
+ @project = create(:project, import_type: 'google_code', creator_id: user.id, import_source: 'vim')
stub_client(repos: [@repo], incompatible_repos: [])
get :status
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index e478a253b3f..e00403118a0 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -24,7 +24,7 @@ describe InvitesController do
describe 'GET #decline' do
it 'declines user' do
get :decline, id: token
- expect{member.reload}.to raise_error ActiveRecord::RecordNotFound
+ expect {member.reload}.to raise_error ActiveRecord::RecordNotFound
expect(response).to have_http_status(302)
expect(flash[:notice]).to include 'You have declined the invitation to join'
diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb
new file mode 100644
index 00000000000..7b0976e3e67
--- /dev/null
+++ b/spec/controllers/metrics_controller_spec.rb
@@ -0,0 +1,114 @@
+require 'spec_helper'
+
+describe MetricsController do
+ include StubENV
+
+ let(:json_response) { JSON.parse(response.body) }
+ let(:metrics_multiproc_dir) { Dir.mktmpdir }
+ let(:whitelisted_ip) { '127.0.0.1' }
+ let(:whitelisted_ip_range) { '10.0.0.0/24' }
+ let(:ip_in_whitelisted_range) { '10.0.0.1' }
+ let(:not_whitelisted_ip) { '10.0.1.1' }
+
+ before do
+ stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ allow(Prometheus::Client.configuration).to receive(:multiprocess_files_dir).and_return(metrics_multiproc_dir)
+ allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(true)
+ allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip, whitelisted_ip_range])
+ end
+
+ describe '#index' do
+ shared_examples_for 'endpoint providing metrics' do
+ it 'returns DB ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^db_ping_timeout 0$/)
+ expect(response.body).to match(/^db_ping_success 1$/)
+ expect(response.body).to match(/^db_ping_latency_seconds [0-9\.]+$/)
+ end
+
+ it 'returns Redis ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^redis_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_ping_success 1$/)
+ expect(response.body).to match(/^redis_ping_latency_seconds [0-9\.]+$/)
+ end
+
+ it 'returns Caching ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^redis_cache_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_cache_ping_success 1$/)
+ expect(response.body).to match(/^redis_cache_ping_latency_seconds [0-9\.]+$/)
+ end
+
+ it 'returns Queues ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^redis_queues_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_queues_ping_success 1$/)
+ expect(response.body).to match(/^redis_queues_ping_latency_seconds [0-9\.]+$/)
+ end
+
+ it 'returns SharedState ping metrics' do
+ get :index
+
+ expect(response.body).to match(/^redis_shared_state_ping_timeout 0$/)
+ expect(response.body).to match(/^redis_shared_state_ping_success 1$/)
+ expect(response.body).to match(/^redis_shared_state_ping_latency_seconds [0-9\.]+$/)
+ end
+
+ it 'returns file system check metrics' do
+ get :index
+
+ expect(response.body).to match(/^filesystem_access_latency_seconds{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_write_latency_seconds{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/)
+ expect(response.body).to match(/^filesystem_read_latency_seconds{shard="default"} [0-9\.]+$/)
+ expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/)
+ end
+
+ context 'prometheus metrics are disabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false)
+ end
+
+ it 'returns proper response' do
+ get :index
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+
+ context 'accessed from whitelisted ip' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
+ end
+
+ it_behaves_like 'endpoint providing metrics'
+ end
+
+ context 'accessed from ip in whitelisted range' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip_in_whitelisted_range)
+ end
+
+ it_behaves_like 'endpoint providing metrics'
+ end
+
+ context 'accessed from not whitelisted ip' do
+ before do
+ allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip)
+ end
+
+ it 'returns proper response' do
+ get :index
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/notification_settings_controller_spec.rb b/spec/controllers/notification_settings_controller_spec.rb
index 9e3a31e1a6b..bef815ee1f7 100644
--- a/spec/controllers/notification_settings_controller_spec.rb
+++ b/spec/controllers/notification_settings_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe NotificationSettingsController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:group) { create(:group, :internal) }
let(:user) { create(:user) }
@@ -58,7 +58,10 @@ describe NotificationSettingsController do
expect(response.status).to eq 200
expect(notification_setting.level).to eq("custom")
- expect(notification_setting.events).to eq(custom_events)
+
+ custom_events.each do |event, value|
+ expect(notification_setting.event_enabled?(event)).to eq(value)
+ end
end
end
end
@@ -86,15 +89,21 @@ describe NotificationSettingsController do
expect(response.status).to eq 200
expect(notification_setting.level).to eq("custom")
- expect(notification_setting.events).to eq(custom_events)
+
+ custom_events.each do |event, value|
+ expect(notification_setting.event_enabled?(event)).to eq(value)
+ end
end
end
end
end
context 'not authorized' do
- let(:private_project) { create(:empty_project, :private) }
- before { sign_in(user) }
+ let(:private_project) { create(:project, :private) }
+
+ before do
+ sign_in(user)
+ end
it 'returns 404' do
post :create,
@@ -120,7 +129,9 @@ describe NotificationSettingsController do
end
context 'when authorized' do
- before{ sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'returns success' do
put :update,
@@ -152,7 +163,9 @@ describe NotificationSettingsController do
context 'not authorized' do
let(:other_user) { create(:user) }
- before { sign_in(other_user) }
+ before do
+ sign_in(other_user)
+ end
it 'returns 404' do
put :update,
diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb
new file mode 100644
index 00000000000..ac7f73c6e81
--- /dev/null
+++ b/spec/controllers/oauth/authorizations_controller_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe Oauth::AuthorizationsController do
+ let(:user) { create(:user) }
+
+ let(:doorkeeper) do
+ Doorkeeper::Application.create(
+ name: "MyApp",
+ redirect_uri: 'http://example.com',
+ scopes: "")
+ end
+
+ let(:params) do
+ {
+ response_type: "code",
+ client_id: doorkeeper.uid,
+ redirect_uri: doorkeeper.redirect_uri,
+ state: 'state'
+ }
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #new' do
+ context 'without valid params' do
+ it 'returns 200 code and renders error view' do
+ get :new
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template('doorkeeper/authorizations/error')
+ end
+ end
+
+ context 'with valid params' do
+ it 'returns 200 code and renders view' do
+ get :new, params
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template('doorkeeper/authorizations/new')
+ end
+
+ it 'deletes session.user_return_to and redirects when skip authorization' do
+ doorkeeper.update(trusted: true)
+ request.session['user_return_to'] = 'http://example.com'
+
+ get :new, params
+
+ expect(request.session['user_return_to']).to be_nil
+ expect(response).to have_http_status(302)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
new file mode 100644
index 00000000000..2955d01fad0
--- /dev/null
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe PasswordsController do
+ describe '#check_password_authentication_available' do
+ before do
+ @request.env["devise.mapping"] = Devise.mappings[:user]
+ end
+
+ context 'when password authentication is disabled' do
+ it 'prevents a password reset' do
+ stub_application_setting(password_authentication_enabled: false)
+
+ post :create
+
+ expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ end
+ end
+
+ context 'when reset email belongs to an ldap user' do
+ let(:user) { create(:omniauth_user, provider: 'ldapmain', email: 'ldapuser@gitlab.com') }
+
+ it 'prevents a password reset' do
+ post :create, user: { email: user.email }
+
+ expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ end
+ end
+ end
+end
diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb
index 2f9d18e3a0e..d387aba227b 100644
--- a/spec/controllers/profiles/accounts_controller_spec.rb
+++ b/spec/controllers/profiles/accounts_controller_spec.rb
@@ -29,7 +29,7 @@ describe Profiles::AccountsController do
end
end
- [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0].each do |provider|
+ [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq].each do |provider|
describe "#{provider} provider" do
let(:user) { create(:omniauth_user, provider: provider.to_s) }
diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb
index 61e4fae46fb..363ed410bc0 100644
--- a/spec/controllers/profiles/keys_controller_spec.rb
+++ b/spec/controllers/profiles/keys_controller_spec.rb
@@ -49,7 +49,7 @@ describe Profiles::KeysController do
expect(response.body).to eq(user.all_ssh_keys.join("\n"))
expect(response.body).to include(key.key.sub(' dummy@gitlab.com', ''))
- expect(response.body).to include(another_key.key)
+ expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', ''))
expect(response.body).not_to include(deploy_key.key)
end
diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
new file mode 100644
index 00000000000..ed08a4c1bf2
--- /dev/null
+++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe Profiles::PersonalAccessTokensController do
+ let(:user) { create(:user) }
+ let(:token_attributes) { attributes_for(:personal_access_token) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe '#create' do
+ def created_token
+ PersonalAccessToken.order(:created_at).last
+ end
+
+ it "allows creation of a token with scopes" do
+ name = 'My PAT'
+ scopes = %w[api read_user]
+
+ post :create, personal_access_token: token_attributes.merge(scopes: scopes, name: name)
+
+ expect(created_token).not_to be_nil
+ expect(created_token.name).to eq(name)
+ expect(created_token.scopes).to eq(scopes)
+ expect(PersonalAccessToken.active).to include(created_token)
+ end
+
+ it "allows creation of a token with an expiry date" do
+ expires_at = 5.days.from_now.to_date
+
+ post :create, personal_access_token: token_attributes.merge(expires_at: expires_at)
+
+ expect(created_token).not_to be_nil
+ expect(created_token.expires_at).to eq(expires_at)
+ end
+ end
+
+ describe '#index' do
+ let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
+ let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
+ let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
+
+ before do
+ get :index
+ end
+
+ it "retrieves active personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
+ end
+
+ it "retrieves inactive personal access tokens" do
+ expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token)
+ end
+
+ it "does not retrieve impersonation personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
+ expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token)
+ end
+ end
+end
diff --git a/spec/controllers/profiles/personal_access_tokens_spec.rb b/spec/controllers/profiles/personal_access_tokens_spec.rb
deleted file mode 100644
index 98a43e278b2..00000000000
--- a/spec/controllers/profiles/personal_access_tokens_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-require 'spec_helper'
-
-describe Profiles::PersonalAccessTokensController do
- let(:user) { create(:user) }
- let(:token_attributes) { attributes_for(:personal_access_token) }
-
- before { sign_in(user) }
-
- describe '#create' do
- def created_token
- PersonalAccessToken.order(:created_at).last
- end
-
- it "allows creation of a token with scopes" do
- name = 'My PAT'
- scopes = %w[api read_user]
-
- post :create, personal_access_token: token_attributes.merge(scopes: scopes, name: name)
-
- expect(created_token).not_to be_nil
- expect(created_token.name).to eq(name)
- expect(created_token.scopes).to eq(scopes)
- expect(PersonalAccessToken.active).to include(created_token)
- end
-
- it "allows creation of a token with an expiry date" do
- expires_at = 5.days.from_now.to_date
-
- post :create, personal_access_token: token_attributes.merge(expires_at: expires_at)
-
- expect(created_token).not_to be_nil
- expect(created_token.expires_at).to eq(expires_at)
- end
- end
-
- describe '#index' do
- let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
- let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
- let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
-
- before { get :index }
-
- it "retrieves active personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
- end
-
- it "retrieves inactive personal access tokens" do
- expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token)
- end
-
- it "does not retrieve impersonation personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- end
- end
-end
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index 7b3aa0491c7..a5f544b4f92 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -43,7 +43,8 @@ describe Profiles::PreferencesController do
dashboard: 'stars'
}.with_indifferent_access
- expect(user).to receive(:update_attributes).with(prefs)
+ expect(user).to receive(:assign_attributes).with(prefs)
+ expect(user).to receive(:save)
go params: prefs
end
@@ -51,7 +52,7 @@ describe Profiles::PreferencesController do
context 'on failed update' do
it 'sets the flash' do
- expect(user).to receive(:update_attributes).and_return(false)
+ expect(user).to receive(:save).and_return(false)
go
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
new file mode 100644
index 00000000000..9d60dab12d1
--- /dev/null
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -0,0 +1,31 @@
+require('spec_helper')
+
+describe ProfilesController do
+ describe "PUT update" do
+ it "allows an email update from a user without an external email address" do
+ user = create(:user)
+ sign_in(user)
+
+ put :update,
+ user: { email: "john@gmail.com", name: "John" }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(user.unconfirmed_email).to eq('john@gmail.com')
+ end
+
+ it "ignores an email update from a user with an external email address" do
+ ldap_user = create(:omniauth_user, external_email: true)
+ sign_in(ldap_user)
+
+ put :update,
+ user: { email: "john@gmail.com", name: "John" }
+
+ ldap_user.reload
+
+ expect(response.status).to eq(302)
+ expect(ldap_user.unconfirmed_email).not_to eq('john@gmail.com')
+ end
+ end
+end
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
new file mode 100644
index 00000000000..d2c613a2423
--- /dev/null
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -0,0 +1,181 @@
+require 'spec_helper'
+
+describe Projects::ArtifactsController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: project,
+ sha: project.commit.sha,
+ ref: project.default_branch,
+ status: 'success')
+ end
+
+ let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
+
+ before do
+ project.team << [user, :developer]
+
+ sign_in(user)
+ end
+
+ describe 'GET download' do
+ it 'sends the artifacts file' do
+ expect(controller).to receive(:send_file).with(job.artifacts_file.path, disposition: 'attachment').and_call_original
+
+ get :download, namespace_id: project.namespace, project_id: project, job_id: job
+ end
+ end
+
+ describe 'GET browse' do
+ context 'when the directory exists' do
+ it 'renders the browse view' do
+ get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'other_artifacts_0.1.2'
+
+ expect(response).to render_template('projects/artifacts/browse')
+ end
+ end
+
+ context 'when the directory does not exist' do
+ it 'responds Not Found' do
+ get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
+
+ expect(response).to be_not_found
+ end
+ end
+ end
+
+ describe 'GET file' do
+ context 'when the file exists' do
+ it 'renders the file view' do
+ get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt'
+
+ expect(response).to render_template('projects/artifacts/file')
+ end
+ end
+
+ context 'when the file does not exist' do
+ it 'responds Not Found' do
+ get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
+
+ expect(response).to be_not_found
+ end
+ end
+ end
+
+ describe 'GET raw' do
+ context 'when the file exists' do
+ it 'serves the file using workhorse' do
+ get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt'
+
+ send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
+
+ expect(send_data).to start_with('artifacts-entry:')
+
+ base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
+ params = JSON.parse(Base64.urlsafe_decode64(base64_params))
+
+ expect(params.keys).to eq(%w(Archive Entry))
+ expect(params['Archive']).to end_with('build_artifacts.zip')
+ expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
+ end
+ end
+
+ context 'when the file does not exist' do
+ it 'responds Not Found' do
+ get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
+
+ expect(response).to be_not_found
+ end
+ end
+ end
+
+ describe 'GET latest_succeeded' do
+ def params_from_ref(ref = pipeline.ref, job_name = job.name, path = 'browse')
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ ref_name_and_path: File.join(ref, path),
+ job: job_name
+ }
+ end
+
+ context 'cannot find the job' do
+ shared_examples 'not found' do
+ it { expect(response).to have_http_status(:not_found) }
+ end
+
+ context 'has no such ref' do
+ before do
+ get :latest_succeeded, params_from_ref('TAIL', job.name)
+ end
+
+ it_behaves_like 'not found'
+ end
+
+ context 'has no such job' do
+ before do
+ get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD')
+ end
+
+ it_behaves_like 'not found'
+ end
+
+ context 'has no path' do
+ before do
+ get :latest_succeeded, params_from_ref(pipeline.sha, job.name, '')
+ end
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ context 'found the job and redirect' do
+ shared_examples 'redirect to the job' do
+ it 'redirects' do
+ path = browse_project_job_artifacts_path(project, job)
+
+ expect(response).to redirect_to(path)
+ end
+ end
+
+ context 'with regular branch' do
+ before do
+ pipeline.update(ref: 'master',
+ sha: project.commit('master').sha)
+
+ get :latest_succeeded, params_from_ref('master')
+ end
+
+ it_behaves_like 'redirect to the job'
+ end
+
+ context 'with branch name containing slash' do
+ before do
+ pipeline.update(ref: 'improve/awesome',
+ sha: project.commit('improve/awesome').sha)
+
+ get :latest_succeeded, params_from_ref('improve/awesome')
+ end
+
+ it_behaves_like 'redirect to the job'
+ end
+
+ context 'with branch name and path containing slashes' do
+ before do
+ pipeline.update(ref: 'improve/awesome',
+ sha: project.commit('improve/awesome').sha)
+
+ get :latest_succeeded, params_from_ref('improve/awesome', job.name, 'file/README.md')
+ end
+
+ it 'redirects' do
+ path = file_project_job_artifacts_path(project, job, 'README.md')
+
+ expect(response).to redirect_to(path)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb
index 8b71d6518bb..f5ea097af8b 100644
--- a/spec/controllers/projects/avatars_controller_spec.rb
+++ b/spec/controllers/projects/avatars_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::AvatarsController do
- let(:project) { create(:empty_project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
+ let(:project) { create(:project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
let(:user) { create(:user) }
before do
diff --git a/spec/controllers/projects/badges_controller_spec.rb b/spec/controllers/projects/badges_controller_spec.rb
new file mode 100644
index 00000000000..d68200164e4
--- /dev/null
+++ b/spec/controllers/projects/badges_controller_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Projects::BadgesController do
+ let(:project) { pipeline.project }
+ let!(:pipeline) { create(:ci_empty_pipeline) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'requests the pipeline badge successfully' do
+ get_badge(:pipeline)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'requests the coverage badge successfully' do
+ get_badge(:coverage)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ def get_badge(badge)
+ get badge, namespace_id: project.namespace.to_param, project_id: project, ref: pipeline.ref, format: :svg
+ end
+end
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index 0fd09d156c4..64b9af7b845 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -3,6 +3,77 @@ require 'rails_helper'
describe Projects::BlobController do
let(:project) { create(:project, :public, :repository) }
+ describe "GET show" do
+ render_views
+
+ context 'with file path' do
+ before do
+ get(:show,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id)
+ end
+
+ context "valid branch, valid file" do
+ let(:id) { 'master/README.md' }
+ it { is_expected.to respond_with(:success) }
+ end
+
+ context "valid branch, invalid file" do
+ let(:id) { 'master/invalid-path.rb' }
+ it { is_expected.to respond_with(:not_found) }
+ end
+
+ context "invalid branch, valid file" do
+ let(:id) { 'invalid-branch/README.md' }
+ it { is_expected.to respond_with(:not_found) }
+ end
+
+ context "binary file" do
+ let(:id) { 'binary-encoding/encoding/binary-1.bin' }
+ it { is_expected.to respond_with(:success) }
+ end
+ end
+
+ context 'with file path and JSON format' do
+ context "valid branch, valid file" do
+ let(:id) { 'master/README.md' }
+
+ before do
+ get(:show,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id,
+ format: :json)
+ end
+
+ it do
+ expect(response).to be_ok
+ expect(json_response).to have_key 'html'
+ expect(json_response).to have_key 'raw_path'
+ end
+ end
+ end
+
+ context 'with tree path' do
+ before do
+ get(:show,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id)
+ controller.instance_variable_set(:@blob, nil)
+ end
+
+ context 'redirect to tree' do
+ let(:id) { 'markdown/doc' }
+ it 'redirects' do
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/markdown/doc")
+ end
+ end
+ end
+ end
+
describe 'GET diff' do
let(:user) { create(:user) }
@@ -66,7 +137,7 @@ describe Projects::BlobController do
end
it 'redirects to blob show' do
- expect(response).to redirect_to(namespace_project_blob_path(project.namespace, project, 'master/CHANGELOG'))
+ expect(response).to redirect_to(project_blob_path(project, 'master/CHANGELOG'))
end
end
@@ -113,7 +184,7 @@ describe Projects::BlobController do
end
def blob_after_edit_path
- namespace_project_blob_path(project.namespace, project, 'master/CHANGELOG')
+ project_blob_path(project, 'master/CHANGELOG')
end
before do
@@ -135,14 +206,14 @@ describe Projects::BlobController do
it 'redirects to MR diff' do
put :update, mr_params
- after_edit_path = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ after_edit_path = diffs_project_merge_request_path(project, merge_request)
file_anchor = "##{Digest::SHA1.hexdigest('CHANGELOG')}"
expect(response).to redirect_to(after_edit_path + file_anchor)
end
context "when user doesn't have access" do
before do
- other_project = create(:empty_project)
+ other_project = create(:project, :repository)
merge_request.update!(source_project: other_project, target_project: other_project)
end
@@ -172,7 +243,7 @@ describe Projects::BlobController do
it 'redirects to blob' do
put :update, default_params
- expect(response).to redirect_to(namespace_project_blob_path(forked_project.namespace, forked_project, 'master/CHANGELOG'))
+ expect(response).to redirect_to(project_blob_path(forked_project, 'master/CHANGELOG'))
end
end
@@ -184,8 +255,7 @@ describe Projects::BlobController do
put :update, default_params
expect(response).to redirect_to(
- new_namespace_project_merge_request_path(
- forked_project.namespace,
+ project_new_merge_request_path(
forked_project,
merge_request: {
source_project_id: forked_project.id,
diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb
index 15667e8d4b1..3f6c1092163 100644
--- a/spec/controllers/projects/boards/issues_controller_spec.rb
+++ b/spec/controllers/projects/boards/issues_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::Boards::IssuesController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:guest) { create(:user) }
@@ -34,7 +34,7 @@ describe Projects::Boards::IssuesController do
issue = create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
- create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
+ create(:labeled_issue, project: project, labels: [development], assignees: [johndoe])
issue.subscribe(johndoe, project)
list_issues user: user, board: board, list: list2
diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb
index 432f3c53c90..65beec16307 100644
--- a/spec/controllers/projects/boards/lists_controller_spec.rb
+++ b/spec/controllers/projects/boards/lists_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::Boards::ListsController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:guest) { create(:user) }
@@ -27,7 +27,7 @@ describe Projects::Boards::ListsController do
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists')
- expect(parsed_response.length).to eq 2
+ expect(parsed_response.length).to eq 3
end
context 'with unauthorized user' do
diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb
index aed3a45c413..9e2e9a39481 100644
--- a/spec/controllers/projects/boards_controller_spec.rb
+++ b/spec/controllers/projects/boards_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::BoardsController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
before do
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index d20e7368086..745d051a5c1 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -14,7 +14,7 @@ describe Projects::BranchesController do
controller.instance_variable_set(:@project, project)
end
- describe "POST create" do
+ describe "POST create with HTML format" do
render_views
context "on creation of a new branch" do
@@ -32,8 +32,8 @@ describe Projects::BranchesController do
let(:branch) { "merge_branch" }
let(:ref) { "master" }
it 'redirects' do
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/merge_branch")
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/merge_branch")
end
end
@@ -41,8 +41,8 @@ describe Projects::BranchesController do
let(:branch) { "<script>alert('merge');</script>" }
let(:ref) { "master" }
it 'redirects' do
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/alert('merge');")
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/alert('merge');")
end
end
@@ -81,8 +81,8 @@ describe Projects::BranchesController do
branch_name: branch,
issue_iid: issue.iid
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/1-feature-branch")
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/1-feature-branch")
end
it 'posts a system note' do
@@ -96,7 +96,7 @@ describe Projects::BranchesController do
end
context 'repository-less project' do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
it 'redirects to newly created branch' do
result = { status: :success, branch: double(name: branch) }
@@ -110,7 +110,7 @@ describe Projects::BranchesController do
branch_name: branch,
issue_iid: issue.iid
- expect(response).to redirect_to namespace_project_tree_path(project.namespace, project, branch)
+ expect(response).to redirect_to project_tree_path(project, branch)
end
it 'redirects to autodeploy setup page' do
@@ -127,7 +127,7 @@ describe Projects::BranchesController do
branch_name: branch,
issue_iid: issue.iid
- expect(response.location).to include(namespace_project_new_blob_path(project.namespace, project, branch))
+ expect(response.location).to include(project_new_blob_path(project, branch))
expect(response).to have_http_status(302)
end
end
@@ -152,6 +152,42 @@ describe Projects::BranchesController do
end
end
+ describe 'POST create with JSON format' do
+ before do
+ sign_in(user)
+ end
+
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ create_branch name: 'my-branch', ref: 'master'
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the created branch' do
+ create_branch name: 'my-branch', ref: 'master'
+
+ expect(response).to match_response_schema('branch')
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns an unprocessable entity 422 response' do
+ create_branch name: "<script>alert('merge');</script>", ref: "<script>alert('ref');</script>"
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ def create_branch(name:, ref:)
+ post :create, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ branch_name: name,
+ ref: ref,
+ format: :json
+ end
+ end
+
describe "POST destroy with HTML format" do
render_views
@@ -177,33 +213,98 @@ describe Projects::BranchesController do
sign_in(user)
post :destroy,
- format: :js,
- id: branch,
- namespace_id: project.namespace,
- project_id: project
+ format: format,
+ id: branch,
+ namespace_id: project.namespace,
+ project_id: project
end
- context "valid branch name, valid source" do
+ context 'as JS' do
let(:branch) { "feature" }
+ let(:format) { :js }
- it { expect(response).to have_http_status(200) }
- end
+ context "valid branch name, valid source" do
+ let(:branch) { "feature" }
- context "valid branch name with unencoded slashes" do
- let(:branch) { "improve/awesome" }
+ it { expect(response).to have_http_status(200) }
+ it { expect(response.body).to be_blank }
+ end
+
+ context "valid branch name with unencoded slashes" do
+ let(:branch) { "improve/awesome" }
+
+ it { expect(response).to have_http_status(200) }
+ it { expect(response.body).to be_blank }
+ end
- it { expect(response).to have_http_status(200) }
+ context "valid branch name with encoded slashes" do
+ let(:branch) { "improve%2Fawesome" }
+
+ it { expect(response).to have_http_status(200) }
+ it { expect(response.body).to be_blank }
+ end
+
+ context "invalid branch name, valid ref" do
+ let(:branch) { "no-branch" }
+
+ it { expect(response).to have_http_status(404) }
+ it { expect(response.body).to be_blank }
+ end
end
- context "valid branch name with encoded slashes" do
- let(:branch) { "improve%2Fawesome" }
+ context 'as JSON' do
+ let(:branch) { "feature" }
+ let(:format) { :json }
- it { expect(response).to have_http_status(200) }
+ context 'valid branch name, valid source' do
+ let(:branch) { "feature" }
+
+ it 'returns JSON response with message' do
+ expect(json_response).to eql("message" => 'Branch was removed')
+ end
+
+ it { expect(response).to have_http_status(200) }
+ end
+
+ context 'valid branch name with unencoded slashes' do
+ let(:branch) { "improve/awesome" }
+
+ it 'returns JSON response with message' do
+ expect(json_response).to eql('message' => 'Branch was removed')
+ end
+
+ it { expect(response).to have_http_status(200) }
+ end
+
+ context "valid branch name with encoded slashes" do
+ let(:branch) { 'improve%2Fawesome' }
+
+ it 'returns JSON response with message' do
+ expect(json_response).to eql('message' => 'Branch was removed')
+ end
+
+ it { expect(response).to have_http_status(200) }
+ end
+
+ context 'invalid branch name, valid ref' do
+ let(:branch) { 'no-branch' }
+
+ it 'returns JSON response with message' do
+ expect(json_response).to eql('message' => 'No such branch')
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
end
- context "invalid branch name, valid ref" do
- let(:branch) { "no-branch" }
- it { expect(response).to have_http_status(404) }
+ context 'as HTML' do
+ let(:branch) { "feature" }
+ let(:format) { :html }
+
+ it 'redirects to branches path' do
+ expect(response)
+ .to redirect_to(project_branches_path(project))
+ end
end
end
@@ -222,7 +323,7 @@ describe Projects::BranchesController do
it 'redirects to branches' do
destroy_all_merged
- expect(response).to redirect_to namespace_project_branches_path(project.namespace, project)
+ expect(response).to redirect_to project_branches_path(project)
end
it 'starts worker to delete merged branches' do
@@ -266,19 +367,5 @@ describe Projects::BranchesController do
expect(parsed_response.first).to eq 'master'
end
end
-
- context 'show_all = true' do
- it 'returns all the branches name' do
- get :index,
- namespace_id: project.namespace,
- project_id: project,
- format: :json,
- show_all: true
-
- parsed_response = JSON.parse(response.body)
-
- expect(parsed_response.length).to eq(project.repository.branches.count)
- end
- end
end
end
diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb
deleted file mode 100644
index fb4ccfa58c2..00000000000
--- a/spec/controllers/projects/builds_controller_spec.rb
+++ /dev/null
@@ -1,104 +0,0 @@
-require 'spec_helper'
-
-describe Projects::BuildsController do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
-
- before 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) }
- let(:status) { build.detailed_status(double('user')) }
-
- before do
- get :status, namespace_id: project.namespace,
- project_id: project,
- id: build.id,
- format: :json
- end
-
- it 'return a detailed build status in json' do
- expect(response).to have_http_status(:ok)
- expect(json_response['text']).to eq status.text
- expect(json_response['label']).to eq status.label
- expect(json_response['icon']).to eq status.icon
- expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
- end
- end
-
- describe 'GET trace.json' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline) }
- let(:user) { create(:user) }
-
- context 'when user is logged in as developer' do
- before do
- project.add_developer(user)
- sign_in(user)
- get_trace
- end
-
- it 'traces build log' do
- expect(response).to have_http_status(:ok)
- expect(json_response['id']).to eq build.id
- expect(json_response['status']).to eq build.status
- end
- end
-
- context 'when user is logged in as non member' do
- before do
- sign_in(user)
- get_trace
- end
-
- it 'traces build log' do
- expect(response).to have_http_status(:ok)
- expect(json_response['id']).to eq build.id
- expect(json_response['status']).to eq build.status
- end
- end
-
- def get_trace
- get :trace, namespace_id: project.namespace,
- project_id: project,
- id: build.id,
- format: :json
- end
- end
-end
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 69e4706dc71..df53863482d 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -66,8 +66,8 @@ describe Projects::CommitController do
end
it "does not escape Html" do
- allow_any_instance_of(Commit).to receive(:"to_#{format}").
- and_return('HTML entities &<>" ')
+ allow_any_instance_of(Commit).to receive(:"to_#{format}")
+ .and_return('HTML entities &<>" ')
go(id: commit.id, format: format)
@@ -169,7 +169,7 @@ describe Projects::CommitController do
start_branch: 'master',
id: commit.id)
- expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master')
+ expect(response).to redirect_to project_commits_path(project, 'master')
expect(flash[:notice]).to eq('The commit has been successfully reverted.')
end
end
@@ -191,7 +191,7 @@ describe Projects::CommitController do
start_branch: 'master',
id: commit.id)
- expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, commit.id)
+ expect(response).to redirect_to project_commit_path(project, commit.id)
expect(flash[:alert]).to match('Sorry, we cannot revert this commit automatically.')
end
end
@@ -218,7 +218,7 @@ describe Projects::CommitController do
start_branch: 'master',
id: master_pickable_commit.id)
- expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master')
+ expect(response).to redirect_to project_commits_path(project, 'master')
expect(flash[:notice]).to eq('The commit has been successfully cherry-picked.')
end
end
@@ -240,7 +240,7 @@ describe Projects::CommitController do
start_branch: 'master',
id: master_pickable_commit.id)
- expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, master_pickable_commit.id)
+ expect(response).to redirect_to project_commit_path(project, master_pickable_commit.id)
expect(flash[:alert]).to match('Sorry, we cannot cherry-pick this commit automatically.')
end
end
@@ -281,7 +281,9 @@ describe Projects::CommitController do
end
context 'when the path does not exist in the diff' do
- before { diff_for_path(id: commit.id, old_path: existing_path.succ, new_path: existing_path.succ) }
+ before do
+ diff_for_path(id: commit.id, old_path: existing_path.succ, new_path: existing_path.succ)
+ end
it 'returns a 404' do
expect(response).to have_http_status(404)
@@ -302,7 +304,9 @@ describe Projects::CommitController do
end
context 'when the commit does not exist' do
- before { diff_for_path(id: commit.id.succ, old_path: existing_path, new_path: existing_path) }
+ before do
+ diff_for_path(id: commit.id.succ, old_path: existing_path, new_path: existing_path)
+ end
it 'returns a 404' do
expect(response).to have_http_status(404)
@@ -339,7 +343,8 @@ describe Projects::CommitController do
get_pipelines(id: commit.id, format: :json)
expect(response).to be_ok
- expect(JSON.parse(response.body)).not_to be_empty
+ expect(JSON.parse(response.body)['pipelines']).not_to be_empty
+ expect(JSON.parse(response.body)['count']['all']).to eq 1
end
end
end
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index 15ac4e0925a..b4f9fd9b7a2 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -72,7 +72,7 @@ describe Projects::CompareController do
from: '',
to: 'master')
- expect(response).to redirect_to(namespace_project_compare_index_path(project.namespace, project, to: 'master'))
+ expect(response).to redirect_to(project_compare_index_path(project, to: 'master'))
end
it 'redirects back to index when params[:to] is empty and preserves params[:from]' do
@@ -82,7 +82,7 @@ describe Projects::CompareController do
from: 'master',
to: '')
- expect(response).to redirect_to(namespace_project_compare_index_path(project.namespace, project, from: 'master'))
+ expect(response).to redirect_to(project_compare_index_path(project, from: 'master'))
end
it 'redirects back to index when params[:from] and params[:to] are empty' do
@@ -128,7 +128,9 @@ describe Projects::CompareController do
end
context 'when the path does not exist in the diff' do
- before { diff_for_path(from: ref_from, to: ref_to, old_path: existing_path.succ, new_path: existing_path.succ) }
+ before do
+ diff_for_path(from: ref_from, to: ref_to, old_path: existing_path.succ, new_path: existing_path.succ)
+ end
it 'returns a 404' do
expect(response).to have_http_status(404)
@@ -149,7 +151,9 @@ describe Projects::CompareController do
end
context 'when the from ref does not exist' do
- before { diff_for_path(from: ref_from.succ, to: ref_to, old_path: existing_path, new_path: existing_path) }
+ before do
+ diff_for_path(from: ref_from.succ, to: ref_to, old_path: existing_path, new_path: existing_path)
+ end
it 'returns a 404' do
expect(response).to have_http_status(404)
@@ -157,7 +161,9 @@ describe Projects::CompareController do
end
context 'when the to ref does not exist' do
- before { diff_for_path(from: ref_from, to: ref_to.succ, old_path: existing_path, new_path: existing_path) }
+ before do
+ diff_for_path(from: ref_from, to: ref_to.succ, old_path: existing_path, new_path: existing_path)
+ end
it 'returns a 404' do
expect(response).to have_http_status(404)
diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb
new file mode 100644
index 00000000000..c3208357694
--- /dev/null
+++ b/spec/controllers/projects/deploy_keys_controller_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+describe Projects::DeployKeysController do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+
+ sign_in(user)
+ end
+
+ describe 'GET index' do
+ let(:params) do
+ { namespace_id: project.namespace, project_id: project }
+ end
+
+ context 'when html requested' do
+ it 'redirects to blob' do
+ get :index, params
+
+ expect(response).to redirect_to(namespace_project_settings_repository_path(params))
+ end
+ end
+
+ context 'when json requested' do
+ let(:project2) { create(:project, :internal)}
+ let(:project_private) { create(:project, :private)}
+
+ let(:deploy_key_internal) do
+ create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com')
+ end
+ let(:deploy_key_actual) do
+ create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
+ end
+ let!(:deploy_key_public) { create(:deploy_key, public: true) }
+
+ let!(:deploy_keys_project_internal) do
+ create(:deploy_keys_project, project: project2, deploy_key: deploy_key_internal)
+ end
+
+ let!(:deploy_keys_actual_project) do
+ create(:deploy_keys_project, project: project, deploy_key: deploy_key_actual)
+ end
+
+ let!(:deploy_keys_project_private) do
+ create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key))
+ end
+
+ before do
+ project2.team << [user, :developer]
+ end
+
+ it 'returns json in a correct format' do
+ get :index, params.merge(format: :json)
+
+ json = JSON.parse(response.body)
+
+ expect(json.keys).to match_array(%w(enabled_keys available_project_keys public_keys))
+ expect(json['enabled_keys'].count).to eq(1)
+ expect(json['available_project_keys'].count).to eq(1)
+ expect(json['public_keys'].count).to eq(1)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb
new file mode 100644
index 00000000000..3daff1eeea3
--- /dev/null
+++ b/spec/controllers/projects/deployments_controller_spec.rb
@@ -0,0 +1,180 @@
+require 'spec_helper'
+
+describe Projects::DeploymentsController do
+ include ApiHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:environment) { create(:environment, name: 'production', project: project) }
+
+ before do
+ project.team << [user, :master]
+
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ it 'returns list of deployments from last 8 hours' do
+ create(:deployment, environment: environment, created_at: 9.hours.ago)
+ create(:deployment, environment: environment, created_at: 7.hours.ago)
+ create(:deployment, environment: environment)
+
+ get :index, deployment_params(after: 8.hours.ago)
+
+ expect(response).to be_ok
+
+ expect(json_response['deployments'].count).to eq(2)
+ end
+
+ it 'returns a list with deployments information' do
+ create(:deployment, environment: environment)
+
+ get :index, deployment_params
+
+ expect(response).to be_ok
+ expect(response).to match_response_schema('deployments')
+ end
+ end
+
+ describe 'GET #metrics' do
+ let(:deployment) { create(:deployment, project: project, environment: environment) }
+
+ before do
+ allow(controller).to receive(:deployment).and_return(deployment)
+ end
+
+ context 'when metrics are disabled' do
+ before do
+ allow(deployment).to receive(:has_metrics?).and_return false
+ end
+
+ it 'responds with not found' do
+ get :metrics, deployment_params(id: deployment.id)
+
+ expect(response).to be_not_found
+ end
+ end
+
+ context 'when metrics are enabled' do
+ before do
+ allow(deployment).to receive(:has_metrics?).and_return true
+ end
+
+ context 'when environment has no metrics' do
+ before do
+ expect(deployment).to receive(:metrics).and_return(nil)
+ end
+
+ it 'returns a empty response 204 resposne' do
+ get :metrics, deployment_params(id: deployment.id)
+ expect(response).to have_http_status(204)
+ expect(response.body).to eq('')
+ end
+ end
+
+ context 'when environment has some metrics' do
+ let(:empty_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42
+ }
+ end
+
+ before do
+ expect(deployment).to receive(:metrics).and_return(empty_metrics)
+ end
+
+ it 'returns a metrics JSON document' do
+ get :metrics, deployment_params(id: deployment.id)
+
+ expect(response).to be_ok
+ expect(json_response['success']).to be(true)
+ expect(json_response['metrics']).to eq({})
+ expect(json_response['last_update']).to eq(42)
+ end
+ end
+
+ context 'when metrics service does not implement deployment metrics' do
+ before do
+ allow(deployment).to receive(:metrics).and_raise(NotImplementedError)
+ end
+
+ it 'responds with not found' do
+ get :metrics, deployment_params(id: deployment.id)
+
+ expect(response).to be_not_found
+ end
+ end
+ end
+ end
+
+ describe 'GET #additional_metrics' do
+ let(:deployment) { create(:deployment, project: project, environment: environment) }
+
+ before do
+ allow(controller).to receive(:deployment).and_return(deployment)
+ end
+
+ context 'when metrics are disabled' do
+ before do
+ allow(deployment).to receive(:has_metrics?).and_return false
+ end
+
+ it 'responds with not found' do
+ get :metrics, deployment_params(id: deployment.id)
+
+ expect(response).to be_not_found
+ end
+ end
+
+ context 'when metrics are enabled' do
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(deployment.project).to receive(:prometheus_service).and_return(prometheus_service)
+ end
+
+ context 'when environment has no metrics' do
+ before do
+ expect(deployment).to receive(:additional_metrics).and_return({})
+ end
+
+ it 'returns a empty response 204 response' do
+ get :additional_metrics, deployment_params(id: deployment.id, format: :json)
+ expect(response).to have_http_status(204)
+ expect(response.body).to eq('')
+ end
+ end
+
+ context 'when environment has some metrics' do
+ let(:empty_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42
+ }
+ end
+
+ before do
+ expect(deployment).to receive(:additional_metrics).and_return(empty_metrics)
+ end
+
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, deployment_params(id: deployment.id, format: :json)
+
+ expect(response).to be_ok
+ expect(json_response['success']).to be(true)
+ expect(json_response['metrics']).to eq({})
+ expect(json_response['last_update']).to eq(42)
+ end
+ end
+ end
+ end
+
+ def deployment_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace,
+ project_id: project,
+ environment_id: environment.id)
+ end
+end
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 5c478534ff3..5a95f4f6199 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -1,25 +1,25 @@
require 'spec_helper'
describe Projects::EnvironmentsController do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ set(:user) { create(:user) }
+ set(:project) { create(:project) }
- let(:environment) do
+ set(:environment) do
create(:environment, name: 'production', project: project)
end
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
describe 'GET index' do
- context 'when standardrequest has been made' do
+ context 'when a request for the HTML is made' do
it 'responds with status code 200' do
get :index, environment_params
- expect(response).to be_ok
+ expect(response).to have_http_status(:ok)
end
end
@@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
+
+ it 'sets the polling interval header' do
+ expect(response).to have_http_status(:ok)
+ expect(response.headers['Poll-Interval']).to eq("3000")
+ end
end
context 'when requesting stopped environments scope' do
@@ -84,6 +89,9 @@ describe Projects::EnvironmentsController do
create(:environment, project: project,
name: 'staging-1.0/review',
state: :available)
+ create(:environment, project: project,
+ name: 'staging-1.0/zzz',
+ state: :available)
end
context 'when using default format' do
@@ -98,7 +106,7 @@ describe Projects::EnvironmentsController do
end
context 'when using JSON format' do
- it 'responds with JSON' do
+ it 'sorts the subfolders lexicographically' do
get :folder, namespace_id: project.namespace,
project_id: project,
id: 'staging-1.0',
@@ -108,6 +116,8 @@ describe Projects::EnvironmentsController do
expect(response).not_to render_template 'folder'
expect(json_response['environments'][0])
.to include('name' => 'staging-1.0/review')
+ expect(json_response['environments'][1])
+ .to include('name' => 'staging-1.0/zzz')
end
end
end
@@ -149,6 +159,48 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'PATCH #stop' do
+ context 'when env not available' do
+ it 'returns 404' do
+ allow_any_instance_of(Environment).to receive(:available?) { false }
+
+ patch :stop, environment_params(format: :json)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when stop action' do
+ it 'returns action url' do
+ action = create(:ci_build, :manual)
+
+ allow_any_instance_of(Environment)
+ .to receive_messages(available?: true, stop_with_action!: action)
+
+ patch :stop, environment_params(format: :json)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to eq(
+ { 'redirect_url' =>
+ project_job_url(project, action) })
+ end
+ end
+
+ context 'when no stop action' do
+ it 'returns env url' do
+ allow_any_instance_of(Environment)
+ .to receive_messages(available?: true, stop_with_action!: nil)
+
+ patch :stop, environment_params(format: :json)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to eq(
+ { 'redirect_url' =>
+ project_environment_url(project, environment) })
+ end
+ end
+ end
+
describe 'GET #terminal' do
context 'with valid id' do
it 'responds with a status code 200' do
@@ -181,14 +233,14 @@ describe Projects::EnvironmentsController do
context 'and valid id' do
it 'returns the first terminal for the environment' do
- expect_any_instance_of(Environment).
- to receive(:terminals).
- and_return([:fake_terminal])
+ expect_any_instance_of(Environment)
+ .to receive(:terminals)
+ .and_return([:fake_terminal])
- expect(Gitlab::Workhorse).
- to receive(:terminal_websocket).
- with(:fake_terminal).
- and_return(workhorse: :response)
+ expect(Gitlab::Workhorse)
+ .to receive(:terminal_websocket)
+ .with(:fake_terminal)
+ .and_return(workhorse: :response)
get :terminal_websocket_authorize, environment_params
@@ -264,6 +316,48 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'GET #additional_metrics' do
+ before do
+ allow(controller).to receive(:environment).and_return(environment)
+ end
+
+ context 'when environment has no metrics' do
+ before do
+ expect(environment).to receive(:additional_metrics).and_return(nil)
+ end
+
+ context 'when requesting metrics as JSON' do
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, environment_params(format: :json)
+
+ expect(response).to have_http_status(204)
+ expect(json_response).to eq({})
+ end
+ end
+ end
+
+ context 'when environment has some metrics' do
+ before do
+ expect(environment)
+ .to receive(:additional_metrics)
+ .and_return({
+ success: true,
+ data: {},
+ last_update: 42
+ })
+ end
+
+ it 'returns a metrics JSON document' do
+ get :additional_metrics, environment_params(format: :json)
+
+ expect(response).to be_ok
+ expect(json_response['success']).to be(true)
+ expect(json_response['data']).to eq({})
+ expect(json_response['last_update']).to eq(42)
+ end
+ end
+ end
+
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb
index 8282d79298f..dc8290c438e 100644
--- a/spec/controllers/projects/forks_controller_spec.rb
+++ b/spec/controllers/projects/forks_controller_spec.rb
@@ -14,7 +14,9 @@ describe Projects::ForksController do
end
context 'when fork is public' do
- before { forked_project.update_attribute(:visibility_level, Project::PUBLIC) }
+ before do
+ forked_project.update_attribute(:visibility_level, Project::PUBLIC)
+ end
it 'is visible for non logged in users' do
get_forks
@@ -35,7 +37,9 @@ describe Projects::ForksController do
end
context 'when user is logged in' do
- before { sign_in(project.creator) }
+ before do
+ sign_in(project.creator)
+ end
context 'when user is not a Project member neither a group member' do
it 'does not see the Project listed' do
@@ -46,7 +50,9 @@ describe Projects::ForksController do
end
context 'when user is a member of the Project' do
- before { forked_project.team << [project.creator, :developer] }
+ before do
+ forked_project.team << [project.creator, :developer]
+ end
it 'sees the project listed' do
get_forks
@@ -56,7 +62,9 @@ describe Projects::ForksController do
end
context 'when user is a member of the Group' do
- before { forked_project.group.add_developer(project.creator) }
+ before do
+ forked_project.group.add_developer(project.creator)
+ end
it 'sees the project listed' do
get_forks
diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb
index e0de62e4454..5af03ae118c 100644
--- a/spec/controllers/projects/graphs_controller_spec.rb
+++ b/spec/controllers/projects/graphs_controller_spec.rb
@@ -24,37 +24,4 @@ describe Projects::GraphsController do
expect(response).to redirect_to action: :charts
end
end
-
- describe 'GET charts' do
- let(:linguist_repository) do
- double(languages: {
- 'Ruby' => 1000,
- 'CoffeeScript' => 350,
- 'NSIS' => 15
- })
- end
-
- let(:expected_values) do
- nsis_color = "##{Digest::SHA256.hexdigest('NSIS')[0...6]}"
- [
- # colors from Linguist:
- { label: "Ruby", color: "#701516", highlight: "#701516" },
- { label: "CoffeeScript", color: "#244776", highlight: "#244776" },
- # colors from SHA256 fallback:
- { label: "NSIS", color: nsis_color, highlight: nsis_color }
- ]
- end
-
- before do
- allow(Linguist::Repository).to receive(:new).and_return(linguist_repository)
- end
-
- it 'sets the correct colour according to language' do
- get(:charts, namespace_id: project.namespace, project_id: project, id: 'master')
-
- expected_values.each do |val|
- expect(assigns(:languages)).to include(a_hash_including(val))
- end
- end
- end
end
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index ca4a8e871c0..f8c792cd0f0 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Projects::GroupLinksController do
let(:group) { create(:group, :private) }
let(:group2) { create(:group, :private) }
- let(:project) { create(:empty_project, :private, group: group2) }
+ let(:project) { create(:project, :private, group: group2) }
let(:user) { create(:user) }
before do
@@ -22,7 +22,10 @@ describe Projects::GroupLinksController do
end
context 'when user has access to group he want to link project to' do
- before { group.add_developer(user) }
+ before do
+ group.add_developer(user)
+ end
+
include_context 'link project to group'
it 'links project with selected group' do
@@ -31,7 +34,7 @@ describe Projects::GroupLinksController do
it 'redirects to project group links page' do
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
end
end
@@ -62,7 +65,7 @@ describe Projects::GroupLinksController do
it 'redirects to project group links page' do
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
end
end
@@ -76,7 +79,7 @@ describe Projects::GroupLinksController do
it 'redirects to project group links page' do
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
expect(flash[:alert]).to eq('Please select a group.')
end
diff --git a/spec/controllers/projects/hooks_controller_spec.rb b/spec/controllers/projects/hooks_controller_spec.rb
new file mode 100644
index 00000000000..07174660f46
--- /dev/null
+++ b/spec/controllers/projects/hooks_controller_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Projects::HooksController do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ describe '#index' do
+ it 'redirects to settings/integrations page' do
+ get(:index, namespace_id: project.namespace, project_id: project)
+
+ expect(response).to redirect_to(
+ project_settings_integrations_path(project)
+ )
+ end
+ end
+end
diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb
index 6724b474179..2a5ec6d584b 100644
--- a/spec/controllers/projects/imports_controller_spec.rb
+++ b/spec/controllers/projects/imports_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::ImportsController do
describe 'GET #show' do
context 'when repository does not exists' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
sign_in(user)
@@ -59,7 +59,7 @@ describe Projects::ImportsController do
it 'redirects to new_namespace_project_import_path' do
get :show, namespace_id: project.namespace.to_param, project_id: project
- expect(response).to redirect_to new_namespace_project_import_path(project.namespace, project)
+ expect(response).to redirect_to new_project_import_path(project)
end
end
@@ -75,7 +75,7 @@ describe Projects::ImportsController do
get :show, namespace_id: project.namespace.to_param, project_id: project
expect(flash[:notice]).to eq 'The project was successfully forked.'
- expect(response).to redirect_to namespace_project_path(project.namespace, project)
+ expect(response).to redirect_to project_path(project)
end
end
@@ -84,14 +84,14 @@ describe Projects::ImportsController do
get :show, namespace_id: project.namespace.to_param, project_id: project
expect(flash[:notice]).to eq 'The project was successfully imported.'
- expect(response).to redirect_to namespace_project_path(project.namespace, project)
+ expect(response).to redirect_to project_path(project)
end
end
context 'when continue params is present' do
let(:params) do
{
- to: namespace_project_path(project.namespace, project),
+ to: project_path(project),
notice: 'Finished'
}
end
@@ -120,7 +120,7 @@ describe Projects::ImportsController do
it 'redirects to namespace_project_path' do
get :show, namespace_id: project.namespace.to_param, project_id: project
- expect(response).to redirect_to namespace_project_path(project.namespace, project)
+ expect(response).to redirect_to project_path(project)
end
end
end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 79034b8d24d..b571b11dcac 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -1,20 +1,36 @@
require('spec_helper')
describe Projects::IssuesController do
- let(:project) { create(:project_empty_repo) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:issue) { create(:issue, project: project) }
describe "GET #index" do
context 'external issue tracker' do
- it 'redirects to the external issue tracker' do
- external = double(project_path: 'https://example.com/project')
- allow(project).to receive(:external_issue_tracker).and_return(external)
- controller.instance_variable_set(:@project, project)
+ before do
+ sign_in(user)
+ project.add_developer(user)
+ create(:jira_service, project: project)
+ end
- get :index, namespace_id: project.namespace, project_id: project
+ context 'when GitLab issues disabled' do
+ it 'returns 404 status' do
+ project.issues_enabled = false
+ project.save!
+
+ get :index, namespace_id: project.namespace, project_id: project
- expect(response).to redirect_to('https://example.com/project')
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when GitLab issues enabled' do
+ it 'renders the "index" template' do
+ get :index, namespace_id: project.namespace, project_id: project
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template(:index)
+ end
end
end
@@ -35,20 +51,12 @@ describe Projects::IssuesController do
it "returns 301 if request path doesn't match project path" do
get :index, namespace_id: project.namespace, project_id: project.path.upcase
- expect(response).to redirect_to(namespace_project_issues_path(project.namespace, project))
+ expect(response).to redirect_to(project_issues_path(project))
end
it "returns 404 when issues are disabled" do
project.issues_enabled = false
- project.save
-
- get :index, namespace_id: project.namespace, project_id: project
- expect(response).to have_http_status(404)
- end
-
- it "returns 404 when external issue tracker is enabled" do
- controller.instance_variable_set(:@project, project)
- allow(project).to receive(:default_issues_tracker?).and_return(false)
+ project.save!
get :index, namespace_id: project.namespace, project_id: project
expect(response).to have_http_status(404)
@@ -139,19 +147,62 @@ describe Projects::IssuesController do
end
context 'external issue tracker' do
+ let!(:service) do
+ create(:custom_issue_tracker_service, project: project, title: 'Custom Issue Tracker', new_issue_url: 'http://test.com')
+ end
+
before do
sign_in(user)
project.team << [user, :developer]
- end
- it 'redirects to the external issue tracker' do
- external = double(new_issue_path: 'https://example.com/issues/new')
+ external = double
allow(project).to receive(:external_issue_tracker).and_return(external)
- controller.instance_variable_set(:@project, project)
+ end
- get :new, namespace_id: project.namespace, project_id: project
+ context 'when GitLab issues disabled' do
+ it 'returns 404 status' do
+ project.issues_enabled = false
+ project.save!
- expect(response).to redirect_to('https://example.com/issues/new')
+ get :new, namespace_id: project.namespace, project_id: project
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when GitLab issues enabled' do
+ it 'renders the "new" template' do
+ get :new, namespace_id: project.namespace, project_id: project
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template(:new)
+ end
+ end
+ end
+ end
+
+ describe 'Redirect after sign in' do
+ context 'with an AJAX request' do
+ it 'does not store the visited URL' do
+ xhr :get,
+ :show,
+ format: :json,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue.iid
+
+ expect(session['user_return_to']).to be_blank
+ end
+ end
+
+ context 'without an AJAX request' do
+ it 'stores the visited URL' do
+ get :show,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: issue.iid
+
+ expect(session['user_return_to']).to eq("/#{project.namespace.to_param}/#{project.to_param}/issues/#{issue.iid}")
end
end
end
@@ -173,20 +224,22 @@ describe Projects::IssuesController do
namespace_id: project.namespace.to_param,
project_id: project,
id: issue.iid,
- issue: { assignee_id: assignee.id },
+ issue: { assignee_ids: [assignee.id] },
format: :json
body = JSON.parse(response.body)
- expect(body['assignee'].keys)
- .to match_array(%w(name username avatar_url))
+ expect(body['assignees'].first.keys)
+ .to match_array(%w(id name username avatar_url state web_url))
end
end
context 'when moving issue to another private project' do
- let(:another_project) { create(:empty_project, :private) }
+ let(:another_project) { create(:project, :private) }
context 'when user has access to move issue' do
- before { another_project.team << [user, :reporter] }
+ before do
+ another_project.team << [user, :reporter]
+ end
it 'moves issue to another project' do
move_issue
@@ -224,23 +277,28 @@ describe Projects::IssuesController do
end
context 'when an issue is identified as spam' do
- before { allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) }
+ before do
+ allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
+ end
context 'when captcha is not verified' do
def update_spam_issue
update_issue(title: 'Spam Title', description: 'Spam lives here')
end
- before { allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) }
+ before do
+ allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
+ end
it 'rejects an issue recognized as a spam' do
- expect { update_spam_issue }.not_to change{ issue.reload.title }
+ expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true)
+ expect { update_spam_issue }.not_to change { issue.reload.title }
end
it 'rejects an issue recognized as a spam when recaptcha disabled' do
stub_application_setting(recaptcha_enabled: false)
- expect { update_spam_issue }.not_to change{ issue.reload.title }
+ expect { update_spam_issue }.not_to change { issue.reload.title }
end
it 'creates a spam log' do
@@ -295,12 +353,12 @@ describe Projects::IssuesController do
it 'redirect to issue page' do
update_verified_issue
- expect(response).
- to redirect_to(namespace_project_issue_path(project.namespace, project, issue))
+ expect(response)
+ .to redirect_to(project_issue_path(project, issue))
end
it 'accepts an issue after recaptcha is verified' do
- expect{ update_verified_issue }.to change{ issue.reload.title }.to(spammy_title)
+ expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
end
it 'marks spam log as recaptcha_verified' do
@@ -310,8 +368,8 @@ describe Projects::IssuesController do
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
- expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }.
- not_to change { SpamLog.last.recaptcha_verified }
+ expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }
+ .not_to change { SpamLog.last.recaptcha_verified }
end
end
end
@@ -348,7 +406,7 @@ describe Projects::IssuesController do
let(:admin) { create(:admin) }
let!(:issue) { create(:issue, project: project) }
let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) }
- let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) }
+ let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) }
describe 'GET #index' do
it 'does not list confidential issues for guests' do
@@ -479,6 +537,36 @@ describe Projects::IssuesController do
end
end
+ describe 'GET #realtime_changes' do
+ it_behaves_like 'restricted action', success: 200
+
+ def go(id:)
+ get :realtime_changes,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: id
+ end
+
+ context 'when an issue was edited by a deleted user' do
+ let(:deleted_user) { create(:user) }
+
+ before do
+ project.team << [user, :developer]
+
+ issue.update!(last_edited_by: deleted_user, last_edited_at: Time.now)
+
+ deleted_user.destroy
+ sign_in(user)
+ end
+
+ it 'returns 200' do
+ go(id: issue.iid)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+
describe 'GET #edit' do
it_behaves_like 'restricted action', success: 200
@@ -506,7 +594,7 @@ describe Projects::IssuesController do
describe 'POST #create' do
def post_new_issue(issue_attrs = {}, additional_params = {})
sign_in(user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
project.team << [user, :developer]
post :create, {
@@ -593,14 +681,18 @@ describe Projects::IssuesController do
end
context 'when an issue is identified as spam' do
- before { allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) }
+ before do
+ allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
+ end
context 'when captcha is not verified' do
def post_spam_issue
post_new_issue(title: 'Spam Title', description: 'Spam lives here')
end
- before { allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) }
+ before do
+ allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
+ end
it 'rejects an issue recognized as a spam' do
expect { post_spam_issue }.not_to change(Issue, :count)
@@ -648,8 +740,8 @@ describe Projects::IssuesController do
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
- expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }.
- not_to change { SpamLog.last.recaptcha_verified }
+ expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }
+ .not_to change { SpamLog.last.recaptcha_verified }
end
end
end
@@ -665,7 +757,7 @@ describe Projects::IssuesController do
end
end
- context 'when description has slash commands' do
+ context 'when description has quick actions' do
before do
sign_in(user)
end
@@ -712,7 +804,10 @@ describe Projects::IssuesController do
describe "DELETE #destroy" do
context "when the user is a developer" do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
+
it "rejects a developer to destroy an issue" do
delete :destroy, namespace_id: project.namespace, project_id: project, id: issue.iid
expect(response).to have_http_status(404)
@@ -722,15 +817,17 @@ describe Projects::IssuesController do
context "when the user is owner" do
let(:owner) { create(:user) }
let(:namespace) { create(:namespace, owner: owner) }
- let(:project) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
- before { sign_in(owner) }
+ before do
+ sign_in(owner)
+ end
it "deletes the issue" do
delete :destroy, namespace_id: project.namespace, project_id: project, id: issue.iid
expect(response).to have_http_status(302)
- expect(controller).to set_flash[:notice].to(/The issue was successfully deleted\./).now
+ expect(controller).to set_flash[:notice].to(/The issue was successfully deleted\./)
end
it 'delegates the update of the todos count cache to TodoService' do
@@ -744,7 +841,7 @@ describe Projects::IssuesController do
describe 'POST #toggle_award_emoji' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it "toggles the award emoji" do
@@ -756,4 +853,30 @@ describe Projects::IssuesController do
expect(response).to have_http_status(200)
end
end
+
+ describe 'POST create_merge_request' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'creates a new merge request' do
+ expect { create_merge_request }.to change(project.merge_requests, :count).by(1)
+ end
+
+ it 'render merge request as json' do
+ create_merge_request
+
+ expect(response).to match_response_schema('merge_request')
+ end
+
+ def create_merge_request
+ post :create_merge_request, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: issue.to_param,
+ format: :json
+ end
+ end
end
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
new file mode 100644
index 00000000000..fdd7e6f173f
--- /dev/null
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -0,0 +1,443 @@
+require 'spec_helper'
+
+describe Projects::JobsController do
+ include ApiHelpers
+
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:user) { create(:user) }
+
+ before do
+ stub_not_protect_default_branch
+ end
+
+ describe 'GET index' do
+ context 'when scope is pending' do
+ before do
+ create(:ci_build, :pending, pipeline: pipeline)
+
+ get_index(scope: 'pending')
+ end
+
+ it 'has only pending builds' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:builds).first.status).to eq('pending')
+ end
+ end
+
+ context 'when scope is running' do
+ before do
+ create(:ci_build, :running, pipeline: pipeline)
+
+ get_index(scope: 'running')
+ end
+
+ it 'has only running jobs' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:builds).first.status).to eq('running')
+ end
+ end
+
+ context 'when scope is finished' do
+ before do
+ create(:ci_build, :success, pipeline: pipeline)
+
+ get_index(scope: 'finished')
+ end
+
+ it 'has only finished jobs' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:builds).first.status).to eq('success')
+ end
+ end
+
+ context 'when page is specified' do
+ let(:last_page) { project.builds.page.total_pages }
+
+ context 'when page number is eligible' do
+ before do
+ create_list(:ci_build, 2, pipeline: pipeline)
+
+ get_index(page: last_page.to_param)
+ end
+
+ it 'redirects to the page' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:builds).current_page).to eq(last_page)
+ end
+ end
+ end
+
+ context 'number of queries' do
+ before do
+ Ci::Build::AVAILABLE_STATUSES.each do |status|
+ create_job(status, status)
+ end
+ end
+
+ it 'verifies number of queries', :request_store do
+ recorded = ActiveRecord::QueryRecorder.new { get_index }
+ expect(recorded.count).to be_within(5).of(7)
+ end
+
+ def create_job(name, status)
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, :tags, :triggered, :artifacts,
+ pipeline: pipeline, name: name, status: status)
+ end
+ end
+
+ def get_index(**extra_params)
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }
+
+ get :index, params.merge(extra_params)
+ end
+ end
+
+ describe 'GET show' do
+ let!(:job) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ context 'when requesting HTML' do
+ context 'when job exists' do
+ before do
+ get_show(id: job.id)
+ end
+
+ it 'has a job' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:build).id).to eq(job.id)
+ end
+ end
+
+ context 'when job does not exist' do
+ before do
+ get_show(id: 1234)
+ end
+
+ it 'renders not_found' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when requesting JSON' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ allow_any_instance_of(Ci::Build).to receive(:merge_request).and_return(merge_request)
+
+ get_show(id: job.id, format: :json)
+ end
+
+ it 'exposes needed information' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['raw_path']).to match(/jobs\/\d+\/raw\z/)
+ expect(json_response.dig('merge_request', 'path')).to match(/merge_requests\/\d+\z/)
+ expect(json_response['new_issue_path'])
+ .to include('/issues/new')
+ end
+ end
+
+ def get_show(**extra_params)
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }
+
+ get :show, params.merge(extra_params)
+ end
+ end
+
+ describe 'GET trace.json' do
+ before do
+ get_trace
+ end
+
+ context 'when job has a trace' do
+ let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+
+ it 'returns a trace' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['id']).to eq job.id
+ expect(json_response['status']).to eq job.status
+ expect(json_response['html']).to eq('BUILD TRACE')
+ end
+ end
+
+ context 'when job has no traces' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'returns no traces' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['id']).to eq job.id
+ expect(json_response['status']).to eq job.status
+ expect(json_response['html']).to be_nil
+ end
+ end
+
+ context 'when job has a trace with ANSI sequence and Unicode' do
+ let(:job) { create(:ci_build, :unicode_trace, pipeline: pipeline) }
+
+ it 'returns a trace with Unicode' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['id']).to eq job.id
+ expect(json_response['status']).to eq job.status
+ expect(json_response['html']).to include("ヾ(´༎ຶД༎ຶ`)ノ")
+ end
+ end
+
+ def get_trace
+ get :trace, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id,
+ format: :json
+ end
+ end
+
+ describe 'GET status.json' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+ let(:status) { job.detailed_status(double('user')) }
+
+ before do
+ get :status, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id,
+ format: :json
+ end
+
+ it 'return a detailed job status in json' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['text']).to eq status.text
+ expect(json_response['label']).to eq status.label
+ expect(json_response['icon']).to eq status.icon
+ expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
+ end
+ end
+
+ describe 'POST retry' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ post_retry
+ end
+
+ context 'when job is retryable' do
+ let(:job) { create(:ci_build, :retryable, pipeline: pipeline) }
+
+ it 'redirects to the retried job page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id))
+ end
+ end
+
+ context 'when job is not retryable' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'renders unprocessable_entity' do
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ def post_retry
+ post :retry, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+ end
+
+ describe 'POST play' do
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: 'master', project: project)
+
+ sign_in(user)
+
+ post_play
+ end
+
+ context 'when job is playable' do
+ let(:job) { create(:ci_build, :playable, pipeline: pipeline) }
+
+ it 'redirects to the played job page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_job_path(id: job.id))
+ end
+
+ it 'transits to pending' do
+ expect(job.reload).to be_pending
+ end
+ end
+
+ context 'when job is not playable' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'renders unprocessable_entity' do
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ def post_play
+ post :play, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+ end
+
+ describe 'POST cancel' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ post_cancel
+ end
+
+ context 'when job is cancelable' do
+ let(:job) { create(:ci_build, :cancelable, pipeline: pipeline) }
+
+ it 'redirects to the canceled job page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_job_path(id: job.id))
+ end
+
+ it 'transits to canceled' do
+ expect(job.reload).to be_canceled
+ end
+ end
+
+ context 'when job is not cancelable' do
+ let(:job) { create(:ci_build, :canceled, pipeline: pipeline) }
+
+ it 'returns unprocessable_entity' do
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ def post_cancel
+ post :cancel, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+ end
+
+ describe 'POST cancel_all' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ context 'when jobs are cancelable' do
+ before do
+ create_list(:ci_build, 2, :cancelable, pipeline: pipeline)
+
+ post_cancel_all
+ end
+
+ it 'redirects to a index page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_jobs_path)
+ end
+
+ it 'transits to canceled' do
+ expect(Ci::Build.all).to all(be_canceled)
+ end
+ end
+
+ context 'when jobs are not cancelable' do
+ before do
+ create_list(:ci_build, 2, :canceled, pipeline: pipeline)
+
+ post_cancel_all
+ end
+
+ it 'redirects to a index page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_jobs_path)
+ end
+ end
+
+ def post_cancel_all
+ post :cancel_all, namespace_id: project.namespace,
+ project_id: project
+ end
+ end
+
+ describe 'POST erase' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ post_erase
+ end
+
+ context 'when job is erasable' do
+ let(:job) { create(:ci_build, :erasable, :trace, pipeline: pipeline) }
+
+ it 'redirects to the erased job page' do
+ expect(response).to have_http_status(:found)
+ expect(response).to redirect_to(namespace_project_job_path(id: job.id))
+ end
+
+ it 'erases artifacts' do
+ expect(job.artifacts_file.exists?).to be_falsey
+ expect(job.artifacts_metadata.exists?).to be_falsey
+ end
+
+ it 'erases trace' do
+ expect(job.trace.exist?).to be_falsey
+ end
+ end
+
+ context 'when job is not erasable' do
+ let(:job) { create(:ci_build, :erased, pipeline: pipeline) }
+
+ it 'returns unprocessable_entity' do
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ def post_erase
+ post :erase, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+ end
+
+ describe 'GET raw' do
+ before do
+ get_raw
+ end
+
+ context 'when job has a trace file' do
+ let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+
+ it 'send a trace file' do
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type).to eq 'text/plain; charset=utf-8'
+ expect(response.body).to eq 'BUILD TRACE'
+ end
+ end
+
+ context 'when job does not have a trace file' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'returns not_found' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ def get_raw
+ post :raw, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+ end
+end
diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb
index 05999431d8f..f4e2dca883d 100644
--- a/spec/controllers/projects/labels_controller_spec.rb
+++ b/spec/controllers/projects/labels_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::LabelsController do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
before do
@@ -73,7 +73,7 @@ describe Projects::LabelsController do
describe 'POST #generate' do
context 'personal project' do
- let(:personal_project) { create(:empty_project, namespace: user.namespace) }
+ let(:personal_project) { create(:project, namespace: user.namespace) }
it 'creates labels' do
post :generate, namespace_id: personal_project.namespace.to_param, project_id: personal_project
@@ -117,7 +117,7 @@ describe Projects::LabelsController do
let!(:promoted_label_name) { "Promoted Label" }
let!(:label_1) { create(:label, title: promoted_label_name, project: project) }
- context 'not group owner' do
+ context 'not group reporters' do
it 'denies access' do
post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param
@@ -125,9 +125,9 @@ describe Projects::LabelsController do
end
end
- context 'group owner' do
+ context 'group reporter' do
before do
- GroupMember.add_users(group, [user], :owner)
+ group.add_reporter(user)
end
it 'gives access' do
@@ -157,4 +157,74 @@ describe Projects::LabelsController do
end
end
end
+
+ describe '#ensure_canonical_path' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a GET request' do
+ context 'when requesting the canonical path' do
+ context 'non-show path' do
+ context 'with exactly matching casing' do
+ it 'does not redirect' do
+ get :index, namespace_id: project.namespace, project_id: project.to_param
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :index, namespace_id: project.namespace, project_id: project.to_param.upcase
+
+ expect(response).to redirect_to(project_labels_path(project))
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let!(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
+
+ it 'redirects to the canonical path' do
+ get :index, namespace_id: project.namespace, project_id: project.to_param + 'old'
+
+ expect(response).to redirect_to(project_labels_path(project))
+ expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, project))
+ end
+ end
+ end
+ end
+
+ context 'for a non-GET request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :generate, namespace_id: project.namespace, project_id: project
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ post :generate, namespace_id: project.namespace, project_id: project
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let!(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
+
+ it 'returns not found' do
+ post :generate, namespace_id: project.namespace, project_id: project.to_param + 'old'
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ def project_moved_message(redirect_route, project)
+ "Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb
index c5abf11cfa5..4eea7041d29 100644
--- a/spec/controllers/projects/mattermosts_controller_spec.rb
+++ b/spec/controllers/projects/mattermosts_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::MattermostsController do
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user) }
before do
@@ -11,8 +11,8 @@ describe Projects::MattermostsController do
describe 'GET #new' do
before do
- allow_any_instance_of(MattermostSlashCommandsService).
- to receive(:list_teams).and_return([])
+ allow_any_instance_of(MattermostSlashCommandsService)
+ .to receive(:list_teams).and_return([])
end
it 'accepts the request' do
@@ -38,7 +38,7 @@ describe Projects::MattermostsController do
it 'shows the error' do
allow_any_instance_of(MattermostSlashCommandsService).to receive(:configure).and_return([false, "error message"])
- expect(subject).to redirect_to(new_namespace_project_mattermost_url(project.namespace, project))
+ expect(subject).to redirect_to(new_project_mattermost_url(project))
end
end
@@ -51,7 +51,7 @@ describe Projects::MattermostsController do
subject
service = project.services.last
- expect(subject).to redirect_to(edit_namespace_project_service_url(project.namespace, project, service))
+ expect(subject).to redirect_to(edit_project_service_url(project, service))
end
end
end
diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
new file mode 100644
index 00000000000..393d38c6e6b
--- /dev/null
+++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
@@ -0,0 +1,307 @@
+require 'spec_helper'
+
+describe Projects::MergeRequests::ConflictsController do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
+ let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+ let(:merge_request_with_conflicts) do
+ create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
+ mr.mark_as_unmergeable
+ end
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET show' do
+ context 'when the conflicts cannot be resolved in the UI' do
+ before do
+ allow_any_instance_of(Gitlab::Conflict::Parser)
+ .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+
+ get :show,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ format: 'json'
+ end
+
+ it 'returns a 200 status code' do
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns JSON with a message' do
+ expect(json_response.keys).to contain_exactly('message', 'type')
+ end
+ end
+
+ context 'with valid conflicts' do
+ before do
+ get :show,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ format: 'json'
+ end
+
+ it 'matches the schema' do
+ expect(response).to match_response_schema('conflicts')
+ end
+
+ it 'includes meta info about the MR' do
+ expect(json_response['commit_message']).to include('Merge branch')
+ expect(json_response['commit_sha']).to match(/\h{40}/)
+ expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
+ expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
+ end
+
+ it 'includes each file that has conflicts' do
+ filenames = json_response['files'].map { |file| file['new_path'] }
+
+ expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
+ end
+
+ it 'splits files into sections with lines' do
+ json_response['files'].each do |file|
+ file['sections'].each do |section|
+ expect(section).to include('conflict', 'lines')
+
+ section['lines'].each do |line|
+ if section['conflict']
+ expect(line['type']).to be_in(%w(old new))
+ expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
+ else
+ if line['type'].nil?
+ expect(line['old_line']).not_to eq(nil)
+ expect(line['new_line']).not_to eq(nil)
+ else
+ expect(line['type']).to eq('match')
+ expect(line['old_line']).to eq(nil)
+ expect(line['new_line']).to eq(nil)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ it 'has unique section IDs across files' do
+ section_ids = json_response['files'].flat_map do |file|
+ file['sections'].map { |section| section['id'] }.compact
+ end
+
+ expect(section_ids.uniq).to eq(section_ids)
+ end
+ end
+ end
+
+ describe 'GET conflict_for_path' do
+ def conflict_for_path(path)
+ get :conflict_for_path,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ old_path: path,
+ new_path: path,
+ format: 'json'
+ end
+
+ context 'when the conflicts cannot be resolved in the UI' do
+ before do
+ allow_any_instance_of(Gitlab::Conflict::Parser)
+ .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+
+ conflict_for_path('files/ruby/regex.rb')
+ end
+
+ it 'returns a 404 status code' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when the file does not exist cannot be resolved in the UI' do
+ before do
+ conflict_for_path('files/ruby/regexp.rb')
+ end
+
+ it 'returns a 404 status code' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'with an existing file' do
+ let(:path) { 'files/ruby/regex.rb' }
+
+ before do
+ conflict_for_path(path)
+ end
+
+ it 'returns a 200 status code' do
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns the file in JSON format' do
+ content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts)
+ .file_for_path(path, path)
+ .content
+
+ expect(json_response).to include('old_path' => path,
+ 'new_path' => path,
+ 'blob_icon' => 'file-text-o',
+ 'blob_path' => a_string_ending_with(path),
+ 'blob_ace_mode' => 'ruby',
+ 'content' => content)
+ end
+ end
+ end
+
+ context 'POST resolve_conflicts' do
+ let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
+
+ def resolve_conflicts(files)
+ post :resolve_conflicts,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ format: 'json',
+ files: files,
+ commit_message: 'Commit message'
+ end
+
+ context 'with valid params' do
+ before do
+ resolved_files = [
+ {
+ 'new_path' => 'files/ruby/popen.rb',
+ 'old_path' => 'files/ruby/popen.rb',
+ 'sections' => {
+ '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
+ }
+ }, {
+ 'new_path' => 'files/ruby/regex.rb',
+ 'old_path' => 'files/ruby/regex.rb',
+ 'sections' => {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
+ }
+ }
+ ]
+
+ resolve_conflicts(resolved_files)
+ end
+
+ it 'creates a new commit on the branch' do
+ expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
+ expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
+ end
+
+ it 'returns an OK response' do
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context 'when sections are missing' do
+ before do
+ resolved_files = [
+ {
+ 'new_path' => 'files/ruby/popen.rb',
+ 'old_path' => 'files/ruby/popen.rb',
+ 'sections' => {
+ '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
+ }
+ }, {
+ 'new_path' => 'files/ruby/regex.rb',
+ 'old_path' => 'files/ruby/regex.rb',
+ 'sections' => {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head'
+ }
+ }
+ ]
+
+ resolve_conflicts(resolved_files)
+ end
+
+ it 'returns a 400 error' do
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'has a message with the name of the first missing section' do
+ expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21')
+ end
+
+ it 'does not create a new commit' do
+ expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
+ end
+ end
+
+ context 'when files are missing' do
+ before do
+ resolved_files = [
+ {
+ 'new_path' => 'files/ruby/regex.rb',
+ 'old_path' => 'files/ruby/regex.rb',
+ 'sections' => {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
+ }
+ }
+ ]
+
+ resolve_conflicts(resolved_files)
+ end
+
+ it 'returns a 400 error' do
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'has a message with the name of the missing file' do
+ expect(json_response['message']).to include('files/ruby/popen.rb')
+ end
+
+ it 'does not create a new commit' do
+ expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
+ end
+ end
+
+ context 'when a file has identical content to the conflict' do
+ before do
+ content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts)
+ .file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb')
+ .content
+
+ resolved_files = [
+ {
+ 'new_path' => 'files/ruby/popen.rb',
+ 'old_path' => 'files/ruby/popen.rb',
+ 'content' => content
+ }, {
+ 'new_path' => 'files/ruby/regex.rb',
+ 'old_path' => 'files/ruby/regex.rb',
+ 'sections' => {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
+ }
+ }
+ ]
+
+ resolve_conflicts(resolved_files)
+ end
+
+ it 'returns a 400 error' do
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'has a message with the path of the problem file' do
+ expect(json_response['message']).to include('files/ruby/popen.rb')
+ end
+
+ it 'does not create a new commit' do
+ expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
new file mode 100644
index 00000000000..fc4cec53374
--- /dev/null
+++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe Projects::MergeRequests::CreationsController do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
+ let(:fork_project) { create(:forked_project_with_submodules) }
+
+ before do
+ fork_project.team << [user, :master]
+
+ sign_in(user)
+ end
+
+ describe 'GET new' do
+ context 'merge request that removes a submodule' do
+ render_views
+
+ it 'renders new merge request widget template' do
+ get :new,
+ namespace_id: fork_project.namespace.to_param,
+ project_id: fork_project,
+ merge_request: {
+ source_branch: 'remove-submodule',
+ target_branch: 'master'
+ }
+
+ expect(response).to be_success
+ end
+ end
+ end
+
+ describe 'GET pipelines' do
+ before do
+ create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id,
+ ref: 'remove-submodule',
+ project: fork_project)
+ end
+
+ it 'renders JSON including serialized pipelines' do
+ get :pipelines,
+ namespace_id: fork_project.namespace.to_param,
+ project_id: fork_project,
+ merge_request: {
+ source_branch: 'remove-submodule',
+ target_branch: 'master'
+ },
+ format: :json
+
+ expect(response).to be_ok
+ expect(json_response).to have_key 'pipelines'
+ expect(json_response['pipelines']).not_to be_empty
+ end
+ end
+
+ describe 'GET diff_for_path' do
+ def diff_for_path(extra_params = {})
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ format: 'json'
+ }
+
+ get :diff_for_path, params.merge(extra_params)
+ end
+
+ let(:existing_path) { 'files/ruby/feature.rb' }
+
+ context 'when both branches are in the same project' do
+ it 'disables diff notes' do
+ diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' })
+
+ expect(assigns(:diff_notes_disabled)).to be_truthy
+ end
+
+ it 'only renders the diffs for the path given' do
+ expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
+ expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
+ meth.call(diffs)
+ end
+
+ diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' })
+ end
+ end
+
+ context 'when the source branch is in a different project to the target' do
+ let(:other_project) { create(:project, :repository) }
+
+ before do
+ other_project.team << [user, :master]
+ end
+
+ context 'when the path exists in the diff' do
+ it 'disables diff notes' do
+ diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' })
+
+ expect(assigns(:diff_notes_disabled)).to be_truthy
+ end
+
+ it 'only renders the diffs for the path given' do
+ expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
+ expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
+ meth.call(diffs)
+ end
+
+ diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' })
+ end
+ end
+
+ context 'when the path does not exist in the diff' do
+ before do
+ diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb', merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' })
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
new file mode 100644
index 00000000000..fad2c8f3ab7
--- /dev/null
+++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
@@ -0,0 +1,160 @@
+require 'spec_helper'
+
+describe Projects::MergeRequests::DiffsController do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
+ let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET show' do
+ def go(extra_params = {})
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid,
+ format: 'json'
+ }
+
+ get :show, params.merge(extra_params)
+ end
+
+ context 'with default params' do
+ context 'for the same project' do
+ before do
+ go
+ end
+
+ it 'renders the diffs template to a string' do
+ expect(response).to render_template('projects/merge_requests/diffs/_diffs')
+ expect(json_response).to have_key('html')
+ end
+ end
+
+ context 'with forked projects with submodules' do
+ render_views
+
+ let(:project) { create(:project, :repository) }
+ let(:fork_project) { create(:forked_project_with_submodules) }
+ let(:merge_request) { create(:merge_request_with_diffs, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) }
+
+ before do
+ fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id)
+ fork_project.save
+ merge_request.reload
+ go
+ end
+
+ it 'renders' do
+ expect(response).to be_success
+ expect(response.body).to have_content('Subproject commit')
+ end
+ end
+ end
+
+ context 'with ignore_whitespace_change' do
+ before do
+ go(w: 1)
+ end
+
+ it 'renders the diffs template to a string' do
+ expect(response).to render_template('projects/merge_requests/diffs/_diffs')
+ expect(json_response).to have_key('html')
+ end
+ end
+
+ context 'with view' do
+ before do
+ go(view: 'parallel')
+ end
+
+ it 'saves the preferred diff view in a cookie' do
+ expect(response.cookies['diff_view']).to eq('parallel')
+ end
+ end
+ end
+
+ describe 'GET diff_for_path' do
+ def diff_for_path(extra_params = {})
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid,
+ format: 'json'
+ }
+
+ get :diff_for_path, params.merge(extra_params)
+ end
+
+ let(:existing_path) { 'files/ruby/popen.rb' }
+
+ context 'when the merge request exists' do
+ context 'when the user can view the merge request' do
+ context 'when the path exists in the diff' do
+ it 'enables diff notes' do
+ diff_for_path(old_path: existing_path, new_path: existing_path)
+
+ expect(assigns(:diff_notes_disabled)).to be_falsey
+ 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
+ expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
+ expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
+ meth.call(diffs)
+ end
+
+ diff_for_path(old_path: existing_path, new_path: existing_path)
+ end
+ end
+
+ context 'when the path does not exist in the diff' do
+ before do
+ diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb')
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ context 'when the user cannot view the merge request' do
+ before do
+ project.team.truncate
+ diff_for_path(old_path: existing_path, new_path: existing_path)
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ context 'when the merge request does not exist' do
+ before do
+ diff_for_path(id: merge_request.iid.succ, old_path: existing_path, new_path: existing_path)
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when the merge request belongs to a different project' do
+ let(:other_project) { create(:project) }
+
+ before do
+ other_project.team << [user, :master]
+ diff_for_path(old_path: existing_path, new_path: existing_path, project_id: other_project)
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index a793da4162a..bb67db268fa 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
describe Projects::MergeRequestsController do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_with_conflicts) do
create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
@@ -12,50 +12,17 @@ describe Projects::MergeRequestsController do
before do
sign_in(user)
- project.team << [user, :master]
end
- describe 'GET new' do
- context 'merge request that removes a submodule' do
- render_views
-
- let(:fork_project) { create(:forked_project_with_submodules) }
- before { fork_project.team << [user, :master] }
-
- context 'when rendering HTML response' do
- it 'renders new merge request widget template' do
- submit_new_merge_request
-
- expect(response).to be_success
- end
- end
-
- context 'when rendering JSON response' do
- before do
- create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id,
- ref: 'remove-submodule',
- project: fork_project)
- end
-
- it 'renders JSON including serialized pipelines' do
- submit_new_merge_request(format: :json)
-
- expect(response).to be_ok
- expect(json_response).to have_key 'pipelines'
- expect(json_response['pipelines']).not_to be_empty
- end
- end
- end
+ describe 'GET commit_change_content' do
+ it 'renders commit_change_content template' do
+ get :commit_change_content,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid,
+ format: 'html'
- def submit_new_merge_request(format: :html)
- get :new,
- namespace_id: fork_project.namespace.to_param,
- project_id: fork_project,
- merge_request: {
- source_branch: 'remove-submodule',
- target_branch: 'master'
- },
- format: format
+ expect(response).to render_template('_commit_change_content')
end
end
@@ -71,63 +38,59 @@ describe Projects::MergeRequestsController do
end
describe "GET show" do
- shared_examples "export merge as" do |format|
- it "does generally work" do
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: format)
+ def go(extra_params = {})
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid
+ }
+
+ get :show, params.merge(extra_params)
+ end
+
+ it_behaves_like "loads labels", :show
+
+ describe 'as html' do
+ it "renders merge request page" do
+ go(format: :html)
expect(response).to be_success
end
+ end
- it_behaves_like "loads labels", :show
-
- it "generates it" do
- expect_any_instance_of(MergeRequest).to receive(:"to_#{format}")
+ describe 'as json' do
+ context 'with basic param' do
+ it 'renders basic MR entity as json' do
+ go(basic: true, format: :json)
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: format)
+ expect(response).to match_response_schema('entities/merge_request_basic')
+ end
end
- it "renders it" do
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: format)
+ context 'without basic param' do
+ it 'renders the merge request in the json format' do
+ go(format: :json)
- expect(response.body).to eq(merge_request.send(:"to_#{format}").to_s)
+ expect(response).to match_response_schema('entities/merge_request')
+ end
end
- it "does not escape Html" do
- allow_any_instance_of(MergeRequest).to receive(:"to_#{format}").
- and_return('HTML entities &<>" ')
+ context 'number of queries', :request_store do
+ it 'verifies number of queries' do
+ # pre-create objects
+ merge_request
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: format)
+ recorded = ActiveRecord::QueryRecorder.new { go(format: :json) }
- expect(response.body).not_to include('&amp;')
- expect(response.body).not_to include('&gt;')
- expect(response.body).not_to include('&lt;')
- expect(response.body).not_to include('&quot;')
+ expect(recorded.count).to be_within(5).of(30)
+ expect(recorded.cached_count).to eq(0)
+ end
end
end
describe "as diff" do
it "triggers workhorse to serve the request" do
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: :diff)
+ go(format: :diff)
expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-diff:")
end
@@ -135,11 +98,7 @@ describe Projects::MergeRequestsController do
describe "as patch" do
it 'triggers workhorse to serve the request' do
- get(:show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: :patch)
+ go(format: :patch)
expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-format-patch:")
end
@@ -147,7 +106,7 @@ describe Projects::MergeRequestsController do
end
describe 'GET index' do
- let!(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+ let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
def get_merge_requests(page = nil)
get :index,
@@ -191,6 +150,8 @@ describe Projects::MergeRequestsController do
context 'when filtering by opened state' do
context 'with opened merge requests' do
it 'lists those merge requests' do
+ expect(merge_request).to be_persisted
+
get_merge_requests
expect(assigns(:merge_requests)).to include(merge_request)
@@ -232,7 +193,7 @@ describe Projects::MergeRequestsController do
end
context 'there is no source project' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:fork_project) { create(:forked_project_with_submodules) }
let(:merge_request) { create(:merge_request, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) }
@@ -295,19 +256,20 @@ describe Projects::MergeRequestsController do
namespace_id: project.namespace,
project_id: project,
id: merge_request.iid,
- format: 'raw'
+ format: 'json'
}
end
- context 'when the user does not have access' do
+ context 'when user cannot access' do
+ let(:user) { create(:user) }
+
before do
- project.team.truncate
- project.team << [user, :reporter]
- post :merge, base_params
+ project.add_reporter(user)
+ xhr :post, :merge, base_params
end
- it 'returns not found' do
- expect(response).to be_not_found
+ it 'returns 404' do
+ expect(response).to have_http_status(404)
end
end
@@ -319,15 +281,17 @@ describe Projects::MergeRequestsController do
end
it 'returns :failed' do
- expect(assigns(:status)).to eq(:failed)
+ expect(json_response).to eq('status' => 'failed')
end
end
context 'when the sha parameter does not match the source SHA' do
- before { post :merge, base_params.merge(sha: 'foo') }
+ before do
+ post :merge, base_params.merge(sha: 'foo')
+ end
it 'returns :sha_mismatch' do
- expect(assigns(:status)).to eq(:sha_mismatch)
+ expect(json_response).to eq('status' => 'sha_mismatch')
end
end
@@ -339,7 +303,7 @@ describe Projects::MergeRequestsController do
it 'returns :success' do
merge_with_sha
- expect(assigns(:status)).to eq(:success)
+ expect(json_response).to eq('status' => 'success')
end
it 'starts the merge immediately' do
@@ -354,13 +318,13 @@ describe Projects::MergeRequestsController do
end
before do
- create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch)
+ create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch, head_pipeline_of: merge_request)
end
it 'returns :merge_when_pipeline_succeeds' do
merge_when_pipeline_succeeds
- expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds)
+ expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds')
end
it 'sets the MR to merge when the pipeline succeeds' do
@@ -382,7 +346,7 @@ describe Projects::MergeRequestsController do
it 'returns :merge_when_pipeline_succeeds' do
merge_when_pipeline_succeeds
- expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds)
+ expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds')
end
end
end
@@ -403,7 +367,7 @@ describe Projects::MergeRequestsController do
it 'returns :failed' do
merge_with_sha
- expect(assigns(:status)).to eq(:failed)
+ expect(json_response).to eq('status' => 'failed')
end
end
@@ -416,7 +380,7 @@ describe Projects::MergeRequestsController do
it 'returns :success' do
merge_with_sha
- expect(assigns(:status)).to eq(:success)
+ expect(json_response).to eq('status' => 'success')
end
end
end
@@ -434,7 +398,7 @@ describe Projects::MergeRequestsController do
it 'returns :success' do
merge_with_sha
- expect(assigns(:status)).to eq(:success)
+ expect(json_response).to eq('status' => 'success')
end
end
@@ -447,7 +411,7 @@ describe Projects::MergeRequestsController do
it 'returns :success' do
merge_with_sha
- expect(assigns(:status)).to eq(:success)
+ expect(json_response).to eq('status' => 'success')
end
end
end
@@ -456,6 +420,8 @@ describe Projects::MergeRequestsController do
end
describe "DELETE destroy" do
+ let(:user) { create(:user) }
+
it "denies access to users unless they're admin or project owner" do
delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid
@@ -465,15 +431,17 @@ describe Projects::MergeRequestsController do
context "when the user is owner" do
let(:owner) { create(:user) }
let(:namespace) { create(:namespace, owner: owner) }
- let(:project) { create(:project, namespace: namespace) }
+ let(:project) { create(:project, :repository, namespace: namespace) }
- before { sign_in owner }
+ before do
+ sign_in owner
+ end
it "deletes the merge request" do
delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid
expect(response).to have_http_status(302)
- expect(controller).to set_flash[:notice].to(/The merge request was successfully deleted\./).now
+ expect(controller).to set_flash[:notice].to(/The merge request was successfully deleted\./)
end
it 'delegates the update of the todos count cache to TodoService' do
@@ -484,216 +452,6 @@ describe Projects::MergeRequestsController do
end
end
- describe 'GET diffs' do
- def go(extra_params = {})
- params = {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid
- }
-
- get :diffs, params.merge(extra_params)
- end
-
- it_behaves_like "loads labels", :diffs
-
- context 'with default params' do
- context 'as html' do
- before { go(format: 'html') }
-
- it 'renders the diff template' do
- expect(response).to render_template('diffs')
- end
- end
-
- context 'as json' do
- before { go(format: 'json') }
-
- it 'renders the diffs template to a string' do
- expect(response).to render_template('projects/merge_requests/show/_diffs')
- expect(json_response).to have_key('html')
- end
- end
-
- context 'with forked projects with submodules' do
- render_views
-
- let(:project) { create(:project) }
- let(:fork_project) { create(:forked_project_with_submodules) }
- let(:merge_request) { create(:merge_request_with_diffs, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) }
-
- before do
- fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id)
- fork_project.save
- merge_request.reload
- go(format: 'json')
- end
-
- it 'renders' do
- expect(response).to be_success
- expect(response.body).to have_content('Subproject commit')
- end
- end
- end
-
- context 'with ignore_whitespace_change' do
- context 'as html' do
- before { go(format: 'html', w: 1) }
-
- it 'renders the diff template' do
- expect(response).to render_template('diffs')
- end
- end
-
- context 'as json' do
- before { go(format: 'json', w: 1) }
-
- it 'renders the diffs template to a string' do
- expect(response).to render_template('projects/merge_requests/show/_diffs')
- expect(json_response).to have_key('html')
- end
- end
- end
-
- context 'with view' do
- before { go(view: 'parallel') }
-
- it 'saves the preferred diff view in a cookie' do
- expect(response.cookies['diff_view']).to eq('parallel')
- end
- end
- end
-
- describe 'GET diff_for_path' do
- def diff_for_path(extra_params = {})
- params = {
- namespace_id: project.namespace.to_param,
- project_id: project
- }
-
- get :diff_for_path, params.merge(extra_params)
- end
-
- context 'when an ID param is passed' do
- let(:existing_path) { 'files/ruby/popen.rb' }
-
- context 'when the merge request exists' do
- context 'when the user can view the merge request' do
- context 'when the path exists in the diff' do
- it 'enables diff notes' 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(: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
- expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
- expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
- meth.call(diffs)
- end
-
- diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path)
- end
- end
-
- context 'when the path does not exist in the diff' do
- before { diff_for_path(id: merge_request.iid, old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb') }
-
- it 'returns a 404' do
- expect(response).to have_http_status(404)
- end
- end
- end
-
- context 'when the user cannot view the merge request' do
- before do
- project.team.truncate
- diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path)
- end
-
- it 'returns a 404' do
- expect(response).to have_http_status(404)
- end
- end
- end
-
- context 'when the merge request does not exist' do
- before { diff_for_path(id: merge_request.iid.succ, old_path: existing_path, new_path: existing_path) }
-
- it 'returns a 404' do
- expect(response).to have_http_status(404)
- end
- end
-
- context 'when the merge request belongs to a different project' do
- let(:other_project) { create(:empty_project) }
-
- before do
- other_project.team << [user, :master]
- diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path, project_id: other_project)
- end
-
- it 'returns a 404' do
- expect(response).to have_http_status(404)
- end
- end
- end
-
- context 'when source and target params are passed' do
- let(:existing_path) { 'files/ruby/feature.rb' }
-
- context 'when both branches are in the same project' do
- it 'disables diff notes' do
- diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' })
-
- expect(assigns(:diff_notes_disabled)).to be_truthy
- end
-
- it 'only renders the diffs for the path given' do
- expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
- expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
- meth.call(diffs)
- end
-
- diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' })
- end
- end
-
- context 'when the source branch is in a different project to the target' do
- let(:other_project) { create(:project) }
-
- before { other_project.team << [user, :master] }
-
- context 'when the path exists in the diff' do
- it 'disables diff notes' do
- diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' })
-
- expect(assigns(:diff_notes_disabled)).to be_truthy
- end
-
- it 'only renders the diffs for the path given' do
- expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
- expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
- meth.call(diffs)
- end
-
- diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' })
- end
- end
-
- context 'when the path does not exist in the diff' do
- before { diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb', merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) }
-
- it 'returns a 404' do
- expect(response).to have_http_status(404)
- end
- end
- end
- end
- end
-
describe 'GET commits' do
def go(format: 'html')
get :commits,
@@ -703,23 +461,11 @@ describe Projects::MergeRequestsController do
format: format
end
- it_behaves_like "loads labels", :commits
-
- context 'as html' do
- it 'renders the show template' do
- go
+ it 'renders the commits template to a string' do
+ go format: 'json'
- expect(response).to render_template('show')
- end
- end
-
- context 'as json' do
- it 'renders the commits template to a string' do
- go format: 'json'
-
- expect(response).to render_template('projects/merge_requests/show/_commits')
- expect(json_response).to have_key('html')
- end
+ expect(response).to render_template('projects/merge_requests/_commits')
+ expect(json_response).to have_key('html')
end
end
@@ -728,319 +474,68 @@ describe Projects::MergeRequestsController do
create(:ci_pipeline, project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
- end
- context 'when using HTML format' do
- it_behaves_like "loads labels", :pipelines
+ get :pipelines,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid,
+ format: :json
end
- context 'when using JSON format' do
- before do
- get :pipelines,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- format: :json
- end
-
- it 'responds with serialized pipelines' do
- expect(json_response).not_to be_empty
- end
+ it 'responds with serialized pipelines' do
+ expect(json_response['pipelines']).not_to be_empty
+ expect(json_response['count']['all']).to eq 1
end
end
- describe 'GET conflicts' do
- context 'when the conflicts cannot be resolved in the UI' do
- before do
- allow_any_instance_of(Gitlab::Conflict::Parser).
- to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
-
- get :conflicts,
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- format: 'json'
- end
-
- it 'returns a 200 status code' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'returns JSON with a message' do
- expect(json_response.keys).to contain_exactly('message', 'type')
- end
- end
-
- context 'with valid conflicts' do
- before do
- get :conflicts,
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- format: 'json'
- end
-
- it 'matches the schema' do
- expect(response).to match_response_schema('conflicts')
- end
-
- it 'includes meta info about the MR' do
- expect(json_response['commit_message']).to include('Merge branch')
- expect(json_response['commit_sha']).to match(/\h{40}/)
- expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
- expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
- end
-
- it 'includes each file that has conflicts' do
- filenames = json_response['files'].map { |file| file['new_path'] }
-
- expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
- end
-
- it 'splits files into sections with lines' do
- json_response['files'].each do |file|
- file['sections'].each do |section|
- expect(section).to include('conflict', 'lines')
-
- section['lines'].each do |line|
- if section['conflict']
- expect(line['type']).to be_in(%w(old new))
- expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
- else
- if line['type'].nil?
- expect(line['old_line']).not_to eq(nil)
- expect(line['new_line']).not_to eq(nil)
- else
- expect(line['type']).to eq('match')
- expect(line['old_line']).to eq(nil)
- expect(line['new_line']).to eq(nil)
- end
- end
- end
- end
- end
- end
-
- it 'has unique section IDs across files' do
- section_ids = json_response['files'].flat_map do |file|
- file['sections'].map { |section| section['id'] }.compact
- end
-
- expect(section_ids.uniq).to eq(section_ids)
- end
- end
- end
-
- context 'POST remove_wip' do
- it 'removes the wip status' do
+ describe 'POST remove_wip' do
+ before do
merge_request.title = merge_request.wip_title
merge_request.save
- post :remove_wip,
- namespace_id: merge_request.project.namespace.to_param,
- project_id: merge_request.project,
- id: merge_request.iid
-
- expect(merge_request.reload.title).to eq(merge_request.wipless_title)
- end
- end
-
- describe 'GET conflict_for_path' do
- def conflict_for_path(path)
- get :conflict_for_path,
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- old_path: path,
- new_path: path,
- format: 'json'
- end
-
- context 'when the conflicts cannot be resolved in the UI' do
- before do
- allow_any_instance_of(Gitlab::Conflict::Parser).
- to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
-
- conflict_for_path('files/ruby/regex.rb')
- end
-
- it 'returns a 404 status code' do
- expect(response).to have_http_status(:not_found)
- end
+ xhr :post, :remove_wip,
+ namespace_id: merge_request.project.namespace.to_param,
+ project_id: merge_request.project,
+ id: merge_request.iid,
+ format: :json
end
- context 'when the file does not exist cannot be resolved in the UI' do
- before { conflict_for_path('files/ruby/regexp.rb') }
-
- it 'returns a 404 status code' do
- expect(response).to have_http_status(:not_found)
- end
+ it 'removes the wip status' do
+ expect(merge_request.reload.title).to eq(merge_request.wipless_title)
end
- context 'with an existing file' do
- let(:path) { 'files/ruby/regex.rb' }
-
- before { conflict_for_path(path) }
-
- it 'returns a 200 status code' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'returns the file in JSON format' do
- content = merge_request_with_conflicts.conflicts.file_for_path(path, path).content
-
- expect(json_response).to include('old_path' => path,
- 'new_path' => path,
- 'blob_icon' => 'file-text-o',
- 'blob_path' => a_string_ending_with(path),
- 'blob_ace_mode' => 'ruby',
- 'content' => content)
- end
+ it 'renders MergeRequest as JSON' do
+ expect(json_response.keys).to include('id', 'iid', 'description')
end
end
- context 'POST resolve_conflicts' do
- let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
-
- def resolve_conflicts(files)
- post :resolve_conflicts,
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- format: 'json',
- files: files,
- commit_message: 'Commit message'
- end
-
- context 'with valid params' do
- before do
- resolved_files = [
- {
- 'new_path' => 'files/ruby/popen.rb',
- 'old_path' => 'files/ruby/popen.rb',
- 'sections' => {
- '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
- }
- }, {
- 'new_path' => 'files/ruby/regex.rb',
- 'old_path' => 'files/ruby/regex.rb',
- 'sections' => {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
- }
- }
- ]
-
- resolve_conflicts(resolved_files)
- end
-
- it 'creates a new commit on the branch' do
- expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
- expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
- end
-
- it 'returns an OK response' do
- expect(response).to have_http_status(:ok)
- end
+ describe 'POST cancel_merge_when_pipeline_succeeds' do
+ subject do
+ xhr :post, :cancel_merge_when_pipeline_succeeds,
+ namespace_id: merge_request.project.namespace.to_param,
+ project_id: merge_request.project,
+ id: merge_request.iid,
+ format: :json
end
- context 'when sections are missing' do
- before do
- resolved_files = [
- {
- 'new_path' => 'files/ruby/popen.rb',
- 'old_path' => 'files/ruby/popen.rb',
- 'sections' => {
- '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
- }
- }, {
- 'new_path' => 'files/ruby/regex.rb',
- 'old_path' => 'files/ruby/regex.rb',
- 'sections' => {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head'
- }
- }
- ]
-
- resolve_conflicts(resolved_files)
- end
+ it 'calls MergeRequests::MergeWhenPipelineSucceedsService' do
+ mwps_service = double
- it 'returns a 400 error' do
- expect(response).to have_http_status(:bad_request)
- end
+ allow(MergeRequests::MergeWhenPipelineSucceedsService)
+ .to receive(:new)
+ .and_return(mwps_service)
- it 'has a message with the name of the first missing section' do
- expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21')
- end
+ expect(mwps_service).to receive(:cancel).with(merge_request)
- it 'does not create a new commit' do
- expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
- end
+ subject
end
- context 'when files are missing' do
- before do
- resolved_files = [
- {
- 'new_path' => 'files/ruby/regex.rb',
- 'old_path' => 'files/ruby/regex.rb',
- 'sections' => {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
- }
- }
- ]
+ it { is_expected.to have_http_status(:success) }
- resolve_conflicts(resolved_files)
- end
-
- it 'returns a 400 error' do
- expect(response).to have_http_status(:bad_request)
- end
+ it 'renders MergeRequest as JSON' do
+ subject
- it 'has a message with the name of the missing file' do
- expect(json_response['message']).to include('files/ruby/popen.rb')
- end
-
- it 'does not create a new commit' do
- expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
- end
- end
-
- context 'when a file has identical content to the conflict' do
- before do
- resolved_files = [
- {
- 'new_path' => 'files/ruby/popen.rb',
- 'old_path' => 'files/ruby/popen.rb',
- 'content' => merge_request_with_conflicts.conflicts.file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb').content
- }, {
- 'new_path' => 'files/ruby/regex.rb',
- 'old_path' => 'files/ruby/regex.rb',
- 'sections' => {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
- }
- }
- ]
-
- resolve_conflicts(resolved_files)
- end
-
- it 'returns a 400 error' do
- expect(response).to have_http_status(:bad_request)
- end
-
- it 'has a message with the path of the problem file' do
- expect(json_response['message']).to include('files/ruby/popen.rb')
- end
-
- it 'does not create a new commit' do
- expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
- end
+ expect(json_response.keys).to include('id', 'iid', 'description')
end
end
@@ -1067,7 +562,7 @@ describe Projects::MergeRequestsController do
end
it 'correctly pluralizes flash message on success' do
- issue2.update!(assignee: user)
+ issue2.assignees = [user]
post_assign_issues
@@ -1075,9 +570,9 @@ describe Projects::MergeRequestsController do
end
it 'calls MergeRequests::AssignIssuesService' do
- expect(MergeRequests::AssignIssuesService).to receive(:new).
- with(project, user, merge_request: merge_request).
- and_return(double(execute: { count: 1 }))
+ expect(MergeRequests::AssignIssuesService).to receive(:new)
+ .with(project, user, merge_request: merge_request)
+ .and_return(double(execute: { count: 1 }))
post_assign_issues
end
@@ -1094,7 +589,7 @@ describe Projects::MergeRequestsController do
describe 'GET ci_environments_status' do
context 'the environment is from a forked project' do
- let!(:forked) { create(:project) }
+ let!(:forked) { create(:project, :repository) }
let!(:environment) { create(:environment, project: forked) }
let!(:deployment) { create(:deployment, environment: environment, sha: forked.commit.id, ref: 'master') }
let(:admin) { create(:admin) }
@@ -1116,75 +611,7 @@ describe Projects::MergeRequestsController do
end
it 'links to the environment on that project' do
- expect(json_response.first['url']).to match /#{forked.path_with_namespace}/
- end
- end
- end
-
- describe 'GET merge_widget_refresh' do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- id: merge_request.iid,
- format: :raw
- }
- end
-
- before do
- project.team << [user, :developer]
- xhr :get, :merge_widget_refresh, params
- end
-
- context 'when merge in progress' do
- let(:merge_request) { create(:merge_request, source_project: project, in_progress_merge_commit_sha: 'sha') }
-
- it 'returns an OK response' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'sets status to :success' do
- expect(assigns(:status)).to eq(:success)
- expect(response).to render_template('merge')
- end
- end
-
- context 'when merge request was merged already' do
- let(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
-
- it 'returns an OK response' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'sets status to :success' do
- expect(assigns(:status)).to eq(:success)
- expect(response).to render_template('merge')
- end
- end
-
- context 'when waiting for build' do
- let(:merge_request) { create(:merge_request, source_project: project, merge_when_pipeline_succeeds: true, merge_user: user) }
-
- it 'returns an OK response' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'sets status to :merge_when_pipeline_succeeds' do
- expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds)
- expect(response).to render_template('merge')
- end
- end
-
- context 'when MR does not have special state' do
- let(:merge_request) { create(:merge_request, source_project: project) }
-
- it 'returns an OK response' do
- expect(response).to have_http_status(:ok)
- end
-
- it 'sets status to success' do
- expect(assigns(:status)).to eq(:success)
- expect(response).to render_template('merge')
+ expect(json_response.first['url']).to match /#{forked.full_path}/
end
end
end
@@ -1194,12 +621,15 @@ describe Projects::MergeRequestsController do
let!(:pipeline) do
create(:ci_pipeline, project: merge_request.source_project,
ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha)
+ sha: merge_request.diff_head_sha,
+ head_pipeline_of: merge_request)
end
let(:status) { pipeline.detailed_status(double('user')) }
- before { get_pipeline_status }
+ before do
+ get_pipeline_status
+ end
it 'return a detailed head_pipeline status in json' do
expect(response).to have_http_status(:ok)
@@ -1211,7 +641,9 @@ describe Projects::MergeRequestsController do
end
context 'when head_pipeline does not exist' do
- before { get_pipeline_status }
+ before do
+ get_pipeline_status
+ end
it 'return empty' do
expect(response).to have_http_status(:ok)
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 47e61c3cea8..62f1fb1f697 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -1,12 +1,13 @@
require 'spec_helper'
describe Projects::MilestonesController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
let(:issue) { create(:issue, project: project, milestone: milestone) }
let!(:label) { create(:label, project: project, title: 'Issue Label', issues: [issue]) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) }
+ let(:milestone_path) { namespace_project_milestone_path }
before do
sign_in(user)
@@ -14,6 +15,8 @@ describe Projects::MilestonesController do
controller.instance_variable_set(:@project, project)
end
+ it_behaves_like 'milestone tabs'
+
describe "#show" do
render_views
@@ -28,6 +31,40 @@ describe Projects::MilestonesController do
end
end
+ describe "#index" do
+ context "as html" do
+ before do
+ get :index, namespace_id: project.namespace.id, project_id: project.id
+ end
+
+ it "queries only projects milestones" do
+ milestones = assigns(:milestones)
+
+ expect(milestones.count).to eq(1)
+ expect(milestones.where(project_id: nil)).to be_empty
+ end
+ end
+
+ context "as json" do
+ let!(:group) { create(:group, :public) }
+ let!(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ before do
+ project.update(namespace: group)
+ get :index, namespace_id: project.namespace.id, project_id: project.id, format: :json
+ end
+
+ it "queries projects milestones and groups milestones" do
+ milestones = assigns(:milestones)
+
+ expect(milestones.count).to eq(2)
+ expect(milestones.where(project_id: nil).first).to eq(group_milestone)
+ expect(milestones.where(group_id: nil).first).to eq(milestone)
+ end
+ end
+ end
+
describe "#destroy" do
it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id)
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index f140eaef5d5..f280c55059c 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::NotesController do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:note) { create(:note, noteable: issue, project: project) }
@@ -131,7 +131,7 @@ describe Projects::NotesController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it "returns status 302 for html" do
@@ -165,6 +165,107 @@ describe Projects::NotesController do
expect(response).to have_http_status(302)
end
end
+
+ context 'when creating a commit comment from an MR fork' do
+ let(:project) { create(:project, :repository) }
+
+ let(:fork_project) do
+ create(:project, :repository).tap do |fork|
+ create(:forked_project_link, forked_to_project: fork, forked_from_project: project)
+ end
+ end
+
+ let(:merge_request) do
+ create(:merge_request, source_project: fork_project, target_project: project, source_branch: 'feature', target_branch: 'master')
+ end
+
+ let(:existing_comment) do
+ create(:note_on_commit, note: 'a note', project: fork_project, commit_id: merge_request.commit_shas.first)
+ end
+
+ def post_create(extra_params = {})
+ post :create, {
+ note: { note: 'some other note' },
+ namespace_id: project.namespace,
+ project_id: project,
+ target_type: 'merge_request',
+ target_id: merge_request.id,
+ note_project_id: fork_project.id,
+ in_reply_to_discussion_id: existing_comment.discussion_id
+ }.merge(extra_params)
+ end
+
+ context 'when the note_project_id is not correct' do
+ it 'returns a 404' do
+ post_create(note_project_id: Project.maximum(:id).succ)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when the user has no access to the fork' do
+ it 'returns a 404' do
+ post_create
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when the user has access to the fork' do
+ let(:discussion) { fork_project.notes.find_discussion(existing_comment.discussion_id) }
+
+ before do
+ fork_project.add_developer(user)
+
+ existing_comment
+ end
+
+ it 'creates the note' do
+ expect { post_create }.to change { fork_project.notes.count }.by(1)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE destroy' do
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: note,
+ format: :js
+ }
+ end
+
+ context 'user is the author of a note' do
+ before do
+ sign_in(note.author)
+ project.team << [note.author, :developer]
+ end
+
+ it "returns status 200 for html" do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(200)
+ end
+
+ it "deletes the note" do
+ expect { delete :destroy, request_params }.to change { Note.count }.from(1).to(0)
+ end
+ end
+
+ context 'user is not the author of a note' do
+ before do
+ sign_in(user)
+ project.team << [user, :developer]
+ end
+
+ it "returns status 404" do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
end
describe 'POST toggle_award_emoji' do
diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb
new file mode 100644
index 00000000000..4d0111302f3
--- /dev/null
+++ b/spec/controllers/projects/pages_controller_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe Projects::PagesController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :access_requestable) }
+
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project
+ }
+ end
+
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
+ sign_in(user)
+ project.add_master(user)
+ end
+
+ describe 'GET show' do
+ it 'returns 200 status' do
+ get :show, request_params
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ describe 'DELETE destroy' do
+ it 'returns 302 status' do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'pages disabled' do
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
+ end
+
+ describe 'GET show' do
+ it 'returns 404 status' do
+ get :show, request_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ describe 'DELETE destroy' do
+ it 'returns 404 status' do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb
index 2362df895a8..ad4d7da3bdd 100644
--- a/spec/controllers/projects/pages_domains_controller_spec.rb
+++ b/spec/controllers/projects/pages_domains_controller_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
describe Projects::PagesDomainsController do
- let(:user) { create(:user) }
+ let(:user) { create(:user) }
let(:project) { create(:project) }
+ let!(:pages_domain) { create(:pages_domain, project: project) }
let(:request_params) do
{
@@ -11,14 +12,17 @@ describe Projects::PagesDomainsController do
}
end
+ let(:pages_domain_params) do
+ build(:pages_domain, :with_certificate, :with_key, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain)
+ end
+
before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET show' do
- let!(:pages_domain) { create(:pages_domain, project: project) }
-
it "displays the 'show' page" do
get(:show, request_params.merge(id: pages_domain.domain))
@@ -37,28 +41,60 @@ describe Projects::PagesDomainsController do
end
describe 'POST create' do
- let(:pages_domain_params) do
- build(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate, :domain)
- end
-
it "creates a new pages domain" do
expect do
post(:create, request_params.merge(pages_domain: pages_domain_params))
end.to change { PagesDomain.count }.by(1)
- expect(response).to redirect_to(namespace_project_pages_path(project.namespace, project))
+ expect(response).to redirect_to(project_pages_path(project))
end
end
describe 'DELETE destroy' do
- let!(:pages_domain) { create(:pages_domain, project: project) }
-
it "deletes the pages domain" do
expect do
delete(:destroy, request_params.merge(id: pages_domain.domain))
end.to change { PagesDomain.count }.by(-1)
- expect(response).to redirect_to(namespace_project_pages_path(project.namespace, project))
+ expect(response).to redirect_to(project_pages_path(project))
+ end
+ end
+
+ context 'pages disabled' do
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
+ end
+
+ describe 'GET show' do
+ it 'returns 404 status' do
+ get(:show, request_params.merge(id: pages_domain.domain))
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ describe 'GET new' do
+ it 'returns 404 status' do
+ get :new, request_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ describe 'POST create' do
+ it "returns 404 status" do
+ post(:create, request_params.merge(pages_domain: pages_domain_params))
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ describe 'DELETE destroy' do
+ it "deletes the pages domain" do
+ delete(:destroy, request_params.merge(id: pages_domain.domain))
+
+ expect(response).to have_http_status(404)
+ end
end
end
end
diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
new file mode 100644
index 00000000000..4ac0559c679
--- /dev/null
+++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
@@ -0,0 +1,398 @@
+require 'spec_helper'
+
+describe Projects::PipelineSchedulesController do
+ include AccessMatchersForController
+
+ set(:project) { create(:project, :public) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
+
+ describe 'GET #index' do
+ let(:scope) { nil }
+ let!(:inactive_pipeline_schedule) do
+ create(:ci_pipeline_schedule, :inactive, project: project)
+ end
+
+ it 'renders the index view' do
+ visit_pipelines_schedules
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+
+ it 'avoids N + 1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count
+
+ create_list(:ci_pipeline_schedule, 2, project: project)
+
+ expect { visit_pipelines_schedules }.not_to exceed_query_limit(control_count)
+ end
+
+ context 'when the scope is set to active' do
+ let(:scope) { 'active' }
+
+ before do
+ visit_pipelines_schedules
+ end
+
+ it 'only shows active pipeline schedules' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:schedules)).to include(pipeline_schedule)
+ expect(assigns(:schedules)).not_to include(inactive_pipeline_schedule)
+ end
+ end
+
+ def visit_pipelines_schedules
+ get :index, namespace_id: project.namespace.to_param, project_id: project, scope: scope
+ end
+ end
+
+ describe 'GET #new' do
+ set(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'initializes a pipeline schedule model' do
+ get :new, namespace_id: project.namespace.to_param, project_id: project
+
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:schedule)).to be_a_new(Ci::PipelineSchedule)
+ end
+ end
+
+ describe 'POST #create' do
+ describe 'functionality' do
+ set(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ let(:basic_param) do
+ attributes_for(:ci_pipeline_schedule)
+ end
+
+ context 'when variables_attributes has one variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ })
+ end
+
+ it 'creates a new schedule' do
+ expect { go }
+ .to change { Ci::PipelineSchedule.count }.by(1)
+ .and change { Ci::PipelineScheduleVariable.count }.by(1)
+
+ expect(response).to have_http_status(:found)
+
+ Ci::PipelineScheduleVariable.last.tap do |v|
+ expect(v.key).to eq("AAA")
+ expect(v.value).to eq("AAA123")
+ end
+ end
+ end
+
+ context 'when variables_attributes has two variables and duplicted' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
+ })
+ end
+
+ it 'returns an error that the keys of variable are duplicated' do
+ expect { go }
+ .to change { Ci::PipelineSchedule.count }.by(0)
+ .and change { Ci::PipelineScheduleVariable.count }.by(0)
+
+ expect(assigns(:schedule).errors['variables']).not_to be_empty
+ end
+ end
+ end
+
+ describe 'security' do
+ let(:schedule) { attributes_for(:ci_pipeline_schedule) }
+
+ it { expect { go }.to be_allowed_for(:admin) }
+ it { expect { go }.to be_allowed_for(:owner).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_allowed_for(:developer).of(project) }
+ it { expect { go }.to be_denied_for(:reporter).of(project) }
+ it { expect { go }.to be_denied_for(:guest).of(project) }
+ it { expect { go }.to be_denied_for(:user) }
+ it { expect { go }.to be_denied_for(:external) }
+ it { expect { go }.to be_denied_for(:visitor) }
+ end
+
+ def go
+ post :create, namespace_id: project.namespace.to_param, project_id: project, schedule: schedule
+ end
+ end
+
+ describe 'PUT #update' do
+ describe 'functionality' do
+ set(:user) { create(:user) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ context 'when a pipeline schedule has no variables' do
+ let(:basic_param) do
+ { description: 'updated_desc', cron: '0 1 * * *', cron_timezone: 'UTC', ref: 'patch-x', active: true }
+ end
+
+ context 'when params include one variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ })
+ end
+
+ it 'inserts new variable to the pipeline schedule' do
+ expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(1)
+
+ pipeline_schedule.reload
+ expect(response).to have_http_status(:found)
+ expect(pipeline_schedule.variables.last.key).to eq('AAA')
+ expect(pipeline_schedule.variables.last.value).to eq('AAA123')
+ end
+ end
+
+ context 'when params include two duplicated variables' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
+ })
+ end
+
+ it 'returns an error that variables are duplciated' do
+ go
+
+ expect(assigns(:schedule).errors['variables']).not_to be_empty
+ end
+ end
+ end
+
+ context 'when a pipeline schedule has one variable' do
+ let(:basic_param) do
+ { description: 'updated_desc', cron: '0 1 * * *', cron_timezone: 'UTC', ref: 'patch-x', active: true }
+ end
+
+ let!(:pipeline_schedule_variable) do
+ create(:ci_pipeline_schedule_variable,
+ key: 'CCC', pipeline_schedule: pipeline_schedule)
+ end
+
+ context 'when adds a new variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ })
+ end
+
+ it 'adds the new variable' do
+ expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(1)
+
+ pipeline_schedule.reload
+ expect(pipeline_schedule.variables.last.key).to eq('AAA')
+ end
+ end
+
+ context 'when adds a new duplicated variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ key: 'CCC', value: 'AAA123' }]
+ })
+ end
+
+ it 'returns an error' do
+ expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
+
+ pipeline_schedule.reload
+ expect(assigns(:schedule).errors['variables']).not_to be_empty
+ end
+ end
+
+ context 'when updates a variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ id: pipeline_schedule_variable.id, value: 'new_value' }]
+ })
+ end
+
+ it 'updates the variable' do
+ expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
+
+ pipeline_schedule_variable.reload
+ expect(pipeline_schedule_variable.value).to eq('new_value')
+ end
+ end
+
+ context 'when deletes a variable' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true }]
+ })
+ end
+
+ it 'delete the existsed variable' do
+ expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(-1)
+ end
+ end
+
+ context 'when deletes and creates a same key simultaneously' do
+ let(:schedule) do
+ basic_param.merge({
+ variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true },
+ { key: 'CCC', value: 'CCC123' }]
+ })
+ end
+
+ it 'updates the variable' do
+ expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
+
+ pipeline_schedule.reload
+ expect(pipeline_schedule.variables.last.key).to eq('CCC')
+ expect(pipeline_schedule.variables.last.value).to eq('CCC123')
+ end
+ end
+ end
+ end
+
+ describe 'security' do
+ let(:schedule) { { description: 'updated_desc' } }
+
+ it { expect { go }.to be_allowed_for(:admin) }
+ it { expect { go }.to be_allowed_for(:owner).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_denied_for(:reporter).of(project) }
+ it { expect { go }.to be_denied_for(:guest).of(project) }
+ it { expect { go }.to be_denied_for(:user) }
+ it { expect { go }.to be_denied_for(:external) }
+ it { expect { go }.to be_denied_for(:visitor) }
+
+ context 'when a developer created a pipeline schedule' do
+ let(:developer_1) { create(:user) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer_1) }
+
+ before do
+ project.add_developer(developer_1)
+ end
+
+ it { expect { go }.to be_allowed_for(developer_1) }
+ it { expect { go }.to be_denied_for(:developer).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ end
+
+ context 'when a master created a pipeline schedule' do
+ let(:master_1) { create(:user) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: master_1) }
+
+ before do
+ project.add_master(master_1)
+ end
+
+ it { expect { go }.to be_allowed_for(master_1) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_denied_for(:developer).of(project) }
+ end
+ end
+
+ def go
+ put :update, namespace_id: project.namespace.to_param,
+ project_id: project, id: pipeline_schedule,
+ schedule: schedule
+ end
+ end
+
+ describe 'GET #edit' do
+ describe 'functionality' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'loads the pipeline schedule' do
+ get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
+
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:schedule)).to eq(pipeline_schedule)
+ end
+ end
+
+ describe 'security' do
+ it { expect { go }.to be_allowed_for(:admin) }
+ it { expect { go }.to be_allowed_for(:owner).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_denied_for(:reporter).of(project) }
+ it { expect { go }.to be_denied_for(:guest).of(project) }
+ it { expect { go }.to be_denied_for(:user) }
+ it { expect { go }.to be_denied_for(:external) }
+ it { expect { go }.to be_denied_for(:visitor) }
+ end
+
+ def go
+ get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
+ end
+ end
+
+ describe 'GET #take_ownership' do
+ describe 'security' do
+ it { expect { go }.to be_allowed_for(:admin) }
+ it { expect { go }.to be_allowed_for(:owner).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_denied_for(:reporter).of(project) }
+ it { expect { go }.to be_denied_for(:guest).of(project) }
+ it { expect { go }.to be_denied_for(:user) }
+ it { expect { go }.to be_denied_for(:external) }
+ it { expect { go }.to be_denied_for(:visitor) }
+ end
+
+ def go
+ post :take_ownership, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ set(:user) { create(:user) }
+
+ context 'when a developer makes the request' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
+ end
+
+ it 'does not delete the pipeline schedule' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when a master makes the request' do
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'destroys the pipeline schedule' do
+ expect do
+ delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
+ end.to change { project.pipeline_schedules.count }.by(-1)
+
+ expect(response).to have_http_status(302)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index b9bacc5a64a..f9d77c7ad03 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -1,10 +1,18 @@
require 'spec_helper'
describe Projects::PipelinesController do
+ include ApiHelpers
+
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
+ let(:feature) { ProjectFeature::DISABLED }
before do
+ stub_not_protect_default_branch
+ project.add_developer(user)
+ project.project_feature.update(
+ builds_access_level: feature)
+
sign_in(user)
end
@@ -22,6 +30,7 @@ describe Projects::PipelinesController do
it 'returns JSON with serialized pipelines' do
expect(response).to have_http_status(:ok)
+ expect(response).to match_response_schema('pipeline')
expect(json_response).to include('pipelines')
expect(json_response['pipelines'].count).to eq 4
@@ -32,6 +41,55 @@ describe Projects::PipelinesController do
end
end
+ describe 'GET show JSON' do
+ let(:pipeline) { create(:ci_pipeline_with_one_job, project: project) }
+
+ it 'returns the pipeline' do
+ get_pipeline_json
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response).not_to be_an(Array)
+ expect(json_response['id']).to be(pipeline.id)
+ expect(json_response['details']).to have_key 'stages'
+ end
+
+ context 'when the pipeline has multiple stages and groups', :request_store do
+ before do
+ create_build('build', 0, 'build')
+ create_build('test', 1, 'rspec 0')
+ create_build('deploy', 2, 'production')
+ create_build('post deploy', 3, 'pages 0')
+ end
+
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) do
+ create(:ci_empty_pipeline, project: project, user: user, sha: project.commit.id)
+ end
+
+ it 'does not perform N + 1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count
+
+ create_build('test', 1, 'rspec 1')
+ create_build('test', 1, 'spinach 0')
+ create_build('test', 1, 'spinach 1')
+ create_build('test', 1, 'audit')
+ create_build('post deploy', 3, 'pages 1')
+ create_build('post deploy', 3, 'pages 2')
+
+ new_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count
+ expect(new_count).to be_within(12).of(control_count)
+ end
+ end
+
+ def get_pipeline_json
+ get :show, namespace_id: project.namespace, project_id: project, id: pipeline, format: :json
+ end
+
+ def create_build(stage, stage_idx, name)
+ create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
+ end
+ end
+
describe 'GET stages.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -87,4 +145,58 @@ describe Projects::PipelinesController do
expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
end
end
+
+ describe 'POST retry.json' do
+ let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
+ let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ before do
+ post :retry, namespace_id: project.namespace,
+ project_id: project,
+ id: pipeline.id,
+ format: :json
+ end
+
+ context 'when builds are enabled' do
+ let(:feature) { ProjectFeature::ENABLED }
+
+ it 'retries a pipeline without returning any content' do
+ expect(response).to have_http_status(:no_content)
+ expect(build.reload).to be_retried
+ end
+ end
+
+ context 'when builds are disabled' do
+ it 'fails to retry pipeline' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST cancel.json' do
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:build) { create(:ci_build, :running, pipeline: pipeline) }
+
+ before do
+ post :cancel, namespace_id: project.namespace,
+ project_id: project,
+ id: pipeline.id,
+ format: :json
+ end
+
+ context 'when builds are enabled' do
+ let(:feature) { ProjectFeature::ENABLED }
+
+ it 'cancels a pipeline without returning any content' do
+ expect(response).to have_http_status(:no_content)
+ expect(pipeline.reload).to be_canceled
+ end
+ end
+
+ context 'when builds are disabled' do
+ it 'fails to retry pipeline' do
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb
index a4b4392d7cc..3cb1bec5ea2 100644
--- a/spec/controllers/projects/project_members_controller_spec.rb
+++ b/spec/controllers/projects/project_members_controller_spec.rb
@@ -2,24 +2,27 @@ require('spec_helper')
describe Projects::ProjectMembersController do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
describe 'GET index' do
- it 'should have the settings/members address with a 302 status code' do
+ it 'should have the project_members address with a 200 status code' do
get :index, namespace_id: project.namespace, project_id: project
- expect(response).to have_http_status(302)
- expect(response.location).to include namespace_project_settings_members_path(project.namespace, project)
+ expect(response).to have_http_status(200)
end
end
describe 'POST create' do
let(:project_user) { create(:user) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when user does not have enough rights' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it 'returns 404' do
post :create, namespace_id: project.namespace,
@@ -33,10 +36,12 @@ describe Projects::ProjectMembersController do
end
context 'when user has enough rights' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'adds user to members' do
- expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(true)
+ expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :success)
post :create, namespace_id: project.namespace,
project_id: project,
@@ -44,19 +49,19 @@ describe Projects::ProjectMembersController do
access_level: Gitlab::Access::GUEST
expect(response).to set_flash.to 'Users were successfully added.'
- expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project))
+ expect(response).to redirect_to(project_project_members_path(project))
end
it 'adds no user to members' do
- expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(false)
+ expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :failure, message: 'Message')
post :create, namespace_id: project.namespace,
project_id: project,
user_ids: '',
access_level: Gitlab::Access::GUEST
- expect(response).to set_flash.to 'No users specified.'
- expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project))
+ expect(response).to set_flash.to 'Message'
+ expect(response).to redirect_to(project_project_members_path(project))
end
end
end
@@ -64,7 +69,9 @@ describe Projects::ProjectMembersController do
describe 'DELETE destroy' do
let(:member) { create(:project_member, :developer, project: project) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 404' do
@@ -78,7 +85,9 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it 'returns 404' do
delete :destroy, namespace_id: project.namespace,
@@ -91,7 +100,9 @@ describe Projects::ProjectMembersController do
end
context 'when user has enough rights' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it '[HTML] removes user from members' do
delete :destroy, namespace_id: project.namespace,
@@ -99,7 +110,7 @@ describe Projects::ProjectMembersController do
id: member
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
expect(project.members).not_to include member
end
@@ -117,7 +128,9 @@ describe Projects::ProjectMembersController do
end
describe 'DELETE leave' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 404' do
@@ -130,7 +143,9 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'and is not an owner' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it 'removes user from members' do
delete :leave, namespace_id: project.namespace,
@@ -143,9 +158,11 @@ describe Projects::ProjectMembersController do
end
context 'and is an owner' do
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'cannot remove himself from the project' do
delete :leave, namespace_id: project.namespace,
@@ -156,14 +173,16 @@ describe Projects::ProjectMembersController do
end
context 'and is a requester' do
- before { project.request_access(user) }
+ before do
+ project.request_access(user)
+ end
it 'removes user from members' do
delete :leave, namespace_id: project.namespace,
project_id: project
expect(response).to set_flash.to 'Your access request to the project has been withdrawn.'
- expect(response).to redirect_to(namespace_project_path(project.namespace, project))
+ expect(response).to redirect_to(project_path(project))
expect(project.requesters).to be_empty
expect(project.users).not_to include user
end
@@ -172,7 +191,9 @@ describe Projects::ProjectMembersController do
end
describe 'POST request_access' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'creates a new ProjectMember that is not a team member' do
post :request_access, namespace_id: project.namespace,
@@ -180,7 +201,7 @@ describe Projects::ProjectMembersController do
expect(response).to set_flash.to 'Your request for access has been queued for review.'
expect(response).to redirect_to(
- namespace_project_path(project.namespace, project)
+ project_path(project)
)
expect(project.requesters.exists?(user_id: user)).to be_truthy
expect(project.users).not_to include user
@@ -190,7 +211,9 @@ describe Projects::ProjectMembersController do
describe 'POST approve' do
let(:member) { create(:project_member, :access_request, project: project) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when member is not found' do
it 'returns 404' do
@@ -204,7 +227,9 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it 'returns 404' do
post :approve_access_request, namespace_id: project.namespace,
@@ -217,7 +242,9 @@ describe Projects::ProjectMembersController do
end
context 'when user has enough rights' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'adds user to members' do
post :approve_access_request, namespace_id: project.namespace,
@@ -225,7 +252,7 @@ describe Projects::ProjectMembersController do
id: member
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
expect(project.members).to include member
end
@@ -234,7 +261,7 @@ describe Projects::ProjectMembersController do
end
describe 'POST apply_import' do
- let(:another_project) { create(:empty_project, :private) }
+ let(:another_project) { create(:project, :private) }
let(:member) { create(:user) }
before do
@@ -252,14 +279,17 @@ describe Projects::ProjectMembersController do
end
context 'when user can access source project members' do
- before { another_project.team << [user, :guest] }
+ before do
+ another_project.team << [user, :guest]
+ end
+
include_context 'import applied'
it 'imports source project members' do
expect(project.team_members).to include member
expect(response).to set_flash.to 'Successfully imported'
expect(response).to redirect_to(
- namespace_project_settings_members_path(project.namespace, project)
+ project_project_members_path(project)
)
end
end
diff --git a/spec/controllers/projects/prometheus_controller_spec.rb b/spec/controllers/projects/prometheus_controller_spec.rb
new file mode 100644
index 00000000000..8407a53272a
--- /dev/null
+++ b/spec/controllers/projects/prometheus_controller_spec.rb
@@ -0,0 +1,59 @@
+require('spec_helper')
+
+describe Projects::PrometheusController do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project) }
+
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(controller).to receive(:project).and_return(project)
+ allow(project).to receive(:prometheus_service).and_return(prometheus_service)
+
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'GET #active_metrics' do
+ context 'when prometheus metrics are enabled' do
+ context 'when data is not present' do
+ before do
+ allow(prometheus_service).to receive(:matched_metrics).and_return({})
+ end
+
+ it 'returns no content response' do
+ get :active_metrics, project_params(format: :json)
+
+ expect(response).to have_http_status(204)
+ end
+ end
+
+ context 'when data is available' do
+ let(:sample_response) { { some_data: 1 } }
+
+ before do
+ allow(prometheus_service).to receive(:matched_metrics).and_return(sample_response)
+ end
+
+ it 'returns no content response' do
+ get :active_metrics, project_params(format: :json)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to eq(sample_response.deep_stringify_keys)
+ end
+ end
+
+ context 'when requesting non json response' do
+ it 'returns not found response' do
+ get :active_metrics, project_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+
+ def project_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project)
+ end
+end
diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb
index 952071af57f..b4eaab29fed 100644
--- a/spec/controllers/projects/raw_controller_spec.rb
+++ b/spec/controllers/projects/raw_controller_spec.rb
@@ -15,8 +15,8 @@ describe Projects::RawController do
expect(response).to have_http_status(200)
expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(response.header['Content-Disposition']).
- to eq('inline')
+ expect(response.header['Content-Disposition'])
+ .to eq('inline')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:')
end
end
@@ -88,8 +88,8 @@ describe Projects::RawController do
expect(response).to have_http_status(200)
expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(response.header['Content-Disposition']).
- to eq('inline')
+ expect(response.header['Content-Disposition'])
+ .to eq('inline')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:')
end
end
diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb
index 464302824a8..2805968dcd9 100644
--- a/spec/controllers/projects/registry/repositories_controller_spec.rb
+++ b/spec/controllers/projects/registry/repositories_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::Registry::RepositoriesController do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
before do
sign_in(user)
diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb
new file mode 100644
index 00000000000..f4af3587d23
--- /dev/null
+++ b/spec/controllers/projects/registry/tags_controller_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe Projects::Registry::TagsController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :private) }
+
+ before do
+ sign_in(user)
+ stub_container_registry_config(enabled: true)
+ end
+
+ context 'when user has access to registry' do
+ before do
+ project.add_developer(user)
+ end
+
+ describe 'POST destroy' do
+ context 'when there is matching tag present' do
+ before do
+ stub_container_registry_tags(repository: /image/, tags: %w[rc1 test.])
+ end
+
+ let(:repository) do
+ create(:container_repository, name: 'image', project: project)
+ end
+
+ it 'makes it possible to delete regular tag' do
+ expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete)
+
+ destroy_tag('rc1')
+ end
+
+ it 'makes it possible to delete a tag that ends with a dot' do
+ expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete)
+
+ destroy_tag('test.')
+ end
+ end
+ end
+ end
+
+ def destroy_tag(name)
+ post :destroy, namespace_id: project.namespace,
+ project_id: project,
+ repository_id: repository,
+ id: name
+ end
+end
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb
index 9c55d159fa0..f712d1e0d63 100644
--- a/spec/controllers/projects/repositories_controller_spec.rb
+++ b/spec/controllers/projects/repositories_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::RepositoriesController do
describe "GET archive" do
context 'as a guest' do
it 'responds with redirect in correct format' do
- get :archive, namespace_id: project.namespace, project_id: project, format: "zip"
+ get :archive, namespace_id: project.namespace, project_id: project, format: "zip", ref: 'master'
expect(response.header["Content-Type"]).to start_with('text/html')
expect(response).to be_redirect
diff --git a/spec/controllers/projects/runners_controller_spec.rb b/spec/controllers/projects/runners_controller_spec.rb
index 0fa249e4405..2b6f988fd9c 100644
--- a/spec/controllers/projects/runners_controller_spec.rb
+++ b/spec/controllers/projects/runners_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::RunnersController do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:runner) { create(:ci_runner) }
let(:params) do
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 2d892f4a2b7..4e9b0c09ff2 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -3,7 +3,9 @@ require 'spec_helper'
describe Projects::ServicesController do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:service) { create(:service, project: project) }
+ let(:service) { create(:hipchat_service, project: project) }
+ let(:hipchat_client) { { '#room' => double(send: true) } }
+ let(:service_params) { { token: 'hipchat_token_p', room: '#room' } }
before do
sign_in(user)
@@ -13,97 +15,81 @@ describe Projects::ServicesController do
controller.instance_variable_set(:@service, service)
end
- shared_examples_for 'services controller' do |referrer|
- before do
- request.env["HTTP_REFERER"] = referrer
- end
-
- describe "#test" do
- context 'when can_test? returns false' do
- it 'renders 404' do
- allow_any_instance_of(Service).to receive(:can_test?).and_return(false)
+ describe '#test' do
+ context 'when can_test? returns false' do
+ it 'renders 404' do
+ allow_any_instance_of(Service).to receive(:can_test?).and_return(false)
- get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html
+ put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id
- expect(response).to have_http_status(404)
- end
+ expect(response).to have_http_status(404)
end
+ end
- context 'success' do
- context 'with empty project' do
- let(:project) { create(:empty_project) }
-
- context 'with chat notification service' do
- let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') }
-
- it 'redirects and show success message' do
- allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true)
-
- get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html
+ context 'success' do
+ context 'with empty project' do
+ let(:project) { create(:project) }
- expect(response).to redirect_to(root_path)
- expect(flash[:notice]).to eq('We sent a request to the provided URL')
- end
- end
+ context 'with chat notification service' do
+ let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') }
- it 'redirects and show success message' do
- expect(service).to receive(:test).and_return(success: true, result: 'done')
+ it 'returns success' do
+ allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true)
- get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html
+ put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id
- expect(response).to redirect_to(root_path)
- expect(flash[:notice]).to eq('We sent a request to the provided URL')
+ expect(response.status).to eq(200)
end
end
- it "redirects and show success message" do
- expect(service).to receive(:test).and_return(success: true, result: 'done')
+ it 'returns success' do
+ expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client)
- get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html
+ put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params
- expect(response).to redirect_to(root_path)
- expect(flash[:notice]).to eq('We sent a request to the provided URL')
+ expect(response.status).to eq(200)
end
end
- context 'failure' do
- it "redirects and show failure message" do
- expect(service).to receive(:test).and_return(success: false, result: 'Bad test')
+ it 'returns success' do
+ expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client)
- get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html
+ put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params
- expect(response).to redirect_to(root_path)
- expect(flash[:alert]).to eq('We tried to send a request to the provided URL but an error occurred: Bad test')
- end
+ expect(response.status).to eq(200)
end
end
- end
- describe 'referrer defined' do
- it_should_behave_like 'services controller' do
- let!(:referrer) { "/" }
- end
- end
+ context 'failure' do
+ it 'returns success status code and the error message' do
+ expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_raise('Bad test')
- describe 'referrer undefined' do
- it_should_behave_like 'services controller' do
- let!(:referrer) { nil }
+ put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params
+
+ expect(response.status).to eq(200)
+ expect(JSON.parse(response.body))
+ .to eq('error' => true, 'message' => 'Test failed.', 'service_response' => 'Bad test')
+ end
end
end
describe 'PUT #update' do
- context 'on successful update' do
- it 'sets the flash' do
- expect(service).to receive(:to_param).and_return('hipchat')
- expect(service).to receive(:event_names).and_return(HipchatService.event_names)
+ context 'when param `active` is set to true' do
+ it 'activates the service and redirects to integrations paths' do
+ put :update,
+ namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: true }
+
+ expect(response).to redirect_to(project_settings_integrations_path(project))
+ expect(flash[:notice]).to eq 'HipChat activated.'
+ end
+ end
+ context 'when param `active` is set to false' do
+ it 'does not activate the service but saves the settings' do
put :update,
- namespace_id: project.namespace.id,
- project_id: project.id,
- id: service.id,
- service: { active: false }
+ namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: false }
- expect(flash[:notice]).to eq 'Successfully updated.'
+ expect(flash[:notice]).to eq 'HipChat settings saved, but not activated.'
end
end
end
diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
index e9a91cff1b3..a8f4b79b64c 100644
--- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
@@ -1,7 +1,7 @@
require('spec_helper')
describe Projects::Settings::CiCdController do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:user) { create(:user) }
before do
diff --git a/spec/controllers/projects/settings/integrations_controller_spec.rb b/spec/controllers/projects/settings/integrations_controller_spec.rb
index 65f7bb34f4a..e0f9a5b24a6 100644
--- a/spec/controllers/projects/settings/integrations_controller_spec.rb
+++ b/spec/controllers/projects/settings/integrations_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::Settings::IntegrationsController do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
before do
diff --git a/spec/controllers/projects/settings/members_controller_spec.rb b/spec/controllers/projects/settings/members_controller_spec.rb
deleted file mode 100644
index 076d6cd9c6e..00000000000
--- a/spec/controllers/projects/settings/members_controller_spec.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-require('spec_helper')
-
-describe Projects::Settings::MembersController do
- let(:project) { create(:empty_project, :public, :access_requestable) }
-
- describe 'GET show' do
- it 'renders show with 200 status code' do
- get :show, namespace_id: project.namespace, project_id: project
-
- expect(response).to have_http_status(200)
- expect(response).to render_template(:show)
- end
- end
-end
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index 24a59caff4e..cc444f31797 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -46,7 +46,9 @@ describe Projects::SnippetsController do
end
context 'when signed in as the author' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'renders the snippet' do
get :index, namespace_id: project.namespace, project_id: project
@@ -57,7 +59,9 @@ describe Projects::SnippetsController do
end
context 'when signed in as a project member' do
- before { sign_in(user2) }
+ before do
+ sign_in(user2)
+ end
it 'renders the snippet' do
get :index, namespace_id: project.namespace, project_id: project
@@ -78,8 +82,18 @@ describe Projects::SnippetsController do
post :create, {
namespace_id: project.namespace.to_param,
project_id: project,
- project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
+ project_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
+
+ Snippet.last
+ end
+
+ it 'creates the snippet correctly' do
+ snippet = create_snippet(project, visibility_level: Snippet::PRIVATE)
+
+ expect(snippet.title).to eq('Title')
+ expect(snippet.content).to eq('Content')
+ expect(snippet.description).to eq('Description')
end
context 'when the snippet is spam' do
@@ -89,21 +103,21 @@ describe Projects::SnippetsController do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(project, visibility_level: Snippet::PRIVATE) }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(project, visibility_level: Snippet::PRIVATE) }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the shippet' do
- expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }.
- not_to change { Snippet.count }
+ expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }
+ .not_to change { Snippet.count }
expect(response).to render_template(:new)
end
it 'creates a spam log' do
- expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :new with recaptcha disabled' do
@@ -134,7 +148,7 @@ describe Projects::SnippetsController do
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
- expect(response).to redirect_to(Snippet.last)
+ expect(response).to redirect_to(project_snippet_path(project, Snippet.last))
end
end
end
@@ -169,8 +183,8 @@ describe Projects::SnippetsController do
let(:visibility_level) { Snippet::PRIVATE }
it 'updates the snippet' do
- expect { update_snippet(title: 'Foo') }.
- to change { snippet.reload.title }.to('Foo')
+ expect { update_snippet(title: 'Foo') }
+ .to change { snippet.reload.title }.to('Foo')
end
end
@@ -178,13 +192,13 @@ describe Projects::SnippetsController do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
- expect { update_snippet(title: 'Foo') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo') }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo') }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :edit with recaptcha disabled' do
@@ -214,7 +228,7 @@ describe Projects::SnippetsController do
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
- expect(response).to redirect_to(snippet)
+ expect(response).to redirect_to(project_snippet_path(project, snippet))
end
end
end
@@ -223,13 +237,13 @@ describe Projects::SnippetsController do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the shippet' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :edit with recaptcha disabled' do
@@ -259,7 +273,7 @@ describe Projects::SnippetsController do
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
- expect(response).to redirect_to(snippet)
+ expect(response).to redirect_to(project_snippet_path(project, snippet))
end
end
end
@@ -307,7 +321,9 @@ describe Projects::SnippetsController do
end
context 'when signed in as the author' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'renders the snippet' do
get action, namespace_id: project.namespace, project_id: project, id: project_snippet.to_param
@@ -318,7 +334,9 @@ describe Projects::SnippetsController do
end
context 'when signed in as a project member' do
- before { sign_in(user2) }
+ before do
+ sign_in(user2)
+ end
it 'renders the snippet' do
get action, namespace_id: project.namespace, project_id: project, id: project_snippet.to_param
@@ -339,7 +357,9 @@ describe Projects::SnippetsController do
end
context 'when signed in' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'responds with status 404' do
get action, namespace_id: project.namespace, project_id: project, id: 42
diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb
index fc97bac64cd..c48f41ca12e 100644
--- a/spec/controllers/projects/tags_controller_spec.rb
+++ b/spec/controllers/projects/tags_controller_spec.rb
@@ -6,7 +6,9 @@ describe Projects::TagsController do
let!(:invalid_release) { create(:release, project: project, tag: 'does-not-exist') }
describe 'GET index' do
- before { get :index, namespace_id: project.namespace.to_param, project_id: project }
+ before do
+ get :index, namespace_id: project.namespace.to_param, project_id: project
+ end
it 'returns the tags for the page' do
expect(assigns(:tags).map(&:name)).to eq(['v1.1.0', 'v1.0.0'])
@@ -19,7 +21,9 @@ describe Projects::TagsController do
end
describe 'GET show' do
- before { get :show, namespace_id: project.namespace.to_param, project_id: project, id: id }
+ before do
+ get :show, namespace_id: project.namespace.to_param, project_id: project, id: id
+ end
context "valid tag" do
let(:id) { 'v1.0.0' }
diff --git a/spec/controllers/projects/todo_controller_spec.rb b/spec/controllers/projects/todo_controller_spec.rb
deleted file mode 100644
index c5a4153d991..00000000000
--- a/spec/controllers/projects/todo_controller_spec.rb
+++ /dev/null
@@ -1,144 +0,0 @@
-require('spec_helper')
-
-describe Projects::TodosController do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
- let(:issue) { create(:issue, project: project) }
- let(:merge_request) { create(:merge_request, source_project: project) }
-
- context 'Issues' do
- describe 'POST create' do
- def go
- post :create,
- namespace_id: project.namespace,
- project_id: project,
- issuable_id: issue.id,
- issuable_type: 'issue',
- format: 'html'
- end
-
- context 'when authorized' do
- before do
- sign_in(user)
- project.team << [user, :developer]
- end
-
- it 'creates todo for issue' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for issue that user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_http_status(404)
- end
-
- it 'does not create todo for issue when user not logged in' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_http_status(302)
- end
- end
-
- context 'when not authorized for issue' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect{ go }.not_to change { user.todos.count }
- expect(response).to have_http_status(404)
- end
- end
- end
- end
-
- context 'Merge Requests' do
- describe 'POST create' do
- def go
- post :create,
- namespace_id: project.namespace,
- project_id: project,
- issuable_id: merge_request.id,
- issuable_type: 'merge_request',
- format: 'html'
- end
-
- context 'when authorized' do
- before do
- sign_in(user)
- project.team << [user, :developer]
- end
-
- it 'creates todo for merge request' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for merge request user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_http_status(404)
- end
-
- it 'does not create todo for merge request user has no access to' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_http_status(302)
- end
- end
-
- context 'when not authorized for merge_request' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect{ go }.not_to change { user.todos.count }
- expect(response).to have_http_status(404)
- end
- end
- end
- end
-end
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
new file mode 100644
index 00000000000..41d211ed1bb
--- /dev/null
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -0,0 +1,144 @@
+require('spec_helper')
+
+describe Projects::TodosController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ context 'Issues' do
+ describe 'POST create' do
+ def go
+ post :create,
+ namespace_id: project.namespace,
+ project_id: project,
+ issuable_id: issue.id,
+ issuable_type: 'issue',
+ format: 'html'
+ end
+
+ context 'when authorized' do
+ before do
+ sign_in(user)
+ project.team << [user, :developer]
+ end
+
+ it 'creates todo for issue' do
+ expect do
+ go
+ end.to change { user.todos.count }.by(1)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns todo path and pending count' do
+ go
+
+ expect(response).to have_http_status(200)
+ expect(json_response['count']).to eq 1
+ expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
+ end
+ end
+
+ context 'when not authorized for project' do
+ it 'does not create todo for issue that user has no access to' do
+ sign_in(user)
+ expect do
+ go
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'does not create todo for issue when user not logged in' do
+ expect do
+ go
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when not authorized for issue' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+ sign_in(user)
+ end
+
+ it "doesn't create todo" do
+ expect { go }.not_to change { user.todos.count }
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+
+ context 'Merge Requests' do
+ describe 'POST create' do
+ def go
+ post :create,
+ namespace_id: project.namespace,
+ project_id: project,
+ issuable_id: merge_request.id,
+ issuable_type: 'merge_request',
+ format: 'html'
+ end
+
+ context 'when authorized' do
+ before do
+ sign_in(user)
+ project.team << [user, :developer]
+ end
+
+ it 'creates todo for merge request' do
+ expect do
+ go
+ end.to change { user.todos.count }.by(1)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns todo path and pending count' do
+ go
+
+ expect(response).to have_http_status(200)
+ expect(json_response['count']).to eq 1
+ expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
+ end
+ end
+
+ context 'when not authorized for project' do
+ it 'does not create todo for merge request user has no access to' do
+ sign_in(user)
+ expect do
+ go
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'does not create todo for merge request user has no access to' do
+ expect do
+ go
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when not authorized for merge_request' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
+ sign_in(user)
+ end
+
+ it "doesn't create todo" do
+ expect { go }.not_to change { user.todos.count }
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb
index a43dad5756d..775f3998f5d 100644
--- a/spec/controllers/projects/tree_controller_spec.rb
+++ b/spec/controllers/projects/tree_controller_spec.rb
@@ -81,9 +81,9 @@ describe Projects::TreeController do
context 'redirect to blob' do
let(:id) { 'master/README.md' }
it 'redirects' do
- redirect_url = "/#{project.path_with_namespace}/blob/master/README.md"
- expect(subject).
- to redirect_to(redirect_url)
+ redirect_url = "/#{project.full_path}/blob/master/README.md"
+ expect(subject)
+ .to redirect_to(redirect_url)
end
end
end
@@ -106,8 +106,8 @@ describe Projects::TreeController do
let(:branch_name) { 'master-test'}
it 'redirects to the new directory' do
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/#{branch_name}/#{path}")
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/#{branch_name}/#{path}")
expect(flash[:notice]).to eq('The directory has been successfully created.')
end
end
@@ -117,8 +117,8 @@ describe Projects::TreeController do
let(:branch_name) { 'master'}
it 'does not allow overwriting of existing files' do
- expect(subject).
- to redirect_to("/#{project.path_with_namespace}/tree/master")
+ expect(subject)
+ .to redirect_to("/#{project.full_path}/tree/master")
expect(flash[:alert]).to eq('A file with this name already exists')
end
end
diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb
index cd6961a7bd5..488bcf31371 100644
--- a/spec/controllers/projects/uploads_controller_spec.rb
+++ b/spec/controllers/projects/uploads_controller_spec.rb
@@ -1,7 +1,7 @@
require('spec_helper')
describe Projects::UploadsController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') }
let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
diff --git a/spec/controllers/projects/variables_controller_spec.rb b/spec/controllers/projects/variables_controller_spec.rb
index 1ecfe48475c..6957fb43c19 100644
--- a/spec/controllers/projects/variables_controller_spec.rb
+++ b/spec/controllers/projects/variables_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::VariablesController do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
before do
@@ -15,13 +15,13 @@ describe Projects::VariablesController do
post :create, namespace_id: project.namespace.to_param, project_id: project,
variable: { key: "one", value: "two" }
- expect(flash[:notice]).to include 'Variables were successfully updated.'
- expect(response).to redirect_to(namespace_project_settings_ci_cd_path(project.namespace, project))
+ expect(flash[:notice]).to include 'Variable was successfully created.'
+ expect(response).to redirect_to(project_settings_ci_cd_path(project))
end
end
context 'variable is invalid' do
- it 'shows an alert flash message' do
+ it 'renders show' do
post :create, namespace_id: project.namespace.to_param, project_id: project,
variable: { key: "..one", value: "two" }
@@ -35,7 +35,6 @@ describe Projects::VariablesController do
context 'updating a variable with valid characters' do
before do
- variable.project_id = project.id
project.variables << variable
end
@@ -44,7 +43,7 @@ describe Projects::VariablesController do
id: variable.id, variable: { key: variable.key, value: 'two' }
expect(flash[:notice]).to include 'Variable was successfully updated.'
- expect(response).to redirect_to(namespace_project_variables_path(project.namespace, project))
+ expect(response).to redirect_to(project_variables_path(project))
end
it 'renders the action #show if the variable key is invalid' do
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index eafc2154568..c0e48046937 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -1,8 +1,8 @@
require('spec_helper')
describe ProjectsController do
- let(:project) { create(:empty_project) }
- let(:public_project) { create(:empty_project, :public) }
+ let(:project) { create(:project) }
+ let(:public_project) { create(:project, :public) }
let(:user) { create(:user) }
let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') }
let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
@@ -29,10 +29,12 @@ describe ProjectsController do
describe "GET show" do
context "user not project member" do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context "user does not have access to project" do
- let(:private_project) { create(:empty_project, :private) }
+ let(:private_project) { create(:project, :private) }
it "does not initialize notification setting" do
get :show, namespace_id: private_project.namespace, id: private_project
@@ -105,10 +107,26 @@ describe ProjectsController do
end
end
+ context 'when the storage is not available', broken_storage: true do
+ let(:project) { create(:project, :broken_storage) }
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'renders a 503' do
+ get :show, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(503)
+ end
+ end
+
context "project with empty repo" do
let(:empty_project) { create(:project_empty_repo, :public) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
User.project_views.keys.each do |project_view|
context "with #{project_view} view set" do
@@ -128,7 +146,9 @@ describe ProjectsController do
context "project with broken repo" do
let(:empty_project) { create(:project_broken_repo, :public) }
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
User.project_views.keys.each do |project_view|
context "with #{project_view} view set" do
@@ -169,28 +189,8 @@ describe ProjectsController do
end
end
- context "when requested with case sensitive namespace and project path" do
- context "when there is a match with the same casing" do
- it "loads the project" do
- get :show, namespace_id: public_project.namespace, id: public_project
-
- expect(assigns(:project)).to eq(public_project)
- expect(response).to have_http_status(200)
- end
- end
-
- context "when there is a match with different casing" do
- it "redirects to the normalized path" do
- get :show, namespace_id: public_project.namespace, id: public_project.path.upcase
-
- expect(assigns(:project)).to eq(public_project)
- expect(response).to redirect_to("/#{public_project.full_path}")
- end
- end
- end
-
context "when the url contains .atom" do
- let(:public_project_with_dot_atom) { build(:empty_project, :public, name: 'my.atom', path: 'my.atom') }
+ let(:public_project_with_dot_atom) { build(:project, :public, name: 'my.atom', path: 'my.atom') }
it 'expects an error creating the project' do
expect(public_project_with_dot_atom).not_to be_valid
@@ -199,7 +199,7 @@ describe ProjectsController do
context 'when the project is pending deletions' do
it 'renders a 404 error' do
- project = create(:empty_project, pending_delete: true)
+ project = create(:project, pending_delete: true)
sign_in(user)
get :show, namespace_id: project.namespace, id: project
@@ -224,22 +224,88 @@ describe ProjectsController do
render_views
let(:admin) { create(:admin) }
+ let(:project) { create(:project, :repository) }
- it "sets the repository to the right path after a rename" do
- project = create(:project, :repository)
- new_path = 'renamed_path'
- project_params = { path: new_path }
- controller.instance_variable_set(:@project, project)
+ before do
sign_in(admin)
+ end
+
+ context 'when only renaming a project path' do
+ it "sets the repository to the right path after a rename" do
+ expect { update_project path: 'renamed_path' }
+ .to change { project.reload.path }
+
+ expect(project.path).to include 'renamed_path'
+ expect(assigns(:repository).path).to include project.path
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when project has container repositories with tags' do
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(repository: /image/, tags: %w[rc1])
+ create(:container_repository, project: project, name: :image)
+ end
+
+ it 'does not allow to rename the project' do
+ expect { update_project path: 'renamed_path' }
+ .not_to change { project.reload.path }
+
+ expect(controller).to set_flash[:alert].to(/container registry tags/)
+ expect(response).to have_http_status(200)
+ end
+ end
+ def update_project(**parameters)
put :update,
- namespace_id: project.namespace,
- id: project.id,
- project: project_params
+ namespace_id: project.namespace.path,
+ id: project.path,
+ project: parameters
+ end
+ end
- expect(project.repository.path).to include(new_path)
- expect(assigns(:repository).path).to eq(project.repository.path)
- expect(response).to have_http_status(302)
+ describe '#transfer' do
+ render_views
+
+ let(:project) { create(:project, :repository) }
+ let(:admin) { create(:admin) }
+ let(:new_namespace) { create(:namespace) }
+
+ it 'updates namespace' do
+ sign_in(admin)
+
+ put :transfer,
+ namespace_id: project.namespace.path,
+ new_namespace_id: new_namespace.id,
+ id: project.path,
+ format: :js
+
+ project.reload
+
+ expect(project.namespace).to eq(new_namespace)
+ expect(response).to have_http_status(200)
+ end
+
+ context 'when new namespace is empty' do
+ it 'project namespace is not changed' do
+ controller.instance_variable_set(:@project, project)
+ sign_in(admin)
+
+ old_namespace = project.namespace
+
+ put :transfer,
+ namespace_id: old_namespace.path,
+ new_namespace_id: nil,
+ id: project.path,
+ format: :js
+
+ project.reload
+
+ expect(project.namespace).to eq(old_namespace)
+ expect(response).to have_http_status(200)
+ expect(flash[:alert]).to eq 'Please select a new namespace for your project.'
+ end
end
end
@@ -259,8 +325,8 @@ describe ProjectsController do
end
context "when the project is forked" do
- let(:project) { create(:project) }
- let(:fork_project) { create(:project, forked_from_project: project) }
+ let(:project) { create(:project, :repository) }
+ let(:fork_project) { create(:project, :repository, forked_from_project: project) }
let(:merge_request) do
create(:merge_request,
source_project: fork_project,
@@ -338,7 +404,7 @@ describe ProjectsController do
end
context 'with forked project' do
- let(:project_fork) { create(:project, namespace: user.namespace) }
+ let(:project_fork) { create(:project, :repository, namespace: user.namespace) }
before do
create(:forked_project_link, forked_to_project: project_fork)
@@ -378,7 +444,7 @@ describe ProjectsController do
end
describe "GET refs" do
- let(:public_project) { create(:project, :public) }
+ let(:public_project) { create(:project, :public, :repository) }
it "gets a list of branches and tags" do
get :refs, namespace_id: public_project.namespace, id: public_project
@@ -408,4 +474,223 @@ describe ProjectsController do
expect(JSON.parse(response.body).keys).to match_array(%w(body references))
end
end
+
+ describe '#ensure_canonical_path' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a GET request' do
+ context 'when requesting the canonical path' do
+ context "with exactly matching casing" do
+ it "loads the project" do
+ get :show, namespace_id: public_project.namespace, id: public_project
+
+ expect(assigns(:project)).to eq(public_project)
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context "with different casing" do
+ it "redirects to the normalized path" do
+ get :show, namespace_id: public_project.namespace, id: public_project.path.upcase
+
+ expect(assigns(:project)).to eq(public_project)
+ expect(response).to redirect_to("/#{public_project.full_path}")
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") }
+
+ it 'redirects to the canonical path' do
+ get :show, namespace_id: 'foo', id: 'bar'
+
+ expect(response).to redirect_to(public_project)
+ expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project))
+ end
+
+ it 'redirects to the canonical path (testing non-show action)' do
+ get :refs, namespace_id: 'foo', id: 'bar'
+
+ expect(response).to redirect_to(refs_project_path(public_project))
+ expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project))
+ end
+ end
+ end
+
+ context 'for a POST request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :toggle_star, namespace_id: public_project.namespace, id: public_project.path.upcase
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ post :toggle_star, namespace_id: public_project.namespace, id: public_project.path.upcase
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") }
+
+ it 'returns not found' do
+ post :toggle_star, namespace_id: 'foo', id: 'bar'
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ context 'for a DELETE request' do
+ before do
+ sign_in(create(:admin))
+ end
+
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ delete :destroy, namespace_id: project.namespace, id: project.path.upcase
+
+ expect(response).not_to have_http_status(404)
+ end
+
+ it 'does not redirect to the correct casing' do
+ delete :destroy, namespace_id: project.namespace, id: project.path.upcase
+
+ expect(response).not_to have_http_status(301)
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let!(:redirect_route) { project.redirect_routes.create!(path: "foo/bar") }
+
+ it 'returns not found' do
+ delete :destroy, namespace_id: 'foo', id: 'bar'
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+ end
+
+ describe '#export' do
+ before do
+ sign_in(user)
+
+ project.add_master(user)
+ end
+
+ context 'when project export is enabled' do
+ it 'returns 302' do
+ get :export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when project export is disabled' do
+ before do
+ stub_application_setting(project_export_enabled?: false)
+ end
+
+ it 'returns 404' do
+ get :export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe '#download_export' do
+ before do
+ sign_in(user)
+
+ project.add_master(user)
+ end
+
+ context 'when project export is enabled' do
+ it 'returns 302' do
+ get :download_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when project export is disabled' do
+ before do
+ stub_application_setting(project_export_enabled?: false)
+ end
+
+ it 'returns 404' do
+ get :download_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe '#remove_export' do
+ before do
+ sign_in(user)
+
+ project.add_master(user)
+ end
+
+ context 'when project export is enabled' do
+ it 'returns 302' do
+ post :remove_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when project export is disabled' do
+ before do
+ stub_application_setting(project_export_enabled?: false)
+ end
+
+ it 'returns 404' do
+ post :remove_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe '#generate_new_export' do
+ before do
+ sign_in(user)
+
+ project.add_master(user)
+ end
+
+ context 'when project export is enabled' do
+ it 'returns 302' do
+ post :generate_new_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(302)
+ end
+ end
+
+ context 'when project export is disabled' do
+ before do
+ stub_application_setting(project_export_enabled?: false)
+ end
+
+ it 'returns 404' do
+ post :generate_new_export, namespace_id: project.namespace, id: project
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ def project_moved_message(redirect_route, project)
+ "Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 71dd9ef3eb4..5a4ab39ab86 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -5,7 +5,7 @@ describe RegistrationsController do
let(:user_params) { { user: { name: 'new_user', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } }
context 'email confirmation' do
- around(:each) do |example|
+ around do |example|
perform_enqueued_jobs do
example.run
end
@@ -15,7 +15,7 @@ describe RegistrationsController do
it 'signs the user in' do
allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(false)
- expect { post(:create, user_params) }.not_to change{ ActionMailer::Base.deliveries.size }
+ expect { post(:create, user_params) }.not_to change { ActionMailer::Base.deliveries.size }
expect(subject.current_user).not_to be_nil
end
end
@@ -77,7 +77,7 @@ describe RegistrationsController do
end
it 'schedules the user for destruction' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {})
post(:destroy)
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 3173aae664c..37f961d0c94 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -8,7 +8,7 @@ describe SearchController do
end
it 'finds issue comments' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
note = create(:note_on_issue, project: project)
get :show, project_id: project.id, scope: 'notes', search: note.note
@@ -18,10 +18,12 @@ describe SearchController do
context 'on restricted projects' do
context 'when signed out' do
- before { sign_out(user) }
+ before do
+ sign_out(user)
+ end
it "doesn't expose comments on issues" do
- project = create(:empty_project, :public, :issues_private)
+ project = create(:project, :public, :issues_private)
note = create(:note_on_issue, project: project)
get :show, project_id: project.id, scope: 'notes', search: note.note
@@ -31,7 +33,7 @@ describe SearchController do
end
it "doesn't expose comments on merge_requests" do
- project = create(:empty_project, :public, :merge_requests_private)
+ project = create(:project, :public, :merge_requests_private)
note = create(:note_on_merge_request, project: project)
get :show, project_id: project.id, scope: 'notes', search: note.note
@@ -40,7 +42,7 @@ describe SearchController do
end
it "doesn't expose comments on snippets" do
- project = create(:empty_project, :public, :snippets_private)
+ project = create(:project, :public, :snippets_private)
note = create(:note_on_project_snippet, project: project)
get :show, project_id: project.id, scope: 'notes', search: note.note
diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb
index 954fc2eaf21..31593ce7311 100644
--- a/spec/controllers/sent_notifications_controller_spec.rb
+++ b/spec/controllers/sent_notifications_controller_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
-describe SentNotificationsController, type: :controller do
+describe SentNotificationsController do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:sent_notification) { create(:sent_notification, project: project, noteable: issue, recipient: user) }
let(:issue) do
@@ -14,14 +14,16 @@ describe SentNotificationsController, type: :controller do
describe 'GET unsubscribe' do
context 'when the user is not logged in' do
context 'when the force param is passed' do
- before { get(:unsubscribe, id: sent_notification.reply_key, force: true) }
+ before do
+ get(:unsubscribe, id: sent_notification.reply_key, force: true)
+ end
it 'unsubscribes the user' do
expect(issue.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
- expect(controller).to set_flash[:notice].to(/unsubscribed/).now
+ expect(controller).to set_flash[:notice].to(/unsubscribed/)
end
it 'redirects to the login page' do
@@ -30,7 +32,9 @@ describe SentNotificationsController, type: :controller do
end
context 'when the force param is not passed' do
- before { get(:unsubscribe, id: sent_notification.reply_key) }
+ before do
+ get(:unsubscribe, id: sent_notification.reply_key)
+ end
it 'does not unsubscribe the user' do
expect(issue.subscribed?(user, project)).to be_truthy
@@ -47,10 +51,14 @@ describe SentNotificationsController, type: :controller do
end
context 'when the user is logged in' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
context 'when the ID passed does not exist' do
- before { get(:unsubscribe, id: sent_notification.reply_key.reverse) }
+ before do
+ get(:unsubscribe, id: sent_notification.reply_key.reverse)
+ end
it 'does not unsubscribe the user' do
expect(issue.subscribed?(user, project)).to be_truthy
@@ -66,19 +74,21 @@ describe SentNotificationsController, type: :controller do
end
context 'when the force param is passed' do
- before { get(:unsubscribe, id: sent_notification.reply_key, force: true) }
+ before do
+ get(:unsubscribe, id: sent_notification.reply_key, force: true)
+ end
it 'unsubscribes the user' do
expect(issue.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
- expect(controller).to set_flash[:notice].to(/unsubscribed/).now
+ expect(controller).to set_flash[:notice].to(/unsubscribed/)
end
it 'redirects to the issue page' do
- expect(response).
- to redirect_to(namespace_project_issue_path(project.namespace, project, issue))
+ expect(response)
+ .to redirect_to(project_issue_path(project, issue))
end
end
@@ -89,19 +99,22 @@ describe SentNotificationsController, type: :controller do
end
end
let(:sent_notification) { create(:sent_notification, project: project, noteable: merge_request, recipient: user) }
- before { get(:unsubscribe, id: sent_notification.reply_key) }
+
+ before do
+ get(:unsubscribe, id: sent_notification.reply_key)
+ end
it 'unsubscribes the user' do
expect(merge_request.subscribed?(user, project)).to be_falsey
end
it 'sets the flash message' do
- expect(controller).to set_flash[:notice].to(/unsubscribed/).now
+ expect(controller).to set_flash[:notice].to(/unsubscribed/)
end
it 'redirects to the merge request page' do
- expect(response).
- to redirect_to(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ expect(response)
+ .to redirect_to(project_merge_request_path(project, merge_request))
end
end
end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 038132cffe0..a22fd8eaf9b 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -1,9 +1,42 @@
require 'spec_helper'
describe SessionsController do
+ include DeviseHelpers
+
+ describe '#new' do
+ before do
+ set_devise_mapping(context: @request)
+ end
+
+ context 'when auto sign-in is enabled' do
+ before do
+ stub_omniauth_setting(auto_sign_in_with_provider: :saml)
+ allow(controller).to receive(:omniauth_authorize_path).with(:user, :saml)
+ .and_return('/saml')
+ end
+
+ context 'and no auto_sign_in param is passed' do
+ it 'redirects to :omniauth_authorize_path' do
+ get(:new)
+
+ expect(response).to have_http_status(302)
+ expect(response).to redirect_to('/saml')
+ end
+ end
+
+ context 'and auto_sign_in=false param is passed' do
+ it 'responds with 200' do
+ get(:new, auto_sign_in: 'false')
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+ end
+
describe '#create' do
before do
- @request.env['devise.mapping'] = Devise.mappings[:user]
+ set_devise_mapping(context: @request)
end
context 'when using standard authentications' do
@@ -16,7 +49,7 @@ describe SessionsController do
end
end
- context 'when using valid password', :redis do
+ context 'when using valid password', :clean_gitlab_redis_shared_state do
include UserActivitiesHelpers
let(:user) { create(:user) }
@@ -58,8 +91,8 @@ describe SessionsController do
context 'remember_me field' do
it 'sets a remember_user_token cookie when enabled' do
allow(controller).to receive(:find_user).and_return(user)
- expect(controller).
- to receive(:remember_me).with(user).and_call_original
+ expect(controller)
+ .to receive(:remember_me).with(user).and_call_original
authenticate_2fa(remember_me: '1', otp_attempt: user.current_otp)
@@ -111,7 +144,9 @@ describe SessionsController do
end
context 'when OTP is invalid' do
- before { authenticate_2fa(otp_attempt: 'invalid') }
+ before do
+ authenticate_2fa(otp_attempt: 'invalid')
+ end
it 'does not authenticate' do
expect(subject.current_user).not_to eq user
@@ -138,7 +173,9 @@ describe SessionsController do
end
context 'when OTP is invalid' do
- before { authenticate_2fa(otp_attempt: 'invalid') }
+ before do
+ authenticate_2fa(otp_attempt: 'invalid')
+ end
it 'does not authenticate' do
expect(subject.current_user).not_to eq user
@@ -193,8 +230,8 @@ describe SessionsController do
it 'sets a remember_user_token cookie when enabled' do
allow(U2fRegistration).to receive(:authenticate).and_return(true)
allow(controller).to receive(:find_user).and_return(user)
- expect(controller).
- to receive(:remember_me).with(user).and_call_original
+ expect(controller)
+ .to receive(:remember_me).with(user).and_call_original
authenticate_2fa_u2f(remember_me: '1', login: user.username, device_response: "{}")
@@ -222,13 +259,13 @@ describe SessionsController do
describe '#new' do
before do
- @request.env['devise.mapping'] = Devise.mappings[:user]
+ set_devise_mapping(context: @request)
end
it 'redirects correctly for referer on same host with params' do
search_path = '/search?search=seed_project'
- allow(controller.request).to receive(:referer).
- and_return('http://%{host}%{path}' % { host: Gitlab.config.gitlab.host, path: search_path })
+ allow(controller.request).to receive(:referer)
+ .and_return('http://%{host}%{path}' % { host: Gitlab.config.gitlab.host, path: search_path })
get(:new, redirect_to_referer: :yes)
diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb
new file mode 100644
index 00000000000..225753333ee
--- /dev/null
+++ b/spec/controllers/snippets/notes_controller_spec.rb
@@ -0,0 +1,196 @@
+require 'spec_helper'
+
+describe Snippets::NotesController do
+ let(:user) { create(:user) }
+
+ let(:private_snippet) { create(:personal_snippet, :private) }
+ let(:internal_snippet) { create(:personal_snippet, :internal) }
+ let(:public_snippet) { create(:personal_snippet, :public) }
+
+ let(:note_on_private) { create(:note_on_personal_snippet, noteable: private_snippet) }
+ let(:note_on_internal) { create(:note_on_personal_snippet, noteable: internal_snippet) }
+ let(:note_on_public) { create(:note_on_personal_snippet, noteable: public_snippet) }
+
+ describe 'GET index' do
+ context 'when a snippet is public' do
+ before do
+ note_on_public
+
+ get :index, { snippet_id: public_snippet }
+ end
+
+ it "returns status 200" do
+ expect(response).to have_http_status(200)
+ end
+
+ it "returns not empty array of notes" do
+ expect(JSON.parse(response.body)["notes"].empty?).to be_falsey
+ end
+ end
+
+ context 'when a snippet is internal' do
+ before do
+ note_on_internal
+ end
+
+ context 'when user not logged in' do
+ it "returns status 404" do
+ get :index, { snippet_id: internal_snippet }
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when user logged in' do
+ before do
+ sign_in(user)
+ end
+
+ it "returns status 200" do
+ get :index, { snippet_id: internal_snippet }
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+
+ context 'when a snippet is private' do
+ before do
+ note_on_private
+ end
+
+ context 'when user not logged in' do
+ it "returns status 404" do
+ get :index, { snippet_id: private_snippet }
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when user other than author logged in' do
+ before do
+ sign_in(user)
+ end
+
+ it "returns status 404" do
+ get :index, { snippet_id: private_snippet }
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when author logged in' do
+ before do
+ note_on_private
+
+ sign_in(private_snippet.author)
+ end
+
+ it "returns status 200" do
+ get :index, { snippet_id: private_snippet }
+
+ expect(response).to have_http_status(200)
+ end
+
+ it "returns 1 note" do
+ get :index, { snippet_id: private_snippet }
+
+ expect(JSON.parse(response.body)['notes'].count).to eq(1)
+ end
+ end
+ end
+
+ context 'dont show non visible notes' do
+ before do
+ note_on_public
+
+ sign_in(user)
+
+ expect_any_instance_of(Note).to receive(:cross_reference_not_visible_for?).and_return(true)
+ end
+
+ it "does not return any note" do
+ get :index, { snippet_id: public_snippet }
+
+ expect(JSON.parse(response.body)['notes'].count).to eq(0)
+ end
+ end
+ end
+
+ describe 'DELETE destroy' do
+ let(:request_params) do
+ {
+ snippet_id: public_snippet,
+ id: note_on_public,
+ format: :js
+ }
+ end
+
+ context 'when user is the author of a note' do
+ before do
+ sign_in(note_on_public.author)
+ end
+
+ it "returns status 200" do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(200)
+ end
+
+ it "deletes the note" do
+ expect { delete :destroy, request_params }.to change { Note.count }.from(1).to(0)
+ end
+
+ context 'system note' do
+ before do
+ expect_any_instance_of(Note).to receive(:system?).and_return(true)
+ end
+
+ it "does not delete the note" do
+ expect { delete :destroy, request_params }.not_to change { Note.count }
+ end
+ end
+ end
+
+ context 'when user is not the author of a note' do
+ before do
+ sign_in(user)
+
+ note_on_public
+ end
+
+ it "returns status 404" do
+ delete :destroy, request_params
+
+ expect(response).to have_http_status(404)
+ end
+
+ it "does not update the note" do
+ expect { delete :destroy, request_params }.not_to change { Note.count }
+ end
+ end
+ end
+
+ describe 'POST toggle_award_emoji' do
+ let(:note) { create(:note_on_personal_snippet, noteable: public_snippet) }
+ before do
+ sign_in(user)
+ end
+
+ subject { post(:toggle_award_emoji, snippet_id: public_snippet, id: note.id, name: "thumbsup") }
+
+ it "toggles the award emoji" do
+ expect { subject }.to change { note.award_emoji.count }.by(1)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it "removes the already awarded emoji when it exists" do
+ note.toggle_award_emoji('thumbsup', user) # create award emoji before
+
+ expect { subject }.to change { AwardEmoji.count }.by(-1)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 234f3edd3d8..7c5d059760f 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -3,6 +3,34 @@ require 'spec_helper'
describe SnippetsController do
let(:user) { create(:user) }
+ describe 'GET #index' do
+ let(:user) { create(:user) }
+
+ context 'when username parameter is present' do
+ it 'renders snippets of a user when username is present' do
+ get :index, username: user.username
+
+ expect(response).to render_template(:index)
+ end
+ end
+
+ context 'when username parameter is not present' do
+ it 'redirects to explore snippets page when user is not logged in' do
+ get :index
+
+ expect(response).to redirect_to(explore_snippets_path)
+ end
+
+ it 'redirects to snippets dashboard page when user is logged in' do
+ sign_in(user)
+
+ get :index
+
+ expect(response).to redirect_to(dashboard_snippets_path)
+ end
+ end
+ end
+
describe 'GET #new' do
context 'when signed in' do
before do
@@ -132,7 +160,7 @@ describe SnippetsController do
it 'responds with status 404' do
get :show, id: 'doesntexist'
- expect(response).to have_http_status(404)
+ expect(response).to redirect_to(new_user_session_path)
end
end
end
@@ -143,12 +171,50 @@ describe SnippetsController do
sign_in(user)
post :create, {
- personal_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
+ personal_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
Snippet.last
end
+ it 'creates the snippet correctly' do
+ snippet = create_snippet(visibility_level: Snippet::PRIVATE)
+
+ expect(snippet.title).to eq('Title')
+ expect(snippet.content).to eq('Content')
+ expect(snippet.description).to eq('Description')
+ end
+
+ context 'when the snippet description contains a file' do
+ let(:picture_file) { '/-/system/temp/secret56/picture.jpg' }
+ let(:text_file) { '/-/system/temp/secret78/text.txt' }
+ let(:description) do
+ "Description with picture: ![picture](/uploads#{picture_file}) and "\
+ "text: [text.txt](/uploads#{text_file})"
+ end
+
+ before do
+ allow(FileUtils).to receive(:mkdir_p)
+ allow(FileUtils).to receive(:move)
+ end
+
+ subject { create_snippet({ description: description }, { files: [picture_file, text_file] }) }
+
+ it 'creates the snippet' do
+ expect { subject }.to change { Snippet.count }.by(1)
+ end
+
+ it 'stores the snippet description correctly' do
+ snippet = subject
+
+ expected_description = "Description with picture: "\
+ "![picture](/uploads/-/system/personal_snippet/#{snippet.id}/secret56/picture.jpg) and "\
+ "text: [text.txt](/uploads/-/system/personal_snippet/#{snippet.id}/secret78/text.txt)"
+
+ expect(snippet.description).to eq(expected_description)
+ end
+ end
+
context 'when the snippet is spam' do
before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
@@ -156,20 +222,20 @@ describe SnippetsController do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(visibility_level: Snippet::PRIVATE) }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(visibility_level: Snippet::PRIVATE) }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the shippet' do
- expect { create_snippet(visibility_level: Snippet::PUBLIC) }.
- not_to change { Snippet.count }
+ expect { create_snippet(visibility_level: Snippet::PUBLIC) }
+ .not_to change { Snippet.count }
end
it 'creates a spam log' do
- expect { create_snippet(visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :new with recaptcha disabled' do
@@ -230,8 +296,8 @@ describe SnippetsController do
let(:visibility_level) { Snippet::PRIVATE }
it 'updates the snippet' do
- expect { update_snippet(title: 'Foo') }.
- to change { snippet.reload.title }.to('Foo')
+ expect { update_snippet(title: 'Foo') }
+ .to change { snippet.reload.title }.to('Foo')
end
end
@@ -239,13 +305,13 @@ describe SnippetsController do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :edit with recaptcha disabled' do
@@ -275,7 +341,7 @@ describe SnippetsController do
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
- expect(response).to redirect_to(snippet)
+ expect(response).to redirect_to(snippet_path(snippet))
end
end
end
@@ -284,13 +350,13 @@ describe SnippetsController do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
- expect { update_snippet(title: 'Foo') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo') }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo') }
+ .to change { SpamLog.count }.by(1)
end
it 'renders :edit with recaptcha disabled' do
@@ -350,144 +416,140 @@ describe SnippetsController do
end
end
- %w(raw download).each do |action|
- describe "GET #{action}" do
- context 'when the personal snippet is private' do
- let(:personal_snippet) { create(:personal_snippet, :private, author: user) }
+ describe "GET #raw" do
+ context 'when the personal snippet is private' do
+ let(:personal_snippet) { create(:personal_snippet, :private, author: user) }
- context 'when signed in' do
- before do
- sign_in(user)
- end
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
- context 'when signed in user is not the author' do
- let(:other_author) { create(:author) }
- let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) }
+ context 'when signed in user is not the author' do
+ let(:other_author) { create(:author) }
+ let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) }
- it 'responds with status 404' do
- get action, id: other_personal_snippet.to_param
+ it 'responds with status 404' do
+ get :raw, id: other_personal_snippet.to_param
- expect(response).to have_http_status(404)
- end
+ expect(response).to have_http_status(404)
end
+ end
- context 'when signed in user is the author' do
- before { get action, id: personal_snippet.to_param }
+ context 'when signed in user is the author' do
+ before do
+ get :raw, id: personal_snippet.to_param
+ end
- it 'responds with status 200' do
- expect(assigns(:snippet)).to eq(personal_snippet)
- expect(response).to have_http_status(200)
- end
+ it 'responds with status 200' do
+ expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(response).to have_http_status(200)
+ end
- it 'has expected headers' do
- expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
+ it 'has expected headers' do
+ expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
- if action == :download
- expect(response.header['Content-Disposition']).to match(/attachment/)
- elsif action == :raw
- expect(response.header['Content-Disposition']).to match(/inline/)
- end
- end
+ expect(response.header['Content-Disposition']).to match(/inline/)
end
end
+ end
- context 'when not signed in' do
- it 'redirects to the sign in page' do
- get action, id: personal_snippet.to_param
+ context 'when not signed in' do
+ it 'redirects to the sign in page' do
+ get :raw, id: personal_snippet.to_param
- expect(response).to redirect_to(new_user_session_path)
- end
+ expect(response).to redirect_to(new_user_session_path)
end
end
+ end
- context 'when the personal snippet is internal' do
- let(:personal_snippet) { create(:personal_snippet, :internal, author: user) }
+ context 'when the personal snippet is internal' do
+ let(:personal_snippet) { create(:personal_snippet, :internal, author: user) }
- context 'when signed in' do
- before do
- sign_in(user)
- end
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
- it 'responds with status 200' do
- get action, id: personal_snippet.to_param
+ it 'responds with status 200' do
+ get :raw, id: personal_snippet.to_param
- expect(assigns(:snippet)).to eq(personal_snippet)
- expect(response).to have_http_status(200)
- end
+ expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(response).to have_http_status(200)
end
+ end
- context 'when not signed in' do
- it 'redirects to the sign in page' do
- get action, id: personal_snippet.to_param
+ context 'when not signed in' do
+ it 'redirects to the sign in page' do
+ get :raw, id: personal_snippet.to_param
- expect(response).to redirect_to(new_user_session_path)
- end
+ expect(response).to redirect_to(new_user_session_path)
end
end
+ end
- context 'when the personal snippet is public' do
- let(:personal_snippet) { create(:personal_snippet, :public, author: user) }
+ context 'when the personal snippet is public' do
+ let(:personal_snippet) { create(:personal_snippet, :public, author: user) }
- context 'when signed in' do
- before do
- sign_in(user)
- end
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
- it 'responds with status 200' do
- get action, id: personal_snippet.to_param
+ it 'responds with status 200' do
+ get :raw, id: personal_snippet.to_param
- expect(assigns(:snippet)).to eq(personal_snippet)
- expect(response).to have_http_status(200)
- end
+ expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(response).to have_http_status(200)
+ end
- context 'CRLF line ending' do
- let(:personal_snippet) do
- create(:personal_snippet, :public, author: user, content: "first line\r\nsecond line\r\nthird line")
- end
+ context 'CRLF line ending' do
+ let(:personal_snippet) do
+ create(:personal_snippet, :public, author: user, content: "first line\r\nsecond line\r\nthird line")
+ end
- it 'returns LF line endings by default' do
- get action, id: personal_snippet.to_param
+ it 'returns LF line endings by default' do
+ get :raw, id: personal_snippet.to_param
- expect(response.body).to eq("first line\nsecond line\nthird line")
- end
+ expect(response.body).to eq("first line\nsecond line\nthird line")
+ end
- it 'does not convert line endings when parameter present' do
- get action, id: personal_snippet.to_param, line_ending: :raw
+ it 'does not convert line endings when parameter present' do
+ get :raw, id: personal_snippet.to_param, line_ending: :raw
- expect(response.body).to eq("first line\r\nsecond line\r\nthird line")
- end
+ expect(response.body).to eq("first line\r\nsecond line\r\nthird line")
end
end
+ end
- context 'when not signed in' do
- it 'responds with status 200' do
- get action, id: personal_snippet.to_param
+ context 'when not signed in' do
+ it 'responds with status 200' do
+ get :raw, id: personal_snippet.to_param
- expect(assigns(:snippet)).to eq(personal_snippet)
- expect(response).to have_http_status(200)
- end
+ expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(response).to have_http_status(200)
end
end
+ end
- context 'when the personal snippet does not exist' do
- context 'when signed in' do
- before do
- sign_in(user)
- end
+ context 'when the personal snippet does not exist' do
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
- it 'responds with status 404' do
- get action, id: 'doesntexist'
+ it 'responds with status 404' do
+ get :raw, id: 'doesntexist'
- expect(response).to have_http_status(404)
- end
+ expect(response).to have_http_status(404)
end
+ end
- context 'when not signed in' do
- it 'responds with status 404' do
- get action, id: 'doesntexist'
+ context 'when not signed in' do
+ it 'redirects to the sign in path' do
+ get :raw, id: 'doesntexist'
- expect(response).to have_http_status(404)
- end
+ expect(response).to redirect_to(new_user_session_path)
end
end
end
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index f67d26da0ac..b29f3d861be 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -8,9 +8,130 @@ end
describe UploadsController do
let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
+ describe 'POST create' do
+ let(:model) { 'personal_snippet' }
+ let(:snippet) { create(:personal_snippet, :public) }
+ let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') }
+ let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
+
+ context 'when a user does not have permissions to upload a file' do
+ it "returns 401 when the user is not logged in" do
+ post :create, model: model, id: snippet.id, format: :json
+
+ expect(response).to have_http_status(401)
+ end
+
+ it "returns 404 when user can't comment on a snippet" do
+ private_snippet = create(:personal_snippet, :private)
+
+ sign_in(user)
+ post :create, model: model, id: private_snippet.id, format: :json
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when a user is logged in' do
+ before do
+ sign_in(user)
+ end
+
+ it "returns an error without file" do
+ post :create, model: model, id: snippet.id, format: :json
+
+ expect(response).to have_http_status(422)
+ end
+
+ it "returns an error with invalid model" do
+ expect { post :create, model: 'invalid', id: snippet.id, format: :json }
+ .to raise_error(ActionController::UrlGenerationError)
+ end
+
+ it "returns 404 status when object not found" do
+ post :create, model: model, id: 9999, format: :json
+
+ expect(response).to have_http_status(404)
+ end
+
+ context 'with valid image' do
+ before do
+ post :create, model: 'personal_snippet', id: snippet.id, file: jpg, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ expect(response.body).to match '\"alt\":\"rails_sample\"'
+ expect(response.body).to match "\"url\":\"/uploads"
+ end
+
+ it 'creates a corresponding Upload record' do
+ upload = Upload.last
+
+ aggregate_failures do
+ expect(upload).to exist
+ expect(upload.model).to eq snippet
+ end
+ end
+ end
+
+ context 'with valid non-image file' do
+ before do
+ post :create, model: 'personal_snippet', id: snippet.id, file: txt, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
+ expect(response.body).to match "\"url\":\"/uploads"
+ end
+
+ it 'creates a corresponding Upload record' do
+ upload = Upload.last
+
+ aggregate_failures do
+ expect(upload).to exist
+ expect(upload.model).to eq snippet
+ end
+ end
+ end
+
+ context 'temporal with valid image' do
+ subject do
+ post :create, model: 'personal_snippet', file: jpg, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ subject
+
+ expect(response.body).to match '\"alt\":\"rails_sample\"'
+ expect(response.body).to match "\"url\":\"/uploads/-/system/temp"
+ end
+
+ it 'does not create an Upload record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
+
+ context 'temporal with valid non-image file' do
+ subject do
+ post :create, model: 'personal_snippet', file: txt, format: :json
+ end
+
+ it 'returns a content with original filename, new link, and correct type.' do
+ subject
+
+ expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
+ expect(response.body).to match "\"url\":\"/uploads/-/system/temp"
+ end
+
+ it 'does not create an Upload record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
+ end
+ end
+
describe "GET show" do
context 'Content-Disposition security measures' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'for PNG files' do
it 'returns Content-Disposition: inline' do
@@ -82,7 +203,7 @@ describe UploadsController do
end
context "when viewing a project avatar" do
- let!(:project) { create(:empty_project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
+ let!(:project) { create(:project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
context "when the project is public" do
before do
@@ -386,5 +507,45 @@ describe UploadsController do
end
end
end
+
+ context 'Appearance' do
+ context 'when viewing a custom header logo' do
+ let!(:appearance) { create :appearance, header_logo: fixture_file_upload(Rails.root.join('spec/fixtures/dk.png'), 'image/png') }
+
+ context 'when not signed in' do
+ it 'responds with status 200' do
+ get :show, model: 'appearance', mounted_as: 'header_logo', id: appearance.id, filename: 'dk.png'
+
+ expect(response).to have_http_status(200)
+ end
+
+ it_behaves_like 'content not cached without revalidation' do
+ subject do
+ get :show, model: 'appearance', mounted_as: 'header_logo', id: appearance.id, filename: 'dk.png'
+ response
+ end
+ end
+ end
+ end
+
+ context 'when viewing a custom logo' do
+ let!(:appearance) { create :appearance, logo: fixture_file_upload(Rails.root.join('spec/fixtures/dk.png'), 'image/png') }
+
+ context 'when not signed in' do
+ it 'responds with status 200' do
+ get :show, model: 'appearance', mounted_as: 'logo', id: appearance.id, filename: 'dk.png'
+
+ expect(response).to have_http_status(200)
+ end
+
+ it_behaves_like 'content not cached without revalidation' do
+ subject do
+ get :show, model: 'appearance', mounted_as: 'logo', id: appearance.id, filename: 'dk.png'
+ response
+ end
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index bbe9aaf737f..2cecd2646fc 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -4,15 +4,6 @@ describe UsersController do
let(:user) { create(:user) }
describe 'GET #show' do
- it 'is case-insensitive' do
- user = create(:user, username: 'CamelCaseUser')
- sign_in(user)
-
- get :show, username: user.username.downcase
-
- expect(response).to be_success
- end
-
context 'with rendered views' do
render_views
@@ -45,14 +36,16 @@ describe UsersController do
end
context 'when logged out' do
- it 'renders 404' do
+ it 'redirects to login page' do
get :show, username: user.username
- expect(response).to have_http_status(404)
+ expect(response).to redirect_to new_user_session_path
end
end
context 'when logged in' do
- before { sign_in(user) }
+ before do
+ sign_in(user)
+ end
it 'renders show' do
get :show, username: user.username
@@ -61,26 +54,52 @@ describe UsersController do
end
end
end
+
+ context 'when a user by that username does not exist' do
+ context 'when logged out' do
+ it 'redirects to login page' do
+ get :show, username: 'nonexistent'
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+
+ context 'when logged in' do
+ before do
+ sign_in(user)
+ end
+
+ it 'renders 404' do
+ get :show, username: 'nonexistent'
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
end
describe 'GET #calendar' do
it 'renders calendar' do
sign_in(user)
- get :calendar, username: user.username
+ get :calendar, username: user.username, format: :json
- expect(response).to render_template('calendar')
+ expect(response).to have_http_status(200)
end
context 'forked project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:forked_project) { Projects::ForkService.new(project, user).execute }
before do
sign_in(user)
project.team << [user, :developer]
- EventCreateService.new.push(project, user, [])
- EventCreateService.new.push(forked_project, user, [])
+
+ push_data = Gitlab::DataBuilder::Push.build_sample(project, user)
+
+ fork_push_data = Gitlab::DataBuilder::Push
+ .build_sample(forked_project, user)
+
+ EventCreateService.new.push(project, user, push_data)
+ EventCreateService.new.push(forked_project, user, fork_push_data)
end
it 'includes forked projects' do
@@ -91,8 +110,8 @@ describe UsersController do
end
describe 'GET #calendar_activities' do
- let!(:project) { create(:empty_project) }
- let!(:user) { create(:user) }
+ let!(:project) { create(:project) }
+ let(:user) { create(:user) }
before do
allow_any_instance_of(User).to receive(:contributed_projects_ids).and_return([project.id])
@@ -133,4 +152,175 @@ describe UsersController do
end
end
end
+
+ describe 'GET #exists' do
+ before do
+ sign_in(user)
+ end
+
+ context 'when user exists' do
+ it 'returns JSON indicating the user exists' do
+ get :exists, username: user.username
+
+ expected_json = { exists: true }.to_json
+ expect(response.body).to eq(expected_json)
+ end
+
+ context 'when the casing is different' do
+ let(:user) { create(:user, username: 'CamelCaseUser') }
+
+ it 'returns JSON indicating the user exists' do
+ get :exists, username: user.username.downcase
+
+ expected_json = { exists: true }.to_json
+ expect(response.body).to eq(expected_json)
+ end
+ end
+ end
+
+ context 'when the user does not exist' do
+ it 'returns JSON indicating the user does not exist' do
+ get :exists, username: 'foo'
+
+ expected_json = { exists: false }.to_json
+ expect(response.body).to eq(expected_json)
+ end
+
+ context 'when a user changed their username' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') }
+
+ it 'returns JSON indicating a user by that username does not exist' do
+ get :exists, username: 'old-username'
+
+ expected_json = { exists: false }.to_json
+ expect(response.body).to eq(expected_json)
+ end
+ end
+ end
+ end
+
+ describe '#ensure_canonical_path' do
+ before do
+ sign_in(user)
+ end
+
+ context 'for a GET request' do
+ context 'when requesting users at the root path' do
+ context 'when requesting the canonical path' do
+ let(:user) { create(:user, username: 'CamelCaseUser') }
+
+ context 'with exactly matching casing' do
+ it 'responds with success' do
+ get :show, username: user.username
+
+ expect(response).to be_success
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :show, username: user.username.downcase
+
+ expect(response).to redirect_to(user)
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-path') }
+
+ it 'redirects to the canonical path' do
+ get :show, username: redirect_route.path
+
+ expect(response).to redirect_to(user)
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+
+ context 'when the old path is a substring of the scheme or host' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'http') }
+
+ it 'does not modify the requested host' do
+ get :show, username: redirect_route.path
+
+ expect(response).to redirect_to(user)
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+ end
+
+ context 'when the old path is substring of users' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'ser') }
+
+ it 'redirects to the canonical path' do
+ get :show, username: redirect_route.path
+
+ expect(response).to redirect_to(user)
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+ end
+ end
+ end
+
+ context 'when requesting users under the /users path' do
+ context 'when requesting the canonical path' do
+ let(:user) { create(:user, username: 'CamelCaseUser') }
+
+ context 'with exactly matching casing' do
+ it 'responds with success' do
+ get :projects, username: user.username
+
+ expect(response).to be_success
+ end
+ end
+
+ context 'with different casing' do
+ it 'redirects to the correct casing' do
+ get :projects, username: user.username.downcase
+
+ expect(response).to redirect_to(user_projects_path(user))
+ expect(controller).not_to set_flash[:notice]
+ end
+ end
+ end
+
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-path') }
+
+ it 'redirects to the canonical path' do
+ get :projects, username: redirect_route.path
+
+ expect(response).to redirect_to(user_projects_path(user))
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+
+ context 'when the old path is a substring of the scheme or host' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'http') }
+
+ it 'does not modify the requested host' do
+ get :projects, username: redirect_route.path
+
+ expect(response).to redirect_to(user_projects_path(user))
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+ end
+
+ context 'when the old path is substring of users' do
+ let(:redirect_route) { user.namespace.redirect_routes.create(path: 'ser') }
+
+ # I.e. /users/ser should not become /ufoos/ser
+ it 'does not modify the /users part of the path' do
+ get :projects, username: redirect_route.path
+
+ expect(response).to redirect_to(user_projects_path(user))
+ expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user))
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def user_moved_message(redirect_route, user)
+ "User '#{redirect_route.path}' was moved to '#{user.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb
deleted file mode 100644
index 007b35bbb77..00000000000
--- a/spec/db/production/settings.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require 'spec_helper'
-require 'rainbow/ext/string'
-
-describe 'seed production settings', lib: true do
- include StubENV
-
- context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do
- before do
- stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789')
- end
-
- it 'writes the token to the database' do
- load(File.join(__dir__, '../../../db/fixtures/production/010_settings.rb'))
- expect(ApplicationSetting.current.runners_registration_token).to eq('013456789')
- end
- end
-end
diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb
new file mode 100644
index 00000000000..79e67330854
--- /dev/null
+++ b/spec/db/production/settings_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+require 'rainbow/ext/string'
+
+describe 'seed production settings' do
+ include StubENV
+ let(:settings_file) { Rails.root.join('db/fixtures/production/010_settings.rb') }
+ let(:settings) { Gitlab::CurrentSettings.current_application_settings }
+
+ context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do
+ before do
+ stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789')
+ end
+
+ it 'writes the token to the database' do
+ load(settings_file)
+
+ expect(settings.runners_registration_token).to eq('013456789')
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is set in the environment' do
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is true' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'true')
+ end
+
+ it 'prometheus_metrics_enabled is set to true ' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(true)
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'false')
+ end
+
+ it 'prometheus_metrics_enabled is set to false' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(false)
+ end
+ end
+
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do
+ before do
+ stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', '')
+ end
+
+ it 'prometheus_metrics_enabled is set to false' do
+ load(settings_file)
+
+ expect(settings.prometheus_metrics_enabled).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/factories/application_settings.rb b/spec/factories/application_settings.rb
new file mode 100644
index 00000000000..aef65e724c2
--- /dev/null
+++ b/spec/factories/application_settings.rb
@@ -0,0 +1,4 @@
+FactoryGirl.define do
+ factory :application_setting do
+ end
+end
diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb
index 4df9aef2846..1ec042a6ab4 100644
--- a/spec/factories/boards.rb
+++ b/spec/factories/boards.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :board do
- project factory: :empty_project
+ project
after(:create) do |board|
board.lists.create(list_type: :closed)
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index b62def83ee4..5bba1dec7db 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -64,7 +64,8 @@ FactoryGirl.define do
trait :teardown_environment do
environment 'staging'
options environment: { name: 'staging',
- action: 'stop' }
+ action: 'stop',
+ url: 'http://staging.example.com/$CI_JOB_NAME' }
end
trait :allowed_to_fail do
@@ -79,6 +80,23 @@ FactoryGirl.define do
manual
end
+ trait :retryable do
+ success
+ end
+
+ trait :retried do
+ retried true
+ end
+
+ trait :cancelable do
+ pending
+ end
+
+ trait :erasable do
+ success
+ artifacts
+ end
+
trait :tags do
tag_list [:docker, :ruby]
end
@@ -92,7 +110,7 @@ FactoryGirl.define do
end
after(:build) do |build, evaluator|
- build.project = build.pipeline.project
+ build.project ||= build.pipeline.project
end
factory :ci_not_started_build do
@@ -115,6 +133,16 @@ FactoryGirl.define do
end
end
+ trait :unicode_trace do
+ after(:create) do |build, evaluator|
+ trace = File.binread(
+ File.expand_path(
+ Rails.root.join('spec/fixtures/trace/ansi-sequence-and-unicode')))
+
+ build.trace.set(trace)
+ end
+ end
+
trait :erased do
erased_at Time.now
erased_by factory: :user
@@ -170,8 +198,8 @@ FactoryGirl.define do
trait :extended_options do
options do
{
- image: 'ruby:2.1',
- services: ['postgres'],
+ image: { name: 'ruby:2.1', entrypoint: '/bin/sh' },
+ services: ['postgres', { name: 'docker:dind', entrypoint: '/bin/sh', command: 'sleep 30', alias: 'docker' }],
after_script: %w(ls date),
artifacts: {
name: 'artifacts_file',
@@ -183,7 +211,8 @@ FactoryGirl.define do
cache: {
key: 'cache_key',
untracked: false,
- paths: ['vendor/*']
+ paths: ['vendor/*'],
+ policy: 'pull-push'
}
}
end
diff --git a/spec/factories/ci/group_variables.rb b/spec/factories/ci/group_variables.rb
new file mode 100644
index 00000000000..565ced9eb1a
--- /dev/null
+++ b/spec/factories/ci/group_variables.rb
@@ -0,0 +1,12 @@
+FactoryGirl.define do
+ factory :ci_group_variable, class: Ci::GroupVariable do
+ sequence(:key) { |n| "VARIABLE_#{n}" }
+ value 'VARIABLE_VALUE'
+
+ trait(:protected) do
+ protected true
+ end
+
+ group factory: :group
+ end
+end
diff --git a/spec/factories/ci/pipeline_schedule.rb b/spec/factories/ci/pipeline_schedule.rb
new file mode 100644
index 00000000000..564fef6833b
--- /dev/null
+++ b/spec/factories/ci/pipeline_schedule.rb
@@ -0,0 +1,29 @@
+FactoryGirl.define do
+ factory :ci_pipeline_schedule, class: Ci::PipelineSchedule do
+ cron '0 1 * * *'
+ cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
+ ref 'master'
+ active true
+ description "pipeline schedule"
+ project
+
+ trait :nightly do
+ cron '0 1 * * *'
+ cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
+ end
+
+ trait :weekly do
+ cron '0 1 * * 6'
+ cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
+ end
+
+ trait :monthly do
+ cron '0 1 22 * *'
+ cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
+ end
+
+ trait :inactive do
+ active false
+ end
+ end
+end
diff --git a/spec/factories/ci/pipeline_schedule_variables.rb b/spec/factories/ci/pipeline_schedule_variables.rb
new file mode 100644
index 00000000000..ca64d1aada0
--- /dev/null
+++ b/spec/factories/ci/pipeline_schedule_variables.rb
@@ -0,0 +1,8 @@
+FactoryGirl.define do
+ factory :ci_pipeline_schedule_variable, class: Ci::PipelineScheduleVariable do
+ sequence(:key) { |n| "VARIABLE_#{n}" }
+ value 'VARIABLE_VALUE'
+
+ pipeline_schedule factory: :ci_pipeline_schedule
+ end
+end
diff --git a/spec/factories/ci/pipeline_variable_variables.rb b/spec/factories/ci/pipeline_variable_variables.rb
new file mode 100644
index 00000000000..7c1a7faec08
--- /dev/null
+++ b/spec/factories/ci/pipeline_variable_variables.rb
@@ -0,0 +1,8 @@
+FactoryGirl.define do
+ factory :ci_pipeline_variable, class: Ci::PipelineVariable do
+ sequence(:key) { |n| "VARIABLE_#{n}" }
+ value 'VARIABLE_VALUE'
+
+ pipeline factory: :ci_empty_pipeline
+ end
+end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 561fbc8e247..e83a0e599a8 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -1,40 +1,47 @@
FactoryGirl.define do
factory :ci_empty_pipeline, class: Ci::Pipeline do
+ source :push
ref 'master'
sha '97de212e80737a608d939f648d959671fb0a0142'
status 'pending'
- project factory: :empty_project
+ project
factory :ci_pipeline_without_jobs do
after(:build) do |pipeline|
- allow(pipeline).to receive(:ci_yaml_file) { YAML.dump({}) }
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({}))
end
end
factory :ci_pipeline_with_one_job do
after(:build) do |pipeline|
allow(pipeline).to receive(:ci_yaml_file) do
- YAML.dump({ rspec: { script: "ls" } })
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({ rspec: { script: "ls" } }))
end
end
end
+ # Persist merge request head_pipeline_id
+ # on pipeline factories to avoid circular references
+ transient { head_pipeline_of nil }
+
+ after(:create) do |pipeline, evaluator|
+ merge_request = evaluator.head_pipeline_of
+ merge_request&.update(head_pipeline: pipeline)
+ end
+
factory :ci_pipeline do
transient { config nil }
after(:build) do |pipeline, evaluator|
- allow(pipeline).to receive(:ci_yaml_file) do
- if evaluator.config
- YAML.dump(evaluator.config)
- else
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
- end
+ if evaluator.config
+ pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump(evaluator.config))
- # Populates pipeline with errors
- #
- pipeline.config_processor if evaluator.config
+ # Populates pipeline with errors
+ pipeline.config_processor if evaluator.config
+ else
+ pipeline.instance_variable_set(:@ci_yaml_file, File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')))
+ end
end
trait :invalid do
diff --git a/spec/factories/ci/runner_projects.rb b/spec/factories/ci/runner_projects.rb
index 6712dd5d82e..fa76d0ecd8c 100644
--- a/spec/factories/ci/runner_projects.rb
+++ b/spec/factories/ci/runner_projects.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :ci_runner_project, class: Ci::RunnerProject do
- runner_id 1
- project_id 1
+ runner factory: :ci_runner
+ project
end
end
diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb
index 7f557b25ccb..d3c8bf9d54f 100644
--- a/spec/factories/ci/stages.rb
+++ b/spec/factories/ci/stages.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
- factory :ci_stage, class: Ci::Stage do
+ factory :ci_stage, class: Ci::LegacyStage do
+ skip_create
+
transient do
name 'test'
status nil
@@ -8,7 +10,9 @@ FactoryGirl.define do
end
initialize_with do
- Ci::Stage.new(pipeline, name: name, status: status, warnings: warnings)
+ Ci::LegacyStage.new(pipeline, name: name,
+ status: status,
+ warnings: warnings)
end
end
end
diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb
index b8d8fab0e0b..10e0ab4fd3c 100644
--- a/spec/factories/ci/trigger_requests.rb
+++ b/spec/factories/ci/trigger_requests.rb
@@ -1,8 +1,8 @@
FactoryGirl.define do
factory :ci_trigger_request, class: Ci::TriggerRequest do
- factory :ci_trigger_request_with_variables do
- trigger factory: :ci_trigger
+ trigger factory: :ci_trigger
+ factory :ci_trigger_request_with_variables do
variables do
{
TRIGGER_KEY_1: 'TRIGGER_VALUE_1',
diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/trigger_schedules.rb
deleted file mode 100644
index 2390706fa41..00000000000
--- a/spec/factories/ci/trigger_schedules.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-FactoryGirl.define do
- factory :ci_trigger_schedule, class: Ci::TriggerSchedule 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.project ||= trigger_schedule.trigger.project
- end
-
- trait :nightly do
- cron '0 1 * * *'
- cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
- end
-
- trait :weekly do
- cron '0 1 * * 6'
- cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
- end
-
- trait :monthly do
- cron '0 1 22 * *'
- cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
- end
- end
-end
diff --git a/spec/factories/ci/triggers.rb b/spec/factories/ci/triggers.rb
index c3a29d8bf04..40c4663c6d8 100644
--- a/spec/factories/ci/triggers.rb
+++ b/spec/factories/ci/triggers.rb
@@ -2,13 +2,6 @@ FactoryGirl.define do
factory :ci_trigger_without_token, class: Ci::Trigger do
factory :ci_trigger do
sequence(:token) { |n| "token#{n}" }
-
- factory :ci_trigger_for_trigger_schedule do
- token { SecureRandom.hex(15) }
- owner factory: :user
- project factory: :project
- ref 'master'
- end
end
end
end
diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb
index 6653f0bb5c3..d8fd513ffcf 100644
--- a/spec/factories/ci/variables.rb
+++ b/spec/factories/ci/variables.rb
@@ -2,5 +2,11 @@ FactoryGirl.define do
factory :ci_variable, class: Ci::Variable do
sequence(:key) { |n| "VARIABLE_#{n}" }
value 'VARIABLE_VALUE'
+
+ trait(:protected) do
+ protected true
+ end
+
+ project
end
end
diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb
index 89e260cf65b..f4f12a095fc 100644
--- a/spec/factories/commits.rb
+++ b/spec/factories/commits.rb
@@ -3,7 +3,7 @@ require_relative '../support/repo_helpers'
FactoryGirl.define do
factory :commit do
git_commit RepoHelpers.sample_commit
- project factory: :empty_project
+ project
initialize_with do
new(git_commit, project)
diff --git a/spec/factories/conversational_development_index_metrics.rb b/spec/factories/conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..3806c43ba15
--- /dev/null
+++ b/spec/factories/conversational_development_index_metrics.rb
@@ -0,0 +1,43 @@
+FactoryGirl.define do
+ factory :conversational_development_index_metric, class: ConversationalDevelopmentIndex::Metric do
+ leader_issues 9.256
+ instance_issues 1.234
+ percentage_issues 13.331
+
+ leader_notes 30.33333
+ instance_notes 28.123
+ percentage_notes 92.713
+
+ leader_milestones 16.2456
+ instance_milestones 1.234
+ percentage_milestones 7.595
+
+ leader_boards 5.2123
+ instance_boards 3.254
+ percentage_boards 62.429
+
+ leader_merge_requests 1.2
+ instance_merge_requests 0.6
+ percentage_merge_requests 50.0
+
+ leader_ci_pipelines 12.1234
+ instance_ci_pipelines 2.344
+ percentage_ci_pipelines 19.334
+
+ leader_environments 3.3333
+ instance_environments 2.2222
+ percentage_environments 66.672
+
+ leader_deployments 1.200
+ instance_deployments 0.771
+ percentage_deployments 64.25
+
+ leader_projects_prometheus_active 0.111
+ instance_projects_prometheus_active 0.109
+ percentage_projects_prometheus_active 98.198
+
+ leader_service_desk_issues 15.891
+ instance_service_desk_issues 13.345
+ percentage_service_desk_issues 83.978
+ end
+end
diff --git a/spec/factories/deploy_keys_projects.rb b/spec/factories/deploy_keys_projects.rb
index 75f8982ecd9..27cece487bd 100644
--- a/spec/factories/deploy_keys_projects.rb
+++ b/spec/factories/deploy_keys_projects.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :deploy_keys_project do
deploy_key
- project factory: :empty_project
+ project
end
end
diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb
index 29ad1af9fd9..e5abfd67d60 100644
--- a/spec/factories/deployments.rb
+++ b/spec/factories/deployments.rb
@@ -10,6 +10,10 @@ FactoryGirl.define do
after(:build) do |deployment, evaluator|
deployment.project ||= deployment.environment.project
+
+ unless deployment.project.repository_exists?
+ allow(deployment.project.repository).to receive(:fetch_ref)
+ end
end
end
end
diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb
index 3fbf24b5c7d..9034476d094 100644
--- a/spec/factories/environments.rb
+++ b/spec/factories/environments.rb
@@ -2,12 +2,10 @@ FactoryGirl.define do
factory :environment, class: Environment do
sequence(:name) { |n| "environment#{n}" }
- project factory: :empty_project
+ association :project, :repository
sequence(:external_url) { |n| "https://env#{n}.example.gitlab.com" }
trait :with_review_app do |environment|
- project
-
transient do
ref 'master'
end
@@ -18,15 +16,21 @@ FactoryGirl.define do
# interconnected objects to simulate a review app.
#
after(:create) do |environment, evaluator|
+ pipeline = create(:ci_pipeline, project: environment.project)
+
+ deployable = create(:ci_build, name: "#{environment.name}:deploy",
+ pipeline: pipeline)
+
deployment = create(:deployment,
environment: environment,
project: environment.project,
+ deployable: deployable,
ref: evaluator.ref,
sha: environment.project.commit(evaluator.ref).id)
teardown_build = create(:ci_build, :manual,
- name: "#{deployment.environment.name}:teardown",
- pipeline: deployment.deployable.pipeline)
+ name: "#{environment.name}:teardown",
+ pipeline: pipeline)
deployment.update_column(:on_stop, teardown_build.name)
environment.update_attribute(:deployments, [deployment])
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index 55727d6b62c..ad9f7e2caef 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -1,7 +1,8 @@
FactoryGirl.define do
factory :event do
- project factory: :empty_project
+ project
author factory: :user
+ action Event::JOINED
trait(:created) { action Event::CREATED }
trait(:updated) { action Event::UPDATED }
@@ -20,4 +21,19 @@ FactoryGirl.define do
target factory: :closed_issue
end
end
+
+ factory :push_event, class: PushEvent do
+ project factory: :project_empty_repo
+ author factory: :user
+ action Event::PUSHED
+ end
+
+ factory :push_event_payload do
+ event
+ commit_count 1
+ action :pushed
+ ref_type :branch
+ ref 'master'
+ commit_to '3cdce97ed87c91368561584e7358f4d46e3e173c'
+ end
end
diff --git a/spec/factories/file_uploader.rb b/spec/factories/file_uploader.rb
deleted file mode 100644
index bc74aeecc3b..00000000000
--- a/spec/factories/file_uploader.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-FactoryGirl.define do
- factory :file_uploader do
- project factory: :empty_project
- secret nil
-
- transient do
- fixture { 'rails_sample.jpg' }
- path { File.join(Rails.root, 'spec/fixtures', fixture) }
- file { Rack::Test::UploadedFile.new(path) }
- end
-
- after(:build) do |uploader, evaluator|
- uploader.store!(evaluator.file)
- end
-
- initialize_with do
- new(project, secret)
- end
- end
-end
diff --git a/spec/factories/file_uploaders.rb b/spec/factories/file_uploaders.rb
new file mode 100644
index 00000000000..622571390d2
--- /dev/null
+++ b/spec/factories/file_uploaders.rb
@@ -0,0 +1,22 @@
+FactoryGirl.define do
+ factory :file_uploader do
+ skip_create
+
+ project
+ secret nil
+
+ transient do
+ fixture { 'rails_sample.jpg' }
+ path { File.join(Rails.root, 'spec/fixtures', fixture) }
+ file { Rack::Test::UploadedFile.new(path) }
+ end
+
+ after(:build) do |uploader, evaluator|
+ uploader.store!(evaluator.file)
+ end
+
+ initialize_with do
+ new(project, secret)
+ end
+ end
+end
diff --git a/spec/factories/forked_project_links.rb b/spec/factories/forked_project_links.rb
index b16c1272e68..9b34651a4ae 100644
--- a/spec/factories/forked_project_links.rb
+++ b/spec/factories/forked_project_links.rb
@@ -1,11 +1,15 @@
FactoryGirl.define do
factory :forked_project_link do
- association :forked_to_project, factory: :project
- association :forked_from_project, factory: :project
+ association :forked_to_project, factory: [:project, :repository]
+ association :forked_from_project, factory: [:project, :repository]
after(:create) do |link|
link.forked_from_project.reload
link.forked_to_project.reload
end
+
+ trait :forked_to_empty_project do
+ association :forked_to_project, factory: [:project, :repository]
+ end
end
end
diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb
new file mode 100644
index 00000000000..1258dce8940
--- /dev/null
+++ b/spec/factories/gpg_keys.rb
@@ -0,0 +1,8 @@
+require_relative '../support/gpg_helpers'
+
+FactoryGirl.define do
+ factory :gpg_key do
+ key GpgHelpers::User1.public_key
+ user
+ end
+end
diff --git a/spec/factories/gpg_signature.rb b/spec/factories/gpg_signature.rb
new file mode 100644
index 00000000000..a5aeffbe12d
--- /dev/null
+++ b/spec/factories/gpg_signature.rb
@@ -0,0 +1,11 @@
+require_relative '../support/gpg_helpers'
+
+FactoryGirl.define do
+ factory :gpg_signature do
+ commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) }
+ project
+ gpg_key
+ gpg_key_primary_keyid { gpg_key.primary_keyid }
+ valid_signature true
+ end
+end
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
index 080b2e75ea1..32cbfe28a60 100644
--- a/spec/factories/group_members.rb
+++ b/spec/factories/group_members.rb
@@ -10,5 +10,11 @@ FactoryGirl.define do
trait(:master) { access_level GroupMember::MASTER }
trait(:owner) { access_level GroupMember::OWNER }
trait(:access_request) { requested_at Time.now }
+
+ trait(:invited) do
+ user_id nil
+ invite_token 'xxx'
+ invite_email 'email@email.com'
+ end
end
end
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
index 86f51ffca99..52f76b094a3 100644
--- a/spec/factories/groups.rb
+++ b/spec/factories/groups.rb
@@ -17,6 +17,10 @@ FactoryGirl.define do
visibility_level Gitlab::VisibilityLevel::PRIVATE
end
+ trait :with_avatar do
+ avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ end
+
trait :access_requestable do
request_access_enabled true
end
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index f1fd1fd7f73..7c3b80198f9 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -2,7 +2,7 @@ FactoryGirl.define do
factory :issue do
title { generate(:title) }
author
- project factory: :empty_project
+ project
trait :confidential do
confidential true
@@ -16,12 +16,8 @@ FactoryGirl.define do
state :closed
end
- trait :reopened do
- state :reopened
- end
-
factory :closed_issue, traits: [:closed]
- factory :reopened_issue, traits: [:reopened]
+ factory :reopened_issue, traits: [:opened]
factory :labeled_issue do
transient do
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index 4e140102492..a13b6e3596e 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -1,27 +1,18 @@
+require_relative '../support/helpers/key_generator_helper'
+
FactoryGirl.define do
factory :key do
title
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0= dummy@gitlab.com'
- end
+ key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com' }
- factory :deploy_key, class: 'DeployKey' do
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O96x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaCrzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy05qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz'
- end
- end
+ factory :deploy_key, class: 'DeployKey'
factory :personal_key do
user
end
factory :another_key do
- key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ'
- end
-
- factory :another_deploy_key, class: 'DeployKey' do
- end
+ factory :another_deploy_key, class: 'DeployKey'
end
factory :write_access_key, class: 'DeployKey' do
diff --git a/spec/factories/label_priorities.rb b/spec/factories/label_priorities.rb
index f25939d2d3e..7430466fc57 100644
--- a/spec/factories/label_priorities.rb
+++ b/spec/factories/label_priorities.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :label_priority do
- project factory: :empty_project
+ project
label
sequence(:priority)
end
diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb
index 22c2a1f15e2..416317d677b 100644
--- a/spec/factories/labels.rb
+++ b/spec/factories/labels.rb
@@ -5,7 +5,7 @@ FactoryGirl.define do
end
factory :label, traits: [:base_label], class: ProjectLabel do
- project factory: :empty_project
+ project
transient do
priority nil
diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb
index f6a78811cbe..48142d3c49b 100644
--- a/spec/factories/lists.rb
+++ b/spec/factories/lists.rb
@@ -6,6 +6,12 @@ FactoryGirl.define do
sequence(:position)
end
+ factory :backlog_list, parent: :list do
+ list_type :backlog
+ label nil
+ position nil
+ end
+
factory :closed_list, parent: :list do
list_type :closed
label nil
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 253a025af48..cbec716d6ea 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -44,10 +44,6 @@ FactoryGirl.define do
state :opened
end
- trait :reopened do
- state :reopened
- end
-
trait :locked do
state :locked
end
@@ -72,9 +68,20 @@ FactoryGirl.define do
merge_user author
end
+ after(:build) do |merge_request|
+ target_project = merge_request.target_project
+ source_project = merge_request.source_project
+
+ # Fake `write_ref` if we don't have repository
+ # We have too many existing tests replying on this behaviour
+ unless [target_project, source_project].all?(&:repository_exists?)
+ allow(merge_request).to receive(:write_ref)
+ end
+ end
+
factory :merged_merge_request, traits: [:merged]
factory :closed_merge_request, traits: [:closed]
- factory :reopened_merge_request, traits: [:reopened]
+ factory :reopened_merge_request, traits: [:opened]
factory :merge_request_with_diffs, traits: [:with_diffs]
factory :merge_request_with_diff_notes do
after(:create) do |mr|
diff --git a/spec/factories/milestones.rb b/spec/factories/milestones.rb
index 841ab3c73b8..2f75bf12cd7 100644
--- a/spec/factories/milestones.rb
+++ b/spec/factories/milestones.rb
@@ -1,7 +1,13 @@
FactoryGirl.define do
factory :milestone do
title
- project factory: :empty_project
+
+ transient do
+ project nil
+ group nil
+ project_id nil
+ group_id nil
+ end
trait :active do
state "active"
@@ -11,6 +17,20 @@ FactoryGirl.define do
state "closed"
end
+ after(:build, :stub) do |milestone, evaluator|
+ if evaluator.group
+ milestone.group = evaluator.group
+ elsif evaluator.group_id
+ milestone.group_id = evaluator.group_id
+ elsif evaluator.project
+ milestone.project = evaluator.project
+ elsif evaluator.project_id
+ milestone.project_id = evaluator.project_id
+ else
+ milestone.project = create(:project)
+ end
+ end
+
factory :active_milestone, traits: [:active]
factory :closed_milestone, traits: [:closed]
end
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index 93f4903119c..f0d05504b7e 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -4,8 +4,8 @@ include ActionDispatch::TestProcess
FactoryGirl.define do
factory :note do
- project factory: :empty_project
- note "Note"
+ project
+ note { generate(:title) }
author
on_issue
@@ -29,6 +29,8 @@ FactoryGirl.define do
factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote
+ factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], 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
diff --git a/spec/factories/notification_settings.rb b/spec/factories/notification_settings.rb
index b5e96d18b8f..e9171528d86 100644
--- a/spec/factories/notification_settings.rb
+++ b/spec/factories/notification_settings.rb
@@ -1,8 +1,7 @@
FactoryGirl.define do
factory :notification_setting do
- source factory: :empty_project
+ source factory: :project
user
level 3
- events []
end
end
diff --git a/spec/factories/personal_snippets.rb b/spec/factories/personal_snippets.rb
deleted file mode 100644
index 0f13b2c1020..00000000000
--- a/spec/factories/personal_snippets.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-FactoryGirl.define do
- factory :personal_snippet, parent: :snippet, class: :PersonalSnippet do
- end
-end
diff --git a/spec/factories/project_group_links.rb b/spec/factories/project_group_links.rb
index 50341d943f5..e73cc05f9d7 100644
--- a/spec/factories/project_group_links.rb
+++ b/spec/factories/project_group_links.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :project_group_link do
- project factory: :empty_project
+ project
group
end
end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 39c2a9dd1fb..accae636a3a 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -1,6 +1,8 @@
FactoryGirl.define do
factory :project_hook do
url { generate(:url) }
+ enable_ssl_verification false
+ project
trait :token do
token { SecureRandom.hex(10) }
@@ -11,8 +13,9 @@ FactoryGirl.define do
merge_requests_events true
tag_push_events true
issues_events true
+ confidential_issues_events true
note_events true
- build_events true
+ job_events true
pipeline_events true
wiki_page_events true
end
diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb
index d62799a5a47..9cf3a1e8e8a 100644
--- a/spec/factories/project_members.rb
+++ b/spec/factories/project_members.rb
@@ -1,7 +1,7 @@
FactoryGirl.define do
factory :project_member do
user
- project factory: :empty_project
+ project
master
trait(:guest) { access_level ProjectMember::GUEST }
@@ -9,5 +9,11 @@ FactoryGirl.define do
trait(:developer) { access_level ProjectMember::DEVELOPER }
trait(:master) { access_level ProjectMember::MASTER }
trait(:access_request) { requested_at Time.now }
+
+ trait(:invited) do
+ user_id nil
+ invite_token 'xxx'
+ invite_email 'email@email.com'
+ end
end
end
diff --git a/spec/factories/project_snippets.rb b/spec/factories/project_snippets.rb
deleted file mode 100644
index e0fe1b36fd3..00000000000
--- a/spec/factories/project_snippets.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-FactoryGirl.define do
- factory :project_snippet, parent: :snippet, class: :ProjectSnippet do
- project factory: :empty_project
- end
-end
diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb
index 72d43096216..6c2ed7c6581 100644
--- a/spec/factories/project_statistics.rb
+++ b/spec/factories/project_statistics.rb
@@ -1,6 +1,10 @@
FactoryGirl.define do
factory :project_statistics do
- project { create :project }
- namespace { project.namespace }
+ project
+
+ initialize_with do
+ # statistics are automatically created when a project is created
+ project&.statistics || new
+ end
end
end
diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb
index a3403fd76ae..38fcab7466d 100644
--- a/spec/factories/project_wikis.rb
+++ b/spec/factories/project_wikis.rb
@@ -1,6 +1,8 @@
FactoryGirl.define do
factory :project_wiki do
- project factory: :empty_project
+ skip_create
+
+ project
user factory: :user
initialize_with { new(project, user) }
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 0db2fe04edd..4a2034b31b3 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -1,17 +1,54 @@
+require_relative '../support/test_env'
+
FactoryGirl.define do
# Project without repository
#
# Project does not have bare repository.
# Use this factory if you don't need repository in tests
- factory :empty_project, class: 'Project' do
+ factory :project, class: 'Project' do
sequence(:name) { |n| "project#{n}" }
path { name.downcase.gsub(/\s/, '_') }
- namespace
- creator
-
# Behaves differently to nil due to cache_has_external_issue_tracker
has_external_issue_tracker false
+ # Associations
+ namespace
+ creator { group ? create(:user) : namespace&.owner }
+
+ # Nest Project Feature attributes
+ transient do
+ wiki_access_level ProjectFeature::ENABLED
+ builds_access_level ProjectFeature::ENABLED
+ snippets_access_level ProjectFeature::ENABLED
+ issues_access_level ProjectFeature::ENABLED
+ merge_requests_access_level ProjectFeature::ENABLED
+ repository_access_level ProjectFeature::ENABLED
+ end
+
+ after(:create) do |project, evaluator|
+ # Builds and MRs can't have higher visibility level than repository access level.
+ builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min
+ merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min
+
+ project.project_feature.update_columns(
+ wiki_access_level: evaluator.wiki_access_level,
+ builds_access_level: builds_access_level,
+ snippets_access_level: evaluator.snippets_access_level,
+ issues_access_level: evaluator.issues_access_level,
+ merge_requests_access_level: merge_requests_access_level,
+ repository_access_level: evaluator.repository_access_level)
+
+ # Normally the class Projects::CreateService is used for creating
+ # projects, and this class takes care of making sure the owner and current
+ # user have access to the project. Our specs don't use said service class,
+ # thus we must manually refresh things here.
+ unless project.group || project.pending_delete
+ project.add_master(project.owner)
+ end
+
+ project.group&.refresh_members_authorized_projects
+ end
+
trait :public do
visibility_level Gitlab::VisibilityLevel::PUBLIC
end
@@ -24,6 +61,22 @@ FactoryGirl.define do
visibility_level Gitlab::VisibilityLevel::PRIVATE
end
+ trait :import_scheduled do
+ import_status :scheduled
+ end
+
+ trait :import_started do
+ import_status :started
+ end
+
+ trait :import_finished do
+ import_status :finished
+ end
+
+ trait :import_failed do
+ import_status :failed
+ end
+
trait :archived do
archived true
end
@@ -32,8 +85,50 @@ FactoryGirl.define do
request_access_enabled true
end
+ trait :with_avatar do
+ avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ end
+
+ trait :broken_storage do
+ after(:create) do |project|
+ project.update_column(:repository_storage, 'broken')
+ end
+ end
+
+ # Test repository - https://gitlab.com/gitlab-org/gitlab-test
trait :repository do
- # no-op... for now!
+ path { 'gitlabhq' }
+
+ test_repo
+
+ transient do
+ create_templates nil
+ end
+
+ after :create do |project, evaluator|
+ if evaluator.create_templates
+ templates_path = "#{evaluator.create_templates}_templates"
+
+ project.repository.create_file(
+ project.creator,
+ ".gitlab/#{templates_path}/bug.md",
+ 'something valid',
+ message: 'test 3',
+ branch_name: 'master')
+ project.repository.create_file(
+ project.creator,
+ ".gitlab/#{templates_path}/template_test.md",
+ 'template_test',
+ message: 'test 1',
+ branch_name: 'master')
+ project.repository.create_file(
+ project.creator,
+ ".gitlab/#{templates_path}/feature_proposal.md",
+ 'feature_proposal',
+ message: 'test 2',
+ branch_name: 'master')
+ end
+ end
end
trait :empty_repo do
@@ -42,7 +137,7 @@ FactoryGirl.define do
# We delete hooks so that gitlab-shell will not try to authenticate with
# an API that isn't running
- FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.path_with_namespace}.git", 'hooks'))
+ FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.disk_path}.git", 'hooks'))
end
end
@@ -50,13 +145,15 @@ FactoryGirl.define do
after(:create) do |project|
raise "Failed to create repository!" unless project.create_repository
- FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.path_with_namespace}.git", 'refs'))
+ FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.disk_path}.git", 'refs'))
end
end
trait :test_repo do
after :create do |project|
- TestEnv.copy_repo(project)
+ TestEnv.copy_repo(project,
+ bare_repo: TestEnv.factory_repo_path_bare,
+ refs: TestEnv::BRANCH_SHA)
end
end
@@ -78,97 +175,30 @@ FactoryGirl.define do
trait(:repository_enabled) { repository_access_level ProjectFeature::ENABLED }
trait(:repository_disabled) { repository_access_level ProjectFeature::DISABLED }
trait(:repository_private) { repository_access_level ProjectFeature::PRIVATE }
-
- # Nest Project Feature attributes
- transient do
- wiki_access_level ProjectFeature::ENABLED
- builds_access_level ProjectFeature::ENABLED
- snippets_access_level ProjectFeature::ENABLED
- issues_access_level ProjectFeature::ENABLED
- merge_requests_access_level ProjectFeature::ENABLED
- repository_access_level ProjectFeature::ENABLED
- end
-
- after(:create) do |project, evaluator|
- # Builds and MRs can't have higher visibility level than repository access level.
- builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min
- merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min
-
- project.project_feature.
- update_attributes!(
- wiki_access_level: evaluator.wiki_access_level,
- builds_access_level: builds_access_level,
- snippets_access_level: evaluator.snippets_access_level,
- issues_access_level: evaluator.issues_access_level,
- merge_requests_access_level: merge_requests_access_level,
- repository_access_level: evaluator.repository_access_level
- )
- end
end
# Project with empty repository
#
# This is a case when you just created a project
# but not pushed any code there yet
- factory :project_empty_repo, parent: :empty_project do
+ factory :project_empty_repo, parent: :project do
empty_repo
end
# Project with broken repository
#
# Project with an invalid repository state
- factory :project_broken_repo, parent: :empty_project do
+ factory :project_broken_repo, parent: :project do
broken_repo
end
- # Project with test repository
- #
- # Test repository source can be found at
- # https://gitlab.com/gitlab-org/gitlab-test
- factory :project, parent: :empty_project do
- path { 'gitlabhq' }
-
- test_repo
-
- transient do
- create_template nil
- end
-
- after :create do |project, evaluator|
- TestEnv.copy_repo(project)
-
- if evaluator.create_template
- args = evaluator.create_template
-
- project.add_user(args[:user], args[:access])
-
- project.repository.create_file(
- args[:user],
- ".gitlab/#{args[:path]}/bug.md",
- 'something valid',
- message: 'test 3',
- branch_name: 'master')
- project.repository.create_file(
- args[:user],
- ".gitlab/#{args[:path]}/template_test.md",
- 'template_test',
- message: 'test 1',
- branch_name: 'master')
- project.repository.create_file(
- args[:user],
- ".gitlab/#{args[:path]}/feature_proposal.md",
- 'feature_proposal',
- message: 'test 2',
- branch_name: 'master')
- end
- end
- end
-
- factory :forked_project_with_submodules, parent: :empty_project do
+ factory :forked_project_with_submodules, parent: :project do
path { 'forked-gitlabhq' }
after :create do |project|
- TestEnv.copy_forked_repo_with_submodules(project)
+ TestEnv.copy_repo(project,
+ bare_repo: TestEnv.forked_repo_path_bare,
+ refs: TestEnv::FORKED_BRANCH_SHA)
end
end
@@ -180,7 +210,7 @@ FactoryGirl.define do
active: true,
properties: {
'project_url' => 'http://redmine/projects/project_name_in_redmine',
- 'issues_url' => "http://redmine/#{project.id}/project_name_in_redmine/:id",
+ 'issues_url' => 'http://redmine/projects/project_name_in_redmine/issues/:id',
'new_issue_url' => 'http://redmine/projects/project_name_in_redmine/issues/new'
}
)
@@ -192,11 +222,11 @@ FactoryGirl.define do
jira_service
end
- factory :kubernetes_project, parent: :empty_project do
+ factory :kubernetes_project, parent: :project do
kubernetes_service
end
- factory :prometheus_project, parent: :empty_project do
+ factory :prometheus_project, parent: :project do
after :create do |project|
project.create_prometheus_service(
active: true,
diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb
index b2695e0482a..fe0cbfc4444 100644
--- a/spec/factories/protected_branches.rb
+++ b/spec/factories/protected_branches.rb
@@ -3,26 +3,64 @@ FactoryGirl.define do
name
project
- after(:build) do |protected_branch|
- protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER)
- protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER)
+ transient do
+ default_push_level true
+ default_merge_level true
+ default_access_level true
end
trait :developers_can_push do
- after(:create) do |protected_branch|
- protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
+ transient do
+ default_push_level false
+ end
+
+ after(:build) do |protected_branch|
+ protected_branch.push_access_levels.new(access_level: Gitlab::Access::DEVELOPER)
end
end
trait :developers_can_merge do
- after(:create) do |protected_branch|
- protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
+ transient do
+ default_merge_level false
+ end
+
+ after(:build) do |protected_branch|
+ protected_branch.merge_access_levels.new(access_level: Gitlab::Access::DEVELOPER)
end
end
trait :no_one_can_push do
+ transient do
+ default_push_level false
+ end
+
+ after(:build) do |protected_branch|
+ protected_branch.push_access_levels.new(access_level: Gitlab::Access::NO_ACCESS)
+ end
+ end
+
+ trait :masters_can_push do
+ transient do
+ default_push_level false
+ end
+
+ after(:build) do |protected_branch|
+ protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER)
+ end
+ end
+
+ after(:build) do |protected_branch, evaluator|
+ if evaluator.default_access_level && evaluator.default_push_level
+ protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER)
+ end
+ if evaluator.default_access_level && evaluator.default_merge_level
+ protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER)
+ end
+ end
+
+ trait :no_one_can_merge do
after(:create) do |protected_branch|
- protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS)
+ protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS)
end
end
end
diff --git a/spec/factories/protected_tags.rb b/spec/factories/protected_tags.rb
index d8e90ae1ee1..225588b23cc 100644
--- a/spec/factories/protected_tags.rb
+++ b/spec/factories/protected_tags.rb
@@ -3,19 +3,43 @@ FactoryGirl.define do
name
project
- after(:build) do |protected_tag|
- protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
+ transient do
+ default_access_level true
end
trait :developers_can_create do
- after(:create) do |protected_tag|
- protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
+ transient do
+ default_access_level false
+ end
+
+ after(:build) do |protected_tag|
+ protected_tag.create_access_levels.new(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)
+ transient do
+ default_access_level false
+ end
+
+ after(:build) do |protected_tag|
+ protected_tag.create_access_levels.new(access_level: Gitlab::Access::NO_ACCESS)
+ end
+ end
+
+ trait :masters_can_create do
+ transient do
+ default_access_level false
+ end
+
+ after(:build) do |protected_tag|
+ protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
+ end
+ end
+
+ after(:build) do |protected_tag, evaluator|
+ if evaluator.default_access_level
+ protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
end
end
end
diff --git a/spec/factories/releases.rb b/spec/factories/releases.rb
index 6a6d6fa171f..74497dc82c0 100644
--- a/spec/factories/releases.rb
+++ b/spec/factories/releases.rb
@@ -2,6 +2,6 @@ FactoryGirl.define do
factory :release do
tag "v1.1.0"
description "Awesome release"
- project factory: :empty_project
+ project
end
end
diff --git a/spec/factories/sent_notifications.rb b/spec/factories/sent_notifications.rb
index 99253be5a22..c2febf5b438 100644
--- a/spec/factories/sent_notifications.rb
+++ b/spec/factories/sent_notifications.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :sent_notification do
- project factory: :empty_project
+ project
recipient factory: :user
noteable { create(:issue, project: project) }
reply_key { SentNotification.reply_key }
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index 62aa71ae8d8..c2674ce2d11 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -1,11 +1,11 @@
FactoryGirl.define do
factory :service do
- project factory: :empty_project
+ project
type 'Service'
end
factory :custom_issue_tracker_service, class: CustomIssueTrackerService do
- project factory: :empty_project
+ project
type 'CustomIssueTrackerService'
category 'issue_tracker'
active true
@@ -17,21 +17,34 @@ FactoryGirl.define do
end
factory :kubernetes_service do
- project factory: :empty_project
+ project
active true
properties({
- namespace: 'somepath',
api_url: 'https://kubernetes.example.com',
- token: 'a' * 40,
+ token: 'a' * 40
+ })
+ end
+
+ factory :prometheus_service do
+ project
+ active true
+ properties({
+ api_url: 'https://prometheus.example.com/'
})
end
factory :jira_service do
- project factory: :empty_project
+ project
active true
properties(
url: 'https://jira.example.com',
project_key: 'jira-key'
)
end
+
+ factory :hipchat_service do
+ project
+ type 'HipchatService'
+ token 'test_token'
+ end
end
diff --git a/spec/factories/snippets.rb b/spec/factories/snippets.rb
index 18cb0f5de26..075bccd7f94 100644
--- a/spec/factories/snippets.rb
+++ b/spec/factories/snippets.rb
@@ -3,6 +3,7 @@ FactoryGirl.define do
author
title { generate(:title) }
content { generate(:title) }
+ description { generate(:title) }
file_name { generate(:filename) }
trait :public do
@@ -17,4 +18,11 @@ FactoryGirl.define do
visibility_level Snippet::PRIVATE
end
end
+
+ factory :project_snippet, parent: :snippet, class: :ProjectSnippet do
+ project
+ end
+
+ factory :personal_snippet, parent: :snippet, class: :PersonalSnippet do
+ end
end
diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb
index b11b0a0a17b..1ae7fc9f384 100644
--- a/spec/factories/subscriptions.rb
+++ b/spec/factories/subscriptions.rb
@@ -1,7 +1,7 @@
FactoryGirl.define do
factory :subscription do
user
- project factory: :empty_project
+ project
subscribable factory: :issue
end
end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index c1ac3bb84ad..4975befbfe3 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -1,6 +1,6 @@
FactoryGirl.define do
factory :todo do
- project factory: :empty_project
+ project
author
user
target factory: :issue
@@ -45,7 +45,7 @@ FactoryGirl.define do
end
factory :on_commit_todo, class: Todo do
- project factory: :empty_project
+ project
author
user
action { Todo::ASSIGNED }
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
new file mode 100644
index 00000000000..3222c41c3d8
--- /dev/null
+++ b/spec/factories/uploads.rb
@@ -0,0 +1,8 @@
+FactoryGirl.define do
+ factory :upload do
+ model { build(:project) }
+ path { "uploads/-/system/project/avatar/avatar.jpg" }
+ size 100.kilobytes
+ uploader "AvatarUploader"
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index e1ae94a08e4..4000cd085b7 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -1,5 +1,5 @@
FactoryGirl.define do
- factory :user, aliases: [:author, :assignee, :recipient, :owner, :creator, :resource_owner] do
+ factory :user, aliases: [:author, :assignee, :recipient, :owner, :resource_owner] do
email { generate(:email) }
name { generate(:name) }
username { generate(:username) }
@@ -8,6 +8,14 @@ FactoryGirl.define do
confirmation_token { nil }
can_create_group true
+ after(:stub) do |user|
+ user.notification_email = user.email
+ end
+
+ before(:create) do |user|
+ user.ensure_rss_token
+ end
+
trait :admin do
admin true
end
@@ -29,6 +37,10 @@ FactoryGirl.define do
after(:build) { |user, _| user.block! }
end
+ trait :with_avatar do
+ avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ end
+
trait :two_factor_via_otp do
before(:create) do |user|
user.otp_required_for_login = true
diff --git a/spec/factories/web_hook_log.rb b/spec/factories/web_hook_log.rb
new file mode 100644
index 00000000000..230b3f6b26e
--- /dev/null
+++ b/spec/factories/web_hook_log.rb
@@ -0,0 +1,14 @@
+FactoryGirl.define do
+ factory :web_hook_log do
+ web_hook factory: :project_hook
+ trigger 'push_hooks'
+ url { generate(:url) }
+ request_headers {}
+ request_data {}
+ response_headers {}
+ response_body ''
+ response_status '200'
+ execution_duration 2.0
+ internal_error_message nil
+ end
+end
diff --git a/spec/factories/wiki_directories.rb b/spec/factories/wiki_directories.rb
index 3f3c864ac2b..3b4cfc380b8 100644
--- a/spec/factories/wiki_directories.rb
+++ b/spec/factories/wiki_directories.rb
@@ -1,5 +1,7 @@
FactoryGirl.define do
factory :wiki_directory do
+ skip_create
+
slug '/path_up_to/dir'
initialize_with { new(slug) }
end
diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb
index 786e1456f5f..09b3c0b0994 100644
--- a/spec/factories_spec.rb
+++ b/spec/factories_spec.rb
@@ -3,14 +3,20 @@ require 'spec_helper'
describe 'factories' do
FactoryGirl.factories.each do |factory|
describe "#{factory.name} factory" do
- let(:entity) { build(factory.name) }
+ it 'does not raise error when built' do
+ expect { build(factory.name) }.not_to raise_error
+ end
it 'does not raise error when created' do
- expect { entity }.not_to raise_error
+ expect { create(factory.name) }.not_to raise_error
end
- it 'is valid', if: factory.build_class < ActiveRecord::Base do
- expect(entity).to be_valid
+ factory.definition.defined_traits.map(&:name).each do |trait_name|
+ describe "linting #{trait_name} trait" do
+ skip 'does not raise error when created' do
+ expect { create(factory.name, trait_name) }.not_to raise_error
+ end
+ end
end
end
end
diff --git a/spec/features/abuse_report_spec.rb b/spec/features/abuse_report_spec.rb
index 1e11fb756b2..091fdcec3db 100644
--- a/spec/features/abuse_report_spec.rb
+++ b/spec/features/abuse_report_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Abuse reports', feature: true do
+feature 'Abuse reports' do
let(:another_user) { create(:user) }
before do
- login_as :user
+ sign_in(create(:user))
end
scenario 'Report abuse' do
@@ -12,7 +12,7 @@ feature 'Abuse reports', feature: true do
click_link 'Report abuse'
- fill_in 'abuse_report_message', with: 'This user send spam'
+ fill_in 'abuse_report_message', with: 'This user sends spam'
click_button 'Send report'
expect(page).to have_content 'Thank you for your report'
diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb
index 340884fc986..2144f6ba635 100644
--- a/spec/features/admin/admin_abuse_reports_spec.rb
+++ b/spec/features/admin/admin_abuse_reports_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe "Admin::AbuseReports", feature: true, js: true do
+describe "Admin::AbuseReports", js: true do
let(:user) { create(:user) }
context 'as an admin' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
describe 'if a user has been reported for abuse' do
diff --git a/spec/features/admin/admin_active_tab_spec.rb b/spec/features/admin/admin_active_tab_spec.rb
index 16064d60ce2..07430ecd6e0 100644
--- a/spec/features/admin/admin_active_tab_spec.rb
+++ b/spec/features/admin/admin_active_tab_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
RSpec.describe 'admin active tab' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
shared_examples 'page has active tab' do |title|
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index 96d715ef383..5f3a37c1dcc 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Admin Appearance', feature: true do
+feature 'Admin Appearance' do
let!(:appearance) { create(:appearance) }
scenario 'Create new appearance' do
- login_as :admin
+ sign_in(create(:admin))
visit admin_appearances_path
fill_in 'appearance_title', with: 'MyCompany'
@@ -20,7 +20,7 @@ feature 'Admin Appearance', feature: true do
end
scenario 'Preview appearance' do
- login_as :admin
+ sign_in(create(:admin))
visit admin_appearances_path
click_link "Preview"
@@ -34,7 +34,7 @@ feature 'Admin Appearance', feature: true do
end
scenario 'Appearance logo' do
- login_as :admin
+ sign_in(create(:admin))
visit admin_appearances_path
attach_file(:appearance_logo, logo_fixture)
@@ -46,7 +46,7 @@ feature 'Admin Appearance', feature: true do
end
scenario 'Header logos' do
- login_as :admin
+ sign_in(create(:admin))
visit admin_appearances_path
attach_file(:appearance_header_logo, logo_fixture)
@@ -63,11 +63,11 @@ feature 'Admin Appearance', feature: true do
end
def logo_selector
- '//img[@src^="/uploads/appearance/logo"]'
+ '//img[data-src^="/uploads/-/system/appearance/logo"]'
end
def header_logo_selector
- '//img[@src^="/uploads/appearance/header_logo"]'
+ '//img[data-src^="/uploads/-/system/appearance/header_logo"]'
end
def logo_fixture
diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb
index d6c63f66a9b..cbccf370514 100644
--- a/spec/features/admin/admin_broadcast_messages_spec.rb
+++ b/spec/features/admin/admin_broadcast_messages_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Admin Broadcast Messages', feature: true do
+feature 'Admin Broadcast Messages' do
before do
- login_as :admin
+ sign_in(create(:admin))
create(:broadcast_message, :expired, message: 'Migration to new server')
visit admin_broadcast_messages_path
end
diff --git a/spec/features/admin/admin_browse_spam_logs_spec.rb b/spec/features/admin/admin_browse_spam_logs_spec.rb
index bee57472270..31d4142a8e9 100644
--- a/spec/features/admin/admin_browse_spam_logs_spec.rb
+++ b/spec/features/admin/admin_browse_spam_logs_spec.rb
@@ -4,7 +4,7 @@ describe 'Admin browse spam logs' do
let!(:spam_log) { create(:spam_log, description: 'abcde ' * 20) }
before do
- login_as :admin
+ sign_in(create(:admin))
end
scenario 'Browse spam logs' do
diff --git a/spec/features/admin/admin_browses_logs_spec.rb b/spec/features/admin/admin_browses_logs_spec.rb
index d880f3f07db..3e3404dfdac 100644
--- a/spec/features/admin/admin_browses_logs_spec.rb
+++ b/spec/features/admin/admin_browses_logs_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'Admin browses logs' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
it 'shows available log files' do
diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb
index 9d5ce876c29..e020579f71e 100644
--- a/spec/features/admin/admin_builds_spec.rb
+++ b/spec/features/admin/admin_builds_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'Admin Builds' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
describe 'GET /admin/builds' do
@@ -16,7 +16,7 @@ describe 'Admin Builds' do
create(:ci_build, pipeline: pipeline, status: :success)
create(:ci_build, pipeline: pipeline, status: :failed)
- visit admin_builds_path
+ visit admin_jobs_path
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_selector('.row-content-block', text: 'All jobs')
@@ -27,7 +27,7 @@ describe 'Admin Builds' do
context 'when have no jobs' do
it 'shows a message' do
- visit admin_builds_path
+ visit admin_jobs_path
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_content 'No jobs to show'
@@ -44,7 +44,7 @@ describe 'Admin Builds' do
build3 = create(:ci_build, pipeline: pipeline, status: :success)
build4 = create(:ci_build, pipeline: pipeline, status: :failed)
- visit admin_builds_path(scope: :pending)
+ visit admin_jobs_path(scope: :pending)
expect(page).to have_selector('.nav-links li.active', text: 'Pending')
expect(page.find('.build-link')).to have_content(build1.id)
@@ -59,7 +59,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :success)
- visit admin_builds_path(scope: :pending)
+ visit admin_jobs_path(scope: :pending)
expect(page).to have_selector('.nav-links li.active', text: 'Pending')
expect(page).to have_content 'No jobs to show'
@@ -76,7 +76,7 @@ describe 'Admin Builds' do
build3 = create(:ci_build, pipeline: pipeline, status: :failed)
build4 = create(:ci_build, pipeline: pipeline, status: :pending)
- visit admin_builds_path(scope: :running)
+ visit admin_jobs_path(scope: :running)
expect(page).to have_selector('.nav-links li.active', text: 'Running')
expect(page.find('.build-link')).to have_content(build1.id)
@@ -91,7 +91,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :success)
- visit admin_builds_path(scope: :running)
+ visit admin_jobs_path(scope: :running)
expect(page).to have_selector('.nav-links li.active', text: 'Running')
expect(page).to have_content 'No jobs to show'
@@ -107,7 +107,7 @@ describe 'Admin Builds' do
build2 = create(:ci_build, pipeline: pipeline, status: :running)
build3 = create(:ci_build, pipeline: pipeline, status: :success)
- visit admin_builds_path(scope: :finished)
+ visit admin_jobs_path(scope: :finished)
expect(page).to have_selector('.nav-links li.active', text: 'Finished')
expect(page.find('.build-link')).not_to have_content(build1.id)
@@ -121,7 +121,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :running)
- visit admin_builds_path(scope: :finished)
+ visit admin_jobs_path(scope: :finished)
expect(page).to have_selector('.nav-links li.active', text: 'Finished')
expect(page).to have_content 'No jobs to show'
diff --git a/spec/features/admin/admin_cohorts_spec.rb b/spec/features/admin/admin_cohorts_spec.rb
index dd14ffdb2ce..bca52bf674c 100644
--- a/spec/features/admin/admin_cohorts_spec.rb
+++ b/spec/features/admin/admin_cohorts_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
-feature 'Admin cohorts page', feature: true do
+feature 'Admin cohorts page' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
scenario 'See users count per month' do
diff --git a/spec/features/admin/admin_conversational_development_index_spec.rb b/spec/features/admin/admin_conversational_development_index_spec.rb
new file mode 100644
index 00000000000..2d2c7df5364
--- /dev/null
+++ b/spec/features/admin/admin_conversational_development_index_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe 'Admin Conversational Development Index' do
+ before do
+ sign_in(create(:admin))
+ end
+
+ context 'when usage ping is disabled' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: false)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Usage ping is not enabled')
+ end
+ end
+
+ context 'when there is no data to display' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Data is still calculating')
+ end
+ end
+
+ context 'when there is data to display' do
+ it 'shows numbers for each metric' do
+ stub_application_setting(usage_ping_enabled: true)
+ create(:conversational_development_index_metric)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content(
+ 'Issues created per active user 1.2 You 9.3 Lead 13.3%'
+ )
+ end
+ end
+end
diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb
index c0b6995a84a..241c7cbc34e 100644
--- a/spec/features/admin/admin_deploy_keys_spec.rb
+++ b/spec/features/admin/admin_deploy_keys_spec.rb
@@ -1,50 +1,77 @@
require 'spec_helper'
-RSpec.describe 'admin deploy keys', type: :feature do
+RSpec.describe 'admin deploy keys' do
let!(:deploy_key) { create(:deploy_key, public: true) }
let!(:another_deploy_key) { create(:another_deploy_key, public: true) }
before do
- login_as(:admin)
+ sign_in(create(:admin))
end
it 'show all public deploy keys' do
visit admin_deploy_keys_path
- expect(page).to have_content(deploy_key.title)
- expect(page).to have_content(another_deploy_key.title)
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content(deploy_key.title)
+ expect(page).to have_content(another_deploy_key.title)
+ end
end
- describe 'create new deploy key' do
+ describe 'create a new deploy key' do
+ let(:new_ssh_key) { attributes_for(:key)[:key] }
+
before do
visit admin_deploy_keys_path
click_link 'New deploy key'
end
- it 'creates new deploy key' do
- fill_deploy_key
+ it 'creates a new deploy key' do
+ fill_in 'deploy_key_title', with: 'laptop'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ check 'deploy_key_can_push'
click_button 'Create'
- expect_renders_new_key
- end
+ expect(current_path).to eq admin_deploy_keys_path
- it 'creates new deploy key with write access' do
- fill_deploy_key
- check "deploy_key_can_push"
- click_button "Create"
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content('laptop')
+ expect(page).to have_content('Yes')
+ end
+ end
+ end
- expect_renders_new_key
- expect(page).to have_content('Yes')
+ describe 'update an existing deploy key' do
+ before do
+ visit admin_deploy_keys_path
+ find('tr', text: deploy_key.title).click_link('Edit')
end
- def expect_renders_new_key
+ it 'updates an existing deploy key' do
+ fill_in 'deploy_key_title', with: 'new-title'
+ check 'deploy_key_can_push'
+ click_button 'Save changes'
+
expect(current_path).to eq admin_deploy_keys_path
- expect(page).to have_content('laptop')
+
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content('new-title')
+ expect(page).to have_content('Yes')
+ end
end
+ end
- def fill_deploy_key
- fill_in 'deploy_key_title', with: 'laptop'
- fill_in 'deploy_key_key', with: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop'
+ describe 'remove an existing deploy key' do
+ before do
+ visit admin_deploy_keys_path
+ end
+
+ it 'removes an existing deploy key' do
+ find('tr', text: deploy_key.title).click_link('Remove')
+
+ expect(current_path).to eq admin_deploy_keys_path
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).not_to have_content(deploy_key.title)
+ end
end
end
end
diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb
index 273cacd82cd..9ea3cfa72c6 100644
--- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb
+++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-feature 'Admin disables Git access protocol', feature: true do
+feature 'Admin disables Git access protocol' do
include StubENV
- let(:project) { create(:empty_project, :empty_repo) }
+ let(:project) { create(:project, :empty_repo) }
let(:admin) { create(:admin) }
background do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- login_as(admin)
+ sign_in(admin)
end
context 'with HTTP disabled' do
@@ -32,7 +32,7 @@ feature 'Admin disables Git access protocol', feature: true do
scenario 'shows only HTTP url' do
visit_project
- expect(page).to have_content("git clone #{project.http_url_to_repo(admin)}")
+ expect(page).to have_content("git clone #{project.http_url_to_repo}")
expect(page).not_to have_selector('#clone-dropdown')
end
end
@@ -51,7 +51,7 @@ feature 'Admin disables Git access protocol', feature: true do
end
def visit_project
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
def disable_http_protocol
diff --git a/spec/features/admin/admin_disables_two_factor_spec.rb b/spec/features/admin/admin_disables_two_factor_spec.rb
index 71be66303d2..e214ae6b78d 100644
--- a/spec/features/admin/admin_disables_two_factor_spec.rb
+++ b/spec/features/admin/admin_disables_two_factor_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
-feature 'Admin disables 2FA for a user', feature: true do
+feature 'Admin disables 2FA for a user' do
scenario 'successfully', js: true do
- login_as(:admin)
+ sign_in(create(:admin))
user = create(:user, :two_factor)
edit_user(user)
@@ -17,7 +17,7 @@ feature 'Admin disables 2FA for a user', feature: true do
end
scenario 'for a user without 2FA enabled' do
- login_as(:admin)
+ sign_in(create(:admin))
user = create(:user)
edit_user(user)
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index d5f595894d6..3768727d8ae 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -1,14 +1,15 @@
require 'spec_helper'
-feature 'Admin Groups', feature: true do
+feature 'Admin Groups' do
include Select2Helper
let(:internal) { Gitlab::VisibilityLevel::INTERNAL }
let(:user) { create :user }
let!(:group) { create :group }
- let!(:current_user) { login_as :admin }
+ let!(:current_user) { create(:admin) }
before do
+ sign_in(current_user)
stub_application_setting(default_group_visibility: internal)
end
@@ -24,7 +25,9 @@ feature 'Admin Groups', feature: true do
it 'creates new group' do
visit admin_groups_path
- click_link "New group"
+ page.within '#content-body' do
+ click_link "New group"
+ end
path_component = 'gitlab'
group_name = 'GitLab group name'
group_description = 'Description of group for GitLab'
@@ -162,7 +165,7 @@ feature 'Admin Groups', feature: true do
describe 'shared projects' do
it 'renders shared project' do
- empty_project = create(:empty_project)
+ empty_project = create(:project)
empty_project.project_group_links.create!(
group_access: Gitlab::Access::MASTER,
group: group
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index 523afa2318f..37fd3e171eb 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature "Admin Health Check", feature: true do
+feature "Admin Health Check", feature: true, broken_storage: true do
include StubENV
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- login_as :admin
+ sign_in(create(:admin))
end
describe '#show' do
@@ -55,4 +55,26 @@ feature "Admin Health Check", feature: true do
expect(page).to have_content('The server is on fire')
end
end
+
+ context 'with repository storage failures' do
+ before do
+ # Track a failure
+ Gitlab::Git::Storage::CircuitBreaker.for_storage('broken').perform { nil } rescue nil
+ visit admin_health_check_path
+ end
+
+ it 'shows storage failure information' do
+ hostname = Gitlab::Environment.hostname
+
+ expect(page).to have_content('broken: failed storage access attempt on host:')
+ expect(page).to have_content("#{hostname}: 1 of 10 failures.")
+ end
+
+ it 'allows resetting storage failures' do
+ click_button 'Reset git storage health information'
+
+ expect(page).to have_content('Git storage health information has been reset')
+ expect(page).not_to have_content('failed storage access attempt')
+ end
+ end
end
diff --git a/spec/features/admin/admin_hook_logs_spec.rb b/spec/features/admin/admin_hook_logs_spec.rb
new file mode 100644
index 00000000000..710822ac042
--- /dev/null
+++ b/spec/features/admin/admin_hook_logs_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+feature 'Admin::HookLogs' do
+ let(:project) { create(:project) }
+ let(:system_hook) { create(:system_hook) }
+ let(:hook_log) { create(:web_hook_log, web_hook: system_hook, internal_error_message: 'some error') }
+
+ before do
+ sign_in(create(:admin))
+ end
+
+ scenario 'show list of hook logs' do
+ hook_log
+ visit edit_admin_hook_path(system_hook)
+
+ expect(page).to have_content('Recent Deliveries')
+ expect(page).to have_content(hook_log.url)
+ end
+
+ scenario 'show hook log details' do
+ hook_log
+ visit edit_admin_hook_path(system_hook)
+ click_link 'View details'
+
+ expect(page).to have_content("POST #{hook_log.url}")
+ expect(page).to have_content(hook_log.internal_error_message)
+ expect(page).to have_content('Resend Request')
+ end
+
+ scenario 'retry hook log' do
+ WebMock.stub_request(:post, system_hook.url)
+
+ hook_log
+ visit edit_admin_hook_path(system_hook)
+ click_link 'View details'
+ click_link 'Resend Request'
+
+ expect(current_path).to eq(edit_admin_hook_path(system_hook))
+ end
+end
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index fb519a9bf12..30fcb334b60 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -1,31 +1,31 @@
require 'spec_helper'
-describe "Admin::Hooks", feature: true do
+describe 'Admin::Hooks' do
before do
@project = create(:project)
- login_as :admin
+ sign_in(create(:admin))
@system_hook = create(:system_hook)
end
- describe "GET /admin/hooks" do
- it "is ok" do
+ describe 'GET /admin/hooks' do
+ it 'is ok' do
visit admin_root_path
- page.within ".layout-nav" do
- click_on "Hooks"
+ page.within '.layout-nav' do
+ click_on 'Hooks'
end
expect(current_path).to eq(admin_hooks_path)
end
- it "has hooks list" do
+ it 'has hooks list' do
visit admin_hooks_path
expect(page).to have_content(@system_hook.url)
end
end
- describe "New Hook" do
+ describe 'New Hook' do
let(:url) { generate(:url) }
it 'adds new hook' do
@@ -40,11 +40,47 @@ describe "Admin::Hooks", feature: true do
end
end
- describe "Test" do
+ describe 'Update existing hook' do
+ let(:new_url) { generate(:url) }
+
+ it 'updates existing hook' do
+ visit admin_hooks_path
+
+ click_link 'Edit'
+ fill_in 'hook_url', with: new_url
+ check 'Enable SSL verification'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'SSL Verification: enabled'
+ expect(current_path).to eq(admin_hooks_path)
+ expect(page).to have_content(new_url)
+ end
+ end
+
+ describe 'Remove existing hook' do
+ context 'removes existing hook' do
+ it 'from hooks list page' do
+ visit admin_hooks_path
+
+ expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1)
+ end
+
+ it 'from hook edit page' do
+ visit admin_hooks_path
+ click_link 'Edit'
+
+ expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1)
+ end
+ end
+ end
+
+ describe 'Test', js: true do
before do
WebMock.stub_request(:post, @system_hook.url)
visit admin_hooks_path
- click_link "Test hook"
+
+ find('.hook-test-button.dropdown').click
+ click_link 'Push events'
end
it { expect(current_path).to eq(admin_hooks_path) }
diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb
index fa3d9ee25c0..ae9b47299e6 100644
--- a/spec/features/admin/admin_labels_spec.rb
+++ b/spec/features/admin/admin_labels_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe 'admin issues labels' do
let!(:feature_label) { Label.create(title: 'feature', template: true) }
before do
- login_as :admin
+ sign_in(create(:admin))
end
describe 'list' do
@@ -34,11 +34,11 @@ RSpec.describe 'admin issues labels' do
page.within '.labels' do
page.all('.btn-remove').each do |remove|
remove.click
- wait_for_ajax
+ wait_for_requests
end
end
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content("There are no labels yet")
expect(page).not_to have_content('bug')
diff --git a/spec/features/admin/admin_manage_applications_spec.rb b/spec/features/admin/admin_manage_applications_spec.rb
index 0079125889b..f979d2f6090 100644
--- a/spec/features/admin/admin_manage_applications_spec.rb
+++ b/spec/features/admin/admin_manage_applications_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-RSpec.describe 'admin manage applications', feature: true do
+RSpec.describe 'admin manage applications' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
it do
@@ -13,19 +13,24 @@ RSpec.describe 'admin manage applications', feature: true do
fill_in :doorkeeper_application_name, with: 'test'
fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com'
+ check :doorkeeper_application_trusted
click_on 'Submit'
expect(page).to have_content('Application: test')
expect(page).to have_content('Application Id')
expect(page).to have_content('Secret')
+ expect(page).to have_content('Trusted Y')
click_on 'Edit'
expect(page).to have_content('Edit application')
fill_in :doorkeeper_application_name, with: 'test_changed'
+ uncheck :doorkeeper_application_trusted
+
click_on 'Submit'
expect(page).to have_content('test_changed')
expect(page).to have_content('Application Id')
expect(page).to have_content('Secret')
+ expect(page).to have_content('Trusted N')
visit admin_applications_path
page.within '.oauth-applications' do
diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb
index 9d205104ebe..77710f80036 100644
--- a/spec/features/admin/admin_projects_spec.rb
+++ b/spec/features/admin/admin_projects_spec.rb
@@ -1,18 +1,21 @@
require 'spec_helper'
-describe "Admin::Projects", feature: true do
+describe "Admin::Projects" do
include Select2Helper
let(:user) { create :user }
- let!(:project) { create(:project) }
- let!(:current_user) do
- login_as :admin
+ let(:project) { create(:project) }
+ let(:current_user) { create(:admin) }
+
+ before do
+ sign_in(current_user)
end
describe "GET /admin/projects" do
let!(:archived_project) { create :project, :public, :archived }
before do
+ expect(project).to be_persisted
visit admin_projects_path
end
@@ -37,15 +40,14 @@ describe "Admin::Projects", feature: true do
describe "GET /admin/projects/:namespace_id/:id" do
before do
- visit admin_projects_path
- click_link "#{project.name}"
- end
+ expect(project).to be_persisted
- it do
- expect(current_path).to eq admin_namespace_project_path(project.namespace, project)
+ visit admin_projects_path
+ click_link project.name
end
it "has project info" do
+ expect(current_path).to eq admin_project_path(project)
expect(page).to have_content(project.path)
expect(page).to have_content(project.name)
expect(page).to have_content(project.name_with_namespace)
@@ -54,15 +56,18 @@ describe "Admin::Projects", feature: true do
end
describe 'transfer project' do
+ # The gitlab-shell transfer will fail for a project without a repository
+ let(:project) { create(:project, :repository) }
+
before do
create(:group, name: 'Web')
- allow_any_instance_of(Projects::TransferService).
- to receive(:move_uploads_to_new_namespace).and_return(true)
+ allow_any_instance_of(Projects::TransferService)
+ .to receive(:move_uploads_to_new_namespace).and_return(true)
end
it 'transfers project to group web', js: true do
- visit admin_namespace_project_path(project.namespace, project)
+ visit admin_project_path(project)
click_button 'Search for Namespace'
click_link 'group: web'
@@ -79,7 +84,7 @@ describe "Admin::Projects", feature: true do
end
it 'adds admin a to a project as developer', js: true do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
page.within '.users-project-form' do
select2(current_user.id, from: '#user_ids', multiple: true)
@@ -102,7 +107,7 @@ describe "Admin::Projects", feature: true do
end
it 'removes admin from the project' do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
page.within '.content-list' do
expect(page).to have_content(current_user.name)
diff --git a/spec/features/admin/admin_requests_profiles_spec.rb b/spec/features/admin/admin_requests_profiles_spec.rb
new file mode 100644
index 00000000000..380cd5d7703
--- /dev/null
+++ b/spec/features/admin/admin_requests_profiles_spec.rb
@@ -0,0 +1,69 @@
+require 'spec_helper'
+
+describe 'Admin::RequestsProfilesController' do
+ before do
+ FileUtils.mkdir_p(Gitlab::RequestProfiler::PROFILES_DIR)
+ sign_in(create(:admin))
+ end
+
+ after do
+ Gitlab::RequestProfiler.remove_all_profiles
+ end
+
+ describe 'GET /admin/requests_profiles' do
+ it 'shows the current profile token' do
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+
+ visit admin_requests_profiles_path
+
+ expect(page).to have_content("X-Profile-Token: #{Gitlab::RequestProfiler.profile_token}")
+ end
+
+ it 'lists all available profiles' do
+ time1 = 1.hour.ago
+ time2 = 2.hours.ago
+ time3 = 3.hours.ago
+ profile1 = "|gitlab-org|gitlab-ce_#{time1.to_i}.html"
+ profile2 = "|gitlab-org|gitlab-ce_#{time2.to_i}.html"
+ profile3 = "|gitlab-com|infrastructure_#{time3.to_i}.html"
+
+ FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile1}")
+ FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile2}")
+ FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile3}")
+
+ visit admin_requests_profiles_path
+
+ within('.panel', text: '/gitlab-org/gitlab-ce') do
+ expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile1)}']", text: time1.to_s(:long))
+ expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile2)}']", text: time2.to_s(:long))
+ end
+
+ within('.panel', text: '/gitlab-com/infrastructure') do
+ expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile3)}']", text: time3.to_s(:long))
+ end
+ end
+ end
+
+ describe 'GET /admin/requests_profiles/:profile' do
+ context 'when a profile exists' do
+ it 'displays the content of the profile' do
+ content = 'This is a request profile'
+ profile = "|gitlab-org|gitlab-ce_#{Time.now.to_i}.html"
+
+ File.write("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile}", content)
+
+ visit admin_requests_profile_path(profile)
+
+ expect(page).to have_content(content)
+ end
+ end
+
+ context 'when a profile does not exist' do
+ it 'shows an error message' do
+ visit admin_requests_profile_path('|non|existent_12345.html')
+
+ expect(page).to have_content('Profile not found')
+ end
+ end
+ end
+end
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 5dcc7d35d82..e3bb16af38a 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -5,35 +5,58 @@ describe "Admin Runners" do
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- login_as :admin
+ sign_in(create(:admin))
end
describe "Runners page" do
- before do
- runner = FactoryGirl.create(:ci_runner, contacted_at: Time.now)
- pipeline = FactoryGirl.create(:ci_pipeline)
- FactoryGirl.create(:ci_build, pipeline: pipeline, runner_id: runner.id)
- visit admin_runners_path
- end
+ let(:pipeline) { create(:ci_pipeline) }
+
+ context "when there are runners" do
+ before do
+ runner = FactoryGirl.create(:ci_runner, contacted_at: Time.now)
+ FactoryGirl.create(:ci_build, pipeline: pipeline, runner_id: runner.id)
+ visit admin_runners_path
+ end
+
+ it 'has all necessary texts' do
+ expect(page).to have_text "How to setup"
+ expect(page).to have_text "Runners with last contact more than a minute ago: 1"
+ end
+
+ describe 'search' do
+ before do
+ FactoryGirl.create :ci_runner, description: 'runner-foo'
+ FactoryGirl.create :ci_runner, description: 'runner-bar'
+ end
+
+ it 'shows correct runner when description matches' do
+ search_form = find('#runners-search')
+ search_form.fill_in 'search', with: 'runner-foo'
+ search_form.click_button 'Search'
- it 'has all necessary texts' do
- expect(page).to have_text "To register a new Runner"
- expect(page).to have_text "Runners with last contact more than a minute ago: 1"
+ expect(page).to have_content("runner-foo")
+ expect(page).not_to have_content("runner-bar")
+ end
+
+ it 'shows no runner when description does not match' do
+ search_form = find('#runners-search')
+ search_form.fill_in 'search', with: 'runner-baz'
+ search_form.click_button 'Search'
+
+ expect(page).to have_text 'No runners found'
+ end
+ end
end
- describe 'search' do
+ context "when there are no runners" do
before do
- FactoryGirl.create :ci_runner, description: 'runner-foo'
- FactoryGirl.create :ci_runner, description: 'runner-bar'
-
- search_form = find('#runners-search')
- search_form.fill_in 'search', with: 'runner-foo'
- search_form.click_button 'Search'
+ visit admin_runners_path
end
- it 'shows correct runner' do
- expect(page).to have_content("runner-foo")
- expect(page).not_to have_content("runner-bar")
+ it 'has all necessary texts including no runner message' do
+ expect(page).to have_text "How to setup"
+ expect(page).to have_text "Runners with last contact more than a minute ago: 0"
+ expect(page).to have_text 'No runners found'
end
end
end
@@ -42,8 +65,8 @@ describe "Admin Runners" do
let(:runner) { FactoryGirl.create :ci_runner }
before do
- @project1 = FactoryGirl.create(:empty_project)
- @project2 = FactoryGirl.create(:empty_project)
+ @project1 = FactoryGirl.create(:project)
+ @project2 = FactoryGirl.create(:project)
visit admin_runner_path(runner)
end
@@ -134,15 +157,17 @@ describe "Admin Runners" do
describe 'runners registration token' do
let!(:token) { current_application_settings.runners_registration_token }
- before { visit admin_runners_path }
+
+ before do
+ visit admin_runners_path
+ end
it 'has a registration token' do
- expect(page).to have_content("Registration token is #{token}")
- expect(page).to have_selector('#runners-token', text: token)
+ expect(page.find('#registration_token')).to have_content(token)
end
describe 'reload registration token' do
- let(:page_token) { find('#runners-token').text }
+ let(:page_token) { find('#registration_token').text }
before do
click_button 'Reset runners registration token'
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 5099441dce2..dbb0ae9c86e 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Admin updates settings', feature: true do
+feature 'Admin updates settings' do
include StubENV
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- login_as :admin
+ sign_in(create(:admin))
visit admin_application_settings_path
end
@@ -16,14 +16,34 @@ feature 'Admin updates settings', feature: true do
expect(page).to have_content "Application settings saved successfully"
end
+ scenario 'Uncheck all restricted visibility levels' do
+ find('#application_setting_visibility_level_0').set(false)
+ find('#application_setting_visibility_level_10').set(false)
+ find('#application_setting_visibility_level_20').set(false)
+
+ click_button 'Save'
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(find('#application_setting_visibility_level_0')).not_to be_checked
+ expect(find('#application_setting_visibility_level_10')).not_to be_checked
+ expect(find('#application_setting_visibility_level_20')).not_to be_checked
+ end
+
scenario 'Change application settings' do
uncheck 'Gravatar enabled'
fill_in 'Home page URL', with: 'https://about.gitlab.com/'
fill_in 'Help page text', with: 'Example text'
+ check 'Hide marketing-related entries from help'
+ fill_in 'Support page URL', with: 'http://example.com/help'
+ uncheck 'Project export enabled'
click_button 'Save'
expect(current_application_settings.gravatar_enabled).to be_falsey
expect(current_application_settings.home_page_url).to eq "https://about.gitlab.com/"
+ expect(current_application_settings.help_page_text).to eq "Example text"
+ expect(current_application_settings.help_page_hide_commercial_content).to be_truthy
+ expect(current_application_settings.help_page_support_url).to eq "http://example.com/help"
+ expect(current_application_settings.project_export_enabled).to be_falsey
expect(page).to have_content "Application settings saved successfully"
end
@@ -51,6 +71,14 @@ feature 'Admin updates settings', feature: true do
expect(find('#service_push_channel').value).to eq '#test_channel'
end
+ context 'sign-in restrictions', :js do
+ it 'de-activates oauth sign-in source' do
+ find('.btn', text: 'GitLab.com').click
+
+ expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active')
+ end
+ end
+
def check_all_events
page.check('Active')
page.check('Push')
diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb
index 1df972843e2..1fd1cda694a 100644
--- a/spec/features/admin/admin_system_info_spec.rb
+++ b/spec/features/admin/admin_system_info_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'Admin System Info' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
describe 'GET /admin/system_info' do
@@ -20,6 +20,7 @@ describe 'Admin System Info' do
expect(page).to have_content 'CPU 2 cores'
expect(page).to have_content 'Memory 4 GB / 16 GB'
expect(page).to have_content 'Disks'
+ expect(page).to have_content 'Uptime'
end
end
@@ -34,6 +35,7 @@ describe 'Admin System Info' do
expect(page).to have_content 'CPU Unable to collect CPU info'
expect(page).to have_content 'Memory 4 GB / 16 GB'
expect(page).to have_content 'Disks'
+ expect(page).to have_content 'Uptime'
end
end
@@ -48,6 +50,7 @@ describe 'Admin System Info' do
expect(page).to have_content 'CPU 2 cores'
expect(page).to have_content 'Memory Unable to collect memory info'
expect(page).to have_content 'Disks'
+ expect(page).to have_content 'Uptime'
end
end
end
diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
index 0fb4baeb71c..034682dae27 100644
--- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb
+++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do
+describe 'Admin > Users > Impersonation Tokens', js: true do
let(:admin) { create(:admin) }
let!(:user) { create(:user) }
@@ -8,11 +8,13 @@ describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do
find(".table.active-tokens")
end
- def inactive_impersonation_tokens
- find(".table.inactive-tokens")
+ def no_personal_access_tokens_message
+ find(".settings-message")
end
- before { login_as(admin) }
+ before do
+ sign_in(admin)
+ end
describe "token creation" do
it "allows creation of a token" do
@@ -30,11 +32,13 @@ describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do
check "api"
check "read_user"
- expect { click_on "Create impersonation token" }.to change { PersonalAccessTokensFinder.new(impersonation: true).execute.count }
+ click_on "Create impersonation token"
+
expect(active_impersonation_tokens).to have_text(name)
expect(active_impersonation_tokens).to have_text('In')
expect(active_impersonation_tokens).to have_text('api')
expect(active_impersonation_tokens).to have_text('read_user')
+ expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1)
end
end
@@ -58,15 +62,17 @@ describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do
click_on "Revoke"
- expect(inactive_impersonation_tokens).to have_text(impersonation_token.name)
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
end
- it "moves expired tokens to the 'inactive' section" do
+ it "removes expired tokens from 'active' section" do
impersonation_token.update(expires_at: 5.days.ago)
visit admin_user_impersonation_tokens_path(user_id: user.username)
- expect(inactive_impersonation_tokens).to have_text(impersonation_token.name)
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
end
end
end
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index c5b1ef1295c..e2e2b13cf8a 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -1,11 +1,15 @@
require 'spec_helper'
-describe "Admin::Users", feature: true do
+describe "Admin::Users" do
let!(:user) do
create(:omniauth_user, provider: 'twitter', extern_uid: '123456')
end
- let!(:current_user) { login_as :admin }
+ let!(:current_user) { create(:admin) }
+
+ before do
+ sign_in(current_user)
+ end
describe "GET /admin/users" do
before do
@@ -21,6 +25,9 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(current_user.name)
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
+ expect(page).to have_link('Block', href: block_admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Two-factor Authentication filters' do
@@ -75,10 +82,10 @@ describe "Admin::Users", feature: true do
it "applies defaults to user" do
click_button "Create user"
user = User.find_by(username: 'bang')
- expect(user.projects_limit).
- to eq(Gitlab.config.gitlab.default_projects_limit)
- expect(user.can_create_group).
- to eq(Gitlab.config.gitlab.default_can_create_group)
+ expect(user.projects_limit)
+ .to eq(Gitlab.config.gitlab.default_projects_limit)
+ expect(user.can_create_group)
+ .to eq(Gitlab.config.gitlab.default_can_create_group)
end
it "creates user with valid data" do
@@ -114,11 +121,17 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
+ expect(page).to have_link('Block user', href: block_admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Impersonation' do
let(:another_user) { create(:user) }
- before { visit admin_user_path(another_user) }
+
+ before do
+ visit admin_user_path(another_user)
+ end
context 'before impersonating' do
it 'shows impersonate button for other users' do
@@ -143,7 +156,9 @@ describe "Admin::Users", feature: true do
end
context 'when impersonating' do
- before { click_link 'Impersonate' }
+ before do
+ click_link 'Impersonate'
+ end
it 'logs in as the user when impersonate is clicked' do
expect(page.find(:css, '.header-user .profile-link')['data-user']).to eql(another_user.username)
@@ -277,7 +292,7 @@ describe "Admin::Users", feature: true do
page.within(first('.group_member')) do
find('.btn-remove').click
end
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_selector('.group_member')
end
diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb
index 855247de2ea..c2b7543a690 100644
--- a/spec/features/admin/admin_uses_repository_checks_spec.rb
+++ b/spec/features/admin/admin_uses_repository_checks_spec.rb
@@ -1,15 +1,15 @@
require 'rails_helper'
-feature 'Admin uses repository checks', feature: true do
+feature 'Admin uses repository checks' do
include StubENV
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- login_as :admin
+ sign_in(create(:admin))
end
scenario 'to trigger a single check' do
- project = create(:empty_project)
+ project = create(:project)
visit_admin_project_page(project)
page.within('.repository-check') do
@@ -20,10 +20,10 @@ feature 'Admin uses repository checks', feature: true do
end
scenario 'to see a single failed repository check' do
- project = create(:empty_project)
+ project = create(:project)
project.update_columns(
last_repository_check_failed: true,
- last_repository_check_at: Time.now,
+ last_repository_check_at: Time.now
)
visit_admin_project_page(project)
@@ -43,6 +43,6 @@ feature 'Admin uses repository checks', feature: true do
end
def visit_admin_project_page(project)
- visit admin_namespace_project_path(project.namespace, project)
+ visit admin_project_path(project)
end
end
diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb
index 58b14e09740..5aae2dbaf91 100644
--- a/spec/features/atom/dashboard_issues_spec.rb
+++ b/spec/features/atom/dashboard_issues_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "Dashboard Issues Feed", feature: true do
+describe "Dashboard Issues Feed" do
describe "GET /issues" do
let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
@@ -20,28 +20,35 @@ describe "Dashboard Issues Feed", feature: true do
expect(body).to have_selector('title', text: "#{user.name} issues")
end
+ it "renders atom feed via RSS token" do
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token)
+
+ expect(response_headers['Content-Type']).to have_content('application/atom+xml')
+ expect(body).to have_selector('title', text: "#{user.name} issues")
+ end
+
it "renders atom feed with url parameters" do
- visit issues_dashboard_path(:atom, private_token: user.private_token, state: 'opened', assignee_id: user.id)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id)
link = find('link[type="application/atom+xml"]')
params = CGI.parse(URI.parse(link[:href]).query)
- expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('rss_token' => [user.rss_token])
expect(params).to include('state' => ['opened'])
expect(params).to include('assignee_id' => [user.id.to_s])
end
context "issue with basic fields" do
- let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') }
+ let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') }
it "renders issue fields" do
- visit issues_dashboard_path(:atom, private_token: user.private_token)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token)
entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]")
expect(entry).to be_present
expect(entry).to have_selector('author email', text: issue2.author_public_email)
- expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email)
+ expect(entry).to have_selector('assignees email', text: assignee.public_email)
expect(entry).not_to have_selector('labels')
expect(entry).not_to have_selector('milestone')
expect(entry).to have_selector('description', text: issue2.description)
@@ -51,20 +58,20 @@ describe "Dashboard Issues Feed", feature: true do
context "issue with label and milestone" do
let!(:milestone1) { create(:milestone, project: project1, title: 'v1') }
let!(:label1) { create(:label, project: project1, title: 'label1') }
- let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) }
+ let!(:issue1) { create(:issue, author: user, assignees: [assignee], project: project1, milestone: milestone1) }
before do
issue1.labels << label1
end
it "renders issue label and milestone info" do
- visit issues_dashboard_path(:atom, private_token: user.private_token)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token)
entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]")
expect(entry).to be_present
expect(entry).to have_selector('author email', text: issue1.author_public_email)
- expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email)
+ expect(entry).to have_selector('assignees email', text: assignee.public_email)
expect(entry).to have_selector('labels label', text: label1.title)
expect(entry).to have_selector('milestone', text: milestone1.title)
expect(entry).not_to have_selector('description')
diff --git a/spec/features/atom/dashboard_spec.rb b/spec/features/atom/dashboard_spec.rb
index 746df36bb25..321c8a2a670 100644
--- a/spec/features/atom/dashboard_spec.rb
+++ b/spec/features/atom/dashboard_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "Dashboard Feed", feature: true do
+describe "Dashboard Feed" do
describe "GET /" do
let!(:user) { create(:user, name: "Jonh") }
@@ -11,6 +11,13 @@ describe "Dashboard Feed", feature: true do
end
end
+ context "projects atom feed via RSS token" do
+ it "renders projects atom feed" do
+ visit dashboard_projects_path(:atom, rss_token: user.rss_token)
+ expect(body).to have_selector('feed title')
+ end
+ end
+
context 'feed content' do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project, author: user, description: '') }
@@ -20,7 +27,7 @@ describe "Dashboard Feed", feature: true do
project.team << [user, :master]
issue_event(issue, user)
note_event(note, user)
- visit dashboard_projects_path(:atom, private_token: user.private_token)
+ visit dashboard_projects_path(:atom, rss_token: user.rss_token)
end
it "has issue opened event" do
@@ -28,8 +35,8 @@ describe "Dashboard Feed", feature: true do
end
it "has issue comment event" do
- expect(body).
- to have_content("#{user.name} commented on issue ##{issue.iid}")
+ expect(body)
+ .to have_content("#{user.name} commented on issue ##{issue.iid}")
end
end
end
diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb
index b3903ec2faf..3eeb4d35131 100644
--- a/spec/features/atom/issues_spec.rb
+++ b/spec/features/atom/issues_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'Issues Feed', feature: true do
+describe 'Issues Feed' do
describe 'GET /issues' do
let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
let!(:group) { create(:group) }
let!(:project) { create(:project) }
- let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) }
+ let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) }
before do
project.team << [user, :developer]
@@ -15,51 +15,68 @@ describe 'Issues Feed', feature: true do
context 'when authenticated' do
it 'renders atom feed' do
- login_with user
- visit namespace_project_issues_path(project.namespace, project, :atom)
+ sign_in user
+ visit project_issues_path(project, :atom)
- expect(response_headers['Content-Type']).
- to have_content('application/atom+xml')
+ expect(response_headers['Content-Type'])
+ .to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email)
- expect(body).to have_selector('assignee email', text: issue.author_public_email)
+ expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
+ expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('entry summary', text: issue.title)
end
end
context 'when authenticated via private token' do
it 'renders atom feed' do
- visit namespace_project_issues_path(project.namespace, project, :atom,
+ visit project_issues_path(project, :atom,
private_token: user.private_token)
- expect(response_headers['Content-Type']).
- to have_content('application/atom+xml')
+ expect(response_headers['Content-Type'])
+ .to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email)
- expect(body).to have_selector('assignee email', text: issue.author_public_email)
+ expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
+ expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
+ expect(body).to have_selector('entry summary', text: issue.title)
+ end
+ end
+
+ context 'when authenticated via RSS token' do
+ it 'renders atom feed' do
+ visit project_issues_path(project, :atom,
+ rss_token: user.rss_token)
+
+ expect(response_headers['Content-Type'])
+ .to have_content('application/atom+xml')
+ expect(body).to have_selector('title', text: "#{project.name} issues")
+ expect(body).to have_selector('author email', text: issue.author_public_email)
+ expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
+ expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('entry summary', text: issue.title)
end
end
it "renders atom feed with url parameters for project issues" do
- visit namespace_project_issues_path(project.namespace, project,
- :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id)
+ visit project_issues_path(project,
+ :atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id)
link = find('link[type="application/atom+xml"]')
params = CGI.parse(URI.parse(link[:href]).query)
- expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('rss_token' => [user.rss_token])
expect(params).to include('state' => ['opened'])
expect(params).to include('assignee_id' => [user.id.to_s])
end
it "renders atom feed with url parameters for group issues" do
- visit issues_group_path(group, :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id)
+ visit issues_group_path(group, :atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id)
link = find('link[type="application/atom+xml"]')
params = CGI.parse(URI.parse(link[:href]).query)
- expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('rss_token' => [user.rss_token])
expect(params).to include('state' => ['opened'])
expect(params).to include('assignee_id' => [user.id.to_s])
end
diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb
index 55e10a1a89b..79069bbca8e 100644
--- a/spec/features/atom/users_spec.rb
+++ b/spec/features/atom/users_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "User Feed", feature: true do
+describe "User Feed" do
describe "GET /" do
let!(:user) { create(:user) }
@@ -11,8 +11,15 @@ describe "User Feed", feature: true do
end
end
+ context 'user atom feed via RSS token' do
+ it "renders user atom feed" do
+ visit user_path(user, :atom, rss_token: user.rss_token)
+ expect(body).to have_selector('feed title')
+ end
+ end
+
context 'feed content' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:issue) do
create(:issue,
project: project,
@@ -40,7 +47,7 @@ describe "User Feed", feature: true do
issue_event(issue, user)
note_event(note, user)
merge_request_event(merge_request, user)
- visit user_path(user, :atom, private_token: user.private_token)
+ visit user_path(user, :atom, rss_token: user.rss_token)
end
it 'has issue opened event' do
@@ -48,12 +55,12 @@ describe "User Feed", feature: true do
end
it 'has issue comment event' do
- expect(body).
- to have_content("#{safe_name} commented on issue ##{issue.iid}")
+ expect(body)
+ .to have_content("#{safe_name} commented on issue ##{issue.iid}")
end
it 'has XHTML summaries in issue descriptions' do
- expect(body).to match /we have a bug!<\/p>\n\n<hr ?\/>\n\n<p dir="auto">I guess/
+ expect(body).to match /<hr ?\/>/
end
it 'has XHTML summaries in notes' do
diff --git a/spec/features/auto_deploy_spec.rb b/spec/features/auto_deploy_spec.rb
index 67b0f006854..dff6f96b663 100644
--- a/spec/features/auto_deploy_spec.rb
+++ b/spec/features/auto_deploy_spec.rb
@@ -5,16 +5,9 @@ describe 'Auto deploy' do
let(:project) { create(:project, :repository) }
before do
- project.create_kubernetes_service(
- active: true,
- properties: {
- namespace: project.path,
- api_url: 'https://kubernetes.example.com',
- token: 'a' * 40,
- }
- )
+ create :kubernetes_service, project: project
project.team << [user, :master]
- login_as user
+ sign_in user
end
context 'when no deployment service is active' do
@@ -23,7 +16,7 @@ describe 'Auto deploy' do
end
it 'does not show a button to set up auto deploy' do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
expect(page).to have_no_content('Set up auto deploy')
end
end
@@ -31,7 +24,7 @@ describe 'Auto deploy' do
context 'when a deployment service is active' do
before do
project.kubernetes_service.update!(active: true)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
it 'shows a button to set up auto deploy' do
@@ -53,7 +46,7 @@ describe 'Auto deploy' do
within '.gitlab-ci-yml-selector' do
click_on 'OpenShift'
end
- wait_for_ajax
+ wait_for_requests
click_button 'Commit changes'
expect(page).to have_content('New Merge Request From auto-deploy into master')
diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb
index 505e0b5c355..a6ad5981f8f 100644
--- a/spec/features/boards/add_issues_modal_spec.rb
+++ b/spec/features/boards/add_issues_modal_spec.rb
@@ -1,9 +1,7 @@
require 'rails_helper'
-describe 'Issue Boards add issue modal', :feature, :js do
- include WaitForVueResource
-
- let(:project) { create(:empty_project, :public) }
+describe 'Issue Boards add issue modal', :js do
+ let(:project) { create(:project, :public) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let!(:planning) { create(:label, project: project, name: 'Planning') }
@@ -16,16 +14,16 @@ describe 'Issue Boards add issue modal', :feature, :js do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'resets filtered search state' do
- visit namespace_project_board_path(project.namespace, project, board, search: 'testing')
+ visit project_board_path(project, board, search: 'testing')
- wait_for_vue_resource
+ wait_for_requests
click_button('Add issues')
@@ -74,7 +72,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
before do
click_button('Add issues')
- wait_for_vue_resource
+ wait_for_requests
end
it 'loads issues' do
@@ -107,7 +105,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
click_button('Add issues')
- wait_for_vue_resource
+ wait_for_requests
page.within('.add-issues-modal') do
expect(find('.add-issues-footer')).not_to have_button(planning.title)
@@ -122,7 +120,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
find('.form-control').native.send_keys(issue.title)
find('.form-control').native.send_keys(:enter)
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 1)
end
@@ -133,7 +131,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
find('.form-control').native.send_keys('testing search')
find('.form-control').native.send_keys(:enter)
- wait_for_vue_resource
+ wait_for_requests
expect(page).not_to have_selector('.card')
expect(page).not_to have_content("You haven't added any issues to your project yet")
@@ -233,7 +231,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
click_button 'Add 1 issue'
end
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card')
end
end
@@ -249,7 +247,7 @@ describe 'Issue Boards add issue modal', :feature, :js do
click_button 'Add 1 issue'
end
- page.within(find('.board:nth-child(2)')) do
+ page.within(find('.board:nth-child(3)')) do
expect(page).to have_selector('.card')
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index a172ce1e8c0..ce458431c55 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-describe 'Issue Boards', feature: true, js: true do
- include WaitForVueResource
+describe 'Issue Boards', js: true do
include DragTo
- let(:project) { create(:empty_project, :public) }
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let!(:user2) { create(:user) }
@@ -13,14 +13,14 @@ describe 'Issue Boards', feature: true, js: true do
project.team << [user, :master]
project.team << [user2, :master]
- login_as(user)
+ sign_in(user)
end
context 'no lists' do
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
- expect(page).to have_selector('.board', count: 2)
+ visit project_board_path(project, board)
+ wait_for_requests
+ expect(page).to have_selector('.board', count: 3)
end
it 'shows blank state' do
@@ -37,18 +37,18 @@ describe 'Issue Boards', feature: true, js: true do
page.within(find('.board-blank-state')) do
click_button("Nevermind, I'll use my own")
end
- expect(page).to have_selector('.board', count: 1)
+ expect(page).to have_selector('.board', count: 2)
end
it 'creates default lists' do
- lists = ['To Do', 'Doing', 'Closed']
+ lists = ['Backlog', 'To Do', 'Doing', 'Closed']
page.within(find('.board-blank-state')) do
click_button('Add default lists')
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 3)
+ expect(page).to have_selector('.board', count: 4)
page.all('.board').each_with_index do |list, i|
expect(list.find('.board-title')).to have_content(lists[i])
@@ -71,7 +71,7 @@ describe 'Issue Boards', feature: true, js: true do
let!(:list2) { create(:list, board: board, label: development, position: 1) }
let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
- let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) }
+ let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) }
let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) }
let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) }
let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) }
@@ -82,33 +82,29 @@ describe 'Issue Boards', feature: true, js: true do
let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting], relative_position: 1) }
before do
- visit namespace_project_board_path(project.namespace, project, board)
+ visit project_board_path(project, board)
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 3)
- expect(find('.board:nth-child(1)')).to have_selector('.card')
+ expect(page).to have_selector('.board', count: 4)
expect(find('.board:nth-child(2)')).to have_selector('.card')
expect(find('.board:nth-child(3)')).to have_selector('.card')
- end
-
- it 'shows lists' do
- expect(page).to have_selector('.board', count: 3)
+ expect(find('.board:nth-child(4)')).to have_selector('.card')
end
it 'shows description tooltip on list title' do
- page.within('.board:nth-child(1)') do
+ page.within('.board:nth-child(2)') do
expect(find('.board-title span.has-tooltip')[:title]).to eq('Test')
end
end
it 'shows issues in lists' do
- wait_for_board_cards(1, 8)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 8)
+ wait_for_board_cards(3, 2)
end
it 'shows confidential issues with icon' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.confidential-icon', count: 1)
end
end
@@ -117,45 +113,45 @@ describe 'Issue Boards', feature: true, js: true do
find('.filtered-search').set(issue8.title)
find('.filtered-search').native.send_keys(:enter)
- wait_for_vue_resource
+ wait_for_requests
- expect(find('.board:nth-child(1)')).to have_selector('.card', count: 0)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0)
- expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1)
end
it 'search list' do
find('.filtered-search').set(issue5.title)
find('.filtered-search').native.send_keys(:enter)
- wait_for_vue_resource
+ wait_for_requests
- expect(find('.board:nth-child(1)')).to have_selector('.card', count: 1)
- expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 0)
end
it 'allows user to delete board' do
- page.within(find('.board:nth-child(1)')) do
+ page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'removes checkmark in new list dropdown after deleting' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
- page.within(find('.board:nth-child(1)')) do
+ page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'infinite scrolls list' do
@@ -163,22 +159,22 @@ describe 'Issue Boards', feature: true, js: true do
create(:labeled_issue, project: project, labels: [planning])
end
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('58')
expect(page).to have_selector('.card', count: 20)
expect(page).to have_content('Showing 20 of 58 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
- wait_for_vue_resource
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
+ wait_for_requests
expect(page).to have_selector('.card', count: 40)
expect(page).to have_content('Showing 40 of 58 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
- wait_for_vue_resource
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
+ wait_for_requests
expect(page).to have_selector('.card', count: 58)
expect(page).to have_content('Showing all issues')
@@ -187,83 +183,83 @@ describe 'Issue Boards', feature: true, js: true do
context 'closed' do
it 'shows list of closed issues' do
- wait_for_board_cards(3, 1)
- wait_for_ajax
+ wait_for_board_cards(4, 1)
+ wait_for_requests
end
it 'moves issue to closed' do
- drag(list_from_index: 0, list_to_index: 2)
+ drag(list_from_index: 1, list_to_index: 3)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 7)
wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 2)
- expect(find('.board:nth-child(1)')).not_to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).to have_selector('.card', count: 2)
- expect(find('.board:nth-child(3)')).to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).not_to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
it 'removes all of the same issue to closed' do
- drag(list_from_index: 0, list_to_index: 2)
+ drag(list_from_index: 1, list_to_index: 3)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
+ wait_for_board_cards(2, 7)
wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 2)
- expect(find('.board:nth-child(1)')).not_to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).to have_content(issue9.title)
- expect(find('.board:nth-child(3)')).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).not_to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
end
context 'lists' do
it 'changes position of list' do
- drag(list_from_index: 1, list_to_index: 0, selector: '.board-header')
+ drag(list_from_index: 2, list_to_index: 1, selector: '.board-header')
- wait_for_board_cards(1, 2)
- wait_for_board_cards(2, 8)
- wait_for_board_cards(3, 1)
+ wait_for_board_cards(2, 2)
+ wait_for_board_cards(3, 8)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(1)')).to have_content(development.title)
- expect(find('.board:nth-child(1)')).to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).to have_content(development.title)
+ expect(find('.board:nth-child(2)')).to have_content(planning.title)
end
it 'issue moves between lists' do
- drag(list_from_index: 0, from_index: 1, list_to_index: 1)
+ drag(list_from_index: 1, from_index: 1, list_to_index: 2)
- wait_for_board_cards(1, 7)
- wait_for_board_cards(2, 2)
- wait_for_board_cards(3, 1)
+ wait_for_board_cards(2, 7)
+ wait_for_board_cards(3, 2)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(2)')).to have_content(issue6.title)
- expect(find('.board:nth-child(2)').all('.card').last).not_to have_content(development.title)
+ expect(find('.board:nth-child(3)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(3)').all('.card').last).to have_content(development.title)
end
it 'issue moves between lists' do
- drag(list_from_index: 1, list_to_index: 0)
+ drag(list_from_index: 2, list_to_index: 1)
- wait_for_board_cards(1, 9)
- wait_for_board_cards(2, 1)
+ wait_for_board_cards(2, 9)
wait_for_board_cards(3, 1)
+ wait_for_board_cards(4, 1)
- expect(find('.board:nth-child(1)')).to have_content(issue7.title)
- expect(find('.board:nth-child(1)').all('.card').first).not_to have_content(planning.title)
+ expect(find('.board:nth-child(2)')).to have_content(issue7.title)
+ expect(find('.board:nth-child(2)').all('.card').first).to have_content(planning.title)
end
it 'issue moves from closed' do
- drag(list_from_index: 2, list_to_index: 1)
+ drag(list_from_index: 2, list_to_index: 3)
- expect(find('.board:nth-child(2)')).to have_content(issue8.title)
+ wait_for_board_cards(2, 8)
+ wait_for_board_cards(3, 1)
+ wait_for_board_cards(4, 2)
- wait_for_board_cards(1, 8)
- wait_for_board_cards(2, 3)
- wait_for_board_cards(3, 0)
+ expect(find('.board:nth-child(4)')).to have_content(issue8.title)
end
context 'issue card' do
it 'shows assignee' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.avatar', count: 1)
end
end
@@ -272,7 +268,7 @@ describe 'Issue Boards', feature: true, js: true do
context 'new list' do
it 'shows all labels in new list dropdown' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
expect(page).to have_content(planning.title)
@@ -283,52 +279,52 @@ describe 'Issue Boards', feature: true, js: true do
it 'creates new list for label' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
click_link testing.title
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Backlog label' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
click_link backlog.title
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Closed label' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
click_link closed.title
end
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
it 'keeps dropdown open after adding new list' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
click_link closed.title
end
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_css('#js-add-list.open')
end
@@ -336,7 +332,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'creates new list from a new label' do
click_button 'Add list'
- wait_for_ajax
+ wait_for_requests
click_link 'Create new label'
@@ -346,10 +342,10 @@ describe 'Issue Boards', feature: true, js: true do
click_button 'Create'
- wait_for_ajax
- wait_for_vue_resource
+ wait_for_requests
+ wait_for_requests
- expect(page).to have_selector('.board', count: 4)
+ expect(page).to have_selector('.board', count: 5)
end
end
end
@@ -360,9 +356,9 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(user2.username)
submit_filter
- wait_for_vue_resource
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_requests
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by assignee' do
@@ -370,10 +366,10 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(user.username)
submit_filter
- wait_for_vue_resource
+ wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by milestone' do
@@ -381,10 +377,10 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(milestone.title)
submit_filter
- wait_for_vue_resource
- wait_for_board_cards(1, 1)
- wait_for_board_cards(2, 0)
+ wait_for_requests
+ wait_for_board_cards(2, 1)
wait_for_board_cards(3, 0)
+ wait_for_board_cards(4, 0)
end
it 'filters by label' do
@@ -392,9 +388,9 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(testing.title)
submit_filter
- wait_for_vue_resource
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_requests
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by label with space after reload' do
@@ -404,17 +400,17 @@ describe 'Issue Boards', feature: true, js: true do
# Test after reload
page.evaluate_script 'window.location.reload()'
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
- wait_for_vue_resource
+ wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
- page.within(find('.board:nth-child(2)')) do
+ page.within(find('.board:nth-child(3)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
@@ -425,12 +421,12 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(testing.title)
submit_filter
- wait_for_board_cards(1, 1)
+ wait_for_board_cards(2, 1)
find('.clear-search').click
submit_filter
- wait_for_board_cards(1, 8)
+ wait_for_board_cards(2, 8)
end
it 'infinite scrolls list with label filter' do
@@ -442,19 +438,19 @@ describe 'Issue Boards', feature: true, js: true do
click_filter_link(testing.title)
submit_filter
- wait_for_vue_resource
+ wait_for_requests
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('51')
expect(page).to have_selector('.card', count: 20)
expect(page).to have_content('Showing 20 of 51 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
expect(page).to have_selector('.card', count: 40)
expect(page).to have_content('Showing 40 of 51 issues')
- evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
expect(page).to have_selector('.card', count: 51)
expect(page).to have_content('Showing all issues')
@@ -470,50 +466,50 @@ describe 'Issue Boards', feature: true, js: true do
submit_filter
- wait_for_vue_resource
+ wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'filters by clicking label button on issue' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card', count: 8)
expect(find('.card', match: :first)).to have_content(bug.title)
click_button(bug.title)
- wait_for_vue_resource
+ wait_for_requests
end
page.within('.tokens-container') do
expect(page).to have_content(bug.title)
end
- wait_for_vue_resource
+ wait_for_requests
- wait_for_board_cards(1, 1)
- wait_for_empty_boards((2..3))
+ wait_for_board_cards(2, 1)
+ wait_for_empty_boards((3..4))
end
it 'removes label filter by clicking label button on issue' do
- page.within(find('.board', match: :first)) do
+ page.within(find('.board:nth-child(2)')) do
page.within(find('.card', match: :first)) do
click_button(bug.title)
end
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 1)
end
- wait_for_vue_resource
+ wait_for_requests
end
end
end
context 'keyboard shortcuts' do
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'allows user to use keyboard shortcuts' do
@@ -524,9 +520,9 @@ describe 'Issue Boards', feature: true, js: true do
context 'signed out user' do
before do
- logout
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ sign_out(:user)
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'displays lists' do
@@ -547,10 +543,10 @@ describe 'Issue Boards', feature: true, js: true do
before do
project.team << [user_guest, :guest]
- logout
- login_as(user_guest)
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ sign_out(:user)
+ sign_in(user_guest)
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'does not show create new list' do
diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb
index c50155a6d14..4cbb48e2e6e 100644
--- a/spec/features/boards/issue_ordering_spec.rb
+++ b/spec/features/boards/issue_ordering_spec.rb
@@ -1,10 +1,9 @@
require 'rails_helper'
-describe 'Issue Boards', :feature, :js do
- include WaitForVueResource
+describe 'Issue Boards', :js do
include DragTo
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project) }
@@ -16,21 +15,21 @@ describe 'Issue Boards', :feature, :js do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'un-ordered issues' do
let!(:issue4) { create(:labeled_issue, project: project, labels: [label]) }
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'has un-ordered issue as last issue' do
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(all('.card').last).to have_content(issue4.title)
end
end
@@ -38,7 +37,9 @@ describe 'Issue Boards', :feature, :js do
it 'moves un-ordered issue to top of list' do
drag(from_index: 3, to_index: 0)
- page.within(first('.board')) do
+ wait_for_requests
+
+ page.within(find('.board:nth-child(2)')) do
expect(first('.card')).to have_content(issue4.title)
end
end
@@ -46,16 +47,16 @@ describe 'Issue Boards', :feature, :js do
context 'ordering in list' do
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'moves from middle to top' do
drag(from_index: 1, to_index: 0)
- wait_for_vue_resource
+ wait_for_requests
expect(first('.card')).to have_content(issue2.title)
end
@@ -63,7 +64,7 @@ describe 'Issue Boards', :feature, :js do
it 'moves from middle to bottom' do
drag(from_index: 1, to_index: 2)
- wait_for_vue_resource
+ wait_for_requests
expect(all('.card').last).to have_content(issue2.title)
end
@@ -71,7 +72,7 @@ describe 'Issue Boards', :feature, :js do
it 'moves from top to bottom' do
drag(from_index: 0, to_index: 2)
- wait_for_vue_resource
+ wait_for_requests
expect(all('.card').last).to have_content(issue3.title)
end
@@ -79,7 +80,7 @@ describe 'Issue Boards', :feature, :js do
it 'moves from bottom to top' do
drag(from_index: 2, to_index: 0)
- wait_for_vue_resource
+ wait_for_requests
expect(first('.card')).to have_content(issue1.title)
end
@@ -87,7 +88,7 @@ describe 'Issue Boards', :feature, :js do
it 'moves from top to middle' do
drag(from_index: 0, to_index: 1)
- wait_for_vue_resource
+ wait_for_requests
expect(first('.card')).to have_content(issue2.title)
end
@@ -95,7 +96,7 @@ describe 'Issue Boards', :feature, :js do
it 'moves from bottom to middle' do
drag(from_index: 2, to_index: 1)
- wait_for_vue_resource
+ wait_for_requests
expect(all('.card').last).to have_content(issue2.title)
end
@@ -109,53 +110,53 @@ describe 'Issue Boards', :feature, :js do
let!(:issue6) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label2], relative_position: 1.0) }
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
- expect(page).to have_selector('.board', count: 3)
+ expect(page).to have_selector('.board', count: 4)
end
it 'moves to top of another list' do
- drag(list_from_index: 0, list_to_index: 1)
+ drag(list_from_index: 1, list_to_index: 2)
- wait_for_vue_resource
+ wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(first('.card')).to have_content(issue3.title)
end
end
it 'moves to bottom of another list' do
- drag(list_from_index: 0, list_to_index: 1, to_index: 2)
+ drag(list_from_index: 1, list_to_index: 2, to_index: 2)
- wait_for_vue_resource
+ wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(all('.card').last).to have_content(issue3.title)
end
end
it 'moves to index of another list' do
- drag(list_from_index: 0, list_to_index: 1, to_index: 1)
+ drag(list_from_index: 1, list_to_index: 2, to_index: 1)
- wait_for_vue_resource
+ wait_for_requests
- expect(first('.board')).to have_selector('.card', count: 2)
- expect(all('.board')[1]).to have_selector('.card', count: 4)
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2)
+ expect(all('.board')[2]).to have_selector('.card', count: 4)
- page.within(all('.board')[1]) do
+ page.within(all('.board')[2]) do
expect(all('.card')[1]).to have_content(issue3.title)
end
end
end
- def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0)
+ def drag(selector: '.board-list', list_from_index: 1, from_index: 0, to_index: 0, list_to_index: 1)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
diff --git a/spec/features/boards/keyboard_shortcut_spec.rb b/spec/features/boards/keyboard_shortcut_spec.rb
index a9cc6c49f8e..61b53aa5d1e 100644
--- a/spec/features/boards/keyboard_shortcut_spec.rb
+++ b/spec/features/boards/keyboard_shortcut_spec.rb
@@ -1,22 +1,20 @@
require 'rails_helper'
-describe 'Issue Boards shortcut', feature: true, js: true do
- include WaitForVueResource
-
- let(:project) { create(:empty_project) }
+describe 'Issue Boards shortcut', js: true do
+ let(:project) { create(:project) }
before do
create(:board, project: project)
- login_as :admin
+ sign_in(create(:admin))
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
it 'takes user to issue board index' do
find('body').native.send_keys('gb')
expect(page).to have_selector('.boards-list')
- wait_for_vue_resource
+ wait_for_requests
end
end
diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb
index 4a4c13e79c8..422d96175f7 100644
--- a/spec/features/boards/modal_filter_spec.rb
+++ b/spec/features/boards/modal_filter_spec.rb
@@ -1,9 +1,7 @@
require 'rails_helper'
-describe 'Issue Boards add issue modal filtering', :feature, :js do
- include WaitForVueResource
-
- let(:project) { create(:empty_project, :public) }
+describe 'Issue Boards add issue modal filtering', :js do
+ let(:project) { create(:project, :public) }
let(:board) { create(:board, project: project) }
let(:planning) { create(:label, project: project, name: 'Planning') }
let!(:list1) { create(:list, board: board, label: planning, position: 0) }
@@ -14,7 +12,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
it 'shows empty state when no results found' do
@@ -24,7 +22,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
find('.form-control').native.send_keys('testing empty state')
find('.form-control').native.send_keys(:enter)
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_content('There are no issues to show.')
end
@@ -38,7 +36,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 0)
@@ -48,7 +46,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
click_button('Add issues')
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 1)
end
@@ -62,13 +60,13 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 0)
find('.clear-search').click
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.card', count: 1)
end
@@ -89,16 +87,16 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.js-visual-token', text: user2.username)
+ expect(page).to have_selector('.js-visual-token', text: user2.name)
expect(page).to have_selector('.card', count: 1)
end
end
end
context 'assignee' do
- let!(:issue) { create(:issue, project: project, assignee: user2) }
+ let!(:issue) { create(:issue, project: project, assignees: [user2]) }
before do
project.team << [user2, :developer]
@@ -112,7 +110,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.js-visual-token', text: 'none')
expect(page).to have_selector('.card', count: 1)
@@ -125,9 +123,9 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
- expect(page).to have_selector('.js-visual-token', text: user2.username)
+ expect(page).to have_selector('.js-visual-token', text: user2.name)
expect(page).to have_selector('.card', count: 1)
end
end
@@ -147,7 +145,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.js-visual-token', text: 'upcoming')
expect(page).to have_selector('.card', count: 0)
@@ -160,7 +158,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.js-visual-token', text: milestone.name)
expect(page).to have_selector('.card', count: 1)
@@ -182,7 +180,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.js-visual-token', text: 'none')
expect(page).to have_selector('.card', count: 1)
@@ -195,7 +193,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
submit_filter
page.within('.add-issues-modal') do
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.js-visual-token', text: label.title)
expect(page).to have_selector('.card', count: 1)
@@ -204,8 +202,8 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
end
def visit_board
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
click_button('Add issues')
end
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
index f04a1a89e96..f67372337ec 100644
--- a/spec/features/boards/new_issue_spec.rb
+++ b/spec/features/boards/new_issue_spec.rb
@@ -1,9 +1,7 @@
require 'rails_helper'
-describe 'Issue Boards new issue', feature: true, js: true do
- include WaitForVueResource
-
- let(:project) { create(:empty_project, :public) }
+describe 'Issue Boards new issue', js: true do
+ let(:project) { create(:project, :public) }
let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, position: 0) }
let(:user) { create(:user) }
@@ -12,27 +10,27 @@ describe 'Issue Boards new issue', feature: true, js: true do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 3)
end
it 'displays new issue button' do
- expect(page).to have_selector('.board-issue-count-holder .btn', count: 1)
+ expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
end
it 'does not display new issue button in closed list' do
- page.within('.board:nth-child(2)') do
- expect(page).not_to have_selector('.board-issue-count-holder .btn')
+ page.within('.board:nth-child(3)') do
+ expect(page).not_to have_selector('.issue-count-badge-add-button')
end
end
it 'shows form when clicking button' do
page.within(first('.board')) do
- find('.board-issue-count-holder .btn').click
+ find('.issue-count-badge-add-button').click
expect(page).to have_selector('.board-new-issue-form')
end
@@ -40,7 +38,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
it 'hides form when clicking cancel' do
page.within(first('.board')) do
- find('.board-issue-count-holder .btn').click
+ find('.issue-count-badge-add-button').click
expect(page).to have_selector('.board-new-issue-form')
@@ -52,7 +50,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
it 'creates new issue' do
page.within(first('.board')) do
- find('.board-issue-count-holder .btn').click
+ find('.issue-count-badge-add-button').click
end
page.within(first('.board-new-issue-form')) do
@@ -60,16 +58,16 @@ describe 'Issue Boards new issue', feature: true, js: true do
click_button 'Submit issue'
end
- wait_for_vue_resource
+ wait_for_requests
- page.within(first('.board .board-issue-count')) do
+ page.within(first('.board .issue-count-badge-count')) do
expect(page).to have_content('1')
end
end
it 'shows sidebar when creating new issue' do
page.within(first('.board')) do
- find('.board-issue-count-holder .btn').click
+ find('.issue-count-badge-add-button').click
end
page.within(first('.board-new-issue-form')) do
@@ -77,7 +75,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
click_button 'Submit issue'
end
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_selector('.issue-boards-sidebar')
end
@@ -85,12 +83,12 @@ describe 'Issue Boards new issue', feature: true, js: true do
context 'unauthorized user' do
before do
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'does not display new issue button' do
- expect(page).to have_selector('.board-issue-count-holder .btn', count: 0)
+ expect(page).to have_selector('.issue-count-badge-add-button', count: 0)
end
end
end
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index bafa4f05937..c3bf50ef9d1 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -1,34 +1,31 @@
require 'rails_helper'
-describe 'Issue Boards', feature: true, js: true do
- include WaitForVueResource
-
+describe 'Issue Boards', js: true do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:user2) { create(:user) }
+ let(:project) { create(:project, :public) }
let!(:milestone) { create(:milestone, project: project) }
let!(:development) { create(:label, project: project, name: 'Development') }
let!(:bug) { create(:label, project: project, name: 'Bug') }
let!(:regression) { create(:label, project: project, name: 'Regression') }
let!(:stretch) { create(:label, project: project, name: 'Stretch') }
- let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) }
+ let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) }
let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, label: development, position: 0) }
- let(:card) { first('.board').first('.card') }
+ let(:card) { find('.board:nth-child(2)').first('.card') }
- before do
- Timecop.freeze
+ around do |example|
+ Timecop.freeze { example.run }
+ end
- project.team << [user, :master]
+ before do
+ project.add_master(user)
- login_as(user)
+ sign_in(user)
- visit namespace_project_board_path(project.namespace, project, board)
- wait_for_vue_resource
- end
-
- after do
- Timecop.return
+ visit project_board_path(project, board)
+ wait_for_requests
end
it 'shows sidebar when clicking issue' do
@@ -73,13 +70,29 @@ describe 'Issue Boards', feature: true, js: true do
click_button 'Remove from board'
end
- wait_for_vue_resource
+ wait_for_requests
- page.within(first('.board')) do
+ page.within(find('.board:nth-child(2)')) do
expect(page).to have_selector('.card', count: 1)
end
end
+ it 'does not show remove button for backlog or closed issues' do
+ create(:issue, project: project)
+ create(:issue, :closed, project: project)
+
+ visit project_board_path(project, board)
+ wait_for_requests
+
+ click_card(find('.board:nth-child(1)').first('.card'))
+
+ expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board'
+
+ click_card(find('.board:nth-child(3)').first('.card'))
+
+ expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board'
+ end
+
context 'assignee' do
it 'updates the issues assignee' do
click_card(card)
@@ -87,12 +100,12 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.assignee') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
- wait_for_vue_resource
+ wait_for_requests
end
expect(page).to have_content(user.name)
@@ -102,20 +115,20 @@ describe 'Issue Boards', feature: true, js: true do
end
it 'removes the assignee' do
- card_two = first('.board').find('.card:nth-child(2)')
+ card_two = find('.board:nth-child(2)').find('.card:nth-child(2)')
click_card(card_two)
page.within('.assignee') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-user') do
click_link 'Unassigned'
-
- wait_for_vue_resource
end
+ wait_for_requests
+
expect(page).to have_content('No assignee')
end
@@ -128,9 +141,9 @@ describe 'Issue Boards', feature: true, js: true do
page.within(find('.assignee')) do
expect(page).to have_content('No assignee')
- click_link 'assign yourself'
+ click_button 'assign yourself'
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_content(user.name)
end
@@ -138,31 +151,31 @@ describe 'Issue Boards', feature: true, js: true do
expect(card).to have_selector('.avatar')
end
- it 'resets assignee dropdown' do
+ it 'updates assignee dropdown' do
click_card(card)
page.within('.assignee') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
- wait_for_vue_resource
+ wait_for_requests
end
expect(page).to have_content(user.name)
end
- page.within(first('.board')) do
- find('.card:nth-child(2)').click
+ page.within(find('.board:nth-child(2)')) do
+ find('.card:nth-child(2)').trigger('click')
end
page.within('.assignee') do
click_link 'Edit'
- expect(page).not_to have_selector('.is-active')
+ expect(find('.dropdown-menu')).to have_selector('.is-active')
end
end
end
@@ -174,11 +187,11 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.milestone') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
click_link milestone.title
- wait_for_vue_resource
+ wait_for_requests
page.within('.value') do
expect(page).to have_content(milestone.title)
@@ -192,11 +205,11 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.milestone') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
click_link "No Milestone"
- wait_for_vue_resource
+ wait_for_requests
page.within('.value') do
expect(page).not_to have_content(milestone.title)
@@ -214,7 +227,7 @@ describe 'Issue Boards', feature: true, js: true do
click_button Date.today.day
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_content(Date.today.to_s(:medium))
end
@@ -228,11 +241,11 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.labels') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
click_link bug.title
- wait_for_vue_resource
+ wait_for_requests
find('.dropdown-menu-close-icon').click
@@ -242,7 +255,7 @@ describe 'Issue Boards', feature: true, js: true do
end
end
- expect(card).to have_selector('.label', count: 2)
+ expect(card).to have_selector('.label', count: 3)
expect(card).to have_content(bug.title)
end
@@ -252,12 +265,12 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.labels') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
click_link bug.title
click_link regression.title
- wait_for_vue_resource
+ wait_for_requests
find('.dropdown-menu-close-icon').click
@@ -268,7 +281,7 @@ describe 'Issue Boards', feature: true, js: true do
end
end
- expect(card).to have_selector('.label', count: 3)
+ expect(card).to have_selector('.label', count: 4)
expect(card).to have_content(bug.title)
expect(card).to have_content(regression.title)
end
@@ -279,11 +292,11 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.labels') do
click_link 'Edit'
- wait_for_ajax
+ wait_for_requests
click_link stretch.title
- wait_for_vue_resource
+ wait_for_requests
find('.dropdown-menu-close-icon').click
@@ -293,7 +306,7 @@ describe 'Issue Boards', feature: true, js: true do
end
end
- expect(card).not_to have_selector('.label')
+ expect(card).to have_selector('.label', count: 1)
expect(card).not_to have_content(stretch.title)
end
end
@@ -304,7 +317,7 @@ describe 'Issue Boards', feature: true, js: true do
page.within('.subscription') do
click_button 'Subscribe'
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content("Unsubscribe")
end
end
diff --git a/spec/features/boards/sub_group_project_spec.rb b/spec/features/boards/sub_group_project_spec.rb
new file mode 100644
index 00000000000..11a54079f4f
--- /dev/null
+++ b/spec/features/boards/sub_group_project_spec.rb
@@ -0,0 +1,43 @@
+require 'rails_helper'
+
+describe 'Sub-group project issue boards', :js do
+ let(:group) { create(:group) }
+ let(:nested_group_1) { create(:group, parent: group) }
+ let(:project) { create(:project, group: nested_group_1) }
+ let(:board) { create(:board, project: project) }
+ let(:label) { create(:label, project: project) }
+ let(:user) { create(:user) }
+ let!(:list1) { create(:list, board: board, label: label, position: 0) }
+ let!(:issue) { create(:labeled_issue, project: project, labels: [label]) }
+
+ before do
+ project.add_master(user)
+
+ sign_in(user)
+
+ visit project_board_path(project, board)
+ wait_for_requests
+ end
+
+ it 'creates new label from sidebar' do
+ find('.card').click
+
+ page.within '.labels' do
+ click_link 'Edit'
+ click_link 'Create new label'
+ end
+
+ page.within '.dropdown-new-label' do
+ fill_in 'new_label_name', with: 'test label'
+ first('.suggest-colors-dropdown a').click
+
+ click_button 'Create'
+
+ wait_for_requests
+ end
+
+ page.within '.labels' do
+ expect(page).to have_link 'test label'
+ end
+ end
+end
diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb
index 496faf87a16..9a597a2d690 100644
--- a/spec/features/calendar_spec.rb
+++ b/spec/features/calendar_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Contributions Calendar', :feature, :js do
+feature 'Contributions Calendar', :js do
let(:user) { create(:user) }
- let(:contributed_project) { create(:empty_project, :public) }
+ let(:contributed_project) { create(:project, :public) }
let(:issue_note) { create(:note, project: contributed_project) }
# Ex/ Sunday Jan 1, 2016
@@ -42,14 +42,14 @@ feature 'Contributions Calendar', :feature, :js do
end
def push_code_contribution
- push_params = {
- project: contributed_project,
- action: Event::PUSHED,
- author_id: user.id,
- data: { commit_count: 3 }
- }
-
- Event.create(push_params)
+ event = create(:push_event, project: contributed_project, author: user)
+
+ create(:push_event_payload,
+ event: event,
+ commit_from: '11f9ac0a48b62cef25eedede4c1819964f08d5ce',
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 3,
+ ref: 'master')
end
def note_comment_contribution
@@ -68,13 +68,13 @@ feature 'Contributions Calendar', :feature, :js do
end
before do
- login_as user
+ sign_in user
end
describe 'calendar day selection' do
before do
visit user.username
- wait_for_ajax
+ wait_for_requests
end
it 'displays calendar' do
@@ -86,7 +86,7 @@ feature 'Contributions Calendar', :feature, :js do
before do
cells[0].click
- wait_for_ajax
+ wait_for_requests
@first_day_activities = selected_day_activities
end
@@ -97,7 +97,7 @@ feature 'Contributions Calendar', :feature, :js do
describe 'select another calendar day' do
before do
cells[1].click
- wait_for_ajax
+ wait_for_requests
end
it 'displays different calendar day activities' do
@@ -108,7 +108,7 @@ feature 'Contributions Calendar', :feature, :js do
describe 'deselect calendar day' do
before do
cells[0].click
- wait_for_ajax
+ wait_for_requests
end
it 'hides calendar day activities' do
@@ -122,7 +122,7 @@ feature 'Contributions Calendar', :feature, :js do
shared_context 'visit user page' do
before do
visit user.username
- wait_for_ajax
+ wait_for_requests
end
end
diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb
index 3ebc432206a..af4cc00162a 100644
--- a/spec/features/ci_lint_spec.rb
+++ b/spec/features/ci_lint_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'CI Lint', js: true do
before do
- login_as :user
+ sign_in(create(:user))
end
describe 'YAML parsing' do
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index e6c4ab24de5..0c9fcc60d30 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -4,10 +4,11 @@ describe 'Commits' do
include CiStatusHelper
let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
describe 'CI' do
before do
- login_as :user
+ sign_in(user)
stub_ci_pipeline_to_return_yaml_file
end
@@ -27,7 +28,7 @@ describe 'Commits' do
let!(:status) { create(:generic_commit_status, pipeline: pipeline) }
before do
- project.team << [@user, :reporter]
+ project.team << [user, :reporter]
end
describe 'Commit builds' do
@@ -52,7 +53,7 @@ describe 'Commits' do
context 'when logged as developer' do
before do
- project.team << [@user, :developer]
+ project.team << [user, :developer]
end
describe 'Project commits' do
@@ -65,7 +66,7 @@ describe 'Commits' do
end
before do
- visit namespace_project_commits_path(project.namespace, project, :master)
+ visit project_commits_path(project, :master)
end
it 'shows correct build status from default branch' do
@@ -76,7 +77,7 @@ describe 'Commits' do
end
end
- describe 'Commit builds' do
+ describe 'Commit builds', :js do
before do
visit ci_status_path(pipeline)
end
@@ -85,7 +86,6 @@ describe 'Commits' do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
- expect(page).to have_content pipeline.created_at.strftime('%b %d, %Y')
end
end
@@ -102,7 +102,7 @@ describe 'Commits' do
end
describe 'Cancel all builds' do
- it 'cancels commit' do
+ it 'cancels commit', :js do
visit ci_status_path(pipeline)
click_on 'Cancel running'
expect(page).to have_content 'canceled'
@@ -110,9 +110,9 @@ describe 'Commits' do
end
describe 'Cancel build' do
- it 'cancels build' do
+ it 'cancels build', :js do
visit ci_status_path(pipeline)
- find('a.btn[title="Cancel"]').click
+ find('.js-btn-cancel-pipeline').click
expect(page).to have_content 'canceled'
end
end
@@ -147,22 +147,25 @@ describe 'Commits' do
context "when logged as reporter" do
before do
- project.team << [@user, :reporter]
+ project.team << [user, :reporter]
build.update_attributes(artifacts_file: artifacts_file)
visit ci_status_path(pipeline)
end
- it do
+ it 'Renders header', :js do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
- expect(page).to have_link('Download artifacts')
expect(page).not_to have_link('Cancel running')
expect(page).not_to have_link('Retry')
end
+
+ it do
+ expect(page).to have_link('Download artifacts')
+ end
end
- context 'when accessing internal project with disallowed access' do
+ context 'when accessing internal project with disallowed access', :js do
before do
project.update(
visibility_level: Gitlab::VisibilityLevel::INTERNAL,
@@ -175,7 +178,7 @@ describe 'Commits' do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message
expect(page).to have_content pipeline.user.name
- expect(page).not_to have_link('Download artifacts')
+
expect(page).not_to have_link('Cancel running')
expect(page).not_to have_link('Retry')
end
@@ -185,12 +188,11 @@ describe 'Commits' do
context 'viewing commits for a branch' do
let(:branch_name) { 'master' }
- let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_commits_path(project.namespace, project, branch_name)
+ sign_in(user)
+ visit project_commits_path(project, branch_name)
end
it 'includes the committed_date for each commit' do
@@ -201,4 +203,105 @@ describe 'Commits' do
end
end
end
+
+ describe 'GPG signed commits', :js do
+ it 'changes from unverified to verified when the user changes his email to match the gpg key' do
+ user = create :user, email: 'unrelated.user@example.org'
+ project.team << [user, :master]
+
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ sign_in(user)
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).not_to have_content 'Verified'
+ end
+
+ # user changes his email which makes the gpg key verified
+ Sidekiq::Testing.inline! do
+ user.skip_reconfirmation!
+ user.update_attributes!(email: GpgHelpers::User1.emails.first)
+ end
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).to have_content 'Verified'
+ end
+ end
+
+ it 'changes from unverified to verified when the user adds the missing gpg key' do
+ user = create :user, email: GpgHelpers::User1.emails.first
+ project.team << [user, :master]
+
+ sign_in(user)
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).not_to have_content 'Verified'
+ end
+
+ # user adds the gpg key which makes the signature valid
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).to have_content 'Verified'
+ end
+ end
+
+ it 'shows popover badges' do
+ gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard'
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user
+ end
+
+ user = create :user
+ project.team << [user, :master]
+
+ sign_in(user)
+ visit project_commits_path(project, :'signed-commits')
+
+ # unverified signature
+ click_on 'Unverified', match: :first
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with an unverified signature.'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
+ end
+
+ # verified and the gpg user has a gitlab profile
+ click_on 'Verified', match: :first
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with a verified signature.'
+ expect(page).to have_content 'Nannie Bernhard'
+ expect(page).to have_content '@nannie.bernhard'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
+ end
+
+ # verified and the gpg user's profile doesn't exist anymore
+ gpg_user.destroy!
+
+ visit project_commits_path(project, :'signed-commits')
+
+ click_on 'Verified', match: :first
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with a verified signature.'
+ expect(page).to have_content 'Nannie Bernhard'
+ expect(page).to have_content 'nannie.bernhard@example.com'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
+ end
+ end
+ end
end
diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb
index b86609e07c5..ae39ba4da6b 100644
--- a/spec/features/container_registry_spec.rb
+++ b/spec/features/container_registry_spec.rb
@@ -2,14 +2,14 @@ require 'spec_helper'
describe "Container Registry" do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:container_repository) do
create(:container_repository, name: 'my/image')
end
before do
- login_as(user)
+ sign_in(user)
project.add_developer(user)
stub_container_registry_config(enabled: true)
stub_container_registry_tags(repository: :any, tags: [])
@@ -19,7 +19,7 @@ describe "Container Registry" do
scenario 'user visits container registry main page' do
visit_container_registry
- expect(page).to have_content 'No container image repositories'
+ expect(page).to have_content 'No container images'
end
end
@@ -55,7 +55,6 @@ describe "Container Registry" do
end
def visit_container_registry
- visit namespace_project_container_registry_index_path(
- project.namespace, project)
+ visit project_container_registry_index_path(project)
end
end
diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb
index 344e31e5ef5..3e6a27eafd8 100644
--- a/spec/features/copy_as_gfm_spec.rb
+++ b/spec/features/copy_as_gfm_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'Copy as GFM', feature: true, js: true do
+describe 'Copy as GFM', js: true do
include MarkupHelper
include RepoHelpers
include ActionView::Helpers::JavaScriptHelper
before do
- login_as :admin
+ sign_in(create(:admin))
end
describe 'Copying rendered GFM' do
@@ -16,7 +16,7 @@ describe 'Copy as GFM', feature: true, js: true do
# `markdown` helper expects a `@project` variable
@project = @feat.project
- visit namespace_project_issue_path(@project.namespace, @project, @feat.issue)
+ visit project_issue_path(@project, @feat.issue)
end
# The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML.
@@ -51,7 +51,6 @@ describe 'Copy as GFM', feature: true, js: true do
To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/).
-
- Manage Git repositories with fine grained access controls that keep your code secure
- Perform code reviews and enhance collaboration with merge requests
@@ -66,6 +65,38 @@ describe 'Copy as GFM', feature: true, js: true do
GFM
)
+ aggregate_failures('an accidentally selected empty element') do
+ gfm = '# Heading1'
+
+ html = <<-HTML.strip_heredoc
+ <h1>Heading1</h1>
+
+ <h2></h2>
+ HTML
+
+ output_gfm = html_to_gfm(html)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+
+ aggregate_failures('an accidentally selected other element') do
+ gfm = 'Test comment with **Markdown!**'
+
+ html = <<-HTML.strip_heredoc
+ <li class="note">
+ <div class="md">
+ <p>
+ Test comment with <strong>Markdown!</strong>
+ </p>
+ </div>
+ </li>
+
+ <li class="note"></li>
+ HTML
+
+ output_gfm = html_to_gfm(html)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+
verify(
'InlineDiffFilter',
@@ -90,13 +121,13 @@ describe 'Copy as GFM', feature: true, js: true do
# full issue reference
@feat.issue.to_reference(full: true),
# issue URL
- namespace_project_issue_url(@project.namespace, @project, @feat.issue),
+ project_issue_url(@project, @feat.issue),
# issue URL with note anchor
- namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'),
+ project_issue_url(@project, @feat.issue, anchor: 'note_123'),
# issue link
- "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})",
+ "[Issue](#{project_issue_url(@project, @feat.issue)})",
# issue link with note anchor
- "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})",
+ "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})"
)
verify(
@@ -352,7 +383,6 @@ describe 'Copy as GFM', feature: true, js: true do
<<-GFM.strip_heredoc,
- Nested
-
- Lists
GFM
@@ -375,7 +405,6 @@ describe 'Copy as GFM', feature: true, js: true do
<<-GFM.strip_heredoc,
1. Nested
-
1. Numbered lists
GFM
@@ -437,7 +466,7 @@ describe 'Copy as GFM', feature: true, js: true do
context 'from a diff' do
before do
- visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
+ visit project_commit_path(project, sample_commit.id)
end
context 'selecting one word of text' do
@@ -478,7 +507,8 @@ describe 'Copy as GFM', feature: true, js: true do
context 'from a blob' do
before do
- visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb'))
+ visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb'))
+ wait_for_requests
end
context 'selecting one word of text' do
@@ -519,7 +549,8 @@ describe 'Copy as GFM', feature: true, js: true do
context 'from a GFM code block' do
before do
- visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md'))
+ visit project_blob_path(project, File.join('markdown', 'doc/api/users.md'))
+ wait_for_requests
end
context 'selecting one word of text' do
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index b93275c330b..bfe9dac3bd4 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -1,27 +1,35 @@
require 'spec_helper'
-feature 'Cycle Analytics', feature: true, js: true do
+feature 'Cycle Analytics', js: true do
let(:user) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
- let(:mr) { create_merge_request_closing_issue(issue) }
- let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) }
+ let(:mr) { create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") }
+ let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
context 'as an allowed user' do
context 'when project is new' do
before do
- project.team << [user, :master]
- login_as(user)
- visit namespace_project_cycle_analytics_path(project.namespace, project)
- wait_for_ajax
+ project.add_master(user)
+
+ sign_in(user)
+
+ visit project_cycle_analytics_path(project)
+ wait_for_requests
end
it 'shows introductory message' do
expect(page).to have_content('Introducing Cycle Analytics')
end
+ it 'shows pipeline summary' do
+ expect(new_issues_counter).to have_content('-')
+ expect(commits_counter).to have_content('-')
+ expect(deploys_counter).to have_content('-')
+ end
+
it 'shows active stage with empty message' do
expect(page).to have_selector('.stage-nav-item.active', text: 'Issue')
expect(page).to have_content("We don't have enough data to show this stage.")
@@ -30,14 +38,20 @@ feature 'Cycle Analytics', feature: true, js: true do
context "when there's cycle analytics data" do
before do
- project.team << [user, :master]
-
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
+ project.add_master(user)
+
create_cycle
deploy_master
- login_as(user)
- visit namespace_project_cycle_analytics_path(project.namespace, project)
+ sign_in(user)
+ visit project_cycle_analytics_path(project)
+ end
+
+ it 'shows pipeline summary' do
+ expect(new_issues_counter).to have_content('1')
+ expect(commits_counter).to have_content('2')
+ expect(deploys_counter).to have_content('1')
end
it 'shows data on each stage' do
@@ -61,20 +75,53 @@ feature 'Cycle Analytics', feature: true, js: true do
click_stage('Production')
expect_issue_to_be_present
end
+
+ context "when I change the time period observed" do
+ before do
+ _two_weeks_old_issue = create(:issue, project: project, created_at: 2.weeks.ago)
+
+ click_button('Last 30 days')
+ click_link('Last 7 days')
+ wait_for_requests
+ end
+
+ it 'shows only relevant data' do
+ expect(new_issues_counter).to have_content('1')
+ end
+ end
+ end
+
+ context "when my preferred language is Spanish" do
+ before do
+ user.update_attribute(:preferred_language, 'es')
+
+ project.team << [user, :master]
+ sign_in(user)
+ visit project_cycle_analytics_path(project)
+ wait_for_requests
+ end
+
+ it 'shows the content in Spanish' do
+ expect(page).to have_content('Estado del Pipeline')
+ end
+
+ it 'resets the language to English' do
+ expect(I18n.locale).to eq(:en)
+ end
end
end
context "as a guest" do
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
create_cycle
deploy_master
- login_as(guest)
- visit namespace_project_cycle_analytics_path(project.namespace, project)
- wait_for_ajax
+ sign_in(guest)
+ visit project_cycle_analytics_path(project)
+ wait_for_requests
end
it 'needs permissions to see restricted stages' do
@@ -88,6 +135,18 @@ feature 'Cycle Analytics', feature: true, js: true do
end
end
+ def new_issues_counter
+ find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3")
+ end
+
+ def commits_counter
+ find(:xpath, "//p[contains(text(),'Commits')]/preceding-sibling::h3")
+ end
+
+ def deploys_counter
+ find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3")
+ end
+
def expect_issue_to_be_present
expect(find('.stage-events')).to have_content(issue.title)
expect(find('.stage-events')).to have_content(issue.author.name)
@@ -118,6 +177,6 @@ feature 'Cycle Analytics', feature: true, js: true do
def click_stage(stage_name)
find('.stage-nav li', text: stage_name).click
- wait_for_ajax
+ wait_for_requests
end
end
diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb
index ae750be4d4a..067e4337e6a 100644
--- a/spec/features/dashboard/active_tab_spec.rb
+++ b/spec/features/dashboard/active_tab_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Active Tab', js: true, feature: true do
+RSpec.describe 'Dashboard Active Tab', js: true do
before do
- login_as :user
+ sign_in(create(:user))
end
shared_examples 'page has active tab' do |title|
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb
index c977f266296..582868bac1e 100644
--- a/spec/features/dashboard/activity_spec.rb
+++ b/spec/features/dashboard/activity_spec.rb
@@ -1,11 +1,154 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Activity', feature: true do
+feature 'Dashboard > Activity' do
+ let(:user) { create(:user) }
+
before do
- login_as(create :user)
- visit activity_dashboard_path
+ sign_in(user)
+ end
+
+ context 'rss' do
+ before do
+ visit activity_dashboard_path
+ end
+
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
+ end
+
+ context 'event filters', :js do
+ let(:project) { create(:project) }
+
+ let(:merge_request) do
+ create(:merge_request, author: user, source_project: project, target_project: project)
+ end
+
+ let(:note) { create(:note, project: project, noteable: merge_request) }
+
+ let!(:push_event) do
+ event = create(:push_event, project: project, author: user)
+
+ create(:push_event_payload,
+ event: event,
+ action: :created,
+ commit_to: '0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e',
+ ref: 'new_design',
+ commit_count: 1)
+
+ event
+ end
+
+ let!(:merged_event) do
+ create(:event, :merged, project: project, target: merge_request, author: user)
+ end
+
+ let!(:joined_event) do
+ create(:event, :joined, project: project, author: user)
+ end
+
+ let!(:closed_event) do
+ create(:event, :closed, project: project, target: merge_request, author: user)
+ end
+
+ let!(:comments_event) do
+ create(:event, :commented, project: project, target: note, author: user)
+ end
+
+ before do
+ project.add_master(user)
+
+ visit activity_dashboard_path
+ wait_for_requests
+ end
+
+ scenario 'user should see all events' do
+ within '.content_list' do
+ expect(page).to have_content('pushed new branch')
+ expect(page).to have_content('joined')
+ expect(page).to have_content('accepted')
+ expect(page).to have_content('closed')
+ expect(page).to have_content('commented on')
+ end
+ end
+
+ scenario 'user should see only pushed events' do
+ click_link('Push events')
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).to have_content('pushed new branch')
+ expect(page).not_to have_content('joined')
+ expect(page).not_to have_content('accepted')
+ expect(page).not_to have_content('closed')
+ expect(page).not_to have_content('commented on')
+ end
+ end
+
+ scenario 'user should see only merged events' do
+ click_link('Merge events')
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).not_to have_content('pushed new branch')
+ expect(page).not_to have_content('joined')
+ expect(page).to have_content('accepted')
+ expect(page).not_to have_content('closed')
+ expect(page).not_to have_content('commented on')
+ end
+ end
+
+ scenario 'user should see only issues events' do
+ click_link('Issue events')
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).not_to have_content('pushed new branch')
+ expect(page).not_to have_content('joined')
+ expect(page).not_to have_content('accepted')
+ expect(page).to have_content('closed')
+ expect(page).not_to have_content('commented on')
+ end
+ end
+
+ scenario 'user should see only comments events' do
+ click_link('Comments')
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).not_to have_content('pushed new branch')
+ expect(page).not_to have_content('joined')
+ expect(page).not_to have_content('accepted')
+ expect(page).not_to have_content('closed')
+ expect(page).to have_content('commented on')
+ end
+ end
+
+ scenario 'user should see only joined events' do
+ click_link('Team')
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).not_to have_content('pushed new branch')
+ expect(page).to have_content('joined')
+ expect(page).not_to have_content('accepted')
+ expect(page).not_to have_content('closed')
+ expect(page).not_to have_content('commented on')
+ end
+ end
+
+ scenario 'user see selected event after page reloading' do
+ click_link('Push events')
+ wait_for_requests
+ visit activity_dashboard_path
+ wait_for_requests
+
+ within '.content_list' do
+ expect(page).to have_content('pushed new branch')
+ expect(page).not_to have_content('joined')
+ expect(page).not_to have_content('accepted')
+ expect(page).not_to have_content('closed')
+ expect(page).not_to have_content('commented on')
+ end
+ end
end
-
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
end
diff --git a/spec/features/dashboard/archived_projects_spec.rb b/spec/features/dashboard/archived_projects_spec.rb
index f33bcbb5318..814ec0e59c7 100644
--- a/spec/features/dashboard/archived_projects_spec.rb
+++ b/spec/features/dashboard/archived_projects_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Archived Project', feature: true do
+RSpec.describe 'Dashboard Archived Project' do
let(:user) { create :user }
let(:project) { create :project}
let(:archived_project) { create(:project, :archived) }
@@ -9,7 +9,7 @@ RSpec.describe 'Dashboard Archived Project', feature: true do
project.team << [user, :master]
archived_project.team << [user, :master]
- login_as(user)
+ sign_in(user)
visit dashboard_projects_path
end
diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb
index 0e9e3f78be2..b6dce1b8ec4 100644
--- a/spec/features/dashboard/datetime_on_tooltips_spec.rb
+++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Tooltips on .timeago dates', feature: true, js: true do
+feature 'Tooltips on .timeago dates', js: true do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
let(:created_date) { Date.yesterday.to_time }
- let(:expected_format) { created_date.strftime('%b %-d, %Y %l:%M%P') }
+ let(:expected_format) { created_date.in_time_zone.strftime('%b %-d, %Y %l:%M%P') }
context 'on the activity tab' do
before do
@@ -13,9 +13,9 @@ feature 'Tooltips on .timeago dates', feature: true, js: true do
Event.create( project: project, author_id: user.id, action: Event::JOINED,
updated_at: created_date, created_at: created_date)
- login_as user
+ sign_in user
visit user_path(user)
- wait_for_ajax()
+ wait_for_requests()
page.find('.js-timeago').hover
end
@@ -30,9 +30,9 @@ feature 'Tooltips on .timeago dates', feature: true, js: true do
project.team << [user, :master]
create(:snippet, author: user, updated_at: created_date, created_at: created_date)
- login_as user
+ sign_in user
visit user_snippets_path(user)
- wait_for_ajax()
+ wait_for_requests()
page.find('.js-timeago.snippet-created-ago').hover
end
diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb
index 1d4b86ed4b4..60a16830cdc 100644
--- a/spec/features/dashboard/group_spec.rb
+++ b/spec/features/dashboard/group_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Group', feature: true do
+RSpec.describe 'Dashboard Group' do
before do
- login_as(:user)
+ sign_in(create(:user))
end
it 'creates new group', js: true do
visit dashboard_groups_path
- click_link 'New group'
+ find('.btn-new').trigger('click')
new_path = 'Samurai'
new_description = 'Tokugawa Shogunate'
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index 52b4d82e856..533df7a325c 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -1,45 +1,129 @@
require 'spec_helper'
-describe 'Dashboard Groups page', js: true, feature: true do
+feature 'Dashboard Groups page', :js do
let!(:user) { create :user }
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, :nested) }
let!(:another_group) { create(:group) }
- before do
+ it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
- login_as(user)
-
+ sign_in(user)
visit dashboard_groups_path
- end
- it 'shows groups user is member of' do
expect(page).to have_content(group.full_name)
expect(page).to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name)
end
- it 'filters groups' do
- fill_in 'filter_groups', with: group.name
- wait_for_ajax
+ describe 'when filtering groups' do
+ before do
+ group.add_owner(user)
+ nested_group.add_owner(user)
- expect(page).to have_content(group.full_name)
- expect(page).not_to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ sign_in(user)
+
+ visit dashboard_groups_path
+ end
+
+ it 'filters groups' do
+ fill_in 'filter_groups', with: group.name
+ wait_for_requests
+
+ expect(page).to have_content(group.full_name)
+ expect(page).not_to have_content(nested_group.full_name)
+ expect(page).not_to have_content(another_group.full_name)
+ end
+
+ it 'resets search when user cleans the input' do
+ fill_in 'filter_groups', with: group.name
+ wait_for_requests
+
+ fill_in 'filter_groups', with: ''
+ wait_for_requests
+
+ expect(page).to have_content(group.full_name)
+ expect(page).to have_content(nested_group.full_name)
+ expect(page).not_to have_content(another_group.full_name)
+ expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
+ end
end
- it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
- wait_for_ajax
+ describe 'group with subgroups' do
+ let!(:subgroup) { create(:group, :public, parent: group) }
- fill_in 'filter_groups', with: ""
- wait_for_ajax
+ before do
+ group.add_owner(user)
+ subgroup.add_owner(user)
- expect(page).to have_content(group.full_name)
- expect(page).to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
- expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
+ sign_in(user)
+
+ visit dashboard_groups_path
+ end
+
+ it 'shows subgroups inside of its parent group' do
+ expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
+ expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
+ end
+
+ it 'can toggle parent group' do
+ # Expanded by default
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
+
+ # Collapse
+ find("#group-#{group.id}").trigger('click')
+
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
+ expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+
+ # Expand
+ find("#group-#{group.id}").trigger('click')
+
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+ end
+ end
+
+ describe 'when using pagination' do
+ let(:group2) { create(:group) }
+
+ before do
+ group.add_owner(user)
+ group2.add_owner(user)
+
+ allow(Kaminari.config).to receive(:default_per_page).and_return(1)
+
+ sign_in(user)
+ visit dashboard_groups_path
+ end
+
+ it 'shows pagination' do
+ expect(page).to have_selector('.gl-pagination')
+ expect(page).to have_selector('.gl-pagination .page', count: 2)
+ end
+
+ it 'loads results for next page' do
+ # Check first page
+ expect(page).to have_content(group2.full_name)
+ expect(page).to have_selector("#group-#{group2.id}")
+ expect(page).not_to have_content(group.full_name)
+ expect(page).not_to have_selector("#group-#{group.id}")
+
+ # Go to next page
+ find(".gl-pagination .page:not(.active) a").trigger('click')
+
+ wait_for_requests
+
+ # Check second page
+ expect(page).to have_content(group.full_name)
+ expect(page).to have_selector("#group-#{group.id}")
+ expect(page).not_to have_content(group2.full_name)
+ expect(page).not_to have_selector("#group-#{group2.id}")
+ end
end
end
diff --git a/spec/features/dashboard/help_spec.rb b/spec/features/dashboard/help_spec.rb
index 2803f7ec62b..68bfbf22736 100644
--- a/spec/features/dashboard/help_spec.rb
+++ b/spec/features/dashboard/help_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Help', feature: true do
+RSpec.describe 'Dashboard Help' do
before do
- login_as(:user)
+ sign_in(create(:user))
end
it 'renders correctly markdown' do
diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb
index 4fca7577e74..b431f72fcc9 100644
--- a/spec/features/dashboard/issuables_counter_spec.rb
+++ b/spec/features/dashboard/issuables_counter_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe 'Navigation bar counter', feature: true, caching: true do
+describe 'Navigation bar counter', :use_clean_rails_memory_store_caching do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
- issue.update(assignee: user)
+ issue.assignees = [user]
merge_request.update(assignee: user)
- login_as(user)
+ sign_in(user)
end
it 'reflects dashboard issues count' do
@@ -17,7 +17,9 @@ describe 'Navigation bar counter', feature: true, caching: true do
expect_counters('issues', '1')
- issue.update(assignee: nil)
+ issue.assignees = []
+
+ user.invalidate_cache_counts
Timecop.travel(3.minutes.from_now) do
visit issues_path
@@ -33,6 +35,8 @@ describe 'Navigation bar counter', feature: true, caching: true do
merge_request.update(assignee: nil)
+ user.invalidate_cache_counts
+
Timecop.travel(3.minutes.from_now) do
visit merge_requests_path
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
new file mode 100644
index 00000000000..facb67ae787
--- /dev/null
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -0,0 +1,115 @@
+require 'spec_helper'
+
+feature 'Dashboard Issues filtering', :js do
+ include SortingHelper
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:milestone) { create(:milestone, project: project) }
+
+ let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
+ let!(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit_issues
+ end
+
+ context 'filtering by milestone' do
+ it 'shows all issues with no milestone' do
+ show_milestone_dropdown
+
+ click_link 'No Milestone'
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_selector('.issue', count: 1)
+ end
+
+ it 'shows all issues with any milestone' do
+ show_milestone_dropdown
+
+ click_link 'Any Milestone'
+
+ expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
+ expect(page).to have_selector('.issue', count: 2)
+ end
+
+ it 'shows all issues with the selected milestone' do
+ show_milestone_dropdown
+
+ page.within '.dropdown-content' do
+ click_link milestone.title
+ end
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_selector('.issue', count: 1)
+ end
+
+ it 'updates atom feed link' do
+ visit_issues(milestone_title: '', assignee_id: user.id)
+
+ link = find('.nav-controls a[title="Subscribe"]')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expect(params).to include('rss_token' => [user.rss_token])
+ expect(params).to include('milestone_title' => [''])
+ expect(params).to include('assignee_id' => [user.id.to_s])
+ expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
+ expect(auto_discovery_params).to include('milestone_title' => [''])
+ expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ end
+ end
+
+ context 'filtering by label' do
+ let(:label) { create(:label, project: project) }
+ let!(:label_link) { create(:label_link, label: label, target: issue) }
+
+ it 'shows all issues without filter' do
+ page.within 'ul.content-list' do
+ expect(page).to have_content issue.title
+ expect(page).to have_content issue2.title
+ end
+ end
+
+ it 'shows all issues with the selected label' do
+ page.within '.labels-filter' do
+ find('.dropdown').click
+ click_link label.title
+ end
+
+ page.within 'ul.content-list' do
+ expect(page).to have_content issue.title
+ expect(page).not_to have_content issue2.title
+ end
+ end
+ end
+
+ context 'sorting' do
+ it 'shows sorted issues' do
+ sorting_by('Oldest updated')
+ visit_issues
+
+ expect(find('.issues-filters')).to have_content('Oldest updated')
+ end
+
+ it 'keeps sorting issues after visiting Projects Issues page' do
+ sorting_by('Oldest updated')
+ visit project_issues_path(project)
+
+ expect(find('.issues-filters')).to have_content('Oldest updated')
+ end
+ end
+
+ def show_milestone_dropdown
+ click_button 'Milestone'
+ expect(page).to have_selector('.dropdown-content', visible: true)
+ end
+
+ def visit_issues(*args)
+ visit issues_dashboard_path(*args)
+ end
+end
diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb
index f4420814c3a..795335aa106 100644
--- a/spec/features/dashboard/issues_spec.rb
+++ b/spec/features/dashboard/issues_spec.rb
@@ -1,51 +1,103 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Issues', feature: true do
+RSpec.describe 'Dashboard Issues' do
let(:current_user) { create :user }
- let(:public_project) { create(:empty_project, :public) }
- let(:project) do
- create(:empty_project) do |project|
- project.team << [current_user, :master]
- end
- end
-
+ let(:user) { current_user } # Shared examples depend on this being available
+ let!(:public_project) { create(:project, :public) }
+ let(:project) { create(:project) }
+ let(:project_with_issues_disabled) { create(:project, :issues_disabled) }
let!(:authored_issue) { create :issue, author: current_user, project: project }
let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project }
- let!(:assigned_issue) { create :issue, assignee: current_user, project: project }
+ let!(:assigned_issue) { create :issue, assignees: [current_user], project: project }
let!(:other_issue) { create :issue, project: project }
before do
- login_as(current_user)
-
+ [project, project_with_issues_disabled].each { |project| project.team << [current_user, :master] }
+ sign_in(current_user)
visit issues_dashboard_path(assignee_id: current_user.id)
end
- it 'shows issues assigned to current user' do
- expect(page).to have_content(assigned_issue.title)
- expect(page).not_to have_content(authored_issue.title)
- expect(page).not_to have_content(other_issue.title)
- end
+ describe 'issues' do
+ it 'shows issues assigned to current user' do
+ expect(page).to have_content(assigned_issue.title)
+ expect(page).not_to have_content(authored_issue.title)
+ expect(page).not_to have_content(other_issue.title)
+ end
- it 'shows issues when current user is author', js: true do
- find('#assignee_id', visible: false).set('')
- find('.js-author-search', match: :first).click
- find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click
+ it 'shows checkmark when unassigned is selected for assignee', js: true do
+ find('.js-assignee-search').click
+ find('li', text: 'Unassigned').click
+ find('.js-assignee-search').click
- expect(page).to have_content(authored_issue.title)
- expect(page).to have_content(authored_issue_on_public_project.title)
- expect(page).not_to have_content(assigned_issue.title)
- expect(page).not_to have_content(other_issue.title)
- end
+ expect(find('li[data-user-id="0"] a.is-active')).to be_visible
+ end
- it 'shows all issues' do
- click_link('Reset filters')
+ it 'shows issues when current user is author', js: true do
+ find('#assignee_id', visible: false).set('')
+ find('.js-author-search', match: :first).click
- expect(page).to have_content(authored_issue.title)
- expect(page).to have_content(authored_issue_on_public_project.title)
- expect(page).to have_content(assigned_issue.title)
- expect(page).to have_content(other_issue.title)
+ expect(find('li[data-user-id="null"] a.is-active')).to be_visible
+
+ find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click
+ find('.js-author-search', match: :first).click
+
+ page.within '.dropdown-menu-user' do
+ expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible
+ end
+
+ expect(page).to have_content(authored_issue.title)
+ expect(page).to have_content(authored_issue_on_public_project.title)
+ expect(page).not_to have_content(assigned_issue.title)
+ expect(page).not_to have_content(other_issue.title)
+ end
+
+ it 'shows all issues' do
+ click_link('Reset filters')
+
+ expect(page).to have_content(authored_issue.title)
+ expect(page).to have_content(authored_issue_on_public_project.title)
+ expect(page).to have_content(assigned_issue.title)
+ expect(page).to have_content(other_issue.title)
+ end
+
+ it 'state filter tabs work' do
+ find('#state-closed').click
+ expect(page).to have_current_path(issues_dashboard_url(assignee_id: current_user.id, state: 'closed'), url: true)
+ end
+
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ describe 'new issue dropdown' do
+ it 'shows projects only with issues feature enabled', js: true do
+ find('.new-project-item-select-button').trigger('click')
+
+ page.within('.select2-results') do
+ expect(page).to have_content(project.name_with_namespace)
+ expect(page).not_to have_content(project_with_issues_disabled.name_with_namespace)
+ end
+ end
+
+ it 'shows the new issue page', js: true do
+ find('.new-project-item-select-button').trigger('click')
+
+ wait_for_requests
+
+ project_path = "/#{project.path_with_namespace}"
+ project_json = { name: project.name_with_namespace, url: project_path }.to_json
+
+ # similate selection, and prevent overlap by dropdown menu
+ execute_script("$('.project-item-select').val('#{project_json}').trigger('change');")
+ execute_script("$('#select2-drop-mask').remove();")
+
+ find('.new-project-item-link').trigger('click')
+
+ expect(page).to have_current_path("#{project_path}/issues/new")
+
+ page.within('#content-body') do
+ expect(page).to have_selector('.issue-form')
+ end
+ end
+ end
end
diff --git a/spec/features/dashboard/label_filter_spec.rb b/spec/features/dashboard/label_filter_spec.rb
index 4cff12de854..b1a207682c3 100644
--- a/spec/features/dashboard/label_filter_spec.rb
+++ b/spec/features/dashboard/label_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Dashboard > label filter', feature: true, js: true do
+describe 'Dashboard > label filter', js: true do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
let(:project2) { create(:project, name: 'test2', path: 'test2', namespace: user.namespace) }
@@ -11,7 +11,7 @@ describe 'Dashboard > label filter', feature: true, js: true do
project.labels << label
project2.labels << label2
- login_as(user)
+ sign_in(user)
visit issues_dashboard_path
end
diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb
index 508ca38d7e5..b4992dd54a1 100644
--- a/spec/features/dashboard/merge_requests_spec.rb
+++ b/spec/features/dashboard/merge_requests_spec.rb
@@ -1,32 +1,130 @@
require 'spec_helper'
-describe 'Dashboard Merge Requests' do
+feature 'Dashboard Merge Requests' do
+ include FilterItemSelectHelper
+ include SortingHelper
+
let(:current_user) { create :user }
- let(:project) do
- create(:empty_project) do |project|
- project.add_master(current_user)
- end
- end
+ let(:project) { create(:project) }
+
+ let(:public_project) { create(:project, :public, :repository) }
+ let(:forked_project) { Projects::ForkService.new(public_project, current_user).execute }
before do
- login_as(current_user)
+ project.add_master(current_user)
+ sign_in(current_user)
+ end
+
+ context 'new merge request dropdown' do
+ let(:project_with_disabled_merge_requests) { create(:project, :merge_requests_disabled) }
+
+ before do
+ project_with_disabled_merge_requests.add_master(current_user)
+ visit merge_requests_dashboard_path
+ end
+
+ it 'shows projects only with merge requests feature enabled', js: true do
+ find('.new-project-item-select-button').trigger('click')
+
+ page.within('.select2-results') do
+ expect(page).to have_content(project.name_with_namespace)
+ expect(page).not_to have_content(project_with_disabled_merge_requests.name_with_namespace)
+ end
+ end
end
- it 'should show an empty state' do
- visit merge_requests_dashboard_path(assignee_id: current_user.id)
+ context 'no merge requests exist' do
+ it 'shows an empty state' do
+ visit merge_requests_dashboard_path(assignee_id: current_user.id)
- expect(page).to have_selector('.empty-state')
+ expect(page).to have_selector('.empty-state')
+ end
end
- context 'if there are merge requests' do
+ context 'merge requests exist' do
+ let!(:assigned_merge_request) do
+ create(:merge_request, assignee: current_user, target_project: project, source_project: project)
+ end
+
+ let!(:assigned_merge_request_from_fork) do
+ create(:merge_request,
+ source_branch: 'markdown', assignee: current_user,
+ target_project: public_project, source_project: forked_project
+ )
+ end
+
+ let!(:authored_merge_request) do
+ create(:merge_request,
+ source_branch: 'markdown', author: current_user,
+ target_project: project, source_project: project
+ )
+ end
+
+ let!(:authored_merge_request_from_fork) do
+ create(:merge_request,
+ source_branch: 'feature_conflict',
+ author: current_user,
+ target_project: public_project, source_project: forked_project
+ )
+ end
+
+ let!(:other_merge_request) do
+ create(:merge_request,
+ source_branch: 'fix',
+ target_project: project, source_project: project
+ )
+ end
+
before do
- create(:merge_request, assignee: current_user, source_project: project)
+ visit merge_requests_dashboard_path(assignee_id: current_user.id)
+ end
+
+ it 'shows assigned merge requests' do
+ expect(page).to have_content(assigned_merge_request.title)
+ expect(page).to have_content(assigned_merge_request_from_fork.title)
+
+ expect(page).not_to have_content(authored_merge_request.title)
+ expect(page).not_to have_content(authored_merge_request_from_fork.title)
+ expect(page).not_to have_content(other_merge_request.title)
+ end
+
+ it 'shows authored merge requests', js: true do
+ filter_item_select('Any Assignee', '.js-assignee-search')
+ filter_item_select(current_user.to_reference, '.js-author-search')
+
+ expect(page).to have_content(authored_merge_request.title)
+ expect(page).to have_content(authored_merge_request_from_fork.title)
+
+ expect(page).not_to have_content(assigned_merge_request.title)
+ expect(page).not_to have_content(assigned_merge_request_from_fork.title)
+ expect(page).not_to have_content(other_merge_request.title)
+ end
+
+ it 'shows all merge requests', js: true do
+ filter_item_select('Any Assignee', '.js-assignee-search')
+ filter_item_select('Any Author', '.js-author-search')
+
+ expect(page).to have_content(authored_merge_request.title)
+ expect(page).to have_content(authored_merge_request_from_fork.title)
+ expect(page).to have_content(assigned_merge_request.title)
+ expect(page).to have_content(assigned_merge_request_from_fork.title)
+ expect(page).to have_content(other_merge_request.title)
+ end
+
+ it 'shows sorted merge requests' do
+ sorting_by('Oldest updated')
visit merge_requests_dashboard_path(assignee_id: current_user.id)
+
+ expect(find('.issues-filters')).to have_content('Oldest updated')
end
- it 'should not show an empty state' do
- expect(page).not_to have_selector('.empty-state')
+ it 'keeps sorting merge requests after visiting Projects MR page' do
+ sorting_by('Oldest updated')
+
+ visit project_merge_requests_path(project)
+
+ expect(find('.issues-filters')).to have_content('Oldest updated')
end
end
end
diff --git a/spec/features/dashboard/milestone_filter_spec.rb b/spec/features/dashboard/milestone_filter_spec.rb
new file mode 100644
index 00000000000..c965b565ca3
--- /dev/null
+++ b/spec/features/dashboard/milestone_filter_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+feature 'Dashboard > milestone filter', :js do
+ include FilterItemSelectHelper
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, name: 'test', namespace: user.namespace) }
+ let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
+ let(:milestone2) { create(:milestone, title: 'v2.0', project: project) }
+ let!(:issue) { create :issue, author: user, project: project, milestone: milestone }
+ let!(:issue2) { create :issue, author: user, project: project, milestone: milestone2 }
+
+ before do
+ sign_in(user)
+ visit issues_dashboard_path(author_id: user.id)
+ end
+
+ context 'default state' do
+ it 'shows issues with Any Milestone' do
+ page.all('.issue-info').each do |issue_info|
+ expect(issue_info.text).to match(/v\d.0/)
+ end
+ end
+ end
+
+ context 'filtering by milestone' do
+ milestone_select_selector = '.js-milestone-select'
+
+ before do
+ filter_item_select('v1.0', milestone_select_selector)
+ find(milestone_select_selector).click
+ wait_for_requests
+ end
+
+ it 'shows issues with Milestone v1.0' do
+ expect(find('.issues-list')).to have_selector('.issue', count: 1)
+ expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1)
+ end
+
+ it 'should not change active Milestone unless clicked' do
+ expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1)
+
+ # open & close dropdown
+ find('.dropdown-menu-close').click
+
+ expect(find('.milestone-filter')).not_to have_selector('.dropdown.open')
+
+ find(milestone_select_selector).click
+
+ expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1)
+ expect(find('.dropdown-content a.is-active')).to have_content('v1.0')
+ end
+ end
+end
diff --git a/spec/features/dashboard/milestone_tabs_spec.rb b/spec/features/dashboard/milestone_tabs_spec.rb
new file mode 100644
index 00000000000..6fcde35f541
--- /dev/null
+++ b/spec/features/dashboard/milestone_tabs_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe 'Dashboard milestone tabs', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let!(:label) { create(:label, project: project) }
+ let(:project_milestone) { create(:milestone, project: project) }
+ let(:milestone) do
+ DashboardMilestone.build(
+ [project],
+ project_milestone.title
+ )
+ end
+ let!(:merge_request) { create(:labeled_merge_request, source_project: project, target_project: project, milestone: project_milestone, labels: [label]) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit dashboard_milestone_path(milestone.safe_title, title: milestone.title)
+ end
+
+ it 'loads merge requests async' do
+ click_link 'Merge Requests'
+
+ expect(page).to have_selector('.milestone-merge_requests-list')
+ end
+
+ it 'loads participants async' do
+ click_link 'Participants'
+
+ expect(page).to have_selector('#tab-participants .bordered-list')
+ end
+
+ it 'loads labels async' do
+ click_link 'Labels'
+
+ expect(page).to have_selector('#tab-labels .bordered-list')
+ end
+end
diff --git a/spec/features/dashboard/milestones_spec.rb b/spec/features/dashboard/milestones_spec.rb
new file mode 100644
index 00000000000..41d37376cfb
--- /dev/null
+++ b/spec/features/dashboard/milestones_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+feature 'Dashboard > Milestones' do
+ describe 'as anonymous user' do
+ before do
+ visit dashboard_milestones_path
+ end
+
+ it 'is redirected to sign-in page' do
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+
+ describe 'as logged-in user' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let!(:milestone) { create(:milestone, project: project) }
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ visit dashboard_milestones_path
+ end
+
+ it 'sees milestones' do
+ expect(current_path).to eq dashboard_milestones_path
+ expect(page).to have_content(milestone.title)
+ end
+ 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 16c214ae060..4a004107408 100644
--- a/spec/features/dashboard/project_member_activity_index_spec.rb
+++ b/spec/features/dashboard/project_member_activity_index_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Project member activity', feature: true, js: true do
+feature 'Project member activity', js: true do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public, name: 'x', namespace: user.namespace) }
+ let(:project) { create(:project, :public, name: 'x', namespace: user.namespace) }
before do
project.team << [user, :master]
@@ -10,26 +10,32 @@ feature 'Project member activity', feature: true, js: true do
def visit_activities_and_wait_with_event(event_type)
Event.create(project: project, author_id: user.id, action: event_type)
- visit activity_namespace_project_path(project.namespace, project)
- wait_for_ajax
+ visit activity_project_path(project)
+ wait_for_requests
end
subject { page.find(".event-title").text }
context 'when a user joins the project' do
- before { visit_activities_and_wait_with_event(Event::JOINED) }
+ before do
+ visit_activities_and_wait_with_event(Event::JOINED)
+ end
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) }
+ before do
+ visit_activities_and_wait_with_event(Event::LEFT)
+ end
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) }
+ before do
+ visit_activities_and_wait_with_event(Event::EXPIRED)
+ end
it "presents the correct message" do
message = "#{user.name} removed due to membership expiration from project"
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index f1789fc9d43..06a43909053 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -1,12 +1,19 @@
require 'spec_helper'
-RSpec.describe 'Dashboard Projects', feature: true do
+feature 'Dashboard Projects' do
let(:user) { create(:user) }
- let(:project) { create(:project, name: "awesome stuff") }
+ let(:project) { create(:project, :repository, name: 'awesome stuff') }
+ let(:project2) { create(:project, :public, name: 'Community project') }
before do
project.team << [user, :developer]
- login_as user
+ sign_in(user)
+ end
+
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" do
+ before do
+ visit dashboard_projects_path
+ end
end
it 'shows the project the user in a member of in the list' do
@@ -14,8 +21,48 @@ RSpec.describe 'Dashboard Projects', feature: true do
expect(page).to have_content('awesome stuff')
end
- describe "with a pipeline", redis: true do
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) }
+ it 'shows "New project" button' do
+ visit dashboard_projects_path
+
+ page.within '#content-body' do
+ expect(page).to have_link('New project')
+ end
+ end
+
+ context 'when last_repository_updated_at, last_activity_at and update_at are present' do
+ it 'shows the last_repository_updated_at attribute as the update date' do
+ project.update_attributes!(last_repository_updated_at: Time.now, last_activity_at: 1.hour.ago)
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{project.last_repository_updated_at.getutc.iso8601}']")
+ end
+ end
+
+ context 'when last_repository_updated_at and last_activity_at are missing' do
+ it 'shows the updated_at attribute as the update date' do
+ project.update_attributes!(last_repository_updated_at: nil, last_activity_at: nil)
+ project.touch
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{project.updated_at.getutc.iso8601}']")
+ end
+ end
+
+ context 'when on Starred projects tab' do
+ it 'shows only starred projects' do
+ user.toggle_star(project2)
+
+ visit(starred_dashboard_projects_path)
+
+ expect(page).not_to have_content(project.name)
+ expect(page).to have_content(project2.name)
+ end
+ end
+
+ describe 'with a pipeline', clean_gitlab_redis_shared_state: true do
+ let(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) }
before do
# Since the cache isn't updated when a new pipeline is created
@@ -27,9 +74,50 @@ RSpec.describe 'Dashboard Projects', feature: true do
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)}']")
+ page.within('.controls') do
+ expect(page).to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit)}']")
+ expect(page).to have_css('.ci-status-link')
+ expect(page).to have_css('.ci-status-icon-success')
+ expect(page).to have_link('Commit: passed')
+ end
end
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ context 'last push widget' do
+ let(:push_event_data) do
+ {
+ before: Gitlab::Git::BLANK_SHA,
+ after: '0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e',
+ ref: 'refs/heads/feature',
+ user_id: user.id,
+ user_name: user.name,
+ repository: {
+ name: project.name,
+ url: 'localhost/rubinius',
+ description: '',
+ homepage: 'localhost/rubinius',
+ private: true
+ }
+ }
+ end
+ let!(:push_event) { create(:event, :pushed, data: push_event_data, project: project, author: user) }
+
+ before do
+ visit dashboard_projects_path
+ end
+
+ scenario 'shows "Create merge request" button' do
+ expect(page).to have_content 'You pushed to feature'
+
+ within('#content-body') do
+ find_link('Create merge request', visible: false).click
+ end
+
+ expect(page).to have_selector('.merge-request-form')
+ expect(current_path).to eq project_new_merge_request_path(project)
+ expect(find('#merge_request_target_project_id').value).to eq project.id.to_s
+ expect(find('input#merge_request_source_branch').value).to eq 'feature'
+ expect(find('input#merge_request_target_branch').value).to eq 'master'
+ end
+ end
end
diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb
index 4c9adcabe34..5f1f0c10339 100644
--- a/spec/features/dashboard/shortcuts_spec.rb
+++ b/spec/features/dashboard/shortcuts_spec.rb
@@ -1,28 +1,28 @@
require 'spec_helper'
-feature 'Dashboard shortcuts', feature: true, js: true do
+feature 'Dashboard shortcuts', :js do
context 'logged in' do
before do
- login_as :user
+ sign_in(create(:user))
visit root_dashboard_path
end
scenario 'Navigate to tabs' do
- find('body').native.send_keys([:shift, 'P'])
-
- check_page_title('Projects')
-
- find('body').native.send_key([:shift, 'I'])
+ find('body').send_keys([:shift, 'I'])
check_page_title('Issues')
- find('body').native.send_key([:shift, 'M'])
+ find('body').send_keys([:shift, 'M'])
check_page_title('Merge Requests')
- find('body').native.send_keys([:shift, 'T'])
+ find('body').send_keys([:shift, 'T'])
check_page_title('Todos')
+
+ find('body').send_keys([:shift, 'P'])
+
+ check_page_title('Projects')
end
end
@@ -32,17 +32,20 @@ feature 'Dashboard shortcuts', feature: true, js: true do
end
scenario 'Navigate to tabs' do
- find('body').native.send_keys([:shift, 'P'])
-
- expect(page).to have_content('No projects found')
-
- find('body').native.send_keys([:shift, 'G'])
+ find('body').send_keys([:shift, 'G'])
+ find('.nothing-here-block')
expect(page).to have_content('No public groups')
- find('body').native.send_keys([:shift, 'S'])
+ find('body').send_keys([:shift, 'S'])
+ find('.nothing-here-block')
expect(page).to have_selector('.snippets-list-holder')
+
+ find('body').send_keys([:shift, 'P'])
+
+ find('.nothing-here-block')
+ expect(page).to have_content('No projects found')
end
end
diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb
index 62937688c22..fb4263d74c4 100644
--- a/spec/features/dashboard/snippets_spec.rb
+++ b/spec/features/dashboard/snippets_spec.rb
@@ -1,15 +1,62 @@
require 'spec_helper'
-describe 'Dashboard snippets', feature: true do
+describe 'Dashboard snippets' do
context 'when the project has snippets' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) }
before do
allow(Snippet).to receive(:default_per_page).and_return(1)
- login_as(project.owner)
+ sign_in(project.owner)
visit dashboard_snippets_path
end
it_behaves_like 'paginated snippets'
end
+
+ context 'filtering by visibility' do
+ let(:user) { create(:user) }
+ let!(:snippets) do
+ [
+ create(:personal_snippet, :public, author: user),
+ create(:personal_snippet, :internal, author: user),
+ create(:personal_snippet, :private, author: user),
+ create(:personal_snippet, :public)
+ ]
+ end
+
+ before do
+ sign_in(user)
+
+ visit dashboard_snippets_path
+ end
+
+ it 'contains all snippets of logged user' do
+ expect(page).to have_selector('.snippet-row', count: 3)
+
+ expect(page).to have_content(snippets[0].title)
+ expect(page).to have_content(snippets[1].title)
+ expect(page).to have_content(snippets[2].title)
+ end
+
+ it 'contains all private snippets of logged user when clicking on private' do
+ click_link('Private')
+
+ expect(page).to have_selector('.snippet-row', count: 1)
+ expect(page).to have_content(snippets[2].title)
+ end
+
+ it 'contains all internal snippets of logged user when clicking on internal' do
+ click_link('Internal')
+
+ expect(page).to have_selector('.snippet-row', count: 1)
+ expect(page).to have_content(snippets[1].title)
+ end
+
+ it 'contains all public snippets of logged user when clicking on public' do
+ click_link('Public')
+
+ expect(page).to have_selector('.snippet-row', count: 1)
+ expect(page).to have_content(snippets[0].title)
+ end
+ end
end
diff --git a/spec/features/dashboard/todos/target_state_spec.rb b/spec/features/dashboard/todos/target_state_spec.rb
new file mode 100644
index 00000000000..030a86d1c01
--- /dev/null
+++ b/spec/features/dashboard/todos/target_state_spec.rb
@@ -0,0 +1,65 @@
+require 'rails_helper'
+
+feature 'Dashboard > Todo target states' do
+ let(:user) { create(:user) }
+ let(:author) { create(:user) }
+ let(:project) { create(:project, :public) }
+
+ before do
+ sign_in(user)
+ end
+
+ scenario 'on a closed issue todo has closed label' do
+ issue_closed = create(:issue, state: 'closed')
+ create_todo issue_closed
+ visit dashboard_todos_path
+
+ page.within '.todos-list' do
+ expect(page).to have_content('Closed')
+ end
+ end
+
+ scenario 'on an open issue todo does not have an open label' do
+ issue_open = create(:issue)
+ create_todo issue_open
+ visit dashboard_todos_path
+
+ page.within '.todos-list' do
+ expect(page).not_to have_content('Open')
+ end
+ end
+
+ scenario 'on a merged merge request todo has merged label' do
+ mr_merged = create(:merge_request, :simple, :merged, author: user)
+ create_todo mr_merged
+ visit dashboard_todos_path
+
+ page.within '.todos-list' do
+ expect(page).to have_content('Merged')
+ end
+ end
+
+ scenario 'on a closed merge request todo has closed label' do
+ mr_closed = create(:merge_request, :simple, :closed, author: user)
+ create_todo mr_closed
+ visit dashboard_todos_path
+
+ page.within '.todos-list' do
+ expect(page).to have_content('Closed')
+ end
+ end
+
+ scenario 'on an open merge request todo does not have an open label' do
+ mr_open = create(:merge_request, :simple, author: user)
+ create_todo mr_open
+ visit dashboard_todos_path
+
+ page.within '.todos-list' do
+ expect(page).not_to have_content('Open')
+ end
+ end
+
+ def create_todo(target)
+ create(:todo, :mentioned, user: user, project: project, target: target, author: author)
+ end
+end
diff --git a/spec/features/dashboard/todos/todos_filtering_spec.rb b/spec/features/dashboard/todos/todos_filtering_spec.rb
new file mode 100644
index 00000000000..54d477f7274
--- /dev/null
+++ b/spec/features/dashboard/todos/todos_filtering_spec.rb
@@ -0,0 +1,153 @@
+require 'spec_helper'
+
+feature 'Dashboard > User filters todos', js: true do
+ let(:user_1) { create(:user, username: 'user_1', name: 'user_1') }
+ let(:user_2) { create(:user, username: 'user_2', name: 'user_2') }
+
+ let(:project_1) { create(:project, name: 'project_1') }
+ let(:project_2) { create(:project, name: 'project_2') }
+
+ let(:issue) { create(:issue, title: 'issue', project: project_1) }
+
+ let!(:merge_request) { create(:merge_request, source_project: project_2, title: 'merge_request') }
+
+ before do
+ create(:todo, user: user_1, author: user_2, project: project_1, target: issue, action: 1)
+ create(:todo, user: user_1, author: user_1, project: project_2, target: merge_request, action: 2)
+
+ project_1.team << [user_1, :developer]
+ project_2.team << [user_1, :developer]
+ sign_in(user_1)
+ visit dashboard_todos_path
+ end
+
+ it 'filters by project' do
+ click_button 'Project'
+ within '.dropdown-menu-project' do
+ fill_in 'Search projects', with: project_1.name_with_namespace
+ click_link project_1.name_with_namespace
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content project_1.name_with_namespace
+ expect(page).not_to have_content project_2.name_with_namespace
+ end
+
+ context 'Author filter' do
+ it 'filters by author' do
+ click_button 'Author'
+
+ within '.dropdown-menu-author' do
+ fill_in 'Search authors', with: user_1.name
+ click_link user_1.name
+ end
+
+ wait_for_requests
+
+ expect(find('.todos-list')).to have_content 'merge request'
+ expect(find('.todos-list')).not_to have_content 'issue'
+ end
+
+ it 'shows only authors of existing todos' do
+ click_button 'Author'
+
+ within '.dropdown-menu-author' do
+ # It should contain two users + 'Any Author'
+ expect(page).to have_selector('.dropdown-menu-user-link', count: 3)
+ expect(page).to have_content(user_1.name)
+ expect(page).to have_content(user_2.name)
+ end
+ end
+
+ it 'shows only authors of existing done todos' do
+ user_3 = create :user
+ user_4 = create :user
+ create(:todo, user: user_1, author: user_3, project: project_1, target: issue, action: 1, state: :done)
+ create(:todo, user: user_1, author: user_4, project: project_2, target: merge_request, action: 2, state: :done)
+
+ project_1.team << [user_3, :developer]
+ project_2.team << [user_4, :developer]
+
+ visit dashboard_todos_path(state: 'done')
+
+ click_button 'Author'
+
+ within '.dropdown-menu-author' do
+ # It should contain two users + 'Any Author'
+ expect(page).to have_selector('.dropdown-menu-user-link', count: 3)
+ expect(page).to have_content(user_3.name)
+ expect(page).to have_content(user_4.name)
+ expect(page).not_to have_content(user_1.name)
+ expect(page).not_to have_content(user_2.name)
+ end
+ end
+ end
+
+ it 'filters by type' do
+ click_button 'Type'
+ within '.dropdown-menu-type' do
+ click_link 'Issue'
+ end
+
+ wait_for_requests
+
+ expect(find('.todos-list')).to have_content issue.to_reference
+ expect(find('.todos-list')).not_to have_content merge_request.to_reference
+ end
+
+ describe 'filter by action' do
+ before do
+ create(:todo, :build_failed, user: user_1, author: user_2, project: project_1)
+ create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue)
+ end
+
+ it 'filters by Assigned' do
+ filter_action('Assigned')
+
+ expect_to_see_action(:assigned)
+ end
+
+ it 'filters by Mentioned' do
+ filter_action('Mentioned')
+
+ expect_to_see_action(:mentioned)
+ end
+
+ it 'filters by Added' do
+ filter_action('Added')
+
+ expect_to_see_action(:marked)
+ end
+
+ it 'filters by Pipelines' do
+ filter_action('Pipelines')
+
+ expect_to_see_action(:build_failed)
+ end
+
+ def filter_action(name)
+ click_button 'Action'
+ within '.dropdown-menu-action' do
+ click_link name
+ end
+
+ wait_for_requests
+ end
+
+ def expect_to_see_action(action_name)
+ action_names = {
+ assigned: ' assigned you ',
+ mentioned: ' mentioned ',
+ marked: ' added a todo for ',
+ build_failed: ' build failed for '
+ }
+
+ action_name_text = action_names.delete(action_name)
+ expect(find('.todos-list')).to have_content action_name_text
+ action_names.each_value do |other_action_text|
+ expect(find('.todos-list')).not_to have_content other_action_text
+ end
+ end
+ end
+end
diff --git a/spec/features/dashboard/todos/todos_sorting_spec.rb b/spec/features/dashboard/todos/todos_sorting_spec.rb
new file mode 100644
index 00000000000..b7d39a872b0
--- /dev/null
+++ b/spec/features/dashboard/todos/todos_sorting_spec.rb
@@ -0,0 +1,99 @@
+require 'spec_helper'
+
+feature 'Dashboard > User sorts todos' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) }
+ let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) }
+ let(:label_3) { create(:label, title: 'label_3', project: project, priority: 3) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'sort options' do
+ let(:issue_1) { create(:issue, title: 'issue_1', project: project) }
+ let(:issue_2) { create(:issue, title: 'issue_2', project: project) }
+ let(:issue_3) { create(:issue, title: 'issue_3', project: project) }
+ let(:issue_4) { create(:issue, title: 'issue_4', project: project) }
+
+ let!(:merge_request_1) { create(:merge_request, source_project: project, title: 'merge_request_1') }
+
+ before do
+ create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago)
+ create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago)
+ create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago)
+ create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago)
+ create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
+
+ merge_request_1.labels << label_1
+ issue_3.labels << label_1
+ issue_2.labels << label_3
+ issue_1.labels << label_2
+
+ sign_in(user)
+ visit dashboard_todos_path
+ end
+
+ it 'sorts with oldest created todos first' do
+ click_link 'Last created'
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content('merge_request_1')
+ expect(results_list.all('p')[1]).to have_content('issue_1')
+ expect(results_list.all('p')[2]).to have_content('issue_3')
+ expect(results_list.all('p')[3]).to have_content('issue_2')
+ expect(results_list.all('p')[4]).to have_content('issue_4')
+ end
+
+ it 'sorts with newest created todos first' do
+ click_link 'Oldest created'
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content('issue_4')
+ expect(results_list.all('p')[1]).to have_content('issue_2')
+ expect(results_list.all('p')[2]).to have_content('issue_3')
+ expect(results_list.all('p')[3]).to have_content('issue_1')
+ expect(results_list.all('p')[4]).to have_content('merge_request_1')
+ end
+
+ it 'sorts by label priority' do
+ click_link 'Label priority'
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content('issue_3')
+ expect(results_list.all('p')[1]).to have_content('merge_request_1')
+ expect(results_list.all('p')[2]).to have_content('issue_1')
+ expect(results_list.all('p')[3]).to have_content('issue_2')
+ expect(results_list.all('p')[4]).to have_content('issue_4')
+ end
+ end
+
+ context 'issues and merge requests' do
+ let(:issue_1) { create(:issue, id: 10000, title: 'issue_1', project: project) }
+ let(:issue_2) { create(:issue, id: 10001, title: 'issue_2', project: project) }
+ let(:merge_request_1) { create(:merge_request, id: 10000, title: 'merge_request_1', source_project: project) }
+
+ before do
+ issue_1.labels << label_1
+ issue_2.labels << label_2
+
+ create(:todo, user: user, project: project, target: issue_1)
+ create(:todo, user: user, project: project, target: issue_2)
+ create(:todo, user: user, project: project, target: merge_request_1)
+
+ sign_in(user)
+ visit dashboard_todos_path
+ end
+
+ it "doesn't mix issues and merge requests label priorities" do
+ click_link 'Label priority'
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content('issue_1')
+ expect(results_list.all('p')[1]).to have_content('issue_2')
+ expect(results_list.all('p')[2]).to have_content('merge_request_1')
+ end
+ end
+end
diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb
new file mode 100644
index 00000000000..30bab7eeaa7
--- /dev/null
+++ b/spec/features/dashboard/todos/todos_spec.rb
@@ -0,0 +1,338 @@
+require 'spec_helper'
+
+feature 'Dashboard Todos' do
+ let(:user) { create(:user) }
+ let(:author) { create(:user) }
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, due_date: Date.today) }
+
+ context 'User does not have todos' do
+ before do
+ sign_in(user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows "All done" message' do
+ expect(page).to have_content 'Todos let you see what you should do next.'
+ end
+ end
+
+ context 'User has a todo', js: true do
+ before do
+ create(:todo, :mentioned, user: user, project: project, target: issue, author: author)
+ sign_in(user)
+
+ visit dashboard_todos_path
+ end
+
+ it 'has todo present' do
+ expect(page).to have_selector('.todos-list .todo', count: 1)
+ end
+
+ it 'shows due date as today' do
+ within first('.todo') do
+ expect(page).to have_content 'Due today'
+ end
+ end
+
+ shared_examples 'deleting the todo' do
+ before do
+ within first('.todo') do
+ click_link 'Done'
+ end
+ end
+
+ it 'is marked as done-reversible in the list' do
+ expect(page).to have_selector('.todos-list .todo.todo-pending.done-reversible')
+ end
+
+ it 'shows Undo button' do
+ expect(page).to have_selector('.js-undo-todo', visible: true)
+ expect(page).to have_selector('.js-done-todo', visible: false)
+ end
+
+ it 'updates todo count' do
+ expect(page).to have_content 'To do 0'
+ expect(page).to have_content 'Done 1'
+ end
+
+ it 'has not "All done" message' do
+ expect(page).not_to have_selector('.todos-all-done')
+ end
+ end
+
+ shared_examples 'deleting and restoring the todo' do
+ before do
+ within first('.todo') do
+ click_link 'Done'
+ wait_for_requests
+ click_link 'Undo'
+ end
+ end
+
+ it 'is marked back as pending in the list' do
+ expect(page).not_to have_selector('.todos-list .todo.todo-pending.done-reversible')
+ expect(page).to have_selector('.todos-list .todo.todo-pending')
+ end
+
+ it 'shows Done button' do
+ expect(page).to have_selector('.js-undo-todo', visible: false)
+ expect(page).to have_selector('.js-done-todo', visible: true)
+ end
+
+ it 'updates todo count' do
+ expect(page).to have_content 'To do 1'
+ expect(page).to have_content 'Done 0'
+ end
+ end
+
+ it_behaves_like 'deleting the todo'
+ it_behaves_like 'deleting and restoring the todo'
+
+ context 'todo is stale on the page' do
+ before do
+ todos = TodosFinder.new(user, state: :pending).execute
+ TodoService.new.mark_todos_as_done(todos, user)
+ end
+
+ it_behaves_like 'deleting the todo'
+ it_behaves_like 'deleting and restoring the todo'
+ end
+ end
+
+ context 'User created todos for themself' do
+ before do
+ sign_in(user)
+ end
+
+ context 'issue assigned todo' do
+ before do
+ create(:todo, :assigned, user: user, project: project, target: issue, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows issue assigned to yourself message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You assigned issue #{issue.to_reference(full: true)} to yourself")
+ end
+ end
+ end
+
+ context 'marked todo' do
+ before do
+ create(:todo, :marked, user: user, project: project, target: issue, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows you added a todo message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You added a todo for issue #{issue.to_reference(full: true)}")
+ expect(page).not_to have_content('to yourself')
+ end
+ end
+ end
+
+ context 'mentioned todo' do
+ before do
+ create(:todo, :mentioned, user: user, project: project, target: issue, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows you mentioned yourself message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You mentioned yourself on issue #{issue.to_reference(full: true)}")
+ expect(page).not_to have_content('to yourself')
+ end
+ end
+ end
+
+ context 'directly_addressed todo' do
+ before do
+ create(:todo, :directly_addressed, user: user, project: project, target: issue, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows you directly addressed yourself message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You directly addressed yourself on issue #{issue.to_reference(full: true)}")
+ expect(page).not_to have_content('to yourself')
+ end
+ end
+ end
+
+ context 'approval todo' do
+ let(:merge_request) { create(:merge_request) }
+
+ before do
+ create(:todo, :approval_required, user: user, project: project, target: merge_request, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows you set yourself as an approver message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You set yourself as an approver for merge request #{merge_request.to_reference(full: true)}")
+ expect(page).not_to have_content('to yourself')
+ end
+ end
+ end
+ end
+
+ context 'User has done todos', js: true do
+ before do
+ create(:todo, :mentioned, :done, user: user, project: project, target: issue, author: author)
+ sign_in(user)
+ visit dashboard_todos_path(state: :done)
+ end
+
+ it 'has the done todo present' do
+ expect(page).to have_selector('.todos-list .todo.todo-done', count: 1)
+ end
+
+ describe 'restoring the todo' do
+ before do
+ within first('.todo') do
+ click_link 'Add todo'
+ end
+ end
+
+ it 'is removed from the list' do
+ expect(page).not_to have_selector('.todos-list .todo.todo-done')
+ end
+
+ it 'updates todo count' do
+ expect(page).to have_content 'To do 1'
+ expect(page).to have_content 'Done 0'
+ end
+ end
+ end
+
+ context 'User has Todos with labels spanning multiple projects' do
+ before do
+ label1 = create(:label, project: project)
+ note1 = create(:note_on_issue, note: "Hello #{label1.to_reference(format: :name)}", noteable_id: issue.id, noteable_type: 'Issue', project: issue.project)
+ create(:todo, :mentioned, project: project, target: issue, user: user, note_id: note1.id)
+
+ project2 = create(:project, :public)
+ label2 = create(:label, project: project2)
+ issue2 = create(:issue, project: project2)
+ note2 = create(:note_on_issue, note: "Test #{label2.to_reference(format: :name)}", noteable_id: issue2.id, noteable_type: 'Issue', project: project2)
+ create(:todo, :mentioned, project: project2, target: issue2, user: user, note_id: note2.id)
+
+ gitlab_sign_in(user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows page with two Todos' do
+ expect(page).to have_selector('.todos-list .todo', count: 2)
+ end
+ end
+
+ context 'User has multiple pages of Todos' do
+ before do
+ allow(Todo).to receive(:default_per_page).and_return(1)
+
+ # Create just enough records to cause us to paginate
+ create_list(:todo, 2, :mentioned, user: user, project: project, target: issue, author: author)
+
+ sign_in(user)
+ end
+
+ it 'is paginated' do
+ visit dashboard_todos_path
+
+ expect(page).to have_selector('.gl-pagination')
+ end
+
+ it 'is has the right number of pages' do
+ visit dashboard_todos_path
+
+ expect(page).to have_selector('.gl-pagination .page', count: 2)
+ end
+
+ describe 'mark all as done', js: true do
+ before do
+ visit dashboard_todos_path
+ find('.js-todos-mark-all').trigger('click')
+ end
+
+ it 'shows "All done" message!' do
+ expect(page).to have_content 'To do 0'
+ expect(page).to have_content "You're all done!"
+ expect(page).not_to have_selector('.gl-pagination')
+ end
+
+ it 'shows "Undo mark all as done" button' do
+ expect(page).to have_selector('.js-todos-mark-all', visible: false)
+ expect(page).to have_selector('.js-todos-undo-all', visible: true)
+ end
+ end
+
+ describe 'undo mark all as done', js: true do
+ before do
+ visit dashboard_todos_path
+ end
+
+ it 'shows the restored todo list' do
+ mark_all_and_undo
+
+ expect(page).to have_selector('.todos-list .todo', count: 1)
+ expect(page).to have_selector('.gl-pagination')
+ expect(page).not_to have_content "You're all done!"
+ end
+
+ it 'updates todo count' do
+ mark_all_and_undo
+
+ expect(page).to have_content 'To do 2'
+ expect(page).to have_content 'Done 0'
+ end
+
+ it 'shows "Mark all as done" button' do
+ mark_all_and_undo
+
+ expect(page).to have_selector('.js-todos-mark-all', visible: true)
+ expect(page).to have_selector('.js-todos-undo-all', visible: false)
+ end
+
+ context 'User has deleted a todo' do
+ before do
+ within first('.todo') do
+ click_link 'Done'
+ end
+ end
+
+ it 'shows the restored todo list with the deleted todo' do
+ mark_all_and_undo
+
+ expect(page).to have_selector('.todos-list .todo.todo-pending', count: 1)
+ end
+ end
+
+ def mark_all_and_undo
+ find('.js-todos-mark-all').trigger('click')
+ wait_for_requests
+ find('.js-todos-undo-all').trigger('click')
+ wait_for_requests
+ end
+ end
+ end
+
+ context 'User has a Build Failed todo' do
+ let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: author) }
+
+ before do
+ sign_in(user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows the todo' do
+ expect(page).to have_content 'The build failed for merge request'
+ end
+
+ it 'links to the pipelines for the merge request' do
+ href = pipelines_project_merge_request_path(project, todo.target)
+
+ expect(page).to have_link "merge request #{todo.target.to_reference(full: true)}", href
+ end
+ end
+end
diff --git a/spec/features/dashboard/user_filters_projects_spec.rb b/spec/features/dashboard/user_filters_projects_spec.rb
index 34d6257f5fd..c352b6ded14 100644
--- a/spec/features/dashboard/user_filters_projects_spec.rb
+++ b/spec/features/dashboard/user_filters_projects_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Dashboard > User filters projects', :feature do
+describe 'Dashboard > User filters projects' do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'Victorialand', namespace: user.namespace) }
let(:user2) { create(:user) }
@@ -9,7 +9,7 @@ describe 'Dashboard > User filters projects', :feature do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
describe 'filtering personal projects' do
diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb
deleted file mode 100644
index b6b87905231..00000000000
--- a/spec/features/dashboard_issues_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'spec_helper'
-
-describe "Dashboard Issues filtering", feature: true, js: true do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
- let(:milestone) { create(:milestone, project: project) }
-
- context 'filtering by milestone' do
- before do
- project.team << [user, :master]
- login_as(user)
-
- create(:issue, project: project, author: user, assignee: user)
- create(:issue, project: project, author: user, assignee: user, milestone: milestone)
-
- visit_issues
- end
-
- it 'shows all issues with no milestone' do
- show_milestone_dropdown
-
- click_link 'No Milestone'
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_selector('.issue', count: 1)
- end
-
- it 'shows all issues with any milestone' do
- show_milestone_dropdown
-
- click_link 'Any Milestone'
-
- expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- expect(page).to have_selector('.issue', count: 2)
- end
-
- it 'shows all issues with the selected milestone' do
- show_milestone_dropdown
-
- page.within '.dropdown-content' do
- click_link milestone.title
- end
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_selector('.issue', count: 1)
- end
-
- it 'updates atom feed link' do
- visit_issues(milestone_title: '', assignee_id: user.id)
-
- link = find('.nav-controls a[title="Subscribe"]')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
-
- expect(params).to include('private_token' => [user.private_token])
- expect(params).to include('milestone_title' => [''])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('private_token' => [user.private_token])
- expect(auto_discovery_params).to include('milestone_title' => [''])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
- end
- end
-
- def show_milestone_dropdown
- click_button 'Milestone'
- expect(page).to have_selector('.dropdown-content', visible: true)
- end
-
- def visit_issues(*args)
- visit issues_dashboard_path(*args)
- end
-end
diff --git a/spec/features/dashboard_milestones_spec.rb b/spec/features/dashboard_milestones_spec.rb
deleted file mode 100644
index f32fddbc9fa..00000000000
--- a/spec/features/dashboard_milestones_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require 'spec_helper'
-
-feature 'Dashboard > Milestones', feature: true do
- describe 'as anonymous user' do
- before do
- visit dashboard_milestones_path
- end
-
- it 'is redirected to sign-in page' do
- expect(current_path).to eq new_user_session_path
- end
- end
-
- describe 'as logged-in user' do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
- let!(:milestone) { create(:milestone, project: project) }
- before do
- project.team << [user, :master]
- login_with(user)
- visit dashboard_milestones_path
- end
-
- it 'sees milestones' do
- expect(current_path).to eq dashboard_milestones_path
- expect(page).to have_content(milestone.title)
- end
- end
-end
diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb
index 96e0b78f6b9..0375d0bf8ff 100644
--- a/spec/features/discussion_comments/commit_spec.rb
+++ b/spec/features/discussion_comments/commit_spec.rb
@@ -1,17 +1,17 @@
require 'spec_helper'
-describe 'Discussion Comments Merge Request', :feature, :js do
+describe 'Discussion Comments Merge Request', :js do
include RepoHelpers
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
project.add_master(user)
- login_as(user)
+ sign_in(user)
- visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
+ visit project_commit_path(project, sample_commit.id)
end
it_behaves_like 'discussion comments', 'commit'
diff --git a/spec/features/discussion_comments/issue_spec.rb b/spec/features/discussion_comments/issue_spec.rb
index ccc9efccd18..9812eaf3420 100644
--- a/spec/features/discussion_comments/issue_spec.rb
+++ b/spec/features/discussion_comments/issue_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe 'Discussion Comments Issue', :feature, :js do
+describe 'Discussion Comments Issue', :js do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
before do
project.add_master(user)
- login_as(user)
+ sign_in(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it_behaves_like 'discussion comments', 'issue'
diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb
index f99ebeb9cd9..b0019c32189 100644
--- a/spec/features/discussion_comments/merge_request_spec.rb
+++ b/spec/features/discussion_comments/merge_request_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe 'Discussion Comments Merge Request', :feature, :js do
+describe 'Discussion Comments Merge Request', :js do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
project.add_master(user)
- login_as(user)
+ sign_in(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it_behaves_like 'discussion comments', 'merge request'
diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb
index 19a306511b2..1e6389d9a13 100644
--- a/spec/features/discussion_comments/snippets_spec.rb
+++ b/spec/features/discussion_comments/snippets_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe 'Discussion Comments Issue', :feature, :js do
+describe 'Discussion Comments Issue', :js do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
before do
project.add_master(user)
- login_as(user)
+ sign_in(user)
- visit namespace_project_snippet_path(project.namespace, project, snippet)
+ visit project_snippet_path(project, snippet)
end
it_behaves_like 'discussion comments', 'snippet'
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 76c77e0bc5f..357d86497d9 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -1,15 +1,20 @@
require 'spec_helper'
-feature 'Expand and collapse diffs', js: true, feature: true do
+feature 'Expand and collapse diffs', js: true do
let(:branch) { 'expand-collapse-diffs' }
let(:project) { create(:project, :repository) }
before do
- login_as :admin
+ # Set the limits to those when these specs were written, to avoid having to
+ # update the test repo every time we change them.
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
+
+ sign_in(create(:admin))
# Ensure that undiffable.md is in .gitattributes
project.repository.copy_gitattributes(branch)
- visit namespace_project_commit_path(project.namespace, project, project.commit(branch))
+ visit project_commit_path(project, project.commit(branch))
execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });')
end
@@ -33,10 +38,10 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(large_diff).not_to have_selector('.code')
expect(large_diff).to have_selector('.nothing-here-block')
- visit namespace_project_commit_path(project.namespace, project, project.commit(branch), anchor: "#{large_diff[:id]}_0_1")
+ visit project_commit_path(project, project.commit(branch), anchor: "#{large_diff[:id]}_0_1")
execute_script('window.location.reload()')
- wait_for_ajax
+ wait_for_requests
expect(large_diff).to have_selector('.code')
expect(large_diff).not_to have_selector('.nothing-here-block')
@@ -47,10 +52,10 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(large_diff).not_to have_selector('.code')
expect(large_diff).to have_selector('.nothing-here-block')
- visit namespace_project_commit_path(project.namespace, project, project.commit(branch), anchor: large_diff[:id])
+ visit project_commit_path(project, project.commit(branch), anchor: large_diff[:id])
execute_script('window.location.reload()')
- wait_for_ajax
+ wait_for_requests
expect(large_diff).to have_selector('.code')
expect(large_diff).not_to have_selector('.nothing-here-block')
@@ -62,18 +67,6 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(small_diff).not_to have_selector('.nothing-here-block')
end
- it 'collapses large diffs by default' do
- expect(large_diff).not_to have_selector('.code')
- expect(large_diff).to have_selector('.nothing-here-block')
- end
-
- it 'collapses large diffs for renamed files by default' do
- expect(large_diff_renamed).not_to have_selector('.code')
- expect(large_diff_renamed).to have_selector('.nothing-here-block')
- expect(large_diff_renamed).to have_selector('.js-file-title .deletion')
- expect(large_diff_renamed).to have_selector('.js-file-title .addition')
- end
-
it 'shows non-renderable diffs as such immediately, regardless of their size' do
expect(undiffable).not_to have_selector('.code')
expect(undiffable).to have_selector('.nothing-here-block')
@@ -94,7 +87,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do
context 'expanding a diff for a renamed file' do
before do
large_diff_renamed.find('.click-to-expand').click
- wait_for_ajax
+ wait_for_requests
end
it 'shows the old content' do
@@ -116,7 +109,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do
find('.js-file-title', match: :first)
# Click `large_diff.md` title
all('.diff-toggle-caret')[1].click
- wait_for_ajax
+ wait_for_requests
end
it 'makes a request to get the content' do
@@ -136,10 +129,10 @@ feature 'Expand and collapse diffs', js: true, feature: true do
before do
large_diff.find('.diff-line-num', match: :prefer_exact).hover
- large_diff.find('.add-diff-note').click
+ large_diff.find('.add-diff-note', match: :prefer_exact).click
large_diff.find('.note-textarea').send_keys comment_text
large_diff.find_button('Comment').click
- wait_for_ajax
+ wait_for_requests
end
it 'adds the comment' do
@@ -147,7 +140,9 @@ feature 'Expand and collapse diffs', js: true, feature: true do
end
context 'reloading the page' do
- before { refresh }
+ before do
+ refresh
+ end
it 'collapses the large diff by default' do
expect(large_diff).not_to have_selector('.code')
@@ -160,7 +155,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do
find('.js-file-title', match: :first)
# Click `large_diff.md` title
all('.diff-toggle-caret')[1].click
- wait_for_ajax
+ wait_for_requests
end
it 'shows the diff content' do
@@ -216,7 +211,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(page).to have_no_content('No longer a symlink')
find('.click-to-expand').click
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content('No longer a symlink')
end
@@ -269,11 +264,11 @@ feature 'Expand and collapse diffs', js: true, feature: true do
# Wait for elements to appear to ensure full page reload
expect(page).to have_content('This diff was suppressed by a .gitattributes entry')
- expect(page).to have_content('This diff could not be displayed because it is too large.')
+ expect(page).to have_content('This source diff could not be displayed because it is too large.')
expect(page).to have_content('too_large_image.jpg')
find('.note-textarea')
- wait_for_ajax
+ wait_for_requests
execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });')
end
diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb
index 8e5421a984b..b5325301968 100644
--- a/spec/features/explore/groups_list_spec.rb
+++ b/spec/features/explore/groups_list_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-describe 'Explore Groups page', js: true, feature: true do
+describe 'Explore Groups page', :js do
let!(:user) { create :user }
let!(:group) { create(:group) }
let!(:public_group) { create(:group, :public) }
let!(:private_group) { create(:group, :private) }
- let!(:empty_project) { create(:empty_project, group: public_group) }
+ let!(:empty_project) { create(:project, group: public_group) }
before do
group.add_owner(user)
- login_as(user)
+ sign_in(user)
visit explore_groups_path
end
@@ -23,7 +23,7 @@ describe 'Explore Groups page', js: true, feature: true do
it 'filters groups' do
fill_in 'filter_groups', with: group.name
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).not_to have_content(public_group.full_name)
@@ -32,10 +32,10 @@ describe 'Explore Groups page', js: true, feature: true do
it 'resets search when user cleans the input' do
fill_in 'filter_groups', with: group.name
- wait_for_ajax
+ wait_for_requests
fill_in 'filter_groups', with: ""
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).to have_content(public_group.full_name)
@@ -46,19 +46,39 @@ describe 'Explore Groups page', js: true, feature: true do
it 'shows non-archived projects count' do
# Initially project is not archived
expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
-
+
# Archive project
empty_project.archive!
visit explore_groups_path
# Check project count
expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
-
+
# Unarchive project
empty_project.unarchive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ end
+
+ describe 'landing component' do
+ it 'should show a landing component' do
+ expect(page).to have_content('Below you will find all the groups that are public.')
+ end
+
+ it 'should be dismissable' do
+ find('.dismiss-button').click
+
+ expect(page).not_to have_content('Below you will find all the groups that are public.')
+ end
+
+ it 'should persistently not show once dismissed' do
+ find('.dismiss-button').click
+
+ visit explore_groups_path
+
+ expect(page).not_to have_content('Below you will find all the groups that are public.')
+ end
end
end
diff --git a/spec/features/explore/new_menu_spec.rb b/spec/features/explore/new_menu_spec.rb
new file mode 100644
index 00000000000..2cd06258e22
--- /dev/null
+++ b/spec/features/explore/new_menu_spec.rb
@@ -0,0 +1,173 @@
+require 'spec_helper'
+
+feature 'Top Plus Menu', :js do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :repository, creator: user, namespace: user.namespace) }
+ let(:public_project) { create(:project, :public) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ context 'used by full user' do
+ before do
+ sign_in(user)
+ end
+
+ scenario 'click on New project shows new project page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New project")
+
+ expect(page).to have_content('Project path')
+ expect(page).to have_content('Project name')
+ end
+
+ scenario 'click on New group shows new group page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New group")
+
+ expect(page).to have_content('Group path')
+ expect(page).to have_content('Group name')
+ end
+
+ scenario 'click on New snippet shows new snippet page' do
+ visit root_dashboard_path
+
+ click_topmenuitem("New snippet")
+
+ expect(page).to have_content('New Snippet')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'click on New issue shows new issue page' do
+ visit project_path(project)
+
+ click_topmenuitem("New issue")
+
+ expect(page).to have_content('New Issue')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'click on New merge request shows new merge request page' do
+ visit project_path(project)
+
+ click_topmenuitem("New merge request")
+
+ expect(page).to have_content('New Merge Request')
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+ end
+
+ scenario 'click on New project snippet shows new snippet page' do
+ visit project_path(project)
+
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ find('.header-new-project-snippet a').trigger('click')
+ end
+
+ expect(page).to have_content('New Snippet')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'Click on New subgroup shows new group page' do
+ visit group_path(group)
+
+ click_topmenuitem("New subgroup")
+
+ expect(page).to have_content('Group path')
+ expect(page).to have_content('Group name')
+ end
+
+ scenario 'Click on New project in group shows new project page' do
+ visit group_path(group)
+
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ find('.header-new-group-project a').trigger('click')
+ end
+
+ expect(page).to have_content('Project path')
+ expect(page).to have_content('Project name')
+ end
+ end
+
+ context 'used by guest user' do
+ let(:guest_user) { create(:user) }
+
+ before do
+ group.add_guest(guest_user)
+ project.add_guest(guest_user)
+
+ sign_in(guest_user)
+ end
+
+ scenario 'click on New issue shows new issue page' do
+ visit project_path(project)
+
+ click_topmenuitem("New issue")
+
+ expect(page).to have_content('New Issue')
+ expect(page).to have_content('Title')
+ end
+
+ scenario 'has no New merge request menu item' do
+ visit project_path(project)
+
+ hasnot_topmenuitem("New merge request")
+ end
+
+ scenario 'has no New project snippet menu item' do
+ visit project_path(project)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet')
+ end
+
+ scenario 'public project has no New Issue Button' do
+ visit project_path(public_project)
+
+ hasnot_topmenuitem("New issue")
+ end
+
+ scenario 'public project has no New merge request menu item' do
+ visit project_path(public_project)
+
+ hasnot_topmenuitem("New merge request")
+ end
+
+ scenario 'public project has no New project snippet menu item' do
+ visit project_path(public_project)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet')
+ end
+
+ scenario 'has no New subgroup menu item' do
+ visit group_path(group)
+
+ hasnot_topmenuitem("New subgroup")
+ end
+
+ scenario 'has no New project for group menu item' do
+ visit group_path(group)
+
+ expect(find('.header-new.dropdown')).not_to have_selector('.header-new-group-project')
+ end
+ end
+
+ def click_topmenuitem(item_name)
+ page.within '.header-content' do
+ find('.header-new-dropdown-toggle').trigger('click')
+ expect(page).to have_selector('.header-new.dropdown.open', count: 1)
+ click_link item_name
+ end
+ end
+
+ def hasnot_topmenuitem(item_name)
+ expect(find('.header-new.dropdown')).not_to have_content(item_name)
+ end
+end
diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb
index 01b1aee4fd3..53b3bb3b65f 100644
--- a/spec/features/gitlab_flavored_markdown_spec.rb
+++ b/spec/features/gitlab_flavored_markdown_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
-describe "GitLab Flavored Markdown", feature: true do
- let(:project) { create(:empty_project) }
+describe "GitLab Flavored Markdown" do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:fred) do
create(:user, name: 'fred') do |user|
@@ -10,8 +11,8 @@ describe "GitLab Flavored Markdown", feature: true do
end
before do
- login_as(:user)
- project.add_developer(@user)
+ sign_in(user)
+ project.add_developer(user)
end
describe "for commits" do
@@ -19,76 +20,68 @@ describe "GitLab Flavored Markdown", feature: true do
let(:commit) { project.commit }
before do
- allow_any_instance_of(Commit).to receive(:title).
- and_return("fix #{issue.to_reference}\n\nask #{fred.to_reference} for details")
+ allow_any_instance_of(Commit).to receive(:title)
+ .and_return("fix #{issue.to_reference}\n\nask #{fred.to_reference} for details")
end
it "renders title in commits#index" do
- visit namespace_project_commits_path(project.namespace, project, 'master', limit: 1)
+ visit project_commits_path(project, 'master', limit: 1)
expect(page).to have_link(issue.to_reference)
end
it "renders title in commits#show" do
- visit namespace_project_commit_path(project.namespace, project, commit)
+ visit project_commit_path(project, commit)
expect(page).to have_link(issue.to_reference)
end
it "renders description in commits#show" do
- visit namespace_project_commit_path(project.namespace, project, commit)
+ visit project_commit_path(project, commit)
expect(page).to have_link(fred.to_reference)
end
it "renders title in repositories#branches" do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project)
expect(page).to have_link(issue.to_reference)
end
end
- describe "for issues", feature: true, js: true do
- include WaitForVueResource
-
+ describe "for issues", js: true do
before do
@other_issue = create(:issue,
- author: @user,
- assignee: @user,
+ author: user,
+ assignees: [user],
project: project)
@issue = create(:issue,
- author: @user,
- assignee: @user,
+ author: user,
+ assignees: [user],
project: project,
title: "fix #{@other_issue.to_reference}",
description: "ask #{fred.to_reference} for details")
+
+ @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world")
end
it "renders subject in issues#index" do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
expect(page).to have_link(@other_issue.to_reference)
end
it "renders subject in issues#show" do
- visit namespace_project_issue_path(project.namespace, project, @issue)
+ visit project_issue_path(project, @issue)
expect(page).to have_link(@other_issue.to_reference)
end
it "renders details in issues#show" do
- visit namespace_project_issue_path(project.namespace, project, @issue)
+ visit project_issue_path(project, @issue)
expect(page).to have_link(fred.to_reference)
end
-
- it "renders updated subject once edited somewhere else in issues#show" do
- visit namespace_project_issue_path(project.namespace, project, @issue)
- @issue.update(title: "fix #{@other_issue.to_reference} and update")
-
- wait_for_vue_resource
- expect(page).to have_text("fix #{@other_issue.to_reference} and update")
- end
end
describe "for merge requests" do
@@ -99,13 +92,13 @@ describe "GitLab Flavored Markdown", feature: true do
end
it "renders title in merge_requests#index" do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).to have_link(issue.to_reference)
end
it "renders title in merge_requests#show" do
- visit namespace_project_merge_request_path(project.namespace, project, @merge_request)
+ visit project_merge_request_path(project, @merge_request)
expect(page).to have_link(issue.to_reference)
end
@@ -120,19 +113,19 @@ describe "GitLab Flavored Markdown", feature: true do
end
it "renders title in milestones#index" do
- visit namespace_project_milestones_path(project.namespace, project)
+ visit project_milestones_path(project)
expect(page).to have_link(issue.to_reference)
end
it "renders title in milestones#show" do
- visit namespace_project_milestone_path(project.namespace, project, @milestone)
+ visit project_milestone_path(project, @milestone)
expect(page).to have_link(issue.to_reference)
end
it "renders description in milestones#show" do
- visit namespace_project_milestone_path(project.namespace, project, @milestone)
+ visit project_milestone_path(project, @milestone)
expect(page).to have_link(fred.to_reference)
end
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index 4b22b07494d..f04e13adba7 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-feature 'Global search', feature: true do
+feature 'Global search' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
before do
project.team << [user, :master]
- login_with(user)
+ sign_in(user)
end
describe 'I search through the issues and I see pagination' do
diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb
new file mode 100644
index 00000000000..37814ba6238
--- /dev/null
+++ b/spec/features/group_variables_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+feature 'Group variables', js: true do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+
+ background do
+ group.add_master(user)
+ gitlab_sign_in(user)
+ end
+
+ context 'when user creates a new variable' do
+ background do
+ visit group_settings_ci_cd_path(group)
+ fill_in 'variable_key', with: 'AAA'
+ fill_in 'variable_value', with: 'AAA123'
+ find(:css, "#variable_protected").set(true)
+ click_on 'Add new variable'
+ end
+
+ scenario 'user sees the created variable' do
+ page.within('.variables-table') do
+ expect(find(".variable-key")).to have_content('AAA')
+ expect(find(".variable-value")).to have_content('******')
+ expect(find(".variable-protected")).to have_content('Yes')
+ end
+ click_on 'Reveal Values'
+ page.within('.variables-table') do
+ expect(find(".variable-value")).to have_content('AAA123')
+ end
+ end
+ end
+
+ context 'when user edits a variable' do
+ background do
+ create(:ci_group_variable, key: 'AAA', value: 'AAA123', protected: true,
+ group: group)
+
+ visit group_settings_ci_cd_path(group)
+
+ page.within('.variable-menu') do
+ click_on 'Update'
+ end
+
+ fill_in 'variable_key', with: 'BBB'
+ fill_in 'variable_value', with: 'BBB123'
+ find(:css, "#variable_protected").set(false)
+ click_on 'Save variable'
+ end
+
+ scenario 'user sees the updated variable' do
+ page.within('.variables-table') do
+ expect(find(".variable-key")).to have_content('BBB')
+ expect(find(".variable-value")).to have_content('******')
+ expect(find(".variable-protected")).to have_content('No')
+ end
+ end
+ end
+
+ context 'when user deletes a variable' do
+ background do
+ create(:ci_group_variable, key: 'BBB', value: 'BBB123', protected: false,
+ group: group)
+
+ visit group_settings_ci_cd_path(group)
+
+ page.within('.variable-menu') do
+ page.accept_alert 'Are you sure?' do
+ click_on 'Remove'
+ end
+ end
+ end
+
+ scenario 'user does not see the deleted variable' do
+ expect(page).to have_no_css('.variables-table')
+ end
+ end
+end
diff --git a/spec/features/groups/activity_spec.rb b/spec/features/groups/activity_spec.rb
index 3b481cba424..d3b25ec3d6c 100644
--- a/spec/features/groups/activity_spec.rb
+++ b/spec/features/groups/activity_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
-feature 'Group activity page', feature: true do
+feature 'Group activity page' do
+ let(:user) { create(:group_member, :developer, user: create(:user), group: group ).user }
let(:group) { create(:group) }
let(:path) { activity_group_path(group) }
context 'when signed in' do
before do
- user = create(:group_member, :developer, user: create(:user), group: group ).user
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -20,7 +20,7 @@ feature 'Group activity page', feature: true do
visit path
end
- it_behaves_like "it has an RSS button without a private token"
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb
index fef8e41bffe..243e8536168 100644
--- a/spec/features/groups/empty_states_spec.rb
+++ b/spec/features/groups/empty_states_spec.rb
@@ -5,11 +5,11 @@ feature 'Groups Merge Requests Empty States' do
let(:user) { create(:group_member, :developer, user: create(:user), group: group ).user }
before do
- login_as(user)
+ sign_in(user)
end
context 'group has a project' do
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
before do
project.add_master(user)
@@ -38,7 +38,7 @@ feature 'Groups Merge Requests Empty States' do
it 'should show a new merge request button' do
within '.empty-state' do
- expect(page).to have_content('New merge request')
+ expect(page).to have_content('create merge request')
end
end
@@ -63,7 +63,7 @@ feature 'Groups Merge Requests Empty States' do
it 'should not show a new merge request button' do
within '.empty-state' do
- expect(page).not_to have_link('New merge request')
+ expect(page).not_to have_link('create merge request')
end
end
end
diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb
index 8a1d415c4f1..a7b8b702ab7 100644
--- a/spec/features/groups/group_name_toggle_spec.rb
+++ b/spec/features/groups/group_name_toggle_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Group name toggle', feature: true, js: true do
+feature 'Group name toggle', js: true do
let(:group) { create(:group) }
let(:nested_group_1) { create(:group, parent: group) }
let(:nested_group_2) { create(:group, parent: nested_group_1) }
@@ -9,7 +9,7 @@ feature 'Group name toggle', feature: true, js: true do
SMALL_SCREEN = 300
before do
- login_as :user
+ sign_in(create(:user))
end
it 'is not present if enough horizontal space' do
@@ -22,7 +22,7 @@ feature 'Group name toggle', feature: true, js: true do
expect(page).not_to have_css('.group-name-toggle')
end
- it 'is present if the title is longer than the container' do
+ it 'is present if the title is longer than the container', :nested_groups do
visit group_path(nested_group_3)
title_width = page.evaluate_script("$('.title')[0].offsetWidth")
@@ -35,7 +35,7 @@ feature 'Group name toggle', feature: true, js: true do
expect(title_width).to be > container_width
end
- it 'should show the full group namespace when toggled' do
+ it 'should show the full group namespace when toggled', :nested_groups do
page_height = page.current_window.size[1]
page.current_window.resize_to(SMALL_SCREEN, page_height)
visit group_path(nested_group_3)
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
new file mode 100644
index 00000000000..d0316cfb18d
--- /dev/null
+++ b/spec/features/groups/group_settings_spec.rb
@@ -0,0 +1,85 @@
+require 'spec_helper'
+
+feature 'Edit group settings' do
+ given(:user) { create(:user) }
+ given(:group) { create(:group, path: 'foo') }
+
+ background do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ describe 'when the group path is changed' do
+ let(:new_group_path) { 'bar' }
+ let(:old_group_full_path) { "/#{group.path}" }
+ let(:new_group_full_path) { "/#{new_group_path}" }
+
+ scenario 'the group is accessible via the new path' do
+ update_path(new_group_path)
+ visit new_group_full_path
+ expect(current_path).to eq(new_group_full_path)
+ expect(find('h1.group-title')).to have_content(group.name)
+ end
+
+ scenario 'the old group path redirects to the new path' do
+ update_path(new_group_path)
+ visit old_group_full_path
+ expect(current_path).to eq(new_group_full_path)
+ expect(find('h1.group-title')).to have_content(group.name)
+ end
+
+ context 'with a subgroup' do
+ given!(:subgroup) { create(:group, parent: group, path: 'subgroup') }
+ given(:old_subgroup_full_path) { "/#{group.path}/#{subgroup.path}" }
+ given(:new_subgroup_full_path) { "/#{new_group_path}/#{subgroup.path}" }
+
+ scenario 'the subgroup is accessible via the new path' do
+ update_path(new_group_path)
+ visit new_subgroup_full_path
+ expect(current_path).to eq(new_subgroup_full_path)
+ expect(find('h1.group-title')).to have_content(subgroup.name)
+ end
+
+ scenario 'the old subgroup path redirects to the new path' do
+ update_path(new_group_path)
+ visit old_subgroup_full_path
+ expect(current_path).to eq(new_subgroup_full_path)
+ expect(find('h1.group-title')).to have_content(subgroup.name)
+ end
+ end
+
+ context 'with a project' do
+ given!(:project) { create(:project, group: group) }
+ given(:old_project_full_path) { "/#{group.path}/#{project.path}" }
+ given(:new_project_full_path) { "/#{new_group_path}/#{project.path}" }
+
+ before(:context) do
+ TestEnv.clean_test_path
+ end
+
+ after do
+ TestEnv.clean_test_path
+ end
+
+ scenario 'the project is accessible via the new path' do
+ update_path(new_group_path)
+ visit new_project_full_path
+ expect(current_path).to eq(new_project_full_path)
+ expect(find('h1.title')).to have_content(project.path)
+ end
+
+ scenario 'the old project path redirects to the new path' do
+ update_path(new_group_path)
+ visit old_project_full_path
+ expect(current_path).to eq(new_project_full_path)
+ expect(find('h1.title')).to have_content(project.path)
+ end
+ end
+ end
+end
+
+def update_path(new_group_path)
+ visit edit_group_path(group)
+ fill_in 'group_path', with: new_group_path
+ click_button 'Save group'
+end
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index 45f57845c74..cdf7aceb13c 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
-feature 'Group issues page', feature: true do
+feature 'Group issues page' do
+ include FilteredSearchHelpers
+
let(:path) { issues_group_path(group) }
let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
@@ -12,15 +14,15 @@ feature 'Group issues page', feature: true do
context 'when signed in' do
let(:user) { user_in_group }
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
let(:user) { nil }
- it_behaves_like "it has an RSS button without a private token"
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
@@ -31,12 +33,10 @@ feature 'Group issues page', feature: true do
let(:path) { issues_group_path(group) }
it 'filters by only group users' do
- click_button('Assignee')
-
- wait_for_ajax
+ filtered_search.set('assignee:')
- expect(find('.dropdown-menu-assignee')).to have_link(user.name)
- expect(find('.dropdown-menu-assignee')).not_to have_link(user2.name)
+ expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
+ expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
end
end
end
diff --git a/spec/features/groups/labels/edit_spec.rb b/spec/features/groups/labels/edit_spec.rb
index 69281cecb7b..fb338127861 100644
--- a/spec/features/groups/labels/edit_spec.rb
+++ b/spec/features/groups/labels/edit_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Edit group label', feature: true do
+feature 'Edit group label' do
given(:user) { create(:user) }
given(:group) { create(:group) }
given(:label) { create(:group_label, group: group) }
background do
group.add_owner(user)
- login_as(user)
+ sign_in(user)
visit edit_group_label_path(group, label)
end
diff --git a/spec/features/groups/labels/subscription_spec.rb b/spec/features/groups/labels/subscription_spec.rb
new file mode 100644
index 00000000000..1dd09d4f203
--- /dev/null
+++ b/spec/features/groups/labels/subscription_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+feature 'Labels subscription' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let!(:feature) { create(:group_label, group: group, title: 'feature') }
+
+ context 'when signed in' do
+ before do
+ group.add_developer(user)
+ gitlab_sign_in user
+ end
+
+ scenario 'users can subscribe/unsubscribe to group labels', js: true do
+ visit group_labels_path(group)
+
+ expect(page).to have_content('feature')
+
+ within "#group_label_#{feature.id}" do
+ expect(page).not_to have_button 'Unsubscribe'
+
+ click_button 'Subscribe'
+
+ expect(page).not_to have_button 'Subscribe'
+ expect(page).to have_button 'Unsubscribe'
+
+ click_button 'Unsubscribe'
+
+ expect(page).to have_button 'Subscribe'
+ expect(page).not_to have_button 'Unsubscribe'
+ end
+ end
+ end
+
+ context 'when not signed in' do
+ it 'users can not subscribe/unsubscribe to labels' do
+ visit group_labels_path(group)
+
+ expect(page).to have_content 'feature'
+ expect(page).not_to have_button('Subscribe')
+ end
+ end
+
+ def click_link_on_dropdown(text)
+ find('.dropdown-group-label').click
+
+ page.within('.dropdown-group-label') do
+ find('a.js-subscribe-button', text: text).click
+ end
+ end
+end
diff --git a/spec/features/groups/members/last_owner_cannot_leave_group_spec.rb b/spec/features/groups/members/last_owner_cannot_leave_group_spec.rb
deleted file mode 100644
index be60b0489c7..00000000000
--- a/spec/features/groups/members/last_owner_cannot_leave_group_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > Last owner cannot leave group', feature: true do
- let(:owner) { create(:user) }
- let(:group) { create(:group) }
-
- background do
- group.add_owner(owner)
- login_as(owner)
- visit group_path(group)
- end
-
- scenario 'user does not see a "Leave group" link' do
- expect(page).not_to have_content 'Leave group'
- end
-end
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
new file mode 100644
index 00000000000..067a2dc850f
--- /dev/null
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+feature 'Groups > Members > Leave group' do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:group) { create(:group) }
+
+ background do
+ gitlab_sign_in(user)
+ end
+
+ scenario 'guest leaves the group' do
+ group.add_guest(user)
+ group.add_owner(other_user)
+
+ visit group_path(group)
+ click_link 'Leave group'
+
+ expect(current_path).to eq(dashboard_groups_path)
+ expect(page).to have_content left_group_message(group)
+ expect(group.users).not_to include(user)
+ end
+
+ scenario 'guest leaves the group as last member' do
+ group.add_guest(user)
+
+ visit group_path(group)
+ click_link 'Leave group'
+
+ expect(current_path).to eq(dashboard_groups_path)
+ expect(page).to have_content left_group_message(group)
+ expect(group.users).not_to include(user)
+ end
+
+ scenario 'owner leaves the group if they is not the last owner' do
+ group.add_owner(user)
+ group.add_owner(other_user)
+
+ visit group_path(group)
+ click_link 'Leave group'
+
+ expect(current_path).to eq(dashboard_groups_path)
+ expect(page).to have_content left_group_message(group)
+ expect(group.users).not_to include(user)
+ end
+
+ scenario 'owner can not leave the group if they is a last owner' do
+ group.add_owner(user)
+
+ visit group_path(group)
+
+ expect(page).not_to have_content 'Leave group'
+
+ visit group_group_members_path(group)
+
+ expect(find(:css, '.project-members-page li', text: user.name)).not_to have_selector(:css, 'a.btn-remove')
+ end
+
+ def left_group_message(group)
+ "You left the \"#{group.name}\""
+ end
+end
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
new file mode 100644
index 00000000000..5c5d48c3623
--- /dev/null
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+feature 'Groups > Members > List members' do
+ include Select2Helper
+
+ let(:user1) { create(:user, name: 'John Doe') }
+ let(:user2) { create(:user, name: 'Mary Jane') }
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+
+ background do
+ gitlab_sign_in(user1)
+ end
+
+ scenario 'show members from current group and parent', :nested_groups do
+ group.add_developer(user1)
+ nested_group.add_developer(user2)
+
+ visit group_group_members_path(nested_group)
+
+ expect(first_row.text).to include(user1.name)
+ expect(second_row.text).to include(user2.name)
+ end
+
+ scenario 'show user once if member of both current group and parent', :nested_groups do
+ group.add_developer(user1)
+ nested_group.add_developer(user1)
+
+ visit group_group_members_path(nested_group)
+
+ expect(first_row.text).to include(user1.name)
+ expect(second_row).to be_blank
+ end
+
+ def first_row
+ page.all('ul.content-list > li')[0]
+ end
+
+ def second_row
+ page.all('ul.content-list > li')[1]
+ end
+end
diff --git a/spec/features/groups/members/list_spec.rb b/spec/features/groups/members/list_spec.rb
deleted file mode 100644
index 543879bd21d..00000000000
--- a/spec/features/groups/members/list_spec.rb
+++ /dev/null
@@ -1,105 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups members list', feature: true do
- include Select2Helper
-
- let(:user1) { create(:user, name: 'John Doe') }
- let(:user2) { create(:user, name: 'Mary Jane') }
- let(:group) { create(:group) }
- let(:nested_group) { create(:group, parent: group) }
-
- background do
- login_as(user1)
- end
-
- scenario 'show members from current group and parent' do
- group.add_developer(user1)
- nested_group.add_developer(user2)
-
- visit group_group_members_path(nested_group)
-
- expect(first_row.text).to include(user1.name)
- expect(second_row.text).to include(user2.name)
- end
-
- scenario 'show user once if member of both current group and parent' do
- group.add_developer(user1)
- nested_group.add_developer(user1)
-
- visit group_group_members_path(nested_group)
-
- expect(first_row.text).to include(user1.name)
- expect(second_row).to be_blank
- end
-
- scenario 'update user to owner level', :js do
- group.add_owner(user1)
- group.add_developer(user2)
-
- visit group_group_members_path(group)
-
- page.within(second_row) do
- click_button('Developer')
- click_link('Owner')
-
- expect(page).to have_button('Owner')
- end
- end
-
- scenario 'add user to group', :js do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- add_user(user2.id, 'Reporter')
-
- page.within(second_row) do
- expect(page).to have_content(user2.name)
- expect(page).to have_button('Reporter')
- end
- end
-
- scenario 'add yourself to group when already an owner', :js do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- add_user(user1.id, 'Reporter')
-
- page.within(first_row) do
- expect(page).to have_content(user1.name)
- expect(page).to have_content('Owner')
- end
- end
-
- scenario 'invite user to group', :js do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- add_user('test@example.com', 'Reporter')
-
- page.within(second_row) do
- expect(page).to have_content('test@example.com')
- expect(page).to have_content('Invited')
- expect(page).to have_button('Reporter')
- end
- end
-
- def first_row
- page.all('ul.content-list > li')[0]
- end
-
- def second_row
- page.all('ul.content-list > li')[1]
- end
-
- def add_user(id, role)
- page.within ".users-group-form" do
- select2(id, from: "#user_ids", multiple: true)
- select(role, from: "access_level")
- end
-
- click_button "Add to group"
- end
-end
diff --git a/spec/features/groups/members/manage_access_requests_spec.rb b/spec/features/groups/members/manage_access_requests_spec.rb
new file mode 100644
index 00000000000..b83cd657ef7
--- /dev/null
+++ b/spec/features/groups/members/manage_access_requests_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+feature 'Groups > Members > Manage access requests' do
+ let(:user) { create(:user) }
+ let(:owner) { create(:user) }
+ let(:group) { create(:group, :public, :access_requestable) }
+
+ background do
+ group.request_access(user)
+ group.add_owner(owner)
+ sign_in(owner)
+ end
+
+ scenario 'owner can see access requests' do
+ visit group_group_members_path(group)
+
+ expect_visible_access_request(group, user)
+ end
+
+ scenario 'owner can grant access' do
+ visit group_group_members_path(group)
+
+ expect_visible_access_request(group, user)
+
+ perform_enqueued_jobs { click_on 'Grant access' }
+
+ expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email]
+ expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was granted"
+ end
+
+ scenario 'owner can deny access' do
+ visit group_group_members_path(group)
+
+ expect_visible_access_request(group, user)
+
+ perform_enqueued_jobs { click_on 'Deny access' }
+
+ expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email]
+ expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was denied"
+ end
+
+ def expect_visible_access_request(group, user)
+ expect(group.requesters.exists?(user_id: user)).to be_truthy
+ expect(page).to have_content "Users requesting access to #{group.name} 1"
+ expect(page).to have_content user.name
+ end
+end
diff --git a/spec/features/groups/members/manage_members.rb b/spec/features/groups/members/manage_members.rb
new file mode 100644
index 00000000000..9039b283393
--- /dev/null
+++ b/spec/features/groups/members/manage_members.rb
@@ -0,0 +1,113 @@
+require 'spec_helper'
+
+feature 'Groups > Members > Manage members' do
+ include Select2Helper
+
+ let(:user1) { create(:user, name: 'John Doe') }
+ let(:user2) { create(:user, name: 'Mary Jane') }
+ let(:group) { create(:group) }
+
+ background do
+ sign_in(user1)
+ end
+
+ scenario 'update user to owner level', :js do
+ group.add_owner(user1)
+ group.add_developer(user2)
+
+ visit group_group_members_path(group)
+
+ page.within(second_row) do
+ click_button('Developer')
+ click_link('Owner')
+
+ expect(page).to have_button('Owner')
+ end
+ end
+
+ scenario 'add user to group', :js do
+ group.add_owner(user1)
+
+ visit group_group_members_path(group)
+
+ add_user(user2.id, 'Reporter')
+
+ page.within(second_row) do
+ expect(page).to have_content(user2.name)
+ expect(page).to have_button('Reporter')
+ end
+ end
+
+ scenario 'remove user from group', :js do
+ group.add_owner(user1)
+ group.add_developer(user2)
+
+ visit group_group_members_path(group)
+
+ find(:css, '.project-members-page li', text: user2.name).find(:css, 'a.btn-remove').click
+
+ expect(page).not_to have_content(user2.name)
+ expect(group.users).not_to include(user2)
+ end
+
+ scenario 'add yourself to group when already an owner', :js do
+ group.add_owner(user1)
+
+ visit group_group_members_path(group)
+
+ add_user(user1.id, 'Reporter')
+
+ page.within(first_row) do
+ expect(page).to have_content(user1.name)
+ expect(page).to have_content('Owner')
+ end
+ end
+
+ scenario 'invite user to group', :js do
+ group.add_owner(user1)
+
+ visit group_group_members_path(group)
+
+ add_user('test@example.com', 'Reporter')
+
+ page.within(second_row) do
+ expect(page).to have_content('test@example.com')
+ expect(page).to have_content('Invited')
+ expect(page).to have_button('Reporter')
+ end
+ end
+
+ scenario 'guest can not manage other users' do
+ group.add_guest(user1)
+ group.add_developer(user2)
+
+ visit group_group_members_path(group)
+
+ expect(page).not_to have_button 'Add to group'
+
+ page.within(second_row) do
+ # Can not modify user2 role
+ expect(page).not_to have_button 'Developer'
+
+ # Can not remove user2
+ expect(page).not_to have_css('a.btn-remove')
+ end
+ end
+
+ def first_row
+ page.all('ul.content-list > li')[0]
+ end
+
+ def second_row
+ page.all('ul.content-list > li')[1]
+ end
+
+ def add_user(id, role)
+ page.within ".users-group-form" do
+ select2(id, from: "#user_ids", multiple: true)
+ select(role, from: "access_level")
+ end
+
+ click_button "Add to group"
+ end
+end
diff --git a/spec/features/groups/members/member_cannot_request_access_to_his_project_spec.rb b/spec/features/groups/members/member_cannot_request_access_to_his_project_spec.rb
deleted file mode 100644
index 37c433cc09a..00000000000
--- a/spec/features/groups/members/member_cannot_request_access_to_his_project_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > Member cannot request access to his project', feature: true do
- let(:member) { create(:user) }
- let(:group) { create(:group) }
-
- background do
- group.add_developer(member)
- login_as(member)
- visit group_path(group)
- end
-
- scenario 'member does not see the request access button' do
- expect(page).not_to have_content 'Request Access'
- end
-end
diff --git a/spec/features/groups/members/member_leaves_group_spec.rb b/spec/features/groups/members/member_leaves_group_spec.rb
deleted file mode 100644
index ac4d94658ae..00000000000
--- a/spec/features/groups/members/member_leaves_group_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > Member leaves group', feature: true do
- let(:user) { create(:user) }
- let(:owner) { create(:user) }
- let(:group) { create(:group, :public) }
-
- background do
- group.add_owner(owner)
- group.add_developer(user)
- login_as(user)
- visit group_path(group)
- end
-
- scenario 'user leaves group' do
- click_link 'Leave group'
-
- expect(current_path).to eq(dashboard_groups_path)
- expect(group.users.exists?(user.id)).to be_falsey
- end
-end
diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb
deleted file mode 100644
index dbe150823ba..00000000000
--- a/spec/features/groups/members/owner_manages_access_requests_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > Owner manages access requests', feature: true do
- let(:user) { create(:user) }
- let(:owner) { create(:user) }
- let(:group) { create(:group, :public, :access_requestable) }
-
- background do
- group.request_access(user)
- group.add_owner(owner)
- login_as(owner)
- end
-
- scenario 'owner can see access requests' do
- visit group_group_members_path(group)
-
- expect_visible_access_request(group, user)
- end
-
- scenario 'master can grant access' do
- visit group_group_members_path(group)
-
- expect_visible_access_request(group, user)
-
- perform_enqueued_jobs { click_on 'Grant access' }
-
- expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email]
- expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was granted"
- end
-
- scenario 'master can deny access' do
- visit group_group_members_path(group)
-
- expect_visible_access_request(group, user)
-
- perform_enqueued_jobs { click_on 'Deny access' }
-
- expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email]
- expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was denied"
- end
-
- def expect_visible_access_request(group, user)
- expect(group.requesters.exists?(user_id: user)).to be_truthy
- expect(page).to have_content "Users requesting access to #{group.name} 1"
- expect(page).to have_content user.name
- end
-end
diff --git a/spec/features/groups/members/request_access_spec.rb b/spec/features/groups/members/request_access_spec.rb
new file mode 100644
index 00000000000..1f3c7fd3859
--- /dev/null
+++ b/spec/features/groups/members/request_access_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+feature 'Groups > Members > Request access' do
+ let(:user) { create(:user) }
+ let(:owner) { create(:user) }
+ let(:group) { create(:group, :public, :access_requestable) }
+ let!(:project) { create(:project, :private, namespace: group) }
+
+ background do
+ group.add_owner(owner)
+ sign_in(user)
+ visit group_path(group)
+ end
+
+ scenario 'request access feature is disabled' do
+ group.update_attributes(request_access_enabled: false)
+ visit group_path(group)
+
+ expect(page).not_to have_content 'Request Access'
+ end
+
+ scenario 'user can request access to a group' do
+ perform_enqueued_jobs { click_link 'Request Access' }
+
+ expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email]
+ expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group"
+
+ expect(group.requesters.exists?(user_id: user)).to be_truthy
+ expect(page).to have_content 'Your request for access has been queued for review.'
+
+ expect(page).to have_content 'Withdraw Access Request'
+ expect(page).not_to have_content 'Leave group'
+ end
+
+ scenario 'user does not see private projects' do
+ perform_enqueued_jobs { click_link 'Request Access' }
+
+ expect(page).not_to have_content project.name
+ end
+
+ scenario 'user does not see group in the Dashboard > Groups page' do
+ perform_enqueued_jobs { click_link 'Request Access' }
+
+ visit dashboard_groups_path
+
+ expect(page).not_to have_content group.name
+ end
+
+ scenario 'user is not listed in the group members page' do
+ click_link 'Request Access'
+
+ expect(group.requesters.exists?(user_id: user)).to be_truthy
+
+ click_link 'Members'
+
+ page.within('.content') do
+ expect(page).not_to have_content(user.name)
+ end
+ end
+
+ scenario 'user can withdraw its request for access' do
+ click_link 'Request Access'
+
+ expect(group.requesters.exists?(user_id: user)).to be_truthy
+
+ click_link 'Withdraw Access Request'
+
+ expect(group.requesters.exists?(user_id: user)).to be_falsey
+ expect(page).to have_content 'Your access request to the group has been withdrawn.'
+ end
+
+ scenario 'member does not see the request access button' do
+ group.add_owner(user)
+ visit group_path(group)
+
+ expect(page).not_to have_content 'Request Access'
+ end
+end
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
new file mode 100644
index 00000000000..e175ad04f86
--- /dev/null
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -0,0 +1,98 @@
+require 'spec_helper'
+
+feature 'Groups > Members > Sort members' do
+ let(:owner) { create(:user, name: 'John Doe') }
+ let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
+ let(:group) { create(:group) }
+
+ background do
+ create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
+ create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
+
+ sign_in(owner)
+ end
+
+ scenario 'sorts alphabetically by default' do
+ visit_members_list(sort: nil)
+
+ expect(first_member).to include(owner.name)
+ expect(second_member).to include(developer.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ end
+
+ scenario 'sorts by access level ascending' do
+ visit_members_list(sort: :access_level_asc)
+
+ expect(first_member).to include(developer.name)
+ expect(second_member).to include(owner.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
+ end
+
+ scenario 'sorts by access level descending' do
+ visit_members_list(sort: :access_level_desc)
+
+ expect(first_member).to include(owner.name)
+ expect(second_member).to include(developer.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
+ end
+
+ scenario 'sorts by last joined' do
+ visit_members_list(sort: :last_joined)
+
+ expect(first_member).to include(developer.name)
+ expect(second_member).to include(owner.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
+ end
+
+ scenario 'sorts by oldest joined' do
+ visit_members_list(sort: :oldest_joined)
+
+ expect(first_member).to include(owner.name)
+ expect(second_member).to include(developer.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
+ end
+
+ scenario 'sorts by name ascending' do
+ visit_members_list(sort: :name_asc)
+
+ expect(first_member).to include(owner.name)
+ expect(second_member).to include(developer.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ end
+
+ scenario 'sorts by name descending' do
+ visit_members_list(sort: :name_desc)
+
+ expect(first_member).to include(developer.name)
+ expect(second_member).to include(owner.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
+ end
+
+ scenario 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
+ visit_members_list(sort: :recent_sign_in)
+
+ expect(first_member).to include(owner.name)
+ expect(second_member).to include(developer.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
+ end
+
+ scenario 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
+ visit_members_list(sort: :oldest_sign_in)
+
+ expect(first_member).to include(developer.name)
+ expect(second_member).to include(owner.name)
+ expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
+ end
+
+ def visit_members_list(sort:)
+ visit group_group_members_path(group.to_param, sort: sort)
+ end
+
+ def first_member
+ page.all('ul.content-list > li').first.text
+ end
+
+ def second_member
+ page.all('ul.content-list > li').last.text
+ end
+end
diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb
deleted file mode 100644
index 608aedd3471..00000000000
--- a/spec/features/groups/members/sorting_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > Sorting', feature: true do
- let(:owner) { create(:user, name: 'John Doe') }
- let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
- let(:group) { create(:group) }
-
- background do
- create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
- create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
-
- login_as(owner)
- end
-
- scenario 'sorts alphabetically by default' do
- visit_members_list(sort: nil)
-
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
- end
-
- scenario 'sorts by access level ascending' do
- visit_members_list(sort: :access_level_asc)
-
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
- end
-
- scenario 'sorts by access level descending' do
- visit_members_list(sort: :access_level_desc)
-
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
- end
-
- scenario 'sorts by last joined' do
- visit_members_list(sort: :last_joined)
-
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
- end
-
- scenario 'sorts by oldest joined' do
- visit_members_list(sort: :oldest_joined)
-
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
- end
-
- scenario 'sorts by name ascending' do
- visit_members_list(sort: :name_asc)
-
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
- end
-
- scenario 'sorts by name descending' do
- visit_members_list(sort: :name_desc)
-
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
- end
-
- scenario 'sorts by recent sign in' do
- visit_members_list(sort: :recent_sign_in)
-
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
- end
-
- scenario 'sorts by oldest sign in' do
- visit_members_list(sort: :oldest_sign_in)
-
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
- end
-
- def visit_members_list(sort:)
- visit group_group_members_path(group.to_param, sort: sort)
- end
-
- def first_member
- page.all('ul.content-list > li').first.text
- end
-
- def second_member
- page.all('ul.content-list > li').last.text
- end
-end
diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb
deleted file mode 100644
index e4b5ea91bd3..00000000000
--- a/spec/features/groups/members/user_requests_access_spec.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-require 'spec_helper'
-
-feature 'Groups > Members > User requests access', feature: true do
- let(:user) { create(:user) }
- let(:owner) { create(:user) }
- let(:group) { create(:group, :public, :access_requestable) }
- let!(:project) { create(:project, :private, namespace: group) }
-
- background do
- group.add_owner(owner)
- login_as(user)
- visit group_path(group)
- end
-
- scenario 'request access feature is disabled' do
- group.update_attributes(request_access_enabled: false)
- visit group_path(group)
-
- expect(page).not_to have_content 'Request Access'
- end
-
- scenario 'user can request access to a group' do
- perform_enqueued_jobs { click_link 'Request Access' }
-
- expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email]
- expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group"
-
- expect(group.requesters.exists?(user_id: user)).to be_truthy
- expect(page).to have_content 'Your request for access has been queued for review.'
-
- expect(page).to have_content 'Withdraw Access Request'
- expect(page).not_to have_content 'Leave group'
- end
-
- scenario 'user does not see private projects' do
- perform_enqueued_jobs { click_link 'Request Access' }
-
- expect(page).not_to have_content project.name
- end
-
- scenario 'user does not see group in the Dashboard > Groups page' do
- perform_enqueued_jobs { click_link 'Request Access' }
-
- visit dashboard_groups_path
-
- expect(page).not_to have_content group.name
- end
-
- scenario 'user is not listed in the group members page' do
- click_link 'Request Access'
-
- expect(group.requesters.exists?(user_id: user)).to be_truthy
-
- click_link 'Members'
-
- page.within('.content') do
- expect(page).not_to have_content(user.name)
- end
- end
-
- scenario 'user can withdraw its request for access' do
- click_link 'Request Access'
-
- expect(group.requesters.exists?(user_id: user)).to be_truthy
-
- click_link 'Withdraw Access Request'
-
- expect(group.requesters.exists?(user_id: user)).to be_falsey
- expect(page).to have_content 'Your access request to the group has been withdrawn.'
- end
-end
diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb
index b55078c3bf6..c2241feb9f7 100644
--- a/spec/features/groups/merge_requests_spec.rb
+++ b/spec/features/groups/merge_requests_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Group merge requests page', feature: true do
+feature 'Group merge requests page' do
let(:path) { merge_requests_group_path(group) }
let(:issuable) { create(:merge_request, source_project: project, target_project: project, title: 'this is my created issuable') }
include_examples 'project features apply to issuables', MergeRequest
context 'archived issuable' do
- let(:project_archived) { create(:project, :archived, :merge_requests_enabled, group: group) }
+ let(:project_archived) { create(:project, :archived, :merge_requests_enabled, :repository, group: group) }
let(:issuable_archived) { create(:merge_request, source_project: project_archived, target_project: project_archived, title: 'issuable of an archived project') }
let(:access_level) { ProjectFeature::ENABLED }
let(:user) { user_in_group }
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index daa2c6afd63..56144d17d4f 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -1,18 +1,16 @@
require 'rails_helper'
-feature 'Group milestones', :feature, :js do
+feature 'Group milestones', :js do
let(:group) { create(:group) }
let!(:project) { create(:project_empty_repo, group: group) }
let(:user) { create(:group_member, :master, user: create(:user), group: group ).user }
- before do
- Timecop.freeze
-
- login_as(user)
+ around do |example|
+ Timecop.freeze { example.run }
end
- after do
- Timecop.return
+ before do
+ sign_in(user)
end
context 'create a milestone' do
@@ -33,4 +31,57 @@ feature 'Group milestones', :feature, :js do
expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y'))
end
end
+
+ context 'milestones list' do
+ let!(:other_project) { create(:project_empty_repo, group: group) }
+
+ let!(:active_project_milestone1) { create(:milestone, project: project, state: 'active', title: 'v1.0') }
+ let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') }
+ let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') }
+ let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') }
+ let!(:active_group_milestone) { create(:milestone, group: group, state: 'active') }
+ let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') }
+
+ before do
+ visit group_milestones_path(group)
+ end
+
+ it 'counts milestones correctly' do
+ expect(find('.top-area .active .badge').text).to eq("2")
+ expect(find('.top-area .closed .badge').text).to eq("2")
+ expect(find('.top-area .all .badge').text).to eq("4")
+ end
+
+ it 'lists legacy group milestones and group milestones' do
+ legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first
+
+ expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1)
+ expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1)
+ end
+
+ it 'updates milestone' do
+ page.within(".milestones #milestone_#{active_group_milestone.id}") do
+ click_link('Edit')
+ end
+
+ page.within('.milestone-form') do
+ fill_in 'milestone_title', with: 'new title'
+ click_button('Update milestone')
+ end
+
+ expect(find('#content-body h2')).to have_content('new title')
+ end
+
+ it 'shows milestone detail and supports its edit' do
+ page.within(".milestones #milestone_#{active_group_milestone.id}") do
+ click_link(active_group_milestone.title)
+ end
+
+ page.within('.detail-page-header') do
+ click_link('Edit')
+ end
+
+ expect(page).to have_selector('.milestone-form')
+ end
+ end
end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index fb39693e8ca..303013e59d5 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -1,17 +1,20 @@
require 'spec_helper'
-feature 'Group show page', feature: true do
+feature 'Group show page' do
let(:group) { create(:group) }
let(:path) { group_path(group) }
context 'when signed in' do
+ let(:user) do
+ create(:group_member, :developer, user: create(:user), group: group ).user
+ end
+
before do
- user = create(:group_member, :developer, user: create(:user), group: group ).user
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -19,6 +22,6 @@ feature 'Group show page', feature: true do
visit path
end
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 3d32c47bf09..e59a484d992 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Group', feature: true do
+feature 'Group' do
before do
- login_as(:admin)
+ sign_in(create(:admin))
end
matcher :have_namespace_error_message do
@@ -12,7 +12,9 @@ feature 'Group', feature: true do
end
describe 'create a group' do
- before { visit new_group_path }
+ before do
+ visit new_group_path
+ end
describe 'with space in group path' do
it 'renders new group form with validation errors' do
@@ -83,7 +85,7 @@ feature 'Group', feature: true do
end
end
- describe 'create a nested group', js: true do
+ describe 'create a nested group', :nested_groups, js: true do
let(:group) { create(:group, path: 'foo') }
context 'as admin' do
@@ -106,8 +108,8 @@ feature 'Group', feature: true do
before do
group.add_owner(user)
- logout
- login_as(user)
+ sign_out(:user)
+ sign_in(user)
visit subgroups_group_path(group)
click_link 'New Subgroup'
@@ -126,19 +128,21 @@ feature 'Group', feature: true do
it 'checks permissions to avoid exposing groups by parent_id' do
group = create(:group, :private, path: 'secret-group')
- logout
- login_as(:user)
+ sign_out(:user)
+ sign_in(create(:user))
visit new_group_path(parent_id: group.id)
expect(page).not_to have_content('secret-group')
end
- describe 'group edit' do
+ describe 'group edit', js: true do
let(:group) { create(:group) }
let(:path) { edit_group_path(group) }
let(:new_name) { 'new-name' }
- before { visit path }
+ before do
+ visit path
+ end
it 'saves new settings' do
fill_in 'group_name', with: new_name
@@ -153,8 +157,8 @@ feature 'Group', feature: true do
end
it 'removes group' do
- click_link 'Remove group'
-
+ expect { remove_with_confirm('Remove group', group.path) }.to change {Group.count}.by(-1)
+ expect(group.members.all.count).to be_zero
expect(page).to have_content "scheduled for deletion"
end
end
@@ -196,7 +200,7 @@ feature 'Group', feature: true do
end
end
- describe 'group page with nested groups', js: true do
+ describe 'group page with nested groups', :nested_groups, js: true do
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
let!(:path) { group_path(group) }
@@ -208,4 +212,10 @@ feature 'Group', feature: true do
expect(page).to have_content(nested_group.name)
end
end
+
+ def remove_with_confirm(button_text, confirm_with)
+ click_button button_text
+ fill_in 'confirm_name_input', with: confirm_with
+ click_button 'Confirm'
+ end
end
diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb
index e0b2404e60a..93be3b066ee 100644
--- a/spec/features/help_pages_spec.rb
+++ b/spec/features/help_pages_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Help Pages', feature: true do
+describe 'Help Pages' do
describe 'Get the main help page' do
shared_examples_for 'help page' do |prefix: ''|
it 'prefixes links correctly' do
@@ -34,29 +34,46 @@ describe 'Help Pages', feature: true do
end
end
- context 'in a production environment with version check enabled', js: true do
+ context 'in a production environment with version check enabled', :js do
before do
allow(Rails.env).to receive(:production?) { true }
- allow(current_application_settings).to receive(:version_check_enabled) { true }
+ allow_any_instance_of(ApplicationSetting).to receive(:version_check_enabled) { true }
allow_any_instance_of(VersionCheck).to receive(:url) { '/version-check-url' }
- login_as :user
+ sign_in(create(:user))
visit help_path
end
- it 'should display a version check image' do
- expect(find('.js-version-status-badge')).to be_visible
+ it 'has a version check image' do
+ expect(find('.js-version-status-badge', visible: false)['src']).to end_with('/version-check-url')
end
- it 'should have a src url' do
- expect(find('.js-version-status-badge')['src']).to match(/\/version-check-url/)
+ it 'hides the version check image if the image request fails' do
+ # We use '--load-images=yes' with poltergeist so the image fails to load
+ expect(page).to have_selector('.js-version-status-badge', visible: false)
end
+ end
+
+ describe 'when help page is customized' do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:help_page_hide_commercial_content?) { true }
+ allow_any_instance_of(ApplicationSetting).to receive(:help_page_text) { "My Custom Text" }
+ allow_any_instance_of(ApplicationSetting).to receive(:help_page_support_url) { "http://example.com/help" }
- it 'should hide the version check image if the image request fails' do
- # We use '--load-images=no' with poltergeist so we must trigger manually
- execute_script("$('.js-version-status-badge').trigger('error');")
+ sign_in(create(:user))
+ visit help_path
+ end
+
+ it 'should display custom help page text' do
+ expect(page).to have_text "My Custom Text"
+ end
+
+ it 'should hide marketing content when enabled' do
+ expect(page).not_to have_link "Get a support subscription"
+ end
- expect(find('.js-version-status-badge', visible: false)).not_to be_visible
+ it 'should use a custom support url' do
+ expect(page).to have_link "See our website for getting help", href: "http://example.com/help"
end
end
end
diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb
new file mode 100644
index 00000000000..3df77a104e8
--- /dev/null
+++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb
@@ -0,0 +1,116 @@
+require 'spec_helper'
+
+describe 'Issuables Close/Reopen/Report toggle' do
+ let(:user) { create(:user) }
+
+ shared_examples 'an issuable close/reopen/report toggle' do
+ let(:container) { find('.issuable-close-dropdown') }
+ let(:human_model_name) { issuable.model_name.human.downcase }
+
+ it 'shows toggle' do
+ expect(page).to have_link("Close #{human_model_name}")
+ expect(page).to have_selector('.issuable-close-dropdown')
+ end
+
+ it 'opens a dropdown when toggle is clicked' do
+ container.find('.dropdown-toggle').click
+
+ expect(container).to have_selector('.dropdown-menu')
+ expect(container).to have_content("Close #{human_model_name}")
+ expect(container).to have_content('Report abuse')
+ expect(container).to have_content("Report #{human_model_name.pluralize} that are abusive, inappropriate or spam.")
+ expect(container).to have_selector('.close-item.droplab-item-selected')
+ expect(container).to have_selector('.report-item')
+ expect(container).not_to have_selector('.report-item.droplab-item-selected')
+ expect(container).not_to have_selector('.reopen-item')
+ end
+
+ it 'changes the button when an item is selected' do
+ button = container.find('.issuable-close-button')
+
+ container.find('.dropdown-toggle').click
+ container.find('.report-item').click
+
+ expect(container).not_to have_selector('.dropdown-menu')
+ expect(button).to have_content('Report abuse')
+
+ container.find('.dropdown-toggle').click
+ container.find('.close-item').click
+
+ expect(button).to have_content("Close #{human_model_name}")
+ end
+ end
+
+ context 'on an issue' do
+ let(:project) { create(:project) }
+ let(:issuable) { create(:issue, project: project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+ end
+
+ context 'when user has permission to update', :js do
+ before do
+ visit project_issue_path(project, issuable)
+ end
+
+ it_behaves_like 'an issuable close/reopen/report toggle'
+ end
+
+ context 'when user doesnt have permission to update' do
+ let(:cant_project) { create(:project) }
+ let(:cant_issuable) { create(:issue, project: cant_project) }
+
+ before do
+ cant_project.add_guest(user)
+
+ visit project_issue_path(cant_project, cant_issuable)
+ end
+
+ it 'only shows the `Report abuse` and `New issue` buttons' do
+ expect(page).to have_link('Report abuse')
+ expect(page).to have_link('New issue')
+ expect(page).not_to have_link('Close issue')
+ expect(page).not_to have_link('Reopen issue')
+ expect(page).not_to have_link('Edit')
+ end
+ end
+ end
+
+ context 'on a merge request' do
+ let(:project) { create(:project, :repository) }
+ let(:issuable) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_master(user)
+ login_as user
+ end
+
+ context 'when user has permission to update', :js do
+ before do
+ visit project_merge_request_path(project, issuable)
+ end
+
+ it_behaves_like 'an issuable close/reopen/report toggle'
+ end
+
+ context 'when user doesnt have permission to update' do
+ let(:cant_project) { create(:project, :repository) }
+ let(:cant_issuable) { create(:merge_request, source_project: cant_project) }
+
+ before do
+ cant_project.add_reporter(user)
+
+ visit project_merge_request_path(cant_project, cant_issuable)
+ end
+
+ it 'only shows a `Report abuse` button' do
+ expect(page).to have_link('Report abuse')
+ expect(page).not_to have_link('Close merge request')
+ expect(page).not_to have_link('Reopen merge request')
+ expect(page).not_to have_link('Edit')
+ end
+ end
+ end
+end
diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb
index bfe43bff10f..b72b690110f 100644
--- a/spec/features/issuables/default_sort_order_spec.rb
+++ b/spec/features/issuables/default_sort_order_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe 'Projects > Issuables > Default sort order', feature: true do
- let(:project) { create(:empty_project, :public) }
+describe 'Projects > Issuables > Default sort order' do
+ let(:project) { create(:project, :public) }
let(:first_created_issuable) { issuables.order_created_asc.first }
let(:last_created_issuable) { issuables.order_created_desc.first }
@@ -153,7 +153,9 @@ describe 'Projects > Issuables > Default sort order', feature: true do
context 'when the sort in the URL is id_desc' do
let(:issuable_type) { :issue }
- before { visit_issues(project, sort: 'id_desc') }
+ before do
+ visit_issues(project, sort: 'id_desc')
+ end
it 'shows the sort order as last created' do
expect(find('.issues-other-filters')).to have_content('Last created')
@@ -165,7 +167,9 @@ describe 'Projects > Issuables > Default sort order', feature: true do
context 'when the sort in the URL is id_asc' do
let(:issuable_type) { :issue }
- before { visit_issues(project, sort: 'id_asc') }
+ before do
+ visit_issues(project, sort: 'id_asc')
+ end
it 'shows the sort order as oldest created' do
expect(find('.issues-other-filters')).to have_content('Oldest created')
diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb
index f3ec80bb149..2f45ef856a5 100644
--- a/spec/features/issuables/issuable_list_spec.rb
+++ b/spec/features/issuables/issuable_list_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-describe 'issuable list', feature: true do
- let(:project) { create(:empty_project) }
+describe 'issuable list' do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
issuable_types = [:issue, :merge_request]
before do
project.add_user(user, :developer)
- login_as(user)
+ sign_in(user)
issuable_types.each { |type| create_issuables(type) }
end
@@ -39,9 +39,9 @@ describe 'issuable list', feature: true do
def visit_issuable_list(issuable_type)
if issuable_type == :issue
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
else
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
end
@@ -52,6 +52,9 @@ describe 'issuable list', feature: true do
create(:issue, project: project, author: user)
else
create(:merge_request, source_project: project, source_branch: generate(:branch))
+ source_branch = FFaker::Name.name
+ pipeline = create(:ci_empty_pipeline, project: project, ref: source_branch, status: %w(running failed success).sample, sha: 'any')
+ create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline)
end
2.times do
diff --git a/spec/features/issuables/markdown_references_spec.rb b/spec/features/issuables/markdown_references_spec.rb
new file mode 100644
index 00000000000..dd4e10a9886
--- /dev/null
+++ b/spec/features/issuables/markdown_references_spec.rb
@@ -0,0 +1,193 @@
+require 'rails_helper'
+
+describe 'Markdown References', :js do
+ let(:user) { create(:user) }
+ let(:actual_project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, target_project: actual_project, source_project: actual_project)}
+ let(:issue_actual_project) { create(:issue, project: actual_project) }
+ let!(:other_project) { create(:project, :public) }
+ let!(:issue_other_project) { create(:issue, project: other_project) }
+ let(:issues) { [issue_actual_project, issue_other_project] }
+
+ def build_note
+ markdown = "Referencing internal issue #{issue_actual_project.to_reference}, " +
+ "cross-project #{issue_other_project.to_reference(actual_project)} external JIRA-5 " +
+ "and non existing #999"
+
+ page.within('#diff-notes-app') do
+ fill_in 'note_note', with: markdown
+ end
+ end
+
+ shared_examples 'correct references' do
+ before do
+ remotelink = double(:remotelink, all: [], build: double(save!: true))
+
+ stub_request(:get, "https://jira.example.com/rest/api/2/issue/JIRA-5")
+ stub_request(:post, "https://jira.example.com/rest/api/2/issue/JIRA-5/comment")
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:remotelink).and_return(remotelink)
+
+ sign_in(user)
+ visit merge_request_path(merge_request)
+ build_note
+ end
+
+ def links_expectations
+ issues.each do |issue|
+ if referenced_issues.include?(issue)
+ expect(page).to have_link(issue.to_reference, href: issue_path(issue))
+ else
+ expect(page).not_to have_link(issue.to_reference, href: issue_path(issue))
+ end
+ end
+
+ if jira_referenced
+ expect(page).to have_link('JIRA-5', href: 'https://jira.example.com/browse/JIRA-5')
+ else
+ expect(page).not_to have_link('JIRA-5', href: 'https://jira.example.com/browse/JIRA-5')
+ end
+
+ expect(page).not_to have_link('#999')
+ end
+
+ it 'creates a link to the referenced issue on the preview' do
+ find('.js-md-preview-button').click
+ wait_for_requests
+
+ page.within('.md-preview-holder') do
+ links_expectations
+ end
+ end
+
+ it 'creates a link to the referenced issue after submit' do
+ click_button 'Comment'
+ wait_for_requests
+
+ page.within('#diff-notes-app') do
+ links_expectations
+ end
+ end
+
+ it 'creates a note on the referenced issues' do
+ click_button 'Comment'
+ wait_for_requests
+
+ if referenced_issues.include?(issue_actual_project)
+ visit issue_path(issue_actual_project)
+
+ page.within('#notes') do
+ expect(page).to have_content(
+ "#{user.to_reference} mentioned in merge request #{merge_request.to_reference}"
+ )
+ end
+ end
+
+ if referenced_issues.include?(issue_other_project)
+ visit issue_path(issue_other_project)
+
+ page.within('#notes') do
+ expect(page).to have_content(
+ "#{user.to_reference} mentioned in merge request #{merge_request.to_reference(other_project)}"
+ )
+ end
+ end
+ end
+ end
+
+ context 'when internal issues tracker is enabled for the other project' do
+ context 'when only internal issues tracker is enabled for the actual project' do
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_actual_project, issue_other_project] }
+ let(:jira_referenced) { false }
+ end
+ end
+
+ context 'when both external and internal issues trackers are enabled for the actual project' do
+ before do
+ create(:jira_service, project: actual_project)
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_actual_project, issue_other_project] }
+ let(:jira_referenced) { true }
+ end
+ end
+
+ context 'when only external issues tracker is enabled for the actual project' do
+ before do
+ create(:jira_service, project: actual_project)
+
+ actual_project.issues_enabled = false
+ actual_project.save!
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_other_project] }
+ let(:jira_referenced) { true }
+ end
+ end
+
+ context 'when no tracker is enabled for the actual project' do
+ before do
+ actual_project.issues_enabled = false
+ actual_project.save!
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_other_project] }
+ let(:jira_referenced) { false }
+ end
+ end
+ end
+
+ context 'when internal issues tracker is disabled for the other project' do
+ before do
+ other_project.issues_enabled = false
+ other_project.save!
+ end
+
+ context 'when only internal issues tracker is enabled for the actual project' do
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_actual_project] }
+ let(:jira_referenced) { false }
+ end
+ end
+
+ context 'when both external and internal issues trackers are enabled for the actual project' do
+ before do
+ create(:jira_service, project: actual_project)
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [issue_actual_project] }
+ let(:jira_referenced) { true }
+ end
+ end
+
+ context 'when only external issues tracker is enabled for the actual project' do
+ before do
+ create(:jira_service, project: actual_project)
+
+ actual_project.issues_enabled = false
+ actual_project.save!
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [] }
+ let(:jira_referenced) { true }
+ end
+ end
+
+ context 'when no issues tracker is enabled for the actual project' do
+ before do
+ actual_project.issues_enabled = false
+ actual_project.save!
+ end
+
+ include_examples 'correct references' do
+ let(:referenced_issues) { [] }
+ let(:jira_referenced) { false }
+ end
+ end
+ end
+end
diff --git a/spec/features/issuables/user_sees_sidebar_spec.rb b/spec/features/issuables/user_sees_sidebar_spec.rb
new file mode 100644
index 00000000000..2bd1c8aab86
--- /dev/null
+++ b/spec/features/issuables/user_sees_sidebar_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+describe 'Issue Sidebar on Mobile' do
+ include MobileHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:issue) { create(:issue, project: project) }
+ let!(:user) { create(:user)}
+
+ before do
+ sign_in(user)
+ end
+
+ context 'mobile sidebar on merge requests', js: true do
+ before do
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ it_behaves_like "issue sidebar stays collapsed on mobile"
+ end
+
+ context 'mobile sidebar on issues', js: true do
+ before do
+ visit project_issue_path(project, issue)
+ end
+
+ it_behaves_like "issue sidebar stays collapsed on mobile"
+ end
+end
diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb
index 71df3c949db..134e618feac 100644
--- a/spec/features/issues/award_emoji_spec.rb
+++ b/spec/features/issues/award_emoji_spec.rb
@@ -1,28 +1,26 @@
require 'rails_helper'
-describe 'Awards Emoji', feature: true do
- include WaitForVueResource
-
+describe 'Awards Emoji' do
let!(:project) { create(:project, :public) }
let!(:user) { create(:user) }
let(:issue) do
create(:issue,
- assignee: @user,
+ assignees: [user],
project: project)
end
context 'authorized user' do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
describe 'visiting an issue with a legacy award emoji that is not valid anymore' do
before do
# The `heart_tip` emoji is not valid anymore so we need to skip validation
issue.award_emoji.build(user: user, name: 'heart_tip').save!(validate: false)
- visit namespace_project_issue_path(project.namespace, project, issue)
- wait_for_vue_resource
+ visit project_issue_path(project, issue)
+ wait_for_requests
end
# Regression test: https://gitlab.com/gitlab-org/gitlab-ce/issues/29529
@@ -35,20 +33,20 @@ describe 'Awards Emoji', feature: true do
let!(:note) { create(:note_on_issue, noteable: issue, project: issue.project, note: "Hello world") }
before do
- visit namespace_project_issue_path(project.namespace, project, issue)
- wait_for_vue_resource
+ visit project_issue_path(project, issue)
+ wait_for_requests
end
it 'increments the thumbsdown emoji', js: true do
find('[data-name="thumbsdown"]').click
- wait_for_ajax
+ wait_for_requests
expect(thumbsdown_emoji).to have_text("1")
end
context 'click the thumbsup emoji' do
it 'increments the thumbsup emoji', js: true do
find('[data-name="thumbsup"]').click
- wait_for_ajax
+ wait_for_requests
expect(thumbsup_emoji).to have_text("1")
end
@@ -60,7 +58,7 @@ describe 'Awards Emoji', feature: true do
context 'click the thumbsdown emoji' do
it 'increments the thumbsdown emoji', js: true do
find('[data-name="thumbsdown"]').click
- wait_for_ajax
+ wait_for_requests
expect(thumbsdown_emoji).to have_text("1")
end
@@ -83,13 +81,13 @@ describe 'Awards Emoji', feature: true do
end
end
- context 'execute /award slash command' do
+ context 'execute /award quick action' do
it 'toggles the emoji award on noteable', js: true do
- execute_slash_command('/award :100:')
+ execute_quick_action('/award :100:')
expect(find(noteable_award_counter)).to have_text("1")
- execute_slash_command('/award :100:')
+ execute_quick_action('/award :100:')
expect(page).not_to have_selector(noteable_award_counter)
end
@@ -99,7 +97,7 @@ describe 'Awards Emoji', feature: true do
context 'unauthorized user', js: true do
before do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it 'has disabled emoji button' do
@@ -107,13 +105,13 @@ describe 'Awards Emoji', feature: true do
end
end
- def execute_slash_command(cmd)
+ def execute_quick_action(cmd)
within('.js-main-target-form') do
fill_in 'note[note]', with: cmd
click_button 'Comment'
end
- wait_for_ajax
+ wait_for_requests
end
def thumbsup_emoji
@@ -143,6 +141,6 @@ describe 'Awards Emoji', feature: true do
find('[data-name="smiley"]').click
end
- wait_for_ajax
+ wait_for_requests
end
end
diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb
index 401e1ea2b89..e95eb19f7d1 100644
--- a/spec/features/issues/award_spec.rb
+++ b/spec/features/issues/award_spec.rb
@@ -1,14 +1,15 @@
require 'rails_helper'
-feature 'Issue awards', js: true, feature: true do
+feature 'Issue awards', js: true do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
describe 'logged in' do
before do
- login_as(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ wait_for_requests
end
it 'adds award to issue' do
@@ -16,7 +17,7 @@ feature 'Issue awards', js: true, feature: true do
expect(page).to have_selector('.js-emoji-btn.active')
expect(first('.js-emoji-btn')).to have_content '1'
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
expect(first('.js-emoji-btn')).to have_content '1'
end
@@ -25,7 +26,7 @@ feature 'Issue awards', js: true, feature: true do
find('.js-emoji-btn.active').click
expect(first('.js-emoji-btn')).to have_content '0'
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
expect(first('.js-emoji-btn')).to have_content '0'
end
@@ -39,7 +40,8 @@ feature 'Issue awards', js: true, feature: true do
describe 'logged out' do
before do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
+ wait_for_requests
end
it 'does not see award menu button' do
diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb
index 1de50d6d77e..b2229b44f99 100644
--- a/spec/features/issues/bulk_assignment_labels_spec.rb
+++ b/spec/features/issues/bulk_assignment_labels_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-feature 'Issues > Labels bulk assignment', feature: true do
+feature 'Issues > Labels bulk assignment' do
let(:user) { create(:user) }
let!(:project) { create(:project) }
let!(:issue1) { create(:issue, project: project, title: "Issue 1") }
@@ -13,18 +13,33 @@ feature 'Issues > Labels bulk assignment', feature: true do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
+ end
+
+ context 'sidebar' do
+ before do
+ enable_bulk_update
+ end
+
+ it 'is present when bulk edit is enabled' do
+ expect(page).to have_css('.issuable-sidebar')
+ end
+
+ it 'is not present when bulk edit is disabled' do
+ disable_bulk_update
+ expect(page).not_to have_css('.issuable-sidebar')
+ end
end
context 'can bulk assign' do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
context 'a label' do
context 'to all issues' do
before do
- check 'check_all_issues'
+ check 'check-all-issues'
open_labels_dropdown ['bug']
update_issues
end
@@ -52,7 +67,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'multiple labels' do
context 'to all issues' do
before do
- check 'check_all_issues'
+ check 'check-all-issues'
open_labels_dropdown %w(bug feature)
update_issues
end
@@ -86,9 +101,10 @@ feature 'Issues > Labels bulk assignment', feature: true do
before do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
- check 'check_all_issues'
+ enable_bulk_update
+ check 'check-all-issues'
+
open_labels_dropdown ['bug']
update_issues
end
@@ -107,9 +123,8 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
- check 'check_all_issues'
+ enable_bulk_update
+ check 'check-all-issues'
unmark_labels_in_dropdown %w(bug feature)
update_issues
end
@@ -127,8 +142,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
+ enable_bulk_update
check_issue issue1
unmark_labels_in_dropdown ['bug']
update_issues
@@ -147,8 +161,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue2.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
-
+ enable_bulk_update
check_issue issue1
check_issue issue2
unmark_labels_in_dropdown ['bug']
@@ -171,14 +184,15 @@ feature 'Issues > Labels bulk assignment', feature: true do
before do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps labels' do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
- check 'check_all_issues'
+ check 'check-all-issues'
+
open_milestone_dropdown(['First Release'])
update_issues
@@ -192,14 +206,13 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'setting a milestone and adding another label' do
before do
issue1.labels << bug
-
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps existing label and new label is present' do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
- check 'check_all_issues'
+ check 'check-all-issues'
open_milestone_dropdown ['First Release']
open_labels_dropdown ['feature']
update_issues
@@ -218,7 +231,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << feature
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps existing label and new label is present' do
@@ -226,7 +239,8 @@ feature 'Issues > Labels bulk assignment', feature: true do
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
- check 'check_all_issues'
+ check 'check-all-issues'
+
open_milestone_dropdown ['First Release']
unmark_labels_in_dropdown ['feature']
update_issues
@@ -248,7 +262,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << bug
issue2.labels << feature
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'keeps labels' do
@@ -257,7 +271,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
expect(find("#issue_#{issue2.id}")).to have_content 'feature'
expect(find("#issue_#{issue2.id}")).to have_content 'First Release'
- check 'check_all_issues'
+ check 'check-all-issues'
open_milestone_dropdown(['No Milestone'])
update_issues
@@ -272,8 +286,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'toggling checked issues' do
before do
issue1.labels << bug
-
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it do
@@ -298,15 +311,15 @@ feature 'Issues > Labels bulk assignment', feature: true do
issue1.labels << feature
issue2.labels << bug
- visit namespace_project_issues_path(project.namespace, project)
+ enable_bulk_update
end
it 'applies label from filtered results' do
- check 'check_all_issues'
+ check 'check-all-issues'
- page.within('.issues_bulk_update') do
- click_button 'Labels'
- wait_for_ajax
+ page.within('.issues-bulk-update') do
+ click_button 'Select labels'
+ wait_for_requests
expect(find('.dropdown-menu-labels li', text: 'bug')).to have_css('.is-active')
expect(find('.dropdown-menu-labels li', text: 'feature')).to have_css('.is-indeterminate')
@@ -333,23 +346,24 @@ feature 'Issues > Labels bulk assignment', feature: true do
context 'as a guest' do
before do
- login_as user
+ sign_in user
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
context 'cannot bulk assign labels' do
it do
- expect(page).not_to have_css '.check_all_issues'
+ expect(page).not_to have_button 'Edit issues'
+ expect(page).not_to have_css '.check-all-issues'
expect(page).not_to have_css '.issue-check'
end
end
end
def open_milestone_dropdown(items = [])
- page.within('.issues_bulk_update') do
- click_button 'Milestone'
- wait_for_ajax
+ page.within('.issues-bulk-update') do
+ click_button 'Select milestone'
+ wait_for_requests
items.map do |item|
click_link item
end
@@ -357,9 +371,9 @@ feature 'Issues > Labels bulk assignment', feature: true do
end
def open_labels_dropdown(items = [], unmark = false)
- page.within('.issues_bulk_update') do
- click_button 'Labels'
- wait_for_ajax
+ page.within('.issues-bulk-update') do
+ click_button 'Select labels'
+ wait_for_requests
items.map do |item|
click_link item
end
@@ -391,7 +405,16 @@ feature 'Issues > Labels bulk assignment', feature: true do
end
def update_issues
- click_button 'Update issues'
- wait_for_ajax
+ click_button 'Update all'
+ wait_for_requests
+ end
+
+ def enable_bulk_update
+ visit project_issues_path(project)
+ click_button 'Edit issues'
+ end
+
+ def disable_bulk_update
+ click_button 'Cancel'
end
end
diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb
new file mode 100644
index 00000000000..546dc7e8a49
--- /dev/null
+++ b/spec/features/issues/create_branch_merge_request_spec.rb
@@ -0,0 +1,93 @@
+require 'rails_helper'
+
+feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :repository) }
+ let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
+
+ context 'for team members' do
+ before do
+ project.team << [user, :developer]
+ sign_in(user)
+ end
+
+ it 'allows creating a merge request from the issue page' do
+ visit project_issue_path(project, issue)
+
+ perform_enqueued_jobs do
+ select_dropdown_option('create-mr')
+
+ expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"')
+ expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first))
+
+ wait_for_requests
+ end
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_content("created branch 1-cherry-coloured-funk")
+ expect(page).to have_content("mentioned in merge request !1")
+ end
+
+ it 'allows creating a branch from the issue page' do
+ visit project_issue_path(project, issue)
+
+ select_dropdown_option('create-branch')
+
+ wait_for_requests
+
+ expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk')
+ expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk')
+ end
+
+ context "when there is a referenced merge request" do
+ let!(:note) do
+ create(:note, :on_issue, :system, project: project, noteable: issue,
+ note: "mentioned in #{referenced_mr.to_reference}")
+ end
+
+ let(:referenced_mr) do
+ create(:merge_request, :simple, source_project: project, target_project: project,
+ description: "Fixes #{issue.to_reference}", author: user)
+ end
+
+ before do
+ referenced_mr.cache_merge_request_closes_issues!(user)
+
+ visit project_issue_path(project, issue)
+ end
+
+ it 'disables the create branch button' do
+ expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)')
+ expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false)
+ expect(page).to have_content /1 Related Merge Request/
+ end
+ end
+
+ context 'when issue is confidential' do
+ it 'disables the create branch button' do
+ issue = create(:issue, :confidential, project: project)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('.create-mr-dropdown-wrap')
+ end
+ end
+ end
+
+ context 'for visitors' do
+ before do
+ visit project_issue_path(project, issue)
+ end
+
+ it 'shows no buttons' do
+ expect(page).not_to have_selector('.create-mr-dropdown-wrap')
+ end
+ end
+
+ def select_dropdown_option(option)
+ find('.create-mr-dropdown-wrap .dropdown-toggle').click
+ find("li[data-value='#{option}']").click
+ find('.js-create-merge-request').click
+ end
+end
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 58f897cba3e..80cc8d22999 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
@@ -1,21 +1,21 @@
require 'rails_helper'
-feature 'Resolving all open discussions in a merge request from an issue', feature: true, js: true do
+feature 'Resolving all open discussions in a merge request from an issue', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
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
project.team << [user, :master]
- login_as user
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in user
+ visit project_merge_request_path(project, merge_request)
end
it 'shows a button to resolve all discussions by creating a new issue' do
- within('li#resolve-count-app') do
- expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ within('#resolve-count-app') do
+ expect(page).to have_link "Resolve all discussions in new issue", href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
end
@@ -25,13 +25,13 @@ feature 'Resolving all open discussions in a merge request from an issue', featu
end
it 'hides the link for creating a new issue' do
- expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ expect(page).not_to have_link "Resolve all discussions in new issue", href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
end
context 'creating an issue for discussions' do
before do
- click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ click_link "Resolve all discussions in new issue", href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
it_behaves_like 'creating an issue for a discussion'
@@ -45,32 +45,32 @@ feature 'Resolving all open discussions in a merge request from an issue', featu
context 'with the internal tracker disabled' do
before do
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not show a link to create a new issue' do
- expect(page).not_to have_link 'open an issue to resolve them later'
+ expect(page).not_to have_link 'Create an issue to resolve them later'
end
end
context 'merge request has discussions that need to be resolved' do
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'shows a warning that the merge request contains unresolved discussions' do
- expect(page).to have_content 'This merge request has unresolved discussions'
+ expect(page).to have_content 'There are unresolved discussions.'
end
it 'has a link to resolve all discussions by creating an issue' do
page.within '.mr-widget-body' do
- expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ expect(page).to have_link 'Create an issue to resolve them later', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
end
context 'creating an issue for discussions' do
before do
- page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ page.click_link 'Create an issue to resolve them later', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
it_behaves_like 'creating an issue for a discussion'
@@ -82,8 +82,8 @@ feature 'Resolving all open discussions in a merge request from an issue', featu
describe 'as a reporter' do
before do
project.team << [user, :reporter]
- login_as user
- visit new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ sign_in user
+ visit new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
it 'Shows a notice to ask someone else to resolve the discussions' do
diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
index 3a5a79e03f4..ad5fd0fd97b 100644
--- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
@@ -1,22 +1,22 @@
require 'rails_helper'
-feature 'Resolve an open discussion in a merge request by creating an issue', feature: true do
+feature 'Resolve an open discussion in a merge request by creating an issue' do
let(:user) { create(:user) }
- let(:project) { create(:project, only_allow_merge_if_all_discussions_are_resolved: true) }
+ let(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) }
let(:merge_request) { create(:merge_request, source_project: project) }
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
project.team << [user, :master]
- login_as user
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in user
+ visit project_merge_request_path(project, merge_request)
end
context 'with the internal tracker disabled' do
before do
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not show a link to create a new issue' do
@@ -43,14 +43,14 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe
end
it 'has a link to create a new issue for a discussion' do
- new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid)
+ new_issue_link = new_project_issue_path(project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid)
expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link
end
context 'creating the issue' do
before do
- click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid)
+ click_link 'Resolve this discussion in a new issue', href: new_project_issue_path(project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid)
end
it 'has a hidden field for the discussion' do
@@ -66,10 +66,9 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe
describe 'as a reporter' do
before do
project.team << [user, :reporter]
- login_as user
- visit new_namespace_project_issue_path(project.namespace, project,
- merge_request_to_resolve_discussions_of: merge_request.iid,
- discussion_to_resolve: discussion.id)
+ sign_in user
+ visit new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid,
+ discussion_to_resolve: discussion.id)
end
it 'Shows a notice to ask someone else to resolve the discussions' do
diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
index 0b573d7cef4..2cc027aac9e 100644
--- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-describe 'Dropdown assignee', :feature, :js do
+describe 'Dropdown assignee', :js do
include FilteredSearchHelpers
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user, name: 'administrator', username: 'root') }
let!(:user_john) { create(:user, name: 'John', username: 'th0mas') }
let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') }
@@ -23,10 +23,10 @@ describe 'Dropdown assignee', :feature, :js do
project.team << [user, :master]
project.team << [user_john, :master]
project.team << [user_jacob, :master]
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'behavior' do
@@ -58,7 +58,7 @@ describe 'Dropdown assignee', :feature, :js do
it 'should load all the assignees when opened' do
filtered_search.set('assignee:')
- expect(dropdown_assignee_size).to eq(3)
+ expect(dropdown_assignee_size).to eq(4)
end
it 'shows current user at top of dropdown' do
@@ -134,8 +134,10 @@ describe 'Dropdown assignee', :feature, :js do
it 'fills in the assignee username when the assignee has not been filtered' do
click_assignee(user_jacob.name)
+ wait_for_requests
+
expect(page).to have_css(js_dropdown_assignee, visible: false)
- expect_tokens([{ name: 'assignee', value: "@#{user_jacob.username}" }])
+ expect_tokens([assignee_token(user_jacob.name)])
expect_filtered_search_input_empty
end
@@ -143,8 +145,10 @@ describe 'Dropdown assignee', :feature, :js do
filtered_search.send_keys('roo')
click_assignee(user.name)
+ wait_for_requests
+
expect(page).to have_css(js_dropdown_assignee, visible: false)
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input_empty
end
@@ -152,7 +156,26 @@ describe 'Dropdown assignee', :feature, :js do
find('#js-dropdown-assignee .filter-dropdown-item', text: 'No Assignee').click
expect(page).to have_css(js_dropdown_assignee, visible: false)
- expect_tokens([{ name: 'assignee', value: 'none' }])
+ expect_tokens([assignee_token('none')])
+ expect_filtered_search_input_empty
+ end
+ end
+
+ describe 'selecting from dropdown without Ajax call' do
+ before do
+ Gitlab::Testing::RequestBlockerMiddleware.block_requests!
+ filtered_search.set('assignee:')
+ end
+
+ after do
+ Gitlab::Testing::RequestBlockerMiddleware.allow_requests!
+ end
+
+ it 'selects current user' do
+ find('#js-dropdown-assignee .filter-dropdown-item', text: user.username).click
+
+ expect(page).to have_css(js_dropdown_assignee, visible: false)
+ expect_tokens([assignee_token(user.username)])
expect_filtered_search_input_empty
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb
index 0579d6c80ab..975dc035f2d 100644
--- a/spec/features/issues/filtered_search/dropdown_author_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-describe 'Dropdown author', js: true, feature: true do
+describe 'Dropdown author', js: true do
include FilteredSearchHelpers
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user, name: 'administrator', username: 'root') }
let!(:user_john) { create(:user, name: 'John', username: 'th0mas') }
let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') }
@@ -16,7 +16,7 @@ describe 'Dropdown author', js: true, feature: true do
end
sleep 0.5
- wait_for_ajax
+ wait_for_requests
end
def dropdown_author_size
@@ -31,10 +31,10 @@ describe 'Dropdown author', js: true, feature: true do
project.team << [user, :master]
project.team << [user_john, :master]
project.team << [user_jacob, :master]
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'behavior' do
@@ -65,7 +65,7 @@ describe 'Dropdown author', js: true, feature: true do
it 'should load all the authors when opened' do
send_keys_to_filtered_search('author:')
- expect(dropdown_author_size).to eq(3)
+ expect(dropdown_author_size).to eq(4)
end
it 'shows current user at top of dropdown' do
@@ -121,16 +121,39 @@ describe 'Dropdown author', js: true, feature: true do
it 'fills in the author username when the author has not been filtered' do
click_author(user_jacob.name)
+ wait_for_requests
+
expect(page).to have_css(js_dropdown_author, visible: false)
- expect_tokens([{ name: 'author', value: "@#{user_jacob.username}" }])
+ expect_tokens([author_token(user_jacob.name)])
expect_filtered_search_input_empty
end
it 'fills in the author username when the author has been filtered' do
click_author(user.name)
+ wait_for_requests
+
+ expect(page).to have_css(js_dropdown_author, visible: false)
+ expect_tokens([author_token(user.name)])
+ expect_filtered_search_input_empty
+ end
+ end
+
+ describe 'selecting from dropdown without Ajax call' do
+ before do
+ Gitlab::Testing::RequestBlockerMiddleware.block_requests!
+ filtered_search.set('author:')
+ end
+
+ after do
+ Gitlab::Testing::RequestBlockerMiddleware.allow_requests!
+ end
+
+ it 'selects current user' do
+ find('#js-dropdown-author .filter-dropdown-item', text: user.username).click
+
expect(page).to have_css(js_dropdown_author, visible: false)
- expect_tokens([{ name: 'author', value: "@#{user.username}" }])
+ expect_tokens([author_token(user.username)])
expect_filtered_search_input_empty
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
index b9a37cfcc22..04d6dea4b8c 100644
--- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-describe 'Dropdown hint', :js, :feature do
+describe 'Dropdown hint', :js do
include FilteredSearchHelpers
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_hint) { '#js-dropdown-hint' }
@@ -14,10 +14,10 @@ describe 'Dropdown hint', :js, :feature do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'behavior' do
diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb
index abe5d61e38c..e84b07ec2ef 100644
--- a/spec/features/issues/filtered_search/dropdown_label_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe 'Dropdown label', js: true, feature: true do
+describe 'Dropdown label', js: true do
include FilteredSearchHelpers
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_label) { '#js-dropdown-label' }
@@ -34,10 +34,10 @@ describe 'Dropdown label', js: true, feature: true do
before do
project.add_master(user)
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'keyboard navigation' do
@@ -47,7 +47,7 @@ describe 'Dropdown label', js: true, feature: true do
filtered_search.native.send_keys(:down, :down, :enter)
- expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }])
+ expect_tokens([label_token(bug_label.title)])
expect_filtered_search_input_empty
end
end
@@ -178,7 +178,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(bug_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }])
+ expect_tokens([label_token(bug_label.title)])
expect_filtered_search_input_empty
end
@@ -187,7 +187,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(bug_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }])
+ expect_tokens([label_token(bug_label.title)])
expect_filtered_search_input_empty
end
@@ -195,7 +195,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(two_words_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "\"#{two_words_label.title}\"" }])
+ expect_tokens([label_token("\"#{two_words_label.title}\"")])
expect_filtered_search_input_empty
end
@@ -203,7 +203,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(long_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "\"#{long_label.title}\"" }])
+ expect_tokens([label_token("\"#{long_label.title}\"")])
expect_filtered_search_input_empty
end
@@ -211,7 +211,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(wont_fix_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "~'#{wont_fix_label.title}'" }])
+ expect_tokens([label_token("'#{wont_fix_label.title}'")])
expect_filtered_search_input_empty
end
@@ -219,7 +219,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(uppercase_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "~#{uppercase_label.title}" }])
+ expect_tokens([label_token(uppercase_label.title)])
expect_filtered_search_input_empty
end
@@ -227,7 +227,7 @@ describe 'Dropdown label', js: true, feature: true do
click_label(special_label.title)
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: "~#{special_label.title}" }])
+ expect_tokens([label_token(special_label.title)])
expect_filtered_search_input_empty
end
@@ -235,7 +235,7 @@ describe 'Dropdown label', js: true, feature: true do
find("#{js_dropdown_label} .filter-dropdown-item", text: 'No Label').click
expect(page).not_to have_css(js_dropdown_label)
- expect_tokens([{ name: 'label', value: 'none' }])
+ expect_tokens([label_token('none', false)])
expect_filtered_search_input_empty
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 448259057b0..5f99921ae2e 100644
--- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-describe 'Dropdown milestone', :feature, :js do
+describe 'Dropdown milestone', :js do
include FilteredSearchHelpers
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user) }
let!(:milestone) { create(:milestone, title: 'v1.0', project: project) }
let!(:uppercase_milestone) { create(:milestone, title: 'CAP_MILESTONE', project: project) }
@@ -30,10 +30,10 @@ describe 'Dropdown milestone', :feature, :js do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'behavior' do
@@ -134,7 +134,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%#{milestone.title}" }])
+ expect_tokens([milestone_token(milestone.title)])
expect_filtered_search_input_empty
end
@@ -143,7 +143,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%#{milestone.title}" }])
+ expect_tokens([milestone_token(milestone.title)])
expect_filtered_search_input_empty
end
@@ -151,7 +151,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(two_words_milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%\"#{two_words_milestone.title}\"" }])
+ expect_tokens([milestone_token("\"#{two_words_milestone.title}\"")])
expect_filtered_search_input_empty
end
@@ -159,7 +159,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(long_milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%\"#{long_milestone.title}\"" }])
+ expect_tokens([milestone_token("\"#{long_milestone.title}\"")])
expect_filtered_search_input_empty
end
@@ -167,7 +167,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(wont_fix_milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%'#{wont_fix_milestone.title}'" }])
+ expect_tokens([milestone_token("'#{wont_fix_milestone.title}'")])
expect_filtered_search_input_empty
end
@@ -175,7 +175,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(uppercase_milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%#{uppercase_milestone.title}" }])
+ expect_tokens([milestone_token(uppercase_milestone.title)])
expect_filtered_search_input_empty
end
@@ -183,7 +183,7 @@ describe 'Dropdown milestone', :feature, :js do
click_milestone(special_milestone.title)
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: "%#{special_milestone.title}" }])
+ expect_tokens([milestone_token(special_milestone.title)])
expect_filtered_search_input_empty
end
@@ -191,7 +191,7 @@ describe 'Dropdown milestone', :feature, :js do
click_static_milestone('No Milestone')
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: 'none' }])
+ expect_tokens([milestone_token('none', false)])
expect_filtered_search_input_empty
end
@@ -199,7 +199,7 @@ describe 'Dropdown milestone', :feature, :js do
click_static_milestone('Upcoming')
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: 'upcoming' }])
+ expect_tokens([milestone_token('upcoming', false)])
expect_filtered_search_input_empty
end
@@ -207,7 +207,7 @@ describe 'Dropdown milestone', :feature, :js do
click_static_milestone('Started')
expect(page).to have_css(js_dropdown_milestone, visible: false)
- expect_tokens([{ name: 'milestone', value: 'started' }])
+ expect_tokens([milestone_token('started', false)])
expect_filtered_search_input_empty
end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index c824aa6a414..2070043d842 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'Filter issues', js: true, feature: true do
+describe 'Filter issues', js: true do
include Devise::Test::IntegrationHelpers
include FilteredSearchHelpers
let!(:group) { create(:group) }
let!(:project) { create(:project, group: group) }
- let!(:user) { create(:user, username: 'joe') }
+ let!(:user) { create(:user, username: 'joe', name: 'Joe') }
let!(:user2) { create(:user, username: 'jane') }
let!(:label) { create(:label, project: project) }
let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
@@ -51,15 +51,15 @@ describe 'Filter issues', js: true, feature: true do
create(:issue, project: project, title: "issue with 'single quotes'")
create(:issue, project: project, title: "issue with \"double quotes\"")
create(:issue, project: project, title: "issue with !@\#{$%^&*()-+")
- create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignee: user)
- create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignee: user)
+ create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user])
+ create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user])
issue = create(:issue,
title: "Bug 2",
project: project,
milestone: milestone,
author: user,
- assignee: user)
+ assignees: [user])
issue.labels << bug_label
issue_with_caps_label = create(:issue,
@@ -67,7 +67,7 @@ describe 'Filter issues', js: true, feature: true do
project: project,
milestone: milestone,
author: user,
- assignee: user)
+ assignees: [user])
issue_with_caps_label.labels << caps_sensitive_label
issue_with_everything = create(:issue,
@@ -75,7 +75,7 @@ describe 'Filter issues', js: true, feature: true do
project: project,
milestone: milestone,
author: user,
- assignee: user)
+ assignees: [user])
issue_with_everything.labels << bug_label
issue_with_everything.labels << caps_sensitive_label
@@ -89,7 +89,7 @@ describe 'Filter issues', js: true, feature: true do
milestone: future_milestone,
project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'filter issues by author' do
@@ -97,7 +97,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched author' do
input_filtered_search("author:@#{user.username}")
- expect_tokens([{ name: 'author', value: user.username }])
+ wait_for_requests
+
+ expect_tokens([author_token(user.name)])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
@@ -117,7 +119,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched author and text' do
input_filtered_search("author:@#{user.username} #{search_term}")
- expect_tokens([{ name: 'author', value: user.username }])
+ wait_for_requests
+
+ expect_tokens([author_token(user.name)])
expect_issues_list_count(3)
expect_filtered_search_input(search_term)
end
@@ -125,10 +129,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched author, assignee and text' do
input_filtered_search("author:@#{user.username} assignee:@#{user.username} #{search_term}")
- expect_tokens([
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username }
- ])
+ wait_for_requests
+
+ expect_tokens([author_token(user.name), assignee_token(user.name)])
expect_issues_list_count(3)
expect_filtered_search_input(search_term)
end
@@ -136,10 +139,12 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched author, assignee, label, and text' do
input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username },
- { name: 'label', value: caps_sensitive_label.title }
+ author_token(user.name),
+ assignee_token(user.name),
+ label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -148,11 +153,13 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched author, assignee, label, milestone and text' do
input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username },
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'milestone', value: milestone.title }
+ author_token(user.name),
+ assignee_token(user.name),
+ label_token(caps_sensitive_label.title),
+ milestone_token(milestone.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -169,7 +176,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched assignee' do
input_filtered_search("assignee:@#{user.username}")
- expect_tokens([{ name: 'assignee', value: user.username }])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name)])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
@@ -177,7 +186,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by no assignee' do
input_filtered_search('assignee:none')
- expect_tokens([{ name: 'assignee', value: 'none' }])
+ expect_tokens([assignee_token('none')])
expect_issues_list_count(8, 1)
expect_filtered_search_input_empty
end
@@ -197,7 +206,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched assignee and text' do
input_filtered_search("assignee:@#{user.username} #{search_term}")
- expect_tokens([{ name: 'assignee', value: user.username }])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name)])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
end
@@ -205,10 +216,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched assignee, author and text' do
input_filtered_search("assignee:@#{user.username} author:@#{user.username} #{search_term}")
- expect_tokens([
- { name: 'assignee', value: user.username },
- { name: 'author', value: user.username }
- ])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name), author_token(user.name)])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
end
@@ -216,10 +226,12 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched assignee, author, label, text' do
input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'assignee', value: user.username },
- { name: 'author', value: user.username },
- { name: 'label', value: caps_sensitive_label.title }
+ assignee_token(user.name),
+ author_token(user.name),
+ label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -229,10 +241,10 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
expect_tokens([
- { name: 'assignee', value: user.username },
- { name: 'author', value: user.username },
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'milestone', value: milestone.title }
+ assignee_token(user.name),
+ author_token(user.name),
+ label_token(caps_sensitive_label.title),
+ milestone_token(milestone.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -253,7 +265,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label' do
input_filtered_search("label:~#{bug_label.title}")
- expect_tokens([{ name: 'label', value: bug_label.title }])
+ expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(2)
expect_filtered_search_input_empty
end
@@ -261,7 +273,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by no label' do
input_filtered_search('label:none')
- expect_tokens([{ name: 'label', value: 'none' }])
+ expect_tokens([label_token('none', false)])
expect_issues_list_count(9, 1)
expect_filtered_search_input_empty
end
@@ -274,8 +286,8 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}")
expect_tokens([
- { name: 'label', value: bug_label.title },
- { name: 'label', value: caps_sensitive_label.title }
+ label_token(bug_label.title),
+ label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input_empty
@@ -287,7 +299,8 @@ describe 'Filter issues', js: true, feature: true do
special_issue.labels << special_label
input_filtered_search("label:~#{special_label.title}")
- expect_tokens([{ name: 'label', value: special_label.title }])
+
+ expect_tokens([label_token(special_label.title)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -297,7 +310,7 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~#{new_label.title}")
- expect_tokens([{ name: 'label', value: new_label.title }])
+ expect_tokens([label_token(new_label.title)])
expect_no_issues_list()
expect_filtered_search_input_empty
end
@@ -311,25 +324,27 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~'#{special_multiple_label.title}'")
- # filtered search defaults quotations to double quotes
- expect_tokens([{ name: 'label', value: "\"#{special_multiple_label.title}\"" }])
+ # Check for search results (which makes sure that the page has changed)
expect_issues_list_count(1)
+ # filtered search defaults quotations to double quotes
+ expect_tokens([label_token("\"#{special_multiple_label.title}\"")])
+
expect_filtered_search_input_empty
end
it 'single quotes' do
input_filtered_search("label:~'#{multiple_words_label.title}'")
- expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }])
expect_issues_list_count(1)
+ expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_filtered_search_input_empty
end
it 'double quotes' do
input_filtered_search("label:~\"#{multiple_words_label.title}\"")
- expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }])
+ expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -341,7 +356,7 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~'#{double_quotes_label.title}'")
- expect_tokens([{ name: 'label', value: "'#{double_quotes_label.title}'" }])
+ expect_tokens([label_token("'#{double_quotes_label.title}'")])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -353,7 +368,7 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~\"#{single_quotes_label.title}\"")
- expect_tokens([{ name: 'label', value: "\"#{single_quotes_label.title}\"" }])
+ expect_tokens([label_token("\"#{single_quotes_label.title}\"")])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -363,7 +378,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label and text' do
input_filtered_search("label:~#{caps_sensitive_label.title} #{search_term}")
- expect_tokens([{ name: 'label', value: caps_sensitive_label.title }])
+ expect_tokens([label_token(caps_sensitive_label.title)])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
end
@@ -371,10 +386,9 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label, author and text' do
input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
- expect_tokens([
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username }
- ])
+ wait_for_requests
+
+ expect_tokens([label_token(caps_sensitive_label.title), author_token(user.name)])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
end
@@ -382,10 +396,12 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label, author, assignee and text' do
input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username }
+ label_token(caps_sensitive_label.title),
+ author_token(user.name),
+ assignee_token(user.name)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -395,10 +411,10 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
expect_tokens([
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username },
- { name: 'milestone', value: milestone.title }
+ label_token(caps_sensitive_label.title),
+ author_token(user.name),
+ assignee_token(user.name),
+ milestone_token(milestone.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -410,8 +426,8 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} #{search_term}")
expect_tokens([
- { name: 'label', value: bug_label.title },
- { name: 'label', value: caps_sensitive_label.title }
+ label_token(bug_label.title),
+ label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -420,10 +436,12 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label, label2, author and text' do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'label', value: bug_label.title },
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username }
+ label_token(bug_label.title),
+ label_token(caps_sensitive_label.title),
+ author_token(user.name)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -432,11 +450,13 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label, label2, author, assignee and text' do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'label', value: bug_label.title },
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username }
+ label_token(bug_label.title),
+ label_token(caps_sensitive_label.title),
+ author_token(user.name),
+ assignee_token(user.name)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -445,12 +465,14 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'label', value: bug_label.title },
- { name: 'label', value: caps_sensitive_label.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username },
- { name: 'milestone', value: milestone.title }
+ label_token(bug_label.title),
+ label_token(caps_sensitive_label.title),
+ author_token(user.name),
+ assignee_token(user.name),
+ milestone_token(milestone.title)
])
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
@@ -459,7 +481,7 @@ describe 'Filter issues', js: true, feature: true do
context 'issue label clicked' do
before do
- find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click
+ find('.issues-list .issue .issue-main-info .issuable-info a .label', text: multiple_words_label.title).click
end
it 'filters' do
@@ -467,7 +489,7 @@ describe 'Filter issues', js: true, feature: true do
end
it 'displays in search bar' do
- expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }])
+ expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_filtered_search_input_empty
end
end
@@ -484,7 +506,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched milestone' do
input_filtered_search("milestone:%#{milestone.title}")
- expect_tokens([{ name: 'milestone', value: milestone.title }])
+ expect_tokens([milestone_token(milestone.title)])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
@@ -492,7 +514,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by no milestone' do
input_filtered_search("milestone:none")
- expect_tokens([{ name: 'milestone', value: 'none' }])
+ expect_tokens([milestone_token('none', false)])
expect_issues_list_count(7, 1)
expect_filtered_search_input_empty
end
@@ -500,7 +522,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by upcoming milestones' do
input_filtered_search("milestone:upcoming")
- expect_tokens([{ name: 'milestone', value: 'upcoming' }])
+ expect_tokens([milestone_token('upcoming', false)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -508,7 +530,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by started milestones' do
input_filtered_search("milestone:started")
- expect_tokens([{ name: 'milestone', value: 'started' }])
+ expect_tokens([milestone_token('started', false)])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
@@ -527,7 +549,7 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("milestone:%#{special_milestone.title}")
- expect_tokens([{ name: 'milestone', value: special_milestone.title }])
+ expect_tokens([milestone_token(special_milestone.title)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -537,7 +559,7 @@ describe 'Filter issues', js: true, feature: true do
input_filtered_search("milestone:%#{new_milestone.title}")
- expect_tokens([{ name: 'milestone', value: new_milestone.title }])
+ expect_tokens([milestone_token(new_milestone.title)])
expect_no_issues_list()
expect_filtered_search_input_empty
end
@@ -549,7 +571,7 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched milestone and text' do
input_filtered_search("milestone:%#{milestone.title} #{search_term}")
- expect_tokens([{ name: 'milestone', value: milestone.title }])
+ expect_tokens([milestone_token(milestone.title)])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
end
@@ -557,9 +579,11 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched milestone, author and text' do
input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'milestone', value: milestone.title },
- { name: 'author', value: user.username }
+ milestone_token(milestone.title),
+ author_token(user.name)
])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
@@ -568,10 +592,12 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched milestone, author, assignee and text' do
input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'milestone', value: milestone.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username }
+ milestone_token(milestone.title),
+ author_token(user.name),
+ assignee_token(user.name)
])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
@@ -580,11 +606,13 @@ describe 'Filter issues', js: true, feature: true do
it 'filters issues by searched milestone, author, assignee, label and text' do
input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} #{search_term}")
+ wait_for_requests
+
expect_tokens([
- { name: 'milestone', value: milestone.title },
- { name: 'author', value: user.username },
- { name: 'assignee', value: user.username },
- { name: 'label', value: bug_label.title }
+ milestone_token(milestone.title),
+ author_token(user.name),
+ assignee_token(user.name),
+ label_token(bug_label.title)
])
expect_issues_list_count(2)
expect_filtered_search_input(search_term)
@@ -761,7 +789,7 @@ describe 'Filter issues', js: true, feature: true do
sort_toggle.click
find('.filtered-search-wrapper .dropdown-menu li a', text: 'Oldest updated').click
- wait_for_ajax
+ wait_for_requests
expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title)
end
@@ -777,26 +805,26 @@ describe 'Filter issues', js: true, feature: true do
end
it 'open state' do
- find('.issues-state-filters a', text: 'Closed').click
- wait_for_ajax
+ find('.issues-state-filters [data-state="closed"]').click
+ wait_for_requests
- find('.issues-state-filters a', text: 'Open').click
- wait_for_ajax
+ find('.issues-state-filters [data-state="opened"]').click
+ wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 4)
end
it 'closed state' do
- find('.issues-state-filters a', text: 'Closed').click
- wait_for_ajax
+ find('.issues-state-filters [data-state="closed"]').click
+ wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 1)
expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title)
end
it 'all state' do
- find('.issues-state-filters a', text: 'All').click
- wait_for_ajax
+ find('.issues-state-filters [data-state="all"]').click
+ wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 5)
end
@@ -804,16 +832,16 @@ describe 'Filter issues', js: true, feature: true do
describe 'RSS feeds' do
it 'updates atom feed link for project issues' do
- visit namespace_project_issues_path(project.namespace, project, milestone_title: milestone.title, assignee_id: user.id)
+ visit project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id)
link = find_link('Subscribe')
params = CGI.parse(URI.parse(link[:href]).query)
auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
- expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('rss_token' => [user.rss_token])
expect(params).to include('milestone_title' => [milestone.title])
expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('private_token' => [user.private_token])
+ expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
end
@@ -825,10 +853,10 @@ describe 'Filter issues', js: true, feature: true do
auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
- expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('rss_token' => [user.rss_token])
expect(params).to include('milestone_title' => [milestone.title])
expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('private_token' => [user.private_token])
+ expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
end
@@ -836,7 +864,7 @@ describe 'Filter issues', js: true, feature: true do
context 'URL has a trailing slash' do
before do
- visit "#{namespace_project_issues_path(project.namespace, project)}/"
+ visit "#{project_issues_path(project)}/"
end
it 'milestone dropdown loads milestones' do
diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb
index 08fe3b4553b..5eeecaeda47 100644
--- a/spec/features/issues/filtered_search/recent_searches_spec.rb
+++ b/spec/features/issues/filtered_search/recent_searches_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-describe 'Recent searches', js: true, feature: true do
+describe 'Recent searches', js: true do
include FilteredSearchHelpers
- let!(:group) { create(:group) }
- let!(:project) { create(:project, group: group) }
- let!(:user) { create(:user) }
+ let(:project_1) { create(:project, :public) }
+ let(:project_2) { create(:project, :public) }
+ let(:project_1_local_storage_key) { "#{project_1.full_path}-issue-recent-searches" }
before do
Capybara.ignore_hidden_elements = false
- project.add_master(user)
- group.add_developer(user)
- create(:issue, project: project)
- login_as(user)
+ create(:issue, project: project_1)
+ create(:issue, project: project_2)
+ # Visit any fast-loading page so we can clear local storage without a DOM exception
+ visit '/404'
remove_recent_searches
end
@@ -22,7 +22,7 @@ describe 'Recent searches', js: true, feature: true do
end
it 'searching adds to recent searches' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project_1)
input_filtered_search('foo', submit: true)
input_filtered_search('bar', submit: true)
@@ -35,8 +35,8 @@ describe 'Recent searches', js: true, feature: true do
end
it 'visiting URL with search params adds to recent searches' do
- visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar')
- visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply')
+ visit project_issues_path(project_1, label_name: 'foo', search: 'bar')
+ visit project_issues_path(project_1, label_name: 'qux', search: 'garply')
items = all('.filtered-search-history-dropdown-item', visible: false)
@@ -46,9 +46,9 @@ describe 'Recent searches', js: true, feature: true do
end
it 'saved recent searches are restored last on the list' do
- set_recent_searches('["saved1", "saved2"]')
+ set_recent_searches(project_1_local_storage_key, '["saved1", "saved2"]')
- visit namespace_project_issues_path(project.namespace, project, search: 'foo')
+ visit project_issues_path(project_1, search: 'foo')
items = all('.filtered-search-history-dropdown-item', visible: false)
@@ -58,9 +58,27 @@ describe 'Recent searches', js: true, feature: true do
expect(items[2].text).to eq('saved2')
end
+ it 'searches are scoped to projects' do
+ visit project_issues_path(project_1)
+
+ input_filtered_search('foo', submit: true)
+ input_filtered_search('bar', submit: true)
+
+ visit project_issues_path(project_2)
+
+ input_filtered_search('more', submit: true)
+ input_filtered_search('things', submit: true)
+
+ items = all('.filtered-search-history-dropdown-item', visible: false)
+
+ expect(items.count).to eq(2)
+ expect(items[0].text).to eq('things')
+ expect(items[1].text).to eq('more')
+ end
+
it 'clicking item fills search input' do
- set_recent_searches('["foo", "bar"]')
- visit namespace_project_issues_path(project.namespace, project)
+ set_recent_searches(project_1_local_storage_key, '["foo", "bar"]')
+ visit project_issues_path(project_1)
all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click')
wait_for_filtered_search('foo')
@@ -69,8 +87,8 @@ describe 'Recent searches', js: true, feature: true do
end
it 'clear recent searches button, clears recent searches' do
- set_recent_searches('["foo"]')
- visit namespace_project_issues_path(project.namespace, project)
+ set_recent_searches(project_1_local_storage_key, '["foo"]')
+ visit project_issues_path(project_1)
items_before = all('.filtered-search-history-dropdown-item', visible: false)
@@ -83,8 +101,8 @@ describe 'Recent searches', js: true, feature: true do
end
it 'shows flash error when failed to parse saved history' do
- set_recent_searches('fail')
- visit namespace_project_issues_path(project.namespace, project)
+ set_recent_searches(project_1_local_storage_key, 'fail')
+ visit project_issues_path(project_1)
expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches')
end
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index 3ea95aed0a6..a432d031337 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -1,18 +1,18 @@
require 'rails_helper'
-describe 'Search bar', js: true, feature: true do
+describe 'Search bar', js: true do
include FilteredSearchHelpers
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
def get_left_style(style)
@@ -32,7 +32,7 @@ describe 'Search bar', js: true, feature: true do
it 'selects item' do
filtered_search.native.send_keys(:down, :down, :enter)
- expect_tokens([{ name: 'author' }])
+ expect_tokens([author_token])
expect_filtered_search_input_empty
end
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 96e87c82d2c..14a555fde10 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -1,9 +1,10 @@
require 'rails_helper'
-describe 'Visual tokens', js: true, feature: true do
+describe 'Visual tokens', js: true do
include FilteredSearchHelpers
+ include WaitForRequests
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let!(:user) { create(:user, name: 'administrator', username: 'root') }
let!(:user_rock) { create(:user, name: 'The Rock', username: 'rock') }
let!(:milestone_nine) { create(:milestone, title: '9.0', project: project) }
@@ -24,16 +25,16 @@ describe 'Visual tokens', js: true, feature: true do
before do
project.add_user(user, :master)
project.add_user(user_rock, :master)
- login_as(user)
+ sign_in(user)
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
end
describe 'editing author token' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
- first('.tokens-container .filtered-search-token').double_click
+ first('.tokens-container .filtered-search-token').click
end
it 'opens author dropdown' do
@@ -70,7 +71,8 @@ describe 'Visual tokens', js: true, feature: true do
end
it 'changes value in visual token' do
- expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}")
+ wait_for_requests
+ expect(first('.tokens-container .filtered-search-token .value').text).to eq("#{user_rock.name}")
end
it 'moves input to the right' do
@@ -131,7 +133,7 @@ describe 'Visual tokens', js: true, feature: true do
describe 'editing milestone token' do
before do
input_filtered_search('milestone:%10.0 author:none', submit: false)
- first('.tokens-container .filtered-search-token').double_click
+ first('.tokens-container .filtered-search-token').click
first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item')
end
@@ -329,7 +331,7 @@ describe 'Visual tokens', js: true, feature: true do
it 'does not tokenize incomplete token' do
filtered_search.send_keys('author:')
- find('#content-body').click
+ find('body').click
token = page.all('.tokens-container .js-visual-token')[1]
expect_filtered_search_input_empty
@@ -344,8 +346,8 @@ describe 'Visual tokens', js: true, feature: true do
it 'tokenizes the search term to complete visual token' do
expect_tokens([
- { name: 'author', value: '@root' },
- { name: 'assignee', value: 'none' }
+ author_token(user.name),
+ assignee_token('none')
])
end
end
diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb
index 755992069ff..4297bfff3d9 100644
--- a/spec/features/issues/form_spec.rb
+++ b/spec/features/issues/form_spec.rb
@@ -1,7 +1,8 @@
require 'rails_helper'
-describe 'New/edit issue', feature: true, js: true do
- include GitlabRoutingHelper
+describe 'New/edit issue', :js do
+ include ActionView::Helpers::JavaScriptHelper
+ include FormHelper
let!(:project) { create(:project) }
let!(:user) { create(:user)}
@@ -9,17 +10,95 @@ describe 'New/edit issue', feature: true, js: true do
let!(:milestone) { create(:milestone, project: project) }
let!(:label) { create(:label, project: project) }
let!(:label2) { create(:label, project: project) }
- let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) }
+ let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
before do
project.team << [user, :master]
project.team << [user2, :master]
- login_as(user)
+ sign_in(user)
end
context 'new issue' do
before do
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
+ end
+
+ describe 'shorten users API pagination limit' do
+ before do
+ # Using `allow_any_instance_of`/`and_wrap_original`, `original` would
+ # somehow refer to the very block we defined to _wrap_ that method, instead of
+ # the original method, resulting in infinite recurison when called.
+ # This is likely a bug with helper modules included into dynamically generated view classes.
+ # To work around this, we have to hold on to and call to the original implementation manually.
+ original_issue_dropdown_options = FormHelper.instance_method(:issue_assignees_dropdown_options)
+ allow_any_instance_of(FormHelper).to receive(:issue_assignees_dropdown_options).and_wrap_original do |original, *args|
+ options = original_issue_dropdown_options.bind(original.receiver).call(*args)
+ options[:data][:per_page] = 2
+
+ options
+ end
+
+ visit new_project_issue_path(project)
+
+ click_button 'Unassigned'
+
+ wait_for_requests
+ end
+
+ it 'should display selected users even if they are not part of the original API call' do
+ find('.dropdown-input-field').native.send_keys user2.name
+
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content user2.name
+ click_link user2.name
+ end
+
+ find('.js-assignee-search').click
+ find('.js-dropdown-input-clear').click
+
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content user.name
+ expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s)
+ end
+ end
+ end
+
+ describe 'single assignee' do
+ before do
+ click_button 'Unassigned'
+
+ wait_for_requests
+ end
+
+ it 'unselects other assignees when unassigned is selected' do
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+
+ click_button user2.name
+
+ page.within '.dropdown-menu-user' do
+ click_link 'Unassigned'
+ end
+
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0')
+ end
+
+ it 'toggles assign to me when current user is selected and unselected' do
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+
+ expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
+
+ click_button user.name
+
+ page.within('.dropdown-menu-user') do
+ click_link user.name
+ end
+
+ expect(page.find('.dropdown-menu-user', visible: false)).not_to be_visible
+ end
end
it 'allows user to create new issue' do
@@ -27,18 +106,24 @@ describe 'New/edit issue', feature: true, js: true do
fill_in 'issue_description', with: 'title'
expect(find('a', text: 'Assign to me')).to be_visible
- click_button 'Assignee'
+ click_button 'Unassigned'
+
+ wait_for_requests
+
page.within '.dropdown-menu-user' do
click_link user2.name
end
- expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s)
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
page.within '.js-assignee-search' do
expect(page).to have_content user2.name
end
expect(find('a', text: 'Assign to me')).to be_visible
click_link 'Assign to me'
- expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s)
+ assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false)
+
+ expect(assignee_ids[0].value).to match(user.id.to_s)
+
page.within '.js-assignee-search' do
expect(page).to have_content user.name
end
@@ -68,7 +153,7 @@ describe 'New/edit issue', feature: true, js: true do
page.within '.issuable-sidebar' do
page.within '.assignee' do
- expect(page).to have_content user.name
+ expect(page).to have_content "Assignee"
end
page.within '.milestone' do
@@ -105,15 +190,41 @@ describe 'New/edit issue', feature: true, js: true do
expect(find('.js-label-select')).to have_content('Labels')
end
+
+ it 'correctly updates the selected user when changing assignee' do
+ click_button 'Unassigned'
+
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+
+ expect(find('.js-assignee-search')).to have_content(user.name)
+ click_button user.name
+
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+
+ expect(find('.js-assignee-search')).to have_content(user2.name)
+ end
+
+ it 'description has autocomplete' do
+ find('#issue_description').native.send_keys('')
+ fill_in 'issue_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
end
context 'edit issue' do
before do
- visit edit_namespace_project_issue_path(project.namespace, project, issue)
+ visit edit_project_issue_path(project, issue)
end
it 'allows user to update issue' do
- expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s)
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
@@ -153,5 +264,53 @@ describe 'New/edit issue', feature: true, js: true do
end
end
end
+
+ it 'description has autocomplete' do
+ find('#issue_description').native.send_keys('')
+ fill_in 'issue_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
+ end
+
+ describe 'sub-group project' do
+ let(:group) { create(:group) }
+ let(:nested_group_1) { create(:group, parent: group) }
+ let(:sub_group_project) { create(:project, group: nested_group_1) }
+
+ before do
+ sub_group_project.add_master(user)
+
+ visit new_project_issue_path(sub_group_project)
+ end
+
+ it 'creates new label from dropdown' do
+ click_button 'Labels'
+
+ click_link 'Create new label'
+
+ page.within '.dropdown-new-label' do
+ fill_in 'new_label_name', with: 'test label'
+ first('.suggest-colors-dropdown a').click
+
+ click_button 'Create'
+
+ wait_for_requests
+ end
+
+ page.within '.dropdown-menu-labels' do
+ expect(page).to have_link 'test label'
+ end
+ end
+ end
+
+ def before_for_selector(selector)
+ js = <<-JS.strip_heredoc
+ (function(selector) {
+ var el = document.querySelector(selector);
+ return window.getComputedStyle(el, '::before').getPropertyValue('content');
+ })("#{escape_javascript(selector)}")
+ JS
+ page.evaluate_script(js)
end
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index ad29911248f..b84635c5134 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-feature 'GFM autocomplete', feature: true, js: true do
+feature 'GFM autocomplete', js: true do
let(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let(:project) { create(:project) }
let(:label) { create(:label, project: project, title: 'special+') }
@@ -8,10 +8,22 @@ feature 'GFM autocomplete', feature: true, js: true do
before do
project.team << [user, :master]
- login_as(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ sign_in(user)
+ visit project_issue_path(project, issue)
- wait_for_ajax
+ wait_for_requests
+ end
+
+ it 'updates issue descripton with GFM reference' do
+ find('.issuable-edit').click
+
+ find('#issue-description').native.send_keys("@#{user.name[0...3]}")
+
+ find('.atwho-view .cur').trigger('click')
+
+ click_button 'Save changes'
+
+ expect(find('.description')).to have_content(user.to_reference)
end
it 'opens autocomplete menu when field starts with text' do
@@ -40,7 +52,7 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).to have_selector('.atwho-container')
- wait_for_ajax
+ wait_for_requests
expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type')
end
@@ -80,7 +92,7 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).to have_selector('.atwho-container')
- wait_for_ajax
+ wait_for_requests
expect(find('#at-view-64')).to have_selector('.cur:first-of-type')
end
@@ -93,7 +105,7 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).to have_selector('.atwho-container')
- wait_for_ajax
+ wait_for_requests
expect(find('#at-view-64')).to have_content(user.name)
end
@@ -106,7 +118,7 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).to have_selector('.atwho-container')
- wait_for_ajax
+ wait_for_requests
expect(find('#at-view-58')).to have_selector('.cur:first-of-type')
end
@@ -208,7 +220,7 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).not_to have_selector('.atwho-view')
end
- it 'triggers autocomplete after selecting a slash command' do
+ it 'triggers autocomplete after selecting a quick action' do
note = find('#note_note')
page.within '.timeline-content-form' do
note.native.send_keys('')
diff --git a/spec/features/issues/group_label_sidebar_spec.rb b/spec/features/issues/group_label_sidebar_spec.rb
index fc8515cfe9b..9c10f78f67a 100644
--- a/spec/features/issues/group_label_sidebar_spec.rb
+++ b/spec/features/issues/group_label_sidebar_spec.rb
@@ -1,18 +1,14 @@
require 'rails_helper'
-describe 'Group label on issue', :feature do
+describe 'Group label on issue' do
it 'renders link to the project issues page' do
group = create(:group)
- project = create(:empty_project, :public, namespace: group)
+ project = create(:project, :public, namespace: group)
feature = create(:group_label, group: group, title: 'feature')
issue = create(:labeled_issue, project: project, labels: [feature])
- label_link = namespace_project_issues_path(
- project.namespace,
- project,
- label_name: [feature.name]
- )
+ label_link = project_issues_path(project, label_name: [feature.name])
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
link = find('.issuable-show-labels a')
diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb
new file mode 100644
index 00000000000..28b636f9359
--- /dev/null
+++ b/spec/features/issues/issue_detail_spec.rb
@@ -0,0 +1,43 @@
+require 'rails_helper'
+
+feature 'Issue Detail', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project, author: user) }
+
+ context 'when user displays the issue' do
+ before do
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ end
+
+ it 'shows the issue' do
+ page.within('.issuable-details') do
+ expect(find('h2')).to have_content(issue.title)
+ end
+ end
+ end
+
+ context 'when edited by a user who is later deleted' do
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ wait_for_requests
+
+ click_link 'Edit'
+ fill_in 'issue-title', with: 'issue title'
+ click_button 'Save'
+
+ visit profile_account_path
+ click_link 'Delete account'
+
+ visit project_issue_path(project, issue)
+ end
+
+ it 'shows the issue' do
+ page.within('.issuable-details') do
+ expect(find('h2')).to have_content(issue.reload.title)
+ end
+ end
+ end
+end
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index baacd7edb86..af11b474842 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -1,15 +1,16 @@
require 'rails_helper'
-feature 'Issue Sidebar', feature: true do
+feature 'Issue Sidebar' do
include MobileHelpers
- let(:project) { create(:project, :public) }
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)}
let!(:label) { create(:label, project: project, title: 'bug') }
before do
- login_as(user)
+ sign_in(user)
end
context 'assignee', js: true do
@@ -22,7 +23,7 @@ feature 'Issue Sidebar', feature: true do
find('.block.assignee .edit-link').click
- wait_for_ajax
+ wait_for_requests
end
it 'shows author in assignee dropdown' do
@@ -36,11 +37,43 @@ feature 'Issue Sidebar', feature: true do
find('.dropdown-input-field').native.send_keys user2.name
sleep 1 # Required to wait for end of input delay
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content(user2.name)
end
end
+
+ it 'assigns yourself' do
+ find('.block.assignee .dropdown-menu-toggle').click
+
+ click_button 'assign yourself'
+
+ wait_for_requests
+
+ find('.block.assignee .edit-link').click
+
+ page.within '.dropdown-menu-user' do
+ expect(page.find('.dropdown-header')).to be_visible
+ expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name)
+ end
+ end
+
+ it 'keeps your filtered term after filtering and dismissing the dropdown' do
+ find('.dropdown-input-field').native.send_keys user2.name
+
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ expect(page).not_to have_content 'Unassigned'
+ click_link user2.name
+ end
+
+ find('.js-right-sidebar').click
+ find('.block.assignee .edit-link').click
+
+ expect(page.all('.dropdown-menu-user li').length).to eq(1)
+ expect(find('.dropdown-input-field').value).to eq(user2.name)
+ end
end
context 'as a allowed user' do
@@ -55,10 +88,12 @@ feature 'Issue Sidebar', feature: true do
# Resize the window
resize_screen_sm
# Make sure the sidebar is collapsed
+ find(sidebar_selector)
expect(page).to have_css(sidebar_selector)
# Once is collapsed let's open the sidebard and reload
open_issue_sidebar
refresh
+ find(sidebar_selector)
expect(page).to have_css(sidebar_selector)
# Restore the window size as it was including the sidebar
restore_window_size
@@ -95,8 +130,8 @@ feature 'Issue Sidebar', feature: true do
it 'adds new label' do
page.within('.block.labels') do
fill_in 'new_label_name', with: 'wontfix'
- page.find(".suggest-colors a", match: :first).click
- click_button 'Create'
+ page.find('.suggest-colors a', match: :first).trigger('click')
+ page.find('button', text: 'Create').trigger('click')
page.within('.dropdown-page-one') do
expect(page).to have_content 'wontfix'
@@ -107,8 +142,8 @@ feature 'Issue Sidebar', feature: true do
it 'shows error message if label title is taken' do
page.within('.block.labels') do
fill_in 'new_label_name', with: label.title
- page.find('.suggest-colors a', match: :first).click
- click_button 'Create'
+ page.find('.suggest-colors a', match: :first).trigger('click')
+ page.find('button', text: 'Create').trigger('click')
page.within('.dropdown-page-two') do
expect(page).to have_content 'Title has already been taken'
@@ -119,20 +154,6 @@ feature 'Issue Sidebar', feature: true do
end
end
- context 'as a allowed mobile user', js: true do
- before do
- project.team << [user, :developer]
- resize_screen_xs
- visit_issue(project, issue)
- end
-
- context 'mobile sidebar' do
- it 'collapses the sidebar for small screens' do
- expect(page).not_to have_css('aside.right-sidebar.right-sidebar-collapsed')
- end
- end
- end
-
context 'as a guest' do
before do
project.team << [user, :guest]
@@ -145,13 +166,11 @@ feature 'Issue Sidebar', feature: true do
end
def visit_issue(project, issue)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
def open_issue_sidebar
- page.within('aside.right-sidebar.right-sidebar-collapsed') do
- find('.js-sidebar-toggle').click
- sleep 1
- end
+ find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').trigger('click')
+ find('aside.right-sidebar.right-sidebar-expanded')
end
end
diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb
index c8c9c50396b..8c23fcd483b 100644
--- a/spec/features/issues/markdown_toolbar_spec.rb
+++ b/spec/features/issues/markdown_toolbar_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-feature 'Issue markdown toolbar', feature: true, js: true do
+feature 'Issue markdown toolbar', js: true do
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
- let(:user) { create(:user) }
+ let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it "doesn't include first new line when adding bold" do
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index 6c09903a2f6..494c309c9ea 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -2,14 +2,14 @@ require 'rails_helper'
feature 'issue move to another project' do
let(:user) { create(:user) }
- let(:old_project) { create(:project) }
+ let(:old_project) { create(:project, :repository) }
let(:text) { 'Some issue description' }
let(:issue) do
create(:issue, description: text, project: old_project, author: user)
end
- background { login_as(user) }
+ background { sign_in(user) }
context 'user does not have permission to move issue' do
background do
@@ -38,20 +38,19 @@ feature 'issue move to another project' do
end
scenario 'moving issue to another project', js: true do
- find('#move_to_project_id', visible: false).set(new_project.id)
+ find('#issuable-move', visible: false).set(new_project.id)
click_button('Save changes')
- expect(current_url).to include project_path(new_project)
-
expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}")
expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}")
expect(page).to have_content(issue.title)
+ expect(page.current_path).to include project_path(new_project)
end
scenario 'searching project dropdown', js: true do
new_project_search.team << [user, :reporter]
- page.within '.js-move-dropdown' do
+ page.within '.detail-page-description' do
first('.select2-choice').click
end
@@ -69,7 +68,7 @@ feature 'issue move to another project' do
background { another_project.team << [user, :guest] }
scenario 'browsing projects in projects select' do
- click_link 'Select project'
+ click_link 'Move to a different project'
page.within '.select2-results' do
expect(page).to have_content 'No project'
@@ -96,10 +95,6 @@ feature 'issue move to another project' do
end
def issue_path(issue)
- namespace_project_issue_path(issue.project.namespace, issue.project, issue)
- end
-
- def project_path(project)
- namespace_project_path(new_project.namespace, new_project)
+ project_issue_path(issue.project, issue)
end
end
diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb
deleted file mode 100644
index c0ab42c6822..00000000000
--- a/spec/features/issues/new_branch_button_spec.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-require 'rails_helper'
-
-feature 'Start new branch from an issue', feature: true, js: true do
- let!(:project) { create(:project) }
- let!(:issue) { create(:issue, project: project) }
- let!(:user) { create(:user)}
-
- context "for team members" do
- before do
- project.team << [user, :master]
- login_as(user)
- end
-
- it 'shows the new branch button' do
- visit namespace_project_issue_path(project.namespace, project, issue)
-
- expect(page).to have_css('#new-branch .available')
- end
-
- context "when there is a referenced merge request" do
- let!(:note) do
- create(:note, :on_issue, :system, project: project, noteable: issue,
- note: "mentioned in #{referenced_mr.to_reference}")
- end
-
- let(:referenced_mr) do
- create(:merge_request, :simple, source_project: project, target_project: project,
- description: "Fixes #{issue.to_reference}", author: user)
- end
-
- before do
- referenced_mr.cache_merge_request_closes_issues!(user)
-
- visit namespace_project_issue_path(project.namespace, project, issue)
- end
-
- it "hides the new branch button" do
- expect(page).to have_css('#new-branch .unavailable')
- expect(page).not_to have_css('#new-branch .available')
- expect(page).to have_content /1 Related Merge Request/
- end
- end
-
- context 'when issue is confidential' do
- it 'hides the new branch button' do
- issue = create(:issue, :confidential, project: project)
-
- visit namespace_project_issue_path(project.namespace, project, issue)
-
- expect(page).not_to have_css('#new-branch')
- end
- end
- end
-
- context 'for visitors' do
- it 'shows no buttons' do
- visit namespace_project_issue_path(project.namespace, project, issue)
-
- expect(page).not_to have_css('#new-branch')
- end
- end
-end
diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb
index 378f6de1a78..62dbc3efb01 100644
--- a/spec/features/issues/note_polling_spec.rb
+++ b/spec/features/issues/note_polling_spec.rb
@@ -1,17 +1,138 @@
require 'spec_helper'
-feature 'Issue notes polling', :feature, :js do
- let(:project) { create(:empty_project, :public) }
+feature 'Issue notes polling', :js do
+ include NoteInteractionHelpers
+
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
- before do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ describe 'creates' do
+ before do
+ visit project_issue_path(project, issue)
+ end
+
+ it 'displays the new comment' do
+ note = create(:note, noteable: issue, project: project, note: 'Looks good!')
+ page.execute_script('notes.refresh();')
+
+ expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
+ end
+ end
+
+ describe 'updates' do
+ context 'when from own user' do
+ let(:user) { create(:user) }
+ let(:note_text) { "Hello World" }
+ let(:updated_text) { "Bye World" }
+ let!(:existing_note) { create(:note, noteable: issue, project: project, author: user, note: note_text) }
+
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'has .original-note-content to compare against' do
+ expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
+ expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
+
+ update_note(existing_note, updated_text)
+
+ expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
+ expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
+ end
+
+ it 'displays the updated content' do
+ expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
+
+ update_note(existing_note, updated_text)
+
+ expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
+ end
+
+ it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do
+ click_edit_action(existing_note)
+
+ expect(page).to have_field("note[note]", with: note_text)
+
+ update_note(existing_note, updated_text)
+
+ expect(page).to have_field("note[note]", with: updated_text)
+ end
+
+ it 'when editing but you changed some things, and an update comes in, show a warning' do
+ click_edit_action(existing_note)
+
+ expect(page).to have_field("note[note]", with: note_text)
+
+ find("#note_#{existing_note.id} .js-note-text").set('something random')
+ update_note(existing_note, updated_text)
+
+ expect(page).to have_selector(".alert")
+ end
+
+ it 'when editing but you changed some things, an update comes in, and you press cancel, show the updated content' do
+ click_edit_action(existing_note)
+
+ expect(page).to have_field("note[note]", with: note_text)
+
+ find("#note_#{existing_note.id} .js-note-text").set('something random')
+
+ update_note(existing_note, updated_text)
+
+ find("#note_#{existing_note.id} .note-edit-cancel").click
+
+ expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
+ end
+ end
+
+ context 'when from another user' do
+ let(:user1) { create(:user) }
+ let(:user2) { create(:user) }
+ let(:note_text) { "Hello World" }
+ let(:updated_text) { "Bye World" }
+ let!(:existing_note) { create(:note, noteable: issue, project: project, author: user1, note: note_text) }
+
+ before do
+ sign_in(user2)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'has .original-note-content to compare against' do
+ expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
+ expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
+
+ update_note(existing_note, updated_text)
+
+ expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
+ expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
+ end
+ end
+
+ context 'system notes' do
+ let(:user) { create(:user) }
+ let(:note_text) { "Some system note" }
+ let!(:system_note) { create(:system_note, noteable: issue, project: project, author: user, note: note_text) }
+
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'has .original-note-content to compare against' do
+ expect(page).to have_selector("#note_#{system_note.id}", text: note_text)
+ expect(page).to have_selector("#note_#{system_note.id} .original-note-content", count: 1, visible: false)
+ end
+ end
end
- it 'should display the new comment' do
- note = create(:note, noteable: issue, project: project, note: 'Looks good!')
+ def update_note(note, new_text)
+ note.update(note: new_text)
page.execute_script('notes.refresh();')
+ end
+
+ def click_edit_action(note)
+ note_element = find("#note_#{note.id}")
- expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
+ note_element.find('.js-note-edit').click
end
end
diff --git a/spec/features/issues/notes_on_issues_spec.rb b/spec/features/issues/notes_on_issues_spec.rb
new file mode 100644
index 00000000000..05c93a19253
--- /dev/null
+++ b/spec/features/issues/notes_on_issues_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+
+describe 'Create notes on issues', :js do
+ let(:user) { create(:user) }
+
+ shared_examples 'notes with reference' do
+ let(:issue) { create(:issue, project: project) }
+ let(:note_text) { "Check #{mention.to_reference}" }
+
+ before do
+ project.team << [user, :developer]
+ sign_in(user)
+ visit project_issue_path(project, issue)
+
+ fill_in 'note[note]', with: note_text
+ click_button 'Comment'
+
+ wait_for_requests
+ end
+
+ it 'creates a note with reference and cross references the issue' do
+ page.within('div#notes li.note div.note-text') do
+ expect(page).to have_content(note_text)
+ expect(page.find('a')).to have_content(mention.to_reference)
+ end
+
+ find('div#notes li.note div.note-text a').click
+
+ page.within('div#notes li.note .system-note-message') do
+ expect(page).to have_content('mentioned in issue')
+ expect(page.find('a')).to have_content(issue.to_reference)
+ end
+ end
+ end
+
+ context 'mentioning issue on a private project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :private) }
+ let(:mention) { create(:issue, project: project) }
+ end
+ end
+
+ context 'mentioning issue on an internal project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :internal) }
+ let(:mention) { create(:issue, project: project) }
+ end
+ end
+
+ context 'mentioning issue on a public project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :public) }
+ let(:mention) { create(:issue, project: project) }
+ end
+ end
+
+ context 'mentioning merge request on a private project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :private, :repository) }
+ let(:mention) { create(:merge_request, source_project: project) }
+ end
+ end
+
+ context 'mentioning merge request on an internal project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :internal, :repository) }
+ let(:mention) { create(:merge_request, source_project: project) }
+ end
+ end
+
+ context 'mentioning merge request on a public project' do
+ it_behaves_like 'notes with reference' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:mention) { create(:merge_request, source_project: project) }
+ end
+ end
+end
diff --git a/spec/features/issues/spam_issues_spec.rb b/spec/features/issues/spam_issues_spec.rb
index 6001476d0ca..332ce78b138 100644
--- a/spec/features/issues/spam_issues_spec.rb
+++ b/spec/features/issues/spam_issues_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe 'New issue', feature: true, js: true do
+describe 'New issue', js: true do
include StubENV
let(:project) { create(:project, :public) }
@@ -18,14 +18,14 @@ describe 'New issue', feature: true, js: true do
)
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'when identified as a spam' do
before do
WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: "true", status: 200)
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
end
it 'creates an issue after solving reCaptcha' do
@@ -50,7 +50,7 @@ describe 'New issue', feature: true, js: true do
before do
WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: 'false', status: 200)
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
end
it 'creates an issue' do
diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb
index 3fde85b0a5c..8405f1cd48d 100644
--- a/spec/features/issues/todo_spec.rb
+++ b/spec/features/issues/todo_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-feature 'Manually create a todo item from issue', feature: true, js: true do
- let!(:project) { create(:project) }
- let!(:issue) { create(:issue, project: project) }
- let!(:user) { create(:user)}
+feature 'Manually create a todo item from issue', js: true do
+ let!(:project) { create(:project) }
+ let!(:issue) { create(:issue, project: project) }
+ let!(:user) { create(:user)}
before do
project.team << [user, :master]
- login_as(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ sign_in(user)
+ visit project_issue_path(project, issue)
end
it 'creates todo when clicking button' do
@@ -21,7 +21,7 @@ feature 'Manually create a todo item from issue', feature: true, js: true do
expect(page).to have_content '1'
end
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
page.within '.header-content .todos-count' do
expect(page).to have_content '1'
@@ -36,7 +36,7 @@ feature 'Manually create a todo item from issue', feature: true, js: true do
expect(page).to have_selector('.todos-count', visible: false)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
expect(page).to have_selector('.todos-count', visible: false)
end
diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb
index 7fa83c1fcf7..bcc6e9bab0f 100644
--- a/spec/features/issues/update_issues_spec.rb
+++ b/spec/features/issues/update_issues_spec.rb
@@ -1,20 +1,21 @@
require 'rails_helper'
-feature 'Multiple issue updating from issues#index', feature: true do
+feature 'Multiple issue updating from issues#index', :js do
let!(:project) { create(:project) }
let!(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)}
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
- context 'status', js: true do
+ context 'status' do
it 'sets to closed' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
- find('#check_all_issues').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: 'Closed').click
@@ -24,9 +25,10 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'sets to open' do
create_closed
- visit namespace_project_issues_path(project.namespace, project, state: 'closed')
+ visit project_issues_path(project, state: 'closed')
- find('#check_all_issues').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: 'Open').click
@@ -35,11 +37,12 @@ feature 'Multiple issue updating from issues#index', feature: true do
end
end
- context 'assignee', js: true do
+ context 'assignee' do
it 'updates to current user' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
- find('#check_all_issues').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
click_update_assignee_button
find('.dropdown-menu-user-link', text: user.username).click
@@ -52,9 +55,10 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'updates to unassigned' do
create_assigned
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
- find('#check_all_issues').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
click_update_assignee_button
click_link 'Unassigned'
@@ -63,14 +67,15 @@ feature 'Multiple issue updating from issues#index', feature: true do
end
end
- context 'milestone', js: true do
- let(:milestone) { create(:milestone, project: project) }
+ context 'milestone' do
+ let!(:milestone) { create(:milestone, project: project) }
it 'updates milestone' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: milestone.title).click
click_update_issues_button
@@ -80,12 +85,13 @@ feature 'Multiple issue updating from issues#index', feature: true do
it 'sets to no milestone' do
create_with_milestone
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
expect(first('.issue')).to have_content milestone.title
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit issues'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: "No Milestone").click
click_update_issues_button
@@ -99,7 +105,7 @@ feature 'Multiple issue updating from issues#index', feature: true do
end
def create_assigned
- create(:issue, project: project, assignee: user)
+ create(:issue, project: project, assignees: [user])
end
def create_with_milestone
@@ -108,11 +114,11 @@ feature 'Multiple issue updating from issues#index', feature: true do
def click_update_assignee_button
find('.js-update-assignee').click
- wait_for_ajax
+ wait_for_requests
end
def click_update_issues_button
- find('.update_selected_issues').click
- wait_for_ajax
+ find('.update-selected-issues').click
+ wait_for_requests
end
end
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
index 4cd6c1171ac..4b63cc844f3 100644
--- a/spec/features/issues/user_uses_slash_commands_spec.rb
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-feature 'Issues > User uses slash commands', feature: true, js: true do
- include SlashCommandsHelpers
+feature 'Issues > User uses quick actions', js: true do
+ include QuickActionsHelpers
- it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do
+ it_behaves_like 'issuable record that supports quick actions in its description and notes', :issue do
let(:issuable) { create(:issue, project: project) }
end
@@ -13,12 +13,22 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ sign_in(user)
+ visit project_issue_path(project, issue)
end
after do
- wait_for_ajax
+ wait_for_requests
+ end
+
+ describe 'time tracking' do
+ let(:issue) { create(:issue, project: project) }
+
+ before do
+ visit project_issue_path(project, issue)
+ end
+
+ it_behaves_like 'issuable time tracker'
end
describe 'adding a due date from note' do
@@ -41,9 +51,9 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- logout
- login_with(guest)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ gitlab_sign_out
+ sign_in(guest)
+ visit project_issue_path(project, issue)
end
it 'does not create a note, and sets the due date accordingly' do
@@ -81,9 +91,9 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- logout
- login_with(guest)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ gitlab_sign_out
+ sign_in(guest)
+ visit project_issue_path(project, issue)
end
it 'does not create a note, and sets the due date accordingly' do
@@ -99,65 +109,50 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
end
end
- describe 'Issuable time tracking' do
+ describe 'toggling the WIP prefix from the title from note' do
let(:issue) { create(:issue, project: project) }
- before do
- project.team << [user, :developer]
- end
-
- context 'Issue' do
- before do
- visit namespace_project_issue_path(project.namespace, project, issue)
- end
-
- it_behaves_like 'issuable time tracker'
- end
-
- context '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 'does not recognize the command nor create a note' do
+ write_note("/wip")
- it_behaves_like 'issuable time tracker'
+ expect(page).not_to have_content '/wip'
end
end
- describe 'Issuable time tracking' do
+ describe 'mark issue as duplicate' do
let(:issue) { create(:issue, project: project) }
+ let(:original_issue) { create(:issue, project: project) }
- before do
- project.team << [user, :developer]
- end
+ context 'when the current user can update issues' do
+ it 'does not create a note, and marks the issue as a duplicate' do
+ write_note("/duplicate ##{original_issue.to_reference}")
- context 'Issue' do
- before do
- visit namespace_project_issue_path(project.namespace, project, issue)
- end
+ expect(page).not_to have_content "/duplicate #{original_issue.to_reference}"
+ expect(page).to have_content 'Commands applied'
+ expect(page).to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
- it_behaves_like 'issuable time tracker'
+ expect(issue.reload).to be_closed
+ end
end
- context 'Merge Request' do
- let(:merge_request) { create(:merge_request, source_project: project) }
-
+ context 'when the current user cannot update the issue' do
+ let(:guest) { create(:user) }
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ project.team << [guest, :guest]
+ gitlab_sign_out
+ sign_in(guest)
+ visit project_issue_path(project, issue)
end
- it_behaves_like 'issuable time tracker'
- end
- end
-
- describe 'toggling the WIP prefix from the title from note' do
- let(:issue) { create(:issue, project: project) }
+ it 'does not create a note, and does not mark the issue as a duplicate' do
+ write_note("/duplicate ##{original_issue.to_reference}")
- it 'does not recognize the command nor create a note' do
- write_note("/wip")
+ expect(page).to have_content "/duplicate ##{original_issue.to_reference}"
+ expect(page).not_to have_content 'Commands applied'
+ expect(page).not_to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
- expect(page).not_to have_content '/wip'
+ expect(issue.reload).to be_open
+ end
end
end
end
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index 81cc8513454..3ffc80622f5 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -1,56 +1,50 @@
require 'spec_helper'
-describe 'Issues', feature: true do
+describe 'Issues' do
include DropzoneHelper
include IssueHelpers
include SortingHelper
- let(:project) { create(:empty_project, :public) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
before do
- login_as :user
+ sign_in(user)
user2 = create(:user)
- project.team << [[@user, user2], :developer]
+ project.team << [[user, user2], :developer]
end
describe 'Edit issue' do
let!(:issue) do
create(:issue,
- author: @user,
- assignee: @user,
+ author: user,
+ assignees: [user],
project: project)
end
before do
- visit edit_namespace_project_issue_path(project.namespace, project, issue)
+ visit edit_project_issue_path(project, issue)
find('.js-zen-enter').click
end
it 'opens new issue popup' do
expect(page).to have_content("Issue ##{issue.iid}")
end
-
- describe 'fill in' do
- before do
- fill_in 'issue_title', with: 'bug 345'
- fill_in 'issue_description', with: 'bug description'
- end
- end
end
describe 'Editing issue assignee' do
let!(:issue) do
create(:issue,
- author: @user,
- assignee: @user,
+ author: user,
+ assignees: [user],
project: project)
end
it 'allows user to select unassigned', js: true do
- visit edit_namespace_project_issue_path(project.namespace, project, issue)
+ visit edit_project_issue_path(project, issue)
- expect(page).to have_content "Assignee #{@user.name}"
+ expect(page).to have_content "Assignee #{user.name}"
first('.js-user-search').click
click_link 'Unassigned'
@@ -61,14 +55,14 @@ describe 'Issues', feature: true do
expect(page).to have_content 'No assignee - assign yourself'
end
- expect(issue.reload.assignee).to be_nil
+ expect(issue.reload.assignees).to be_empty
end
end
describe 'due date', js: true do
context 'on new form' do
before do
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
end
it 'saves with due date' do
@@ -93,10 +87,10 @@ describe 'Issues', feature: true do
end
context 'on edit form' do
- let(:issue) { create(:issue, author: @user, project: project, due_date: Date.today.at_beginning_of_month.to_s) }
+ let(:issue) { create(:issue, author: user, project: project, due_date: Date.today.at_beginning_of_month.to_s) }
before do
- visit edit_namespace_project_issue_path(project.namespace, project, issue)
+ visit edit_project_issue_path(project, issue)
end
it 'saves with due date' do
@@ -138,10 +132,10 @@ describe 'Issues', feature: true do
describe 'Issue info' do
it 'excludes award_emoji from comment count' do
- issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar')
+ issue = create(:issue, author: user, assignees: [user], project: project, title: 'foobar')
create(:award_emoji, awardable: issue)
- visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id)
+ visit project_issues_path(project, assignee_id: user.id)
expect(page).to have_content 'foobar'
expect(page.all('.no-comments').first.text).to eq "0"
@@ -152,22 +146,22 @@ describe 'Issues', feature: true do
before do
%w(foobar barbaz gitlab).each do |title|
create(:issue,
- author: @user,
- assignee: @user,
+ author: user,
+ assignees: [user],
project: project,
title: title)
end
@issue = Issue.find_by(title: 'foobar')
@issue.milestone = create(:milestone, project: project)
- @issue.assignee = nil
+ @issue.assignees = []
@issue.save
end
let(:issue) { @issue }
it 'allows filtering by issues with no specified assignee' do
- visit namespace_project_issues_path(project.namespace, project, assignee_id: IssuableFinder::NONE)
+ visit project_issues_path(project, assignee_id: IssuableFinder::NONE)
expect(page).to have_content 'foobar'
expect(page).not_to have_content 'barbaz'
@@ -175,7 +169,7 @@ describe 'Issues', feature: true do
end
it 'allows filtering by a specified assignee' do
- visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id)
+ visit project_issues_path(project, assignee_id: user.id)
expect(page).not_to have_content 'foobar'
expect(page).to have_content 'barbaz'
@@ -196,14 +190,14 @@ describe 'Issues', feature: true do
let(:later_due_milestone) { create(:milestone, due_date: '2013-12-12') }
it 'sorts by newest' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_recently_created)
+ visit project_issues_path(project, sort: sort_value_recently_created)
expect(first_issue).to include('foo')
expect(last_issue).to include('baz')
end
it 'sorts by oldest' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_oldest_created)
+ visit project_issues_path(project, sort: sort_value_oldest_created)
expect(first_issue).to include('baz')
expect(last_issue).to include('foo')
@@ -212,7 +206,7 @@ describe 'Issues', feature: true do
it 'sorts by most recently updated' do
baz.updated_at = Time.now + 100
baz.save
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_recently_updated)
+ visit project_issues_path(project, sort: sort_value_recently_updated)
expect(first_issue).to include('baz')
end
@@ -220,7 +214,7 @@ describe 'Issues', feature: true do
it 'sorts by least recently updated' do
baz.updated_at = Time.now - 100
baz.save
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_oldest_updated)
+ visit project_issues_path(project, sort: sort_value_oldest_updated)
expect(first_issue).to include('baz')
end
@@ -232,13 +226,13 @@ describe 'Issues', feature: true do
end
it 'sorts by recently due date' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_soon)
+ visit project_issues_path(project, sort: sort_value_due_date_soon)
expect(first_issue).to include('foo')
end
it 'sorts by least recently due date' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_later)
+ visit project_issues_path(project, sort: sort_value_due_date_later)
expect(first_issue).to include('bar')
end
@@ -246,19 +240,22 @@ describe 'Issues', feature: true do
it 'sorts by least recently due date by excluding nil due dates' do
bar.update(due_date: nil)
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_later)
+ visit project_issues_path(project, sort: sort_value_due_date_later)
expect(first_issue).to include('foo')
end
context 'with a filter on labels' do
let(:label) { create(:label, project: project) }
- before { create(:label_link, label: label, target: foo) }
+
+ before do
+ create(:label_link, label: label, target: foo)
+ end
it 'sorts by least recently due date by excluding nil due dates' do
bar.update(due_date: nil)
- visit namespace_project_issues_path(project.namespace, project, label_names: [label.name], sort: sort_value_due_date_later)
+ visit project_issues_path(project, label_names: [label.name], sort: sort_value_due_date_later)
expect(first_issue).to include('foo')
end
@@ -272,7 +269,7 @@ describe 'Issues', feature: true do
end
it 'filters by none' do
- visit namespace_project_issues_path(project.namespace, project, due_date: Issue::NoDueDate.name)
+ visit project_issues_path(project, due_date: Issue::NoDueDate.name)
expect(page).not_to have_content('foo')
expect(page).not_to have_content('bar')
@@ -280,7 +277,7 @@ describe 'Issues', feature: true do
end
it 'filters by any' do
- visit namespace_project_issues_path(project.namespace, project, due_date: Issue::AnyDueDate.name)
+ visit project_issues_path(project, due_date: Issue::AnyDueDate.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
@@ -292,7 +289,7 @@ describe 'Issues', feature: true do
bar.update(due_date: Date.today.end_of_week)
baz.update(due_date: Date.today - 8.days)
- visit namespace_project_issues_path(project.namespace, project, due_date: Issue::DueThisWeek.name)
+ visit project_issues_path(project, due_date: Issue::DueThisWeek.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
@@ -304,7 +301,7 @@ describe 'Issues', feature: true do
bar.update(due_date: Date.today.end_of_month)
baz.update(due_date: Date.today - 50.days)
- visit namespace_project_issues_path(project.namespace, project, due_date: Issue::DueThisMonth.name)
+ visit project_issues_path(project, due_date: Issue::DueThisMonth.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
@@ -316,7 +313,7 @@ describe 'Issues', feature: true do
bar.update(due_date: Date.today + 20.days)
baz.update(due_date: Date.yesterday)
- visit namespace_project_issues_path(project.namespace, project, due_date: Issue::Overdue.name)
+ visit project_issues_path(project, due_date: Issue::Overdue.name)
expect(page).not_to have_content('foo')
expect(page).not_to have_content('bar')
@@ -333,14 +330,14 @@ describe 'Issues', feature: true do
end
it 'sorts by recently due milestone' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_milestone_soon)
+ visit project_issues_path(project, sort: sort_value_milestone_soon)
expect(first_issue).to include('foo')
expect(last_issue).to include('baz')
end
it 'sorts by least recently due milestone' do
- visit namespace_project_issues_path(project.namespace, project, sort: sort_value_milestone_later)
+ visit project_issues_path(project, sort: sort_value_milestone_later)
expect(first_issue).to include('bar')
expect(last_issue).to include('baz')
@@ -351,14 +348,14 @@ describe 'Issues', feature: true do
let(:user2) { create(:user) }
before do
- foo.assignee = user2
+ foo.assignees << user2
foo.save
- bar.assignee = user2
+ bar.assignees << user2
bar.save
end
it 'sorts with a filter applied' do
- visit namespace_project_issues_path(project.namespace, project,
+ visit project_issues_path(project,
sort: sort_value_oldest_created,
assignee_id: user2.id)
@@ -370,13 +367,13 @@ describe 'Issues', feature: true do
end
describe 'when I want to reset my incoming email token' do
- let(:project1) { create(:empty_project, namespace: @user.namespace) }
+ let(:project1) { create(:project, namespace: user.namespace) }
let!(:issue) { create(:issue, project: project1) }
before do
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
- project1.team << [@user, :master]
- visit namespace_project_issues_path(@user.namespace, project1)
+ project1.team << [user, :master]
+ visit namespace_project_issues_path(user.namespace, project1)
end
it 'changes incoming email address token', js: true do
@@ -384,10 +381,10 @@ describe 'Issues', feature: true do
previous_token = find('input#issue_email').value
find('.incoming-email-token-reset').trigger('click')
- wait_for_ajax
+ wait_for_requests
expect(page).to have_no_field('issue_email', with: previous_token)
- new_token = project1.new_issue_address(@user.reload)
+ new_token = project1.new_issue_address(user.reload)
expect(page).to have_field(
'issue_email',
with: new_token
@@ -396,11 +393,11 @@ describe 'Issues', feature: true do
end
describe 'update labels from issue#show', js: true do
- let(:issue) { create(:issue, project: project, author: @user, assignee: @user) }
+ let(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
let!(:label) { create(:label, project: project) }
before do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it 'will not send ajax request when no data is changed' do
@@ -415,26 +412,30 @@ describe 'Issues', feature: true do
end
describe 'update assignee from issue#show' do
- let(:issue) { create(:issue, project: project, author: @user, assignee: @user) }
+ let(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
context 'by authorized user' do
it 'allows user to select unassigned', js: true do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
page.within('.assignee') do
- expect(page).to have_content "#{@user.name}"
+ expect(page).to have_content "#{user.name}"
click_link 'Edit'
click_link 'Unassigned'
+ first('.title').click
expect(page).to have_content 'No assignee'
end
- expect(issue.reload.assignee).to be_nil
+ # wait_for_requests does not work with vue-resource at the moment
+ sleep 1
+
+ expect(issue.reload.assignees).to be_empty
end
it 'allows user to select an assignee', js: true do
- issue2 = create(:issue, project: project, author: @user)
- visit namespace_project_issue_path(project.namespace, project, issue2)
+ issue2 = create(:issue, project: project, author: user)
+ visit project_issue_path(project, issue2)
page.within('.assignee') do
expect(page).to have_content "No assignee"
@@ -445,30 +446,30 @@ describe 'Issues', feature: true do
end
page.within '.dropdown-menu-user' do
- click_link @user.name
+ click_link user.name
end
page.within('.assignee') do
- expect(page).to have_content @user.name
+ expect(page).to have_content user.name
end
end
it 'allows user to unselect themselves', js: true do
- issue2 = create(:issue, project: project, author: @user)
- visit namespace_project_issue_path(project.namespace, project, issue2)
+ issue2 = create(:issue, project: project, author: user)
+ visit project_issue_path(project, issue2)
page.within '.assignee' do
click_link 'Edit'
- click_link @user.name
+ click_link user.name
- page.within '.value' do
- expect(page).to have_content @user.name
+ page.within '.value .author' do
+ expect(page).to have_content user.name
end
click_link 'Edit'
- click_link @user.name
+ click_link user.name
- page.within '.value' do
+ page.within '.value .assign-yourself' do
expect(page).to have_content "No assignee"
end
end
@@ -483,22 +484,22 @@ describe 'Issues', feature: true do
end
it 'shows assignee text', js: true do
- logout
- login_with guest
+ sign_out(:user)
+ sign_in(guest)
- visit namespace_project_issue_path(project.namespace, project, issue)
- expect(page).to have_content issue.assignee.name
+ visit project_issue_path(project, issue)
+ expect(page).to have_content issue.assignees.first.name
end
end
end
describe 'update milestone from issue#show' do
- let!(:issue) { create(:issue, project: project, author: @user) }
+ let!(:issue) { create(:issue, project: project, author: user) }
let!(:milestone) { create(:milestone, project: project) }
context 'by authorized user' do
it 'allows user to select unassigned', js: true do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
page.within('.milestone') do
expect(page).to have_content "None"
@@ -516,7 +517,7 @@ describe 'Issues', feature: true do
end
it 'allows user to de-select milestone', js: true do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
page.within('.milestone') do
click_link 'Edit'
@@ -546,46 +547,39 @@ describe 'Issues', feature: true do
end
it 'shows milestone text', js: true do
- logout
- login_with guest
+ sign_out(:user)
+ sign_in(guest)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
expect(page).to have_content milestone.title
end
end
-
- describe 'removing assignee' do
- let(:user2) { create(:user) }
-
- before do
- issue.assignee = user2
- issue.save
- end
- end
end
describe 'new issue' do
+ let!(:issue) { create(:issue, project: project) }
+
context 'by unauthenticated user' do
before do
- logout
+ sign_out(:user)
end
it 'redirects to signin then back to new issue after signin' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
click_link 'New issue'
expect(current_path).to eq new_user_session_path
- login_as :user
+ gitlab_sign_in(create(:user))
- expect(current_path).to eq new_namespace_project_issue_path(project.namespace, project)
+ expect(current_path).to eq new_project_issue_path(project)
end
end
context 'dropzone upload file', js: true do
before do
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
end
it 'uploads file when dragging into textarea' do
@@ -606,13 +600,13 @@ describe 'Issues', feature: true do
before do
project.repository.create_file(
- @user,
+ user,
'.gitlab/issue_templates/bug.md',
'this is a test "bug" template',
message: 'added issue template',
branch_name: 'master')
- visit new_namespace_project_issue_path(project.namespace, project, issuable_template: 'bug')
+ visit new_project_issue_path(project, issuable_template: 'bug')
end
it 'fills in template' do
@@ -629,13 +623,13 @@ describe 'Issues', feature: true do
project.issues << issue
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
click_button('Email a new issue')
end
it 'click the button to show modal for the new email' do
page.within '#issue-email-modal' do
- email = project.new_issue_address(@user)
+ email = project.new_issue_address(user)
expect(page).to have_selector("input[value='#{email}']")
end
@@ -643,7 +637,7 @@ describe 'Issues', feature: true do
end
context 'with existing issues' do
- let!(:issue) { create(:issue, project: project, author: @user) }
+ let!(:issue) { create(:issue, project: project, author: user) }
it_behaves_like 'show the email in the modal'
end
@@ -655,10 +649,10 @@ describe 'Issues', feature: true do
describe 'due date' do
context 'update due on issue#show', js: true do
- let(:issue) { create(:issue, project: project, author: @user, assignee: @user) }
+ let(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
before do
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it 'adds due date to issue' do
@@ -671,7 +665,7 @@ describe 'Issues', feature: true do
click_button date.day
end
- wait_for_ajax
+ wait_for_requests
expect(find('.value').text).to have_content date.strftime('%b %-d, %Y')
end
@@ -687,7 +681,7 @@ describe 'Issues', feature: true do
click_button date.day
end
- wait_for_ajax
+ wait_for_requests
expect(page).to have_no_content 'No due date'
@@ -699,19 +693,42 @@ describe 'Issues', feature: true do
end
describe 'title issue#show', js: true do
- include WaitForVueResource
-
it 'updates the title', js: true do
- issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title')
+ issue = create(:issue, author: user, assignees: [user], project: project, title: 'new title')
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
expect(page).to have_text("new title")
issue.update(title: "updated title")
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_text("updated title")
end
end
+
+ describe 'confidential issue#show', js: true do
+ it 'shows confidential sibebar information as confidential and can be turned off' do
+ issue = create(:issue, :confidential, project: project)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_css('.confidential-issue-warning')
+ expect(page).to have_css('.is-confidential')
+ expect(page).not_to have_css('.is-not-confidential')
+
+ find('.confidential-edit').click
+ expect(page).to have_css('.confidential-warning-message')
+
+ within('.confidential-warning-message') do
+ find('.btn-close').click
+ end
+
+ wait_for_requests
+
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('.is-confidential')
+ end
+ end
end
diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb
index 11d417c253d..c9983f0941f 100644
--- a/spec/features/login_spec.rb
+++ b/spec/features/login_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Login', feature: true do
+feature 'Login' do
describe 'initial login after setup' do
it 'allows the initial admin to create a password' do
# This behavior is dependent on there only being one user
@@ -36,27 +36,27 @@ feature 'Login', feature: true do
it 'prevents the user from logging in' do
user = create(:user, :blocked)
- login_with(user)
+ gitlab_sign_in(user)
expect(page).to have_content('Your account has been blocked.')
end
- it 'does not update Devise trackable attributes' do
+ it 'does not update Devise trackable attributes', :clean_gitlab_redis_shared_state do
user = create(:user, :blocked)
- expect { login_with(user) }.not_to change { user.reload.sign_in_count }
+ expect { gitlab_sign_in(user) }.not_to change { user.reload.sign_in_count }
end
end
describe 'with the ghost user' do
it 'disallows login' do
- login_with(User.ghost)
+ gitlab_sign_in(User.ghost)
expect(page).to have_content('Invalid Login or password.')
end
- it 'does not update Devise trackable attributes' do
- expect { login_with(User.ghost) }.not_to change { User.ghost.reload.sign_in_count }
+ it 'does not update Devise trackable attributes', :clean_gitlab_redis_shared_state do
+ expect { gitlab_sign_in(User.ghost) }.not_to change { User.ghost.reload.sign_in_count }
end
end
@@ -70,7 +70,7 @@ feature 'Login', feature: true do
let(:user) { create(:user, :two_factor) }
before do
- login_with(user, remember: true)
+ gitlab_sign_in(user, remember: true)
expect(page).to have_content('Two-Factor Authentication')
end
@@ -122,8 +122,8 @@ feature 'Login', feature: true do
end
it 'invalidates the used code' do
- expect { enter_code(codes.sample) }.
- to change { user.reload.otp_backup_codes.size }.by(-1)
+ expect { enter_code(codes.sample) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
end
end
@@ -143,31 +143,10 @@ feature 'Login', feature: true do
end
context 'logging in via OAuth' do
- def saml_config
- OpenStruct.new(name: 'saml', label: 'saml', args: {
- assertion_consumer_service_url: 'https://localhost:3443/users/auth/saml/callback',
- idp_cert_fingerprint: '26:43:2C:47:AF:F0:6B:D0:07:9C:AD:A3:74:FE:5D:94:5F:4E:9E:52',
- idp_sso_target_url: 'https://idp.example.com/sso/saml',
- issuer: 'https://localhost:3443/',
- name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
- })
- end
-
- def stub_omniauth_config(messages)
- Rails.application.env_config['devise.mapping'] = Devise.mappings[:user]
- Rails.application.routes.disable_clear_and_finalize = true
- Rails.application.routes.draw do
- post '/users/auth/saml' => 'omniauth_callbacks#saml'
- end
- allow(Gitlab::OAuth::Provider).to receive_messages(providers: [:saml], config_for: saml_config)
- allow(Gitlab.config.omniauth).to receive_messages(messages)
- expect_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml')
- end
-
it 'shows 2FA prompt after OAuth login' do
- stub_omniauth_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [saml_config])
+ stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [mock_saml_config])
user = create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: 'saml')
- login_via('saml', user, 'my-uid')
+ gitlab_sign_in_via('saml', user, 'my-uid')
expect(page).to have_content('Two-Factor Authentication')
enter_code(user.current_otp)
@@ -180,19 +159,19 @@ feature 'Login', feature: true do
let(:user) { create(:user) }
it 'allows basic login' do
- login_with(user)
+ gitlab_sign_in(user)
expect(current_path).to eq root_path
end
it 'does not show a "You are already signed in." error message' do
- login_with(user)
+ gitlab_sign_in(user)
expect(page).not_to have_content('You are already signed in.')
end
it 'blocks invalid login' do
user = create(:user, password: 'not-the-default')
- login_with(user)
+ gitlab_sign_in(user)
expect(page).to have_content('Invalid Login or password.')
end
end
@@ -202,12 +181,14 @@ feature 'Login', feature: true do
# TODO: otp_grace_period_started_at
context 'global setting' do
- before(:each) { stub_application_setting(require_two_factor_authentication: true) }
+ before do
+ stub_application_setting(require_two_factor_authentication: true)
+ end
context 'with grace period defined' do
- before(:each) do
+ before do
stub_application_setting(two_factor_grace_period: 48)
- login_with(user)
+ gitlab_sign_in(user)
end
context 'within the grace period' do
@@ -242,9 +223,9 @@ feature 'Login', feature: true do
end
context 'without grace period defined' do
- before(:each) do
+ before do
stub_application_setting(two_factor_grace_period: 0)
- login_with(user)
+ gitlab_sign_in(user)
end
it 'redirects to two-factor configuration page' do
@@ -265,9 +246,9 @@ feature 'Login', feature: true do
end
context 'with grace period defined' do
- before(:each) do
+ before do
stub_application_setting(two_factor_grace_period: 48)
- login_with(user)
+ gitlab_sign_in(user)
end
context 'within the grace period' do
@@ -306,9 +287,9 @@ feature 'Login', feature: true do
end
context 'without grace period defined' do
- before(:each) do
+ before do
stub_application_setting(two_factor_grace_period: 0)
- login_with(user)
+ gitlab_sign_in(user)
end
it 'redirects to two-factor configuration page' do
diff --git a/spec/features/markdown_spec.rb b/spec/features/markdown_spec.rb
index ba930de937d..b70d3060f05 100644
--- a/spec/features/markdown_spec.rb
+++ b/spec/features/markdown_spec.rb
@@ -24,7 +24,7 @@ require 'erb'
#
# See the MarkdownFeature class for setup details.
-describe 'GitLab Markdown', feature: true do
+describe 'GitLab Markdown' do
include Capybara::Node::Matchers
include MarkupHelper
include MarkdownMatchers
@@ -58,8 +58,8 @@ describe 'GitLab Markdown', feature: true do
end
it 'allows Markdown in tables' do
- expect(doc.at_css('td:contains("Baz")').children.to_html).
- to eq '<strong>Baz</strong>'
+ expect(doc.at_css('td:contains("Baz")').children.to_html)
+ .to eq '<strong>Baz</strong>'
end
it 'parses fenced code blocks' do
@@ -100,7 +100,7 @@ describe 'GitLab Markdown', feature: true do
end
it 'permits img elements' do
- expect(doc).to have_selector('img[src*="smile.png"]')
+ expect(doc).to have_selector('img[data-src*="smile.png"]')
end
it 'permits br elements' do
@@ -158,14 +158,14 @@ describe 'GitLab Markdown', feature: true do
describe 'Edge Cases' do
it 'allows markup inside link elements' do
aggregate_failures do
- expect(doc.at_css('a[href="#link-emphasis"]').to_html).
- to eq %{<a href="#link-emphasis"><em>text</em></a>}
+ expect(doc.at_css('a[href="#link-emphasis"]').to_html)
+ .to eq %{<a href="#link-emphasis"><em>text</em></a>}
- expect(doc.at_css('a[href="#link-strong"]').to_html).
- to eq %{<a href="#link-strong"><strong>text</strong></a>}
+ expect(doc.at_css('a[href="#link-strong"]').to_html)
+ .to eq %{<a href="#link-strong"><strong>text</strong></a>}
- expect(doc.at_css('a[href="#link-code"]').to_html).
- to eq %{<a href="#link-code"><code>text</code></a>}
+ expect(doc.at_css('a[href="#link-code"]').to_html)
+ .to eq %{<a href="#link-code"><code>text</code></a>}
end
end
end
diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb
index 43cc6f2a2a7..63fa72650ac 100644
--- a/spec/features/merge_requests/assign_issues_spec.rb
+++ b/spec/features/merge_requests/assign_issues_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
-feature 'Merge request issue assignment', js: true, feature: true do
+feature 'Merge request issue assignment', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, :simple, source_project: project, author: user, description: "fixes #{issue1.to_reference} and #{issue2.to_reference}") }
@@ -13,12 +13,12 @@ feature 'Merge request issue assignment', js: true, feature: true do
end
def visit_merge_request(current_user = nil)
- login_as(current_user || user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(current_user || user)
+ visit project_merge_request_path(project, merge_request)
end
context 'logged in as author' do
- scenario 'updates related issues' do
+ it 'updates related issues' do
visit_merge_request
click_link "Assign yourself to these issues"
@@ -33,7 +33,7 @@ feature 'Merge request issue assignment', js: true, feature: true do
end
it "doesn't display if related issues are already assigned" do
- [issue1, issue2].each { |issue| issue.update!(assignee: user) }
+ [issue1, issue2].each { |issue| issue.update!(assignees: [user]) }
visit_merge_request
diff --git a/spec/features/merge_requests/award_spec.rb b/spec/features/merge_requests/award_spec.rb
index ac260e118d0..e886309133d 100644
--- a/spec/features/merge_requests/award_spec.rb
+++ b/spec/features/merge_requests/award_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-feature 'Merge request awards', js: true, feature: true do
+feature 'Merge request awards', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
describe 'logged in' do
before do
- login_as(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
end
it 'adds award to merge request' do
@@ -16,7 +16,7 @@ feature 'Merge request awards', js: true, feature: true do
expect(page).to have_selector('.js-emoji-btn.active')
expect(first('.js-emoji-btn')).to have_content '1'
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
expect(first('.js-emoji-btn')).to have_content '1'
end
@@ -25,7 +25,7 @@ feature 'Merge request awards', js: true, feature: true do
find('.js-emoji-btn.active').click
expect(first('.js-emoji-btn')).to have_content '0'
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
expect(first('.js-emoji-btn')).to have_content '0'
end
@@ -39,7 +39,7 @@ feature 'Merge request awards', js: true, feature: true do
describe 'logged out' do
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not see award menu button' do
diff --git a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
index 77b7ba4ac7a..1f5e7b55fb0 100644
--- a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
+++ b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-feature 'Check if mergeable with unresolved discussions', js: true, feature: true do
+feature 'Check if mergeable with unresolved discussions', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let!(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) }
before do
- login_as user
+ sign_in user
project.team << [user, :master]
end
@@ -19,8 +19,8 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru
it 'does not allow to merge' do
visit_merge_request(merge_request)
- expect(page).not_to have_button 'Accept merge request'
- expect(page).to have_content('This merge request has unresolved discussions')
+ expect(page).not_to have_button 'Merge'
+ expect(page).to have_content('There are unresolved discussions.')
end
end
@@ -32,7 +32,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ expect(page).to have_button 'Merge'
end
end
end
@@ -46,7 +46,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru
it 'does not allow to merge' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ expect(page).to have_button 'Merge'
end
end
@@ -58,12 +58,12 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ expect(page).to have_button 'Merge'
end
end
end
def visit_merge_request(merge_request)
- visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
+ visit project_merge_request_path(merge_request.project, merge_request)
end
end
diff --git a/spec/features/merge_requests/cherry_pick_spec.rb b/spec/features/merge_requests/cherry_pick_spec.rb
index dfe7c910a10..4b1e1b9a8d4 100644
--- a/spec/features/merge_requests/cherry_pick_spec.rb
+++ b/spec/features/merge_requests/cherry_pick_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe 'Cherry-pick Merge Requests' do
+describe 'Cherry-pick Merge Requests', js: true do
let(:user) { create(:user) }
let(:group) { create(:group) }
- let(:project) { create(:project, namespace: group) }
+ let(:project) { create(:project, :repository, namespace: group) }
let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user) }
before do
- login_as user
+ sign_in user
project.team << [user, :master]
end
@@ -28,7 +28,7 @@ describe 'Cherry-pick Merge Requests' do
end
it "doesn't show a Cherry-pick button" do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
expect(page).not_to have_link "Cherry-pick"
end
@@ -36,7 +36,7 @@ describe 'Cherry-pick Merge Requests' do
context "With a merge commit" do
it "shows a Cherry-pick button" do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
expect(page).to have_link "Cherry-pick"
end
diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb
index eafcab6a0d7..299b4f5708a 100644
--- a/spec/features/merge_requests/closes_issues_spec.rb
+++ b/spec/features/merge_requests/closes_issues_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Merge Request closing issues message', feature: true do
+feature 'Merge Request closing issues message', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:issue_1) { create(:issue, project: project)}
let(:issue_2) { create(:issue, project: project)}
let(:merge_request) do
@@ -20,22 +20,17 @@ feature 'Merge Request closing issues message', feature: true do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
- end
-
- context 'not closing or mentioning any issue' do
- it 'does not display closing issue message' do
- expect(page).not_to have_css('.mr-widget-footer')
- end
+ visit project_merge_request_path(project, merge_request)
+ wait_for_requests
end
context 'closing issues but not mentioning any other issue' do
let(:merge_request_description) { "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}")
+ expect(page).to have_content("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
end
end
@@ -43,7 +38,7 @@ feature 'Merge Request closing issues message', feature: true do
let(:merge_request_description) { "Description\n\nRefers to #{issue_1.to_reference} and #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Issues #{issue_1.to_reference} and #{issue_2.to_reference} are mentioned but will not be closed.")
+ expect(page).to have_content("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
end
end
@@ -51,7 +46,8 @@ feature 'Merge Request closing issues message', feature: true do
let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.")
+ expect(page).to have_content("Closes #{issue_1.to_reference}")
+ expect(page).to have_content("Mentions #{issue_2.to_reference}")
end
end
@@ -59,7 +55,7 @@ feature 'Merge Request closing issues message', feature: true do
let(:merge_request_title) { "closing #{issue_1.to_reference}, #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}")
+ expect(page).to have_content("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
end
end
@@ -67,7 +63,7 @@ feature 'Merge Request closing issues message', feature: true do
let(:merge_request_title) { "Refers to #{issue_1.to_reference} and #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Issues #{issue_1.to_reference} and #{issue_2.to_reference} are mentioned but will not be closed.")
+ expect(page).to have_content("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
end
end
@@ -75,7 +71,8 @@ feature 'Merge Request closing issues message', feature: true do
let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" }
it 'does not display closing issue message' do
- expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.")
+ expect(page).to have_content("Closes #{issue_1.to_reference}")
+ expect(page).to have_content("Mentions #{issue_2.to_reference}")
end
end
end
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
index 43977ad2fc5..2d2c674f8fb 100644
--- a/spec/features/merge_requests/conflicts_spec.rb
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -1,8 +1,13 @@
require 'spec_helper'
-feature 'Merge request conflict resolution', js: true, feature: true do
+feature 'Merge request conflict resolution', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ # In order to have the diffs collapsed, we need to disable the increase feature
+ stub_feature_flags(gitlab_git_diff_size_limit_increase: false)
+ end
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr|
@@ -23,13 +28,14 @@ feature 'Merge request conflict resolution', js: true, feature: true do
end
click_button 'Commit conflict resolution'
- wait_for_ajax
expect(page).to have_content('All merge conflicts were resolved')
merge_request.reload_diff
+ wait_for_requests
+
click_on 'Changes'
- wait_for_ajax
+ wait_for_requests
within find('.diff-file', text: 'files/ruby/popen.rb') do
expect(page).to have_selector('.line_content.new', text: "vars = { 'PWD' => path }")
@@ -53,23 +59,25 @@ feature 'Merge request conflict resolution', js: true, feature: true do
within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
click_button 'Edit inline'
- wait_for_ajax
+ wait_for_requests
execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("One morning");')
end
within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do
click_button 'Edit inline'
- wait_for_ajax
+ wait_for_requests
execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");')
end
click_button 'Commit conflict resolution'
- wait_for_ajax
+
expect(page).to have_content('All merge conflicts were resolved')
merge_request.reload_diff
+ wait_for_requests
+
click_on 'Changes'
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content('One morning')
expect(page).to have_content('Gregor Samsa woke from troubled dreams')
@@ -79,20 +87,24 @@ feature 'Merge request conflict resolution', js: true, feature: true do
context 'can be resolved in the UI' do
before do
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
end
context 'the conflicts are resolvable' do
let(:merge_request) { create_merge_request('conflict-resolvable') }
- before { visit namespace_project_merge_request_path(project.namespace, project, merge_request) }
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
it 'shows a link to the conflict resolution page' do
expect(page).to have_link('conflicts', href: /\/conflicts\Z/)
end
context 'in Inline view mode' do
- before { click_link('conflicts', href: /\/conflicts\Z/) }
+ before do
+ click_link('conflicts', href: /\/conflicts\Z/)
+ end
include_examples "conflicts are resolved in Interactive mode"
include_examples "conflicts are resolved in Edit inline mode"
@@ -100,7 +112,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
context 'in Parallel view mode' do
before do
- click_link('conflicts', href: /\/conflicts\Z/)
+ click_link('conflicts', href: /\/conflicts\Z/)
click_button 'Side-by-side'
end
@@ -113,7 +125,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
let(:merge_request) { create_merge_request('conflict-contains-conflict-markers') }
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
click_link('conflicts', href: /\/conflicts\Z/)
end
@@ -126,21 +138,22 @@ feature 'Merge request conflict resolution', js: true, feature: true do
it 'conflicts are resolved in Edit inline mode' do
within find('.files-wrapper .diff-file', text: 'files/markdown/ruby-style-guide.md') do
- wait_for_ajax
+ wait_for_requests
execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("Gregor Samsa woke from troubled dreams");')
end
click_button 'Commit conflict resolution'
- wait_for_ajax
expect(page).to have_content('All merge conflicts were resolved')
merge_request.reload_diff
+ wait_for_requests
+
click_on 'Changes'
- wait_for_ajax
+ wait_for_requests
click_link 'Expand all'
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content('Gregor Samsa woke from troubled dreams')
end
@@ -151,7 +164,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
'conflict-too-large' => 'when the conflicts contain a large file',
'conflict-binary-file' => 'when the conflicts contain a binary file',
'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another',
- 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file',
+ 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file'
}.freeze
UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
@@ -160,9 +173,9 @@ feature 'Merge request conflict resolution', js: true, feature: true do
before do
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not show a link to the conflict resolution page' do
@@ -171,7 +184,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
it 'shows an error if the conflicts page is visited directly' do
visit current_url + '/conflicts'
- wait_for_ajax
+ wait_for_requests
expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
end
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index 16b09933bda..d7f3d91e625 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -1,19 +1,17 @@
require 'spec_helper'
-feature 'Create New Merge Request', feature: true, js: true do
- include WaitForVueResource
-
+feature 'Create New Merge Request', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
end
it 'selects the source branch sha when a tag with the same name exists' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
click_link 'New merge request'
expect(page).to have_content('Source branch')
@@ -26,7 +24,7 @@ feature 'Create New Merge Request', feature: true, js: true do
end
it 'selects the target branch sha when a tag with the same name exists' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
click_link 'New merge request'
@@ -34,13 +32,13 @@ feature 'Create New Merge Request', feature: true, js: true do
expect(page).to have_content('Target branch')
first('.js-target-branch').click
- first('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0').click
+ find('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0', match: :first).click
expect(page).to have_content "b83d6e3"
end
it 'generates a diff for an orphaned branch' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
expect(page).to have_content('Source branch')
@@ -65,34 +63,34 @@ feature 'Create New Merge Request', feature: true, js: true do
context 'when target project cannot be viewed by the current user' do
it 'does not leak the private project name & namespace' do
- private_project = create(:project, :private)
+ private_project = create(:project, :private, :repository)
- visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_project_id: private_project.id })
+ visit project_new_merge_request_path(project, merge_request: { target_project_id: private_project.id })
- expect(page).not_to have_content private_project.path_with_namespace
- expect(page).to have_content project.path_with_namespace
+ expect(page).not_to have_content private_project.full_path
+ expect(page).to have_content project.full_path
end
end
context 'when source project cannot be viewed by the current user' do
it 'does not leak the private project name & namespace' do
- private_project = create(:project, :private)
+ private_project = create(:project, :private, :repository)
- visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { source_project_id: private_project.id })
+ visit project_new_merge_request_path(project, merge_request: { source_project_id: private_project.id })
- expect(page).not_to have_content private_project.path_with_namespace
- expect(page).to have_content project.path_with_namespace
+ expect(page).not_to have_content private_project.full_path
+ expect(page).to have_content project.full_path
end
end
it 'populates source branch button' do
- visit new_namespace_project_merge_request_path(project.namespace, project, change_branches: true, merge_request: { target_branch: 'master', source_branch: 'fix' })
+ visit project_new_merge_request_path(project, change_branches: true, merge_request: { target_branch: 'master', source_branch: 'fix' })
expect(find('.js-source-branch')).to have_content('fix')
end
it 'allows to change the diff view' do
- visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' })
+ visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'fix' })
click_link 'Changes'
@@ -108,7 +106,7 @@ feature 'Create New Merge Request', feature: true, js: true do
end
it 'does not allow non-existing branches' do
- visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'non-exist-target', source_branch: 'non-exist-source' })
+ visit project_new_merge_request_path(project, merge_request: { target_branch: 'non-exist-target', source_branch: 'non-exist-source' })
expect(page).to have_content('The form contains the following errors')
expect(page).to have_content('Source branch "non-exist-source" does not exist')
@@ -117,7 +115,7 @@ feature 'Create New Merge Request', feature: true, js: true do
context 'when a branch contains commits that both delete and add the same image' do
it 'renders the diff successfully' do
- visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' })
+ visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' })
click_link "Changes"
@@ -127,7 +125,7 @@ feature 'Create New Merge Request', feature: true, js: true do
# Isolates a regression (see #24627)
it 'does not show error messages on initial form' do
- visit new_namespace_project_merge_request_path(project.namespace, project)
+ visit project_new_merge_request_path(project)
expect(page).not_to have_selector('#error_explanation')
expect(page).not_to have_content('The form contains the following error')
end
@@ -140,13 +138,13 @@ feature 'Create New Merge Request', feature: true, js: true do
end
it 'shows pipelines for a new merge request' do
- visit new_namespace_project_merge_request_path(
- project.namespace, project,
+ visit project_new_merge_request_path(
+ project,
merge_request: { target_branch: 'master', source_branch: 'fix' })
page.within('.merge-request') do
click_link 'Pipelines'
- wait_for_vue_resource
+ wait_for_requests
expect(page).to have_content "##{pipeline.id}"
end
diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb
index 18833ba7266..09541873f71 100644
--- a/spec/features/merge_requests/created_from_fork_spec.rb
+++ b/spec/features/merge_requests/created_from_fork_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
feature 'Merge request created from fork' do
given(:user) { create(:user) }
- given(:project) { create(:project, :public) }
- given(:fork_project) { create(:project, :public) }
+ given(:project) { create(:project, :public, :repository) }
+ given(:fork_project) { create(:project, :public, :repository) }
given!(:merge_request) do
create(:forked_project_link, forked_to_project: fork_project,
@@ -16,7 +16,7 @@ feature 'Merge request created from fork' do
background do
fork_project.team << [user, :master]
- login_as user
+ sign_in user
end
scenario 'user can access merge request' do
@@ -25,13 +25,40 @@ feature 'Merge request created from fork' do
expect(page).to have_content 'Test merge request'
end
+ context 'when a commit comment exists on the merge request' do
+ given(:comment) { 'A commit comment' }
+ given(:reply) { 'A reply comment' }
+
+ background do
+ create(:note_on_commit, note: comment,
+ project: fork_project,
+ commit_id: merge_request.commit_shas.first)
+ end
+
+ scenario 'user can reply to the comment', js: true do
+ visit_merge_request(merge_request)
+
+ expect(page).to have_content(comment)
+
+ page.within('.discussion-notes') do
+ find('.btn-text-field').click
+ find('#note_note').send_keys(reply)
+ find('.comment-btn').click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content(reply)
+ end
+ end
+
context 'source project is deleted' do
background do
MergeRequests::MergeService.new(project, user).execute(merge_request)
fork_project.destroy!
end
- scenario 'user can access merge request' do
+ scenario 'user can access merge request', js: true do
visit_merge_request(merge_request)
expect(page).to have_content 'Test merge request'
@@ -56,7 +83,7 @@ feature 'Merge request created from fork' do
visit_merge_request(merge_request)
page.within('.merge-request-tabs') { click_link 'Pipelines' }
- page.within('table.ci-table') do
+ page.within('.ci-table') do
expect(page).to have_content pipeline.status
expect(page).to have_content pipeline.id
end
@@ -64,7 +91,6 @@ feature 'Merge request created from fork' do
end
def visit_merge_request(mr)
- visit namespace_project_merge_request_path(project.namespace,
- project, mr)
+ visit project_merge_request_path(project, mr)
end
end
diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb
index 648678e2b1a..874c6e2ff69 100644
--- a/spec/features/merge_requests/deleted_source_branch_spec.rb
+++ b/spec/features/merge_requests/deleted_source_branch_spec.rb
@@ -3,24 +3,20 @@ require 'spec_helper'
# This test serves as a regression test for a bug that caused an error
# message to be shown by JavaScript when the source branch was deleted.
# Please do not remove "js: true".
-describe 'Deleted source branch', feature: true, js: true do
+describe 'Deleted source branch', js: true do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
before do
- login_as user
+ sign_in user
merge_request.project.team << [user, :master]
merge_request.update!(source_branch: 'this-branch-does-not-exist')
- visit namespace_project_merge_request_path(
- merge_request.project.namespace,
- merge_request.project,
- merge_request
- )
+ visit project_merge_request_path(merge_request.project, merge_request)
end
it 'shows a message about missing source branch' do
expect(page).to have_content(
- 'Source branch this-branch-does-not-exist does not exist'
+ 'Source branch does not exist.'
)
end
@@ -32,9 +28,9 @@ describe 'Deleted source branch', feature: true, js: true do
end
click_on 'Changes'
- wait_for_ajax
+ wait_for_requests
expect(page).to have_selector('.diffs.tab-pane .nothing-here-block')
- expect(page).to have_content('Nothing to merge from this-branch-does-not-exist into feature')
+ expect(page).to have_content('Source branch does not exist.')
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 b2e170513c4..c4f02311f13 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -1,8 +1,10 @@
require 'spec_helper'
-feature 'Diff note avatars', feature: true, js: true do
+feature 'Diff note avatars', js: true do
+ include NoteInteractionHelpers
+
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") }
let(:path) { "files/ruby/popen.rb" }
let(:position) do
@@ -18,12 +20,12 @@ feature 'Diff note avatars', feature: true, js: true do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
end
context 'discussion tab' do
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not show avatars on discussion tab' do
@@ -48,7 +50,7 @@ feature 'Diff note avatars', feature: true, js: true do
context 'commit view' do
before do
- visit namespace_project_commit_path(project.namespace, project, merge_request.commits.first.id)
+ visit project_commit_path(project, merge_request.commits.first.id)
end
it 'does not render avatar after commenting' do
@@ -60,10 +62,10 @@ feature 'Diff note avatars', feature: true, js: true do
click_button 'Comment'
- wait_for_ajax
+ wait_for_requests
end
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
expect(page).to have_content('test comment')
expect(page).not_to have_selector('.js-avatar-container')
@@ -74,9 +76,9 @@ feature 'Diff note avatars', feature: true, js: true do
%w(inline parallel).each do |view|
context "#{view} view" do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view)
+ visit diffs_project_merge_request_path(project, merge_request, view: view)
- wait_for_ajax
+ wait_for_requests
end
it 'shows note avatar' do
@@ -91,7 +93,7 @@ feature 'Diff note avatars', feature: true, js: true do
page.within find("[id='#{position.line_code(project.repository)}']") do
find('.diff-notes-collapse').click
- expect(first('img.js-diff-comment-avatar')["title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
+ expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
end
end
@@ -110,11 +112,13 @@ feature 'Diff note avatars', feature: true, js: true do
end
it 'removes avatar when note is deleted' do
+ open_more_actions_dropdown(note)
+
page.within find(".note-row-#{note.id}") do
find('.js-note-delete').click
end
- wait_for_ajax
+ wait_for_requests
page.within find("[id='#{position.line_code(project.repository)}']") do
expect(page).not_to have_selector('img.js-diff-comment-avatar')
@@ -129,7 +133,7 @@ feature 'Diff note avatars', feature: true, js: true do
click_button 'Comment'
- wait_for_ajax
+ wait_for_requests
end
page.within find("[id='#{position.line_code(project.repository)}']") do
@@ -148,12 +152,12 @@ feature 'Diff note avatars', feature: true, js: true do
find('.js-comment-button').trigger 'click'
- wait_for_ajax
+ wait_for_requests
end
end
page.within find("[id='#{position.line_code(project.repository)}']") do
- find('.diff-notes-collapse').click
+ find('.diff-notes-collapse').trigger('click')
expect(page).to have_selector('img.js-diff-comment-avatar', count: 3)
expect(find('.diff-comments-more-count')).to have_content '+1'
@@ -164,9 +168,9 @@ feature 'Diff note avatars', feature: true, js: true do
before do
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)
+ visit diffs_project_merge_request_path(project, merge_request, view: view)
- wait_for_ajax
+ wait_for_requests
end
it 'shows extra comment count' do
diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb
index 0e23c3a8849..ac7f75bd308 100644
--- a/spec/features/merge_requests/diff_notes_resolve_spec.rb
+++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Diff notes resolve', feature: true, js: true do
+feature 'Diff notes resolve', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") }
let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
let(:path) { "files/ruby/popen.rb" }
@@ -19,7 +19,7 @@ feature 'Diff notes resolve', feature: true, js: true do
context 'no discussions' do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
note.destroy
visit_merge_request
end
@@ -33,7 +33,7 @@ feature 'Diff notes resolve', feature: true, js: true do
context 'as authorized user' do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
visit_merge_request
end
@@ -275,7 +275,7 @@ feature 'Diff notes resolve', feature: true, js: true do
end
page.within '.line-resolve-all-container' do
- page.find('.discussion-next-btn').click
+ page.find('.discussion-next-btn').trigger('click')
end
expect(page.evaluate_script("$('body').scrollTop()")).to be > 0
@@ -402,7 +402,7 @@ feature 'Diff notes resolve', feature: true, js: true do
before do
project.team << [guest, :guest]
- login_as guest
+ sign_in guest
end
context 'someone elses merge request' do
@@ -494,6 +494,6 @@ feature 'Diff notes resolve', feature: true, js: true do
def visit_merge_request(mr = nil)
mr = mr || merge_request
- visit namespace_project_merge_request_path(mr.project.namespace, mr.project, mr)
+ visit project_merge_request_path(mr.project, mr)
end
end
diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb
index 7dee3b852ca..a8f5dc275e4 100644
--- a/spec/features/merge_requests/diffs_spec.rb
+++ b/spec/features/merge_requests/diffs_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
-feature 'Diffs URL', js: true, feature: true do
- let(:project) { create(:project, :public) }
+feature 'Diffs URL', js: true do
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
context 'when visit with */* as accept header' do
- before(:each) do
+ before do
page.driver.add_header('Accept', '*/*')
end
it 'renders the notes' do
create :note_on_merge_request, project: project, noteable: merge_request, note: 'Rebasing with master'
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit diffs_project_merge_request_path(project, merge_request)
# Load notes and diff through AJAX
expect(page).to have_css('.note-text', visible: false, text: 'Rebasing with master')
@@ -20,11 +20,39 @@ feature 'Diffs URL', js: true, feature: true do
end
end
+ context 'when linking to note' do
+ describe 'with unresolved note' do
+ let(:note) { create :diff_note_on_merge_request, project: project, noteable: merge_request }
+ let(:fragment) { "#note_#{note.id}" }
+
+ before do
+ visit "#{diffs_project_merge_request_path(project, merge_request)}#{fragment}"
+ end
+
+ it 'shows expanded note' do
+ expect(page).to have_selector(fragment, visible: true)
+ end
+ end
+
+ describe 'with resolved note' do
+ let(:note) { create :diff_note_on_merge_request, :resolved, project: project, noteable: merge_request }
+ let(:fragment) { "#note_#{note.id}" }
+
+ before do
+ visit "#{diffs_project_merge_request_path(project, merge_request)}#{fragment}"
+ end
+
+ it 'shows expanded note' do
+ expect(page).to have_selector(fragment, visible: true)
+ end
+ end
+ end
+
context 'when merge request has overflow' do
it 'displays warning' do
allow(Commit).to receive(:max_diff_options).and_return(max_files: 3)
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit diffs_project_merge_request_path(project, merge_request)
page.within('.alert') do
expect(page).to have_text("Too many changes to show. Plain diff Email patch To preserve
@@ -40,10 +68,14 @@ feature 'Diffs URL', js: true, feature: true do
let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) }
let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") }
+ before do
+ forked_project.repository.after_import
+ end
+
context 'as author' do
it 'shows direct edit link' do
- login_as(author_user)
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(author_user)
+ visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
expect(page).to have_selector("[id=\"#{changelog_id}\"] a.js-edit-blob")
@@ -52,8 +84,8 @@ feature 'Diffs URL', js: true, feature: true do
context 'as user who needs to fork' do
it 'shows fork/cancel confirmation' do
- login_as(user)
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(user)
+ visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
find("[id=\"#{changelog_id}\"] .js-edit-blob").click
diff --git a/spec/features/merge_requests/discussion_spec.rb b/spec/features/merge_requests/discussion_spec.rb
index f59d0faa274..05789bbd31d 100644
--- a/spec/features/merge_requests/discussion_spec.rb
+++ b/spec/features/merge_requests/discussion_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Merge Request Discussions', feature: true do
+feature 'Merge Request Discussions' do
before do
- login_as :admin
+ sign_in(create(:admin))
end
- context "Diff discussions" do
+ describe "Diff discussions" do
let(:merge_request) { create(:merge_request, importing: true) }
let(:project) { merge_request.source_project }
let!(:old_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: outdated_diff_refs) }
@@ -26,14 +26,14 @@ feature 'Merge Request Discussions', feature: true do
let(:outdated_diff_refs) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs }
- before(:each) do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ before do
+ visit project_merge_request_path(project, merge_request)
end
context 'active discussions' do
it 'shows a link to the diff' do
within(".discussion[data-discussion-id='#{active_discussion.id}']") do
- path = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: active_discussion.line_code)
+ path = diffs_project_merge_request_path(project, merge_request, anchor: active_discussion.line_code)
expect(page).to have_link('the diff', href: path)
end
end
@@ -42,10 +42,49 @@ feature 'Merge Request Discussions', feature: true do
context 'outdated discussions' do
it 'shows a link to the outdated diff' do
within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do
- path = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: old_merge_request_diff.id, anchor: outdated_discussion.line_code)
- expect(page).to have_link('an outdated diff', href: path)
+ path = diffs_project_merge_request_path(project, merge_request, diff_id: old_merge_request_diff.id, anchor: outdated_discussion.line_code)
+ expect(page).to have_link('an old version of the diff', href: path)
end
end
end
end
+
+ describe 'Commit comments displayed in MR context', :js do
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.project }
+
+ shared_examples 'a functional discussion' do
+ let(:discussion_id) { note.discussion_id(merge_request) }
+
+ it 'is displayed' do
+ expect(page).to have_css(".discussion[data-discussion-id='#{discussion_id}']")
+ end
+
+ it 'can be replied to' do
+ within(".discussion[data-discussion-id='#{discussion_id}']") do
+ click_button 'Reply...'
+ fill_in 'note[note]', with: 'Test!'
+ click_button 'Comment'
+
+ expect(page).to have_css('.note', count: 2)
+ end
+ end
+ end
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ context 'a regular commit comment' do
+ let(:note) { create(:note_on_commit, project: project) }
+
+ it_behaves_like 'a functional discussion'
+ end
+
+ context 'a commit diff comment' do
+ let(:note) { create(:diff_note_on_commit, project: project) }
+
+ it_behaves_like 'a functional discussion'
+ end
+ end
end
diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb
index cb3bc392903..7386e78fb13 100644
--- a/spec/features/merge_requests/edit_mr_spec.rb
+++ b/spec/features/merge_requests/edit_mr_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-feature 'Edit Merge Request', feature: true do
+feature 'Edit Merge Request' do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
- visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit edit_project_merge_request_path(project, merge_request)
end
context 'editing a MR' do
@@ -29,15 +29,16 @@ feature 'Edit Merge Request', feature: true do
expect(page).to have_content 'Someone edited the merge request the same time you did'
end
- it 'allows to unselect "Remove source branch"' do
+ it 'allows to unselect "Remove source branch"', js: true do
merge_request.update(merge_params: { 'force_remove_source_branch' => '1' })
expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy
- visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit edit_project_merge_request_path(project, merge_request)
uncheck 'Remove source branch when merge request is accepted'
click_button 'Save changes'
+ expect(page).to have_unchecked_field 'remove-source-branch-input'
expect(page).to have_content 'Remove source branch'
end
diff --git a/spec/features/merge_requests/filter_by_labels_spec.rb b/spec/features/merge_requests/filter_by_labels_spec.rb
index 32a9082b9b9..9912e8165e6 100644
--- a/spec/features/merge_requests/filter_by_labels_spec.rb
+++ b/spec/features/merge_requests/filter_by_labels_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-feature 'Issue filtering by Labels', feature: true, js: true do
+feature 'Merge Request filtering by Labels', :js do
include FilteredSearchHelpers
include MergeRequestHelpers
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let!(:user) { create(:user) }
let!(:label) { create(:label, project: project) }
@@ -12,9 +12,9 @@ feature 'Issue filtering by Labels', feature: true, js: true do
let!(:feature) { create(:label, project: project, title: 'feature') }
let!(:enhancement) { create(:label, project: project, title: 'enhancement') }
- let!(:mr1) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "bugfix1") }
- let!(:mr2) { create(:merge_request, title: "Bugfix2", source_project: project, target_project: project, source_branch: "bugfix2") }
- let!(:mr3) { create(:merge_request, title: "Feature1", source_project: project, target_project: project, source_branch: "feature1") }
+ let!(:mr1) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "fix") }
+ let!(:mr2) { create(:merge_request, title: "Bugfix2", source_project: project, target_project: project, source_branch: "wip") }
+ let!(:mr3) { create(:merge_request, title: "Feature1", source_project: project, target_project: project, source_branch: "improve/awesome") }
before do
mr1.labels << bug
@@ -25,10 +25,10 @@ feature 'Issue filtering by Labels', feature: true, js: true do
mr3.title = "Feature1"
mr3.labels << feature
- project.team << [user, :master]
- login_as(user)
+ project.add_master(user)
+ sign_in(user)
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
context 'filter by label bug' do
diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb
index 265a0cfc198..166c02a7a7f 100644
--- a/spec/features/merge_requests/filter_by_milestone_spec.rb
+++ b/spec/features/merge_requests/filter_by_milestone_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-feature 'Merge Request filtering by Milestone', feature: true do
+feature 'Merge Request filtering by Milestone' do
include FilteredSearchHelpers
include MergeRequestHelpers
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let!(:user) { create(:user)}
let(:milestone) { create(:milestone, project: project) }
@@ -15,7 +15,7 @@ feature 'Merge Request filtering by Milestone', feature: true do
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
scenario 'filters by no Milestone', js: true do
@@ -25,7 +25,7 @@ feature 'Merge Request filtering by Milestone', feature: true do
visit_merge_requests(project)
input_filtered_search('milestone:none')
- expect_tokens([{ name: 'milestone', value: 'none' }])
+ expect_tokens([milestone_token('none', false)])
expect_filtered_search_input_empty
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
diff --git a/spec/features/merge_requests/filter_merge_requests_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb
index 2da60e9f4ad..b51ae0890e4 100644
--- a/spec/features/merge_requests/filter_merge_requests_spec.rb
+++ b/spec/features/merge_requests/filter_merge_requests_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-describe 'Filter merge requests', feature: true do
+describe 'Filter merge requests' do
include FilteredSearchHelpers
include MergeRequestHelpers
- let!(:project) { create(:project) }
+ let!(:project) { create(:project, :repository) }
let!(:group) { create(:group) }
let!(:user) { create(:user) }
let!(:milestone) { create(:milestone, project: project) }
@@ -12,19 +12,21 @@ describe 'Filter merge requests', feature: true do
let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
before do
- project.team << [user, :master]
+ project.add_master(user)
group.add_developer(user)
- login_as(user)
+ sign_in(user)
create(:merge_request, source_project: project, target_project: project)
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
describe 'for assignee from mr#index' do
let(:search_query) { "assignee:@#{user.username}" }
def expect_assignee_visual_tokens
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input_empty
end
@@ -40,13 +42,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_assignee_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_assignee_visual_tokens()
end
@@ -57,7 +59,7 @@ describe 'Filter merge requests', feature: true do
let(:search_query) { "milestone:%\"#{milestone.title}\"" }
def expect_milestone_visual_tokens
- expect_tokens([{ name: 'milestone', value: "%\"#{milestone.title}\"" }])
+ expect_tokens([milestone_token("\"#{milestone.title}\"")])
expect_filtered_search_input_empty
end
@@ -73,13 +75,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_milestone_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_milestone_visual_tokens()
end
@@ -91,7 +93,7 @@ describe 'Filter merge requests', feature: true do
input_filtered_search('label:none')
expect_mr_list_count(1)
- expect_tokens([{ name: 'label', value: 'none' }])
+ expect_tokens([label_token('none', false)])
expect_filtered_search_input_empty
end
@@ -99,7 +101,7 @@ describe 'Filter merge requests', feature: true do
input_filtered_search("label:~#{label.title}")
expect_mr_list_count(0)
- expect_tokens([{ name: 'label', value: "~#{label.title}" }])
+ expect_tokens([label_token(label.title)])
expect_filtered_search_input_empty
end
@@ -107,10 +109,7 @@ describe 'Filter merge requests', feature: true do
input_filtered_search("label:~\"#{wontfix.title}\" label:~#{label.title}")
expect_mr_list_count(0)
- expect_tokens([
- { name: 'label', value: "~\"#{wontfix.title}\"" },
- { name: 'label', value: "~#{label.title}" }
- ])
+ expect_tokens([label_token("\"#{wontfix.title}\""), label_token(label.title)])
expect_filtered_search_input_empty
end
@@ -118,43 +117,31 @@ describe 'Filter merge requests', feature: true do
input_filtered_search("label:~\"#{wontfix.title}\"")
expect_mr_list_count(0)
- expect_tokens([{ name: 'label', value: "~\"#{wontfix.title}\"" }])
+ expect_tokens([label_token("\"#{wontfix.title}\"")])
expect_filtered_search_input_empty
input_filtered_search_keys("label:~#{label.title}")
expect_mr_list_count(0)
- expect_tokens([
- { name: 'label', value: "~\"#{wontfix.title}\"" },
- { name: 'label', value: "~#{label.title}" }
- ])
+ expect_tokens([label_token("\"#{wontfix.title}\""), label_token(label.title)])
expect_filtered_search_input_empty
end
end
- describe 'for assignee and label from issues#index' do
+ describe 'for assignee and label from mr#index' do
let(:search_query) { "assignee:@#{user.username} label:~#{label.title}" }
before do
- input_filtered_search("assignee:@#{user.username}")
-
- expect_mr_list_count(1)
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
- expect_filtered_search_input_empty
-
- input_filtered_search_keys("label:~#{label.title} ")
-
- expect_mr_list_count(1)
+ input_filtered_search(search_query)
- find("#state-opened[href=\"#{URI.parse(current_url).path}?assignee_username=#{user.username}&label_name%5B%5D=#{label.title}&scope=all&state=opened\"]")
+ expect_mr_list_count(0)
end
context 'assignee and label', js: true do
def expect_assignee_label_visual_tokens
- expect_tokens([
- { name: 'assignee', value: "@#{user.username}" },
- { name: 'label', value: "~#{label.title}" }
- ])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name), label_token(label.title)])
expect_filtered_search_input_empty
end
@@ -163,13 +150,13 @@ describe 'Filter merge requests', feature: true do
end
it 'does not change when closed link is clicked' do
- find('.issues-state-filters a', text: "Closed").click
+ find('.issues-state-filters [data-state="closed"]').click
expect_assignee_label_visual_tokens()
end
it 'does not change when all link is clicked' do
- find('.issues-state-filters a', text: "All").click
+ find('.issues-state-filters [data-state="all"]').click
expect_assignee_label_visual_tokens()
end
@@ -178,7 +165,7 @@ describe 'Filter merge requests', feature: true do
describe 'filter merge requests by text' do
before do
- create(:merge_request, title: "Bug", source_project: project, target_project: project, source_branch: "bug")
+ create(:merge_request, title: "Bug", source_project: project, target_project: project, source_branch: "wip")
bug_label = create(:label, project: project, title: 'bug')
milestone = create(:milestone, title: "8", project: project)
@@ -187,13 +174,13 @@ describe 'Filter merge requests', feature: true do
title: "Bug 2",
source_project: project,
target_project: project,
- source_branch: "bug2",
+ source_branch: "fix",
milestone: milestone,
author: user,
assignee: user)
mr.labels << bug_label
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
context 'only text', js: true do
@@ -222,7 +209,7 @@ describe 'Filter merge requests', feature: true do
input_filtered_search_keys(' label:~bug')
expect_mr_list_count(1)
- expect_tokens([{ name: 'label', value: '~bug' }])
+ expect_tokens([label_token('bug')])
expect_filtered_search_input('Bug')
end
@@ -235,7 +222,7 @@ describe 'Filter merge requests', feature: true do
input_filtered_search_keys(' milestone:%8')
expect_mr_list_count(1)
- expect_tokens([{ name: 'milestone', value: '%8' }])
+ expect_tokens([milestone_token('8')])
expect_filtered_search_input('Bug')
end
@@ -248,7 +235,10 @@ describe 'Filter merge requests', feature: true do
input_filtered_search_keys(" assignee:@#{user.username}")
expect_mr_list_count(1)
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input('Bug')
end
@@ -260,24 +250,26 @@ describe 'Filter merge requests', feature: true do
input_filtered_search_keys(" author:@#{user.username}")
+ wait_for_requests
+
expect_mr_list_count(1)
- expect_tokens([{ name: 'author', value: "@#{user.username}" }])
+ expect_tokens([author_token(user.name)])
expect_filtered_search_input('Bug')
end
end
end
- describe 'filter merge requests and sort', js: true do
+ describe 'filter merge requests and sort', :js do
before do
bug_label = create(:label, project: project, title: 'bug')
- mr1 = create(:merge_request, title: "Frontend", source_project: project, target_project: project, source_branch: "Frontend")
- mr2 = create(:merge_request, title: "Bug 2", source_project: project, target_project: project, source_branch: "bug2")
+ mr1 = create(:merge_request, title: "Frontend", source_project: project, target_project: project, source_branch: "wip")
+ mr2 = create(:merge_request, title: "Bug 2", source_project: project, target_project: project, source_branch: "fix")
mr1.labels << bug_label
mr2.labels << bug_label
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it 'is able to filter and sort merge requests' do
@@ -289,7 +281,7 @@ describe 'Filter merge requests', feature: true do
page.within '.dropdown-menu-sort' do
click_link 'Oldest created'
end
- wait_for_ajax
+ wait_for_requests
page.within '.mr-list' do
expect(page).to have_content('Frontend')
@@ -299,9 +291,11 @@ describe 'Filter merge requests', feature: true do
describe 'filter by assignee id', js: true do
it 'filter by current user' do
- visit namespace_project_merge_requests_path(project.namespace, project, assignee_id: user.id)
+ visit project_merge_requests_path(project, assignee_id: user.id)
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+ wait_for_requests
+
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input_empty
end
@@ -309,18 +303,22 @@ describe 'Filter merge requests', feature: true do
new_user = create(:user)
project.add_developer(new_user)
- visit namespace_project_merge_requests_path(project.namespace, project, assignee_id: new_user.id)
+ visit project_merge_requests_path(project, assignee_id: new_user.id)
+
+ wait_for_requests
- expect_tokens([{ name: 'assignee', value: "@#{new_user.username}" }])
+ expect_tokens([assignee_token(new_user.name)])
expect_filtered_search_input_empty
end
end
describe 'filter by author id', js: true do
it 'filter by current user' do
- visit namespace_project_merge_requests_path(project.namespace, project, author_id: user.id)
+ visit project_merge_requests_path(project, author_id: user.id)
- expect_tokens([{ name: 'author', value: "@#{user.username}" }])
+ wait_for_requests
+
+ expect_tokens([author_token(user.name)])
expect_filtered_search_input_empty
end
@@ -328,9 +326,11 @@ describe 'Filter merge requests', feature: true do
new_user = create(:user)
project.add_developer(new_user)
- visit namespace_project_merge_requests_path(project.namespace, project, author_id: new_user.id)
+ visit project_merge_requests_path(project, author_id: new_user.id)
+
+ wait_for_requests
- expect_tokens([{ name: 'author', value: "@#{new_user.username}" }])
+ expect_tokens([author_token(new_user.name)])
expect_filtered_search_input_empty
end
end
diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb
index f8518f450dc..89410b0e90f 100644
--- a/spec/features/merge_requests/form_spec.rb
+++ b/spec/features/merge_requests/form_spec.rb
@@ -1,15 +1,13 @@
require 'rails_helper'
-describe 'New/edit merge request', feature: true, js: true do
- include GitlabRoutingHelper
-
- let!(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:fork_project) { create(:project, forked_from_project: project) }
- let!(:user) { create(:user)}
- let!(:user2) { create(:user)}
- let!(:milestone) { create(:milestone, project: project) }
- let!(:label) { create(:label, project: project) }
- let!(:label2) { create(:label, project: project) }
+describe 'New/edit merge request', :js do
+ let!(:project) { create(:project, :public, :repository) }
+ let(:fork_project) { create(:project, :repository, forked_from_project: project) }
+ let!(:user) { create(:user) }
+ let!(:user2) { create(:user) }
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:label) { create(:label, project: project) }
+ let!(:label2) { create(:label, project: project) }
before do
project.team << [user, :master]
@@ -18,13 +16,12 @@ describe 'New/edit merge request', feature: true, js: true do
context 'owned projects' do
before do
- login_as(user)
+ sign_in(user)
end
context 'new merge request' do
before do
- visit new_namespace_project_merge_request_path(
- project.namespace,
+ visit project_new_merge_request_path(
project,
merge_request: {
source_project_id: project.id,
@@ -44,7 +41,7 @@ describe 'New/edit merge request', feature: true, js: true do
expect(page).to have_content user2.name
end
- click_link 'Assign to me'
+ find('a', text: 'Assign to me').trigger('click')
expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s)
page.within '.js-assignee-search' do
expect(page).to have_content user.name
@@ -90,12 +87,19 @@ describe 'New/edit merge request', feature: true, js: true do
page.within '.issuable-meta' do
merge_request = MergeRequest.find_by(source_branch: 'fix')
- expect(page).to have_text("Merge Request #{merge_request.to_reference}")
+ expect(page).to have_text("Merge request #{merge_request.to_reference}")
# compare paths because the host differ in test
expect(find_link(merge_request.to_reference)[:href])
.to end_with(merge_request_path(merge_request))
end
end
+
+ it 'description has autocomplete' do
+ find('#merge_request_description').native.send_keys('')
+ fill_in 'merge_request_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
end
context 'edit merge request' do
@@ -107,7 +111,7 @@ describe 'New/edit merge request', feature: true, js: true do
target_branch: 'master'
)
- visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit edit_project_merge_request_path(project, merge_request)
end
it 'updates merge request' do
@@ -157,19 +161,25 @@ describe 'New/edit merge request', feature: true, js: true do
end
end
end
+
+ it 'description has autocomplete' do
+ find('#merge_request_description').native.send_keys('')
+ fill_in 'merge_request_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
end
end
context 'forked project' do
before do
fork_project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'new merge request' do
before do
- visit new_namespace_project_merge_request_path(
- fork_project.namespace,
+ visit project_new_merge_request_path(
fork_project,
merge_request: {
source_project_id: fork_project.id,
@@ -237,7 +247,7 @@ describe 'New/edit merge request', feature: true, js: true do
target_branch: 'master'
)
- visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit edit_project_merge_request_path(project, merge_request)
end
it 'should update merge request' do
diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb
index 1bc2a5548dd..429bc277d73 100644
--- a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb
+++ b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-feature 'Clicking toggle commit message link', feature: true, js: true do
+feature 'Clicking toggle commit message link', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:issue_1) { create(:issue, project: project)}
let(:issue_2) { create(:issue, project: project)}
let(:merge_request) do
@@ -14,8 +14,6 @@ feature 'Clicking toggle commit message link', feature: true, js: true do
)
end
let(:textbox) { page.find(:css, '.js-commit-message', visible: false) }
- let(:include_link) { page.find(:css, '.js-with-description-link', visible: false) }
- let(:do_not_include_link) { page.find(:css, '.js-without-description-link', visible: false) }
let(:default_message) do
[
"Merge branch 'feature' into 'master'",
@@ -36,11 +34,11 @@ feature 'Clicking toggle commit message link', feature: true, js: true do
before do
project.team << [user, :master]
- login_as user
+ sign_in user
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
- expect(textbox).not_to be_visible
+ expect(page).not_to have_selector('.js-commit-message')
click_button "Modify commit message"
expect(textbox).to be_visible
end
@@ -56,19 +54,4 @@ feature 'Clicking toggle commit message link', feature: true, js: true do
expect(textbox.value).to eq(default_message)
end
-
- it "toggles link between 'Include description' and 'Don't include description'" do
- expect(include_link).to be_visible
- expect(do_not_include_link).not_to be_visible
-
- click_link "Include description in commit message"
-
- expect(include_link).not_to be_visible
- expect(do_not_include_link).to be_visible
-
- click_link "Don't include description in commit message"
-
- expect(include_link).to be_visible
- expect(do_not_include_link).not_to be_visible
- end
end
diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb
index 497240803d4..0b5a595acce 100644
--- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb
+++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb
@@ -1,22 +1,26 @@
require 'spec_helper'
-feature 'Merge immediately', :feature, :js do
+feature 'Merge immediately', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
- let(:merge_request) do
+ let!(:merge_request) do
create(:merge_request_with_diffs, source_project: project,
author: user,
- title: 'Bug NS-04')
+ title: 'Bug NS-04',
+ head_pipeline: pipeline,
+ source_branch: pipeline.ref)
end
let(:pipeline) do
create(:ci_pipeline, project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch)
+ ref: 'master',
+ sha: project.repository.commit('master').id)
end
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
context 'when there is active pipeline for merge request' do
background do
@@ -24,19 +28,21 @@ feature 'Merge immediately', :feature, :js do
end
before do
- login_as user
- visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
+ sign_in user
+ visit project_merge_request_path(merge_request.project, merge_request)
end
it 'enables merge immediately' do
page.within '.mr-widget-body' do
find('.dropdown-toggle').click
- click_link 'Merge immediately'
+ Sidekiq::Testing.fake! do
+ click_link 'Merge immediately'
- expect(find('.js-merge-when-pipeline-succeeds-button')).to have_content('Merge in progress')
+ expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress')
- wait_for_ajax
+ wait_for_requests
+ end
end
end
end
diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb
index cd540ca113a..ac46cc1f0e4 100644
--- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb
@@ -1,22 +1,26 @@
require 'spec_helper'
-feature 'Merge When Pipeline Succeeds', :feature, :js do
+feature 'Merge When Pipeline Succeeds', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) do
create(:merge_request_with_diffs, source_project: project,
author: user,
- title: 'Bug NS-04')
+ title: 'Bug NS-04',
+ merge_params: { force_remove_source_branch: '1' })
end
let(:pipeline) do
create(:ci_pipeline, project: project,
sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch)
+ ref: merge_request.source_branch,
+ head_pipeline_of: merge_request)
end
- before { project.team << [user, :master] }
+ before do
+ project.add_master(user)
+ end
context 'when there is active pipeline for merge request' do
background do
@@ -24,7 +28,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
end
before do
- login_as user
+ sign_in user
visit_merge_request(merge_request)
end
@@ -37,9 +41,9 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
it 'activates the Merge when pipeline succeeds feature' do
click_button "Merge when pipeline succeeds"
- expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds."
- expect(page).to have_content "The source branch will not be removed."
- expect(page).to have_link "Cancel automatic merge"
+ expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
+ expect(page).to have_content "The source branch will not be removed"
+ expect(page).to have_selector ".js-cancel-auto-merge"
visit_merge_request(merge_request) # Needed to refresh the page
expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i
end
@@ -79,7 +83,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
source_project: project,
title: 'Bug NS-04',
author: user,
- merge_user: user)
+ merge_user: user,
+ merge_params: { force_remove_source_branch: '1' })
end
before do
@@ -92,13 +97,11 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
describe 'enabling Merge when pipeline succeeds via dropdown' do
it 'activates the Merge when pipeline succeeds feature' do
- click_button 'Select merge moment'
- within('.js-merge-dropdown') do
- click_link 'Merge when pipeline succeeds'
- end
+ find('.js-merge-moment').click
+ click_link 'Merge when pipeline succeeds'
- expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds."
- expect(page).to have_content "The source branch will not be removed."
+ expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
+ expect(page).to have_content "The source branch will not be removed"
expect(page).to have_link "Cancel automatic merge"
end
end
@@ -118,7 +121,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
end
before do
- login_as user
+ sign_in user
visit_merge_request(merge_request)
end
@@ -131,13 +134,6 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
expect(page).to have_content "canceled the automatic merge"
end
- it "allows the user to remove the source branch" do
- expect(page).to have_link "Remove source branch when merged"
-
- click_link "Remove source branch when merged"
- expect(page).to have_content "The source branch will be removed"
- end
-
context 'when pipeline succeeds' do
background { build.success }
@@ -159,6 +155,6 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do
end
def visit_merge_request(merge_request)
- visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
+ visit project_merge_request_path(merge_request.project, merge_request)
end
end
diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
index 449a60c1d05..b1215f9ba63 100644
--- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-feature 'Mini Pipeline Graph', :js, :feature do
+feature 'Mini Pipeline Graph', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project, head_pipeline: pipeline) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) }
let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') }
@@ -11,14 +11,40 @@ feature 'Mini Pipeline Graph', :js, :feature do
before do
build.run
- login_as(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(user)
+ visit_merge_request
+ end
+
+ def visit_merge_request(format = :html)
+ visit project_merge_request_path(project, merge_request, format: format)
end
it 'should display a mini pipeline graph' do
expect(page).to have_selector('.mr-widget-pipeline-graph')
end
+ context 'as json' do
+ let(:artifacts_file1) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') }
+ let(:artifacts_file2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/png') }
+
+ before do
+ create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file1)
+ create(:ci_build, pipeline: pipeline, when: 'manual')
+ end
+
+ it 'avoids repeated database queries' do
+ before = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) }
+
+ create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file2)
+ create(:ci_build, pipeline: pipeline, when: 'manual')
+
+ after = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) }
+
+ expect(before.count).to eq(after.count)
+ expect(before.cached_count).to eq(after.cached_count)
+ end
+ end
+
describe 'build list toggle' do
let(:toggle) do
find('.mini-pipeline-graph-dropdown-toggle')
@@ -56,7 +82,7 @@ feature 'Mini Pipeline Graph', :js, :feature do
before do
toggle.click
- wait_for_ajax
+ wait_for_requests
end
it 'should open when toggle is clicked' do
@@ -85,7 +111,7 @@ feature 'Mini Pipeline Graph', :js, :feature do
build_item.click
find('.build-page')
- expect(current_path).to eql(namespace_project_build_path(project.namespace, project, build))
+ expect(current_path).to eql(project_job_path(project, build))
end
it 'should show tooltip when hovered' do
diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
index 4a590e3bf68..59e67420333 100644
--- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
+++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
@@ -1,30 +1,32 @@
require 'spec_helper'
-feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true do
+feature 'Only allow merge requests to be merged if the pipeline succeeds', js: true do
let(:merge_request) { create(:merge_request_with_diffs) }
let(:project) { merge_request.target_project }
before do
- login_as merge_request.author
+ sign_in merge_request.author
project.team << [merge_request.author, :master]
end
- context 'project does not have CI enabled' do
+ context 'project does not have CI enabled', js: true do
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
end
end
- context 'when project has CI enabled' do
+ context 'when project has CI enabled', js: true do
given!(:pipeline) do
create(:ci_empty_pipeline,
project: project,
sha: merge_request.diff_head_sha,
ref: merge_request.source_branch,
- status: status)
+ status: status, head_pipeline_of: merge_request)
end
context 'when merge requests can only be merged if the pipeline succeeds' do
@@ -38,8 +40,10 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'does not allow to merge immediately' do
visit_merge_request(merge_request)
+ wait_for_requests
+
expect(page).to have_button 'Merge when pipeline succeeds'
- expect(page).not_to have_button 'Select merge moment'
+ expect(page).not_to have_button '.js-merge-moment'
end
end
@@ -49,8 +53,10 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'does not allow MR to be merged' do
visit_merge_request(merge_request)
- expect(page).not_to have_button 'Accept merge request'
- expect(page).to have_content('Please retry the job or push a new commit to fix the failure.')
+ wait_for_requests
+
+ expect(page).to have_css('button[disabled="disabled"]', text: 'Merge')
+ expect(page).to have_content('Please retry the job or push a new commit to fix the failure')
end
end
@@ -60,8 +66,10 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'does not allow MR to be merged' do
visit_merge_request(merge_request)
- expect(page).not_to have_button 'Accept merge request'
- expect(page).to have_content('Please retry the job or push a new commit to fix the failure.')
+ wait_for_requests
+
+ expect(page).not_to have_button 'Merge'
+ expect(page).to have_content('Please retry the job or push a new commit to fix the failure')
end
end
@@ -71,7 +79,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
end
end
@@ -81,7 +91,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
end
end
end
@@ -94,12 +106,14 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
context 'when CI is running' do
given(:status) { :running }
- it 'allows MR to be merged immediately', js: true do
+ it 'allows MR to be merged immediately' do
visit_merge_request(merge_request)
+ wait_for_requests
+
expect(page).to have_button 'Merge when pipeline succeeds'
- click_button 'Select merge moment'
+ page.find('.js-merge-moment').click
expect(page).to have_content 'Merge immediately'
end
end
@@ -110,7 +124,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
end
end
@@ -120,13 +136,15 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu
it 'allows MR to be merged' do
visit_merge_request(merge_request)
- expect(page).to have_button 'Accept merge request'
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
end
end
end
end
def visit_merge_request(merge_request)
- visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
+ visit project_merge_request_path(merge_request.project, merge_request)
end
end
diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb
index 99e283ac181..347ce788b36 100644
--- a/spec/features/merge_requests/pipelines_spec.rb
+++ b/spec/features/merge_requests/pipelines_spec.rb
@@ -1,45 +1,88 @@
require 'spec_helper'
-feature 'Pipelines for Merge Requests', feature: true, js: true do
- given(:user) { create(:user) }
- given(:merge_request) { create(:merge_request) }
- given(:project) { merge_request.target_project }
-
- before do
- project.team << [user, :master]
- login_as user
- end
+feature 'Pipelines for Merge Requests', js: true do
+ describe 'pipeline tab' do
+ given(:user) { create(:user) }
+ given(:merge_request) { create(:merge_request) }
+ given(:project) { merge_request.target_project }
- context 'with pipelines' do
- let!(:pipeline) do
- create(:ci_empty_pipeline,
- project: merge_request.source_project,
- ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha)
+ before do
+ project.team << [user, :master]
+ sign_in user
end
- before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ context 'with pipelines' do
+ let!(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ scenario 'user visits merge request pipelines tab' do
+ page.within('.merge-request-tabs') do
+ click_link('Pipelines')
+ end
+ wait_for_requests
+
+ expect(page).to have_selector('.stage-cell')
+ end
end
- scenario 'user visits merge request pipelines tab' do
- page.within('.merge-request-tabs') do
- click_link('Pipelines')
+ context 'without pipelines' do
+ before do
+ visit project_merge_request_path(project, merge_request)
end
- wait_for_ajax
- expect(page).to have_selector('.pipeline-actions')
+ scenario 'user visits merge request page' do
+ page.within('.merge-request-tabs') do
+ expect(page).to have_no_link('Pipelines')
+ end
+ end
end
end
- context 'without pipelines' do
- before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ describe 'race condition' do
+ given(:project) { create(:project, :repository) }
+ given(:user) { create(:user) }
+ given(:build_push_data) { { ref: 'feature', checkout_sha: TestEnv::BRANCH_SHA['feature'] } }
+
+ given(:merge_request_params) do
+ { "source_branch" => "feature", "source_project_id" => project.id,
+ "target_branch" => "master", "target_project_id" => project.id, "title" => "A" }
+ end
+
+ background do
+ project.add_master(user)
+ sign_in user
end
- scenario 'user visits merge request page' do
- page.within('.merge-request-tabs') do
- expect(page).to have_no_link('Pipelines')
+ context 'when pipeline and merge request were created simultaneously' do
+ background do
+ stub_ci_pipeline_to_return_yaml_file
+
+ threads = []
+
+ threads << Thread.new do
+ @merge_request = MergeRequests::CreateService.new(project, user, merge_request_params).execute
+ end
+
+ threads << Thread.new do
+ @pipeline = Ci::CreatePipelineService.new(project, user, build_push_data).execute(:push)
+ end
+
+ threads.each { |thr| thr.join }
+ end
+
+ scenario 'user sees pipeline in merge request widget' do
+ visit project_merge_request_path(project, @merge_request)
+
+ expect(page.find(".ci-widget")).to have_content(TestEnv::BRANCH_SHA['feature'])
+ expect(page.find(".ci-widget")).to have_content("##{@pipeline.id}")
end
end
end
diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb
index 275f81f50dc..eed95816bdf 100644
--- a/spec/features/merge_requests/reset_filters_spec.rb
+++ b/spec/features/merge_requests/reset_filters_spec.rb
@@ -1,16 +1,16 @@
require 'rails_helper'
-feature 'Merge requests filter clear button', feature: true, js: true do
+feature 'Merge requests filter clear button', :js do
include FilteredSearchHelpers
include MergeRequestHelpers
include IssueHelpers
- let!(:project) { create(:project, :public) }
+ let!(:project) { create(:project, :public, :repository) }
let!(:user) { create(:user) }
let!(:milestone) { create(:milestone, project: project) }
let!(:bug) { create(:label, project: project, name: 'bug')}
- let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "Feature", milestone: milestone, author: user, assignee: user) }
- let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") }
+ let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "improve/awesome", milestone: milestone, author: user, assignee: user) }
+ let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "fix") }
let(:merge_request_css) { '.merge-request' }
let(:clear_search_css) { '.filtered-search-box .clear-search' }
diff --git a/spec/features/merge_requests/target_branch_spec.rb b/spec/features/merge_requests/target_branch_spec.rb
index b6134540273..9bbf2610bcb 100644
--- a/spec/features/merge_requests/target_branch_spec.rb
+++ b/spec/features/merge_requests/target_branch_spec.rb
@@ -1,27 +1,19 @@
require 'spec_helper'
-describe 'Target branch', feature: true do
+describe 'Target branch', js: true do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
def path_to_merge_request
- namespace_project_merge_request_path(
- project.namespace,
- project, merge_request
- )
+ project_merge_request_path(project, merge_request)
end
before do
- login_as user
+ sign_in user
project.team << [user, :master]
end
- it 'shows link to target branch' do
- visit path_to_merge_request
- expect(page).to have_link('feature', href: namespace_project_commits_path(project.namespace, project, merge_request.target_branch))
- end
-
context 'when branch was deleted' do
before do
DeleteBranchService.new(project, user).execute('feature')
@@ -30,12 +22,12 @@ describe 'Target branch', feature: true do
it 'shows a message about missing target branch' do
expect(page).to have_content(
- 'Target branch feature does not exist'
+ 'Target branch does not exist'
)
end
it 'does not show link to target branch' do
- expect(page).not_to have_link('feature')
+ expect(page).not_to have_selector('.mr-widget-body .js-branch-text a')
end
end
end
diff --git a/spec/features/merge_requests/toggle_whitespace_changes_spec.rb b/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
index 0f98737b700..dd989fd49b2 100644
--- a/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
+++ b/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Toggle Whitespace Changes', js: true, feature: true do
+feature 'Toggle Whitespace Changes', js: true do
before do
- login_as :admin
+ sign_in(create(:admin))
merge_request = create(:merge_request)
project = merge_request.source_project
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit diffs_project_merge_request_path(project, merge_request)
end
it 'has a button to toggle whitespace changes' do
diff --git a/spec/features/merge_requests/toggler_behavior_spec.rb b/spec/features/merge_requests/toggler_behavior_spec.rb
index 3acd3f6a8b3..4e5ec9fbd2d 100644
--- a/spec/features/merge_requests/toggler_behavior_spec.rb
+++ b/spec/features/merge_requests/toggler_behavior_spec.rb
@@ -1,17 +1,17 @@
require 'spec_helper'
-feature 'toggler_behavior', js: true, feature: true do
+feature 'toggler_behavior', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, author: user) }
let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
let(:fragment_id) { "#note_#{note.id}" }
before do
- login_as :admin
+ sign_in(create(:admin))
project = merge_request.source_project
page.current_window.resize_to(1000, 300)
- visit "#{namespace_project_merge_request_path(project.namespace, project, merge_request)}#{fragment_id}"
+ visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}"
end
describe 'scroll position' do
diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb
index 9ecc998785b..e6dc284cba7 100644
--- a/spec/features/merge_requests/update_merge_requests_spec.rb
+++ b/spec/features/merge_requests/update_merge_requests_spec.rb
@@ -1,19 +1,19 @@
require 'rails_helper'
-feature 'Multiple merge requests updating from merge_requests#index', feature: true do
+feature 'Multiple merge requests updating from merge_requests#index' do
let!(:user) { create(:user)}
- let!(:project) { create(:project) }
+ let!(:project) { create(:project, :repository) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'status', js: true do
describe 'close merge request' do
before do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it 'closes merge request' do
@@ -26,7 +26,7 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
describe 'reopen merge request' do
before do
merge_request.close
- visit namespace_project_merge_requests_path(project.namespace, project, state: 'closed')
+ visit project_merge_requests_path(project, state: 'closed')
end
it 'reopens merge request' do
@@ -40,7 +40,7 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
context 'assignee', js: true do
describe 'set assignee' do
before do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it "updates merge request with assignee" do
@@ -56,7 +56,7 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
before do
merge_request.assignee = user
merge_request.save
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it "removes assignee from the merge request" do
@@ -72,7 +72,7 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
describe 'set milestone' do
before do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it "updates merge request with milestone" do
@@ -86,7 +86,7 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
before do
merge_request.milestone = milestone
merge_request.save
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it "removes milestone from the merge request" do
@@ -98,16 +98,18 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
end
def change_status(text)
- find('#check_all_issues').click
+ click_button 'Edit merge requests'
+ find('#check-all-issues').click
find('.js-issue-status').click
find('.dropdown-menu-status a', text: text).click
click_update_merge_requests_button
end
def change_assignee(text)
- find('#check_all_issues').click
+ click_button 'Edit merge requests'
+ find('#check-all-issues').click
find('.js-update-assignee').click
- wait_for_ajax
+ wait_for_requests
page.within '.dropdown-menu-user' do
click_link text
@@ -117,14 +119,15 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t
end
def change_milestone(text)
- find('#check_all_issues').click
- find('.issues_bulk_update .js-milestone-select').click
+ click_button 'Edit merge requests'
+ find('#check-all-issues').click
+ find('.issues-bulk-update .js-milestone-select').click
find('.dropdown-menu-milestone a', text: text).click
click_update_merge_requests_button
end
def click_update_merge_requests_button
- find('.update_selected_issues').click
- wait_for_ajax
+ find('.update-selected-issues').click
+ wait_for_requests
end
end
diff --git a/spec/features/merge_requests/user_lists_merge_requests_spec.rb b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
index cabb8e455f9..20008b4e7f9 100644
--- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe 'Projects > Merge requests > User lists merge requests', feature: true do
+describe 'Projects > Merge requests > User lists merge requests' do
include MergeRequestHelpers
include SortingHelper
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:user) { create(:user) }
before do
@@ -24,12 +24,10 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
milestone: create(:milestone, due_date: '2013-12-12'),
created_at: 2.minutes.ago,
updated_at: 2.minutes.ago)
- # lfs in itself is not a great choice for the title if one wants to match the whole body content later on
- # just think about the scenario when faker generates 'Chester Runolfsson' as the user's name
create(:merge_request,
- title: 'merge_lfs',
+ title: 'merge-test',
source_project: project,
- source_branch: 'merge_lfs',
+ source_branch: 'merge-test',
created_at: 3.minutes.ago,
updated_at: 10.seconds.ago)
end
@@ -37,8 +35,8 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
it 'filters on no assignee' do
visit_merge_requests(project, assignee_id: IssuableFinder::NONE)
- expect(current_path).to eq(namespace_project_merge_requests_path(project.namespace, project))
- expect(page).to have_content 'merge_lfs'
+ expect(current_path).to eq(project_merge_requests_path(project))
+ expect(page).to have_content 'merge-test'
expect(page).not_to have_content 'fix'
expect(page).not_to have_content 'markdown'
expect(count_merge_requests).to eq(1)
@@ -47,7 +45,7 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
it 'filters on a specific assignee' do
visit_merge_requests(project, assignee_id: user.id)
- expect(page).not_to have_content 'merge_lfs'
+ expect(page).not_to have_content 'merge-test'
expect(page).to have_content 'fix'
expect(page).to have_content 'markdown'
expect(count_merge_requests).to eq(2)
@@ -57,14 +55,14 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
visit_merge_requests(project, sort: sort_value_recently_created)
expect(first_merge_request).to include('fix')
- expect(last_merge_request).to include('merge_lfs')
+ expect(last_merge_request).to include('merge-test')
expect(count_merge_requests).to eq(3)
end
it 'sorts by oldest' do
visit_merge_requests(project, sort: sort_value_oldest_created)
- expect(first_merge_request).to include('merge_lfs')
+ expect(first_merge_request).to include('merge-test')
expect(last_merge_request).to include('fix')
expect(count_merge_requests).to eq(3)
end
@@ -72,7 +70,7 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
it 'sorts by last updated' do
visit_merge_requests(project, sort: sort_value_recently_updated)
- expect(first_merge_request).to include('merge_lfs')
+ expect(first_merge_request).to include('merge-test')
expect(count_merge_requests).to eq(3)
end
@@ -136,7 +134,7 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true
end
it 'sorts by recently due milestone' do
- visit namespace_project_merge_requests_path(project.namespace, project,
+ visit project_merge_requests_path(project,
label_name: [label.name, label2.name],
assignee_id: user.id,
sort: sort_value_milestone_soon)
diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
index 7756202e3f5..f89dd38e5cd 100644
--- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
@@ -7,7 +7,7 @@ feature 'Merge requests > User posts diff notes', :js do
before do
project.add_developer(user)
- login_as(user)
+ sign_in(user)
end
let(:comment_button_class) { '.add-diff-note' }
@@ -17,7 +17,7 @@ feature 'Merge requests > User posts diff notes', :js do
context 'when hovering over a parallel view diff file' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'parallel')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'parallel')
end
context 'with an old line on the left and no line on the right' do
@@ -71,9 +71,9 @@ feature 'Merge requests > User posts diff notes', :js do
end
context 'with an unfolded line' do
- before(:each) do
+ before do
find('.js-unfold', match: :first).click
- wait_for_ajax
+ wait_for_requests
end
# The first `.js-unfold` unfolds upwards, therefore the first
@@ -92,7 +92,7 @@ feature 'Merge requests > User posts diff notes', :js do
context 'when hovering over an inline view diff file' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'inline')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
end
context 'with a new line' do
@@ -120,9 +120,9 @@ feature 'Merge requests > User posts diff notes', :js do
end
context 'with an unfolded line' do
- before(:each) do
+ before do
find('.js-unfold', match: :first).click
- wait_for_ajax
+ wait_for_requests
end
# The first `.js-unfold` unfolds upwards, therefore the first
@@ -136,9 +136,9 @@ feature 'Merge requests > User posts diff notes', :js do
context 'when hovering over a diff discussion' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'inline')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not allow commenting' do
@@ -149,7 +149,7 @@ feature 'Merge requests > User posts diff notes', :js do
context 'when cancelling the comment addition' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'inline')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
end
context 'with a new line' do
@@ -161,7 +161,7 @@ feature 'Merge requests > User posts diff notes', :js do
describe 'with muliple note forms' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'inline')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
click_diff_line(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
click_diff_line(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'))
end
@@ -181,7 +181,7 @@ feature 'Merge requests > User posts diff notes', :js do
context 'when the MR only supports legacy diff notes' do
before do
merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: 'inline')
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
end
context 'with a new line' do
@@ -213,7 +213,7 @@ feature 'Merge requests > User posts diff notes', :js do
write_comment_on_line(line_holder, diff_side)
click_button 'Comment'
- wait_for_ajax
+ wait_for_requests
assert_comment_persistence(line_holder, asset_form_reset: asset_form_reset)
end
diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb
index c7cc4d6bc72..d7cda73ab40 100644
--- a/spec/features/merge_requests/user_posts_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_notes_spec.rb
@@ -1,7 +1,9 @@
require 'spec_helper'
describe 'Merge requests > User posts notes', :js do
- let(:project) { create(:project) }
+ include NoteInteractionHelpers
+
+ let(:project) { create(:project, :repository) }
let(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
@@ -11,8 +13,8 @@ describe 'Merge requests > User posts notes', :js do
end
before do
- login_as :admin
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(create(:admin))
+ visit project_merge_request_path(project, merge_request)
end
subject { page }
@@ -20,8 +22,8 @@ describe 'Merge requests > User posts notes', :js 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 .js-comment-button').value).
- to eq('Comment')
+ 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')
end
@@ -73,6 +75,7 @@ describe 'Merge requests > User posts notes', :js do
describe 'editing the note' do
before do
find('.note').hover
+
find('.js-note-edit').click
end
@@ -98,7 +101,9 @@ describe 'Merge requests > User posts notes', :js do
find('.btn-save').click
end
+ wait_for_requests
find('.note').hover
+
find('.js-note-edit').click
page.within('.current-note-edit-form') do
@@ -116,8 +121,8 @@ describe 'Merge requests > User posts notes', :js do
page.within("#note_#{note.id}") do
is_expected.to have_css('.note_edited_ago')
- expect(find('.note_edited_ago').text).
- to match(/less than a minute ago/)
+ expect(find('.note_edited_ago').text)
+ .to match(/less than a minute ago/)
end
end
end
@@ -125,6 +130,7 @@ describe 'Merge requests > User posts notes', :js do
describe 'deleting an attachment' do
before do
find('.note').hover
+
find('.js-note-edit').click
end
@@ -138,7 +144,7 @@ describe 'Merge requests > User posts notes', :js do
find('.js-note-attachment-delete').click
is_expected.not_to have_css('.note-attachment')
is_expected.not_to have_css('.current-note-edit-form')
- wait_for_ajax
+ wait_for_requests
end
end
end
diff --git a/spec/features/merge_requests/user_sees_system_notes_spec.rb b/spec/features/merge_requests/user_sees_system_notes_spec.rb
index 55d0f9d728c..03dc61c2efa 100644
--- a/spec/features/merge_requests/user_sees_system_notes_spec.rb
+++ b/spec/features/merge_requests/user_sees_system_notes_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
feature 'Merge requests > User sees system notes' do
- let(:public_project) { create(:project, :public) }
- let(:private_project) { create(:project, :private) }
+ let(:public_project) { create(:project, :public, :repository) }
+ let(:private_project) { create(:project, :private, :repository) }
let(:issue) { create(:issue, project: private_project) }
let(:merge_request) { create(:merge_request, source_project: public_project, source_branch: 'markdown') }
let!(:note) { create(:note_on_merge_request, :system, noteable: merge_request, project: public_project, note: "mentioned in #{issue.to_reference(public_project)}") }
@@ -11,11 +11,11 @@ feature 'Merge requests > User sees system notes' do
before do
user = create(:user)
private_project.add_developer(user)
- login_as(user)
+ sign_in(user)
end
it 'shows the system note' do
- visit namespace_project_merge_request_path(public_project.namespace, public_project, merge_request)
+ visit project_merge_request_path(public_project, merge_request)
expect(page).to have_css('.system-note')
end
@@ -23,7 +23,7 @@ feature 'Merge requests > User sees system notes' do
context 'when not logged-in' do
it 'hides the system note' do
- visit namespace_project_merge_request_path(public_project.namespace, public_project, merge_request)
+ visit project_merge_request_path(public_project, merge_request)
expect(page).not_to have_css('.system-note')
end
diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
index 1c0f21e5616..95c50df1896 100644
--- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb
+++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
@@ -1,27 +1,35 @@
require 'rails_helper'
-feature 'Merge Requests > User uses slash commands', feature: true, js: true do
- include SlashCommandsHelpers
+feature 'Merge Requests > User uses quick actions', js: true do
+ include QuickActionsHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:merge_request) { create(:merge_request, source_project: project) }
- let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
-
- it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do
+ it_behaves_like 'issuable record that supports quick actions in its description and notes', :merge_request do
let(:issuable) { create(:merge_request, source_project: project) }
let(:new_url_opts) { { merge_request: { source_branch: 'feature', target_branch: 'master' } } }
end
describe 'merge-request-only commands' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
end
after do
- wait_for_ajax
+ wait_for_requests
+ end
+
+ describe 'time tracking' do
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it_behaves_like 'issuable time tracker'
end
describe 'toggling the WIP prefix in the title from note' do
@@ -51,9 +59,9 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- logout
- login_with(guest)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not change the WIP prefix' do
@@ -97,9 +105,9 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- logout
- login_with(guest)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not merge the MR' do
@@ -121,17 +129,17 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
end
describe '/target_branch command in merge request' do
- let(:another_project) { create(:project, :public) }
+ let(:another_project) { create(:project, :public, :repository) }
let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
before do
- logout
+ sign_out(:user)
another_project.team << [user, :master]
- login_with(user)
+ sign_in(user)
end
it 'changes target_branch in new merge_request' do
- visit new_namespace_project_merge_request_path(another_project.namespace, another_project, new_url_opts)
+ visit project_new_merge_request_path(another_project, new_url_opts)
fill_in "merge_request_title", with: 'My brand new feature'
fill_in "merge_request_description", with: "le feature \n/target_branch fix\nFeature description:"
@@ -145,7 +153,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
it 'does not change target branch when merge request is edited' do
new_merge_request = create(:merge_request, source_project: another_project)
- visit edit_namespace_project_merge_request_path(another_project.namespace, another_project, new_merge_request)
+ visit edit_project_merge_request_path(another_project, new_merge_request)
fill_in "merge_request_description", with: "Want to update target branch\n/target_branch fix\n"
click_button "Save changes"
@@ -160,6 +168,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
it 'changes target branch from a note' do
write_note("message start \n/target_branch merge-test\n message end.")
+ wait_for_requests
expect(page).not_to have_content('/target_branch')
expect(page).to have_content('message start')
expect(page).to have_content('message end.')
@@ -180,9 +189,9 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- logout
- login_with(guest)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
end
it 'does not change target branch' do
diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb
index 7a2da623c58..8e231fbc281 100644
--- a/spec/features/merge_requests/versions_spec.rb
+++ b/spec/features/merge_requests/versions_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Merge Request versions', js: true, feature: true do
+feature 'Merge Request versions', js: true do
let(:merge_request) { create(:merge_request, importing: true) }
let(:project) { merge_request.source_project }
let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
@@ -8,8 +8,8 @@ feature 'Merge Request versions', js: true, feature: true do
let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
before do
- login_as :admin
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ sign_in(create(:admin))
+ visit diffs_project_merge_request_path(project, merge_request)
end
it 'show the latest version of the diff' do
@@ -24,7 +24,12 @@ feature 'Merge Request versions', js: true, feature: true do
before do
page.within '.mr-version-dropdown' do
find('.btn-default').click
- find(:link, 'version 1').trigger('click')
+ click_link 'version 1'
+ end
+
+ # Wait for the page to load
+ page.within '.mr-version-dropdown' do
+ expect(page).to have_content 'version 1'
end
end
@@ -36,8 +41,8 @@ feature 'Merge Request versions', js: true, feature: true do
expect(page).to have_content '5 changed files'
end
- it 'show the message about disabled comment creation' do
- expect(page).to have_content 'comment creation is disabled'
+ it 'show the message about comments' do
+ expect(page).to have_content 'Not all comments are displayed'
end
it 'shows comments that were last relevant at that version' do
@@ -52,21 +57,46 @@ feature 'Merge Request versions', js: true, feature: true do
outdated_diff_note.position = outdated_diff_note.original_position
outdated_diff_note.save!
+ visit current_url
+
expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']")
end
+
+ it 'allows commenting' do
+ diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']"
+ line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_2_2'
+
+ page.within(diff_file_selector) do
+ find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover'
+ find(".line_holder[id='#{line_code}'] button").trigger 'click'
+
+ page.within("form[data-line-code='#{line_code}']") do
+ fill_in "note[note]", with: "Typo, please fix"
+ find(".js-comment-button").click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content("Typo, please fix")
+ end
+ end
end
describe 'compare with older version' do
before do
page.within '.mr-version-compare-dropdown' do
find('.btn-default').click
- find(:link, 'version 1').trigger('click')
+ click_link 'version 1'
+ end
+
+ # Wait for the page to load
+ page.within '.mr-version-compare-dropdown' do
+ expect(page).to have_content 'version 1'
end
end
it 'has a path with comparison context' do
- expect(page).to have_current_path diffs_namespace_project_merge_request_path(
- project.namespace,
+ expect(page).to have_current_path diffs_project_merge_request_path(
project,
merge_request.iid,
diff_id: merge_request_diff3.id,
@@ -80,8 +110,45 @@ feature 'Merge Request versions', js: true, feature: true do
end
end
- it 'show the message about disabled comments' do
- expect(page).to have_content 'Comments are disabled'
+ it 'show the message about comments' do
+ expect(page).to have_content 'Not all comments are displayed'
+ end
+
+ it 'shows comments that were last relevant at that version' do
+ position = Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: 4,
+ new_line: 4,
+ diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs
+ )
+ outdated_diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
+ outdated_diff_note.position = outdated_diff_note.original_position
+ outdated_diff_note.save!
+
+ visit current_url
+ wait_for_requests
+
+ expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']")
+ end
+
+ it 'allows commenting' do
+ diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']"
+ line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_4'
+
+ page.within(diff_file_selector) do
+ find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover'
+ find(".line_holder[id='#{line_code}'] button").trigger 'click'
+
+ page.within("form[data-line-code='#{line_code}']") do
+ fill_in "note[note]", with: "Typo, please fix"
+ find(".js-comment-button").click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content("Typo, please fix")
+ end
end
it 'show diff between new and old version' do
diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb
index 00d191ddf2c..c0221525c9f 100644
--- a/spec/features/merge_requests/widget_deployments_spec.rb
+++ b/spec/features/merge_requests/widget_deployments_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Widget Deployments Header', feature: true, js: true do
+feature 'Widget Deployments Header', js: true do
describe 'when deployed to an environment' do
given(:user) { create(:user) }
given(:project) { merge_request.target_project }
@@ -12,16 +12,16 @@ feature 'Widget Deployments Header', feature: true, js: true do
given!(:manual) { }
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
scenario 'displays that the environment is deployed' do
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content("Deployed to #{environment.name}")
- expect(find('.ci_widget > span > span')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
+ expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
end
context 'with stop action' do
@@ -34,15 +34,15 @@ feature 'Widget Deployments Header', feature: true, js: true do
end
background do
- wait_for_ajax
+ wait_for_requests
end
scenario 'does show stop button' do
- expect(page).to have_link('Stop environment')
+ expect(page).to have_button('Stop environment')
end
scenario 'does start build when stop button clicked' do
- click_link('Stop environment')
+ click_button('Stop environment')
expect(page).to have_content('close_app')
end
@@ -51,7 +51,7 @@ feature 'Widget Deployments Header', feature: true, js: true do
given(:role) { :reporter }
scenario 'does not show stop button' do
- expect(page).not_to have_link('Stop environment')
+ expect(page).not_to have_button('Stop environment')
end
end
end
diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb
index d918181a238..fd991293ee9 100644
--- a/spec/features/merge_requests/widget_spec.rb
+++ b/spec/features/merge_requests/widget_spec.rb
@@ -1,35 +1,34 @@
require 'rails_helper'
-describe 'Merge request', :feature, :js do
+describe 'Merge request', :js do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'new merge request' do
before do
- visit new_namespace_project_merge_request_path(
- project.namespace,
+ visit project_new_merge_request_path(
project,
merge_request: {
source_project_id: project.id,
target_project_id: project.id,
source_branch: 'feature',
target_branch: 'master'
- }
- )
+ })
end
it 'shows widget status after creating new merge request' do
click_button 'Submit merge request'
- wait_for_ajax
+ wait_for_requests
expect(page).to have_selector('.accept-merge-request')
+ expect(find('.accept-merge-request')['disabled']).not_to be(true)
end
end
@@ -43,22 +42,23 @@ describe 'Merge request', :feature, :js do
end
before do
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'shows environments link' do
- wait_for_ajax
+ wait_for_requests
page.within('.mr-widget-heading') do
expect(page).to have_content("Deployed to #{environment.name}")
- expect(find('.js-environment-link')[:href]).to include(environment.formatted_external_url)
+ expect(find('.js-deploy-url')[:href]).to include(environment.formatted_external_url)
end
end
it 'shows green accept merge request button' do
# Wait for the `ci_status` and `merge_check` requests
- wait_for_ajax
- expect(page).to have_selector('.accept-merge-request.btn-create')
+ wait_for_requests
+ expect(page).to have_selector('.accept-merge-request')
+ expect(find('.accept-merge-request')['disabled']).not_to be(true)
end
end
@@ -69,12 +69,12 @@ describe 'Merge request', :feature, :js do
type: 'CiService',
category: 'ci')
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'has danger button while waiting for external CI status' do
# Wait for the `ci_status` and `merge_check` requests
- wait_for_ajax
+ wait_for_requests
expect(page).to have_selector('.accept-merge-request.btn-danger')
end
end
@@ -86,29 +86,31 @@ describe 'Merge request', :feature, :js do
sha: merge_request.diff_head_sha,
ref: merge_request.source_branch,
status: 'failed',
- statuses: [commit_status])
+ statuses: [commit_status],
+ head_pipeline_of: merge_request)
create(:ci_build, :pending, pipeline: pipeline)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'has danger button when not succeeded' do
# Wait for the `ci_status` and `merge_check` requests
- wait_for_ajax
+ wait_for_requests
expect(page).to have_selector('.accept-merge-request.btn-danger')
end
end
context 'when merge request is in the blocked pipeline state' do
before do
- create(:ci_pipeline, project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- status: :manual)
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ status: :manual,
+ head_pipeline_of: merge_request)
- visit namespace_project_merge_request_path(project.namespace,
- project,
- merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'shows information about blocked pipeline' do
@@ -126,16 +128,17 @@ describe 'Merge request', :feature, :js do
sha: merge_request.diff_head_sha,
ref: merge_request.source_branch,
status: 'pending',
- statuses: [commit_status])
+ statuses: [commit_status],
+ head_pipeline_of: merge_request)
create(:ci_build, :pending, pipeline: pipeline)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'has info button when MWBS button' do
# Wait for the `ci_status` and `merge_check` requests
- wait_for_ajax
- expect(page).to have_selector('.merge-when-pipeline-succeeds.btn-info')
+ wait_for_requests
+ expect(page).to have_selector('.accept-merge-request.btn-info')
end
end
@@ -147,12 +150,33 @@ describe 'Merge request', :feature, :js do
merge_error: 'Something went wrong'
)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit project_merge_request_path(project, merge_request)
end
it 'shows information about the merge error' do
# Wait for the `ci_status` and `merge_check` requests
- wait_for_ajax
+ wait_for_requests
+
+ page.within('.mr-widget-body') do
+ expect(page).to have_content('Something went wrong')
+ end
+ end
+ end
+
+ context 'view merge request with MWPS enabled but automatically merge fails' do
+ before do
+ merge_request.update(
+ merge_when_pipeline_succeeds: true,
+ merge_user: merge_request.author,
+ merge_error: 'Something went wrong'
+ )
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows information about the merge error' do
+ # Wait for the `ci_status` and `merge_check` requests
+ wait_for_requests
page.within('.mr-widget-body') do
expect(page).to have_content('Something went wrong')
@@ -163,15 +187,49 @@ describe 'Merge request', :feature, :js do
context 'merge error' do
before do
allow_any_instance_of(Repository).to receive(:merge).and_return(false)
- visit namespace_project_merge_request_path(project.namespace, project, merge_request)
- click_button 'Accept merge request'
- wait_for_ajax
+ visit project_merge_request_path(project, merge_request)
end
it 'updates the MR widget' do
+ click_button 'Merge'
+
page.within('.mr-widget-body') do
expect(page).to have_content('Conflicts detected during merge')
end
end
end
+
+ context 'user can merge into source project but cannot push to fork', js: true do
+ let(:fork_project) { create(:project, :public, :repository) }
+ let(:user2) { create(:user) }
+
+ before do
+ project.team << [user2, :master]
+ sign_out(:user)
+ sign_in(user2)
+ merge_request.update(target_project: fork_project)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'user can merge into the source project' do
+ expect(page).to have_button('Merge', disabled: false)
+ end
+
+ it 'user cannot remove source branch' do
+ expect(page).to have_field('remove-source-branch-input', disabled: true)
+ end
+ end
+
+ context 'ongoing merge process' do
+ it 'shows Merging state' do
+ allow_any_instance_of(MergeRequest).to receive(:merge_ongoing?).and_return(true)
+
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).not_to have_button('Merge')
+ expect(page).to have_content('This merge request is in the process of being merged')
+ end
+ end
end
diff --git a/spec/features/merge_requests/wip_message_spec.rb b/spec/features/merge_requests/wip_message_spec.rb
index 3311731b33b..b422c76249d 100644
--- a/spec/features/merge_requests/wip_message_spec.rb
+++ b/spec/features/merge_requests/wip_message_spec.rb
@@ -1,26 +1,24 @@
require 'spec_helper'
-feature 'Work In Progress help message', feature: true do
- let!(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+feature 'Work In Progress help message' do
+ let!(:project) { create(:project, :public, :repository) }
let!(:user) { create(:user) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'with WIP commits' do
it 'shows a specific WIP hint' do
- visit new_namespace_project_merge_request_path(
- project.namespace,
+ visit project_new_merge_request_path(
project,
merge_request: {
source_project_id: project.id,
target_project_id: project.id,
source_branch: 'wip',
target_branch: 'master'
- }
- )
+ })
within_wip_explanation do
expect(page).to have_text(
@@ -32,16 +30,14 @@ feature 'Work In Progress help message', feature: true do
context 'without WIP commits' do
it 'shows the regular WIP message' do
- visit new_namespace_project_merge_request_path(
- project.namespace,
+ visit project_new_merge_request_path(
project,
merge_request: {
source_project_id: project.id,
target_project_id: project.id,
source_branch: 'fix',
target_branch: 'master'
- }
- )
+ })
within_wip_explanation do
expect(page).not_to have_text(
diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb
index c07de01c594..6c9dc67ad74 100644
--- a/spec/features/milestone_spec.rb
+++ b/spec/features/milestone_spec.rb
@@ -1,17 +1,19 @@
require 'rails_helper'
-feature 'Milestone', feature: true do
- let(:project) { create(:empty_project, :public) }
+feature 'Milestone' do
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:user) { create(:user) }
before do
+ create(:group_member, group: group, user: user)
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
feature 'Create a milestone' do
scenario 'shows an informative message for a new milestone' do
- visit new_namespace_project_milestone_path(project.namespace, project)
+ visit new_project_milestone_path(project)
page.within '.milestone-form' do
fill_in "milestone_title", with: '8.7'
@@ -31,23 +33,36 @@ feature 'Milestone', feature: true do
milestone = create(:milestone, project: project, title: 8.7)
create(:issue, title: "Bugfix1", project: project, milestone: milestone, state: "closed")
- visit namespace_project_milestone_path(project.namespace, project, milestone)
+ visit project_milestone_path(project, milestone)
expect(find('.alert-success')).to have_content('All issues for this milestone are closed. You may close this milestone now.')
end
end
- feature 'Open a milestone with an existing title' do
- scenario 'displays validation message' do
+ feature 'Open a project milestone with an existing title' do
+ scenario 'displays validation message when there is a project milestone with same title' do
milestone = create(:milestone, project: project, title: 8.7)
- visit new_namespace_project_milestone_path(project.namespace, project)
+ visit new_project_milestone_path(project)
page.within '.milestone-form' do
fill_in "milestone_title", with: milestone.title
end
find('input[name="commit"]').click
- expect(find('.alert-danger')).to have_content('Title has already been taken')
+ expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
+ end
+
+ scenario 'displays validation message when there is a group milestone with same title' do
+ milestone = create(:milestone, project_id: nil, group: project.group, title: 8.7)
+
+ visit new_group_milestone_path(project.group)
+
+ page.within '.milestone-form' do
+ fill_in "milestone_title", with: milestone.title
+ end
+ find('input[name="commit"]').click
+
+ expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
end
end
diff --git a/spec/features/milestones/milestones_spec.rb b/spec/features/milestones/milestones_spec.rb
deleted file mode 100644
index 50d7ca39045..00000000000
--- a/spec/features/milestones/milestones_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-require 'rails_helper'
-
-describe 'Milestone draggable', feature: true, js: true do
- include DragTo
-
- let(:milestone) { create(:milestone, project: project, title: 8.14) }
- let(:project) { create(:empty_project, :public) }
- let(:user) { create(:user) }
-
- context 'issues' do
- let(:issue) { page.find_by_id('issues-list-unassigned').find('li') }
- let(:issue_target) { page.find_by_id('issues-list-ongoing') }
-
- it 'does not allow guest to drag issue' do
- create_and_drag_issue
-
- expect(issue_target).not_to have_selector('.issuable-row')
- end
-
- it 'does not allow authorized user to drag issue' do
- login_as(user)
- create_and_drag_issue
-
- expect(issue_target).not_to have_selector('.issuable-row')
- end
-
- it 'allows author to drag issue' do
- login_as(user)
- create_and_drag_issue(author: user)
-
- expect(issue_target).to have_selector('.issuable-row')
- end
-
- it 'allows admin to drag issue' do
- login_as(:admin)
- create_and_drag_issue
-
- expect(issue_target).to have_selector('.issuable-row')
- end
- end
-
- context 'merge requests' do
- let(:merge_request) { page.find_by_id('merge_requests-list-unassigned').find('li') }
- let(:merge_request_target) { page.find_by_id('merge_requests-list-ongoing') }
-
- it 'does not allow guest to drag merge request' do
- create_and_drag_merge_request
-
- expect(merge_request_target).not_to have_selector('.issuable-row')
- end
-
- it 'does not allow authorized user to drag merge request' do
- login_as(user)
- create_and_drag_merge_request
-
- expect(merge_request_target).not_to have_selector('.issuable-row')
- end
-
- it 'allows author to drag merge request' do
- login_as(user)
- create_and_drag_merge_request(author: user)
-
- expect(merge_request_target).to have_selector('.issuable-row')
- end
-
- it 'allows admin to drag merge request' do
- login_as(:admin)
- create_and_drag_merge_request
-
- expect(merge_request_target).to have_selector('.issuable-row')
- end
- end
-
- def create_and_drag_issue(params = {})
- create(:issue, params.merge(title: 'Foo', project: project, milestone: milestone))
-
- visit namespace_project_milestone_path(project.namespace, project, milestone)
- scroll_into_view('.milestone-content')
- drag_to(selector: '.issues-sortable-list', list_to_index: 1)
-
- wait_for_ajax
- end
-
- def create_and_drag_merge_request(params = {})
- create(:merge_request, params.merge(title: 'Foo', source_project: project, target_project: project, milestone: milestone))
-
- visit namespace_project_milestone_path(project.namespace, project, milestone)
- page.find("a[href='#tab-merge-requests']").click
- scroll_into_view('.milestone-content')
- drag_to(selector: '.merge_requests-sortable-list', list_to_index: 1)
-
- wait_for_ajax
- end
-
- def scroll_into_view(selector)
- page.evaluate_script("document.querySelector('#{selector}').scrollIntoView();")
- end
-end
diff --git a/spec/features/milestones/show_spec.rb b/spec/features/milestones/show_spec.rb
index 40b4dc63697..624f13922ed 100644
--- a/spec/features/milestones/show_spec.rb
+++ b/spec/features/milestones/show_spec.rb
@@ -1,19 +1,19 @@
require 'rails_helper'
-describe 'Milestone show', feature: true do
+describe 'Milestone show' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:milestone) { create(:milestone, project: project) }
let(:labels) { create_list(:label, 2, project: project) }
- let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone, labels: labels } }
+ let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone, labels: labels } }
before do
- project.add_user(user, :developer)
- login_as(user)
+ project.add_user(user, :developer)
+ sign_in(user)
end
def visit_milestone
- visit namespace_project_milestone_path(project.namespace, project, milestone)
+ visit project_milestone_path(project, milestone)
end
it 'avoids N+1 database queries' do
diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb
new file mode 100644
index 00000000000..49d8e52f861
--- /dev/null
+++ b/spec/features/oauth_login_spec.rb
@@ -0,0 +1,114 @@
+require 'spec_helper'
+
+feature 'OAuth Login', :js, :allow_forgery_protection do
+ include DeviseHelpers
+
+ def enter_code(code)
+ fill_in 'user_otp_attempt', with: code
+ click_button 'Verify code'
+ end
+
+ def stub_omniauth_config(provider)
+ OmniAuth.config.add_mock(provider, OmniAuth::AuthHash.new(provider: provider.to_s, uid: "12345"))
+ set_devise_mapping(context: Rails.application)
+ Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider]
+ end
+
+ providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2,
+ :facebook, :cas3, :auth0, :authentiq]
+
+ before(:all) do
+ # The OmniAuth `full_host` parameter doesn't get set correctly (it gets set to something like `http://localhost`
+ # here), and causes integration tests to fail with 404s. We set the `full_host` by removing the request path (and
+ # anything after it) from the request URI.
+ @omniauth_config_full_host = OmniAuth.config.full_host
+ OmniAuth.config.full_host = ->(request) { request['REQUEST_URI'].sub(/#{request['REQUEST_PATH']}.*/, '') }
+ end
+
+ after(:all) do
+ OmniAuth.config.full_host = @omniauth_config_full_host
+ end
+
+ providers.each do |provider|
+ context "when the user logs in using the #{provider} provider" do
+ context 'when two-factor authentication is disabled' do
+ it 'logs the user in' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid')
+
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'when two-factor authentication is enabled' do
+ it 'logs the user in' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid')
+
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'when "remember me" is checked' do
+ context 'when two-factor authentication is disabled' do
+ it 'remembers the user after a browser restart' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid', remember_me: true)
+
+ clear_browser_session
+
+ visit(root_path)
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'when two-factor authentication is enabled' do
+ it 'remembers the user after a browser restart' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid', remember_me: true)
+ enter_code(user.current_otp)
+
+ clear_browser_session
+
+ visit(root_path)
+ expect(current_path).to eq root_path
+ end
+ end
+ end
+
+ context 'when "remember me" is not checked' do
+ context 'when two-factor authentication is disabled' do
+ it 'does not remember the user after a browser restart' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid', remember_me: false)
+
+ clear_browser_session
+
+ visit(root_path)
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+
+ context 'when two-factor authentication is enabled' do
+ it 'does not remember the user after a browser restart' do
+ stub_omniauth_config(provider)
+ user = create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: provider.to_s)
+ login_via(provider.to_s, user, 'my-uid', remember_me: false)
+ enter_code(user.current_otp)
+
+ clear_browser_session
+
+ visit(root_path)
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb
index 449ce80bc71..a22d548eef3 100644
--- a/spec/features/participants_autocomplete_spec.rb
+++ b/spec/features/participants_autocomplete_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
feature 'Member autocomplete', :js do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:author) { create(:user) }
let(:note) { create(:note, noteable: noteable, project: noteable.project) }
before do
note # actually create the note
- login_as(user)
+ sign_in(user)
end
shared_examples "open suggestions when typing @" do
@@ -29,7 +29,7 @@ feature 'Member autocomplete', :js do
context 'adding a new note on a Issue' do
let(:noteable) { create(:issue, author: author, project: project) }
before do
- visit namespace_project_issue_path(project.namespace, project, noteable)
+ visit project_issue_path(project, noteable)
end
include_examples "open suggestions when typing @"
@@ -42,7 +42,7 @@ feature 'Member autocomplete', :js do
target_project: project, author: author)
end
before do
- visit namespace_project_merge_request_path(project.namespace, project, noteable)
+ visit project_merge_request_path(project, noteable)
end
include_examples "open suggestions when typing @"
@@ -54,9 +54,10 @@ feature 'Member autocomplete', :js do
let(:note) { create(:note_on_commit, project: project, commit_id: project.commit.id) }
before do
- allow_any_instance_of(Commit).to receive(:author).and_return(author)
+ allow(User).to receive(:find_by_any_email)
+ .with(noteable.author_email.downcase).and_return(author)
- visit namespace_project_commit_path(project.namespace, project, noteable)
+ visit project_commit_path(project, noteable)
end
include_examples "open suggestions when typing @"
diff --git a/spec/features/password_reset_spec.rb b/spec/features/password_reset_spec.rb
index 257d363438c..b45972b7f6b 100644
--- a/spec/features/password_reset_spec.rb
+++ b/spec/features/password_reset_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Password reset', feature: true do
+feature 'Password reset' do
describe 'throttling' do
it 'sends reset instructions when not previously sent' do
user = create(:user)
@@ -16,7 +16,7 @@ feature 'Password reset', feature: true do
user.send_reset_password_instructions
user.update_attribute(:reset_password_sent_at, 5.minutes.ago)
- expect{ forgot_password(user) }.to change{ user.reset_password_sent_at }
+ expect { forgot_password(user) }.to change { user.reset_password_sent_at }
expect(page).to have_content(I18n.t('devise.passwords.send_paranoid_instructions'))
expect(current_path).to eq new_user_session_path
end
@@ -27,7 +27,7 @@ feature 'Password reset', feature: true do
# Reload because PG handles datetime less precisely than Ruby/Rails
user.reload
- expect{ forgot_password(user) }.not_to change{ user.reset_password_sent_at }
+ expect { forgot_password(user) }.not_to change { user.reset_password_sent_at }
expect(page).to have_content(I18n.t('devise.passwords.send_paranoid_instructions'))
expect(current_path).to eq new_user_session_path
end
diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb
index e63feb14b7e..f183dd8cb75 100644
--- a/spec/features/profile_spec.rb
+++ b/spec/features/profile_spec.rb
@@ -1,15 +1,14 @@
require 'spec_helper'
-describe 'Profile account page', feature: true do
+describe 'Profile account page' do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
end
- describe 'when signup is enabled' do
+ describe 'when I delete my account' do
before do
- stub_application_setting(signup_enabled: true)
visit profile_account_path
end
@@ -21,29 +20,32 @@ describe 'Profile account page', feature: true do
end
end
- describe 'when signup is disabled' do
+ describe 'when I reset private token' do
before do
- stub_application_setting(signup_enabled: false)
visit profile_account_path
end
- it 'does not have option to remove account' do
- expect(page).not_to have_content('Remove account')
- expect(current_path).to eq(profile_account_path)
+ it 'resets private token' do
+ previous_token = find("#private-token").value
+
+ click_link('Reset private token')
+
+ expect(find('#private-token').value).not_to eq(previous_token)
end
end
- describe 'when I reset private token' do
+ describe 'when I reset RSS token' do
before do
visit profile_account_path
end
- it 'resets private token' do
- previous_token = find("#private-token").value
+ it 'resets RSS token' do
+ previous_token = find("#rss-token").value
- click_link('Reset private token')
+ click_link('Reset RSS token')
- expect(find('#private-token').value).not_to eq(previous_token)
+ expect(page).to have_content 'RSS token was successfully reset'
+ expect(find('#rss-token').value).not_to eq(previous_token)
end
end
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
new file mode 100644
index 00000000000..dcd0449dbcb
--- /dev/null
+++ b/spec/features/profiles/account_spec.rb
@@ -0,0 +1,64 @@
+require 'rails_helper'
+
+feature 'Profile > Account' do
+ given(:user) { create(:user, username: 'foo') }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'Change username' do
+ given(:new_username) { 'bar' }
+ given(:new_user_path) { "/#{new_username}" }
+ given(:old_user_path) { "/#{user.username}" }
+
+ scenario 'the user is accessible via the new path' do
+ update_username(new_username)
+ visit new_user_path
+ expect(current_path).to eq(new_user_path)
+ expect(find('.user-info')).to have_content(new_username)
+ end
+
+ scenario 'the old user path redirects to the new path' do
+ update_username(new_username)
+ visit old_user_path
+ expect(current_path).to eq(new_user_path)
+ expect(find('.user-info')).to have_content(new_username)
+ end
+
+ context 'with a project' do
+ given!(:project) { create(:project, namespace: user.namespace) }
+ given(:new_project_path) { "/#{new_username}/#{project.path}" }
+ given(:old_project_path) { "/#{user.username}/#{project.path}" }
+
+ before(:context) do
+ TestEnv.clean_test_path
+ end
+
+ after do
+ TestEnv.clean_test_path
+ end
+
+ scenario 'the project is accessible via the new path' do
+ update_username(new_username)
+ visit new_project_path
+ expect(current_path).to eq(new_project_path)
+ expect(find('h1.title')).to have_content(project.path)
+ end
+
+ scenario 'the old project path redirects to the new path' do
+ update_username(new_username)
+ visit old_project_path
+ expect(current_path).to eq(new_project_path)
+ expect(find('h1.title')).to have_content(project.path)
+ end
+ end
+ end
+end
+
+def update_username(new_username)
+ allow(user.namespace).to receive(:move_dir)
+ visit profile_account_path
+ fill_in 'user_username', with: new_username
+ click_button 'Update username'
+end
diff --git a/spec/features/profiles/chat_names_spec.rb b/spec/features/profiles/chat_names_spec.rb
index 6f6f7029c0b..35793539e0e 100644
--- a/spec/features/profiles/chat_names_spec.rb
+++ b/spec/features/profiles/chat_names_spec.rb
@@ -1,11 +1,11 @@
require 'rails_helper'
-feature 'Profile > Chat', feature: true do
+feature 'Profile > Chat' do
given(:user) { create(:user) }
given(:service) { create(:service) }
before do
- login_as(user)
+ sign_in(user)
end
describe 'uses authorization link' do
diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb
new file mode 100644
index 00000000000..6edc482b47e
--- /dev/null
+++ b/spec/features/profiles/gpg_keys_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+feature 'Profile > GPG Keys' do
+ let(:user) { create(:user, email: GpgHelpers::User2.emails.first) }
+
+ before do
+ login_as(user)
+ end
+
+ describe 'User adds a key' do
+ before do
+ visit profile_gpg_keys_path
+ end
+
+ scenario 'saves the new key' do
+ fill_in('Key', with: GpgHelpers::User2.public_key)
+ click_button('Add key')
+
+ expect(page).to have_content('bette.cartwright@example.com Verified')
+ expect(page).to have_content('bette.cartwright@example.net Unverified')
+ expect(page).to have_content(GpgHelpers::User2.fingerprint)
+ end
+ end
+
+ scenario 'User sees their key' do
+ create(:gpg_key, user: user, key: GpgHelpers::User2.public_key)
+ visit profile_gpg_keys_path
+
+ expect(page).to have_content('bette.cartwright@example.com Verified')
+ expect(page).to have_content('bette.cartwright@example.net Unverified')
+ expect(page).to have_content(GpgHelpers::User2.fingerprint)
+ end
+
+ scenario 'User removes a key via the key index' do
+ create(:gpg_key, user: user, key: GpgHelpers::User2.public_key)
+ visit profile_gpg_keys_path
+
+ click_link('Remove')
+
+ expect(page).to have_content('Your GPG keys (0)')
+ end
+
+ scenario 'User revokes a key via the key index' do
+ gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key
+ gpg_signature = create :gpg_signature, gpg_key: gpg_key, valid_signature: true
+
+ visit profile_gpg_keys_path
+
+ click_link('Revoke')
+
+ expect(page).to have_content('Your GPG keys (0)')
+
+ expect(gpg_signature.reload).to have_attributes(
+ valid_signature: false,
+ gpg_key: nil
+ )
+ end
+end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index 2f436f153aa..6541ea6bf57 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-feature 'Profile > SSH Keys', feature: true do
+feature 'Profile > SSH Keys' do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
end
describe 'User adds a key' do
diff --git a/spec/features/profiles/oauth_applications_spec.rb b/spec/features/profiles/oauth_applications_spec.rb
index 1a5a9059dbd..45f78444362 100644
--- a/spec/features/profiles/oauth_applications_spec.rb
+++ b/spec/features/profiles/oauth_applications_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe 'Profile > Applications', feature: true do
+describe 'Profile > Applications' do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
end
describe 'User manages applications', js: true do
diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb
index 4cbdd89d46f..2c757f99a27 100644
--- a/spec/features/profiles/password_spec.rb
+++ b/spec/features/profiles/password_spec.rb
@@ -1,44 +1,74 @@
require 'spec_helper'
-describe 'Profile > Password', feature: true do
- let(:user) { create(:user, password_automatically_set: true) }
+describe 'Profile > Password' do
+ context 'Password authentication enabled' do
+ let(:user) { create(:user, password_automatically_set: true) }
- before do
- login_as(user)
- visit edit_profile_password_path
- end
+ before do
+ sign_in(user)
+ visit edit_profile_password_path
+ end
- def fill_passwords(password, confirmation)
- fill_in 'New password', with: password
- fill_in 'Password confirmation', with: confirmation
+ def fill_passwords(password, confirmation)
+ fill_in 'New password', with: password
+ fill_in 'Password confirmation', with: confirmation
- click_button 'Save password'
- end
+ click_button 'Save password'
+ end
+
+ context 'User with password automatically set' do
+ describe 'User puts different passwords in the field and in the confirmation' do
+ it 'shows an error message' do
+ fill_passwords('mypassword', 'mypassword2')
- context 'User with password automatically set' do
- describe 'User puts different passwords in the field and in the confirmation' do
- it 'shows an error message' do
- fill_passwords('mypassword', 'mypassword2')
+ page.within('.alert-danger') do
+ expect(page).to have_content("Password confirmation doesn't match Password")
+ end
+ end
+
+ it 'does not contain the current password field after an error' do
+ fill_passwords('mypassword', 'mypassword2')
- page.within('.alert-danger') do
- expect(page).to have_content("Password confirmation doesn't match Password")
+ expect(page).to have_no_field('user[current_password]')
end
end
- it 'does not contains the current password field after an error' do
- fill_passwords('mypassword', 'mypassword2')
+ describe 'User puts the same passwords in the field and in the confirmation' do
+ it 'shows a success message' do
+ fill_passwords('mypassword', 'mypassword')
- expect(page).to have_no_field('user[current_password]')
+ page.within('.flash-notice') do
+ expect(page).to have_content('Password was successfully updated. Please login with it')
+ end
+ end
end
end
+ end
- describe 'User puts the same passwords in the field and in the confirmation' do
- it 'shows a success message' do
- fill_passwords('mypassword', 'mypassword')
+ context 'Password authentication unavailable' do
+ before do
+ gitlab_sign_in(user)
+ end
- page.within('.flash-notice') do
- expect(page).to have_content('Password was successfully updated. Please login with it')
- end
+ context 'Regular user' do
+ let(:user) { create(:user) }
+
+ it 'renders 404 when sign-in is disabled' do
+ stub_application_setting(password_authentication_enabled: false)
+
+ visit edit_profile_password_path
+
+ expect(page).to have_http_status(404)
+ end
+ end
+
+ context 'LDAP user' do
+ let(:user) { create(:omniauth_user, provider: 'ldapmain') }
+
+ it 'renders 404' do
+ visit edit_profile_password_path
+
+ expect(page).to have_http_status(404)
end
end
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 27a20e78a43..f3124bbf29e 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-describe 'Profile > Personal Access Tokens', feature: true, js: true do
+describe 'Profile > Personal Access Tokens', js: true do
let(:user) { create(:user) }
def active_personal_access_tokens
find(".table.active-tokens")
end
- def inactive_personal_access_tokens
- find(".table.inactive-tokens")
+ def no_personal_access_tokens_message
+ find(".settings-message")
end
def created_personal_access_token
@@ -17,12 +17,13 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do
def disallow_personal_access_token_saves!
allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false)
+
errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
end
before do
- login_as(user)
+ sign_in(user)
end
describe "token creation" do
@@ -79,20 +80,25 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do
visit profile_personal_access_tokens_path
click_on "Revoke"
- expect(inactive_personal_access_tokens).to have_text(personal_access_token.name)
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
end
- it "moves expired tokens to the 'inactive' section" do
+ it "removes expired tokens from 'active' section" do
personal_access_token.update(expires_at: 5.days.ago)
visit profile_personal_access_tokens_path
- expect(inactive_personal_access_tokens).to have_text(personal_access_token.name)
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
end
context "when revocation fails" do
it "displays an error message" do
- disallow_personal_access_token_saves!
visit profile_personal_access_tokens_path
+ allow_any_instance_of(PersonalAccessToken).to receive(:update!).and_return(false)
+
+ errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
+ allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
click_on "Revoke"
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb
index 15c8677fcd3..c935cdfd5c4 100644
--- a/spec/features/profiles/preferences_spec.rb
+++ b/spec/features/profiles/preferences_spec.rb
@@ -1,35 +1,39 @@
require 'spec_helper'
-describe 'Profile > Preferences', feature: true do
+describe 'Profile > Preferences', :js do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
visit profile_preferences_path
end
- describe 'User changes their syntax highlighting theme', js: true do
+ describe 'User changes their syntax highlighting theme' do
it 'creates a flash message' do
choose 'user_color_scheme_id_5'
+ wait_for_requests
+
expect_preferences_saved_message
end
it 'updates their preference' do
choose 'user_color_scheme_id_5'
- allowing_for_delay do
- visit page.current_path
- expect(page).to have_checked_field('user_color_scheme_id_5')
- end
+ wait_for_requests
+ refresh
+
+ expect(page).to have_checked_field('user_color_scheme_id_5')
end
end
- describe 'User changes their default dashboard', js: true do
+ describe 'User changes their default dashboard' do
it 'creates a flash message' do
select 'Starred Projects', from: 'user_dashboard'
click_button 'Save'
+ wait_for_requests
+
expect_preferences_saved_message
end
@@ -37,14 +41,14 @@ describe 'Profile > Preferences', feature: true do
select 'Starred Projects', from: 'user_dashboard'
click_button 'Save'
- allowing_for_delay do
- find('#logo').click
+ wait_for_requests
+
+ find('#logo').click
- expect(page).to have_content("You don't have starred projects yet")
- expect(page.current_path).to eq starred_dashboard_projects_path
- end
+ expect(page).to have_content("You don't have starred projects yet")
+ expect(page.current_path).to eq starred_dashboard_projects_path
- click_link 'Your projects'
+ find('.shortcuts-activity').trigger('click')
expect(page).not_to have_content("You don't have starred projects yet")
expect(page.current_path).to eq dashboard_projects_path
diff --git a/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb b/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb
index e05fbb3715c..6a4173d43e1 100644
--- a/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb
+++ b/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Profile > Notifications > User changes notified_of_own_activity setting', feature: true, js: true do
+feature 'Profile > Notifications > User changes notified_of_own_activity setting', js: true do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
end
scenario 'User opts into receiving notifications about their own activity' do
diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb
new file mode 100644
index 00000000000..48c1787c8b7
--- /dev/null
+++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+feature 'User visits the notifications tab', js: true do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ visit(profile_notifications_path)
+ end
+
+ it 'changes the project notifications setting' do
+ expect(page).to have_content('Notifications')
+
+ first('#notifications-button').trigger('click')
+ click_link('On mention')
+
+ expect(page).to have_content('On mention')
+ end
+end
diff --git a/spec/features/projects/activity/rss_spec.rb b/spec/features/projects/activity/rss_spec.rb
index b47c6d431eb..84c2faa2015 100644
--- a/spec/features/projects/activity/rss_spec.rb
+++ b/spec/features/projects/activity/rss_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
feature 'Project Activity RSS' do
- let(:project) { create(:empty_project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { activity_namespace_project_path(project.namespace, project) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+ let(:path) { activity_project_path(project) }
before do
create(:issue, project: project)
@@ -10,13 +11,12 @@ feature 'Project Activity RSS' do
context 'when signed in' do
before do
- user = create(:user)
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "it has an RSS button with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
end
context 'when signed out' do
@@ -24,6 +24,6 @@ feature 'Project Activity RSS' do
visit path
end
- it_behaves_like "it has an RSS button without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
end
end
diff --git a/spec/features/projects/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb
new file mode 100644
index 00000000000..42b47cb3301
--- /dev/null
+++ b/spec/features/projects/artifacts/browse_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+feature 'Browse artifact', :js do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+
+ def browse_path(path)
+ browse_project_job_artifacts_path(project, job, path)
+ end
+
+ context 'when visiting old URL' do
+ let(:browse_url) do
+ browse_path('other_artifacts_0.1.2')
+ end
+
+ before do
+ visit browse_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(browse_url)
+ end
+ end
+end
diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb
new file mode 100644
index 00000000000..f1bdb2812c6
--- /dev/null
+++ b/spec/features/projects/artifacts/download_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+feature 'Download artifact', :js do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) }
+ let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) }
+
+ shared_examples 'downloading' do
+ it 'downloads the zip' do
+ expect(page.response_headers['Content-Disposition'])
+ .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+
+ # Check the content does match, but don't print this as error message
+ expect(page.source.b == job.artifacts_file.file.read.b)
+ end
+ end
+
+ context 'when downloading' do
+ before do
+ visit download_url
+ end
+
+ context 'via job id' do
+ let(:download_url) do
+ download_project_job_artifacts_path(project, job)
+ end
+
+ it_behaves_like 'downloading'
+ end
+
+ context 'via branch name and job name' do
+ let(:download_url) do
+ latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name)
+ end
+
+ it_behaves_like 'downloading'
+ end
+ end
+
+ context 'when visiting old URL' do
+ before do
+ visit download_url.sub('/-/jobs', '/builds')
+ end
+
+ context 'via job id' do
+ let(:download_url) do
+ download_project_job_artifacts_path(project, job)
+ end
+
+ it_behaves_like 'downloading'
+ end
+
+ context 'via branch name and job name' do
+ let(:download_url) do
+ latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name)
+ end
+
+ it_behaves_like 'downloading'
+ end
+ end
+end
diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb
new file mode 100644
index 00000000000..b2be10a7e0c
--- /dev/null
+++ b/spec/features/projects/artifacts/file_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+feature 'Artifact file', :js do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
+
+ def visit_file(path)
+ visit file_path(path)
+ end
+
+ def file_path(path)
+ file_project_job_artifacts_path(project, build, path)
+ end
+
+ context 'Text file' do
+ before do
+ visit_file('other_artifacts_0.1.2/doc_sample.txt')
+
+ wait_for_requests
+ end
+
+ it 'displays an error' do
+ aggregate_failures do
+ # shows an error message
+ expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'JPG file' do
+ before do
+ page.driver.browser.url_blacklist = []
+ visit_file('rails_sample.jpg')
+
+ wait_for_requests
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows rendered image
+ expect(page).to have_selector('.image_file img')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'when visiting old URL' do
+ let(:file_url) do
+ file_path('other_artifacts_0.1.2/doc_sample.txt')
+ end
+
+ before do
+ visit file_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(file_url)
+ end
+ end
+end
diff --git a/spec/features/projects/artifacts/raw_spec.rb b/spec/features/projects/artifacts/raw_spec.rb
new file mode 100644
index 00000000000..0bec6e9ad31
--- /dev/null
+++ b/spec/features/projects/artifacts/raw_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+feature 'Raw artifact', :js do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+
+ def raw_path(path)
+ raw_project_job_artifacts_path(project, job, path)
+ end
+
+ context 'when visiting old URL' do
+ let(:raw_url) do
+ raw_path('other_artifacts_0.1.2/doc_sample.txt')
+ end
+
+ before do
+ visit raw_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(raw_url)
+ end
+ end
+end
diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb
index 01a95bf49ac..368a046f741 100644
--- a/spec/features/projects/badges/coverage_spec.rb
+++ b/spec/features/projects/badges/coverage_spec.rb
@@ -7,7 +7,7 @@ feature 'test coverage badge' do
context 'when user has access to view badge' do
background do
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
end
scenario 'user requests coverage badge image for pipeline' do
@@ -45,7 +45,7 @@ feature 'test coverage badge' do
end
context 'when user does not have access to view badge' do
- background { login_as(user) }
+ background { sign_in(user) }
scenario 'user requests test coverage badge image' do
show_test_coverage_badge
@@ -55,7 +55,7 @@ feature 'test coverage badge' do
end
def create_pipeline
- opts = { project: project, ref: 'master', sha: project.commit.id }
+ opts = { project: project }
create(:ci_pipeline, opts).tap do |pipeline|
yield pipeline
@@ -70,8 +70,7 @@ feature 'test coverage badge' do
end
def show_test_coverage_badge(job: nil)
- visit coverage_namespace_project_badges_path(
- project.namespace, project, ref: :master, job: job, format: :svg)
+ visit coverage_project_badges_path(project, ref: :master, job: job, format: :svg)
end
def expect_coverage_badge(coverage)
diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb
index ae9db0c0d6e..89ae891037e 100644
--- a/spec/features/projects/badges/list_spec.rb
+++ b/spec/features/projects/badges/list_spec.rb
@@ -3,23 +3,23 @@ require 'spec_helper'
feature 'list of badges' do
background do
user = create(:user)
- project = create(:project)
+ project = create(:project, :repository)
project.team << [user, :master]
- login_as(user)
- visit namespace_project_pipelines_settings_path(project.namespace, project)
+ sign_in(user)
+ visit project_pipelines_settings_path(project)
end
scenario 'user wants to see build status badge' do
- page.within('.build-status') do
- expect(page).to have_content 'build status'
+ page.within('.pipeline-status') do
+ expect(page).to have_content 'pipeline status'
expect(page).to have_content 'Markdown'
expect(page).to have_content 'HTML'
expect(page).to have_content 'AsciiDoc'
expect(page).to have_css('.highlight', count: 3)
- expect(page).to have_xpath("//img[@alt='build status']")
+ expect(page).to have_xpath("//img[@alt='pipeline status']")
page.within('.highlight', match: :first) do
- expect(page).to have_content 'badges/master/build.svg'
+ expect(page).to have_content 'badges/master/pipeline.svg'
end
end
end
@@ -40,14 +40,14 @@ feature 'list of badges' do
end
scenario 'user changes current ref of build status badge', js: true do
- page.within('.build-status') do
+ page.within('.pipeline-status') do
first('.js-project-refs-dropdown').click
page.within '.project-refs-form' do
click_link 'improve/awesome'
end
- expect(page).to have_content 'badges/improve/awesome/build.svg'
+ expect(page).to have_content 'badges/improve/awesome/pipeline.svg'
end
end
end
diff --git a/spec/features/projects/badges/pipeline_badge_spec.rb b/spec/features/projects/badges/pipeline_badge_spec.rb
new file mode 100644
index 00000000000..b83ea8f4eaa
--- /dev/null
+++ b/spec/features/projects/badges/pipeline_badge_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+feature 'Pipeline Badge' do
+ set(:project) { create(:project, :repository, :public) }
+ let(:ref) { project.default_branch }
+
+ # this can't be tested in the controller, as it bypasses the rails router
+ # and constructs a route based on the controller being tested
+ # Keep around until 10.0, see gitlab-org/gitlab-ce#35307
+ context 'when the deprecated badge is requested' do
+ it 'displays the badge' do
+ visit build_project_badges_path(project, ref: ref, format: :svg)
+
+ expect(page.status_code).to eq(200)
+ end
+ end
+
+ context 'when the project has a pipeline' do
+ let!(:pipeline) { create(:ci_empty_pipeline, project: project, ref: ref, sha: project.commit(ref).sha) }
+ let!(:job) { create(:ci_build, pipeline: pipeline) }
+
+ context 'when the pipeline was successfull' do
+ it 'displays so on the badge' do
+ job.success
+
+ visit pipeline_project_badges_path(project, ref: ref, format: :svg)
+
+ expect(page.status_code).to eq(200)
+ expect_badge('passed')
+ end
+ end
+
+ context 'when the pipeline failed' do
+ it 'shows displays so on the badge' do
+ job.drop
+
+ visit pipeline_project_badges_path(project, ref: ref, format: :svg)
+
+ expect(page.status_code).to eq(200)
+ expect_badge('failed')
+ end
+ end
+
+ context 'when the pipeline is running' do
+ it 'shows displays so on the badge' do
+ create(:ci_build, pipeline: pipeline, name: 'second build', status_event: 'run')
+
+ visit pipeline_project_badges_path(project, ref: ref, format: :svg)
+
+ expect(page.status_code).to eq(200)
+ expect_badge('running')
+ end
+ end
+
+ context 'when a new pipeline is created' do
+ it 'shows a fresh badge' do
+ visit pipeline_project_badges_path(project, ref: ref, format: :svg)
+
+ expect(page.status_code).to eq(200)
+ expect(page.response_headers['Cache-Control']).to include 'no-cache'
+ end
+ end
+
+ def expect_badge(status)
+ svg = Nokogiri::XML.parse(page.body)
+ expect(page.response_headers['Content-Type']).to include('image/svg+xml')
+ expect(svg.at(%Q{text:contains("#{status}")})).to be_truthy
+ end
+ end
+end
diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
index d94204230f6..1160f674974 100644
--- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
+++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true, js: true do
+feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', js: true do
include TreeHelper
let(:project) { create(:project, :public, :repository) }
@@ -13,14 +13,14 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
end
def visit_blob(fragment = nil)
- visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment)
+ visit project_blob_path(project, tree_join('master', path), anchor: fragment)
end
describe 'Click "Permalink" button' do
it 'works with no initial line number fragment hash' do
visit_blob
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path))))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path))))
end
it 'maintains intitial fragment hash' do
@@ -28,7 +28,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
visit_blob(fragment)
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: fragment)))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: fragment)))
end
it 'changes fragment hash if line number clicked' do
@@ -39,7 +39,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
find('#L3').click
find("##{ending_fragment}").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment)))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: ending_fragment)))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -51,7 +51,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
find('#L3').click
find("##{ending_fragment}").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment)))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: ending_fragment)))
end
end
@@ -59,7 +59,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
it 'works with no initial line number fragment hash' do
visit_blob
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path))))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path))))
end
it 'maintains intitial fragment hash' do
@@ -67,7 +67,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
visit_blob(fragment)
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: fragment)))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: fragment)))
end
it 'changes fragment hash if line number clicked' do
@@ -78,7 +78,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
find('#L3').click
find("##{ending_fragment}").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment)))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: ending_fragment)))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -90,7 +90,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true,
find('#L3').click
find("##{ending_fragment}").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment)))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: ending_fragment)))
end
end
end
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 7cfa5b9716f..3d465e709b9 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -1,21 +1,499 @@
require 'spec_helper'
-feature 'File blob', feature: true do
- include TreeHelper
+feature 'File blob', :js do
+ let(:project) { create(:project, :public, :repository) }
- let(:project) { create(:project, :public, :test_repo) }
- let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') }
- let(:branch) { 'master' }
- let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] }
+ def visit_blob(path, anchor: nil, ref: 'master')
+ visit project_blob_path(project, File.join(ref, path), anchor: anchor)
- context 'anonymous' do
- context 'from blob file path' do
+ wait_for_requests
+ end
+
+ context 'Ruby file' do
+ before do
+ visit_blob('files/ruby/popen.rb')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows highlighted Ruby code
+ expect(page).to have_css(".js-syntax-highlight")
+ expect(page).to have_content("require 'fileutils'")
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+ end
+ end
+ end
+
+ context 'Markdown file' do
+ context 'visiting directly' do
+ before do
+ visit_blob('files/markdown/ruby-style-guide.md')
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows rendered Markdown
+ expect(page).to have_link("PEP-8")
+
+ # shows a viewer switcher
+ expect(page).to have_selector('.js-blob-viewer-switcher')
+
+ # shows a disabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn.disabled')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+ end
+ end
+
+ context 'switching to the simple viewer' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=simple]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # shows highlighted Markdown code
+ expect(page).to have_css(".js-syntax-highlight")
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+
+ context 'switching to the rich viewer again' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=rich]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+ end
+
+ context 'visiting with a line number anchor' do
+ before do
+ visit_blob('files/markdown/ruby-style-guide.md', anchor: 'L1')
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # highlights the line in question
+ expect(page).to have_selector('#LC1.hll')
+
+ # shows highlighted Markdown code
+ expect(page).to have_css(".js-syntax-highlight")
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+
+ context 'Markdown file (stored in LFS)' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add Markdown in LFS",
+ file_path: 'files/lfs/file.md',
+ file_content: project.repository.blob_at('master', 'files/lfs/lfs_object.iso').data
+ ).execute
+ end
+
+ context 'when LFS is enabled on the project' do
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ project.update_attribute(:lfs_enabled, true)
+
+ visit_blob('files/lfs/file.md')
+ end
+
+ it 'displays an error' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows an error message
+ expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can download it instead.')
+
+ # shows a viewer switcher
+ expect(page).to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+
+ context 'switching to the simple viewer' do
+ before do
+ find('.js-blob-viewer-switcher .js-blob-viewer-switch-btn[data-viewer=simple]').click
+
+ wait_for_requests
+ end
+
+ it 'displays an error' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # shows an error message
+ expect(page).to have_content('The source could not be displayed because it is stored in LFS. You can download it instead.')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+ end
+ end
+ end
+ end
+
+ context 'when LFS is disabled on the project' do
+ before do
+ visit_blob('files/lfs/file.md')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows text
+ expect(page).to have_content('size 1575078')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+ end
+ end
+ end
+ end
+
+ context 'PDF file' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add PDF",
+ file_path: 'files/test.pdf',
+ file_content: project.repository.blob_at('add-pdf-file', 'files/pdf/test.pdf').data
+ ).execute
+
+ visit_blob('files/test.pdf')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows rendered PDF
+ expect(page).to have_selector('.js-pdf-viewer')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'ISO file (stored in LFS)' do
+ context 'when LFS is enabled on the project' do
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ project.update_attribute(:lfs_enabled, true)
+
+ visit_blob('files/lfs/lfs_object.iso')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows a download link
+ expect(page).to have_link('Download (1.5 MB)')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'when LFS is disabled on the project' do
before do
- visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path))
+ visit_blob('files/lfs/lfs_object.iso')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows text
+ expect(page).to have_content('size 1575078')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+ end
+ end
+ end
+ end
+
+ context 'ZIP file' do
+ before do
+ visit_blob('Gemfile.zip')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows a download link
+ expect(page).to have_link('Download (2.11 KB)')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'empty file' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add empty file",
+ file_path: 'files/empty.md',
+ file_content: ''
+ ).execute
+
+ visit_blob('files/empty.md')
+ end
+
+ it 'displays an error' do
+ aggregate_failures do
+ # shows an error message
+ expect(page).to have_content('Empty file')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # does not show a copy button
+ expect(page).not_to have_selector('.js-copy-blob-source-btn')
+
+ # does not show a download or raw button
+ expect(page).not_to have_link('Download')
+ expect(page).not_to have_link('Open raw')
+ end
+ end
+ end
+
+ context 'binary file that appears to be text in the first 1024 bytes' do
+ before do
+ visit_blob('encoding/binary-1.bin', ref: 'binary-encoding')
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows a download link
+ expect(page).to have_link('Download (23.8 KB)')
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # The specs below verify an arguably incorrect result, but since we only
+ # learn that the file is not actually text once the text viewer content
+ # is loaded asynchronously, there is no straightforward way to get these
+ # synchronously loaded elements to display correctly.
+ #
+ # Clicking the copy button will result in nothing being copied.
+ # Clicking the raw button will result in the binary file being downloaded,
+ # as expected.
+
+ # shows an enabled copy button, incorrectly
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button, incorrectly
+ expect(page).to have_link('Open raw')
+ end
+ end
+ end
+
+ context '.gitlab-ci.yml' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add .gitlab-ci.yml",
+ file_path: '.gitlab-ci.yml',
+ file_content: File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ ).execute
+
+ visit_blob('.gitlab-ci.yml')
+ end
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that configuration is valid
+ expect(page).to have_content('This GitLab CI configuration is valid.')
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
+ end
+ end
+
+ context '.gitlab/route-map.yml' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add .gitlab/route-map.yml",
+ file_path: '.gitlab/route-map.yml',
+ file_content: <<-MAP.strip_heredoc
+ # Team data
+ - source: 'data/team.yml'
+ public: 'team/'
+ MAP
+ ).execute
+
+ visit_blob('.gitlab/route-map.yml')
+ end
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows that map is valid
+ expect(page).to have_content('This Route Map is valid.')
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more')
+ end
+ end
+ end
+
+ context 'LICENSE' do
+ before do
+ visit_blob('LICENSE')
+ end
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows license
+ expect(page).to have_content('This project is licensed under the MIT License.')
+
+ # shows a learn more link
+ expect(page).to have_link('Learn more', 'http://choosealicense.com/licenses/mit/')
end
+ end
+ end
+
+ context '*.gemspec' do
+ before do
+ project.add_master(project.creator)
+
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add activerecord.gemspec",
+ file_path: 'activerecord.gemspec',
+ file_content: <<-SPEC.strip_heredoc
+ Gem::Specification.new do |s|
+ s.platform = Gem::Platform::RUBY
+ s.name = "activerecord"
+ end
+ SPEC
+ ).execute
+
+ visit_blob('activerecord.gemspec')
+ end
+
+ it 'displays an auxiliary viewer' do
+ aggregate_failures do
+ # shows names of dependency manager and package
+ expect(page).to have_content('This project manages its dependencies using RubyGems and defines a gem named activerecord.')
+
+ # shows a link to the gem
+ expect(page).to have_link('activerecord', 'https://rubygems.org/gems/activerecord')
- it 'updates content' do
- expect(page).to have_link 'Edit'
+ # shows a learn more link
+ expect(page).to have_link('Learn more', 'http://choosealicense.com/licenses/mit/')
end
end
end
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index cc5b1a7e734..62ac9fd0e95 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-feature 'Editing file blob', feature: true, js: true do
+feature 'Editing file blob', js: true do
include TreeHelper
- let(:project) { create(:project, :public, :test_repo) }
+ let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') }
let(:branch) { 'master' }
let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] }
@@ -14,11 +14,11 @@ feature 'Editing file blob', feature: true, js: true do
before do
project.team << [user, role]
- login_as(user)
+ sign_in(user)
end
def edit_and_commit
- wait_for_ajax
+ wait_for_requests
find('.js-edit-blob').click
execute_script('ace.edit("editor").setValue("class NextFeature\nend\n")')
click_button 'Commit changes'
@@ -26,7 +26,7 @@ feature 'Editing file blob', feature: true, js: true do
context 'from MR diff' do
before do
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit diffs_project_merge_request_path(project, merge_request)
edit_and_commit
end
@@ -37,7 +37,7 @@ feature 'Editing file blob', feature: true, js: true do
context 'from blob file path' do
before do
- visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path))
+ visit project_blob_path(project, tree_join(branch, file_path))
edit_and_commit
end
@@ -55,15 +55,15 @@ feature 'Editing file blob', feature: true, js: true do
before do
project.team << [user, :developer]
- visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))
+ visit project_edit_blob_path(project, tree_join(branch, file_path))
end
it 'redirects to sign in and returns' do
expect(page).to have_current_path(new_user_session_path)
- login_as(user)
+ gitlab_sign_in(user)
- expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)))
+ expect(page).to have_current_path(project_edit_blob_path(project, tree_join(branch, file_path)))
end
end
@@ -71,15 +71,15 @@ feature 'Editing file blob', feature: true, js: true do
let(:user) { create(:user) }
before do
- visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))
+ visit project_edit_blob_path(project, tree_join(branch, file_path))
end
it 'redirects to sign in and returns' do
expect(page).to have_current_path(new_user_session_path)
- login_as(user)
+ gitlab_sign_in(user)
- expect(page).to have_current_path(namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path)))
+ expect(page).to have_current_path(project_blob_path(project, tree_join(branch, file_path)))
end
end
end
@@ -92,27 +92,27 @@ feature 'Editing file blob', feature: true, js: true do
project.team << [user, :developer]
project.repository.add_branch(user, protected_branch, 'master')
create(:protected_branch, project: project, name: protected_branch)
- login_as(user)
+ sign_in(user)
end
context 'on some branch' do
before do
- visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))
+ visit project_edit_blob_path(project, tree_join(branch, file_path))
end
it 'shows blob editor with same branch' do
- expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)))
- expect(find('.js-target-branch .dropdown-toggle-text').text).to eq(branch)
+ expect(page).to have_current_path(project_edit_blob_path(project, tree_join(branch, file_path)))
+ expect(find('.js-branch-name').value).to eq(branch)
end
end
context 'with protected branch' do
before do
- visit namespace_project_edit_blob_path(project.namespace, project, tree_join(protected_branch, file_path))
+ visit project_edit_blob_path(project, tree_join(protected_branch, file_path))
end
it 'shows blob editor with patch branch' do
- expect(find('.js-target-branch .dropdown-toggle-text').text).to eq('patch-1')
+ expect(find('.js-branch-name').value).to eq('patch-1')
end
end
end
@@ -122,13 +122,13 @@ feature 'Editing file blob', feature: true, js: true do
before do
project.team << [user, :master]
- login_as(user)
- visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))
+ sign_in(user)
+ visit project_edit_blob_path(project, tree_join(branch, file_path))
end
it 'shows blob editor with same branch' do
- expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)))
- expect(find('.js-target-branch .dropdown-toggle-text').text).to eq(branch)
+ expect(page).to have_current_path(project_edit_blob_path(project, tree_join(branch, file_path)))
+ expect(find('.js-branch-name').value).to eq(branch)
end
end
end
diff --git a/spec/features/projects/blobs/shortcuts_blob_spec.rb b/spec/features/projects/blobs/shortcuts_blob_spec.rb
index 30e2d587267..1e3080fa319 100644
--- a/spec/features/projects/blobs/shortcuts_blob_spec.rb
+++ b/spec/features/projects/blobs/shortcuts_blob_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Blob shortcuts', feature: true do
+feature 'Blob shortcuts' do
include TreeHelper
let(:project) { create(:project, :public, :repository) }
let(:path) { project.repository.ls_files(project.repository.root_ref)[0] }
@@ -12,7 +12,7 @@ feature 'Blob shortcuts', feature: true do
end
def visit_blob(fragment = nil)
- visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment)
+ visit project_blob_path(project, tree_join('master', path), anchor: fragment)
end
describe 'pressing "y"' do
@@ -21,7 +21,7 @@ feature 'Blob shortcuts', feature: true do
find('body').native.send_key('y')
- expect(page).to have_current_path(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path))), url: true)
+ expect(page).to have_current_path(get_absolute_url(project_blob_path(project, tree_join(sha, path))), url: true)
end
it 'maintains fragment hash when redirecting' do
@@ -30,7 +30,7 @@ feature 'Blob shortcuts', feature: true do
find('body').native.send_key('y')
- expect(page).to have_current_path(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: fragment)), url: true)
+ expect(page).to have_current_path(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: fragment)), url: true)
end
end
end
diff --git a/spec/features/projects/blobs/user_create_spec.rb b/spec/features/projects/blobs/user_create_spec.rb
deleted file mode 100644
index d805450e095..00000000000
--- a/spec/features/projects/blobs/user_create_spec.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-require 'spec_helper'
-
-feature 'New blob creation', feature: true, js: true do
- include TargetBranchHelpers
-
- given(:user) { create(:user) }
- given(:role) { :developer }
- given(:project) { create(:project) }
- given(:content) { 'class NextFeature\nend\n' }
-
- background do
- login_as(user)
- project.team << [user, role]
- visit namespace_project_new_blob_path(project.namespace, project, 'master')
- end
-
- def edit_file
- wait_for_ajax
- fill_in 'file_name', with: 'feature.rb'
- execute_script("ace.edit('editor').setValue('#{content}')")
- end
-
- def commit_file
- click_button 'Commit changes'
- end
-
- context 'with default target branch' do
- background do
- edit_file
- commit_file
- end
-
- scenario 'creates the blob in the default branch' do
- expect(page).to have_content 'master'
- expect(page).to have_content 'successfully created'
- expect(page).to have_content 'NextFeature'
- end
- end
-
- context 'with different target branch' do
- background do
- edit_file
- select_branch('feature')
- commit_file
- end
-
- scenario 'creates the blob in the different branch' do
- expect(page).to have_content 'feature'
- expect(page).to have_content 'successfully created'
- end
- end
-
- context 'with a new target branch' do
- given(:new_branch_name) { 'new-feature' }
-
- background do
- edit_file
- create_new_branch(new_branch_name)
- commit_file
- end
-
- scenario 'creates the blob in the new branch' do
- expect(page).to have_content new_branch_name
- expect(page).to have_content 'successfully created'
- end
- scenario 'returns you to the mr' do
- expect(page).to have_content 'New Merge Request'
- expect(page).to have_content "From #{new_branch_name} into master"
- expect(page).to have_content 'Add new file'
- end
- end
-
- context 'the file already exist in the source branch' do
- background do
- Files::CreateService.new(
- project,
- user,
- start_branch: 'master',
- branch_name: 'master',
- commit_message: 'Create file',
- file_path: 'feature.rb',
- file_content: content
- ).execute
- edit_file
- commit_file
- end
-
- scenario 'shows error message' do
- expect(page).to have_content('A file with this name already exists')
- expect(page).to have_content('New file')
- expect(page).to have_content('NextFeature')
- end
- end
-end
diff --git a/spec/features/projects/branches/download_buttons_spec.rb b/spec/features/projects/branches/download_buttons_spec.rb
index 92028c19361..ad06cee4e81 100644
--- a/spec/features/projects/branches/download_buttons_spec.rb
+++ b/spec/features/projects/branches/download_buttons_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Download buttons in branches page', feature: true do
+feature 'Download buttons in branches page' do
given(:user) { create(:user) }
given(:role) { :developer }
given(:status) { 'success' }
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given(:pipeline) do
create(:ci_pipeline,
@@ -22,20 +22,18 @@ feature 'Download buttons in branches page', feature: true do
end
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
end
describe 'when checking branches' do
context 'with artifacts' do
before do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project)
end
scenario 'shows download artifacts button' do
- href = latest_succeeded_namespace_project_artifacts_path(
- project.namespace, project, 'binary-encoding/download',
- job: 'build')
+ href = latest_succeeded_project_artifacts_path(project, 'binary-encoding/download', job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href
end
diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb
new file mode 100644
index 00000000000..0be434a567b
--- /dev/null
+++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe 'New Branch Ref Dropdown', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:toggle) { find('.create-from .dropdown-menu-toggle') }
+
+ before do
+ project.add_master(user)
+
+ sign_in(user)
+ visit new_project_branch_path(project)
+ end
+
+ it 'filters a list of branches and tags' do
+ toggle.click
+
+ filter_by('v1.0.0')
+
+ expect(items_count).to be(1)
+
+ filter_by('video')
+
+ expect(items_count).to be(1)
+
+ find('.create-from .dropdown-content li').click
+
+ expect(toggle).to have_content 'video'
+ end
+
+ it 'accepts a manually entered commit SHA' do
+ toggle.click
+
+ filter_by('somecommitsha')
+
+ find('.create-from input[type=search]').send_keys(:enter)
+
+ expect(toggle).to have_content 'somecommitsha'
+ end
+
+ def items_count
+ all('.create-from .dropdown-content li').length
+ end
+
+ def filter_by(filter_text)
+ fill_in 'Filter by Git revision', with: filter_text
+ end
+end
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index 8e0306ce83b..ad4527a0b74 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -1,48 +1,177 @@
require 'spec_helper'
-describe 'Branches', feature: true do
- let(:project) { create(:project, :public) }
+describe 'Branches' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
let(:repository) { project.repository }
- context 'logged in' do
+ def set_protected_branch_name(branch_name)
+ find(".js-protected-branch-select").click
+ find(".dropdown-input-field").set(branch_name)
+ click_on("Create wildcard #{branch_name}")
+ end
+
+ context 'logged in as developer' do
before do
- login_as :user
- project.team << [@user, :developer]
+ sign_in(user)
+ project.team << [user, :developer]
end
describe 'Initial branches page' do
it 'shows all the branches' do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project)
- repository.branches { |branch| expect(page).to have_content("#{branch.name}") }
- expect(page).to have_content("Protected branches can be managed in project settings")
+ repository.branches_sorted_by(:name).first(20).each do |branch|
+ expect(page).to have_content("#{branch.name}")
+ end
+ end
+
+ it 'sorts the branches by name' do
+ visit project_branches_path(project)
+
+ click_button "Last updated" # Open sorting dropdown
+ click_link "Name"
+
+ sorted = repository.branches_sorted_by(:name).first(20).map do |branch|
+ Regexp.escape(branch.name)
+ end
+ expect(page).to have_content(/#{sorted.join(".*")}/)
+ end
+
+ it 'sorts the branches by last updated' do
+ visit project_branches_path(project)
+
+ click_button "Last updated" # Open sorting dropdown
+ click_link "Last updated"
+
+ sorted = repository.branches_sorted_by(:updated_desc).first(20).map do |branch|
+ Regexp.escape(branch.name)
+ end
+ expect(page).to have_content(/#{sorted.join(".*")}/)
+ end
+
+ it 'sorts the branches by oldest updated' do
+ visit project_branches_path(project)
+
+ click_button "Last updated" # Open sorting dropdown
+ click_link "Oldest updated"
+
+ sorted = repository.branches_sorted_by(:updated_asc).first(20).map do |branch|
+ Regexp.escape(branch.name)
+ end
+ expect(page).to have_content(/#{sorted.join(".*")}/)
end
it 'avoids a N+1 query in branches index' do
- control_count = ActiveRecord::QueryRecorder.new { visit namespace_project_branches_path(project.namespace, project) }.count
+ control_count = ActiveRecord::QueryRecorder.new { visit project_branches_path(project) }.count
- %w(one two three four five).each { |ref| repository.add_branch(@user, ref, 'master') }
+ %w(one two three four five).each { |ref| repository.add_branch(user, ref, 'master') }
- expect { visit namespace_project_branches_path(project.namespace, project) }.not_to exceed_query_limit(control_count)
+ expect { visit project_branches_path(project) }.not_to exceed_query_limit(control_count)
end
end
describe 'Find branches' do
it 'shows filtered branches', js: true do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project)
+
+ fill_in 'branch-search', with: 'fix'
+ find('#branch-search').native.send_keys(:enter)
+
+ expect(page).to have_content('fix')
+ expect(find('.all-branches')).to have_selector('li', count: 1)
+ end
+ end
+
+ describe 'Delete unprotected branch' do
+ it 'removes branch after confirmation', js: true do
+ visit project_branches_path(project)
fill_in 'branch-search', with: 'fix'
+
find('#branch-search').native.send_keys(:enter)
expect(page).to have_content('fix')
expect(find('.all-branches')).to have_selector('li', count: 1)
+ find('.js-branch-fix .btn-remove').trigger(:click)
+
+ expect(page).not_to have_content('fix')
+ expect(find('.all-branches')).to have_selector('li', count: 0)
+ end
+ end
+
+ describe 'Delete protected branch' do
+ before do
+ project.add_user(user, :master)
+ visit project_protected_branches_path(project)
+ set_protected_branch_name('fix')
+ click_on "Protect"
+
+ within(".protected-branches-list") { expect(page).to have_content('fix') }
+ expect(ProtectedBranch.count).to eq(1)
+ project.add_user(user, :developer)
+ end
+
+ it 'does not allow devleoper to removes protected branch', js: true do
+ visit project_branches_path(project)
+
+ fill_in 'branch-search', with: 'fix'
+ find('#branch-search').native.send_keys(:enter)
+
+ expect(page).to have_css('.btn-remove.disabled')
+ end
+ end
+ end
+
+ context 'logged in as master' do
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ describe 'Initial branches page' do
+ it 'shows description for admin' do
+ visit project_branches_path(project)
+
+ expect(page).to have_content("Protected branches can be managed in project settings")
+ end
+ end
+
+ describe 'Delete protected branch' do
+ before do
+ visit project_protected_branches_path(project)
+ set_protected_branch_name('fix')
+ click_on "Protect"
+
+ within(".protected-branches-list") { expect(page).to have_content('fix') }
+ expect(ProtectedBranch.count).to eq(1)
+ end
+
+ it 'removes branch after modal confirmation', js: true do
+ visit project_branches_path(project)
+
+ fill_in 'branch-search', with: 'fix'
+ find('#branch-search').native.send_keys(:enter)
+
+ expect(page).to have_content('fix')
+ expect(find('.all-branches')).to have_selector('li', count: 1)
+ page.find('[data-target="#modal-delete-branch"]').trigger(:click)
+
+ expect(page).to have_css('.js-delete-branch[disabled]')
+ fill_in 'delete_branch_input', with: 'fix'
+ click_link 'Delete protected branch'
+
+ fill_in 'branch-search', with: 'fix'
+ find('#branch-search').native.send_keys(:enter)
+
+ expect(page).to have_content('No branches to show')
end
end
end
context 'logged out' do
before do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project)
end
it 'does not show merge request button' do
diff --git a/spec/features/projects/builds_spec.rb b/spec/features/projects/builds_spec.rb
deleted file mode 100644
index ab10434e10c..00000000000
--- a/spec/features/projects/builds_spec.rb
+++ /dev/null
@@ -1,477 +0,0 @@
-require 'spec_helper'
-require 'tempfile'
-
-feature 'Builds', :feature do
- let(:user) { create(:user) }
- let(:user_access_level) { :developer }
- let(:project) { create(:project) }
- let(:pipeline) { create(:ci_pipeline, project: project) }
-
- let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
- let(:build2) { create(:ci_build) }
-
- let(:artifacts_file) do
- fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif')
- end
-
- before do
- project.team << [user, user_access_level]
- login_as(user)
- end
-
- describe "GET /:project/builds" do
- let!(:build) { create(:ci_build, pipeline: pipeline) }
-
- context "Pending scope" do
- before do
- visit namespace_project_builds_path(project.namespace, project, scope: :pending)
- end
-
- it "shows Pending tab jobs" do
- expect(page).to have_link 'Cancel running'
- expect(page).to have_selector('.nav-links li.active', text: 'Pending')
- expect(page).to have_content build.short_sha
- expect(page).to have_content build.ref
- expect(page).to have_content build.name
- end
- end
-
- context "Running scope" do
- before do
- build.run!
- visit namespace_project_builds_path(project.namespace, project, scope: :running)
- end
-
- it "shows Running tab jobs" do
- expect(page).to have_selector('.nav-links li.active', text: 'Running')
- expect(page).to have_link 'Cancel running'
- expect(page).to have_content build.short_sha
- expect(page).to have_content build.ref
- expect(page).to have_content build.name
- end
- end
-
- context "Finished scope" do
- before do
- build.run!
- visit namespace_project_builds_path(project.namespace, project, scope: :finished)
- end
-
- it "shows Finished tab jobs" do
- expect(page).to have_selector('.nav-links li.active', text: 'Finished')
- expect(page).to have_content 'No jobs to show'
- expect(page).to have_link 'Cancel running'
- end
- end
-
- context "All jobs" do
- before do
- project.builds.running_or_pending.each(&:success)
- visit namespace_project_builds_path(project.namespace, project)
- end
-
- it "shows All tab jobs" do
- expect(page).to have_selector('.nav-links li.active', text: 'All')
- expect(page).to have_content build.short_sha
- expect(page).to have_content build.ref
- expect(page).to have_content build.name
- expect(page).not_to have_link 'Cancel running'
- end
- end
- end
-
- describe "POST /:project/builds/:id/cancel_all" do
- before do
- build.run!
- visit namespace_project_builds_path(project.namespace, project)
- click_link "Cancel running"
- end
-
- it 'shows all necessary content' do
- expect(page).to have_selector('.nav-links li.active', text: 'All')
- expect(page).to have_content 'canceled'
- expect(page).to have_content build.short_sha
- expect(page).to have_content build.ref
- expect(page).to have_content build.name
- expect(page).not_to have_link 'Cancel running'
- end
- end
-
- describe "GET /:project/builds/:id" do
- context "Job from project" do
- before do
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- it 'shows commit`s data' do
- expect(page.status_code).to eq(200)
- expect(page).to have_content pipeline.sha[0..7]
- expect(page).to have_content pipeline.git_commit_message
- expect(page).to have_content pipeline.git_author_name
- end
-
- it 'shows active build' do
- expect(page).to have_selector('.build-job.active')
- end
- end
-
- context "Job from other project" do
- before do
- visit namespace_project_build_path(project.namespace, project, build2)
- end
-
- it { expect(page.status_code).to eq(404) }
- end
-
- context "Download artifacts" do
- before do
- build.update_attributes(artifacts_file: artifacts_file)
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- it 'has button to download artifacts' do
- expect(page).to have_content 'Download'
- end
- end
-
- context 'Artifacts expire date' do
- before do
- build.update_attributes(artifacts_file: artifacts_file,
- artifacts_expire_at: expire_at)
-
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- context 'no expire date defined' do
- let(:expire_at) { nil }
-
- it 'does not have the Keep button' do
- expect(page).not_to have_content 'Keep'
- end
- end
-
- context 'when expire date is defined' do
- let(:expire_at) { Time.now + 7.days }
-
- context 'when user has ability to update job' do
- it 'keeps artifacts when keep button is clicked' do
- expect(page).to have_content 'The artifacts will be removed'
-
- click_link 'Keep'
-
- expect(page).to have_no_link 'Keep'
- expect(page).to have_no_content 'The artifacts will be removed'
- end
- end
-
- context 'when user does not have ability to update job' do
- let(:user_access_level) { :guest }
-
- it 'does not have keep button' do
- expect(page).to have_no_link 'Keep'
- end
- end
- end
-
- context 'when artifacts expired' do
- let(:expire_at) { Time.now - 7.days }
-
- it 'does not have the Keep button' do
- expect(page).to have_content 'The artifacts were removed'
- expect(page).not_to have_link 'Keep'
- end
- end
- end
-
- feature 'Raw trace' do
- before do
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- it do
- expect(page).to have_link 'Raw'
- end
- end
-
- feature 'HTML trace', :js do
- before do
- build.run!
-
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- context 'when job has an initial trace' do
- it 'loads job trace' do
- expect(page).to have_content 'BUILD TRACE'
-
- build.trace.write do |stream|
- stream.append(' and more trace', 11)
- end
-
- expect(page).to have_content 'BUILD TRACE and more trace'
- end
- end
- end
-
- feature 'Variables' do
- let(:trigger_request) { create(:ci_trigger_request_with_variables) }
-
- let(:build) do
- create :ci_build, pipeline: pipeline, trigger_request: trigger_request
- end
-
- before do
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- it 'shows variable key and value after click', js: true do
- expect(page).to have_css('.reveal-variables')
- expect(page).not_to have_css('.js-build-variable')
- expect(page).not_to have_css('.js-build-value')
-
- click_button 'Reveal Variables'
-
- expect(page).not_to have_css('.reveal-variables')
- expect(page).to have_selector('.js-build-variable', text: 'TRIGGER_KEY_1')
- expect(page).to have_selector('.js-build-value', text: 'TRIGGER_VALUE_1')
- end
- end
-
- context 'when job starts environment' do
- let(:environment) { create(:environment, project: project) }
- let(:pipeline) { create(:ci_pipeline, project: project) }
-
- context 'job is successfull and has deployment' do
- let(:deployment) { create(:deployment) }
- let(:build) { create(:ci_build, :success, environment: environment.name, deployments: [deployment], pipeline: pipeline) }
-
- it 'shows a link for the job' do
- visit namespace_project_build_path(project.namespace, project, build)
-
- expect(page).to have_link environment.name
- end
- end
-
- context 'job is complete and not successfull' do
- let(:build) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) }
-
- it 'shows a link for the job' do
- visit namespace_project_build_path(project.namespace, project, build)
-
- expect(page).to have_link environment.name
- end
- end
-
- context 'job creates a new deployment' do
- let!(:deployment) { create(:deployment, environment: environment, sha: project.commit.id) }
- let(:build) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) }
-
- it 'shows a link to latest deployment' do
- visit namespace_project_build_path(project.namespace, project, build)
-
- expect(page).to have_link('latest deployment')
- end
- end
- end
- end
-
- describe "POST /:project/builds/:id/cancel" do
- context "Job from project" do
- before do
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- click_link "Cancel"
- end
-
- it 'loads the page and shows all needed controls' do
- expect(page.status_code).to eq(200)
- expect(page).to have_content 'canceled'
- expect(page).to have_content 'Retry'
- end
- end
-
- context "Job from other project" do
- before do
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- page.driver.post(cancel_namespace_project_build_path(project.namespace, project, build2))
- end
-
- it { expect(page.status_code).to eq(404) }
- end
- end
-
- describe "POST /:project/builds/:id/retry" do
- context "Job from project" do
- before do
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- click_link 'Cancel'
- page.within('.build-header') do
- click_link 'Retry job'
- end
- end
-
- it 'shows the right status and buttons' do
- expect(page).to have_http_status(200)
- expect(page).to have_content 'pending'
- page.within('aside.right-sidebar') do
- expect(page).to have_content 'Cancel'
- end
- end
- end
-
- context "Build from other project" do
- before do
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- click_link 'Cancel'
- page.driver.post(retry_namespace_project_build_path(project.namespace, project, build2))
- end
-
- it { expect(page).to have_http_status(404) }
- end
-
- context "Build that current user is not allowed to retry" do
- before do
- build.run!
- build.cancel!
- project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
-
- logout_direct
- login_with(create(:user))
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- it 'does not show the Retry button' do
- page.within('aside.right-sidebar') do
- expect(page).not_to have_content 'Retry'
- end
- end
- end
- end
-
- describe "GET /:project/builds/:id/download" do
- before do
- build.update_attributes(artifacts_file: artifacts_file)
- visit namespace_project_build_path(project.namespace, project, build)
- click_link 'Download'
- end
-
- context "Build from other project" do
- before do
- build2.update_attributes(artifacts_file: artifacts_file)
- visit download_namespace_project_build_artifacts_path(project.namespace, project, build2)
- end
-
- it { expect(page.status_code).to eq(404) }
- end
- end
-
- describe 'GET /:project/builds/:id/raw' do
- context 'access source' do
- context 'build from project' do
- before do
- Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile')
- build.run!
- visit namespace_project_build_path(project.namespace, project, build)
- page.within('.js-build-sidebar') { click_link 'Raw' }
- end
-
- it 'sends the right headers' do
- expect(page.status_code).to eq(200)
- expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(page.response_headers['X-Sendfile']).to eq(build.trace.send(:current_path))
- end
- end
-
- context 'build from other project' do
- before do
- Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile')
- build2.run!
- visit raw_namespace_project_build_path(project.namespace, project, build2)
- end
-
- it 'sends the right headers' do
- expect(page.status_code).to eq(404)
- end
- end
- end
-
- context 'storage form' do
- let(:existing_file) { Tempfile.new('existing-trace-file').path }
-
- before do
- Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile')
-
- build.run!
-
- allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths)
- .and_return(paths)
-
- visit namespace_project_build_path(project.namespace, project, build)
- end
-
- context 'when build has trace in file' do
- let(:paths) do
- [existing_file]
- end
-
- before do
- page.within('.js-build-sidebar') { click_link 'Raw' }
- end
-
- it 'sends the right headers' do
- expect(page.status_code).to eq(200)
- expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(page.response_headers['X-Sendfile']).to eq(existing_file)
- end
- end
-
- context 'when build has trace in DB' do
- let(:paths) { [] }
-
- it 'sends the right headers' do
- expect(page.status_code).not_to have_link('Raw')
- end
- end
- end
- end
-
- describe "GET /:project/builds/:id/trace.json" do
- context "Build from project" do
- before do
- visit trace_namespace_project_build_path(project.namespace, project, build, format: :json)
- end
-
- it { expect(page.status_code).to eq(200) }
- end
-
- context "Build from other project" do
- before do
- visit trace_namespace_project_build_path(project.namespace, project, build2, format: :json)
- end
-
- it { expect(page.status_code).to eq(404) }
- end
- end
-
- describe "GET /:project/builds/:id/status" do
- context "Build from project" do
- before do
- visit status_namespace_project_build_path(project.namespace, project, build)
- end
-
- it { expect(page.status_code).to eq(200) }
- end
-
- context "Build from other project" do
- before do
- visit status_namespace_project_build_path(project.namespace, project, build2)
- end
-
- it { expect(page.status_code).to eq(404) }
- end
- end
-end
diff --git a/spec/features/projects/commit/builds_spec.rb b/spec/features/projects/commit/builds_spec.rb
index 268d420c594..740331fe42a 100644
--- a/spec/features/projects/commit/builds_spec.rb
+++ b/spec/features/projects/commit/builds_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
feature 'project commit pipelines', js: true do
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
background do
user = create(:user)
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'when no builds triggered yet' do
@@ -17,7 +17,7 @@ feature 'project commit pipelines', js: true do
end
scenario 'user views commit pipelines page' do
- visit pipelines_namespace_project_commit_path(project.namespace, project, project.commit.sha)
+ visit pipelines_project_commit_path(project, project.commit.sha)
page.within('.table-holder') do
expect(page).to have_content project.pipelines[0].status # pipeline status
diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb
index 5d64d42fd61..7086f56bb1b 100644
--- a/spec/features/projects/commit/cherry_pick_spec.rb
+++ b/spec/features/projects/commit/cherry_pick_spec.rb
@@ -1,15 +1,16 @@
require 'spec_helper'
describe 'Cherry-pick Commits' do
+ let(:user) { create(:user) }
let(:group) { create(:group) }
- let(:project) { create(:project, namespace: group) }
+ let(:project) { create(:project, :repository, namespace: group) }
let(:master_pickable_commit) { project.commit('7d3b0f7cff5f37573aea97cebfd5692ea1689924') }
let(:master_pickable_merge) { project.commit('e56497bb5f03a90a51293fc6d516788730953899') }
before do
- login_as :user
- project.team << [@user, :master]
- visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id)
+ sign_in(user)
+ project.team << [user, :master]
+ visit project_commit_path(project, master_pickable_commit.id)
end
context "I cherry-pick a commit" do
@@ -42,7 +43,7 @@ describe 'Cherry-pick Commits' do
uncheck 'create_merge_request'
click_button 'Cherry-pick'
end
- visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id)
+ visit project_commit_path(project, master_pickable_commit.id)
find("a[href='#modal-cherry-pick-commit']").click
page.within('#modal-cherry-pick-commit') do
uncheck 'create_merge_request'
@@ -72,10 +73,12 @@ describe 'Cherry-pick Commits' do
click_button 'master'
end
- wait_for_ajax
+ wait_for_requests
- page.within('#modal-cherry-pick-commit .dropdown-menu .dropdown-content') do
- click_link 'feature'
+ page.within('#modal-cherry-pick-commit .dropdown-menu') do
+ find('.dropdown-input input').set('feature')
+ wait_for_requests
+ click_link "feature"
end
page.within('#modal-cherry-pick-commit') do
diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
index 98c0f2c63b0..2ef74e8857c 100644
--- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb
+++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
@@ -1,11 +1,11 @@
require 'rails_helper'
-feature 'Mini Pipeline Graph in Commit View', :js, :feature do
+feature 'Mini Pipeline Graph in Commit View', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
before do
- login_as(user)
+ sign_in(user)
end
context 'when commit has pipelines' do
@@ -22,7 +22,7 @@ feature 'Mini Pipeline Graph in Commit View', :js, :feature do
before do
build.run
- visit namespace_project_commit_path(project.namespace, project, project.commit.id)
+ visit project_commit_path(project, project.commit.id)
end
it 'should display a mini pipeline graph' do
@@ -32,7 +32,7 @@ feature 'Mini Pipeline Graph in Commit View', :js, :feature do
it 'should show the builds list when stage is clicked' do
first('.mini-pipeline-graph-dropdown-toggle').click
- wait_for_ajax
+ wait_for_requests
page.within '.js-builds-dropdown-list' do
expect(page).to have_selector('.ci-status-icon-running')
@@ -43,7 +43,7 @@ feature 'Mini Pipeline Graph in Commit View', :js, :feature do
context 'when commit does not have pipelines' do
before do
- visit namespace_project_commit_path(project.namespace, project, project.commit.id)
+ visit project_commit_path(project, project.commit.id)
end
it 'should not display a mini pipeline graph' do
diff --git a/spec/features/projects/commit/rss_spec.rb b/spec/features/projects/commit/rss_spec.rb
index 6e0e1916f87..db958346f06 100644
--- a/spec/features/projects/commit/rss_spec.rb
+++ b/spec/features/projects/commit/rss_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
feature 'Project Commits RSS' do
+ let(:user) { create(:user) }
let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { namespace_project_commits_path(project.namespace, project, :master) }
+ let(:path) { project_commits_path(project, :master) }
context 'when signed in' do
before do
- user = create(:user)
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -21,7 +21,7 @@ feature 'Project Commits RSS' do
visit path
end
- it_behaves_like "it has an RSS button without a private token"
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb
index b2a3b111c9e..82d73fe8531 100644
--- a/spec/features/projects/compare_spec.rb
+++ b/spec/features/projects/compare_spec.rb
@@ -2,12 +2,12 @@ require "spec_helper"
describe "Compare", js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
project.team << [user, :master]
- login_as user
- visit namespace_project_compare_index_path(project.namespace, project, from: "master", to: "master")
+ sign_in user
+ visit project_compare_index_path(project, from: "master", to: "master")
end
describe "branches" do
@@ -24,6 +24,7 @@ describe "Compare", js: true do
expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("binary-encoding")
click_button "Compare"
+
expect(page).to have_content "Commits"
end
@@ -52,8 +53,12 @@ describe "Compare", js: true do
def select_using_dropdown(dropdown_type, selection)
dropdown = find(".js-compare-#{dropdown_type}-dropdown")
dropdown.find(".compare-dropdown-toggle").click
+ # find input before using to wait for the inputs visiblity
+ dropdown.find('.dropdown-menu')
dropdown.fill_in("Filter by Git revision", with: selection)
- wait_for_ajax
- dropdown.find_all("a[data-ref=\"#{selection}\"]", visible: true).last.click
+ wait_for_requests
+ # find before all to wait for the items visiblity
+ dropdown.find("a[data-ref=\"#{selection}\"]", match: :first)
+ dropdown.all("a[data-ref=\"#{selection}\"]").last.click
end
end
diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb
index 0b997f130ea..2d1a9b931b5 100644
--- a/spec/features/projects/deploy_keys_spec.rb
+++ b/spec/features/projects/deploy_keys_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'Project deploy keys', feature: true do
+describe 'Project deploy keys', :js do
let(:user) { create(:user) }
let(:project) { create(:project_empty_repo) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
describe 'removing key' do
@@ -15,11 +15,15 @@ describe 'Project deploy keys', feature: true do
end
it 'removes association between project and deploy key' do
- visit namespace_project_settings_repository_path(project.namespace, project)
+ visit project_settings_repository_path(project)
- page.within '.deploy-keys' do
- expect { click_on 'Remove' }
- .to change { project.deploy_keys.count }.by(-1)
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_selector('.deploy-keys li', count: 1)
+
+ click_on 'Remove'
+
+ expect(page).not_to have_selector('.fa-spinner', count: 0)
+ expect(page).to have_selector('.deploy-keys li', count: 0)
end
end
end
diff --git a/spec/features/projects/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/developer_views_empty_project_instructions_spec.rb
index 2352329d58c..fe8567ce348 100644
--- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb
+++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb
@@ -1,13 +1,13 @@
require 'rails_helper'
-feature 'Developer views empty project instructions', feature: true do
- let(:project) { create(:empty_project, :empty_repo) }
+feature 'Developer views empty project instructions' do
+ let(:project) { create(:project, :empty_repo) }
let(:developer) { create(:user) }
background do
project.team << [developer, :developer]
- login_as(developer)
+ sign_in(developer)
end
context 'without an SSH key' do
@@ -47,7 +47,7 @@ feature 'Developer views empty project instructions', feature: true do
end
def visit_project
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
def select_protocol(protocol)
@@ -56,14 +56,8 @@ feature 'Developer views empty project instructions', feature: true do
end
def expect_instructions_for(protocol)
- url =
- case protocol
- when 'ssh'
- project.ssh_url_to_repo
- when 'http'
- project.http_url_to_repo(developer)
- end
-
- expect(page).to have_content("git clone #{url}")
+ msg = :"#{protocol.downcase}_url_to_repo"
+
+ expect(page).to have_content("git clone #{project.send(msg)}")
end
end
diff --git a/spec/features/projects/diffs/diff_show_spec.rb b/spec/features/projects/diffs/diff_show_spec.rb
new file mode 100644
index 00000000000..bc102895aaf
--- /dev/null
+++ b/spec/features/projects/diffs/diff_show_spec.rb
@@ -0,0 +1,137 @@
+require 'spec_helper'
+
+feature 'Diff file viewer', :js do
+ let(:project) { create(:project, :public, :repository) }
+
+ def visit_commit(sha, anchor: nil)
+ visit project_commit_path(project, sha, anchor: anchor)
+
+ wait_for_requests
+ end
+
+ context 'Ruby file' do
+ before do
+ visit_commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
+ end
+
+ it 'shows highlighted Ruby code' do
+ within('.diff-file[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd"]') do
+ expect(page).to have_css(".js-syntax-highlight")
+ expect(page).to have_content("def popen(cmd, path=nil)")
+ end
+ end
+ end
+
+ context 'Ruby file (stored in LFS)' do
+ before do
+ project.add_master(project.creator)
+
+ @commit_id = Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'master',
+ branch_name: 'master',
+ commit_message: "Add Ruby file in LFS",
+ file_path: 'files/lfs/ruby.rb',
+ file_content: project.repository.blob_at('master', 'files/lfs/lfs_object.iso').data
+ ).execute[:result]
+ end
+
+ context 'when LFS is enabled on the project' do
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ project.update_attribute(:lfs_enabled, true)
+
+ visit_commit(@commit_id)
+ end
+
+ it 'shows an error message' do
+ expect(page).to have_content('This source diff could not be displayed because it is stored in LFS. You can view the blob instead.')
+ end
+ end
+
+ context 'when LFS is disabled on the project' do
+ before do
+ visit_commit(@commit_id)
+ end
+
+ it 'displays the diff' do
+ expect(page).to have_content('size 1575078')
+ end
+ end
+ end
+
+ context 'Image file' do
+ before do
+ visit_commit('2f63565e7aac07bcdadb654e253078b727143ec4')
+ end
+
+ it 'shows a rendered image' do
+ within('.diff-file[id="e986451b8f7397b617dbb6fffcb5539328c56921"]') do
+ expect(page).to have_css('img[alt="files/images/6049019_460s.jpg"]')
+ end
+ end
+ end
+
+ context 'ISO file (stored in LFS)' do
+ context 'when LFS is enabled on the project' do
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ project.update_attribute(:lfs_enabled, true)
+
+ visit_commit('048721d90c449b244b7b4c53a9186b04330174ec')
+ end
+
+ it 'shows that file was added' do
+ expect(page).to have_content('File added')
+ end
+ end
+
+ context 'when LFS is disabled on the project' do
+ before do
+ visit_commit('048721d90c449b244b7b4c53a9186b04330174ec')
+ end
+
+ it 'displays the diff' do
+ expect(page).to have_content('size 1575078')
+ end
+ end
+ end
+
+ context 'ZIP file' do
+ before do
+ visit_commit('ae73cb07c9eeaf35924a10f713b364d32b2dd34f')
+ end
+
+ it 'shows that file was added' do
+ expect(page).to have_content('File added')
+ end
+ end
+
+ context 'binary file that appears to be text in the first 1024 bytes' do
+ before do
+ # The file we're visiting is smaller than 10 KB and we want it collapsed
+ # so we need to disable the size increase feature.
+ stub_feature_flags(gitlab_git_diff_size_limit_increase: false)
+
+ visit_commit('7b1cf4336b528e0f3d1d140ee50cafdbc703597c')
+ end
+
+ it 'shows the diff is collapsed' do
+ expect(page).to have_content('This diff is collapsed. Click to expand it.')
+ end
+
+ context 'expanding the diff' do
+ before do
+ # We can't use `click_link` because the "link" doesn't have an `href`.
+ find('a.click-to-expand').click
+
+ wait_for_requests
+ end
+
+ it 'shows there is no preview' do
+ expect(page).to have_content('No preview for this file type')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/edit_spec.rb b/spec/features/projects/edit_spec.rb
index a263781c43c..d3b1d1f7be3 100644
--- a/spec/features/projects/edit_spec.rb
+++ b/spec/features/projects/edit_spec.rb
@@ -1,14 +1,14 @@
require 'rails_helper'
-feature 'Project edit', feature: true, js: true do
+feature 'Project edit', js: true do
let(:user) { create(:user) }
let(:project) { create(:project) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
end
context 'feature visibility' do
diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb
index ee925e811e1..82a722c5960 100644
--- a/spec/features/projects/environments/environment_metrics_spec.rb
+++ b/spec/features/projects/environments/environment_metrics_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Environment > Metrics', :feature do
+feature 'Environment > Metrics' do
include PrometheusHelpers
given(:user) { create(:user) }
@@ -15,7 +15,7 @@ feature 'Environment > Metrics', :feature do
create(:deployment, environment: environment, deployable: build)
stub_all_prometheus_requests(environment.slug)
- login_as(user)
+ sign_in(user)
visit_environment(environment)
end
@@ -27,13 +27,11 @@ feature 'Environment > Metrics', :feature do
scenario 'shows metrics' do
click_link('See metrics')
- expect(page).to have_css('svg.prometheus-graph')
+ expect(page).to have_css('div#prometheus-graphs')
end
end
def visit_environment(environment)
- visit namespace_project_environment_path(environment.project.namespace,
- environment.project,
- environment)
+ visit project_environment_path(environment.project, environment)
end
end
diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb
index acc3efe04e6..56addd64056 100644
--- a/spec/features/projects/environments/environment_spec.rb
+++ b/spec/features/projects/environments/environment_spec.rb
@@ -1,17 +1,18 @@
require 'spec_helper'
-feature 'Environment', :feature do
- given(:project) { create(:empty_project) }
+feature 'Environment' do
+ given(:project) { create(:project) }
given(:user) { create(:user) }
given(:role) { :developer }
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
end
feature 'environment details page' do
given!(:environment) { create(:environment, project: project) }
+ given!(:permissions) { }
given!(:deployment) { }
given!(:action) { }
@@ -62,18 +63,31 @@ feature 'Environment', :feature do
name: 'deploy to production')
end
- scenario 'does show a play button' do
- expect(page).to have_link(action.name.humanize)
- end
+ context 'when user has ability to trigger deployment' do
+ given(:permissions) do
+ create(:protected_branch, :developers_can_merge,
+ name: action.ref, project: project)
+ end
- scenario 'does allow to play manual action' do
- expect(action).to be_manual
+ it 'does show a play button' do
+ expect(page).to have_link(action.name.humanize)
+ end
- expect { click_link(action.name.humanize) }
- .not_to change { Ci::Pipeline.count }
+ it 'does allow to play manual action' do
+ expect(action).to be_manual
- expect(page).to have_content(action.name)
- expect(action.reload).to be_pending
+ expect { click_link(action.name.humanize) }
+ .not_to change { Ci::Pipeline.count }
+
+ expect(page).to have_content(action.name)
+ expect(action.reload).to be_pending
+ end
+ end
+
+ context 'when user has no ability to trigger a deployment' do
+ it 'does not show a play button' do
+ expect(page).not_to have_link(action.name.humanize)
+ end
end
context 'with external_url' do
@@ -100,7 +114,7 @@ feature 'Environment', :feature do
before do
# Stub #terminals as it causes js-enabled feature specs to render the page incorrectly
allow_any_instance_of(Environment).to receive(:terminals) { nil }
- visit terminal_namespace_project_environment_path(project.namespace, project, environment)
+ visit terminal_project_environment_path(project, environment)
end
it 'displays a web terminal' do
@@ -132,10 +146,23 @@ feature 'Environment', :feature do
on_stop: 'close_app')
end
- scenario 'does allow to stop environment' do
- click_link('Stop')
+ context 'when user has ability to stop environment' do
+ given(:permissions) do
+ create(:protected_branch, :developers_can_merge,
+ name: action.ref, project: project)
+ end
- expect(page).to have_content('close_app')
+ it 'allows to stop environment' do
+ click_link('Stop')
+
+ expect(page).to have_content('close_app')
+ end
+ end
+
+ context 'when user has no ability to stop environment' do
+ it 'does not allow to stop environment' do
+ expect(page).to have_no_link('Stop')
+ end
end
context 'for reporter' do
@@ -146,12 +173,6 @@ feature 'Environment', :feature do
end
end
end
-
- context 'without stop action' do
- scenario 'does allow to stop environment' do
- click_link('Stop')
- end
- end
end
context 'when environment is stopped' do
@@ -173,9 +194,7 @@ feature 'Environment', :feature do
name: 'staging-1.0/review',
state: :available)
- visit folder_namespace_project_environments_path(project.namespace,
- project,
- id: 'staging-1.0')
+ visit folder_project_environments_path(project, id: 'staging-1.0')
end
it 'renders a correct environment folder' do
@@ -186,7 +205,7 @@ feature 'Environment', :feature do
end
feature 'auto-close environment when branch is deleted' do
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given!(:environment) do
create(:environment, :with_review_app, project: project,
@@ -200,7 +219,7 @@ feature 'Environment', :feature do
end
scenario 'user deletes the branch with running environment' do
- visit namespace_project_branches_path(project.namespace, project)
+ visit project_branches_path(project, search: 'feature')
remove_branch_with_hooks(project, user, 'feature') do
page.within('.js-branch-feature') { find('a.btn-remove').click }
@@ -228,12 +247,10 @@ feature 'Environment', :feature do
end
def visit_environment(environment)
- visit namespace_project_environment_path(environment.project.namespace,
- environment.project,
- environment)
+ visit project_environment_path(environment.project, environment)
end
def have_terminal_button
- have_link(nil, href: terminal_namespace_project_environment_path(project.namespace, project, environment))
+ have_link(nil, href: terminal_project_environment_path(project, environment))
end
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index cf393afccbb..1c59e57c0a4 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Environments page', :feature, :js do
- given(:project) { create(:empty_project) }
+feature 'Environments page', :js do
+ given(:project) { create(:project) }
given(:user) { create(:user) }
given(:role) { :developer }
background do
project.team << [user, role]
- login_as(user)
+ sign_in(user)
end
given!(:environment) { }
@@ -29,15 +29,15 @@ feature 'Environments page', :feature, :js do
describe 'in available tab page' do
it 'should show one environment' do
- visit namespace_project_environments_path(project.namespace, project, scope: 'available')
+ visit project_environments_path(project, scope: 'available')
expect(page).to have_css('.environments-container')
- expect(page.all('tbody > tr').length).to eq(1)
+ expect(page.all('.environment-name').length).to eq(1)
end
end
describe 'in stopped tab page' do
it 'should show no environments' do
- visit namespace_project_environments_path(project.namespace, project, scope: 'stopped')
+ visit project_environments_path(project, scope: 'stopped')
expect(page).to have_css('.environments-container')
expect(page).to have_content('You don\'t have any environments right now')
end
@@ -49,7 +49,7 @@ feature 'Environments page', :feature, :js do
describe 'in available tab page' do
it 'should show no environments' do
- visit namespace_project_environments_path(project.namespace, project, scope: 'available')
+ visit project_environments_path(project, scope: 'available')
expect(page).to have_css('.environments-container')
expect(page).to have_content('You don\'t have any environments right now')
end
@@ -57,9 +57,9 @@ feature 'Environments page', :feature, :js do
describe 'in stopped tab page' do
it 'should show one environment' do
- visit namespace_project_environments_path(project.namespace, project, scope: 'stopped')
+ visit project_environments_path(project, scope: 'stopped')
expect(page).to have_css('.environments-container')
- expect(page.all('tbody > tr').length).to eq(1)
+ expect(page.all('.environment-name').length).to eq(1)
end
end
end
@@ -111,7 +111,7 @@ feature 'Environments page', :feature, :js do
end
context 'with deployments' do
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given(:deployment) do
create(:deployment, environment: environment,
@@ -151,7 +151,7 @@ feature 'Environments page', :feature, :js do
find('.js-dropdown-play-icon-container').click
expect(page).to have_content(action.name.humanize)
- expect { find('.js-manual-action-link').click }
+ expect { find('.js-manual-action-link').trigger('click') }
.not_to change { Ci::Pipeline.count }
end
@@ -239,7 +239,9 @@ feature 'Environments page', :feature, :js do
context 'when logged as developer' do
before do
- click_link 'New environment'
+ within(".top-area") do
+ click_link 'New environment'
+ end
end
context 'for valid name' do
@@ -275,10 +277,10 @@ feature 'Environments page', :feature, :js do
end
def have_terminal_button
- have_link(nil, href: terminal_namespace_project_environment_path(project.namespace, project, environment))
+ have_link(nil, href: terminal_project_environment_path(project, environment))
end
def visit_environments(project)
- visit namespace_project_environments_path(project.namespace, project)
+ visit project_environments_path(project)
end
end
diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb
index b080a8d500e..24691629063 100644
--- a/spec/features/projects/features_visibility_spec.rb
+++ b/spec/features/projects/features_visibility_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe 'Edit Project Settings', feature: true do
+describe 'Edit Project Settings' do
let(:member) { create(:user) }
- let!(:project) { create(:project, :public, path: 'gitlab', name: 'sample') }
+ let!(:project) { create(:project, :public, :repository) }
let!(:issue) { create(:issue, project: project) }
let(:non_member) { create(:user) }
describe 'project features visibility selectors', js: true do
before do
project.team << [member, :master]
- login_as(member)
+ sign_in(member)
end
tools = { builds: "pipelines", issues: "issues", wiki: "wiki", snippets: "snippets", merge_requests: "merge_requests" }
@@ -17,42 +17,57 @@ describe 'Edit Project Settings', feature: true do
tools.each do |tool_name, shortcut_name|
describe "feature #{tool_name}" do
it 'toggles visibility' do
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
select 'Disabled', from: "project_project_feature_attributes_#{tool_name}_access_level"
- click_button 'Save changes'
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button 'Save changes'
+ end
+ wait_for_requests
expect(page).not_to have_selector(".shortcuts-#{shortcut_name}")
select 'Everyone with access', from: "project_project_feature_attributes_#{tool_name}_access_level"
- click_button 'Save changes'
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button 'Save changes'
+ end
+ wait_for_requests
expect(page).to have_selector(".shortcuts-#{shortcut_name}")
select 'Only team members', from: "project_project_feature_attributes_#{tool_name}_access_level"
- click_button 'Save changes'
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button 'Save changes'
+ end
+ wait_for_requests
expect(page).to have_selector(".shortcuts-#{shortcut_name}")
-
- sleep 0.1
end
end
end
- context "When external issue tracker is enabled" do
- it "does not hide issues tab" do
- project.project_feature.update(issues_access_level: ProjectFeature::DISABLED)
+ context 'When external issue tracker is enabled and issues enabled on project settings' do
+ it 'does not hide issues tab' do
+ allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(JiraService.new)
+
+ visit project_path(project)
+
+ expect(page).to have_selector('.shortcuts-issues')
+ end
+ end
+
+ context 'When external issue tracker is enabled and issues disabled on project settings' do
+ it 'hides issues tab' do
+ project.issues_enabled = false
+ project.save!
allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(JiraService.new)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
- expect(page).to have_selector(".shortcuts-issues")
+ expect(page).not_to have_selector('.shortcuts-issues')
end
end
context "pipelines subtabs" do
it "shows builds when enabled" do
- visit namespace_project_pipelines_path(project.namespace, project)
+ visit project_pipelines_path(project)
expect(page).to have_selector(".shortcuts-builds")
end
@@ -60,7 +75,7 @@ describe 'Edit Project Settings', feature: true do
it "hides builds when disabled" do
allow(Ability).to receive(:allowed?).with(member, :read_builds, project).and_return(false)
- visit namespace_project_pipelines_path(project.namespace, project)
+ visit project_pipelines_path(project)
expect(page).not_to have_selector(".shortcuts-builds")
end
@@ -68,20 +83,26 @@ describe 'Edit Project Settings', feature: true do
end
describe 'project features visibility pages' do
- before do
- @tools =
- {
- builds: namespace_project_pipelines_path(project.namespace, project),
- issues: namespace_project_issues_path(project.namespace, project),
- wiki: namespace_project_wiki_path(project.namespace, project, :home),
- snippets: namespace_project_snippets_path(project.namespace, project),
- merge_requests: namespace_project_merge_requests_path(project.namespace, project),
- }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ let(:tools) do
+ {
+ builds: project_job_path(project, job),
+ issues: project_issues_path(project),
+ wiki: project_wiki_path(project, :home),
+ snippets: project_snippets_path(project),
+ merge_requests: project_merge_requests_path(project)
+ }
end
context 'normal user' do
+ before do
+ sign_in(member)
+ end
+
it 'renders 200 if tool is enabled' do
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::ENABLED)
visit url
expect(page.status_code).to eq(200)
@@ -89,7 +110,7 @@ describe 'Edit Project Settings', feature: true do
end
it 'renders 404 if feature is disabled' do
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED)
visit url
expect(page.status_code).to eq(404)
@@ -99,21 +120,21 @@ describe 'Edit Project Settings', feature: true do
it 'renders 404 if feature is enabled only for team members' do
project.team.truncate
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE)
visit url
expect(page.status_code).to eq(404)
end
end
- it 'renders 200 if users is member of group' do
+ it 'renders 200 if user is member of group' do
group = create(:group)
project.group = group
project.save
group.add_owner(member)
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE)
visit url
expect(page.status_code).to eq(200)
@@ -124,11 +145,11 @@ describe 'Edit Project Settings', feature: true do
context 'admin user' do
before do
non_member.update_attribute(:admin, true)
- login_as(non_member)
+ sign_in(non_member)
end
it 'renders 404 if feature is disabled' do
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED)
visit url
expect(page.status_code).to eq(404)
@@ -138,7 +159,7 @@ describe 'Edit Project Settings', feature: true do
it 'renders 200 if feature is enabled only for team members' do
project.team.truncate
- @tools.each do |method_name, url|
+ tools.each do |method_name, url|
project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE)
visit url
expect(page.status_code).to eq(200)
@@ -150,14 +171,18 @@ describe 'Edit Project Settings', feature: true do
describe 'repository visibility', js: true do
before do
project.team << [member, :master]
- login_as(member)
- visit edit_namespace_project_path(project.namespace, project)
+ sign_in(member)
+ visit edit_project_path(project)
end
it "disables repository related features" do
select "Disabled", from: "project_project_feature_attributes_repository_access_level"
- expect(find(".edit-project")).to have_selector("select.disabled", count: 2)
+ page.within('.sharing-permissions') do
+ click_button "Save changes"
+ end
+
+ expect(find(".sharing-permissions")).to have_selector("select.disabled", count: 2)
end
it "shows empty features project homepage" do
@@ -165,10 +190,12 @@ describe 'Edit Project Settings', feature: true do
select "Disabled", from: "project_project_feature_attributes_issues_access_level"
select "Disabled", from: "project_project_feature_attributes_wiki_access_level"
- click_button "Save changes"
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button "Save changes"
+ end
+ wait_for_requests
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
expect(page).to have_content "Customize your workflow!"
end
@@ -178,10 +205,12 @@ describe 'Edit Project Settings', feature: true do
select "Disabled", from: "project_project_feature_attributes_issues_access_level"
select "Disabled", from: "project_project_feature_attributes_wiki_access_level"
- click_button "Save changes"
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button "Save changes"
+ end
+ wait_for_requests
- visit activity_namespace_project_path(project.namespace, project)
+ visit activity_project_path(project)
page.within(".event-filter") do
expect(page).to have_selector("a", count: 2)
@@ -199,7 +228,7 @@ describe 'Edit Project Settings', feature: true do
expect(page).to have_content("Comments")
end
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
select "Disabled", from: "project_project_feature_attributes_merge_requests_access_level"
@@ -207,7 +236,7 @@ describe 'Edit Project Settings', feature: true do
expect(page).to have_content("Comments")
end
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
select "Disabled", from: "project_project_feature_attributes_repository_access_level"
@@ -215,14 +244,16 @@ describe 'Edit Project Settings', feature: true do
expect(page).not_to have_content("Comments")
end
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
end
def save_changes_and_check_activity_tab
- click_button "Save changes"
- wait_for_ajax
+ page.within('.sharing-permissions') do
+ click_button "Save changes"
+ end
+ wait_for_requests
- visit activity_namespace_project_path(project.namespace, project)
+ visit activity_project_path(project)
page.within(".event-filter") do
yield
@@ -236,8 +267,8 @@ describe 'Edit Project Settings', feature: true do
before do
project.team << [member, :guest]
- login_as(member)
- visit namespace_project_path(project.namespace, project)
+ sign_in(member)
+ visit project_path(project)
end
it "does not show project statistic for guest" do
diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb
index d281043caa3..f62a9edd37e 100644
--- a/spec/features/projects/files/browse_files_spec.rb
+++ b/spec/features/projects/files/browse_files_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-feature 'user browses project', feature: true do
- let(:project) { create(:project) }
+feature 'user browses project', js: true do
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_tree_path(project.namespace, project, project.default_branch)
+ sign_in(user)
+ visit project_tree_path(project, project.default_branch)
end
scenario "can see blame of '.gitignore'" do
click_link ".gitignore"
click_link 'Blame'
-
+
expect(page).to have_content "*.rb"
expect(page).to have_content "Dmitriy Zaporozhets"
expect(page).to have_content "Initial commit"
@@ -24,10 +24,23 @@ feature 'user browses project', feature: true do
click_link 'files'
click_link 'lfs'
click_link 'lfs_object.iso'
+ wait_for_requests
expect(page).not_to have_content 'Download (1.5 MB)'
expect(page).to have_content 'version https://git-lfs.github.com/spec/v1'
expect(page).to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897'
expect(page).to have_content 'size 1575078'
end
+
+ scenario 'can see last commit for current directory' do
+ last_commit = project.repository.last_commit_for_path(project.default_branch, 'files')
+
+ click_link 'files'
+ wait_for_requests
+
+ page.within('.blob-commit-info') do
+ expect(page).to have_content last_commit.short_id
+ expect(page).to have_content last_commit.author_name
+ end
+ end
end
diff --git a/spec/features/projects/files/creating_a_file_spec.rb b/spec/features/projects/files/creating_a_file_spec.rb
index 69744ac3948..e13bf4b6089 100644
--- a/spec/features/projects/files/creating_a_file_spec.rb
+++ b/spec/features/projects/files/creating_a_file_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'User wants to create a file', feature: true do
- let(:project) { create(:project) }
+feature 'User wants to create a file' do
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
background do
project.team << [user, :master]
- login_as user
- visit namespace_project_new_blob_path(project.namespace, project, project.default_branch)
+ sign_in user
+ visit project_new_blob_path(project, project.default_branch)
end
def submit_new_file(options)
@@ -30,11 +30,6 @@ feature 'User wants to create a file', feature: true do
expect(page).to have_content 'The file has been successfully created'
end
- scenario 'file name contains invalid characters' do
- submit_new_file(file_name: '\\')
- expect(page).to have_content 'Path can contain only'
- end
-
scenario 'file name contains directory traversal' do
submit_new_file(file_name: '../README.md')
expect(page).to have_content 'Path cannot include directory traversal'
diff --git a/spec/features/projects/files/dockerfile_dropdown_spec.rb b/spec/features/projects/files/dockerfile_dropdown_spec.rb
index 548131c7cd4..cebb238dda1 100644
--- a/spec/features/projects/files/dockerfile_dropdown_spec.rb
+++ b/spec/features/projects/files/dockerfile_dropdown_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
require 'fileutils'
-feature 'User wants to add a Dockerfile file', feature: true do
+feature 'User wants to add a Dockerfile file' do
before do
user = create(:user)
- project = create(:project)
+ project = create(:project, :repository)
project.team << [user, :master]
- login_as user
+ sign_in user
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: 'Dockerfile')
+ visit project_new_blob_path(project, 'master', file_name: 'Dockerfile')
end
scenario 'user can see Dockerfile dropdown' do
@@ -19,14 +19,14 @@ feature 'User wants to add a Dockerfile file', feature: true do
scenario 'user can pick a Dockerfile file from the dropdown', js: true do
find('.js-dockerfile-selector').click
- wait_for_ajax
+ wait_for_requests
within '.dockerfile-selector' do
find('.dropdown-input-field').set('HTTPd')
find('.dropdown-content li', text: 'HTTPd').click
end
- wait_for_ajax
+ wait_for_requests
expect(page).to have_css('.dockerfile-selector .dropdown-toggle-text', text: 'HTTPd')
expect(page).to have_content('COPY ./ /usr/local/apache2/htdocs/')
diff --git a/spec/features/projects/files/download_buttons_spec.rb b/spec/features/projects/files/download_buttons_spec.rb
index d7c29a7e074..d2382d55c0b 100644
--- a/spec/features/projects/files/download_buttons_spec.rb
+++ b/spec/features/projects/files/download_buttons_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Download buttons in files tree', feature: true do
+feature 'Download buttons in files tree' do
given(:user) { create(:user) }
given(:role) { :developer }
given(:status) { 'success' }
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given(:pipeline) do
create(:ci_pipeline,
@@ -22,21 +22,18 @@ feature 'Download buttons in files tree', feature: true do
end
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
end
describe 'when files tree' do
context 'with artifacts' do
before do
- visit namespace_project_tree_path(
- project.namespace, project, project.default_branch)
+ visit project_tree_path(project, project.default_branch)
end
scenario 'shows download artifacts button' do
- href = latest_succeeded_namespace_project_artifacts_path(
- project.namespace, project, "#{project.default_branch}/download",
- job: 'build')
+ href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href
end
diff --git a/spec/features/projects/files/edit_file_soft_wrap_spec.rb b/spec/features/projects/files/edit_file_soft_wrap_spec.rb
index 012befa7990..c7e3f657639 100644
--- a/spec/features/projects/files/edit_file_soft_wrap_spec.rb
+++ b/spec/features/projects/files/edit_file_soft_wrap_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-feature 'User uses soft wrap whilst editing file', feature: true, js: true do
+feature 'User uses soft wrap whilst editing file', js: true do
before do
user = create(:user)
- project = create(:project)
+ project = create(:project, :repository)
project.team << [user, :master]
- login_as user
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: 'test_file-name')
+ sign_in user
+ visit project_new_blob_path(project, 'master', file_name: 'test_file-name')
editor = find('.file-editor.code')
editor.click
editor.send_keys 'Touch water with paw then recoil in horror chase dog then
diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb
index 7a3afafec29..20be968e89f 100644
--- a/spec/features/projects/files/editing_a_file_spec.rb
+++ b/spec/features/projects/files/editing_a_file_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-feature 'User wants to edit a file', feature: true do
- let(:project) { create(:project) }
+feature 'User wants to edit a file' do
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:commit_params) do
{
@@ -17,8 +17,8 @@ feature 'User wants to edit a file', feature: true do
background do
project.team << [user, :master]
- login_as user
- visit namespace_project_edit_blob_path(project.namespace, project,
+ sign_in user
+ visit project_edit_blob_path(project,
File.join(project.default_branch, '.gitignore'))
end
diff --git a/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb
index 5c8105de4cb..702b99de733 100644
--- a/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb
+++ b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'User views files page', feature: true do
+feature 'User views files page' do
let(:user) { create(:user) }
let(:project) { create(:forked_project_with_submodules) }
before do
project.team << [user, :master]
- login_as user
- visit namespace_project_tree_path(project.namespace, project, project.repository.root_ref)
+ sign_in user
+ visit project_tree_path(project, project.repository.root_ref)
end
scenario 'user sees folders and submodules sorted together, followed by files' do
diff --git a/spec/features/projects/files/find_file_keyboard_spec.rb b/spec/features/projects/files/find_file_keyboard_spec.rb
index e7a6749d8ac..7f97fdb8cc9 100644
--- a/spec/features/projects/files/find_file_keyboard_spec.rb
+++ b/spec/features/projects/files/find_file_keyboard_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-feature 'Find file keyboard shortcuts', feature: true, js: true do
+feature 'Find file keyboard shortcuts', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
- visit namespace_project_find_file_path(project.namespace, project, project.repository.root_ref)
+ visit project_find_file_path(project, project.repository.root_ref)
- wait_for_ajax
+ wait_for_requests
end
it 'opens file when pressing enter key' do
diff --git a/spec/features/projects/files/find_files_spec.rb b/spec/features/projects/files/find_files_spec.rb
index 716b7591b95..57d67b28920 100644
--- a/spec/features/projects/files/find_files_spec.rb
+++ b/spec/features/projects/files/find_files_spec.rb
@@ -1,29 +1,22 @@
require 'spec_helper'
-feature 'Find files button in the tree header', feature: true do
+feature 'Find files button in the tree header' do
given(:user) { create(:user) }
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
background do
- login_as(user)
+ sign_in(user)
project.team << [user, :developer]
end
scenario 'project main screen' do
- visit namespace_project_path(
- project.namespace,
- project
- )
+ visit project_path(project)
expect(page).to have_selector('.tree-controls .shortcuts-find-file')
end
scenario 'project tree screen' do
- visit namespace_project_tree_path(
- project.namespace,
- project,
- project.default_branch
- )
+ visit project_tree_path(project, project.default_branch)
expect(page).to have_selector('.tree-controls .shortcuts-find-file')
end
diff --git a/spec/features/projects/files/gitignore_dropdown_spec.rb b/spec/features/projects/files/gitignore_dropdown_spec.rb
index e59428f8b24..e2044c9d5aa 100644
--- a/spec/features/projects/files/gitignore_dropdown_spec.rb
+++ b/spec/features/projects/files/gitignore_dropdown_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-feature 'User wants to add a .gitignore file', feature: true do
+feature 'User wants to add a .gitignore file' do
before do
user = create(:user)
- project = create(:project)
+ project = create(:project, :repository)
project.team << [user, :master]
- login_as user
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: '.gitignore')
+ sign_in user
+ visit project_new_blob_path(project, 'master', file_name: '.gitignore')
end
scenario 'user can see .gitignore dropdown' do
@@ -15,12 +15,12 @@ feature 'User wants to add a .gitignore file', feature: true do
scenario 'user can pick a .gitignore file from the dropdown', js: true do
find('.js-gitignore-selector').click
- wait_for_ajax
+ wait_for_requests
within '.gitignore-selector' do
find('.dropdown-input-field').set('rails')
find('.dropdown-content li', text: 'Rails').click
end
- wait_for_ajax
+ wait_for_requests
expect(page).to have_css('.gitignore-selector .dropdown-toggle-text', text: 'Rails')
expect(page).to have_content('/.bundle')
diff --git a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
index 85b66b93fba..ab242b0b0b5 100644
--- a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
+++ b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-feature 'User wants to add a .gitlab-ci.yml file', feature: true do
+feature 'User wants to add a .gitlab-ci.yml file' do
before do
user = create(:user)
- project = create(:project)
+ project = create(:project, :repository)
project.team << [user, :master]
- login_as user
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: '.gitlab-ci.yml')
+ sign_in user
+ visit project_new_blob_path(project, 'master', file_name: '.gitlab-ci.yml')
end
scenario 'user can see .gitlab-ci.yml dropdown' do
@@ -15,12 +15,12 @@ feature 'User wants to add a .gitlab-ci.yml file', feature: true do
scenario 'user can pick a template from the dropdown', js: true do
find('.js-gitlab-ci-yml-selector').click
- wait_for_ajax
+ wait_for_requests
within '.gitlab-ci-yml-selector' do
find('.dropdown-input-field').set('Jekyll')
find('.dropdown-content li', text: 'Jekyll').click
end
- wait_for_ajax
+ wait_for_requests
expect(page).to have_css('.gitlab-ci-yml-selector .dropdown-toggle-text', text: 'Jekyll')
expect(page).to have_content('This file is a template, and might need editing before it works on your project')
diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
index 249830921ac..95af263bcac 100644
--- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb
+++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
-feature 'project owner creates a license file', feature: true, js: true do
+feature 'project owner creates a license file', js: true do
let(:project_master) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
background do
project.repository.delete_file(project_master, 'LICENSE',
message: 'Remove LICENSE', branch_name: 'master')
project.team << [project_master, :master]
- login_as(project_master)
- visit namespace_project_path(project.namespace, project)
+ sign_in(project_master)
+ visit project_path(project)
end
scenario 'project master creates a license file manually from a template' do
- visit namespace_project_tree_path(project.namespace, project, project.repository.root_ref)
+ visit project_tree_path(project, project.repository.root_ref)
find('.add-to-tree').click
click_link 'New file'
@@ -30,7 +30,7 @@ feature 'project owner creates a license file', feature: true, js: true do
click_button 'Commit changes'
expect(current_path).to eq(
- namespace_project_blob_path(project.namespace, project, 'master/LICENSE'))
+ project_blob_path(project, 'master/LICENSE'))
expect(page).to have_content('MIT License')
expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
end
@@ -40,7 +40,7 @@ feature 'project owner creates a license file', feature: true, js: true do
expect(page).to have_content('New file')
expect(current_path).to eq(
- namespace_project_new_blob_path(project.namespace, project, 'master'))
+ project_new_blob_path(project, 'master'))
expect(find('#file_name').value).to eq('LICENSE')
expect(page).to have_selector('.license-selector')
@@ -54,7 +54,7 @@ feature 'project owner creates a license file', feature: true, js: true do
click_button 'Commit changes'
expect(current_path).to eq(
- namespace_project_blob_path(project.namespace, project, 'master/LICENSE'))
+ project_blob_path(project, 'master/LICENSE'))
expect(page).to have_content('MIT License')
expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
end
@@ -63,7 +63,7 @@ feature 'project owner creates a license file', feature: true, js: true do
page.within('.js-license-selector-wrap') do
click_button 'Apply a license template'
click_link template
- wait_for_ajax
+ wait_for_requests
end
end
end
diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
index 70a41886985..7bcab01c739 100644
--- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
@@ -1,21 +1,21 @@
require 'spec_helper'
-feature 'project owner sees a link to create a license file in empty project', feature: true, js: true do
+feature 'project owner sees a link to create a license file in empty project', js: true do
let(:project_master) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
background do
project.team << [project_master, :master]
- login_as(project_master)
+ sign_in(project_master)
end
scenario 'project master creates a license file from a template' do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
click_link 'Create empty bare repository'
click_on 'LICENSE'
expect(page).to have_content('New file')
expect(current_path).to eq(
- namespace_project_new_blob_path(project.namespace, project, 'master'))
+ project_new_blob_path(project, 'master'))
expect(find('#file_name').value).to eq('LICENSE')
expect(page).to have_selector('.license-selector')
@@ -31,7 +31,7 @@ feature 'project owner sees a link to create a license file in empty project', f
click_button 'Commit changes'
expect(current_path).to eq(
- namespace_project_blob_path(project.namespace, project, 'master/LICENSE'))
+ project_blob_path(project, 'master/LICENSE'))
expect(page).to have_content('MIT License')
expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
end
@@ -40,7 +40,7 @@ feature 'project owner sees a link to create a license file in empty project', f
page.within('.js-license-selector-wrap') do
click_button 'Apply a license template'
click_link template
- wait_for_ajax
+ wait_for_requests
end
end
end
diff --git a/spec/features/projects/files/template_type_dropdown_spec.rb b/spec/features/projects/files/template_type_dropdown_spec.rb
index 9fcf12e6cb9..48003eeaa87 100644
--- a/spec/features/projects/files/template_type_dropdown_spec.rb
+++ b/spec/features/projects/files/template_type_dropdown_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
feature 'Template type dropdown selector', js: true do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
end
context 'editing a non-matching file' do
@@ -31,7 +31,7 @@ feature 'Template type dropdown selector', js: true do
context 'editing a matching file' do
before do
- visit namespace_project_edit_blob_path(project.namespace, project, File.join(project.default_branch, 'LICENSE'))
+ visit project_edit_blob_path(project, File.join(project.default_branch, 'LICENSE'))
end
scenario 'displayed' do
@@ -61,7 +61,7 @@ feature 'Template type dropdown selector', js: true do
context 'creating a matching file' do
before do
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: '.gitignore')
+ visit project_new_blob_path(project, 'master', file_name: '.gitignore')
end
scenario 'is displayed' do
@@ -79,7 +79,7 @@ feature 'Template type dropdown selector', js: true do
context 'creating a file' do
before do
- visit namespace_project_new_blob_path(project.namespace, project, project.default_branch)
+ visit project_new_blob_path(project, project.default_branch)
end
scenario 'type selector is shown' do
@@ -129,7 +129,7 @@ def check_type_selector_toggle_text(template_type)
end
def create_and_edit_file(file_name)
- visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: file_name)
+ visit project_new_blob_path(project, 'master', file_name: file_name)
click_button "Commit changes"
- visit namespace_project_edit_blob_path(project.namespace, project, File.join(project.default_branch, file_name))
+ visit project_edit_blob_path(project, File.join(project.default_branch, file_name))
end
diff --git a/spec/features/projects/files/undo_template_spec.rb b/spec/features/projects/files/undo_template_spec.rb
index cd3af0b7d29..9bcd5beabb8 100644
--- a/spec/features/projects/files/undo_template_spec.rb
+++ b/spec/features/projects/files/undo_template_spec.rb
@@ -1,17 +1,17 @@
require 'spec_helper'
feature 'Template Undo Button', js: true do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
end
context 'editing a matching file and applying a template' do
before do
- visit namespace_project_edit_blob_path(project.namespace, project, File.join(project.default_branch, "LICENSE"))
+ visit project_edit_blob_path(project, File.join(project.default_branch, "LICENSE"))
select_file_template('.js-license-selector', 'Apache License 2.0')
end
@@ -20,9 +20,9 @@ feature 'Template Undo Button', js: true do
end
end
- context 'creating a non-matching file' do
+ context 'creating a non-matching file' do
before do
- visit namespace_project_new_blob_path(project.namespace, project, 'master')
+ visit project_new_blob_path(project, 'master')
select_file_template_type('LICENSE')
select_file_template('.js-license-selector', 'Apache License 2.0')
end
@@ -57,7 +57,7 @@ end
def select_file_template(template_selector_selector, template_name)
find(template_selector_selector).click
find('.dropdown-content li', text: template_name).click
- wait_for_ajax
+ wait_for_requests
end
def select_file_template_type(template_type)
diff --git a/spec/features/projects/gfm_autocomplete_load_spec.rb b/spec/features/projects/gfm_autocomplete_load_spec.rb
index dd9622f16a0..cff3b1f5743 100644
--- a/spec/features/projects/gfm_autocomplete_load_spec.rb
+++ b/spec/features/projects/gfm_autocomplete_load_spec.rb
@@ -1,20 +1,20 @@
require 'spec_helper'
-describe 'GFM autocomplete loading', feature: true, js: true do
- let(:project) { create(:project) }
+describe 'GFM autocomplete loading', js: true do
+ let(:project) { create(:project) }
before do
- login_as :admin
+ sign_in(create(:admin))
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
it 'does not load on project#show' do
- expect(evaluate_script('gl.GfmAutoComplete.dataSources')).to eq({})
+ expect(evaluate_script('gl.GfmAutoComplete')).to eq(nil)
end
it 'loads on new issue page' do
- visit new_namespace_project_issue_path(project.namespace, project)
+ visit new_project_issue_path(project)
expect(evaluate_script('gl.GfmAutoComplete.dataSources')).not_to eq({})
end
diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb
index c969acc9140..5195d027a9f 100644
--- a/spec/features/projects/group_links_spec.rb
+++ b/spec/features/projects/group_links_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Project group links', :feature, :js do
+feature 'Project group links', :js do
include Select2Helper
let(:master) { create(:user) }
@@ -9,22 +9,24 @@ feature 'Project group links', :feature, :js do
background do
project.add_master(master)
- login_as(master)
+ sign_in(master)
end
context 'setting an expiration date for a group link' do
before do
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
+
+ click_on 'share-with-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.current + 4.5.days).strftime('%Y-%m-%d')
page.find('body').click
- click_on 'Share'
+ find('.btn-create').trigger('click')
end
it 'shows the expiration time with a warning class' do
- page.within('.enabled-groups') do
- expect(page).to have_content('expires in 4 days')
+ page.within('.project-members-groups') do
+ expect(page).to have_content('Expires in 4 days')
expect(page).to have_selector('.text-warning')
end
end
@@ -40,9 +42,10 @@ feature 'Project group links', :feature, :js do
another_group.add_master(master)
end
- it 'does not show ancestors' do
- visit namespace_project_settings_members_path(project.namespace, project)
+ it 'does not show ancestors', :nested_groups do
+ visit project_settings_members_path(project)
+ click_on 'share-with-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
@@ -58,7 +61,7 @@ feature 'Project group links', :feature, :js do
group.add_owner(master)
group_two.add_owner(master)
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
execute_script 'GroupsSelect.PER_PAGE = 1;'
open_select2 '#link_group_id'
end
diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb
index 726469daba4..2385e1d9333 100644
--- a/spec/features/projects/guest_navigation_menu_spec.rb
+++ b/spec/features/projects/guest_navigation_menu_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-describe "Guest navigation menu" do
- let(:project) { create(:empty_project, :private, public_builds: false) }
+describe 'Guest navigation menu' do
+ let(:project) { create(:project, :private, public_builds: false) }
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
- login_as(guest)
+ sign_in(guest)
end
- it "shows allowed tabs only" do
- visit namespace_project_path(project.namespace, project)
+ it 'shows allowed tabs only' do
+ visit project_path(project)
- within(".nav-links") do
+ within('.layout-nav') do
expect(page).to have_content 'Project'
expect(page).to have_content 'Issues'
expect(page).to have_content 'Wiki'
@@ -23,4 +23,60 @@ describe "Guest navigation menu" do
expect(page).not_to have_content 'Merge Requests'
end
end
+
+ it 'does not show fork button' do
+ visit project_path(project)
+
+ within('.count-buttons') do
+ expect(page).not_to have_link 'Fork'
+ end
+ end
+
+ it 'does not show clone path' do
+ visit project_path(project)
+
+ within('.project-repo-buttons') do
+ expect(page).not_to have_selector '.project-clone-holder'
+ end
+ end
+
+ describe 'project landing page' do
+ before do
+ project.project_feature.update!(
+ issues_access_level: ProjectFeature::DISABLED,
+ wiki_access_level: ProjectFeature::DISABLED
+ )
+ end
+
+ it 'does not show the project file list landing page' do
+ visit project_path(project)
+
+ expect(page).not_to have_selector '.project-stats'
+ expect(page).not_to have_selector '.project-last-commit'
+ expect(page).not_to have_selector '.project-show-files'
+ expect(page).to have_selector '.project-show-customize_workflow'
+ end
+
+ it 'shows the customize workflow when issues and wiki are disabled' do
+ visit project_path(project)
+
+ expect(page).to have_selector '.project-show-customize_workflow'
+ end
+
+ it 'shows the wiki when enabled' do
+ project.project_feature.update!(wiki_access_level: ProjectFeature::PRIVATE)
+
+ visit project_path(project)
+
+ expect(page).to have_selector '.project-show-wiki'
+ end
+
+ it 'shows the issues when enabled' do
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+
+ visit project_path(project)
+
+ expect(page).to have_selector '.issues-list'
+ end
+ end
end
diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb
index 40caf89dd54..62d244ff259 100644
--- a/spec/features/projects/import_export/export_file_spec.rb
+++ b/spec/features/projects/import_export/export_file_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
# It looks up for any sensitive word inside the JSON, so if a sensitive word is found
# we''l have to either include it adding the model that includes it to the +safe_list+
# or make sure the attribute is blacklisted in the +import_export.yml+ configuration
-feature 'Import/Export - project export integration test', feature: true, js: true do
+feature 'Import/Export - project export integration test', js: true do
include Select2Helper
include ExportFileHelper
@@ -33,17 +33,17 @@ feature 'Import/Export - project export integration test', feature: true, js: tr
context 'admin user' do
before do
- login_as(user)
+ sign_in(user)
end
scenario 'exports a project successfully' do
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
expect(page).to have_content('Export project')
click_link 'Export project'
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
expect(page).to have_content('Download export')
diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb
index 583f479ec18..6a324d32ca7 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Import/Export - project import integration test', feature: true, js: true do
+feature 'Import/Export - project import integration test', js: true do
include Select2Helper
let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') }
@@ -10,7 +10,7 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
end
- after(:each) do
+ after do
FileUtils.rm_rf(export_path, secure: true)
end
@@ -19,7 +19,7 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
let!(:namespace) { create(:namespace, name: "asd", owner: user) }
before do
- login_as(user)
+ gitlab_sign_in(user)
end
scenario 'user imports an exported project successfully' do
@@ -29,8 +29,9 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
fill_in :project_path, with: 'test-project-path', visible: true
click_link 'GitLab export'
- expect(page).to have_content('GitLab project export')
+ expect(page).to have_content('Import an exported GitLab project')
expect(URI.parse(current_url).query).to eq("namespace_id=#{namespace.id}&path=test-project-path")
+ expect(Gitlab::ImportExport).to receive(:import_upload_path).with(filename: /\A\h{32}_test-project-path\z/).and_call_original
attach_file('file', file)
@@ -53,7 +54,6 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
select2(namespace.id, from: '#project_namespace_id')
fill_in :project_path, with: project.name, visible: true
click_link 'GitLab export'
-
attach_file('file', file)
click_on 'Import project'
@@ -61,23 +61,12 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
expect(page).to have_content('Project could not be imported')
end
end
-
- scenario 'project with no name' do
- create(:project, namespace: namespace)
-
- visit new_project_path
-
- select2(namespace.id, from: '#project_namespace_id')
-
- # Check for tooltip disabled import button
- expect(find('.import_gitlab_project')['title']).to eq('Please enter a valid project name.')
- end
end
context 'when limited to the default user namespace' do
let(:user) { create(:user) }
before do
- login_as(user)
+ gitlab_sign_in(user)
end
scenario 'passes correct namespace ID in the URL' do
@@ -98,6 +87,6 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
end
def project_hook_exists?(project)
- Gitlab::Git::Hook.new('post-receive', project.repository.path).exists?
+ Gitlab::Git::Hook.new('post-receive', project).exists?
end
end
diff --git a/spec/features/projects/import_export/namespace_export_file_spec.rb b/spec/features/projects/import_export/namespace_export_file_spec.rb
index cb399ea55df..691b0e1e4ca 100644
--- a/spec/features/projects/import_export/namespace_export_file_spec.rb
+++ b/spec/features/projects/import_export/namespace_export_file_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Import/Export - Namespace export file cleanup', feature: true, js: true do
+feature 'Import/Export - Namespace export file cleanup', js: true do
let(:export_path) { "#{Dir.tmpdir}/import_file_spec" }
let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
background do
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
@@ -16,7 +16,7 @@ feature 'Import/Export - Namespace export file cleanup', feature: true, js: true
context 'admin user' do
before do
- login_as(:admin)
+ sign_in(create(:admin))
end
context 'moving the namespace' do
@@ -48,13 +48,13 @@ feature 'Import/Export - Namespace export file cleanup', feature: true, js: true
end
def setup_export_project
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
expect(page).to have_content('Export project')
click_link 'Export project'
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
expect(page).to have_content('Download export')
end
diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz
index 399c1d478c5..e03e7b88174 100644
--- a/spec/features/projects/import_export/test_project_export.tar.gz
+++ b/spec/features/projects/import_export/test_project_export.tar.gz
Binary files differ
diff --git a/spec/features/projects/issuable_counts_caching_spec.rb b/spec/features/projects/issuable_counts_caching_spec.rb
new file mode 100644
index 00000000000..1804d9dc244
--- /dev/null
+++ b/spec/features/projects/issuable_counts_caching_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe 'Issuable counts caching', :use_clean_rails_memory_store_caching do
+ let!(:member) { create(:user) }
+ let!(:member_2) { create(:user) }
+ let!(:non_member) { create(:user) }
+ let!(:project) { create(:project, :public) }
+ let!(:open_issue) { create(:issue, project: project) }
+ let!(:confidential_issue) { create(:issue, :confidential, project: project, author: non_member) }
+ let!(:closed_issue) { create(:issue, :closed, project: project) }
+
+ before do
+ project.add_developer(member)
+ project.add_developer(member_2)
+ end
+
+ it 'caches issuable counts correctly for non-members' do
+ # We can't use expect_any_instance_of because that uses a single instance.
+ counts = 0
+
+ allow_any_instance_of(IssuesFinder).to receive(:count_by_state).and_wrap_original do |m, *args|
+ counts += 1
+
+ m.call(*args)
+ end
+
+ aggregate_failures 'only counts once on first load with no params, and caches for later loads' do
+ expect { visit project_issues_path(project) }
+ .to change { counts }.by(1)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+ end
+
+ aggregate_failures 'uses counts from cache on load from non-member' do
+ sign_in(non_member)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_out(non_member)
+ end
+
+ aggregate_failures 'does not use the same cache for a member' do
+ sign_in(member)
+
+ expect { visit project_issues_path(project) }
+ .to change { counts }.by(1)
+
+ sign_out(member)
+ end
+
+ aggregate_failures 'uses the same cache for all members' do
+ sign_in(member_2)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_out(member_2)
+ end
+
+ aggregate_failures 'shares caches when params are passed' do
+ expect { visit project_issues_path(project, author_username: non_member.username) }
+ .to change { counts }.by(1)
+
+ sign_in(member)
+
+ expect { visit project_issues_path(project, author_username: non_member.username) }
+ .to change { counts }.by(1)
+
+ sign_in(non_member)
+
+ expect { visit project_issues_path(project, author_username: non_member.username) }
+ .not_to change { counts }
+
+ sign_in(member_2)
+
+ expect { visit project_issues_path(project, author_username: non_member.username) }
+ .not_to change { counts }
+
+ sign_out(member_2)
+ end
+
+ aggregate_failures 'resets caches on issue close' do
+ Issues::CloseService.new(project, member).execute(open_issue)
+
+ expect { visit project_issues_path(project) }
+ .to change { counts }.by(1)
+
+ sign_in(member)
+
+ expect { visit project_issues_path(project) }
+ .to change { counts }.by(1)
+
+ sign_in(non_member)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_in(member_2)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_out(member_2)
+ end
+
+ aggregate_failures 'does not reset caches on issue update' do
+ Issues::UpdateService.new(project, member, title: 'new title').execute(open_issue)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_in(member)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_in(non_member)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_in(member_2)
+
+ expect { visit project_issues_path(project) }
+ .not_to change { counts }
+
+ sign_out(member_2)
+ end
+ end
+end
diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
index d28a853bbc2..d2789d0aa52 100644
--- a/spec/features/projects/issuable_templates_spec.rb
+++ b/spec/features/projects/issuable_templates_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
-feature 'issuable templates', feature: true, js: true do
+feature 'issuable templates', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
before do
project.team << [user, :master]
- login_as user
+ sign_in user
end
context 'user creates an issue using templates' do
let(:template_content) { 'this is a test "bug" template' }
let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) }
- let(:issue) { create(:issue, author: user, assignee: user, project: project) }
+ let(:issue) { create(:issue, author: user, assignees: [user], project: project) }
let(:description_addition) { ' appending to description' }
background do
@@ -28,20 +28,20 @@ feature 'issuable templates', feature: true, js: true do
longtemplate_content,
message: 'added issue template',
branch_name: 'master')
- visit edit_namespace_project_issue_path project.namespace, project, issue
+ visit edit_project_issue_path project, issue
fill_in :'issue[title]', with: 'test issue title'
end
scenario 'user selects "bug" template' do
select_template 'bug'
- wait_for_ajax
+ wait_for_requests
assert_template
save_changes
end
scenario 'user selects "bug" template and then "no template"' do
select_template 'bug'
- wait_for_ajax
+ wait_for_requests
select_option 'No template'
assert_template('')
save_changes('')
@@ -49,7 +49,7 @@ feature 'issuable templates', feature: true, js: true do
scenario 'user selects "bug" template, edits description and then selects "reset template"' do
select_template 'bug'
- wait_for_ajax
+ wait_for_requests
find_field('issue_description').send_keys(description_addition)
assert_template(template_content + description_addition)
select_option 'Reset template'
@@ -61,7 +61,7 @@ feature 'issuable templates', feature: true, js: true do
start_height = page.evaluate_script('$(".markdown-area").outerHeight()')
select_template 'test'
- wait_for_ajax
+ wait_for_requests
end_height = page.evaluate_script('$(".markdown-area").outerHeight()')
@@ -72,7 +72,7 @@ feature 'issuable templates', feature: true, js: true do
context 'user creates an issue using templates, with a prior description' do
let(:prior_description) { 'test issue description' }
let(:template_content) { 'this is a test "bug" template' }
- let(:issue) { create(:issue, author: user, assignee: user, project: project) }
+ let(:issue) { create(:issue, author: user, assignees: [user], project: project) }
background do
project.repository.create_file(
@@ -81,14 +81,14 @@ feature 'issuable templates', feature: true, js: true do
template_content,
message: 'added issue template',
branch_name: 'master')
- visit edit_namespace_project_issue_path project.namespace, project, issue
+ visit edit_project_issue_path project, issue
fill_in :'issue[title]', with: 'test issue title'
fill_in :'issue[description]', with: prior_description
end
scenario 'user selects "bug" template' do
select_template 'bug'
- wait_for_ajax
+ wait_for_requests
assert_template("#{template_content}")
save_changes
end
@@ -105,13 +105,13 @@ feature 'issuable templates', feature: true, js: true do
template_content,
message: 'added merge request template',
branch_name: 'master')
- visit edit_namespace_project_merge_request_path project.namespace, project, merge_request
+ visit edit_project_merge_request_path project, merge_request
fill_in :'merge_request[title]', with: 'test merge request title'
end
scenario 'user selects "feature-proposal" template' do
select_template 'feature-proposal'
- wait_for_ajax
+ wait_for_requests
assert_template
save_changes
end
@@ -120,22 +120,25 @@ feature 'issuable templates', feature: true, js: true do
context 'user creates a merge request from a forked project using templates' do
let(:template_content) { 'this is a test "feature-proposal" template' }
let(:fork_user) { create(:user) }
- let(:fork_project) { create(:project, :public) }
+ let(:fork_project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project, target_project: project) }
background do
- logout
+ sign_out(:user)
+
project.team << [fork_user, :developer]
fork_project.team << [fork_user, :master]
create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project)
- login_as fork_user
+
+ sign_in(fork_user)
+
project.repository.create_file(
fork_user,
'.gitlab/merge_request_templates/feature-proposal.md',
template_content,
message: 'added merge request template',
branch_name: 'master')
- visit edit_namespace_project_merge_request_path project.namespace, project, merge_request
+ visit edit_project_merge_request_path project, merge_request
fill_in :'merge_request[title]', with: 'test merge request title'
end
@@ -143,7 +146,7 @@ feature 'issuable templates', feature: true, js: true do
context 'template exists in target project' do
scenario 'user selects template' do
select_template 'feature-proposal'
- wait_for_ajax
+ wait_for_requests
assert_template
save_changes
end
diff --git a/spec/features/projects/issues/list_spec.rb b/spec/features/projects/issues/list_spec.rb
index 3137af074ca..9fc03f49f5b 100644
--- a/spec/features/projects/issues/list_spec.rb
+++ b/spec/features/projects/issues/list_spec.rb
@@ -2,18 +2,18 @@ require 'spec_helper'
feature 'Issues List' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
background do
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
end
scenario 'user does not see create new list button' do
create(:issue, project: project)
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
expect(page).not_to have_selector('.js-new-board-list')
end
diff --git a/spec/features/projects/issues/rss_spec.rb b/spec/features/projects/issues/rss_spec.rb
index 71429f00095..58eeef8c258 100644
--- a/spec/features/projects/issues/rss_spec.rb
+++ b/spec/features/projects/issues/rss_spec.rb
@@ -1,23 +1,24 @@
require 'spec_helper'
feature 'Project Issues RSS' do
- let(:project) { create(:empty_project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { namespace_project_issues_path(project.namespace, project) }
+ let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+ let(:path) { project_issues_path(project) }
before do
create(:issue, project: project)
end
context 'when signed in' do
+ let(:user) { create(:user) }
+
before do
- user = create(:user)
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "it has an RSS button with current_user's private token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -25,7 +26,7 @@ feature 'Project Issues RSS' do
visit path
end
- it_behaves_like "it has an RSS button without a private token"
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
new file mode 100644
index 00000000000..037ac00d39f
--- /dev/null
+++ b/spec/features/projects/jobs_spec.rb
@@ -0,0 +1,546 @@
+require 'spec_helper'
+require 'tempfile'
+
+feature 'Jobs' do
+ let(:user) { create(:user) }
+ let(:user_access_level) { :developer }
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:job2) { create(:ci_build) }
+
+ let(:artifacts_file) do
+ fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif')
+ end
+
+ before do
+ project.team << [user, user_access_level]
+ sign_in(user)
+ end
+
+ describe "GET /:project/jobs" do
+ let!(:job) { create(:ci_build, pipeline: pipeline) }
+
+ context "Pending scope" do
+ before do
+ visit project_jobs_path(project, scope: :pending)
+ end
+
+ it "shows Pending tab jobs" do
+ expect(page).to have_link 'Cancel running'
+ expect(page).to have_selector('.nav-links li.active', text: 'Pending')
+ expect(page).to have_content job.short_sha
+ expect(page).to have_content job.ref
+ expect(page).to have_content job.name
+ end
+ end
+
+ context "Running scope" do
+ before do
+ job.run!
+ visit project_jobs_path(project, scope: :running)
+ end
+
+ it "shows Running tab jobs" do
+ expect(page).to have_selector('.nav-links li.active', text: 'Running')
+ expect(page).to have_link 'Cancel running'
+ expect(page).to have_content job.short_sha
+ expect(page).to have_content job.ref
+ expect(page).to have_content job.name
+ end
+ end
+
+ context "Finished scope" do
+ before do
+ job.run!
+ visit project_jobs_path(project, scope: :finished)
+ end
+
+ it "shows Finished tab jobs" do
+ expect(page).to have_selector('.nav-links li.active', text: 'Finished')
+ expect(page).to have_content 'No jobs to show'
+ expect(page).to have_link 'Cancel running'
+ end
+ end
+
+ context "All jobs" do
+ before do
+ project.builds.running_or_pending.each(&:success)
+ visit project_jobs_path(project)
+ end
+
+ it "shows All tab jobs" do
+ expect(page).to have_selector('.nav-links li.active', text: 'All')
+ expect(page).to have_content job.short_sha
+ expect(page).to have_content job.ref
+ expect(page).to have_content job.name
+ expect(page).not_to have_link 'Cancel running'
+ end
+ end
+
+ context "when visiting old URL" do
+ let(:jobs_url) do
+ project_jobs_path(project)
+ end
+
+ before do
+ visit jobs_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(jobs_url)
+ end
+ end
+ end
+
+ describe "POST /:project/jobs/:id/cancel_all" do
+ before do
+ job.run!
+ visit project_jobs_path(project)
+ click_link "Cancel running"
+ end
+
+ it 'shows all necessary content' do
+ expect(page).to have_selector('.nav-links li.active', text: 'All')
+ expect(page).to have_content 'canceled'
+ expect(page).to have_content job.short_sha
+ expect(page).to have_content job.ref
+ expect(page).to have_content job.name
+ expect(page).not_to have_link 'Cancel running'
+ end
+ end
+
+ describe "GET /:project/jobs/:id" do
+ context "Job from project" do
+ let(:job) { create(:ci_build, :success, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows status name', :js do
+ expect(page).to have_css('.ci-status.ci-success', text: 'passed')
+ end
+
+ it 'shows commit`s data' do
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content pipeline.sha[0..7]
+ expect(page).to have_content pipeline.git_commit_message
+ expect(page).to have_content pipeline.git_author_name
+ end
+
+ it 'shows active job' do
+ expect(page).to have_selector('.build-job.active')
+ end
+ end
+
+ context 'when job is not running', :js do
+ let(:job) { create(:ci_build, :success, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows retry button' do
+ expect(page).to have_link('Retry')
+ end
+
+ context 'if job passed' do
+ it 'does not show New issue button' do
+ expect(page).not_to have_link('New issue')
+ end
+ end
+
+ context 'if job failed' do
+ let(:job) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows New issue button' do
+ expect(page).to have_link('New issue')
+ end
+
+ it 'links to issues/new with the title and description filled in' do
+ button_title = "Build Failed ##{job.id}"
+ job_path = project_job_path(project, job)
+ options = { issue: { title: button_title, description: job_path } }
+
+ href = new_project_issue_path(project, options)
+
+ page.within('.header-action-buttons') do
+ expect(find('.js-new-issue')['href']).to include(href)
+ end
+ end
+ end
+ end
+
+ context "Job from other project" do
+ before do
+ visit project_job_path(project, job2)
+ end
+
+ it { expect(page.status_code).to eq(404) }
+ end
+
+ context "Download artifacts" do
+ before do
+ job.update_attributes(artifacts_file: artifacts_file)
+ visit project_job_path(project, job)
+ end
+
+ it 'has button to download artifacts' do
+ expect(page).to have_content 'Download'
+ end
+ end
+
+ context 'Artifacts expire date' do
+ before do
+ job.update_attributes(artifacts_file: artifacts_file,
+ artifacts_expire_at: expire_at)
+
+ visit project_job_path(project, job)
+ end
+
+ context 'no expire date defined' do
+ let(:expire_at) { nil }
+
+ it 'does not have the Keep button' do
+ expect(page).not_to have_content 'Keep'
+ end
+ end
+
+ context 'when expire date is defined' do
+ let(:expire_at) { Time.now + 7.days }
+
+ context 'when user has ability to update job' do
+ it 'keeps artifacts when keep button is clicked' do
+ expect(page).to have_content 'The artifacts will be removed'
+
+ click_link 'Keep'
+
+ expect(page).to have_no_link 'Keep'
+ expect(page).to have_no_content 'The artifacts will be removed'
+ end
+ end
+
+ context 'when user does not have ability to update job' do
+ let(:user_access_level) { :guest }
+
+ it 'does not have keep button' do
+ expect(page).to have_no_link 'Keep'
+ end
+ end
+ end
+
+ context 'when artifacts expired' do
+ let(:expire_at) { Time.now - 7.days }
+
+ it 'does not have the Keep button' do
+ expect(page).to have_content 'The artifacts were removed'
+ expect(page).not_to have_link 'Keep'
+ end
+ end
+ end
+
+ context "when visiting old URL" do
+ let(:job_url) do
+ project_job_path(project, job)
+ end
+
+ before do
+ visit job_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(job_url)
+ end
+ end
+
+ feature 'Raw trace' do
+ before do
+ job.run!
+
+ visit project_job_path(project, job)
+ end
+
+ it do
+ expect(page).to have_css('.js-raw-link')
+ end
+ end
+
+ feature 'HTML trace', :js do
+ before do
+ job.run!
+
+ visit project_job_path(project, job)
+ end
+
+ context 'when job has an initial trace' do
+ it 'loads job trace' do
+ expect(page).to have_content 'BUILD TRACE'
+
+ job.trace.write do |stream|
+ stream.append(' and more trace', 11)
+ end
+
+ expect(page).to have_content 'BUILD TRACE and more trace'
+ end
+ end
+ end
+
+ feature 'Variables' do
+ let(:trigger_request) { create(:ci_trigger_request_with_variables) }
+
+ let(:job) do
+ create :ci_build, pipeline: pipeline, trigger_request: trigger_request
+ end
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows variable key and value after click', js: true do
+ expect(page).to have_css('.reveal-variables')
+ expect(page).not_to have_css('.js-build-variable')
+ expect(page).not_to have_css('.js-build-value')
+
+ click_button 'Reveal Variables'
+
+ expect(page).not_to have_css('.reveal-variables')
+ expect(page).to have_selector('.js-build-variable', text: 'TRIGGER_KEY_1')
+ expect(page).to have_selector('.js-build-value', text: 'TRIGGER_VALUE_1')
+ end
+ end
+
+ context 'when job starts environment' do
+ let(:environment) { create(:environment, project: project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'job is successfull and has deployment' do
+ let(:deployment) { create(:deployment) }
+ let(:job) { create(:ci_build, :success, environment: environment.name, deployments: [deployment], pipeline: pipeline) }
+
+ it 'shows a link for the job' do
+ visit project_job_path(project, job)
+
+ expect(page).to have_link environment.name
+ end
+ end
+
+ context 'job is complete and not successful' do
+ let(:job) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) }
+
+ it 'shows a link for the job' do
+ visit project_job_path(project, job)
+
+ expect(page).to have_link environment.name
+ end
+ end
+
+ context 'job creates a new deployment' do
+ let!(:deployment) { create(:deployment, environment: environment, sha: project.commit.id) }
+ let(:job) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) }
+
+ it 'shows a link to latest deployment' do
+ visit project_job_path(project, job)
+
+ expect(page).to have_link('latest deployment')
+ end
+ end
+ end
+ end
+
+ describe "POST /:project/jobs/:id/cancel", :js do
+ context "Job from project" do
+ before do
+ job.run!
+ visit project_job_path(project, job)
+ find('.js-cancel-job').click()
+ end
+
+ it 'loads the page and shows all needed controls' do
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content 'Retry'
+ end
+ end
+ end
+
+ describe "POST /:project/jobs/:id/retry" do
+ context "Job from project", :js do
+ before do
+ job.run!
+ visit project_job_path(project, job)
+ find('.js-cancel-job').click()
+ find('.js-retry-button').trigger('click')
+ end
+
+ it 'shows the right status and buttons', :js do
+ expect(page).to have_http_status(200)
+ page.within('aside.right-sidebar') do
+ expect(page).to have_content 'Cancel'
+ end
+ end
+ end
+
+ context "Job that current user is not allowed to retry" do
+ before do
+ job.run!
+ job.cancel!
+ project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+
+ sign_out(:user)
+ sign_in(create(:user))
+ visit project_job_path(project, job)
+ end
+
+ it 'does not show the Retry button' do
+ page.within('aside.right-sidebar') do
+ expect(page).not_to have_content 'Retry'
+ end
+ end
+ end
+ end
+
+ describe "GET /:project/jobs/:id/download" do
+ before do
+ job.update_attributes(artifacts_file: artifacts_file)
+ visit project_job_path(project, job)
+ click_link 'Download'
+ end
+
+ context "Build from other project" do
+ before do
+ job2.update_attributes(artifacts_file: artifacts_file)
+ visit download_project_job_artifacts_path(project, job2)
+ end
+
+ it { expect(page.status_code).to eq(404) }
+ end
+ end
+
+ describe 'GET /:project/jobs/:id/raw', :js do
+ context 'access source' do
+ context 'job from project' do
+ before do
+ Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
+ job.run!
+ visit project_job_path(project, job)
+ find('.js-raw-link-controller').click()
+ end
+
+ it 'sends the right headers' do
+ expect(page.status_code).to eq(200)
+ expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
+ expect(page.response_headers['X-Sendfile']).to eq(job.trace.send(:current_path))
+ end
+ end
+
+ context 'job from other project' do
+ before do
+ Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
+ job2.run!
+ visit raw_project_job_path(project, job2)
+ end
+
+ it 'sends the right headers' do
+ expect(page.status_code).to eq(404)
+ end
+ end
+ end
+
+ context 'storage form' do
+ let(:existing_file) { Tempfile.new('existing-trace-file').path }
+
+ before do
+ Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
+
+ job.run!
+ end
+
+ context 'when job has trace in file', :js do
+ before do
+ allow_any_instance_of(Gitlab::Ci::Trace)
+ .to receive(:paths)
+ .and_return([existing_file])
+
+ visit project_job_path(project, job)
+
+ find('.js-raw-link-controller').click
+ end
+
+ it 'sends the right headers' do
+ expect(page.status_code).to eq(200)
+ expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
+ expect(page.response_headers['X-Sendfile']).to eq(existing_file)
+ end
+ end
+
+ context 'when job has trace in the database', :js do
+ before do
+ allow_any_instance_of(Gitlab::Ci::Trace)
+ .to receive(:paths)
+ .and_return([])
+
+ visit project_job_path(project, job)
+ end
+
+ it 'sends the right headers' do
+ expect(page).not_to have_selector('.js-raw-link-controller')
+ end
+ end
+ end
+
+ context "when visiting old URL" do
+ let(:raw_job_url) do
+ raw_project_job_path(project, job)
+ end
+
+ before do
+ visit raw_job_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(raw_job_url)
+ end
+ end
+ end
+
+ describe "GET /:project/jobs/:id/trace.json" do
+ context "Job from project" do
+ before do
+ visit trace_project_job_path(project, job, format: :json)
+ end
+
+ it { expect(page.status_code).to eq(200) }
+ end
+
+ context "Job from other project" do
+ before do
+ visit trace_project_job_path(project, job2, format: :json)
+ end
+
+ it { expect(page.status_code).to eq(404) }
+ end
+ end
+
+ describe "GET /:project/jobs/:id/status" do
+ context "Job from project" do
+ before do
+ visit status_project_job_path(project, job)
+ end
+
+ it { expect(page.status_code).to eq(200) }
+ end
+
+ context "Job from other project" do
+ before do
+ visit status_project_job_path(project, job2)
+ end
+
+ it { expect(page.status_code).to eq(404) }
+ end
+ end
+end
diff --git a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb
index e2911a37e40..0292a3192d8 100644
--- a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb
+++ b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Issue prioritization', feature: true do
+feature 'Issue prioritization' do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
@@ -28,8 +28,8 @@ feature 'Issue prioritization', feature: true do
issue_2.labels << label_4
issue_1.labels << label_5
- login_as user
- visit namespace_project_issues_path(project.namespace, project, sort: 'label_priority')
+ sign_in user
+ visit project_issues_path(project, sort: 'label_priority')
# Ensure we are indicating that issues are sorted by priority
expect(page).to have_selector('.dropdown-toggle', text: 'Label priority')
@@ -67,8 +67,8 @@ feature 'Issue prioritization', feature: true do
issue_4.labels << label_4 # 7
issue_6.labels << label_5 # 8 - No priority
- login_as user
- visit namespace_project_issues_path(project.namespace, project, sort: 'label_priority')
+ sign_in user
+ visit project_issues_path(project, sort: 'label_priority')
expect(page).to have_selector('.dropdown-toggle', text: 'Label priority')
diff --git a/spec/features/projects/labels/subscription_spec.rb b/spec/features/projects/labels/subscription_spec.rb
index 3130d87fba5..5716d151250 100644
--- a/spec/features/projects/labels/subscription_spec.rb
+++ b/spec/features/projects/labels/subscription_spec.rb
@@ -1,20 +1,20 @@
require 'spec_helper'
-feature 'Labels subscription', feature: true do
+feature 'Labels subscription' do
let(:user) { create(:user) }
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let!(:bug) { create(:label, project: project, title: 'bug') }
let!(:feature) { create(:group_label, group: group, title: 'feature') }
context 'when signed in' do
before do
project.team << [user, :developer]
- login_as user
+ sign_in user
end
scenario 'users can subscribe/unsubscribe to labels', js: true do
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content('bug')
expect(page).to have_content('feature')
@@ -55,7 +55,7 @@ feature 'Labels subscription', feature: true do
context 'when not signed in' do
it 'users can not subscribe/unsubscribe to labels' do
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content 'bug'
expect(page).to have_content 'feature'
diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb
index 836f81fb16d..8f85e972027 100644
--- a/spec/features/projects/labels/update_prioritization_spec.rb
+++ b/spec/features/projects/labels/update_prioritization_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Prioritize labels', feature: true do
+feature 'Prioritize labels' do
include DragTo
let(:user) { create(:user) }
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let!(:bug) { create(:label, project: project, title: 'bug') }
let!(:wontfix) { create(:label, project: project, title: 'wontfix') }
let!(:feature) { create(:group_label, group: group, title: 'feature') }
@@ -14,17 +14,17 @@ feature 'Prioritize labels', feature: true do
before do
project.team << [user, :developer]
- login_as user
+ sign_in user
end
scenario 'user can prioritize a group label', js: true do
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content('Star labels to start sorting by priority')
page.within('.other-labels') do
all('.js-toggle-priority')[1].click
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_content('feature')
end
@@ -37,13 +37,13 @@ feature 'Prioritize labels', feature: true do
scenario 'user can unprioritize a group label', js: true do
create(:label_priority, project: project, label: feature, priority: 1)
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
page.within('.prioritized-labels') do
expect(page).to have_content('feature')
first('.js-toggle-priority').click
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_content('bug')
end
@@ -53,13 +53,13 @@ feature 'Prioritize labels', feature: true do
end
scenario 'user can prioritize a project label', js: true do
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content('Star labels to start sorting by priority')
page.within('.other-labels') do
first('.js-toggle-priority').click
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_content('bug')
end
@@ -72,13 +72,13 @@ feature 'Prioritize labels', feature: true do
scenario 'user can unprioritize a project label', js: true do
create(:label_priority, project: project, label: bug, priority: 1)
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
page.within('.prioritized-labels') do
expect(page).to have_content('bug')
first('.js-toggle-priority').click
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_content('bug')
end
@@ -92,7 +92,7 @@ feature 'Prioritize labels', feature: true do
create(:label_priority, project: project, label: bug, priority: 1)
create(:label_priority, project: project, label: feature, priority: 2)
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content 'bug'
expect(page).to have_content 'feature'
@@ -107,38 +107,46 @@ feature 'Prioritize labels', feature: true do
end
refresh
- wait_for_ajax
+ wait_for_requests
page.within('.prioritized-labels') do
expect(first('li')).to have_content('feature')
expect(page.all('li').last).to have_content('bug')
end
end
+
+ it 'shows a help message about prioritized labels' do
+ visit project_labels_path(project)
+
+ expect(page).to have_content 'Star a label'
+ end
end
context 'as a guest' do
it 'does not prioritize labels' do
guest = create(:user)
- login_as guest
+ sign_in guest
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content 'feature'
expect(page).not_to have_css('.prioritized-labels')
+ expect(page).not_to have_content 'Star a label'
end
end
context 'as a non signed in user' do
it 'does not prioritize labels' do
- visit namespace_project_labels_path(project.namespace, project)
+ visit project_labels_path(project)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content 'feature'
expect(page).not_to have_css('.prioritized-labels')
+ expect(page).not_to have_content 'Star a label'
end
end
end
diff --git a/spec/features/projects/main/download_buttons_spec.rb b/spec/features/projects/main/download_buttons_spec.rb
index 02198ff3e41..3f2579bb01a 100644
--- a/spec/features/projects/main/download_buttons_spec.rb
+++ b/spec/features/projects/main/download_buttons_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Download buttons in project main page', feature: true do
+feature 'Download buttons in project main page' do
given(:user) { create(:user) }
given(:role) { :developer }
given(:status) { 'success' }
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given(:pipeline) do
create(:ci_pipeline,
@@ -22,20 +22,18 @@ feature 'Download buttons in project main page', feature: true do
end
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
end
describe 'when checking project main page' do
context 'with artifacts' do
before do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
scenario 'shows download artifacts button' do
- href = latest_succeeded_namespace_project_artifacts_path(
- project.namespace, project, "#{project.default_branch}/download",
- job: 'build')
+ href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href
end
diff --git a/spec/features/projects/main/rss_spec.rb b/spec/features/projects/main/rss_spec.rb
index b1a3af612a1..7914180b951 100644
--- a/spec/features/projects/main/rss_spec.rb
+++ b/spec/features/projects/main/rss_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
feature 'Project RSS' do
+ let(:user) { create(:user) }
let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { namespace_project_path(project.namespace, project) }
+ let(:path) { project_path(project) }
context 'when signed in' do
before do
- user = create(:user)
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -20,6 +20,6 @@ feature 'Project RSS' do
visit path
end
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/projects/members/anonymous_user_sees_members_spec.rb b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
index d82cf53c690..bf0990d675d 100644
--- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb
+++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-feature 'Projects > Members > Anonymous user sees members', feature: true do
+feature 'Projects > Members > Anonymous user sees members' do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
background do
project.team << [user, :master]
@@ -11,10 +11,10 @@ feature 'Projects > Members > Anonymous user sees members', feature: true do
end
scenario "anonymous user visits the project's members page and sees the list of members" do
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_project_members_path(project)
expect(current_path).to eq(
- namespace_project_settings_members_path(project.namespace, project))
+ project_project_members_path(project))
expect(page).to have_content(user.name)
end
end
diff --git a/spec/features/projects/members/group_links_spec.rb b/spec/features/projects/members/group_links_spec.rb
index ab2b089db2e..1c348b987d4 100644
--- a/spec/features/projects/members/group_links_spec.rb
+++ b/spec/features/projects/members/group_links_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-feature 'Projects > Members > Anonymous user sees members', feature: true, js: true do
+feature 'Projects > Members > Anonymous user sees members', js: true do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
background do
project.team << [user, :master]
@group_link = create(:project_group_link, project: project, group: group)
- login_as(user)
- visit namespace_project_settings_members_path(project.namespace, project)
+ sign_in(user)
+ visit project_settings_members_path(project)
end
it 'updates group access level' do
@@ -20,9 +20,9 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t
click_link 'Guest'
end
- wait_for_ajax
+ wait_for_requests
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
expect(first('.group_member')).to have_content('Guest')
end
@@ -31,7 +31,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t
tomorrow = Date.today + 3
fill_in "member_expires_at_#{group.id}", with: tomorrow.strftime("%F")
- wait_for_ajax
+ wait_for_requests
page.within(find('li.group_member')) do
expect(page).to have_content('Expires in')
@@ -42,7 +42,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t
page.within(first('.group_member')) do
find('.btn-remove').click
end
- wait_for_ajax
+ wait_for_requests
expect(page).not_to have_selector('.group_member')
end
diff --git a/spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb b/spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb
index b483ba4c54c..6b450fa4e45 100644
--- a/spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb
+++ b/spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-feature 'Projects > Members > Group member cannot leave group project', feature: true do
+feature 'Projects > Members > Group member cannot leave group project' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
background do
group.add_developer(user)
- login_as(user)
- visit namespace_project_path(project.namespace, project)
+ sign_in(user)
+ visit project_path(project)
end
scenario 'user does not see a "Leave project" link' do
diff --git a/spec/features/projects/members/group_member_cannot_request_access_to_his_group_project_spec.rb b/spec/features/projects/members/group_member_cannot_request_access_to_his_group_project_spec.rb
index ff9b6007806..296a80a3c60 100644
--- a/spec/features/projects/members/group_member_cannot_request_access_to_his_group_project_spec.rb
+++ b/spec/features/projects/members/group_member_cannot_request_access_to_his_group_project_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Members > Group member cannot request access to his group project', feature: true do
+feature 'Projects > Members > Group member cannot request access to his group project' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
@@ -41,7 +41,7 @@ feature 'Projects > Members > Group member cannot request access to his group pr
end
def login_and_visit_project_page(user)
- login_as(user)
- visit namespace_project_path(project.namespace, project)
+ sign_in(user)
+ visit project_path(project)
end
end
diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb
index 3385e5972ff..c140fece41d 100644
--- a/spec/features/projects/members/group_members_spec.rb
+++ b/spec/features/projects/members/group_members_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-feature 'Projects members', feature: true do
+feature 'Projects members' do
let(:user) { create(:user) }
let(:developer) { create(:user) }
let(:group) { create(:group, :public, :access_requestable) }
- let(:project) { create(:empty_project, :public, :access_requestable, creator: user, group: group) }
+ let(:project) { create(:project, :public, :access_requestable, creator: user, group: group) }
let(:project_invitee) { create(:project_member, project: project, invite_token: '123', invite_email: 'test1@abc.com', user: nil) }
let(:group_invitee) { create(:group_member, group: group, invite_token: '123', invite_email: 'test2@abc.com', user: nil) }
let(:project_requester) { create(:user) }
@@ -13,13 +13,13 @@ feature 'Projects members', feature: true do
background do
project.team << [developer, :developer]
group.add_owner(user)
- login_as(user)
+ sign_in(user)
end
context 'with a group invitee' do
before do
group_invitee
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
end
scenario 'does not appear in the project members page' do
@@ -33,7 +33,7 @@ feature 'Projects members', feature: true do
before do
group_invitee
project_invitee
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
end
scenario 'shows the project invitee, the project developer, and the group owner' do
@@ -54,7 +54,7 @@ feature 'Projects members', feature: true do
context 'with a group requester' do
before do
group.request_access(group_requester)
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
end
scenario 'does not appear in the project members page' do
@@ -68,7 +68,7 @@ feature 'Projects members', feature: true do
before do
group.request_access(group_requester)
project.request_access(project_requester)
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
end
scenario 'shows the project requester, the project developer, and the group owner' do
diff --git a/spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb b/spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb
index bdeeef57273..c8988aa63a7 100644
--- a/spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb
+++ b/spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Members > Group requester cannot request access to project', feature: true, js: true do
+feature 'Projects > Members > Group requester cannot request access to project', js: true do
let(:user) { create(:user) }
let(:owner) { create(:user) }
let(:group) { create(:group, :public, :access_requestable) }
@@ -8,10 +8,10 @@ feature 'Projects > Members > Group requester cannot request access to project',
background do
group.add_owner(owner)
- login_as(user)
+ sign_in(user)
visit group_path(group)
perform_enqueued_jobs { click_link 'Request Access' }
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
scenario 'group requester does not see the request access / withdraw access request button' do
diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb
index deea34214fb..237c059e595 100644
--- a/spec/features/projects/members/list_spec.rb
+++ b/spec/features/projects/members/list_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Project members list', feature: true do
+feature 'Project members list' do
include Select2Helper
let(:user1) { create(:user, name: 'John Doe') }
@@ -9,7 +9,7 @@ feature 'Project members list', feature: true do
let(:project) { create(:project, namespace: group) }
background do
- login_as(user1)
+ sign_in(user1)
group.add_owner(user1)
end
@@ -85,6 +85,6 @@ feature 'Project members list', feature: true do
end
def visit_members_page
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_settings_members_path(project)
end
end
diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
index 19d14ad9af4..cd621b6b3ce 100644
--- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Members > Master adds member with expiration date', feature: true, js: true do
+feature 'Projects > Members > Master adds member with expiration date', js: true do
include Select2Helper
include ActiveSupport::Testing::TimeHelpers
@@ -10,13 +10,13 @@ feature 'Projects > Members > Master adds member with expiration date', feature:
background do
project.team << [master, :master]
- login_as(master)
+ sign_in(master)
end
scenario 'expiration date is displayed in the members list' do
travel_to Time.zone.parse('2016-08-06 08:00') do
date = 4.days.from_now
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
page.within '.users-project-form' do
select2(new_member.id, from: '#user_ids', multiple: true)
@@ -34,11 +34,11 @@ feature 'Projects > Members > Master adds member with expiration date', feature:
travel_to Time.zone.parse('2016-08-06 08:00') do
date = 3.days.from_now
project.team.add_users([new_member.id], :developer, expires_at: Date.today.to_s(:medium))
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
page.within "#project_member_#{new_member.project_members.first.id}" do
find('.js-access-expiration-date').set date.to_s(:medium)
- wait_for_ajax
+ wait_for_requests
expect(page).to have_content('Expires in 3 days')
end
end
diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb
index 143390b71cd..eb3c8034873 100644
--- a/spec/features/projects/members/master_manages_access_requests_spec.rb
+++ b/spec/features/projects/members/master_manages_access_requests_spec.rb
@@ -1,24 +1,24 @@
require 'spec_helper'
-feature 'Projects > Members > Master manages access requests', feature: true do
+feature 'Projects > Members > Master manages access requests' do
let(:user) { create(:user) }
let(:master) { create(:user) }
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
background do
project.request_access(user)
project.team << [master, :master]
- login_as(master)
+ sign_in(master)
end
scenario 'master can see access requests' do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
expect_visible_access_request(project, user)
end
scenario 'master can grant access' do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
expect_visible_access_request(project, user)
@@ -29,7 +29,7 @@ feature 'Projects > Members > Master manages access requests', feature: true do
end
scenario 'master can deny access' do
- visit namespace_project_project_members_path(project.namespace, project)
+ visit project_project_members_path(project)
expect_visible_access_request(project, user)
diff --git a/spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb b/spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb
index 9564347e733..04806f8fd9e 100644
--- a/spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb
+++ b/spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Projects > Members > Member cannot request access to his project', feature: true do
+feature 'Projects > Members > Member cannot request access to his project' do
let(:member) { create(:user) }
let(:project) { create(:project) }
background do
project.team << [member, :developer]
- login_as(member)
- visit namespace_project_path(project.namespace, project)
+ sign_in(member)
+ visit project_path(project)
end
scenario 'member does not see the request access button' do
diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb
index 5daa932e4e6..1bcf827d33c 100644
--- a/spec/features/projects/members/member_leaves_project_spec.rb
+++ b/spec/features/projects/members/member_leaves_project_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Projects > Members > Member leaves project', feature: true do
+feature 'Projects > Members > Member leaves project' do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
background do
project.team << [user, :developer]
- login_as(user)
- visit namespace_project_path(project.namespace, project)
+ sign_in(user)
+ visit project_path(project)
end
scenario 'user leaves project' do
diff --git a/spec/features/projects/members/owner_cannot_leave_project_spec.rb b/spec/features/projects/members/owner_cannot_leave_project_spec.rb
index b26d55c5d5d..15162d01c44 100644
--- a/spec/features/projects/members/owner_cannot_leave_project_spec.rb
+++ b/spec/features/projects/members/owner_cannot_leave_project_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Projects > Members > Owner cannot leave project', feature: true do
+feature 'Projects > Members > Owner cannot leave project' do
let(:project) { create(:project) }
background do
- login_as(project.owner)
- visit namespace_project_path(project.namespace, project)
+ sign_in(project.owner)
+ visit project_path(project)
end
scenario 'user does not see a "Leave project" link' do
diff --git a/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb b/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb
index 4ca9272b9c1..c27925c8dc4 100644
--- a/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb
+++ b/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Projects > Members > Owner cannot request access to his project', feature: true do
+feature 'Projects > Members > Owner cannot request access to his project' do
let(:project) { create(:project) }
background do
- login_as(project.owner)
- visit namespace_project_path(project.namespace, project)
+ sign_in(project.owner)
+ visit project_path(project)
end
scenario 'owner does not see the request access button' do
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index c7a32a65e49..afa173c59e5 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -1,15 +1,14 @@
require 'spec_helper'
-feature 'Projects > Members > Sorting', feature: true do
+feature 'Projects > Members > Sorting' do
let(:master) { create(:user, name: 'John Doe') }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project, namespace: master.namespace, creator: master) }
background do
- create(:project_member, :master, user: master, project: project, created_at: 5.days.ago)
create(:project_member, :developer, user: developer, project: project, created_at: 3.days.ago)
- login_as(master)
+ sign_in(master)
end
scenario 'sorts alphabetically by default' do
@@ -39,16 +38,16 @@ feature 'Projects > Members > Sorting', feature: true do
scenario 'sorts by last joined' do
visit_members_list(sort: :last_joined)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(master.name)
+ expect(first_member).to include(master.name)
+ expect(second_member).to include(developer.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
end
scenario 'sorts by oldest joined' do
visit_members_list(sort: :oldest_joined)
- expect(first_member).to include(master.name)
- expect(second_member).to include(developer.name)
+ expect(first_member).to include(developer.name)
+ expect(second_member).to include(master.name)
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
end
@@ -68,7 +67,7 @@ feature 'Projects > Members > Sorting', feature: true do
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
end
- scenario 'sorts by recent sign in' do
+ scenario 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
visit_members_list(sort: :recent_sign_in)
expect(first_member).to include(master.name)
@@ -76,7 +75,7 @@ feature 'Projects > Members > Sorting', feature: true do
expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
end
- scenario 'sorts by oldest sign in' do
+ scenario 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
visit_members_list(sort: :oldest_sign_in)
expect(first_member).to include(developer.name)
@@ -85,7 +84,7 @@ feature 'Projects > Members > Sorting', feature: true do
end
def visit_members_list(sort:)
- visit namespace_project_project_members_path(project.namespace.to_param, project, sort: sort)
+ visit project_project_members_path(project, sort: sort)
end
def first_member
diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb
index 1bf8f710b9f..24c9f708456 100644
--- a/spec/features/projects/members/user_requests_access_spec.rb
+++ b/spec/features/projects/members/user_requests_access_spec.rb
@@ -1,19 +1,18 @@
require 'spec_helper'
-feature 'Projects > Members > User requests access', feature: true do
+feature 'Projects > Members > User requests access' do
let(:user) { create(:user) }
- let(:master) { create(:user) }
- let(:project) { create(:project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable, :repository) }
+ let(:master) { project.owner }
background do
- project.team << [master, :master]
- login_as(user)
- visit namespace_project_path(project.namespace, project)
+ sign_in(user)
+ visit project_path(project)
end
scenario 'request access feature is disabled' do
project.update_attributes(request_access_enabled: false)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
expect(page).not_to have_content 'Request Access'
end
@@ -36,7 +35,7 @@ feature 'Projects > Members > User requests access', feature: true do
project.project_feature.update!(repository_access_level: ProjectFeature::PRIVATE,
builds_access_level: ProjectFeature::PRIVATE,
merge_requests_access_level: ProjectFeature::PRIVATE)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
expect(page).to have_content 'Request Access'
end
@@ -47,10 +46,11 @@ feature 'Projects > Members > User requests access', feature: true do
expect(project.requesters.exists?(user_id: user)).to be_truthy
- open_project_settings_menu
- click_link 'Members'
+ page.within('.layout-nav .nav-links') do
+ click_link('Members')
+ end
- visit namespace_project_settings_members_path(project.namespace, project)
+ visit project_project_members_path(project)
page.within('.content') do
expect(page).not_to have_content(user.name)
end
diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb
index 05f3162f13c..85d518c0cc3 100644
--- a/spec/features/projects/merge_request_button_spec.rb
+++ b/spec/features/projects/merge_request_button_spec.rb
@@ -1,36 +1,33 @@
require 'spec_helper'
-feature 'Merge Request button', feature: true do
+feature 'Merge Request button' do
shared_examples 'Merge request button only shown when allowed' do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:forked_project) { create(:project, :public, forked_from_project: project) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:forked_project) { create(:project, :public, :repository, forked_from_project: project) }
context 'not logged in' do
it 'does not show Create merge request button' do
visit url
- within("#content-body") do
- expect(page).not_to have_link(label)
- end
+ expect(page).not_to have_link(label)
end
end
context 'logged in as developer' do
before do
- login_as(user)
- project.team << [user, :developer]
+ sign_in(user)
+ project.add_developer(user)
end
it 'shows Create merge request button' do
- href = new_namespace_project_merge_request_path(project.namespace,
- project,
- merge_request: { source_branch: 'feature',
- target_branch: 'master' })
+ href = project_new_merge_request_path(project,
+ merge_request: { source_branch: 'feature',
+ target_branch: 'master' })
visit url
- within("#content-body") do
+ within('#content-body') do
expect(page).to have_link(label, href: href)
end
end
@@ -43,7 +40,7 @@ feature 'Merge Request button', feature: true do
it 'does not show Create merge request button' do
visit url
- within("#content-body") do
+ within('#content-body') do
expect(page).not_to have_link(label)
end
end
@@ -52,13 +49,13 @@ feature 'Merge Request button', feature: true do
context 'logged in as non-member' do
before do
- login_as(user)
+ sign_in(user)
end
it 'does not show Create merge request button' do
visit url
- within("#content-body") do
+ within('#content-body') do
expect(page).not_to have_link(label)
end
end
@@ -67,10 +64,9 @@ feature 'Merge Request button', feature: true do
let(:user) { forked_project.owner }
it 'shows Create merge request button' do
- href = new_namespace_project_merge_request_path(forked_project.namespace,
- forked_project,
- merge_request: { source_branch: 'feature',
- target_branch: 'master' })
+ href = project_new_merge_request_path(forked_project,
+ merge_request: { source_branch: 'feature',
+ target_branch: 'master' })
visit fork_url
@@ -85,24 +81,24 @@ feature 'Merge Request button', feature: true do
context 'on branches page' do
it_behaves_like 'Merge request button only shown when allowed' do
let(:label) { 'Merge request' }
- let(:url) { namespace_project_branches_path(project.namespace, project) }
- let(:fork_url) { namespace_project_branches_path(forked_project.namespace, forked_project) }
+ let(:url) { project_branches_path(project, search: 'feature') }
+ let(:fork_url) { project_branches_path(forked_project, search: 'feature') }
end
end
context 'on compare page' do
it_behaves_like 'Merge request button only shown when allowed' do
let(:label) { 'Create merge request' }
- let(:url) { namespace_project_compare_path(project.namespace, project, from: 'master', to: 'feature') }
- let(:fork_url) { namespace_project_compare_path(forked_project.namespace, forked_project, from: 'master', to: 'feature') }
+ let(:url) { project_compare_path(project, from: 'master', to: 'feature') }
+ let(:fork_url) { project_compare_path(forked_project, from: 'master', to: 'feature') }
end
end
context 'on commits page' do
it_behaves_like 'Merge request button only shown when allowed' do
let(:label) { 'Create merge request' }
- let(:url) { namespace_project_commits_path(project.namespace, project, 'feature') }
- let(:fork_url) { namespace_project_commits_path(forked_project.namespace, forked_project, 'feature') }
+ let(:url) { project_commits_path(project, 'feature') }
+ let(:fork_url) { project_commits_path(forked_project, 'feature') }
end
end
end
diff --git a/spec/features/projects/merge_requests/list_spec.rb b/spec/features/projects/merge_requests/list_spec.rb
index 7e8a796c55d..a879efef4b5 100644
--- a/spec/features/projects/merge_requests/list_spec.rb
+++ b/spec/features/projects/merge_requests/list_spec.rb
@@ -2,39 +2,39 @@ require 'spec_helper'
feature 'Merge Requests List' do
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
background do
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
end
scenario 'user does not see create new list button' do
create(:merge_request, source_project: project)
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).not_to have_selector('.js-new-board-list')
end
it 'should show an empty state' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).to have_selector('.empty-state')
end
it 'empty state should have a create merge request button' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
- expect(page).to have_link 'New merge request', href: new_namespace_project_merge_request_path(project.namespace, project)
+ expect(page).to have_link 'New merge request', href: project_new_merge_request_path(project)
end
context 'if there are merge requests' do
before do
create(:merge_request, assignee: user, source_project: project)
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
end
it 'should not show an empty state' do
diff --git a/spec/features/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb
index 5e19907eef9..30de3e83fbb 100644
--- a/spec/features/projects/milestones/milestone_spec.rb
+++ b/spec/features/projects/milestones/milestone_spec.rb
@@ -1,17 +1,17 @@
require 'spec_helper'
-feature 'Project milestone', :feature do
+feature 'Project milestone' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, name: 'test', namespace: user.namespace) }
+ let(:project) { create(:project, name: 'test', namespace: user.namespace) }
let(:milestone) { create(:milestone, project: project) }
before do
- login_as(user)
+ sign_in(user)
end
context 'when project has enabled issues' do
before do
- visit namespace_project_milestone_path(project.namespace, project, milestone)
+ visit project_milestone_path(project, milestone)
end
it 'shows issues tab' do
@@ -38,7 +38,7 @@ feature 'Project milestone', :feature do
context 'when project has disabled issues' do
before do
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
- visit namespace_project_milestone_path(project.namespace, project, milestone)
+ visit project_milestone_path(project, milestone)
end
it 'hides issues tab' do
@@ -68,7 +68,7 @@ feature 'Project milestone', :feature do
before do
create(:issue, project: project, milestone: milestone)
- visit namespace_project_milestone_path(project.namespace, project, milestone)
+ visit project_milestone_path(project, milestone)
end
describe 'the collapsed sidebar' do
@@ -78,11 +78,10 @@ feature 'Project milestone', :feature do
it 'shows the total MR and issue counts' do
find('.milestone-sidebar .block', match: :first)
- blocks = all('.milestone-sidebar .block')
aggregate_failures 'MR and issue blocks' do
- expect(blocks[3]).to have_content 1
- expect(blocks[4]).to have_content 0
+ expect(find('.milestone-sidebar .block.issues')).to have_content 1
+ expect(find('.milestone-sidebar .block.merge-requests')).to have_content 0
end
end
end
diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb
index da3eaed707a..c531b81e04d 100644
--- a/spec/features/projects/milestones/milestones_sorting_spec.rb
+++ b/spec/features/projects/milestones/milestones_sorting_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-feature 'Milestones sorting', :feature, :js do
+feature 'Milestones sorting', :js do
include SortingHelper
let(:user) { create(:user) }
- let(:project) { create(:empty_project, name: 'test', namespace: user.namespace) }
+ let(:project) { create(:project, name: 'test', namespace: user.namespace) }
before do
# Milestones
@@ -15,11 +15,11 @@ feature 'Milestones sorting', :feature, :js do
due_date: 11.days.from_now,
created_at: 1.hour.ago,
title: "bbb", project: project)
- login_as(user)
+ sign_in(user)
end
scenario 'visit project milestones and sort by due_date_asc' do
- visit namespace_project_milestones_path(project.namespace, project)
+ visit project_milestones_path(project)
expect(page).to have_button('Due soon')
diff --git a/spec/features/projects/milestones/new_spec.rb b/spec/features/projects/milestones/new_spec.rb
new file mode 100644
index 00000000000..f7900210fe6
--- /dev/null
+++ b/spec/features/projects/milestones/new_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+feature 'Creating a new project milestone', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, name: 'test', namespace: user.namespace) }
+
+ before do
+ login_as(user)
+ visit new_project_milestone_path(project)
+ end
+
+ it 'description has autocomplete' do
+ find('#milestone_description').native.send_keys('')
+ fill_in 'milestone_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
+end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index c66b9a34b86..22fb1223739 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -1,13 +1,27 @@
-require "spec_helper"
+require 'spec_helper'
-feature "New project", feature: true do
+feature 'New project' do
let(:user) { create(:admin) }
before do
- login_as(user)
+ sign_in(user)
end
- context "Visibility level selector" do
+ it 'shows "New project" page' do
+ visit new_project_path
+
+ expect(page).to have_content('Project path')
+ expect(page).to have_content('Project name')
+
+ expect(page).to have_link('GitHub')
+ expect(page).to have_link('Bitbucket')
+ expect(page).to have_link('GitLab.com')
+ expect(page).to have_link('Google Code')
+ expect(page).to have_button('Repo by URL')
+ expect(page).to have_link('GitLab export')
+ end
+
+ context 'Visibility level selector' do
Gitlab::VisibilityLevel.options.each do |key, level|
it "sets selector to #{key}" do
stub_application_setting(default_project_visibility: level)
@@ -17,10 +31,10 @@ feature "New project", feature: true do
expect(find_field("project_visibility_level_#{level}")).to be_checked
end
- it 'saves visibility level on validation error' do
+ it "saves visibility level #{level} on validation error" do
visit new_project_path
- choose(key)
+ choose(s_(key))
click_button('Create project')
expect(find_field("project_visibility_level_#{level}")).to be_checked
@@ -28,20 +42,20 @@ feature "New project", feature: true do
end
end
- context "Namespace selector" do
- context "with user namespace" do
+ context 'Namespace selector' do
+ context 'with user namespace' do
before do
visit new_project_path
end
- it "selects the user namespace" do
- namespace = find("#project_namespace_id")
+ it 'selects the user namespace' do
+ namespace = find('#project_namespace_id')
expect(namespace.text).to eq user.username
end
end
- context "with group namespace" do
+ context 'with group namespace' do
let(:group) { create(:group, :private, owner: user) }
before do
@@ -49,13 +63,13 @@ feature "New project", feature: true do
visit new_project_path(namespace_id: group.id)
end
- it "selects the group namespace" do
- namespace = find("#project_namespace_id option[selected]")
+ it 'selects the group namespace' do
+ namespace = find('#project_namespace_id option[selected]')
expect(namespace.text).to eq group.name
end
- context "on validation error" do
+ context 'on validation error' do
before do
fill_in('project_path', with: 'private-group-project')
choose('Internal')
@@ -64,15 +78,15 @@ feature "New project", feature: true do
expect(page).to have_css '.project-edit-errors .alert.alert-danger'
end
- it "selects the group namespace" do
- namespace = find("#project_namespace_id option[selected]")
+ it 'selects the group namespace' do
+ namespace = find('#project_namespace_id option[selected]')
expect(namespace.text).to eq group.name
end
end
end
- context "with subgroup namespace" do
+ context 'with subgroup namespace' do
let(:group) { create(:group, :private, owner: user) }
let(:subgroup) { create(:group, parent: group) }
@@ -81,8 +95,8 @@ feature "New project", feature: true do
visit new_project_path(namespace_id: subgroup.id)
end
- it "selects the group namespace" do
- namespace = find("#project_namespace_id option[selected]")
+ it 'selects the group namespace' do
+ namespace = find('#project_namespace_id option[selected]')
expect(namespace.text).to eq subgroup.full_path
end
@@ -94,10 +108,45 @@ feature "New project", feature: true do
visit new_project_path
end
- it 'does not autocomplete sensitive git repo URL' do
- autocomplete = find('#project_import_url')['autocomplete']
+ context 'from git repository url' do
+ before do
+ first('.import_git').click
+ end
+
+ it 'does not autocomplete sensitive git repo URL' do
+ autocomplete = find('#project_import_url')['autocomplete']
+
+ expect(autocomplete).to eq('off')
+ end
+
+ it 'shows import instructions' do
+ git_import_instructions = first('.js-toggle-content')
- expect(autocomplete).to eq('off')
+ expect(git_import_instructions).to be_visible
+ expect(git_import_instructions).to have_content 'Git repository URL'
+ end
+ end
+
+ context 'from GitHub' do
+ before do
+ first('.import_github').click
+ end
+
+ it 'shows import instructions' do
+ expect(page).to have_content('Import Projects from GitHub')
+ expect(current_path).to eq new_import_github_path
+ end
+ end
+
+ context 'from Google Code' do
+ before do
+ first('.import_google_code').click
+ end
+
+ it 'shows import instructions' do
+ expect(page).to have_content('Import projects from Google Code')
+ expect(current_path).to eq new_import_google_code_path
+ end
end
end
end
diff --git a/spec/features/projects/no_password_spec.rb b/spec/features/projects/no_password_spec.rb
new file mode 100644
index 00000000000..6aff079dd39
--- /dev/null
+++ b/spec/features/projects/no_password_spec.rb
@@ -0,0 +1,69 @@
+require 'spec_helper'
+
+feature 'No Password Alert' do
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ context 'with internal auth enabled' do
+ before do
+ sign_in(user)
+ visit project_path(project)
+ end
+
+ context 'when user has a password' do
+ let(:user) { create(:user) }
+
+ it 'shows no alert' do
+ expect(page).not_to have_content "You won't be able to pull or push project code via HTTP until you set a password on your account"
+ end
+ end
+
+ context 'when user has password automatically set' do
+ let(:user) { create(:user, password_automatically_set: true) }
+
+ it 'shows a password alert' do
+ expect(page).to have_content "You won't be able to pull or push project code via HTTP until you set a password on your account"
+ end
+ end
+ end
+
+ context 'with internal auth disabled' do
+ let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml') }
+
+ before do
+ stub_application_setting(password_authentication_enabled?: false)
+ stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [mock_saml_config])
+ end
+
+ context 'when user has no personal access tokens' do
+ it 'has a personal access token alert' do
+ gitlab_sign_in_via('saml', user, 'my-uid')
+ visit project_path(project)
+
+ expect(page).to have_content "You won't be able to pull or push project code via HTTP until you create a personal access token on your account"
+ end
+ end
+
+ context 'when user has a personal access token' do
+ it 'shows no alert' do
+ create(:personal_access_token, user: user)
+ gitlab_sign_in_via('saml', user, 'my-uid')
+ visit project_path(project)
+
+ expect(page).not_to have_content "You won't be able to pull or push project code via HTTP until you create a personal access token on your account"
+ end
+ end
+ end
+
+ context 'when user is ldap user' do
+ let(:user) { create(:omniauth_user, password_automatically_set: true) }
+
+ before do
+ sign_in(user)
+ visit project_path(project)
+ end
+
+ it 'shows no alert' do
+ expect(page).not_to have_content "You won't be able to pull or push project code via HTTP until you"
+ end
+ end
+end
diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb
index 11793c0f303..013ed6f2e58 100644
--- a/spec/features/projects/pages_spec.rb
+++ b/spec/features/projects/pages_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-feature 'Pages', feature: true do
- given(:project) { create(:empty_project) }
+feature 'Pages' do
+ given(:project) { create(:project) }
given(:user) { create(:user) }
given(:role) { :master }
@@ -10,12 +10,12 @@ feature 'Pages', feature: true do
project.team << [user, role]
- login_as(user)
+ sign_in(user)
end
shared_examples 'no pages deployed' do
scenario 'does not see anything to destroy' do
- visit namespace_project_pages_path(project.namespace, project)
+ visit project_pages_path(project)
expect(page).not_to have_link('Remove pages')
expect(page).not_to have_text('Only the project owner can remove pages')
@@ -33,7 +33,7 @@ feature 'Pages', feature: true do
end
scenario 'sees "Remove pages" link' do
- visit namespace_project_pages_path(project.namespace, project)
+ visit project_pages_path(project)
expect(page).to have_link('Remove pages')
end
@@ -49,7 +49,7 @@ feature 'Pages', feature: true do
end
scenario 'sees "Only the project owner can remove pages" text' do
- visit namespace_project_pages_path(project.namespace, project)
+ visit project_pages_path(project)
expect(page).to have_text('Only the project owner can remove pages')
end
diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb
new file mode 100644
index 00000000000..24b335a7068
--- /dev/null
+++ b/spec/features/projects/pipeline_schedules_spec.rb
@@ -0,0 +1,308 @@
+require 'spec_helper'
+
+feature 'Pipeline Schedules', :js do
+ include PipelineSchedulesHelper
+
+ let!(:project) { create(:project, :repository) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) }
+ let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+ let(:scope) { nil }
+ let!(:user) { create(:user) }
+
+ context 'logged in as master' do
+ before do
+ project.add_master(user)
+ gitlab_sign_in(user)
+ end
+
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+ end
+
+ describe 'The view' do
+ it 'displays the required information description' do
+ page.within('.pipeline-schedule-table-row') do
+ expect(page).to have_content('pipeline schedule')
+ expect(find(".next-run-cell time")['data-original-title'])
+ .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
+ expect(page).to have_link('master')
+ expect(page).to have_link("##{pipeline.id}")
+ end
+ end
+
+ it 'creates a new scheduled pipeline' do
+ click_link 'New schedule'
+
+ expect(page).to have_content('Schedule a new pipeline')
+ end
+
+ it 'changes ownership of the pipeline' do
+ click_link 'Take ownership'
+ page.within('.pipeline-schedule-table-row') do
+ expect(page).not_to have_content('No owner')
+ expect(page).to have_link('John Doe')
+ end
+ end
+
+ it 'edits the pipeline' do
+ page.within('.pipeline-schedule-table-row') do
+ click_link 'Edit'
+ end
+
+ expect(page).to have_content('Edit Pipeline Schedule')
+ end
+
+ it 'deletes the pipeline' do
+ click_link 'Delete'
+
+ expect(page).not_to have_css(".pipeline-schedule-table-row")
+ end
+ end
+
+ context 'when ref is nil' do
+ before do
+ pipeline_schedule.update_attribute(:ref, nil)
+ visit_pipelines_schedules
+ end
+
+ it 'shows a list of the pipeline schedules with empty ref column' do
+ expect(first('.branch-name-cell').text).to eq('')
+ end
+ end
+
+ context 'when ref is empty' do
+ before do
+ pipeline_schedule.update_attribute(:ref, '')
+ visit_pipelines_schedules
+ end
+
+ it 'shows a list of the pipeline schedules with empty ref column' do
+ expect(first('.branch-name-cell').text).to eq('')
+ end
+ end
+ end
+
+ describe 'POST /projects/pipeline_schedules/new' do
+ before do
+ visit_new_pipeline_schedule
+ end
+
+ it 'sets defaults for timezone and target branch' do
+ expect(page).to have_button('master')
+ expect(page).to have_button('UTC')
+ end
+
+ it 'it creates a new scheduled pipeline' do
+ fill_in_schedule_form
+ save_pipeline_schedule
+
+ expect(page).to have_content('my fancy description')
+ end
+
+ it 'it prevents an invalid form from being submitted' do
+ save_pipeline_schedule
+
+ expect(page).to have_content('This field is required')
+ end
+ end
+
+ describe 'PATCH /projects/pipelines_schedules/:id/edit' do
+ before do
+ edit_pipeline_schedule
+ end
+
+ it 'it displays existing properties' do
+ description = find_field('schedule_description').value
+ expect(description).to eq('pipeline schedule')
+ expect(page).to have_button('master')
+ expect(page).to have_button('UTC')
+ end
+
+ it 'edits the scheduled pipeline' do
+ fill_in 'schedule_description', with: 'my brand new description'
+
+ save_pipeline_schedule
+
+ expect(page).to have_content('my brand new description')
+ end
+
+ context 'when ref is nil' do
+ before do
+ pipeline_schedule.update_attribute(:ref, nil)
+ edit_pipeline_schedule
+ end
+
+ it 'shows the pipeline schedule with default ref' do
+ page.within('.js-target-branch-dropdown') do
+ expect(first('.dropdown-toggle-text').text).to eq('master')
+ end
+ end
+ end
+
+ context 'when ref is empty' do
+ before do
+ pipeline_schedule.update_attribute(:ref, '')
+ edit_pipeline_schedule
+ end
+
+ it 'shows the pipeline schedule with default ref' do
+ page.within('.js-target-branch-dropdown') do
+ expect(first('.dropdown-toggle-text').text).to eq('master')
+ end
+ end
+ end
+ end
+
+ context 'when user creates a new pipeline schedule with variables' do
+ background do
+ visit_pipelines_schedules
+ click_link 'New schedule'
+ fill_in_schedule_form
+ all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA')
+ all('[name="schedule[variables_attributes][][value]"]')[0].set('AAA123')
+ all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB')
+ all('[name="schedule[variables_attributes][][value]"]')[1].set('BBB123')
+ save_pipeline_schedule
+ end
+
+ scenario 'user sees the new variable in edit window' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.pipeline-variable-list') do
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('AAA')
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('AAA123')
+ expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-key-input").value).to eq('BBB')
+ expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-value-input").value).to eq('BBB123')
+ end
+ end
+ end
+
+ context 'when user edits a variable of a pipeline schedule' do
+ background do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
+ end
+
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ all('[name="schedule[variables_attributes][][key]"]')[0].set('foo')
+ all('[name="schedule[variables_attributes][][value]"]')[0].set('bar')
+ click_button 'Save pipeline schedule'
+ end
+
+ scenario 'user sees the updated variable in edit window' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.pipeline-variable-list') do
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('foo')
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('bar')
+ end
+ end
+ end
+
+ context 'when user removes a variable of a pipeline schedule' do
+ background do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
+ end
+
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ find('.pipeline-variable-list .pipeline-variable-row-remove-button').click
+ click_button 'Save pipeline schedule'
+ end
+
+ scenario 'user does not see the removed variable in edit window' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.pipeline-variable-list') do
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('')
+ expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('')
+ end
+ end
+ end
+
+ context 'when active is true and next_run_at is NULL' do
+ background do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ pipeline_schedule.update_attribute(:cron, nil) # Consequently next_run_at will be nil
+ end
+ end
+
+ scenario 'user edit and recover the problematic pipeline schedule' do
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ fill_in 'schedule_cron', with: '* 1 2 3 4'
+ click_button 'Save pipeline schedule'
+
+ page.within('.pipeline-schedule-table-row:nth-child(1)') do
+ expect(page).to have_css(".next-run-cell time")
+ end
+ end
+ end
+ end
+
+ context 'logged in as non-member' do
+ before do
+ gitlab_sign_in(user)
+ end
+
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+ end
+
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
+ end
+ end
+ end
+
+ context 'not logged in' do
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+ end
+
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
+ end
+ end
+ end
+
+ def visit_new_pipeline_schedule
+ visit new_project_pipeline_schedule_path(project, pipeline_schedule)
+ end
+
+ def edit_pipeline_schedule
+ visit edit_project_pipeline_schedule_path(project, pipeline_schedule)
+ end
+
+ def visit_pipelines_schedules
+ visit project_pipeline_schedules_path(project, scope: scope)
+ end
+
+ def select_timezone
+ find('.js-timezone-dropdown').click
+ click_link 'American Samoa'
+ end
+
+ def select_target_branch
+ find('.js-target-branch-dropdown').click
+ click_link 'master'
+ end
+
+ def save_pipeline_schedule
+ click_button 'Save pipeline schedule'
+ end
+
+ def fill_in_schedule_form
+ fill_in 'schedule_description', with: 'my fancy description'
+ fill_in 'schedule_cron', with: '* 1 2 3 4'
+
+ select_timezone
+ select_target_branch
+ end
+end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 5a53e48f5f8..acbc5b046e6 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -1,13 +1,11 @@
require 'spec_helper'
-describe 'Pipeline', :feature, :js do
- include GitlabRoutingHelper
-
- let(:project) { create(:empty_project) }
+describe 'Pipeline', :js do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
project.team << [user, :developer]
end
@@ -44,10 +42,12 @@ describe 'Pipeline', :feature, :js do
describe 'GET /:project/pipelines/:id' do
include_context 'pipeline builds'
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }
- before { visit namespace_project_pipeline_path(project.namespace, project, pipeline) }
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
it 'shows the pipeline graph' do
expect(page).to have_selector('.pipeline-visualization')
@@ -164,7 +164,9 @@ describe 'Pipeline', :feature, :js do
it { expect(page).not_to have_content('retried') }
context 'when retrying' do
- before { find('.js-retry-button').trigger('click') }
+ before do
+ find('.js-retry-button').trigger('click')
+ end
it { expect(page).not_to have_content('Retry') }
end
@@ -174,7 +176,9 @@ describe 'Pipeline', :feature, :js do
it { expect(page).not_to have_selector('.ci-canceled') }
context 'when canceling' do
- before { click_on 'Cancel running' }
+ before do
+ click_on 'Cancel running'
+ end
it { expect(page).not_to have_content('Cancel running') }
end
@@ -184,11 +188,11 @@ describe 'Pipeline', :feature, :js do
describe 'GET /:project/pipelines/:id/builds' do
include_context 'pipeline builds'
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
- visit builds_namespace_project_pipeline_path(project.namespace, project, pipeline)
+ visit builds_project_pipeline_path(project, pipeline)
end
it 'shows a list of jobs' do
@@ -226,10 +230,11 @@ describe 'Pipeline', :feature, :js do
it { expect(page).not_to have_content('retried') }
context 'when retrying' do
- before { find('.js-retry-button').trigger('click') }
+ before do
+ find('.js-retry-button').trigger('click')
+ end
it { expect(page).not_to have_content('Retry') }
- it { expect(page).to have_selector('.retried') }
end
end
@@ -237,10 +242,11 @@ describe 'Pipeline', :feature, :js do
it { expect(page).not_to have_selector('.ci-canceled') }
context 'when canceling' do
- before { click_on 'Cancel running' }
+ before do
+ click_on 'Cancel running'
+ end
it { expect(page).not_to have_content('Cancel running') }
- it { expect(page).to have_selector('.ci-canceled') }
end
end
@@ -254,4 +260,57 @@ describe 'Pipeline', :feature, :js do
it { expect(build_manual.reload).to be_pending }
end
end
+
+ describe 'GET /:project/pipelines/:id/failures' do
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
+ let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) }
+ let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ context 'with failed build' do
+ before do
+ failed_build.trace.set('4 examples, 1 failure')
+
+ visit pipeline_failures_page
+ end
+
+ it 'shows jobs tab pane as active' do
+ expect(page).to have_content('Failed Jobs')
+ expect(page).to have_css('#js-tab-failures.active')
+ end
+
+ it 'lists failed builds' do
+ expect(page).to have_content(failed_build.name)
+ expect(page).to have_content(failed_build.stage)
+ end
+
+ it 'shows build failure logs' do
+ expect(page).to have_content('4 examples, 1 failure')
+ end
+ end
+
+ context 'when missing build logs' do
+ before do
+ visit pipeline_failures_page
+ end
+
+ it 'includes failed jobs' do
+ expect(page).to have_content('No job trace')
+ end
+ end
+
+ context 'without failures' do
+ before do
+ failed_build.update!(status: :success)
+
+ visit pipeline_failures_page
+ end
+
+ it 'displays the pipeline graph' do
+ expect(current_path).to eq(pipeline_path(pipeline))
+ expect(page).not_to have_content('Failed Jobs')
+ expect(page).to have_selector('.pipeline-visualization')
+ end
+ end
+ end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 2272b19bc8f..f7b40cb1820 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -1,20 +1,18 @@
require 'spec_helper'
-describe 'Pipelines', :feature, :js do
- include WaitForVueResource
-
- let(:project) { create(:empty_project) }
+describe 'Pipelines', :js do
+ let(:project) { create(:project) }
context 'when user is logged in' do
let(:user) { create(:user) }
before do
- login_as(user)
+ sign_in(user)
project.team << [user, :developer]
end
describe 'GET /:project/pipelines' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let!(:pipeline) do
create(
@@ -22,7 +20,7 @@ describe 'Pipelines', :feature, :js do
project: project,
ref: 'master',
status: 'running',
- sha: project.commit.id,
+ sha: project.commit.id
)
end
@@ -53,8 +51,8 @@ describe 'Pipelines', :feature, :js do
context 'header tabs' do
before do
- visit namespace_project_pipelines_path(project.namespace, project)
- wait_for_vue_resource
+ visit project_pipelines_path(project)
+ wait_for_requests
end
it 'shows a tab for All pipelines and count' do
@@ -106,7 +104,7 @@ describe 'Pipelines', :feature, :js do
context 'when canceling' do
before do
find('.js-pipelines-cancel-button').click
- wait_for_vue_resource
+ wait_for_requests
end
it 'indicated that pipelines was canceled' do
@@ -136,7 +134,7 @@ describe 'Pipelines', :feature, :js do
context 'when retrying' do
before do
find('.js-pipelines-retry-button').click
- wait_for_vue_resource
+ wait_for_requests
end
it 'shows running pipeline that is not retryable' do
@@ -151,7 +149,9 @@ describe 'Pipelines', :feature, :js do
create(:ci_pipeline, :invalid, project: project)
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it 'contains badge that indicates errors' do
expect(page).to have_content 'yaml invalid'
@@ -173,10 +173,12 @@ describe 'Pipelines', :feature, :js do
commands: 'test')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it 'has a dropdown with play button' do
- expect(page).to have_selector('.dropdown-toggle.btn.btn-default .icon-play')
+ expect(page).to have_selector('.dropdown-new.btn.btn-default .icon-play')
end
it 'has link to the manual action' do
@@ -206,7 +208,9 @@ describe 'Pipelines', :feature, :js do
stage: 'test')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it 'is cancelable' do
expect(page).to have_selector('.js-pipelines-cancel-button')
@@ -217,7 +221,9 @@ describe 'Pipelines', :feature, :js do
end
context 'when canceling' do
- before { find('.js-pipelines-cancel-button').trigger('click') }
+ before do
+ find('.js-pipelines-cancel-button').trigger('click')
+ end
it 'indicates that pipeline was canceled' do
expect(page).not_to have_selector('.js-pipelines-cancel-button')
@@ -257,7 +263,9 @@ describe 'Pipelines', :feature, :js do
stage: 'test')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it 'has artifats' do
expect(page).to have_selector('.build-artifacts')
@@ -286,7 +294,9 @@ describe 'Pipelines', :feature, :js do
stage: 'test')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it { expect(page).not_to have_selector('.build-artifacts') }
end
@@ -299,7 +309,9 @@ describe 'Pipelines', :feature, :js do
stage: 'test')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it { expect(page).not_to have_selector('.build-artifacts') }
end
@@ -312,7 +324,9 @@ describe 'Pipelines', :feature, :js do
name: 'build')
end
- before { visit_project_pipelines }
+ before do
+ visit_project_pipelines
+ end
it 'should render a mini pipeline graph' do
expect(page).to have_selector('.js-mini-pipeline-graph')
@@ -355,26 +369,78 @@ describe 'Pipelines', :feature, :js do
end
it 'should render pagination' do
- visit namespace_project_pipelines_path(project.namespace, project)
- wait_for_vue_resource
+ visit project_pipelines_path(project)
+ wait_for_requests
expect(page).to have_selector('.gl-pagination')
end
it 'should render second page of pipelines' do
- visit namespace_project_pipelines_path(project.namespace, project, page: '2')
- wait_for_vue_resource
+ visit project_pipelines_path(project, page: '2')
+ wait_for_requests
expect(page).to have_selector('.gl-pagination .page', count: 2)
end
end
end
+ describe 'GET /:project/pipelines/show' do
+ let(:project) { create(:project, :repository) }
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: project,
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ create_build('build', 0, 'build', :success)
+ create_build('test', 1, 'rspec 0:2', :pending)
+ create_build('test', 1, 'rspec 1:2', :running)
+ create_build('test', 1, 'spinach 0:2', :created)
+ create_build('test', 1, 'spinach 1:2', :created)
+ create_build('test', 1, 'audit', :created)
+ create_build('deploy', 2, 'production', :created)
+
+ create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
+
+ visit project_pipeline_path(project, pipeline)
+ wait_for_requests
+ end
+
+ it 'shows a graph with grouped stages' do
+ expect(page).to have_css('.js-pipeline-graph')
+
+ # header
+ expect(page).to have_text("##{pipeline.id}")
+ expect(page).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"]))
+ expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user))
+
+ # stages
+ expect(page).to have_text('Build')
+ expect(page).to have_text('Test')
+ expect(page).to have_text('Deploy')
+ expect(page).to have_text('External')
+
+ # builds
+ expect(page).to have_text('rspec')
+ expect(page).to have_text('spinach')
+ expect(page).to have_text('rspec')
+ expect(page).to have_text('production')
+ expect(page).to have_text('jenkins')
+ end
+
+ def create_build(stage, stage_idx, name, status)
+ create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status)
+ end
+ end
+
describe 'POST /:project/pipelines' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
- visit new_namespace_project_pipeline_path(project.namespace, project)
+ visit new_project_pipeline_path(project)
end
context 'for valid commit', js: true do
@@ -387,16 +453,22 @@ describe 'Pipelines', :feature, :js do
end
context 'with gitlab-ci.yml' do
- before { stub_ci_pipeline_to_return_yaml_file }
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+ end
it 'creates a new pipeline' do
expect { click_on 'Create pipeline' }
.to change { Ci::Pipeline.count }.by(1)
+
+ expect(Ci::Pipeline.last).to be_web
end
end
context 'without gitlab-ci.yml' do
- before { click_on 'Create pipeline' }
+ before do
+ click_on 'Create pipeline'
+ end
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
end
@@ -404,10 +476,10 @@ describe 'Pipelines', :feature, :js do
end
describe 'Create pipelines' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
- visit new_namespace_project_pipeline_path(project.namespace, project)
+ visit new_project_pipeline_path(project)
end
describe 'new pipeline page' do
@@ -436,25 +508,25 @@ describe 'Pipelines', :feature, :js do
context 'when user is not logged in' do
before do
- visit namespace_project_pipelines_path(project.namespace, project)
+ visit project_pipelines_path(project)
end
context 'when project is public' do
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
it { expect(page).to have_content 'Build with confidence' }
it { expect(page).to have_http_status(:success) }
end
context 'when project is private' do
- let(:project) { create(:project, :private) }
+ let(:project) { create(:project, :private, :repository) }
it { expect(page).to have_content 'You need to sign in' }
end
end
def visit_project_pipelines(**query)
- visit namespace_project_pipelines_path(project.namespace, project, query)
- wait_for_vue_resource
+ visit project_pipelines_path(project, query)
+ wait_for_requests
end
end
diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb
index 5d0314d5c09..80d91e5915f 100644
--- a/spec/features/projects/project_settings_spec.rb
+++ b/spec/features/projects/project_settings_spec.rb
@@ -1,64 +1,173 @@
require 'spec_helper'
-describe 'Edit Project Settings', feature: true do
+describe 'Edit Project Settings' do
+ include Select2Helper
+
let(:user) { create(:user) }
- let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') }
+ let(:project) { create(:project, namespace: user.namespace, path: 'gitlab', name: 'sample') }
before do
- login_as(user)
- project.team << [user, :master]
+ sign_in(user)
end
- describe 'Project settings', js: true do
+ describe 'Project settings section', js: true do
it 'shows errors for invalid project name' do
- visit edit_namespace_project_path(project.namespace, project)
-
+ visit edit_project_path(project)
fill_in 'project_name_edit', with: 'foo&bar'
-
- click_button 'Save changes'
-
+ page.within('.general-settings') do
+ click_button 'Save changes'
+ end
expect(page).to have_field 'project_name_edit', with: 'foo&bar'
expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'."
expect(page).to have_button 'Save changes'
end
- scenario 'shows a successful notice when the project is updated' do
- visit edit_namespace_project_path(project.namespace, project)
-
+ it 'shows a successful notice when the project is updated' do
+ visit edit_project_path(project)
fill_in 'project_name_edit', with: 'hello world'
-
- click_button 'Save changes'
-
+ page.within('.general-settings') do
+ click_button 'Save changes'
+ end
expect(page).to have_content "Project 'hello world' was successfully updated."
end
end
- describe 'Rename repository' do
- it 'shows errors for invalid project path/name' do
- visit edit_namespace_project_path(project.namespace, project)
-
- fill_in 'project_name', with: 'foo&bar'
- fill_in 'Path', with: 'foo&bar'
+ describe 'Rename repository section' do
+ context 'with invalid characters' do
+ it 'shows errors for invalid project path/name' do
+ rename_project(project, name: 'foo&bar', path: 'foo&bar')
+ expect(page).to have_field 'Project name', with: 'foo&bar'
+ expect(page).to have_field 'Path', with: 'foo&bar'
+ expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'."
+ expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'"
+ end
+ end
- click_button 'Rename project'
+ context 'when changing project name' do
+ it 'renames the repository' do
+ rename_project(project, name: 'bar')
+ expect(find('h1.title')).to have_content(project.name)
+ end
+
+ context 'with emojis' do
+ it 'shows error for invalid project name' do
+ rename_project(project, name: '🚀 foo bar â˜ï¸')
+ expect(page).to have_field 'Project name', with: '🚀 foo bar â˜ï¸'
+ expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'."
+ end
+ end
+ end
- expect(page).to have_field 'Project name', with: 'foo&bar'
- expect(page).to have_field 'Path', with: 'foo&bar'
- expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'."
- expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'"
+ context 'when changing project path' do
+ let(:project) { create(:project, :repository, namespace: user.namespace, name: 'gitlabhq') }
+
+ before(:context) do
+ TestEnv.clean_test_path
+ end
+
+ after do
+ TestEnv.clean_test_path
+ end
+
+ specify 'the project is accessible via the new path' do
+ rename_project(project, path: 'bar')
+ new_path = namespace_project_path(project.namespace, 'bar')
+ visit new_path
+ expect(current_path).to eq(new_path)
+ expect(find('h1.title')).to have_content(project.name)
+ end
+
+ specify 'the project is accessible via a redirect from the old path' do
+ old_path = project_path(project)
+ rename_project(project, path: 'bar')
+ new_path = namespace_project_path(project.namespace, 'bar')
+ visit old_path
+ expect(current_path).to eq(new_path)
+ expect(find('h1.title')).to have_content(project.name)
+ end
+
+ context 'and a new project is added with the same path' do
+ it 'overrides the redirect' do
+ old_path = project_path(project)
+ rename_project(project, path: 'bar')
+ new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
+ visit old_path
+ expect(current_path).to eq(old_path)
+ expect(find('h1.title')).to have_content(new_project.name)
+ end
+ end
end
end
- describe 'Rename repository name with emojis' do
- it 'shows error for invalid project name' do
- visit edit_namespace_project_path(project.namespace, project)
+ describe 'Transfer project section', js: true do
+ let!(:project) { create(:project, :repository, namespace: user.namespace, name: 'gitlabhq') }
+ let!(:group) { create(:group) }
- fill_in 'project_name', with: '🚀 foo bar â˜ï¸'
+ before(:context) do
+ TestEnv.clean_test_path
+ end
- click_button 'Rename project'
+ before do
+ group.add_owner(user)
+ end
- expect(page).to have_field 'Project name', with: '🚀 foo bar â˜ï¸'
- expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'."
+ after do
+ TestEnv.clean_test_path
+ end
+
+ specify 'the project is accessible via the new path' do
+ transfer_project(project, group)
+ new_path = namespace_project_path(group, project)
+ visit new_path
+ expect(current_path).to eq(new_path)
+ expect(find('h1.title')).to have_content(project.name)
+ end
+
+ specify 'the project is accessible via a redirect from the old path' do
+ old_path = project_path(project)
+ transfer_project(project, group)
+ new_path = namespace_project_path(group, project)
+ visit old_path
+ expect(current_path).to eq(new_path)
+ expect(find('h1.title')).to have_content(project.name)
+ end
+
+ context 'and a new project is added with the same path' do
+ it 'overrides the redirect' do
+ old_path = project_path(project)
+ transfer_project(project, group)
+ new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
+ visit old_path
+ expect(current_path).to eq(old_path)
+ expect(find('h1.title')).to have_content(new_project.name)
+ end
end
end
end
+
+def rename_project(project, name: nil, path: nil)
+ visit edit_project_path(project)
+ fill_in('project_name', with: name) if name
+ fill_in('Path', with: path) if path
+ click_button('Rename project')
+ wait_for_edit_project_page_reload
+ project.reload
+end
+
+def transfer_project(project, namespace)
+ visit edit_project_path(project)
+ select2(namespace.id, from: '#new_namespace_id')
+ click_button('Transfer project')
+ confirm_transfer_modal
+ wait_for_edit_project_page_reload
+ project.reload
+end
+
+def confirm_transfer_modal
+ fill_in('confirm_name_input', with: project.path)
+ click_button 'Confirm'
+end
+
+def wait_for_edit_project_page_reload
+ expect(find('.project-edit-container')).to have_content('Rename repository')
+end
diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb
index 881ad7910dd..f0a23729220 100644
--- a/spec/features/projects/ref_switcher_spec.rb
+++ b/spec/features/projects/ref_switcher_spec.rb
@@ -1,37 +1,37 @@
require 'rails_helper'
-feature 'Ref switcher', feature: true, js: true do
+feature 'Ref switcher', js: true do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
before do
project.team << [user, :master]
- login_as(user)
- visit namespace_project_tree_path(project.namespace, project, 'master')
+ sign_in(user)
+ visit project_tree_path(project, 'master')
end
it 'allow user to change ref by enter key' do
click_button 'master'
- wait_for_ajax
+ wait_for_requests
page.within '.project-refs-form' do
input = find('input[type="search"]')
input.set 'binary'
- wait_for_ajax
+ wait_for_requests
- expect(find('.dropdown-content ul')).to have_selector('li', count: 6)
+ expect(find('.dropdown-content ul')).to have_selector('li', count: 7)
page.within '.dropdown-content ul' do
input.native.send_keys :enter
end
end
- expect(page).to have_title 'binary-encoding'
+ expect(page).to have_title 'add-pdf-text-binary'
end
it "user selects ref with special characters" do
click_button 'master'
- wait_for_ajax
+ wait_for_requests
page.within '.project-refs-form' do
page.fill_in 'Search branches and tags', with: "'test'"
diff --git a/spec/features/projects/services/jira_service_spec.rb b/spec/features/projects/services/jira_service_spec.rb
new file mode 100644
index 00000000000..65e3a487d4b
--- /dev/null
+++ b/spec/features/projects/services/jira_service_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+feature 'Setup Jira service', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:service) { project.create_jira_service }
+
+ let(:url) { 'http://jira.example.com' }
+ let(:test_url) { 'http://jira.example.com/rest/api/2/serverInfo' }
+
+ def fill_form(active = true)
+ check 'Active' if active
+
+ fill_in 'service_url', with: url
+ fill_in 'service_username', with: 'username'
+ fill_in 'service_password', with: 'password'
+ fill_in 'service_jira_issue_transition_id', with: '25'
+ end
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+
+ visit project_settings_integrations_path(project)
+ end
+
+ describe 'user sets and activates Jira Service' do
+ context 'when Jira connection test succeeds' do
+ it 'activates the JIRA service' do
+ server_info = { key: 'value' }.to_json
+ WebMock.stub_request(:get, test_url).with(basic_auth: %w(username password)).to_return(body: server_info)
+
+ click_link('JIRA')
+ fill_form
+ click_button('Test settings and save changes')
+ wait_for_requests
+
+ expect(page).to have_content('JIRA activated.')
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+ end
+
+ context 'when Jira connection test fails' do
+ it 'shows errors when some required fields are not filled in' do
+ click_link('JIRA')
+
+ check 'Active'
+ fill_in 'service_password', with: 'password'
+ click_button('Test settings and save changes')
+
+ page.within('.service-settings') do
+ expect(page).to have_content('This field is required.')
+ end
+ end
+
+ it 'activates the JIRA service' do
+ WebMock.stub_request(:get, test_url).with(basic_auth: %w(username password))
+ .to_raise(JIRA::HTTPError.new(double(message: 'message')))
+
+ click_link('JIRA')
+ fill_form
+ click_button('Test settings and save changes')
+ wait_for_requests
+
+ expect(find('.flash-container-page')).to have_content 'Test failed. message'
+ expect(find('.flash-container-page')).to have_content 'Save anyway'
+
+ find('.flash-alert .flash-action').trigger('click')
+ wait_for_requests
+
+ expect(page).to have_content('JIRA activated.')
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+ end
+ end
+
+ describe 'user sets Jira Service but keeps it disabled' do
+ context 'when Jira connection test succeeds' do
+ it 'activates the JIRA service' do
+ click_link('JIRA')
+ fill_form(false)
+ click_button('Save changes')
+
+ expect(page).to have_content('JIRA settings saved, but not activated.')
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb
index dc3854262e7..95d5e8b14b9 100644
--- a/spec/features/projects/services/mattermost_slash_command_spec.rb
+++ b/spec/features/projects/services/mattermost_slash_command_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-feature 'Setup Mattermost slash commands', :feature, :js do
+feature 'Setup Mattermost slash commands', :js do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:service) { project.create_mattermost_slash_commands_service }
let(:mattermost_enabled) { true }
before do
stub_mattermost_setting(enabled: mattermost_enabled)
project.team << [user, :master]
- login_as(user)
- visit edit_namespace_project_service_path(project.namespace, project, service)
+ sign_in(user)
+ visit edit_project_service_path(project, service)
end
describe 'user visits the mattermost slash command config page' do
@@ -24,15 +24,25 @@ feature 'Setup Mattermost slash commands', :feature, :js do
expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
end
- it 'shows the token after saving' do
+ it 'redirects to the integrations page after saving but not activating' do
token = ('a'..'z').to_a.join
fill_in 'service_token', with: token
- click_on 'Save'
+ click_on 'Save changes'
- value = find_field('service_token').value
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Mattermost slash commands settings saved, but not activated.')
+ end
+
+ it 'redirects to the integrations page after activating' do
+ token = ('a'..'z').to_a.join
+
+ fill_in 'service_token', with: token
+ check 'service_active'
+ click_on 'Save changes'
- expect(value).to eq(token)
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Mattermost slash commands activated.')
end
it 'shows the add to mattermost button' do
@@ -149,7 +159,7 @@ feature 'Setup Mattermost slash commands', :feature, :js do
it 'shows the correct trigger url' do
value = find_field('request_url').value
- expect(value).to match("api/v3/projects/#{project.id}/services/mattermost_slash_commands/trigger")
+ expect(value).to match("api/v4/projects/#{project.id}/services/mattermost_slash_commands/trigger")
end
it 'shows a token placeholder' do
diff --git a/spec/features/projects/services/slack_service_spec.rb b/spec/features/projects/services/slack_service_spec.rb
index c0a4a1e4bf5..c10ec5e2987 100644
--- a/spec/features/projects/services/slack_service_spec.rb
+++ b/spec/features/projects/services/slack_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Slack service > Setup events', feature: true do
+feature 'Projects > Slack service > Setup events' do
let(:user) { create(:user) }
let(:service) { SlackService.new }
let(:project) { create(:project, slack_service: service) }
@@ -9,11 +9,11 @@ feature 'Projects > Slack service > Setup events', feature: true do
service.fields
service.update_attributes(push_channel: 1, issue_channel: 2, merge_request_channel: 3, note_channel: 4, tag_push_channel: 5, pipeline_channel: 6, wiki_page_channel: 7)
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
scenario 'user can filter events by channel' do
- visit edit_namespace_project_service_path(project.namespace, project, service)
+ visit edit_project_service_path(project, service)
expect(page.find_field("service_push_channel").value).to have_content '1'
expect(page.find_field("service_issue_channel").value).to have_content '2'
diff --git a/spec/features/projects/services/slack_slash_command_spec.rb b/spec/features/projects/services/slack_slash_command_spec.rb
index db903a0c8f0..a8baf126269 100644
--- a/spec/features/projects/services/slack_slash_command_spec.rb
+++ b/spec/features/projects/services/slack_slash_command_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-feature 'Slack slash commands', feature: true do
+feature 'Slack slash commands' do
given(:user) { create(:user) }
given(:project) { create(:project) }
given(:service) { project.create_slack_slash_commands_service }
background do
project.team << [user, :master]
- login_as(user)
- visit edit_namespace_project_service_path(project.namespace, project, service)
+ sign_in(user)
+ visit edit_project_service_path(project, service)
end
it 'shows a token placeholder' do
@@ -21,17 +21,25 @@ feature 'Slack slash commands', feature: true do
expect(page).to have_content('This service allows users to perform common')
end
- it 'shows the token after saving' do
+ it 'redirects to the integrations page after saving but not activating' do
fill_in 'service_token', with: 'token'
click_on 'Save'
- value = find_field('service_token').value
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Slack slash commands settings saved, but not activated.')
+ end
+
+ it 'redirects to the integrations page after activating' do
+ fill_in 'service_token', with: 'token'
+ check 'service_active'
+ click_on 'Save'
- expect(value).to eq('token')
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Slack slash commands activated.')
end
it 'shows the correct trigger url' do
value = find_field('url').value
- expect(value).to match("api/v3/projects/#{project.id}/services/slack_slash_commands/trigger")
+ expect(value).to match("api/v4/projects/#{project.id}/services/slack_slash_commands/trigger")
end
end
diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb
new file mode 100644
index 00000000000..d932c4e4d9a
--- /dev/null
+++ b/spec/features/projects/settings/integration_settings_spec.rb
@@ -0,0 +1,141 @@
+require 'spec_helper'
+
+feature 'Integration settings' do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:role) { :developer }
+ let(:integrations_path) { project_settings_integrations_path(project) }
+
+ background do
+ sign_in(user)
+ project.team << [user, role]
+ end
+
+ context 'for developer' do
+ given(:role) { :developer }
+
+ scenario 'to be disallowed to view' do
+ visit integrations_path
+
+ expect(page.status_code).to eq(404)
+ end
+ end
+
+ context 'for master' do
+ given(:role) { :master }
+
+ context 'Webhooks' do
+ let(:hook) { create(:project_hook, :all_events_enabled, enable_ssl_verification: true, project: project) }
+ let(:url) { generate(:url) }
+
+ scenario 'show list of webhooks' do
+ hook
+
+ visit integrations_path
+
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content(hook.url)
+ expect(page).to have_content('SSL Verification: enabled')
+ expect(page).to have_content('Push events')
+ expect(page).to have_content('Tag push events')
+ expect(page).to have_content('Issues events')
+ expect(page).to have_content('Confidential issues events')
+ expect(page).to have_content('Note events')
+ expect(page).to have_content('Merge requests events')
+ expect(page).to have_content('Pipeline events')
+ expect(page).to have_content('Wiki page events')
+ end
+
+ scenario 'create webhook' do
+ visit integrations_path
+
+ fill_in 'hook_url', with: url
+ check 'Tag push events'
+ check 'Enable SSL verification'
+ check 'Job events'
+
+ click_button 'Add webhook'
+
+ expect(page).to have_content(url)
+ expect(page).to have_content('SSL Verification: enabled')
+ expect(page).to have_content('Push events')
+ expect(page).to have_content('Tag push events')
+ expect(page).to have_content('Job events')
+ end
+
+ scenario 'edit existing webhook' do
+ hook
+ visit integrations_path
+
+ click_link 'Edit'
+ fill_in 'hook_url', with: url
+ check 'Enable SSL verification'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'SSL Verification: enabled'
+ expect(page).to have_content(url)
+ end
+
+ scenario 'test existing webhook', js: true do
+ WebMock.stub_request(:post, hook.url)
+ visit integrations_path
+
+ find('.hook-test-button.dropdown').click
+ click_link 'Push events'
+
+ expect(current_path).to eq(integrations_path)
+ end
+
+ context 'remove existing webhook' do
+ scenario 'from webhooks list page' do
+ hook
+ visit integrations_path
+
+ expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1)
+ end
+
+ scenario 'from webhook edit page' do
+ hook
+ visit integrations_path
+ click_link 'Edit'
+
+ expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1)
+ end
+ end
+ end
+
+ context 'Webhook logs' do
+ let(:hook) { create(:project_hook, project: project) }
+ let(:hook_log) { create(:web_hook_log, web_hook: hook, internal_error_message: 'some error') }
+
+ scenario 'show list of hook logs' do
+ hook_log
+ visit edit_project_hook_path(project, hook)
+
+ expect(page).to have_content('Recent Deliveries')
+ expect(page).to have_content(hook_log.url)
+ end
+
+ scenario 'show hook log details' do
+ hook_log
+ visit edit_project_hook_path(project, hook)
+ click_link 'View details'
+
+ expect(page).to have_content("POST #{hook_log.url}")
+ expect(page).to have_content(hook_log.internal_error_message)
+ expect(page).to have_content('Resend Request')
+ end
+
+ scenario 'retry hook log' do
+ WebMock.stub_request(:post, hook.url)
+
+ hook_log
+ visit edit_project_hook_path(project, hook)
+ click_link 'View details'
+ click_link 'Resend Request'
+
+ expect(current_path).to eq(edit_project_hook_path(project, hook))
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb
index 321af416c91..104ce08d9f3 100644
--- a/spec/features/projects/settings/merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/merge_requests_settings_spec.rb
@@ -1,14 +1,12 @@
require 'spec_helper'
-feature 'Project settings > Merge Requests', feature: true, js: true do
- include GitlabRoutingHelper
-
- let(:project) { create(:empty_project, :public) }
+feature 'Project settings > Merge Requests', :js do
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
background do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'when Merge Request and Pipelines are initially enabled' do
@@ -22,6 +20,9 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
select 'Disabled', from: "project_project_feature_attributes_merge_requests_access_level"
+ within('.sharing-permissions-form') do
+ click_on('Save changes')
+ end
expect(page).not_to have_content('Only allow merge requests to be merged if the pipeline succeeds')
expect(page).not_to have_content('Only allow merge requests to be merged if all discussions are resolved')
@@ -39,6 +40,9 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
select 'Everyone with access', from: "project_project_feature_attributes_builds_access_level"
+ within('.sharing-permissions-form') do
+ click_on('Save changes')
+ end
expect(page).to have_content('Only allow merge requests to be merged if the pipeline succeeds')
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
@@ -57,6 +61,9 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
expect(page).not_to have_content('Only allow merge requests to be merged if all discussions are resolved')
select 'Everyone with access', from: "project_project_feature_attributes_merge_requests_access_level"
+ within('.sharing-permissions-form') do
+ click_on('Save changes')
+ end
expect(page).to have_content('Only allow merge requests to be merged if the pipeline succeeds')
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
@@ -75,7 +82,9 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
scenario 'when unchecked sets :printing_merge_request_link_enabled to false' do
uncheck('project_printing_merge_request_link_enabled')
- click_on('Save')
+ within('.merge-request-settings-form') do
+ click_on('Save changes')
+ end
# Wait for save to complete and page to reload
checkbox = find_field('project_printing_merge_request_link_enabled')
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 035c57eaa47..232d796a200 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -1,16 +1,14 @@
require 'spec_helper'
-feature "Pipelines settings", feature: true do
- include GitlabRoutingHelper
-
- let(:project) { create(:empty_project) }
+feature "Pipelines settings" do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:role) { :developer }
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
- visit namespace_project_pipelines_settings_path(project.namespace, project)
+ visit project_pipelines_settings_path(project)
end
context 'for developer' do
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
new file mode 100644
index 00000000000..15180d4b498
--- /dev/null
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -0,0 +1,95 @@
+require 'spec_helper'
+
+feature 'Repository settings' do
+ let(:project) { create(:project_empty_repo) }
+ let(:user) { create(:user) }
+ let(:role) { :developer }
+
+ background do
+ project.team << [user, role]
+ sign_in(user)
+ end
+
+ context 'for developer' do
+ given(:role) { :developer }
+
+ scenario 'is not allowed to view' do
+ visit project_settings_repository_path(project)
+
+ expect(page.status_code).to eq(404)
+ end
+ end
+
+ context 'for master' do
+ given(:role) { :master }
+
+ context 'Deploy Keys', js: true do
+ let(:private_deploy_key) { create(:deploy_key, title: 'private_deploy_key', public: false) }
+ let(:public_deploy_key) { create(:another_deploy_key, title: 'public_deploy_key', public: true) }
+ let(:new_ssh_key) { attributes_for(:key)[:key] }
+
+ scenario 'get list of keys' do
+ project.deploy_keys << private_deploy_key
+ project.deploy_keys << public_deploy_key
+
+ visit project_settings_repository_path(project)
+
+ expect(page.status_code).to eq(200)
+ expect(page).to have_content('private_deploy_key')
+ expect(page).to have_content('public_deploy_key')
+ end
+
+ scenario 'add a new deploy key' do
+ visit project_settings_repository_path(project)
+
+ fill_in 'deploy_key_title', with: 'new_deploy_key'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ check 'deploy_key_can_push'
+ click_button 'Add key'
+
+ expect(page).to have_content('new_deploy_key')
+ expect(page).to have_content('Write access allowed')
+ end
+
+ scenario 'edit an existing deploy key' do
+ project.deploy_keys << private_deploy_key
+ visit project_settings_repository_path(project)
+
+ find('li', text: private_deploy_key.title).click_link('Edit')
+
+ fill_in 'deploy_key_title', with: 'updated_deploy_key'
+ check 'deploy_key_can_push'
+ click_button 'Save changes'
+
+ expect(page).to have_content('updated_deploy_key')
+ expect(page).to have_content('Write access allowed')
+ end
+
+ scenario 'edit a deploy key from projects user has access to' do
+ project2 = create(:project_empty_repo)
+ project2.team << [user, role]
+ project2.deploy_keys << private_deploy_key
+
+ visit project_settings_repository_path(project)
+
+ find('li', text: private_deploy_key.title).click_link('Edit')
+
+ fill_in 'deploy_key_title', with: 'updated_deploy_key'
+ check 'deploy_key_can_push'
+ click_button 'Save changes'
+
+ expect(page).to have_content('updated_deploy_key')
+ expect(page).to have_content('Write access allowed')
+ end
+
+ scenario 'remove an existing deploy key' do
+ project.deploy_keys << private_deploy_key
+ visit project_settings_repository_path(project)
+
+ find('li', text: private_deploy_key.title).click_button('Remove')
+
+ expect(page).not_to have_content(private_deploy_key.title)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb
index cef315ac9cd..1756c7d00fe 100644
--- a/spec/features/projects/settings/visibility_settings_spec.rb
+++ b/spec/features/projects/settings/visibility_settings_spec.rb
@@ -1,20 +1,20 @@
require 'spec_helper'
-feature 'Visibility settings', feature: true, js: true do
+feature 'Visibility settings', js: true do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace, visibility_level: 20) }
context 'as owner' do
before do
- login_as(user)
- visit edit_namespace_project_path(project.namespace, project)
+ sign_in(user)
+ visit edit_project_path(project)
end
scenario 'project visibility select is available' do
visibility_select_container = find('.js-visibility-select')
expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s
- expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
end
scenario 'project visibility description updates on change' do
@@ -32,8 +32,8 @@ feature 'Visibility settings', feature: true, js: true do
before do
project.team << [master_user, :master]
- login_as(master_user)
- visit edit_namespace_project_path(project.namespace, project)
+ sign_in(master_user)
+ visit edit_project_path(project)
end
scenario 'project visibility is locked' do
@@ -41,7 +41,7 @@ feature 'Visibility settings', feature: true, js: true do
expect(visibility_select_container).not_to have_select '.visibility-select'
expect(visibility_select_container).to have_content 'Public'
- expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
end
end
end
diff --git a/spec/features/projects/shortcuts_spec.rb b/spec/features/projects/shortcuts_spec.rb
index 54aa9c66a08..bf18c444c3d 100644
--- a/spec/features/projects/shortcuts_spec.rb
+++ b/spec/features/projects/shortcuts_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-feature 'Project shortcuts', feature: true do
+feature 'Project shortcuts' do
let(:project) { create(:project, name: 'Victorialand') }
let(:user) { create(:user) }
describe 'On a project', js: true do
before do
project.team << [user, :master]
- login_as user
- visit namespace_project_path(project.namespace, project)
+ sign_in user
+ visit project_path(project)
end
describe 'pressing "i"' do
diff --git a/spec/features/projects/show_project_spec.rb b/spec/features/projects/show_project_spec.rb
new file mode 100644
index 00000000000..1bc6fae9e7f
--- /dev/null
+++ b/spec/features/projects/show_project_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe 'Project show page', feature: true do
+ context 'when project pending delete' do
+ let(:project) { create(:project, :empty_repo, pending_delete: true) }
+
+ before do
+ sign_in(project.owner)
+ end
+
+ it 'shows error message if deletion for project fails' do
+ project.update_attributes(delete_error: "Something went wrong", pending_delete: false)
+
+ visit project_path(project)
+
+ expect(page).to have_selector('.project-deletion-failed-message')
+ expect(page).to have_content("This project was scheduled for deletion, but failed with the following message: #{project.delete_error}")
+ end
+ end
+end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
new file mode 100644
index 00000000000..3e79dba3f19
--- /dev/null
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+feature 'Create Snippet', :js do
+ include DropzoneHelper
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+
+ def fill_form
+ fill_in 'project_snippet_title', with: 'My Snippet Title'
+ fill_in 'project_snippet_description', with: 'My Snippet **Description**'
+ page.within('.file-editor') do
+ find('.ace_editor').native.send_keys('Hello World!')
+ end
+ end
+
+ context 'when a user is authenticated' do
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+
+ visit project_snippets_path(project)
+
+ click_on('New snippet')
+ end
+
+ it 'creates a new snippet' do
+ fill_form
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ expect(page).to have_content('Hello World!')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ end
+
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ expect(page.find_field("project_snippet_description").value).to have_content('banana_sample')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
+ end
+
+ it 'creates a snippet when all reuiqred fields are filled in after validation failing' do
+ fill_in 'project_snippet_title', with: 'My Snippet Title'
+ click_button('Create snippet')
+
+ expect(page).to have_selector('#error_explanation')
+
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ expect(page).to have_content('Hello World!')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
+ end
+ end
+
+ context 'when a user is not authenticated' do
+ it 'shows a public snippet on the index page but not the New snippet button' do
+ snippet = create(:project_snippet, :public, project: project)
+
+ visit project_snippets_path(project)
+
+ expect(page).to have_content(snippet.title)
+ expect(page).not_to have_content('New snippet')
+ end
+ end
+end
diff --git a/spec/features/projects/snippets/show_spec.rb b/spec/features/projects/snippets/show_spec.rb
new file mode 100644
index 00000000000..08dc7cf6c5b
--- /dev/null
+++ b/spec/features/projects/snippets/show_spec.rb
@@ -0,0 +1,144 @@
+require 'spec_helper'
+
+feature 'Project snippet', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:snippet) { create(:project_snippet, project: project, file_name: file_name, content: content) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ context 'Ruby file' do
+ let(:file_name) { 'popen.rb' }
+ let(:content) { project.repository.blob_at('master', 'files/ruby/popen.rb').data }
+
+ before do
+ visit project_snippet_path(project, snippet)
+
+ wait_for_requests
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows highlighted Ruby code
+ expect(page).to have_content("require 'fileutils'")
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'Markdown file' do
+ let(:file_name) { 'ruby-style-guide.md' }
+ let(:content) { project.repository.blob_at('master', 'files/markdown/ruby-style-guide.md').data }
+
+ context 'visiting directly' do
+ before do
+ visit project_snippet_path(project, snippet)
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows rendered Markdown
+ expect(page).to have_link("PEP-8")
+
+ # shows a viewer switcher
+ expect(page).to have_selector('.js-blob-viewer-switcher')
+
+ # shows a disabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn.disabled')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+
+ context 'switching to the simple viewer' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=simple]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # shows highlighted Markdown code
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+
+ context 'switching to the rich viewer again' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=rich]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+ end
+
+ context 'visiting with a line number anchor' do
+ before do
+ visit project_snippet_path(project, snippet, anchor: 'L1')
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # highlights the line in question
+ expect(page).to have_selector('#LC1.hll')
+
+ # shows highlighted Markdown code
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/snippets_spec.rb b/spec/features/projects/snippets_spec.rb
index d37e8ed4699..1cfbbb4cb62 100644
--- a/spec/features/projects/snippets_spec.rb
+++ b/spec/features/projects/snippets_spec.rb
@@ -1,14 +1,44 @@
require 'spec_helper'
-describe 'Project snippets', feature: true do
+describe 'Project snippets', :js do
context 'when the project has snippets' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) }
- before do
- allow(Snippet).to receive(:default_per_page).and_return(1)
- visit namespace_project_snippets_path(project.namespace, project)
+ let!(:other_snippet) { create(:project_snippet) }
+
+ context 'pagination' do
+ before do
+ allow(Snippet).to receive(:default_per_page).and_return(1)
+
+ visit project_snippets_path(project)
+ end
+
+ it_behaves_like 'paginated snippets'
+ end
+
+ context 'list content' do
+ it 'contains all project snippets' do
+ visit project_snippets_path(project)
+
+ expect(page).to have_selector('.snippet-row', count: 2)
+
+ expect(page).to have_content(snippets[0].title)
+ expect(page).to have_content(snippets[1].title)
+ end
end
- it_behaves_like 'paginated snippets'
+ context 'when submitting a note' do
+ before do
+ sign_in(create(:admin))
+ visit project_snippet_path(project, snippets[0])
+ end
+
+ it 'should have autocomplete' do
+ find('#note_note').native.send_keys('')
+ fill_in 'note[note]', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
+ end
end
end
diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb
new file mode 100644
index 00000000000..aaf64d42515
--- /dev/null
+++ b/spec/features/projects/sub_group_issuables_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe 'Subgroup Issuables', :js, :nested_groups do
+ let!(:group) { create(:group, name: 'group') }
+ let!(:subgroup) { create(:group, parent: group, name: 'subgroup') }
+ let!(:project) { create(:project, namespace: subgroup, name: 'project') }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in user
+ end
+
+ it 'shows the full subgroup title when issues index page is empty' do
+ visit project_issues_path(project)
+
+ expect_to_have_full_subgroup_title
+ end
+
+ it 'shows the full subgroup title when merge requests index page is empty' do
+ visit project_merge_requests_path(project)
+
+ expect_to_have_full_subgroup_title
+ end
+
+ def expect_to_have_full_subgroup_title
+ title = find('.title-container')
+
+ expect(title).not_to have_selector '.initializing'
+ expect(title).to have_content 'group / subgroup / project'
+ end
+end
diff --git a/spec/features/projects/tags/download_buttons_spec.rb b/spec/features/projects/tags/download_buttons_spec.rb
index dd93d25c2c6..d38a5b1324b 100644
--- a/spec/features/projects/tags/download_buttons_spec.rb
+++ b/spec/features/projects/tags/download_buttons_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-feature 'Download buttons in tags page', feature: true do
+feature 'Download buttons in tags page' do
given(:user) { create(:user) }
given(:role) { :developer }
given(:status) { 'success' }
given(:tag) { 'v1.0.0' }
- given(:project) { create(:project) }
+ given(:project) { create(:project, :repository) }
given(:pipeline) do
create(:ci_pipeline,
@@ -23,20 +23,18 @@ feature 'Download buttons in tags page', feature: true do
end
background do
- login_as(user)
+ sign_in(user)
project.team << [user, role]
end
describe 'when checking tags' do
context 'with artifacts' do
before do
- visit namespace_project_tags_path(project.namespace, project)
+ visit project_tags_path(project)
end
scenario 'shows download artifacts button' do
- href = latest_succeeded_namespace_project_artifacts_path(
- project.namespace, project, "#{tag}/download",
- job: 'build')
+ href = latest_succeeded_project_artifacts_path(project, "#{tag}/download", job: 'build')
expect(page).to have_link "Download '#{build.name}'", href: href
end
diff --git a/spec/features/projects/tree/rss_spec.rb b/spec/features/projects/tree/rss_spec.rb
index 9ac51997d65..4f2e0a76a65 100644
--- a/spec/features/projects/tree/rss_spec.rb
+++ b/spec/features/projects/tree/rss_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
feature 'Project Tree RSS' do
+ let(:user) { create(:user) }
let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { namespace_project_tree_path(project.namespace, project, :master) }
+ let(:path) { project_tree_path(project, :master) }
context 'when signed in' do
before do
- user = create(:user)
project.team << [user, :developer]
- login_as(user)
+ sign_in(user)
visit path
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
end
context 'when signed out' do
@@ -20,6 +20,6 @@ feature 'Project Tree RSS' do
visit path
end
- it_behaves_like "an autodiscoverable RSS feed without a private token"
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
end
diff --git a/spec/features/projects/user_browses_files_spec.rb b/spec/features/projects/user_browses_files_spec.rb
new file mode 100644
index 00000000000..b7a0b72db50
--- /dev/null
+++ b/spec/features/projects/user_browses_files_spec.rb
@@ -0,0 +1,188 @@
+require 'spec_helper'
+
+describe 'User browses files' do
+ include DropzoneHelper
+
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:tree_path_ref_6d39438) { project_tree_path(project, '6d39438') }
+ let(:tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ context 'when browsing the master branch' do
+ before do
+ visit(tree_path_root_ref)
+ end
+
+ it 'shows files from a repository' do
+ expect(page).to have_content('VERSION')
+ expect(page).to have_content('.gitignore')
+ expect(page).to have_content('LICENSE')
+ end
+
+ it 'shows the "Browse Directory" link' do
+ click_link('files')
+ click_link('History')
+
+ expect(page).to have_link('Browse Directory')
+ expect(page).not_to have_link('Browse Code')
+ end
+
+ it 'shows the "Browse File" link' do
+ page.within('.tree-table') do
+ click_link('README.md')
+ end
+ click_link('History')
+
+ expect(page).to have_link('Browse File')
+ expect(page).not_to have_link('Browse Files')
+ end
+
+ it 'shows the "Browse Code" link' do
+ click_link('History')
+
+ expect(page).to have_link('Browse Files')
+ expect(page).not_to have_link('Browse Directory')
+ end
+
+ it 'redirects to the permalink URL' do
+ click_link('.gitignore')
+ click_link('Permalink')
+
+ permalink_path = project_blob_path(project, "#{project.repository.commit.sha}/.gitignore")
+
+ expect(current_path).to eq(permalink_path)
+ end
+ end
+
+ context 'when browsing a specific ref' do
+ before do
+ visit(tree_path_ref_6d39438)
+ end
+
+ it 'shows files from a repository for "6d39438"' do
+ expect(current_path).to eq(tree_path_ref_6d39438)
+ expect(page).to have_content('.gitignore')
+ expect(page).to have_content('LICENSE')
+ end
+
+ it 'shows files from a repository with apostroph in its name', js: true do
+ first('.js-project-refs-dropdown').click
+
+ page.within('.project-refs-form') do
+ click_link("'test'")
+ end
+
+ expect(page).to have_selector('.dropdown-toggle-text', text: "'test'")
+
+ visit(project_tree_path(project, "'test'"))
+
+ expect(page).to have_css('.tree-commit-link', visible: true)
+ expect(page).not_to have_content('Loading commit data...')
+ end
+
+ it 'shows the code with a leading dot in the directory', js: true do
+ first('.js-project-refs-dropdown').click
+
+ page.within('.project-refs-form') do
+ click_link('fix')
+ end
+
+ visit(project_tree_path(project, 'fix/.testdir'))
+
+ expect(page).to have_css('.tree-commit-link', visible: true)
+ expect(page).not_to have_content('Loading commit data...')
+ end
+
+ it 'does not show the permalink link' do
+ click_link('.gitignore')
+
+ expect(page).not_to have_link('permalink')
+ end
+ end
+
+ context 'when browsing a file content' do
+ before do
+ visit(tree_path_root_ref)
+ click_link('.gitignore')
+ end
+
+ it 'shows a file content', js: true do
+ wait_for_requests
+ expect(page).to have_content('*.rbc')
+ end
+ end
+
+ context 'when browsing a raw file' do
+ before do
+ visit(project_blob_path(project, File.join(RepoHelpers.sample_commit.id, RepoHelpers.sample_blob.path)))
+ end
+
+ it 'shows a raw file content' do
+ click_link('Open raw')
+ expect(source).to eq('') # Body is filled in by gitlab-workhorse
+ end
+ end
+
+ context 'when browsing an LFS object' do
+ before do
+ allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true)
+ visit(project_tree_path(project, 'lfs'))
+ end
+
+ it 'shows an LFS object' do
+ click_link('files')
+ click_link('lfs')
+ click_link('lfs_object.iso')
+
+ expect(page).to have_content('Download (1.5 MB)')
+ expect(page).not_to have_content('version https://git-lfs.github.com/spec/v1')
+ expect(page).not_to have_content('oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897')
+ expect(page).not_to have_content('size 1575078')
+
+ page.within('.content') do
+ expect(page).to have_content('Delete')
+ expect(page).to have_content('History')
+ expect(page).to have_content('Permalink')
+ expect(page).to have_content('Replace')
+ expect(page).not_to have_content('Annotate')
+ expect(page).not_to have_content('Blame')
+ expect(page).not_to have_content('Edit')
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'when previewing a file content' do
+ before do
+ visit(tree_path_root_ref)
+ end
+
+ it 'shows a preview of a file content', js: true do
+ find('.add-to-tree').click
+ click_link('Upload file')
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'))
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'New commit message')
+ end
+
+ fill_in(:branch_name, with: 'new_branch_name', visible: true)
+ click_button('Upload file')
+
+ visit(project_blob_path(project, 'new_branch_name/logo_sample.svg'))
+
+ expect(page).to have_css('.file-content img')
+ end
+ end
+end
diff --git a/spec/features/projects/user_create_dir_spec.rb b/spec/features/projects/user_create_dir_spec.rb
deleted file mode 100644
index 5dfdc465d7d..00000000000
--- a/spec/features/projects/user_create_dir_spec.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-require 'spec_helper'
-
-feature 'New directory creation', feature: true, js: true do
- include TargetBranchHelpers
-
- given(:user) { create(:user) }
- given(:role) { :developer }
- given(:project) { create(:project) }
-
- background do
- login_as(user)
- project.team << [user, role]
- visit namespace_project_tree_path(project.namespace, project, 'master')
- open_new_directory_modal
- fill_in 'dir_name', with: 'new_directory'
- end
-
- def open_new_directory_modal
- first('.add-to-tree').click
- click_link 'New directory'
- end
-
- def create_directory
- click_button 'Create directory'
- end
-
- context 'with default target branch' do
- background do
- create_directory
- end
-
- scenario 'creates the directory in the default branch' do
- expect(page).to have_content 'master'
- expect(page).to have_content 'The directory has been successfully created'
- expect(page).to have_content 'new_directory'
- end
- end
-
- context 'with different target branch' do
- background do
- select_branch('feature')
- create_directory
- end
-
- scenario 'creates the directory in the different branch' do
- expect(page).to have_content 'feature'
- expect(page).to have_content 'The directory has been successfully created'
- end
- end
-
- context 'with a new target branch' do
- given(:new_branch_name) { 'new-feature' }
-
- background do
- create_new_branch(new_branch_name)
- create_directory
- end
-
- scenario 'creates the directory in the new branch' do
- expect(page).to have_content new_branch_name
- expect(page).to have_content 'The directory has been successfully created'
- end
-
- scenario 'redirects to the merge request' do
- expect(page).to have_content 'New Merge Request'
- expect(page).to have_content "From #{new_branch_name} into master"
- expect(page).to have_content 'Add new directory'
- expect(current_path).to eq(new_namespace_project_merge_request_path(project.namespace, project))
- end
- end
-end
diff --git a/spec/features/projects/user_creates_directory_spec.rb b/spec/features/projects/user_creates_directory_spec.rb
new file mode 100644
index 00000000000..1ba5d83eadf
--- /dev/null
+++ b/spec/features/projects/user_creates_directory_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+feature 'User creates a directory', js: true do
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository) }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :developer]
+ sign_in(user)
+ visit project_tree_path(project, 'master')
+ end
+
+ context 'with default target branch' do
+ before do
+ first('.add-to-tree').click
+ click_link('New directory')
+ end
+
+ it 'creates the directory in the default branch' do
+ fill_in(:dir_name, with: 'new_directory')
+ click_button('Create directory')
+
+ expect(page).to have_content('master')
+ expect(page).to have_content('The directory has been successfully created')
+ expect(page).to have_content('new_directory')
+ end
+
+ it 'does not create a directory with a name of already existed directory' do
+ fill_in(:dir_name, with: 'files')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Create directory')
+
+ expect(page).to have_content('A directory with this name already exists')
+ expect(current_path).to eq(project_tree_path(project, 'master'))
+ end
+ end
+
+ context 'with a new target branch' do
+ before do
+ first('.add-to-tree').click
+ click_link('New directory')
+ fill_in(:dir_name, with: 'new_directory')
+ fill_in(:branch_name, with: 'new-feature')
+ click_button('Create directory')
+ end
+
+ it 'creates the directory in the new branch and redirect to the merge request' do
+ expect(page).to have_content('new-feature')
+ expect(page).to have_content('The directory has been successfully created')
+ expect(page).to have_content('New Merge Request')
+ expect(page).to have_content('From new-feature into master')
+ expect(page).to have_content('Add new directory')
+
+ expect(current_path).to eq(project_new_merge_request_path(project))
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'creates a directory in a forked project' do
+ find('.add-to-tree').click
+ click_link('New directory')
+
+ expect(page).to have_content(fork_message)
+
+ find('.add-to-tree').click
+ click_link('New directory')
+ fill_in(:dir_name, with: 'new_directory')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Create directory')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+ end
+ end
+end
diff --git a/spec/features/projects/user_creates_files_spec.rb b/spec/features/projects/user_creates_files_spec.rb
new file mode 100644
index 00000000000..4b78cc4fc53
--- /dev/null
+++ b/spec/features/projects/user_creates_files_spec.rb
@@ -0,0 +1,153 @@
+require 'spec_helper'
+
+describe 'User creates files' do
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ context 'without commiting a new file' do
+ context 'when an user has write access' do
+ before do
+ visit(project_tree_path_root_ref)
+ end
+
+ it 'opens new file page' do
+ find('.add-to-tree').click
+ click_link('New file')
+
+ expect(page).to have_content('New file')
+ expect(page).to have_content('Commit message')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'opens new file page on a forked project' do
+ find('.add-to-tree').click
+ click_link('New file')
+
+ expect(page).to have_selector('.file-editor')
+ expect(page).to have_content(fork_message)
+ expect(page).to have_content('New file')
+ expect(page).to have_content('Commit message')
+ end
+ end
+ end
+
+ context 'with commiting a new file' do
+ context 'when an user has write access' do
+ before do
+ visit(project_tree_path_root_ref)
+
+ find('.add-to-tree').click
+ click_link('New file')
+ end
+
+ it 'creates and commit a new file', js: true do
+ expect(page).to have_selector('.file-editor')
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:file_name, with: 'not_a_file.md')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ new_file_path = project_blob_path(project, 'master/not_a_file.md')
+
+ expect(current_path).to eq(new_file_path)
+
+ wait_for_requests
+
+ expect(page).to have_content('*.rbca')
+ end
+
+ it 'creates and commit a new file with new lines at the end of file', js: true do
+ execute_script('ace.edit("editor").setValue("Sample\n\n\n")')
+ fill_in(:file_name, with: 'not_a_file.md')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ new_file_path = project_blob_path(project, 'master/not_a_file.md')
+
+ expect(current_path).to eq(new_file_path)
+
+ find('.js-edit-blob').click
+
+ expect(evaluate_script('ace.edit("editor").getValue()')).to eq("Sample\n\n\n")
+ end
+
+ it 'creates and commit a new file with a directory name', js: true do
+ fill_in(:file_name, with: 'foo/bar/baz.txt')
+
+ expect(page).to have_selector('.file-editor')
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ expect(current_path).to eq(project_blob_path(project, 'master/foo/bar/baz.txt'))
+
+ wait_for_requests
+
+ expect(page).to have_content('*.rbca')
+ end
+
+ it 'creates and commit a new file specifying a new branch', js: true do
+ expect(page).to have_selector('.file-editor')
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:file_name, with: 'not_a_file.md')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ fill_in(:branch_name, with: 'new_branch_name', visible: true)
+ click_button('Commit changes')
+
+ expect(current_path).to eq(project_new_merge_request_path(project))
+
+ click_link('Changes')
+
+ wait_for_requests
+
+ expect(page).to have_content('*.rbca')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'creates and commit new file in forked project', js: true do
+ find('.add-to-tree').click
+ click_link('New file')
+
+ expect(page).to have_selector('.file-editor')
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+
+ fill_in(:file_name, with: 'not_a_file.md')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+ expect(page).to have_content('New commit message')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb
new file mode 100644
index 00000000000..1c3791f63ac
--- /dev/null
+++ b/spec/features/projects/user_creates_project_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+feature 'User creates a project', js: true do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ create(:personal_key, user: user)
+ visit(new_project_path)
+ end
+
+ it 'creates a new project' do
+ fill_in(:project_path, with: 'Empty')
+
+ page.within('#content-body') do
+ click_button('Create project')
+ end
+
+ project = Project.last
+
+ expect(current_path).to eq(project_path(project))
+ expect(page).to have_content('Empty')
+ expect(page).to have_content('git init')
+ expect(page).to have_content('git remote')
+ expect(page).to have_content(project.url_to_repo)
+ end
+end
diff --git a/spec/features/projects/user_deletes_files_spec.rb b/spec/features/projects/user_deletes_files_spec.rb
new file mode 100644
index 00000000000..95cd316be0e
--- /dev/null
+++ b/spec/features/projects/user_deletes_files_spec.rb
@@ -0,0 +1,68 @@
+require 'spec_helper'
+
+describe 'User deletes files' do
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when an user has write access' do
+ before do
+ project.team << [user, :master]
+ visit(project_tree_path_root_ref)
+ end
+
+ it 'deletes the file', js: true do
+ click_link('.gitignore')
+
+ expect(page).to have_content('.gitignore')
+
+ click_on('Delete')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Delete file')
+
+ expect(current_path).to eq(project_tree_path(project, 'master'))
+ expect(page).not_to have_content('.gitignore')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'deletes the file in a forked project', js: true do
+ click_link('.gitignore')
+
+ expect(page).to have_content('.gitignore')
+
+ click_on('Delete')
+
+ expect(page).to have_link('Fork')
+ expect(page).to have_button('Cancel')
+
+ click_link('Fork')
+
+ expect(page).to have_content(fork_message)
+
+ click_on('Delete')
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Delete file')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+ expect(page).to have_content('New commit message')
+ end
+ end
+end
diff --git a/spec/features/projects/user_edits_files_spec.rb b/spec/features/projects/user_edits_files_spec.rb
new file mode 100644
index 00000000000..3129aad8473
--- /dev/null
+++ b/spec/features/projects/user_edits_files_spec.rb
@@ -0,0 +1,138 @@
+require 'spec_helper'
+
+describe 'User edits files' do
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when an user has write access' do
+ before do
+ project.team << [user, :master]
+ visit(project_tree_path_root_ref)
+ end
+
+ it 'inserts a content of a file', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+
+ expect(evaluate_script('ace.edit("editor").getValue()')).to eq('*.rbca')
+ end
+
+ it 'does not show the edit link if a file is binary' do
+ binary_file = File.join(project.repository.root_ref, 'files/images/logo-black.png')
+ visit(project_blob_path(project, binary_file))
+
+ expect(page).not_to have_link('edit')
+ end
+
+ it 'commits an edited file', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ expect(current_path).to eq(project_blob_path(project, 'master/.gitignore'))
+
+ wait_for_requests
+
+ expect(page).to have_content('*.rbca')
+ end
+
+ it 'commits an edited file to a new branch', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ fill_in(:branch_name, with: 'new_branch_name', visible: true)
+ click_button('Commit changes')
+
+ expect(current_path).to eq(project_new_merge_request_path(project))
+
+ click_link('Changes')
+
+ wait_for_requests
+ expect(page).to have_content('*.rbca')
+ end
+
+ it 'shows the diff of an edited file', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ click_link('Preview changes')
+
+ expect(page).to have_css('.line_holder.new')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'inserts a content of a file in a forked project', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ expect(page).to have_link('Fork')
+ expect(page).to have_button('Cancel')
+
+ click_link('Fork')
+
+ expect(page).to have_content(
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ )
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+
+ expect(evaluate_script('ace.edit("editor").getValue()')).to eq('*.rbca')
+ end
+
+ it 'commits an edited file in a forked project', js: true do
+ click_link('.gitignore')
+ find('.js-edit-blob').click
+
+ expect(page).to have_link('Fork')
+ expect(page).to have_button('Cancel')
+
+ click_link('Fork')
+
+ wait_for_requests
+
+ execute_script("ace.edit('editor').setValue('*.rbca')")
+ fill_in(:commit_message, with: 'New commit message', visible: true)
+ click_button('Commit changes')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+
+ wait_for_requests
+
+ expect(page).to have_content('New commit message')
+ end
+ end
+end
diff --git a/spec/features/projects/user_replaces_files_spec.rb b/spec/features/projects/user_replaces_files_spec.rb
new file mode 100644
index 00000000000..e284fdefd4f
--- /dev/null
+++ b/spec/features/projects/user_replaces_files_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+describe 'User replaces files' do
+ include DropzoneHelper
+
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when an user has write access' do
+ before do
+ project.team << [user, :master]
+ visit(project_tree_path_root_ref)
+ end
+
+ it 'replaces an existed file with a new one', js: true do
+ click_link('.gitignore')
+
+ expect(page).to have_content('.gitignore')
+
+ click_on('Replace')
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'Replacement file commit message')
+ end
+
+ click_button('Replace file')
+
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
+ expect(page).to have_content('Sed ut perspiciatis unde omnis')
+ expect(page).to have_content('Replacement file commit message')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'replaces an existed file with a new one in a forked project', js: true do
+ click_link('.gitignore')
+
+ expect(page).to have_content('.gitignore')
+
+ click_on('Replace')
+
+ expect(page).to have_link('Fork')
+ expect(page).to have_button('Cancel')
+
+ click_link('Fork')
+
+ expect(page).to have_content(fork_message)
+
+ click_on('Replace')
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'Replacement file commit message')
+ end
+
+ click_button('Replace file')
+
+ expect(page).to have_content('Replacement file commit message')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+
+ click_link('Changes')
+
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
+ expect(page).to have_content('Sed ut perspiciatis unde omnis')
+ end
+ end
+end
diff --git a/spec/features/projects/user_uploads_files_spec.rb b/spec/features/projects/user_uploads_files_spec.rb
new file mode 100644
index 00000000000..98871317ca3
--- /dev/null
+++ b/spec/features/projects/user_uploads_files_spec.rb
@@ -0,0 +1,82 @@
+require 'spec_helper'
+
+describe 'User uploads files' do
+ include DropzoneHelper
+
+ let(:fork_message) do
+ "You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+ let(:project) { create(:project, :repository, name: 'Shop') }
+ let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
+ let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
+ let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ context 'when an user has write access' do
+ before do
+ visit(project_tree_path_root_ref)
+ end
+
+ it 'uploads and commit a new file', js: true do
+ find('.add-to-tree').click
+ click_link('Upload file')
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'New commit message')
+ end
+
+ fill_in(:branch_name, with: 'new_branch_name', visible: true)
+ click_button('Upload file')
+
+ expect(page).to have_content('New commit message')
+ expect(current_path).to eq(project_new_merge_request_path(project))
+
+ click_link('Changes')
+
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
+ expect(page).to have_content('Sed ut perspiciatis unde omnis')
+ end
+ end
+
+ context 'when an user does not have write access' do
+ before do
+ project2.team << [user, :reporter]
+ visit(project2_tree_path_root_ref)
+ end
+
+ it 'uploads and commit a new fileto a forked project', js: true do
+ find('.add-to-tree').click
+ click_link('Upload file')
+
+ expect(page).to have_content(fork_message)
+
+ find('.add-to-tree').click
+ click_link('Upload file')
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'New commit message')
+ end
+
+ click_button('Upload file')
+
+ expect(page).to have_content('New commit message')
+
+ fork = user.fork_of(project2)
+
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+
+ click_link('Changes')
+
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
+ expect(page).to have_content('Sed ut perspiciatis unde omnis')
+ end
+ end
+end
diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb
index b7a41ca54e6..2a316a0d0db 100644
--- a/spec/features/projects/view_on_env_spec.rb
+++ b/spec/features/projects/view_on_env_spec.rb
@@ -50,11 +50,11 @@ describe 'View on environment', js: true do
let(:merge_request) { create(:merge_request, :simple, source_project: project, source_branch: branch_name) }
before do
- login_as(user)
+ sign_in(user)
- visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
+ visit diffs_project_merge_request_path(project, merge_request)
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
@@ -66,11 +66,11 @@ describe 'View on environment', js: true do
context 'when visiting a comparison for the branch' do
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_compare_path(project.namespace, project, from: 'master', to: branch_name)
+ visit project_compare_path(project, from: 'master', to: branch_name)
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
@@ -80,11 +80,11 @@ describe 'View on environment', js: true do
context 'when visiting a comparison for the commit' do
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_compare_path(project.namespace, project, from: 'master', to: sha)
+ visit project_compare_path(project, from: 'master', to: sha)
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
@@ -94,11 +94,11 @@ describe 'View on environment', js: true do
context 'when visiting a blob on the branch' do
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_blob_path(project.namespace, project, File.join(branch_name, file_path))
+ visit project_blob_path(project, File.join(branch_name, file_path))
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
@@ -108,11 +108,11 @@ describe 'View on environment', js: true do
context 'when visiting a blob on the commit' do
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_blob_path(project.namespace, project, File.join(sha, file_path))
+ visit project_blob_path(project, File.join(sha, file_path))
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
@@ -122,11 +122,11 @@ describe 'View on environment', js: true do
context 'when visiting the commit' do
before do
- login_as(user)
+ sign_in(user)
- visit namespace_project_commit_path(project.namespace, project, sha)
+ visit project_commit_path(project, sha)
- wait_for_ajax
+ wait_for_requests
end
it 'has a "View on env" button' do
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 43d8b45669e..9a4ccf3c54d 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Wiki > User previews markdown changes', feature: true, js: true do
+feature 'Projects > Wiki > User previews markdown changes', js: true do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
let(:wiki_content) do
@@ -14,17 +14,18 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
background do
project.team << [user, :master]
- login_as(user)
-
- visit namespace_project_path(project.namespace, project)
- click_link 'Wiki'
WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
+
+ sign_in(user)
+
+ visit project_path(project)
+ find('.shortcuts-wiki').trigger('click')
end
context "while creating a new wiki page" do
context "when there are no spaces or hyphens in the page name" do
it "rewrites relative links as expected" do
- click_link 'New page'
+ find('.add-new-wiki').trigger('click')
page.within '#modal-new-wiki' do
fill_in :new_wiki_path, with: 'a/b/c/d'
click_button 'Create page'
@@ -37,10 +38,10 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/e/f/relative\">relative link 3</a>")
end
end
@@ -59,10 +60,10 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
end
end
@@ -73,7 +74,7 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
fill_in :new_wiki_path, with: 'a-page/b-page/c-page/d-page'
click_button 'Create page'
end
-
+
page.within '.wiki-form' do
fill_in :wiki_content, with: wiki_content
click_on "Preview"
@@ -81,17 +82,17 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
end
end
end
context "while editing a wiki page" do
def create_wiki_page(path)
- click_link 'New page'
+ find('.add-new-wiki').trigger('click')
page.within '#modal-new-wiki' do
fill_in :new_wiki_path, with: path
@@ -114,10 +115,10 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/e/f/relative\">relative link 3</a>")
end
end
@@ -131,10 +132,10 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
end
end
@@ -148,10 +149,10 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t
expect(page).to have_content("regular link")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/regular\">regular link</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
+ expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
end
end
end
diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb
index c1f6b0cce3b..eaff5f876b6 100644
--- a/spec/features/projects/wiki/shortcuts_spec.rb
+++ b/spec/features/projects/wiki/shortcuts_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-feature 'Wiki shortcuts', :feature, :js do
+feature 'Wiki shortcuts', :js do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
let(:wiki_page) do
WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
end
before do
- login_as(user)
- visit namespace_project_wiki_path(project.namespace, project, wiki_page)
+ sign_in(user)
+ visit project_wiki_path(project, wiki_page)
end
scenario 'Visit edit wiki page using "e" keyboard shortcut' do
diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
index 1ffac8cd542..9d66f482c8d 100644
--- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
@@ -1,20 +1,23 @@
require 'spec_helper'
-feature 'Projects > Wiki > User creates wiki page', feature: true do
+feature 'Projects > Wiki > User creates wiki page', :js do
let(:user) { create(:user) }
background do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
- visit namespace_project_path(project.namespace, project)
- click_link 'Wiki'
+ visit project_path(project)
end
context 'in the user namespace' do
let(:project) { create(:project, namespace: user.namespace) }
context 'when wiki is empty' do
+ before do
+ find('.shortcuts-wiki').trigger('click')
+ end
+
scenario 'commit message field has value "Create home"' do
expect(page).to have_field('wiki[message]', with: 'Create home')
end
@@ -28,15 +31,50 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
expect(page).to have_content("Last edited by #{user.name}")
expect(page).to have_content('My awesome wiki!')
end
+
+ scenario 'creates ASCII wiki with LaTeX blocks' do
+ stub_application_setting(plantuml_url: 'http://localhost', plantuml_enabled: true)
+
+ ascii_content = <<~MD
+ :stem: latexmath
+
+ [stem]
+ ++++
+ \sqrt{4} = 2
+ ++++
+
+ another part
+
+ [latexmath]
+ ++++
+ \beta_x \gamma
+ ++++
+
+ stem:[2+2] is 4
+ MD
+
+ find('#wiki_format option[value=asciidoc]').select_option
+ fill_in :wiki_content, with: ascii_content
+
+ page.within '.wiki-form' do
+ click_button 'Create page'
+ end
+
+ page.within '.wiki' do
+ expect(page).to have_selector('.katex', count: 3)
+ expect(page).to have_content('2+2 is 4')
+ end
+ end
end
context 'when wiki is not empty' do
before do
WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
+ find('.shortcuts-wiki').trigger('click')
end
context 'via the "new wiki page" page' do
- scenario 'when the wiki page has a single word name', js: true do
+ scenario 'when the wiki page has a single word name' do
click_link 'New page'
page.within '#modal-new-wiki' do
@@ -57,7 +95,7 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
expect(page).to have_content('My awesome wiki!')
end
- scenario 'when the wiki page has spaces in the name', js: true do
+ scenario 'when the wiki page has spaces in the name' do
click_link 'New page'
page.within '#modal-new-wiki' do
@@ -78,7 +116,7 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
expect(page).to have_content('My awesome wiki!')
end
- scenario 'when the wiki page has hyphens in the name', js: true do
+ scenario 'when the wiki page has hyphens in the name' do
click_link 'New page'
page.within '#modal-new-wiki' do
@@ -99,6 +137,22 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
expect(page).to have_content('My awesome wiki!')
end
end
+
+ scenario 'content has autocomplete' do
+ click_link 'New page'
+
+ page.within '#modal-new-wiki' do
+ fill_in :new_wiki_path, with: 'test-autocomplete'
+ click_button 'Create page'
+ end
+
+ page.within '.wiki-form' do
+ find('#wiki_content').native.send_keys('')
+ fill_in :wiki_content, with: '@'
+ end
+
+ expect(page).to have_selector('.atwho-view')
+ end
end
end
@@ -106,6 +160,10 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
let(:project) { create(:project, namespace: create(:group, :public)) }
context 'when wiki is empty' do
+ before do
+ find('.shortcuts-wiki').trigger('click')
+ end
+
scenario 'commit message field has value "Create home"' do
expect(page).to have_field('wiki[message]', with: 'Create home')
end
@@ -125,9 +183,10 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do
context 'when wiki is not empty' do
before do
WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
+ find('.shortcuts-wiki').trigger('click')
end
- scenario 'via the "new wiki page" page', js: true do
+ scenario 'via the "new wiki page" page' do
click_link 'New page'
page.within '#modal-new-wiki' do
diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
index 6825b95c8aa..9a92622ba2b 100644
--- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Projects > Wiki > User views Git access wiki page', :feature do
+describe 'Projects > Wiki > User views Git access wiki page' do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:wiki_page) do
@@ -13,14 +13,14 @@ describe 'Projects > Wiki > User views Git access wiki page', :feature do
end
before do
- login_as(user)
+ sign_in(user)
end
scenario 'Visit Wiki Page Current Commit' do
- visit namespace_project_wiki_path(project.namespace, project, wiki_page)
+ visit project_wiki_path(project, wiki_page)
click_link 'Clone repository'
- expect(page).to have_text("Clone repository #{project.wiki.path_with_namespace}")
- expect(page).to have_text(project.wiki.http_url_to_repo(user))
+ expect(page).to have_text("Clone repository #{project.wiki.full_path}")
+ expect(page).to have_text(project.wiki.http_url_to_repo)
end
end
diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
index 86cf520ea80..64a80aec205 100644
--- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
@@ -1,15 +1,14 @@
require 'spec_helper'
-feature 'Projects > Wiki > User updates wiki page', feature: true do
+feature 'Projects > Wiki > User updates wiki page' do
let(:user) { create(:user) }
+ let!(:wiki_page) { WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute }
background do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
- visit namespace_project_path(project.namespace, project)
- WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute
- click_link 'Wiki'
+ visit project_wikis_path(project)
end
context 'in the user namespace' do
@@ -42,6 +41,25 @@ feature 'Projects > Wiki > User updates wiki page', feature: true do
expect(page).to have_content('Content can\'t be blank')
expect(find('textarea#wiki_content').value).to eq ''
end
+
+ scenario 'content has autocomplete', :js do
+ click_link 'Edit'
+
+ find('#wiki_content').native.send_keys('')
+ fill_in :wiki_content, with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
+ end
+
+ scenario 'page has been updated since the user opened the edit page' do
+ click_link 'Edit'
+
+ wiki_page.update(content: 'Update')
+
+ click_button 'Save changes'
+
+ expect(page).to have_content 'Someone edited the page the same time you did.'
end
end
diff --git a/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb
index c17e06612de..92e96f11219 100644
--- a/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Wiki > User views the wiki page', feature: true do
+feature 'Projects > Wiki > User views the wiki page' do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:old_page_version_id) { wiki_page.versions.last.id }
@@ -15,7 +15,7 @@ feature 'Projects > Wiki > User views the wiki page', feature: true do
background do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
WikiPages::UpdateService.new(
project,
user,
@@ -26,18 +26,13 @@ feature 'Projects > Wiki > User views the wiki page', feature: true do
end
scenario 'Visit Wiki Page Current Commit' do
- visit namespace_project_wiki_path(project.namespace, project, wiki_page)
+ visit project_wiki_path(project, wiki_page)
expect(page).to have_selector('a.btn', text: 'Edit')
end
scenario 'Visit Wiki Page Historical Commit' do
- visit namespace_project_wiki_path(
- project.namespace,
- project,
- wiki_page,
- version_id: old_page_version_id
- )
+ visit project_wiki_path(project, wiki_page, version_id: old_page_version_id)
expect(page).not_to have_selector('a.btn', text: 'Edit')
end
diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
index 20219f3cc9a..cf9fe4c1ad1 100644
--- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-describe 'Projects > Wiki > User views wiki in project page', feature: true do
+describe 'Projects > Wiki > User views wiki in project page' do
let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_as(user)
+ sign_in(user)
end
context 'when repository is disabled for project' do
let(:project) do
- create(:empty_project,
+ create(:project,
:repository_disabled,
:merge_requests_disabled,
:builds_disabled)
@@ -27,14 +27,10 @@ describe 'Projects > Wiki > User views wiki in project page', feature: true do
end
it 'displays the correct URL for the link' do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
expect(page).to have_link(
'some link',
- href: namespace_project_wiki_path(
- project.namespace,
- project,
- 'other-page'
- )
+ href: project_wiki_path(project, 'other-page')
)
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 060e19596ae..baf3d29e6c5 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -1,12 +1,33 @@
require 'spec_helper'
-feature 'Project', feature: true do
+feature 'Project' do
+ describe 'creating from template' do
+ let(:user) { create(:user) }
+ let(:template) { Gitlab::ProjectTemplate.find(:rails) }
+
+ before do
+ sign_in user
+ visit new_project_path
+ end
+
+ it "allows creation from templates" do
+ page.choose(template.name)
+ fill_in("project_path", with: template.name)
+
+ page.within '#content-body' do
+ click_button "Create project"
+ end
+
+ expect(page).to have_content template.name
+ end
+ end
+
describe 'description' do
let(:project) { create(:project, :repository) }
- let(:path) { namespace_project_path(project.namespace, project) }
+ let(:path) { project_path(project) }
before do
- login_as(:admin)
+ sign_in(create(:admin))
end
it 'parses Markdown' do
@@ -36,12 +57,12 @@ feature 'Project', feature: true do
describe 'remove forked relationship', js: true do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
before do
- login_with user
+ sign_in user
create(:forked_project_link, forked_to_project: project)
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
end
it 'removes fork' do
@@ -57,12 +78,12 @@ feature 'Project', feature: true do
describe 'removal', js: true do
let(:user) { create(:user, username: 'test', name: 'test') }
- let(:project) { create(:empty_project, namespace: user.namespace, name: 'project1') }
+ let(:project) { create(:project, namespace: user.namespace, name: 'project1') }
before do
- login_with(user)
+ sign_in(user)
project.team << [user, :master]
- visit edit_namespace_project_path(project.namespace, project)
+ visit edit_project_path(project)
end
it 'removes a project' do
@@ -76,12 +97,12 @@ feature 'Project', feature: true do
describe 'project title' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
before do
- login_with(user)
+ sign_in(user)
project.add_user(user, Gitlab::Access::MASTER)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
it 'clicks toggle and shows dropdown', js: true do
@@ -92,16 +113,16 @@ feature 'Project', feature: true do
describe 'project title' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
- let(:project2) { create(:empty_project, namespace: user.namespace, path: 'test') }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let(:project2) { create(:project, namespace: user.namespace, path: 'test') }
let(:issue) { create(:issue, project: project) }
context 'on issues page', js: true do
before do
- login_with(user)
+ sign_in(user)
project.add_user(user, Gitlab::Access::MASTER)
project2.add_user(user, Gitlab::Access::MASTER)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
it 'clicks toggle and shows dropdown' do
@@ -123,8 +144,8 @@ feature 'Project', feature: true do
before do
project.team << [user, :master]
- login_as user
- visit namespace_project_path(project.namespace, project)
+ sign_in user
+ visit project_path(project)
end
it 'has working links to files' do
@@ -146,6 +167,21 @@ feature 'Project', feature: true do
end
end
+ describe 'activity view' do
+ let(:user) { create(:user, project_view: 'activity') }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.team << [user, :master]
+ sign_in user
+ visit project_path(project)
+ end
+
+ it 'loads activity', :js do
+ expect(page).to have_selector('.event-item')
+ end
+ end
+
def remove_with_confirm(button_text, confirm_with)
click_button button_text
fill_in 'confirm_name_input', with: confirm_with
diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb
deleted file mode 100644
index eb3cea775da..00000000000
--- a/spec/features/protected_branches/access_control_ce_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-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")
-
- unless allowed_to_push_button.text == access_type_name
- allowed_to_push_button.click
- within(".dropdown.open .dropdown-menu") { click_on access_type_name }
- end
- end
-
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id])
- end
-
- 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)
-
- within(".protected-branches-list") do
- find(".js-allowed-to-push").click
-
- within('.js-allowed-to-push-container') do
- expect(first("li")).to have_content("Roles")
- click_on access_type_name
- end
- end
-
- wait_for_ajax
-
- expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
- end
- end
-
- 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")
-
- unless allowed_to_merge_button.text == access_type_name
- allowed_to_merge_button.click
- within(".dropdown.open .dropdown-menu") { click_on access_type_name }
- end
- end
-
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
- end
-
- 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)
-
- within(".protected-branches-list") do
- find(".js-allowed-to-merge").click
-
- within('.js-allowed-to-merge-container') do
- expect(first("li")).to have_content("Roles")
- click_on access_type_name
- end
- end
-
- wait_for_ajax
-
- expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id)
- end
- end
-end
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index acc5641f930..3677bf38724 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -1,21 +1,22 @@
require 'spec_helper'
-Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f }
-feature 'Projected Branches', feature: true, js: true do
+feature 'Protected Branches', js: true do
let(:user) { create(:user, :admin) }
let(:project) { create(:project, :repository) }
- before { login_as(user) }
+ before do
+ sign_in(user)
+ end
def set_protected_branch_name(branch_name)
- find(".js-protected-branch-select").click
+ find(".js-protected-branch-select").trigger('click')
find(".dropdown-input-field").set(branch_name)
click_on("Create wildcard #{branch_name}")
end
describe "explicit protected branches" do
it "allows creating explicit protected branches" do
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('some-branch')
click_on "Protect"
@@ -28,7 +29,7 @@ feature 'Projected Branches', feature: true, js: true do
commit = create(:commit, project: project)
project.repository.add_branch(user, 'some-branch', commit.id)
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('some-branch')
click_on "Protect"
@@ -36,7 +37,7 @@ feature 'Projected Branches', feature: true, js: true do
end
it "displays an error message if the named branch does not exist" do
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('some-branch')
click_on "Protect"
@@ -46,7 +47,7 @@ feature 'Projected Branches', feature: true, js: true do
describe "wildcard protected branches" do
it "allows creating protected branches with a wildcard" do
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('*-stable')
click_on "Protect"
@@ -59,7 +60,7 @@ feature 'Projected Branches', feature: true, js: true do
project.repository.add_branch(user, 'production-stable', 'master')
project.repository.add_branch(user, 'staging-stable', 'master')
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('*-stable')
click_on "Protect"
@@ -71,11 +72,11 @@ feature 'Projected Branches', feature: true, js: true do
project.repository.add_branch(user, 'staging-stable', 'master')
project.repository.add_branch(user, 'development', 'master')
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
set_protected_branch_name('*-stable')
click_on "Protect"
- visit namespace_project_protected_branches_path(project.namespace, project)
+ visit project_protected_branches_path(project)
click_on "2 matching branches"
within(".protected-branches-list") do
diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb
deleted file mode 100644
index 5b2baf8616c..00000000000
--- a/spec/features/protected_tags/access_control_ce_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-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
index e3aa87ded28..8abd4403065 100644
--- a/spec/features/protected_tags_spec.rb
+++ b/spec/features/protected_tags_spec.rb
@@ -1,21 +1,23 @@
require 'spec_helper'
-Dir["./spec/features/protected_tags/*.rb"].sort.each { |f| require f }
-feature 'Projected Tags', feature: true, js: true do
+feature 'Protected Tags', js: true do
let(:user) { create(:user, :admin) }
let(:project) { create(:project, :repository) }
- before { login_as(user) }
+ before do
+ sign_in(user)
+ end
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}")
+ find('.protected-tags-dropdown .dropdown-menu', visible: false)
end
describe "explicit protected tags" do
it "allows creating explicit protected tags" do
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
click_on "Protect"
@@ -28,7 +30,7 @@ feature 'Projected Tags', feature: true, js: true do
commit = create(:commit, project: project)
project.repository.add_tag(user, 'some-tag', commit.id)
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
click_on "Protect"
@@ -36,7 +38,7 @@ feature 'Projected Tags', feature: true, js: true do
end
it "displays an error message if the named tag does not exist" do
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
click_on "Protect"
@@ -46,7 +48,7 @@ feature 'Projected Tags', feature: true, js: true do
describe "wildcard protected tags" do
it "allows creating protected tags with a wildcard" do
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
click_on "Protect"
@@ -59,7 +61,7 @@ feature 'Projected Tags', feature: true, js: true 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)
+ visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
click_on "Protect"
@@ -71,11 +73,11 @@ feature 'Projected Tags', feature: true, js: true do
project.repository.add_tag(user, 'staging-stable', 'master')
project.repository.add_tag(user, 'development', 'master')
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
click_on "Protect"
- visit namespace_project_protected_tags_path(project.namespace, project)
+ visit project_protected_tags_path(project)
click_on "2 matching tags"
within(".protected-tags-list") do
diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb
new file mode 100644
index 00000000000..b1f51959d54
--- /dev/null
+++ b/spec/features/raven_js_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+feature 'RavenJS', :js do
+ let(:raven_path) { '/raven.bundle.js' }
+
+ it 'should not load raven if sentry is disabled' do
+ visit new_user_session_path
+
+ expect(has_requested_raven).to eq(false)
+ end
+
+ it 'should load raven if sentry is enabled' do
+ stub_application_setting(clientside_sentry_dsn: 'https://key@domain.com/id', clientside_sentry_enabled: true)
+
+ visit new_user_session_path
+
+ expect(has_requested_raven).to eq(true)
+ end
+
+ def has_requested_raven
+ page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)}
+ end
+end
diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb
new file mode 100644
index 00000000000..3bf25221e36
--- /dev/null
+++ b/spec/features/reportable_note/commit_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe 'Reportable note on commit', :js do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'a normal note' do
+ let!(:note) { create(:note_on_commit, commit_id: sample_commit.id, project: project) }
+
+ before do
+ visit project_commit_path(project, sample_commit.id)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+
+ context 'a diff note' do
+ let!(:note) { create(:diff_note_on_commit, commit_id: sample_commit.id, project: project) }
+
+ before do
+ visit project_commit_path(project, sample_commit.id)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
new file mode 100644
index 00000000000..21e96f6f103
--- /dev/null
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe 'Reportable note on issue', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let!(:note) { create(:note_on_issue, noteable: issue, project: project) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_issue_path(project, issue)
+ end
+
+ it_behaves_like 'reportable note'
+end
diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb
new file mode 100644
index 00000000000..bb296546e06
--- /dev/null
+++ b/spec/features/reportable_note/merge_request_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe 'Reportable note on merge request', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ context 'a normal note' do
+ let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) }
+
+ it_behaves_like 'reportable note'
+ end
+
+ context 'a diff note' do
+ let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb
new file mode 100644
index 00000000000..f1e48ed46be
--- /dev/null
+++ b/spec/features/reportable_note/snippets_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe 'Reportable note on snippets', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'on project snippet' do
+ let(:snippet) { create(:project_snippet, :public, project: project, author: user) }
+ let!(:note) { create(:note_on_project_snippet, noteable: snippet, project: project) }
+
+ before do
+ visit project_snippet_path(project, snippet)
+ end
+
+ it_behaves_like 'reportable note'
+ end
+end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 0e1cc9a0f73..cac31c34ad1 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -1,20 +1,21 @@
require 'spec_helper'
describe "Runners" do
- include GitlabRoutingHelper
-
let(:user) { create(:user) }
- before { login_as(user) }
+
+ before do
+ sign_in(user)
+ end
describe "specific runners" do
before do
- @project = FactoryGirl.create :empty_project, shared_runners_enabled: false
+ @project = FactoryGirl.create :project, shared_runners_enabled: false
@project.team << [user, :master]
- @project2 = FactoryGirl.create :empty_project
+ @project2 = FactoryGirl.create :project
@project2.team << [user, :master]
- @project3 = FactoryGirl.create :empty_project
+ @project3 = FactoryGirl.create :project
@project3.team << [user, :developer]
@shared_runner = FactoryGirl.create :ci_runner, :shared
@@ -69,7 +70,7 @@ describe "Runners" do
describe "shared runners" do
before do
- @project = FactoryGirl.create :empty_project, shared_runners_enabled: false
+ @project = FactoryGirl.create :project, shared_runners_enabled: false
@project.team << [user, :master]
visit runners_path(@project)
end
@@ -86,7 +87,7 @@ describe "Runners" do
before do
stub_application_setting(shared_runners_text: shared_runners_text)
- project = FactoryGirl.create :empty_project, shared_runners_enabled: false
+ project = FactoryGirl.create :project, shared_runners_enabled: false
project.team << [user, :master]
visit runners_path(project)
end
@@ -98,7 +99,7 @@ describe "Runners" do
describe "show page" do
before do
- @project = FactoryGirl.create :empty_project
+ @project = FactoryGirl.create :project
@project.team << [user, :master]
@specific_runner = FactoryGirl.create :ci_runner
@project.runners << @specific_runner
@@ -112,7 +113,7 @@ describe "Runners" do
end
feature 'configuring runners ability to picking untagged jobs' do
- given(:project) { create(:empty_project) }
+ given(:project) { create(:project) }
given(:runner) { create(:ci_runner) }
background do
@@ -121,13 +122,15 @@ describe "Runners" do
end
scenario 'user checks default configuration' do
- visit namespace_project_runner_path(project.namespace, project, runner)
+ visit project_runner_path(project, runner)
expect(page).to have_content 'Can run untagged jobs Yes'
end
context 'when runner has tags' do
- before { runner.update_attribute(:tag_list, ['tag']) }
+ before do
+ runner.update_attribute(:tag_list, ['tag'])
+ end
scenario 'user wants to prevent runner from running untagged job' do
visit runners_path(project)
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index da6388dcdf2..6742d77937f 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe "Search", feature: true do
+describe "Search" do
include FilteredSearchHelpers
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
- let!(:issue) { create(:issue, project: project, assignee: user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let!(:issue) { create(:issue, project: project, assignees: [user]) }
let!(:issue2) { create(:issue, project: project, author: user) }
before do
- login_with(user)
+ sign_in(user)
project.team << [user, :reporter]
visit search_path
end
@@ -20,14 +20,15 @@ describe "Search", feature: true do
context 'search filters', js: true do
let(:group) { create(:group) }
+ let!(:group_project) { create(:project, group: group) }
before do
group.add_owner(user)
end
it 'shows group name after filtering' do
- find('.js-search-group-dropdown').click
- wait_for_ajax
+ find('.js-search-group-dropdown').trigger('click')
+ wait_for_requests
page.within '.search-holder' do
click_link group.name
@@ -36,10 +37,28 @@ describe "Search", feature: true do
expect(find('.js-search-group-dropdown')).to have_content(group.name)
end
+ it 'filters by group projects after filtering by group' do
+ find('.js-search-group-dropdown').trigger('click')
+ wait_for_requests
+
+ page.within '.search-holder' do
+ click_link group.name
+ end
+
+ expect(find('.js-search-group-dropdown')).to have_content(group.name)
+
+ page.within('.project-filter') do
+ find('.js-search-project-dropdown').trigger('click')
+ wait_for_requests
+
+ expect(page).to have_link(group_project.name_with_namespace)
+ end
+ end
+
it 'shows project name after filtering' do
page.within('.project-filter') do
- find('.js-search-project-dropdown').click
- wait_for_ajax
+ find('.js-search-project-dropdown').trigger('click')
+ wait_for_requests
click_link project.name_with_namespace
end
@@ -64,10 +83,12 @@ describe "Search", feature: true do
let(:project) { create(:project, :repository) }
let(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'Bug here') }
- before { note.update_attributes(commit_id: 12345678) }
+ before do
+ note.update_attributes(commit_id: 12345678)
+ end
it 'finds comment' do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
page.within '.search' do
fill_in 'search', with: note.note
@@ -90,7 +111,7 @@ describe "Search", feature: true do
project: project)
# Must visit project dashboard since global search won't search
# everything (e.g. comments, snippets, etc.)
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
page.within '.search' do
fill_in 'search', with: note.note
@@ -104,7 +125,7 @@ describe "Search", feature: true do
it 'finds a commit' do
project = create(:project, :repository) { |p| p.add_reporter(user) }
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
page.within '.search' do
fill_in 'search', with: 'add'
@@ -118,7 +139,7 @@ describe "Search", feature: true do
it 'finds a code' do
project = create(:project, :repository) { |p| p.add_reporter(user) }
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
page.within '.search' do
fill_in 'search', with: 'application.js'
@@ -133,9 +154,9 @@ describe "Search", feature: true do
end
end
- describe 'Right header search field', feature: true do
+ describe 'Right header search field' do
it 'allows enter key to search', js: true do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
fill_in 'search', with: 'gitlab'
find('#search').native.send_keys(:enter)
@@ -146,7 +167,7 @@ describe "Search", feature: true do
describe 'Search in project page' do
before do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
end
it 'shows top right search form' do
@@ -174,37 +195,33 @@ describe "Search", feature: true do
it 'takes user to her issues page when issues assigned is clicked' do
find('.dropdown-menu').click_link 'Issues assigned to me'
- sleep 2
expect(page).to have_selector('.filtered-search')
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input_empty
end
it 'takes user to her issues page when issues authored is clicked' do
find('.dropdown-menu').click_link "Issues I've created"
- sleep 2
expect(page).to have_selector('.filtered-search')
- expect_tokens([{ name: 'author', value: "@#{user.username}" }])
+ expect_tokens([author_token(user.name)])
expect_filtered_search_input_empty
end
it 'takes user to her MR page when MR assigned is clicked' do
find('.dropdown-menu').click_link 'Merge requests assigned to me'
- sleep 2
expect(page).to have_selector('.merge-requests-holder')
- expect_tokens([{ name: 'assignee', value: "@#{user.username}" }])
+ expect_tokens([assignee_token(user.name)])
expect_filtered_search_input_empty
end
it 'takes user to her MR page when MR authored is clicked' do
find('.dropdown-menu').click_link "Merge requests I've created"
- sleep 2
expect(page).to have_selector('.merge-requests-holder')
- expect_tokens([{ name: 'author', value: "@#{user.username}" }])
+ expect_tokens([author_token(user.name)])
expect_filtered_search_input_empty
end
end
@@ -235,7 +252,7 @@ describe "Search", feature: true do
click_button 'Search'
- expect(page).to have_current_path(namespace_project_commit_path(project.namespace, project, '6d394385cf567f80a8fd85055db1ab4c5295806f'))
+ expect(page).to have_current_path(project_commit_path(project, '6d394385cf567f80a8fd85055db1ab4c5295806f'))
end
it 'redirects to single commit regardless of query case' do
@@ -243,7 +260,7 @@ describe "Search", feature: true do
click_button 'Search'
- expect(page).to have_current_path(namespace_project_commit_path(project.namespace, project, '6d394385cf567f80a8fd85055db1ab4c5295806f'))
+ expect(page).to have_current_path(project_commit_path(project, '6d394385cf567f80a8fd85055db1ab4c5295806f'))
end
it 'holds on /search page when the only commit is found by message' do
diff --git a/spec/features/security/admin_access_spec.rb b/spec/features/security/admin_access_spec.rb
index e180ca53eb5..3ca1303bda6 100644
--- a/spec/features/security/admin_access_spec.rb
+++ b/spec/features/security/admin_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "Admin::Projects", feature: true do
+describe "Admin::Projects" do
include AccessMatchers
describe "GET /admin/projects" do
diff --git a/spec/features/security/dashboard_access_spec.rb b/spec/features/security/dashboard_access_spec.rb
index 40f773956d1..149bd32e736 100644
--- a/spec/features/security/dashboard_access_spec.rb
+++ b/spec/features/security/dashboard_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "Dashboard access", feature: true do
+describe "Dashboard access" do
include AccessMatchers
describe "GET /dashboard" do
diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb
index 87cce32d6c6..5067f0b0a49 100644
--- a/spec/features/security/group/internal_access_spec.rb
+++ b/spec/features/security/group/internal_access_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe 'Internal Group access', feature: true do
+describe 'Internal Group access' do
include AccessMatchers
let(:group) { create(:group, :internal) }
@@ -49,6 +49,7 @@ describe 'Internal Group access', feature: true do
end
describe 'GET /groups/:path/merge_requests' do
+ let(:project) { create(:project, :internal, :repository, group: group) }
subject { merge_requests_group_path(group) }
it { is_expected.to be_allowed_for(:admin) }
diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb
index 1d6b3e77c22..ff32413dc7e 100644
--- a/spec/features/security/group/private_access_spec.rb
+++ b/spec/features/security/group/private_access_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe 'Private Group access', feature: true do
+describe 'Private Group access' do
include AccessMatchers
let(:group) { create(:group, :private) }
@@ -49,6 +49,7 @@ describe 'Private Group access', feature: true do
end
describe 'GET /groups/:path/merge_requests' do
+ let(:project) { create(:project, :private, :repository, group: group) }
subject { merge_requests_group_path(group) }
it { is_expected.to be_allowed_for(:admin) }
diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb
index d7d76177269..16d114fb3f7 100644
--- a/spec/features/security/group/public_access_spec.rb
+++ b/spec/features/security/group/public_access_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe 'Public Group access', feature: true do
+describe 'Public Group access' do
include AccessMatchers
let(:group) { create(:group, :public) }
@@ -49,6 +49,7 @@ describe 'Public Group access', feature: true do
end
describe 'GET /groups/:path/merge_requests' do
+ let(:project) { create(:project, :public, :repository, group: group) }
subject { merge_requests_group_path(group) }
it { is_expected.to be_allowed_for(:admin) }
diff --git a/spec/features/security/profile_access_spec.rb b/spec/features/security/profile_access_spec.rb
index c19678ab381..41eb7b26578 100644
--- a/spec/features/security/profile_access_spec.rb
+++ b/spec/features/security/profile_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe "Profile access", feature: true do
+describe "Profile access" do
include AccessMatchers
describe "GET /profile/keys" do
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index 6ecdc8cbb71..a7928857b7d 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe "Internal Project Access", feature: true do
+describe "Internal Project Access" do
include AccessMatchers
- let(:project) { create(:project, :internal) }
+ set(:project) { create(:project, :internal, :repository) }
describe "Project should be internal" do
describe '#internal?' do
@@ -13,7 +13,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path" do
- subject { namespace_project_path(project.namespace, project) }
+ subject { project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -27,7 +27,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/tree/master" do
- subject { namespace_project_tree_path(project.namespace, project, project.repository.root_ref) }
+ subject { project_tree_path(project, project.repository.root_ref) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -41,7 +41,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/commits/master" do
- subject { namespace_project_commits_path(project.namespace, project, project.repository.root_ref, limit: 1) }
+ subject { project_commits_path(project, project.repository.root_ref, limit: 1) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -55,7 +55,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/commit/:sha" do
- subject { namespace_project_commit_path(project.namespace, project, project.repository.commit) }
+ subject { project_commit_path(project, project.repository.commit) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -69,7 +69,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/compare" do
- subject { namespace_project_compare_index_path(project.namespace, project) }
+ subject { project_compare_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -83,7 +83,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/settings/members" do
- subject { namespace_project_settings_members_path(project.namespace, project) }
+ subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -97,7 +97,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/settings/ci_cd" do
- subject { namespace_project_settings_ci_cd_path(project.namespace, project) }
+ subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -111,7 +111,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/settings/repository" do
- subject { namespace_project_settings_repository_path(project.namespace, project) }
+ subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -126,7 +126,7 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/blob" do
let(:commit) { project.repository.commit }
- subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) }
+ subject { project_blob_path(project, File.join(commit.id, '.gitignore')) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -140,7 +140,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/edit" do
- subject { edit_namespace_project_path(project.namespace, project) }
+ subject { edit_project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -154,7 +154,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/deploy_keys" do
- subject { namespace_project_deploy_keys_path(project.namespace, project) }
+ subject { project_deploy_keys_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -168,7 +168,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/issues" do
- subject { namespace_project_issues_path(project.namespace, project) }
+ subject { project_issues_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -183,7 +183,7 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/issues/:id/edit" do
let(:issue) { create(:issue, project: project) }
- subject { edit_namespace_project_issue_path(project.namespace, project, issue) }
+ subject { edit_project_issue_path(project, issue) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -197,7 +197,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -211,7 +211,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/snippets/new" do
- subject { new_namespace_project_snippet_path(project.namespace, project) }
+ subject { new_project_snippet_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -225,7 +225,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/merge_requests" do
- subject { namespace_project_merge_requests_path(project.namespace, project) }
+ subject { project_merge_requests_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -239,7 +239,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/merge_requests/new" do
- subject { new_namespace_project_merge_request_path(project.namespace, project) }
+ subject { project_new_merge_request_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -253,7 +253,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/branches" do
- subject { namespace_project_branches_path(project.namespace, project) }
+ subject { project_branches_path(project) }
before do
# Speed increase
@@ -272,7 +272,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/tags" do
- subject { namespace_project_tags_path(project.namespace, project) }
+ subject { project_tags_path(project) }
before do
# Speed increase
@@ -291,7 +291,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/settings/integrations" do
- subject { namespace_project_settings_integrations_path(project.namespace, project) }
+ subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -305,7 +305,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/pipelines" do
- subject { namespace_project_pipelines_path(project.namespace, project) }
+ subject { project_pipelines_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -320,7 +320,7 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/pipelines/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
- subject { namespace_project_pipeline_path(project.namespace, project, pipeline) }
+ subject { project_pipeline_path(project, pipeline) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -334,10 +334,12 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/builds" do
- subject { namespace_project_builds_path(project.namespace, project) }
+ subject { project_jobs_path(project) }
context "when allowed for public and internal" do
- before { project.update(public_builds: true) }
+ before do
+ project.update(public_builds: true)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -351,7 +353,9 @@ describe "Internal Project Access", feature: true do
end
context "when disallowed for public and internal" do
- before { project.update(public_builds: false) }
+ before do
+ project.update(public_builds: false)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -368,10 +372,12 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
- subject { namespace_project_build_path(project.namespace, project, build.id) }
+ subject { project_job_path(project, build.id) }
context "when allowed for public and internal" do
- before { project.update(public_builds: true) }
+ before do
+ project.update(public_builds: true)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -385,7 +391,9 @@ describe "Internal Project Access", feature: true do
end
context "when disallowed for public and internal" do
- before { project.update(public_builds: false) }
+ before do
+ project.update(public_builds: false)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -399,8 +407,60 @@ describe "Internal Project Access", feature: true do
end
end
+ describe 'GET /:project_path/builds/:id/trace' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ subject { trace_project_job_path(project, build.id) }
+
+ context 'when allowed for public and internal' do
+ before do
+ project.update(public_builds: true)
+ end
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ context 'when disallowed for public and internal' do
+ before do
+ project.update(public_builds: false)
+ end
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+ end
+
+ describe "GET /:project_path/pipeline_schedules" do
+ subject { project_pipeline_schedules_path(project) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
describe "GET /:project_path/environments" do
- subject { namespace_project_environments_path(project.namespace, project) }
+ subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -415,7 +475,22 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/environments/:id" do
let(:environment) { create(:environment, project: project) }
- subject { namespace_project_environment_path(project.namespace, project, environment) }
+ subject { project_environment_path(project, environment) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/environments/:id/deployments" do
+ let(:environment) { create(:environment, project: project) }
+ subject { project_environment_deployments_path(project, environment) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -429,7 +504,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/environments/new" do
- subject { new_namespace_project_environment_path(project.namespace, project) }
+ subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -451,7 +526,7 @@ describe "Internal Project Access", feature: true do
project.container_repositories << container_repository
end
- subject { namespace_project_container_registry_index_path(project.namespace, project) }
+ subject { project_container_registry_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index a8fc0624588..a4396b20afd 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe "Private Project Access", feature: true do
+describe "Private Project Access" do
include AccessMatchers
- let(:project) { create(:project, :private, public_builds: false) }
+ set(:project) { create(:project, :private, :repository, public_builds: false) }
describe "Project should be private" do
describe '#private?' do
@@ -13,7 +13,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path" do
- subject { namespace_project_path(project.namespace, project) }
+ subject { project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -27,7 +27,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/tree/master" do
- subject { namespace_project_tree_path(project.namespace, project, project.repository.root_ref) }
+ subject { project_tree_path(project, project.repository.root_ref) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -41,7 +41,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/commits/master" do
- subject { namespace_project_commits_path(project.namespace, project, project.repository.root_ref, limit: 1) }
+ subject { project_commits_path(project, project.repository.root_ref, limit: 1) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -55,7 +55,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/commit/:sha" do
- subject { namespace_project_commit_path(project.namespace, project, project.repository.commit) }
+ subject { project_commit_path(project, project.repository.commit) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -69,7 +69,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/compare" do
- subject { namespace_project_compare_index_path(project.namespace, project) }
+ subject { project_compare_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -83,7 +83,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/settings/members" do
- subject { namespace_project_settings_members_path(project.namespace, project) }
+ subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -97,7 +97,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/settings/ci_cd" do
- subject { namespace_project_settings_ci_cd_path(project.namespace, project) }
+ subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -111,7 +111,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/settings/repository" do
- subject { namespace_project_settings_repository_path(project.namespace, project) }
+ subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -126,7 +126,7 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/blob" do
let(:commit) { project.repository.commit }
- subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore'))}
+ subject { project_blob_path(project, File.join(commit.id, '.gitignore'))}
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -140,7 +140,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/edit" do
- subject { edit_namespace_project_path(project.namespace, project) }
+ subject { edit_project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -154,7 +154,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/deploy_keys" do
- subject { namespace_project_deploy_keys_path(project.namespace, project) }
+ subject { project_deploy_keys_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -168,7 +168,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/issues" do
- subject { namespace_project_issues_path(project.namespace, project) }
+ subject { project_issues_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -183,7 +183,7 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/issues/:id/edit" do
let(:issue) { create(:issue, project: project) }
- subject { edit_namespace_project_issue_path(project.namespace, project, issue) }
+ subject { edit_project_issue_path(project, issue) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -197,7 +197,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -211,7 +211,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/merge_requests" do
- subject { namespace_project_merge_requests_path(project.namespace, project) }
+ subject { project_merge_requests_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -225,7 +225,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/branches" do
- subject { namespace_project_branches_path(project.namespace, project) }
+ subject { project_branches_path(project) }
before do
# Speed increase
@@ -244,7 +244,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/tags" do
- subject { namespace_project_tags_path(project.namespace, project) }
+ subject { project_tags_path(project) }
before do
# Speed increase
@@ -263,7 +263,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/namespace/hooks" do
- subject { namespace_project_settings_integrations_path(project.namespace, project) }
+ subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -277,7 +277,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/pipelines" do
- subject { namespace_project_pipelines_path(project.namespace, project) }
+ subject { project_pipelines_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -304,7 +304,7 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/pipelines/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
- subject { namespace_project_pipeline_path(project.namespace, project, pipeline) }
+ subject { project_pipeline_path(project, pipeline) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -330,7 +330,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/builds" do
- subject { namespace_project_builds_path(project.namespace, project) }
+ subject { project_jobs_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -358,7 +358,7 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
- subject { namespace_project_build_path(project.namespace, project, build.id) }
+ subject { project_job_path(project, build.id) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -388,8 +388,40 @@ describe "Private Project Access", feature: true do
end
end
+ describe 'GET /:project_path/builds/:id/trace' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ subject { trace_project_job_path(project, build.id) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+
+ context 'when public builds is enabled' do
+ before do
+ project.update(public_builds: true)
+ end
+
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ end
+
+ context 'when public builds is disabled' do
+ before do
+ project.update(public_builds: false)
+ end
+
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ end
+ end
+
describe "GET /:project_path/environments" do
- subject { namespace_project_environments_path(project.namespace, project) }
+ subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -404,7 +436,22 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/environments/:id" do
let(:environment) { create(:environment, project: project) }
- subject { namespace_project_environment_path(project.namespace, project, environment) }
+ subject { project_environment_path(project, environment) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/environments/:id/deployments" do
+ let(:environment) { create(:environment, project: project) }
+ subject { project_environment_deployments_path(project, environment) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -418,7 +465,49 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/environments/new" do
- subject { new_namespace_project_environment_path(project.namespace, project) }
+ subject { new_project_environment_path(project) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_denied_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/pipeline_schedules" do
+ subject { project_pipeline_schedules_path(project) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/pipeline_schedules/new" do
+ subject { new_project_pipeline_schedule_path(project) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_denied_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/environments/new" do
+ subject { new_project_pipeline_schedule_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -440,7 +529,7 @@ describe "Private Project Access", feature: true do
project.container_repositories << container_repository
end
- subject { namespace_project_container_registry_index_path(project.namespace, project) }
+ subject { project_container_registry_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index c4d2f50ca14..fccdeb0e5b7 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe "Public Project Access", feature: true do
+describe "Public Project Access" do
include AccessMatchers
- let(:project) { create(:project, :public) }
+ set(:project) { create(:project, :public, :repository) }
describe "Project should be public" do
describe '#public?' do
@@ -13,7 +13,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path" do
- subject { namespace_project_path(project.namespace, project) }
+ subject { project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -27,7 +27,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/tree/master" do
- subject { namespace_project_tree_path(project.namespace, project, project.repository.root_ref) }
+ subject { project_tree_path(project, project.repository.root_ref) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -41,7 +41,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/commits/master" do
- subject { namespace_project_commits_path(project.namespace, project, project.repository.root_ref, limit: 1) }
+ subject { project_commits_path(project, project.repository.root_ref, limit: 1) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -55,7 +55,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/commit/:sha" do
- subject { namespace_project_commit_path(project.namespace, project, project.repository.commit) }
+ subject { project_commit_path(project, project.repository.commit) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -69,7 +69,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/compare" do
- subject { namespace_project_compare_index_path(project.namespace, project) }
+ subject { project_compare_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -83,7 +83,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/settings/members" do
- subject { namespace_project_settings_members_path(project.namespace, project) }
+ subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -97,7 +97,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/settings/ci_cd" do
- subject { namespace_project_settings_ci_cd_path(project.namespace, project) }
+ subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -111,7 +111,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/settings/repository" do
- subject { namespace_project_settings_repository_path(project.namespace, project) }
+ subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -125,7 +125,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/pipelines" do
- subject { namespace_project_pipelines_path(project.namespace, project) }
+ subject { project_pipelines_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -140,7 +140,7 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/pipelines/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
- subject { namespace_project_pipeline_path(project.namespace, project, pipeline) }
+ subject { project_pipeline_path(project, pipeline) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -154,10 +154,12 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/builds" do
- subject { namespace_project_builds_path(project.namespace, project) }
+ subject { project_jobs_path(project) }
context "when allowed for public" do
- before { project.update(public_builds: true) }
+ before do
+ project.update(public_builds: true)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -171,7 +173,9 @@ describe "Public Project Access", feature: true do
end
context "when disallowed for public" do
- before { project.update(public_builds: false) }
+ before do
+ project.update(public_builds: false)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -188,10 +192,12 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
- subject { namespace_project_build_path(project.namespace, project, build.id) }
+ subject { project_job_path(project, build.id) }
context "when allowed for public" do
- before { project.update(public_builds: true) }
+ before do
+ project.update(public_builds: true)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -205,7 +211,9 @@ describe "Public Project Access", feature: true do
end
context "when disallowed for public" do
- before { project.update(public_builds: false) }
+ before do
+ project.update(public_builds: false)
+ end
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -219,8 +227,60 @@ describe "Public Project Access", feature: true do
end
end
+ describe 'GET /:project_path/builds/:id/trace' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ subject { trace_project_job_path(project, build.id) }
+
+ context 'when allowed for public' do
+ before do
+ project.update(public_builds: true)
+ end
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_allowed_for(:external) }
+ it { is_expected.to be_allowed_for(:visitor) }
+ end
+
+ context 'when disallowed for public' do
+ before do
+ project.update(public_builds: false)
+ end
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+ end
+
+ describe "GET /:project_path/pipeline_schedules" do
+ subject { project_pipeline_schedules_path(project) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_allowed_for(:external) }
+ it { is_expected.to be_allowed_for(:visitor) }
+ end
+
describe "GET /:project_path/environments" do
- subject { namespace_project_environments_path(project.namespace, project) }
+ subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -235,7 +295,22 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/environments/:id" do
let(:environment) { create(:environment, project: project) }
- subject { namespace_project_environment_path(project.namespace, project, environment) }
+ subject { project_environment_path(project, environment) }
+
+ it { is_expected.to be_allowed_for(:admin) }
+ it { is_expected.to be_allowed_for(:owner).of(project) }
+ it { is_expected.to be_allowed_for(:master).of(project) }
+ it { is_expected.to be_allowed_for(:developer).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_denied_for(:guest).of(project) }
+ it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_denied_for(:external) }
+ it { is_expected.to be_denied_for(:visitor) }
+ end
+
+ describe "GET /:project_path/environments/:id/deployments" do
+ let(:environment) { create(:environment, project: project) }
+ subject { project_environment_deployments_path(project, environment) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -249,7 +324,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/environments/new" do
- subject { new_namespace_project_environment_path(project.namespace, project) }
+ subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -265,7 +340,7 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/blob" do
let(:commit) { project.repository.commit }
- subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) }
+ subject { project_blob_path(project, File.join(commit.id, '.gitignore')) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -278,7 +353,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/edit" do
- subject { edit_namespace_project_path(project.namespace, project) }
+ subject { edit_project_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -292,7 +367,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/deploy_keys" do
- subject { namespace_project_deploy_keys_path(project.namespace, project) }
+ subject { project_deploy_keys_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -306,7 +381,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/issues" do
- subject { namespace_project_issues_path(project.namespace, project) }
+ subject { project_issues_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -321,7 +396,7 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/issues/:id/edit" do
let(:issue) { create(:issue, project: project) }
- subject { edit_namespace_project_issue_path(project.namespace, project, issue) }
+ subject { edit_project_issue_path(project, issue) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -335,7 +410,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -349,7 +424,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/snippets/new" do
- subject { new_namespace_project_snippet_path(project.namespace, project) }
+ subject { new_project_snippet_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -363,7 +438,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/merge_requests" do
- subject { namespace_project_merge_requests_path(project.namespace, project) }
+ subject { project_merge_requests_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -377,7 +452,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/merge_requests/new" do
- subject { new_namespace_project_merge_request_path(project.namespace, project) }
+ subject { project_new_merge_request_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -391,7 +466,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/branches" do
- subject { namespace_project_branches_path(project.namespace, project) }
+ subject { project_branches_path(project) }
before do
# Speed increase
@@ -410,7 +485,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/tags" do
- subject { namespace_project_tags_path(project.namespace, project) }
+ subject { project_tags_path(project) }
before do
# Speed increase
@@ -429,7 +504,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/settings/integrations" do
- subject { namespace_project_settings_integrations_path(project.namespace, project) }
+ subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -451,7 +526,7 @@ describe "Public Project Access", feature: true do
project.container_repositories << container_repository
end
- subject { namespace_project_container_registry_index_path(project.namespace, project) }
+ subject { project_container_registry_index_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/security/project/snippet/internal_access_spec.rb b/spec/features/security/project/snippet/internal_access_spec.rb
index 2659b3ee3ec..d7dc99c0a57 100644
--- a/spec/features/security/project/snippet/internal_access_spec.rb
+++ b/spec/features/security/project/snippet/internal_access_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe "Internal Project Snippets Access", feature: true do
+describe "Internal Project Snippets Access" do
include AccessMatchers
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
let(:internal_snippet) { create(:project_snippet, :internal, project: project, author: project.owner) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: project.owner) }
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -23,7 +23,7 @@ describe "Internal Project Snippets Access", feature: true do
end
describe "GET /:project_path/snippets/new" do
- subject { new_namespace_project_snippet_path(project.namespace, project) }
+ subject { new_project_snippet_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -38,7 +38,7 @@ describe "Internal Project Snippets Access", feature: true do
describe "GET /:project_path/snippets/:id" do
context "for an internal snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, internal_snippet) }
+ subject { project_snippet_path(project, internal_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -52,7 +52,7 @@ describe "Internal Project Snippets Access", feature: true do
end
context "for a private snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -68,7 +68,7 @@ describe "Internal Project Snippets Access", feature: true do
describe "GET /:project_path/snippets/:id/raw" do
context "for an internal snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, internal_snippet) }
+ subject { raw_project_snippet_path(project, internal_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -82,7 +82,7 @@ describe "Internal Project Snippets Access", feature: true do
end
context "for a private snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { raw_project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/security/project/snippet/private_access_spec.rb b/spec/features/security/project/snippet/private_access_spec.rb
index 6eb9f163bd5..3ec1a388185 100644
--- a/spec/features/security/project/snippet/private_access_spec.rb
+++ b/spec/features/security/project/snippet/private_access_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-describe "Private Project Snippets Access", feature: true do
+describe "Private Project Snippets Access" do
include AccessMatchers
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: project.owner) }
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -22,7 +22,7 @@ describe "Private Project Snippets Access", feature: true do
end
describe "GET /:project_path/snippets/new" do
- subject { new_namespace_project_snippet_path(project.namespace, project) }
+ subject { new_project_snippet_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -36,7 +36,7 @@ describe "Private Project Snippets Access", feature: true do
end
describe "GET /:project_path/snippets/:id for a private snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -50,7 +50,7 @@ describe "Private Project Snippets Access", feature: true do
end
describe "GET /:project_path/snippets/:id/raw for a private snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { raw_project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/security/project/snippet/public_access_spec.rb b/spec/features/security/project/snippet/public_access_spec.rb
index f3329d0bc96..39b104bfe27 100644
--- a/spec/features/security/project/snippet/public_access_spec.rb
+++ b/spec/features/security/project/snippet/public_access_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-describe "Public Project Snippets Access", feature: true do
+describe "Public Project Snippets Access" do
include AccessMatchers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:public_snippet) { create(:project_snippet, :public, project: project, author: project.owner) }
let(:internal_snippet) { create(:project_snippet, :internal, project: project, author: project.owner) }
let(:private_snippet) { create(:project_snippet, :private, project: project, author: project.owner) }
describe "GET /:project_path/snippets" do
- subject { namespace_project_snippets_path(project.namespace, project) }
+ subject { project_snippets_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -24,7 +24,7 @@ describe "Public Project Snippets Access", feature: true do
end
describe "GET /:project_path/snippets/new" do
- subject { new_namespace_project_snippet_path(project.namespace, project) }
+ subject { new_project_snippet_path(project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -39,7 +39,7 @@ describe "Public Project Snippets Access", feature: true do
describe "GET /:project_path/snippets/:id" do
context "for a public snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, public_snippet) }
+ subject { project_snippet_path(project, public_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -53,7 +53,7 @@ describe "Public Project Snippets Access", feature: true do
end
context "for an internal snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, internal_snippet) }
+ subject { project_snippet_path(project, internal_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -67,7 +67,7 @@ describe "Public Project Snippets Access", feature: true do
end
context "for a private snippet" do
- subject { namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -83,7 +83,7 @@ describe "Public Project Snippets Access", feature: true do
describe "GET /:project_path/snippets/:id/raw" do
context "for a public snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, public_snippet) }
+ subject { raw_project_snippet_path(project, public_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -97,7 +97,7 @@ describe "Public Project Snippets Access", feature: true do
end
context "for an internal snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, internal_snippet) }
+ subject { raw_project_snippet_path(project, internal_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
@@ -111,7 +111,7 @@ describe "Public Project Snippets Access", feature: true do
end
context "for a private snippet" do
- subject { raw_namespace_project_snippet_path(project.namespace, project, private_snippet) }
+ subject { raw_project_snippet_path(project, private_snippet) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb
index 9fde8d6e5cf..b6367b88e17 100644
--- a/spec/features/signup_spec.rb
+++ b/spec/features/signup_spec.rb
@@ -1,9 +1,11 @@
require 'spec_helper'
-feature 'Signup', feature: true do
+feature 'Signup' do
describe 'signup with no errors' do
context "when sending confirmation email" do
- before { allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(true) }
+ before do
+ stub_application_setting(send_user_confirmation_email: true)
+ end
it 'creates the user account and sends a confirmation email' do
user = build(:user)
@@ -23,7 +25,9 @@ feature 'Signup', feature: true do
end
context "when not sending confirmation email" do
- before { allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(false) }
+ before do
+ stub_application_setting(send_user_confirmation_email: false)
+ end
it 'creates the user account and goes to dashboard' do
user = build(:user)
diff --git a/spec/features/snippets/create_snippet_spec.rb b/spec/features/snippets/create_snippet_spec.rb
deleted file mode 100644
index 5470276bf06..00000000000
--- a/spec/features/snippets/create_snippet_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require 'rails_helper'
-
-feature 'Create Snippet', feature: true do
- before do
- login_as :user
- visit new_snippet_path
- end
-
- scenario 'Authenticated user creates a snippet' do
- fill_in 'personal_snippet_title', with: 'My Snippet Title'
- page.within('.file-editor') do
- find(:xpath, "//input[@id='personal_snippet_content']").set 'Hello World!'
- end
-
- click_button 'Create snippet'
-
- expect(page).to have_content('My Snippet Title')
- expect(page).to have_content('Hello World!')
- end
-
- scenario 'Authenticated user creates a snippet with + in filename' do
- fill_in 'personal_snippet_title', with: 'My Snippet Title'
- page.within('.file-editor') do
- find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name'
- find(:xpath, "//input[@id='personal_snippet_content']").set 'Hello World!'
- end
-
- click_button 'Create snippet'
-
- expect(page).to have_content('My Snippet Title')
- expect(page).to have_content('snippet+file+name')
- expect(page).to have_content('Hello World!')
- end
-end
diff --git a/spec/features/snippets/explore_spec.rb b/spec/features/snippets/explore_spec.rb
index 10a4597e467..835fd90adc8 100644
--- a/spec/features/snippets/explore_spec.rb
+++ b/spec/features/snippets/explore_spec.rb
@@ -1,16 +1,33 @@
require 'rails_helper'
-feature 'Explore Snippets', feature: true do
- scenario 'User should see snippets that are not private' do
- public_snippet = create(:personal_snippet, :public)
- internal_snippet = create(:personal_snippet, :internal)
- private_snippet = create(:personal_snippet, :private)
+feature 'Explore Snippets' do
+ let!(:public_snippet) { create(:personal_snippet, :public) }
+ let!(:internal_snippet) { create(:personal_snippet, :internal) }
+ let!(:private_snippet) { create(:personal_snippet, :private) }
- login_as create(:user)
+ scenario 'User should see snippets that are not private' do
+ sign_in create(:user)
visit explore_snippets_path
expect(page).to have_content(public_snippet.title)
expect(page).to have_content(internal_snippet.title)
expect(page).not_to have_content(private_snippet.title)
end
+
+ scenario 'External user should see only public snippets' do
+ sign_in create(:user, :external)
+ visit explore_snippets_path
+
+ expect(page).to have_content(public_snippet.title)
+ expect(page).not_to have_content(internal_snippet.title)
+ expect(page).not_to have_content(private_snippet.title)
+ end
+
+ scenario 'Not authenticated user should see only public snippets' do
+ visit explore_snippets_path
+
+ expect(page).to have_content(public_snippet.title)
+ expect(page).not_to have_content(internal_snippet.title)
+ expect(page).not_to have_content(private_snippet.title)
+ end
end
diff --git a/spec/features/snippets/internal_snippet_spec.rb b/spec/features/snippets/internal_snippet_spec.rb
new file mode 100644
index 00000000000..3a229612235
--- /dev/null
+++ b/spec/features/snippets/internal_snippet_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+feature 'Internal Snippets', js: true do
+ let(:internal_snippet) { create(:personal_snippet, :internal) }
+
+ describe 'normal user' do
+ before do
+ sign_in(create(:user))
+ end
+
+ scenario 'sees internal snippets' do
+ visit snippet_path(internal_snippet)
+
+ expect(page).to have_content(internal_snippet.content)
+ end
+
+ scenario 'sees raw internal snippets' do
+ visit raw_snippet_path(internal_snippet)
+
+ expect(page).to have_content(internal_snippet.content)
+ end
+ end
+end
diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb
new file mode 100644
index 00000000000..c0c293dee78
--- /dev/null
+++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb
@@ -0,0 +1,124 @@
+require 'spec_helper'
+
+describe 'Comments on personal snippets', :js do
+ include NoteInteractionHelpers
+
+ let!(:user) { create(:user) }
+ let!(:snippet) { create(:personal_snippet, :public) }
+ let!(:snippet_notes) do
+ [
+ create(:note_on_personal_snippet, noteable: snippet, author: user),
+ create(:note_on_personal_snippet, noteable: snippet)
+ ]
+ end
+ let!(:other_note) { create(:note_on_personal_snippet) }
+
+ before do
+ sign_in user
+ visit snippet_path(snippet)
+ end
+
+ subject { page }
+
+ context 'when viewing the snippet detail page' do
+ it 'contains notes for a snippet with correct action icons' do
+ expect(page).to have_selector('#notes-list li', count: 2)
+
+ open_more_actions_dropdown(snippet_notes[0])
+
+ # comment authored by current user
+ page.within("#notes-list li#note_#{snippet_notes[0].id}") do
+ expect(page).to have_content(snippet_notes[0].note)
+ expect(page).to have_selector('.js-note-delete')
+ expect(page).to have_selector('.note-emoji-button')
+ end
+
+ find('body').click # close dropdown
+ open_more_actions_dropdown(snippet_notes[1])
+
+ page.within("#notes-list li#note_#{snippet_notes[1].id}") do
+ expect(page).to have_content(snippet_notes[1].note)
+ expect(page).not_to have_selector('.js-note-delete')
+ expect(page).to have_selector('.note-emoji-button')
+ end
+ end
+ end
+
+ context 'when submitting a note' do
+ it 'shows a valid form' do
+ is_expected.to have_css('.js-main-target-form', visible: true, count: 1)
+ 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')
+ end
+ end
+
+ it 'previews a note' do
+ fill_in 'note[note]', with: 'This is **awesome**!'
+ find('.js-md-preview-button').click
+
+ page.within('.new-note .md-preview') do
+ expect(page).to have_content('This is awesome!')
+ expect(page).to have_selector('strong')
+ end
+ end
+
+ it 'creates a note' do
+ fill_in 'note[note]', with: 'This is **awesome**!'
+ click_button 'Comment'
+
+ expect(find('div#notes')).to have_content('This is awesome!')
+ end
+
+ it 'should not have autocomplete' do
+ wait_for_requests
+ request_count_before = page.driver.network_traffic.count
+
+ find('#note_note').native.send_keys('')
+ fill_in 'note[note]', with: '@'
+
+ wait_for_requests
+ request_count_after = page.driver.network_traffic.count
+
+ # This selector probably won't be in place even if autocomplete was enabled
+ # but we want to make sure
+ expect(page).not_to have_selector('.atwho-view')
+ expect(request_count_before).to eq(request_count_after)
+ end
+ end
+
+ context 'when editing a note' do
+ it 'changes the text' do
+ find('.js-note-edit').click
+
+ page.within('.current-note-edit-form') do
+ fill_in 'note[note]', with: 'new content'
+ find('.btn-save').click
+ end
+
+ page.within("#notes-list li#note_#{snippet_notes[0].id}") do
+ edited_text = find('.edited-text')
+
+ expect(page).to have_css('.note_edited_ago')
+ expect(page).to have_content('new content')
+ expect(edited_text).to have_selector('.note_edited_ago')
+ end
+ end
+ end
+
+ context 'when deleting a note' do
+ it 'removes the note from the snippet detail page' do
+ open_more_actions_dropdown(snippet_notes[0])
+
+ page.within("#notes-list li#note_#{snippet_notes[0].id}") do
+ click_on 'Delete comment'
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_selector("#notes-list li#note_#{snippet_notes[0].id}")
+ end
+ end
+end
diff --git a/spec/features/snippets/public_snippets_spec.rb b/spec/features/snippets/public_snippets_spec.rb
index 34300ccb940..bdeeca7187e 100644
--- a/spec/features/snippets/public_snippets_spec.rb
+++ b/spec/features/snippets/public_snippets_spec.rb
@@ -1,10 +1,11 @@
require 'rails_helper'
-feature 'Public Snippets', feature: true do
+feature 'Public Snippets', :js do
scenario 'Unauthenticated user should see public snippets' do
public_snippet = create(:personal_snippet, :public)
visit snippet_path(public_snippet)
+ wait_for_requests
expect(page).to have_content(public_snippet.content)
end
diff --git a/spec/features/snippets/search_snippets_spec.rb b/spec/features/snippets/search_snippets_spec.rb
index 146cd3af848..cd66a2cb20c 100644
--- a/spec/features/snippets/search_snippets_spec.rb
+++ b/spec/features/snippets/search_snippets_spec.rb
@@ -1,11 +1,11 @@
require 'rails_helper'
-feature 'Search Snippets', feature: true do
+feature 'Search Snippets' do
scenario 'User searches for snippets by title' do
public_snippet = create(:personal_snippet, :public, title: 'Beginning and Middle')
private_snippet = create(:personal_snippet, :private, title: 'Middle and End')
- login_as private_snippet.author
+ sign_in private_snippet.author
visit dashboard_snippets_path
page.within '.search' do
@@ -41,7 +41,7 @@ feature 'Search Snippets', feature: true do
CONTENT
)
- login_as create(:user)
+ sign_in create(:user)
visit dashboard_snippets_path
page.within '.search' do
diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb
new file mode 100644
index 00000000000..5a48f5774ca
--- /dev/null
+++ b/spec/features/snippets/show_spec.rb
@@ -0,0 +1,138 @@
+require 'spec_helper'
+
+feature 'Snippet', :js do
+ let(:project) { create(:project, :repository) }
+ let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content) }
+
+ context 'Ruby file' do
+ let(:file_name) { 'popen.rb' }
+ let(:content) { project.repository.blob_at('master', 'files/ruby/popen.rb').data }
+
+ before do
+ visit snippet_path(snippet)
+
+ wait_for_requests
+ end
+
+ it 'displays the blob' do
+ aggregate_failures do
+ # shows highlighted Ruby code
+ expect(page).to have_content("require 'fileutils'")
+
+ # does not show a viewer switcher
+ expect(page).not_to have_selector('.js-blob-viewer-switcher')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+ end
+
+ context 'Markdown file' do
+ let(:file_name) { 'ruby-style-guide.md' }
+ let(:content) { project.repository.blob_at('master', 'files/markdown/ruby-style-guide.md').data }
+
+ context 'visiting directly' do
+ before do
+ visit snippet_path(snippet)
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows rendered Markdown
+ expect(page).to have_link("PEP-8")
+
+ # shows a viewer switcher
+ expect(page).to have_selector('.js-blob-viewer-switcher')
+
+ # shows a disabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn.disabled')
+
+ # shows a raw button
+ expect(page).to have_link('Open raw')
+
+ # shows a download button
+ expect(page).to have_link('Download')
+ end
+ end
+
+ context 'switching to the simple viewer' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=simple]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # shows highlighted Markdown code
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+
+ context 'switching to the rich viewer again' do
+ before do
+ find('.js-blob-viewer-switch-btn[data-viewer=rich]').click
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the rich viewer' do
+ aggregate_failures do
+ # hides the simple viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false)
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]')
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+ end
+
+ context 'visiting with a line number anchor' do
+ before do
+ visit snippet_path(snippet, anchor: 'L1')
+
+ wait_for_requests
+ end
+
+ it 'displays the blob using the simple viewer' do
+ aggregate_failures do
+ # hides the rich viewer
+ expect(page).to have_selector('.blob-viewer[data-type="simple"]')
+ expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false)
+
+ # highlights the line in question
+ expect(page).to have_selector('#LC1.hll')
+
+ # shows highlighted Markdown code
+ expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)")
+
+ # shows an enabled copy button
+ expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
new file mode 100644
index 00000000000..d732383a1e1
--- /dev/null
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -0,0 +1,107 @@
+require 'rails_helper'
+
+feature 'User creates snippet', :js do
+ include DropzoneHelper
+
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ visit new_snippet_path
+ end
+
+ def fill_form
+ fill_in 'personal_snippet_title', with: 'My Snippet Title'
+ fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
+ page.within('.file-editor') do
+ find('.ace_editor').native.send_keys 'Hello World!'
+ end
+ end
+
+ scenario 'Authenticated user creates a snippet' do
+ fill_form
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ expect(page).to have_content('Hello World!')
+ end
+
+ scenario 'previews a snippet with file' do
+ fill_in 'personal_snippet_description', with: 'My Snippet'
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ find('.js-md-preview-button').click
+
+ page.within('#new_personal_snippet .md-preview') do
+ expect(page).to have_content('My Snippet')
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/temp/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+ end
+
+ scenario 'uploads a file when dragging into textarea' do
+ fill_form
+
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+
+ scenario 'validation fails for the first time' do
+ fill_in 'personal_snippet_title', with: 'My Snippet Title'
+ click_button('Create snippet')
+
+ expect(page).to have_selector('#error_explanation')
+
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ page.within('.snippet-header .description') do
+ expect(page).to have_content('My Snippet Description')
+ expect(page).to have_selector('strong')
+ end
+ expect(page).to have_content('Hello World!')
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
+
+ visit(link)
+ expect(page.status_code).to eq(200)
+ end
+
+ scenario 'Authenticated user creates a snippet with + in filename' do
+ fill_in 'personal_snippet_title', with: 'My Snippet Title'
+ page.within('.file-editor') do
+ find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name'
+ find('.ace_editor').native.send_keys 'Hello World!'
+ end
+
+ click_button 'Create snippet'
+ wait_for_requests
+
+ expect(page).to have_content('My Snippet Title')
+ expect(page).to have_content('snippet+file+name')
+ expect(page).to have_content('Hello World!')
+ end
+end
diff --git a/spec/features/snippets/user_deletes_snippet_spec.rb b/spec/features/snippets/user_deletes_snippet_spec.rb
new file mode 100644
index 00000000000..ae5b883c477
--- /dev/null
+++ b/spec/features/snippets/user_deletes_snippet_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+feature 'User deletes snippet' do
+ let(:user) { create(:user) }
+ let(:content) { 'puts "test"' }
+ let(:snippet) { create(:personal_snippet, :public, content: content, author: user) }
+
+ before do
+ sign_in(user)
+
+ visit snippet_path(snippet)
+ end
+
+ it 'deletes the snippet' do
+ first(:link, 'Delete').click
+
+ expect(page).not_to have_content(snippet.title)
+ end
+end
diff --git a/spec/features/snippets/user_edits_snippet_spec.rb b/spec/features/snippets/user_edits_snippet_spec.rb
new file mode 100644
index 00000000000..71de6b6bd1c
--- /dev/null
+++ b/spec/features/snippets/user_edits_snippet_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+feature 'User edits snippet', :js do
+ include DropzoneHelper
+
+ let(:file_name) { 'test.rb' }
+ let(:content) { 'puts "test"' }
+
+ let(:user) { create(:user) }
+ let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, author: user) }
+
+ before do
+ sign_in(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_requests
+ end
+
+ it 'updates the snippet' do
+ fill_in 'personal_snippet_title', with: 'New Snippet Title'
+
+ click_button('Save changes')
+ wait_for_requests
+
+ expect(page).to have_content('New Snippet Title')
+ end
+
+ it 'updates the snippet with files attached' do
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ expect(page.find_field('personal_snippet_description').value).to have_content('banana_sample')
+
+ click_button('Save changes')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
+ end
+
+ it 'updates the snippet to make it internal' do
+ choose 'Internal'
+
+ click_button 'Save changes'
+ wait_for_requests
+
+ expect(page).to have_no_xpath("//i[@class='fa fa-lock']")
+ expect(page).to have_xpath("//i[@class='fa fa-shield']")
+ end
+
+ it 'updates the snippet to make it public' do
+ choose 'Public'
+
+ click_button 'Save changes'
+ wait_for_requests
+
+ expect(page).to have_no_xpath("//i[@class='fa fa-lock']")
+ expect(page).to have_xpath("//i[@class='fa fa-globe']")
+ end
+end
diff --git a/spec/features/snippets/user_snippets_spec.rb b/spec/features/snippets/user_snippets_spec.rb
index 191c2fb9a22..7bc27486787 100644
--- a/spec/features/snippets/user_snippets_spec.rb
+++ b/spec/features/snippets/user_snippets_spec.rb
@@ -1,13 +1,13 @@
require 'rails_helper'
-feature 'User Snippets', feature: true do
+feature 'User Snippets' do
let(:author) { create(:user) }
let!(:public_snippet) { create(:personal_snippet, :public, author: author, title: "This is a public snippet") }
let!(:internal_snippet) { create(:personal_snippet, :internal, author: author, title: "This is an internal snippet") }
let!(:private_snippet) { create(:personal_snippet, :private, author: author, title: "This is a private snippet") }
background do
- login_as author
+ sign_in author
visit dashboard_snippets_path
end
diff --git a/spec/features/snippets_spec.rb b/spec/features/snippets_spec.rb
index 70b16bfc810..96c50f6c804 100644
--- a/spec/features/snippets_spec.rb
+++ b/spec/features/snippets_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe 'Snippets', feature: true do
+describe 'Snippets' do
context 'when the project has snippets' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) }
before do
allow(Snippet).to receive(:default_per_page).and_return(1)
diff --git a/spec/features/tags/master_creates_tag_spec.rb b/spec/features/tags/master_creates_tag_spec.rb
index ca25c696f75..39d79a3327b 100644
--- a/spec/features/tags/master_creates_tag_spec.rb
+++ b/spec/features/tags/master_creates_tag_spec.rb
@@ -1,60 +1,92 @@
require 'spec_helper'
-feature 'Master creates tag', feature: true do
+feature 'Master creates tag' do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_tags_path(project.namespace, project)
+ sign_in(user)
end
- scenario 'with an invalid name displays an error' do
- create_tag_in_form(tag: 'v 1.0', ref: 'master')
+ context 'from tag list' do
+ before do
+ visit project_tags_path(project)
+ end
- expect(page).to have_content 'Tag name invalid'
- end
+ scenario 'with an invalid name displays an error' do
+ create_tag_in_form(tag: 'v 1.0', ref: 'master')
- scenario 'with an invalid reference displays an error' do
- create_tag_in_form(tag: 'v2.0', ref: 'foo')
+ expect(page).to have_content 'Tag name invalid'
+ end
- expect(page).to have_content 'Target foo is invalid'
- end
+ scenario 'with an invalid reference displays an error' do
+ create_tag_in_form(tag: 'v2.0', ref: 'foo')
- scenario 'that already exists displays an error' do
- create_tag_in_form(tag: 'v1.1.0', ref: 'master')
+ expect(page).to have_content 'Target foo is invalid'
+ end
- expect(page).to have_content 'Tag v1.1.0 already exists'
- end
+ scenario 'that already exists displays an error' do
+ create_tag_in_form(tag: 'v1.1.0', ref: 'master')
+
+ expect(page).to have_content 'Tag v1.1.0 already exists'
+ end
- scenario 'with multiline message displays the message in a <pre> block' do
- create_tag_in_form(tag: 'v3.0', ref: 'master', message: "Awesome tag message\n\n- hello\n- world")
+ scenario 'with multiline message displays the message in a <pre> block' do
+ create_tag_in_form(tag: 'v3.0', ref: 'master', message: "Awesome tag message\n\n- hello\n- world")
- expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v3.0'))
- expect(page).to have_content 'v3.0'
- page.within 'pre.wrap' do
- expect(page).to have_content "Awesome tag message\n\n- hello\n- world"
+ expect(current_path).to eq(
+ project_tag_path(project, 'v3.0'))
+ expect(page).to have_content 'v3.0'
+ page.within 'pre.wrap' do
+ expect(page).to have_content "Awesome tag message\n\n- hello\n- world"
+ end
+ end
+
+ scenario 'with multiline release notes parses the release note as Markdown' do
+ create_tag_in_form(tag: 'v4.0', ref: 'master', desc: "Awesome release notes\n\n- hello\n- world")
+
+ expect(current_path).to eq(
+ project_tag_path(project, 'v4.0'))
+ expect(page).to have_content 'v4.0'
+ page.within '.description' do
+ expect(page).to have_content 'Awesome release notes'
+ expect(page).to have_selector('ul li', count: 2)
+ end
+ end
+
+ scenario 'opens dropdown for ref', js: true do
+ click_link 'New tag'
+ ref_row = find('.form-group:nth-of-type(2) .col-sm-10')
+ page.within ref_row do
+ ref_input = find('[name="ref"]', visible: false)
+ expect(ref_input.value).to eq 'master'
+ expect(find('.dropdown-toggle-text')).to have_content 'master'
+
+ find('.js-branch-select').trigger('click')
+
+ expect(find('.dropdown-menu')).to have_content 'empty-branch'
+ end
end
end
- scenario 'with multiline release notes parses the release note as Markdown' do
- create_tag_in_form(tag: 'v4.0', ref: 'master', desc: "Awesome release notes\n\n- hello\n- world")
+ context 'from new tag page' do
+ before do
+ visit new_project_tag_path(project)
+ end
+
+ it 'description has autocomplete', :js do
+ find('#release_description').native.send_keys('')
+ fill_in 'release_description', with: '@'
- expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v4.0'))
- expect(page).to have_content 'v4.0'
- page.within '.description' do
- expect(page).to have_content 'Awesome release notes'
- expect(page).to have_selector('ul li', count: 2)
+ expect(page).to have_selector('.atwho-view')
end
end
def create_tag_in_form(tag:, ref:, message: nil, desc: nil)
click_link 'New tag'
fill_in 'tag_name', with: tag
- fill_in 'ref', with: ref
+ find('#ref', visible: false).set(ref)
fill_in 'message', with: message unless message.nil?
fill_in 'release_description', with: desc unless desc.nil?
click_button 'Create tag'
diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb
index ccfafe6db7d..4d6fc13557f 100644
--- a/spec/features/tags/master_deletes_tag_spec.rb
+++ b/spec/features/tags/master_deletes_tag_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Master deletes tag', feature: true do
+feature 'Master deletes tag' do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_tags_path(project.namespace, project)
+ sign_in(user)
+ visit project_tags_path(project)
end
context 'from the tags list page', js: true do
@@ -24,12 +24,12 @@ feature 'Master deletes tag', feature: true do
scenario 'deletes the tag' do
click_on 'v1.0.0'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.0.0'))
+ project_tag_path(project, 'v1.0.0'))
click_on 'Delete tag'
expect(current_path).to eq(
- namespace_project_tags_path(project.namespace, project))
+ project_tags_path(project))
expect(page).not_to have_content 'v1.0.0'
end
end
diff --git a/spec/features/tags/master_updates_tag_spec.rb b/spec/features/tags/master_updates_tag_spec.rb
index 6b5b3122f72..b93ad44dfd3 100644
--- a/spec/features/tags/master_updates_tag_spec.rb
+++ b/spec/features/tags/master_updates_tag_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-feature 'Master updates tag', feature: true do
+feature 'Master updates tag' do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
project.team << [user, :master]
- login_with(user)
- visit namespace_project_tags_path(project.namespace, project)
+ sign_in(user)
+ visit project_tags_path(project)
end
context 'from the tags list page' do
@@ -20,10 +20,21 @@ feature 'Master updates tag', feature: true do
click_button 'Save changes'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.1.0'))
+ project_tag_path(project, 'v1.1.0'))
expect(page).to have_content 'v1.1.0'
expect(page).to have_content 'Awesome release notes'
end
+
+ scenario 'description has autocomplete', :js do
+ page.within(first('.content-list .controls')) do
+ click_link 'Edit release notes'
+ end
+
+ find('#release_description').native.send_keys('')
+ fill_in 'release_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
end
context 'from a specific tag page' do
@@ -34,7 +45,7 @@ feature 'Master updates tag', feature: true do
click_button 'Save changes'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.1.0'))
+ project_tag_path(project, 'v1.1.0'))
expect(page).to have_content 'v1.1.0'
expect(page).to have_content 'Awesome release notes'
end
diff --git a/spec/features/tags/master_views_tags_spec.rb b/spec/features/tags/master_views_tags_spec.rb
index 922ac15a2eb..9edc7ced163 100644
--- a/spec/features/tags/master_views_tags_spec.rb
+++ b/spec/features/tags/master_views_tags_spec.rb
@@ -1,23 +1,23 @@
require 'spec_helper'
-feature 'Master views tags', feature: true do
+feature 'Master views tags' do
let(:user) { create(:user) }
before do
project.team << [user, :master]
- login_with(user)
+ sign_in(user)
end
context 'when project has no tags' do
let(:project) { create(:project_empty_repo) }
before do
- visit namespace_project_path(project.namespace, project)
+ visit project_path(project)
click_on 'README'
fill_in :commit_message, with: 'Add a README file', visible: true
# Remove pre-receive hook so we can push without auth
FileUtils.rm_f(File.join(project.repository.path, 'hooks', 'pre-receive'))
click_button 'Commit changes'
- visit namespace_project_tags_path(project.namespace, project)
+ visit project_tags_path(project)
end
scenario 'displays a specific message' do
@@ -26,19 +26,19 @@ feature 'Master views tags', feature: true do
end
context 'when project has tags' do
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
let(:repository) { project.repository }
before do
- visit namespace_project_tags_path(project.namespace, project)
+ visit project_tags_path(project)
end
scenario 'avoids a N+1 query in branches index' do
- control_count = ActiveRecord::QueryRecorder.new { visit namespace_project_tags_path(project.namespace, project) }.count
+ control_count = ActiveRecord::QueryRecorder.new { visit project_tags_path(project) }.count
%w(one two three four five).each { |tag| repository.add_tag(user, tag, 'master', 'foo') }
- expect { visit namespace_project_tags_path(project.namespace, project) }.not_to exceed_query_limit(control_count)
+ expect { visit project_tags_path(project) }.not_to exceed_query_limit(control_count)
end
scenario 'views the tags list page' do
@@ -49,7 +49,7 @@ feature 'Master views tags', feature: true do
click_on 'v1.0.0'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.0.0'))
+ project_tag_path(project, 'v1.0.0'))
expect(page).to have_content 'v1.0.0'
expect(page).to have_content 'This tag has no release notes.'
end
@@ -59,24 +59,24 @@ feature 'Master views tags', feature: true do
click_on 'v1.0.0'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.0.0'))
+ project_tag_path(project, 'v1.0.0'))
click_on 'Browse files'
expect(current_path).to eq(
- namespace_project_tree_path(project.namespace, project, 'v1.0.0'))
+ project_tree_path(project, 'v1.0.0'))
end
scenario 'has a button to browse commits' do
click_on 'v1.0.0'
expect(current_path).to eq(
- namespace_project_tag_path(project.namespace, project, 'v1.0.0'))
+ project_tag_path(project, 'v1.0.0'))
click_on 'Browse commits'
expect(current_path).to eq(
- namespace_project_commits_path(project.namespace, project, 'v1.0.0'))
+ project_commits_path(project, 'v1.0.0'))
end
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index c33692fc4a9..580258f77eb 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-feature 'Task Lists', feature: true do
+feature 'Task Lists' do
include Warden::Test::Helpers
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
@@ -52,22 +52,23 @@ feature 'Task Lists', feature: true do
before do
Warden.test_mode!
- project.team << [user, :master]
- project.team << [user2, :guest]
+ project.add_master(user)
+ project.add_guest(user2)
login_as(user)
end
def visit_issue(project, issue)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ visit project_issue_path(project, issue)
end
describe 'for Issues' do
- describe 'multiple tasks' do
+ describe 'multiple tasks', js: true do
let!(:issue) { create(:issue, description: markdown, author: user, project: project) }
it 'renders' do
visit_issue(project, issue)
+ wait_for_requests
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 6)
@@ -76,38 +77,38 @@ feature 'Task Lists', feature: true do
it 'contains the required selectors' do
visit_issue(project, issue)
+ wait_for_requests
- container = '.detail-page-description .description.js-task-list-container'
-
- expect(page).to have_selector(container)
- expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox")
- expect(page).to have_selector("#{container} .js-task-list-field")
- expect(page).to have_selector('form.js-issuable-update')
+ expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
expect(page).to have_selector('a.btn-close')
end
it 'is only editable by author' do
visit_issue(project, issue)
- expect(page).to have_selector('.js-task-list-container')
+ wait_for_requests
- logout(:user)
+ expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
+ logout(:user)
login_as(user2)
visit current_path
- expect(page).not_to have_selector('.js-task-list-container')
+ wait_for_requests
+
+ expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
end
it 'provides a summary on Issues#index' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
expect(page).to have_content("2 of 6 tasks completed")
end
end
- describe 'single incomplete task' do
+ describe 'single incomplete task', js: true do
let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) }
it 'renders' do
visit_issue(project, issue)
+ wait_for_requests
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1)
@@ -115,16 +116,18 @@ feature 'Task Lists', feature: true do
end
it 'provides a summary on Issues#index' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
+
expect(page).to have_content("0 of 1 task completed")
end
end
- describe 'single complete task' do
+ describe 'single complete task', js: true do
let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) }
it 'renders' do
visit_issue(project, issue)
+ wait_for_requests
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1)
@@ -132,7 +135,8 @@ feature 'Task Lists', feature: true do
end
it 'provides a summary on Issues#index' do
- visit namespace_project_issues_path(project.namespace, project)
+ visit project_issues_path(project)
+
expect(page).to have_content("1 of 1 task completed")
end
end
@@ -140,7 +144,9 @@ feature 'Task Lists', feature: true do
describe 'nested tasks', js: true do
let(:issue) { create(:issue, description: nested_tasks_markdown, author: user, project: project) }
- before { visit_issue(project, issue) }
+ before do
+ visit_issue(project, issue)
+ end
it 'renders' do
expect(page).to have_selector('ul.task-list', count: 2)
@@ -236,7 +242,7 @@ feature 'Task Lists', feature: true do
describe 'for Merge Requests' do
def visit_merge_request(project, merge)
- visit namespace_project_merge_request_path(project.namespace, project, merge)
+ visit project_merge_request_path(project, merge)
end
describe 'multiple tasks' do
@@ -275,7 +281,7 @@ feature 'Task Lists', feature: true do
end
it 'provides a summary on MergeRequests#index' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).to have_content("2 of 6 tasks completed")
end
end
@@ -292,7 +298,7 @@ feature 'Task Lists', feature: true do
end
it 'provides a summary on MergeRequests#index' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).to have_content("0 of 1 task completed")
end
end
@@ -309,7 +315,7 @@ feature 'Task Lists', feature: true do
end
it 'provides a summary on MergeRequests#index' do
- visit namespace_project_merge_requests_path(project.namespace, project)
+ visit project_merge_requests_path(project)
expect(page).to have_content("1 of 1 task completed")
end
end
diff --git a/spec/features/todos/target_state_spec.rb b/spec/features/todos/target_state_spec.rb
deleted file mode 100644
index 32fa88a2b21..00000000000
--- a/spec/features/todos/target_state_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-require 'rails_helper'
-
-feature 'Todo target states', feature: true do
- let(:user) { create(:user) }
- let(:author) { create(:user) }
- let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
-
- before do
- login_as user
- end
-
- scenario 'on a closed issue todo has closed label' do
- issue_closed = create(:issue, state: 'closed')
- create_todo issue_closed
- visit dashboard_todos_path
-
- page.within '.todos-list' do
- expect(page).to have_content('Closed')
- end
- end
-
- scenario 'on an open issue todo does not have an open label' do
- issue_open = create(:issue)
- create_todo issue_open
- visit dashboard_todos_path
-
- page.within '.todos-list' do
- expect(page).not_to have_content('Open')
- end
- end
-
- scenario 'on a merged merge request todo has merged label' do
- mr_merged = create(:merge_request, :simple, author: user, state: 'merged')
- create_todo mr_merged
- visit dashboard_todos_path
-
- page.within '.todos-list' do
- expect(page).to have_content('Merged')
- end
- end
-
- scenario 'on a closed merge request todo has closed label' do
- mr_closed = create(:merge_request, :simple, author: user, state: 'closed')
- create_todo mr_closed
- visit dashboard_todos_path
-
- page.within '.todos-list' do
- expect(page).to have_content('Closed')
- end
- end
-
- scenario 'on an open merge request todo does not have an open label' do
- mr_open = create(:merge_request, :simple, author: user)
- create_todo mr_open
- visit dashboard_todos_path
-
- page.within '.todos-list' do
- expect(page).not_to have_content('Open')
- end
- end
-
- def create_todo(target)
- create(:todo, :mentioned, user: user, project: project, target: target, author: author)
- end
-end
diff --git a/spec/features/todos/todos_filtering_spec.rb b/spec/features/todos/todos_filtering_spec.rb
deleted file mode 100644
index cecb98641a6..00000000000
--- a/spec/features/todos/todos_filtering_spec.rb
+++ /dev/null
@@ -1,153 +0,0 @@
-require 'spec_helper'
-
-describe 'Dashboard > User filters todos', feature: true, js: true do
- let(:user_1) { create(:user, username: 'user_1', name: 'user_1') }
- let(:user_2) { create(:user, username: 'user_2', name: 'user_2') }
-
- let(:project_1) { create(:empty_project, name: 'project_1') }
- let(:project_2) { create(:empty_project, name: 'project_2') }
-
- let(:issue) { create(:issue, title: 'issue', project: project_1) }
-
- let!(:merge_request) { create(:merge_request, source_project: project_2, title: 'merge_request') }
-
- before do
- create(:todo, user: user_1, author: user_2, project: project_1, target: issue, action: 1)
- create(:todo, user: user_1, author: user_1, project: project_2, target: merge_request, action: 2)
-
- project_1.team << [user_1, :developer]
- project_2.team << [user_1, :developer]
- login_as(user_1)
- visit dashboard_todos_path
- end
-
- it 'filters by project' do
- click_button 'Project'
- within '.dropdown-menu-project' do
- fill_in 'Search projects', with: project_1.name_with_namespace
- click_link project_1.name_with_namespace
- end
-
- wait_for_ajax
-
- expect(page).to have_content project_1.name_with_namespace
- expect(page).not_to have_content project_2.name_with_namespace
- end
-
- context "Author filter" do
- it 'filters by author' do
- click_button 'Author'
-
- within '.dropdown-menu-author' do
- fill_in 'Search authors', with: user_1.name
- click_link user_1.name
- end
-
- wait_for_ajax
-
- expect(find('.todos-list')).to have_content user_1.name
- expect(find('.todos-list')).not_to have_content user_2.name
- end
-
- it "shows only authors of existing todos" do
- click_button 'Author'
-
- within '.dropdown-menu-author' do
- # It should contain two users + "Any Author"
- expect(page).to have_selector('.dropdown-menu-user-link', count: 3)
- expect(page).to have_content(user_1.name)
- expect(page).to have_content(user_2.name)
- end
- end
-
- it "shows only authors of existing done todos" do
- user_3 = create :user
- user_4 = create :user
- create(:todo, user: user_1, author: user_3, project: project_1, target: issue, action: 1, state: :done)
- create(:todo, user: user_1, author: user_4, project: project_2, target: merge_request, action: 2, state: :done)
-
- project_1.team << [user_3, :developer]
- project_2.team << [user_4, :developer]
-
- visit dashboard_todos_path(state: 'done')
-
- click_button 'Author'
-
- within '.dropdown-menu-author' do
- # It should contain two users + "Any Author"
- expect(page).to have_selector('.dropdown-menu-user-link', count: 3)
- expect(page).to have_content(user_3.name)
- expect(page).to have_content(user_4.name)
- expect(page).not_to have_content(user_1.name)
- expect(page).not_to have_content(user_2.name)
- end
- end
- end
-
- it 'filters by type' do
- click_button 'Type'
- within '.dropdown-menu-type' do
- click_link 'Issue'
- end
-
- wait_for_ajax
-
- expect(find('.todos-list')).to have_content issue.to_reference
- expect(find('.todos-list')).not_to have_content merge_request.to_reference
- end
-
- describe 'filter by action' do
- before do
- create(:todo, :build_failed, user: user_1, author: user_2, project: project_1)
- create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue)
- end
-
- it 'filters by Assigned' do
- filter_action('Assigned')
-
- expect_to_see_action(:assigned)
- end
-
- it 'filters by Mentioned' do
- filter_action('Mentioned')
-
- expect_to_see_action(:mentioned)
- end
-
- it 'filters by Added' do
- filter_action('Added')
-
- expect_to_see_action(:marked)
- end
-
- it 'filters by Pipelines' do
- filter_action('Pipelines')
-
- expect_to_see_action(:build_failed)
- end
-
- def filter_action(name)
- click_button 'Action'
- within '.dropdown-menu-action' do
- click_link name
- end
-
- wait_for_ajax
- end
-
- def expect_to_see_action(action_name)
- action_names = {
- assigned: ' assigned you ',
- mentioned: ' mentioned ',
- marked: ' added a todo for ',
- build_failed: ' build failed for '
- }
-
- action_name_text = action_names.delete(action_name)
- expect(find('.todos-list')).to have_content action_name_text
- action_names.each_value do |other_action_text|
- expect(find('.todos-list')).not_to have_content other_action_text
- end
- end
- end
-end
diff --git a/spec/features/todos/todos_sorting_spec.rb b/spec/features/todos/todos_sorting_spec.rb
deleted file mode 100644
index 4d5bd476301..00000000000
--- a/spec/features/todos/todos_sorting_spec.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-require 'spec_helper'
-
-describe "Dashboard > User sorts todos", feature: true do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
-
- let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) }
- let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) }
- let(:label_3) { create(:label, title: 'label_3', project: project, priority: 3) }
-
- before { project.team << [user, :developer] }
-
- context 'sort options' do
- let(:issue_1) { create(:issue, title: 'issue_1', project: project) }
- let(:issue_2) { create(:issue, title: 'issue_2', project: project) }
- let(:issue_3) { create(:issue, title: 'issue_3', project: project) }
- let(:issue_4) { create(:issue, title: 'issue_4', project: project) }
-
- let!(:merge_request_1) { create(:merge_request, source_project: project, title: "merge_request_1") }
-
- before do
- create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago)
- create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago)
- create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago)
- create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago)
- create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
-
- merge_request_1.labels << label_1
- issue_3.labels << label_1
- issue_2.labels << label_3
- issue_1.labels << label_2
-
- login_as(user)
- visit dashboard_todos_path
- end
-
- it "sorts with oldest created todos first" do
- click_link "Last created"
-
- results_list = page.find('.todos-list')
- expect(results_list.all('p')[0]).to have_content("merge_request_1")
- expect(results_list.all('p')[1]).to have_content("issue_1")
- expect(results_list.all('p')[2]).to have_content("issue_3")
- expect(results_list.all('p')[3]).to have_content("issue_2")
- expect(results_list.all('p')[4]).to have_content("issue_4")
- end
-
- it "sorts with newest created todos first" do
- click_link "Oldest created"
-
- results_list = page.find('.todos-list')
- expect(results_list.all('p')[0]).to have_content("issue_4")
- expect(results_list.all('p')[1]).to have_content("issue_2")
- expect(results_list.all('p')[2]).to have_content("issue_3")
- expect(results_list.all('p')[3]).to have_content("issue_1")
- expect(results_list.all('p')[4]).to have_content("merge_request_1")
- end
-
- it "sorts by label priority" do
- click_link "Label priority"
-
- results_list = page.find('.todos-list')
- expect(results_list.all('p')[0]).to have_content("issue_3")
- expect(results_list.all('p')[1]).to have_content("merge_request_1")
- expect(results_list.all('p')[2]).to have_content("issue_1")
- expect(results_list.all('p')[3]).to have_content("issue_2")
- expect(results_list.all('p')[4]).to have_content("issue_4")
- end
- end
-
- context 'issues and merge requests' do
- let(:issue_1) { create(:issue, id: 10000, title: 'issue_1', project: project) }
- let(:issue_2) { create(:issue, id: 10001, title: 'issue_2', project: project) }
- let(:merge_request_1) { create(:merge_request, id: 10000, title: 'merge_request_1', source_project: project) }
-
- before do
- issue_1.labels << label_1
- issue_2.labels << label_2
-
- create(:todo, user: user, project: project, target: issue_1)
- create(:todo, user: user, project: project, target: issue_2)
- create(:todo, user: user, project: project, target: merge_request_1)
-
- login_as(user)
- visit dashboard_todos_path
- end
-
- it "doesn't mix issues and merge requests label priorities" do
- click_link "Label priority"
-
- results_list = page.find('.todos-list')
- expect(results_list.all('p')[0]).to have_content("issue_1")
- expect(results_list.all('p')[1]).to have_content("issue_2")
- expect(results_list.all('p')[2]).to have_content("merge_request_1")
- end
- end
-end
diff --git a/spec/features/todos/todos_spec.rb b/spec/features/todos/todos_spec.rb
deleted file mode 100644
index 50c207fb9cb..00000000000
--- a/spec/features/todos/todos_spec.rb
+++ /dev/null
@@ -1,301 +0,0 @@
-require 'spec_helper'
-
-describe 'Dashboard Todos', feature: true do
- let(:user) { create(:user) }
- let(:author) { create(:user) }
- let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:issue) { create(:issue, due_date: Date.today) }
-
- describe 'GET /dashboard/todos' do
- context 'User does not have todos' do
- before do
- login_as(user)
- visit dashboard_todos_path
- end
- it 'shows "All done" message' do
- expect(page).to have_content "Todos let you see what you should do next."
- end
- end
-
- context 'User has a todo', js: true do
- before do
- create(:todo, :mentioned, user: user, project: project, target: issue, author: author)
- login_as(user)
- visit dashboard_todos_path
- end
-
- it 'has todo present' do
- expect(page).to have_selector('.todos-list .todo', count: 1)
- end
-
- it 'shows due date as today' do
- within first('.todo') do
- expect(page).to have_content 'Due today'
- end
- end
-
- shared_examples 'deleting the todo' do
- before do
- within first('.todo') do
- click_link 'Done'
- end
- end
-
- it 'is marked as done-reversible in the list' do
- expect(page).to have_selector('.todos-list .todo.todo-pending.done-reversible')
- end
-
- it 'shows Undo button' do
- expect(page).to have_selector('.js-undo-todo', visible: true)
- expect(page).to have_selector('.js-done-todo', visible: false)
- end
-
- it 'updates todo count' do
- expect(page).to have_content 'To do 0'
- expect(page).to have_content 'Done 1'
- end
-
- it 'has not "All done" message' do
- expect(page).not_to have_selector('.todos-all-done')
- end
- end
-
- shared_examples 'deleting and restoring the todo' do
- before do
- within first('.todo') do
- click_link 'Done'
- wait_for_ajax
- click_link 'Undo'
- end
- end
-
- it 'is marked back as pending in the list' do
- expect(page).not_to have_selector('.todos-list .todo.todo-pending.done-reversible')
- expect(page).to have_selector('.todos-list .todo.todo-pending')
- end
-
- it 'shows Done button' do
- expect(page).to have_selector('.js-undo-todo', visible: false)
- expect(page).to have_selector('.js-done-todo', visible: true)
- end
-
- it 'updates todo count' do
- expect(page).to have_content 'To do 1'
- expect(page).to have_content 'Done 0'
- end
- end
-
- it_behaves_like 'deleting the todo'
- it_behaves_like 'deleting and restoring the todo'
-
- context 'todo is stale on the page' do
- before do
- todos = TodosFinder.new(user, state: :pending).execute
- TodoService.new.mark_todos_as_done(todos, user)
- end
-
- it_behaves_like 'deleting the todo'
- it_behaves_like 'deleting and restoring the todo'
- end
- end
-
- context 'User has done todos', js: true do
- before do
- create(:todo, :mentioned, :done, user: user, project: project, target: issue, author: author)
- login_as(user)
- visit dashboard_todos_path(state: :done)
- end
-
- it 'has the done todo present' do
- expect(page).to have_selector('.todos-list .todo.todo-done', count: 1)
- end
-
- describe 'restoring the todo' do
- before do
- within first('.todo') do
- click_link 'Add todo'
- end
- end
-
- it 'is removed from the list' do
- expect(page).not_to have_selector('.todos-list .todo.todo-done')
- end
-
- it 'updates todo count' do
- expect(page).to have_content 'To do 1'
- expect(page).to have_content 'Done 0'
- end
- end
- end
-
- context 'User has Todos with labels spanning multiple projects' do
- before do
- label1 = create(:label, project: project)
- note1 = create(:note_on_issue, note: "Hello #{label1.to_reference(format: :name)}", noteable_id: issue.id, noteable_type: 'Issue', project: issue.project)
- create(:todo, :mentioned, project: project, target: issue, user: user, note_id: note1.id)
-
- project2 = create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- label2 = create(:label, project: project2)
- issue2 = create(:issue, project: project2)
- note2 = create(:note_on_issue, note: "Test #{label2.to_reference(format: :name)}", noteable_id: issue2.id, noteable_type: 'Issue', project: project2)
- create(:todo, :mentioned, project: project2, target: issue2, user: user, note_id: note2.id)
-
- login_as(user)
- visit dashboard_todos_path
- end
-
- it 'shows page with two Todos' do
- expect(page).to have_selector('.todos-list .todo', count: 2)
- end
- end
-
- context 'User has multiple pages of Todos' do
- before do
- allow(Todo).to receive(:default_per_page).and_return(1)
-
- # Create just enough records to cause us to paginate
- create_list(:todo, 2, :mentioned, user: user, project: project, target: issue, author: author)
-
- login_as(user)
- end
-
- it 'is paginated' do
- visit dashboard_todos_path
-
- expect(page).to have_selector('.gl-pagination')
- end
-
- it 'is has the right number of pages' do
- visit dashboard_todos_path
-
- expect(page).to have_selector('.gl-pagination .page', count: 2)
- end
-
- describe 'mark all as done', js: true do
- before do
- visit dashboard_todos_path
- click_link 'Mark all as done'
- end
-
- it 'shows "All done" message!' do
- expect(page).to have_content 'To do 0'
- expect(page).to have_content "You're all done!"
- expect(page).not_to have_selector('.gl-pagination')
- end
-
- it 'shows "Undo mark all as done" button' do
- expect(page).to have_selector('.js-todos-mark-all', visible: false)
- expect(page).to have_selector('.js-todos-undo-all', visible: true)
- end
- end
-
- describe 'undo mark all as done', js: true do
- before do
- visit dashboard_todos_path
- end
-
- it 'shows the restored todo list' do
- mark_all_and_undo
-
- expect(page).to have_selector('.todos-list .todo', count: 1)
- expect(page).to have_selector('.gl-pagination')
- expect(page).not_to have_content "You're all done!"
- end
-
- it 'updates todo count' do
- mark_all_and_undo
-
- expect(page).to have_content 'To do 2'
- expect(page).to have_content 'Done 0'
- end
-
- it 'shows "Mark all as done" button' do
- mark_all_and_undo
-
- expect(page).to have_selector('.js-todos-mark-all', visible: true)
- expect(page).to have_selector('.js-todos-undo-all', visible: false)
- end
-
- context 'User has deleted a todo' do
- before do
- within first('.todo') do
- click_link 'Done'
- end
- end
-
- it 'shows the restored todo list with the deleted todo' do
- mark_all_and_undo
-
- expect(page).to have_selector('.todos-list .todo.todo-pending', count: 1)
- end
- end
-
- def mark_all_and_undo
- click_link 'Mark all as done'
- wait_for_ajax
- click_link 'Undo mark all as done'
- wait_for_ajax
- end
- end
- end
-
- context 'User has a Todo in a project pending deletion' do
- before do
- deleted_project = create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC, pending_delete: true)
- create(:todo, :mentioned, user: user, project: deleted_project, target: issue, author: author)
- create(:todo, :mentioned, user: user, project: deleted_project, target: issue, author: author, state: :done)
- login_as(user)
- visit dashboard_todos_path
- end
-
- it 'shows "All done" message' do
- within('.todos-count') { expect(page).to have_content '0' }
- expect(page).to have_content 'To do 0'
- expect(page).to have_content 'Done 0'
- expect(page).to have_selector('.todos-all-done', count: 1)
- end
- end
-
- context 'User have large number of todos' do
- before do
- create_list(:todo, 101, :mentioned, user: user, project: project, target: issue, author: author)
-
- login_as(user)
- visit dashboard_todos_path
- end
-
- it 'shows 99+ for count >= 100 in notification' do
- expect(page).to have_selector('.todos-count', text: '99+')
- end
-
- it 'shows exact number in To do tab' do
- expect(page).to have_selector('.todos-pending .badge', text: '101')
- end
-
- it 'shows exact number for count < 100' do
- 3.times { first('.js-done-todo').click }
-
- expect(page).to have_selector('.todos-count', text: '98')
- end
- end
-
- context 'User has a Build Failed todo' do
- let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: author) }
-
- before do
- login_as user
- visit dashboard_todos_path
- end
-
- it 'shows the todo' do
- expect(page).to have_content 'The build failed for merge request'
- end
-
- it 'links to the pipelines for the merge request' do
- href = pipelines_namespace_project_merge_request_path(project.namespace, project, todo.target)
-
- expect(page).to have_link "merge request #{todo.target.to_reference(full: true)}", href
- end
- end
- end
-end
diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb
index 81fa2de1cc3..47664de469a 100644
--- a/spec/features/triggers_spec.rb
+++ b/spec/features/triggers_spec.rb
@@ -1,18 +1,20 @@
require 'spec_helper'
-feature 'Triggers', feature: true, js: true do
+feature 'Triggers', js: true do
let(:trigger_title) { 'trigger desc' }
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest_user) { create(:user) }
- before { login_as(user) }
before do
- @project = create(:empty_project)
+ sign_in(user)
+
+ @project = create(:project)
@project.team << [user, :master]
@project.team << [user2, :master]
@project.team << [guest_user, :guest]
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+
+ visit project_settings_ci_cd_path(@project)
end
describe 'create trigger workflow' do
@@ -31,7 +33,7 @@ feature 'Triggers', feature: true, js: true do
# See if "trigger creation successful" message displayed and description and owner are correct
expect(page.find('.flash-notice')).to have_content 'Trigger was created successfully.'
expect(page.find('.triggers-list')).to have_content 'trigger desc'
- expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
end
end
@@ -40,7 +42,7 @@ feature 'Triggers', feature: true, js: true do
scenario 'click on edit trigger opens edit trigger page' do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if edit page has correct descrption
find('a[title="Edit"]').click
@@ -49,7 +51,7 @@ feature 'Triggers', feature: true, js: true do
scenario 'edit trigger and save' do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if edit page opens, then fill in new description and save
find('a[title="Edit"]').click
@@ -59,13 +61,13 @@ feature 'Triggers', feature: true, js: true do
# See if "trigger updated successfully" message displayed and description and owner are correct
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
expect(page.find('.triggers-list')).to have_content new_trigger_title
- expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
end
scenario 'edit "legacy" trigger and save' do
# Create new trigger without owner association, i.e. Legacy trigger
create(:ci_trigger, owner: nil, project: @project)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if the trigger can be edited and description is blank
find('a[title="Edit"]').click
@@ -77,65 +79,12 @@ 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
- before(:each) do
+ before do
create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
end
scenario 'button "Take ownership" has correct alert' do
@@ -149,15 +98,15 @@ feature 'Triggers', feature: true, js: true do
page.accept_confirm do
expect(page.find('.flash-notice')).to have_content 'Trigger was re-assigned.'
expect(page.find('.triggers-list')).to have_content trigger_title
- expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
end
end
end
describe 'trigger "Revoke" workflow' do
- before(:each) do
+ before do
create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
end
scenario 'button "Revoke" has correct alert' do
@@ -182,7 +131,7 @@ feature 'Triggers', feature: true, js: true do
scenario 'show "legacy" badge for legacy trigger' do
create(:ci_trigger, owner: nil, project: @project)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if trigger without owner (i.e. legacy) shows "legacy" badge and is editable
expect(page.find('.triggers-list')).to have_content 'legacy'
@@ -191,7 +140,7 @@ feature 'Triggers', feature: true, js: true do
scenario 'show "invalid" badge for trigger with owner having insufficient permissions' do
create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable
expect(page.find('.triggers-list')).to have_content 'invalid'
@@ -201,27 +150,27 @@ feature 'Triggers', feature: true, js: true do
scenario 'do not show "Edit" or full token for not owned trigger' do
# Create trigger with user different from current_user
create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button
expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3])
expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard')
# See if trigger owner name doesn't match with current_user and trigger is non-editable
- expect(page.find('.triggers-list .trigger-owner')).not_to have_content @user.name
+ expect(page.find('.triggers-list .trigger-owner')).not_to have_content user.name
expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
end
scenario 'show "Edit" and full token for owned trigger' do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ visit project_settings_ci_cd_path(@project)
# See if trigger shows full token and has copy-to-clipboard button
expect(page.find('.triggers-list')).to have_content @project.triggers.first.token
expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard')
# See if trigger owner name matches with current_user and is editable
- expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
end
end
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
index 544d2dcb87f..f3662cb184f 100644
--- a/spec/features/u2f_spec.rb
+++ b/spec/features/u2f_spec.rb
@@ -1,12 +1,14 @@
require 'spec_helper'
feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
- before { allow_any_instance_of(U2fHelper).to receive(:inject_u2f_api?).and_return(true) }
+ before do
+ allow_any_instance_of(U2fHelper).to receive(:inject_u2f_api?).and_return(true)
+ end
def manage_two_factor_authentication
click_on 'Manage two-factor authentication'
expect(page).to have_content("Setup new U2F device")
- wait_for_ajax
+ wait_for_requests
end
def register_u2f_device(u2f_device = nil, name: 'My device')
@@ -23,12 +25,14 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
let(:user) { create(:user) }
before do
- login_as(user)
+ gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
end
describe 'when 2FA via OTP is disabled' do
- before { user.update_attribute(:otp_required_for_login, false) }
+ before do
+ user.update_attribute(:otp_required_for_login, false)
+ end
it 'does not allow registering a new device' do
visit profile_account_path
@@ -89,10 +93,10 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
manage_two_factor_authentication
u2f_device = register_u2f_device
expect(page).to have_content('Your U2F device was registered')
- logout
+ gitlab_sign_out
# Second user
- user = login_as(:user)
+ user = gitlab_sign_in(:user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
@@ -143,18 +147,18 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
before do
# Register and logout
- login_as(user)
+ gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
@u2f_device = register_u2f_device
- logout
+ gitlab_sign_out
end
describe "when 2FA via OTP is disabled" do
it "allows logging in with the U2F device" do
user.update_attribute(:otp_required_for_login, false)
- login_with(user)
+ gitlab_sign_in(user)
@u2f_device.respond_to_u2f_authentication
@@ -166,7 +170,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
describe "when 2FA via OTP is enabled" do
it "allows logging in with the U2F device" do
user.update_attribute(:otp_required_for_login, true)
- login_with(user)
+ gitlab_sign_in(user)
@u2f_device.respond_to_u2f_authentication
@@ -176,7 +180,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
end
it 'persists remember_me value via hidden field' do
- login_with(user, remember: true)
+ gitlab_sign_in(user, remember: true)
@u2f_device.respond_to_u2f_authentication
expect(page).to have_content('We heard back from your U2F device')
@@ -191,15 +195,15 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
describe "but not the current user" do
it "does not allow logging in with that particular device" do
# Register current user with the different U2F device
- current_user = login_as(:user)
+ current_user = gitlab_sign_in(:user)
current_user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
register_u2f_device(name: 'My other device')
- logout
+ gitlab_sign_out
# Try authenticating user with the old U2F device
- login_as(current_user)
+ gitlab_sign_in(current_user)
@u2f_device.respond_to_u2f_authentication
expect(page).to have_content('We heard back from your U2F device')
expect(page).to have_content('Authentication via U2F device failed')
@@ -209,15 +213,15 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
describe "and also the current user" do
it "allows logging in with that particular device" do
# Register current user with the same U2F device
- current_user = login_as(:user)
+ current_user = gitlab_sign_in(:user)
current_user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
register_u2f_device(@u2f_device)
- logout
+ gitlab_sign_out
# Try authenticating user with the same U2F device
- login_as(current_user)
+ gitlab_sign_in(current_user)
@u2f_device.respond_to_u2f_authentication
expect(page).to have_content('We heard back from your U2F device')
@@ -229,7 +233,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
describe "when a given U2F device has not been registered" do
it "does not allow logging in with that particular device" do
unregistered_device = FakeU2fDevice.new(page, 'My device')
- login_as(user)
+ gitlab_sign_in(user)
unregistered_device.respond_to_u2f_authentication
expect(page).to have_content('We heard back from your U2F device')
@@ -240,7 +244,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
describe "when more than one device has been registered by the same user" do
it "allows logging in with either device" do
# Register first device
- user = login_as(:user)
+ user = gitlab_sign_in(:user)
user.update_attribute(:otp_required_for_login, true)
visit profile_two_factor_auth_path
expect(page).to have_content("Your U2F device needs to be set up.")
@@ -250,17 +254,17 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
visit profile_two_factor_auth_path
expect(page).to have_content("Your U2F device needs to be set up.")
second_device = register_u2f_device(name: 'My other device')
- logout
+ gitlab_sign_out
# Authenticate as both devices
[first_device, second_device].each do |device|
- login_as(user)
+ gitlab_sign_in(user)
device.respond_to_u2f_authentication
expect(page).to have_content('We heard back from your U2F device')
expect(page).to have_css('.sign-out-link', visible: false)
- logout
+ gitlab_sign_out
end
end
end
@@ -269,7 +273,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
let(:user) { create(:user) }
before do
- user = login_as(:user)
+ user = gitlab_sign_in(:user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
@@ -296,15 +300,15 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
before do
# Register and logout
- login_as(user)
+ gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
end
describe 'when no u2f device is registered' do
before do
- logout
- login_with(user)
+ gitlab_sign_out
+ gitlab_sign_in(user)
end
it 'shows the fallback otp code UI' do
@@ -316,8 +320,8 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
before do
manage_two_factor_authentication
@u2f_device = register_u2f_device
- logout
- login_with(user)
+ gitlab_sign_out
+ gitlab_sign_in(user)
end
it 'provides a button that shows the fallback otp code UI' do
diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb
index e2d9cfdd0b0..392d8e3e1c1 100644
--- a/spec/features/unsubscribe_links_spec.rb
+++ b/spec/features/unsubscribe_links_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'Unsubscribe links', feature: true do
+describe 'Unsubscribe links' do
include Warden::Test::Helpers
let(:recipient) { create(:user) }
let(:author) { create(:user) }
- let(:project) { create(:empty_project, :public) }
- let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } }
+ let(:project) { create(:project, :public) }
+ let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } }
let(:issue) { Issues::CreateService.new(project, author, params).execute }
let(:mail) { ActionMailer::Base.deliveries.last }
@@ -24,8 +24,8 @@ describe 'Unsubscribe links', feature: true do
visit body_link
expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last)
- expect(page).to have_text(%(Unsubscribe from issue #{issue.title} (#{issue.to_reference})))
- expect(page).to have_text(%(Are you sure you want to unsubscribe from issue #{issue.title} (#{issue.to_reference})?))
+ expect(page).to have_text(%(Unsubscribe from issue))
+ expect(page).to have_text(%(Are you sure you want to unsubscribe from the issue: #{issue.title} (#{issue.to_reference})?))
expect(issue.subscribed?(recipient, project)).to be_truthy
click_link 'Unsubscribe'
@@ -56,7 +56,9 @@ describe 'Unsubscribe links', feature: true do
end
context 'when logged in' do
- before { login_as(recipient) }
+ before do
+ sign_in(recipient)
+ end
it 'unsubscribes from the issue when visiting the link from the email body' do
visit body_link
diff --git a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
index f88a515f7fc..e8884bc1a00 100644
--- a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb
@@ -1,11 +1,11 @@
require 'rails_helper'
-feature 'User uploads avatar to group', feature: true do
+feature 'User uploads avatar to group' do
scenario 'they see the new avatar' do
user = create(:user)
group = create(:group)
group.add_owner(user)
- login_as(user)
+ sign_in(user)
visit edit_group_path(group)
attach_file(
@@ -18,7 +18,7 @@ feature 'User uploads avatar to group', feature: true do
visit group_path(group)
- expect(page).to have_selector(%Q(img[src$="/uploads/group/avatar/#{group.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/group/avatar/#{group.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(group.reload.avatar.file).to exist
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 0dfd29045e5..52003bb0859 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
-feature 'User uploads avatar to profile', feature: true do
+feature 'User uploads avatar to profile' do
scenario 'they see their new avatar' do
user = create(:user)
- login_as(user)
+ sign_in(user)
visit profile_path
attach_file(
@@ -16,7 +16,7 @@ feature 'User uploads avatar to profile', feature: true do
visit user_path(user)
- expect(page).to have_selector(%Q(img[src$="/uploads/user/avatar/#{user.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb
index 0c160dd74b4..53cad623a35 100644
--- a/spec/features/uploads/user_uploads_file_to_note_spec.rb
+++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb
@@ -1,22 +1,82 @@
require 'rails_helper'
-feature 'User uploads file to note', feature: true do
+feature 'User uploads file to note' do
include DropzoneHelper
let(:user) { create(:user) }
- let(:project) { create(:empty_project, creator: user, namespace: user.namespace) }
+ let(:project) { create(:project, creator: user, namespace: user.namespace) }
+ let(:issue) { create(:issue, project: project, author: user) }
- scenario 'they see the attached file', js: true do
- issue = create(:issue, project: project, author: user)
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ context 'before uploading' do
+ it 'shows "Attach a file" button', js: true do
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
+ end
+
+ context 'uploading is in progress' do
+ it 'shows "Cancel" button on uploading', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false)
+
+ expect(page).to have_button('Cancel')
+ end
+
+ it 'cancels uploading on clicking to "Cancel" button', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false)
+
+ click_button 'Cancel'
+
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_button('Cancel')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
+
+ it 'shows "Attaching a file" message on uploading 1 file', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false)
+
+ expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -')
+ end
+
+ it 'shows "Attaching 2 files" message on uploading 2 file', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4'),
+ Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false)
+
+ expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching 2 files -')
+ end
+
+ it 'shows error message, "retry" and "attach a new file" link a if file is too big', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4')], 0.01)
+
+ error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.'
+
+ expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text)
+ expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again')
+ expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file')
+ expect(page).not_to have_button('Attach a file')
+ end
+ end
+
+ context 'uploading is complete' do
+ it 'shows "Attach a file" button on uploading complete', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')])
+ wait_for_requests
- login_as(user)
- visit namespace_project_issue_path(project.namespace, project, issue)
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
- dropzone_file(Rails.root.join('spec', 'fixtures', 'dk.png'))
- click_button 'Comment'
- wait_for_ajax
+ scenario 'they see the attached file', js: true do
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')])
+ click_button 'Comment'
+ wait_for_requests
- expect(find('a.no-attachment-icon img[alt="dk"]')['src'])
- .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$})
+ expect(find('a.no-attachment-icon img[alt="dk"]')['src'])
+ .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$})
+ end
end
end
diff --git a/spec/features/user_callout_spec.rb b/spec/features/user_callout_spec.rb
index 848af5e3a4d..37d66b618af 100644
--- a/spec/features/user_callout_spec.rb
+++ b/spec/features/user_callout_spec.rb
@@ -3,10 +3,10 @@ require 'spec_helper'
describe 'User Callouts', js: true do
let(:user) { create(:user) }
let(:another_user) { create(:user) }
- let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') }
+ let(:project) { create(:project, path: 'gitlab', name: 'sample') }
before do
- login_as(user)
+ sign_in(user)
project.team << [user, :master]
end
@@ -20,7 +20,7 @@ describe 'User Callouts', js: true do
visit dashboard_projects_path
within('.user-callout') do
- find('.close').click
+ find('.close').trigger('click')
end
visit dashboard_projects_path
diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb
new file mode 100644
index 00000000000..670e8dda916
--- /dev/null
+++ b/spec/features/user_can_display_performance_bar_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+describe 'User can display performance bar', :js do
+ shared_examples 'performance bar is disabled' do
+ it 'does not show the performance bar by default' do
+ expect(page).not_to have_css('#peek')
+ end
+
+ context 'when user press `pb`' do
+ before do
+ find('body').native.send_keys('pb')
+ end
+
+ it 'does not show the performance bar by default' do
+ expect(page).not_to have_css('#peek')
+ end
+ end
+ end
+
+ shared_examples 'performance bar is enabled' do
+ it 'does not show the performance bar by default' do
+ expect(page).not_to have_css('#peek')
+ end
+
+ context 'when user press `pb`' do
+ before do
+ find('body').native.send_keys('pb')
+ end
+
+ it 'shows the performance bar' do
+ expect(page).to have_css('#peek')
+ end
+ end
+ end
+
+ let(:group) { create(:group) }
+
+ context 'when user is logged-out' do
+ before do
+ visit root_path
+ end
+
+ context 'when the performance_bar feature is disabled' do
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: nil)
+ end
+
+ it_behaves_like 'performance bar is disabled'
+ end
+
+ context 'when the performance_bar feature is enabled' do
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: group.id)
+ end
+
+ it_behaves_like 'performance bar is disabled'
+ end
+ end
+
+ context 'when user is logged-in' do
+ before do
+ user = create(:user)
+
+ sign_in(user)
+ group.add_guest(user)
+
+ visit root_path
+ end
+
+ context 'when the performance_bar feature is disabled' do
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: nil)
+ end
+
+ it_behaves_like 'performance bar is disabled'
+ end
+
+ context 'when the performance_bar feature is enabled' do
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: group.id)
+ end
+
+ it_behaves_like 'performance bar is enabled'
+ end
+ end
+end
diff --git a/spec/features/users/projects_spec.rb b/spec/features/users/projects_spec.rb
index 373b64808f8..f079771cee1 100644
--- a/spec/features/users/projects_spec.rb
+++ b/spec/features/users/projects_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-describe 'Projects tab on a user profile', :feature, :js do
+describe 'Projects tab on a user profile', :js do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, namespace: user.namespace) }
- let!(:project2) { create(:empty_project, namespace: user.namespace) }
+ let!(:project) { create(:project, namespace: user.namespace) }
+ let!(:project2) { create(:project, namespace: user.namespace) }
before do
allow(Project).to receive(:default_per_page).and_return(1)
- login_as(user)
+ sign_in(user)
visit user_path(user)
@@ -16,7 +16,7 @@ describe 'Projects tab on a user profile', :feature, :js do
click_link('Personal projects')
end
- wait_for_ajax
+ wait_for_requests
end
it 'paginates results' do
diff --git a/spec/features/users/rss_spec.rb b/spec/features/users/rss_spec.rb
index 14564abb16d..7c5abe54d56 100644
--- a/spec/features/users/rss_spec.rb
+++ b/spec/features/users/rss_spec.rb
@@ -1,15 +1,16 @@
require 'spec_helper'
feature 'User RSS' do
+ let(:user) { create(:user) }
let(:path) { user_path(create(:user)) }
context 'when signed in' do
before do
- login_as(create(:user))
+ sign_in(user)
visit path
end
- it_behaves_like "it has an RSS button with current_user's private token"
+ it_behaves_like "it has an RSS button with current_user's RSS token"
end
context 'when signed out' do
@@ -17,6 +18,6 @@ feature 'User RSS' do
visit path
end
- it_behaves_like "it has an RSS button without a private token"
+ it_behaves_like "it has an RSS button without an RSS token"
end
end
diff --git a/spec/features/users/snippets_spec.rb b/spec/features/users/snippets_spec.rb
index 1546a06b80c..13760b4c2fc 100644
--- a/spec/features/users/snippets_spec.rb
+++ b/spec/features/users/snippets_spec.rb
@@ -1,16 +1,48 @@
require 'spec_helper'
-describe 'Snippets tab on a user profile', feature: true, js: true do
+describe 'Snippets tab on a user profile', js: true do
context 'when the user has snippets' do
let(:user) { create(:user) }
- let!(:snippets) { create_list(:snippet, 2, :public, author: user) }
- before do
- allow(Snippet).to receive(:default_per_page).and_return(1)
- visit user_path(user)
- page.within('.user-profile-nav') { click_link 'Snippets' }
- wait_for_ajax
+
+ context 'pagination' do
+ let!(:snippets) { create_list(:snippet, 2, :public, author: user) }
+
+ before do
+ allow(Snippet).to receive(:default_per_page).and_return(1)
+ visit user_path(user)
+ page.within('.user-profile-nav') { click_link 'Snippets' }
+ wait_for_requests
+ end
+
+ it_behaves_like 'paginated snippets', remote: true
end
- it_behaves_like 'paginated snippets', remote: true
+ context 'list content' do
+ let!(:public_snippet) { create(:snippet, :public, author: user) }
+ let!(:internal_snippet) { create(:snippet, :internal, author: user) }
+ let!(:private_snippet) { create(:snippet, :private, author: user) }
+ let!(:other_snippet) { create(:snippet, :public) }
+
+ it 'contains only internal and public snippets of a user when a user is logged in' do
+ sign_in(create(:user))
+ visit user_path(user)
+ page.within('.user-profile-nav') { click_link 'Snippets' }
+ wait_for_requests
+
+ expect(page).to have_selector('.snippet-row', count: 2)
+
+ expect(page).to have_content(public_snippet.title)
+ expect(page).to have_content(internal_snippet.title)
+ end
+
+ it 'contains only public snippets of a user when a user is not logged in' do
+ visit user_path(user)
+ page.within('.user-profile-nav') { click_link 'Snippets' }
+ wait_for_requests
+
+ expect(page).to have_selector('.snippet-row', count: 1)
+ expect(page).to have_content(public_snippet.title)
+ end
+ end
end
end
diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb
index c43feadc808..15b89dac572 100644
--- a/spec/features/users_spec.rb
+++ b/spec/features/users_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Users', feature: true, js: true do
+feature 'Users', js: true do
let(:user) { create(:user, username: 'user1', name: 'User 1', email: 'user1@gitlab.com') }
scenario 'GET /users/sign_in creates a new user account' do
@@ -24,7 +24,7 @@ feature 'Users', feature: true, js: true do
user.reload
expect(user.reset_password_token).not_to be_nil
- login_with(user)
+ gitlab_sign_in(user)
expect(current_path).to eq root_path
user.reload
@@ -45,7 +45,9 @@ feature 'Users', feature: true, js: true do
end
describe 'redirect alias routes' do
- before { user }
+ before do
+ expect(user).to be_persisted
+ end
scenario '/u/user1 redirects to user page' do
visit '/u/user1'
@@ -71,38 +73,38 @@ feature 'Users', feature: true, js: true do
let(:loading_icon) { '.fa.fa-spinner' }
let(:username_input) { 'new_user_username' }
- before(:each) do
+ before do
visit new_user_session_path
click_link 'Register'
end
scenario 'doesn\'t show an error border if the username is available' do
fill_in username_input, with: 'new-user'
- wait_for_ajax
+ wait_for_requests
expect(find('.username')).not_to have_css '.gl-field-error-outline'
end
scenario 'does not show an error border if the username contains dots (.)' do
fill_in username_input, with: 'new.user.username'
- wait_for_ajax
+ wait_for_requests
expect(find('.username')).not_to have_css '.gl-field-error-outline'
end
scenario 'shows an error border if the username already exists' do
fill_in username_input, with: user.username
- wait_for_ajax
+ wait_for_requests
expect(find('.username')).to have_css '.gl-field-error-outline'
end
scenario 'shows an error border if the username contains special characters' do
fill_in username_input, with: 'new$user!username'
- wait_for_ajax
+ wait_for_requests
expect(find('.username')).to have_css '.gl-field-error-outline'
end
end
def errors_on_page(page)
- page.find('#error_explanation').find('ul').all('li').map{ |item| item.text }.join("\n")
+ page.find('#error_explanation').find('ul').all('li').map { |item| item.text }.join("\n")
end
def number_of_errors_on_page(page)
diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb
index b83a230c1f8..6794bf4f4ba 100644
--- a/spec/features/variables_spec.rb
+++ b/spec/features/variables_spec.rb
@@ -2,15 +2,15 @@ require 'spec_helper'
describe 'Project variables', js: true do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:variable) { create(:ci_variable, key: 'test_key', value: 'test value') }
before do
- login_as(user)
+ sign_in(user)
project.team << [user, :master]
project.variables << variable
- visit namespace_project_settings_ci_cd_path(project.namespace, project)
+ visit project_settings_ci_cd_path(project)
end
it 'shows list of variables' do
@@ -19,14 +19,15 @@ describe 'Project variables', js: true do
end
end
- it 'adds new variable' do
+ it 'adds new secret variable' do
fill_in('variable_key', with: 'key')
fill_in('variable_value', with: 'key value')
click_button('Add new variable')
- expect(page).to have_content('Variables were successfully updated.')
+ expect(page).to have_content('Variable was successfully created.')
page.within('.variables-table') do
expect(page).to have_content('key')
+ expect(page).to have_content('No')
end
end
@@ -35,12 +36,25 @@ describe 'Project variables', js: true do
fill_in('variable_value', with: '')
click_button('Add new variable')
- expect(page).to have_content('Variables were successfully updated.')
+ expect(page).to have_content('Variable was successfully created.')
page.within('.variables-table') do
expect(page).to have_content('new_key')
end
end
+ it 'adds new protected variable' do
+ fill_in('variable_key', with: 'key')
+ fill_in('variable_value', with: 'value')
+ check('Protected')
+ click_button('Add new variable')
+
+ expect(page).to have_content('Variable was successfully created.')
+ page.within('.variables-table') do
+ expect(page).to have_content('key')
+ expect(page).to have_content('Yes')
+ end
+ end
+
it 'reveals and hides new variable' do
fill_in('variable_key', with: 'key')
fill_in('variable_value', with: 'key value')
@@ -68,7 +82,7 @@ describe 'Project variables', js: true do
it 'deletes variable' do
page.within('.variables-table') do
- find('.btn-variable-delete').click
+ click_on 'Remove'
end
expect(page).not_to have_selector('variables-table')
@@ -76,7 +90,7 @@ describe 'Project variables', js: true do
it 'edits variable' do
page.within('.variables-table') do
- find('.btn-variable-edit').click
+ click_on 'Update'
end
expect(page).to have_content('Update variable')
@@ -85,12 +99,12 @@ describe 'Project variables', js: true do
click_button('Save variable')
expect(page).to have_content('Variable was successfully updated.')
- expect(project.variables.first.value).to eq('key value')
+ expect(project.variables(true).first.value).to eq('key value')
end
it 'edits variable with empty value' do
page.within('.variables-table') do
- find('.btn-variable-edit').click
+ click_on 'Update'
end
expect(page).to have_content('Update variable')
@@ -98,6 +112,34 @@ describe 'Project variables', js: true do
click_button('Save variable')
expect(page).to have_content('Variable was successfully updated.')
- expect(project.variables.first.value).to eq('')
+ expect(project.variables(true).first.value).to eq('')
+ end
+
+ it 'edits variable to be protected' do
+ page.within('.variables-table') do
+ click_on 'Update'
+ end
+
+ expect(page).to have_content('Update variable')
+ check('Protected')
+ click_button('Save variable')
+
+ expect(page).to have_content('Variable was successfully updated.')
+ expect(project.variables(true).first).to be_protected
+ end
+
+ it 'edits variable to be unprotected' do
+ project.variables.first.update(protected: true)
+
+ page.within('.variables-table') do
+ click_on 'Update'
+ end
+
+ expect(page).to have_content('Update variable')
+ uncheck('Protected')
+ click_button('Save variable')
+
+ expect(page).to have_content('Variable was successfully updated.')
+ expect(project.variables(true).first).not_to be_protected
end
end
diff --git a/spec/finders/access_requests_finder_spec.rb b/spec/finders/access_requests_finder_spec.rb
index c7278e971ae..0789d3a9b44 100644
--- a/spec/finders/access_requests_finder_spec.rb
+++ b/spec/finders/access_requests_finder_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe AccessRequestsFinder, services: true do
+describe AccessRequestsFinder do
let(:user) { create(:user) }
let(:access_requester) { create(:user) }
let(:project) do
- create(:empty_project, :public, :access_requestable) do |project|
+ create(:project, :public, :access_requestable) do |project|
project.request_access(access_requester)
end
end
diff --git a/spec/finders/admin/projects_finder_spec.rb b/spec/finders/admin/projects_finder_spec.rb
new file mode 100644
index 00000000000..28e36330029
--- /dev/null
+++ b/spec/finders/admin/projects_finder_spec.rb
@@ -0,0 +1,142 @@
+require 'spec_helper'
+
+describe Admin::ProjectsFinder do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group, :public) }
+
+ let!(:private_project) do
+ create(:project, :private, name: 'A', path: 'A')
+ end
+
+ let!(:internal_project) do
+ create(:project, :internal, group: group, name: 'B', path: 'B')
+ end
+
+ let!(:public_project) do
+ create(:project, :public, group: group, name: 'C', path: 'C')
+ end
+
+ let!(:shared_project) do
+ create(:project, :private, name: 'D', path: 'D')
+ end
+
+ let(:params) { {} }
+ let(:current_user) { user }
+ let(:project_ids_relation) { nil }
+ let(:finder) { described_class.new(params: params, current_user: current_user) }
+
+ subject { finder.execute.to_a }
+
+ context 'without a user' do
+ let(:current_user) { nil }
+
+ it { is_expected.to match_array([shared_project, public_project, internal_project, private_project]) }
+ end
+
+ context 'with a user' do
+ it { is_expected.to match_array([shared_project, public_project, internal_project, private_project]) }
+ end
+
+ context 'with pending delete project' do
+ let!(:pending_delete_project) { create(:project, pending_delete: true) }
+
+ it { is_expected.not_to include(pending_delete_project) }
+ end
+
+ context 'filter by namespace_id' do
+ let(:namespace) { create(:namespace) }
+ let!(:project_in_namespace) { create(:project, namespace: namespace) }
+ let(:params) { { namespace_id: namespace.id } }
+
+ it { is_expected.to eq([project_in_namespace]) }
+ end
+
+ context 'filter by visibility_level' do
+ before do
+ private_project.add_master(user)
+ end
+
+ context 'private' do
+ let(:params) { { visibility_level: Gitlab::VisibilityLevel::PRIVATE } }
+
+ it { is_expected.to match_array([shared_project, private_project]) }
+ end
+
+ context 'internal' do
+ let(:params) { { visibility_level: Gitlab::VisibilityLevel::INTERNAL } }
+
+ it { is_expected.to eq([internal_project]) }
+ end
+
+ context 'public' do
+ let(:params) { { visibility_level: Gitlab::VisibilityLevel::PUBLIC } }
+
+ it { is_expected.to eq([public_project]) }
+ end
+ end
+
+ context 'filter by push' do
+ let(:pushed_event) { create(:event, :pushed) }
+ let!(:project_with_push) { pushed_event.project }
+ let(:params) { { with_push: true } }
+
+ it { is_expected.to eq([project_with_push]) }
+ end
+
+ context 'filter by abandoned' do
+ before do
+ private_project.update(last_activity_at: Time.zone.now - 6.months - 1.minute)
+ end
+
+ let(:params) { { abandoned: true } }
+
+ it { is_expected.to eq([private_project]) }
+ end
+
+ context 'filter by last_repository_check_failed' do
+ before do
+ private_project.update(last_repository_check_failed: true)
+ end
+
+ let(:params) { { last_repository_check_failed: true } }
+
+ it { is_expected.to eq([private_project]) }
+ end
+
+ context 'filter by archived' do
+ let!(:archived_project) { create(:project, :public, :archived, name: 'E', path: 'E') }
+
+ context 'archived=false' do
+ let(:params) { { archived: false } }
+
+ it { is_expected.to match_array([shared_project, public_project, internal_project, private_project]) }
+ end
+
+ context 'archived=true' do
+ let(:params) { { archived: true } }
+
+ it { is_expected.to match_array([archived_project, shared_project, public_project, internal_project, private_project]) }
+ end
+ end
+
+ context 'filter by personal' do
+ let!(:personal_project) { create(:project, namespace: user.namespace) }
+ let(:params) { { personal: true } }
+
+ it { is_expected.to eq([personal_project]) }
+ end
+
+ context 'filter by name' do
+ let(:params) { { name: 'C' } }
+
+ it { is_expected.to match_array([shared_project, public_project, private_project]) }
+ end
+
+ context 'sorting' do
+ let(:params) { { sort: 'name_asc' } }
+
+ it { is_expected.to eq([private_project, internal_project, public_project, shared_project]) }
+ end
+ end
+end
diff --git a/spec/finders/contributed_projects_finder_spec.rb b/spec/finders/contributed_projects_finder_spec.rb
index 34f665826b6..60ea98e61c7 100644
--- a/spec/finders/contributed_projects_finder_spec.rb
+++ b/spec/finders/contributed_projects_finder_spec.rb
@@ -6,16 +6,16 @@ describe ContributedProjectsFinder do
let(:finder) { described_class.new(source_user) }
- let!(:public_project) { create(:empty_project, :public) }
- let!(:private_project) { create(:empty_project, :private) }
+ let!(:public_project) { create(:project, :public) }
+ let!(:private_project) { create(:project, :private) }
before do
private_project.add_master(source_user)
private_project.add_developer(current_user)
public_project.add_master(source_user)
- create(:event, :pushed, project: public_project, target: public_project, author: source_user)
- create(:event, :pushed, project: private_project, target: private_project, author: source_user)
+ create(:push_event, project: public_project, author: source_user)
+ create(:push_event, project: private_project, author: source_user)
end
describe 'without a current user' do
diff --git a/spec/finders/environments_finder_spec.rb b/spec/finders/environments_finder_spec.rb
index 0c063f6d5ee..3a8a1e7de74 100644
--- a/spec/finders/environments_finder_spec.rb
+++ b/spec/finders/environments_finder_spec.rb
@@ -12,7 +12,7 @@ describe EnvironmentsFinder do
context 'tagged deployment' do
before do
- create(:deployment, environment: environment, ref: '1.0', tag: true, sha: project.commit.id)
+ create(:deployment, environment: environment, ref: 'v1.1.0', tag: true, sha: project.commit.id)
end
it 'returns environment when with_tags is set' do
diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb
new file mode 100644
index 00000000000..18d6c0cfd74
--- /dev/null
+++ b/spec/finders/events_finder_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe EventsFinder do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:project1) { create(:project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:project2) { create(:project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:closed_issue) { create(:closed_issue, project: project1, author: user) }
+ let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) }
+ let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
+ let!(:opened_merge_request_event) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 1, 31)) }
+ let(:closed_issue2) { create(:closed_issue, project: project1, author: user) }
+ let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) }
+ let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) }
+ let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) }
+
+ context 'when targeting a user' do
+ it 'returns events between specified dates filtered on action and type' do
+ events = described_class.new(source: user, current_user: user, action: 'created', target_type: 'merge_request', after: Date.new(2017, 1, 1), before: Date.new(2017, 2, 1)).execute
+
+ expect(events).to eq([opened_merge_request_event])
+ end
+
+ it 'does not return events the current_user does not have access to' do
+ events = described_class.new(source: user, current_user: other_user).execute
+
+ expect(events).not_to include(opened_merge_request_event)
+ end
+ end
+
+ context 'when targeting a project' do
+ it 'returns project events between specified dates filtered on action and type' do
+ events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute
+
+ expect(events).to eq([closed_issue_event])
+ end
+
+ it 'does not return events the current_user does not have access to' do
+ events = described_class.new(source: project2, current_user: other_user).execute
+
+ expect(events).to be_empty
+ end
+ end
+end
diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb
index b762756f9ce..db3fcc23475 100644
--- a/spec/finders/group_members_finder_spec.rb
+++ b/spec/finders/group_members_finder_spec.rb
@@ -18,7 +18,7 @@ describe GroupMembersFinder, '#execute' do
expect(result.to_a).to eq([member3, member2, member1])
end
- it 'returns members for nested group' do
+ it 'returns members for nested group', :nested_groups do
group.add_master(user2)
nested_group.request_access(user4)
member1 = group.add_master(user1)
diff --git a/spec/finders/group_projects_finder_spec.rb b/spec/finders/group_projects_finder_spec.rb
index 3c7c9bdcd08..c6d257bc479 100644
--- a/spec/finders/group_projects_finder_spec.rb
+++ b/spec/finders/group_projects_finder_spec.rb
@@ -7,11 +7,11 @@ describe GroupProjectsFinder do
let(:finder) { described_class.new(group: group, current_user: current_user, options: options) }
- let!(:public_project) { create(:empty_project, :public, group: group, path: '1') }
- let!(:private_project) { create(:empty_project, :private, group: group, path: '2') }
- let!(:shared_project_1) { create(:empty_project, :public, path: '3') }
- let!(:shared_project_2) { create(:empty_project, :private, path: '4') }
- let!(:shared_project_3) { create(:empty_project, :internal, path: '5') }
+ let!(:public_project) { create(:project, :public, group: group, path: '1') }
+ let!(:private_project) { create(:project, :private, group: group, path: '2') }
+ let!(:shared_project_1) { create(:project, :public, path: '3') }
+ let!(:shared_project_2) { create(:project, :private, path: '4') }
+ let!(:shared_project_3) { create(:project, :internal, path: '5') }
before do
shared_project_1.project_group_links.create(group_access: Gitlab::Access::MASTER, group: group)
diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb
index d5d111e8d15..abc470788e1 100644
--- a/spec/finders/groups_finder_spec.rb
+++ b/spec/finders/groups_finder_spec.rb
@@ -3,29 +3,115 @@ require 'spec_helper'
describe GroupsFinder do
describe '#execute' do
let(:user) { create(:user) }
- let!(:private_group) { create(:group, :private) }
- let!(:internal_group) { create(:group, :internal) }
- let!(:public_group) { create(:group, :public) }
- let(:finder) { described_class.new }
- describe 'execute' do
- describe 'without a user' do
- subject { finder.execute }
+ context 'root level groups' do
+ let!(:private_group) { create(:group, :private) }
+ let!(:internal_group) { create(:group, :internal) }
+ let!(:public_group) { create(:group, :public) }
+
+ context 'without a user' do
+ subject { described_class.new.execute }
it { is_expected.to eq([public_group]) }
end
- describe 'with a user' do
- subject { finder.execute(user) }
+ context 'with a user' do
+ subject { described_class.new(user).execute }
context 'normal user' do
- it { is_expected.to eq([public_group, internal_group]) }
+ it { is_expected.to contain_exactly(public_group, internal_group) }
end
context 'external user' do
let(:user) { create(:user, external: true) }
- it { is_expected.to eq([public_group]) }
+ it { is_expected.to contain_exactly(public_group) }
+ end
+
+ context 'user is member of the private group' do
+ before do
+ private_group.add_guest(user)
+ end
+
+ it { is_expected.to contain_exactly(public_group, internal_group, private_group) }
+ end
+ end
+ end
+
+ context 'subgroups', :nested_groups do
+ let!(:parent_group) { create(:group, :public) }
+ let!(:public_subgroup) { create(:group, :public, parent: parent_group) }
+ let!(:internal_subgroup) { create(:group, :internal, parent: parent_group) }
+ let!(:private_subgroup) { create(:group, :private, parent: parent_group) }
+
+ context 'without a user' do
+ it 'only returns parent and public subgroups' do
+ expect(described_class.new(nil).execute).to contain_exactly(parent_group, public_subgroup)
+ end
+ end
+
+ context 'with a user' do
+ subject { described_class.new(user).execute }
+
+ it 'returns parent, public, and internal subgroups' do
+ is_expected.to contain_exactly(parent_group, public_subgroup, internal_subgroup)
+ end
+
+ context 'being member' do
+ it 'returns parent, public subgroups, internal subgroups, and private subgroups user is member of' do
+ private_subgroup.add_guest(user)
+
+ is_expected.to contain_exactly(parent_group, public_subgroup, internal_subgroup, private_subgroup)
+ end
+ end
+
+ context 'parent group private' do
+ before do
+ parent_group.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context 'being member of parent group' do
+ it 'returns all subgroups' do
+ parent_group.add_guest(user)
+
+ is_expected.to contain_exactly(parent_group, public_subgroup, internal_subgroup, private_subgroup)
+ end
+ end
+
+ context 'authorized to private project' do
+ context 'project one level deep' do
+ let!(:subproject) { create(:project, :private, namespace: private_subgroup) }
+ before do
+ subproject.add_guest(user)
+ end
+
+ it 'includes the subgroup of the project' do
+ is_expected.to include(private_subgroup)
+ end
+
+ it 'does not include private subgroups deeper down' do
+ subsubgroup = create(:group, :private, parent: private_subgroup)
+
+ is_expected.not_to include(subsubgroup)
+ end
+ end
+
+ context 'project two levels deep' do
+ let!(:private_subsubgroup) { create(:group, :private, parent: private_subgroup) }
+ let!(:subsubproject) { create(:project, :private, namespace: private_subsubgroup) }
+ before do
+ subsubproject.add_guest(user)
+ end
+
+ it 'returns all the ancestor groups' do
+ is_expected.to include(private_subsubgroup, private_subgroup, parent_group)
+ end
+
+ it 'returns the groups for a given parent' do
+ expect(described_class.new(user, parent: parent_group).execute).to include(private_subgroup)
+ end
+ end
+ end
end
end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index a1ae1d746af..8769a52863c 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -3,20 +3,20 @@ require 'spec_helper'
describe IssuesFinder do
set(:user) { create(:user) }
set(:user2) { create(:user) }
- set(:project1) { create(:empty_project) }
- set(:project2) { create(:empty_project) }
+ set(:project1) { create(:project) }
+ set(:project2) { create(:project) }
set(:milestone) { create(:milestone, project: project1) }
set(:label) { create(:label, project: project2) }
- set(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') }
- set(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') }
- set(:issue3) { create(:issue, author: user2, assignee: user2, project: project2, title: 'tanuki', description: 'tanuki') }
+ set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago) }
+ set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') }
+ set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 1.week.from_now) }
describe '#execute' do
- set(:closed_issue) { create(:issue, author: user2, assignee: user2, project: project2, state: 'closed') }
+ set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') }
set(:label_link) { create(:label_link, label: label, target: issue2) }
let(:search_user) { user }
let(:params) { {} }
- let(:issues) { IssuesFinder.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
+ let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
before(:context) do
project1.team << [user, :master]
@@ -59,6 +59,23 @@ describe IssuesFinder do
end
end
+ context 'filtering by group milestone' do
+ let!(:group) { create(:group, :public) }
+ let(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let(:params) { { milestone_title: group_milestone.title } }
+
+ before do
+ project2.update(namespace: group)
+ issue2.update(milestone: group_milestone)
+ issue3.update(milestone: group_milestone)
+ end
+
+ it 'returns issues assigned to that group milestone' do
+ expect(issues).to contain_exactly(issue2, issue3)
+ end
+ end
+
context 'filtering by no milestone' do
let(:params) { { milestone_title: Milestone::None.title } }
@@ -70,9 +87,9 @@ describe IssuesFinder do
context 'filtering by upcoming milestone' do
let(:params) { { milestone_title: Milestone::Upcoming.name } }
- let(:project_no_upcoming_milestones) { create(:empty_project, :public) }
- let(:project_next_1_1) { create(:empty_project, :public) }
- let(:project_next_8_8) { create(:empty_project, :public) }
+ let(:project_no_upcoming_milestones) { create(:project, :public) }
+ let(:project_next_1_1) { create(:project, :public) }
+ let(:project_next_8_8) { create(:project, :public) }
let(:yesterday) { Date.today - 1.day }
let(:tomorrow) { Date.today + 1.day }
@@ -91,7 +108,7 @@ describe IssuesFinder do
before do
milestones.each do |milestone|
- create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user)
+ create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user])
end
end
@@ -104,9 +121,9 @@ describe IssuesFinder do
context 'filtering by started milestone' do
let(:params) { { milestone_title: Milestone::Started.name } }
- let(:project_no_started_milestones) { create(:empty_project, :public) }
- let(:project_started_1_and_2) { create(:empty_project, :public) }
- let(:project_started_8) { create(:empty_project, :public) }
+ let(:project_no_started_milestones) { create(:project, :public) }
+ let(:project_started_1_and_2) { create(:project, :public) }
+ let(:project_started_8) { create(:project, :public) }
let(:yesterday) { Date.today - 1.day }
let(:tomorrow) { Date.today + 1.day }
@@ -126,7 +143,7 @@ describe IssuesFinder do
before do
milestones.each do |milestone|
- create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user)
+ create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user])
end
end
@@ -148,7 +165,9 @@ describe IssuesFinder do
let(:params) { { label_name: [label.title, label2.title].join(',') } }
let(:label2) { create(:label, project: project2) }
- before { create(:label_link, label: label2, target: issue2) }
+ before do
+ create(:label_link, label: label2, target: issue2)
+ end
it 'returns the unique issues with any of those labels' do
expect(issues).to contain_exactly(issue2)
@@ -213,6 +232,24 @@ describe IssuesFinder do
end
end
+ context 'filtering by created_at' do
+ context 'through created_after' do
+ let(:params) { { created_after: issue3.created_at } }
+
+ it 'returns issues created on or after the given date' do
+ expect(issues).to contain_exactly(issue3)
+ end
+ end
+
+ context 'through created_before' do
+ let(:params) { { created_before: issue1.created_at + 1.second } }
+
+ it 'returns issues created on or before the given date' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+ end
+
context 'when the user is unauthorized' do
let(:search_user) { nil }
@@ -231,7 +268,7 @@ describe IssuesFinder do
it 'finds issues user can access due to group' do
group = create(:group)
- project = create(:empty_project, group: group)
+ project = create(:project, group: group)
issue = create(:issue, project: project)
group.add_user(user, :owner)
@@ -259,7 +296,7 @@ describe IssuesFinder do
let(:scope) { nil }
it "doesn't return team-only issues to non team members" do
- project = create(:empty_project, :public, :issues_private)
+ project = create(:project, :public, :issues_private)
issue = create(:issue, project: project)
expect(issues).not_to include(issue)
@@ -275,22 +312,121 @@ describe IssuesFinder do
end
end
- describe '.not_restricted_by_confidentiality' do
- let(:authorized_user) { create(:user) }
- let(:project) { create(:empty_project, namespace: authorized_user.namespace) }
- let!(:public_issue) { create(:issue, project: project) }
- let!(:confidential_issue) { create(:issue, project: project, confidential: true) }
+ describe '#with_confidentiality_access_check' do
+ let(:guest) { create(:user) }
+ set(:authorized_user) { create(:user) }
+ set(:project) { create(:project, namespace: authorized_user.namespace) }
+ set(:public_issue) { create(:issue, project: project) }
+ set(:confidential_issue) { create(:issue, project: project, confidential: true) }
- it 'returns non confidential issues for nil user' do
- expect(IssuesFinder.send(:not_restricted_by_confidentiality, nil)).to include(public_issue)
- end
+ context 'when no project filter is given' do
+ let(:params) { {} }
+
+ context 'for an anonymous user' do
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+ end
+
+ context 'for a user without project membership' do
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+ end
+
+ context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
+ before do
+ project.add_guest(guest)
+ end
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+ end
- it 'returns non confidential issues for user not authorized for the issues projects' do
- expect(IssuesFinder.send(:not_restricted_by_confidentiality, user)).to include(public_issue)
+ context 'for a project member with access to view confidential issues' do
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
+
+ it 'returns all issues' do
+ expect(subject).to include(public_issue, confidential_issue)
+ end
+ end
end
- it 'returns all issues for user authorized for the issues projects' do
- expect(IssuesFinder.send(:not_restricted_by_confidentiality, authorized_user)).to include(public_issue, confidential_issue)
+ context 'when searching within a specific project' do
+ let(:params) { { project_id: project.id } }
+
+ context 'for an anonymous user' do
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+
+ it 'does not filter by confidentiality' do
+ expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
+
+ context 'for a user without project membership' do
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+
+ it 'filters by confidentiality' do
+ expect(Issue).to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
+
+ context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
+ before do
+ project.add_guest(guest)
+ end
+
+ it 'returns only public issues' do
+ expect(subject).to include(public_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+
+ it 'filters by confidentiality' do
+ expect(Issue).to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
+
+ context 'for a project member with access to view confidential issues' do
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
+
+ it 'returns all issues' do
+ expect(subject).to include(public_issue, confidential_issue)
+ end
+
+ it 'does not filter by confidentiality' do
+ expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
end
end
end
diff --git a/spec/finders/joined_groups_finder_spec.rb b/spec/finders/joined_groups_finder_spec.rb
index 4c389746252..29a47e005a6 100644
--- a/spec/finders/joined_groups_finder_spec.rb
+++ b/spec/finders/joined_groups_finder_spec.rb
@@ -42,7 +42,7 @@ describe JoinedGroupsFinder do
context 'if profile visitor is in one of the private group projects' do
before do
- project = create(:empty_project, :private, group: private_group, name: 'B', path: 'B')
+ project = create(:project, :private, group: private_group, name: 'B', path: 'B')
project.add_user(profile_visitor, Gitlab::Access::DEVELOPER)
end
diff --git a/spec/finders/labels_finder_spec.rb b/spec/finders/labels_finder_spec.rb
index 1724cdba830..afa2a40ed2a 100644
--- a/spec/finders/labels_finder_spec.rb
+++ b/spec/finders/labels_finder_spec.rb
@@ -6,11 +6,11 @@ describe LabelsFinder do
let(:group_2) { create(:group) }
let(:group_3) { create(:group) }
- let(:project_1) { create(:empty_project, namespace: group_1) }
- let(:project_2) { create(:empty_project, namespace: group_2) }
- let(:project_3) { create(:empty_project) }
- let(:project_4) { create(:empty_project, :public) }
- let(:project_5) { create(:empty_project, namespace: group_1) }
+ let(:project_1) { create(:project, namespace: group_1) }
+ let(:project_2) { create(:project, namespace: group_2) }
+ let(:project_3) { create(:project) }
+ let(:project_4) { create(:project, :public) }
+ let(:project_5) { create(:project, namespace: group_1) }
let!(:project_label_1) { create(:label, project: project_1, title: 'Label 1') }
let!(:project_label_2) { create(:label, project: project_2, title: 'Label 2') }
@@ -49,12 +49,12 @@ describe LabelsFinder do
end
context 'filtering by group_id' do
- it 'returns labels available for any project within the group' do
+ it 'returns labels available for any non-archived project within the group' do
group_1.add_developer(user)
-
+ project_1.archive!
finder = described_class.new(user, group_id: group_1.id)
- expect(finder.execute).to eq [group_label_2, project_label_1, group_label_1, project_label_5]
+ expect(finder.execute).to eq [group_label_2, group_label_1, project_label_5]
end
end
@@ -68,7 +68,7 @@ describe LabelsFinder do
context 'as an administrator' do
it 'does not return labels from another project' do
# Purposefully creating a project with _nothing_ associated to it
- isolated_project = create(:empty_project)
+ isolated_project = create(:project)
admin = create(:admin)
# project_3 has a label associated to it, which we don't want coming
diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb
index cf691cf684b..300ba8422e8 100644
--- a/spec/finders/members_finder_spec.rb
+++ b/spec/finders/members_finder_spec.rb
@@ -9,7 +9,7 @@ describe MembersFinder, '#execute' do
let(:user3) { create(:user) }
let(:user4) { create(:user) }
- it 'returns members for project and parent groups' do
+ it 'returns members for project and parent groups', :nested_groups do
nested_group.request_access(user1)
member1 = group.add_master(user2)
member2 = nested_group.add_master(user3)
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 21ef94ac5d1..b54155a6704 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -4,9 +4,9 @@ describe MergeRequestsFinder do
let(:user) { create :user }
let(:user2) { create :user }
- let(:project1) { create(:empty_project) }
- let(:project2) { create(:empty_project, forked_from_project: project1) }
- let(:project3) { create(:empty_project, :archived, forked_from_project: project1) }
+ let(:project1) { create(:project) }
+ let(:project2) { create(:project, forked_from_project: project1) }
+ let(:project3) { create(:project, :archived, forked_from_project: project1) }
let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) }
let!(:merge_request2) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1, state: 'closed') }
@@ -23,28 +23,89 @@ describe MergeRequestsFinder do
describe "#execute" do
it 'filters by scope' do
params = { scope: 'authored', state: 'opened' }
- merge_requests = MergeRequestsFinder.new(user, params).execute
+ merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3)
end
it 'filters by project' do
params = { project_id: project1.id, scope: 'authored', state: 'opened' }
- merge_requests = MergeRequestsFinder.new(user, params).execute
+ merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1)
end
it 'filters by non_archived' do
params = { non_archived: true }
- merge_requests = MergeRequestsFinder.new(user, params).execute
+ merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3)
end
it 'filters by iid' do
params = { project_id: project1.id, iids: merge_request1.iid }
- merge_requests = MergeRequestsFinder.new(user, params).execute
+ merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1)
end
+
+ context 'filtering by group milestone' do
+ let!(:group) { create(:group, :public) }
+ let(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let(:params) { { milestone_title: group_milestone.title } }
+
+ before do
+ project2.update(namespace: group)
+ merge_request2.update(milestone: group_milestone)
+ merge_request3.update(milestone: group_milestone)
+ end
+
+ it 'returns issues assigned to that group milestone' do
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(merge_request2, merge_request3)
+ end
+ end
+
+ context 'with created_after and created_before params' do
+ let(:project4) { create(:project, forked_from_project: project1) }
+
+ let!(:new_merge_request) do
+ create(:merge_request,
+ :simple,
+ author: user,
+ created_at: 1.week.from_now,
+ source_project: project4,
+ target_project: project1)
+ end
+
+ let!(:old_merge_request) do
+ create(:merge_request,
+ :simple,
+ author: user,
+ created_at: 1.week.ago,
+ source_project: project4,
+ target_project: project4)
+ end
+
+ before do
+ project4.add_master(user)
+ end
+
+ it 'filters by created_after' do
+ params = { project_id: project1.id, created_after: new_merge_request.created_at }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(new_merge_request)
+ end
+
+ it 'filters by created_before' do
+ params = { project_id: project4.id, created_before: old_merge_request.created_at + 1.second }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(old_merge_request)
+ end
+ end
end
end
diff --git a/spec/finders/milestones_finder_spec.rb b/spec/finders/milestones_finder_spec.rb
new file mode 100644
index 00000000000..8ae08656e01
--- /dev/null
+++ b/spec/finders/milestones_finder_spec.rb
@@ -0,0 +1,90 @@
+require 'spec_helper'
+
+describe MilestonesFinder do
+ let(:group) { create(:group) }
+ let(:project_1) { create(:project, namespace: group) }
+ let(:project_2) { create(:project, namespace: group) }
+ let!(:milestone_1) { create(:milestone, group: group, title: 'one test', due_date: Date.today) }
+ let!(:milestone_2) { create(:milestone, group: group) }
+ let!(:milestone_3) { create(:milestone, project: project_1, state: 'active', due_date: Date.tomorrow) }
+ let!(:milestone_4) { create(:milestone, project: project_2, state: 'active') }
+
+ it 'it returns milestones for projects' do
+ result = described_class.new(project_ids: [project_1.id, project_2.id], state: 'all').execute
+
+ expect(result).to contain_exactly(milestone_3, milestone_4)
+ end
+
+ it 'returns milestones for groups' do
+ result = described_class.new(group_ids: group.id, state: 'all').execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_2)
+ end
+
+ it 'returns milestones for groups and projects' do
+ result = described_class.new(project_ids: [project_1.id, project_2.id], group_ids: group.id, state: 'all').execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
+ end
+
+ context 'with filters' do
+ let(:params) do
+ {
+ project_ids: [project_1.id, project_2.id],
+ group_ids: group.id,
+ state: 'all'
+ }
+ end
+
+ before do
+ milestone_1.close
+ milestone_3.close
+ end
+
+ it 'filters by active state' do
+ params[:state] = 'active'
+ result = described_class.new(params).execute
+
+ expect(result).to contain_exactly(milestone_2, milestone_4)
+ end
+
+ it 'filters by closed state' do
+ params[:state] = 'closed'
+ result = described_class.new(params).execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_3)
+ end
+
+ it 'filters by title' do
+ result = described_class.new(params.merge(title: 'one test')).execute
+
+ expect(result.to_a).to contain_exactly(milestone_1)
+ end
+ end
+
+ context 'with order' do
+ let(:params) do
+ {
+ project_ids: [project_1.id, project_2.id],
+ group_ids: group.id,
+ state: 'all'
+ }
+ end
+
+ it "default orders by due date" do
+ result = described_class.new(params).execute
+
+ expect(result.first).to eq(milestone_1)
+ expect(result.second).to eq(milestone_3)
+ end
+
+ it "orders by parameter" do
+ result = described_class.new(params.merge(order: 'id DESC')).execute
+
+ expect(result.first).to eq(milestone_4)
+ expect(result.second).to eq(milestone_3)
+ expect(result.third).to eq(milestone_2)
+ expect(result.fourth).to eq(milestone_1)
+ end
+ end
+end
diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb
index dea87980e25..e577083a2d0 100644
--- a/spec/finders/move_to_project_finder_spec.rb
+++ b/spec/finders/move_to_project_finder_spec.rb
@@ -2,13 +2,13 @@ require 'spec_helper'
describe MoveToProjectFinder do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
- let(:no_access_project) { create(:empty_project) }
- let(:guest_project) { create(:empty_project) }
- let(:reporter_project) { create(:empty_project) }
- let(:developer_project) { create(:empty_project) }
- let(:master_project) { create(:empty_project) }
+ let(:no_access_project) { create(:project) }
+ let(:guest_project) { create(:project) }
+ let(:reporter_project) { create(:project) }
+ let(:developer_project) { create(:project) }
+ let(:master_project) { create(:project) }
subject { described_class.new(user) }
@@ -37,7 +37,7 @@ describe MoveToProjectFinder do
it 'does not return archived projects' do
reporter_project.team << [user, :reporter]
reporter_project.archive!
- other_reporter_project = create(:empty_project)
+ other_reporter_project = create(:project)
other_reporter_project.team << [user, :reporter]
expect(subject.execute(project).to_a).to eq([other_reporter_project])
@@ -46,7 +46,7 @@ describe MoveToProjectFinder do
it 'does not return projects for which issues are disabled' do
reporter_project.team << [user, :reporter]
reporter_project.update_attributes(issues_enabled: false)
- other_reporter_project = create(:empty_project)
+ other_reporter_project = create(:project)
other_reporter_project.team << [user, :reporter]
expect(subject.execute(project).to_a).to eq([other_reporter_project])
@@ -83,10 +83,10 @@ describe MoveToProjectFinder do
end
it 'returns projects matching a search query' do
- foo_project = create(:empty_project)
+ foo_project = create(:project)
foo_project.team << [user, :master]
- wadus_project = create(:empty_project, name: 'wadus')
+ wadus_project = create(:project, name: 'wadus')
wadus_project.team << [user, :master]
expect(subject.execute(project).to_a).to eq([wadus_project, foo_project])
diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb
index 765bf44d863..900fa2b12d1 100644
--- a/spec/finders/notes_finder_spec.rb
+++ b/spec/finders/notes_finder_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe NotesFinder do
let(:user) { create :user }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
project.team << [user, :master]
@@ -43,7 +43,7 @@ describe NotesFinder do
context 'on restricted projects' do
let(:project) do
- create(:empty_project,
+ create(:project,
:public,
:issues_private,
:snippets_private,
@@ -110,6 +110,15 @@ describe NotesFinder do
expect(notes.count).to eq(1)
end
+ it 'finds notes on personal snippets' do
+ note = create(:note_on_personal_snippet)
+ params = { target_type: 'personal_snippet', target_id: note.noteable_id }
+
+ notes = described_class.new(project, user, params).execute
+
+ expect(notes.count).to eq(1)
+ end
+
it 'raises an exception for an invalid target_type' do
params[:target_type] = 'invalid'
expect { described_class.new(project, user, params).execute }.to raise_error('invalid target_type')
@@ -147,7 +156,7 @@ describe NotesFinder do
end
describe '.search' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:note) { create(:note_on_issue, note: 'WoW', project: project) }
it 'returns notes with matching content' do
diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb
index fd92664ca24..3f22b3a253d 100644
--- a/spec/finders/personal_access_tokens_finder_spec.rb
+++ b/spec/finders/personal_access_tokens_finder_spec.rb
@@ -25,49 +25,65 @@ describe PersonalAccessTokensFinder do
end
describe 'without impersonation' do
- before { params[:impersonation] = false }
+ before do
+ params[:impersonation] = false
+ end
it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) }
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_personal_access_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) }
end
end
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) }
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_impersonation_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) }
end
end
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it do
is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token,
@@ -81,7 +97,9 @@ describe PersonalAccessTokensFinder do
it { is_expected.to eq(active_personal_access_token) }
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to be_nil }
end
@@ -93,7 +111,9 @@ describe PersonalAccessTokensFinder do
it { is_expected.to eq(active_personal_access_token) }
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to be_nil }
end
@@ -109,7 +129,9 @@ describe PersonalAccessTokensFinder do
let!(:other_user_expired_impersonation_token) { create(:personal_access_token, :expired, :impersonation, user: user2) }
let!(:other_user_revoked_impersonation_token) { create(:personal_access_token, :revoked, :impersonation, user: user2) }
- before { params[:user] = user }
+ before do
+ params[:user] = user
+ end
it do
is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token,
@@ -118,49 +140,65 @@ describe PersonalAccessTokensFinder do
end
describe 'without impersonation' do
- before { params[:impersonation] = false }
+ before do
+ params[:impersonation] = false
+ end
it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) }
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_personal_access_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) }
end
end
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) }
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_impersonation_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) }
end
end
describe 'with active state' do
- before { params[:state] = 'active' }
+ before do
+ params[:state] = 'active'
+ end
it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) }
end
describe 'with inactive state' do
- before { params[:state] = 'inactive' }
+ before do
+ params[:state] = 'inactive'
+ end
it do
is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token,
@@ -174,7 +212,9 @@ describe PersonalAccessTokensFinder do
it { is_expected.to eq(active_personal_access_token) }
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to be_nil }
end
@@ -186,7 +226,9 @@ describe PersonalAccessTokensFinder do
it { is_expected.to eq(active_personal_access_token) }
describe 'with impersonation' do
- before { params[:impersonation] = true }
+ before do
+ params[:impersonation] = true
+ end
it { is_expected.to be_nil }
end
diff --git a/spec/finders/personal_projects_finder_spec.rb b/spec/finders/personal_projects_finder_spec.rb
index e0e17af681a..d0113ba87df 100644
--- a/spec/finders/personal_projects_finder_spec.rb
+++ b/spec/finders/personal_projects_finder_spec.rb
@@ -4,14 +4,14 @@ describe PersonalProjectsFinder do
let(:source_user) { create(:user) }
let(:current_user) { create(:user) }
let(:finder) { described_class.new(source_user) }
- let!(:public_project) { create(:empty_project, :public, namespace: source_user.namespace) }
+ let!(:public_project) { create(:project, :public, namespace: source_user.namespace) }
let!(:private_project) do
- create(:empty_project, :private, namespace: source_user.namespace, path: 'mepmep')
+ create(:project, :private, namespace: source_user.namespace, path: 'mepmep')
end
let!(:internal_project) do
- create(:empty_project, :internal, namespace: source_user.namespace, path: 'C')
+ create(:project, :internal, namespace: source_user.namespace, path: 'C')
end
before do
@@ -32,7 +32,9 @@ describe PersonalProjectsFinder do
end
context 'external' do
- before { current_user.update_attributes(external: true) }
+ before do
+ current_user.update_attributes(external: true)
+ end
it { is_expected.to eq([private_project, public_project]) }
end
diff --git a/spec/finders/pipeline_schedules_finder_spec.rb b/spec/finders/pipeline_schedules_finder_spec.rb
new file mode 100644
index 00000000000..b9538649b3f
--- /dev/null
+++ b/spec/finders/pipeline_schedules_finder_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe PipelineSchedulesFinder do
+ let(:project) { create(:project) }
+
+ let!(:active_schedule) { create(:ci_pipeline_schedule, project: project) }
+ let!(:inactive_schedule) { create(:ci_pipeline_schedule, :inactive, project: project) }
+
+ subject { described_class.new(project).execute(params) }
+
+ describe "#execute" do
+ context 'when the scope is nil' do
+ let(:params) { { scope: nil } }
+
+ it 'selects all pipeline pipeline schedules' do
+ expect(subject.count).to be(2)
+ expect(subject).to include(active_schedule, inactive_schedule)
+ end
+ end
+
+ context 'when the scope is active' do
+ let(:params) { { scope: 'active' } }
+
+ it 'selects only active pipelines' do
+ expect(subject.count).to be(1)
+ expect(subject).to include(active_schedule)
+ expect(subject).not_to include(inactive_schedule)
+ end
+ end
+
+ context 'when the scope is inactve' do
+ let(:params) { { scope: 'inactive' } }
+
+ it 'selects only inactive pipelines' do
+ expect(subject.count).to be(1)
+ expect(subject).not_to include(active_schedule)
+ expect(subject).to include(inactive_schedule)
+ end
+ end
+ end
+end
diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb
index 6bada7b3eb9..2b19cda35b0 100644
--- a/spec/finders/pipelines_finder_spec.rb
+++ b/spec/finders/pipelines_finder_spec.rb
@@ -3,50 +3,205 @@ require 'spec_helper'
describe PipelinesFinder do
let(:project) { create(:project, :repository) }
- let!(:tag_pipeline) { create(:ci_pipeline, project: project, ref: 'v1.0.0') }
- let!(:branch_pipeline) { create(:ci_pipeline, project: project) }
-
- subject { described_class.new(project).execute(params) }
+ subject { described_class.new(project, params).execute }
describe "#execute" do
- context 'when a scope is passed' do
- context 'when scope is nil' do
- let(:params) { { scope: nil } }
+ context 'when params is empty' do
+ let(:params) { {} }
+ let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) }
+
+ it 'returns all pipelines' do
+ is_expected.to match_array(pipelines)
+ end
+ end
+
+ %w[running pending].each do |target|
+ context "when scope is #{target}" do
+ let(:params) { { scope: target } }
+ let!(:pipeline) { create(:ci_pipeline, project: project, status: target) }
- it 'selects all pipelines' do
- expect(subject.count).to be 2
- expect(subject).to include tag_pipeline
- expect(subject).to include branch_pipeline
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline])
end
end
+ end
+
+ context 'when scope is finished' do
+ let(:params) { { scope: 'finished' } }
+ let!(:pipelines) do
+ [create(:ci_pipeline, project: project, status: 'success'),
+ create(:ci_pipeline, project: project, status: 'failed'),
+ create(:ci_pipeline, project: project, status: 'canceled')]
+ end
- context 'when selecting branches' do
+ it 'returns matched pipelines' do
+ is_expected.to match_array(pipelines)
+ end
+ end
+
+ context 'when scope is branches or tags' do
+ let!(:pipeline_branch) { create(:ci_pipeline, project: project) }
+ let!(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) }
+
+ context 'when scope is branches' do
let(:params) { { scope: 'branches' } }
- it 'excludes tags' do
- expect(subject).not_to include tag_pipeline
- expect(subject).to include branch_pipeline
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline_branch])
end
end
- context 'when selecting tags' do
+ context 'when scope is tags' do
let(:params) { { scope: 'tags' } }
- it 'excludes branches' do
- expect(subject).to include tag_pipeline
- expect(subject).not_to include branch_pipeline
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline_tag])
+ end
+ end
+ end
+
+ HasStatus::AVAILABLE_STATUSES.each do |target|
+ context "when status is #{target}" do
+ let(:params) { { status: target } }
+ let!(:pipeline) { create(:ci_pipeline, project: project, status: target) }
+
+ before do
+ exception_status = HasStatus::AVAILABLE_STATUSES - [target]
+ create(:ci_pipeline, project: project, status: exception_status.first)
+ end
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline])
end
end
end
- # Scoping to pending will speed up the test as it doesn't hit the FS
- let(:params) { { scope: 'pending' } }
+ context 'when ref is specified' do
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'when ref exists' do
+ let(:params) { { ref: 'master' } }
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline])
+ end
+ end
+
+ context 'when ref does not exist' do
+ let(:params) { { ref: 'invalid-ref' } }
+
+ it 'returns empty' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ context 'when name is specified' do
+ let(:user) { create(:user) }
+ let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+
+ context 'when name exists' do
+ let(:params) { { name: user.name } }
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline])
+ end
+ end
+
+ context 'when name does not exist' do
+ let(:params) { { name: 'invalid-name' } }
+
+ it 'returns empty' do
+ is_expected.to be_empty
+ end
+ end
+ end
- it 'orders in descending order on ID' do
- feature_pipeline = create(:ci_pipeline, project: project, ref: 'feature')
+ context 'when username is specified' do
+ let(:user) { create(:user) }
+ let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- expected_ids = [feature_pipeline.id, branch_pipeline.id, tag_pipeline.id].sort.reverse
- expect(subject.map(&:id)).to eq expected_ids
+ context 'when username exists' do
+ let(:params) { { username: user.username } }
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline])
+ end
+ end
+
+ context 'when username does not exist' do
+ let(:params) { { username: 'invalid-username' } }
+
+ it 'returns empty' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ context 'when yaml_errors is specified' do
+ let!(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') }
+ let!(:pipeline2) { create(:ci_pipeline, project: project) }
+
+ context 'when yaml_errors is true' do
+ let(:params) { { yaml_errors: true } }
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline1])
+ end
+ end
+
+ context 'when yaml_errors is false' do
+ let(:params) { { yaml_errors: false } }
+
+ it 'returns matched pipelines' do
+ is_expected.to eq([pipeline2])
+ end
+ end
+
+ context 'when yaml_errors is invalid' do
+ let(:params) { { yaml_errors: "invalid-yaml_errors" } }
+
+ it 'returns all pipelines' do
+ is_expected.to match_array([pipeline1, pipeline2])
+ end
+ end
+ end
+
+ context 'when order_by and sort are specified' do
+ context 'when order_by user_id' do
+ let(:params) { { order_by: 'user_id', sort: 'asc' } }
+ let!(:pipelines) { Array.new(2) { create(:ci_pipeline, project: project, user: create(:user)) } }
+
+ it 'sorts as user_id: :asc' do
+ is_expected.to match_array(pipelines)
+ end
+
+ context 'when sort is invalid' do
+ let(:params) { { order_by: 'user_id', sort: 'invalid_sort' } }
+
+ it 'sorts as user_id: :desc' do
+ is_expected.to eq(pipelines.sort_by { |p| -p.user.id })
+ end
+ end
+ end
+
+ context 'when order_by is invalid' do
+ let(:params) { { order_by: 'invalid_column', sort: 'asc' } }
+ let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) }
+
+ it 'sorts as id: :asc' do
+ is_expected.to eq(pipelines.sort_by { |p| p.id })
+ end
+ end
+
+ context 'when both are nil' do
+ let(:params) { { order_by: nil, sort: nil } }
+ let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) }
+
+ it 'sorts as id: :desc' do
+ is_expected.to eq(pipelines.sort_by { |p| -p.id })
+ end
+ end
end
end
end
diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb
index 148adcffe3b..a5de586e869 100644
--- a/spec/finders/projects_finder_spec.rb
+++ b/spec/finders/projects_finder_spec.rb
@@ -6,19 +6,19 @@ describe ProjectsFinder do
let(:group) { create(:group, :public) }
let!(:private_project) do
- create(:empty_project, :private, name: 'A', path: 'A')
+ create(:project, :private, name: 'A', path: 'A')
end
let!(:internal_project) do
- create(:empty_project, :internal, group: group, name: 'B', path: 'B')
+ create(:project, :internal, group: group, name: 'B', path: 'B')
end
let!(:public_project) do
- create(:empty_project, :public, group: group, name: 'C', path: 'C')
+ create(:project, :public, group: group, name: 'C', path: 'C')
end
let!(:shared_project) do
- create(:empty_project, :private, name: 'D', path: 'D')
+ create(:project, :private, name: 'D', path: 'D')
end
let(:params) { {} }
@@ -90,7 +90,7 @@ describe ProjectsFinder do
end
describe 'filter by personal' do
- let!(:personal_project) { create(:empty_project, namespace: user.namespace) }
+ let!(:personal_project) { create(:project, namespace: user.namespace) }
let(:params) { { personal: true } }
it { is_expected.to eq([personal_project]) }
@@ -109,7 +109,7 @@ describe ProjectsFinder do
end
describe 'filter by archived' do
- let!(:archived_project) { create(:empty_project, :public, :archived, name: 'E', path: 'E') }
+ let!(:archived_project) { create(:project, :public, :archived, name: 'E', path: 'E') }
context 'non_archived=true' do
let(:params) { { non_archived: true } }
@@ -137,6 +137,13 @@ describe ProjectsFinder do
it { is_expected.to eq([public_project]) }
end
+ describe 'filter by owned' do
+ let(:params) { { owned: true } }
+ let!(:owned_project) { create(:project, :private, namespace: current_user.namespace) }
+
+ it { is_expected.to eq([owned_project]) }
+ end
+
describe 'filter by non_public' do
let(:params) { { non_public: true } }
before do
@@ -146,13 +153,19 @@ describe ProjectsFinder do
it { is_expected.to eq([private_project]) }
end
- describe 'filter by viewable_starred_projects' do
+ describe 'filter by starred' do
let(:params) { { starred: true } }
before do
current_user.toggle_star(public_project)
end
it { is_expected.to eq([public_project]) }
+
+ it 'returns only projects the user has access to' do
+ current_user.toggle_star(private_project)
+
+ is_expected.to eq([public_project])
+ end
end
describe 'sorting' do
diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb
index 975e99c5807..7ae7b7d2140 100644
--- a/spec/finders/snippets_finder_spec.rb
+++ b/spec/finders/snippets_finder_spec.rb
@@ -5,82 +5,148 @@ describe SnippetsFinder do
let(:user1) { create :user }
let(:group) { create :group, :public }
- let(:project1) { create(:empty_project, :public, group: group) }
- let(:project2) { create(:empty_project, :private, group: group) }
+ let(:project1) { create(:project, :public, group: group) }
+ let(:project2) { create(:project, :private, group: group) }
- context ':all filter' do
+ context 'all snippets visible to a user' do
let!(:snippet1) { create(:personal_snippet, :private) }
let!(:snippet2) { create(:personal_snippet, :internal) }
let!(:snippet3) { create(:personal_snippet, :public) }
+ let!(:project_snippet1) { create(:project_snippet, :private) }
+ let!(:project_snippet2) { create(:project_snippet, :internal) }
+ let!(:project_snippet3) { create(:project_snippet, :public) }
it "returns all private and internal snippets" do
- snippets = SnippetsFinder.new.execute(user, filter: :all)
- expect(snippets).to include(snippet2, snippet3)
- expect(snippets).not_to include(snippet1)
+ snippets = described_class.new(user, scope: :all).execute
+ expect(snippets).to include(snippet2, snippet3, project_snippet2, project_snippet3)
+ expect(snippets).not_to include(snippet1, project_snippet1)
end
it "returns all public snippets" do
- snippets = SnippetsFinder.new.execute(nil, filter: :all)
- expect(snippets).to include(snippet3)
- expect(snippets).not_to include(snippet1, snippet2)
+ snippets = described_class.new(nil, scope: :all).execute
+ expect(snippets).to include(snippet3, project_snippet3)
+ expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2)
+ end
+
+ it "returns all public and internal snippets for normal user" do
+ snippets = described_class.new(user).execute
+
+ expect(snippets).to include(snippet2, snippet3, project_snippet2, project_snippet3)
+ expect(snippets).not_to include(snippet1, project_snippet1)
+ end
+
+ it "returns all public snippets for non authorized user" do
+ snippets = described_class.new(nil).execute
+
+ expect(snippets).to include(snippet3, project_snippet3)
+ expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2)
+ end
+
+ it "returns all public and authored snippets for external user" do
+ external_user = create(:user, :external)
+ authored_snippet = create(:personal_snippet, :internal, author: external_user)
+
+ snippets = described_class.new(external_user).execute
+
+ expect(snippets).to include(snippet3, project_snippet3, authored_snippet)
+ expect(snippets).not_to include(snippet1, snippet2, project_snippet1, project_snippet2)
end
end
- context ':public filter' do
+ context 'filter by visibility' do
let!(:snippet1) { create(:personal_snippet, :private) }
let!(:snippet2) { create(:personal_snippet, :internal) }
let!(:snippet3) { create(:personal_snippet, :public) }
- it "returns public public snippets" do
- snippets = SnippetsFinder.new.execute(nil, filter: :public)
+ it "returns public snippets when visibility is PUBLIC" do
+ snippets = described_class.new(nil, visibility: Snippet::PUBLIC).execute
expect(snippets).to include(snippet3)
expect(snippets).not_to include(snippet1, snippet2)
end
end
- context ':by_user filter' do
+ context 'filter by scope' do
+ let!(:snippet1) { create(:personal_snippet, :private, author: user) }
+ let!(:snippet2) { create(:personal_snippet, :internal, author: user) }
+ let!(:snippet3) { create(:personal_snippet, :public, author: user) }
+
+ it "returns all snippets for 'all' scope" do
+ snippets = described_class.new(user, scope: :all).execute
+
+ expect(snippets).to include(snippet1, snippet2, snippet3)
+ end
+
+ it "returns all snippets for 'are_private' scope" do
+ snippets = described_class.new(user, scope: :are_private).execute
+
+ expect(snippets).to include(snippet1)
+ expect(snippets).not_to include(snippet2, snippet3)
+ end
+
+ it "returns all snippets for 'are_interna;' scope" do
+ snippets = described_class.new(user, scope: :are_internal).execute
+
+ expect(snippets).to include(snippet2)
+ expect(snippets).not_to include(snippet1, snippet3)
+ end
+
+ it "returns all snippets for 'are_private' scope" do
+ snippets = described_class.new(user, scope: :are_public).execute
+
+ expect(snippets).to include(snippet3)
+ expect(snippets).not_to include(snippet1, snippet2)
+ end
+ end
+
+ context 'filter by author' do
let!(:snippet1) { create(:personal_snippet, :private, author: user) }
let!(:snippet2) { create(:personal_snippet, :internal, author: user) }
let!(:snippet3) { create(:personal_snippet, :public, author: user) }
it "returns all public and internal snippets" do
- snippets = SnippetsFinder.new.execute(user1, filter: :by_user, user: user)
+ snippets = described_class.new(user1, author: user).execute
+
expect(snippets).to include(snippet2, snippet3)
expect(snippets).not_to include(snippet1)
end
it "returns internal snippets" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_internal")
+ snippets = described_class.new(user, author: user, visibility: Snippet::INTERNAL).execute
+
expect(snippets).to include(snippet2)
expect(snippets).not_to include(snippet1, snippet3)
end
it "returns private snippets" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_private")
+ snippets = described_class.new(user, author: user, visibility: Snippet::PRIVATE).execute
+
expect(snippets).to include(snippet1)
expect(snippets).not_to include(snippet2, snippet3)
end
it "returns public snippets" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_public")
+ snippets = described_class.new(user, author: user, visibility: Snippet::PUBLIC).execute
+
expect(snippets).to include(snippet3)
expect(snippets).not_to include(snippet1, snippet2)
end
it "returns all snippets" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user)
+ snippets = described_class.new(user, author: user).execute
+
expect(snippets).to include(snippet1, snippet2, snippet3)
end
it "returns only public snippets if unauthenticated user" do
- snippets = SnippetsFinder.new.execute(nil, filter: :by_user, user: user)
+ snippets = described_class.new(nil, author: user).execute
+
expect(snippets).to include(snippet3)
expect(snippets).not_to include(snippet2, snippet1)
end
end
- context 'by_project filter' do
+ context 'filter by project' do
before do
@snippet1 = create(:project_snippet, :private, project: project1)
@snippet2 = create(:project_snippet, :internal, project: project1)
@@ -88,43 +154,52 @@ describe SnippetsFinder do
end
it "returns public snippets for unauthorized user" do
- snippets = SnippetsFinder.new.execute(nil, filter: :by_project, project: project1)
+ snippets = described_class.new(nil, project: project1).execute
+
expect(snippets).to include(@snippet3)
expect(snippets).not_to include(@snippet1, @snippet2)
end
it "returns public and internal snippets for non project members" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1)
+ snippets = described_class.new(user, project: project1).execute
+
expect(snippets).to include(@snippet2, @snippet3)
expect(snippets).not_to include(@snippet1)
end
it "returns public snippets for non project members" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_public")
+ snippets = described_class.new(user, project: project1, visibility: Snippet::PUBLIC).execute
+
expect(snippets).to include(@snippet3)
expect(snippets).not_to include(@snippet1, @snippet2)
end
it "returns internal snippets for non project members" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_internal")
+ snippets = described_class.new(user, project: project1, visibility: Snippet::INTERNAL).execute
+
expect(snippets).to include(@snippet2)
expect(snippets).not_to include(@snippet1, @snippet3)
end
it "does not return private snippets for non project members" do
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private")
+ snippets = described_class.new(user, project: project1, visibility: Snippet::PRIVATE).execute
+
expect(snippets).not_to include(@snippet1, @snippet2, @snippet3)
end
it "returns all snippets for project members" do
project1.team << [user, :developer]
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1)
+
+ snippets = described_class.new(user, project: project1).execute
+
expect(snippets).to include(@snippet1, @snippet2, @snippet3)
end
it "returns private snippets for project members" do
project1.team << [user, :developer]
- snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private")
+
+ snippets = described_class.new(user, project: project1, visibility: Snippet::PRIVATE).execute
+
expect(snippets).to include(@snippet1)
end
end
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index f7e7e733cf7..884ce22091e 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -3,10 +3,12 @@ require 'spec_helper'
describe TodosFinder do
describe '#execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:finder) { described_class }
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
describe '#sort' do
context 'by date' do
diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb
new file mode 100644
index 00000000000..1bab6d64388
--- /dev/null
+++ b/spec/finders/users_finder_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+
+describe UsersFinder do
+ describe '#execute' do
+ let!(:user1) { create(:user, username: 'johndoe') }
+ let!(:user2) { create(:user, :blocked, username: 'notsorandom') }
+ let!(:external_user) { create(:user, :external) }
+ let!(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
+
+ context 'with a normal user' do
+ let(:user) { create(:user) }
+
+ it 'returns all users' do
+ users = described_class.new(user).execute
+
+ expect(users).to contain_exactly(user, user1, user2, omniauth_user)
+ end
+
+ it 'filters by username' do
+ users = described_class.new(user, username: 'johndoe').execute
+
+ expect(users).to contain_exactly(user1)
+ end
+
+ it 'filters by search' do
+ users = described_class.new(user, search: 'orando').execute
+
+ expect(users).to contain_exactly(user2)
+ end
+
+ it 'filters by blocked users' do
+ users = described_class.new(user, blocked: true).execute
+
+ expect(users).to contain_exactly(user2)
+ end
+
+ it 'filters by active users' do
+ users = described_class.new(user, active: true).execute
+
+ expect(users).to contain_exactly(user, user1, omniauth_user)
+ end
+
+ it 'returns no external users' do
+ users = described_class.new(user, external: true).execute
+
+ expect(users).to contain_exactly(user, user1, user2, omniauth_user)
+ end
+
+ it 'filters by created_at' do
+ filtered_user_before = create(:user, created_at: 3.days.ago)
+ filtered_user_after = create(:user, created_at: Time.now + 3.days)
+
+ users = described_class.new(user,
+ created_after: 2.days.ago,
+ created_before: Time.now + 2.days).execute
+
+ expect(users.map(&:username)).not_to include([filtered_user_before.username, filtered_user_after.username])
+ end
+ end
+
+ context 'with an admin user' do
+ let(:admin) { create(:admin) }
+
+ it 'filters by external users' do
+ users = described_class.new(admin, external: true).execute
+
+ expect(users).to contain_exactly(external_user)
+ end
+
+ it 'returns all users' do
+ users = described_class.new(admin).execute
+
+ expect(users).to contain_exactly(admin, user1, user2, external_user, omniauth_user)
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/branch.json b/spec/fixtures/api/schemas/branch.json
new file mode 100644
index 00000000000..0bb74577010
--- /dev/null
+++ b/spec/fixtures/api/schemas/branch.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "required" : [
+ "name",
+ "url"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "url": { "type": "uri" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/deployments.json b/spec/fixtures/api/schemas/deployments.json
new file mode 100644
index 00000000000..1112f23aab2
--- /dev/null
+++ b/spec/fixtures/api/schemas/deployments.json
@@ -0,0 +1,58 @@
+{
+ "additionalProperties": false,
+ "properties": {
+ "deployments": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "iid": {
+ "type": "integer"
+ },
+ "last?": {
+ "type": "boolean"
+ },
+ "ref": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ },
+ "sha": {
+ "type": "string"
+ },
+ "tag": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "sha",
+ "created_at",
+ "iid",
+ "tag",
+ "last?",
+ "ref",
+ "id"
+ ],
+ "type": "object"
+ },
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": [
+ "deployments"
+ ],
+ "type": "object"
+}
diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json
new file mode 100644
index 00000000000..2f12b671dec
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/merge_request.json
@@ -0,0 +1,100 @@
+{
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "iid": { "type": "integer" },
+ "author_id": { "type": "integer" },
+ "description": { "type": ["string", "null"] },
+ "lock_version": { "type": ["string", "null"] },
+ "milestone_id": { "type": ["string", "null"] },
+ "position": { "type": "integer" },
+ "state": { "type": "string" },
+ "title": { "type": "string" },
+ "updated_by_id": { "type": ["string", "null"] },
+ "created_at": { "type": "string" },
+ "updated_at": { "type": "string" },
+ "deleted_at": { "type": ["string", "null"] },
+ "time_estimate": { "type": "integer" },
+ "total_time_spent": { "type": "integer" },
+ "human_time_estimate": { "type": ["integer", "null"] },
+ "human_total_time_spent": { "type": ["integer", "null"] },
+ "in_progress_merge_commit_sha": { "type": ["string", "null"] },
+ "merge_error": { "type": ["string", "null"] },
+ "merge_commit_sha": { "type": ["string", "null"] },
+ "merge_params": { "type": ["object", "null"] },
+ "merge_status": { "type": "string" },
+ "merge_user_id": { "type": ["integer", "null"] },
+ "merge_when_pipeline_succeeds": { "type": "boolean" },
+ "source_branch": { "type": "string" },
+ "source_project_id": { "type": "integer" },
+ "target_branch": { "type": "string" },
+ "target_project_id": { "type": "integer" },
+ "merge_event": { "type": ["object", "null"] },
+ "closed_event": { "type": ["object", "null"] },
+ "author": { "type": ["object", "null"] },
+ "merge_user": { "type": ["object", "null"] },
+ "diff_head_sha": { "type": ["string", "null"] },
+ "diff_head_commit_short_id": { "type": ["string", "null"] },
+ "merge_commit_message": { "type": ["string", "null"] },
+ "pipeline": { "type": ["object", "null"] },
+ "work_in_progress": { "type": "boolean" },
+ "source_branch_exists": { "type": "boolean" },
+ "mergeable_discussions_state": { "type": "boolean" },
+ "conflicts_can_be_resolved_in_ui": { "type": "boolean" },
+ "branch_missing": { "type": "boolean" },
+ "has_conflicts": { "type": "boolean" },
+ "can_be_merged": { "type": "boolean" },
+ "project_archived": { "type": "boolean" },
+ "only_allow_merge_if_pipeline_succeeds": { "type": "boolean" },
+ "has_ci": { "type": "boolean" },
+ "ci_status": { "type": ["string", "null"] },
+ "issues_links": {
+ "type": "object",
+ "required": ["closing", "mentioned_but_not_closing", "assign_to_closing"],
+ "properties" : {
+ "closing": { "type": "string" },
+ "mentioned_but_not_closing": { "type": "string" },
+ "assign_to_closing": { "type": ["string", "null"] }
+ },
+ "additionalProperties": false
+ },
+ "source_branch_with_namespace_link": { "type": "string" },
+ "current_user": {
+ "type": "object",
+ "required": [
+ "can_remove_source_branch",
+ "can_revert_on_current_merge_request",
+ "can_cherry_pick_on_current_merge_request"
+ ],
+ "properties": {
+ "can_remove_source_branch": { "type": "boolean" },
+ "can_revert_on_current_merge_request": { "type": ["boolean", "null"] },
+ "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] }
+ },
+ "additionalProperties": false
+ },
+ "target_branch_commits_path": { "type": "string" },
+ "target_branch_tree_path": { "type": "string" },
+ "source_branch_path": { "type": "string" },
+ "conflict_resolution_path": { "type": ["string", "null"] },
+ "cancel_merge_when_pipeline_succeeds_path": { "type": "string" },
+ "create_issue_to_resolve_discussions_path": { "type": "string" },
+ "merge_path": { "type": "string" },
+ "cherry_pick_in_fork_path": { "type": ["string", "null"] },
+ "revert_in_fork_path": { "type": ["string", "null"] },
+ "email_patches_path": { "type": "string" },
+ "plain_diff_path": { "type": "string" },
+ "status_path": { "type": "string" },
+ "new_blob_path": { "type": "string" },
+ "merge_check_path": { "type": "string" },
+ "ci_environments_status_path": { "type": "string" },
+ "merge_commit_message_with_description": { "type": "string" },
+ "diverged_commits_count": { "type": "integer" },
+ "commit_change_content_path": { "type": "string" },
+ "remove_wip_path": { "type": "string" },
+ "commits_count": { "type": "integer" },
+ "remove_source_branch": { "type": ["boolean", "null"] },
+ "merge_ongoing": { "type": "boolean" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
new file mode 100644
index 00000000000..6b14188582a
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -0,0 +1,15 @@
+{
+ "type": "object",
+ "properties" : {
+ "state": { "type": "string" },
+ "merge_status": { "type": "string" },
+ "source_branch_exists": { "type": "boolean" },
+ "time_estimate": { "type": "integer" },
+ "total_time_spent": { "type": "integer" },
+ "human_time_estimate": { "type": ["string", "null"] },
+ "human_total_time_spent": { "type": ["string", "null"] },
+ "merge_error": { "type": ["string", "null"] },
+ "assignee_id": { "type": ["integer", "null"] }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json
index 21c078e0f44..ff86437fdd5 100644
--- a/spec/fixtures/api/schemas/issue.json
+++ b/spec/fixtures/api/schemas/issue.json
@@ -46,6 +46,24 @@
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
},
+ "assignees": {
+ "type": "array",
+ "items": {
+ "type": ["object", "null"],
+ "required": [
+ "id",
+ "name",
+ "username",
+ "avatar_url"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar_url": { "type": "uri" }
+ }
+ }
+ },
"subscribed": { "type": ["boolean", "null"] }
},
"additionalProperties": false
diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json
index 11a4caf6628..622a1e40d07 100644
--- a/spec/fixtures/api/schemas/list.json
+++ b/spec/fixtures/api/schemas/list.json
@@ -10,7 +10,7 @@
"id": { "type": "integer" },
"list_type": {
"type": "string",
- "enum": ["label", "closed"]
+ "enum": ["backlog", "label", "closed"]
},
"label": {
"type": ["object", "null"],
diff --git a/spec/fixtures/api/schemas/merge_request.json b/spec/fixtures/api/schemas/merge_request.json
new file mode 100644
index 00000000000..36962660cd9
--- /dev/null
+++ b/spec/fixtures/api/schemas/merge_request.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "required" : [
+ "iid",
+ "url"
+ ],
+ "properties" : {
+ "iid": { "type": "integer" },
+ "url": { "type": "uri" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/pipeline.json b/spec/fixtures/api/schemas/pipeline.json
new file mode 100644
index 00000000000..55511d17b5e
--- /dev/null
+++ b/spec/fixtures/api/schemas/pipeline.json
@@ -0,0 +1,354 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "definitions": {},
+ "id": "http://example.com/example.json",
+ "properties": {
+ "commit": {
+ "id": "/properties/commit",
+ "properties": {
+ "author": {
+ "id": "/properties/commit/properties/author",
+ "type": "null"
+ },
+ "author_email": {
+ "id": "/properties/commit/properties/author_email",
+ "type": "string"
+ },
+ "author_gravatar_url": {
+ "id": "/properties/commit/properties/author_gravatar_url",
+ "type": "string"
+ },
+ "author_name": {
+ "id": "/properties/commit/properties/author_name",
+ "type": "string"
+ },
+ "authored_date": {
+ "id": "/properties/commit/properties/authored_date",
+ "type": "string"
+ },
+ "commit_path": {
+ "id": "/properties/commit/properties/commit_path",
+ "type": "string"
+ },
+ "commit_url": {
+ "id": "/properties/commit/properties/commit_url",
+ "type": "string"
+ },
+ "committed_date": {
+ "id": "/properties/commit/properties/committed_date",
+ "type": "string"
+ },
+ "committer_email": {
+ "id": "/properties/commit/properties/committer_email",
+ "type": "string"
+ },
+ "committer_name": {
+ "id": "/properties/commit/properties/committer_name",
+ "type": "string"
+ },
+ "created_at": {
+ "id": "/properties/commit/properties/created_at",
+ "type": "string"
+ },
+ "id": {
+ "id": "/properties/commit/properties/id",
+ "type": "string"
+ },
+ "message": {
+ "id": "/properties/commit/properties/message",
+ "type": "string"
+ },
+ "parent_ids": {
+ "id": "/properties/commit/properties/parent_ids",
+ "items": {
+ "id": "/properties/commit/properties/parent_ids/items",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "short_id": {
+ "id": "/properties/commit/properties/short_id",
+ "type": "string"
+ },
+ "title": {
+ "id": "/properties/commit/properties/title",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "created_at": {
+ "id": "/properties/created_at",
+ "type": "string"
+ },
+ "details": {
+ "id": "/properties/details",
+ "properties": {
+ "artifacts": {
+ "id": "/properties/details/properties/artifacts",
+ "items": {},
+ "type": "array"
+ },
+ "duration": {
+ "id": "/properties/details/properties/duration",
+ "type": "integer"
+ },
+ "finished_at": {
+ "id": "/properties/details/properties/finished_at",
+ "type": "string"
+ },
+ "manual_actions": {
+ "id": "/properties/details/properties/manual_actions",
+ "items": {},
+ "type": "array"
+ },
+ "stages": {
+ "id": "/properties/details/properties/stages",
+ "items": {
+ "id": "/properties/details/properties/stages/items",
+ "properties": {
+ "dropdown_path": {
+ "id": "/properties/details/properties/stages/items/properties/dropdown_path",
+ "type": "string"
+ },
+ "groups": {
+ "id": "/properties/details/properties/stages/items/properties/groups",
+ "items": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items",
+ "properties": {
+ "name": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/name",
+ "type": "string"
+ },
+ "size": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/size",
+ "type": "integer"
+ },
+ "status": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status",
+ "properties": {
+ "details_path": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/details_path",
+ "type": "null"
+ },
+ "favicon": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/favicon",
+ "type": "string"
+ },
+ "group": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/group",
+ "type": "string"
+ },
+ "has_details": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/has_details",
+ "type": "boolean"
+ },
+ "icon": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/icon",
+ "type": "string"
+ },
+ "label": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/label",
+ "type": "string"
+ },
+ "text": {
+ "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/text",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "name": {
+ "id": "/properties/details/properties/stages/items/properties/name",
+ "type": "string"
+ },
+ "path": {
+ "id": "/properties/details/properties/stages/items/properties/path",
+ "type": "string"
+ },
+ "status": {
+ "id": "/properties/details/properties/stages/items/properties/status",
+ "properties": {
+ "details_path": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/details_path",
+ "type": "string"
+ },
+ "favicon": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/favicon",
+ "type": "string"
+ },
+ "group": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/group",
+ "type": "string"
+ },
+ "has_details": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/has_details",
+ "type": "boolean"
+ },
+ "icon": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/icon",
+ "type": "string"
+ },
+ "label": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/label",
+ "type": "string"
+ },
+ "text": {
+ "id": "/properties/details/properties/stages/items/properties/status/properties/text",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "title": {
+ "id": "/properties/details/properties/stages/items/properties/title",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "status": {
+ "id": "/properties/details/properties/status",
+ "properties": {
+ "details_path": {
+ "id": "/properties/details/properties/status/properties/details_path",
+ "type": "string"
+ },
+ "favicon": {
+ "id": "/properties/details/properties/status/properties/favicon",
+ "type": "string"
+ },
+ "group": {
+ "id": "/properties/details/properties/status/properties/group",
+ "type": "string"
+ },
+ "has_details": {
+ "id": "/properties/details/properties/status/properties/has_details",
+ "type": "boolean"
+ },
+ "icon": {
+ "id": "/properties/details/properties/status/properties/icon",
+ "type": "string"
+ },
+ "label": {
+ "id": "/properties/details/properties/status/properties/label",
+ "type": "string"
+ },
+ "text": {
+ "id": "/properties/details/properties/status/properties/text",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "flags": {
+ "id": "/properties/flags",
+ "properties": {
+ "cancelable": {
+ "id": "/properties/flags/properties/cancelable",
+ "type": "boolean"
+ },
+ "latest": {
+ "id": "/properties/flags/properties/latest",
+ "type": "boolean"
+ },
+ "retryable": {
+ "id": "/properties/flags/properties/retryable",
+ "type": "boolean"
+ },
+ "stuck": {
+ "id": "/properties/flags/properties/stuck",
+ "type": "boolean"
+ },
+ "triggered": {
+ "id": "/properties/flags/properties/triggered",
+ "type": "boolean"
+ },
+ "yaml_errors": {
+ "id": "/properties/flags/properties/yaml_errors",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "id": "/properties/id",
+ "type": "integer"
+ },
+ "path": {
+ "id": "/properties/path",
+ "type": "string"
+ },
+ "ref": {
+ "id": "/properties/ref",
+ "properties": {
+ "branch": {
+ "id": "/properties/ref/properties/branch",
+ "type": "boolean"
+ },
+ "name": {
+ "id": "/properties/ref/properties/name",
+ "type": "string"
+ },
+ "path": {
+ "id": "/properties/ref/properties/path",
+ "type": "string"
+ },
+ "tag": {
+ "id": "/properties/ref/properties/tag",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "retry_path": {
+ "id": "/properties/retry_path",
+ "type": "string"
+ },
+ "updated_at": {
+ "id": "/properties/updated_at",
+ "type": "string"
+ },
+ "user": {
+ "id": "/properties/user",
+ "properties": {
+ "avatar_url": {
+ "id": "/properties/user/properties/avatar_url",
+ "type": "string"
+ },
+ "id": {
+ "id": "/properties/user/properties/id",
+ "type": "integer"
+ },
+ "name": {
+ "id": "/properties/user/properties/name",
+ "type": "string"
+ },
+ "state": {
+ "id": "/properties/user/properties/state",
+ "type": "string"
+ },
+ "username": {
+ "id": "/properties/user/properties/username",
+ "type": "string"
+ },
+ "web_url": {
+ "id": "/properties/user/properties/web_url",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+}
diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json
new file mode 100644
index 00000000000..f6346bd0fb6
--- /dev/null
+++ b/spec/fixtures/api/schemas/pipeline_schedule.json
@@ -0,0 +1,41 @@
+{
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "description": { "type": "string" },
+ "ref": { "type": "string" },
+ "cron": { "type": "string" },
+ "cron_timezone": { "type": "string" },
+ "next_run_at": { "type": "date" },
+ "active": { "type": "boolean" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "last_pipeline": {
+ "type": ["object", "null"],
+ "properties": {
+ "id": { "type": "integer" },
+ "sha": { "type": "string" },
+ "ref": { "type": "string" },
+ "status": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "owner": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "id": { "type": "integer" },
+ "state": { "type": "string" },
+ "avatar_url": { "type": "uri" },
+ "web_url": { "type": "uri" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "id", "description", "ref", "cron", "cron_timezone", "next_run_at",
+ "active", "created_at", "updated_at", "owner"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/pipeline_schedules.json b/spec/fixtures/api/schemas/pipeline_schedules.json
new file mode 100644
index 00000000000..173a28d2505
--- /dev/null
+++ b/spec/fixtures/api/schemas/pipeline_schedules.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "pipeline_schedule.json" }
+}
diff --git a/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json b/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
new file mode 100644
index 00000000000..47b5d283b8c
--- /dev/null
+++ b/spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
@@ -0,0 +1,58 @@
+{
+ "items": {
+ "properties": {
+ "group": {
+ "type": "string"
+ },
+ "metrics": {
+ "items": {
+ "properties": {
+ "queries": {
+ "items": {
+ "properties": {
+ "query_range": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ },
+ "result": {
+ "type": "any"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "title": {
+ "type": "string"
+ },
+ "weight": {
+ "type": "integer"
+ },
+ "y_label": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "required": [
+ "metrics",
+ "title",
+ "weight"
+ ],
+ "type": "array"
+ },
+ "priority": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "required": [
+ "group",
+ "priority",
+ "metrics"
+ ],
+ "type": "array"
+} \ No newline at end of file
diff --git a/spec/fixtures/api/schemas/public_api/v3/issues.json b/spec/fixtures/api/schemas/public_api/v3/issues.json
index f2ee9c925ae..51b0822bc66 100644
--- a/spec/fixtures/api/schemas/public_api/v3/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v3/issues.json
@@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
index 01f9fbb2c89..b5c74bcc26e 100644
--- a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
@@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/branch.json b/spec/fixtures/api/schemas/public_api/v4/branch.json
new file mode 100644
index 00000000000..a3581178974
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/branch.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "required" : [
+ "name",
+ "commit",
+ "merged",
+ "protected",
+ "developers_can_push",
+ "developers_can_merge"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "commit": { "$ref": "commit/basic.json" },
+ "merged": { "type": "boolean" },
+ "protected": { "type": "boolean" },
+ "developers_can_push": { "type": "boolean" },
+ "developers_can_merge": { "type": "boolean" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/branches.json b/spec/fixtures/api/schemas/public_api/v4/branches.json
new file mode 100644
index 00000000000..854c902b485
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/branches.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "branch.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/comment.json b/spec/fixtures/api/schemas/public_api/v4/comment.json
new file mode 100644
index 00000000000..52cfe86aeeb
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/comment.json
@@ -0,0 +1,21 @@
+{
+ "type": "object",
+ "required" : [
+ "name",
+ "message",
+ "commit",
+ "release"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "message": { "type": ["string", "null"] },
+ "commit": { "$ref": "commit/basic.json" },
+ "release": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "release.json" }
+ ]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit/basic.json b/spec/fixtures/api/schemas/public_api/v4/commit/basic.json
new file mode 100644
index 00000000000..9d99628a286
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commit/basic.json
@@ -0,0 +1,37 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "short_id",
+ "title",
+ "created_at",
+ "parent_ids",
+ "message",
+ "author_name",
+ "author_email",
+ "authored_date",
+ "committer_name",
+ "committer_email",
+ "committed_date"
+ ],
+ "properties" : {
+ "id": { "type": ["string", "null"] },
+ "short_id": { "type": ["string", "null"] },
+ "title": { "type": "string" },
+ "created_at": { "type": "date" },
+ "parent_ids": {
+ "type": ["array", "null"],
+ "items": {
+ "type": "string",
+ "additionalProperties": false
+ }
+ },
+ "message": { "type": "string" },
+ "author_name": { "type": "string" },
+ "author_email": { "type": "string" },
+ "authored_date": { "type": "date" },
+ "committer_name": { "type": "string" },
+ "committer_email": { "type": "string" },
+ "committed_date": { "type": "date" }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit/detail.json b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json
new file mode 100644
index 00000000000..b7b2535c204
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json
@@ -0,0 +1,16 @@
+{
+ "type": "object",
+ "allOf": [
+ { "$ref": "basic.json" },
+ {
+ "required" : [
+ "stats",
+ "status"
+ ],
+ "properties": {
+ "stats": { "$ref": "../commit_stats.json" },
+ "status": { "type": ["string", "null"] }
+ }
+ }
+ ]
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_note.json b/spec/fixtures/api/schemas/public_api/v4/commit_note.json
new file mode 100644
index 00000000000..02081989271
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commit_note.json
@@ -0,0 +1,19 @@
+{
+ "type": "object",
+ "required" : [
+ "note",
+ "path",
+ "line",
+ "line_type",
+ "author",
+ "created_at"
+ ],
+ "properties" : {
+ "note": { "type": ["string", "null"] },
+ "path": { "type": ["string", "null"] },
+ "line": { "type": ["integer", "null"] },
+ "line_type": { "type": ["string", "null"] },
+ "author": { "$ref": "user/basic.json" },
+ "created_at": { "type": "date" }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_notes.json b/spec/fixtures/api/schemas/public_api/v4/commit_notes.json
new file mode 100644
index 00000000000..d65a7d677ea
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commit_notes.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "commit_note.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_stats.json b/spec/fixtures/api/schemas/public_api/v4/commit_stats.json
new file mode 100644
index 00000000000..779384c62e6
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commit_stats.json
@@ -0,0 +1,14 @@
+{
+ "type": "object",
+ "required" : [
+ "additions",
+ "deletions",
+ "total"
+ ],
+ "properties" : {
+ "additions": { "type": "integer" },
+ "deletions": { "type": "integer" },
+ "total": { "type": "integer" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commits.json b/spec/fixtures/api/schemas/public_api/v4/commits.json
new file mode 100644
index 00000000000..98b17a96071
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commits.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "commit/basic.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json
index 52199e75734..bd6bfc03199 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issues.json
@@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
@@ -33,6 +34,21 @@
},
"additionalProperties": false
},
+ "assignees": {
+ "type": "array",
+ "items": {
+ "type": ["object", "null"],
+ "properties": {
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "id": { "type": "integer" },
+ "state": { "type": "string" },
+ "avatar_url": { "type": "uri" },
+ "web_url": { "type": "uri" }
+ },
+ "additionalProperties": false
+ }
+ },
"assignee": {
"type": ["object", "null"],
"properties": {
@@ -67,7 +83,7 @@
"required": [
"id", "iid", "project_id", "title", "description",
"state", "created_at", "updated_at", "labels",
- "milestone", "assignee", "author", "user_notes_count",
+ "milestone", "assignees", "author", "user_notes_count",
"upvotes", "downvotes", "due_date", "confidential",
"web_url"
],
diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
index 51642e8cbb8..60aa47c1259 100644
--- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
@@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/release.json b/spec/fixtures/api/schemas/public_api/v4/release.json
new file mode 100644
index 00000000000..6612c2a9911
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/release.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "required" : [
+ "tag_name",
+ "description"
+ ],
+ "properties" : {
+ "tag_name": { "type": ["string", "null"] },
+ "description": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/tag.json b/spec/fixtures/api/schemas/public_api/v4/tag.json
new file mode 100644
index 00000000000..52cfe86aeeb
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/tag.json
@@ -0,0 +1,21 @@
+{
+ "type": "object",
+ "required" : [
+ "name",
+ "message",
+ "commit",
+ "release"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "message": { "type": ["string", "null"] },
+ "commit": { "$ref": "commit/basic.json" },
+ "release": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "release.json" }
+ ]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/tags.json b/spec/fixtures/api/schemas/public_api/v4/tags.json
new file mode 100644
index 00000000000..eae352e7f87
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/tags.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "tag.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/user/admin.json b/spec/fixtures/api/schemas/public_api/v4/user/admin.json
new file mode 100644
index 00000000000..f733914fbf8
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/user/admin.json
@@ -0,0 +1,34 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "username",
+ "email",
+ "name",
+ "state",
+ "avatar_url",
+ "web_url",
+ "created_at",
+ "is_admin",
+ "bio",
+ "location",
+ "skype",
+ "linkedin",
+ "twitter",
+ "website_url",
+ "organization",
+ "last_sign_in_at",
+ "confirmed_at",
+ "color_scheme_id",
+ "projects_limit",
+ "current_sign_in_at",
+ "identities",
+ "can_create_group",
+ "can_create_project",
+ "two_factor_enabled",
+ "external"
+ ],
+ "properties": {
+ "$ref": "full.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/user/basic.json b/spec/fixtures/api/schemas/public_api/v4/user/basic.json
new file mode 100644
index 00000000000..9f69d31971c
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/user/basic.json
@@ -0,0 +1,15 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "state",
+ "avatar_url",
+ "web_url"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "state": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "web_url": { "type": "string" }
+ }
+}
diff --git a/spec/fixtures/config/kubeconfig-without-ca.yml b/spec/fixtures/config/kubeconfig-without-ca.yml
new file mode 100644
index 00000000000..b2cb989d548
--- /dev/null
+++ b/spec/fixtures/config/kubeconfig-without-ca.yml
@@ -0,0 +1,18 @@
+---
+apiVersion: v1
+clusters:
+- name: gitlab-deploy
+ cluster:
+ server: https://kube.domain.com
+contexts:
+- name: gitlab-deploy
+ context:
+ cluster: gitlab-deploy
+ namespace: NAMESPACE
+ user: gitlab-deploy
+current-context: gitlab-deploy
+kind: Config
+users:
+- name: gitlab-deploy
+ user:
+ token: TOKEN
diff --git a/spec/fixtures/config/kubeconfig.yml b/spec/fixtures/config/kubeconfig.yml
new file mode 100644
index 00000000000..c4e8e573c32
--- /dev/null
+++ b/spec/fixtures/config/kubeconfig.yml
@@ -0,0 +1,19 @@
+---
+apiVersion: v1
+clusters:
+- name: gitlab-deploy
+ cluster:
+ server: https://kube.domain.com
+ certificate-authority-data: "UEVN\n"
+contexts:
+- name: gitlab-deploy
+ context:
+ cluster: gitlab-deploy
+ namespace: NAMESPACE
+ user: gitlab-deploy
+current-context: gitlab-deploy
+kind: Config
+users:
+- name: gitlab-deploy
+ user:
+ token: TOKEN
diff --git a/spec/fixtures/config/redis_cache_config_with_env.yml b/spec/fixtures/config/redis_cache_config_with_env.yml
new file mode 100644
index 00000000000..52fd5a06460
--- /dev/null
+++ b/spec/fixtures/config/redis_cache_config_with_env.yml
@@ -0,0 +1,2 @@
+test:
+ url: <%= ENV['TEST_GITLAB_REDIS_CACHE_URL'] %>
diff --git a/spec/fixtures/config/redis_cache_new_format_host.yml b/spec/fixtures/config/redis_cache_new_format_host.yml
new file mode 100644
index 00000000000..a24f3716391
--- /dev/null
+++ b/spec/fixtures/config/redis_cache_new_format_host.yml
@@ -0,0 +1,29 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development:
+ url: redis://:mynewpassword@localhost:6380/10
+ sentinels:
+ -
+ host: localhost
+ port: 26380 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26380 # point to sentinel, not to redis port
+test:
+ url: redis://:mynewpassword@localhost:6380/10
+ sentinels:
+ -
+ host: localhost
+ port: 26380 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26380 # point to sentinel, not to redis port
+production:
+ url: redis://:mynewpassword@localhost:6380/10
+ sentinels:
+ -
+ host: slave1
+ port: 26380 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26380 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_cache_new_format_socket.yml b/spec/fixtures/config/redis_cache_new_format_socket.yml
new file mode 100644
index 00000000000..3634c550163
--- /dev/null
+++ b/spec/fixtures/config/redis_cache_new_format_socket.yml
@@ -0,0 +1,6 @@
+development:
+ url: unix:/path/to/redis.cache.sock
+test:
+ url: unix:/path/to/redis.cache.sock
+production:
+ url: unix:/path/to/redis.cache.sock
diff --git a/spec/fixtures/config/redis_cache_old_format_host.yml b/spec/fixtures/config/redis_cache_old_format_host.yml
new file mode 100644
index 00000000000..3609dcd022e
--- /dev/null
+++ b/spec/fixtures/config/redis_cache_old_format_host.yml
@@ -0,0 +1,5 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development: redis://:mypassword@localhost:6380/10
+test: redis://:mypassword@localhost:6380/10
+production: redis://:mypassword@localhost:6380/10
diff --git a/spec/fixtures/config/redis_cache_old_format_socket.yml b/spec/fixtures/config/redis_cache_old_format_socket.yml
new file mode 100644
index 00000000000..26fa0eda245
--- /dev/null
+++ b/spec/fixtures/config/redis_cache_old_format_socket.yml
@@ -0,0 +1,3 @@
+development: unix:/path/to/old/redis.cache.sock
+test: unix:/path/to/old/redis.cache.sock
+production: unix:/path/to/old/redis.cache.sock
diff --git a/spec/fixtures/config/redis_new_format_host.yml b/spec/fixtures/config/redis_new_format_host.yml
index 13772677a45..8d134d467e9 100644
--- a/spec/fixtures/config/redis_new_format_host.yml
+++ b/spec/fixtures/config/redis_new_format_host.yml
@@ -5,25 +5,25 @@ development:
sentinels:
-
host: localhost
- port: 26380 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: slave2
- port: 26381 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
test:
url: redis://:mynewpassword@localhost:6379/99
sentinels:
-
host: localhost
- port: 26380 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: slave2
- port: 26381 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
production:
url: redis://:mynewpassword@localhost:6379/99
sentinels:
-
host: slave1
- port: 26380 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: slave2
- port: 26381 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_queues_config_with_env.yml b/spec/fixtures/config/redis_queues_config_with_env.yml
new file mode 100644
index 00000000000..d16a9d8a7f8
--- /dev/null
+++ b/spec/fixtures/config/redis_queues_config_with_env.yml
@@ -0,0 +1,2 @@
+test:
+ url: <%= ENV['TEST_GITLAB_REDIS_QUEUES_URL'] %>
diff --git a/spec/fixtures/config/redis_queues_new_format_host.yml b/spec/fixtures/config/redis_queues_new_format_host.yml
new file mode 100644
index 00000000000..1535584d779
--- /dev/null
+++ b/spec/fixtures/config/redis_queues_new_format_host.yml
@@ -0,0 +1,29 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development:
+ url: redis://:mynewpassword@localhost:6381/11
+ sentinels:
+ -
+ host: localhost
+ port: 26381 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26381 # point to sentinel, not to redis port
+test:
+ url: redis://:mynewpassword@localhost:6381/11
+ sentinels:
+ -
+ host: localhost
+ port: 26381 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26381 # point to sentinel, not to redis port
+production:
+ url: redis://:mynewpassword@localhost:6381/11
+ sentinels:
+ -
+ host: slave1
+ port: 26381 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26381 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_queues_new_format_socket.yml b/spec/fixtures/config/redis_queues_new_format_socket.yml
new file mode 100644
index 00000000000..b488d84d022
--- /dev/null
+++ b/spec/fixtures/config/redis_queues_new_format_socket.yml
@@ -0,0 +1,6 @@
+development:
+ url: unix:/path/to/redis.queues.sock
+test:
+ url: unix:/path/to/redis.queues.sock
+production:
+ url: unix:/path/to/redis.queues.sock
diff --git a/spec/fixtures/config/redis_queues_old_format_host.yml b/spec/fixtures/config/redis_queues_old_format_host.yml
new file mode 100644
index 00000000000..6531748a8d7
--- /dev/null
+++ b/spec/fixtures/config/redis_queues_old_format_host.yml
@@ -0,0 +1,5 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development: redis://:mypassword@localhost:6381/11
+test: redis://:mypassword@localhost:6381/11
+production: redis://:mypassword@localhost:6381/11
diff --git a/spec/fixtures/config/redis_queues_old_format_socket.yml b/spec/fixtures/config/redis_queues_old_format_socket.yml
new file mode 100644
index 00000000000..53f5db72758
--- /dev/null
+++ b/spec/fixtures/config/redis_queues_old_format_socket.yml
@@ -0,0 +1,3 @@
+development: unix:/path/to/old/redis.queues.sock
+test: unix:/path/to/old/redis.queues.sock
+production: unix:/path/to/old/redis.queues.sock
diff --git a/spec/fixtures/config/redis_shared_state_config_with_env.yml b/spec/fixtures/config/redis_shared_state_config_with_env.yml
new file mode 100644
index 00000000000..eab7203d0de
--- /dev/null
+++ b/spec/fixtures/config/redis_shared_state_config_with_env.yml
@@ -0,0 +1,2 @@
+test:
+ url: <%= ENV['TEST_GITLAB_REDIS_SHARED_STATE_URL'] %>
diff --git a/spec/fixtures/config/redis_shared_state_new_format_host.yml b/spec/fixtures/config/redis_shared_state_new_format_host.yml
new file mode 100644
index 00000000000..1180b2b4a82
--- /dev/null
+++ b/spec/fixtures/config/redis_shared_state_new_format_host.yml
@@ -0,0 +1,29 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development:
+ url: redis://:mynewpassword@localhost:6382/12
+ sentinels:
+ -
+ host: localhost
+ port: 26382 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26382 # point to sentinel, not to redis port
+test:
+ url: redis://:mynewpassword@localhost:6382/12
+ sentinels:
+ -
+ host: localhost
+ port: 26382 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26382 # point to sentinel, not to redis port
+production:
+ url: redis://:mynewpassword@localhost:6382/12
+ sentinels:
+ -
+ host: slave1
+ port: 26382 # point to sentinel, not to redis port
+ -
+ host: slave2
+ port: 26382 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_shared_state_new_format_socket.yml b/spec/fixtures/config/redis_shared_state_new_format_socket.yml
new file mode 100644
index 00000000000..1b0e699729e
--- /dev/null
+++ b/spec/fixtures/config/redis_shared_state_new_format_socket.yml
@@ -0,0 +1,6 @@
+development:
+ url: unix:/path/to/redis.shared_state.sock
+test:
+ url: unix:/path/to/redis.shared_state.sock
+production:
+ url: unix:/path/to/redis.shared_state.sock
diff --git a/spec/fixtures/config/redis_shared_state_old_format_host.yml b/spec/fixtures/config/redis_shared_state_old_format_host.yml
new file mode 100644
index 00000000000..fef5e768c5d
--- /dev/null
+++ b/spec/fixtures/config/redis_shared_state_old_format_host.yml
@@ -0,0 +1,5 @@
+# redis://[:password@]host[:port][/db-number][?option=value]
+# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
+development: redis://:mypassword@localhost:6382/12
+test: redis://:mypassword@localhost:6382/12
+production: redis://:mypassword@localhost:6382/12
diff --git a/spec/fixtures/config/redis_shared_state_old_format_socket.yml b/spec/fixtures/config/redis_shared_state_old_format_socket.yml
new file mode 100644
index 00000000000..4746afbb5ef
--- /dev/null
+++ b/spec/fixtures/config/redis_shared_state_old_format_socket.yml
@@ -0,0 +1,3 @@
+development: unix:/path/to/old/redis.shared_state.sock
+test: unix:/path/to/old/redis.shared_state.sock
+production: unix:/path/to/old/redis.shared_state.sock
diff --git a/spec/fixtures/emails/forwarded_new_issue.eml b/spec/fixtures/emails/forwarded_new_issue.eml
new file mode 100644
index 00000000000..258106bb897
--- /dev/null
+++ b/spec/fixtures/emails/forwarded_new_issue.eml
@@ -0,0 +1,25 @@
+Delivered-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.adventuretime.ooo
+Return-Path: <jake@adventuretime.ooo>
+Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
+Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
+Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
+Date: Thu, 13 Jun 2013 17:03:48 -0400
+From: Jake the Dog <jake@adventuretime.ooo>
+Delivered-To: support@adventuretime.ooo
+To: support@adventuretime.ooo
+Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
+Subject: New Issue by email
+Mime-Version: 1.0
+Content-Type: text/plain;
+ charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+X-Sieve: CMU Sieve 2.2
+X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
+ 13 Jun 2013 14:03:48 -0700 (PDT)
+X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
+
+The reply by email functionality should be extended to allow creating a new issue by email.
+
+* Allow an admin to specify which project the issue should be created under by checking the sender domain.
+* Possibly allow the use of regular expression matches within the subject/body to specify which project the issue should be created under.
diff --git a/spec/fixtures/emails/html_empty_link.eml b/spec/fixtures/emails/html_empty_link.eml
new file mode 100644
index 00000000000..1672b98b925
--- /dev/null
+++ b/spec/fixtures/emails/html_empty_link.eml
@@ -0,0 +1,26 @@
+
+MIME-Version: 1.0
+Received: by 10.25.161.144 with HTTP; Tue, 7 Oct 2014 22:17:17 -0700 (PDT)
+X-Originating-IP: [117.207.85.84]
+In-Reply-To: <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail>
+References: <topic/35@discourse.techapj.com>
+ <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail>
+Date: Wed, 8 Oct 2014 10:47:17 +0530
+Delivered-To: arpit@techapj.com
+Message-ID: <CAOJeqne=SJ_LwN4sb-0Y95ejc2OpreVhdmcPn0TnmwSvTCYzzQ@mail.gmail.com>
+Subject: Re: [Discourse] [Meta] Welcome to techAPJ's Discourse!
+From: Arpit Jalan <arpit@techapj.com>
+To: Discourse <mail+e1c7f2a380e33840aeb654f075490bad@arpitjalan.com>Accept-Language: en-US
+Content-Language: en-US
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+x-originating-ip: [134.68.31.227]
+Content-Type: multipart/alternative;
+ boundary="_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_"
+MIME-Version: 1.0
+
+--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_
+Content-Type: text/html; charset="utf-8"
+
+<a name="_MailEndCompose">no brackets!</a>
+--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_--
diff --git a/spec/fixtures/encoding/Japanese.md b/spec/fixtures/encoding/Japanese.md
new file mode 100644
index 00000000000..dd469c9f232
--- /dev/null
+++ b/spec/fixtures/encoding/Japanese.md
@@ -0,0 +1,42 @@
++++
+date = "2017-05-21T13:05:07+09:00"
+title = "レイヤ"
+weight = 10
+
++++
+
+## ã“ã®ãƒãƒ¥ãƒ¼ãƒˆãƒªã‚¢ãƒ«ã§æ‰±ã†å†…容
+1. Redactedã«ãŠã‘ã‚‹2D開発ã§ã®ãƒ¬ã‚¤ãƒ¤ã®åŸºæœ¬çš„ãªæ¦‚è¦
+2. スクリーン上ã®ã‚¹ãƒ—ライトã®é †åºä»˜ã‘方法
+
+### Redactedã«ãŠã‘ã‚‹2D開発ã§ã®ãƒ¬ã‚¤ãƒ¤ã®åŸºæœ¬çš„ãªæ¦‚è¦
+2Dã«ãŠã„ã¦ã¯z軸ãŒå­˜åœ¨ã—ãªã„ãŸã‚ã€ã‚·ãƒ¼ãƒ³å†…è¦ç´ ã®æ画順を制御ã™ã‚‹ãŸã‚ã«ã¯ä»£æ›¿ã¨ãªã‚‹ä»•çµ„ã¿ãŒå¿…è¦ã§ã™ã€‚
+Redactedã§ã¯**レイヤ**ã«ãŠã‘ã‚‹**zIndex**属性を制御å¯èƒ½ã«ã™ã‚‹ã“ã¨ã§ã€ã“ã®èª²é¡Œã‚’解決ã—ã¦ã„ã¾ã™ã€‚
+**デフォルトã§ã¯ã€zIndexã¯0ã¨ãªã‚Šã‚ªãƒ–ジェクトã¯ãƒ¬ã‚¤ãƒ¤ã«è¿½åŠ ã•ã‚ŒãŸé †ç•ªã«æç”»ã•ã‚Œã¾ã™ã€‚**
+
+レイヤã«ã¯ã„ãã¤ã‹ã®é‡è¦ãªç‰¹æ€§ãŒã‚ã‚Šã¾ã™ã€‚
+
+* レイヤã«ã¯ãƒ¬ã‚¤ãƒ¤åŒ–ã•ã‚ŒãŸã‚ªãƒ–ジェクトã®ã¿ã‚’å«ã‚ã‚‹ã“ã¨ãŒã§ãã¾ã™ã€‚(**3Dモデルã¯çµ¶å¯¾ã«è¿½åŠ ã—ãªã„ã§ãã ã•ã„**)
+* レイヤã¯ãƒ¬ã‚¤ãƒ¤åŒ–ã•ã‚ŒãŸã‚ªãƒ–ジェクトã§ã™ã€‚(ã—ãŸãŒã£ã¦ã€ãƒ¬ã‚¤ãƒ¤ã«ã¯ä»–ã®ãƒ¬ã‚¤ãƒ¤ã‚’å«ã‚ã‚‹ã“ã¨ãŒã§ãã¾ã™)
+* レイヤ化ã•ã‚ŒãŸã‚ªãƒ–ジェクトã¯ã€æœ€å¤§ã§1ã¤ã®ãƒ¬ã‚¤ãƒ¤ã«å±žã™ã“ã¨ãŒã§ãã¾ã™ã€‚
+
+レイヤを直接åˆæœŸåŒ–ã™ã‚‹ã“ã¨ã¯ã§ãã¾ã›ã‚“ãŒã€ãã®æ´¾ç”Ÿã‚¯ãƒ©ã‚¹ã¯åˆæœŸåŒ–ã™ã‚‹ã“ã¨ãŒå¯èƒ½ã§ã™ã€‚**Scene2D**ã¨**コンテナ**ã¯ã€**レイヤ**ã‹ã‚‰æ´¾ç”Ÿã™ã‚‹2ã¤ã®ä¸»ãªã‚ªãƒ–ジェクトã§ã™ã€‚ã™ã¹ã¦ã®åˆæœŸåŒ–(createContainerã€instantiateã€...)ã¯ãƒ¬ã‚¤ãƒ¤ä¸Šã§è¡Œã‚ã‚Œã¾ã™ã€‚ã¤ã¾ã‚Šã€2Dã§åˆæœŸåŒ–ã•ã‚Œã‚‹ã™ã¹ã¦ã®ã‚ªãƒ–ジェクトã¯ã€zIndexプロパティをæŒã¤ãƒ¬ã‚¤ãƒ¤åŒ–ã•ã‚ŒãŸã‚ªãƒ–ジェクトã§ã™ã€‚
+
+**zIndexã¯ã‚°ãƒ­ãƒ¼ãƒãƒ«ã§ã¯ã‚ã‚Šã¾ã›ã‚“!**
+
+CSSã¨ã¯ç•°ãªã‚Šã€zIndexã¯ã™ã¹ã¦ã®ã‚ªãƒ–ジェクトã«å¯¾ã—ã¦ã‚°ãƒ­ãƒ¼ãƒãƒ«ã§ã¯ã‚ã‚Šã¾ã›ã‚“。zIndexプロパティã¯è¦ªãƒ¬ã‚¤ãƒ¤ã«å¯¾ã—ã¦ãƒ­ãƒ¼ã‚«ãƒ«ã§ã™ã€‚詳細ã«ã¤ãã¾ã—ã¦ã¯ã€ã‚³ãƒ³ãƒ†ãƒŠãƒãƒ¥ãƒ¼ãƒˆãƒªã‚¢ãƒ«ã§èª¬æ˜Žã—ã¦ã„ã¾ã™ã€‚ [TODO: Link]。
+
+### スクリーン上ã®ã‚¹ãƒ—ライトã®é †åºä»˜ã‘方法
+ã“ã‚Œã¾ã§å­¦ã‚“ã ã“ã¨ã‚’生ã‹ã—ã¦ã€ç”»é¢ã«ã‚¹ãƒ—ライトを表示ã—ã¦ã€zIndexã®è¨­å®šã‚’ã—ã¦ã¿ã¾ã—ょã†ï¼
+
+* ã¾ãšã€æœ€åˆã« (A,B,C) スプライトを生æˆã—ã¾ã™ã€‚
+* スプライトAをシーンã«è¿½åŠ ã—ã¾ã™ï¼ˆzIndex = 0ã€æ¨™æº–色)
+* スプライトBをシーン2ã«è¿½åŠ ã™ã‚‹ã¨ã€**スプライトAã®ä¸Šã«**表示ã•ã‚Œã¾ã™ï¼ˆzIndex = 0ã€èµ¤è‰²ï¼‰
+* 最後ã«ã‚¹ãƒ—ライトCをシーンã«è¿½åŠ ã—ã¾ã™ï¼ˆé’色)ãŒã€ã‚¹ãƒ—ライトã®zIndexã‚’-1ã«è¨­å®šã™ã‚‹ã¨ã€ã‚¹ãƒ—ライトã¯Aã¨Bã®å¾Œå´ã«è¡¨ç¤ºã•ã‚Œã¾ã™ã€‚
+
+{{< code "static/tutorials/layers.html" >}}
+
+### ソースコード全体
+```js
+{{< snippet "static/tutorials/layers.html" >}}
+```
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index 51a3e91d201..4f46e40ce7a 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -166,9 +166,9 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Issue in another project: <%= xissue.to_reference(project) %>
- Ignored in code: `<%= issue.to_reference %>`
- Ignored in links: [Link to <%= issue.to_reference %>](#issue-link)
-- Issue by URL: <%= urls.namespace_project_issue_url(issue.project.namespace, issue.project, issue) %>
+- Issue by URL: <%= urls.project_issue_url(issue.project, issue) %>
- Link to issue by reference: [Issue](<%= issue.to_reference %>)
-- Link to issue by URL: [Issue](<%= urls.namespace_project_issue_url(issue.project.namespace, issue.project, issue) %>)
+- Link to issue by URL: [Issue](<%= urls.project_issue_url(issue.project, issue) %>)
#### MergeRequestReferenceFilter
@@ -176,9 +176,9 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Merge request in another project: <%= xmerge_request.to_reference(project) %>
- Ignored in code: `<%= merge_request.to_reference %>`
- Ignored in links: [Link to <%= merge_request.to_reference %>](#merge-request-link)
-- Merge request by URL: <%= urls.namespace_project_merge_request_url(merge_request.project.namespace, merge_request.project, merge_request) %>
+- Merge request by URL: <%= urls.project_merge_request_url(merge_request.project, merge_request) %>
- Link to merge request by reference: [Merge request](<%= merge_request.to_reference %>)
-- Link to merge request by URL: [Merge request](<%= urls.namespace_project_merge_request_url(merge_request.project.namespace, merge_request.project, merge_request) %>)
+- Link to merge request by URL: [Merge request](<%= urls.project_merge_request_url(merge_request.project, merge_request) %>)
#### SnippetReferenceFilter
@@ -186,9 +186,9 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Snippet in another project: <%= xsnippet.to_reference(project) %>
- Ignored in code: `<%= snippet.to_reference %>`
- Ignored in links: [Link to <%= snippet.to_reference %>](#snippet-link)
-- Snippet by URL: <%= urls.namespace_project_snippet_url(snippet.project.namespace, snippet.project, snippet) %>
+- Snippet by URL: <%= urls.project_snippet_url(snippet.project, snippet) %>
- Link to snippet by reference: [Snippet](<%= snippet.to_reference %>)
-- Link to snippet by URL: [Snippet](<%= urls.namespace_project_snippet_url(snippet.project.namespace, snippet.project, snippet) %>)
+- Link to snippet by URL: [Snippet](<%= urls.project_snippet_url(snippet.project, snippet) %>)
#### CommitRangeReferenceFilter
@@ -196,9 +196,9 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Range in another project: <%= xcommit_range.to_reference(project) %>
- Ignored in code: `<%= commit_range.to_reference %>`
- Ignored in links: [Link to <%= commit_range.to_reference %>](#commit-range-link)
-- Range by URL: <%= urls.namespace_project_compare_url(commit_range.project.namespace, commit_range.project, commit_range.to_param) %>
+- Range by URL: <%= urls.project_compare_url(commit_range.project, commit_range.to_param) %>
- Link to range by reference: [Range](<%= commit_range.to_reference %>)
-- Link to range by URL: [Range](<%= urls.namespace_project_compare_url(commit_range.project.namespace, commit_range.project, commit_range.to_param) %>)
+- Link to range by URL: [Range](<%= urls.project_compare_url(commit_range.project, commit_range.to_param) %>)
#### CommitReferenceFilter
@@ -206,9 +206,9 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Commit in another project: <%= xcommit.to_reference(project) %>
- Ignored in code: `<%= commit.to_reference %>`
- Ignored in links: [Link to <%= commit.to_reference %>](#commit-link)
-- Commit by URL: <%= urls.namespace_project_commit_url(commit.project.namespace, commit.project, commit) %>
+- Commit by URL: <%= urls.project_commit_url(commit.project, commit) %>
- Link to commit by reference: [Commit](<%= commit.to_reference %>)
-- Link to commit by URL: [Commit](<%= urls.namespace_project_commit_url(commit.project.namespace, commit.project, commit) %>)
+- Link to commit by URL: [Commit](<%= urls.project_commit_url(commit.project, commit) %>)
#### LabelReferenceFilter
@@ -227,8 +227,11 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Milestone in another project: <%= xmilestone.to_reference(project) %>
- Ignored in code: `<%= simple_milestone.to_reference %>`
- Ignored in links: [Link to <%= simple_milestone.to_reference %>](#milestone-link)
-- Milestone by URL: <%= urls.namespace_project_milestone_url(milestone.project.namespace, milestone.project, milestone) %>
+- Milestone by URL: <%= urls.milestone_url(milestone) %>
- Link to milestone by URL: [Milestone](<%= milestone.to_reference %>)
+- Group milestone by name: <%= Milestone.reference_prefix %><%= group_milestone.name %>
+- Group milestone by name in quotes: <%= group_milestone.to_reference(format: :name) %>
+- Group milestone by URL is ignore: <%= urls.milestone_url(group_milestone) %>
### Task Lists
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 01bdf01ad22..10bc5f2ecd2 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -1,8 +1,11 @@
+# coding: utf-8
require 'spec_helper'
describe ApplicationHelper do
include UploadHelpers
+ let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
+
describe 'current_controller?' do
it 'returns true when controller matches argument' do
stub_controller_name('foo')
@@ -55,55 +58,96 @@ describe ApplicationHelper do
describe 'project_icon' do
it 'returns an url for the avatar' do
- project = create(:empty_project, avatar: File.open(uploaded_image_temp_path))
+ project = create(:project, avatar: File.open(uploaded_image_temp_path))
+ avatar_url = "/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
+
+ expect(helper.project_icon(project.full_path).to_s)
+ .to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
+
+ allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
+ avatar_url = "#{gitlab_host}/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
- avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif"
- expect(helper.project_icon(project.full_path).to_s).
- to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
+ expect(helper.project_icon(project.full_path).to_s)
+ .to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
it 'gives uploaded icon when present' do
- project = create(:empty_project)
+ project = create(:project)
allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true)
- avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}"
- expect(helper.project_icon(project.full_path).to_s).to match(
- image_tag(avatar_url))
+ avatar_url = "#{gitlab_host}#{project_avatar_path(project)}"
+ expect(helper.project_icon(project.full_path).to_s)
+ .to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
describe 'avatar_icon' do
- it 'returns an url for the avatar' do
- user = create(:user, avatar: File.open(uploaded_image_temp_path))
-
- expect(helper.avatar_icon(user.email).to_s).
- to match("/uploads/user/avatar/#{user.id}/banana_sample.gif")
- end
-
- it 'returns an url for the avatar with relative url' do
- stub_config_setting(relative_url_root: '/gitlab')
- # Must be stubbed after the stub above, and separately
- stub_config_setting(url: Settings.send(:build_gitlab_url))
-
- user = create(:user, avatar: File.open(uploaded_image_temp_path))
-
- expect(helper.avatar_icon(user.email).to_s).
- to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif")
- end
+ let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
+
+ context 'using an email' do
+ context 'when there is a matching user' do
+ it 'returns a relative URL for the avatar' do
+ expect(helper.avatar_icon(user.email).to_s)
+ .to eq("/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
+
+ context 'when an asset_host is set in the config' do
+ let(:asset_host) { 'http://assets' }
+
+ before do
+ allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
+ end
+
+ it 'returns an absolute URL on that asset host' do
+ expect(helper.avatar_icon(user.email, only_path: false).to_s)
+ .to eq("#{asset_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
+ end
+
+ context 'when only_path is set to false' do
+ it 'returns an absolute URL for the avatar' do
+ expect(helper.avatar_icon(user.email, only_path: false).to_s)
+ .to eq("#{gitlab_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
+ end
+
+ context 'when the GitLab instance is at a relative URL' do
+ before do
+ stub_config_setting(relative_url_root: '/gitlab')
+ # Must be stubbed after the stub above, and separately
+ stub_config_setting(url: Settings.send(:build_gitlab_url))
+ end
+
+ it 'returns a relative URL with the correct prefix' do
+ expect(helper.avatar_icon(user.email).to_s)
+ .to eq("/gitlab/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
+ end
+ end
- it 'calls gravatar_icon when no User exists with the given email' do
- expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
+ context 'when no user exists for the email' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
- helper.avatar_icon('foo@example.com', 20, 2)
+ helper.avatar_icon('foo@example.com', 20, 2)
+ end
+ end
end
- describe 'using a User' do
- it 'returns an URL for the avatar' do
- user = create(:user, avatar: File.open(uploaded_image_temp_path))
+ describe 'using a user' do
+ context 'when only_path is true' do
+ it 'returns a relative URL for the avatar' do
+ expect(helper.avatar_icon(user, only_path: true).to_s)
+ .to eq("/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
+ end
- expect(helper.avatar_icon(user).to_s).
- to match("/uploads/user/avatar/#{user.id}/banana_sample.gif")
+ context 'when only_path is false' do
+ it 'returns an absolute URL for the avatar' do
+ expect(helper.avatar_icon(user, only_path: false).to_s)
+ .to eq("#{gitlab_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
+ end
end
end
end
@@ -133,22 +177,22 @@ describe ApplicationHelper do
it 'returns a valid Gravatar URL' do
stub_config_setting(https: false)
- expect(helper.gravatar_icon(user_email)).
- to match('http://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118')
+ expect(helper.gravatar_icon(user_email))
+ .to match('http://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118')
end
it 'uses HTTPs when configured' do
stub_config_setting(https: true)
- expect(helper.gravatar_icon(user_email)).
- to match('https://secure.gravatar.com')
+ expect(helper.gravatar_icon(user_email))
+ .to match('https://secure.gravatar.com')
end
it 'returns custom gravatar path when gravatar_url is set' do
stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}')
- expect(gravatar_icon(user_email, 20)).
- to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118')
+ expect(gravatar_icon(user_email, 20))
+ .to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118')
end
it 'accepts a custom size argument' do
@@ -220,8 +264,8 @@ describe ApplicationHelper do
end
it 'accepts a custom html_class' do
- expect(element(html_class: 'custom_class').attr('class')).
- to eq 'js-timeago custom_class'
+ expect(element(html_class: 'custom_class').attr('class'))
+ .to eq 'js-timeago custom_class'
end
it 'accepts a custom tooltip placement' do
@@ -243,4 +287,24 @@ describe ApplicationHelper do
it { expect(helper.active_when(true)).to eq('active') }
it { expect(helper.active_when(false)).to eq(nil) }
end
+
+ describe '#support_url' do
+ context 'when alternate support url is specified' do
+ let(:alternate_url) { 'http://company.example.com/getting-help' }
+
+ before do
+ stub_application_setting(help_page_support_url: alternate_url)
+ end
+
+ it 'returns the alternate support url' do
+ expect(helper.support_url).to eq(alternate_url)
+ end
+ end
+
+ context 'when alternate support url is not specified' do
+ it 'builds the support url from the promo_url' do
+ expect(helper.support_url).to eq(helper.promo_url + '/getting-help/')
+ end
+ end
+ end
end
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index a0e1265efff..c94fedd615b 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -70,7 +70,7 @@ describe AuthHelper do
end
end
- [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0].each do |provider|
+ [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq].each do |provider|
it "returns false if the provider is #{provider}" do
expect(helper.unlink_allowed?(provider)).to be true
end
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index 581726c1d0e..d16fcf21e45 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe AvatarsHelper do
+ include ApplicationHelper
+
let(:user) { create(:user) }
describe '#user_avatar' do
@@ -15,7 +17,92 @@ describe AvatarsHelper do
end
it "contains the user's avatar image" do
- is_expected.to include(CGI.escapeHTML(user.avatar_url(16)))
+ is_expected.to include(CGI.escapeHTML(user.avatar_url(size: 16)))
+ end
+ end
+
+ describe '#user_avatar_without_link' do
+ let(:options) { { user: user } }
+ subject { helper.user_avatar_without_link(options) }
+
+ it 'displays user avatar' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: 'avatar has-tooltip s16 lazy',
+ alt: "#{user.name}'s avatar",
+ title: user.name,
+ data: { container: 'body', src: avatar_icon(user, 16) }
+ )
+ end
+
+ context 'with css_class parameter' do
+ let(:options) { { user: user, css_class: '.cat-pics' } }
+
+ it 'uses provided css_class' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: "avatar has-tooltip s16 #{options[:css_class]} lazy",
+ alt: "#{user.name}'s avatar",
+ title: user.name,
+ data: { container: 'body', src: avatar_icon(user, 16) }
+ )
+ end
+ end
+
+ context 'with size parameter' do
+ let(:options) { { user: user, size: 99 } }
+
+ it 'uses provided size' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: "avatar has-tooltip s#{options[:size]} lazy",
+ alt: "#{user.name}'s avatar",
+ title: user.name,
+ data: { container: 'body', src: avatar_icon(user, options[:size]) }
+ )
+ end
+ end
+
+ context 'with url parameter' do
+ let(:options) { { user: user, url: '/over/the/rainbow.png' } }
+
+ it 'uses provided url' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: 'avatar has-tooltip s16 lazy',
+ alt: "#{user.name}'s avatar",
+ title: user.name,
+ data: { container: 'body', src: options[:url] }
+ )
+ end
+ end
+
+ context 'with user_name parameter' do
+ let(:options) { { user_name: 'Tinky Winky', user_email: 'no@f.un' } }
+
+ context 'with user parameter' do
+ let(:options) { { user: user, user_name: 'Tinky Winky' } }
+
+ it 'prefers user parameter' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: 'avatar has-tooltip s16 lazy',
+ alt: "#{user.name}'s avatar",
+ title: user.name,
+ data: { container: 'body', src: avatar_icon(user, 16) }
+ )
+ end
+ end
+
+ it 'uses user_name and user_email parameter if user is not present' do
+ is_expected.to eq image_tag(
+ LazyImageTagHelper.placeholder_image,
+ class: 'avatar has-tooltip s16 lazy',
+ alt: "#{options[:user_name]}'s avatar",
+ title: options[:user_name],
+ data: { container: 'body', src: avatar_icon(options[:user_email], 16) }
+ )
+ end
end
end
end
diff --git a/spec/helpers/award_emoji_helper_spec.rb b/spec/helpers/award_emoji_helper_spec.rb
new file mode 100644
index 00000000000..035960ed96e
--- /dev/null
+++ b/spec/helpers/award_emoji_helper_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe AwardEmojiHelper do
+ describe '.toggle_award_url' do
+ context 'note on personal snippet' do
+ let(:note) { create(:note_on_personal_snippet) }
+
+ it 'returns correct url' do
+ expected_url = "/snippets/#{note.noteable.id}/notes/#{note.id}/toggle_award_emoji"
+
+ expect(helper.toggle_award_url(note)).to eq(expected_url)
+ end
+ end
+
+ context 'note on project item' do
+ let(:note) { create(:note_on_project_snippet) }
+
+ it 'returns correct url' do
+ @project = note.noteable.project
+
+ expected_url = "/#{@project.namespace.path}/#{@project.path}/notes/#{note.id}/toggle_award_emoji"
+
+ expect(helper.toggle_award_url(note)).to eq(expected_url)
+ end
+ end
+
+ context 'personal snippet' do
+ let(:snippet) { create(:personal_snippet) }
+
+ it 'returns correct url' do
+ expected_url = "/snippets/#{snippet.id}/toggle_award_emoji"
+
+ expect(helper.toggle_award_url(snippet)).to eq(expected_url)
+ end
+ end
+
+ context 'merge request' do
+ let(:merge_request) { create(:merge_request) }
+
+ it 'returns correct url' do
+ @project = merge_request.project
+
+ expected_url = "/#{@project.namespace.path}/#{@project.path}/merge_requests/#{merge_request.iid}/toggle_award_emoji"
+
+ expect(helper.toggle_award_url(merge_request)).to eq(expected_url)
+ end
+ end
+
+ context 'issue' do
+ let(:issue) { create(:issue) }
+
+ it 'returns correct url' do
+ @project = issue.project
+
+ expected_url = "/#{@project.namespace.path}/#{@project.path}/issues/#{issue.iid}/toggle_award_emoji"
+
+ expect(helper.toggle_award_url(issue)).to eq(expected_url)
+ end
+ end
+ end
+end
diff --git a/spec/helpers/blame_helper_spec.rb b/spec/helpers/blame_helper_spec.rb
new file mode 100644
index 00000000000..b4368516d83
--- /dev/null
+++ b/spec/helpers/blame_helper_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe BlameHelper do
+ describe '#get_age_map_start_date' do
+ let(:dates) do
+ [Time.zone.local(2014, 3, 17, 0, 0, 0),
+ Time.zone.local(2011, 11, 2, 0, 0, 0),
+ Time.zone.local(2015, 7, 9, 0, 0, 0),
+ Time.zone.local(2013, 2, 24, 0, 0, 0),
+ Time.zone.local(2010, 9, 22, 0, 0, 0)]
+ end
+ let(:blame_groups) do
+ [
+ { commit: double(committed_date: dates[0]) },
+ { commit: double(committed_date: dates[1]) },
+ { commit: double(committed_date: dates[2]) }
+ ]
+ end
+
+ it 'returns the earliest date from a blame group' do
+ project = double(created_at: dates[3])
+
+ duration = helper.age_map_duration(blame_groups, project)
+
+ expect(duration[:started_days_ago]).to eq((duration[:now] - dates[1]).to_i / 1.day)
+ end
+
+ it 'returns the earliest date from a project' do
+ project = double(created_at: dates[4])
+
+ duration = helper.age_map_duration(blame_groups, project)
+
+ expect(duration[:started_days_ago]).to eq((duration[:now] - dates[4]).to_i / 1.day)
+ end
+ end
+
+ describe '#age_map_class' do
+ let(:dates) do
+ [Time.zone.local(2014, 3, 17, 0, 0, 0)]
+ end
+ let(:blame_groups) do
+ [
+ { commit: double(committed_date: dates[0]) }
+ ]
+ end
+ let(:duration) do
+ project = double(created_at: dates[0])
+ helper.age_map_duration(blame_groups, project)
+ end
+
+ it 'returns blame-commit-age-9 when oldest' do
+ expect(helper.age_map_class(dates[0], duration)).to eq 'blame-commit-age-9'
+ end
+
+ it 'returns blame-commit-age-0 class when newest' do
+ expect(helper.age_map_class(duration[:now], duration)).to eq 'blame-commit-age-0'
+ end
+ end
+end
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index 508aeb7cf67..c654151564e 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -56,15 +56,14 @@ describe BlobHelper do
end
end
- describe "#sanitize_svg" do
+ describe "#sanitize_svg_data" do
let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') }
let(:data) { open(input_svg_path).read }
let(:expected_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'sanitized.svg') }
let(:expected) { open(expected_svg_path).read }
it 'retains essential elements' do
- blob = OpenStruct.new(data: data)
- expect(sanitize_svg(blob).data).to eq(expected)
+ expect(sanitize_svg_data(data)).to eq(expected)
end
end
@@ -105,4 +104,135 @@ describe BlobHelper do
expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10')
end
end
+
+ context 'viewer related' do
+ include FakeBlobHelpers
+
+ let(:project) { build(:project, lfs_enabled: true) }
+
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ end
+
+ let(:viewer_class) do
+ Class.new(BlobViewer::Base) do
+ include BlobViewer::ServerSide
+
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 5.megabytes
+ self.type = :rich
+ end
+ end
+
+ let(:viewer) { viewer_class.new(blob) }
+ let(:blob) { fake_blob }
+
+ describe '#blob_render_error_reason' do
+ context 'for error :too_large' do
+ context 'when the blob size is larger than the absolute size limit' do
+ let(:blob) { fake_blob(size: 10.megabytes) }
+
+ it 'returns an error message' do
+ expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 5 MB')
+ end
+ end
+
+ context 'when the blob size is larger than the size limit' do
+ let(:blob) { fake_blob(size: 2.megabytes) }
+
+ it 'returns an error message' do
+ expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 1 MB')
+ end
+ end
+ end
+
+ context 'for error :server_side_but_stored_externally' do
+ let(:blob) { fake_blob(lfs: true) }
+
+ it 'returns an error message' do
+ expect(helper.blob_render_error_reason(viewer)).to eq('it is stored in LFS')
+ end
+ end
+ end
+
+ describe '#blob_render_error_options' do
+ before do
+ assign(:project, project)
+ assign(:blob, blob)
+ assign(:id, File.join('master', blob.path))
+
+ controller.params[:controller] = 'projects/blob'
+ controller.params[:action] = 'show'
+ controller.params[:namespace_id] = project.namespace.to_param
+ controller.params[:project_id] = project.to_param
+ controller.params[:id] = File.join('master', blob.path)
+ end
+
+ context 'for error :collapsed' do
+ let(:blob) { fake_blob(size: 2.megabytes) }
+
+ it 'includes a "load it anyway" link' do
+ expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/)
+ end
+ end
+
+ context 'for error :too_large' do
+ let(:blob) { fake_blob(size: 10.megabytes) }
+
+ it 'does not include a "load it anyway" link' do
+ expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/)
+ end
+
+ context 'when the viewer is rich' do
+ context 'the blob is rendered as text' do
+ let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) }
+
+ it 'includes a "view the source" link' do
+ expect(helper.blob_render_error_options(viewer)).to include(/view the source/)
+ end
+ end
+
+ context 'the blob is not rendered as text' do
+ let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 2.megabytes) }
+
+ it 'does not include a "view the source" link' do
+ expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/)
+ end
+ end
+ end
+
+ context 'when the viewer is not rich' do
+ before do
+ viewer_class.type = :simple
+ end
+
+ let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) }
+
+ it 'does not include a "view the source" link' do
+ expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/)
+ end
+ end
+
+ it 'includes a "download it" link' do
+ expect(helper.blob_render_error_options(viewer)).to include(/download it/)
+ end
+ end
+
+ context 'for error :server_side_but_stored_externally' do
+ let(:blob) { fake_blob(path: 'file.md', lfs: true) }
+
+ it 'does not include a "load it anyway" link' do
+ expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/)
+ end
+
+ it 'does not include a "view the source" link' do
+ expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/)
+ end
+
+ it 'includes a "download it" link' do
+ expect(helper.blob_render_error_options(viewer)).to include(/download it/)
+ end
+ end
+ end
+ end
end
diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb
index c6e3c5c2368..9bec0f9f432 100644
--- a/spec/helpers/broadcast_messages_helper_spec.rb
+++ b/spec/helpers/broadcast_messages_helper_spec.rb
@@ -33,8 +33,8 @@ describe BroadcastMessagesHelper do
it 'allows custom style' do
broadcast_message = double(color: '#f2dede', font: '#b94a48')
- expect(helper.broadcast_message_style(broadcast_message)).
- to match('background-color: #f2dede; color: #b94a48')
+ expect(helper.broadcast_message_style(broadcast_message))
+ .to match('background-color: #f2dede; color: #b94a48')
end
end
diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb
new file mode 100644
index 00000000000..250ba239033
--- /dev/null
+++ b/spec/helpers/button_helper_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe ButtonHelper do
+ describe 'http_clone_button' do
+ let(:user) { create(:user) }
+ let(:project) { build_stubbed(:project) }
+ let(:has_tooltip_class) { 'has-tooltip' }
+
+ def element
+ element = helper.http_clone_button(project)
+
+ Nokogiri::HTML::DocumentFragment.parse(element).first_element_child
+ end
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'with internal auth enabled' do
+ context 'when user has a password' do
+ it 'shows no tooltip' do
+ expect(element.attr('class')).not_to include(has_tooltip_class)
+ end
+ end
+
+ context 'when user has password automatically set' do
+ let(:user) { create(:user, password_automatically_set: true) }
+
+ it 'shows a password tooltip' do
+ expect(element.attr('class')).to include(has_tooltip_class)
+ expect(element.attr('data-title')).to eq('Set a password on your account to pull or push via HTTP.')
+ end
+ end
+ end
+
+ context 'with internal auth disabled' do
+ before do
+ stub_application_setting(password_authentication_enabled?: false)
+ end
+
+ context 'when user has no personal access tokens' do
+ it 'has a personal access token tooltip ' do
+ expect(element.attr('class')).to include(has_tooltip_class)
+ expect(element.attr('data-title')).to eq('Create a personal access token on your account to pull or push via HTTP.')
+ end
+ end
+
+ context 'when user has a personal access token' do
+ it 'shows no tooltip' do
+ create(:personal_access_token, user: user)
+
+ expect(element.attr('class')).not_to include(has_tooltip_class)
+ end
+ end
+ end
+
+ context 'when user is ldap user' do
+ let(:user) { create(:omniauth_user, password_automatically_set: true) }
+
+ it 'shows no tooltip' do
+ expect(element.attr('class')).not_to include(has_tooltip_class)
+ end
+ end
+ end
+end
diff --git a/spec/helpers/ci_status_helper_spec.rb b/spec/helpers/ci_status_helper_spec.rb
index e6bb953e9d8..6a3945c0ebc 100644
--- a/spec/helpers/ci_status_helper_spec.rb
+++ b/spec/helpers/ci_status_helper_spec.rb
@@ -48,7 +48,7 @@ describe CiStatusHelper do
describe "#pipeline_status_cache_key" do
it "builds a cache key for pipeline status" do
pipeline_status = Gitlab::Cache::Ci::ProjectPipelineStatus.new(
- build(:project),
+ build_stubbed(:project),
pipeline_info: {
sha: "123abc",
status: "success"
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index a2c008790f9..7179185285c 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -9,8 +9,8 @@ describe CommitsHelper do
author_email: 'my@email.com" onmouseover="alert(1)'
)
- expect(helper.commit_author_link(commit)).
- not_to include('onmouseover="alert(1)"')
+ expect(helper.commit_author_link(commit))
+ .not_to include('onmouseover="alert(1)"')
end
end
@@ -22,13 +22,13 @@ describe CommitsHelper do
committer_email: 'my@email.com" onmouseover="alert(1)'
)
- expect(helper.commit_committer_link(commit)).
- not_to include('onmouseover="alert(1)"')
+ expect(helper.commit_committer_link(commit))
+ .not_to include('onmouseover="alert(1)"')
end
end
describe '#view_on_environment_button' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:environment) { create(:environment, external_url: 'http://example.com') }
let(:path) { 'source/file.html' }
let(:sha) { RepoHelpers.sample_commit.id }
diff --git a/spec/helpers/defer_script_tag_helper_spec.rb b/spec/helpers/defer_script_tag_helper_spec.rb
new file mode 100644
index 00000000000..d10b6f134e4
--- /dev/null
+++ b/spec/helpers/defer_script_tag_helper_spec.rb
@@ -0,0 +1,13 @@
+# coding: utf-8
+require 'spec_helper'
+
+describe DeferScriptTagHelper do
+ describe 'script tag' do
+ script_url = 'test.js'
+
+ it 'returns an script tag with defer=true' do
+ expect(javascript_include_tag(script_url).to_s)
+ .to eq "<script src=\"/javascripts/#{script_url}\" defer=\"defer\"></script>"
+ end
+ end
+end
diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb
index eae097126ce..f81a9b6492c 100644
--- a/spec/helpers/diff_helper_spec.rb
+++ b/spec/helpers/diff_helper_spec.rb
@@ -3,28 +3,41 @@ require 'spec_helper'
describe DiffHelper do
include RepoHelpers
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:commit) { project.commit(sample_commit.id) }
let(:diffs) { commit.raw_diffs }
let(:diff) { diffs.first }
- let(:diff_refs) { [commit.parent, commit] }
+ let(:diff_refs) { commit.diff_refs }
let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
describe 'diff_view' do
+ it 'uses the view param over the cookie' do
+ controller.params[:view] = 'parallel'
+ helper.request.cookies[:diff_view] = 'inline'
+
+ expect(helper.diff_view).to eq :parallel
+ end
+
+ it 'returns the default value when the view param is invalid' do
+ controller.params[:view] = 'invalid'
+
+ expect(helper.diff_view).to eq :inline
+ end
+
it 'returns a valid value when cookie is set' do
helper.request.cookies[:diff_view] = 'parallel'
expect(helper.diff_view).to eq :parallel
end
- it 'returns a default value when cookie is invalid' do
+ it 'returns the default value when cookie is invalid' do
helper.request.cookies[:diff_view] = 'invalid'
expect(helper.diff_view).to eq :inline
end
- it 'returns a default value when cookie is nil' do
+ it 'returns the default value when cookie is nil' do
expect(helper.request.cookies).to be_empty
expect(helper.diff_view).to eq :inline
@@ -33,17 +46,17 @@ describe DiffHelper do
describe 'diff_options' do
it 'returns no collapse false' do
- expect(diff_options).to include(no_collapse: false)
+ expect(diff_options).to include(expanded: false)
end
- it 'returns no collapse true if expand_all_diffs' do
- allow(controller).to receive(:params) { { expand_all_diffs: true } }
- expect(diff_options).to include(no_collapse: true)
+ it 'returns no collapse true if expanded' do
+ allow(controller).to receive(:params) { { expanded: true } }
+ expect(diff_options).to include(expanded: true)
end
it 'returns no collapse true if action name diff_for_path' do
allow(controller).to receive(:action_name) { 'diff_for_path' }
- expect(diff_options).to include(no_collapse: true)
+ expect(diff_options).to include(expanded: true)
end
it 'returns paths if action name diff_for_path and param old path' do
@@ -122,13 +135,49 @@ describe DiffHelper do
it "returns strings with marked inline diffs" do
marked_old_line, marked_new_line = mark_inline_diffs(old_line, new_line)
- expect(marked_old_line).to eq("abc <span class='idiff left right deletion'>&#39;def&#39;</span>")
+ expect(marked_old_line).to eq(%q{abc <span class="idiff left right deletion">&#39;def&#39;</span>})
expect(marked_old_line).to be_html_safe
- expect(marked_new_line).to eq("abc <span class='idiff left right addition'>&quot;def&quot;</span>")
+ expect(marked_new_line).to eq(%q{abc <span class="idiff left right addition">&quot;def&quot;</span>})
expect(marked_new_line).to be_html_safe
end
end
+ describe '#parallel_diff_discussions' do
+ let(:discussion) { { 'abc_3_3' => 'comment' } }
+ let(:diff_file) { double(line_code: 'abc_3_3') }
+
+ before do
+ helper.instance_variable_set(:@grouped_diff_discussions, discussion)
+ end
+
+ it 'does not put comments on nonewline lines' do
+ left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3)
+ right = Gitlab::Diff::Line.new('\\nonewline', 'new-nonewline', 3, 3, 3)
+
+ result = helper.parallel_diff_discussions(left, right, diff_file)
+
+ expect(result).to eq([nil, nil])
+ end
+
+ it 'puts comments on added lines' do
+ left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3)
+ right = Gitlab::Diff::Line.new('new line', 'new', 3, 3, 3)
+
+ result = helper.parallel_diff_discussions(left, right, diff_file)
+
+ expect(result).to eq([nil, 'comment'])
+ end
+
+ it 'puts comments on unchanged lines' do
+ left = Gitlab::Diff::Line.new('unchanged line', nil, 3, 3, 3)
+ right = Gitlab::Diff::Line.new('unchanged line', nil, 3, 3, 3)
+
+ result = helper.parallel_diff_discussions(left, right, diff_file)
+
+ expect(result).to eq(['comment', nil])
+ end
+ end
+
describe "#diff_match_line" do
let(:old_pos) { 40 }
let(:new_pos) { 50 }
@@ -180,4 +229,41 @@ describe DiffHelper do
expect(output).not_to have_css 'td:nth-child(3)'
end
end
+
+ context 'viewer related' do
+ let(:viewer) { diff_file.simple_viewer }
+
+ before do
+ assign(:project, project)
+ end
+
+ describe '#diff_render_error_reason' do
+ context 'for error :too_large' do
+ before do
+ expect(viewer).to receive(:render_error).and_return(:too_large)
+ end
+
+ it 'returns an error message' do
+ expect(helper.diff_render_error_reason(viewer)).to eq('it is too large')
+ end
+ end
+
+ context 'for error :server_side_but_stored_externally' do
+ before do
+ expect(viewer).to receive(:render_error).and_return(:server_side_but_stored_externally)
+ expect(diff_file).to receive(:external_storage).and_return(:lfs)
+ end
+
+ it 'returns an error message' do
+ expect(helper.diff_render_error_reason(viewer)).to eq('it is stored in LFS')
+ end
+ end
+ end
+
+ describe '#diff_render_error_options' do
+ it 'includes a "view the blob" link' do
+ expect(helper.diff_render_error_options(viewer)).to include(/view the blob/)
+ end
+ end
+ end
end
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index cd112dbb2fb..2390c1f3e5d 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -52,7 +52,7 @@ describe EmailsHelper do
)
expect(header_logo).to eq(
- %{<img style="height: 50px" src="/uploads/appearance/header_logo/#{appearance.id}/dk.png" alt="Dk" />}
+ %{<img style="height: 50px" src="/uploads/-/system/appearance/header_logo/#{appearance.id}/dk.png" alt="Dk" />}
)
end
end
diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb
index a7c3c281083..aa138f25bd3 100644
--- a/spec/helpers/events_helper_spec.rb
+++ b/spec/helpers/events_helper_spec.rb
@@ -56,7 +56,7 @@ describe EventsHelper do
it 'preserves code color scheme' do
input = "```ruby\ndef test\n 'hello world'\nend\n```"
- expected = '<pre class="code highlight js-syntax-highlight ruby">' \
+ expected = "\n<pre class=\"code highlight js-syntax-highlight ruby\">" \
"<code><span class=\"line\"><span class=\"k\">def</span> <span class=\"nf\">test</span>...</span>\n" \
"</code></pre>"
expect(helper.event_note(input)).to eq(expected)
@@ -72,13 +72,13 @@ describe EventsHelper do
end
it 'preserves style attribute for a label that can be accessed by current_user' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
expect(format_event_note(project)).to match(/span class=.*style=.*/)
end
it 'does not style a label that can not be accessed by current_user' do
- project = create(:empty_project, :private)
+ project = create(:project, :private)
expect(format_event_note(project)).to eq("<p>#{input}</p>")
end
diff --git a/spec/helpers/form_helper_spec.rb b/spec/helpers/form_helper_spec.rb
index b20373a96fb..18cf0031d5f 100644
--- a/spec/helpers/form_helper_spec.rb
+++ b/spec/helpers/form_helper_spec.rb
@@ -11,18 +11,18 @@ describe FormHelper do
it 'renders an alert div' do
model = double(errors: errors_stub('Error 1'))
- expect(helper.form_errors(model)).
- to include('<div class="alert alert-danger" id="error_explanation">')
+ expect(helper.form_errors(model))
+ .to include('<div class="alert alert-danger" id="error_explanation">')
end
it 'contains a summary message' do
single_error = double(errors: errors_stub('A'))
multi_errors = double(errors: errors_stub('A', 'B', 'C'))
- expect(helper.form_errors(single_error)).
- to include('<h4>The form contains the following error:')
- expect(helper.form_errors(multi_errors)).
- to include('<h4>The form contains the following errors:')
+ expect(helper.form_errors(single_error))
+ .to include('<h4>The form contains the following error:')
+ expect(helper.form_errors(multi_errors))
+ .to include('<h4>The form contains the following errors:')
end
it 'renders each message' do
diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb
index 14847d0a49e..a44b200c5da 100644
--- a/spec/helpers/gitlab_routing_helper_spec.rb
+++ b/spec/helpers/gitlab_routing_helper_spec.rb
@@ -1,48 +1,39 @@
require 'spec_helper'
describe GitlabRoutingHelper do
- describe 'Project URL helpers' do
- describe '#project_members_url' do
- let(:project) { build_stubbed(:empty_project) }
-
- it { expect(project_members_url(project)).to eq namespace_project_project_members_url(project.namespace, project) }
- end
+ let(:project) { build_stubbed(:project) }
+ let(:group) { build_stubbed(:group) }
+ describe 'Project URL helpers' do
describe '#project_member_path' do
let(:project_member) { create(:project_member) }
- it { expect(project_member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) }
+ it { expect(project_member_path(project_member)).to eq project_project_member_path(project_member.source, project_member) }
end
describe '#request_access_project_members_path' do
- let(:project) { build_stubbed(:empty_project) }
-
- it { expect(request_access_project_members_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) }
+ it { expect(request_access_project_members_path(project)).to eq request_access_project_project_members_path(project) }
end
describe '#leave_project_members_path' do
- let(:project) { build_stubbed(:empty_project) }
-
- it { expect(leave_project_members_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) }
+ it { expect(leave_project_members_path(project)).to eq leave_project_project_members_path(project) }
end
describe '#approve_access_request_project_member_path' do
let(:project_member) { create(:project_member) }
- it { expect(approve_access_request_project_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) }
+ it { expect(approve_access_request_project_member_path(project_member)).to eq approve_access_request_project_project_member_path(project_member.source, project_member) }
end
describe '#resend_invite_project_member_path' do
let(:project_member) { create(:project_member) }
- it { expect(resend_invite_project_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) }
+ it { expect(resend_invite_project_member_path(project_member)).to eq resend_invite_project_project_member_path(project_member.source, project_member) }
end
end
describe 'Group URL helpers' do
describe '#group_members_url' do
- let(:group) { build_stubbed(:group) }
-
it { expect(group_members_url(group)).to eq group_group_members_url(group) }
end
@@ -53,14 +44,10 @@ describe GitlabRoutingHelper do
end
describe '#request_access_group_members_path' do
- let(:group) { build_stubbed(:group) }
-
it { expect(request_access_group_members_path(group)).to eq request_access_group_group_members_path(group) }
end
describe '#leave_group_members_path' do
- let(:group) { build_stubbed(:group) }
-
it { expect(leave_group_members_path(group)).to eq leave_group_group_members_path(group) }
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index c8b0d86425f..9d6e03e3868 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe GroupsHelper do
+ include ApplicationHelper
+
describe 'group_icon' do
avatar_file_path = File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
@@ -8,8 +10,8 @@ describe GroupsHelper do
group = create(:group)
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
- expect(group_icon(group.path).to_s).
- to match("/uploads/group/avatar/#{group.id}/banana_sample.gif")
+ expect(group_icon(group.path).to_s)
+ .to match("/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif")
end
it 'gives default avatar_icon when no avatar is present' do
@@ -21,7 +23,7 @@ describe GroupsHelper do
describe 'group_lfs_status' do
let(:group) { create(:group) }
- let!(:project) { create(:empty_project, namespace_id: group.id) }
+ let!(:project) { create(:project, namespace_id: group.id) }
before do
allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
@@ -45,7 +47,7 @@ describe GroupsHelper do
context 'more than one project in group' do
before do
- create(:empty_project, namespace_id: group.id)
+ create(:project, namespace_id: group.id)
end
context 'LFS enabled in group' do
@@ -81,4 +83,15 @@ describe GroupsHelper do
end
end
end
+
+ describe 'group_title', :nested_groups do
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+ let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+
+ it 'outputs the groups in the correct order' do
+ expect(helper.group_title(very_deep_nested_group)).to match(/>#{group.name}<\/a>.*>#{nested_group.name}<\/a>.*>#{deep_nested_group.name}<\/a>/)
+ end
+ end
end
diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb
new file mode 100644
index 00000000000..2e21f1134b1
--- /dev/null
+++ b/spec/helpers/hooks_helper_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe HooksHelper do
+ let(:project) { create(:project) }
+ let(:project_hook) { create(:project_hook, project: project) }
+ let(:system_hook) { create(:system_hook) }
+ let(:trigger) { 'push_events' }
+
+ describe '#link_to_test_hook' do
+ it 'returns project namespaced link' do
+ expect(helper.link_to_test_hook(project_hook, trigger))
+ .to include("href=\"#{test_project_hook_path(project, project_hook, trigger: trigger)}\"")
+ end
+
+ it 'returns admin namespaced link' do
+ expect(helper.link_to_test_hook(system_hook, trigger))
+ .to include("href=\"#{test_admin_hook_path(system_hook, trigger: trigger)}\"")
+ end
+ end
+end
diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb
index 10f293cddf5..9afff47f4e9 100644
--- a/spec/helpers/import_helper_spec.rb
+++ b/spec/helpers/import_helper_spec.rb
@@ -29,21 +29,21 @@ describe ImportHelper do
context 'when provider is "github"' do
context 'when provider does not specify a custom URL' do
it 'uses default GitHub URL' do
- allow(Gitlab.config.omniauth).to receive(:providers).
- and_return([Settingslogic.new('name' => 'github')])
+ allow(Gitlab.config.omniauth).to receive(:providers)
+ .and_return([Settingslogic.new('name' => 'github')])
- expect(helper.provider_project_link('github', 'octocat/Hello-World')).
- to include('href="https://github.com/octocat/Hello-World"')
+ expect(helper.provider_project_link('github', 'octocat/Hello-World'))
+ .to include('href="https://github.com/octocat/Hello-World"')
end
end
context 'when provider specify a custom URL' do
it 'uses custom URL' do
- allow(Gitlab.config.omniauth).to receive(:providers).
- and_return([Settingslogic.new('name' => 'github', 'url' => 'https://github.company.com')])
+ allow(Gitlab.config.omniauth).to receive(:providers)
+ .and_return([Settingslogic.new('name' => 'github', 'url' => 'https://github.company.com')])
- expect(helper.provider_project_link('github', 'octocat/Hello-World')).
- to include('href="https://github.company.com/octocat/Hello-World"')
+ expect(helper.provider_project_link('github', 'octocat/Hello-World'))
+ .to include('href="https://github.company.com/octocat/Hello-World"')
end
end
end
@@ -54,8 +54,8 @@ describe ImportHelper do
end
it 'uses given host' do
- expect(helper.provider_project_link('gitea', 'octocat/Hello-World')).
- to include('href="https://try.gitea.io/octocat/Hello-World"')
+ expect(helper.provider_project_link('gitea', 'octocat/Hello-World'))
+ .to include('href="https://try.gitea.io/octocat/Hello-World"')
end
end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 93bb711f29a..7789cfa3554 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -4,6 +4,23 @@ describe IssuablesHelper do
let(:label) { build_stubbed(:label) }
let(:label2) { build_stubbed(:label) }
+ describe '#users_dropdown_label' do
+ let(:user) { build_stubbed(:user) }
+ let(:user2) { build_stubbed(:user) }
+
+ it 'returns unassigned' do
+ expect(users_dropdown_label([])).to eq('Unassigned')
+ end
+
+ it 'returns selected user\'s name' do
+ expect(users_dropdown_label([user])).to eq(user.name)
+ end
+
+ it 'returns selected user\'s name and counter' do
+ expect(users_dropdown_label([user, user2])).to eq("#{user.name} + 1 more")
+ end
+ end
+
describe '#issuable_labels_tooltip' do
it 'returns label text' do
expect(issuable_labels_tooltip([label])).to eq(label.title)
@@ -23,27 +40,27 @@ describe IssuablesHelper do
end
it 'returns "Open" when state is :opened' do
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
end
it 'returns "Closed" when state is :closed' do
- expect(helper.issuables_state_counter_text(:issues, :closed)).
- to eq('<span>Closed</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :closed))
+ .to eq('<span>Closed</span> <span class="badge">42</span>')
end
it 'returns "Merged" when state is :merged' do
- expect(helper.issuables_state_counter_text(:merge_requests, :merged)).
- to eq('<span>Merged</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:merge_requests, :merged))
+ .to eq('<span>Merged</span> <span class="badge">42</span>')
end
it 'returns "All" when state is :all' do
- expect(helper.issuables_state_counter_text(:merge_requests, :all)).
- to eq('<span>All</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:merge_requests, :all))
+ .to eq('<span>All</span> <span class="badge">42</span>')
end
end
- describe 'counter caching based on issuable type and params', :caching do
+ describe 'counter caching based on issuable type and params', :use_clean_rails_memory_store_caching do
let(:params) do
{
scope: 'created-by-me',
@@ -60,57 +77,92 @@ describe IssuablesHelper do
}.with_indifferent_access
end
+ let(:issues_finder) { IssuesFinder.new(nil, params) }
+ let(:merge_requests_finder) { MergeRequestsFinder.new(nil, params) }
+
+ before do
+ allow(helper).to receive(:issues_finder).and_return(issues_finder)
+ allow(helper).to receive(:merge_requests_finder).and_return(merge_requests_finder)
+ end
+
it 'returns the cached value when called for the same issuable type & with the same params' do
- expect(helper).to receive(:params).twice.and_return(params)
- expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
+
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
+
+ expect(issues_finder).not_to receive(:count_by_state)
+
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
+ end
+
+ it 'takes confidential status into account when searching for issues' do
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
+
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to include('42')
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(issues_finder).to receive(:user_cannot_see_confidential_issues?).twice.and_return(false)
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 40)
- expect(helper).not_to receive(:issuables_count_for_state)
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to include('40')
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(issues_finder).to receive(:user_can_see_all_confidential_issues?).and_return(true)
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 45)
+
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to include('45')
+ end
+
+ it 'does not take confidential status into account when searching for merge requests' do
+ expect(merge_requests_finder).to receive(:count_by_state).and_return(opened: 42)
+ expect(merge_requests_finder).not_to receive(:user_cannot_see_confidential_issues?)
+ expect(merge_requests_finder).not_to receive(:user_can_see_all_confidential_issues?)
+
+ expect(helper.issuables_state_counter_text(:merge_requests, :opened))
+ .to include('42')
end
it 'does not take some keys into account in the cache key' do
- expect(helper).to receive(:params).and_return({
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
+ expect(issues_finder).to receive(:params).and_return({
author_id: '11',
state: 'foo',
sort: 'foo',
utf8: 'foo',
page: 'foo'
}.with_indifferent_access)
- expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
- expect(helper).to receive(:params).and_return({
+ expect(issues_finder).not_to receive(:count_by_state)
+ expect(issues_finder).to receive(:params).and_return({
author_id: '11',
state: 'bar',
sort: 'bar',
utf8: 'bar',
page: 'bar'
}.with_indifferent_access)
- expect(helper).not_to receive(:issuables_count_for_state)
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
end
it 'does not take params order into account in the cache key' do
- expect(helper).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
- expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
+ expect(issues_finder).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
+ expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
- expect(helper).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
- expect(helper).not_to receive(:issuables_count_for_state)
+ expect(issues_finder).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
+ expect(issues_finder).not_to receive(:count_by_state)
- expect(helper.issuables_state_counter_text(:issues, :opened)).
- to eq('<span>Open</span> <span class="badge">42</span>')
+ expect(helper.issuables_state_counter_text(:issues, :opened))
+ .to eq('<span>Open</span> <span class="badge">42</span>')
end
end
end
@@ -175,4 +227,42 @@ describe IssuablesHelper do
expect(helper.issuable_filter_present?).to be_falsey
end
end
+
+ describe '#updated_at_by' do
+ let(:user) { create(:user) }
+ let(:unedited_issuable) { create(:issue) }
+ let(:edited_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) }
+ let(:edited_updated_at_by) do
+ {
+ updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: user.name,
+ path: user_path(user)
+ }
+ }
+ end
+
+ it { expect(helper.updated_at_by(unedited_issuable)).to eq({}) }
+ it { expect(helper.updated_at_by(edited_issuable)).to eq(edited_updated_at_by) }
+
+ context 'when updated by a deleted user' do
+ let(:edited_updated_at_by) do
+ {
+ updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: User.ghost.name,
+ path: user_path(User.ghost)
+ }
+ }
+ end
+
+ before do
+ user.destroy
+ end
+
+ it 'returns "Ghost user" as edited_by' do
+ expect(helper.updated_at_by(edited_issuable.reload)).to eq(edited_updated_at_by)
+ end
+ end
+ end
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 540cb0ab1e0..dc3100311f8 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -1,14 +1,14 @@
require "spec_helper"
describe IssuesHelper do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue) { create :issue, project: project }
let(:ext_project) { create :redmine_project }
describe "url_for_issue" do
let(:issues_url) { ext_project.external_issue_tracker.issues_url}
let(:ext_expected) { issues_url.gsub(':id', issue.iid.to_s).gsub(':project_id', ext_project.id.to_s) }
- let(:int_expected) { polymorphic_path([@project.namespace, project, issue]) }
+ let(:int_expected) { polymorphic_path([@project.namespace, @project, issue]) }
it "returns internal path if used internal tracker" do
@project = project
@@ -22,6 +22,12 @@ describe IssuesHelper do
expect(url_for_issue(issue.iid)).to match(ext_expected)
end
+ it "returns path to internal issue when internal option passed" do
+ @project = ext_project
+
+ expect(url_for_issue(issue.iid, ext_project, internal: true)).to match(int_expected)
+ end
+
it "returns empty string if project nil" do
@project = nil
@@ -93,8 +99,8 @@ describe IssuesHelper do
award = build_stubbed(:award_emoji, user: build_stubbed(:user, name: 'Jane'))
awards = Array.new(5, award).push(my_award)
- expect(award_user_list(awards, current_user, limit: 2)).
- to eq("You, Jane, and 4 more.")
+ expect(award_user_list(awards, current_user, limit: 2))
+ .to eq("You, Jane, and 4 more.")
end
end
@@ -137,7 +143,7 @@ describe IssuesHelper do
let(:merge_request) { create(:merge_request) }
it "links just the merge request" do
- expected_path = namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
+ expected_path = project_merge_request_path(merge_request.project, merge_request)
expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path)
end
diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb
index 7cf535fadae..36d6e495ed0 100644
--- a/spec/helpers/labels_helper_spec.rb
+++ b/spec/helpers/labels_helper_spec.rb
@@ -2,18 +2,18 @@ require 'spec_helper'
describe LabelsHelper do
describe 'link_to_label' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:label) { create(:label, project: project) }
context 'without subject' do
it "uses the label's project" do
- expect(link_to_label(label)).to match %r{<a href="/#{label.project.path_with_namespace}/issues\?label_name%5B%5D=#{label.name}">.*</a>}
+ expect(link_to_label(label)).to match %r{<a href="/#{label.project.full_path}/issues\?label_name%5B%5D=#{label.name}">.*</a>}
end
end
context 'with a project as subject' do
let(:namespace) { build(:namespace, name: 'foo3') }
- let(:another_project) { build(:empty_project, namespace: namespace, name: 'bar3') }
+ let(:another_project) { build(:project, namespace: namespace, name: 'bar3') }
it 'links to project issues page' do
expect(link_to_label(label, subject: another_project)).to match %r{<a href="/foo3/bar3/issues\?label_name%5B%5D=#{label.name}">.*</a>}
@@ -32,7 +32,7 @@ describe LabelsHelper do
['issue', :issue, 'merge_request', :merge_request].each do |type|
context "set to #{type}" do
it 'links to correct page' do
- expect(link_to_label(label, type: type)).to match %r{<a href="/#{label.project.path_with_namespace}/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}">.*</a>}
+ expect(link_to_label(label, type: type)).to match %r{<a href="/#{label.project.full_path}/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}">.*</a>}
end
end
end
@@ -55,8 +55,8 @@ describe LabelsHelper do
context 'without block' do
it 'uses render_colored_label as the link content' do
- expect(self).to receive(:render_colored_label).
- with(label, tooltip: true).and_return('Foo')
+ expect(self).to receive(:render_colored_label)
+ .with(label, tooltip: true).and_return('Foo')
expect(link_to_label(label)).to match('Foo')
end
end
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index c10f4b09b5b..70eb01c9c44 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -25,17 +25,17 @@ describe MarkupHelper do
let(:actual) { "#{merge_request.to_reference} -> #{commit.to_reference} -> #{issue.to_reference}" }
it "links to the merge request" do
- expected = namespace_project_merge_request_path(project.namespace, project, merge_request)
+ expected = project_merge_request_path(project, merge_request)
expect(helper.markdown(actual)).to match(expected)
end
it "links to the commit" do
- expected = namespace_project_commit_path(project.namespace, project, commit)
+ expected = project_commit_path(project, commit)
expect(helper.markdown(actual)).to match(expected)
end
it "links to the issue" do
- expected = namespace_project_issue_path(project.namespace, project, issue)
+ expected = project_issue_path(project, issue)
expect(helper.markdown(actual)).to match(expected)
end
end
@@ -46,7 +46,7 @@ describe MarkupHelper do
let(:second_issue) { create(:issue, project: second_project) }
it 'links to the issue' do
- expected = namespace_project_issue_path(second_project.namespace, second_project, second_issue)
+ expected = project_issue_path(second_project, second_issue)
expect(markdown(actual, project: second_project)).to match(expected)
end
end
@@ -68,8 +68,8 @@ describe MarkupHelper do
expect(doc.css('a')[0].text).to eq 'This should finally fix '
# First issue link
- expect(doc.css('a')[1].attr('href')).
- to eq namespace_project_issue_path(project.namespace, project, issues[0])
+ expect(doc.css('a')[1].attr('href'))
+ .to eq project_issue_path(project, issues[0])
expect(doc.css('a')[1].text).to eq issues[0].to_reference
# Internal commit link
@@ -77,8 +77,8 @@ describe MarkupHelper do
expect(doc.css('a')[2].text).to eq ' and '
# Second issue link
- expect(doc.css('a')[3].attr('href')).
- to eq namespace_project_issue_path(project.namespace, project, issues[1])
+ expect(doc.css('a')[3].attr('href'))
+ .to eq project_issue_path(project, issues[1])
expect(doc.css('a')[3].text).to eq issues[1].to_reference
# Trailing commit link
@@ -98,8 +98,8 @@ describe MarkupHelper do
it "escapes HTML passed in as the body" do
actual = "This is a <h1>test</h1> - see #{issues[0].to_reference}"
- expect(helper.link_to_gfm(actual, link)).
- to match('&lt;h1&gt;test&lt;/h1&gt;')
+ expect(helper.link_to_gfm(actual, link))
+ .to match('&lt;h1&gt;test&lt;/h1&gt;')
end
it 'ignores reference links when they are the entire body' do
@@ -110,8 +110,8 @@ describe MarkupHelper do
it 'replaces commit message with emoji to link' do
actual = link_to_gfm(':book: Book', '/foo')
- expect(actual).
- to eq '<gl-emoji data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo"> Book</a>'
+ expect(actual)
+ .to eq '<gl-emoji title="open book" data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo"> Book</a>'
end
end
@@ -210,11 +210,11 @@ describe MarkupHelper do
describe '#cross_project_reference' do
it 'shows the full MR reference' do
- expect(helper.cross_project_reference(project, merge_request)).to include(project.path_with_namespace)
+ expect(helper.cross_project_reference(project, merge_request)).to include(project.full_path)
end
it 'shows the full issue reference' do
- expect(helper.cross_project_reference(project, issue)).to include(project.path_with_namespace)
+ expect(helper.cross_project_reference(project, issue)).to include(project.full_path)
end
end
end
diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb
index 2b455571d52..33186cf50d5 100644
--- a/spec/helpers/members_helper_spec.rb
+++ b/spec/helpers/members_helper_spec.rb
@@ -11,7 +11,7 @@ describe MembersHelper do
describe '#remove_member_message' do
let(:requester) { create(:user) }
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:project_member) { build(:project_member, project: project) }
let(:project_member_invite) { build(:project_member, project: project).tap { |m| m.generate_invite_token! } }
let(:project_member_request) { project.request_access(requester) }
@@ -32,7 +32,7 @@ describe MembersHelper do
describe '#remove_member_title' do
let(:requester) { create(:user) }
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:project_member) { build(:project_member, project: project) }
let(:project_member_request) { project.request_access(requester) }
let(:group) { create(:group, :access_requestable) }
@@ -46,7 +46,7 @@ describe MembersHelper do
end
describe '#leave_confirmation_message' do
- let(:project) { build_stubbed(:empty_project) }
+ let(:project) { build_stubbed(:project) }
let(:group) { build_stubbed(:group) }
let(:user) { build_stubbed(:user) }
diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb
index e9037749ef2..7d1c17909bf 100644
--- a/spec/helpers/merge_requests_helper_spec.rb
+++ b/spec/helpers/merge_requests_helper_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe MergeRequestsHelper do
describe 'ci_build_details_path' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:merge_request) { MergeRequest.new }
let(:ci_service) { CiService.new }
let(:last_commit) { Ci::Pipeline.new({}) }
@@ -15,61 +15,12 @@ describe MergeRequestsHelper do
end
it 'does not include api credentials in a link' do
- allow(ci_service).
- to receive(:build_page).and_return("http://secretuser:secretpass@jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c")
+ allow(ci_service)
+ .to receive(:build_page).and_return("http://secretuser:secretpass@jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c")
expect(helper.ci_build_details_path(merge_request)).not_to match("secret")
end
end
- describe '#issues_sentence' do
- let(:project) { create :project }
-
- subject { issues_sentence(issues) }
- let(:issues) do
- [build(:issue, iid: 2, project: project),
- build(:issue, iid: 3, project: project),
- build(:issue, iid: 1, project: project)]
- end
-
- it do
- @project = project
-
- is_expected.to eq('#1, #2, and #3')
- end
-
- context 'for JIRA issues' do
- let(:project) { create(:empty_project) }
- let(:issues) do
- [
- ExternalIssue.new('JIRA-456', project),
- ExternalIssue.new('FOOBAR-7890', project),
- ExternalIssue.new('JIRA-123', project)
- ]
- end
-
- it do
- @project = project
- is_expected.to eq('FOOBAR-7890, JIRA-123, and JIRA-456')
- end
- end
-
- context 'for issues from multiple namespaces' do
- let(:project) { create(:project) }
- let(:other_project) { create(:project) }
- let(:issues) do
- [build(:issue, iid: 2, project: project),
- build(:issue, iid: 3, project: other_project),
- build(:issue, iid: 1, project: project)]
- end
-
- it do
- @project = project
-
- is_expected.to eq("#1, #2, and #{other_project.namespace.path}/#{other_project.path}#3")
- end
- end
- end
-
describe '#format_mr_branch_names' do
describe 'within the same project' do
let(:merge_request) { create(:merge_request) }
@@ -79,113 +30,14 @@ describe MergeRequestsHelper do
end
describe 'within different projects' do
- let(:project) { create(:empty_project) }
- let(:fork_project) { create(:empty_project, forked_from_project: project) }
+ let(:project) { create(:project) }
+ let(:fork_project) { create(:project, forked_from_project: project) }
let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: project) }
subject { format_mr_branch_names(merge_request) }
- let(:source_title) { "#{fork_project.path_with_namespace}:#{merge_request.source_branch}" }
- let(:target_title) { "#{project.path_with_namespace}:#{merge_request.target_branch}" }
+ let(:source_title) { "#{fork_project.full_path}:#{merge_request.source_branch}" }
+ let(:target_title) { "#{project.full_path}:#{merge_request.target_branch}" }
it { is_expected.to eq([source_title, target_title]) }
end
end
-
- describe '#mr_widget_refresh_url' do
- let(:guest) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:project_fork) { Projects::ForkService.new(project, guest).execute }
- let(:merge_request) { create(:merge_request, source_project: project_fork, target_project: project) }
-
- it 'returns correct url for MR' do
- expected_url = "#{project.path_with_namespace}/merge_requests/#{merge_request.iid}/merge_widget_refresh"
-
- expect(mr_widget_refresh_url(merge_request)).to end_with(expected_url)
- end
-
- it 'returns empty string for nil' do
- expect(mr_widget_refresh_url(nil)).to eq('')
- end
- end
-
- describe '#mr_closes_issues' do
- let(:user_1) { create(:user) }
- let(:user_2) { create(:user) }
-
- let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) }
- let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) }
-
- let(:issue_1) { create(:issue, project: project_1) }
- let(:issue_2) { create(:issue, project: project_2) }
-
- let(:merge_request) { create(:merge_request, source_project: project_1, target_project: project_1,) }
-
- let(:merge_request) do
- create(:merge_request,
- source_project: project_1, target_project: project_1,
- description: "Fixes #{issue_1.to_reference} Fixes #{issue_2.to_reference(project_1)}")
- end
-
- before do
- project_1.team << [user_2, :developer]
- project_2.team << [user_2, :developer]
- allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch)
- @merge_request = merge_request
- end
-
- context 'user without access to another private project' do
- let(:current_user) { user_1 }
-
- it 'cannot see that project\'s issue that will be closed on acceptance' do
- expect(mr_closes_issues).to contain_exactly(issue_1)
- end
- end
-
- context 'user with access to another private project' do
- let(:current_user) { user_2 }
-
- it 'can see that project\'s issue that will be closed on acceptance' do
- expect(mr_closes_issues).to contain_exactly(issue_1, issue_2)
- end
- end
- end
-
- describe '#mr_issues_mentioned_but_not_closing' do
- let(:user_1) { create(:user) }
- let(:user_2) { create(:user) }
-
- let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) }
- let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) }
-
- let(:issue_1) { create(:issue, project: project_1) }
- let(:issue_2) { create(:issue, project: project_2) }
-
- let(:merge_request) do
- create(:merge_request,
- source_project: project_1, target_project: project_1,
- description: "#{issue_1.to_reference} #{issue_2.to_reference(project_1)}")
- end
-
- before do
- project_1.team << [user_2, :developer]
- project_2.team << [user_2, :developer]
- allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch)
- @merge_request = merge_request
- end
-
- context 'user without access to another private project' do
- let(:current_user) { user_1 }
-
- it 'cannot see that project\'s issue that will be closed on acceptance' do
- expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1)
- end
- end
-
- context 'user with access to another private project' do
- let(:current_user) { user_2 }
-
- it 'can see that project\'s issue that will be closed on acceptance' do
- expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1, issue_2)
- end
- end
- end
end
diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb
index 3cb809d42b5..70b4a89cb86 100644
--- a/spec/helpers/milestones_helper_spec.rb
+++ b/spec/helpers/milestones_helper_spec.rb
@@ -1,6 +1,42 @@
require 'spec_helper'
describe MilestonesHelper do
+ describe '#milestones_filter_dropdown_path' do
+ let(:project) { create(:project) }
+ let(:project2) { create(:project) }
+ let(:group) { create(:group) }
+
+ context 'when @project present' do
+ it 'returns project milestones JSON URL' do
+ assign(:project, project)
+
+ expect(helper.milestones_filter_dropdown_path).to eq(project_milestones_path(project, :json))
+ end
+ end
+
+ context 'when @target_project present' do
+ it 'returns targeted project milestones JSON URL' do
+ assign(:target_project, project2)
+
+ expect(helper.milestones_filter_dropdown_path).to eq(project_milestones_path(project2, :json))
+ end
+ end
+
+ context 'when @group present' do
+ it 'returns group milestones JSON URL' do
+ assign(:group, group)
+
+ expect(helper.milestones_filter_dropdown_path).to eq(group_milestones_path(group, :json))
+ end
+ end
+
+ context 'when neither of @project/@target_project/@group present' do
+ it 'returns dashboard milestones JSON URL' do
+ expect(helper.milestones_filter_dropdown_path).to eq(dashboard_milestones_path(:json))
+ end
+ end
+ end
+
describe "#milestone_date_range" do
def result_for(*args)
milestone_date_range(build(:milestone, *args))
@@ -21,7 +57,7 @@ describe MilestonesHelper do
end
describe '#milestone_counts' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:counts) { helper.milestone_counts(project.milestones) }
context 'when there are milestones' do
diff --git a/spec/helpers/milestones_routing_helper_spec.rb b/spec/helpers/milestones_routing_helper_spec.rb
new file mode 100644
index 00000000000..dc13a43c2ab
--- /dev/null
+++ b/spec/helpers/milestones_routing_helper_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe MilestonesRoutingHelper do
+ let(:project) { build_stubbed(:project) }
+ let(:group) { build_stubbed(:group) }
+
+ describe '#milestone_path' do
+ context 'for a group milestone' do
+ let(:milestone) { build_stubbed(:milestone, group: group, iid: 1) }
+
+ it 'links to the group milestone page' do
+ expect(milestone_path(milestone))
+ .to eq(group_milestone_path(group, milestone))
+ end
+ end
+
+ context 'for a project milestone' do
+ let(:milestone) { build_stubbed(:milestone, project: project, iid: 1) }
+
+ it 'links to the project milestone page' do
+ expect(milestone_path(milestone))
+ .to eq(project_milestone_path(project, milestone))
+ end
+ end
+ end
+
+ describe '#milestone_url' do
+ context 'for a group milestone' do
+ let(:milestone) { build_stubbed(:milestone, group: group, iid: 1) }
+
+ it 'links to the group milestone page' do
+ expect(milestone_url(milestone))
+ .to eq(group_milestone_url(group, milestone))
+ end
+ end
+
+ context 'for a project milestone' do
+ let(:milestone) { build_stubbed(:milestone, project: project, iid: 1) }
+
+ it 'links to the project milestone page' do
+ expect(milestone_url(milestone))
+ .to eq(project_milestone_url(project, milestone))
+ end
+ end
+ end
+end
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index e5143a0263d..8365b3f5538 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe NamespacesHelper, type: :helper do
+describe NamespacesHelper do
let!(:admin) { create(:admin) }
let!(:admin_group) { create(:group, :private) }
let!(:user) { create(:user) }
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index a427de32c4c..9921ca1af33 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -1,9 +1,11 @@
require "spec_helper"
describe NotesHelper do
+ include RepoHelpers
+
let(:owner) { create(:owner) }
let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
let(:master) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
@@ -28,7 +30,7 @@ describe NotesHelper do
end
it 'handles access in different projects' do
- second_project = create(:empty_project)
+ second_project = create(:project)
second_project.team << [master, :reporter]
other_note = create(:note, author: master, project: second_project)
@@ -36,4 +38,232 @@ describe NotesHelper do
expect(helper.note_max_access_for_user(other_note)).to eq('Reporter')
end
end
+
+ describe '#discussion_path' do
+ let(:project) { create(:project, :repository) }
+
+ context 'for a merge request discusion' do
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) }
+ let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
+
+ context 'for a diff discussion' do
+ context 'when the discussion is active' do
+ let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+
+ it 'returns the diff path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(diffs_project_merge_request_path(project, merge_request, anchor: discussion.line_code))
+ end
+ end
+
+ context 'when the discussion is on an older merge request version' do
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: nil,
+ new_line: 4,
+ diff_refs: merge_request_diff1.diff_refs
+ )
+ end
+
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) }
+ let(:discussion) { diff_note.to_discussion }
+
+ before do
+ diff_note.position = diff_note.original_position
+ diff_note.save!
+ end
+
+ it 'returns the diff version path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(diffs_project_merge_request_path(project, merge_request, diff_id: merge_request_diff1, anchor: discussion.line_code))
+ end
+ end
+
+ context 'when the discussion is on a comparison between merge request versions' do
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: 4,
+ new_line: 4,
+ diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs
+ )
+ end
+
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) }
+ let(:discussion) { diff_note.to_discussion }
+
+ before do
+ diff_note.position = diff_note.original_position
+ diff_note.save!
+ end
+
+ it 'returns the diff version comparison path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(diffs_project_merge_request_path(project, merge_request, diff_id: merge_request_diff3, start_sha: merge_request_diff1.head_commit_sha, anchor: discussion.line_code))
+ end
+ end
+
+ context 'when the discussion does not have a merge request version' do
+ let(:outdated_diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) }
+ let(:discussion) { outdated_diff_note.to_discussion }
+
+ before do
+ outdated_diff_note.position = outdated_diff_note.original_position
+ outdated_diff_note.save!
+ end
+
+ it 'returns nil' do
+ expect(helper.discussion_path(discussion)).to be_nil
+ end
+ end
+ end
+
+ context 'for a legacy diff discussion' do
+ let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+
+ context 'when the discussion is active' do
+ before do
+ allow(discussion).to receive(:active?).and_return(true)
+ end
+
+ it 'returns the diff path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(diffs_project_merge_request_path(project, merge_request, anchor: discussion.line_code))
+ end
+ end
+
+ context 'when the discussion is outdated' do
+ before do
+ allow(discussion).to receive(:active?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(helper.discussion_path(discussion)).to be_nil
+ end
+ end
+ end
+
+ context 'for a non-diff discussion' do
+ let(:discussion) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+
+ it 'returns nil' do
+ expect(helper.discussion_path(discussion)).to be_nil
+ end
+ end
+ end
+
+ context 'for a commit discussion' do
+ let(:commit) { discussion.noteable }
+
+ context 'for a diff discussion' do
+ let(:discussion) { create(:diff_note_on_commit, project: project).to_discussion }
+
+ it 'returns the commit path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(project_commit_path(project, commit, anchor: discussion.line_code))
+ end
+ end
+
+ context 'for a legacy diff discussion' do
+ let(:discussion) { create(:legacy_diff_note_on_commit, project: project).to_discussion }
+
+ it 'returns the commit path with the line code' do
+ expect(helper.discussion_path(discussion)).to eq(project_commit_path(project, commit, anchor: discussion.line_code))
+ end
+ end
+
+ context 'for a non-diff discussion' do
+ let(:discussion) { create(:discussion_note_on_commit, project: project).to_discussion }
+
+ it 'returns the commit path' do
+ expect(helper.discussion_path(discussion)).to eq(project_commit_path(project, commit))
+ end
+ end
+ end
+ end
+
+ describe '#notes_url' do
+ it 'return snippet notes path for personal snippet' do
+ @snippet = create(:personal_snippet)
+
+ expect(helper.notes_url).to eq("/snippets/#{@snippet.id}/notes")
+ end
+
+ it 'return project notes path for project snippet' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ @snippet = create(:project_snippet, project: @project)
+ @noteable = @snippet
+
+ expect(helper.notes_url).to eq("/nm/test/noteable/project_snippet/#{@noteable.id}/notes")
+ end
+
+ it 'return project notes path for other noteables' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ @noteable = create(:issue, project: @project)
+
+ expect(helper.notes_url).to eq("/nm/test/noteable/issue/#{@noteable.id}/notes")
+ end
+ end
+
+ describe '#note_url' do
+ it 'return snippet notes path for personal snippet' do
+ note = create(:note_on_personal_snippet)
+
+ expect(helper.note_url(note)).to eq("/snippets/#{note.noteable.id}/notes/#{note.id}")
+ end
+
+ it 'return project notes path for project snippet' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ note = create(:note_on_project_snippet, project: @project)
+
+ expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}")
+ end
+
+ it 'return project notes path for other noteables' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ note = create(:note_on_issue, project: @project)
+
+ expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}")
+ end
+ end
+
+ describe '#form_resurces' do
+ it 'returns note for personal snippet' do
+ @snippet = create(:personal_snippet)
+ @note = create(:note_on_personal_snippet)
+
+ expect(helper.form_resources).to eq([@note])
+ end
+
+ it 'returns namespace, project and note for project snippet' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ @snippet = create(:project_snippet, project: @project)
+ @note = create(:note_on_personal_snippet)
+
+ expect(helper.form_resources).to eq([@project.namespace, @project, @note])
+ end
+
+ it 'returns namespace, project and note path for other noteables' do
+ namespace = create(:namespace, path: 'nm')
+ @project = create(:project, path: 'test', namespace: namespace)
+ @note = create(:note_on_issue, project: @project)
+
+ expect(helper.form_resources).to eq([@project.namespace, @project, @note])
+ end
+ end
+
+ describe '#noteable_note_url' do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:note) { create(:note_on_issue, noteable: issue, project: project) }
+
+ it 'returns the noteable url with an anchor to the note' do
+ expect(noteable_note_url(note)).to match("/#{project.namespace.path}/#{project.path}/issues/#{issue.iid}##{dom_id(note)}")
+ end
+ end
end
diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb
index 9d5f009ebe1..9ecaabc04ed 100644
--- a/spec/helpers/notifications_helper_spec.rb
+++ b/spec/helpers/notifications_helper_spec.rb
@@ -12,5 +12,11 @@ describe NotificationsHelper do
describe 'notification_title' do
it { expect(notification_title(:watch)).to match('Watch') }
it { expect(notification_title(:mention)).to match('On mention') }
+ it { expect(notification_title(:global)).to match('Global') }
+ end
+
+ describe '#notification_event_name' do
+ it { expect(notification_event_name(:success_pipeline)).to match('Successful pipeline') }
+ it { expect(notification_event_name(:failed_pipeline)).to match('Failed pipeline') }
end
end
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index 2cc0b40b2d0..9aca3987657 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -60,7 +60,7 @@ describe PageLayoutHelper do
%w(project user group).each do |type|
context "with @#{type} assigned" do
it "uses #{type.titlecase} avatar if available" do
- object = double(avatar_url: 'http://example.com/uploads/avatar.png')
+ object = double(avatar_url: 'http://example.com/uploads/-/system/avatar.png')
assign(type, object)
expect(helper.page_image).to eq object.avatar_url
@@ -86,8 +86,8 @@ describe PageLayoutHelper do
it 'raises ArgumentError when given more than two attributes' do
map = { foo: 'foo', bar: 'bar', baz: 'baz' }
- expect { helper.page_card_attributes(map) }.
- to raise_error(ArgumentError, /more than two attributes/)
+ expect { helper.page_card_attributes(map) }
+ .to raise_error(ArgumentError, /more than two attributes/)
end
it 'rejects blank values' do
diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb
new file mode 100644
index 00000000000..e235475fb47
--- /dev/null
+++ b/spec/helpers/pagination_helper_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe PaginationHelper do
+ describe '#paginate_collection' do
+ let(:collection) { User.all.page(1) }
+
+ it 'paginates a collection without using a COUNT' do
+ without_count = collection.without_count
+
+ expect(helper).to receive(:paginate_without_count)
+ .with(without_count)
+ .and_call_original
+
+ helper.paginate_collection(without_count)
+ end
+
+ it 'paginates a collection using a COUNT' do
+ expect(helper).to receive(:paginate_with_count).and_call_original
+
+ helper.paginate_collection(collection)
+ end
+ end
+end
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index 2c0e9975f73..a04c87b08eb 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -29,15 +29,15 @@ describe PreferencesHelper do
describe 'user_color_scheme' do
context 'with a user' do
it "returns user's scheme's css_class" do
- allow(helper).to receive(:current_user).
- and_return(double(color_scheme_id: 3))
+ allow(helper).to receive(:current_user)
+ .and_return(double(color_scheme_id: 3))
expect(helper.user_color_scheme).to eq 'solarized-light'
end
it 'returns the default when id is invalid' do
- allow(helper).to receive(:current_user).
- and_return(double(color_scheme_id: Gitlab::ColorSchemes.count + 5))
+ allow(helper).to receive(:current_user)
+ .and_return(double(color_scheme_id: Gitlab::ColorSchemes.count + 5))
end
end
@@ -45,8 +45,8 @@ describe PreferencesHelper do
it 'returns the default theme' do
stub_user
- expect(helper.user_color_scheme).
- to eq Gitlab::ColorSchemes.default.css_class
+ expect(helper.user_color_scheme)
+ .to eq Gitlab::ColorSchemes.default.css_class
end
end
end
@@ -55,8 +55,8 @@ describe PreferencesHelper do
if messages.empty?
allow(helper).to receive(:current_user).and_return(nil)
else
- allow(helper).to receive(:current_user).
- and_return(double('user', messages))
+ allow(helper).to receive(:current_user)
+ .and_return(double('user', messages))
end
end
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
new file mode 100644
index 00000000000..b33b3f3a228
--- /dev/null
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe ProfilesHelper do
+ describe '#email_provider_label' do
+ it "returns nil for users without external email" do
+ user = create(:user)
+ allow(helper).to receive(:current_user).and_return(user)
+
+ expect(helper.email_provider_label).to be_nil
+ end
+
+ it "returns omniauth provider label for users with external email" do
+ stub_cas_omniauth_provider
+ cas_user = create(:omniauth_user, provider: 'cas3', external_email: true, email_provider: 'cas3')
+ allow(helper).to receive(:current_user).and_return(cas_user)
+
+ expect(helper.email_provider_label).to eq('CAS')
+ end
+
+ it "returns 'LDAP' for users with external email but no email provider" do
+ ldap_user = create(:omniauth_user, external_email: true)
+ allow(helper).to receive(:current_user).and_return(ldap_user)
+
+ expect(helper.email_provider_label).to eq('LDAP')
+ end
+ end
+
+ def stub_cas_omniauth_provider
+ provider = OpenStruct.new(
+ 'name' => 'cas3',
+ 'label' => 'CAS'
+ )
+
+ stub_omniauth_setting(providers: [provider])
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index be97973c693..d1efa318d14 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -46,28 +46,28 @@ describe ProjectsHelper do
end
describe "readme_cache_key" do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
helper.instance_variable_set(:@project, project)
end
it "returns a valid cach key" do
- expect(helper.send(:readme_cache_key)).to eq("#{project.path_with_namespace}-#{project.commit.id}-readme")
+ expect(helper.send(:readme_cache_key)).to eq("#{project.full_path}-#{project.commit.id}-readme")
end
it "returns a valid cache key if HEAD does not exist" do
allow(project).to receive(:commit) { nil }
- expect(helper.send(:readme_cache_key)).to eq("#{project.path_with_namespace}-nil-readme")
+ expect(helper.send(:readme_cache_key)).to eq("#{project.full_path}-nil-readme")
end
end
- describe "#project_list_cache_key", redis: true do
- let(:project) { create(:project) }
+ describe "#project_list_cache_key", clean_gitlab_redis_shared_state: true do
+ let(:project) { create(:project, :repository) }
- it "includes the namespace" do
- expect(helper.project_list_cache_key(project)).to include(project.namespace.cache_key)
+ it "includes the route" do
+ expect(helper.project_list_cache_key(project)).to include(project.route.cache_key)
end
it "includes the project" do
@@ -105,7 +105,7 @@ describe ProjectsHelper do
describe '#load_pipeline_status' do
it 'loads the pipeline status in batch' do
- project = build(:empty_project)
+ project = build(:project)
helper.load_pipeline_status([project])
# Skip lazy loading of the `pipeline_status` attribute
@@ -115,9 +115,85 @@ describe ProjectsHelper do
end
end
+ describe '#show_no_ssh_key_message?' do
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'user has no keys' do
+ it 'returns true' do
+ expect(helper.show_no_ssh_key_message?).to be_truthy
+ end
+ end
+
+ context 'user has an ssh key' do
+ it 'returns false' do
+ create(:personal_key, user: user)
+
+ expect(helper.show_no_ssh_key_message?).to be_falsey
+ end
+ end
+ end
+
+ describe '#show_no_password_message?' do
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'user has password set' do
+ it 'returns false' do
+ expect(helper.show_no_password_message?).to be_falsey
+ end
+ end
+
+ context 'user requires a password' do
+ let(:user) { create(:user, password_automatically_set: true) }
+
+ it 'returns true' do
+ expect(helper.show_no_password_message?).to be_truthy
+ end
+ end
+
+ context 'user requires a personal access token' do
+ it 'returns true' do
+ stub_application_setting(password_authentication_enabled?: false)
+
+ expect(helper.show_no_password_message?).to be_truthy
+ end
+ end
+ end
+
+ describe '#link_to_set_password' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'user requires a password' do
+ let(:user) { create(:user, password_automatically_set: true) }
+
+ it 'returns link to set a password' do
+ expect(helper.link_to_set_password).to match %r{<a href="#{edit_profile_password_path}">set a password</a>}
+ end
+ end
+
+ context 'user requires a personal access token' do
+ let(:user) { create(:user) }
+
+ it 'returns link to create a personal access token' do
+ stub_application_setting(password_authentication_enabled?: false)
+
+ expect(helper.link_to_set_password).to match %r{<a href="#{profile_personal_access_tokens_path}">create a personal access token</a>}
+ end
+ end
+ end
+
describe 'link_to_member' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
describe 'using the default options' do
@@ -149,7 +225,7 @@ describe ProjectsHelper do
end
describe '#license_short_name' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when project.repository has a license_key' do
it 'returns the nickname of the license if present' do
@@ -175,7 +251,7 @@ describe ProjectsHelper do
end
describe '#sanitized_import_error' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
allow(project).to receive(:repository_storage_path).and_return('/base/repo/path')
@@ -236,7 +312,7 @@ describe ProjectsHelper do
end
describe "#project_feature_access_select" do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
context "when project is internal or public" do
@@ -250,14 +326,16 @@ describe ProjectsHelper do
end
context "when project is private" do
- before { project.update_attributes(visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+ before do
+ project.update_attributes(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
it "shows only allowed options" do
helper.instance_variable_set(:@project, project)
result = helper.project_feature_access_select(:issues_access_level)
expect(result).to include("Disabled")
expect(result).to include("Only team members")
- expect(result).not_to include("Everyone with access")
+ expect(result).to have_selector('option[disabled]', text: "Everyone with access")
end
end
@@ -272,7 +350,7 @@ describe ProjectsHelper do
expect(result).to include("Disabled")
expect(result).to include("Only team members")
- expect(result).not_to include("Everyone with access")
+ expect(result).to have_selector('option[disabled]', text: "Everyone with access")
expect(result).to have_selector('option[selected]', text: "Only team members")
end
end
@@ -300,4 +378,87 @@ describe ProjectsHelper do
expect(helper.send(:visibility_select_options, project, Gitlab::VisibilityLevel::PRIVATE)).to include('Private')
end
end
+
+ describe '#get_project_nav_tabs' do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:can?) { true }
+ end
+
+ subject do
+ helper.send(:get_project_nav_tabs, project, user)
+ end
+
+ context 'when builds feature is enabled' do
+ before do
+ allow(project).to receive(:builds_enabled?).and_return(true)
+ end
+
+ it "does include pipelines tab" do
+ is_expected.to include(:pipelines)
+ end
+ end
+
+ context 'when builds feature is disabled' do
+ before do
+ allow(project).to receive(:builds_enabled?).and_return(false)
+ end
+
+ it "do not include pipelines tab" do
+ is_expected.not_to include(:pipelines)
+ end
+ end
+ end
+
+ describe '#has_projects_or_name?' do
+ let(:projects) do
+ create(:project)
+ Project.all
+ end
+
+ it 'returns true when there are projects' do
+ expect(helper.has_projects_or_name?(projects, {})).to eq(true)
+ end
+
+ it 'returns true when there are no projects but a name is given' do
+ expect(helper.has_projects_or_name?(Project.none, name: 'foo')).to eq(true)
+ end
+
+ it 'returns false when there are no projects and there is no name' do
+ expect(helper.has_projects_or_name?(Project.none, {})).to eq(false)
+ end
+ end
+
+ describe '#any_projects?' do
+ let!(:project) { create(:project) }
+
+ it 'returns true when projects will be returned' do
+ expect(helper.any_projects?(Project.all)).to eq(true)
+ end
+
+ it 'returns false when no projects will be returned' do
+ expect(helper.any_projects?(Project.none)).to eq(false)
+ end
+
+ it 'returns true when using a non-empty Array' do
+ expect(helper.any_projects?([project])).to eq(true)
+ end
+
+ it 'returns false when using an empty Array' do
+ expect(helper.any_projects?([])).to eq(false)
+ end
+
+ it 'only executes a single query when a LIMIT is applied' do
+ relation = Project.limit(1)
+ recorder = ActiveRecord::QueryRecorder.new do
+ 2.times do
+ helper.any_projects?(relation)
+ end
+ end
+
+ expect(recorder.count).to eq(1)
+ end
+ end
end
diff --git a/spec/helpers/rss_helper_spec.rb b/spec/helpers/rss_helper_spec.rb
index f3f174f3d14..269e1057e8d 100644
--- a/spec/helpers/rss_helper_spec.rb
+++ b/spec/helpers/rss_helper_spec.rb
@@ -3,17 +3,17 @@ require 'spec_helper'
describe RssHelper do
describe '#rss_url_options' do
context 'when signed in' do
- it "includes the current_user's private_token" do
+ it "includes the current_user's rss_token" do
current_user = create(:user)
allow(helper).to receive(:current_user).and_return(current_user)
- expect(helper.rss_url_options).to include private_token: current_user.private_token
+ expect(helper.rss_url_options).to include rss_token: current_user.rss_token
end
end
context 'when signed out' do
- it "does not have a private_token" do
+ it "does not have an rss_token" do
allow(helper).to receive(:current_user).and_return(nil)
- expect(helper.rss_url_options[:private_token]).to be_nil
+ expect(helper.rss_url_options[:rss_token]).to be_nil
end
end
end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index b7e547dc1f5..463af15930d 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -47,7 +47,7 @@ describe SearchHelper do
end
it "includes the user's projects" do
- project = create(:empty_project, namespace: create(:namespace, owner: user))
+ project = create(:project, namespace: create(:namespace, owner: user))
expect(search_autocomplete_opts(project.name).size).to eq(1)
end
@@ -68,4 +68,38 @@ describe SearchHelper do
end
end
end
+
+ describe 'search_filter_input_options' do
+ context 'project' do
+ before do
+ @project = create(:project, :repository)
+ end
+
+ it 'includes id with type' do
+ expect(search_filter_input_options('type')[:id]).to eq('filtered-search-type')
+ end
+
+ it 'includes project-id' do
+ expect(search_filter_input_options('')[:data]['project-id']).to eq(@project.id)
+ end
+
+ it 'includes project base-endpoint' do
+ expect(search_filter_input_options('')[:data]['base-endpoint']).to eq(project_path(@project))
+ end
+ end
+
+ context 'group' do
+ before do
+ @group = create(:group, name: 'group')
+ end
+
+ it 'does not includes project-id' do
+ expect(search_filter_input_options('')[:data]['project-id']).to eq(nil)
+ end
+
+ it 'includes group base-endpoint' do
+ expect(search_filter_input_options('')[:data]['base-endpoint']).to eq("/groups#{group_path(@group)}")
+ end
+ end
+ end
end
diff --git a/spec/helpers/storage_health_helper_spec.rb b/spec/helpers/storage_health_helper_spec.rb
new file mode 100644
index 00000000000..874498e6338
--- /dev/null
+++ b/spec/helpers/storage_health_helper_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe StorageHealthHelper do
+ describe '#failing_storage_health_message' do
+ let(:health) do
+ Gitlab::Git::Storage::Health.new(
+ "<script>alert('storage name');)</script>",
+ []
+ )
+ end
+
+ it 'escapes storage names' do
+ escaped_storage_name = '&lt;script&gt;alert(&#39;storage name&#39;);)&lt;/script&gt;'
+
+ result = helper.failing_storage_health_message(health)
+
+ expect(result).to include(escaped_storage_name)
+ end
+ end
+end
diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb
index 345bc33a67b..c4f4e0d21dc 100644
--- a/spec/helpers/submodule_helper_spec.rb
+++ b/spec/helpers/submodule_helper_spec.rb
@@ -52,6 +52,14 @@ describe SubmoduleHelper do
stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git'].join(''))
expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')])
end
+
+ it 'works with subgroups' do
+ allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure
+ allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root')
+ allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
+ stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-ce.git'].join(''))
+ expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-ce'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-ce', 'hash')])
+ end
end
context 'submodule on github.com' do
@@ -81,6 +89,19 @@ describe SubmoduleHelper do
end
end
+ context 'in-repository submodule' do
+ let(:group) { create(:group, name: "Master Project", path: "master-project") }
+ let(:project) { create(:project, group: group) }
+ before do
+ self.instance_variable_set(:@project, project)
+ end
+
+ it 'in-repository' do
+ stub_url('./')
+ expect(submodule_links(submodule_item)).to eq(["/master-project/#{project.path}", "/master-project/#{project.path}/tree/hash"])
+ end
+ end
+
context 'submodule on gitlab.com' do
it 'detects ssh' do
stub_url('git@gitlab.com:gitlab-org/gitlab-ce.git')
@@ -102,6 +123,11 @@ describe SubmoduleHelper do
expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash'])
end
+ it 'handles urls with trailing whitespace' do
+ stub_url('http://gitlab.com/gitlab-org/gitlab-ce.git ')
+ expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash'])
+ end
+
it 'returns original with non-standard url' do
stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil])
@@ -109,6 +135,18 @@ describe SubmoduleHelper do
end
context 'submodule on unsupported' do
+ it 'sanitizes unsupported protocols' do
+ stub_url('javascript:alert("XSS");')
+
+ expect(helper.submodule_links(submodule_item)).to eq([nil, nil])
+ end
+
+ it 'sanitizes unsupported protocols disguised as a repository URL' do
+ stub_url('javascript:alert("XSS");foo/bar.git')
+
+ expect(helper.submodule_links(submodule_item)).to eq([nil, nil])
+ end
+
it 'returns original' do
stub_url('http://mygitserver.com/gitlab-org/gitlab-ce')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil])
@@ -120,7 +158,7 @@ describe SubmoduleHelper do
context 'submodules with relative links' do
let(:group) { create(:group, name: "Master Project", path: "master-project") }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
let(:commit_id) { sample_commit[:id] }
before do
@@ -132,6 +170,11 @@ describe SubmoduleHelper do
expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"])
end
+ it 'with trailing whitespace' do
+ result = relative_self_links('../test.git ', commit_id)
+ expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"])
+ end
+
it 'two levels down' do
result = relative_self_links('../../test.git', commit_id)
expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"])
@@ -149,7 +192,7 @@ describe SubmoduleHelper do
context 'personal project' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
it 'one level down with personal project' do
result = relative_self_links('../test.git', commit_id)
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index 50060a0925d..f55163c26e9 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -1,8 +1,21 @@
require "spec_helper"
describe TodosHelper do
+ describe '#todos_count_format' do
+ it 'shows fuzzy count for 100 or more items' do
+ expect(helper.todos_count_format(100)).to eq '99+'
+ expect(helper.todos_count_format(1000)).to eq '99+'
+ end
+
+ it 'shows exact count for 99 or fewer items' do
+ expect(helper.todos_count_format(99)).to eq '99'
+ expect(helper.todos_count_format(50)).to eq '50'
+ expect(helper.todos_count_format(1)).to eq '1'
+ end
+ end
+
describe '#todo_projects_options' do
- let(:projects) { create_list(:empty_project, 3) }
+ let(:projects) { create_list(:project, 3) }
let(:user) { create(:user) }
it 'returns users authorised projects in json format' do
diff --git a/spec/helpers/u2f_helper_spec.rb b/spec/helpers/u2f_helper_spec.rb
new file mode 100644
index 00000000000..0d65b4fe0b8
--- /dev/null
+++ b/spec/helpers/u2f_helper_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe U2fHelper do
+ describe 'when not on mobile' do
+ it 'does not inject u2f on chrome 40' do
+ device = double(mobile?: false)
+ browser = double(chrome?: true, opera?: false, version: 40, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq false
+ end
+
+ it 'injects u2f on chrome 41' do
+ device = double(mobile?: false)
+ browser = double(chrome?: true, opera?: false, version: 41, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq true
+ end
+
+ it 'does not inject u2f on opera 39' do
+ device = double(mobile?: false)
+ browser = double(chrome?: false, opera?: true, version: 39, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq false
+ end
+
+ it 'injects u2f on opera 40' do
+ device = double(mobile?: false)
+ browser = double(chrome?: false, opera?: true, version: 40, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq true
+ end
+ end
+
+ describe 'when on mobile' do
+ it 'does not inject u2f on chrome 41' do
+ device = double(mobile?: true)
+ browser = double(chrome?: true, opera?: false, version: 41, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq false
+ end
+
+ it 'does not inject u2f on opera 40' do
+ device = double(mobile?: true)
+ browser = double(chrome?: false, opera?: true, version: 40, device: device)
+ allow(helper).to receive(:browser).and_return(browser)
+ expect(helper.inject_u2f_api?).to eq false
+ end
+ end
+end
diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb
index 889fe441171..5eba03ef576 100644
--- a/spec/helpers/version_check_helper_spec.rb
+++ b/spec/helpers/version_check_helper_spec.rb
@@ -23,7 +23,7 @@ describe VersionCheckHelper do
end
it 'should have a js prefixed css class' do
- expect(@image_tag).to match(/class="js-version-status-badge"/)
+ expect(@image_tag).to match(/class="js-version-status-badge lazy"/)
end
it 'should have a VersionCheck url as the src' do
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index 8942b00b128..c3cccbb0d95 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe VisibilityLevelHelper do
- let(:project) { build(:empty_project) }
+ let(:project) { build(:project) }
let(:group) { build(:group) }
let(:personal_snippet) { build(:personal_snippet) }
let(:project_snippet) { build(:project_snippet) }
@@ -37,7 +37,7 @@ describe VisibilityLevelHelper do
it "describes public projects" do
expect(project_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC))
- .to eq "The project can be cloned without any authentication."
+ .to eq "The project can be accessed without any authentication."
end
end
@@ -60,8 +60,8 @@ describe VisibilityLevelHelper do
describe "skip_level?" do
describe "forks" do
- let(:project) { create(:empty_project, :internal) }
- let(:fork_project) { create(:empty_project, forked_from_project: project) }
+ let(:project) { create(:project, :internal) }
+ let(:fork_project) { create(:project, forked_from_project: project) }
it "skips levels" do
expect(skip_level?(fork_project, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
@@ -71,7 +71,7 @@ describe VisibilityLevelHelper do
end
describe "non-forked project" do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
it "skips levels" do
expect(skip_level?(project, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb
index 374517fec37..83283f03940 100644
--- a/spec/initializers/6_validations_spec.rb
+++ b/spec/initializers/6_validations_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../../config/initializers/6_validations.rb'
-describe '6_validations', lib: true do
+describe '6_validations' do
before :all do
FileUtils.mkdir_p('tmp/tests/paths/a/b/c/d')
FileUtils.mkdir_p('tmp/tests/paths/a/b/c2')
@@ -23,6 +23,16 @@ describe '6_validations', lib: true do
end
end
+ context 'when one of the settings is incorrect' do
+ before do
+ mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c', 'failure_count_threshold' => 'not a number' })
+ end
+
+ it 'throws an error' do
+ expect { validate_storages_config }.to raise_error(/failure_count_threshold/)
+ end
+ end
+
context 'with invalid storage names' do
before do
mock_storages('name with spaces' => { 'path' => 'tmp/tests/paths/a/b/c' })
@@ -84,6 +94,17 @@ describe '6_validations', lib: true do
expect { validate_storages_paths }.not_to raise_error
end
end
+
+ describe 'inaccessible storage' do
+ before do
+ mock_storages('foo' => { 'path' => 'tmp/tests/a/path/that/does/not/exist' })
+ end
+
+ it 'passes through with a warning' do
+ expect(Rails.logger).to receive(:error)
+ expect { validate_storages_paths }.not_to raise_error
+ end
+ end
end
def mock_storages(storages)
diff --git a/spec/initializers/8_metrics_spec.rb b/spec/initializers/8_metrics_spec.rb
index 570754621f3..4e6052a9f80 100644
--- a/spec/initializers/8_metrics_spec.rb
+++ b/spec/initializers/8_metrics_spec.rb
@@ -1,16 +1,25 @@
require 'spec_helper'
-require_relative '../../config/initializers/8_metrics'
-describe 'instrument_classes', lib: true do
+describe 'instrument_classes' do
let(:config) { double(:config) }
+ let(:unicorn_sampler) { double(:unicorn_sampler) }
+ let(:influx_sampler) { double(:influx_sampler) }
+
before do
allow(config).to receive(:instrument_method)
allow(config).to receive(:instrument_methods)
+ allow(config).to receive(:instrument_instance_method)
allow(config).to receive(:instrument_instance_methods)
+ allow(Gitlab::Metrics::UnicornSampler).to receive(:initialize_instance).and_return(unicorn_sampler)
+ allow(Gitlab::Metrics::InfluxSampler).to receive(:initialize_instance).and_return(influx_sampler)
+ allow(unicorn_sampler).to receive(:start)
+ allow(influx_sampler).to receive(:start)
+ allow(Gitlab::Application).to receive(:configure)
end
it 'can autoload and instrument all files' do
+ require_relative '../../config/initializers/8_metrics'
expect { instrument_classes(config) }.not_to raise_error
end
end
diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb
index 65c97da2efd..84ad55e9f98 100644
--- a/spec/initializers/secret_token_spec.rb
+++ b/spec/initializers/secret_token_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../../config/initializers/secret_token'
-describe 'create_tokens', lib: true do
+describe 'create_tokens' do
include StubENV
let(:secrets) { ActiveSupport::OrderedOptions.new }
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index 47b4e431823..e5ec90cb8f9 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -1,41 +1,52 @@
require 'spec_helper'
require_relative '../../config/initializers/1_settings'
-describe Settings, lib: true do
+describe Settings do
+ describe '#repositories' do
+ it 'assigns the default failure attributes' do
+ repository_settings = Gitlab.config.repositories.storages['broken']
+
+ expect(repository_settings['failure_count_threshold']).to eq(10)
+ expect(repository_settings['failure_wait_time']).to eq(30)
+ expect(repository_settings['failure_reset_time']).to eq(1800)
+ expect(repository_settings['storage_timeout']).to eq(5)
+ end
+ end
+
describe '#host_without_www' do
context 'URL with protocol' do
it 'returns the host' do
- expect(Settings.host_without_www('http://foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('http://www.foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('http://secure.foo.com')).to eq 'secure.foo.com'
- expect(Settings.host_without_www('http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
+ expect(described_class.host_without_www('http://foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('http://www.foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('http://secure.foo.com')).to eq 'secure.foo.com'
+ expect(described_class.host_without_www('http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
- expect(Settings.host_without_www('https://foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('https://www.foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('https://secure.foo.com')).to eq 'secure.foo.com'
- expect(Settings.host_without_www('https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'secure.gravatar.com'
+ expect(described_class.host_without_www('https://foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('https://www.foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('https://secure.foo.com')).to eq 'secure.foo.com'
+ expect(described_class.host_without_www('https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'secure.gravatar.com'
end
end
context 'URL without protocol' do
it 'returns the host' do
- expect(Settings.host_without_www('foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('www.foo.com')).to eq 'foo.com'
- expect(Settings.host_without_www('secure.foo.com')).to eq 'secure.foo.com'
- expect(Settings.host_without_www('www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
+ expect(described_class.host_without_www('foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('www.foo.com')).to eq 'foo.com'
+ expect(described_class.host_without_www('secure.foo.com')).to eq 'secure.foo.com'
+ expect(described_class.host_without_www('www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
end
context 'URL with user/port' do
it 'returns the host' do
- expect(Settings.host_without_www('bob:pass@foo.com:8080')).to eq 'foo.com'
- expect(Settings.host_without_www('bob:pass@www.foo.com:8080')).to eq 'foo.com'
- expect(Settings.host_without_www('bob:pass@secure.foo.com:8080')).to eq 'secure.foo.com'
- expect(Settings.host_without_www('bob:pass@www.gravatar.com:8080/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
+ expect(described_class.host_without_www('bob:pass@foo.com:8080')).to eq 'foo.com'
+ expect(described_class.host_without_www('bob:pass@www.foo.com:8080')).to eq 'foo.com'
+ expect(described_class.host_without_www('bob:pass@secure.foo.com:8080')).to eq 'secure.foo.com'
+ expect(described_class.host_without_www('bob:pass@www.gravatar.com:8080/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
- expect(Settings.host_without_www('http://bob:pass@foo.com:8080')).to eq 'foo.com'
- expect(Settings.host_without_www('http://bob:pass@www.foo.com:8080')).to eq 'foo.com'
- expect(Settings.host_without_www('http://bob:pass@secure.foo.com:8080')).to eq 'secure.foo.com'
- expect(Settings.host_without_www('http://bob:pass@www.gravatar.com:8080/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
+ expect(described_class.host_without_www('http://bob:pass@foo.com:8080')).to eq 'foo.com'
+ expect(described_class.host_without_www('http://bob:pass@www.foo.com:8080')).to eq 'foo.com'
+ expect(described_class.host_without_www('http://bob:pass@secure.foo.com:8080')).to eq 'secure.foo.com'
+ expect(described_class.host_without_www('http://bob:pass@www.gravatar.com:8080/avatar/%{hash}?s=%{size}&d=identicon')).to eq 'gravatar.com'
end
end
end
diff --git a/spec/initializers/trusted_proxies_spec.rb b/spec/initializers/trusted_proxies_spec.rb
index 70a18f31744..02a9446ad7b 100644
--- a/spec/initializers/trusted_proxies_spec.rb
+++ b/spec/initializers/trusted_proxies_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'trusted_proxies', lib: true do
+describe 'trusted_proxies' do
context 'with default config' do
before do
set_trusted_proxies([])
diff --git a/spec/javascripts/abuse_reports_spec.js b/spec/javascripts/abuse_reports_spec.js
index 76b370b345b..13cab81dd60 100644
--- a/spec/javascripts/abuse_reports_spec.js
+++ b/spec/javascripts/abuse_reports_spec.js
@@ -1,15 +1,15 @@
-require('~/lib/utils/text_utility');
-require('~/abuse_reports');
+import '~/lib/utils/text_utility';
+import '~/abuse_reports';
((global) => {
describe('Abuse Reports', () => {
const FIXTURE = 'abuse_reports/abuse_reports_list.html.raw';
const MAX_MESSAGE_LENGTH = 500;
- let messages;
+ let $messages;
const assertMaxLength = $message => expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH);
- const findMessage = searchText => messages.filter(
+ const findMessage = searchText => $messages.filter(
(index, element) => element.innerText.indexOf(searchText) > -1,
).first();
@@ -18,7 +18,7 @@ require('~/abuse_reports');
beforeEach(function () {
loadFixtures(FIXTURE);
this.abuseReports = new global.AbuseReports();
- messages = $('.abuse-reports .message');
+ $messages = $('.abuse-reports .message');
});
it('should truncate long messages', () => {
diff --git a/spec/javascripts/activities_spec.js b/spec/javascripts/activities_spec.js
index e6a6fc36ca1..e8c5f721423 100644
--- a/spec/javascripts/activities_spec.js
+++ b/spec/javascripts/activities_spec.js
@@ -1,8 +1,8 @@
/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */
-require('vendor/jquery.endless-scroll.js');
-require('~/pager');
-require('~/activities');
+import 'vendor/jquery.endless-scroll';
+import '~/pager';
+import '~/activities';
(() => {
window.gon || (window.gon = {});
diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js
index a68bccb16f4..46e072a8ebb 100644
--- a/spec/javascripts/ajax_loading_spinner_spec.js
+++ b/spec/javascripts/ajax_loading_spinner_spec.js
@@ -1,7 +1,6 @@
-require('~/extensions/array');
-require('jquery');
-require('jquery-ujs');
-require('~/ajax_loading_spinner');
+import 'jquery';
+import 'jquery-ujs';
+import '~/ajax_loading_spinner';
describe('Ajax Loading Spinner', () => {
const fixtureTemplate = 'static/ajax_loading_spinner.html.raw';
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
new file mode 100644
index 00000000000..867322ce8ae
--- /dev/null
+++ b/spec/javascripts/api_spec.js
@@ -0,0 +1,281 @@
+import Api from '~/api';
+
+describe('Api', () => {
+ const dummyApiVersion = 'v3000';
+ const dummyUrlRoot = 'http://host.invalid';
+ const dummyGon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
+ const dummyResponse = 'hello from outer space!';
+ const sendDummyResponse = () => {
+ const deferred = $.Deferred();
+ deferred.resolve(dummyResponse);
+ return deferred.promise();
+ };
+ let originalGon;
+
+ beforeEach(() => {
+ originalGon = window.gon;
+ window.gon = dummyGon;
+ });
+
+ afterEach(() => {
+ window.gon = originalGon;
+ });
+
+ describe('buildUrl', () => {
+ it('adds URL root and fills in API version', () => {
+ const input = '/api/:version/foo/bar';
+ const expectedOutput = `${dummyUrlRoot}/api/${dummyApiVersion}/foo/bar`;
+
+ const builtUrl = Api.buildUrl(input);
+
+ expect(builtUrl).toEqual(expectedOutput);
+ });
+ });
+
+ describe('group', () => {
+ it('fetches a group', (done) => {
+ const groupId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}.json`;
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ return sendDummyResponse();
+ });
+
+ Api.group(groupId, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('groups', () => {
+ it('fetches groups', (done) => {
+ const query = 'dummy query';
+ const options = { unused: 'option' };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`;
+ const expectedData = Object.assign({
+ search: query,
+ per_page: 20,
+ }, options);
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.groups(query, options, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('namespaces', () => {
+ it('fetches namespaces', (done) => {
+ const query = 'dummy query';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`;
+ const expectedData = {
+ search: query,
+ per_page: 20,
+ };
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.namespaces(query, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('projects', () => {
+ it('fetches projects', (done) => {
+ const query = 'dummy query';
+ const options = { unused: 'option' };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`;
+ const expectedData = Object.assign({
+ search: query,
+ per_page: 20,
+ membership: true,
+ }, options);
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.projects(query, options, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('newLabel', () => {
+ it('creates a new label', (done) => {
+ const namespace = 'some namespace';
+ const project = 'some project';
+ const labelData = { some: 'data' };
+ const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/labels`;
+ const expectedData = {
+ label: labelData,
+ };
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.type).toEqual('POST');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.newLabel(namespace, project, labelData, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('groupProjects', () => {
+ it('fetches group projects', (done) => {
+ const groupId = '123456';
+ const query = 'dummy query';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`;
+ const expectedData = {
+ search: query,
+ per_page: 20,
+ };
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.groupProjects(groupId, query, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('licenseText', () => {
+ it('fetches a license text', (done) => {
+ const licenseKey = "driver's license";
+ const data = { unused: 'option' };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/licenses/${licenseKey}`;
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.data).toEqual(data);
+ return sendDummyResponse();
+ });
+
+ Api.licenseText(licenseKey, data, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('gitignoreText', () => {
+ it('fetches a gitignore text', (done) => {
+ const gitignoreKey = 'ignore git';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitignores/${gitignoreKey}`;
+ spyOn(jQuery, 'get').and.callFake((url, callback) => {
+ expect(url).toEqual(expectedUrl);
+ callback(dummyResponse);
+ });
+
+ Api.gitignoreText(gitignoreKey, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('gitlabCiYml', () => {
+ it('fetches a .gitlab-ci.yml', (done) => {
+ const gitlabCiYmlKey = 'Y CI ML';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitlab_ci_ymls/${gitlabCiYmlKey}`;
+ spyOn(jQuery, 'get').and.callFake((url, callback) => {
+ expect(url).toEqual(expectedUrl);
+ callback(dummyResponse);
+ });
+
+ Api.gitlabCiYml(gitlabCiYmlKey, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('dockerfileYml', () => {
+ it('fetches a Dockerfile', (done) => {
+ const dockerfileYmlKey = 'a giant whale';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/dockerfiles/${dockerfileYmlKey}`;
+ spyOn(jQuery, 'get').and.callFake((url, callback) => {
+ expect(url).toEqual(expectedUrl);
+ callback(dummyResponse);
+ });
+
+ Api.dockerfileYml(dockerfileYmlKey, (response) => {
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('issueTemplate', () => {
+ it('fetches an issue template', (done) => {
+ const namespace = 'some namespace';
+ const project = 'some project';
+ const templateKey = 'template key';
+ const templateType = 'template type';
+ const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${templateKey}`;
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ return sendDummyResponse();
+ });
+
+ Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => {
+ expect(error).toBe(null);
+ expect(response).toBe(dummyResponse);
+ done();
+ });
+ });
+ });
+
+ describe('users', () => {
+ it('fetches users', (done) => {
+ const query = 'dummy query';
+ const options = { unused: 'option' };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`;
+ const expectedData = Object.assign({
+ search: query,
+ per_page: 20,
+ }, options);
+ spyOn(jQuery, 'ajax').and.callFake((request) => {
+ expect(request.url).toEqual(expectedUrl);
+ expect(request.dataType).toEqual('json');
+ expect(request.data).toEqual(expectedData);
+ return sendDummyResponse();
+ });
+
+ Api.users(query, options)
+ .then((response) => {
+ expect(response).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js
new file mode 100644
index 00000000000..9f9acc392c2
--- /dev/null
+++ b/spec/javascripts/autosave_spec.js
@@ -0,0 +1,134 @@
+import Autosave from '~/autosave';
+import AccessorUtilities from '~/lib/utils/accessor';
+
+describe('Autosave', () => {
+ let autosave;
+
+ describe('class constructor', () => {
+ const key = 'key';
+ const field = jasmine.createSpyObj('field', ['data', 'on']);
+
+ beforeEach(() => {
+ spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
+ spyOn(Autosave.prototype, 'restore');
+
+ autosave = new Autosave(field, key);
+ });
+
+ it('should set .isLocalStorageAvailable', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(autosave.isLocalStorageAvailable).toBe(true);
+ });
+ });
+
+ describe('restore', () => {
+ const key = 'key';
+ const field = jasmine.createSpyObj('field', ['trigger']);
+
+ beforeEach(() => {
+ autosave = {
+ field,
+ key,
+ };
+
+ spyOn(window.localStorage, 'getItem');
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = false;
+
+ Autosave.prototype.restore.call(autosave);
+ });
+
+ it('should not call .getItem', () => {
+ expect(window.localStorage.getItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = true;
+
+ Autosave.prototype.restore.call(autosave);
+ });
+
+ it('should call .getItem', () => {
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(key);
+ });
+ });
+ });
+
+ describe('save', () => {
+ const field = jasmine.createSpyObj('field', ['val']);
+
+ beforeEach(() => {
+ autosave = jasmine.createSpyObj('autosave', ['reset']);
+ autosave.field = field;
+
+ field.val.and.returnValue('value');
+
+ spyOn(window.localStorage, 'setItem');
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = false;
+
+ Autosave.prototype.save.call(autosave);
+ });
+
+ it('should not call .setItem', () => {
+ expect(window.localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = true;
+
+ Autosave.prototype.save.call(autosave);
+ });
+
+ it('should call .setItem', () => {
+ expect(window.localStorage.setItem).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('reset', () => {
+ const key = 'key';
+
+ beforeEach(() => {
+ autosave = {
+ key,
+ };
+
+ spyOn(window.localStorage, 'removeItem');
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = false;
+
+ Autosave.prototype.reset.call(autosave);
+ });
+
+ it('should not call .removeItem', () => {
+ expect(window.localStorage.removeItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = true;
+
+ Autosave.prototype.reset.call(autosave);
+ });
+
+ it('should call .removeItem', () => {
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(key);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 68ad5f66676..8e056882108 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -1,9 +1,9 @@
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */
import Cookies from 'js-cookie';
-import AwardsHandler from '~/awards_handler';
+import loadAwardsHandler from '~/awards_handler';
-require('~/lib/utils/common_utils');
+import '~/lib/utils/common_utils';
(function() {
var awardsHandler, lazyAssert, urlRoot, openAndWaitForEmojiMenu;
@@ -26,14 +26,13 @@ require('~/lib/utils/common_utils');
describe('AwardsHandler', function() {
preloadFixtures('issues/issue_with_comment.html.raw');
- beforeEach(function() {
+ beforeEach(function(done) {
loadFixtures('issues/issue_with_comment.html.raw');
- awardsHandler = new AwardsHandler;
- spyOn(awardsHandler, 'postEmoji').and.callFake((function(_this) {
- return function(button, url, emoji, cb) {
- return cb();
- };
- })(this));
+ loadAwardsHandler(true).then((obj) => {
+ awardsHandler = obj;
+ spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
+ done();
+ }).catch(fail);
let isEmojiMenuBuilt = false;
openAndWaitForEmojiMenu = function() {
diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js
index 3deaf258cae..67afba19190 100644
--- a/spec/javascripts/behaviors/autosize_spec.js
+++ b/spec/javascripts/behaviors/autosize_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable space-before-function-paren, no-var, comma-dangle, no-return-assign, max-len */
-require('~/behaviors/autosize');
+import '~/behaviors/autosize';
(function() {
describe('Autosize behavior', function() {
diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js
index dd9ab33289f..5ff66167718 100644
--- a/spec/javascripts/behaviors/bind_in_out_spec.js
+++ b/spec/javascripts/behaviors/bind_in_out_spec.js
@@ -2,7 +2,7 @@ import BindInOut from '~/behaviors/bind_in_out';
import ClassSpecHelper from '../helpers/class_spec_helper';
describe('BindInOut', function () {
- describe('.constructor', function () {
+ describe('constructor', function () {
beforeEach(function () {
this.in = {};
this.out = {};
@@ -53,7 +53,7 @@ describe('BindInOut', function () {
});
});
- describe('.addEvents', function () {
+ describe('addEvents', function () {
beforeEach(function () {
this.in = jasmine.createSpyObj('in', ['addEventListener']);
@@ -79,7 +79,7 @@ describe('BindInOut', function () {
});
});
- describe('.updateOut', function () {
+ describe('updateOut', function () {
beforeEach(function () {
this.in = { value: 'the-value' };
this.out = { textContent: 'not-the-value' };
@@ -98,7 +98,7 @@ describe('BindInOut', function () {
});
});
- describe('.removeEvents', function () {
+ describe('removeEvents', function () {
beforeEach(function () {
this.in = jasmine.createSpyObj('in', ['removeEventListener']);
this.updateOut = () => {};
@@ -122,7 +122,7 @@ describe('BindInOut', function () {
});
});
- describe('.initAll', function () {
+ describe('initAll', function () {
beforeEach(function () {
this.ins = [0, 1, 2];
this.instances = [];
@@ -153,7 +153,7 @@ describe('BindInOut', function () {
});
});
- describe('.init', function () {
+ describe('init', function () {
beforeEach(function () {
spyOn(BindInOut.prototype, 'addEvents').and.callFake(function () { return this; });
spyOn(BindInOut.prototype, 'updateOut').and.callFake(function () { return this; });
diff --git a/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js
new file mode 100644
index 00000000000..ec2c549e032
--- /dev/null
+++ b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js
@@ -0,0 +1,47 @@
+import getUnicodeSupportMap from '~/emoji/support/unicode_support_map';
+import AccessorUtilities from '~/lib/utils/accessor';
+
+describe('Unicode Support Map', () => {
+ describe('getUnicodeSupportMap', () => {
+ const stringSupportMap = 'stringSupportMap';
+
+ beforeEach(() => {
+ spyOn(AccessorUtilities, 'isLocalStorageAccessSafe');
+ spyOn(window.localStorage, 'getItem');
+ spyOn(window.localStorage, 'setItem');
+ spyOn(JSON, 'parse');
+ spyOn(JSON, 'stringify').and.returnValue(stringSupportMap);
+ });
+
+ describe('if isLocalStorageAvailable is `true`', function () {
+ beforeEach(() => {
+ AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(true);
+
+ getUnicodeSupportMap();
+ });
+
+ it('should call .getItem and .setItem', () => {
+ const allArgs = window.localStorage.setItem.calls.allArgs();
+
+ expect(window.localStorage.getItem).toHaveBeenCalledWith('gl-emoji-user-agent');
+ expect(allArgs[0][0]).toBe('gl-emoji-user-agent');
+ expect(allArgs[0][1]).toBe(navigator.userAgent);
+ expect(allArgs[1][0]).toBe('gl-emoji-unicode-support-map');
+ expect(allArgs[1][1]).toBe(stringSupportMap);
+ });
+ });
+
+ describe('if isLocalStorageAvailable is `false`', function () {
+ beforeEach(() => {
+ AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(false);
+
+ getUnicodeSupportMap();
+ });
+
+ it('should not call .getItem or .setItem', () => {
+ expect(window.localStorage.getItem.calls.count()).toBe(1);
+ expect(window.localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index 4820ce41ade..6dc48f9a293 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */
-require('~/behaviors/quick_submit');
+import '~/behaviors/quick_submit';
(function() {
describe('Quick Submit behavior', function() {
@@ -40,16 +40,29 @@ require('~/behaviors/quick_submit');
it('disables input of type submit', function() {
const submitButton = $('.js-quick-submit input[type=submit]');
this.textarea.trigger(keydownEvent());
+
expect(submitButton).toBeDisabled();
});
it('disables button of type submit', function() {
- // button doesn't exist in fixture, add it manually
- const submitButton = $('<button type="submit">Submit it</button>');
- submitButton.insertAfter(this.textarea);
-
+ const submitButton = $('.js-quick-submit input[type=submit]');
this.textarea.trigger(keydownEvent());
+
expect(submitButton).toBeDisabled();
});
+ it('only clicks one submit', function() {
+ const existingSubmit = $('.js-quick-submit input[type=submit]');
+ // Add an extra submit button
+ const newSubmit = $('<button type="submit">Submit it</button>');
+ newSubmit.insertAfter(this.textarea);
+
+ const oldClick = spyOnEvent(existingSubmit, 'click');
+ const newClick = spyOnEvent(newSubmit, 'click');
+
+ this.textarea.trigger(keydownEvent());
+
+ expect(oldClick).not.toHaveBeenTriggered();
+ expect(newClick).toHaveBeenTriggered();
+ });
// We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
// only run the tests that apply to the current platform
if (navigator.userAgent.match(/Macintosh/)) {
diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js
index 3a84013a2ed..f9fa814b801 100644
--- a/spec/javascripts/behaviors/requires_input_spec.js
+++ b/spec/javascripts/behaviors/requires_input_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable space-before-function-paren, no-var */
-require('~/behaviors/requires_input');
+import '~/behaviors/requires_input';
(function() {
describe('requiresInput', function() {
diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js
new file mode 100644
index 00000000000..acd0aaf2a86
--- /dev/null
+++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js
@@ -0,0 +1,51 @@
+/* eslint-disable import/no-unresolved */
+
+import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
+import bmprPath from '../../fixtures/blob/balsamiq/test.bmpr';
+
+describe('Balsamiq integration spec', () => {
+ let container;
+ let endpoint;
+ let balsamiqViewer;
+
+ preloadFixtures('static/balsamiq_viewer.html.raw');
+
+ beforeEach(() => {
+ loadFixtures('static/balsamiq_viewer.html.raw');
+
+ container = document.getElementById('js-balsamiq-viewer');
+ balsamiqViewer = new BalsamiqViewer(container);
+ });
+
+ describe('successful response', () => {
+ beforeEach((done) => {
+ endpoint = bmprPath;
+
+ balsamiqViewer.loadFile(endpoint).then(done).catch(done.fail);
+ });
+
+ it('does not show loading icon', () => {
+ expect(document.querySelector('.loading')).toBeNull();
+ });
+
+ it('renders the balsamiq previews', () => {
+ expect(document.querySelectorAll('.previews .preview').length).not.toEqual(0);
+ });
+ });
+
+ describe('error getting file', () => {
+ beforeEach((done) => {
+ endpoint = 'invalid/path/to/file.bmpr';
+
+ balsamiqViewer.loadFile(endpoint).then(done.fail, null).catch(done);
+ });
+
+ it('does not show loading icon', () => {
+ expect(document.querySelector('.loading')).toBeNull();
+ });
+
+ it('does not render the balsamiq previews', () => {
+ expect(document.querySelectorAll('.previews .preview').length).toEqual(0);
+ });
+ });
+});
diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js
new file mode 100644
index 00000000000..aa87956109f
--- /dev/null
+++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js
@@ -0,0 +1,326 @@
+import sqljs from 'sql.js';
+import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
+import ClassSpecHelper from '../../helpers/class_spec_helper';
+
+describe('BalsamiqViewer', () => {
+ let balsamiqViewer;
+ let viewer;
+
+ describe('class constructor', () => {
+ beforeEach(() => {
+ viewer = {};
+
+ balsamiqViewer = new BalsamiqViewer(viewer);
+ });
+
+ it('should set .viewer', () => {
+ expect(balsamiqViewer.viewer).toBe(viewer);
+ });
+ });
+
+ describe('fileLoaded', () => {
+
+ });
+
+ describe('loadFile', () => {
+ let xhr;
+ let loadFile;
+ const endpoint = 'endpoint';
+
+ beforeEach(() => {
+ xhr = jasmine.createSpyObj('xhr', ['open', 'send']);
+
+ balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']);
+
+ spyOn(window, 'XMLHttpRequest').and.returnValue(xhr);
+
+ loadFile = BalsamiqViewer.prototype.loadFile.call(balsamiqViewer, endpoint);
+ });
+
+ it('should call .open', () => {
+ expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true);
+ });
+
+ it('should set .responseType', () => {
+ expect(xhr.responseType).toBe('arraybuffer');
+ });
+
+ it('should call .send', () => {
+ expect(xhr.send).toHaveBeenCalled();
+ });
+
+ it('should return a promise', () => {
+ expect(loadFile).toEqual(jasmine.any(Promise));
+ });
+ });
+
+ describe('renderFile', () => {
+ let container;
+ let loadEvent;
+ let previews;
+
+ beforeEach(() => {
+ loadEvent = { target: { response: {} } };
+ viewer = jasmine.createSpyObj('viewer', ['appendChild']);
+ previews = [document.createElement('ul'), document.createElement('ul')];
+
+ balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']);
+ balsamiqViewer.viewer = viewer;
+
+ balsamiqViewer.getPreviews.and.returnValue(previews);
+ balsamiqViewer.renderPreview.and.callFake(preview => preview);
+ viewer.appendChild.and.callFake((containerElement) => {
+ container = containerElement;
+ });
+
+ BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent);
+ });
+
+ it('should call .initDatabase', () => {
+ expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response);
+ });
+
+ it('should call .getPreviews', () => {
+ expect(balsamiqViewer.getPreviews).toHaveBeenCalled();
+ });
+
+ it('should call .renderPreview for each preview', () => {
+ const allArgs = balsamiqViewer.renderPreview.calls.allArgs();
+
+ expect(allArgs.length).toBe(2);
+
+ previews.forEach((preview, i) => {
+ expect(allArgs[i][0]).toBe(preview);
+ });
+ });
+
+ it('should set the container HTML', () => {
+ expect(container.innerHTML).toBe('<ul></ul><ul></ul>');
+ });
+
+ it('should add inline preview classes', () => {
+ expect(container.classList[0]).toBe('list-inline');
+ expect(container.classList[1]).toBe('previews');
+ });
+
+ it('should call viewer.appendChild', () => {
+ expect(viewer.appendChild).toHaveBeenCalledWith(container);
+ });
+ });
+
+ describe('initDatabase', () => {
+ let database;
+ let uint8Array;
+ let data;
+
+ beforeEach(() => {
+ uint8Array = {};
+ database = {};
+ data = 'data';
+
+ balsamiqViewer = {};
+
+ spyOn(window, 'Uint8Array').and.returnValue(uint8Array);
+ spyOn(sqljs, 'Database').and.returnValue(database);
+
+ BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
+ });
+
+ it('should instantiate Uint8Array', () => {
+ expect(window.Uint8Array).toHaveBeenCalledWith(data);
+ });
+
+ it('should call sqljs.Database', () => {
+ expect(sqljs.Database).toHaveBeenCalledWith(uint8Array);
+ });
+
+ it('should set .database', () => {
+ expect(balsamiqViewer.database).toBe(database);
+ });
+ });
+
+ describe('getPreviews', () => {
+ let database;
+ let thumbnails;
+ let getPreviews;
+
+ beforeEach(() => {
+ database = jasmine.createSpyObj('database', ['exec']);
+ thumbnails = [{ values: [0, 1, 2] }];
+
+ balsamiqViewer = {
+ database,
+ };
+
+ spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString());
+ database.exec.and.returnValue(thumbnails);
+
+ getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
+ });
+
+ it('should call database.exec', () => {
+ expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails');
+ });
+
+ it('should call .parsePreview for each value', () => {
+ const allArgs = BalsamiqViewer.parsePreview.calls.allArgs();
+
+ expect(allArgs.length).toBe(3);
+
+ thumbnails[0].values.forEach((value, i) => {
+ expect(allArgs[i][0]).toBe(value);
+ });
+ });
+
+ it('should return an array of parsed values', () => {
+ expect(getPreviews).toEqual(['0', '1', '2']);
+ });
+ });
+
+ describe('getResource', () => {
+ let database;
+ let resourceID;
+ let resource;
+ let getResource;
+
+ beforeEach(() => {
+ database = jasmine.createSpyObj('database', ['exec']);
+ resourceID = 4;
+ resource = ['resource'];
+
+ balsamiqViewer = {
+ database,
+ };
+
+ database.exec.and.returnValue(resource);
+
+ getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
+ });
+
+ it('should call database.exec', () => {
+ expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`);
+ });
+
+ it('should return the selected resource', () => {
+ expect(getResource).toBe(resource[0]);
+ });
+ });
+
+ describe('renderPreview', () => {
+ let previewElement;
+ let innerHTML;
+ let preview;
+ let renderPreview;
+
+ beforeEach(() => {
+ innerHTML = '<a>innerHTML</a>';
+ previewElement = {
+ outerHTML: '<p>outerHTML</p>',
+ classList: jasmine.createSpyObj('classList', ['add']),
+ };
+ preview = {};
+
+ balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']);
+
+ spyOn(document, 'createElement').and.returnValue(previewElement);
+ balsamiqViewer.renderTemplate.and.returnValue(innerHTML);
+
+ renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
+ });
+
+ it('should call classList.add', () => {
+ expect(previewElement.classList.add).toHaveBeenCalledWith('preview');
+ });
+
+ it('should call .renderTemplate', () => {
+ expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview);
+ });
+
+ it('should set .innerHTML', () => {
+ expect(previewElement.innerHTML).toBe(innerHTML);
+ });
+
+ it('should return element', () => {
+ expect(renderPreview).toBe(previewElement);
+ });
+ });
+
+ describe('renderTemplate', () => {
+ let preview;
+ let name;
+ let resource;
+ let template;
+ let renderTemplate;
+
+ beforeEach(() => {
+ preview = { resourceID: 1, image: 'image' };
+ name = 'name';
+ resource = 'resource';
+ template = `
+ <div class="panel panel-default">
+ <div class="panel-heading">name</div>
+ <div class="panel-body">
+ <img class="img-thumbnail" src="data:image/png;base64,image"/>
+ </div>
+ </div>
+ `;
+
+ balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']);
+
+ spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name);
+ balsamiqViewer.getResource.and.returnValue(resource);
+
+ renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
+ });
+
+ it('should call .getResource', () => {
+ expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID);
+ });
+
+ it('should call .parseTitle', () => {
+ expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
+ });
+
+ it('should return the template string', function () {
+ expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
+ });
+ });
+
+ describe('parsePreview', () => {
+ let preview;
+ let parsePreview;
+
+ beforeEach(() => {
+ preview = ['{}', '{ "id": 1 }'];
+
+ spyOn(JSON, 'parse').and.callThrough();
+
+ parsePreview = BalsamiqViewer.parsePreview(preview);
+ });
+
+ ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
+
+ it('should return the parsed JSON', () => {
+ expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }'));
+ });
+ });
+
+ describe('parseTitle', () => {
+ let title;
+ let parseTitle;
+
+ beforeEach(() => {
+ title = { values: [['{}', '{}', '{"name":"name"}']] };
+
+ spyOn(JSON, 'parse').and.callThrough();
+
+ parseTitle = BalsamiqViewer.parseTitle(title);
+ });
+
+ ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
+
+ it('should return the name value', () => {
+ expect(parseTitle).toBe('name');
+ });
+ });
+});
diff --git a/spec/javascripts/blob/blob_file_dropzone_spec.js b/spec/javascripts/blob/blob_file_dropzone_spec.js
new file mode 100644
index 00000000000..2c8183ff77b
--- /dev/null
+++ b/spec/javascripts/blob/blob_file_dropzone_spec.js
@@ -0,0 +1,42 @@
+import 'dropzone';
+import BlobFileDropzone from '~/blob/blob_file_dropzone';
+
+describe('BlobFileDropzone', () => {
+ preloadFixtures('blob/show.html.raw');
+
+ beforeEach(() => {
+ loadFixtures('blob/show.html.raw');
+ const form = $('.js-upload-blob-form');
+ this.blobFileDropzone = new BlobFileDropzone(form, 'POST');
+ this.dropzone = $('.js-upload-blob-form .dropzone').get(0).dropzone;
+ this.replaceFileButton = $('#submit-all');
+ });
+
+ describe('submit button', () => {
+ it('requires file', () => {
+ spyOn(window, 'alert');
+
+ this.replaceFileButton.click();
+
+ expect(window.alert).toHaveBeenCalled();
+ });
+
+ it('is disabled while uploading', () => {
+ spyOn(window, 'alert');
+
+ const file = {
+ name: 'some-file.jpg',
+ type: 'jpg',
+ };
+ const fakeEvent = jQuery.Event('drop', {
+ dataTransfer: { files: [file] },
+ });
+
+ this.dropzone.listeners[0].events.drop(fakeEvent);
+ this.replaceFileButton.click();
+
+ expect(window.alert).not.toHaveBeenCalled();
+ expect(this.replaceFileButton.is(':disabled')).toEqual(true);
+ });
+ });
+});
diff --git a/spec/javascripts/blob/create_branch_dropdown_spec.js b/spec/javascripts/blob/create_branch_dropdown_spec.js
deleted file mode 100644
index c1179e572ae..00000000000
--- a/spec/javascripts/blob/create_branch_dropdown_spec.js
+++ /dev/null
@@ -1,107 +0,0 @@
-require('~/gl_dropdown');
-require('~/lib/utils/type_utility');
-require('~/blob/create_branch_dropdown');
-require('~/blob/target_branch_dropdown');
-
-describe('CreateBranchDropdown', () => {
- const fixtureTemplate = 'static/target_branch_dropdown.html.raw';
- // selectors
- const createBranchSel = '.js-new-branch-btn';
- const backBtnSel = '.dropdown-menu-back';
- const cancelBtnSel = '.js-cancel-branch-btn';
- const branchNameSel = '#new_branch_name';
- const branchName = 'new_name';
- let dropdown;
-
- function createDropdown() {
- const dropdownEl = document.querySelector('.js-project-branches-dropdown');
- const projectBranches = getJSONFixture('project_branches.json');
- dropdown = new gl.TargetBranchDropDown(dropdownEl);
- dropdown.cachedRefs = projectBranches;
- return dropdown;
- }
-
- function createBranchBtn() {
- return document.querySelector(createBranchSel);
- }
-
- function backBtn() {
- return document.querySelector(backBtnSel);
- }
-
- function cancelBtn() {
- return document.querySelector(cancelBtnSel);
- }
-
- function branchNameEl() {
- return document.querySelector(branchNameSel);
- }
-
- function changeBranchName(text) {
- branchNameEl().value = text;
- branchNameEl().dispatchEvent(new Event('change'));
- }
-
- preloadFixtures(fixtureTemplate);
-
- beforeEach(() => {
- loadFixtures(fixtureTemplate);
- createDropdown();
- });
-
- it('disable submit when branch name is empty', () => {
- expect(createBranchBtn()).toBeDisabled();
- });
-
- it('enable submit when branch name is present', () => {
- changeBranchName(branchName);
-
- expect(createBranchBtn()).not.toBeDisabled();
- });
-
- it('resets the form when cancel btn is clicked and triggers dropdownback', () => {
- const spyBackEvent = spyOnEvent(backBtnSel, 'click');
- changeBranchName(branchName);
-
- cancelBtn().click();
-
- expect(branchNameEl()).toHaveValue('');
- expect(spyBackEvent).toHaveBeenTriggered();
- });
-
- it('resets the form when back btn is clicked', () => {
- changeBranchName(branchName);
-
- backBtn().click();
-
- expect(branchNameEl()).toHaveValue('');
- });
-
- describe('new branch creation', () => {
- beforeEach(() => {
- changeBranchName(branchName);
- });
- it('sets the new branch name and updates the dropdown', () => {
- spyOn(dropdown, 'setNewBranch');
-
- createBranchBtn().click();
-
- expect(dropdown.setNewBranch).toHaveBeenCalledWith(branchName);
- });
-
- it('resets the form', () => {
- createBranchBtn().click();
-
- expect(branchNameEl()).toHaveValue('');
- });
-
- it('is triggered with enter keypress', () => {
- spyOn(dropdown, 'setNewBranch');
- const enterEvent = new Event('keydown');
- enterEvent.which = 13;
- branchNameEl().dispatchEvent(enterEvent);
-
- expect(dropdown.setNewBranch).toHaveBeenCalledWith(branchName);
- });
- });
-});
diff --git a/spec/javascripts/blob/pdf/index_spec.js b/spec/javascripts/blob/pdf/index_spec.js
index d3a4d04345b..bbeaf95e68d 100644
--- a/spec/javascripts/blob/pdf/index_spec.js
+++ b/spec/javascripts/blob/pdf/index_spec.js
@@ -1,5 +1,7 @@
+/* eslint-disable import/no-unresolved */
+
import renderPDF from '~/blob/pdf';
-import testPDF from './test.pdf';
+import testPDF from '../../fixtures/blob/pdf/test.pdf';
describe('PDF renderer', () => {
let viewer;
@@ -59,7 +61,7 @@ describe('PDF renderer', () => {
describe('error getting file', () => {
beforeEach((done) => {
- viewer.dataset.endpoint = 'invalid/endpoint';
+ viewer.dataset.endpoint = 'invalid/path/to/file.pdf';
app = renderPDF();
checkLoaded(done);
diff --git a/spec/javascripts/blob/pdf/test.pdf b/spec/javascripts/blob/pdf/test.pdf
deleted file mode 100644
index eb3d147fde3..00000000000
--- a/spec/javascripts/blob/pdf/test.pdf
+++ /dev/null
Binary files differ
diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js
deleted file mode 100644
index 4fb79663c51..00000000000
--- a/spec/javascripts/blob/target_branch_dropdown_spec.js
+++ /dev/null
@@ -1,119 +0,0 @@
-require('~/gl_dropdown');
-require('~/lib/utils/type_utility');
-require('~/blob/create_branch_dropdown');
-require('~/blob/target_branch_dropdown');
-
-describe('TargetBranchDropdown', () => {
- const fixtureTemplate = 'static/target_branch_dropdown.html.raw';
- let dropdown;
-
- function createDropdown() {
- const projectBranches = getJSONFixture('project_branches.json');
- const dropdownEl = document.querySelector('.js-project-branches-dropdown');
- dropdown = new gl.TargetBranchDropDown(dropdownEl);
- dropdown.cachedRefs = projectBranches;
- dropdown.refreshData();
- return dropdown;
- }
-
- function submitBtn() {
- return document.querySelector('button[type="submit"]');
- }
-
- function searchField() {
- return document.querySelector('.dropdown-page-one .dropdown-input-field');
- }
-
- function element() {
- return document.querySelectorAll('div.dropdown-content li a');
- }
-
- function elementAtIndex(index) {
- return element()[index];
- }
-
- function clickElementAtIndex(index) {
- elementAtIndex(index).click();
- }
-
- preloadFixtures(fixtureTemplate);
-
- beforeEach(() => {
- loadFixtures(fixtureTemplate);
- createDropdown();
- });
-
- it('disable submit when branch is not selected', () => {
- document.querySelector('input[name="target_branch"]').value = null;
- clickElementAtIndex(1);
-
- expect(submitBtn().getAttribute('disabled')).toEqual('');
- });
-
- it('enable submit when a branch is selected', () => {
- clickElementAtIndex(1);
-
- expect(submitBtn().getAttribute('disabled')).toBe(null);
- });
-
- it('triggers change.branch event on a branch click', () => {
- spyOnEvent(dropdown.$dropdown, 'change.branch');
- clickElementAtIndex(0);
-
- expect('change.branch').toHaveBeenTriggeredOn(dropdown.$dropdown);
- });
-
- describe('#dropdownData', () => {
- it('cache the refs', () => {
- const refs = dropdown.cachedRefs;
- dropdown.cachedRefs = null;
-
- dropdown.dropdownData(refs);
-
- expect(dropdown.cachedRefs).toEqual(refs);
- });
-
- it('returns the Branches with the newBranch and defaultBranch', () => {
- const refs = dropdown.cachedRefs;
- dropdown.branchInput.value = 'master';
- dropdown.newBranch = { id: 'new_branch', text: 'new_branch', title: 'new_branch' };
-
- const branches = dropdown.dropdownData(refs).Branches;
-
- expect(branches.length).toEqual(4);
- expect(branches[0]).toEqual(dropdown.newBranch);
- expect(branches[1]).toEqual({ id: 'master', text: 'master', title: 'master' });
- expect(branches[2]).toEqual({ id: 'development', text: 'development', title: 'development' });
- expect(branches[3]).toEqual({ id: 'staging', text: 'staging', title: 'staging' });
- });
- });
-
- describe('#setNewBranch', () => {
- it('adds the new branch and select it', () => {
- const branchName = 'new_branch';
-
- dropdown.setNewBranch(branchName);
-
- expect(elementAtIndex(0)).toHaveClass('is-active');
- expect(elementAtIndex(0)).toContainHtml(branchName);
- });
-
- it("doesn't add a new branch if already exists in the list", () => {
- const branchName = elementAtIndex(0).text;
- const initialLength = element().length;
-
- dropdown.setNewBranch(branchName);
-
- expect(element().length).toEqual(initialLength);
- });
-
- it('clears the search filter', () => {
- const branchName = elementAtIndex(0).text;
- searchField().value = 'searching';
-
- dropdown.setNewBranch(branchName);
-
- expect(searchField().value).toEqual('');
- });
- });
-});
diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js
new file mode 100644
index 00000000000..cfa6650d85f
--- /dev/null
+++ b/spec/javascripts/blob/viewer/index_spec.js
@@ -0,0 +1,184 @@
+/* eslint-disable no-new */
+import BlobViewer from '~/blob/viewer/index';
+
+describe('Blob viewer', () => {
+ let blob;
+ preloadFixtures('snippets/show.html.raw');
+
+ beforeEach(() => {
+ loadFixtures('snippets/show.html.raw');
+ $('#modal-upload-blob').remove();
+
+ blob = new BlobViewer();
+
+ spyOn($, 'ajax').and.callFake(() => {
+ const d = $.Deferred();
+
+ d.resolve({
+ html: '<div>testing</div>',
+ });
+
+ return d.promise();
+ });
+ });
+
+ afterEach(() => {
+ location.hash = '';
+ });
+
+ it('loads source file after switching views', (done) => {
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
+
+ setTimeout(() => {
+ expect($.ajax).toHaveBeenCalled();
+ expect(
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]')
+ .classList.contains('hidden'),
+ ).toBeFalsy();
+
+ done();
+ });
+ });
+
+ it('loads source file when line number is in hash', (done) => {
+ location.hash = '#L1';
+
+ new BlobViewer();
+
+ setTimeout(() => {
+ expect($.ajax).toHaveBeenCalled();
+ expect(
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]')
+ .classList.contains('hidden'),
+ ).toBeFalsy();
+
+ done();
+ });
+ });
+
+ it('doesnt reload file if already loaded', (done) => {
+ const asyncClick = () => new Promise((resolve) => {
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
+
+ setTimeout(resolve);
+ });
+
+ asyncClick()
+ .then(() => {
+ expect($.ajax).toHaveBeenCalled();
+ return asyncClick();
+ })
+ .then(() => {
+ expect($.ajax.calls.count()).toBe(1);
+ expect(
+ document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'),
+ ).toBe('true');
+
+ done();
+ })
+ .catch(() => {
+ fail();
+ done();
+ });
+ });
+
+ describe('copy blob button', () => {
+ let copyButton;
+
+ beforeEach(() => {
+ copyButton = document.querySelector('.js-copy-blob-source-btn');
+ });
+
+ it('disabled on load', () => {
+ expect(
+ copyButton.classList.contains('disabled'),
+ ).toBeTruthy();
+ });
+
+ it('has tooltip when disabled', () => {
+ expect(
+ copyButton.getAttribute('data-original-title'),
+ ).toBe('Switch to the source to copy it to the clipboard');
+ });
+
+ it('is blurred when clicked and disabled', () => {
+ spyOn(copyButton, 'blur');
+
+ copyButton.click();
+
+ expect(copyButton.blur).toHaveBeenCalled();
+ });
+
+ it('is not blurred when clicked and not disabled', () => {
+ spyOn(copyButton, 'blur');
+
+ copyButton.classList.remove('disabled');
+ copyButton.click();
+
+ expect(copyButton.blur).not.toHaveBeenCalled();
+ });
+
+ it('enables after switching to simple view', (done) => {
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
+
+ setTimeout(() => {
+ expect($.ajax).toHaveBeenCalled();
+ expect(
+ copyButton.classList.contains('disabled'),
+ ).toBeFalsy();
+
+ done();
+ });
+ });
+
+ it('updates tooltip after switching to simple view', (done) => {
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
+
+ setTimeout(() => {
+ expect($.ajax).toHaveBeenCalled();
+
+ expect(
+ copyButton.getAttribute('data-original-title'),
+ ).toBe('Copy source to clipboard');
+
+ done();
+ });
+ });
+ });
+
+ describe('switchToViewer', () => {
+ it('removes active class from old viewer button', () => {
+ blob.switchToViewer('simple');
+
+ expect(
+ document.querySelector('.js-blob-viewer-switch-btn.active[data-viewer="rich"]'),
+ ).toBeNull();
+ });
+
+ it('adds active class to new viewer button', () => {
+ const simpleBtn = document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]');
+
+ spyOn(simpleBtn, 'blur');
+
+ blob.switchToViewer('simple');
+
+ expect(
+ simpleBtn.classList.contains('active'),
+ ).toBeTruthy();
+ expect(simpleBtn.blur).toHaveBeenCalled();
+ });
+
+ it('sends AJAX request when switching to simple view', () => {
+ blob.switchToViewer('simple');
+
+ expect($.ajax).toHaveBeenCalled();
+ });
+
+ it('does not send AJAX request when switching to rich view', () => {
+ blob.switchToViewer('simple');
+ blob.switchToViewer('rich');
+
+ expect($.ajax.calls.count()).toBe(1);
+ });
+ });
+});
diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js
index de072e7e470..447b244c71f 100644
--- a/spec/javascripts/boards/board_card_spec.js
+++ b/spec/javascripts/boards/board_card_spec.js
@@ -1,18 +1,18 @@
/* global List */
-/* global ListUser */
+/* global ListAssignee */
/* global ListLabel */
/* global listObj */
/* global boardsMockInterceptor */
/* global BoardService */
import Vue from 'vue';
-import '~/boards/models/user';
+import '~/boards/models/assignee';
-require('~/boards/models/list');
-require('~/boards/models/label');
-require('~/boards/stores/boards_store');
-const boardCard = require('~/boards/components/board_card').default;
-require('./mock_data');
+import '~/boards/models/list';
+import '~/boards/models/label';
+import '~/boards/stores/boards_store';
+import boardCard from '~/boards/components/board_card';
+import './mock_data';
describe('Issue card', () => {
let vm;
@@ -133,12 +133,12 @@ describe('Issue card', () => {
});
it('does not set detail issue if img is clicked', (done) => {
- vm.issue.assignee = new ListUser({
+ vm.issue.assignees = [new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
- });
+ })];
Vue.nextTick(() => {
triggerEvent('mouseup', vm.$el.querySelector('img'));
diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js
index 3f598887603..a89be911667 100644
--- a/spec/javascripts/boards/board_list_spec.js
+++ b/spec/javascripts/boards/board_list_spec.js
@@ -35,6 +35,7 @@ describe('Board list component', () => {
iid: 1,
confidential: false,
labels: [],
+ assignees: [],
});
list.issuesSize = 1;
list.issues.push(issue);
diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js
index 4999933c0c1..c0a7323a505 100644
--- a/spec/javascripts/boards/board_new_issue_spec.js
+++ b/spec/javascripts/boards/board_new_issue_spec.js
@@ -6,12 +6,13 @@
import Vue from 'vue';
import boardNewIssue from '~/boards/components/board_new_issue';
-require('~/boards/models/list');
-require('./mock_data');
+import '~/boards/models/list';
+import './mock_data';
describe('Issue boards new issue form', () => {
let vm;
let list;
+ let newIssueMock;
const promiseReturn = {
json() {
return {
@@ -19,8 +20,13 @@ describe('Issue boards new issue form', () => {
};
},
};
+
const submitIssue = () => {
- vm.$el.querySelector('.btn-success').click();
+ const dummySubmitEvent = {
+ preventDefault() {},
+ };
+ vm.$refs.submitButton = vm.$el.querySelector('.btn-success');
+ return vm.submit(dummySubmitEvent);
};
beforeEach((done) => {
@@ -31,29 +37,35 @@ describe('Issue boards new issue form', () => {
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
- setTimeout(() => {
- list = new List(listObj);
-
- spyOn(gl.boardService, 'newIssue').and.callFake(() => new Promise((resolve, reject) => {
- if (vm.title === 'error') {
- reject();
- } else {
- resolve(promiseReturn);
- }
- }));
-
- vm = new BoardNewIssueComp({
- propsData: {
- list,
- },
- }).$mount();
-
- done();
- }, 0);
+ list = new List(listObj);
+
+ newIssueMock = Promise.resolve(promiseReturn);
+ spyOn(list, 'newIssue').and.callFake(() => newIssueMock);
+
+ vm = new BoardNewIssueComp({
+ propsData: {
+ list,
+ },
+ }).$mount();
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
});
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ it('calls submit if submit button is clicked', (done) => {
+ spyOn(vm, 'submit');
+ vm.title = 'Testing Title';
+
+ Vue.nextTick()
+ .then(() => {
+ vm.$el.querySelector('.btn-success').click();
+
+ expect(vm.submit.calls.count()).toBe(1);
+ expect(vm.$refs['submit-button']).toBe(vm.$el.querySelector('.btn-success'));
+ })
+ .then(done)
+ .catch(done.fail);
});
it('disables submit button if title is empty', () => {
@@ -63,128 +75,122 @@ describe('Issue boards new issue form', () => {
it('enables submit button if title is not empty', (done) => {
vm.title = 'Testing Title';
- setTimeout(() => {
- expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
- expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
-
- done();
- }, 0);
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
+ expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('clears title after clicking cancel', (done) => {
vm.$el.querySelector('.btn-default').click();
- setTimeout(() => {
- expect(vm.title).toBe('');
- done();
- }, 0);
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.title).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
});
it('does not create new issue if title is empty', (done) => {
- submitIssue();
-
- setTimeout(() => {
- expect(gl.boardService.newIssue).not.toHaveBeenCalled();
- done();
- }, 0);
+ submitIssue()
+ .then(() => {
+ expect(list.newIssue).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
describe('submit success', () => {
it('creates new issue', (done) => {
vm.title = 'submit title';
- setTimeout(() => {
- submitIssue();
-
- expect(gl.boardService.newIssue).toHaveBeenCalled();
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(list.newIssue).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
it('enables button after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- expect(vm.$el.querySelector('.btn-success').disbled).not.toBe(true);
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('clears title after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- expect(vm.title).toBe('');
- done();
- }, 0);
- });
-
- it('adds new issue to list after submit', (done) => {
- vm.title = 'submit issue';
-
- setTimeout(() => {
- submitIssue();
-
- expect(list.issues.length).toBe(2);
- expect(list.issues[1].title).toBe('submit issue');
- expect(list.issues[1].subscribed).toBe(true);
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(vm.title).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
});
it('sets detail issue after submit', (done) => {
+ expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined);
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
- done();
- });
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
+ })
+ .then(done)
+ .catch(done.fail);
});
it('sets detail list after submit', (done) => {
vm.title = 'submit issue';
- setTimeout(() => {
- submitIssue();
-
- expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
- done();
- }, 0);
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
+ expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
describe('submit error', () => {
- it('removes issue', (done) => {
+ beforeEach(() => {
+ newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
vm.title = 'error';
+ });
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ it('removes issue', (done) => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(list.issues.length).toBe(1);
- done();
- }, 500);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('shows error', (done) => {
- vm.title = 'error';
- submitIssue();
-
- setTimeout(() => {
- submitIssue();
-
- setTimeout(() => {
+ Vue.nextTick()
+ .then(submitIssue)
+ .then(() => {
expect(vm.error).toBe(true);
- done();
- }, 500);
- }, 0);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js
index b55ff2f473a..5ea160b7790 100644
--- a/spec/javascripts/boards/boards_store_spec.js
+++ b/spec/javascripts/boards/boards_store_spec.js
@@ -8,14 +8,14 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
-require('~/lib/utils/url_utility');
-require('~/boards/models/issue');
-require('~/boards/models/label');
-require('~/boards/models/list');
-require('~/boards/models/user');
-require('~/boards/services/board_service');
-require('~/boards/stores/boards_store');
-require('./mock_data');
+import '~/lib/utils/url_utility';
+import '~/boards/models/issue';
+import '~/boards/models/label';
+import '~/boards/models/list';
+import '~/boards/models/assignee';
+import '~/boards/services/board_service';
+import '~/boards/stores/boards_store';
+import './mock_data';
describe('Store', () => {
beforeEach(() => {
@@ -212,7 +212,8 @@ describe('Store', () => {
title: 'Testing',
iid: 2,
confidential: false,
- labels: []
+ labels: [],
+ assignees: [],
});
const list = gl.issueBoards.BoardsStore.addList(listObj);
diff --git a/spec/javascripts/boards/components/board_spec.js b/spec/javascripts/boards/components/board_spec.js
new file mode 100644
index 00000000000..c4e8966ad6c
--- /dev/null
+++ b/spec/javascripts/boards/components/board_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import '~/boards/services/board_service';
+import '~/boards/components/board';
+import '~/boards/models/list';
+
+describe('Board component', () => {
+ let vm;
+ let el;
+
+ beforeEach((done) => {
+ loadFixtures('boards/show.html.raw');
+
+ el = document.createElement('div');
+ document.body.appendChild(el);
+
+ // eslint-disable-next-line no-undef
+ gl.boardService = new BoardService('/', '/', 1);
+
+ vm = new gl.issueBoards.Board({
+ propsData: {
+ boardId: '1',
+ disabled: false,
+ issueLinkBase: '/',
+ rootPath: '/',
+ // eslint-disable-next-line no-undef
+ list: new List({
+ id: 1,
+ position: 0,
+ title: 'test',
+ list_type: 'backlog',
+ }),
+ },
+ }).$mount(el);
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ // remove the component from the DOM
+ document.querySelector('.board').remove();
+
+ localStorage.removeItem(`boards.${vm.boardId}.${vm.list.type}.expanded`);
+ });
+
+ it('board is expandable when list type is backlog', () => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(true);
+ });
+
+ it('board is expandable when list type is closed', (done) => {
+ vm.list.type = 'closed';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+
+ it('board is not expandable when list type is label', (done) => {
+ vm.list.type = 'label';
+ vm.list.isExpandable = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-expandable'),
+ ).toBe(false);
+
+ done();
+ });
+ });
+
+ it('collapses when clicking header', (done) => {
+ vm.$el.querySelector('.board-header').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+
+ it('created sets isExpanded to true from localStorage', (done) => {
+ vm.$el.querySelector('.board-header').click();
+
+ return Vue.nextTick()
+ .then(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ // call created manually
+ vm.$options.created[0].call(vm);
+
+ return Vue.nextTick();
+ })
+ .then(() => {
+ expect(
+ vm.$el.classList.contains('is-collapsed'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js
index 1a5e9e9fd07..69cfcbbce5a 100644
--- a/spec/javascripts/boards/issue_card_spec.js
+++ b/spec/javascripts/boards/issue_card_spec.js
@@ -1,20 +1,20 @@
-/* global ListUser */
+/* global ListAssignee */
/* global ListLabel */
/* global listObj */
/* global ListIssue */
import Vue from 'vue';
-require('~/boards/models/issue');
-require('~/boards/models/label');
-require('~/boards/models/list');
-require('~/boards/models/user');
-require('~/boards/stores/boards_store');
-require('~/boards/components/issue_card_inner');
-require('./mock_data');
+import '~/boards/models/issue';
+import '~/boards/models/label';
+import '~/boards/models/list';
+import '~/boards/models/assignee';
+import '~/boards/stores/boards_store';
+import '~/boards/components/issue_card_inner';
+import './mock_data';
describe('Issue card component', () => {
- const user = new ListUser({
+ const user = new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
@@ -40,6 +40,7 @@ describe('Issue card component', () => {
iid: 1,
confidential: false,
labels: [list.label],
+ assignees: [],
});
component = new Vue({
@@ -92,12 +93,12 @@ describe('Issue card component', () => {
it('renders confidential icon', (done) => {
component.issue.confidential = true;
- setTimeout(() => {
+ Vue.nextTick(() => {
expect(
component.$el.querySelector('.confidential-icon'),
).not.toBeNull();
done();
- }, 0);
+ });
});
it('renders issue ID with #', () => {
@@ -109,34 +110,32 @@ describe('Issue card component', () => {
describe('assignee', () => {
it('does not render assignee', () => {
expect(
- component.$el.querySelector('.card-assignee'),
+ component.$el.querySelector('.card-assignee .avatar'),
).toBeNull();
});
describe('exists', () => {
beforeEach((done) => {
- component.issue.assignee = user;
+ component.issue.assignees = [user];
- setTimeout(() => {
- done();
- }, 0);
+ Vue.nextTick(() => done());
});
it('renders assignee', () => {
expect(
- component.$el.querySelector('.card-assignee'),
+ component.$el.querySelector('.card-assignee .avatar'),
).not.toBeNull();
});
it('sets title', () => {
expect(
- component.$el.querySelector('.card-assignee').getAttribute('title'),
+ component.$el.querySelector('.card-assignee img').getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
});
it('sets users path', () => {
expect(
- component.$el.querySelector('.card-assignee').getAttribute('href'),
+ component.$el.querySelector('.card-assignee a').getAttribute('href'),
).toBe('/test');
});
@@ -146,34 +145,121 @@ describe('Issue card component', () => {
).not.toBeNull();
});
});
+
+ describe('assignee default avatar', () => {
+ beforeEach((done) => {
+ component.issue.assignees = [new ListAssignee({
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ }, 'default_avatar')];
+
+ Vue.nextTick(done);
+ });
+
+ it('displays defaults avatar if users avatar is null', () => {
+ expect(
+ component.$el.querySelector('.card-assignee img'),
+ ).not.toBeNull();
+ expect(
+ component.$el.querySelector('.card-assignee img').getAttribute('src'),
+ ).toBe('default_avatar');
+ });
+ });
});
- describe('labels', () => {
- it('does not render any', () => {
- expect(
- component.$el.querySelector('.label'),
- ).toBeNull();
+ describe('multiple assignees', () => {
+ beforeEach((done) => {
+ component.issue.assignees = [
+ user,
+ new ListAssignee({
+ id: 2,
+ name: 'user2',
+ username: 'user2',
+ avatar: 'test_image',
+ }),
+ new ListAssignee({
+ id: 3,
+ name: 'user3',
+ username: 'user3',
+ avatar: 'test_image',
+ }),
+ new ListAssignee({
+ id: 4,
+ name: 'user4',
+ username: 'user4',
+ avatar: 'test_image',
+ })];
+
+ Vue.nextTick(() => done());
+ });
+
+ it('renders all four assignees', () => {
+ expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4);
});
+ describe('more than four assignees', () => {
+ beforeEach((done) => {
+ component.issue.assignees.push(new ListAssignee({
+ id: 5,
+ name: 'user5',
+ username: 'user5',
+ avatar: 'test_image',
+ }));
+
+ Vue.nextTick(() => done());
+ });
+
+ it('renders more avatar counter', () => {
+ expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2');
+ });
+
+ it('renders three assignees', () => {
+ expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3);
+ });
+
+ it('renders 99+ avatar counter', (done) => {
+ for (let i = 5; i < 104; i += 1) {
+ const u = new ListAssignee({
+ id: i,
+ name: 'name',
+ username: 'username',
+ avatar: 'test_image',
+ });
+ component.issue.assignees.push(u);
+ }
+
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+');
+ done();
+ });
+ });
+ });
+ });
+
+ describe('labels', () => {
describe('exists', () => {
beforeEach((done) => {
component.issue.addLabel(label1);
- setTimeout(() => {
- done();
- }, 0);
+ Vue.nextTick(() => done());
});
- it('does not render list label', () => {
+ it('renders list label', () => {
expect(
component.$el.querySelectorAll('.label').length,
- ).toBe(1);
+ ).toBe(2);
});
it('renders label', () => {
+ const nodes = [];
+ component.$el.querySelectorAll('.label').forEach((label) => {
+ nodes.push(label.title);
+ });
+
expect(
- component.$el.querySelector('.label').textContent,
- ).toContain(label1.title);
+ nodes.includes(label1.description),
+ ).toBe(true);
});
it('sets label description as title', () => {
@@ -183,9 +269,14 @@ describe('Issue card component', () => {
});
it('sets background color of button', () => {
+ const nodes = [];
+ component.$el.querySelectorAll('.label').forEach((label) => {
+ nodes.push(label.style.backgroundColor);
+ });
+
expect(
- component.$el.querySelector('.label').style.backgroundColor,
- ).toContain(label1.color);
+ nodes.includes(label1.color),
+ ).toBe(true);
});
});
});
diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js
index c96dfe94a4a..cd1497bc5e6 100644
--- a/spec/javascripts/boards/issue_spec.js
+++ b/spec/javascripts/boards/issue_spec.js
@@ -2,14 +2,15 @@
/* global BoardService */
/* global ListIssue */
-require('~/lib/utils/url_utility');
-require('~/boards/models/issue');
-require('~/boards/models/label');
-require('~/boards/models/list');
-require('~/boards/models/user');
-require('~/boards/services/board_service');
-require('~/boards/stores/boards_store');
-require('./mock_data');
+import Vue from 'vue';
+import '~/lib/utils/url_utility';
+import '~/boards/models/issue';
+import '~/boards/models/label';
+import '~/boards/models/list';
+import '~/boards/models/assignee';
+import '~/boards/services/board_service';
+import '~/boards/stores/boards_store';
+import './mock_data';
describe('Issue model', () => {
let issue;
@@ -27,7 +28,13 @@ describe('Issue model', () => {
title: 'test',
color: 'red',
description: 'testing'
- }]
+ }],
+ assignees: [{
+ id: 1,
+ name: 'name',
+ username: 'username',
+ avatar_url: 'http://avatar_url',
+ }],
});
});
@@ -80,6 +87,33 @@ describe('Issue model', () => {
expect(issue.labels.length).toBe(0);
});
+ it('adds assignee', () => {
+ issue.addAssignee({
+ id: 2,
+ name: 'Bruce Wayne',
+ username: 'batman',
+ avatar_url: 'http://batman',
+ });
+
+ expect(issue.assignees.length).toBe(2);
+ });
+
+ it('finds assignee', () => {
+ const assignee = issue.findAssignee(issue.assignees[0]);
+ expect(assignee).toBeDefined();
+ });
+
+ it('removes assignee', () => {
+ const assignee = issue.findAssignee(issue.assignees[0]);
+ issue.removeAssignee(assignee);
+ expect(issue.assignees.length).toBe(0);
+ });
+
+ it('removes all assignees', () => {
+ issue.removeAllAssignees();
+ expect(issue.assignees.length).toBe(0);
+ });
+
it('sets position to infinity if no position is stored', () => {
expect(issue.position).toBe(Infinity);
});
@@ -90,9 +124,31 @@ describe('Issue model', () => {
iid: 1,
confidential: false,
relative_position: 1,
- labels: []
+ labels: [],
+ assignees: [],
});
expect(relativePositionIssue.position).toBe(1);
});
+
+ describe('update', () => {
+ it('passes assignee ids when there are assignees', (done) => {
+ spyOn(Vue.http, 'patch').and.callFake((url, data) => {
+ expect(data.issue.assignee_ids).toEqual([1]);
+ done();
+ });
+
+ issue.update('url');
+ });
+
+ it('passes assignee ids of [0] when there are no assignees', (done) => {
+ spyOn(Vue.http, 'patch').and.callFake((url, data) => {
+ expect(data.issue.assignee_ids).toEqual([0]);
+ done();
+ });
+
+ issue.removeAllAssignees();
+ issue.update('url');
+ });
+ });
});
diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js
index 24a2da9f6b6..db50829a276 100644
--- a/spec/javascripts/boards/list_spec.js
+++ b/spec/javascripts/boards/list_spec.js
@@ -8,14 +8,14 @@
import Vue from 'vue';
-require('~/lib/utils/url_utility');
-require('~/boards/models/issue');
-require('~/boards/models/label');
-require('~/boards/models/list');
-require('~/boards/models/user');
-require('~/boards/services/board_service');
-require('~/boards/stores/boards_store');
-require('./mock_data');
+import '~/lib/utils/url_utility';
+import '~/boards/models/issue';
+import '~/boards/models/label';
+import '~/boards/models/list';
+import '~/boards/models/assignee';
+import '~/boards/services/board_service';
+import '~/boards/stores/boards_store';
+import './mock_data';
describe('List model', () => {
let list;
@@ -94,7 +94,8 @@ describe('List model', () => {
title: 'Testing',
iid: _.random(10000),
confidential: false,
- labels: [list.label, listDup.label]
+ labels: [list.label, listDup.label],
+ assignees: [],
});
list.issues.push(issue);
@@ -119,7 +120,8 @@ describe('List model', () => {
title: 'Testing',
iid: _.random(10000) + i,
confidential: false,
- labels: [list.label]
+ labels: [list.label],
+ assignees: [],
}));
}
list.issuesSize = 50;
@@ -137,7 +139,8 @@ describe('List model', () => {
title: 'Testing',
iid: _.random(10000),
confidential: false,
- labels: [list.label]
+ labels: [list.label],
+ assignees: [],
}));
list.issuesSize = 2;
@@ -147,4 +150,41 @@ describe('List model', () => {
expect(list.getIssues).toHaveBeenCalled();
});
});
+
+ describe('newIssue', () => {
+ beforeEach(() => {
+ spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
+ json() {
+ return {
+ iid: 42,
+ };
+ },
+ }));
+ });
+
+ it('adds new issue to top of list', (done) => {
+ list.issues.push(new ListIssue({
+ title: 'Testing',
+ iid: _.random(10000),
+ confidential: false,
+ labels: [list.label],
+ assignees: [],
+ }));
+ const dummyIssue = new ListIssue({
+ title: 'new issue',
+ iid: _.random(10000),
+ confidential: false,
+ labels: [list.label],
+ assignees: [],
+ });
+
+ list.newIssue(dummyIssue)
+ .then(() => {
+ expect(list.issues.length).toBe(2);
+ expect(list.issues[0]).toBe(dummyIssue);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js
index a4fa694eebe..a64c3964ee3 100644
--- a/spec/javascripts/boards/mock_data.js
+++ b/spec/javascripts/boards/mock_data.js
@@ -33,7 +33,8 @@ const BoardsMockData = {
title: 'Testing',
iid: 1,
confidential: false,
- labels: []
+ labels: [],
+ assignees: [],
}],
size: 1
}
diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js
index 80db816aff8..32e6d04df9f 100644
--- a/spec/javascripts/boards/modal_store_spec.js
+++ b/spec/javascripts/boards/modal_store_spec.js
@@ -1,10 +1,10 @@
/* global ListIssue */
-require('~/boards/models/issue');
-require('~/boards/models/label');
-require('~/boards/models/list');
-require('~/boards/models/user');
-require('~/boards/stores/modal_store');
+import '~/boards/models/issue';
+import '~/boards/models/label';
+import '~/boards/models/list';
+import '~/boards/models/assignee';
+import '~/boards/stores/modal_store';
describe('Modal store', () => {
let issue;
@@ -21,12 +21,14 @@ describe('Modal store', () => {
iid: 1,
confidential: false,
labels: [],
+ assignees: [],
});
issue2 = new ListIssue({
title: 'Testing',
iid: 2,
confidential: false,
labels: [],
+ assignees: [],
});
Store.store.issues.push(issue);
Store.store.issues.push(issue2);
diff --git a/spec/javascripts/bootstrap_linked_tabs_spec.js b/spec/javascripts/bootstrap_linked_tabs_spec.js
index fa9f95e16cd..93dc60d59fe 100644
--- a/spec/javascripts/bootstrap_linked_tabs_spec.js
+++ b/spec/javascripts/bootstrap_linked_tabs_spec.js
@@ -1,15 +1,6 @@
-require('~/lib/utils/bootstrap_linked_tabs');
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
(() => {
- // TODO: remove this hack!
- // PhantomJS causes spyOn to panic because replaceState isn't "writable"
- let phantomjs;
- try {
- phantomjs = !Object.getOwnPropertyDescriptor(window.history, 'replaceState').writable;
- } catch (err) {
- phantomjs = false;
- }
-
describe('Linked Tabs', () => {
preloadFixtures('static/linked_tabs.html.raw');
@@ -19,13 +10,11 @@ require('~/lib/utils/bootstrap_linked_tabs');
describe('when is initialized', () => {
beforeEach(() => {
- if (!phantomjs) {
- spyOn(window.history, 'replaceState').and.callFake(function () {});
- }
+ spyOn(window.history, 'replaceState').and.callFake(function () {});
});
it('should activate the tab correspondent to the given action', () => {
- const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
+ const linkedTabs = new LinkedTabs({ // eslint-disable-line
action: 'tab1',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
@@ -35,7 +24,7 @@ require('~/lib/utils/bootstrap_linked_tabs');
});
it('should active the default tab action when the action is show', () => {
- const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
+ const linkedTabs = new LinkedTabs({ // eslint-disable-line
action: 'show',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
@@ -47,9 +36,9 @@ require('~/lib/utils/bootstrap_linked_tabs');
describe('on click', () => {
it('should change the url according to the clicked tab', () => {
- const historySpy = !phantomjs && spyOn(history, 'replaceState').and.callFake(() => {});
+ const historySpy = spyOn(history, 'replaceState').and.callFake(() => {});
- const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
+ const linkedTabs = new LinkedTabs({
action: 'show',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
diff --git a/spec/javascripts/breakpoints_spec.js b/spec/javascripts/breakpoints_spec.js
new file mode 100644
index 00000000000..b1b5d36c1fb
--- /dev/null
+++ b/spec/javascripts/breakpoints_spec.js
@@ -0,0 +1,15 @@
+import bp, {
+ breakpoints,
+} from '~/breakpoints';
+
+describe('breakpoints', () => {
+ Object.keys(breakpoints).forEach((key) => {
+ const size = breakpoints[key];
+
+ it(`returns ${key} when larger than ${size}`, () => {
+ spyOn(bp, 'windowWidth').and.returnValue(size + 10);
+
+ expect(bp.getBreakpointSize()).toBe(key);
+ });
+ });
+});
diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js
index 8ec96bdb583..35149611095 100644
--- a/spec/javascripts/build_spec.js
+++ b/spec/javascripts/build_spec.js
@@ -5,16 +5,14 @@ import '~/lib/utils/datetime_utility';
import '~/lib/utils/url_utility';
import '~/build';
import '~/breakpoints';
-import 'vendor/jquery.nicescroll';
describe('Build', () => {
- const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`;
+ const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`;
preloadFixtures('builds/build-with-artifacts.html.raw');
beforeEach(() => {
loadFixtures('builds/build-with-artifacts.html.raw');
- spyOn($, 'ajax');
});
describe('class constructor', () => {
@@ -33,7 +31,6 @@ describe('Build', () => {
it('copies build options', function () {
expect(this.build.pageUrl).toBe(BUILD_URL);
- expect(this.build.buildUrl).toBe(`${BUILD_URL}.json`);
expect(this.build.buildStatus).toBe('success');
expect(this.build.buildStage).toBe('test');
expect(this.build.state).toBe('');
@@ -60,32 +57,19 @@ describe('Build', () => {
it('displays the remove date correctly', () => {
const removeDateElement = document.querySelector('.js-artifacts-remove');
- expect(removeDateElement.innerText.trim()).toBe('1 year');
+ expect(removeDateElement.innerText.trim()).toBe('1 year remaining');
});
});
describe('running build', () => {
- beforeEach(function () {
- this.build = new Build();
- });
-
it('updates the build trace on an interval', function () {
+ const deferred1 = $.Deferred();
+ const deferred2 = $.Deferred();
+ const deferred3 = $.Deferred();
+ spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise());
spyOn(gl.utils, 'visitUrl');
- jasmine.clock().tick(4001);
-
- 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($, {
+ deferred1.resolve({
html: '<span>Update<span>',
status: 'running',
state: 'newstate',
@@ -93,20 +77,9 @@ describe('Build', () => {
complete: false,
});
- expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
- expect(this.build.state).toBe('newstate');
-
- jasmine.clock().tick(4001);
-
- expect($.ajax.calls.count()).toBe(3);
-
- args = $.ajax.calls.argsFor(2)[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));
+ deferred2.resolve();
- args.success.call($, {
+ deferred3.resolve({
html: '<span>More</span>',
status: 'running',
state: 'finalstate',
@@ -114,151 +87,206 @@ describe('Build', () => {
complete: true,
});
+ this.build = new Build();
+
+ expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+ expect(this.build.state).toBe('newstate');
+
+ jasmine.clock().tick(4001);
+
expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/);
expect(this.build.state).toBe('finalstate');
});
it('replaces the entire build trace', () => {
+ const deferred1 = $.Deferred();
+ const deferred2 = $.Deferred();
+ const deferred3 = $.Deferred();
+
+ spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise());
+
spyOn(gl.utils, 'visitUrl');
- jasmine.clock().tick(4001);
- let args = $.ajax.calls.argsFor(0)[0];
- args.success.call($, {
- html: '<span>Update</span>',
+ deferred1.resolve({
+ html: '<span>Update<span>',
status: 'running',
append: false,
complete: false,
});
- expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+ deferred2.resolve();
- jasmine.clock().tick(4001);
- args = $.ajax.calls.argsFor(2)[0];
- args.success.call($, {
+ deferred3.resolve({
html: '<span>Different</span>',
status: 'running',
append: false,
});
- expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
- expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
- });
+ this.build = new Build();
- it('reloads the page when the build is done', () => {
- spyOn(gl.utils, 'visitUrl');
+ expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
jasmine.clock().tick(4001);
- 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);
+ expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
+ expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
});
+ });
- describe('truncated information', () => {
- describe('when size is less than total', () => {
- 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,
- size: 50,
- total: 100,
- });
-
- expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden');
+ describe('truncated information', () => {
+ describe('when size is less than total', () => {
+ it('shows information about truncated log', () => {
+ spyOn(gl.utils, 'visitUrl');
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ deferred.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size: 50,
+ total: 100,
});
- it('shows the size in KiB', () => {
- jasmine.clock().tick(4001);
- const [{ success }] = $.ajax.calls.argsFor(0);
- const size = 50;
-
- success.call($, {
- html: '<span>Update</span>',
- status: 'success',
- append: false,
- size,
- total: 100,
- });
-
- expect(
- document.querySelector('.js-truncated-info-size').textContent.trim(),
- ).toEqual(`${bytesToKiB(size)}`);
+ this.build = new Build();
+
+ expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden');
+ });
+
+ it('shows the size in KiB', () => {
+ const size = 50;
+ spyOn(gl.utils, 'visitUrl');
+ const deferred = $.Deferred();
+
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ deferred.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size,
+ total: 100,
});
- it('shows incremented size', () => {
- jasmine.clock().tick(4001);
- let args = $.ajax.calls.argsFor(0)[0];
- args.success.call($, {
- html: '<span>Update</span>',
- status: 'success',
- append: false,
- size: 50,
- total: 100,
- });
-
- expect(
- document.querySelector('.js-truncated-info-size').textContent.trim(),
- ).toEqual(`${bytesToKiB(50)}`);
-
- jasmine.clock().tick(4001);
- args = $.ajax.calls.argsFor(2)[0];
- args.success.call($, {
- html: '<span>Update</span>',
- status: 'success',
- append: true,
- size: 10,
- total: 100,
- });
-
- expect(
- document.querySelector('.js-truncated-info-size').textContent.trim(),
- ).toEqual(`${bytesToKiB(60)}`);
+ this.build = new Build();
+
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${bytesToKiB(size)}`);
+ });
+
+ it('shows incremented size', () => {
+ const deferred1 = $.Deferred();
+ const deferred2 = $.Deferred();
+ const deferred3 = $.Deferred();
+
+ spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise());
+
+ spyOn(gl.utils, 'visitUrl');
+
+ deferred1.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size: 50,
+ total: 100,
});
- it('renders the raw link', () => {
- jasmine.clock().tick(4001);
- const [{ success }] = $.ajax.calls.argsFor(0);
-
- success.call($, {
- html: '<span>Update</span>',
- status: 'success',
- append: false,
- size: 50,
- total: 100,
- });
-
- expect(
- document.querySelector('.js-raw-link').textContent.trim(),
- ).toContain('Complete Raw');
+ deferred2.resolve();
+
+ this.build = new Build();
+
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${bytesToKiB(50)}`);
+
+ jasmine.clock().tick(4001);
+
+ deferred3.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: true,
+ size: 10,
+ total: 100,
});
+
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${bytesToKiB(60)}`);
});
- describe('when size is equal than total', () => {
- it('does not show the trunctated information', () => {
- jasmine.clock().tick(4001);
- const [{ success }] = $.ajax.calls.argsFor(0);
+ it('renders the raw link', () => {
+ const deferred = $.Deferred();
+ spyOn(gl.utils, 'visitUrl');
+
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ deferred.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size: 50,
+ total: 100,
+ });
+
+ this.build = new Build();
- success.call($, {
- html: '<span>Update</span>',
- status: 'success',
- append: false,
- size: 100,
- total: 100,
- });
+ expect(
+ document.querySelector('.js-raw-link').textContent.trim(),
+ ).toContain('Complete Raw');
+ });
+ });
- expect(document.querySelector('.js-truncated-info').classList).toContain('hidden');
+ describe('when size is equal than total', () => {
+ it('does not show the trunctated information', () => {
+ const deferred = $.Deferred();
+ spyOn(gl.utils, 'visitUrl');
+
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ deferred.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size: 100,
+ total: 100,
});
+
+ this.build = new Build();
+
+ expect(document.querySelector('.js-truncated-info').classList).toContain('hidden');
});
});
});
+
+ describe('output trace', () => {
+ beforeEach(() => {
+ const deferred = $.Deferred();
+ spyOn(gl.utils, 'visitUrl');
+
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ deferred.resolve({
+ html: '<span>Update</span>',
+ status: 'success',
+ append: false,
+ size: 50,
+ total: 100,
+ });
+
+ this.build = new Build();
+ });
+
+ it('should render trace controls', () => {
+ const controllers = document.querySelector('.controllers');
+
+ expect(controllers.querySelector('.js-raw-link-controller')).toBeDefined();
+ expect(controllers.querySelector('.js-erase-link')).toBeDefined();
+ expect(controllers.querySelector('.js-scroll-up')).toBeDefined();
+ expect(controllers.querySelector('.js-scroll-down')).toBeDefined();
+ });
+
+ it('should render received output', () => {
+ expect(
+ document.querySelector('.js-build-output').innerHTML,
+ ).toEqual('<span>Update</span>');
+ });
+ });
});
});
diff --git a/spec/javascripts/ci_status_icon_spec.js b/spec/javascripts/ci_status_icon_spec.js
deleted file mode 100644
index c83416c15ef..00000000000
--- a/spec/javascripts/ci_status_icon_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as icons from '~/ci_status_icons';
-
-describe('CI status icons', () => {
- const statuses = [
- 'canceled',
- 'created',
- 'failed',
- 'manual',
- 'pending',
- 'running',
- 'skipped',
- 'success',
- 'warning',
- ];
-
- statuses.forEach((status) => {
- it(`should export a ${status} svg`, () => {
- const key = `${status.toUpperCase()}_SVG`;
-
- expect(Object.hasOwnProperty.call(icons, key)).toBe(true);
- expect(icons[key]).toMatch(/^<svg/);
- });
- });
-
- describe('default export map', () => {
- const entityIconNames = [
- 'icon_status_canceled',
- 'icon_status_created',
- 'icon_status_failed',
- 'icon_status_manual',
- 'icon_status_pending',
- 'icon_status_running',
- 'icon_status_skipped',
- 'icon_status_success',
- 'icon_status_warning',
- ];
-
- entityIconNames.forEach((iconName) => {
- it(`should have a '${iconName}' key`, () => {
- expect(Object.hasOwnProperty.call(icons.default, iconName)).toBe(true);
- });
- });
- });
-});
diff --git a/spec/javascripts/close_reopen_report_toggle_spec.js b/spec/javascripts/close_reopen_report_toggle_spec.js
new file mode 100644
index 00000000000..925e959c85a
--- /dev/null
+++ b/spec/javascripts/close_reopen_report_toggle_spec.js
@@ -0,0 +1,270 @@
+import CloseReopenReportToggle from '~/close_reopen_report_toggle';
+import DropLab from '~/droplab/drop_lab';
+
+describe('CloseReopenReportToggle', () => {
+ describe('class constructor', () => {
+ const dropdownTrigger = {};
+ const dropdownList = {};
+ const button = {};
+ let commentTypeToggle;
+
+ beforeEach(function () {
+ commentTypeToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('sets .dropdownTrigger', function () {
+ expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger);
+ });
+
+ it('sets .dropdownList', function () {
+ expect(commentTypeToggle.dropdownList).toBe(dropdownList);
+ });
+
+ it('sets .button', function () {
+ expect(commentTypeToggle.button).toBe(button);
+ });
+ });
+
+ describe('initDroplab', () => {
+ let closeReopenReportToggle;
+ const dropdownList = jasmine.createSpyObj('dropdownList', ['querySelector']);
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+ const config = {};
+
+ beforeEach(() => {
+ spyOn(DropLab.prototype, 'init');
+ dropdownList.querySelector.and.returnValues(reopenItem, closeItem);
+
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ spyOn(closeReopenReportToggle, 'setConfig').and.returnValue(config);
+
+ closeReopenReportToggle.initDroplab();
+ });
+
+ it('sets .reopenItem and .closeItem', () => {
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item');
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item');
+ expect(closeReopenReportToggle.reopenItem).toBe(reopenItem);
+ expect(closeReopenReportToggle.closeItem).toBe(closeItem);
+ });
+
+ it('sets .droplab', () => {
+ expect(closeReopenReportToggle.droplab).toEqual(jasmine.any(Object));
+ });
+
+ it('calls .setConfig', () => {
+ expect(closeReopenReportToggle.setConfig).toHaveBeenCalled();
+ });
+
+ it('calls droplab.init', () => {
+ expect(DropLab.prototype.init).toHaveBeenCalledWith(
+ dropdownTrigger,
+ dropdownList,
+ jasmine.any(Array),
+ config,
+ );
+ });
+ });
+
+ describe('updateButton', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = jasmine.createSpyObj('button', ['blur']);
+ const isClosed = true;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ spyOn(closeReopenReportToggle, 'toggleButtonType');
+
+ closeReopenReportToggle.updateButton(isClosed);
+ });
+
+ it('calls .toggleButtonType', () => {
+ expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('calls .button.blur', () => {
+ expect(closeReopenReportToggle.button.blur).toHaveBeenCalled();
+ });
+ });
+
+ describe('toggleButtonType', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const isClosed = true;
+ const showItem = jasmine.createSpyObj('showItem', ['click']);
+ const hideItem = {};
+ showItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
+ hideItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ spyOn(closeReopenReportToggle, 'getButtonTypes').and.returnValue([showItem, hideItem]);
+
+ closeReopenReportToggle.toggleButtonType(isClosed);
+ });
+
+ it('calls .getButtonTypes', () => {
+ expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('removes hide class and add selected class to showItem, opposite for hideItem', () => {
+ expect(showItem.classList.remove).toHaveBeenCalledWith('hidden');
+ expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected');
+ expect(hideItem.classList.add).toHaveBeenCalledWith('hidden');
+ expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected');
+ });
+
+ it('clicks the showItem', () => {
+ expect(showItem.click).toHaveBeenCalled();
+ });
+ });
+
+ describe('getButtonTypes', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ closeReopenReportToggle.reopenItem = reopenItem;
+ closeReopenReportToggle.closeItem = closeItem;
+ });
+
+ it('returns reopenItem, closeItem if isClosed is true', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(true);
+
+ expect(buttonTypes).toEqual([reopenItem, closeItem]);
+ });
+
+ it('returns closeItem, reopenItem if isClosed is false', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(false);
+
+ expect(buttonTypes).toEqual([closeItem, reopenItem]);
+ });
+ });
+
+ describe('setDisable', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
+ const button = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is true', () => {
+ closeReopenReportToggle.setDisable(true);
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => {
+ closeReopenReportToggle.setDisable();
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('enable .button and .dropdownTrigger if shouldDisable is false', () => {
+ closeReopenReportToggle.setDisable(false);
+
+ expect(button.removeAttribute).toHaveBeenCalledWith('disabled');
+ expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled');
+ });
+ });
+
+ describe('setConfig', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ let config;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ config = closeReopenReportToggle.setConfig();
+ });
+
+ it('returns a config object', () => {
+ expect(config).toEqual({
+ InputSetter: [
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'data-value',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'title',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-button-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: dropdownTrigger,
+ valueAttribute: 'data-toggle-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-url',
+ inputAttribute: 'href',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-method',
+ inputAttribute: 'data-method',
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js
deleted file mode 100644
index 82b00b4c1ec..00000000000
--- a/spec/javascripts/commit/pipelines/mock_data.js
+++ /dev/null
@@ -1,89 +0,0 @@
-export default {
- id: 73,
- user: {
- name: 'Administrator',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- path: '/root/review-app/pipelines/73',
- details: {
- status: {
- icon: 'icon_status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- has_details: true,
- details_path: '/root/review-app/pipelines/73',
- },
- duration: null,
- finished_at: '2017-01-25T00:00:17.130Z',
- stages: [{
- name: 'build',
- title: 'build: failed',
- status: {
- icon: 'icon_status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- has_details: true,
- details_path: '/root/review-app/pipelines/73#build',
- },
- path: '/root/review-app/pipelines/73#build',
- dropdown_path: '/root/review-app/pipelines/73/stage.json?stage=build',
- }],
- artifacts: [],
- manual_actions: [
- {
- name: 'stop_review',
- path: '/root/review-app/builds/1463/play',
- },
- {
- name: 'name',
- path: '/root/review-app/builds/1490/play',
- },
- ],
- },
- flags: {
- latest: true,
- triggered: false,
- stuck: false,
- yaml_errors: false,
- retryable: true,
- cancelable: false,
- },
- ref:
- {
- name: 'master',
- path: '/root/review-app/tree/master',
- tag: false,
- branch: true,
- },
- commit: {
- id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4',
- short_id: 'fbd79f04',
- title: 'Update .gitlab-ci.yml',
- author_name: 'Administrator',
- author_email: 'admin@example.com',
- created_at: '2017-01-16T12:13:57.000-05:00',
- committer_name: 'Administrator',
- committer_email: 'admin@example.com',
- message: 'Update .gitlab-ci.yml',
- author: {
- name: 'Administrator',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- commit_url: 'http://localhost:3000/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4',
- commit_path: '/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4',
- },
- retry_path: '/root/review-app/pipelines/73/retry',
- created_at: '2017-01-16T17:13:59.800Z',
- updated_at: '2017-01-25T00:00:17.132Z',
-};
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js
index ad31448f81c..a34cadec0ab 100644
--- a/spec/javascripts/commit/pipelines/pipelines_spec.js
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js
@@ -1,12 +1,17 @@
import Vue from 'vue';
-import PipelinesTable from '~/commit/pipelines/pipelines_table';
-import pipeline from './mock_data';
+import pipelinesTable from '~/commit/pipelines/pipelines_table.vue';
describe('Pipelines table in Commits and Merge requests', () => {
- preloadFixtures('static/pipelines_table.html.raw');
+ const jsonFixtureName = 'pipelines/pipelines.json';
+ let pipeline;
+ let PipelinesTable;
+
+ preloadFixtures(jsonFixtureName);
beforeEach(() => {
- loadFixtures('static/pipelines_table.html.raw');
+ PipelinesTable = Vue.extend(pipelinesTable);
+ const pipelines = getJSONFixture(jsonFixtureName).pipelines;
+ pipeline = pipelines.find(p => p.id === 1);
});
describe('successful request', () => {
@@ -21,8 +26,11 @@ describe('Pipelines table in Commits and Merge requests', () => {
Vue.http.interceptors.push(pipelinesEmptyResponse);
this.component = new PipelinesTable({
- el: document.querySelector('#commit-pipeline-table-view'),
- });
+ propsData: {
+ endpoint: 'endpoint',
+ helpPagePath: 'foo',
+ },
+ }).$mount();
});
afterEach(function () {
@@ -53,8 +61,11 @@ describe('Pipelines table in Commits and Merge requests', () => {
Vue.http.interceptors.push(pipelinesResponse);
this.component = new PipelinesTable({
- el: document.querySelector('#commit-pipeline-table-view'),
- });
+ propsData: {
+ endpoint: 'endpoint',
+ helpPagePath: 'foo',
+ },
+ }).$mount();
});
afterEach(() => {
@@ -66,7 +77,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
it('should render a table with the received pipelines', (done) => {
setTimeout(() => {
- expect(this.component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1);
+ expect(this.component.$el.querySelectorAll('.ci-table .commit').length).toEqual(1);
expect(this.component.$el.querySelector('.realtime-loading')).toBe(null);
expect(this.component.$el.querySelector('.empty-state')).toBe(null);
expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBe(null);
@@ -74,6 +85,41 @@ describe('Pipelines table in Commits and Merge requests', () => {
}, 0);
});
});
+
+ describe('pipeline badge counts', () => {
+ const pipelinesResponse = (request, next) => {
+ next(request.respondWith(JSON.stringify([pipeline]), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(pipelinesResponse);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, pipelinesResponse);
+ this.component.$destroy();
+ });
+
+ it('should receive update-pipelines-count event', (done) => {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+
+ element.addEventListener('update-pipelines-count', (event) => {
+ expect(event.detail.pipelines).toEqual([pipeline]);
+ done();
+ });
+
+ this.component = new PipelinesTable({
+ propsData: {
+ endpoint: 'endpoint',
+ helpPagePath: 'foo',
+ },
+ }).$mount();
+ element.appendChild(this.component.$el);
+ });
+ });
});
describe('unsuccessfull request', () => {
@@ -87,8 +133,11 @@ describe('Pipelines table in Commits and Merge requests', () => {
Vue.http.interceptors.push(pipelinesErrorResponse);
this.component = new PipelinesTable({
- el: document.querySelector('#commit-pipeline-table-view'),
- });
+ propsData: {
+ endpoint: 'endpoint',
+ helpPagePath: 'foo',
+ },
+ }).$mount();
});
afterEach(function () {
@@ -103,7 +152,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
expect(this.component.$el.querySelector('.realtime-loading')).toBe(null);
expect(this.component.$el.querySelector('.js-empty-state')).toBe(null);
- expect(this.component.$el.querySelector('table')).toBe(null);
+ expect(this.component.$el.querySelector('.ci-table')).toBe(null);
done();
}, 0);
});
diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js
index 05260760c43..ace95000468 100644
--- a/spec/javascripts/commits_spec.js
+++ b/spec/javascripts/commits_spec.js
@@ -1,19 +1,10 @@
/* global CommitsList */
-require('vendor/jquery.endless-scroll');
-require('~/pager');
-require('~/commits');
+import 'vendor/jquery.endless-scroll';
+import '~/pager';
+import '~/commits';
(() => {
- // TODO: remove this hack!
- // PhantomJS causes spyOn to panic because replaceState isn't "writable"
- let phantomjs;
- try {
- phantomjs = !Object.getOwnPropertyDescriptor(window.history, 'replaceState').writable;
- } catch (err) {
- phantomjs = false;
- }
-
describe('Commits List', () => {
beforeEach(() => {
setFixtures(`
@@ -28,6 +19,32 @@ require('~/commits');
expect(CommitsList).toBeDefined();
});
+ describe('processCommits', () => {
+ it('should join commit headers', () => {
+ CommitsList.$contentList = $(`
+ <div>
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ </div>
+ `);
+
+ const data = `
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ `;
+
+ // The last commit header should be removed
+ // since the previous one has the same data-day value.
+ expect(CommitsList.processCommits(data).find('li.commit-header').length).toBe(0);
+ });
+ });
+
describe('on entering input', () => {
let ajaxSpy;
@@ -35,9 +52,7 @@ require('~/commits');
CommitsList.init(25);
CommitsList.searchField.val('');
- if (!phantomjs) {
- spyOn(history, 'replaceState').and.stub();
- }
+ spyOn(history, 'replaceState').and.stub();
ajaxSpy = spyOn(jQuery, 'ajax').and.callFake((req) => {
req.success({
data: '<li>Result</li>',
diff --git a/spec/javascripts/copy_as_gfm_spec.js b/spec/javascripts/copy_as_gfm_spec.js
new file mode 100644
index 00000000000..ded450749d3
--- /dev/null
+++ b/spec/javascripts/copy_as_gfm_spec.js
@@ -0,0 +1,49 @@
+import '~/copy_as_gfm';
+
+(() => {
+ describe('gl.CopyAsGFM', () => {
+ describe('gl.CopyAsGFM.pasteGFM', () => {
+ function callPasteGFM() {
+ const e = {
+ originalEvent: {
+ clipboardData: {
+ getData(mimeType) {
+ // When GFM code is copied, we put the regular plain text
+ // on the clipboard as `text/plain`, and the GFM as `text/x-gfm`.
+ // This emulates the behavior of `getData` with that data.
+ if (mimeType === 'text/plain') {
+ return 'code';
+ }
+ if (mimeType === 'text/x-gfm') {
+ return '`code`';
+ }
+ return null;
+ },
+ },
+ },
+ preventDefault() {},
+ };
+
+ window.gl.CopyAsGFM.pasteGFM(e);
+ }
+
+ it('wraps pasted code when not already in code tags', () => {
+ spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => {
+ const insertedText = textFunc('This is code: ', '');
+ expect(insertedText).toEqual('`code`');
+ });
+
+ callPasteGFM();
+ });
+
+ it('does not wrap pasted code when already in code tags', () => {
+ spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => {
+ const insertedText = textFunc('This is code: `', '`');
+ expect(insertedText).toEqual('code');
+ });
+
+ callPasteGFM();
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js
index 50000c5a5f5..2fb9eb0ca85 100644
--- a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js
+++ b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
import limitWarningComp from '~/cycle_analytics/components/limit_warning_component';
+Vue.use(Translate);
+
describe('Limit warning component', () => {
let component;
let LimitWarningComponent;
diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js
index d5eec10be42..3391cade541 100644
--- a/spec/javascripts/datetime_utility_spec.js
+++ b/spec/javascripts/datetime_utility_spec.js
@@ -1,7 +1,31 @@
-require('~/lib/utils/datetime_utility');
+import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
(() => {
describe('Date time utils', () => {
+ describe('timeFor', () => {
+ it('returns `past due` when in past', () => {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() - 1);
+
+ expect(
+ gl.utils.timeFor(date),
+ ).toBe('Past due');
+ });
+
+ it('returns remaining time when in the future', () => {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() + 1);
+
+ // Add a day to prevent a transient error. If date is even 1 second
+ // short of a full year, timeFor will return '11 months remaining'
+ date.setDate(date.getDate() + 1);
+
+ expect(
+ gl.utils.timeFor(date),
+ ).toBe('1 year remaining');
+ });
+ });
+
describe('get day name', () => {
it('should return Sunday', () => {
const day = gl.utils.getDayName(new Date('07/17/2016'));
@@ -62,4 +86,13 @@ require('~/lib/utils/datetime_utility');
});
});
});
+
+ describe('timeIntervalInWords', () => {
+ it('should return string with number of minutes and seconds', () => {
+ expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
+ expect(timeIntervalInWords(1)).toEqual('1 second');
+ expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
+ expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
+ });
+ });
})();
diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js
new file mode 100644
index 00000000000..5b93fbc5575
--- /dev/null
+++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+import eventHub from '~/deploy_keys/eventhub';
+import actionBtn from '~/deploy_keys/components/action_btn.vue';
+
+describe('Deploy keys action btn', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ const deployKey = data.enabled_keys[0];
+ let vm;
+
+ beforeEach((done) => {
+ const ActionBtnComponent = Vue.extend(actionBtn);
+
+ vm = new ActionBtnComponent({
+ propsData: {
+ deployKey,
+ type: 'enable',
+ },
+ }).$mount();
+
+ setTimeout(done);
+ });
+
+ it('renders the type as uppercase', () => {
+ expect(
+ vm.$el.textContent.trim(),
+ ).toBe('Enable');
+ });
+
+ it('sends eventHub event with btn type', (done) => {
+ spyOn(eventHub, '$emit');
+
+ vm.$el.click();
+
+ setTimeout(() => {
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('enable.key', deployKey);
+
+ done();
+ });
+ });
+
+ it('shows loading spinner after click', (done) => {
+ vm.$el.click();
+
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('.fa'),
+ ).toBeDefined();
+
+ done();
+ });
+ });
+
+ it('disables button after click', (done) => {
+ vm.$el.click();
+
+ setTimeout(() => {
+ expect(
+ vm.$el.classList.contains('disabled'),
+ ).toBeTruthy();
+
+ expect(
+ vm.$el.getAttribute('disabled'),
+ ).toBe('disabled');
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js
new file mode 100644
index 00000000000..700897f50b0
--- /dev/null
+++ b/spec/javascripts/deploy_keys/components/app_spec.js
@@ -0,0 +1,142 @@
+import Vue from 'vue';
+import eventHub from '~/deploy_keys/eventhub';
+import deployKeysApp from '~/deploy_keys/components/app.vue';
+
+describe('Deploy keys app component', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let vm;
+
+ const deployKeysResponse = (request, next) => {
+ next(request.respondWith(JSON.stringify(data), {
+ status: 200,
+ }));
+ };
+
+ beforeEach((done) => {
+ const Component = Vue.extend(deployKeysApp);
+
+ Vue.http.interceptors.push(deployKeysResponse);
+
+ vm = new Component({
+ propsData: {
+ endpoint: '/test',
+ },
+ }).$mount();
+
+ setTimeout(done);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse);
+ });
+
+ it('renders loading icon', (done) => {
+ vm.store.keys = {};
+ vm.isLoading = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.deploy-keys-panel').length,
+ ).toBe(0);
+
+ expect(
+ vm.$el.querySelector('.fa-spinner'),
+ ).toBeDefined();
+
+ done();
+ });
+ });
+
+ it('renders keys panels', () => {
+ expect(
+ vm.$el.querySelectorAll('.deploy-keys-panel').length,
+ ).toBe(3);
+ });
+
+ it('does not render key panels when keys object is empty', (done) => {
+ vm.store.keys = {};
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.deploy-keys-panel').length,
+ ).toBe(0);
+
+ done();
+ });
+ });
+
+ it('does not render public panel when empty', (done) => {
+ vm.store.keys.public_keys = [];
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.deploy-keys-panel').length,
+ ).toBe(2);
+
+ done();
+ });
+ });
+
+ it('re-fetches deploy keys when enabling a key', (done) => {
+ const key = data.public_keys[0];
+
+ spyOn(vm.service, 'getKeys');
+ spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => {
+ resolve();
+
+ setTimeout(() => {
+ expect(vm.service.getKeys).toHaveBeenCalled();
+
+ done();
+ });
+ }));
+
+ eventHub.$emit('enable.key', key);
+
+ expect(vm.service.enableKey).toHaveBeenCalledWith(key.id);
+ });
+
+ it('re-fetches deploy keys when disabling a key', (done) => {
+ const key = data.public_keys[0];
+
+ spyOn(window, 'confirm').and.returnValue(true);
+ spyOn(vm.service, 'getKeys');
+ spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => {
+ resolve();
+
+ setTimeout(() => {
+ expect(vm.service.getKeys).toHaveBeenCalled();
+
+ done();
+ });
+ }));
+
+ eventHub.$emit('disable.key', key);
+
+ expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ });
+
+ it('calls disableKey when removing a key', (done) => {
+ const key = data.public_keys[0];
+
+ spyOn(window, 'confirm').and.returnValue(true);
+ spyOn(vm.service, 'getKeys');
+ spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => {
+ resolve();
+
+ setTimeout(() => {
+ expect(vm.service.getKeys).toHaveBeenCalled();
+
+ done();
+ });
+ }));
+
+ eventHub.$emit('remove.key', key);
+
+ expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ });
+
+ it('hasKeys returns true when there are keys', () => {
+ expect(vm.hasKeys).toEqual(3);
+ });
+});
diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js
new file mode 100644
index 00000000000..5b64cbb2dfc
--- /dev/null
+++ b/spec/javascripts/deploy_keys/components/key_spec.js
@@ -0,0 +1,105 @@
+import Vue from 'vue';
+import DeployKeysStore from '~/deploy_keys/store';
+import key from '~/deploy_keys/components/key.vue';
+
+describe('Deploy keys key', () => {
+ let vm;
+ const KeyComponent = Vue.extend(key);
+ const data = getJSONFixture('deploy_keys/keys.json');
+ const createComponent = (deployKey) => {
+ const store = new DeployKeysStore();
+ store.keys = data;
+
+ vm = new KeyComponent({
+ propsData: {
+ deployKey,
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ },
+ }).$mount();
+ };
+
+ describe('enabled key', () => {
+ const deployKey = data.enabled_keys[0];
+
+ beforeEach((done) => {
+ createComponent(deployKey);
+
+ setTimeout(done);
+ });
+
+ it('renders the keys title', () => {
+ expect(
+ vm.$el.querySelector('.title').textContent.trim(),
+ ).toContain('My title');
+ });
+
+ it('renders human friendly formatted created date', () => {
+ expect(
+ vm.$el.querySelector('.key-created-at').textContent.trim(),
+ ).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`);
+ });
+
+ it('shows edit button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
+ ).toBe('Edit');
+ });
+
+ it('shows remove button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
+ ).toBe('Remove');
+ });
+
+ it('shows write access text when key has write access', (done) => {
+ vm.deployKey.can_push = true;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.write-access-allowed'),
+ ).not.toBeNull();
+
+ expect(
+ vm.$el.querySelector('.write-access-allowed').textContent.trim(),
+ ).toBe('Write access allowed');
+
+ done();
+ });
+ });
+ });
+
+ describe('public keys', () => {
+ const deployKey = data.public_keys[0];
+
+ beforeEach((done) => {
+ createComponent(deployKey);
+
+ setTimeout(done);
+ });
+
+ it('shows edit button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
+ ).toBe('Edit');
+ });
+
+ it('shows enable button', () => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
+ ).toBe('Enable');
+ });
+
+ it('shows disable button when key is enabled', (done) => {
+ vm.store.keys.enabled_keys.push(deployKey);
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
+ ).toBe('Disable');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js
new file mode 100644
index 00000000000..08357d2b547
--- /dev/null
+++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js
@@ -0,0 +1,71 @@
+import Vue from 'vue';
+import DeployKeysStore from '~/deploy_keys/store';
+import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
+
+describe('Deploy keys panel', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let vm;
+
+ beforeEach((done) => {
+ const DeployKeysPanelComponent = Vue.extend(deployKeysPanel);
+ const store = new DeployKeysStore();
+ store.keys = data;
+
+ vm = new DeployKeysPanelComponent({
+ propsData: {
+ title: 'test',
+ keys: data.enabled_keys,
+ showHelpBox: true,
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ },
+ }).$mount();
+
+ setTimeout(done);
+ });
+
+ it('renders the title with keys count', () => {
+ expect(
+ vm.$el.querySelector('h5').textContent.trim(),
+ ).toContain('test');
+
+ expect(
+ vm.$el.querySelector('h5').textContent.trim(),
+ ).toContain(`(${vm.keys.length})`);
+ });
+
+ it('renders list of keys', () => {
+ expect(
+ vm.$el.querySelectorAll('li').length,
+ ).toBe(vm.keys.length);
+ });
+
+ it('renders help box if keys are empty', (done) => {
+ vm.keys = [];
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.settings-message'),
+ ).toBeDefined();
+
+ expect(
+ vm.$el.querySelector('.settings-message').textContent.trim(),
+ ).toBe('No deploy keys found. Create one with the form above.');
+
+ done();
+ });
+ });
+
+ it('does not render help box if keys are empty & showHelpBox is false', (done) => {
+ vm.keys = [];
+ vm.showHelpBox = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.settings-message'),
+ ).toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/diff_comments_store_spec.js b/spec/javascripts/diff_comments_store_spec.js
index 66ece7e4f41..d6fc6b56b82 100644
--- a/spec/javascripts/diff_comments_store_spec.js
+++ b/spec/javascripts/diff_comments_store_spec.js
@@ -1,9 +1,9 @@
/* eslint-disable jasmine/no-global-setup, dot-notation, jasmine/no-expect-in-setup-teardown, max-len */
/* global CommentsStore */
-require('~/diff_notes/models/discussion');
-require('~/diff_notes/models/note');
-require('~/diff_notes/stores/comments');
+import '~/diff_notes/models/discussion';
+import '~/diff_notes/models/note';
+import '~/diff_notes/stores/comments';
function createDiscussion(noteId = 1, resolved = true) {
CommentsStore.create({
diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js
index fd153a49fcd..b9d28db74cc 100644
--- a/spec/javascripts/droplab/constants_spec.js
+++ b/spec/javascripts/droplab/constants_spec.js
@@ -27,6 +27,12 @@ describe('constants', function () {
});
});
+ describe('TEMPLATE_REGEX', function () {
+ it('should be a handlebars templating syntax regex', function() {
+ expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g);
+ });
+ });
+
describe('IGNORE_CLASS', function () {
it('should be `droplab-item-ignore`', function() {
expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore');
diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js
index 7516b301917..2bbcebeeac0 100644
--- a/spec/javascripts/droplab/drop_down_spec.js
+++ b/spec/javascripts/droplab/drop_down_spec.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
import DropDown from '~/droplab/drop_down';
import utils from '~/droplab/utils';
import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants';
@@ -17,7 +15,7 @@ describe('DropDown', function () {
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);
@@ -152,7 +150,7 @@ describe('DropDown', function () {
it('should call addSelectedClass', function () {
expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement);
- })
+ });
it('should call .preventDefault', function () {
expect(this.event.preventDefault).toHaveBeenCalled();
@@ -293,36 +291,6 @@ describe('DropDown', 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: () => {} };
@@ -399,7 +367,7 @@ describe('DropDown', function () {
expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function));
});
- it('should call .renderChildren for each data item', function() {
+ it('should call .renderChildren for each data item', function () {
expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length);
});
@@ -407,7 +375,7 @@ describe('DropDown', function () {
expect(this.renderableList.innerHTML).toBe('01');
});
- describe('if no data argument is passed' , function () {
+ describe('if no data argument is passed', function () {
beforeEach(function () {
this.data.map.calls.reset();
this.dropdown.renderChildren.calls.reset();
@@ -446,20 +414,20 @@ describe('DropDown', function () {
describe('renderChildren', function () {
beforeEach(function () {
this.templateString = 'templateString';
- this.dropdown = { setImagesSrc: () => {}, templateString: this.templateString };
+ this.dropdown = { 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(utils, 'template').and.returnValue(this.html);
spyOn(document, 'createElement').and.returnValue(this.template);
- spyOn(this.dropdown, 'setImagesSrc');
+ spyOn(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);
+ expect(utils.template).toHaveBeenCalledWith(this.templateString, this.data);
});
it('should call document.createElement', function () {
@@ -471,7 +439,7 @@ describe('DropDown', function () {
});
it('should call .setImagesSrc with the template', function () {
- expect(this.dropdown.setImagesSrc).toHaveBeenCalledWith(this.template);
+ expect(DropDown.setImagesSrc).toHaveBeenCalledWith(this.template);
});
it('should set the template display to none', function () {
@@ -496,12 +464,11 @@ describe('DropDown', function () {
describe('setImagesSrc', function () {
beforeEach(function () {
- this.dropdown = {};
this.template = { querySelectorAll: () => {} };
spyOn(this.template, 'querySelectorAll').and.returnValue([]);
- DropDown.prototype.setImagesSrc.call(this.dropdown, this.template);
+ DropDown.setImagesSrc(this.template);
});
it('should call .querySelectorAll', function () {
@@ -562,7 +529,7 @@ describe('DropDown', function () {
describe('toggle', function () {
beforeEach(function () {
- this.hidden = true
+ this.hidden = true;
this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} };
spyOn(this.dropdown, 'show');
@@ -577,7 +544,7 @@ describe('DropDown', function () {
describe('if .hidden is false', function () {
beforeEach(function () {
- this.hidden = false
+ this.hidden = false;
this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} };
spyOn(this.dropdown, 'show');
diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js
index 8ebdcdd1404..75bf5f3d611 100644
--- a/spec/javascripts/droplab/hook_spec.js
+++ b/spec/javascripts/droplab/hook_spec.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
import Hook from '~/droplab/hook';
import * as dropdownSrc from '~/droplab/drop_down';
@@ -73,10 +71,4 @@ describe('Hook', function () {
});
});
});
-
- describe('addEvents', function () {
- it('should exist', function () {
- expect(Hook.prototype.hasOwnProperty('addEvents')).toBe(true);
- });
- });
});
diff --git a/spec/javascripts/droplab/plugins/ajax_filter_spec.js b/spec/javascripts/droplab/plugins/ajax_filter_spec.js
new file mode 100644
index 00000000000..8155d98b543
--- /dev/null
+++ b/spec/javascripts/droplab/plugins/ajax_filter_spec.js
@@ -0,0 +1,72 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+import AjaxFilter from '~/droplab/plugins/ajax_filter';
+
+describe('AjaxFilter', () => {
+ let dummyConfig;
+ const dummyData = 'dummy data';
+ let dummyList;
+
+ beforeEach(() => {
+ dummyConfig = {
+ endpoint: 'dummy endpoint',
+ searchKey: 'dummy search key',
+ };
+ dummyList = {
+ data: [],
+ list: document.createElement('div'),
+ };
+
+ AjaxFilter.hook = {
+ config: {
+ AjaxFilter: dummyConfig,
+ },
+ list: dummyList,
+ };
+ });
+
+ describe('trigger', () => {
+ let ajaxSpy;
+
+ beforeEach(() => {
+ spyOn(AjaxCache, 'retrieve').and.callFake(url => ajaxSpy(url));
+ spyOn(AjaxFilter, '_loadData');
+
+ dummyConfig.onLoadingFinished = jasmine.createSpy('spy');
+
+ const dynamicList = document.createElement('div');
+ dynamicList.dataset.dynamic = true;
+ dummyList.list.appendChild(dynamicList);
+ });
+
+ it('calls onLoadingFinished after loading data', (done) => {
+ ajaxSpy = (url) => {
+ expect(url).toBe('dummy endpoint?dummy search key=');
+ return Promise.resolve(dummyData);
+ };
+
+ AjaxFilter.trigger()
+ .then(() => {
+ expect(dummyConfig.onLoadingFinished.calls.count()).toBe(1);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not call onLoadingFinished if Ajax call fails', (done) => {
+ const dummyError = new Error('My dummy is sick! :-(');
+ ajaxSpy = (url) => {
+ expect(url).toBe('dummy endpoint?dummy search key=');
+ return Promise.reject(dummyError);
+ };
+
+ AjaxFilter.trigger()
+ .then(done.fail)
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(dummyConfig.onLoadingFinished.calls.count()).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/droplab/plugins/ajax_spec.js b/spec/javascripts/droplab/plugins/ajax_spec.js
new file mode 100644
index 00000000000..085f25764fe
--- /dev/null
+++ b/spec/javascripts/droplab/plugins/ajax_spec.js
@@ -0,0 +1,36 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+import Ajax from '~/droplab/plugins/ajax';
+
+describe('Ajax', () => {
+ describe('preprocessing', () => {
+ const config = {};
+
+ describe('is not configured', () => {
+ it('passes the data through', () => {
+ const data = ['data'];
+ expect(Ajax.preprocessing(config, data)).toEqual(data);
+ });
+ });
+
+ describe('is configured', () => {
+ const processedArray = ['processed'];
+
+ beforeEach(() => {
+ config.preprocessing = () => processedArray;
+ spyOn(config, 'preprocessing').and.callFake(() => processedArray);
+ });
+
+ it('calls preprocessing', () => {
+ Ajax.preprocessing(config, []);
+ expect(config.preprocessing.calls.count()).toBe(1);
+ });
+
+ it('overrides AjaxCache', () => {
+ spyOn(AjaxCache, 'override').and.callFake((endpoint, results) => expect(results).toEqual(processedArray));
+
+ Ajax.preprocessing(config, []);
+ expect(AjaxCache.override.calls.count()).toBe(1);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/emoji_spec.js b/spec/javascripts/emoji_spec.js
new file mode 100644
index 00000000000..fa11c602ec3
--- /dev/null
+++ b/spec/javascripts/emoji_spec.js
@@ -0,0 +1,429 @@
+import { glEmojiTag } from '~/emoji';
+import isEmojiUnicodeSupported, {
+ isFlagEmoji,
+ isKeycapEmoji,
+ isSkinToneComboEmoji,
+ isHorceRacingSkinToneComboEmoji,
+ isPersonZwjEmoji,
+} from '~/emoji/support/is_emoji_unicode_supported';
+
+const emptySupportMap = {
+ personZwj: false,
+ horseRacing: false,
+ flag: false,
+ skinToneModifier: false,
+ '9.0': false,
+ '8.0': false,
+ '7.0': false,
+ 6.1: false,
+ '6.0': false,
+ 5.2: false,
+ 5.1: false,
+ 4.1: false,
+ '4.0': false,
+ 3.2: false,
+ '3.0': false,
+ 1.1: false,
+};
+
+const emojiFixtureMap = {
+ bomb: {
+ name: 'bomb',
+ moji: '💣',
+ unicodeVersion: '6.0',
+ },
+ construction_worker_tone5: {
+ name: 'construction_worker_tone5',
+ moji: '👷ðŸ¿',
+ unicodeVersion: '8.0',
+ },
+ five: {
+ name: 'five',
+ moji: '5ï¸âƒ£',
+ unicodeVersion: '3.0',
+ },
+ grey_question: {
+ name: 'grey_question',
+ moji: 'â”',
+ unicodeVersion: '6.0',
+ },
+};
+
+function markupToDomElement(markup) {
+ const div = document.createElement('div');
+ div.innerHTML = markup;
+ return div.firstElementChild;
+}
+
+function testGlEmojiImageFallback(element, name, src) {
+ expect(element.tagName.toLowerCase()).toBe('img');
+ expect(element.getAttribute('src')).toBe(src);
+ expect(element.getAttribute('title')).toBe(`:${name}:`);
+ expect(element.getAttribute('alt')).toBe(`:${name}:`);
+}
+
+const defaults = {
+ forceFallback: false,
+ sprite: false,
+};
+
+function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
+ const opts = Object.assign({}, defaults, options);
+ expect(element.tagName.toLowerCase()).toBe('gl-emoji');
+ expect(element.dataset.name).toBe(name);
+ expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
+ expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
+
+ const fallbackSpriteClass = `emoji-${name}`;
+ if (opts.sprite) {
+ expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
+ }
+
+ if (opts.forceFallback && opts.sprite) {
+ expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
+ }
+
+ if (opts.forceFallback && !opts.sprite) {
+ // Check for image fallback
+ testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
+ } else {
+ // Otherwise make sure things are still unicode text
+ expect(element.textContent.trim()).toBe(unicodeMoji);
+ }
+}
+
+describe('gl_emoji', () => {
+ describe('glEmojiTag', () => {
+ it('bomb emoji', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('bomb emoji with image fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+
+ it('bomb emoji with sprite fallback readiness', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ sprite: true,
+ },
+ );
+ });
+ it('bomb emoji with sprite fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ sprite: true,
+ },
+ );
+ });
+
+ it('question mark when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('question mark with image fallback when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+ });
+
+ describe('isFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isFlagEmoji('')).toBeFalsy();
+ });
+ it('should detect flag_ac', () => {
+ expect(isFlagEmoji('🇦🇨')).toBeTruthy();
+ });
+ it('should detect flag_us', () => {
+ expect(isFlagEmoji('🇺🇸')).toBeTruthy();
+ });
+ it('should detect flag_zw', () => {
+ expect(isFlagEmoji('🇿🇼')).toBeTruthy();
+ });
+ it('should not detect flags', () => {
+ expect(isFlagEmoji('ðŸŽ')).toBeFalsy();
+ });
+ it('should not detect triangular_flag_on_post', () => {
+ expect(isFlagEmoji('🚩')).toBeFalsy();
+ });
+ it('should not detect single letter', () => {
+ expect(isFlagEmoji('🇦')).toBeFalsy();
+ });
+ it('should not detect >2 letters', () => {
+ expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
+ });
+ });
+
+ describe('isKeycapEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isKeycapEmoji('')).toBeFalsy();
+ });
+ it('should detect one(keycap)', () => {
+ expect(isKeycapEmoji('1ï¸âƒ£')).toBeTruthy();
+ });
+ it('should detect nine(keycap)', () => {
+ expect(isKeycapEmoji('9ï¸âƒ£')).toBeTruthy();
+ });
+ it('should not detect ten(keycap)', () => {
+ expect(isKeycapEmoji('🔟')).toBeFalsy();
+ });
+ it('should not detect hash(keycap)', () => {
+ expect(isKeycapEmoji('#⃣')).toBeFalsy();
+ });
+ });
+
+ describe('isSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isSkinToneComboEmoji('')).toBeFalsy();
+ });
+ it('should detect hand_splayed_tone5', () => {
+ expect(isSkinToneComboEmoji('ðŸ–ðŸ¿')).toBeTruthy();
+ });
+ it('should not detect hand_splayed', () => {
+ expect(isSkinToneComboEmoji('ðŸ–')).toBeFalsy();
+ });
+ it('should detect lifter_tone1', () => {
+ expect(isSkinToneComboEmoji('ðŸ‹ðŸ»')).toBeTruthy();
+ });
+ it('should not detect lifter', () => {
+ expect(isSkinToneComboEmoji('ðŸ‹')).toBeFalsy();
+ });
+ it('should detect rowboat_tone4', () => {
+ expect(isSkinToneComboEmoji('🚣ðŸ¾')).toBeTruthy();
+ });
+ it('should not detect rowboat', () => {
+ expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
+ });
+ it('should not detect individual tone emoji', () => {
+ expect(isSkinToneComboEmoji('ðŸ»')).toBeFalsy();
+ });
+ });
+
+ describe('isHorceRacingSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy();
+ });
+ it('should detect horse_racing_tone2', () => {
+ expect(isHorceRacingSkinToneComboEmoji('ðŸ‡ðŸ¼')).toBeTruthy();
+ });
+ it('should not detect horse_racing', () => {
+ expect(isHorceRacingSkinToneComboEmoji('ðŸ‡')).toBeFalsy();
+ });
+ });
+
+ describe('isPersonZwjEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isPersonZwjEmoji('')).toBeFalsy();
+ });
+ it('should detect couple_mm', () => {
+ expect(isPersonZwjEmoji('👨â€â¤ï¸â€ðŸ‘¨')).toBeTruthy();
+ });
+ it('should not detect couple_with_heart', () => {
+ expect(isPersonZwjEmoji('💑')).toBeFalsy();
+ });
+ it('should not detect couplekiss', () => {
+ expect(isPersonZwjEmoji('ðŸ’')).toBeFalsy();
+ });
+ it('should detect family_mmb', () => {
+ expect(isPersonZwjEmoji('👨â€ðŸ‘¨â€ðŸ‘¦')).toBeTruthy();
+ });
+ it('should detect family_mwgb', () => {
+ expect(isPersonZwjEmoji('👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦')).toBeTruthy();
+ });
+ it('should not detect family', () => {
+ expect(isPersonZwjEmoji('👪')).toBeFalsy();
+ });
+ it('should detect kiss_ww', () => {
+ expect(isPersonZwjEmoji('👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘©')).toBeTruthy();
+ });
+ it('should not detect girl', () => {
+ expect(isPersonZwjEmoji('👧')).toBeFalsy();
+ });
+ it('should not detect girl_tone5', () => {
+ expect(isPersonZwjEmoji('👧ðŸ¿')).toBeFalsy();
+ });
+ it('should not detect man', () => {
+ expect(isPersonZwjEmoji('👨')).toBeFalsy();
+ });
+ it('should not detect woman', () => {
+ expect(isPersonZwjEmoji('👩')).toBeFalsy();
+ });
+ });
+
+ describe('isEmojiUnicodeSupported', () => {
+ it('should gracefully handle empty string with unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported(
+ { '1.0': true },
+ '',
+ '1.0',
+ );
+ expect(isSupported).toBeTruthy();
+ });
+ it('should gracefully handle empty string without unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported(
+ {},
+ '',
+ '1.0',
+ );
+ expect(isSupported).toBeFalsy();
+ });
+ it('bomb(6.0) with 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = Object.assign({}, emptySupportMap, {
+ '6.0': true,
+ });
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('bomb(6.0) without 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = emptySupportMap;
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('bomb(6.0) without 6.0 but with 9.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = Object.assign({}, emptySupportMap, {
+ '9.0': true,
+ });
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
+ const emojiKey = 'construction_worker_tone5';
+ const unicodeSupportMap = Object.assign({}, emptySupportMap, {
+ skinToneModifier: false,
+ '9.0': true,
+ '8.0': true,
+ '7.0': true,
+ 6.1: true,
+ '6.0': true,
+ 5.2: true,
+ 5.1: true,
+ 4.1: true,
+ '4.0': true,
+ 3.2: true,
+ '3.0': true,
+ 1.1: true,
+ });
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('use native keycap on >=57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = Object.assign({}, emptySupportMap, {
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 57,
+ },
+ });
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('fallback keycap on <57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = Object.assign({}, emptySupportMap, {
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 50,
+ },
+ });
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+ expect(isSupported).toBeFalsy();
+ });
+ });
+});
diff --git a/spec/javascripts/environments/environment_actions_spec.js b/spec/javascripts/environments/environment_actions_spec.js
index 676bf61cfd9..ea40a1fcd4b 100644
--- a/spec/javascripts/environments/environment_actions_spec.js
+++ b/spec/javascripts/environments/environment_actions_spec.js
@@ -4,7 +4,6 @@ import actionsComp from '~/environments/components/environment_actions.vue';
describe('Actions Component', () => {
let ActionsComponent;
let actionsMock;
- let spy;
let component;
beforeEach(() => {
@@ -26,20 +25,23 @@ describe('Actions Component', () => {
},
];
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
component = new ActionsComponent({
propsData: {
actions: actionsMock,
- service: {
- postAction: spy,
- },
},
}).$mount();
});
+ describe('computed', () => {
+ it('title', () => {
+ expect(component.title).toEqual('Deploy to...');
+ });
+ });
+
it('should render a dropdown button with icon and title attribute', () => {
expect(component.$el.querySelector('.fa-caret-down')).toBeDefined();
- expect(component.$el.querySelector('.dropdown-new').getAttribute('title')).toEqual('Deploy to...');
+ expect(component.$el.querySelector('.dropdown-new').getAttribute('data-original-title')).toEqual('Deploy to...');
+ expect(component.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual('Deploy to...');
});
it('should render a dropdown with the provided list of actions', () => {
@@ -48,13 +50,6 @@ describe('Actions Component', () => {
).toEqual(actionsMock.length);
});
- it('should call the service when an action is clicked', () => {
- component.$el.querySelector('.dropdown').click();
- component.$el.querySelector('.js-manual-action-link').click();
-
- expect(spy).toHaveBeenCalledWith(actionsMock[0].play_path);
- });
-
it('should render a disabled action when it\'s not playable', () => {
expect(
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
diff --git a/spec/javascripts/environments/environment_monitoring_spec.js b/spec/javascripts/environments/environment_monitoring_spec.js
index 0f3dba66230..f8d8223967a 100644
--- a/spec/javascripts/environments/environment_monitoring_spec.js
+++ b/spec/javascripts/environments/environment_monitoring_spec.js
@@ -3,21 +3,30 @@ import monitoringComp from '~/environments/components/environment_monitoring.vue
describe('Monitoring Component', () => {
let MonitoringComponent;
+ let component;
+
+ const monitoringUrl = 'https://gitlab.com';
beforeEach(() => {
MonitoringComponent = Vue.extend(monitoringComp);
- });
- it('should render a link to environment monitoring page', () => {
- const monitoringUrl = 'https://gitlab.com';
- const component = new MonitoringComponent({
+ component = new MonitoringComponent({
propsData: {
monitoringUrl,
},
}).$mount();
+ });
+ describe('computed', () => {
+ it('title', () => {
+ expect(component.title).toEqual('Monitoring');
+ });
+ });
+
+ it('should render a link to environment monitoring page', () => {
expect(component.$el.getAttribute('href')).toEqual(monitoringUrl);
expect(component.$el.querySelector('.fa-area-chart')).toBeDefined();
- expect(component.$el.getAttribute('title')).toEqual('Monitoring');
+ expect(component.$el.getAttribute('data-original-title')).toEqual('Monitoring');
+ expect(component.$el.getAttribute('aria-label')).toEqual('Monitoring');
});
});
diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js
index 25397714a76..eb8e49d81fe 100644
--- a/spec/javascripts/environments/environment_rollback_spec.js
+++ b/spec/javascripts/environments/environment_rollback_spec.js
@@ -4,11 +4,9 @@ import rollbackComp from '~/environments/components/environment_rollback.vue';
describe('Rollback Component', () => {
const retryURL = 'https://gitlab.com/retry';
let RollbackComponent;
- let spy;
beforeEach(() => {
RollbackComponent = Vue.extend(rollbackComp);
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
});
it('Should render Re-deploy label when isLastDeployment is true', () => {
@@ -17,9 +15,6 @@ describe('Rollback Component', () => {
propsData: {
retryUrl: retryURL,
isLastDeployment: true,
- service: {
- postAction: spy,
- },
},
}).$mount();
@@ -32,28 +27,9 @@ describe('Rollback Component', () => {
propsData: {
retryUrl: retryURL,
isLastDeployment: false,
- service: {
- postAction: spy,
- },
},
}).$mount();
expect(component.$el.querySelector('span').textContent).toContain('Rollback');
});
-
- it('should call the service when the button is clicked', () => {
- const component = new RollbackComponent({
- propsData: {
- retryUrl: retryURL,
- isLastDeployment: false,
- service: {
- postAction: spy,
- },
- },
- }).$mount();
-
- component.$el.click();
-
- expect(spy).toHaveBeenCalledWith(retryURL);
- });
});
diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js
index 1c54cc3054c..0c8817a8148 100644
--- a/spec/javascripts/environments/environment_spec.js
+++ b/spec/javascripts/environments/environment_spec.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import '~/flash';
import environmentsComponent from '~/environments/components/environment.vue';
import { environment, folder } from './mock_data';
+import { headersInterceptor } from '../helpers/vue_resource_helper';
describe('Environment', () => {
preloadFixtures('static/environments/environments.html.raw');
@@ -25,12 +26,14 @@ describe('Environment', () => {
beforeEach(() => {
Vue.http.interceptors.push(environmentsEmptyResponseInterceptor);
+ Vue.http.interceptors.push(headersInterceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsEmptyResponseInterceptor,
);
+ Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
});
it('should render the empty state', (done) => {
@@ -41,7 +44,7 @@ describe('Environment', () => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-new-environment-button').textContent,
- ).toContain('New Environment');
+ ).toContain('New environment');
expect(
component.$el.querySelector('.js-blank-state-title').textContent,
@@ -54,6 +57,10 @@ describe('Environment', () => {
describe('with paginated environments', () => {
const environmentsResponseInterceptor = (request, next) => {
+ next((response) => {
+ response.headers.set('X-nExt-pAge', '2');
+ });
+
next(request.respondWith(JSON.stringify({
environments: [environment],
stopped_count: 1,
@@ -73,6 +80,7 @@ describe('Environment', () => {
beforeEach(() => {
Vue.http.interceptors.push(environmentsResponseInterceptor);
+ Vue.http.interceptors.push(headersInterceptor);
component = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
});
@@ -82,6 +90,7 @@ describe('Environment', () => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsResponseInterceptor,
);
+ Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
});
it('should render a table with environments', (done) => {
@@ -271,7 +280,7 @@ describe('Environment', () => {
// wait for next async request
setTimeout(() => {
expect(component.$el.querySelectorAll('.js-child-row').length).toEqual(1);
- expect(component.$el.querySelector('td.text-center > a.btn').textContent).toContain('Show all');
+ expect(component.$el.querySelector('.text-center > a.btn').textContent).toContain('Show all');
Vue.http.interceptors = _.without(Vue.http.interceptors, folderInterceptor);
done();
diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js
index 942e4aaabd4..3f95faf466a 100644
--- a/spec/javascripts/environments/environment_stop_spec.js
+++ b/spec/javascripts/environments/environment_stop_spec.js
@@ -4,31 +4,28 @@ import stopComp from '~/environments/components/environment_stop.vue';
describe('Stop Component', () => {
let StopComponent;
let component;
- let spy;
const stopURL = '/stop';
beforeEach(() => {
StopComponent = Vue.extend(stopComp);
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
spyOn(window, 'confirm').and.returnValue(true);
component = new StopComponent({
propsData: {
stopUrl: stopURL,
- service: {
- postAction: spy,
- },
},
}).$mount();
});
- it('should render a button to stop the environment', () => {
- expect(component.$el.tagName).toEqual('BUTTON');
- expect(component.$el.getAttribute('title')).toEqual('Stop');
+ describe('computed', () => {
+ it('title', () => {
+ expect(component.title).toEqual('Stop');
+ });
});
- it('should call the service when an action is clicked', () => {
- component.$el.click();
- expect(spy).toHaveBeenCalled();
+ it('should render a button to stop the environment', () => {
+ expect(component.$el.tagName).toEqual('BUTTON');
+ expect(component.$el.getAttribute('data-original-title')).toEqual('Stop');
+ expect(component.$el.getAttribute('aria-label')).toEqual('Stop');
});
});
diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js
index effbc6c3ee1..2862971bec4 100644
--- a/spec/javascripts/environments/environment_table_spec.js
+++ b/spec/javascripts/environments/environment_table_spec.js
@@ -29,6 +29,6 @@ describe('Environment item', () => {
},
}).$mount();
- expect(component.$el.tagName).toEqual('TABLE');
+ expect(component.$el.getAttribute('class')).toContain('ci-table');
});
});
diff --git a/spec/javascripts/environments/environment_terminal_button_spec.js b/spec/javascripts/environments/environment_terminal_button_spec.js
index 858472af4b6..f1576b19d1b 100644
--- a/spec/javascripts/environments/environment_terminal_button_spec.js
+++ b/spec/javascripts/environments/environment_terminal_button_spec.js
@@ -16,9 +16,16 @@ describe('Stop Component', () => {
}).$mount();
});
+ describe('computed', () => {
+ it('title', () => {
+ expect(component.title).toEqual('Terminal');
+ });
+ });
+
it('should render a link to open a web terminal with the provided path', () => {
expect(component.$el.tagName).toEqual('A');
- expect(component.$el.getAttribute('title')).toEqual('Terminal');
+ expect(component.$el.getAttribute('data-original-title')).toEqual('Terminal');
+ expect(component.$el.getAttribute('aria-label')).toEqual('Terminal');
expect(component.$el.getAttribute('href')).toEqual(terminalPath);
});
});
diff --git a/spec/javascripts/environments/environments_store_spec.js b/spec/javascripts/environments/environments_store_spec.js
index f617c4bdffe..f2c6ec24dd7 100644
--- a/spec/javascripts/environments/environments_store_spec.js
+++ b/spec/javascripts/environments/environments_store_spec.js
@@ -86,6 +86,16 @@ describe('Store', () => {
store.toggleFolder(store.state.environments[1]);
expect(store.state.environments[1].isOpen).toEqual(false);
});
+
+ it('should keep folder open when environments are updated', () => {
+ store.storeEnvironments(serverData);
+
+ store.toggleFolder(store.state.environments[1]);
+ expect(store.state.environments[1].isOpen).toEqual(true);
+
+ store.storeEnvironments(serverData);
+ expect(store.state.environments[1].isOpen).toEqual(true);
+ });
});
describe('setfolderContent', () => {
@@ -97,6 +107,17 @@ describe('Store', () => {
expect(store.state.environments[1].children.length).toEqual(serverData.length);
expect(store.state.environments[1].children[0].isChildren).toEqual(true);
});
+
+ it('should keep folder content when environments are updated', () => {
+ store.storeEnvironments(serverData);
+
+ store.setfolderContent(store.state.environments[1], serverData);
+
+ expect(store.state.environments[1].children.length).toEqual(serverData.length);
+ // poll
+ store.storeEnvironments(serverData);
+ expect(store.state.environments[1].children.length).toEqual(serverData.length);
+ });
});
describe('store pagination', () => {
@@ -123,4 +144,13 @@ describe('Store', () => {
expect(store.state.paginationInformation).toEqual(expectedResult);
});
});
+
+ describe('getOpenFolders', () => {
+ it('should return open folder', () => {
+ store.storeEnvironments(serverData);
+
+ store.toggleFolder(store.state.environments[1]);
+ expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]);
+ });
+ });
});
diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js
index 350078ad5f5..fdaea5c0b0c 100644
--- a/spec/javascripts/environments/folder/environments_folder_view_spec.js
+++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import '~/flash';
import environmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue';
import { environmentsList } from '../mock_data';
+import { headersInterceptor } from '../../helpers/vue_resource_helper';
describe('Environments Folder View', () => {
preloadFixtures('static/environments/environments_folder_view.html.raw');
@@ -36,6 +37,8 @@ describe('Environments Folder View', () => {
beforeEach(() => {
Vue.http.interceptors.push(environmentsResponseInterceptor);
+ Vue.http.interceptors.push(headersInterceptor);
+
component = new EnvironmentsFolderViewComponent({
el: document.querySelector('#environments-folder-list-view'),
});
@@ -45,6 +48,7 @@ describe('Environments Folder View', () => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsResponseInterceptor,
);
+ Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
});
it('should render a table with environments', (done) => {
diff --git a/spec/javascripts/extensions/array_spec.js b/spec/javascripts/extensions/array_spec.js
deleted file mode 100644
index 4b871fe967d..00000000000
--- a/spec/javascripts/extensions/array_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/* eslint-disable space-before-function-paren, no-var */
-
-require('~/extensions/array');
-
-(function() {
- describe('Array extensions', function() {
- describe('first', function() {
- return it('returns the first item', function() {
- var arr;
- arr = [0, 1, 2, 3, 4, 5];
- return expect(arr.first()).toBe(0);
- });
- });
- describe('last', function() {
- return it('returns the last item', function() {
- var arr;
- arr = [0, 1, 2, 3, 4, 5];
- return expect(arr.last()).toBe(5);
- });
- });
- });
-}).call(window);
diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js
index 2722882375f..79447787fc9 100644
--- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js
+++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js
@@ -2,6 +2,8 @@ import Vue from 'vue';
import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content';
+import '~/filtered_search/filtered_search_token_keys';
+
const createComponent = (propsData) => {
const Component = Vue.extend(RecentSearchesDropdownContent);
@@ -17,12 +19,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = {
items: [],
+ allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
};
const propsDataWithItems = {
items: [
'foo',
'author:@root label:~foo bar',
],
+ allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
};
let vm;
@@ -76,6 +80,26 @@ describe('RecentSearchesDropdownContent', () => {
});
});
+ describe('if isLocalStorageAvailable is `false`', () => {
+ let el;
+
+ beforeEach(() => {
+ const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems);
+
+ vm = createComponent(props);
+ el = vm.$el;
+ });
+
+ it('should render an info note', () => {
+ const note = el.querySelector('.dropdown-info-note');
+ const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
+
+ expect(note).toBeDefined();
+ expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled');
+ expect(items.length).toEqual(propsDataWithoutItems.items.length);
+ });
+ });
+
describe('computed', () => {
describe('processedItems', () => {
it('with items', () => {
diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js
index 3f92fe4701e..b3c9bca64cc 100644
--- a/spec/javascripts/filtered_search/dropdown_user_spec.js
+++ b/spec/javascripts/filtered_search/dropdown_user_spec.js
@@ -1,7 +1,7 @@
-require('~/filtered_search/dropdown_utils');
-require('~/filtered_search/filtered_search_tokenizer');
-require('~/filtered_search/filtered_search_dropdown');
-require('~/filtered_search/dropdown_user');
+import '~/filtered_search/dropdown_utils';
+import '~/filtered_search/filtered_search_tokenizer';
+import '~/filtered_search/filtered_search_dropdown';
+import '~/filtered_search/dropdown_user';
describe('Dropdown User', () => {
describe('getSearchInput', () => {
@@ -12,7 +12,9 @@ describe('Dropdown User', () => {
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
- dropdownUser = new gl.DropdownUser();
+ dropdownUser = new gl.DropdownUser({
+ tokenKeys: gl.FilteredSearchTokenKeys,
+ });
});
it('should not return the double quote found in value', () => {
@@ -66,4 +68,41 @@ describe('Dropdown User', () => {
window.gon = {};
});
});
+
+ describe('hideCurrentUser', () => {
+ const fixtureTemplate = 'issues/issue_list.html.raw';
+ preloadFixtures(fixtureTemplate);
+
+ let dropdown;
+ let authorFilterDropdownElement;
+
+ beforeEach(() => {
+ loadFixtures(fixtureTemplate);
+ authorFilterDropdownElement = document.querySelector('#js-dropdown-author');
+ const dummyInput = document.createElement('div');
+ dropdown = new gl.DropdownUser({
+ dropdown: authorFilterDropdownElement,
+ input: dummyInput,
+ });
+ });
+
+ const findCurrentUserElement = () => authorFilterDropdownElement.querySelector('.js-current-user');
+
+ it('hides the current user from dropdown', () => {
+ const currentUserElement = findCurrentUserElement();
+ expect(currentUserElement).not.toBe(null);
+
+ dropdown.hideCurrentUser();
+
+ expect(currentUserElement.classList).toContain('hidden');
+ });
+
+ it('does nothing if no user is logged in', () => {
+ const currentUserElement = findCurrentUserElement();
+ currentUserElement.parentNode.removeChild(currentUserElement);
+ expect(findCurrentUserElement()).toBe(null);
+
+ dropdown.hideCurrentUser();
+ });
+ });
});
diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js
index c820c955172..b1b3d43f241 100644
--- a/spec/javascripts/filtered_search/dropdown_utils_spec.js
+++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js
@@ -1,9 +1,12 @@
-require('~/extensions/array');
-require('~/filtered_search/dropdown_utils');
-require('~/filtered_search/filtered_search_tokenizer');
-require('~/filtered_search/filtered_search_dropdown_manager');
+import '~/filtered_search/dropdown_utils';
+import '~/filtered_search/filtered_search_tokenizer';
+import '~/filtered_search/filtered_search_dropdown_manager';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Dropdown Utils', () => {
+ const issueListFixture = 'issues/issue_list.html.raw';
+ preloadFixtures(issueListFixture);
+
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
@@ -122,6 +125,7 @@ describe('Dropdown Utils', () => {
describe('filterHint', () => {
let input;
+ let allowedKeys;
beforeEach(() => {
setFixtures(`
@@ -133,30 +137,38 @@ describe('Dropdown Utils', () => {
`);
input = document.getElementById('test');
+ allowedKeys = gl.FilteredSearchTokenKeys.getKeys();
});
+ function config() {
+ return {
+ input,
+ allowedKeys,
+ };
+ }
+
it('should filter', () => {
input.value = 'l';
- let updatedItem = gl.DropdownUtils.filterHint(input, {
+ let updatedItem = gl.DropdownUtils.filterHint(config(), {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(false);
input.value = 'o';
- updatedItem = gl.DropdownUtils.filterHint(input, {
+ updatedItem = gl.DropdownUtils.filterHint(config(), {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(true);
});
it('should return droplab_hidden false when item has no hint', () => {
- const updatedItem = gl.DropdownUtils.filterHint(input, {}, '');
+ const updatedItem = gl.DropdownUtils.filterHint(config(), {}, '');
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should allow multiple if item.type is array', () => {
input.value = 'label:~first la';
- const updatedItem = gl.DropdownUtils.filterHint(input, {
+ const updatedItem = gl.DropdownUtils.filterHint(config(), {
hint: 'label',
type: 'array',
});
@@ -165,12 +177,12 @@ describe('Dropdown Utils', () => {
it('should prevent multiple if item.type is not array', () => {
input.value = 'milestone:~first mile';
- let updatedItem = gl.DropdownUtils.filterHint(input, {
+ let updatedItem = gl.DropdownUtils.filterHint(config(), {
hint: 'milestone',
});
expect(updatedItem.droplab_hidden).toBe(true);
- updatedItem = gl.DropdownUtils.filterHint(input, {
+ updatedItem = gl.DropdownUtils.filterHint(config(), {
hint: 'milestone',
type: 'string',
});
@@ -178,6 +190,102 @@ describe('Dropdown Utils', () => {
});
});
+ describe('mergeDuplicateLabels', () => {
+ const dataMap = {
+ label: {
+ title: 'label',
+ color: '#FFFFFF',
+ },
+ };
+
+ it('should add label to dataMap if it is not a duplicate', () => {
+ const newLabel = {
+ title: 'new-label',
+ color: '#000000',
+ };
+
+ const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, newLabel);
+ expect(updated[newLabel.title]).toEqual(newLabel);
+ });
+
+ it('should merge colors if label is a duplicate', () => {
+ const duplicate = {
+ title: 'label',
+ color: '#000000',
+ };
+
+ const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, duplicate);
+ expect(updated.label.multipleColors).toEqual([dataMap.label.color, duplicate.color]);
+ });
+ });
+
+ describe('duplicateLabelColor', () => {
+ it('should linear-gradient 2 colors', () => {
+ const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000']);
+ expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 50%, #000000 50%, #000000 100%)');
+ });
+
+ it('should linear-gradient 3 colors', () => {
+ const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333']);
+ expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 33%, #000000 33%, #000000 66%, #333333 66%, #333333 100%)');
+ });
+
+ it('should linear-gradient 4 colors', () => {
+ const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333', '#DDDDDD']);
+ expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 25%, #000000 25%, #000000 50%, #333333 50%, #333333 75%, #DDDDDD 75%, #DDDDDD 100%)');
+ });
+
+ it('should not linear-gradient more than 4 colors', () => {
+ const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333', '#DDDDDD', '#EEEEEE']);
+ expect(gradient.indexOf('#EEEEEE') === -1).toEqual(true);
+ });
+ });
+
+ describe('duplicateLabelPreprocessing', () => {
+ it('should set preprocessed to true', () => {
+ const results = gl.DropdownUtils.duplicateLabelPreprocessing([]);
+ expect(results.preprocessed).toEqual(true);
+ });
+
+ it('should not mutate existing data if there are no duplicates', () => {
+ const data = [{
+ title: 'label1',
+ color: '#FFFFFF',
+ }, {
+ title: 'label2',
+ color: '#000000',
+ }];
+ const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
+
+ expect(results.length).toEqual(2);
+ expect(results[0]).toEqual(data[0]);
+ expect(results[1]).toEqual(data[1]);
+ });
+
+ describe('duplicate labels', () => {
+ const data = [{
+ title: 'label',
+ color: '#FFFFFF',
+ }, {
+ title: 'label',
+ color: '#000000',
+ }];
+ const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
+
+ it('should merge duplicate labels', () => {
+ expect(results.length).toEqual(1);
+ });
+
+ it('should convert multiple colored labels into linear-gradient', () => {
+ expect(results[0].color).toEqual(gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000']));
+ });
+
+ it('should set multiple colored label text color to black', () => {
+ expect(results[0].text_color).toEqual('#000000');
+ });
+ });
+ });
+
describe('setDataValueIfSelected', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
@@ -305,4 +413,29 @@ describe('Dropdown Utils', () => {
});
});
});
+
+ describe('getSearchQuery', () => {
+ let authorToken;
+
+ beforeEach(() => {
+ loadFixtures(issueListFixture);
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
+ const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
+
+ const tokensContainer = document.querySelector('.tokens-container');
+ tokensContainer.appendChild(searchTermToken);
+ tokensContainer.appendChild(authorToken);
+ });
+
+ it('uses original value if present', () => {
+ const originalValue = 'original dance';
+ const valueContainer = authorToken.querySelector('.value-container');
+ valueContainer.dataset.originalValue = originalValue;
+
+ const searchQuery = gl.DropdownUtils.getSearchQuery();
+
+ expect(searchQuery).toBe(' search term author:original dance');
+ });
+ });
});
diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js
index 17bf8932489..5c7e9115aac 100644
--- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js
@@ -1,9 +1,12 @@
-require('~/extensions/array');
-require('~/filtered_search/filtered_search_visual_tokens');
-require('~/filtered_search/filtered_search_tokenizer');
-require('~/filtered_search/filtered_search_dropdown_manager');
+import '~/filtered_search/filtered_search_visual_tokens';
+import '~/filtered_search/filtered_search_tokenizer';
+import '~/filtered_search/filtered_search_dropdown_manager';
describe('Filtered Search Dropdown Manager', () => {
+ beforeEach(() => {
+ spyOn(jQuery, 'ajax');
+ });
+
describe('addWordToInput', () => {
function getInputValue() {
return document.querySelector('.filtered-search').value;
diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
index e747aa497c2..16ae649ee60 100644
--- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
@@ -1,10 +1,14 @@
-require('~/lib/utils/url_utility');
-require('~/lib/utils/common_utils');
-require('~/filtered_search/filtered_search_token_keys');
-require('~/filtered_search/filtered_search_tokenizer');
-require('~/filtered_search/filtered_search_dropdown_manager');
-require('~/filtered_search/filtered_search_manager');
-const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper');
+import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store';
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+import '~/lib/utils/url_utility';
+import '~/lib/utils/common_utils';
+import '~/filtered_search/filtered_search_token_keys';
+import '~/filtered_search/filtered_search_tokenizer';
+import '~/filtered_search/filtered_search_dropdown_manager';
+import '~/filtered_search/filtered_search_manager';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Manager', () => {
let input;
@@ -44,25 +48,117 @@ describe('Filtered Search Manager', () => {
</div>
`);
+ spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
+ });
+
+ const initializeManager = () => {
+ /* eslint-disable jasmine/no-unsafe-spy */
spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
- spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
+ /* eslint-enable jasmine/no-unsafe-spy */
input = document.querySelector('.filtered-search');
tokensContainer = document.querySelector('.tokens-container');
manager = new gl.FilteredSearchManager();
- });
+ manager.setup();
+ };
afterEach(() => {
manager.cleanup();
});
+ describe('class constructor', () => {
+ const isLocalStorageAvailable = 'isLocalStorageAvailable';
+
+ beforeEach(() => {
+ spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable);
+ spyOn(recentSearchesStoreSrc, 'default');
+ spyOn(RecentSearchesRoot.prototype, 'render');
+ });
+
+ it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
+ manager = new gl.FilteredSearchManager();
+
+ expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
+ expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({
+ isLocalStorageAvailable,
+ allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
+ });
+ });
+ });
+
+ describe('setup', () => {
+ beforeEach(() => {
+ manager = new gl.FilteredSearchManager();
+ });
+
+ it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
+ spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError()));
+ spyOn(window, 'Flash');
+
+ manager.setup();
+
+ expect(window.Flash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('searchState', () => {
+ beforeEach(() => {
+ spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {});
+ initializeManager();
+ });
+
+ it('should blur button', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+ spyOn(e.currentTarget, 'blur').and.callThrough();
+ manager.searchState(e);
+
+ expect(e.currentTarget.blur).toHaveBeenCalled();
+ });
+
+ it('should not call search if there is no state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+
+ manager.searchState(e);
+ expect(gl.FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
+ });
+
+ it('should call search when there is state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ dataset: {
+ state: 'opened',
+ },
+ },
+ };
+
+ manager.searchState(e);
+ expect(gl.FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
+ });
+ });
+
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('should search with a single word', (done) => {
input.value = 'searchTerm';
@@ -112,6 +208,10 @@ describe('Filtered Search Manager', () => {
});
describe('handleInputPlaceholder', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('should render placeholder when there is no input', () => {
expect(input.placeholder).toEqual(placeholder);
});
@@ -138,6 +238,10 @@ describe('Filtered Search Manager', () => {
});
describe('checkForBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
@@ -175,6 +279,10 @@ describe('Filtered Search Manager', () => {
});
describe('removeToken', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
@@ -206,6 +314,7 @@ describe('Filtered Search Manager', () => {
describe('removeSelectedTokenKeydown', () => {
beforeEach(() => {
+ initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
);
@@ -259,63 +368,39 @@ describe('Filtered Search Manager', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
- manager.removeSelectedToken();
+ initializeManager();
});
it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
+ manager.removeSelectedToken();
+
expect(gl.FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
});
it('calls handleInputPlaceholder', () => {
+ manager.removeSelectedToken();
+
expect(manager.handleInputPlaceholder).toHaveBeenCalled();
});
it('calls toggleClearSearchButton', () => {
+ manager.removeSelectedToken();
+
expect(manager.toggleClearSearchButton).toHaveBeenCalled();
});
it('calls update dropdown offset', () => {
+ manager.removeSelectedToken();
+
expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
});
});
- describe('unselects token', () => {
+ describe('toggleInputContainerFocus', () => {
beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)}
- ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
- `);
+ initializeManager();
});
- it('unselects token when input is clicked', () => {
- const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
-
- expect(selectedToken.classList.contains('selected')).toEqual(true);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
-
- // Click directly on input attached to document
- // so that the click event will propagate properly
- document.querySelector('.filtered-search').click();
-
- expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
- expect(selectedToken.classList.contains('selected')).toEqual(false);
- });
-
- it('unselects token when document.body is clicked', () => {
- const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
-
- expect(selectedToken.classList.contains('selected')).toEqual(true);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
-
- document.body.click();
-
- expect(selectedToken.classList.contains('selected')).toEqual(false);
- expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
- });
- });
-
- describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => {
input.focus();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);
diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js
index 6f9fa434c35..69b424c3af5 100644
--- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js
@@ -1,5 +1,4 @@
-require('~/extensions/array');
-require('~/filtered_search/filtered_search_token_keys');
+import '~/filtered_search/filtered_search_token_keys';
describe('Filtered Search Token Keys', () => {
describe('get', () => {
diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
index 3e2e577f115..585bea9b499 100644
--- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
@@ -1,11 +1,12 @@
-require('~/extensions/array');
-require('~/filtered_search/filtered_search_token_keys');
-require('~/filtered_search/filtered_search_tokenizer');
+import '~/filtered_search/filtered_search_token_keys';
+import '~/filtered_search/filtered_search_tokenizer';
describe('Filtered Search Tokenizer', () => {
+ const allowedKeys = gl.FilteredSearchTokenKeys.getKeys();
+
describe('processTokens', () => {
it('returns for input containing only search value', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('searchTerm');
+ const results = gl.FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys);
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(0);
expect(results.lastToken).toBe(results.searchToken);
@@ -13,7 +14,7 @@ describe('Filtered Search Tokenizer', () => {
it('returns for input containing only tokens', () => {
const results = gl.FilteredSearchTokenizer
- .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none');
+ .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none', allowedKeys);
expect(results.searchToken).toBe('');
expect(results.tokens.length).toBe(4);
expect(results.tokens[3]).toBe(results.lastToken);
@@ -37,7 +38,7 @@ describe('Filtered Search Tokenizer', () => {
it('returns for input starting with search value and ending with tokens', () => {
const results = gl.FilteredSearchTokenizer
- .processTokens('searchTerm anotherSearchTerm milestone:none');
+ .processTokens('searchTerm anotherSearchTerm milestone:none', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0]).toBe(results.lastToken);
@@ -48,7 +49,7 @@ describe('Filtered Search Tokenizer', () => {
it('returns for input starting with tokens and ending with search value', () => {
const results = gl.FilteredSearchTokenizer
- .processTokens('assignee:@user searchTerm');
+ .processTokens('assignee:@user searchTerm', allowedKeys);
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(1);
@@ -60,7 +61,7 @@ describe('Filtered Search Tokenizer', () => {
it('returns for input containing search value wrapped between tokens', () => {
const results = gl.FilteredSearchTokenizer
- .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none');
+ .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
@@ -81,7 +82,7 @@ describe('Filtered Search Tokenizer', () => {
it('returns for input containing search value in between tokens', () => {
const results = gl.FilteredSearchTokenizer
- .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing');
+ .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
expect(results.tokens[2]).toBe(results.lastToken);
@@ -100,14 +101,14 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns search value for invalid tokens', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('fake:token');
+ const results = gl.FilteredSearchTokenizer.processTokens('fake:token', allowedKeys);
expect(results.lastToken).toBe('fake:token');
expect(results.searchToken).toBe('fake:token');
expect(results.tokens.length).toEqual(0);
});
it('returns search value and token for mix of valid and invalid tokens', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token');
+ const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys);
expect(results.tokens.length).toEqual(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('real');
@@ -117,13 +118,13 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns search value for invalid symbols', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('std::includes');
+ const results = gl.FilteredSearchTokenizer.processTokens('std::includes', allowedKeys);
expect(results.lastToken).toBe('std::includes');
expect(results.searchToken).toBe('std::includes');
});
it('removes duplicated values', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo');
+ const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys);
expect(results.tokens.length).toBe(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('foo');
diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
index d75b9061281..67166802c70 100644
--- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
@@ -1,8 +1,22 @@
-require('~/filtered_search/filtered_search_visual_tokens');
-const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper');
+import AjaxCache from '~/lib/utils/ajax_cache';
+import UsersCache from '~/lib/utils/users_cache';
+
+import '~/filtered_search/filtered_search_visual_tokens';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Visual Tokens', () => {
+ const subject = gl.FilteredSearchVisualTokens;
+
+ const findElements = (tokenElement) => {
+ const tokenNameElement = tokenElement.querySelector('.name');
+ const tokenValueContainer = tokenElement.querySelector('.value-container');
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ return { tokenNameElement, tokenValueContainer, tokenValueElement };
+ };
+
let tokensContainer;
+ let authorToken;
+ let bugLabelToken;
beforeEach(() => {
setFixtures(`
@@ -11,12 +25,15 @@ describe('Filtered Search Visual Tokens', () => {
</ul>
`);
tokensContainer = document.querySelector('.tokens-container');
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
+ bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
});
describe('getLastVisualTokenBeforeInput', () => {
it('returns when there are no visual tokens', () => {
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
expect(lastVisualToken).toEqual(null);
expect(isLastVisualTokenValid).toEqual(true);
@@ -25,11 +42,11 @@ describe('Filtered Search Visual Tokens', () => {
describe('input is the last item in tokensContainer', () => {
it('returns when there is one visual token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
+ bugLabelToken.outerHTML,
);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token'));
expect(isLastVisualTokenValid).toEqual(true);
@@ -41,7 +58,7 @@ describe('Filtered Search Visual Tokens', () => {
);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token'));
expect(isLastVisualTokenValid).toEqual(false);
@@ -49,13 +66,13 @@ describe('Filtered Search Visual Tokens', () => {
it('returns when there are multiple visual tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
`);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
const items = document.querySelectorAll('.tokens-container .js-visual-token');
expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true);
@@ -64,13 +81,13 @@ describe('Filtered Search Visual Tokens', () => {
it('returns when there are multiple visual tokens and an incomplete visual token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')}
`);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
const items = document.querySelectorAll('.tokens-container .js-visual-token');
expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true);
@@ -81,13 +98,13 @@ describe('Filtered Search Visual Tokens', () => {
describe('input is a middle item in tokensContainer', () => {
it('returns last token before input', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createInputHTML()}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
`);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token'));
expect(isLastVisualTokenValid).toEqual(true);
@@ -101,7 +118,7 @@ describe('Filtered Search Visual Tokens', () => {
`);
const { lastVisualToken, isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = subject.getLastVisualTokenBeforeInput();
expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token'));
expect(isLastVisualTokenValid).toEqual(false);
@@ -112,7 +129,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('unselectTokens', () => {
it('does nothing when there are no tokens', () => {
const beforeHTML = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.unselectTokens();
+ subject.unselectTokens();
expect(tokensContainer.innerHTML).toEqual(beforeHTML);
});
@@ -126,7 +143,7 @@ describe('Filtered Search Visual Tokens', () => {
const selected = tokensContainer.querySelector('.js-visual-token .selected');
expect(selected.classList.contains('selected')).toEqual(true);
- gl.FilteredSearchVisualTokens.unselectTokens();
+ subject.unselectTokens();
expect(selected.classList.contains('selected')).toEqual(false);
});
@@ -135,7 +152,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('selectToken', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
`);
@@ -145,7 +162,7 @@ describe('Filtered Search Visual Tokens', () => {
const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable');
firstTokenButton.classList.add('selected');
- gl.FilteredSearchVisualTokens.selectToken(firstTokenButton);
+ subject.selectToken(firstTokenButton);
expect(firstTokenButton.classList.contains('selected')).toEqual(false);
});
@@ -154,7 +171,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds selected class', () => {
const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable');
- gl.FilteredSearchVisualTokens.selectToken(firstTokenButton);
+ subject.selectToken(firstTokenButton);
expect(firstTokenButton.classList.contains('selected')).toEqual(true);
});
@@ -163,7 +180,7 @@ describe('Filtered Search Visual Tokens', () => {
const tokenButtons = tokensContainer.querySelectorAll('.js-visual-token .selectable');
tokenButtons[1].classList.add('selected');
- gl.FilteredSearchVisualTokens.selectToken(tokenButtons[0]);
+ subject.selectToken(tokenButtons[0]);
expect(tokenButtons[0].classList.contains('selected')).toEqual(true);
expect(tokenButtons[1].classList.contains('selected')).toEqual(false);
@@ -179,7 +196,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.removeSelectedToken();
+ subject.removeSelectedToken();
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
});
@@ -191,7 +208,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.removeSelectedToken();
+ subject.removeSelectedToken();
expect(tokensContainer.querySelector('.js-visual-token .selectable')).toEqual(null);
});
@@ -203,7 +220,7 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => {
setFixtures(`
<div class="test-area">
- ${gl.FilteredSearchVisualTokens.createVisualTokenElementHTML()}
+ ${subject.createVisualTokenElementHTML()}
</div>
`);
@@ -243,7 +260,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => {
it('renders search visual tokens', () => {
- gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true);
+ subject.addVisualTokenElement('search term', null, true);
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-term')).toEqual(true);
@@ -252,7 +269,7 @@ describe('Filtered Search Visual Tokens', () => {
});
it('renders filter visual token name', () => {
- gl.FilteredSearchVisualTokens.addVisualTokenElement('milestone');
+ subject.addVisualTokenElement('milestone');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -261,7 +278,7 @@ describe('Filtered Search Visual Tokens', () => {
});
it('renders filter visual token name and value', () => {
- gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend');
+ subject.addVisualTokenElement('label', 'Frontend');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -272,7 +289,7 @@ describe('Filtered Search Visual Tokens', () => {
it('inserts visual token before input', () => {
tokensContainer.appendChild(FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root'));
- gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend');
+ subject.addVisualTokenElement('label', 'Frontend');
const tokens = tokensContainer.querySelectorAll('.js-visual-token');
const labelToken = tokens[0];
const assigneeToken = tokens[1];
@@ -294,7 +311,7 @@ describe('Filtered Search Visual Tokens', () => {
);
const original = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value');
+ subject.addValueToPreviousVisualTokenElement('value');
expect(original).toEqual(tokensContainer.innerHTML);
});
@@ -306,7 +323,7 @@ describe('Filtered Search Visual Tokens', () => {
`);
const original = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value');
+ subject.addValueToPreviousVisualTokenElement('value');
expect(original).toEqual(tokensContainer.innerHTML);
});
@@ -317,7 +334,7 @@ describe('Filtered Search Visual Tokens', () => {
);
const original = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value');
+ subject.addValueToPreviousVisualTokenElement('value');
const updatedToken = tokensContainer.querySelector('.js-visual-token');
expect(updatedToken.querySelector('.name').innerText).toEqual('label');
@@ -328,7 +345,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addFilterVisualToken', () => {
it('creates visual token with just tokenName', () => {
- gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone');
+ subject.addFilterVisualToken('milestone');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -337,8 +354,8 @@ describe('Filtered Search Visual Tokens', () => {
});
it('creates visual token with just tokenValue', () => {
- gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone');
- gl.FilteredSearchVisualTokens.addFilterVisualToken('%8.17');
+ subject.addFilterVisualToken('milestone');
+ subject.addFilterVisualToken('%8.17');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -347,7 +364,7 @@ describe('Filtered Search Visual Tokens', () => {
});
it('creates full visual token', () => {
- gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', '@john');
+ subject.addFilterVisualToken('assignee', '@john');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -358,7 +375,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addSearchVisualToken', () => {
it('creates search visual token', () => {
- gl.FilteredSearchVisualTokens.addSearchVisualToken('search term');
+ subject.addSearchVisualToken('search term');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-term')).toEqual(true);
@@ -372,7 +389,7 @@ describe('Filtered Search Visual Tokens', () => {
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
`);
- gl.FilteredSearchVisualTokens.addSearchVisualToken('append this');
+ subject.addSearchVisualToken('append this');
const token = tokensContainer.querySelector('.filtered-search-term');
expect(token.querySelector('.name').innerText).toEqual('search term append this');
@@ -384,10 +401,26 @@ describe('Filtered Search Visual Tokens', () => {
it('should get last token value', () => {
const value = '~bug';
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value),
+ bugLabelToken.outerHTML,
+ );
+
+ expect(subject.getLastTokenPartial()).toEqual(value);
+ });
+
+ it('should get last token original value if available', () => {
+ const originalValue = '@user';
+ const valueContainer = authorToken.querySelector('.value-container');
+ valueContainer.dataset.originalValue = originalValue;
+ const avatar = document.createElement('img');
+ const valueElement = valueContainer.querySelector('.value');
+ valueElement.insertAdjacentElement('afterbegin', avatar);
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ authorToken.outerHTML,
);
- expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(value);
+ const lastTokenValue = subject.getLastTokenPartial();
+
+ expect(lastTokenValue).toEqual(originalValue);
});
it('should get last token name if there is no value', () => {
@@ -396,11 +429,11 @@ describe('Filtered Search Visual Tokens', () => {
FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name),
);
- expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(name);
+ expect(subject.getLastTokenPartial()).toEqual(name);
});
it('should return empty when there are no tokens', () => {
- expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual('');
+ expect(subject.getLastTokenPartial()).toEqual('');
});
});
@@ -412,7 +445,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
+ subject.removeLastTokenPartial();
expect(tokensContainer.querySelector('.js-visual-token .value')).toEqual(null);
});
@@ -424,14 +457,14 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokensContainer.querySelector('.js-visual-token .name')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
+ subject.removeLastTokenPartial();
expect(tokensContainer.querySelector('.js-visual-token .name')).toEqual(null);
});
it('should not remove anything when there are no tokens', () => {
const html = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
+ subject.removeLastTokenPartial();
expect(tokensContainer.innerHTML).toEqual(html);
});
@@ -440,7 +473,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('tokenizeInput', () => {
it('does not do anything if there is no input', () => {
const original = tokensContainer.innerHTML;
- gl.FilteredSearchVisualTokens.tokenizeInput();
+ subject.tokenizeInput();
expect(tokensContainer.innerHTML).toEqual(original);
});
@@ -452,7 +485,7 @@ describe('Filtered Search Visual Tokens', () => {
const input = document.querySelector('.filtered-search');
input.value = 'some value';
- gl.FilteredSearchVisualTokens.tokenizeInput();
+ subject.tokenizeInput();
const newToken = tokensContainer.querySelector('.filtered-search-term');
@@ -468,7 +501,7 @@ describe('Filtered Search Visual Tokens', () => {
const input = document.querySelector('.filtered-search');
input.value = '@john';
- gl.FilteredSearchVisualTokens.tokenizeInput();
+ subject.tokenizeInput();
const updatedToken = tokensContainer.querySelector('.filtered-search-token');
@@ -495,29 +528,39 @@ describe('Filtered Search Visual Tokens', () => {
it('tokenize\'s existing input', () => {
input.value = 'some text';
- spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough();
+ spyOn(subject, 'tokenizeInput').and.callThrough();
- gl.FilteredSearchVisualTokens.editToken(token);
+ subject.editToken(token);
- expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled();
+ expect(subject.tokenizeInput).toHaveBeenCalled();
expect(input.value).not.toEqual('some text');
});
it('moves input to the token position', () => {
expect(tokensContainer.children[3].querySelector('.filtered-search')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.editToken(token);
+ subject.editToken(token);
expect(tokensContainer.children[1].querySelector('.filtered-search')).not.toEqual(null);
expect(tokensContainer.children[3].querySelector('.filtered-search')).toEqual(null);
});
it('input contains the visual token value', () => {
- gl.FilteredSearchVisualTokens.editToken(token);
+ subject.editToken(token);
expect(input.value).toEqual('none');
});
+ it('input contains the original value if present', () => {
+ const originalValue = '@user';
+ const valueContainer = token.querySelector('.value-container');
+ valueContainer.dataset.originalValue = originalValue;
+
+ subject.editToken(token);
+
+ expect(input.value).toEqual(originalValue);
+ });
+
describe('selected token is a search term token', () => {
beforeEach(() => {
token = document.querySelector('.filtered-search-term');
@@ -526,7 +569,7 @@ describe('Filtered Search Visual Tokens', () => {
it('token is removed', () => {
expect(tokensContainer.querySelector('.filtered-search-term')).not.toEqual(null);
- gl.FilteredSearchVisualTokens.editToken(token);
+ subject.editToken(token);
expect(tokensContainer.querySelector('.filtered-search-term')).toEqual(null);
});
@@ -534,7 +577,7 @@ describe('Filtered Search Visual Tokens', () => {
it('input has the same value as removed token', () => {
expect(input.value).toEqual('');
- gl.FilteredSearchVisualTokens.editToken(token);
+ subject.editToken(token);
expect(input.value).toEqual('search');
});
@@ -547,25 +590,25 @@ describe('Filtered Search Visual Tokens', () => {
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'),
);
- spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callFake(() => {});
- spyOn(gl.FilteredSearchVisualTokens, 'getLastVisualTokenBeforeInput').and.callThrough();
+ spyOn(subject, 'tokenizeInput').and.callFake(() => {});
+ spyOn(subject, 'getLastVisualTokenBeforeInput').and.callThrough();
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ subject.moveInputToTheRight();
- expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled();
- expect(gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput).not.toHaveBeenCalled();
+ expect(subject.tokenizeInput).toHaveBeenCalled();
+ expect(subject.getLastVisualTokenBeforeInput).not.toHaveBeenCalled();
});
it('tokenize\'s input', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createInputHTML()}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
`;
document.querySelector('.filtered-search').value = 'none';
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ subject.moveInputToTheRight();
const value = tokensContainer.querySelector('.js-visual-token .value');
expect(value.innerText).toEqual('none');
@@ -575,12 +618,12 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createInputHTML()}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
`;
document.querySelector('.filtered-search').value = 'test';
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ subject.moveInputToTheRight();
const searchValue = tokensContainer.querySelector('.filtered-search-term .name');
expect(searchValue.innerText).toEqual('test');
@@ -590,10 +633,10 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createInputHTML()}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
+ ${bugLabelToken.outerHTML}
`;
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ subject.moveInputToTheRight();
expect(tokensContainer.children[2].querySelector('.filtered-search')).not.toEqual(null);
});
@@ -605,10 +648,301 @@ describe('Filtered Search Visual Tokens', () => {
${FilteredSearchSpecHelper.createInputHTML('', '~bug')}
`;
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ subject.moveInputToTheRight();
const token = tokensContainer.children[1];
expect(token.querySelector('.value').innerText).toEqual('~bug');
});
});
+
+ describe('renderVisualTokenValue', () => {
+ const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
+ const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken('milestone', 'upcoming');
+
+ let updateLabelTokenColorSpy;
+ let updateUserTokenAppearanceSpy;
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${authorToken.outerHTML}
+ ${bugLabelToken.outerHTML}
+ ${keywordToken.outerHTML}
+ ${milestoneToken.outerHTML}
+ `);
+
+ spyOn(subject, 'updateLabelTokenColor');
+ updateLabelTokenColorSpy = subject.updateLabelTokenColor;
+
+ spyOn(subject, 'updateUserTokenAppearance');
+ updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
+ });
+
+ it('renders a author token value element', () => {
+ const { tokenNameElement, tokenValueContainer, tokenValueElement } =
+ findElements(authorToken);
+ const tokenName = tokenNameElement.innerText;
+ const tokenValue = 'new value';
+
+ subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
+
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1);
+ const expectedArgs = [tokenValueContainer, tokenValueElement, tokenValue];
+ expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs);
+ expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
+ });
+
+ it('renders a label token value element', () => {
+ const { tokenNameElement, tokenValueContainer, tokenValueElement } =
+ findElements(bugLabelToken);
+ const tokenName = tokenNameElement.innerText;
+ const tokenValue = 'new value';
+
+ subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue);
+
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
+ const expectedArgs = [tokenValueContainer, tokenValue];
+ expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
+ expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
+ });
+
+ it('renders a milestone token value element', () => {
+ const { tokenNameElement, tokenValueElement } = findElements(milestoneToken);
+ const tokenName = tokenNameElement.innerText;
+ const tokenValue = 'new value';
+
+ subject.renderVisualTokenValue(milestoneToken, tokenName, tokenValue);
+
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
+ expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
+ });
+ });
+
+ describe('updateUserTokenAppearance', () => {
+ let usersCacheSpy;
+
+ beforeEach(() => {
+ spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username));
+ });
+
+ it('ignores special value "none"', (done) => {
+ usersCacheSpy = (username) => {
+ expect(username).toBe('none');
+ done.fail('Should not resolve "none"!');
+ };
+ const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, 'none')
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('ignores error if UsersCache throws', (done) => {
+ spyOn(window, 'Flash');
+ const dummyError = new Error('Earth rotated backwards');
+ const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = (username) => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.reject(dummyError);
+ };
+
+ subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(window.Flash.calls.count()).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does nothing if user cannot be found', (done) => {
+ const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = (username) => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(undefined);
+ };
+
+ subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('replaces author token with avatar and display name', (done) => {
+ const dummyUser = {
+ name: 'Important Person',
+ avatar_url: 'https://host.invalid/mypics/avatar.png',
+ };
+ const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = (username) => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ const avatar = tokenValueElement.querySelector('img.avatar');
+ expect(avatar.src).toBe(dummyUser.avatar_url);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('setTokenStyle', () => {
+ let originalTextColor;
+
+ beforeEach(() => {
+ originalTextColor = bugLabelToken.style.color;
+ });
+
+ it('should set backgroundColor', () => {
+ const originalBackgroundColor = bugLabelToken.style.backgroundColor;
+ const token = subject.setTokenStyle(bugLabelToken, 'blue', 'white');
+ expect(token.style.backgroundColor).toEqual('blue');
+ expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
+ });
+
+ it('should not set backgroundColor when it is a linear-gradient', () => {
+ const token = subject.setTokenStyle(bugLabelToken, 'linear-gradient(135deg, red, blue)', 'white');
+ expect(token.style.backgroundColor).toEqual(bugLabelToken.style.backgroundColor);
+ });
+
+ it('should set textColor', () => {
+ const token = subject.setTokenStyle(bugLabelToken, 'white', 'black');
+ expect(token.style.color).toEqual('black');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ });
+
+ it('should add inverted class when textColor is #FFFFFF', () => {
+ const token = subject.setTokenStyle(bugLabelToken, 'black', '#FFFFFF');
+ expect(token.style.color).toEqual('rgb(255, 255, 255)');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true);
+ });
+ });
+
+ describe('preprocessLabel', () => {
+ const endpoint = 'endpoint';
+
+ it('does not preprocess more than once', () => {
+ let labels = [];
+
+ spyOn(gl.DropdownUtils, 'duplicateLabelPreprocessing').and.callFake(() => []);
+
+ labels = gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
+ gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
+
+ expect(gl.DropdownUtils.duplicateLabelPreprocessing.calls.count()).toEqual(1);
+ });
+
+ describe('not preprocessed before', () => {
+ it('returns preprocessed labels', () => {
+ let labels = [];
+ expect(labels.preprocessed).not.toEqual(true);
+ labels = gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
+ expect(labels.preprocessed).toEqual(true);
+ });
+
+ it('overrides AjaxCache with preprocessed results', () => {
+ spyOn(AjaxCache, 'override').and.callFake(() => {});
+ gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, []);
+ expect(AjaxCache.override.calls.count()).toEqual(1);
+ });
+ });
+ });
+
+ describe('updateLabelTokenColor', () => {
+ const jsonFixtureName = 'labels/project_labels.json';
+ const dummyEndpoint = '/dummy/endpoint';
+
+ preloadFixtures(jsonFixtureName);
+
+ let labelData;
+
+ beforeAll(() => {
+ labelData = getJSONFixture(jsonFixtureName);
+ });
+
+ const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist');
+ const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"');
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${bugLabelToken.outerHTML}
+ ${missingLabelToken.outerHTML}
+ ${spaceLabelToken.outerHTML}
+ `);
+
+ const filteredSearchInput = document.querySelector('.filtered-search');
+ filteredSearchInput.dataset.baseEndpoint = dummyEndpoint;
+
+ AjaxCache.internalStorage = { };
+ AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData;
+ });
+
+ const parseColor = (color) => {
+ const dummyElement = document.createElement('div');
+ dummyElement.style.color = color;
+ return dummyElement.style.color;
+ };
+
+ const expectValueContainerStyle = (tokenValueContainer, label) => {
+ expect(tokenValueContainer.getAttribute('style')).not.toBe(null);
+ expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color));
+ expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color));
+ };
+
+ const findLabel = tokenValue => labelData.find(
+ label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`,
+ );
+
+ it('updates the color of a label token', (done) => {
+ const { tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject.updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates the color of a label token with spaces', (done) => {
+ const { tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject.updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not change color of a missing label', (done) => {
+ const { tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+ expect(matchingLabel).toBe(undefined);
+
+ subject.updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.getAttribute('style')).toBe(null);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js
new file mode 100644
index 00000000000..d8ba6de5f45
--- /dev/null
+++ b/spec/javascripts/filtered_search/recent_searches_root_spec.js
@@ -0,0 +1,31 @@
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+import * as vueSrc from 'vue';
+
+describe('RecentSearchesRoot', () => {
+ describe('render', () => {
+ let recentSearchesRoot;
+ let data;
+ let template;
+
+ beforeEach(() => {
+ recentSearchesRoot = {
+ store: {
+ state: 'state',
+ },
+ };
+
+ spyOn(vueSrc, 'default').and.callFake((options) => {
+ data = options.data;
+ template = options.template;
+ });
+
+ RecentSearchesRoot.prototype.render.call(recentSearchesRoot);
+ });
+
+ it('should instantiate Vue', () => {
+ expect(vueSrc.default).toHaveBeenCalled();
+ expect(data()).toBe(recentSearchesRoot.store.state);
+ expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"');
+ });
+ });
+});
diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js
new file mode 100644
index 00000000000..ea7c146fa4f
--- /dev/null
+++ b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js
@@ -0,0 +1,18 @@
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+
+describe('RecentSearchesServiceError', () => {
+ let recentSearchesServiceError;
+
+ beforeEach(() => {
+ recentSearchesServiceError = new RecentSearchesServiceError();
+ });
+
+ it('instantiates an instance of RecentSearchesServiceError and not an Error', () => {
+ expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError));
+ expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError');
+ });
+
+ it('should set a default message', () => {
+ expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable');
+ });
+});
diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js
index c255bf7c939..c293c0afa97 100644
--- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js
+++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js
@@ -1,6 +1,6 @@
-/* eslint-disable promise/catch-or-return */
-
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import AccessorUtilities from '~/lib/utils/accessor';
describe('RecentSearchesService', () => {
let service;
@@ -11,17 +11,19 @@ describe('RecentSearchesService', () => {
});
describe('fetch', () => {
+ beforeEach(() => {
+ spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true);
+ });
+
it('should default to empty array', (done) => {
const fetchItemsPromise = service.fetch();
fetchItemsPromise
.then((items) => {
expect(items).toEqual([]);
- done();
})
- .catch((err) => {
- done.fail('Shouldn\'t reject with empty localStorage key', err);
- });
+ .then(done)
+ .catch(done.fail);
});
it('should reject when unable to parse', (done) => {
@@ -29,9 +31,24 @@ describe('RecentSearchesService', () => {
const fetchItemsPromise = service.fetch();
fetchItemsPromise
- .catch(() => {
- done();
- });
+ .then(done.fail)
+ .catch((error) => {
+ expect(error).toEqual(jasmine.any(SyntaxError));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should reject when service is unavailable', (done) => {
+ RecentSearchesService.isAvailable.and.returnValue(false);
+
+ service.fetch()
+ .then(done.fail)
+ .catch((error) => {
+ expect(error).toEqual(jasmine.any(Error));
+ })
+ .then(done)
+ .catch(done.fail);
});
it('should return items from localStorage', (done) => {
@@ -41,18 +58,98 @@ describe('RecentSearchesService', () => {
fetchItemsPromise
.then((items) => {
expect(items).toEqual(['foo', 'bar']);
- done();
- });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.and.returnValue(false);
+
+ spyOn(window.localStorage, 'getItem');
+ });
+
+ it('should not call .getItem', (done) => {
+ RecentSearchesService.prototype.fetch()
+ .then(done.fail)
+ .catch((err) => {
+ expect(err).toEqual(new RecentSearchesServiceError());
+ expect(window.localStorage.getItem).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
});
});
describe('setRecentSearches', () => {
+ beforeEach(() => {
+ spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true);
+ });
+
it('should save things in localStorage', () => {
const items = ['foo', 'bar'];
service.save(items);
- const newLocalStorageValue =
- window.localStorage.getItem(service.localStorageKey);
+ const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey);
expect(JSON.parse(newLocalStorageValue)).toEqual(items);
});
});
+
+ describe('save', () => {
+ beforeEach(() => {
+ spyOn(window.localStorage, 'setItem');
+ spyOn(RecentSearchesService, 'isAvailable');
+ });
+
+ describe('if .isAvailable returns `true`', () => {
+ const searchesString = 'searchesString';
+ const localStorageKey = 'localStorageKey';
+ const recentSearchesService = {
+ localStorageKey,
+ };
+
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.and.returnValue(true);
+
+ spyOn(JSON, 'stringify').and.returnValue(searchesString);
+ });
+
+ it('should call .setItem', () => {
+ RecentSearchesService.prototype.save.call(recentSearchesService);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString);
+ });
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.and.returnValue(false);
+ });
+
+ it('should not call .setItem', () => {
+ RecentSearchesService.prototype.save();
+
+ expect(window.localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('isAvailable', () => {
+ let isAvailable;
+
+ beforeEach(() => {
+ spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough();
+
+ isAvailable = RecentSearchesService.isAvailable();
+ });
+
+ it('should call .isLocalStorageAccessSafe', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ });
+
+ it('should return a boolean', () => {
+ expect(typeof isAvailable).toBe('boolean');
+ });
+ });
});
diff --git a/spec/javascripts/fixtures/abuse_reports.rb b/spec/javascripts/fixtures/abuse_reports.rb
index de673f94d72..387858cba77 100644
--- a/spec/javascripts/fixtures/abuse_reports.rb
+++ b/spec/javascripts/fixtures/abuse_reports.rb
@@ -14,7 +14,7 @@ describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :controll
clean_frontend_fixtures('abuse_reports/')
end
- before(:each) do
+ before do
sign_in(admin)
end
diff --git a/spec/javascripts/fixtures/balsamiq.rb b/spec/javascripts/fixtures/balsamiq.rb
new file mode 100644
index 00000000000..234e246119a
--- /dev/null
+++ b/spec/javascripts/fixtures/balsamiq.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe 'Balsamiq file', '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'balsamiq-project') }
+
+ before(:all) do
+ clean_frontend_fixtures('blob/balsamiq/')
+ end
+
+ it 'blob/balsamiq/test.bmpr' do |example|
+ blob = project.repository.blob_at('b89b56d79', 'files/images/balsamiq.bmpr')
+
+ store_frontend_fixture(blob.data.force_encoding('utf-8'), example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/balsamiq_viewer.html.haml b/spec/javascripts/fixtures/balsamiq_viewer.html.haml
new file mode 100644
index 00000000000..18166ba4901
--- /dev/null
+++ b/spec/javascripts/fixtures/balsamiq_viewer.html.haml
@@ -0,0 +1 @@
+.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: '/test' } }
diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/blob.rb
new file mode 100644
index 00000000000..2dffc42b0ef
--- /dev/null
+++ b/spec/javascripts/fixtures/blob.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('blob/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'blob/show.html.raw' do |example|
+ get(:show,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: 'add-ipython-files/files/ipython/basic.ipynb')
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/boards.rb b/spec/javascripts/fixtures/boards.rb
new file mode 100644
index 00000000000..494c9cabdcc
--- /dev/null
+++ b/spec/javascripts/fixtures/boards.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Projects::BoardsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'boards-project') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('boards/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'boards/show.html.raw' do |example|
+ get(:index,
+ namespace_id: project.namespace,
+ project_id: project)
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/branches.rb b/spec/javascripts/fixtures/branches.rb
index a059818145b..bb3bdf7c215 100644
--- a/spec/javascripts/fixtures/branches.rb
+++ b/spec/javascripts/fixtures/branches.rb
@@ -13,7 +13,7 @@ describe Projects::BranchesController, '(JavaScript fixtures)', type: :controlle
clean_frontend_fixtures('branches/')
end
- before(:each) do
+ before do
sign_in(admin)
end
diff --git a/spec/javascripts/fixtures/builds.rb b/spec/javascripts/fixtures/builds.rb
deleted file mode 100644
index 320de791b08..00000000000
--- a/spec/javascripts/fixtures/builds.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require 'spec_helper'
-
-describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller do
- include JavaScriptFixturesHelpers
-
- let(:admin) { create(:admin) }
- let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
- let(:project) { create(:project_empty_repo, namespace: namespace, path: 'builds-project') }
- let(:pipeline) { create(:ci_empty_pipeline, project: project) }
- let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) }
- let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') }
- let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') }
-
- render_views
-
- before(:all) do
- clean_frontend_fixtures('builds/')
- end
-
- before(:each) do
- sign_in(admin)
- end
-
- it 'builds/build-with-artifacts.html.raw' do |example|
- get :show,
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: build_with_artifacts.to_param
-
- expect(response).to be_success
- store_frontend_fixture(response, example.description)
- end
-end
diff --git a/spec/javascripts/fixtures/dashboard.rb b/spec/javascripts/fixtures/dashboard.rb
index e83db8daaf2..793ffa7c220 100644
--- a/spec/javascripts/fixtures/dashboard.rb
+++ b/spec/javascripts/fixtures/dashboard.rb
@@ -13,7 +13,7 @@ describe Dashboard::ProjectsController, '(JavaScript fixtures)', type: :controll
clean_frontend_fixtures('dashboard/')
end
- before(:each) do
+ before do
sign_in(admin)
end
diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb
new file mode 100644
index 00000000000..bea161c514f
--- /dev/null
+++ b/spec/javascripts/fixtures/deploy_keys.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') }
+ let(:project2) { create(:project, :internal)}
+
+ before(:all) do
+ clean_frontend_fixtures('deploy_keys/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ render_views
+
+ it 'deploy_keys/keys.json' do |example|
+ create(:deploy_key, public: true)
+ project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com')
+ internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
+ create(:deploy_keys_project, project: project, deploy_key: project_key)
+ create(:deploy_keys_project, project: project2, deploy_key: internal_key)
+
+ get :index,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ format: :json
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/environments.rb b/spec/javascripts/fixtures/environments.rb
new file mode 100644
index 00000000000..d2457d75419
--- /dev/null
+++ b/spec/javascripts/fixtures/environments.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Projects::EnvironmentsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'environments-project') }
+ let(:environment) { create(:environment, name: 'production', project: project) }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('environments/metrics')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'environments/metrics/metrics.html.raw' do |example|
+ get :metrics,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: environment.id
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml
deleted file mode 100644
index e2dd9519898..00000000000
--- a/spec/javascripts/fixtures/environments/metrics.html.haml
+++ /dev/null
@@ -1,62 +0,0 @@
-.prometheus-container{ 'data-has-metrics': "false", 'data-doc-link': '/help/administration/monitoring/prometheus/index.md', 'data-prometheus-integration': '/root/hello-prometheus/services/prometheus/edit' }
- .top-area
- .row
- .col-sm-6
- %h3.page-title
- Metrics for environment
- .prometheus-state
- .js-getting-started.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- %svg
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Get started with performance monitoring
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. Learn more about performance monitoring
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- %a.btn.btn-success
- Configure Prometheus
- .js-loading.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- %svg
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Waiting for performance data
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- %a.btn.btn-success
- View documentation
- .js-unable-to-connect.hidden
- .row
- .col-md-4.col-md-offset-4.state-svg
- %svg
- .row
- .col-md-6.col-md-offset-3
- %h4.text-center.state-title
- Unable to connect to Prometheus server
- .row
- .col-md-6.col-md-offset-3
- .description-text.text-center.state-description
- Ensure connectivity is available from the GitLab server to the Prometheus server
- .row.state-button-section
- .col-md-4.col-md-offset-4.text-center.state-button
- %a.btn.btn-success
- View documentation
- .prometheus-graphs
- .row
- .col-sm-12
- %svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
- .row
- .col-sm-12
- %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
diff --git a/spec/javascripts/fixtures/graph.html.haml b/spec/javascripts/fixtures/graph.html.haml
new file mode 100644
index 00000000000..4fedb0f1ded
--- /dev/null
+++ b/spec/javascripts/fixtures/graph.html.haml
@@ -0,0 +1 @@
+#js-pipeline-graph-vue{ data: { endpoint: "foo" } }
diff --git a/spec/javascripts/fixtures/issuable_filter.html.haml b/spec/javascripts/fixtures/issuable_filter.html.haml
index ae745b292e6..84fa5395cb8 100644
--- a/spec/javascripts/fixtures/issuable_filter.html.haml
+++ b/spec/javascripts/fixtures/issuable_filter.html.haml
@@ -1,6 +1,6 @@
%form.js-filter-form{action: '/user/project/issues?scope=all&state=closed'}
%input{id: 'utf8', name: 'utf8', value: '✓'}
- %input{id: 'check_all_issues', name: 'check_all_issues'}
+ %input{id: 'check-all-issues', name: 'check-all-issues'}
%input{id: 'search', name: 'search'}
%input{id: 'author_id', name: 'author_id'}
%input{id: 'assignee_id', name: 'assignee_id'}
diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb
index 88e3f860809..d3ad50af1b9 100644
--- a/spec/javascripts/fixtures/issues.rb
+++ b/spec/javascripts/fixtures/issues.rb
@@ -13,7 +13,7 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
clean_frontend_fixtures('issues/')
end
- before(:each) do
+ before do
sign_in(admin)
end
@@ -36,6 +36,17 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
render_issue(example.description, issue)
end
+ it 'issues/issue_list.html.raw' do |example|
+ create(:issue, project: project)
+
+ get :index,
+ namespace_id: project.namespace.to_param,
+ project_id: project
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+
private
def render_issue(fixture_file_name, issue)
diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb
new file mode 100644
index 00000000000..83a96797506
--- /dev/null
+++ b/spec/javascripts/fixtures/jobs.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'builds-project') }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) }
+ let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') }
+ let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('builds/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'builds/build-with-artifacts.html.raw' do |example|
+ get :show,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: build_with_artifacts.to_param
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb
new file mode 100644
index 00000000000..814f065f3a4
--- /dev/null
+++ b/spec/javascripts/fixtures/labels.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe 'Labels (JavaScript fixtures)' do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:group) { create(:group, name: 'frontend-fixtures-group' )}
+ let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') }
+
+ let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') }
+ let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') }
+ let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') }
+
+ let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') }
+ let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') }
+ let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') }
+
+ before(:all) do
+ clean_frontend_fixtures('labels/')
+ end
+
+ describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do
+ render_views
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'labels/group_labels.json' do |example|
+ get :index,
+ group_id: group,
+ format: 'json'
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+ end
+
+ describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do
+ render_views
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'labels/project_labels.json' do |example|
+ get :index,
+ namespace_id: group,
+ project_id: project,
+ format: 'json'
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+ end
+end
diff --git a/spec/javascripts/fixtures/line_highlighter.html.haml b/spec/javascripts/fixtures/line_highlighter.html.haml
index 514877340e4..2782c50e298 100644
--- a/spec/javascripts/fixtures/line_highlighter.html.haml
+++ b/spec/javascripts/fixtures/line_highlighter.html.haml
@@ -1,4 +1,4 @@
-#blob-content-holder
+.file-holder
.file-content
.line-numbers
- 1.upto(25) do |i|
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index 47d904b865b..f97a5d2b5de 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -5,7 +5,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
let(:admin) { create(:admin) }
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
- let(:project) { create(:project, namespace: namespace, path: 'merge-requests-project') }
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') }
let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) }
let(:pipeline) do
@@ -16,6 +16,16 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
sha: merge_request.diff_head_sha
)
end
+ let(:path) { "files/ruby/popen.rb" }
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs
+ )
+ end
render_views
@@ -23,7 +33,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
clean_frontend_fixtures('merge_requests/')
end
- before(:each) do
+ before do
sign_in(admin)
end
@@ -39,13 +49,20 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merged_merge_request)
end
+ it 'merge_requests/diff_comment.html.raw' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
+ render_merge_request(example.description, merge_request)
+ end
+
private
def render_merge_request(fixture_file_name, merge_request)
get :show,
namespace_id: project.namespace.to_param,
project_id: project,
- id: merge_request.to_param
+ id: merge_request.to_param,
+ format: :html
expect(response).to be_success
store_frontend_fixture(response, fixture_file_name)
diff --git a/spec/javascripts/fixtures/merge_requests_diffs.rb b/spec/javascripts/fixtures/merge_requests_diffs.rb
new file mode 100644
index 00000000000..6e0a97d2e3f
--- /dev/null
+++ b/spec/javascripts/fixtures/merge_requests_diffs.rb
@@ -0,0 +1,57 @@
+
+require 'spec_helper'
+
+describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') }
+ let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') }
+ let(:path) { "files/ruby/popen.rb" }
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs
+ )
+ end
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('merge_request_diffs/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'merge_request_diffs/inline_changes_tab_with_comments.json' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
+ render_merge_request(example.description, merge_request)
+ end
+
+ it 'merge_request_diffs/parallel_changes_tab_with_comments.json' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
+ render_merge_request(example.description, merge_request, view: 'parallel')
+ end
+
+ private
+
+ def render_merge_request(fixture_file_name, merge_request, view: 'inline')
+ get :show,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.to_param,
+ format: :json,
+ view: view
+
+ expect(response).to be_success
+ store_frontend_fixture(response, fixture_file_name)
+ end
+end
diff --git a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
index 29370b974af..b532b48a95b 100644
--- a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
+++ b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
@@ -3,7 +3,7 @@
Dropdown
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
- .js-builds-dropdown-list.scrollable-menu
+ %li.js-builds-dropdown-list.scrollable-menu
- .js-builds-dropdown-loading.builds-dropdown-loading.hidden
- %span.fa.fa-spinner.fa-spin
+ %li.js-builds-dropdown-loading.hidden
+ %span.fa.fa-spinner
diff --git a/spec/javascripts/fixtures/oauth_remember_me.html.haml b/spec/javascripts/fixtures/oauth_remember_me.html.haml
new file mode 100644
index 00000000000..7886e995e57
--- /dev/null
+++ b/spec/javascripts/fixtures/oauth_remember_me.html.haml
@@ -0,0 +1,5 @@
+#oauth-container
+ %input#remember_me{ type: "checkbox" }
+
+ %a.oauth-login.twitter{ href: "http://example.com/" }
+ %a.oauth-login.github{ href: "http://example.com/" }
diff --git a/spec/javascripts/fixtures/pdf.rb b/spec/javascripts/fixtures/pdf.rb
new file mode 100644
index 00000000000..ef9976b9fd3
--- /dev/null
+++ b/spec/javascripts/fixtures/pdf.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe 'PDF file', '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'pdf-project') }
+
+ before(:all) do
+ clean_frontend_fixtures('blob/pdf/')
+ end
+
+ it 'blob/pdf/test.pdf' do |example|
+ blob = project.repository.blob_at('e774ebd33', 'files/pdf/test.pdf')
+
+ store_frontend_fixture(blob.data.force_encoding("utf-8"), example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/pipelines.rb b/spec/javascripts/fixtures/pipelines.rb
new file mode 100644
index 00000000000..bb85da50f0f
--- /dev/null
+++ b/spec/javascripts/fixtures/pipelines.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') }
+ let(:commit) { create(:commit, project: project) }
+ let(:commit_without_author) { RepoHelpers.another_sample_commit }
+ let!(:user) { create(:user, email: commit.author_email) }
+ let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) }
+ let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) }
+ let!(:pipeline_without_commit) { create(:ci_pipeline, project: project, sha: '0000') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('pipelines/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'pipelines/pipelines.json' do |example|
+ get :index,
+ namespace_id: namespace,
+ project_id: project,
+ format: :json
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/pipelines_table.html.haml b/spec/javascripts/fixtures/pipelines_table.html.haml
deleted file mode 100644
index ad1682704bb..00000000000
--- a/spec/javascripts/fixtures/pipelines_table.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-#commit-pipeline-table-view{ data: { endpoint: "endpoint", "help-page-path": "foo" } }
diff --git a/spec/javascripts/fixtures/project_branches.json b/spec/javascripts/fixtures/project_branches.json
deleted file mode 100644
index a96a4c0c095..00000000000
--- a/spec/javascripts/fixtures/project_branches.json
+++ /dev/null
@@ -1,5 +0,0 @@
-[
- "master",
- "development",
- "staging"
-]
diff --git a/spec/javascripts/fixtures/project_select_combo_button.html.haml b/spec/javascripts/fixtures/project_select_combo_button.html.haml
new file mode 100644
index 00000000000..54bc1a59279
--- /dev/null
+++ b/spec/javascripts/fixtures/project_select_combo_button.html.haml
@@ -0,0 +1,6 @@
+.project-item-select-holder
+ %input.project-item-select{ data: { group_id: '12345' , relative_path: 'issues/new' } }
+ %a.new-project-item-link{ data: { label: 'New issue' }, href: ''}
+ %i.fa.fa-spinner.spin
+ %a.new-project-item-select-button
+ %i.fa.fa-caret-down
diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb
index 6c33b240e5c..f09d44a49d1 100644
--- a/spec/javascripts/fixtures/projects.rb
+++ b/spec/javascripts/fixtures/projects.rb
@@ -13,7 +13,7 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do
clean_frontend_fixtures('projects/')
end
- before(:each) do
+ before do
sign_in(admin)
end
diff --git a/spec/javascripts/fixtures/prometheus_service.rb b/spec/javascripts/fixtures/prometheus_service.rb
new file mode 100644
index 00000000000..7968c9425f2
--- /dev/null
+++ b/spec/javascripts/fixtures/prometheus_service.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') }
+ let!(:service) { create(:prometheus_service, project: project) }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('services/prometheus')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'services/prometheus/prometheus_service.html.raw' do |example|
+ get :edit,
+ namespace_id: namespace,
+ project_id: project,
+ id: service.to_param
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/raw.rb b/spec/javascripts/fixtures/raw.rb
index 1ce622fc836..25f5a3b0bb3 100644
--- a/spec/javascripts/fixtures/raw.rb
+++ b/spec/javascripts/fixtures/raw.rb
@@ -4,7 +4,7 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do
include JavaScriptFixturesHelpers
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
- let(:project) { create(:project, namespace: namespace, path: 'raw-project') }
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'raw-project') }
before(:all) do
clean_frontend_fixtures('blob/notebook/')
@@ -21,4 +21,10 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do
store_frontend_fixture(blob.data, example.description)
end
+
+ it 'blob/notebook/math.json' do |example|
+ blob = project.repository.blob_at('93ee732', 'files/ipython/math.ipynb')
+
+ store_frontend_fixture(blob.data, example.description)
+ end
end
diff --git a/spec/javascripts/fixtures/services.rb b/spec/javascripts/fixtures/services.rb
new file mode 100644
index 00000000000..80915c32a74
--- /dev/null
+++ b/spec/javascripts/fixtures/services.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') }
+ let!(:service) { create(:custom_issue_tracker_service, project: project, title: 'Custom Issue Tracker') }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('services/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'services/edit_service.html.raw' do |example|
+ get :edit,
+ namespace_id: namespace,
+ project_id: project,
+ id: service.to_param
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/snippet.rb b/spec/javascripts/fixtures/snippet.rb
new file mode 100644
index 00000000000..01bfb87b0c1
--- /dev/null
+++ b/spec/javascripts/fixtures/snippet.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe SnippetsController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') }
+ let(:snippet) { create(:personal_snippet, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: admin) }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('snippets/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'snippets/show.html.raw' do |example|
+ get(:show, id: snippet.to_param)
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/target_branch_dropdown.html.haml b/spec/javascripts/fixtures/target_branch_dropdown.html.haml
deleted file mode 100644
index 821fb7940a0..00000000000
--- a/spec/javascripts/fixtures/target_branch_dropdown.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-%form.js-edit-blob-form
- %input{type: 'hidden', name: 'target_branch', value: 'master'}
- %div
- .dropdown
- %button.dropdown-menu-toggle.js-project-branches-dropdown.js-target-branch{type: 'button', data: {toggle: 'dropdown', selected: 'master', field_name: 'target_branch', form_id: '.js-edit-blob-form'}}
- .dropdown-menu.dropdown-menu-selectable.dropdown-menu-paging
- .dropdown-page-one
- .dropdown-title 'Select branch'
- .dropdown-input
- %input.dropdown-input-field{type: 'search', value: ''}
- %i.fa.fa-search.dropdown-input-search
- %i.fa.fa-times-dropdown-input-clear.js-dropdown-input-clear{role: 'button'}
- .dropdown-content
- .dropdown-footer
- %ul.dropdown-footer-list
- %li
- %a.create-new-branch.dropdown-toggle-page{href: "#"}
- Create new branch
- .dropdown-page-two.dropdown-new-branch
- %button.dropdown-title-button.dropdown-menu-back{type: 'button'}
- .dropdown_title 'Create new branch'
- .dropdown_content
- %input#new_branch_name.default-dropdown-input{ type: "text", placeholder: "Name new branch" }
- %button.btn.btn-primary.pull-left.js-new-branch-btn{ type: "button" }
- Create
- %button.btn.btn-default.pull-right.js-cancel-branch-btn{ type: "button" }
- Cancel
- %button{type: 'submit'}
diff --git a/spec/javascripts/fixtures/todos.rb b/spec/javascripts/fixtures/todos.rb
index a81ef8c5492..ba630365c18 100644
--- a/spec/javascripts/fixtures/todos.rb
+++ b/spec/javascripts/fixtures/todos.rb
@@ -18,7 +18,7 @@ describe 'Todos (JavaScript fixtures)' do
describe Dashboard::TodosController, '(JavaScript fixtures)', type: :controller do
render_views
- before(:each) do
+ before do
sign_in(admin)
end
@@ -33,7 +33,7 @@ describe 'Todos (JavaScript fixtures)' do
describe Projects::TodosController, '(JavaScript fixtures)', type: :controller do
render_views
- before(:each) do
+ before do
sign_in(admin)
end
diff --git a/spec/javascripts/fixtures/u2f.rb b/spec/javascripts/fixtures/u2f.rb
index c9c0b891237..e3d7986f2cf 100644
--- a/spec/javascripts/fixtures/u2f.rb
+++ b/spec/javascripts/fixtures/u2f.rb
@@ -10,10 +10,12 @@ context 'U2F' do
end
describe SessionsController, '(JavaScript fixtures)', type: :controller do
+ include DeviseHelpers
+
render_views
before do
- @request.env['devise.mapping'] = Devise.mappings[:user]
+ set_devise_mapping(context: @request)
end
it 'u2f/authenticate.html.raw' do |example|
diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js
new file mode 100644
index 00000000000..2e81a1b056b
--- /dev/null
+++ b/spec/javascripts/fly_out_nav_spec.js
@@ -0,0 +1,331 @@
+import Cookies from 'js-cookie';
+import {
+ calculateTop,
+ showSubLevelItems,
+ canShowSubItems,
+ canShowActiveSubItems,
+ mouseEnterTopItems,
+ mouseLeaveTopItem,
+ setOpenMenu,
+ mousePos,
+ getHideSubItemsInterval,
+ documentMouseMove,
+ getHeaderHeight,
+} from '~/fly_out_nav';
+import bp from '~/breakpoints';
+
+describe('Fly out sidebar navigation', () => {
+ let el;
+ let breakpointSize = 'lg';
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ el.style.position = 'relative';
+ document.body.appendChild(el);
+
+ spyOn(bp, 'getBreakpointSize').and.callFake(() => breakpointSize);
+
+ setOpenMenu(null);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ breakpointSize = 'lg';
+ mousePos.length = 0;
+ });
+
+ describe('calculateTop', () => {
+ it('returns boundingRect top', () => {
+ const boundingRect = {
+ top: 100,
+ height: 100,
+ };
+
+ expect(
+ calculateTop(boundingRect, 100),
+ ).toBe(100);
+ });
+
+ it('returns boundingRect - bottomOverflow', () => {
+ const boundingRect = {
+ top: window.innerHeight - 50,
+ height: 100,
+ };
+
+ expect(
+ calculateTop(boundingRect, 100),
+ ).toBe(window.innerHeight - 50);
+ });
+ });
+
+ describe('getHideSubItemsInterval', () => {
+ beforeEach(() => {
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: fixed; top: 0; left: 100px; height: 150px;"></div>';
+ });
+
+ it('returns 0 if currentOpenMenu is nil', () => {
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(0);
+ });
+
+ it('returns 0 when mouse above sub-items', () => {
+ showSubLevelItems(el);
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: el.getBoundingClientRect().top,
+ });
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: el.getBoundingClientRect().top - 50,
+ });
+
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(0);
+ });
+
+ it('returns 0 when mouse is below sub-items', () => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+
+ showSubLevelItems(el);
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: el.getBoundingClientRect().top,
+ });
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: (el.getBoundingClientRect().top - subItems.getBoundingClientRect().height) + 50,
+ });
+
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(0);
+ });
+
+ it('returns 300 when mouse is moved towards sub-items', () => {
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: el.getBoundingClientRect().top,
+ });
+ showSubLevelItems(el);
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left + 20,
+ clientY: el.getBoundingClientRect().top + 10,
+ });
+ console.log(el);
+
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(300);
+ });
+ });
+
+ describe('mouseLeaveTopItem', () => {
+ beforeEach(() => {
+ spyOn(el.classList, 'remove');
+ });
+
+ it('removes is-over class if currentOpenMenu is null', () => {
+ mouseLeaveTopItem(el);
+
+ expect(
+ el.classList.remove,
+ ).toHaveBeenCalledWith('is-over');
+ });
+
+ it('removes is-over class if currentOpenMenu is null & there are sub-items', () => {
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+
+ mouseLeaveTopItem(el);
+
+ expect(
+ el.classList.remove,
+ ).toHaveBeenCalledWith('is-over');
+ });
+
+ it('does not remove is-over class if currentOpenMenu is the passed in sub-items', () => {
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+
+ setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ mouseLeaveTopItem(el);
+
+ expect(
+ el.classList.remove,
+ ).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('mouseEnterTopItems', () => {
+ beforeEach(() => {
+ jasmine.clock().install();
+
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute; top: 0; left: 100px; height: 200px;"></div>';
+ });
+
+ afterEach(() => {
+ jasmine.clock().uninstall();
+ });
+
+ it('shows sub-items after 0ms if no menu is open', () => {
+ mouseEnterTopItems(el);
+
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(0);
+
+ jasmine.clock().tick(0);
+
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).toBe('block');
+ });
+
+ it('shows sub-items after 300ms if a menu is currently open', () => {
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left,
+ clientY: el.getBoundingClientRect().top,
+ });
+
+ setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+
+ documentMouseMove({
+ clientX: el.getBoundingClientRect().left + 20,
+ clientY: el.getBoundingClientRect().top + 10,
+ });
+
+ mouseEnterTopItems(el);
+
+ expect(
+ getHideSubItemsInterval(),
+ ).toBe(300);
+
+ jasmine.clock().tick(300);
+
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).toBe('block');
+ });
+ });
+
+ describe('showSubLevelItems', () => {
+ beforeEach(() => {
+ el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+ });
+
+ it('adds is-over class to el', () => {
+ spyOn(el.classList, 'add');
+
+ showSubLevelItems(el);
+
+ expect(
+ el.classList.add,
+ ).toHaveBeenCalledWith('is-over');
+ });
+
+ it('does not show sub-items on mobile', () => {
+ breakpointSize = 'xs';
+
+ showSubLevelItems(el);
+
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).not.toBe('block');
+ });
+
+ it('shows sub-items', () => {
+ showSubLevelItems(el);
+
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).toBe('block');
+ });
+
+ it('sets transform of sub-items', () => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+ showSubLevelItems(el);
+
+ expect(
+ subItems.style.transform,
+ ).toBe(`translate3d(0px, ${Math.floor(el.getBoundingClientRect().top) - getHeaderHeight()}px, 0px)`);
+ });
+
+ it('sets is-above when element is above', () => {
+ const subItems = el.querySelector('.sidebar-sub-level-items');
+ subItems.style.height = `${window.innerHeight + el.offsetHeight}px`;
+ el.style.top = `${window.innerHeight - el.offsetHeight}px`;
+
+ spyOn(subItems.classList, 'add');
+
+ showSubLevelItems(el);
+
+ expect(
+ subItems.classList.add,
+ ).toHaveBeenCalledWith('is-above');
+ });
+ });
+
+ describe('canShowSubItems', () => {
+ it('returns true if on desktop size', () => {
+ expect(
+ canShowSubItems(),
+ ).toBeTruthy();
+ });
+
+ it('returns false if on mobile size', () => {
+ breakpointSize = 'xs';
+
+ expect(
+ canShowSubItems(),
+ ).toBeFalsy();
+ });
+ });
+
+ describe('canShowActiveSubItems', () => {
+ afterEach(() => {
+ Cookies.remove('sidebar_collapsed');
+ });
+
+ it('returns true by default', () => {
+ expect(
+ canShowActiveSubItems(el),
+ ).toBeTruthy();
+ });
+
+ it('returns false when cookie is false & element is active', () => {
+ Cookies.set('sidebar_collapsed', 'false');
+ el.classList.add('active');
+
+ expect(
+ canShowActiveSubItems(el),
+ ).toBeFalsy();
+ });
+
+ it('returns true when cookie is false & element is active', () => {
+ Cookies.set('sidebar_collapsed', 'true');
+ el.classList.add('active');
+
+ expect(
+ canShowActiveSubItems(el),
+ ).toBeTruthy();
+ });
+
+ it('returns true when element is active & breakpoint is sm', () => {
+ breakpointSize = 'sm';
+ el.classList.add('active');
+
+ expect(
+ canShowActiveSubItems(el),
+ ).toBeTruthy();
+ });
+
+ it('returns true when element is active & breakpoint is md', () => {
+ breakpointSize = 'md';
+ el.classList.add('active');
+
+ expect(
+ canShowActiveSubItems(el),
+ ).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js
index 5dfa4008fbd..ad0c7264616 100644
--- a/spec/javascripts/gfm_auto_complete_spec.js
+++ b/spec/javascripts/gfm_auto_complete_spec.js
@@ -1,13 +1,15 @@
/* eslint no-param-reassign: "off" */
-require('~/gfm_auto_complete');
-require('vendor/jquery.caret');
-require('vendor/jquery.atwho');
+import GfmAutoComplete from '~/gfm_auto_complete';
-const global = window.gl || (window.gl = {});
-const GfmAutoComplete = global.GfmAutoComplete;
+import 'vendor/jquery.caret';
+import 'vendor/jquery.atwho';
describe('GfmAutoComplete', function () {
+ const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ fetchData: () => {},
+ });
+
describe('DefaultOptions.sorter', function () {
describe('assets loading', function () {
beforeEach(function () {
@@ -16,7 +18,7 @@ describe('GfmAutoComplete', function () {
this.atwhoInstance = { setting: {} };
this.items = [];
- this.sorterValue = GfmAutoComplete.DefaultOptions.sorter
+ this.sorterValue = gfmAutoCompleteCallbacks.sorter
.call(this.atwhoInstance, '', this.items);
});
@@ -38,7 +40,7 @@ describe('GfmAutoComplete', function () {
it('should enable highlightFirst if alwaysHighlightFirst is set', function () {
const atwhoInstance = { setting: { alwaysHighlightFirst: true } };
- GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance);
+ gfmAutoCompleteCallbacks.sorter.call(atwhoInstance);
expect(atwhoInstance.setting.highlightFirst).toBe(true);
});
@@ -46,7 +48,7 @@ describe('GfmAutoComplete', function () {
it('should enable highlightFirst if a query is present', function () {
const atwhoInstance = { setting: {} };
- GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, 'query');
+ gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query');
expect(atwhoInstance.setting.highlightFirst).toBe(true);
});
@@ -58,7 +60,7 @@ describe('GfmAutoComplete', function () {
const items = [];
const searchKey = 'searchKey';
- GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, query, items, searchKey);
+ gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey);
expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey);
});
@@ -67,7 +69,7 @@ describe('GfmAutoComplete', function () {
describe('DefaultOptions.matcher', function () {
const defaultMatcher = (context, flag, subtext) => (
- GfmAutoComplete.DefaultOptions.matcher.call(context, flag, subtext)
+ gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext)
);
const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%'];
diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js
index c207fb00a47..10fcc590c89 100644
--- a/spec/javascripts/gl_dropdown_spec.js
+++ b/spec/javascripts/gl_dropdown_spec.js
@@ -1,9 +1,8 @@
/* eslint-disable comma-dangle, no-param-reassign, no-unused-expressions, max-len */
-require('~/gl_dropdown');
-require('~/lib/utils/common_utils');
-require('~/lib/utils/type_utility');
-require('~/lib/utils/url_utility');
+import '~/gl_dropdown';
+import '~/lib/utils/common_utils';
+import '~/lib/utils/url_utility';
(() => {
const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link';
@@ -44,21 +43,18 @@ require('~/lib/utils/url_utility');
preloadFixtures('static/gl_dropdown.html.raw');
loadJSONFixtures('projects.json');
- function initDropDown(hasRemote, isFilterable) {
- this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({
+ function initDropDown(hasRemote, isFilterable, extraOpts = {}) {
+ const options = Object.assign({
selectable: true,
filterable: isFilterable,
data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData,
search: {
fields: ['name']
},
- text: (project) => {
- (project.name_with_namespace || project.name);
- },
- id: (project) => {
- project.id;
- }
- });
+ text: project => (project.name_with_namespace || project.name),
+ id: project => project.id,
+ }, extraOpts);
+ this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown(options);
}
beforeEach(() => {
@@ -80,6 +76,37 @@ require('~/lib/utils/url_utility');
expect(this.dropdownContainerElement).toHaveClass('open');
});
+ it('escapes HTML as text', () => {
+ this.projectsData[0].name_with_namespace = '<script>alert("testing");</script>';
+
+ initDropDown.call(this, false);
+
+ this.dropdownButtonElement.click();
+
+ expect(
+ $('.dropdown-content li:first-child').text(),
+ ).toBe('<script>alert("testing");</script>');
+ });
+
+ it('should output HTML when highlighting', () => {
+ this.projectsData[0].name_with_namespace = 'testing';
+ $('.dropdown-input .dropdown-input-field').val('test');
+
+ initDropDown.call(this, false, true, {
+ highlight: true,
+ });
+
+ this.dropdownButtonElement.click();
+
+ expect(
+ $('.dropdown-content li:first-child').text(),
+ ).toBe('testing');
+
+ expect(
+ $('.dropdown-content li:first-child a').html(),
+ ).toBe('<b>t</b><b>e</b><b>s</b><b>t</b>ing');
+ });
+
describe('that is open', () => {
beforeEach(() => {
initDropDown.call(this, false, false);
@@ -158,7 +185,7 @@ require('~/lib/utils/url_utility');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
- it('should focus on input when opening for the second time', () => {
+ it('should focus on input when opening for the second time after transition', () => {
remoteCallback();
this.dropdownContainerElement.trigger({
type: 'keyup',
@@ -166,6 +193,7 @@ require('~/lib/utils/url_utility');
keyCode: ARROW_KEYS.ESC
});
this.dropdownButtonElement.click();
+ this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
@@ -174,6 +202,7 @@ require('~/lib/utils/url_utility');
it('should focus input when passing array data to drop down', () => {
initDropDown.call(this, false, true);
this.dropdownButtonElement.click();
+ this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
diff --git a/spec/javascripts/gl_emoji_spec.js b/spec/javascripts/gl_emoji_spec.js
deleted file mode 100644
index b2b46640e5b..00000000000
--- a/spec/javascripts/gl_emoji_spec.js
+++ /dev/null
@@ -1,399 +0,0 @@
-import { glEmojiTag } from '~/behaviors/gl_emoji';
-import {
- isEmojiUnicodeSupported,
- isFlagEmoji,
- isKeycapEmoji,
- isSkinToneComboEmoji,
- isHorceRacingSkinToneComboEmoji,
- isPersonZwjEmoji,
-} from '~/behaviors/gl_emoji/is_emoji_unicode_supported';
-
-const emptySupportMap = {
- personZwj: false,
- horseRacing: false,
- flag: false,
- skinToneModifier: false,
- '9.0': false,
- '8.0': false,
- '7.0': false,
- 6.1: false,
- '6.0': false,
- 5.2: false,
- 5.1: false,
- 4.1: false,
- '4.0': false,
- 3.2: false,
- '3.0': false,
- 1.1: false,
-};
-
-const emojiFixtureMap = {
- bomb: {
- name: 'bomb',
- moji: '💣',
- unicodeVersion: '6.0',
- },
- construction_worker_tone5: {
- name: 'construction_worker_tone5',
- moji: '👷ðŸ¿',
- unicodeVersion: '8.0',
- },
- five: {
- name: 'five',
- moji: '5ï¸âƒ£',
- unicodeVersion: '3.0',
- },
- grey_question: {
- name: 'grey_question',
- moji: 'â”',
- unicodeVersion: '6.0',
- },
-};
-
-function markupToDomElement(markup) {
- const div = document.createElement('div');
- div.innerHTML = markup;
- return div.firstElementChild;
-}
-
-function testGlEmojiImageFallback(element, name, src) {
- expect(element.tagName.toLowerCase()).toBe('img');
- expect(element.getAttribute('src')).toBe(src);
- expect(element.getAttribute('title')).toBe(`:${name}:`);
- expect(element.getAttribute('alt')).toBe(`:${name}:`);
-}
-
-const defaults = {
- forceFallback: false,
- sprite: false,
-};
-
-function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
- const opts = Object.assign({}, defaults, options);
- expect(element.tagName.toLowerCase()).toBe('gl-emoji');
- expect(element.dataset.name).toBe(name);
- expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
- expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
-
- const fallbackSpriteClass = `emoji-${name}`;
- if (opts.sprite) {
- expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
- }
-
- if (opts.forceFallback && opts.sprite) {
- expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
- }
-
- if (opts.forceFallback && !opts.sprite) {
- // Check for image fallback
- testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
- } else {
- // Otherwise make sure things are still unicode text
- expect(element.textContent.trim()).toBe(unicodeMoji);
- }
-}
-
-describe('gl_emoji', () => {
- describe('glEmojiTag', () => {
- it('bomb emoji', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- );
- });
-
- it('bomb emoji with image fallback', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- forceFallback: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- },
- );
- });
-
- it('bomb emoji with sprite fallback readiness', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- sprite: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- sprite: true,
- },
- );
- });
- it('bomb emoji with sprite fallback', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- forceFallback: true,
- sprite: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- sprite: true,
- },
- );
- });
-
- it('question mark when invalid emoji name given', () => {
- const name = 'invalid_emoji';
- const emojiKey = 'grey_question';
- const markup = glEmojiTag(name);
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- );
- });
-
- it('question mark with image fallback when invalid emoji name given', () => {
- const name = 'invalid_emoji';
- const emojiKey = 'grey_question';
- const markup = glEmojiTag(name, {
- forceFallback: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- },
- );
- });
- });
-
- describe('isFlagEmoji', () => {
- it('should detect flag_ac', () => {
- expect(isFlagEmoji('🇦🇨')).toBeTruthy();
- });
- it('should detect flag_us', () => {
- expect(isFlagEmoji('🇺🇸')).toBeTruthy();
- });
- it('should detect flag_zw', () => {
- expect(isFlagEmoji('🇿🇼')).toBeTruthy();
- });
- it('should not detect flags', () => {
- expect(isFlagEmoji('ðŸŽ')).toBeFalsy();
- });
- it('should not detect triangular_flag_on_post', () => {
- expect(isFlagEmoji('🚩')).toBeFalsy();
- });
- it('should not detect single letter', () => {
- expect(isFlagEmoji('🇦')).toBeFalsy();
- });
- it('should not detect >2 letters', () => {
- expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
- });
- });
-
- describe('isKeycapEmoji', () => {
- it('should detect one(keycap)', () => {
- expect(isKeycapEmoji('1ï¸âƒ£')).toBeTruthy();
- });
- it('should detect nine(keycap)', () => {
- expect(isKeycapEmoji('9ï¸âƒ£')).toBeTruthy();
- });
- it('should not detect ten(keycap)', () => {
- expect(isKeycapEmoji('🔟')).toBeFalsy();
- });
- it('should not detect hash(keycap)', () => {
- expect(isKeycapEmoji('#⃣')).toBeFalsy();
- });
- });
-
- describe('isSkinToneComboEmoji', () => {
- it('should detect hand_splayed_tone5', () => {
- expect(isSkinToneComboEmoji('ðŸ–ðŸ¿')).toBeTruthy();
- });
- it('should not detect hand_splayed', () => {
- expect(isSkinToneComboEmoji('ðŸ–')).toBeFalsy();
- });
- it('should detect lifter_tone1', () => {
- expect(isSkinToneComboEmoji('ðŸ‹ðŸ»')).toBeTruthy();
- });
- it('should not detect lifter', () => {
- expect(isSkinToneComboEmoji('ðŸ‹')).toBeFalsy();
- });
- it('should detect rowboat_tone4', () => {
- expect(isSkinToneComboEmoji('🚣ðŸ¾')).toBeTruthy();
- });
- it('should not detect rowboat', () => {
- expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
- });
- it('should not detect individual tone emoji', () => {
- expect(isSkinToneComboEmoji('ðŸ»')).toBeFalsy();
- });
- });
-
- describe('isHorceRacingSkinToneComboEmoji', () => {
- it('should detect horse_racing_tone2', () => {
- expect(isHorceRacingSkinToneComboEmoji('ðŸ‡ðŸ¼')).toBeTruthy();
- });
- it('should not detect horse_racing', () => {
- expect(isHorceRacingSkinToneComboEmoji('ðŸ‡')).toBeFalsy();
- });
- });
-
- describe('isPersonZwjEmoji', () => {
- it('should detect couple_mm', () => {
- expect(isPersonZwjEmoji('👨â€â¤ï¸â€ðŸ‘¨')).toBeTruthy();
- });
- it('should not detect couple_with_heart', () => {
- expect(isPersonZwjEmoji('💑')).toBeFalsy();
- });
- it('should not detect couplekiss', () => {
- expect(isPersonZwjEmoji('ðŸ’')).toBeFalsy();
- });
- it('should detect family_mmb', () => {
- expect(isPersonZwjEmoji('👨â€ðŸ‘¨â€ðŸ‘¦')).toBeTruthy();
- });
- it('should detect family_mwgb', () => {
- expect(isPersonZwjEmoji('👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦')).toBeTruthy();
- });
- it('should not detect family', () => {
- expect(isPersonZwjEmoji('👪')).toBeFalsy();
- });
- it('should detect kiss_ww', () => {
- expect(isPersonZwjEmoji('👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘©')).toBeTruthy();
- });
- it('should not detect girl', () => {
- expect(isPersonZwjEmoji('👧')).toBeFalsy();
- });
- it('should not detect girl_tone5', () => {
- expect(isPersonZwjEmoji('👧ðŸ¿')).toBeFalsy();
- });
- it('should not detect man', () => {
- expect(isPersonZwjEmoji('👨')).toBeFalsy();
- });
- it('should not detect woman', () => {
- expect(isPersonZwjEmoji('👩')).toBeFalsy();
- });
- });
-
- describe('isEmojiUnicodeSupported', () => {
- it('bomb(6.0) with 6.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '6.0': true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeTruthy();
- });
-
- it('bomb(6.0) without 6.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = emptySupportMap;
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeFalsy();
- });
-
- it('bomb(6.0) without 6.0 but with 9.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '9.0': true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeFalsy();
- });
-
- it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
- const emojiKey = 'construction_worker_tone5';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- skinToneModifier: false,
- '9.0': true,
- '8.0': true,
- '7.0': true,
- 6.1: true,
- '6.0': true,
- 5.2: true,
- 5.1: true,
- 4.1: true,
- '4.0': true,
- 3.2: true,
- '3.0': true,
- 1.1: true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeFalsy();
- });
-
- it('use native keycap on >=57 chrome', () => {
- const emojiKey = 'five';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '3.0': true,
- meta: {
- isChrome: true,
- chromeVersion: 57,
- },
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeTruthy();
- });
-
- it('fallback keycap on <57 chrome', () => {
- const emojiKey = 'five';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '3.0': true,
- meta: {
- isChrome: true,
- chromeVersion: 50,
- },
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
- expect(isSupported).toBeFalsy();
- });
- });
-});
diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/javascripts/gl_field_errors_spec.js
index 733023481f5..fa24aa426b6 100644
--- a/spec/javascripts/gl_field_errors_spec.js
+++ b/spec/javascripts/gl_field_errors_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable space-before-function-paren, arrow-body-style */
-require('~/gl_field_errors');
+import '~/gl_field_errors';
((global) => {
preloadFixtures('static/gl_field_errors.html.raw');
diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js
index 71d6e2a7e22..837feacec1d 100644
--- a/spec/javascripts/gl_form_spec.js
+++ b/spec/javascripts/gl_form_spec.js
@@ -1,9 +1,9 @@
-/* global autosize */
+import autosize from 'vendor/autosize';
+import '~/gl_form';
+import '~/lib/utils/text_utility';
+import '~/lib/utils/common_utils';
-window.autosize = require('vendor/autosize');
-require('~/gl_form');
-require('~/lib/utils/text_utility');
-require('~/lib/utils/common_utils');
+window.autosize = autosize;
describe('GLForm', () => {
const global = window.gl || (window.gl = {});
@@ -27,12 +27,12 @@ describe('GLForm', () => {
$.prototype.off.calls.reset();
$.prototype.on.calls.reset();
$.prototype.css.calls.reset();
- autosize.calls.reset();
+ window.autosize.calls.reset();
done();
});
});
- describe('.setupAutosize', () => {
+ describe('setupAutosize', () => {
beforeEach((done) => {
this.glForm.setupAutosize();
setTimeout(() => {
@@ -51,7 +51,7 @@ describe('GLForm', () => {
});
it('should autosize the textarea', () => {
- expect(autosize).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(window.autosize).toHaveBeenCalledWith(jasmine.any(Object));
});
it('should set the resize css property to vertical', () => {
@@ -59,7 +59,7 @@ describe('GLForm', () => {
});
});
- describe('.setHeightData', () => {
+ describe('setHeightData', () => {
beforeEach(() => {
spyOn($.prototype, 'data');
spyOn($.prototype, 'outerHeight').and.returnValue(200);
@@ -75,13 +75,13 @@ describe('GLForm', () => {
});
});
- describe('.destroyAutosize', () => {
+ describe('destroyAutosize', () => {
describe('when called', () => {
beforeEach(() => {
spyOn($.prototype, 'data');
spyOn($.prototype, 'outerHeight').and.returnValue(200);
spyOn(window, 'outerHeight').and.returnValue(400);
- spyOn(autosize, 'destroy');
+ spyOn(window.autosize, 'destroy');
this.glForm.destroyAutosize();
});
@@ -95,7 +95,7 @@ describe('GLForm', () => {
});
it('should call autosize destroy', () => {
- expect(autosize.destroy).toHaveBeenCalledWith(this.textarea);
+ expect(window.autosize.destroy).toHaveBeenCalledWith(this.textarea);
});
it('should set the data-height attribute', () => {
@@ -114,9 +114,9 @@ describe('GLForm', () => {
it('should return undefined if the data-height equals the outerHeight', () => {
spyOn($.prototype, 'outerHeight').and.returnValue(200);
spyOn($.prototype, 'data').and.returnValue(200);
- spyOn(autosize, 'destroy');
+ spyOn(window.autosize, 'destroy');
expect(this.glForm.destroyAutosize()).toBeUndefined();
- expect(autosize.destroy).not.toHaveBeenCalled();
+ expect(window.autosize.destroy).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/javascripts/gpg_badges_spec.js b/spec/javascripts/gpg_badges_spec.js
new file mode 100644
index 00000000000..7a826487bf9
--- /dev/null
+++ b/spec/javascripts/gpg_badges_spec.js
@@ -0,0 +1,48 @@
+import GpgBadges from '~/gpg_badges';
+
+describe('GpgBadges', () => {
+ const dummyCommitSha = 'n0m0rec0ffee';
+ const dummyBadgeHtml = 'dummy html';
+ const dummyResponse = {
+ signatures: [{
+ commit_sha: dummyCommitSha,
+ html: dummyBadgeHtml,
+ }],
+ };
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="parent-container">
+ <div class="js-loading-gpg-badge" data-commit-sha="${dummyCommitSha}"></div>
+ </div>
+ `);
+ });
+
+ it('displays a loading spinner', () => {
+ spyOn($, 'get').and.returnValue({
+ done() {
+ // intentionally left blank
+ },
+ });
+
+ GpgBadges.fetch();
+
+ expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
+ const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin');
+ expect(spinners.length).toBe(1);
+ });
+
+ it('replaces the loading spinner', () => {
+ spyOn($, 'get').and.returnValue({
+ done(callback) {
+ callback(dummyResponse);
+ },
+ });
+
+ GpgBadges.fetch();
+
+ expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
+ const parentContainer = document.querySelector('.parent-container');
+ expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
+ });
+});
diff --git a/spec/javascripts/groups/group_identicon_spec.js b/spec/javascripts/groups/group_identicon_spec.js
new file mode 100644
index 00000000000..66772327503
--- /dev/null
+++ b/spec/javascripts/groups/group_identicon_spec.js
@@ -0,0 +1,60 @@
+import Vue from 'vue';
+import groupIdenticonComponent from '~/groups/components/group_identicon.vue';
+import GroupsStore from '~/groups/stores/groups_store';
+import { group1 } from './mock_data';
+
+const createComponent = () => {
+ const Component = Vue.extend(groupIdenticonComponent);
+ const store = new GroupsStore();
+ const group = store.decorateGroup(group1);
+
+ return new Component({
+ propsData: {
+ entityId: group.id,
+ entityName: group.name,
+ },
+ }).$mount();
+};
+
+describe('GroupIdenticonComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ describe('computed', () => {
+ describe('identiconStyles', () => {
+ it('should return styles attribute value with `background-color` property', () => {
+ vm.entityId = 4;
+
+ expect(vm.identiconStyles).toBeDefined();
+ expect(vm.identiconStyles.indexOf('background-color: #E0F2F1;') > -1).toBeTruthy();
+ });
+
+ it('should return styles attribute value with `color` property', () => {
+ vm.entityId = 4;
+
+ expect(vm.identiconStyles).toBeDefined();
+ expect(vm.identiconStyles.indexOf('color: #555;') > -1).toBeTruthy();
+ });
+ });
+
+ describe('identiconTitle', () => {
+ it('should return first letter of entity title in uppercase', () => {
+ vm.entityName = 'dummy-group';
+
+ expect(vm.identiconTitle).toBeDefined();
+ expect(vm.identiconTitle).toBe('D');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render identicon', () => {
+ expect(vm.$el.nodeName).toBe('DIV');
+ expect(vm.$el.classList.contains('identicon')).toBeTruthy();
+ expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
new file mode 100644
index 00000000000..25e10552d95
--- /dev/null
+++ b/spec/javascripts/groups/group_item_spec.js
@@ -0,0 +1,102 @@
+import Vue from 'vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import GroupsStore from '~/groups/stores/groups_store';
+import { group1 } from './mock_data';
+
+describe('Groups Component', () => {
+ let GroupItemComponent;
+ let component;
+ let store;
+ let group;
+
+ describe('group with default data', () => {
+ beforeEach((done) => {
+ GroupItemComponent = Vue.extend(groupItemComponent);
+ store = new GroupsStore();
+ group = store.decorateGroup(group1);
+
+ component = new GroupItemComponent({
+ propsData: {
+ group,
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should render the group item correctly', () => {
+ expect(component.$el.classList.contains('group-row')).toBe(true);
+ expect(component.$el.classList.contains('.no-description')).toBe(false);
+ expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects);
+ expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers);
+ expect(component.$el.querySelector('.group-visibility')).toBeDefined();
+ expect(component.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(component.$el.querySelector('.title').textContent).toContain(group.name);
+ expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess);
+ expect(component.$el.querySelector('.description').textContent).toContain(group.description);
+ expect(component.$el.querySelector('.edit-group')).toBeDefined();
+ expect(component.$el.querySelector('.leave-group')).toBeDefined();
+ });
+ });
+
+ describe('group without description', () => {
+ beforeEach((done) => {
+ GroupItemComponent = Vue.extend(groupItemComponent);
+ store = new GroupsStore();
+ group1.description = '';
+ group = store.decorateGroup(group1);
+
+ component = new GroupItemComponent({
+ propsData: {
+ group,
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should render group item correctly', () => {
+ expect(component.$el.querySelector('.description').textContent).toBe('');
+ expect(component.$el.classList.contains('.no-description')).toBe(false);
+ });
+ });
+
+ describe('user has not access to group', () => {
+ beforeEach((done) => {
+ GroupItemComponent = Vue.extend(groupItemComponent);
+ store = new GroupsStore();
+ group1.permissions.human_group_access = null;
+ group = store.decorateGroup(group1);
+
+ component = new GroupItemComponent({
+ propsData: {
+ group,
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should not display access type', () => {
+ expect(component.$el.querySelector('.access-type')).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js
new file mode 100644
index 00000000000..b14153dbbfa
--- /dev/null
+++ b/spec/javascripts/groups/groups_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import eventHub from '~/groups/event_hub';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupsComponent from '~/groups/components/groups.vue';
+import GroupsStore from '~/groups/stores/groups_store';
+import { groupsData } from './mock_data';
+
+describe('Groups Component', () => {
+ let GroupsComponent;
+ let store;
+ let component;
+ let groups;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ store = new GroupsStore();
+ groups = store.setGroups(groupsData.groups);
+
+ store.storePagination(groupsData.pagination);
+
+ GroupsComponent = Vue.extend(groupsComponent);
+
+ component = new GroupsComponent({
+ propsData: {
+ groups: store.state.groups,
+ pageInfo: store.state.pageInfo,
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ describe('with data', () => {
+ it('should render a list of groups', () => {
+ expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true);
+ expect(component.$el.querySelector('#group-12')).toBeDefined();
+ expect(component.$el.querySelector('#group-1119')).toBeDefined();
+ expect(component.$el.querySelector('#group-1120')).toBeDefined();
+ });
+
+ it('should respect the order of groups', () => {
+ const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
+ expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
+ expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
+ });
+
+ it('should render group and its subgroup', () => {
+ const lists = component.$el.querySelectorAll('.group-list-tree');
+
+ expect(lists.length).toBe(3); // one parent and two subgroups
+
+ expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
+ expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
+
+ expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
+ });
+
+ it('should render group identicon when group avatar is not present', () => {
+ const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
+ expect(avatar.nodeName).toBe('DIV');
+ expect(avatar.classList.contains('identicon')).toBeTruthy();
+ expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
+ });
+
+ it('should render group avatar when group avatar is present', () => {
+ const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar');
+ expect(avatar.nodeName).toBe('IMG');
+ expect(avatar.classList.contains('identicon')).toBeFalsy();
+ });
+
+ it('should remove prefix of parent group', () => {
+ expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
+ });
+
+ it('should remove the group after leaving the group', (done) => {
+ spyOn(window, 'confirm').and.returnValue(true);
+
+ eventHub.$on('leaveGroup', (group, collection) => {
+ store.removeGroup(group, collection);
+ });
+
+ component.$el.querySelector('#group-12 .leave-group').click();
+
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('#group-12')).toBeNull();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
new file mode 100644
index 00000000000..5bb84b591f4
--- /dev/null
+++ b/spec/javascripts/groups/mock_data.js
@@ -0,0 +1,114 @@
+const group1 = {
+ id: 12,
+ name: 'level1',
+ path: 'level1',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/level1',
+ group_path: '/level1',
+ full_name: 'level1',
+ full_path: 'level1',
+ parent_id: null,
+ created_at: '2017-05-15T19:01:23.670Z',
+ updated_at: '2017-05-15T19:01:23.670Z',
+ number_projects_with_delimiter: '1',
+ number_users_with_delimiter: '1',
+ has_subgroups: true,
+ permissions: {
+ human_group_access: 'Master',
+ },
+};
+
+// This group has no direct parent, should be placed as subgroup of group1
+const group14 = {
+ id: 1128,
+ name: 'level4',
+ path: 'level4',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
+ group_path: '/level1/level2/level3/level4',
+ full_name: 'level1 / level2 / level3 / level4',
+ full_path: 'level1/level2/level3/level4',
+ parent_id: 1127,
+ created_at: '2017-05-15T19:02:01.645Z',
+ updated_at: '2017-05-15T19:02:01.645Z',
+ number_projects_with_delimiter: '1',
+ number_users_with_delimiter: '1',
+ has_subgroups: true,
+ permissions: {
+ human_group_access: 'Master',
+ },
+};
+
+const group2 = {
+ id: 1119,
+ name: 'devops',
+ path: 'devops',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/groups/devops',
+ group_path: '/devops',
+ full_name: 'devops',
+ full_path: 'devops',
+ parent_id: null,
+ created_at: '2017-05-11T19:35:09.635Z',
+ updated_at: '2017-05-11T19:35:09.635Z',
+ number_projects_with_delimiter: '1',
+ number_users_with_delimiter: '1',
+ has_subgroups: true,
+ permissions: {
+ human_group_access: 'Master',
+ },
+};
+
+const group21 = {
+ id: 1120,
+ name: 'chef',
+ path: 'chef',
+ description: 'foo',
+ visibility: 'public',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ web_url: 'http://localhost:3000/groups/devops/chef',
+ group_path: '/devops/chef',
+ full_name: 'devops / chef',
+ full_path: 'devops/chef',
+ parent_id: 1119,
+ created_at: '2017-05-11T19:51:04.060Z',
+ updated_at: '2017-05-11T19:51:04.060Z',
+ number_projects_with_delimiter: '1',
+ number_users_with_delimiter: '1',
+ has_subgroups: true,
+ permissions: {
+ human_group_access: 'Master',
+ },
+};
+
+const groupsData = {
+ groups: [group1, group14, group2, group21],
+ pagination: {
+ Date: 'Mon, 22 May 2017 22:31:52 GMT',
+ 'X-Prev-Page': '1',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Total': '31',
+ 'Transfer-Encoding': 'chunked',
+ 'X-Runtime': '0.611144',
+ 'X-Xss-Protection': '1; mode=block',
+ 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
+ 'X-Ua-Compatible': 'IE=edge',
+ 'X-Per-Page': '20',
+ Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
+ 'X-Next-Page': '',
+ Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
+ 'X-Frame-Options': 'DENY',
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'max-age=0, private, must-revalidate',
+ 'X-Total-Pages': '2',
+ 'X-Page': '2',
+ },
+};
+
+export { groupsData, group1 };
diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js
index b5dde5525e5..0e01934d3a3 100644
--- a/spec/javascripts/header_spec.js
+++ b/spec/javascripts/header_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable space-before-function-paren, no-var */
-require('~/header');
-require('~/lib/utils/text_utility');
+import '~/header';
+import '~/lib/utils/text_utility';
(function() {
describe('Header', function() {
diff --git a/spec/javascripts/helpers/class_spec_helper.js b/spec/javascripts/helpers/class_spec_helper.js
index 61db27a8fcc..7a60d33b471 100644
--- a/spec/javascripts/helpers/class_spec_helper.js
+++ b/spec/javascripts/helpers/class_spec_helper.js
@@ -1,4 +1,4 @@
-class ClassSpecHelper {
+export default class ClassSpecHelper {
static itShouldBeAStaticMethod(base, method) {
return it('should be a static method', () => {
expect(Object.prototype.hasOwnProperty.call(base, method)).toBeTruthy();
@@ -7,5 +7,3 @@ class ClassSpecHelper {
}
window.ClassSpecHelper = ClassSpecHelper;
-
-module.exports = ClassSpecHelper;
diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/javascripts/helpers/class_spec_helper_spec.js
index 0a61e561640..686b8eaed31 100644
--- a/spec/javascripts/helpers/class_spec_helper_spec.js
+++ b/spec/javascripts/helpers/class_spec_helper_spec.js
@@ -1,9 +1,9 @@
/* global ClassSpecHelper */
-require('./class_spec_helper');
+import './class_spec_helper';
describe('ClassSpecHelper', () => {
- describe('.itShouldBeAStaticMethod', function () {
+ describe('itShouldBeAStaticMethod', function () {
beforeEach(() => {
class TestClass {
instanceMethod() { this.prop = 'val'; }
diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js
index b8d4a93b1ab..8933dd5def4 100644
--- a/spec/javascripts/helpers/filtered_search_spec_helper.js
+++ b/spec/javascripts/helpers/filtered_search_spec_helper.js
@@ -1,4 +1,4 @@
-class FilteredSearchSpecHelper {
+export default class FilteredSearchSpecHelper {
static createFilterVisualTokenHTML(name, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML;
}
@@ -30,12 +30,15 @@ class FilteredSearchSpecHelper {
`;
}
+ static createSearchVisualToken(name) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-term');
+ li.innerHTML = `<div class="name">${name}</div>`;
+ return li;
+ }
+
static createSearchVisualTokenHTML(name) {
- return `
- <li class="js-visual-token filtered-search-term">
- <div class="name">${name}</div>
- </li>
- `;
+ return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
}
static createInputHTML(placeholder = '', value = '') {
@@ -53,5 +56,3 @@ class FilteredSearchSpecHelper {
`;
}
}
-
-module.exports = FilteredSearchSpecHelper;
diff --git a/spec/javascripts/helpers/user_mock_data_helper.js b/spec/javascripts/helpers/user_mock_data_helper.js
new file mode 100644
index 00000000000..a9783ea065c
--- /dev/null
+++ b/spec/javascripts/helpers/user_mock_data_helper.js
@@ -0,0 +1,16 @@
+export default {
+ createNumberRandomUsers(numberUsers) {
+ const users = [];
+ for (let i = 0; i < numberUsers; i = i += 1) {
+ users.push(
+ {
+ avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: (i + 1),
+ name: `GitLab User ${i}`,
+ username: `gitlab${i}`,
+ },
+ );
+ }
+ return users;
+ },
+};
diff --git a/spec/javascripts/helpers/vue_resource_helper.js b/spec/javascripts/helpers/vue_resource_helper.js
new file mode 100644
index 00000000000..0d1bf5e2e80
--- /dev/null
+++ b/spec/javascripts/helpers/vue_resource_helper.js
@@ -0,0 +1,11 @@
+// eslint-disable-next-line import/prefer-default-export
+export const headersInterceptor = (request, next) => {
+ next((response) => {
+ const headers = {};
+ response.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+ // eslint-disable-next-line no-param-reassign
+ response.headers = headers;
+ });
+};
diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js
new file mode 100644
index 00000000000..3daeb91b1e2
--- /dev/null
+++ b/spec/javascripts/integrations/integration_settings_form_spec.js
@@ -0,0 +1,199 @@
+import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+
+describe('IntegrationSettingsForm', () => {
+ const FIXTURE = 'services/edit_service.html.raw';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('contructor', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ spyOn(integrationSettingsForm, 'init');
+ });
+
+ it('should initialize form element refs on class object', () => {
+ // Form Reference
+ expect(integrationSettingsForm.$form).toBeDefined();
+ expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM');
+
+ // Form Child Elements
+ expect(integrationSettingsForm.$serviceToggle).toBeDefined();
+ expect(integrationSettingsForm.$submitBtn).toBeDefined();
+ expect(integrationSettingsForm.$submitBtnLoader).toBeDefined();
+ expect(integrationSettingsForm.$submitBtnLabel).toBeDefined();
+ });
+
+ it('should initialize form metadata on class object', () => {
+ expect(integrationSettingsForm.testEndPoint).toBeDefined();
+ expect(integrationSettingsForm.canTestService).toBeDefined();
+ });
+ });
+
+ describe('toggleServiceState', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should remove `novalidate` attribute to form when called with `true`', () => {
+ integrationSettingsForm.toggleServiceState(true);
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined();
+ });
+
+ it('should set `novalidate` attribute to form when called with `false`', () => {
+ integrationSettingsForm.toggleServiceState(false);
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined();
+ });
+ });
+
+ describe('toggleSubmitBtnLabel', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => {
+ integrationSettingsForm.canTestService = true;
+
+ integrationSettingsForm.toggleSubmitBtnLabel(true);
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Test settings and save changes');
+ });
+
+ it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => {
+ integrationSettingsForm.canTestService = false;
+
+ integrationSettingsForm.toggleSubmitBtnLabel(false);
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+
+ integrationSettingsForm.toggleSubmitBtnLabel(true);
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+
+ integrationSettingsForm.canTestService = true;
+
+ integrationSettingsForm.toggleSubmitBtnLabel(false);
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+ });
+ });
+
+ describe('toggleSubmitBtnState', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should disable Save button and show loader animation when called with `true`', () => {
+ integrationSettingsForm.toggleSubmitBtnState(true);
+
+ expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy();
+ expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should enable Save button and hide loader animation when called with `false`', () => {
+ integrationSettingsForm.toggleSubmitBtnState(false);
+
+ expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy();
+ expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('testSettings', () => {
+ let integrationSettingsForm;
+ let formData;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ formData = integrationSettingsForm.$form.serialize();
+ });
+
+ it('should make an ajax request with provided `formData`', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ expect($.ajax).toHaveBeenCalledWith({
+ type: 'PUT',
+ url: integrationSettingsForm.testEndPoint,
+ data: formData,
+ });
+ });
+
+ it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => {
+ const errorMessage = 'Test failed.';
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ deferred.resolve({ error: true, message: errorMessage, service_response: 'some error' });
+
+ const $flashContainer = $('.flash-container');
+ expect($flashContainer.find('.flash-text').text()).toEqual('Test failed. some error');
+ expect($flashContainer.find('.flash-action')).toBeDefined();
+ expect($flashContainer.find('.flash-action').text()).toEqual('Save anyway');
+ });
+
+ it('should submit form if ajax request responds without any error in test', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ spyOn(integrationSettingsForm.$form, 'submit');
+ deferred.resolve({ error: false });
+
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+
+ it('should submit form when clicked on `Save anyway` action of error Flash', () => {
+ const errorMessage = 'Test failed.';
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ deferred.resolve({ error: true, message: errorMessage });
+
+ const $flashAction = $('.flash-container .flash-action');
+ expect($flashAction).toBeDefined();
+
+ spyOn(integrationSettingsForm.$form, 'submit');
+ $flashAction.trigger('click');
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+
+ it('should show error Flash if ajax request failed', () => {
+ const errorMessage = 'Something went wrong on our end.';
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ deferred.reject();
+
+ expect($('.flash-container .flash-text').text()).toEqual(errorMessage);
+ });
+
+ it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+
+ integrationSettingsForm.testSettings(formData);
+
+ spyOn(integrationSettingsForm, 'toggleSubmitBtnState');
+ deferred.reject();
+
+ expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
+ });
+ });
+});
diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js
index 26d87cc5931..45f55395d3a 100644
--- a/spec/javascripts/issuable_spec.js
+++ b/spec/javascripts/issuable_spec.js
@@ -1,7 +1,7 @@
-/* global Issuable */
+/* global IssuableIndex */
-require('~/lib/utils/url_utility');
-require('~/issuable');
+import '~/lib/utils/url_utility';
+import '~/issuable_index';
(() => {
const BASE_URL = '/user/project/issues?scope=all&state=closed';
@@ -24,11 +24,11 @@ require('~/issuable');
beforeEach(() => {
loadFixtures('static/issuable_filter.html.raw');
- Issuable.init();
+ IssuableIndex.init();
});
it('should be defined', () => {
- expect(window.Issuable).toBeDefined();
+ expect(window.IssuableIndex).toBeDefined();
});
describe('filtering', () => {
@@ -43,7 +43,7 @@ require('~/issuable');
it('should contain only the default parameters', () => {
spyOn(gl.utils, 'visitUrl');
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS);
});
@@ -52,7 +52,7 @@ require('~/issuable');
spyOn(gl.utils, 'visitUrl');
updateForm({ search: 'broken' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
const params = `${DEFAULT_PARAMS}&search=broken`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
@@ -64,14 +64,14 @@ require('~/issuable');
// initial filter
updateForm({ milestone_title: 'v1.0' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
// update filter
updateForm({ label_name: 'Frontend' }, $filtersForm);
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`;
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params);
});
diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js
index 0a830f25e29..8ff93c4f918 100644
--- a/spec/javascripts/issuable_time_tracker_spec.js
+++ b/spec/javascripts/issuable_time_tracker_spec.js
@@ -2,7 +2,7 @@
import Vue from 'vue';
-require('~/issuable/time_tracking/components/time_tracker');
+import timeTracker from '~/sidebar/components/time_tracking/time_tracker';
function initTimeTrackingComponent(opts) {
setFixtures(`
@@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) {
time_spent: opts.timeSpent,
human_time_estimate: opts.timeEstimateHumanReadable,
human_time_spent: opts.timeSpentHumanReadable,
- docsUrl: '/help/workflow/time_tracking.md',
+ rootPath: '/',
};
- const TimeTrackingComponent = Vue.component('issuable-time-tracker');
+ const TimeTrackingComponent = Vue.extend(timeTracker);
this.timeTracker = new TimeTrackingComponent({
el: '#mock-container',
propsData: this.initialData,
});
}
-((gl) => {
- describe('Issuable Time Tracker', function() {
- describe('Initialization', function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
- });
+describe('Issuable Time Tracker', function() {
+ describe('Initialization', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
+ });
- it('should return something defined', function() {
- expect(this.timeTracker).toBeDefined();
- });
+ it('should return something defined', function() {
+ expect(this.timeTracker).toBeDefined();
+ });
- it ('should correctly set timeEstimate', function(done) {
- Vue.nextTick(() => {
- expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate);
- done();
- });
+ it ('should correctly set timeEstimate', function(done) {
+ Vue.nextTick(() => {
+ expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate);
+ done();
});
- it ('should correctly set time_spent', function(done) {
- Vue.nextTick(() => {
- expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent);
- done();
- });
+ });
+ it ('should correctly set time_spent', function(done) {
+ Vue.nextTick(() => {
+ expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent);
+ done();
});
});
+ });
- describe('Content Display', function() {
- describe('Panes', function() {
- describe('Comparison pane', function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
+ describe('Content Display', function() {
+ describe('Panes', function() {
+ describe('Comparison pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
+ });
+
+ it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
+ Vue.nextTick(() => {
+ const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
+ expect(this.timeTracker.showComparisonState).toBe(true);
+ done();
});
+ });
- it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
+ describe('Remaining meter', function() {
+ it('should display the remaining meter with the correct width', function(done) {
Vue.nextTick(() => {
- const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
- expect(this.timeTracker.showComparisonState).toBe(true);
+ const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
+ const correctWidth = '5%';
+
+ expect(meterWidth).toBe(correctWidth);
done();
- });
+ })
});
- describe('Remaining meter', function() {
- it('should display the remaining meter with the correct width', function(done) {
- Vue.nextTick(() => {
- const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
- const correctWidth = '5%';
-
- expect(meterWidth).toBe(correctWidth);
- done();
- })
- });
-
- it('should display the remaining meter with the correct background color when within estimate', function(done) {
- Vue.nextTick(() => {
- const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
- expect(styledMeter.length).toBe(1);
- done()
- });
+ it('should display the remaining meter with the correct background color when within estimate', function(done) {
+ Vue.nextTick(() => {
+ const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
+ expect(styledMeter.length).toBe(1);
+ done()
});
+ });
- it('should display the remaining meter with the correct background color when over estimate', function(done) {
- this.timeTracker.time_estimate = 100000;
- this.timeTracker.time_spent = 20000000;
- Vue.nextTick(() => {
- const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
- expect(styledMeter.length).toBe(1);
- done();
- });
+ it('should display the remaining meter with the correct background color when over estimate', function(done) {
+ this.timeTracker.time_estimate = 100000;
+ this.timeTracker.time_spent = 20000000;
+ Vue.nextTick(() => {
+ const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
+ expect(styledMeter.length).toBe(1);
+ done();
});
});
});
+ });
- describe("Estimate only pane", function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
- });
+ describe("Estimate only pane", function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
+ });
- it('should display the human readable version of time estimated', function(done) {
- Vue.nextTick(() => {
- const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
- const correctText = 'Estimated: 2h 46m';
+ it('should display the human readable version of time estimated', function(done) {
+ Vue.nextTick(() => {
+ const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
+ const correctText = 'Estimated: 2h 46m';
- expect(estimateText).toBe(correctText);
- done();
- });
+ expect(estimateText).toBe(correctText);
+ done();
});
});
+ });
- describe('Spent only pane', function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
- });
+ describe('Spent only pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
+ });
- it('should display the human readable version of time spent', function(done) {
- Vue.nextTick(() => {
- const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
- const correctText = 'Spent: 1h 23m';
+ it('should display the human readable version of time spent', function(done) {
+ Vue.nextTick(() => {
+ const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
+ const correctText = 'Spent: 1h 23m';
- expect(spentText).toBe(correctText);
- done();
- });
+ expect(spentText).toBe(correctText);
+ done();
});
});
+ });
- describe('No time tracking pane', function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 });
- });
+ describe('No time tracking pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
+ });
- it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
- Vue.nextTick(() => {
- const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
- const noTrackingText =$noTrackingPane.innerText;
- const correctText = 'No estimate or time spent';
+ it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
+ Vue.nextTick(() => {
+ const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
+ const noTrackingText =$noTrackingPane.innerText;
+ const correctText = 'No estimate or time spent';
- expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
- expect($noTrackingPane).toBeVisible();
- expect(noTrackingText).toBe(correctText);
- done();
- });
+ expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
+ expect($noTrackingPane).toBeVisible();
+ expect(noTrackingText).toBe(correctText);
+ done();
});
});
+ });
- describe("Help pane", function() {
- beforeEach(function() {
- initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
- });
+ describe("Help pane", function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
+ });
- it('should not show the "Help" pane by default', function(done) {
- Vue.nextTick(() => {
- const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+ it('should not show the "Help" pane by default', function(done) {
+ Vue.nextTick(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
- expect(this.timeTracker.showHelpState).toBe(false);
- expect($helpPane).toBeNull();
- done();
- });
+ expect(this.timeTracker.showHelpState).toBe(false);
+ expect($helpPane).toBeNull();
+ done();
});
+ });
- it('should show the "Help" pane when help button is clicked', function(done) {
- Vue.nextTick(() => {
- $(this.timeTracker.$el).find('.help-button').click();
+ it('should show the "Help" pane when help button is clicked', function(done) {
+ Vue.nextTick(() => {
+ $(this.timeTracker.$el).find('.help-button').click();
- setTimeout(() => {
- const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
- expect(this.timeTracker.showHelpState).toBe(true);
- expect($helpPane).toBeVisible();
- done();
- }, 10);
- });
+ setTimeout(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+ expect(this.timeTracker.showHelpState).toBe(true);
+ expect($helpPane).toBeVisible();
+ done();
+ }, 10);
});
+ });
- it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
- Vue.nextTick(() => {
- $(this.timeTracker.$el).find('.help-button').click();
+ it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
+ Vue.nextTick(() => {
+ $(this.timeTracker.$el).find('.help-button').click();
- setTimeout(() => {
+ setTimeout(() => {
- $(this.timeTracker.$el).find('.close-help-button').click();
+ $(this.timeTracker.$el).find('.close-help-button').click();
- setTimeout(() => {
- const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+ setTimeout(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
- expect(this.timeTracker.showHelpState).toBe(false);
- expect($helpPane).toBeNull();
+ expect(this.timeTracker.showHelpState).toBe(false);
+ expect($helpPane).toBeNull();
- done();
- }, 1000);
+ done();
}, 1000);
- });
+ }, 1000);
});
});
});
});
});
-})(window.gl || (window.gl = {}));
+});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
new file mode 100644
index 00000000000..81ce18bf2fb
--- /dev/null
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -0,0 +1,379 @@
+import Vue from 'vue';
+import '~/render_math';
+import '~/render_gfm';
+import issuableApp from '~/issue_show/components/app.vue';
+import eventHub from '~/issue_show/event_hub';
+import issueShowData from '../mock_data';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('Issuable output', () => {
+ let requestData = issueShowData.initialRequest;
+
+ document.body.innerHTML = '<span id="task_status"></span>';
+
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(requestData), {
+ status: 200,
+ }));
+ };
+
+ let vm;
+
+ beforeEach((done) => {
+ spyOn(eventHub, '$emit');
+
+ const IssuableDescriptionComponent = Vue.extend(issuableApp);
+
+ requestData = issueShowData.initialRequest;
+ Vue.http.interceptors.push(interceptor);
+
+ vm = new IssuableDescriptionComponent({
+ propsData: {
+ canUpdate: true,
+ canDestroy: true,
+ canMove: true,
+ endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
+ issuableRef: '#1',
+ initialTitleHtml: '',
+ initialTitleText: '',
+ initialDescriptionHtml: '',
+ initialDescriptionText: '',
+ markdownPreviewUrl: '/',
+ markdownDocs: '/',
+ projectsAutocompleteUrl: '/',
+ isConfidential: false,
+ projectNamespace: '/',
+ projectPath: '/',
+ },
+ }).$mount();
+
+ setTimeout(done);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+
+ vm.poll.stop();
+ });
+
+ it('should render a title/description/edited and update title/description/edited on update', (done) => {
+ let editedText;
+ Vue.nextTick()
+ .then(() => {
+ editedText = vm.$el.querySelector('.edited-text');
+ })
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
+ expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description');
+ expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ })
+ .then(() => {
+ requestData = issueShowData.secondRequest;
+ vm.poll.makeRequest();
+ })
+ .then(() => new Promise(resolve => setTimeout(resolve)))
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('2 (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
+ expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows actions if permissions are correct', (done) => {
+ vm.showForm = true;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('does not show actions if permissions are incorrect', (done) => {
+ vm.showForm = true;
+ vm.canUpdate = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn'),
+ ).toBeNull();
+
+ done();
+ });
+ });
+
+ it('does not update formState if form is already open', (done) => {
+ vm.openForm();
+
+ vm.state.titleText = 'testing 123';
+
+ vm.openForm();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.store.formState.title,
+ ).not.toBe('testing 123');
+
+ done();
+ });
+ });
+
+ describe('updateIssuable', () => {
+ it('fetches new data after update', (done) => {
+ spyOn(vm.service, 'getData').and.callThrough();
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ confidential: false,
+ web_url: location.pathname,
+ };
+ },
+ });
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ vm.service.getData,
+ ).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('reloads the page if the confidential status has changed', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ confidential: true,
+ web_url: location.pathname,
+ };
+ },
+ });
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ gl.utils.visitUrl,
+ ).toHaveBeenCalledWith(location.pathname);
+
+ done();
+ });
+ });
+
+ it('correctly updates issuable data', (done) => {
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve();
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ vm.service.updateIssuable,
+ ).toHaveBeenCalledWith(vm.formState);
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('close.form');
+
+ done();
+ });
+ });
+
+ it('does not redirect if issue has not moved', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ web_url: location.pathname,
+ confidential: vm.isConfidential,
+ };
+ },
+ });
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ gl.utils.visitUrl,
+ ).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('redirects if issue is moved', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
+ };
+ },
+ });
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ gl.utils.visitUrl,
+ ).toHaveBeenCalledWith('/testing-issue-move');
+
+ done();
+ });
+ });
+
+ it('does not update issuable if project move confirm is false', (done) => {
+ spyOn(window, 'confirm').and.returnValue(false);
+ spyOn(vm.service, 'updateIssuable');
+
+ vm.store.formState.move_to_project_id = 1;
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ vm.service.updateIssuable,
+ ).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('closes form on error', (done) => {
+ spyOn(window, 'Flash').and.callThrough();
+ spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => {
+ reject();
+ }));
+
+ vm.updateIssuable();
+
+ setTimeout(() => {
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('close.form');
+ expect(
+ window.Flash,
+ ).toHaveBeenCalledWith('Error updating issue');
+
+ done();
+ });
+ });
+ });
+
+ describe('deleteIssuable', () => {
+ it('changes URL when deleted', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+ spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return { web_url: '/test' };
+ },
+ });
+ }));
+
+ vm.deleteIssuable();
+
+ setTimeout(() => {
+ expect(
+ gl.utils.visitUrl,
+ ).toHaveBeenCalledWith('/test');
+
+ done();
+ });
+ });
+
+ it('stops polling when deleting', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+ spyOn(vm.poll, 'stop').and.callThrough();
+ spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return { web_url: '/test' };
+ },
+ });
+ }));
+
+ vm.deleteIssuable();
+
+ setTimeout(() => {
+ expect(
+ vm.poll.stop,
+ ).toHaveBeenCalledWith();
+
+ done();
+ });
+ });
+
+ it('closes form on error', (done) => {
+ spyOn(window, 'Flash').and.callThrough();
+ spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => {
+ reject();
+ }));
+
+ vm.deleteIssuable();
+
+ setTimeout(() => {
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('close.form');
+ expect(
+ window.Flash,
+ ).toHaveBeenCalledWith('Error deleting issue');
+
+ done();
+ });
+ });
+ });
+
+ describe('open form', () => {
+ it('shows locked warning if form is open & data is different', (done) => {
+ Vue.nextTick()
+ .then(() => {
+ vm.openForm();
+
+ requestData = issueShowData.secondRequest;
+ vm.poll.makeRequest();
+ })
+ .then(() => new Promise(resolve => setTimeout(resolve)))
+ .then(() => {
+ expect(
+ vm.formState.lockedWarningVisible,
+ ).toBeTruthy();
+
+ expect(
+ vm.$el.querySelector('.alert'),
+ ).not.toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js
new file mode 100644
index 00000000000..360691a3546
--- /dev/null
+++ b/spec/javascripts/issue_show/components/description_spec.js
@@ -0,0 +1,129 @@
+import Vue from 'vue';
+import descriptionComponent from '~/issue_show/components/description.vue';
+
+describe('Description component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(descriptionComponent);
+
+ if (!document.querySelector('.issuable-meta')) {
+ const metaData = document.createElement('div');
+ metaData.classList.add('issuable-meta');
+ metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>';
+
+ document.body.appendChild(metaData);
+ }
+
+ vm = new Component({
+ propsData: {
+ canUpdate: true,
+ descriptionHtml: 'test',
+ descriptionText: 'test',
+ updatedAt: new Date().toString(),
+ taskStatus: '',
+ },
+ }).$mount();
+ });
+
+ it('animates description changes', (done) => {
+ vm.descriptionHtml = 'changed';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.wiki').classList.contains('issue-realtime-pre-pulse'),
+ ).toBeTruthy();
+
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('.wiki').classList.contains('issue-realtime-trigger-pulse'),
+ ).toBeTruthy();
+
+ done();
+ });
+ });
+ });
+
+ // TODO: gl.TaskList no longer exists. rewrite these tests once we have a way to rewire ES modules
+
+ // it('re-inits the TaskList when description changed', (done) => {
+ // spyOn(gl, 'TaskList');
+ // vm.descriptionHtml = 'changed';
+ //
+ // setTimeout(() => {
+ // expect(
+ // gl.TaskList,
+ // ).toHaveBeenCalled();
+ //
+ // done();
+ // });
+ // });
+
+ // it('does not re-init the TaskList when canUpdate is false', (done) => {
+ // spyOn(gl, 'TaskList');
+ // vm.canUpdate = false;
+ // vm.descriptionHtml = 'changed';
+ //
+ // setTimeout(() => {
+ // expect(
+ // gl.TaskList,
+ // ).not.toHaveBeenCalled();
+ //
+ // done();
+ // });
+ // });
+
+ describe('taskStatus', () => {
+ it('adds full taskStatus', (done) => {
+ vm.taskStatus = '1 of 1';
+
+ setTimeout(() => {
+ expect(
+ document.querySelector('.issuable-meta #task_status').textContent.trim(),
+ ).toBe('1 of 1');
+
+ done();
+ });
+ });
+
+ it('adds short taskStatus', (done) => {
+ vm.taskStatus = '1 of 1';
+
+ setTimeout(() => {
+ expect(
+ document.querySelector('.issuable-meta #task_status_short').textContent.trim(),
+ ).toBe('1/1 task');
+
+ done();
+ });
+ });
+
+ it('clears task status text when no tasks are present', (done) => {
+ vm.taskStatus = '0 of 0';
+
+ setTimeout(() => {
+ expect(
+ document.querySelector('.issuable-meta #task_status').textContent.trim(),
+ ).toBe('');
+
+ done();
+ });
+ });
+ });
+
+ it('applies syntax highlighting and math when description changed', (done) => {
+ spyOn(vm, 'renderGFM').and.callThrough();
+ spyOn($.prototype, 'renderGFM').and.callThrough();
+ vm.descriptionHtml = 'changed';
+
+ Vue.nextTick(() => {
+ setTimeout(() => {
+ expect(vm.$refs['gfm-content']).toBeDefined();
+ expect(vm.renderGFM).toHaveBeenCalled();
+ expect($.prototype.renderGFM).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/javascripts/issue_show/components/edit_actions_spec.js
new file mode 100644
index 00000000000..f6625b748b6
--- /dev/null
+++ b/spec/javascripts/issue_show/components/edit_actions_spec.js
@@ -0,0 +1,147 @@
+import Vue from 'vue';
+import editActions from '~/issue_show/components/edit_actions.vue';
+import eventHub from '~/issue_show/event_hub';
+import Store from '~/issue_show/stores';
+
+describe('Edit Actions components', () => {
+ let vm;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(editActions);
+ const store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ store.formState.title = 'test';
+
+ spyOn(eventHub, '$emit');
+
+ vm = new Component({
+ propsData: {
+ canDestroy: true,
+ formState: store.formState,
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders all buttons as enabled', () => {
+ expect(
+ vm.$el.querySelectorAll('.disabled').length,
+ ).toBe(0);
+
+ expect(
+ vm.$el.querySelectorAll('[disabled]').length,
+ ).toBe(0);
+ });
+
+ it('does not render delete button if canUpdate is false', (done) => {
+ vm.canDestroy = false;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn-danger'),
+ ).toBeNull();
+
+ done();
+ });
+ });
+
+ it('disables submit button when title is blank', (done) => {
+ vm.formState.title = '';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn-save').getAttribute('disabled'),
+ ).toBe('disabled');
+
+ done();
+ });
+ });
+
+ describe('updateIssuable', () => {
+ it('sends update.issauble event when clicking save button', () => {
+ vm.$el.querySelector('.btn-save').click();
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('update.issuable');
+ });
+
+ it('shows loading icon after clicking save button', (done) => {
+ vm.$el.querySelector('.btn-save').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn-save .fa'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('disabled button after clicking save button', (done) => {
+ vm.$el.querySelector('.btn-save').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn-save').getAttribute('disabled'),
+ ).toBe('disabled');
+
+ done();
+ });
+ });
+ });
+
+ describe('closeForm', () => {
+ it('emits close.form when clicking cancel', () => {
+ vm.$el.querySelector('.btn-default').click();
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('close.form');
+ });
+ });
+
+ describe('deleteIssuable', () => {
+ it('sends delete.issuable event when clicking save button', () => {
+ spyOn(window, 'confirm').and.returnValue(true);
+ vm.$el.querySelector('.btn-danger').click();
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalledWith('delete.issuable');
+ });
+
+ it('shows loading icon after clicking delete button', (done) => {
+ spyOn(window, 'confirm').and.returnValue(true);
+ vm.$el.querySelector('.btn-danger').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.btn-danger .fa'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('does no actions when confirm is false', (done) => {
+ spyOn(window, 'confirm').and.returnValue(false);
+ vm.$el.querySelector('.btn-danger').click();
+
+ Vue.nextTick(() => {
+ expect(
+ eventHub.$emit,
+ ).not.toHaveBeenCalledWith('delete.issuable');
+ expect(
+ vm.$el.querySelector('.btn-danger .fa'),
+ ).toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js
new file mode 100644
index 00000000000..a0d0750ae34
--- /dev/null
+++ b/spec/javascripts/issue_show/components/edited_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import edited from '~/issue_show/components/edited.vue';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('edited', () => {
+ const EditedComponent = Vue.extend(edited);
+
+ it('should render an edited at+by string', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+
+ it('if no updatedAt is provided, no time element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ });
+
+ it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy();
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+});
diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js
new file mode 100644
index 00000000000..df8189d9290
--- /dev/null
+++ b/spec/javascripts/issue_show/components/fields/description_spec.js
@@ -0,0 +1,76 @@
+import Vue from 'vue';
+import eventHub from '~/issue_show/event_hub';
+import Store from '~/issue_show/stores';
+import descriptionField from '~/issue_show/components/fields/description.vue';
+import { keyboardDownEvent } from '../../helpers';
+
+describe('Description field component', () => {
+ let vm;
+ let store;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(descriptionField);
+ const el = document.createElement('div');
+ store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ store.formState.description = 'test';
+
+ document.body.appendChild(el);
+
+ spyOn(eventHub, '$emit');
+
+ vm = new Component({
+ el,
+ propsData: {
+ markdownPreviewUrl: '/',
+ markdownDocs: '/',
+ formState: store.formState,
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders markdown field with description', () => {
+ expect(
+ vm.$el.querySelector('.md-area textarea').value,
+ ).toBe('test');
+ });
+
+ it('renders markdown field with a markdown description', (done) => {
+ store.formState.description = '**test**';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.md-area textarea').value,
+ ).toBe('**test**');
+
+ done();
+ });
+ });
+
+ it('focuses field when mounted', () => {
+ expect(
+ document.activeElement,
+ ).toBe(vm.$refs.textarea);
+ });
+
+ it('triggers update with meta+enter', () => {
+ vm.$el.querySelector('.md-area textarea').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalled();
+ });
+
+ it('triggers update with ctrl+enter', () => {
+ vm.$el.querySelector('.md-area textarea').dispatchEvent(keyboardDownEvent(13, false, true));
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalled();
+ });
+});
diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js
new file mode 100644
index 00000000000..2b7ee65094b
--- /dev/null
+++ b/spec/javascripts/issue_show/components/fields/description_template_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
+import '~/templates/issuable_template_selector';
+import '~/templates/issuable_template_selectors';
+
+describe('Issue description template component', () => {
+ let vm;
+ let formState;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(descriptionTemplate);
+ formState = {
+ description: 'test',
+ };
+
+ vm = new Component({
+ propsData: {
+ formState,
+ issuableTemplates: [{ name: 'test' }],
+ projectPath: '/',
+ projectNamespace: '/',
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders templates as JSON array in data attribute', () => {
+ expect(
+ vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data'),
+ ).toBe('[{"name":"test"}]');
+ });
+
+ it('updates formState when changing template', () => {
+ vm.issuableTemplate.editor.setValue('test new template');
+
+ expect(
+ formState.description,
+ ).toBe('test new template');
+ });
+
+ it('returns formState description with editor getValue', () => {
+ formState.description = 'testing new template';
+
+ expect(
+ vm.issuableTemplate.editor.getValue(),
+ ).toBe('testing new template');
+ });
+});
diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js
new file mode 100644
index 00000000000..86d35c33ff4
--- /dev/null
+++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import projectMove from '~/issue_show/components/fields/project_move.vue';
+
+describe('Project move field component', () => {
+ let vm;
+ let formState;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(projectMove);
+
+ formState = {
+ move_to_project_id: 0,
+ };
+
+ vm = new Component({
+ propsData: {
+ formState,
+ projectsAutocompleteUrl: '/autocomplete',
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('mounts select2 element', () => {
+ expect(
+ vm.$el.querySelector('.select2-container'),
+ ).not.toBeNull();
+ });
+
+ it('updates formState on change', () => {
+ $(vm.$refs['move-dropdown']).val(2).trigger('change');
+
+ expect(
+ formState.move_to_project_id,
+ ).toBe(2);
+ });
+});
diff --git a/spec/javascripts/issue_show/components/fields/title_spec.js b/spec/javascripts/issue_show/components/fields/title_spec.js
new file mode 100644
index 00000000000..a03b462689f
--- /dev/null
+++ b/spec/javascripts/issue_show/components/fields/title_spec.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import eventHub from '~/issue_show/event_hub';
+import Store from '~/issue_show/stores';
+import titleField from '~/issue_show/components/fields/title.vue';
+import { keyboardDownEvent } from '../../helpers';
+
+describe('Title field component', () => {
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ const Component = Vue.extend(titleField);
+ store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ store.formState.title = 'test';
+
+ spyOn(eventHub, '$emit');
+
+ vm = new Component({
+ propsData: {
+ formState: store.formState,
+ },
+ }).$mount();
+ });
+
+ it('renders form control with formState title', () => {
+ expect(
+ vm.$el.querySelector('.form-control').value,
+ ).toBe('test');
+ });
+
+ it('triggers update with meta+enter', () => {
+ vm.$el.querySelector('.form-control').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalled();
+ });
+
+ it('triggers update with ctrl+enter', () => {
+ vm.$el.querySelector('.form-control').dispatchEvent(keyboardDownEvent(13, false, true));
+
+ expect(
+ eventHub.$emit,
+ ).toHaveBeenCalled();
+ });
+});
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
new file mode 100644
index 00000000000..9a85223208c
--- /dev/null
+++ b/spec/javascripts/issue_show/components/form_spec.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+import formComponent from '~/issue_show/components/form.vue';
+import '~/templates/issuable_template_selector';
+import '~/templates/issuable_template_selectors';
+
+describe('Inline edit form component', () => {
+ let vm;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(formComponent);
+
+ vm = new Component({
+ propsData: {
+ canDestroy: true,
+ canMove: true,
+ formState: {
+ title: 'b',
+ description: 'a',
+ lockedWarningVisible: false,
+ },
+ markdownPreviewUrl: '/',
+ markdownDocs: '/',
+ projectsAutocompleteUrl: '/',
+ projectPath: '/',
+ projectNamespace: '/',
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('does not render template selector if no templates exist', () => {
+ expect(
+ vm.$el.querySelector('.js-issuable-selector-wrap'),
+ ).toBeNull();
+ });
+
+ it('renders template selector when templates exists', (done) => {
+ spyOn(gl, 'IssuableTemplateSelectors');
+ vm.issuableTemplates = ['test'];
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-issuable-selector-wrap'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('hides locked warning by default', () => {
+ expect(
+ vm.$el.querySelector('.alert'),
+ ).toBeNull();
+ });
+
+ it('shows locked warning if formState is different', (done) => {
+ vm.formState.lockedWarningVisible = true;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.alert'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js
new file mode 100644
index 00000000000..a2d90a9b9f5
--- /dev/null
+++ b/spec/javascripts/issue_show/components/title_spec.js
@@ -0,0 +1,75 @@
+import Vue from 'vue';
+import Store from '~/issue_show/stores';
+import titleComponent from '~/issue_show/components/title.vue';
+
+describe('Title component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(titleComponent);
+ const store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ vm = new Component({
+ propsData: {
+ issuableRef: '#1',
+ titleHtml: 'Testing <img />',
+ titleText: 'Testing',
+ showForm: false,
+ formState: store.formState,
+ },
+ }).$mount();
+ });
+
+ it('renders title HTML', () => {
+ expect(
+ vm.$el.innerHTML.trim(),
+ ).toBe('Testing <img>');
+ });
+
+ it('updates page title when changing titleHtml', (done) => {
+ spyOn(vm, 'setPageTitle');
+ vm.titleHtml = 'test';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.setPageTitle,
+ ).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('animates title changes', (done) => {
+ vm.titleHtml = 'test';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.classList.contains('issue-realtime-pre-pulse'),
+ ).toBeTruthy();
+
+ setTimeout(() => {
+ expect(
+ vm.$el.classList.contains('issue-realtime-trigger-pulse'),
+ ).toBeTruthy();
+
+ done();
+ });
+ });
+ });
+
+ it('updates page title after changing title', (done) => {
+ vm.titleHtml = 'changed';
+ vm.titleText = 'changed';
+
+ Vue.nextTick(() => {
+ expect(
+ document.querySelector('title').textContent.trim(),
+ ).toContain('changed');
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/helpers.js b/spec/javascripts/issue_show/helpers.js
new file mode 100644
index 00000000000..5d2ced98ae4
--- /dev/null
+++ b/spec/javascripts/issue_show/helpers.js
@@ -0,0 +1,10 @@
+// eslint-disable-next-line import/prefer-default-export
+export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => {
+ const e = new CustomEvent('keydown');
+
+ e.keyCode = code;
+ e.metaKey = metaKey;
+ e.ctrlKey = ctrlKey;
+
+ return e;
+};
diff --git a/spec/javascripts/issue_show/issue_title_spec.js b/spec/javascripts/issue_show/issue_title_spec.js
deleted file mode 100644
index 03edbf9f947..00000000000
--- a/spec/javascripts/issue_show/issue_title_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Vue from 'vue';
-import issueTitle from '~/issue_show/issue_title.vue';
-
-describe('Issue Title', () => {
- let IssueTitleComponent;
-
- beforeEach(() => {
- IssueTitleComponent = Vue.extend(issueTitle);
- });
-
- it('should render a title', () => {
- const component = new IssueTitleComponent({
- propsData: {
- initialTitle: 'wow',
- endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title',
- },
- }).$mount();
-
- expect(component.$el.classList).toContain('title');
- expect(component.$el.innerHTML).toContain('wow');
- });
-});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
new file mode 100644
index 00000000000..eb3111412a7
--- /dev/null
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -0,0 +1,32 @@
+export default {
+ initialRequest: {
+ title: '<p>this is a title</p>',
+ title_text: 'this is a title',
+ description: '<p>this is a description!</p>',
+ description_text: 'this is a description',
+ task_status: '2 of 4 completed',
+ updated_at: '2015-05-15T12:31:04.428Z',
+ updated_by_name: 'Some User',
+ updated_by_path: '/some_user',
+ },
+ secondRequest: {
+ title: '<p>2</p>',
+ title_text: '2',
+ description: '<p>42</p>',
+ description_text: '42',
+ task_status: '0 of 0 completed',
+ updated_at: '2016-05-15T12:31:04.428Z',
+ updated_by_name: 'Other User',
+ updated_by_path: '/other_user',
+ },
+ issueSpecRequest: {
+ title: '<p>this is a title</p>',
+ title_text: 'this is a title',
+ description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>',
+ description_text: '- [ ] Task List Item',
+ task_status: '0 of 1 completed',
+ updated_at: '2017-05-15T12:31:04.428Z',
+ updated_by_name: 'Last User',
+ updated_by_path: '/last_user',
+ },
+};
diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js
index 9a2570ef7e9..0c8c4d2cea6 100644
--- a/spec/javascripts/issue_spec.js
+++ b/spec/javascripts/issue_spec.js
@@ -1,10 +1,10 @@
/* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */
import Issue from '~/issue';
-
-require('~/lib/utils/text_utility');
+import CloseReopenReportToggle from '~/close_reopen_report_toggle';
+import '~/lib/utils/text_utility';
describe('Issue', function() {
- let $boxClosed, $boxOpen, $btnClose, $btnReopen;
+ let $boxClosed, $boxOpen, $btn;
preloadFixtures('issues/closed-issue.html.raw');
preloadFixtures('issues/issue-with-task-list.html.raw');
@@ -20,9 +20,7 @@ describe('Issue', function() {
function expectIssueState(isIssueOpen) {
expectVisibility($boxClosed, !isIssueOpen);
expectVisibility($boxOpen, isIssueOpen);
-
- expectVisibility($btnClose, isIssueOpen);
- expectVisibility($btnReopen, !isIssueOpen);
+ expect($btn).toHaveText(isIssueOpen ? 'Close issue' : 'Reopen issue');
}
function expectNewBranchButtonState(isPending, canCreate) {
@@ -57,7 +55,7 @@ describe('Issue', function() {
}
}
- function findElements() {
+ function findElements(isIssueInitiallyOpen) {
$boxClosed = $('div.status-box-closed');
expect($boxClosed).toExist();
expect($boxClosed).toHaveText('Closed');
@@ -66,13 +64,9 @@ describe('Issue', function() {
expect($boxOpen).toExist();
expect($boxOpen).toHaveText('Open');
- $btnClose = $('.btn-close.btn-grouped');
- expect($btnClose).toExist();
- expect($btnClose).toHaveText('Close issue');
-
- $btnReopen = $('.btn-reopen.btn-grouped');
- expect($btnReopen).toExist();
- expect($btnReopen).toHaveText('Reopen issue');
+ $btn = $('.js-issuable-close-button');
+ expect($btn).toExist();
+ expect($btn).toHaveText(isIssueInitiallyOpen ? 'Close issue' : 'Reopen issue');
}
describe('task lists', function() {
@@ -81,12 +75,6 @@ describe('Issue', function() {
this.issue = new Issue();
});
- it('modifies the Markdown field', function() {
- spyOn(jQuery, 'ajax').and.stub();
- $('input[type=checkbox]').attr('checked', true).trigger('change');
- expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
- });
-
it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH');
@@ -105,11 +93,10 @@ describe('Issue', function() {
function ajaxSpy(req) {
if (req.url === this.$triggeredButton.attr('href')) {
expect(req.type).toBe('PUT');
- expect(this.$triggeredButton).toHaveProp('disabled', true);
expectNewBranchButtonState(true, false);
return this.issueStateDeferred;
- } else if (req.url === Issue.$btnNewBranch.data('path')) {
- expect(req.type).toBe('get');
+ } else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) {
+ expect(req.type).toBe('GET');
expectNewBranchButtonState(true, false);
return this.canCreateBranchDeferred;
}
@@ -125,10 +112,11 @@ describe('Issue', function() {
loadFixtures('issues/closed-issue.html.raw');
}
- findElements();
+ findElements(isIssueInitiallyOpen);
this.issue = new Issue();
expectIssueState(isIssueInitiallyOpen);
- this.$triggeredButton = isIssueInitiallyOpen ? $btnClose : $btnReopen;
+
+ this.$triggeredButton = $btn;
this.$projectIssuesCounter = $('.issue_counter');
this.$projectIssuesCounter.text('1,001');
@@ -149,7 +137,7 @@ describe('Issue', function() {
});
expectIssueState(!isIssueInitiallyOpen);
- expect(this.$triggeredButton).toHaveProp('disabled', false);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002');
expectNewBranchButtonState(false, !isIssueInitiallyOpen);
});
@@ -164,7 +152,7 @@ describe('Issue', function() {
});
expectIssueState(isIssueInitiallyOpen);
- expect(this.$triggeredButton).toHaveProp('disabled', false);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expectErrorMessage();
expect(this.$projectIssuesCounter.text()).toBe('1,001');
expectNewBranchButtonState(false, isIssueInitiallyOpen);
@@ -178,7 +166,7 @@ describe('Issue', function() {
});
expectIssueState(isIssueInitiallyOpen);
- expect(this.$triggeredButton).toHaveProp('disabled', true);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expectErrorMessage();
expect(this.$projectIssuesCounter.text()).toBe('1,001');
expectNewBranchButtonState(false, isIssueInitiallyOpen);
@@ -201,4 +189,37 @@ describe('Issue', function() {
});
});
});
+
+ describe('units', () => {
+ describe('class constructor', () => {
+ it('calls .initCloseReopenReport', () => {
+ spyOn(Issue.prototype, 'initCloseReopenReport');
+
+ new Issue(); // eslint-disable-line no-new
+
+ expect(Issue.prototype.initCloseReopenReport).toHaveBeenCalled();
+ });
+ });
+
+ describe('initCloseReopenReport', () => {
+ it('calls .initDroplab', () => {
+ const container = jasmine.createSpyObj('container', ['querySelector']);
+ const dropdownTrigger = {};
+ const dropdownList = {};
+ const button = {};
+
+ spyOn(document, 'querySelector').and.returnValue(container);
+ spyOn(CloseReopenReportToggle.prototype, 'initDroplab');
+ container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button);
+
+ Issue.prototype.initCloseReopenReport();
+
+ expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
+ expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js
new file mode 100644
index 00000000000..c7179b3e03d
--- /dev/null
+++ b/spec/javascripts/jobs/header_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import headerComponent from '~/jobs/components/header.vue';
+
+describe('Job details header', () => {
+ let HeaderComponent;
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ HeaderComponent = Vue.extend(headerComponent);
+
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+ props = {
+ job: {
+ status: {
+ group: 'failed',
+ icon: 'ci-status-failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ id: 123,
+ created_at: threeWeeksAgo.toISOString(),
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ retry_path: 'path',
+ new_issue_path: 'path',
+ },
+ isLoading: false,
+ };
+
+ vm = new HeaderComponent({ propsData: props }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render provided job information', () => {
+ expect(
+ vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
+ });
+
+ it('should render retry link', () => {
+ expect(
+ vm.$el.querySelector('.js-retry-button').getAttribute('href'),
+ ).toEqual(props.job.retry_path);
+ });
+
+ it('should render new issue link', () => {
+ expect(
+ vm.$el.querySelector('.js-new-issue').getAttribute('href'),
+ ).toEqual(props.job.new_issue_path);
+ });
+});
diff --git a/spec/javascripts/jobs/job_details_mediator_spec.js b/spec/javascripts/jobs/job_details_mediator_spec.js
new file mode 100644
index 00000000000..1d7fa7e12fc
--- /dev/null
+++ b/spec/javascripts/jobs/job_details_mediator_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import JobMediator from '~/jobs/job_details_mediator';
+import job from './mock_data';
+
+describe('JobMediator', () => {
+ let mediator;
+
+ beforeEach(() => {
+ mediator = new JobMediator({ endpoint: 'foo' });
+ });
+
+ it('should set defaults', () => {
+ expect(mediator.store).toBeDefined();
+ expect(mediator.service).toBeDefined();
+ expect(mediator.options).toEqual({ endpoint: 'foo' });
+ expect(mediator.state.isLoading).toEqual(false);
+ });
+
+ describe('request and store data', () => {
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(job), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(interceptor);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
+ });
+
+ it('should store received data', (done) => {
+ mediator.fetchJob();
+
+ setTimeout(() => {
+ expect(mediator.store.state.job).toEqual(job);
+ done();
+ }, 0);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/job_store_spec.js b/spec/javascripts/jobs/job_store_spec.js
new file mode 100644
index 00000000000..d00faf29d1e
--- /dev/null
+++ b/spec/javascripts/jobs/job_store_spec.js
@@ -0,0 +1,26 @@
+import JobStore from '~/jobs/stores/job_store';
+import job from './mock_data';
+
+describe('Job Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new JobStore();
+ });
+
+ it('should set defaults', () => {
+ expect(store.state.job).toEqual({});
+ });
+
+ describe('storeJob', () => {
+ it('should store empty object if none is provided', () => {
+ store.storeJob();
+ expect(store.state.job).toEqual({});
+ });
+
+ it('should store provided argument', () => {
+ store.storeJob(job);
+ expect(store.state.job).toEqual(job);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/mock_data.js b/spec/javascripts/jobs/mock_data.js
new file mode 100644
index 00000000000..17e4ef26b2c
--- /dev/null
+++ b/spec/javascripts/jobs/mock_data.js
@@ -0,0 +1,123 @@
+const threeWeeksAgo = new Date();
+threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+export default {
+ id: 4757,
+ name: 'test',
+ build_path: '/root/ci-mock/-/jobs/4757',
+ retry_path: '/root/ci-mock/-/jobs/4757/retry',
+ cancel_path: '/root/ci-mock/-/jobs/4757/cancel',
+ new_issue_path: '/root/ci-mock/issues/new',
+ playable: false,
+ created_at: threeWeeksAgo.toISOString(),
+ updated_at: threeWeeksAgo.toISOString(),
+ finished_at: threeWeeksAgo.toISOString(),
+ queued: 9.54,
+ status: {
+ icon: 'icon_status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ has_details: true,
+ details_path: '/root/ci-mock/-/jobs/4757',
+ favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
+ action: {
+ icon: 'icon_action_retry',
+ title: 'Retry',
+ path: '/root/ci-mock/-/jobs/4757/retry',
+ method: 'post',
+ },
+ },
+ coverage: 20,
+ erased_at: threeWeeksAgo.toISOString(),
+ duration: 6.785563,
+ tags: ['tag'],
+ user: {
+ name: 'Root',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ erase_path: '/root/ci-mock/-/jobs/4757/erase',
+ artifacts: [null],
+ runner: {
+ id: 1,
+ description: 'local ci runner',
+ edit_path: '/root/ci-mock/runners/1/edit',
+ },
+ pipeline: {
+ id: 140,
+ user: {
+ name: 'Root',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ active: false,
+ coverage: null,
+ source: 'unknown',
+ created_at: '2017-05-24T09:59:58.634Z',
+ updated_at: '2017-06-01T17:32:00.062Z',
+ path: '/root/ci-mock/pipelines/140',
+ flags: {
+ latest: true,
+ stuck: false,
+ yaml_errors: false,
+ retryable: false,
+ cancelable: false,
+ },
+ details: {
+ status: {
+ icon: 'icon_status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ has_details: true,
+ details_path: '/root/ci-mock/pipelines/140',
+ favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
+ },
+ duration: 6,
+ finished_at: '2017-06-01T17:32:00.042Z',
+ },
+ ref: {
+ name: 'abc',
+ path: '/root/ci-mock/commits/abc',
+ tag: false,
+ branch: true,
+ },
+ commit: {
+ id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
+ short_id: 'c5864777',
+ title: 'Add new file',
+ created_at: '2017-05-24T10:59:52.000+01:00',
+ parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'],
+ message: 'Add new file',
+ author_name: 'Root',
+ author_email: 'admin@example.com',
+ authored_date: '2017-05-24T10:59:52.000+01:00',
+ committer_name: 'Root',
+ committer_email: 'admin@example.com',
+ committed_date: '2017-05-24T10:59:52.000+01:00',
+ author: {
+ name: 'Root',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ commit_url: 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
+ commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
+ },
+ },
+ merge_request: {
+ iid: 2,
+ path: '/root/ci-mock/merge_requests/2',
+ },
+ raw_path: '/root/ci-mock/builds/4757/raw',
+};
diff --git a/spec/javascripts/jobs/sidebar_detail_row_spec.js b/spec/javascripts/jobs/sidebar_detail_row_spec.js
new file mode 100644
index 00000000000..3ac65709c4a
--- /dev/null
+++ b/spec/javascripts/jobs/sidebar_detail_row_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
+
+describe('Sidebar detail row', () => {
+ let SidebarDetailRow;
+ let vm;
+
+ beforeEach(() => {
+ SidebarDetailRow = Vue.extend(sidebarDetailRow);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render no title', () => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ value: 'this is the value',
+ },
+ }).$mount();
+
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
+ });
+
+ beforeEach(() => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ title: 'this is the title',
+ value: 'this is the value',
+ },
+ }).$mount();
+ });
+
+ it('should render provided title and value', () => {
+ expect(
+ vm.$el.textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual('this is the title: this is the value');
+ });
+});
diff --git a/spec/javascripts/jobs/sidebar_details_block_spec.js b/spec/javascripts/jobs/sidebar_details_block_spec.js
new file mode 100644
index 00000000000..95532ef5382
--- /dev/null
+++ b/spec/javascripts/jobs/sidebar_details_block_spec.js
@@ -0,0 +1,111 @@
+import Vue from 'vue';
+import sidebarDetailsBlock from '~/jobs/components/sidebar_details_block.vue';
+import job from './mock_data';
+
+describe('Sidebar details block', () => {
+ let SidebarComponent;
+ let vm;
+
+ function trimWhitespace(element) {
+ return element.textContent.replace(/\s+/g, ' ').trim();
+ }
+
+ beforeEach(() => {
+ SidebarComponent = Vue.extend(sidebarDetailsBlock);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when it is loading', () => {
+ it('should render a loading spinner', () => {
+ vm = new SidebarComponent({
+ propsData: {
+ job: {},
+ isLoading: true,
+ },
+ }).$mount();
+
+ expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
+ });
+ });
+
+ beforeEach(() => {
+ vm = new SidebarComponent({
+ propsData: {
+ job,
+ isLoading: false,
+ },
+ }).$mount();
+ });
+
+ describe('actions', () => {
+ it('should render link to new issue', () => {
+ expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path);
+ expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
+ });
+
+ it('should render link to retry job', () => {
+ expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path);
+ });
+
+ it('should render link to cancel job', () => {
+ expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
+ });
+ });
+
+ describe('information', () => {
+ it('should render merge request link', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-mr')),
+ ).toEqual('Merge Request: !2');
+
+ expect(
+ vm.$el.querySelector('.js-job-mr a').getAttribute('href'),
+ ).toEqual(job.merge_request.path);
+ });
+
+ it('should render job duration', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-duration')),
+ ).toEqual('Duration: 6 seconds');
+ });
+
+ it('should render erased date', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-erased')),
+ ).toEqual('Erased: 3 weeks ago');
+ });
+
+ it('should render finished date', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-finished')),
+ ).toEqual('Finished: 3 weeks ago');
+ });
+
+ it('should render queued date', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-queued')),
+ ).toEqual('Queued: 9 seconds');
+ });
+
+ it('should render runner ID', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-runner')),
+ ).toEqual('Runner: #1');
+ });
+
+ it('should render coverage', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
+ ).toEqual('Coverage: 20%');
+ });
+
+ it('should render tags', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-tags')),
+ ).toEqual('Tags: tag');
+ });
+ });
+});
diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js
index 37e038c16da..e47adc49224 100644
--- a/spec/javascripts/labels_issue_sidebar_spec.js
+++ b/spec/javascripts/labels_issue_sidebar_spec.js
@@ -2,15 +2,13 @@
/* global IssuableContext */
/* global LabelsSelect */
-require('~/lib/utils/type_utility');
-require('~/gl_dropdown');
-require('select2');
-require('vendor/jquery.nicescroll');
-require('~/api');
-require('~/create_label');
-require('~/issuable_context');
-require('~/users_select');
-require('~/labels_select');
+import '~/gl_dropdown';
+import 'select2';
+import '~/api';
+import '~/create_label';
+import '~/issuable_context';
+import '~/users_select';
+import '~/labels_select';
(() => {
let saveLabelCount = 0;
diff --git a/spec/javascripts/landing_spec.js b/spec/javascripts/landing_spec.js
new file mode 100644
index 00000000000..7916073190a
--- /dev/null
+++ b/spec/javascripts/landing_spec.js
@@ -0,0 +1,160 @@
+import Landing from '~/landing';
+import Cookies from 'js-cookie';
+
+describe('Landing', function () {
+ describe('class constructor', function () {
+ beforeEach(function () {
+ this.landingElement = {};
+ this.dismissButton = {};
+ this.cookieName = 'cookie_name';
+
+ this.landing = new Landing(this.landingElement, this.dismissButton, this.cookieName);
+ });
+
+ it('should set .landing', function () {
+ expect(this.landing.landingElement).toBe(this.landingElement);
+ });
+
+ it('should set .cookieName', function () {
+ expect(this.landing.cookieName).toBe(this.cookieName);
+ });
+
+ it('should set .dismissButton', function () {
+ expect(this.landing.dismissButton).toBe(this.dismissButton);
+ });
+
+ it('should set .eventWrapper', function () {
+ expect(this.landing.eventWrapper).toEqual({});
+ });
+ });
+
+ describe('toggle', function () {
+ beforeEach(function () {
+ this.isDismissed = false;
+ this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) };
+ this.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: this.landingElement,
+ };
+
+ spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed);
+ spyOn(this.landing, 'addEvents');
+
+ Landing.prototype.toggle.call(this.landing);
+ });
+
+ it('should call .isDismissed', function () {
+ expect(this.landing.isDismissed).toHaveBeenCalled();
+ });
+
+ it('should call .classList.toggle', function () {
+ expect(this.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', this.isDismissed);
+ });
+
+ it('should call .addEvents', function () {
+ expect(this.landing.addEvents).toHaveBeenCalled();
+ });
+
+ describe('if isDismissed is true', function () {
+ beforeEach(function () {
+ this.isDismissed = true;
+ this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) };
+ this.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: this.landingElement,
+ };
+
+ spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed);
+ spyOn(this.landing, 'addEvents');
+
+ this.landing.isDismissed.calls.reset();
+
+ Landing.prototype.toggle.call(this.landing);
+ });
+
+ it('should not call .addEvents', function () {
+ expect(this.landing.addEvents).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addEvents', function () {
+ beforeEach(function () {
+ this.dismissButton = jasmine.createSpyObj('dismissButton', ['addEventListener']);
+ this.eventWrapper = {};
+ this.landing = {
+ eventWrapper: this.eventWrapper,
+ dismissButton: this.dismissButton,
+ dismissLanding: () => {},
+ };
+
+ Landing.prototype.addEvents.call(this.landing);
+ });
+
+ it('should set .eventWrapper.dismissLanding', function () {
+ expect(this.eventWrapper.dismissLanding).toEqual(jasmine.any(Function));
+ });
+
+ it('should call .addEventListener', function () {
+ expect(this.dismissButton.addEventListener).toHaveBeenCalledWith('click', this.eventWrapper.dismissLanding);
+ });
+ });
+
+ describe('removeEvents', function () {
+ beforeEach(function () {
+ this.dismissButton = jasmine.createSpyObj('dismissButton', ['removeEventListener']);
+ this.eventWrapper = { dismissLanding: () => {} };
+ this.landing = {
+ eventWrapper: this.eventWrapper,
+ dismissButton: this.dismissButton,
+ };
+
+ Landing.prototype.removeEvents.call(this.landing);
+ });
+
+ it('should call .removeEventListener', function () {
+ expect(this.dismissButton.removeEventListener).toHaveBeenCalledWith('click', this.eventWrapper.dismissLanding);
+ });
+ });
+
+ describe('dismissLanding', function () {
+ beforeEach(function () {
+ this.landingElement = { classList: jasmine.createSpyObj('classList', ['add']) };
+ this.cookieName = 'cookie_name';
+ this.landing = { landingElement: this.landingElement, cookieName: this.cookieName };
+
+ spyOn(Cookies, 'set');
+
+ Landing.prototype.dismissLanding.call(this.landing);
+ });
+
+ it('should call .classList.add', function () {
+ expect(this.landingElement.classList.add).toHaveBeenCalledWith('hidden');
+ });
+
+ it('should call Cookies.set', function () {
+ expect(Cookies.set).toHaveBeenCalledWith(this.cookieName, 'true', { expires: 365 });
+ });
+ });
+
+ describe('isDismissed', function () {
+ beforeEach(function () {
+ this.cookieName = 'cookie_name';
+ this.landing = { cookieName: this.cookieName };
+
+ spyOn(Cookies, 'get').and.returnValue('true');
+
+ this.isDismissed = Landing.prototype.isDismissed.call(this.landing);
+ });
+
+ it('should call Cookies.get', function () {
+ expect(Cookies.get).toHaveBeenCalledWith(this.cookieName);
+ });
+
+ it('should return a boolean', function () {
+ expect(typeof this.isDismissed).toEqual('boolean');
+ });
+ });
+});
diff --git a/spec/javascripts/lazy_loader_spec.js b/spec/javascripts/lazy_loader_spec.js
new file mode 100644
index 00000000000..1d81e4e2d1a
--- /dev/null
+++ b/spec/javascripts/lazy_loader_spec.js
@@ -0,0 +1,57 @@
+import LazyLoader from '~/lazy_loader';
+
+let lazyLoader = null;
+
+describe('LazyLoader', function () {
+ preloadFixtures('issues/issue_with_comment.html.raw');
+
+ beforeEach(function () {
+ loadFixtures('issues/issue_with_comment.html.raw');
+ lazyLoader = new LazyLoader({
+ observerNode: 'body',
+ });
+ // Doing everything that happens normally in onload
+ lazyLoader.loadCheck();
+ });
+ describe('behavior', function () {
+ it('should copy value from data-src to src for img 1', function (done) {
+ const img = document.querySelectorAll('img[data-src]')[0];
+ const originalDataSrc = img.getAttribute('data-src');
+ img.scrollIntoView();
+
+ setTimeout(() => {
+ expect(img.getAttribute('src')).toBe(originalDataSrc);
+ expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
+ done();
+ }, 100);
+ });
+
+ it('should lazy load dynamically added data-src images', function (done) {
+ const newImg = document.createElement('img');
+ const testPath = '/img/testimg.png';
+ newImg.className = 'lazy';
+ newImg.setAttribute('data-src', testPath);
+ document.body.appendChild(newImg);
+ newImg.scrollIntoView();
+
+ setTimeout(() => {
+ expect(newImg.getAttribute('src')).toBe(testPath);
+ expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
+ done();
+ }, 100);
+ });
+
+ it('should not alter normal images', function (done) {
+ const newImg = document.createElement('img');
+ const testPath = '/img/testimg.png';
+ newImg.setAttribute('src', testPath);
+ document.body.appendChild(newImg);
+ newImg.scrollIntoView();
+
+ setTimeout(() => {
+ expect(newImg).not.toHaveClass('js-lazy-loaded');
+ done();
+ }, 100);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/accessor_spec.js b/spec/javascripts/lib/utils/accessor_spec.js
new file mode 100644
index 00000000000..b768d6f2a68
--- /dev/null
+++ b/spec/javascripts/lib/utils/accessor_spec.js
@@ -0,0 +1,78 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+
+describe('AccessorUtilities', () => {
+ const testError = new Error('test error');
+
+ describe('isPropertyAccessSafe', () => {
+ let base;
+
+ it('should return `true` if access is safe', () => {
+ base = { testProp: 'testProp' };
+
+ expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true);
+ });
+
+ it('should return `false` if access throws an error', () => {
+ base = { get testProp() { throw testError; } };
+
+ expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false);
+ });
+
+ it('should return `false` if property is undefined', () => {
+ base = {};
+
+ expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false);
+ });
+ });
+
+ describe('isFunctionCallSafe', () => {
+ const base = {};
+
+ it('should return `true` if calling is safe', () => {
+ base.func = () => {};
+
+ expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true);
+ });
+
+ it('should return `false` if calling throws an error', () => {
+ base.func = () => { throw new Error('test error'); };
+
+ expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false);
+ });
+
+ it('should return `false` if function is undefined', () => {
+ base.func = undefined;
+
+ expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false);
+ });
+ });
+
+ describe('isLocalStorageAccessSafe', () => {
+ beforeEach(() => {
+ spyOn(window.localStorage, 'setItem');
+ spyOn(window.localStorage, 'removeItem');
+ });
+
+ it('should return `true` if access is safe', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true);
+ });
+
+ it('should return `false` if access to .setItem isnt safe', () => {
+ window.localStorage.setItem.and.callFake(() => { throw testError; });
+
+ expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false);
+ });
+
+ it('should set a test item if access is safe', () => {
+ AccessorUtilities.isLocalStorageAccessSafe();
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true');
+ });
+
+ it('should remove the test item if access is safe', () => {
+ AccessorUtilities.isLocalStorageAccessSafe();
+
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe');
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js
new file mode 100644
index 00000000000..49971bd91e2
--- /dev/null
+++ b/spec/javascripts/lib/utils/ajax_cache_spec.js
@@ -0,0 +1,198 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+
+describe('AjaxCache', () => {
+ const dummyEndpoint = '/AjaxCache/dummyEndpoint';
+ const dummyResponse = {
+ important: 'dummy data',
+ };
+
+ beforeEach(() => {
+ AjaxCache.internalStorage = { };
+ AjaxCache.pendingRequests = { };
+ });
+
+ describe('get', () => {
+ it('returns undefined if cache is empty', () => {
+ const data = AjaxCache.get(dummyEndpoint);
+
+ expect(data).toBe(undefined);
+ });
+
+ it('returns undefined if cache contains no matching data', () => {
+ AjaxCache.internalStorage['not matching'] = dummyResponse;
+
+ const data = AjaxCache.get(dummyEndpoint);
+
+ expect(data).toBe(undefined);
+ });
+
+ it('returns matching data', () => {
+ AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
+
+ const data = AjaxCache.get(dummyEndpoint);
+
+ expect(data).toBe(dummyResponse);
+ });
+ });
+
+ describe('hasData', () => {
+ it('returns false if cache is empty', () => {
+ expect(AjaxCache.hasData(dummyEndpoint)).toBe(false);
+ });
+
+ it('returns false if cache contains no matching data', () => {
+ AjaxCache.internalStorage['not matching'] = dummyResponse;
+
+ expect(AjaxCache.hasData(dummyEndpoint)).toBe(false);
+ });
+
+ it('returns true if data is available', () => {
+ AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
+
+ expect(AjaxCache.hasData(dummyEndpoint)).toBe(true);
+ });
+ });
+
+ describe('remove', () => {
+ it('does nothing if cache is empty', () => {
+ AjaxCache.remove(dummyEndpoint);
+
+ expect(AjaxCache.internalStorage).toEqual({ });
+ });
+
+ it('does nothing if cache contains no matching data', () => {
+ AjaxCache.internalStorage['not matching'] = dummyResponse;
+
+ AjaxCache.remove(dummyEndpoint);
+
+ expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse);
+ });
+
+ it('removes matching data', () => {
+ AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
+
+ AjaxCache.remove(dummyEndpoint);
+
+ expect(AjaxCache.internalStorage).toEqual({ });
+ });
+ });
+
+ describe('override', () => {
+ it('overrides existing cache', () => {
+ AjaxCache.internalStorage.endpoint = 'existing-endpoint';
+ AjaxCache.override('endpoint', 'new-endpoint');
+
+ expect(AjaxCache.internalStorage.endpoint).toEqual('new-endpoint');
+ });
+ });
+
+ describe('retrieve', () => {
+ let ajaxSpy;
+
+ beforeEach(() => {
+ spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url));
+ });
+
+ it('stores and returns data from Ajax call if cache is empty', (done) => {
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ const deferred = $.Deferred();
+ deferred.resolve(dummyResponse);
+ return deferred.promise();
+ };
+
+ AjaxCache.retrieve(dummyEndpoint)
+ .then((data) => {
+ expect(data).toBe(dummyResponse);
+ expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+ });
+
+ it('makes no Ajax call if request is pending', () => {
+ const responseDeferred = $.Deferred();
+
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ // neither reject nor resolve to keep request pending
+ return responseDeferred.promise();
+ };
+
+ const unexpectedResponse = data => fail(`Did not expect response: ${data}`);
+
+ AjaxCache.retrieve(dummyEndpoint)
+ .then(unexpectedResponse)
+ .catch(fail);
+
+ AjaxCache.retrieve(dummyEndpoint)
+ .then(unexpectedResponse)
+ .catch(fail);
+
+ expect($.ajax.calls.count()).toBe(1);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', (done) => {
+ const dummyStatusText = 'exploded';
+ const dummyErrorMessage = 'server exploded';
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ const deferred = $.Deferred();
+ deferred.reject(null, dummyStatusText, dummyErrorMessage);
+ return deferred.promise();
+ };
+
+ AjaxCache.retrieve(dummyEndpoint)
+ .then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`))
+ .catch((error) => {
+ expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`);
+ expect(error.textStatus).toBe(dummyStatusText);
+ done();
+ })
+ .catch(fail);
+ });
+
+ it('makes no Ajax call if matching data exists', (done) => {
+ AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
+ ajaxSpy = () => fail(new Error('expected no Ajax call!'));
+
+ AjaxCache.retrieve(dummyEndpoint)
+ .then((data) => {
+ expect(data).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+ });
+
+ it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => {
+ const oldDummyResponse = {
+ important: 'old dummy data',
+ };
+
+ AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse;
+
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ const deferred = $.Deferred();
+ deferred.resolve(dummyResponse);
+ return deferred.promise();
+ };
+
+ // Call without forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint)
+ .then((data) => {
+ expect(data).toBe(oldDummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+
+ // Call with forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint, true)
+ .then((data) => {
+ expect(data).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/cache_spec.js b/spec/javascripts/lib/utils/cache_spec.js
new file mode 100644
index 00000000000..2fe02a7592c
--- /dev/null
+++ b/spec/javascripts/lib/utils/cache_spec.js
@@ -0,0 +1,65 @@
+import Cache from '~/lib/utils/cache';
+
+describe('Cache', () => {
+ const dummyKey = 'just some key';
+ const dummyValue = 'more than a value';
+ let cache;
+
+ beforeEach(() => {
+ cache = new Cache();
+ });
+
+ describe('get', () => {
+ it('return cached data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ expect(cache.get(dummyKey)).toBe(dummyValue);
+ });
+
+ it('returns undefined for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.get(dummyKey)).toBe(undefined);
+ });
+ });
+
+ describe('hasData', () => {
+ it('return true for cached data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ expect(cache.hasData(dummyKey)).toBe(true);
+ });
+
+ it('returns false for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.hasData(dummyKey)).toBe(false);
+ });
+ });
+
+ describe('remove', () => {
+ it('removes data from cache', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ });
+
+ it('does nothing for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ });
+
+ it('does not remove wrong data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+ cache.internalStorage[dummyKey + dummyKey] = dummyValue + dummyValue;
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.internalStorage[dummyKey + dummyKey]).toBe(dummyValue + dummyValue);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index a00efa10119..55037bbbf73 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable promise/catch-or-return */
-require('~/lib/utils/common_utils');
+import '~/lib/utils/common_utils';
(() => {
describe('common_utils', () => {
@@ -41,6 +41,16 @@ require('~/lib/utils/common_utils');
const paramsArray = gl.utils.getUrlParamsArray();
expect(paramsArray[0][0] !== '?').toBe(true);
});
+
+ it('should decode params', () => {
+ history.pushState('', '', '?label_name%5B%5D=test');
+
+ expect(
+ gl.utils.getUrlParamsArray()[0],
+ ).toBe('label_name[]=test');
+
+ history.pushState('', '', '?');
+ });
});
describe('gl.utils.handleLocationHash', () => {
@@ -133,6 +143,7 @@ require('~/lib/utils/common_utils');
it('should return valid parameter', () => {
const value = gl.utils.getParameterByName('scope');
+ expect(gl.utils.getParameterByName('p')).toEqual('2');
expect(value).toBe('all');
});
@@ -140,6 +151,14 @@ require('~/lib/utils/common_utils');
const value = gl.utils.getParameterByName('fakeParameter');
expect(value).toBe(null);
});
+
+ it('should return valid paramentes if URL is provided', () => {
+ let value = gl.utils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar');
+ expect(value).toBe('bar');
+
+ value = gl.utils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu');
+ expect(value).toBe('canchu');
+ });
});
describe('gl.utils.normalizedHeaders', () => {
@@ -346,7 +365,7 @@ require('~/lib/utils/common_utils');
describe('gl.utils.setCiStatusFavicon', () => {
it('should set page favicon to CI status favicon based on provided status', () => {
- const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1/status.json`;
+ const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`;
const FAVICON_PATH = '//icon_status_success';
const spySetFavicon = spyOn(gl.utils, 'setFavicon').and.stub();
const spyResetFavicon = spyOn(gl.utils, 'resetFavicon').and.stub();
@@ -362,5 +381,16 @@ require('~/lib/utils/common_utils');
gl.utils.setCiStatusFavicon(BUILD_URL);
});
});
+
+ describe('gl.utils.ajaxPost', () => {
+ it('should perform `$.ajax` call and do `POST` request', () => {
+ const requestURL = '/some/random/api';
+ const data = { keyname: 'value' };
+ const ajaxSpy = spyOn($, 'ajax').and.callFake(() => {});
+
+ gl.utils.ajaxPost(requestURL, data);
+ expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST');
+ });
+ });
});
})();
diff --git a/spec/javascripts/lib/utils/dom_utils_spec.js b/spec/javascripts/lib/utils/dom_utils_spec.js
new file mode 100644
index 00000000000..867bf5912d1
--- /dev/null
+++ b/spec/javascripts/lib/utils/dom_utils_spec.js
@@ -0,0 +1,35 @@
+import { addClassIfElementExists } from '~/lib/utils/dom_utils';
+
+describe('DOM Utils', () => {
+ describe('addClassIfElementExists', () => {
+ const className = 'biology';
+ const fixture = `
+ <div class="parent">
+ <div class="child"></div>
+ </div>
+ `;
+
+ let parentElement;
+
+ beforeEach(() => {
+ setFixtures(fixture);
+ parentElement = document.querySelector('.parent');
+ });
+
+ it('adds class if element exists', () => {
+ const childElement = parentElement.querySelector('.child');
+ expect(childElement).not.toBe(null);
+
+ addClassIfElementExists(childElement, className);
+
+ expect(childElement.classList).toContain(className);
+ });
+
+ it('does not throw if element does not exist', () => {
+ const childElement = parentElement.querySelector('.other-child');
+ expect(childElement).toBe(null);
+
+ addClassIfElementExists(childElement, className);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js
index 90b12c9f115..83c92deccdc 100644
--- a/spec/javascripts/lib/utils/number_utility_spec.js
+++ b/spec/javascripts/lib/utils/number_utility_spec.js
@@ -1,4 +1,4 @@
-import { formatRelevantDigits, bytesToKiB } from '~/lib/utils/number_utils';
+import { formatRelevantDigits, bytesToKiB, bytesToMiB } from '~/lib/utils/number_utils';
describe('Number Utils', () => {
describe('formatRelevantDigits', () => {
@@ -45,4 +45,11 @@ describe('Number Utils', () => {
expect(bytesToKiB(1000)).toEqual(0.9765625);
});
});
+
+ describe('bytesToMiB', () => {
+ it('calculates MiB for the given bytes', () => {
+ expect(bytesToMiB(1048576)).toEqual(1);
+ expect(bytesToMiB(1000000)).toEqual(0.95367431640625);
+ });
+ });
});
diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js
index 918b6d32c43..2aa7011ca51 100644
--- a/spec/javascripts/lib/utils/poll_spec.js
+++ b/spec/javascripts/lib/utils/poll_spec.js
@@ -1,9 +1,5 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
import Poll from '~/lib/utils/poll';
-Vue.use(VueResource);
-
const waitForAllCallsToFinish = (service, waitForCount, successCallback) => {
const timer = () => {
setTimeout(() => {
@@ -12,116 +8,91 @@ const waitForAllCallsToFinish = (service, waitForCount, successCallback) => {
} else {
timer();
}
- }, 5);
+ }, 0);
};
timer();
};
-class ServiceMock {
- constructor(endpoint) {
- this.service = Vue.resource(endpoint);
- }
+function mockServiceCall(service, response, shouldFail = false) {
+ const action = shouldFail ? Promise.reject : Promise.resolve;
+ const responseObject = response;
- fetch() {
- return this.service.get();
- }
+ if (!responseObject.headers) responseObject.headers = {};
+
+ service.fetch.and.callFake(action.bind(Promise, responseObject));
}
describe('Poll', () => {
- let callbacks;
- let service;
-
- beforeEach(() => {
- callbacks = {
- success: () => {},
- error: () => {},
- };
-
- service = new ServiceMock('endpoint');
+ const service = jasmine.createSpyObj('service', ['fetch']);
+ const callbacks = jasmine.createSpyObj('callbacks', ['success', 'error', 'notification']);
- spyOn(callbacks, 'success');
- spyOn(callbacks, 'error');
- spyOn(service, 'fetch').and.callThrough();
- });
-
- it('calls the success callback when no header for interval is provided', (done) => {
- const successInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 200 }));
- };
-
- Vue.http.interceptors.push(successInterceptor);
-
- new Poll({
+ function setup() {
+ return new Poll({
resource: service,
method: 'fetch',
successCallback: callbacks.success,
errorCallback: callbacks.error,
+ notificationCallback: callbacks.notification,
}).makeRequest();
+ }
+
+ afterEach(() => {
+ callbacks.success.calls.reset();
+ callbacks.error.calls.reset();
+ callbacks.notification.calls.reset();
+ service.fetch.calls.reset();
+ });
+
+ it('calls the success callback when no header for interval is provided', (done) => {
+ mockServiceCall(service, { status: 200 });
+ setup();
waitForAllCallsToFinish(service, 1, () => {
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor);
-
done();
- }, 0);
+ });
});
- it('calls the error callback whe the http request returns an error', (done) => {
- const errorInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 500 }));
- };
-
- Vue.http.interceptors.push(errorInterceptor);
-
- new Poll({
- resource: service,
- method: 'fetch',
- successCallback: callbacks.success,
- errorCallback: callbacks.error,
- }).makeRequest();
+ it('calls the error callback when the http request returns an error', (done) => {
+ mockServiceCall(service, { status: 500 }, true);
+ setup();
waitForAllCallsToFinish(service, 1, () => {
expect(callbacks.success).not.toHaveBeenCalled();
expect(callbacks.error).toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
done();
});
});
- it('should call the success callback when the interval header is -1', (done) => {
- const intervalInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': -1 } }));
- };
+ it('skips the error callback when request is aborted', (done) => {
+ mockServiceCall(service, { status: 0 }, true);
+ setup();
- Vue.http.interceptors.push(intervalInterceptor);
+ waitForAllCallsToFinish(service, 1, () => {
+ expect(callbacks.success).not.toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+ expect(callbacks.notification).toHaveBeenCalled();
- new Poll({
- resource: service,
- method: 'fetch',
- successCallback: callbacks.success,
- errorCallback: callbacks.error,
- }).makeRequest();
+ done();
+ });
+ });
- setTimeout(() => {
+ it('should call the success callback when the interval header is -1', (done) => {
+ mockServiceCall(service, { status: 200, headers: { 'poll-interval': -1 } });
+ setup().then(() => {
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor);
-
done();
- }, 0);
+ }).catch(done.fail);
});
it('starts polling when http status is 200 and interval header is provided', (done) => {
- const pollInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } }));
- };
-
- Vue.http.interceptors.push(pollInterceptor);
+ mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
resource: service,
@@ -141,19 +112,13 @@ describe('Poll', () => {
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor);
-
done();
});
});
describe('stop', () => {
it('stops polling when method is called', (done) => {
- const pollInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } }));
- };
-
- Vue.http.interceptors.push(pollInterceptor);
+ mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
resource: service,
@@ -174,8 +139,6 @@ describe('Poll', () => {
expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
expect(Polling.stop).toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor);
-
done();
});
});
@@ -183,11 +146,7 @@ describe('Poll', () => {
describe('restart', () => {
it('should restart polling when its called', (done) => {
- const pollInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } }));
- };
-
- Vue.http.interceptors.push(pollInterceptor);
+ mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
resource: service,
@@ -215,8 +174,6 @@ describe('Poll', () => {
expect(Polling.stop).toHaveBeenCalled();
expect(Polling.restart).toHaveBeenCalled();
- Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor);
-
done();
});
});
diff --git a/spec/javascripts/lib/utils/sticky_spec.js b/spec/javascripts/lib/utils/sticky_spec.js
new file mode 100644
index 00000000000..c3ee3ef9825
--- /dev/null
+++ b/spec/javascripts/lib/utils/sticky_spec.js
@@ -0,0 +1,52 @@
+import { isSticky } from '~/lib/utils/sticky';
+
+describe('sticky', () => {
+ const el = {
+ offsetTop: 0,
+ classList: {},
+ };
+
+ beforeEach(() => {
+ el.offsetTop = 0;
+ el.classList.add = jasmine.createSpy('spy');
+ el.classList.remove = jasmine.createSpy('spy');
+ });
+
+ describe('classList.remove', () => {
+ it('does not call classList.remove when stuck', () => {
+ isSticky(el, 0, 0);
+
+ expect(
+ el.classList.remove,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('calls classList.remove when not stuck', () => {
+ el.offsetTop = 10;
+ isSticky(el, 0, 0);
+
+ expect(
+ el.classList.remove,
+ ).toHaveBeenCalledWith('is-stuck');
+ });
+ });
+
+ describe('classList.add', () => {
+ it('calls classList.add when stuck', () => {
+ isSticky(el, 0, 0);
+
+ expect(
+ el.classList.add,
+ ).toHaveBeenCalledWith('is-stuck');
+ });
+
+ it('does not call classList.add when not stuck', () => {
+ el.offsetTop = 10;
+ isSticky(el, 0, 0);
+
+ expect(
+ el.classList.add,
+ ).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js
index daef9b93fa5..ca1b1b7cc3c 100644
--- a/spec/javascripts/lib/utils/text_utility_spec.js
+++ b/spec/javascripts/lib/utils/text_utility_spec.js
@@ -1,4 +1,4 @@
-require('~/lib/utils/text_utility');
+import '~/lib/utils/text_utility';
describe('text_utility', () => {
describe('gl.text.getTextWidth', () => {
diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js
new file mode 100644
index 00000000000..ec6ea35952b
--- /dev/null
+++ b/spec/javascripts/lib/utils/users_cache_spec.js
@@ -0,0 +1,136 @@
+import Api from '~/api';
+import UsersCache from '~/lib/utils/users_cache';
+
+describe('UsersCache', () => {
+ const dummyUsername = 'win';
+ const dummyUser = 'has a farm';
+
+ beforeEach(() => {
+ UsersCache.internalStorage = { };
+ });
+
+ describe('get', () => {
+ it('returns undefined for empty cache', () => {
+ expect(UsersCache.internalStorage).toEqual({ });
+
+ const user = UsersCache.get(dummyUsername);
+
+ expect(user).toBe(undefined);
+ });
+
+ it('returns undefined for missing user', () => {
+ UsersCache.internalStorage['no body'] = 'no data';
+
+ const user = UsersCache.get(dummyUsername);
+
+ expect(user).toBe(undefined);
+ });
+
+ it('returns matching user', () => {
+ UsersCache.internalStorage[dummyUsername] = dummyUser;
+
+ const user = UsersCache.get(dummyUsername);
+
+ expect(user).toBe(dummyUser);
+ });
+ });
+
+ describe('hasData', () => {
+ it('returns false for empty cache', () => {
+ expect(UsersCache.internalStorage).toEqual({ });
+
+ expect(UsersCache.hasData(dummyUsername)).toBe(false);
+ });
+
+ it('returns false for missing user', () => {
+ UsersCache.internalStorage['no body'] = 'no data';
+
+ expect(UsersCache.hasData(dummyUsername)).toBe(false);
+ });
+
+ it('returns true for matching user', () => {
+ UsersCache.internalStorage[dummyUsername] = dummyUser;
+
+ expect(UsersCache.hasData(dummyUsername)).toBe(true);
+ });
+ });
+
+ describe('remove', () => {
+ it('does nothing if cache is empty', () => {
+ expect(UsersCache.internalStorage).toEqual({ });
+
+ UsersCache.remove(dummyUsername);
+
+ expect(UsersCache.internalStorage).toEqual({ });
+ });
+
+ it('does nothing if cache contains no matching data', () => {
+ UsersCache.internalStorage['no body'] = 'no data';
+
+ UsersCache.remove(dummyUsername);
+
+ expect(UsersCache.internalStorage['no body']).toBe('no data');
+ });
+
+ it('removes matching data', () => {
+ UsersCache.internalStorage[dummyUsername] = dummyUser;
+
+ UsersCache.remove(dummyUsername);
+
+ expect(UsersCache.internalStorage).toEqual({ });
+ });
+ });
+
+ describe('retrieve', () => {
+ let apiSpy;
+
+ beforeEach(() => {
+ spyOn(Api, 'users').and.callFake((query, options) => apiSpy(query, options));
+ });
+
+ it('stores and returns data from API call if cache is empty', (done) => {
+ apiSpy = (query, options) => {
+ expect(query).toBe('');
+ expect(options).toEqual({ username: dummyUsername });
+ return Promise.resolve([dummyUser]);
+ };
+
+ UsersCache.retrieve(dummyUsername)
+ .then((user) => {
+ expect(user).toBe(dummyUser);
+ expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', (done) => {
+ const dummyError = new Error('server exploded');
+ apiSpy = (query, options) => {
+ expect(query).toBe('');
+ expect(options).toEqual({ username: dummyUsername });
+ return Promise.reject(dummyError);
+ };
+
+ UsersCache.retrieve(dummyUsername)
+ .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`))
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('makes no Ajax call if matching data exists', (done) => {
+ UsersCache.internalStorage[dummyUsername] = dummyUser;
+ apiSpy = () => fail(new Error('expected no Ajax call!'));
+
+ UsersCache.retrieve(dummyUsername)
+ .then((user) => {
+ expect(user).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js
index a1fd2d38968..aee274641e8 100644
--- a/spec/javascripts/line_highlighter_spec.js
+++ b/spec/javascripts/line_highlighter_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable space-before-function-paren, no-var, no-param-reassign, quotes, prefer-template, no-else-return, new-cap, dot-notation, no-return-assign, comma-dangle, no-new, one-var, one-var-declaration-per-line, jasmine/no-spec-dupes, no-underscore-dangle, max-len */
/* global LineHighlighter */
-require('~/line_highlighter');
+import '~/line_highlighter';
(function() {
describe('LineHighlighter', function() {
@@ -58,7 +58,7 @@ require('~/line_highlighter');
return expect(func).not.toThrow();
});
});
- describe('#clickHandler', function() {
+ describe('clickHandler', function() {
it('handles clicking on a child icon element', function() {
var spy;
spy = spyOn(this["class"], 'setHash').and.callThrough();
@@ -176,7 +176,7 @@ require('~/line_highlighter');
});
});
});
- describe('#hashToRange', function() {
+ describe('hashToRange', function() {
beforeEach(function() {
return this.subject = this["class"].hashToRange;
});
@@ -190,7 +190,7 @@ require('~/line_highlighter');
return expect(this.subject('#foo')).toEqual([null, null]);
});
});
- describe('#highlightLine', function() {
+ describe('highlightLine', function() {
beforeEach(function() {
return this.subject = this["class"].highlightLine;
});
@@ -203,7 +203,7 @@ require('~/line_highlighter');
return expect($('#LC13')).toHaveClass(this.css);
});
});
- return describe('#setHash', function() {
+ return describe('setHash', function() {
beforeEach(function() {
return this.subject = this["class"].setHash;
});
diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js
new file mode 100644
index 00000000000..395dc560671
--- /dev/null
+++ b/spec/javascripts/merge_request_notes_spec.js
@@ -0,0 +1,99 @@
+/* global Notes */
+
+import 'vendor/autosize';
+import '~/gl_form';
+import '~/lib/utils/text_utility';
+import '~/render_gfm';
+import '~/render_math';
+import '~/notes';
+
+const upArrowKeyCode = 38;
+
+describe('Merge request notes', () => {
+ window.gon = window.gon || {};
+ window.gl = window.gl || {};
+ gl.utils = gl.utils || {};
+
+ const discussionTabFixture = 'merge_requests/diff_comment.html.raw';
+ const changesTabJsonFixture = 'merge_request_diffs/inline_changes_tab_with_comments.json';
+ preloadFixtures(discussionTabFixture, changesTabJsonFixture);
+
+ describe('Discussion tab with diff comments', () => {
+ beforeEach(() => {
+ loadFixtures(discussionTabFixture);
+ gl.utils.disableButtonIfEmptyField = _.noop;
+ window.project_uploads_path = 'http://test.host/uploads';
+ $('body').data('page', 'projects:merge_requests:show');
+ window.gon.current_user_id = $('.note:last').data('author-id');
+
+ return new Notes('', []);
+ });
+
+ describe('up arrow', () => {
+ it('edits last comment when triggered in main form', () => {
+ const upArrowEvent = $.Event('keydown');
+ upArrowEvent.which = upArrowKeyCode;
+
+ spyOnEvent('.note:last .js-note-edit', 'click');
+
+ $('.js-note-text').trigger(upArrowEvent);
+
+ expect('click').toHaveBeenTriggeredOn('.note:last .js-note-edit');
+ });
+
+ it('edits last comment in discussion when triggered in discussion form', (done) => {
+ const upArrowEvent = $.Event('keydown');
+ upArrowEvent.which = upArrowKeyCode;
+
+ spyOnEvent('.note-discussion .js-note-edit', 'click');
+
+ $('.js-discussion-reply-button').click();
+
+ setTimeout(() => {
+ expect(
+ $('.note-discussion .js-note-text'),
+ ).toExist();
+
+ $('.note-discussion .js-note-text').trigger(upArrowEvent);
+
+ expect('click').toHaveBeenTriggeredOn('.note-discussion .js-note-edit');
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('Changes tab with diff comments', () => {
+ beforeEach(() => {
+ const diffsResponse = getJSONFixture(changesTabJsonFixture);
+ const noteFormHtml = `<form class="js-new-note-form">
+ <textarea class="js-note-text"></textarea>
+ </form>`;
+ setFixtures(diffsResponse.html + noteFormHtml);
+ $('body').data('page', 'projects:merge_requests:show');
+ window.gon.current_user_id = $('.note:last').data('author-id');
+
+ return new Notes('', []);
+ });
+
+ describe('up arrow', () => {
+ it('edits last comment in discussion when triggered in discussion form', (done) => {
+ const upArrowEvent = $.Event('keydown');
+ upArrowEvent.which = upArrowKeyCode;
+
+ spyOnEvent('.note:last .js-note-edit', 'click');
+
+ $('.js-discussion-reply-button').trigger('click');
+
+ setTimeout(() => {
+ $('.js-note-text').trigger(upArrowEvent);
+
+ expect('click').toHaveBeenTriggeredOn('.note:last .js-note-edit');
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js
index fd97dced870..6ff42e2378d 100644
--- a/spec/javascripts/merge_request_spec.js
+++ b/spec/javascripts/merge_request_spec.js
@@ -1,11 +1,13 @@
/* eslint-disable space-before-function-paren, no-return-assign */
/* global MergeRequest */
-require('~/merge_request');
+import '~/merge_request';
+import CloseReopenReportToggle from '~/close_reopen_report_toggle';
+import IssuablesHelper from '~/helpers/issuables_helper';
(function() {
describe('MergeRequest', function() {
- return describe('task lists', function() {
+ describe('task lists', function() {
preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
beforeEach(function() {
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
@@ -13,7 +15,9 @@ require('~/merge_request');
});
it('modifies the Markdown field', function() {
spyOn(jQuery, 'ajax').and.stub();
- $('input[type=checkbox]').attr('checked', true).trigger('change');
+ const changeEvent = document.createEvent('HTMLEvents');
+ changeEvent.initEvent('change', true, true);
+ $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent);
return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
});
return it('submits an ajax request on tasklist:changed', function() {
@@ -25,5 +29,34 @@ require('~/merge_request');
return $('.js-task-list-field').trigger('tasklist:changed');
});
});
+
+ describe('class constructor', () => {
+ it('calls .initCloseReopenReport', () => {
+ spyOn(IssuablesHelper, 'initCloseReopenReport');
+
+ new MergeRequest(); // eslint-disable-line no-new
+
+ expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled();
+ });
+
+ it('calls .initDroplab', () => {
+ const container = jasmine.createSpyObj('container', ['querySelector']);
+ const dropdownTrigger = {};
+ const dropdownList = {};
+ const button = {};
+
+ spyOn(CloseReopenReportToggle.prototype, 'initDroplab');
+ spyOn(document, 'querySelector').and.returnValue(container);
+ container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button);
+
+ new MergeRequest(); // eslint-disable-line no-new
+
+ expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
+ expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
+ expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
+ });
+ });
});
}).call(window);
diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js
index e437333d522..dc40244c20e 100644
--- a/spec/javascripts/merge_request_tabs_spec.js
+++ b/spec/javascripts/merge_request_tabs_spec.js
@@ -1,24 +1,16 @@
/* eslint-disable no-var, comma-dangle, object-shorthand */
+/* global Notes */
-require('~/merge_request_tabs');
-require('~/commit/pipelines/pipelines_bundle.js');
-require('~/breakpoints');
-require('~/lib/utils/common_utils');
-require('~/diff');
-require('~/single_file_diff');
-require('~/files_comment_button');
-require('vendor/jquery.scrollTo');
+import '~/merge_request_tabs';
+import '~/commit/pipelines/pipelines_bundle';
+import '~/breakpoints';
+import '~/lib/utils/common_utils';
+import '~/diff';
+import '~/files_comment_button';
+import '~/notes';
+import 'vendor/jquery.scrollTo';
(function () {
- // TODO: remove this hack!
- // PhantomJS causes spyOn to panic because replaceState isn't "writable"
- var phantomjs;
- try {
- phantomjs = !Object.getOwnPropertyDescriptor(window.history, 'replaceState').writable;
- } catch (err) {
- phantomjs = false;
- }
-
describe('MergeRequestTabs', function () {
var stubLocation = {};
var setLocation = function (stubs) {
@@ -29,17 +21,23 @@ require('vendor/jquery.scrollTo');
};
$.extend(stubLocation, defaults, stubs || {});
};
- preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
+
+ const inlineChangesTabJsonFixture = 'merge_request_diffs/inline_changes_tab_with_comments.json';
+ const parallelChangesTabJsonFixture = 'merge_request_diffs/parallel_changes_tab_with_comments.json';
+ preloadFixtures(
+ 'merge_requests/merge_request_with_task_list.html.raw',
+ 'merge_requests/diff_comment.html.raw',
+ inlineChangesTabJsonFixture,
+ parallelChangesTabJsonFixture
+ );
beforeEach(function () {
this.class = new gl.MergeRequestTabs({ stubLocation: stubLocation });
setLocation();
- if (!phantomjs) {
- this.spies = {
- history: spyOn(window.history, 'replaceState').and.callFake(function () {})
- };
- }
+ this.spies = {
+ history: spyOn(window.history, 'replaceState').and.callFake(function () {})
+ };
});
afterEach(function () {
@@ -47,20 +45,16 @@ require('vendor/jquery.scrollTo');
this.class.destroyPipelinesView();
});
- describe('#activateTab', function () {
+ describe('activateTab', function () {
beforeEach(function () {
spyOn($, 'ajax').and.callFake(function () {});
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
this.subject = this.class.activateTab;
});
- it('shows the first tab when action is show', function () {
+ it('shows the notes tab when action is show', function () {
this.subject('show');
expect($('#notes')).toHaveClass('active');
});
- it('shows the notes tab when action is notes', function () {
- this.subject('notes');
- expect($('#notes')).toHaveClass('active');
- });
it('shows the commits tab when action is commits', function () {
this.subject('commits');
expect($('#commits')).toHaveClass('active');
@@ -71,7 +65,7 @@ require('vendor/jquery.scrollTo');
});
});
- describe('#opensInNewTab', function () {
+ describe('opensInNewTab', function () {
var tabUrl;
var windowTarget = '_blank';
@@ -152,7 +146,7 @@ require('vendor/jquery.scrollTo');
});
});
- describe('#setCurrentAction', function () {
+ describe('setCurrentAction', function () {
beforeEach(function () {
spyOn($, 'ajax').and.callFake(function () {});
this.subject = this.class.setCurrentAction;
@@ -162,7 +156,7 @@ require('vendor/jquery.scrollTo');
setLocation({
pathname: '/foo/bar/merge_requests/1/commits'
});
- expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1');
+ expect(this.subject('show')).toBe('/foo/bar/merge_requests/1');
expect(this.subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs');
});
@@ -171,7 +165,7 @@ require('vendor/jquery.scrollTo');
pathname: '/foo/bar/merge_requests/1/diffs'
});
- expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1');
+ expect(this.subject('show')).toBe('/foo/bar/merge_requests/1');
expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits');
});
@@ -179,7 +173,7 @@ require('vendor/jquery.scrollTo');
setLocation({
pathname: '/foo/bar/merge_requests/1/diffs.html'
});
- expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1');
+ expect(this.subject('show')).toBe('/foo/bar/merge_requests/1');
expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits');
});
@@ -206,11 +200,9 @@ require('vendor/jquery.scrollTo');
pathname: '/foo/bar/merge_requests/1'
});
newState = this.subject('commits');
- if (!phantomjs) {
- expect(this.spies.history).toHaveBeenCalledWith({
- url: newState
- }, document.title, newState);
- }
+ expect(this.spies.history).toHaveBeenCalledWith({
+ url: newState
+ }, document.title, newState);
});
it('treats "show" like "notes"', function () {
@@ -221,7 +213,7 @@ require('vendor/jquery.scrollTo');
});
});
- describe('#tabShown', () => {
+ describe('tabShown', () => {
beforeEach(function () {
spyOn($, 'ajax').and.callFake(function (options) {
options.success({ html: '' });
@@ -281,13 +273,136 @@ require('vendor/jquery.scrollTo');
});
});
- describe('#loadDiff', function () {
+ describe('loadDiff', function () {
+ beforeEach(() => {
+ loadFixtures('merge_requests/diff_comment.html.raw');
+ spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests');
+ window.gl.ImageFile = () => {};
+ window.notes = new Notes('', []);
+ spyOn(window.notes, 'toggleDiffNote').and.callThrough();
+ });
+
+ afterEach(() => {
+ delete window.gl.ImageFile;
+ delete window.notes;
+ });
+
it('requires an absolute pathname', function () {
spyOn($, 'ajax').and.callFake(function (options) {
expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json');
});
+
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
});
+
+ describe('with inline diff', () => {
+ let noteId;
+ let noteLineNumId;
+
+ beforeEach(() => {
+ const diffsResponse = getJSONFixture(inlineChangesTabJsonFixture);
+
+ const $html = $(diffsResponse.html);
+ noteId = $html.find('.note').attr('id');
+ noteLineNumId = $html
+ .find('.note')
+ .closest('.notes_holder')
+ .prev('.line_holder')
+ .find('a[data-linenumber]')
+ .attr('href')
+ .replace('#', '');
+
+ spyOn($, 'ajax').and.callFake(function (options) {
+ options.success(diffsResponse);
+ });
+ });
+
+ describe('with note fragment hash', () => {
+ it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'old',
+ forceShow: true,
+ });
+ });
+
+ it('should gracefully ignore non-existant fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with line number fragment hash', () => {
+ it('should gracefully ignore line number fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteLineNumId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteLineNumId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('with parallel diff', () => {
+ let noteId;
+ let noteLineNumId;
+
+ beforeEach(() => {
+ const diffsResponse = getJSONFixture(parallelChangesTabJsonFixture);
+
+ const $html = $(diffsResponse.html);
+ noteId = $html.find('.note').attr('id');
+ noteLineNumId = $html
+ .find('.note')
+ .closest('.notes_holder')
+ .prev('.line_holder')
+ .find('a[data-linenumber]')
+ .attr('href')
+ .replace('#', '');
+
+ spyOn($, 'ajax').and.callFake(function (options) {
+ options.success(diffsResponse);
+ });
+ });
+
+ describe('with note fragment hash', () => {
+ it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId);
+
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'new',
+ forceShow: true,
+ });
+ });
+
+ it('should gracefully ignore non-existant fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with line number fragment hash', () => {
+ it('should gracefully ignore line number fragment hash', function () {
+ spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteLineNumId);
+ this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
+
+ expect(noteLineNumId.length).toBeGreaterThan(0);
+ expect(window.notes.toggleDiffNote).not.toHaveBeenCalled();
+ });
+ });
+ });
});
});
}).call(window);
diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js
deleted file mode 100644
index 88dae8c3e06..00000000000
--- a/spec/javascripts/merge_request_widget_spec.js
+++ /dev/null
@@ -1,199 +0,0 @@
-/* eslint-disable space-before-function-paren, quotes, comma-dangle, dot-notation, quote-props, no-var, max-len */
-
-require('~/merge_request_widget');
-require('~/smart_interval');
-require('~/lib/utils/datetime_utility');
-
-(function() {
- describe('MergeRequestWidget', function() {
- beforeEach(function() {
- window.notifyPermissions = function() {};
- window.notify = function() {};
- this.opts = {
- ci_status_url: "http://sampledomain.local/ci/getstatus",
- ci_environments_status_url: "http://sampledomain.local/ci/getenvironmentsstatus",
- ci_status: "",
- ci_message: {
- normal: "Build {{status}} for \"{{title}}\"",
- preparing: "{{status}} build for \"{{title}}\""
- },
- ci_title: {
- preparing: "{{status}} build",
- normal: "Build {{status}}"
- },
- gitlab_icon: "gitlab_logo.png",
- ci_pipeline: 80,
- ci_sha: "12a34bc5",
- builds_path: "http://sampledomain.local/sampleBuildsPath",
- commits_path: "http://sampledomain.local/commits",
- pipeline_path: "http://sampledomain.local/pipelines"
- };
- this["class"] = new window.gl.MergeRequestWidget(this.opts);
- });
-
- describe('getCIEnvironmentsStatus', function() {
- beforeEach(function() {
- this.ciEnvironmentsStatusData = [{
- created_at: '2016-09-12T13:38:30.636Z',
- environment_id: 1,
- environment_name: 'env1',
- external_url: 'https://test-url.com',
- external_url_formatted: 'test-url.com'
- }];
-
- spyOn(jQuery, 'getJSON').and.callFake(function(req, cb) {
- cb(this.ciEnvironmentsStatusData);
- }.bind(this));
- });
-
- it('should call renderEnvironments when the environments property is set', function() {
- const spy = spyOn(this.class, 'renderEnvironments').and.stub();
- this.class.getCIEnvironmentsStatus();
- expect(spy).toHaveBeenCalledWith(this.ciEnvironmentsStatusData);
- });
-
- it('should not call renderEnvironments when the environments property is not set', function() {
- this.ciEnvironmentsStatusData = null;
- const spy = spyOn(this.class, 'renderEnvironments').and.stub();
- this.class.getCIEnvironmentsStatus();
- expect(spy).not.toHaveBeenCalled();
- });
- });
-
- describe('renderEnvironments', function() {
- describe('should render correct timeago', function() {
- beforeEach(function() {
- this.environments = [{
- id: 'test-environment-id',
- url: 'testurl',
- deployed_at: new Date().toISOString(),
- deployed_at_formatted: true
- }];
- });
-
- function getTimeagoText(template) {
- var el = document.createElement('html');
- el.innerHTML = template;
- return el.querySelector('.js-environment-timeago').innerText.trim();
- }
-
- it('should render less than a minute ago text', function() {
- spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
- expect(getTimeagoText(template)).toBe('less than a minute ago.');
- });
-
- this.class.renderEnvironments(this.environments);
- });
-
- it('should render about an hour ago text', function() {
- var oneHourAgo = new Date();
- oneHourAgo.setHours(oneHourAgo.getHours() - 1);
-
- this.environments[0].deployed_at = oneHourAgo.toISOString();
- spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
- expect(getTimeagoText(template)).toBe('about an hour ago.');
- });
-
- this.class.renderEnvironments(this.environments);
- });
-
- it('should render about 2 hours ago text', function() {
- var twoHoursAgo = new Date();
- twoHoursAgo.setHours(twoHoursAgo.getHours() - 2);
-
- this.environments[0].deployed_at = twoHoursAgo.toISOString();
- spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
- expect(getTimeagoText(template)).toBe('about 2 hours ago.');
- });
-
- this.class.renderEnvironments(this.environments);
- });
- });
- });
-
- describe('mergeInProgress', function() {
- it('should display error with h4 tag', function() {
- spyOn(this.class.$widgetBody, 'html').and.callFake(function(html) {
- expect(html).toBe('<h4>Sorry, something went wrong.</h4>');
- });
- spyOn($, 'ajax').and.callFake(function(e) {
- e.success({ merge_error: 'Sorry, something went wrong.' });
- });
- this.class.mergeInProgress(null);
- });
- });
-
- describe('getCIStatus', function() {
- beforeEach(function() {
- this.ciStatusData = {
- "title": "Sample MR title",
- "pipeline": 80,
- "sha": "12a34bc5",
- "status": "success",
- "coverage": 98
- };
-
- spyOn(jQuery, 'getJSON').and.callFake((function(_this) {
- return function(req, cb) {
- return cb(_this.ciStatusData);
- };
- })(this));
- });
- it('should call showCIStatus even if a notification should not be displayed', function() {
- var spy;
- spy = spyOn(this["class"], 'showCIStatus').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(false);
- return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status);
- });
- it('should call showCIStatus when a notification should be displayed', function() {
- var spy;
- spy = spyOn(this["class"], 'showCIStatus').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(true);
- return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status);
- });
- it('should call showCICoverage when the coverage rate is set', function() {
- var spy;
- spy = spyOn(this["class"], 'showCICoverage').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(false);
- return expect(spy).toHaveBeenCalledWith(this.ciStatusData.coverage);
- });
- it('should not call showCICoverage when the coverage rate is not set', function() {
- var spy;
- this.ciStatusData.coverage = null;
- spy = spyOn(this["class"], 'showCICoverage').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(false);
- return expect(spy).not.toHaveBeenCalled();
- });
- it('should not display a notification on the first check after the widget has been created', function() {
- var spy;
- spy = spyOn(window, 'notify');
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"] = new window.gl.MergeRequestWidget(this.opts);
- this["class"].getCIStatus(true);
- return expect(spy).not.toHaveBeenCalled();
- });
- it('should update the pipeline URL when the pipeline changes', function() {
- var spy;
- spy = spyOn(this["class"], 'updatePipelineUrls').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(false);
- this.ciStatusData.pipeline += 1;
- this["class"].getCIStatus(false);
- return expect(spy).toHaveBeenCalled();
- });
- it('should update the commit URL when the sha changes', function() {
- var spy;
- spy = spyOn(this["class"], 'updateCommitUrls').and.stub();
- spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {});
- this["class"].getCIStatus(false);
- this.ciStatusData.sha = "9b50b99a";
- this["class"].getCIStatus(false);
- return expect(spy).toHaveBeenCalled();
- });
- });
- });
-}).call(window);
diff --git a/spec/javascripts/merged_buttons_spec.js b/spec/javascripts/merged_buttons_spec.js
deleted file mode 100644
index b5c5e60dd97..00000000000
--- a/spec/javascripts/merged_buttons_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/* global MergedButtons */
-
-import '~/merged_buttons';
-
-describe('MergedButtons', () => {
- const fixturesPath = 'merge_requests/merged_merge_request.html.raw';
- preloadFixtures(fixturesPath);
-
- beforeEach(() => {
- loadFixtures(fixturesPath);
- this.mergedButtons = new MergedButtons();
- this.$removeBranchWidget = $('.remove_source_branch_widget:not(.failed)');
- this.$removeBranchProgress = $('.remove_source_branch_in_progress');
- this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
- this.$removeBranchButton = $('.remove_source_branch');
- });
-
- describe('removeSourceBranch', () => {
- it('shows loader', () => {
- $('.remove_source_branch').trigger('click');
- expect(this.$removeBranchProgress).toBeVisible();
- expect(this.$removeBranchWidget).not.toBeVisible();
- });
- });
-
- describe('removeBranchSuccess', () => {
- it('refreshes page when branch removed', () => {
- spyOn(gl.utils, 'refreshCurrentPage').and.stub();
- const response = { status: 200 };
- this.$removeBranchButton.trigger('ajax:success', response, 'xhr');
- expect(gl.utils.refreshCurrentPage).toHaveBeenCalled();
- });
- });
-
- describe('removeBranchError', () => {
- it('shows error message', () => {
- const response = { status: 500 };
- this.$removeBranchButton.trigger('ajax:error', response, 'xhr');
- expect(this.$removeBranchFailed).toBeVisible();
- expect(this.$removeBranchProgress).not.toBeVisible();
- expect(this.$removeBranchWidget).not.toBeVisible();
- });
- });
-});
diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js
new file mode 100644
index 00000000000..b69f4eddffc
--- /dev/null
+++ b/spec/javascripts/monitoring/mock_data.js
@@ -0,0 +1,4230 @@
+/* eslint-disable quote-props, indent, comma-dangle */
+
+const metricsGroupsAPIResponse = {
+ 'success': true,
+ 'data': [
+ {
+ 'group': 'Kubernetes',
+ 'priority': 1,
+ 'metrics': [
+ {
+ 'title': 'Memory usage',
+ 'weight': 1,
+ 'queries': [
+ {
+ 'query_range': 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20',
+ 'y_label': 'Memory',
+ 'unit': 'MiB',
+ 'result': [
+ {
+ 'metric': {},
+ 'values': [
+ [
+ 1495700554.925,
+ '8.0390625'
+ ],
+ [
+ 1495700614.925,
+ '8.0390625'
+ ],
+ [
+ 1495700674.925,
+ '8.0390625'
+ ],
+ [
+ 1495700734.925,
+ '8.0390625'
+ ],
+ [
+ 1495700794.925,
+ '8.0390625'
+ ],
+ [
+ 1495700854.925,
+ '8.0390625'
+ ],
+ [
+ 1495700914.925,
+ '8.0390625'
+ ],
+ [
+ 1495700974.925,
+ '8.0390625'
+ ],
+ [
+ 1495701034.925,
+ '8.0390625'
+ ],
+ [
+ 1495701094.925,
+ '8.0390625'
+ ],
+ [
+ 1495701154.925,
+ '8.0390625'
+ ],
+ [
+ 1495701214.925,
+ '8.0390625'
+ ],
+ [
+ 1495701274.925,
+ '8.0390625'
+ ],
+ [
+ 1495701334.925,
+ '8.0390625'
+ ],
+ [
+ 1495701394.925,
+ '8.0390625'
+ ],
+ [
+ 1495701454.925,
+ '8.0390625'
+ ],
+ [
+ 1495701514.925,
+ '8.0390625'
+ ],
+ [
+ 1495701574.925,
+ '8.0390625'
+ ],
+ [
+ 1495701634.925,
+ '8.0390625'
+ ],
+ [
+ 1495701694.925,
+ '8.0390625'
+ ],
+ [
+ 1495701754.925,
+ '8.0390625'
+ ],
+ [
+ 1495701814.925,
+ '8.0390625'
+ ],
+ [
+ 1495701874.925,
+ '8.0390625'
+ ],
+ [
+ 1495701934.925,
+ '8.0390625'
+ ],
+ [
+ 1495701994.925,
+ '8.0390625'
+ ],
+ [
+ 1495702054.925,
+ '8.0390625'
+ ],
+ [
+ 1495702114.925,
+ '8.0390625'
+ ],
+ [
+ 1495702174.925,
+ '8.0390625'
+ ],
+ [
+ 1495702234.925,
+ '8.0390625'
+ ],
+ [
+ 1495702294.925,
+ '8.0390625'
+ ],
+ [
+ 1495702354.925,
+ '8.0390625'
+ ],
+ [
+ 1495702414.925,
+ '8.0390625'
+ ],
+ [
+ 1495702474.925,
+ '8.0390625'
+ ],
+ [
+ 1495702534.925,
+ '8.0390625'
+ ],
+ [
+ 1495702594.925,
+ '8.0390625'
+ ],
+ [
+ 1495702654.925,
+ '8.0390625'
+ ],
+ [
+ 1495702714.925,
+ '8.0390625'
+ ],
+ [
+ 1495702774.925,
+ '8.0390625'
+ ],
+ [
+ 1495702834.925,
+ '8.0390625'
+ ],
+ [
+ 1495702894.925,
+ '8.0390625'
+ ],
+ [
+ 1495702954.925,
+ '8.0390625'
+ ],
+ [
+ 1495703014.925,
+ '8.0390625'
+ ],
+ [
+ 1495703074.925,
+ '8.0390625'
+ ],
+ [
+ 1495703134.925,
+ '8.0390625'
+ ],
+ [
+ 1495703194.925,
+ '8.0390625'
+ ],
+ [
+ 1495703254.925,
+ '8.03515625'
+ ],
+ [
+ 1495703314.925,
+ '8.03515625'
+ ],
+ [
+ 1495703374.925,
+ '8.03515625'
+ ],
+ [
+ 1495703434.925,
+ '8.03515625'
+ ],
+ [
+ 1495703494.925,
+ '8.03515625'
+ ],
+ [
+ 1495703554.925,
+ '8.03515625'
+ ],
+ [
+ 1495703614.925,
+ '8.03515625'
+ ],
+ [
+ 1495703674.925,
+ '8.03515625'
+ ],
+ [
+ 1495703734.925,
+ '8.03515625'
+ ],
+ [
+ 1495703794.925,
+ '8.03515625'
+ ],
+ [
+ 1495703854.925,
+ '8.03515625'
+ ],
+ [
+ 1495703914.925,
+ '8.03515625'
+ ],
+ [
+ 1495703974.925,
+ '8.03515625'
+ ],
+ [
+ 1495704034.925,
+ '8.03515625'
+ ],
+ [
+ 1495704094.925,
+ '8.03515625'
+ ],
+ [
+ 1495704154.925,
+ '8.03515625'
+ ],
+ [
+ 1495704214.925,
+ '7.9296875'
+ ],
+ [
+ 1495704274.925,
+ '7.9296875'
+ ],
+ [
+ 1495704334.925,
+ '7.9296875'
+ ],
+ [
+ 1495704394.925,
+ '7.9296875'
+ ],
+ [
+ 1495704454.925,
+ '7.9296875'
+ ],
+ [
+ 1495704514.925,
+ '7.9296875'
+ ],
+ [
+ 1495704574.925,
+ '7.9296875'
+ ],
+ [
+ 1495704634.925,
+ '7.9296875'
+ ],
+ [
+ 1495704694.925,
+ '7.9296875'
+ ],
+ [
+ 1495704754.925,
+ '7.9296875'
+ ],
+ [
+ 1495704814.925,
+ '7.9296875'
+ ],
+ [
+ 1495704874.925,
+ '7.9296875'
+ ],
+ [
+ 1495704934.925,
+ '7.9296875'
+ ],
+ [
+ 1495704994.925,
+ '7.9296875'
+ ],
+ [
+ 1495705054.925,
+ '7.9296875'
+ ],
+ [
+ 1495705114.925,
+ '7.9296875'
+ ],
+ [
+ 1495705174.925,
+ '7.9296875'
+ ],
+ [
+ 1495705234.925,
+ '7.9296875'
+ ],
+ [
+ 1495705294.925,
+ '7.9296875'
+ ],
+ [
+ 1495705354.925,
+ '7.9296875'
+ ],
+ [
+ 1495705414.925,
+ '7.9296875'
+ ],
+ [
+ 1495705474.925,
+ '7.9296875'
+ ],
+ [
+ 1495705534.925,
+ '7.9296875'
+ ],
+ [
+ 1495705594.925,
+ '7.9296875'
+ ],
+ [
+ 1495705654.925,
+ '7.9296875'
+ ],
+ [
+ 1495705714.925,
+ '7.9296875'
+ ],
+ [
+ 1495705774.925,
+ '7.9296875'
+ ],
+ [
+ 1495705834.925,
+ '7.9296875'
+ ],
+ [
+ 1495705894.925,
+ '7.9296875'
+ ],
+ [
+ 1495705954.925,
+ '7.9296875'
+ ],
+ [
+ 1495706014.925,
+ '7.9296875'
+ ],
+ [
+ 1495706074.925,
+ '7.9296875'
+ ],
+ [
+ 1495706134.925,
+ '7.9296875'
+ ],
+ [
+ 1495706194.925,
+ '7.9296875'
+ ],
+ [
+ 1495706254.925,
+ '7.9296875'
+ ],
+ [
+ 1495706314.925,
+ '7.9296875'
+ ],
+ [
+ 1495706374.925,
+ '7.9296875'
+ ],
+ [
+ 1495706434.925,
+ '7.9296875'
+ ],
+ [
+ 1495706494.925,
+ '7.9296875'
+ ],
+ [
+ 1495706554.925,
+ '7.9296875'
+ ],
+ [
+ 1495706614.925,
+ '7.9296875'
+ ],
+ [
+ 1495706674.925,
+ '7.9296875'
+ ],
+ [
+ 1495706734.925,
+ '7.9296875'
+ ],
+ [
+ 1495706794.925,
+ '7.9296875'
+ ],
+ [
+ 1495706854.925,
+ '7.9296875'
+ ],
+ [
+ 1495706914.925,
+ '7.9296875'
+ ],
+ [
+ 1495706974.925,
+ '7.9296875'
+ ],
+ [
+ 1495707034.925,
+ '7.9296875'
+ ],
+ [
+ 1495707094.925,
+ '7.9296875'
+ ],
+ [
+ 1495707154.925,
+ '7.9296875'
+ ],
+ [
+ 1495707214.925,
+ '7.9296875'
+ ],
+ [
+ 1495707274.925,
+ '7.9296875'
+ ],
+ [
+ 1495707334.925,
+ '7.9296875'
+ ],
+ [
+ 1495707394.925,
+ '7.9296875'
+ ],
+ [
+ 1495707454.925,
+ '7.9296875'
+ ],
+ [
+ 1495707514.925,
+ '7.9296875'
+ ],
+ [
+ 1495707574.925,
+ '7.9296875'
+ ],
+ [
+ 1495707634.925,
+ '7.9296875'
+ ],
+ [
+ 1495707694.925,
+ '7.9296875'
+ ],
+ [
+ 1495707754.925,
+ '7.9296875'
+ ],
+ [
+ 1495707814.925,
+ '7.9296875'
+ ],
+ [
+ 1495707874.925,
+ '7.9296875'
+ ],
+ [
+ 1495707934.925,
+ '7.9296875'
+ ],
+ [
+ 1495707994.925,
+ '7.9296875'
+ ],
+ [
+ 1495708054.925,
+ '7.9296875'
+ ],
+ [
+ 1495708114.925,
+ '7.9296875'
+ ],
+ [
+ 1495708174.925,
+ '7.9296875'
+ ],
+ [
+ 1495708234.925,
+ '7.9296875'
+ ],
+ [
+ 1495708294.925,
+ '7.9296875'
+ ],
+ [
+ 1495708354.925,
+ '7.9296875'
+ ],
+ [
+ 1495708414.925,
+ '7.9296875'
+ ],
+ [
+ 1495708474.925,
+ '7.9296875'
+ ],
+ [
+ 1495708534.925,
+ '7.9296875'
+ ],
+ [
+ 1495708594.925,
+ '7.9296875'
+ ],
+ [
+ 1495708654.925,
+ '7.9296875'
+ ],
+ [
+ 1495708714.925,
+ '7.9296875'
+ ],
+ [
+ 1495708774.925,
+ '7.9296875'
+ ],
+ [
+ 1495708834.925,
+ '7.9296875'
+ ],
+ [
+ 1495708894.925,
+ '7.9296875'
+ ],
+ [
+ 1495708954.925,
+ '7.8984375'
+ ],
+ [
+ 1495709014.925,
+ '7.8984375'
+ ],
+ [
+ 1495709074.925,
+ '7.8984375'
+ ],
+ [
+ 1495709134.925,
+ '7.8984375'
+ ],
+ [
+ 1495709194.925,
+ '7.8984375'
+ ],
+ [
+ 1495709254.925,
+ '7.89453125'
+ ],
+ [
+ 1495709314.925,
+ '7.89453125'
+ ],
+ [
+ 1495709374.925,
+ '7.89453125'
+ ],
+ [
+ 1495709434.925,
+ '7.89453125'
+ ],
+ [
+ 1495709494.925,
+ '7.89453125'
+ ],
+ [
+ 1495709554.925,
+ '7.89453125'
+ ],
+ [
+ 1495709614.925,
+ '7.89453125'
+ ],
+ [
+ 1495709674.925,
+ '7.89453125'
+ ],
+ [
+ 1495709734.925,
+ '7.89453125'
+ ],
+ [
+ 1495709794.925,
+ '7.89453125'
+ ],
+ [
+ 1495709854.925,
+ '7.89453125'
+ ],
+ [
+ 1495709914.925,
+ '7.89453125'
+ ],
+ [
+ 1495709974.925,
+ '7.89453125'
+ ],
+ [
+ 1495710034.925,
+ '7.89453125'
+ ],
+ [
+ 1495710094.925,
+ '7.89453125'
+ ],
+ [
+ 1495710154.925,
+ '7.89453125'
+ ],
+ [
+ 1495710214.925,
+ '7.89453125'
+ ],
+ [
+ 1495710274.925,
+ '7.89453125'
+ ],
+ [
+ 1495710334.925,
+ '7.89453125'
+ ],
+ [
+ 1495710394.925,
+ '7.89453125'
+ ],
+ [
+ 1495710454.925,
+ '7.89453125'
+ ],
+ [
+ 1495710514.925,
+ '7.89453125'
+ ],
+ [
+ 1495710574.925,
+ '7.89453125'
+ ],
+ [
+ 1495710634.925,
+ '7.89453125'
+ ],
+ [
+ 1495710694.925,
+ '7.89453125'
+ ],
+ [
+ 1495710754.925,
+ '7.89453125'
+ ],
+ [
+ 1495710814.925,
+ '7.89453125'
+ ],
+ [
+ 1495710874.925,
+ '7.89453125'
+ ],
+ [
+ 1495710934.925,
+ '7.89453125'
+ ],
+ [
+ 1495710994.925,
+ '7.89453125'
+ ],
+ [
+ 1495711054.925,
+ '7.89453125'
+ ],
+ [
+ 1495711114.925,
+ '7.89453125'
+ ],
+ [
+ 1495711174.925,
+ '7.8515625'
+ ],
+ [
+ 1495711234.925,
+ '7.8515625'
+ ],
+ [
+ 1495711294.925,
+ '7.8515625'
+ ],
+ [
+ 1495711354.925,
+ '7.8515625'
+ ],
+ [
+ 1495711414.925,
+ '7.8515625'
+ ],
+ [
+ 1495711474.925,
+ '7.8515625'
+ ],
+ [
+ 1495711534.925,
+ '7.8515625'
+ ],
+ [
+ 1495711594.925,
+ '7.8515625'
+ ],
+ [
+ 1495711654.925,
+ '7.8515625'
+ ],
+ [
+ 1495711714.925,
+ '7.8515625'
+ ],
+ [
+ 1495711774.925,
+ '7.8515625'
+ ],
+ [
+ 1495711834.925,
+ '7.8515625'
+ ],
+ [
+ 1495711894.925,
+ '7.8515625'
+ ],
+ [
+ 1495711954.925,
+ '7.8515625'
+ ],
+ [
+ 1495712014.925,
+ '7.8515625'
+ ],
+ [
+ 1495712074.925,
+ '7.8515625'
+ ],
+ [
+ 1495712134.925,
+ '7.8515625'
+ ],
+ [
+ 1495712194.925,
+ '7.8515625'
+ ],
+ [
+ 1495712254.925,
+ '7.8515625'
+ ],
+ [
+ 1495712314.925,
+ '7.8515625'
+ ],
+ [
+ 1495712374.925,
+ '7.8515625'
+ ],
+ [
+ 1495712434.925,
+ '7.83203125'
+ ],
+ [
+ 1495712494.925,
+ '7.83203125'
+ ],
+ [
+ 1495712554.925,
+ '7.83203125'
+ ],
+ [
+ 1495712614.925,
+ '7.83203125'
+ ],
+ [
+ 1495712674.925,
+ '7.83203125'
+ ],
+ [
+ 1495712734.925,
+ '7.83203125'
+ ],
+ [
+ 1495712794.925,
+ '7.83203125'
+ ],
+ [
+ 1495712854.925,
+ '7.83203125'
+ ],
+ [
+ 1495712914.925,
+ '7.83203125'
+ ],
+ [
+ 1495712974.925,
+ '7.83203125'
+ ],
+ [
+ 1495713034.925,
+ '7.83203125'
+ ],
+ [
+ 1495713094.925,
+ '7.83203125'
+ ],
+ [
+ 1495713154.925,
+ '7.83203125'
+ ],
+ [
+ 1495713214.925,
+ '7.83203125'
+ ],
+ [
+ 1495713274.925,
+ '7.83203125'
+ ],
+ [
+ 1495713334.925,
+ '7.83203125'
+ ],
+ [
+ 1495713394.925,
+ '7.8125'
+ ],
+ [
+ 1495713454.925,
+ '7.8125'
+ ],
+ [
+ 1495713514.925,
+ '7.8125'
+ ],
+ [
+ 1495713574.925,
+ '7.8125'
+ ],
+ [
+ 1495713634.925,
+ '7.8125'
+ ],
+ [
+ 1495713694.925,
+ '7.8125'
+ ],
+ [
+ 1495713754.925,
+ '7.8125'
+ ],
+ [
+ 1495713814.925,
+ '7.8125'
+ ],
+ [
+ 1495713874.925,
+ '7.8125'
+ ],
+ [
+ 1495713934.925,
+ '7.8125'
+ ],
+ [
+ 1495713994.925,
+ '7.8125'
+ ],
+ [
+ 1495714054.925,
+ '7.8125'
+ ],
+ [
+ 1495714114.925,
+ '7.8125'
+ ],
+ [
+ 1495714174.925,
+ '7.8125'
+ ],
+ [
+ 1495714234.925,
+ '7.8125'
+ ],
+ [
+ 1495714294.925,
+ '7.8125'
+ ],
+ [
+ 1495714354.925,
+ '7.80859375'
+ ],
+ [
+ 1495714414.925,
+ '7.80859375'
+ ],
+ [
+ 1495714474.925,
+ '7.80859375'
+ ],
+ [
+ 1495714534.925,
+ '7.80859375'
+ ],
+ [
+ 1495714594.925,
+ '7.80859375'
+ ],
+ [
+ 1495714654.925,
+ '7.80859375'
+ ],
+ [
+ 1495714714.925,
+ '7.80859375'
+ ],
+ [
+ 1495714774.925,
+ '7.80859375'
+ ],
+ [
+ 1495714834.925,
+ '7.80859375'
+ ],
+ [
+ 1495714894.925,
+ '7.80859375'
+ ],
+ [
+ 1495714954.925,
+ '7.80859375'
+ ],
+ [
+ 1495715014.925,
+ '7.80859375'
+ ],
+ [
+ 1495715074.925,
+ '7.80859375'
+ ],
+ [
+ 1495715134.925,
+ '7.80859375'
+ ],
+ [
+ 1495715194.925,
+ '7.80859375'
+ ],
+ [
+ 1495715254.925,
+ '7.80859375'
+ ],
+ [
+ 1495715314.925,
+ '7.80859375'
+ ],
+ [
+ 1495715374.925,
+ '7.80859375'
+ ],
+ [
+ 1495715434.925,
+ '7.80859375'
+ ],
+ [
+ 1495715494.925,
+ '7.80859375'
+ ],
+ [
+ 1495715554.925,
+ '7.80859375'
+ ],
+ [
+ 1495715614.925,
+ '7.80859375'
+ ],
+ [
+ 1495715674.925,
+ '7.80859375'
+ ],
+ [
+ 1495715734.925,
+ '7.80859375'
+ ],
+ [
+ 1495715794.925,
+ '7.80859375'
+ ],
+ [
+ 1495715854.925,
+ '7.80859375'
+ ],
+ [
+ 1495715914.925,
+ '7.80078125'
+ ],
+ [
+ 1495715974.925,
+ '7.80078125'
+ ],
+ [
+ 1495716034.925,
+ '7.80078125'
+ ],
+ [
+ 1495716094.925,
+ '7.80078125'
+ ],
+ [
+ 1495716154.925,
+ '7.80078125'
+ ],
+ [
+ 1495716214.925,
+ '7.796875'
+ ],
+ [
+ 1495716274.925,
+ '7.796875'
+ ],
+ [
+ 1495716334.925,
+ '7.796875'
+ ],
+ [
+ 1495716394.925,
+ '7.796875'
+ ],
+ [
+ 1495716454.925,
+ '7.796875'
+ ],
+ [
+ 1495716514.925,
+ '7.796875'
+ ],
+ [
+ 1495716574.925,
+ '7.796875'
+ ],
+ [
+ 1495716634.925,
+ '7.796875'
+ ],
+ [
+ 1495716694.925,
+ '7.796875'
+ ],
+ [
+ 1495716754.925,
+ '7.796875'
+ ],
+ [
+ 1495716814.925,
+ '7.796875'
+ ],
+ [
+ 1495716874.925,
+ '7.79296875'
+ ],
+ [
+ 1495716934.925,
+ '7.79296875'
+ ],
+ [
+ 1495716994.925,
+ '7.79296875'
+ ],
+ [
+ 1495717054.925,
+ '7.79296875'
+ ],
+ [
+ 1495717114.925,
+ '7.79296875'
+ ],
+ [
+ 1495717174.925,
+ '7.7890625'
+ ],
+ [
+ 1495717234.925,
+ '7.7890625'
+ ],
+ [
+ 1495717294.925,
+ '7.7890625'
+ ],
+ [
+ 1495717354.925,
+ '7.7890625'
+ ],
+ [
+ 1495717414.925,
+ '7.7890625'
+ ],
+ [
+ 1495717474.925,
+ '7.7890625'
+ ],
+ [
+ 1495717534.925,
+ '7.7890625'
+ ],
+ [
+ 1495717594.925,
+ '7.7890625'
+ ],
+ [
+ 1495717654.925,
+ '7.7890625'
+ ],
+ [
+ 1495717714.925,
+ '7.7890625'
+ ],
+ [
+ 1495717774.925,
+ '7.7890625'
+ ],
+ [
+ 1495717834.925,
+ '7.77734375'
+ ],
+ [
+ 1495717894.925,
+ '7.77734375'
+ ],
+ [
+ 1495717954.925,
+ '7.77734375'
+ ],
+ [
+ 1495718014.925,
+ '7.77734375'
+ ],
+ [
+ 1495718074.925,
+ '7.77734375'
+ ],
+ [
+ 1495718134.925,
+ '7.7421875'
+ ],
+ [
+ 1495718194.925,
+ '7.7421875'
+ ],
+ [
+ 1495718254.925,
+ '7.7421875'
+ ],
+ [
+ 1495718314.925,
+ '7.7421875'
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'title': 'CPU usage',
+ 'weight': 1,
+ 'queries': [
+ {
+ 'query_range': 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100',
+ 'result': [
+ {
+ 'metric': {},
+ 'values': [
+ [
+ 1495700554.925,
+ '0.0010794445585559514'
+ ],
+ [
+ 1495700614.925,
+ '0.003927214935433527'
+ ],
+ [
+ 1495700674.925,
+ '0.0053045219047619975'
+ ],
+ [
+ 1495700734.925,
+ '0.0048892095238097155'
+ ],
+ [
+ 1495700794.925,
+ '0.005827140952381137'
+ ],
+ [
+ 1495700854.925,
+ '0.00569846906219937'
+ ],
+ [
+ 1495700914.925,
+ '0.004972616802849382'
+ ],
+ [
+ 1495700974.925,
+ '0.005117509523809902'
+ ],
+ [
+ 1495701034.925,
+ '0.00512389061919564'
+ ],
+ [
+ 1495701094.925,
+ '0.005199100501890691'
+ ],
+ [
+ 1495701154.925,
+ '0.005415746394885837'
+ ],
+ [
+ 1495701214.925,
+ '0.005607682788146286'
+ ],
+ [
+ 1495701274.925,
+ '0.005641300000000118'
+ ],
+ [
+ 1495701334.925,
+ '0.0071166279368766495'
+ ],
+ [
+ 1495701394.925,
+ '0.0063242138095234044'
+ ],
+ [
+ 1495701454.925,
+ '0.005793314698235304'
+ ],
+ [
+ 1495701514.925,
+ '0.00703934942237556'
+ ],
+ [
+ 1495701574.925,
+ '0.006357007076123191'
+ ],
+ [
+ 1495701634.925,
+ '0.003753167300126738'
+ ],
+ [
+ 1495701694.925,
+ '0.005018469678430698'
+ ],
+ [
+ 1495701754.925,
+ '0.0045217153371887'
+ ],
+ [
+ 1495701814.925,
+ '0.006140104285714119'
+ ],
+ [
+ 1495701874.925,
+ '0.004818684285714102'
+ ],
+ [
+ 1495701934.925,
+ '0.005079509718955242'
+ ],
+ [
+ 1495701994.925,
+ '0.005059981142498263'
+ ],
+ [
+ 1495702054.925,
+ '0.005269098389538773'
+ ],
+ [
+ 1495702114.925,
+ '0.005269954285714175'
+ ],
+ [
+ 1495702174.925,
+ '0.014199241435795856'
+ ],
+ [
+ 1495702234.925,
+ '0.01511936843111017'
+ ],
+ [
+ 1495702294.925,
+ '0.0060933692920682875'
+ ],
+ [
+ 1495702354.925,
+ '0.004945682380952493'
+ ],
+ [
+ 1495702414.925,
+ '0.005641266666666565'
+ ],
+ [
+ 1495702474.925,
+ '0.005223752857142996'
+ ],
+ [
+ 1495702534.925,
+ '0.005743098505699831'
+ ],
+ [
+ 1495702594.925,
+ '0.00538493380952391'
+ ],
+ [
+ 1495702654.925,
+ '0.005507793883751339'
+ ],
+ [
+ 1495702714.925,
+ '0.005666705714285466'
+ ],
+ [
+ 1495702774.925,
+ '0.006231530000000112'
+ ],
+ [
+ 1495702834.925,
+ '0.006570768635394899'
+ ],
+ [
+ 1495702894.925,
+ '0.005551146666666895'
+ ],
+ [
+ 1495702954.925,
+ '0.005602604737098058'
+ ],
+ [
+ 1495703014.925,
+ '0.00613993580402159'
+ ],
+ [
+ 1495703074.925,
+ '0.004770258764368832'
+ ],
+ [
+ 1495703134.925,
+ '0.005512376671364914'
+ ],
+ [
+ 1495703194.925,
+ '0.005254436666666674'
+ ],
+ [
+ 1495703254.925,
+ '0.0050109839141320505'
+ ],
+ [
+ 1495703314.925,
+ '0.0049478019256960016'
+ ],
+ [
+ 1495703374.925,
+ '0.0037666860965123463'
+ ],
+ [
+ 1495703434.925,
+ '0.004813526061656314'
+ ],
+ [
+ 1495703494.925,
+ '0.005047748095238278'
+ ],
+ [
+ 1495703554.925,
+ '0.00386494081008772'
+ ],
+ [
+ 1495703614.925,
+ '0.004304037408111405'
+ ],
+ [
+ 1495703674.925,
+ '0.004999466661587168'
+ ],
+ [
+ 1495703734.925,
+ '0.004689140476190834'
+ ],
+ [
+ 1495703794.925,
+ '0.004746126153582475'
+ ],
+ [
+ 1495703854.925,
+ '0.004482706382572302'
+ ],
+ [
+ 1495703914.925,
+ '0.004032808931864524'
+ ],
+ [
+ 1495703974.925,
+ '0.005728319047618988'
+ ],
+ [
+ 1495704034.925,
+ '0.004436139179627006'
+ ],
+ [
+ 1495704094.925,
+ '0.004553455714285617'
+ ],
+ [
+ 1495704154.925,
+ '0.003455244285714341'
+ ],
+ [
+ 1495704214.925,
+ '0.004742244761904621'
+ ],
+ [
+ 1495704274.925,
+ '0.005366978571428422'
+ ],
+ [
+ 1495704334.925,
+ '0.004257954837665058'
+ ],
+ [
+ 1495704394.925,
+ '0.005431603259831257'
+ ],
+ [
+ 1495704454.925,
+ '0.0052009214498621986'
+ ],
+ [
+ 1495704514.925,
+ '0.004317201904761618'
+ ],
+ [
+ 1495704574.925,
+ '0.004307384285714157'
+ ],
+ [
+ 1495704634.925,
+ '0.004789801146644822'
+ ],
+ [
+ 1495704694.925,
+ '0.0051429795906706485'
+ ],
+ [
+ 1495704754.925,
+ '0.005322495714285479'
+ ],
+ [
+ 1495704814.925,
+ '0.004512809333244233'
+ ],
+ [
+ 1495704874.925,
+ '0.004953843582568726'
+ ],
+ [
+ 1495704934.925,
+ '0.005812690120858119'
+ ],
+ [
+ 1495704994.925,
+ '0.004997024285714838'
+ ],
+ [
+ 1495705054.925,
+ '0.005246216154439592'
+ ],
+ [
+ 1495705114.925,
+ '0.0063494966618726795'
+ ],
+ [
+ 1495705174.925,
+ '0.005306004342898225'
+ ],
+ [
+ 1495705234.925,
+ '0.005081412857142978'
+ ],
+ [
+ 1495705294.925,
+ '0.00511409523809522'
+ ],
+ [
+ 1495705354.925,
+ '0.0047861001481192'
+ ],
+ [
+ 1495705414.925,
+ '0.005107688228042962'
+ ],
+ [
+ 1495705474.925,
+ '0.005271929582294012'
+ ],
+ [
+ 1495705534.925,
+ '0.004453254502681249'
+ ],
+ [
+ 1495705594.925,
+ '0.005799134293959226'
+ ],
+ [
+ 1495705654.925,
+ '0.005340865929502478'
+ ],
+ [
+ 1495705714.925,
+ '0.004911654761904942'
+ ],
+ [
+ 1495705774.925,
+ '0.005888234873953261'
+ ],
+ [
+ 1495705834.925,
+ '0.005565283333332954'
+ ],
+ [
+ 1495705894.925,
+ '0.005522869047618869'
+ ],
+ [
+ 1495705954.925,
+ '0.005177549737621646'
+ ],
+ [
+ 1495706014.925,
+ '0.0053145810232096465'
+ ],
+ [
+ 1495706074.925,
+ '0.004751095238095275'
+ ],
+ [
+ 1495706134.925,
+ '0.006242077142856976'
+ ],
+ [
+ 1495706194.925,
+ '0.00621034406957871'
+ ],
+ [
+ 1495706254.925,
+ '0.006887592738978596'
+ ],
+ [
+ 1495706314.925,
+ '0.006328128779726213'
+ ],
+ [
+ 1495706374.925,
+ '0.007488363809523927'
+ ],
+ [
+ 1495706434.925,
+ '0.006193758571428157'
+ ],
+ [
+ 1495706494.925,
+ '0.0068798371839706935'
+ ],
+ [
+ 1495706554.925,
+ '0.005757034340423128'
+ ],
+ [
+ 1495706614.925,
+ '0.004571388497294698'
+ ],
+ [
+ 1495706674.925,
+ '0.00620283044923395'
+ ],
+ [
+ 1495706734.925,
+ '0.005607562380952455'
+ ],
+ [
+ 1495706794.925,
+ '0.005506969933620308'
+ ],
+ [
+ 1495706854.925,
+ '0.005621118095238131'
+ ],
+ [
+ 1495706914.925,
+ '0.004876606098698849'
+ ],
+ [
+ 1495706974.925,
+ '0.0047871205988517206'
+ ],
+ [
+ 1495707034.925,
+ '0.00526405939458784'
+ ],
+ [
+ 1495707094.925,
+ '0.005716323800605852'
+ ],
+ [
+ 1495707154.925,
+ '0.005301459523809575'
+ ],
+ [
+ 1495707214.925,
+ '0.0051613042857144905'
+ ],
+ [
+ 1495707274.925,
+ '0.005384792857142714'
+ ],
+ [
+ 1495707334.925,
+ '0.005259719047619222'
+ ],
+ [
+ 1495707394.925,
+ '0.00584101142857182'
+ ],
+ [
+ 1495707454.925,
+ '0.0060066121920326326'
+ ],
+ [
+ 1495707514.925,
+ '0.006359978571428453'
+ ],
+ [
+ 1495707574.925,
+ '0.006315876322151109'
+ ],
+ [
+ 1495707634.925,
+ '0.005590012517198831'
+ ],
+ [
+ 1495707694.925,
+ '0.005517419877137072'
+ ],
+ [
+ 1495707754.925,
+ '0.006089813430348506'
+ ],
+ [
+ 1495707814.925,
+ '0.00466754476190479'
+ ],
+ [
+ 1495707874.925,
+ '0.006059954380517721'
+ ],
+ [
+ 1495707934.925,
+ '0.005085657142856972'
+ ],
+ [
+ 1495707994.925,
+ '0.005897665238095296'
+ ],
+ [
+ 1495708054.925,
+ '0.0062282023199555885'
+ ],
+ [
+ 1495708114.925,
+ '0.00526214553236979'
+ ],
+ [
+ 1495708174.925,
+ '0.0044803300000000644'
+ ],
+ [
+ 1495708234.925,
+ '0.005421443333333592'
+ ],
+ [
+ 1495708294.925,
+ '0.005694326244512144'
+ ],
+ [
+ 1495708354.925,
+ '0.005527721904761457'
+ ],
+ [
+ 1495708414.925,
+ '0.005988819523809819'
+ ],
+ [
+ 1495708474.925,
+ '0.005484704285714448'
+ ],
+ [
+ 1495708534.925,
+ '0.005041123649230085'
+ ],
+ [
+ 1495708594.925,
+ '0.005717767639612059'
+ ],
+ [
+ 1495708654.925,
+ '0.005412954417342863'
+ ],
+ [
+ 1495708714.925,
+ '0.005833343333333254'
+ ],
+ [
+ 1495708774.925,
+ '0.005448135238094969'
+ ],
+ [
+ 1495708834.925,
+ '0.005117341428571432'
+ ],
+ [
+ 1495708894.925,
+ '0.005888345825277833'
+ ],
+ [
+ 1495708954.925,
+ '0.005398543809524135'
+ ],
+ [
+ 1495709014.925,
+ '0.005325611428571416'
+ ],
+ [
+ 1495709074.925,
+ '0.005848668571428527'
+ ],
+ [
+ 1495709134.925,
+ '0.005135003105145044'
+ ],
+ [
+ 1495709194.925,
+ '0.0054551400000003'
+ ],
+ [
+ 1495709254.925,
+ '0.005319472937322171'
+ ],
+ [
+ 1495709314.925,
+ '0.00585677857142792'
+ ],
+ [
+ 1495709374.925,
+ '0.0062146261904759215'
+ ],
+ [
+ 1495709434.925,
+ '0.0067105060904182265'
+ ],
+ [
+ 1495709494.925,
+ '0.005829691904762108'
+ ],
+ [
+ 1495709554.925,
+ '0.005719280952381261'
+ ],
+ [
+ 1495709614.925,
+ '0.005682603793416407'
+ ],
+ [
+ 1495709674.925,
+ '0.0055272846277326934'
+ ],
+ [
+ 1495709734.925,
+ '0.0057123680952386735'
+ ],
+ [
+ 1495709794.925,
+ '0.00520597958075818'
+ ],
+ [
+ 1495709854.925,
+ '0.005584358957263837'
+ ],
+ [
+ 1495709914.925,
+ '0.005601104275197466'
+ ],
+ [
+ 1495709974.925,
+ '0.005991657142857066'
+ ],
+ [
+ 1495710034.925,
+ '0.00553722238095218'
+ ],
+ [
+ 1495710094.925,
+ '0.005127883122696293'
+ ],
+ [
+ 1495710154.925,
+ '0.005498111927534584'
+ ],
+ [
+ 1495710214.925,
+ '0.005609934069084202'
+ ],
+ [
+ 1495710274.925,
+ '0.00459206285714307'
+ ],
+ [
+ 1495710334.925,
+ '0.0047910828571428084'
+ ],
+ [
+ 1495710394.925,
+ '0.0056014671288845685'
+ ],
+ [
+ 1495710454.925,
+ '0.005686936791078528'
+ ],
+ [
+ 1495710514.925,
+ '0.00444480476190448'
+ ],
+ [
+ 1495710574.925,
+ '0.005780394696738921'
+ ],
+ [
+ 1495710634.925,
+ '0.0053107227550210365'
+ ],
+ [
+ 1495710694.925,
+ '0.005096031495761817'
+ ],
+ [
+ 1495710754.925,
+ '0.005451377979091524'
+ ],
+ [
+ 1495710814.925,
+ '0.005328136666667083'
+ ],
+ [
+ 1495710874.925,
+ '0.006020612857143043'
+ ],
+ [
+ 1495710934.925,
+ '0.0061063585714285365'
+ ],
+ [
+ 1495710994.925,
+ '0.006018346015752312'
+ ],
+ [
+ 1495711054.925,
+ '0.005069130952381193'
+ ],
+ [
+ 1495711114.925,
+ '0.005458406190476052'
+ ],
+ [
+ 1495711174.925,
+ '0.00577219190476179'
+ ],
+ [
+ 1495711234.925,
+ '0.005760814645658314'
+ ],
+ [
+ 1495711294.925,
+ '0.005371875716579101'
+ ],
+ [
+ 1495711354.925,
+ '0.0064232666666665834'
+ ],
+ [
+ 1495711414.925,
+ '0.009369806836906667'
+ ],
+ [
+ 1495711474.925,
+ '0.008956864761904692'
+ ],
+ [
+ 1495711534.925,
+ '0.005266849368559271'
+ ],
+ [
+ 1495711594.925,
+ '0.005335111364934262'
+ ],
+ [
+ 1495711654.925,
+ '0.006461778319586945'
+ ],
+ [
+ 1495711714.925,
+ '0.004687939890762393'
+ ],
+ [
+ 1495711774.925,
+ '0.004438831245760684'
+ ],
+ [
+ 1495711834.925,
+ '0.005142786666666613'
+ ],
+ [
+ 1495711894.925,
+ '0.007257734212054963'
+ ],
+ [
+ 1495711954.925,
+ '0.005621991904761494'
+ ],
+ [
+ 1495712014.925,
+ '0.007868689999999862'
+ ],
+ [
+ 1495712074.925,
+ '0.00910970215275738'
+ ],
+ [
+ 1495712134.925,
+ '0.006151004285714278'
+ ],
+ [
+ 1495712194.925,
+ '0.005447120924961522'
+ ],
+ [
+ 1495712254.925,
+ '0.005150705153929503'
+ ],
+ [
+ 1495712314.925,
+ '0.006358108714969314'
+ ],
+ [
+ 1495712374.925,
+ '0.0057725354795696475'
+ ],
+ [
+ 1495712434.925,
+ '0.005232139047619015'
+ ],
+ [
+ 1495712494.925,
+ '0.004932809617949037'
+ ],
+ [
+ 1495712554.925,
+ '0.004511607508499662'
+ ],
+ [
+ 1495712614.925,
+ '0.00440487701522666'
+ ],
+ [
+ 1495712674.925,
+ '0.005479113333333174'
+ ],
+ [
+ 1495712734.925,
+ '0.004726317619047547'
+ ],
+ [
+ 1495712794.925,
+ '0.005582041102958029'
+ ],
+ [
+ 1495712854.925,
+ '0.006381481216082099'
+ ],
+ [
+ 1495712914.925,
+ '0.005474260014095208'
+ ],
+ [
+ 1495712974.925,
+ '0.00567597142857188'
+ ],
+ [
+ 1495713034.925,
+ '0.0064741233333332985'
+ ],
+ [
+ 1495713094.925,
+ '0.005467475714285271'
+ ],
+ [
+ 1495713154.925,
+ '0.004868648393824457'
+ ],
+ [
+ 1495713214.925,
+ '0.005254923286444893'
+ ],
+ [
+ 1495713274.925,
+ '0.005599217150312865'
+ ],
+ [
+ 1495713334.925,
+ '0.005105413720618919'
+ ],
+ [
+ 1495713394.925,
+ '0.007246073333333279'
+ ],
+ [
+ 1495713454.925,
+ '0.005990312380952272'
+ ],
+ [
+ 1495713514.925,
+ '0.005594601853351101'
+ ],
+ [
+ 1495713574.925,
+ '0.004739258673727054'
+ ],
+ [
+ 1495713634.925,
+ '0.003932121428571783'
+ ],
+ [
+ 1495713694.925,
+ '0.005018188268459395'
+ ],
+ [
+ 1495713754.925,
+ '0.004538238095237985'
+ ],
+ [
+ 1495713814.925,
+ '0.00561816643265435'
+ ],
+ [
+ 1495713874.925,
+ '0.0063132584495033586'
+ ],
+ [
+ 1495713934.925,
+ '0.00442385238095213'
+ ],
+ [
+ 1495713994.925,
+ '0.004181795887658453'
+ ],
+ [
+ 1495714054.925,
+ '0.004437759047619037'
+ ],
+ [
+ 1495714114.925,
+ '0.006421748157178241'
+ ],
+ [
+ 1495714174.925,
+ '0.006525143809523842'
+ ],
+ [
+ 1495714234.925,
+ '0.004715904935144247'
+ ],
+ [
+ 1495714294.925,
+ '0.005966040152763461'
+ ],
+ [
+ 1495714354.925,
+ '0.005614535466921674'
+ ],
+ [
+ 1495714414.925,
+ '0.004934375119415906'
+ ],
+ [
+ 1495714474.925,
+ '0.0054122933333327385'
+ ],
+ [
+ 1495714534.925,
+ '0.004926540699612279'
+ ],
+ [
+ 1495714594.925,
+ '0.006124649517134237'
+ ],
+ [
+ 1495714654.925,
+ '0.004629427092013995'
+ ],
+ [
+ 1495714714.925,
+ '0.005117951257607005'
+ ],
+ [
+ 1495714774.925,
+ '0.004868774512685422'
+ ],
+ [
+ 1495714834.925,
+ '0.005310093333333399'
+ ],
+ [
+ 1495714894.925,
+ '0.0054907752286127345'
+ ],
+ [
+ 1495714954.925,
+ '0.004597678117351089'
+ ],
+ [
+ 1495715014.925,
+ '0.0059622552380952'
+ ],
+ [
+ 1495715074.925,
+ '0.005352457072655368'
+ ],
+ [
+ 1495715134.925,
+ '0.005491630952381143'
+ ],
+ [
+ 1495715194.925,
+ '0.006391770078379791'
+ ],
+ [
+ 1495715254.925,
+ '0.005933472857142518'
+ ],
+ [
+ 1495715314.925,
+ '0.005301314285714163'
+ ],
+ [
+ 1495715374.925,
+ '0.0058352959724814165'
+ ],
+ [
+ 1495715434.925,
+ '0.006154755147867044'
+ ],
+ [
+ 1495715494.925,
+ '0.009391935637482038'
+ ],
+ [
+ 1495715554.925,
+ '0.007846462857142592'
+ ],
+ [
+ 1495715614.925,
+ '0.00477608215316353'
+ ],
+ [
+ 1495715674.925,
+ '0.006132865238094998'
+ ],
+ [
+ 1495715734.925,
+ '0.006159762457649516'
+ ],
+ [
+ 1495715794.925,
+ '0.005957307073265968'
+ ],
+ [
+ 1495715854.925,
+ '0.006652319091792501'
+ ],
+ [
+ 1495715914.925,
+ '0.005493557402895287'
+ ],
+ [
+ 1495715974.925,
+ '0.0058652434829145166'
+ ],
+ [
+ 1495716034.925,
+ '0.005627400430468021'
+ ],
+ [
+ 1495716094.925,
+ '0.006240656190475609'
+ ],
+ [
+ 1495716154.925,
+ '0.006305997676168624'
+ ],
+ [
+ 1495716214.925,
+ '0.005388057732783248'
+ ],
+ [
+ 1495716274.925,
+ '0.0052814916048421244'
+ ],
+ [
+ 1495716334.925,
+ '0.00699498614272497'
+ ],
+ [
+ 1495716394.925,
+ '0.00627768693035141'
+ ],
+ [
+ 1495716454.925,
+ '0.0042411487048161145'
+ ],
+ [
+ 1495716514.925,
+ '0.005348647473627653'
+ ],
+ [
+ 1495716574.925,
+ '0.0047176657142853975'
+ ],
+ [
+ 1495716634.925,
+ '0.004437898571428686'
+ ],
+ [
+ 1495716694.925,
+ '0.004923527366927261'
+ ],
+ [
+ 1495716754.925,
+ '0.005131935066048421'
+ ],
+ [
+ 1495716814.925,
+ '0.005046949523809611'
+ ],
+ [
+ 1495716874.925,
+ '0.00547184095238092'
+ ],
+ [
+ 1495716934.925,
+ '0.005224140016380444'
+ ],
+ [
+ 1495716994.925,
+ '0.005297991171665292'
+ ],
+ [
+ 1495717054.925,
+ '0.005492965995623498'
+ ],
+ [
+ 1495717114.925,
+ '0.005754660000000403'
+ ],
+ [
+ 1495717174.925,
+ '0.005949557138639285'
+ ],
+ [
+ 1495717234.925,
+ '0.006091816112534666'
+ ],
+ [
+ 1495717294.925,
+ '0.005554210080192063'
+ ],
+ [
+ 1495717354.925,
+ '0.006411504395279871'
+ ],
+ [
+ 1495717414.925,
+ '0.006319643996609606'
+ ],
+ [
+ 1495717474.925,
+ '0.005539174405717675'
+ ],
+ [
+ 1495717534.925,
+ '0.0053157078842772255'
+ ],
+ [
+ 1495717594.925,
+ '0.005247480952381066'
+ ],
+ [
+ 1495717654.925,
+ '0.004820141620396252'
+ ],
+ [
+ 1495717714.925,
+ '0.005906173868322844'
+ ],
+ [
+ 1495717774.925,
+ '0.006173117219570961'
+ ],
+ [
+ 1495717834.925,
+ '0.005963340952380661'
+ ],
+ [
+ 1495717894.925,
+ '0.005698976627681527'
+ ],
+ [
+ 1495717954.925,
+ '0.004751279096346378'
+ ],
+ [
+ 1495718014.925,
+ '0.005733142379359711'
+ ],
+ [
+ 1495718074.925,
+ '0.004831689010348035'
+ ],
+ [
+ 1495718134.925,
+ '0.005188370476191092'
+ ],
+ [
+ 1495718194.925,
+ '0.004793227554547938'
+ ],
+ [
+ 1495718254.925,
+ '0.003997442857142731'
+ ],
+ [
+ 1495718314.925,
+ '0.004386040132951264'
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ 'last_update': '2017-05-25T13:18:34.949Z'
+};
+
+export default metricsGroupsAPIResponse;
+
+const responseMockData = {
+ 'GET': {
+ '/root/hello-prometheus/environments/30/additional_metrics.json': metricsGroupsAPIResponse,
+ 'http://test.host/frontend-fixtures/environments-project/environments/1/additional_metrics.json': metricsGroupsAPIResponse, // TODO: MAke sure this works in the monitoring_bundle_spec
+ },
+};
+
+export const deploymentData = [
+ {
+ id: 111,
+ iid: 3,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master'
+ },
+ created_at: '2017-05-31T21:23:37.881Z',
+ tag: false,
+ 'last?': true
+ },
+ {
+ id: 110,
+ iid: 2,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master'
+ },
+ created_at: '2017-05-30T20:08:04.629Z',
+ tag: false,
+ 'last?': false
+ },
+ {
+ id: 109,
+ iid: 1,
+ sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2',
+ ref: {
+ name: 'update2-readme'
+ },
+ created_at: '2017-05-30T17:42:38.409Z',
+ tag: false,
+ 'last?': false
+ }
+];
+
+export const statePaths = {
+ settingsPath: '/root/hello-prometheus/services/prometheus/edit',
+ documentationPath: '/help/administration/monitoring/prometheus/index.md',
+};
+
+export const singleRowMetrics = [
+ {
+ 'title': 'CPU usage',
+ 'weight': 1,
+ 'y_label': 'Memory',
+ 'queries': [
+ {
+ 'query_range': 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100',
+ 'label': 'Container CPU',
+ 'result': [
+ {
+ 'metric': {
+
+ },
+ 'values': [
+ {
+ 'time': '2017-06-04T21:22:59.508Z',
+ 'value': '0.06335544298150002'
+ },
+ {
+ 'time': '2017-06-04T21:23:59.508Z',
+ 'value': '0.0420347312480917'
+ },
+ {
+ 'time': '2017-06-04T21:24:59.508Z',
+ 'value': '0.0023175131665412706'
+ },
+ {
+ 'time': '2017-06-04T21:25:59.508Z',
+ 'value': '0.002315870476190476'
+ },
+ {
+ 'time': '2017-06-04T21:26:59.508Z',
+ 'value': '0.0025005961904761894'
+ },
+ {
+ 'time': '2017-06-04T21:27:59.508Z',
+ 'value': '0.0024612605834341264'
+ },
+ {
+ 'time': '2017-06-04T21:28:59.508Z',
+ 'value': '0.002313129398767631'
+ },
+ {
+ 'time': '2017-06-04T21:29:59.508Z',
+ 'value': '0.002411067353663882'
+ },
+ {
+ 'time': '2017-06-04T21:30:59.508Z',
+ 'value': '0.002577309263721303'
+ },
+ {
+ 'time': '2017-06-04T21:31:59.508Z',
+ 'value': '0.00242688307730403'
+ },
+ {
+ 'time': '2017-06-04T21:32:59.508Z',
+ 'value': '0.0024168360301330457'
+ },
+ {
+ 'time': '2017-06-04T21:33:59.508Z',
+ 'value': '0.0020449528090743714'
+ },
+ {
+ 'time': '2017-06-04T21:34:59.508Z',
+ 'value': '0.0019149619047619036'
+ },
+ {
+ 'time': '2017-06-04T21:35:59.508Z',
+ 'value': '0.0024491714364625094'
+ },
+ {
+ 'time': '2017-06-04T21:36:59.508Z',
+ 'value': '0.002728773131172677'
+ },
+ {
+ 'time': '2017-06-04T21:37:59.508Z',
+ 'value': '0.0028439119047618997'
+ },
+ {
+ 'time': '2017-06-04T21:38:59.508Z',
+ 'value': '0.0026307480952380917'
+ },
+ {
+ 'time': '2017-06-04T21:39:59.508Z',
+ 'value': '0.0025024842620546446'
+ },
+ {
+ 'time': '2017-06-04T21:40:59.508Z',
+ 'value': '0.002300662387260825'
+ },
+ {
+ 'time': '2017-06-04T21:41:59.508Z',
+ 'value': '0.002052890924848337'
+ },
+ {
+ 'time': '2017-06-04T21:42:59.508Z',
+ 'value': '0.0023711195238095275'
+ },
+ {
+ 'time': '2017-06-04T21:43:59.508Z',
+ 'value': '0.002513477619047618'
+ },
+ {
+ 'time': '2017-06-04T21:44:59.508Z',
+ 'value': '0.0023489776287844897'
+ },
+ {
+ 'time': '2017-06-04T21:45:59.508Z',
+ 'value': '0.002542572310212481'
+ },
+ {
+ 'time': '2017-06-04T21:46:59.508Z',
+ 'value': '0.0024579470671707952'
+ },
+ {
+ 'time': '2017-06-04T21:47:59.508Z',
+ 'value': '0.0028725150236664403'
+ },
+ {
+ 'time': '2017-06-04T21:48:59.508Z',
+ 'value': '0.0024356089105610525'
+ },
+ {
+ 'time': '2017-06-04T21:49:59.508Z',
+ 'value': '0.002544015828269929'
+ },
+ {
+ 'time': '2017-06-04T21:50:59.508Z',
+ 'value': '0.0029595013380824906'
+ },
+ {
+ 'time': '2017-06-04T21:51:59.508Z',
+ 'value': '0.0023084015085858'
+ },
+ {
+ 'time': '2017-06-04T21:52:59.508Z',
+ 'value': '0.0021070500000000083'
+ },
+ {
+ 'time': '2017-06-04T21:53:59.508Z',
+ 'value': '0.0022950066191106617'
+ },
+ {
+ 'time': '2017-06-04T21:54:59.508Z',
+ 'value': '0.002492719454470995'
+ },
+ {
+ 'time': '2017-06-04T21:55:59.508Z',
+ 'value': '0.00244312761904762'
+ },
+ {
+ 'time': '2017-06-04T21:56:59.508Z',
+ 'value': '0.0023495500000000028'
+ },
+ {
+ 'time': '2017-06-04T21:57:59.508Z',
+ 'value': '0.0020597072353070005'
+ },
+ {
+ 'time': '2017-06-04T21:58:59.508Z',
+ 'value': '0.0021482352044800866'
+ },
+ {
+ 'time': '2017-06-04T21:59:59.508Z',
+ 'value': '0.002333490000000004'
+ },
+ {
+ 'time': '2017-06-04T22:00:59.508Z',
+ 'value': '0.0025899442857142815'
+ },
+ {
+ 'time': '2017-06-04T22:01:59.508Z',
+ 'value': '0.002430299999999999'
+ },
+ {
+ 'time': '2017-06-04T22:02:59.508Z',
+ 'value': '0.0023550328092113476'
+ },
+ {
+ 'time': '2017-06-04T22:03:59.508Z',
+ 'value': '0.0026521871636872793'
+ },
+ {
+ 'time': '2017-06-04T22:04:59.508Z',
+ 'value': '0.0023080671428571398'
+ },
+ {
+ 'time': '2017-06-04T22:05:59.508Z',
+ 'value': '0.0024108401032390896'
+ },
+ {
+ 'time': '2017-06-04T22:06:59.508Z',
+ 'value': '0.002433249366678738'
+ },
+ {
+ 'time': '2017-06-04T22:07:59.508Z',
+ 'value': '0.0023242202306688682'
+ },
+ {
+ 'time': '2017-06-04T22:08:59.508Z',
+ 'value': '0.002388222857142859'
+ },
+ {
+ 'time': '2017-06-04T22:09:59.508Z',
+ 'value': '0.002115974914046794'
+ },
+ {
+ 'time': '2017-06-04T22:10:59.508Z',
+ 'value': '0.0025090043331269917'
+ },
+ {
+ 'time': '2017-06-04T22:11:59.508Z',
+ 'value': '0.002445507057277277'
+ },
+ {
+ 'time': '2017-06-04T22:12:59.508Z',
+ 'value': '0.0026348773751130976'
+ },
+ {
+ 'time': '2017-06-04T22:13:59.508Z',
+ 'value': '0.0025616258583088104'
+ },
+ {
+ 'time': '2017-06-04T22:14:59.508Z',
+ 'value': '0.0021544093415751505'
+ },
+ {
+ 'time': '2017-06-04T22:15:59.508Z',
+ 'value': '0.002649394767668881'
+ },
+ {
+ 'time': '2017-06-04T22:16:59.508Z',
+ 'value': '0.0024023332666685705'
+ },
+ {
+ 'time': '2017-06-04T22:17:59.508Z',
+ 'value': '0.0025444105294235306'
+ },
+ {
+ 'time': '2017-06-04T22:18:59.508Z',
+ 'value': '0.0027298872305772806'
+ },
+ {
+ 'time': '2017-06-04T22:19:59.508Z',
+ 'value': '0.0022880104956379287'
+ },
+ {
+ 'time': '2017-06-04T22:20:59.508Z',
+ 'value': '0.002473246666666661'
+ },
+ {
+ 'time': '2017-06-04T22:21:59.508Z',
+ 'value': '0.002259948381935587'
+ },
+ {
+ 'time': '2017-06-04T22:22:59.508Z',
+ 'value': '0.0025778470886268835'
+ },
+ {
+ 'time': '2017-06-04T22:23:59.508Z',
+ 'value': '0.002246127910852894'
+ },
+ {
+ 'time': '2017-06-04T22:24:59.508Z',
+ 'value': '0.0020697466666666758'
+ },
+ {
+ 'time': '2017-06-04T22:25:59.508Z',
+ 'value': '0.00225859722473547'
+ },
+ {
+ 'time': '2017-06-04T22:26:59.508Z',
+ 'value': '0.0026466728254554814'
+ },
+ {
+ 'time': '2017-06-04T22:27:59.508Z',
+ 'value': '0.002151247619047619'
+ },
+ {
+ 'time': '2017-06-04T22:28:59.508Z',
+ 'value': '0.002324161444543914'
+ },
+ {
+ 'time': '2017-06-04T22:29:59.508Z',
+ 'value': '0.002476474313796452'
+ },
+ {
+ 'time': '2017-06-04T22:30:59.508Z',
+ 'value': '0.0023922184232080517'
+ },
+ {
+ 'time': '2017-06-04T22:31:59.508Z',
+ 'value': '0.0025094934237468933'
+ },
+ {
+ 'time': '2017-06-04T22:32:59.508Z',
+ 'value': '0.0025665311098200883'
+ },
+ {
+ 'time': '2017-06-04T22:33:59.508Z',
+ 'value': '0.0024154900681661374'
+ },
+ {
+ 'time': '2017-06-04T22:34:59.508Z',
+ 'value': '0.0023267450166192037'
+ },
+ {
+ 'time': '2017-06-04T22:35:59.508Z',
+ 'value': '0.002156521904761904'
+ },
+ {
+ 'time': '2017-06-04T22:36:59.508Z',
+ 'value': '0.0025474356898637007'
+ },
+ {
+ 'time': '2017-06-04T22:37:59.508Z',
+ 'value': '0.0025989409624670233'
+ },
+ {
+ 'time': '2017-06-04T22:38:59.508Z',
+ 'value': '0.002348336664762987'
+ },
+ {
+ 'time': '2017-06-04T22:39:59.508Z',
+ 'value': '0.002665888246554726'
+ },
+ {
+ 'time': '2017-06-04T22:40:59.508Z',
+ 'value': '0.002652684787474174'
+ },
+ {
+ 'time': '2017-06-04T22:41:59.508Z',
+ 'value': '0.002472620430865355'
+ },
+ {
+ 'time': '2017-06-04T22:42:59.508Z',
+ 'value': '0.0020616469210110247'
+ },
+ {
+ 'time': '2017-06-04T22:43:59.508Z',
+ 'value': '0.0022434546372311934'
+ },
+ {
+ 'time': '2017-06-04T22:44:59.508Z',
+ 'value': '0.0024469386784827982'
+ },
+ {
+ 'time': '2017-06-04T22:45:59.508Z',
+ 'value': '0.0026192823809523787'
+ },
+ {
+ 'time': '2017-06-04T22:46:59.508Z',
+ 'value': '0.003451999542852798'
+ },
+ {
+ 'time': '2017-06-04T22:47:59.508Z',
+ 'value': '0.0031780314285714288'
+ },
+ {
+ 'time': '2017-06-04T22:48:59.508Z',
+ 'value': '0.0024403352380952415'
+ },
+ {
+ 'time': '2017-06-04T22:49:59.508Z',
+ 'value': '0.001998824761904764'
+ },
+ {
+ 'time': '2017-06-04T22:50:59.508Z',
+ 'value': '0.0023792404761904806'
+ },
+ {
+ 'time': '2017-06-04T22:51:59.508Z',
+ 'value': '0.002725906190476185'
+ },
+ {
+ 'time': '2017-06-04T22:52:59.508Z',
+ 'value': '0.0020989528671155624'
+ },
+ {
+ 'time': '2017-06-04T22:53:59.508Z',
+ 'value': '0.00228808226745016'
+ },
+ {
+ 'time': '2017-06-04T22:54:59.508Z',
+ 'value': '0.0019860807413192147'
+ },
+ {
+ 'time': '2017-06-04T22:55:59.508Z',
+ 'value': '0.0022698085714285897'
+ },
+ {
+ 'time': '2017-06-04T22:56:59.508Z',
+ 'value': '0.0022839098467604415'
+ },
+ {
+ 'time': '2017-06-04T22:57:59.508Z',
+ 'value': '0.002531114761904749'
+ },
+ {
+ 'time': '2017-06-04T22:58:59.508Z',
+ 'value': '0.0028941072550999016'
+ },
+ {
+ 'time': '2017-06-04T22:59:59.508Z',
+ 'value': '0.002547169523809506'
+ },
+ {
+ 'time': '2017-06-04T23:00:59.508Z',
+ 'value': '0.0024062999999999958'
+ },
+ {
+ 'time': '2017-06-04T23:01:59.508Z',
+ 'value': '0.0026939518471604386'
+ },
+ {
+ 'time': '2017-06-04T23:02:59.508Z',
+ 'value': '0.002362901428571429'
+ },
+ {
+ 'time': '2017-06-04T23:03:59.508Z',
+ 'value': '0.002663927142857154'
+ },
+ {
+ 'time': '2017-06-04T23:04:59.508Z',
+ 'value': '0.0026173314285714354'
+ },
+ {
+ 'time': '2017-06-04T23:05:59.508Z',
+ 'value': '0.002326527366406044'
+ },
+ {
+ 'time': '2017-06-04T23:06:59.508Z',
+ 'value': '0.002035313809523809'
+ },
+ {
+ 'time': '2017-06-04T23:07:59.508Z',
+ 'value': '0.002421447414786533'
+ },
+ {
+ 'time': '2017-06-04T23:08:59.508Z',
+ 'value': '0.002898313809523804'
+ },
+ {
+ 'time': '2017-06-04T23:09:59.508Z',
+ 'value': '0.002544891856112907'
+ },
+ {
+ 'time': '2017-06-04T23:10:59.508Z',
+ 'value': '0.002290625356938882'
+ },
+ {
+ 'time': '2017-06-04T23:11:59.508Z',
+ 'value': '0.002483028095238096'
+ },
+ {
+ 'time': '2017-06-04T23:12:59.508Z',
+ 'value': '0.0023396832350784237'
+ },
+ {
+ 'time': '2017-06-04T23:13:59.508Z',
+ 'value': '0.002085529248176153'
+ },
+ {
+ 'time': '2017-06-04T23:14:59.508Z',
+ 'value': '0.0022417815068428012'
+ },
+ {
+ 'time': '2017-06-04T23:15:59.508Z',
+ 'value': '0.002660293333333341'
+ },
+ {
+ 'time': '2017-06-04T23:16:59.508Z',
+ 'value': '0.0029845149093818226'
+ },
+ {
+ 'time': '2017-06-04T23:17:59.508Z',
+ 'value': '0.0027716655079475464'
+ },
+ {
+ 'time': '2017-06-04T23:18:59.508Z',
+ 'value': '0.0025217708908741128'
+ },
+ {
+ 'time': '2017-06-04T23:19:59.508Z',
+ 'value': '0.0025811235131094055'
+ },
+ {
+ 'time': '2017-06-04T23:20:59.508Z',
+ 'value': '0.002209904761904762'
+ },
+ {
+ 'time': '2017-06-04T23:21:59.508Z',
+ 'value': '0.0025053322926383344'
+ },
+ {
+ 'time': '2017-06-04T23:22:59.508Z',
+ 'value': '0.002350917636526411'
+ },
+ {
+ 'time': '2017-06-04T23:23:59.508Z',
+ 'value': '0.0018477500000000078'
+ },
+ {
+ 'time': '2017-06-04T23:24:59.508Z',
+ 'value': '0.002427629523809527'
+ },
+ {
+ 'time': '2017-06-04T23:25:59.508Z',
+ 'value': '0.0019305498147601655'
+ },
+ {
+ 'time': '2017-06-04T23:26:59.508Z',
+ 'value': '0.002097250000000006'
+ },
+ {
+ 'time': '2017-06-04T23:27:59.508Z',
+ 'value': '0.002675020952780041'
+ },
+ {
+ 'time': '2017-06-04T23:28:59.508Z',
+ 'value': '0.0023142214285714374'
+ },
+ {
+ 'time': '2017-06-04T23:29:59.508Z',
+ 'value': '0.0023644723809523737'
+ },
+ {
+ 'time': '2017-06-04T23:30:59.508Z',
+ 'value': '0.002108696190476198'
+ },
+ {
+ 'time': '2017-06-04T23:31:59.508Z',
+ 'value': '0.0019918289697997194'
+ },
+ {
+ 'time': '2017-06-04T23:32:59.508Z',
+ 'value': '0.001583584285714283'
+ },
+ {
+ 'time': '2017-06-04T23:33:59.508Z',
+ 'value': '0.002073770226383112'
+ },
+ {
+ 'time': '2017-06-04T23:34:59.508Z',
+ 'value': '0.0025877664234966818'
+ },
+ {
+ 'time': '2017-06-04T23:35:59.508Z',
+ 'value': '0.0021138238095238147'
+ },
+ {
+ 'time': '2017-06-04T23:36:59.508Z',
+ 'value': '0.0022140838095238303'
+ },
+ {
+ 'time': '2017-06-04T23:37:59.508Z',
+ 'value': '0.0018592674425248847'
+ },
+ {
+ 'time': '2017-06-04T23:38:59.508Z',
+ 'value': '0.0020461969533657016'
+ },
+ {
+ 'time': '2017-06-04T23:39:59.508Z',
+ 'value': '0.0021593628571428543'
+ },
+ {
+ 'time': '2017-06-04T23:40:59.508Z',
+ 'value': '0.0024330682564928188'
+ },
+ {
+ 'time': '2017-06-04T23:41:59.508Z',
+ 'value': '0.0021501804779093174'
+ },
+ {
+ 'time': '2017-06-04T23:42:59.508Z',
+ 'value': '0.0025787493928397945'
+ },
+ {
+ 'time': '2017-06-04T23:43:59.508Z',
+ 'value': '0.002593657082448396'
+ },
+ {
+ 'time': '2017-06-04T23:44:59.508Z',
+ 'value': '0.0021316752380952306'
+ },
+ {
+ 'time': '2017-06-04T23:45:59.508Z',
+ 'value': '0.0026972905019952086'
+ },
+ {
+ 'time': '2017-06-04T23:46:59.508Z',
+ 'value': '0.002580250764292983'
+ },
+ {
+ 'time': '2017-06-04T23:47:59.508Z',
+ 'value': '0.00227103000000001'
+ },
+ {
+ 'time': '2017-06-04T23:48:59.508Z',
+ 'value': '0.0023678515647321146'
+ },
+ {
+ 'time': '2017-06-04T23:49:59.508Z',
+ 'value': '0.002371472857142866'
+ },
+ {
+ 'time': '2017-06-04T23:50:59.508Z',
+ 'value': '0.0026181353688500978'
+ },
+ {
+ 'time': '2017-06-04T23:51:59.508Z',
+ 'value': '0.0025609667711121217'
+ },
+ {
+ 'time': '2017-06-04T23:52:59.508Z',
+ 'value': '0.0027145308139922557'
+ },
+ {
+ 'time': '2017-06-04T23:53:59.508Z',
+ 'value': '0.0024249397613310512'
+ },
+ {
+ 'time': '2017-06-04T23:54:59.508Z',
+ 'value': '0.002399907142857147'
+ },
+ {
+ 'time': '2017-06-04T23:55:59.508Z',
+ 'value': '0.0024753357142857195'
+ },
+ {
+ 'time': '2017-06-04T23:56:59.508Z',
+ 'value': '0.0026179149325231575'
+ },
+ {
+ 'time': '2017-06-04T23:57:59.508Z',
+ 'value': '0.0024261340368186956'
+ },
+ {
+ 'time': '2017-06-04T23:58:59.508Z',
+ 'value': '0.0021061071428571517'
+ },
+ {
+ 'time': '2017-06-04T23:59:59.508Z',
+ 'value': '0.0024033971105037015'
+ },
+ {
+ 'time': '2017-06-05T00:00:59.508Z',
+ 'value': '0.0028287676190475956'
+ },
+ {
+ 'time': '2017-06-05T00:01:59.508Z',
+ 'value': '0.002499719050294778'
+ },
+ {
+ 'time': '2017-06-05T00:02:59.508Z',
+ 'value': '0.0026726102153353856'
+ },
+ {
+ 'time': '2017-06-05T00:03:59.508Z',
+ 'value': '0.00262582619047618'
+ },
+ {
+ 'time': '2017-06-05T00:04:59.508Z',
+ 'value': '0.002280473147363316'
+ },
+ {
+ 'time': '2017-06-05T00:05:59.508Z',
+ 'value': '0.002095581470652675'
+ },
+ {
+ 'time': '2017-06-05T00:06:59.508Z',
+ 'value': '0.002270768490828408'
+ },
+ {
+ 'time': '2017-06-05T00:07:59.508Z',
+ 'value': '0.002728577415023017'
+ },
+ {
+ 'time': '2017-06-05T00:08:59.508Z',
+ 'value': '0.002652512857142863'
+ },
+ {
+ 'time': '2017-06-05T00:09:59.508Z',
+ 'value': '0.0022781033924455674'
+ },
+ {
+ 'time': '2017-06-05T00:10:59.508Z',
+ 'value': '0.0025345038095238234'
+ },
+ {
+ 'time': '2017-06-05T00:11:59.508Z',
+ 'value': '0.002376050020000397'
+ },
+ {
+ 'time': '2017-06-05T00:12:59.508Z',
+ 'value': '0.002455068143506122'
+ },
+ {
+ 'time': '2017-06-05T00:13:59.508Z',
+ 'value': '0.002826705714285719'
+ },
+ {
+ 'time': '2017-06-05T00:14:59.508Z',
+ 'value': '0.002343833692070314'
+ },
+ {
+ 'time': '2017-06-05T00:15:59.508Z',
+ 'value': '0.00264853297122164'
+ },
+ {
+ 'time': '2017-06-05T00:16:59.508Z',
+ 'value': '0.0027656335117426257'
+ },
+ {
+ 'time': '2017-06-05T00:17:59.508Z',
+ 'value': '0.0025896543842439564'
+ },
+ {
+ 'time': '2017-06-05T00:18:59.508Z',
+ 'value': '0.002180053237081201'
+ },
+ {
+ 'time': '2017-06-05T00:19:59.508Z',
+ 'value': '0.002475245002333342'
+ },
+ {
+ 'time': '2017-06-05T00:20:59.508Z',
+ 'value': '0.0027559767805101065'
+ },
+ {
+ 'time': '2017-06-05T00:21:59.508Z',
+ 'value': '0.0022294836141296607'
+ },
+ {
+ 'time': '2017-06-05T00:22:59.508Z',
+ 'value': '0.0021383590476190643'
+ },
+ {
+ 'time': '2017-06-05T00:23:59.508Z',
+ 'value': '0.002085417956361494'
+ },
+ {
+ 'time': '2017-06-05T00:24:59.508Z',
+ 'value': '0.0024140319047619013'
+ },
+ {
+ 'time': '2017-06-05T00:25:59.508Z',
+ 'value': '0.0024513114285714304'
+ },
+ {
+ 'time': '2017-06-05T00:26:59.508Z',
+ 'value': '0.0026932152380952446'
+ },
+ {
+ 'time': '2017-06-05T00:27:59.508Z',
+ 'value': '0.0022656844350898517'
+ },
+ {
+ 'time': '2017-06-05T00:28:59.508Z',
+ 'value': '0.0024483785714285704'
+ },
+ {
+ 'time': '2017-06-05T00:29:59.508Z',
+ 'value': '0.002559505804817207'
+ },
+ {
+ 'time': '2017-06-05T00:30:59.508Z',
+ 'value': '0.0019485681088751649'
+ },
+ {
+ 'time': '2017-06-05T00:31:59.508Z',
+ 'value': '0.00228367984456996'
+ },
+ {
+ 'time': '2017-06-05T00:32:59.508Z',
+ 'value': '0.002522149047619049'
+ },
+ {
+ 'time': '2017-06-05T00:33:59.508Z',
+ 'value': '0.0026860117715406737'
+ },
+ {
+ 'time': '2017-06-05T00:34:59.508Z',
+ 'value': '0.002679669523809523'
+ },
+ {
+ 'time': '2017-06-05T00:35:59.508Z',
+ 'value': '0.0022201920970675937'
+ },
+ {
+ 'time': '2017-06-05T00:36:59.508Z',
+ 'value': '0.0022917647619047615'
+ },
+ {
+ 'time': '2017-06-05T00:37:59.508Z',
+ 'value': '0.0021774059294673576'
+ },
+ {
+ 'time': '2017-06-05T00:38:59.508Z',
+ 'value': '0.0024637766666666763'
+ },
+ {
+ 'time': '2017-06-05T00:39:59.508Z',
+ 'value': '0.002470468290174195'
+ },
+ {
+ 'time': '2017-06-05T00:40:59.508Z',
+ 'value': '0.0022188616082057812'
+ },
+ {
+ 'time': '2017-06-05T00:41:59.508Z',
+ 'value': '0.002421840744373875'
+ },
+ {
+ 'time': '2017-06-05T00:42:59.508Z',
+ 'value': '0.0023918266666666547'
+ },
+ {
+ 'time': '2017-06-05T00:43:59.508Z',
+ 'value': '0.002195743809523809'
+ },
+ {
+ 'time': '2017-06-05T00:44:59.508Z',
+ 'value': '0.0025514828571428687'
+ },
+ {
+ 'time': '2017-06-05T00:45:59.508Z',
+ 'value': '0.0027981709349612694'
+ },
+ {
+ 'time': '2017-06-05T00:46:59.508Z',
+ 'value': '0.002557977142857146'
+ },
+ {
+ 'time': '2017-06-05T00:47:59.508Z',
+ 'value': '0.002213244285714286'
+ },
+ {
+ 'time': '2017-06-05T00:48:59.508Z',
+ 'value': '0.0025706738095238046'
+ },
+ {
+ 'time': '2017-06-05T00:49:59.508Z',
+ 'value': '0.002210976666666671'
+ },
+ {
+ 'time': '2017-06-05T00:50:59.508Z',
+ 'value': '0.002055377091646749'
+ },
+ {
+ 'time': '2017-06-05T00:51:59.508Z',
+ 'value': '0.002308368095238119'
+ },
+ {
+ 'time': '2017-06-05T00:52:59.508Z',
+ 'value': '0.0024687939885141615'
+ },
+ {
+ 'time': '2017-06-05T00:53:59.508Z',
+ 'value': '0.002563018571428578'
+ },
+ {
+ 'time': '2017-06-05T00:54:59.508Z',
+ 'value': '0.00240563291078959'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'title': 'Memory usage',
+ 'weight': 1,
+ 'y_label': 'Values',
+ 'queries': [
+ {
+ 'query_range': 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20',
+ 'label': 'Container memory',
+ 'unit': 'MiB',
+ 'result': [
+ {
+ 'metric': {
+
+ },
+ 'values': [
+ {
+ 'time': '2017-06-04T21:22:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:23:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:24:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:25:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:26:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:27:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:28:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:29:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:30:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:31:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:32:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:33:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:34:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:35:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:36:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:37:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:38:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:39:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:40:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:41:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:42:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:43:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:44:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:45:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:46:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:47:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:48:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:49:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:50:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:51:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:52:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:53:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:54:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:55:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:56:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:57:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:58:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T21:59:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:00:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:01:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:02:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:03:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:04:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:05:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:06:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:07:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:08:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:09:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:10:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:11:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:12:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:13:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:14:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:15:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:16:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:17:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:18:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:19:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:20:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:21:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:22:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:23:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:24:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:25:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:26:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:27:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:28:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:29:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:30:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:31:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:32:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:33:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:34:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:35:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:36:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:37:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:38:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:39:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:40:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:41:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:42:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:43:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:44:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:45:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:46:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:47:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:48:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:49:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:50:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:51:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:52:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:53:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:54:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:55:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:56:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:57:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:58:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T22:59:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:00:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:01:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:02:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:03:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:04:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:05:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:06:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:07:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:08:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:09:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:10:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:11:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:12:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:13:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:14:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:15:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:16:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:17:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:18:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:19:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:20:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:21:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:22:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:23:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:24:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:25:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:26:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:27:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:28:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:29:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:30:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:31:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:32:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:33:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:34:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:35:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:36:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:37:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:38:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:39:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:40:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:41:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:42:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:43:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:44:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:45:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:46:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:47:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:48:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:49:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:50:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:51:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:52:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:53:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:54:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:55:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:56:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:57:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:58:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-04T23:59:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:00:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:01:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:02:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:03:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:04:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:05:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:06:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:07:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:08:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:09:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:10:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:11:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:12:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:13:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:14:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:15:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:16:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:17:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:18:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:19:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:20:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:21:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:22:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:23:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:24:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:25:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:26:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:27:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:28:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:29:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:30:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:31:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:32:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:33:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:34:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:35:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:36:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:37:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:38:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:39:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:40:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:41:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:42:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:43:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:44:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:45:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:46:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:47:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:48:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:49:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:50:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:51:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:52:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:53:59.508Z',
+ 'value': '15.0859375'
+ },
+ {
+ 'time': '2017-06-05T00:54:59.508Z',
+ 'value': '15.0859375'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+];
+
+export function MonitorMockInterceptor(request, next) {
+ const body = responseMockData[request.method.toUpperCase()][request.url];
+
+ next(request.respondWith(JSON.stringify(body), {
+ status: 200,
+ }));
+}
diff --git a/spec/javascripts/monitoring/monitoring_column_spec.js b/spec/javascripts/monitoring/monitoring_column_spec.js
new file mode 100644
index 00000000000..c423024dce0
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_column_spec.js
@@ -0,0 +1,109 @@
+import Vue from 'vue';
+import _ from 'underscore';
+import MonitoringColumn from '~/monitoring/components/monitoring_column.vue';
+import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
+import eventHub from '~/monitoring/event_hub';
+import { deploymentData, singleRowMetrics } from './mock_data';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringColumn);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+describe('MonitoringColumn', () => {
+ beforeEach(() => {
+ spyOn(MonitoringMixins.methods, 'formatDeployments').and.callFake(function fakeFormat() {
+ return {};
+ });
+ });
+
+ it('has a title', () => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(component.columnData.title);
+ });
+
+ it('creates a path for the line and area of the graph', (done) => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ Vue.nextTick(() => {
+ expect(component.area).toBeDefined();
+ expect(component.line).toBeDefined();
+ expect(typeof component.area).toEqual('string');
+ expect(typeof component.line).toEqual('string');
+ expect(_.isFunction(component.xScale)).toBe(true);
+ expect(_.isFunction(component.yScale)).toBe(true);
+ done();
+ });
+ });
+
+ describe('Computed props', () => {
+ it('axisTransform translates an element Y position depending of its height', () => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ const transformedHeight = `${component.graphHeight - 100}`;
+ expect(component.axisTransform.indexOf(transformedHeight))
+ .not.toEqual(-1);
+ });
+
+ it('outterViewBox gets a width and height property based on the DOM size of the element', () => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ const viewBoxArray = component.outterViewBox.split(' ');
+ expect(typeof component.outterViewBox).toEqual('string');
+ expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
+ expect(viewBoxArray[3]).toEqual(component.graphHeight.toString());
+ });
+ });
+
+ it('sends an event to the eventhub when it has finished resizing', (done) => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+ spyOn(eventHub, '$emit');
+
+ component.updateAspectRatio = true;
+ Vue.nextTick(() => {
+ expect(eventHub.$emit).toHaveBeenCalled();
+ done();
+ });
+ });
+
+ it('has a title for the y-axis and the chart legend that comes from the backend', () => {
+ const component = createComponent({
+ columnData: singleRowMetrics[0],
+ classType: 'col-md-6',
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.yAxisLabel).toEqual(component.columnData.y_label);
+ expect(component.legendTitle).toEqual(component.columnData.queries[0].label);
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_deployment_spec.js b/spec/javascripts/monitoring/monitoring_deployment_spec.js
new file mode 100644
index 00000000000..5cc5b514824
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_deployment_spec.js
@@ -0,0 +1,137 @@
+import Vue from 'vue';
+import MonitoringState from '~/monitoring/components/monitoring_deployment.vue';
+import { deploymentData } from './mock_data';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringState);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+describe('MonitoringDeployment', () => {
+ const reducedDeploymentData = [deploymentData[0]];
+ reducedDeploymentData[0].ref = reducedDeploymentData[0].ref.name;
+ reducedDeploymentData[0].xPos = 10;
+ reducedDeploymentData[0].time = new Date(reducedDeploymentData[0].created_at);
+ describe('Methods', () => {
+ it('refText shows the ref when a tag is available', () => {
+ reducedDeploymentData[0].tag = '1.0';
+ const component = createComponent({
+ showDeployInfo: false,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.refText(reducedDeploymentData[0]),
+ ).toEqual(reducedDeploymentData[0].ref);
+ });
+
+ it('refText shows the sha when no tag is available', () => {
+ reducedDeploymentData[0].tag = null;
+ const component = createComponent({
+ showDeployInfo: false,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.refText(reducedDeploymentData[0]),
+ ).toContain('f5bcd1');
+ });
+
+ it('nameDeploymentClass creates a class with the prefix deploy-info-', () => {
+ const component = createComponent({
+ showDeployInfo: false,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.nameDeploymentClass(reducedDeploymentData[0]),
+ ).toContain('deploy-info');
+ });
+
+ it('transformDeploymentGroup translates an available deployment', () => {
+ const component = createComponent({
+ showDeployInfo: false,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.transformDeploymentGroup(reducedDeploymentData[0]),
+ ).toContain('translate(11, 20)');
+ });
+
+ it('hides the deployment flag', () => {
+ reducedDeploymentData[0].showDeploymentFlag = false;
+ const component = createComponent({
+ showDeployInfo: true,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(component.$el.querySelector('.js-deploy-info-box')).toBeNull();
+ });
+
+ it('shows the deployment flag', () => {
+ reducedDeploymentData[0].showDeploymentFlag = true;
+ const component = createComponent({
+ showDeployInfo: true,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.$el.querySelector('.js-deploy-info-box').style.display,
+ ).not.toEqual('display: none;');
+ });
+
+ it('shows the refText inside a text element with the deploy-info-text class', () => {
+ reducedDeploymentData[0].showDeploymentFlag = true;
+ const component = createComponent({
+ showDeployInfo: true,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(
+ component.$el.querySelector('.deploy-info-text').firstChild.nodeValue.trim(),
+ ).toEqual(component.refText(reducedDeploymentData[0]));
+ });
+
+ it('should contain a hidden gradient', () => {
+ const component = createComponent({
+ showDeployInfo: true,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
+ });
+
+ describe('Computed props', () => {
+ it('calculatedHeight', () => {
+ const component = createComponent({
+ showDeployInfo: true,
+ deploymentData: reducedDeploymentData,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(component.calculatedHeight).toEqual(180);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_flag_spec.js b/spec/javascripts/monitoring/monitoring_flag_spec.js
new file mode 100644
index 00000000000..3861a95ff07
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_flag_spec.js
@@ -0,0 +1,76 @@
+import Vue from 'vue';
+import MonitoringFlag from '~/monitoring/components/monitoring_flag.vue';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringFlag);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+function getCoordinate(component, selector, coordinate) {
+ const coordinateVal = component.$el.querySelector(selector).getAttribute(coordinate);
+ return parseInt(coordinateVal, 10);
+}
+
+describe('MonitoringFlag', () => {
+ it('has a line and a circle located at the currentXCoordinate and currentYCoordinate', () => {
+ const component = createComponent({
+ currentXCoordinate: 200,
+ currentYCoordinate: 100,
+ currentFlagPosition: 100,
+ currentData: {
+ time: new Date('2017-06-04T18:17:33.501Z'),
+ value: '1.49609375',
+ },
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(getCoordinate(component, '.selected-metric-line', 'x1'))
+ .toEqual(component.currentXCoordinate);
+ expect(getCoordinate(component, '.selected-metric-line', 'x2'))
+ .toEqual(component.currentXCoordinate);
+ expect(getCoordinate(component, '.circle-metric', 'cx'))
+ .toEqual(component.currentXCoordinate);
+ expect(getCoordinate(component, '.circle-metric', 'cy'))
+ .toEqual(component.currentYCoordinate);
+ });
+
+ it('has a SVG with the class rect-text-metric at the currentFlagPosition', () => {
+ const component = createComponent({
+ currentXCoordinate: 200,
+ currentYCoordinate: 100,
+ currentFlagPosition: 100,
+ currentData: {
+ time: new Date('2017-06-04T18:17:33.501Z'),
+ value: '1.49609375',
+ },
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ const svg = component.$el.querySelector('.rect-text-metric');
+ expect(svg.tagName).toEqual('svg');
+ expect(parseInt(svg.getAttribute('x'), 10)).toEqual(component.currentFlagPosition);
+ });
+
+ describe('Computed props', () => {
+ it('calculatedHeight', () => {
+ const component = createComponent({
+ currentXCoordinate: 200,
+ currentYCoordinate: 100,
+ currentFlagPosition: 100,
+ currentData: {
+ time: new Date('2017-06-04T18:17:33.501Z'),
+ value: '1.49609375',
+ },
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ });
+
+ expect(component.calculatedHeight).toEqual(180);
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_legends_spec.js b/spec/javascripts/monitoring/monitoring_legends_spec.js
new file mode 100644
index 00000000000..4c69b81e650
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_legends_spec.js
@@ -0,0 +1,111 @@
+import Vue from 'vue';
+import MonitoringLegends from '~/monitoring/components/monitoring_legends.vue';
+import measurements from '~/monitoring/utils/measurements';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringLegends);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+function getTextFromNode(component, selector) {
+ return component.$el.querySelector(selector).firstChild.nodeValue.trim();
+}
+
+describe('MonitoringLegends', () => {
+ describe('Computed props', () => {
+ it('textTransform', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
+ });
+
+ it('xPosition', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(component.xPosition).toEqual(180);
+ });
+
+ it('yPosition', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(component.yPosition).toEqual(240);
+ });
+
+ it('rectTransform', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
+ });
+ });
+
+ it('has 2 rect-axis-text rect svg elements', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
+ });
+
+ it('contains text to signal the usage, title and time', () => {
+ const component = createComponent({
+ graphWidth: 500,
+ graphHeight: 300,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ areaColorRgb: '#f0f0f0',
+ legendTitle: 'Title',
+ yAxisLabel: 'Values',
+ metricUsage: 'Value',
+ });
+
+ expect(getTextFromNode(component, '.text-metric-title')).toEqual(component.legendTitle);
+ expect(getTextFromNode(component, '.text-metric-usage')).toEqual(component.metricUsage);
+ expect(getTextFromNode(component, '.label-axis-text')).toEqual(component.yAxisLabel);
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_row_spec.js b/spec/javascripts/monitoring/monitoring_row_spec.js
new file mode 100644
index 00000000000..a82480e8342
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_row_spec.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import MonitoringRow from '~/monitoring/components/monitoring_row.vue';
+import { deploymentData, singleRowMetrics } from './mock_data';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringRow);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+describe('MonitoringRow', () => {
+ describe('Computed props', () => {
+ it('bootstrapClass is set to col-md-6 when rowData is higher/equal to 2', () => {
+ const component = createComponent({
+ rowData: singleRowMetrics,
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.bootstrapClass).toEqual('col-md-6');
+ });
+
+ it('bootstrapClass is set to col-md-12 when rowData is lower than 2', () => {
+ const component = createComponent({
+ rowData: [singleRowMetrics[0]],
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.bootstrapClass).toEqual('col-md-12');
+ });
+ });
+
+ it('has one column', () => {
+ const component = createComponent({
+ rowData: singleRowMetrics,
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.$el.querySelectorAll('.prometheus-svg-container').length)
+ .toEqual(component.rowData.length);
+ });
+
+ it('has two columns', () => {
+ const component = createComponent({
+ rowData: singleRowMetrics,
+ updateAspectRatio: false,
+ deploymentData,
+ });
+
+ expect(component.$el.querySelectorAll('.col-md-6').length)
+ .toEqual(component.rowData.length);
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_spec.js b/spec/javascripts/monitoring/monitoring_spec.js
new file mode 100644
index 00000000000..6c7b691baa4
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import Monitoring from '~/monitoring/components/monitoring.vue';
+import { MonitorMockInterceptor } from './mock_data';
+
+describe('Monitoring', () => {
+ const fixtureName = 'environments/metrics/metrics.html.raw';
+ let MonitoringComponent;
+ let component;
+ preloadFixtures(fixtureName);
+
+ beforeEach(() => {
+ loadFixtures(fixtureName);
+ MonitoringComponent = Vue.extend(Monitoring);
+ });
+
+ describe('no metrics are available yet', () => {
+ it('shows a getting started empty state when no metrics are present', () => {
+ component = new MonitoringComponent({
+ el: document.querySelector('#prometheus-graphs'),
+ });
+
+ component.$mount();
+ expect(component.$el.querySelector('#prometheus-graphs')).toBe(null);
+ expect(component.state).toEqual('gettingStarted');
+ });
+ });
+
+ describe('requests information to the server', () => {
+ beforeEach(() => {
+ document.querySelector('#prometheus-graphs').setAttribute('data-has-metrics', 'true');
+ Vue.http.interceptors.push(MonitorMockInterceptor);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, MonitorMockInterceptor);
+ });
+
+ it('shows up a loading state', (done) => {
+ component = new MonitoringComponent({
+ el: document.querySelector('#prometheus-graphs'),
+ });
+ component.$mount();
+ Vue.nextTick(() => {
+ expect(component.state).toEqual('loading');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_state_spec.js b/spec/javascripts/monitoring/monitoring_state_spec.js
new file mode 100644
index 00000000000..4c0c558502f
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_state_spec.js
@@ -0,0 +1,110 @@
+import Vue from 'vue';
+import MonitoringState from '~/monitoring/components/monitoring_state.vue';
+import { statePaths } from './mock_data';
+
+const createComponent = (propsData) => {
+ const Component = Vue.extend(MonitoringState);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+function getTextFromNode(component, selector) {
+ return component.$el.querySelector(selector).firstChild.nodeValue.trim();
+}
+
+describe('MonitoringState', () => {
+ describe('Computed props', () => {
+ it('currentState', () => {
+ const component = createComponent({
+ selectedState: 'gettingStarted',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.currentState).toBe(component.states.gettingStarted);
+ });
+
+ it('buttonPath returns settings path for the state "gettingStarted"', () => {
+ const component = createComponent({
+ selectedState: 'gettingStarted',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.buttonPath).toEqual(statePaths.settingsPath);
+ expect(component.buttonPath).not.toEqual(statePaths.documentationPath);
+ });
+
+ it('buttonPath returns documentation path for any of the other states', () => {
+ const component = createComponent({
+ selectedState: 'loading',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.buttonPath).toEqual(statePaths.documentationPath);
+ expect(component.buttonPath).not.toEqual(statePaths.settingsPath);
+ });
+
+ it('showButtonDescription returns a description with a link for the unableToConnect state', () => {
+ const component = createComponent({
+ selectedState: 'unableToConnect',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.showButtonDescription).toEqual(true);
+ });
+
+ it('showButtonDescription returns the description without a link for any other state', () => {
+ const component = createComponent({
+ selectedState: 'loading',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.showButtonDescription).toEqual(false);
+ });
+ });
+
+ it('should show the gettingStarted state', () => {
+ const component = createComponent({
+ selectedState: 'gettingStarted',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ expect(getTextFromNode(component, '.state-title')).toEqual(component.states.gettingStarted.title);
+ expect(getTextFromNode(component, '.state-description')).toEqual(component.states.gettingStarted.description);
+ expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.gettingStarted.buttonText);
+ });
+
+ it('should show the loading state', () => {
+ const component = createComponent({
+ selectedState: 'loading',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ expect(getTextFromNode(component, '.state-title')).toEqual(component.states.loading.title);
+ expect(getTextFromNode(component, '.state-description')).toEqual(component.states.loading.description);
+ expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.loading.buttonText);
+ });
+
+ it('should show the unableToConnect state', () => {
+ const component = createComponent({
+ selectedState: 'unableToConnect',
+ settingsPath: statePaths.settingsPath,
+ documentationPath: statePaths.documentationPath,
+ });
+
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ expect(getTextFromNode(component, '.state-title')).toEqual(component.states.unableToConnect.title);
+ expect(component.$el.querySelector('.state-description a')).toBeDefined();
+ expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.unableToConnect.buttonText);
+ });
+});
diff --git a/spec/javascripts/monitoring/monitoring_store_spec.js b/spec/javascripts/monitoring/monitoring_store_spec.js
new file mode 100644
index 00000000000..20c1e6a0005
--- /dev/null
+++ b/spec/javascripts/monitoring/monitoring_store_spec.js
@@ -0,0 +1,24 @@
+import MonitoringStore from '~/monitoring/stores/monitoring_store';
+import MonitoringMock, { deploymentData } from './mock_data';
+
+describe('MonitoringStore', () => {
+ this.store = new MonitoringStore();
+ this.store.storeMetrics(MonitoringMock.data);
+
+ it('contains one group that contains two queries sorted by priority in one row', () => {
+ expect(this.store.groups).toBeDefined();
+ expect(this.store.groups.length).toEqual(1);
+ expect(this.store.groups[0].metrics.length).toEqual(1);
+ });
+
+ it('gets the metrics count for every group', () => {
+ expect(this.store.getMetricsCount()).toEqual(2);
+ });
+
+ it('contains deployment data', () => {
+ this.store.storeDeploymentData(deploymentData);
+ expect(this.store.deploymentData).toBeDefined();
+ expect(this.store.deploymentData.length).toEqual(3);
+ expect(typeof this.store.deploymentData[0]).toEqual('object');
+ });
+});
diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js
deleted file mode 100644
index 4b904fc2960..00000000000
--- a/spec/javascripts/monitoring/prometheus_graph_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import 'jquery';
-import PrometheusGraph from '~/monitoring/prometheus_graph';
-import { prometheusMockData } from './prometheus_mock_data';
-
-describe('PrometheusGraph', () => {
- const fixtureName = 'static/environments/metrics.html.raw';
- const prometheusGraphContainer = '.prometheus-graph';
- const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`;
-
- preloadFixtures(fixtureName);
-
- beforeEach(() => {
- loadFixtures(fixtureName);
- $('.prometheus-container').data('has-metrics', 'true');
- this.prometheusGraph = new PrometheusGraph();
- const self = this;
- const fakeInit = (metricsResponse) => {
- self.prometheusGraph.transformData(metricsResponse);
- self.prometheusGraph.createGraph();
- };
- spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit);
- });
-
- it('initializes graph properties', () => {
- // Test for the measurements
- expect(this.prometheusGraph.margin).toBeDefined();
- expect(this.prometheusGraph.marginLabelContainer).toBeDefined();
- expect(this.prometheusGraph.originalWidth).toBeDefined();
- expect(this.prometheusGraph.originalHeight).toBeDefined();
- expect(this.prometheusGraph.height).toBeDefined();
- expect(this.prometheusGraph.width).toBeDefined();
- expect(this.prometheusGraph.backOffRequestCounter).toBeDefined();
- // Test for the graph properties (colors, radius, etc.)
- expect(this.prometheusGraph.graphSpecificProperties).toBeDefined();
- expect(this.prometheusGraph.commonGraphProperties).toBeDefined();
- });
-
- it('transforms the data', () => {
- this.prometheusGraph.init(prometheusMockData.metrics);
- Object.keys(this.prometheusGraph.graphSpecificProperties, (key) => {
- const graphProps = this.prometheusGraph.graphSpecificProperties[key];
- expect(graphProps.data).toBeDefined();
- expect(graphProps.data.length).toBe(121);
- });
- });
-
- it('creates two graphs', () => {
- this.prometheusGraph.init(prometheusMockData.metrics);
- expect($(prometheusGraphContainer).length).toBe(2);
- });
-
- describe('Graph contents', () => {
- beforeEach(() => {
- this.prometheusGraph.init(prometheusMockData.metrics);
- });
-
- it('has axis, an area, a line and a overlay', () => {
- const $graphContainer = $(prometheusGraphContents).find('.x-axis').parent();
- expect($graphContainer.find('.x-axis')).toBeDefined();
- expect($graphContainer.find('.y-axis')).toBeDefined();
- expect($graphContainer.find('.prometheus-graph-overlay')).toBeDefined();
- expect($graphContainer.find('.metric-line')).toBeDefined();
- expect($graphContainer.find('.metric-area')).toBeDefined();
- });
-
- it('has legends, labels and an extra axis that labels the metrics', () => {
- const $prometheusGraphContents = $(prometheusGraphContents);
- const $axisLabelContainer = $(prometheusGraphContents).find('.label-x-axis-line').parent();
- expect($prometheusGraphContents.find('.label-x-axis-line')).toBeDefined();
- expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined();
- expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined();
- expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined();
- expect($axisLabelContainer.find('rect').length).toBe(3);
- expect($axisLabelContainer.find('text').length).toBe(4);
- });
- });
-});
-
-describe('PrometheusGraphs UX states', () => {
- const fixtureName = 'static/environments/metrics.html.raw';
- preloadFixtures(fixtureName);
-
- beforeEach(() => {
- loadFixtures(fixtureName);
- this.prometheusGraph = new PrometheusGraph();
- });
-
- it('shows a specified state', () => {
- this.prometheusGraph.state = '.js-getting-started';
- this.prometheusGraph.updateState();
- const $state = $('.js-getting-started');
- expect($state).toBeDefined();
- expect($('.state-title', $state)).toBeDefined();
- expect($('.state-svg', $state)).toBeDefined();
- expect($('.state-description', $state)).toBeDefined();
- expect($('.state-button', $state)).toBeDefined();
- });
-});
diff --git a/spec/javascripts/monitoring/prometheus_mock_data.js b/spec/javascripts/monitoring/prometheus_mock_data.js
deleted file mode 100644
index 1cdc14faaa8..00000000000
--- a/spec/javascripts/monitoring/prometheus_mock_data.js
+++ /dev/null
@@ -1,1014 +0,0 @@
-/* eslint-disable import/prefer-default-export*/
-export const prometheusMockData = {
- status: 200,
- metrics: {
- success: true,
- metrics: {
- memory_values: [
- {
- metric: {
- },
- values: [
- [
- 1488462917.256,
- '10.12890625',
- ],
- [
- 1488462977.256,
- '10.140625',
- ],
- [
- 1488463037.256,
- '10.140625',
- ],
- [
- 1488463097.256,
- '10.14453125',
- ],
- [
- 1488463157.256,
- '10.1484375',
- ],
- [
- 1488463217.256,
- '10.15625',
- ],
- [
- 1488463277.256,
- '10.15625',
- ],
- [
- 1488463337.256,
- '10.15625',
- ],
- [
- 1488463397.256,
- '10.1640625',
- ],
- [
- 1488463457.256,
- '10.171875',
- ],
- [
- 1488463517.256,
- '10.171875',
- ],
- [
- 1488463577.256,
- '10.171875',
- ],
- [
- 1488463637.256,
- '10.18359375',
- ],
- [
- 1488463697.256,
- '10.1953125',
- ],
- [
- 1488463757.256,
- '10.203125',
- ],
- [
- 1488463817.256,
- '10.20703125',
- ],
- [
- 1488463877.256,
- '10.20703125',
- ],
- [
- 1488463937.256,
- '10.20703125',
- ],
- [
- 1488463997.256,
- '10.20703125',
- ],
- [
- 1488464057.256,
- '10.2109375',
- ],
- [
- 1488464117.256,
- '10.2109375',
- ],
- [
- 1488464177.256,
- '10.2109375',
- ],
- [
- 1488464237.256,
- '10.2109375',
- ],
- [
- 1488464297.256,
- '10.21484375',
- ],
- [
- 1488464357.256,
- '10.22265625',
- ],
- [
- 1488464417.256,
- '10.22265625',
- ],
- [
- 1488464477.256,
- '10.2265625',
- ],
- [
- 1488464537.256,
- '10.23046875',
- ],
- [
- 1488464597.256,
- '10.23046875',
- ],
- [
- 1488464657.256,
- '10.234375',
- ],
- [
- 1488464717.256,
- '10.234375',
- ],
- [
- 1488464777.256,
- '10.234375',
- ],
- [
- 1488464837.256,
- '10.234375',
- ],
- [
- 1488464897.256,
- '10.234375',
- ],
- [
- 1488464957.256,
- '10.234375',
- ],
- [
- 1488465017.256,
- '10.23828125',
- ],
- [
- 1488465077.256,
- '10.23828125',
- ],
- [
- 1488465137.256,
- '10.2421875',
- ],
- [
- 1488465197.256,
- '10.2421875',
- ],
- [
- 1488465257.256,
- '10.2421875',
- ],
- [
- 1488465317.256,
- '10.2421875',
- ],
- [
- 1488465377.256,
- '10.2421875',
- ],
- [
- 1488465437.256,
- '10.2421875',
- ],
- [
- 1488465497.256,
- '10.2421875',
- ],
- [
- 1488465557.256,
- '10.2421875',
- ],
- [
- 1488465617.256,
- '10.2421875',
- ],
- [
- 1488465677.256,
- '10.2421875',
- ],
- [
- 1488465737.256,
- '10.2421875',
- ],
- [
- 1488465797.256,
- '10.24609375',
- ],
- [
- 1488465857.256,
- '10.25',
- ],
- [
- 1488465917.256,
- '10.25390625',
- ],
- [
- 1488465977.256,
- '9.98828125',
- ],
- [
- 1488466037.256,
- '9.9921875',
- ],
- [
- 1488466097.256,
- '9.9921875',
- ],
- [
- 1488466157.256,
- '9.99609375',
- ],
- [
- 1488466217.256,
- '10',
- ],
- [
- 1488466277.256,
- '10.00390625',
- ],
- [
- 1488466337.256,
- '10.0078125',
- ],
- [
- 1488466397.256,
- '10.01171875',
- ],
- [
- 1488466457.256,
- '10.0234375',
- ],
- [
- 1488466517.256,
- '10.02734375',
- ],
- [
- 1488466577.256,
- '10.02734375',
- ],
- [
- 1488466637.256,
- '10.03125',
- ],
- [
- 1488466697.256,
- '10.03125',
- ],
- [
- 1488466757.256,
- '10.03125',
- ],
- [
- 1488466817.256,
- '10.03125',
- ],
- [
- 1488466877.256,
- '10.03125',
- ],
- [
- 1488466937.256,
- '10.03125',
- ],
- [
- 1488466997.256,
- '10.03125',
- ],
- [
- 1488467057.256,
- '10.0390625',
- ],
- [
- 1488467117.256,
- '10.0390625',
- ],
- [
- 1488467177.256,
- '10.04296875',
- ],
- [
- 1488467237.256,
- '10.05078125',
- ],
- [
- 1488467297.256,
- '10.05859375',
- ],
- [
- 1488467357.256,
- '10.06640625',
- ],
- [
- 1488467417.256,
- '10.06640625',
- ],
- [
- 1488467477.256,
- '10.0703125',
- ],
- [
- 1488467537.256,
- '10.07421875',
- ],
- [
- 1488467597.256,
- '10.0859375',
- ],
- [
- 1488467657.256,
- '10.0859375',
- ],
- [
- 1488467717.256,
- '10.09765625',
- ],
- [
- 1488467777.256,
- '10.1015625',
- ],
- [
- 1488467837.256,
- '10.10546875',
- ],
- [
- 1488467897.256,
- '10.10546875',
- ],
- [
- 1488467957.256,
- '10.125',
- ],
- [
- 1488468017.256,
- '10.13671875',
- ],
- [
- 1488468077.256,
- '10.1484375',
- ],
- [
- 1488468137.256,
- '10.15625',
- ],
- [
- 1488468197.256,
- '10.16796875',
- ],
- [
- 1488468257.256,
- '10.171875',
- ],
- [
- 1488468317.256,
- '10.171875',
- ],
- [
- 1488468377.256,
- '10.171875',
- ],
- [
- 1488468437.256,
- '10.171875',
- ],
- [
- 1488468497.256,
- '10.171875',
- ],
- [
- 1488468557.256,
- '10.171875',
- ],
- [
- 1488468617.256,
- '10.171875',
- ],
- [
- 1488468677.256,
- '10.17578125',
- ],
- [
- 1488468737.256,
- '10.17578125',
- ],
- [
- 1488468797.256,
- '10.265625',
- ],
- [
- 1488468857.256,
- '10.19921875',
- ],
- [
- 1488468917.256,
- '10.19921875',
- ],
- [
- 1488468977.256,
- '10.19921875',
- ],
- [
- 1488469037.256,
- '10.19921875',
- ],
- [
- 1488469097.256,
- '10.19921875',
- ],
- [
- 1488469157.256,
- '10.203125',
- ],
- [
- 1488469217.256,
- '10.43359375',
- ],
- [
- 1488469277.256,
- '10.20703125',
- ],
- [
- 1488469337.256,
- '10.2109375',
- ],
- [
- 1488469397.256,
- '10.22265625',
- ],
- [
- 1488469457.256,
- '10.21484375',
- ],
- [
- 1488469517.256,
- '10.21484375',
- ],
- [
- 1488469577.256,
- '10.21484375',
- ],
- [
- 1488469637.256,
- '10.22265625',
- ],
- [
- 1488469697.256,
- '10.234375',
- ],
- [
- 1488469757.256,
- '10.234375',
- ],
- [
- 1488469817.256,
- '10.234375',
- ],
- [
- 1488469877.256,
- '10.2421875',
- ],
- [
- 1488469937.256,
- '10.25',
- ],
- [
- 1488469997.256,
- '10.25390625',
- ],
- [
- 1488470057.256,
- '10.26171875',
- ],
- [
- 1488470117.256,
- '10.2734375',
- ],
- ],
- },
- ],
- memory_current: [
- {
- metric: {
- },
- value: [
- 1488470117.737,
- '10.2734375',
- ],
- },
- ],
- cpu_values: [
- {
- metric: {
- },
- values: [
- [
- 1488462918.15,
- '0.0002996458625058103',
- ],
- [
- 1488462978.15,
- '0.0002652382333333314',
- ],
- [
- 1488463038.15,
- '0.0003485461333333421',
- ],
- [
- 1488463098.15,
- '0.0003420421999999886',
- ],
- [
- 1488463158.15,
- '0.00023107150000001297',
- ],
- [
- 1488463218.15,
- '0.00030463981666664826',
- ],
- [
- 1488463278.15,
- '0.0002477177833333677',
- ],
- [
- 1488463338.15,
- '0.00026936656666665115',
- ],
- [
- 1488463398.15,
- '0.000406264750000022',
- ],
- [
- 1488463458.15,
- '0.00029592802026561453',
- ],
- [
- 1488463518.15,
- '0.00023426999683316343',
- ],
- [
- 1488463578.15,
- '0.0003057080666666915',
- ],
- [
- 1488463638.15,
- '0.0003408470500000149',
- ],
- [
- 1488463698.15,
- '0.00025497336666665166',
- ],
- [
- 1488463758.15,
- '0.0003009282833333534',
- ],
- [
- 1488463818.15,
- '0.0003119383499999924',
- ],
- [
- 1488463878.15,
- '0.00028719019999998705',
- ],
- [
- 1488463938.15,
- '0.000327864749999988',
- ],
- [
- 1488463998.15,
- '0.0002514917333333422',
- ],
- [
- 1488464058.15,
- '0.0003614651166666742',
- ],
- [
- 1488464118.15,
- '0.0003221668000000122',
- ],
- [
- 1488464178.15,
- '0.00023323083333330884',
- ],
- [
- 1488464238.15,
- '0.00028531499475009274',
- ],
- [
- 1488464298.15,
- '0.0002627695294921391',
- ],
- [
- 1488464358.15,
- '0.00027145463333333453',
- ],
- [
- 1488464418.15,
- '0.00025669488333335266',
- ],
- [
- 1488464478.15,
- '0.00022307761666665965',
- ],
- [
- 1488464538.15,
- '0.0003307265833333517',
- ],
- [
- 1488464598.15,
- '0.0002817050666666709',
- ],
- [
- 1488464658.15,
- '0.00022357458333332285',
- ],
- [
- 1488464718.15,
- '0.00032648590000000275',
- ],
- [
- 1488464778.15,
- '0.00028410750000000816',
- ],
- [
- 1488464838.15,
- '0.0003038076999999954',
- ],
- [
- 1488464898.15,
- '0.00037568226666667335',
- ],
- [
- 1488464958.15,
- '0.00020160354999999202',
- ],
- [
- 1488465018.15,
- '0.0003229403333333399',
- ],
- [
- 1488465078.15,
- '0.00033516069999999236',
- ],
- [
- 1488465138.15,
- '0.0003365978333333371',
- ],
- [
- 1488465198.15,
- '0.00020262178333331585',
- ],
- [
- 1488465258.15,
- '0.00040567498333331876',
- ],
- [
- 1488465318.15,
- '0.00029114155000001436',
- ],
- [
- 1488465378.15,
- '0.0002498841000000122',
- ],
- [
- 1488465438.15,
- '0.00027296763333331715',
- ],
- [
- 1488465498.15,
- '0.0002958794000000135',
- ],
- [
- 1488465558.15,
- '0.0002922354666666867',
- ],
- [
- 1488465618.15,
- '0.00034186624999999653',
- ],
- [
- 1488465678.15,
- '0.0003397984166666627',
- ],
- [
- 1488465738.15,
- '0.0002658284166666469',
- ],
- [
- 1488465798.15,
- '0.00026221139999999346',
- ],
- [
- 1488465858.15,
- '0.00029467960000001034',
- ],
- [
- 1488465918.15,
- '0.0002634141333333358',
- ],
- [
- 1488465978.15,
- '0.0003202958333333209',
- ],
- [
- 1488466038.15,
- '0.00037890760000000394',
- ],
- [
- 1488466098.15,
- '0.00023453356666666518',
- ],
- [
- 1488466158.15,
- '0.0002866827333333433',
- ],
- [
- 1488466218.15,
- '0.0003335935499999998',
- ],
- [
- 1488466278.15,
- '0.00022787131666666125',
- ],
- [
- 1488466338.15,
- '0.00033821938333333064',
- ],
- [
- 1488466398.15,
- '0.00029233375000001043',
- ],
- [
- 1488466458.15,
- '0.00026562758333333514',
- ],
- [
- 1488466518.15,
- '0.0003142600999999819',
- ],
- [
- 1488466578.15,
- '0.00027392178333333444',
- ],
- [
- 1488466638.15,
- '0.00028178598333334173',
- ],
- [
- 1488466698.15,
- '0.0002463400666666911',
- ],
- [
- 1488466758.15,
- '0.00040234373333332125',
- ],
- [
- 1488466818.15,
- '0.00023677453333332822',
- ],
- [
- 1488466878.15,
- '0.00030852703333333523',
- ],
- [
- 1488466938.15,
- '0.0003582272833333455',
- ],
- [
- 1488466998.15,
- '0.0002176380833332973',
- ],
- [
- 1488467058.15,
- '0.00026180203333335447',
- ],
- [
- 1488467118.15,
- '0.00027862966666667436',
- ],
- [
- 1488467178.15,
- '0.0002769731166666567',
- ],
- [
- 1488467238.15,
- '0.0002832899166666477',
- ],
- [
- 1488467298.15,
- '0.0003446533500000311',
- ],
- [
- 1488467358.15,
- '0.0002691345999999761',
- ],
- [
- 1488467418.15,
- '0.000284919933333357',
- ],
- [
- 1488467478.15,
- '0.0002396026166666528',
- ],
- [
- 1488467538.15,
- '0.00035625295000002075',
- ],
- [
- 1488467598.15,
- '0.00036759816666664946',
- ],
- [
- 1488467658.15,
- '0.00030326608333333855',
- ],
- [
- 1488467718.15,
- '0.00023584972418043393',
- ],
- [
- 1488467778.15,
- '0.00025744508892115107',
- ],
- [
- 1488467838.15,
- '0.00036737541666663395',
- ],
- [
- 1488467898.15,
- '0.00034325741666666094',
- ],
- [
- 1488467958.15,
- '0.00026390046666667407',
- ],
- [
- 1488468018.15,
- '0.0003302534500000102',
- ],
- [
- 1488468078.15,
- '0.00035243794999999527',
- ],
- [
- 1488468138.15,
- '0.00020149738333333407',
- ],
- [
- 1488468198.15,
- '0.0003183469666666679',
- ],
- [
- 1488468258.15,
- '0.0003835329166666845',
- ],
- [
- 1488468318.15,
- '0.0002485075333333124',
- ],
- [
- 1488468378.15,
- '0.0003011457166666768',
- ],
- [
- 1488468438.15,
- '0.00032242785497684965',
- ],
- [
- 1488468498.15,
- '0.0002659713747457531',
- ],
- [
- 1488468558.15,
- '0.0003476860333333202',
- ],
- [
- 1488468618.15,
- '0.00028336403333334794',
- ],
- [
- 1488468678.15,
- '0.00017132354999998728',
- ],
- [
- 1488468738.15,
- '0.0003001915833333276',
- ],
- [
- 1488468798.15,
- '0.0003025715666666725',
- ],
- [
- 1488468858.15,
- '0.0003012370166666815',
- ],
- [
- 1488468918.15,
- '0.00030203619999997025',
- ],
- [
- 1488468978.15,
- '0.0002804355000000314',
- ],
- [
- 1488469038.15,
- '0.00033194884999998564',
- ],
- [
- 1488469098.15,
- '0.00025201496666665455',
- ],
- [
- 1488469158.15,
- '0.0002777531500000189',
- ],
- [
- 1488469218.15,
- '0.0003314885833333392',
- ],
- [
- 1488469278.15,
- '0.0002234891422095589',
- ],
- [
- 1488469338.15,
- '0.000349117355867791',
- ],
- [
- 1488469398.15,
- '0.0004036731333333303',
- ],
- [
- 1488469458.15,
- '0.00024553911666667835',
- ],
- [
- 1488469518.15,
- '0.0003056456833333184',
- ],
- [
- 1488469578.15,
- '0.0002618737166666681',
- ],
- [
- 1488469638.15,
- '0.00022972643333331414',
- ],
- [
- 1488469698.15,
- '0.0003713522500000307',
- ],
- [
- 1488469758.15,
- '0.00018322576666666515',
- ],
- [
- 1488469818.15,
- '0.00034534762753952466',
- ],
- [
- 1488469878.15,
- '0.00028200510008501677',
- ],
- [
- 1488469938.15,
- '0.0002773708499999768',
- ],
- [
- 1488469998.15,
- '0.00027547160000001013',
- ],
- [
- 1488470058.15,
- '0.00031713610000000023',
- ],
- [
- 1488470118.15,
- '0.00035276853333332525',
- ],
- ],
- },
- ],
- cpu_current: [
- {
- metric: {
- },
- value: [
- 1488470118.566,
- '0.00035276853333332525',
- ],
- },
- ],
- last_update: '2017-03-02T15:55:18.981Z',
- },
- },
-};
diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js
index 90a429beeca..c57f44dae17 100644
--- a/spec/javascripts/new_branch_spec.js
+++ b/spec/javascripts/new_branch_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable space-before-function-paren, one-var, no-var, one-var-declaration-per-line, no-return-assign, quotes, max-len */
/* global NewBranchForm */
-require('~/new_branch_form');
+import '~/new_branch_form';
(function() {
describe('Branch', function() {
diff --git a/spec/javascripts/notebook/cells/markdown_spec.js b/spec/javascripts/notebook/cells/markdown_spec.js
index 38c976f38d8..a88e9ed3d99 100644
--- a/spec/javascripts/notebook/cells/markdown_spec.js
+++ b/spec/javascripts/notebook/cells/markdown_spec.js
@@ -1,8 +1,11 @@
import Vue from 'vue';
import MarkdownComponent from '~/notebook/cells/markdown.vue';
+import katex from 'vendor/katex';
const Component = Vue.extend(MarkdownComponent);
+window.katex = katex;
+
describe('Markdown component', () => {
let vm;
let cell;
@@ -38,4 +41,58 @@ describe('Markdown component', () => {
it('renders the markdown HTML', () => {
expect(vm.$el.querySelector('.markdown h1')).not.toBeNull();
});
+
+ describe('katex', () => {
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/math.json');
+ });
+
+ it('renders multi-line katex', (done) => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[0],
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.katex'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('renders inline katex', (done) => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('p:first-child .katex'),
+ ).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('renders multiple inline katex', (done) => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('p:nth-child(2) .katex').length,
+ ).toBe(4);
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index ca8ee04d955..2c096ed08a8 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -1,17 +1,37 @@
/* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */
/* global Notes */
-require('~/notes');
-require('vendor/autosize');
-require('~/gl_form');
-require('~/lib/utils/text_utility');
+import 'vendor/autosize';
+import '~/gl_form';
+import '~/lib/utils/text_utility';
+import '~/render_gfm';
+import '~/render_math';
+import '~/notes';
(function() {
window.gon || (window.gon = {});
window.gl = window.gl || {};
gl.utils = gl.utils || {};
+ const htmlEscape = (comment) => {
+ const escapedString = comment.replace(/["&'<>]/g, (a) => {
+ const escapedToken = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ "'": '&#x27;',
+ '`': '&#x60;'
+ }[a];
+
+ return escapedToken;
+ });
+
+ return escapedString;
+ };
+
describe('Notes', function() {
+ const FLASH_TYPE_ALERT = 'alert';
var commentsTemplate = 'issues/issue_with_comment.html.raw';
preloadFixtures(commentsTemplate);
@@ -24,14 +44,16 @@ require('~/lib/utils/text_utility');
describe('task lists', function() {
beforeEach(function() {
- $('form').on('submit', function(e) {
+ $('.js-comment-button').on('click', function(e) {
e.preventDefault();
});
- this.notes = new Notes();
+ this.notes = new Notes('', []);
});
it('modifies the Markdown field', function() {
- $('input[type=checkbox]').attr('checked', true).trigger('change');
+ const changeEvent = document.createEvent('HTMLEvents');
+ changeEvent.initEvent('change', true, true);
+ $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent);
expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
});
@@ -49,7 +71,7 @@ require('~/lib/utils/text_utility');
var textarea = '.js-note-text';
beforeEach(function() {
- this.notes = new Notes();
+ this.notes = new Notes('', []);
this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update');
spyOn(this.notes, 'renderNote').and.stub();
@@ -58,9 +80,12 @@ require('~/lib/utils/text_utility');
reset: function() {}
});
- $('form').on('submit', function(e) {
+ $('.js-comment-button').on('click', (e) => {
+ const $form = $(this);
e.preventDefault();
- $('.js-main-target-form').trigger('ajax:success');
+ this.notes.addNote($form);
+ this.notes.reenableTargetFormSubmitButton(e);
+ this.notes.resetMainTargetForm(e);
});
});
@@ -73,6 +98,88 @@ require('~/lib/utils/text_utility');
});
});
+ describe('updateNote', () => {
+ let sampleComment;
+ let noteEntity;
+ let $form;
+ let $notesContainer;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ window.gon.current_username = 'root';
+ window.gon.current_user_fullname = 'Administrator';
+ sampleComment = 'foo';
+ noteEntity = {
+ id: 1234,
+ html: `<li class="note note-row-1234 timeline-entry" id="note_1234">
+ <div class="note-text">${sampleComment}</div>
+ </li>`,
+ note: sampleComment,
+ valid: true
+ };
+ $form = $('form.js-main-target-form');
+ $notesContainer = $('ul.main-notes-list');
+ $form.find('textarea.js-note-text').val(sampleComment);
+ });
+
+ it('updates note and resets edit form', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ spyOn(this.notes, 'revertNoteEditForm');
+ spyOn(this.notes, 'setupNewNote');
+
+ $('.js-comment-button').click();
+ deferred.resolve(noteEntity);
+
+ const $targetNote = $notesContainer.find(`#note_${noteEntity.id}`);
+ const updatedNote = Object.assign({}, noteEntity);
+ updatedNote.note = 'bar';
+ this.notes.updateNote(updatedNote, $targetNote);
+
+ expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith($targetNote);
+ expect(this.notes.setupNewNote).toHaveBeenCalled();
+ });
+ });
+
+ describe('updateNoteTargetSelector', () => {
+ const hash = 'note_foo';
+ let $note;
+
+ beforeEach(() => {
+ $note = $(`<div id="${hash}"></div>`);
+ spyOn($note, 'filter').and.callThrough();
+ spyOn($note, 'toggleClass').and.callThrough();
+ });
+
+ it('sets target when hash matches', () => {
+ spyOn(gl.utils, 'getLocationHash');
+ gl.utils.getLocationHash.and.returnValue(hash);
+
+ Notes.updateNoteTargetSelector($note);
+
+ expect($note.filter).toHaveBeenCalledWith(`#${hash}`);
+ expect($note.toggleClass).toHaveBeenCalledWith('target', true);
+ });
+
+ it('unsets target when hash does not match', () => {
+ spyOn(gl.utils, 'getLocationHash');
+ gl.utils.getLocationHash.and.returnValue('note_doesnotexist');
+
+ Notes.updateNoteTargetSelector($note);
+
+ expect($note.toggleClass).toHaveBeenCalledWith('target', false);
+ });
+
+ it('unsets target when there is not a hash fragment anymore', () => {
+ spyOn(gl.utils, 'getLocationHash');
+ gl.utils.getLocationHash.and.returnValue(null);
+
+ Notes.updateNoteTargetSelector($note);
+
+ expect($note.toggleClass).toHaveBeenCalledWith('target', false);
+ });
+ });
+
describe('renderNote', () => {
let notes;
let note;
@@ -80,35 +187,126 @@ require('~/lib/utils/text_utility');
beforeEach(() => {
note = {
- discussion_html: null,
+ id: 1,
valid: true,
- html: '<div></div>',
+ note: 'heya',
+ html: '<div>heya</div>',
};
- $notesList = jasmine.createSpyObj('$notesList', ['find']);
+ $notesList = jasmine.createSpyObj('$notesList', [
+ 'find',
+ 'append',
+ ]);
notes = jasmine.createSpyObj('notes', [
+ 'setupNewNote',
'refresh',
- 'isNewNote',
'collapseLongCommitList',
'updateNotesCount',
+ 'putConflictEditWarningInPlace'
]);
notes.taskList = jasmine.createSpyObj('tasklist', ['init']);
notes.note_ids = [];
+ notes.updatedNotesTrackingMap = {};
- spyOn(window, '$').and.returnValue($notesList);
spyOn(gl.utils, 'localTimeAgo');
- spyOn(Notes, 'animateAppendNote');
- notes.isNewNote.and.returnValue(true);
+ spyOn(Notes, 'isNewNote').and.callThrough();
+ spyOn(Notes, 'isUpdatedNote').and.callThrough();
+ spyOn(Notes, 'animateAppendNote').and.callThrough();
+ spyOn(Notes, 'animateUpdateNote').and.callThrough();
+ });
- Notes.prototype.renderNote.call(notes, note);
+ describe('when adding note', () => {
+ it('should call .animateAppendNote', () => {
+ Notes.isNewNote.and.returnValue(true);
+ Notes.prototype.renderNote.call(notes, note, null, $notesList);
+
+ expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList);
+ });
});
- it('should query for the notes list', () => {
- expect(window.$).toHaveBeenCalledWith('ul.main-notes-list');
+ describe('when note was edited', () => {
+ it('should call .animateUpdateNote', () => {
+ Notes.isNewNote.and.returnValue(false);
+ Notes.isUpdatedNote.and.returnValue(true);
+ const $note = $('<div>');
+ $notesList.find.and.returnValue($note);
+ const $newNote = $(note.html);
+ Notes.animateUpdateNote.and.returnValue($newNote);
+
+ Notes.prototype.renderNote.call(notes, note, null, $notesList);
+
+ expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note);
+ expect(notes.setupNewNote).toHaveBeenCalledWith($newNote);
+ });
+
+ describe('while editing', () => {
+ it('should update textarea if nothing has been touched', () => {
+ Notes.isNewNote.and.returnValue(false);
+ Notes.isUpdatedNote.and.returnValue(true);
+ const $note = $(`<div class="is-editing">
+ <div class="original-note-content">initial</div>
+ <textarea class="js-note-text">initial</textarea>
+ </div>`);
+ $notesList.find.and.returnValue($note);
+ Notes.prototype.renderNote.call(notes, note, null, $notesList);
+
+ expect($note.find('.js-note-text').val()).toEqual(note.note);
+ });
+
+ it('should call .putConflictEditWarningInPlace', () => {
+ Notes.isNewNote.and.returnValue(false);
+ Notes.isUpdatedNote.and.returnValue(true);
+ const $note = $(`<div class="is-editing">
+ <div class="original-note-content">initial</div>
+ <textarea class="js-note-text">different</textarea>
+ </div>`);
+ $notesList.find.and.returnValue($note);
+ Notes.prototype.renderNote.call(notes, note, null, $notesList);
+
+ expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note);
+ });
+ });
+ });
+ });
+
+ describe('isUpdatedNote', () => {
+ it('should consider same note text as the same', () => {
+ const result = Notes.isUpdatedNote(
+ {
+ note: 'initial'
+ },
+ $(`<div>
+ <div class="original-note-content">initial</div>
+ </div>`)
+ );
+
+ expect(result).toEqual(false);
});
- it('should call .animateAppendNote', () => {
- expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList);
+ it('should consider same note with trailing newline as the same', () => {
+ const result = Notes.isUpdatedNote(
+ {
+ note: 'initial\n'
+ },
+ $(`<div>
+ <div class="original-note-content">initial\n</div>
+ </div>`)
+ );
+
+ expect(result).toEqual(false);
+ });
+
+ it('should consider different notes as different', () => {
+ const result = Notes.isUpdatedNote(
+ {
+ note: 'foo'
+ },
+ $(`<div>
+ <div class="original-note-content">bar</div>
+ </div>`)
+ );
+
+ expect(result).toEqual(true);
});
});
@@ -131,15 +329,15 @@ require('~/lib/utils/text_utility');
row = jasmine.createSpyObj('row', ['prevAll', 'first', 'find']);
notes = jasmine.createSpyObj('notes', [
- 'isNewNote',
'isParallelView',
'updateNotesCount',
]);
notes.note_ids = [];
spyOn(gl.utils, 'localTimeAgo');
+ spyOn(Notes, 'isNewNote');
spyOn(Notes, 'animateAppendNote');
- notes.isNewNote.and.returnValue(true);
+ Notes.isNewNote.and.returnValue(true);
notes.isParallelView.and.returnValue(false);
row.prevAll.and.returnValue(row);
row.first.and.returnValue(row);
@@ -147,14 +345,12 @@ require('~/lib/utils/text_utility');
});
describe('Discussion root note', () => {
- let $notesList;
let body;
beforeEach(() => {
body = jasmine.createSpyObj('body', ['attr']);
discussionContainer = { length: 0 };
- spyOn(window, '$').and.returnValues(discussionContainer, body, $notesList);
$form.closest.and.returnValues(row, $form);
$form.find.and.returnValues(discussionContainer);
body.attr.and.returnValue('');
@@ -162,12 +358,8 @@ require('~/lib/utils/text_utility');
Notes.prototype.renderDiscussionNote.call(notes, note, $form);
});
- it('should query for the notes list', () => {
- expect(window.$.calls.argsFor(2)).toEqual(['ul.main-notes-list']);
- });
-
it('should call Notes.animateAppendNote', () => {
- expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $notesList);
+ expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list'));
});
});
@@ -175,16 +367,12 @@ require('~/lib/utils/text_utility');
beforeEach(() => {
discussionContainer = { length: 1 };
- spyOn(window, '$').and.returnValues(discussionContainer);
- $form.closest.and.returnValues(row);
+ $form.closest.and.returnValues(row, $form);
+ $form.find.and.returnValues(discussionContainer);
Notes.prototype.renderDiscussionNote.call(notes, note, $form);
});
- it('should query foor the discussion container', () => {
- expect(window.$).toHaveBeenCalledWith(`.notes[data-discussion-id="${note.discussion_id}"]`);
- });
-
it('should call Notes.animateAppendNote', () => {
expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer);
});
@@ -193,35 +381,442 @@ require('~/lib/utils/text_utility');
describe('animateAppendNote', () => {
let noteHTML;
- let $note;
let $notesList;
+ let $resultantNote;
beforeEach(() => {
noteHTML = '<div></div>';
- $note = jasmine.createSpyObj('$note', ['addClass', 'renderGFM', 'removeClass']);
$notesList = jasmine.createSpyObj('$notesList', ['append']);
- spyOn(window, '$').and.returnValue($note);
- spyOn(window, 'setTimeout').and.callThrough();
- $note.addClass.and.returnValue($note);
- $note.renderGFM.and.returnValue($note);
+ $resultantNote = Notes.animateAppendNote(noteHTML, $notesList);
+ });
+
+ it('should have `fade-in-full` class', () => {
+ expect($resultantNote.hasClass('fade-in-full')).toEqual(true);
+ });
+
+ it('should append note to the notes list', () => {
+ expect($notesList.append).toHaveBeenCalledWith($resultantNote);
+ });
+ });
+
+ describe('animateUpdateNote', () => {
+ let noteHTML;
+ let $note;
+ let $updatedNote;
+
+ beforeEach(() => {
+ noteHTML = '<div></div>';
+ $note = jasmine.createSpyObj('$note', [
+ 'replaceWith'
+ ]);
- Notes.animateAppendNote(noteHTML, $notesList);
+ $updatedNote = Notes.animateUpdateNote(noteHTML, $note);
});
- it('should init the note jquery object', () => {
- expect(window.$).toHaveBeenCalledWith(noteHTML);
+ it('should have `fade-in` class', () => {
+ expect($updatedNote.hasClass('fade-in')).toEqual(true);
});
- it('should call addClass', () => {
- expect($note.addClass).toHaveBeenCalledWith('fade-in');
+ it('should call replaceWith on $note', () => {
+ expect($note.replaceWith).toHaveBeenCalledWith($updatedNote);
});
- it('should call renderGFM', () => {
- expect($note.renderGFM).toHaveBeenCalledWith();
+ });
+
+ describe('putEditFormInPlace', () => {
+ it('should call gl.GLForm with GFM parameter passed through', () => {
+ spyOn(gl, 'GLForm');
+
+ const $el = jasmine.createSpyObj('$form', ['find', 'closest']);
+ $el.find.and.returnValue($('<div>'));
+ $el.closest.and.returnValue($('<div>'));
+
+ Notes.prototype.putEditFormInPlace.call({
+ getEditFormSelector: () => '',
+ enableGFM: true
+ }, $el);
+
+ expect(gl.GLForm).toHaveBeenCalledWith(jasmine.any(Object), true);
});
+ });
- it('should append note to the notes list', () => {
- expect($notesList.append).toHaveBeenCalledWith($note);
+ describe('postComment & updateComment', () => {
+ const sampleComment = 'foo';
+ const updatedComment = 'bar';
+ const note = {
+ id: 1234,
+ html: `<li class="note note-row-1234 timeline-entry" id="note_1234">
+ <div class="note-text">${sampleComment}</div>
+ </li>`,
+ note: sampleComment,
+ valid: true
+ };
+ let $form;
+ let $notesContainer;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ window.gon.current_username = 'root';
+ window.gon.current_user_fullname = 'Administrator';
+ $form = $('form.js-main-target-form');
+ $notesContainer = $('ul.main-notes-list');
+ $form.find('textarea.js-note-text').val(sampleComment);
+ });
+
+ it('should show placeholder note while new comment is being posted', () => {
+ $('.js-comment-button').click();
+ expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true);
+ });
+
+ it('should remove placeholder note when new comment is done posting', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ expect($notesContainer.find('.note.being-posted').length).toEqual(0);
+ });
+
+ it('should show actual note element when new comment is done posting', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true);
+ });
+
+ it('should reset Form when new comment is done posting', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ expect($form.find('textarea.js-note-text').val()).toEqual('');
+ });
+
+ it('should show flash error message when new comment failed to be posted', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.reject();
+ expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true);
+ });
+
+ it('should show flash error message when comment failed to be updated', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ const $noteEl = $notesContainer.find(`#note_${note.id}`);
+ $noteEl.find('.js-note-edit').click();
+ $noteEl.find('textarea.js-note-text').val(updatedComment);
+ $noteEl.find('.js-comment-save-button').click();
+
+ deferred.reject();
+ const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`);
+ expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals
+ expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original
+ expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown
+ });
+ });
+
+ describe('postComment with Slash commands', () => {
+ const sampleComment = '/assign @root\n/award :100:';
+ const note = {
+ commands_changes: {
+ assignee_id: 1,
+ emoji_award: '100'
+ },
+ errors: {
+ commands_only: ['Commands applied']
+ },
+ valid: false
+ };
+ let $form;
+ let $notesContainer;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ window.gon.current_username = 'root';
+ window.gon.current_user_fullname = 'Administrator';
+ gl.awardsHandler = {
+ addAwardToEmojiBar: () => {},
+ scrollToAwards: () => {}
+ };
+ gl.GfmAutoComplete = {
+ dataSources: {
+ commands: '/root/test-project/autocomplete_sources/commands'
+ }
+ };
+ $form = $('form.js-main-target-form');
+ $notesContainer = $('ul.main-notes-list');
+ $form.find('textarea.js-note-text').val(sampleComment);
+ });
+
+ it('should remove slash command placeholder when comment with slash commands is done posting', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ spyOn(gl.awardsHandler, 'addAwardToEmojiBar').and.callThrough();
+ $('.js-comment-button').click();
+
+ expect($notesContainer.find('.system-note.being-posted').length).toEqual(1); // Placeholder shown
+ deferred.resolve(note);
+ expect($notesContainer.find('.system-note.being-posted').length).toEqual(0); // Placeholder removed
+ });
+ });
+
+ describe('update comment with script tags', () => {
+ const sampleComment = '<script></script>';
+ const updatedComment = '<script></script>';
+ const note = {
+ id: 1234,
+ html: `<li class="note note-row-1234 timeline-entry" id="note_1234">
+ <div class="note-text">${sampleComment}</div>
+ </li>`,
+ note: sampleComment,
+ valid: true
+ };
+ let $form;
+ let $notesContainer;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ window.gon.current_username = 'root';
+ window.gon.current_user_fullname = 'Administrator';
+ $form = $('form.js-main-target-form');
+ $notesContainer = $('ul.main-notes-list');
+ $form.find('textarea.js-note-text').html(sampleComment);
+ });
+
+ it('should not render a script tag', () => {
+ const deferred = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(deferred.promise());
+ $('.js-comment-button').click();
+
+ deferred.resolve(note);
+ const $noteEl = $notesContainer.find(`#note_${note.id}`);
+ $noteEl.find('.js-note-edit').click();
+ $noteEl.find('textarea.js-note-text').html(updatedComment);
+ $noteEl.find('.js-comment-save-button').click();
+
+ const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`).find('.js-task-list-container');
+ expect($updatedNoteEl.find('.note-text').text().trim()).toEqual('');
+ });
+ });
+
+ describe('getFormData', () => {
+ let $form;
+ let sampleComment;
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+
+ $form = $('form');
+ sampleComment = 'foobar';
+ });
+
+ it('should return form metadata object from form reference', () => {
+ $form.find('textarea.js-note-text').val(sampleComment);
+ const { formData, formContent, formAction } = this.notes.getFormData($form);
+
+ expect(formData.indexOf(sampleComment) > -1).toBe(true);
+ expect(formContent).toEqual(sampleComment);
+ expect(formAction).toEqual($form.attr('action'));
+ });
+
+ it('should return form metadata with sanitized formContent from form reference', () => {
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+
+ sampleComment = '<script>alert("Boom!");</script>';
+ $form.find('textarea.js-note-text').val(sampleComment);
+
+ const { formContent } = this.notes.getFormData($form);
+
+ expect(_.escape).toHaveBeenCalledWith(sampleComment);
+ expect(formContent).toEqual('&lt;script&gt;alert(&quot;Boom!&quot;);&lt;/script&gt;');
+ });
+ });
+
+ describe('hasQuickActions', () => {
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ });
+
+ it('should return true when comment begins with a quick action', () => {
+ const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this';
+ const hasQuickActions = this.notes.hasQuickActions(sampleComment);
+
+ expect(hasQuickActions).toBeTruthy();
+ });
+
+ it('should return false when comment does NOT begin with a quick action', () => {
+ const sampleComment = 'Hey, /unassign Merging this';
+ const hasQuickActions = this.notes.hasQuickActions(sampleComment);
+
+ expect(hasQuickActions).toBeFalsy();
+ });
+
+ it('should return false when comment does NOT have any quick actions', () => {
+ const sampleComment = 'Looking good, Awesome!';
+ const hasQuickActions = this.notes.hasQuickActions(sampleComment);
+
+ expect(hasQuickActions).toBeFalsy();
+ });
+ });
+
+ describe('stripQuickActions', () => {
+ it('should strip quick actions from the comment which begins with a quick action', () => {
+ this.notes = new Notes();
+ const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this';
+ const stripedComment = this.notes.stripQuickActions(sampleComment);
+
+ expect(stripedComment).toBe('');
+ });
+
+ it('should strip quick actions from the comment but leaves plain comment if it is present', () => {
+ this.notes = new Notes();
+ const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this';
+ const stripedComment = this.notes.stripQuickActions(sampleComment);
+
+ expect(stripedComment).toBe('Merging this');
+ });
+
+ it('should NOT strip string that has slashes within', () => {
+ this.notes = new Notes();
+ const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1';
+ const stripedComment = this.notes.stripQuickActions(sampleComment);
+
+ expect(stripedComment).toBe(sampleComment);
+ });
+ });
+
+ describe('getQuickActionDescription', () => {
+ const availableQuickActions = [
+ { name: 'close', description: 'Close this issue', params: [] },
+ { name: 'title', description: 'Change title', params: [{}] },
+ { name: 'estimate', description: 'Set time estimate', params: [{}] }
+ ];
+
+ beforeEach(() => {
+ this.notes = new Notes();
+ });
+
+ it('should return executing quick action description when note has single quick action', () => {
+ const sampleComment = '/close';
+ expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying command to close this issue');
+ });
+
+ it('should return generic multiple quick action description when note has multiple quick actions', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying multiple commands');
+ });
+
+ it('should return generic quick action description when available quick actions list is not populated', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getQuickActionDescription(sampleComment)).toBe('Applying command');
+ });
+ });
+
+ describe('createPlaceholderNote', () => {
+ const sampleComment = 'foobar';
+ const uniqueId = 'b1234-a4567';
+ const currentUsername = 'root';
+ const currentUserFullname = 'Administrator';
+ const currentUserAvatar = 'avatar_url';
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ });
+
+ it('should return constructed placeholder element for regular note based on form contents', () => {
+ const $tempNote = this.notes.createPlaceholderNote({
+ formContent: sampleComment,
+ uniqueId,
+ isDiscussionNote: false,
+ currentUsername,
+ currentUserFullname,
+ currentUserAvatar,
+ });
+ const $tempNoteHeader = $tempNote.find('.note-header');
+
+ expect($tempNote.prop('nodeName')).toEqual('LI');
+ expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
+ $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() {
+ expect($(this).attr('href')).toEqual(`/${currentUsername}`);
+ });
+ expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar);
+ expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy();
+ expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname);
+ expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`);
+ expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment);
+ });
+
+ it('should return constructed placeholder element for discussion note based on form contents', () => {
+ const $tempNote = this.notes.createPlaceholderNote({
+ formContent: sampleComment,
+ uniqueId,
+ isDiscussionNote: true,
+ currentUsername,
+ currentUserFullname
+ });
+
+ expect($tempNote.prop('nodeName')).toEqual('LI');
+ expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
+ });
+ });
+
+ describe('createPlaceholderSystemNote', () => {
+ const sampleCommandDescription = 'Applying command to close this issue';
+ const uniqueId = 'b1234-a4567';
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+ });
+
+ it('should return constructed placeholder element for system note based on form contents', () => {
+ const $tempNote = this.notes.createPlaceholderSystemNote({
+ formContent: sampleCommandDescription,
+ uniqueId,
+ });
+
+ expect($tempNote.prop('nodeName')).toEqual('LI');
+ expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
+ expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription);
+ });
+ });
+
+ describe('appendFlash', () => {
+ beforeEach(() => {
+ this.notes = new Notes();
+ });
+
+ it('shows a flash message', () => {
+ this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline);
+
+ expect($('.flash-alert').is(':visible')).toBeTruthy();
+ });
+ });
+
+ describe('clearFlash', () => {
+ beforeEach(() => {
+ $(document).off('ajax:success');
+ this.notes = new Notes();
+ });
+
+ it('hides visible flash message', () => {
+ this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline);
+
+ this.notes.clearFlash();
+
+ expect($('.flash-alert').is(':visible')).toBeFalsy();
});
});
});
diff --git a/spec/javascripts/oauth_remember_me_spec.js b/spec/javascripts/oauth_remember_me_spec.js
new file mode 100644
index 00000000000..f90e0093d25
--- /dev/null
+++ b/spec/javascripts/oauth_remember_me_spec.js
@@ -0,0 +1,26 @@
+import OAuthRememberMe from '~/oauth_remember_me';
+
+describe('OAuthRememberMe', () => {
+ preloadFixtures('static/oauth_remember_me.html.raw');
+
+ beforeEach(() => {
+ loadFixtures('static/oauth_remember_me.html.raw');
+
+ new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents();
+ });
+
+ it('adds the "remember_me" query parameter to all OAuth login buttons', () => {
+ $('#oauth-container #remember_me').click();
+
+ expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/?remember_me=1');
+ expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/?remember_me=1');
+ });
+
+ it('removes the "remember_me" query parameter from all OAuth login buttons', () => {
+ $('#oauth-container #remember_me').click();
+ $('#oauth-container #remember_me').click();
+
+ expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/');
+ expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/');
+ });
+});
diff --git a/spec/javascripts/pager_spec.js b/spec/javascripts/pager_spec.js
index d966226909b..1d3e1263371 100644
--- a/spec/javascripts/pager_spec.js
+++ b/spec/javascripts/pager_spec.js
@@ -1,6 +1,6 @@
/* global fixture */
-require('~/pager');
+import '~/pager';
describe('pager', () => {
const Pager = window.Pager;
diff --git a/spec/javascripts/pdf/index_spec.js b/spec/javascripts/pdf/index_spec.js
new file mode 100644
index 00000000000..bebed432f91
--- /dev/null
+++ b/spec/javascripts/pdf/index_spec.js
@@ -0,0 +1,61 @@
+/* eslint-disable import/no-unresolved */
+
+import Vue from 'vue';
+import { PDFJS } from 'vendor/pdf';
+import workerSrc from 'vendor/pdf.worker.min';
+
+import PDFLab from '~/pdf/index.vue';
+import pdf from '../fixtures/blob/pdf/test.pdf';
+
+PDFJS.workerSrc = workerSrc;
+const Component = Vue.extend(PDFLab);
+
+describe('PDF component', () => {
+ let vm;
+
+ const checkLoaded = (done) => {
+ if (vm.loading) {
+ setTimeout(() => {
+ checkLoaded(done);
+ }, 100);
+ } else {
+ done();
+ }
+ };
+
+ describe('without PDF data', () => {
+ beforeEach((done) => {
+ vm = new Component({
+ propsData: {
+ pdf: '',
+ },
+ });
+
+ vm.$mount();
+
+ checkLoaded(done);
+ });
+
+ it('does not render', () => {
+ expect(vm.$el.tagName).toBeUndefined();
+ });
+ });
+
+ describe('with PDF data', () => {
+ beforeEach((done) => {
+ vm = new Component({
+ propsData: {
+ pdf,
+ },
+ });
+
+ vm.$mount();
+
+ checkLoaded(done);
+ });
+
+ it('renders pdf component', () => {
+ expect(vm.$el.tagName).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/pdf/page_spec.js b/spec/javascripts/pdf/page_spec.js
new file mode 100644
index 00000000000..ac5b21e8f6c
--- /dev/null
+++ b/spec/javascripts/pdf/page_spec.js
@@ -0,0 +1,57 @@
+/* eslint-disable import/no-unresolved */
+
+import Vue from 'vue';
+import pdfjsLib from 'vendor/pdf';
+import workerSrc from 'vendor/pdf.worker.min';
+
+import PageComponent from '~/pdf/page/index.vue';
+import testPDF from '../fixtures/blob/pdf/test.pdf';
+
+const Component = Vue.extend(PageComponent);
+
+describe('Page component', () => {
+ let vm;
+ let testPage;
+ pdfjsLib.PDFJS.workerSrc = workerSrc;
+
+ const checkRendered = (done) => {
+ if (vm.rendering) {
+ setTimeout(() => {
+ checkRendered(done);
+ }, 100);
+ } else {
+ done();
+ }
+ };
+
+ beforeEach((done) => {
+ pdfjsLib.getDocument(testPDF)
+ .then(pdf => pdf.getPage(1))
+ .then((page) => {
+ testPage = page;
+ done();
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ });
+
+ describe('render', () => {
+ beforeEach((done) => {
+ vm = new Component({
+ propsData: {
+ page: testPage,
+ number: 1,
+ },
+ });
+
+ vm.$mount();
+
+ checkRendered(done);
+ });
+
+ it('renders first page', () => {
+ expect(vm.$el.tagName).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js
new file mode 100644
index 00000000000..040d14efed2
--- /dev/null
+++ b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js
@@ -0,0 +1,178 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import IntervalPatternInput from '~/pipeline_schedules/components/interval_pattern_input.vue';
+
+Vue.use(Translate);
+
+const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
+const inputNameAttribute = 'schedule[cron]';
+
+const cronIntervalPresets = {
+ everyDay: '0 4 * * *',
+ everyWeek: '0 4 * * 0',
+ everyMonth: '0 4 1 * *',
+};
+
+window.gl = window.gl || {};
+
+window.gl.pipelineScheduleFieldErrors = {
+ updateFormValidityState: () => {},
+};
+
+describe('Interval Pattern Input Component', function () {
+ describe('when prop initialCronInterval is passed (edit)', function () {
+ describe('when prop initialCronInterval is custom', function () {
+ beforeEach(function () {
+ this.initialCronInterval = '1 2 3 4 5';
+ this.intervalPatternComponent = new IntervalPatternInputComponent({
+ propsData: {
+ initialCronInterval: this.initialCronInterval,
+ },
+ }).$mount();
+ });
+
+ it('is initialized as a Vue component', function () {
+ expect(this.intervalPatternComponent).toBeDefined();
+ });
+
+ it('prop initialCronInterval is set', function () {
+ expect(this.intervalPatternComponent.initialCronInterval).toBe(this.initialCronInterval);
+ });
+
+ it('sets isEditable to true', function (done) {
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.isEditable).toBe(true);
+ done();
+ });
+ });
+ });
+
+ describe('when prop initialCronInterval is preset', function () {
+ beforeEach(function () {
+ this.intervalPatternComponent = new IntervalPatternInputComponent({
+ propsData: {
+ inputNameAttribute,
+ initialCronInterval: '0 4 * * *',
+ },
+ }).$mount();
+ });
+
+ it('is initialized as a Vue component', function () {
+ expect(this.intervalPatternComponent).toBeDefined();
+ });
+
+ it('sets isEditable to false', function (done) {
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.isEditable).toBe(false);
+ done();
+ });
+ });
+ });
+ });
+
+ describe('when prop initialCronInterval is not passed (new)', function () {
+ beforeEach(function () {
+ this.intervalPatternComponent = new IntervalPatternInputComponent({
+ propsData: {
+ inputNameAttribute,
+ },
+ }).$mount();
+ });
+
+ it('is initialized as a Vue component', function () {
+ expect(this.intervalPatternComponent).toBeDefined();
+ });
+
+ it('prop initialCronInterval is set', function () {
+ const defaultInitialCronInterval = '';
+ expect(this.intervalPatternComponent.initialCronInterval).toBe(defaultInitialCronInterval);
+ });
+
+ it('sets isEditable to true', function (done) {
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.isEditable).toBe(true);
+ done();
+ });
+ });
+ });
+
+ describe('User Actions', function () {
+ beforeEach(function () {
+ // For an unknown reason, some browsers do not propagate click events
+ // on radio buttons in a way Vue can register. So, we have to mount
+ // to a fixture.
+ setFixtures('<div id="my-mount"></div>');
+
+ this.initialCronInterval = '1 2 3 4 5';
+ this.intervalPatternComponent = new IntervalPatternInputComponent({
+ propsData: {
+ initialCronInterval: this.initialCronInterval,
+ },
+ }).$mount('#my-mount');
+ });
+
+ it('cronInterval is updated when everyday preset interval is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-day').click();
+
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyDay);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyDay);
+ done();
+ });
+ });
+
+ it('cronInterval is updated when everyweek preset interval is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-week').click();
+
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyWeek);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyWeek);
+
+ done();
+ });
+ });
+
+ it('cronInterval is updated when everymonth preset interval is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-month').click();
+
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyMonth);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyMonth);
+ done();
+ });
+ });
+
+ it('only a space is added to cronInterval (trimmed later) when custom radio is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-month').click();
+ this.intervalPatternComponent.$el.querySelector('#custom').click();
+
+ Vue.nextTick(() => {
+ const intervalWithSpaceAppended = `${cronIntervalPresets.everyMonth} `;
+ expect(this.intervalPatternComponent.cronInterval).toBe(intervalWithSpaceAppended);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(intervalWithSpaceAppended);
+ done();
+ });
+ });
+
+ it('text input is disabled when preset interval is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-month').click();
+
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.isEditable).toBe(false);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(true);
+ done();
+ });
+ });
+
+ it('text input is enabled when custom is selected', function (done) {
+ this.intervalPatternComponent.$el.querySelector('#every-month').click();
+ this.intervalPatternComponent.$el.querySelector('#custom').click();
+
+ Vue.nextTick(() => {
+ expect(this.intervalPatternComponent.isEditable).toBe(true);
+ expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(false);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js
new file mode 100644
index 00000000000..ed481cb60a1
--- /dev/null
+++ b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import Cookies from 'js-cookie';
+import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_schedules_callout.vue';
+
+const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
+const cookieKey = 'pipeline_schedules_callout_dismissed';
+const docsUrl = 'help/ci/scheduled_pipelines';
+
+describe('Pipeline Schedule Callout', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
+ `);
+ });
+
+ describe('independent of cookies', () => {
+ beforeEach(() => {
+ this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('the component can be initialized', () => {
+ expect(this.calloutComponent).toBeDefined();
+ });
+
+ it('correctly sets illustrationSvg', () => {
+ expect(this.calloutComponent.illustrationSvg).toContain('<svg');
+ });
+
+ it('correctly sets docsUrl', () => {
+ expect(this.calloutComponent.docsUrl).toContain(docsUrl);
+ });
+ });
+
+ describe(`when ${cookieKey} cookie is set`, () => {
+ beforeEach(() => {
+ Cookies.set(cookieKey, true);
+ this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to true', () => {
+ expect(this.calloutComponent.calloutDismissed).toBe(true);
+ });
+
+ it('does not render the callout', () => {
+ expect(this.calloutComponent.$el.childNodes.length).toBe(0);
+ });
+ });
+
+ describe('when cookie is not set', () => {
+ beforeEach(() => {
+ Cookies.remove(cookieKey);
+ this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to false', () => {
+ expect(this.calloutComponent.calloutDismissed).toBe(false);
+ });
+
+ it('renders the callout container', () => {
+ expect(this.calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
+ });
+
+ it('renders the callout svg', () => {
+ expect(this.calloutComponent.$el.outerHTML).toContain('<svg');
+ });
+
+ it('renders the callout title', () => {
+ expect(this.calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
+ });
+
+ it('renders the callout text', () => {
+ expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
+ });
+
+ it('renders the documentation url', () => {
+ expect(this.calloutComponent.$el.outerHTML).toContain(docsUrl);
+ });
+
+ it('updates calloutDismissed when close button is clicked', (done) => {
+ this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(this.calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('#dismissCallout updates calloutDismissed', (done) => {
+ this.calloutComponent.dismissCallout();
+
+ Vue.nextTick(() => {
+ expect(this.calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('is hidden when close button is clicked', (done) => {
+ this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(this.calloutComponent.$el.childNodes.length).toBe(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js b/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js
new file mode 100644
index 00000000000..5b316b319a5
--- /dev/null
+++ b/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js
@@ -0,0 +1,145 @@
+import {
+ setupPipelineVariableList,
+ insertRow,
+ removeRow,
+} from '~/pipeline_schedules/setup_pipeline_variable_list';
+
+describe('Pipeline Variable List', () => {
+ let $markup;
+
+ describe('insertRow', () => {
+ it('should insert another row', () => {
+ $markup = $(`<div>
+ <li class="js-row">
+ <input>
+ <textarea></textarea>
+ </li>
+ </div>`);
+
+ insertRow($markup.find('.js-row'));
+
+ expect($markup.find('.js-row').length).toBe(2);
+ });
+
+ it('should clear `data-is-persisted` on cloned row', () => {
+ $markup = $(`<div>
+ <li class="js-row" data-is-persisted="true"></li>
+ </div>`);
+
+ insertRow($markup.find('.js-row'));
+
+ const $lastRow = $markup.find('.js-row').last();
+ expect($lastRow.attr('data-is-persisted')).toBe(undefined);
+ });
+
+ it('should clear inputs on cloned row', () => {
+ $markup = $(`<div>
+ <li class="js-row">
+ <input value="foo">
+ <textarea>bar</textarea>
+ </li>
+ </div>`);
+
+ insertRow($markup.find('.js-row'));
+
+ const $lastRow = $markup.find('.js-row').last();
+ expect($lastRow.find('input').val()).toBe('');
+ expect($lastRow.find('textarea').val()).toBe('');
+ });
+ });
+
+ describe('removeRow', () => {
+ it('should remove dynamic row', () => {
+ $markup = $(`<div>
+ <li class="js-row">
+ <input>
+ <textarea></textarea>
+ </li>
+ </div>`);
+
+ removeRow($markup.find('.js-row'));
+
+ expect($markup.find('.js-row').length).toBe(0);
+ });
+
+ it('should hide and mark to destroy with already persisted rows', () => {
+ $markup = $(`<div>
+ <li class="js-row" data-is-persisted="true">
+ <input class="js-destroy-input">
+ </li>
+ </div>`);
+
+ const $row = $markup.find('.js-row');
+ removeRow($row);
+
+ expect($row.find('.js-destroy-input').val()).toBe('1');
+ expect($markup.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('setupPipelineVariableList', () => {
+ beforeEach(() => {
+ $markup = $(`<form>
+ <li class="js-row">
+ <input class="js-user-input" name="schedule[variables_attributes][][key]">
+ <textarea class="js-user-input" name="schedule[variables_attributes][][value]"></textarea>
+ <button class="js-row-remove-button"></button>
+ <button class="js-row-add-button"></button>
+ </li>
+ </form>`);
+
+ setupPipelineVariableList($markup);
+ });
+
+ it('should remove the row when clicking the remove button', () => {
+ $markup.find('.js-row-remove-button').trigger('click');
+
+ expect($markup.find('.js-row').length).toBe(0);
+ });
+
+ it('should add another row when editing the last rows key input', () => {
+ const $row = $markup.find('.js-row');
+ $row.find('input.js-user-input')
+ .val('foo')
+ .trigger('input');
+
+ expect($markup.find('.js-row').length).toBe(2);
+ });
+
+ it('should add another row when editing the last rows value textarea', () => {
+ const $row = $markup.find('.js-row');
+ $row.find('textarea.js-user-input')
+ .val('foo')
+ .trigger('input');
+
+ expect($markup.find('.js-row').length).toBe(2);
+ });
+
+ it('should remove empty row after blurring', () => {
+ const $row = $markup.find('.js-row');
+ $row.find('input.js-user-input')
+ .val('foo')
+ .trigger('input');
+
+ expect($markup.find('.js-row').length).toBe(2);
+
+ $row.find('input.js-user-input')
+ .val('')
+ .trigger('input')
+ .trigger('blur');
+
+ expect($markup.find('.js-row').length).toBe(1);
+ });
+
+ it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => {
+ const $row = $markup.find('.js-row');
+ expect($row.find('input').attr('name')).toBe('schedule[variables_attributes][][key]');
+ expect($row.find('textarea').attr('name')).toBe('schedule[variables_attributes][][value]');
+
+ $markup.filter('form').submit();
+
+ expect($row.find('input').attr('name')).toBe('');
+ expect($row.find('textarea').attr('name')).toBe('');
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/async_button_spec.js b/spec/javascripts/pipelines/async_button_spec.js
index 28c9c7ab282..48620898357 100644
--- a/spec/javascripts/pipelines/async_button_spec.js
+++ b/spec/javascripts/pipelines/async_button_spec.js
@@ -1,25 +1,20 @@
import Vue from 'vue';
import asyncButtonComp from '~/pipelines/components/async_button.vue';
+import eventHub from '~/pipelines/event_hub';
describe('Pipelines Async Button', () => {
let component;
- let spy;
let AsyncButtonComponent;
beforeEach(() => {
AsyncButtonComponent = Vue.extend(asyncButtonComp);
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
-
component = new AsyncButtonComponent({
propsData: {
endpoint: '/foo',
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
- service: {
- postAction: spy,
- },
},
}).$mount();
});
@@ -33,7 +28,7 @@ describe('Pipelines Async Button', () => {
});
it('should render the provided title', () => {
- expect(component.$el.getAttribute('title')).toContain('Foo');
+ expect(component.$el.getAttribute('data-original-title')).toContain('Foo');
expect(component.$el.getAttribute('aria-label')).toContain('Foo');
});
@@ -41,37 +36,12 @@ describe('Pipelines Async Button', () => {
expect(component.$el.getAttribute('class')).toContain('bar');
});
- it('should call the service when it is clicked with the provided endpoint', () => {
- component.$el.click();
- expect(spy).toHaveBeenCalledWith('/foo');
- });
-
- it('should hide loading if request fails', () => {
- spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
-
- component = new AsyncButtonComponent({
- propsData: {
- endpoint: '/foo',
- title: 'Foo',
- icon: 'fa fa-foo',
- cssClass: 'bar',
- dataAttributes: {
- 'data-foo': 'foo',
- },
- service: {
- postAction: spy,
- },
- },
- }).$mount();
-
- component.$el.click();
- expect(component.$el.querySelector('.fa-spinner')).toBe(null);
- });
-
describe('With confirm dialog', () => {
it('should call the service when confimation is positive', () => {
spyOn(window, 'confirm').and.returnValue(true);
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
+ eventHub.$on('postAction', (endpoint) => {
+ expect(endpoint).toEqual('/foo');
+ });
component = new AsyncButtonComponent({
propsData: {
@@ -79,15 +49,11 @@ describe('Pipelines Async Button', () => {
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
- service: {
- postAction: spy,
- },
confirmActionMessage: 'bar',
},
}).$mount();
component.$el.click();
- expect(spy).toHaveBeenCalledWith('/foo');
});
});
});
diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js
new file mode 100644
index 00000000000..85bd87318db
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/action_component_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import actionComponent from '~/pipelines/components/graph/action_component.vue';
+
+describe('pipeline graph action component', () => {
+ let component;
+
+ beforeEach((done) => {
+ const ActionComponent = Vue.extend(actionComponent);
+ component = new ActionComponent({
+ propsData: {
+ tooltipText: 'bar',
+ link: 'foo',
+ actionMethod: 'post',
+ actionIcon: 'icon_action_cancel',
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('should render a link', () => {
+ expect(component.$el.getAttribute('href')).toEqual('foo');
+ });
+
+ it('should render the provided title as a bootstrap tooltip', () => {
+ expect(component.$el.getAttribute('data-original-title')).toEqual('bar');
+ });
+
+ it('should update bootstrap tooltip when title changes', (done) => {
+ component.tooltipText = 'changed';
+
+ setTimeout(() => {
+ expect(component.$el.getAttribute('data-original-title')).toBe('changed');
+ done();
+ });
+ });
+
+ it('should render an svg', () => {
+ expect(component.$el.querySelector('.ci-action-icon-wrapper')).toBeDefined();
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js
new file mode 100644
index 00000000000..25fd18b197e
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import dropdownActionComponent from '~/pipelines/components/graph/dropdown_action_component.vue';
+
+describe('action component', () => {
+ let component;
+
+ beforeEach((done) => {
+ const DropdownActionComponent = Vue.extend(dropdownActionComponent);
+ component = new DropdownActionComponent({
+ propsData: {
+ tooltipText: 'bar',
+ link: 'foo',
+ actionMethod: 'post',
+ actionIcon: 'icon_action_cancel',
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('should render a link', () => {
+ expect(component.$el.getAttribute('href')).toEqual('foo');
+ });
+
+ it('should render the provided title as a bootstrap tooltip', () => {
+ expect(component.$el.getAttribute('data-original-title')).toEqual('bar');
+ });
+
+ it('should render an svg', () => {
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js
new file mode 100644
index 00000000000..713baa65a17
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/graph_component_spec.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import graphComponent from '~/pipelines/components/graph/graph_component.vue';
+import graphJSON from './mock_data';
+
+describe('graph component', () => {
+ preloadFixtures('static/graph.html.raw');
+
+ let GraphComponent;
+
+ beforeEach(() => {
+ loadFixtures('static/graph.html.raw');
+ GraphComponent = Vue.extend(graphComponent);
+ });
+
+ describe('while is loading', () => {
+ it('should render a loading icon', () => {
+ const component = new GraphComponent({
+ propsData: {
+ isLoading: true,
+ pipeline: {},
+ },
+ }).$mount('#js-pipeline-graph-vue');
+ expect(component.$el.querySelector('.loading-icon')).toBeDefined();
+ });
+ });
+
+ describe('with data', () => {
+ it('should render the graph', () => {
+ const component = new GraphComponent({
+ propsData: {
+ isLoading: false,
+ pipeline: graphJSON,
+ },
+ }).$mount('#js-pipeline-graph-vue');
+
+ expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true);
+
+ expect(
+ component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'),
+ ).toEqual(true);
+
+ expect(
+ component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'),
+ ).toEqual(true);
+
+ expect(
+ component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'),
+ ).toEqual(true);
+
+ expect(component.$el.querySelector('loading-icon')).toBe(null);
+
+ expect(component.$el.querySelector('.stage-column-list')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js
new file mode 100644
index 00000000000..e90593e0f40
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/job_component_spec.js
@@ -0,0 +1,121 @@
+import Vue from 'vue';
+import jobComponent from '~/pipelines/components/graph/job_component.vue';
+
+describe('pipeline graph job component', () => {
+ let JobComponent;
+
+ const mockJob = {
+ id: 4256,
+ name: 'test',
+ status: {
+ icon: 'icon_status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ details_path: '/root/ci-mock/builds/4256',
+ action: {
+ icon: 'icon_action_retry',
+ title: 'Retry',
+ path: '/root/ci-mock/builds/4256/retry',
+ method: 'post',
+ },
+ },
+ };
+
+ beforeEach(() => {
+ JobComponent = Vue.extend(jobComponent);
+ });
+
+ describe('name with link', () => {
+ it('should render the job name and status with a link', (done) => {
+ const component = new JobComponent({
+ propsData: {
+ job: mockJob,
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ const link = component.$el.querySelector('a');
+
+ expect(link.getAttribute('href')).toEqual(mockJob.status.details_path);
+
+ expect(
+ link.getAttribute('data-original-title'),
+ ).toEqual(`${mockJob.name} - ${mockJob.status.label}`);
+
+ expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined();
+
+ expect(
+ component.$el.querySelector('.ci-status-text').textContent.trim(),
+ ).toEqual(mockJob.name);
+
+ done();
+ });
+ });
+ });
+
+ describe('name without link', () => {
+ it('it should render status and name', () => {
+ const component = new JobComponent({
+ propsData: {
+ job: {
+ id: 4256,
+ name: 'test',
+ status: {
+ icon: 'icon_status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ details_path: '/root/ci-mock/builds/4256',
+ },
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined();
+
+ expect(
+ component.$el.querySelector('.ci-status-text').textContent.trim(),
+ ).toEqual(mockJob.name);
+ });
+ });
+
+ describe('action icon', () => {
+ it('it should render the action icon', () => {
+ const component = new JobComponent({
+ propsData: {
+ job: mockJob,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('a.ci-action-icon-container')).toBeDefined();
+ expect(component.$el.querySelector('i.ci-action-icon-wrapper')).toBeDefined();
+ });
+ });
+
+ describe('dropdown', () => {
+ it('should render the dropdown action icon', () => {
+ const component = new JobComponent({
+ propsData: {
+ job: mockJob,
+ isDropdown: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('a.ci-action-icon-wrapper')).toBeDefined();
+ });
+ });
+
+ it('should render provided class name', () => {
+ const component = new JobComponent({
+ propsData: {
+ job: mockJob,
+ cssClassJobName: 'css-class-job-name',
+ },
+ }).$mount();
+
+ expect(
+ component.$el.querySelector('a').classList.contains('css-class-job-name'),
+ ).toBe(true);
+ });
+});
diff --git a/spec/javascripts/pipelines/graph/job_name_component_spec.js b/spec/javascripts/pipelines/graph/job_name_component_spec.js
new file mode 100644
index 00000000000..8e2071ba0b3
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/job_name_component_spec.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue';
+
+describe('job name component', () => {
+ let component;
+
+ beforeEach(() => {
+ const JobNameComponent = Vue.extend(jobNameComponent);
+ component = new JobNameComponent({
+ propsData: {
+ name: 'foo',
+ status: {
+ icon: 'icon_status_success',
+ },
+ },
+ }).$mount();
+ });
+
+ it('should render the provided name', () => {
+ expect(component.$el.querySelector('.ci-status-text').textContent.trim()).toEqual('foo');
+ });
+
+ it('should render an icon with the provided status', () => {
+ expect(component.$el.querySelector('.ci-status-icon-success')).toBeDefined();
+ expect(component.$el.querySelector('.ci-status-icon-success svg')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js
new file mode 100644
index 00000000000..56c522b7f77
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/mock_data.js
@@ -0,0 +1,232 @@
+/* eslint-disable quote-props, quotes, comma-dangle */
+export default {
+ "id": 123,
+ "user": {
+ "name": "Root",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": null,
+ "web_url": "http://localhost:3000/root"
+ },
+ "active": false,
+ "coverage": null,
+ "path": "/root/ci-mock/pipelines/123",
+ "details": {
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/pipelines/123",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico"
+ },
+ "duration": 9,
+ "finished_at": "2017-04-19T14:30:27.542Z",
+ "stages": [{
+ "name": "test",
+ "title": "test: passed",
+ "groups": [{
+ "name": "test",
+ "size": 1,
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4153",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4153/retry",
+ "method": "post"
+ }
+ },
+ "jobs": [{
+ "id": 4153,
+ "name": "test",
+ "build_path": "/root/ci-mock/builds/4153",
+ "retry_path": "/root/ci-mock/builds/4153/retry",
+ "playable": false,
+ "created_at": "2017-04-13T09:25:18.959Z",
+ "updated_at": "2017-04-13T09:25:23.118Z",
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4153",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4153/retry",
+ "method": "post"
+ }
+ }
+ }]
+ }],
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/pipelines/123#test",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico"
+ },
+ "path": "/root/ci-mock/pipelines/123#test",
+ "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=test"
+ }, {
+ "name": "deploy",
+ "title": "deploy: passed",
+ "groups": [{
+ "name": "deploy to production",
+ "size": 1,
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4166",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4166/retry",
+ "method": "post"
+ }
+ },
+ "jobs": [{
+ "id": 4166,
+ "name": "deploy to production",
+ "build_path": "/root/ci-mock/builds/4166",
+ "retry_path": "/root/ci-mock/builds/4166/retry",
+ "playable": false,
+ "created_at": "2017-04-19T14:29:46.463Z",
+ "updated_at": "2017-04-19T14:30:27.498Z",
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4166",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4166/retry",
+ "method": "post"
+ }
+ }
+ }]
+ }, {
+ "name": "deploy to staging",
+ "size": 1,
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4159",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4159/retry",
+ "method": "post"
+ }
+ },
+ "jobs": [{
+ "id": 4159,
+ "name": "deploy to staging",
+ "build_path": "/root/ci-mock/builds/4159",
+ "retry_path": "/root/ci-mock/builds/4159/retry",
+ "playable": false,
+ "created_at": "2017-04-18T16:32:08.420Z",
+ "updated_at": "2017-04-18T16:32:12.631Z",
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/builds/4159",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico",
+ "action": {
+ "icon": "icon_action_retry",
+ "title": "Retry",
+ "path": "/root/ci-mock/builds/4159/retry",
+ "method": "post"
+ }
+ }
+ }]
+ }],
+ "status": {
+ "icon": "icon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/ci-mock/pipelines/123#deploy",
+ "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico"
+ },
+ "path": "/root/ci-mock/pipelines/123#deploy",
+ "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=deploy"
+ }],
+ "artifacts": [],
+ "manual_actions": [{
+ "name": "deploy to production",
+ "path": "/root/ci-mock/builds/4166/play",
+ "playable": false
+ }]
+ },
+ "flags": {
+ "latest": true,
+ "triggered": false,
+ "stuck": false,
+ "yaml_errors": false,
+ "retryable": false,
+ "cancelable": false
+ },
+ "ref": {
+ "name": "master",
+ "path": "/root/ci-mock/tree/master",
+ "tag": false,
+ "branch": true
+ },
+ "commit": {
+ "id": "798e5f902592192afaba73f4668ae30e56eae492",
+ "short_id": "798e5f90",
+ "title": "Merge branch 'new-branch' into 'master'\r",
+ "created_at": "2017-04-13T10:25:17.000+01:00",
+ "parent_ids": ["54d483b1ed156fbbf618886ddf7ab023e24f8738", "c8e2d38a6c538822e81c57022a6e3a0cfedebbcc"],
+ "message": "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1",
+ "author_name": "Root",
+ "author_email": "admin@example.com",
+ "authored_date": "2017-04-13T10:25:17.000+01:00",
+ "committer_name": "Root",
+ "committer_email": "admin@example.com",
+ "committed_date": "2017-04-13T10:25:17.000+01:00",
+ "author": {
+ "name": "Root",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": null,
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_gravatar_url": null,
+ "commit_url": "http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492",
+ "commit_path": "/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492"
+ },
+ "created_at": "2017-04-13T09:25:18.881Z",
+ "updated_at": "2017-04-19T14:30:27.561Z"
+};
diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
new file mode 100644
index 00000000000..aa4d6eedaf4
--- /dev/null
+++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
+
+describe('stage column component', () => {
+ let component;
+ const mockJob = {
+ id: 4256,
+ name: 'test',
+ status: {
+ icon: 'icon_status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ details_path: '/root/ci-mock/builds/4256',
+ action: {
+ icon: 'icon_action_retry',
+ title: 'Retry',
+ path: '/root/ci-mock/builds/4256/retry',
+ method: 'post',
+ },
+ },
+ };
+
+ beforeEach(() => {
+ const StageColumnComponent = Vue.extend(stageColumnComponent);
+
+ component = new StageColumnComponent({
+ propsData: {
+ title: 'foo',
+ jobs: [mockJob, mockJob, mockJob],
+ },
+ }).$mount();
+ });
+
+ it('should render provided title', () => {
+ expect(component.$el.querySelector('.stage-name').textContent.trim()).toEqual('foo');
+ });
+
+ it('should render the provided jobs', () => {
+ expect(component.$el.querySelectorAll('.builds-container > ul > li').length).toEqual(3);
+ });
+});
diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js
new file mode 100644
index 00000000000..cecc7ceb53d
--- /dev/null
+++ b/spec/javascripts/pipelines/header_component_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import headerComponent from '~/pipelines/components/header_component.vue';
+import eventHub from '~/pipelines/event_hub';
+
+describe('Pipeline details header', () => {
+ let HeaderComponent;
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ HeaderComponent = Vue.extend(headerComponent);
+
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+ props = {
+ pipeline: {
+ details: {
+ status: {
+ group: 'failed',
+ icon: 'ci-status-failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ },
+ id: 123,
+ created_at: threeWeeksAgo.toISOString(),
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ retry_path: 'path',
+ },
+ isLoading: false,
+ };
+
+ vm = new HeaderComponent({ propsData: props }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render provided pipeline info', () => {
+ expect(
+ vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo');
+ });
+
+ describe('action buttons', () => {
+ it('should call postAction when button action is clicked', () => {
+ eventHub.$on('headerPostAction', (action) => {
+ expect(action.path).toEqual('path');
+ });
+
+ vm.$el.querySelector('button').click();
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js
deleted file mode 100644
index 2365a662b9f..00000000000
--- a/spec/javascripts/pipelines/mock_data.js
+++ /dev/null
@@ -1,107 +0,0 @@
-export default {
- pipelines: [{
- id: 115,
- user: {
- name: 'Root',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- path: '/root/review-app/pipelines/115',
- details: {
- status: {
- icon: 'icon_status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- has_details: true,
- details_path: '/root/review-app/pipelines/115',
- },
- duration: null,
- finished_at: '2017-03-17T19:00:15.996Z',
- stages: [{
- name: 'build',
- title: 'build: failed',
- status: {
- icon: 'icon_status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- has_details: true,
- details_path: '/root/review-app/pipelines/115#build',
- },
- path: '/root/review-app/pipelines/115#build',
- dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=build',
- },
- {
- name: 'review',
- title: 'review: skipped',
- status: {
- icon: 'icon_status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- has_details: true,
- details_path: '/root/review-app/pipelines/115#review',
- },
- path: '/root/review-app/pipelines/115#review',
- dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=review',
- }],
- artifacts: [],
- manual_actions: [{
- name: 'stop_review',
- path: '/root/review-app/builds/3766/play',
- }],
- },
- flags: {
- latest: true,
- triggered: false,
- stuck: false,
- yaml_errors: false,
- retryable: true,
- cancelable: false,
- },
- ref: {
- name: 'thisisabranch',
- path: '/root/review-app/tree/thisisabranch',
- tag: false,
- branch: true,
- },
- commit: {
- id: '9e87f87625b26c42c59a2ee0398f81d20cdfe600',
- short_id: '9e87f876',
- title: 'Update README.md',
- created_at: '2017-03-15T22:58:28.000+00:00',
- parent_ids: ['3744f9226e699faec2662a8b267e5d3fd0bfff0e'],
- message: 'Update README.md',
- author_name: 'Root',
- author_email: 'admin@example.com',
- authored_date: '2017-03-15T22:58:28.000+00:00',
- committer_name: 'Root',
- committer_email: 'admin@example.com',
- committed_date: '2017-03-15T22:58:28.000+00:00',
- author: {
- name: 'Root',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
- commit_url: 'http://localhost:3000/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600',
- commit_path: '/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600',
- },
- retry_path: '/root/review-app/pipelines/115/retry',
- created_at: '2017-03-15T22:58:33.436Z',
- updated_at: '2017-03-17T19:00:15.997Z',
- }],
- count: {
- all: 52,
- running: 0,
- pending: 0,
- finished: 52,
- },
-};
diff --git a/spec/javascripts/pipelines/nav_controls_spec.js b/spec/javascripts/pipelines/nav_controls_spec.js
index 601eebce38a..f1697840fcd 100644
--- a/spec/javascripts/pipelines/nav_controls_spec.js
+++ b/spec/javascripts/pipelines/nav_controls_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import navControlsComp from '~/pipelines/components/nav_controls';
+import navControlsComp from '~/pipelines/components/nav_controls.vue';
describe('Pipelines Nav Controls', () => {
let NavControlsComponent;
diff --git a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
new file mode 100644
index 00000000000..9fec2f61f78
--- /dev/null
+++ b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import PipelineMediator from '~/pipelines/pipeline_details_mediatior';
+
+describe('PipelineMdediator', () => {
+ let mediator;
+ beforeEach(() => {
+ mediator = new PipelineMediator({ endpoint: 'foo' });
+ });
+
+ it('should set defaults', () => {
+ expect(mediator.options).toEqual({ endpoint: 'foo' });
+ expect(mediator.state.isLoading).toEqual(false);
+ expect(mediator.store).toBeDefined();
+ expect(mediator.service).toBeDefined();
+ });
+
+ describe('request and store data', () => {
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify({ foo: 'bar' }), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(interceptor);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
+ });
+
+ it('should store received data', (done) => {
+ mediator.fetchPipeline();
+
+ setTimeout(() => {
+ expect(mediator.store.state.pipeline).toEqual({ foo: 'bar' });
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/pipeline_store_spec.js b/spec/javascripts/pipelines/pipeline_store_spec.js
new file mode 100644
index 00000000000..85d13445b01
--- /dev/null
+++ b/spec/javascripts/pipelines/pipeline_store_spec.js
@@ -0,0 +1,27 @@
+import PipelineStore from '~/pipelines/stores/pipeline_store';
+
+describe('Pipeline Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ });
+
+ it('should set defaults', () => {
+ expect(store.state).toEqual({ pipeline: {} });
+ expect(store.state.pipeline).toEqual({});
+ });
+
+ describe('storePipeline', () => {
+ it('should store empty object if none is provided', () => {
+ store.storePipeline();
+
+ expect(store.state.pipeline).toEqual({});
+ });
+
+ it('should store received object', () => {
+ store.storePipeline({ foo: 'bar' });
+ expect(store.state.pipeline).toEqual({ foo: 'bar' });
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js
index 53931d67ad7..3c4b20a5f06 100644
--- a/spec/javascripts/pipelines/pipeline_url_spec.js
+++ b/spec/javascripts/pipelines/pipeline_url_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import pipelineUrlComp from '~/pipelines/components/pipeline_url';
+import pipelineUrlComp from '~/pipelines/components/pipeline_url.vue';
describe('Pipeline Url Component', () => {
let PipelineUrlComponent;
@@ -19,7 +19,7 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(component.$el.tagName).toEqual('TD');
+ expect(component.$el.getAttribute('class')).toContain('table-section');
});
it('should render a link the provided path and id', () => {
@@ -47,6 +47,7 @@ describe('Pipeline Url Component', () => {
web_url: '/',
name: 'foo',
avatar_url: '/',
+ path: '/',
},
},
};
@@ -60,7 +61,7 @@ describe('Pipeline Url Component', () => {
expect(
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
).toEqual(mockData.pipeline.user.web_url);
- expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
+ expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
});
@@ -93,7 +94,7 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(component.$el.querySelector('.js-pipeline-url-lastest').textContent).toContain('latest');
+ expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest');
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
});
diff --git a/spec/javascripts/pipelines/pipelines_actions_spec.js b/spec/javascripts/pipelines/pipelines_actions_spec.js
index c89dacbcd93..72fb0a8f9ef 100644
--- a/spec/javascripts/pipelines/pipelines_actions_spec.js
+++ b/spec/javascripts/pipelines/pipelines_actions_spec.js
@@ -1,9 +1,8 @@
import Vue from 'vue';
-import pipelinesActionsComp from '~/pipelines/components/pipelines_actions';
+import pipelinesActionsComp from '~/pipelines/components/pipelines_actions.vue';
describe('Pipelines Actions dropdown', () => {
let component;
- let spy;
let actions;
let ActionsComponent;
@@ -22,14 +21,9 @@ describe('Pipelines Actions dropdown', () => {
},
];
- spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
-
component = new ActionsComponent({
propsData: {
actions,
- service: {
- postAction: spy,
- },
},
}).$mount();
});
@@ -40,31 +34,6 @@ describe('Pipelines Actions dropdown', () => {
).toEqual(actions.length);
});
- it('should call the service when an action is clicked', () => {
- component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
- component.$el.querySelector('.js-pipeline-action-link').click();
-
- expect(spy).toHaveBeenCalledWith(actions[0].path);
- });
-
- it('should hide loading if request fails', () => {
- spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
-
- component = new ActionsComponent({
- propsData: {
- actions,
- service: {
- postAction: spy,
- },
- },
- }).$mount();
-
- component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
- component.$el.querySelector('.js-pipeline-action-link').click();
-
- expect(component.$el.querySelector('.fa-spinner')).toEqual(null);
- });
-
it('should render a disabled action when it\'s not playable', () => {
expect(
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
diff --git a/spec/javascripts/pipelines/pipelines_artifacts_spec.js b/spec/javascripts/pipelines/pipelines_artifacts_spec.js
index 9724b63d957..acb67d0ec21 100644
--- a/spec/javascripts/pipelines/pipelines_artifacts_spec.js
+++ b/spec/javascripts/pipelines/pipelines_artifacts_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import artifactsComp from '~/pipelines/components/pipelines_artifacts';
+import artifactsComp from '~/pipelines/components/pipelines_artifacts.vue';
describe('Pipelines Artifacts dropdown', () => {
let component;
diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js
index e9c05f74ce6..c30abb2edb0 100644
--- a/spec/javascripts/pipelines/pipelines_spec.js
+++ b/spec/javascripts/pipelines/pipelines_spec.js
@@ -1,15 +1,20 @@
import Vue from 'vue';
-import pipelinesComp from '~/pipelines/pipelines';
+import pipelinesComp from '~/pipelines/components/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store';
-import pipelinesData from './mock_data';
describe('Pipelines', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
preloadFixtures('static/pipelines.html.raw');
+ preloadFixtures(jsonFixtureName);
let PipelinesComponent;
+ let pipeline;
beforeEach(() => {
loadFixtures('static/pipelines.html.raw');
+ const pipelines = getJSONFixture(jsonFixtureName).pipelines;
+ pipeline = pipelines.find(p => p.id === 1);
PipelinesComponent = Vue.extend(pipelinesComp);
});
@@ -17,7 +22,7 @@ describe('Pipelines', () => {
describe('successfull request', () => {
describe('with pipelines', () => {
const pipelinesInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify(pipelinesData), {
+ next(request.respondWith(JSON.stringify(pipeline), {
status: 200,
}));
};
diff --git a/spec/javascripts/pipelines/pipelines_table_row_spec.js b/spec/javascripts/pipelines/pipelines_table_row_spec.js
new file mode 100644
index 00000000000..7ce39dca112
--- /dev/null
+++ b/spec/javascripts/pipelines/pipelines_table_row_spec.js
@@ -0,0 +1,161 @@
+import Vue from 'vue';
+import tableRowComp from '~/pipelines/components/pipelines_table_row.vue';
+
+describe('Pipelines Table Row', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+ const buildComponent = (pipeline) => {
+ const PipelinesTableRowComponent = Vue.extend(tableRowComp);
+ return new PipelinesTableRowComponent({
+ el: document.querySelector('.test-dom-element'),
+ propsData: {
+ pipeline,
+ service: {},
+ },
+ }).$mount();
+ };
+
+ let component;
+ let pipeline;
+ let pipelineWithoutAuthor;
+ let pipelineWithoutCommit;
+
+ preloadFixtures(jsonFixtureName);
+
+ beforeEach(() => {
+ const pipelines = getJSONFixture(jsonFixtureName).pipelines;
+ pipeline = pipelines.find(p => p.id === 1);
+ pipelineWithoutAuthor = pipelines.find(p => p.id === 2);
+ pipelineWithoutCommit = pipelines.find(p => p.id === 3);
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should render a table row', () => {
+ component = buildComponent(pipeline);
+ expect(component.$el.getAttribute('class')).toContain('gl-responsive-table-row');
+ });
+
+ describe('status column', () => {
+ beforeEach(() => {
+ component = buildComponent(pipeline);
+ });
+
+ it('should render a pipeline link', () => {
+ expect(
+ component.$el.querySelector('.table-section.commit-link a').getAttribute('href'),
+ ).toEqual(pipeline.path);
+ });
+
+ it('should render status text', () => {
+ expect(
+ component.$el.querySelector('.table-section.commit-link a').textContent,
+ ).toContain(pipeline.details.status.text);
+ });
+ });
+
+ describe('information column', () => {
+ beforeEach(() => {
+ component = buildComponent(pipeline);
+ });
+
+ it('should render a pipeline link', () => {
+ expect(
+ component.$el.querySelector('.table-section:nth-child(2) a').getAttribute('href'),
+ ).toEqual(pipeline.path);
+ });
+
+ it('should render pipeline ID', () => {
+ expect(
+ component.$el.querySelector('.table-section:nth-child(2) a > span').textContent,
+ ).toEqual(`#${pipeline.id}`);
+ });
+
+ describe('when a user is provided', () => {
+ it('should render user information', () => {
+ expect(
+ component.$el.querySelector('.table-section:nth-child(2) a:nth-child(3)').getAttribute('href'),
+ ).toEqual(pipeline.user.path);
+
+ expect(
+ component.$el.querySelector('.table-section:nth-child(2) img').getAttribute('data-original-title'),
+ ).toEqual(pipeline.user.name);
+ });
+ });
+ });
+
+ describe('commit column', () => {
+ it('should render link to commit', () => {
+ component = buildComponent(pipeline);
+
+ const commitLink = component.$el.querySelector('.branch-commit .commit-sha');
+ expect(commitLink.getAttribute('href')).toEqual(pipeline.commit.commit_path);
+ });
+
+ const findElements = () => {
+ const commitTitleElement = component.$el.querySelector('.branch-commit .commit-title');
+ const commitAuthorElement = commitTitleElement.querySelector('a.avatar-image-container');
+
+ if (!commitAuthorElement) {
+ return { commitAuthorElement };
+ }
+
+ const commitAuthorLink = commitAuthorElement.getAttribute('href');
+ const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('data-original-title');
+
+ return { commitAuthorElement, commitAuthorLink, commitAuthorName };
+ };
+
+ it('renders nothing without commit', () => {
+ expect(pipelineWithoutCommit.commit).toBe(null);
+ component = buildComponent(pipelineWithoutCommit);
+
+ const { commitAuthorElement } = findElements();
+
+ expect(commitAuthorElement).toBe(null);
+ });
+
+ it('renders commit author', () => {
+ component = buildComponent(pipeline);
+ const { commitAuthorLink, commitAuthorName } = findElements();
+
+ expect(commitAuthorLink).toEqual(pipeline.commit.author.path);
+ expect(commitAuthorName).toEqual(pipeline.commit.author.username);
+ });
+
+ it('renders commit with unregistered author', () => {
+ expect(pipelineWithoutAuthor.commit.author).toBe(null);
+ component = buildComponent(pipelineWithoutAuthor);
+
+ const { commitAuthorLink, commitAuthorName } = findElements();
+
+ expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`);
+ expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name);
+ });
+ });
+
+ describe('stages column', () => {
+ beforeEach(() => {
+ component = buildComponent(pipeline);
+ });
+
+ it('should render an icon for each stage', () => {
+ expect(
+ component.$el.querySelectorAll('.table-section:nth-child(4) .js-builds-dropdown-button').length,
+ ).toEqual(pipeline.details.stages.length);
+ });
+ });
+
+ describe('actions column', () => {
+ beforeEach(() => {
+ component = buildComponent(pipeline);
+ });
+
+ it('should render the provided actions', () => {
+ expect(
+ component.$el.querySelectorAll('.table-section:nth-child(6) ul li').length,
+ ).toEqual(pipeline.details.manual_actions.length);
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/pipelines_table_spec.js b/spec/javascripts/pipelines/pipelines_table_spec.js
new file mode 100644
index 00000000000..3afe89c8db4
--- /dev/null
+++ b/spec/javascripts/pipelines/pipelines_table_spec.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import pipelinesTableComp from '~/pipelines/components/pipelines_table.vue';
+import '~/lib/utils/datetime_utility';
+
+describe('Pipelines Table', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ let pipeline;
+ let PipelinesTableComponent;
+
+ preloadFixtures(jsonFixtureName);
+
+ beforeEach(() => {
+ PipelinesTableComponent = Vue.extend(pipelinesTableComp);
+ const pipelines = getJSONFixture(jsonFixtureName).pipelines;
+ pipeline = pipelines.find(p => p.id === 1);
+ });
+
+ describe('table', () => {
+ let component;
+ beforeEach(() => {
+ component = new PipelinesTableComponent({
+ propsData: {
+ pipelines: [],
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should render a table', () => {
+ expect(component.$el.getAttribute('class')).toContain('ci-table');
+ });
+
+ it('should render table head with correct columns', () => {
+ expect(component.$el.querySelector('.table-section.js-pipeline-status').textContent.trim()).toEqual('Status');
+ expect(component.$el.querySelector('.table-section.js-pipeline-info').textContent.trim()).toEqual('Pipeline');
+ expect(component.$el.querySelector('.table-section.js-pipeline-commit').textContent.trim()).toEqual('Commit');
+ expect(component.$el.querySelector('.table-section.js-pipeline-stages').textContent.trim()).toEqual('Stages');
+ });
+ });
+
+ describe('without data', () => {
+ it('should render an empty table', () => {
+ const component = new PipelinesTableComponent({
+ propsData: {
+ pipelines: [],
+ },
+ }).$mount();
+ expect(component.$el.querySelectorAll('.commit.gl-responsive-table-row').length).toEqual(0);
+ });
+ });
+
+ describe('with data', () => {
+ it('should render rows', () => {
+ const component = new PipelinesTableComponent({
+ propsData: {
+ pipelines: [pipeline],
+ },
+ }).$mount();
+
+ expect(component.$el.querySelectorAll('.commit.gl-responsive-table-row').length).toEqual(1);
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js
index 2f1154bd999..1b96b2e3d51 100644
--- a/spec/javascripts/pipelines/stage_spec.js
+++ b/spec/javascripts/pipelines/stage_spec.js
@@ -1,81 +1,129 @@
import Vue from 'vue';
-import { SUCCESS_SVG } from '~/ci_status_icons';
-import Stage from '~/pipelines/components/stage';
+import stage from '~/pipelines/components/stage.vue';
+
+describe('Pipelines stage component', () => {
+ let StageComponent;
+ let component;
+
+ beforeEach(() => {
+ StageComponent = Vue.extend(stage);
+
+ component = new StageComponent({
+ propsData: {
+ stage: {
+ status: {
+ group: 'success',
+ icon: 'icon_status_success',
+ title: 'success',
+ },
+ dropdown_path: 'foo',
+ },
+ updateDropdown: false,
+ },
+ }).$mount();
+ });
-function minify(string) {
- return string.replace(/\s/g, '');
-}
+ it('should render a dropdown with the status icon', () => {
+ expect(component.$el.getAttribute('class')).toEqual('dropdown');
+ expect(component.$el.querySelector('svg')).toBeDefined();
+ expect(component.$el.querySelector('button').getAttribute('data-toggle')).toEqual('dropdown');
+ });
-describe('Pipelines Stage', () => {
- describe('data', () => {
- let stageReturnValue;
+ describe('with successfull request', () => {
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify({ html: 'foo' }), {
+ status: 200,
+ }));
+ };
beforeEach(() => {
- stageReturnValue = Stage.data();
+ Vue.http.interceptors.push(interceptor);
});
- it('should return object with .builds and .spinner', () => {
- expect(stageReturnValue).toEqual({
- builds: '',
- spinner: '<span class="fa fa-spinner fa-spin"></span>',
- });
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, interceptor,
+ );
});
- });
- describe('computed', () => {
- describe('svgHTML', function () {
- let stage;
- let svgHTML;
+ it('should render the received data', (done) => {
+ component.$el.querySelector('button').click();
- beforeEach(() => {
- stage = { stage: { status: { icon: 'icon_status_success' } } };
-
- svgHTML = Stage.computed.svgHTML.call(stage);
- });
-
- it("should return the correct icon for the stage's status", () => {
- expect(svgHTML).toBe(SUCCESS_SVG);
- });
+ setTimeout(() => {
+ expect(
+ component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
+ ).toEqual('foo');
+ done();
+ }, 0);
});
});
- describe('when mounted', () => {
- let StageComponent;
- let renderedComponent;
- let stage;
+ describe('when request fails', () => {
+ const interceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify({}), {
+ status: 500,
+ }));
+ };
beforeEach(() => {
- stage = { status: { icon: 'icon_status_success' } };
-
- StageComponent = Vue.extend(Stage);
+ Vue.http.interceptors.push(interceptor);
+ });
- renderedComponent = new StageComponent({
- propsData: {
- stage,
- },
- }).$mount();
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, interceptor,
+ );
});
- it('should render the correct status svg', () => {
- const minifiedComponent = minify(renderedComponent.$el.outerHTML);
- const expectedSVG = minify(SUCCESS_SVG);
+ it('should close the dropdown', () => {
+ component.$el.click();
- expect(minifiedComponent).toContain(expectedSVG);
+ setTimeout(() => {
+ expect(component.$el.classList.contains('open')).toEqual(false);
+ }, 0);
});
});
- describe('when request fails', () => {
- it('closes dropdown', () => {
- spyOn($, 'ajax').and.callFake(options => options.error());
- const StageComponent = Vue.extend(Stage);
+ describe('update endpoint correctly', () => {
+ const updatedInterceptor = (request, next) => {
+ if (request.url === 'bar') {
+ next(request.respondWith(JSON.stringify({ html: 'this is the updated content' }), {
+ status: 200,
+ }));
+ }
+ next();
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(updatedInterceptor);
+ });
- const component = new StageComponent({
- propsData: { stage: { status: { icon: 'foo' } } },
- }).$mount();
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, updatedInterceptor,
+ );
+ });
- expect(
- component.$el.classList.contains('open'),
- ).toEqual(false);
+ it('should update the stage to request the new endpoint provided', (done) => {
+ component.stage = {
+ status: {
+ group: 'running',
+ icon: 'running',
+ title: 'running',
+ },
+ dropdown_path: 'bar',
+ };
+
+ Vue.nextTick(() => {
+ component.$el.querySelector('button').click();
+
+ setTimeout(() => {
+ expect(
+ component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
+ ).toEqual('this is the updated content');
+ done();
+ });
+ });
});
});
});
diff --git a/spec/javascripts/pipelines/time_ago_spec.js b/spec/javascripts/pipelines/time_ago_spec.js
index 24581e8c672..42b34c82f89 100644
--- a/spec/javascripts/pipelines/time_ago_spec.js
+++ b/spec/javascripts/pipelines/time_ago_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import timeAgo from '~/pipelines/components/time_ago';
+import timeAgo from '~/pipelines/components/time_ago.vue';
describe('Timeago component', () => {
let TimeAgo;
diff --git a/spec/javascripts/pipelines_spec.js b/spec/javascripts/pipelines_spec.js
index 72770a702d3..c08a73851be 100644
--- a/spec/javascripts/pipelines_spec.js
+++ b/spec/javascripts/pipelines_spec.js
@@ -1,30 +1,17 @@
-require('~/pipelines');
+import Pipelines from '~/pipelines';
-// Fix for phantomJS
-if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) {
- Element.prototype.matches = Element.prototype.webkitMatchesSelector;
-}
+describe('Pipelines', () => {
+ preloadFixtures('static/pipeline_graph.html.raw');
-(() => {
- describe('Pipelines', () => {
- preloadFixtures('static/pipeline_graph.html.raw');
-
- beforeEach(() => {
- loadFixtures('static/pipeline_graph.html.raw');
- });
-
- it('should be defined', () => {
- expect(window.gl.Pipelines).toBeDefined();
- });
-
- it('should create a `Pipelines` instance without options', () => {
- expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line
- });
+ beforeEach(() => {
+ loadFixtures('static/pipeline_graph.html.raw');
+ });
- it('should create a `Pipelines` instance with options', () => {
- const pipelines = new window.gl.Pipelines({ foo: 'bar' });
+ it('should be defined', () => {
+ expect(Pipelines).toBeDefined();
+ });
- expect(pipelines.pipelineGraph).toBeDefined();
- });
+ it('should create a `Pipelines` instance without options', () => {
+ expect(() => { new Pipelines(); }).not.toThrow(); //eslint-disable-line
});
-})();
+});
diff --git a/spec/javascripts/pretty_time_spec.js b/spec/javascripts/pretty_time_spec.js
index a4662cfb557..de99e7e3894 100644
--- a/spec/javascripts/pretty_time_spec.js
+++ b/spec/javascripts/pretty_time_spec.js
@@ -1,4 +1,4 @@
-require('~/lib/utils/pretty_time');
+import '~/lib/utils/pretty_time';
(() => {
const prettyTime = gl.utils.prettyTime;
diff --git a/spec/javascripts/project_select_combo_button_spec.js b/spec/javascripts/project_select_combo_button_spec.js
new file mode 100644
index 00000000000..e10a5a3bef6
--- /dev/null
+++ b/spec/javascripts/project_select_combo_button_spec.js
@@ -0,0 +1,105 @@
+import ProjectSelectComboButton from '~/project_select_combo_button';
+
+const fixturePath = 'static/project_select_combo_button.html.raw';
+
+describe('Project Select Combo Button', function () {
+ preloadFixtures(fixturePath);
+
+ beforeEach(function () {
+ this.defaults = {
+ label: 'Select project to create issue',
+ groupId: 12345,
+ projectMeta: {
+ name: 'My Cool Project',
+ url: 'http://mycoolproject.com',
+ },
+ newProjectMeta: {
+ name: 'My Other Cool Project',
+ url: 'http://myothercoolproject.com',
+ },
+ localStorageKey: 'group-12345-new-issue-recent-project',
+ relativePath: 'issues/new',
+ };
+
+ loadFixtures(fixturePath);
+
+ this.newItemBtn = document.querySelector('.new-project-item-link');
+ this.projectSelectInput = document.querySelector('.project-item-select');
+ });
+
+ describe('on page load when localStorage is empty', function () {
+ beforeEach(function () {
+ this.comboButton = new ProjectSelectComboButton(this.projectSelectInput);
+ });
+
+ it('newItemBtn is disabled', function () {
+ expect(this.newItemBtn.hasAttribute('disabled')).toBe(true);
+ expect(this.newItemBtn.classList.contains('disabled')).toBe(true);
+ });
+
+ it('newItemBtn href is null', function () {
+ expect(this.newItemBtn.getAttribute('href')).toBe('');
+ });
+
+ it('newItemBtn text is the plain default label', function () {
+ expect(this.newItemBtn.textContent).toBe(this.defaults.label);
+ });
+ });
+
+ describe('on page load when localStorage is filled', function () {
+ beforeEach(function () {
+ window.localStorage
+ .setItem(this.defaults.localStorageKey, JSON.stringify(this.defaults.projectMeta));
+ this.comboButton = new ProjectSelectComboButton(this.projectSelectInput);
+ });
+
+ it('newItemBtn is not disabled', function () {
+ expect(this.newItemBtn.hasAttribute('disabled')).toBe(false);
+ expect(this.newItemBtn.classList.contains('disabled')).toBe(false);
+ });
+
+ it('newItemBtn href is correctly set', function () {
+ expect(this.newItemBtn.getAttribute('href')).toBe(this.defaults.projectMeta.url);
+ });
+
+ it('newItemBtn text is the cached label', function () {
+ expect(this.newItemBtn.textContent)
+ .toBe(`New issue in ${this.defaults.projectMeta.name}`);
+ });
+
+ afterEach(function () {
+ window.localStorage.clear();
+ });
+ });
+
+ describe('after selecting a new project', function () {
+ beforeEach(function () {
+ this.comboButton = new ProjectSelectComboButton(this.projectSelectInput);
+
+ // mock the effect of selecting an item from the projects dropdown (select2)
+ $('.project-item-select')
+ .val(JSON.stringify(this.defaults.newProjectMeta))
+ .trigger('change');
+ });
+
+ it('newItemBtn is not disabled', function () {
+ expect(this.newItemBtn.hasAttribute('disabled')).toBe(false);
+ expect(this.newItemBtn.classList.contains('disabled')).toBe(false);
+ });
+
+ it('newItemBtn href is correctly set', function () {
+ expect(this.newItemBtn.getAttribute('href'))
+ .toBe('http://myothercoolproject.com/issues/new');
+ });
+
+ it('newItemBtn text is the selected project label', function () {
+ expect(this.newItemBtn.textContent)
+ .toBe(`New issue in ${this.defaults.newProjectMeta.name}`);
+ });
+
+ afterEach(function () {
+ window.localStorage.clear();
+ });
+ });
+});
+
diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js
index 3a1d4e2440f..cc336180ff7 100644
--- a/spec/javascripts/project_title_spec.js
+++ b/spec/javascripts/project_title_spec.js
@@ -1,54 +1,57 @@
-/* eslint-disable space-before-function-paren, no-unused-expressions, no-return-assign, no-param-reassign, no-var, new-cap, wrap-iife, no-unused-vars, quotes, jasmine/no-expect-in-setup-teardown, max-len */
/* global Project */
-require('select2/select2.js');
-require('~/lib/utils/type_utility');
-require('~/gl_dropdown');
-require('~/api');
-require('~/project_select');
-require('~/project');
+import 'select2/select2';
+import '~/gl_dropdown';
+import '~/api';
+import '~/project_select';
+import '~/project';
-(function() {
- describe('Project Title', function() {
- preloadFixtures('issues/open-issue.html.raw');
- loadJSONFixtures('projects.json');
+describe('Project Title', () => {
+ preloadFixtures('issues/open-issue.html.raw');
+ loadJSONFixtures('projects.json');
- beforeEach(function() {
- loadFixtures('issues/open-issue.html.raw');
+ beforeEach(() => {
+ loadFixtures('issues/open-issue.html.raw');
- window.gon = {};
- window.gon.api_version = 'v3';
+ window.gon = {};
+ window.gon.api_version = 'v3';
- return this.project = new Project();
- });
+ // eslint-disable-next-line no-new
+ new Project();
+ });
- describe('project list', function() {
- var fakeAjaxResponse = function fakeAjaxResponse(req) {
- var d;
- expect(req.url).toBe('/api/v3/projects.json?simple=true');
- expect(req.data).toEqual({ search: '', order_by: 'last_activity_at', per_page: 20, membership: true });
- d = $.Deferred();
- d.resolve(this.projects_data);
- return d.promise();
- };
-
- beforeEach((function(_this) {
- return function() {
- _this.projects_data = getJSONFixture('projects.json');
- return spyOn(jQuery, 'ajax').and.callFake(fakeAjaxResponse.bind(_this));
- };
- })(this));
- it('toggles dropdown', function() {
- var menu = $('.js-dropdown-menu-projects');
- $('.js-projects-dropdown-toggle').click();
- expect(menu).toHaveClass('open');
- menu.find('.dropdown-menu-close-icon').click();
- expect(menu).not.toHaveClass('open');
+ describe('project list', () => {
+ let reqUrl;
+ let reqData;
+
+ beforeEach(() => {
+ const fakeResponseData = getJSONFixture('projects.json');
+ spyOn(jQuery, 'ajax').and.callFake((req) => {
+ const def = $.Deferred();
+ reqUrl = req.url;
+ reqData = req.data;
+ def.resolve(fakeResponseData);
+ return def.promise();
});
});
- afterEach(() => {
- window.gon = {};
+ it('toggles dropdown', () => {
+ const $menu = $('.js-dropdown-menu-projects');
+ $('.js-projects-dropdown-toggle').click();
+ expect($menu).toHaveClass('open');
+ expect(reqUrl).toBe('/api/v3/projects.json?simple=true');
+ expect(reqData).toEqual({
+ search: '',
+ order_by: 'last_activity_at',
+ per_page: 20,
+ membership: true,
+ });
+ $menu.find('.dropdown-menu-close-icon').click();
+ expect($menu).not.toHaveClass('open');
});
});
-}).call(window);
+
+ afterEach(() => {
+ window.gon = {};
+ });
+});
diff --git a/spec/javascripts/projects/project_import_gitlab_project_spec.js b/spec/javascripts/projects/project_import_gitlab_project_spec.js
new file mode 100644
index 00000000000..2f1aae109e3
--- /dev/null
+++ b/spec/javascripts/projects/project_import_gitlab_project_spec.js
@@ -0,0 +1,25 @@
+import projectImportGitlab from '~/projects/project_import_gitlab_project';
+
+describe('Import Gitlab project', () => {
+ let projectName;
+ beforeEach(() => {
+ projectName = 'project';
+ window.history.pushState({}, null, `?path=${projectName}`);
+
+ setFixtures(`
+ <input class="js-path-name" />
+ `);
+
+ projectImportGitlab.bindEvents();
+ });
+
+ afterEach(() => {
+ window.history.pushState({}, null, '');
+ });
+
+ describe('path name', () => {
+ it('should fill in the project name derived from the previously filled project name', () => {
+ expect(document.querySelector('.js-path-name').value).toEqual(projectName);
+ });
+ });
+});
diff --git a/spec/javascripts/projects/project_new_spec.js b/spec/javascripts/projects/project_new_spec.js
new file mode 100644
index 00000000000..850768f0e4f
--- /dev/null
+++ b/spec/javascripts/projects/project_new_spec.js
@@ -0,0 +1,127 @@
+import projectNew from '~/projects/project_new';
+
+describe('New Project', () => {
+ let $projectImportUrl;
+ let $projectPath;
+
+ beforeEach(() => {
+ setFixtures(`
+ <input id="project_import_url" />
+ <input id="project_path" />
+ `);
+
+ $projectImportUrl = $('#project_import_url');
+ $projectPath = $('#project_path');
+ });
+
+ describe('deriveProjectPathFromUrl', () => {
+ const dummyImportUrl = `${gl.TEST_HOST}/dummy/import/url.git`;
+
+ beforeEach(() => {
+ projectNew.bindEvents();
+ $projectPath.val('').keyup().val(dummyImportUrl);
+ });
+
+ it('does not change project path for disabled $projectImportUrl', () => {
+ $projectImportUrl.attr('disabled', true);
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual(dummyImportUrl);
+ });
+
+ describe('for enabled $projectImportUrl', () => {
+ beforeEach(() => {
+ $projectImportUrl.attr('disabled', false);
+ });
+
+ it('does not change project path if it is set by user', () => {
+ $projectPath.keyup();
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual(dummyImportUrl);
+ });
+
+ it('does not change project path for empty $projectImportUrl', () => {
+ $projectImportUrl.val('');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual(dummyImportUrl);
+ });
+
+ it('does not change project path for whitespace $projectImportUrl', () => {
+ $projectImportUrl.val(' ');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual(dummyImportUrl);
+ });
+
+ it('does not change project path for $projectImportUrl without slashes', () => {
+ $projectImportUrl.val('has-no-slash');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual(dummyImportUrl);
+ });
+
+ it('changes project path to last $projectImportUrl component', () => {
+ $projectImportUrl.val('/this/is/last');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('last');
+ });
+
+ it('ignores trailing slashes in $projectImportUrl', () => {
+ $projectImportUrl.val('/has/trailing/slash/');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('slash');
+ });
+
+ it('ignores fragment identifier in $projectImportUrl', () => {
+ $projectImportUrl.val('/this/has/a#fragment-identifier/');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('a');
+ });
+
+ it('ignores query string in $projectImportUrl', () => {
+ $projectImportUrl.val('/url/with?query=string');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('with');
+ });
+
+ it('ignores trailing .git in $projectImportUrl', () => {
+ $projectImportUrl.val('/repository.git');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('repository');
+ });
+
+ it('changes project path for HTTPS URL in $projectImportUrl', () => {
+ $projectImportUrl.val('https://username:password@gitlab.company.com/group/project.git');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('project');
+ });
+
+ it('changes project path for SSH URL in $projectImportUrl', () => {
+ $projectImportUrl.val('git@gitlab.com:gitlab-org/gitlab-ce.git');
+
+ projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+
+ expect($projectPath.val()).toEqual('gitlab-ce');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/prometheus_metrics/mock_data.js b/spec/javascripts/prometheus_metrics/mock_data.js
new file mode 100644
index 00000000000..3af56df92e2
--- /dev/null
+++ b/spec/javascripts/prometheus_metrics/mock_data.js
@@ -0,0 +1,41 @@
+export const metrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 0,
+ },
+];
+
+export const missingVarMetrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 1,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 3,
+ },
+];
diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
new file mode 100644
index 00000000000..2b3a821dbd9
--- /dev/null
+++ b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
@@ -0,0 +1,158 @@
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+import PANEL_STATE from '~/prometheus_metrics/constants';
+import { metrics, missingVarMetrics } from './mock_data';
+
+describe('PrometheusMetrics', () => {
+ const FIXTURE = 'services/prometheus/prometheus_service.html.raw';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('constructor', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should initialize wrapper element refs on class object', () => {
+ expect(prometheusMetrics.$wrapper).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
+ expect(prometheusMetrics.$panelToggle).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
+ });
+
+ it('should initialize metadata on class object', () => {
+ expect(prometheusMetrics.backOffRequestCounter).toEqual(0);
+ expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test');
+ });
+ });
+
+ describe('showMonitoringMetricsPanelState', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loading state when called with `loading`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+
+ it('should show metrics list when called with `list`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should show empty state when called with `empty`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('populateActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show monitored metrics list', () => {
+ prometheusMetrics.populateActiveMetrics(metrics);
+
+ const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li');
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('12');
+ expect($metricsListLi.length).toEqual(metrics.length);
+ expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`);
+ });
+
+ it('should show missing environment variables list', () => {
+ prometheusMetrics.populateActiveMetrics(missingVarMetrics);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2');
+ expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2);
+ expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined();
+ });
+ });
+
+ describe('loadActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loader animation while response is being loaded and hide it when request is complete', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+
+ prometheusMetrics.loadActiveMetrics();
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect($.getJSON).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
+
+ deferred.resolve({ data: metrics, success: true });
+
+ setTimeout(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should show empty state if response failed to load', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn(prometheusMetrics, 'populateActiveMetrics');
+
+ prometheusMetrics.loadActiveMetrics();
+
+ deferred.reject();
+
+ setTimeout(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should populate metrics list once response is loaded', (done) => {
+ const deferred = $.Deferred();
+ spyOn($, 'getJSON').and.returnValue(deferred.promise());
+ spyOn(prometheusMetrics, 'populateActiveMetrics');
+
+ prometheusMetrics.loadActiveMetrics();
+
+ deferred.resolve({ data: metrics, success: true });
+
+ setTimeout(() => {
+ expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js
new file mode 100644
index 00000000000..a503a54029f
--- /dev/null
+++ b/spec/javascripts/raven/index_spec.js
@@ -0,0 +1,44 @@
+import RavenConfig from '~/raven/raven_config';
+import index from '~/raven/index';
+
+describe('RavenConfig options', () => {
+ const sentryDsn = 'sentryDsn';
+ const currentUserId = 'currentUserId';
+ const gitlabUrl = 'gitlabUrl';
+ const isProduction = 'isProduction';
+ const revision = 'revision';
+ let indexReturnValue;
+
+ beforeEach(() => {
+ window.gon = {
+ sentry_dsn: sentryDsn,
+ current_user_id: currentUserId,
+ gitlab_url: gitlabUrl,
+ revision,
+ };
+
+ process.env.NODE_ENV = isProduction;
+ process.env.HEAD_COMMIT_SHA = revision;
+
+ spyOn(RavenConfig, 'init');
+
+ indexReturnValue = index();
+ });
+
+ it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => {
+ expect(RavenConfig.init).toHaveBeenCalledWith({
+ sentryDsn,
+ currentUserId,
+ whitelistUrls: [gitlabUrl],
+ isProduction,
+ release: revision,
+ tags: {
+ revision,
+ },
+ });
+ });
+
+ it('should return RavenConfig', () => {
+ expect(indexReturnValue).toBe(RavenConfig);
+ });
+});
diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js
new file mode 100644
index 00000000000..c82658b9262
--- /dev/null
+++ b/spec/javascripts/raven/raven_config_spec.js
@@ -0,0 +1,254 @@
+import Raven from 'raven-js';
+import RavenConfig from '~/raven/raven_config';
+
+describe('RavenConfig', () => {
+ describe('IGNORE_ERRORS', () => {
+ it('should be an array of strings', () => {
+ const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string');
+
+ expect(areStrings).toBe(true);
+ });
+ });
+
+ describe('IGNORE_URLS', () => {
+ it('should be an array of regexps', () => {
+ const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp);
+
+ expect(areRegExps).toBe(true);
+ });
+ });
+
+ describe('SAMPLE_RATE', () => {
+ it('should be a finite number', () => {
+ expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number');
+ });
+ });
+
+ describe('init', () => {
+ const options = {
+ currentUserId: 1,
+ };
+
+ beforeEach(() => {
+ spyOn(RavenConfig, 'configure');
+ spyOn(RavenConfig, 'bindRavenErrors');
+ spyOn(RavenConfig, 'setUser');
+
+ RavenConfig.init(options);
+ });
+
+ it('should set the options property', () => {
+ expect(RavenConfig.options).toEqual(options);
+ });
+
+ it('should call the configure method', () => {
+ expect(RavenConfig.configure).toHaveBeenCalled();
+ });
+
+ it('should call the error bindings method', () => {
+ expect(RavenConfig.bindRavenErrors).toHaveBeenCalled();
+ });
+
+ it('should call setUser', () => {
+ expect(RavenConfig.setUser).toHaveBeenCalled();
+ });
+
+ it('should not call setUser if there is no current user ID', () => {
+ RavenConfig.setUser.calls.reset();
+
+ options.currentUserId = undefined;
+
+ RavenConfig.init(options);
+
+ expect(RavenConfig.setUser).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('configure', () => {
+ let raven;
+ let ravenConfig;
+ const options = {
+ sentryDsn: '//sentryDsn',
+ whitelistUrls: ['//gitlabUrl'],
+ isProduction: true,
+ release: 'revision',
+ tags: {
+ revision: 'revision',
+ },
+ };
+
+ beforeEach(() => {
+ ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']);
+ raven = jasmine.createSpyObj('raven', ['install']);
+
+ spyOn(Raven, 'config').and.returnValue(raven);
+
+ ravenConfig.options = options;
+ ravenConfig.IGNORE_ERRORS = 'ignore_errors';
+ ravenConfig.IGNORE_URLS = 'ignore_urls';
+
+ RavenConfig.configure.call(ravenConfig);
+ });
+
+ it('should call Raven.config', () => {
+ expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, {
+ release: options.release,
+ tags: options.tags,
+ whitelistUrls: options.whitelistUrls,
+ environment: 'production',
+ ignoreErrors: ravenConfig.IGNORE_ERRORS,
+ ignoreUrls: ravenConfig.IGNORE_URLS,
+ shouldSendCallback: jasmine.any(Function),
+ });
+ });
+
+ it('should call Raven.install', () => {
+ expect(raven.install).toHaveBeenCalled();
+ });
+
+ it('should set .environment to development if isProduction is false', () => {
+ ravenConfig.options.isProduction = false;
+
+ RavenConfig.configure.call(ravenConfig);
+
+ expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, {
+ release: options.release,
+ tags: options.tags,
+ whitelistUrls: options.whitelistUrls,
+ environment: 'development',
+ ignoreErrors: ravenConfig.IGNORE_ERRORS,
+ ignoreUrls: ravenConfig.IGNORE_URLS,
+ shouldSendCallback: jasmine.any(Function),
+ });
+ });
+ });
+
+ describe('setUser', () => {
+ let ravenConfig;
+
+ beforeEach(() => {
+ ravenConfig = { options: { currentUserId: 1 } };
+ spyOn(Raven, 'setUserContext');
+
+ RavenConfig.setUser.call(ravenConfig);
+ });
+
+ it('should call .setUserContext', function () {
+ expect(Raven.setUserContext).toHaveBeenCalledWith({
+ id: ravenConfig.options.currentUserId,
+ });
+ });
+ });
+
+ describe('handleRavenErrors', () => {
+ let event;
+ let req;
+ let config;
+ let err;
+
+ beforeEach(() => {
+ event = {};
+ req = { status: 'status', responseText: 'responseText', statusText: 'statusText' };
+ config = { type: 'type', url: 'url', data: 'data' };
+ err = {};
+
+ spyOn(Raven, 'captureMessage');
+
+ RavenConfig.handleRavenErrors(event, req, config, err);
+ });
+
+ it('should call Raven.captureMessage', () => {
+ expect(Raven.captureMessage).toHaveBeenCalledWith(err, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: req.responseText,
+ error: err,
+ event,
+ },
+ });
+ });
+
+ describe('if no err is provided', () => {
+ beforeEach(() => {
+ Raven.captureMessage.calls.reset();
+
+ RavenConfig.handleRavenErrors(event, req, config);
+ });
+
+ it('should use req.statusText as the error value', () => {
+ expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: req.responseText,
+ error: req.statusText,
+ event,
+ },
+ });
+ });
+ });
+
+ describe('if no req.responseText is provided', () => {
+ beforeEach(() => {
+ req.responseText = undefined;
+
+ Raven.captureMessage.calls.reset();
+
+ RavenConfig.handleRavenErrors(event, req, config, err);
+ });
+
+ it('should use `Unknown response text` as the response', () => {
+ expect(Raven.captureMessage).toHaveBeenCalledWith(err, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: 'Unknown response text',
+ error: err,
+ event,
+ },
+ });
+ });
+ });
+ });
+
+ describe('shouldSendSample', () => {
+ let randomNumber;
+
+ beforeEach(() => {
+ RavenConfig.SAMPLE_RATE = 50;
+
+ spyOn(Math, 'random').and.callFake(() => randomNumber);
+ });
+
+ it('should call Math.random', () => {
+ RavenConfig.shouldSendSample();
+
+ expect(Math.random).toHaveBeenCalled();
+ });
+
+ it('should return true if the sample rate is greater than the random number * 100', () => {
+ randomNumber = 0.1;
+
+ expect(RavenConfig.shouldSendSample()).toBe(true);
+ });
+
+ it('should return false if the sample rate is less than the random number * 100', () => {
+ randomNumber = 0.9;
+
+ expect(RavenConfig.shouldSendSample()).toBe(false);
+ });
+
+ it('should return true if the sample rate is equal to the random number * 100', () => {
+ randomNumber = 0.5;
+
+ expect(RavenConfig.shouldSendSample()).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js
new file mode 100644
index 00000000000..249a2f36fcd
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_commit_section_spec.js
@@ -0,0 +1,160 @@
+import Vue from 'vue';
+import repoCommitSection from '~/repo/components/repo_commit_section.vue';
+import RepoStore from '~/repo/stores/repo_store';
+import Api from '~/api';
+
+describe('RepoCommitSection', () => {
+ const branch = 'master';
+ const projectUrl = 'projectUrl';
+ const changedFiles = [{
+ id: 0,
+ changed: true,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`,
+ path: 'dir/file0.ext',
+ newContent: 'a',
+ }, {
+ id: 1,
+ changed: true,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`,
+ path: 'dir/file1.ext',
+ newContent: 'b',
+ }];
+ const openedFiles = changedFiles.concat([{
+ id: 2,
+ url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`,
+ path: 'dir/file2.ext',
+ changed: false,
+ }]);
+
+ RepoStore.projectUrl = projectUrl;
+
+ function createComponent(el) {
+ const RepoCommitSection = Vue.extend(repoCommitSection);
+
+ return new RepoCommitSection().$mount(el);
+ }
+
+ it('renders a commit section', () => {
+ RepoStore.isCommitable = true;
+ RepoStore.currentBranch = branch;
+ RepoStore.targetBranch = branch;
+ RepoStore.openedFiles = openedFiles;
+
+ const vm = createComponent();
+ const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')];
+ const commitMessage = vm.$el.querySelector('#commit-message');
+ const submitCommit = vm.$refs.submitCommit;
+ const targetBranch = vm.$el.querySelector('.target-branch');
+
+ expect(vm.$el.querySelector(':scope > form')).toBeTruthy();
+ expect(vm.$el.querySelector('.staged-files').textContent.trim()).toEqual('Staged files (2)');
+ expect(changedFileElements.length).toEqual(2);
+
+ changedFileElements.forEach((changedFile, i) => {
+ expect(changedFile.textContent.trim()).toEqual(changedFiles[i].path);
+ });
+
+ expect(commitMessage.tagName).toEqual('TEXTAREA');
+ expect(commitMessage.name).toEqual('commit-message');
+ expect(submitCommit.type).toEqual('submit');
+ expect(submitCommit.disabled).toBeTruthy();
+ expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy();
+ expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files');
+ expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch');
+ expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual(branch);
+ });
+
+ it('does not render if not isCommitable', () => {
+ RepoStore.isCommitable = false;
+ RepoStore.openedFiles = [{
+ id: 0,
+ changed: true,
+ }];
+
+ const vm = createComponent();
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+
+ it('does not render if no changedFiles', () => {
+ RepoStore.isCommitable = true;
+ RepoStore.openedFiles = [];
+
+ const vm = createComponent();
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+
+ it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => {
+ const projectId = 'projectId';
+ const commitMessage = 'commitMessage';
+ RepoStore.isCommitable = true;
+ RepoStore.currentBranch = branch;
+ RepoStore.targetBranch = branch;
+ RepoStore.openedFiles = openedFiles;
+ RepoStore.projectId = projectId;
+
+ // We need to append to body to get form `submit` events working
+ // Otherwise we run into, "Form submission canceled because the form is not connected"
+ // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+
+ const vm = createComponent(el);
+ const commitMessageEl = vm.$el.querySelector('#commit-message');
+ const submitCommit = vm.$refs.submitCommit;
+
+ vm.commitMessage = commitMessage;
+
+ Vue.nextTick(() => {
+ expect(commitMessageEl.value).toBe(commitMessage);
+ expect(submitCommit.disabled).toBeFalsy();
+
+ spyOn(vm, 'makeCommit').and.callThrough();
+ spyOn(Api, 'commitMultiple');
+
+ submitCommit.click();
+
+ Vue.nextTick(() => {
+ expect(vm.makeCommit).toHaveBeenCalled();
+ expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeTruthy();
+
+ const args = Api.commitMultiple.calls.allArgs()[0];
+ const { commit_message, actions, branch: payloadBranch } = args[1];
+
+ expect(args[0]).toBe(projectId);
+ expect(commit_message).toBe(commitMessage);
+ expect(actions.length).toEqual(2);
+ expect(payloadBranch).toEqual(branch);
+ expect(actions[0].action).toEqual('update');
+ expect(actions[1].action).toEqual('update');
+ expect(actions[0].content).toEqual(openedFiles[0].newContent);
+ expect(actions[1].content).toEqual(openedFiles[1].newContent);
+ expect(actions[0].file_path).toEqual(openedFiles[0].path);
+ expect(actions[1].file_path).toEqual(openedFiles[1].path);
+
+ done();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('resetCommitState', () => {
+ it('should reset store vars and scroll to top', () => {
+ const vm = {
+ submitCommitsLoading: true,
+ changedFiles: new Array(10),
+ commitMessage: 'commitMessage',
+ editMode: true,
+ };
+
+ repoCommitSection.methods.resetCommitState.call(vm);
+
+ expect(vm.submitCommitsLoading).toEqual(false);
+ expect(vm.changedFiles).toEqual([]);
+ expect(vm.commitMessage).toEqual('');
+ expect(vm.editMode).toEqual(false);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js
new file mode 100644
index 00000000000..29dc2d21e4b
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_edit_button_spec.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import repoEditButton from '~/repo/components/repo_edit_button.vue';
+import RepoStore from '~/repo/stores/repo_store';
+
+describe('RepoEditButton', () => {
+ function createComponent() {
+ const RepoEditButton = Vue.extend(repoEditButton);
+
+ return new RepoEditButton().$mount();
+ }
+
+ it('renders an edit button that toggles the view state', (done) => {
+ RepoStore.isCommitable = true;
+ RepoStore.changedFiles = [];
+ RepoStore.binary = false;
+ RepoStore.openedFiles = [{}, {}];
+
+ const vm = createComponent();
+
+ expect(vm.$el.tagName).toEqual('BUTTON');
+ expect(vm.$el.textContent).toMatch('Edit');
+
+ spyOn(vm, 'editCancelClicked').and.callThrough();
+ spyOn(vm, 'toggleProjectRefsForm');
+
+ vm.$el.click();
+
+ Vue.nextTick(() => {
+ expect(vm.editCancelClicked).toHaveBeenCalled();
+ expect(vm.toggleProjectRefsForm).toHaveBeenCalled();
+ expect(vm.$el.textContent).toMatch('Cancel edit');
+ done();
+ });
+ });
+
+ it('does not render if not isCommitable', () => {
+ RepoStore.isCommitable = false;
+
+ const vm = createComponent();
+
+ expect(vm.$el.innerHTML).toBeUndefined();
+ });
+
+ describe('methods', () => {
+ describe('editCancelClicked', () => {
+ it('sets dialog to open when there are changedFiles');
+
+ it('toggles editMode and calls toggleBlobView');
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js
new file mode 100644
index 00000000000..85d55d171f9
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_editor_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import repoEditor from '~/repo/components/repo_editor.vue';
+
+describe('RepoEditor', () => {
+ beforeEach(() => {
+ const RepoEditor = Vue.extend(repoEditor);
+
+ this.vm = new RepoEditor().$mount();
+ });
+
+ it('renders an ide container', (done) => {
+ this.vm.openedFiles = ['idiidid'];
+ this.vm.binary = false;
+
+ Vue.nextTick(() => {
+ expect(this.vm.shouldHideEditor).toBe(false);
+ expect(this.vm.$el.id).toEqual('ide');
+ expect(this.vm.$el.tagName).toBe('DIV');
+ done();
+ });
+ });
+
+ describe('when there are no open files', () => {
+ it('does not render the ide', (done) => {
+ this.vm.openedFiles = [];
+
+ Vue.nextTick(() => {
+ expect(this.vm.shouldHideEditor).toBe(true);
+ expect(this.vm.$el.tagName).not.toBeDefined();
+ done();
+ });
+ });
+ });
+
+ describe('when open file is binary and not raw', () => {
+ it('does not render the IDE', (done) => {
+ this.vm.binary = true;
+ this.vm.activeFile = {
+ raw: false,
+ };
+
+ Vue.nextTick(() => {
+ expect(this.vm.shouldHideEditor).toBe(true);
+ expect(this.vm.$el.tagName).not.toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js
new file mode 100644
index 00000000000..dfab51710c3
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js
@@ -0,0 +1,75 @@
+import Vue from 'vue';
+import repoFileButtons from '~/repo/components/repo_file_buttons.vue';
+import RepoStore from '~/repo/stores/repo_store';
+
+describe('RepoFileButtons', () => {
+ function createComponent() {
+ const RepoFileButtons = Vue.extend(repoFileButtons);
+
+ return new RepoFileButtons().$mount();
+ }
+
+ it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
+ const activeFile = {
+ extension: 'md',
+ url: 'url',
+ raw_path: 'raw_path',
+ blame_path: 'blame_path',
+ commits_path: 'commits_path',
+ permalink: 'permalink',
+ };
+ const activeFileLabel = 'activeFileLabel';
+ RepoStore.openedFiles = new Array(1);
+ RepoStore.activeFile = activeFile;
+ RepoStore.activeFileLabel = activeFileLabel;
+ RepoStore.editMode = true;
+ RepoStore.binary = false;
+
+ const vm = createComponent();
+ const raw = vm.$el.querySelector('.raw');
+ const blame = vm.$el.querySelector('.blame');
+ const history = vm.$el.querySelector('.history');
+
+ expect(vm.$el.id).toEqual('repo-file-buttons');
+ expect(raw.href).toMatch(`/${activeFile.raw_path}`);
+ expect(raw.textContent.trim()).toEqual('Raw');
+ expect(blame.href).toMatch(`/${activeFile.blame_path}`);
+ expect(blame.textContent.trim()).toEqual('Blame');
+ expect(history.href).toMatch(`/${activeFile.commits_path}`);
+ expect(history.textContent.trim()).toEqual('History');
+ expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
+ expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel);
+ });
+
+ it('triggers rawPreviewToggle on preview click', () => {
+ const activeFile = {
+ extension: 'md',
+ url: 'url',
+ };
+ RepoStore.openedFiles = new Array(1);
+ RepoStore.activeFile = activeFile;
+ RepoStore.editMode = true;
+
+ const vm = createComponent();
+ const preview = vm.$el.querySelector('.preview');
+
+ spyOn(vm, 'rawPreviewToggle');
+
+ preview.click();
+
+ expect(vm.rawPreviewToggle).toHaveBeenCalled();
+ });
+
+ it('does not render preview toggle if not canPreview', () => {
+ const activeFile = {
+ extension: 'abcd',
+ url: 'url',
+ };
+ RepoStore.openedFiles = new Array(1);
+ RepoStore.activeFile = activeFile;
+
+ const vm = createComponent();
+
+ expect(vm.$el.querySelector('.preview')).toBeFalsy();
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_file_options_spec.js b/spec/javascripts/repo/components/repo_file_options_spec.js
new file mode 100644
index 00000000000..9759b4bf12d
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_file_options_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import repoFileOptions from '~/repo/components/repo_file_options.vue';
+
+describe('RepoFileOptions', () => {
+ const projectName = 'projectName';
+
+ function createComponent(propsData) {
+ const RepoFileOptions = Vue.extend(repoFileOptions);
+
+ return new RepoFileOptions({
+ propsData,
+ }).$mount();
+ }
+
+ it('renders the title and new file/folder buttons if isMini is true', () => {
+ const vm = createComponent({
+ isMini: true,
+ projectName,
+ });
+
+ expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy();
+ expect(vm.$el.querySelector('.title').textContent).toEqual(projectName);
+ });
+
+ it('does not render if isMini is false', () => {
+ const vm = createComponent({
+ isMini: false,
+ projectName,
+ });
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js
new file mode 100644
index 00000000000..518a2d25ecf
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_file_spec.js
@@ -0,0 +1,136 @@
+import Vue from 'vue';
+import repoFile from '~/repo/components/repo_file.vue';
+
+describe('RepoFile', () => {
+ const updated = 'updated';
+ const file = {
+ icon: 'icon',
+ url: 'url',
+ name: 'name',
+ lastCommitMessage: 'message',
+ lastCommitUpdate: Date.now(),
+ level: 10,
+ };
+ const activeFile = {
+ url: 'url',
+ };
+
+ function createComponent(propsData) {
+ const RepoFile = Vue.extend(repoFile);
+
+ return new RepoFile({
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ spyOn(repoFile.mixins[0].methods, 'timeFormated').and.returnValue(updated);
+ });
+
+ it('renders link, icon, name and last commit details', () => {
+ const vm = createComponent({
+ file,
+ activeFile,
+ });
+ const name = vm.$el.querySelector('.repo-file-name');
+ const fileIcon = vm.$el.querySelector('.file-icon');
+
+ expect(vm.$el.classList.contains('active')).toBeTruthy();
+ expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px');
+ expect(name.title).toEqual(file.url);
+ expect(name.href).toMatch(`/${file.url}`);
+ expect(name.textContent.trim()).toEqual(file.name);
+ expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(file.lastCommitMessage);
+ expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated);
+ expect(fileIcon.classList.contains(file.icon)).toBeTruthy();
+ expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`);
+ });
+
+ it('does render if hasFiles is true and is loading tree', () => {
+ const vm = createComponent({
+ file,
+ activeFile,
+ loading: {
+ tree: true,
+ },
+ hasFiles: true,
+ });
+
+ expect(vm.$el.innerHTML).toBeTruthy();
+ expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
+ });
+
+ it('renders a spinner if the file is loading', () => {
+ file.loading = true;
+ const vm = createComponent({
+ file,
+ activeFile,
+ loading: {
+ tree: true,
+ },
+ hasFiles: true,
+ });
+
+ expect(vm.$el.innerHTML).toBeTruthy();
+ expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`);
+ });
+
+ it('does not render if loading tree', () => {
+ const vm = createComponent({
+ file,
+ activeFile,
+ loading: {
+ tree: true,
+ },
+ });
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+
+ it('does not render commit message and datetime if mini', () => {
+ const vm = createComponent({
+ file,
+ activeFile,
+ isMini: true,
+ });
+
+ expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
+ expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
+ });
+
+ it('does not set active class if file is active file', () => {
+ const vm = createComponent({
+ file,
+ activeFile: {},
+ });
+
+ expect(vm.$el.classList.contains('active')).toBeFalsy();
+ });
+
+ it('fires linkClicked when the link is clicked', () => {
+ const vm = createComponent({
+ file,
+ activeFile,
+ });
+
+ spyOn(vm, 'linkClicked');
+
+ vm.$el.querySelector('.repo-file-name').click();
+
+ expect(vm.linkClicked).toHaveBeenCalledWith(file);
+ });
+
+ describe('methods', () => {
+ describe('linkClicked', () => {
+ const vm = jasmine.createSpyObj('vm', ['$emit']);
+
+ it('$emits linkclicked with file obj', () => {
+ const theFile = {};
+
+ repoFile.methods.linkClicked.call(vm, theFile);
+
+ expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js
new file mode 100644
index 00000000000..a030314d749
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_loading_file_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
+
+describe('RepoLoadingFile', () => {
+ function createComponent(propsData) {
+ const RepoLoadingFile = Vue.extend(repoLoadingFile);
+
+ return new RepoLoadingFile({
+ propsData,
+ }).$mount();
+ }
+
+ function assertLines(lines) {
+ lines.forEach((line, n) => {
+ const index = n + 1;
+ expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy();
+ });
+ }
+
+ function assertColumns(columns) {
+ columns.forEach((column) => {
+ const container = column.querySelector('.animation-container');
+ const lines = [...container.querySelectorAll(':scope > div')];
+
+ expect(container).toBeTruthy();
+ expect(lines.length).toEqual(6);
+ assertLines(lines);
+ });
+ }
+
+ it('renders 3 columns of animated LoC', () => {
+ const vm = createComponent({
+ loading: {
+ tree: true,
+ },
+ hasFiles: false,
+ });
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(3);
+ assertColumns(columns);
+ });
+
+ it('renders 1 column of animated LoC if isMini', () => {
+ const vm = createComponent({
+ loading: {
+ tree: true,
+ },
+ hasFiles: false,
+ isMini: true,
+ });
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(1);
+ assertColumns(columns);
+ });
+
+ it('does not render if tree is not loading', () => {
+ const vm = createComponent({
+ loading: {
+ tree: false,
+ },
+ hasFiles: false,
+ });
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+
+ it('does not render if hasFiles is true', () => {
+ const vm = createComponent({
+ loading: {
+ tree: true,
+ },
+ hasFiles: true,
+ });
+
+ expect(vm.$el.innerHTML).toBeFalsy();
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js
new file mode 100644
index 00000000000..34dde545e6a
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
+
+describe('RepoPrevDirectory', () => {
+ function createComponent(propsData) {
+ const RepoPrevDirectory = Vue.extend(repoPrevDirectory);
+
+ return new RepoPrevDirectory({
+ propsData,
+ }).$mount();
+ }
+
+ it('renders a prev dir link', () => {
+ const prevUrl = 'prevUrl';
+ const vm = createComponent({
+ prevUrl,
+ });
+ const link = vm.$el.querySelector('a');
+
+ spyOn(vm, 'linkClicked');
+
+ expect(link.href).toMatch(`/${prevUrl}`);
+ expect(link.textContent).toEqual('..');
+
+ link.click();
+
+ expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl);
+ });
+
+ describe('methods', () => {
+ describe('linkClicked', () => {
+ const vm = jasmine.createSpyObj('vm', ['$emit']);
+
+ it('$emits linkclicked with file obj', () => {
+ const file = {};
+
+ repoPrevDirectory.methods.linkClicked.call(vm, file);
+
+ expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_preview_spec.js b/spec/javascripts/repo/components/repo_preview_spec.js
new file mode 100644
index 00000000000..4920cf02083
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_preview_spec.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import repoPreview from '~/repo/components/repo_preview.vue';
+import RepoStore from '~/repo/stores/repo_store';
+
+describe('RepoPreview', () => {
+ function createComponent() {
+ const RepoPreview = Vue.extend(repoPreview);
+
+ return new RepoPreview().$mount();
+ }
+
+ it('renders a div with the activeFile html', () => {
+ const activeFile = {
+ html: '<p class="file-content">html</p>',
+ };
+ RepoStore.activeFile = activeFile;
+
+ const vm = createComponent();
+
+ expect(vm.$el.tagName).toEqual('DIV');
+ expect(vm.$el.innerHTML).toContain(activeFile.html);
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js
new file mode 100644
index 00000000000..abcff8e537e
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_sidebar_spec.js
@@ -0,0 +1,111 @@
+import Vue from 'vue';
+import Helper from '~/repo/helpers/repo_helper';
+import RepoService from '~/repo/services/repo_service';
+import RepoStore from '~/repo/stores/repo_store';
+import repoSidebar from '~/repo/components/repo_sidebar.vue';
+
+describe('RepoSidebar', () => {
+ function createComponent() {
+ const RepoSidebar = Vue.extend(repoSidebar);
+
+ return new RepoSidebar().$mount();
+ }
+
+ it('renders a sidebar', () => {
+ RepoStore.files = [{
+ id: 0,
+ }];
+ RepoStore.openedFiles = [];
+ const vm = createComponent();
+ const thead = vm.$el.querySelector('thead');
+ const tbody = vm.$el.querySelector('tbody');
+
+ expect(vm.$el.id).toEqual('sidebar');
+ expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
+ expect(thead.querySelector('.name').textContent).toEqual('Name');
+ expect(thead.querySelector('.last-commit').textContent).toEqual('Last Commit');
+ expect(thead.querySelector('.last-update').textContent).toEqual('Last Update');
+ expect(tbody.querySelector('.repo-file-options')).toBeFalsy();
+ expect(tbody.querySelector('.prev-directory')).toBeFalsy();
+ expect(tbody.querySelector('.loading-file')).toBeFalsy();
+ expect(tbody.querySelector('.file')).toBeTruthy();
+ });
+
+ it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => {
+ RepoStore.openedFiles = [{
+ id: 0,
+ }];
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
+ expect(vm.$el.querySelector('thead')).toBeFalsy();
+ expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy();
+ });
+
+ it('renders 5 loading files if tree is loading and not hasFiles', () => {
+ RepoStore.loading = {
+ tree: true,
+ };
+ RepoStore.files = [];
+ const vm = createComponent();
+
+ expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
+ });
+
+ it('renders a prev directory if isRoot', () => {
+ RepoStore.files = [{
+ id: 0,
+ }];
+ RepoStore.isRoot = true;
+ const vm = createComponent();
+
+ expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
+ });
+
+ describe('methods', () => {
+ describe('fileClicked', () => {
+ it('should fetch data for new file', () => {
+ spyOn(Helper, 'getContent').and.callThrough();
+ const file1 = {
+ id: 0,
+ url: '',
+ };
+ RepoStore.files = [file1];
+ RepoStore.isRoot = true;
+ const vm = createComponent();
+
+ vm.fileClicked(file1);
+
+ expect(Helper.getContent).toHaveBeenCalledWith(file1);
+ });
+
+ it('should hide files in directory if already open', () => {
+ spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough();
+ const file1 = {
+ id: 0,
+ type: 'tree',
+ url: '',
+ opened: true,
+ };
+ RepoStore.files = [file1];
+ RepoStore.isRoot = true;
+ const vm = createComponent();
+
+ vm.fileClicked(file1);
+
+ expect(RepoStore.removeChildFilesOfTree).toHaveBeenCalledWith(file1);
+ });
+ });
+
+ describe('goToPreviousDirectoryClicked', () => {
+ it('should hide files in directory if already open', () => {
+ const prevUrl = 'foo/bar';
+ const vm = createComponent();
+
+ vm.goToPreviousDirectoryClicked(prevUrl);
+
+ expect(RepoService.url).toEqual(prevUrl);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js
new file mode 100644
index 00000000000..d2a790ad73a
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_tab_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+import repoTab from '~/repo/components/repo_tab.vue';
+
+describe('RepoTab', () => {
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ propsData,
+ }).$mount();
+ }
+
+ it('renders a close link and a name link', () => {
+ const tab = {
+ url: 'url',
+ name: 'name',
+ };
+ const vm = createComponent({
+ tab,
+ });
+ const close = vm.$el.querySelector('.close');
+ const name = vm.$el.querySelector(`a[title="${tab.url}"]`);
+
+ spyOn(vm, 'closeTab');
+ spyOn(vm, 'tabClicked');
+
+ expect(close.querySelector('.fa-times')).toBeTruthy();
+ expect(name.textContent.trim()).toEqual(tab.name);
+
+ close.click();
+ name.click();
+
+ expect(vm.closeTab).toHaveBeenCalledWith(tab);
+ expect(vm.tabClicked).toHaveBeenCalledWith(tab);
+ });
+
+ it('renders an fa-circle icon if tab is changed', () => {
+ const tab = {
+ url: 'url',
+ name: 'name',
+ changed: true,
+ };
+ const vm = createComponent({
+ tab,
+ });
+
+ expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy();
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ const vm = jasmine.createSpyObj('vm', ['$emit']);
+
+ it('returns undefined and does not $emit if file is changed', () => {
+ const file = { changed: true };
+ const returnVal = repoTab.methods.closeTab.call(vm, file);
+
+ expect(returnVal).toBeUndefined();
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('$emits tabclosed event with file obj', () => {
+ const file = { changed: false };
+ repoTab.methods.closeTab.call(vm, file);
+
+ expect(vm.$emit).toHaveBeenCalledWith('tabclosed', file);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js
new file mode 100644
index 00000000000..a02b54efafc
--- /dev/null
+++ b/spec/javascripts/repo/components/repo_tabs_spec.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import RepoStore from '~/repo/stores/repo_store';
+import repoTabs from '~/repo/components/repo_tabs.vue';
+
+describe('RepoTabs', () => {
+ const openedFiles = [{
+ id: 0,
+ active: true,
+ }, {
+ id: 1,
+ }];
+
+ function createComponent() {
+ const RepoTabs = Vue.extend(repoTabs);
+
+ return new RepoTabs().$mount();
+ }
+
+ it('renders a list of tabs', () => {
+ RepoStore.openedFiles = openedFiles;
+
+ const vm = createComponent();
+ const tabs = [...vm.$el.querySelectorAll(':scope > li')];
+
+ expect(vm.$el.id).toEqual('tabs');
+ expect(tabs.length).toEqual(3);
+ expect(tabs[0].classList.contains('active')).toBeTruthy();
+ expect(tabs[1].classList.contains('active')).toBeFalsy();
+ expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
+ });
+
+ describe('methods', () => {
+ describe('tabClosed', () => {
+ it('calls removeFromOpenedFiles with file obj', () => {
+ const file = {};
+
+ spyOn(RepoStore, 'removeFromOpenedFiles');
+
+ repoTabs.methods.tabClosed(file);
+
+ expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/repo/monaco_loader_spec.js b/spec/javascripts/repo/monaco_loader_spec.js
new file mode 100644
index 00000000000..be6e779c50f
--- /dev/null
+++ b/spec/javascripts/repo/monaco_loader_spec.js
@@ -0,0 +1,17 @@
+/* global __webpack_public_path__ */
+import monacoContext from 'monaco-editor/dev/vs/loader';
+
+describe('MonacoLoader', () => {
+ it('calls require.config and exports require', () => {
+ spyOn(monacoContext.require, 'config');
+
+ const monacoLoader = require('~/repo/monaco_loader'); // eslint-disable-line global-require
+
+ expect(monacoContext.require.config).toHaveBeenCalledWith({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+ });
+ expect(monacoLoader.default).toBe(monacoContext.require);
+ });
+});
diff --git a/spec/javascripts/repo/services/repo_service_spec.js b/spec/javascripts/repo/services/repo_service_spec.js
new file mode 100644
index 00000000000..d74e6a67b1e
--- /dev/null
+++ b/spec/javascripts/repo/services/repo_service_spec.js
@@ -0,0 +1,121 @@
+import axios from 'axios';
+import RepoService from '~/repo/services/repo_service';
+
+describe('RepoService', () => {
+ it('has default json format param', () => {
+ expect(RepoService.options.params.format).toBe('json');
+ });
+
+ describe('buildParams', () => {
+ let newParams;
+ const url = 'url';
+
+ beforeEach(() => {
+ newParams = {};
+
+ spyOn(Object, 'assign').and.returnValue(newParams);
+ });
+
+ it('clones params', () => {
+ const params = RepoService.buildParams(url);
+
+ expect(Object.assign).toHaveBeenCalledWith({}, RepoService.options.params);
+
+ expect(params).toBe(newParams);
+ });
+
+ it('sets and returns viewer params to richif urlIsRichBlob is true', () => {
+ spyOn(RepoService, 'urlIsRichBlob').and.returnValue(true);
+
+ const params = RepoService.buildParams(url);
+
+ expect(params.viewer).toEqual('rich');
+ });
+
+ it('returns params urlIsRichBlob is false', () => {
+ spyOn(RepoService, 'urlIsRichBlob').and.returnValue(false);
+
+ const params = RepoService.buildParams(url);
+
+ expect(params.viewer).toBeUndefined();
+ });
+
+ it('calls urlIsRichBlob with the objects url prop if no url arg is provided', () => {
+ spyOn(RepoService, 'urlIsRichBlob');
+ RepoService.url = url;
+
+ RepoService.buildParams();
+
+ expect(RepoService.urlIsRichBlob).toHaveBeenCalledWith(url);
+ });
+ });
+
+ describe('urlIsRichBlob', () => {
+ it('returns true for md extension', () => {
+ const isRichBlob = RepoService.urlIsRichBlob('url.md');
+
+ expect(isRichBlob).toBeTruthy();
+ });
+
+ it('returns false for js extension', () => {
+ const isRichBlob = RepoService.urlIsRichBlob('url.js');
+
+ expect(isRichBlob).toBeFalsy();
+ });
+ });
+
+ describe('getContent', () => {
+ const params = {};
+ const url = 'url';
+ const requestPromise = Promise.resolve();
+
+ beforeEach(() => {
+ spyOn(RepoService, 'buildParams').and.returnValue(params);
+ spyOn(axios, 'get').and.returnValue(requestPromise);
+ });
+
+ it('calls buildParams and axios.get', () => {
+ const request = RepoService.getContent(url);
+
+ expect(RepoService.buildParams).toHaveBeenCalledWith(url);
+ expect(axios.get).toHaveBeenCalledWith(url, {
+ params,
+ });
+ expect(request).toBe(requestPromise);
+ });
+
+ it('uses object url prop if no url arg is provided', () => {
+ RepoService.url = url;
+
+ RepoService.getContent();
+
+ expect(axios.get).toHaveBeenCalledWith(url, {
+ params,
+ });
+ });
+ });
+
+ describe('getBase64Content', () => {
+ const url = 'url';
+ const response = { data: 'data' };
+
+ beforeEach(() => {
+ spyOn(RepoService, 'bufferToBase64');
+ spyOn(axios, 'get').and.returnValue(Promise.resolve(response));
+ });
+
+ it('calls axios.get and bufferToBase64 on completion', (done) => {
+ const request = RepoService.getBase64Content(url);
+
+ expect(axios.get).toHaveBeenCalledWith(url, {
+ responseType: 'arraybuffer',
+ });
+ expect(request).toEqual(jasmine.any(Promise));
+
+ request.then(() => {
+ expect(RepoService.bufferToBase64).toHaveBeenCalledWith(response.data);
+ done();
+ }).catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index aaf058bd755..a53f58b5d0d 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -1,10 +1,9 @@
/* eslint-disable space-before-function-paren, max-len, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign, comma-dangle, object-shorthand, prefer-template, quotes, new-parens, vars-on-top, new-cap, max-len */
-require('~/gl_dropdown');
-require('~/search_autocomplete');
-require('~/lib/utils/common_utils');
-require('~/lib/utils/type_utility');
-require('vendor/fuzzaldrin-plus');
+import '~/gl_dropdown';
+import '~/search_autocomplete';
+import '~/lib/utils/common_utils';
+import 'vendor/fuzzaldrin-plus';
(function() {
var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget;
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index 9e19dabd0e3..3515dfbc60b 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,8 +1,8 @@
/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */
-require('~/copy_as_gfm');
-require('~/shortcuts_issuable');
+import '~/copy_as_gfm';
+import '~/shortcuts_issuable';
(function() {
describe('ShortcutsIssuable', function() {
@@ -13,7 +13,7 @@ require('~/shortcuts_issuable');
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
this.shortcut = new ShortcutsIssuable();
});
- describe('#replyWithSelectedText', function() {
+ describe('replyWithSelectedText', function() {
var stubSelection;
// Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
stubSelection = function(html) {
diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js
new file mode 100644
index 00000000000..ac93f918ce4
--- /dev/null
+++ b/spec/javascripts/sidebar/assignee_title_spec.js
@@ -0,0 +1,105 @@
+import Vue from 'vue';
+import AssigneeTitle from '~/sidebar/components/assignees/assignee_title';
+
+describe('AssigneeTitle component', () => {
+ let component;
+ let AssigneeTitleComponent;
+
+ beforeEach(() => {
+ AssigneeTitleComponent = Vue.extend(AssigneeTitle);
+ });
+
+ describe('assignee title', () => {
+ it('renders assignee', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 1,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.innerText.trim()).toEqual('Assignee');
+ });
+
+ it('renders 2 assignees', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 2,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.innerText.trim()).toEqual('2 Assignees');
+ });
+ });
+
+ describe('gutter toggle', () => {
+ it('does not show toggle by default', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 2,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.gutter-toggle')).toBeNull();
+ });
+
+ it('shows toggle when showToggle is true', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 2,
+ editable: false,
+ showToggle: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.gutter-toggle')).toEqual(jasmine.any(Object));
+ });
+ });
+
+ it('does not render spinner by default', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 0,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.fa')).toBeNull();
+ });
+
+ it('renders spinner when loading', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ loading: true,
+ numberOfAssignees: 0,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.fa')).not.toBeNull();
+ });
+
+ it('does not render edit link when not editable', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 0,
+ editable: false,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.edit-link')).toBeNull();
+ });
+
+ it('renders edit link when editable', () => {
+ component = new AssigneeTitleComponent({
+ propsData: {
+ numberOfAssignees: 0,
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.edit-link')).not.toBeNull();
+ });
+});
diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js
new file mode 100644
index 00000000000..c9453a21189
--- /dev/null
+++ b/spec/javascripts/sidebar/assignees_spec.js
@@ -0,0 +1,272 @@
+import Vue from 'vue';
+import Assignee from '~/sidebar/components/assignees/assignees';
+import UsersMock from './mock_data';
+import UsersMockHelper from '../helpers/user_mock_data_helper';
+
+describe('Assignee component', () => {
+ let component;
+ let AssigneeComponent;
+
+ beforeEach(() => {
+ AssigneeComponent = Vue.extend(Assignee);
+ });
+
+ describe('No assignees/users', () => {
+ it('displays no assignee icon when collapsed', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: false,
+ },
+ }).$mount();
+
+ const collapsed = component.$el.querySelector('.sidebar-collapsed-icon');
+ expect(collapsed.childElementCount).toEqual(1);
+ expect(collapsed.children[0].getAttribute('aria-label')).toEqual('No Assignee');
+ expect(collapsed.children[0].classList.contains('fa')).toEqual(true);
+ expect(collapsed.children[0].classList.contains('fa-user')).toEqual(true);
+ });
+
+ it('displays only "No assignee" when no users are assigned and the issue is read-only', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: false,
+ },
+ }).$mount();
+ const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim();
+
+ expect(componentTextNoUsers).toBe('No assignee');
+ expect(componentTextNoUsers.indexOf('assign yourself')).toEqual(-1);
+ });
+
+ it('displays only "No assignee" when no users are assigned and the issue can be edited', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: true,
+ },
+ }).$mount();
+ const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim();
+
+ expect(componentTextNoUsers.indexOf('No assignee')).toEqual(0);
+ expect(componentTextNoUsers.indexOf('assign yourself')).toBeGreaterThan(0);
+ });
+
+ it('emits the assign-self event when "assign yourself" is clicked', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: true,
+ },
+ }).$mount();
+
+ spyOn(component, '$emit');
+ component.$el.querySelector('.assign-yourself .btn-link').click();
+ expect(component.$emit).toHaveBeenCalledWith('assign-self');
+ });
+ });
+
+ describe('One assignee/user', () => {
+ it('displays one assignee icon when collapsed', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users: [
+ UsersMock.user,
+ ],
+ editable: false,
+ },
+ }).$mount();
+
+ const collapsed = component.$el.querySelector('.sidebar-collapsed-icon');
+ const assignee = collapsed.children[0];
+ expect(collapsed.childElementCount).toEqual(1);
+ expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatar);
+ expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`);
+ expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name);
+ });
+
+ it('Shows one user with avatar, username and author name', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000/',
+ users: [
+ UsersMock.user,
+ ],
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.author_link')).not.toBeNull();
+ // The image
+ expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatar);
+ // Author name
+ expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name);
+ // Username
+ expect(component.$el.querySelector('.author_link .username').innerText.trim()).toEqual(`@${UsersMock.user.username}`);
+ });
+
+ it('has the root url present in the assigneeUrl method', () => {
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000/',
+ users: [
+ UsersMock.user,
+ ],
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(-1);
+ });
+ });
+
+ describe('Two or more assignees/users', () => {
+ it('displays two assignee icons when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: false,
+ },
+ }).$mount();
+
+ const collapsed = component.$el.querySelector('.sidebar-collapsed-icon');
+ expect(collapsed.childElementCount).toEqual(2);
+
+ const first = collapsed.children[0];
+ expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar);
+ expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`);
+ expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name);
+
+ const second = collapsed.children[1];
+ expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatar);
+ expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`);
+ expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name);
+ });
+
+ it('displays one assignee icon and counter when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: false,
+ },
+ }).$mount();
+
+ const collapsed = component.$el.querySelector('.sidebar-collapsed-icon');
+ expect(collapsed.childElementCount).toEqual(2);
+
+ const first = collapsed.children[0];
+ expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar);
+ expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`);
+ expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name);
+
+ const second = collapsed.children[1];
+ expect(second.querySelector('.avatar-counter').innerText.trim()).toEqual('+2');
+ });
+
+ it('Shows two assignees', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelectorAll('.user-item').length).toEqual(users.length);
+ expect(component.$el.querySelector('.user-list-more')).toBe(null);
+ });
+
+ it('Shows the "show-less" assignees label', (done) => {
+ const users = UsersMockHelper.createNumberRandomUsers(6);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.$el.querySelectorAll('.user-item').length).toEqual(component.defaultRenderCount);
+ expect(component.$el.querySelector('.user-list-more')).not.toBe(null);
+ const usersLabelExpectation = users.length - component.defaultRenderCount;
+ expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim())
+ .not.toBe(`+${usersLabelExpectation} more`);
+ component.toggleShowLess();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim())
+ .toBe('- show less');
+ done();
+ });
+ });
+
+ it('Shows the "show-less" when "n+ more " label is clicked', (done) => {
+ const users = UsersMockHelper.createNumberRandomUsers(6);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: true,
+ },
+ }).$mount();
+
+ component.$el.querySelector('.user-list-more .btn-link').click();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim())
+ .toBe('- show less');
+ done();
+ });
+ });
+
+ it('gets the count of avatar via a computed property ', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(6);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: true,
+ },
+ }).$mount();
+
+ expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`);
+ });
+
+ describe('n+ more label', () => {
+ beforeEach(() => {
+ const users = UsersMockHelper.createNumberRandomUsers(6);
+ component = new AssigneeComponent({
+ propsData: {
+ rootPath: 'http://localhost:3000',
+ users,
+ editable: true,
+ },
+ }).$mount();
+ });
+
+ it('shows "+1 more" label', () => {
+ expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim())
+ .toBe('+ 1 more');
+ });
+
+ it('shows "show less" label', (done) => {
+ component.toggleShowLess();
+
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim())
+ .toBe('- show less');
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/sidebar/confidential_edit_buttons_spec.js b/spec/javascripts/sidebar/confidential_edit_buttons_spec.js
new file mode 100644
index 00000000000..482be466aad
--- /dev/null
+++ b/spec/javascripts/sidebar/confidential_edit_buttons_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
+
+describe('Edit Form Buttons', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(editFormButtons);
+ const toggleForm = () => { };
+ const updateConfidentialAttribute = () => { };
+
+ vm1 = new Component({
+ propsData: {
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+ });
+
+ it('renders on or off text based on confidentiality', () => {
+ expect(
+ vm1.$el.innerHTML.includes('Turn Off'),
+ ).toBe(true);
+
+ expect(
+ vm2.$el.innerHTML.includes('Turn On'),
+ ).toBe(true);
+ });
+});
diff --git a/spec/javascripts/sidebar/confidential_edit_form_buttons_spec.js b/spec/javascripts/sidebar/confidential_edit_form_buttons_spec.js
new file mode 100644
index 00000000000..724f5126945
--- /dev/null
+++ b/spec/javascripts/sidebar/confidential_edit_form_buttons_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import editForm from '~/sidebar/components/confidential/edit_form.vue';
+
+describe('Edit Form Dropdown', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(editForm);
+ const toggleForm = () => { };
+ const updateConfidentialAttribute = () => { };
+
+ vm1 = new Component({
+ propsData: {
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+ });
+
+ it('renders on the appropriate warning text', () => {
+ expect(
+ vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.'),
+ ).toBe(true);
+
+ expect(
+ vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.'),
+ ).toBe(true);
+ });
+});
diff --git a/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js
new file mode 100644
index 00000000000..88a33caf2e3
--- /dev/null
+++ b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import confidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue';
+
+describe('Confidential Issue Sidebar Block', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(confidentialIssueSidebar);
+ const service = {
+ update: () => new Promise((resolve, reject) => {
+ resolve(true);
+ reject('failed!');
+ }),
+ };
+
+ vm1 = new Component({
+ propsData: {
+ isConfidential: true,
+ isEditable: true,
+ service,
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isConfidential: false,
+ isEditable: false,
+ service,
+ },
+ }).$mount();
+ });
+
+ it('shows if confidential and/or editable', () => {
+ expect(
+ vm1.$el.innerHTML.includes('Edit'),
+ ).toBe(true);
+
+ expect(
+ vm1.$el.innerHTML.includes('This issue is confidential'),
+ ).toBe(true);
+
+ expect(
+ vm2.$el.innerHTML.includes('Not confidential'),
+ ).toBe(true);
+ });
+
+ it('displays the edit form when editable', (done) => {
+ expect(vm1.edit).toBe(false);
+
+ vm1.$el.querySelector('.confidential-edit').click();
+
+ expect(vm1.edit).toBe(true);
+
+ setTimeout(() => {
+ expect(
+ vm1.$el
+ .innerHTML
+ .includes('You are going to turn off the confidentiality.'),
+ ).toBe(true);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js
new file mode 100644
index 00000000000..9fc8667ecc9
--- /dev/null
+++ b/spec/javascripts/sidebar/mock_data.js
@@ -0,0 +1,109 @@
+/* eslint-disable quote-props*/
+
+const sidebarMockData = {
+ 'GET': {
+ '/gitlab-org/gitlab-shell/issues/5.json': {
+ id: 45,
+ iid: 5,
+ author_id: 23,
+ description: 'Nulla ullam commodi delectus adipisci quis sit.',
+ lock_version: null,
+ milestone_id: 21,
+ position: 0,
+ state: 'closed',
+ title: 'Vel et nulla voluptatibus corporis dolor iste saepe laborum.',
+ updated_by_id: 1,
+ created_at: '2017-02-02T21: 49: 49.664Z',
+ updated_at: '2017-05-03T22: 26: 03.760Z',
+ deleted_at: null,
+ time_estimate: 0,
+ total_time_spent: 0,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ branch_name: null,
+ confidential: false,
+ assignees: [
+ {
+ name: 'User 0',
+ username: 'user0',
+ id: 22,
+ state: 'active',
+ avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
+ web_url: 'http: //localhost:3001/user0',
+ },
+ {
+ name: 'Marguerite Bartell',
+ username: 'tajuana',
+ id: 18,
+ state: 'active',
+ avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
+ web_url: 'http: //localhost:3001/tajuana',
+ },
+ {
+ name: 'Laureen Ritchie',
+ username: 'michaele.will',
+ id: 16,
+ state: 'active',
+ avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
+ web_url: 'http: //localhost:3001/michaele.will',
+ },
+ ],
+ due_date: null,
+ moved_to_id: null,
+ project_id: 4,
+ weight: null,
+ milestone: {
+ id: 21,
+ iid: 1,
+ project_id: 4,
+ title: 'v0.0',
+ description: 'Molestiae commodi laboriosam odio sunt eaque reprehenderit.',
+ state: 'active',
+ created_at: '2017-02-02T21: 49: 30.530Z',
+ updated_at: '2017-02-02T21: 49: 30.530Z',
+ due_date: null,
+ start_date: null,
+ },
+ labels: [],
+ },
+ },
+ 'PUT': {
+ '/gitlab-org/gitlab-shell/issues/5.json': {
+ data: {},
+ },
+ },
+};
+
+export default {
+ mediator: {
+ endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
+ editable: true,
+ currentUser: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
+ rootPath: '/',
+ },
+ time: {
+ time_estimate: 3600,
+ total_time_spent: 0,
+ human_time_estimate: '1h',
+ human_total_time_spent: null,
+ },
+ user: {
+ avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+
+ sidebarMockInterceptor(request, next) {
+ const body = sidebarMockData[request.method.toUpperCase()][request.url];
+
+ next(request.respondWith(JSON.stringify(body), {
+ status: 200,
+ }));
+ },
+};
diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js
new file mode 100644
index 00000000000..929ba75e67d
--- /dev/null
+++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+
+describe('sidebar assignees', () => {
+ let component;
+ let SidebarAssigneeComponent;
+ preloadFixtures('issues/open-issue.html.raw');
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
+ SidebarAssigneeComponent = Vue.extend(SidebarAssignees);
+ spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough();
+ spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough();
+ this.mediator = new SidebarMediator(Mock.mediator);
+ loadFixtures('issues/open-issue.html.raw');
+ this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
+ });
+
+ it('calls the mediator when saves the assignees', () => {
+ component = new SidebarAssigneeComponent()
+ .$mount(this.sidebarAssigneesEl);
+ component.saveAssignees();
+
+ expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled();
+ });
+
+ it('calls the mediator when "assignSelf" method is called', () => {
+ component = new SidebarAssigneeComponent()
+ .$mount(this.sidebarAssigneesEl);
+ component.assignSelf();
+
+ expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled();
+ expect(this.mediator.store.assignees.length).toEqual(1);
+ });
+
+ it('hides assignees until fetched', (done) => {
+ component = new SidebarAssigneeComponent().$mount(this.sidebarAssigneesEl);
+ const currentAssignee = this.sidebarAssigneesEl.querySelector('.value');
+ expect(currentAssignee).toBe(null);
+
+ component.store.isFetching.assignees = false;
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.value')).toBeVisible();
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js
new file mode 100644
index 00000000000..e246f41ee82
--- /dev/null
+++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import Mock from './mock_data';
+
+describe('Sidebar mediator', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
+ this.mediator = new SidebarMediator(Mock.mediator);
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
+ });
+
+ it('assigns yourself ', () => {
+ this.mediator.assignYourself();
+
+ expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser);
+ expect(this.mediator.store.assignees[0]).toEqual(Mock.mediator.currentUser);
+ });
+
+ it('saves assignees', (done) => {
+ this.mediator.saveAssignees('issue[assignee_ids]')
+ .then((resp) => {
+ expect(resp.status).toEqual(200);
+ done();
+ })
+ .catch(() => {});
+ });
+
+ it('fetches the data', () => {
+ spyOn(this.mediator.service, 'get').and.callThrough();
+ this.mediator.fetch();
+ expect(this.mediator.service.get).toHaveBeenCalled();
+ });
+});
diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js
new file mode 100644
index 00000000000..91a4dd669a7
--- /dev/null
+++ b/spec/javascripts/sidebar/sidebar_service_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import Mock from './mock_data';
+
+describe('Sidebar service', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
+ this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json');
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
+ });
+
+ it('gets the data', (done) => {
+ this.service.get()
+ .then((resp) => {
+ expect(resp).toBeDefined();
+ done();
+ })
+ .catch(() => {});
+ });
+
+ it('updates the data', (done) => {
+ this.service.update('issue[assignee_ids]', [1])
+ .then((resp) => {
+ expect(resp).toBeDefined();
+ done();
+ })
+ .catch(() => {});
+ });
+});
diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js
new file mode 100644
index 00000000000..b3fa156eb64
--- /dev/null
+++ b/spec/javascripts/sidebar/sidebar_store_spec.js
@@ -0,0 +1,85 @@
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+import UsersMockHelper from '../helpers/user_mock_data_helper';
+
+describe('Sidebar store', () => {
+ const assignee = {
+ id: 2,
+ name: 'gitlab user 2',
+ username: 'gitlab2',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ };
+
+ const anotherAssignee = {
+ id: 3,
+ name: 'gitlab user 3',
+ username: 'gitlab3',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ };
+
+ beforeEach(() => {
+ this.store = new SidebarStore({
+ currentUser: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
+ editable: true,
+ rootPath: '/',
+ endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
+ });
+ });
+
+ afterEach(() => {
+ SidebarStore.singleton = null;
+ });
+
+ it('has default isFetching values', () => {
+ expect(this.store.isFetching.assignees).toBe(true);
+ });
+
+ it('adds a new assignee', () => {
+ this.store.addAssignee(assignee);
+ expect(this.store.assignees.length).toEqual(1);
+ });
+
+ it('removes an assignee', () => {
+ this.store.removeAssignee(assignee);
+ expect(this.store.assignees.length).toEqual(0);
+ });
+
+ it('finds an existent assignee', () => {
+ let foundAssignee;
+
+ this.store.addAssignee(assignee);
+ foundAssignee = this.store.findAssignee(assignee);
+ expect(foundAssignee).toBeDefined();
+ expect(foundAssignee).toEqual(assignee);
+ foundAssignee = this.store.findAssignee(anotherAssignee);
+ expect(foundAssignee).toBeUndefined();
+ });
+
+ it('removes all assignees', () => {
+ this.store.removeAllAssignees();
+ expect(this.store.assignees.length).toEqual(0);
+ });
+
+ it('set assigned data', () => {
+ const users = {
+ assignees: UsersMockHelper.createNumberRandomUsers(3),
+ };
+
+ this.store.setAssigneeData(users);
+ expect(this.store.isFetching.assignees).toBe(false);
+ expect(this.store.assignees.length).toEqual(3);
+ });
+
+ it('set time tracking data', () => {
+ this.store.setTimeTrackingData(Mock.time);
+ expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate);
+ expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent);
+ expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate);
+ expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent);
+ });
+});
diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js
index d83d9a57b42..a53e8a94d89 100644
--- a/spec/javascripts/signin_tabs_memoizer_spec.js
+++ b/spec/javascripts/signin_tabs_memoizer_spec.js
@@ -1,6 +1,7 @@
-require('~/signin_tabs_memoizer');
+import AccessorUtilities from '~/lib/utils/accessor';
+import SigninTabsMemoizer from '~/signin_tabs_memoizer';
-((global) => {
+(() => {
describe('SigninTabsMemoizer', () => {
const fixtureTemplate = 'static/signin_tabs.html.raw';
const tabSelector = 'ul.nav-tabs';
@@ -8,7 +9,7 @@ require('~/signin_tabs_memoizer');
let memo;
function createMemoizer() {
- memo = new global.ActiveTabMemoizer({
+ memo = new SigninTabsMemoizer({
currentTabKey,
tabSelector,
});
@@ -19,6 +20,8 @@ require('~/signin_tabs_memoizer');
beforeEach(() => {
loadFixtures(fixtureTemplate);
+
+ spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
});
it('does nothing if no tab was previously selected', () => {
@@ -49,5 +52,91 @@ require('~/signin_tabs_memoizer');
expect(memo.readData()).toEqual('#standard');
});
+
+ describe('class constructor', () => {
+ beforeEach(() => {
+ memo = createMemoizer();
+ });
+
+ it('should set .isLocalStorageAvailable', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(memo.isLocalStorageAvailable).toBe(true);
+ });
+ });
+
+ describe('saveData', () => {
+ beforeEach(() => {
+ memo = {
+ currentTabKey,
+ };
+
+ spyOn(localStorage, 'setItem');
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(function () {
+ memo.isLocalStorageAvailable = false;
+
+ SigninTabsMemoizer.prototype.saveData.call(memo);
+ });
+
+ it('should not call .setItem', () => {
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ const value = 'value';
+
+ beforeEach(function () {
+ memo.isLocalStorageAvailable = true;
+
+ SigninTabsMemoizer.prototype.saveData.call(memo, value);
+ });
+
+ it('should call .setItem', () => {
+ expect(localStorage.setItem).toHaveBeenCalledWith(currentTabKey, value);
+ });
+ });
+ });
+
+ describe('readData', () => {
+ const itemValue = 'itemValue';
+ let readData;
+
+ beforeEach(() => {
+ memo = {
+ currentTabKey,
+ };
+
+ spyOn(localStorage, 'getItem').and.returnValue(itemValue);
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(function () {
+ memo.isLocalStorageAvailable = false;
+
+ readData = SigninTabsMemoizer.prototype.readData.call(memo);
+ });
+
+ it('should not call .getItem and should return `null`', () => {
+ expect(localStorage.getItem).not.toHaveBeenCalled();
+ expect(readData).toBe(null);
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(function () {
+ memo.isLocalStorageAvailable = true;
+
+ readData = SigninTabsMemoizer.prototype.readData.call(memo);
+ });
+
+ it('should call .getItem and return the localStorage value', () => {
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(currentTabKey);
+ expect(readData).toBe(itemValue);
+ });
+ });
+ });
});
-})(window);
+})();
diff --git a/spec/javascripts/smart_interval_spec.js b/spec/javascripts/smart_interval_spec.js
index 4366ec2a5b8..7833bf3fb04 100644
--- a/spec/javascripts/smart_interval_spec.js
+++ b/spec/javascripts/smart_interval_spec.js
@@ -1,4 +1,4 @@
-require('~/smart_interval');
+import '~/smart_interval';
(() => {
const DEFAULT_MAX_INTERVAL = 100;
diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js
deleted file mode 100644
index 454386697f5..00000000000
--- a/spec/javascripts/subbable_resource_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/* eslint-disable max-len, arrow-parens, comma-dangle */
-
-require('~/subbable_resource');
-
-/*
-* Test that each rest verb calls the publish and subscribe function and passes the correct value back
-*
-*
-* */
-((global) => {
- describe('Subbable Resource', function () {
- describe('PubSub', function () {
- beforeEach(function () {
- this.MockResource = new global.SubbableResource('https://example.com');
- });
- it('should successfully add a single subscriber', function () {
- const callback = () => {};
- this.MockResource.subscribe(callback);
-
- expect(this.MockResource.subscribers.length).toBe(1);
- expect(this.MockResource.subscribers[0]).toBe(callback);
- });
-
- it('should successfully add multiple subscribers', function () {
- const callbackOne = () => {};
- const callbackTwo = () => {};
- const callbackThree = () => {};
-
- this.MockResource.subscribe(callbackOne);
- this.MockResource.subscribe(callbackTwo);
- this.MockResource.subscribe(callbackThree);
-
- expect(this.MockResource.subscribers.length).toBe(3);
- });
-
- it('should successfully publish an update to a single subscriber', function () {
- const state = { myprop: 1 };
-
- const callbacks = {
- one: (data) => expect(data.myprop).toBe(2),
- two: (data) => expect(data.myprop).toBe(2),
- three: (data) => expect(data.myprop).toBe(2)
- };
-
- const spyOne = spyOn(callbacks, 'one');
- const spyTwo = spyOn(callbacks, 'two');
- const spyThree = spyOn(callbacks, 'three');
-
- this.MockResource.subscribe(callbacks.one);
- this.MockResource.subscribe(callbacks.two);
- this.MockResource.subscribe(callbacks.three);
-
- state.myprop += 1;
-
- this.MockResource.publish(state);
-
- expect(spyOne).toHaveBeenCalled();
- expect(spyTwo).toHaveBeenCalled();
- expect(spyThree).toHaveBeenCalled();
- });
- });
- });
-})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js
index cea223bd243..946f98379ce 100644
--- a/spec/javascripts/syntax_highlight_spec.js
+++ b/spec/javascripts/syntax_highlight_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable space-before-function-paren, no-var, no-return-assign, quotes */
-require('~/syntax_highlight');
+import '~/syntax_highlight';
(function() {
describe('Syntax Highlighter', function() {
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 07dc51a7815..d4e134583c7 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -1,19 +1,70 @@
-// enable test fixtures
-require('jasmine-jquery');
+/* eslint-disable jasmine/no-global-setup */
+import $ from 'jquery';
+import _ from 'underscore';
+import 'jasmine-jquery';
+import '~/commons';
+
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent);
+Vue.config.devtools = !isHeadlessChrome;
+Vue.config.productionTip = false;
-jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures';
-jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures';
+Vue.use(VueResource);
+
+// enable test fixtures
+jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
+jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
-// include common libraries
-require('~/commons/index.js');
-window.$ = window.jQuery = require('jquery');
-window._ = require('underscore');
+// globalize common libraries
+window.$ = window.jQuery = $;
+window._ = _;
// stub expected globals
window.gl = window.gl || {};
window.gl.TEST_HOST = 'http://test.host';
window.gon = window.gon || {};
+let hasUnhandledPromiseRejections = false;
+
+window.addEventListener('unhandledrejection', (event) => {
+ hasUnhandledPromiseRejections = true;
+ console.error('Unhandled promise rejection:');
+ console.error(event.reason.stack || event.reason);
+});
+
+const checkUnhandledPromiseRejections = (done) => {
+ expect(hasUnhandledPromiseRejections).toBe(false);
+ done();
+};
+
+// HACK: Chrome 59 disconnects if there are too many synchronous tests in a row
+// because it appears to lock up the thread that communicates to Karma's socket
+// This async beforeEach gets called on every spec and releases the JS thread long
+// enough for the socket to continue to communicate.
+// The downside is that it creates a minor performance penalty in the time it takes
+// to run our unit tests.
+beforeEach(done => done());
+
+beforeAll(() => {
+ const origError = console.error;
+ spyOn(console, 'error').and.callFake((message) => {
+ if (/^\[Vue warn\]/.test(message)) {
+ fail(message);
+ } else {
+ origError(message);
+ }
+ });
+});
+
+const builtinVueHttpInterceptors = Vue.http.interceptors.slice();
+
+beforeEach(() => {
+ // restore interceptors so we have no remaining ones from previous tests
+ Vue.http.interceptors = builtinVueHttpInterceptors.slice();
+});
+
// render all of our tests
const testsContext = require.context('.', true, /_spec$/);
testsContext.keys().forEach(function (path) {
@@ -29,6 +80,10 @@ testsContext.keys().forEach(function (path) {
}
});
+it('has no unhandled Promise rejections', (done) => {
+ setTimeout(checkUnhandledPromiseRejections(done), 1000);
+});
+
// if we're generating coverage reports, make sure to include all files so
// that we can catch files with 0% coverage
// see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15
@@ -49,13 +104,11 @@ if (process.env.BABEL_ENV === 'coverage') {
'./environments/environments_bundle.js',
'./filtered_search/filtered_search_bundle.js',
'./graphs/graphs_bundle.js',
- './issuable/issuable_bundle.js',
'./issuable/time_tracking/time_tracking_bundle.js',
'./main.js',
'./merge_conflicts/merge_conflicts_bundle.js',
'./merge_conflicts/components/inline_conflict_lines.js',
'./merge_conflicts/components/parallel_conflict_lines.js',
- './merge_request_widget/ci_bundle.js',
'./monitoring/monitoring_bundle.js',
'./network/network_bundle.js',
'./network/branch_graph.js',
diff --git a/spec/javascripts/todos_spec.js b/spec/javascripts/todos_spec.js
index 66e4fbd6304..fd492159081 100644
--- a/spec/javascripts/todos_spec.js
+++ b/spec/javascripts/todos_spec.js
@@ -1,5 +1,5 @@
-require('~/todos');
-require('~/lib/utils/common_utils');
+import Todos from '~/todos';
+import '~/lib/utils/common_utils';
describe('Todos', () => {
preloadFixtures('todos/todos.html.raw');
@@ -9,7 +9,7 @@ describe('Todos', () => {
loadFixtures('todos/todos.html.raw');
todoItem = document.querySelector('.todos-list .todo');
- return new gl.Todos();
+ return new Todos();
});
describe('goToTodoUrl', () => {
diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js
index af2d02b6b29..a160c86308d 100644
--- a/spec/javascripts/u2f/authenticate_spec.js
+++ b/spec/javascripts/u2f/authenticate_spec.js
@@ -2,11 +2,11 @@
/* global MockU2FDevice */
/* global U2FAuthenticate */
-require('~/u2f/authenticate');
-require('~/u2f/util');
-require('~/u2f/error');
-require('vendor/u2f');
-require('./mock_u2f_device');
+import '~/u2f/authenticate';
+import '~/u2f/util';
+import '~/u2f/error';
+import 'vendor/u2f';
+import './mock_u2f_device';
(function() {
describe('U2FAuthenticate', function() {
diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js
index 6677fe9c1ee..4eb8ad3d9e4 100644
--- a/spec/javascripts/u2f/mock_u2f_device.js
+++ b/spec/javascripts/u2f/mock_u2f_device.js
@@ -1,12 +1,10 @@
/* eslint-disable space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-expressions, no-return-assign, no-param-reassign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.MockU2FDevice = (function() {
function MockU2FDevice() {
- this.respondToAuthenticateRequest = bind(this.respondToAuthenticateRequest, this);
- this.respondToRegisterRequest = bind(this.respondToRegisterRequest, this);
+ this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this);
+ this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this);
window.u2f || (window.u2f = {});
window.u2f.register = (function(_this) {
return function(appId, registerRequests, signRequests, callback) {
diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js
index 3960759f7cb..a445c80f2af 100644
--- a/spec/javascripts/u2f/register_spec.js
+++ b/spec/javascripts/u2f/register_spec.js
@@ -2,11 +2,11 @@
/* global MockU2FDevice */
/* global U2FRegister */
-require('~/u2f/register');
-require('~/u2f/util');
-require('~/u2f/error');
-require('vendor/u2f');
-require('./mock_u2f_device');
+import '~/u2f/register';
+import '~/u2f/util';
+import '~/u2f/error';
+import 'vendor/u2f';
+import './mock_u2f_device';
(function() {
describe('U2FRegister', function() {
diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js
index 464c1fce210..9637bd0414a 100644
--- a/spec/javascripts/version_check_image_spec.js
+++ b/spec/javascripts/version_check_image_spec.js
@@ -1,9 +1,8 @@
-const ClassSpecHelper = require('./helpers/class_spec_helper');
-const VersionCheckImage = require('~/version_check_image');
-require('jquery');
+import VersionCheckImage from '~/version_check_image';
+import ClassSpecHelper from './helpers/class_spec_helper';
describe('VersionCheckImage', function () {
- describe('.bindErrorEvent', function () {
+ describe('bindErrorEvent', function () {
ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent');
beforeEach(function () {
diff --git a/spec/javascripts/visibility_select_spec.js b/spec/javascripts/visibility_select_spec.js
index 9727c03c91e..82714cb69bd 100644
--- a/spec/javascripts/visibility_select_spec.js
+++ b/spec/javascripts/visibility_select_spec.js
@@ -1,8 +1,6 @@
-require('~/visibility_select');
+import VisibilitySelect from '~/visibility_select';
(() => {
- const VisibilitySelect = gl.VisibilitySelect;
-
describe('VisibilitySelect', function () {
const lockedElement = document.createElement('div');
lockedElement.dataset.helpBlock = 'lockedHelpBlock';
@@ -22,7 +20,7 @@ require('~/visibility_select');
spyOn(Element.prototype, 'querySelector').and.callFake(selector => mockElements[selector]);
});
- describe('#constructor', function () {
+ describe('constructor', function () {
beforeEach(function () {
this.visibilitySelect = new VisibilitySelect(mockElements.container);
});
@@ -48,7 +46,7 @@ require('~/visibility_select');
});
});
- describe('#init', function () {
+ describe('init', function () {
describe('if there is a select', function () {
beforeEach(function () {
this.visibilitySelect = new VisibilitySelect(mockElements.container);
@@ -85,7 +83,7 @@ require('~/visibility_select');
});
});
- describe('#updateHelpText', function () {
+ describe('updateHelpText', function () {
beforeEach(function () {
this.visibilitySelect = new VisibilitySelect(mockElements.container);
this.visibilitySelect.init();
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js
new file mode 100644
index 00000000000..a750bc78f36
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author';
+
+const author = {
+ webUrl: 'http://foo.bar',
+ avatarUrl: 'http://gravatar.com/foo',
+ name: 'fatihacet',
+};
+const createComponent = () => {
+ const Component = Vue.extend(authorComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { author },
+ });
+};
+
+describe('MRWidgetAuthor', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const authorProp = authorComponent.props.author;
+
+ expect(authorProp).toBeDefined();
+ expect(authorProp.type instanceof Object).toBeTruthy();
+ expect(authorProp.required).toBeTruthy();
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const el = createComponent().$el;
+
+ expect(el.tagName).toEqual('A');
+ expect(el.getAttribute('href')).toEqual(author.webUrl);
+ expect(el.querySelector('img').getAttribute('src')).toEqual(author.avatarUrl);
+ expect(el.querySelector('.author').innerText.trim()).toEqual(author.name);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js
new file mode 100644
index 00000000000..515ddcbb875
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js
@@ -0,0 +1,61 @@
+import Vue from 'vue';
+import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time';
+
+const props = {
+ actionText: 'Merged by',
+ author: {
+ webUrl: 'http://foo.bar',
+ avatarUrl: 'http://gravatar.com/foo',
+ name: 'fatihacet',
+ },
+ dateTitle: '2017-03-23T23:02:00.807Z',
+ dateReadable: '12 hours ago',
+};
+const createComponent = () => {
+ const Component = Vue.extend(authorTimeComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: props,
+ });
+};
+
+describe('MRWidgetAuthorTime', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props;
+ const ActionTextClass = actionText.type;
+ const DateTitleClass = dateTitle.type;
+ const DateReadableClass = dateReadable.type;
+
+ expect(new ActionTextClass() instanceof String).toBeTruthy();
+ expect(actionText.required).toBeTruthy();
+
+ expect(author.type instanceof Object).toBeTruthy();
+ expect(author.required).toBeTruthy();
+
+ expect(new DateTitleClass() instanceof String).toBeTruthy();
+ expect(dateTitle.required).toBeTruthy();
+
+ expect(new DateReadableClass() instanceof String).toBeTruthy();
+ expect(dateReadable.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components', () => {
+ expect(authorTimeComponent.components['mr-widget-author']).toBeDefined();
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const el = createComponent().$el;
+
+ expect(el.tagName).toEqual('H4');
+ expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl);
+ expect(el.querySelector('time').innerText).toContain(props.dateReadable);
+ expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js
new file mode 100644
index 00000000000..7ee998c8fce
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js
@@ -0,0 +1,179 @@
+import Vue from 'vue';
+import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+
+const deploymentMockData = [
+ {
+ id: 15,
+ name: 'review/diplo',
+ url: '/root/acets-review-apps/environments/15',
+ stop_url: '/root/acets-review-apps/environments/15/stop',
+ metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics',
+ metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics',
+ external_url: 'http://diplo.',
+ external_url_formatted: 'diplo.',
+ deployed_at: '2017-03-22T22:44:42.258Z',
+ deployed_at_formatted: 'Mar 22, 2017 10:44pm',
+ },
+];
+const createComponent = () => {
+ const Component = Vue.extend(deploymentComponent);
+ const mr = {
+ deployments: deploymentMockData,
+ };
+ const service = {};
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr, service },
+ });
+};
+
+describe('MRWidgetDeployment', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr, service } = deploymentComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+
+ expect(service.type instanceof Object).toBeTruthy();
+ expect(service.required).toBeTruthy();
+ });
+ });
+
+ describe('methods', () => {
+ let vm = createComponent();
+ const deployment = deploymentMockData[0];
+
+ describe('formatDate', () => {
+ it('should work', () => {
+ const readable = gl.utils.getTimeago().format(deployment.deployed_at);
+ expect(vm.formatDate(deployment.deployed_at)).toEqual(readable);
+ });
+ });
+
+ describe('hasExternalUrls', () => {
+ it('should return true', () => {
+ expect(vm.hasExternalUrls(deployment)).toBeTruthy();
+ });
+
+ it('should return false when there is not enough information', () => {
+ expect(vm.hasExternalUrls()).toBeFalsy();
+ expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy();
+ expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy();
+ });
+ });
+
+ describe('hasDeploymentTime', () => {
+ it('should return true', () => {
+ expect(vm.hasDeploymentTime(deployment)).toBeTruthy();
+ });
+
+ it('should return false when there is not enough information', () => {
+ expect(vm.hasDeploymentTime()).toBeFalsy();
+ expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy();
+ expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy();
+ });
+ });
+
+ describe('hasDeploymentMeta', () => {
+ it('should return true', () => {
+ expect(vm.hasDeploymentMeta(deployment)).toBeTruthy();
+ });
+
+ it('should return false when there is not enough information', () => {
+ expect(vm.hasDeploymentMeta()).toBeFalsy();
+ expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy();
+ expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy();
+ });
+ });
+
+ describe('stopEnvironment', () => {
+ const url = '/foo/bar';
+ const returnPromise = () => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ redirect_url: url,
+ };
+ },
+ });
+ });
+ const mockStopEnvironment = () => {
+ vm.stopEnvironment(deploymentMockData);
+ return vm;
+ };
+
+ it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => {
+ spyOn(window, 'confirm').and.returnValue(true);
+ spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true));
+ spyOn(gl.utils, 'visitUrl').and.returnValue(true);
+ vm = mockStopEnvironment();
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url);
+ setTimeout(() => {
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith(url);
+ done();
+ }, 333);
+ });
+
+ it('should show a confirm dialog but should not work if the dialog is rejected', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+ spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false));
+ vm = mockStopEnvironment();
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+ const [deployment] = deploymentMockData;
+
+ beforeEach(() => {
+ vm = createComponent(deploymentMockData);
+ el = vm.$el;
+ });
+
+ it('should render template elements correctly', () => {
+ expect(el.classList.contains('mr-widget-heading')).toBeTruthy();
+ expect(el.querySelector('.js-icon-link')).toBeDefined();
+ expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url);
+ expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name);
+ expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url);
+ expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted);
+ expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at));
+ expect(el.querySelector('.js-mr-memory-usage')).toBeDefined();
+ expect(el.querySelector('button')).toBeDefined();
+ });
+
+ it('should list multiple deployments', (done) => {
+ vm.mr.deployments.push(deployment);
+ vm.mr.deployments.push(deployment);
+
+ Vue.nextTick(() => {
+ expect(el.querySelectorAll('.ci-widget').length).toEqual(3);
+ expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3);
+ done();
+ });
+ });
+
+ it('should not have some elements when there is not enough data', (done) => {
+ vm.mr.deployments = [{}];
+
+ Vue.nextTick(() => {
+ expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0);
+ expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0);
+ expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0);
+ expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0);
+ expect(el.querySelectorAll('.button').length).toEqual(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
new file mode 100644
index 00000000000..06f89fabf42
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
@@ -0,0 +1,105 @@
+import Vue from 'vue';
+import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header';
+
+const createComponent = (mr) => {
+ const Component = Vue.extend(headerComponent);
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ });
+};
+
+describe('MRWidgetHeader', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr } = headerComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+ });
+ });
+
+ describe('computed', () => {
+ let vm;
+ beforeEach(() => {
+ vm = createComponent({
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '/foo/bar/mr-widget-refactor',
+ targetBranch: 'master',
+ });
+ });
+
+ it('shouldShowCommitsBehindText', () => {
+ expect(vm.shouldShowCommitsBehindText).toBeTruthy();
+
+ vm.mr.divergedCommitsCount = 0;
+ expect(vm.shouldShowCommitsBehindText).toBeFalsy();
+ });
+
+ it('commitsText', () => {
+ expect(vm.commitsText).toEqual('commits');
+
+ vm.mr.divergedCommitsCount = 1;
+ expect(vm.commitsText).toEqual('commit');
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+ const sourceBranchPath = '/foo/bar/mr-widget-refactor';
+ const mr = {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: `<a href="${sourceBranchPath}">mr-widget-refactor</a>`,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: true,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ };
+
+ beforeEach(() => {
+ vm = createComponent(mr);
+ el = vm.$el;
+ });
+
+ it('should render template elements correctly', () => {
+ expect(el.classList.contains('mr-source-target')).toBeTruthy();
+ const sourceBranchLink = el.querySelectorAll('.label-branch')[0];
+ const targetBranchLink = el.querySelectorAll('.label-branch')[1];
+ const commitsCount = el.querySelector('.diverged-commits-count');
+
+ expect(sourceBranchLink.textContent).toContain(mr.sourceBranch);
+ expect(targetBranchLink.textContent).toContain(mr.targetBranch);
+ expect(sourceBranchLink.querySelector('a').getAttribute('href')).toEqual(sourceBranchPath);
+ expect(targetBranchLink.querySelector('a').getAttribute('href')).toEqual(mr.targetBranchTreePath);
+ expect(commitsCount.textContent).toContain('12 commits behind');
+ expect(commitsCount.querySelector('a').getAttribute('href')).toEqual(mr.targetBranchPath);
+
+ expect(el.textContent).toContain('Check out branch');
+ expect(el.querySelectorAll('.dropdown li a')[0].getAttribute('href')).toEqual(mr.emailPatchesPath);
+ expect(el.querySelectorAll('.dropdown li a')[1].getAttribute('href')).toEqual(mr.plainDiffPath);
+ });
+
+ it('should not have right action links if the MR state is not open', (done) => {
+ vm.mr.isOpen = false;
+ Vue.nextTick(() => {
+ expect(el.textContent).not.toContain('Check out branch');
+ expect(el.querySelectorAll('.dropdown li a').length).toEqual(0);
+ done();
+ });
+ });
+
+ it('should not render diverged commits count if the MR has no diverged commits', (done) => {
+ vm.mr.divergedCommitsCount = null;
+ Vue.nextTick(() => {
+ expect(el.textContent).not.toContain('commits behind');
+ expect(el.querySelectorAll('.diverged-commits-count').length).toEqual(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js
new file mode 100644
index 00000000000..2ae3adc1f93
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js
@@ -0,0 +1,233 @@
+import Vue from 'vue';
+import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+
+const url = '/root/acets-review-apps/environments/15/deployments/1/metrics';
+const monitoringUrl = '/root/acets-review-apps/environments/15/metrics';
+
+const metricsMockData = {
+ success: true,
+ metrics: {
+ memory_before: [
+ {
+ metric: {},
+ value: [1495785220.607, '9572875.906976745'],
+ },
+ ],
+ memory_after: [
+ {
+ metric: {},
+ value: [1495787020.607, '4485853.130206379'],
+ },
+ ],
+ memory_values: [
+ {
+ metric: {},
+ values: [
+ [1493716685, '4.30859375'],
+ ],
+ },
+ ],
+ },
+ last_update: '2017-05-02T12:34:49.628Z',
+ deployment_time: 1493718485,
+};
+
+const createComponent = () => {
+ const Component = Vue.extend(memoryUsageComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: {
+ metricsUrl: url,
+ metricsMonitoringUrl: monitoringUrl,
+ memoryMetrics: [],
+ deploymentTime: 0,
+ hasMetrics: false,
+ loadFailed: false,
+ loadingMetrics: true,
+ backOffRequestCounter: 0,
+ },
+ });
+};
+
+const messages = {
+ loadingMetrics: 'Loading deployment statistics',
+ hasMetrics: 'Memory usage unchanged from 0MB to 0MB',
+ loadFailed: 'Failed to load deployment statistics',
+ metricsUnavailable: 'Deployment statistics are not available currently',
+};
+
+describe('MemoryUsage', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ describe('props', () => {
+ it('should have props with defaults', () => {
+ const { metricsUrl } = memoryUsageComponent.props;
+ const MetricsUrlTypeClass = metricsUrl.type;
+
+ Vue.nextTick(() => {
+ expect(new MetricsUrlTypeClass() instanceof String).toBeTruthy();
+ expect(metricsUrl.required).toBeTruthy();
+ });
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ const data = memoryUsageComponent.data();
+
+ expect(Array.isArray(data.memoryMetrics)).toBeTruthy();
+ expect(data.memoryMetrics.length).toBe(0);
+
+ expect(typeof data.deploymentTime).toBe('number');
+ expect(data.deploymentTime).toBe(0);
+
+ expect(typeof data.hasMetrics).toBe('boolean');
+ expect(data.hasMetrics).toBeFalsy();
+
+ expect(typeof data.loadFailed).toBe('boolean');
+ expect(data.loadFailed).toBeFalsy();
+
+ expect(typeof data.loadingMetrics).toBe('boolean');
+ expect(data.loadingMetrics).toBeTruthy();
+
+ expect(typeof data.backOffRequestCounter).toBe('number');
+ expect(data.backOffRequestCounter).toBe(0);
+ });
+ });
+
+ describe('computed', () => {
+ describe('memoryChangeType', () => {
+ it('should return "increased" if memoryFrom value is less than memoryTo value', () => {
+ vm.memoryFrom = 4.28;
+ vm.memoryTo = 9.13;
+
+ expect(vm.memoryChangeType).toEqual('increased');
+ });
+
+ it('should return "decreased" if memoryFrom value is less than memoryTo value', () => {
+ vm.memoryFrom = 9.13;
+ vm.memoryTo = 4.28;
+
+ expect(vm.memoryChangeType).toEqual('decreased');
+ });
+
+ it('should return "unchanged" if memoryFrom value equal to memoryTo value', () => {
+ vm.memoryFrom = 1;
+ vm.memoryTo = 1;
+
+ expect(vm.memoryChangeType).toEqual('unchanged');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ const { metrics, deployment_time } = metricsMockData;
+
+ describe('getMegabytes', () => {
+ it('should return Megabytes from provided Bytes value', () => {
+ const memoryInBytes = '9572875.906976745';
+
+ expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13');
+ });
+ });
+
+ describe('computeGraphData', () => {
+ it('should populate sparkline graph', () => {
+ vm.computeGraphData(metrics, deployment_time);
+ const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm;
+
+ expect(hasMetrics).toBeTruthy();
+ expect(memoryMetrics.length > 0).toBeTruthy();
+ expect(deploymentTime).toEqual(deployment_time);
+ expect(memoryFrom).toEqual('9.13');
+ expect(memoryTo).toEqual('4.28');
+ });
+ });
+
+ describe('loadMetrics', () => {
+ const returnServicePromise = () => new Promise((resolve) => {
+ resolve({
+ json() {
+ return metricsMockData;
+ },
+ });
+ });
+
+ it('should load metrics data using MRWidgetService', (done) => {
+ spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true));
+ spyOn(vm, 'computeGraphData');
+
+ vm.loadMetrics();
+ setTimeout(() => {
+ expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url);
+ expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time);
+ done();
+ }, 333);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render template elements correctly', () => {
+ expect(el.classList.contains('mr-memory-usage')).toBeTruthy();
+ expect(el.querySelector('.js-usage-info')).toBeDefined();
+ });
+
+ it('should show loading metrics message while metrics are being loaded', (done) => {
+ vm.loadingMetrics = true;
+ vm.hasMetrics = false;
+ vm.loadFailed = false;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined();
+ expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics);
+ done();
+ });
+ });
+
+ it('should show deployment memory usage when metrics are loaded', (done) => {
+ vm.loadingMetrics = false;
+ vm.hasMetrics = true;
+ vm.loadFailed = false;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.memory-graph-container')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics);
+ done();
+ });
+ });
+
+ it('should show failure message when metrics loading failed', (done) => {
+ vm.loadingMetrics = false;
+ vm.hasMetrics = false;
+ vm.loadFailed = true;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed);
+ done();
+ });
+ });
+
+ it('should show metrics unavailable message when metrics loading failed', (done) => {
+ vm.loadingMetrics = false;
+ vm.hasMetrics = false;
+ vm.loadFailed = false;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js
new file mode 100644
index 00000000000..4da4fc82c26
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help';
+
+const props = {
+ missingBranch: 'this-is-not-the-branch-you-are-looking-for',
+};
+const text = `If the ${props.missingBranch} branch exists in your local repository`;
+
+const createComponent = () => {
+ const Component = Vue.extend(mergeHelpComponent);
+ return new Component({
+ el: document.createElement('div'),
+ propsData: props,
+ });
+};
+
+describe('MRWidgetMergeHelp', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { missingBranch } = mergeHelpComponent.props;
+ const MissingBranchTypeClass = missingBranch.type;
+
+ expect(new MissingBranchTypeClass() instanceof String).toBeTruthy();
+ expect(missingBranch.required).toBeFalsy();
+ expect(missingBranch.default).toEqual('');
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ it('should have the correct elements', () => {
+ expect(el.classList.contains('mr-widget-help')).toBeTruthy();
+ expect(el.textContent).toContain(text);
+ });
+
+ it('should not show missing branch name if missingBranch props is not provided', (done) => {
+ vm.missingBranch = null;
+ Vue.nextTick(() => {
+ expect(el.textContent).not.toContain(text);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
new file mode 100644
index 00000000000..c763487d12f
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -0,0 +1,129 @@
+import Vue from 'vue';
+import { statusIconEntityMap } from '~/vue_shared/ci_status_icons';
+import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline';
+import mockData from '../mock_data';
+
+const createComponent = (mr) => {
+ const Component = Vue.extend(pipelineComponent);
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ });
+};
+
+describe('MRWidgetPipeline', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr } = pipelineComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components added', () => {
+ expect(pipelineComponent.components['pipeline-stage']).toBeDefined();
+ expect(pipelineComponent.components.ciIcon).toBeDefined();
+ });
+ });
+
+ describe('computed', () => {
+ describe('svg', () => {
+ it('should have the proper SVG icon', () => {
+ const vm = createComponent({ pipeline: mockData.pipeline });
+
+ expect(vm.svg).toEqual(statusIconEntityMap.icon_status_failed);
+ });
+ });
+
+ describe('hasCIError', () => {
+ it('should return false when there is no CI error', () => {
+ const vm = createComponent({
+ pipeline: mockData.pipeline,
+ hasCI: true,
+ ciStatus: 'success',
+ });
+
+ expect(vm.hasCIError).toBeFalsy();
+ });
+
+ it('should return true when there is a CI error', () => {
+ const vm = createComponent({
+ pipeline: mockData.pipeline,
+ hasCI: true,
+ ciStatus: null,
+ });
+
+ expect(vm.hasCIError).toBeTruthy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+ const { pipeline } = mockData;
+ const mr = {
+ hasCI: true,
+ ciStatus: 'success',
+ pipelineDetailedStatus: pipeline.details.status,
+ pipeline,
+ };
+
+ beforeEach(() => {
+ vm = createComponent(mr);
+ el = vm.$el;
+ });
+
+ it('should render template elements correctly', () => {
+ expect(el.classList.contains('mr-widget-heading')).toBeTruthy();
+ expect(el.querySelectorAll('.ci-status-icon.ci-status-icon-success').length).toEqual(1);
+ expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipeline.id}`);
+ expect(el.innerText).toContain('passed');
+ expect(el.querySelector('.pipeline-id').getAttribute('href')).toEqual(pipeline.path);
+ expect(el.querySelectorAll('.stage-container').length).toEqual(2);
+ expect(el.querySelector('.js-ci-error')).toEqual(null);
+ expect(el.querySelector('.js-commit-link').getAttribute('href')).toEqual(pipeline.commit.commit_path);
+ expect(el.querySelector('.js-commit-link').textContent).toContain(pipeline.commit.short_id);
+ expect(el.querySelector('.js-mr-coverage').textContent).toContain(`Coverage ${pipeline.coverage}%`);
+ });
+
+ it('should list single stage', (done) => {
+ pipeline.details.stages.splice(0, 1);
+
+ Vue.nextTick(() => {
+ expect(el.querySelectorAll('.stage-container button').length).toEqual(1);
+ done();
+ });
+ });
+
+ it('should not have stages when there is no stage', (done) => {
+ vm.mr.pipeline.details.stages = [];
+
+ Vue.nextTick(() => {
+ expect(el.querySelectorAll('.stage-container button').length).toEqual(0);
+ done();
+ });
+ });
+
+ it('should not have coverage text when pipeline has no coverage info', (done) => {
+ vm.mr.pipeline.coverage = null;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-mr-coverage')).toEqual(null);
+ done();
+ });
+ });
+
+ it('should show CI error when there is a CI error', (done) => {
+ vm.mr.ciStatus = null;
+
+ Vue.nextTick(() => {
+ expect(el.querySelectorAll('.js-ci-error').length).toEqual(1);
+ expect(el.innerText).toContain('Could not connect to the CI server');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js
new file mode 100644
index 00000000000..f86fb6a0b4b
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links';
+
+const createComponent = (data) => {
+ const Component = Vue.extend(relatedLinksComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: data,
+ });
+};
+
+describe('MRWidgetRelatedLinks', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { relatedLinks } = relatedLinksComponent.props;
+
+ expect(relatedLinks).toBeDefined();
+ expect(relatedLinks.type instanceof Object).toBeTruthy();
+ expect(relatedLinks.required).toBeTruthy();
+ });
+ });
+
+ describe('computed', () => {
+ const data = {
+ relatedLinks: {
+ closing: '/foo',
+ mentioned: '/foo',
+ assignToMe: '/foo',
+ },
+ };
+
+ describe('hasLinks', () => {
+ it('should return correct value when we have links reference', () => {
+ const vm = createComponent(data);
+ expect(vm.hasLinks).toBeTruthy();
+
+ vm.relatedLinks.closing = null;
+ expect(vm.hasLinks).toBeTruthy();
+
+ vm.relatedLinks.mentioned = null;
+ expect(vm.hasLinks).toBeTruthy();
+
+ vm.relatedLinks.assignToMe = null;
+ expect(vm.hasLinks).toBeFalsy();
+ });
+ });
+
+ describe('closesText', () => {
+ it('returns correct text for open merge request', () => {
+ data.state = 'open';
+ const vm = createComponent(data);
+ expect(vm.closesText).toEqual('Closes');
+ });
+
+ it('returns correct text for closed merge request', () => {
+ data.state = 'closed';
+ const vm = createComponent(data);
+ expect(vm.closesText).toEqual('Did not close');
+ });
+
+ it('returns correct tense for merged request', () => {
+ data.state = 'merged';
+ const vm = createComponent(data);
+ expect(vm.closesText).toEqual('Closed');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should have only have closing issues text', () => {
+ const vm = createComponent({
+ relatedLinks: {
+ closing: '<a href="#">#23</a> and <a>#42</a>',
+ },
+ });
+ const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim();
+
+ expect(content).toContain('Closes #23 and #42');
+ expect(content).not.toContain('Mentions');
+ });
+
+ it('should have only have mentioned issues text', () => {
+ const vm = createComponent({
+ relatedLinks: {
+ mentioned: '<a href="#">#7</a>',
+ },
+ });
+
+ expect(vm.$el.innerText).toContain('Mentions #7');
+ expect(vm.$el.innerText).not.toContain('Closes');
+ });
+
+ it('should have closing and mentioned issues at the same time', () => {
+ const vm = createComponent({
+ relatedLinks: {
+ closing: '<a href="#">#7</a>',
+ mentioned: '<a href="#">#23</a> and <a>#42</a>',
+ },
+ });
+ const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim();
+
+ expect(content).toContain('Closes #7');
+ expect(content).toContain('Mentions #23 and #42');
+ });
+
+ it('should have assing issues link', () => {
+ const vm = createComponent({
+ relatedLinks: {
+ assignToMe: '<a href="#">Assign yourself to these issues</a>',
+ },
+ });
+
+ expect(vm.$el.innerText).toContain('Assign yourself to these issues');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js
new file mode 100644
index 00000000000..4869fb17d96
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived';
+
+describe('MRWidgetArchived', () => {
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const Component = Vue.extend(archivedComponent);
+ const el = new Component({
+ el: document.createElement('div'),
+ }).$el;
+
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy();
+ expect(el.querySelector('button').disabled).toBeTruthy();
+ expect(el.innerText).toContain('This project is archived, write access has been disabled');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
new file mode 100644
index 00000000000..6042d7384d5
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import autoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed';
+
+const mergeError = 'This is the merge error';
+
+describe('MRWidgetAutoMergeFailed', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const mrProp = autoMergeFailedComponent.props.mr;
+
+ expect(mrProp.type instanceof Object).toBeTruthy();
+ expect(mrProp.required).toBeTruthy();
+ });
+ });
+
+ describe('template', () => {
+ const Component = Vue.extend(autoMergeFailedComponent);
+ const vm = new Component({
+ el: document.createElement('div'),
+ propsData: {
+ mr: { mergeError },
+ },
+ });
+
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeFalsy();
+ expect(vm.$el.innerText).toContain('This merge request failed to be merged automatically');
+ expect(vm.$el.innerText).toContain(mergeError);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js
new file mode 100644
index 00000000000..6b7aa935ad3
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking';
+
+describe('MRWidgetChecking', () => {
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const Component = Vue.extend(checkingComponent);
+ const el = new Component({
+ el: document.createElement('div'),
+ }).$el;
+
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy();
+ expect(el.querySelector('button').disabled).toBeTruthy();
+ expect(el.innerText).toContain('Checking ability to merge automatically');
+ expect(el.querySelector('i')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js
new file mode 100644
index 00000000000..47303d1e80f
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed';
+
+const mr = {
+ targetBranch: 'good-branch',
+ targetBranchPath: '/good-branch',
+ closedBy: {
+ name: 'Fatih Acet',
+ username: 'fatihacet',
+ },
+ updatedAt: '2017-03-23T20:08:08.845Z',
+ closedAt: '1 day ago',
+};
+
+const createComponent = () => {
+ const Component = Vue.extend(closedComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ }).$el;
+};
+
+describe('MRWidgetClosed', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const mrProp = closedComponent.props.mr;
+
+ expect(mrProp.type instanceof Object).toBeTruthy();
+ expect(mrProp.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components added', () => {
+ expect(closedComponent.components['mr-widget-author-and-time']).toBeDefined();
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const el = createComponent();
+
+ expect(el.querySelector('h4').textContent).toContain('Closed by');
+ expect(el.querySelector('h4').textContent).toContain(mr.closedBy.name);
+ expect(el.textContent).toContain('The changes were not merged into');
+ expect(el.querySelector('.label-branch').getAttribute('href')).toEqual(mr.targetBranchPath);
+ expect(el.querySelector('.label-branch').textContent).toContain(mr.targetBranch);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
new file mode 100644
index 00000000000..3b7b7d93662
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts';
+
+const path = '/conflicts';
+const createComponent = () => {
+ const Component = Vue.extend(conflictsComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: {
+ mr: {
+ canMerge: true,
+ conflictResolutionPath: path,
+ },
+ },
+ });
+};
+
+describe('MRWidgetConflicts', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr } = conflictsComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const el = createComponent().$el;
+ const resolveButton = el.querySelector('.js-resolve-conflicts-button');
+ const mergeButton = el.querySelector('.mr-widget-body .btn');
+ const mergeLocallyButton = el.querySelector('.js-merge-locally-button');
+
+ expect(el.textContent).toContain('There are merge conflicts');
+ expect(el.textContent).not.toContain('ask someone with write access');
+ expect(el.querySelector('.btn-success').disabled).toBeTruthy();
+ expect(resolveButton.textContent).toContain('Resolve conflicts');
+ expect(resolveButton.getAttribute('href')).toEqual(path);
+ expect(mergeButton.textContent).toContain('Merge');
+ expect(mergeLocallyButton.textContent).toContain('Merge locally');
+ });
+
+ describe('when user does not have permission to merge', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ vm.mr.canMerge = false;
+ });
+
+ it('should show proper message', (done) => {
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('ask someone with write access');
+ done();
+ });
+ });
+
+ it('should not have action buttons', (done) => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('.btn').length).toBe(1);
+ expect(vm.$el.querySelector('.js-resolve-conflicts-button')).toEqual(null);
+ expect(vm.$el.querySelector('.js-merge-locally-button')).toEqual(null);
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js
new file mode 100644
index 00000000000..cef365eec8a
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js
@@ -0,0 +1,122 @@
+import Vue from 'vue';
+import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+
+const mr = {
+ mergeError: 'Merge error happened.',
+};
+const createComponent = () => {
+ const Component = Vue.extend(failedToMergeComponent);
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ });
+};
+
+describe('MRWidgetFailedToMerge', () => {
+ describe('data', () => {
+ it('should have default data', () => {
+ const data = failedToMergeComponent.data();
+
+ expect(data.timer).toEqual(10);
+ expect(data.isRefreshing).toBeFalsy();
+ });
+ });
+
+ describe('computed', () => {
+ describe('timerText', () => {
+ it('should return correct timer text', () => {
+ const vm = createComponent();
+ expect(vm.timerText).toEqual('10 seconds');
+
+ vm.timer = 1;
+ expect(vm.timerText).toEqual('a second');
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should disable polling', () => {
+ spyOn(eventHub, '$emit');
+ createComponent();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling');
+ });
+ });
+
+ describe('methods', () => {
+ describe('refresh', () => {
+ it('should emit event to request component refresh', () => {
+ spyOn(eventHub, '$emit');
+ const vm = createComponent();
+
+ expect(vm.isRefreshing).toBeFalsy();
+
+ vm.refresh();
+ expect(vm.isRefreshing).toBeTruthy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
+ expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling');
+ });
+ });
+
+ describe('updateTimer', () => {
+ it('should update timer and emit event when timer end', () => {
+ const vm = createComponent();
+ spyOn(vm, 'refresh');
+
+ expect(vm.timer).toEqual(10);
+
+ for (let i = 0; i < 10; i++) { // eslint-disable-line
+ expect(vm.timer).toEqual(10 - i);
+ vm.updateTimer();
+ }
+
+ expect(vm.refresh).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ it('should have correct elements', (done) => {
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.innerText).toContain('Merge error happened.');
+ expect(el.innerText).toContain('Refreshing in 10 seconds');
+ expect(el.innerText).not.toContain('Merge failed.');
+ expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(el.querySelector('button').innerText).toContain('Merge');
+ expect(el.querySelector('.js-refresh-button').innerText).toContain('Refresh now');
+ expect(el.querySelector('.js-refresh-label')).toEqual(null);
+ expect(el.innerText).not.toContain('Refreshing now');
+ setTimeout(() => {
+ expect(el.innerText).toContain('Refreshing in 9 seconds');
+ done();
+ }, 1010);
+ });
+
+ it('should just generic merge failed message if merge_error is not available', (done) => {
+ vm.mr.mergeError = null;
+
+ Vue.nextTick(() => {
+ expect(el.innerText).toContain('Merge failed.');
+ expect(el.innerText).not.toContain('Merge error happened.');
+ done();
+ });
+ });
+
+ it('should show refresh label when refresh requested', () => {
+ vm.refresh();
+ Vue.nextTick(() => {
+ expect(el.innerText).not.toContain('Merge failed. Refreshing');
+ expect(el.innerText).toContain('Refreshing now');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js
new file mode 100644
index 00000000000..237035648cf
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging';
+
+describe('MRWidgetMerging', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr } = mergingComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const Component = Vue.extend(mergingComponent);
+ const mr = {
+ targetBranchPath: '/branch-path',
+ targetBranch: 'branch',
+ };
+ const el = new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ }).$el;
+
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.innerText).toContain('This merge request is in the process of being merged');
+ expect(el.innerText).toContain('changes will be merged into');
+ expect(el.querySelector('.label-branch a').getAttribute('href')).toEqual(mr.targetBranchPath);
+ expect(el.querySelector('.label-branch a').textContent).toContain(mr.targetBranch);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js
new file mode 100644
index 00000000000..9a71d0b47d7
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js
@@ -0,0 +1,213 @@
+import Vue from 'vue';
+import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+
+const targetBranchPath = '/foo/bar';
+const targetBranch = 'foo';
+const sha = '1EA2EZ34';
+
+const createComponent = () => {
+ const Component = Vue.extend(mwpsComponent);
+ const mr = {
+ shouldRemoveSourceBranch: false,
+ canRemoveSourceBranch: true,
+ canCancelAutomaticMerge: true,
+ mergeUserId: 1,
+ currentUserId: 1,
+ setToMWPSBy: {},
+ sha,
+ targetBranchPath,
+ targetBranch,
+ };
+
+ const service = {
+ cancelAutomaticMerge() {},
+ mergeResource: {
+ save() {},
+ },
+ };
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr, service },
+ });
+};
+
+describe('MRWidgetMergeWhenPipelineSucceeds', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr, service } = mwpsComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+
+ expect(service.type instanceof Object).toBeTruthy();
+ expect(service.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components added', () => {
+ expect(mwpsComponent.components['mr-widget-author']).toBeDefined();
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ const data = mwpsComponent.data();
+
+ expect(data.isCancellingAutoMerge).toBeFalsy();
+ expect(data.isRemovingSourceBranch).toBeFalsy();
+ });
+ });
+
+ describe('computed', () => {
+ describe('canRemoveSourceBranch', () => {
+ it('should return true when user is able to remove source branch', () => {
+ const vm = createComponent();
+
+ expect(vm.canRemoveSourceBranch).toBeTruthy();
+ });
+
+ it('should return false when user id is not the same with who set the MWPS', () => {
+ const vm = createComponent();
+
+ vm.mr.mergeUserId = 2;
+ expect(vm.canRemoveSourceBranch).toBeFalsy();
+
+ vm.mr.currentUserId = 2;
+ expect(vm.canRemoveSourceBranch).toBeTruthy();
+
+ vm.mr.currentUserId = 3;
+ expect(vm.canRemoveSourceBranch).toBeFalsy();
+ });
+
+ it('should return false when shouldRemoveSourceBranch set to false', () => {
+ const vm = createComponent();
+
+ vm.mr.shouldRemoveSourceBranch = true;
+ expect(vm.canRemoveSourceBranch).toBeFalsy();
+ });
+
+ it('should return false if user is not able to remove the source branch', () => {
+ const vm = createComponent();
+
+ vm.mr.canRemoveSourceBranch = false;
+ expect(vm.canRemoveSourceBranch).toBeFalsy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('cancelAutomaticMerge', () => {
+ it('should set flag and call service then tell main component to update the widget with data', (done) => {
+ const vm = createComponent();
+ const mrObj = {
+ is_new_mr_data: true,
+ };
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'cancelAutomaticMerge').and.returnValue(new Promise((resolve) => {
+ resolve({
+ json() {
+ return mrObj;
+ },
+ });
+ }));
+
+ vm.cancelAutomaticMerge();
+ setTimeout(() => {
+ expect(vm.isCancellingAutoMerge).toBeTruthy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj);
+ done();
+ }, 333);
+ });
+ });
+
+ describe('removeSourceBranch', () => {
+ it('should set flag and call service then request main component to update the widget', (done) => {
+ const vm = createComponent();
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service.mergeResource, 'save').and.returnValue(new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ status: 'merge_when_pipeline_succeeds',
+ };
+ },
+ });
+ }));
+
+ vm.removeSourceBranch();
+ setTimeout(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
+ expect(vm.service.mergeResource.save).toHaveBeenCalledWith({
+ sha,
+ merge_when_pipeline_succeeds: true,
+ should_remove_source_branch: true,
+ });
+ done();
+ }, 333);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ it('should have correct elements', () => {
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.innerText).toContain('to be merged automatically when the pipeline succeeds');
+ expect(el.innerText).toContain('The changes will be merged into');
+ expect(el.innerText).toContain(targetBranch);
+ expect(el.innerText).toContain('The source branch will not be removed');
+ expect(el.querySelector('.js-cancel-auto-merge').innerText).toContain('Cancel automatic merge');
+ expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeFalsy();
+ expect(el.querySelector('.js-remove-source-branch').innerText).toContain('Remove source branch');
+ expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeFalsy();
+ });
+
+ it('should disable cancel auto merge button when the action is in progress', (done) => {
+ vm.isCancellingAutoMerge = true;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should show source branch will be removed text when it source branch set to remove', (done) => {
+ vm.mr.shouldRemoveSourceBranch = true;
+
+ Vue.nextTick(() => {
+ const normalizedText = el.innerText.replace(/\s+/g, ' ');
+ expect(normalizedText).toContain('The source branch will be removed');
+ expect(normalizedText).not.toContain('The source branch will not be removed');
+ done();
+ });
+ });
+
+ it('should not show remove source branch button when user not able to remove source branch', (done) => {
+ vm.mr.currentUserId = 4;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-remove-source-branch')).toEqual(null);
+ done();
+ });
+ });
+
+ it('should disable remove source branch button when the action is in progress', (done) => {
+ vm.isRemovingSourceBranch = true;
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeTruthy();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js
new file mode 100644
index 00000000000..afaa750199a
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js
@@ -0,0 +1,174 @@
+import Vue from 'vue';
+import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+
+const targetBranch = 'foo';
+
+const createComponent = () => {
+ const Component = Vue.extend(mergedComponent);
+ const mr = {
+ isRemovingSourceBranch: false,
+ cherryPickInForkPath: false,
+ canCherryPickInCurrentMR: true,
+ revertInForkPath: false,
+ canRevertInCurrentMR: true,
+ canRemoveSourceBranch: true,
+ sourceBranchRemoved: true,
+ mergedBy: {},
+ mergedAt: '',
+ updatedAt: '',
+ targetBranch,
+ };
+
+ const service = {
+ removeSourceBranch() {},
+ };
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr, service },
+ });
+};
+
+describe('MRWidgetMerged', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr, service } = mergedComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+
+ expect(service.type instanceof Object).toBeTruthy();
+ expect(service.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components added', () => {
+ expect(mergedComponent.components['mr-widget-author-and-time']).toBeDefined();
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ const data = mergedComponent.data();
+
+ expect(data.isMakingRequest).toBeFalsy();
+ });
+ });
+
+ describe('computed', () => {
+ describe('shouldShowRemoveSourceBranch', () => {
+ it('should correct value when fields changed', () => {
+ const vm = createComponent();
+ vm.mr.sourceBranchRemoved = false;
+ expect(vm.shouldShowRemoveSourceBranch).toBeTruthy();
+
+ vm.mr.sourceBranchRemoved = true;
+ expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+
+ vm.mr.sourceBranchRemoved = false;
+ vm.mr.canRemoveSourceBranch = false;
+ expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+
+ vm.mr.canRemoveSourceBranch = true;
+ vm.isMakingRequest = true;
+ expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+
+ vm.mr.isRemovingSourceBranch = true;
+ vm.mr.canRemoveSourceBranch = true;
+ vm.isMakingRequest = true;
+ expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ });
+ });
+ describe('shouldShowSourceBranchRemoving', () => {
+ it('should correct value when fields changed', () => {
+ const vm = createComponent();
+ vm.mr.sourceBranchRemoved = false;
+ expect(vm.shouldShowSourceBranchRemoving).toBeFalsy();
+
+ vm.mr.sourceBranchRemoved = true;
+ expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+
+ vm.mr.sourceBranchRemoved = false;
+ vm.isMakingRequest = true;
+ expect(vm.shouldShowSourceBranchRemoving).toBeTruthy();
+
+ vm.isMakingRequest = false;
+ vm.mr.isRemovingSourceBranch = true;
+ expect(vm.shouldShowSourceBranchRemoving).toBeTruthy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('removeSourceBranch', () => {
+ it('should set flag and call service then request main component to update the widget', (done) => {
+ const vm = createComponent();
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'removeSourceBranch').and.returnValue(new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ message: 'Branch was removed',
+ };
+ },
+ });
+ }));
+
+ vm.removeSourceBranch();
+ setTimeout(() => {
+ const args = eventHub.$emit.calls.argsFor(0);
+ expect(vm.isMakingRequest).toBeTruthy();
+ expect(args[0]).toEqual('MRWidgetUpdateRequested');
+ expect(args[1]).not.toThrow();
+ done();
+ }, 333);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ it('should have correct elements', () => {
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.querySelector('.js-mr-widget-author')).toBeDefined();
+ expect(el.innerText).toContain('The changes were merged into');
+ expect(el.innerText).toContain(targetBranch);
+ expect(el.innerText).toContain('The source branch has been removed');
+ expect(el.innerText).toContain('Revert');
+ expect(el.innerText).toContain('Cherry-pick');
+ expect(el.innerText).not.toContain('You can remove source branch now');
+ expect(el.innerText).not.toContain('The source branch is being removed');
+ });
+
+ it('should not show source branch removed text', (done) => {
+ vm.mr.sourceBranchRemoved = false;
+
+ Vue.nextTick(() => {
+ expect(el.innerText).toContain('You can remove source branch now');
+ expect(el.innerText).not.toContain('The source branch has been removed');
+ done();
+ });
+ });
+
+ it('should show source branch removing text', (done) => {
+ vm.mr.isRemovingSourceBranch = true;
+ vm.mr.sourceBranchRemoved = false;
+
+ Vue.nextTick(() => {
+ expect(el.innerText).toContain('The source branch is being removed');
+ expect(el.innerText).not.toContain('You can remove source branch now');
+ expect(el.innerText).not.toContain('The source branch has been removed');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js
new file mode 100644
index 00000000000..720effb5c1c
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch';
+
+const createComponent = () => {
+ const Component = Vue.extend(missingBranchComponent);
+ const mr = {
+ sourceBranchRemoved: true,
+ };
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ });
+};
+
+describe('MRWidgetMissingBranch', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const mrProp = missingBranchComponent.props.mr;
+
+ expect(mrProp.type instanceof Object).toBeTruthy();
+ expect(mrProp.required).toBeTruthy();
+ });
+ });
+
+ describe('components', () => {
+ it('should have components added', () => {
+ expect(missingBranchComponent.components['mr-widget-merge-help']).toBeDefined();
+ });
+ });
+
+ describe('computed', () => {
+ describe('missingBranchName', () => {
+ it('should return proper branch name', () => {
+ const vm = createComponent();
+ expect(vm.missingBranchName).toEqual('source');
+
+ vm.mr.sourceBranchRemoved = false;
+ expect(vm.missingBranchName).toEqual('target');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should have correct elements', () => {
+ const el = createComponent().$el;
+ const content = el.textContent.replace(/\n(\s)+/g, ' ').trim();
+
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(content).toContain('source branch does not exist.');
+ expect(content).toContain('Please restore it or use a different source branch');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js
new file mode 100644
index 00000000000..33f20ab132d
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed';
+
+describe('MRWidgetNotAllowed', () => {
+ describe('template', () => {
+ const Component = Vue.extend(notAllowedComponent);
+ const vm = new Component({
+ el: document.createElement('div'),
+ });
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(vm.$el.innerText).toContain('Ready to be merged automatically.');
+ expect(vm.$el.innerText).toContain('Ask someone with write access to this repository to merge this request');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js
new file mode 100644
index 00000000000..a8a02fa6b66
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import nothingToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge';
+
+describe('MRWidgetNothingToMerge', () => {
+ describe('template', () => {
+ const Component = Vue.extend(nothingToMergeComponent);
+ const newBlobPath = '/foo';
+ const vm = new Component({
+ el: document.createElement('div'),
+ propsData: {
+ mr: { newBlobPath },
+ },
+ });
+
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('a').href).toContain(newBlobPath);
+ expect(vm.$el.innerText).toContain('Currently there are no changes in this merge request\'s source branch');
+ expect(vm.$el.innerText).toContain('Please push new commits or use a different branch.');
+ });
+
+ it('should not show new blob link if there is no link available', () => {
+ vm.mr.newBlobPath = null;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('a')).toEqual(null);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js
new file mode 100644
index 00000000000..d0702f9f503
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked';
+
+describe('MRWidgetPipelineBlocked', () => {
+ describe('template', () => {
+ const Component = Vue.extend(pipelineBlockedComponent);
+ const vm = new Component({
+ el: document.createElement('div'),
+ });
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(vm.$el.innerText).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js
new file mode 100644
index 00000000000..78bac1c61a5
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import pipelineFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_failed';
+
+describe('MRWidgetPipelineFailed', () => {
+ describe('template', () => {
+ const Component = Vue.extend(pipelineFailedComponent);
+ const vm = new Component({
+ el: document.createElement('div'),
+ });
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(vm.$el.innerText).toContain('The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
new file mode 100644
index 00000000000..c607c9746a4
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -0,0 +1,422 @@
+import Vue from 'vue';
+import readyToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_ready_to_merge';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+import * as simplePoll from '~/lib/utils/simple_poll';
+
+const commitMessage = 'This is the commit message';
+const commitMessageWithDescription = 'This is the commit message description';
+const createComponent = (customConfig = {}) => {
+ const Component = Vue.extend(readyToMergeComponent);
+ const mr = {
+ isPipelineActive: false,
+ pipeline: null,
+ isPipelineFailed: false,
+ onlyAllowMergeIfPipelineSucceeds: false,
+ hasCI: false,
+ ciStatus: null,
+ sha: '12345678',
+ commitMessage,
+ commitMessageWithDescription,
+ shouldRemoveSourceBranch: true,
+ canRemoveSourceBranch: false,
+ };
+
+ Object.assign(mr, customConfig.mr);
+
+ const service = {
+ merge() {},
+ poll() {},
+ };
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr, service },
+ });
+};
+
+describe('MRWidgetReadyToMerge', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr, service } = readyToMergeComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+
+ expect(service.type instanceof Object).toBeTruthy();
+ expect(service.required).toBeTruthy();
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ expect(vm.mergeWhenBuildSucceeds).toBeFalsy();
+ expect(vm.useCommitMessageWithDescription).toBeFalsy();
+ expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy();
+ expect(vm.showCommitMessageEditor).toBeFalsy();
+ expect(vm.isMakingRequest).toBeFalsy();
+ expect(vm.isMergingImmediately).toBeFalsy();
+ expect(vm.commitMessage).toBe(vm.mr.commitMessage);
+ expect(vm.successSvg).toBeDefined();
+ expect(vm.warningSvg).toBeDefined();
+ });
+ });
+
+ describe('computed', () => {
+ describe('commitMessageLinkTitle', () => {
+ const withDesc = 'Include description in commit message';
+ const withoutDesc = "Don't include description in commit message";
+
+ it('should return message with description', () => {
+ expect(vm.commitMessageLinkTitle).toEqual(withDesc);
+ });
+
+ it('should return message without description', () => {
+ vm.useCommitMessageWithDescription = true;
+ expect(vm.commitMessageLinkTitle).toEqual(withoutDesc);
+ });
+ });
+
+ describe('mergeButtonClass', () => {
+ const defaultClass = 'btn btn-small btn-success accept-merge-request';
+ const failedClass = `${defaultClass} btn-danger`;
+ const inActionClass = `${defaultClass} btn-info`;
+
+ it('should return default class', () => {
+ vm.mr.pipeline = true;
+ expect(vm.mergeButtonClass).toEqual(defaultClass);
+ });
+
+ it('should return failed class when MR has CI but also has an unknown status', () => {
+ vm.mr.hasCI = true;
+ expect(vm.mergeButtonClass).toEqual(failedClass);
+ });
+
+ it('should return default class when MR has no pipeline', () => {
+ expect(vm.mergeButtonClass).toEqual(defaultClass);
+ });
+
+ it('should return in action class when pipeline is active', () => {
+ vm.mr.pipeline = {};
+ vm.mr.isPipelineActive = true;
+ expect(vm.mergeButtonClass).toEqual(inActionClass);
+ });
+
+ it('should return failed class when pipeline is failed', () => {
+ vm.mr.pipeline = {};
+ vm.mr.isPipelineFailed = true;
+ expect(vm.mergeButtonClass).toEqual(failedClass);
+ });
+ });
+
+ describe('mergeButtonText', () => {
+ it('should return Merge', () => {
+ expect(vm.mergeButtonText).toEqual('Merge');
+ });
+
+ it('should return Merge in progress', () => {
+ vm.isMergingImmediately = true;
+ expect(vm.mergeButtonText).toEqual('Merge in progress');
+ });
+
+ it('should return Merge when pipeline succeeds', () => {
+ vm.isMergingImmediately = false;
+ vm.mr.isPipelineActive = true;
+ expect(vm.mergeButtonText).toEqual('Merge when pipeline succeeds');
+ });
+ });
+
+ describe('shouldShowMergeOptionsDropdown', () => {
+ it('should return false with initial data', () => {
+ expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy();
+ });
+
+ it('should return true when pipeline active', () => {
+ vm.mr.isPipelineActive = true;
+ expect(vm.shouldShowMergeOptionsDropdown).toBeTruthy();
+ });
+
+ it('should return false when pipeline active but only merge when pipeline succeeds set in project options', () => {
+ vm.mr.isPipelineActive = true;
+ vm.mr.onlyAllowMergeIfPipelineSucceeds = true;
+ expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy();
+ });
+ });
+
+ describe('isMergeButtonDisabled', () => {
+ it('should return false with initial data', () => {
+ expect(vm.isMergeButtonDisabled).toBeFalsy();
+ });
+
+ it('should return true when there is no commit message', () => {
+ vm.commitMessage = '';
+ expect(vm.isMergeButtonDisabled).toBeTruthy();
+ });
+
+ it('should return true if merge is not allowed', () => {
+ vm.mr.onlyAllowMergeIfPipelineSucceeds = true;
+ vm.mr.isPipelineFailed = true;
+ expect(vm.isMergeButtonDisabled).toBeTruthy();
+ });
+
+ it('should return true when there vm instance is making request', () => {
+ vm.isMakingRequest = true;
+ expect(vm.isMergeButtonDisabled).toBeTruthy();
+ });
+ });
+
+ describe('Remove source branch checkbox', () => {
+ describe('when user can merge but cannot delete branch', () => {
+ it('isRemoveSourceBranchButtonDisabled should be true', () => {
+ expect(vm.isRemoveSourceBranchButtonDisabled).toBe(true);
+ });
+
+ it('should be disabled in the rendered output', () => {
+ const checkboxElement = vm.$el.querySelector('#remove-source-branch-input');
+ expect(checkboxElement.getAttribute('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('when user can merge and can delete branch', () => {
+ beforeEach(() => {
+ this.customVm = createComponent({
+ mr: { canRemoveSourceBranch: true },
+ });
+ });
+
+ it('isRemoveSourceBranchButtonDisabled should be false', () => {
+ expect(this.customVm.isRemoveSourceBranchButtonDisabled).toBe(false);
+ });
+
+ it('should be enabled in rendered output', () => {
+ const checkboxElement = this.customVm.$el.querySelector('#remove-source-branch-input');
+ expect(checkboxElement.getAttribute('disabled')).toBeNull();
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('isMergeAllowed', () => {
+ it('should return false with initial data', () => {
+ expect(vm.isMergeAllowed()).toBeTruthy();
+ });
+
+ it('should return false when MR is set only merge when pipeline succeeds', () => {
+ vm.mr.onlyAllowMergeIfPipelineSucceeds = true;
+ expect(vm.isMergeAllowed()).toBeTruthy();
+ });
+
+ it('should return true true', () => {
+ vm.mr.onlyAllowMergeIfPipelineSucceeds = true;
+ vm.mr.isPipelineFailed = true;
+ expect(vm.isMergeAllowed()).toBeFalsy();
+ });
+ });
+
+ describe('updateCommitMessage', () => {
+ it('should revert flag and change commitMessage', () => {
+ expect(vm.useCommitMessageWithDescription).toBeFalsy();
+ expect(vm.commitMessage).toEqual(commitMessage);
+ vm.updateCommitMessage();
+ expect(vm.useCommitMessageWithDescription).toBeTruthy();
+ expect(vm.commitMessage).toEqual(commitMessageWithDescription);
+ vm.updateCommitMessage();
+ expect(vm.useCommitMessageWithDescription).toBeFalsy();
+ expect(vm.commitMessage).toEqual(commitMessage);
+ });
+ });
+
+ describe('toggleCommitMessageEditor', () => {
+ it('should toggle showCommitMessageEditor flag', () => {
+ expect(vm.showCommitMessageEditor).toBeFalsy();
+ vm.toggleCommitMessageEditor();
+ expect(vm.showCommitMessageEditor).toBeTruthy();
+ });
+ });
+
+ describe('handleMergeButtonClick', () => {
+ const returnPromise = status => new Promise((resolve) => {
+ resolve({
+ json() {
+ return { status };
+ },
+ });
+ });
+
+ it('should handle merge when pipeline succeeds', (done) => {
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'merge').and.returnValue(returnPromise('merge_when_pipeline_succeeds'));
+ vm.removeSourceBranch = false;
+ vm.handleMergeButtonClick(true);
+
+ setTimeout(() => {
+ expect(vm.setToMergeWhenPipelineSucceeds).toBeTruthy();
+ expect(vm.isMakingRequest).toBeTruthy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
+
+ const params = vm.service.merge.calls.argsFor(0)[0];
+ expect(params.sha).toEqual(vm.mr.sha);
+ expect(params.commit_message).toEqual(vm.mr.commitMessage);
+ expect(params.should_remove_source_branch).toBeFalsy();
+ expect(params.merge_when_pipeline_succeeds).toBeTruthy();
+ done();
+ }, 333);
+ });
+
+ it('should handle merge failed', (done) => {
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'merge').and.returnValue(returnPromise('failed'));
+ vm.handleMergeButtonClick(false, true);
+
+ setTimeout(() => {
+ expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy();
+ expect(vm.isMakingRequest).toBeTruthy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined);
+
+ const params = vm.service.merge.calls.argsFor(0)[0];
+ expect(params.should_remove_source_branch).toBeTruthy();
+ expect(params.merge_when_pipeline_succeeds).toBeFalsy();
+ done();
+ }, 333);
+ });
+
+ it('should handle merge action accepted case', (done) => {
+ spyOn(vm.service, 'merge').and.returnValue(returnPromise('success'));
+ spyOn(vm, 'initiateMergePolling');
+ vm.handleMergeButtonClick();
+
+ setTimeout(() => {
+ expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy();
+ expect(vm.isMakingRequest).toBeTruthy();
+ expect(vm.initiateMergePolling).toHaveBeenCalled();
+
+ const params = vm.service.merge.calls.argsFor(0)[0];
+ expect(params.should_remove_source_branch).toBeTruthy();
+ expect(params.merge_when_pipeline_succeeds).toBeFalsy();
+ done();
+ }, 333);
+ });
+ });
+
+ describe('initiateMergePolling', () => {
+ it('should call simplePoll', () => {
+ spyOn(simplePoll, 'default');
+ vm.initiateMergePolling();
+ expect(simplePoll.default).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleMergePolling', () => {
+ const returnPromise = state => new Promise((resolve) => {
+ resolve({
+ json() {
+ return { state, source_branch_exists: true };
+ },
+ });
+ });
+
+ it('should call start and stop polling when MR merged', (done) => {
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged'));
+ spyOn(vm, 'initiateRemoveSourceBranchPolling');
+
+ let cpc = false; // continuePollingCalled
+ let spc = false; // stopPollingCalled
+
+ vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; });
+ setTimeout(() => {
+ expect(vm.service.poll).toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
+ expect(eventHub.$emit).toHaveBeenCalledWith('FetchActionsContent');
+ expect(vm.initiateRemoveSourceBranchPolling).toHaveBeenCalled();
+ expect(cpc).toBeFalsy();
+ expect(spc).toBeTruthy();
+
+ done();
+ }, 333);
+ });
+
+ it('should continue polling until MR is merged', (done) => {
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise('some_other_state'));
+ spyOn(vm, 'initiateRemoveSourceBranchPolling');
+
+ let cpc = false; // continuePollingCalled
+ let spc = false; // stopPollingCalled
+
+ vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; });
+ setTimeout(() => {
+ expect(cpc).toBeTruthy();
+ expect(spc).toBeFalsy();
+
+ done();
+ }, 333);
+ });
+ });
+
+ describe('initiateRemoveSourceBranchPolling', () => {
+ it('should emit event and call simplePoll', () => {
+ spyOn(eventHub, '$emit');
+ spyOn(simplePoll, 'default');
+
+ vm.initiateRemoveSourceBranchPolling();
+ expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [true]);
+ expect(simplePoll.default).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleRemoveBranchPolling', () => {
+ const returnPromise = state => new Promise((resolve) => {
+ resolve({
+ json() {
+ return { source_branch_exists: state };
+ },
+ });
+ });
+
+ it('should call start and stop polling when MR merged', (done) => {
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise(false));
+
+ let cpc = false; // continuePollingCalled
+ let spc = false; // stopPollingCalled
+
+ vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; });
+ setTimeout(() => {
+ expect(vm.service.poll).toHaveBeenCalled();
+
+ const args = eventHub.$emit.calls.argsFor(0);
+ expect(args[0]).toEqual('MRWidgetUpdateRequested');
+ expect(args[1]).toBeDefined();
+ args[1]();
+ expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [false]);
+
+ expect(cpc).toBeFalsy();
+ expect(spc).toBeTruthy();
+
+ done();
+ }, 333);
+ });
+
+ it('should continue polling until MR is merged', (done) => {
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise(true));
+
+ let cpc = false; // continuePollingCalled
+ let spc = false; // stopPollingCalled
+
+ vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; });
+ setTimeout(() => {
+ expect(cpc).toBeTruthy();
+ expect(spc).toBeFalsy();
+
+ done();
+ }, 333);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
new file mode 100644
index 00000000000..4c67504b642
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch';
+
+describe('MRWidgetSHAMismatch', () => {
+ describe('template', () => {
+ const Component = Vue.extend(shaMismatchComponent);
+ const vm = new Component({
+ el: document.createElement('div'),
+ });
+ it('should have correct elements', () => {
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js
new file mode 100644
index 00000000000..fe87f110354
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions';
+
+describe('MRWidgetUnresolvedDiscussions', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr } = unresolvedDiscussionsComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+ });
+ });
+
+ describe('template', () => {
+ let el;
+ let vm;
+ const path = 'foo/bar';
+
+ beforeEach(() => {
+ const Component = Vue.extend(unresolvedDiscussionsComponent);
+ const mr = {
+ createIssueToResolveDiscussionsPath: path,
+ };
+ vm = new Component({
+ el: document.createElement('div'),
+ propsData: { mr },
+ });
+ el = vm.$el;
+ });
+
+ it('should have correct elements', () => {
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.innerText).toContain('There are unresolved discussions. Please resolve these discussions');
+ expect(el.innerText).toContain('Create an issue to resolve them later');
+ expect(el.querySelector('.js-create-issue').getAttribute('href')).toEqual(path);
+ });
+
+ it('should not show create issue button if user cannot create issue', (done) => {
+ vm.mr.createIssueToResolveDiscussionsPath = '';
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-create-issue')).toEqual(null);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js
new file mode 100644
index 00000000000..2cb3aaa6951
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js
@@ -0,0 +1,96 @@
+import Vue from 'vue';
+import wipComponent from '~/vue_merge_request_widget/components/states/mr_widget_wip';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+
+const createComponent = () => {
+ const Component = Vue.extend(wipComponent);
+ const mr = {
+ title: 'The best MR ever',
+ removeWIPPath: '/path/to/remove/wip',
+ };
+ const service = {
+ removeWIP() {},
+ };
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { mr, service },
+ });
+};
+
+describe('MRWidgetWIP', () => {
+ describe('props', () => {
+ it('should have props', () => {
+ const { mr, service } = wipComponent.props;
+
+ expect(mr.type instanceof Object).toBeTruthy();
+ expect(mr.required).toBeTruthy();
+
+ expect(service.type instanceof Object).toBeTruthy();
+ expect(service.required).toBeTruthy();
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ const vm = createComponent();
+ expect(vm.isMakingRequest).toBeFalsy();
+ });
+ });
+
+ describe('methods', () => {
+ const mrObj = {
+ is_new_mr_data: true,
+ };
+
+ describe('removeWIP', () => {
+ it('should make a request to service and handle response', (done) => {
+ const vm = createComponent();
+
+ spyOn(window, 'Flash').and.returnValue(true);
+ spyOn(eventHub, '$emit');
+ spyOn(vm.service, 'removeWIP').and.returnValue(new Promise((resolve) => {
+ resolve({
+ json() {
+ return mrObj;
+ },
+ });
+ }));
+
+ vm.removeWIP();
+ setTimeout(() => {
+ expect(vm.isMakingRequest).toBeTruthy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj);
+ expect(window.Flash).toHaveBeenCalledWith('The merge request can now be merged.', 'notice');
+ done();
+ }, 333);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ it('should have correct elements', () => {
+ expect(el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(el.innerText).toContain('This is a Work in Progress');
+ expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
+ expect(el.querySelector('button').innerText).toContain('Merge');
+ expect(el.querySelector('.js-remove-wip').innerText).toContain('Resolve WIP status');
+ });
+
+ it('should not show removeWIP button is user cannot update MR', (done) => {
+ vm.mr.removeWIPPath = '';
+
+ Vue.nextTick(() => {
+ expect(el.querySelector('.js-remove-wip')).toEqual(null);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
new file mode 100644
index 00000000000..0795d0aaa82
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -0,0 +1,214 @@
+/* eslint-disable */
+
+export default {
+ "id": 132,
+ "iid": 22,
+ "assignee_id": null,
+ "author_id": 1,
+ "description": "",
+ "lock_version": null,
+ "milestone_id": null,
+ "position": 0,
+ "state": "merged",
+ "title": "Update README.md",
+ "updated_by_id": null,
+ "created_at": "2017-04-07T12:27:26.718Z",
+ "updated_at": "2017-04-07T15:39:25.852Z",
+ "deleted_at": null,
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "in_progress_merge_commit_sha": null,
+ "merge_commit_sha": "53027d060246c8f47e4a9310fb332aa52f221775",
+ "merge_error": null,
+ "merge_params": {
+ "force_remove_source_branch": null
+ },
+ "merge_status": "can_be_merged",
+ "merge_user_id": null,
+ "merge_when_pipeline_succeeds": false,
+ "source_branch": "daaaa",
+ "source_branch_link": "daaaa",
+ "source_project_id": 19,
+ "target_branch": "master",
+ "target_project_id": 19,
+ "merge_event": {
+ "author": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "updated_at": "2017-04-07T15:39:25.696Z"
+ },
+ "closed_event": null,
+ "author": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "merge_user": null,
+ "diff_head_sha": "104096c51715e12e7ae41f9333e9fa35b73f385d",
+ "diff_head_commit_short_id": "104096c5",
+ "merge_commit_message": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
+ "pipeline": {
+ "id": 172,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "active": false,
+ "coverage": "92.16",
+ "path": "/root/acets-app/pipelines/172",
+ "details": {
+ "status": {
+ "icon": "icon_status_success",
+ "favicon": "favicon_status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "has_details": true,
+ "details_path": "/root/acets-app/pipelines/172"
+ },
+ "duration": null,
+ "finished_at": "2017-04-07T14:00:14.256Z",
+ "stages": [
+ {
+ "name": "build",
+ "title": "build: failed",
+ "status": {
+ "icon": "icon_status_failed",
+ "favicon": "favicon_status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "has_details": true,
+ "details_path": "/root/acets-app/pipelines/172#build"
+ },
+ "path": "/root/acets-app/pipelines/172#build",
+ "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=build"
+ },
+ {
+ "name": "review",
+ "title": "review: skipped",
+ "status": {
+ "icon": "icon_status_skipped",
+ "favicon": "favicon_status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "has_details": true,
+ "details_path": "/root/acets-app/pipelines/172#review"
+ },
+ "path": "/root/acets-app/pipelines/172#review",
+ "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=review"
+ }
+ ],
+ "artifacts": [
+
+ ],
+ "manual_actions": [
+ {
+ "name": "stop_review",
+ "path": "/root/acets-app/builds/1427/play",
+ "playable": false
+ }
+ ]
+ },
+ "flags": {
+ "latest": false,
+ "triggered": false,
+ "stuck": false,
+ "yaml_errors": false,
+ "retryable": true,
+ "cancelable": false
+ },
+ "ref": {
+ "name": "daaaa",
+ "path": "/root/acets-app/tree/daaaa",
+ "tag": false,
+ "branch": true
+ },
+ "commit": {
+ "id": "104096c51715e12e7ae41f9333e9fa35b73f385d",
+ "short_id": "104096c5",
+ "title": "Update README.md",
+ "created_at": "2017-04-07T15:27:18.000+03:00",
+ "parent_ids": [
+ "2396536178668d8930c29d904e53bd4d06228b32"
+ ],
+ "message": "Update README.md",
+ "author_name": "Administrator",
+ "author_email": "admin@example.com",
+ "authored_date": "2017-04-07T15:27:18.000+03:00",
+ "committer_name": "Administrator",
+ "committer_email": "admin@example.com",
+ "committed_date": "2017-04-07T15:27:18.000+03:00",
+ "author": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "author_gravatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "commit_url": "http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d",
+ "commit_path": "/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d"
+ },
+ "retry_path": "/root/acets-app/pipelines/172/retry",
+ "created_at": "2017-04-07T12:27:19.520Z",
+ "updated_at": "2017-04-07T15:28:44.800Z"
+ },
+ "work_in_progress": false,
+ "source_branch_exists": false,
+ "mergeable_discussions_state": true,
+ "conflicts_can_be_resolved_in_ui": false,
+ "branch_missing": true,
+ "commits_count": 1,
+ "has_conflicts": false,
+ "can_be_merged": true,
+ "has_ci": true,
+ "ci_status": "success",
+ "pipeline_status_path": "/root/acets-app/merge_requests/22/pipeline_status",
+ "issues_links": {
+ "closing": "",
+ "mentioned_but_not_closing": ""
+ },
+ "current_user": {
+ "can_resolve_conflicts": true,
+ "can_remove_source_branch": false,
+ "can_revert_on_current_merge_request": true,
+ "can_cherry_pick_on_current_merge_request": true
+ },
+ "target_branch_path": "/root/acets-app/branches/master",
+ "source_branch_path": "/root/acets-app/branches/daaaa",
+ "conflict_resolution_ui_path": "/root/acets-app/merge_requests/22/conflicts",
+ "remove_wip_path": "/root/acets-app/merge_requests/22/remove_wip",
+ "cancel_merge_when_pipeline_succeeds_path": "/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds",
+ "create_issue_to_resolve_discussions_path": "/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22",
+ "merge_path": "/root/acets-app/merge_requests/22/merge",
+ "cherry_pick_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1",
+ "revert_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1",
+ "email_patches_path": "/root/acets-app/merge_requests/22.patch",
+ "plain_diff_path": "/root/acets-app/merge_requests/22.diff",
+ "ci_status_path": "/root/acets-app/merge_requests/22/ci_status",
+ "status_path": "/root/acets-app/merge_requests/22.json",
+ "merge_check_path": "/root/acets-app/merge_requests/22/merge_check",
+ "ci_environments_status_url": "/root/acets-app/merge_requests/22/ci_environments_status",
+ "project_archived": false,
+ "merge_commit_message_with_description": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
+ "diverged_commits_count": 0,
+ "only_allow_merge_if_pipeline_succeeds": false,
+ "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content"
+}
diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js
new file mode 100644
index 00000000000..669ee248bf1
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js
@@ -0,0 +1,361 @@
+import Vue from 'vue';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+import notify from '~/lib/utils/notify';
+import mockData from './mock_data';
+
+const createComponent = () => {
+ delete mrWidgetOptions.el; // Prevent component mounting
+ gl.mrWidgetData = mockData;
+ const Component = Vue.extend(mrWidgetOptions);
+ return new Component();
+};
+
+const returnPromise = data => new Promise((resolve) => {
+ resolve({
+ json() {
+ return data;
+ },
+ body: data,
+ });
+});
+
+describe('mrWidgetOptions', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ describe('data', () => {
+ it('should instantiate Store and Service', () => {
+ expect(vm.mr).toBeDefined();
+ expect(vm.service).toBeDefined();
+ });
+ });
+
+ describe('computed', () => {
+ describe('componentName', () => {
+ it('should return merged component', () => {
+ expect(vm.componentName).toEqual('mr-widget-merged');
+ });
+
+ it('should return conflicts component', () => {
+ vm.mr.state = 'conflicts';
+ expect(vm.componentName).toEqual('mr-widget-conflicts');
+ });
+ });
+
+ describe('shouldRenderMergeHelp', () => {
+ it('should return false for the initial merged state', () => {
+ expect(vm.shouldRenderMergeHelp).toBeFalsy();
+ });
+
+ it('should return true for a state which requires help widget', () => {
+ vm.mr.state = 'conflicts';
+ expect(vm.shouldRenderMergeHelp).toBeTruthy();
+ });
+ });
+
+ describe('shouldRenderPipelines', () => {
+ it('should return true for the initial data', () => {
+ expect(vm.shouldRenderPipelines).toBeTruthy();
+ });
+
+ it('should return true when pipeline is empty but MR.hasCI is set to true', () => {
+ vm.mr.pipeline = {};
+ expect(vm.shouldRenderPipelines).toBeTruthy();
+ });
+
+ it('should return true when pipeline available', () => {
+ vm.mr.hasCI = false;
+ expect(vm.shouldRenderPipelines).toBeTruthy();
+ });
+
+ it('should return false when there is no pipeline', () => {
+ vm.mr.pipeline = {};
+ vm.mr.hasCI = false;
+ expect(vm.shouldRenderPipelines).toBeFalsy();
+ });
+ });
+
+ describe('shouldRenderRelatedLinks', () => {
+ it('should return false for the initial data', () => {
+ expect(vm.shouldRenderRelatedLinks).toBeFalsy();
+ });
+
+ it('should return true if there is relatedLinks in MR', () => {
+ vm.mr.relatedLinks = {};
+ expect(vm.shouldRenderRelatedLinks).toBeTruthy();
+ });
+ });
+
+ describe('shouldRenderDeployments', () => {
+ it('should return false for the initial data', () => {
+ expect(vm.shouldRenderDeployments).toBeFalsy();
+ });
+
+ it('should return true if there is deployments', () => {
+ vm.mr.deployments.push({}, {});
+ expect(vm.shouldRenderDeployments).toBeTruthy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('checkStatus', () => {
+ it('should tell service to check status', (done) => {
+ spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData));
+ spyOn(vm.mr, 'setData');
+ spyOn(vm, 'handleNotification');
+
+ let isCbExecuted = false;
+ const cb = () => {
+ isCbExecuted = true;
+ };
+
+ vm.checkStatus(cb);
+
+ setTimeout(() => {
+ expect(vm.service.checkStatus).toHaveBeenCalled();
+ expect(vm.mr.setData).toHaveBeenCalled();
+ expect(vm.handleNotification).toHaveBeenCalledWith(mockData);
+ expect(isCbExecuted).toBeTruthy();
+ done();
+ }, 333);
+ });
+ });
+
+ describe('initPolling', () => {
+ it('should call SmartInterval', () => {
+ spyOn(gl, 'SmartInterval').and.returnValue({
+ resume() {},
+ stopTimer() {},
+ });
+ vm.initPolling();
+
+ expect(vm.pollingInterval).toBeDefined();
+ expect(gl.SmartInterval).toHaveBeenCalled();
+ });
+ });
+
+ describe('initDeploymentsPolling', () => {
+ it('should call SmartInterval', () => {
+ spyOn(gl, 'SmartInterval');
+ vm.initDeploymentsPolling();
+
+ expect(vm.deploymentsInterval).toBeDefined();
+ expect(gl.SmartInterval).toHaveBeenCalled();
+ });
+ });
+
+ describe('fetchDeployments', () => {
+ it('should fetch deployments', (done) => {
+ spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }]));
+
+ vm.fetchDeployments();
+
+ setTimeout(() => {
+ expect(vm.service.fetchDeployments).toHaveBeenCalled();
+ expect(vm.mr.deployments.length).toEqual(1);
+ expect(vm.mr.deployments[0].deployment).toEqual(1);
+ done();
+ }, 333);
+ });
+ });
+
+ describe('fetchActionsContent', () => {
+ it('should fetch content of Cherry Pick and Revert modals', (done) => {
+ spyOn(vm.service, 'fetchMergeActionsContent').and.returnValue(returnPromise('hello world'));
+
+ vm.fetchActionsContent();
+
+ setTimeout(() => {
+ expect(vm.service.fetchMergeActionsContent).toHaveBeenCalled();
+ expect(document.body.textContent).toContain('hello world');
+ done();
+ }, 333);
+ });
+ });
+
+ describe('bindEventHubListeners', () => {
+ it('should bind eventHub listeners', () => {
+ spyOn(vm, 'checkStatus').and.returnValue(() => {});
+ spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData));
+ spyOn(vm, 'fetchActionsContent');
+ spyOn(vm.mr, 'setData');
+ spyOn(vm, 'resumePolling');
+ spyOn(vm, 'stopPolling');
+ spyOn(eventHub, '$on');
+
+ vm.bindEventHubListeners();
+
+ eventHub.$emit('SetBranchRemoveFlag', ['flag']);
+ expect(vm.mr.isRemovingSourceBranch).toEqual('flag');
+
+ eventHub.$emit('FailedToMerge');
+ expect(vm.mr.state).toEqual('failedToMerge');
+
+ eventHub.$emit('UpdateWidgetData', mockData);
+ expect(vm.mr.setData).toHaveBeenCalledWith(mockData);
+
+ eventHub.$emit('EnablePolling');
+ expect(vm.resumePolling).toHaveBeenCalled();
+
+ eventHub.$emit('DisablePolling');
+ expect(vm.stopPolling).toHaveBeenCalled();
+
+ const listenersWithServiceRequest = {
+ MRWidgetUpdateRequested: true,
+ FetchActionsContent: true,
+ };
+
+ const allArgs = eventHub.$on.calls.allArgs();
+ allArgs.forEach((params) => {
+ const eventName = params[0];
+ const callback = params[1];
+
+ if (listenersWithServiceRequest[eventName]) {
+ listenersWithServiceRequest[eventName] = callback;
+ }
+ });
+
+ listenersWithServiceRequest.MRWidgetUpdateRequested();
+ expect(vm.checkStatus).toHaveBeenCalled();
+
+ listenersWithServiceRequest.FetchActionsContent();
+ expect(vm.fetchActionsContent).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleMounted', () => {
+ it('should call required methods to do the initial kick-off', () => {
+ spyOn(vm, 'initDeploymentsPolling');
+ spyOn(vm, 'setFavicon');
+
+ vm.handleMounted();
+
+ expect(vm.setFavicon).toHaveBeenCalled();
+ expect(vm.initDeploymentsPolling).toHaveBeenCalled();
+ });
+ });
+
+ describe('setFavicon', () => {
+ it('should call setFavicon method', () => {
+ spyOn(gl.utils, 'setFavicon');
+ vm.setFavicon();
+
+ expect(gl.utils.setFavicon).toHaveBeenCalledWith(vm.mr.ciStatusFaviconPath);
+ });
+
+ it('should not call setFavicon when there is no ciStatusFaviconPath', () => {
+ spyOn(gl.utils, 'setFavicon');
+ vm.mr.ciStatusFaviconPath = null;
+ vm.setFavicon();
+
+ expect(gl.utils.setFavicon).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('handleNotification', () => {
+ const data = {
+ ci_status: 'running',
+ title: 'title',
+ pipeline: { details: { status: { label: 'running-label' } } },
+ };
+
+ beforeEach(() => {
+ spyOn(notify, 'notifyMe');
+
+ vm.mr.ciStatus = 'failed';
+ vm.mr.gitlabLogo = 'logo.png';
+ });
+
+ it('should call notifyMe', () => {
+ vm.handleNotification(data);
+
+ expect(notify.notifyMe).toHaveBeenCalledWith(
+ 'Pipeline running-label',
+ 'Pipeline running-label for "title"',
+ 'logo.png',
+ );
+ });
+
+ it('should not call notifyMe if the status has not changed', () => {
+ vm.mr.ciStatus = data.ci_status;
+
+ vm.handleNotification(data);
+
+ expect(notify.notifyMe).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('resumePolling', () => {
+ it('should call stopTimer on pollingInterval', () => {
+ spyOn(vm.pollingInterval, 'resume');
+
+ vm.resumePolling();
+ expect(vm.pollingInterval.resume).toHaveBeenCalled();
+ });
+ });
+
+ describe('stopPolling', () => {
+ it('should call stopTimer on pollingInterval', () => {
+ spyOn(vm.pollingInterval, 'stopTimer');
+
+ vm.stopPolling();
+ expect(vm.pollingInterval.stopTimer).toHaveBeenCalled();
+ });
+ });
+
+ describe('createService', () => {
+ it('should instantiate a Service', () => {
+ const endpoints = {
+ mergePath: '/nice/path',
+ mergeCheckPath: '/nice/path',
+ cancelAutoMergePath: '/nice/path',
+ removeWIPPath: '/nice/path',
+ sourceBranchPath: '/nice/path',
+ ciEnvironmentsStatusPath: '/nice/path',
+ statusPath: '/nice/path',
+ mergeActionsContentPath: '/nice/path',
+ };
+
+ const serviceInstance = vm.createService(endpoints);
+ const isInstanceOfMRService = serviceInstance instanceof MRWidgetService;
+ expect(isInstanceOfMRService).toBe(true);
+ Object.keys(serviceInstance).forEach((key) => {
+ expect(serviceInstance[key]).toBeDefined();
+ });
+ });
+ });
+ });
+
+ describe('components', () => {
+ it('should register all components', () => {
+ const comps = mrWidgetOptions.components;
+ expect(comps['mr-widget-header']).toBeDefined();
+ expect(comps['mr-widget-merge-help']).toBeDefined();
+ expect(comps['mr-widget-pipeline']).toBeDefined();
+ expect(comps['mr-widget-deployment']).toBeDefined();
+ expect(comps['mr-widget-related-links']).toBeDefined();
+ expect(comps['mr-widget-merged']).toBeDefined();
+ expect(comps['mr-widget-closed']).toBeDefined();
+ expect(comps['mr-widget-merging']).toBeDefined();
+ expect(comps['mr-widget-failed-to-merge']).toBeDefined();
+ expect(comps['mr-widget-wip']).toBeDefined();
+ expect(comps['mr-widget-archived']).toBeDefined();
+ expect(comps['mr-widget-conflicts']).toBeDefined();
+ expect(comps['mr-widget-nothing-to-merge']).toBeDefined();
+ expect(comps['mr-widget-not-allowed']).toBeDefined();
+ expect(comps['mr-widget-missing-branch']).toBeDefined();
+ expect(comps['mr-widget-ready-to-merge']).toBeDefined();
+ expect(comps['mr-widget-checking']).toBeDefined();
+ expect(comps['mr-widget-unresolved-discussions']).toBeDefined();
+ expect(comps['mr-widget-pipeline-blocked']).toBeDefined();
+ expect(comps['mr-widget-pipeline-failed']).toBeDefined();
+ expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js
new file mode 100644
index 00000000000..b63633c03b8
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js
@@ -0,0 +1,46 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+
+Vue.use(VueResource);
+
+describe('MRWidgetService', () => {
+ const mr = {
+ mergePath: './',
+ mergeCheckPath: './',
+ cancelAutoMergePath: './',
+ removeWIPPath: './',
+ sourceBranchPath: './',
+ ciEnvironmentsStatusPath: './',
+ statusPath: './',
+ mergeActionsContentPath: './',
+ isServiceStore: true,
+ };
+
+ it('should have store and resources created in constructor', () => {
+ const service = new MRWidgetService(mr);
+
+ expect(service.mergeResource).toBeDefined();
+ expect(service.mergeCheckResource).toBeDefined();
+ expect(service.cancelAutoMergeResource).toBeDefined();
+ expect(service.removeWIPResource).toBeDefined();
+ expect(service.removeSourceBranchResource).toBeDefined();
+ expect(service.deploymentsResource).toBeDefined();
+ expect(service.pollResource).toBeDefined();
+ expect(service.mergeActionsContentResource).toBeDefined();
+ });
+
+ it('should have methods defined', () => {
+ const service = new MRWidgetService(mr);
+
+ expect(service.merge()).toBeDefined();
+ expect(service.cancelAutomaticMerge()).toBeDefined();
+ expect(service.removeWIP()).toBeDefined();
+ expect(service.removeSourceBranch()).toBeDefined();
+ expect(service.fetchDeployments()).toBeDefined();
+ expect(service.poll()).toBeDefined();
+ expect(service.checkStatus()).toBeDefined();
+ expect(service.fetchMergeActionsContent()).toBeDefined();
+ expect(MRWidgetService.stopEnvironment()).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js
new file mode 100644
index 00000000000..179e42a7cc4
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js
@@ -0,0 +1,65 @@
+import getStateKey from '~/vue_merge_request_widget/stores/get_state_key';
+
+describe('getStateKey', () => {
+ it('should return proper state name', () => {
+ const context = {
+ mergeStatus: 'checked',
+ mergeWhenPipelineSucceeds: false,
+ canMerge: true,
+ onlyAllowMergeIfPipelineSucceeds: false,
+ isPipelineFailed: false,
+ hasMergeableDiscussionsState: false,
+ isPipelineBlocked: false,
+ canBeMerged: false,
+ };
+ const data = {
+ project_archived: false,
+ branch_missing: false,
+ commits_count: 2,
+ has_conflicts: false,
+ work_in_progress: false,
+ };
+ const bound = getStateKey.bind(context, data);
+ expect(bound()).toEqual(null);
+
+ context.canBeMerged = true;
+ expect(bound()).toEqual('readyToMerge');
+
+ context.canMerge = false;
+ expect(bound()).toEqual('notAllowedToMerge');
+
+ context.mergeWhenPipelineSucceeds = true;
+ expect(bound()).toEqual('mergeWhenPipelineSucceeds');
+
+ context.hasSHAChanged = true;
+ expect(bound()).toEqual('shaMismatch');
+
+ context.isPipelineBlocked = true;
+ expect(bound()).toEqual('pipelineBlocked');
+
+ context.hasMergeableDiscussionsState = true;
+ expect(bound()).toEqual('unresolvedDiscussions');
+
+ context.onlyAllowMergeIfPipelineSucceeds = true;
+ context.isPipelineFailed = true;
+ expect(bound()).toEqual('pipelineFailed');
+
+ data.work_in_progress = true;
+ expect(bound()).toEqual('workInProgress');
+
+ data.has_conflicts = true;
+ expect(bound()).toEqual('conflicts');
+
+ context.mergeStatus = 'unchecked';
+ expect(bound()).toEqual('checking');
+
+ data.commits_count = 0;
+ expect(bound()).toEqual('nothingToMerge');
+
+ data.branch_missing = true;
+ expect(bound()).toEqual('missingBranch');
+
+ data.project_archived = true;
+ expect(bound()).toEqual('archived');
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
new file mode 100644
index 00000000000..56dd0198ae2
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -0,0 +1,22 @@
+import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
+import mockData from '../mock_data';
+
+describe('MergeRequestStore', () => {
+ describe('setData', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new MergeRequestStore(mockData);
+ });
+
+ it('should set hasSHAChanged when the diff SHA changes', () => {
+ store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
+ expect(store.hasSHAChanged).toBe(true);
+ });
+
+ it('should not set hasSHAChanged when other data changes', () => {
+ store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress });
+ expect(store.hasSHAChanged).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js
new file mode 100644
index 00000000000..3d53a5ab24d
--- /dev/null
+++ b/spec/javascripts/vue_shared/ci_action_icons_spec.js
@@ -0,0 +1,27 @@
+import getActionIcon from '~/vue_shared/ci_action_icons';
+import cancelSVG from 'icons/_icon_action_cancel.svg';
+import retrySVG from 'icons/_icon_action_retry.svg';
+import playSVG from 'icons/_icon_action_play.svg';
+import stopSVG from 'icons/_icon_action_stop.svg';
+
+describe('getActionIcon', () => {
+ it('should return an empty string', () => {
+ expect(getActionIcon()).toEqual('');
+ });
+
+ it('should return cancel svg', () => {
+ expect(getActionIcon('icon_action_cancel')).toEqual(cancelSVG);
+ });
+
+ it('should return retry svg', () => {
+ expect(getActionIcon('icon_action_retry')).toEqual(retrySVG);
+ });
+
+ it('should return play svg', () => {
+ expect(getActionIcon('icon_action_play')).toEqual(playSVG);
+ });
+
+ it('should render stop svg', () => {
+ expect(getActionIcon('icon_action_stop')).toEqual(stopSVG);
+ });
+});
diff --git a/spec/javascripts/vue_shared/ci_status_icon_spec.js b/spec/javascripts/vue_shared/ci_status_icon_spec.js
new file mode 100644
index 00000000000..b6621d6054d
--- /dev/null
+++ b/spec/javascripts/vue_shared/ci_status_icon_spec.js
@@ -0,0 +1,27 @@
+import { borderlessStatusIconEntityMap, statusIconEntityMap } from '~/vue_shared/ci_status_icons';
+
+describe('CI status icons', () => {
+ const statuses = [
+ 'icon_status_canceled',
+ 'icon_status_created',
+ 'icon_status_failed',
+ 'icon_status_manual',
+ 'icon_status_pending',
+ 'icon_status_running',
+ 'icon_status_skipped',
+ 'icon_status_success',
+ 'icon_status_warning',
+ ];
+
+ it('should have a dictionary for borderless icons', () => {
+ statuses.forEach((status) => {
+ expect(borderlessStatusIconEntityMap[status]).toBeDefined();
+ });
+ });
+
+ it('should have a dictionary for icons', () => {
+ statuses.forEach((status) => {
+ expect(statusIconEntityMap[status]).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
new file mode 100644
index 00000000000..daed4da3e15
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
@@ -0,0 +1,89 @@
+import Vue from 'vue';
+import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
+
+describe('CI Badge Link Component', () => {
+ let CIBadge;
+
+ const statuses = {
+ canceled: {
+ text: 'canceled',
+ label: 'canceled',
+ group: 'canceled',
+ icon: 'icon_status_canceled',
+ details_path: 'status/canceled',
+ },
+ created: {
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ icon: 'icon_status_created',
+ details_path: 'status/created',
+ },
+ failed: {
+ text: 'failed',
+ label: 'failed',
+ group: 'failed',
+ icon: 'icon_status_failed',
+ details_path: 'status/failed',
+ },
+ manual: {
+ text: 'manual',
+ label: 'manual action',
+ group: 'manual',
+ icon: 'icon_status_manual',
+ details_path: 'status/manual',
+ },
+ pending: {
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ icon: 'icon_status_pending',
+ details_path: 'status/pending',
+ },
+ running: {
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ icon: 'icon_status_running',
+ details_path: 'status/running',
+ },
+ skipped: {
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ icon: 'icon_status_skipped',
+ details_path: 'status/skipped',
+ },
+ success_warining: {
+ text: 'passed',
+ label: 'passed',
+ group: 'success_with_warnings',
+ icon: 'icon_status_warning',
+ details_path: 'status/warning',
+ },
+ success: {
+ text: 'passed',
+ label: 'passed',
+ group: 'passed',
+ icon: 'icon_status_success',
+ details_path: 'status/passed',
+ },
+ };
+
+ it('should render each status badge', () => {
+ CIBadge = Vue.extend(ciBadge);
+ Object.keys(statuses).map((status) => {
+ const vm = new CIBadge({
+ propsData: {
+ status: statuses[status],
+ },
+ }).$mount();
+
+ expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path);
+ expect(vm.$el.textContent.trim()).toEqual(statuses[status].text);
+ expect(vm.$el.getAttribute('class')).toEqual(`ci-status ci-${statuses[status].group}`);
+ expect(vm.$el.querySelector('svg')).toBeDefined();
+ return vm;
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/ci_icon_spec.js b/spec/javascripts/vue_shared/components/ci_icon_spec.js
new file mode 100644
index 00000000000..d8664408595
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/ci_icon_spec.js
@@ -0,0 +1,139 @@
+import Vue from 'vue';
+import ciIcon from '~/vue_shared/components/ci_icon.vue';
+
+describe('CI Icon component', () => {
+ let CiIcon;
+ beforeEach(() => {
+ CiIcon = Vue.extend(ciIcon);
+ });
+
+ it('should render a span element with an svg', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_success',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.tagName).toEqual('SPAN');
+ expect(component.$el.querySelector('span > svg')).toBeDefined();
+ });
+
+ it('should render a success status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_success',
+ group: 'success',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-success')).toEqual(true);
+ });
+
+ it('should render a failed status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_failed',
+ group: 'failed',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-failed')).toEqual(true);
+ });
+
+ it('should render success with warnings status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_warning',
+ group: 'warning',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-warning')).toEqual(true);
+ });
+
+ it('should render pending status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_pending',
+ group: 'pending',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-pending')).toEqual(true);
+ });
+
+ it('should render running status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_running',
+ group: 'running',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-running')).toEqual(true);
+ });
+
+ it('should render created status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_created',
+ group: 'created',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-created')).toEqual(true);
+ });
+
+ it('should render skipped status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_skipped',
+ group: 'skipped',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-skipped')).toEqual(true);
+ });
+
+ it('should render canceled status', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_canceled',
+ group: 'canceled',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-canceled')).toEqual(true);
+ });
+
+ it('should render status for manual action', () => {
+ const component = new CiIcon({
+ propsData: {
+ status: {
+ icon: 'icon_status_manual',
+ group: 'manual',
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.classList.contains('ci-status-icon-manual')).toEqual(true);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js
index df547299d75..d5754aaa9e7 100644
--- a/spec/javascripts/vue_shared/components/commit_spec.js
+++ b/spec/javascripts/vue_shared/components/commit_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import commitComp from '~/vue_shared/components/commit';
+import commitComp from '~/vue_shared/components/commit.vue';
describe('Commit component', () => {
let props;
@@ -22,8 +22,9 @@ describe('Commit component', () => {
shortSha: 'b7836edd',
title: 'Commit message',
author: {
- avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
+ path: '/jschatz1',
username: 'jschatz1',
},
},
@@ -44,8 +45,9 @@ describe('Commit component', () => {
shortSha: 'b7836edd',
title: 'Commit message',
author: {
- avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png',
+ avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png',
web_url: 'https://gitlab.com/jschatz1',
+ path: '/jschatz1',
username: 'jschatz1',
},
commitIconSvg: '<svg></svg>',
@@ -61,16 +63,16 @@ describe('Commit component', () => {
});
it('should render a link to the ref url', () => {
- expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.commitRef.ref_url);
+ expect(component.$el.querySelector('.ref-name').getAttribute('href')).toEqual(props.commitRef.ref_url);
});
it('should render the ref name', () => {
- expect(component.$el.querySelector('.branch-name').textContent).toContain(props.commitRef.name);
+ expect(component.$el.querySelector('.ref-name').textContent).toContain(props.commitRef.name);
});
it('should render the commit short sha with a link to the commit url', () => {
- expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commitUrl);
- expect(component.$el.querySelector('.commit-id').textContent).toContain(props.shortSha);
+ expect(component.$el.querySelector('.commit-sha').getAttribute('href')).toEqual(props.commitUrl);
+ expect(component.$el.querySelector('.commit-sha').textContent).toContain(props.shortSha);
});
it('should render the given commitIconSvg', () => {
@@ -81,12 +83,12 @@ describe('Commit component', () => {
it('should render a link to the author profile', () => {
expect(
component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href'),
- ).toEqual(props.author.web_url);
+ ).toEqual(props.author.path);
});
it('Should render the author avatar with title and alt attributes', () => {
expect(
- component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title'),
+ component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('data-original-title'),
).toContain(props.author.username);
expect(
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt'),
diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
new file mode 100644
index 00000000000..b4553acb341
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
@@ -0,0 +1,98 @@
+import Vue from 'vue';
+import headerCi from '~/vue_shared/components/header_ci_component.vue';
+
+describe('Header CI Component', () => {
+ let HeaderCi;
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ HeaderCi = Vue.extend(headerCi);
+
+ props = {
+ status: {
+ group: 'failed',
+ icon: 'ci-status-failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ itemName: 'job',
+ itemId: 123,
+ time: '2017-05-08T14:57:39.781Z',
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ actions: [
+ {
+ label: 'Retry',
+ path: 'path',
+ type: 'button',
+ cssClass: 'btn',
+ isLoading: false,
+ },
+ {
+ label: 'Go',
+ path: 'path',
+ type: 'link',
+ cssClass: 'link',
+ isLoading: false,
+ },
+ ],
+ hasSidebarButton: true,
+ };
+
+ vm = new HeaderCi({
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render status badge', () => {
+ expect(vm.$el.querySelector('.ci-failed')).toBeDefined();
+ expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined();
+ expect(
+ vm.$el.querySelector('.ci-failed').getAttribute('href'),
+ ).toEqual(props.status.details_path);
+ });
+
+ it('should render item name and id', () => {
+ expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
+ });
+
+ it('should render timeago date', () => {
+ expect(vm.$el.querySelector('time')).toBeDefined();
+ });
+
+ it('should render user icon and name', () => {
+ expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name);
+ });
+
+ it('should render provided actions', () => {
+ expect(vm.$el.querySelector('.btn').tagName).toEqual('BUTTON');
+ expect(vm.$el.querySelector('.btn').textContent.trim()).toEqual(props.actions[0].label);
+ expect(vm.$el.querySelector('.link').tagName).toEqual('A');
+ expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label);
+ expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path);
+ });
+
+ it('should show loading icon', (done) => {
+ vm.actions[0].isLoading = true;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.btn .fa-spinner').getAttribute('style')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should render sidebar toggle button', () => {
+ expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/loading_icon_spec.js b/spec/javascripts/vue_shared/components/loading_icon_spec.js
new file mode 100644
index 00000000000..1baf3537741
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/loading_icon_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+
+describe('Loading Icon Component', () => {
+ let LoadingIconComponent;
+
+ beforeEach(() => {
+ LoadingIconComponent = Vue.extend(loadingIcon);
+ });
+
+ it('should render a spinner font awesome icon', () => {
+ const component = new LoadingIconComponent().$mount();
+
+ expect(
+ component.$el.querySelector('i').getAttribute('class'),
+ ).toEqual('fa fa-spin fa-spinner fa-1x');
+
+ expect(component.$el.tagName).toEqual('DIV');
+ expect(component.$el.classList.contains('text-center')).toEqual(true);
+ });
+
+ it('should render accessibility attributes', () => {
+ const component = new LoadingIconComponent().$mount();
+
+ const icon = component.$el.querySelector('i');
+ expect(icon.getAttribute('aria-hidden')).toEqual('true');
+ expect(icon.getAttribute('aria-label')).toEqual('Loading');
+ });
+
+ it('should render the provided label', () => {
+ const component = new LoadingIconComponent({
+ propsData: {
+ label: 'This is a loading icon',
+ },
+ }).$mount();
+
+ expect(
+ component.$el.querySelector('i').getAttribute('aria-label'),
+ ).toEqual('This is a loading icon');
+ });
+
+ it('should render the provided size', () => {
+ const component = new LoadingIconComponent({
+ propsData: {
+ size: '2',
+ },
+ }).$mount();
+
+ expect(
+ component.$el.querySelector('i').classList.contains('fa-2x'),
+ ).toEqual(true);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js
new file mode 100644
index 00000000000..291e19c9f3c
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js
@@ -0,0 +1,154 @@
+import Vue from 'vue';
+import fieldComponent from '~/vue_shared/components/markdown/field.vue';
+
+describe('Markdown field component', () => {
+ let vm;
+
+ beforeEach((done) => {
+ vm = new Vue({
+ data() {
+ return {
+ text: 'testing\n123',
+ };
+ },
+ components: {
+ fieldComponent,
+ },
+ template: `
+ <field-component
+ marodown-preview-url="/preview"
+ markdown-docs="/docs"
+ >
+ <textarea
+ slot="textarea"
+ v-model="text">
+ </textarea>
+ </field-component>
+ `,
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ describe('mounted', () => {
+ it('renders textarea inside backdrop', () => {
+ expect(
+ vm.$el.querySelector('.zen-backdrop textarea'),
+ ).not.toBeNull();
+ });
+
+ describe('markdown preview', () => {
+ let previewLink;
+
+ beforeEach(() => {
+ spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => {
+ resolve({
+ json() {
+ return {
+ body: '<p>markdown preview</p>',
+ };
+ },
+ });
+ }));
+
+ previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a');
+ });
+
+ it('sets preview link as active', (done) => {
+ previewLink.click();
+
+ Vue.nextTick(() => {
+ expect(
+ previewLink.parentNode.classList.contains('active'),
+ ).toBeTruthy();
+
+ done();
+ });
+ });
+
+ it('shows preview loading text', (done) => {
+ previewLink.click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.md-preview').textContent.trim(),
+ ).toContain('Loading...');
+
+ done();
+ });
+ });
+
+ it('renders markdown preview', (done) => {
+ previewLink.click();
+
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('.md-preview').innerHTML,
+ ).toContain('<p>markdown preview</p>');
+
+ done();
+ });
+ });
+
+ it('renders GFM with jQuery', (done) => {
+ spyOn($.fn, 'renderGFM');
+ previewLink.click();
+
+ setTimeout(() => {
+ expect(
+ $.fn.renderGFM,
+ ).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+
+ describe('markdown buttons', () => {
+ it('converts single words', (done) => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.setSelectionRange(0, 7);
+ vm.$el.querySelector('.js-md').click();
+
+ Vue.nextTick(() => {
+ expect(
+ textarea.value,
+ ).toContain('**testing**');
+
+ done();
+ });
+ });
+
+ it('converts a line', (done) => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.setSelectionRange(0, 0);
+ vm.$el.querySelectorAll('.js-md')[4].click();
+
+ Vue.nextTick(() => {
+ expect(
+ textarea.value,
+ ).toContain('* testing');
+
+ done();
+ });
+ });
+
+ it('converts multiple lines', (done) => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.setSelectionRange(0, 50);
+ vm.$el.querySelectorAll('.js-md')[4].click();
+
+ Vue.nextTick(() => {
+ expect(
+ textarea.value,
+ ).toContain('* testing\n* 123');
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js
new file mode 100644
index 00000000000..7110ff36937
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import headerComponent from '~/vue_shared/components/markdown/header.vue';
+
+describe('Markdown field header component', () => {
+ let vm;
+
+ beforeEach((done) => {
+ const Component = Vue.extend(headerComponent);
+
+ vm = new Component({
+ propsData: {
+ previewMarkdown: false,
+ },
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders markdown buttons', () => {
+ expect(
+ vm.$el.querySelectorAll('.js-md').length,
+ ).toBe(7);
+ });
+
+ it('renders `write` link as active when previewMarkdown is false', () => {
+ expect(
+ vm.$el.querySelector('li:nth-child(1)').classList.contains('active'),
+ ).toBeTruthy();
+ });
+
+ it('renders `preview` link as active when previewMarkdown is true', (done) => {
+ vm.previewMarkdown = true;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('li:nth-child(2)').classList.contains('active'),
+ ).toBeTruthy();
+
+ done();
+ });
+ });
+
+ it('emits toggle markdown event when clicking preview', () => {
+ spyOn(vm, '$emit');
+
+ vm.$el.querySelector('li:nth-child(2) a').click();
+
+ expect(
+ vm.$emit,
+ ).toHaveBeenCalledWith('toggle-markdown');
+ });
+
+ it('blurs preview link after click', (done) => {
+ const link = vm.$el.querySelector('li:nth-child(2) a');
+ spyOn(HTMLElement.prototype, 'blur');
+
+ link.click();
+
+ setTimeout(() => {
+ expect(
+ link.blur,
+ ).toHaveBeenCalled();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/memory_graph_spec.js b/spec/javascripts/vue_shared/components/memory_graph_spec.js
new file mode 100644
index 00000000000..d46a3f2328e
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/memory_graph_spec.js
@@ -0,0 +1,143 @@
+import Vue from 'vue';
+import memoryGraphComponent from '~/vue_shared/components/memory_graph';
+import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data';
+
+const defaultHeight = '25';
+const defaultWidth = '100';
+
+const createComponent = () => {
+ const Component = Vue.extend(memoryGraphComponent);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: {
+ metrics: [],
+ deploymentTime: 0,
+ width: '',
+ height: '',
+ pathD: '',
+ pathViewBox: '',
+ dotX: '',
+ dotY: '',
+ },
+ });
+};
+
+describe('MemoryGraph', () => {
+ let vm;
+ let el;
+
+ beforeEach(() => {
+ vm = createComponent();
+ el = vm.$el;
+ });
+
+ describe('props', () => {
+ it('should have props with defaults', (done) => {
+ const { metrics, deploymentTime, width, height } = memoryGraphComponent.props;
+
+ Vue.nextTick(() => {
+ const typeClassMatcher = (propItem, expectedType) => {
+ const PropItemTypeClass = propItem.type;
+ expect(new PropItemTypeClass() instanceof expectedType).toBeTruthy();
+ expect(propItem.required).toBeTruthy();
+ };
+
+ typeClassMatcher(metrics, Array);
+ typeClassMatcher(deploymentTime, Number);
+ typeClassMatcher(width, String);
+ typeClassMatcher(height, String);
+ done();
+ });
+ });
+ });
+
+ describe('data', () => {
+ it('should have default data', () => {
+ const data = memoryGraphComponent.data();
+ const dataValidator = (dataItem, expectedType, defaultVal) => {
+ expect(typeof dataItem).toBe(expectedType);
+ expect(dataItem).toBe(defaultVal);
+ };
+
+ dataValidator(data.pathD, 'string', '');
+ dataValidator(data.pathViewBox, 'string', '');
+ dataValidator(data.dotX, 'string', '');
+ dataValidator(data.dotY, 'string', '');
+ });
+ });
+
+ describe('computed', () => {
+ describe('getFormattedMedian', () => {
+ it('should show human readable median value based on provided median timestamp', () => {
+ vm.deploymentTime = mockMedian;
+ const formattedMedian = vm.getFormattedMedian;
+ expect(formattedMedian.indexOf('Deployed') > -1).toBeTruthy();
+ expect(formattedMedian.indexOf('ago') > -1).toBeTruthy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('getMedianMetricIndex', () => {
+ it('should return index of closest metric timestamp to that of median', () => {
+ const matchingIndex = vm.getMedianMetricIndex(mockMedian, mockMetrics);
+ expect(matchingIndex).toBe(mockMedianIndex);
+ });
+ });
+
+ describe('getGraphPlotValues', () => {
+ it('should return Object containing values to plot graph', () => {
+ const plotValues = vm.getGraphPlotValues(mockMedian, mockMetrics);
+ expect(plotValues.pathD).toBeDefined();
+ expect(Array.isArray(plotValues.pathD)).toBeTruthy();
+
+ expect(plotValues.pathViewBox).toBeDefined();
+ expect(typeof plotValues.pathViewBox).toBe('object');
+
+ expect(plotValues.dotX).toBeDefined();
+ expect(typeof plotValues.dotX).toBe('number');
+
+ expect(plotValues.dotY).toBeDefined();
+ expect(typeof plotValues.dotY).toBe('number');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render template elements correctly', () => {
+ expect(el.classList.contains('memory-graph-container')).toBeTruthy();
+ expect(el.querySelector('svg')).toBeDefined();
+ });
+
+ it('should render graph when renderGraph is called internally', (done) => {
+ const { pathD, pathViewBox, dotX, dotY } = vm.getGraphPlotValues(mockMedian, mockMetrics);
+ vm.height = defaultHeight;
+ vm.width = defaultWidth;
+ vm.pathD = `M ${pathD}`;
+ vm.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
+ vm.dotX = dotX;
+ vm.dotY = dotY;
+
+ Vue.nextTick(() => {
+ const svgEl = el.querySelector('svg');
+ expect(svgEl).toBeDefined();
+ expect(svgEl.getAttribute('height')).toBe(defaultHeight);
+ expect(svgEl.getAttribute('width')).toBe(defaultWidth);
+
+ const pathEl = el.querySelector('path');
+ expect(pathEl).toBeDefined();
+ expect(pathEl.getAttribute('d')).toBe(`M ${pathD}`);
+ expect(pathEl.getAttribute('viewBox')).toBe(`0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`);
+
+ const circleEl = el.querySelector('circle');
+ expect(circleEl).toBeDefined();
+ expect(circleEl.getAttribute('r')).toBe('1.5');
+ expect(circleEl.getAttribute('tranform')).toBe('translate(0 -1)');
+ expect(circleEl.getAttribute('cx')).toBe(`${dotX}`);
+ expect(circleEl.getAttribute('cy')).toBe(`${dotY}`);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/mock_data.js b/spec/javascripts/vue_shared/components/mock_data.js
new file mode 100644
index 00000000000..0d781bdca74
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/mock_data.js
@@ -0,0 +1,69 @@
+/* eslint-disable */
+
+export const mockMetrics = [
+ [1493716685, '4.30859375'],
+ [1493716745, '4.30859375'],
+ [1493716805, '4.30859375'],
+ [1493716865, '4.30859375'],
+ [1493716925, '4.30859375'],
+ [1493716985, '4.30859375'],
+ [1493717045, '4.30859375'],
+ [1493717105, '4.30859375'],
+ [1493717165, '4.30859375'],
+ [1493717225, '4.30859375'],
+ [1493717285, '4.30859375'],
+ [1493717345, '4.30859375'],
+ [1493717405, '4.30859375'],
+ [1493717465, '4.30859375'],
+ [1493717525, '4.30859375'],
+ [1493717585, '4.30859375'],
+ [1493717645, '4.30859375'],
+ [1493717705, '4.30859375'],
+ [1493717765, '4.30859375'],
+ [1493717825, '4.30859375'],
+ [1493717885, '4.30859375'],
+ [1493717945, '4.30859375'],
+ [1493718005, '4.30859375'],
+ [1493718065, '4.30859375'],
+ [1493718125, '4.30859375'],
+ [1493718185, '4.30859375'],
+ [1493718245, '4.30859375'],
+ [1493718305, '4.234375'],
+ [1493718365, '4.234375'],
+ [1493718425, '4.234375'],
+ [1493718485, '4.234375'],
+ [1493718545, '4.243489583333333'],
+ [1493718605, '4.2109375'],
+ [1493718665, '4.2109375'],
+ [1493718725, '4.2109375'],
+ [1493718785, '4.26171875'],
+ [1493718845, '4.26171875'],
+ [1493718905, '4.26171875'],
+ [1493718965, '4.26171875'],
+ [1493719025, '4.26171875'],
+ [1493719085, '4.26171875'],
+ [1493719145, '4.26171875'],
+ [1493719205, '4.26171875'],
+ [1493719265, '4.26171875'],
+ [1493719325, '4.26171875'],
+ [1493719385, '4.26171875'],
+ [1493719445, '4.26171875'],
+ [1493719505, '4.26171875'],
+ [1493719565, '4.26171875'],
+ [1493719625, '4.26171875'],
+ [1493719685, '4.26171875'],
+ [1493719745, '4.26171875'],
+ [1493719805, '4.26171875'],
+ [1493719865, '4.26171875'],
+ [1493719925, '4.26171875'],
+ [1493719985, '4.26171875'],
+ [1493720045, '4.26171875'],
+ [1493720105, '4.26171875'],
+ [1493720165, '4.26171875'],
+ [1493720225, '4.26171875'],
+ [1493720285, '4.26171875'],
+];
+
+export const mockMedian = 1493718485;
+
+export const mockMedianIndex = 30;
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
deleted file mode 100644
index 699625cdbb7..00000000000
--- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import Vue from 'vue';
-import tableRowComp from '~/vue_shared/components/pipelines_table_row';
-import pipeline from '../../commit/pipelines/mock_data';
-
-describe('Pipelines Table Row', () => {
- let component;
-
- beforeEach(() => {
- const PipelinesTableRowComponent = Vue.extend(tableRowComp);
-
- component = new PipelinesTableRowComponent({
- el: document.querySelector('.test-dom-element'),
- propsData: {
- pipeline,
- service: {},
- },
- }).$mount();
- });
-
- it('should render a table row', () => {
- expect(component.$el).toEqual('TR');
- });
-
- describe('status column', () => {
- it('should render a pipeline link', () => {
- expect(
- component.$el.querySelector('td.commit-link a').getAttribute('href'),
- ).toEqual(pipeline.path);
- });
-
- it('should render status text', () => {
- expect(
- component.$el.querySelector('td.commit-link a').textContent,
- ).toContain(pipeline.details.status.text);
- });
- });
-
- describe('information column', () => {
- it('should render a pipeline link', () => {
- expect(
- component.$el.querySelector('td:nth-child(2) a').getAttribute('href'),
- ).toEqual(pipeline.path);
- });
-
- it('should render pipeline ID', () => {
- expect(
- component.$el.querySelector('td:nth-child(2) a > span').textContent,
- ).toEqual(`#${pipeline.id}`);
- });
-
- describe('when a user is provided', () => {
- it('should render user information', () => {
- expect(
- component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'),
- ).toEqual(pipeline.user.web_url);
-
- expect(
- component.$el.querySelector('td:nth-child(2) img').getAttribute('title'),
- ).toEqual(pipeline.user.name);
- });
- });
- });
-
- describe('commit column', () => {
- it('should render link to commit', () => {
- expect(
- component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'),
- ).toEqual(pipeline.commit.commit_path);
- });
- });
-
- describe('stages column', () => {
- it('should render an icon for each stage', () => {
- expect(
- component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length,
- ).toEqual(pipeline.details.stages.length);
- });
- });
-
- describe('actions column', () => {
- it('should render the provided actions', () => {
- expect(
- component.$el.querySelectorAll('td:nth-child(6) ul li').length,
- ).toEqual(pipeline.details.manual_actions.length);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_spec.js
deleted file mode 100644
index 4d3ced944d7..00000000000
--- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Vue from 'vue';
-import pipelinesTableComp from '~/vue_shared/components/pipelines_table';
-import '~/lib/utils/datetime_utility';
-import pipeline from '../../commit/pipelines/mock_data';
-
-describe('Pipelines Table', () => {
- let PipelinesTableComponent;
-
- beforeEach(() => {
- PipelinesTableComponent = Vue.extend(pipelinesTableComp);
- });
-
- describe('table', () => {
- let component;
- beforeEach(() => {
- component = new PipelinesTableComponent({
- propsData: {
- pipelines: [],
- service: {},
- },
- }).$mount();
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render a table', () => {
- expect(component.$el).toEqual('TABLE');
- });
-
- it('should render table head with correct columns', () => {
- expect(component.$el.querySelector('th.js-pipeline-status').textContent).toEqual('Status');
- expect(component.$el.querySelector('th.js-pipeline-info').textContent).toEqual('Pipeline');
- expect(component.$el.querySelector('th.js-pipeline-commit').textContent).toEqual('Commit');
- expect(component.$el.querySelector('th.js-pipeline-stages').textContent).toEqual('Stages');
- expect(component.$el.querySelector('th.js-pipeline-date').textContent).toEqual('');
- expect(component.$el.querySelector('th.js-pipeline-actions').textContent).toEqual('');
- });
- });
-
- describe('without data', () => {
- it('should render an empty table', () => {
- const component = new PipelinesTableComponent({
- propsData: {
- pipelines: [],
- service: {},
- },
- }).$mount();
- expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0);
- });
- });
-
- describe('with data', () => {
- it('should render rows', () => {
- const component = new PipelinesTableComponent({
- el: document.querySelector('.test-dom-element'),
- propsData: {
- pipelines: [pipeline],
- service: {},
- },
- }).$mount();
-
- expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js
index 96038718191..b0b78e34e0f 100644
--- a/spec/javascripts/vue_shared/components/table_pagination_spec.js
+++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js
@@ -1,150 +1,188 @@
import Vue from 'vue';
-import paginationComp from '~/vue_shared/components/table_pagination';
-import '~/lib/utils/common_utils';
+import paginationComp from '~/vue_shared/components/table_pagination.vue';
describe('Pagination component', () => {
let component;
let PaginationComponent;
-
- const changeChanges = {
- one: '',
- };
-
- const change = (one) => {
- changeChanges.one = one;
- };
+ let spy;
+ let mountComponet;
beforeEach(() => {
+ spy = jasmine.createSpy('spy');
PaginationComponent = Vue.extend(paginationComp);
- });
-
- it('should render and start at page 1', () => {
- component = new PaginationComponent({
- propsData: {
- pageInfo: {
- totalPages: 10,
- nextPage: 2,
- previousPage: '',
- },
- change,
- },
- }).$mount();
- expect(component.$el.classList).toContain('gl-pagination');
-
- component.changePage({ target: { innerText: '1' } });
-
- expect(changeChanges.one).toEqual(1);
+ mountComponet = function (props) {
+ return new PaginationComponent({
+ propsData: props,
+ }).$mount();
+ };
});
- it('should go to the previous page', () => {
- component = new PaginationComponent({
- propsData: {
+ describe('render', () => {
+ describe('prev button', () => {
+ it('should be disabled and non clickable', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 2,
+ page: 1,
+ perPage: 20,
+ previousPage: NaN,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ expect(
+ component.$el.querySelector('.js-previous-button').classList.contains('disabled'),
+ ).toEqual(true);
+
+ component.$el.querySelector('.js-previous-button a').click();
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be enabled and clickable', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ component.$el.querySelector('.js-previous-button a').click();
+
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('first button', () => {
+ it('should call the change callback with the first page', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ const button = component.$el.querySelector('.js-first-button a');
+
+ expect(button.textContent.trim()).toEqual('« First');
+
+ button.click();
+
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('last button', () => {
+ it('should call the change callback with the last page', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ const button = component.$el.querySelector('.js-last-button a');
+
+ expect(button.textContent.trim()).toEqual('Last »');
+
+ button.click();
+
+ expect(spy).toHaveBeenCalledWith(5);
+ });
+ });
+
+ describe('next button', () => {
+ it('should be disabled and non clickable', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 5,
+ page: 5,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ expect(
+ component.$el.querySelector('.js-next-button').textContent.trim(),
+ ).toEqual('Next');
+
+ component.$el.querySelector('.js-next-button a').click();
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be enabled and clickable', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ component.$el.querySelector('.js-next-button a').click();
+
+ expect(spy).toHaveBeenCalledWith(4);
+ });
+ });
+
+ describe('numbered buttons', () => {
+ it('should render 5 pages', () => {
+ component = mountComponet({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ expect(component.$el.querySelectorAll('.page').length).toEqual(5);
+ });
+ });
+
+ it('should render the spread operator', () => {
+ component = mountComponet({
pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
totalPages: 10,
- nextPage: 3,
- previousPage: 1,
},
- change,
- },
- }).$mount();
-
- component.changePage({ target: { innerText: 'Prev' } });
-
- expect(changeChanges.one).toEqual(1);
- });
-
- it('should go to the next page', () => {
- component = new PaginationComponent({
- propsData: {
- pageInfo: {
- totalPages: 10,
- nextPage: 5,
- previousPage: 3,
- },
- change,
- },
- }).$mount();
-
- component.changePage({ target: { innerText: 'Next' } });
-
- expect(changeChanges.one).toEqual(5);
- });
-
- it('should go to the last page', () => {
- component = new PaginationComponent({
- propsData: {
- pageInfo: {
- totalPages: 10,
- nextPage: 5,
- previousPage: 3,
- },
- change,
- },
- }).$mount();
-
- component.changePage({ target: { innerText: 'Last »' } });
-
- expect(changeChanges.one).toEqual(10);
- });
-
- it('should go to the first page', () => {
- component = new PaginationComponent({
- propsData: {
- pageInfo: {
- totalPages: 10,
- nextPage: 5,
- previousPage: 3,
- },
- change,
- },
- }).$mount();
-
- component.changePage({ target: { innerText: '« First' } });
-
- expect(changeChanges.one).toEqual(1);
- });
-
- it('should do nothing', () => {
- component = new PaginationComponent({
- propsData: {
- pageInfo: {
- totalPages: 10,
- nextPage: 2,
- previousPage: '',
- },
- change,
- },
- }).$mount();
-
- component.changePage({ target: { innerText: '...' } });
-
- expect(changeChanges.one).toEqual(1);
- });
-});
-
-describe('paramHelper', () => {
- afterEach(() => {
- window.history.pushState({}, null, '');
- });
-
- it('can parse url parameters correctly', () => {
- window.history.pushState({}, null, '?scope=all&p=2');
-
- const scope = gl.utils.getParameterByName('scope');
- const p = gl.utils.getParameterByName('p');
-
- expect(scope).toEqual('all');
- expect(p).toEqual('2');
- });
-
- it('returns null if param not in url', () => {
- window.history.pushState({}, null, '?p=2');
-
- const scope = gl.utils.getParameterByName('scope');
- const p = gl.utils.getParameterByName('p');
+ change: spy,
+ });
- expect(scope).toEqual(null);
- expect(p).toEqual('2');
+ expect(component.$el.querySelector('.separator').textContent.trim()).toEqual('...');
+ });
});
});
diff --git a/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js
new file mode 100644
index 00000000000..b4c1f70ed1e
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import '~/lib/utils/datetime_utility';
+
+describe('Time ago with tooltip component', () => {
+ let TimeagoTooltip;
+ let vm;
+
+ beforeEach(() => {
+ TimeagoTooltip = Vue.extend(timeagoTooltip);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render timeago with a bootstrap tooltip', () => {
+ vm = new TimeagoTooltip({
+ propsData: {
+ time: '2017-05-08T14:57:39.781Z',
+ },
+ }).$mount();
+
+ expect(vm.$el.tagName).toEqual('TIME');
+ expect(
+ vm.$el.getAttribute('data-original-title'),
+ ).toEqual(gl.utils.formatDate('2017-05-08T14:57:39.781Z'));
+ expect(vm.$el.getAttribute('data-placement')).toEqual('top');
+
+ const timeago = gl.utils.getTimeago();
+
+ expect(vm.$el.textContent.trim()).toEqual(timeago.format('2017-05-08T14:57:39.781Z'));
+ });
+
+ it('should render tooltip placed in bottom', () => {
+ vm = new TimeagoTooltip({
+ propsData: {
+ time: '2017-05-08T14:57:39.781Z',
+ tooltipPlacement: 'bottom',
+ },
+ }).$mount();
+
+ expect(vm.$el.getAttribute('data-placement')).toEqual('bottom');
+ });
+
+ it('should render provided html class', () => {
+ vm = new TimeagoTooltip({
+ propsData: {
+ time: '2017-05-08T14:57:39.781Z',
+ cssClass: 'foo',
+ },
+ }).$mount();
+
+ expect(vm.$el.classList.contains('foo')).toEqual(true);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
new file mode 100644
index 00000000000..8daa7610274
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+
+const UserAvatarImageComponent = Vue.extend(UserAvatarImage);
+
+describe('User Avatar Image Component', function () {
+ describe('Initialization', function () {
+ beforeEach(function () {
+ this.propsData = {
+ size: 99,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+ };
+
+ this.userAvatarImage = new UserAvatarImageComponent({
+ propsData: this.propsData,
+ }).$mount();
+ });
+
+ it('should return a defined Vue component', function () {
+ expect(this.userAvatarImage).toBeDefined();
+ });
+
+ it('should have <img> as a child element', function () {
+ expect(this.userAvatarImage.$el.tagName).toBe('IMG');
+ });
+
+ it('should properly compute tooltipContainer', function () {
+ expect(this.userAvatarImage.tooltipContainer).toBe('body');
+ });
+
+ it('should properly render tooltipContainer', function () {
+ expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body');
+ });
+
+ it('should properly compute avatarSizeClass', function () {
+ expect(this.userAvatarImage.avatarSizeClass).toBe('s99');
+ });
+
+ it('should properly render img css', function () {
+ const classList = this.userAvatarImage.$el.classList;
+ const containsAvatar = classList.contains('avatar');
+ const containsSizeClass = classList.contains('s99');
+ const containsCustomClass = classList.contains('myextraavatarclass');
+
+ expect(containsAvatar).toBe(true);
+ expect(containsSizeClass).toBe(true);
+ expect(containsCustomClass).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js
new file mode 100644
index 00000000000..52e450e9ba5
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+describe('User Avatar Link Component', function () {
+ beforeEach(function () {
+ this.propsData = {
+ linkHref: 'myavatarurl.com',
+ imgSize: 99,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ imgCssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+ };
+
+ const UserAvatarLinkComponent = Vue.extend(UserAvatarLink);
+
+ this.userAvatarLink = new UserAvatarLinkComponent({
+ propsData: this.propsData,
+ }).$mount();
+
+ this.userAvatarImage = this.userAvatarLink.$children[0];
+ });
+
+ it('should return a defined Vue component', function () {
+ expect(this.userAvatarLink).toBeDefined();
+ });
+
+ it('should have user-avatar-image registered as child component', function () {
+ expect(this.userAvatarLink.$options.components.userAvatarImage).toBeDefined();
+ });
+
+ it('user-avatar-link should have user-avatar-image as child component', function () {
+ expect(this.userAvatarImage).toBeDefined();
+ });
+
+ it('should render <a> as a child element', function () {
+ expect(this.userAvatarLink.$el.tagName).toBe('A');
+ });
+
+ it('should have <img> as a child element', function () {
+ expect(this.userAvatarLink.$el.querySelector('img')).not.toBeNull();
+ });
+
+ it('should return neccessary props as defined', function () {
+ _.each(this.propsData, (val, key) => {
+ expect(this.userAvatarLink[key]).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
new file mode 100644
index 00000000000..b8d639ffbec
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue';
+import avatarSvg from 'icons/_icon_random.svg';
+
+const UserAvatarSvgComponent = Vue.extend(UserAvatarSvg);
+
+describe('User Avatar Svg Component', function () {
+ describe('Initialization', function () {
+ beforeEach(function () {
+ this.propsData = {
+ size: 99,
+ svg: avatarSvg,
+ };
+
+ this.userAvatarSvg = new UserAvatarSvgComponent({
+ propsData: this.propsData,
+ }).$mount();
+ });
+
+ it('should return a defined Vue component', function () {
+ expect(this.userAvatarSvg).toBeDefined();
+ });
+
+ it('should have <svg> as a child element', function () {
+ expect(this.userAvatarSvg.$el.tagName).toEqual('svg');
+ expect(this.userAvatarSvg.$el.innerHTML).toContain('<path');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/directives/tooltip_spec.js b/spec/javascripts/vue_shared/directives/tooltip_spec.js
new file mode 100644
index 00000000000..b1b3071527b
--- /dev/null
+++ b/spec/javascripts/vue_shared/directives/tooltip_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+describe('Tooltip directive', () => {
+ let vm;
+
+ afterEach(() => {
+ if (vm) {
+ vm.$destroy();
+ }
+ });
+
+ describe('with a single tooltip', () => {
+ beforeEach(() => {
+ const SomeComponent = Vue.extend({
+ directives: {
+ tooltip,
+ },
+ template: `
+ <div
+ v-tooltip
+ title="foo">
+ </div>
+ `,
+ });
+
+ vm = new SomeComponent().$mount();
+ });
+
+ it('should have tooltip plugin applied', () => {
+ expect($(vm.$el).data('bs.tooltip')).toBeDefined();
+ });
+ });
+
+ describe('with multiple tooltips', () => {
+ beforeEach(() => {
+ const SomeComponent = Vue.extend({
+ directives: {
+ tooltip,
+ },
+ template: `
+ <div>
+ <div
+ v-tooltip
+ class="js-look-for-tooltip"
+ title="foo">
+ </div>
+ <div
+ v-tooltip
+ title="bar">
+ </div>
+ </div>
+ `,
+ });
+
+ vm = new SomeComponent().$mount();
+ });
+
+ it('should have tooltip plugin applied to all instances', () => {
+ expect($(vm.$el).find('.js-look-for-tooltip').data('bs.tooltip')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js
new file mode 100644
index 00000000000..cbb3cbdff46
--- /dev/null
+++ b/spec/javascripts/vue_shared/translate_spec.js
@@ -0,0 +1,90 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+describe('Vue translate filter', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+
+ document.body.appendChild(el);
+ });
+
+ it('translate single text', (done) => {
+ const comp = new Vue({
+ el,
+ template: `
+ <span>
+ {{ __('testing') }}
+ </span>
+ `,
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ comp.$el.textContent.trim(),
+ ).toBe('testing');
+
+ done();
+ });
+ });
+
+ it('translate plural text with single count', (done) => {
+ const comp = new Vue({
+ el,
+ template: `
+ <span>
+ {{ n__('%d day', '%d days', 1) }}
+ </span>
+ `,
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ comp.$el.textContent.trim(),
+ ).toBe('1 day');
+
+ done();
+ });
+ });
+
+ it('translate plural text with multiple count', (done) => {
+ const comp = new Vue({
+ el,
+ template: `
+ <span>
+ {{ n__('%d day', '%d days', 2) }}
+ </span>
+ `,
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ comp.$el.textContent.trim(),
+ ).toBe('2 days');
+
+ done();
+ });
+ });
+
+ it('translate plural without replacing any text', (done) => {
+ const comp = new Vue({
+ el,
+ template: `
+ <span>
+ {{ n__('day', 'days', 2) }}
+ </span>
+ `,
+ }).$mount();
+
+ Vue.nextTick(() => {
+ expect(
+ comp.$el.textContent.trim(),
+ ).toBe('days');
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js
index 99515f2e5f2..a225b04c47e 100644
--- a/spec/javascripts/zen_mode_spec.js
+++ b/spec/javascripts/zen_mode_spec.js
@@ -1,9 +1,8 @@
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, object-shorthand, comma-dangle, no-return-assign, new-cap, max-len */
/* global Dropzone */
/* global Mousetrap */
-/* global ZenMode */
-require('~/zen_mode');
+import ZenMode from '~/zen_mode';
(function() {
var enterZen, escapeKeydown, exitZen;
diff --git a/spec/lib/after_commit_queue_spec.rb b/spec/lib/after_commit_queue_spec.rb
new file mode 100644
index 00000000000..6e7c2ec2363
--- /dev/null
+++ b/spec/lib/after_commit_queue_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe AfterCommitQueue do
+ it 'runs after transaction is committed' do
+ called = false
+ test_proc = proc { called = true }
+
+ project = build(:project)
+ project.run_after_commit(&test_proc)
+
+ project.save
+
+ expect(called).to be true
+ end
+end
diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb
index 267318faed4..fb3ef04b860 100644
--- a/spec/lib/api/helpers/pagination_spec.rb
+++ b/spec/lib/api/helpers/pagination_spec.rb
@@ -32,7 +32,7 @@ describe API::Helpers::Pagination do
context 'when resource can be paginated' do
before do
- create_list(:empty_project, 3)
+ create_list(:project, 3)
end
describe 'first page' do
diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb
index deaabceef1c..d70749536b8 100644
--- a/spec/lib/banzai/cross_project_reference_spec.rb
+++ b/spec/lib/banzai/cross_project_reference_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::CrossProjectReference, lib: true do
+describe Banzai::CrossProjectReference do
include described_class
describe '#project_from_ref' do
@@ -24,8 +24,8 @@ describe Banzai::CrossProjectReference, lib: true do
it 'returns the referenced project' do
project2 = double('referenced project')
- expect(Project).to receive(:find_by_full_path).
- with('cross/reference').and_return(project2)
+ expect(Project).to receive(:find_by_full_path)
+ .with('cross/reference').and_return(project2)
expect(project_from_ref('cross/reference')).to eq project2
end
diff --git a/spec/lib/banzai/filter/abstract_reference_filter_spec.rb b/spec/lib/banzai/filter/abstract_reference_filter_spec.rb
index 27684882435..7c0ba9ee67f 100644
--- a/spec/lib/banzai/filter/abstract_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/abstract_reference_filter_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
describe Banzai::Filter::AbstractReferenceFilter do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe '#references_per_project' do
it 'returns a Hash containing references grouped per project paths' do
- doc = Nokogiri::HTML.fragment("#1 #{project.path_with_namespace}#2")
+ doc = Nokogiri::HTML.fragment("#1 #{project.full_path}#2")
filter = described_class.new(doc, project: project)
expect(filter).to receive(:object_class).exactly(4).times.and_return(Issue)
@@ -14,7 +14,7 @@ describe Banzai::Filter::AbstractReferenceFilter do
refs = filter.references_per_project
expect(refs).to be_an_instance_of(Hash)
- expect(refs[project.path_with_namespace]).to eq(Set.new(%w[1 2]))
+ expect(refs[project.full_path]).to eq(Set.new(%w[1 2]))
end
end
@@ -23,11 +23,11 @@ describe Banzai::Filter::AbstractReferenceFilter do
doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project)
- expect(filter).to receive(:references_per_project).
- and_return({ project.path_with_namespace => Set.new(%w[1]) })
+ expect(filter).to receive(:references_per_project)
+ .and_return({ project.full_path => Set.new(%w[1]) })
- expect(filter.projects_per_reference).
- to eq({ project.path_with_namespace => project })
+ expect(filter.projects_per_reference)
+ .to eq({ project.full_path => project })
end
end
@@ -37,35 +37,26 @@ describe Banzai::Filter::AbstractReferenceFilter do
context 'with RequestStore disabled' do
it 'returns a list of Projects for a list of paths' do
- expect(filter.find_projects_for_paths([project.path_with_namespace])).
- to eq([project])
+ expect(filter.find_projects_for_paths([project.full_path]))
+ .to eq([project])
end
it "return an empty array for paths that don't exist" do
- expect(filter.find_projects_for_paths(['nonexistent/project'])).
- to eq([])
+ expect(filter.find_projects_for_paths(['nonexistent/project']))
+ .to eq([])
end
end
- context 'with RequestStore enabled' do
- before do
- RequestStore.begin!
- end
-
- after do
- RequestStore.end!
- RequestStore.clear!
- end
-
+ context 'with RequestStore enabled', :request_store do
it 'returns a list of Projects for a list of paths' do
- expect(filter.find_projects_for_paths([project.path_with_namespace])).
- to eq([project])
+ expect(filter.find_projects_for_paths([project.full_path]))
+ .to eq([project])
end
context "when no project with that path exists" do
it "returns no value" do
- expect(filter.find_projects_for_paths(['nonexistent/project'])).
- to eq([])
+ expect(filter.find_projects_for_paths(['nonexistent/project']))
+ .to eq([])
end
it "adds the ref to the project refs cache" do
@@ -84,8 +75,8 @@ describe Banzai::Filter::AbstractReferenceFilter do
end
it "return an empty array for paths that don't exist" do
- expect(filter.find_projects_for_paths(['nonexistent/project'])).
- to eq([])
+ expect(filter.find_projects_for_paths(['nonexistent/project']))
+ .to eq([])
end
end
end
@@ -97,7 +88,7 @@ describe Banzai::Filter::AbstractReferenceFilter do
doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project)
- expect(filter.current_project_path).to eq(project.path_with_namespace)
+ expect(filter.current_project_path).to eq(project.full_path)
end
end
end
diff --git a/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb b/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb
new file mode 100644
index 00000000000..34f1657b6d3
--- /dev/null
+++ b/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Banzai::Filter::AsciiDocPostProcessingFilter do
+ include FilterSpecHelper
+
+ it "adds class for elements with data-math-style" do
+ result = filter('<pre data-math-style="inline">some code</pre><div data-math>and</div>').to_html
+ expect(result).to eq('<pre data-math-style="inline" class="code math js-render-math">some code</pre><div data-math>and</div>')
+ end
+
+ it "keeps content when no data-math-style found" do
+ result = filter('<pre>some code</pre><div data-math>and</div>').to_html
+ expect(result).to eq('<pre>some code</pre><div data-math>and</div>')
+ end
+end
diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb
index a6d2ea11fcc..b7c2ff03125 100644
--- a/spec/lib/banzai/filter/autolink_filter_spec.rb
+++ b/spec/lib/banzai/filter/autolink_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::AutolinkFilter, lib: true do
+describe Banzai::Filter::AutolinkFilter do
include FilterSpecHelper
let(:link) { 'http://about.gitlab.com/' }
diff --git a/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb b/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
index 2799249ae3e..8224dc5a6b9 100644
--- a/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
+++ b/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe Banzai::Filter::BlockquoteFenceFilter, lib: true do
+describe Banzai::Filter::BlockquoteFenceFilter do
include FilterSpecHelper
it 'converts blockquote fences to blockquote lines' do
diff --git a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb
index deadc36524c..935146c17fc 100644
--- a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
+describe Banzai::Filter::CommitRangeReferenceFilter do
include FilterSpecHelper
let(:project) { create(:project, :public, :repository) }
@@ -28,15 +28,15 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
it 'links to a valid two-dot reference' do
doc = reference_filter("See #{reference2}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_compare_url(project.namespace, project, range2.to_param)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project, range2.to_param)
end
it 'links to a valid three-dot reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_compare_url(project.namespace, project, range.to_param)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project, range.to_param)
end
it 'links to a valid short ID' do
@@ -94,39 +94,39 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
- expect(link).to eq urls.namespace_project_compare_url(project.namespace, project, from: commit1.id, to: commit2.id, only_path: true)
+ expect(link).to eq urls.project_compare_url(project, from: commit1.id, to: commit2.id, only_path: true)
end
end
context 'cross-project / cross-namespace complete reference' do
let(:project2) { create(:project, :public, :repository) }
- let(:reference) { "#{project2.path_with_namespace}@#{commit1.id}...#{commit2.id}" }
+ let(:reference) { "#{project2.full_path}@#{commit1.id}...#{commit2.id}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_compare_url(project2.namespace, project2, range.to_param)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
end
it 'link has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.css('a').first.text).
- to eql("#{project2.path_with_namespace}@#{commit1.short_id}...#{commit2.short_id}")
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.full_path}@#{commit1.short_id}...#{commit2.short_id}")
end
it 'has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.text).to eql("Fixed (#{project2.path_with_namespace}@#{commit1.short_id}...#{commit2.short_id}.)")
+ expect(doc.text).to eql("Fixed (#{project2.full_path}@#{commit1.short_id}...#{commit2.short_id}.)")
end
it 'ignores invalid commit IDs on the referenced project' do
- exp = act = "Fixed #{project2.path_with_namespace}@#{commit1.id.reverse}...#{commit2.id}"
+ exp = act = "Fixed #{project2.full_path}@#{commit1.id.reverse}...#{commit2.id}"
expect(reference_filter(act).to_html).to eq exp
- exp = act = "Fixed #{project2.path_with_namespace}@#{commit1.id}...#{commit2.id.reverse}"
+ exp = act = "Fixed #{project2.full_path}@#{commit1.id}...#{commit2.id.reverse}"
expect(reference_filter(act).to_html).to eq exp
end
end
@@ -140,15 +140,15 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_compare_url(project2.namespace, project2, range.to_param)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
end
it 'link has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.css('a').first.text).
- to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
end
it 'has valid text' do
@@ -175,15 +175,15 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_compare_url(project2.namespace, project2, range.to_param)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
end
it 'link has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.css('a').first.text).
- to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
end
it 'has valid text' do
@@ -205,7 +205,7 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
let(:namespace) { create(:namespace) }
let(:project2) { create(:project, :public, :repository, namespace: namespace) }
let(:range) { CommitRange.new("#{commit1.id}...master", project) }
- let(:reference) { urls.namespace_project_compare_url(project2.namespace, project2, from: commit1.id, to: 'master') }
+ let(:reference) { urls.project_compare_url(project2, from: commit1.id, to: 'master') }
before do
range.project = project2
@@ -214,8 +214,8 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq reference
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
end
it 'links with adjacent text' do
diff --git a/spec/lib/banzai/filter/commit_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_reference_filter_spec.rb
index a19aac61229..702fcac0c6f 100644
--- a/spec/lib/banzai/filter/commit_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_reference_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::CommitReferenceFilter, lib: true do
+describe Banzai::Filter::CommitReferenceFilter do
include FilterSpecHelper
let(:project) { create(:project, :public, :repository) }
@@ -26,8 +26,8 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
doc = reference_filter("See #{reference[0...size]}")
expect(doc.css('a').first.text).to eq commit.short_id
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_commit_url(project.namespace, project, reference)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_commit_url(project, reference)
end
end
@@ -90,7 +90,7 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
- expect(link).to eq urls.namespace_project_commit_url(project.namespace, project, reference, only_path: true)
+ expect(link).to eq urls.project_commit_url(project, reference, only_path: true)
end
end
@@ -98,18 +98,18 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
let(:namespace) { create(:namespace) }
let(:project2) { create(:project, :public, :repository, namespace: namespace) }
let(:commit) { project2.commit }
- let(:reference) { "#{project2.path_with_namespace}@#{commit.short_id}" }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.css('a').first.text).to eql("#{project2.path_with_namespace}@#{commit.short_id}")
+ expect(doc.css('a').first.text).to eql("#{project2.full_path}@#{commit.short_id}")
end
it 'has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.text).to eql("See (#{project2.path_with_namespace}@#{commit.short_id}.)")
+ expect(doc.text).to eql("See (#{project2.full_path}@#{commit.short_id}.)")
end
it 'ignores invalid commit IDs on the referenced project' do
@@ -121,10 +121,10 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
context 'cross-project / same-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
let(:project2) { create(:project, :public, :repository, namespace: namespace) }
let(:commit) { project2.commit }
- let(:reference) { "#{project2.path_with_namespace}@#{commit.short_id}" }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
@@ -147,10 +147,10 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
context 'cross-project shorthand reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
let(:project2) { create(:project, :public, :repository, namespace: namespace) }
let(:commit) { project2.commit }
- let(:reference) { "#{project2.path_with_namespace}@#{commit.short_id}" }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
@@ -175,13 +175,13 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do
let(:namespace) { create(:namespace) }
let(:project2) { create(:project, :public, :repository, namespace: namespace) }
let(:commit) { project2.commit }
- let(:reference) { urls.namespace_project_commit_url(project2.namespace, project2, commit.id) }
+ let(:reference) { urls.project_commit_url(project2, commit.id) }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_commit_url(project2.namespace, project2, commit.id)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_commit_url(project2, commit.id)
end
it 'links with adjacent text' do
diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb
index 086a006c45f..10910f22d4a 100644
--- a/spec/lib/banzai/filter/emoji_filter_spec.rb
+++ b/spec/lib/banzai/filter/emoji_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::EmojiFilter, lib: true do
+describe Banzai::Filter::EmojiFilter do
include FilterSpecHelper
before do
diff --git a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
index fbf7a461fa5..a0d391d981c 100644
--- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
+describe Banzai::Filter::ExternalIssueReferenceFilter do
include FilterSpecHelper
def helper
@@ -58,8 +58,8 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
end
it 'escapes the title attribute' do
- allow(project.external_issue_tracker).to receive(:title).
- and_return(%{"></a>whatever<a title="})
+ allow(project.external_issue_tracker).to receive(:title)
+ .and_return(%{"></a>whatever<a title="})
doc = filter("Issue #{reference}")
expect(doc.text).to eq "Issue #{reference}"
@@ -82,16 +82,18 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
context 'with RequestStore enabled' do
let(:reference_filter) { HTML::Pipeline.new([described_class]) }
- before { allow(RequestStore).to receive(:active?).and_return(true) }
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ end
it 'queries the collection on the first call' do
expect_any_instance_of(Project).to receive(:default_issues_tracker?).once.and_call_original
- expect_any_instance_of(Project).to receive(:issue_reference_pattern).once.and_call_original
+ expect_any_instance_of(Project).to receive(:external_issue_reference_pattern).once.and_call_original
not_cached = reference_filter.call("look for #{reference}", { project: project })
expect_any_instance_of(Project).not_to receive(:default_issues_tracker?)
- expect_any_instance_of(Project).not_to receive(:issue_reference_pattern)
+ expect_any_instance_of(Project).not_to receive(:external_issue_reference_pattern)
cached = reference_filter.call("look for #{reference}", { project: project })
@@ -106,6 +108,11 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
let(:issue) { ExternalIssue.new("#123", project) }
let(:reference) { issue.to_reference }
+ before do
+ project.issues_enabled = false
+ project.save!
+ end
+
it_behaves_like "external issue tracker"
end
diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb
index d9e4525cb28..2a3c0cd78b8 100644
--- a/spec/lib/banzai/filter/external_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_link_filter_spec.rb
@@ -1,6 +1,23 @@
require 'spec_helper'
-describe Banzai::Filter::ExternalLinkFilter, lib: true do
+shared_examples 'an external link with rel attribute' do
+ it 'adds rel="nofollow" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'nofollow'
+ end
+
+ it 'adds rel="noreferrer" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'noreferrer'
+ end
+
+ it 'adds rel="noopener" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'noopener'
+ end
+end
+
+describe Banzai::Filter::ExternalLinkFilter do
include FilterSpecHelper
it 'ignores elements without an href attribute' do
@@ -22,49 +39,58 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do
context 'for root links on document' do
let(:doc) { filter %q(<a href="https://google.com/">Google</a>) }
- it 'adds rel="nofollow" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'nofollow'
+ it_behaves_like 'an external link with rel attribute'
+ end
+
+ context 'for nested links on document' do
+ let(:doc) { filter %q(<p><a href="https://google.com/">Google</a></p>) }
+
+ it_behaves_like 'an external link with rel attribute'
+ end
+
+ context 'for invalid urls' do
+ it 'skips broken hrefs' do
+ doc = filter %q(<p><a href="don't crash on broken urls">Google</a></p>)
+ expected = %q(<p><a href="don't%20crash%20on%20broken%20urls">Google</a></p>)
+
+ expect(doc.to_html).to eq(expected)
end
- it 'adds rel="noreferrer" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'noreferrer'
+ it 'skips improperly formatted mailtos' do
+ doc = filter %q(<p><a href="mailto://jblogs@example.com">Email</a></p>)
+ expected = %q(<p><a href="mailto://jblogs@example.com">Email</a></p>)
+
+ expect(doc.to_html).to eq(expected)
end
end
- context 'for nested links on document' do
- let(:doc) { filter %q(<p><a href="https://google.com/">Google</a></p>) }
+ context 'for links with a username' do
+ context 'with a valid username' do
+ let(:doc) { filter %q(<a href="https://user@google.com/">Google</a>) }
- it 'adds rel="nofollow" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'nofollow'
+ it_behaves_like 'an external link with rel attribute'
end
- it 'adds rel="noreferrer" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'noreferrer'
+ context 'with an impersonated username' do
+ let(:internal) { Gitlab.config.gitlab.url }
+
+ let(:doc) { filter %Q(<a href="https://#{internal}@example.com" target="_blank">Reverse Tabnabbing</a>) }
+
+ it_behaves_like 'an external link with rel attribute'
end
end
context 'for non-lowercase scheme links' do
- let(:doc_with_http) { filter %q(<p><a href="httP://google.com/">Google</a></p>) }
- let(:doc_with_https) { filter %q(<p><a href="hTTpS://google.com/">Google</a></p>) }
-
- it 'adds rel="nofollow" to external links' do
- expect(doc_with_http.at_css('a')).to have_attribute('rel')
- expect(doc_with_https.at_css('a')).to have_attribute('rel')
+ context 'with http' do
+ let(:doc) { filter %q(<p><a href="httP://google.com/">Google</a></p>) }
- expect(doc_with_http.at_css('a')['rel']).to include 'nofollow'
- expect(doc_with_https.at_css('a')['rel']).to include 'nofollow'
+ it_behaves_like 'an external link with rel attribute'
end
- it 'adds rel="noreferrer" to external links' do
- expect(doc_with_http.at_css('a')).to have_attribute('rel')
- expect(doc_with_https.at_css('a')).to have_attribute('rel')
+ context 'with https' do
+ let(:doc) { filter %q(<p><a href="hTTpS://google.com/">Google</a></p>) }
- expect(doc_with_http.at_css('a')['rel']).to include 'noreferrer'
- expect(doc_with_https.at_css('a')['rel']).to include 'noreferrer'
+ it_behaves_like 'an external link with rel attribute'
end
it 'skips internal links' do
@@ -84,14 +110,6 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do
context 'for protocol-relative links' do
let(:doc) { filter %q(<p><a href="//google.com/">Google</a></p>) }
- it 'adds rel="nofollow" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'nofollow'
- end
-
- it 'adds rel="noreferrer" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'noreferrer'
- end
+ it_behaves_like 'an external link with rel attribute'
end
end
diff --git a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
index 082c0d4dd0d..97d612e6347 100644
--- a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
+++ b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::Filter::GollumTagsFilter, lib: true do
+describe Banzai::Filter::GollumTagsFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { double }
let(:project_wiki) { ProjectWiki.new(project, user) }
@@ -22,7 +22,7 @@ describe Banzai::Filter::GollumTagsFilter, lib: true do
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", project_wiki: project_wiki)
- expect(doc.at_css('img')['src']).to eq "#{project_wiki.wiki_base_path}/images/image.jpg"
+ expect(doc.at_css('img')['data-src']).to eq "#{project_wiki.wiki_base_path}/images/image.jpg"
end
it 'does not creates img tag if image does not exist' do
@@ -40,7 +40,7 @@ describe Banzai::Filter::GollumTagsFilter, lib: true do
tag = '[[http://example.com/image.jpg]]'
doc = filter("See #{tag}", project_wiki: project_wiki)
- expect(doc.at_css('img')['src']).to eq "http://example.com/image.jpg"
+ expect(doc.at_css('img')['data-src']).to eq "http://example.com/image.jpg"
end
it 'does not creates img tag for invalid URL' do
diff --git a/spec/lib/banzai/filter/html_entity_filter_spec.rb b/spec/lib/banzai/filter/html_entity_filter_spec.rb
index f9e6bd609f0..91e18d876d5 100644
--- a/spec/lib/banzai/filter/html_entity_filter_spec.rb
+++ b/spec/lib/banzai/filter/html_entity_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::HtmlEntityFilter, lib: true do
+describe Banzai::Filter::HtmlEntityFilter do
include FilterSpecHelper
let(:unescaped) { 'foo <strike attr="foo">&&&</strike>' }
diff --git a/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb b/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb
new file mode 100644
index 00000000000..c19de7b784a
--- /dev/null
+++ b/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe Banzai::Filter::ImageLazyLoadFilter, lib: true do
+ include FilterSpecHelper
+
+ def image(path)
+ %(<img src="#{path}" />)
+ end
+
+ it 'transforms the image src to a data-src' do
+ doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
+ expect(doc.at_css('img')['data-src']).to eq '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'
+ end
+
+ it 'works with external images' do
+ doc = filter(image('https://i.imgur.com/DfssX9C.jpg'))
+ expect(doc.at_css('img')['data-src']).to eq 'https://i.imgur.com/DfssX9C.jpg'
+ end
+end
diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb
index 294558b3db2..51920869545 100644
--- a/spec/lib/banzai/filter/image_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/image_link_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::ImageLinkFilter, lib: true do
+describe Banzai::Filter::ImageLinkFilter do
include FilterSpecHelper
def image(path)
diff --git a/spec/lib/banzai/filter/inline_diff_filter_spec.rb b/spec/lib/banzai/filter/inline_diff_filter_spec.rb
index 9e526371294..63c4ab61b86 100644
--- a/spec/lib/banzai/filter/inline_diff_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_diff_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::InlineDiffFilter, lib: true do
+describe Banzai::Filter::InlineDiffFilter do
include FilterSpecHelper
it 'adds inline diff span tags for deletions when using square brackets' do
diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
index 9c2399815b9..cacb33d3372 100644
--- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb
+++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-describe Banzai::Filter::IssuableStateFilter, lib: true do
+describe Banzai::Filter::IssuableStateFilter do
include ActionView::Helpers::UrlHelper
include FilterSpecHelper
let(:user) { create(:user) }
let(:context) { { current_user: user, issuable_state_filter_enabled: true } }
let(:closed_issue) { create_issue(:closed) }
- let(:project) { create(:empty_project, :public) }
- let(:other_project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
+ let(:other_project) { create(:project, :public) }
def create_link(text, data)
link_to(text, '', class: 'gfm has-tooltip', data: data)
@@ -107,14 +107,6 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
expect(doc.css('a').last.text).to eq(issue.to_reference)
end
- it 'ignores reopened issue references' do
- issue = create_issue(:reopened)
- link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue')
- doc = filter(link, context)
-
- expect(doc.css('a').last.text).to eq(issue.to_reference)
- end
-
it 'appends state to closed issue references' do
link = create_link(closed_issue.to_reference, issue: closed_issue.id, reference_type: 'issue')
doc = filter(link, context)
@@ -139,7 +131,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
end
it 'ignores reopened merge request references' do
- merge_request = create_merge_request(:reopened)
+ merge_request = create_merge_request(:opened)
link = create_link(
merge_request.to_reference,
diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
index f1082495fcc..9c74c9b8c99 100644
--- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Banzai::Filter::IssueReferenceFilter, lib: true do
+describe Banzai::Filter::IssueReferenceFilter do
include FilterSpecHelper
def helper
IssuesHelper
end
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
it 'requires project context' do
@@ -39,18 +39,11 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
let(:reference) { "##{issue.iid}" }
- it 'ignores valid references when using non-default tracker' do
- allow(project).to receive(:default_issues_tracker?).and_return(false)
-
- exp = act = "Issue #{reference}"
- expect(reference_filter(act).to_html).to eq exp
- end
-
it 'links to a valid reference' do
doc = reference_filter("Fixed #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project)
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project)
end
it 'links with adjacent text' do
@@ -132,14 +125,14 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
context 'cross-project / cross-namespace complete reference' do
it_behaves_like 'a reference containing an element node'
- let(:project2) { create(:empty_project, :public) }
+ let(:project2) { create(:project, :public) }
let(:issue) { create(:issue, project: project2) }
- let(:reference) { "#{project2.path_with_namespace}##{issue.iid}" }
+ let(:reference) { "#{project2.full_path}##{issue.iid}" }
it 'ignores valid references when cross-reference project uses external tracker' do
- expect_any_instance_of(described_class).to receive(:find_object).
- with(project2, issue.iid).
- and_return(nil)
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
exp = act = "Issue #{reference}"
expect(reference_filter(act).to_html).to eq exp
@@ -148,20 +141,20 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project2)
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project2)
end
it 'link has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.css('a').first.text).to eql("#{project2.path_with_namespace}##{issue.iid}")
+ expect(doc.css('a').first.text).to eql("#{project2.full_path}##{issue.iid}")
end
it 'has valid text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.text).to eq("Fixed (#{project2.path_with_namespace}##{issue.iid}.)")
+ expect(doc.text).to eq("Fixed (#{project2.full_path}##{issue.iid}.)")
end
it 'ignores invalid issue IDs on the referenced project' do
@@ -175,15 +168,15 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it_behaves_like 'a reference containing an element node'
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:issue) { create(:issue, project: project2) }
- let(:reference) { "#{project2.path_with_namespace}##{issue.iid}" }
+ let(:reference) { "#{project2.full_path}##{issue.iid}" }
it 'ignores valid references when cross-reference project uses external tracker' do
- expect_any_instance_of(described_class).to receive(:find_object).
- with(project2, issue.iid).
- and_return(nil)
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
exp = act = "Issue #{reference}"
expect(reference_filter(act).to_html).to eq exp
@@ -192,8 +185,8 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project2)
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project2)
end
it 'link has valid text' do
@@ -219,15 +212,15 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it_behaves_like 'a reference containing an element node'
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:issue) { create(:issue, project: project2) }
let(:reference) { "#{project2.path}##{issue.iid}" }
it 'ignores valid references when cross-reference project uses external tracker' do
- expect_any_instance_of(described_class).to receive(:find_object).
- with(project2, issue.iid).
- and_return(nil)
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
exp = act = "Issue #{reference}"
expect(reference_filter(act).to_html).to eq exp
@@ -236,8 +229,8 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project2)
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project2)
end
it 'link has valid text' do
@@ -263,15 +256,15 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it_behaves_like 'a reference containing an element node'
let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:issue) { create(:issue, project: project2) }
let(:reference) { helper.url_for_issue(issue.iid, project2) + "#note_123" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq reference
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
end
it 'links with adjacent text' do
@@ -284,7 +277,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it_behaves_like 'a reference containing an element node'
let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:issue) { create(:issue, project: project2) }
let(:reference) { issue.to_reference(project) }
let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
@@ -292,8 +285,8 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project2)
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project2)
end
it 'links with adjacent text' do
@@ -306,7 +299,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it_behaves_like 'a reference containing an element node'
let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:issue) { create(:issue, project: project2) }
let(:reference) { "#{helper.url_for_issue(issue.iid, project2) + "#note_123"}" }
let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
@@ -314,8 +307,8 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
- expect(doc.css('a').first.attr('href')).
- to eq helper.url_for_issue(issue.iid, project2) + "#note_123"
+ expect(doc.css('a').first.attr('href'))
+ .to eq helper.url_for_issue(issue.iid, project2) + "#note_123"
end
it 'links with adjacent text' do
@@ -330,32 +323,14 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project)
- expect(filter).to receive(:projects_per_reference).
- and_return({ project.path_with_namespace => project })
-
- expect(filter).to receive(:references_per_project).
- and_return({ project.path_with_namespace => Set.new([issue.iid]) })
-
- expect(filter.issues_per_project).
- to eq({ project => { issue.iid => issue } })
- end
- end
-
- context 'using an external issue tracker' do
- it 'returns a Hash containing the issues per project' do
- doc = Nokogiri::HTML.fragment('')
- filter = described_class.new(doc, project: project)
-
- expect(project).to receive(:default_issues_tracker?).and_return(false)
-
- expect(filter).to receive(:projects_per_reference).
- and_return({ project.path_with_namespace => project })
+ expect(filter).to receive(:projects_per_reference)
+ .and_return({ project.full_path => project })
- expect(filter).to receive(:references_per_project).
- and_return({ project.path_with_namespace => Set.new([1]) })
+ expect(filter).to receive(:references_per_project)
+ .and_return({ project.full_path => Set.new([issue.iid]) })
- expect(filter.issues_per_project[project][1]).
- to be_an_instance_of(ExternalIssue)
+ expect(filter.issues_per_project)
+ .to eq({ project => { issue.iid => issue } })
end
end
end
diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb
index 284641fb20a..2cd30a5e302 100644
--- a/spec/lib/banzai/filter/label_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
require 'html/pipeline'
-describe Banzai::Filter::LabelReferenceFilter, lib: true do
+describe Banzai::Filter::LabelReferenceFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project, :public, name: 'sample-project') }
+ let(:project) { create(:project, :public, name: 'sample-project') }
let(:label) { create(:label, project: project) }
let(:reference) { label.to_reference }
@@ -45,7 +45,7 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
- expect(link).to eq urls.namespace_project_issues_path(project.namespace, project, label_name: label.name)
+ expect(link).to eq urls.project_issues_path(project, label_name: label.name)
end
context 'project that does not exist referenced' do
@@ -72,8 +72,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
end
it 'links with adjacent text' do
@@ -95,8 +95,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See gfm'
end
@@ -119,8 +119,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See 2fa'
end
@@ -143,8 +143,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See ?g.fm&'
end
@@ -168,8 +168,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See gfm references'
end
@@ -192,8 +192,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See 2 factor authentication'
end
@@ -216,8 +216,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
expect(doc.text).to eq 'See g.fm & references?'
end
@@ -250,9 +250,9 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
doc = reference_filter("See #{references}")
expect(doc.css('a').map { |a| a.attr('href') }).to match_array([
- urls.namespace_project_issues_url(project.namespace, project, label_name: bug.name),
- urls.namespace_project_issues_url(project.namespace, project, label_name: feature_proposal.name),
- urls.namespace_project_issues_url(project.namespace, project, label_name: technical_debt.name)
+ urls.project_issues_url(project, label_name: bug.name),
+ urls.project_issues_url(project, label_name: feature_proposal.name),
+ urls.project_issues_url(project, label_name: technical_debt.name)
])
expect(doc.text).to eq 'See bug, feature proposal, technical debt'
end
@@ -265,9 +265,9 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
doc = reference_filter("See #{references}")
expect(doc.css('a').map { |a| a.attr('href') }).to match_array([
- urls.namespace_project_issues_url(project.namespace, project, label_name: bug.name),
- urls.namespace_project_issues_url(project.namespace, project, label_name: feature_proposal.name),
- urls.namespace_project_issues_url(project.namespace, project, label_name: technical_debt.name)
+ urls.project_issues_url(project, label_name: bug.name),
+ urls.project_issues_url(project, label_name: feature_proposal.name),
+ urls.project_issues_url(project, label_name: technical_debt.name)
])
expect(doc.text).to eq 'See bug feature proposal technical debt'
end
@@ -287,8 +287,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
end
it 'links with adjacent text' do
@@ -315,7 +315,7 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'group label references' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:group_label) { create(:group_label, name: 'gfm references', group: group) }
context 'without project reference' do
@@ -324,8 +324,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}", project: project)
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: group_label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: group_label.name)
expect(doc.text).to eq 'See gfm references'
end
@@ -347,8 +347,8 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}", project: project)
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_issues_url(project.namespace, project, label_name: group_label.name)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: group_label.name)
expect(doc.text).to eq "See gfm references"
end
@@ -366,16 +366,14 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
end
describe 'cross-project / cross-namespace complete reference' do
- let(:project2) { create(:empty_project) }
+ let(:project2) { create(:project) }
let(:label) { create(:label, project: project2, color: '#00ff00') }
- let(:reference) { "#{project2.path_with_namespace}~#{label.name}" }
+ let(:reference) { "#{project2.full_path}~#{label.name}" }
let!(:result) { reference_filter("See #{reference}") }
it 'links to a valid reference' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(project2.namespace,
- project2,
- label_name: label.name)
+ .to eq urls.project_issues_url(project2, label_name: label.name)
end
it 'has valid color' do
@@ -399,17 +397,15 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'cross-project / same-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, namespace: namespace) }
- let(:project2) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, namespace: namespace) }
let(:label) { create(:label, project: project2, color: '#00ff00') }
- let(:reference) { "#{project2.path_with_namespace}~#{label.name}" }
+ let(:reference) { "#{project2.full_path}~#{label.name}" }
let!(:result) { reference_filter("See #{reference}") }
it 'links to a valid reference' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(project2.namespace,
- project2,
- label_name: label.name)
+ .to eq urls.project_issues_url(project2, label_name: label.name)
end
it 'has valid color' do
@@ -433,22 +429,20 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'cross-project shorthand reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, namespace: namespace) }
- let(:project2) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, namespace: namespace) }
let(:label) { create(:label, project: project2, color: '#00ff00') }
let(:reference) { "#{project2.path}~#{label.name}" }
let!(:result) { reference_filter("See #{reference}") }
it 'links to a valid reference' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(project2.namespace,
- project2,
- label_name: label.name)
+ .to eq urls.project_issues_url(project2, label_name: label.name)
end
it 'has valid color' do
- expect(result.css('a span').first.attr('style')).
- to match /background-color: #00ff00/
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
end
it 'has valid link text' do
@@ -468,33 +462,31 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'cross group label references' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:another_group) { create(:group) }
- let(:another_project) { create(:empty_project, :public, namespace: another_group) }
+ let(:another_project) { create(:project, :public, namespace: another_group) }
let(:group_label) { create(:group_label, group: another_group, color: '#00ff00') }
- let(:reference) { "#{another_project.path_with_namespace}~#{group_label.name}" }
+ let(:reference) { "#{another_project.full_path}~#{group_label.name}" }
let!(:result) { reference_filter("See #{reference}", project: project) }
it 'points to referenced project issues page' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(another_project.namespace,
- another_project,
- label_name: group_label.name)
+ .to eq urls.project_issues_url(another_project, label_name: group_label.name)
end
it 'has valid color' do
- expect(result.css('a span').first.attr('style')).
- to match /background-color: #00ff00/
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
end
it 'has valid link text' do
- expect(result.css('a').first.text).
- to eq "#{group_label.name} in #{another_project.name_with_namespace}"
+ expect(result.css('a').first.text)
+ .to eq "#{group_label.name} in #{another_project.name_with_namespace}"
end
it 'has valid text' do
- expect(result.text).
- to eq "See #{group_label.name} in #{another_project.name_with_namespace}"
+ expect(result.text)
+ .to eq "See #{group_label.name} in #{another_project.name_with_namespace}"
end
it 'ignores invalid IDs on the referenced label' do
@@ -506,32 +498,30 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'cross-project / same-group_label complete reference' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
- let(:another_project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:another_project) { create(:project, :public, namespace: group) }
let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
- let(:reference) { "#{another_project.path_with_namespace}~#{group_label.name}" }
+ let(:reference) { "#{another_project.full_path}~#{group_label.name}" }
let!(:result) { reference_filter("See #{reference}", project: project) }
it 'points to referenced project issues page' do
- expect(result.css('a').first.attr('href')).
- to eq urls.namespace_project_issues_url(another_project.namespace,
- another_project,
- label_name: group_label.name)
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(another_project, label_name: group_label.name)
end
it 'has valid color' do
- expect(result.css('a span').first.attr('style')).
- to match /background-color: #00ff00/
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
end
it 'has valid link text' do
- expect(result.css('a').first.text).
- to eq "#{group_label.name} in #{another_project.name}"
+ expect(result.css('a').first.text)
+ .to eq "#{group_label.name} in #{another_project.name}"
end
it 'has valid text' do
- expect(result.text).
- to eq "See #{group_label.name} in #{another_project.name}"
+ expect(result.text)
+ .to eq "See #{group_label.name} in #{another_project.name}"
end
it 'ignores invalid IDs on the referenced label' do
@@ -543,16 +533,14 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'same project / same group_label complete reference' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
- let(:reference) { "#{project.path_with_namespace}~#{group_label.name}" }
+ let(:reference) { "#{project.full_path}~#{group_label.name}" }
let!(:result) { reference_filter("See #{reference}", project: project) }
it 'points to referenced project issues page' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(project.namespace,
- project,
- label_name: group_label.name)
+ .to eq urls.project_issues_url(project, label_name: group_label.name)
end
it 'has valid color' do
@@ -577,21 +565,19 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do
describe 'same project / same group_label shorthand reference' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
let(:reference) { "#{project.path}~#{group_label.name}" }
let!(:result) { reference_filter("See #{reference}", project: project) }
it 'points to referenced project issues page' do
expect(result.css('a').first.attr('href'))
- .to eq urls.namespace_project_issues_url(project.namespace,
- project,
- label_name: group_label.name)
+ .to eq urls.project_issues_url(project, label_name: group_label.name)
end
it 'has valid color' do
- expect(result.css('a span').first.attr('style')).
- to match /background-color: #00ff00/
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
end
it 'has valid link text' do
diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb
index 897288b8ad5..00c407d1b69 100644
--- a/spec/lib/banzai/filter/markdown_filter_spec.rb
+++ b/spec/lib/banzai/filter/markdown_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::MarkdownFilter, lib: true do
+describe Banzai::Filter::MarkdownFilter do
include FilterSpecHelper
context 'code block' do
diff --git a/spec/lib/banzai/filter/math_filter_spec.rb b/spec/lib/banzai/filter/math_filter_spec.rb
index 51883782e19..cade8cb409e 100644
--- a/spec/lib/banzai/filter/math_filter_spec.rb
+++ b/spec/lib/banzai/filter/math_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::MathFilter, lib: true do
+describe Banzai::Filter::MathFilter do
include FilterSpecHelper
it 'leaves regular inline code unchanged' do
diff --git a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb
index 40232f6e426..ed2788f8a33 100644
--- a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
+describe Banzai::Filter::MergeRequestReferenceFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:merge) { create(:merge_request, source_project: project) }
it 'requires project context' do
@@ -36,8 +36,8 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_merge_request_url(project.namespace, project, merge)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_merge_request_url(project, merge)
end
it 'links with adjacent text' do
@@ -95,21 +95,20 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
- expect(link).to eq urls.namespace_project_merge_request_url(project.namespace, project, merge, only_path: true)
+ expect(link).to eq urls.project_merge_request_url(project, merge, only_path: true)
end
end
context 'cross-project / cross-namespace complete reference' do
- let(:project2) { create(:empty_project, :public) }
+ let(:project2) { create(:project, :public) }
let(:merge) { create(:merge_request, source_project: project2) }
- let(:reference) { "#{project2.path_with_namespace}!#{merge.iid}" }
+ let(:reference) { "#{project2.full_path}!#{merge.iid}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_merge_request_url(project2.namespace,
- project2, merge)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
end
it 'link has valid text' do
@@ -133,17 +132,16 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
context 'cross-project / same-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let!(:merge) { create(:merge_request, source_project: project2) }
- let(:reference) { "#{project2.path_with_namespace}!#{merge.iid}" }
+ let(:reference) { "#{project2.full_path}!#{merge.iid}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_merge_request_url(project2.namespace,
- project2, merge)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
end
it 'link has valid text' do
@@ -167,17 +165,16 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
context 'cross-project shorthand reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let!(:merge) { create(:merge_request, source_project: project2) }
let(:reference) { "#{project2.path}!#{merge.iid}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_merge_request_url(project2.namespace,
- project2, merge)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
end
it 'link has valid text' do
@@ -201,15 +198,15 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do
context 'cross-project URL reference' do
let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:merge) { create(:merge_request, source_project: project2, target_project: project2) }
- let(:reference) { urls.namespace_project_merge_request_url(project2.namespace, project2, merge) + '/diffs#note_123' }
+ let(:reference) { urls.project_merge_request_url(project2, merge) + '/diffs#note_123' }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq reference
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
end
it 'links with adjacent text' do
diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
index a317c751d32..ebd6c79077e 100644
--- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
@@ -1,59 +1,59 @@
require 'spec_helper'
-describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
+describe Banzai::Filter::MilestoneReferenceFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project, :public) }
- let(:milestone) { create(:milestone, project: project) }
- let(:reference) { milestone.to_reference }
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, :public, group: group) }
it 'requires project context' do
expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
end
- %w(pre code a style).each do |elem|
- it "ignores valid references contained inside '#{elem}' element" do
- exp = act = "<#{elem}>milestone #{milestone.to_reference}</#{elem}>"
- expect(reference_filter(act).to_html).to eq exp
+ shared_examples 'reference parsing' do
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>milestone #{reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
end
- end
- it 'includes default classes' do
- doc = reference_filter("Milestone #{reference}")
- expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone has-tooltip'
- end
+ it 'includes default classes' do
+ doc = reference_filter("Milestone #{reference}")
- it 'includes a data-project attribute' do
- doc = reference_filter("Milestone #{reference}")
- link = doc.css('a').first
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone has-tooltip'
+ end
- expect(link).to have_attribute('data-project')
- expect(link.attr('data-project')).to eq project.id.to_s
- end
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Milestone #{reference}")
+ link = doc.css('a').first
- it 'includes a data-milestone attribute' do
- doc = reference_filter("See #{reference}")
- link = doc.css('a').first
+ expect(link).to have_attribute('data-project')
+ expect(link.attr('data-project')).to eq project.id.to_s
+ end
- expect(link).to have_attribute('data-milestone')
- expect(link.attr('data-milestone')).to eq milestone.id.to_s
- end
+ it 'includes a data-milestone attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
- it 'supports an :only_path context' do
- doc = reference_filter("Milestone #{reference}", only_path: true)
- link = doc.css('a').first.attr('href')
+ expect(link).to have_attribute('data-milestone')
+ expect(link.attr('data-milestone')).to eq milestone.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Milestone #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
- expect(link).not_to match %r(https?://)
- expect(link).to eq urls.
- namespace_project_milestone_path(project.namespace, project, milestone)
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.milestone_path(milestone)
+ end
end
- context 'Integer-based references' do
+ shared_examples 'Integer-based references' do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(project.namespace, project, milestone)
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
end
it 'links with adjacent text' do
@@ -68,15 +68,17 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
end
end
- context 'String-based single-word references' do
- let(:milestone) { create(:milestone, name: 'gfm', project: project) }
+ shared_examples 'String-based single-word references' do
let(:reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+ before do
+ milestone.update!(name: 'gfm')
+ end
+
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(project.namespace, project, milestone)
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
expect(doc.text).to eq 'See gfm'
end
@@ -92,15 +94,17 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
end
end
- context 'String-based multi-word references in quotes' do
- let(:milestone) { create(:milestone, name: 'gfm references', project: project) }
+ shared_examples 'String-based multi-word references in quotes' do
let(:reference) { milestone.to_reference(format: :name) }
+ before do
+ milestone.update!(name: 'gfm references')
+ end
+
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(project.namespace, project, milestone)
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
expect(doc.text).to eq 'See gfm references'
end
@@ -116,23 +120,27 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
end
end
- describe 'referencing a milestone in a link href' do
- let(:reference) { %Q{<a href="#{milestone.to_reference}">Milestone</a>} }
+ shared_examples 'referencing a milestone in a link href' do
+ let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+ let(:link_reference) { %Q{<a href="#{unquoted_reference}">Milestone</a>} }
+
+ before do
+ milestone.update!(name: 'gfm')
+ end
it 'links to a valid reference' do
- doc = reference_filter("See #{reference}")
+ doc = reference_filter("See #{link_reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(project.namespace, project, milestone)
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
end
it 'links with adjacent text' do
- doc = reference_filter("Milestone (#{reference}.)")
+ doc = reference_filter("Milestone (#{link_reference}.)")
expect(doc.to_html).to match(%r(\(<a.+>Milestone</a>\.\)))
end
it 'includes a data-project attribute' do
- doc = reference_filter("Milestone #{reference}")
+ doc = reference_filter("Milestone #{link_reference}")
link = doc.css('a').first
expect(link).to have_attribute('data-project')
@@ -140,7 +148,35 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
end
it 'includes a data-milestone attribute' do
- doc = reference_filter("See #{reference}")
+ doc = reference_filter("See #{link_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-milestone')
+ expect(link.attr('data-milestone')).to eq milestone.id.to_s
+ end
+ end
+
+ shared_examples 'linking to a milestone as the entire link' do
+ let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+ let(:link) { urls.milestone_url(milestone) }
+ let(:link_reference) { %Q{<a href="#{link}">#{link}</a>} }
+
+ it 'replaces the link text with the milestone reference' do
+ doc = reference_filter("See #{link}")
+
+ expect(doc.css('a').first.text).to eq(unquoted_reference)
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Milestone #{link_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-project')
+ expect(link.attr('data-project')).to eq project.id.to_s
+ end
+
+ it 'includes a data-milestone attribute' do
+ doc = reference_filter("See #{link_reference}")
link = doc.css('a').first
expect(link).to have_attribute('data-milestone')
@@ -148,32 +184,30 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
end
end
- describe 'cross-project / cross-namespace complete reference' do
+ shared_examples 'cross-project / cross-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:another_project) { create(:empty_project, :public, namespace: namespace) }
+ let(:another_project) { create(:project, :public, namespace: namespace) }
let(:milestone) { create(:milestone, project: another_project) }
- let(:reference) { "#{another_project.path_with_namespace}%#{milestone.iid}" }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
- expect(result.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(another_project.namespace,
- another_project,
- milestone)
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
end
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.css('a').first.text).
- to eq("#{milestone.name} in #{another_project.path_with_namespace}")
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.name} in #{another_project.full_path}")
end
it 'has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.text).
- to eq("See (#{milestone.name} in #{another_project.path_with_namespace}.)")
+ expect(doc.text)
+ .to eq("See (#{milestone.name} in #{another_project.full_path}.)")
end
it 'escapes the name attribute' do
@@ -181,38 +215,36 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.text).
- to eq "#{milestone.name} in #{another_project.path_with_namespace}"
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.name} in #{another_project.full_path}"
end
end
- describe 'cross-project / same-namespace complete reference' do
+ shared_examples 'cross-project / same-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:another_project) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:another_project) { create(:project, :public, namespace: namespace) }
let(:milestone) { create(:milestone, project: another_project) }
- let(:reference) { "#{another_project.path_with_namespace}%#{milestone.iid}" }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
- expect(result.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(another_project.namespace,
- another_project,
- milestone)
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
end
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.css('a').first.text).
- to eq("#{milestone.name} in #{another_project.path}")
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.name} in #{another_project.path}")
end
it 'has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.text).
- to eq("See (#{milestone.name} in #{another_project.path}.)")
+ expect(doc.text)
+ .to eq("See (#{milestone.name} in #{another_project.path}.)")
end
it 'escapes the name attribute' do
@@ -220,38 +252,36 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.text).
- to eq "#{milestone.name} in #{another_project.path}"
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.name} in #{another_project.path}"
end
end
- describe 'cross project shorthand reference' do
+ shared_examples 'cross project shorthand reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:another_project) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:another_project) { create(:project, :public, namespace: namespace) }
let(:milestone) { create(:milestone, project: another_project) }
let(:reference) { "#{another_project.path}%#{milestone.iid}" }
let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
- expect(result.css('a').first.attr('href')).to eq urls.
- namespace_project_milestone_url(another_project.namespace,
- another_project,
- milestone)
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
end
it 'link has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.css('a').first.text).
- to eq("#{milestone.name} in #{another_project.path}")
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.name} in #{another_project.path}")
end
it 'has valid text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.text).
- to eq("See (#{milestone.name} in #{another_project.path}.)")
+ expect(doc.text)
+ .to eq("See (#{milestone.name} in #{another_project.path}.)")
end
it 'escapes the name attribute' do
@@ -259,8 +289,58 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.text).
- to eq "#{milestone.name} in #{another_project.path}"
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.name} in #{another_project.path}"
+ end
+ end
+
+ context 'project milestones' do
+ let(:milestone) { create(:milestone, project: project) }
+ let(:reference) { milestone.to_reference }
+
+ include_examples 'reference parsing'
+
+ it_behaves_like 'Integer-based references'
+ it_behaves_like 'String-based single-word references'
+ it_behaves_like 'String-based multi-word references in quotes'
+ it_behaves_like 'referencing a milestone in a link href'
+ it_behaves_like 'cross-project / cross-namespace complete reference'
+ it_behaves_like 'cross-project / same-namespace complete reference'
+ it_behaves_like 'cross project shorthand reference'
+ end
+
+ context 'group milestones' do
+ let(:milestone) { create(:milestone, group: group) }
+ let(:reference) { milestone.to_reference(format: :name) }
+
+ include_examples 'reference parsing'
+
+ it_behaves_like 'String-based single-word references'
+ it_behaves_like 'String-based multi-word references in quotes'
+ it_behaves_like 'referencing a milestone in a link href'
+
+ it 'does not support references by IID' do
+ doc = reference_filter("See #{Milestone.reference_prefix}#{milestone.iid}")
+
+ expect(doc.css('a')).to be_empty
+ end
+
+ it 'does not support references by link' do
+ doc = reference_filter("See #{urls.milestone_url(milestone)}")
+
+ expect(doc.css('a').first.text).to eq(urls.milestone_url(milestone))
+ end
+
+ it 'does not support cross-project references' do
+ another_group = create(:group)
+ another_project = create(:project, :public, group: group)
+ project_reference = another_project.to_reference(project)
+
+ milestone.update!(group: another_group)
+
+ doc = reference_filter("See #{project_reference}#{reference}")
+
+ expect(doc.css('a')).to be_empty
end
end
end
diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb
index 9b8ecb201f3..8235c411eb7 100644
--- a/spec/lib/banzai/filter/plantuml_filter_spec.rb
+++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::PlantumlFilter, lib: true do
+describe Banzai::Filter::PlantumlFilter do
include FilterSpecHelper
it 'should replace plantuml pre tag with img tag' do
diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb
index 8a6fe1ad6a3..68643effb66 100644
--- a/spec/lib/banzai/filter/redactor_filter_spec.rb
+++ b/spec/lib/banzai/filter/redactor_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::RedactorFilter, lib: true do
+describe Banzai::Filter::RedactorFilter do
include ActionView::Helpers::UrlHelper
include FilterSpecHelper
@@ -17,7 +17,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'skips when the skip_redaction flag is set' do
user = create(:user)
- project = create(:empty_project)
+ project = create(:project)
link = reference_link(project: project.id, reference_type: 'test')
doc = filter(link, current_user: user, skip_redaction: true)
@@ -33,17 +33,19 @@ describe Banzai::Filter::RedactorFilter, lib: true do
end
before do
- allow(Banzai::ReferenceParser).to receive(:[]).
- with('test').
- and_return(parser_class)
+ allow(Banzai::ReferenceParser).to receive(:[])
+ .with('test')
+ .and_return(parser_class)
end
context 'valid projects' do
- before { allow_any_instance_of(Banzai::ReferenceParser::BaseParser).to receive(:can_read_reference?).and_return(true) }
+ before do
+ allow_any_instance_of(Banzai::ReferenceParser::BaseParser).to receive(:can_read_reference?).and_return(true)
+ end
it 'allows permitted Project references' do
user = create(:user)
- project = create(:empty_project)
+ project = create(:project)
project.team << [user, :master]
link = reference_link(project: project.id, reference_type: 'test')
@@ -54,11 +56,13 @@ describe Banzai::Filter::RedactorFilter, lib: true do
end
context 'invalid projects' do
- before { allow_any_instance_of(Banzai::ReferenceParser::BaseParser).to receive(:can_read_reference?).and_return(false) }
+ before do
+ allow_any_instance_of(Banzai::ReferenceParser::BaseParser).to receive(:can_read_reference?).and_return(false)
+ end
it 'removes unpermitted references' do
user = create(:user)
- project = create(:empty_project)
+ project = create(:project)
link = reference_link(project: project.id, reference_type: 'test')
doc = filter(link, current_user: user)
@@ -78,7 +82,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
context 'for confidential issues' do
it 'removes references for non project members' do
non_member = create(:user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
issue = create(:issue, :confidential, project: project)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
@@ -89,7 +93,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'removes references for project members with guest role' do
member = create(:user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
project.team << [member, :guest]
issue = create(:issue, :confidential, project: project)
@@ -101,7 +105,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'allows references for author' do
author = create(:user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
issue = create(:issue, :confidential, project: project, author: author)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
@@ -112,8 +116,8 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'allows references for assignee' do
assignee = create(:user)
- project = create(:empty_project, :public)
- issue = create(:issue, :confidential, project: project, assignee: assignee)
+ project = create(:project, :public)
+ issue = create(:issue, :confidential, project: project, assignees: [assignee])
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
doc = filter(link, current_user: assignee)
@@ -123,7 +127,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'allows references for project members' do
member = create(:user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
project.team << [member, :developer]
issue = create(:issue, :confidential, project: project)
@@ -135,7 +139,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'allows references for admin' do
admin = create(:admin)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
issue = create(:issue, :confidential, project: project)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
@@ -147,7 +151,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do
it 'allows references for non confidential issues' do
user = create(:user)
- project = create(:empty_project, :public)
+ project = create(:project, :public)
issue = create(:issue, project: project)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
diff --git a/spec/lib/banzai/filter/reference_filter_spec.rb b/spec/lib/banzai/filter/reference_filter_spec.rb
index 55e681f6faf..f96b6c83b0a 100644
--- a/spec/lib/banzai/filter/reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/reference_filter_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe Banzai::Filter::ReferenceFilter, lib: true do
- let(:project) { build(:project) }
+describe Banzai::Filter::ReferenceFilter do
+ let(:project) { build_stubbed(:project) }
describe '#each_node' do
it 'iterates over the nodes in a document' do
document = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
filter = described_class.new(document, project: project)
- expect { |b| filter.each_node(&b) }.
- to yield_with_args(an_instance_of(Nokogiri::XML::Element))
+ expect { |b| filter.each_node(&b) }
+ .to yield_with_args(an_instance_of(Nokogiri::XML::Element))
end
it 'returns an Enumerator when no block is given' do
diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb
index 1957ba739e2..08beede62db 100644
--- a/spec/lib/banzai/filter/relative_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::RelativeLinkFilter, lib: true do
+describe Banzai::Filter::RelativeLinkFilter do
def filter(doc, contexts = {})
contexts.reverse_merge!({
commit: commit,
@@ -26,7 +26,7 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do
end
let(:project) { create(:project, :repository) }
- let(:project_path) { project.path_with_namespace }
+ let(:project_path) { project.full_path }
let(:ref) { 'markdown' }
let(:commit) { project.commit(ref) }
let(:project_wiki) { nil }
@@ -56,7 +56,7 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do
end
context 'without a repository' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
include_examples :preserve_unchanged
end
@@ -72,15 +72,15 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do
it 'ignores ref if commit is passed' do
doc = filter(link('non/existent.file'), commit: project.commit('empty-branch') )
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/#{ref}/non/existent.file" # non-existent files have no leading blob/raw/tree
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/#{ref}/non/existent.file" # non-existent files have no leading blob/raw/tree
end
shared_examples :valid_repository do
it 'rebuilds absolute URL for a file in the repo' do
doc = filter(link('/doc/api/README.md'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
end
it 'ignores absolute URLs with two leading slashes' do
@@ -90,71 +90,71 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do
it 'rebuilds relative URL for a file in the repo' do
doc = filter(link('doc/api/README.md'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
end
it 'rebuilds relative URL for a file in the repo with leading ./' do
doc = filter(link('./doc/api/README.md'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
end
it 'rebuilds relative URL for a file in the repo up one directory' do
relative_link = link('../api/README.md')
doc = filter(relative_link, requested_path: 'doc/update/7.14-to-8.0.md')
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
end
it 'rebuilds relative URL for a file in the repo up multiple directories' do
relative_link = link('../../../api/README.md')
doc = filter(relative_link, requested_path: 'doc/foo/bar/baz/README.md')
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/doc/api/README.md"
end
it 'rebuilds relative URL for a file in the repository root' do
relative_link = link('../README.md')
doc = filter(relative_link, requested_path: 'doc/some-file.md')
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/README.md"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/README.md"
end
it 'rebuilds relative URL for a file in the repo with an anchor' do
doc = filter(link('README.md#section'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/blob/#{ref}/README.md#section"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/blob/#{ref}/README.md#section"
end
it 'rebuilds relative URL for a directory in the repo' do
doc = filter(link('doc/api/'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/tree/#{ref}/doc/api"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/tree/#{ref}/doc/api"
end
it 'rebuilds relative URL for an image in the repo' do
doc = filter(image('files/images/logo-black.png'))
- expect(doc.at_css('img')['src']).
- to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png"
+ expect(doc.at_css('img')['src'])
+ .to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png"
end
it 'rebuilds relative URL for link to an image in the repo' do
doc = filter(link('files/images/logo-black.png'))
- expect(doc.at_css('a')['href']).
- to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png"
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png"
end
it 'rebuilds relative URL for a video in the repo' do
doc = filter(video('files/videos/intro.mp4'), commit: project.commit('video'), ref: 'video')
- expect(doc.at_css('video')['src']).
- to eq "/#{project_path}/raw/video/files/videos/intro.mp4"
+ expect(doc.at_css('video')['src'])
+ .to eq "/#{project_path}/raw/video/files/videos/intro.mp4"
end
it 'does not modify relative URL with an anchor only' do
diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb
index fdbc65b5e00..35a32a46eff 100644
--- a/spec/lib/banzai/filter/sanitization_filter_spec.rb
+++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::SanitizationFilter, lib: true do
+describe Banzai::Filter::SanitizationFilter do
include FilterSpecHelper
describe 'default whitelist' do
@@ -97,6 +97,22 @@ describe Banzai::Filter::SanitizationFilter, lib: true do
expect(filter(act).to_html).to eq exp
end
+ it 'allows `data-math-style` attribute on `code` and `pre` elements' do
+ html = <<-HTML
+ <pre class="code" data-math-style="inline">something</pre>
+ <code class="code" data-math-style="inline">something</code>
+ <div class="code" data-math-style="inline">something</div>
+ HTML
+
+ output = <<-HTML
+ <pre data-math-style="inline">something</pre>
+ <code data-math-style="inline">something</code>
+ <div>something</div>
+ HTML
+
+ expect(filter(html).to_html).to eq(output)
+ end
+
it 'removes `rel` attribute from `a` elements' do
act = %q{<a href="#" rel="nofollow">Link</a>}
exp = %q{<a href="#">Link</a>}
@@ -205,8 +221,8 @@ describe Banzai::Filter::SanitizationFilter, lib: true do
end
it 'disallows invalid URIs' do
- expect(Addressable::URI).to receive(:parse).with('foo://example.com').
- and_raise(Addressable::URI::InvalidURIError)
+ expect(Addressable::URI).to receive(:parse).with('foo://example.com')
+ .and_raise(Addressable::URI::InvalidURIError)
input = '<a href="foo://example.com">Foo</a>'
output = filter(input)
diff --git a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
index e036514d283..90ac4c7b238 100644
--- a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::Filter::SnippetReferenceFilter, lib: true do
+describe Banzai::Filter::SnippetReferenceFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:snippet) { create(:project_snippet, project: project) }
let(:reference) { snippet.to_reference }
@@ -22,8 +22,8 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.
- namespace_project_snippet_url(project.namespace, project, snippet)
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_snippet_url(project, snippet)
end
it 'links with adjacent text' do
@@ -75,21 +75,21 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
- expect(link).to eq urls.namespace_project_snippet_url(project.namespace, project, snippet, only_path: true)
+ expect(link).to eq urls.project_snippet_url(project, snippet, only_path: true)
end
end
context 'cross-project / cross-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let!(:snippet) { create(:project_snippet, project: project2) }
- let(:reference) { "#{project2.path_with_namespace}$#{snippet.id}" }
+ let(:reference) { "#{project2.full_path}$#{snippet.id}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_snippet_url(project2.namespace, project2, snippet)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
end
it 'link has valid text' do
@@ -113,16 +113,16 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do
context 'cross-project / same-namespace complete reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let!(:snippet) { create(:project_snippet, project: project2) }
- let(:reference) { "#{project2.path_with_namespace}$#{snippet.id}" }
+ let(:reference) { "#{project2.full_path}$#{snippet.id}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_snippet_url(project2.namespace, project2, snippet)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
end
it 'link has valid text' do
@@ -146,16 +146,16 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do
context 'cross-project shorthand reference' do
let(:namespace) { create(:namespace) }
- let(:project) { create(:empty_project, :public, namespace: namespace) }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let!(:snippet) { create(:project_snippet, project: project2) }
let(:reference) { "#{project2.path}$#{snippet.id}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_snippet_url(project2.namespace, project2, snippet)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
end
it 'link has valid text' do
@@ -179,15 +179,15 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do
context 'cross-project URL reference' do
let(:namespace) { create(:namespace, name: 'cross-reference') }
- let(:project2) { create(:empty_project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
let(:snippet) { create(:project_snippet, project: project2) }
- let(:reference) { urls.namespace_project_snippet_url(project2.namespace, project2, snippet) }
+ let(:reference) { urls.project_snippet_url(project2, snippet) }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
- expect(doc.css('a').first.attr('href')).
- to eq urls.namespace_project_snippet_url(project2.namespace, project2, snippet)
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
end
it 'links with adjacent text' do
diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
index f61fc8ceb9e..5a23e0e70cc 100644
--- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
+++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::SyntaxHighlightFilter, lib: true do
+describe Banzai::Filter::SyntaxHighlightFilter do
include FilterSpecHelper
context "when no language is specified" do
diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
index 70b31f3a880..ff6b19459bb 100644
--- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
+++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::TableOfContentsFilter, lib: true do
+describe Banzai::Filter::TableOfContentsFilter do
include FilterSpecHelper
def header(level, text)
diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb
index 639cac6406a..60a88e903ef 100644
--- a/spec/lib/banzai/filter/upload_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::UploadLinkFilter, lib: true do
+describe Banzai::Filter::UploadLinkFilter do
def filter(doc, contexts = {})
contexts.reverse_merge!({
project: project
@@ -29,7 +29,7 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do
%(<div><a href="#{path}">#{path}</a></div>)
end
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
shared_examples :preserve_unchanged do
it 'does not modify any relative URL in anchor' do
@@ -51,22 +51,22 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do
context 'with a valid repository' do
it 'rebuilds relative URL for a link' do
doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
- expect(doc.at_css('a')['href']).
- to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
+ expect(doc.at_css('a')['href'])
+ .to eq "#{Gitlab.config.gitlab.url}/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
doc = filter(nested_link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
- expect(doc.at_css('a')['href']).
- to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
+ expect(doc.at_css('a')['href'])
+ .to eq "#{Gitlab.config.gitlab.url}/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
end
it 'rebuilds relative URL for an image' do
doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
- expect(doc.at_css('img')['src']).
- to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
+ expect(doc.at_css('img')['src'])
+ .to eq "#{Gitlab.config.gitlab.url}/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
doc = filter(nested_image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
- expect(doc.at_css('img')['src']).
- to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
+ expect(doc.at_css('img')['src'])
+ .to eq "#{Gitlab.config.gitlab.url}/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
end
it 'does not modify absolute URL' do
@@ -79,13 +79,13 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do
escaped = Addressable::URI.escape(path)
# Stub these methods so the file doesn't actually need to be in the repo
- allow_any_instance_of(described_class).
- to receive(:file_exists?).and_return(true)
- allow_any_instance_of(described_class).
- to receive(:image?).with(path).and_return(true)
+ allow_any_instance_of(described_class)
+ .to receive(:file_exists?).and_return(true)
+ allow_any_instance_of(described_class)
+ .to receive(:image?).with(path).and_return(true)
doc = filter(image(escaped))
- expect(doc.at_css('img')['src']).to match "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/%ED%95%9C%EA%B8%80.png"
+ expect(doc.at_css('img')['src']).to match "#{Gitlab.config.gitlab.url}/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png"
end
end
diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb
index 63b23dac7ed..34dac1db69a 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::Filter::UserReferenceFilter, lib: true do
+describe Banzai::Filter::UserReferenceFilter do
include FilterSpecHelper
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:reference) { user.to_reference }
@@ -16,6 +16,11 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do
expect(reference_filter(act).to_html).to eq(exp)
end
+ it 'ignores references with text before the @ sign' do
+ exp = act = "Hey foo#{reference}"
+ expect(reference_filter(act).to_html).to eq(exp)
+ end
+
%w(pre code a style).each do |elem|
it "ignores valid references contained inside '#{elem}' element" do
exp = act = "<#{elem}>Hey #{reference}</#{elem}>"
@@ -38,7 +43,7 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do
expect(doc.css('a').length).to eq 1
expect(doc.css('a').first.attr('href'))
- .to eq urls.namespace_project_url(project.namespace, project)
+ .to eq urls.project_url(project)
end
it 'includes a data-author attribute when there is an author' do
diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb
index 00494f545a3..81dda0687f3 100644
--- a/spec/lib/banzai/filter/video_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/video_link_filter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::Filter::VideoLinkFilter, lib: true do
+describe Banzai::Filter::VideoLinkFilter do
def filter(doc, contexts = {})
contexts.reverse_merge!({
project: project
diff --git a/spec/lib/banzai/filter/wiki_link_filter_spec.rb b/spec/lib/banzai/filter/wiki_link_filter_spec.rb
index 92d88c4172c..9596f004052 100644
--- a/spec/lib/banzai/filter/wiki_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/wiki_link_filter_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Banzai::Filter::WikiLinkFilter, lib: true do
+describe Banzai::Filter::WikiLinkFilter do
include FilterSpecHelper
let(:namespace) { build_stubbed(:namespace, name: "wiki_link_ns") }
- let(:project) { build_stubbed(:empty_project, :public, name: "wiki_link_project", namespace: namespace) }
+ let(:project) { build_stubbed(:project, :public, name: "wiki_link_project", namespace: namespace) }
let(:user) { double }
let(:wiki) { ProjectWiki.new(project, user) }
diff --git a/spec/lib/banzai/filter/yaml_front_matter_filter_spec.rb b/spec/lib/banzai/filter/yaml_front_matter_filter_spec.rb
index fe70eada7eb..9f1b862ef19 100644
--- a/spec/lib/banzai/filter/yaml_front_matter_filter_spec.rb
+++ b/spec/lib/banzai/filter/yaml_front_matter_filter_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe Banzai::Filter::YamlFrontMatterFilter, lib: true do
+describe Banzai::Filter::YamlFrontMatterFilter do
include FilterSpecHelper
it 'allows for `encoding:` before the frontmatter' do
diff --git a/spec/lib/banzai/issuable_extractor_spec.rb b/spec/lib/banzai/issuable_extractor_spec.rb
index e5d332efb08..69763476dac 100644
--- a/spec/lib/banzai/issuable_extractor_spec.rb
+++ b/spec/lib/banzai/issuable_extractor_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Banzai::IssuableExtractor, lib: true do
- let(:project) { create(:empty_project) }
+describe Banzai::IssuableExtractor do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:extractor) { described_class.new(project, user) }
let(:issue) { create(:issue, project: project) }
@@ -29,16 +29,7 @@ describe Banzai::IssuableExtractor, lib: true do
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
-
+ describe 'caching', :request_store do
it 'saves records to cache' do
extractor.extract([issue_link, merge_request_link])
diff --git a/spec/lib/banzai/note_renderer_spec.rb b/spec/lib/banzai/note_renderer_spec.rb
index 49556074278..32764bee5eb 100644
--- a/spec/lib/banzai/note_renderer_spec.rb
+++ b/spec/lib/banzai/note_renderer_spec.rb
@@ -8,15 +8,15 @@ describe Banzai::NoteRenderer do
wiki = double(:wiki)
user = double(:user)
- expect(Banzai::ObjectRenderer).to receive(:new).
- with(project, user,
+ expect(Banzai::ObjectRenderer).to receive(:new)
+ .with(project, user,
requested_path: 'foo',
project_wiki: wiki,
- ref: 'bar').
- and_call_original
+ ref: 'bar')
+ .and_call_original
- expect_any_instance_of(Banzai::ObjectRenderer).
- to receive(:render).with([note], :note)
+ expect_any_instance_of(Banzai::ObjectRenderer)
+ .to receive(:render).with([note], :note)
described_class.render([note], project, user, 'foo', wiki, 'bar')
end
diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb
index dd2674f9f20..7f5d481c36c 100644
--- a/spec/lib/banzai/object_renderer_spec.rb
+++ b/spec/lib/banzai/object_renderer_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Banzai::ObjectRenderer do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { project.owner }
let(:renderer) { described_class.new(project, user, custom_value: 'value') }
let(:object) { Note.new(note: 'hello', note_html: '<p dir="auto">hello</p>', cached_markdown_version: CacheMarkdownField::CACHE_VERSION) }
@@ -28,7 +28,7 @@ describe Banzai::ObjectRenderer do
it 'passes context to PostProcessPipeline' do
another_user = create(:user)
- another_project = create(:empty_project)
+ another_project = create(:project)
object = Note.new(
note: 'hello',
note_html: 'hello',
diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
index 2501b638774..e9c7a2f352e 100644
--- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
@@ -2,7 +2,7 @@ require 'rails_helper'
describe Banzai::Pipeline::FullPipeline do
describe 'References' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
it 'handles markdown inside a reference' do
diff --git a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb
new file mode 100644
index 00000000000..75413596431
--- /dev/null
+++ b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb
@@ -0,0 +1,90 @@
+require 'rails_helper'
+
+describe Banzai::Pipeline::GfmPipeline do
+ describe 'integration between parsing regular and external issue references' do
+ let(:project) { create(:redmine_project, :public) }
+
+ context 'when internal issue tracker is enabled' do
+ context 'when shorthand pattern #ISSUE_ID is used' do
+ it 'links an internal issue if it exists' do
+ issue = create(:issue, project: project)
+ markdown = issue.to_reference(project, full: true)
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq(
+ Gitlab::Routing.url_helpers.project_issue_path(project, issue)
+ )
+ end
+
+ it 'does not link any issue if it does not exist on GitLab' do
+ markdown = '#12'
+
+ result = described_class.call(markdown, project: project)[:output]
+ expect(result.css('a')).to be_empty
+ end
+ end
+
+ it 'allows to use long external reference syntax for Redmine' do
+ markdown = 'API_32-12'
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq 'http://redmine/projects/project_name_in_redmine/issues/12'
+ end
+
+ it 'parses cross-project references to regular issues' do
+ other_project = create(:project, :public)
+ issue = create(:issue, project: other_project)
+ markdown = issue.to_reference(project, full: true)
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq(
+ Gitlab::Routing.url_helpers.project_issue_path(other_project, issue)
+ )
+ end
+ end
+
+ context 'when internal issue tracker is disabled' do
+ before do
+ project.issues_enabled = false
+ project.save!
+ end
+
+ it 'allows to use shorthand external reference syntax for Redmine' do
+ markdown = '#12'
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq 'http://redmine/projects/project_name_in_redmine/issues/12'
+ end
+
+ it 'allows to use long external reference syntax for Redmine' do
+ markdown = 'API_32-12'
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq 'http://redmine/projects/project_name_in_redmine/issues/12'
+ end
+
+ it 'parses cross-project references to regular issues' do
+ other_project = create(:project, :public)
+ issue = create(:issue, project: other_project)
+ markdown = issue.to_reference(project, full: true)
+
+ result = described_class.call(markdown, project: project)[:output]
+ link = result.css('a').first
+
+ expect(link['href']).to eq(
+ Gitlab::Routing.url_helpers.project_issue_path(other_project, issue)
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
index ac9bde6baf1..88ae4c1e07a 100644
--- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
@@ -53,7 +53,7 @@ describe Banzai::Pipeline::WikiPipeline do
describe "Links" do
let(:namespace) { create(:namespace, name: "wiki_link_ns") }
- let(:project) { create(:empty_project, :public, name: "wiki_link_project", namespace: namespace) }
+ let(:project) { create(:project, :public, name: "wiki_link_project", namespace: namespace) }
let(:project_wiki) { ProjectWiki.new(project, double(:user)) }
let(:page) { build(:wiki_page, wiki: project_wiki, page: OpenStruct.new(url_path: 'nested/twice/start-page')) }
diff --git a/spec/lib/banzai/redactor_spec.rb b/spec/lib/banzai/redactor_spec.rb
index e6f2963193c..2424c3fdc66 100644
--- a/spec/lib/banzai/redactor_spec.rb
+++ b/spec/lib/banzai/redactor_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Banzai::Redactor do
let(:user) { build(:user) }
- let(:project) { build(:empty_project) }
+ let(:project) { build(:project) }
let(:redactor) { described_class.new(project, user) }
describe '#redact' do
@@ -12,11 +12,11 @@ describe Banzai::Redactor do
end
it 'redacts an array of documents' do
- doc1 = Nokogiri::HTML.
- fragment('<a class="gfm" data-reference-type="issue">foo</a>')
+ doc1 = Nokogiri::HTML
+ .fragment('<a class="gfm" data-reference-type="issue">foo</a>')
- doc2 = Nokogiri::HTML.
- fragment('<a class="gfm" data-reference-type="issue">bar</a>')
+ doc2 = Nokogiri::HTML
+ .fragment('<a class="gfm" data-reference-type="issue">bar</a>')
redacted_data = redactor.redact([doc1, doc2])
@@ -93,9 +93,9 @@ describe Banzai::Redactor do
doc = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
node = doc.children[0]
- expect(redactor).to receive(:nodes_visible_to_user).
- with([node]).
- and_return(Set.new)
+ expect(redactor).to receive(:nodes_visible_to_user)
+ .with([node])
+ .and_return(Set.new)
redactor.redact_document_nodes([{ document: doc, nodes: [node] }])
@@ -108,10 +108,10 @@ describe Banzai::Redactor do
doc = Nokogiri::HTML.fragment('<a data-reference-type="issue"></a>')
node = doc.children[0]
- expect_any_instance_of(Banzai::ReferenceParser::IssueParser).
- to receive(:nodes_visible_to_user).
- with(user, [node]).
- and_return([node])
+ expect_any_instance_of(Banzai::ReferenceParser::IssueParser)
+ .to receive(:nodes_visible_to_user)
+ .with(user, [node])
+ .and_return([node])
expect(redactor.nodes_visible_to_user([node])).to eq(Set.new([node]))
end
diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb
index d5746107ee1..6175d4c4ca9 100644
--- a/spec/lib/banzai/reference_parser/base_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::BaseParser, lib: true do
+describe Banzai::ReferenceParser::BaseParser do
include ReferenceParserHelpers
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
subject do
klass = Class.new(described_class) do
@@ -30,7 +30,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
it 'checks if user can read the resource' do
link['data-project'] = project.id.to_s
- expect(subject).to receive(:can_read_reference?).with(user, project)
+ expect(subject).to receive(:can_read_reference?).with(user, project, link)
subject.nodes_visible_to_user(user, [link])
end
@@ -54,8 +54,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
describe '#referenced_by' do
context 'when references_relation is implemented' do
it 'returns a collection of objects' do
- links = Nokogiri::HTML.fragment("<a data-foo='#{user.id}'></a>").
- children
+ links = Nokogiri::HTML.fragment("<a data-foo='#{user.id}'></a>")
+ .children
expect(subject).to receive(:references_relation).and_return(User)
expect(subject.referenced_by(links)).to eq([user])
@@ -66,8 +66,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
it 'raises NotImplementedError' do
links = Nokogiri::HTML.fragment('<a data-foo="1"></a>').children
- expect { subject.referenced_by(links) }.
- to raise_error(NotImplementedError)
+ expect { subject.referenced_by(links) }
+ .to raise_error(NotImplementedError)
end
end
end
@@ -80,8 +80,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
describe '#gather_attributes_per_project' do
it 'returns a Hash containing attribute values per project' do
- link = Nokogiri::HTML.fragment('<a data-project="1" data-foo="2"></a>').
- children[0]
+ link = Nokogiri::HTML.fragment('<a data-project="1" data-foo="2"></a>')
+ .children[0]
hash = subject.gather_attributes_per_project([link], 'data-foo')
@@ -95,42 +95,42 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
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(:has_attribute?)
+ .with('data-user')
+ .and_return(true)
- expect(link).to receive(:attr).
- with('data-user').
- and_return(user.id.to_s)
+ 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.to_s])
+ expect(subject).to receive(:unique_attribute_values)
+ .with(nodes, 'data-user')
+ .and_return([user.id.to_s])
hash = subject.grouped_objects_for_nodes(nodes, User, 'data-user')
expect(hash).to eq({ link => user })
end
- it 'returns an empty Hash when entry does not exist in the database' do
+ it 'returns an empty Hash when entry does not exist in the database', :request_store do
link = double(:link)
- expect(link).to receive(:has_attribute?).
- with('data-user').
- and_return(true)
+ expect(link).to receive(:has_attribute?)
+ .with('data-user')
+ .and_return(true)
- expect(link).to receive(:attr).
- with('data-user').
- and_return('1')
+ expect(link).to receive(:attr)
+ .with('data-user')
+ .and_return('1')
nodes = [link]
bad_id = user.id + 100
- expect(subject).to receive(:unique_attribute_values).
- with(nodes, 'data-user').
- and_return([bad_id.to_s])
+ expect(subject).to receive(:unique_attribute_values)
+ .with(nodes, 'data-user')
+ .and_return([bad_id.to_s])
hash = subject.grouped_objects_for_nodes(nodes, User, 'data-user')
@@ -142,15 +142,15 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
it 'returns an Array of unique values' do
link = double(:link)
- expect(link).to receive(:has_attribute?).
- with('data-foo').
- twice.
- and_return(true)
+ expect(link).to receive(:has_attribute?)
+ .with('data-foo')
+ .twice
+ .and_return(true)
- expect(link).to receive(:attr).
- with('data-foo').
- twice.
- and_return('1')
+ expect(link).to receive(:attr)
+ .with('data-foo')
+ .twice
+ .and_return('1')
nodes = [link, link]
@@ -167,9 +167,9 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
instance = dummy.new(project, user)
document = Nokogiri::HTML.fragment('<a class="gfm"></a><a class="gfm" data-reference-type="test"></a>')
- expect(instance).to receive(:gather_references).
- with([document.children[1]]).
- and_return([user])
+ expect(instance).to receive(:gather_references)
+ .with([document.children[1]])
+ .and_return([user])
expect(instance.process([document])).to eq([user])
end
@@ -179,9 +179,9 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
let(:link) { double(:link) }
it 'does not process links a user can not reference' do
- expect(subject).to receive(:nodes_user_can_reference).
- with(user, [link]).
- and_return([])
+ expect(subject).to receive(:nodes_user_can_reference)
+ .with(user, [link])
+ .and_return([])
expect(subject).to receive(:referenced_by).with([])
@@ -189,13 +189,13 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
end
it 'does not process links a user can not see' do
- expect(subject).to receive(:nodes_user_can_reference).
- with(user, [link]).
- and_return([link])
+ expect(subject).to receive(:nodes_user_can_reference)
+ .with(user, [link])
+ .and_return([link])
- expect(subject).to receive(:nodes_visible_to_user).
- with(user, [link]).
- and_return([])
+ expect(subject).to receive(:nodes_visible_to_user)
+ .with(user, [link])
+ .and_return([])
expect(subject).to receive(:referenced_by).with([])
@@ -203,13 +203,13 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
end
it 'returns the references if a user can reference and see a link' do
- expect(subject).to receive(:nodes_user_can_reference).
- with(user, [link]).
- and_return([link])
+ expect(subject).to receive(:nodes_user_can_reference)
+ .with(user, [link])
+ .and_return([link])
- expect(subject).to receive(:nodes_visible_to_user).
- with(user, [link]).
- and_return([link])
+ expect(subject).to receive(:nodes_visible_to_user)
+ .with(user, [link])
+ .and_return([link])
expect(subject).to receive(:referenced_by).with([link])
@@ -221,8 +221,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
it 'delegates the permissions check to the Ability class' do
user = double(:user)
- expect(Ability).to receive(:allowed?).
- with(user, :read_project, project)
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_project, project)
subject.can?(user, :read_project, project)
end
@@ -230,8 +230,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
describe '#find_projects_for_hash_keys' do
it 'returns a list of Projects' do
- expect(subject.find_projects_for_hash_keys(project.id => project)).
- to eq([project])
+ expect(subject.find_projects_for_hash_keys(project.id => project))
+ .to eq([project])
end
end
@@ -243,8 +243,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
expect(collection).to receive(:where).twice.and_call_original
2.times do
- expect(subject.collection_objects_for_ids(collection, [user.id])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(collection, [user.id]))
+ .to eq([user])
end
end
end
@@ -258,8 +258,8 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
end
it 'queries the collection on the first call' do
- expect(subject.collection_objects_for_ids(User, [user.id])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(User, [user.id]))
+ .to eq([user])
end
it 'does not query previously queried objects' do
@@ -268,34 +268,34 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do
expect(collection).to receive(:where).once.and_call_original
2.times do
- expect(subject.collection_objects_for_ids(collection, [user.id])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(collection, [user.id]))
+ .to eq([user])
end
end
it 'casts String based IDs to Fixnums before querying objects' do
2.times do
- expect(subject.collection_objects_for_ids(User, [user.id.to_s])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(User, [user.id.to_s]))
+ .to eq([user])
end
end
it 'queries any additional objects after the first call' do
other_user = create(:user)
- expect(subject.collection_objects_for_ids(User, [user.id])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(User, [user.id]))
+ .to eq([user])
- expect(subject.collection_objects_for_ids(User, [user.id, other_user.id])).
- to eq([user, other_user])
+ expect(subject.collection_objects_for_ids(User, [user.id, other_user.id]))
+ .to eq([user, other_user])
end
it 'caches objects on a per collection class basis' do
- expect(subject.collection_objects_for_ids(User, [user.id])).
- to eq([user])
+ expect(subject.collection_objects_for_ids(User, [user.id]))
+ .to eq([user])
- expect(subject.collection_objects_for_ids(Project, [project.id])).
- to eq([project])
+ expect(subject.collection_objects_for_ids(Project, [project.id]))
+ .to eq([project])
end
end
end
diff --git a/spec/lib/banzai/reference_parser/commit_parser_spec.rb b/spec/lib/banzai/reference_parser/commit_parser_spec.rb
index 412ffa77c36..3505659c2c3 100644
--- a/spec/lib/banzai/reference_parser/commit_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/commit_parser_spec.rb
@@ -1,16 +1,18 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::CommitParser, lib: true do
+describe Banzai::ReferenceParser::CommitParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
context 'when the link has a data-issue attribute' do
- before { link['data-commit'] = 123 }
+ before do
+ link['data-commit'] = 123
+ end
it_behaves_like "referenced feature visibility", "repository"
end
@@ -30,30 +32,30 @@ describe Banzai::ReferenceParser::CommitParser, lib: true do
it 'returns an Array of commits' do
commit = double(:commit)
- allow_any_instance_of(Project).to receive(:valid_repo?).
- and_return(true)
+ allow_any_instance_of(Project).to receive(:valid_repo?)
+ .and_return(true)
- expect(subject).to receive(:find_commits).
- with(project, ['123']).
- and_return([commit])
+ expect(subject).to receive(:find_commits)
+ .with(project, ['123'])
+ .and_return([commit])
expect(subject.referenced_by([link])).to eq([commit])
end
it 'returns an empty Array when the commit could not be found' do
- allow_any_instance_of(Project).to receive(:valid_repo?).
- and_return(true)
+ allow_any_instance_of(Project).to receive(:valid_repo?)
+ .and_return(true)
- expect(subject).to receive(:find_commits).
- with(project, ['123']).
- and_return([])
+ expect(subject).to receive(:find_commits)
+ .with(project, ['123'])
+ .and_return([])
expect(subject.referenced_by([link])).to eq([])
end
it 'skips projects without valid repositories' do
- allow_any_instance_of(Project).to receive(:valid_repo?).
- and_return(false)
+ allow_any_instance_of(Project).to receive(:valid_repo?)
+ .and_return(false)
expect(subject.referenced_by([link])).to eq([])
end
@@ -61,8 +63,8 @@ describe Banzai::ReferenceParser::CommitParser, lib: true do
context 'when the link does not have a data-commit attribute' do
it 'returns an empty Array' do
- allow_any_instance_of(Project).to receive(:valid_repo?).
- and_return(true)
+ allow_any_instance_of(Project).to receive(:valid_repo?)
+ .and_return(true)
expect(subject.referenced_by([link])).to eq([])
end
@@ -71,8 +73,8 @@ describe Banzai::ReferenceParser::CommitParser, lib: true do
context 'when the link does not have a data-project attribute' do
it 'returns an empty Array' do
- allow_any_instance_of(Project).to receive(:valid_repo?).
- and_return(true)
+ allow_any_instance_of(Project).to receive(:valid_repo?)
+ .and_return(true)
expect(subject.referenced_by([link])).to eq([])
end
diff --git a/spec/lib/banzai/reference_parser/commit_range_parser_spec.rb b/spec/lib/banzai/reference_parser/commit_range_parser_spec.rb
index 96e55b0997a..21813177deb 100644
--- a/spec/lib/banzai/reference_parser/commit_range_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/commit_range_parser_spec.rb
@@ -1,16 +1,18 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::CommitRangeParser, lib: true do
+describe Banzai::ReferenceParser::CommitRangeParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
context 'when the link has a data-issue attribute' do
- before { link['data-commit-range'] = '123..456' }
+ before do
+ link['data-commit-range'] = '123..456'
+ end
it_behaves_like "referenced feature visibility", "repository"
end
@@ -30,17 +32,17 @@ describe Banzai::ReferenceParser::CommitRangeParser, lib: true do
it 'returns an Array of commit ranges' do
range = double(:range)
- expect(subject).to receive(:find_object).
- with(project, '123..456').
- and_return(range)
+ expect(subject).to receive(:find_object)
+ .with(project, '123..456')
+ .and_return(range)
expect(subject.referenced_by([link])).to eq([range])
end
it 'returns an empty Array when the commit range could not be found' do
- expect(subject).to receive(:find_object).
- with(project, '123..456').
- and_return(nil)
+ expect(subject).to receive(:find_object)
+ .with(project, '123..456')
+ .and_return(nil)
expect(subject.referenced_by([link])).to eq([])
end
@@ -86,17 +88,17 @@ describe Banzai::ReferenceParser::CommitRangeParser, lib: true do
it 'returns an Array of range objects' do
range = double(:commit)
- expect(subject).to receive(:find_object).
- with(project, '123..456').
- and_return(range)
+ expect(subject).to receive(:find_object)
+ .with(project, '123..456')
+ .and_return(range)
expect(subject.find_ranges(project, ['123..456'])).to eq([range])
end
it 'skips ranges that could not be found' do
- expect(subject).to receive(:find_object).
- with(project, '123..456').
- and_return(nil)
+ expect(subject).to receive(:find_object)
+ .with(project, '123..456')
+ .and_return(nil)
expect(subject.find_ranges(project, ['123..456'])).to eq([])
end
diff --git a/spec/lib/banzai/reference_parser/external_issue_parser_spec.rb b/spec/lib/banzai/reference_parser/external_issue_parser_spec.rb
index 0af36776a54..25969b65168 100644
--- a/spec/lib/banzai/reference_parser/external_issue_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/external_issue_parser_spec.rb
@@ -1,16 +1,18 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::ExternalIssueParser, lib: true do
+describe Banzai::ReferenceParser::ExternalIssueParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
context 'when the link has a data-issue attribute' do
- before { link['data-external-issue'] = 123 }
+ before do
+ link['data-external-issue'] = 123
+ end
levels = [ProjectFeature::DISABLED, ProjectFeature::PRIVATE, ProjectFeature::ENABLED]
diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
index 7031c47231c..23dbe2b6238 100644
--- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::IssueParser, lib: true do
+describe Banzai::ReferenceParser::IssueParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:issue) { create(:issue, project: project) }
let(:link) { empty_html_link }
@@ -18,17 +18,17 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do
it_behaves_like "referenced feature visibility", "issues"
it 'returns the nodes when the user can read the issue' do
- expect(Ability).to receive(:issues_readable_by_user).
- with([issue], user).
- and_return([issue])
+ expect(Ability).to receive(:issues_readable_by_user)
+ .with([issue], user)
+ .and_return([issue])
expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
end
it 'returns an empty Array when the user can not read the issue' do
- expect(Ability).to receive(:issues_readable_by_user).
- with([issue], user).
- and_return([])
+ expect(Ability).to receive(:issues_readable_by_user)
+ .with([issue], user)
+ .and_return([])
expect(subject.nodes_visible_to_user(user, [link])).to eq([])
end
@@ -39,16 +39,6 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do
expect(subject.nodes_visible_to_user(user, [link])).to eq([])
end
end
-
- context 'when the project uses an external issue tracker' do
- it 'returns all nodes' do
- link = double(:link)
-
- expect(project).to receive(:external_issue_tracker).and_return(true)
-
- expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
- end
- end
end
describe '#referenced_by' do
diff --git a/spec/lib/banzai/reference_parser/label_parser_spec.rb b/spec/lib/banzai/reference_parser/label_parser_spec.rb
index 8c540d35ddd..b700161d6c2 100644
--- a/spec/lib/banzai/reference_parser/label_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/label_parser_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::LabelParser, lib: true do
+describe Banzai::ReferenceParser::LabelParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project) }
subject { described_class.new(project, user) }
@@ -11,7 +11,9 @@ describe Banzai::ReferenceParser::LabelParser, lib: true do
describe '#nodes_visible_to_user' do
context 'when the link has a data-issue attribute' do
- before { link['data-label'] = label.id.to_s }
+ before do
+ link['data-label'] = label.id.to_s
+ end
it_behaves_like "referenced feature visibility", "issues", "merge_requests"
end
diff --git a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
index cb69ca16800..775749ae3a7 100644
--- a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::MergeRequestParser, lib: true do
+describe Banzai::ReferenceParser::MergeRequestParser do
include ReferenceParserHelpers
let(:user) { create(:user) }
diff --git a/spec/lib/banzai/reference_parser/milestone_parser_spec.rb b/spec/lib/banzai/reference_parser/milestone_parser_spec.rb
index 2d4d589ae34..7dacdf8d629 100644
--- a/spec/lib/banzai/reference_parser/milestone_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/milestone_parser_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::MilestoneParser, lib: true do
+describe Banzai::ReferenceParser::MilestoneParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
subject { described_class.new(project, user) }
@@ -11,7 +11,9 @@ describe Banzai::ReferenceParser::MilestoneParser, lib: true do
describe '#nodes_visible_to_user' do
context 'when the link has a data-issue attribute' do
- before { link['data-milestone'] = milestone.id.to_s }
+ before do
+ link['data-milestone'] = milestone.id.to_s
+ end
it_behaves_like "referenced feature visibility", "issues", "merge_requests"
end
diff --git a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
index d217a775802..69ec3f66aa8 100644
--- a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb
@@ -1,23 +1,202 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::SnippetParser, lib: true do
+describe Banzai::ReferenceParser::SnippetParser do
include ReferenceParserHelpers
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
+
let(:user) { create(:user) }
- let(:snippet) { create(:snippet, project: project) }
+ let(:external_user) { create(:user, :external) }
+ let(:project_member) { create(:user) }
+
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
+ def visible_references(snippet_visibility, user = nil)
+ snippet = create(:project_snippet, snippet_visibility, project: project)
+ link['data-project'] = project.id.to_s
+ link['data-snippet'] = snippet.id.to_s
+
+ subject.nodes_visible_to_user(user, [link])
+ end
+
+ before do
+ project.add_user(project_member, :developer)
+ end
+
describe '#nodes_visible_to_user' do
- context 'when the link has a data-issue attribute' do
- before { link['data-snippet'] = snippet.id.to_s }
+ context 'when a project is public and the snippets feature is enabled for everyone' do
+ before do
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::ENABLED)
+ end
+
+ it 'creates a reference for guest for a public snippet' do
+ expect(visible_references(:public)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to eq([link])
+ end
+
+ it 'does not create a reference for an external user for an internal snippet' do
+ expect(visible_references(:internal, external_user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is public and the snippets feature is enabled for project team members' do
+ before do
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is internal and the snippets feature is enabled for everyone' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::INTERNAL)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::ENABLED)
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public)).to be_empty
+ end
+
+ it 'does not create a reference for an external user for a public snippet' do
+ expect(visible_references(:public, external_user)).to be_empty
+ end
- it_behaves_like "referenced feature visibility", "snippets"
+ it 'creates a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to eq([link])
+ end
+
+ it 'creates a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to eq([link])
+ end
+
+ it 'does not create a reference for an external user for an internal snippet' do
+ expect(visible_references(:internal, external_user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is internal and the snippets feature is enabled for project team members' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::INTERNAL)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
+ end
+
+ context 'when a project is private and the snippets feature is enabled for project team members' do
+ before do
+ project.update_attribute(:visibility, Gitlab::VisibilityLevel::PRIVATE)
+ project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::PRIVATE)
+ end
+
+ it 'creates a reference for a project member for a public snippet' do
+ expect(visible_references(:public, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for guest for a public snippet' do
+ expect(visible_references(:public, nil)).to be_empty
+ end
+
+ it 'does not create a reference for a regular user for a public snippet' do
+ expect(visible_references(:public, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for an internal snippet' do
+ expect(visible_references(:internal, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for an internal snippet' do
+ expect(visible_references(:internal, user)).to be_empty
+ end
+
+ it 'creates a reference for a project member for a private snippet' do
+ expect(visible_references(:private, project_member)).to eq([link])
+ end
+
+ it 'does not create a reference for a regular user for a private snippet' do
+ expect(visible_references(:private, user)).to be_empty
+ end
end
end
describe '#referenced_by' do
+ let(:snippet) { create(:snippet, project: project) }
describe 'when the link has a data-snippet attribute' do
context 'using an existing snippet ID' do
it 'returns an Array of snippets' do
@@ -31,7 +210,7 @@ describe Banzai::ReferenceParser::SnippetParser, lib: true do
it 'returns an empty Array' do
link['data-snippet'] = ''
- expect(subject.referenced_by([link])).to eq([])
+ expect(subject.referenced_by([link])).to be_empty
end
end
end
diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb
index 4ec998efe53..e49726aca6c 100644
--- a/spec/lib/banzai/reference_parser/user_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::UserParser, lib: true do
+describe Banzai::ReferenceParser::UserParser do
include ReferenceParserHelpers
let(:group) { create(:group) }
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public, group: group, creator: user) }
+ let(:project) { create(:project, :public, group: group, creator: user) }
subject { described_class.new(project, user) }
let(:link) { empty_html_link }
@@ -42,6 +42,20 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
expect(subject.referenced_by([link])).to eq([user])
end
+
+ context 'when RequestStore is active', :request_store do
+ let(:other_user) { create(:user) }
+
+ it 'does not return users from the first call in the second' do
+ link['data-user'] = user.id.to_s
+
+ expect(subject.referenced_by([link])).to eq([user])
+
+ link['data-user'] = other_user.id.to_s
+
+ expect(subject.referenced_by([link])).to eq([other_user])
+ end
+ end
end
context 'when the link has a data-project attribute' do
@@ -74,7 +88,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
end
end
- describe '#nodes_visible_to_use?' do
+ describe '#nodes_visible_to_user' do
context 'when the link has a data-group attribute' do
context 'using an existing group ID' do
before do
@@ -82,17 +96,17 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
end
it 'returns the nodes if the user can read the group' do
- expect(Ability).to receive(:allowed?).
- with(user, :read_group, group).
- and_return(true)
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_group, group)
+ .and_return(true)
expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
end
it 'returns an empty Array if the user can not read the group' do
- expect(Ability).to receive(:allowed?).
- with(user, :read_group, group).
- and_return(false)
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_group, group)
+ .and_return(false)
expect(subject.nodes_visible_to_user(user, [link])).to eq([])
end
@@ -111,25 +125,25 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
end
it 'returns the nodes if the user can read the project' do
- other_project = create(:empty_project, :public)
+ other_project = create(:project, :public)
link['data-project'] = other_project.id.to_s
- expect(Ability).to receive(:allowed?).
- with(user, :read_project, other_project).
- and_return(true)
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_project, other_project)
+ .and_return(true)
expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
end
it 'returns an empty Array if the user can not read the project' do
- other_project = create(:empty_project, :public)
+ other_project = create(:project, :public)
link['data-project'] = other_project.id.to_s
- expect(Ability).to receive(:allowed?).
- with(user, :read_project, other_project).
- and_return(false)
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_project, other_project)
+ .and_return(false)
expect(subject.nodes_visible_to_user(user, [link])).to eq([])
end
@@ -147,7 +161,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
describe '#nodes_user_can_reference' do
context 'when the link has a data-author attribute' do
it 'returns the nodes when the user is a member of the project' do
- other_project = create(:empty_project)
+ other_project = create(:project)
other_project.team << [user, :developer]
link['data-project'] = other_project.id.to_s
@@ -164,7 +178,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
end
it 'returns an empty Array when the user could not be found' do
- other_project = create(:empty_project)
+ other_project = create(:project)
link['data-project'] = other_project.id.to_s
link['data-author'] = ''
@@ -173,7 +187,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do
end
it 'returns an empty Array when the user is not a team member' do
- other_project = create(:empty_project)
+ other_project = create(:project)
link['data-project'] = other_project.id.to_s
link['data-author'] = user.id.to_s
diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb
index e6f8d2a1fed..0e094405e33 100644
--- a/spec/lib/banzai/renderer_spec.rb
+++ b/spec/lib/banzai/renderer_spec.rb
@@ -11,7 +11,7 @@ describe Banzai::Renderer do
end
describe '#render_field' do
- let(:renderer) { Banzai::Renderer }
+ let(:renderer) { described_class }
subject { renderer.render_field(object, :field) }
context 'with a stale cache' do
diff --git a/spec/lib/bitbucket/paginator_spec.rb b/spec/lib/bitbucket/paginator_spec.rb
index 2c972da682e..bdf10a5e2a2 100644
--- a/spec/lib/bitbucket/paginator_spec.rb
+++ b/spec/lib/bitbucket/paginator_spec.rb
@@ -15,7 +15,7 @@ describe Bitbucket::Paginator do
expect(paginator.items).to match(['item_2'])
allow(paginator).to receive(:fetch_next_page).and_return(nil)
- expect{ paginator.items }.to raise_error(StopIteration)
+ expect { paginator.items }.to raise_error(StopIteration)
end
end
end
diff --git a/spec/lib/ci/ansi2html_spec.rb b/spec/lib/ci/ansi2html_spec.rb
index a5dfb49478a..e49ecadde20 100644
--- a/spec/lib/ci/ansi2html_spec.rb
+++ b/spec/lib/ci/ansi2html_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::Ansi2html, lib: true do
+describe Ci::Ansi2html do
subject { described_class }
it "prints non-ansi as-is" do
diff --git a/spec/lib/ci/charts_spec.rb b/spec/lib/ci/charts_spec.rb
index fb6cc398307..f0769deef21 100644
--- a/spec/lib/ci/charts_spec.rb
+++ b/spec/lib/ci/charts_spec.rb
@@ -1,21 +1,21 @@
require 'spec_helper'
-describe Ci::Charts, lib: true do
- context "build_times" do
- let(:project) { create(:empty_project) }
- let(:chart) { Ci::Charts::BuildTime.new(project) }
+describe Ci::Charts do
+ context "pipeline_times" do
+ let(:project) { create(:project) }
+ let(:chart) { Ci::Charts::PipelineTime.new(project) }
- subject { chart.build_times }
+ subject { chart.pipeline_times }
before do
create(:ci_empty_pipeline, project: project, duration: 120)
end
- it 'returns build times in minutes' do
+ it 'returns pipeline times in minutes' do
is_expected.to contain_exactly(2)
end
- it 'handles nil build times' do
+ it 'handles nil pipeline times' do
create(:ci_empty_pipeline, project: project, duration: nil)
is_expected.to contain_exactly(2, 0)
diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
index 53abc056602..ee28387cd48 100644
--- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
+++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
module Ci
- describe GitlabCiYamlProcessor, lib: true do
+ describe GitlabCiYamlProcessor, :lib do
+ subject { described_class.new(config, path) }
let(:path) { 'path' }
describe 'our current .gitlab-ci.yml' do
@@ -31,6 +32,28 @@ module Ci
end
end
+ describe 'retry entry' do
+ context 'when retry count is specified' do
+ let(:config) do
+ YAML.dump(rspec: { script: 'rspec', retry: 1 })
+ end
+
+ it 'includes retry count in build options attribute' do
+ expect(subject[:options]).to include(retry: 1)
+ end
+ end
+
+ context 'when retry count is not specified' do
+ let(:config) do
+ YAML.dump(rspec: { script: 'rspec' })
+ end
+
+ it 'does not persist retry count in the database' do
+ expect(subject[:options]).not_to have_key(:retry)
+ end
+ end
+ end
+
describe 'allow failure entry' do
context 'when job is a manual action' do
context 'when allow_failure is defined' do
@@ -82,6 +105,67 @@ module Ci
end
end
+ describe '#stage_seeds' do
+ context 'when no refs policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod' },
+ rspec: { stage: 'test', script: 'rspec' },
+ spinach: { stage: 'test', script: 'spinach' })
+ end
+
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'correctly fabricates a stage seeds object' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 2
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.second.stage[:name]).to eq 'deploy'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'rspec'
+ expect(seeds.first.builds.dig(1, :name)).to eq 'spinach'
+ expect(seeds.second.builds.dig(0, :name)).to eq 'production'
+ end
+ end
+
+ context 'when refs policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['master'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['tags'] })
+ end
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline, ref: 'feature', tag: true)
+ end
+
+ it 'returns stage seeds only assigned to master to master' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
+ end
+ end
+
+ context 'when source policy is specified' do
+ let(:config) do
+ YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['schedules'] })
+ end
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline, source: :schedule)
+ end
+
+ it 'returns stage seeds only assigned to schedules' do
+ seeds = subject.stage_seeds(pipeline)
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.stage[:name]).to eq 'test'
+ expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
+ end
+ end
+ end
+
describe "#builds_for_ref" do
let(:type) { 'test' }
@@ -101,7 +185,10 @@ module Ci
commands: "pwd\nrspec",
coverage_regex: nil,
tag_list: [],
- options: {},
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"]
+ },
allow_failure: false,
when: "on_success",
environment: nil,
@@ -176,26 +263,44 @@ module Ci
expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0)
end
- it "returns builds if only has a triggers keyword specified and a trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["triggers"] }
- })
+ it "returns builds if only has special keywords specified and source matches" do
+ possibilities = [{ keyword: 'pushes', source: 'push' },
+ { keyword: 'web', source: 'web' },
+ { keyword: 'triggers', source: 'trigger' },
+ { keyword: 'schedules', source: 'schedule' },
+ { keyword: 'api', source: 'api' },
+ { keyword: 'external', source: 'external' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(1)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1)
+ end
end
- it "does not return builds if only has a triggers keyword specified and no trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["triggers"] }
- })
+ it "does not return builds if only has special keywords specified and source doesn't match" do
+ possibilities = [{ keyword: 'pushes', source: 'web' },
+ { keyword: 'web', source: 'push' },
+ { keyword: 'triggers', source: 'schedule' },
+ { keyword: 'schedules', source: 'external' },
+ { keyword: 'api', source: 'trigger' },
+ { keyword: 'external', source: 'api' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0)
+ end
end
it "returns builds if only has current repository path" do
@@ -225,7 +330,7 @@ module Ci
before_script: ["pwd"],
rspec: { script: "rspec", type: "test", only: %w(master deploy) },
staging: { script: "deploy", type: "deploy", only: %w(master deploy) },
- production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] },
+ production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] }
})
config_processor = GitlabCiYamlProcessor.new(config, 'fork')
@@ -332,26 +437,44 @@ module Ci
expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1)
end
- it "does not return builds if except has a triggers keyword specified and a trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["triggers"] }
- })
+ it "does not return builds if except has special keywords specified and source matches" do
+ possibilities = [{ keyword: 'pushes', source: 'push' },
+ { keyword: 'web', source: 'web' },
+ { keyword: 'triggers', source: 'trigger' },
+ { keyword: 'schedules', source: 'schedule' },
+ { keyword: 'api', source: 'api' },
+ { keyword: 'external', source: 'external' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(0)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0)
+ end
end
- it "returns builds if except has a triggers keyword specified and no trigger is provided" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["triggers"] }
- })
+ it "returns builds if except has special keywords specified and source doesn't match" do
+ possibilities = [{ keyword: 'pushes', source: 'web' },
+ { keyword: 'web', source: 'push' },
+ { keyword: 'triggers', source: 'schedule' },
+ { keyword: 'schedules', source: 'external' },
+ { keyword: 'api', source: 'trigger' },
+ { keyword: 'external', source: 'api' }]
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ possibilities.each do |possibility|
+ config = YAML.dump({
+ before_script: ["pwd"],
+ rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
+ })
- expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1)
+ end
end
it "does not return builds if except has current repository path" do
@@ -381,7 +504,7 @@ module Ci
before_script: ["pwd"],
rspec: { script: "rspec", type: "test", except: ["master", "deploy", "test@fork"] },
staging: { script: "deploy", type: "deploy", except: ["master"] },
- production: { script: "deploy", type: "deploy", except: ["master@fork"] },
+ production: { script: "deploy", type: "deploy", except: ["master@fork"] }
})
config_processor = GitlabCiYamlProcessor.new(config, 'fork')
@@ -498,62 +621,134 @@ module Ci
end
describe "Image and service handling" do
- it "returns image and service when defined" do
- config = YAML.dump({
- image: "ruby:2.1",
- services: ["mysql"],
- before_script: ["pwd"],
- rspec: { script: "rspec" }
- })
+ context "when extended docker configuration is used" do
+ it "returns image and service when defined" do
+ config = YAML.dump({ image: { name: "ruby:2.1", entrypoint: ["/usr/local/bin/init", "run"] },
+ services: ["mysql", { name: "docker:dind", alias: "docker",
+ entrypoint: ["/usr/local/bin/init", "run"],
+ command: ["/usr/local/bin/init", "run"] }],
+ before_script: ["pwd"],
+ rspec: { script: "rspec" } })
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
- expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
- expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
- stage: "test",
- stage_idx: 1,
- name: "rspec",
- commands: "pwd\nrspec",
- coverage_regex: nil,
- tag_list: [],
- options: {
- image: "ruby:2.1",
- services: ["mysql"]
- },
- allow_failure: false,
- when: "on_success",
- environment: nil,
- yaml_variables: []
- })
+ expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
+ expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
+ stage: "test",
+ stage_idx: 1,
+ name: "rspec",
+ commands: "pwd\nrspec",
+ coverage_regex: nil,
+ tag_list: [],
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"],
+ image: { name: "ruby:2.1", entrypoint: ["/usr/local/bin/init", "run"] },
+ services: [{ name: "mysql" },
+ { name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"],
+ command: ["/usr/local/bin/init", "run"] }]
+ },
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ yaml_variables: []
+ })
+ end
+
+ it "returns image and service when overridden for job" do
+ config = YAML.dump({ image: "ruby:2.1",
+ services: ["mysql"],
+ before_script: ["pwd"],
+ rspec: { image: { name: "ruby:2.5", entrypoint: ["/usr/local/bin/init", "run"] },
+ services: [{ name: "postgresql", alias: "db-pg",
+ entrypoint: ["/usr/local/bin/init", "run"],
+ command: ["/usr/local/bin/init", "run"] }, "docker:dind"],
+ script: "rspec" } })
+
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
+ expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
+ stage: "test",
+ stage_idx: 1,
+ name: "rspec",
+ commands: "pwd\nrspec",
+ coverage_regex: nil,
+ tag_list: [],
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"],
+ image: { name: "ruby:2.5", entrypoint: ["/usr/local/bin/init", "run"] },
+ services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"],
+ command: ["/usr/local/bin/init", "run"] },
+ { name: "docker:dind" }]
+ },
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ yaml_variables: []
+ })
+ end
end
- it "returns image and service when overridden for job" do
- config = YAML.dump({
- image: "ruby:2.1",
- services: ["mysql"],
- before_script: ["pwd"],
- rspec: { image: "ruby:2.5", services: ["postgresql"], script: "rspec" }
- })
+ context "when etended docker configuration is not used" do
+ it "returns image and service when defined" do
+ config = YAML.dump({ image: "ruby:2.1",
+ services: ["mysql", "docker:dind"],
+ before_script: ["pwd"],
+ rspec: { script: "rspec" } })
- config_processor = GitlabCiYamlProcessor.new(config, path)
+ config_processor = GitlabCiYamlProcessor.new(config, path)
- expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
- expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
- stage: "test",
- stage_idx: 1,
- name: "rspec",
- commands: "pwd\nrspec",
- coverage_regex: nil,
- tag_list: [],
- options: {
- image: "ruby:2.5",
- services: ["postgresql"]
- },
- allow_failure: false,
- when: "on_success",
- environment: nil,
- yaml_variables: []
- })
+ expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
+ expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
+ stage: "test",
+ stage_idx: 1,
+ name: "rspec",
+ commands: "pwd\nrspec",
+ coverage_regex: nil,
+ tag_list: [],
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"],
+ image: { name: "ruby:2.1" },
+ services: [{ name: "mysql" }, { name: "docker:dind" }]
+ },
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ yaml_variables: []
+ })
+ end
+
+ it "returns image and service when overridden for job" do
+ config = YAML.dump({ image: "ruby:2.1",
+ services: ["mysql"],
+ before_script: ["pwd"],
+ rspec: { image: "ruby:2.5", services: ["postgresql", "docker:dind"], script: "rspec" } })
+
+ config_processor = GitlabCiYamlProcessor.new(config, path)
+
+ expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(1)
+ expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({
+ stage: "test",
+ stage_idx: 1,
+ name: "rspec",
+ commands: "pwd\nrspec",
+ coverage_regex: nil,
+ tag_list: [],
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"],
+ image: { name: "ruby:2.5" },
+ services: [{ name: "postgresql" }, { name: "docker:dind" }]
+ },
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ yaml_variables: []
+ })
+ end
end
end
@@ -717,6 +912,7 @@ module Ci
paths: ["logs/", "binaries/"],
untracked: true,
key: 'key',
+ policy: 'pull-push'
)
end
@@ -735,6 +931,7 @@ module Ci
paths: ["logs/", "binaries/"],
untracked: true,
key: 'key',
+ policy: 'pull-push'
)
end
@@ -743,7 +940,7 @@ module Ci
cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' },
rspec: {
script: "rspec",
- cache: { paths: ["test/"], untracked: false, key: 'local' },
+ cache: { paths: ["test/"], untracked: false, key: 'local' }
}
})
@@ -754,6 +951,7 @@ module Ci
paths: ["test/"],
untracked: false,
key: 'local',
+ policy: 'pull-push'
)
end
end
@@ -786,8 +984,10 @@ module Ci
coverage_regex: nil,
tag_list: [],
options: {
- image: "ruby:2.1",
- services: ["mysql"],
+ before_script: ["pwd"],
+ script: ["rspec"],
+ image: { name: "ruby:2.1" },
+ services: [{ name: "mysql" }],
artifacts: {
name: "custom_name",
paths: ["logs/", "binaries/"],
@@ -997,7 +1197,9 @@ module Ci
commands: "test",
coverage_regex: nil,
tag_list: [],
- options: {},
+ options: {
+ script: ["test"]
+ },
when: "on_success",
allow_failure: false,
environment: nil,
@@ -1043,7 +1245,9 @@ module Ci
commands: "execute-script-for-job",
coverage_regex: nil,
tag_list: [],
- options: {},
+ options: {
+ script: ["execute-script-for-job"]
+ },
when: "on_success",
allow_failure: false,
environment: nil,
@@ -1056,7 +1260,9 @@ module Ci
commands: "execute-script-for-job",
coverage_regex: nil,
tag_list: [],
- options: {},
+ options: {
+ script: ["execute-script-for-job"]
+ },
when: "on_success",
allow_failure: false,
environment: nil,
@@ -1117,11 +1323,11 @@ EOT
describe "Error handling" do
it "fails to parse YAML" do
- expect{GitlabCiYamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError)
+ expect {GitlabCiYamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError)
end
it "indicates that object is invalid" do
- expect{GitlabCiYamlProcessor.new("invalid_yaml")}.to raise_error(GitlabCiYamlProcessor::ValidationError)
+ expect {GitlabCiYamlProcessor.new("invalid_yaml")}.to raise_error(GitlabCiYamlProcessor::ValidationError)
end
it "returns errors if tags parameter is invalid" do
@@ -1163,7 +1369,7 @@ EOT
config = YAML.dump({ image: ["test"], rspec: { script: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "image config should be a string")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "image config should be a hash or a string")
end
it "returns errors if job name is blank" do
@@ -1184,35 +1390,35 @@ EOT
config = YAML.dump({ rspec: { script: "test", image: ["test"] } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:image config should be a string")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:image config should be a hash or a string")
end
it "returns errors if services parameter is not an array" do
config = YAML.dump({ services: "test", rspec: { script: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "services config should be an array of strings")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "services config should be a array")
end
it "returns errors if services parameter is not an array of strings" do
config = YAML.dump({ services: [10, "test"], rspec: { script: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "services config should be an array of strings")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "service config should be a hash or a string")
end
it "returns errors if job services parameter is not an array" do
config = YAML.dump({ rspec: { script: "test", services: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:services config should be an array of strings")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:services config should be a array")
end
it "returns errors if job services parameter is not an array of strings" do
config = YAML.dump({ rspec: { script: "test", services: [10, "test"] } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:services config should be an array of strings")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "service config should be a hash or a string")
end
it "returns error if job configuration is invalid" do
@@ -1226,7 +1432,7 @@ EOT
config = YAML.dump({ extra: { script: 'rspec', services: "test" } })
expect do
GitlabCiYamlProcessor.new(config, path)
- end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra:services config should be an array of strings")
+ end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra:services config should be a array")
end
it "returns errors if there are no jobs defined" do
diff --git a/spec/lib/ci/mask_secret_spec.rb b/spec/lib/ci/mask_secret_spec.rb
index 3101bed20fb..f7b753b022b 100644
--- a/spec/lib/ci/mask_secret_spec.rb
+++ b/spec/lib/ci/mask_secret_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::MaskSecret, lib: true do
+describe Ci::MaskSecret do
subject { described_class }
describe '#mask' do
diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb
index 96dacdc5cd2..4dab58b26a0 100644
--- a/spec/lib/constraints/group_url_constrainer_spec.rb
+++ b/spec/lib/constraints/group_url_constrainer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GroupUrlConstrainer, lib: true do
+describe GroupUrlConstrainer do
let!(:group) { create(:group, path: 'gitlab') }
describe '#matches?' do
@@ -17,14 +17,49 @@ describe GroupUrlConstrainer, lib: true do
it { expect(subject.matches?(request)).to be_truthy }
end
+ context 'valid request for nested group with reserved top level name' do
+ let!(:nested_group) { create(:group, path: 'api', parent: group) }
+ let!(:request) { build_request('gitlab/api') }
+
+ it { expect(subject.matches?(request)).to be_truthy }
+ end
+
context 'invalid request' do
let(:request) { build_request('foo') }
it { expect(subject.matches?(request)).to be_falsey }
end
+
+ context 'when the request matches a redirect route' do
+ context 'for a root group' do
+ let!(:redirect_route) { group.redirect_routes.create!(path: 'gitlabb') }
+
+ context 'and is a GET request' do
+ let(:request) { build_request(redirect_route.path) }
+
+ it { expect(subject.matches?(request)).to be_truthy }
+ end
+
+ context 'and is NOT a GET request' do
+ let(:request) { build_request(redirect_route.path, 'POST') }
+
+ it { expect(subject.matches?(request)).to be_falsey }
+ end
+ end
+
+ context 'for a nested group' do
+ let!(:nested_group) { create(:group, path: 'nested', parent: group) }
+ let!(:redirect_route) { nested_group.redirect_routes.create!(path: 'gitlabb/nested') }
+ let(:request) { build_request(redirect_route.path) }
+
+ it { expect(subject.matches?(request)).to be_truthy }
+ end
+ end
end
- def build_request(path)
- double(:request, params: { id: path })
+ def build_request(path, method = 'GET')
+ double(:request,
+ 'get?': (method == 'GET'),
+ params: { id: path })
end
end
diff --git a/spec/lib/constraints/project_url_constrainer_spec.rb b/spec/lib/constraints/project_url_constrainer_spec.rb
index 4f25ad88960..92331eb2e5d 100644
--- a/spec/lib/constraints/project_url_constrainer_spec.rb
+++ b/spec/lib/constraints/project_url_constrainer_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe ProjectUrlConstrainer, lib: true do
- let!(:project) { create(:empty_project) }
+describe ProjectUrlConstrainer do
+ let!(:project) { create(:project) }
let!(:namespace) { project.namespace }
describe '#matches?' do
@@ -24,9 +24,26 @@ describe ProjectUrlConstrainer, lib: true do
it { expect(subject.matches?(request)).to be_falsey }
end
end
+
+ context 'when the request matches a redirect route' do
+ let(:old_project_path) { 'old_project_path' }
+ let!(:redirect_route) { project.redirect_routes.create!(path: "#{namespace.full_path}/#{old_project_path}") }
+
+ context 'and is a GET request' do
+ let(:request) { build_request(namespace.full_path, old_project_path) }
+ it { expect(subject.matches?(request)).to be_truthy }
+ end
+
+ context 'and is NOT a GET request' do
+ let(:request) { build_request(namespace.full_path, old_project_path, 'POST') }
+ it { expect(subject.matches?(request)).to be_falsey }
+ end
+ end
end
- def build_request(namespace, project)
- double(:request, params: { namespace_id: namespace, id: project })
+ def build_request(namespace, project, method = 'GET')
+ double(:request,
+ 'get?': (method == 'GET'),
+ params: { namespace_id: namespace, id: project })
end
end
diff --git a/spec/lib/constraints/user_url_constrainer_spec.rb b/spec/lib/constraints/user_url_constrainer_spec.rb
index 207b6fe6c9e..cb3b4ff1391 100644
--- a/spec/lib/constraints/user_url_constrainer_spec.rb
+++ b/spec/lib/constraints/user_url_constrainer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe UserUrlConstrainer, lib: true do
+describe UserUrlConstrainer do
let!(:user) { create(:user, username: 'dz') }
describe '#matches?' do
@@ -15,9 +15,26 @@ describe UserUrlConstrainer, lib: true do
it { expect(subject.matches?(request)).to be_falsey }
end
+
+ context 'when the request matches a redirect route' do
+ let(:old_project_path) { 'old_project_path' }
+ let!(:redirect_route) { user.namespace.redirect_routes.create!(path: 'foo') }
+
+ context 'and is a GET request' do
+ let(:request) { build_request(redirect_route.path) }
+ it { expect(subject.matches?(request)).to be_truthy }
+ end
+
+ context 'and is NOT a GET request' do
+ let(:request) { build_request(redirect_route.path, 'POST') }
+ it { expect(subject.matches?(request)).to be_falsey }
+ end
+ end
end
- def build_request(username)
- double(:request, params: { username: username })
+ def build_request(username, method = 'GET')
+ double(:request,
+ 'get?': (method == 'GET'),
+ params: { username: username })
end
end
diff --git a/spec/lib/container_registry/blob_spec.rb b/spec/lib/container_registry/blob_spec.rb
index f06e5fd54a2..c73faa55513 100644
--- a/spec/lib/container_registry/blob_spec.rb
+++ b/spec/lib/container_registry/blob_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe ContainerRegistry::Blob do
let(:group) { create(:group, name: 'group') }
- let(:project) { create(:empty_project, path: 'test', group: group) }
+ let(:project) { create(:project, path: 'test', group: group) }
let(:repository) do
create(:container_repository, name: 'image',
@@ -72,8 +72,8 @@ describe ContainerRegistry::Blob do
describe '#data' do
context 'when locally stored' do
before do
- stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345').
- to_return(
+ stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345')
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: '{"key":"value"}')
@@ -97,9 +97,9 @@ describe ContainerRegistry::Blob do
context 'for a valid address' do
before do
- stub_request(:get, location).
- with(headers: { 'Authorization' => nil }).
- to_return(
+ stub_request(:get, location)
+ .with { |request| !request.headers.include?('Authorization') }
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: '{"key":"value"}')
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
new file mode 100644
index 00000000000..3df33f48adb
--- /dev/null
+++ b/spec/lib/container_registry/client_spec.rb
@@ -0,0 +1,39 @@
+# coding: utf-8
+require 'spec_helper'
+
+describe ContainerRegistry::Client do
+ let(:token) { '12345' }
+ let(:options) { { token: token } }
+ let(:client) { described_class.new("http://container-registry", options) }
+
+ describe '#blob' do
+ it 'GET /v2/:name/blobs/:digest' do
+ stub_request(:get, "http://container-registry/v2/group/test/blobs/sha256:0123456789012345")
+ .with(headers: {
+ 'Accept' => 'application/octet-stream',
+ 'Authorization' => "bearer #{token}"
+ })
+ .to_return(status: 200, body: "Blob")
+
+ expect(client.blob('group/test', 'sha256:0123456789012345')).to eq('Blob')
+ end
+
+ it 'follows 307 redirect for GET /v2/:name/blobs/:digest' do
+ stub_request(:get, "http://container-registry/v2/group/test/blobs/sha256:0123456789012345")
+ .with(headers: {
+ 'Accept' => 'application/octet-stream',
+ 'Authorization' => "bearer #{token}"
+ })
+ .to_return(status: 307, body: "", headers: { Location: 'http://redirected' })
+ # We should probably use hash_excluding here, but that requires an update to WebMock:
+ # https://github.com/bblimke/webmock/blob/master/lib/webmock/matchers/hash_excluding_matcher.rb
+ stub_request(:get, "http://redirected/")
+ .with { |request| !request.headers.include?('Authorization') }
+ .to_return(status: 200, body: "Successfully redirected")
+
+ response = client.blob('group/test', 'sha256:0123456789012345')
+
+ expect(response).to eq('Successfully redirected')
+ end
+ end
+end
diff --git a/spec/lib/container_registry/path_spec.rb b/spec/lib/container_registry/path_spec.rb
index c2bcb54210b..84cacdd3f0d 100644
--- a/spec/lib/container_registry/path_spec.rb
+++ b/spec/lib/container_registry/path_spec.rb
@@ -90,7 +90,7 @@ describe ContainerRegistry::Path do
describe '#has_repository?' do
context 'when project exists' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:path) { "#{project.full_path}/my/image" }
context 'when path already has matching repository' do
@@ -123,8 +123,8 @@ describe ContainerRegistry::Path do
let(:path) { 'some_group/some_project' }
before do
- create(:empty_project, group: group, name: 'some_project')
- create(:empty_project, name: 'some_project')
+ create(:project, group: group, name: 'some_project')
+ create(:project, name: 'some_project')
end
it 'returns a correct project' do
@@ -142,7 +142,7 @@ describe ContainerRegistry::Path do
context 'when matching multi-level path' do
let(:project) do
- create(:empty_project, group: group, name: 'some_project')
+ create(:project, group: group, name: 'some_project')
end
context 'when using the zero-level path' do
@@ -192,7 +192,7 @@ describe ContainerRegistry::Path do
let(:group) { create(:group, path: 'Some_Group') }
before do
- create(:empty_project, group: group, name: 'some_project')
+ create(:project, group: group, name: 'some_project')
end
context 'when project path equal repository path' do
@@ -235,7 +235,7 @@ describe ContainerRegistry::Path do
let(:group) { create(:group, path: 'SomeGroup') }
before do
- create(:empty_project, group: group, name: 'MyProject')
+ create(:project, group: group, name: 'MyProject')
end
it 'returns downcased project path' do
diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb
index f8fffbdca41..e76463b5e7c 100644
--- a/spec/lib/container_registry/tag_spec.rb
+++ b/spec/lib/container_registry/tag_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe ContainerRegistry::Tag do
let(:group) { create(:group, name: 'group') }
- let(:project) { create(:project, path: 'test', group: group) }
+ let(:project) { create(:project, :repository, path: 'test', group: group) }
let(:repository) do
create(:container_repository, name: '', project: project)
@@ -60,9 +60,9 @@ describe ContainerRegistry::Tag do
context 'manifest processing' do
context 'schema v1' do
before do
- stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag').
- with(headers: headers).
- to_return(
+ stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag')
+ .with(headers: headers)
+ .to_return(
status: 200,
body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest_1.json'),
headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' })
@@ -97,9 +97,9 @@ describe ContainerRegistry::Tag do
context 'schema v2' do
before do
- stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag').
- with(headers: headers).
- to_return(
+ stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag')
+ .with(headers: headers)
+ .to_return(
status: 200,
body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'),
headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' })
@@ -134,9 +134,9 @@ describe ContainerRegistry::Tag do
context 'when locally stored' do
before do
- stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
- with(headers: { 'Accept' => 'application/octet-stream' }).
- to_return(
+ stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac')
+ .with(headers: { 'Accept' => 'application/octet-stream' })
+ .to_return(
status: 200,
body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json'))
end
@@ -146,14 +146,14 @@ describe ContainerRegistry::Tag do
context 'when externally stored' do
before do
- stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
- with(headers: { 'Accept' => 'application/octet-stream' }).
- to_return(
+ stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac')
+ .with(headers: { 'Accept' => 'application/octet-stream' })
+ .to_return(
status: 307,
headers: { 'Location' => 'http://external.com/blob/file' })
- stub_request(:get, 'http://external.com/blob/file').
- to_return(
+ stub_request(:get, 'http://external.com/blob/file')
+ .to_return(
status: 200,
body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json'))
end
diff --git a/spec/lib/disable_email_interceptor_spec.rb b/spec/lib/disable_email_interceptor_spec.rb
index 8f51474476d..3652d928c43 100644
--- a/spec/lib/disable_email_interceptor_spec.rb
+++ b/spec/lib/disable_email_interceptor_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe DisableEmailInterceptor, lib: true do
+describe DisableEmailInterceptor do
before do
- Mail.register_interceptor(DisableEmailInterceptor)
+ Mail.register_interceptor(described_class)
end
it 'does not send emails' do
@@ -14,7 +14,7 @@ describe DisableEmailInterceptor, lib: true do
# Removing interceptor from the list because unregister_interceptor is
# implemented in later version of mail gem
# See: https://github.com/mikel/mail/pull/705
- Mail.unregister_interceptor(DisableEmailInterceptor)
+ Mail.unregister_interceptor(described_class)
end
def deliver_mail
diff --git a/spec/lib/event_filter_spec.rb b/spec/lib/event_filter_spec.rb
index d70690f589d..87ae6b6cf01 100644
--- a/spec/lib/event_filter_spec.rb
+++ b/spec/lib/event_filter_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe EventFilter, lib: true do
+describe EventFilter do
describe '#apply_filter' do
let(:source_user) { create(:user) }
- let!(:public_project) { create(:empty_project, :public) }
+ let!(:public_project) { create(:project, :public) }
- let!(:push_event) { create(:event, :pushed, project: public_project, target: public_project, author: source_user) }
+ let!(:push_event) { create(:push_event, project: public_project, author: source_user) }
let!(:merged_event) { create(:event, :merged, project: public_project, target: public_project, author: source_user) }
let!(:created_event) { create(:event, :created, project: public_project, target: public_project, author: source_user) }
let!(:updated_event) { create(:event, :updated, project: public_project, target: public_project, author: source_user) }
@@ -16,42 +16,42 @@ describe EventFilter, lib: true do
let!(:left_event) { create(:event, :left, project: public_project, target: public_project, author: source_user) }
it 'applies push filter' do
- events = EventFilter.new(EventFilter.push).apply_filter(Event.all)
+ events = described_class.new(described_class.push).apply_filter(Event.all)
expect(events).to contain_exactly(push_event)
end
it 'applies merged filter' do
- events = EventFilter.new(EventFilter.merged).apply_filter(Event.all)
+ events = described_class.new(described_class.merged).apply_filter(Event.all)
expect(events).to contain_exactly(merged_event)
end
it 'applies issue filter' do
- events = EventFilter.new(EventFilter.issue).apply_filter(Event.all)
+ events = described_class.new(described_class.issue).apply_filter(Event.all)
expect(events).to contain_exactly(created_event, updated_event, closed_event, reopened_event)
end
it 'applies comments filter' do
- events = EventFilter.new(EventFilter.comments).apply_filter(Event.all)
+ events = described_class.new(described_class.comments).apply_filter(Event.all)
expect(events).to contain_exactly(comments_event)
end
it 'applies team filter' do
- events = EventFilter.new(EventFilter.team).apply_filter(Event.all)
+ events = described_class.new(described_class.team).apply_filter(Event.all)
expect(events).to contain_exactly(joined_event, left_event)
end
it 'applies all filter' do
- events = EventFilter.new(EventFilter.all).apply_filter(Event.all)
+ events = described_class.new(described_class.all).apply_filter(Event.all)
expect(events).to contain_exactly(push_event, merged_event, created_event, updated_event, closed_event, reopened_event, comments_event, joined_event, left_event)
end
it 'applies no filter' do
- events = EventFilter.new(nil).apply_filter(Event.all)
+ events = described_class.new(nil).apply_filter(Event.all)
expect(events).to contain_exactly(push_event, merged_event, created_event, updated_event, closed_event, reopened_event, comments_event, joined_event, left_event)
end
it 'applies unknown filter' do
- events = EventFilter.new('').apply_filter(Event.all)
+ events = described_class.new('').apply_filter(Event.all)
expect(events).to contain_exactly(push_event, merged_event, created_event, updated_event, closed_event, reopened_event, comments_event, joined_event, left_event)
end
end
diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb
index 90628917943..7faa0f31b68 100644
--- a/spec/lib/expand_variables_spec.rb
+++ b/spec/lib/expand_variables_spec.rb
@@ -25,7 +25,7 @@ describe ExpandVariables do
result: 'keyvalueresult',
variables: [
{ key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
+ { key: 'variable2', value: 'result' }
] },
{ value: 'key${variable}${variable2}',
result: 'keyvalueresult',
@@ -37,7 +37,7 @@ describe ExpandVariables do
result: 'keyresultvalue',
variables: [
{ key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
+ { key: 'variable2', value: 'result' }
] },
{ value: 'key${variable2}${variable}',
result: 'keyresultvalue',
@@ -49,7 +49,7 @@ describe ExpandVariables do
result: 'review/feature/add-review-apps',
variables: [
{ key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' }
- ] },
+ ] }
]
tests.each do |test|
diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb
index 33ab005667a..e13406d1972 100644
--- a/spec/lib/extracts_path_spec.rb
+++ b/spec/lib/extracts_path_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe ExtractsPath, lib: true do
- include ExtractsPath
+describe ExtractsPath do
+ include described_class
include RepoHelpers
- include Gitlab::Routing.url_helpers
+ include Gitlab::Routing
let(:project) { double('project') }
let(:request) { double('request') }
@@ -14,8 +14,8 @@ describe ExtractsPath, lib: true do
repo = double(ref_names: ['master', 'foo/bar/baz', 'v1.0.0', 'v2.0.0',
'release/app', 'release/app/v1.0.0'])
allow(project).to receive(:repository).and_return(repo)
- allow(project).to receive(:path_with_namespace).
- and_return('gitlab/gitlab-ci')
+ allow(project).to receive(:full_path)
+ .and_return('gitlab/gitlab-ci')
allow(request).to receive(:format=)
end
@@ -29,7 +29,7 @@ describe ExtractsPath, lib: true do
it "log tree path has no escape sequences" do
assign_ref_vars
- expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb")
+ expect(@logs_path).to eq("/#{@project.full_path}/refs/#{ref}/logs_tree/files/ruby/popen.rb")
end
context 'ref contains %20' do
@@ -56,7 +56,7 @@ describe ExtractsPath, lib: true do
context 'subclass overrides get_id' do
it 'uses ref returned by get_id' do
- allow_any_instance_of(self.class).to receive(:get_id){ '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' }
+ allow_any_instance_of(self.class).to receive(:get_id) { '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' }
assign_ref_vars
@@ -77,7 +77,10 @@ describe ExtractsPath, lib: true do
context 'without a path' do
let(:params) { { ref: 'v1.0.0.atom' } }
- before { assign_ref_vars }
+
+ before do
+ assign_ref_vars
+ end
it 'sets the un-suffixed version as @ref' do
expect(@ref).to eq('v1.0.0')
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
new file mode 100644
index 00000000000..1076c63b5f2
--- /dev/null
+++ b/spec/lib/feature_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Feature do
+ describe '.get' do
+ let(:feature) { double(:feature) }
+ let(:key) { 'my_feature' }
+
+ it 'returns the Flipper feature' do
+ expect_any_instance_of(Flipper::DSL).to receive(:feature).with(key)
+ .and_return(feature)
+
+ expect(described_class.get(key)).to be(feature)
+ end
+ end
+
+ describe '.all' do
+ let(:features) { Set.new }
+
+ it 'returns the Flipper features as an array' do
+ expect_any_instance_of(Flipper::DSL).to receive(:features)
+ .and_return(features)
+
+ expect(described_class.all).to eq(features.to_a)
+ end
+ end
+end
diff --git a/spec/lib/file_size_validator_spec.rb b/spec/lib/file_size_validator_spec.rb
index fda6f9a6c88..c44bc1840df 100644
--- a/spec/lib/file_size_validator_spec.rb
+++ b/spec/lib/file_size_validator_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe FileSizeValidator, lib: true do
- let(:validator) { FileSizeValidator.new(options) }
+describe FileSizeValidator do
+ let(:validator) { described_class.new(options) }
let(:attachment) { AttachmentUploader.new }
let(:note) { create(:note) }
@@ -24,13 +24,13 @@ describe FileSizeValidator, lib: true do
describe 'options uses a symbol' do
let(:options) do
{
- maximum: :test,
+ maximum: :max_attachment_size,
attributes: { attachment: attachment }
}
end
before do
- allow(note).to receive(:test) { 10 }
+ expect(note).to receive(:max_attachment_size) { 10 }
end
it 'attachment exceeds maximum limit' do
diff --git a/spec/lib/git_ref_validator_spec.rb b/spec/lib/git_ref_validator_spec.rb
deleted file mode 100644
index cc8daa535d6..00000000000
--- a/spec/lib/git_ref_validator_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::GitRefValidator, lib: true do
- it { expect(Gitlab::GitRefValidator.validate('feature/new')).to be_truthy }
- it { expect(Gitlab::GitRefValidator.validate('implement_@all')).to be_truthy }
- it { expect(Gitlab::GitRefValidator.validate('my_new_feature')).to be_truthy }
- it { expect(Gitlab::GitRefValidator.validate('#1')).to be_truthy }
- it { expect(Gitlab::GitRefValidator.validate('feature/refs/heads/foo')).to be_truthy }
- it { expect(Gitlab::GitRefValidator.validate('feature/~new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/^new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/:new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/?new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/*new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/[new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/new/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature/new.')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature\@{')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature\new')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature//new')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('feature new')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('refs/heads/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('refs/remotes/')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('refs/heads/feature')).to be_falsey }
- it { expect(Gitlab::GitRefValidator.validate('refs/remotes/origin')).to be_falsey }
-end
diff --git a/spec/lib/gitlab/allowable_spec.rb b/spec/lib/gitlab/allowable_spec.rb
index 87733d53e92..9d80d480b52 100644
--- a/spec/lib/gitlab/allowable_spec.rb
+++ b/spec/lib/gitlab/allowable_spec.rb
@@ -9,7 +9,7 @@ describe Gitlab::Allowable do
let(:user) { create(:user) }
context 'when user is allowed to do something' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
it 'reports correct ability to perform action' do
expect(subject.can?(user, :read_project, project)).to be true
@@ -17,7 +17,7 @@ describe Gitlab::Allowable do
end
context 'when user is not allowed to do something' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it 'reports correct ability to perform action' do
expect(subject.can?(user, :read_project, project)).to be false
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index 0f47fb2fbd9..f668f78c2b8 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
require 'nokogiri'
module Gitlab
- describe Asciidoc, lib: true do
+ describe Asciidoc do
let(:input) { '<b>ascii</b>' }
let(:context) { {} }
let(:html) { 'H<sub>2</sub>O' }
@@ -22,7 +22,22 @@ module Gitlab
expect(Asciidoctor).to receive(:convert)
.with(input, expected_asciidoc_opts).and_return(html)
- expect(render(input)).to eq(html)
+ expect(render(input, context)).to eq(html)
+ end
+
+ context "with asciidoc_opts" do
+ it "merges the options with default ones" do
+ expected_asciidoc_opts = {
+ safe: :secure,
+ backend: :gitlab_html5,
+ attributes: described_class::DEFAULT_ADOC_ATTRS
+ }
+
+ expect(Asciidoctor).to receive(:convert)
+ .with(input, expected_asciidoc_opts).and_return(html)
+
+ render(input, context)
+ end
end
context "XSS" do
@@ -33,7 +48,7 @@ module Gitlab
},
'images' => {
input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]',
- output: "<div>\n<p><span><img src=\"https://localhost.com/image.png\" alt=\"Alt text\"></span></p>\n</div>"
+ output: "<img src=\"https://localhost.com/image.png\" alt=\"Alt text\">"
},
'pre' => {
input: '```mypre"><script>alert(3)</script>',
@@ -43,10 +58,43 @@ module Gitlab
links.each do |name, data|
it "does not convert dangerous #{name} into HTML" do
- expect(render(data[:input])).to eq(data[:output])
+ expect(render(data[:input], context)).to include(data[:output])
end
end
end
+
+ context 'external links' do
+ it 'adds the `rel` attribute to the link' do
+ output = render('link:https://google.com[Google]', context)
+
+ expect(output).to include('rel="nofollow noreferrer noopener"')
+ end
+ end
+
+ context 'LaTex code' do
+ it 'adds class js-render-math to the output' do
+ input = <<~MD
+ :stem: latexmath
+
+ [stem]
+ ++++
+ \sqrt{4} = 2
+ ++++
+
+ another part
+
+ [latexmath]
+ ++++
+ \beta_x \gamma
+ ++++
+
+ stem:[2+2] is 4
+ MD
+
+ expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math"><code>eta_x gamma</code></pre>')
+ expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>')
+ end
+ end
end
def render(*args)
diff --git a/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb b/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb
index 94dcddcc30c..f29431b937c 100644
--- a/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb
+++ b/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Auth::UniqueIpsLimiter, :redis, lib: true do
+describe Gitlab::Auth::UniqueIpsLimiter, :clean_gitlab_redis_shared_state do
include_context 'unique ips sign in limit'
let(:user) { create(:user) }
@@ -40,7 +40,9 @@ describe Gitlab::Auth::UniqueIpsLimiter, :redis, lib: true do
end
context 'allow 2 unique ips' do
- before { current_application_settings.update!(unique_ips_limit_per_user: 2) }
+ before do
+ current_application_settings.update!(unique_ips_limit_per_user: 2)
+ end
it 'blocks user trying to login from third ip' do
change_ip('ip1')
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index d4a43192d03..4a498e79c87 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Auth, lib: true do
+describe Gitlab::Auth do
let(:gl_auth) { described_class }
describe 'constants' do
@@ -17,7 +17,11 @@ describe Gitlab::Auth, lib: true do
end
it 'OPTIONAL_SCOPES contains all non-default scopes' do
- expect(subject::OPTIONAL_SCOPES).to eq [:read_user, :openid]
+ expect(subject::OPTIONAL_SCOPES).to eq %i[read_user read_registry openid]
+ end
+
+ it 'REGISTRY_SCOPES contains all registry related scopes' do
+ expect(subject::REGISTRY_SCOPES).to eq %i[read_registry]
end
end
@@ -61,7 +65,7 @@ describe Gitlab::Auth, lib: true do
end
it 'recognizes other ci services' do
- project = create(:empty_project)
+ project = create(:project)
project.create_drone_ci_service(active: true)
project.drone_ci_service.update(token: 'token')
@@ -143,6 +147,13 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities))
end
+ it 'succeeds for personal access tokens with the `read_registry` scope' do
+ personal_access_token = create(:personal_access_token, scopes: ['read_registry'])
+
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '')
+ expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [:read_container_image]))
+ end
+
it 'succeeds if it is an impersonation token' do
impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api'])
@@ -150,18 +161,11 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities))
end
- it 'fails for personal access tokens with other scopes' do
+ it 'limits abilities based on scope' do
personal_access_token = create(:personal_access_token, scopes: ['read_user'])
- expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '')
- expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil))
- end
-
- it 'fails for impersonation token with other scopes' do
- impersonation_token = create(:personal_access_token, scopes: ['read_user'])
-
- expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '')
- expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil))
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '')
+ expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, []))
end
it 'fails if password is nil' do
@@ -175,7 +179,7 @@ describe Gitlab::Auth, lib: true do
user = create(
:user,
username: 'normal_user',
- password: 'my-secret',
+ password: 'my-secret'
)
expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip'))
@@ -186,7 +190,7 @@ describe Gitlab::Auth, lib: true do
user = create(
:user,
username: 'oauth2',
- password: 'my-secret',
+ password: 'my-secret'
)
expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip'))
@@ -200,6 +204,12 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new)
end
+
+ it 'throws an error suggesting user create a PAT when internal auth is disabled' do
+ allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled?) { false }
+
+ expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalTokenError)
+ end
end
describe 'find_with_user_password' do
@@ -269,6 +279,16 @@ describe Gitlab::Auth, lib: true do
gl_auth.find_with_user_password('ldap_user', 'password')
end
end
+
+ context "with sign-in disabled" do
+ before do
+ stub_application_setting(password_authentication_enabled: false)
+ end
+
+ it "does not find user by valid login/password" do
+ expect(gl_auth.find_with_user_password(username, password)).to be_nil
+ end
+ end
end
private
@@ -293,7 +313,8 @@ describe Gitlab::Auth, lib: true do
def full_authentication_abilities
read_authentication_abilities + [
:push_code,
- :create_container_image
+ :create_container_image,
+ :admin_container_image
]
end
end
diff --git a/spec/lib/gitlab/backend/shell_spec.rb b/spec/lib/gitlab/backend/shell_spec.rb
deleted file mode 100644
index 6675d26734e..00000000000
--- a/spec/lib/gitlab/backend/shell_spec.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-require 'spec_helper'
-require 'stringio'
-
-describe Gitlab::Shell, lib: true do
- let(:project) { double('Project', id: 7, path: 'diaspora') }
- let(:gitlab_shell) { Gitlab::Shell.new }
-
- before do
- allow(Project).to receive(:find).and_return(project)
- end
-
- it { is_expected.to respond_to :add_key }
- it { is_expected.to respond_to :remove_key }
- it { is_expected.to respond_to :add_repository }
- it { is_expected.to respond_to :remove_repository }
- it { is_expected.to respond_to :fork_repository }
- it { is_expected.to respond_to :add_namespace }
- it { is_expected.to respond_to :rm_namespace }
- it { is_expected.to respond_to :mv_namespace }
- it { is_expected.to respond_to :exists? }
-
- it { expect(gitlab_shell.url_to_repo('diaspora')).to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "diaspora.git") }
-
- describe 'memoized secret_token' do
- let(:secret_file) { 'tmp/tests/.secret_shell_test' }
- let(:link_file) { 'tmp/tests/shell-secret-test/.gitlab_shell_secret' }
-
- before do
- allow(Gitlab.config.gitlab_shell).to receive(:secret_file).and_return(secret_file)
- allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-secret-test')
- FileUtils.mkdir('tmp/tests/shell-secret-test')
- Gitlab::Shell.ensure_secret_token!
- end
-
- after do
- FileUtils.rm_rf('tmp/tests/shell-secret-test')
- FileUtils.rm_rf(secret_file)
- end
-
- it 'creates and links the secret token file' do
- secret_token = Gitlab::Shell.secret_token
-
- expect(File.exist?(secret_file)).to be(true)
- expect(File.read(secret_file).chomp).to eq(secret_token)
- expect(File.symlink?(link_file)).to be(true)
- expect(File.readlink(link_file)).to eq(secret_file)
- end
- end
-
- describe '#add_key' do
- it 'removes trailing garbage' do
- allow(gitlab_shell).to receive(:gitlab_shell_keys_path).and_return(:gitlab_shell_keys_path)
- expect(Gitlab::Utils).to receive(:system_silent).with(
- [:gitlab_shell_keys_path, 'add-key', 'key-123', 'ssh-rsa foobar']
- )
-
- gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage')
- end
- end
-
- describe Gitlab::Shell::KeyAdder, lib: true do
- describe '#add_key' do
- it 'removes trailing garbage' do
- io = spy(:io)
- adder = described_class.new(io)
-
- adder.add_key('key-42', "ssh-rsa foo bar\tbaz")
-
- expect(io).to have_received(:puts).with("key-42\tssh-rsa foo")
- end
-
- it 'handles multiple spaces in the key' do
- io = spy(:io)
- adder = described_class.new(io)
-
- adder.add_key('key-42', "ssh-rsa foo")
-
- expect(io).to have_received(:puts).with("key-42\tssh-rsa foo")
- end
-
- it 'raises an exception if the key contains a tab' do
- expect do
- described_class.new(StringIO.new).add_key('key-42', "ssh-rsa\tfoobar")
- end.to raise_error(Gitlab::Shell::Error)
- end
-
- it 'raises an exception if the key contains a newline' do
- expect do
- described_class.new(StringIO.new).add_key('key-42', "ssh-rsa foobar\nssh-rsa pawned")
- end.to raise_error(Gitlab::Shell::Error)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
new file mode 100644
index 00000000000..7cd2ce82eda
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
@@ -0,0 +1,199 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits do
+ describe '#perform' do
+ set(:merge_request) { create(:merge_request) }
+ set(:merge_request_diff) { merge_request.merge_request_diff }
+ let(:updated_merge_request_diff) { MergeRequestDiff.find(merge_request_diff.id) }
+
+ def diffs_to_hashes(diffs)
+ diffs.as_json(only: Gitlab::Git::Diff::SERIALIZE_KEYS).map(&:with_indifferent_access)
+ end
+
+ def quote_yaml(value)
+ MergeRequestDiff.connection.quote(YAML.dump(value))
+ end
+
+ def convert_to_yaml(merge_request_diff_id, commits, diffs)
+ MergeRequestDiff.where(id: merge_request_diff_id).update_all(
+ "st_commits = #{quote_yaml(commits)}, st_diffs = #{quote_yaml(diffs)}"
+ )
+ end
+
+ shared_examples 'updated MR diff' do
+ before do
+ convert_to_yaml(merge_request_diff.id, commits, diffs)
+
+ MergeRequestDiffCommit.delete_all
+ MergeRequestDiffFile.delete_all
+
+ subject.perform(merge_request_diff.id, merge_request_diff.id)
+ end
+
+ it 'creates correct entries in the merge_request_diff_commits table' do
+ expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(commits.count)
+ expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(commits)
+ end
+
+ it 'creates correct entries in the merge_request_diff_files table' do
+ expect(updated_merge_request_diff.merge_request_diff_files.count).to eq(expected_diffs.count)
+ expect(diffs_to_hashes(updated_merge_request_diff.raw_diffs)).to eq(expected_diffs)
+ end
+
+ it 'sets the st_commits and st_diffs columns to nil' do
+ expect(updated_merge_request_diff.st_commits_before_type_cast).to be_nil
+ expect(updated_merge_request_diff.st_diffs_before_type_cast).to be_nil
+ end
+ end
+
+ context 'when the diff IDs passed do not exist' do
+ it 'does not raise' do
+ expect { subject.perform(0, 0) }.not_to raise_exception
+ end
+ end
+
+ context 'when the merge request diff has no serialised commits or diffs' do
+ before do
+ merge_request_diff.update(st_commits: nil, st_diffs: nil)
+ end
+
+ it 'does not raise' do
+ expect { subject.perform(merge_request_diff.id, merge_request_diff.id) }
+ .not_to raise_exception
+ end
+ end
+
+ context 'processing multiple merge request diffs' do
+ let(:start_id) { described_class::MergeRequestDiff.minimum(:id) }
+ let(:stop_id) { described_class::MergeRequestDiff.maximum(:id) }
+
+ before do
+ merge_request.reload_diff(true)
+
+ convert_to_yaml(start_id, merge_request_diff.commits, merge_request_diff.diffs)
+ convert_to_yaml(stop_id, updated_merge_request_diff.commits, updated_merge_request_diff.diffs)
+
+ MergeRequestDiffCommit.delete_all
+ MergeRequestDiffFile.delete_all
+ end
+
+ context 'when BUFFER_ROWS is exceeded' do
+ before do
+ stub_const("#{described_class}::BUFFER_ROWS", 1)
+ end
+
+ it 'updates and continues' do
+ expect(described_class::MergeRequestDiff).to receive(:transaction).twice
+
+ subject.perform(start_id, stop_id)
+ end
+ end
+
+ context 'when BUFFER_ROWS is not exceeded' do
+ it 'only updates once' do
+ expect(described_class::MergeRequestDiff).to receive(:transaction).once
+
+ subject.perform(start_id, stop_id)
+ end
+ end
+ end
+
+ context 'when the merge request diff update fails' do
+ before do
+ allow(described_class::MergeRequestDiff)
+ .to receive(:update_all).and_raise(ActiveRecord::Rollback)
+ end
+
+ it 'does not add any diff commits' do
+ expect { subject.perform(merge_request_diff.id, merge_request_diff.id) }
+ .not_to change { MergeRequestDiffCommit.count }
+ end
+
+ it 'does not add any diff files' do
+ expect { subject.perform(merge_request_diff.id, merge_request_diff.id) }
+ .not_to change { MergeRequestDiffFile.count }
+ end
+ end
+
+ context 'when the merge request diff has valid commits and diffs' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
+ let(:expected_diffs) { diffs }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diffs do not have too_large set' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
+
+ let(:diffs) do
+ expected_diffs.map { |diff| diff.except(:too_large) }
+ end
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diffs have binary content' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_diffs) { diffs }
+
+ # The start of a PDF created by Illustrator
+ let(:binary_string) do
+ "\x25\x50\x44\x46\x2d\x31\x2e\x35\x0d\x25\xe2\xe3\xcf\xd3\x0d\x0a".force_encoding(Encoding::BINARY)
+ end
+
+ let(:diffs) do
+ [
+ {
+ 'diff' => binary_string,
+ 'new_path' => 'path',
+ 'old_path' => 'path',
+ 'a_mode' => '100644',
+ 'b_mode' => '100644',
+ 'new_file' => false,
+ 'renamed_file' => false,
+ 'deleted_file' => false,
+ 'too_large' => false
+ }
+ ]
+ end
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diff has commits, but no diffs' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:diffs) { [] }
+ let(:expected_diffs) { diffs }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diffs have invalid content' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:diffs) { ['--broken-diff'] }
+ let(:expected_diffs) { [] }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diffs are Rugged::Patch instances' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:diffs) { first_commit.rugged_diff_from_parent.patches }
+ let(:expected_diffs) { [] }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diffs are Rugged::Diff::Delta instances' do
+ let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:diffs) { first_commit.rugged_diff_from_parent.deltas }
+ let(:expected_diffs) { [] }
+
+ include_examples 'updated MR diff'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb b/spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb
new file mode 100644
index 00000000000..0d5fffa38ff
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb
@@ -0,0 +1,412 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::MigrateEventsToPushEventPayloads::Event do
+ describe '#commit_title' do
+ it 'returns nil when there are no commits' do
+ expect(described_class.new.commit_title).to be_nil
+ end
+
+ it 'returns nil when there are commits without commit messages' do
+ event = described_class.new
+
+ allow(event).to receive(:commits).and_return([{ id: '123' }])
+
+ expect(event.commit_title).to be_nil
+ end
+
+ it 'returns the commit message when it is less than 70 characters long' do
+ event = described_class.new
+
+ allow(event).to receive(:commits).and_return([{ message: 'Hello world' }])
+
+ expect(event.commit_title).to eq('Hello world')
+ end
+
+ it 'returns the first line of a commit message if multiple lines are present' do
+ event = described_class.new
+
+ allow(event).to receive(:commits).and_return([{ message: "Hello\n\nworld" }])
+
+ expect(event.commit_title).to eq('Hello')
+ end
+
+ it 'truncates the commit to 70 characters when it is too long' do
+ event = described_class.new
+
+ allow(event).to receive(:commits).and_return([{ message: 'a' * 100 }])
+
+ expect(event.commit_title).to eq(('a' * 67) + '...')
+ end
+ end
+
+ describe '#commit_from_sha' do
+ it 'returns nil when pushing to a new ref' do
+ event = described_class.new
+
+ allow(event).to receive(:create?).and_return(true)
+
+ expect(event.commit_from_sha).to be_nil
+ end
+
+ it 'returns the ID of the first commit when pushing to an existing ref' do
+ event = described_class.new
+
+ allow(event).to receive(:create?).and_return(false)
+ allow(event).to receive(:data).and_return(before: '123')
+
+ expect(event.commit_from_sha).to eq('123')
+ end
+ end
+
+ describe '#commit_to_sha' do
+ it 'returns nil when removing an existing ref' do
+ event = described_class.new
+
+ allow(event).to receive(:remove?).and_return(true)
+
+ expect(event.commit_to_sha).to be_nil
+ end
+
+ it 'returns the ID of the last commit when pushing to an existing ref' do
+ event = described_class.new
+
+ allow(event).to receive(:remove?).and_return(false)
+ allow(event).to receive(:data).and_return(after: '123')
+
+ expect(event.commit_to_sha).to eq('123')
+ end
+ end
+
+ describe '#data' do
+ it 'returns the deserialized data' do
+ event = described_class.new(data: { before: '123' })
+
+ expect(event.data).to eq(before: '123')
+ end
+
+ it 'returns an empty hash when no data is present' do
+ event = described_class.new
+
+ expect(event.data).to eq({})
+ end
+ end
+
+ describe '#commits' do
+ it 'returns an Array of commits' do
+ event = described_class.new(data: { commits: [{ id: '123' }] })
+
+ expect(event.commits).to eq([{ id: '123' }])
+ end
+
+ it 'returns an empty array when no data is present' do
+ event = described_class.new
+
+ expect(event.commits).to eq([])
+ end
+ end
+
+ describe '#commit_count' do
+ it 'returns the number of commits' do
+ event = described_class.new(data: { total_commits_count: 2 })
+
+ expect(event.commit_count).to eq(2)
+ end
+
+ it 'returns 0 when no data is present' do
+ event = described_class.new
+
+ expect(event.commit_count).to eq(0)
+ end
+ end
+
+ describe '#ref' do
+ it 'returns the name of the ref' do
+ event = described_class.new(data: { ref: 'refs/heads/master' })
+
+ expect(event.ref).to eq('refs/heads/master')
+ end
+ end
+
+ describe '#trimmed_ref_name' do
+ it 'returns the trimmed ref name for a branch' do
+ event = described_class.new(data: { ref: 'refs/heads/master' })
+
+ expect(event.trimmed_ref_name).to eq('master')
+ end
+
+ it 'returns the trimmed ref name for a tag' do
+ event = described_class.new(data: { ref: 'refs/tags/v1.2' })
+
+ expect(event.trimmed_ref_name).to eq('v1.2')
+ end
+ end
+
+ describe '#create?' do
+ it 'returns true when creating a new ref' do
+ event = described_class.new(data: { before: described_class::BLANK_REF })
+
+ expect(event.create?).to eq(true)
+ end
+
+ it 'returns false when pushing to an existing ref' do
+ event = described_class.new(data: { before: '123' })
+
+ expect(event.create?).to eq(false)
+ end
+ end
+
+ describe '#remove?' do
+ it 'returns true when removing an existing ref' do
+ event = described_class.new(data: { after: described_class::BLANK_REF })
+
+ expect(event.remove?).to eq(true)
+ end
+
+ it 'returns false when pushing to an existing ref' do
+ event = described_class.new(data: { after: '123' })
+
+ expect(event.remove?).to eq(false)
+ end
+ end
+
+ describe '#push_action' do
+ let(:event) { described_class.new }
+
+ it 'returns :created when creating a new ref' do
+ allow(event).to receive(:create?).and_return(true)
+
+ expect(event.push_action).to eq(:created)
+ end
+
+ it 'returns :removed when removing an existing ref' do
+ allow(event).to receive(:create?).and_return(false)
+ allow(event).to receive(:remove?).and_return(true)
+
+ expect(event.push_action).to eq(:removed)
+ end
+
+ it 'returns :pushed when pushing to an existing ref' do
+ allow(event).to receive(:create?).and_return(false)
+ allow(event).to receive(:remove?).and_return(false)
+
+ expect(event.push_action).to eq(:pushed)
+ end
+ end
+
+ describe '#ref_type' do
+ let(:event) { described_class.new }
+
+ it 'returns :tag for a tag' do
+ allow(event).to receive(:ref).and_return('refs/tags/1.2')
+
+ expect(event.ref_type).to eq(:tag)
+ end
+
+ it 'returns :branch for a branch' do
+ allow(event).to receive(:ref).and_return('refs/heads/1.2')
+
+ expect(event.ref_type).to eq(:branch)
+ end
+ end
+end
+
+##
+# The background migration relies on a temporary table, hence we're migrating
+# to a specific version of the database where said table is still present.
+#
+describe Gitlab::BackgroundMigration::MigrateEventsToPushEventPayloads, :migration, schema: 20170608152748 do
+ let(:migration) { described_class.new }
+ let(:project) { create(:project_empty_repo) }
+ let(:author) { create(:user) }
+
+ # We can not rely on FactoryGirl as the state of Event may change in ways that
+ # the background migration does not expect, hence we use the Event class of
+ # the migration itself.
+ def create_push_event(project, author, data = nil)
+ klass = Gitlab::BackgroundMigration::MigrateEventsToPushEventPayloads::Event
+
+ klass.create!(
+ action: klass::PUSHED,
+ project_id: project.id,
+ author_id: author.id,
+ data: data
+ )
+ end
+
+ describe '#perform' do
+ it 'returns if data should not be migrated' do
+ allow(migration).to receive(:migrate?).and_return(false)
+
+ expect(migration).not_to receive(:find_events)
+
+ migration.perform(1, 10)
+ end
+
+ it 'migrates the range of events if data is to be migrated' do
+ event1 = create_push_event(project, author, { commits: [] })
+ event2 = create_push_event(project, author, { commits: [] })
+
+ allow(migration).to receive(:migrate?).and_return(true)
+
+ expect(migration).to receive(:process_event).twice
+
+ migration.perform(event1.id, event2.id)
+ end
+ end
+
+ describe '#process_event' do
+ it 'processes a regular event' do
+ event = double(:event, push_event?: false)
+
+ expect(migration).to receive(:replicate_event)
+ expect(migration).not_to receive(:create_push_event_payload)
+
+ migration.process_event(event)
+ end
+
+ it 'processes a push event' do
+ event = double(:event, push_event?: true)
+
+ expect(migration).to receive(:replicate_event)
+ expect(migration).to receive(:create_push_event_payload)
+
+ migration.process_event(event)
+ end
+ end
+
+ describe '#replicate_event' do
+ it 'replicates the event to the "events_for_migration" table' do
+ event = create_push_event(
+ project,
+ author,
+ data: { commits: [] },
+ title: 'bla'
+ )
+
+ attributes = event
+ .attributes.with_indifferent_access.except(:title, :data)
+
+ expect(described_class::EventForMigration)
+ .to receive(:create!)
+ .with(attributes)
+
+ migration.replicate_event(event)
+ end
+ end
+
+ describe '#create_push_event_payload' do
+ let(:push_data) do
+ {
+ commits: [],
+ ref: 'refs/heads/master',
+ before: '156e0e9adc587a383a7eeb5b21ddecb9044768a8',
+ after: '0' * 40,
+ total_commits_count: 1
+ }
+ end
+
+ let(:event) do
+ create_push_event(project, author, push_data)
+ end
+
+ before do
+ # The foreign key in push_event_payloads at this point points to the
+ # "events_for_migration" table so we need to make sure a row exists in
+ # said table.
+ migration.replicate_event(event)
+ end
+
+ it 'creates a push event payload for an event' do
+ payload = migration.create_push_event_payload(event)
+
+ expect(PushEventPayload.count).to eq(1)
+ expect(payload.valid?).to eq(true)
+ end
+
+ it 'does not create push event payloads for removed events' do
+ allow(event).to receive(:id).and_return(-1)
+
+ payload = migration.create_push_event_payload(event)
+
+ expect(payload).to be_nil
+ expect(PushEventPayload.count).to eq(0)
+ end
+
+ it 'encodes and decodes the commit IDs from and to binary data' do
+ payload = migration.create_push_event_payload(event)
+ packed = migration.pack(push_data[:before])
+
+ expect(payload.commit_from).to eq(packed)
+ expect(payload.commit_to).to be_nil
+ end
+ end
+
+ describe '#find_events' do
+ it 'returns the events for the given ID range' do
+ event1 = create_push_event(project, author, { commits: [] })
+ event2 = create_push_event(project, author, { commits: [] })
+ event3 = create_push_event(project, author, { commits: [] })
+ events = migration.find_events(event1.id, event2.id)
+
+ expect(events.length).to eq(2)
+ expect(events.pluck(:id)).not_to include(event3.id)
+ end
+ end
+
+ describe '#migrate?' do
+ it 'returns true when data should be migrated' do
+ allow(described_class::Event)
+ .to receive(:table_exists?).and_return(true)
+
+ allow(described_class::PushEventPayload)
+ .to receive(:table_exists?).and_return(true)
+
+ allow(described_class::EventForMigration)
+ .to receive(:table_exists?).and_return(true)
+
+ expect(migration.migrate?).to eq(true)
+ end
+
+ it 'returns false if the "events" table does not exist' do
+ allow(described_class::Event)
+ .to receive(:table_exists?).and_return(false)
+
+ expect(migration.migrate?).to eq(false)
+ end
+
+ it 'returns false if the "push_event_payloads" table does not exist' do
+ allow(described_class::Event)
+ .to receive(:table_exists?).and_return(true)
+
+ allow(described_class::PushEventPayload)
+ .to receive(:table_exists?).and_return(false)
+
+ expect(migration.migrate?).to eq(false)
+ end
+
+ it 'returns false when the "events_for_migration" table does not exist' do
+ allow(described_class::Event)
+ .to receive(:table_exists?).and_return(true)
+
+ allow(described_class::PushEventPayload)
+ .to receive(:table_exists?).and_return(true)
+
+ allow(described_class::EventForMigration)
+ .to receive(:table_exists?).and_return(false)
+
+ expect(migration.migrate?).to eq(false)
+ end
+ end
+
+ describe '#pack' do
+ it 'packs a SHA1 into a 20 byte binary string' do
+ packed = migration.pack('156e0e9adc587a383a7eeb5b21ddecb9044768a8')
+
+ expect(packed.bytesize).to eq(20)
+ end
+
+ it 'returns nil if the input value is nil' do
+ expect(migration.pack(nil)).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder_spec.rb b/spec/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder_spec.rb
new file mode 100644
index 00000000000..59f69d1e4b1
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::MigrateSystemUploadsToNewFolder do
+ let(:migration) { described_class.new }
+
+ before do
+ allow(migration).to receive(:logger).and_return(Logger.new(nil))
+ end
+
+ describe '#perform' do
+ it 'renames the path of system-uploads', truncate: true do
+ upload = create(:upload, model: create(:project), path: 'uploads/system/project/avatar.jpg')
+
+ migration.perform('uploads/system/', 'uploads/-/system/')
+
+ expect(upload.reload.path).to eq('uploads/-/system/project/avatar.jpg')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/move_personal_snippet_files_spec.rb b/spec/lib/gitlab/background_migration/move_personal_snippet_files_spec.rb
new file mode 100644
index 00000000000..ee60e498b59
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/move_personal_snippet_files_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::MovePersonalSnippetFiles do
+ let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'move_snippet_files_test') }
+ let(:old_uploads_dir) { File.join('uploads', 'system', 'personal_snippet') }
+ let(:new_uploads_dir) { File.join('uploads', '-', 'system', 'personal_snippet') }
+ let(:snippet) do
+ snippet = create(:personal_snippet)
+ create_upload_for_snippet(snippet)
+ snippet.update_attributes!(description: markdown_linking_file(snippet))
+ snippet
+ end
+
+ let(:migration) { described_class.new }
+
+ before do
+ allow(migration).to receive(:base_directory) { test_dir }
+ end
+
+ describe '#perform' do
+ it 'moves the file on the disk' do
+ expected_path = File.join(test_dir, new_uploads_dir, snippet.id.to_s, "secret#{snippet.id}", 'upload.txt')
+
+ migration.perform(old_uploads_dir, new_uploads_dir)
+
+ expect(File.exist?(expected_path)).to be_truthy
+ end
+
+ it 'updates the markdown of the snippet' do
+ expected_path = File.join(new_uploads_dir, snippet.id.to_s, "secret#{snippet.id}", 'upload.txt')
+ expected_markdown = "[an upload](#{expected_path})"
+
+ migration.perform(old_uploads_dir, new_uploads_dir)
+
+ expect(snippet.reload.description).to eq(expected_markdown)
+ end
+
+ it 'updates the markdown of notes' do
+ expected_path = File.join(new_uploads_dir, snippet.id.to_s, "secret#{snippet.id}", 'upload.txt')
+ expected_markdown = "with [an upload](#{expected_path})"
+
+ note = create(:note_on_personal_snippet, noteable: snippet, note: "with #{markdown_linking_file(snippet)}")
+
+ migration.perform(old_uploads_dir, new_uploads_dir)
+
+ expect(note.reload.note).to eq(expected_markdown)
+ end
+ end
+
+ def create_upload_for_snippet(snippet)
+ snippet_path = path_for_file_in_snippet(snippet)
+ path = File.join(old_uploads_dir, snippet.id.to_s, snippet_path)
+ absolute_path = File.join(test_dir, path)
+
+ FileUtils.mkdir_p(File.dirname(absolute_path))
+ FileUtils.touch(absolute_path)
+
+ create(:upload, model: snippet, path: snippet_path, uploader: PersonalFileUploader)
+ end
+
+ def path_for_file_in_snippet(snippet)
+ secret = "secret#{snippet.id}"
+ filename = 'upload.txt'
+
+ File.join(secret, filename)
+ end
+
+ def markdown_linking_file(snippet)
+ path = File.join(old_uploads_dir, snippet.id.to_s, path_for_file_in_snippet(snippet))
+ "[an upload](#{path})"
+ end
+end
diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb
new file mode 100644
index 00000000000..4ad69aeba43
--- /dev/null
+++ b/spec/lib/gitlab/background_migration_spec.rb
@@ -0,0 +1,122 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration do
+ describe '.queue' do
+ it 'returns background migration worker queue' do
+ expect(described_class.queue)
+ .to eq BackgroundMigrationWorker.sidekiq_options['queue']
+ end
+ end
+
+ describe '.steal' do
+ context 'when there are enqueued jobs present' do
+ let(:queue) do
+ [double(args: ['Foo', [10, 20]], queue: described_class.queue)]
+ end
+
+ before do
+ allow(Sidekiq::Queue).to receive(:new)
+ .with(described_class.queue)
+ .and_return(queue)
+ end
+
+ context 'when queue contains unprocessed jobs' do
+ it 'steals jobs from a queue' do
+ expect(queue[0]).to receive(:delete).and_return(true)
+
+ expect(described_class).to receive(:perform)
+ .with('Foo', [10, 20])
+
+ described_class.steal('Foo')
+ end
+
+ it 'does not steal job that has already been taken' do
+ expect(queue[0]).to receive(:delete).and_return(false)
+
+ expect(described_class).not_to receive(:perform)
+
+ described_class.steal('Foo')
+ end
+
+ it 'does not steal jobs for a different migration' do
+ expect(described_class).not_to receive(:perform)
+
+ expect(queue[0]).not_to receive(:delete)
+
+ described_class.steal('Bar')
+ end
+ end
+
+ context 'when one of the jobs raises an error' do
+ let(:migration) { spy(:migration) }
+
+ let(:queue) do
+ [double(args: ['Foo', [10, 20]], queue: described_class.queue),
+ double(args: ['Foo', [20, 30]], queue: described_class.queue)]
+ end
+
+ before do
+ stub_const("#{described_class}::Foo", migration)
+
+ allow(queue[0]).to receive(:delete).and_return(true)
+ allow(queue[1]).to receive(:delete).and_return(true)
+ end
+
+ it 'enqueues the migration again and re-raises the error' do
+ allow(migration).to receive(:perform).with(10, 20)
+ .and_raise(Exception, 'Migration error').once
+
+ expect(BackgroundMigrationWorker).to receive(:perform_async)
+ .with('Foo', [10, 20]).once
+
+ expect { described_class.steal('Foo') }.to raise_error(Exception)
+ end
+ end
+ end
+
+ context 'when there are scheduled jobs present', :sidekiq, :redis do
+ it 'steals all jobs from the scheduled sets' do
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker.perform_in(10.minutes, 'Object')
+
+ expect(Sidekiq::ScheduledSet.new).to be_one
+ expect(described_class).to receive(:perform).with('Object', any_args)
+
+ described_class.steal('Object')
+
+ expect(Sidekiq::ScheduledSet.new).to be_none
+ end
+ end
+ end
+
+ context 'when there are enqueued and scheduled jobs present', :sidekiq, :redis do
+ it 'steals from the scheduled sets queue first' do
+ Sidekiq::Testing.disable! do
+ expect(described_class).to receive(:perform)
+ .with('Object', [1]).ordered
+ expect(described_class).to receive(:perform)
+ .with('Object', [2]).ordered
+
+ BackgroundMigrationWorker.perform_async('Object', [2])
+ BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1])
+
+ described_class.steal('Object')
+ end
+ end
+ end
+ end
+
+ describe '.perform' do
+ let(:migration) { spy(:migration) }
+
+ before do
+ stub_const("#{described_class.name}::Foo", migration)
+ end
+
+ it 'performs a background migration' do
+ expect(migration).to receive(:perform).with(10, 20).once
+
+ described_class.perform('Foo', [10, 20])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb
index f84782ab440..8772d3d5ada 100644
--- a/spec/lib/gitlab/backup/manager_spec.rb
+++ b/spec/lib/gitlab/backup/manager_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Backup::Manager, lib: true do
+describe Backup::Manager do
include StubENV
let(:progress) { StringIO.new }
@@ -24,8 +24,9 @@ describe Backup::Manager, lib: true do
describe '#remove_old' do
let(:files) do
[
- '1451606400_2016_01_01_gitlab_backup.tar',
- '1451520000_2015_12_31_gitlab_backup.tar',
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6_gitlab_backup.tar',
+ '1451510000_2015_12_30_gitlab_backup.tar',
'1450742400_2015_12_22_gitlab_backup.tar',
'1449878400_gitlab_backup.tar',
'1449014400_gitlab_backup.tar',
@@ -58,6 +59,7 @@ describe Backup::Manager, lib: true do
context 'when there are no files older than keep_time' do
before do
+ # Set to 30 days
allow(Gitlab.config.backup).to receive(:keep_time).and_return(2592000)
subject.remove_old
@@ -74,19 +76,24 @@ describe Backup::Manager, lib: true do
context 'when keep_time is set to remove files' do
before do
+ # Set to 1 second
allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
subject.remove_old
end
- it 'removes matching files with a human-readable timestamp' do
+ it 'removes matching files with a human-readable versioned timestamp' do
expect(FileUtils).to have_received(:rm).with(files[1])
+ end
+
+ it 'removes matching files with a human-readable non-versioned timestamp' do
expect(FileUtils).to have_received(:rm).with(files[2])
+ expect(FileUtils).to have_received(:rm).with(files[3])
end
it 'removes matching files without a human-readable timestamp' do
- expect(FileUtils).to have_received(:rm).with(files[3])
expect(FileUtils).to have_received(:rm).with(files[4])
+ expect(FileUtils).to have_received(:rm).with(files[5])
end
it 'does not remove files that are not old enough' do
@@ -94,11 +101,11 @@ describe Backup::Manager, lib: true do
end
it 'does not remove non-matching files' do
- expect(FileUtils).not_to have_received(:rm).with(files[5])
+ expect(FileUtils).not_to have_received(:rm).with(files[6])
end
it 'prints a done message' do
- expect(progress).to have_received(:puts).with('done. (4 removed)')
+ expect(progress).to have_received(:puts).with('done. (5 removed)')
end
end
@@ -117,10 +124,11 @@ describe Backup::Manager, lib: true do
expect(FileUtils).to have_received(:rm).with(files[2])
expect(FileUtils).to have_received(:rm).with(files[3])
expect(FileUtils).to have_received(:rm).with(files[4])
+ expect(FileUtils).to have_received(:rm).with(files[5])
end
it 'sets the correct removed count' do
- expect(progress).to have_received(:puts).with('done. (3 removed)')
+ expect(progress).to have_received(:puts).with('done. (4 removed)')
end
it 'prints the error from file that could not be removed' do
@@ -150,8 +158,8 @@ describe Backup::Manager, lib: true do
before do
allow(Dir).to receive(:glob).and_return(
[
- '1451606400_2016_01_01_gitlab_backup.tar',
- '1451520000_2015_12_31_gitlab_backup.tar',
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar',
+ '1451520000_2015_12_31_gitlab_backup.tar'
]
)
end
@@ -187,23 +195,75 @@ describe Backup::Manager, lib: true do
before do
allow(Dir).to receive(:glob).and_return(
[
- '1451606400_2016_01_01_gitlab_backup.tar'
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar'
]
)
allow(File).to receive(:exist?).and_return(true)
allow(Kernel).to receive(:system).and_return(true)
allow(YAML).to receive(:load_file).and_return(gitlab_version: Gitlab::VERSION)
- stub_env('BACKUP', '1451606400_2016_01_01')
+ stub_env('BACKUP', '1451606400_2016_01_01_1.2.3')
end
it 'unpacks the file' do
subject.unpack
expect(Kernel).to have_received(:system)
- .with("tar", "-xf", "1451606400_2016_01_01_gitlab_backup.tar")
+ .with("tar", "-xf", "1451606400_2016_01_01_1.2.3_gitlab_backup.tar")
expect(progress).to have_received(:puts).with(a_string_matching('done'))
end
end
end
+
+ describe '#upload' do
+ let(:backup_file) { Tempfile.new('backup', Gitlab.config.backup.path) }
+ let(:backup_filename) { File.basename(backup_file.path) }
+
+ before do
+ allow(subject).to receive(:tar_file).and_return(backup_filename)
+
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'AWS',
+ aws_access_key_id: 'id',
+ aws_secret_access_key: 'secret'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: 104857600,
+ encryption: nil,
+ storage_class: nil
+ }
+ )
+
+ # the Fog mock only knows about directories we create explicitly
+ Fog.mock!
+ connection = ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys)
+ connection.directories.create(key: Gitlab.config.backup.upload.remote_directory)
+ end
+
+ context 'target path' do
+ it 'uses the tar filename by default' do
+ expect_any_instance_of(Fog::Collection).to receive(:create)
+ .with(hash_including(key: backup_filename))
+ .and_return(true)
+
+ Dir.chdir(Gitlab.config.backup.path) do
+ subject.upload
+ end
+ end
+
+ it 'adds the DIRECTORY environment variable if present' do
+ stub_env('DIRECTORY', 'daily')
+
+ expect_any_instance_of(Fog::Collection).to receive(:create)
+ .with(hash_including(key: "daily/#{backup_filename}"))
+ .and_return(true)
+
+ Dir.chdir(Gitlab.config.backup.path) do
+ subject.upload
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/backup/repository_spec.rb b/spec/lib/gitlab/backup/repository_spec.rb
new file mode 100644
index 00000000000..535cce12780
--- /dev/null
+++ b/spec/lib/gitlab/backup/repository_spec.rb
@@ -0,0 +1,117 @@
+require 'spec_helper'
+
+describe Backup::Repository do
+ let(:progress) { StringIO.new }
+ let!(:project) { create(:project) }
+
+ before do
+ allow(progress).to receive(:puts)
+ allow(progress).to receive(:print)
+
+ allow_any_instance_of(String).to receive(:color) do |string, _color|
+ string
+ end
+
+ allow_any_instance_of(described_class).to receive(:progress).and_return(progress)
+ end
+
+ describe '#dump' do
+ describe 'repo failure' do
+ before do
+ allow_any_instance_of(Repository).to receive(:empty_repo?).and_raise(Rugged::OdbError)
+ allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0])
+ end
+
+ it 'does not raise error' do
+ expect { described_class.new.dump }.not_to raise_error
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.dump
+
+ expect(progress).to have_received(:puts).with("Ignoring repository error and continuing backing up project: #{project.full_path} - Rugged::OdbError")
+ end
+ end
+
+ describe 'command failure' do
+ before do
+ allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1])
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.dump
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ end
+ end
+ end
+
+ describe '#restore' do
+ describe 'command failure' do
+ before do
+ allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1])
+ end
+
+ it 'shows the appropriate error' do
+ described_class.new.restore
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ end
+ end
+ end
+
+ describe '#empty_repo?' do
+ context 'for a wiki' do
+ let(:wiki) { create(:project_wiki) }
+
+ context 'wiki repo has content' do
+ let!(:wiki_page) { create(:wiki_page, wiki: wiki) }
+
+ before do
+ wiki.repository.exists? # initial cache
+ end
+
+ context '`repository.exists?` is incorrectly cached as false' do
+ before do
+ repo = wiki.repository
+ repo.send(:cache).expire(:exists?)
+ repo.send(:cache).fetch(:exists?) { false }
+ repo.send(:instance_variable_set, :@exists, false)
+ end
+
+ it 'returns false, regardless of bad cache value' do
+ expect(described_class.new.send(:empty_repo?, wiki)).to be_falsey
+ end
+ end
+
+ context '`repository.exists?` is correctly cached as true' do
+ it 'returns false' do
+ expect(described_class.new.send(:empty_repo?, wiki)).to be_falsey
+ end
+ end
+ end
+
+ context 'wiki repo does not have content' do
+ context '`repository.exists?` is incorrectly cached as true' do
+ before do
+ repo = wiki.repository
+ repo.send(:cache).expire(:exists?)
+ repo.send(:cache).fetch(:exists?) { true }
+ repo.send(:instance_variable_set, :@exists, true)
+ end
+
+ it 'returns true, regardless of bad cache value' do
+ expect(described_class.new.send(:empty_repo?, wiki)).to be_truthy
+ end
+ end
+
+ context '`repository.exists?` is correctly cached as false' do
+ it 'returns true' do
+ expect(described_class.new.send(:empty_repo?, wiki)).to be_truthy
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb
deleted file mode 100644
index 9df96ea04eb..00000000000
--- a/spec/lib/gitlab/badge/build/metadata_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/badge/shared/metadata'
-
-describe Gitlab::Badge::Build::Metadata do
- let(:badge) { double(project: create(:empty_project), ref: 'feature') }
- let(:metadata) { described_class.new(badge) }
-
- it_behaves_like 'badge metadata'
-
- describe '#title' do
- it 'returns build status title' do
- expect(metadata.title).to eq 'build status'
- end
- end
-
- describe '#image_url' do
- it 'returns valid url' do
- expect(metadata.image_url).to include 'badges/feature/build.svg'
- end
- end
-
- describe '#link_url' do
- it 'returns valid link' do
- expect(metadata.link_url).to include 'commits/feature'
- end
- end
-end
diff --git a/spec/lib/gitlab/badge/build/status_spec.rb b/spec/lib/gitlab/badge/build/status_spec.rb
deleted file mode 100644
index 3c5414701a7..00000000000
--- a/spec/lib/gitlab/badge/build/status_spec.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Badge::Build::Status do
- let(:project) { create(:project, :repository) }
- let(:sha) { project.commit.sha }
- let(:branch) { 'master' }
- let(:badge) { described_class.new(project, branch) }
-
- describe '#entity' do
- it 'always says build' do
- expect(badge.entity).to eq 'build'
- end
- end
-
- describe '#template' do
- it 'returns badge template' do
- expect(badge.template.key_text).to eq 'build'
- end
- end
-
- describe '#metadata' do
- it 'returns badge metadata' do
- expect(badge.metadata.image_url)
- .to include 'badges/master/build.svg'
- end
- end
-
- context 'build exists' do
- let!(:build) { create_build(project, sha, branch) }
-
- context 'build success' do
- before { build.success! }
-
- describe '#status' do
- it 'is successful' do
- expect(badge.status).to eq 'success'
- end
- end
- end
-
- context 'build failed' do
- before { build.drop! }
-
- describe '#status' do
- it 'failed' do
- expect(badge.status).to eq 'failed'
- end
- end
- end
-
- context 'when outdated pipeline for given ref exists' do
- before do
- build.success!
-
- old_build = create_build(project, '11eeffdd', branch)
- old_build.drop!
- end
-
- it 'does not take outdated pipeline into account' do
- expect(badge.status).to eq 'success'
- end
- end
-
- context 'when multiple pipelines exist for given sha' do
- before do
- build.drop!
-
- new_build = create_build(project, sha, branch)
- new_build.success!
- end
-
- it 'does not take outdated pipeline into account' do
- expect(badge.status).to eq 'success'
- end
- end
- end
-
- context 'build does not exist' do
- describe '#status' do
- it 'is unknown' do
- expect(badge.status).to eq 'unknown'
- end
- end
- end
-
- def create_build(project, sha, branch)
- pipeline = create(:ci_empty_pipeline,
- project: project,
- sha: sha,
- ref: branch)
-
- create(:ci_build, pipeline: pipeline, stage: 'notify')
- end
-end
diff --git a/spec/lib/gitlab/badge/build/template_spec.rb b/spec/lib/gitlab/badge/build/template_spec.rb
deleted file mode 100644
index a7e21fb8bb1..00000000000
--- a/spec/lib/gitlab/badge/build/template_spec.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Badge::Build::Template do
- let(:badge) { double(entity: 'build', status: 'success') }
- let(:template) { described_class.new(badge) }
-
- describe '#key_text' do
- it 'is always says build' do
- expect(template.key_text).to eq 'build'
- end
- end
-
- describe '#value_text' do
- it 'is status value' do
- expect(template.value_text).to eq 'success'
- end
- end
-
- describe 'widths and text anchors' do
- it 'has fixed width and text anchors' do
- expect(template.width).to eq 92
- expect(template.key_width).to eq 38
- expect(template.value_width).to eq 54
- expect(template.key_text_anchor).to eq 19
- expect(template.value_text_anchor).to eq 65
- end
- end
-
- describe '#key_color' do
- it 'is always the same' do
- expect(template.key_color).to eq '#555'
- end
- end
-
- describe '#value_color' do
- context 'when status is success' do
- it 'has expected color' do
- expect(template.value_color).to eq '#4c1'
- end
- end
-
- context 'when status is failed' do
- before do
- allow(badge).to receive(:status).and_return('failed')
- end
-
- it 'has expected color' do
- expect(template.value_color).to eq '#e05d44'
- end
- end
-
- context 'when status is running' do
- before do
- allow(badge).to receive(:status).and_return('running')
- end
-
- it 'has expected color' do
- expect(template.value_color).to eq '#dfb317'
- end
- end
-
- context 'when status is unknown' do
- before do
- allow(badge).to receive(:status).and_return('unknown')
- end
-
- it 'has expected color' do
- expect(template.value_color).to eq '#9f9f9f'
- end
- end
-
- context 'when status does not match any known statuses' do
- before do
- allow(badge).to receive(:status).and_return('invalid')
- end
-
- it 'has expected color' do
- expect(template.value_color).to eq '#9f9f9f'
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/badge/coverage/metadata_spec.rb b/spec/lib/gitlab/badge/coverage/metadata_spec.rb
index 5e93935ea37..74eaf7eaf8b 100644
--- a/spec/lib/gitlab/badge/coverage/metadata_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/metadata_spec.rb
@@ -3,7 +3,7 @@ require 'lib/gitlab/badge/shared/metadata'
describe Gitlab::Badge::Coverage::Metadata do
let(:badge) do
- double(project: create(:empty_project), ref: 'feature', job: 'test')
+ double(project: create(:project), ref: 'feature', job: 'test')
end
let(:metadata) { described_class.new(badge) }
diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb
index 1547bd3228c..da789bf3705 100644
--- a/spec/lib/gitlab/badge/coverage/report_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/report_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Badge::Coverage::Report do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:job_name) { nil }
let(:badge) do
diff --git a/spec/lib/gitlab/badge/pipeline/metadata_spec.rb b/spec/lib/gitlab/badge/pipeline/metadata_spec.rb
new file mode 100644
index 00000000000..9032a8e9016
--- /dev/null
+++ b/spec/lib/gitlab/badge/pipeline/metadata_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+require 'lib/gitlab/badge/shared/metadata'
+
+describe Gitlab::Badge::Pipeline::Metadata do
+ let(:badge) { double(project: create(:project), ref: 'feature') }
+ let(:metadata) { described_class.new(badge) }
+
+ it_behaves_like 'badge metadata'
+
+ describe '#title' do
+ it 'returns build status title' do
+ expect(metadata.title).to eq 'pipeline status'
+ end
+ end
+
+ describe '#image_url' do
+ it 'returns valid url' do
+ expect(metadata.image_url).to include 'badges/feature/pipeline.svg'
+ end
+ end
+
+ describe '#link_url' do
+ it 'returns valid link' do
+ expect(metadata.link_url).to include 'commits/feature'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/badge/pipeline/status_spec.rb b/spec/lib/gitlab/badge/pipeline/status_spec.rb
new file mode 100644
index 00000000000..dc835375c66
--- /dev/null
+++ b/spec/lib/gitlab/badge/pipeline/status_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe Gitlab::Badge::Pipeline::Status do
+ let(:project) { create(:project, :repository) }
+ let(:sha) { project.commit.sha }
+ let(:branch) { 'master' }
+ let(:badge) { described_class.new(project, branch) }
+
+ describe '#entity' do
+ it 'always says pipeline' do
+ expect(badge.entity).to eq 'pipeline'
+ end
+ end
+
+ describe '#template' do
+ it 'returns badge template' do
+ expect(badge.template.key_text).to eq 'pipeline'
+ end
+ end
+
+ describe '#metadata' do
+ it 'returns badge metadata' do
+ expect(badge.metadata.image_url).to include 'badges/master/pipeline.svg'
+ end
+ end
+
+ context 'pipeline exists' do
+ let!(:pipeline) { create_pipeline(project, sha, branch) }
+
+ context 'pipeline success' do
+ before do
+ pipeline.success!
+ end
+
+ describe '#status' do
+ it 'is successful' do
+ expect(badge.status).to eq 'success'
+ end
+ end
+ end
+
+ context 'pipeline failed' do
+ before do
+ pipeline.drop!
+ end
+
+ describe '#status' do
+ it 'failed' do
+ expect(badge.status).to eq 'failed'
+ end
+ end
+ end
+
+ context 'when outdated pipeline for given ref exists' do
+ before do
+ pipeline.success!
+
+ old_pipeline = create_pipeline(project, '11eeffdd', branch)
+ old_pipeline.drop!
+ end
+
+ it 'does not take outdated pipeline into account' do
+ expect(badge.status).to eq 'success'
+ end
+ end
+
+ context 'when multiple pipelines exist for given sha' do
+ before do
+ pipeline.drop!
+
+ new_pipeline = create_pipeline(project, sha, branch)
+ new_pipeline.success!
+ end
+
+ it 'does not take outdated pipeline into account' do
+ expect(badge.status).to eq 'success'
+ end
+ end
+ end
+
+ context 'build does not exist' do
+ describe '#status' do
+ it 'is unknown' do
+ expect(badge.status).to eq 'unknown'
+ end
+ end
+ end
+
+ def create_pipeline(project, sha, branch)
+ pipeline = create(:ci_empty_pipeline,
+ project: project,
+ sha: sha,
+ ref: branch)
+
+ create(:ci_build, pipeline: pipeline, stage: 'notify')
+ end
+end
diff --git a/spec/lib/gitlab/badge/pipeline/template_spec.rb b/spec/lib/gitlab/badge/pipeline/template_spec.rb
new file mode 100644
index 00000000000..20fa4f879c3
--- /dev/null
+++ b/spec/lib/gitlab/badge/pipeline/template_spec.rb
@@ -0,0 +1,82 @@
+require 'spec_helper'
+
+describe Gitlab::Badge::Pipeline::Template do
+ let(:badge) { double(entity: 'pipeline', status: 'success') }
+ let(:template) { described_class.new(badge) }
+
+ describe '#key_text' do
+ it 'is always says pipeline' do
+ expect(template.key_text).to eq 'pipeline'
+ end
+ end
+
+ describe '#value_text' do
+ it 'is status value' do
+ expect(template.value_text).to eq 'passed'
+ end
+ end
+
+ describe 'widths and text anchors' do
+ it 'has fixed width and text anchors' do
+ expect(template.width).to eq 116
+ expect(template.key_width).to eq 62
+ expect(template.value_width).to eq 54
+ expect(template.key_text_anchor).to eq 31
+ expect(template.value_text_anchor).to eq 89
+ end
+ end
+
+ describe '#key_color' do
+ it 'is always the same' do
+ expect(template.key_color).to eq '#555'
+ end
+ end
+
+ describe '#value_color' do
+ context 'when status is success' do
+ it 'has expected color' do
+ expect(template.value_color).to eq '#4c1'
+ end
+ end
+
+ context 'when status is failed' do
+ before do
+ allow(badge).to receive(:status).and_return('failed')
+ end
+
+ it 'has expected color' do
+ expect(template.value_color).to eq '#e05d44'
+ end
+ end
+
+ context 'when status is running' do
+ before do
+ allow(badge).to receive(:status).and_return('running')
+ end
+
+ it 'has expected color' do
+ expect(template.value_color).to eq '#dfb317'
+ end
+ end
+
+ context 'when status is unknown' do
+ before do
+ allow(badge).to receive(:status).and_return('unknown')
+ end
+
+ it 'has expected color' do
+ expect(template.value_color).to eq '#9f9f9f'
+ end
+ end
+
+ context 'when status does not match any known statuses' do
+ before do
+ allow(badge).to receive(:status).and_return('invalid')
+ end
+
+ it 'has expected color' do
+ expect(template.value_color).to eq '#9f9f9f'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
index a7ee7f53a6b..a66347ead76 100644
--- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::BitbucketImport::Importer, lib: true do
+describe Gitlab::BitbucketImport::Importer do
include ImportSpecHelper
before do
@@ -52,13 +52,13 @@ describe Gitlab::BitbucketImport::Importer, lib: true do
let(:project) do
create(
- :empty_project,
+ :project,
import_source: project_identifier,
- import_data: ProjectImportData.new(credentials: data)
+ import_data_attributes: { credentials: data }
)
end
- let(:importer) { Gitlab::BitbucketImport::Importer.new(project) }
+ let(:importer) { described_class.new(project) }
let(:issues_statuses_sample_data) do
{
@@ -86,11 +86,9 @@ describe Gitlab::BitbucketImport::Importer, lib: true do
headers: { "Content-Type" => "application/json" },
body: issues_statuses_sample_data.to_json)
- stub_request(:get, "https://api.bitbucket.org/2.0/repositories/namespace/repo?pagelen=50&sort=created_on").
- with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer', 'User-Agent' => 'Faraday v0.9.2' }).
- to_return(status: 200,
- body: "",
- headers: {})
+ stub_request(:get, "https://api.bitbucket.org/2.0/repositories/namespace/repo?pagelen=50&sort=created_on")
+ .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer', 'User-Agent' => 'Faraday v0.9.2' })
+ .to_return(status: 200, body: "", headers: {})
sample_issues_statuses.each_with_index do |issue, index|
stub_request(
diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
index 773d0d4d288..ae5b31dc12d 100644
--- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::BitbucketImport::ProjectCreator, lib: true do
+describe Gitlab::BitbucketImport::ProjectCreator do
let(:user) { create(:user) }
let(:repo) do
@@ -15,7 +15,7 @@ describe Gitlab::BitbucketImport::ProjectCreator, lib: true do
has_wiki?: false)
end
- let(:namespace){ create(:group, owner: user) }
+ let(:namespace) { create(:group, owner: user) }
let(:token) { "asdasd12345" }
let(:secret) { "sekrettt" }
let(:access_params) { { bitbucket_access_token: token, bitbucket_access_token_secret: secret } }
@@ -27,7 +27,7 @@ describe Gitlab::BitbucketImport::ProjectCreator, lib: true do
it 'creates project' do
allow_any_instance_of(Project).to receive(:add_import_job)
- project_creator = Gitlab::BitbucketImport::ProjectCreator.new(repo, 'vim', namespace, user, access_params)
+ project_creator = described_class.new(repo, 'vim', namespace, user, access_params)
project = project_creator.execute
expect(project.import_url).to eq("ssh://git@bitbucket.org/asd/vim.git")
diff --git a/spec/lib/gitlab/blame_spec.rb b/spec/lib/gitlab/blame_spec.rb
index 26b1baf75be..7cab04e9fc9 100644
--- a/spec/lib/gitlab/blame_spec.rb
+++ b/spec/lib/gitlab/blame_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Blame, lib: true do
+describe Gitlab::Blame do
let(:project) { create(:project, :repository) }
let(:path) { 'files/ruby/popen.rb' }
let(:commit) { project.commit('master') }
diff --git a/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb
index b386852b196..16704ff5e77 100644
--- a/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb
+++ b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
- let(:project) { create(:project) }
+describe Gitlab::Cache::Ci::ProjectPipelineStatus, :clean_gitlab_redis_cache do
+ let!(:project) { create(:project, :repository) }
let(:pipeline_status) { described_class.new(project) }
let(:cache_key) { "projects/#{project.id}/pipeline_status" }
@@ -18,7 +18,7 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
let(:sha) { '424d1b73bc0d3cb726eb7dc4ce17a4d48552f8c6' }
let(:ref) { 'master' }
let(:pipeline_info) { { sha: sha, status: status, ref: ref } }
- let(:project_without_status) { create(:project) }
+ let!(:project_without_status) { create(:project, :repository) }
describe '.load_in_batch_for_projects' do
it 'preloads pipeline_status on projects' do
@@ -28,8 +28,8 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
expect(project.instance_variable_get('@pipeline_status')).to be_a(described_class)
end
- describe 'without a status in redis' do
- it 'loads the status from a commit when it was not in redis' do
+ describe 'without a status in redis_cache' do
+ it 'loads the status from a commit when it was not in redis_cache' do
empty_status = { sha: nil, status: nil, ref: nil }
fake_pipeline = described_class.new(
project_without_status,
@@ -37,11 +37,11 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
loaded_from_cache: false
)
- expect(described_class).to receive(:new).
- with(project_without_status,
+ expect(described_class).to receive(:new)
+ .with(project_without_status,
pipeline_info: empty_status,
- loaded_from_cache: false).
- and_return(fake_pipeline)
+ loaded_from_cache: false)
+ .and_return(fake_pipeline)
expect(fake_pipeline).to receive(:load_from_project)
expect(fake_pipeline).to receive(:store_in_cache)
@@ -49,8 +49,9 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
it 'only connects to redis twice' do
- # Once to load, once to store in the cache
- expect(Gitlab::Redis).to receive(:with).exactly(2).and_call_original
+ # Stub circuitbreaker so it doesn't count the redis connections in there
+ stub_circuit_breaker(project_without_status)
+ expect(Gitlab::Redis::Cache).to receive(:with).exactly(2).and_call_original
described_class.load_in_batch_for_projects([project_without_status])
@@ -58,9 +59,9 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
end
- describe 'when a status was cached in redis' do
+ describe 'when a status was cached in redis_cache' do
before do
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.mapped_hmset(cache_key,
{ sha: sha, status: status, ref: ref })
end
@@ -76,8 +77,8 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
expect(pipeline_status.ref).to eq(ref)
end
- it 'only connects to redis once' do
- expect(Gitlab::Redis).to receive(:with).exactly(1).and_call_original
+ it 'only connects to redis_cache once' do
+ expect(Gitlab::Redis::Cache).to receive(:with).exactly(1).and_call_original
described_class.load_in_batch_for_projects([project])
@@ -94,8 +95,8 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
describe '.cached_results_for_projects' do
- it 'loads a status from redis for all projects' do
- Gitlab::Redis.with do |redis|
+ it 'loads a status from caching for all projects' do
+ Gitlab::Redis::Cache.with do |redis|
redis.mapped_hmset(cache_key, { sha: sha, status: status, ref: ref })
end
@@ -112,12 +113,12 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
pipeline = build_stubbed(:ci_pipeline,
sha: '123456', status: 'success', ref: 'master')
fake_status = double
- expect(described_class).to receive(:new).
- with(pipeline.project,
+ expect(described_class).to receive(:new)
+ .with(pipeline.project,
pipeline_info: {
sha: '123456', status: 'success', ref: 'master'
- }).
- and_return(fake_status)
+ })
+ .and_return(fake_status)
expect(fake_status).to receive(:store_in_cache_if_needed)
@@ -183,7 +184,7 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
end
- describe "#load_from_project" do
+ describe "#load_from_project", :clean_gitlab_redis_cache do
let!(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) }
it 'reads the status from the pipeline for the commit' do
@@ -195,7 +196,7 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
it "doesn't fail for an empty project" do
- status_for_empty_commit = described_class.new(create(:empty_project))
+ status_for_empty_commit = described_class.new(create(:project))
status_for_empty_commit.load_status
@@ -203,48 +204,48 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
end
- describe "#store_in_cache", :redis do
- it "sets the object in redis" do
+ describe "#store_in_cache", :clean_gitlab_redis_cache do
+ it "sets the object in caching" do
pipeline_status.sha = '123456'
pipeline_status.status = 'failed'
pipeline_status.store_in_cache
- read_sha, read_status = Gitlab::Redis.with { |redis| redis.hmget(cache_key, :sha, :status) }
+ read_sha, read_status = Gitlab::Redis::Cache.with { |redis| redis.hmget(cache_key, :sha, :status) }
expect(read_sha).to eq('123456')
expect(read_status).to eq('failed')
end
end
- describe '#store_in_cache_if_needed', :redis do
+ describe '#store_in_cache_if_needed', :clean_gitlab_redis_cache do
it 'stores the state in the cache when the sha is the HEAD of the project' do
create(:ci_pipeline, :success, project: project, sha: project.commit.sha)
pipeline_status = described_class.load_for_project(project)
pipeline_status.store_in_cache_if_needed
- sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget(cache_key, :sha, :status, :ref) }
+ sha, status, ref = Gitlab::Redis::Cache.with { |redis| redis.hmget(cache_key, :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
+ it "doesn't store the status in redis_cache when the sha is not the head of the project" do
other_status = described_class.new(
project,
pipeline_info: { sha: "123456", status: "failed" }
)
other_status.store_in_cache_if_needed
- sha, status = Gitlab::Redis.with { |redis| redis.hmget(cache_key, :sha, :status) }
+ sha, status = Gitlab::Redis::Cache.with { |redis| redis.hmget(cache_key, :sha, :status) }
expect(sha).to be_nil
expect(status).to be_nil
end
it "deletes the cache if the repository doesn't have a head commit" do
- empty_project = create(:empty_project)
- Gitlab::Redis.with do |redis|
+ empty_project = create(:project)
+ Gitlab::Redis::Cache.with do |redis|
redis.mapped_hmset(cache_key,
{ sha: 'sha', status: 'pending', ref: 'master' })
end
@@ -255,7 +256,7 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
})
other_status.store_in_cache_if_needed
- sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/pipeline_status", :sha, :status, :ref) }
+ sha, status, ref = Gitlab::Redis::Cache.with { |redis| redis.hmget("projects/#{empty_project.id}/pipeline_status", :sha, :status, :ref) }
expect(sha).to be_nil
expect(status).to be_nil
@@ -263,20 +264,20 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
end
- describe "with a status in redis", :redis do
+ describe "with a status in caching", :clean_gitlab_redis_cache do
let(:status) { 'success' }
let(:sha) { '424d1b73bc0d3cb726eb7dc4ce17a4d48552f8c6' }
let(:ref) { 'master' }
before do
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.mapped_hmset(cache_key,
{ sha: sha, status: status, ref: ref })
end
end
describe '#load_from_cache' do
- it 'reads the status from redis' do
+ it 'reads the status from redis_cache' do
pipeline_status.load_from_cache
expect(pipeline_status.sha).to eq(sha)
@@ -292,13 +293,22 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do
end
describe '#delete_from_cache' do
- it 'deletes values from redis' do
+ it 'deletes values from redis_cache' do
pipeline_status.delete_from_cache
- key_exists = Gitlab::Redis.with { |redis| redis.exists(cache_key) }
+ key_exists = Gitlab::Redis::Cache.with { |redis| redis.exists(cache_key) }
expect(key_exists).to be_falsy
end
end
end
+
+ def stub_circuit_breaker(project)
+ fake_circuitbreaker = double
+ allow(fake_circuitbreaker).to receive(:perform).and_yield
+ allow(project.repository.raw_repository)
+ .to receive(:circuit_breaker).and_return(fake_circuitbreaker)
+ allow(project.repository)
+ .to receive(:circuit_breaker).and_return(fake_circuitbreaker)
+ end
end
diff --git a/spec/lib/gitlab/cache/request_cache_spec.rb b/spec/lib/gitlab/cache/request_cache_spec.rb
new file mode 100644
index 00000000000..5b82c216a13
--- /dev/null
+++ b/spec/lib/gitlab/cache/request_cache_spec.rb
@@ -0,0 +1,133 @@
+require 'spec_helper'
+
+describe Gitlab::Cache::RequestCache do
+ let(:klass) do
+ Class.new do
+ extend Gitlab::Cache::RequestCache
+
+ attr_accessor :id, :name, :result, :extra
+
+ def self.name
+ 'ExpensiveAlgorithm'
+ end
+
+ def initialize(id, name, result, extra = nil)
+ self.id = id
+ self.name = name
+ self.result = result
+ self.extra = nil
+ end
+
+ request_cache def compute(arg)
+ result << arg
+ end
+
+ request_cache def repute(arg)
+ result << arg
+ end
+
+ def dispute(arg)
+ result << arg
+ end
+ request_cache(:dispute) { extra }
+ end
+ end
+
+ let(:algorithm) { klass.new('id', 'name', []) }
+
+ shared_examples 'cache for the same instance' do
+ it 'does not compute twice for the same argument' do
+ algorithm.compute(true)
+ result = algorithm.compute(true)
+
+ expect(result).to eq([true])
+ end
+
+ it 'computes twice for the different argument' do
+ algorithm.compute(true)
+ result = algorithm.compute(false)
+
+ expect(result).to eq([true, false])
+ end
+
+ it 'computes twice for the different class name' do
+ algorithm.compute(true)
+ allow(klass).to receive(:name).and_return('CheapAlgo')
+ result = algorithm.compute(true)
+
+ expect(result).to eq([true, true])
+ end
+
+ it 'computes twice for the different method' do
+ algorithm.compute(true)
+ result = algorithm.repute(true)
+
+ expect(result).to eq([true, true])
+ end
+
+ context 'when request_cache_key is provided' do
+ before do
+ klass.request_cache_key do
+ [id, name]
+ end
+ end
+
+ it 'computes twice for the different keys, id' do
+ algorithm.compute(true)
+ algorithm.id = 'ad'
+ result = algorithm.compute(true)
+
+ expect(result).to eq([true, true])
+ end
+
+ it 'computes twice for the different keys, name' do
+ algorithm.compute(true)
+ algorithm.name = 'same'
+ result = algorithm.compute(true)
+
+ expect(result).to eq([true, true])
+ end
+
+ it 'uses extra method cache key if provided' do
+ algorithm.dispute(true) # miss
+ algorithm.extra = true
+ algorithm.dispute(true) # miss
+ result = algorithm.dispute(true) # hit
+
+ expect(result).to eq([true, true])
+ end
+ end
+ end
+
+ context 'when RequestStore is active', :request_store do
+ it_behaves_like 'cache for the same instance'
+
+ it 'computes once for different instances when keys are the same' do
+ algorithm.compute(true)
+ result = klass.new('id', 'name', algorithm.result).compute(true)
+
+ expect(result).to eq([true])
+ end
+
+ it 'computes twice if RequestStore starts over' do
+ algorithm.compute(true)
+ RequestStore.end!
+ RequestStore.clear!
+ RequestStore.begin!
+ result = algorithm.compute(true)
+
+ expect(result).to eq([true, true])
+ end
+ end
+
+ context 'when RequestStore is inactive' do
+ it_behaves_like 'cache for the same instance'
+
+ it 'computes twice for different instances even if keys are the same' do
+ algorithm.compute(true)
+ result = klass.new('id', 'name', algorithm.result).compute(true)
+
+ expect(result).to eq([true, true])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/changes_list_spec.rb b/spec/lib/gitlab/changes_list_spec.rb
index 69d86144e32..464508fcd73 100644
--- a/spec/lib/gitlab/changes_list_spec.rb
+++ b/spec/lib/gitlab/changes_list_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::ChangesList do
let(:invalid_changes) { 1 }
context 'when changes is a valid string' do
- let(:changes_list) { Gitlab::ChangesList.new(valid_changes_string) }
+ let(:changes_list) { described_class.new(valid_changes_string) }
it 'splits elements by newline character' do
expect(changes_list).to contain_exactly({
diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb
deleted file mode 100644
index b6e924d67be..00000000000
--- a/spec/lib/gitlab/chat_commands/command_spec.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Command, service: true do
- let(:project) { create(:empty_project) }
- let(:user) { create(:user) }
-
- describe '#execute' do
- subject do
- described_class.new(project, user, params).execute
- end
-
- context 'when no command is available' do
- let(:params) { { text: 'issue show 1' } }
- let(:project) { create(:empty_project, has_external_issue_tracker: true) }
-
- it 'displays 404 messages' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to start_with('404 not found')
- end
- end
-
- context 'when an unknown command is triggered' do
- let(:params) { { command: '/gitlab', text: "unknown command 123" } }
-
- it 'displays the help message' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to start_with('Unknown command')
- expect(subject[:text]).to match('/gitlab issue show')
- end
- end
-
- context 'the user can not create an issue' do
- let(:params) { { text: "issue create my new issue" } }
-
- it 'rejects the actions' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to start_with('Whoops! This action is not allowed')
- end
- end
-
- context 'when trying to do deployment' do
- let(:params) { { text: 'deploy staging to production' } }
- let!(:build) { create(:ci_build, project: project) }
- let!(:staging) { create(:environment, name: 'staging', project: project) }
- let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
- let!(:manual) do
- create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
- end
-
- context 'and user can not create deployment' do
- it 'returns action' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to start_with('Whoops! This action is not allowed')
- end
- end
-
- context 'and user does have deployment permission' do
- before do
- project.team << [user, :developer]
- end
-
- it 'returns action' do
- expect(subject[:text]).to include('Deployment started from staging to production')
- expect(subject[:response_type]).to be(:in_channel)
- end
-
- context 'when duplicate action exists' do
- let!(:manual2) do
- create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
- end
-
- it 'returns error' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to include('Too many actions defined')
- end
- end
- end
- end
- end
-
- describe '#match_command' do
- subject { described_class.new(project, user, params).match_command.first }
-
- context 'IssueShow is triggered' do
- let(:params) { { text: 'issue show 123' } }
-
- it { is_expected.to eq(Gitlab::ChatCommands::IssueShow) }
- end
-
- context 'IssueCreate is triggered' do
- let(:params) { { text: 'issue create my title' } }
-
- it { is_expected.to eq(Gitlab::ChatCommands::IssueNew) }
- end
-
- context 'IssueSearch is triggered' do
- let(:params) { { text: 'issue search my query' } }
-
- it { is_expected.to eq(Gitlab::ChatCommands::IssueSearch) }
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb
deleted file mode 100644
index b3358a32161..00000000000
--- a/spec/lib/gitlab/chat_commands/deploy_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Deploy, service: true do
- describe '#execute' do
- let(:project) { create(:empty_project) }
- let(:user) { create(:user) }
- let(:regex_match) { described_class.match('deploy staging to production') }
-
- before do
- project.team << [user, :master]
- end
-
- subject do
- described_class.new(project, user).execute(regex_match)
- end
-
- context 'if no environment is defined' do
- it 'does not execute an action' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to eq("No action found to be executed")
- end
- end
-
- context 'with environment' do
- let!(:staging) { create(:environment, name: 'staging', project: project) }
- let!(:build) { create(:ci_build, project: project) }
- let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
-
- context 'without actions' do
- it 'does not execute an action' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to eq("No action found to be executed")
- end
- end
-
- context 'with action' do
- let!(:manual1) do
- create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
- end
-
- it 'returns success result' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(subject[:text]).to start_with('Deployment started from staging to production')
- end
-
- context 'when duplicate action exists' do
- let!(:manual2) do
- create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
- end
-
- it 'returns error' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to eq('Too many actions defined')
- end
- end
-
- context 'when teardown action exists' do
- let!(:teardown) do
- create(:ci_build, :manual, :teardown_environment,
- project: project, pipeline: build.pipeline,
- name: 'teardown', environment: 'production')
- end
-
- it 'returns the success message' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(subject[:text]).to start_with('Deployment started from staging to production')
- end
- end
- end
- end
- end
-
- describe 'self.match' do
- it 'matches the environment' do
- match = described_class.match('deploy staging to production')
-
- expect(match[:from]).to eq('staging')
- expect(match[:to]).to eq('production')
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/issue_new_spec.rb b/spec/lib/gitlab/chat_commands/issue_new_spec.rb
deleted file mode 100644
index 84c22328064..00000000000
--- a/spec/lib/gitlab/chat_commands/issue_new_spec.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::IssueNew, service: true do
- describe '#execute' do
- let(:project) { create(:empty_project) }
- let(:user) { create(:user) }
- let(:regex_match) { described_class.match("issue create bird is the word") }
-
- before do
- project.team << [user, :master]
- end
-
- subject do
- described_class.new(project, user).execute(regex_match)
- end
-
- context 'without description' do
- it 'creates the issue' do
- expect { subject }.to change { project.issues.count }.by(1)
-
- expect(subject[:response_type]).to be(:in_channel)
- end
- end
-
- context 'with description' do
- let(:description) { "Surfin bird" }
- let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") }
-
- it 'creates the issue with description' do
- subject
-
- expect(Issue.last.description).to eq(description)
- end
- end
-
- context "with more newlines between the title and the description" do
- let(:description) { "Surfin bird" }
- let(:regex_match) { described_class.match("issue create bird is the word\n\n#{description}\n") }
-
- it 'creates the issue' do
- expect { subject }.to change { project.issues.count }.by(1)
- end
- end
-
- context 'issue cannot be created' do
- let!(:issue) { create(:issue, project: project, title: 'bird is the word') }
- let(:regex_match) { described_class.match("issue create #{'a' * 512}}") }
-
- it 'displays the errors' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to match("- Title is too long")
- end
- end
- end
-
- describe '.match' do
- it 'matches the title without description' do
- match = described_class.match("issue create my title")
-
- expect(match[:title]).to eq('my title')
- expect(match[:description]).to eq("")
- end
-
- it 'matches the title with description' do
- match = described_class.match("issue create my title\n\ndescription")
-
- expect(match[:title]).to eq('my title')
- expect(match[:description]).to eq('description')
- end
-
- it 'matches the alias new' do
- match = described_class.match("issue new my title")
-
- expect(match).not_to be_nil
- expect(match[:title]).to eq('my title')
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/issue_search_spec.rb b/spec/lib/gitlab/chat_commands/issue_search_spec.rb
deleted file mode 100644
index 551ccb79a58..00000000000
--- a/spec/lib/gitlab/chat_commands/issue_search_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::IssueSearch, service: true do
- describe '#execute' do
- let!(:issue) { create(:issue, project: project, title: 'find me') }
- let!(:confidential) { create(:issue, :confidential, project: project, title: 'mepmep find') }
- let(:project) { create(:empty_project) }
- let(:user) { issue.author }
- let(:regex_match) { described_class.match("issue search find") }
-
- subject do
- described_class.new(project, user).execute(regex_match)
- end
-
- context 'when the user has no access' do
- it 'only returns the open issues' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to match("not found")
- end
- end
-
- context 'the user has access' do
- before do
- project.team << [user, :master]
- end
-
- it 'returns all results' do
- expect(subject).to have_key(:attachments)
- expect(subject[:text]).to eq("Here are the 2 issues I found:")
- end
- end
-
- context 'without hits on the query' do
- it 'returns an empty collection' do
- expect(subject[:text]).to match("not found")
- end
- end
- end
-
- describe 'self.match' do
- let(:query) { "my search keywords" }
- it 'matches the query' do
- match = described_class.match("issue search #{query}")
-
- expect(match[:query]).to eq(query)
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/issue_show_spec.rb
deleted file mode 100644
index 1f20d0a44ce..00000000000
--- a/spec/lib/gitlab/chat_commands/issue_show_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::IssueShow, service: true do
- describe '#execute' do
- let(:issue) { create(:issue, project: project) }
- let(:project) { create(:empty_project) }
- let(:user) { issue.author }
- let(:regex_match) { described_class.match("issue show #{issue.iid}") }
-
- before do
- project.team << [user, :master]
- end
-
- subject do
- described_class.new(project, user).execute(regex_match)
- end
-
- context 'the issue exists' do
- let(:title) { subject[:attachments].first[:title] }
-
- it 'returns the issue' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(title).to start_with(issue.title)
- end
-
- context 'when its reference is given' do
- let(:regex_match) { described_class.match("issue show #{issue.to_reference}") }
-
- it 'shows the issue' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(title).to start_with(issue.title)
- end
- end
- end
-
- context 'the issue does not exist' do
- let(:regex_match) { described_class.match("issue show 2343242") }
-
- it "returns not found" do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to match("not found")
- end
- end
- end
-
- describe '.match' do
- it 'matches the iid' do
- match = described_class.match("issue show 123")
-
- expect(match[:iid]).to eq("123")
- end
-
- it 'accepts a reference' do
- match = described_class.match("issue show #{Issue.reference_prefix}123")
-
- expect(match[:iid]).to eq("123")
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/presenters/access_spec.rb b/spec/lib/gitlab/chat_commands/presenters/access_spec.rb
deleted file mode 100644
index ae41d75ab0c..00000000000
--- a/spec/lib/gitlab/chat_commands/presenters/access_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Presenters::Access do
- describe '#access_denied' do
- subject { described_class.new.access_denied }
-
- it { is_expected.to be_a(Hash) }
-
- it 'displays an error message' do
- expect(subject[:text]).to match("is not allowed")
- expect(subject[:response_type]).to be(:ephemeral)
- end
- end
-
- describe '#not_found' do
- subject { described_class.new.not_found }
-
- it { is_expected.to be_a(Hash) }
-
- it 'tells the user the resource was not found' do
- expect(subject[:text]).to match("not found!")
- expect(subject[:response_type]).to be(:ephemeral)
- end
- end
-
- describe '#authorize' do
- context 'with an authorization URL' do
- subject { described_class.new('http://authorize.me').authorize }
-
- it { is_expected.to be_a(Hash) }
-
- it 'tells the user to authorize' do
- expect(subject[:text]).to match("connect your GitLab account")
- expect(subject[:response_type]).to be(:ephemeral)
- end
- end
-
- context 'without authorization url' do
- subject { described_class.new.authorize }
-
- it { is_expected.to be_a(Hash) }
-
- it 'tells the user to authorize' do
- expect(subject[:text]).to match("Couldn't identify you")
- expect(subject[:response_type]).to be(:ephemeral)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb b/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb
deleted file mode 100644
index dc2dd300072..00000000000
--- a/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Presenters::Deploy do
- let(:build) { create(:ci_build) }
-
- describe '#present' do
- subject { described_class.new(build).present('staging', 'prod') }
-
- it { is_expected.to have_key(:text) }
- it { is_expected.to have_key(:response_type) }
- it { is_expected.to have_key(:status) }
- it { is_expected.not_to have_key(:attachments) }
-
- it 'messages the channel of the deploy' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(subject[:text]).to start_with("Deployment started from staging to prod")
- end
- end
-
- describe '#no_actions' do
- subject { described_class.new(nil).no_actions }
-
- it { is_expected.to have_key(:text) }
- it { is_expected.to have_key(:response_type) }
- it { is_expected.to have_key(:status) }
- it { is_expected.not_to have_key(:attachments) }
-
- it 'tells the user there is no action' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to eq("No action found to be executed")
- end
- end
-
- describe '#too_many_actions' do
- subject { described_class.new([]).too_many_actions }
-
- it { is_expected.to have_key(:text) }
- it { is_expected.to have_key(:response_type) }
- it { is_expected.to have_key(:status) }
- it { is_expected.not_to have_key(:attachments) }
-
- it 'tells the user there is no action' do
- expect(subject[:response_type]).to be(:ephemeral)
- expect(subject[:text]).to eq("Too many actions defined")
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb
deleted file mode 100644
index 17fcdbc2452..00000000000
--- a/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Presenters::IssueNew do
- let(:project) { create(:empty_project) }
- let(:issue) { create(:issue, project: project) }
- let(:attachment) { subject[:attachments].first }
-
- subject { described_class.new(issue).present }
-
- it { is_expected.to be_a(Hash) }
-
- it 'shows the issue' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(subject).to have_key(:attachments)
- expect(attachment[:title]).to start_with(issue.title)
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb
deleted file mode 100644
index ec6d3e34a96..00000000000
--- a/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Presenters::IssueSearch do
- let(:project) { create(:empty_project) }
- let(:message) { subject[:text] }
-
- before { create_list(:issue, 2, project: project) }
-
- subject { described_class.new(project.issues).present }
-
- it 'formats the message correct' do
- is_expected.to have_key(:text)
- is_expected.to have_key(:status)
- is_expected.to have_key(:response_type)
- is_expected.to have_key(:attachments)
- end
-
- it 'shows a list of results' do
- expect(subject[:response_type]).to be(:ephemeral)
-
- expect(message).to start_with("Here are the 2 issues I found")
- end
-end
diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb
deleted file mode 100644
index 3916fc704a4..00000000000
--- a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ChatCommands::Presenters::IssueShow do
- let(:project) { create(:empty_project) }
- let(:issue) { create(:issue, project: project) }
- let(:attachment) { subject[:attachments].first }
-
- subject { described_class.new(issue).present }
-
- it { is_expected.to be_a(Hash) }
-
- it 'shows the issue' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(subject).to have_key(:attachments)
- expect(attachment[:title]).to start_with(issue.title)
- end
-
- context 'with upvotes' do
- before do
- create(:award_emoji, :upvote, awardable: issue)
- end
-
- it 'shows the upvote count' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(attachment[:text]).to start_with("**Open** · :+1: 1")
- end
- end
-
- context 'with labels' do
- let(:label) { create(:label, project: project, title: 'mep') }
- let(:label1) { create(:label, project: project, title: 'mop') }
-
- before do
- issue.labels << [label, label1]
- end
-
- it 'shows the labels' do
- labels = attachment[:fields].find { |f| f[:title] == 'Labels' }
-
- expect(labels[:value]).to eq("mep, mop")
- end
- end
-
- context 'confidential issue' do
- let(:issue) { create(:issue, project: project) }
-
- it 'shows an ephemeral response' do
- expect(subject[:response_type]).to be(:in_channel)
- expect(attachment[:text]).to start_with("**Open**")
- end
- end
-end
diff --git a/spec/lib/gitlab/chat_name_token_spec.rb b/spec/lib/gitlab/chat_name_token_spec.rb
index 8c1e6efa9db..1e9fb9077fc 100644
--- a/spec/lib/gitlab/chat_name_token_spec.rb
+++ b/spec/lib/gitlab/chat_name_token_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::ChatNameToken, lib: true do
+describe Gitlab::ChatNameToken do
context 'when using unknown token' do
let(:token) { }
diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb
index 959ae02c222..6c25b7349e1 100644
--- a/spec/lib/gitlab/checks/change_access_spec.rb
+++ b/spec/lib/gitlab/checks/change_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Checks::ChangeAccess, lib: true do
+describe Gitlab::Checks::ChangeAccess do
describe '#exec' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
@@ -20,47 +20,48 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
).exec
end
- before { project.add_developer(user) }
+ before do
+ project.add_developer(user)
+ end
context 'without failed checks' do
- it "doesn't return any error" do
- expect(subject.status).to be(true)
+ it "doesn't raise an error" do
+ expect { subject }.not_to raise_error
end
end
context 'when the user is not allowed to push code' do
- it 'returns an error' do
+ it 'raises an error' do
expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.')
end
end
context 'tags check' do
let(:ref) { 'refs/tags/v1.0.0' }
- it 'returns an error if the user is not allowed to update tags' do
+ it 'raises an error if the user is not allowed to update tags' do
allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true)
expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to change existing tags on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.')
end
context 'with protected tag' do
let!(:protected_tag) { create(:protected_tag, project: project, name: 'v*') }
context 'as master' do
- before { project.add_master(user) }
+ before do
+ project.add_master(user)
+ end
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')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/)
end
end
@@ -69,8 +70,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
it 'is prevented' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('cannot be updated')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/)
end
end
end
@@ -81,55 +81,85 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:ref) { 'refs/tags/v9.1.0' }
it 'prevents creation below access level' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('allowed to create this tag as it is protected')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/)
end
context 'when user has access' do
let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') }
it 'allows tag creation' do
- expect(subject.status).to be(true)
+ expect { subject }.not_to raise_error
end
end
end
end
end
- context 'protected branches check' do
- before do
- allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true)
+ context 'branches check' do
+ context 'trying to delete the default branch' do
+ let(:newrev) { '0000000000000000000000000000000000000000' }
+ let(:ref) { 'refs/heads/master' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.')
+ end
end
- it 'returns an error if the user is not allowed to do forced pushes to protected branches' do
- expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
+ context 'protected branches check' do
+ before do
+ allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true)
+ allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true)
+ end
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.')
- end
+ it 'raises an error if the user is not allowed to do forced pushes to protected branches' do
+ expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- it 'returns an error if the user is not allowed to merge to protected branches' do
- expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true)
- expect(user_access).to receive(:can_merge_to_branch?).and_return(false)
- expect(user_access).to receive(:can_push_to_branch?).and_return(false)
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.')
+ end
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.')
- end
+ it 'raises an error if the user is not allowed to merge to protected branches' do
+ expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true)
+ expect(user_access).to receive(:can_merge_to_branch?).and_return(false)
+ expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- it 'returns an error if the user is not allowed to push to protected branches' do
- expect(user_access).to receive(:can_push_to_branch?).and_return(false)
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.')
+ end
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.')
- end
+ it 'raises an error if the user is not allowed to push to protected branches' do
+ expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- context 'branch deletion' do
- let(:newrev) { '0000000000000000000000000000000000000000' }
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.')
+ end
+
+ context 'branch deletion' do
+ let(:newrev) { '0000000000000000000000000000000000000000' }
+ let(:ref) { 'refs/heads/feature' }
+
+ context 'if the user is not allowed to delete protected branches' do
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.')
+ end
+ end
+
+ context 'if the user is allowed to delete protected branches' do
+ before do
+ project.add_master(user)
+ end
+
+ context 'through the web interface' do
+ let(:protocol) { 'web' }
+
+ it 'allows branch deletion' do
+ expect { subject }.not_to raise_error
+ end
+ end
- it 'returns an error if the user is not allowed to delete protected branches' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to delete protected branches from this project.')
+ context 'over SSH or HTTP' do
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.')
+ end
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/checks/force_push_spec.rb b/spec/lib/gitlab/checks/force_push_spec.rb
index 7a84bbebd02..f8c8b83a3ac 100644
--- a/spec/lib/gitlab/checks/force_push_spec.rb
+++ b/spec/lib/gitlab/checks/force_push_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-describe Gitlab::Checks::ChangeAccess, lib: true do
+describe Gitlab::Checks::ForcePush do
let(:project) { create(:project, :repository) }
- context "exit code checking" do
+ context "exit code checking", skip_gitaly_mock: true do
it "does not raise a runtime error if the `popen` call to git returns a zero exit code" do
allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0])
- expect { Gitlab::Checks::ForcePush.force_push?(project, 'oldrev', 'newrev') }.not_to raise_error
+ expect { described_class.force_push?(project, 'oldrev', 'newrev') }.not_to raise_error
end
it "raises a runtime error if the `popen` call to git returns a non-zero exit code" do
allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1])
- expect { Gitlab::Checks::ForcePush.force_push?(project, 'oldrev', 'newrev') }.to raise_error(RuntimeError)
+ expect { described_class.force_push?(project, 'oldrev', 'newrev') }.to raise_error(RuntimeError)
end
end
end
diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb
index abc93e1b44a..3b905611467 100644
--- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb
+++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb
@@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do
subject { |example| path(example).nodes }
it { is_expected.to eq 4 }
end
+
+ describe '#blob' do
+ let(:file_entry) { |example| path(example) }
+ subject { file_entry.blob }
+
+ it 'returns a blob representing the entry data' do
+ expect(subject).to be_a(Blob)
+ expect(subject.path).to eq(file_entry.path)
+ expect(subject.size).to eq(file_entry.metadata[:size])
+ end
+ end
end
describe 'non-existent/', path: 'non-existent/' do
diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb
index eea01f91879..6a52ae01b2f 100644
--- a/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb
+++ b/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb
@@ -33,8 +33,8 @@ describe Gitlab::Ci::Build::Artifacts::Metadata do
subject { metadata('other_artifacts_0.1.2/').find_entries! }
it 'matches correct paths' do
- expect(subject.keys).
- to contain_exactly 'other_artifacts_0.1.2/',
+ expect(subject.keys)
+ .to contain_exactly 'other_artifacts_0.1.2/',
'other_artifacts_0.1.2/doc_sample.txt',
'other_artifacts_0.1.2/another-subdirectory/'
end
@@ -44,8 +44,8 @@ describe Gitlab::Ci::Build::Artifacts::Metadata do
subject { metadata('other_artifacts_0.1.2/another-subdirectory/').find_entries! }
it 'matches correct paths' do
- expect(subject.keys).
- to contain_exactly 'other_artifacts_0.1.2/another-subdirectory/',
+ expect(subject.keys)
+ .to contain_exactly 'other_artifacts_0.1.2/another-subdirectory/',
'other_artifacts_0.1.2/another-subdirectory/empty_directory/',
'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif'
end
@@ -55,8 +55,8 @@ describe Gitlab::Ci::Build::Artifacts::Metadata do
subject { metadata('other_artifacts_0.1.2/', recursive: true).find_entries! }
it 'matches correct paths' do
- expect(subject.keys).
- to contain_exactly 'other_artifacts_0.1.2/',
+ expect(subject.keys)
+ .to contain_exactly 'other_artifacts_0.1.2/',
'other_artifacts_0.1.2/doc_sample.txt',
'other_artifacts_0.1.2/another-subdirectory/',
'other_artifacts_0.1.2/another-subdirectory/empty_directory/',
diff --git a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
index 10b4b7a8826..d53db05e5e6 100644
--- a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
+++ b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
@@ -3,14 +3,14 @@ require 'spec_helper'
describe Gitlab::Ci::Build::Credentials::Factory do
let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
- subject { Gitlab::Ci::Build::Credentials::Factory.new(build).create! }
+ subject { described_class.new(build).create! }
class TestProvider
def initialize(build); end
end
before do
- allow_any_instance_of(Gitlab::Ci::Build::Credentials::Factory).to receive(:providers).and_return([TestProvider])
+ allow_any_instance_of(described_class).to receive(:providers).and_return([TestProvider])
end
context 'when provider is valid' do
diff --git a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
index 84e44dd53e2..c6054138cde 100644
--- a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
+++ b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
@@ -4,14 +4,14 @@ describe Gitlab::Ci::Build::Credentials::Registry do
let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
let(:registry_url) { 'registry.example.com:5005' }
- subject { Gitlab::Ci::Build::Credentials::Registry.new(build) }
+ subject { described_class.new(build) }
before do
stub_container_registry_config(host_port: registry_url)
end
it 'contains valid DockerRegistry credentials' do
- expect(subject).to be_kind_of(Gitlab::Ci::Build::Credentials::Registry)
+ expect(subject).to be_kind_of(described_class)
expect(subject.username).to eq 'gitlab-ci-token'
expect(subject.password).to eq build.token
@@ -20,7 +20,7 @@ describe Gitlab::Ci::Build::Credentials::Registry do
end
describe '.valid?' do
- subject { Gitlab::Ci::Build::Credentials::Registry.new(build).valid? }
+ subject { described_class.new(build).valid? }
context 'when registry is enabled' do
before do
diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb
index 382385dfd6b..773a52cdfbc 100644
--- a/spec/lib/gitlab/ci/build/image_spec.rb
+++ b/spec/lib/gitlab/ci/build/image_spec.rb
@@ -10,12 +10,28 @@ describe Gitlab::Ci::Build::Image do
let(:image_name) { 'ruby:2.1' }
let(:job) { create(:ci_build, options: { image: image_name } ) }
- it 'fabricates an object of the proper class' do
- is_expected.to be_kind_of(described_class)
+ context 'when image is defined as string' do
+ it 'fabricates an object of the proper class' do
+ is_expected.to be_kind_of(described_class)
+ end
+
+ it 'populates fabricated object with the proper name attribute' do
+ expect(subject.name).to eq(image_name)
+ end
end
- it 'populates fabricated object with the proper name attribute' do
- expect(subject.name).to eq(image_name)
+ context 'when image is defined as hash' do
+ let(:entrypoint) { '/bin/sh' }
+ let(:job) { create(:ci_build, options: { image: { name: image_name, entrypoint: entrypoint } } ) }
+
+ it 'fabricates an object of the proper class' do
+ is_expected.to be_kind_of(described_class)
+ end
+
+ it 'populates fabricated object with the proper attributes' do
+ expect(subject.name).to eq(image_name)
+ expect(subject.entrypoint).to eq(entrypoint)
+ end
end
context 'when image name is empty' do
@@ -41,10 +57,39 @@ describe Gitlab::Ci::Build::Image do
let(:service_image_name) { 'postgres' }
let(:job) { create(:ci_build, options: { services: [service_image_name] }) }
- it 'fabricates an non-empty array of objects' do
- is_expected.to be_kind_of(Array)
- is_expected.not_to be_empty
- expect(subject.first.name).to eq(service_image_name)
+ context 'when service is defined as string' do
+ it 'fabricates an non-empty array of objects' do
+ is_expected.to be_kind_of(Array)
+ is_expected.not_to be_empty
+ end
+
+ it 'populates fabricated objects with the proper name attributes' do
+ expect(subject.first).to be_kind_of(described_class)
+ expect(subject.first.name).to eq(service_image_name)
+ end
+ end
+
+ context 'when service is defined as hash' do
+ let(:service_entrypoint) { '/bin/sh' }
+ let(:service_alias) { 'db' }
+ let(:service_command) { 'sleep 30' }
+ let(:job) do
+ create(:ci_build, options: { services: [{ name: service_image_name, entrypoint: service_entrypoint,
+ alias: service_alias, command: service_command }] })
+ end
+
+ it 'fabricates an non-empty array of objects' do
+ is_expected.to be_kind_of(Array)
+ is_expected.not_to be_empty
+ expect(subject.first).to be_kind_of(described_class)
+ end
+
+ it 'populates fabricated objects with the proper attributes' do
+ expect(subject.first.name).to eq(service_image_name)
+ expect(subject.first.entrypoint).to eq(service_entrypoint)
+ expect(subject.first.alias).to eq(service_alias)
+ expect(subject.first.command).to eq(service_command)
+ end
end
context 'when service image name is empty' do
diff --git a/spec/lib/gitlab/ci/build/step_spec.rb b/spec/lib/gitlab/ci/build/step_spec.rb
index 49457b129e3..5a21282712a 100644
--- a/spec/lib/gitlab/ci/build/step_spec.rb
+++ b/spec/lib/gitlab/ci/build/step_spec.rb
@@ -1,21 +1,50 @@
require 'spec_helper'
describe Gitlab::Ci::Build::Step do
- let(:job) { create(:ci_build, :no_options, commands: "ls -la\ndate") }
-
describe '#from_commands' do
- subject { described_class.from_commands(job) }
-
- it 'fabricates an object' do
- expect(subject.name).to eq(:script)
- expect(subject.script).to eq(['ls -la', 'date'])
- expect(subject.timeout).to eq(job.timeout)
- expect(subject.when).to eq('on_success')
- expect(subject.allow_failure).to be_falsey
+ shared_examples 'has correct script' do
+ subject { described_class.from_commands(job) }
+
+ it 'fabricates an object' do
+ expect(subject.name).to eq(:script)
+ expect(subject.script).to eq(script)
+ expect(subject.timeout).to eq(job.timeout)
+ expect(subject.when).to eq('on_success')
+ expect(subject.allow_failure).to be_falsey
+ end
+ end
+
+ context 'when commands are specified' do
+ it_behaves_like 'has correct script' do
+ let(:job) { create(:ci_build, :no_options, commands: "ls -la\ndate") }
+ let(:script) { ['ls -la', 'date'] }
+ end
+ end
+
+ context 'when script option is specified' do
+ it_behaves_like 'has correct script' do
+ let(:job) { create(:ci_build, :no_options, options: { script: ["ls -la\necho aaa", "date"] }) }
+ let(:script) { ["ls -la\necho aaa", 'date'] }
+ end
+ end
+
+ context 'when before and script option is specified' do
+ it_behaves_like 'has correct script' do
+ let(:job) do
+ create(:ci_build, options: {
+ before_script: ["ls -la\necho aaa"],
+ script: ["date"]
+ })
+ end
+
+ let(:script) { ["ls -la\necho aaa", 'date'] }
+ end
end
end
describe '#from_after_script' do
+ let(:job) { create(:ci_build) }
+
subject { described_class.from_after_script(job) }
context 'when after_script is empty' do
diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
index 2ed120f356a..8f711e02f9b 100644
--- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
@@ -1,28 +1,52 @@
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Cache do
- let(:entry) { described_class.new(config) }
+ subject(:entry) { described_class.new(config) }
describe 'validations' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
context 'when entry config value is correct' do
+ let(:policy) { nil }
+
let(:config) do
{ key: 'some key',
untracked: true,
- paths: ['some/path/'] }
+ paths: ['some/path/'],
+ policy: policy }
end
describe '#value' do
it 'returns hash value' do
- expect(entry.value).to eq config
+ expect(entry.value).to eq(key: 'some key', untracked: true, paths: ['some/path/'], policy: 'pull-push')
end
end
describe '#valid?' do
- it 'is valid' do
- expect(entry).to be_valid
- end
+ it { is_expected.to be_valid }
+ end
+
+ context 'policy is pull-push' do
+ let(:policy) { 'pull-push' }
+
+ it { is_expected.to be_valid }
+ it { expect(entry.value).to include(policy: 'pull-push') }
+ end
+
+ context 'policy is push' do
+ let(:policy) { 'push' }
+
+ it { is_expected.to be_valid }
+ it { expect(entry.value).to include(policy: 'push') }
+ end
+
+ context 'policy is pull' do
+ let(:policy) { 'pull' }
+
+ it { is_expected.to be_valid }
+ it { expect(entry.value).to include(policy: 'pull') }
end
context 'when key is missing' do
@@ -42,12 +66,20 @@ describe Gitlab::Ci::Config::Entry::Cache do
context 'when entry value is not correct' do
describe '#errors' do
+ subject { entry.errors }
context 'when is not a hash' do
let(:config) { 'ls' }
it 'reports errors with config value' do
- expect(entry.errors)
- .to include 'cache config should be a hash'
+ is_expected.to include 'cache config should be a hash'
+ end
+ end
+
+ context 'when policy is unknown' do
+ let(:config) { { policy: "unknown" } }
+
+ it 'reports error' do
+ is_expected.to include('cache policy should be pull-push, push, or pull')
end
end
@@ -55,8 +87,7 @@ describe Gitlab::Ci::Config::Entry::Cache do
let(:config) { { key: 1 } }
it 'reports error with descendants' do
- expect(entry.errors)
- .to include 'key config should be a string or symbol'
+ is_expected.to include 'key config should be a string or symbol'
end
end
@@ -64,8 +95,7 @@ describe Gitlab::Ci::Config::Entry::Cache do
let(:config) { { invalid: true } }
it 'reports error with descendants' do
- expect(entry.errors)
- .to include 'cache config contains unknown keys: invalid'
+ is_expected.to include 'cache config contains unknown keys: invalid'
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
index c330e609337..3c0007f4d57 100644
--- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
@@ -3,7 +3,9 @@ require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Environment do
let(:entry) { described_class.new(config) }
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
context 'when configuration is a string' do
let(:config) { 'production' }
diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb
index 684d01e9056..1860ed79bfd 100644
--- a/spec/lib/gitlab/ci/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb
@@ -33,7 +33,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
describe '#compose!' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
it 'creates nodes hash' do
expect(global.descendants).to be_an Array
@@ -79,7 +81,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
context 'when composed' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
describe '#errors' do
it 'has no errors' do
@@ -95,13 +99,13 @@ describe Gitlab::Ci::Config::Entry::Global do
describe '#image_value' do
it 'returns valid image' do
- expect(global.image_value).to eq 'ruby:2.2'
+ expect(global.image_value).to eq(name: 'ruby:2.2')
end
end
describe '#services_value' do
it 'returns array of services' do
- expect(global.services_value).to eq ['postgres:9.1', 'mysql:5.5']
+ expect(global.services_value).to eq [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }]
end
end
@@ -113,7 +117,7 @@ describe Gitlab::Ci::Config::Entry::Global do
describe '#variables_value' do
it 'returns variables' do
- expect(global.variables_value).to eq(VAR: 'value')
+ expect(global.variables_value).to eq('VAR' => 'value')
end
end
@@ -139,7 +143,7 @@ describe Gitlab::Ci::Config::Entry::Global do
describe '#cache_value' do
it 'returns cache configuration' do
expect(global.cache_value)
- .to eq(key: 'k', untracked: true, paths: ['public/'])
+ .to eq(key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push')
end
end
@@ -150,24 +154,24 @@ describe Gitlab::Ci::Config::Entry::Global do
script: %w[rspec ls],
before_script: %w(ls pwd),
commands: "ls\npwd\nrspec\nls",
- image: 'ruby:2.2',
- services: ['postgres:9.1', 'mysql:5.5'],
+ image: { name: 'ruby:2.2' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'] },
- variables: { VAR: 'value' },
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push' },
+ variables: { 'VAR' => 'value' },
ignore: false,
after_script: ['make clean'] },
spinach: { name: :spinach,
before_script: [],
script: %w[spinach],
commands: 'spinach',
- image: 'ruby:2.2',
- services: ['postgres:9.1', 'mysql:5.5'],
+ image: { name: 'ruby:2.2' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'] },
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push' },
variables: {},
ignore: false,
- after_script: ['make clean'] },
+ after_script: ['make clean'] }
)
end
end
@@ -175,7 +179,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
context 'when most of entires not defined' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
let(:hash) do
{ cache: { key: 'a' }, rspec: { script: %w[ls] } }
@@ -206,7 +212,7 @@ describe Gitlab::Ci::Config::Entry::Global do
describe '#cache_value' do
it 'returns correct cache definition' do
- expect(global.cache_value).to eq(key: 'a')
+ expect(global.cache_value).to eq(key: 'a', policy: 'pull-push')
end
end
end
@@ -218,7 +224,9 @@ describe Gitlab::Ci::Config::Entry::Global do
# details.
#
context 'when entires specified but not defined' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
let(:hash) do
{ variables: nil, rspec: { script: 'rspec' } }
@@ -233,7 +241,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
context 'when configuration is not valid' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
context 'when before script is not an array' do
let(:hash) do
@@ -297,7 +307,9 @@ describe Gitlab::Ci::Config::Entry::Global do
end
describe '#[]' do
- before { global.compose! }
+ before do
+ global.compose!
+ end
let(:hash) do
{ cache: { key: 'a' }, rspec: { script: 'ls' } }
diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb
index 3c99cb0a1ee..1a4d9ed5517 100644
--- a/spec/lib/gitlab/ci/config/entry/image_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb
@@ -3,43 +3,104 @@ require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Image do
let(:entry) { described_class.new(config) }
- describe 'validation' do
- context 'when entry config value is correct' do
- let(:config) { 'ruby:2.2' }
+ context 'when configuration is a string' do
+ let(:config) { 'ruby:2.2' }
- describe '#value' do
- it 'returns image string' do
- expect(entry.value).to eq 'ruby:2.2'
- end
+ describe '#value' do
+ it 'returns image hash' do
+ expect(entry.value).to eq({ name: 'ruby:2.2' })
end
+ end
+
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+
+ describe '#image' do
+ it "returns image's name" do
+ expect(entry.name).to eq 'ruby:2.2'
+ end
+ end
- describe '#errors' do
- it 'does not append errors' do
- expect(entry.errors).to be_empty
- end
+ describe '#entrypoint' do
+ it "returns image's entrypoint" do
+ expect(entry.entrypoint).to be_nil
end
+ end
+ end
- describe '#valid?' do
- it 'is valid' do
- expect(entry).to be_valid
- end
+ context 'when configuration is a hash' do
+ let(:config) { { name: 'ruby:2.2', entrypoint: %w(/bin/sh run) } }
+
+ describe '#value' do
+ it 'returns image hash' do
+ expect(entry.value).to eq(config)
end
end
- context 'when entry value is not correct' do
- let(:config) { ['ruby:2.2'] }
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
- describe '#errors' do
- it 'saves errors' do
- expect(entry.errors)
- .to include 'image config should be a string'
- end
+ describe '#image' do
+ it "returns image's name" do
+ expect(entry.name).to eq 'ruby:2.2'
end
+ end
+
+ describe '#entrypoint' do
+ it "returns image's entrypoint" do
+ expect(entry.entrypoint).to eq %w(/bin/sh run)
+ end
+ end
+ end
+
+ context 'when entry value is not correct' do
+ let(:config) { ['ruby:2.2'] }
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include 'image config should be a hash or a string'
+ end
+ end
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+ end
+
+ context 'when unexpected key is specified' do
+ let(:config) { { name: 'ruby:2.2', non_existing: 'test' } }
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include 'image config contains unknown keys: non_existing'
+ end
+ end
- describe '#valid?' do
- it 'is not valid' do
- expect(entry).not_to be_valid
- end
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 9249bb9c172..6769f64f950 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -18,7 +18,9 @@ describe Gitlab::Ci::Config::Entry::Job do
end
describe 'validations' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
context 'when entry config value is correct' do
let(:config) { { script: 'rspec' } }
@@ -78,6 +80,45 @@ describe Gitlab::Ci::Config::Entry::Job do
expect(entry.errors).to include "job script can't be blank"
end
end
+
+ context 'when retry value is not correct' do
+ context 'when it is not a numeric value' do
+ let(:config) { { retry: true } }
+
+ it 'returns error about invalid type' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include 'job retry is not a number'
+ end
+ end
+
+ context 'when it is lower than zero' do
+ let(:config) { { retry: -1 } }
+
+ it 'returns error about value too low' do
+ expect(entry).not_to be_valid
+ expect(entry.errors)
+ .to include 'job retry must be greater than or equal to 0'
+ end
+ end
+
+ context 'when it is not an integer' do
+ let(:config) { { retry: 1.5 } }
+
+ it 'returns error about wrong value' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include 'job retry must be an integer'
+ end
+ end
+
+ context 'when the value is too high' do
+ let(:config) { { retry: 10 } }
+
+ it 'returns error about value too high' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include 'job retry must be less than or equal to 2'
+ end
+ end
+ end
end
end
@@ -97,15 +138,17 @@ describe Gitlab::Ci::Config::Entry::Job do
let(:deps) { double('deps', '[]' => unspecified) }
context 'when job config overrides global config' do
- before { entry.compose!(deps) }
+ before do
+ entry.compose!(deps)
+ end
let(:config) do
{ script: 'rspec', image: 'some_image', cache: { key: 'test' } }
end
it 'overrides global config' do
- expect(entry[:image].value).to eq 'some_image'
- expect(entry[:cache].value).to eq(key: 'test')
+ expect(entry[:image].value).to eq(name: 'some_image')
+ expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push')
end
end
@@ -119,16 +162,20 @@ describe Gitlab::Ci::Config::Entry::Job do
it 'uses config from global entry' do
expect(entry[:image].value).to eq 'specified'
- expect(entry[:cache].value).to eq(key: 'test')
+ expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push')
end
end
end
context 'when composed' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
describe '#value' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
context 'when entry is correct' do
let(:config) do
diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
index 7d104372ac6..c0a2b6517e3 100644
--- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
@@ -4,7 +4,9 @@ describe Gitlab::Ci::Config::Entry::Jobs do
let(:entry) { described_class.new(config) }
describe 'validations' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
context 'when entry config value is correct' do
let(:config) { { rspec: { script: 'rspec' } } }
@@ -48,7 +50,9 @@ describe Gitlab::Ci::Config::Entry::Jobs do
end
context 'when valid job entries composed' do
- before { entry.compose! }
+ before do
+ entry.compose!
+ end
let(:config) do
{ rspec: { script: 'rspec' },
diff --git a/spec/lib/gitlab/ci/config/entry/service_spec.rb b/spec/lib/gitlab/ci/config/entry/service_spec.rb
new file mode 100644
index 00000000000..9ebf947a751
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/service_spec.rb
@@ -0,0 +1,119 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Config::Entry::Service do
+ let(:entry) { described_class.new(config) }
+
+ before do
+ entry.compose!
+ end
+
+ context 'when configuration is a string' do
+ let(:config) { 'postgresql:9.5' }
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+
+ describe '#value' do
+ it 'returns valid hash' do
+ expect(entry.value).to include(name: 'postgresql:9.5')
+ end
+ end
+
+ describe '#image' do
+ it "returns service's image name" do
+ expect(entry.name).to eq 'postgresql:9.5'
+ end
+ end
+
+ describe '#alias' do
+ it "returns service's alias" do
+ expect(entry.alias).to be_nil
+ end
+ end
+
+ describe '#command' do
+ it "returns service's command" do
+ expect(entry.command).to be_nil
+ end
+ end
+ end
+
+ context 'when configuration is a hash' do
+ let(:config) do
+ { name: 'postgresql:9.5', alias: 'db', command: %w(cmd run), entrypoint: %w(/bin/sh run) }
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+
+ describe '#value' do
+ it 'returns valid hash' do
+ expect(entry.value).to eq config
+ end
+ end
+
+ describe '#image' do
+ it "returns service's image name" do
+ expect(entry.name).to eq 'postgresql:9.5'
+ end
+ end
+
+ describe '#alias' do
+ it "returns service's alias" do
+ expect(entry.alias).to eq 'db'
+ end
+ end
+
+ describe '#command' do
+ it "returns service's command" do
+ expect(entry.command).to eq %w(cmd run)
+ end
+ end
+
+ describe '#entrypoint' do
+ it "returns service's entrypoint" do
+ expect(entry.entrypoint).to eq %w(/bin/sh run)
+ end
+ end
+ end
+
+ context 'when entry value is not correct' do
+ let(:config) { ['postgresql:9.5'] }
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include 'service config should be a hash or a string'
+ end
+ end
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+ end
+
+ context 'when unexpected key is specified' do
+ let(:config) { { name: 'postgresql:9.5', non_existing: 'test' } }
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include 'service config contains unknown keys: non_existing'
+ end
+ end
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/services_spec.rb b/spec/lib/gitlab/ci/config/entry/services_spec.rb
index 66fad3b6b16..7c4319aee63 100644
--- a/spec/lib/gitlab/ci/config/entry/services_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/services_spec.rb
@@ -3,37 +3,32 @@ require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Services do
let(:entry) { described_class.new(config) }
- describe 'validations' do
- context 'when entry config value is correct' do
- let(:config) { ['postgres:9.1', 'mysql:5.5'] }
+ before do
+ entry.compose!
+ end
- describe '#value' do
- it 'returns array of services as is' do
- expect(entry.value).to eq config
- end
- end
+ context 'when configuration is valid' do
+ let(:config) { ['postgresql:9.5', { name: 'postgresql:9.1', alias: 'postgres_old' }] }
- describe '#valid?' do
- it 'is valid' do
- expect(entry).to be_valid
- end
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
end
end
- context 'when entry value is not correct' do
- let(:config) { 'ls' }
-
- describe '#errors' do
- it 'saves errors' do
- expect(entry.errors)
- .to include 'services config should be an array of strings'
- end
+ describe '#value' do
+ it 'returns valid array' do
+ expect(entry.value).to eq([{ name: 'postgresql:9.5' }, { name: 'postgresql:9.1', alias: 'postgres_old' }])
end
+ end
+ end
+
+ context 'when configuration is invalid' do
+ let(:config) { 'postgresql:9.5' }
- describe '#valid?' do
- it 'is not valid' do
- expect(entry).not_to be_valid
- end
+ describe '#valid?' do
+ it 'is invalid' do
+ expect(entry).not_to be_valid
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
index f15f02f403e..84bfef9e8ad 100644
--- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
@@ -13,6 +13,14 @@ describe Gitlab::Ci::Config::Entry::Variables do
it 'returns hash with key value strings' do
expect(entry.value).to eq config
end
+
+ context 'with numeric keys and values in the config' do
+ let(:config) { { 10 => 20 } }
+
+ it 'converts numeric key and numeric value into strings' do
+ expect(entry.value).to eq('10' => '20')
+ end
+ end
end
describe '#errors' do
diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb
index 0864bc7258d..809fda11879 100644
--- a/spec/lib/gitlab/ci/cron_parser_spec.rb
+++ b/spec/lib/gitlab/ci/cron_parser_spec.rb
@@ -60,14 +60,60 @@ describe Gitlab::Ci::CronParser do
end
end
- context 'when cron_timezone is US/Pacific' do
- let(:cron) { '0 0 * * *' }
- let(:cron_timezone) { 'US/Pacific' }
+ context 'when cron_timezone is TZInfo format' do
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone['UTC'])
+ end
- it_behaves_like "returns time in the future"
+ let(:hour_in_utc) do
+ ActiveSupport::TimeZone[cron_timezone]
+ .now.change(hour: 0).in_time_zone('UTC').hour
+ end
+
+ context 'when cron_timezone is US/Pacific' do
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'US/Pacific' }
+
+ it_behaves_like "returns time in the future"
+
+ it 'converts time in server time zone' do
+ expect(subject.hour).to eq(hour_in_utc)
+ end
+ end
+ end
+
+ context 'when cron_timezone is ActiveSupport::TimeZone format' do
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone['UTC'])
+ end
+
+ let(:hour_in_utc) do
+ ActiveSupport::TimeZone[cron_timezone]
+ .now.change(hour: 0).in_time_zone('UTC').hour
+ end
+
+ context 'when cron_timezone is Berlin' do
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'Berlin' }
+
+ it_behaves_like "returns time in the future"
+
+ it 'converts time in server time zone' do
+ expect(subject.hour).to eq(hour_in_utc)
+ end
+ end
- it 'converts time in server time zone' do
- expect(subject.hour).to eq((Time.zone.now.in_time_zone(cron_timezone).utc_offset / 60 / 60).abs)
+ context 'when cron_timezone is Eastern Time (US & Canada)' do
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'Eastern Time (US & Canada)' }
+
+ it_behaves_like "returns time in the future"
+
+ it 'converts time in server time zone' do
+ expect(subject.hour).to eq(hour_in_utc)
+ end
end
end
end
@@ -76,9 +122,21 @@ describe Gitlab::Ci::CronParser do
let(:cron) { 'invalid_cron' }
let(:cron_timezone) { 'invalid_cron_timezone' }
- it 'returns nil' do
- is_expected.to be_nil
- end
+ it { is_expected.to be_nil }
+ end
+
+ context 'when cron syntax is quoted' do
+ let(:cron) { "'0 * * * *'" }
+ let(:cron_timezone) { 'UTC' }
+
+ it { expect(subject).to be_nil }
+ end
+
+ context 'when cron syntax is rufus-scheduler syntax' do
+ let(:cron) { 'every 3h' }
+ let(:cron_timezone) { 'UTC' }
+
+ it { expect(subject).to be_nil }
end
end
@@ -96,6 +154,12 @@ describe Gitlab::Ci::CronParser do
it { is_expected.to eq(false) }
end
+
+ context 'when cron syntax is quoted' do
+ let(:cron) { "'0 * * * *'" }
+
+ it { is_expected.to eq(false) }
+ end
end
describe '#cron_timezone_valid?' do
@@ -112,5 +176,11 @@ describe Gitlab::Ci::CronParser do
it { is_expected.to eq(false) }
end
+
+ context 'when cron_timezone is ActiveSupport::TimeZone format' do
+ let(:cron_timezone) { 'Eastern Time (US & Canada)' }
+
+ it { is_expected.to eq(true) }
+ end
end
end
diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb
new file mode 100644
index 00000000000..d7e91a5a62c
--- /dev/null
+++ b/spec/lib/gitlab/ci/stage/seed_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Stage::Seed do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ let(:builds) do
+ [{ name: 'rspec' }, { name: 'spinach' }]
+ end
+
+ subject do
+ described_class.new(pipeline, 'test', builds)
+ end
+
+ describe '#stage' do
+ it 'returns hash attributes of a stage' do
+ expect(subject.stage).to be_a Hash
+ expect(subject.stage).to include(:name, :project)
+ end
+ end
+
+ describe '#builds' do
+ it 'returns hash attributes of all builds' do
+ expect(subject.builds.size).to eq 2
+ expect(subject.builds).to all(include(ref: 'master'))
+ expect(subject.builds).to all(include(tag: false))
+ expect(subject.builds).to all(include(project: pipeline.project))
+ expect(subject.builds)
+ .to all(include(trigger_request: pipeline.trigger_requests.first))
+ end
+ end
+
+ describe '#user=' do
+ let(:user) { build(:user) }
+
+ it 'assignes relevant pipeline attributes' do
+ subject.user = user
+
+ expect(subject.builds).to all(include(user: user))
+ end
+ end
+
+ describe '#create!' do
+ it 'creates all stages and builds' do
+ subject.create!
+
+ expect(pipeline.reload.stages.count).to eq 1
+ expect(pipeline.reload.builds.count).to eq 2
+ expect(pipeline.builds).to all(satisfy { |job| job.stage_id.present? })
+ expect(pipeline.builds).to all(satisfy { |job| job.pipeline.present? })
+ expect(pipeline.builds).to all(satisfy { |job| job.project.present? })
+ expect(pipeline.stages)
+ .to all(satisfy { |stage| stage.pipeline.present? })
+ expect(pipeline.stages)
+ .to all(satisfy { |stage| stage.project.present? })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/build/action_spec.rb b/spec/lib/gitlab/ci/status/build/action_spec.rb
new file mode 100644
index 00000000000..8c25f72804b
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/action_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Action do
+ let(:status) { double('core status') }
+ let(:user) { double('user') }
+
+ subject do
+ described_class.new(status)
+ end
+
+ describe '#label' do
+ before do
+ allow(status).to receive(:label).and_return('label')
+ end
+
+ context 'when status has action' do
+ before do
+ allow(status).to receive(:has_action?).and_return(true)
+ end
+
+ it 'does not append text' do
+ expect(subject.label).to eq 'label'
+ end
+ end
+
+ context 'when status does not have action' do
+ before do
+ allow(status).to receive(:has_action?).and_return(false)
+ end
+
+ it 'appends text about action not allowed' do
+ expect(subject.label).to eq 'label (not allowed)'
+ end
+ end
+ end
+
+ describe '.matches?' do
+ subject { described_class.matches?(build, user) }
+
+ context 'when build is an action' do
+ let(:build) { create(:ci_build, :manual) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not manual' do
+ let(:build) { create(:ci_build) }
+
+ it 'does not match' do
+ expect(subject).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb
index 8ad9b7cdf07..5a7a42d84c0 100644
--- a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb
@@ -47,7 +47,11 @@ describe Gitlab::Ci::Status::Build::Cancelable do
describe '#has_action?' do
context 'when user is allowed to update build' do
- before { build.project.team << [user, :developer] }
+ before do
+ stub_not_protect_default_branch
+
+ build.project.add_developer(user)
+ end
it { is_expected.to have_action }
end
diff --git a/spec/lib/gitlab/ci/status/build/common_spec.rb b/spec/lib/gitlab/ci/status/build/common_spec.rb
index 40b96b1807b..03d1f46b517 100644
--- a/spec/lib/gitlab/ci/status/build/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/common_spec.rb
@@ -17,13 +17,17 @@ describe Gitlab::Ci::Status::Build::Common do
describe '#has_details?' do
context 'when user has access to read build' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it { is_expected.to have_details }
end
context 'when user does not have access to read build' do
- before { project.update(public_builds: false) }
+ before do
+ project.update(public_builds: false)
+ end
it { is_expected.not_to have_details }
end
@@ -31,7 +35,7 @@ describe Gitlab::Ci::Status::Build::Common do
describe '#details_path' do
it 'links to the build details page' do
- expect(subject.details_path).to include "builds/#{build.id}"
+ expect(subject.details_path).to include "jobs/#{build.id}"
end
end
end
diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb
index e648a3ac3a2..8768302eda1 100644
--- a/spec/lib/gitlab/ci/status/build/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb
@@ -6,7 +6,11 @@ describe Gitlab::Ci::Status::Build::Factory do
let(:status) { factory.fabricate! }
let(:factory) { described_class.new(build, user) }
- before { project.team << [user, :developer] }
+ before do
+ stub_not_protect_default_branch
+
+ project.add_developer(user)
+ end
context 'when build is successful' do
let(:build) { create(:ci_build, :success) }
@@ -204,11 +208,12 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
- .to eq [Gitlab::Ci::Status::Build::Play]
+ .to eq [Gitlab::Ci::Status::Build::Play,
+ Gitlab::Ci::Status::Build::Action]
end
- it 'fabricates a play detailed status' do
- expect(status).to be_a Gitlab::Ci::Status::Build::Play
+ it 'fabricates action detailed status' do
+ expect(status).to be_a Gitlab::Ci::Status::Build::Action
end
it 'fabricates status with correct details' do
@@ -216,11 +221,29 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(status.group).to eq 'manual'
expect(status.icon).to eq 'icon_status_manual'
expect(status.favicon).to eq 'favicon_status_manual'
- expect(status.label).to eq 'manual play action'
+ expect(status.label).to include 'manual play action'
expect(status).to have_details
- expect(status).to have_action
expect(status.action_path).to include 'play'
end
+
+ context 'when user has ability to play action' do
+ it 'fabricates status that has action' do
+ expect(status).to have_action
+ end
+ end
+
+ context 'when user does not have ability to play action' do
+ before do
+ allow(build.project).to receive(:empty_repo?).and_return(false)
+
+ create(:protected_branch, :no_one_can_push,
+ name: build.ref, project: build.project)
+ end
+
+ it 'fabricates status that has no action' do
+ expect(status).not_to have_action
+ end
+ end
end
context 'when build is an environment stop action' do
@@ -232,21 +255,31 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
- .to eq [Gitlab::Ci::Status::Build::Stop]
+ .to eq [Gitlab::Ci::Status::Build::Stop,
+ Gitlab::Ci::Status::Build::Action]
end
- it 'fabricates a stop detailed status' do
- expect(status).to be_a Gitlab::Ci::Status::Build::Stop
+ it 'fabricates action detailed status' do
+ expect(status).to be_a Gitlab::Ci::Status::Build::Action
end
- it 'fabricates status with correct details' do
- expect(status.text).to eq 'manual'
- expect(status.group).to eq 'manual'
- expect(status.icon).to eq 'icon_status_manual'
- expect(status.favicon).to eq 'favicon_status_manual'
- expect(status.label).to eq 'manual stop action'
- expect(status).to have_details
- expect(status).to have_action
+ context 'when user is not allowed to execute manual action' do
+ before do
+ allow(build.project).to receive(:empty_repo?).and_return(false)
+
+ create(:protected_branch, :no_one_can_push,
+ name: build.ref, project: build.project)
+ end
+
+ it 'fabricates status with correct details' do
+ expect(status.text).to eq 'manual'
+ expect(status.group).to eq 'manual'
+ expect(status.icon).to eq 'icon_status_manual'
+ expect(status.favicon).to eq 'favicon_status_manual'
+ expect(status.label).to eq 'manual stop action (not allowed)'
+ expect(status).to have_details
+ expect(status).not_to have_action
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb
index 6c97a4fe5ca..32b2e62e4e0 100644
--- a/spec/lib/gitlab/ci/status/build/play_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/play_spec.rb
@@ -1,43 +1,56 @@
require 'spec_helper'
describe Gitlab::Ci::Status::Build::Play do
- let(:status) { double('core') }
- let(:user) { double('user') }
+ let(:user) { create(:user) }
+ let(:project) { build.project }
+ let(:build) { create(:ci_build, :manual) }
+ let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
subject { described_class.new(status) }
describe '#label' do
- it { expect(subject.label).to eq 'manual play action' }
+ it 'has a label that says it is a manual action' do
+ expect(subject.label).to eq 'manual play action'
+ end
end
- describe 'action details' do
- let(:user) { create(:user) }
- let(:build) { create(:ci_build) }
- let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
+ describe '#has_action?' do
+ context 'when user is allowed to update build' do
+ context 'when user is allowed to trigger protected action' do
+ before do
+ project.add_developer(user)
- describe '#has_action?' do
- context 'when user is allowed to update build' do
- before { build.project.team << [user, :developer] }
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+ end
it { is_expected.to have_action }
end
- context 'when user is not allowed to update build' do
+ context 'when user can not push to the branch' do
+ before do
+ build.project.add_developer(user)
+ end
+
it { is_expected.not_to have_action }
end
end
- describe '#action_path' do
- it { expect(subject.action_path).to include "#{build.id}/play" }
+ context 'when user is not allowed to update build' do
+ it { is_expected.not_to have_action }
end
+ end
- describe '#action_icon' do
- it { expect(subject.action_icon).to eq 'icon_action_play' }
- end
+ describe '#action_path' do
+ it { expect(subject.action_path).to include "#{build.id}/play" }
+ end
- describe '#action_title' do
- it { expect(subject.action_title).to eq 'Play' }
- end
+ describe '#action_icon' do
+ it { expect(subject.action_icon).to eq 'icon_action_play' }
+ end
+
+ describe '#action_title' do
+ it { expect(subject.action_title).to eq 'Play' }
end
describe '.matches?' do
diff --git a/spec/lib/gitlab/ci/status/build/retryable_spec.rb b/spec/lib/gitlab/ci/status/build/retryable_spec.rb
index 2db0f8d29bd..21026f2c968 100644
--- a/spec/lib/gitlab/ci/status/build/retryable_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/retryable_spec.rb
@@ -47,7 +47,11 @@ describe Gitlab::Ci::Status::Build::Retryable do
describe '#has_action?' do
context 'when user is allowed to update build' do
- before { build.project.team << [user, :developer] }
+ before do
+ stub_not_protect_default_branch
+
+ build.project.add_developer(user)
+ end
it { is_expected.to have_action }
end
diff --git a/spec/lib/gitlab/ci/status/build/stop_spec.rb b/spec/lib/gitlab/ci/status/build/stop_spec.rb
index 8d021c35a69..e0425103f41 100644
--- a/spec/lib/gitlab/ci/status/build/stop_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/stop_spec.rb
@@ -19,7 +19,11 @@ describe Gitlab::Ci::Status::Build::Stop do
describe '#has_action?' do
context 'when user is allowed to update build' do
- before { build.project.team << [user, :developer] }
+ before do
+ stub_not_protect_default_branch
+
+ build.project.add_developer(user)
+ end
it { is_expected.to have_action }
end
diff --git a/spec/lib/gitlab/ci/status/extended_spec.rb b/spec/lib/gitlab/ci/status/extended_spec.rb
index c2d74ca5cde..6eacb07078b 100644
--- a/spec/lib/gitlab/ci/status/extended_spec.rb
+++ b/spec/lib/gitlab/ci/status/extended_spec.rb
@@ -1,12 +1,8 @@
require 'spec_helper'
describe Gitlab::Ci::Status::Extended do
- subject do
- Class.new.include(described_class)
- end
-
it 'requires subclass to implement matcher' do
- expect { subject.matches?(double, double) }
+ expect { described_class.matches?(double, double) }
.to raise_error(NotImplementedError)
end
end
diff --git a/spec/lib/gitlab/ci/status/external/common_spec.rb b/spec/lib/gitlab/ci/status/external/common_spec.rb
index 5a97d98b55f..b38fbee2486 100644
--- a/spec/lib/gitlab/ci/status/external/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/external/common_spec.rb
@@ -4,9 +4,10 @@ describe Gitlab::Ci::Status::External::Common do
let(:user) { create(:user) }
let(:project) { external_status.project }
let(:external_target_url) { 'http://example.gitlab.com/status' }
+ let(:external_description) { 'my description' }
let(:external_status) do
- create(:generic_commit_status, target_url: external_target_url)
+ create(:generic_commit_status, target_url: external_target_url, description: external_description)
end
subject do
@@ -15,13 +16,21 @@ describe Gitlab::Ci::Status::External::Common do
.extend(described_class)
end
+ describe '#label' do
+ it 'returns description' do
+ expect(subject.label).to eq external_description
+ end
+ end
+
describe '#has_action?' do
it { is_expected.not_to have_action }
end
describe '#has_details?' do
context 'when user has access to read commit status' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it { is_expected.to have_details }
end
diff --git a/spec/lib/gitlab/ci/status/group/common_spec.rb b/spec/lib/gitlab/ci/status/group/common_spec.rb
new file mode 100644
index 00000000000..c0ca05881f5
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/group/common_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Group::Common do
+ subject do
+ Gitlab::Ci::Status::Core.new(double, double)
+ .extend(described_class)
+ end
+
+ it 'does not have action' do
+ expect(subject).not_to have_action
+ end
+
+ it 'has details' do
+ expect(subject).not_to have_details
+ end
+
+ it 'has no details_path' do
+ expect(subject.details_path).to be_falsy
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/group/factory_spec.rb b/spec/lib/gitlab/ci/status/group/factory_spec.rb
new file mode 100644
index 00000000000..0cd83123938
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/group/factory_spec.rb
@@ -0,0 +1,13 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Group::Factory do
+ it 'inherits from the core factory' do
+ expect(described_class)
+ .to be < Gitlab::Ci::Status::Factory
+ end
+
+ it 'exposes group helpers' do
+ expect(described_class.common_helpers)
+ .to eq Gitlab::Ci::Status::Group::Common
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/pipeline/common_spec.rb b/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
index d665674bf70..4a5b45e7cae 100644
--- a/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Ci::Status::Pipeline::Common do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
let(:pipeline) { create(:ci_pipeline, project: project) }
subject do
@@ -17,7 +17,9 @@ describe Gitlab::Ci::Status::Pipeline::Common do
describe '#has_details?' do
context 'when user has access to read pipeline' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it { is_expected.to have_details }
end
diff --git a/spec/lib/gitlab/ci/status/stage/common_spec.rb b/spec/lib/gitlab/ci/status/stage/common_spec.rb
index 8814a7614a0..f5f03ac0395 100644
--- a/spec/lib/gitlab/ci/status/stage/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/common_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Ci::Status::Stage::Common do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:stage) do
diff --git a/spec/lib/gitlab/ci/status/stage/factory_spec.rb b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
index bbb40e2c1ab..432b07e4902 100644
--- a/spec/lib/gitlab/ci/status/stage/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Ci::Status::Stage::Factory do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:stage) do
diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb
index 40ac5a3ed37..e5555546fa8 100644
--- a/spec/lib/gitlab/ci/trace/stream_spec.rb
+++ b/spec/lib/gitlab/ci/trace/stream_spec.rb
@@ -240,9 +240,50 @@ describe Gitlab::Ci::Trace::Stream do
end
context 'multiple results in content & regex' do
- let(:data) { ' (98.39%) covered. (98.29%) covered' }
+ let(:data) do
+ <<~HEREDOC
+ (98.39%) covered
+ (98.29%) covered
+ HEREDOC
+ end
+
+ let(:regex) { '\(\d+.\d+\%\) covered' }
+
+ it 'returns the last matched coverage' do
+ is_expected.to eq("98.29")
+ end
+ end
+
+ context 'when BUFFER_SIZE is smaller than stream.size' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
+ let(:regex) { '\(\d+.\d+\%\) covered' }
+
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ end
+
+ it { is_expected.to eq("98.29") }
+ end
+
+ context 'when regex is multi-byte char' do
+ let(:data) { '95.0 ゴッドファット\n' }
+ let(:regex) { '\d+\.\d+ ゴッドファット' }
+
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ end
+
+ it { is_expected.to eq('95.0') }
+ end
+
+ context 'when BUFFER_SIZE is equal to stream.size' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
let(:regex) { '\(\d+.\d+\%\) covered' }
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length)
+ end
+
it { is_expected.to eq("98.29") }
end
@@ -252,5 +293,55 @@ describe Gitlab::Ci::Trace::Stream do
it { is_expected.to eq("65") }
end
+
+ context 'malicious regexp' do
+ let(:data) { malicious_text }
+ let(:regex) { malicious_regexp }
+
+ include_examples 'malicious regexp'
+ end
+
+ context 'multi-line data with rooted regexp' do
+ let(:data) { "\n65%\n" }
+ let(:regex) { '^(\d+)\%$' }
+
+ it { is_expected.to eq('65') }
+ end
+
+ context 'long line' do
+ let(:data) { 'a' * 80000 + '100%' + 'a' * 80000 }
+ let(:regex) { '\d+\%' }
+
+ it { is_expected.to eq('100') }
+ end
+
+ context 'many lines' do
+ let(:data) { "foo\n" * 80000 + "100%\n" + "foo\n" * 80000 }
+ let(:regex) { '\d+\%' }
+
+ it { is_expected.to eq('100') }
+ end
+
+ context 'empty regex' do
+ let(:data) { 'foo' }
+ let(:regex) { '' }
+
+ it 'skips processing' do
+ expect(stream).not_to receive(:read)
+
+ is_expected.to be_nil
+ end
+ end
+
+ context 'nil regex' do
+ let(:data) { 'foo' }
+ let(:regex) { nil }
+
+ it 'skips processing' do
+ expect(stream).not_to receive(:read)
+
+ is_expected.to be_nil
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci_access_spec.rb b/spec/lib/gitlab/ci_access_spec.rb
new file mode 100644
index 00000000000..75b90e76083
--- /dev/null
+++ b/spec/lib/gitlab/ci_access_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Gitlab::CiAccess do
+ let(:access) { described_class.new }
+
+ describe '#can_do_action?' do
+ context 'when action is :build_download_code' do
+ it { expect(access.can_do_action?(:build_download_code)).to be_truthy }
+ end
+
+ context 'when action is not :build_download_code' do
+ it { expect(access.can_do_action?(:download_code)).to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb
index 97af1c2523d..15012495247 100644
--- a/spec/lib/gitlab/closing_issue_extractor_spec.rb
+++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::ClosingIssueExtractor, lib: true do
- let(:project) { create(:empty_project) }
- let(:project2) { create(:empty_project) }
+describe Gitlab::ClosingIssueExtractor do
+ let(:project) { create(:project) }
+ let(:project2) { create(:project) }
let(:forked_project) { Projects::ForkService.new(project, project.creator).execute }
let(:issue) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project2) }
@@ -276,7 +276,7 @@ describe Gitlab::ClosingIssueExtractor, lib: true do
context "with a cross-project URL" do
it do
- message = "Closes #{urls.namespace_project_issue_url(issue2.project.namespace, issue2.project, issue2)}"
+ message = "Closes #{urls.project_issue_url(issue2.project, issue2)}"
expect(subject.closed_by_message(message)).to eq([issue2])
end
end
@@ -292,7 +292,7 @@ describe Gitlab::ClosingIssueExtractor, lib: true do
context "with an invalid URL" do
it do
- message = "Closes https://google.com#{urls.namespace_project_issue_path(issue2.project.namespace, issue2.project, issue2)}"
+ message = "Closes https://google.com#{urls.project_issue_path(issue2.project, issue2)}"
expect(subject.closed_by_message(message)).to eq([])
end
end
@@ -306,58 +306,58 @@ describe Gitlab::ClosingIssueExtractor, lib: true do
it 'fetches issues in single line message' do
message = "Closes #{reference} and fix #{reference2}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, other_issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue])
end
it 'fetches comma-separated issues references in single line message' do
message = "Closes #{reference}, closes #{reference2}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, other_issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue])
end
it 'fetches comma-separated issues numbers in single line message' do
message = "Closes #{reference}, #{reference2} and #{reference3}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, other_issue, third_issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue, third_issue])
end
it 'fetches issues in multi-line message' do
message = "Awesome commit (closes #{reference})\nAlso fixes #{reference2}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, other_issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue])
end
it 'fetches issues in hybrid message' do
message = "Awesome commit (closes #{reference})\n"\
"Also fixing issues #{reference2}, #{reference3} and #4"
- expect(subject.closed_by_message(message)).
- to match_array([issue, other_issue, third_issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue, third_issue])
end
it "fetches cross-project references" do
message = "Closes #{reference} and #{cross_reference}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, issue2])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, issue2])
end
it "fetches cross-project URL references" do
- message = "Closes #{urls.namespace_project_issue_url(issue2.project.namespace, issue2.project, issue2)} and #{reference}"
+ message = "Closes #{urls.project_issue_url(issue2.project, issue2)} and #{reference}"
- expect(subject.closed_by_message(message)).
- to match_array([issue, issue2])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, issue2])
end
it "ignores invalid cross-project URL references" do
- message = "Closes https://google.com#{urls.namespace_project_issue_path(issue2.project.namespace, issue2.project, issue2)} and #{reference}"
+ message = "Closes https://google.com#{urls.project_issue_path(issue2.project, issue2)} and #{reference}"
- expect(subject.closed_by_message(message)).
- to match_array([issue])
+ expect(subject.closed_by_message(message))
+ .to match_array([issue])
end
end
end
diff --git a/spec/lib/gitlab/color_schemes_spec.rb b/spec/lib/gitlab/color_schemes_spec.rb
index 0a1ec66f199..c7be45dbcd3 100644
--- a/spec/lib/gitlab/color_schemes_spec.rb
+++ b/spec/lib/gitlab/color_schemes_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::ColorSchemes, lib: true do
+describe Gitlab::ColorSchemes do
describe '.body_classes' do
it 'returns a space-separated list of class names' do
css = described_class.body_classes
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
index 39d892c18c0..a4d7628b03a 100644
--- a/spec/lib/gitlab/conflict/file_collection_spec.rb
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::Conflict::FileCollection, lib: true do
+describe Gitlab::Conflict::FileCollection do
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
- let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) }
+ let(:file_collection) { described_class.read_only(merge_request) }
describe '#files' do
it 'returns an array of Conflict::Files' do
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
index 780ac0ad97e..5356e9742b4 100644
--- a/spec/lib/gitlab/conflict/file_spec.rb
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Conflict::File, lib: true do
+describe Gitlab::Conflict::File do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:rugged) { repository.rugged }
@@ -10,7 +10,7 @@ describe Gitlab::Conflict::File, lib: true do
let(:index) { rugged.merge_commits(our_commit, their_commit) }
let(:conflict) { index.conflicts.last }
let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
- let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) }
+ let(:conflict_file) { described_class.new(merge_file_result, conflict, merge_request: merge_request) }
describe '#resolve_lines' do
let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
@@ -43,8 +43,8 @@ describe Gitlab::Conflict::File, lib: true do
end
it 'returns a file containing only the chosen parts of the resolved sections' do
- expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)).
- to eq(%w(both new both old both new both))
+ expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first))
+ .to eq(%w(both new both old both new both))
end
end
@@ -52,14 +52,14 @@ describe Gitlab::Conflict::File, lib: true do
empty_hash = section_keys.map { |key| [key, nil] }.to_h
invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
- expect { conflict_file.resolve_lines({}) }.
- to raise_error(Gitlab::Conflict::File::MissingResolution)
+ expect { conflict_file.resolve_lines({}) }
+ .to raise_error(Gitlab::Conflict::File::MissingResolution)
- expect { conflict_file.resolve_lines(empty_hash) }.
- to raise_error(Gitlab::Conflict::File::MissingResolution)
+ expect { conflict_file.resolve_lines(empty_hash) }
+ .to raise_error(Gitlab::Conflict::File::MissingResolution)
- expect { conflict_file.resolve_lines(invalid_hash) }.
- to raise_error(Gitlab::Conflict::File::MissingResolution)
+ expect { conflict_file.resolve_lines(invalid_hash) }
+ .to raise_error(Gitlab::Conflict::File::MissingResolution)
end
end
@@ -220,7 +220,7 @@ end
FILE
end
- let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) }
+ let(:conflict_file) { described_class.new({ data: file }, conflict, merge_request: merge_request) }
let(:sections) { conflict_file.sections }
it 'sets the correct match line headers' do
@@ -250,8 +250,8 @@ FILE
describe '#as_json' do
it 'includes the blob path for the file' do
- expect(conflict_file.as_json[:blob_path]).
- to eq("/#{project.full_path}/blob/#{our_commit.oid}/files/ruby/regex.rb")
+ expect(conflict_file.as_json[:blob_path])
+ .to eq("/#{project.full_path}/blob/#{our_commit.oid}/files/ruby/regex.rb")
end
it 'includes the blob icon for the file' do
@@ -264,8 +264,8 @@ FILE
end
it 'includes the detected language of the conflict file' do
- expect(conflict_file.as_json(full_content: true)[:blob_ace_mode]).
- to eq('ruby')
+ expect(conflict_file.as_json(full_content: true)[:blob_ace_mode])
+ .to eq('ruby')
end
end
end
diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb
index 2570f95dd21..fce606a2bb5 100644
--- a/spec/lib/gitlab/conflict/parser_spec.rb
+++ b/spec/lib/gitlab/conflict/parser_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::Conflict::Parser, lib: true do
- let(:parser) { Gitlab::Conflict::Parser.new }
+describe Gitlab::Conflict::Parser do
+ let(:parser) { described_class.new }
describe '#parse' do
def parse_text(text)
@@ -122,18 +122,18 @@ CONFLICT
context 'when the file contents include conflict delimiters' do
context 'when there is a non-start delimiter first' do
it 'raises UnexpectedDelimiter when there is a middle delimiter first' do
- expect { parse_text('=======') }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text('=======') }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when there is an end delimiter first' do
- expect { parse_text('>>>>>>> README.md') }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text('>>>>>>> README.md') }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when there is an end delimiter for a different path first' do
- expect { parse_text('>>>>>>> some-other-path.md') }.
- not_to raise_error
+ expect { parse_text('>>>>>>> some-other-path.md') }
+ .not_to raise_error
end
end
@@ -142,18 +142,18 @@ CONFLICT
let(:end_text) { "\n=======\n>>>>>>> README.md" }
it 'raises UnexpectedDelimiter when it is followed by an end delimiter' do
- expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by another start delimiter' do
- expect { parse_text(start_text + start_text + end_text) }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text(start_text + start_text + end_text) }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for a different path' do
- expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
- not_to raise_error
+ expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }
+ .not_to raise_error
end
end
@@ -162,59 +162,59 @@ CONFLICT
let(:end_text) { "\n>>>>>>> README.md" }
it 'raises UnexpectedDelimiter when it is followed by another middle delimiter' do
- expect { parse_text(start_text + '=======' + end_text) }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text(start_text + '=======' + end_text) }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by a start delimiter' do
- expect { parse_text(start_text + start_text + end_text) }.
- to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ expect { parse_text(start_text + start_text + end_text) }
+ .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for another path' do
- expect { parse_text(start_text + '<<<<<<< some-other-path.md' + end_text) }.
- not_to raise_error
+ expect { parse_text(start_text + '<<<<<<< some-other-path.md' + end_text) }
+ .not_to raise_error
end
end
it 'raises MissingEndDelimiter when there is no end delimiter at the end' do
start_text = "<<<<<<< README.md\n=======\n"
- expect { parse_text(start_text) }.
- to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ expect { parse_text(start_text) }
+ .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
- expect { parse_text(start_text + '>>>>>>> some-other-path.md') }.
- to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ expect { parse_text(start_text + '>>>>>>> some-other-path.md') }
+ .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
end
end
context 'other file types' do
it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
- expect { parse_text('') }.
- to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ expect { parse_text('') }
+ .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
- expect { parse_text(nil) }.
- to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ expect { parse_text(nil) }
+ .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
it 'raises UnmergeableFile when the file is over 200 KB' do
- expect { parse_text('a' * 204801) }.
- to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ expect { parse_text('a' * 204801) }
+ .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
# All text from Rugged has an encoding of ASCII_8BIT, so force that in
# these strings.
context 'when the file contains UTF-8 characters' do
it 'does not raise' do
- expect { parse_text("Espa\xC3\xB1a".force_encoding(Encoding::ASCII_8BIT)) }.
- not_to raise_error
+ expect { parse_text("Espa\xC3\xB1a".force_encoding(Encoding::ASCII_8BIT)) }
+ .not_to raise_error
end
end
context 'when the file contains non-UTF-8 characters' do
it 'raises UnsupportedEncoding' do
- expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }.
- to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding)
+ expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }
+ .to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding)
end
end
end
diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb
index e18a219ef36..f1655854486 100644
--- a/spec/lib/gitlab/contributions_calendar_spec.rb
+++ b/spec/lib/gitlab/contributions_calendar_spec.rb
@@ -5,29 +5,31 @@ describe Gitlab::ContributionsCalendar do
let(:user) { create(:user) }
let(:private_project) do
- create(:empty_project, :private) do |project|
+ create(:project, :private) do |project|
create(:project_member, user: contributor, project: project)
end
end
let(:public_project) do
- create(:empty_project, :public) do |project|
+ create(:project, :public) do |project|
create(:project_member, user: contributor, project: project)
end
end
let(:feature_project) do
- create(:empty_project, :public, :issues_private) do |project|
+ create(:project, :public, :issues_private) do |project|
create(:project_member, user: contributor, project: project).project
end
end
- let(:today) { Time.now.to_date }
+ let(:today) { Time.now.utc.to_date }
+ let(:yesterday) { today - 1.day }
+ let(:tomorrow) { today + 1.day }
let(:last_week) { today - 7.days }
let(:last_year) { today - 1.year }
before do
- travel_to today
+ travel_to Time.now.utc.end_of_day
end
after do
@@ -38,7 +40,7 @@ describe Gitlab::ContributionsCalendar do
described_class.new(contributor, current_user)
end
- def create_event(project, day)
+ def create_event(project, day, hour = 0)
@targets ||= {}
@targets[project] ||= create(:issue, project: project, author: contributor)
@@ -47,7 +49,7 @@ describe Gitlab::ContributionsCalendar do
action: Event::CREATED,
target: @targets[project],
author: contributor,
- created_at: day,
+ created_at: DateTime.new(day.year, day.month, day.day, hour)
)
end
@@ -68,6 +70,34 @@ describe Gitlab::ContributionsCalendar do
expect(calendar(user).activity_dates[today]).to eq(0)
expect(calendar(contributor).activity_dates[today]).to eq(2)
end
+
+ context "when events fall under different dates depending on the time zone" do
+ before do
+ create_event(public_project, today, 1)
+ create_event(public_project, today, 4)
+ create_event(public_project, today, 10)
+ create_event(public_project, today, 16)
+ create_event(public_project, today, 23)
+ end
+
+ it "renders correct event counts within the UTC timezone" do
+ Time.use_zone('UTC') do
+ expect(calendar.activity_dates).to eq(today => 5)
+ end
+ end
+
+ it "renders correct event counts within the Sydney timezone" do
+ Time.use_zone('Sydney') do
+ expect(calendar.activity_dates).to eq(today => 3, tomorrow => 2)
+ end
+ end
+
+ it "renders correct event counts within the US Central timezone" do
+ Time.use_zone('Central Time (US & Canada)') do
+ expect(calendar.activity_dates).to eq(yesterday => 2, today => 3)
+ end
+ end
+ end
end
describe '#events_by_date' do
diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb
index b01c4805a34..d57ffcae8e1 100644
--- a/spec/lib/gitlab/current_settings_spec.rb
+++ b/spec/lib/gitlab/current_settings_spec.rb
@@ -10,33 +10,78 @@ describe Gitlab::CurrentSettings do
describe '#current_application_settings' do
context 'with DB available' do
before do
- allow_any_instance_of(Gitlab::CurrentSettings).to receive(:connect_to_db?).and_return(true)
+ allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(true)
end
it 'attempts to use cached values first' do
- expect(ApplicationSetting).to receive(:current)
- expect(ApplicationSetting).not_to receive(:last)
+ expect(ApplicationSetting).to receive(:cached)
expect(current_application_settings).to be_a(ApplicationSetting)
end
it 'falls back to DB if Redis returns an empty value' do
+ expect(ApplicationSetting).to receive(:cached).and_return(nil)
expect(ApplicationSetting).to receive(:last).and_call_original
expect(current_application_settings).to be_a(ApplicationSetting)
end
it 'falls back to DB if Redis fails' do
- expect(ApplicationSetting).to receive(:current).and_raise(::Redis::BaseError)
- expect(ApplicationSetting).to receive(:last).and_call_original
+ db_settings = ApplicationSetting.create!(ApplicationSetting.defaults)
- expect(current_application_settings).to be_a(ApplicationSetting)
+ expect(ApplicationSetting).to receive(:cached).and_raise(::Redis::BaseError)
+ expect(Rails.cache).to receive(:fetch).with(ApplicationSetting::CACHE_KEY).and_raise(Redis::BaseError)
+
+ expect(current_application_settings).to eq(db_settings)
+ end
+
+ it 'creates default ApplicationSettings if none are present' do
+ expect(ApplicationSetting).to receive(:cached).and_raise(::Redis::BaseError)
+ expect(Rails.cache).to receive(:fetch).with(ApplicationSetting::CACHE_KEY).and_raise(Redis::BaseError)
+
+ settings = current_application_settings
+
+ expect(settings).to be_a(ApplicationSetting)
+ expect(settings).to be_persisted
+ expect(settings).to have_attributes(ApplicationSetting.defaults)
+ end
+
+ context 'with migrations pending' do
+ before do
+ expect(ActiveRecord::Migrator).to receive(:needs_migration?).and_return(true)
+ end
+
+ it 'returns an in-memory ApplicationSetting object' do
+ settings = current_application_settings
+
+ expect(settings).to be_a(OpenStruct)
+ expect(settings.sign_in_enabled?).to eq(settings.sign_in_enabled)
+ expect(settings.sign_up_enabled?).to eq(settings.sign_up_enabled)
+ end
+
+ it 'uses the existing database settings and falls back to defaults' do
+ db_settings = create(:application_setting,
+ home_page_url: 'http://mydomain.com',
+ signup_enabled: false)
+ settings = current_application_settings
+ app_defaults = ApplicationSetting.last
+
+ expect(settings).to be_a(OpenStruct)
+ expect(settings.home_page_url).to eq(db_settings.home_page_url)
+ expect(settings.signup_enabled?).to be_falsey
+ expect(settings.signup_enabled).to be_falsey
+
+ # Check that unspecified values use the defaults
+ settings.reject! { |key, _| [:home_page_url, :signup_enabled].include? key }
+ settings.each { |key, _| expect(settings[key]).to eq(app_defaults[key]) }
+ end
end
end
context 'with DB unavailable' do
before do
- allow_any_instance_of(Gitlab::CurrentSettings).to receive(:connect_to_db?).and_return(false)
+ allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(false)
+ allow_any_instance_of(described_class).to receive(:retrieve_settings_from_database_cache?).and_return(nil)
end
it 'returns an in-memory ApplicationSetting object' do
diff --git a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb
index c455cd9b942..0560c47f03f 100644
--- a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb
@@ -2,14 +2,14 @@ require 'spec_helper'
describe Gitlab::CycleAnalytics::BaseEventFetcher do
let(:max_events) { 2 }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user, :admin) }
let(:start_time_attrs) { Issue.arel_table[:created_at] }
let(:end_time_attrs) { [Issue::Metrics.arel_table[:first_associated_with_milestone_at]] }
- let(:options) do
+ let(:options) do
{ start_time_attrs: start_time_attrs,
end_time_attrs: end_time_attrs,
- from: 30.days.ago }
+ from: 30.days.ago }
end
subject do
@@ -20,7 +20,7 @@ describe Gitlab::CycleAnalytics::BaseEventFetcher do
before do
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return(Issue.all)
- allow_any_instance_of(Gitlab::CycleAnalytics::BaseEventFetcher).to receive(:serialize) do |event|
+ allow_any_instance_of(described_class).to receive(:serialize) do |event|
event
end
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index 9d2ba481919..28ea7d4c303 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe 'cycle analytics events' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
@@ -11,8 +11,6 @@ describe 'cycle analytics events' do
end
before do
- allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context])
-
setup(context)
end
@@ -128,7 +126,8 @@ describe 'cycle analytics events' do
create(:ci_pipeline,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
- project: context.project)
+ project: context.project,
+ head_pipeline_of: merge_request)
end
before do
@@ -224,7 +223,8 @@ describe 'cycle analytics events' do
create(:ci_pipeline,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
- project: context.project)
+ project: context.project,
+ head_pipeline_of: merge_request)
end
before do
@@ -332,7 +332,7 @@ describe 'cycle analytics events' do
def setup(context)
milestone = create(:milestone, project: project)
context.update(milestone: milestone)
- mr = create_merge_request_closing_issue(context)
+ mr = create_merge_request_closing_issue(context, commit_message: "References #{context.to_reference}")
ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash)
end
diff --git a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
index 2d85e712db0..2a0dd7be439 100644
--- a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::CycleAnalytics::Permissions do
- let(:project) { create(:empty_project, public_builds: false) }
+ let(:project) { create(:project, public_builds: false) }
let(:user) { create(:user) }
subject { described_class.get(user: user, project: project) }
diff --git a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
index 9c5e57342e9..c22d27f60d6 100644
--- a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
shared_examples 'default query config' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:event) { described_class.new(project: project, stage: stage_name, options: { from: 1.day.ago }) }
it 'has the stage attribute' do
diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
index 3dd76ba5b8a..2e67c1c7f78 100644
--- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::CycleAnalytics::StageSummary, models: true do
+describe Gitlab::CycleAnalytics::StageSummary do
let(:project) { create(:project, :repository) }
let(:from) { 1.day.ago }
let(:user) { create(:user, :admin) }
@@ -15,7 +15,7 @@ describe Gitlab::CycleAnalytics::StageSummary, models: true do
end
it "doesn't find issues from other projects" do
- Timecop.freeze(5.days.from_now) { create(:issue, project: create(:empty_project)) }
+ Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project)) }
expect(subject.first[:value]).to eq(0)
end
diff --git a/spec/lib/gitlab/daemon_spec.rb b/spec/lib/gitlab/daemon_spec.rb
new file mode 100644
index 00000000000..2bdb0dfc736
--- /dev/null
+++ b/spec/lib/gitlab/daemon_spec.rb
@@ -0,0 +1,103 @@
+require 'spec_helper'
+
+describe Gitlab::Daemon do
+ subject { described_class.new }
+
+ before do
+ allow(subject).to receive(:start_working)
+ allow(subject).to receive(:stop_working)
+ end
+
+ describe '.instance' do
+ before do
+ allow(Kernel).to receive(:at_exit)
+ end
+
+ after do
+ described_class.instance_variable_set(:@instance, nil)
+ end
+
+ it 'provides instance of Daemon' do
+ expect(described_class.instance).to be_instance_of(described_class)
+ end
+
+ it 'subsequent invocations provide the same instance' do
+ expect(described_class.instance).to eq(described_class.instance)
+ end
+
+ it 'creates at_exit hook when instance is created' do
+ expect(described_class.instance).not_to be_nil
+
+ expect(Kernel).to have_received(:at_exit)
+ end
+ end
+
+ describe 'when Daemon is enabled' do
+ before do
+ allow(subject).to receive(:enabled?).and_return(true)
+ end
+
+ describe 'when Daemon is stopped' do
+ describe '#start' do
+ it 'starts the Daemon' do
+ expect { subject.start.join }.to change { subject.thread? }.from(false).to(true)
+
+ expect(subject).to have_received(:start_working)
+ end
+ end
+
+ describe '#stop' do
+ it "doesn't shutdown stopped Daemon" do
+ expect { subject.stop }.not_to change { subject.thread? }
+
+ expect(subject).not_to have_received(:start_working)
+ end
+ end
+ end
+
+ describe 'when Daemon is running' do
+ before do
+ subject.start.join
+ end
+
+ describe '#start' do
+ it "doesn't start running Daemon" do
+ expect { subject.start.join }.not_to change { subject.thread? }
+
+ expect(subject).to have_received(:start_working).once
+ end
+ end
+
+ describe '#stop' do
+ it 'shutdowns Daemon' do
+ expect { subject.stop }.to change { subject.thread? }.from(true).to(false)
+
+ expect(subject).to have_received(:stop_working)
+ end
+ end
+ end
+ end
+
+ describe 'when Daemon is disabled' do
+ before do
+ allow(subject).to receive(:enabled?).and_return(false)
+ end
+
+ describe '#start' do
+ it "doesn't start working" do
+ expect(subject.start).to be_nil
+ expect { subject.start }.not_to change { subject.thread? }
+
+ expect(subject).not_to have_received(:start_working)
+ end
+ end
+
+ describe '#stop' do
+ it "doesn't stop working" do
+ expect { subject.stop }.not_to change { subject.thread? }
+
+ expect(subject).not_to have_received(:stop_working)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb
index 04ec34492e1..aaa42566a4d 100644
--- a/spec/lib/gitlab/data_builder/note_spec.rb
+++ b/spec/lib/gitlab/data_builder/note_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::DataBuilder::Note, lib: true do
+describe Gitlab::DataBuilder::Note do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:data) { described_class.build(note, user) }
let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors
- before(:each) do
+ before do
expect(data).to have_key(:object_attributes)
expect(data[:object_attributes]).to have_key(:url)
expect(data[:object_attributes][:url])
diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb
index dbcfb9b7400..cb430b47463 100644
--- a/spec/lib/gitlab/data_builder/push_spec.rb
+++ b/spec/lib/gitlab/data_builder/push_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::DataBuilder::Push, lib: true do
+describe Gitlab::DataBuilder::Push do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -35,6 +35,7 @@ describe Gitlab::DataBuilder::Push, lib: true do
it { expect(data[:ref]).to eq('refs/tags/v1.1.0') }
it { expect(data[:user_id]).to eq(user.id) }
it { expect(data[:user_name]).to eq(user.name) }
+ it { expect(data[:user_username]).to eq(user.username) }
it { expect(data[:user_email]).to eq(user.email) }
it { expect(data[:user_avatar]).to eq(user.avatar_url) }
it { expect(data[:project_id]).to eq(project.id) }
@@ -46,8 +47,8 @@ describe Gitlab::DataBuilder::Push, lib: true do
include_examples 'deprecated repository hook data'
it 'does not raise an error when given nil commits' do
- expect { described_class.build(spy, spy, spy, spy, spy, nil) }.
- not_to raise_error
+ expect { described_class.build(spy, spy, spy, spy, spy, nil) }
+ .not_to raise_error
end
end
end
diff --git a/spec/lib/gitlab/data_builder/wiki_page_spec.rb b/spec/lib/gitlab/data_builder/wiki_page_spec.rb
new file mode 100644
index 00000000000..a776d888c47
--- /dev/null
+++ b/spec/lib/gitlab/data_builder/wiki_page_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Gitlab::DataBuilder::WikiPage do
+ let(:project) { create(:project, :repository) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki) }
+ let(:user) { create(:user) }
+
+ describe '.build' do
+ let(:data) { described_class.build(wiki_page, user, 'create') }
+
+ it { expect(data).to be_a(Hash) }
+ it { expect(data[:object_kind]).to eq('wiki_page') }
+ it { expect(data[:user]).to eq(user.hook_attrs) }
+ it { expect(data[:project]).to eq(project.hook_attrs) }
+ it { expect(data[:wiki]).to eq(project.wiki.hook_attrs) }
+
+ it { expect(data[:object_attributes]).to include(wiki_page.hook_attrs) }
+ it { expect(data[:object_attributes]).to include(url: Gitlab::UrlBuilder.build(wiki_page)) }
+ it { expect(data[:object_attributes]).to include(action: 'create') }
+ end
+end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index a044b871730..ec2274a70aa 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -1,13 +1,48 @@
require 'spec_helper'
-describe Gitlab::Database::MigrationHelpers, lib: true do
+describe Gitlab::Database::MigrationHelpers do
let(:model) do
ActiveRecord::Migration.new.extend(
- Gitlab::Database::MigrationHelpers
+ described_class
)
end
- before { allow(model).to receive(:puts) }
+ before do
+ allow(model).to receive(:puts)
+ end
+
+ describe '#add_timestamps_with_timezone' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
+ context 'using PostgreSQL' do
+ before do
+ allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
+ allow(model).to receive(:disable_statement_timeout)
+ end
+
+ it 'adds "created_at" and "updated_at" fields with the "datetime_with_timezone" data type' do
+ expect(model).to receive(:add_column).with(:foo, :created_at, :datetime_with_timezone, { null: false })
+ expect(model).to receive(:add_column).with(:foo, :updated_at, :datetime_with_timezone, { null: false })
+
+ model.add_timestamps_with_timezone(:foo)
+ end
+ end
+
+ context 'using MySQL' do
+ before do
+ allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ end
+
+ it 'adds "created_at" and "updated_at" fields with "datetime_with_timezone" data type' do
+ expect(model).to receive(:add_column).with(:foo, :created_at, :datetime_with_timezone, { null: false })
+ expect(model).to receive(:add_column).with(:foo, :updated_at, :datetime_with_timezone, { null: false })
+
+ model.add_timestamps_with_timezone(:foo)
+ end
+ end
+ end
describe '#add_concurrent_index' do
context 'outside a transaction' do
@@ -22,15 +57,15 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
end
it 'creates the index concurrently' do
- expect(model).to receive(:add_index).
- with(:users, :foo, algorithm: :concurrently)
+ expect(model).to receive(:add_index)
+ .with(:users, :foo, algorithm: :concurrently)
model.add_concurrent_index(:users, :foo)
end
it 'creates unique index concurrently' do
- expect(model).to receive(:add_index).
- with(:users, :foo, { algorithm: :concurrently, unique: true })
+ expect(model).to receive(:add_index)
+ .with(:users, :foo, { algorithm: :concurrently, unique: true })
model.add_concurrent_index(:users, :foo, unique: true)
end
@@ -40,8 +75,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'creates a regular index' do
expect(Gitlab::Database).to receive(:postgresql?).and_return(false)
- expect(model).to receive(:add_index).
- with(:users, :foo, {})
+ expect(model).to receive(:add_index)
+ .with(:users, :foo, {})
model.add_concurrent_index(:users, :foo)
end
@@ -52,8 +87,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'raises RuntimeError' do
expect(model).to receive(:transaction_open?).and_return(true)
- expect { model.add_concurrent_index(:users, :foo) }.
- to raise_error(RuntimeError)
+ expect { model.add_concurrent_index(:users, :foo) }
+ .to raise_error(RuntimeError)
end
end
end
@@ -66,24 +101,31 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
context 'using PostgreSQL' do
before do
- allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
+ allow(model).to receive(:supports_drop_index_concurrently?).and_return(true)
allow(model).to receive(:disable_statement_timeout)
end
- it 'removes the index concurrently' do
- expect(model).to receive(:remove_index).
- with(:users, { algorithm: :concurrently, column: :foo })
+ it 'removes the index concurrently by column name' do
+ expect(model).to receive(:remove_index)
+ .with(:users, { algorithm: :concurrently, column: :foo })
model.remove_concurrent_index(:users, :foo)
end
+
+ it 'removes the index concurrently by index name' do
+ expect(model).to receive(:remove_index)
+ .with(:users, { algorithm: :concurrently, name: "index_x_by_y" })
+
+ model.remove_concurrent_index_by_name(:users, "index_x_by_y")
+ end
end
context 'using MySQL' do
it 'removes an index' do
expect(Gitlab::Database).to receive(:postgresql?).and_return(false)
- expect(model).to receive(:remove_index).
- with(:users, { column: :foo })
+ expect(model).to receive(:remove_index)
+ .with(:users, { column: :foo })
model.remove_concurrent_index(:users, :foo)
end
@@ -94,8 +136,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'raises RuntimeError' do
expect(model).to receive(:transaction_open?).and_return(true)
- expect { model.remove_concurrent_index(:users, :foo) }.
- to raise_error(RuntimeError)
+ expect { model.remove_concurrent_index(:users, :foo) }
+ .to raise_error(RuntimeError)
end
end
end
@@ -120,8 +162,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'creates a regular foreign key' do
allow(Gitlab::Database).to receive(:mysql?).and_return(true)
- expect(model).to receive(:add_foreign_key).
- with(:projects, :users, column: :user_id, on_delete: :cascade)
+ expect(model).to receive(:add_foreign_key)
+ .with(:projects, :users, column: :user_id, on_delete: :cascade)
model.add_concurrent_foreign_key(:projects, :users, column: :user_id)
end
@@ -132,13 +174,23 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
allow(Gitlab::Database).to receive(:mysql?).and_return(false)
end
- it 'creates a concurrent foreign key' do
+ it 'creates a concurrent foreign key and validates it' do
expect(model).to receive(:disable_statement_timeout)
expect(model).to receive(:execute).ordered.with(/NOT VALID/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
model.add_concurrent_foreign_key(:projects, :users, column: :user_id)
end
+
+ it 'appends a valid ON DELETE statement' do
+ expect(model).to receive(:disable_statement_timeout)
+ expect(model).to receive(:execute).with(/ON DELETE SET NULL/)
+ expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
+
+ model.add_concurrent_foreign_key(:projects, :users,
+ column: :user_id,
+ on_delete: :nullify)
+ end
end
end
end
@@ -220,31 +272,53 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
end
describe '#update_column_in_batches' do
- before do
- create_list(:empty_project, 5)
- end
+ context 'when running outside of a transaction' do
+ before do
+ expect(model).to receive(:transaction_open?).and_return(false)
- it 'updates all the rows in a table' do
- model.update_column_in_batches(:projects, :import_error, 'foo')
+ create_list(:project, 5)
+ end
- expect(Project.where(import_error: 'foo').count).to eq(5)
- end
+ it 'updates all the rows in a table' do
+ model.update_column_in_batches(:projects, :import_error, 'foo')
- it 'updates boolean values correctly' do
- model.update_column_in_batches(:projects, :archived, true)
+ expect(Project.where(import_error: 'foo').count).to eq(5)
+ end
- expect(Project.where(archived: true).count).to eq(5)
- end
+ it 'updates boolean values correctly' do
+ model.update_column_in_batches(:projects, :archived, true)
+
+ expect(Project.where(archived: true).count).to eq(5)
+ end
- context 'when a block is supplied' do
- it 'yields an Arel table and query object to the supplied block' do
- first_id = Project.first.id
+ context 'when a block is supplied' do
+ it 'yields an Arel table and query object to the supplied block' do
+ first_id = Project.first.id
- model.update_column_in_batches(:projects, :archived, true) do |t, query|
- query.where(t[:id].eq(first_id))
+ model.update_column_in_batches(:projects, :archived, true) do |t, query|
+ query.where(t[:id].eq(first_id))
+ end
+
+ expect(Project.where(archived: true).count).to eq(1)
+ end
+ end
+
+ context 'when the value is Arel.sql (Arel::Nodes::SqlLiteral)' do
+ it 'updates the value as a SQL expression' do
+ model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1'))
+
+ expect(Project.sum(:star_count)).to eq(2 * Project.count)
end
+ end
+ end
+
+ context 'when running inside the transaction' do
+ it 'raises RuntimeError' do
+ expect(model).to receive(:transaction_open?).and_return(true)
- expect(Project.where(archived: true).count).to eq(1)
+ expect do
+ model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1'))
+ end.to raise_error(RuntimeError)
end
end
end
@@ -253,20 +327,22 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
context 'outside of a transaction' do
context 'when a column limit is not set' do
before do
- expect(model).to receive(:transaction_open?).and_return(false)
+ expect(model).to receive(:transaction_open?)
+ .and_return(false)
+ .at_least(:once)
expect(model).to receive(:transaction).and_yield
- expect(model).to receive(:add_column).
- with(:projects, :foo, :integer, default: nil)
+ expect(model).to receive(:add_column)
+ .with(:projects, :foo, :integer, default: nil)
- expect(model).to receive(:change_column_default).
- with(:projects, :foo, 10)
+ expect(model).to receive(:change_column_default)
+ .with(:projects, :foo, 10)
end
it 'adds the column while allowing NULL values' do
- expect(model).to receive(:update_column_in_batches).
- with(:projects, :foo, 10)
+ expect(model).to receive(:update_column_in_batches)
+ .with(:projects, :foo, 10)
expect(model).not_to receive(:change_column_null)
@@ -276,22 +352,22 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
end
it 'adds the column while not allowing NULL values' do
- expect(model).to receive(:update_column_in_batches).
- with(:projects, :foo, 10)
+ expect(model).to receive(:update_column_in_batches)
+ .with(:projects, :foo, 10)
- expect(model).to receive(:change_column_null).
- with(:projects, :foo, false)
+ expect(model).to receive(:change_column_null)
+ .with(:projects, :foo, false)
model.add_column_with_default(:projects, :foo, :integer, default: 10)
end
it 'removes the added column whenever updating the rows fails' do
- expect(model).to receive(:update_column_in_batches).
- with(:projects, :foo, 10).
- and_raise(RuntimeError)
+ expect(model).to receive(:update_column_in_batches)
+ .with(:projects, :foo, 10)
+ .and_raise(RuntimeError)
- expect(model).to receive(:remove_column).
- with(:projects, :foo)
+ expect(model).to receive(:remove_column)
+ .with(:projects, :foo)
expect do
model.add_column_with_default(:projects, :foo, :integer, default: 10)
@@ -299,12 +375,12 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
end
it 'removes the added column whenever changing a column NULL constraint fails' do
- expect(model).to receive(:change_column_null).
- with(:projects, :foo, false).
- and_raise(RuntimeError)
+ expect(model).to receive(:change_column_null)
+ .with(:projects, :foo, false)
+ .and_raise(RuntimeError)
- expect(model).to receive(:remove_column).
- with(:projects, :foo)
+ expect(model).to receive(:remove_column)
+ .with(:projects, :foo)
expect do
model.add_column_with_default(:projects, :foo, :integer, default: 10)
@@ -320,8 +396,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
allow(model).to receive(:change_column_null).with(:projects, :foo, false)
allow(model).to receive(:change_column_default).with(:projects, :foo, 10)
- expect(model).to receive(:add_column).
- with(:projects, :foo, :integer, default: nil, limit: 8)
+ expect(model).to receive(:add_column)
+ .with(:projects, :foo, :integer, default: nil, limit: 8)
model.add_column_with_default(:projects, :foo, :integer, default: 10, limit: 8)
end
@@ -344,8 +420,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'raises RuntimeError' do
allow(model).to receive(:transaction_open?).and_return(true)
- expect { model.rename_column_concurrently(:users, :old, :new) }.
- to raise_error(RuntimeError)
+ expect { model.rename_column_concurrently(:users, :old, :new) }
+ .to raise_error(RuntimeError)
end
end
@@ -376,19 +452,22 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'renames a column concurrently' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
- expect(model).to receive(:install_rename_triggers_for_mysql).
- with(trigger_name, 'users', 'old', 'new')
+ expect(model).to receive(:install_rename_triggers_for_mysql)
+ .with(trigger_name, 'users', 'old', 'new')
- expect(model).to receive(:add_column).
- with(:users, :new, :integer,
+ expect(model).to receive(:add_column)
+ .with(:users, :new, :integer,
limit: old_column.limit,
- default: old_column.default,
- null: old_column.null,
precision: old_column.precision,
scale: old_column.scale)
+ expect(model).to receive(:change_column_default)
+ .with(:users, :new, old_column.default)
+
expect(model).to receive(:update_column_in_batches)
+ expect(model).to receive(:change_column_null).with(:users, :new, false)
+
expect(model).to receive(:copy_indexes).with(:users, :old, :new)
expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new)
@@ -400,19 +479,22 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'renames a column concurrently' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
- expect(model).to receive(:install_rename_triggers_for_postgresql).
- with(trigger_name, 'users', 'old', 'new')
+ expect(model).to receive(:install_rename_triggers_for_postgresql)
+ .with(trigger_name, 'users', 'old', 'new')
- expect(model).to receive(:add_column).
- with(:users, :new, :integer,
+ expect(model).to receive(:add_column)
+ .with(:users, :new, :integer,
limit: old_column.limit,
- default: old_column.default,
- null: old_column.null,
precision: old_column.precision,
scale: old_column.scale)
+ expect(model).to receive(:change_column_default)
+ .with(:users, :new, old_column.default)
+
expect(model).to receive(:update_column_in_batches)
+ expect(model).to receive(:change_column_null).with(:users, :new, false)
+
expect(model).to receive(:copy_indexes).with(:users, :old, :new)
expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new)
@@ -426,8 +508,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'cleans up the renaming procedure for PostgreSQL' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
- expect(model).to receive(:remove_rename_triggers_for_postgresql).
- with(:users, /trigger_.{12}/)
+ expect(model).to receive(:remove_rename_triggers_for_postgresql)
+ .with(:users, /trigger_.{12}/)
expect(model).to receive(:remove_column).with(:users, :old)
@@ -437,8 +519,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
it 'cleans up the renaming procedure for MySQL' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
- expect(model).to receive(:remove_rename_triggers_for_mysql).
- with(/trigger_.{12}/)
+ expect(model).to receive(:remove_rename_triggers_for_mysql)
+ .with(/trigger_.{12}/)
expect(model).to receive(:remove_column).with(:users, :old)
@@ -448,8 +530,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
describe '#change_column_type_concurrently' do
it 'changes the column type' do
- expect(model).to receive(:rename_column_concurrently).
- with('users', 'username', 'username_for_type_change', type: :text)
+ expect(model).to receive(:rename_column_concurrently)
+ .with('users', 'username', 'username_for_type_change', type: :text)
model.change_column_type_concurrently('users', 'username', :text)
end
@@ -457,11 +539,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
describe '#cleanup_concurrent_column_type_change' do
it 'cleans up the type changing procedure' do
- expect(model).to receive(:cleanup_concurrent_column_rename).
- with('users', 'username', 'username_for_type_change')
+ expect(model).to receive(:cleanup_concurrent_column_rename)
+ .with('users', 'username', 'username_for_type_change')
- expect(model).to receive(:rename_column).
- with('users', 'username_for_type_change', 'username')
+ expect(model).to receive(:rename_column)
+ .with('users', 'username_for_type_change', 'username')
model.cleanup_concurrent_column_type_change('users', 'username')
end
@@ -469,11 +551,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
describe '#install_rename_triggers_for_postgresql' do
it 'installs the triggers for PostgreSQL' do
- expect(model).to receive(:execute).
- with(/CREATE OR REPLACE FUNCTION foo()/m)
+ expect(model).to receive(:execute)
+ .with(/CREATE OR REPLACE FUNCTION foo()/m)
- expect(model).to receive(:execute).
- with(/CREATE TRIGGER foo/m)
+ expect(model).to receive(:execute)
+ .with(/CREATE TRIGGER foo/m)
model.install_rename_triggers_for_postgresql('foo', :users, :old, :new)
end
@@ -481,11 +563,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
describe '#install_rename_triggers_for_mysql' do
it 'installs the triggers for MySQL' do
- expect(model).to receive(:execute).
- with(/CREATE TRIGGER foo_insert.+ON users/m)
+ expect(model).to receive(:execute)
+ .with(/CREATE TRIGGER foo_insert.+ON users/m)
- expect(model).to receive(:execute).
- with(/CREATE TRIGGER foo_update.+ON users/m)
+ expect(model).to receive(:execute)
+ .with(/CREATE TRIGGER foo_update.+ON users/m)
model.install_rename_triggers_for_mysql('foo', :users, :old, :new)
end
@@ -511,8 +593,8 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
describe '#rename_trigger_name' do
it 'returns a String' do
- expect(model.rename_trigger_name(:users, :foo, :bar)).
- to match(/trigger_.{12}/)
+ expect(model.rename_trigger_name(:users, :foo, :bar))
+ .to match(/trigger_.{12}/)
end
end
@@ -551,11 +633,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect(model).to receive(:add_concurrent_index).
- with(:issues,
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
%w(gl_project_id),
unique: false,
name: 'index_on_issues_gl_project_id',
@@ -578,11 +660,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect(model).to receive(:add_concurrent_index).
- with(:issues,
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
%w(gl_project_id foobar),
unique: false,
name: 'index_on_issues_gl_project_id_foobar',
@@ -605,11 +687,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect(model).to receive(:add_concurrent_index).
- with(:issues,
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
%w(gl_project_id),
unique: false,
name: 'index_on_issues_gl_project_id',
@@ -633,11 +715,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect(model).to receive(:add_concurrent_index).
- with(:issues,
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
%w(gl_project_id),
unique: false,
name: 'index_on_issues_gl_project_id',
@@ -661,11 +743,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect(model).to receive(:add_concurrent_index).
- with(:issues,
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
%w(gl_project_id),
unique: false,
name: 'index_on_issues_gl_project_id',
@@ -689,11 +771,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
lengths: [],
orders: [])
- allow(model).to receive(:indexes_for).with(:issues, 'project_id').
- and_return([index])
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
- expect { model.copy_indexes(:issues, :project_id, :gl_project_id) }.
- to raise_error(RuntimeError)
+ expect { model.copy_indexes(:issues, :project_id, :gl_project_id) }
+ .to raise_error(RuntimeError)
end
end
end
@@ -705,11 +787,11 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
to_table: 'projects',
on_delete: :cascade)
- allow(model).to receive(:foreign_keys_for).with(:issues, :project_id).
- and_return([fk])
+ allow(model).to receive(:foreign_keys_for).with(:issues, :project_id)
+ .and_return([fk])
- expect(model).to receive(:add_concurrent_foreign_key).
- with('issues', 'projects', column: :gl_project_id, on_delete: :cascade)
+ expect(model).to receive(:add_concurrent_foreign_key)
+ .with('issues', 'projects', column: :gl_project_id, on_delete: :cascade)
model.copy_foreign_keys(:issues, :project_id, :gl_project_id)
end
@@ -726,4 +808,41 @@ describe Gitlab::Database::MigrationHelpers, lib: true do
expect(model.column_for(:users, :kittens)).to be_nil
end
end
+
+ describe '#replace_sql' do
+ context 'using postgres' do
+ before do
+ allow(Gitlab::Database).to receive(:mysql?).and_return(false)
+ end
+
+ it 'builds the sql with correct functions' do
+ expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s)
+ .to include('regexp_replace')
+ end
+ end
+
+ context 'using mysql' do
+ before do
+ allow(Gitlab::Database).to receive(:mysql?).and_return(true)
+ end
+
+ it 'builds the sql with the correct functions' do
+ expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s)
+ .to include('locate', 'insert')
+ end
+ end
+
+ describe 'results' do
+ let!(:user) { create(:user, name: 'Kathy Alice Aliceson') }
+
+ it 'replaces the correct part of the string' do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ query = model.replace_sql(Arel::Table.new(:users)[:name], 'Alice', 'Eve')
+
+ model.update_column_in_batches(:users, :name, query)
+
+ expect(user.reload.name).to eq('Kathy Eve Aliceson')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
new file mode 100644
index 00000000000..90aa4f63dd5
--- /dev/null
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
@@ -0,0 +1,280 @@
+require 'spec_helper'
+
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :truncate do
+ let(:migration) { FakeRenameReservedPathMigrationV1.new }
+ let(:subject) { described_class.new(['the-path'], migration) }
+
+ before do
+ allow(migration).to receive(:say)
+ TestEnv.clean_test_path
+ end
+
+ def migration_namespace(namespace)
+ Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::
+ Namespace.find(namespace.id)
+ end
+
+ def migration_project(project)
+ Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::
+ Project.find(project.id)
+ end
+
+ describe "#remove_last_ocurrence" do
+ it "removes only the last occurance of a string" do
+ input = "this/is/a-word-to-replace/namespace/with/a-word-to-replace"
+
+ expect(subject.remove_last_occurrence(input, "a-word-to-replace"))
+ .to eq("this/is/a-word-to-replace/namespace/with/")
+ end
+ end
+
+ describe '#remove_cached_html_for_projects' do
+ let(:project) { create(:project, description_html: 'Project description') }
+
+ it 'removes description_html from projects' do
+ subject.remove_cached_html_for_projects([project.id])
+
+ expect(project.reload.description_html).to be_nil
+ end
+
+ it 'removes issue descriptions' do
+ issue = create(:issue, project: project, description_html: 'Issue description')
+
+ subject.remove_cached_html_for_projects([project.id])
+
+ expect(issue.reload.description_html).to be_nil
+ end
+
+ it 'removes merge request descriptions' do
+ merge_request = create(:merge_request,
+ source_project: project,
+ target_project: project,
+ description_html: 'MergeRequest description')
+
+ subject.remove_cached_html_for_projects([project.id])
+
+ expect(merge_request.reload.description_html).to be_nil
+ end
+
+ it 'removes note html' do
+ note = create(:note,
+ project: project,
+ noteable: create(:issue, project: project),
+ note_html: 'note description')
+
+ subject.remove_cached_html_for_projects([project.id])
+
+ expect(note.reload.note_html).to be_nil
+ end
+
+ it 'removes milestone description' do
+ milestone = create(:milestone,
+ project: project,
+ description_html: 'milestone description')
+
+ subject.remove_cached_html_for_projects([project.id])
+
+ expect(milestone.reload.description_html).to be_nil
+ end
+ end
+
+ describe '#rename_path_for_routable' do
+ context 'for namespaces' do
+ let(:namespace) { create(:namespace, path: 'the-path') }
+ it "renames namespaces called the-path" do
+ subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(namespace.reload.path).to eq("the-path0")
+ end
+
+ it "renames the route to the namespace" do
+ subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(Namespace.find(namespace.id).full_path).to eq("the-path0")
+ end
+
+ it "renames the route for projects of the namespace" do
+ project = create(:project, :repository, path: "project-path", namespace: namespace)
+
+ subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(project.route.reload.path).to eq("the-path0/project-path")
+ end
+
+ it 'returns the old & the new path' do
+ old_path, new_path = subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(old_path).to eq('the-path')
+ expect(new_path).to eq('the-path0')
+ end
+
+ it "doesn't rename routes that start with a similar name" do
+ other_namespace = create(:namespace, path: 'the-path-but-not-really')
+ project = create(:project, path: 'the-project', namespace: other_namespace)
+
+ subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(project.route.reload.path).to eq('the-path-but-not-really/the-project')
+ end
+
+ context "the-path namespace -> subgroup -> the-path0 project" do
+ it "updates the route of the project correctly" do
+ subgroup = create(:group, path: "subgroup", parent: namespace)
+ project = create(:project, :repository, path: "the-path0", namespace: subgroup)
+
+ subject.rename_path_for_routable(migration_namespace(namespace))
+
+ expect(project.route.reload.path).to eq("the-path0/subgroup/the-path0")
+ end
+ end
+ end
+
+ context 'for projects' do
+ let(:parent) { create(:namespace, path: 'the-parent') }
+ let(:project) { create(:project, path: 'the-path', namespace: parent) }
+
+ it 'renames the project called `the-path`' do
+ subject.rename_path_for_routable(migration_project(project))
+
+ expect(project.reload.path).to eq('the-path0')
+ end
+
+ it 'renames the route for the project' do
+ subject.rename_path_for_routable(project)
+
+ expect(project.reload.route.path).to eq('the-parent/the-path0')
+ end
+
+ it 'returns the old & new path' do
+ old_path, new_path = subject.rename_path_for_routable(migration_project(project))
+
+ expect(old_path).to eq('the-parent/the-path')
+ expect(new_path).to eq('the-parent/the-path0')
+ end
+ end
+ end
+
+ describe '#perform_rename' do
+ describe 'for namespaces' do
+ let(:namespace) { create(:namespace, path: 'the-path') }
+ it 'renames the path' do
+ subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed')
+
+ expect(namespace.reload.path).to eq('renamed')
+ end
+
+ it 'renames all the routes for the namespace' do
+ child = create(:group, path: 'child', parent: namespace)
+ project = create(:project, :repository, namespace: child, path: 'the-project')
+ other_one = create(:namespace, path: 'the-path-is-similar')
+
+ subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed')
+
+ expect(namespace.reload.route.path).to eq('renamed')
+ expect(child.reload.route.path).to eq('renamed/child')
+ expect(project.reload.route.path).to eq('renamed/child/the-project')
+ expect(other_one.reload.route.path).to eq('the-path-is-similar')
+ end
+ end
+ end
+
+ describe '#move_pages' do
+ it 'moves the pages directory' do
+ expect(subject).to receive(:move_folders)
+ .with(TestEnv.pages_path, 'old-path', 'new-path')
+
+ subject.move_pages('old-path', 'new-path')
+ end
+ end
+
+ describe "#move_uploads" do
+ let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') }
+ let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') }
+
+ it 'moves subdirectories in the uploads folder' do
+ expect(subject).to receive(:uploads_dir).and_return(uploads_dir)
+ expect(subject).to receive(:move_folders).with(uploads_dir, 'old_path', 'new_path')
+
+ subject.move_uploads('old_path', 'new_path')
+ end
+
+ it "doesn't move uploads when they are stored in object storage" do
+ expect(subject).to receive(:file_storage?).and_return(false)
+ expect(subject).not_to receive(:move_folders)
+
+ subject.move_uploads('old_path', 'new_path')
+ end
+ end
+
+ describe '#move_folders' do
+ let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') }
+ let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(subject).to receive(:uploads_dir).and_return(uploads_dir)
+ end
+
+ it 'moves a folder with files' do
+ source = File.join(uploads_dir, 'parent-group', 'sub-group')
+ FileUtils.mkdir_p(source)
+ destination = File.join(uploads_dir, 'parent-group', 'moved-group')
+ FileUtils.touch(File.join(source, 'test.txt'))
+ expected_file = File.join(destination, 'test.txt')
+
+ subject.move_folders(uploads_dir, File.join('parent-group', 'sub-group'), File.join('parent-group', 'moved-group'))
+
+ expect(File.exist?(expected_file)).to be(true)
+ end
+ end
+
+ describe '#track_rename', redis: true do
+ it 'tracks a rename in redis' do
+ key = 'rename:FakeRenameReservedPathMigrationV1:namespace'
+
+ subject.track_rename('namespace', 'path/to/namespace', 'path/to/renamed')
+
+ old_path, new_path = [nil, nil]
+ Gitlab::Redis::SharedState.with do |redis|
+ rename_info = redis.lpop(key)
+ old_path, new_path = JSON.parse(rename_info)
+ end
+
+ expect(old_path).to eq('path/to/namespace')
+ expect(new_path).to eq('path/to/renamed')
+ end
+ end
+
+ describe '#reverts_for_type', redis: true do
+ it 'yields for each tracked rename' do
+ subject.track_rename('project', 'old_path', 'new_path')
+ subject.track_rename('project', 'old_path2', 'new_path2')
+ subject.track_rename('namespace', 'namespace_path', 'new_namespace_path')
+
+ expect { |b| subject.reverts_for_type('project', &b) }
+ .to yield_successive_args(%w(old_path2 new_path2), %w(old_path new_path))
+ expect { |b| subject.reverts_for_type('namespace', &b) }
+ .to yield_with_args('namespace_path', 'new_namespace_path')
+ end
+
+ it 'keeps the revert in redis if it failed' do
+ subject.track_rename('project', 'old_path', 'new_path')
+
+ subject.reverts_for_type('project') do
+ raise 'whatever happens, keep going!'
+ end
+
+ key = 'rename:FakeRenameReservedPathMigrationV1:project'
+ stored_renames = nil
+ rename_count = 0
+ Gitlab::Redis::SharedState.with do |redis|
+ stored_renames = redis.lrange(key, 0, 1)
+ rename_count = redis.llen(key)
+ end
+
+ expect(rename_count).to eq(1)
+ expect(JSON.parse(stored_renames.first)).to eq(%w(old_path new_path))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb
new file mode 100644
index 00000000000..32ac0b88a9b
--- /dev/null
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb
@@ -0,0 +1,289 @@
+require 'spec_helper'
+
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :truncate do
+ let(:migration) { FakeRenameReservedPathMigrationV1.new }
+ let(:subject) { described_class.new(['the-path'], migration) }
+ let(:namespace) { create(:group, name: 'the-path') }
+
+ before do
+ allow(migration).to receive(:say)
+ TestEnv.clean_test_path
+ end
+
+ def migration_namespace(namespace)
+ Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::
+ Namespace.find(namespace.id)
+ end
+
+ describe '#namespaces_for_paths' do
+ context 'nested namespaces' do
+ let(:subject) { described_class.new(['parent/the-Path'], migration) }
+
+ it 'includes the namespace' do
+ parent = create(:group, path: 'parent')
+ child = create(:group, path: 'the-path', parent: parent)
+
+ found_ids = subject.namespaces_for_paths(type: :child)
+ .map(&:id)
+
+ expect(found_ids).to contain_exactly(child.id)
+ end
+ end
+
+ context 'for child namespaces' do
+ it 'only returns child namespaces with the correct path' do
+ _root_namespace = create(:group, path: 'THE-path')
+ _other_path = create(:group,
+ path: 'other',
+ parent: create(:group))
+ namespace = create(:group,
+ path: 'the-path',
+ parent: create(:group))
+
+ found_ids = subject.namespaces_for_paths(type: :child)
+ .map(&:id)
+
+ expect(found_ids).to contain_exactly(namespace.id)
+ end
+
+ it 'has no namespaces that look the same' do
+ _root_namespace = create(:group, path: 'THE-path')
+ _similar_path = create(:group,
+ path: 'not-really-the-path',
+ parent: create(:group))
+ namespace = create(:group,
+ path: 'the-path',
+ parent: create(:group))
+
+ found_ids = subject.namespaces_for_paths(type: :child)
+ .map(&:id)
+
+ expect(found_ids).to contain_exactly(namespace.id)
+ end
+ end
+
+ context 'for top levelnamespaces' do
+ it 'only returns child namespaces with the correct path' do
+ root_namespace = create(:group, path: 'the-path')
+ _other_path = create(:group, path: 'other')
+ _child_namespace = create(:group,
+ path: 'the-path',
+ parent: create(:group))
+
+ found_ids = subject.namespaces_for_paths(type: :top_level)
+ .map(&:id)
+
+ expect(found_ids).to contain_exactly(root_namespace.id)
+ end
+
+ it 'has no namespaces that just look the same' do
+ root_namespace = create(:group, path: 'the-path')
+ _similar_path = create(:group, path: 'not-really-the-path')
+ _child_namespace = create(:group,
+ path: 'the-path',
+ parent: create(:group))
+
+ found_ids = subject.namespaces_for_paths(type: :top_level)
+ .map(&:id)
+
+ expect(found_ids).to contain_exactly(root_namespace.id)
+ end
+ end
+ end
+
+ describe '#move_repositories' do
+ let(:namespace) { create(:group, name: 'hello-group') }
+ it 'moves a project for a namespace' do
+ create(:project, :repository, namespace: namespace, path: 'hello-project')
+ expected_path = File.join(TestEnv.repos_path, 'bye-group', 'hello-project.git')
+
+ subject.move_repositories(namespace, 'hello-group', 'bye-group')
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+
+ it 'moves a namespace in a subdirectory correctly' do
+ child_namespace = create(:group, name: 'sub-group', parent: namespace)
+ create(:project, :repository, namespace: child_namespace, path: 'hello-project')
+
+ expected_path = File.join(TestEnv.repos_path, 'hello-group', 'renamed-sub-group', 'hello-project.git')
+
+ subject.move_repositories(child_namespace, 'hello-group/sub-group', 'hello-group/renamed-sub-group')
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+
+ it 'moves a parent namespace with subdirectories' do
+ child_namespace = create(:group, name: 'sub-group', parent: namespace)
+ create(:project, :repository, namespace: child_namespace, path: 'hello-project')
+ expected_path = File.join(TestEnv.repos_path, 'renamed-group', 'sub-group', 'hello-project.git')
+
+ subject.move_repositories(child_namespace, 'hello-group', 'renamed-group')
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+ end
+
+ describe "#child_ids_for_parent" do
+ it "collects child ids for all levels" do
+ parent = create(:group)
+ first_child = create(:group, parent: parent)
+ second_child = create(:group, parent: parent)
+ third_child = create(:group, parent: second_child)
+ all_ids = [parent.id, first_child.id, second_child.id, third_child.id]
+
+ collected_ids = subject.child_ids_for_parent(parent, ids: [parent.id])
+
+ expect(collected_ids).to contain_exactly(*all_ids)
+ end
+ end
+
+ describe "#rename_namespace" do
+ it 'renames paths & routes for the namespace' do
+ expect(subject).to receive(:rename_path_for_routable)
+ .with(namespace)
+ .and_call_original
+
+ subject.rename_namespace(namespace)
+
+ expect(namespace.reload.path).to eq('the-path0')
+ end
+
+ it 'tracks the rename' do
+ expect(subject).to receive(:track_rename)
+ .with('namespace', 'the-path', 'the-path0')
+
+ subject.rename_namespace(namespace)
+ end
+
+ it 'renames things related to the namespace' do
+ expect(subject).to receive(:rename_namespace_dependencies)
+ .with(namespace, 'the-path', 'the-path0')
+
+ subject.rename_namespace(namespace)
+ end
+ end
+
+ describe '#rename_namespace_dependencies' do
+ it "moves the the repository for a project in the namespace" do
+ create(:project, :repository, namespace: namespace, path: "the-path-project")
+ expected_repo = File.join(TestEnv.repos_path, "the-path0", "the-path-project.git")
+
+ subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0')
+
+ expect(File.directory?(expected_repo)).to be(true)
+ end
+
+ it "moves the uploads for the namespace" do
+ expect(subject).to receive(:move_uploads).with("the-path", "the-path0")
+
+ subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0')
+ end
+
+ it "moves the pages for the namespace" do
+ expect(subject).to receive(:move_pages).with("the-path", "the-path0")
+
+ subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0')
+ end
+
+ it 'invalidates the markdown cache of related projects' do
+ project = create(:project, namespace: namespace, path: "the-path-project")
+
+ expect(subject).to receive(:remove_cached_html_for_projects).with([project.id])
+
+ subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0')
+ end
+
+ it "doesn't rename users for other namespaces" do
+ expect(subject).not_to receive(:rename_user)
+
+ subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0')
+ end
+
+ it 'renames the username of a namespace for a user' do
+ user = create(:user, username: 'the-path')
+
+ expect(subject).to receive(:rename_user).with('the-path', 'the-path0')
+
+ subject.rename_namespace_dependencies(user.namespace, 'the-path', 'the-path0')
+ end
+ end
+
+ describe '#rename_user' do
+ it 'renames a username' do
+ subject = described_class.new([], migration)
+ user = create(:user, username: 'broken')
+
+ subject.rename_user('broken', 'broken0')
+
+ expect(user.reload.username).to eq('broken0')
+ end
+ end
+
+ describe '#rename_namespaces' do
+ let!(:top_level_namespace) { create(:group, path: 'the-path') }
+ let!(:child_namespace) do
+ create(:group, path: 'the-path', parent: create(:group))
+ end
+
+ it 'renames top level namespaces the namespace' do
+ expect(subject).to receive(:rename_namespace)
+ .with(migration_namespace(top_level_namespace))
+
+ subject.rename_namespaces(type: :top_level)
+ end
+
+ it 'renames child namespaces' do
+ expect(subject).to receive(:rename_namespace)
+ .with(migration_namespace(child_namespace))
+
+ subject.rename_namespaces(type: :child)
+ end
+ end
+
+ describe '#revert_renames', redis: true do
+ it 'renames the routes back to the previous values' do
+ project = create(:project, :repository, path: 'a-project', namespace: namespace)
+ subject.rename_namespace(namespace)
+
+ expect(subject).to receive(:perform_rename)
+ .with(
+ kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Namespace),
+ 'the-path0',
+ 'the-path'
+ ).and_call_original
+
+ subject.revert_renames
+
+ expect(namespace.reload.path).to eq('the-path')
+ expect(namespace.reload.route.path).to eq('the-path')
+ expect(project.reload.route.path).to eq('the-path/a-project')
+ end
+
+ it 'moves the repositories back to their original place' do
+ project = create(:project, :repository, path: 'a-project', namespace: namespace)
+ project.create_repository
+ subject.rename_namespace(namespace)
+
+ expected_path = File.join(TestEnv.repos_path, 'the-path', 'a-project.git')
+
+ expect(subject).to receive(:rename_namespace_dependencies)
+ .with(
+ kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Namespace),
+ 'the-path0',
+ 'the-path'
+ ).and_call_original
+
+ subject.revert_renames
+
+ expect(File.directory?(expected_path)).to be_truthy
+ end
+
+ it "doesn't break when the namespace was renamed" do
+ subject.rename_namespace(namespace)
+ namespace.update_attributes!(path: 'renamed-afterwards')
+
+ expect { subject.revert_renames }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb
new file mode 100644
index 00000000000..595e06a9748
--- /dev/null
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb
@@ -0,0 +1,160 @@
+require 'spec_helper'
+
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :truncate do
+ let(:migration) { FakeRenameReservedPathMigrationV1.new }
+ let(:subject) { described_class.new(['the-path'], migration) }
+ let(:project) do
+ create(:project,
+ path: 'the-path',
+ namespace: create(:namespace, path: 'known-parent' ))
+ end
+
+ before do
+ allow(migration).to receive(:say)
+ TestEnv.clean_test_path
+ end
+
+ describe '#projects_for_paths' do
+ it 'searches using nested paths' do
+ namespace = create(:namespace, path: 'hello')
+ project = create(:project, path: 'THE-path', namespace: namespace)
+
+ result_ids = described_class.new(['Hello/the-path'], migration)
+ .projects_for_paths.map(&:id)
+
+ expect(result_ids).to contain_exactly(project.id)
+ end
+
+ it 'includes the correct projects' do
+ project = create(:project, path: 'THE-path')
+ _other_project = create(:project)
+
+ result_ids = subject.projects_for_paths.map(&:id)
+
+ expect(result_ids).to contain_exactly(project.id)
+ end
+ end
+
+ describe '#rename_projects' do
+ let!(:projects) { create_list(:project, 2, path: 'the-path') }
+
+ it 'renames each project' do
+ expect(subject).to receive(:rename_project).twice
+
+ subject.rename_projects
+ end
+
+ it 'invalidates the markdown cache of related projects' do
+ expect(subject).to receive(:remove_cached_html_for_projects)
+ .with(projects.map(&:id))
+
+ subject.rename_projects
+ end
+ end
+
+ describe '#rename_project' do
+ it 'renames path & route for the project' do
+ expect(subject).to receive(:rename_path_for_routable)
+ .with(project)
+ .and_call_original
+
+ subject.rename_project(project)
+
+ expect(project.reload.path).to eq('the-path0')
+ end
+
+ it 'tracks the rename' do
+ expect(subject).to receive(:track_rename)
+ .with('project', 'known-parent/the-path', 'known-parent/the-path0')
+
+ subject.rename_project(project)
+ end
+
+ it 'renames the folders for the project' do
+ expect(subject).to receive(:move_project_folders).with(project, 'known-parent/the-path', 'known-parent/the-path0')
+
+ subject.rename_project(project)
+ end
+ end
+
+ describe '#move_project_folders' do
+ it 'moves the wiki & the repo' do
+ expect(subject).to receive(:move_repository)
+ .with(project, 'known-parent/the-path.wiki', 'known-parent/the-path0.wiki')
+ expect(subject).to receive(:move_repository)
+ .with(project, 'known-parent/the-path', 'known-parent/the-path0')
+
+ subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0')
+ end
+
+ it 'moves uploads' do
+ expect(subject).to receive(:move_uploads)
+ .with('known-parent/the-path', 'known-parent/the-path0')
+
+ subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0')
+ end
+
+ it 'moves pages' do
+ expect(subject).to receive(:move_pages)
+ .with('known-parent/the-path', 'known-parent/the-path0')
+
+ subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0')
+ end
+ end
+
+ describe '#move_repository' do
+ let(:known_parent) { create(:namespace, path: 'known-parent') }
+ let(:project) { create(:project, :repository, path: 'the-path', namespace: known_parent) }
+
+ it 'moves the repository for a project' do
+ expected_path = File.join(TestEnv.repos_path, 'known-parent', 'new-repo.git')
+
+ subject.move_repository(project, 'known-parent/the-path', 'known-parent/new-repo')
+
+ expect(File.directory?(expected_path)).to be(true)
+ end
+ end
+
+ describe '#revert_renames', redis: true do
+ it 'renames the routes back to the previous values' do
+ subject.rename_project(project)
+
+ expect(subject).to receive(:perform_rename)
+ .with(
+ kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Project),
+ 'known-parent/the-path0',
+ 'known-parent/the-path'
+ ).and_call_original
+
+ subject.revert_renames
+
+ expect(project.reload.path).to eq('the-path')
+ expect(project.route.path).to eq('known-parent/the-path')
+ end
+
+ it 'moves the repositories back to their original place' do
+ project.create_repository
+ subject.rename_project(project)
+
+ expected_path = File.join(TestEnv.repos_path, 'known-parent', 'the-path.git')
+
+ expect(subject).to receive(:move_project_folders)
+ .with(
+ kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Project),
+ 'known-parent/the-path0',
+ 'known-parent/the-path'
+ ).and_call_original
+
+ subject.revert_renames
+
+ expect(File.directory?(expected_path)).to be_truthy
+ end
+
+ it "doesn't break when the project was renamed" do
+ subject.rename_project(project)
+ project.update_attributes!(path: 'renamed-afterwards')
+
+ expect { subject.revert_renames }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
new file mode 100644
index 00000000000..7695b95dc57
--- /dev/null
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
@@ -0,0 +1,76 @@
+require 'spec_helper'
+
+shared_examples 'renames child namespaces' do |type|
+ it 'renames namespaces' do
+ rename_namespaces = double
+ expect(described_class::RenameNamespaces)
+ .to receive(:new).with(['first-path', 'second-path'], subject)
+ .and_return(rename_namespaces)
+ expect(rename_namespaces).to receive(:rename_namespaces)
+ .with(type: :child)
+
+ subject.rename_wildcard_paths(['first-path', 'second-path'])
+ end
+end
+
+describe Gitlab::Database::RenameReservedPathsMigration::V1, :truncate do
+ let(:subject) { FakeRenameReservedPathMigrationV1.new }
+
+ before do
+ allow(subject).to receive(:say)
+ end
+
+ describe '#rename_child_paths' do
+ it_behaves_like 'renames child namespaces'
+ end
+
+ describe '#rename_wildcard_paths' do
+ it_behaves_like 'renames child namespaces'
+
+ it 'should rename projects' do
+ rename_projects = double
+ expect(described_class::RenameProjects)
+ .to receive(:new).with(['the-path'], subject)
+ .and_return(rename_projects)
+
+ expect(rename_projects).to receive(:rename_projects)
+
+ subject.rename_wildcard_paths(['the-path'])
+ end
+ end
+
+ describe '#rename_root_paths' do
+ it 'should rename namespaces' do
+ rename_namespaces = double
+ expect(described_class::RenameNamespaces)
+ .to receive(:new).with(['the-path'], subject)
+ .and_return(rename_namespaces)
+ expect(rename_namespaces).to receive(:rename_namespaces)
+ .with(type: :top_level)
+
+ subject.rename_root_paths('the-path')
+ end
+ end
+
+ describe '#revert_renames' do
+ it 'renames namespaces' do
+ rename_namespaces = double
+ expect(described_class::RenameNamespaces)
+ .to receive(:new).with([], subject)
+ .and_return(rename_namespaces)
+ expect(rename_namespaces).to receive(:revert_renames)
+
+ subject.revert_renames
+ end
+
+ it 'renames projects' do
+ rename_projects = double
+ expect(described_class::RenameProjects)
+ .to receive(:new).with([], subject)
+ .and_return(rename_projects)
+ expect(rename_projects).to receive(:revert_renames)
+
+ subject.revert_renames
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/sha_attribute_spec.rb b/spec/lib/gitlab/database/sha_attribute_spec.rb
new file mode 100644
index 00000000000..62c1d37ea1c
--- /dev/null
+++ b/spec/lib/gitlab/database/sha_attribute_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Database::ShaAttribute do
+ let(:sha) do
+ '9a573a369a5bfbb9a4a36e98852c21af8a44ea8b'
+ end
+
+ let(:binary_sha) do
+ [sha].pack('H*')
+ end
+
+ let(:binary_from_db) do
+ if Gitlab::Database.postgresql?
+ "\\x#{sha}"
+ else
+ binary_sha
+ end
+ end
+
+ let(:attribute) { described_class.new }
+
+ describe '#type_cast_from_database' do
+ it 'converts the binary SHA to a String' do
+ expect(attribute.type_cast_from_database(binary_from_db)).to eq(sha)
+ end
+ end
+
+ describe '#type_cast_for_database' do
+ it 'converts a SHA String to binary data' do
+ expect(attribute.type_cast_for_database(sha).to_s).to eq(binary_sha)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index 9b1d66a1b1c..5fa94999d25 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database, lib: true do
+describe Gitlab::Database do
before do
stub_const('MigrationTest', Class.new { include Gitlab::Database })
end
@@ -34,8 +34,8 @@ describe Gitlab::Database, lib: true do
describe '.version' do
context "on mysql" do
it "extracts the version number" do
- allow(described_class).to receive(:database_version).
- and_return("5.7.12-standard")
+ allow(described_class).to receive(:database_version)
+ .and_return("5.7.12-standard")
expect(described_class.version).to eq '5.7.12-standard'
end
@@ -43,24 +43,50 @@ describe Gitlab::Database, lib: true do
context "on postgresql" do
it "extracts the version number" do
- allow(described_class).to receive(:database_version).
- and_return("PostgreSQL 9.4.4 on x86_64-apple-darwin14.3.0")
+ allow(described_class).to receive(:database_version)
+ .and_return("PostgreSQL 9.4.4 on x86_64-apple-darwin14.3.0")
expect(described_class.version).to eq '9.4.4'
end
end
end
+ describe '.join_lateral_supported?' do
+ it 'returns false when using MySQL' do
+ allow(described_class).to receive(:postgresql?).and_return(false)
+
+ expect(described_class.join_lateral_supported?).to eq(false)
+ end
+
+ it 'returns false when using PostgreSQL 9.2' do
+ allow(described_class).to receive(:postgresql?).and_return(true)
+ allow(described_class).to receive(:version).and_return('9.2.1')
+
+ expect(described_class.join_lateral_supported?).to eq(false)
+ end
+
+ it 'returns true when using PostgreSQL 9.3.0 or newer' do
+ allow(described_class).to receive(:postgresql?).and_return(true)
+ allow(described_class).to receive(:version).and_return('9.3.0')
+
+ expect(described_class.join_lateral_supported?).to eq(true)
+ end
+ end
+
describe '.nulls_last_order' do
context 'when using PostgreSQL' do
- before { expect(described_class).to receive(:postgresql?).and_return(true) }
+ before do
+ expect(described_class).to receive(:postgresql?).and_return(true)
+ end
it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column ASC NULLS LAST'}
it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC NULLS LAST'}
end
context 'when using MySQL' do
- before { expect(described_class).to receive(:postgresql?).and_return(false) }
+ before do
+ expect(described_class).to receive(:postgresql?).and_return(false)
+ end
it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column IS NULL, column ASC'}
it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC'}
@@ -69,14 +95,18 @@ describe Gitlab::Database, lib: true do
describe '.nulls_first_order' do
context 'when using PostgreSQL' do
- before { expect(described_class).to receive(:postgresql?).and_return(true) }
+ before do
+ expect(described_class).to receive(:postgresql?).and_return(true)
+ end
it { expect(described_class.nulls_first_order('column', 'ASC')).to eq 'column ASC NULLS FIRST'}
it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'}
end
context 'when using MySQL' do
- before { expect(described_class).to receive(:postgresql?).and_return(false) }
+ before do
+ expect(described_class).to receive(:postgresql?).and_return(false)
+ end
it { expect(described_class.nulls_first_order('column', 'ASC')).to eq 'column ASC'}
it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column IS NULL, column DESC'}
@@ -121,6 +151,59 @@ describe Gitlab::Database, lib: true do
end
end
+ describe '.bulk_insert' do
+ before do
+ allow(described_class).to receive(:connection).and_return(connection)
+ allow(connection).to receive(:quote_column_name, &:itself)
+ allow(connection).to receive(:quote, &:itself)
+ allow(connection).to receive(:execute)
+ end
+
+ let(:connection) { double(:connection) }
+
+ let(:rows) do
+ [
+ { a: 1, b: 2, c: 3 },
+ { c: 6, a: 4, b: 5 }
+ ]
+ end
+
+ it 'does nothing with empty rows' do
+ expect(connection).not_to receive(:execute)
+
+ described_class.bulk_insert('test', [])
+ end
+
+ it 'uses the ordering from the first row' do
+ expect(connection).to receive(:execute) do |sql|
+ expect(sql).to include('(1, 2, 3)')
+ expect(sql).to include('(4, 5, 6)')
+ end
+
+ described_class.bulk_insert('test', rows)
+ end
+
+ it 'quotes column names' do
+ expect(connection).to receive(:quote_column_name).with(:a)
+ expect(connection).to receive(:quote_column_name).with(:b)
+ expect(connection).to receive(:quote_column_name).with(:c)
+
+ described_class.bulk_insert('test', rows)
+ end
+
+ it 'quotes values' do
+ 1.upto(6) do |i|
+ expect(connection).to receive(:quote).with(i)
+ end
+
+ described_class.bulk_insert('test', rows)
+ end
+
+ it 'handles non-UTF-8 data' do
+ expect { described_class.bulk_insert('test', [{ a: "\255" }]) }.not_to raise_error
+ end
+ end
+
describe '.create_connection_pool' do
it 'creates a new connection pool with specific pool size' do
pool = described_class.create_connection_pool(5)
diff --git a/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb
new file mode 100644
index 00000000000..3a93d5e1e97
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::CartfileLinker do
+ describe '.support?' do
+ it 'supports Cartfile' do
+ expect(described_class.support?('Cartfile')).to be_truthy
+ end
+
+ it 'supports Cartfile.private' do
+ expect(described_class.support?('Cartfile.private')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('test.Cartfile')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "Cartfile" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ # Require version 2.3.1 or later
+ github "ReactiveCocoa/ReactiveCocoa" >= 2.3.1
+
+ # Require version 1.x
+ github "Mantle/Mantle" ~> 1.0 # (1.0 or later, but less than 2.0)
+
+ # Require exactly version 0.4.1
+ github "jspahrsummers/libextobjc" == 0.4.1
+
+ # Use the latest version
+ github "jspahrsummers/xcconfigs"
+
+ # Use the branch
+ github "jspahrsummers/xcconfigs" "branch"
+
+ # Use a project from GitHub Enterprise
+ github "https://enterprise.local/ghe/desktop/git-error-translations"
+
+ # Use a project from any arbitrary server, on the "development" branch
+ git "https://enterprise.local/desktop/git-error-translations2.git" "development"
+
+ # Use a local project
+ git "file:///directory/to/project" "branch"
+
+ # A binary only framework
+ binary "https://my.domain.com/release/MyFramework.json" ~> 2.3
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('ReactiveCocoa/ReactiveCocoa', 'https://github.com/ReactiveCocoa/ReactiveCocoa'))
+ expect(subject).to include(link('Mantle/Mantle', 'https://github.com/Mantle/Mantle'))
+ expect(subject).to include(link('jspahrsummers/libextobjc', 'https://github.com/jspahrsummers/libextobjc'))
+ expect(subject).to include(link('jspahrsummers/xcconfigs', 'https://github.com/jspahrsummers/xcconfigs'))
+ end
+
+ it 'links Git repos' do
+ expect(subject).to include(link('https://enterprise.local/ghe/desktop/git-error-translations', 'https://enterprise.local/ghe/desktop/git-error-translations'))
+ expect(subject).to include(link('https://enterprise.local/desktop/git-error-translations2.git', 'https://enterprise.local/desktop/git-error-translations2.git'))
+ end
+
+ it 'links binary-only frameworks' do
+ expect(subject).to include(link('https://my.domain.com/release/MyFramework.json', 'https://my.domain.com/release/MyFramework.json'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb
new file mode 100644
index 00000000000..4d222564fd0
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb
@@ -0,0 +1,82 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::ComposerJsonLinker do
+ describe '.support?' do
+ it 'supports composer.json' do
+ expect(described_class.support?('composer.json')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('composer.json.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "composer.json" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ {
+ "name": "laravel/laravel",
+ "homepage": "https://laravel.com/",
+ "description": "The Laravel Framework.",
+ "keywords": ["framework", "laravel"],
+ "license": "MIT",
+ "type": "project",
+ "repositories": [
+ {
+ "type": "git",
+ "url": "https://github.com/laravel/laravel.git"
+ }
+ ],
+ "require": {
+ "php": ">=5.5.9",
+ "laravel/framework": "5.2.*"
+ },
+ "require-dev": {
+ "fzaninotto/faker": "~1.4",
+ "mockery/mockery": "0.9.*",
+ "phpunit/phpunit": "~4.0",
+ "symfony/css-selector": "2.8.*|3.0.*",
+ "symfony/dom-crawler": "2.8.*|3.0.*"
+ }
+ }
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the module name' do
+ expect(subject).to include(link('laravel/laravel', 'https://packagist.org/packages/laravel/laravel'))
+ end
+
+ it 'links the homepage' do
+ expect(subject).to include(link('https://laravel.com/', 'https://laravel.com/'))
+ end
+
+ it 'links the repository URL' do
+ expect(subject).to include(link('https://github.com/laravel/laravel.git', 'https://github.com/laravel/laravel.git'))
+ end
+
+ it 'links the license' do
+ expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('laravel/framework', 'https://packagist.org/packages/laravel/framework'))
+ expect(subject).to include(link('fzaninotto/faker', 'https://packagist.org/packages/fzaninotto/faker'))
+ expect(subject).to include(link('mockery/mockery', 'https://packagist.org/packages/mockery/mockery'))
+ expect(subject).to include(link('phpunit/phpunit', 'https://packagist.org/packages/phpunit/phpunit'))
+ expect(subject).to include(link('symfony/css-selector', 'https://packagist.org/packages/symfony/css-selector'))
+ expect(subject).to include(link('symfony/dom-crawler', 'https://packagist.org/packages/symfony/dom-crawler'))
+ end
+
+ it 'does not link core dependencies' do
+ expect(subject).not_to include(link('php', 'https://packagist.org/packages/php'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb
new file mode 100644
index 00000000000..a97803b119e
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::GemfileLinker do
+ describe '.support?' do
+ it 'supports Gemfile' do
+ expect(described_class.support?('Gemfile')).to be_truthy
+ end
+
+ it 'supports gems.rb' do
+ expect(described_class.support?('gems.rb')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('Gemfile.lock')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { 'Gemfile' }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ source 'https://rubygems.org'
+
+ gem "rails", '4.2.6', github: "rails/rails"
+ gem 'rails-deprecated_sanitizer', '~> 1.0.3'
+ gem 'responders', '~> 2.0', :github => 'rails/responders'
+ gem 'sprockets', '~> 3.6.0', git: 'https://gitlab.example.com/gems/sprockets'
+ gem 'default_value_for', '~> 3.0.0'
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links sources' do
+ expect(subject).to include(link('https://rubygems.org', 'https://rubygems.org'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('rails', 'https://rubygems.org/gems/rails'))
+ expect(subject).to include(link('rails-deprecated_sanitizer', 'https://rubygems.org/gems/rails-deprecated_sanitizer'))
+ expect(subject).to include(link('responders', 'https://rubygems.org/gems/responders'))
+ expect(subject).to include(link('sprockets', 'https://rubygems.org/gems/sprockets'))
+ expect(subject).to include(link('default_value_for', 'https://rubygems.org/gems/default_value_for'))
+ end
+
+ it 'links GitHub repos' do
+ expect(subject).to include(link('rails/rails', 'https://github.com/rails/rails'))
+ expect(subject).to include(link('rails/responders', 'https://github.com/rails/responders'))
+ end
+
+ it 'links Git repos' do
+ expect(subject).to include(link('https://gitlab.example.com/gems/sprockets', 'https://gitlab.example.com/gems/sprockets'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb
new file mode 100644
index 00000000000..24ad7d12f4c
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb
@@ -0,0 +1,66 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::GemspecLinker do
+ describe '.support?' do
+ it 'supports *.gemspec' do
+ expect(described_class.support?('gitlab_git.gemspec')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('.gemspec.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "gitlab_git.gemspec" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ Gem::Specification.new do |s|
+ s.name = 'gitlab_git'
+ s.version = `cat VERSION`
+ s.date = Time.now.strftime('%Y-%m-%d')
+ s.summary = "Gitlab::Git library"
+ s.description = "GitLab wrapper around git objects"
+ s.authors = ["Dmitriy Zaporozhets"]
+ s.email = 'dmitriy.zaporozhets@gmail.com'
+ s.license = 'MIT'
+ s.files = `git ls-files lib/`.split('\n') << 'VERSION'
+ s.homepage = 'https://gitlab.com/gitlab-org/gitlab_git'
+
+ s.add_dependency('github-linguist', '~> 4.7.0')
+ s.add_dependency('activesupport', '~> 4.0')
+ s.add_dependency('rugged', '~> 0.24.0')
+ s.add_runtime_dependency('charlock_holmes', '~> 0.7.3')
+ s.add_development_dependency('listen', '~> 3.0.6')
+ end
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the gem name' do
+ expect(subject).to include(link('gitlab_git', 'https://rubygems.org/gems/gitlab_git'))
+ end
+
+ it 'links the license' do
+ expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/'))
+ end
+
+ it 'links the homepage' do
+ expect(subject).to include(link('https://gitlab.com/gitlab-org/gitlab_git', 'https://gitlab.com/gitlab-org/gitlab_git'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('github-linguist', 'https://rubygems.org/gems/github-linguist'))
+ expect(subject).to include(link('activesupport', 'https://rubygems.org/gems/activesupport'))
+ expect(subject).to include(link('rugged', 'https://rubygems.org/gems/rugged'))
+ expect(subject).to include(link('charlock_holmes', 'https://rubygems.org/gems/charlock_holmes'))
+ expect(subject).to include(link('listen', 'https://rubygems.org/gems/listen'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb
new file mode 100644
index 00000000000..ae5ad39ad11
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb
@@ -0,0 +1,84 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::GodepsJsonLinker do
+ describe '.support?' do
+ it 'supports Godeps.json' do
+ expect(described_class.support?('Godeps.json')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('Godeps.json.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "Godeps.json" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ {
+ "ImportPath": "gitlab.com/gitlab-org/gitlab-pages",
+ "GoVersion": "go1.5",
+ "Packages": [
+ "./..."
+ ],
+ "Deps": [
+ {
+ "ImportPath": "github.com/kardianos/osext",
+ "Rev": "efacde03154693404c65e7aa7d461ac9014acd0c"
+ },
+ {
+ "ImportPath": "github.com/stretchr/testify/assert",
+ "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b"
+ },
+ {
+ "ImportPath": "github.com/stretchr/testify/require",
+ "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b"
+ },
+ {
+ "ImportPath": "gitlab.com/group/project/path",
+ "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b"
+ },
+ {
+ "ImportPath": "gitlab.com/group/subgroup/project.git/path",
+ "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b"
+ },
+ {
+ "ImportPath": "golang.org/x/crypto/ssh/terminal",
+ "Rev": "1351f936d976c60a0a48d728281922cf63eafb8d"
+ },
+ {
+ "ImportPath": "golang.org/x/net/http2",
+ "Rev": "b4e17d61b15679caf2335da776c614169a1b4643"
+ }
+ ]
+ }
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the package name' do
+ expect(subject).to include(link('gitlab.com/gitlab-org/gitlab-pages', 'https://gitlab.com/gitlab-org/gitlab-pages'))
+ end
+
+ it 'links GitHub repos' do
+ expect(subject).to include(link('github.com/kardianos/osext', 'https://github.com/kardianos/osext'))
+ expect(subject).to include(link('github.com/stretchr/testify/assert', 'https://github.com/stretchr/testify/tree/master/assert'))
+ expect(subject).to include(link('github.com/stretchr/testify/require', 'https://github.com/stretchr/testify/tree/master/require'))
+ end
+
+ it 'links GitLab projects' do
+ expect(subject).to include(link('gitlab.com/group/project/path', 'https://gitlab.com/group/project/tree/master/path'))
+ expect(subject).to include(link('gitlab.com/group/subgroup/project.git/path', 'https://gitlab.com/group/subgroup/project/tree/master/path'))
+ end
+
+ it 'links Golang packages' do
+ expect(subject).to include(link('golang.org/x/net/http2', 'https://godoc.org/golang.org/x/net/http2'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb
new file mode 100644
index 00000000000..1e8b72afb7b
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb
@@ -0,0 +1,94 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::PackageJsonLinker do
+ describe '.support?' do
+ it 'supports package.json' do
+ expect(described_class.support?('package.json')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('package.json.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "package.json" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ {
+ "name": "module-name",
+ "version": "10.3.1",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vuejs/vue.git"
+ },
+ "homepage": "https://github.com/vuejs/vue#readme",
+ "scripts": {
+ "karma": "karma start config/karma.config.js --single-run"
+ },
+ "dependencies": {
+ "primus": "*",
+ "async": "~0.8.0",
+ "express": "4.2.x",
+ "bigpipe": "bigpipe/pagelet",
+ "plates": "https://github.com/flatiron/plates/tarball/master",
+ "karma": "^1.4.1"
+ },
+ "devDependencies": {
+ "vows": "^0.7.0",
+ "assume": "<1.0.0 || >=2.3.1 <2.4.5 || >=2.5.2 <3.0.0",
+ "pre-commit": "*"
+ },
+ "license": "MIT"
+ }
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the module name' do
+ expect(subject).to include(link('module-name', 'https://npmjs.com/package/module-name'))
+ end
+
+ it 'links the homepage' do
+ expect(subject).to include(link('https://github.com/vuejs/vue#readme', 'https://github.com/vuejs/vue#readme'))
+ end
+
+ it 'links the repository URL' do
+ expect(subject).to include(link('https://github.com/vuejs/vue.git', 'https://github.com/vuejs/vue.git'))
+ end
+
+ it 'links the license' do
+ expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('primus', 'https://npmjs.com/package/primus'))
+ expect(subject).to include(link('async', 'https://npmjs.com/package/async'))
+ expect(subject).to include(link('express', 'https://npmjs.com/package/express'))
+ expect(subject).to include(link('bigpipe', 'https://npmjs.com/package/bigpipe'))
+ expect(subject).to include(link('plates', 'https://npmjs.com/package/plates'))
+ expect(subject).to include(link('karma', 'https://npmjs.com/package/karma'))
+ expect(subject).to include(link('vows', 'https://npmjs.com/package/vows'))
+ expect(subject).to include(link('assume', 'https://npmjs.com/package/assume'))
+ expect(subject).to include(link('pre-commit', 'https://npmjs.com/package/pre-commit'))
+ end
+
+ it 'links GitHub repos' do
+ expect(subject).to include(link('bigpipe/pagelet', 'https://github.com/bigpipe/pagelet'))
+ end
+
+ it 'links Git repos' do
+ expect(subject).to include(link('https://github.com/flatiron/plates/tarball/master', 'https://github.com/flatiron/plates/tarball/master'))
+ end
+
+ it 'does not link scripts with the same key as a package' do
+ expect(subject).not_to include(link('karma start config/karma.config.js --single-run', 'https://github.com/karma start config/karma.config.js --single-run'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb
new file mode 100644
index 00000000000..cdfd7ad9826
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::PodfileLinker do
+ describe '.support?' do
+ it 'supports Podfile' do
+ expect(described_class.support?('Podfile')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('Podfile.lock')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "Podfile" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ source 'https://github.com/artsy/Specs.git'
+ source 'https://github.com/CocoaPods/Specs.git'
+
+ platform :ios, '8.0'
+ use_frameworks!
+ inhibit_all_warnings!
+
+ target 'Artsy' do
+ pod 'AFNetworking', "~> 2.5"
+ pod 'Interstellar/Core', git: 'https://github.com/ashfurrow/Interstellar.git', branch: 'observable-unsubscribe'
+ end
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links sources' do
+ expect(subject).to include(link('https://github.com/artsy/Specs.git', 'https://github.com/artsy/Specs.git'))
+ expect(subject).to include(link('https://github.com/CocoaPods/Specs.git', 'https://github.com/CocoaPods/Specs.git'))
+ end
+
+ it 'links packages' do
+ expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking'))
+ expect(subject).to include(link('Interstellar/Core', 'https://cocoapods.org/pods/Interstellar'))
+ end
+
+ it 'links Git repos' do
+ expect(subject).to include(link('https://github.com/ashfurrow/Interstellar.git', 'https://github.com/ashfurrow/Interstellar.git'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb
new file mode 100644
index 00000000000..d4a398c5948
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb
@@ -0,0 +1,96 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::PodspecJsonLinker do
+ describe '.support?' do
+ it 'supports *.podspec.json' do
+ expect(described_class.support?('Reachability.podspec.json')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('.podspec.json.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "AFNetworking.podspec.json" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ {
+ "name": "AFNetworking",
+ "version": "2.0.0",
+ "license": "MIT",
+ "summary": "A delightful iOS and OS X networking framework.",
+ "homepage": "https://github.com/AFNetworking/AFNetworking",
+ "authors": {
+ "Mattt Thompson": "m@mattt.me"
+ },
+ "source": {
+ "git": "https://github.com/AFNetworking/AFNetworking.git",
+ "tag": "2.0.0",
+ "submodules": true
+ },
+ "requires_arc": true,
+ "platforms": {
+ "ios": "6.0",
+ "osx": "10.8"
+ },
+ "public_header_files": "AFNetworking/*.h",
+ "subspecs": [
+ {
+ "name": "NSURLConnection",
+ "dependencies": {
+ "AFNetworking/Serialization": [
+
+ ],
+ "AFNetworking/Reachability": [
+
+ ],
+ "AFNetworking/Security": [
+
+ ]
+ },
+ "source_files": [
+ "AFNetworking/AFURLConnectionOperation.{h,m}",
+ "AFNetworking/AFHTTPRequestOperation.{h,m}",
+ "AFNetworking/AFHTTPRequestOperationManager.{h,m}"
+ ]
+ }
+ ]
+ }
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the gem name' do
+ expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking'))
+ end
+
+ it 'links the license' do
+ expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/'))
+ end
+
+ it 'links the homepage' do
+ expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking', 'https://github.com/AFNetworking/AFNetworking'))
+ end
+
+ it 'links the source URL' do
+ expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking.git', 'https://github.com/AFNetworking/AFNetworking.git'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('AFNetworking/Serialization', 'https://cocoapods.org/pods/AFNetworking'))
+ expect(subject).to include(link('AFNetworking/Reachability', 'https://cocoapods.org/pods/AFNetworking'))
+ expect(subject).to include(link('AFNetworking/Security', 'https://cocoapods.org/pods/AFNetworking'))
+ end
+
+ it 'does not link subspec names' do
+ expect(subject).not_to include(link('NSURLConnection', 'https://cocoapods.org/pods/NSURLConnection'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb
new file mode 100644
index 00000000000..ed60ab45955
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb
@@ -0,0 +1,69 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::PodspecLinker do
+ describe '.support?' do
+ it 'supports *.podspec' do
+ expect(described_class.support?('Reachability.podspec')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('.podspec.example')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "Reachability.podspec" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ Pod::Spec.new do |spec|
+ spec.name = 'Reachability'
+ spec.version = '3.1.0'
+ spec.license = { :type => 'GPL-3.0' }
+ spec.license = "MIT"
+ spec.license = { type: 'Apache-2.0' }
+ spec.homepage = 'https://github.com/tonymillion/Reachability'
+ spec.authors = { 'Tony Million' => 'tonymillion@gmail.com' }
+ spec.summary = 'ARC and GCD Compatible Reachability Class for iOS and OS X.'
+ spec.source = { :git => 'https://github.com/tonymillion/Reachability.git', :tag => 'v3.1.0' }
+ spec.source_files = 'Reachability.{h,m}'
+ spec.framework = 'SystemConfiguration'
+
+ spec.dependency 'AFNetworking', '~> 1.0'
+ spec.dependency 'RestKit/CoreData', '~> 0.20.0'
+ spec.ios.dependency 'MBProgressHUD', '~> 0.5'
+ end
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links the gem name' do
+ expect(subject).to include(link('Reachability', 'https://cocoapods.org/pods/Reachability'))
+ end
+
+ it 'links the license' do
+ expect(subject).to include(link('GPL-3.0', 'http://choosealicense.com/licenses/gpl-3.0/'))
+ expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/'))
+ expect(subject).to include(link('Apache-2.0', 'http://choosealicense.com/licenses/apache-2.0/'))
+ end
+
+ it 'links the homepage' do
+ expect(subject).to include(link('https://github.com/tonymillion/Reachability', 'https://github.com/tonymillion/Reachability'))
+ end
+
+ it 'links the source URL' do
+ expect(subject).to include(link('https://github.com/tonymillion/Reachability.git', 'https://github.com/tonymillion/Reachability.git'))
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking'))
+ expect(subject).to include(link('RestKit/CoreData', 'https://cocoapods.org/pods/RestKit'))
+ expect(subject).to include(link('MBProgressHUD', 'https://cocoapods.org/pods/MBProgressHUD'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb
new file mode 100644
index 00000000000..ef952b3abd5
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker::RequirementsTxtLinker do
+ describe '.support?' do
+ it 'supports requirements.txt' do
+ expect(described_class.support?('requirements.txt')).to be_truthy
+ end
+
+ it 'supports doc-requirements.txt' do
+ expect(described_class.support?('doc-requirements.txt')).to be_truthy
+ end
+
+ it 'does not support other files' do
+ expect(described_class.support?('requirements')).to be_falsey
+ end
+ end
+
+ describe '#link' do
+ let(:file_name) { "requirements.txt" }
+
+ let(:file_content) do
+ <<-CONTENT.strip_heredoc
+ #
+ ####### example-requirements.txt #######
+ #
+ ###### Requirements without Version Specifiers ######
+ nose
+ nose-cov
+ beautifulsoup4
+ #
+ ###### Requirements with Version Specifiers ######
+ # See https://www.python.org/dev/peps/pep-0440/#version-specifiers
+ docopt == 0.6.1 # Version Matching. Must be version 0.6.1
+ keyring >= 4.1.1 # Minimum version 4.1.1
+ coverage != 3.5 # Version Exclusion. Anything except version 3.5
+ Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*
+ #
+ ###### Refer to other requirements files ######
+ -r other-requirements.txt
+ #
+ #
+ ###### A particular file ######
+ ./downloads/numpy-1.9.2-cp34-none-win32.whl
+ http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl
+ #
+ ###### Additional Requirements without Version Specifiers ######
+ # Same as 1st section, just here to show that you can put things in any order.
+ rejected
+ green
+ #
+
+ Jinja2>=2.3
+ Pygments>=1.2
+ Sphinx>=1.3
+ docutils>=0.7
+ markupsafe
+ pytest~=3.0
+ foop!=3.0
+ CONTENT
+ end
+
+ subject { Gitlab::Highlight.highlight(file_name, file_content) }
+
+ def link(name, url)
+ %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>}
+ end
+
+ it 'links dependencies' do
+ expect(subject).to include(link('nose', 'https://pypi.python.org/pypi/nose'))
+ expect(subject).to include(link('nose-cov', 'https://pypi.python.org/pypi/nose-cov'))
+ expect(subject).to include(link('beautifulsoup4', 'https://pypi.python.org/pypi/beautifulsoup4'))
+ expect(subject).to include(link('docopt', 'https://pypi.python.org/pypi/docopt'))
+ expect(subject).to include(link('keyring', 'https://pypi.python.org/pypi/keyring'))
+ expect(subject).to include(link('coverage', 'https://pypi.python.org/pypi/coverage'))
+ expect(subject).to include(link('Mopidy-Dirble', 'https://pypi.python.org/pypi/Mopidy-Dirble'))
+ expect(subject).to include(link('rejected', 'https://pypi.python.org/pypi/rejected'))
+ expect(subject).to include(link('green', 'https://pypi.python.org/pypi/green'))
+ expect(subject).to include(link('Jinja2', 'https://pypi.python.org/pypi/Jinja2'))
+ expect(subject).to include(link('Pygments', 'https://pypi.python.org/pypi/Pygments'))
+ expect(subject).to include(link('Sphinx', 'https://pypi.python.org/pypi/Sphinx'))
+ expect(subject).to include(link('docutils', 'https://pypi.python.org/pypi/docutils'))
+ expect(subject).to include(link('markupsafe', 'https://pypi.python.org/pypi/markupsafe'))
+ expect(subject).to include(link('pytest', 'https://pypi.python.org/pypi/pytest'))
+ expect(subject).to include(link('foop', 'https://pypi.python.org/pypi/foop'))
+ end
+
+ it 'links URLs' do
+ expect(subject).to include(link('http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl', 'http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl'))
+ end
+
+ it 'does not contain link with a newline as package name' do
+ expect(subject).not_to include(link("\n", "https://pypi.python.org/pypi/\n"))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/dependency_linker_spec.rb b/spec/lib/gitlab/dependency_linker_spec.rb
new file mode 100644
index 00000000000..10d2f701298
--- /dev/null
+++ b/spec/lib/gitlab/dependency_linker_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+describe Gitlab::DependencyLinker do
+ describe '.link' do
+ it 'links using GemfileLinker' do
+ blob_name = 'Gemfile'
+
+ expect(described_class::GemfileLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using GemspecLinker' do
+ blob_name = 'gitlab_git.gemspec'
+
+ expect(described_class::GemspecLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using PackageJsonLinker' do
+ blob_name = 'package.json'
+
+ expect(described_class::PackageJsonLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using ComposerJsonLinker' do
+ blob_name = 'composer.json'
+
+ expect(described_class::ComposerJsonLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using PodfileLinker' do
+ blob_name = 'Podfile'
+
+ expect(described_class::PodfileLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using PodspecLinker' do
+ blob_name = 'Reachability.podspec'
+
+ expect(described_class::PodspecLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using PodspecJsonLinker' do
+ blob_name = 'AFNetworking.podspec.json'
+
+ expect(described_class::PodspecJsonLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using CartfileLinker' do
+ blob_name = 'Cartfile'
+
+ expect(described_class::CartfileLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using GodepsJsonLinker' do
+ blob_name = 'Godeps.json'
+
+ expect(described_class::GodepsJsonLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+
+ it 'links using RequirementsTxtLinker' do
+ blob_name = 'requirements.txt'
+
+ expect(described_class::RequirementsTxtLinker).to receive(:link)
+
+ described_class.link(blob_name, nil, nil)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/diff/diff_refs_spec.rb b/spec/lib/gitlab/diff/diff_refs_spec.rb
new file mode 100644
index 00000000000..c73708d90a8
--- /dev/null
+++ b/spec/lib/gitlab/diff/diff_refs_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe Gitlab::Diff::DiffRefs do
+ let(:project) { create(:project, :repository) }
+
+ describe '#compare_in' do
+ context 'with diff refs for the initial commit' do
+ let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') }
+ subject { commit.diff_refs }
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a commit' do
+ let(:commit) { project.commit('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ subject { commit.diff_refs }
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a comparison through the base' do
+ subject do
+ described_class.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+
+ context 'with diff refs for a straight comparison' do
+ subject do
+ described_class.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ it 'returns an appropriate comparison' do
+ compare = subject.compare_in(project)
+
+ expect(compare.diff_refs).to eq(subject)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
index f2bc15d39d7..d81774c8b8f 100644
--- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
+++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb
@@ -5,15 +5,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
let(:diff_files) { described_class.new(merge_request.merge_request_diff, diff_options: nil).diff_files }
it 'does not highlight binary files' do
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(double("text?" => false))
-
- expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
-
- diff_files
- end
-
- it 'does not highlight file if blob is not accessable' do
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(nil)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(false)
expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
@@ -21,7 +13,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
end
it 'does not files marked as undiffable in .gitattributes' do
- allow_any_instance_of(Repository).to receive(:diffable?).and_return(false)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false)
expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines)
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index 050689b7c9a..d3d841b0668 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::Diff::File, lib: true do
+describe Gitlab::Diff::File do
include RepoHelpers
let(:project) { create(:project, :repository) }
let(:commit) { project.commit(sample_commit.id) }
let(:diff) { commit.raw_diffs.first }
- let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: project.repository) }
+ let(:diff_file) { described_class.new(diff, diff_refs: commit.diff_refs, repository: project.repository) }
describe '#diff_lines' do
let(:diff_lines) { diff_file.diff_lines }
@@ -47,14 +47,6 @@ describe Gitlab::Diff::File, lib: true do
end
end
- describe '#old_content_commit' do
- it 'returns base commit' do
- old_content_commit = diff_file.old_content_commit
-
- expect(old_content_commit.id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
- end
- end
-
describe '#old_blob' do
it 'returns blob of commit of base commit' do
old_data = diff_file.old_blob.data
@@ -63,11 +55,334 @@ describe Gitlab::Diff::File, lib: true do
end
end
- describe '#blob' do
+ describe '#new_blob' do
it 'returns blob of new commit' do
- data = diff_file.blob.data
+ data = diff_file.new_blob.data
expect(data).to include('raise RuntimeError, "System commands must be given as an array of strings"')
end
end
+
+ describe '#diffable?' do
+ let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') }
+ let(:diffs) { commit.diffs }
+
+ before do
+ info_dir_path = File.join(project.repository.path_to_repo, 'info')
+
+ FileUtils.mkdir(info_dir_path) unless File.exist?(info_dir_path)
+ File.write(File.join(info_dir_path, 'attributes'), "*.md -diff\n")
+ end
+
+ it "returns true for files that do not have attributes" do
+ diff_file = diffs.diff_file_with_new_path('LICENSE')
+ expect(diff_file.diffable?).to be_truthy
+ end
+
+ it "returns false for files that have been marked as not being diffable in attributes" do
+ diff_file = diffs.diff_file_with_new_path('README.md')
+ expect(diff_file.diffable?).to be_falsey
+ end
+ end
+
+ describe '#content_changed?' do
+ context 'when created' do
+ let(:commit) { project.commit('33f3729a45c02fc67d00adb1b8bca394b0e761d9') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ it 'returns false' do
+ expect(diff_file.content_changed?).to be_falsey
+ end
+ end
+
+ context 'when deleted' do
+ let(:commit) { project.commit('d59c60028b053793cecfb4022de34602e1a9218e') }
+ let(:diff_file) { commit.diffs.diff_file_with_old_path('files/js/commit.js.coffee') }
+
+ it 'returns false' do
+ expect(diff_file.content_changed?).to be_falsey
+ end
+ end
+
+ context 'when renamed' do
+ let(:commit) { project.commit('6907208d755b60ebeacb2e9dfea74c92c3449a1f') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/js/commit.coffee') }
+
+ before do
+ allow(diff_file.new_blob).to receive(:id).and_return(diff_file.old_blob.id)
+ end
+
+ it 'returns false' do
+ expect(diff_file.content_changed?).to be_falsey
+ end
+ end
+
+ context 'when content changed' do
+ context 'when binary' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ it 'returns true' do
+ expect(diff_file.content_changed?).to be_truthy
+ end
+ end
+
+ context 'when not binary' do
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ it 'returns true' do
+ expect(diff_file.content_changed?).to be_truthy
+ end
+ end
+ end
+ end
+
+ describe '#simple_viewer' do
+ context 'when the file is not diffable' do
+ before do
+ allow(diff_file).to receive(:diffable?).and_return(false)
+ end
+
+ it 'returns a Not Diffable viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::NotDiffable)
+ end
+ end
+
+ context 'when the content changed' do
+ context 'when the file represented by the diff file is binary' do
+ before do
+ allow(diff_file).to receive(:raw_binary?).and_return(true)
+ end
+
+ it 'returns a No Preview viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::NoPreview)
+ end
+ end
+
+ context 'when the diff file old and new blob types are different' do
+ before do
+ allow(diff_file).to receive(:different_type?).and_return(true)
+ end
+
+ it 'returns a No Preview viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::NoPreview)
+ end
+ end
+
+ context 'when the file represented by the diff file is text-based' do
+ it 'returns a text viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Text)
+ end
+ end
+ end
+
+ context 'when created' do
+ let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ before do
+ allow(diff_file).to receive(:content_changed?).and_return(nil)
+ end
+
+ context 'when the file represented by the diff file is binary' do
+ before do
+ allow(diff_file).to receive(:raw_binary?).and_return(true)
+ end
+
+ it 'returns an Added viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Added)
+ end
+ end
+
+ context 'when the diff file old and new blob types are different' do
+ before do
+ allow(diff_file).to receive(:different_type?).and_return(true)
+ end
+
+ it 'returns an Added viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Added)
+ end
+ end
+
+ context 'when the file represented by the diff file is text-based' do
+ it 'returns a text viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Text)
+ end
+ end
+ end
+
+ context 'when deleted' do
+ let(:commit) { project.commit('d59c60028b053793cecfb4022de34602e1a9218e') }
+ let(:diff_file) { commit.diffs.diff_file_with_old_path('files/js/commit.js.coffee') }
+
+ before do
+ allow(diff_file).to receive(:content_changed?).and_return(nil)
+ end
+
+ context 'when the file represented by the diff file is binary' do
+ before do
+ allow(diff_file).to receive(:raw_binary?).and_return(true)
+ end
+
+ it 'returns a Deleted viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Deleted)
+ end
+ end
+
+ context 'when the diff file old and new blob types are different' do
+ before do
+ allow(diff_file).to receive(:different_type?).and_return(true)
+ end
+
+ it 'returns a Deleted viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Deleted)
+ end
+ end
+
+ context 'when the file represented by the diff file is text-based' do
+ it 'returns a text viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Text)
+ end
+ end
+ end
+
+ context 'when renamed' do
+ let(:commit) { project.commit('6907208d755b60ebeacb2e9dfea74c92c3449a1f') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/js/commit.coffee') }
+
+ before do
+ allow(diff_file).to receive(:content_changed?).and_return(nil)
+ end
+
+ it 'returns a Renamed viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::Renamed)
+ end
+ end
+
+ context 'when mode changed' do
+ before do
+ allow(diff_file).to receive(:content_changed?).and_return(nil)
+ allow(diff_file).to receive(:mode_changed?).and_return(true)
+ end
+
+ it 'returns a Mode Changed viewer' do
+ expect(diff_file.simple_viewer).to be_a(DiffViewer::ModeChanged)
+ end
+ end
+ end
+
+ describe '#rich_viewer' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ context 'when the diff file has a matching viewer' do
+ context 'when the diff file content did not change' do
+ before do
+ allow(diff_file).to receive(:content_changed?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(diff_file.rich_viewer).to be_nil
+ end
+ end
+
+ context 'when the diff file is not diffable' do
+ before do
+ allow(diff_file).to receive(:diffable?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(diff_file.rich_viewer).to be_nil
+ end
+ end
+
+ context 'when the diff file old and new blob types are different' do
+ before do
+ allow(diff_file).to receive(:different_type?).and_return(true)
+ end
+
+ it 'returns nil' do
+ expect(diff_file.rich_viewer).to be_nil
+ end
+ end
+
+ context 'when the diff file has an external storage error' do
+ before do
+ allow(diff_file).to receive(:external_storage_error?).and_return(true)
+ end
+
+ it 'returns nil' do
+ expect(diff_file.rich_viewer).to be_nil
+ end
+ end
+
+ context 'when everything is right' do
+ it 'returns the viewer' do
+ expect(diff_file.rich_viewer).to be_a(DiffViewer::Image)
+ end
+ end
+ end
+
+ context 'when the diff file does not have a matching viewer' do
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ it 'returns nil' do
+ expect(diff_file.rich_viewer).to be_nil
+ end
+ end
+ end
+
+ describe '#rendered_as_text?' do
+ context 'when the simple viewer is text-based' do
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ context 'when ignoring errors' do
+ context 'when the viewer has render errors' do
+ before do
+ diff_file.diff.too_large!
+ end
+
+ it 'returns true' do
+ expect(diff_file.rendered_as_text?).to be_truthy
+ end
+ end
+
+ context "when the viewer doesn't have render errors" do
+ it 'returns true' do
+ expect(diff_file.rendered_as_text?).to be_truthy
+ end
+ end
+ end
+
+ context 'when not ignoring errors' do
+ context 'when the viewer has render errors' do
+ before do
+ diff_file.diff.too_large!
+ end
+
+ it 'returns false' do
+ expect(diff_file.rendered_as_text?(ignore_errors: false)).to be_falsey
+ end
+ end
+
+ context "when the viewer doesn't have render errors" do
+ it 'returns true' do
+ expect(diff_file.rendered_as_text?(ignore_errors: false)).to be_truthy
+ end
+ end
+ end
+ end
+
+ context 'when the simple viewer is binary' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ it 'returns false' do
+ expect(diff_file.rendered_as_text?).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb
index c6bd4e81f4f..cd602ccab8e 100644
--- a/spec/lib/gitlab/diff/highlight_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::Highlight, lib: true do
+describe Gitlab::Diff::Highlight do
include RepoHelpers
let(:project) { create(:project, :repository) }
@@ -10,7 +10,7 @@ describe Gitlab::Diff::Highlight, lib: true do
describe '#highlight' do
context "with a diff file" do
- let(:subject) { Gitlab::Diff::Highlight.new(diff_file, repository: project.repository).highlight }
+ let(:subject) { described_class.new(diff_file, repository: project.repository).highlight }
it 'returns Gitlab::Diff::Line elements' do
expect(subject.first).to be_an_instance_of(Gitlab::Diff::Line)
@@ -34,14 +34,14 @@ describe Gitlab::Diff::Highlight, lib: true do
end
it 'highlights and marks added lines' do
- code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class='idiff left'>RuntimeError</span></span><span class="p"><span class='idiff'>,</span></span><span class='idiff right'> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n}
+ code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n}
expect(subject[5].text).to eq(code)
end
end
context "with diff lines" do
- let(:subject) { Gitlab::Diff::Highlight.new(diff_file.diff_lines, repository: project.repository).highlight }
+ let(:subject) { described_class.new(diff_file.diff_lines, repository: project.repository).highlight }
it 'returns Gitlab::Diff::Line elements' do
expect(subject.first).to be_an_instance_of(Gitlab::Diff::Line)
@@ -67,7 +67,7 @@ describe Gitlab::Diff::Highlight, lib: true do
end
it 'marks added lines' do
- code = %q{+ raise <span class='idiff left right'>RuntimeError, </span>&quot;System commands must be given as an array of strings&quot;}
+ code = %q{+ raise <span class="idiff left right">RuntimeError, </span>&quot;System commands must be given as an array of strings&quot;}
expect(subject[5].text).to eq(code)
expect(subject[5].text).to be_html_safe
diff --git a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb
new file mode 100644
index 00000000000..046b096e366
--- /dev/null
+++ b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb
@@ -0,0 +1,14 @@
+require 'spec_helper'
+
+describe Gitlab::Diff::InlineDiffMarkdownMarker do
+ describe '#mark' do
+ let(:raw) { "abc 'def'" }
+ let(:inline_diffs) { [2..5] }
+ let(:subject) { described_class.new(raw).mark(inline_diffs, mode: :deletion) }
+
+ it 'marks the range' do
+ expect(subject).to eq("ab{-c &#39;d-}ef&#39;")
+ expect(subject).to be_html_safe
+ end
+ end
+end
diff --git a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb
index 198ff977f24..c3bf34c24ae 100644
--- a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb
+++ b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb
@@ -1,26 +1,26 @@
require 'spec_helper'
-describe Gitlab::Diff::InlineDiffMarker, lib: true do
- describe '#inline_diffs' do
+describe Gitlab::Diff::InlineDiffMarker do
+ describe '#mark' do
context "when the rich text is html safe" do
- let(:raw) { "abc 'def'" }
+ let(:raw) { "abc 'def'" }
let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def">&#39;def&#39;</span>}.html_safe }
let(:inline_diffs) { [2..5] }
- let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw, rich).mark(inline_diffs) }
+ let(:subject) { described_class.new(raw, rich).mark(inline_diffs) }
- it 'marks the inline diffs' do
- expect(subject).to eq(%{<span class="abc">ab<span class='idiff left'>c</span></span><span class="space"><span class='idiff'> </span></span><span class="def"><span class='idiff right'>&#39;d</span>ef&#39;</span>})
+ it 'marks the range' do
+ expect(subject).to eq(%{<span class="abc">ab<span class="idiff left">c</span></span><span class="space"><span class="idiff"> </span></span><span class="def"><span class="idiff right">&#39;d</span>ef&#39;</span>})
expect(subject).to be_html_safe
end
end
context "when the text text is not html safe" do
- let(:raw) { "abc 'def'" }
+ let(:raw) { "abc 'def'" }
let(:inline_diffs) { [2..5] }
- let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw).mark(inline_diffs) }
+ let(:subject) { described_class.new(raw).mark(inline_diffs) }
- it 'marks the inline diffs' do
- expect(subject).to eq(%{ab<span class='idiff left right'>c &#39;d</span>ef&#39;})
+ it 'marks the range' do
+ expect(subject).to eq(%{ab<span class="idiff left right">c &#39;d</span>ef&#39;})
expect(subject).to be_html_safe
end
end
diff --git a/spec/lib/gitlab/diff/inline_diff_spec.rb b/spec/lib/gitlab/diff/inline_diff_spec.rb
index 8ca3f73509e..15451c2cf99 100644
--- a/spec/lib/gitlab/diff/inline_diff_spec.rb
+++ b/spec/lib/gitlab/diff/inline_diff_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::InlineDiff, lib: true do
+describe Gitlab::Diff::InlineDiff do
describe '.for_lines' do
let(:diff) do
<<-EOF.strip_heredoc
diff --git a/spec/lib/gitlab/diff/line_mapper_spec.rb b/spec/lib/gitlab/diff/line_mapper_spec.rb
index 2c7ecd1907e..42750bf9ea1 100644
--- a/spec/lib/gitlab/diff/line_mapper_spec.rb
+++ b/spec/lib/gitlab/diff/line_mapper_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::LineMapper, lib: true do
+describe Gitlab::Diff::LineMapper do
include RepoHelpers
let(:project) { create(:project, :repository) }
diff --git a/spec/lib/gitlab/diff/parallel_diff_spec.rb b/spec/lib/gitlab/diff/parallel_diff_spec.rb
index 0f779339c54..e9fc7be366a 100644
--- a/spec/lib/gitlab/diff/parallel_diff_spec.rb
+++ b/spec/lib/gitlab/diff/parallel_diff_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::ParallelDiff, lib: true do
+describe Gitlab::Diff::ParallelDiff do
include RepoHelpers
let(:project) { create(:project, :repository) }
diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb
index e76128ecd87..8af49ed50ff 100644
--- a/spec/lib/gitlab/diff/parser_spec.rb
+++ b/spec/lib/gitlab/diff/parser_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::Diff::Parser, lib: true do
+describe Gitlab::Diff::Parser do
include RepoHelpers
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:commit) { project.commit(sample_commit.id) }
let(:diff) { commit.raw_diffs.first }
- let(:parser) { Gitlab::Diff::Parser.new }
+ let(:parser) { described_class.new }
describe '#parse' do
let(:diff) do
diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index cdf0af6d7ef..d4a2a852c12 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::Position, lib: true do
+describe Gitlab::Diff::Position do
include RepoHelpers
let(:project) { create(:project, :repository) }
@@ -22,7 +22,7 @@ describe Gitlab::Diff::Position, lib: true do
it "returns the correct diff file" do
diff_file = subject.diff_file(project.repository)
- expect(diff_file.new_file).to be true
+ expect(diff_file.new_file?).to be true
expect(diff_file.new_path).to eq(subject.new_path)
expect(diff_file.diff_refs).to eq(subject.diff_refs)
end
@@ -314,7 +314,7 @@ describe Gitlab::Diff::Position, lib: true do
it "returns the correct diff file" do
diff_file = subject.diff_file(project.repository)
- expect(diff_file.deleted_file).to be true
+ expect(diff_file.deleted_file?).to be true
expect(diff_file.old_path).to eq(subject.old_path)
expect(diff_file.diff_refs).to eq(subject.diff_refs)
end
@@ -356,7 +356,7 @@ describe Gitlab::Diff::Position, lib: true do
it "returns the correct diff file" do
diff_file = subject.diff_file(project.repository)
- expect(diff_file.new_file).to be true
+ expect(diff_file.new_file?).to be true
expect(diff_file.new_path).to eq(subject.new_path)
expect(diff_file.diff_refs).to eq(subject.diff_refs)
end
@@ -381,6 +381,54 @@ describe Gitlab::Diff::Position, lib: true do
end
end
+ describe "position for a file in a straight comparison" do
+ let(:diff_refs) do
+ Gitlab::Diff::DiffRefs.new(
+ start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature
+ base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9',
+ head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master
+ )
+ end
+
+ subject do
+ described_class.new(
+ old_path: "files/ruby/feature.rb",
+ new_path: "files/ruby/feature.rb",
+ old_line: 3,
+ new_line: nil,
+ diff_refs: diff_refs
+ )
+ end
+
+ describe "#diff_file" do
+ it "returns the correct diff file" do
+ diff_file = subject.diff_file(project.repository)
+
+ expect(diff_file.deleted_file?).to be true
+ expect(diff_file.old_path).to eq(subject.old_path)
+ expect(diff_file.diff_refs).to eq(subject.diff_refs)
+ end
+ end
+
+ describe "#diff_line" do
+ it "returns the correct diff line" do
+ diff_line = subject.diff_line(project.repository)
+
+ expect(diff_line.removed?).to be true
+ expect(diff_line.old_line).to eq(subject.old_line)
+ expect(diff_line.text).to eq("- puts 'bar'")
+ end
+ end
+
+ describe "#line_code" do
+ it "returns the correct line code" do
+ line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+
+ expect(subject.line_code(project.repository)).to eq(line_code)
+ end
+ end
+ end
+
describe "#to_json" do
let(:hash) do
{
diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb
index a10a251dc4a..8beebc10040 100644
--- a/spec/lib/gitlab/diff/position_tracer_spec.rb
+++ b/spec/lib/gitlab/diff/position_tracer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Diff::PositionTracer, lib: true do
+describe Gitlab::Diff::PositionTracer do
# Douwe's diary New York City, 2016-06-28
# --------------------------------------------------------------------------
#
@@ -61,9 +61,10 @@ describe Gitlab::Diff::PositionTracer, lib: true do
let(:old_diff_refs) { raise NotImplementedError }
let(:new_diff_refs) { raise NotImplementedError }
+ let(:change_diff_refs) { raise NotImplementedError }
let(:old_position) { raise NotImplementedError }
- let(:position_tracer) { described_class.new(repository: project.repository, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs) }
+ let(:position_tracer) { described_class.new(project: project, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs) }
subject { position_tracer.trace(old_position) }
def diff_refs(base_commit, head_commit)
@@ -77,16 +78,40 @@ describe Gitlab::Diff::PositionTracer, lib: true do
Gitlab::Diff::Position.new(attrs)
end
- def expect_new_position(attrs, new_position = subject)
- if attrs.nil?
- expect(new_position).to be_nil
- else
- expect(new_position).not_to be_nil
+ def expect_new_position(attrs, result = subject)
+ aggregate_failures("expect new position #{attrs.inspect}") do
+ if attrs.nil?
+ expect(result[:outdated]).to be_truthy
+ else
+ expect(result[:outdated]).to be_falsey
- expect(new_position.diff_refs).to eq(new_diff_refs)
+ new_position = result[:position]
+ expect(new_position).not_to be_nil
- attrs.each do |attr, value|
- expect(new_position.send(attr)).to eq(value)
+ expect(new_position.diff_refs).to eq(new_diff_refs)
+
+ attrs.each do |attr, value|
+ expect(new_position.send(attr)).to eq(value)
+ end
+ end
+ end
+ end
+
+ def expect_change_position(attrs, result = subject)
+ aggregate_failures("expect change position #{attrs.inspect}") do
+ expect(result[:outdated]).to be_truthy
+
+ change_position = result[:position]
+ if attrs.nil? || attrs.empty?
+ expect(change_position).to be_nil
+ else
+ expect(change_position).not_to be_nil
+
+ expect(change_position.diff_refs).to eq(change_diff_refs)
+
+ attrs.each do |attr, value|
+ expect(change_position.send(attr)).to eq(value)
+ end
end
end
end
@@ -395,6 +420,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(initial_commit, update_line_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, update_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -407,14 +433,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 + BB
# 3 + C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) }
let(:new_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
+ let(:change_diff_refs) { diff_refs(update_line_commit, delete_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 3) }
# old diff:
@@ -426,8 +458,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 + A
# 2 + BB
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 3,
+ new_line: nil
+ )
end
end
end
@@ -512,6 +549,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, update_line_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, update_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -525,14 +563,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 + BB
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(move_line_commit, delete_line_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, delete_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 3) }
# old diff:
@@ -545,8 +589,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 2 A
# 3 - C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 3,
+ new_line: nil
+ )
end
end
end
@@ -558,6 +607,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when the file's content was unchanged between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, rename_file_commit) }
+ let(:change_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -569,8 +619,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 1 BB
# 2 2 A
- it "returns nil since the line doesn't exist in the new diffs anymore" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: nil,
+ new_line: 2
+ )
end
end
@@ -628,6 +683,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, update_line_again_commit) }
+ let(:change_diff_refs) { diff_refs(delete_line_commit, update_line_again_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -640,28 +696,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 - A
# 2 + AA
- it "returns nil" do
- expect(subject).to be_nil
- end
- end
-
- context "when that line was deleted between the old and the new diff" do
- let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
- let(:new_diff_refs) { diff_refs(delete_line_commit, delete_line_again_commit) }
- let(:old_position) { position(new_path: file_name, new_line: 1) }
-
- # old diff:
- # 1 + BB
- # 2 + A
- #
- # new diff:
- # file_name -> new_file_name
- # 1 - BB
- # 2 - A
- # 1 + AA
-
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: new_file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
end
@@ -673,6 +714,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when the file's content was unchanged between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) }
+ let(:change_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -683,8 +725,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 - BB
# 2 - A
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
@@ -692,6 +739,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was unchanged between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, delete_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -703,14 +751,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 - BB
# 2 - A
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was moved between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) }
let(:new_diff_refs) { diff_refs(move_line_commit, delete_file_commit) }
+ let(:change_diff_refs) { diff_refs(update_line_commit, delete_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -723,14 +777,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 - A
# 3 - C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was changed between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(update_line_commit, delete_file_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, delete_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -743,14 +803,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 - BB
# 3 - C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, delete_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 3) }
# old diff:
@@ -762,8 +828,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 - BB
# 2 - A
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 3,
+ new_line: nil
+ )
end
end
end
@@ -775,6 +846,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when the file's content was unchanged between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, create_second_file_commit) }
+ let(:change_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -787,8 +859,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 2 B
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: nil,
+ new_line: 2
+ )
end
end
@@ -796,6 +873,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was unchanged between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(update_line_commit, update_second_file_line_commit) }
+ let(:change_diff_refs) { diff_refs(initial_commit, update_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 1) }
# old diff:
@@ -808,14 +886,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 2 BB
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: nil,
+ new_line: 1
+ )
end
end
context "when that line was moved between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) }
let(:new_diff_refs) { diff_refs(move_line_commit, move_second_file_line_commit) }
+ let(:change_diff_refs) { diff_refs(initial_commit, move_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -828,14 +912,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 2 A
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: nil,
+ new_line: 1
+ )
end
end
context "when that line was changed between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) }
let(:new_diff_refs) { diff_refs(update_line_commit, update_second_file_line_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, update_second_file_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 2) }
# old diff:
@@ -848,14 +938,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 2 BB
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
context "when that line was deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(delete_line_commit, delete_second_file_line_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, delete_second_file_line_commit) }
let(:old_position) { position(new_path: file_name, new_line: 3) }
# old diff:
@@ -867,8 +963,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 1 BB
# 2 2 A
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 3,
+ new_line: nil
+ )
end
end
end
@@ -957,6 +1058,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed or deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(initial_commit, create_file_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, create_file_commit) }
let(:old_position) { position(old_path: file_name, new_path: file_name, new_line: 1) }
# old diff:
@@ -970,8 +1072,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 + B
# 3 + C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 1,
+ new_line: nil
+ )
end
end
end
@@ -980,6 +1087,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when the position pointed at a deleted line in the old diff" do
let(:old_diff_refs) { diff_refs(create_file_commit, update_line_commit) }
let(:new_diff_refs) { diff_refs(initial_commit, update_line_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, initial_commit) }
let(:old_position) { position(old_path: file_name, new_path: file_name, old_line: 2) }
# old diff:
@@ -993,8 +1101,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 + BB
# 3 + C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 2,
+ new_line: nil
+ )
end
end
@@ -1076,6 +1189,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed or deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(initial_commit, delete_line_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, delete_line_commit) }
let(:old_position) { position(old_path: file_name, new_path: file_name, old_line: 3, new_line: 3) }
# old diff:
@@ -1088,8 +1202,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 1 + A
# 2 + B
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 3,
+ new_line: nil
+ )
end
end
end
@@ -1182,6 +1301,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
context "when that line was changed or deleted between the old and the new diff" do
let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, update_line_commit) }
+ let(:change_diff_refs) { diff_refs(move_line_commit, update_line_commit) }
let(:old_position) { position(old_path: file_name, new_path: file_name, new_line: 1) }
# old diff:
@@ -1196,8 +1316,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do
# 2 + BB
# 3 3 C
- it "returns nil" do
- expect(subject).to be_nil
+ it "returns the position of the change" do
+ expect_change_position(
+ old_path: file_name,
+ new_path: file_name,
+ old_line: 1,
+ new_line: nil
+ )
end
end
end
@@ -1239,7 +1364,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
describe "typical use scenarios" do
let(:second_branch_name) { "#{branch_name}-2" }
- def expect_positions(old_attrs, new_attrs)
+ def expect_new_positions(old_attrs, new_attrs)
old_positions = old_attrs.map do |old_attrs|
position(old_attrs)
end
@@ -1248,8 +1373,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do
position_tracer.trace(old_position)
end
- new_positions.zip(new_attrs).each do |new_position, new_attrs|
- expect_new_position(new_attrs, new_position)
+ aggregate_failures do
+ new_positions.zip(new_attrs).each do |new_position, new_attrs|
+ if new_attrs&.delete(:change)
+ expect_change_position(new_attrs, new_position)
+ else
+ expect_new_position(new_attrs, new_position)
+ end
+ end
end
end
@@ -1330,6 +1461,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
describe "simple push of new commit" do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) }
+ let(:change_diff_refs) { diff_refs(update_file_commit, update_file_again_commit) }
# old diff:
# 1 1 A
@@ -1368,14 +1500,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do
{ old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 },
{ old_path: file_name, old_line: 2 },
{ old_path: file_name, new_path: file_name, old_line: 3, new_line: 3 },
- { old_path: file_name, old_line: 4, new_line: 4 },
- nil,
+ { new_path: file_name, new_line: 4, change: true },
+ { new_path: file_name, old_line: 3, change: true },
{ old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 },
- { old_path: file_name, old_line: 6 },
- { new_path: file_name, new_line: 7 },
+ { new_path: file_name, old_line: 5, change: true },
+ { new_path: file_name, new_line: 7 }
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
@@ -1402,6 +1534,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, second_create_file_commit) }
+ let(:change_diff_refs) { diff_refs(update_file_commit, second_create_file_commit) }
# old diff:
# 1 1 A
@@ -1440,20 +1573,21 @@ describe Gitlab::Diff::PositionTracer, lib: true do
{ old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 },
{ old_path: file_name, old_line: 2 },
{ old_path: file_name, new_path: file_name, old_line: 3, new_line: 3 },
- { old_path: file_name, old_line: 4, new_line: 4 },
- nil,
+ { new_path: file_name, new_line: 4, change: true },
+ { old_path: file_name, old_line: 3, change: true },
{ old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 },
- { old_path: file_name, old_line: 6 },
- { new_path: file_name, new_line: 7 },
+ { old_path: file_name, old_line: 5, change: true },
+ { new_path: file_name, new_line: 7 }
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
describe "force push to delete last commit" do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, update_file_commit) }
+ let(:change_diff_refs) { diff_refs(update_file_again_commit, update_file_commit) }
# old diff:
# 1 1 A
@@ -1492,16 +1626,16 @@ describe Gitlab::Diff::PositionTracer, lib: true do
new_position_attrs = [
{ old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 },
{ old_path: file_name, old_line: 2 },
- nil,
+ { old_path: file_name, old_line: 2, change: true },
{ old_path: file_name, new_path: file_name, old_line: 3, new_line: 2 },
- { old_path: file_name, old_line: 4 },
+ { old_path: file_name, old_line: 4, change: true },
{ old_path: file_name, new_path: file_name, old_line: 5, new_line: 4 },
- { old_path: file_name, new_path: file_name, old_line: 6, new_line: 5 },
- nil,
- { new_path: file_name, new_line: 6 },
+ { new_path: file_name, new_line: 5, change: true },
+ { old_path: file_name, old_line: 6, change: true },
+ { new_path: file_name, new_line: 6 }
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
@@ -1567,6 +1701,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, overwrite_update_file_again_commit) }
+ let(:change_diff_refs) { diff_refs(update_file_again_commit, overwrite_update_file_again_commit) }
# old diff:
# 1 1 A
@@ -1618,7 +1753,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
{ new_path: file_name, new_line: 10 }, # + G
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
@@ -1643,6 +1778,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) }
let(:new_diff_refs) { diff_refs(create_file_commit, merge_commit) }
+ let(:change_diff_refs) { diff_refs(update_file_again_commit, merge_commit) }
# old diff:
# 1 1 A
@@ -1694,13 +1830,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do
{ new_path: file_name, new_line: 10 }, # + G
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
describe "changing target branch" do
let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) }
let(:new_diff_refs) { diff_refs(update_file_commit, update_file_again_commit) }
+ let(:change_diff_refs) { diff_refs(create_file_commit, update_file_commit) }
# old diff:
# 1 1 A
@@ -1739,17 +1876,17 @@ describe Gitlab::Diff::PositionTracer, lib: true do
new_position_attrs = [
{ old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 },
- nil,
+ { old_path: file_name, old_line: 2, change: true },
{ new_path: file_name, new_line: 2 },
{ old_path: file_name, new_path: file_name, old_line: 2, new_line: 3 },
{ new_path: file_name, new_line: 4 },
{ old_path: file_name, new_path: file_name, old_line: 4, new_line: 5 },
{ old_path: file_name, old_line: 5 },
{ new_path: file_name, new_line: 6 },
- { new_path: file_name, new_line: 7 },
+ { new_path: file_name, new_line: 7 }
]
- expect_positions(old_position_attrs, new_position_attrs)
+ expect_new_positions(old_position_attrs, new_position_attrs)
end
end
end
diff --git a/spec/lib/gitlab/downtime_check_spec.rb b/spec/lib/gitlab/downtime_check_spec.rb
index 42d895e548e..1f1e4e0216c 100644
--- a/spec/lib/gitlab/downtime_check_spec.rb
+++ b/spec/lib/gitlab/downtime_check_spec.rb
@@ -11,12 +11,12 @@ describe Gitlab::DowntimeCheck do
context 'when a migration does not specify if downtime is required' do
it 'raises RuntimeError' do
- expect(subject).to receive(:class_for_migration_file).
- with(path).
- and_return(Class.new)
+ expect(subject).to receive(:class_for_migration_file)
+ .with(path)
+ .and_return(Class.new)
- expect { subject.check([path]) }.
- to raise_error(RuntimeError, /it requires downtime/)
+ expect { subject.check([path]) }
+ .to raise_error(RuntimeError, /it requires downtime/)
end
end
@@ -25,12 +25,12 @@ describe Gitlab::DowntimeCheck do
it 'raises RuntimeError' do
stub_const('TestMigration::DOWNTIME', true)
- expect(subject).to receive(:class_for_migration_file).
- with(path).
- and_return(TestMigration)
+ expect(subject).to receive(:class_for_migration_file)
+ .with(path)
+ .and_return(TestMigration)
- expect { subject.check([path]) }.
- to raise_error(RuntimeError, /no reason was given/)
+ expect { subject.check([path]) }
+ .to raise_error(RuntimeError, /no reason was given/)
end
end
@@ -39,9 +39,9 @@ describe Gitlab::DowntimeCheck do
stub_const('TestMigration::DOWNTIME', true)
stub_const('TestMigration::DOWNTIME_REASON', 'foo')
- expect(subject).to receive(:class_for_migration_file).
- with(path).
- and_return(TestMigration)
+ expect(subject).to receive(:class_for_migration_file)
+ .with(path)
+ .and_return(TestMigration)
messages = subject.check([path])
@@ -65,9 +65,9 @@ describe Gitlab::DowntimeCheck do
expect(subject).to receive(:require).with(path)
- expect(subject).to receive(:class_for_migration_file).
- with(path).
- and_return(TestMigration)
+ expect(subject).to receive(:class_for_migration_file)
+ .with(path)
+ .and_return(TestMigration)
expect(subject).to receive(:puts).with(an_instance_of(String))
diff --git a/spec/lib/gitlab/email/attachment_uploader_spec.rb b/spec/lib/gitlab/email/attachment_uploader_spec.rb
index 08b2577ecc4..f61dbc67ad1 100644
--- a/spec/lib/gitlab/email/attachment_uploader_spec.rb
+++ b/spec/lib/gitlab/email/attachment_uploader_spec.rb
@@ -1,6 +1,6 @@
require "spec_helper"
-describe Gitlab::Email::AttachmentUploader, lib: true do
+describe Gitlab::Email::AttachmentUploader do
describe "#execute" do
let(:project) { build(:project) }
let(:message_raw) { fixture_file("emails/attachment.eml") }
diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
index 4a9c9a7fe34..bd36d1d309d 100644
--- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../email_shared_blocks'
-describe Gitlab::Email::Handler::CreateIssueHandler, lib: true do
+describe Gitlab::Email::Handler::CreateIssueHandler do
include_context :email_shared_context
it_behaves_like :reply_processing_shared_examples
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 3f79eaf7afb..d0fa16ce4d1 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../email_shared_blocks'
-describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
+describe Gitlab::Email::Handler::CreateNoteHandler do
include_context :email_shared_context
it_behaves_like :reply_processing_shared_examples
@@ -36,15 +36,6 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
end
end
- context "when the email was auto generated" do
- let!(:mail_key) { '636ca428858779856c226bb145ef4fad' }
- let!(:email_raw) { fixture_file("emails/auto_reply.eml") }
-
- it "raises an AutoGeneratedEmailError" do
- expect { receiver.execute }.to raise_error(Gitlab::Email::AutoGeneratedEmailError)
- end
- end
-
context "when the noteable could not be found" do
before do
noteable.destroy
@@ -91,7 +82,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
end
end
- context 'when the note contains slash commands' do
+ context 'when the note contains quick actions' do
let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") }
context 'and current user cannot update noteable' do
diff --git a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb
index 0939e6c4514..21796694f26 100644
--- a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../email_shared_blocks'
-describe Gitlab::Email::Handler::UnsubscribeHandler, lib: true do
+describe Gitlab::Email::Handler::UnsubscribeHandler do
include_context :email_shared_context
before do
@@ -10,7 +10,7 @@ describe Gitlab::Email::Handler::UnsubscribeHandler, lib: true do
end
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}+unsubscribe") }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:noteable) { create(:issue, project: project) }
diff --git a/spec/lib/gitlab/email/message/repository_push_spec.rb b/spec/lib/gitlab/email/message/repository_push_spec.rb
index 7b3291b8315..83c4d177cae 100644
--- a/spec/lib/gitlab/email/message/repository_push_spec.rb
+++ b/spec/lib/gitlab/email/message/repository_push_spec.rb
@@ -117,7 +117,7 @@ describe Gitlab::Email::Message::RepositoryPush do
describe '#subject' do
subject { message.subject }
- it { is_expected.to include "[Git][#{project.path_with_namespace}]" }
+ it { is_expected.to include "[Git][#{project.full_path}]" }
it { is_expected.to include "#{compare.commits.length} commits" }
it { is_expected.to include compare.commits.first.message.split("\n").first }
end
diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb
index 2a86b427806..59f43abf26d 100644
--- a/spec/lib/gitlab/email/receiver_spec.rb
+++ b/spec/lib/gitlab/email/receiver_spec.rb
@@ -1,13 +1,31 @@
require 'spec_helper'
require_relative 'email_shared_blocks'
-describe Gitlab::Email::Receiver, lib: true do
+describe Gitlab::Email::Receiver do
include_context :email_shared_context
+ context "when the email contains a valid email address in a Delivered-To header" do
+ let(:email_raw) { fixture_file('emails/forwarded_new_issue.eml') }
+ let(:handler) { double(:handler) }
+
+ before do
+ stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo")
+
+ allow(handler).to receive(:execute)
+ allow(handler).to receive(:metrics_params)
+ end
+
+ it "finds the mail key" do
+ expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), 'gitlabhq/gitlabhq+auth_token').and_return(handler)
+
+ receiver.execute
+ end
+ end
+
context "when we cannot find a capable handler" do
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "!!!") }
- it "raises a UnknownIncomingEmail" do
+ it "raises an UnknownIncomingEmail error" do
expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail)
end
end
@@ -19,4 +37,12 @@ describe Gitlab::Email::Receiver, lib: true do
expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError)
end
end
+
+ context "when the email was auto generated" do
+ let(:email_raw) { fixture_file("emails/auto_reply.eml") }
+
+ it "raises an AutoGeneratedEmailError" do
+ expect { receiver.execute }.to raise_error(Gitlab::Email::AutoGeneratedEmailError)
+ end
+ end
end
diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb
index 28698e89c33..e21a998adfe 100644
--- a/spec/lib/gitlab/email/reply_parser_spec.rb
+++ b/spec/lib/gitlab/email/reply_parser_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
# Inspired in great part by Discourse's Email::Receiver
-describe Gitlab::Email::ReplyParser, lib: true do
+describe Gitlab::Email::ReplyParser do
describe '#execute' do
def test_parse_body(mail_string)
described_class.new(Mail::Message.new(mail_string)).execute
@@ -20,8 +20,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "properly renders plaintext-only email" do
- expect(test_parse_body(fixture_file("emails/plaintext_only.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/plaintext_only.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
### reply from default mail client in Windows 8.1 Metro
@@ -46,8 +46,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "handles multiple paragraphs" do
- expect(test_parse_body(fixture_file("emails/paragraphs.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/paragraphs.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
Is there any reason the *old* candy can't be be kept in silos while the new candy
is imported into *new* silos?
@@ -61,8 +61,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "handles multiple paragraphs when parsing html" do
- expect(test_parse_body(fixture_file("emails/html_paragraphs.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/html_paragraphs.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
Awesome!
@@ -74,8 +74,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "handles newlines" do
- expect(test_parse_body(fixture_file("emails/newlines.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/newlines.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
This is my reply.
It is my best reply.
@@ -85,8 +85,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "handles inline reply" do
- expect(test_parse_body(fixture_file("emails/inline_reply.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/inline_reply.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
> techAPJ <https://meta.discourse.org/users/techapj>
> November 28
@@ -132,8 +132,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "properly renders email reply from gmail web client" do
- expect(test_parse_body(fixture_file("emails/gmail_web.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/gmail_web.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
### This is a reply from standard GMail in Google Chrome.
@@ -151,8 +151,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "properly renders email reply from iOS default mail client" do
- expect(test_parse_body(fixture_file("emails/ios_default.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/ios_default.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
### this is a reply from iOS default mail
@@ -166,8 +166,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "properly renders email reply from Android 5 gmail client" do
- expect(test_parse_body(fixture_file("emails/android_gmail.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/android_gmail.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
### this is a reply from Android 5 gmail
@@ -184,8 +184,8 @@ describe Gitlab::Email::ReplyParser, lib: true do
end
it "properly renders email reply from Windows 8.1 Metro default mail client" do
- expect(test_parse_body(fixture_file("emails/windows_8_metro.eml"))).
- to eq(
+ expect(test_parse_body(fixture_file("emails/windows_8_metro.eml")))
+ .to eq(
<<-BODY.strip_heredoc.chomp
### reply from default mail client in Windows 8.1 Metro
@@ -208,5 +208,9 @@ describe Gitlab::Email::ReplyParser, lib: true do
it "properly renders html-only email from MS Outlook" do
expect(test_parse_body(fixture_file("emails/outlook_html.eml"))).to eq("Microsoft Outlook 2010")
end
+
+ it "does not wrap links with no href in unnecessary brackets" do
+ expect(test_parse_body(fixture_file("emails/html_empty_link.eml"))).to eq("no brackets!")
+ end
end
end
diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb
new file mode 100644
index 00000000000..8b14b227e65
--- /dev/null
+++ b/spec/lib/gitlab/encoding_helper_spec.rb
@@ -0,0 +1,135 @@
+require "spec_helper"
+
+describe Gitlab::EncodingHelper do
+ let(:ext_class) { Class.new { extend Gitlab::EncodingHelper } }
+ let(:binary_string) { File.read(Rails.root + "spec/fixtures/dk.png") }
+
+ describe '#encode!' do
+ [
+ [
+ 'leaves ascii only string as is',
+ 'ascii only string',
+ 'ascii only string'
+ ],
+ [
+ 'leaves valid utf8 string as is',
+ 'multibyte string №∑∉',
+ 'multibyte string №∑∉'
+ ],
+ [
+ 'removes invalid bytes from ASCII-8bit encoded multibyte string. This can occur when a git diff match line truncates in the middle of a multibyte character. This occurs after the second word in this example. The test string is as short as we can get while still triggering the error condition when not looking at `detect[:confidence]`.',
+ "mu ns\xC3\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ".force_encoding('ASCII-8BIT'),
+ "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi "
+ ]
+ ].each do |description, test_string, xpect|
+ it description do
+ expect(ext_class.encode!(test_string)).to eq(xpect)
+ end
+ end
+
+ it 'leaves binary string as is' do
+ expect(ext_class.encode!(binary_string)).to eq(binary_string)
+ end
+
+ context 'with corrupted diff' do
+ let(:corrupted_diff) do
+ with_empty_bare_repository do |repo|
+ content = File.read(Rails.root.join(
+ 'spec/fixtures/encoding/Japanese.md').to_s)
+ commit_a = commit(repo, 'Japanese.md', content)
+ commit_b = commit(repo, 'Japanese.md',
+ content.sub('[TODO: Link]', '[ç¾åœ¨ä½œæ¥­ä¸­ã§ã™: Link]'))
+
+ repo.diff(commit_a, commit_b).each_line.map(&:content).join
+ end
+ end
+
+ let(:cleaned_diff) do
+ corrupted_diff.dup.force_encoding('UTF-8')
+ .encode!('UTF-8', invalid: :replace, replace: '')
+ end
+
+ let(:encoded_diff) do
+ described_class.encode!(corrupted_diff.dup)
+ end
+
+ it 'does not corrupt data but remove invalid characters' do
+ expect(encoded_diff).to eq(cleaned_diff)
+ end
+
+ def commit(repo, path, content)
+ oid = repo.write(content, :blob)
+ index = repo.index
+
+ index.read_tree(repo.head.target.tree) unless repo.empty?
+
+ index.add(path: path, oid: oid, mode: 0100644)
+ user = { name: 'Test', email: 'test@example.com' }
+
+ Rugged::Commit.create(
+ repo,
+ tree: index.write_tree(repo),
+ author: user,
+ committer: user,
+ message: "Update #{path}",
+ parents: repo.empty? ? [] : [repo.head.target].compact,
+ update_ref: 'HEAD'
+ )
+ end
+ end
+ end
+
+ describe '#encode_utf8' do
+ [
+ [
+ "encodes valid utf8 encoded string to utf8",
+ "λ, λ, λ".encode("UTF-8"),
+ "λ, λ, λ".encode("UTF-8")
+ ],
+ [
+ "encodes valid ASCII-8BIT encoded string to utf8",
+ "ascii only".encode("ASCII-8BIT"),
+ "ascii only".encode("UTF-8")
+ ],
+ [
+ "encodes valid ISO-8859-1 encoded string to utf8",
+ "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("ISO-8859-1", "UTF-8"),
+ "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8")
+ ]
+ ].each do |description, test_string, xpect|
+ it description do
+ r = ext_class.encode_utf8(test_string.force_encoding('UTF-8'))
+ expect(r).to eq(xpect)
+ expect(r.encoding.name).to eq('UTF-8')
+ end
+ end
+
+ it 'returns empty string on conversion errors' do
+ expect { ext_class.encode_utf8('') }.not_to raise_error(ArgumentError)
+ end
+ end
+
+ describe '#clean' do
+ [
+ [
+ 'leaves ascii only string as is',
+ 'ascii only string',
+ 'ascii only string'
+ ],
+ [
+ 'leaves valid utf8 string as is',
+ 'multibyte string №∑∉',
+ 'multibyte string №∑∉'
+ ],
+ [
+ 'removes invalid bytes from ASCII-8bit encoded multibyte string.',
+ "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'),
+ "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg"
+ ]
+ ].each do |description, test_string, xpect|
+ it description do
+ expect(ext_class.encode!(test_string)).to eq(xpect)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb
index 24df04e985a..4a54d641b4e 100644
--- a/spec/lib/gitlab/etag_caching/middleware_spec.rb
+++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb
@@ -15,13 +15,13 @@ describe Gitlab::EtagCaching::Middleware do
end
it 'does not add ETag header' do
- _, headers, _ = middleware.call(build_env(path, if_none_match))
+ _, headers, _ = middleware.call(build_request(path, if_none_match))
expect(headers['ETag']).to be_nil
end
it 'passes status code from app' do
- status, _, _ = middleware.call(build_env(path, if_none_match))
+ status, _, _ = middleware.call(build_request(path, if_none_match))
expect(status).to eq app_status_code
end
@@ -39,7 +39,7 @@ describe Gitlab::EtagCaching::Middleware do
expect_any_instance_of(Gitlab::EtagCaching::Store)
.to receive(:touch).and_return('123')
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
context 'when If-None-Match header was specified' do
@@ -51,7 +51,7 @@ describe Gitlab::EtagCaching::Middleware do
expect(Gitlab::Metrics).to receive(:add_event)
.with(:etag_caching_key_not_found, endpoint: 'issue_notes')
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
end
end
@@ -65,7 +65,7 @@ describe Gitlab::EtagCaching::Middleware do
end
it 'returns this value as header' do
- _, headers, _ = middleware.call(build_env(path, if_none_match))
+ _, headers, _ = middleware.call(build_request(path, if_none_match))
expect(headers['ETag']).to eq 'W/"123"'
end
@@ -82,17 +82,17 @@ describe Gitlab::EtagCaching::Middleware do
it 'does not call app' do
expect(app).not_to receive(:call)
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
it 'returns status code 304' do
- status, _, _ = middleware.call(build_env(path, if_none_match))
+ status, _, _ = middleware.call(build_request(path, if_none_match))
expect(status).to eq 304
end
it 'returns empty body' do
- _, _, body = middleware.call(build_env(path, if_none_match))
+ _, _, body = middleware.call(build_request(path, if_none_match))
expect(body).to be_empty
end
@@ -103,17 +103,17 @@ describe Gitlab::EtagCaching::Middleware do
expect(Gitlab::Metrics).to receive(:add_event)
.with(:etag_caching_cache_hit, endpoint: 'issue_notes')
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
context 'when polling is disabled' do
before do
- allow(Gitlab::PollingInterval).to receive(:polling_enabled?).
- and_return(false)
+ allow(Gitlab::PollingInterval).to receive(:polling_enabled?)
+ .and_return(false)
end
it 'returns status code 429' do
- status, _, _ = middleware.call(build_env(path, if_none_match))
+ status, _, _ = middleware.call(build_request(path, if_none_match))
expect(status).to eq 429
end
@@ -131,7 +131,7 @@ describe Gitlab::EtagCaching::Middleware do
it 'calls app' do
expect(app).to receive(:call).and_return([app_status_code, {}, ['body']])
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
it 'tracks "etag_caching_resource_changed" event' do
@@ -142,7 +142,7 @@ describe Gitlab::EtagCaching::Middleware do
expect(Gitlab::Metrics).to receive(:add_event)
.with(:etag_caching_resource_changed, endpoint: 'issue_notes')
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
end
end
@@ -160,7 +160,26 @@ describe Gitlab::EtagCaching::Middleware do
expect(Gitlab::Metrics).to receive(:add_event)
.with(:etag_caching_header_missing, endpoint: 'issue_notes')
- middleware.call(build_env(path, if_none_match))
+ middleware.call(build_request(path, if_none_match))
+ end
+ end
+
+ context 'when GitLab instance is using a relative URL' do
+ before do
+ mock_app_response
+ end
+
+ it 'uses full path as cache key' do
+ env = {
+ 'PATH_INFO' => enabled_path,
+ 'SCRIPT_NAME' => '/relative-gitlab'
+ }
+
+ expect_any_instance_of(Gitlab::EtagCaching::Store)
+ .to receive(:get).with("/relative-gitlab#{enabled_path}")
+ .and_return(nil)
+
+ middleware.call(env)
end
end
@@ -173,10 +192,7 @@ describe Gitlab::EtagCaching::Middleware do
.to receive(:get).and_return(value)
end
- def build_env(path, if_none_match)
- {
- 'PATH_INFO' => path,
- 'HTTP_IF_NONE_MATCH' => if_none_match
- }
+ def build_request(path, if_none_match)
+ { 'PATH_INFO' => path, 'HTTP_IF_NONE_MATCH' => if_none_match }
end
end
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index f3dacb4ef04..f69cb502ca6 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -2,82 +2,91 @@ require 'spec_helper'
describe Gitlab::EtagCaching::Router do
it 'matches issue notes endpoint' do
- env = build_env(
+ result = described_class.match(
'/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'issue_notes'
end
it 'matches issue title endpoint' do
- env = build_env(
- '/my-group/my-project/issues/123/rendered_title'
+ result = described_class.match(
+ '/my-group/my-project/issues/123/realtime_changes'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'issue_title'
end
it 'matches project pipelines endpoint' do
- env = build_env(
+ result = described_class.match(
'/my-group/my-project/pipelines.json'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'project_pipelines'
end
it 'matches commit pipelines endpoint' do
- env = build_env(
+ result = described_class.match(
'/my-group/my-project/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'commit_pipelines'
end
it 'matches new merge request pipelines endpoint' do
- env = build_env(
+ result = described_class.match(
'/my-group/my-project/merge_requests/new.json'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'new_merge_request_pipelines'
end
it 'matches merge request pipelines endpoint' do
- env = build_env(
+ result = described_class.match(
'/my-group/my-project/merge_requests/234/pipelines.json'
)
- result = described_class.match(env)
-
expect(result).to be_present
expect(result.name).to eq 'merge_request_pipelines'
end
+ it 'matches build endpoint' do
+ result = described_class.match(
+ '/my-group/my-project/builds/234.json'
+ )
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_build'
+ end
+
it 'does not match blob with confusing name' do
- env = build_env(
+ result = described_class.match(
'/my-group/my-project/blob/master/pipelines.json'
)
- result = described_class.match(env)
-
expect(result).to be_blank
end
- def build_env(path)
- { 'PATH_INFO' => path }
+ it 'matches the environments path' do
+ result = described_class.match(
+ '/my-group/my-project/environments.json'
+ )
+
+ expect(result).to be_present
+ expect(result.name).to eq 'environments'
+ end
+
+ it 'matches pipeline#show endpoint' do
+ result = described_class.match(
+ '/my-group/my-project/pipelines/2.json'
+ )
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_pipeline'
end
end
diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb
index a366d68a146..c1ed47cf64a 100644
--- a/spec/lib/gitlab/exclusive_lease_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::ExclusiveLease, type: :redis do
+describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
let(:unique_key) { SecureRandom.hex(10) }
describe '#try_obtain' do
@@ -19,6 +19,19 @@ describe Gitlab::ExclusiveLease, type: :redis do
end
end
+ describe '#renew' do
+ it 'returns true when we have the existing lease' do
+ lease = described_class.new(unique_key, timeout: 3600)
+ expect(lease.try_obtain).to be_present
+ expect(lease.renew).to be_truthy
+ end
+
+ it 'returns false when we dont have a lease' do
+ lease = described_class.new(unique_key, timeout: 3600)
+ expect(lease.renew).to be_falsey
+ end
+ end
+
describe '#exists?' do
it 'returns true for an existing lease' do
lease = described_class.new(unique_key, timeout: 3600)
diff --git a/spec/lib/gitlab/fake_application_settings_spec.rb b/spec/lib/gitlab/fake_application_settings_spec.rb
new file mode 100644
index 00000000000..34322c2a693
--- /dev/null
+++ b/spec/lib/gitlab/fake_application_settings_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe Gitlab::FakeApplicationSettings do
+ let(:defaults) { { password_authentication_enabled: false, foobar: 'asdf', signup_enabled: true, 'test?' => 123 } }
+
+ subject { described_class.new(defaults) }
+
+ it 'wraps OpenStruct variables properly' do
+ expect(subject.password_authentication_enabled).to be_falsey
+ expect(subject.signup_enabled).to be_truthy
+ expect(subject.foobar).to eq('asdf')
+ end
+
+ it 'defines predicate methods' do
+ expect(subject.password_authentication_enabled?).to be_falsey
+ expect(subject.signup_enabled?).to be_truthy
+ end
+
+ it 'predicate method changes when value is updated' do
+ subject.password_authentication_enabled = true
+
+ expect(subject.password_authentication_enabled?).to be_truthy
+ end
+
+ it 'does not define a predicate method' do
+ expect(subject.foobar?).to be_nil
+ end
+
+ it 'does not override an existing predicate method' do
+ expect(subject.test?).to eq(123)
+ end
+end
diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb
index e5ba13bbaf8..695fd6f8573 100644
--- a/spec/lib/gitlab/file_detector_spec.rb
+++ b/spec/lib/gitlab/file_detector_spec.rb
@@ -3,13 +3,13 @@ require 'spec_helper'
describe Gitlab::FileDetector do
describe '.types_in_paths' do
it 'returns the file types for the given paths' do
- expect(described_class.types_in_paths(%w(README.md CHANGELOG VERSION VERSION))).
- to eq(%i{readme changelog version})
+ expect(described_class.types_in_paths(%w(README.md CHANGELOG VERSION VERSION)))
+ .to eq(%i{readme changelog version})
end
it 'does not include unrecognized file paths' do
- expect(described_class.types_in_paths(%w(README.md foo.txt))).
- to eq(%i{readme})
+ expect(described_class.types_in_paths(%w(README.md foo.txt)))
+ .to eq(%i{readme})
end
end
diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb
new file mode 100644
index 00000000000..3fb6315a39a
--- /dev/null
+++ b/spec/lib/gitlab/file_finder_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Gitlab::FileFinder do
+ describe '#find' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:finder) { described_class.new(project, project.default_branch) }
+
+ it 'finds by name' do
+ results = finder.find('files')
+ expect(results.map(&:first)).to include('files/images/wm.svg')
+ end
+
+ it 'finds by content' do
+ results = finder.find('files')
+
+ blob = results.select { |result| result.first == "CHANGELOG" }.flatten.last
+
+ expect(blob.filename).to eq("CHANGELOG")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/fogbugz_import/client_spec.rb b/spec/lib/gitlab/fogbugz_import/client_spec.rb
index 252cd4c55c7..dcd1a2d9813 100644
--- a/spec/lib/gitlab/fogbugz_import/client_spec.rb
+++ b/spec/lib/gitlab/fogbugz_import/client_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::FogbugzImport::Client, lib: true do
+describe Gitlab::FogbugzImport::Client do
let(:client) { described_class.new(uri: '', token: '') }
let(:one_user) { { 'people' => { 'person' => { "ixPerson" => "2", "sFullName" => "James" } } } }
let(:two_users) { { 'people' => { 'person' => [one_user, { "ixPerson" => "3" }] } } }
diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
index 5d416c9eec3..a3d323fe28a 100644
--- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
@@ -2,11 +2,13 @@ require 'spec_helper'
describe Gitlab::Gfm::ReferenceRewriter do
let(:text) { 'some text' }
- let(:old_project) { create(:empty_project, name: 'old-project') }
- let(:new_project) { create(:empty_project, name: 'new-project') }
+ let(:old_project) { create(:project, name: 'old-project') }
+ let(:new_project) { create(:project, name: 'new-project') }
let(:user) { create(:user) }
- before { old_project.team << [user, :reporter] }
+ before do
+ old_project.team << [user, :reporter]
+ end
describe '#rewrite' do
subject do
diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
index c3016f63ebf..39e3b875c49 100644
--- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
describe Gitlab::Gfm::UploadsRewriter do
let(:user) { create(:user) }
- let(:old_project) { create(:empty_project) }
- let(:new_project) { create(:empty_project) }
+ let(:old_project) { create(:project) }
+ let(:new_project) { create(:project) }
let(:rewriter) { described_class.new(text, old_project, user) }
context 'text contains links to uploads' do
@@ -39,8 +39,8 @@ describe Gitlab::Gfm::UploadsRewriter do
it 'copies files' do
expect(new_files).to all(exist)
expect(old_paths).not_to match_array new_paths
- expect(old_paths).to all(include(old_project.path_with_namespace))
- expect(new_paths).to all(include(new_project.path_with_namespace))
+ expect(old_paths).to all(include(old_project.full_path))
+ expect(new_paths).to all(include(new_project.full_path))
end
it 'does not remove old files' do
diff --git a/spec/lib/gitlab/git/attributes_spec.rb b/spec/lib/gitlab/git/attributes_spec.rb
index 1cfd8db09a5..b715fc3410a 100644
--- a/spec/lib/gitlab/git/attributes_spec.rb
+++ b/spec/lib/gitlab/git/attributes_spec.rb
@@ -14,13 +14,13 @@ describe Gitlab::Git::Attributes, seed_helper: true do
end
it 'returns a Hash containing multiple attributes' do
- expect(subject.attributes('test.sh')).
- to eq({ 'eol' => 'lf', 'gitlab-language' => 'shell' })
+ expect(subject.attributes('test.sh'))
+ .to eq({ 'eol' => 'lf', 'gitlab-language' => 'shell' })
end
it 'returns a Hash containing attributes for a file with multiple extensions' do
- expect(subject.attributes('test.haml.html')).
- to eq({ 'gitlab-language' => 'haml' })
+ expect(subject.attributes('test.haml.html'))
+ .to eq({ 'gitlab-language' => 'haml' })
end
it 'returns a Hash containing attributes for a file in a directory' do
@@ -28,8 +28,8 @@ describe Gitlab::Git::Attributes, seed_helper: true do
end
it 'returns a Hash containing attributes with query string parameters' do
- expect(subject.attributes('foo.cgi')).
- to eq({ 'key' => 'value?p1=v1&p2=v2' })
+ expect(subject.attributes('foo.cgi'))
+ .to eq({ 'key' => 'value?p1=v1&p2=v2' })
end
it 'returns a Hash containing the attributes for an absolute path' do
@@ -39,11 +39,11 @@ describe Gitlab::Git::Attributes, seed_helper: true do
it 'returns a Hash containing the attributes when a pattern is defined using an absolute path' do
# When a path is given without a leading slash it should still match
# patterns defined with a leading slash.
- expect(subject.attributes('foo.png')).
- to eq({ 'gitlab-language' => 'png' })
+ expect(subject.attributes('foo.png'))
+ .to eq({ 'gitlab-language' => 'png' })
- expect(subject.attributes('/foo.png')).
- to eq({ 'gitlab-language' => 'png' })
+ expect(subject.attributes('/foo.png'))
+ .to eq({ 'gitlab-language' => 'png' })
end
it 'returns an empty Hash for a defined path without attributes' do
@@ -74,8 +74,8 @@ describe Gitlab::Git::Attributes, seed_helper: true do
end
it 'parses an entry that uses a tab to separate the pattern and attributes' do
- expect(subject.patterns[File.join(path, '*.md')]).
- to eq({ 'gitlab-language' => 'markdown' })
+ expect(subject.patterns[File.join(path, '*.md')])
+ .to eq({ 'gitlab-language' => 'markdown' })
end
it 'stores patterns in reverse order' do
@@ -91,9 +91,9 @@ describe Gitlab::Git::Attributes, seed_helper: true do
end
it 'does not parse anything when the attributes file does not exist' do
- expect(File).to receive(:exist?).
- with(File.join(path, 'info/attributes')).
- and_return(false)
+ expect(File).to receive(:exist?)
+ .with(File.join(path, 'info/attributes'))
+ .and_return(false)
expect(subject.patterns).to eq({})
end
@@ -115,13 +115,13 @@ describe Gitlab::Git::Attributes, seed_helper: true do
it 'parses multiple attributes' do
input = 'boolean key=value -negated'
- expect(subject.parse_attributes(input)).
- to eq({ 'boolean' => true, 'key' => 'value', 'negated' => false })
+ expect(subject.parse_attributes(input))
+ .to eq({ 'boolean' => true, 'key' => 'value', 'negated' => false })
end
it 'parses attributes with query string parameters' do
- expect(subject.parse_attributes('foo=bar?baz=1')).
- to eq({ 'foo' => 'bar?baz=1' })
+ expect(subject.parse_attributes('foo=bar?baz=1'))
+ .to eq({ 'foo' => 'bar?baz=1' })
end
end
@@ -133,9 +133,9 @@ describe Gitlab::Git::Attributes, seed_helper: true do
end
it 'does not yield when the attributes file does not exist' do
- expect(File).to receive(:exist?).
- with(File.join(path, 'info/attributes')).
- and_return(false)
+ expect(File).to receive(:exist?)
+ .with(File.join(path, 'info/attributes'))
+ .and_return(false)
expect { |b| subject.each_line(&b) }.not_to yield_control
end
diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb
index 8b041ac69b1..800c245b130 100644
--- a/spec/lib/gitlab/git/blame_spec.rb
+++ b/spec/lib/gitlab/git/blame_spec.rb
@@ -7,60 +7,73 @@ describe Gitlab::Git::Blame, seed_helper: true do
Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md")
end
- context "each count" do
- it do
- data = []
- blame.each do |commit, line|
- data << {
- commit: commit,
- line: line
- }
- end
-
- expect(data.size).to eq(95)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq("# Contribute to GitLab")
- end
- end
+ shared_examples 'blaming a file' do
+ context "each count" do
+ it do
+ data = []
+ blame.each do |commit, line|
+ data << {
+ commit: commit,
+ line: line
+ }
+ end
- context "ISO-8859 encoding" do
- let(:blame) do
- Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
+ expect(data.size).to eq(95)
+ expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(data.first[:line]).to eq("# Contribute to GitLab")
+ expect(data.first[:line]).to be_utf8
+ end
end
- it 'converts to UTF-8' do
- data = []
- blame.each do |commit, line|
- data << {
- commit: commit,
- line: line
- }
+ context "ISO-8859 encoding" do
+ let(:blame) do
+ Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
end
- expect(data.size).to eq(1)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq("Ä ü")
- end
- end
+ it 'converts to UTF-8' do
+ data = []
+ blame.each do |commit, line|
+ data << {
+ commit: commit,
+ line: line
+ }
+ end
- context "unknown encoding" do
- let(:blame) do
- Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
+ expect(data.size).to eq(1)
+ expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(data.first[:line]).to eq("Ä ü")
+ expect(data.first[:line]).to be_utf8
+ end
end
- it 'converts to UTF-8' do
- expect(CharlockHolmes::EncodingDetector).to receive(:detect).and_return(nil)
- data = []
- blame.each do |commit, line|
- data << {
+ context "unknown encoding" do
+ let(:blame) do
+ Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
+ end
+
+ it 'converts to UTF-8' do
+ expect(CharlockHolmes::EncodingDetector).to receive(:detect).and_return(nil)
+ data = []
+ blame.each do |commit, line|
+ data << {
commit: commit,
line: line
- }
- end
+ }
+ end
- expect(data.size).to eq(1)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq(" ")
+ expect(data.size).to eq(1)
+ expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(data.first[:line]).to eq(" ")
+ expect(data.first[:line]).to be_utf8
+ end
end
end
+
+ context 'when Gitaly blame feature is enabled' do
+ it_behaves_like 'blaming a file'
+ end
+
+ context 'when Gitaly blame feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'blaming a file'
+ end
end
diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb
index e6a07a58d73..dfab0c2fe85 100644
--- a/spec/lib/gitlab/git/blob_spec.rb
+++ b/spec/lib/gitlab/git/blob_spec.rb
@@ -15,7 +15,7 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
end
- describe '.find' do
+ shared_examples 'finding blobs' do
context 'file in subdir' do
let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb") }
@@ -78,12 +78,18 @@ describe Gitlab::Git::Blob, seed_helper: true do
context 'large file' do
let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, 'files/images/6049019_460s.jpg') }
let(:blob_size) { 111803 }
+ let(:stub_limit) { 1000 }
+
+ before do
+ stub_const('Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE', stub_limit)
+ end
it { expect(blob.size).to eq(blob_size) }
- it { expect(blob.data.length).to eq(blob_size) }
+ it { expect(blob.data.length).to eq(stub_limit) }
it 'check that this test is sane' do
- expect(blob.size).to be <= Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE
+ # It only makes sense to test limiting if the blob is larger than the limit.
+ expect(blob.size).to be > Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE
end
it 'can load all data' do
@@ -92,16 +98,26 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
it 'marks the blob as binary' do
- expect(Gitlab::Git::Blob).to receive(:new).
- with(hash_including(binary: true)).
- and_call_original
+ expect(Gitlab::Git::Blob).to receive(:new)
+ .with(hash_including(binary: true))
+ .and_call_original
expect(blob).to be_binary
end
end
end
- describe '.raw' do
+ describe '.find' do
+ context 'when project_raw_show Gitaly feature is enabled' do
+ it_behaves_like 'finding blobs'
+ end
+
+ context 'when project_raw_show Gitaly feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'finding blobs'
+ end
+ end
+
+ shared_examples 'finding blobs by ID' do
let(:raw_blob) { Gitlab::Git::Blob.raw(repository, SeedRepo::RubyBlob::ID) }
it { expect(raw_blob.id).to eq(SeedRepo::RubyBlob::ID) }
it { expect(raw_blob.data[0..10]).to eq("require \'fi") }
@@ -126,6 +142,87 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
end
+ describe '.raw' do
+ context 'when the blob_raw Gitaly feature is enabled' do
+ it_behaves_like 'finding blobs by ID'
+ end
+
+ context 'when the blob_raw Gitaly feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'finding blobs by ID'
+ end
+ end
+
+ describe '.batch' do
+ let(:blob_references) do
+ [
+ [SeedRepo::Commit::ID, "files/ruby/popen.rb"],
+ [SeedRepo::Commit::ID, 'six']
+ ]
+ end
+
+ subject { described_class.batch(repository, blob_references) }
+
+ it { expect(subject.size).to eq(blob_references.size) }
+
+ context 'first blob' do
+ let(:blob) { subject[0] }
+
+ it { expect(blob.id).to eq(SeedRepo::RubyBlob::ID) }
+ it { expect(blob.name).to eq(SeedRepo::RubyBlob::NAME) }
+ it { expect(blob.path).to eq("files/ruby/popen.rb") }
+ it { expect(blob.commit_id).to eq(SeedRepo::Commit::ID) }
+ it { expect(blob.data[0..10]).to eq(SeedRepo::RubyBlob::CONTENT[0..10]) }
+ it { expect(blob.size).to eq(669) }
+ it { expect(blob.mode).to eq("100644") }
+ end
+
+ context 'second blob' do
+ let(:blob) { subject[1] }
+
+ it { expect(blob.id).to eq('409f37c4f05865e4fb208c771485f211a22c4c2d') }
+ it { expect(blob.data).to eq('') }
+ it 'does not mark the blob as binary' do
+ expect(blob).not_to be_binary
+ end
+ end
+
+ context 'limiting' do
+ subject { described_class.batch(repository, blob_references, blob_size_limit: blob_size_limit) }
+
+ context 'default' do
+ let(:blob_size_limit) { nil }
+
+ it 'limits to MAX_DATA_DISPLAY_SIZE' do
+ stub_const('Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE', 100)
+
+ expect(subject.first.data.size).to eq(100)
+ end
+ end
+
+ context 'positive' do
+ let(:blob_size_limit) { 10 }
+
+ it { expect(subject.first.data.size).to eq(10) }
+ end
+
+ context 'zero' do
+ let(:blob_size_limit) { 0 }
+
+ it { expect(subject.first.data).to eq('') }
+ end
+
+ context 'negative' do
+ let(:blob_size_limit) { -1 }
+
+ it 'ignores MAX_DATA_DISPLAY_SIZE' do
+ stub_const('Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE', 100)
+
+ expect(subject.first.data.size).to eq(669)
+ end
+ end
+ end
+ end
+
describe 'encoding' do
context 'file with russian text' do
let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/russian.rb") }
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index 3e44c577643..ac33cd8a2c9 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -2,7 +2,7 @@ require "spec_helper"
describe Gitlab::Git::Commit, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
- let(:commit) { Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID) }
+ let(:commit) { described_class.find(repository, SeedRepo::Commit::ID) }
let(:rugged_commit) do
repository.rugged.lookup(SeedRepo::Commit::ID)
end
@@ -24,7 +24,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
}
@parents = [repo.head.target]
- @gitlab_parents = @parents.map { |c| Gitlab::Git::Commit.decorate(c) }
+ @gitlab_parents = @parents.map { |c| described_class.decorate(repository, c) }
@tree = @parents.first.tree
sha = Rugged::Commit.create(
@@ -38,7 +38,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
)
@raw_commit = repo.lookup(sha)
- @commit = Gitlab::Git::Commit.new(@raw_commit)
+ @commit = described_class.new(repository, @raw_commit)
end
it { expect(@commit.short_id).to eq(@raw_commit.oid[0..10]) }
@@ -64,48 +64,97 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
end
+ describe "Commit info from gitaly commit" do
+ let(:id) { 'f00' }
+ let(:parent_ids) { %w(b45 b46) }
+ let(:subject) { "My commit".force_encoding('ASCII-8BIT') }
+ let(:body) { subject + "My body".force_encoding('ASCII-8BIT') }
+ let(:committer) do
+ Gitaly::CommitAuthor.new(
+ name: generate(:name),
+ email: generate(:email),
+ date: Google::Protobuf::Timestamp.new(seconds: 123)
+ )
+ end
+ let(:author) do
+ Gitaly::CommitAuthor.new(
+ name: generate(:name),
+ email: generate(:email),
+ date: Google::Protobuf::Timestamp.new(seconds: 456)
+ )
+ end
+ let(:gitaly_commit) do
+ Gitaly::GitCommit.new(
+ id: id,
+ subject: subject,
+ body: body,
+ author: author,
+ committer: committer,
+ parent_ids: parent_ids
+ )
+ end
+ let(:commit) { described_class.new(repository, gitaly_commit) }
+
+ it { expect(commit.short_id).to eq(id[0..10]) }
+ it { expect(commit.id).to eq(id) }
+ it { expect(commit.sha).to eq(id) }
+ it { expect(commit.safe_message).to eq(body) }
+ it { expect(commit.created_at).to eq(Time.at(committer.date.seconds)) }
+ it { expect(commit.author_email).to eq(author.email) }
+ it { expect(commit.author_name).to eq(author.name) }
+ it { expect(commit.committer_name).to eq(committer.name) }
+ it { expect(commit.committer_email).to eq(committer.email) }
+ it { expect(commit.parent_ids).to eq(parent_ids) }
+
+ context 'no body' do
+ let(:body) { "".force_encoding('ASCII-8BIT') }
+
+ it { expect(commit.safe_message).to eq(subject) }
+ end
+ end
+
context 'Class methods' do
describe '.find' do
it "should return first head commit if without params" do
- expect(Gitlab::Git::Commit.last(repository).id).to eq(
- repository.raw.head.target.oid
+ expect(described_class.last(repository).id).to eq(
+ repository.rugged.head.target.oid
)
end
it "should return valid commit" do
- expect(Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID)).to be_valid_commit
+ expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_valid_commit
end
it "should return valid commit for tag" do
- expect(Gitlab::Git::Commit.find(repository, 'v1.0.0').id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
+ expect(described_class.find(repository, 'v1.0.0').id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
end
it "should return nil for non-commit ids" do
blob = Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb")
- expect(Gitlab::Git::Commit.find(repository, blob.id)).to be_nil
+ expect(described_class.find(repository, blob.id)).to be_nil
end
it "should return nil for parent of non-commit object" do
blob = Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb")
- expect(Gitlab::Git::Commit.find(repository, "#{blob.id}^")).to be_nil
+ expect(described_class.find(repository, "#{blob.id}^")).to be_nil
end
it "should return nil for nonexisting ids" do
- expect(Gitlab::Git::Commit.find(repository, "+123_4532530XYZ")).to be_nil
+ expect(described_class.find(repository, "+123_4532530XYZ")).to be_nil
end
context 'with broken repo' do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH) }
it 'returns nil' do
- expect(Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID)).to be_nil
+ expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_nil
end
end
end
describe '.last_for_path' do
context 'no path' do
- subject { Gitlab::Git::Commit.last_for_path(repository, 'master') }
+ subject { described_class.last_for_path(repository, 'master') }
describe '#id' do
subject { super().id }
@@ -114,7 +163,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
context 'path' do
- subject { Gitlab::Git::Commit.last_for_path(repository, 'master', 'files/ruby') }
+ subject { described_class.last_for_path(repository, 'master', 'files/ruby') }
describe '#id' do
subject { super().id }
@@ -123,7 +172,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
context 'ref + path' do
- subject { Gitlab::Git::Commit.last_for_path(repository, SeedRepo::Commit::ID, 'encoding') }
+ subject { described_class.last_for_path(repository, SeedRepo::Commit::ID, 'encoding') }
describe '#id' do
subject { super().id }
@@ -135,7 +184,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
describe '.where' do
context 'path is empty string' do
subject do
- commits = Gitlab::Git::Commit.where(
+ commits = described_class.where(
repo: repository,
ref: 'master',
path: '',
@@ -153,7 +202,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
context 'path is nil' do
subject do
- commits = Gitlab::Git::Commit.where(
+ commits = described_class.where(
repo: repository,
ref: 'master',
path: nil,
@@ -171,7 +220,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
context 'ref is branch name' do
subject do
- commits = Gitlab::Git::Commit.where(
+ commits = described_class.where(
repo: repository,
ref: 'master',
path: 'files',
@@ -191,7 +240,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
context 'ref is commit id' do
subject do
- commits = Gitlab::Git::Commit.where(
+ commits = described_class.where(
repo: repository,
ref: "874797c3a73b60d2187ed6e2fcabd289ff75171e",
path: 'files',
@@ -211,7 +260,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
context 'ref is tag' do
subject do
- commits = Gitlab::Git::Commit.where(
+ commits = described_class.where(
repo: repository,
ref: 'v1.0.0',
path: 'files',
@@ -232,7 +281,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
describe '.between' do
subject do
- commits = Gitlab::Git::Commit.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID)
+ commits = described_class.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID)
commits.map { |c| c.id }
end
@@ -244,68 +293,91 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
describe '.find_all' do
- context 'max_count' do
- subject do
- commits = Gitlab::Git::Commit.find_all(
- repository,
- max_count: 50
- )
+ shared_examples 'finding all commits' do
+ it 'should return a return a collection of commits' do
+ commits = described_class.find_all(repository)
- commits.map { |c| c.id }
+ expect(commits).to all( be_a_kind_of(described_class) )
end
- it 'has 31 elements' do
- expect(subject.size).to eq(33)
+ context 'max_count' do
+ subject do
+ commits = described_class.find_all(
+ repository,
+ max_count: 50
+ )
+
+ commits.map(&:id)
+ end
+
+ it 'has 34 elements' do
+ expect(subject.size).to eq(34)
+ end
+
+ it 'includes the expected commits' do
+ expect(subject).to include(
+ SeedRepo::Commit::ID,
+ SeedRepo::Commit::PARENT_ID,
+ SeedRepo::FirstCommit::ID
+ )
+ end
end
- it { is_expected.to include(SeedRepo::Commit::ID) }
- it { is_expected.to include(SeedRepo::Commit::PARENT_ID) }
- it { is_expected.to include(SeedRepo::FirstCommit::ID) }
- end
-
- context 'ref + max_count + skip' do
- subject do
- commits = Gitlab::Git::Commit.find_all(
- repository,
- ref: 'master',
- max_count: 50,
- skip: 1
- )
- commits.map { |c| c.id }
+ context 'ref + max_count + skip' do
+ subject do
+ commits = described_class.find_all(
+ repository,
+ ref: 'master',
+ max_count: 50,
+ skip: 1
+ )
+
+ commits.map(&:id)
+ end
+
+ it 'has 24 elements' do
+ expect(subject.size).to eq(24)
+ end
+
+ it 'includes the expected commits' do
+ expect(subject).to include(SeedRepo::Commit::ID, SeedRepo::FirstCommit::ID)
+ expect(subject).not_to include(SeedRepo::LastCommit::ID)
+ end
end
+ end
- it 'has 23 elements' do
- expect(subject.size).to eq(24)
- end
- it { is_expected.to include(SeedRepo::Commit::ID) }
- it { is_expected.to include(SeedRepo::FirstCommit::ID) }
- it { is_expected.not_to include(SeedRepo::LastCommit::ID) }
+ context 'when Gitaly find_all_commits feature is enabled' do
+ it_behaves_like 'finding all commits'
end
- context 'contains feature + max_count' do
- subject do
- commits = Gitlab::Git::Commit.find_all(
- repository,
- contains: 'feature',
- max_count: 7
- )
+ context 'when Gitaly find_all_commits feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'finding all commits'
- commits.map { |c| c.id }
- end
+ context 'while applying a sort order based on the `order` option' do
+ it "allows ordering topologically (no parents shown before their children)" do
+ expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_TOPO)
- it 'has 7 elements' do
- expect(subject.size).to eq(7)
- end
+ described_class.find_all(repository, order: :topo)
+ end
- it { is_expected.not_to include(SeedRepo::Commit::PARENT_ID) }
- it { is_expected.not_to include(SeedRepo::Commit::ID) }
- it { is_expected.to include(SeedRepo::BigCommit::ID) }
+ it "allows ordering by date" do
+ expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO)
+
+ described_class.find_all(repository, order: :date)
+ end
+
+ it "applies no sorting by default" do
+ expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_NONE)
+
+ described_class.find_all(repository)
+ end
+ end
end
end
end
describe '#init_from_rugged' do
- let(:gitlab_commit) { Gitlab::Git::Commit.new(rugged_commit) }
+ let(:gitlab_commit) { described_class.new(repository, rugged_commit) }
subject { gitlab_commit }
describe '#id' do
@@ -315,7 +387,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
describe '#init_from_hash' do
- let(:commit) { Gitlab::Git::Commit.new(sample_commit_hash) }
+ let(:commit) { described_class.new(repository, sample_commit_hash) }
subject { commit }
describe '#id' do
@@ -382,7 +454,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
describe '#ref_names' do
- let(:commit) { Gitlab::Git::Commit.find(repository, 'master') }
+ let(:commit) { described_class.find(repository, 'master') }
subject { commit.ref_names(repository) }
it 'has 1 element' do
diff --git a/spec/lib/gitlab/git/compare_spec.rb b/spec/lib/gitlab/git/compare_spec.rb
index 7c45071ec45..4c9f4a28f32 100644
--- a/spec/lib/gitlab/git/compare_spec.rb
+++ b/spec/lib/gitlab/git/compare_spec.rb
@@ -2,8 +2,8 @@ require "spec_helper"
describe Gitlab::Git::Compare, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
- let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, false) }
- let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, true) }
+ let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: false) }
+ let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: true) }
describe '#commits' do
subject do
diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb
index 122c93dcd69..3494f0cc98d 100644
--- a/spec/lib/gitlab/git/diff_collection_spec.rb
+++ b/spec/lib/gitlab/git/diff_collection_spec.rb
@@ -6,18 +6,18 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
iterator,
max_files: max_files,
max_lines: max_lines,
- all_diffs: all_diffs,
- no_collapse: no_collapse
+ limits: limits,
+ expanded: expanded
)
end
- let(:iterator) { Array.new(file_count, fake_diff(line_length, line_count)) }
+ let(:iterator) { MutatingConstantIterator.new(file_count, fake_diff(line_length, line_count)) }
let(:file_count) { 0 }
let(:line_length) { 1 }
let(:line_count) { 1 }
let(:max_files) { 10 }
let(:max_lines) { 100 }
- let(:all_diffs) { false }
- let(:no_collapse) { true }
+ let(:limits) { true }
+ let(:expanded) { true }
describe '#to_a' do
subject { super().to_a }
@@ -64,10 +64,18 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
subject { super().real_size }
it { is_expected.to eq('3') }
end
- it { expect(subject.size).to eq(3) }
+
+ describe '#size' do
+ it { expect(subject.size).to eq(3) }
+
+ it 'does not change after peeking' do
+ subject.any?
+ expect(subject.size).to eq(3)
+ end
+ end
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
describe '#overflow?' do
subject { super().overflow? }
@@ -83,7 +91,15 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
subject { super().real_size }
it { is_expected.to eq('3') }
end
- it { expect(subject.size).to eq(3) }
+
+ describe '#size' do
+ it { expect(subject.size).to eq(3) }
+
+ it 'does not change after peeking' do
+ subject.any?
+ expect(subject.size).to eq(3)
+ end
+ end
end
end
@@ -107,7 +123,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
it { expect(subject.size).to eq(0) }
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
describe '#overflow?' do
subject { super().overflow? }
@@ -151,7 +167,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
it { expect(subject.size).to eq(10) }
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
describe '#overflow?' do
subject { super().overflow? }
@@ -191,7 +207,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
it { expect(subject.size).to eq(3) }
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
describe '#overflow?' do
subject { super().overflow? }
@@ -257,7 +273,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
it { expect(subject.size).to eq(9) }
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
describe '#overflow?' do
subject { super().overflow? }
@@ -309,8 +325,8 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
it 'yields Diff instances even when they are too large' do
- expect { |b| collection.each(&b) }.
- to yield_with_args(an_instance_of(Gitlab::Git::Diff))
+ expect { |b| collection.each(&b) }
+ .to yield_with_args(an_instance_of(Gitlab::Git::Diff))
end
it 'prunes diffs that are too large' do
@@ -325,14 +341,15 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
context 'when diff is quite large will collapse by default' do
- let(:iterator) { [{ diff: 'a' * 20480 }] }
+ let(:iterator) { [{ diff: 'a' * (Gitlab::Git::Diff.collapse_limit + 1) }] }
+ let(:max_files) { 100 }
context 'when no collapse is set' do
- let(:no_collapse) { true }
+ let(:expanded) { true }
it 'yields Diff instances even when they are quite big' do
- expect { |b| subject.each(&b) }.
- to yield_with_args(an_instance_of(Gitlab::Git::Diff))
+ expect { |b| subject.each(&b) }
+ .to yield_with_args(an_instance_of(Gitlab::Git::Diff))
end
it 'does not prune diffs' do
@@ -347,11 +364,11 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
context 'when no collapse is unset' do
- let(:no_collapse) { false }
+ let(:expanded) { false }
it 'yields Diff instances even when they are quite big' do
- expect { |b| subject.each(&b) }.
- to yield_with_args(an_instance_of(Gitlab::Git::Diff))
+ expect { |b| subject.each(&b) }
+ .to yield_with_args(an_instance_of(Gitlab::Git::Diff))
end
it 'prunes diffs that are quite big' do
@@ -367,7 +384,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
context 'when go over safe limits on files' do
let(:iterator) { [fake_diff(1, 1)] * 4 }
- before(:each) do
+ before do
stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: 2, max_lines: max_lines })
end
@@ -392,7 +409,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
]
end
- before(:each) do
+ before do
stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: max_files, max_lines: 80 })
end
@@ -417,7 +434,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
]
end
- before(:each) do
+ before do
stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: max_files, max_lines: 80 })
end
@@ -434,11 +451,11 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
context 'when limiting is disabled' do
- let(:all_diffs) { true }
+ let(:limits) { false }
it 'yields Diff instances even when they are quite big' do
- expect { |b| subject.each(&b) }.
- to yield_with_args(an_instance_of(Gitlab::Git::Diff))
+ expect { |b| subject.each(&b) }
+ .to yield_with_args(an_instance_of(Gitlab::Git::Diff))
end
it 'does not prune diffs' do
@@ -457,4 +474,24 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
def fake_diff(line_length, line_count)
{ 'diff' => "#{'a' * line_length}\n" * line_count }
end
+
+ class MutatingConstantIterator
+ include Enumerable
+
+ def initialize(count, value)
+ @count = count
+ @value = value
+ end
+
+ def each
+ return enum_for(:each) unless block_given?
+
+ loop do
+ break if @count.zero?
+ # It is critical to decrement before yielding. We may never reach the lines after 'yield'.
+ @count -= 1
+ yield @value
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 7253a2edeff..7ea3386ac2a 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -31,6 +31,36 @@ EOT
[".gitmodules"]).patches.first
end
+ describe 'size limit feature toggles' do
+ context 'when the feature gitlab_git_diff_size_limit_increase is enabled' do
+ before do
+ stub_feature_flags(gitlab_git_diff_size_limit_increase: true)
+ end
+
+ it 'returns 200 KB for size_limit' do
+ expect(described_class.size_limit).to eq(200.kilobytes)
+ end
+
+ it 'returns 100 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(100.kilobytes)
+ end
+ end
+
+ context 'when the feature gitlab_git_diff_size_limit_increase is disabled' do
+ before do
+ stub_feature_flags(gitlab_git_diff_size_limit_increase: false)
+ end
+
+ it 'returns 100 KB for size_limit' do
+ expect(described_class.size_limit).to eq(100.kilobytes)
+ end
+
+ it 'returns 10 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(10.kilobytes)
+ end
+ end
+ end
+
describe '.new' do
context 'using a Hash' do
context 'with a small diff' do
@@ -47,7 +77,7 @@ EOT
context 'using a diff that is too large' do
it 'prunes the diff' do
- diff = described_class.new(diff: 'a' * 204800)
+ diff = described_class.new(diff: 'a' * (described_class.size_limit + 1))
expect(diff.diff).to be_empty
expect(diff).to be_too_large
@@ -60,7 +90,7 @@ EOT
let(:diff) { described_class.new(@rugged_diff) }
it 'initializes the diff' do
- expect(diff.to_hash).to eq(@raw_diff_hash.merge(too_large: nil))
+ expect(diff.to_hash).to eq(@raw_diff_hash)
end
it 'does not prune the diff' do
@@ -70,8 +100,8 @@ EOT
context 'using a diff that is too large' do
it 'prunes the diff' do
- expect_any_instance_of(String).to receive(:bytesize).
- and_return(1024 * 1024 * 1024)
+ expect_any_instance_of(String).to receive(:bytesize)
+ .and_return(1024 * 1024 * 1024)
diff = described_class.new(@rugged_diff)
@@ -85,12 +115,12 @@ EOT
# The patch total size is 200, with lines between 21 and 54.
# This is a quick-and-dirty way to test this. Ideally, a new patch is
# added to the test repo with a size that falls between the real limits.
- stub_const("#{described_class}::DIFF_SIZE_LIMIT", 150)
- stub_const("#{described_class}::DIFF_COLLAPSE_LIMIT", 100)
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(150)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(100)
end
it 'prunes the diff as a large diff instead of as a collapsed diff' do
- diff = described_class.new(@rugged_diff, collapse: true)
+ diff = described_class.new(@rugged_diff, expanded: false)
expect(diff.diff).to be_empty
expect(diff).to be_too_large
@@ -100,8 +130,8 @@ EOT
context 'using a large binary diff' do
it 'does not prune the diff' do
- expect_any_instance_of(Rugged::Diff::Delta).to receive(:binary?).
- and_return(true)
+ expect_any_instance_of(Rugged::Diff::Delta).to receive(:binary?)
+ .and_return(true)
diff = described_class.new(@rugged_diff)
@@ -110,23 +140,23 @@ EOT
end
end
- context 'using a Gitaly::CommitDiffResponse' do
+ context 'using a GitalyClient::Diff' do
let(:diff) do
described_class.new(
- Gitaly::CommitDiffResponse.new(
+ Gitlab::GitalyClient::Diff.new(
to_path: ".gitmodules",
from_path: ".gitmodules",
old_mode: 0100644,
new_mode: 0100644,
from_id: '357406f3075a57708d0163752905cc1576fceacc',
to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
- raw_chunks: raw_chunks,
+ patch: raw_patch
)
)
end
context 'with a small diff' do
- let(:raw_chunks) { [@raw_diff_hash[:diff]] }
+ let(:raw_patch) { @raw_diff_hash[:diff] }
it 'initializes the diff' do
expect(diff.to_hash).to eq(@raw_diff_hash)
@@ -138,13 +168,21 @@ EOT
end
context 'using a diff that is too large' do
- let(:raw_chunks) { ['a' * 204800] }
+ let(:raw_patch) { 'a' * 204800 }
it 'prunes the diff' do
expect(diff.diff).to be_empty
expect(diff).to be_too_large
end
end
+
+ context 'when the patch passed is not UTF-8-encoded' do
+ let(:raw_patch) { @raw_diff_hash[:diff].encode(Encoding::ASCII_8BIT) }
+
+ it 'encodes diff patch to UTF-8' do
+ expect(diff.diff).to be_utf8
+ end
+ end
end
end
@@ -203,7 +241,7 @@ EOT
end
describe '.filter_diff_options' do
- let(:options) { { max_size: 100, invalid_opt: true } }
+ let(:options) { { max_files: 100, invalid_opt: true } }
context "without default options" do
let(:filtered_options) { described_class.filter_diff_options(options) }
@@ -215,7 +253,7 @@ EOT
context "with default options" do
let(:filtered_options) do
- default_options = { max_size: 5, bad_opt: 1, ignore_whitespace: true }
+ default_options = { max_files: 5, bad_opt: 1, ignore_whitespace_change: true }
described_class.filter_diff_options(options, default_options)
end
@@ -225,12 +263,12 @@ EOT
end
it "should merge with default options" do
- expect(filtered_options).to have_key(:ignore_whitespace)
+ expect(filtered_options).to have_key(:ignore_whitespace_change)
end
it "should override default options" do
- expect(filtered_options).to have_key(:max_size)
- expect(filtered_options[:max_size]).to eq(100)
+ expect(filtered_options).to have_key(:max_files)
+ expect(filtered_options[:max_files]).to eq(100)
end
end
end
@@ -269,7 +307,7 @@ EOT
it 'returns true for a diff that was explicitly marked as being too large' do
diff = described_class.new(diff: 'a')
- diff.prune_large_diff!
+ diff.too_large!
expect(diff.too_large?).to eq(true)
end
@@ -291,31 +329,31 @@ EOT
it 'returns true for a diff that was explicitly marked as being collapsed' do
diff = described_class.new(diff: 'a')
- diff.prune_collapsed_diff!
+ diff.collapse!
expect(diff).to be_collapsed
end
end
- describe '#collapsible?' do
+ describe '#collapsed?' do
it 'returns true for a diff that is quite large' do
- diff = described_class.new(diff: 'a' * 20480)
+ diff = described_class.new({ diff: 'a' * (described_class.collapse_limit + 1) }, expanded: false)
- expect(diff).to be_collapsible
+ expect(diff).to be_collapsed
end
it 'returns false for a diff that is small enough' do
- diff = described_class.new(diff: 'a')
+ diff = described_class.new({ diff: 'a' }, expanded: false)
- expect(diff).not_to be_collapsible
+ expect(diff).not_to be_collapsed
end
end
- describe '#prune_collapsed_diff!' do
+ describe '#collapse!' do
it 'prunes the diff' do
diff = described_class.new(diff: "foo\nbar")
- diff.prune_collapsed_diff!
+ diff.collapse!
expect(diff.diff).to eq('')
expect(diff.line_count).to eq(0)
diff --git a/spec/lib/gitlab/git/encoding_helper_spec.rb b/spec/lib/gitlab/git/encoding_helper_spec.rb
deleted file mode 100644
index f6ac7b23d1d..00000000000
--- a/spec/lib/gitlab/git/encoding_helper_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-require "spec_helper"
-
-describe Gitlab::Git::EncodingHelper do
- let(:ext_class) { Class.new { extend Gitlab::Git::EncodingHelper } }
- let(:binary_string) { File.join(SEED_STORAGE_PATH, 'gitlab_logo.png') }
-
- describe '#encode!' do
- [
- [
- 'leaves ascii only string as is',
- 'ascii only string',
- 'ascii only string'
- ],
- [
- 'leaves valid utf8 string as is',
- 'multibyte string №∑∉',
- 'multibyte string №∑∉'
- ],
- [
- 'removes invalid bytes from ASCII-8bit encoded multibyte string. This can occur when a git diff match line truncates in the middle of a multibyte character. This occurs after the second word in this example. The test string is as short as we can get while still triggering the error condition when not looking at `detect[:confidence]`.',
- "mu ns\xC3\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ".force_encoding('ASCII-8BIT'),
- "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ",
- ],
- ].each do |description, test_string, xpect|
- it description do
- expect(ext_class.encode!(test_string)).to eq(xpect)
- end
- end
-
- it 'leaves binary string as is' do
- expect(ext_class.encode!(binary_string)).to eq(binary_string)
- end
- end
-
- describe '#encode_utf8' do
- [
- [
- "encodes valid utf8 encoded string to utf8",
- "λ, λ, λ".encode("UTF-8"),
- "λ, λ, λ".encode("UTF-8"),
- ],
- [
- "encodes valid ASCII-8BIT encoded string to utf8",
- "ascii only".encode("ASCII-8BIT"),
- "ascii only".encode("UTF-8"),
- ],
- [
- "encodes valid ISO-8859-1 encoded string to utf8",
- "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("ISO-8859-1", "UTF-8"),
- "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8"),
- ],
- ].each do |description, test_string, xpect|
- it description do
- r = ext_class.encode_utf8(test_string.force_encoding('UTF-8'))
- expect(r).to eq(xpect)
- expect(r.encoding.name).to eq('UTF-8')
- end
- end
-
- it 'returns empty string on conversion errors' do
- expect { ext_class.encode_utf8('') }.not_to raise_error(ArgumentError)
- end
- end
-
- describe '#clean' do
- [
- [
- 'leaves ascii only string as is',
- 'ascii only string',
- 'ascii only string'
- ],
- [
- 'leaves valid utf8 string as is',
- 'multibyte string №∑∉',
- 'multibyte string №∑∉'
- ],
- [
- 'removes invalid bytes from ASCII-8bit encoded multibyte string.',
- "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'),
- "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg",
- ],
- ].each do |description, test_string, xpect|
- it description do
- expect(ext_class.encode!(test_string)).to eq(xpect)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/git/gitmodules_parser_spec.rb b/spec/lib/gitlab/git/gitmodules_parser_spec.rb
new file mode 100644
index 00000000000..143aa2218c9
--- /dev/null
+++ b/spec/lib/gitlab/git/gitmodules_parser_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Gitlab::Git::GitmodulesParser do
+ it 'should parse a .gitmodules file correctly' do
+ parser = described_class.new(<<-'GITMODULES'.strip_heredoc)
+ [submodule "vendor/libgit2"]
+ path = vendor/libgit2
+ [submodule "vendor/libgit2"]
+ url = https://github.com/nodegit/libgit2.git
+
+ # a comment
+ [submodule "moved"]
+ path = new/path
+ url = https://example.com/some/project
+ [submodule "bogus"]
+ url = https://example.com/another/project
+ GITMODULES
+
+ modules = parser.parse
+
+ expect(modules).to eq({
+ 'vendor/libgit2' => { 'name' => 'vendor/libgit2',
+ 'url' => 'https://github.com/nodegit/libgit2.git' },
+ 'new/path' => { 'name' => 'moved',
+ 'url' => 'https://example.com/some/project' }
+ })
+ end
+end
diff --git a/spec/lib/gitlab/git/hook_spec.rb b/spec/lib/gitlab/git/hook_spec.rb
index 3f279c21865..19391a70cf6 100644
--- a/spec/lib/gitlab/git/hook_spec.rb
+++ b/spec/lib/gitlab/git/hook_spec.rb
@@ -1,21 +1,29 @@
require 'spec_helper'
require 'fileutils'
-describe Gitlab::Git::Hook, lib: true do
+describe Gitlab::Git::Hook do
+ before do
+ # We need this because in the spec/spec_helper.rb we define it like this:
+ # allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil])
+ allow_any_instance_of(described_class).to receive(:trigger).and_call_original
+ end
+
describe "#trigger" do
let(:project) { create(:project, :repository) }
+ let(:repo_path) { project.repository.path }
let(:user) { create(:user) }
+ let(:gl_id) { Gitlab::GlId.gl_id(user) }
def create_hook(name)
- FileUtils.mkdir_p(File.join(project.repository.path, 'hooks'))
- File.open(File.join(project.repository.path, 'hooks', name), 'w', 0755) do |f|
+ FileUtils.mkdir_p(File.join(repo_path, 'hooks'))
+ File.open(File.join(repo_path, 'hooks', name), 'w', 0755) do |f|
f.write('exit 0')
end
end
def create_failing_hook(name)
- FileUtils.mkdir_p(File.join(project.repository.path, 'hooks'))
- File.open(File.join(project.repository.path, 'hooks', name), 'w', 0755) do |f|
+ FileUtils.mkdir_p(File.join(repo_path, 'hooks'))
+ File.open(File.join(repo_path, 'hooks', name), 'w', 0755) do |f|
f.write(<<-HOOK)
echo 'regular message from the hook'
echo 'error message from the hook' 1>&2
@@ -27,13 +35,29 @@ describe Gitlab::Git::Hook, lib: true do
['pre-receive', 'post-receive', 'update'].each do |hook_name|
context "when triggering a #{hook_name} hook" do
context "when the hook is successful" do
+ let(:hook_path) { File.join(repo_path, 'hooks', hook_name) }
+ let(:gl_repository) { Gitlab::GlRepository.gl_repository(project, false) }
+ let(:env) do
+ {
+ 'GL_ID' => gl_id,
+ 'PWD' => repo_path,
+ 'GL_PROTOCOL' => 'web',
+ 'GL_REPOSITORY' => gl_repository
+ }
+ end
+
it "returns success with no errors" do
create_hook(hook_name)
- hook = Gitlab::Git::Hook.new(hook_name, project.repository.path)
+ hook = described_class.new(hook_name, project)
blank = Gitlab::Git::BLANK_SHA
ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
- status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+ if hook_name != 'update'
+ expect(Open3).to receive(:popen3)
+ .with(env, hook_path, chdir: repo_path).and_call_original
+ end
+
+ status, errors = hook.trigger(gl_id, blank, blank, ref)
expect(status).to be true
expect(errors).to be_blank
end
@@ -42,11 +66,11 @@ describe Gitlab::Git::Hook, lib: true do
context "when the hook is unsuccessful" do
it "returns failure with errors" do
create_failing_hook(hook_name)
- hook = Gitlab::Git::Hook.new(hook_name, project.repository.path)
+ hook = described_class.new(hook_name, project)
blank = Gitlab::Git::BLANK_SHA
ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
- status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+ status, errors = hook.trigger(gl_id, blank, blank, ref)
expect(status).to be false
expect(errors).to eq("error message from the hook\n")
end
@@ -56,11 +80,11 @@ describe Gitlab::Git::Hook, lib: true do
context "when the hook doesn't exist" do
it "returns success with no errors" do
- hook = Gitlab::Git::Hook.new('unknown_hook', project.repository.path)
+ hook = described_class.new('unknown_hook', project)
blank = Gitlab::Git::BLANK_SHA
ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
- status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+ status, errors = hook.trigger(gl_id, blank, blank, ref)
expect(status).to be true
expect(errors).to be_nil
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index f88653cb1fe..4ef5d9070a2 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -1,14 +1,27 @@
require "spec_helper"
describe Gitlab::Git::Repository, seed_helper: true do
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
+
+ shared_examples 'wrapping gRPC errors' do |gitaly_client_class, gitaly_client_method|
+ it 'wraps gRPC not found error' do
+ expect_any_instance_of(gitaly_client_class).to receive(gitaly_client_method)
+ .and_raise(GRPC::NotFound)
+ expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository)
+ end
+
+ it 'wraps gRPC unknown error' do
+ expect_any_instance_of(gitaly_client_class).to receive(gitaly_client_method)
+ .and_raise(GRPC::Unknown)
+ expect { subject }.to raise_error(Gitlab::Git::CommandError)
+ end
+ end
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
describe "Respond to" do
subject { repository }
- it { is_expected.to respond_to(:raw) }
it { is_expected.to respond_to(:rugged) }
it { is_expected.to respond_to(:root_ref) }
it { is_expected.to respond_to(:tags) }
@@ -16,7 +29,9 @@ describe Gitlab::Git::Repository, seed_helper: true do
describe '#root_ref' do
context 'with gitaly disabled' do
- before { allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false) }
+ before do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false)
+ end
it 'calls #discover_default_branch' do
expect(repository).to receive(:discover_default_branch)
@@ -24,24 +39,35 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- # TODO: Uncomment when feature is reenabled
- # context 'with gitaly enabled' do
- # before { stub_gitaly }
- #
- # it 'gets the branch name from GitalyClient' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name)
- # repository.root_ref
- # end
- #
- # it 'wraps GRPC exceptions' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name).
- # and_raise(GRPC::Unknown)
- # expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError)
- # end
- # end
+ it 'returns UTF-8' do
+ expect(repository.root_ref).to be_utf8
+ end
+
+ it 'gets the branch name from GitalyClient' do
+ expect_any_instance_of(Gitlab::GitalyClient::RefService).to receive(:default_branch_name)
+ repository.root_ref
+ end
+
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :default_branch_name do
+ subject { repository.root_ref }
+ end
end
describe "#rugged" do
+ describe 'when storage is broken', broken_storage: true do
+ it 'raises a storage exception when storage is not available' do
+ broken_repo = described_class.new('broken', 'a/path.git')
+
+ expect { broken_repo.rugged }.to raise_error(Gitlab::Git::Storage::Inaccessible)
+ end
+ end
+
+ it 'raises a no repository exception when there is no repo' do
+ broken_repo = described_class.new('default', 'a/path.git')
+
+ expect { broken_repo.rugged }.to raise_error(Gitlab::Git::Repository::NoRepository)
+ end
+
context 'with no Git env stored' do
before do
expect(Gitlab::Git::Env).to receive(:all).and_return({})
@@ -110,34 +136,35 @@ describe Gitlab::Git::Repository, seed_helper: true do
it 'has SeedRepo::Repo::BRANCHES.size elements' do
expect(subject.size).to eq(SeedRepo::Repo::BRANCHES.size)
end
+
+ it 'returns UTF-8' do
+ expect(subject.first).to be_utf8
+ end
+
it { is_expected.to include("master") }
it { is_expected.not_to include("branch-from-space") }
- # TODO: Uncomment when feature is reenabled
- # context 'with gitaly enabled' do
- # before { stub_gitaly }
- #
- # it 'gets the branch names from GitalyClient' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names)
- # subject
- # end
- #
- # it 'wraps GRPC exceptions' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names).
- # and_raise(GRPC::Unknown)
- # expect { subject }.to raise_error(Gitlab::Git::CommandError)
- # end
- # end
+ it 'gets the branch names from GitalyClient' do
+ expect_any_instance_of(Gitlab::GitalyClient::RefService).to receive(:branch_names)
+ subject
+ end
+
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :branch_names
end
describe '#tag_names' do
subject { repository.tag_names }
it { is_expected.to be_kind_of Array }
+
it 'has SeedRepo::Repo::TAGS.size elements' do
expect(subject.size).to eq(SeedRepo::Repo::TAGS.size)
end
+ it 'returns UTF-8' do
+ expect(subject.first).to be_utf8
+ end
+
describe '#last' do
subject { super().last }
it { is_expected.to eq("v1.2.1") }
@@ -145,21 +172,12 @@ describe Gitlab::Git::Repository, seed_helper: true do
it { is_expected.to include("v1.0.0") }
it { is_expected.not_to include("v5.0.0") }
- # TODO: Uncomment when feature is reenabled
- # context 'with gitaly enabled' do
- # before { stub_gitaly }
- #
- # it 'gets the tag names from GitalyClient' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names)
- # subject
- # end
- #
- # it 'wraps GRPC exceptions' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names).
- # and_raise(GRPC::Unknown)
- # expect { subject }.to raise_error(Gitlab::Git::CommandError)
- # end
- # end
+ it 'gets the tag names from GitalyClient' do
+ expect_any_instance_of(Gitlab::GitalyClient::RefService).to receive(:tag_names)
+ subject
+ end
+
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tag_names
end
shared_examples 'archive check' do |extenstion|
@@ -229,33 +247,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
it { expect(repository.bare?).to be_truthy }
end
- describe '#heads' do
- let(:heads) { repository.heads }
- subject { heads }
-
- it { is_expected.to be_kind_of Array }
-
- describe '#size' do
- subject { super().size }
- it { is_expected.to eq(SeedRepo::Repo::BRANCHES.size) }
- end
-
- context :head do
- subject { heads.first }
-
- describe '#name' do
- subject { super().name }
- it { is_expected.to eq("feature") }
- end
-
- context :commit do
- subject { heads.first.dereferenced_target.sha }
-
- it { is_expected.to eq("0b4bc9a49b562e85de7cc9e834518ea6828729b9") }
- end
- end
- end
-
describe '#ref_names' do
let(:ref_names) { repository.ref_names }
subject { ref_names }
@@ -273,39 +264,41 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- describe '#search_files' do
- let(:results) { repository.search_files('rails', 'master') }
- subject { results }
+ describe '#submodule_url_for' do
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
+ let(:ref) { 'master' }
- it { is_expected.to be_kind_of Array }
+ def submodule_url(path)
+ repository.submodule_url_for(ref, path)
+ end
- describe '#first' do
- subject { super().first }
- it { is_expected.to be_kind_of Gitlab::Git::BlobSnippet }
+ it { expect(submodule_url('six')).to eq('git://github.com/randx/six.git') }
+ it { expect(submodule_url('nested/six')).to eq('git://github.com/randx/six.git') }
+ it { expect(submodule_url('deeper/nested/six')).to eq('git://github.com/randx/six.git') }
+ it { expect(submodule_url('invalid/path')).to eq(nil) }
+
+ context 'uncommitted submodule dir' do
+ let(:ref) { 'fix-existing-submodule-dir' }
+
+ it { expect(submodule_url('submodule-existing-dir')).to eq(nil) }
end
- context 'blob result' do
- subject { results.first }
+ context 'tags' do
+ let(:ref) { 'v1.2.1' }
- describe '#ref' do
- subject { super().ref }
- it { is_expected.to eq('master') }
- end
+ it { expect(submodule_url('six')).to eq('git://github.com/randx/six.git') }
+ end
- describe '#filename' do
- subject { super().filename }
- it { is_expected.to eq('CHANGELOG') }
- end
+ context 'no .gitmodules at commit' do
+ let(:ref) { '9596bc54a6f0c0c98248fe97077eb5ccf48a98d0' }
- describe '#startline' do
- subject { super().startline }
- it { is_expected.to eq(35) }
- end
+ it { expect(submodule_url('six')).to eq(nil) }
+ end
- describe '#data' do
- subject { super().data }
- it { is_expected.to include "Ability to filter by multiple labels" }
- end
+ context 'no gitlink entry' do
+ let(:ref) { '6d39438' }
+
+ it { expect(submodule_url('six')).to eq(nil) }
end
end
@@ -313,7 +306,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
context 'where repo has submodules' do
- let(:submodules) { repository.submodules('master') }
+ let(:submodules) { repository.send(:submodules, 'master') }
let(:submodule) { submodules.first }
it { expect(submodules).to be_kind_of Hash }
@@ -323,7 +316,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(submodule).to eq([
"six", {
"id" => "409f37c4f05865e4fb208c771485f211a22c4c2d",
- "path" => "six",
+ "name" => "six",
"url" => "git://github.com/randx/six.git"
}
])
@@ -331,14 +324,14 @@ describe Gitlab::Git::Repository, seed_helper: true do
it 'should handle nested submodules correctly' do
nested = submodules['nested/six']
- expect(nested['path']).to eq('nested/six')
+ expect(nested['name']).to eq('nested/six')
expect(nested['url']).to eq('git://github.com/randx/six.git')
expect(nested['id']).to eq('24fb71c79fcabc63dfd8832b12ee3bf2bf06b196')
end
it 'should handle deeply nested submodules correctly' do
nested = submodules['deeper/nested/six']
- expect(nested['path']).to eq('deeper/nested/six')
+ expect(nested['name']).to eq('deeper/nested/six')
expect(nested['url']).to eq('git://github.com/randx/six.git')
expect(nested['id']).to eq('24fb71c79fcabc63dfd8832b12ee3bf2bf06b196')
end
@@ -348,25 +341,38 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
it 'should not have an entry for an uncommited submodule dir' do
- submodules = repository.submodules('fix-existing-submodule-dir')
+ submodules = repository.send(:submodules, 'fix-existing-submodule-dir')
expect(submodules).not_to have_key('submodule-existing-dir')
end
it 'should handle tags correctly' do
- submodules = repository.submodules('v1.2.1')
+ submodules = repository.send(:submodules, 'v1.2.1')
expect(submodules.first).to eq([
"six", {
"id" => "409f37c4f05865e4fb208c771485f211a22c4c2d",
- "path" => "six",
+ "name" => "six",
"url" => "git://github.com/randx/six.git"
}
])
end
+
+ it 'should not break on invalid syntax' do
+ allow(repository).to receive(:blob_content).and_return(<<-GITMODULES.strip_heredoc)
+ [submodule "six"]
+ path = six
+ url = git://github.com/randx/six.git
+
+ [submodule]
+ foo = bar
+ GITMODULES
+
+ expect(submodules).to have_key('six')
+ end
end
context 'where repo doesn\'t have submodules' do
- let(:submodules) { repository.submodules('6d39438') }
+ let(:submodules) { repository.send(:submodules, '6d39438') }
it 'should return an empty hash' do
expect(submodules).to be_empty
end
@@ -374,145 +380,20 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#commit_count' do
- it { expect(repository.commit_count("master")).to eq(25) }
- it { expect(repository.commit_count("feature")).to eq(9) }
- end
-
- describe "#reset" do
- change_path = File.join(SEED_STORAGE_PATH, TEST_NORMAL_REPO_PATH, "CHANGELOG")
- untracked_path = File.join(SEED_STORAGE_PATH, TEST_NORMAL_REPO_PATH, "UNTRACKED")
- tracked_path = File.join(SEED_STORAGE_PATH, TEST_NORMAL_REPO_PATH, "files", "ruby", "popen.rb")
-
- change_text = "New changelog text"
- untracked_text = "This file is untracked"
-
- reset_commit = SeedRepo::LastCommit::ID
-
- context "--hard" do
- before(:all) do
- # Modify a tracked file
- File.open(change_path, "w") do |f|
- f.write(change_text)
- end
-
- # Add an untracked file to the working directory
- File.open(untracked_path, "w") do |f|
- f.write(untracked_text)
- end
-
- @normal_repo = Gitlab::Git::Repository.new('default', TEST_NORMAL_REPO_PATH)
- @normal_repo.reset("HEAD", :hard)
- end
-
- it "should replace the working directory with the content of the index" do
- File.open(change_path, "r") do |f|
- expect(f.each_line.first).not_to eq(change_text)
- end
-
- File.open(tracked_path, "r") do |f|
- expect(f.each_line.to_a[8]).to include('raise RuntimeError, "System commands')
- end
- end
-
- it "should not touch untracked files" do
- expect(File.exist?(untracked_path)).to be_truthy
- end
-
- it "should move the HEAD to the correct commit" do
- new_head = @normal_repo.rugged.head.target.oid
- expect(new_head).to eq(reset_commit)
- end
-
- it "should move the tip of the master branch to the correct commit" do
- new_tip = @normal_repo.rugged.references["refs/heads/master"].
- target.oid
-
- expect(new_tip).to eq(reset_commit)
- end
-
- after(:all) do
- # Fast-forward to the original HEAD
- FileUtils.rm_rf(TEST_NORMAL_REPO_PATH)
- ensure_seeds
- end
+ shared_examples 'simple commit counting' do
+ it { expect(repository.commit_count("master")).to eq(25) }
+ it { expect(repository.commit_count("feature")).to eq(9) }
end
- end
-
- describe "#checkout" do
- new_branch = "foo_branch"
- context "-b" do
- before(:all) do
- @normal_repo = Gitlab::Git::Repository.new('default', TEST_NORMAL_REPO_PATH)
- @normal_repo.checkout(new_branch, { b: true }, "origin/feature")
- end
-
- it "should create a new branch" do
- expect(@normal_repo.rugged.branches[new_branch]).not_to be_nil
- end
-
- it "should move the HEAD to the correct commit" do
- expect(@normal_repo.rugged.head.target.oid).to(
- eq(@normal_repo.rugged.branches["origin/feature"].target.oid)
- )
- end
-
- it "should refresh the repo's #heads collection" do
- head_names = @normal_repo.heads.map { |h| h.name }
- expect(head_names).to include(new_branch)
- end
-
- after(:all) do
- FileUtils.rm_rf(TEST_NORMAL_REPO_PATH)
- ensure_seeds
+ context 'when Gitaly commit_count feature is enabled' do
+ it_behaves_like 'simple commit counting'
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::CommitService, :commit_count do
+ subject { repository.commit_count('master') }
end
end
- context "without -b" do
- context "and specifying a nonexistent branch" do
- it "should not do anything" do
- normal_repo = Gitlab::Git::Repository.new('default', TEST_NORMAL_REPO_PATH)
-
- expect { normal_repo.checkout(new_branch) }.to raise_error(Rugged::ReferenceError)
- expect(normal_repo.rugged.branches[new_branch]).to be_nil
- expect(normal_repo.rugged.head.target.oid).to(
- eq(normal_repo.rugged.branches["master"].target.oid)
- )
-
- head_names = normal_repo.heads.map { |h| h.name }
- expect(head_names).not_to include(new_branch)
- end
-
- after(:all) do
- FileUtils.rm_rf(TEST_NORMAL_REPO_PATH)
- ensure_seeds
- end
- end
-
- context "and with a valid branch" do
- before(:all) do
- @normal_repo = Gitlab::Git::Repository.new('default', TEST_NORMAL_REPO_PATH)
- @normal_repo.rugged.branches.create("feature", "origin/feature")
- @normal_repo.checkout("feature")
- end
-
- it "should move the HEAD to the correct commit" do
- expect(@normal_repo.rugged.head.target.oid).to(
- eq(@normal_repo.rugged.branches["feature"].target.oid)
- )
- end
-
- it "should update the working directory" do
- File.open(File.join(SEED_STORAGE_PATH, TEST_NORMAL_REPO_PATH, ".gitignore"), "r") do |f|
- expect(f.read.each_line.to_a).not_to include(".DS_Store\n")
- end
- end
-
- after(:all) do
- FileUtils.rm_rf(SEED_STORAGE_PATH, TEST_NORMAL_REPO_PATH)
- ensure_seeds
- end
- end
+ context 'when Gitaly commit_count feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'simple commit counting'
end
end
@@ -526,10 +407,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(@repo.rugged.branches["feature"]).to be_nil
end
- it "should update the repo's #heads collection" do
- expect(@repo.heads).not_to include("feature")
- end
-
after(:all) do
FileUtils.rm_rf(TEST_MUTABLE_REPO_PATH)
ensure_seeds
@@ -551,11 +428,11 @@ describe Gitlab::Git::Repository, seed_helper: true do
it "should fail if we create an existing branch" do
@repo.create_branch('duplicated_branch', 'master')
- expect{@repo.create_branch('duplicated_branch', 'master')}.to raise_error("Branch duplicated_branch already exists")
+ expect {@repo.create_branch('duplicated_branch', 'master')}.to raise_error("Branch duplicated_branch already exists")
end
it "should fail if we create a branch from a non existing ref" do
- expect{@repo.create_branch('branch_based_in_wrong_ref', 'master_2_the_revenge')}.to raise_error("Invalid reference master_2_the_revenge")
+ expect {@repo.create_branch('branch_based_in_wrong_ref', 'master_2_the_revenge')}.to raise_error("Invalid reference master_2_the_revenge")
end
after(:all) do
@@ -634,17 +511,22 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe "#log" do
- commit_with_old_name = nil
- commit_with_new_name = nil
- rename_commit = nil
+ let(:commit_with_old_name) do
+ Gitlab::Git::Commit.decorate(repository, @commit_with_old_name_id)
+ end
+ let(:commit_with_new_name) do
+ Gitlab::Git::Commit.decorate(repository, @commit_with_new_name_id)
+ end
+ let(:rename_commit) do
+ Gitlab::Git::Commit.decorate(repository, @rename_commit_id)
+ end
before(:context) do
# Add new commits so that there's a renamed file in the commit history
repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH).rugged
-
- commit_with_old_name = new_commit_edit_old_file(repo)
- rename_commit = new_commit_move_file(repo)
- commit_with_new_name = new_commit_edit_new_file(repo)
+ @commit_with_old_name_id = new_commit_edit_old_file(repo)
+ @rename_commit_id = new_commit_move_file(repo)
+ @commit_with_new_name_id = new_commit_edit_new_file(repo)
end
after(:context) do
@@ -817,8 +699,8 @@ describe Gitlab::Git::Repository, seed_helper: true do
context "compare results between log_by_walk and log_by_shell" do
let(:options) { { ref: "master" } }
- let(:commits_by_walk) { repository.log(options).map(&:oid) }
- let(:commits_by_shell) { repository.log(options.merge({ disable_walk: true })).map(&:oid) }
+ let(:commits_by_walk) { repository.log(options).map(&:id) }
+ let(:commits_by_shell) { repository.log(options.merge({ disable_walk: true })).map(&:id) }
it { expect(commits_by_walk).to eq(commits_by_shell) }
@@ -861,7 +743,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(commits.size).to be > 0
expect(commits).to satisfy do |commits|
- commits.all? { |commit| commit.time >= options[:after] }
+ commits.all? { |commit| commit.committed_date >= options[:after] }
end
end
end
@@ -874,7 +756,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(commits.size).to be > 0
expect(commits).to satisfy do |commits|
- commits.all? { |commit| commit.time <= options[:before] }
+ commits.all? { |commit| commit.committed_date <= options[:before] }
end
end
end
@@ -883,7 +765,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } }
def commit_files(commit)
- commit.diff(commit.parent_ids.first).deltas.flat_map do |delta|
+ commit.rugged_diff_from_parent.deltas.flat_map do |delta|
[delta.old_file[:path], delta.new_file[:path]].uniq.compact
end
end
@@ -899,13 +781,13 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- describe "#commits_between" do
+ describe "#rugged_commits_between" do
context 'two SHAs' do
let(:first_sha) { 'b0e52af38d7ea43cf41d8a6f2471351ac036d6c9' }
let(:second_sha) { '0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326' }
it 'returns the number of commits between' do
- expect(repository.commits_between(first_sha, second_sha).count).to eq(3)
+ expect(repository.rugged_commits_between(first_sha, second_sha).count).to eq(3)
end
end
@@ -914,11 +796,11 @@ describe Gitlab::Git::Repository, seed_helper: true do
let(:branch) { 'master' }
it 'returns the number of commits between a sha and a branch' do
- expect(repository.commits_between(sha, branch).count).to eq(5)
+ expect(repository.rugged_commits_between(sha, branch).count).to eq(5)
end
it 'returns the number of commits between a branch and a sha' do
- expect(repository.commits_between(branch, sha).count).to eq(0) # sha is before branch
+ expect(repository.rugged_commits_between(branch, sha).count).to eq(0) # sha is before branch
end
end
@@ -927,7 +809,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
let(:second_branch) { 'master' }
it 'returns the number of commits between' do
- expect(repository.commits_between(first_branch, second_branch).count).to eq(17)
+ expect(repository.rugged_commits_between(first_branch, second_branch).count).to eq(17)
end
end
end
@@ -939,29 +821,39 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#count_commits' do
- context 'with after timestamp' do
- it 'returns the number of commits after timestamp' do
- options = { ref: 'master', limit: nil, after: Time.iso8601('2013-03-03T20:15:01+00:00') }
+ shared_examples 'extended commit counting' do
+ context 'with after timestamp' do
+ it 'returns the number of commits after timestamp' do
+ options = { ref: 'master', limit: nil, after: Time.iso8601('2013-03-03T20:15:01+00:00') }
- expect(repository.count_commits(options)).to eq(25)
+ expect(repository.count_commits(options)).to eq(25)
+ end
end
- end
- context 'with before timestamp' do
- it 'returns the number of commits after timestamp' do
- options = { ref: 'feature', limit: nil, before: Time.iso8601('2015-03-03T20:15:01+00:00') }
+ context 'with before timestamp' do
+ it 'returns the number of commits before timestamp' do
+ options = { ref: 'feature', limit: nil, before: Time.iso8601('2015-03-03T20:15:01+00:00') }
- expect(repository.count_commits(options)).to eq(9)
+ expect(repository.count_commits(options)).to eq(9)
+ end
end
- end
- context 'with path' do
- it 'returns the number of commits with path ' do
- options = { ref: 'master', limit: nil, path: "encoding" }
+ context 'with path' do
+ it 'returns the number of commits with path ' do
+ options = { ref: 'master', limit: nil, path: "encoding" }
- expect(repository.count_commits(options)).to eq(2)
+ expect(repository.count_commits(options)).to eq(2)
+ end
end
end
+
+ context 'when Gitaly count_commits feature is enabled' do
+ it_behaves_like 'extended commit counting'
+ end
+
+ context 'when Gitaly count_commits feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'extended commit counting'
+ end
end
describe "branch_names_contains" do
@@ -1031,63 +923,76 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- describe '#find_commits' do
- it 'should return a return a collection of commits' do
- commits = repository.find_commits
+ describe '#ref_name_for_sha' do
+ let(:ref_path) { 'refs/heads' }
+ let(:sha) { repository.find_branch('master').dereferenced_target.id }
+ let(:ref_name) { 'refs/heads/master' }
- expect(commits).not_to be_empty
- expect(commits).to all( be_a_kind_of(Gitlab::Git::Commit) )
+ it 'returns the ref name for the given sha' do
+ expect(repository.ref_name_for_sha(ref_path, sha)).to eq(ref_name)
end
- context 'while applying a sort order based on the `order` option' do
- it "allows ordering topologically (no parents shown before their children)" do
- expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_TOPO)
+ it "returns an empty name if the ref doesn't exist" do
+ expect(repository.ref_name_for_sha(ref_path, "000000")).to eq("")
+ end
- repository.find_commits(order: :topo)
- end
+ it "raise an exception if the ref is empty" do
+ expect { repository.ref_name_for_sha(ref_path, "") }.to raise_error(ArgumentError)
+ end
- it "allows ordering by date" do
- expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE)
+ it "raise an exception if the ref is nil" do
+ expect { repository.ref_name_for_sha(ref_path, nil) }.to raise_error(ArgumentError)
+ end
+ end
- repository.find_commits(order: :date)
- end
+ describe '#branches' do
+ subject { repository.branches }
- it "applies no sorting by default" do
- expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_NONE)
+ context 'with local and remote branches' do
+ let(:repository) do
+ Gitlab::Git::Repository.new('default', File.join(TEST_MUTABLE_REPO_PATH, '.git'))
+ end
- repository.find_commits
+ before do
+ create_remote_branch(repository, 'joe', 'remote_branch', 'master')
+ repository.create_branch('local_branch', 'master')
end
- end
- end
- describe '#branches with deleted branch' do
- before(:each) do
- ref = double()
- allow(ref).to receive(:name) { 'bad-branch' }
- allow(ref).to receive(:target) { raise Rugged::ReferenceError }
- allow(repository.rugged).to receive(:branches) { [ref] }
- end
+ after do
+ FileUtils.rm_rf(TEST_MUTABLE_REPO_PATH)
+ ensure_seeds
+ end
- it 'should return empty branches' do
- expect(repository.branches).to eq([])
+ it 'returns the local and remote branches' do
+ expect(subject.any? { |b| b.name == 'joe/remote_branch' }).to eq(true)
+ expect(subject.any? { |b| b.name == 'local_branch' }).to eq(true)
+ end
end
- end
- describe '#branch_count' do
- before(:each) do
- valid_ref = double(:ref)
- invalid_ref = double(:ref)
-
- allow(valid_ref).to receive_messages(name: 'master', target: double(:target))
+ # With Gitaly enabled, Gitaly just doesn't return deleted branches.
+ context 'with deleted branch with Gitaly disabled' do
+ before do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false)
+ end
- allow(invalid_ref).to receive_messages(name: 'bad-branch')
- allow(invalid_ref).to receive(:target) { raise Rugged::ReferenceError }
+ it 'returns no results' do
+ ref = double()
+ allow(ref).to receive(:name) { 'bad-branch' }
+ allow(ref).to receive(:target) { raise Rugged::ReferenceError }
+ branches = double()
+ allow(branches).to receive(:each) { [ref].each }
+ allow(repository.rugged).to receive(:branches) { branches }
- allow(repository.rugged).to receive_messages(branches: [valid_ref, invalid_ref])
+ expect(subject).to be_empty
+ end
end
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :branches
+ end
+
+ describe '#branch_count' do
it 'returns the number of branches' do
- expect(repository.branch_count).to eq(1)
+ expect(repository.branch_count).to eq(10)
end
end
@@ -1103,7 +1008,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(master_file_paths).to include("files/html/500.html")
end
- it "dose not read submodule directory and empty directory of master branch" do
+ it "does not read submodule directory and empty directory of master branch" do
expect(master_file_paths).not_to include("six")
end
@@ -1124,7 +1029,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
context "with no .gitattrbutes" do
- before(:each) do
+ before do
repository.copy_gitattributes("master")
end
@@ -1132,13 +1037,13 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(File.exist?(attributes_path)).to be_falsey
end
- after(:each) do
+ after do
FileUtils.rm_rf(attributes_path)
end
end
context "with .gitattrbutes" do
- before(:each) do
+ before do
repository.copy_gitattributes("gitattributes")
end
@@ -1151,13 +1056,13 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(contents).to eq("*.md binary\n")
end
- after(:each) do
+ after do
FileUtils.rm_rf(attributes_path)
end
end
context "with updated .gitattrbutes" do
- before(:each) do
+ before do
repository.copy_gitattributes("gitattributes")
repository.copy_gitattributes("gitattributes-updated")
end
@@ -1171,13 +1076,13 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(contents).to eq("*.txt binary\n")
end
- after(:each) do
+ after do
FileUtils.rm_rf(attributes_path)
end
end
context "with no .gitattrbutes in HEAD but with previous info/attributes" do
- before(:each) do
+ before do
repository.copy_gitattributes("gitattributes")
repository.copy_gitattributes("master")
end
@@ -1186,53 +1091,12 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(File.exist?(attributes_path)).to be_falsey
end
- after(:each) do
+ after do
FileUtils.rm_rf(attributes_path)
end
end
end
- describe '#diffable' do
- info_dir_path = attributes_path = File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info')
- attributes_path = File.join(info_dir_path, 'attributes')
-
- before(:all) do
- FileUtils.mkdir(info_dir_path) unless File.exist?(info_dir_path)
- File.write(attributes_path, "*.md -diff\n")
- end
-
- it "should return true for files which are text and do not have attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'LICENSE'
- )
- expect(repository.diffable?(blob)).to be_truthy
- end
-
- it "should return false for binary files which do not have attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'files/images/logo-white.png'
- )
- expect(repository.diffable?(blob)).to be_falsey
- end
-
- it "should return false for text files which have been marked as not being diffable in attributes" do
- blob = Gitlab::Git::Blob.find(
- repository,
- '33bcff41c232a11727ac6d660bd4b0c2ba86d63d',
- 'README.md'
- )
- expect(repository.diffable?(blob)).to be_falsey
- end
-
- after(:all) do
- FileUtils.rm_rf(info_dir_path)
- end
- end
-
describe '#tag_exists?' do
it 'returns true for an existing tag' do
tag = repository.tag_names.first
@@ -1261,7 +1125,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
describe '#local_branches' do
before(:all) do
- @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH)
+ @repo = Gitlab::Git::Repository.new('default', File.join(TEST_MUTABLE_REPO_PATH, '.git'))
end
after(:all) do
@@ -1270,17 +1134,75 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
it 'returns the local branches' do
- create_remote_branch('joe', 'remote_branch', 'master')
+ create_remote_branch(@repo, 'joe', 'remote_branch', 'master')
@repo.create_branch('local_branch', 'master')
expect(@repo.local_branches.any? { |branch| branch.name == 'remote_branch' }).to eq(false)
expect(@repo.local_branches.any? { |branch| branch.name == 'local_branch' }).to eq(true)
end
+
+ it 'returns a Branch with UTF-8 fields' do
+ branches = @repo.local_branches.to_a
+ expect(branches.size).to be > 0
+ branches.each do |branch|
+ expect(branch.name).to be_utf8
+ expect(branch.target).to be_utf8 unless branch.target.nil?
+ end
+ end
+
+ it 'gets the branches from GitalyClient' do
+ expect_any_instance_of(Gitlab::GitalyClient::RefService).to receive(:local_branches)
+ .and_return([])
+ @repo.local_branches
+ end
+
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :local_branches do
+ subject { @repo.local_branches }
+ end
+ end
+
+ describe '#languages' do
+ shared_examples 'languages' do
+ it 'returns exactly the expected results' do
+ languages = repository.languages('4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6')
+ expected_languages = [
+ { value: 66.63, label: "Ruby", color: "#701516", highlight: "#701516" },
+ { value: 22.96, label: "JavaScript", color: "#f1e05a", highlight: "#f1e05a" },
+ { value: 7.9, label: "HTML", color: "#e44b23", highlight: "#e44b23" },
+ { value: 2.51, label: "CoffeeScript", color: "#244776", highlight: "#244776" }
+ ]
+
+ expect(languages.size).to eq(expected_languages.size)
+
+ expected_languages.size.times do |i|
+ a = expected_languages[i]
+ b = languages[i]
+
+ expect(a.keys.sort).to eq(b.keys.sort)
+ expect(a[:value]).to be_within(0.1).of(b[:value])
+
+ non_float_keys = a.keys - [:value]
+ expect(a.values_at(*non_float_keys)).to eq(b.values_at(*non_float_keys))
+ end
+ end
+
+ it "uses the repository's HEAD when no ref is passed" do
+ lang = repository.languages.first
+
+ expect(lang[:label]).to eq('Ruby')
+ end
+ end
+
+ it_behaves_like 'languages'
+
+ context 'with rugged', skip_gitaly_mock: true do
+ it_behaves_like 'languages'
+ end
end
- def create_remote_branch(remote_name, branch_name, source_branch_name)
- source_branch = @repo.branches.find { |branch| branch.name == source_branch_name }
- rugged = @repo.rugged
+ def create_remote_branch(repository, remote_name, branch_name, source_branch_name)
+ source_branch = repository.branches.find { |branch| branch.name == source_branch_name }
+ rugged = repository.rugged
rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", source_branch.dereferenced_target.sha)
end
@@ -1353,11 +1275,4 @@ describe Gitlab::Git::Repository, seed_helper: true do
sha = Rugged::Commit.create(repo, options)
repo.lookup(sha)
end
-
- def stub_gitaly
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
-
- stub = double(:stub)
- allow(Gitaly::Ref::Stub).to receive(:new).and_return(stub)
- end
end
diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb
index 78894ba9409..b051a088171 100644
--- a/spec/lib/gitlab/git/rev_list_spec.rb
+++ b/spec/lib/gitlab/git/rev_list_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Git::RevList, lib: true do
+describe Gitlab::Git::RevList do
let(:project) { create(:project, :repository) }
before do
@@ -11,7 +11,7 @@ describe Gitlab::Git::RevList, lib: true do
end
context "#new_refs" do
- let(:rev_list) { Gitlab::Git::RevList.new(newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
+ let(:rev_list) { described_class.new(newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
it 'calls out to `popen`' do
expect(Gitlab::Popen).to receive(:popen).with([
@@ -33,7 +33,7 @@ describe Gitlab::Git::RevList, lib: true do
end
context "#missed_ref" do
- let(:rev_list) { Gitlab::Git::RevList.new(oldrev: 'oldrev', newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
+ let(:rev_list) { described_class.new(oldrev: 'oldrev', newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
it 'calls out to `popen`' do
expect(Gitlab::Popen).to receive(:popen).with([
diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
new file mode 100644
index 00000000000..c86353abb7c
--- /dev/null
+++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
@@ -0,0 +1,329 @@
+require 'spec_helper'
+
+describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: true, broken_storage: true do
+ let(:storage_name) { 'default' }
+ let(:circuit_breaker) { described_class.new(storage_name) }
+ let(:hostname) { Gitlab::Environment.hostname }
+ let(:cache_key) { "storage_accessible:#{storage_name}:#{hostname}" }
+
+ before do
+ # Override test-settings for the circuitbreaker with something more realistic
+ # for these specs.
+ stub_storage_settings('default' => {
+ 'path' => TestEnv.repos_path,
+ 'failure_count_threshold' => 10,
+ 'failure_wait_time' => 30,
+ 'failure_reset_time' => 1800,
+ 'storage_timeout' => 5
+ },
+ 'broken' => {
+ 'path' => 'tmp/tests/non-existent-repositories',
+ 'failure_count_threshold' => 10,
+ 'failure_wait_time' => 30,
+ 'failure_reset_time' => 1800,
+ 'storage_timeout' => 5
+ }
+ )
+ end
+
+ def value_from_redis(name)
+ Gitlab::Git::Storage.redis.with do |redis|
+ redis.hmget(cache_key, name)
+ end.first
+ end
+
+ def set_in_redis(name, value)
+ Gitlab::Git::Storage.redis.with do |redis|
+ redis.hmset(cache_key, name, value)
+ end.first
+ end
+
+ describe '.reset_all!' do
+ it 'clears all entries form redis' do
+ set_in_redis(:failure_count, 10)
+
+ described_class.reset_all!
+
+ key_exists = Gitlab::Git::Storage.redis.with { |redis| redis.exists(cache_key) }
+
+ expect(key_exists).to be_falsey
+ end
+ end
+
+ describe '.for_storage' do
+ it 'only builds a single circuitbreaker per storage' do
+ expect(described_class).to receive(:new).once.and_call_original
+
+ breaker = described_class.for_storage('default')
+
+ expect(breaker).to be_a(described_class)
+ expect(described_class.for_storage('default')).to eq(breaker)
+ end
+ end
+
+ describe '#initialize' do
+ it 'assigns the settings' do
+ expect(circuit_breaker.hostname).to eq(hostname)
+ expect(circuit_breaker.storage).to eq('default')
+ expect(circuit_breaker.storage_path).to eq(TestEnv.repos_path)
+ expect(circuit_breaker.failure_count_threshold).to eq(10)
+ expect(circuit_breaker.failure_wait_time).to eq(30)
+ expect(circuit_breaker.failure_reset_time).to eq(1800)
+ expect(circuit_breaker.storage_timeout).to eq(5)
+ end
+ end
+
+ describe '#perform' do
+ it 'raises an exception with retry time when the circuit is open' do
+ allow(circuit_breaker).to receive(:circuit_broken?).and_return(true)
+
+ expect { |b| circuit_breaker.perform(&b) }
+ .to raise_error(Gitlab::Git::Storage::CircuitOpen)
+ end
+
+ it 'yields the block' do
+ expect { |b| circuit_breaker.perform(&b) }
+ .to yield_control
+ end
+
+ it 'checks if the storage is available' do
+ expect(circuit_breaker).to receive(:check_storage_accessible!)
+
+ circuit_breaker.perform { 'hello world' }
+ end
+
+ it 'returns the value of the block' do
+ result = circuit_breaker.perform { 'return value' }
+
+ expect(result).to eq('return value')
+ end
+
+ it 'raises possible errors' do
+ expect { circuit_breaker.perform { raise Rugged::OSError.new('Broken') } }
+ .to raise_error(Rugged::OSError)
+ end
+
+ context 'with the feature disabled' do
+ it 'returns the block without checking accessibility' do
+ stub_feature_flags(git_storage_circuit_breaker: false)
+
+ expect(circuit_breaker).not_to receive(:circuit_broken?)
+
+ result = circuit_breaker.perform { 'hello' }
+
+ expect(result).to eq('hello')
+ end
+ end
+ end
+
+ describe '#circuit_broken?' do
+ it 'is working when there is no last failure' do
+ set_in_redis(:last_failure, nil)
+ set_in_redis(:failure_count, 0)
+
+ expect(circuit_breaker.circuit_broken?).to be_falsey
+ end
+
+ it 'is broken when there was a recent failure' do
+ Timecop.freeze do
+ set_in_redis(:last_failure, 1.second.ago.to_f)
+ set_in_redis(:failure_count, 1)
+
+ expect(circuit_breaker.circuit_broken?).to be_truthy
+ end
+ end
+
+ it 'is broken when there are too many failures' do
+ set_in_redis(:last_failure, 1.day.ago.to_f)
+ set_in_redis(:failure_count, 200)
+
+ expect(circuit_breaker.circuit_broken?).to be_truthy
+ end
+
+ context 'the `failure_wait_time` is set to 0' do
+ before do
+ stub_storage_settings('default' => {
+ 'failure_wait_time' => 0,
+ 'path' => TestEnv.repos_path
+ })
+ end
+
+ it 'is working even when there is a recent failure' do
+ Timecop.freeze do
+ set_in_redis(:last_failure, 0.seconds.ago.to_f)
+ set_in_redis(:failure_count, 1)
+
+ expect(circuit_breaker.circuit_broken?).to be_falsey
+ end
+ end
+ end
+ end
+
+ describe "storage_available?" do
+ context 'the storage is available' do
+ it 'tracks that the storage was accessible an raises the error' do
+ expect(circuit_breaker).to receive(:track_storage_accessible)
+
+ circuit_breaker.storage_available?
+ end
+
+ it 'only performs the check once' do
+ expect(Gitlab::Git::Storage::ForkedStorageCheck)
+ .to receive(:storage_available?).once.and_call_original
+
+ 2.times { circuit_breaker.storage_available? }
+ end
+ end
+
+ context 'storage is not available' do
+ let(:storage_name) { 'broken' }
+
+ it 'tracks that the storage was inaccessible' do
+ expect(circuit_breaker).to receive(:track_storage_inaccessible)
+
+ circuit_breaker.storage_available?
+ end
+ end
+ end
+
+ describe '#check_storage_accessible!' do
+ it 'raises an exception with retry time when the circuit is open' do
+ allow(circuit_breaker).to receive(:circuit_broken?).and_return(true)
+
+ expect { circuit_breaker.check_storage_accessible! }
+ .to raise_error do |exception|
+ expect(exception).to be_kind_of(Gitlab::Git::Storage::CircuitOpen)
+ expect(exception.retry_after).to eq(30)
+ end
+ end
+
+ context 'the storage is not available' do
+ let(:storage_name) { 'broken' }
+
+ it 'raises an error' do
+ expect(circuit_breaker).to receive(:track_storage_inaccessible)
+
+ expect { circuit_breaker.check_storage_accessible! }
+ .to raise_error do |exception|
+ expect(exception).to be_kind_of(Gitlab::Git::Storage::Inaccessible)
+ expect(exception.retry_after).to eq(30)
+ end
+ end
+ end
+ end
+
+ describe '#track_storage_inaccessible' do
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ it 'records the failure time in redis' do
+ circuit_breaker.track_storage_inaccessible
+
+ failure_time = value_from_redis(:last_failure)
+
+ expect(Time.at(failure_time.to_i)).to be_within(1.second).of(Time.now)
+ end
+
+ it 'sets the failure time on the breaker without reloading' do
+ circuit_breaker.track_storage_inaccessible
+
+ expect(circuit_breaker).not_to receive(:get_failure_info)
+ expect(circuit_breaker.last_failure).to eq(Time.now)
+ end
+
+ it 'increments the failure count in redis' do
+ set_in_redis(:failure_count, 10)
+
+ circuit_breaker.track_storage_inaccessible
+
+ expect(value_from_redis(:failure_count).to_i).to be(11)
+ end
+
+ it 'increments the failure count on the breaker without reloading' do
+ set_in_redis(:failure_count, 10)
+
+ circuit_breaker.track_storage_inaccessible
+
+ expect(circuit_breaker).not_to receive(:get_failure_info)
+ expect(circuit_breaker.failure_count).to eq(11)
+ end
+ end
+
+ describe '#track_storage_accessible' do
+ it 'sets the failure count to zero in redis' do
+ set_in_redis(:failure_count, 10)
+
+ circuit_breaker.track_storage_accessible
+
+ expect(value_from_redis(:failure_count).to_i).to be(0)
+ end
+
+ it 'sets the failure count to zero on the breaker without reloading' do
+ set_in_redis(:failure_count, 10)
+
+ circuit_breaker.track_storage_accessible
+
+ expect(circuit_breaker).not_to receive(:get_failure_info)
+ expect(circuit_breaker.failure_count).to eq(0)
+ end
+
+ it 'removes the last failure time from redis' do
+ set_in_redis(:last_failure, Time.now.to_i)
+
+ circuit_breaker.track_storage_accessible
+
+ expect(circuit_breaker).not_to receive(:get_failure_info)
+ expect(circuit_breaker.last_failure).to be_nil
+ end
+
+ it 'removes the last failure time from the breaker without reloading' do
+ set_in_redis(:last_failure, Time.now.to_i)
+
+ circuit_breaker.track_storage_accessible
+
+ expect(value_from_redis(:last_failure)).to be_empty
+ end
+
+ it 'wont connect to redis when there are no failures' do
+ expect(Gitlab::Git::Storage.redis).to receive(:with).once
+ .and_call_original
+ expect(circuit_breaker).to receive(:track_storage_accessible)
+ .and_call_original
+
+ circuit_breaker.track_storage_accessible
+ end
+ end
+
+ describe '#no_failures?' do
+ it 'is false when a failure was tracked' do
+ set_in_redis(:last_failure, Time.now.to_i)
+ set_in_redis(:failure_count, 1)
+
+ expect(circuit_breaker.no_failures?).to be_falsey
+ end
+ end
+
+ describe '#last_failure' do
+ it 'returns the last failure time' do
+ time = Time.parse("2017-05-26 17:52:30")
+ set_in_redis(:last_failure, time.to_i)
+
+ expect(circuit_breaker.last_failure).to eq(time)
+ end
+ end
+
+ describe '#failure_count' do
+ it 'returns the failure count' do
+ set_in_redis(:failure_count, 7)
+
+ expect(circuit_breaker.failure_count).to eq(7)
+ end
+ end
+
+ describe '#cache_key' do
+ it 'includes storage and host' do
+ expect(circuit_breaker.cache_key).to eq(cache_key)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/storage/forked_storage_check_spec.rb b/spec/lib/gitlab/git/storage/forked_storage_check_spec.rb
new file mode 100644
index 00000000000..12366151f44
--- /dev/null
+++ b/spec/lib/gitlab/git/storage/forked_storage_check_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe Gitlab::Git::Storage::ForkedStorageCheck, skip_database_cleaner: true do
+ let(:existing_path) do
+ existing_path = TestEnv.repos_path
+ FileUtils.mkdir_p(existing_path)
+ existing_path
+ end
+
+ describe '.storage_accessible?' do
+ it 'detects when a storage is not available' do
+ expect(described_class.storage_available?('/non/existant/path')).to be_falsey
+ end
+
+ it 'detects when a storage is available' do
+ expect(described_class.storage_available?(existing_path)).to be_truthy
+ end
+
+ it 'returns false when the check takes to long' do
+ # We're forking a process here that takes too long
+ # It will be killed it's parent process will be killed by it's parent
+ # and waited for inside `Gitlab::Git::Storage::ForkedStorageCheck.timeout_check`
+ allow(described_class).to receive(:check_filesystem_in_process) do
+ Process.spawn("sleep 10")
+ end
+ result = true
+
+ runtime = Benchmark.realtime do
+ result = described_class.storage_available?(existing_path, 0.5)
+ end
+
+ expect(result).to be_falsey
+ expect(runtime).to be < 1.0
+ end
+
+ describe 'when using paths with spaces' do
+ let(:test_dir) { Rails.root.join('tmp', 'tests', 'storage_check') }
+ let(:path_with_spaces) { File.join(test_dir, 'path with spaces') }
+
+ around do |example|
+ FileUtils.mkdir_p(path_with_spaces)
+ example.run
+ FileUtils.rm_r(test_dir)
+ end
+
+ it 'works for paths with spaces' do
+ expect(described_class.storage_available?(path_with_spaces)).to be_truthy
+ end
+
+ it 'works for a realpath with spaces' do
+ symlink_location = File.join(test_dir, 'a symlink')
+ FileUtils.ln_s(path_with_spaces, symlink_location)
+
+ expect(described_class.storage_available?(symlink_location)).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/storage/health_spec.rb b/spec/lib/gitlab/git/storage/health_spec.rb
new file mode 100644
index 00000000000..2d3af387971
--- /dev/null
+++ b/spec/lib/gitlab/git/storage/health_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+describe Gitlab::Git::Storage::Health, clean_gitlab_redis_shared_state: true, broken_storage: true do
+ let(:host1_key) { 'storage_accessible:broken:web01' }
+ let(:host2_key) { 'storage_accessible:default:kiq01' }
+
+ def set_in_redis(cache_key, value)
+ Gitlab::Git::Storage.redis.with do |redis|
+ redis.hmset(cache_key, :failure_count, value)
+ end.first
+ end
+
+ describe '.for_failing_storages' do
+ it 'only includes health status for failures' do
+ set_in_redis(host1_key, 10)
+ set_in_redis(host2_key, 0)
+
+ expect(described_class.for_failing_storages.map(&:storage_name))
+ .to contain_exactly('broken')
+ end
+ end
+
+ describe '.load_for_keys' do
+ let(:subject) do
+ results = Gitlab::Git::Storage.redis.with do |redis|
+ fake_future = double
+ allow(fake_future).to receive(:value).and_return([host1_key])
+ described_class.load_for_keys({ 'broken' => fake_future }, redis)
+ end
+
+ # Make sure the `Redis#future is loaded
+ results.inject({}) do |result, (name, info)|
+ info.each { |i| i[:failure_count] = i[:failure_count].value.to_i }
+
+ result[name] = info
+
+ result
+ end
+ end
+
+ it 'loads when there is no info in redis' do
+ expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 0 }])
+ end
+
+ it 'reads the correct values for a storage from redis' do
+ set_in_redis(host1_key, 5)
+ set_in_redis(host2_key, 7)
+
+ expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 5 }])
+ end
+ end
+
+ describe '.for_all_storages' do
+ it 'loads health status for all configured storages' do
+ healths = described_class.for_all_storages
+
+ expect(healths.map(&:storage_name)).to contain_exactly('default', 'broken')
+ end
+ end
+
+ describe '#failing_info' do
+ it 'only contains storages that have failures' do
+ health = described_class.new('broken', [{ name: host1_key, failure_count: 0 },
+ { name: host2_key, failure_count: 3 }])
+
+ expect(health.failing_info).to contain_exactly({ name: host2_key, failure_count: 3 })
+ end
+ end
+
+ describe '#total_failures' do
+ it 'sums up all the failures' do
+ health = described_class.new('broken', [{ name: host1_key, failure_count: 2 },
+ { name: host2_key, failure_count: 3 }])
+
+ expect(health.total_failures).to eq(5)
+ end
+ end
+
+ describe '#failing_on_hosts' do
+ it 'collects only the failing hostnames' do
+ health = described_class.new('broken', [{ name: host1_key, failure_count: 2 },
+ { name: host2_key, failure_count: 0 }])
+
+ expect(health.failing_on_hosts).to contain_exactly('web01')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb
index 67a9c974298..78d1e120013 100644
--- a/spec/lib/gitlab/git/tag_spec.rb
+++ b/spec/lib/gitlab/git/tag_spec.rb
@@ -3,23 +3,33 @@ require "spec_helper"
describe Gitlab::Git::Tag, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
- describe 'first tag' do
- let(:tag) { repository.tags.first }
+ shared_examples 'Gitlab::Git::Repository#tags' do
+ describe 'first tag' do
+ let(:tag) { repository.tags.first }
- it { expect(tag.name).to eq("v1.0.0") }
- it { expect(tag.target).to eq("f4e6814c3e4e7a0de82a9e7cd20c626cc963a2f8") }
- it { expect(tag.dereferenced_target.sha).to eq("6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9") }
- it { expect(tag.message).to eq("Release") }
- end
+ it { expect(tag.name).to eq("v1.0.0") }
+ it { expect(tag.target).to eq("f4e6814c3e4e7a0de82a9e7cd20c626cc963a2f8") }
+ it { expect(tag.dereferenced_target.sha).to eq("6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9") }
+ it { expect(tag.message).to eq("Release") }
+ end
+
+ describe 'last tag' do
+ let(:tag) { repository.tags.last }
- describe 'last tag' do
- let(:tag) { repository.tags.last }
+ it { expect(tag.name).to eq("v1.2.1") }
+ it { expect(tag.target).to eq("2ac1f24e253e08135507d0830508febaaccf02ee") }
+ it { expect(tag.dereferenced_target.sha).to eq("fa1b1e6c004a68b7d8763b86455da9e6b23e36d6") }
+ it { expect(tag.message).to eq("Version 1.2.1") }
+ end
- it { expect(tag.name).to eq("v1.2.1") }
- it { expect(tag.target).to eq("2ac1f24e253e08135507d0830508febaaccf02ee") }
- it { expect(tag.dereferenced_target.sha).to eq("fa1b1e6c004a68b7d8763b86455da9e6b23e36d6") }
- it { expect(tag.message).to eq("Version 1.2.1") }
+ it { expect(repository.tags.size).to eq(SeedRepo::Repo::TAGS.size) }
end
- it { expect(repository.tags.size).to eq(SeedRepo::Repo::TAGS.size) }
+ context 'when Gitaly tags feature is enabled' do
+ it_behaves_like 'Gitlab::Git::Repository#tags'
+ end
+
+ context 'when Gitaly tags feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'Gitlab::Git::Repository#tags'
+ end
end
diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb
index 4b76a43e6b5..98ddd3c3664 100644
--- a/spec/lib/gitlab/git/tree_spec.rb
+++ b/spec/lib/gitlab/git/tree_spec.rb
@@ -1,8 +1,9 @@
require "spec_helper"
describe Gitlab::Git::Tree, seed_helper: true do
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
+
context :repo do
- let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
let(:tree) { Gitlab::Git::Tree.where(repository, SeedRepo::Commit::ID) }
it { expect(tree).to be_kind_of Array }
@@ -74,4 +75,24 @@ describe Gitlab::Git::Tree, seed_helper: true do
it { expect(submodule.name).to eq('gitlab-shell') }
end
end
+
+ describe '#where' do
+ context 'with gitaly disabled' do
+ before do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false)
+ end
+
+ it 'calls #tree_entries_from_rugged' do
+ expect(described_class).to receive(:tree_entries_from_rugged)
+
+ described_class.where(repository, SeedRepo::Commit::ID, '/')
+ end
+ end
+
+ it 'gets the tree entries from GitalyClient' do
+ expect_any_instance_of(Gitlab::GitalyClient::CommitService).to receive(:tree_entries)
+
+ described_class.where(repository, SeedRepo::Commit::ID, '/')
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/util_spec.rb b/spec/lib/gitlab/git/util_spec.rb
index bcca4d4c746..88c871855df 100644
--- a/spec/lib/gitlab/git/util_spec.rb
+++ b/spec/lib/gitlab/git/util_spec.rb
@@ -6,10 +6,10 @@ describe Gitlab::Git::Util do
["", 0],
["foo", 1],
["foo\n", 1],
- ["foo\n\n", 2],
+ ["foo\n\n", 2]
].each do |string, line_count|
it "counts #{line_count} lines in #{string.inspect}" do
- expect(Gitlab::Git::Util.count_lines(string)).to eq(line_count)
+ expect(described_class.count_lines(string)).to eq(line_count)
end
end
end
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index d8b72615fab..2d6ea37d0ac 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -1,10 +1,14 @@
require 'spec_helper'
-describe Gitlab::GitAccess, lib: true do
- let(:access) { Gitlab::GitAccess.new(actor, project, 'web', authentication_abilities: authentication_abilities) }
+describe Gitlab::GitAccess do
+ let(:pull_access_check) { access.check('git-upload-pack', '_any') }
+ let(:push_access_check) { access.check('git-receive-pack', '_any') }
+ let(:access) { described_class.new(actor, project, protocol, authentication_abilities: authentication_abilities, redirected_path: redirected_path) }
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:actor) { user }
+ let(:protocol) { 'ssh' }
+ let(:redirected_path) { nil }
let(:authentication_abilities) do
[
:read_project,
@@ -15,58 +19,246 @@ describe Gitlab::GitAccess, lib: true do
describe '#check with single protocols allowed' do
def disable_protocol(protocol)
- settings = ::ApplicationSetting.create_from_defaults
- settings.update_attribute(:enabled_git_access_protocol, protocol)
+ allow(Gitlab::ProtocolAccess).to receive(:allowed?).with(protocol).and_return(false)
end
context 'ssh disabled' do
before do
disable_protocol('ssh')
- @acc = Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities)
end
it 'blocks ssh git push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
it 'blocks ssh git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
end
context 'http disabled' do
+ let(:protocol) { 'http' }
+
before do
disable_protocol('http')
- @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities)
end
it 'blocks http push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
it 'blocks http git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
end
end
- describe '#check_download_access!' do
- subject { access.check('git-upload-pack', '_any') }
+ describe '#check_project_accessibility!' do
+ context 'when the project exists' do
+ context 'when actor exists' do
+ context 'when actor is a DeployKey' do
+ let(:deploy_key) { create(:deploy_key, user: user, can_push: true) }
+ let(:actor) { deploy_key }
+
+ context 'when the DeployKey has access to the project' do
+ before do
+ deploy_key.projects << project
+ end
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the Deploykey does not have access to the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ context 'when actor is a User' do
+ context 'when the User can read the project' do
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the User cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ # For backwards compatibility
+ context 'when actor is :ci' do
+ let(:actor) { :ci }
+ let(:authentication_abilities) { build_authentication_abilities }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+ end
+
+ context 'when actor is nil' do
+ let(:actor) { nil }
+
+ context 'when guests can read the project' do
+ let(:project) { create(:project, :repository, :public) }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+
+ context 'when guests cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+ end
+
+ context 'when the project is nil' do
+ let(:project) { nil }
+
+ it 'blocks any command with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ describe '#check_project_moved!' do
+ before do
+ project.team << [user, :master]
+ end
+
+ context 'when a redirect was not followed to find the project' do
+ context 'pull code' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+
+ context 'push code' do
+ it { expect { push_access_check }.not_to raise_error }
+ end
+ end
+
+ context 'when a redirect was followed to find the project' do
+ let(:redirected_path) { 'some/other-path' }
+
+ context 'pull code' do
+ it { expect { pull_access_check }.to raise_not_found(/Project '#{redirected_path}' was moved to '#{project.full_path}'/) }
+ it { expect { pull_access_check }.to raise_not_found(/git remote set-url origin #{project.ssh_url_to_repo}/) }
+
+ context 'http protocol' do
+ let(:protocol) { 'http' }
+ it { expect { pull_access_check }.to raise_not_found(/git remote set-url origin #{project.http_url_to_repo}/) }
+ end
+ end
+
+ context 'push code' do
+ it { expect { push_access_check }.to raise_not_found(/Project '#{redirected_path}' was moved to '#{project.full_path}'/) }
+ it { expect { push_access_check }.to raise_not_found(/git remote set-url origin #{project.ssh_url_to_repo}/) }
+
+ context 'http protocol' do
+ let(:protocol) { 'http' }
+ it { expect { push_access_check }.to raise_not_found(/git remote set-url origin #{project.http_url_to_repo}/) }
+ end
+ end
+ end
+ end
+
+ describe '#check_command_disabled!' do
+ before do
+ project.team << [user, :master]
+ end
+ context 'over http' do
+ let(:protocol) { 'http' }
+
+ context 'when the git-upload-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.to raise_unauthorized('Pulling over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.not_to raise_error }
+ end
+ end
+
+ context 'when the git-receive-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.to raise_unauthorized('Pushing over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
+ end
+ end
+
+ describe '#check_download_access!' do
describe 'master permissions' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
context 'pull code' do
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
describe 'guest permissions' do
- before { project.team << [user, :guest] }
+ before do
+ project.team << [user, :guest]
+ end
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/You are not allowed to download code/) }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
@@ -77,24 +269,22 @@ describe Gitlab::GitAccess, lib: true do
end
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/Your account has been blocked/) }
+ it { expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') }
end
end
describe 'without access to project' do
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'when project is public' do
let(:public_project) { create(:project, :public, :repository) }
- let(:guest_access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) }
- subject { guest_access.check('git-upload-pack', '_any') }
+ let(:access) { described_class.new(nil, public_project, 'web', authentication_abilities: []) }
context 'when repository is enabled' do
it 'give access to download code' do
- expect(subject.allowed?).to be_truthy
+ expect { pull_access_check }.not_to raise_error
end
end
@@ -102,8 +292,7 @@ describe Gitlab::GitAccess, lib: true do
it 'does not give access to download code' do
public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.')
end
end
end
@@ -115,28 +304,30 @@ describe Gitlab::GitAccess, lib: true do
context 'pull code' do
context 'when project is authorized' do
- before { key.projects << project }
+ before do
+ key.projects << project
+ end
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'when unauthorized' do
context 'from public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'from internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'from private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -149,15 +340,17 @@ describe Gitlab::GitAccess, lib: true do
let(:project) { create(:project, :repository, namespace: user.namespace) }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
describe 'reporter user' do
- before { project.team << [user, :reporter] }
+ before do
+ project.team << [user, :reporter]
+ end
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -165,24 +358,36 @@ describe Gitlab::GitAccess, lib: true do
let(:user) { create(:admin) }
context 'when member of the project' do
- before { project.team << [user, :reporter] }
+ before do
+ project.team << [user, :reporter]
+ end
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
context 'when is not member of the project' do
context 'pull code' do
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
end
+
+ describe 'generic CI (build without a user)' do
+ let(:actor) { :ci }
+
+ context 'pull code' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
end
end
describe '#check_push_access!' do
- before { merge_into_protected_branch }
+ before do
+ merge_into_protected_branch
+ end
let(:unprotected_branch) { 'unprotected_branch' }
let(:changes) do
@@ -236,7 +441,7 @@ describe Gitlab::GitAccess, lib: true do
end
permissions_matrix[role].each do |action, allowed|
- context action do
+ context action.to_s do
subject { access.send(:check_push_access!, changes[action]) }
it do
@@ -311,19 +516,25 @@ describe Gitlab::GitAccess, lib: true do
[%w(feature exact), ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type|
context do
- before { create(:protected_branch, name: protected_branch_name, project: project) }
+ before do
+ create(:protected_branch, name: protected_branch_name, project: project)
+ end
run_permission_checks(permissions_matrix)
end
context "when developers are allowed to push into the #{protected_branch_type} protected branch" do
- before { create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) }
+ before do
+ create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project)
+ end
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
context "developers are allowed to merge into the #{protected_branch_type} protected branch" do
- before { create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) }
+ before do
+ create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project)
+ end
context "when a merge request exists for the given source/target branch" do
context "when the merge request is in progress" do
@@ -350,13 +561,17 @@ describe Gitlab::GitAccess, lib: true do
end
context "when developers are allowed to push and merge into the #{protected_branch_type} protected branch" do
- before { create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) }
+ before do
+ create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project)
+ end
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
context "when no one is allowed to push to the #{protected_branch_name} protected branch" do
- before { create(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project) }
+ before do
+ create(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project)
+ end
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false },
master: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false },
@@ -365,42 +580,34 @@ describe Gitlab::GitAccess, lib: true do
end
end
- shared_examples 'pushing code' do |can|
- subject { access.check('git-receive-pack', '_any') }
+ describe 'build authentication abilities' do
+ let(:authentication_abilities) { build_authentication_abilities }
context 'when project is authorized' do
- before { authorize }
+ before do
+ project.team << [user, :reporter]
+ end
- it { expect(subject).public_send(can, be_allowed) }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'when unauthorized' do
context 'to public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
- end
- end
- end
-
- describe 'build authentication abilities' do
- let(:authentication_abilities) { build_authentication_abilities }
-
- it_behaves_like 'pushing code', :not_to do
- def authorize
- project.team << [user, :reporter]
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -412,26 +619,78 @@ describe Gitlab::GitAccess, lib: true do
context 'when deploy_key can push' do
let(:can_push) { true }
- it_behaves_like 'pushing code', :to do
- def authorize
+ context 'when project is authorized' do
+ before do
key.projects << project
end
+
+ it { expect { push_access_check }.not_to raise_error }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
end
end
context 'when deploy_key cannot push' do
let(:can_push) { false }
- it_behaves_like 'pushing code', :not_to do
- def authorize
+ context 'when project is authorized' do
+ before do
key.projects << project
end
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
end
end
end
private
+ def raise_unauthorized(message)
+ raise_error(Gitlab::GitAccess::UnauthorizedError, message)
+ end
+
+ def raise_not_found(message)
+ raise_error(Gitlab::GitAccess::NotFoundError, message)
+ end
+
def build_authentication_abilities
[
:read_project,
diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb
index 1ae293416e4..0376b4ee783 100644
--- a/spec/lib/gitlab/git_access_wiki_spec.rb
+++ b/spec/lib/gitlab/git_access_wiki_spec.rb
@@ -1,9 +1,10 @@
require 'spec_helper'
-describe Gitlab::GitAccessWiki, lib: true do
- let(:access) { Gitlab::GitAccessWiki.new(user, project, 'web', authentication_abilities: authentication_abilities) }
+describe Gitlab::GitAccessWiki do
+ let(:access) { described_class.new(user, project, 'web', authentication_abilities: authentication_abilities, redirected_path: redirected_path) }
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
+ let(:redirected_path) { nil }
let(:authentication_abilities) do
[
:read_project,
@@ -20,7 +21,7 @@ describe Gitlab::GitAccessWiki, lib: true do
subject { access.check('git-receive-pack', changes) }
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { subject }.not_to raise_error }
end
def changes
@@ -36,7 +37,7 @@ describe Gitlab::GitAccessWiki, lib: true do
context 'when wiki feature is enabled' do
it 'give access to download wiki code' do
- expect(subject.allowed?).to be_truthy
+ expect { subject }.not_to raise_error
end
end
@@ -44,8 +45,7 @@ describe Gitlab::GitAccessWiki, lib: true do
it 'does not give access to download wiki code' do
project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to download code from this project.')
end
end
end
diff --git a/spec/lib/gitlab/git_ref_validator_spec.rb b/spec/lib/gitlab/git_ref_validator_spec.rb
new file mode 100644
index 00000000000..e1fa8ae03f8
--- /dev/null
+++ b/spec/lib/gitlab/git_ref_validator_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Gitlab::GitRefValidator do
+ it { expect(described_class.validate('feature/new')).to be_truthy }
+ it { expect(described_class.validate('implement_@all')).to be_truthy }
+ it { expect(described_class.validate('my_new_feature')).to be_truthy }
+ it { expect(described_class.validate('#1')).to be_truthy }
+ it { expect(described_class.validate('feature/refs/heads/foo')).to be_truthy }
+ it { expect(described_class.validate('feature/~new/')).to be_falsey }
+ it { expect(described_class.validate('feature/^new/')).to be_falsey }
+ it { expect(described_class.validate('feature/:new/')).to be_falsey }
+ it { expect(described_class.validate('feature/?new/')).to be_falsey }
+ it { expect(described_class.validate('feature/*new/')).to be_falsey }
+ it { expect(described_class.validate('feature/[new/')).to be_falsey }
+ it { expect(described_class.validate('feature/new/')).to be_falsey }
+ it { expect(described_class.validate('feature/new.')).to be_falsey }
+ it { expect(described_class.validate('feature\@{')).to be_falsey }
+ it { expect(described_class.validate('feature\new')).to be_falsey }
+ it { expect(described_class.validate('feature//new')).to be_falsey }
+ it { expect(described_class.validate('feature new')).to be_falsey }
+ it { expect(described_class.validate('refs/heads/')).to be_falsey }
+ it { expect(described_class.validate('refs/remotes/')).to be_falsey }
+ it { expect(described_class.validate('refs/heads/feature')).to be_falsey }
+ it { expect(described_class.validate('refs/remotes/origin')).to be_falsey }
+end
diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb
index 36f0e6507c8..4702a978f19 100644
--- a/spec/lib/gitlab/git_spec.rb
+++ b/spec/lib/gitlab/git_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::Git, lib: true do
+describe Gitlab::Git do
let(:committer_email) { 'user@example.org' }
let(:committer_name) { 'John Doe' }
describe 'committer_hash' do
it "returns a hash containing the given email and name" do
- committer_hash = Gitlab::Git.committer_hash(email: committer_email, name: committer_name)
+ committer_hash = described_class.committer_hash(email: committer_email, name: committer_name)
expect(committer_hash[:email]).to eq(committer_email)
expect(committer_hash[:name]).to eq(committer_name)
@@ -15,7 +15,7 @@ describe Gitlab::Git, lib: true do
context 'when email is nil' do
it "returns nil" do
- committer_hash = Gitlab::Git.committer_hash(email: nil, name: committer_name)
+ committer_hash = described_class.committer_hash(email: nil, name: committer_name)
expect(committer_hash).to be_nil
end
@@ -23,7 +23,7 @@ describe Gitlab::Git, lib: true do
context 'when name is nil' do
it "returns nil" do
- committer_hash = Gitlab::Git.committer_hash(email: committer_email, name: nil)
+ committer_hash = described_class.committer_hash(email: committer_email, name: nil)
expect(committer_hash).to be_nil
end
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
new file mode 100644
index 00000000000..7fe698fcb18
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -0,0 +1,129 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::CommitService do
+ let(:project) { create(:project, :repository) }
+ let(:storage_name) { project.repository_storage }
+ let(:relative_path) { project.disk_path + '.git' }
+ let(:repository) { project.repository }
+ let(:repository_message) { repository.gitaly_repository }
+ let(:revision) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' }
+ let(:commit) { project.commit(revision) }
+ let(:client) { described_class.new(repository) }
+
+ describe '#diff_from_parent' do
+ context 'when a commit has a parent' do
+ it 'sends an RPC request with the parent ID as left commit' do
+ request = Gitaly::CommitDiffRequest.new(
+ repository: repository_message,
+ left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660',
+ right_commit_id: commit.id,
+ collapse_diffs: true,
+ enforce_limits: true,
+ **Gitlab::Git::DiffCollection.collection_limits.to_h
+ )
+
+ expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash))
+
+ client.diff_from_parent(commit)
+ end
+ end
+
+ context 'when a commit does not have a parent' do
+ it 'sends an RPC request with empty tree ref as left commit' do
+ initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863').raw
+ request = Gitaly::CommitDiffRequest.new(
+ repository: repository_message,
+ left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ right_commit_id: initial_commit.id,
+ collapse_diffs: true,
+ enforce_limits: true,
+ **Gitlab::Git::DiffCollection.collection_limits.to_h
+ )
+
+ expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash))
+
+ client.diff_from_parent(initial_commit)
+ end
+ end
+
+ it 'returns a Gitlab::GitalyClient::DiffStitcher' do
+ ret = client.diff_from_parent(commit)
+
+ expect(ret).to be_kind_of(Gitlab::GitalyClient::DiffStitcher)
+ end
+ end
+
+ describe '#commit_deltas' do
+ context 'when a commit has a parent' do
+ it 'sends an RPC request with the parent ID as left commit' do
+ request = Gitaly::CommitDeltaRequest.new(
+ repository: repository_message,
+ left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660',
+ right_commit_id: commit.id
+ )
+
+ expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_delta).with(request, kind_of(Hash)).and_return([])
+
+ client.commit_deltas(commit)
+ end
+ end
+
+ context 'when a commit does not have a parent' do
+ it 'sends an RPC request with empty tree ref as left commit' do
+ initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863')
+ request = Gitaly::CommitDeltaRequest.new(
+ repository: repository_message,
+ left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ right_commit_id: initial_commit.id
+ )
+
+ expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_delta).with(request, kind_of(Hash)).and_return([])
+
+ client.commit_deltas(initial_commit)
+ end
+ end
+ end
+
+ describe '#between' do
+ let(:from) { 'master' }
+ let(:to) { '4b825dc642cb6eb9a060e54bf8d69288fbee4904' }
+
+ it 'sends an RPC request' do
+ request = Gitaly::CommitsBetweenRequest.new(
+ repository: repository_message, from: from, to: to
+ )
+
+ expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:commits_between)
+ .with(request, kind_of(Hash)).and_return([])
+
+ described_class.new(repository).between(from, to)
+ end
+ end
+
+ describe '#tree_entries' do
+ let(:path) { '/' }
+
+ it 'sends a get_tree_entries message' do
+ expect_any_instance_of(Gitaly::CommitService::Stub)
+ .to receive(:get_tree_entries)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([])
+
+ client.tree_entries(repository, revision, path)
+ end
+ end
+
+ describe '#find_commit' do
+ let(:revision) { '4b825dc642cb6eb9a060e54bf8d69288fbee4904' }
+ it 'sends an RPC request' do
+ request = Gitaly::FindCommitRequest.new(
+ repository: repository_message, revision: revision
+ )
+
+ expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit)
+ .with(request, kind_of(Hash)).and_return(double(commit: nil))
+
+ described_class.new(repository).find_commit(revision)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb
deleted file mode 100644
index 58f11ff8906..00000000000
--- a/spec/lib/gitlab/gitaly_client/commit_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require 'spec_helper'
-
-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) { project.repository.gitaly_repository }
- let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') }
-
- before do
- allow(Gitaly::Diff::Stub).to receive(:new).and_return(diff_stub)
- allow(diff_stub).to receive(:commit_diff).and_return([])
- end
-
- context 'when a commit has a parent' do
- it 'sends an RPC request with the parent ID as left commit' do
- request = Gitaly::CommitDiffRequest.new(
- repository: repository_message,
- left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660',
- right_commit_id: commit.id,
- )
-
- expect(diff_stub).to receive(:commit_diff).with(request)
-
- described_class.diff_from_parent(commit)
- end
- end
-
- context 'when a commit does not have a parent' do
- it 'sends an RPC request with empty tree ref as left commit' do
- initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863')
- request = Gitaly::CommitDiffRequest.new(
- repository: repository_message,
- left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
- right_commit_id: initial_commit.id,
- )
-
- expect(diff_stub).to receive(:commit_diff).with(request)
-
- described_class.diff_from_parent(initial_commit)
- end
- end
-
- it 'returns a Gitlab::Git::DiffCollection' do
- ret = described_class.diff_from_parent(commit)
-
- expect(ret).to be_kind_of(Gitlab::Git::DiffCollection)
- end
-
- it 'passes options to Gitlab::Git::DiffCollection' do
- options = { max_files: 31, max_lines: 13 }
-
- expect(Gitlab::Git::DiffCollection).to receive(:new).with([], options)
-
- described_class.diff_from_parent(commit, options)
- end
- end
-end
diff --git a/spec/lib/gitlab/gitaly_client/diff_spec.rb b/spec/lib/gitlab/gitaly_client/diff_spec.rb
new file mode 100644
index 00000000000..00a31ac0b96
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/diff_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::Diff do
+ let(:diff_fields) do
+ {
+ to_path: ".gitmodules",
+ from_path: ".gitmodules",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ }
+ end
+
+ subject { described_class.new(diff_fields) }
+
+ it { is_expected.to respond_to(:from_path) }
+ it { is_expected.to respond_to(:to_path) }
+ it { is_expected.to respond_to(:old_mode) }
+ it { is_expected.to respond_to(:new_mode) }
+ it { is_expected.to respond_to(:from_id) }
+ it { is_expected.to respond_to(:to_id) }
+ it { is_expected.to respond_to(:patch) }
+
+ describe '#==' do
+ it { expect(subject).to eq(described_class.new(diff_fields)) }
+ it { expect(subject).not_to eq(described_class.new(diff_fields.merge(patch: 'a'))) }
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb
new file mode 100644
index 00000000000..cd3242b9326
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::DiffStitcher do
+ describe 'enumeration' do
+ it 'combines segregated diff messages together' do
+ diff_1 = OpenStruct.new(
+ to_path: ".gitmodules",
+ from_path: ".gitmodules",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ )
+ diff_2 = OpenStruct.new(
+ to_path: ".gitignore",
+ from_path: ".gitignore",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 200
+ )
+ diff_3 = OpenStruct.new(
+ to_path: "README",
+ from_path: "README",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ patch: 'a' * 100
+ )
+
+ msg_1 = OpenStruct.new(diff_1.to_h.except(:patch))
+ msg_1.raw_patch_data = diff_1.patch
+ msg_1.end_of_patch = true
+
+ msg_2 = OpenStruct.new(diff_2.to_h.except(:patch))
+ msg_2.raw_patch_data = diff_2.patch[0..100]
+ msg_2.end_of_patch = false
+
+ msg_3 = OpenStruct.new(raw_patch_data: diff_2.patch[101..-1], end_of_patch: true)
+
+ msg_4 = OpenStruct.new(diff_3.to_h.except(:patch))
+ msg_4.raw_patch_data = diff_3.patch
+ msg_4.end_of_patch = true
+
+ diff_msgs = [msg_1, msg_2, msg_3, msg_4]
+
+ expected_diffs = [
+ Gitlab::GitalyClient::Diff.new(diff_1.to_h),
+ Gitlab::GitalyClient::Diff.new(diff_2.to_h),
+ Gitlab::GitalyClient::Diff.new(diff_3.to_h)
+ ]
+
+ expect(described_class.new(diff_msgs).to_a).to eq(expected_diffs)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/notification_service_spec.rb b/spec/lib/gitlab/gitaly_client/notification_service_spec.rb
new file mode 100644
index 00000000000..ffc3a09be30
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/notification_service_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::NotificationService do
+ describe '#post_receive' do
+ let(:project) { create(:project) }
+ let(:storage_name) { project.repository_storage }
+ let(:relative_path) { project.disk_path + '.git' }
+ subject { described_class.new(project.repository) }
+
+ it 'sends a post_receive message' do
+ expect_any_instance_of(Gitaly::NotificationService::Stub)
+ .to receive(:post_receive).with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+
+ subject.post_receive
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/notifications_spec.rb b/spec/lib/gitlab/gitaly_client/notifications_spec.rb
deleted file mode 100644
index b87dacb175b..00000000000
--- a/spec/lib/gitlab/gitaly_client/notifications_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require 'spec_helper'
-
-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) }
-
- it 'sends a post_receive message' do
- expect_any_instance_of(Gitaly::Notifications::Stub).
- to receive(:post_receive).with(gitaly_request_with_repo_path(repo_path))
-
- subject.post_receive
- end
- end
-end
diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
new file mode 100644
index 00000000000..46efc1b18f0
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::RefService do
+ let(:project) { create(:project) }
+ let(:storage_name) { project.repository_storage }
+ let(:relative_path) { project.disk_path + '.git' }
+ let(:client) { described_class.new(project.repository) }
+
+ describe '#branches' do
+ it 'sends a find_all_branches message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_all_branches)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([])
+
+ client.branches
+ end
+ end
+
+ describe '#branch_names' do
+ it 'sends a find_all_branch_names message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_all_branch_names)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([])
+
+ client.branch_names
+ end
+ end
+
+ describe '#tag_names' do
+ it 'sends a find_all_tag_names message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_all_tag_names)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([])
+
+ client.tag_names
+ end
+ end
+
+ describe '#default_branch_name' do
+ it 'sends a find_default_branch_name message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_default_branch_name)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(name: 'foo'))
+
+ client.default_branch_name
+ end
+ end
+
+ describe '#local_branches' do
+ it 'sends a find_local_branches message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_local_branches)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([])
+
+ client.local_branches
+ end
+
+ it 'parses and sends the sort parameter' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_local_branches)
+ .with(gitaly_request_with_params(sort_by: :UPDATED_DESC), kind_of(Hash))
+ .and_return([])
+
+ client.local_branches(sort_by: 'updated_desc')
+ end
+
+ it 'translates known mismatches on sort param values' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_local_branches)
+ .with(gitaly_request_with_params(sort_by: :NAME), kind_of(Hash))
+ .and_return([])
+
+ client.local_branches(sort_by: 'name_asc')
+ end
+
+ it 'raises an argument error if an invalid sort_by parameter is passed' do
+ expect { client.local_branches(sort_by: 'invalid_sort') }.to raise_error(ArgumentError)
+ end
+ end
+
+ describe '#find_ref_name', seed_helper: true do
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
+ let(:client) { described_class.new(repository) }
+ subject { client.find_ref_name(SeedRepo::Commit::ID, 'refs/heads/master') }
+
+ it { is_expected.to be_utf8 }
+ it { is_expected.to eq('refs/heads/master') }
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb
deleted file mode 100644
index 5405eafd281..00000000000
--- a/spec/lib/gitlab/gitaly_client/ref_spec.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-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) }
-
- before do
- allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true)
- end
-
- describe '#branch_names' do
- it 'sends a find_all_branch_names message' do
- expect_any_instance_of(Gitaly::Ref::Stub).
- to receive(:find_all_branch_names).with(gitaly_request_with_repo_path(repo_path)).
- and_return([])
-
- client.branch_names
- end
- end
-
- describe '#tag_names' do
- it 'sends a find_all_tag_names message' do
- expect_any_instance_of(Gitaly::Ref::Stub).
- to receive(:find_all_tag_names).with(gitaly_request_with_repo_path(repo_path)).
- and_return([])
-
- client.tag_names
- end
- end
-
- describe '#default_branch_name' do
- it 'sends a find_default_branch_name message' do
- expect_any_instance_of(Gitaly::Ref::Stub).
- to receive(:find_default_branch_name).with(gitaly_request_with_repo_path(repo_path)).
- and_return(double(name: 'foo'))
-
- client.default_branch_name
- end
- end
-end
diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb
index 55fcf91fb6e..921e786a55c 100644
--- a/spec/lib/gitlab/gitaly_client_spec.rb
+++ b/spec/lib/gitlab/gitaly_client_spec.rb
@@ -1,14 +1,24 @@
require 'spec_helper'
-describe Gitlab::GitalyClient, lib: true do
- describe '.new_channel' do
+# We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want
+# those stubs while testing the GitalyClient itself.
+describe Gitlab::GitalyClient, skip_gitaly_mock: true do
+ describe '.stub' do
+ # Notice that this is referring to gRPC "stubs", not rspec stubs
+ before do
+ described_class.clear_stubs!
+ end
+
context 'when passed a UNIX socket address' do
- it 'passes the address as-is to GRPC::Core::Channel initializer' do
+ it 'passes the address as-is to GRPC' do
address = 'unix:/tmp/gitaly.sock'
+ allow(Gitlab.config.repositories).to receive(:storages).and_return({
+ 'default' => { 'gitaly_address' => address }
+ })
- expect(GRPC::Core::Channel).to receive(:new).with(address, any_args)
+ expect(Gitaly::CommitService::Stub).to receive(:new).with(address, any_args)
- described_class.new_channel(address)
+ described_class.stub(:commit_service, 'default')
end
end
@@ -17,9 +27,100 @@ describe Gitlab::GitalyClient, lib: true do
address = 'localhost:9876'
prefixed_address = "tcp://#{address}"
- expect(GRPC::Core::Channel).to receive(:new).with(address, any_args)
+ allow(Gitlab.config.repositories).to receive(:storages).and_return({
+ 'default' => { 'gitaly_address' => prefixed_address }
+ })
+
+ expect(Gitaly::CommitService::Stub).to receive(:new).with(address, any_args)
+
+ described_class.stub(:commit_service, 'default')
+ end
+ end
+ end
+
+ describe 'feature_enabled?' do
+ let(:feature_name) { 'my_feature' }
+ let(:real_feature_name) { "gitaly_#{feature_name}" }
+
+ context 'when Gitaly is disabled' do
+ before do
+ allow(described_class).to receive(:enabled?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(described_class.feature_enabled?(feature_name)).to be(false)
+ end
+ end
+
+ context 'when the feature status is DISABLED' do
+ let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::DISABLED }
+
+ it 'returns false' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
+ end
+ end
+
+ context 'when the feature_status is OPT_IN' do
+ let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_IN }
+
+ context "when the feature flag hasn't been set" do
+ it 'returns false' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
+ end
+ end
+
+ context "when the feature flag is set to disable" do
+ before do
+ Feature.get(real_feature_name).disable
+ end
+
+ it 'returns false' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
+ end
+ end
+
+ context "when the feature flag is set to enable" do
+ before do
+ Feature.get(real_feature_name).enable
+ end
+
+ it 'returns true' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
+ end
+ end
+
+ context "when the feature flag is set to a percentage of time" do
+ before do
+ Feature.get(real_feature_name).enable_percentage_of_time(70)
+ end
+
+ it 'bases the result on pseudo-random numbers' do
+ expect(Random).to receive(:rand).and_return(0.3)
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
+
+ expect(Random).to receive(:rand).and_return(0.8)
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
+ end
+ end
+ end
+
+ context 'when the feature_status is OPT_OUT' do
+ let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_OUT }
+
+ context "when the feature flag hasn't been set" do
+ it 'returns true' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
+ end
+ end
+
+ context "when the feature flag is set to disable" do
+ before do
+ Feature.get(real_feature_name).disable
+ end
- described_class.new_channel(prefixed_address)
+ it 'returns false' do
+ expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
+ end
end
end
end
diff --git a/spec/lib/gitlab/github_import/branch_formatter_spec.rb b/spec/lib/gitlab/github_import/branch_formatter_spec.rb
index 3a31f93efa5..426b43f8b51 100644
--- a/spec/lib/gitlab/github_import/branch_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/branch_formatter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::BranchFormatter, lib: true do
+describe Gitlab::GithubImport::BranchFormatter do
let(:project) { create(:project, :repository) }
let(:commit) { create(:commit, project: project) }
let(:repo) { double }
diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb
index 21f2a9e225b..66273255b6f 100644
--- a/spec/lib/gitlab/github_import/client_spec.rb
+++ b/spec/lib/gitlab/github_import/client_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::Client, lib: true do
+describe Gitlab::GithubImport::Client do
let(:token) { '123456' }
let(:github_provider) { Settingslogic.new('app_id' => 'asd123', 'app_secret' => 'asd123', 'name' => 'github', 'args' => { 'client_options' => {} }) }
diff --git a/spec/lib/gitlab/github_import/comment_formatter_spec.rb b/spec/lib/gitlab/github_import/comment_formatter_spec.rb
index cc38872e426..035ac8c7c1f 100644
--- a/spec/lib/gitlab/github_import/comment_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/comment_formatter_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::GithubImport::CommentFormatter, lib: true do
+describe Gitlab::GithubImport::CommentFormatter do
let(:client) { double }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') }
let(:created_at) { DateTime.strptime('2013-04-10T20:09:31Z') }
let(:updated_at) { DateTime.strptime('2014-03-03T18:58:10Z') }
diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb
index 9d5e20841b5..d570f34985b 100644
--- a/spec/lib/gitlab/github_import/importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::Importer, lib: true do
+describe Gitlab::GithubImport::Importer do
shared_examples 'Gitlab::GithubImport::Importer#execute' do
let(:expected_not_called) { [] }
@@ -207,7 +207,7 @@ describe Gitlab::GithubImport::Importer, lib: true do
end
end
- let(:project) { create(:project, :wiki_disabled, import_url: "#{repo_root}/octocat/Hello-World.git") }
+ let(:project) { create(:project, :repository, :wiki_disabled, import_url: "#{repo_root}/octocat/Hello-World.git") }
let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') }
let(:credentials) { { user: 'joe' } }
diff --git a/spec/lib/gitlab/github_import/issuable_formatter_spec.rb b/spec/lib/gitlab/github_import/issuable_formatter_spec.rb
index 6bc5f98ed2c..05294d227bd 100644
--- a/spec/lib/gitlab/github_import/issuable_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/issuable_formatter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::IssuableFormatter, lib: true do
+describe Gitlab::GithubImport::IssuableFormatter do
let(:raw_data) do
double(number: 42)
end
diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb
index f34d09f2c1d..0fc56d92aa6 100644
--- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::GithubImport::IssueFormatter, lib: true do
+describe Gitlab::GithubImport::IssueFormatter do
let(:client) { double }
- let!(:project) { create(:empty_project, namespace: create(:namespace, path: 'octocat')) }
+ let!(:project) { create(:project, namespace: create(:namespace, path: 'octocat')) }
let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') }
let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') }
let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') }
@@ -43,7 +43,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do
description: "*Created by: octocat*\n\nI'm having a problem with this.",
state: 'opened',
author_id: project.creator_id,
- assignee_id: nil,
+ assignee_ids: [],
created_at: created_at,
updated_at: updated_at
}
@@ -64,7 +64,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do
description: "*Created by: octocat*\n\nI'm having a problem with this.",
state: 'closed',
author_id: project.creator_id,
- assignee_id: nil,
+ assignee_ids: [],
created_at: created_at,
updated_at: updated_at
}
@@ -77,19 +77,19 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do
let(:raw_data) { double(base_data.merge(assignee: octocat)) }
it 'returns nil as assignee_id when is not a GitLab user' do
- expect(issue.attributes.fetch(:assignee_id)).to be_nil
+ expect(issue.attributes.fetch(:assignee_ids)).to be_empty
end
it 'returns GitLab user id associated with GitHub id as assignee_id' do
gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github')
- expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id
+ expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id]
end
it 'returns GitLab user id associated with GitHub email as assignee_id' do
gl_user = create(:user, email: octocat.email)
- expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id
+ expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id]
end
end
diff --git a/spec/lib/gitlab/github_import/label_formatter_spec.rb b/spec/lib/gitlab/github_import/label_formatter_spec.rb
index 565435824fd..83fdd2cc415 100644
--- a/spec/lib/gitlab/github_import/label_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/label_formatter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::GithubImport::LabelFormatter, lib: true do
- let(:project) { create(:empty_project) }
+describe Gitlab::GithubImport::LabelFormatter do
+ let(:project) { create(:project) }
let(:raw) { double(name: 'improvements', color: 'e6e6e6') }
subject { described_class.new(project, raw) }
diff --git a/spec/lib/gitlab/github_import/milestone_formatter_spec.rb b/spec/lib/gitlab/github_import/milestone_formatter_spec.rb
index 6d38041c468..683fa51b78e 100644
--- a/spec/lib/gitlab/github_import/milestone_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/milestone_formatter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::GithubImport::MilestoneFormatter, lib: true do
- let(:project) { create(:empty_project) }
+describe Gitlab::GithubImport::MilestoneFormatter do
+ let(:project) { create(:project) }
let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') }
let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') }
let(:base_data) do
diff --git a/spec/lib/gitlab/github_import/project_creator_spec.rb b/spec/lib/gitlab/github_import/project_creator_spec.rb
index a73b1f4ff5d..948e7469a18 100644
--- a/spec/lib/gitlab/github_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/github_import/project_creator_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::ProjectCreator, lib: true do
+describe Gitlab::GithubImport::ProjectCreator do
let(:user) { create(:user) }
let(:namespace) { create(:group, owner: user) }
diff --git a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
index b7c59918a76..2e42f6239b7 100644
--- a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::PullRequestFormatter, lib: true do
+describe Gitlab::GithubImport::PullRequestFormatter do
let(:client) { double }
let(:project) { create(:project, :repository) }
let(:source_sha) { create(:commit, project: project).id }
diff --git a/spec/lib/gitlab/github_import/release_formatter_spec.rb b/spec/lib/gitlab/github_import/release_formatter_spec.rb
index 13b15e669ab..926bf725d6a 100644
--- a/spec/lib/gitlab/github_import/release_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/release_formatter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::GithubImport::ReleaseFormatter, lib: true do
- let!(:project) { create(:empty_project, namespace: create(:namespace, path: 'octocat')) }
+describe Gitlab::GithubImport::ReleaseFormatter do
+ let!(:project) { create(:project, namespace: create(:namespace, path: 'octocat')) }
let(:octocat) { double(id: 123456, login: 'octocat') }
let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') }
diff --git a/spec/lib/gitlab/github_import/user_formatter_spec.rb b/spec/lib/gitlab/github_import/user_formatter_spec.rb
index db792233657..98e3a7c28b9 100644
--- a/spec/lib/gitlab/github_import/user_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/user_formatter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::UserFormatter, lib: true do
+describe Gitlab::GithubImport::UserFormatter do
let(:client) { double }
let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') }
diff --git a/spec/lib/gitlab/github_import/wiki_formatter_spec.rb b/spec/lib/gitlab/github_import/wiki_formatter_spec.rb
index 1bd29b8a563..fcd90fab547 100644
--- a/spec/lib/gitlab/github_import/wiki_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/wiki_formatter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GithubImport::WikiFormatter, lib: true do
+describe Gitlab::GithubImport::WikiFormatter do
let(:project) do
create(:project,
namespace: create(:namespace, path: 'gitlabhq'),
@@ -9,9 +9,9 @@ describe Gitlab::GithubImport::WikiFormatter, lib: true do
subject(:wiki) { described_class.new(project) }
- describe '#path_with_namespace' do
+ describe '#disk_path' do
it 'appends .wiki to project path' do
- expect(wiki.path_with_namespace).to eq 'gitlabhq/gitlabhq.wiki'
+ expect(wiki.disk_path).to eq project.disk_path + '.wiki'
end
end
diff --git a/spec/lib/gitlab/gitlab_import/client_spec.rb b/spec/lib/gitlab/gitlab_import/client_spec.rb
index cd8e805466a..50e8d7183ce 100644
--- a/spec/lib/gitlab/gitlab_import/client_spec.rb
+++ b/spec/lib/gitlab/gitlab_import/client_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Gitlab::GitlabImport::Client, lib: true do
+describe Gitlab::GitlabImport::Client do
include ImportSpecHelper
let(:token) { '123456' }
- let(:client) { Gitlab::GitlabImport::Client.new(token) }
+ let(:client) { described_class.new(token) }
before do
stub_omniauth_provider('gitlab')
diff --git a/spec/lib/gitlab/gitlab_import/importer_spec.rb b/spec/lib/gitlab/gitlab_import/importer_spec.rb
index 9b499b593d3..e1d935602b5 100644
--- a/spec/lib/gitlab/gitlab_import/importer_spec.rb
+++ b/spec/lib/gitlab/gitlab_import/importer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GitlabImport::Importer, lib: true do
+describe Gitlab::GitlabImport::Importer do
include ImportSpecHelper
describe '#execute' do
@@ -24,7 +24,7 @@ describe Gitlab::GitlabImport::Importer, lib: true do
end
it 'persists issues' do
- project = create(:empty_project, import_source: 'asd/vim')
+ project = create(:project, import_source: 'asd/vim')
project.build_import_data(credentials: { password: 'password' })
subject = described_class.new(project)
@@ -45,8 +45,8 @@ describe Gitlab::GitlabImport::Importer, lib: true do
def stub_request(path, body)
url = "https://gitlab.com/api/v3/projects/asd%2Fvim/#{path}?page=1&per_page=100"
- WebMock.stub_request(:get, url).
- to_return(
+ WebMock.stub_request(:get, url)
+ .to_return(
headers: { 'Content-Type' => 'application/json' },
body: body
)
diff --git a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb
index 483f65cd053..82548c7fd31 100644
--- a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GitlabImport::ProjectCreator, lib: true do
+describe Gitlab::GitlabImport::ProjectCreator do
let(:user) { create(:user) }
let(:repo) do
{
@@ -12,7 +12,7 @@ describe Gitlab::GitlabImport::ProjectCreator, lib: true do
owner: { name: "john" }
}.with_indifferent_access
end
- let(:namespace){ create(:group, owner: user) }
+ let(:namespace) { create(:group, owner: user) }
let(:token) { "asdffg" }
let(:access_params) { { gitlab_access_token: token } }
@@ -23,7 +23,7 @@ describe Gitlab::GitlabImport::ProjectCreator, lib: true do
it 'creates project' do
allow_any_instance_of(Project).to receive(:add_import_job)
- project_creator = Gitlab::GitlabImport::ProjectCreator.new(repo, namespace, user, access_params)
+ project_creator = described_class.new(repo, namespace, user, access_params)
project = project_creator.execute
expect(project.import_url).to eq("https://oauth2:asdffg@gitlab.com/asd/vim.git")
diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb
new file mode 100644
index 00000000000..4e09020471b
--- /dev/null
+++ b/spec/lib/gitlab/gl_repository_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe ::Gitlab::GlRepository do
+ describe '.parse' do
+ set(:project) { create(:project, :repository) }
+
+ it 'parses a project gl_repository' do
+ expect(described_class.parse("project-#{project.id}")).to eq([project, false])
+ end
+
+ it 'parses a wiki gl_repository' do
+ expect(described_class.parse("wiki-#{project.id}")).to eq([project, true])
+ end
+
+ it 'throws an argument error on an invalid gl_repository' do
+ expect { described_class.parse("badformat-#{project.id}") }.to raise_error(ArgumentError)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/google_code_import/client_spec.rb b/spec/lib/gitlab/google_code_import/client_spec.rb
index 85949ae8dc4..37985c062b4 100644
--- a/spec/lib/gitlab/google_code_import/client_spec.rb
+++ b/spec/lib/gitlab/google_code_import/client_spec.rb
@@ -1,6 +1,6 @@
require "spec_helper"
-describe Gitlab::GoogleCodeImport::Client, lib: true do
+describe Gitlab::GoogleCodeImport::Client do
let(:raw_data) { JSON.parse(fixture_file("GoogleCodeProjectHosting.json")) }
subject { described_class.new(raw_data) }
diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb
index ccaa88a5c79..798ea0bac58 100644
--- a/spec/lib/gitlab/google_code_import/importer_spec.rb
+++ b/spec/lib/gitlab/google_code_import/importer_spec.rb
@@ -1,6 +1,6 @@
require "spec_helper"
-describe Gitlab::GoogleCodeImport::Importer, lib: true do
+describe Gitlab::GoogleCodeImport::Importer do
let(:mapped_user) { create(:user, username: "thilo123") }
let(:raw_data) { JSON.parse(fixture_file("GoogleCodeProjectHosting.json")) }
let(:client) { Gitlab::GoogleCodeImport::Client.new(raw_data) }
@@ -10,7 +10,7 @@ describe Gitlab::GoogleCodeImport::Importer, lib: true do
'user_map' => { 'thilo...' => "@#{mapped_user.username}" }
}
end
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { described_class.new(project) }
@@ -49,7 +49,7 @@ describe Gitlab::GoogleCodeImport::Importer, lib: true do
expect(issue).not_to be_nil
expect(issue.iid).to eq(169)
expect(issue.author).to eq(project.creator)
- expect(issue.assignee).to eq(mapped_user)
+ expect(issue.assignees).to eq([mapped_user])
expect(issue.state).to eq("closed")
expect(issue.label_names).to include("Priority: Medium")
expect(issue.label_names).to include("Status: Fixed")
diff --git a/spec/lib/gitlab/google_code_import/project_creator_spec.rb b/spec/lib/gitlab/google_code_import/project_creator_spec.rb
index 499a896ee76..8d5b60d50de 100644
--- a/spec/lib/gitlab/google_code_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/google_code_import/project_creator_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::GoogleCodeImport::ProjectCreator, lib: true do
+describe Gitlab::GoogleCodeImport::ProjectCreator do
let(:user) { create(:user) }
let(:repo) do
Gitlab::GoogleCodeImport::Repository.new(
@@ -9,7 +9,7 @@ describe Gitlab::GoogleCodeImport::ProjectCreator, lib: true do
"repositoryUrls" => ["https://vim.googlecode.com/git/"]
)
end
- let(:namespace){ create(:group, owner: user) }
+ let(:namespace) { create(:group, owner: user) }
before do
namespace.add_owner(user)
@@ -18,7 +18,7 @@ describe Gitlab::GoogleCodeImport::ProjectCreator, lib: true do
it 'creates project' do
allow_any_instance_of(Project).to receive(:add_import_job)
- project_creator = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, user)
+ project_creator = described_class.new(repo, namespace, user)
project = project_creator.execute
expect(project.import_url).to eq("https://vim.googlecode.com/git/")
diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb
new file mode 100644
index 00000000000..e521fcc6dc1
--- /dev/null
+++ b/spec/lib/gitlab/gpg/commit_spec.rb
@@ -0,0 +1,128 @@
+require 'rails_helper'
+
+describe Gitlab::Gpg::Commit do
+ describe '#signature' do
+ let!(:project) { create :project, :repository, path: 'sample-project' }
+ let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
+
+ context 'unsigned commit' do
+ it 'returns nil' do
+ expect(described_class.new(project, commit_sha).signature).to be_nil
+ end
+ end
+
+ context 'known and verified public key' do
+ let!(:gpg_key) do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first)
+ end
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns a valid signature' do
+ expect(described_class.new(project, commit_sha).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ valid_signature: true
+ )
+ end
+
+ it 'returns the cached signature on second call' do
+ gpg_commit = described_class.new(project, commit_sha)
+
+ expect(gpg_commit).to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+
+ # consecutive call
+ expect(gpg_commit).not_to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+ end
+ end
+
+ context 'known but unverified public key' do
+ let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key }
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns an invalid signature' do
+ expect(described_class.new(project, commit_sha).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ valid_signature: false
+ )
+ end
+
+ it 'returns the cached signature on second call' do
+ gpg_commit = described_class.new(project, commit_sha)
+
+ expect(gpg_commit).to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+
+ # consecutive call
+ expect(gpg_commit).not_to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+ end
+ end
+
+ context 'unknown public key' do
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns an invalid signature' do
+ expect(described_class.new(project, commit_sha).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: nil,
+ gpg_key_user_email: nil,
+ valid_signature: false
+ )
+ end
+
+ it 'returns the cached signature on second call' do
+ gpg_commit = described_class.new(project, commit_sha)
+
+ expect(gpg_commit).to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+
+ # consecutive call
+ expect(gpg_commit).not_to receive(:using_keychain).and_call_original
+ gpg_commit.signature
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
new file mode 100644
index 00000000000..4de4419de27
--- /dev/null
+++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
@@ -0,0 +1,166 @@
+require 'rails_helper'
+
+RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
+ describe '#run' do
+ let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
+ let!(:project) { create :project, :repository, path: 'sample-project' }
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ context 'gpg signature did have an associated gpg key which was removed later' do
+ let!(:user) { create :user, email: GpgHelpers::User1.emails.first }
+
+ let!(:valid_gpg_signature) do
+ create :gpg_signature,
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: true
+ end
+
+ it 'assigns the gpg key to the signature when the missing gpg key is added' do
+ # InvalidGpgSignatureUpdater is called by the after_create hook
+ gpg_key = create :gpg_key,
+ key: GpgHelpers::User1.public_key,
+ user: user
+
+ expect(valid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: true
+ )
+ end
+
+ it 'does not assign the gpg key when an unrelated gpg key is added' do
+ # InvalidGpgSignatureUpdater is called by the after_create hook
+ create :gpg_key,
+ key: GpgHelpers::User2.public_key,
+ user: user
+
+ expect(valid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: true
+ )
+ end
+ end
+
+ context 'gpg signature did not have an associated gpg key' do
+ let!(:user) { create :user, email: GpgHelpers::User1.emails.first }
+
+ let!(:invalid_gpg_signature) do
+ create :gpg_signature,
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: false
+ end
+
+ it 'updates the signature to being valid when the missing gpg key is added' do
+ # InvalidGpgSignatureUpdater is called by the after_create hook
+ gpg_key = create :gpg_key,
+ key: GpgHelpers::User1.public_key,
+ user: user
+
+ expect(invalid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: true
+ )
+ end
+
+ it 'keeps the signature at being invalid when an unrelated gpg key is added' do
+ # InvalidGpgSignatureUpdater is called by the after_create hook
+ create :gpg_key,
+ key: GpgHelpers::User2.public_key,
+ user: user
+
+ expect(invalid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: false
+ )
+ end
+ end
+
+ context 'gpg signature did have an associated unverified gpg key' do
+ let!(:user) do
+ create(:user, email: 'unrelated@example.com').tap do |user|
+ user.skip_reconfirmation!
+ end
+ end
+
+ let!(:invalid_gpg_signature) do
+ create :gpg_signature,
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: nil,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: false
+ end
+
+ it 'updates the signature to being valid when the user updates the email address' do
+ gpg_key = create :gpg_key,
+ key: GpgHelpers::User1.public_key,
+ user: user
+
+ expect(invalid_gpg_signature.reload.valid_signature).to be_falsey
+
+ # InvalidGpgSignatureUpdater is called by the after_update hook
+ user.update_attributes!(email: GpgHelpers::User1.emails.first)
+
+ expect(invalid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: true
+ )
+ end
+
+ it 'keeps the signature at being invalid when the changed email address is still unrelated' do
+ gpg_key = create :gpg_key,
+ key: GpgHelpers::User1.public_key,
+ user: user
+
+ expect(invalid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: false
+ )
+
+ # InvalidGpgSignatureUpdater is called by the after_update hook
+ user.update_attributes!(email: 'still.unrelated@example.com')
+
+ expect(invalid_gpg_signature.reload).to have_attributes(
+ project: project,
+ commit_sha: commit_sha,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ valid_signature: false
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb
new file mode 100644
index 00000000000..30ad033b204
--- /dev/null
+++ b/spec/lib/gitlab/gpg_spec.rb
@@ -0,0 +1,135 @@
+require 'rails_helper'
+
+describe Gitlab::Gpg do
+ describe '.fingerprints_from_key' do
+ before do
+ # make sure that each method is using the temporary keychain
+ expect(described_class).to receive(:using_tmp_keychain).and_call_original
+ end
+
+ it 'returns CurrentKeyChain.fingerprints_from_key' do
+ expect(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(GpgHelpers::User1.public_key)
+
+ described_class.fingerprints_from_key(GpgHelpers::User1.public_key)
+ end
+ end
+
+ describe '.primary_keyids_from_key' do
+ it 'returns the keyid' do
+ expect(
+ described_class.primary_keyids_from_key(GpgHelpers::User1.public_key)
+ ).to eq [GpgHelpers::User1.primary_keyid]
+ end
+
+ it 'returns an empty array when the key is invalid' do
+ expect(
+ described_class.primary_keyids_from_key('bogus')
+ ).to eq []
+ end
+ end
+
+ describe '.user_infos_from_key' do
+ it 'returns the names and emails' do
+ user_infos = described_class.user_infos_from_key(GpgHelpers::User1.public_key)
+ expect(user_infos).to eq([{
+ name: GpgHelpers::User1.names.first,
+ email: GpgHelpers::User1.emails.first
+ }])
+ end
+
+ it 'returns an empty array when the key is invalid' do
+ expect(
+ described_class.user_infos_from_key('bogus')
+ ).to eq []
+ end
+ end
+
+ describe '.current_home_dir' do
+ let(:default_home_dir) { GPGME::Engine.dirinfo('homedir') }
+
+ it 'returns the default value when no explicit home dir has been set' do
+ expect(described_class.current_home_dir).to eq default_home_dir
+ end
+
+ it 'returns the explicitely set home dir' do
+ GPGME::Engine.home_dir = '/tmp/gpg'
+
+ expect(described_class.current_home_dir).to eq '/tmp/gpg'
+
+ GPGME::Engine.home_dir = GPGME::Engine.dirinfo('homedir')
+ end
+
+ it 'returns the default value when explicitely setting the home dir to nil' do
+ GPGME::Engine.home_dir = nil
+
+ expect(described_class.current_home_dir).to eq default_home_dir
+ end
+ end
+
+ describe '.using_tmp_keychain' do
+ it "the second thread does not change the first thread's directory" do
+ thread1 = Thread.new do
+ described_class.using_tmp_keychain do
+ dir = described_class.current_home_dir
+ sleep 0.1
+ expect(described_class.current_home_dir).to eq dir
+ end
+ end
+
+ thread2 = Thread.new do
+ described_class.using_tmp_keychain do
+ sleep 0.2
+ end
+ end
+
+ thread1.join
+ thread2.join
+ end
+
+ it 'allows recursive execution in the same thread' do
+ expect do
+ described_class.using_tmp_keychain do
+ described_class.using_tmp_keychain do
+ end
+ end
+ end.not_to raise_error(ThreadError)
+ end
+ end
+end
+
+describe Gitlab::Gpg::CurrentKeyChain do
+ around do |example|
+ Gitlab::Gpg.using_tmp_keychain do
+ example.run
+ end
+ end
+
+ describe '.add' do
+ it 'stores the key in the keychain' do
+ expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq []
+
+ described_class.add(GpgHelpers::User1.public_key)
+
+ keys = GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)
+ expect(keys.count).to eq 1
+ expect(keys.first).to have_attributes(
+ email: GpgHelpers::User1.emails.first,
+ fingerprint: GpgHelpers::User1.fingerprint
+ )
+ end
+ end
+
+ describe '.fingerprints_from_key' do
+ it 'returns the fingerprint' do
+ expect(
+ described_class.fingerprints_from_key(GpgHelpers::User1.public_key)
+ ).to eq [GpgHelpers::User1.fingerprint]
+ end
+
+ it 'returns an empty array when the key is invalid' do
+ expect(
+ described_class.fingerprints_from_key('bogus')
+ ).to eq []
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphs/commits_spec.rb b/spec/lib/gitlab/graphs/commits_spec.rb
index abb5a26060f..b2084f56640 100644
--- a/spec/lib/gitlab/graphs/commits_spec.rb
+++ b/spec/lib/gitlab/graphs/commits_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::Graphs::Commits, lib: true do
- let!(:project) { create(:empty_project, :public) }
+describe Gitlab::Graphs::Commits do
+ let!(:project) { create(:project, :public) }
let!(:commit1) { create(:commit, git_commit: RepoHelpers.sample_commit, project: project, committed_date: Time.now) }
let!(:commit1_yesterday) { create(:commit, git_commit: RepoHelpers.sample_commit, project: project, committed_date: 1.day.ago)}
diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb
new file mode 100644
index 00000000000..08010c2d0e2
--- /dev/null
+++ b/spec/lib/gitlab/group_hierarchy_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+
+describe Gitlab::GroupHierarchy, :postgresql do
+ let!(:parent) { create(:group) }
+ let!(:child1) { create(:group, parent: parent) }
+ let!(:child2) { create(:group, parent: child1) }
+
+ describe '#base_and_ancestors' do
+ let(:relation) do
+ described_class.new(Group.where(id: child2.id)).base_and_ancestors
+ end
+
+ it 'includes the base rows' do
+ expect(relation).to include(child2)
+ end
+
+ it 'includes all of the ancestors' do
+ expect(relation).to include(parent, child1)
+ end
+
+ it 'uses ancestors_base #initialize argument' do
+ relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
+
+ expect(relation).to include(parent, child1, child2)
+ end
+ end
+
+ describe '#base_and_descendants' do
+ let(:relation) do
+ described_class.new(Group.where(id: parent.id)).base_and_descendants
+ end
+
+ it 'includes the base rows' do
+ expect(relation).to include(parent)
+ end
+
+ it 'includes all the descendants' do
+ expect(relation).to include(child1, child2)
+ end
+
+ it 'uses descendants_base #initialize argument' do
+ relation = described_class.new(Group.none, Group.where(id: parent.id)).base_and_descendants
+
+ expect(relation).to include(parent, child1, child2)
+ end
+ end
+
+ describe '#all_groups' do
+ let(:relation) do
+ described_class.new(Group.where(id: child1.id)).all_groups
+ end
+
+ it 'includes the base rows' do
+ expect(relation).to include(child1)
+ end
+
+ it 'includes the ancestors' do
+ expect(relation).to include(parent)
+ end
+
+ it 'includes the descendants' do
+ expect(relation).to include(child2)
+ end
+
+ it 'uses ancestors_base #initialize argument for ancestors' do
+ relation = described_class.new(Group.where(id: child1.id), Group.where(id: Group.maximum(:id).succ)).all_groups
+
+ expect(relation).to include(parent)
+ end
+
+ it 'uses descendants_base #initialize argument for descendants' do
+ relation = described_class.new(Group.where(id: Group.maximum(:id).succ), Group.where(id: child1.id)).all_groups
+
+ expect(relation).to include(child2)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/healthchecks/db_check_spec.rb b/spec/lib/gitlab/health_checks/db_check_spec.rb
index 33c6c24449c..33c6c24449c 100644
--- a/spec/lib/gitlab/healthchecks/db_check_spec.rb
+++ b/spec/lib/gitlab/health_checks/db_check_spec.rb
diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb
new file mode 100644
index 00000000000..f5c9680bf59
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb
@@ -0,0 +1,200 @@
+require 'spec_helper'
+
+describe Gitlab::HealthChecks::FsShardsCheck do
+ def command_exists?(command)
+ _, status = Gitlab::Popen.popen(%W{ #{command} 1 echo })
+ status.zero?
+ rescue Errno::ENOENT
+ false
+ end
+
+ def timeout_command
+ @timeout_command ||=
+ if command_exists?('timeout')
+ 'timeout'
+ elsif command_exists?('gtimeout')
+ 'gtimeout'
+ else
+ ''
+ end
+ end
+
+ 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 }
+ stub_const('Gitlab::HealthChecks::FsShardsCheck::TIMEOUT_EXECUTABLE', timeout_command)
+ 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 has a tripped circuitbreaker', broken_storage: true do
+ let(:repository_storages) { ['broken'] }
+ let(:storages_paths) do
+ Gitlab.config.repositories.storages
+ end
+
+ it { is_expected.to include(result_class.new(false, 'circuitbreaker tripped', shard: 'broken')) }
+ end
+
+ context 'storage points to not existing folder' do
+ let(:storages_paths) do
+ {
+ default: { path: 'tmp/this/path/doesnt/exist' }
+ }.with_indifferent_access
+ end
+
+ before do
+ allow(described_class).to receive(:storage_circuitbreaker_test) { true }
+ 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
+
+ expect { subject }.not_to change(Dir.entries(tmp_dir), :count)
+ 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
+ context 'storage points to not existing folder' do
+ let(:storages_paths) do
+ {
+ default: { path: 'tmp/this/path/doesnt/exist' }
+ }.with_indifferent_access
+ end
+
+ it 'provides metrics' do
+ metrics = described_class.metrics
+
+ expect(metrics).to all(have_attributes(labels: { shard: :default }))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_circuitbreaker_latency_seconds, value: be >= 0))
+ end
+ end
+
+ context 'storage points to directory that has both read and write rights' do
+ before do
+ FileUtils.chmod_R(0755, tmp_dir)
+ end
+
+ it 'provides metrics' do
+ metrics = described_class.metrics
+
+ expect(metrics).to all(have_attributes(labels: { shard: :default }))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 1))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 1))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 1))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_circuitbreaker_latency_seconds, value: be >= 0))
+ end
+
+ it 'cleans up files used for metrics' do
+ expect { described_class.metrics }.not_to change(Dir.entries(tmp_dir), :count)
+ end
+ end
+ end
+ end
+
+ context 'when timeout kills fs checks' do
+ before do
+ stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '1')
+
+ allow(described_class).to receive(:exec_with_timeout).and_wrap_original { |m| m.call(%w(sleep 60)) }
+ FileUtils.chmod_R(0755, tmp_dir)
+ end
+
+ describe '#readiness' do
+ subject { described_class.readiness }
+
+ it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: :default)) }
+ end
+
+ describe '#metrics' do
+ it 'provides metrics' do
+ metrics = described_class.metrics
+
+ expect(metrics).to all(have_attributes(labels: { shard: :default }))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
+ expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
+ end
+ end
+ end
+
+ context 'when popen always finds required binaries' do
+ before do
+ allow(described_class).to receive(:exec_with_timeout).and_wrap_original do |method, *args, &block|
+ begin
+ method.call(*args, &block)
+ rescue RuntimeError, Errno::ENOENT
+ raise 'expected not to happen'
+ end
+ end
+
+ stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '10')
+ 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/health_checks/prometheus_text_format_spec.rb b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb
new file mode 100644
index 00000000000..ed757ed60d8
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb
@@ -0,0 +1,41 @@
+describe Gitlab::HealthChecks::PrometheusTextFormat do
+ let(:metric_class) { Gitlab::HealthChecks::Metric }
+ subject { described_class.new }
+
+ describe '#marshal' do
+ let(:sample_metrics) do
+ [metric_class.new('metric1', 1),
+ metric_class.new('metric2', 2)]
+ end
+
+ it 'marshal to text with non repeating type definition' do
+ expected = <<-EXPECTED.strip_heredoc
+ # TYPE metric1 gauge
+ metric1 1
+ # TYPE metric2 gauge
+ metric2 2
+ EXPECTED
+
+ expect(subject.marshal(sample_metrics)).to eq(expected)
+ end
+
+ context 'metrics where name repeats' do
+ let(:sample_metrics) do
+ [metric_class.new('metric1', 1),
+ metric_class.new('metric1', 2),
+ metric_class.new('metric2', 3)]
+ end
+
+ it 'marshal to text with non repeating type definition' do
+ expected = <<-EXPECTED.strip_heredoc
+ # TYPE metric1 gauge
+ metric1 1
+ metric1 2
+ # TYPE metric2 gauge
+ metric2 3
+ EXPECTED
+ expect(subject.marshal(sample_metrics)).to eq(expected)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/health_checks/redis/cache_check_spec.rb b/spec/lib/gitlab/health_checks/redis/cache_check_spec.rb
new file mode 100644
index 00000000000..3693f52b51b
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/redis/cache_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative '../simple_check_shared'
+
+describe Gitlab::HealthChecks::Redis::CacheCheck do
+ include_examples 'simple_check', 'redis_cache_ping', 'RedisCache', 'PONG'
+end
diff --git a/spec/lib/gitlab/health_checks/redis/queues_check_spec.rb b/spec/lib/gitlab/health_checks/redis/queues_check_spec.rb
new file mode 100644
index 00000000000..c69443d205d
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/redis/queues_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative '../simple_check_shared'
+
+describe Gitlab::HealthChecks::Redis::QueuesCheck do
+ include_examples 'simple_check', 'redis_queues_ping', 'RedisQueues', 'PONG'
+end
diff --git a/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb b/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb
new file mode 100644
index 00000000000..03afc1cd761
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative '../simple_check_shared'
+
+describe Gitlab::HealthChecks::Redis::RedisCheck do
+ include_examples 'simple_check', 'redis_ping', 'Redis', 'PONG'
+end
diff --git a/spec/lib/gitlab/health_checks/redis/shared_state_check_spec.rb b/spec/lib/gitlab/health_checks/redis/shared_state_check_spec.rb
new file mode 100644
index 00000000000..b72e152bbe2
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/redis/shared_state_check_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+require_relative '../simple_check_shared'
+
+describe Gitlab::HealthChecks::Redis::SharedStateCheck do
+ include_examples 'simple_check', 'redis_shared_state_ping', 'RedisSharedState', 'PONG'
+end
diff --git a/spec/lib/gitlab/health_checks/simple_check_shared.rb b/spec/lib/gitlab/health_checks/simple_check_shared.rb
new file mode 100644
index 00000000000..e2643458aca
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/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_seconds", 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_seconds", 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_seconds", 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 #{described_class.human_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: "#{described_class.human_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/healthchecks/fs_shards_check_spec.rb b/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb
deleted file mode 100644
index 4cd8cf313a5..00000000000
--- a/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-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
deleted file mode 100644
index 734cdcb893e..00000000000
--- a/spec/lib/gitlab/healthchecks/redis_check_spec.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 1fa6d0faef9..00000000000
--- a/spec/lib/gitlab/healthchecks/simple_check_shared.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-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/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb
index e49799ad105..29e61d15726 100644
--- a/spec/lib/gitlab/highlight_spec.rb
+++ b/spec/lib/gitlab/highlight_spec.rb
@@ -1,45 +1,23 @@
require 'spec_helper'
-describe Gitlab::Highlight, lib: true do
+describe Gitlab::Highlight do
include RepoHelpers
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:commit) { project.commit(sample_commit.id) }
- describe '.highlight_lines' do
- let(:lines) do
- Gitlab::Highlight.highlight_lines(project.repository, commit.id, 'files/ruby/popen.rb')
- end
-
- it 'highlights all the lines properly' do
- expect(lines[4]).to eq(%Q{<span id="LC5" class="line" lang="ruby"> <span class="kp">extend</span> <span class="nb">self</span></span>\n})
- expect(lines[21]).to eq(%Q{<span id="LC22" class="line" lang="ruby"> <span class="k">unless</span> <span class="no">File</span><span class="p">.</span><span class="nf">directory?</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span>\n})
- expect(lines[26]).to eq(%Q{<span id="LC27" class="line" lang="ruby"> <span class="vi">@cmd_status</span> <span class="o">=</span> <span class="mi">0</span></span>\n})
- end
-
- describe 'with CRLF' do
- let(:branch) { 'crlf-diff' }
- let(:blob) { repository.blob_at_branch(branch, path) }
- let(:lines) do
- Gitlab::Highlight.highlight_lines(project.repository, 'crlf-diff', 'files/whitespace')
- end
-
- it 'strips extra LFs' do
- expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\" lang=\"plaintext\">test </span>")
- end
- end
- end
-
describe 'custom highlighting from .gitattributes' do
let(:branch) { 'gitattributes' }
let(:blob) { repository.blob_at_branch(branch, path) }
let(:highlighter) do
- Gitlab::Highlight.new(blob.path, blob.data, repository: repository)
+ described_class.new(blob.path, blob.data, repository: repository)
end
- before { project.change_head('gitattributes') }
+ before do
+ project.change_head('gitattributes')
+ end
describe 'basic language selection' do
let(:path) { 'custom-highlighting/test.gitlab-custom' }
@@ -57,4 +35,26 @@ describe Gitlab::Highlight, lib: true do
end
end
end
+
+ describe '#highlight' do
+ describe 'with CRLF' do
+ let(:branch) { 'crlf-diff' }
+ let(:path) { 'files/whitespace' }
+ let(:blob) { repository.blob_at_branch(branch, path) }
+ let(:lines) do
+ described_class.highlight(blob.path, blob.data, repository: repository).lines
+ end
+
+ it 'strips extra LFs' do
+ expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\" lang=\"plaintext\">test </span>")
+ end
+ end
+
+ it 'links dependencies via DependencyLinker' do
+ expect(Gitlab::DependencyLinker).to receive(:link)
+ .with('file.name', 'Contents', anything).and_call_original
+
+ described_class.highlight('file.name', 'Contents')
+ end
+ end
end
diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb
new file mode 100644
index 00000000000..785035d993f
--- /dev/null
+++ b/spec/lib/gitlab/i18n_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::I18n do
+ let(:user) { create(:user, preferred_language: 'es') }
+
+ describe '.locale=' do
+ after do
+ described_class.use_default_locale
+ end
+
+ it 'sets the locale based on current user preferred language' do
+ described_class.locale = user.preferred_language
+
+ expect(FastGettext.locale).to eq('es')
+ expect(::I18n.locale).to eq(:es)
+ end
+ end
+
+ describe '.use_default_locale' do
+ it 'resets the locale to the default language' do
+ described_class.locale = user.preferred_language
+
+ described_class.use_default_locale
+
+ expect(FastGettext.locale).to eq('en')
+ expect(::I18n.locale).to eq(:en)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/identifier_spec.rb b/spec/lib/gitlab/identifier_spec.rb
index bb758a8a202..cfaeb1f0d4f 100644
--- a/spec/lib/gitlab/identifier_spec.rb
+++ b/spec/lib/gitlab/identifier_spec.rb
@@ -5,15 +5,15 @@ describe Gitlab::Identifier do
Class.new { include Gitlab::Identifier }.new
end
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:key) { create(:key, user: user) }
describe '#identify' do
context 'without an identifier' do
it 'identifies the user using a commit' do
- expect(identifier).to receive(:identify_using_commit).
- with(project, '123')
+ expect(identifier).to receive(:identify_using_commit)
+ .with(project, '123')
identifier.identify('', project, '123')
end
@@ -21,8 +21,8 @@ describe Gitlab::Identifier do
context 'with a user identifier' do
it 'identifies the user using a user ID' do
- expect(identifier).to receive(:identify_using_user).
- with("user-#{user.id}")
+ expect(identifier).to receive(:identify_using_user)
+ .with("user-#{user.id}")
identifier.identify("user-#{user.id}", project, '123')
end
@@ -30,8 +30,8 @@ describe Gitlab::Identifier do
context 'with an SSH key identifier' do
it 'identifies the user using an SSH key ID' do
- expect(identifier).to receive(:identify_using_ssh_key).
- with("key-#{key.id}")
+ expect(identifier).to receive(:identify_using_ssh_key)
+ .with("key-#{key.id}")
identifier.identify("key-#{key.id}", project, '123')
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 0abf89d060c..8da02b0cf00 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -3,12 +3,13 @@ issues:
- subscriptions
- award_emoji
- author
-- assignee
+- assignees
- updated_by
- milestone
- notes
- label_links
- labels
+- last_edited_by
- todos
- user_agent_detail
- moved_to
@@ -16,16 +17,19 @@ issues:
- merge_requests_closing_issues
- metrics
- timelogs
+- issue_assignees
events:
- author
- project
- target
+- push_event_payload
notes:
- award_emoji
- project
- noteable
- author
- updated_by
+- last_edited_by
- resolved_by
- todos
- events
@@ -42,6 +46,7 @@ label:
- merge_requests
- priorities
milestone:
+- group
- project
- issues
- labels
@@ -71,6 +76,7 @@ merge_requests:
- notes
- label_links
- labels
+- last_edited_by
- todos
- target_project
- source_project
@@ -81,14 +87,23 @@ merge_requests:
- merge_requests_closing_issues
- metrics
- timelogs
+- head_pipeline
merge_request_diff:
- merge_request
+- merge_request_diff_commits
+- merge_request_diff_files
+merge_request_diff_commits:
+- merge_request_diff
+merge_request_diff_files:
+- merge_request_diff
pipelines:
- project
- user
+- stages
- statuses
- builds
- trigger_requests
+- variables
- auto_canceled_by
- auto_canceled_pipelines
- auto_canceled_jobs
@@ -97,9 +112,19 @@ pipelines:
- cancelable_statuses
- manual_actions
- artifacts
+- pipeline_schedule
+- merge_requests
+pipeline_variables:
+- pipeline
+stages:
+- project
+- pipeline
+- statuses
+- builds
statuses:
- project
- pipeline
+- stage
- user
- auto_canceled_by
variables:
@@ -108,9 +133,16 @@ triggers:
- project
- trigger_requests
- owner
-- trigger_schedule
-trigger_schedule:
-- trigger
+pipeline_schedules:
+- project
+- owner
+- pipelines
+- last_pipeline
+- variables
+pipeline_schedule:
+- pipelines
+pipeline_schedule_variables:
+- pipeline_schedule
deploy_keys:
- user
- deploy_keys_projects
@@ -120,6 +152,7 @@ services:
- service_hook
hooks:
- project
+- web_hook_logs
protected_branches:
- project
- merge_access_levels
@@ -132,7 +165,9 @@ merge_access_levels:
push_access_levels:
- protected_branch
create_access_levels:
+- user
- protected_tag
+- group
container_repositories:
- project
- name
@@ -217,7 +252,7 @@ project:
- active_runners
- variables
- triggers
-- trigger_schedules
+- pipeline_schedules
- environments
- deployments
- project_feature
@@ -225,6 +260,7 @@ project:
- authorized_users
- project_authorizations
- route
+- redirect_routes
- statistics
- container_repositories
- uploads
@@ -237,3 +273,5 @@ timelogs:
- issue
- merge_request
- user
+push_event_payload:
+- event
diff --git a/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb
index 63bab0f0d0d..cd5a1b2982b 100644
--- a/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb
+++ b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::ImportExport::AttributeCleaner, lib: true do
- let(:relation_class){ double('relation_class').as_null_object }
+describe Gitlab::ImportExport::AttributeCleaner do
+ let(:relation_class) { double('relation_class').as_null_object }
let(:unsafe_hash) do
{
'id' => 101,
diff --git a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
index e24d070706a..65f073b2df3 100644
--- a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
@@ -7,7 +7,7 @@ require 'spec_helper'
# to be included as part of the export, or blacklist them using the import_export.yml configuration file.
# Likewise, new models added to import_export.yml, will need to be added with their correspondent attributes
# to this spec.
-describe 'Import/Export attribute configuration', lib: true do
+describe 'Import/Export attribute configuration' do
include ConfigurationHelper
let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys }
diff --git a/spec/lib/gitlab/import_export/avatar_restorer_spec.rb b/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
index 08a42fd27a2..a93a921e459 100644
--- a/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Gitlab::ImportExport::AvatarRestorer, lib: true do
+describe Gitlab::ImportExport::AvatarRestorer do
include UploadHelpers
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
allow_any_instance_of(described_class).to receive(:avatar_export_file)
diff --git a/spec/lib/gitlab/import_export/avatar_saver_spec.rb b/spec/lib/gitlab/import_export/avatar_saver_spec.rb
index 579a31ead58..3fb5ddde8b5 100644
--- a/spec/lib/gitlab/import_export/avatar_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/avatar_saver_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Gitlab::ImportExport::AvatarSaver, lib: true do
+describe Gitlab::ImportExport::AvatarSaver do
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:project_with_avatar) { create(:empty_project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
- let(:project) { create(:empty_project) }
+ let(:project_with_avatar) { create(:project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
+ let(:project) { create(:project) }
before do
FileUtils.mkdir_p("#{shared.export_path}/avatar/")
diff --git a/spec/lib/gitlab/import_export/file_importer_spec.rb b/spec/lib/gitlab/import_export/file_importer_spec.rb
index b88b9c18c15..162b776e107 100644
--- a/spec/lib/gitlab/import_export/file_importer_spec.rb
+++ b/spec/lib/gitlab/import_export/file_importer_spec.rb
@@ -1,10 +1,11 @@
require 'spec_helper'
-describe Gitlab::ImportExport::FileImporter, lib: true do
+describe Gitlab::ImportExport::FileImporter do
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
let(:export_path) { "#{Dir.tmpdir}/file_importer_spec" }
let(:valid_file) { "#{shared.export_path}/valid.json" }
let(:symlink_file) { "#{shared.export_path}/invalid.json" }
+ let(:hidden_symlink_file) { "#{shared.export_path}/.hidden" }
let(:subfolder_symlink_file) { "#{shared.export_path}/subfolder/invalid.json" }
before do
@@ -25,6 +26,10 @@ describe Gitlab::ImportExport::FileImporter, lib: true do
expect(File.exist?(symlink_file)).to be false
end
+ it 'removes hidden symlinks in root folder' do
+ expect(File.exist?(hidden_symlink_file)).to be false
+ end
+
it 'removes symlinks in subfolders' do
expect(File.exist?(subfolder_symlink_file)).to be false
end
diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb
index c5ce06afd73..c7fbc2bc92f 100644
--- a/spec/lib/gitlab/import_export/fork_spec.rb
+++ b/spec/lib/gitlab/import_export/fork_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe 'forked project import', services: true do
+describe 'forked project import' do
let(:user) { create(:user) }
- let!(:project_with_repo) { create(:project, :test_repo, name: 'test-repo-restorer', path: 'test-repo-restorer') }
- let!(:project) { create(:empty_project) }
+ let!(:project_with_repo) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') }
+ let!(:project) { create(:project, name: 'test-repo-restorer-no-repo', path: 'test-repo-restorer-no-repo') }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) }
- let(:forked_from_project) { create(:project) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
+ let(:forked_from_project) { create(:project, :repository) }
let(:fork_link) { create(:forked_project_link, forked_from_project: project_with_repo) }
let(:repo_saver) { Gitlab::ImportExport::RepoSaver.new(project: project_with_repo, shared: shared) }
let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
@@ -44,6 +44,8 @@ describe 'forked project import', services: true do
end
it 'can access the MR' do
- expect(project.merge_requests.first.ensure_ref_fetched.first).to include('refs/merge-requests/1/head')
+ project.merge_requests.first.ensure_ref_fetched
+
+ expect(project.repository.ref_exists?('refs/merge-requests/1/head')).to be_truthy
end
end
diff --git a/spec/lib/gitlab/import_export/hash_util_spec.rb b/spec/lib/gitlab/import_export/hash_util_spec.rb
index 1c3a0b23ece..366582dece3 100644
--- a/spec/lib/gitlab/import_export/hash_util_spec.rb
+++ b/spec/lib/gitlab/import_export/hash_util_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::ImportExport::HashUtil, lib: true do
+describe Gitlab::ImportExport::HashUtil do
let(:stringified_array) { [{ 'test' => 1 }] }
let(:stringified_array_with_date) { [{ 'test_date' => '2016-04-06 06:17:44 +0200' }] }
diff --git a/spec/lib/gitlab/import_export/import_export_spec.rb b/spec/lib/gitlab/import_export/import_export_spec.rb
index f3fd0d82875..40a5f2294a2 100644
--- a/spec/lib/gitlab/import_export/import_export_spec.rb
+++ b/spec/lib/gitlab/import_export/import_export_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Gitlab::ImportExport, services: true do
+describe Gitlab::ImportExport do
describe 'export filename' do
let(:group) { create(:group, :nested) }
- let(:project) { create(:empty_project, :public, path: 'project-path', namespace: group) }
+ let(:project) { create(:project, :public, path: 'project-path', namespace: group) }
it 'contains the project path' do
expect(described_class.export_filename(project: project)).to include(project.path)
diff --git a/spec/lib/gitlab/import_export/members_mapper_spec.rb b/spec/lib/gitlab/import_export/members_mapper_spec.rb
index b9d4e59e770..246f009ad27 100644
--- a/spec/lib/gitlab/import_export/members_mapper_spec.rb
+++ b/spec/lib/gitlab/import_export/members_mapper_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Gitlab::ImportExport::MembersMapper, services: true do
+describe Gitlab::ImportExport::MembersMapper do
describe 'map members' do
- let(:user) { create(:admin, authorized_projects_populated: true) }
- let(:project) { create(:empty_project, :public, name: 'searchable_project') }
- let(:user2) { create(:user, authorized_projects_populated: true) }
+ let(:user) { create(:admin) }
+ let(:project) { create(:project, :public, name: 'searchable_project') }
+ let(:user2) { create(:user) }
let(:exported_user_id) { 99 }
let(:exported_members) do
[{
@@ -74,7 +74,7 @@ describe Gitlab::ImportExport::MembersMapper, services: true do
end
context 'user is not an admin' do
- let(:user) { create(:user, authorized_projects_populated: true) }
+ let(:user) { create(:user) }
it 'does not map a project member' do
expect(members_mapper.map[exported_user_id]).to eq(user.id)
@@ -94,9 +94,9 @@ describe Gitlab::ImportExport::MembersMapper, services: true do
end
context 'importer same as group member' do
- let(:user2) { create(:admin, authorized_projects_populated: true) }
+ let(:user2) { create(:admin) }
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, name: 'searchable_project', namespace: group) }
+ let(:project) { create(:project, :public, name: 'searchable_project', namespace: group) }
let(:members_mapper) do
described_class.new(
exported_members: exported_members, user: user2, project: project)
@@ -119,7 +119,7 @@ describe Gitlab::ImportExport::MembersMapper, services: true do
context 'importing group members' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
let(:members_mapper) do
described_class.new(
exported_members: exported_members, user: user, project: project)
diff --git a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb
index 349be4596b6..4d87f27ce05 100644
--- a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb
+++ b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
describe Gitlab::ImportExport::MergeRequestParser do
let(:user) { create(:user) }
- let!(:project) { create(:project, :test_repo, name: 'test-repo-restorer', path: 'test-repo-restorer') }
- let(:forked_from_project) { create(:project) }
+ let!(:project) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') }
+ let(:forked_from_project) { create(:project, :repository) }
let(:fork_link) { create(:forked_project_link, forked_from_project: project) }
let!(:merge_request) do
diff --git a/spec/lib/gitlab/import_export/model_configuration_spec.rb b/spec/lib/gitlab/import_export/model_configuration_spec.rb
index 2ede5cdd2ad..5cb8f2589c8 100644
--- a/spec/lib/gitlab/import_export/model_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/model_configuration_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
# Part of the test security suite for the Import/Export feature
# Finds if a new model has been added that can potentially be part of the Import/Export
# If it finds a new model, it will show a +failure_message+ with the options available.
-describe 'Import/Export model configuration', lib: true do
+describe 'Import/Export model configuration' do
include ConfigurationHelper
let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys }
diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json
index bfecfa28ed1..4e631e13410 100644
--- a/spec/lib/gitlab/import_export/project.json
+++ b/spec/lib/gitlab/import_export/project.json
@@ -2,6 +2,7 @@
"description": "Nisi et repellendus ut enim quo accusamus vel magnam.",
"visibility_level": 10,
"archived": false,
+ "description_html": "description",
"labels": [
{
"id": 2,
@@ -2533,7 +2534,6 @@
"iid": 9,
"description": null,
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -2740,13 +2740,12 @@
"merge_request_diff": {
"id": 27,
"state": "collected",
- "st_commits": [
+ "merge_request_diff_commits": [
{
- "id": "bb5206fee213d983da88c47f9cf4cc6caf9c66dc",
+ "merge_request_diff_id": 27,
+ "relative_order": 0,
+ "sha": "bb5206fee213d983da88c47f9cf4cc6caf9c66dc",
"message": "Feature conflcit added\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "5937ac0a7beb003549fc5fd26fc247adbce4a52e"
- ],
"authored_date": "2014-08-06T08:35:52.000+02:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2755,11 +2754,10 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
},
{
- "id": "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
+ "merge_request_diff_id": 27,
+ "relative_order": 1,
+ "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
"message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "570e7b2abdd848b95f2f578043fc23bd6f6fd24d"
- ],
"authored_date": "2014-02-27T10:01:38.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2768,11 +2766,10 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
},
{
- "id": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
+ "merge_request_diff_id": 27,
+ "relative_order": 2,
+ "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
"message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9"
- ],
"authored_date": "2014-02-27T09:57:31.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2781,11 +2778,10 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
},
{
- "id": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9",
+ "merge_request_diff_id": 27,
+ "relative_order": 3,
+ "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9",
"message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "d14d6c0abdd253381df51a723d58691b2ee1ab08"
- ],
"authored_date": "2014-02-27T09:54:21.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2794,11 +2790,10 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
},
{
- "id": "d14d6c0abdd253381df51a723d58691b2ee1ab08",
+ "merge_request_diff_id": 27,
+ "relative_order": 4,
+ "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08",
"message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "c1acaa58bbcbc3eafe538cb8274ba387047b69f8"
- ],
"authored_date": "2014-02-27T09:49:50.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2807,11 +2802,10 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
},
{
- "id": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8",
+ "merge_request_diff_id": 27,
+ "relative_order": 5,
+ "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8",
"message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
- "parent_ids": [
- "ae73cb07c9eeaf35924a10f713b364d32b2dd34f"
- ],
"authored_date": "2014-02-27T09:48:32.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2820,9 +2814,11 @@
"committer_email": "dmitriy.zaporozhets@gmail.com"
}
],
- "utf8_st_diffs": [
+ "merge_request_diff_files": [
{
- "diff": "Binary files a/.DS_Store and /dev/null differ\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 0,
+ "utf8_diff": "Binary files a/.DS_Store and /dev/null differ\n",
"new_path": ".DS_Store",
"old_path": ".DS_Store",
"a_mode": "100644",
@@ -2833,7 +2829,9 @@
"too_large": false
},
{
- "diff": "--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 1,
+ "utf8_diff": "--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n",
"new_path": ".gitignore",
"old_path": ".gitignore",
"a_mode": "100644",
@@ -2844,7 +2842,9 @@
"too_large": false
},
{
- "diff": "--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 2,
+ "utf8_diff": "--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n",
"new_path": ".gitmodules",
"old_path": ".gitmodules",
"a_mode": "100644",
@@ -2855,7 +2855,9 @@
"too_large": false
},
{
- "diff": "Binary files a/files/.DS_Store and /dev/null differ\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 3,
+ "utf8_diff": "Binary files a/files/.DS_Store and /dev/null differ\n",
"new_path": "files/.DS_Store",
"old_path": "files/.DS_Store",
"a_mode": "100644",
@@ -2866,7 +2868,9 @@
"too_large": false
},
{
- "diff": "--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 4,
+ "utf8_diff": "--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n",
"new_path": "files/ruby/feature.rb",
"old_path": "files/ruby/feature.rb",
"a_mode": "0",
@@ -2877,7 +2881,9 @@
"too_large": false
},
{
- "diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" =\u003e path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" =\u003e path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output \u003c\u003c stdout.read\n @cmd_output \u003c\u003c stderr.read\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 5,
+ "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" =\u003e path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" =\u003e path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output \u003c\u003c stdout.read\n @cmd_output \u003c\u003c stderr.read\n",
"new_path": "files/ruby/popen.rb",
"old_path": "files/ruby/popen.rb",
"a_mode": "100644",
@@ -2888,7 +2894,9 @@
"too_large": false
},
{
- "diff": "--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 6,
+ "utf8_diff": "--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n",
"new_path": "files/ruby/regex.rb",
"old_path": "files/ruby/regex.rb",
"a_mode": "100644",
@@ -2899,7 +2907,9 @@
"too_large": false
},
{
- "diff": "--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 7,
+ "utf8_diff": "--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n",
"new_path": "gitlab-grack",
"old_path": "gitlab-grack",
"a_mode": "0",
@@ -2910,7 +2920,9 @@
"too_large": false
},
{
- "diff": "--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n",
+ "merge_request_diff_id": 27,
+ "relative_order": 8,
+ "utf8_diff": "--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n",
"new_path": "gitlab-shell",
"old_path": "gitlab-shell",
"a_mode": "0",
@@ -2970,7 +2982,6 @@
"iid": 8,
"description": null,
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -3254,7 +3265,6 @@
"iid": 7,
"description": "Et commodi deserunt aspernatur vero rerum. Ut non dolorum alias in odit est libero. Voluptatibus eos in et vitae repudiandae facilis ex mollitia.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -3538,7 +3548,6 @@
"iid": 6,
"description": "Dicta magnam non voluptates nam dignissimos nostrum deserunt. Dolorum et suscipit iure quae doloremque. Necessitatibus saepe aut labore sed.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -4228,7 +4237,6 @@
"iid": 5,
"description": "Est eaque quasi qui qui. Similique voluptatem impedit iusto ratione reprehenderit. Itaque est illum ut nulla aut.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -4776,7 +4784,6 @@
"iid": 4,
"description": "Nam magnam odit velit rerum. Sapiente dolore sunt saepe debitis. Culpa maiores ut ad dolores dolorem et.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -5275,7 +5282,6 @@
"iid": 3,
"description": "Libero nesciunt mollitia quis odit eos vero quasi. Iure voluptatem ut sint pariatur voluptates ut aut. Laborum possimus unde illum ipsum eum.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -5535,7 +5541,6 @@
"iid": 2,
"description": "Ut dolor quia aliquid dolore et nisi. Est minus suscipit enim quaerat sapiente consequatur rerum. Eveniet provident consequatur dolor accusantium reiciendis.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -6225,7 +6230,6 @@
"iid": 1,
"description": "Eveniet nihil ratione veniam similique qui aut sapiente tempora. Sed praesentium iusto dignissimos possimus id repudiandae quo nihil. Qui doloremque autem et iure fugit.",
"position": 0,
- "locked_at": null,
"updated_by_id": null,
"merge_error": null,
"merge_params": {
@@ -6996,7 +7000,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "TeamcityService",
"category": "ci",
"default": false,
@@ -7040,7 +7044,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "RedmineService",
"category": "issue_tracker",
"default": false,
@@ -7062,7 +7066,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "PushoverService",
"category": "common",
"default": false,
@@ -7084,7 +7088,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "PivotalTrackerService",
"category": "common",
"default": false,
@@ -7107,7 +7111,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "JiraService",
"category": "issue_tracker",
"default": false,
@@ -7129,7 +7133,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "IrkerService",
"category": "common",
"default": false,
@@ -7173,7 +7177,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "GemnasiumService",
"category": "common",
"default": false,
@@ -7195,7 +7199,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "FlowdockService",
"category": "common",
"default": false,
@@ -7217,7 +7221,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "ExternalWikiService",
"category": "common",
"default": false,
@@ -7239,7 +7243,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "EmailsOnPushService",
"category": "common",
"default": false,
@@ -7261,7 +7265,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "DroneCiService",
"category": "ci",
"default": false,
@@ -7283,7 +7287,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "CustomIssueTrackerService",
"category": "issue_tracker",
"default": false,
@@ -7305,7 +7309,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "CampfireService",
"category": "common",
"default": false,
@@ -7327,7 +7331,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "BuildkiteService",
"category": "ci",
"default": false,
@@ -7349,7 +7353,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "BambooService",
"category": "ci",
"default": false,
@@ -7371,7 +7375,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "AssemblaService",
"category": "common",
"default": false,
@@ -7393,7 +7397,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"type": "AssemblaService",
"category": "common",
"default": false,
@@ -7415,7 +7419,7 @@
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
- "build_events": true,
+ "job_events": true,
"category": "common",
"default": false,
"wiki_page_events": true,
diff --git a/spec/lib/gitlab/import_export/project.light.json b/spec/lib/gitlab/import_export/project.light.json
index a78836c3c34..2d8f3d4a566 100644
--- a/spec/lib/gitlab/import_export/project.light.json
+++ b/spec/lib/gitlab/import_export/project.light.json
@@ -2,6 +2,20 @@
"description": "Nisi et repellendus ut enim quo accusamus vel magnam.",
"visibility_level": 10,
"archived": false,
+ "milestones": [
+ {
+ "id": 1,
+ "title": "test milestone",
+ "project_id": 8,
+ "description": "test milestone",
+ "due_date": null,
+ "created_at": "2016-06-14T15:02:04.415Z",
+ "updated_at": "2016-06-14T15:02:04.415Z",
+ "state": "active",
+ "iid": 1,
+ "group_id": null
+ }
+ ],
"labels": [
{
"id": 2,
@@ -14,20 +28,6 @@
"description": "",
"type": "ProjectLabel",
"priorities": [
- ]
- },
- {
- "id": 3,
- "title": "test3",
- "color": "#428bca",
- "group_id": 8,
- "created_at": "2016-07-22T08:55:44.161Z",
- "updated_at": "2016-07-22T08:55:44.161Z",
- "template": false,
- "description": "",
- "project_id": null,
- "type": "GroupLabel",
- "priorities": [
{
"id": 1,
"project_id": 5,
@@ -39,10 +39,80 @@
]
}
],
+ "issues": [
+ {
+ "id": 1,
+ "title": "Fugiat est minima quae maxime non similique.",
+ "assignee_id": null,
+ "project_id": 8,
+ "author_id": 1,
+ "created_at": "2017-07-07T18:13:01.138Z",
+ "updated_at": "2017-08-15T18:37:40.807Z",
+ "branch_name": null,
+ "description": "Quam totam fuga numquam in eveniet.",
+ "state": "opened",
+ "iid": 20,
+ "updated_by_id": 1,
+ "confidential": false,
+ "deleted_at": null,
+ "due_date": null,
+ "moved_to_id": null,
+ "lock_version": null,
+ "time_estimate": 0,
+ "closed_at": null,
+ "last_edited_at": null,
+ "last_edited_by_id": null,
+ "group_milestone_id": null,
+ "label_links": [
+ {
+ "id": 11,
+ "label_id": 6,
+ "target_id": 1,
+ "target_type": "Issue",
+ "created_at": "2017-08-15T18:37:40.795Z",
+ "updated_at": "2017-08-15T18:37:40.795Z",
+ "label": {
+ "id": 6,
+ "title": "group label",
+ "color": "#A8D695",
+ "project_id": null,
+ "created_at": "2017-08-15T18:37:19.698Z",
+ "updated_at": "2017-08-15T18:37:19.698Z",
+ "template": false,
+ "description": "",
+ "group_id": 5,
+ "type": "GroupLabel",
+ "priorities": []
+ }
+ },
+ {
+ "id": 11,
+ "label_id": 2,
+ "target_id": 1,
+ "target_type": "Issue",
+ "created_at": "2017-08-15T18:37:40.795Z",
+ "updated_at": "2017-08-15T18:37:40.795Z",
+ "label": {
+ "id": 6,
+ "title": "project label",
+ "color": "#A8D695",
+ "project_id": null,
+ "created_at": "2017-08-15T18:37:19.698Z",
+ "updated_at": "2017-08-15T18:37:19.698Z",
+ "template": false,
+ "description": "",
+ "group_id": 5,
+ "type": "ProjectLabel",
+ "priorities": []
+ }
+ }
+ ]
+ }
+ ],
"snippets": [
],
"hooks": [
]
-} \ No newline at end of file
+}
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 0e9607c5bd3..956f1d56eb4 100644
--- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
include ImportExport::CommonUtil
-describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
+describe Gitlab::ImportExport::ProjectTreeRestorer do
describe 'restore project tree' do
before(:context) do
@user = create(:user)
@@ -9,7 +9,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
RSpec::Mocks.with_temporary_scope do
@shared = Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path')
allow(@shared).to receive(:export_path).and_return('spec/lib/gitlab/import_export/')
- @project = create(:empty_project, :builds_disabled, :issues_disabled, name: 'project', path: 'project')
+ @project = create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project')
project_tree_restorer = described_class.new(user: @user, shared: @shared, project: @project)
@restored_project_json = project_tree_restorer.restore
end
@@ -30,6 +30,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED)
end
+ it 'has the project html description' do
+ expect(Project.find_by_path('project').description_html).to eq('description')
+ end
+
it 'has the same label associated to two issues' do
expect(ProjectLabel.find_by_title('test2').issues.count).to eq(2)
end
@@ -82,8 +86,18 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
it 'has the correct data for merge request st_diffs' do
# makes sure we are renaming the custom method +utf8_st_diffs+ into +st_diffs+
+ # one MergeRequestDiff uses the new format, where st_diffs is expected to be nil
+
+ expect(MergeRequestDiff.where.not(st_diffs: nil).count).to eq(8)
+ end
+
+ it 'has the correct data for merge request diff files' do
+ expect(MergeRequestDiffFile.where.not(diff: nil).count).to eq(9)
+ end
- expect(MergeRequestDiff.where.not(st_diffs: nil).count).to eq(9)
+ it 'has the correct data for merge request diff commits in serialised and table formats' do
+ expect(MergeRequestDiff.where.not(st_commits: nil).count).to eq(7)
+ expect(MergeRequestDiffCommit.count).to eq(6)
end
it 'has the correct time for merge request st_commits' do
@@ -164,12 +178,13 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
context 'Light JSON' do
let(:user) { create(:user) }
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path') }
- let!(:project) { create(:empty_project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
+ let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) }
let(:restored_project_json) { project_tree_restorer.restore }
before do
- allow(ImportExport).to receive(:project_filename).and_return('project.light.json')
+ project_tree_restorer.instance_variable_set(:@path, "spec/lib/gitlab/import_export/project.light.json")
+
allow(shared).to receive(:export_path).and_return('spec/lib/gitlab/import_export/')
end
@@ -181,7 +196,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
restored_project_json
- expect(shared.errors.first).not_to include('test')
+ expect(shared.errors.first).to be_nil
end
end
end
@@ -196,7 +211,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
context 'with group' do
let!(:project) do
- create(:empty_project,
+ create(:project,
:builds_disabled,
:issues_disabled,
name: 'project',
@@ -205,15 +220,42 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do
end
before do
+ project_tree_restorer.instance_variable_set(:@path, "spec/lib/gitlab/import_export/project.light.json")
+
restored_project_json
end
- it 'has group labels' do
- expect(GroupLabel.count).to eq(1)
+ it 'correctly restores project' do
+ expect(restored_project_json).to be_truthy
+ expect(shared.errors).to be_empty
+ end
+
+ it 'has labels' do
+ expect(project.labels.count).to eq(2)
+ end
+
+ it 'creates group label' do
+ expect(project.group.labels.count).to eq(1)
end
it 'has label priorities' do
- expect(GroupLabel.first.priorities).not_to be_empty
+ expect(project.labels.first.priorities).not_to be_empty
+ end
+
+ it 'has milestones' do
+ expect(project.milestones.count).to eq(1)
+ end
+
+ it 'has issue' do
+ expect(project.issues.count).to eq(1)
+ expect(project.issues.first.labels.count).to eq(2)
+ end
+
+ it 'has issue with group label and project label' do
+ labels = project.issues.first.labels
+
+ expect(labels.where(type: "GroupLabel").count).to eq(1)
+ expect(labels.where(type: "ProjectLabel").count).to eq(1)
end
end
end
diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb
index d2d89e3b019..a278f89c1a1 100644
--- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::ImportExport::ProjectTreeSaver, services: true do
+describe Gitlab::ImportExport::ProjectTreeSaver do
describe 'saves the project tree into a json object' do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared) }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
let(:user) { create(:user) }
- let(:project) { setup_project }
+ let!(:project) { setup_project }
before do
project.team << [user, :master]
@@ -83,6 +83,14 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do
expect(saved_project_json['merge_requests'].first['merge_request_diff']['utf8_st_diffs']).not_to be_nil
end
+ it 'has merge request diff files' do
+ expect(saved_project_json['merge_requests'].first['merge_request_diff']['merge_request_diff_files']).not_to be_empty
+ end
+
+ it 'has merge request diff commits' do
+ expect(saved_project_json['merge_requests'].first['merge_request_diff']['merge_request_diff_commits']).not_to be_empty
+ end
+
it 'has merge requests comments' do
expect(saved_project_json['merge_requests'].first['notes']).not_to be_empty
end
@@ -145,6 +153,12 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do
expect(project_tree_saver.save).to be true
end
+ it 'does not complain about non UTF-8 characters in MR diff files' do
+ ActiveRecord::Base.connection.execute("UPDATE merge_request_diff_files SET diff = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'")
+
+ expect(project_tree_saver.save).to be true
+ end
+
context 'group members' do
let(:user2) { create(:user, email: 'group@member.com') }
let(:member_emails) do
@@ -189,11 +203,21 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do
end
end
end
+
+ context 'project attributes' do
+ it 'contains the html description' do
+ expect(saved_project_json).to include("description_html" => 'description')
+ end
+
+ it 'does not contain the runners token' do
+ expect(saved_project_json).not_to include("runners_token" => 'token')
+ end
+ end
end
end
def setup_project
- issue = create(:issue, assignee: user)
+ issue = create(:issue, assignees: [user])
snippet = create(:project_snippet)
release = create(:release)
group = create(:group)
@@ -209,6 +233,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do
releases: [release],
group: group
)
+ project.update_column(:description_html, 'description')
project_label = create(:label, project: project)
group_label = create(:group_label, group: group)
create(:label_link, label: project_label, target: issue)
diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb
index 48d74b07e27..e9f5273725d 100644
--- a/spec/lib/gitlab/import_export/reader_spec.rb
+++ b/spec/lib/gitlab/import_export/reader_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Gitlab::ImportExport::Reader, lib: true do
+describe Gitlab::ImportExport::Reader do
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: '') }
let(:test_config) { 'spec/support/import_export/import_export.yml' }
let(:project_tree_hash) do
{
- only: [:name, :path],
+ except: [:id, :created_at],
include: [:issues, :labels,
{ merge_requests: {
only: [:id],
diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb
index 06cd8ab87ed..f1df44cea75 100644
--- a/spec/lib/gitlab/import_export/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::ImportExport::RelationFactory, lib: true do
- let(:project) { create(:empty_project) }
+describe Gitlab::ImportExport::RelationFactory do
+ let(:project) { create(:project) }
let(:members_mapper) { double('members_mapper').as_null_object }
let(:user) { create(:admin) }
let(:created_object) do
@@ -33,7 +33,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do
'tag_push_events' => false,
'note_events' => true,
'enable_ssl_verification' => true,
- 'build_events' => false,
+ 'job_events' => false,
'wiki_page_events' => true,
'token' => token
}
@@ -95,7 +95,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do
'random_id' => 99,
'milestone_id' => 99,
'project_id' => 99,
- 'user_id' => 99,
+ 'user_id' => 99
}
end
diff --git a/spec/lib/gitlab/import_export/repo_restorer_spec.rb b/spec/lib/gitlab/import_export/repo_restorer_spec.rb
index 168a59e5139..2786bc92fe5 100644
--- a/spec/lib/gitlab/import_export/repo_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/repo_restorer_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Gitlab::ImportExport::RepoRestorer, services: true do
+describe Gitlab::ImportExport::RepoRestorer do
describe 'bundle a project Git repo' do
let(:user) { create(:user) }
- let!(:project_with_repo) { create(:project, :test_repo, name: 'test-repo-restorer', path: 'test-repo-restorer') }
- let!(:project) { create(:empty_project) }
+ let!(:project_with_repo) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') }
+ let!(:project) { create(:project) }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
let(:bundler) { Gitlab::ImportExport::RepoSaver.new(project: project_with_repo, shared: shared) }
let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
let(:restorer) do
@@ -34,7 +34,7 @@ describe Gitlab::ImportExport::RepoRestorer, services: true do
it 'has the webhooks' do
restorer.restore
- expect(Gitlab::Git::Hook.new('post-receive', project.repository.path_to_repo)).to exist
+ expect(Gitlab::Git::Hook.new('post-receive', project)).to exist
end
end
end
diff --git a/spec/lib/gitlab/import_export/repo_saver_spec.rb b/spec/lib/gitlab/import_export/repo_saver_spec.rb
index a7f4e11271e..e6ad516deef 100644
--- a/spec/lib/gitlab/import_export/repo_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/repo_saver_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Gitlab::ImportExport::RepoSaver, services: true do
+describe Gitlab::ImportExport::RepoSaver do
describe 'bundle a project Git repo' do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, :public, name: 'searchable_project') }
+ let!(:project) { create(:project, :public, name: 'searchable_project') }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
let(:bundler) { described_class.new(project: project, shared: shared) }
before do
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 0372e3f7dbf..ae3b0173160 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -23,6 +23,8 @@ Issue:
- weight
- time_estimate
- relative_position
+- last_edited_at
+- last_edited_by_id
Event:
- id
- target_type
@@ -34,6 +36,14 @@ Event:
- updated_at
- action
- author_id
+PushEventPayload:
+- commit_count
+- action
+- ref_type
+- commit_from
+- commit_to
+- ref
+- commit_title
Note:
- id
- note
@@ -52,6 +62,7 @@ Note:
- type
- position
- original_position
+- change_position
- resolved_at
- resolved_by_id
- discussion_id
@@ -79,6 +90,7 @@ Milestone:
- id
- title
- project_id
+- group_id
- description
- due_date
- start_date
@@ -89,6 +101,7 @@ Milestone:
ProjectSnippet:
- id
- title
+- description
- content
- author_id
- project_id
@@ -140,7 +153,6 @@ MergeRequest:
- iid
- description
- position
-- locked_at
- updated_by_id
- merge_error
- merge_params
@@ -154,6 +166,9 @@ MergeRequest:
- approvals_before_merge
- rebase_commit_sha
- time_estimate
+- last_edited_at
+- last_edited_by_id
+- head_pipeline_id
MergeRequestDiff:
- id
- state
@@ -165,9 +180,33 @@ MergeRequestDiff:
- real_size
- head_commit_sha
- start_commit_sha
+MergeRequestDiffCommit:
+- merge_request_diff_id
+- relative_order
+- sha
+- authored_date
+- committed_date
+- author_name
+- author_email
+- committer_name
+- committer_email
+- message
+MergeRequestDiffFile:
+- merge_request_diff_id
+- relative_order
+- new_file
+- renamed_file
+- deleted_file
+- new_path
+- old_path
+- a_mode
+- b_mode
+- too_large
+- binary
Ci::Pipeline:
- id
- project_id
+- source
- ref
- sha
- before_sha
@@ -184,6 +223,14 @@ Ci::Pipeline:
- user_id
- lock_version
- auto_canceled_by_id
+- pipeline_schedule_id
+Ci::Stage:
+- id
+- name
+- project_id
+- pipeline_id
+- created_at
+- updated_at
CommitStatus:
- id
- project_id
@@ -205,6 +252,7 @@ CommitStatus:
- stage
- trigger_request_id
- stage_idx
+- stage_id
- tag
- ref
- user_id
@@ -225,6 +273,7 @@ CommitStatus:
- lock_version
- coverage_regex
- auto_canceled_by_id
+- retried
Ci::Variable:
- id
- project_id
@@ -243,18 +292,19 @@ Ci::Trigger:
- owner_id
- description
- ref
-Ci::TriggerSchedule:
+Ci::PipelineSchedule:
- id
-- project_id
-- trigger_id
-- deleted_at
-- created_at
-- updated_at
+- description
+- ref
- cron
- cron_timezone
- next_run_at
-- ref
+- project_id
+- owner_id
- active
+- deleted_at
+- created_at
+- updated_at
DeployKey:
- id
- user_id
@@ -284,7 +334,7 @@ Service:
- tag_push_events
- note_events
- pipeline_events
-- build_events
+- job_events
- category
- default
- wiki_page_events
@@ -304,11 +354,12 @@ ProjectHook:
- note_events
- pipeline_events
- enable_ssl_verification
-- build_events
+- job_events
- wiki_page_events
- token
- group_id
- confidential_issues_events
+- repository_update_events
ProtectedBranch:
- id
- project_id
@@ -329,6 +380,31 @@ Project:
- snippets_enabled
- visibility_level
- archived
+- created_at
+- updated_at
+- last_activity_at
+- star_count
+- ci_id
+- shared_runners_enabled
+- build_coverage_regex
+- build_allow_git_fetchs
+- build_timeout
+- pending_delete
+- public_builds
+- last_repository_check_failed
+- last_repository_check_at
+- container_registry_enabled
+- only_allow_merge_if_pipeline_succeeds
+- has_external_issue_tracker
+- request_access_enabled
+- has_external_wiki
+- only_allow_merge_if_all_discussions_are_resolved
+- auto_cancel_pending_pipelines
+- printing_merge_request_link_enabled
+- build_allow_git_fetch
+- last_repository_updated_at
+- ci_config_path
+- delete_error
Author:
- name
ProjectFeature:
diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb
index 2405ac5abfe..e7d50f75682 100644
--- a/spec/lib/gitlab/import_export/version_checker_spec.rb
+++ b/spec/lib/gitlab/import_export/version_checker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
include ImportExport::CommonUtil
-describe Gitlab::ImportExport::VersionChecker, services: true do
+describe Gitlab::ImportExport::VersionChecker do
let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: '') }
describe 'bundle a project Git repo' do
diff --git a/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb b/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb
deleted file mode 100644
index 071e5fac3f0..00000000000
--- a/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::ImportExport::WikiRepoSaver, services: true do
- describe 'bundle a wiki Git repo' do
- let(:user) { create(:user) }
- let!(:project) { create(:empty_project, :public, name: 'searchable_project') }
- let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) }
- let(:wiki_bundler) { described_class.new(project: project, shared: shared) }
- let!(:project_wiki) { ProjectWiki.new(project, user) }
-
- before do
- project.team << [user, :master]
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
- project_wiki.wiki
- project_wiki.create_page("index", "test content")
- end
-
- after do
- FileUtils.rm_rf(export_path)
- end
-
- it 'bundles the repo successfully' do
- expect(wiki_bundler.save).to be true
- end
- end
-end
diff --git a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
new file mode 100644
index 00000000000..0e55993c8ef
--- /dev/null
+++ b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::WikiRepoSaver do
+ describe 'bundle a wiki Git repo' do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :public, name: 'searchable_project') }
+ let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
+ let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
+ let(:wiki_bundler) { described_class.new(project: project, shared: shared) }
+ let!(:project_wiki) { ProjectWiki.new(project, user) }
+
+ before do
+ project.team << [user, :master]
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ project_wiki.wiki
+ project_wiki.create_page("index", "test content")
+ end
+
+ after do
+ FileUtils.rm_rf(export_path)
+ end
+
+ it 'bundles the repo successfully' do
+ expect(wiki_bundler.save).to be true
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb
index b3b5e5e7e33..c5725f47453 100644
--- a/spec/lib/gitlab/import_sources_spec.rb
+++ b/spec/lib/gitlab/import_sources_spec.rb
@@ -56,7 +56,7 @@ describe Gitlab::ImportSources do
describe '.importer' do
import_sources = {
- 'github' => Gitlab::GithubImport::Importer,
+ 'github' => Github::Import,
'bitbucket' => Gitlab::BitbucketImport::Importer,
'gitlab' => Gitlab::GitlabImport::Importer,
'google_code' => Gitlab::GoogleCodeImport::Importer,
diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb
index 698bd72d0f8..c959add7a36 100644
--- a/spec/lib/gitlab/incoming_email_spec.rb
+++ b/spec/lib/gitlab/incoming_email_spec.rb
@@ -1,6 +1,6 @@
require "spec_helper"
-describe Gitlab::IncomingEmail, lib: true do
+describe Gitlab::IncomingEmail do
describe "self.enabled?" do
context "when reply by email is enabled" do
before do
diff --git a/spec/lib/gitlab/issuable_metadata_spec.rb b/spec/lib/gitlab/issuable_metadata_spec.rb
new file mode 100644
index 00000000000..2455969a183
--- /dev/null
+++ b/spec/lib/gitlab/issuable_metadata_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::IssuableMetadata do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace) }
+
+ subject { Class.new { include Gitlab::IssuableMetadata }.new }
+
+ it 'returns an empty Hash if an empty collection is provided' do
+ expect(subject.issuable_meta_data(Issue.none, 'Issue')).to eq({})
+ end
+
+ context 'issues' do
+ let!(:issue) { create(:issue, author: user, project: project) }
+ let!(:closed_issue) { create(:issue, state: :closed, author: user, project: project) }
+ let!(:downvote) { create(:award_emoji, :downvote, awardable: closed_issue) }
+ let!(:upvote) { create(:award_emoji, :upvote, awardable: issue) }
+ let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test") }
+ let!(:closing_issues) { create(:merge_requests_closing_issues, issue: issue, merge_request: merge_request) }
+
+ it 'aggregates stats on issues' do
+ data = subject.issuable_meta_data(Issue.all, 'Issue')
+
+ expect(data.count).to eq(2)
+ expect(data[issue.id].upvotes).to eq(1)
+ expect(data[issue.id].downvotes).to eq(0)
+ expect(data[issue.id].notes_count).to eq(0)
+ expect(data[issue.id].merge_requests_count).to eq(1)
+
+ expect(data[closed_issue.id].upvotes).to eq(0)
+ expect(data[closed_issue.id].downvotes).to eq(1)
+ expect(data[closed_issue.id].notes_count).to eq(0)
+ expect(data[closed_issue.id].merge_requests_count).to eq(0)
+ end
+ end
+
+ context 'merge requests' do
+ let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test") }
+ let!(:merge_request_closed) { create(:merge_request, state: "closed", source_project: project, target_project: project, title: "Closed Test") }
+ let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request) }
+ let!(:upvote) { create(:award_emoji, :upvote, awardable: merge_request) }
+ let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
+
+ it 'aggregates stats on merge requests' do
+ data = subject.issuable_meta_data(MergeRequest.all, 'MergeRequest')
+
+ expect(data.count).to eq(2)
+ expect(data[merge_request.id].upvotes).to eq(1)
+ expect(data[merge_request.id].downvotes).to eq(1)
+ expect(data[merge_request.id].notes_count).to eq(1)
+ expect(data[merge_request.id].merge_requests_count).to eq(0)
+
+ expect(data[merge_request_closed.id].upvotes).to eq(0)
+ expect(data[merge_request_closed.id].downvotes).to eq(0)
+ expect(data[merge_request_closed.id].notes_count).to eq(0)
+ expect(data[merge_request_closed.id].merge_requests_count).to eq(0)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/issuable_sorter_spec.rb b/spec/lib/gitlab/issuable_sorter_spec.rb
index c9a434b2bcf..642a6cb6caa 100644
--- a/spec/lib/gitlab/issuable_sorter_spec.rb
+++ b/spec/lib/gitlab/issuable_sorter_spec.rb
@@ -1,25 +1,25 @@
require 'spec_helper'
-describe Gitlab::IssuableSorter, lib: true do
- let(:namespace1) { build(:namespace, id: 1) }
- let(:project1) { build(:project, id: 1, namespace: namespace1) }
+describe Gitlab::IssuableSorter do
+ let(:namespace1) { build_stubbed(:namespace, id: 1) }
+ let(:project1) { build_stubbed(:project, id: 1, namespace: namespace1) }
- let(:project2) { build(:project, id: 2, path: "a", namespace: project1.namespace) }
- let(:project3) { build(:project, id: 3, path: "b", namespace: project1.namespace) }
+ let(:project2) { build_stubbed(:project, id: 2, path: "a", namespace: project1.namespace) }
+ let(:project3) { build_stubbed(:project, id: 3, path: "b", namespace: project1.namespace) }
- let(:namespace2) { build(:namespace, id: 2, path: "a") }
- let(:namespace3) { build(:namespace, id: 3, path: "b") }
- let(:project4) { build(:project, id: 4, path: "a", namespace: namespace2) }
- let(:project5) { build(:project, id: 5, path: "b", namespace: namespace2) }
- let(:project6) { build(:project, id: 6, path: "a", namespace: namespace3) }
+ let(:namespace2) { build_stubbed(:namespace, id: 2, path: "a") }
+ let(:namespace3) { build_stubbed(:namespace, id: 3, path: "b") }
+ let(:project4) { build_stubbed(:project, id: 4, path: "a", namespace: namespace2) }
+ let(:project5) { build_stubbed(:project, id: 5, path: "b", namespace: namespace2) }
+ let(:project6) { build_stubbed(:project, id: 6, path: "a", namespace: namespace3) }
let(:unsorted) { [sorted[2], sorted[3], sorted[0], sorted[1]] }
let(:sorted) do
- [build(:issue, iid: 1, project: project1),
- build(:issue, iid: 2, project: project1),
- build(:issue, iid: 10, project: project1),
- build(:issue, iid: 20, project: project1)]
+ [build_stubbed(:issue, iid: 1, project: project1),
+ build_stubbed(:issue, iid: 2, project: project1),
+ build_stubbed(:issue, iid: 10, project: project1),
+ build_stubbed(:issue, iid: 20, project: project1)]
end
it 'sorts references by a given key' do
@@ -41,14 +41,14 @@ describe Gitlab::IssuableSorter, lib: true do
context 'for references from multiple projects and namespaces' do
let(:sorted) do
- [build(:issue, iid: 1, project: project1),
- build(:issue, iid: 2, project: project1),
- build(:issue, iid: 10, project: project1),
- build(:issue, iid: 1, project: project2),
- build(:issue, iid: 1, project: project3),
- build(:issue, iid: 1, project: project4),
- build(:issue, iid: 1, project: project5),
- build(:issue, iid: 1, project: project6)]
+ [build_stubbed(:issue, iid: 1, project: project1),
+ build_stubbed(:issue, iid: 2, project: project1),
+ build_stubbed(:issue, iid: 10, project: project1),
+ build_stubbed(:issue, iid: 1, project: project2),
+ build_stubbed(:issue, iid: 1, project: project3),
+ build_stubbed(:issue, iid: 1, project: project4),
+ build_stubbed(:issue, iid: 1, project: project5),
+ build_stubbed(:issue, iid: 1, project: project6)]
end
let(:unsorted) do
[sorted[3], sorted[1], sorted[4], sorted[2],
diff --git a/spec/lib/gitlab/job_waiter_spec.rb b/spec/lib/gitlab/job_waiter_spec.rb
index 780f5b1f8d7..6186cec2689 100644
--- a/spec/lib/gitlab/job_waiter_spec.rb
+++ b/spec/lib/gitlab/job_waiter_spec.rb
@@ -4,8 +4,8 @@ describe Gitlab::JobWaiter do
describe '#wait' do
let(:waiter) { described_class.new(%w(a)) }
it 'returns when all jobs have been completed' do
- expect(Gitlab::SidekiqStatus).to receive(:all_completed?).with(%w(a)).
- and_return(true)
+ expect(Gitlab::SidekiqStatus).to receive(:all_completed?).with(%w(a))
+ .and_return(true)
expect(waiter).not_to receive(:sleep)
@@ -13,9 +13,9 @@ describe Gitlab::JobWaiter do
end
it 'sleeps between checking the job statuses' do
- expect(Gitlab::SidekiqStatus).to receive(:all_completed?).
- with(%w(a)).
- and_return(false, true)
+ expect(Gitlab::SidekiqStatus).to receive(:all_completed?)
+ .with(%w(a))
+ .and_return(false, true)
expect(waiter).to receive(:sleep).with(described_class::INTERVAL)
diff --git a/spec/lib/gitlab/key_fingerprint_spec.rb b/spec/lib/gitlab/key_fingerprint_spec.rb
index d09f51f3bfc..d643dc5342d 100644
--- a/spec/lib/gitlab/key_fingerprint_spec.rb
+++ b/spec/lib/gitlab/key_fingerprint_spec.rb
@@ -1,12 +1,82 @@
-require "spec_helper"
+require 'spec_helper'
describe Gitlab::KeyFingerprint, lib: true do
- let(:key) { "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" }
- let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" }
+ KEYS = {
+ rsa:
+ 'example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC5z65PwQ1GE6foJgwk' \
+ '9rmQi/glaXbUeVa5uvQpnZ3Z5+forcI7aTngh3aZ/H2UDP2L70TGy7kKNyp0J3a8/OdG' \
+ 'Z08y5yi3JlbjFARO1NyoFEjw2H1SJxeJ43L6zmvTlu+hlK1jSAlidl7enS0ufTlzEEj4' \
+ 'iJcuTPKdVzKRgZuTRVm9woWNVKqIrdRC0rJiTinERnfSAp/vNYERMuaoN4oJt8p/NEek' \
+ 'rmFoDsQOsyDW5RAnCnjWUU+jFBKDpfkJQ1U2n6BjJewC9dl6ODK639l3yN4WOLZEk4tN' \
+ 'UysfbGeF3rmMeflaD6O1Jplpv3YhwVGFNKa7fMq6k3Z0tszTJPYh',
+ ecdsa:
+ 'example.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' \
+ 'bmlzdHAyNTYAAABBBKTJy43NZzJSfNxpv/e2E6Zy3qoHoTQbmOsU5FEfpWfWa1MdTeXQ' \
+ 'YvKOi+qz/1AaNx6BK421jGu74JCDJtiZWT8=',
+ ed25519:
+ '@revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjq' \
+ 'uxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf',
+ dss:
+ 'example.com ssh-dss AAAAB3NzaC1kc3MAAACBAP1/U4EddRIpUt9KnC7s5Of2EbdS' \
+ 'PO9EAMMeP4C2USZpRV1AIlH7WT2NWPq/xfW6MPbLm1Vs14E7gB00b/JmYLdrmVClpJ+f' \
+ '6AR7ECLCT7up1/63xhv4O1fnxqimFQ8E+4P208UewwI1VBNaFpEy9nXzrith1yrv8iID' \
+ 'GZ3RSAHHAAAAFQCXYFCPFSMLzLKSuYKi64QL8Fgc9QAAAIEA9+GghdabPd7LvKtcNrhX' \
+ 'uXmUr7v6OuqC+VdMCz0HgmdRWVeOutRZT+ZxBxCBgLRJFnEj6EwoFhO3zwkyjMim4TwW' \
+ 'eotUfI0o4KOuHiuzpnWRbqN/C/ohNWLx+2J6ASQ7zKTxvqhRkImog9/hWuWfBpKLZl6A' \
+ 'e1UlZAFMO/7PSSoAAACBAJcQ4JODqhuGbXIEpqxetm7PWbdbCcr3y/GzIZ066pRovpL6' \
+ 'qm3qCVIym4cyChxWwb8qlyCIi+YRUUWm1z/wiBYT2Vf3S4FXBnyymCkKEaV/EY7+jd4X' \
+ '1bXI58OD2u+bLCB/sInM4fGB8CZUIWT9nJH0Ve9jJUge2ms348/QOJ1+'
+ }.freeze
- describe "#fingerprint" do
- it "generates the key's fingerprint" do
- expect(Gitlab::KeyFingerprint.new(key).fingerprint).to eq(fingerprint)
+ MD5_FINGERPRINTS = {
+ rsa: '06:b2:8a:92:df:0e:11:2c:ca:7b:8f:a4:ba:6e:4b:fd',
+ ecdsa: '45:ff:5b:98:9a:b6:8a:41:13:c1:30:8b:09:5e:7b:4e',
+ ed25519: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16',
+ dss: '57:98:86:02:5f:9c:f4:9b:ad:5a:1e:51:92:0e:fd:2b'
+ }.freeze
+
+ BIT_COUNTS = {
+ rsa: 2048,
+ ecdsa: 256,
+ ed25519: 256,
+ dss: 1024
+ }.freeze
+
+ describe '#type' do
+ KEYS.each do |type, key|
+ it "calculates the type of #{type} keys" do
+ calculated_type = described_class.new(key).type
+
+ expect(calculated_type).to eq(type.to_s.upcase)
+ end
+ end
+ end
+
+ describe '#fingerprint' do
+ KEYS.each do |type, key|
+ it "calculates the MD5 fingerprint for #{type} keys" do
+ fp = described_class.new(key).fingerprint
+
+ expect(fp).to eq(MD5_FINGERPRINTS[type])
+ end
+ end
+ end
+
+ describe '#bits' do
+ KEYS.each do |type, key|
+ it "calculates the number of bits in #{type} keys" do
+ bits = described_class.new(key).bits
+
+ expect(bits).to eq(BIT_COUNTS[type])
+ end
+ end
+ end
+
+ describe '#key' do
+ it 'carries the unmodified key data' do
+ key = described_class.new(KEYS[:rsa]).key
+
+ expect(key).to eq(KEYS[:rsa])
end
end
end
diff --git a/spec/lib/gitlab/kubernetes_spec.rb b/spec/lib/gitlab/kubernetes_spec.rb
index 91f9d06b85a..34b33772578 100644
--- a/spec/lib/gitlab/kubernetes_spec.rb
+++ b/spec/lib/gitlab/kubernetes_spec.rb
@@ -1,6 +1,7 @@
require 'spec_helper'
describe Gitlab::Kubernetes do
+ include KubernetesHelpers
include described_class
describe '#container_exec_url' do
@@ -36,4 +37,37 @@ describe Gitlab::Kubernetes do
it { expect(result.query).to match(/\Acontainer=container\+1&/) }
end
end
+
+ describe '#filter_by_label' do
+ it 'returns matching labels' do
+ matching_items = [kube_pod(app: 'foo')]
+ items = matching_items + [kube_pod]
+
+ expect(filter_by_label(items, app: 'foo')).to eq(matching_items)
+ end
+ end
+
+ describe '#to_kubeconfig' do
+ subject do
+ to_kubeconfig(
+ url: 'https://kube.domain.com',
+ namespace: 'NAMESPACE',
+ token: 'TOKEN',
+ ca_pem: ca_pem)
+ end
+
+ context 'when CA PEM is provided' do
+ let(:ca_pem) { 'PEM' }
+ let(:path) { expand_fixture_path('config/kubeconfig.yml') }
+
+ it { is_expected.to eq(YAML.load_file(path)) }
+ end
+
+ context 'when CA PEM is not provided' do
+ let(:ca_pem) { nil }
+ let(:path) { expand_fixture_path('config/kubeconfig-without-ca.yml') }
+
+ it { is_expected.to eq(YAML.load_file(path)) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/lazy_spec.rb b/spec/lib/gitlab/lazy_spec.rb
index b5ca89dd242..37a3ac74316 100644
--- a/spec/lib/gitlab/lazy_spec.rb
+++ b/spec/lib/gitlab/lazy_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Lazy, lib: true do
+describe Gitlab::Lazy do
let(:dummy) { double(:dummy) }
context 'when not calling any methods' do
diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb
index 9dd997aa7dc..6a47350be81 100644
--- a/spec/lib/gitlab/ldap/access_spec.rb
+++ b/spec/lib/gitlab/ldap/access_spec.rb
@@ -1,9 +1,19 @@
require 'spec_helper'
-describe Gitlab::LDAP::Access, lib: true do
- let(:access) { Gitlab::LDAP::Access.new user }
+describe Gitlab::LDAP::Access do
+ let(:access) { described_class.new user }
let(:user) { create(:omniauth_user) }
+ describe '.allowed?' do
+ it 'updates the users `last_credential_check_at' do
+ expect(access).to receive(:allowed?) { true }
+ expect(described_class).to receive(:open).and_yield(access)
+
+ expect { described_class.allowed?(user) }
+ .to change { user.last_credential_check_at }
+ end
+ end
+
describe '#allowed?' do
subject { access.allowed? }
diff --git a/spec/lib/gitlab/ldap/adapter_spec.rb b/spec/lib/gitlab/ldap/adapter_spec.rb
index 563c074017a..d17d440d833 100644
--- a/spec/lib/gitlab/ldap/adapter_spec.rb
+++ b/spec/lib/gitlab/ldap/adapter_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::LDAP::Adapter, lib: true do
+describe Gitlab::LDAP::Adapter do
include LdapHelpers
let(:ldap) { double(:ldap) }
@@ -16,7 +16,7 @@ describe Gitlab::LDAP::Adapter, lib: true do
expect(adapter).to receive(:ldap_search) do |arg|
expect(arg[:filter].to_s).to eq('(uid=johndoe)')
expect(arg[:base]).to eq('dc=example,dc=com')
- expect(arg[:attributes]).to match(%w{uid cn mail dn})
+ expect(arg[:attributes]).to match(%w{uid cn dn uid userid sAMAccountName mail email userPrincipalName})
end.and_return({})
adapter.users('uid', 'johndoe')
@@ -26,7 +26,7 @@ describe Gitlab::LDAP::Adapter, lib: true do
expect(adapter).to receive(:ldap_search).with(
base: 'uid=johndoe,ou=users,dc=example,dc=com',
scope: Net::LDAP::SearchScope_BaseObject,
- attributes: %w{uid cn mail dn},
+ attributes: %w{uid cn dn uid userid sAMAccountName mail email userPrincipalName},
filter: nil
).and_return({})
@@ -63,7 +63,7 @@ describe Gitlab::LDAP::Adapter, lib: true do
it 'uses the right uid attribute when non-default' do
stub_ldap_config(uid: 'sAMAccountName')
expect(adapter).to receive(:ldap_search).with(
- hash_including(attributes: %w{sAMAccountName cn mail dn})
+ hash_including(attributes: %w{sAMAccountName cn dn uid userid sAMAccountName mail email userPrincipalName})
).and_return({})
adapter.users('sAMAccountName', 'johndoe')
@@ -74,13 +74,17 @@ describe Gitlab::LDAP::Adapter, lib: true do
subject { adapter.dn_matches_filter?(:dn, :filter) }
context "when the search result is non-empty" do
- before { allow(adapter).to receive(:ldap_search).and_return([:foo]) }
+ before do
+ allow(adapter).to receive(:ldap_search).and_return([:foo])
+ end
it { is_expected.to be_truthy }
end
context "when the search result is empty" do
- before { allow(adapter).to receive(:ldap_search).and_return([]) }
+ before do
+ allow(adapter).to receive(:ldap_search).and_return([])
+ end
it { is_expected.to be_falsey }
end
@@ -91,13 +95,17 @@ describe Gitlab::LDAP::Adapter, lib: true do
context "when the search is successful" do
context "and the result is non-empty" do
- before { allow(ldap).to receive(:search).and_return([:foo]) }
+ before do
+ allow(ldap).to receive(:search).and_return([:foo])
+ end
it { is_expected.to eq [:foo] }
end
context "and the result is empty" do
- before { allow(ldap).to receive(:search).and_return([]) }
+ before do
+ allow(ldap).to receive(:search).and_return([])
+ end
it { is_expected.to eq [] }
end
diff --git a/spec/lib/gitlab/ldap/auth_hash_spec.rb b/spec/lib/gitlab/ldap/auth_hash_spec.rb
index 7a2f774b948..8370adf9211 100644
--- a/spec/lib/gitlab/ldap/auth_hash_spec.rb
+++ b/spec/lib/gitlab/ldap/auth_hash_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Gitlab::LDAP::AuthHash, lib: true do
+describe Gitlab::LDAP::AuthHash do
let(:auth_hash) do
- Gitlab::LDAP::AuthHash.new(
+ described_class.new(
OmniAuth::AuthHash.new(
- uid: '123456',
- provider: 'ldapmain',
+ uid: '123456',
+ provider: 'ldapmain',
info: info,
extra: {
raw_info: raw_info
@@ -33,11 +33,11 @@ describe Gitlab::LDAP::AuthHash, lib: true do
context "without overridden attributes" do
it "has the correct username" do
- expect(auth_hash.username).to eq("123456")
+ expect(auth_hash.username).to eq("123456")
end
it "has the correct name" do
- expect(auth_hash.name).to eq("Smith, J.")
+ expect(auth_hash.name).to eq("Smith, J.")
end
end
@@ -54,11 +54,11 @@ describe Gitlab::LDAP::AuthHash, lib: true do
end
it "has the correct username" do
- expect(auth_hash.username).to eq("johnsmith@example.com")
+ expect(auth_hash.username).to eq("johnsmith@example.com")
end
it "has the correct name" do
- expect(auth_hash.name).to eq("John Smith")
+ expect(auth_hash.name).to eq("John Smith")
end
end
end
diff --git a/spec/lib/gitlab/ldap/authentication_spec.rb b/spec/lib/gitlab/ldap/authentication_spec.rb
index b8f3290e84c..01b6282af0c 100644
--- a/spec/lib/gitlab/ldap/authentication_spec.rb
+++ b/spec/lib/gitlab/ldap/authentication_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::LDAP::Authentication, lib: true do
+describe Gitlab::LDAP::Authentication do
let(:user) { create(:omniauth_user, extern_uid: dn) }
let(:dn) { 'uid=john,ou=people,dc=example,dc=com' }
let(:login) { 'john' }
@@ -16,8 +16,8 @@ describe Gitlab::LDAP::Authentication, lib: true do
# try only to fake the LDAP call
adapter = double('adapter', dn: dn).as_null_object
- allow_any_instance_of(described_class).
- to receive(:adapter).and_return(adapter)
+ allow_any_instance_of(described_class)
+ .to receive(:adapter).and_return(adapter)
expect(described_class.login(login, password)).to be_truthy
end
@@ -25,8 +25,8 @@ describe Gitlab::LDAP::Authentication, lib: true do
it "is false if the user does not exist" do
# try only to fake the LDAP call
adapter = double('adapter', dn: dn).as_null_object
- allow_any_instance_of(described_class).
- to receive(:adapter).and_return(adapter)
+ allow_any_instance_of(described_class)
+ .to receive(:adapter).and_return(adapter)
expect(described_class.login(login, password)).to be_falsey
end
@@ -36,8 +36,8 @@ describe Gitlab::LDAP::Authentication, lib: true do
# try only to fake the LDAP call
adapter = double('adapter', bind_as: nil).as_null_object
- allow_any_instance_of(described_class).
- to receive(:adapter).and_return(adapter)
+ allow_any_instance_of(described_class)
+ .to receive(:adapter).and_return(adapter)
expect(described_class.login(login, password)).to be_falsey
end
diff --git a/spec/lib/gitlab/ldap/config_spec.rb b/spec/lib/gitlab/ldap/config_spec.rb
index cab2e9908ff..ca2213cd112 100644
--- a/spec/lib/gitlab/ldap/config_spec.rb
+++ b/spec/lib/gitlab/ldap/config_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Gitlab::LDAP::Config, lib: true do
+describe Gitlab::LDAP::Config do
include LdapHelpers
- let(:config) { Gitlab::LDAP::Config.new('ldapmain') }
+ let(:config) { described_class.new('ldapmain') }
- describe '#initalize' do
+ describe '#initialize' do
it 'requires a provider' do
- expect{ Gitlab::LDAP::Config.new }.to raise_error ArgumentError
+ expect { described_class.new }.to raise_error ArgumentError
end
it 'works' do
@@ -15,7 +15,7 @@ describe Gitlab::LDAP::Config, lib: true do
end
it 'raises an error if a unknown provider is used' do
- expect{ Gitlab::LDAP::Config.new 'unknown' }.to raise_error(RuntimeError)
+ expect { described_class.new 'unknown' }.to raise_error(RuntimeError)
end
end
@@ -23,9 +23,9 @@ describe Gitlab::LDAP::Config, lib: true do
it 'constructs basic options' do
stub_ldap_config(
options: {
- 'host' => 'ldap.example.com',
- 'port' => 386,
- 'method' => 'plain'
+ 'host' => 'ldap.example.com',
+ 'port' => 386,
+ 'encryption' => 'plain'
}
)
@@ -39,24 +39,140 @@ describe Gitlab::LDAP::Config, lib: true do
it 'includes authentication options when auth is configured' do
stub_ldap_config(
options: {
- 'host' => 'ldap.example.com',
- 'port' => 686,
- 'method' => 'ssl',
- 'bind_dn' => 'uid=admin,dc=example,dc=com',
- 'password' => 'super_secret'
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'bind_dn' => 'uid=admin,dc=example,dc=com',
+ 'password' => 'super_secret'
}
)
- expect(config.adapter_options).to eq(
- host: 'ldap.example.com',
- port: 686,
- encryption: :simple_tls,
+ expect(config.adapter_options).to include({
auth: {
method: :simple,
username: 'uid=admin,dc=example,dc=com',
password: 'super_secret'
}
+ })
+ end
+
+ it 'sets encryption method to simple_tls when configured as simple_tls' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls'
+ }
)
+
+ expect(config.adapter_options[:encryption]).to include({ method: :simple_tls })
+ end
+
+ it 'sets encryption method to start_tls when configured as start_tls' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'start_tls'
+ }
+ )
+
+ expect(config.adapter_options[:encryption]).to include({ method: :start_tls })
+ end
+
+ context 'when verify_certificates is enabled' do
+ it 'sets tls_options to OpenSSL defaults' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true
+ }
+ )
+
+ expect(config.adapter_options[:encryption]).to include({ tls_options: OpenSSL::SSL::SSLContext::DEFAULT_PARAMS })
+ end
+ end
+
+ context 'when verify_certificates is disabled' do
+ it 'sets verify_mode to OpenSSL VERIFY_NONE' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => false
+ }
+ )
+
+ expect(config.adapter_options[:encryption]).to include({
+ tls_options: {
+ verify_mode: OpenSSL::SSL::VERIFY_NONE
+ }
+ })
+ end
+ end
+
+ context 'when ca_file is specified' do
+ it 'passes it through in tls_options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'ca_file' => '/etc/ca.pem'
+ }
+ )
+
+ expect(config.adapter_options[:encryption][:tls_options]).to include({ ca_file: '/etc/ca.pem' })
+ end
+ end
+
+ context 'when ca_file is a blank string' do
+ it 'does not add the ca_file key to tls_options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'ca_file' => ' '
+ }
+ )
+
+ expect(config.adapter_options[:encryption][:tls_options]).not_to have_key(:ca_file)
+ end
+ end
+
+ context 'when ssl_version is specified' do
+ it 'passes it through in tls_options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'ssl_version' => 'TLSv1_2'
+ }
+ )
+
+ expect(config.adapter_options[:encryption][:tls_options]).to include({ ssl_version: 'TLSv1_2' })
+ end
+ end
+
+ context 'when ssl_version is a blank string' do
+ it 'does not add the ssl_version key to tls_options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'ssl_version' => ' '
+ }
+ )
+
+ expect(config.adapter_options[:encryption][:tls_options]).not_to have_key(:ssl_version)
+ end
end
end
@@ -64,11 +180,11 @@ describe Gitlab::LDAP::Config, lib: true do
it 'constructs basic options' do
stub_ldap_config(
options: {
- 'host' => 'ldap.example.com',
- 'port' => 386,
- 'base' => 'ou=users,dc=example,dc=com',
- 'method' => 'plain',
- 'uid' => 'uid'
+ 'host' => 'ldap.example.com',
+ 'port' => 386,
+ 'base' => 'ou=users,dc=example,dc=com',
+ 'encryption' => 'plain',
+ 'uid' => 'uid'
}
)
@@ -76,7 +192,7 @@ describe Gitlab::LDAP::Config, lib: true do
host: 'ldap.example.com',
port: 386,
base: 'ou=users,dc=example,dc=com',
- method: 'plain',
+ encryption: 'plain',
filter: '(uid=%{username})'
)
expect(config.omniauth_options.keys).not_to include(:bind_dn, :password)
@@ -98,6 +214,100 @@ describe Gitlab::LDAP::Config, lib: true do
password: 'super_secret'
)
end
+
+ context 'when verify_certificates is enabled' do
+ it 'specifies disable_verify_certificates as false' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true
+ }
+ )
+
+ expect(config.omniauth_options).to include({ disable_verify_certificates: false })
+ end
+ end
+
+ context 'when verify_certificates is disabled' do
+ it 'specifies disable_verify_certificates as true' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => false
+ }
+ )
+
+ expect(config.omniauth_options).to include({ disable_verify_certificates: true })
+ end
+ end
+
+ context 'when ca_file is present' do
+ it 'passes it through' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'ca_file' => '/etc/ca.pem'
+ }
+ )
+
+ expect(config.omniauth_options).to include({ ca_file: '/etc/ca.pem' })
+ end
+ end
+
+ context 'when ca_file is blank' do
+ it 'does not include the ca_file option' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'ca_file' => ' '
+ }
+ )
+
+ expect(config.omniauth_options).not_to have_key(:ca_file)
+ end
+ end
+
+ context 'when ssl_version is present' do
+ it 'passes it through' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'ssl_version' => 'TLSv1_2'
+ }
+ )
+
+ expect(config.omniauth_options).to include({ ssl_version: 'TLSv1_2' })
+ end
+ end
+
+ context 'when ssl_version is blank' do
+ it 'does not include the ssl_version option' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 686,
+ 'encryption' => 'simple_tls',
+ 'verify_certificates' => true,
+ 'ssl_version' => ' '
+ }
+ )
+
+ expect(config.omniauth_options).not_to have_key(:ssl_version)
+ end
+ end
end
describe '#has_auth?' do
diff --git a/spec/lib/gitlab/ldap/person_spec.rb b/spec/lib/gitlab/ldap/person_spec.rb
index 9a556cde5d5..087c4d8c92c 100644
--- a/spec/lib/gitlab/ldap/person_spec.rb
+++ b/spec/lib/gitlab/ldap/person_spec.rb
@@ -20,7 +20,7 @@ describe Gitlab::LDAP::Person do
it 'uses the configured name attribute and handles values as an array' do
name = 'John Doe'
entry['cn'] = [name]
- person = Gitlab::LDAP::Person.new(entry, 'ldapmain')
+ person = described_class.new(entry, 'ldapmain')
expect(person.name).to eq(name)
end
@@ -30,7 +30,7 @@ describe Gitlab::LDAP::Person do
it 'returns the value of mail, if present' do
mail = 'john@example.com'
entry['mail'] = mail
- person = Gitlab::LDAP::Person.new(entry, 'ldapmain')
+ person = described_class.new(entry, 'ldapmain')
expect(person.email).to eq([mail])
end
@@ -38,7 +38,7 @@ describe Gitlab::LDAP::Person do
it 'returns the value of userPrincipalName, if mail and email are not present' do
user_principal_name = 'john.doe@example.com'
entry['userPrincipalName'] = user_principal_name
- person = Gitlab::LDAP::Person.new(entry, 'ldapmain')
+ person = described_class.new(entry, 'ldapmain')
expect(person.email).to eq([user_principal_name])
end
diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb
index f4aab429931..5100a5a609e 100644
--- a/spec/lib/gitlab/ldap/user_spec.rb
+++ b/spec/lib/gitlab/ldap/user_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::LDAP::User, lib: true do
- let(:ldap_user) { Gitlab::LDAP::User.new(auth_hash) }
+describe Gitlab::LDAP::User do
+ let(:ldap_user) { described_class.new(auth_hash) }
let(:gl_user) { ldap_user.gl_user }
let(:info) do
{
@@ -13,7 +13,7 @@ describe Gitlab::LDAP::User, lib: true do
let(:auth_hash) do
OmniAuth::AuthHash.new(uid: 'my-uid', provider: 'ldapmain', info: info)
end
- let(:ldap_user_upper_case) { Gitlab::LDAP::User.new(auth_hash_upper_case) }
+ let(:ldap_user_upper_case) { described_class.new(auth_hash_upper_case) }
let(:info_upper_case) do
{
name: 'John',
@@ -37,7 +37,7 @@ describe Gitlab::LDAP::User, lib: true do
end
it "does not mark existing ldap user as changed" do
- create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', ldap_email: true)
+ create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', external_email: true, email_provider: 'ldapmain')
expect(ldap_user.changed?).to be_falsey
end
end
@@ -61,12 +61,12 @@ describe Gitlab::LDAP::User, lib: true do
it "finds the user if already existing" do
create(:omniauth_user, extern_uid: 'my-uid', provider: 'ldapmain')
- expect{ ldap_user.save }.not_to change{ User.count }
+ expect { ldap_user.save }.not_to change { User.count }
end
it "connects to existing non-ldap user if the email matches" do
existing_user = create(:omniauth_user, email: 'john@example.com', provider: "twitter")
- expect{ ldap_user.save }.not_to change{ User.count }
+ expect { ldap_user.save }.not_to change { User.count }
existing_user.reload
expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid'
@@ -75,7 +75,7 @@ describe Gitlab::LDAP::User, lib: true do
it 'connects to existing ldap user if the extern_uid changes' do
existing_user = create(:omniauth_user, email: 'john@example.com', extern_uid: 'old-uid', provider: 'ldapmain')
- expect{ ldap_user.save }.not_to change{ User.count }
+ expect { ldap_user.save }.not_to change { User.count }
existing_user.reload
expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid'
@@ -85,7 +85,7 @@ describe Gitlab::LDAP::User, lib: true do
it 'connects to existing ldap user if the extern_uid changes and email address has upper case characters' do
existing_user = create(:omniauth_user, email: 'john@example.com', extern_uid: 'old-uid', provider: 'ldapmain')
- expect{ ldap_user_upper_case.save }.not_to change{ User.count }
+ expect { ldap_user_upper_case.save }.not_to change { User.count }
existing_user.reload
expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid'
@@ -106,7 +106,7 @@ describe Gitlab::LDAP::User, lib: true do
end
it "creates a new user if not found" do
- expect{ ldap_user.save }.to change{ User.count }.by(1)
+ expect { ldap_user.save }.to change { User.count }.by(1)
end
context 'when signup is disabled' do
@@ -141,8 +141,12 @@ describe Gitlab::LDAP::User, lib: true do
expect(ldap_user.gl_user.email).to eq(info[:email])
end
- it "has ldap_email set to true" do
- expect(ldap_user.gl_user.ldap_email?).to be(true)
+ it "has external_email set to true" do
+ expect(ldap_user.gl_user.external_email?).to be(true)
+ end
+
+ it "has email_provider set to provider" do
+ expect(ldap_user.gl_user.email_provider).to eql 'ldapmain'
end
end
@@ -155,21 +159,23 @@ describe Gitlab::LDAP::User, lib: true do
expect(ldap_user.gl_user.temp_oauth_email?).to be(true)
end
- it "has ldap_email set to false" do
- expect(ldap_user.gl_user.ldap_email?).to be(false)
+ it "has external_email set to false" do
+ expect(ldap_user.gl_user.external_email?).to be(false)
end
end
end
describe 'blocking' do
def configure_block(value)
- allow_any_instance_of(Gitlab::LDAP::Config).
- to receive(:block_auto_created_users).and_return(value)
+ allow_any_instance_of(Gitlab::LDAP::Config)
+ .to receive(:block_auto_created_users).and_return(value)
end
context 'signup' do
context 'dont block on create' do
- before { configure_block(false) }
+ before do
+ configure_block(false)
+ end
it do
ldap_user.save
@@ -179,7 +185,9 @@ describe Gitlab::LDAP::User, lib: true do
end
context 'block on create' do
- before { configure_block(true) }
+ before do
+ configure_block(true)
+ end
it do
ldap_user.save
@@ -196,7 +204,9 @@ describe Gitlab::LDAP::User, lib: true do
end
context 'dont block on create' do
- before { configure_block(false) }
+ before do
+ configure_block(false)
+ end
it do
ldap_user.save
@@ -206,7 +216,9 @@ describe Gitlab::LDAP::User, lib: true do
end
context 'block on create' do
- before { configure_block(true) }
+ before do
+ configure_block(true)
+ end
it do
ldap_user.save
diff --git a/spec/lib/gitlab/lfs_token_spec.rb b/spec/lib/gitlab/lfs_token_spec.rb
index e9c1163e22a..3a20dad16d0 100644
--- a/spec/lib/gitlab/lfs_token_spec.rb
+++ b/spec/lib/gitlab/lfs_token_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::LfsToken, lib: true do
+describe Gitlab::LfsToken do
describe '#token' do
shared_examples 'an LFS token generator' do
it 'returns a randomly generated token' do
diff --git a/spec/lib/gitlab/markup_helper_spec.rb b/spec/lib/gitlab/markup_helper_spec.rb
index 93b91b849f2..09e518ff989 100644
--- a/spec/lib/gitlab/markup_helper_spec.rb
+++ b/spec/lib/gitlab/markup_helper_spec.rb
@@ -1,40 +1,40 @@
require 'spec_helper'
-describe Gitlab::MarkupHelper, lib: true do
+describe Gitlab::MarkupHelper do
describe '#markup?' do
%w(textile rdoc org creole wiki
mediawiki rst adoc ad asciidoc mdown md markdown).each do |type|
it "returns true for #{type} files" do
- expect(Gitlab::MarkupHelper.markup?("README.#{type}")).to be_truthy
+ expect(described_class.markup?("README.#{type}")).to be_truthy
end
end
it 'returns false when given a non-markup filename' do
- expect(Gitlab::MarkupHelper.markup?('README.rb')).not_to be_truthy
+ expect(described_class.markup?('README.rb')).not_to be_truthy
end
end
describe '#gitlab_markdown?' do
%w(mdown mkd mkdn md markdown).each do |type|
it "returns true for #{type} files" do
- expect(Gitlab::MarkupHelper.gitlab_markdown?("README.#{type}")).to be_truthy
+ expect(described_class.gitlab_markdown?("README.#{type}")).to be_truthy
end
end
it 'returns false when given a non-markdown filename' do
- expect(Gitlab::MarkupHelper.gitlab_markdown?('README.rb')).not_to be_truthy
+ expect(described_class.gitlab_markdown?('README.rb')).not_to be_truthy
end
end
describe '#asciidoc?' do
%w(adoc ad asciidoc ADOC).each do |type|
it "returns true for #{type} files" do
- expect(Gitlab::MarkupHelper.asciidoc?("README.#{type}")).to be_truthy
+ expect(described_class.asciidoc?("README.#{type}")).to be_truthy
end
end
it 'returns false when given a non-asciidoc filename' do
- expect(Gitlab::MarkupHelper.asciidoc?('README.rb')).not_to be_truthy
+ expect(described_class.asciidoc?('README.rb')).not_to be_truthy
end
end
end
diff --git a/spec/lib/gitlab/metrics/influx_sampler_spec.rb b/spec/lib/gitlab/metrics/influx_sampler_spec.rb
new file mode 100644
index 00000000000..999a9536d82
--- /dev/null
+++ b/spec/lib/gitlab/metrics/influx_sampler_spec.rb
@@ -0,0 +1,150 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::InfluxSampler do
+ let(:sampler) { described_class.new(5) }
+
+ after do
+ Allocations.stop if Gitlab::Metrics.mri?
+ end
+
+ describe '#start' do
+ it 'runs once and gathers a sample at a given interval' do
+ expect(sampler).to receive(:sleep).with(a_kind_of(Numeric)).twice
+ expect(sampler).to receive(:sample).once
+ expect(sampler).to receive(:running).and_return(true, false)
+
+ sampler.start.join
+ end
+ end
+
+ describe '#sample' do
+ it 'samples various statistics' do
+ expect(sampler).to receive(:sample_memory_usage)
+ expect(sampler).to receive(:sample_file_descriptors)
+ expect(sampler).to receive(:sample_objects)
+ expect(sampler).to receive(:sample_gc)
+ expect(sampler).to receive(:flush)
+
+ sampler.sample
+ end
+
+ it 'clears any GC profiles' do
+ expect(sampler).to receive(:flush)
+ expect(GC::Profiler).to receive(:clear)
+
+ sampler.sample
+ end
+ end
+
+ describe '#flush' do
+ it 'schedules the metrics using Sidekiq' do
+ expect(Gitlab::Metrics).to receive(:submit_metrics)
+ .with([an_instance_of(Hash)])
+
+ sampler.sample_memory_usage
+ sampler.flush
+ end
+ end
+
+ describe '#sample_memory_usage' do
+ it 'adds a metric containing the memory usage' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage)
+ .and_return(9000)
+
+ expect(sampler).to receive(:add_metric)
+ .with(/memory_usage/, value: 9000)
+ .and_call_original
+
+ sampler.sample_memory_usage
+ end
+ end
+
+ describe '#sample_file_descriptors' do
+ it 'adds a metric containing the amount of open file descriptors' do
+ expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
+ .and_return(4)
+
+ expect(sampler).to receive(:add_metric)
+ .with(/file_descriptors/, value: 4)
+ .and_call_original
+
+ sampler.sample_file_descriptors
+ end
+ end
+
+ if Gitlab::Metrics.mri?
+ describe '#sample_objects' do
+ it 'adds a metric containing the amount of allocated objects' do
+ expect(sampler).to receive(:add_metric)
+ .with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash))
+ .at_least(:once)
+ .and_call_original
+
+ sampler.sample_objects
+ end
+
+ it 'ignores classes without a name' do
+ expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 })
+
+ expect(sampler).not_to receive(:add_metric)
+ .with('object_counts', an_instance_of(Hash), type: nil)
+
+ sampler.sample_objects
+ end
+ end
+ end
+
+ describe '#sample_gc' do
+ it 'adds a metric containing garbage collection statistics' do
+ expect(GC::Profiler).to receive(:total_time).and_return(0.24)
+
+ expect(sampler).to receive(:add_metric)
+ .with(/gc_statistics/, an_instance_of(Hash))
+ .and_call_original
+
+ sampler.sample_gc
+ end
+ end
+
+ describe '#add_metric' do
+ it 'prefixes the series name for a Rails process' do
+ expect(sampler).to receive(:sidekiq?).and_return(false)
+
+ expect(Gitlab::Metrics::Metric).to receive(:new)
+ .with('rails_cats', { value: 10 }, {})
+ .and_call_original
+
+ sampler.add_metric('cats', value: 10)
+ end
+
+ it 'prefixes the series name for a Sidekiq process' do
+ expect(sampler).to receive(:sidekiq?).and_return(true)
+
+ expect(Gitlab::Metrics::Metric).to receive(:new)
+ .with('sidekiq_cats', { value: 10 }, {})
+ .and_call_original
+
+ sampler.add_metric('cats', value: 10)
+ end
+ end
+
+ describe '#sleep_interval' do
+ it 'returns a Numeric' do
+ expect(sampler.sleep_interval).to be_a_kind_of(Numeric)
+ end
+
+ # Testing random behaviour is very hard, so treat this test as a basic smoke
+ # test instead of a very accurate behaviour/unit test.
+ it 'does not return the same interval twice in a row' do
+ last = nil
+
+ 100.times do
+ interval = sampler.sleep_interval
+
+ expect(interval).not_to eq(last)
+
+ last = interval
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb
index a986cb520fb..4b19ee19103 100644
--- a/spec/lib/gitlab/metrics/instrumentation_spec.rb
+++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb
@@ -78,11 +78,11 @@ describe Gitlab::Metrics::Instrumentation do
end
it 'tracks the call duration upon calling the method' do
- allow(Gitlab::Metrics).to receive(:method_call_threshold).
- and_return(0)
+ allow(Gitlab::Metrics).to receive(:method_call_threshold)
+ .and_return(0)
- allow(described_class).to receive(:transaction).
- and_return(transaction)
+ allow(described_class).to receive(:transaction)
+ .and_return(transaction)
expect_any_instance_of(Gitlab::Metrics::MethodCall).to receive(:measure)
@@ -90,8 +90,8 @@ describe Gitlab::Metrics::Instrumentation do
end
it 'does not track method calls below a given duration threshold' do
- allow(Gitlab::Metrics).to receive(:method_call_threshold).
- and_return(100)
+ allow(Gitlab::Metrics).to receive(:method_call_threshold)
+ .and_return(100)
expect(transaction).not_to receive(:add_metric)
@@ -137,8 +137,8 @@ describe Gitlab::Metrics::Instrumentation do
before do
allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
- described_class.
- instrument_instance_method(@dummy, :bar)
+ described_class
+ .instrument_instance_method(@dummy, :bar)
end
it 'instruments instances of the Class' do
@@ -156,11 +156,11 @@ describe Gitlab::Metrics::Instrumentation do
end
it 'tracks the call duration upon calling the method' do
- allow(Gitlab::Metrics).to receive(:method_call_threshold).
- and_return(0)
+ allow(Gitlab::Metrics).to receive(:method_call_threshold)
+ .and_return(0)
- allow(described_class).to receive(:transaction).
- and_return(transaction)
+ allow(described_class).to receive(:transaction)
+ .and_return(transaction)
expect_any_instance_of(Gitlab::Metrics::MethodCall).to receive(:measure)
@@ -168,8 +168,8 @@ describe Gitlab::Metrics::Instrumentation do
end
it 'does not track method calls below a given duration threshold' do
- allow(Gitlab::Metrics).to receive(:method_call_threshold).
- and_return(100)
+ allow(Gitlab::Metrics).to receive(:method_call_threshold)
+ .and_return(100)
expect(transaction).not_to receive(:add_metric)
@@ -183,8 +183,8 @@ describe Gitlab::Metrics::Instrumentation do
end
it 'does not instrument the method' do
- described_class.
- instrument_instance_method(@dummy, :bar)
+ described_class
+ .instrument_instance_method(@dummy, :bar)
expect(described_class.instrumented?(@dummy)).to eq(false)
end
diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
index fb470ea7568..ec415f2bd85 100644
--- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
@@ -26,8 +26,8 @@ describe Gitlab::Metrics::RackMiddleware do
allow(app).to receive(:call).with(env)
- expect(middleware).to receive(:tag_controller).
- with(an_instance_of(Gitlab::Metrics::Transaction), env)
+ expect(middleware).to receive(:tag_controller)
+ .with(an_instance_of(Gitlab::Metrics::Transaction), env)
middleware.call(env)
end
@@ -40,8 +40,8 @@ describe Gitlab::Metrics::RackMiddleware do
allow(app).to receive(:call).with(env)
- expect(middleware).to receive(:tag_endpoint).
- with(an_instance_of(Gitlab::Metrics::Transaction), env)
+ expect(middleware).to receive(:tag_endpoint)
+ .with(an_instance_of(Gitlab::Metrics::Transaction), env)
middleware.call(env)
end
@@ -49,8 +49,8 @@ describe Gitlab::Metrics::RackMiddleware do
it 'tracks any raised exceptions' do
expect(app).to receive(:call).with(env).and_raise(RuntimeError)
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- to receive(:add_event).with(:rails_exception)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .to receive(:add_event).with(:rails_exception)
expect { middleware.call(env) }.to raise_error(RuntimeError)
end
diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
new file mode 100644
index 00000000000..ebe66948a91
--- /dev/null
+++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::RequestsRackMiddleware do
+ let(:app) { double('app') }
+ subject { described_class.new(app) }
+
+ describe '#call' do
+ let(:status) { 100 }
+ let(:env) { { 'REQUEST_METHOD' => 'GET' } }
+ let(:stack_result) { [status, {}, 'body'] }
+
+ before do
+ allow(app).to receive(:call).and_return(stack_result)
+ end
+
+ context '@app.call succeeds with 200' do
+ before do
+ allow(app).to receive(:call).and_return([200, nil, nil])
+ end
+
+ it 'increments requests count' do
+ expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get')
+
+ subject.call(env)
+ end
+
+ RSpec::Matchers.define :a_positive_execution_time do
+ match { |actual| actual > 0 }
+ end
+
+ it 'measures execution time' do
+ expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ status: 200, method: 'get' }, a_positive_execution_time)
+
+ Timecop.scale(3600) { subject.call(env) }
+ end
+ end
+
+ context '@app.call throws exception' do
+ let(:http_request_duration_seconds) { double('http_request_duration_seconds') }
+
+ before do
+ allow(app).to receive(:call).and_raise(StandardError)
+ allow(described_class).to receive(:http_request_duration_seconds).and_return(http_request_duration_seconds)
+ end
+
+ it 'increments exceptions count' do
+ expect(described_class).to receive_message_chain(:rack_uncaught_errors_count, :increment)
+
+ expect { subject.call(env) }.to raise_error(StandardError)
+ end
+
+ it 'increments requests count' do
+ expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get')
+
+ expect { subject.call(env) }.to raise_error(StandardError)
+ end
+
+ it "does't measure request execution time" do
+ expect(described_class.http_request_duration_seconds).not_to receive(:increment)
+
+ expect { subject.call(env) }.to raise_error(StandardError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/sampler_spec.rb b/spec/lib/gitlab/metrics/sampler_spec.rb
deleted file mode 100644
index 1ab923b58cf..00000000000
--- a/spec/lib/gitlab/metrics/sampler_spec.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Metrics::Sampler do
- let(:sampler) { described_class.new(5) }
-
- after do
- Allocations.stop if Gitlab::Metrics.mri?
- end
-
- describe '#start' do
- it 'gathers a sample at a given interval' do
- expect(sampler).to receive(:sleep).with(a_kind_of(Numeric))
- expect(sampler).to receive(:sample)
- expect(sampler).to receive(:loop).and_yield
-
- sampler.start.join
- end
- end
-
- describe '#sample' do
- it 'samples various statistics' do
- expect(sampler).to receive(:sample_memory_usage)
- expect(sampler).to receive(:sample_file_descriptors)
- expect(sampler).to receive(:sample_objects)
- expect(sampler).to receive(:sample_gc)
- expect(sampler).to receive(:flush)
-
- sampler.sample
- end
-
- it 'clears any GC profiles' do
- expect(sampler).to receive(:flush)
- expect(GC::Profiler).to receive(:clear)
-
- sampler.sample
- end
- end
-
- describe '#flush' do
- it 'schedules the metrics using Sidekiq' do
- expect(Gitlab::Metrics).to receive(:submit_metrics).
- with([an_instance_of(Hash)])
-
- sampler.sample_memory_usage
- sampler.flush
- end
- end
-
- describe '#sample_memory_usage' do
- it 'adds a metric containing the memory usage' do
- expect(Gitlab::Metrics::System).to receive(:memory_usage).
- and_return(9000)
-
- expect(sampler).to receive(:add_metric).
- with(/memory_usage/, value: 9000).
- and_call_original
-
- sampler.sample_memory_usage
- end
- end
-
- describe '#sample_file_descriptors' do
- it 'adds a metric containing the amount of open file descriptors' do
- expect(Gitlab::Metrics::System).to receive(:file_descriptor_count).
- and_return(4)
-
- expect(sampler).to receive(:add_metric).
- with(/file_descriptors/, value: 4).
- and_call_original
-
- sampler.sample_file_descriptors
- end
- end
-
- if Gitlab::Metrics.mri?
- describe '#sample_objects' do
- it 'adds a metric containing the amount of allocated objects' do
- expect(sampler).to receive(:add_metric).
- with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)).
- at_least(:once).
- and_call_original
-
- sampler.sample_objects
- end
-
- it 'ignores classes without a name' do
- expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 })
-
- expect(sampler).not_to receive(:add_metric).
- with('object_counts', an_instance_of(Hash), type: nil)
-
- sampler.sample_objects
- end
- end
- end
-
- describe '#sample_gc' do
- it 'adds a metric containing garbage collection statistics' do
- expect(GC::Profiler).to receive(:total_time).and_return(0.24)
-
- expect(sampler).to receive(:add_metric).
- with(/gc_statistics/, an_instance_of(Hash)).
- and_call_original
-
- sampler.sample_gc
- end
- end
-
- describe '#add_metric' do
- it 'prefixes the series name for a Rails process' do
- expect(sampler).to receive(:sidekiq?).and_return(false)
-
- expect(Gitlab::Metrics::Metric).to receive(:new).
- with('rails_cats', { value: 10 }, {}).
- and_call_original
-
- sampler.add_metric('cats', value: 10)
- end
-
- it 'prefixes the series name for a Sidekiq process' do
- expect(sampler).to receive(:sidekiq?).and_return(true)
-
- expect(Gitlab::Metrics::Metric).to receive(:new).
- with('sidekiq_cats', { value: 10 }, {}).
- and_call_original
-
- sampler.add_metric('cats', value: 10)
- end
- end
-
- describe '#sleep_interval' do
- it 'returns a Numeric' do
- expect(sampler.sleep_interval).to be_a_kind_of(Numeric)
- end
-
- # Testing random behaviour is very hard, so treat this test as a basic smoke
- # test instead of a very accurate behaviour/unit test.
- it 'does not return the same interval twice in a row' do
- last = nil
-
- 100.times do
- interval = sampler.sleep_interval
-
- expect(interval).not_to eq(last)
-
- last = interval
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb
new file mode 100644
index 00000000000..6721e02fb85
--- /dev/null
+++ b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::SidekiqMetricsExporter do
+ let(:exporter) { described_class.new }
+ let(:server) { double('server') }
+
+ before do
+ allow(::WEBrick::HTTPServer).to receive(:new).and_return(server)
+ allow(server).to receive(:mount)
+ allow(server).to receive(:start)
+ allow(server).to receive(:shutdown)
+ end
+
+ describe 'when exporter is enabled' do
+ before do
+ allow(Settings.monitoring.sidekiq_exporter).to receive(:enabled).and_return(true)
+ end
+
+ describe 'when exporter is stopped' do
+ describe '#start' do
+ it 'starts the exporter' do
+ expect { exporter.start.join }.to change { exporter.thread? }.from(false).to(true)
+
+ expect(server).to have_received(:start)
+ end
+
+ describe 'with custom settings' do
+ let(:port) { 99999 }
+ let(:address) { 'sidekiq_exporter_address' }
+
+ before do
+ allow(Settings.monitoring.sidekiq_exporter).to receive(:port).and_return(port)
+ allow(Settings.monitoring.sidekiq_exporter).to receive(:address).and_return(address)
+ end
+
+ it 'starts server with port and address from settings' do
+ exporter.start.join
+
+ expect(::WEBrick::HTTPServer).to have_received(:new).with(
+ Port: port,
+ BindAddress: address
+ )
+ end
+ end
+ end
+
+ describe '#stop' do
+ it "doesn't shutdown stopped server" do
+ expect { exporter.stop }.not_to change { exporter.thread? }
+
+ expect(server).not_to have_received(:shutdown)
+ end
+ end
+ end
+
+ describe 'when exporter is running' do
+ before do
+ exporter.start.join
+ end
+
+ describe '#start' do
+ it "doesn't start running server" do
+ expect { exporter.start.join }.not_to change { exporter.thread? }
+
+ expect(server).to have_received(:start).once
+ end
+ end
+
+ describe '#stop' do
+ it 'shutdowns server' do
+ expect { exporter.stop }.to change { exporter.thread? }.from(true).to(false)
+
+ expect(server).to have_received(:shutdown)
+ end
+ end
+ end
+ end
+
+ describe 'when exporter is disabled' do
+ before do
+ allow(Settings.monitoring.sidekiq_exporter).to receive(:enabled).and_return(false)
+ end
+
+ describe '#start' do
+ it "doesn't start" do
+ expect(exporter.start).to be_nil
+ expect { exporter.start }.not_to change { exporter.thread? }
+
+ expect(server).not_to have_received(:start)
+ end
+ end
+
+ describe '#stop' do
+ it "doesn't shutdown" do
+ expect { exporter.stop }.not_to change { exporter.thread? }
+
+ expect(server).not_to have_received(:shutdown)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
index acaba785606..b576d7173f5 100644
--- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
@@ -8,12 +8,12 @@ describe Gitlab::Metrics::SidekiqMiddleware do
it 'tracks the transaction' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
- expect(Gitlab::Metrics::Transaction).to receive(:new).
- with('TestWorker#perform').
- and_call_original
+ expect(Gitlab::Metrics::Transaction).to receive(:new)
+ .with('TestWorker#perform')
+ .and_call_original
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
- with(:sidekiq_queue_duration, instance_of(Float))
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
+ .with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
@@ -23,12 +23,12 @@ describe Gitlab::Metrics::SidekiqMiddleware do
it 'tracks the transaction (for messages without `enqueued_at`)' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
- expect(Gitlab::Metrics::Transaction).to receive(:new).
- with('TestWorker#perform').
- and_call_original
+ expect(Gitlab::Metrics::Transaction).to receive(:new)
+ .with('TestWorker#perform')
+ .and_call_original
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
- with(:sidekiq_queue_duration, instance_of(Float))
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
+ .with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
@@ -38,17 +38,17 @@ describe Gitlab::Metrics::SidekiqMiddleware do
it 'tracks any raised exceptions' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- to receive(:run).and_raise(RuntimeError)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .to receive(:run).and_raise(RuntimeError)
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- to receive(:add_event).with(:sidekiq_exception)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .to receive(:add_event).with(:sidekiq_exception)
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- to receive(:finish)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .to receive(:finish)
- expect { middleware.call(worker, message, :test) }.
- to raise_error(RuntimeError)
+ expect { middleware.call(worker, message, :test) }
+ .to raise_error(RuntimeError)
end
end
end
diff --git a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
index 0695c5ce096..e7b595405a8 100644
--- a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
@@ -21,11 +21,11 @@ describe Gitlab::Metrics::Subscribers::ActionView do
values = { duration: 2.1 }
tags = { view: 'app/views/x.html.haml' }
- expect(transaction).to receive(:increment).
- with(:view_duration, 2.1)
+ expect(transaction).to receive(:increment)
+ .with(:view_duration, 2.1)
- expect(transaction).to receive(:add_metric).
- with(described_class::SERIES, values, tags)
+ expect(transaction).to receive(:add_metric)
+ .with(described_class::SERIES, values, tags)
subscriber.render_template(event)
end
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
index 49699ffe28f..ce6587e993f 100644
--- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -12,8 +12,8 @@ describe Gitlab::Metrics::Subscribers::ActiveRecord do
describe '#sql' do
describe 'without a current transaction' do
it 'simply returns' do
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- not_to receive(:increment)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .not_to receive(:increment)
subscriber.sql(event)
end
@@ -21,15 +21,15 @@ describe Gitlab::Metrics::Subscribers::ActiveRecord do
describe 'with a current transaction' do
it 'increments the :sql_duration value' do
- expect(subscriber).to receive(:current_transaction).
- at_least(:once).
- and_return(transaction)
+ expect(subscriber).to receive(:current_transaction)
+ .at_least(:once)
+ .and_return(transaction)
- expect(transaction).to receive(:increment).
- with(:sql_duration, 0.2)
+ expect(transaction).to receive(:increment)
+ .with(:sql_duration, 0.2)
- expect(transaction).to receive(:increment).
- with(:sql_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(:sql_count, 1)
subscriber.sql(event)
end
diff --git a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
index d986c6fac43..f04dc8dcc02 100644
--- a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
@@ -8,26 +8,26 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_read' do
it 'increments the cache_read duration' do
- expect(subscriber).to receive(:increment).
- with(:cache_read, event.duration)
+ expect(subscriber).to receive(:increment)
+ .with(:cache_read, event.duration)
subscriber.cache_read(event)
end
context 'with a transaction' do
before do
- allow(subscriber).to receive(:current_transaction).
- and_return(transaction)
+ allow(subscriber).to receive(:current_transaction)
+ .and_return(transaction)
end
context 'with hit event' do
let(:event) { double(:event, duration: 15.2, payload: { hit: true }) }
it 'increments the cache_read_hit count' do
- expect(transaction).to receive(:increment).
- with(:cache_read_hit_count, 1)
- expect(transaction).to receive(:increment).
- with(any_args).at_least(1) # Other calls
+ expect(transaction).to receive(:increment)
+ .with(:cache_read_hit_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(any_args).at_least(1) # Other calls
subscriber.cache_read(event)
end
@@ -36,8 +36,8 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
let(:event) { double(:event, duration: 15.2, payload: { hit: true, super_operation: :fetch }) }
it 'does not increment cache read miss' do
- expect(transaction).not_to receive(:increment).
- with(:cache_read_hit_count, 1)
+ expect(transaction).not_to receive(:increment)
+ .with(:cache_read_hit_count, 1)
subscriber.cache_read(event)
end
@@ -48,10 +48,10 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
let(:event) { double(:event, duration: 15.2, payload: { hit: false }) }
it 'increments the cache_read_miss count' do
- expect(transaction).to receive(:increment).
- with(:cache_read_miss_count, 1)
- expect(transaction).to receive(:increment).
- with(any_args).at_least(1) # Other calls
+ expect(transaction).to receive(:increment)
+ .with(:cache_read_miss_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(any_args).at_least(1) # Other calls
subscriber.cache_read(event)
end
@@ -60,8 +60,8 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
let(:event) { double(:event, duration: 15.2, payload: { hit: false, super_operation: :fetch }) }
it 'does not increment cache read miss' do
- expect(transaction).not_to receive(:increment).
- with(:cache_read_miss_count, 1)
+ expect(transaction).not_to receive(:increment)
+ .with(:cache_read_miss_count, 1)
subscriber.cache_read(event)
end
@@ -72,8 +72,8 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_write' do
it 'increments the cache_write duration' do
- expect(subscriber).to receive(:increment).
- with(:cache_write, event.duration)
+ expect(subscriber).to receive(:increment)
+ .with(:cache_write, event.duration)
subscriber.cache_write(event)
end
@@ -81,8 +81,8 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_delete' do
it 'increments the cache_delete duration' do
- expect(subscriber).to receive(:increment).
- with(:cache_delete, event.duration)
+ expect(subscriber).to receive(:increment)
+ .with(:cache_delete, event.duration)
subscriber.cache_delete(event)
end
@@ -90,8 +90,8 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_exist?' do
it 'increments the cache_exists duration' do
- expect(subscriber).to receive(:increment).
- with(:cache_exists, event.duration)
+ expect(subscriber).to receive(:increment)
+ .with(:cache_exists, event.duration)
subscriber.cache_exist?(event)
end
@@ -108,13 +108,13 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
context 'with a transaction' do
before do
- allow(subscriber).to receive(:current_transaction).
- and_return(transaction)
+ allow(subscriber).to receive(:current_transaction)
+ .and_return(transaction)
end
it 'increments the cache_read_hit count' do
- expect(transaction).to receive(:increment).
- with(:cache_read_hit_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(:cache_read_hit_count, 1)
subscriber.cache_fetch_hit(event)
end
@@ -132,13 +132,13 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
context 'with a transaction' do
before do
- allow(subscriber).to receive(:current_transaction).
- and_return(transaction)
+ allow(subscriber).to receive(:current_transaction)
+ .and_return(transaction)
end
it 'increments the cache_fetch_miss count' do
- expect(transaction).to receive(:increment).
- with(:cache_read_miss_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(:cache_read_miss_count, 1)
subscriber.cache_generate(event)
end
@@ -156,22 +156,22 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
context 'with a transaction' do
before do
- allow(subscriber).to receive(:current_transaction).
- and_return(transaction)
+ allow(subscriber).to receive(:current_transaction)
+ .and_return(transaction)
end
it 'increments the total and specific cache duration' do
- expect(transaction).to receive(:increment).
- with(:cache_duration, event.duration)
+ expect(transaction).to receive(:increment)
+ .with(:cache_duration, event.duration)
- expect(transaction).to receive(:increment).
- with(:cache_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(:cache_count, 1)
- expect(transaction).to receive(:increment).
- with(:cache_delete_duration, event.duration)
+ expect(transaction).to receive(:increment)
+ .with(:cache_delete_duration, event.duration)
- expect(transaction).to receive(:increment).
- with(:cache_delete_count, 1)
+ expect(transaction).to receive(:increment)
+ .with(:cache_delete_count, 1)
subscriber.increment(:cache_delete, event.duration)
end
diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb
index 0c5a6246d85..3779af81512 100644
--- a/spec/lib/gitlab/metrics/transaction_spec.rb
+++ b/spec/lib/gitlab/metrics/transaction_spec.rb
@@ -39,8 +39,8 @@ describe Gitlab::Metrics::Transaction do
describe '#add_metric' do
it 'adds a metric to the transaction' do
- expect(Gitlab::Metrics::Metric).to receive(:new).
- with('rails_foo', { number: 10 }, {})
+ expect(Gitlab::Metrics::Metric).to receive(:new)
+ .with('rails_foo', { number: 10 }, {})
transaction.add_metric('foo', number: 10)
end
@@ -61,8 +61,8 @@ describe Gitlab::Metrics::Transaction do
values = { duration: 0.0, time: 3, allocated_memory: a_kind_of(Numeric) }
- expect(transaction).to receive(:add_metric).
- with('transactions', values, {})
+ expect(transaction).to receive(:add_metric)
+ .with('transactions', values, {})
transaction.track_self
end
@@ -78,8 +78,8 @@ describe Gitlab::Metrics::Transaction do
allocated_memory: a_kind_of(Numeric)
}
- expect(transaction).to receive(:add_metric).
- with('transactions', values, {})
+ expect(transaction).to receive(:add_metric)
+ .with('transactions', values, {})
transaction.track_self
end
@@ -109,8 +109,8 @@ describe Gitlab::Metrics::Transaction do
allocated_memory: a_kind_of(Numeric)
}
- expect(transaction).to receive(:add_metric).
- with('transactions', values, {})
+ expect(transaction).to receive(:add_metric)
+ .with('transactions', values, {})
transaction.track_self
end
@@ -120,8 +120,8 @@ describe Gitlab::Metrics::Transaction do
it 'submits the metrics to Sidekiq' do
transaction.track_self
- expect(Gitlab::Metrics).to receive(:submit_metrics).
- with([an_instance_of(Hash)])
+ expect(Gitlab::Metrics).to receive(:submit_metrics)
+ .with([an_instance_of(Hash)])
transaction.submit
end
@@ -137,8 +137,8 @@ describe Gitlab::Metrics::Transaction do
timestamp: a_kind_of(Integer)
}
- expect(Gitlab::Metrics).to receive(:submit_metrics).
- with([hash])
+ expect(Gitlab::Metrics).to receive(:submit_metrics)
+ .with([hash])
transaction.submit
end
@@ -154,8 +154,8 @@ describe Gitlab::Metrics::Transaction do
timestamp: a_kind_of(Integer)
}
- expect(Gitlab::Metrics).to receive(:submit_metrics).
- with([hash])
+ expect(Gitlab::Metrics).to receive(:submit_metrics)
+ .with([hash])
transaction.submit
end
diff --git a/spec/lib/gitlab/metrics/unicorn_sampler_spec.rb b/spec/lib/gitlab/metrics/unicorn_sampler_spec.rb
new file mode 100644
index 00000000000..dc0d1f2e940
--- /dev/null
+++ b/spec/lib/gitlab/metrics/unicorn_sampler_spec.rb
@@ -0,0 +1,108 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::UnicornSampler do
+ subject { described_class.new(1.second) }
+
+ describe '#sample' do
+ let(:unicorn) { double('unicorn') }
+ let(:raindrops) { double('raindrops') }
+ let(:stats) { double('stats') }
+
+ before do
+ stub_const('Unicorn', unicorn)
+ stub_const('Raindrops::Linux', raindrops)
+ allow(raindrops).to receive(:unix_listener_stats).and_return({})
+ allow(raindrops).to receive(:tcp_listener_stats).and_return({})
+ end
+
+ context 'unicorn listens on unix sockets' do
+ let(:socket_address) { '/some/sock' }
+ let(:sockets) { [socket_address] }
+
+ before do
+ allow(unicorn).to receive(:listener_names).and_return(sockets)
+ end
+
+ it 'samples socket data' do
+ expect(raindrops).to receive(:unix_listener_stats).with(sockets)
+
+ subject.sample
+ end
+
+ context 'stats collected' do
+ before do
+ allow(stats).to receive(:active).and_return('active')
+ allow(stats).to receive(:queued).and_return('queued')
+ allow(raindrops).to receive(:unix_listener_stats).and_return({ socket_address => stats })
+ end
+
+ it 'updates metrics type unix and with addr' do
+ labels = { type: 'unix', address: socket_address }
+
+ expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active')
+ expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued')
+
+ subject.sample
+ end
+ end
+ end
+
+ context 'unicorn listens on tcp sockets' do
+ let(:tcp_socket_address) { '0.0.0.0:8080' }
+ let(:tcp_sockets) { [tcp_socket_address] }
+
+ before do
+ allow(unicorn).to receive(:listener_names).and_return(tcp_sockets)
+ end
+
+ it 'samples socket data' do
+ expect(raindrops).to receive(:tcp_listener_stats).with(tcp_sockets)
+
+ subject.sample
+ end
+
+ context 'stats collected' do
+ before do
+ allow(stats).to receive(:active).and_return('active')
+ allow(stats).to receive(:queued).and_return('queued')
+ allow(raindrops).to receive(:tcp_listener_stats).and_return({ tcp_socket_address => stats })
+ end
+
+ it 'updates metrics type unix and with addr' do
+ labels = { type: 'tcp', address: tcp_socket_address }
+
+ expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active')
+ expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued')
+
+ subject.sample
+ end
+ end
+ end
+ end
+
+ describe '#start' do
+ context 'when enabled' do
+ before do
+ allow(subject).to receive(:enabled?).and_return(true)
+ end
+
+ it 'creates new thread' do
+ expect(Thread).to receive(:new)
+
+ subject.start
+ end
+ end
+
+ context 'when disabled' do
+ before do
+ allow(subject).to receive(:enabled?).and_return(false)
+ end
+
+ it "doesn't create new thread" do
+ expect(Thread).not_to receive(:new)
+
+ subject.start
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
index ab6e311b1e8..599b8807d8d 100644
--- a/spec/lib/gitlab/metrics_spec.rb
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Gitlab::Metrics do
+ include StubENV
+
describe '.settings' do
it 'returns a Hash' do
expect(described_class.settings).to be_an_instance_of(Hash)
@@ -9,7 +11,49 @@ describe Gitlab::Metrics do
describe '.enabled?' do
it 'returns a boolean' do
- expect([true, false].include?(described_class.enabled?)).to eq(true)
+ expect(described_class.enabled?).to be_in([true, false])
+ end
+ end
+
+ describe '.prometheus_metrics_enabled_unmemoized' do
+ subject { described_class.send(:prometheus_metrics_enabled_unmemoized) }
+
+ context 'prometheus metrics enabled in config' do
+ before do
+ allow(described_class).to receive(:current_application_settings).and_return(prometheus_metrics_enabled: true)
+ end
+
+ context 'when metrics folder is present' do
+ before do
+ allow(described_class).to receive(:metrics_folder_present?).and_return(true)
+ end
+
+ it 'metrics are enabled' do
+ expect(subject).to eq(true)
+ end
+ end
+
+ context 'when metrics folder is missing' do
+ before do
+ allow(described_class).to receive(:metrics_folder_present?).and_return(false)
+ end
+
+ it 'metrics are disabled' do
+ expect(subject).to eq(false)
+ end
+ end
+ end
+ end
+
+ describe '.prometheus_metrics_enabled?' do
+ it 'returns a boolean' do
+ expect(described_class.prometheus_metrics_enabled?).to be_in([true, false])
+ end
+ end
+
+ describe '.influx_metrics_enabled?' do
+ it 'returns a boolean' do
+ expect(described_class.influx_metrics_enabled?).to be_in([true, false])
end
end
@@ -20,7 +64,7 @@ describe Gitlab::Metrics do
expect(pool).to receive(:with).and_yield(connection)
expect(connection).to receive(:write_points).with(an_instance_of(Array))
- expect(Gitlab::Metrics).to receive(:pool).and_return(pool)
+ expect(described_class).to receive(:pool).and_return(pool)
described_class.submit_metrics([{ 'series' => 'kittens', 'tags' => {} }])
end
@@ -28,8 +72,8 @@ describe Gitlab::Metrics do
describe '.prepare_metrics' do
it 'returns a Hash with the keys as Symbols' do
- metrics = described_class.
- prepare_metrics([{ 'values' => {}, 'tags' => {} }])
+ metrics = described_class
+ .prepare_metrics([{ 'values' => {}, 'tags' => {} }])
expect(metrics).to eq([{ values: {}, tags: {} }])
end
@@ -64,7 +108,7 @@ describe Gitlab::Metrics do
describe '.measure' do
context 'without a transaction' do
it 'returns the return value of the block' do
- val = Gitlab::Metrics.measure(:foo) { 10 }
+ val = described_class.measure(:foo) { 10 }
expect(val).to eq(10)
end
@@ -74,25 +118,25 @@ describe Gitlab::Metrics do
let(:transaction) { Gitlab::Metrics::Transaction.new }
before do
- allow(Gitlab::Metrics).to receive(:current_transaction).
- and_return(transaction)
+ allow(described_class).to receive(:current_transaction)
+ .and_return(transaction)
end
it 'adds a metric to the current transaction' do
- expect(transaction).to receive(:increment).
- with('foo_real_time', a_kind_of(Numeric))
+ expect(transaction).to receive(:increment)
+ .with('foo_real_time', a_kind_of(Numeric))
- expect(transaction).to receive(:increment).
- with('foo_cpu_time', a_kind_of(Numeric))
+ expect(transaction).to receive(:increment)
+ .with('foo_cpu_time', a_kind_of(Numeric))
- expect(transaction).to receive(:increment).
- with('foo_call_count', 1)
+ expect(transaction).to receive(:increment)
+ .with('foo_call_count', 1)
- Gitlab::Metrics.measure(:foo) { 10 }
+ described_class.measure(:foo) { 10 }
end
it 'returns the return value of the block' do
- val = Gitlab::Metrics.measure(:foo) { 10 }
+ val = described_class.measure(:foo) { 10 }
expect(val).to eq(10)
end
@@ -102,10 +146,10 @@ describe Gitlab::Metrics do
describe '.tag_transaction' do
context 'without a transaction' do
it 'does nothing' do
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- not_to receive(:add_tag)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .not_to receive(:add_tag)
- Gitlab::Metrics.tag_transaction(:foo, 'bar')
+ described_class.tag_transaction(:foo, 'bar')
end
end
@@ -113,13 +157,13 @@ describe Gitlab::Metrics do
let(:transaction) { Gitlab::Metrics::Transaction.new }
it 'adds the tag to the transaction' do
- expect(Gitlab::Metrics).to receive(:current_transaction).
- and_return(transaction)
+ expect(described_class).to receive(:current_transaction)
+ .and_return(transaction)
- expect(transaction).to receive(:add_tag).
- with(:foo, 'bar')
+ expect(transaction).to receive(:add_tag)
+ .with(:foo, 'bar')
- Gitlab::Metrics.tag_transaction(:foo, 'bar')
+ described_class.tag_transaction(:foo, 'bar')
end
end
end
@@ -127,10 +171,10 @@ describe Gitlab::Metrics do
describe '.action=' do
context 'without a transaction' do
it 'does nothing' do
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- not_to receive(:action=)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .not_to receive(:action=)
- Gitlab::Metrics.action = 'foo'
+ described_class.action = 'foo'
end
end
@@ -138,12 +182,12 @@ describe Gitlab::Metrics do
it 'sets the action of a transaction' do
trans = Gitlab::Metrics::Transaction.new
- expect(Gitlab::Metrics).to receive(:current_transaction).
- and_return(trans)
+ expect(described_class).to receive(:current_transaction)
+ .and_return(trans)
expect(trans).to receive(:action=).with('foo')
- Gitlab::Metrics.action = 'foo'
+ described_class.action = 'foo'
end
end
end
@@ -157,10 +201,10 @@ describe Gitlab::Metrics do
describe '.add_event' do
context 'without a transaction' do
it 'does nothing' do
- expect_any_instance_of(Gitlab::Metrics::Transaction).
- not_to receive(:add_event)
+ expect_any_instance_of(Gitlab::Metrics::Transaction)
+ .not_to receive(:add_event)
- Gitlab::Metrics.add_event(:meow)
+ described_class.add_event(:meow)
end
end
@@ -170,11 +214,140 @@ describe Gitlab::Metrics do
expect(transaction).to receive(:add_event).with(:meow)
- expect(Gitlab::Metrics).to receive(:current_transaction).
- and_return(transaction)
+ expect(described_class).to receive(:current_transaction)
+ .and_return(transaction)
+
+ described_class.add_event(:meow)
+ end
+ end
+ end
+
+ shared_examples 'prometheus metrics API' do
+ describe '#counter' do
+ subject { described_class.counter(:couter, 'doc') }
+
+ describe '#increment' do
+ it 'successfully calls #increment without arguments' do
+ expect { subject.increment }.not_to raise_exception
+ end
+
+ it 'successfully calls #increment with 1 argument' do
+ expect { subject.increment({}) }.not_to raise_exception
+ end
+
+ it 'successfully calls #increment with 2 arguments' do
+ expect { subject.increment({}, 1) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:summary, 'doc') }
+
+ describe '#observe' do
+ it 'successfully calls #observe with 2 arguments' do
+ expect { subject.observe({}, 2) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:gauge, 'doc') }
+
+ describe '#set' do
+ it 'successfully calls #set with 2 arguments' do
+ expect { subject.set({}, 1) }.not_to raise_exception
+ end
+ end
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:histogram, 'doc') }
- Gitlab::Metrics.add_event(:meow)
+ describe '#observe' do
+ it 'successfully calls #observe with 2 arguments' do
+ expect { subject.observe({}, 2) }.not_to raise_exception
+ end
end
end
end
+
+ context 'prometheus metrics disabled' do
+ before do
+ allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(false)
+ end
+
+ it_behaves_like 'prometheus metrics API'
+
+ describe '#null_metric' do
+ subject { described_class.provide_metric(:test) }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#counter' do
+ subject { described_class.counter(:counter, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:summary, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:gauge, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:histogram, 'doc') }
+
+ it { is_expected.to be_a(Gitlab::Metrics::NullMetric) }
+ end
+ end
+
+ context 'prometheus metrics enabled' do
+ let(:metrics_multiproc_dir) { Dir.mktmpdir }
+
+ before do
+ stub_const('Prometheus::Client::Multiprocdir', metrics_multiproc_dir)
+ allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(true)
+ end
+
+ it_behaves_like 'prometheus metrics API'
+
+ describe '#null_metric' do
+ subject { described_class.provide_metric(:test) }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#counter' do
+ subject { described_class.counter(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#summary' do
+ subject { described_class.summary(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#gauge' do
+ subject { described_class.gauge(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+
+ describe '#histogram' do
+ subject { described_class.histogram(:name, 'doc') }
+
+ it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb
index c2ab015d5cb..6af1564da19 100644
--- a/spec/lib/gitlab/middleware/go_spec.rb
+++ b/spec/lib/gitlab/middleware/go_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Middleware::Go, lib: true do
+describe Gitlab::Middleware::Go do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
diff --git a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
index 168090d5b5c..88107536c9e 100644
--- a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
+++ b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
@@ -6,7 +6,9 @@ describe Gitlab::Middleware::RailsQueueDuration do
let(:env) { {} }
let(:transaction) { double(:transaction) }
- before { expect(app).to receive(:call).with(env).and_return('yay') }
+ before do
+ expect(app).to receive(:call).with(env).and_return('yay')
+ end
describe '#call' do
it 'calls the app when metrics are disabled' do
@@ -15,7 +17,9 @@ describe Gitlab::Middleware::RailsQueueDuration do
end
context 'when metrics are enabled' do
- before { allow(Gitlab::Metrics).to receive(:current_transaction).and_return(transaction) }
+ before do
+ allow(Gitlab::Metrics).to receive(:current_transaction).and_return(transaction)
+ end
it 'calls the app when metrics are enabled but no timing header is found' do
expect(middleware.call(env)).to eq('yay')
diff --git a/spec/lib/gitlab/o_auth/auth_hash_spec.rb b/spec/lib/gitlab/o_auth/auth_hash_spec.rb
index 8aaeb5779d3..d5f4da3ce36 100644
--- a/spec/lib/gitlab/o_auth/auth_hash_spec.rb
+++ b/spec/lib/gitlab/o_auth/auth_hash_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::OAuth::AuthHash, lib: true do
+describe Gitlab::OAuth::AuthHash do
let(:auth_hash) do
- Gitlab::OAuth::AuthHash.new(
+ described_class.new(
OmniAuth::AuthHash.new(
provider: provider_ascii,
uid: uid_ascii,
@@ -55,7 +55,9 @@ describe Gitlab::OAuth::AuthHash, lib: true do
end
context 'email not provided' do
- before { info_hash.delete(:email) }
+ before do
+ info_hash.delete(:email)
+ end
it 'generates a temp email' do
expect( auth_hash.email).to start_with('temp-email-for-oauth')
@@ -63,7 +65,9 @@ describe Gitlab::OAuth::AuthHash, lib: true do
end
context 'username not provided' do
- before { info_hash.delete(:nickname) }
+ before do
+ info_hash.delete(:nickname)
+ end
it 'takes the first part of the email as username' do
expect(auth_hash.username).to eql 'onur.kucuk_ABC-123'
@@ -71,7 +75,9 @@ describe Gitlab::OAuth::AuthHash, lib: true do
end
context 'name not provided' do
- before { info_hash.delete(:name) }
+ before do
+ info_hash.delete(:name)
+ end
it 'concats first and lastname as the name' do
expect(auth_hash.name).to eql name_utf8
diff --git a/spec/lib/gitlab/o_auth/provider_spec.rb b/spec/lib/gitlab/o_auth/provider_spec.rb
new file mode 100644
index 00000000000..30faf107e3f
--- /dev/null
+++ b/spec/lib/gitlab/o_auth/provider_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+describe Gitlab::OAuth::Provider do
+ describe '#config_for' do
+ context 'for an LDAP provider' do
+ context 'when the provider exists' do
+ it 'returns the config' do
+ expect(described_class.config_for('ldapmain')).to be_a(Hash)
+ end
+ end
+
+ context 'when the provider does not exist' do
+ it 'returns nil' do
+ expect(described_class.config_for('ldapfoo')).to be_nil
+ end
+ end
+ end
+
+ context 'for an OmniAuth provider' do
+ before do
+ provider = OpenStruct.new(
+ name: 'google',
+ app_id: 'asd123',
+ app_secret: 'asd123'
+ )
+ allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider])
+ end
+
+ context 'when the provider exists' do
+ it 'returns the config' do
+ expect(described_class.config_for('google')).to be_a(OpenStruct)
+ end
+ end
+
+ context 'when the provider does not exist' do
+ it 'returns nil' do
+ expect(described_class.config_for('foo')).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb
index 828c953197d..2cf0f7516de 100644
--- a/spec/lib/gitlab/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/o_auth/user_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::OAuth::User, lib: true do
- let(:oauth_user) { Gitlab::OAuth::User.new(auth_hash) }
+describe Gitlab::OAuth::User do
+ let(:oauth_user) { described_class.new(auth_hash) }
let(:gl_user) { oauth_user.gl_user }
let(:uid) { 'my-uid' }
let(:provider) { 'my-provider' }
@@ -28,11 +28,11 @@ describe Gitlab::OAuth::User, lib: true do
end
end
- describe '#save' do
- def stub_omniauth_config(messages)
- allow(Gitlab.config.omniauth).to receive_messages(messages)
- end
+ def stub_omniauth_config(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(messages)
+ end
+ describe '#save' do
def stub_ldap_config(messages)
allow(Gitlab::LDAP::Config).to receive_messages(messages)
end
@@ -112,7 +112,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'with new allow_single_sign_on enabled syntax' do
- before { stub_omniauth_config(allow_single_sign_on: ['twitter']) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: ['twitter'])
+ end
it "creates a user from Omniauth" do
oauth_user.save
@@ -125,7 +127,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context "with old allow_single_sign_on enabled syntax" do
- before { stub_omniauth_config(allow_single_sign_on: true) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: true)
+ end
it "creates a user from Omniauth" do
oauth_user.save
@@ -138,36 +142,51 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'with new allow_single_sign_on disabled syntax' do
- before { stub_omniauth_config(allow_single_sign_on: []) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: [])
+ end
+
it 'throws an error' do
- expect{ oauth_user.save }.to raise_error StandardError
+ expect { oauth_user.save }.to raise_error StandardError
end
end
context 'with old allow_single_sign_on disabled (Default)' do
- before { stub_omniauth_config(allow_single_sign_on: false) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: false)
+ end
+
it 'throws an error' do
- expect{ oauth_user.save }.to raise_error StandardError
+ expect { oauth_user.save }.to raise_error StandardError
end
end
end
context "with auto_link_ldap_user disabled (default)" do
- before { stub_omniauth_config(auto_link_ldap_user: false) }
+ before do
+ stub_omniauth_config(auto_link_ldap_user: false)
+ end
+
include_examples "to verify compliance with allow_single_sign_on"
end
context "with auto_link_ldap_user enabled" do
- before { stub_omniauth_config(auto_link_ldap_user: true) }
+ before do
+ stub_omniauth_config(auto_link_ldap_user: true)
+ end
context "and no LDAP provider defined" do
- before { stub_ldap_config(providers: []) }
+ before do
+ stub_ldap_config(providers: [])
+ end
include_examples "to verify compliance with allow_single_sign_on"
end
context "and at least one LDAP provider is defined" do
- before { stub_ldap_config(providers: %w(ldapmain)) }
+ before do
+ stub_ldap_config(providers: %w(ldapmain))
+ end
context "and a corresponding LDAP person" do
before do
@@ -238,7 +257,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context "and no corresponding LDAP person" do
- before { allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(nil) }
+ before do
+ allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(nil)
+ end
include_examples "to verify compliance with allow_single_sign_on"
end
@@ -248,11 +269,16 @@ describe Gitlab::OAuth::User, lib: true do
describe 'blocking' do
let(:provider) { 'twitter' }
- before { stub_omniauth_config(allow_single_sign_on: ['twitter']) }
+
+ before do
+ stub_omniauth_config(allow_single_sign_on: ['twitter'])
+ end
context 'signup with omniauth only' do
context 'dont block on create' do
- before { stub_omniauth_config(block_auto_created_users: false) }
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
it do
oauth_user.save
@@ -262,7 +288,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'block on create' do
- before { stub_omniauth_config(block_auto_created_users: true) }
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
it do
oauth_user.save
@@ -284,7 +312,9 @@ describe Gitlab::OAuth::User, lib: true do
context "and no account for the LDAP user" do
context 'dont block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
it do
oauth_user.save
@@ -294,7 +324,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
it do
oauth_user.save
@@ -308,7 +340,9 @@ describe Gitlab::OAuth::User, lib: true do
let!(:existing_user) { create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=user1,ou=People,dc=example', provider: 'ldapmain', username: 'john') }
context 'dont block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
it do
oauth_user.save
@@ -318,7 +352,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
it do
oauth_user.save
@@ -336,7 +372,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'dont block on create' do
- before { stub_omniauth_config(block_auto_created_users: false) }
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
it do
oauth_user.save
@@ -346,7 +384,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'block on create' do
- before { stub_omniauth_config(block_auto_created_users: true) }
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
it do
oauth_user.save
@@ -356,7 +396,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'dont block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
it do
oauth_user.save
@@ -366,7 +408,9 @@ describe Gitlab::OAuth::User, lib: true do
end
context 'block on create (LDAP)' do
- before { allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true) }
+ before do
+ allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
it do
oauth_user.save
@@ -377,4 +421,70 @@ describe Gitlab::OAuth::User, lib: true do
end
end
end
+
+ describe 'updating email' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ before do
+ stub_omniauth_config(sync_email_from_provider: 'my-provider')
+ end
+
+ context "when provider sets an email" do
+ it "updates the user email" do
+ expect(gl_user.email).to eq(info_hash[:email])
+ end
+
+ it "has external_email set to true" do
+ expect(gl_user.external_email?).to be(true)
+ end
+
+ it "has email_provider set to provider" do
+ expect(gl_user.email_provider).to eql 'my-provider'
+ end
+ end
+
+ context "when provider doesn't set an email" do
+ before do
+ info_hash.delete(:email)
+ end
+
+ it "does not update the user email" do
+ expect(gl_user.email).not_to eq(info_hash[:email])
+ end
+
+ it "has external_email set to false" do
+ expect(gl_user.external_email?).to be(false)
+ end
+ end
+ end
+
+ describe 'generating username' do
+ context 'when no collision with existing user' do
+ it 'generates the username with no counter' do
+ expect(gl_user.username).to eq('johngitlab-ETC')
+ end
+ end
+
+ context 'when collision with existing user' do
+ it 'generates the username with a counter' do
+ oauth_user.save
+ oauth_user2 = described_class.new(OmniAuth::AuthHash.new(uid: 'my-uid2', provider: provider, info: { nickname: 'johngitlab-ETC@othermail.com', email: 'john@othermail.com' }))
+
+ expect(oauth_user2.gl_user.username).to eq('johngitlab-ETC1')
+ end
+ end
+
+ context 'when username is a reserved word' do
+ let(:info_hash) do
+ {
+ nickname: 'admin@othermail.com',
+ email: 'admin@othermail.com'
+ }
+ end
+
+ it 'generates the username with a counter' do
+ expect(gl_user.username).to eq('admin1')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/optimistic_locking_spec.rb b/spec/lib/gitlab/optimistic_locking_spec.rb
index acce2be93f2..81f81d4f963 100644
--- a/spec/lib/gitlab/optimistic_locking_spec.rb
+++ b/spec/lib/gitlab/optimistic_locking_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::OptimisticLocking, lib: true do
+describe Gitlab::OptimisticLocking do
let!(:pipeline) { create(:ci_pipeline) }
let!(:pipeline2) { Ci::Pipeline.find(pipeline.id) }
diff --git a/spec/lib/gitlab/other_markup_spec.rb b/spec/lib/gitlab/other_markup_spec.rb
index d6d53e8586c..e26f39e193e 100644
--- a/spec/lib/gitlab/other_markup_spec.rb
+++ b/spec/lib/gitlab/other_markup_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::OtherMarkup, lib: true do
+describe Gitlab::OtherMarkup do
let(:context) { {} }
context "XSS Checks" do
@@ -13,7 +13,7 @@ describe Gitlab::OtherMarkup, lib: true do
}
links.each do |name, data|
it "does not convert dangerous #{name} into HTML" do
- expect(render(data[:file], data[:input])).to eq(data[:output])
+ expect(render(data[:file], data[:input], context)).to eq(data[:output])
end
end
end
diff --git a/spec/lib/gitlab/otp_key_rotator_spec.rb b/spec/lib/gitlab/otp_key_rotator_spec.rb
new file mode 100644
index 00000000000..6e6e9ce29ac
--- /dev/null
+++ b/spec/lib/gitlab/otp_key_rotator_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe Gitlab::OtpKeyRotator do
+ let(:file) { Tempfile.new("otp-key-rotator-test") }
+ let(:filename) { file.path }
+ let(:old_key) { Gitlab::Application.secrets.otp_key_base }
+ let(:new_key) { "00" * 32 }
+ let!(:users) { create_list(:user, 5, :two_factor) }
+
+ after do
+ file.close
+ file.unlink
+ end
+
+ def data
+ CSV.read(filename)
+ end
+
+ def build_row(user, applied = false)
+ [user.id.to_s, encrypt_otp(user, old_key), encrypt_otp(user, new_key)]
+ end
+
+ def encrypt_otp(user, key)
+ opts = {
+ value: user.otp_secret,
+ iv: user.encrypted_otp_secret_iv.unpack("m").join,
+ salt: user.encrypted_otp_secret_salt.unpack("m").join,
+ algorithm: 'aes-256-cbc',
+ insecure_mode: true,
+ key: key
+ }
+ [Encryptor.encrypt(opts)].pack("m")
+ end
+
+ subject(:rotator) { described_class.new(filename) }
+
+ describe '#rotate!' do
+ subject(:rotation) { rotator.rotate!(old_key: old_key, new_key: new_key) }
+
+ it 'stores the calculated values in a spreadsheet' do
+ rotation
+
+ expect(data).to match_array(users.map {|u| build_row(u) })
+ end
+
+ context 'new key is too short' do
+ let(:new_key) { "00" * 31 }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+
+ context 'new key is the same as the old key' do
+ let(:new_key) { old_key }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+ end
+
+ describe '#rollback!' do
+ it 'updates rows to the old value' do
+ file.puts("#{users[0].id},old,new")
+ file.close
+
+ rotator.rollback!
+
+ expect(users[0].reload.encrypted_otp_secret).to eq('old')
+ expect(users[1].reload.encrypted_otp_secret).not_to eq('old')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
new file mode 100644
index 00000000000..2f989397f7e
--- /dev/null
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -0,0 +1,425 @@
+# coding: utf-8
+require 'spec_helper'
+
+describe Gitlab::PathRegex do
+ # Pass in a full path to remove the format segment:
+ # `/ci/lint(.:format)` -> `/ci/lint`
+ def without_format(path)
+ path.split('(', 2)[0]
+ end
+
+ # Pass in a full path and get the last segment before a wildcard
+ # That's not a parameter
+ # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path`
+ # -> 'builds/artifacts'
+ def path_before_wildcard(path)
+ path = path.gsub(STARTING_WITH_NAMESPACE, "")
+ path_segments = path.split('/').reject(&:empty?)
+ wildcard_index = path_segments.index { |segment| parameter?(segment) }
+
+ segments_before_wildcard = path_segments[0..wildcard_index - 1]
+
+ segments_before_wildcard.join('/')
+ end
+
+ def parameter?(segment)
+ segment =~ /[*:]/
+ end
+
+ # If the path is reserved. Then no conflicting paths can# be created for any
+ # route using this reserved word.
+ #
+ # Both `builds/artifacts` & `build` are covered by reserving the word
+ # `build`
+ def wildcards_include?(path)
+ described_class::PROJECT_WILDCARD_ROUTES.include?(path) ||
+ described_class::PROJECT_WILDCARD_ROUTES.include?(path.split('/').first)
+ end
+
+ def failure_message(constant_name, migration_helper, missing_words: [], additional_words: [])
+ missing_words = Array(missing_words)
+ additional_words = Array(additional_words)
+ message = ""
+ if missing_words.any?
+ message += <<-MISSING
+ Found new routes that could cause conflicts with existing namespaced routes
+ for groups or projects.
+
+ Add <#{missing_words.join(', ')}> to `Gitlab::PathRegex::#{constant_name}
+ to make sure no projects or namespaces can be created with those paths.
+
+ To rename any existing records with those paths you can use the
+ `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}`
+ migration helper.
+
+ Make sure to make a note of the renamed records in the release blog post.
+
+ MISSING
+ end
+
+ if additional_words.any?
+ message += <<-ADDITIONAL
+ Why are <#{additional_words.join(', ')}> in `#{constant_name}`?
+ If they are really required, update these specs to reflect that.
+
+ ADDITIONAL
+ end
+
+ message
+ end
+
+ let(:all_routes) do
+ route_set = Rails.application.routes
+ routes_collection = route_set.routes
+ routes_array = routes_collection.routes
+ routes_array.map { |route| route.path.spec.to_s }
+ end
+
+ let(:routes_without_format) { all_routes.map { |path| without_format(path) } }
+
+ # Routes not starting with `/:` or `/*`
+ # all routes not starting with a param
+ let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } }
+
+ let(:top_level_words) do
+ words = routes_not_starting_in_wildcard.map do |route|
+ route.split('/')[1]
+ end.compact.uniq
+
+ words + ee_top_level_words + files_in_public + Array(API::API.prefix.to_s)
+ end
+
+ let(:ee_top_level_words) do
+ ['unsubscribes']
+ end
+
+ let(:files_in_public) do
+ git = Gitlab.config.git.bin_path
+ `cd #{Rails.root} && #{git} ls-files public`
+ .split("\n")
+ .map { |entry| entry.gsub('public/', '') }
+ .uniq
+ end
+
+ # All routes that start with a namespaced path, that have 1 or more
+ # path-segments before having another wildcard parameter.
+ # - Starting with paths:
+ # - `/*namespace_id/:project_id/`
+ # - `/*namespace_id/:id/`
+ # - Followed by one or more path-parts not starting with `:` or `*`
+ # - Followed by a path-part that includes a wildcard parameter `*`
+ # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw
+ STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id}
+ NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*}
+ ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*}
+ WILDCARD_SEGMENT = %r{\*}
+ let(:namespaced_wildcard_routes) do
+ routes_without_format.select do |p|
+ p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}}
+ end
+ end
+
+ # This will return all paths that are used in a namespaced route
+ # before another wildcard path:
+ #
+ # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path
+ # /*namespace_id/:project_id/info/lfs/objects/*oid
+ # /*namespace_id/:project_id/commits/*id
+ # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path
+ # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file']
+ let(:all_wildcard_paths) do
+ namespaced_wildcard_routes.map do |route|
+ path_before_wildcard(route)
+ end.uniq
+ end
+
+ STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/}
+ let(:group_routes) do
+ routes_without_format.select do |path|
+ path =~ STARTING_WITH_GROUP
+ end
+ end
+
+ let(:paths_after_group_id) do
+ group_routes.map do |route|
+ route.gsub(STARTING_WITH_GROUP, '').split('/').first
+ end.uniq + ee_paths_after_group_id
+ end
+
+ let(:ee_paths_after_group_id) do
+ %w(analytics
+ ldap
+ ldap_group_links
+ notification_setting
+ audit_events
+ pipeline_quota hooks)
+ end
+
+ describe 'TOP_LEVEL_ROUTES' do
+ it 'includes all the top level namespaces' do
+ failure_block = lambda do
+ missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES
+ additional_words = described_class::TOP_LEVEL_ROUTES - top_level_words
+ failure_message('TOP_LEVEL_ROUTES', 'rename_root_paths',
+ missing_words: missing_words, additional_words: additional_words)
+ end
+
+ expect(described_class::TOP_LEVEL_ROUTES)
+ .to contain_exactly(*top_level_words), failure_block
+ end
+ end
+
+ describe 'GROUP_ROUTES' do
+ it "don't contain a second wildcard" do
+ failure_block = lambda do
+ missing_words = paths_after_group_id - described_class::GROUP_ROUTES
+ additional_words = described_class::GROUP_ROUTES - paths_after_group_id
+ failure_message('GROUP_ROUTES', 'rename_child_paths',
+ missing_words: missing_words, additional_words: additional_words)
+ end
+
+ expect(described_class::GROUP_ROUTES)
+ .to contain_exactly(*paths_after_group_id), failure_block
+ end
+ end
+
+ describe 'PROJECT_WILDCARD_ROUTES' do
+ it 'includes all paths that can be used after a namespace/project path' do
+ aggregate_failures do
+ all_wildcard_paths.each do |path|
+ expect(wildcards_include?(path))
+ .to be(true), failure_message('PROJECT_WILDCARD_ROUTES', 'rename_wildcard_paths', missing_words: path)
+ end
+ end
+ end
+ end
+
+ describe '.root_namespace_path_regex' do
+ subject { described_class.root_namespace_path_regex }
+
+ it 'rejects top level routes' do
+ expect(subject).not_to match('admin/')
+ expect(subject).not_to match('api/')
+ expect(subject).not_to match('.well-known/')
+ end
+
+ it 'accepts project wildcard routes' do
+ expect(subject).to match('blob/')
+ expect(subject).to match('edit/')
+ expect(subject).to match('wikis/')
+ end
+
+ it 'accepts group routes' do
+ expect(subject).to match('activity/')
+ expect(subject).to match('group_members/')
+ expect(subject).to match('subgroups/')
+ end
+
+ it 'is not case sensitive' do
+ expect(subject).not_to match('Users/')
+ end
+
+ it 'does not allow extra slashes' do
+ expect(subject).not_to match('/blob/')
+ expect(subject).not_to match('blob//')
+ end
+ end
+
+ describe '.full_namespace_path_regex' do
+ subject { described_class.full_namespace_path_regex }
+
+ context 'at the top level' do
+ context 'when the final level' do
+ it 'rejects top level routes' do
+ expect(subject).not_to match('admin/')
+ expect(subject).not_to match('api/')
+ expect(subject).not_to match('.well-known/')
+ end
+
+ it 'accepts project wildcard routes' do
+ expect(subject).to match('blob/')
+ expect(subject).to match('edit/')
+ expect(subject).to match('wikis/')
+ end
+
+ it 'accepts group routes' do
+ expect(subject).to match('activity/')
+ expect(subject).to match('group_members/')
+ expect(subject).to match('subgroups/')
+ end
+ end
+
+ context 'when more levels follow' do
+ it 'rejects top level routes' do
+ expect(subject).not_to match('admin/more/')
+ expect(subject).not_to match('api/more/')
+ expect(subject).not_to match('.well-known/more/')
+ end
+
+ it 'accepts project wildcard routes' do
+ expect(subject).to match('blob/more/')
+ expect(subject).to match('edit/more/')
+ expect(subject).to match('wikis/more/')
+ expect(subject).to match('environments/folders/')
+ expect(subject).to match('info/lfs/objects/')
+ end
+
+ it 'accepts group routes' do
+ expect(subject).to match('activity/more/')
+ expect(subject).to match('group_members/more/')
+ expect(subject).to match('subgroups/more/')
+ end
+ end
+ end
+
+ context 'at the second level' do
+ context 'when the final level' do
+ it 'accepts top level routes' do
+ expect(subject).to match('root/admin/')
+ expect(subject).to match('root/api/')
+ expect(subject).to match('root/.well-known/')
+ end
+
+ it 'rejects project wildcard routes' do
+ expect(subject).not_to match('root/blob/')
+ expect(subject).not_to match('root/edit/')
+ expect(subject).not_to match('root/wikis/')
+ expect(subject).not_to match('root/environments/folders/')
+ expect(subject).not_to match('root/info/lfs/objects/')
+ end
+
+ it 'rejects group routes' do
+ expect(subject).not_to match('root/activity/')
+ expect(subject).not_to match('root/group_members/')
+ expect(subject).not_to match('root/subgroups/')
+ end
+ end
+
+ context 'when more levels follow' do
+ it 'accepts top level routes' do
+ expect(subject).to match('root/admin/more/')
+ expect(subject).to match('root/api/more/')
+ expect(subject).to match('root/.well-known/more/')
+ end
+
+ it 'rejects project wildcard routes' do
+ expect(subject).not_to match('root/blob/more/')
+ expect(subject).not_to match('root/edit/more/')
+ expect(subject).not_to match('root/wikis/more/')
+ expect(subject).not_to match('root/environments/folders/more/')
+ expect(subject).not_to match('root/info/lfs/objects/more/')
+ end
+
+ it 'rejects group routes' do
+ expect(subject).not_to match('root/activity/more/')
+ expect(subject).not_to match('root/group_members/more/')
+ expect(subject).not_to match('root/subgroups/more/')
+ end
+ end
+ end
+
+ it 'is not case sensitive' do
+ expect(subject).not_to match('root/Blob/')
+ end
+
+ it 'does not allow extra slashes' do
+ expect(subject).not_to match('/root/admin/')
+ expect(subject).not_to match('root/admin//')
+ end
+ end
+
+ describe '.project_path_regex' do
+ subject { described_class.project_path_regex }
+
+ it 'accepts top level routes' do
+ expect(subject).to match('admin/')
+ expect(subject).to match('api/')
+ expect(subject).to match('.well-known/')
+ end
+
+ it 'rejects project wildcard routes' do
+ expect(subject).not_to match('blob/')
+ expect(subject).not_to match('edit/')
+ expect(subject).not_to match('wikis/')
+ expect(subject).not_to match('environments/folders/')
+ expect(subject).not_to match('info/lfs/objects/')
+ end
+
+ it 'accepts group routes' do
+ expect(subject).to match('activity/')
+ expect(subject).to match('group_members/')
+ expect(subject).to match('subgroups/')
+ end
+
+ it 'is not case sensitive' do
+ expect(subject).not_to match('Blob/')
+ end
+
+ it 'does not allow extra slashes' do
+ expect(subject).not_to match('/admin/')
+ expect(subject).not_to match('admin//')
+ end
+ end
+
+ describe '.full_project_path_regex' do
+ subject { described_class.full_project_path_regex }
+
+ it 'accepts top level routes' do
+ expect(subject).to match('root/admin/')
+ expect(subject).to match('root/api/')
+ expect(subject).to match('root/.well-known/')
+ end
+
+ it 'rejects project wildcard routes' do
+ expect(subject).not_to match('root/blob/')
+ expect(subject).not_to match('root/edit/')
+ expect(subject).not_to match('root/wikis/')
+ expect(subject).not_to match('root/environments/folders/')
+ expect(subject).not_to match('root/info/lfs/objects/')
+ end
+
+ it 'accepts group routes' do
+ expect(subject).to match('root/activity/')
+ expect(subject).to match('root/group_members/')
+ expect(subject).to match('root/subgroups/')
+ end
+
+ it 'is not case sensitive' do
+ expect(subject).not_to match('root/Blob/')
+ end
+
+ it 'does not allow extra slashes' do
+ expect(subject).not_to match('/root/admin/')
+ expect(subject).not_to match('root/admin//')
+ end
+ end
+
+ describe '.namespace_format_regex' do
+ subject { described_class.namespace_format_regex }
+
+ it { is_expected.to match('gitlab-ce') }
+ it { is_expected.to match('gitlab_git') }
+ it { is_expected.to match('_underscore.js') }
+ it { is_expected.to match('100px.com') }
+ it { is_expected.to match('gitlab.org') }
+ it { is_expected.not_to match('?gitlab') }
+ it { is_expected.not_to match('git lab') }
+ it { is_expected.not_to match('gitlab.git') }
+ it { is_expected.not_to match('gitlab.org.') }
+ it { is_expected.not_to match('gitlab.org/') }
+ it { is_expected.not_to match('/gitlab.org') }
+ it { is_expected.not_to match('gitlab git') }
+ end
+
+ describe '.project_path_format_regex' do
+ subject { described_class.project_path_format_regex }
+
+ it { is_expected.to match('gitlab-ce') }
+ it { is_expected.to match('gitlab_git') }
+ it { is_expected.to match('_underscore.js') }
+ it { is_expected.to match('100px.com') }
+ it { is_expected.not_to match('?gitlab') }
+ it { is_expected.not_to match('git lab') }
+ it { is_expected.not_to match('gitlab.git') }
+ end
+end
diff --git a/spec/lib/gitlab/performance_bar_spec.rb b/spec/lib/gitlab/performance_bar_spec.rb
new file mode 100644
index 00000000000..b8a2267f1a4
--- /dev/null
+++ b/spec/lib/gitlab/performance_bar_spec.rb
@@ -0,0 +1,92 @@
+require 'spec_helper'
+
+describe Gitlab::PerformanceBar do
+ shared_examples 'allowed user IDs are cached' do
+ before do
+ # Warm the Redis cache
+ described_class.enabled?(user)
+ end
+
+ it 'caches the allowed user IDs in cache', :use_clean_rails_memory_store_caching do
+ expect do
+ expect(described_class.enabled?(user)).to be_truthy
+ end.not_to exceed_query_limit(0)
+ end
+ end
+
+ describe '.enabled?' do
+ let(:user) { create(:user) }
+
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: -1)
+ end
+
+ it 'returns false when given user is nil' do
+ expect(described_class.enabled?(nil)).to be_falsy
+ end
+
+ it 'returns false when allowed_group_id is nil' do
+ expect(described_class).to receive(:allowed_group_id).and_return(nil)
+
+ expect(described_class.enabled?(user)).to be_falsy
+ end
+
+ context 'when allowed group ID does not exist' do
+ it 'returns false' do
+ expect(described_class.enabled?(user)).to be_falsy
+ end
+ end
+
+ context 'when allowed group exists' do
+ let!(:my_group) { create(:group, path: 'my-group') }
+
+ before do
+ stub_application_setting(performance_bar_allowed_group_id: my_group.id)
+ end
+
+ context 'when user is not a member of the allowed group' do
+ it 'returns false' do
+ expect(described_class.enabled?(user)).to be_falsy
+ end
+
+ it_behaves_like 'allowed user IDs are cached'
+ end
+
+ context 'when user is a member of the allowed group' do
+ before do
+ my_group.add_developer(user)
+ end
+
+ it 'returns true' do
+ expect(described_class.enabled?(user)).to be_truthy
+ end
+
+ it_behaves_like 'allowed user IDs are cached'
+ end
+ end
+
+ context 'when allowed group is nested', :nested_groups do
+ let!(:nested_my_group) { create(:group, parent: create(:group, path: 'my-org'), path: 'my-group') }
+
+ before do
+ create(:group, path: 'my-group')
+ nested_my_group.add_developer(user)
+ stub_application_setting(performance_bar_allowed_group_id: nested_my_group.id)
+ end
+
+ it 'returns the nested group' do
+ expect(described_class.enabled?(user)).to be_truthy
+ end
+ end
+
+ context 'when a nested group has the same path', :nested_groups do
+ before do
+ create(:group, :nested, path: 'my-group').add_developer(user)
+ end
+
+ it 'returns false' do
+ expect(described_class.enabled?(user)).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/polling_interval_spec.rb b/spec/lib/gitlab/polling_interval_spec.rb
index 5ea8ecb1c30..eb8e618156b 100644
--- a/spec/lib/gitlab/polling_interval_spec.rb
+++ b/spec/lib/gitlab/polling_interval_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::PollingInterval, lib: true do
+describe Gitlab::PollingInterval do
let(:polling_interval) { described_class }
describe '.set_header' do
diff --git a/spec/lib/gitlab/popen_spec.rb b/spec/lib/gitlab/popen_spec.rb
index 4ae216d55b0..4567f220c11 100644
--- a/spec/lib/gitlab/popen_spec.rb
+++ b/spec/lib/gitlab/popen_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Gitlab::Popen', lib: true, no_db: true do
+describe 'Gitlab::Popen' do
let(:path) { Rails.root.join('tmp').to_s }
before do
@@ -32,6 +32,17 @@ describe 'Gitlab::Popen', lib: true, no_db: true do
end
end
+ context 'with custom options' do
+ let(:vars) { { 'foobar' => 123, 'PWD' => path } }
+ let(:options) { { chdir: path } }
+
+ it 'calls popen3 with the provided environment variables' do
+ expect(Open3).to receive(:popen3).with(vars, 'ls', options)
+
+ @output, @status = @klass.new.popen(%w(ls), path, { 'foobar' => 123 })
+ end
+ end
+
context 'without a directory argument' do
before do
@output, @status = @klass.new.popen(%w(ls))
@@ -45,7 +56,7 @@ describe 'Gitlab::Popen', lib: true, no_db: true do
before do
@output, @status = @klass.new.popen(%w[cat]) { |stdin| stdin.write 'hello' }
end
-
+
it { expect(@status).to be_zero }
it { expect(@output).to eq('hello') }
end
diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb
new file mode 100644
index 00000000000..953cfbb8b88
--- /dev/null
+++ b/spec/lib/gitlab/project_authorizations_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+describe Gitlab::ProjectAuthorizations do
+ let(:group) { create(:group) }
+ let!(:owned_project) { create(:project) }
+ let!(:other_project) { create(:project) }
+ let!(:group_project) { create(:project, namespace: group) }
+
+ let(:user) { owned_project.namespace.owner }
+
+ def map_access_levels(rows)
+ rows.each_with_object({}) do |row, hash|
+ hash[row.project_id] = row.access_level
+ end
+ end
+
+ before do
+ other_project.team << [user, :reporter]
+ group.add_developer(user)
+ end
+
+ let(:authorizations) do
+ klass = if Group.supports_nested_groups?
+ Gitlab::ProjectAuthorizations::WithNestedGroups
+ else
+ Gitlab::ProjectAuthorizations::WithoutNestedGroups
+ end
+
+ klass.new(user).calculate
+ end
+
+ it 'returns the correct number of authorizations' do
+ expect(authorizations.length).to eq(3)
+ end
+
+ it 'includes the correct projects' do
+ expect(authorizations.pluck(:project_id))
+ .to include(owned_project.id, other_project.id, group_project.id)
+ end
+
+ it 'includes the correct access levels' do
+ mapping = map_access_levels(authorizations)
+
+ expect(mapping[owned_project.id]).to eq(Gitlab::Access::MASTER)
+ expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER)
+ expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ if Group.supports_nested_groups?
+ context 'with nested groups' do
+ let!(:nested_group) { create(:group, parent: group) }
+ let!(:nested_project) { create(:project, namespace: nested_group) }
+
+ it 'includes nested groups' do
+ expect(authorizations.pluck(:project_id)).to include(nested_project.id)
+ end
+
+ it 'inherits access levels when the user is not a member of a nested group' do
+ mapping = map_access_levels(authorizations)
+
+ expect(mapping[nested_project.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ it 'uses the greatest access level when a user is a member of a nested group' do
+ nested_group.add_master(user)
+
+ mapping = map_access_levels(authorizations)
+
+ expect(mapping[nested_project.id]).to eq(Gitlab::Access::MASTER)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index e0ebea63eb4..9c3e7d7e9ba 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Gitlab::ProjectSearchResults, lib: true do
+describe Gitlab::ProjectSearchResults do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:query) { 'hello world' }
describe 'initialize with empty ref' do
@@ -22,11 +22,40 @@ describe Gitlab::ProjectSearchResults, lib: true do
end
describe 'blob search' do
- let(:project) { create(:project, :repository) }
- let(:results) { described_class.new(user, project, 'files').objects('blobs') }
+ let(:project) { create(:project, :public, :repository) }
+
+ subject(:results) { described_class.new(user, project, 'files').objects('blobs') }
+
+ context 'when repository is disabled' do
+ let(:project) { create(:project, :public, :repository, :repository_disabled) }
+
+ it 'hides blobs from members' do
+ project.add_reporter(user)
+
+ is_expected.to be_empty
+ end
+
+ it 'hides blobs from non-members' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when repository is internal' do
+ let(:project) { create(:project, :public, :repository, :repository_private) }
+
+ it 'finds blobs for members' do
+ project.add_reporter(user)
+
+ is_expected.not_to be_empty
+ end
+
+ it 'hides blobs from non-members' do
+ is_expected.to be_empty
+ end
+ end
it 'finds by name' do
- expect(results).to include(["files/images/wm.svg", nil])
+ expect(results.map(&:first)).to include('files/images/wm.svg')
end
it 'finds by content' do
@@ -70,6 +99,46 @@ describe Gitlab::ProjectSearchResults, lib: true do
end
end
+ describe 'wiki search' do
+ let(:project) { create(:project, :public) }
+ let(:wiki) { build(:project_wiki, project: project) }
+ let!(:wiki_page) { wiki.create_page('Title', 'Content') }
+
+ subject(:results) { described_class.new(user, project, 'Content').objects('wiki_blobs') }
+
+ context 'when wiki is disabled' do
+ let(:project) { create(:project, :public, :wiki_disabled) }
+
+ it 'hides wiki blobs from members' do
+ project.add_reporter(user)
+
+ is_expected.to be_empty
+ end
+
+ it 'hides wiki blobs from non-members' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when wiki is internal' do
+ let(:project) { create(:project, :public, :wiki_private) }
+
+ it 'finds wiki blobs for guest' do
+ project.add_guest(user)
+
+ is_expected.not_to be_empty
+ end
+
+ it 'hides wiki blobs from non-members' do
+ is_expected.to be_empty
+ end
+ end
+
+ it 'finds by content' do
+ expect(results).to include("master:Title.md:1:Content\n")
+ end
+ end
+
it 'does not list issues on private projects' do
issue = create(:issue, project: project)
@@ -79,17 +148,16 @@ describe Gitlab::ProjectSearchResults, lib: true do
end
describe 'confidential issues' do
- let(:project) { create(:empty_project) }
let(:query) { 'issue' }
let(:author) { create(:user) }
let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
let!(:issue) { create(:issue, project: project, title: 'Issue 1') }
let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) }
- let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) }
+ let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
it 'does not list project confidential issues for non project members' do
results = described_class.new(non_member, project, query)
@@ -158,7 +226,7 @@ describe Gitlab::ProjectSearchResults, lib: true do
describe 'notes search' do
it 'lists notes' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
note = create(:note, project: project)
results = described_class.new(user, project, note.note)
@@ -167,7 +235,7 @@ describe Gitlab::ProjectSearchResults, lib: true do
end
it "doesn't list issue notes when access is restricted" do
- project = create(:empty_project, :public, :issues_private)
+ project = create(:project, :public, :issues_private)
note = create(:note_on_issue, project: project)
results = described_class.new(user, project, note.note)
@@ -176,7 +244,7 @@ describe Gitlab::ProjectSearchResults, lib: true do
end
it "doesn't list merge_request notes when access is restricted" do
- project = create(:empty_project, :public, :merge_requests_private)
+ project = create(:project, :public, :merge_requests_private)
note = create(:note_on_merge_request, project: project)
results = described_class.new(user, project, note.note)
@@ -277,6 +345,7 @@ describe Gitlab::ProjectSearchResults, lib: true do
context 'by commit hash' do
let(:project) { create(:project, :public, :repository) }
let(:commit) { project.repository.commit('0b4bc9a') }
+
commit_hashes = { short: '0b4bc9a', full: '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
commit_hashes.each do |type, commit_hash|
diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb
new file mode 100644
index 00000000000..d19bd611919
--- /dev/null
+++ b/spec/lib/gitlab/project_template_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Gitlab::ProjectTemplate do
+ describe '.all' do
+ it 'returns a all templates' do
+ expected = [
+ described_class.new('rails', 'Ruby on Rails'),
+ described_class.new('spring', 'Spring'),
+ described_class.new('express', 'NodeJS Express')
+ ]
+
+ expect(described_class.all).to be_an(Array)
+ expect(described_class.all).to eq(expected)
+ end
+ end
+
+ describe '.find' do
+ subject { described_class.find(query) }
+
+ context 'when there is a match' do
+ let(:query) { :rails }
+
+ it { is_expected.to be_a(described_class) }
+ end
+
+ context 'when there is no match' do
+ let(:query) { 'no-match' }
+
+ it { is_expected.to be(nil) }
+ end
+ end
+
+ describe 'instance methods' do
+ subject { described_class.new('phoenix', 'Phoenix Framework') }
+
+ it { is_expected.to respond_to(:logo, :file, :archive_path) }
+ end
+
+ describe 'validate all templates' do
+ set(:admin) { create(:admin) }
+
+ described_class.all.each do |template|
+ it "#{template.name} has a valid archive" do
+ archive = template.archive_path
+
+ expect(File.exist?(archive)).to be(true)
+ end
+
+ context 'with valid parameters' do
+ it 'can be imported' do
+ params = {
+ template_name: template.name,
+ namespace_id: admin.namespace.id,
+ path: template.name
+ }
+
+ project = Projects::CreateFromTemplateService.new(admin, params).execute
+
+ expect(project).to be_valid
+ expect(project).to be_persisted
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/project_transfer_spec.rb b/spec/lib/gitlab/project_transfer_spec.rb
index e2d6b1b9ab7..10c5fb148cd 100644
--- a/spec/lib/gitlab/project_transfer_spec.rb
+++ b/spec/lib/gitlab/project_transfer_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Gitlab::ProjectTransfer, lib: true do
+describe Gitlab::ProjectTransfer do
before do
@root_dir = File.join(Rails.root, "public", "uploads")
- @project_transfer = Gitlab::ProjectTransfer.new
+ @project_transfer = described_class.new
allow(@project_transfer).to receive(:root_dir).and_return(@root_dir)
@project_path_was = "test_project_was"
diff --git a/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb b/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb
new file mode 100644
index 00000000000..5589db92b1d
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb
@@ -0,0 +1,246 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::AdditionalMetricsParser do
+ include Prometheus::MetricBuilders
+
+ let(:parser_error_class) { Gitlab::Prometheus::ParsingError }
+
+ describe '#load_groups_from_yaml' do
+ subject { described_class.load_groups_from_yaml }
+
+ describe 'parsing sample yaml' do
+ let(:sample_yaml) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: "title"
+ required_metrics: [ metric_a, metric_b ]
+ weight: 1
+ queries: [{ query_range: 'query_range_a', label: label, unit: unit }]
+ - title: "title"
+ required_metrics: [metric_a]
+ weight: 1
+ queries: [{ query_range: 'query_range_empty' }]
+ - group: group_b
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: ['metric_a']
+ weight: 1
+ queries: [{query_range: query_range_a}]
+ EOF
+ end
+
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(sample_yaml) }
+ end
+
+ it 'parses to two metric groups with 2 and 1 metric respectively' do
+ expect(subject.count).to eq(2)
+ expect(subject[0].metrics.count).to eq(2)
+ expect(subject[1].metrics.count).to eq(1)
+ end
+
+ it 'provide group data' do
+ expect(subject[0]).to have_attributes(name: 'group_a', priority: 1)
+ expect(subject[1]).to have_attributes(name: 'group_b', priority: 1)
+ end
+
+ it 'provides metrics data' do
+ metrics = subject.flat_map(&:metrics)
+
+ expect(metrics.count).to eq(3)
+ expect(metrics[0]).to have_attributes(title: 'title', required_metrics: %w(metric_a metric_b), weight: 1)
+ expect(metrics[1]).to have_attributes(title: 'title', required_metrics: %w(metric_a), weight: 1)
+ expect(metrics[2]).to have_attributes(title: 'title', required_metrics: %w{metric_a}, weight: 1)
+ end
+
+ it 'provides query data' do
+ queries = subject.flat_map(&:metrics).flat_map(&:queries)
+
+ expect(queries.count).to eq(3)
+ expect(queries[0]).to eq(query_range: 'query_range_a', label: 'label', unit: 'unit')
+ expect(queries[1]).to eq(query_range: 'query_range_empty')
+ expect(queries[2]).to eq(query_range: 'query_range_a')
+ end
+ end
+
+ shared_examples 'required field' do |field_name|
+ context "when #{field_name} is nil" do
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(field_missing) }
+ end
+
+ it 'throws parsing error' do
+ expect { subject }.to raise_error(parser_error_class, /#{field_name} can't be blank/i)
+ end
+ end
+
+ context "when #{field_name} are not specified" do
+ before do
+ allow(described_class).to receive(:load_yaml_file) { YAML.load(field_nil) }
+ end
+
+ it 'throws parsing error' do
+ expect { subject }.to raise_error(parser_error_class, /#{field_name} can't be blank/i)
+ end
+ end
+ end
+
+ describe 'group required fields' do
+ it_behaves_like 'required field', 'metrics' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'name' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group:
+ priority: 1
+ metrics: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - priority: 1
+ metrics: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'priority' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority:
+ metrics: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ metrics: []
+ EOF
+ end
+ end
+ end
+
+ describe 'metrics fields parsing' do
+ it_behaves_like 'required field', 'title' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title:
+ required_metrics: []
+ weight: 1
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - required_metrics: []
+ weight: 1
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'required metrics' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics:
+ weight: 1
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ weight: 1
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', 'weight' do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight:
+ queries: []
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ queries: []
+ EOF
+ end
+ end
+
+ it_behaves_like 'required field', :queries do
+ let(:field_nil) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight: 1
+ queries:
+ EOF
+ end
+
+ let(:field_missing) do
+ <<-EOF.strip_heredoc
+ - group: group_a
+ priority: 1
+ metrics:
+ - title: title
+ required_metrics: []
+ weight: 1
+ EOF
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
new file mode 100644
index 00000000000..c7169717fc1
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery do
+ around do |example|
+ Timecop.freeze(Time.local(2008, 9, 1, 12, 0, 0)) { example.run }
+ end
+
+ include_examples 'additional metrics query' do
+ let(:deployment) { create(:deployment, environment: environment) }
+ let(:query_params) { [deployment.id] }
+
+ it 'queries using specific time' do
+ expect(client).to receive(:query_range).with(anything,
+ start: (deployment.created_at - 30.minutes).to_f,
+ stop: (deployment.created_at + 30.minutes).to_f)
+
+ expect(query_result).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
new file mode 100644
index 00000000000..5a88b23aa82
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery do
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ include_examples 'additional metrics query' do
+ let(:query_params) { [environment.id] }
+
+ it 'queries using specific time' do
+ expect(client).to receive(:query_range).with(anything, start: 8.hours.ago.to_f, stop: Time.now.to_f)
+
+ expect(query_result).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb
new file mode 100644
index 00000000000..ffe3ad85baa
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::DeploymentQuery do
+ let(:environment) { create(:environment, slug: 'environment-slug') }
+ let(:deployment) { create(:deployment, environment: environment) }
+
+ let(:client) { double('prometheus_client') }
+ subject { described_class.new(client) }
+
+ around do |example|
+ time_without_subsecond_values = Time.local(2008, 9, 1, 12, 0, 0)
+ Timecop.freeze(time_without_subsecond_values) { example.run }
+ end
+
+ it 'sends appropriate queries to prometheus' do
+ start_time = (deployment.created_at - 30.minutes).to_f
+ stop_time = (deployment.created_at + 30.minutes).to_f
+ created_at = deployment.created_at.to_f
+
+ expect(client).to receive(:query_range).with('avg(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}) / 2^20',
+ start: start_time, stop: stop_time)
+ expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))',
+ time: created_at)
+ expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))',
+ time: stop_time)
+
+ expect(client).to receive(:query_range).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[2m])) * 100',
+ start: start_time, stop: stop_time)
+ expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100',
+ time: created_at)
+ expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100',
+ time: stop_time)
+
+ expect(subject.query(deployment.id)).to eq(memory_values: nil, memory_before: nil, memory_after: nil,
+ cpu_values: nil, cpu_before: nil, cpu_after: nil)
+ end
+end
diff --git a/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb b/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
new file mode 100644
index 00000000000..2b488101496
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
@@ -0,0 +1,134 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::MatchedMetricsQuery do
+ include Prometheus::MetricBuilders
+
+ let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
+ let(:metric_class) { Gitlab::Prometheus::Metric }
+
+ def series_info_with_environment(*more_metrics)
+ %w{metric_a metric_b}.concat(more_metrics).map { |metric_name| { '__name__' => metric_name, 'environment' => '' } }
+ end
+
+ let(:metric_names) { %w{metric_a metric_b} }
+ let(:series_info_without_environment) do
+ [{ '__name__' => 'metric_a' },
+ { '__name__' => 'metric_b' }]
+ end
+ let(:partialy_empty_series_info) { [{ '__name__' => 'metric_a', 'environment' => '' }] }
+ let(:empty_series_info) { [] }
+
+ let(:client) { double('prometheus_client') }
+
+ subject { described_class.new(client) }
+
+ context 'with one group where two metrics is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ allow(client).to receive(:label_values).and_return(metric_names)
+ end
+
+ context 'both metrics in the group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment)
+ end
+
+ it 'responds with both metrics as actve' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 2, metrics_missing_requirements: 0 }])
+ end
+ end
+
+ context 'none of the metrics pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with both metrics missing requirements' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }])
+ end
+ end
+
+ context 'no series information found about the metrics' do
+ before do
+ allow(client).to receive(:series).and_return(empty_series_info)
+ end
+
+ it 'responds with both metrics missing requirements' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }])
+ end
+ end
+
+ context 'one of the series info was not found' do
+ before do
+ allow(client).to receive(:series).and_return(partialy_empty_series_info)
+ end
+ it 'responds with one active and one missing metric' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 1 }])
+ end
+ end
+ end
+
+ context 'with one group where only one metric is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ allow(client).to receive(:label_values).and_return('metric_a')
+ end
+
+ context 'both metrics in the group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 0 }])
+ end
+ end
+
+ context 'no metrics in group pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([{ group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 1 }])
+ end
+ end
+ end
+
+ context 'with two groups where metrics are found in each group' do
+ let(:second_metric_group) { simple_metric_group(name: 'nameb', metrics: simple_metrics(added_metric_name: 'metric_c')) }
+
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group, second_metric_group])
+ allow(client).to receive(:label_values).and_return('metric_c')
+ end
+
+ context 'all metrics in both groups pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_with_environment('metric_c'))
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([
+ { group: 'name', priority: 1, active_metrics: 1, metrics_missing_requirements: 0 },
+ { group: 'nameb', priority: 1, active_metrics: 2, metrics_missing_requirements: 0 }
+ ]
+ )
+ end
+ end
+
+ context 'no metrics in groups pass requirements' do
+ before do
+ allow(client).to receive(:series).and_return(series_info_without_environment)
+ end
+
+ it 'responds with one metrics as active and no missing requiremens' do
+ expect(subject.query).to eq([
+ { group: 'name', priority: 1, active_metrics: 0, metrics_missing_requirements: 1 },
+ { group: 'nameb', priority: 1, active_metrics: 0, metrics_missing_requirements: 2 }
+ ]
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb
new file mode 100644
index 00000000000..de625324092
--- /dev/null
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -0,0 +1,221 @@
+require 'spec_helper'
+
+describe Gitlab::PrometheusClient do
+ include PrometheusHelpers
+
+ subject { described_class.new(api_url: 'https://prometheus.example.com') }
+
+ describe '#ping' do
+ it 'issues a "query" request to the API endpoint' do
+ req_stub = stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector'))
+
+ expect(subject.ping).to eq({ "resultType" => "vector", "result" => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] })
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ # This shared examples expect:
+ # - query_url: A query URL
+ # - execute_query: A query call
+ shared_examples 'failure response' do
+ context 'when request returns 400 with an error message' do
+ it 'raises a Gitlab::PrometheusError error' do
+ req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' })
+
+ expect { execute_query }
+ .to raise_error(Gitlab::PrometheusError, 'bar!')
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns 400 without an error message' do
+ it 'raises a Gitlab::PrometheusError error' do
+ req_stub = stub_prometheus_request(query_url, status: 400)
+
+ expect { execute_query }
+ .to raise_error(Gitlab::PrometheusError, 'Bad data received')
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns 500' do
+ it 'raises a Gitlab::PrometheusError error' do
+ req_stub = stub_prometheus_request(query_url, status: 500, body: { message: 'FAIL!' })
+
+ expect { execute_query }
+ .to raise_error(Gitlab::PrometheusError, '500 - {"message":"FAIL!"}')
+ expect(req_stub).to have_been_requested
+ end
+ end
+ end
+
+ describe 'failure to reach a provided prometheus url' do
+ let(:prometheus_url) {"https://prometheus.invalid.example.com"}
+
+ context 'exceptions are raised' do
+ it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do
+ req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError)
+
+ expect { subject.send(:get, prometheus_url) }
+ .to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_url}")
+ expect(req_stub).to have_been_requested
+ end
+
+ it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do
+ req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError)
+
+ expect { subject.send(:get, prometheus_url) }
+ .to raise_error(Gitlab::PrometheusError, "#{prometheus_url} contains invalid SSL data")
+ expect(req_stub).to have_been_requested
+ end
+
+ it 'raises a Gitlab::PrometheusError error when a HTTParty::Error is rescued' do
+ req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error)
+
+ expect { subject.send(:get, prometheus_url) }
+ .to raise_error(Gitlab::PrometheusError, "Network connection error")
+ expect(req_stub).to have_been_requested
+ end
+ end
+ end
+
+ describe '#query' do
+ let(:prometheus_query) { prometheus_cpu_query('env-slug') }
+ let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'when request returns vector results' do
+ it 'returns data from the API call' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector'))
+
+ expect(subject.query(prometheus_query)).to eq [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }]
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns matrix results' do
+ it 'returns nil' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('matrix'))
+
+ expect(subject.query(prometheus_query)).to be_nil
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns no data' do
+ it 'returns []' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector'))
+
+ expect(subject.query(prometheus_query)).to be_empty
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ it_behaves_like 'failure response' do
+ let(:execute_query) { subject.query(prometheus_query) }
+ end
+ end
+
+ describe '#series' do
+ let(:query_url) { prometheus_series_url('series_name', 'other_service') }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ it 'calls endpoint and returns list of series' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_series('series_name'))
+ expected = prometheus_series('series_name').deep_stringify_keys['data']
+
+ expect(subject.series('series_name', 'other_service')).to eq(expected)
+
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ describe '#label_values' do
+ let(:query_url) { prometheus_label_values_url('__name__') }
+
+ it 'calls endpoint and returns label values' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_label_values)
+ expected = prometheus_label_values.deep_stringify_keys['data']
+
+ expect(subject.label_values('__name__')).to eq(expected)
+
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ describe '#query_range' do
+ let(:prometheus_query) { prometheus_memory_query('env-slug') }
+ let(:query_url) { prometheus_query_range_url(prometheus_query) }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'when non utc time is passed' do
+ let(:time_stop) { Time.now.in_time_zone("Warsaw") }
+ let(:time_start) { time_stop - 8.hours }
+
+ let(:query_url) { prometheus_query_range_url(prometheus_query, start: time_start.utc.to_f, stop: time_stop.utc.to_f) }
+
+ it 'passed dates are properly converted to utc' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector'))
+
+ subject.query_range(prometheus_query, start: time_start, stop: time_stop)
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when a start time is passed' do
+ let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) }
+
+ it 'passed it in the requested URL' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector'))
+
+ subject.query_range(prometheus_query, start: 2.hours.ago)
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns vector results' do
+ it 'returns nil' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector'))
+
+ expect(subject.query_range(prometheus_query)).to be_nil
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns matrix results' do
+ it 'returns data from the API call' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('matrix'))
+
+ expect(subject.query_range(prometheus_query)).to eq([
+ {
+ "metric" => {},
+ "values" => [[1488758662.506, "0.00002996364761904785"], [1488758722.506, "0.00003090239047619091"]]
+ }
+ ])
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns no data' do
+ it 'returns []' do
+ req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('matrix'))
+
+ expect(subject.query_range(prometheus_query)).to be_empty
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ it_behaves_like 'failure response' do
+ let(:execute_query) { subject.query_range(prometheus_query) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb
deleted file mode 100644
index 280264188e2..00000000000
--- a/spec/lib/gitlab/prometheus_spec.rb
+++ /dev/null
@@ -1,143 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Prometheus, lib: true do
- include PrometheusHelpers
-
- subject { described_class.new(api_url: 'https://prometheus.example.com') }
-
- describe '#ping' do
- it 'issues a "query" request to the API endpoint' do
- req_stub = stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector'))
-
- expect(subject.ping).to eq({ "resultType" => "vector", "result" => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] })
- expect(req_stub).to have_been_requested
- end
- end
-
- # This shared examples expect:
- # - query_url: A query URL
- # - execute_query: A query call
- shared_examples 'failure response' do
- context 'when request returns 400 with an error message' do
- it 'raises a Gitlab::PrometheusError error' do
- req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' })
-
- expect { execute_query }
- .to raise_error(Gitlab::PrometheusError, 'bar!')
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns 400 without an error message' do
- it 'raises a Gitlab::PrometheusError error' do
- req_stub = stub_prometheus_request(query_url, status: 400)
-
- expect { execute_query }
- .to raise_error(Gitlab::PrometheusError, 'Bad data received')
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns 500' do
- it 'raises a Gitlab::PrometheusError error' do
- req_stub = stub_prometheus_request(query_url, status: 500, body: { message: 'FAIL!' })
-
- expect { execute_query }
- .to raise_error(Gitlab::PrometheusError, '500 - {"message":"FAIL!"}')
- expect(req_stub).to have_been_requested
- end
- end
- end
-
- describe '#query' do
- let(:prometheus_query) { prometheus_cpu_query('env-slug') }
- let(:query_url) { prometheus_query_url(prometheus_query) }
-
- context 'when request returns vector results' do
- it 'returns data from the API call' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector'))
-
- expect(subject.query(prometheus_query)).to eq [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }]
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns matrix results' do
- it 'returns nil' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('matrix'))
-
- expect(subject.query(prometheus_query)).to be_nil
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns no data' do
- it 'returns []' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector'))
-
- expect(subject.query(prometheus_query)).to be_empty
- expect(req_stub).to have_been_requested
- end
- end
-
- it_behaves_like 'failure response' do
- let(:execute_query) { subject.query(prometheus_query) }
- end
- end
-
- describe '#query_range' do
- let(:prometheus_query) { prometheus_memory_query('env-slug') }
- let(:query_url) { prometheus_query_range_url(prometheus_query) }
-
- around do |example|
- Timecop.freeze { example.run }
- end
-
- context 'when a start time is passed' do
- let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) }
-
- it 'passed it in the requested URL' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector'))
-
- subject.query_range(prometheus_query, start: 2.hours.ago)
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns vector results' do
- it 'returns nil' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector'))
-
- expect(subject.query_range(prometheus_query)).to be_nil
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns matrix results' do
- it 'returns data from the API call' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('matrix'))
-
- expect(subject.query_range(prometheus_query)).to eq([
- {
- "metric" => {},
- "values" => [[1488758662.506, "0.00002996364761904785"], [1488758722.506, "0.00003090239047619091"]]
- }
- ])
- expect(req_stub).to have_been_requested
- end
- end
-
- context 'when request returns no data' do
- it 'returns []' do
- req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('matrix'))
-
- expect(subject.query_range(prometheus_query)).to be_empty
- expect(req_stub).to have_been_requested
- end
- end
-
- it_behaves_like 'failure response' do
- let(:execute_query) { subject.query_range(prometheus_query) }
- end
- end
-end
diff --git a/spec/lib/gitlab/quick_actions/command_definition_spec.rb b/spec/lib/gitlab/quick_actions/command_definition_spec.rb
new file mode 100644
index 00000000000..f44a562dc63
--- /dev/null
+++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb
@@ -0,0 +1,225 @@
+require 'spec_helper'
+
+describe Gitlab::QuickActions::CommandDefinition do
+ subject { described_class.new(:command) }
+
+ describe "#all_names" do
+ context "when the command has aliases" do
+ before do
+ subject.aliases = [:alias1, :alias2]
+ end
+
+ it "returns an array with the name and aliases" do
+ expect(subject.all_names).to eq([:command, :alias1, :alias2])
+ end
+ end
+
+ context "when the command doesn't have aliases" do
+ it "returns an array with the name" do
+ expect(subject.all_names).to eq([:command])
+ end
+ end
+ end
+
+ describe "#noop?" do
+ context "when the command has an action block" do
+ before do
+ subject.action_block = proc { }
+ end
+
+ it "returns false" do
+ expect(subject.noop?).to be false
+ end
+ end
+
+ context "when the command doesn't have an action block" do
+ it "returns true" do
+ expect(subject.noop?).to be true
+ end
+ end
+ end
+
+ describe "#available?" do
+ let(:opts) { { go: false } }
+
+ context "when the command has a condition block" do
+ before do
+ subject.condition_block = proc { go }
+ end
+
+ context "when the condition block returns true" do
+ before do
+ opts[:go] = true
+ end
+
+ it "returns true" do
+ expect(subject.available?(opts)).to be true
+ end
+ end
+
+ context "when the condition block returns false" do
+ it "returns false" do
+ expect(subject.available?(opts)).to be false
+ end
+ end
+ end
+
+ context "when the command doesn't have a condition block" do
+ it "returns true" do
+ expect(subject.available?(opts)).to be true
+ end
+ end
+ end
+
+ describe "#execute" do
+ let(:context) { OpenStruct.new(run: false) }
+
+ context "when the command is a noop" do
+ it "doesn't execute the command" do
+ expect(context).not_to receive(:instance_exec)
+
+ subject.execute(context, {}, nil)
+
+ expect(context.run).to be false
+ end
+ end
+
+ context "when the command is not a noop" do
+ before do
+ subject.action_block = proc { self.run = true }
+ end
+
+ context "when the command is not available" do
+ before do
+ subject.condition_block = proc { false }
+ end
+
+ it "doesn't execute the command" do
+ subject.execute(context, {}, nil)
+
+ expect(context.run).to be false
+ end
+ end
+
+ context "when the command is available" do
+ context "when the commnd has no arguments" do
+ before do
+ subject.action_block = proc { self.run = true }
+ end
+
+ context "when the command is provided an argument" do
+ it "executes the command" do
+ subject.execute(context, {}, true)
+
+ expect(context.run).to be true
+ end
+ end
+
+ context "when the command is not provided an argument" do
+ it "executes the command" do
+ subject.execute(context, {}, nil)
+
+ expect(context.run).to be true
+ end
+ end
+ end
+
+ context "when the command has 1 required argument" do
+ before do
+ subject.action_block = ->(arg) { self.run = arg }
+ end
+
+ context "when the command is provided an argument" do
+ it "executes the command" do
+ subject.execute(context, {}, true)
+
+ expect(context.run).to be true
+ end
+ end
+
+ context "when the command is not provided an argument" do
+ it "doesn't execute the command" do
+ subject.execute(context, {}, nil)
+
+ expect(context.run).to be false
+ end
+ end
+ end
+
+ context "when the command has 1 optional argument" do
+ before do
+ subject.action_block = proc { |arg = nil| self.run = arg || true }
+ end
+
+ context "when the command is provided an argument" do
+ it "executes the command" do
+ subject.execute(context, {}, true)
+
+ expect(context.run).to be true
+ end
+ end
+
+ context "when the command is not provided an argument" do
+ it "executes the command" do
+ subject.execute(context, {}, nil)
+
+ expect(context.run).to be true
+ end
+ end
+ end
+
+ context 'when the command defines parse_params block' do
+ before do
+ subject.parse_params_block = ->(raw) { raw.strip }
+ subject.action_block = ->(parsed) { self.received_arg = parsed }
+ end
+
+ it 'executes the command passing the parsed param' do
+ subject.execute(context, {}, 'something ')
+
+ expect(context.received_arg).to eq('something')
+ end
+ end
+ end
+ end
+ end
+
+ describe '#explain' do
+ context 'when the command is not available' do
+ before do
+ subject.condition_block = proc { false }
+ subject.explanation = 'Explanation'
+ end
+
+ it 'returns nil' do
+ result = subject.explain({}, {}, nil)
+
+ expect(result).to be_nil
+ end
+ end
+
+ context 'when the explanation is a static string' do
+ before do
+ subject.explanation = 'Explanation'
+ end
+
+ it 'returns this static string' do
+ result = subject.explain({}, {}, nil)
+
+ expect(result).to eq 'Explanation'
+ end
+ end
+
+ context 'when the explanation is dynamic' do
+ before do
+ subject.explanation = proc { |arg| "Dynamic #{arg}" }
+ end
+
+ it 'invokes the proc' do
+ result = subject.explain({}, {}, 'explanation')
+
+ expect(result).to eq 'Dynamic explanation'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/quick_actions/dsl_spec.rb b/spec/lib/gitlab/quick_actions/dsl_spec.rb
new file mode 100644
index 00000000000..ff59dc48bcb
--- /dev/null
+++ b/spec/lib/gitlab/quick_actions/dsl_spec.rb
@@ -0,0 +1,123 @@
+require 'spec_helper'
+
+describe Gitlab::QuickActions::Dsl do
+ before :all do
+ DummyClass = Struct.new(:project) do
+ include Gitlab::QuickActions::Dsl # rubocop:disable RSpec/DescribedClass
+
+ desc 'A command with no args'
+ command :no_args, :none do
+ "Hello World!"
+ end
+
+ params 'The first argument'
+ explanation 'Static explanation'
+ command :explanation_with_aliases, :once, :first do |arg|
+ arg
+ end
+
+ desc do
+ "A dynamic description for #{noteable.upcase}"
+ end
+ params 'The first argument', 'The second argument'
+ command :dynamic_description do |args|
+ args.split
+ end
+
+ command :cc
+
+ explanation do |arg|
+ "Action does something with #{arg}"
+ end
+ condition do
+ project == 'foo'
+ end
+ command :cond_action do |arg|
+ arg
+ end
+
+ parse_params do |raw_arg|
+ raw_arg.strip
+ end
+ command :with_params_parsing do |parsed|
+ parsed
+ end
+
+ params '<Comment>'
+ substitution :something do |text|
+ "#{text} Some complicated thing you want in here"
+ end
+ end
+ end
+
+ describe '.command_definitions' do
+ it 'returns an array with commands definitions' do
+ no_args_def, explanation_with_aliases_def, dynamic_description_def,
+ cc_def, cond_action_def, with_params_parsing_def, substitution_def =
+ DummyClass.command_definitions
+
+ expect(no_args_def.name).to eq(:no_args)
+ expect(no_args_def.aliases).to eq([:none])
+ expect(no_args_def.description).to eq('A command with no args')
+ expect(no_args_def.explanation).to eq('')
+ expect(no_args_def.params).to eq([])
+ expect(no_args_def.condition_block).to be_nil
+ expect(no_args_def.action_block).to be_a_kind_of(Proc)
+ expect(no_args_def.parse_params_block).to be_nil
+
+ expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases)
+ expect(explanation_with_aliases_def.aliases).to eq([:once, :first])
+ expect(explanation_with_aliases_def.description).to eq('')
+ expect(explanation_with_aliases_def.explanation).to eq('Static explanation')
+ expect(explanation_with_aliases_def.params).to eq(['The first argument'])
+ expect(explanation_with_aliases_def.condition_block).to be_nil
+ expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc)
+ expect(explanation_with_aliases_def.parse_params_block).to be_nil
+
+ expect(dynamic_description_def.name).to eq(:dynamic_description)
+ expect(dynamic_description_def.aliases).to eq([])
+ expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE')
+ expect(dynamic_description_def.explanation).to eq('')
+ expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument'])
+ expect(dynamic_description_def.condition_block).to be_nil
+ expect(dynamic_description_def.action_block).to be_a_kind_of(Proc)
+ expect(dynamic_description_def.parse_params_block).to be_nil
+
+ expect(cc_def.name).to eq(:cc)
+ expect(cc_def.aliases).to eq([])
+ expect(cc_def.description).to eq('')
+ expect(cc_def.explanation).to eq('')
+ expect(cc_def.params).to eq([])
+ expect(cc_def.condition_block).to be_nil
+ expect(cc_def.action_block).to be_nil
+ expect(cc_def.parse_params_block).to be_nil
+
+ expect(cond_action_def.name).to eq(:cond_action)
+ expect(cond_action_def.aliases).to eq([])
+ expect(cond_action_def.description).to eq('')
+ expect(cond_action_def.explanation).to be_a_kind_of(Proc)
+ expect(cond_action_def.params).to eq([])
+ expect(cond_action_def.condition_block).to be_a_kind_of(Proc)
+ expect(cond_action_def.action_block).to be_a_kind_of(Proc)
+ expect(cond_action_def.parse_params_block).to be_nil
+
+ expect(with_params_parsing_def.name).to eq(:with_params_parsing)
+ expect(with_params_parsing_def.aliases).to eq([])
+ expect(with_params_parsing_def.description).to eq('')
+ expect(with_params_parsing_def.explanation).to eq('')
+ expect(with_params_parsing_def.params).to eq([])
+ expect(with_params_parsing_def.condition_block).to be_nil
+ expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc)
+ expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc)
+
+ expect(substitution_def.name).to eq(:something)
+ expect(substitution_def.aliases).to eq([])
+ expect(substitution_def.description).to eq('')
+ expect(substitution_def.explanation).to eq('')
+ expect(substitution_def.params).to eq(['<Comment>'])
+ expect(substitution_def.condition_block).to be_nil
+ expect(substitution_def.action_block.call('text')).to eq('text Some complicated thing you want in here')
+ expect(substitution_def.parse_params_block).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb
new file mode 100644
index 00000000000..f7c288f2393
--- /dev/null
+++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb
@@ -0,0 +1,260 @@
+require 'spec_helper'
+
+describe Gitlab::QuickActions::Extractor do
+ let(:definitions) do
+ Class.new do
+ include Gitlab::QuickActions::Dsl
+
+ command(:reopen, :open) { }
+ command(:assign) { }
+ command(:labels) { }
+ command(:power) { }
+ command(:noop_command)
+ substitution(:substitution) { 'foo' }
+ substitution :shrug do |comment|
+ "#{comment} SHRUG"
+ end
+ end.command_definitions
+ end
+
+ let(:extractor) { described_class.new(definitions) }
+
+ shared_examples 'command with no argument' do
+ it 'extracts command' do
+ msg, commands = extractor.extract_commands(original_msg)
+
+ expect(commands).to eq [['reopen']]
+ expect(msg).to eq final_msg
+ end
+ end
+
+ shared_examples 'command with a single argument' do
+ it 'extracts command' do
+ msg, commands = extractor.extract_commands(original_msg)
+
+ expect(commands).to eq [['assign', '@joe']]
+ expect(msg).to eq final_msg
+ end
+ end
+
+ shared_examples 'command with multiple arguments' do
+ it 'extracts command' do
+ msg, commands = extractor.extract_commands(original_msg)
+
+ expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
+ expect(msg).to eq final_msg
+ end
+ end
+
+ describe '#extract_commands' do
+ describe 'command with no argument' do
+ context 'at the start of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "/reopen\nworld" }
+ let(:final_msg) { "world" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "hello\n/reopen\nworld" }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = "hello\nworld /reopen"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\nworld /reopen"
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "hello\n/reopen" }
+ let(:final_msg) { "hello" }
+ end
+ end
+ end
+
+ describe 'command with a single argument' do
+ context 'at the start of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "/assign @joe\nworld" }
+ let(:final_msg) { "world" }
+ end
+
+ it 'allows slash in command arguments' do
+ msg = "/assign @joe / @jane\nworld"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['assign', '@joe / @jane']]
+ expect(msg).to eq 'world'
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe\nworld" }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = "hello\nworld /assign @joe"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\nworld /assign @joe"
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe" }
+ let(:final_msg) { "hello" }
+ end
+ end
+
+ context 'when argument is not separated with a space' do
+ it 'does not extract command' do
+ msg = "hello\n/assign@joe\nworld"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\n/assign@joe\nworld"
+ end
+ end
+ end
+
+ describe 'command with multiple arguments' do
+ context 'at the start of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) }
+ let(:final_msg) { "world" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = %(hello\nworld /labels ~foo ~"bar baz" label)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label)
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) }
+ let(:final_msg) { "hello" }
+ end
+ end
+
+ context 'when argument is not separated with a space' do
+ it 'does not extract command' do
+ msg = %(hello\n/labels~foo ~"bar baz" label\nworld)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld)
+ end
+ end
+ end
+
+ it 'extracts command with multiple arguments and various prefixes' do
+ msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
+ expect(msg).to eq "hello\nworld"
+ end
+
+ it 'does not extract noop commands' do
+ msg = %(hello\nworld\n/reopen\n/noop_command)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['reopen']]
+ expect(msg).to eq "hello\nworld\n/noop_command"
+ end
+
+ it 'extracts and performs substitution commands' do
+ msg = %(hello\nworld\n/reopen\n/substitution)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['reopen'], ['substitution']]
+ expect(msg).to eq "hello\nworld\nfoo"
+ end
+
+ it 'extracts and performs substitution commands' do
+ msg = %(hello\nworld\n/reopen\n/shrug this is great?)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['reopen'], ['shrug', 'this is great?']]
+ expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
+ end
+
+ it 'extracts and performs substitution commands with comments' do
+ msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['reopen'], ['substitution', 'wow this is a thing.']]
+ expect(msg).to eq "hello\nworld\nfoo"
+ end
+
+ it 'extracts multiple commands' do
+ msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']]
+ expect(msg).to eq "hello\nworld"
+ end
+
+ it 'does not alter original content if no command is found' do
+ msg = 'Fixes #123'
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq 'Fixes #123'
+ end
+
+ it 'does not extract commands inside a blockcode' do
+ msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld"
+ expected = msg.delete("\r")
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq expected
+ end
+
+ it 'does not extract commands inside a blockquote' do
+ msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld"
+ expected = msg.delete("\r")
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq expected
+ end
+
+ it 'does not extract commands inside a HTML tag' do
+ msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld"
+ expected = msg.delete("\r")
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq expected
+ end
+ end
+end
diff --git a/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb b/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb
new file mode 100644
index 00000000000..1bb8bc51c96
--- /dev/null
+++ b/spec/lib/gitlab/quick_actions/substitution_definition_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+describe Gitlab::QuickActions::SubstitutionDefinition do
+ let(:content) do
+ <<EOF
+Hello! Let's do this!
+/sub_name I like this stuff
+EOF
+ end
+ subject do
+ described_class.new(:sub_name, action_block: proc { |text| "#{text} foo" })
+ end
+
+ describe '#perform_substitution!' do
+ it 'returns nil if content is nil' do
+ expect(subject.perform_substitution(self, nil)).to be_nil
+ end
+
+ it 'performs the substitution by default' do
+ expect(subject.perform_substitution(self, content)).to eq <<EOF
+Hello! Let's do this!
+I like this stuff foo
+EOF
+ end
+ end
+
+ describe '#match' do
+ it 'checks the content for the command' do
+ expect(subject.match(content)).to be_truthy
+ end
+
+ it 'returns the match data' do
+ data = subject.match(content)
+ expect(data).to be_a(MatchData)
+ expect(data[1]).to eq('I like this stuff')
+ end
+
+ it 'is nil if content does not have the command' do
+ expect(subject.match('blah')).to be_falsey
+ end
+ end
+end
diff --git a/spec/lib/gitlab/redis/cache_spec.rb b/spec/lib/gitlab/redis/cache_spec.rb
new file mode 100644
index 00000000000..5a4f17cfcf6
--- /dev/null
+++ b/spec/lib/gitlab/redis/cache_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Gitlab::Redis::Cache do
+ let(:config_file_name) { "config/redis.cache.yml" }
+ let(:environment_config_file_name) { "GITLAB_REDIS_CACHE_CONFIG_FILE" }
+ let(:config_old_format_socket) { "spec/fixtures/config/redis_cache_old_format_socket.yml" }
+ let(:config_new_format_socket) { "spec/fixtures/config/redis_cache_new_format_socket.yml" }
+ let(:old_socket_path) {"/path/to/old/redis.cache.sock" }
+ let(:new_socket_path) {"/path/to/redis.cache.sock" }
+ let(:config_old_format_host) { "spec/fixtures/config/redis_cache_old_format_host.yml" }
+ let(:config_new_format_host) { "spec/fixtures/config/redis_cache_new_format_host.yml" }
+ let(:redis_port) { 6380 }
+ let(:redis_database) { 10 }
+ let(:sentinel_port) { redis_port + 20000 }
+ let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_cache_config_with_env.yml"}
+ let(:config_env_variable_url) {"TEST_GITLAB_REDIS_CACHE_URL"}
+ let(:class_redis_url) { Gitlab::Redis::Cache::DEFAULT_REDIS_CACHE_URL }
+
+ include_examples "redis_shared_examples"
+end
diff --git a/spec/lib/gitlab/redis/queues_spec.rb b/spec/lib/gitlab/redis/queues_spec.rb
new file mode 100644
index 00000000000..01ca25635a9
--- /dev/null
+++ b/spec/lib/gitlab/redis/queues_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Gitlab::Redis::Queues do
+ let(:config_file_name) { "config/redis.queues.yml" }
+ let(:environment_config_file_name) { "GITLAB_REDIS_QUEUES_CONFIG_FILE" }
+ let(:config_old_format_socket) { "spec/fixtures/config/redis_queues_old_format_socket.yml" }
+ let(:config_new_format_socket) { "spec/fixtures/config/redis_queues_new_format_socket.yml" }
+ let(:old_socket_path) {"/path/to/old/redis.queues.sock" }
+ let(:new_socket_path) {"/path/to/redis.queues.sock" }
+ let(:config_old_format_host) { "spec/fixtures/config/redis_queues_old_format_host.yml" }
+ let(:config_new_format_host) { "spec/fixtures/config/redis_queues_new_format_host.yml" }
+ let(:redis_port) { 6381 }
+ let(:redis_database) { 11 }
+ let(:sentinel_port) { redis_port + 20000 }
+ let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_queues_config_with_env.yml"}
+ let(:config_env_variable_url) {"TEST_GITLAB_REDIS_QUEUES_URL"}
+ let(:class_redis_url) { Gitlab::Redis::Queues::DEFAULT_REDIS_QUEUES_URL }
+
+ include_examples "redis_shared_examples"
+end
diff --git a/spec/lib/gitlab/redis/shared_state_spec.rb b/spec/lib/gitlab/redis/shared_state_spec.rb
new file mode 100644
index 00000000000..24b73745dc5
--- /dev/null
+++ b/spec/lib/gitlab/redis/shared_state_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Gitlab::Redis::SharedState do
+ let(:config_file_name) { "config/redis.shared_state.yml" }
+ let(:environment_config_file_name) { "GITLAB_REDIS_SHARED_STATE_CONFIG_FILE" }
+ let(:config_old_format_socket) { "spec/fixtures/config/redis_shared_state_old_format_socket.yml" }
+ let(:config_new_format_socket) { "spec/fixtures/config/redis_shared_state_new_format_socket.yml" }
+ let(:old_socket_path) {"/path/to/old/redis.shared_state.sock" }
+ let(:new_socket_path) {"/path/to/redis.shared_state.sock" }
+ let(:config_old_format_host) { "spec/fixtures/config/redis_shared_state_old_format_host.yml" }
+ let(:config_new_format_host) { "spec/fixtures/config/redis_shared_state_new_format_host.yml" }
+ let(:redis_port) { 6382 }
+ let(:redis_database) { 12 }
+ let(:sentinel_port) { redis_port + 20000 }
+ let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_shared_state_config_with_env.yml"}
+ let(:config_env_variable_url) {"TEST_GITLAB_REDIS_SHARED_STATE_URL"}
+ let(:class_redis_url) { Gitlab::Redis::SharedState::DEFAULT_REDIS_SHARED_STATE_URL }
+
+ include_examples "redis_shared_examples"
+end
diff --git a/spec/lib/gitlab/redis/wrapper_spec.rb b/spec/lib/gitlab/redis/wrapper_spec.rb
new file mode 100644
index 00000000000..0c22a0d62cc
--- /dev/null
+++ b/spec/lib/gitlab/redis/wrapper_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe Gitlab::Redis::Wrapper do
+ let(:config_file_name) { "config/resque.yml" }
+ let(:environment_config_file_name) { "GITLAB_REDIS_CONFIG_FILE" }
+ let(:config_old_format_socket) { "spec/fixtures/config/redis_old_format_socket.yml" }
+ let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" }
+ let(:old_socket_path) {"/path/to/old/redis.sock" }
+ let(:new_socket_path) {"/path/to/redis.sock" }
+ let(:config_old_format_host) { "spec/fixtures/config/redis_old_format_host.yml" }
+ let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" }
+ let(:redis_port) { 6379 }
+ let(:redis_database) { 99 }
+ let(:sentinel_port) { redis_port + 20000 }
+ let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_config_with_env.yml"}
+ let(:config_env_variable_url) {"TEST_GITLAB_REDIS_URL"}
+ let(:class_redis_url) { Gitlab::Redis::Wrapper::DEFAULT_REDIS_URL }
+
+ include_examples "redis_shared_examples"
+
+ describe '.config_file_path' do
+ it 'returns the absolute path to the configuration file' do
+ expect(described_class.config_file_path('foo.yml'))
+ .to eq Rails.root.join('config', 'foo.yml').to_s
+ end
+ end
+end
diff --git a/spec/lib/gitlab/redis_spec.rb b/spec/lib/gitlab/redis_spec.rb
deleted file mode 100644
index 8b77c925705..00000000000
--- a/spec/lib/gitlab/redis_spec.rb
+++ /dev/null
@@ -1,211 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Redis do
- include StubENV
-
- let(:config) { 'config/resque.yml' }
-
- before(:each) do
- stub_env('GITLAB_REDIS_CONFIG_FILE', Rails.root.join(config).to_s)
- clear_raw_config
- end
-
- after(:each) do
- clear_raw_config
- end
-
- describe '.params' do
- subject { described_class.params }
-
- it 'withstands mutation' do
- params1 = described_class.params
- params2 = described_class.params
- params1[:foo] = :bar
-
- expect(params2).not_to have_key(:foo)
- end
-
- context 'when url contains unix socket reference' do
- let(:config_old) { 'spec/fixtures/config/redis_old_format_socket.yml' }
- let(:config_new) { 'spec/fixtures/config/redis_new_format_socket.yml' }
-
- context 'with old format' do
- let(:config) { config_old }
-
- it 'returns path key instead' do
- is_expected.to include(path: '/path/to/old/redis.sock')
- is_expected.not_to have_key(:url)
- end
- end
-
- context 'with new format' do
- let(:config) { config_new }
-
- it 'returns path key instead' do
- is_expected.to include(path: '/path/to/redis.sock')
- is_expected.not_to have_key(:url)
- end
- end
- end
-
- context 'when url is host based' do
- let(:config_old) { 'spec/fixtures/config/redis_old_format_host.yml' }
- let(:config_new) { 'spec/fixtures/config/redis_new_format_host.yml' }
-
- context 'with old format' do
- let(:config) { config_old }
-
- it 'returns hash with host, port, db, and password' do
- is_expected.to include(host: 'localhost', password: 'mypassword', port: 6379, db: 99)
- is_expected.not_to have_key(:url)
- end
- end
-
- context 'with new format' do
- let(:config) { config_new }
-
- it 'returns hash with host, port, db, and password' do
- is_expected.to include(host: 'localhost', password: 'mynewpassword', port: 6379, db: 99)
- is_expected.not_to have_key(:url)
- end
- end
- end
- end
-
- describe '.url' do
- it 'withstands mutation' do
- url1 = described_class.url
- url2 = described_class.url
- url1 << 'foobar'
-
- expect(url2).not_to end_with('foobar')
- end
-
- context 'when yml file with env variable' do
- let(:config) { 'spec/fixtures/config/redis_config_with_env.yml' }
-
- before do
- stub_env('TEST_GITLAB_REDIS_URL', 'redis://redishost:6379')
- end
-
- it 'reads redis url from env variable' do
- expect(described_class.url).to eq 'redis://redishost:6379'
- end
- end
- end
-
- describe '._raw_config' do
- subject { described_class._raw_config }
- let(:config) { '/var/empty/doesnotexist' }
-
- it 'should be frozen' do
- expect(subject).to be_frozen
- end
-
- it 'returns false when the file does not exist' do
- expect(subject).to eq(false)
- end
- end
-
- describe '.with' do
- before { clear_pool }
- after { clear_pool }
-
- context 'when running not on sidekiq workers' do
- before { allow(Sidekiq).to receive(:server?).and_return(false) }
-
- it 'instantiates a connection pool with size 5' do
- expect(ConnectionPool).to receive(:new).with(size: 5).and_call_original
-
- described_class.with { |_redis| true }
- end
- end
-
- context 'when running on sidekiq workers' do
- before do
- allow(Sidekiq).to receive(:server?).and_return(true)
- allow(Sidekiq).to receive(:options).and_return({ concurrency: 18 })
- end
-
- it 'instantiates a connection pool with a size based on the concurrency of the worker' do
- expect(ConnectionPool).to receive(:new).with(size: 18 + 5).and_call_original
-
- described_class.with { |_redis| true }
- end
- end
- end
-
- describe '#sentinels' do
- subject { described_class.new(Rails.env).sentinels }
-
- context 'when sentinels are defined' do
- let(:config) { 'spec/fixtures/config/redis_new_format_host.yml' }
-
- it 'returns an array of hashes with host and port keys' do
- is_expected.to include(host: 'localhost', port: 26380)
- is_expected.to include(host: 'slave2', port: 26381)
- end
- end
-
- context 'when sentinels are not defined' do
- let(:config) { 'spec/fixtures/config/redis_old_format_host.yml' }
-
- it 'returns nil' do
- is_expected.to be_nil
- end
- end
- end
-
- describe '#sentinels?' do
- subject { described_class.new(Rails.env).sentinels? }
-
- context 'when sentinels are defined' do
- let(:config) { 'spec/fixtures/config/redis_new_format_host.yml' }
-
- it 'returns true' do
- is_expected.to be_truthy
- end
- end
-
- context 'when sentinels are not defined' do
- let(:config) { 'spec/fixtures/config/redis_old_format_host.yml' }
-
- it 'returns false' do
- is_expected.to be_falsey
- end
- end
- end
-
- describe '#raw_config_hash' do
- it 'returns default redis url when no config file is present' do
- expect(subject).to receive(:fetch_config) { false }
-
- expect(subject.send(:raw_config_hash)).to eq(url: Gitlab::Redis::DEFAULT_REDIS_URL)
- end
-
- it 'returns old-style single url config in a hash' do
- expect(subject).to receive(:fetch_config) { 'redis://myredis:6379' }
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://myredis:6379')
- end
- end
-
- describe '#fetch_config' do
- it 'returns false when no config file is present' do
- allow(described_class).to receive(:_raw_config) { false }
-
- expect(subject.send(:fetch_config)).to be_falsey
- end
- end
-
- def clear_raw_config
- described_class.remove_instance_variable(:@_raw_config)
- rescue NameError
- # raised if @_raw_config was not set; ignore
- end
-
- def clear_pool
- described_class.remove_instance_variable(:@pool)
- rescue NameError
- # raised if @pool was not set; ignore
- end
-end
diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb
index 84cfd934fa0..476a3f1998d 100644
--- a/spec/lib/gitlab/reference_extractor_spec.rb
+++ b/spec/lib/gitlab/reference_extractor_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Gitlab::ReferenceExtractor, lib: true do
- let(:project) { create(:empty_project) }
+describe Gitlab::ReferenceExtractor do
+ let(:project) { create(:project) }
before do
project.team << [project.creator, :developer]
end
- subject { Gitlab::ReferenceExtractor.new(project, project.creator) }
+ subject { described_class.new(project, project.creator) }
it 'accesses valid user objects' do
@u_foo = create(:user, username: 'foo')
@@ -183,16 +183,39 @@ describe Gitlab::ReferenceExtractor, lib: true do
context 'with an external issue tracker' do
let(:project) { create(:jira_project) }
+ let(:issue) { create(:issue, project: project) }
+
+ context 'when GitLab issues are enabled' do
+ it 'returns both JIRA and internal issues' do
+ subject.analyze("JIRA-123 and FOOBAR-4567 and #{issue.to_reference}")
+ expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project),
+ ExternalIssue.new('FOOBAR-4567', project),
+ issue]
+ end
+
+ it 'returns only JIRA issues if the internal one does not exists' do
+ subject.analyze("JIRA-123 and FOOBAR-4567 and #999")
+ expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project),
+ ExternalIssue.new('FOOBAR-4567', project)]
+ end
+ end
- it 'returns JIRA issues for a JIRA-integrated project' do
- subject.analyze('JIRA-123 and FOOBAR-4567')
- expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project),
- ExternalIssue.new('FOOBAR-4567', project)]
+ context 'when GitLab issues are disabled' do
+ before do
+ project.issues_enabled = false
+ project.save!
+ end
+
+ it 'returns only JIRA issues' do
+ subject.analyze("JIRA-123 and FOOBAR-4567 and #{issue.to_reference}")
+ expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project),
+ ExternalIssue.new('FOOBAR-4567', project)]
+ end
end
end
context 'with a project with an underscore' do
- let(:other_project) { create(:empty_project, path: 'test_project') }
+ let(:other_project) { create(:project, path: 'test_project') }
let(:issue) { create(:issue, project: other_project) }
before do
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 127cd8c78d8..68a57826647 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -1,19 +1,7 @@
# coding: utf-8
require 'spec_helper'
-describe Gitlab::Regex, lib: true do
- describe '.project_path_regex' do
- subject { described_class.project_path_regex }
-
- it { is_expected.to match('gitlab-ce') }
- it { is_expected.to match('gitlab_git') }
- it { is_expected.to match('_underscore.js') }
- it { is_expected.to match('100px.com') }
- it { is_expected.not_to match('?gitlab') }
- it { is_expected.not_to match('git lab') }
- it { is_expected.not_to match('gitlab.git') }
- end
-
+describe Gitlab::Regex do
describe '.project_name_regex' do
subject { described_class.project_name_regex }
@@ -26,10 +14,16 @@ describe Gitlab::Regex, lib: true do
it { is_expected.not_to match('?gitlab') }
end
- describe '.file_name_regex' do
- subject { described_class.file_name_regex }
+ describe '.environment_slug_regex' do
+ subject { described_class.environment_name_regex }
- it { is_expected.to match('foo@bar') }
+ it { is_expected.to match('foo') }
+ it { is_expected.to match('foo-1') }
+ it { is_expected.to match('FOO') }
+ it { is_expected.to match('foo/1') }
+ it { is_expected.to match('foo.1') }
+ it { is_expected.not_to match('9&foo') }
+ it { is_expected.not_to match('foo-^') }
end
describe '.environment_slug_regex' do
@@ -45,15 +39,14 @@ describe Gitlab::Regex, lib: true do
it { is_expected.not_to match('foo-') }
end
- describe 'FULL_NAMESPACE_REGEX_STR' do
- subject { %r{\A#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}\z} }
+ describe '.container_repository_name_regex' do
+ subject { described_class.container_repository_name_regex }
- it { is_expected.to match('gitlab.org') }
- it { is_expected.to match('gitlab.org/gitlab-git') }
- it { is_expected.not_to match('gitlab.org.') }
- it { is_expected.not_to match('gitlab.org/') }
- it { is_expected.not_to match('/gitlab.org') }
- it { is_expected.not_to match('gitlab.git') }
- it { is_expected.not_to match('gitlab git') }
+ it { is_expected.to match('image') }
+ it { is_expected.to match('my/image') }
+ it { is_expected.to match('my/awesome/image-1') }
+ it { is_expected.to match('my/awesome/image.test') }
+ it { is_expected.not_to match('.my/image') }
+ it { is_expected.not_to match('my/image.') }
end
end
diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb
index 0fb5d7646f2..1a925a15e0c 100644
--- a/spec/lib/gitlab/repo_path_spec.rb
+++ b/spec/lib/gitlab/repo_path_spec.rb
@@ -1,11 +1,55 @@
require 'spec_helper'
describe ::Gitlab::RepoPath do
+ describe '.parse' do
+ set(:project) { create(:project, :repository) }
+
+ context 'a repository storage path' do
+ it 'parses a full repository path' do
+ expect(described_class.parse(project.repository.path)).to eq([project, false, nil])
+ end
+
+ it 'parses a full wiki path' do
+ expect(described_class.parse(project.wiki.repository.path)).to eq([project, true, nil])
+ end
+ end
+
+ context 'a relative path' do
+ it 'parses a relative repository path' do
+ expect(described_class.parse(project.full_path + '.git')).to eq([project, false, nil])
+ end
+
+ it 'parses a relative wiki path' do
+ expect(described_class.parse(project.full_path + '.wiki.git')).to eq([project, true, nil])
+ end
+
+ it 'parses a relative path starting with /' do
+ expect(described_class.parse('/' + project.full_path + '.git')).to eq([project, false, nil])
+ end
+
+ context 'of a redirected project' do
+ let(:redirect) { project.route.create_redirect('foo/bar') }
+
+ it 'parses a relative repository path' do
+ expect(described_class.parse(redirect.path + '.git')).to eq([project, false, 'foo/bar'])
+ end
+
+ it 'parses a relative wiki path' do
+ expect(described_class.parse(redirect.path + '.wiki.git')).to eq([project, true, 'foo/bar.wiki'])
+ end
+
+ it 'parses a relative path starting with /' do
+ expect(described_class.parse('/' + redirect.path + '.git')).to eq([project, false, 'foo/bar'])
+ end
+ end
+ end
+ end
+
describe '.strip_storage_path' do
before do
allow(Gitlab.config.repositories).to receive(:storages).and_return({
'storage1' => { 'path' => '/foo' },
- 'storage2' => { 'path' => '/bar' },
+ 'storage2' => { 'path' => '/bar' }
})
end
@@ -19,4 +63,33 @@ describe ::Gitlab::RepoPath do
)
end
end
+
+ describe '.find_project' do
+ let(:project) { create(:project) }
+ let(:redirect) { project.route.create_redirect('foo/bar/baz') }
+
+ context 'when finding a project by its canonical path' do
+ context 'when the cases match' do
+ it 'returns the project and false' do
+ expect(described_class.find_project(project.full_path)).to eq([project, false])
+ end
+ end
+
+ context 'when the cases do not match' do
+ # This is slightly different than web behavior because on the web it is
+ # easy and safe to redirect someone to the correctly-cased URL. For git
+ # requests, we should accept wrongly-cased URLs because it is a pain to
+ # block people's git operations and force them to update remote URLs.
+ it 'returns the project and false' do
+ expect(described_class.find_project(project.full_path.upcase)).to eq([project, false])
+ end
+ end
+ end
+
+ context 'when finding a project via a redirect' do
+ it 'returns the project and true' do
+ expect(described_class.find_project(redirect.path)).to eq([project, true])
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/request_context_spec.rb b/spec/lib/gitlab/request_context_spec.rb
index a91c8655cdd..8a28ad0e597 100644
--- a/spec/lib/gitlab/request_context_spec.rb
+++ b/spec/lib/gitlab/request_context_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Gitlab::RequestContext, lib: true do
+describe Gitlab::RequestContext do
describe '#client_ip' do
- subject { Gitlab::RequestContext.client_ip }
+ subject { described_class.client_ip }
let(:app) { -> (env) {} }
let(:env) { Hash.new }
context 'when RequestStore::Middleware is used' do
- around(:each) do |example|
+ around do |example|
RequestStore::Middleware.new(-> (env) { example.run }).call({})
end
@@ -16,7 +16,7 @@ describe Gitlab::RequestContext, lib: true do
before do
allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip)
- Gitlab::RequestContext.new(app).call(env)
+ described_class.new(app).call(env)
end
it { is_expected.to eq(ip) }
diff --git a/spec/lib/gitlab/request_forgery_protection_spec.rb b/spec/lib/gitlab/request_forgery_protection_spec.rb
new file mode 100644
index 00000000000..305de613866
--- /dev/null
+++ b/spec/lib/gitlab/request_forgery_protection_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe Gitlab::RequestForgeryProtection, :allow_forgery_protection do
+ let(:csrf_token) { SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH) }
+ let(:env) do
+ {
+ 'rack.input' => '',
+ 'rack.session' => {
+ _csrf_token: csrf_token
+ }
+ }
+ end
+
+ describe '.call' do
+ context 'when the request method is GET' do
+ before do
+ env['REQUEST_METHOD'] = 'GET'
+ end
+
+ it 'does not raise an exception' do
+ expect { described_class.call(env) }.not_to raise_exception
+ end
+ end
+
+ context 'when the request method is POST' do
+ before do
+ env['REQUEST_METHOD'] = 'POST'
+ end
+
+ context 'when the CSRF token is valid' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = csrf_token
+ end
+
+ it 'does not raise an exception' do
+ expect { described_class.call(env) }.not_to raise_exception
+ end
+ end
+
+ context 'when the CSRF token is invalid' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = 'foo'
+ end
+
+ it 'raises an ActionController::InvalidAuthenticityToken exception' do
+ expect { described_class.call(env) }.to raise_exception(ActionController::InvalidAuthenticityToken)
+ end
+ end
+ end
+ end
+
+ describe '.verified?' do
+ context 'when the request method is GET' do
+ before do
+ env['REQUEST_METHOD'] = 'GET'
+ end
+
+ it 'returns true' do
+ expect(described_class.verified?(env)).to be_truthy
+ end
+ end
+
+ context 'when the request method is POST' do
+ before do
+ env['REQUEST_METHOD'] = 'POST'
+ end
+
+ context 'when the CSRF token is valid' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = csrf_token
+ end
+
+ it 'returns true' do
+ expect(described_class.verified?(env)).to be_truthy
+ end
+ end
+
+ context 'when the CSRF token is invalid' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = 'foo'
+ end
+
+ it 'returns false' do
+ expect(described_class.verified?(env)).to be_falsey
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/request_profiler_spec.rb b/spec/lib/gitlab/request_profiler_spec.rb
new file mode 100644
index 00000000000..fd8cbf39bce
--- /dev/null
+++ b/spec/lib/gitlab/request_profiler_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe Gitlab::RequestProfiler do
+ describe '.profile_token' do
+ it 'returns a token' do
+ expect(described_class.profile_token).to be_present
+ end
+
+ it 'caches the token' do
+ expect(Rails.cache).to receive(:fetch).with('profile-token')
+
+ described_class.profile_token
+ end
+ end
+
+ describe '.remove_all_profiles' do
+ it 'removes Gitlab::RequestProfiler::PROFILES_DIR directory' do
+ dir = described_class::PROFILES_DIR
+ FileUtils.mkdir_p(dir)
+
+ expect(Dir.exist?(dir)).to be true
+
+ described_class.remove_all_profiles
+ expect(Dir.exist?(dir)).to be false
+ end
+ end
+end
diff --git a/spec/lib/gitlab/route_map_spec.rb b/spec/lib/gitlab/route_map_spec.rb
index 2370f56a613..d672f7b5675 100644
--- a/spec/lib/gitlab/route_map_spec.rb
+++ b/spec/lib/gitlab/route_map_spec.rb
@@ -1,46 +1,46 @@
require 'spec_helper'
-describe Gitlab::RouteMap, lib: true do
+describe Gitlab::RouteMap do
describe '#initialize' do
context 'when the data is not YAML' do
it 'raises an error' do
- expect { described_class.new('"') }.
- to raise_error(Gitlab::RouteMap::FormatError, /valid YAML/)
+ expect { described_class.new('"') }
+ .to raise_error(Gitlab::RouteMap::FormatError, /valid YAML/)
end
end
context 'when the data is not a YAML array' do
it 'raises an error' do
- expect { described_class.new(YAML.dump('foo')) }.
- to raise_error(Gitlab::RouteMap::FormatError, /an array/)
+ expect { described_class.new(YAML.dump('foo')) }
+ .to raise_error(Gitlab::RouteMap::FormatError, /an array/)
end
end
context 'when an entry is not a hash' do
it 'raises an error' do
- expect { described_class.new(YAML.dump(['foo'])) }.
- to raise_error(Gitlab::RouteMap::FormatError, /a hash/)
+ expect { described_class.new(YAML.dump(['foo'])) }
+ .to raise_error(Gitlab::RouteMap::FormatError, /a hash/)
end
end
context 'when an entry does not have a source key' do
it 'raises an error' do
- expect { described_class.new(YAML.dump([{ 'public' => 'index.html' }])) }.
- to raise_error(Gitlab::RouteMap::FormatError, /source key/)
+ expect { described_class.new(YAML.dump([{ 'public' => 'index.html' }])) }
+ .to raise_error(Gitlab::RouteMap::FormatError, /source key/)
end
end
context 'when an entry does not have a public key' do
it 'raises an error' do
- expect { described_class.new(YAML.dump([{ 'source' => '/index\.html/' }])) }.
- to raise_error(Gitlab::RouteMap::FormatError, /public key/)
+ expect { described_class.new(YAML.dump([{ 'source' => '/index\.html/' }])) }
+ .to raise_error(Gitlab::RouteMap::FormatError, /public key/)
end
end
context 'when an entry source is not a valid regex' do
it 'raises an error' do
- expect { described_class.new(YAML.dump([{ 'source' => '/[/', 'public' => 'index.html' }])) }.
- to raise_error(Gitlab::RouteMap::FormatError, /regular expression/)
+ expect { described_class.new(YAML.dump([{ 'source' => '/[/', 'public' => 'index.html' }])) }
+ .to raise_error(Gitlab::RouteMap::FormatError, /regular expression/)
end
end
@@ -55,6 +55,19 @@ describe Gitlab::RouteMap, lib: true do
end
describe '#public_path_for_source_path' do
+ context 'malicious regexp' do
+ include_examples 'malicious regexp'
+
+ subject do
+ map = described_class.new(<<-"MAP".strip_heredoc)
+ - source: '#{malicious_regexp}'
+ public: '/'
+ MAP
+
+ map.public_path_for_source_path(malicious_text)
+ end
+ end
+
subject do
described_class.new(<<-'MAP'.strip_heredoc)
# Team data
diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb
index b106d156b75..19710029224 100644
--- a/spec/lib/gitlab/saml/user_spec.rb
+++ b/spec/lib/gitlab/saml/user_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Saml::User, lib: true do
+describe Gitlab::Saml::User do
let(:saml_user) { described_class.new(auth_hash) }
let(:gl_user) { saml_user.gl_user }
let(:uid) { 'my-uid' }
@@ -31,11 +31,17 @@ describe Gitlab::Saml::User, lib: true do
allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } })
end
- before { stub_basic_saml_config }
+ before do
+ stub_basic_saml_config
+ end
describe 'account exists on server' do
- before { stub_omniauth_config({ allow_single_sign_on: ['saml'], auto_link_saml_user: true }) }
+ before do
+ stub_omniauth_config({ allow_single_sign_on: ['saml'], auto_link_saml_user: true })
+ end
+
let!(:existing_user) { create(:user, email: 'john@mail.com', username: 'john') }
+
context 'and should bind with SAML' do
it 'adds the SAML identity to the existing user' do
saml_user.save
@@ -57,7 +63,10 @@ describe Gitlab::Saml::User, lib: true do
end
end
- before { stub_saml_group_config(%w(Interns)) }
+ before do
+ stub_saml_group_config(%w(Interns))
+ end
+
context 'are defined but the user does not belong there' do
it 'does not mark the user as external' do
saml_user.save
@@ -80,7 +89,9 @@ describe Gitlab::Saml::User, lib: true do
describe 'no account exists on server' do
shared_examples 'to verify compliance with allow_single_sign_on' do
context 'with allow_single_sign_on enabled' do
- before { stub_omniauth_config(allow_single_sign_on: ['saml']) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: ['saml'])
+ end
it 'creates a user from SAML' do
saml_user.save
@@ -93,16 +104,22 @@ describe Gitlab::Saml::User, lib: true do
end
context 'with allow_single_sign_on default (["saml"])' do
- before { stub_omniauth_config(allow_single_sign_on: ['saml']) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: ['saml'])
+ end
+
it 'does not throw an error' do
- expect{ saml_user.save }.not_to raise_error
+ expect { saml_user.save }.not_to raise_error
end
end
context 'with allow_single_sign_on disabled' do
- before { stub_omniauth_config(allow_single_sign_on: false) }
+ before do
+ stub_omniauth_config(allow_single_sign_on: false)
+ end
+
it 'throws an error' do
- expect{ saml_user.save }.to raise_error StandardError
+ expect { saml_user.save }.to raise_error StandardError
end
end
end
@@ -128,15 +145,22 @@ describe Gitlab::Saml::User, lib: true do
end
context 'with auto_link_ldap_user disabled (default)' do
- before { stub_omniauth_config({ auto_link_ldap_user: false, auto_link_saml_user: false, allow_single_sign_on: ['saml'] }) }
+ before do
+ stub_omniauth_config({ auto_link_ldap_user: false, auto_link_saml_user: false, allow_single_sign_on: ['saml'] })
+ end
+
include_examples 'to verify compliance with allow_single_sign_on'
end
context 'with auto_link_ldap_user enabled' do
- before { stub_omniauth_config({ auto_link_ldap_user: true, auto_link_saml_user: false }) }
+ before do
+ stub_omniauth_config({ auto_link_ldap_user: true, auto_link_saml_user: false })
+ end
context 'and at least one LDAP provider is defined' do
- before { stub_ldap_config(providers: %w(ldapmain)) }
+ before do
+ stub_ldap_config(providers: %w(ldapmain))
+ end
context 'and a corresponding LDAP person' do
before do
@@ -239,11 +263,15 @@ describe Gitlab::Saml::User, lib: true do
end
describe 'blocking' do
- before { stub_omniauth_config({ allow_single_sign_on: ['saml'], auto_link_saml_user: true }) }
+ before do
+ stub_omniauth_config({ allow_single_sign_on: ['saml'], auto_link_saml_user: true })
+ end
context 'signup with SAML only' do
context 'dont block on create' do
- before { stub_omniauth_config(block_auto_created_users: false) }
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
it 'does not block the user' do
saml_user.save
@@ -253,7 +281,9 @@ describe Gitlab::Saml::User, lib: true do
end
context 'block on create' do
- before { stub_omniauth_config(block_auto_created_users: true) }
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
it 'blocks user' do
saml_user.save
@@ -270,7 +300,9 @@ describe Gitlab::Saml::User, lib: true do
end
context 'dont block on create' do
- before { stub_omniauth_config(block_auto_created_users: false) }
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
it do
saml_user.save
@@ -280,7 +312,9 @@ describe Gitlab::Saml::User, lib: true do
end
context 'block on create' do
- before { stub_omniauth_config(block_auto_created_users: true) }
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
it do
saml_user.save
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index 847fb977400..4c5efbde69a 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::SearchResults do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, name: 'foo') }
+ let!(:project) { create(:project, name: 'foo') }
let!(:issue) { create(:issue, project: project, title: 'foo') }
let!(:merge_request) do
@@ -42,7 +42,7 @@ describe Gitlab::SearchResults do
end
it 'includes merge requests from source and target projects' do
- forked_project = create(:empty_project, forked_from_project: project)
+ forked_project = create(:project, forked_from_project: project)
merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo')
results = described_class.new(user, Project.where(id: forked_project.id), 'foo')
@@ -52,17 +52,17 @@ describe Gitlab::SearchResults do
end
it 'does not list issues on private projects' do
- private_project = create(:empty_project, :private)
+ private_project = create(:project, :private)
issue = create(:issue, project: private_project, title: 'foo')
expect(results.objects('issues')).not_to include issue
end
describe 'confidential issues' do
- let(:project_1) { create(:empty_project, :internal) }
- let(:project_2) { create(:empty_project, :internal) }
- let(:project_3) { create(:empty_project, :internal) }
- let(:project_4) { create(:empty_project, :internal) }
+ let(:project_1) { create(:project, :internal) }
+ let(:project_2) { create(:project, :internal) }
+ let(:project_3) { create(:project, :internal) }
+ let(:project_4) { create(:project, :internal) }
let(:query) { 'issue' }
let(:limit_projects) { Project.where(id: [project_1.id, project_2.id, project_3.id]) }
let(:author) { create(:user) }
@@ -72,9 +72,9 @@ describe Gitlab::SearchResults do
let(:admin) { create(:admin) }
let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') }
let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) }
- let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) }
+ let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee]) }
let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) }
- let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) }
+ let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee]) }
let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') }
it 'does not list confidential issues for non project members' do
diff --git a/spec/lib/gitlab/serializer/pagination_spec.rb b/spec/lib/gitlab/serializer/pagination_spec.rb
index 519eb1b274f..1bc6536439e 100644
--- a/spec/lib/gitlab/serializer/pagination_spec.rb
+++ b/spec/lib/gitlab/serializer/pagination_spec.rb
@@ -22,7 +22,9 @@ describe Gitlab::Serializer::Pagination do
let(:params) { { page: 1, per_page: 2 } }
context 'when a multiple resources are present in relation' do
- before { create_list(:user, 3) }
+ before do
+ create_list(:user, 3)
+ end
it 'correctly paginates the resource' do
expect(subject.count).to be 2
diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb
new file mode 100644
index 00000000000..cfadee0bcf5
--- /dev/null
+++ b/spec/lib/gitlab/shell_spec.rb
@@ -0,0 +1,299 @@
+require 'spec_helper'
+require 'stringio'
+
+describe Gitlab::Shell do
+ let(:project) { double('Project', id: 7, path: 'diaspora') }
+ let(:gitlab_shell) { described_class.new }
+ let(:popen_vars) { { 'GIT_TERMINAL_PROMPT' => ENV['GIT_TERMINAL_PROMPT'] } }
+
+ before do
+ allow(Project).to receive(:find).and_return(project)
+ end
+
+ it { is_expected.to respond_to :add_key }
+ it { is_expected.to respond_to :remove_key }
+ it { is_expected.to respond_to :add_repository }
+ it { is_expected.to respond_to :remove_repository }
+ it { is_expected.to respond_to :fork_repository }
+ it { is_expected.to respond_to :add_namespace }
+ it { is_expected.to respond_to :rm_namespace }
+ it { is_expected.to respond_to :mv_namespace }
+ it { is_expected.to respond_to :exists? }
+
+ it { expect(gitlab_shell.url_to_repo('diaspora')).to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "diaspora.git") }
+
+ describe 'memoized secret_token' do
+ let(:secret_file) { 'tmp/tests/.secret_shell_test' }
+ let(:link_file) { 'tmp/tests/shell-secret-test/.gitlab_shell_secret' }
+
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:secret_file).and_return(secret_file)
+ allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-secret-test')
+ FileUtils.mkdir('tmp/tests/shell-secret-test')
+ described_class.ensure_secret_token!
+ end
+
+ after do
+ FileUtils.rm_rf('tmp/tests/shell-secret-test')
+ FileUtils.rm_rf(secret_file)
+ end
+
+ it 'creates and links the secret token file' do
+ secret_token = described_class.secret_token
+
+ expect(File.exist?(secret_file)).to be(true)
+ expect(File.read(secret_file).chomp).to eq(secret_token)
+ expect(File.symlink?(link_file)).to be(true)
+ expect(File.readlink(link_file)).to eq(secret_file)
+ end
+ end
+
+ describe '#add_key' do
+ it 'removes trailing garbage' do
+ allow(gitlab_shell).to receive(:gitlab_shell_keys_path).and_return(:gitlab_shell_keys_path)
+ expect(gitlab_shell).to receive(:gitlab_shell_fast_execute).with(
+ [:gitlab_shell_keys_path, 'add-key', 'key-123', 'ssh-rsa foobar']
+ )
+
+ gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage')
+ end
+ end
+
+ describe Gitlab::Shell::KeyAdder do
+ describe '#add_key' do
+ it 'removes trailing garbage' do
+ io = spy(:io)
+ adder = described_class.new(io)
+
+ adder.add_key('key-42', "ssh-rsa foo bar\tbaz")
+
+ expect(io).to have_received(:puts).with("key-42\tssh-rsa foo")
+ end
+
+ it 'handles multiple spaces in the key' do
+ io = spy(:io)
+ adder = described_class.new(io)
+
+ adder.add_key('key-42', "ssh-rsa foo")
+
+ expect(io).to have_received(:puts).with("key-42\tssh-rsa foo")
+ end
+
+ it 'raises an exception if the key contains a tab' do
+ expect do
+ described_class.new(StringIO.new).add_key('key-42', "ssh-rsa\tfoobar")
+ end.to raise_error(Gitlab::Shell::Error)
+ end
+
+ it 'raises an exception if the key contains a newline' do
+ expect do
+ described_class.new(StringIO.new).add_key('key-42', "ssh-rsa foobar\nssh-rsa pawned")
+ end.to raise_error(Gitlab::Shell::Error)
+ end
+ end
+ end
+
+ describe 'projects commands' do
+ let(:gitlab_shell_path) { File.expand_path('tmp/tests/gitlab-shell') }
+ let(:projects_path) { File.join(gitlab_shell_path, 'bin/gitlab-projects') }
+ let(:gitlab_shell_hooks_path) { File.join(gitlab_shell_path, 'hooks') }
+
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:path).and_return(gitlab_shell_path)
+ allow(Gitlab.config.gitlab_shell).to receive(:hooks_path).and_return(gitlab_shell_hooks_path)
+ allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800)
+ end
+
+ describe '#add_repository' do
+ it 'creates a repository' do
+ created_path = File.join(TestEnv.repos_path, 'project', 'path.git')
+ hooks_path = File.join(created_path, 'hooks')
+
+ begin
+ result = gitlab_shell.add_repository(TestEnv.repos_path, 'project/path')
+
+ repo_stat = File.stat(created_path) rescue nil
+ hooks_stat = File.lstat(hooks_path) rescue nil
+ hooks_dir = File.realpath(hooks_path)
+ ensure
+ FileUtils.rm_rf(created_path)
+ end
+
+ expect(result).to be_truthy
+ expect(repo_stat.mode & 0o777).to eq(0o770)
+ expect(hooks_stat.symlink?).to be_truthy
+ expect(hooks_dir).to eq(gitlab_shell_hooks_path)
+ end
+
+ it 'returns false when the command fails' do
+ expect(FileUtils).to receive(:mkdir_p).and_raise(Errno::EEXIST)
+
+ expect(gitlab_shell.add_repository('current/storage', 'project/path')).to be_falsy
+ end
+ end
+
+ describe '#remove_repository' do
+ it 'returns true when the command succeeds' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'rm-project', 'current/storage', 'project/path.git'],
+ nil, popen_vars).and_return([nil, 0])
+
+ expect(gitlab_shell.remove_repository('current/storage', 'project/path')).to be true
+ end
+
+ it 'returns false when the command fails' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'rm-project', 'current/storage', 'project/path.git'],
+ nil, popen_vars).and_return(["error", 1])
+
+ expect(gitlab_shell.remove_repository('current/storage', 'project/path')).to be false
+ end
+ end
+
+ describe '#mv_repository' do
+ it 'returns true when the command succeeds' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'mv-project', 'current/storage', 'project/path.git', 'project/newpath.git'],
+ nil, popen_vars).and_return([nil, 0])
+
+ expect(gitlab_shell.mv_repository('current/storage', 'project/path', 'project/newpath')).to be true
+ end
+
+ it 'returns false when the command fails' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'mv-project', 'current/storage', 'project/path.git', 'project/newpath.git'],
+ nil, popen_vars).and_return(["error", 1])
+
+ expect(gitlab_shell.mv_repository('current/storage', 'project/path', 'project/newpath')).to be false
+ end
+ end
+
+ describe '#fork_repository' do
+ it 'returns true when the command succeeds' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'fork-project', 'current/storage', 'project/path.git', 'new/storage', 'new-namespace'],
+ nil, popen_vars).and_return([nil, 0])
+
+ expect(gitlab_shell.fork_repository('current/storage', 'project/path', 'new/storage', 'new-namespace')).to be true
+ end
+
+ it 'return false when the command fails' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'fork-project', 'current/storage', 'project/path.git', 'new/storage', 'new-namespace'],
+ nil, popen_vars).and_return(["error", 1])
+
+ expect(gitlab_shell.fork_repository('current/storage', 'project/path', 'new/storage', 'new-namespace')).to be false
+ end
+ end
+
+ describe '#fetch_remote' do
+ def fetch_remote(ssh_auth = nil)
+ gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage', ssh_auth: ssh_auth)
+ end
+
+ def expect_popen(vars = {})
+ popen_args = [
+ projects_path,
+ 'fetch-remote',
+ 'current/storage',
+ 'project/path.git',
+ 'new/storage',
+ Gitlab.config.gitlab_shell.git_timeout.to_s
+ ]
+
+ expect(Gitlab::Popen).to receive(:popen).with(popen_args, nil, popen_vars.merge(vars))
+ end
+
+ def build_ssh_auth(opts = {})
+ defaults = {
+ ssh_import?: true,
+ ssh_key_auth?: false,
+ ssh_known_hosts: nil,
+ ssh_private_key: nil
+ }
+
+ double(:ssh_auth, defaults.merge(opts))
+ end
+
+ it 'returns true when the command succeeds' do
+ expect_popen.and_return([nil, 0])
+
+ expect(fetch_remote).to be_truthy
+ end
+
+ it 'raises an exception when the command fails' do
+ expect_popen.and_return(["error", 1])
+
+ expect { fetch_remote }.to raise_error(Gitlab::Shell::Error, "error")
+ end
+
+ context 'SSH auth' do
+ it 'passes the SSH key if specified' do
+ expect_popen('GITLAB_SHELL_SSH_KEY' => 'foo').and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_key_auth?: true, ssh_private_key: 'foo')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+
+ it 'does not pass an empty SSH key' do
+ expect_popen.and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_key_auth: true, ssh_private_key: '')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+
+ it 'does not pass the key unless SSH key auth is to be used' do
+ expect_popen.and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_key_auth: false, ssh_private_key: 'foo')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+
+ it 'passes the known_hosts data if specified' do
+ expect_popen('GITLAB_SHELL_KNOWN_HOSTS' => 'foo').and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_known_hosts: 'foo')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+
+ it 'does not pass empty known_hosts data' do
+ expect_popen.and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_known_hosts: '')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+
+ it 'does not pass known_hosts data unless SSH is to be used' do
+ expect_popen(popen_vars).and_return([nil, 0])
+
+ ssh_auth = build_ssh_auth(ssh_import?: false, ssh_known_hosts: 'foo')
+
+ expect(fetch_remote(ssh_auth)).to be_truthy
+ end
+ end
+ end
+
+ describe '#import_repository' do
+ it 'returns true when the command succeeds' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"],
+ nil, popen_vars).and_return([nil, 0])
+
+ expect(gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git')).to be true
+ end
+
+ it 'raises an exception when the command fails' do
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"],
+ nil, popen_vars).and_return(["error", 1])
+
+ expect { gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git') }.to raise_error(Gitlab::Shell::Error, "error")
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sherlock/collection_spec.rb b/spec/lib/gitlab/sherlock/collection_spec.rb
index 2ae79b50e77..873ed14f804 100644
--- a/spec/lib/gitlab/sherlock/collection_spec.rb
+++ b/spec/lib/gitlab/sherlock/collection_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::Collection, lib: true do
+describe Gitlab::Sherlock::Collection do
let(:collection) { described_class.new }
let(:transaction) do
diff --git a/spec/lib/gitlab/sherlock/file_sample_spec.rb b/spec/lib/gitlab/sherlock/file_sample_spec.rb
index cadf8bbce78..394421504e0 100644
--- a/spec/lib/gitlab/sherlock/file_sample_spec.rb
+++ b/spec/lib/gitlab/sherlock/file_sample_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::FileSample, lib: true do
+describe Gitlab::Sherlock::FileSample do
let(:sample) { described_class.new(__FILE__, [], 150.4, 2) }
describe '#id' do
@@ -35,8 +35,8 @@ describe Gitlab::Sherlock::FileSample, lib: true do
describe '#relative_path' do
it 'returns the relative path' do
- expect(sample.relative_path).
- to eq('spec/lib/gitlab/sherlock/file_sample_spec.rb')
+ expect(sample.relative_path)
+ .to eq('spec/lib/gitlab/sherlock/file_sample_spec.rb')
end
end
diff --git a/spec/lib/gitlab/sherlock/line_profiler_spec.rb b/spec/lib/gitlab/sherlock/line_profiler_spec.rb
index d57627bba2b..f2f8040fa0b 100644
--- a/spec/lib/gitlab/sherlock/line_profiler_spec.rb
+++ b/spec/lib/gitlab/sherlock/line_profiler_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::LineProfiler, lib: true do
+describe Gitlab::Sherlock::LineProfiler do
let(:profiler) { described_class.new }
describe '#profile' do
@@ -20,9 +20,9 @@ describe Gitlab::Sherlock::LineProfiler, lib: true do
describe '#profile_mri' do
it 'returns an Array containing the return value and profiling samples' do
- allow(profiler).to receive(:lineprof).
- and_yield.
- and_return({ __FILE__ => [[0, 0, 0, 0]] })
+ allow(profiler).to receive(:lineprof)
+ .and_yield
+ .and_return({ __FILE__ => [[0, 0, 0, 0]] })
retval, samples = profiler.profile_mri { 42 }
diff --git a/spec/lib/gitlab/sherlock/line_sample_spec.rb b/spec/lib/gitlab/sherlock/line_sample_spec.rb
index f9b61f8684e..5f02f6a3213 100644
--- a/spec/lib/gitlab/sherlock/line_sample_spec.rb
+++ b/spec/lib/gitlab/sherlock/line_sample_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::LineSample, lib: true do
+describe Gitlab::Sherlock::LineSample do
let(:sample) { described_class.new(150.0, 4) }
describe '#duration' do
diff --git a/spec/lib/gitlab/sherlock/location_spec.rb b/spec/lib/gitlab/sherlock/location_spec.rb
index 5739afa6b1e..b295a624b35 100644
--- a/spec/lib/gitlab/sherlock/location_spec.rb
+++ b/spec/lib/gitlab/sherlock/location_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::Location, lib: true do
+describe Gitlab::Sherlock::Location do
let(:location) { described_class.new(__FILE__, 1) }
describe 'from_ruby_location' do
diff --git a/spec/lib/gitlab/sherlock/middleware_spec.rb b/spec/lib/gitlab/sherlock/middleware_spec.rb
index 2bbeb25ce98..2016023df06 100644
--- a/spec/lib/gitlab/sherlock/middleware_spec.rb
+++ b/spec/lib/gitlab/sherlock/middleware_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::Middleware, lib: true do
+describe Gitlab::Sherlock::Middleware do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
@@ -72,8 +72,8 @@ describe Gitlab::Sherlock::Middleware, lib: true do
'REQUEST_URI' => '/cats'
}
- expect(middleware.transaction_from_env(env)).
- to be_an_instance_of(Gitlab::Sherlock::Transaction)
+ expect(middleware.transaction_from_env(env))
+ .to be_an_instance_of(Gitlab::Sherlock::Transaction)
end
end
end
diff --git a/spec/lib/gitlab/sherlock/query_spec.rb b/spec/lib/gitlab/sherlock/query_spec.rb
index 0a620428138..426071c7f92 100644
--- a/spec/lib/gitlab/sherlock/query_spec.rb
+++ b/spec/lib/gitlab/sherlock/query_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::Query, lib: true do
+describe Gitlab::Sherlock::Query do
let(:started_at) { Time.utc(2015, 1, 1) }
let(:finished_at) { started_at + 5 }
@@ -13,8 +13,8 @@ describe Gitlab::Sherlock::Query, lib: true do
sql = 'SELECT COUNT(*) FROM users WHERE id = $1'
bindings = [[double(:column), 10]]
- query = described_class.
- new_with_bindings(sql, bindings, started_at, finished_at)
+ query = described_class
+ .new_with_bindings(sql, bindings, started_at, finished_at)
expect(query.query).to eq('SELECT COUNT(*) FROM users WHERE id = 10;')
end
diff --git a/spec/lib/gitlab/sherlock/transaction_spec.rb b/spec/lib/gitlab/sherlock/transaction_spec.rb
index 9fe18f253f0..4a14dfbec56 100644
--- a/spec/lib/gitlab/sherlock/transaction_spec.rb
+++ b/spec/lib/gitlab/sherlock/transaction_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Sherlock::Transaction, lib: true do
+describe Gitlab::Sherlock::Transaction do
let(:transaction) { described_class.new('POST', '/cat_pictures') }
describe '#id' do
@@ -109,8 +109,8 @@ describe Gitlab::Sherlock::Transaction, lib: true do
query1 = Gitlab::Sherlock::Query.new('SELECT 1', start_time, start_time)
- query2 = Gitlab::Sherlock::Query.
- new('SELECT 2', start_time, start_time + 5)
+ query2 = Gitlab::Sherlock::Query
+ .new('SELECT 2', start_time, start_time + 5)
transaction.queries << query1
transaction.queries << query2
@@ -162,11 +162,11 @@ describe Gitlab::Sherlock::Transaction, lib: true do
describe '#profile_lines' do
describe 'when line profiling is enabled' do
it 'yields the block using the line profiler' do
- allow(Gitlab::Sherlock).to receive(:enable_line_profiler?).
- and_return(true)
+ allow(Gitlab::Sherlock).to receive(:enable_line_profiler?)
+ .and_return(true)
- allow_any_instance_of(Gitlab::Sherlock::LineProfiler).
- to receive(:profile).and_return('cats are amazing', [])
+ allow_any_instance_of(Gitlab::Sherlock::LineProfiler)
+ .to receive(:profile).and_return('cats are amazing', [])
retval = transaction.profile_lines { 'cats are amazing' }
@@ -176,8 +176,8 @@ describe Gitlab::Sherlock::Transaction, lib: true do
describe 'when line profiling is disabled' do
it 'yields the block' do
- allow(Gitlab::Sherlock).to receive(:enable_line_profiler?).
- and_return(false)
+ allow(Gitlab::Sherlock).to receive(:enable_line_profiler?)
+ .and_return(false)
retval = transaction.profile_lines { 'cats are amazing' }
@@ -196,8 +196,8 @@ describe Gitlab::Sherlock::Transaction, lib: true do
end
it 'tracks executed queries' do
- expect(transaction).to receive(:track_query).
- with('SELECT 1', [], time, time)
+ expect(transaction).to receive(:track_query)
+ .with('SELECT 1', [], time, time)
subscription.publish('test', time, time, nil, query_data)
end
@@ -205,8 +205,8 @@ describe Gitlab::Sherlock::Transaction, lib: true do
it 'only tracks queries triggered from the transaction thread' do
expect(transaction).not_to receive(:track_query)
- Thread.new { subscription.publish('test', time, time, nil, query_data) }.
- join
+ Thread.new { subscription.publish('test', time, time, nil, query_data) }
+ .join
end
end
@@ -228,8 +228,8 @@ describe Gitlab::Sherlock::Transaction, lib: true do
it 'only tracks views rendered from the transaction thread' do
expect(transaction).not_to receive(:track_view)
- Thread.new { subscription.publish('test', time, time, nil, view_data) }.
- join
+ Thread.new { subscription.publish('test', time, time, nil, view_data) }
+ .join
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb b/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb
index 6307f8c16a3..37d9e1d3e6b 100644
--- a/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb
+++ b/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb
@@ -5,8 +5,8 @@ describe Gitlab::SidekiqStatus::ClientMiddleware do
it 'tracks the job in Redis' do
expect(Gitlab::SidekiqStatus).to receive(:set).with('123', Gitlab::SidekiqStatus::DEFAULT_EXPIRATION)
- described_class.new.
- call('Foo', { 'jid' => '123' }, double(:queue), double(:pool)) { nil }
+ described_class.new
+ .call('Foo', { 'jid' => '123' }, double(:queue), double(:pool)) { nil }
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_status/server_middleware_spec.rb b/spec/lib/gitlab/sidekiq_status/server_middleware_spec.rb
index 80728197b8c..04e09d3dec8 100644
--- a/spec/lib/gitlab/sidekiq_status/server_middleware_spec.rb
+++ b/spec/lib/gitlab/sidekiq_status/server_middleware_spec.rb
@@ -5,8 +5,8 @@ describe Gitlab::SidekiqStatus::ServerMiddleware do
it 'stops tracking of a job upon completion' do
expect(Gitlab::SidekiqStatus).to receive(:unset).with('123')
- ret = described_class.new.
- call(double(:worker), { 'jid' => '123' }, double(:queue)) { 10 }
+ ret = described_class.new
+ .call(double(:worker), { 'jid' => '123' }, double(:queue)) { 10 }
expect(ret).to eq(10)
end
diff --git a/spec/lib/gitlab/sidekiq_status_spec.rb b/spec/lib/gitlab/sidekiq_status_spec.rb
index 496e50fbae4..c2e77ef6b6c 100644
--- a/spec/lib/gitlab/sidekiq_status_spec.rb
+++ b/spec/lib/gitlab/sidekiq_status_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::SidekiqStatus do
- describe '.set', :redis do
+ describe '.set', :clean_gitlab_redis_shared_state do
it 'stores the job ID' do
described_class.set('123')
@@ -14,7 +14,7 @@ describe Gitlab::SidekiqStatus do
end
end
- describe '.unset', :redis do
+ describe '.unset', :clean_gitlab_redis_shared_state do
it 'removes the job ID' do
described_class.set('123')
described_class.unset('123')
@@ -27,7 +27,7 @@ describe Gitlab::SidekiqStatus do
end
end
- describe '.all_completed?', :redis do
+ describe '.all_completed?', :clean_gitlab_redis_shared_state do
it 'returns true if all jobs have been completed' do
expect(described_class.all_completed?(%w(123))).to eq(true)
end
@@ -39,7 +39,7 @@ describe Gitlab::SidekiqStatus do
end
end
- describe '.num_running', :redis do
+ describe '.num_running', :clean_gitlab_redis_shared_state do
it 'returns 0 if all jobs have been completed' do
expect(described_class.num_running(%w(123))).to eq(0)
end
@@ -52,7 +52,7 @@ describe Gitlab::SidekiqStatus do
end
end
- describe '.num_completed', :redis do
+ describe '.num_completed', :clean_gitlab_redis_shared_state do
it 'returns 1 if all jobs have been completed' do
expect(described_class.num_completed(%w(123))).to eq(1)
end
@@ -74,7 +74,7 @@ describe Gitlab::SidekiqStatus do
end
end
- describe 'completed', :redis do
+ describe 'completed', :clean_gitlab_redis_shared_state do
it 'returns the completed job' do
expect(described_class.completed_jids(%w(123))).to eq(['123'])
end
diff --git a/spec/lib/gitlab/sidekiq_throttler_spec.rb b/spec/lib/gitlab/sidekiq_throttler_spec.rb
index ff32e0e699d..6374ac80207 100644
--- a/spec/lib/gitlab/sidekiq_throttler_spec.rb
+++ b/spec/lib/gitlab/sidekiq_throttler_spec.rb
@@ -13,14 +13,14 @@ describe Gitlab::SidekiqThrottler do
describe '#execute!' do
it 'sets limits on the selected queues' do
- Gitlab::SidekiqThrottler.execute!
+ described_class.execute!
expect(Sidekiq::Queue['build'].limit).to eq 4
expect(Sidekiq::Queue['project_cache'].limit).to eq 4
end
it 'does not set limits on other queues' do
- Gitlab::SidekiqThrottler.execute!
+ described_class.execute!
expect(Sidekiq::Queue['merge'].limit).to be_nil
end
diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb
deleted file mode 100644
index c9c2f314e57..00000000000
--- a/spec/lib/gitlab/slash_commands/command_definition_spec.rb
+++ /dev/null
@@ -1,173 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::SlashCommands::CommandDefinition do
- subject { described_class.new(:command) }
-
- describe "#all_names" do
- context "when the command has aliases" do
- before do
- subject.aliases = [:alias1, :alias2]
- end
-
- it "returns an array with the name and aliases" do
- expect(subject.all_names).to eq([:command, :alias1, :alias2])
- end
- end
-
- context "when the command doesn't have aliases" do
- it "returns an array with the name" do
- expect(subject.all_names).to eq([:command])
- end
- end
- end
-
- describe "#noop?" do
- context "when the command has an action block" do
- before do
- subject.action_block = proc { }
- end
-
- it "returns false" do
- expect(subject.noop?).to be false
- end
- end
-
- context "when the command doesn't have an action block" do
- it "returns true" do
- expect(subject.noop?).to be true
- end
- end
- end
-
- describe "#available?" do
- let(:opts) { { go: false } }
-
- context "when the command has a condition block" do
- before do
- subject.condition_block = proc { go }
- end
-
- context "when the condition block returns true" do
- before do
- opts[:go] = true
- end
-
- it "returns true" do
- expect(subject.available?(opts)).to be true
- end
- end
-
- context "when the condition block returns false" do
- it "returns false" do
- expect(subject.available?(opts)).to be false
- end
- end
- end
-
- context "when the command doesn't have a condition block" do
- it "returns true" do
- expect(subject.available?(opts)).to be true
- end
- end
- end
-
- describe "#execute" do
- let(:context) { OpenStruct.new(run: false) }
-
- context "when the command is a noop" do
- it "doesn't execute the command" do
- expect(context).not_to receive(:instance_exec)
-
- subject.execute(context, {}, nil)
-
- expect(context.run).to be false
- end
- end
-
- context "when the command is not a noop" do
- before do
- subject.action_block = proc { self.run = true }
- end
-
- context "when the command is not available" do
- before do
- subject.condition_block = proc { false }
- end
-
- it "doesn't execute the command" do
- subject.execute(context, {}, nil)
-
- expect(context.run).to be false
- end
- end
-
- context "when the command is available" do
- context "when the commnd has no arguments" do
- before do
- subject.action_block = proc { self.run = true }
- end
-
- context "when the command is provided an argument" do
- it "executes the command" do
- subject.execute(context, {}, true)
-
- expect(context.run).to be true
- end
- end
-
- context "when the command is not provided an argument" do
- it "executes the command" do
- subject.execute(context, {}, nil)
-
- expect(context.run).to be true
- end
- end
- end
-
- context "when the command has 1 required argument" do
- before do
- subject.action_block = ->(arg) { self.run = arg }
- end
-
- context "when the command is provided an argument" do
- it "executes the command" do
- subject.execute(context, {}, true)
-
- expect(context.run).to be true
- end
- end
-
- context "when the command is not provided an argument" do
- it "doesn't execute the command" do
- subject.execute(context, {}, nil)
-
- expect(context.run).to be false
- end
- end
- end
-
- context "when the command has 1 optional argument" do
- before do
- subject.action_block = proc { |arg = nil| self.run = arg || true }
- end
-
- context "when the command is provided an argument" do
- it "executes the command" do
- subject.execute(context, {}, true)
-
- expect(context.run).to be true
- end
- end
-
- context "when the command is not provided an argument" do
- it "executes the command" do
- subject.execute(context, {}, nil)
-
- expect(context.run).to be true
- end
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb
new file mode 100644
index 00000000000..0173a45d480
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/command_spec.rb
@@ -0,0 +1,111 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Command do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ describe '#execute' do
+ subject do
+ described_class.new(project, user, params).execute
+ end
+
+ context 'when no command is available' do
+ let(:params) { { text: 'issue show 1' } }
+ let(:project) { create(:project, has_external_issue_tracker: true) }
+
+ it 'displays 404 messages' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('404 not found')
+ end
+ end
+
+ context 'when an unknown command is triggered' do
+ let(:params) { { command: '/gitlab', text: "unknown command 123" } }
+
+ it 'displays the help message' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Unknown command')
+ expect(subject[:text]).to match('/gitlab issue show')
+ end
+ end
+
+ context 'the user can not create an issue' do
+ let(:params) { { text: "issue create my new issue" } }
+
+ it 'rejects the actions' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Whoops! This action is not allowed')
+ end
+ end
+
+ context 'when trying to do deployment' do
+ let(:params) { { text: 'deploy staging to production' } }
+ let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+
+ let!(:manual) do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'first',
+ environment: 'production')
+ end
+
+ context 'and user can not create deployment' do
+ it 'returns action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Whoops! This action is not allowed')
+ end
+ end
+
+ context 'and user has deployment permission' do
+ before do
+ build.project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+ end
+
+ it 'returns action' do
+ expect(subject[:text]).to include('Deployment started from staging to production')
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+
+ context 'when duplicate action exists' do
+ let!(:manual2) do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'second',
+ environment: 'production')
+ end
+
+ it 'returns error' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to include("Couldn't find a deployment manual action.")
+ end
+ end
+ end
+ end
+ end
+
+ describe '#match_command' do
+ subject { described_class.new(project, user, params).match_command.first }
+
+ context 'IssueShow is triggered' do
+ let(:params) { { text: 'issue show 123' } }
+
+ it { is_expected.to eq(Gitlab::SlashCommands::IssueShow) }
+ end
+
+ context 'IssueCreate is triggered' do
+ let(:params) { { text: 'issue create my title' } }
+
+ it { is_expected.to eq(Gitlab::SlashCommands::IssueNew) }
+ end
+
+ context 'IssueSearch is triggered' do
+ let(:params) { { text: 'issue search my query' } }
+
+ it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/deploy_spec.rb b/spec/lib/gitlab/slash_commands/deploy_spec.rb
new file mode 100644
index 00000000000..74b5ef4bb26
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/deploy_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Deploy do
+ describe '#execute' do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:regex_match) { described_class.match('deploy staging to production') }
+
+ before do
+ # Make it possible to trigger protected manual actions for developers.
+ #
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: 'master', project: project)
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'if no environment is defined' do
+ it 'does not execute an action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to eq "Couldn't find a deployment manual action."
+ end
+ end
+
+ context 'with environment' do
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+
+ context 'without actions' do
+ it 'does not execute an action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to eq "Couldn't find a deployment manual action."
+ end
+ end
+
+ context 'when single action has been matched' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'first',
+ environment: 'production')
+ end
+
+ it 'returns success result' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject[:text])
+ .to start_with('Deployment started from staging to production')
+ end
+ end
+
+ context 'when more than one action has been matched' do
+ context 'when there is no specific actions with a environment name' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'first',
+ environment: 'production')
+
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'second',
+ environment: 'production')
+ end
+
+ it 'returns error about too many actions defined' do
+ expect(subject[:text]).to eq("Couldn't find a deployment manual action.")
+ expect(subject[:response_type]).to be(:ephemeral)
+ end
+ end
+
+ context 'when one of the actions is environement specific action' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'first',
+ environment: 'production')
+
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'production',
+ environment: 'production')
+ end
+
+ it 'deploys to production' do
+ expect(subject[:text])
+ .to start_with('Deployment started from staging to production')
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+ end
+
+ context 'when one of the actions is a teardown action' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline,
+ name: 'first',
+ environment: 'production')
+
+ create(:ci_build, :manual, :teardown_environment,
+ pipeline: pipeline, name: 'teardown', environment: 'production')
+ end
+
+ it 'deploys to production' do
+ expect(subject[:text])
+ .to start_with('Deployment started from staging to production')
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+ end
+ end
+ end
+ end
+
+ describe 'self.match' do
+ it 'matches the environment' do
+ match = described_class.match('deploy staging to production')
+
+ expect(match[:from]).to eq('staging')
+ expect(match[:to]).to eq('production')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb
deleted file mode 100644
index 26217a0e3b2..00000000000
--- a/spec/lib/gitlab/slash_commands/dsl_spec.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::SlashCommands::Dsl do
- before :all do
- DummyClass = Struct.new(:project) do
- include Gitlab::SlashCommands::Dsl
-
- desc 'A command with no args'
- command :no_args, :none do
- "Hello World!"
- end
-
- params 'The first argument'
- command :one_arg, :once, :first do |arg1|
- arg1
- end
-
- desc do
- "A dynamic description for #{noteable.upcase}"
- end
- params 'The first argument', 'The second argument'
- command :two_args do |arg1, arg2|
- [arg1, arg2]
- end
-
- command :cc
-
- condition do
- project == 'foo'
- end
- command :cond_action do |arg|
- arg
- end
- end
- end
-
- describe '.command_definitions' do
- it 'returns an array with commands definitions' do
- no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions
-
- expect(no_args_def.name).to eq(:no_args)
- expect(no_args_def.aliases).to eq([:none])
- expect(no_args_def.description).to eq('A command with no args')
- expect(no_args_def.params).to eq([])
- expect(no_args_def.condition_block).to be_nil
- expect(no_args_def.action_block).to be_a_kind_of(Proc)
-
- expect(one_arg_def.name).to eq(:one_arg)
- expect(one_arg_def.aliases).to eq([:once, :first])
- expect(one_arg_def.description).to eq('')
- expect(one_arg_def.params).to eq(['The first argument'])
- expect(one_arg_def.condition_block).to be_nil
- expect(one_arg_def.action_block).to be_a_kind_of(Proc)
-
- expect(two_args_def.name).to eq(:two_args)
- expect(two_args_def.aliases).to eq([])
- expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE')
- expect(two_args_def.params).to eq(['The first argument', 'The second argument'])
- expect(two_args_def.condition_block).to be_nil
- expect(two_args_def.action_block).to be_a_kind_of(Proc)
-
- expect(cc_def.name).to eq(:cc)
- expect(cc_def.aliases).to eq([])
- expect(cc_def.description).to eq('')
- expect(cc_def.params).to eq([])
- expect(cc_def.condition_block).to be_nil
- expect(cc_def.action_block).to be_nil
-
- expect(cond_action_def.name).to eq(:cond_action)
- expect(cond_action_def.aliases).to eq([])
- expect(cond_action_def.description).to eq('')
- expect(cond_action_def.params).to eq([])
- expect(cond_action_def.condition_block).to be_a_kind_of(Proc)
- expect(cond_action_def.action_block).to be_a_kind_of(Proc)
- end
- end
-end
diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb
deleted file mode 100644
index d7f77486b3e..00000000000
--- a/spec/lib/gitlab/slash_commands/extractor_spec.rb
+++ /dev/null
@@ -1,223 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::SlashCommands::Extractor do
- let(:definitions) do
- Class.new do
- include Gitlab::SlashCommands::Dsl
-
- command(:reopen, :open) { }
- command(:assign) { }
- command(:labels) { }
- command(:power) { }
- end.command_definitions
- end
-
- let(:extractor) { described_class.new(definitions) }
-
- shared_examples 'command with no argument' do
- it 'extracts command' do
- msg, commands = extractor.extract_commands(original_msg)
-
- expect(commands).to eq [['reopen']]
- expect(msg).to eq final_msg
- end
- end
-
- shared_examples 'command with a single argument' do
- it 'extracts command' do
- msg, commands = extractor.extract_commands(original_msg)
-
- expect(commands).to eq [['assign', '@joe']]
- expect(msg).to eq final_msg
- end
- end
-
- shared_examples 'command with multiple arguments' do
- it 'extracts command' do
- msg, commands = extractor.extract_commands(original_msg)
-
- expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
- expect(msg).to eq final_msg
- end
- end
-
- describe '#extract_commands' do
- describe 'command with no argument' do
- context 'at the start of content' do
- it_behaves_like 'command with no argument' do
- let(:original_msg) { "/reopen\nworld" }
- let(:final_msg) { "world" }
- end
- end
-
- context 'in the middle of content' do
- it_behaves_like 'command with no argument' do
- let(:original_msg) { "hello\n/reopen\nworld" }
- let(:final_msg) { "hello\nworld" }
- end
- end
-
- context 'in the middle of a line' do
- it 'does not extract command' do
- msg = "hello\nworld /reopen"
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq "hello\nworld /reopen"
- end
- end
-
- context 'at the end of content' do
- it_behaves_like 'command with no argument' do
- let(:original_msg) { "hello\n/reopen" }
- let(:final_msg) { "hello" }
- end
- end
- end
-
- describe 'command with a single argument' do
- context 'at the start of content' do
- it_behaves_like 'command with a single argument' do
- let(:original_msg) { "/assign @joe\nworld" }
- let(:final_msg) { "world" }
- end
-
- it 'allows slash in command arguments' do
- msg = "/assign @joe / @jane\nworld"
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to eq [['assign', '@joe / @jane']]
- expect(msg).to eq 'world'
- end
- end
-
- context 'in the middle of content' do
- it_behaves_like 'command with a single argument' do
- let(:original_msg) { "hello\n/assign @joe\nworld" }
- let(:final_msg) { "hello\nworld" }
- end
- end
-
- context 'in the middle of a line' do
- it 'does not extract command' do
- msg = "hello\nworld /assign @joe"
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq "hello\nworld /assign @joe"
- end
- end
-
- context 'at the end of content' do
- it_behaves_like 'command with a single argument' do
- let(:original_msg) { "hello\n/assign @joe" }
- let(:final_msg) { "hello" }
- end
- end
-
- context 'when argument is not separated with a space' do
- it 'does not extract command' do
- msg = "hello\n/assign@joe\nworld"
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq "hello\n/assign@joe\nworld"
- end
- end
- end
-
- describe 'command with multiple arguments' do
- context 'at the start of content' do
- it_behaves_like 'command with multiple arguments' do
- let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) }
- let(:final_msg) { "world" }
- end
- end
-
- context 'in the middle of content' do
- it_behaves_like 'command with multiple arguments' do
- let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) }
- let(:final_msg) { "hello\nworld" }
- end
- end
-
- context 'in the middle of a line' do
- it 'does not extract command' do
- msg = %(hello\nworld /labels ~foo ~"bar baz" label)
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label)
- end
- end
-
- context 'at the end of content' do
- it_behaves_like 'command with multiple arguments' do
- let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) }
- let(:final_msg) { "hello" }
- end
- end
-
- context 'when argument is not separated with a space' do
- it 'does not extract command' do
- msg = %(hello\n/labels~foo ~"bar baz" label\nworld)
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld)
- end
- end
- end
-
- it 'extracts command with multiple arguments and various prefixes' do
- msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
- expect(msg).to eq "hello\nworld"
- end
-
- it 'extracts multiple commands' do
- msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen)
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']]
- expect(msg).to eq "hello\nworld"
- end
-
- it 'does not alter original content if no command is found' do
- msg = 'Fixes #123'
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq 'Fixes #123'
- end
-
- it 'does not extract commands inside a blockcode' do
- msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld"
- expected = msg.delete("\r")
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq expected
- end
-
- it 'does not extract commands inside a blockquote' do
- msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld"
- expected = msg.delete("\r")
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq expected
- end
-
- it 'does not extract commands inside a HTML tag' do
- msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld"
- expected = msg.delete("\r")
- msg, commands = extractor.extract_commands(msg)
-
- expect(commands).to be_empty
- expect(msg).to eq expected
- end
- end
-end
diff --git a/spec/lib/gitlab/slash_commands/issue_new_spec.rb b/spec/lib/gitlab/slash_commands/issue_new_spec.rb
new file mode 100644
index 00000000000..75ae58d0582
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/issue_new_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::IssueNew do
+ describe '#execute' do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:regex_match) { described_class.match("issue create bird is the word") }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'without description' do
+ it 'creates the issue' do
+ expect { subject }.to change { project.issues.count }.by(1)
+
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+ end
+
+ context 'with description' do
+ let(:description) { "Surfin bird" }
+ let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") }
+
+ it 'creates the issue with description' do
+ subject
+
+ expect(Issue.last.description).to eq(description)
+ end
+ end
+
+ context "with more newlines between the title and the description" do
+ let(:description) { "Surfin bird" }
+ let(:regex_match) { described_class.match("issue create bird is the word\n\n#{description}\n") }
+
+ it 'creates the issue' do
+ expect { subject }.to change { project.issues.count }.by(1)
+ end
+ end
+
+ context 'issue cannot be created' do
+ let!(:issue) { create(:issue, project: project, title: 'bird is the word') }
+ let(:regex_match) { described_class.match("issue create #{'a' * 512}}") }
+
+ it 'displays the errors' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to match("- Title is too long")
+ end
+ end
+ end
+
+ describe '.match' do
+ it 'matches the title without description' do
+ match = described_class.match("issue create my title")
+
+ expect(match[:title]).to eq('my title')
+ expect(match[:description]).to eq("")
+ end
+
+ it 'matches the title with description' do
+ match = described_class.match("issue create my title\n\ndescription")
+
+ expect(match[:title]).to eq('my title')
+ expect(match[:description]).to eq('description')
+ end
+
+ it 'matches the alias new' do
+ match = described_class.match("issue new my title")
+
+ expect(match).not_to be_nil
+ expect(match[:title]).to eq('my title')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/issue_search_spec.rb
new file mode 100644
index 00000000000..51f59216413
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/issue_search_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::IssueSearch do
+ describe '#execute' do
+ let!(:issue) { create(:issue, project: project, title: 'find me') }
+ let!(:confidential) { create(:issue, :confidential, project: project, title: 'mepmep find') }
+ let(:project) { create(:project) }
+ let(:user) { issue.author }
+ let(:regex_match) { described_class.match("issue search find") }
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'when the user has no access' do
+ it 'only returns the open issues' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to match("not found")
+ end
+ end
+
+ context 'the user has access' do
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'returns all results' do
+ expect(subject).to have_key(:attachments)
+ expect(subject[:text]).to eq("Here are the 2 issues I found:")
+ end
+ end
+
+ context 'without hits on the query' do
+ it 'returns an empty collection' do
+ expect(subject[:text]).to match("not found")
+ end
+ end
+ end
+
+ describe 'self.match' do
+ let(:query) { "my search keywords" }
+ it 'matches the query' do
+ match = described_class.match("issue search #{query}")
+
+ expect(match[:query]).to eq(query)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/issue_show_spec.rb
new file mode 100644
index 00000000000..08c380ca8f1
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/issue_show_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::IssueShow do
+ describe '#execute' do
+ let(:issue) { create(:issue, project: project) }
+ let(:project) { create(:project) }
+ let(:user) { issue.author }
+ let(:regex_match) { described_class.match("issue show #{issue.iid}") }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'the issue exists' do
+ let(:title) { subject[:attachments].first[:title] }
+
+ it 'returns the issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(title).to start_with(issue.title)
+ end
+
+ context 'when its reference is given' do
+ let(:regex_match) { described_class.match("issue show #{issue.to_reference}") }
+
+ it 'shows the issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(title).to start_with(issue.title)
+ end
+ end
+ end
+
+ context 'the issue does not exist' do
+ let(:regex_match) { described_class.match("issue show 2343242") }
+
+ it "returns not found" do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to match("not found")
+ end
+ end
+ end
+
+ describe '.match' do
+ it 'matches the iid' do
+ match = described_class.match("issue show 123")
+
+ expect(match[:iid]).to eq("123")
+ end
+
+ it 'accepts a reference' do
+ match = described_class.match("issue show #{Issue.reference_prefix}123")
+
+ expect(match[:iid]).to eq("123")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/access_spec.rb b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb
new file mode 100644
index 00000000000..ef3d217f7be
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::Access do
+ describe '#access_denied' do
+ subject { described_class.new.access_denied }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'displays an error message' do
+ expect(subject[:text]).to match("is not allowed")
+ expect(subject[:response_type]).to be(:ephemeral)
+ end
+ end
+
+ describe '#not_found' do
+ subject { described_class.new.not_found }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'tells the user the resource was not found' do
+ expect(subject[:text]).to match("not found!")
+ expect(subject[:response_type]).to be(:ephemeral)
+ end
+ end
+
+ describe '#authorize' do
+ context 'with an authorization URL' do
+ subject { described_class.new('http://authorize.me').authorize }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'tells the user to authorize' do
+ expect(subject[:text]).to match("connect your GitLab account")
+ expect(subject[:response_type]).to be(:ephemeral)
+ end
+ end
+
+ context 'without authorization url' do
+ subject { described_class.new.authorize }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'tells the user to authorize' do
+ expect(subject[:text]).to match("Couldn't identify you")
+ expect(subject[:response_type]).to be(:ephemeral)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb b/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb
new file mode 100644
index 00000000000..d16d122c64e
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::Deploy do
+ let(:build) { create(:ci_build) }
+
+ describe '#present' do
+ subject { described_class.new(build).present('staging', 'prod') }
+
+ it { is_expected.to have_key(:text) }
+ it { is_expected.to have_key(:response_type) }
+ it { is_expected.to have_key(:status) }
+ it { is_expected.not_to have_key(:attachments) }
+
+ it 'messages the channel of the deploy' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject[:text]).to start_with("Deployment started from staging to prod")
+ end
+ end
+
+ describe '#action_not_found' do
+ subject { described_class.new(nil).action_not_found }
+
+ it { is_expected.to have_key(:text) }
+ it { is_expected.to have_key(:response_type) }
+ it { is_expected.to have_key(:status) }
+ it { is_expected.not_to have_key(:attachments) }
+
+ it 'tells the user there is no action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to eq "Couldn't find a deployment manual action."
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb
new file mode 100644
index 00000000000..76e4bad88fd
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::IssueNew do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:attachment) { subject[:attachments].first }
+
+ subject { described_class.new(issue).present }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'shows the issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject).to have_key(:attachments)
+ expect(attachment[:title]).to start_with(issue.title)
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb
new file mode 100644
index 00000000000..5a7ec0685fe
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::IssueSearch do
+ let(:project) { create(:project) }
+ let(:message) { subject[:text] }
+
+ before do
+ create_list(:issue, 2, project: project)
+ end
+
+ subject { described_class.new(project.issues).present }
+
+ it 'formats the message correct' do
+ is_expected.to have_key(:text)
+ is_expected.to have_key(:status)
+ is_expected.to have_key(:response_type)
+ is_expected.to have_key(:attachments)
+ end
+
+ it 'shows a list of results' do
+ expect(subject[:response_type]).to be(:ephemeral)
+
+ expect(message).to start_with("Here are the 2 issues I found")
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb
new file mode 100644
index 00000000000..8f607d7a9c9
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb
@@ -0,0 +1,52 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::IssueShow do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:attachment) { subject[:attachments].first }
+
+ subject { described_class.new(issue).present }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'shows the issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject).to have_key(:attachments)
+ expect(attachment[:title]).to start_with(issue.title)
+ end
+
+ context 'with upvotes' do
+ before do
+ create(:award_emoji, :upvote, awardable: issue)
+ end
+
+ it 'shows the upvote count' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(attachment[:text]).to start_with("**Open** · :+1: 1")
+ end
+ end
+
+ context 'with labels' do
+ let(:label) { create(:label, project: project, title: 'mep') }
+ let(:label1) { create(:label, project: project, title: 'mop') }
+
+ before do
+ issue.labels << [label, label1]
+ end
+
+ it 'shows the labels' do
+ labels = attachment[:fields].find { |f| f[:title] == 'Labels' }
+
+ expect(labels[:value]).to eq("mep, mop")
+ end
+ end
+
+ context 'confidential issue' do
+ let(:issue) { create(:issue, project: project) }
+
+ it 'shows an ephemeral response' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(attachment[:text]).to start_with("**Open**")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sql/glob_spec.rb b/spec/lib/gitlab/sql/glob_spec.rb
new file mode 100644
index 00000000000..f0bb4294d62
--- /dev/null
+++ b/spec/lib/gitlab/sql/glob_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+describe Gitlab::SQL::Glob do
+ describe '.to_like' do
+ it 'matches * as %' do
+ expect(glob('apple', '*')).to be(true)
+ expect(glob('apple', 'app*')).to be(true)
+ expect(glob('apple', 'apple*')).to be(true)
+ expect(glob('apple', '*pple')).to be(true)
+ expect(glob('apple', 'ap*le')).to be(true)
+
+ expect(glob('apple', '*a')).to be(false)
+ expect(glob('apple', 'app*a')).to be(false)
+ expect(glob('apple', 'ap*l')).to be(false)
+ end
+
+ it 'matches % literally' do
+ expect(glob('100%', '100%')).to be(true)
+
+ expect(glob('100%', '%')).to be(false)
+ end
+
+ it 'matches _ literally' do
+ expect(glob('^_^', '^_^')).to be(true)
+
+ expect(glob('^A^', '^_^')).to be(false)
+ end
+ end
+
+ def glob(string, pattern)
+ match(string, subject.to_like(quote(pattern)))
+ end
+
+ def match(string, pattern)
+ value = query("SELECT #{quote(string)} LIKE #{pattern}")
+ .rows.flatten.first
+
+ case value
+ when 't', 1
+ true
+ else
+ false
+ end
+ end
+
+ def query(sql)
+ ActiveRecord::Base.connection.select_all(sql)
+ end
+
+ def quote(string)
+ ActiveRecord::Base.connection.quote(string)
+ end
+end
diff --git a/spec/lib/gitlab/sql/recursive_cte_spec.rb b/spec/lib/gitlab/sql/recursive_cte_spec.rb
new file mode 100644
index 00000000000..25146860615
--- /dev/null
+++ b/spec/lib/gitlab/sql/recursive_cte_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe Gitlab::SQL::RecursiveCTE, :postgresql do
+ let(:cte) { described_class.new(:cte_name) }
+
+ describe '#to_arel' do
+ it 'generates an Arel relation for the CTE body' do
+ rel1 = User.where(id: 1)
+ rel2 = User.where(id: 2)
+
+ cte << rel1
+ cte << rel2
+
+ sql = cte.to_arel.to_sql
+ name = ActiveRecord::Base.connection.quote_table_name(:cte_name)
+
+ sql1, sql2 = ActiveRecord::Base.connection.unprepared_statement do
+ [rel1.except(:order).to_sql, rel2.except(:order).to_sql]
+ end
+
+ expect(sql).to eq("#{name} AS (#{sql1}\nUNION\n#{sql2})")
+ end
+ end
+
+ describe '#alias_to' do
+ it 'returns an alias for the CTE' do
+ table = Arel::Table.new(:kittens)
+
+ source_name = ActiveRecord::Base.connection.quote_table_name(:cte_name)
+ alias_name = ActiveRecord::Base.connection.quote_table_name(:kittens)
+
+ expect(cte.alias_to(table).to_sql).to eq("#{source_name} AS #{alias_name}")
+ end
+ end
+
+ describe '#apply_to' do
+ it 'applies a CTE to an ActiveRecord::Relation' do
+ user = create(:user)
+ cte = described_class.new(:cte_name)
+
+ cte << User.where(id: user.id)
+
+ relation = cte.apply_to(User.all)
+
+ expect(relation.to_sql).to match(/WITH RECURSIVE.+cte_name/)
+ expect(relation.to_a).to eq(User.where(id: user.id).to_a)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index 849edb09476..baf8f6644bf 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::SQL::Union, lib: true do
+describe Gitlab::SQL::Union do
let(:relation_1) { User.where(email: 'alice@example.com').select(:id) }
let(:relation_2) { User.where(email: 'bob@example.com').select(:id) }
@@ -19,7 +19,7 @@ describe Gitlab::SQL::Union, lib: true do
empty_relation = User.none
union = described_class.new([empty_relation, relation_1, relation_2])
- expect{User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error
+ expect {User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error
expect(union.to_sql).to eq("#{to_sql(relation_1)}\nUNION\n#{to_sql(relation_2)}")
end
end
diff --git a/spec/lib/gitlab/string_range_marker_spec.rb b/spec/lib/gitlab/string_range_marker_spec.rb
new file mode 100644
index 00000000000..abeaa7f0ddb
--- /dev/null
+++ b/spec/lib/gitlab/string_range_marker_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::StringRangeMarker do
+ describe '#mark' do
+ context "when the rich text is html safe" do
+ let(:raw) { "abc <def>" }
+ let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def">&lt;def&gt;</span>}.html_safe }
+ let(:inline_diffs) { [2..5] }
+ subject do
+ described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:|
+ "LEFT#{text}RIGHT"
+ end
+ end
+
+ it 'marks the inline diffs' do
+ expect(subject).to eq(%{<span class="abc">abLEFTcRIGHT</span><span class="space">LEFT RIGHT</span><span class="def">LEFT&lt;dRIGHTef&gt;</span>})
+ expect(subject).to be_html_safe
+ end
+ end
+
+ context "when the rich text is not html safe" do
+ let(:raw) { "abc <def>" }
+ let(:inline_diffs) { [2..5] }
+ subject do
+ described_class.new(raw).mark(inline_diffs) do |text, left:, right:|
+ "LEFT#{text}RIGHT"
+ end
+ end
+
+ it 'marks the inline diffs' do
+ expect(subject).to eq(%{abLEFTc &lt;dRIGHTef&gt;})
+ expect(subject).to be_html_safe
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb
new file mode 100644
index 00000000000..d715f9bd641
--- /dev/null
+++ b/spec/lib/gitlab/string_regex_marker_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe Gitlab::StringRegexMarker do
+ describe '#mark' do
+ let(:raw) { %{"name": "AFNetworking"} }
+ let(:rich) { %{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"AFNetworking"</span>}.html_safe }
+ subject do
+ described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:|
+ %{<a href="#">#{text}</a>}
+ end
+ end
+
+ it 'marks the inline diffs' do
+ expect(subject).to eq(%{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"<a href="#">AFNetworking</a>"</span>})
+ expect(subject).to be_html_safe
+ end
+ end
+end
diff --git a/spec/lib/gitlab/template/gitignore_template_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb
index 9750a012e22..97797f42aaa 100644
--- a/spec/lib/gitlab/template/gitignore_template_spec.rb
+++ b/spec/lib/gitlab/template/gitignore_template_spec.rb
@@ -24,7 +24,7 @@ describe Gitlab::Template::GitignoreTemplate do
it 'returns the Gitignore object of a valid file' do
ruby = subject.find('Ruby')
- expect(ruby).to be_a Gitlab::Template::GitignoreTemplate
+ expect(ruby).to be_a described_class
expect(ruby.name).to eq('Ruby')
end
end
diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
index e3b8321eda3..6541326d1de 100644
--- a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
+++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
@@ -25,7 +25,7 @@ describe Gitlab::Template::GitlabCiYmlTemplate do
it 'returns the GitlabCiYml object of a valid file' do
ruby = subject.find('Ruby')
- expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate
+ expect(ruby).to be_a described_class
expect(ruby.name).to eq('Ruby')
end
end
diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb
index 9213ced7b19..7098499f996 100644
--- a/spec/lib/gitlab/template/issue_template_spec.rb
+++ b/spec/lib/gitlab/template/issue_template_spec.rb
@@ -1,61 +1,47 @@
require 'spec_helper'
describe Gitlab::Template::IssueTemplate do
- subject { described_class }
-
- let(:user) { create(:user) }
-
- let(:project) do
- create(:project,
- :repository,
- create_template: {
- user: user,
- access: Gitlab::Access::MASTER,
- path: 'issue_templates'
- })
- end
+ let(:project) { create(:project, :repository, create_templates: :issue) }
describe '.all' do
it 'strips the md suffix' do
- expect(subject.all(project).first.name).not_to end_with('.issue_template')
+ expect(described_class.all(project).first.name).not_to end_with('.issue_template')
end
it 'combines the globals and rest' do
- all = subject.all(project).map(&:name)
+ all = described_class.all(project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
- expect(all).to include('template_test')
end
end
describe '.find' do
it 'returns nil if the file does not exist' do
- expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
+ expect { described_class.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
it 'returns the issue object of a valid file' do
- ruby = subject.find('bug', project)
+ ruby = described_class.find('bug', project)
- expect(ruby).to be_a Gitlab::Template::IssueTemplate
+ expect(ruby).to be_a described_class
expect(ruby.name).to eq('bug')
end
end
describe '.by_category' do
it 'return array of templates' do
- all = subject.by_category('', project).map(&:name)
+ all = described_class.by_category('', project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
- expect(all).to include('template_test')
end
context 'when repo is bare or empty' do
- let(:empty_project) { create(:empty_project) }
- before { empty_project.add_user(user, Gitlab::Access::MASTER) }
+ let(:empty_project) { create(:project) }
it "returns empty array" do
- templates = subject.by_category('', empty_project)
+ templates = described_class.by_category('', empty_project)
+
expect(templates).to be_empty
end
end
@@ -63,24 +49,23 @@ describe Gitlab::Template::IssueTemplate do
describe '#content' do
it 'loads the full file' do
- issue_template = subject.new('.gitlab/issue_templates/bug.md', project)
+ issue_template = described_class.new('.gitlab/issue_templates/bug.md', project)
expect(issue_template.name).to eq 'bug'
expect(issue_template.content).to eq('something valid')
end
it 'raises error when file is not found' do
- issue_template = subject.new('.gitlab/issue_templates/bugnot.md', project)
+ issue_template = described_class.new('.gitlab/issue_templates/bugnot.md', project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
context "when repo is empty" do
- let(:empty_project) { create(:empty_project) }
-
- before { empty_project.add_user(user, Gitlab::Access::MASTER) }
+ let(:empty_project) { create(:project) }
it "raises file not found" do
- issue_template = subject.new('.gitlab/issue_templates/not_existent.md', empty_project)
+ issue_template = described_class.new('.gitlab/issue_templates/not_existent.md', empty_project)
+
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
end
diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb
index 77dd3079e22..bd7ff64aa8a 100644
--- a/spec/lib/gitlab/template/merge_request_template_spec.rb
+++ b/spec/lib/gitlab/template/merge_request_template_spec.rb
@@ -1,61 +1,47 @@
require 'spec_helper'
describe Gitlab::Template::MergeRequestTemplate do
- subject { described_class }
-
- let(:user) { create(:user) }
-
- let(:project) do
- create(:project,
- :repository,
- create_template: {
- user: user,
- access: Gitlab::Access::MASTER,
- path: 'merge_request_templates'
- })
- end
+ let(:project) { create(:project, :repository, create_templates: :merge_request) }
describe '.all' do
it 'strips the md suffix' do
- expect(subject.all(project).first.name).not_to end_with('.issue_template')
+ expect(described_class.all(project).first.name).not_to end_with('.issue_template')
end
it 'combines the globals and rest' do
- all = subject.all(project).map(&:name)
+ all = described_class.all(project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
- expect(all).to include('template_test')
end
end
describe '.find' do
it 'returns nil if the file does not exist' do
- expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
+ expect { described_class.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
it 'returns the merge request object of a valid file' do
- ruby = subject.find('bug', project)
+ ruby = described_class.find('bug', project)
- expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate
+ expect(ruby).to be_a described_class
expect(ruby.name).to eq('bug')
end
end
describe '.by_category' do
it 'return array of templates' do
- all = subject.by_category('', project).map(&:name)
+ all = described_class.by_category('', project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
- expect(all).to include('template_test')
end
context 'when repo is bare or empty' do
- let(:empty_project) { create(:empty_project) }
- before { empty_project.add_user(user, Gitlab::Access::MASTER) }
+ let(:empty_project) { create(:project) }
it "returns empty array" do
- templates = subject.by_category('', empty_project)
+ templates = described_class.by_category('', empty_project)
+
expect(templates).to be_empty
end
end
@@ -63,24 +49,23 @@ describe Gitlab::Template::MergeRequestTemplate do
describe '#content' do
it 'loads the full file' do
- issue_template = subject.new('.gitlab/merge_request_templates/bug.md', project)
+ issue_template = described_class.new('.gitlab/merge_request_templates/bug.md', project)
expect(issue_template.name).to eq 'bug'
expect(issue_template.content).to eq('something valid')
end
it 'raises error when file is not found' do
- issue_template = subject.new('.gitlab/merge_request_templates/bugnot.md', project)
+ issue_template = described_class.new('.gitlab/merge_request_templates/bugnot.md', project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
context "when repo is empty" do
- let(:empty_project) { create(:empty_project) }
-
- before { empty_project.add_user(user, Gitlab::Access::MASTER) }
+ let(:empty_project) { create(:project) }
it "raises file not found" do
- issue_template = subject.new('.gitlab/merge_request_templates/not_existent.md', empty_project)
+ issue_template = described_class.new('.gitlab/merge_request_templates/not_existent.md', empty_project)
+
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
end
diff --git a/spec/lib/gitlab/untrusted_regexp_spec.rb b/spec/lib/gitlab/untrusted_regexp_spec.rb
new file mode 100644
index 00000000000..bed58d407ef
--- /dev/null
+++ b/spec/lib/gitlab/untrusted_regexp_spec.rb
@@ -0,0 +1,98 @@
+require 'spec_helper'
+
+describe Gitlab::UntrustedRegexp do
+ describe '#initialize' do
+ subject { described_class.new(pattern) }
+
+ context 'invalid regexp' do
+ let(:pattern) { '[' }
+
+ it { expect { subject }.to raise_error(RegexpError) }
+ end
+ end
+
+ describe '#replace_all' do
+ it 'replaces all instances of the match in a string' do
+ result = described_class.new('foo').replace_all('foo bar foo', 'oof')
+
+ expect(result).to eq('oof bar oof')
+ end
+ end
+
+ describe '#replace' do
+ it 'replaces the first instance of the match in a string' do
+ result = described_class.new('foo').replace('foo bar foo', 'oof')
+
+ expect(result).to eq('oof bar foo')
+ end
+ end
+
+ describe '#===' do
+ it 'returns true for a match' do
+ result = described_class.new('foo') === 'a foo here'
+
+ expect(result).to be_truthy
+ end
+
+ it 'returns false for no match' do
+ result = described_class.new('foo') === 'a bar here'
+
+ expect(result).to be_falsy
+ end
+ end
+
+ describe '#scan' do
+ subject { described_class.new(regexp).scan(text) }
+ context 'malicious regexp' do
+ let(:text) { malicious_text }
+ let(:regexp) { malicious_regexp }
+
+ include_examples 'malicious regexp'
+ end
+
+ context 'empty regexp' do
+ let(:regexp) { '' }
+ let(:text) { 'foo' }
+
+ it 'returns an array of nil matches' do
+ is_expected.to eq([nil, nil, nil, nil])
+ end
+ end
+
+ context 'empty capture group regexp' do
+ let(:regexp) { '()' }
+ let(:text) { 'foo' }
+
+ it 'returns an array of nil matches in an array' do
+ is_expected.to eq([[nil], [nil], [nil], [nil]])
+ end
+ end
+
+ context 'no capture group' do
+ let(:regexp) { '.+' }
+ let(:text) { 'foo' }
+
+ it 'returns the whole match' do
+ is_expected.to eq(['foo'])
+ end
+ end
+
+ context 'one capture group' do
+ let(:regexp) { '(f).+' }
+ let(:text) { 'foo' }
+
+ it 'returns the captured part' do
+ is_expected.to eq([%w[f]])
+ end
+ end
+
+ context 'two capture groups' do
+ let(:regexp) { '(f).(o)' }
+ let(:text) { 'foo' }
+
+ it 'returns the captured parts' do
+ is_expected.to eq([%w[f o]])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/upgrader_spec.rb b/spec/lib/gitlab/upgrader_spec.rb
index fcfd8d58b70..6106f13c774 100644
--- a/spec/lib/gitlab/upgrader_spec.rb
+++ b/spec/lib/gitlab/upgrader_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Gitlab::Upgrader, lib: true do
- let(:upgrader) { Gitlab::Upgrader.new }
+describe Gitlab::Upgrader do
+ let(:upgrader) { described_class.new }
let(:current_version) { Gitlab::VERSION }
describe 'current_version_raw' do
diff --git a/spec/lib/gitlab/uploads_transfer_spec.rb b/spec/lib/gitlab/uploads_transfer_spec.rb
new file mode 100644
index 00000000000..4275e7b015b
--- /dev/null
+++ b/spec/lib/gitlab/uploads_transfer_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Gitlab::UploadsTransfer do
+ it 'leaves avatar uploads where they are' do
+ project_with_avatar = create(:project, :with_avatar)
+
+ described_class.new.rename_namespace('project', 'project-renamed')
+
+ expect(File.exist?(project_with_avatar.avatar.path)).to be_truthy
+ end
+end
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index a504d299307..f18823b61ef 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::UrlBlocker, lib: true do
+describe Gitlab::UrlBlocker do
describe '#blocked_url?' do
it 'allows imports from configured web host and port' do
import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git"
@@ -20,6 +20,34 @@ describe Gitlab::UrlBlocker, lib: true do
expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git')).to be true
end
+ it 'returns true for a non-alphanumeric hostname' do
+ stub_resolv
+
+ aggregate_failures do
+ expect(described_class).to be_blocked_url('ssh://-oProxyCommand=whoami/a')
+
+ # The leading character here is a Unicode "soft hyphen"
+ expect(described_class).to be_blocked_url('ssh://­oProxyCommand=whoami/a')
+
+ # Unicode alphanumerics are allowed
+ expect(described_class).not_to be_blocked_url('ssh://ÄŸitlab.com/a')
+ end
+ end
+
+ it 'returns true for a non-alphanumeric username' do
+ stub_resolv
+
+ aggregate_failures do
+ expect(described_class).to be_blocked_url('ssh://-oProxyCommand=whoami@example.com/a')
+
+ # The leading character here is a Unicode "soft hyphen"
+ expect(described_class).to be_blocked_url('ssh://­oProxyCommand=whoami@example.com/a')
+
+ # Unicode alphanumerics are allowed
+ expect(described_class).not_to be_blocked_url('ssh://ÄŸitlab@example.com/a')
+ end
+ end
+
it 'returns true for invalid URL' do
expect(described_class.blocked_url?('http://:8080')).to be true
end
@@ -28,4 +56,10 @@ describe Gitlab::UrlBlocker, lib: true do
expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false
end
end
+
+ # Resolv does not support resolving UTF-8 domain names
+ # See https://bugs.ruby-lang.org/issues/4270
+ def stub_resolv
+ allow(Resolv).to receive(:getaddresses).and_return([])
+ end
end
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index 3fe8cf43934..b81749cf428 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::UrlBuilder, lib: true do
+describe Gitlab::UrlBuilder do
describe '.build' do
context 'when passing a Commit' do
it 'returns a proper URL' do
@@ -8,7 +8,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(commit)
- expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.path_with_namespace}/commit/#{commit.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.full_path}/commit/#{commit.id}"
end
end
@@ -18,7 +18,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(issue)
- expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.full_path}/issues/#{issue.iid}"
end
end
@@ -28,7 +28,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(merge_request)
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.full_path}/merge_requests/#{merge_request.iid}"
end
end
@@ -39,7 +39,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.full_path}/commit/#{note.commit_id}#note_#{note.id}"
end
end
@@ -49,7 +49,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.full_path}/commit/#{note.commit_id}#note_#{note.id}"
end
end
@@ -60,7 +60,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.full_path}/issues/#{issue.iid}#note_#{note.id}"
end
end
@@ -71,7 +71,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.full_path}/merge_requests/#{merge_request.iid}#note_#{note.id}"
end
end
@@ -82,7 +82,7 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.full_path}/merge_requests/#{merge_request.iid}#note_#{note.id}"
end
end
@@ -93,16 +93,27 @@ describe Gitlab::UrlBuilder, lib: true do
url = described_class.build(note)
- expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.path_with_namespace}/snippets/#{note.noteable_id}#note_#{note.id}"
+ expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.full_path}/snippets/#{note.noteable_id}#note_#{note.id}"
+ end
+ end
+
+ context 'on a PersonalSnippet' do
+ it 'returns a proper URL' do
+ personal_snippet = create(:personal_snippet)
+ note = build_stubbed(:note_on_personal_snippet, noteable: personal_snippet)
+
+ url = described_class.build(note)
+
+ expect(url).to eq "#{Settings.gitlab['url']}/snippets/#{note.noteable_id}#note_#{note.id}"
end
end
context 'on another object' do
it 'returns a proper URL' do
- project = build_stubbed(:empty_project)
+ project = build_stubbed(:project)
- expect { described_class.build(project) }.
- to raise_error(NotImplementedError, 'No URL builder defined for Project')
+ expect { described_class.build(project) }
+ .to raise_error(NotImplementedError, 'No URL builder defined for Project')
end
end
end
diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb
index fc144a2556a..308b1a128be 100644
--- a/spec/lib/gitlab/url_sanitizer_spec.rb
+++ b/spec/lib/gitlab/url_sanitizer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::UrlSanitizer, lib: true do
+describe Gitlab::UrlSanitizer do
let(:credentials) { { user: 'blah', password: 'password' } }
let(:url_sanitizer) do
described_class.new("https://github.com/me/project.git", credentials: credentials)
@@ -62,11 +62,6 @@ describe Gitlab::UrlSanitizer, lib: true do
end
end
- describe '.http_credentials_for_user' do
- it { expect(described_class.http_credentials_for_user(user)).to eq({ user: 'john.doe' }) }
- it { expect(described_class.http_credentials_for_user('foo')).to eq({}) }
- end
-
describe '#sanitized_url' do
it { expect(url_sanitizer.sanitized_url).to eq("https://github.com/me/project.git") }
end
@@ -76,7 +71,7 @@ describe Gitlab::UrlSanitizer, lib: true do
context 'when user is given to #initialize' do
let(:url_sanitizer) do
- described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user))
+ described_class.new("https://github.com/me/project.git", credentials: { user: user.username })
end
it { expect(url_sanitizer.credentials).to eq({ user: 'john.doe' }) }
@@ -94,7 +89,7 @@ describe Gitlab::UrlSanitizer, lib: true do
context 'when user is given to #initialize' do
let(:url_sanitizer) do
- described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user))
+ described_class.new("https://github.com/me/project.git", credentials: { user: user.username })
end
it { expect(url_sanitizer.full_url).to eq("https://john.doe@github.com/me/project.git") }
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 7f21288cf88..68429d792f2 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -1,12 +1,20 @@
require 'spec_helper'
describe Gitlab::UsageData do
- let!(:project) { create(:empty_project) }
- let!(:project2) { create(:empty_project) }
- let!(:board) { create(:board, project: project) }
+ let(:projects) { create_list(:project, 3) }
+ let!(:board) { create(:board, project: projects[0]) }
describe '#data' do
- subject { Gitlab::UsageData.data }
+ before do
+ create(:jira_service, project: projects[0])
+ create(:jira_service, project: projects[1])
+ create(:prometheus_service, project: projects[1])
+ create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true)
+ create(:service, project: projects[1], type: 'SlackService', active: true)
+ create(:service, project: projects[2], type: 'SlackService', active: true)
+ end
+
+ subject { described_class.data }
it "gathers usage data" do
expect(subject.keys).to match_array(%i(
@@ -17,6 +25,7 @@ describe Gitlab::UsageData do
edition
version
uuid
+ hostname
))
end
@@ -24,17 +33,20 @@ describe Gitlab::UsageData do
count_data = subject[:counts]
expect(count_data[:boards]).to eq(1)
- expect(count_data[:projects]).to eq(2)
+ expect(count_data[:projects]).to eq(3)
expect(count_data.keys).to match_array(%i(
boards
ci_builds
- ci_pipelines
+ ci_internal_pipelines
+ ci_external_pipelines
ci_runners
ci_triggers
+ ci_pipeline_schedules
deploy_keys
deployments
environments
+ in_review_folder
groups
issues
keys
@@ -44,21 +56,34 @@ describe Gitlab::UsageData do
milestones
notes
projects
+ projects_imported_from_github
+ projects_jira_active
+ projects_slack_notifications_active
+ projects_slack_slash_active
projects_prometheus_active
pages_domains
protected_branches
releases
- services
snippets
todos
uploads
web_hooks
))
end
+
+ it 'gathers projects data correctly' do
+ count_data = subject[:counts]
+
+ expect(count_data[:projects]).to eq(3)
+ expect(count_data[:projects_prometheus_active]).to eq(1)
+ expect(count_data[:projects_jira_active]).to eq(2)
+ expect(count_data[:projects_slack_notifications_active]).to eq(2)
+ expect(count_data[:projects_slack_slash_active]).to eq(1)
+ end
end
describe '#license_usage_data' do
- subject { Gitlab::UsageData.license_usage_data }
+ subject { described_class.license_usage_data }
it "gathers license data" do
expect(subject[:uuid]).to eq(current_application_settings.uuid)
diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb
index 611cdbbc865..cd97416bcc9 100644
--- a/spec/lib/gitlab/user_access_spec.rb
+++ b/spec/lib/gitlab/user_access_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Gitlab::UserAccess, lib: true do
- let(:access) { Gitlab::UserAccess.new(user, project: project) }
- let(:project) { create(:project) }
+describe Gitlab::UserAccess do
+ let(:access) { described_class.new(user, project: project) }
+ let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- describe 'can_push_to_branch?' do
+ describe '#can_push_to_branch?' do
describe 'push to none protected branch' do
it 'returns true if user is a master' do
project.team << [user, :master]
@@ -28,7 +28,7 @@ describe Gitlab::UserAccess, lib: true do
describe 'push to empty project' do
let(:empty_project) { create(:project_empty_repo) }
- let(:project_access) { Gitlab::UserAccess.new(user, project: empty_project) }
+ let(:project_access) { described_class.new(user, project: empty_project) }
it 'returns true if user is master' do
empty_project.team << [user, :master]
@@ -87,10 +87,10 @@ describe Gitlab::UserAccess, lib: true do
expect(access.can_push_to_branch?(branch.name)).to be_falsey
end
- it 'returns true if branch does not exist and user has permission to merge' do
+ it 'returns false if branch does not exist' do
project.team << [user, :developer]
- expect(access.can_push_to_branch?(not_existing_branch.name)).to be_truthy
+ expect(access.can_push_to_branch?(not_existing_branch.name)).to be_falsey
end
end
@@ -143,7 +143,7 @@ describe Gitlab::UserAccess, lib: true do
end
end
- describe 'can_create_tag?' do
+ 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)
@@ -211,4 +211,48 @@ describe Gitlab::UserAccess, lib: true do
end
end
end
+
+ describe '#can_delete_branch?' do
+ describe 'delete unprotected branch' do
+ it 'returns true if user is a master' do
+ project.add_user(user, :master)
+
+ expect(access.can_delete_branch?('random_branch')).to be_truthy
+ end
+
+ it 'returns true if user is a developer' do
+ project.add_user(user, :developer)
+
+ expect(access.can_delete_branch?('random_branch')).to be_truthy
+ end
+
+ it 'returns false if user is a reporter' do
+ project.add_user(user, :reporter)
+
+ expect(access.can_delete_branch?('random_branch')).to be_falsey
+ end
+ end
+
+ describe 'delete protected branch' do
+ let(:branch) { create(:protected_branch, project: project, name: "test") }
+
+ it 'returns true if user is a master' do
+ project.add_user(user, :master)
+
+ expect(access.can_delete_branch?(branch.name)).to be_truthy
+ end
+
+ it 'returns false if user is a developer' do
+ project.add_user(user, :developer)
+
+ expect(access.can_delete_branch?(branch.name)).to be_falsey
+ end
+
+ it 'returns false if user is a reporter' do
+ project.add_user(user, :reporter)
+
+ expect(access.can_delete_branch?(branch.name)).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/user_activities_spec.rb b/spec/lib/gitlab/user_activities_spec.rb
index 187d88c8c58..6bce2ee13cf 100644
--- a/spec/lib/gitlab/user_activities_spec.rb
+++ b/spec/lib/gitlab/user_activities_spec.rb
@@ -1,27 +1,27 @@
require 'spec_helper'
-describe Gitlab::UserActivities, :redis, lib: true do
+describe Gitlab::UserActivities, :clean_gitlab_redis_shared_state do
let(:now) { Time.now }
describe '.record' do
context 'with no time given' do
- it 'uses Time.now and records an activity in Redis' do
+ it 'uses Time.now and records an activity in SharedState' do
Timecop.freeze do
now # eager-load now
described_class.record(42)
end
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
end
end
context 'with a time given' do
- it 'uses the given time and records an activity in Redis' do
+ it 'uses the given time and records an activity in SharedState' do
described_class.record(42, now)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
end
@@ -31,30 +31,30 @@ describe Gitlab::UserActivities, :redis, lib: true do
describe '.delete' do
context 'with a single key' do
context 'and key exists' do
- it 'removes the pair from Redis' do
+ it 'removes the pair from SharedState' do
described_class.record(42, now)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
subject.delete(42)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
context 'and key does not exist' do
- it 'removes the pair from Redis' do
- Gitlab::Redis.with do |redis|
+ it 'removes the pair from SharedState' do
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
subject.delete(42)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
@@ -63,33 +63,33 @@ describe Gitlab::UserActivities, :redis, lib: true do
context 'with multiple keys' do
context 'and all keys exist' do
- it 'removes the pair from Redis' do
+ it 'removes the pair from SharedState' do
described_class.record(41, now)
described_class.record(42, now)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['41', now.to_i.to_s], ['42', now.to_i.to_s]]])
end
subject.delete(41, 42)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
context 'and some keys does not exist' do
- it 'removes the existing pair from Redis' do
+ it 'removes the existing pair from SharedState' do
described_class.record(42, now)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
subject.delete(41, 42)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index 56772409989..92787bb262e 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -1,5 +1,21 @@
-describe Gitlab::Utils, lib: true do
- delegate :to_boolean, to: :described_class
+require 'spec_helper'
+
+describe Gitlab::Utils do
+ delegate :to_boolean, :boolean_to_yes_no, :slugify, to: :described_class
+
+ describe '.slugify' do
+ {
+ 'TEST' => 'test',
+ 'project_with_underscores' => 'project-with-underscores',
+ 'namespace/project' => 'namespace-project',
+ 'a' * 70 => 'a' * 63,
+ 'test_trailing_' => 'test-trailing'
+ }.each do |original, expected|
+ it "slugifies #{original} to #{expected}" do
+ expect(slugify(original)).to eq(expected)
+ end
+ end
+ end
describe '.to_boolean' do
it 'accepts booleans' do
@@ -30,4 +46,11 @@ describe Gitlab::Utils, lib: true do
expect(to_boolean(nil)).to be_nil
end
end
+
+ describe '.boolean_to_yes_no' do
+ it 'converts booleans to Yes or No' do
+ expect(boolean_to_yes_no(true)).to eq('Yes')
+ expect(boolean_to_yes_no(false)).to eq('No')
+ end
+ end
end
diff --git a/spec/lib/gitlab/version_info_spec.rb b/spec/lib/gitlab/version_info_spec.rb
index 706ee9bec58..c8a1e433d59 100644
--- a/spec/lib/gitlab/version_info_spec.rb
+++ b/spec/lib/gitlab/version_info_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Gitlab::VersionInfo', lib: true, no_db: true do
+describe 'Gitlab::VersionInfo' do
before do
@unknown = Gitlab::VersionInfo.new
@v0_0_1 = Gitlab::VersionInfo.new(0, 0, 1)
@@ -50,8 +50,8 @@ describe 'Gitlab::VersionInfo', lib: true, no_db: true do
context 'unknown' do
it { expect(@unknown).not_to be @v0_0_1 }
it { expect(@unknown).not_to be Gitlab::VersionInfo.new }
- it { expect{@unknown > @v0_0_1}.to raise_error(ArgumentError) }
- it { expect{@unknown < @v0_0_1}.to raise_error(ArgumentError) }
+ it { expect {@unknown > @v0_0_1}.to raise_error(ArgumentError) }
+ it { expect {@unknown < @v0_0_1}.to raise_error(ArgumentError) }
end
context 'parse' do
diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb
index f2c152cdcd4..32a946ca034 100644
--- a/spec/lib/gitlab/view/presenter/base_spec.rb
+++ b/spec/lib/gitlab/view/presenter/base_spec.rb
@@ -26,7 +26,7 @@ describe Gitlab::View::Presenter::Base do
describe '#can?' do
context 'user is not allowed' do
it 'returns false' do
- presenter = presenter_class.new(build_stubbed(:empty_project))
+ presenter = presenter_class.new(build_stubbed(:project))
expect(presenter.can?(nil, :read_project)).to be_falsy
end
@@ -34,7 +34,7 @@ describe Gitlab::View::Presenter::Base do
context 'user is allowed' do
it 'returns true' do
- presenter = presenter_class.new(build_stubbed(:empty_project, :public))
+ presenter = presenter_class.new(build_stubbed(:project, :public))
expect(presenter.can?(nil, :read_project)).to be_truthy
end
@@ -42,9 +42,9 @@ describe Gitlab::View::Presenter::Base do
context 'subject is overriden' do
it 'returns true' do
- presenter = presenter_class.new(build_stubbed(:empty_project, :public))
+ presenter = presenter_class.new(build_stubbed(:project, :public))
- expect(presenter.can?(nil, :read_project, build_stubbed(:empty_project))).to be_falsy
+ expect(presenter.can?(nil, :read_project, build_stubbed(:project))).to be_falsy
end
end
end
diff --git a/spec/lib/gitlab/view/presenter/delegated_spec.rb b/spec/lib/gitlab/view/presenter/delegated_spec.rb
index e9d4af54389..940a2ce6ebd 100644
--- a/spec/lib/gitlab/view/presenter/delegated_spec.rb
+++ b/spec/lib/gitlab/view/presenter/delegated_spec.rb
@@ -18,8 +18,8 @@ describe Gitlab::View::Presenter::Delegated do
end
it 'raise an error if the presentee already respond to method' do
- expect { presenter_class.new(project, user: 'Jane Doe') }.
- to raise_error Gitlab::View::Presenter::CannotOverrideMethodError
+ expect { presenter_class.new(project, user: 'Jane Doe') }
+ .to raise_error Gitlab::View::Presenter::CannotOverrideMethodError
end
end
diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb
index 3255c6f1ef7..48a67773de9 100644
--- a/spec/lib/gitlab/visibility_level_spec.rb
+++ b/spec/lib/gitlab/visibility_level_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::VisibilityLevel, lib: true do
+describe Gitlab::VisibilityLevel do
describe '.level_value' do
it 'converts "public" to integer value' do
expect(described_class.level_value('public')).to eq(Gitlab::VisibilityLevel::PUBLIC)
@@ -18,4 +18,35 @@ describe Gitlab::VisibilityLevel, lib: true do
expect(described_class.level_value(100)).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
+
+ describe '.levels_for_user' do
+ it 'returns all levels for an admin' do
+ user = build(:user, :admin)
+
+ expect(described_class.levels_for_user(user))
+ .to eq([Gitlab::VisibilityLevel::PRIVATE,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it 'returns INTERNAL and PUBLIC for internal users' do
+ user = build(:user)
+
+ expect(described_class.levels_for_user(user))
+ .to eq([Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it 'returns PUBLIC for external users' do
+ user = build(:user, :external)
+
+ expect(described_class.levels_for_user(user))
+ .to eq([Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it 'returns PUBLIC when no user is given' do
+ expect(described_class.levels_for_user)
+ .to eq([Gitlab::VisibilityLevel::PUBLIC])
+ end
+ end
end
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index b703e9808a8..b66afafa174 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Workhorse, lib: true do
+describe Gitlab::Workhorse do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
@@ -63,13 +63,13 @@ describe Gitlab::Workhorse, lib: true do
end
context 'without ca_pem' do
- subject { Gitlab::Workhorse.terminal_websocket(terminal) }
+ subject { described_class.terminal_websocket(terminal) }
it { is_expected.to eq(workhorse) }
end
context 'with ca_pem' do
- subject { Gitlab::Workhorse.terminal_websocket(terminal(ca_pem: "foo")) }
+ subject { described_class.terminal_websocket(terminal(ca_pem: "foo")) }
it { is_expected.to eq(workhorse(ca_pem: "foo")) }
end
@@ -181,15 +181,31 @@ describe Gitlab::Workhorse, lib: true do
let(:user) { create(:user) }
let(:repo_path) { repository.path_to_repo }
let(:action) { 'info_refs' }
+ let(:params) do
+ { GL_ID: "user-#{user.id}", GL_REPOSITORY: "project-#{project.id}", RepoPath: repo_path }
+ end
+
+ subject { described_class.git_http_ok(repository, false, user, action) }
+
+ it { expect(subject).to include(params) }
+
+ context 'when is_wiki' do
+ let(:params) do
+ { GL_ID: "user-#{user.id}", GL_REPOSITORY: "wiki-#{project.id}", RepoPath: repo_path }
+ end
- subject { described_class.git_http_ok(repository, user, action) }
+ subject { described_class.git_http_ok(repository, true, user, action) }
- it { expect(subject).to include({ GL_ID: "user-#{user.id}", RepoPath: repo_path }) }
+ it { expect(subject).to include(params) }
+ end
context 'when Gitaly is enabled' do
let(:gitaly_params) do
{
- GitalyAddress: Gitlab::GitalyClient.get_address('default'),
+ GitalyServer: {
+ address: Gitlab::GitalyClient.address('default'),
+ token: Gitlab::GitalyClient.token('default')
+ }
}
end
@@ -199,9 +215,10 @@ describe Gitlab::Workhorse, lib: true do
it 'includes a Repository param' do
repo_param = { Repository: {
- path: repo_path,
storage_name: 'default',
relative_path: project.full_path + '.git',
+ git_object_directory: '',
+ git_alternate_object_directories: []
} }
expect(subject).to include(repo_param)
@@ -221,7 +238,8 @@ describe Gitlab::Workhorse, lib: true do
context 'when action is not enabled by feature flag' do
it 'does not include Gitaly params in the returned value' do
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag).and_return(false)
+ status_opt_out = Gitlab::GitalyClient::MigrationStatus::OPT_OUT
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag, status: status_opt_out).and_return(false)
expect(subject).not_to include(gitaly_params)
end
@@ -231,7 +249,7 @@ describe Gitlab::Workhorse, lib: true do
context "when git_receive_pack action is passed" do
let(:action) { 'git_receive_pack' }
- it { expect(subject).not_to include(gitaly_params) }
+ it { expect(subject).to include(gitaly_params) }
end
context "when info_refs action is passed" do
@@ -260,7 +278,7 @@ describe Gitlab::Workhorse, lib: true do
end
it 'set and notify' do
- expect_any_instance_of(Redis).to receive(:publish)
+ expect_any_instance_of(::Redis).to receive(:publish)
.with(described_class::NOTIFICATION_CHANNEL, "test-key=test-value")
subject
@@ -294,11 +312,49 @@ describe Gitlab::Workhorse, lib: true do
end
it 'does not notify' do
- expect_any_instance_of(Redis).not_to receive(:publish)
+ expect_any_instance_of(::Redis).not_to receive(:publish)
subject
end
end
end
end
+
+ describe '.send_git_blob' do
+ include FakeBlobHelpers
+
+ let(:blob) { fake_blob }
+
+ subject { described_class.send_git_blob(repository, blob) }
+
+ context 'when Gitaly workhorse_raw_show feature is enabled' do
+ it 'sets the header correctly' do
+ key, command, params = decode_workhorse_header(subject)
+
+ expect(key).to eq('Gitlab-Workhorse-Send-Data')
+ expect(command).to eq('git-blob')
+ expect(params).to eq({
+ 'GitalyServer' => {
+ address: Gitlab::GitalyClient.address(project.repository_storage),
+ token: Gitlab::GitalyClient.token(project.repository_storage)
+ },
+ 'GetBlobRequest' => {
+ repository: repository.gitaly_repository.to_h,
+ oid: blob.id,
+ limit: -1
+ }
+ }.deep_stringify_keys)
+ end
+ end
+
+ context 'when Gitaly workhorse_raw_show feature is disabled', skip_gitaly_mock: true do
+ it 'sets the header correctly' do
+ key, command, params = decode_workhorse_header(subject)
+
+ expect(key).to eq('Gitlab-Workhorse-Send-Data')
+ expect(command).to eq('git-blob')
+ expect(params).to eq('RepoPath' => repository.path_to_repo, 'BlobId' => blob.id)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb
index c4c107c9eea..f97136f0191 100644
--- a/spec/lib/gitlab_spec.rb
+++ b/spec/lib/gitlab_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe Gitlab, lib: true do
+describe Gitlab do
describe '.com?' do
it 'is true when on GitLab.com' do
stub_config_setting(url: 'https://gitlab.com')
diff --git a/spec/lib/json_web_token/rsa_token_spec.rb b/spec/lib/json_web_token/rsa_token_spec.rb
index 18726754517..d6edc964844 100644
--- a/spec/lib/json_web_token/rsa_token_spec.rb
+++ b/spec/lib/json_web_token/rsa_token_spec.rb
@@ -15,15 +15,19 @@ describe JSONWebToken::RSAToken do
let(:rsa_token) { described_class.new(nil) }
let(:rsa_encoded) { rsa_token.encoded }
- before { allow_any_instance_of(described_class).to receive(:key).and_return(rsa_key) }
+ before do
+ allow_any_instance_of(described_class).to receive(:key).and_return(rsa_key)
+ end
context 'token' do
context 'for valid key to be validated' do
- before { rsa_token['key'] = 'value' }
+ before do
+ rsa_token['key'] = 'value'
+ end
subject { JWT.decode(rsa_encoded, rsa_key) }
- it { expect{subject}.not_to raise_error }
+ it { expect {subject}.not_to raise_error }
it { expect(subject.first).to include('key' => 'value') }
it do
expect(subject.second).to eq(
@@ -37,7 +41,7 @@ describe JSONWebToken::RSAToken do
let(:new_key) { OpenSSL::PKey::RSA.generate(512) }
subject { JWT.decode(rsa_encoded, new_key) }
- it { expect{subject}.to raise_error(JWT::DecodeError) }
+ it { expect {subject}.to raise_error(JWT::DecodeError) }
end
end
end
diff --git a/spec/lib/json_web_token/token_spec.rb b/spec/lib/json_web_token/token_spec.rb
index 3d955e4d774..d7e7560d962 100644
--- a/spec/lib/json_web_token/token_spec.rb
+++ b/spec/lib/json_web_token/token_spec.rb
@@ -3,7 +3,10 @@ describe JSONWebToken::Token do
context 'custom parameters' do
let(:value) { 'value' }
- before { token[:key] = value }
+
+ before do
+ token[:key] = value
+ end
it { expect(token[:key]).to eq(value) }
it { expect(token.payload).to include(key: value) }
diff --git a/spec/lib/light_url_builder_spec.rb b/spec/lib/light_url_builder_spec.rb
deleted file mode 100644
index 3fe8cf43934..00000000000
--- a/spec/lib/light_url_builder_spec.rb
+++ /dev/null
@@ -1,119 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::UrlBuilder, lib: true do
- describe '.build' do
- context 'when passing a Commit' do
- it 'returns a proper URL' do
- commit = build_stubbed(:commit)
-
- url = described_class.build(commit)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.path_with_namespace}/commit/#{commit.id}"
- end
- end
-
- context 'when passing an Issue' do
- it 'returns a proper URL' do
- issue = build_stubbed(:issue, iid: 42)
-
- url = described_class.build(issue)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}"
- end
- end
-
- context 'when passing a MergeRequest' do
- it 'returns a proper URL' do
- merge_request = build_stubbed(:merge_request, iid: 42)
-
- url = described_class.build(merge_request)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}"
- end
- end
-
- context 'when passing a Note' do
- context 'on a Commit' do
- it 'returns a proper URL' do
- note = build_stubbed(:note_on_commit)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
- end
- end
-
- context 'on a Commit Diff' do
- it 'returns a proper URL' do
- note = build_stubbed(:diff_note_on_commit)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}"
- end
- end
-
- context 'on an Issue' do
- it 'returns a proper URL' do
- issue = create(:issue, iid: 42)
- note = build_stubbed(:note_on_issue, noteable: issue)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}#note_#{note.id}"
- end
- end
-
- context 'on a MergeRequest' do
- it 'returns a proper URL' do
- merge_request = create(:merge_request, iid: 42)
- note = build_stubbed(:note_on_merge_request, noteable: merge_request)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
- end
- end
-
- context 'on a MergeRequest Diff' do
- it 'returns a proper URL' do
- merge_request = create(:merge_request, iid: 42)
- note = build_stubbed(:diff_note_on_merge_request, noteable: merge_request)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}"
- end
- end
-
- context 'on a ProjectSnippet' do
- it 'returns a proper URL' do
- project_snippet = create(:project_snippet)
- note = build_stubbed(:note_on_project_snippet, noteable: project_snippet)
-
- url = described_class.build(note)
-
- expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.path_with_namespace}/snippets/#{note.noteable_id}#note_#{note.id}"
- end
- end
-
- context 'on another object' do
- it 'returns a proper URL' do
- project = build_stubbed(:empty_project)
-
- expect { described_class.build(project) }.
- to raise_error(NotImplementedError, 'No URL builder defined for Project')
- end
- end
- end
-
- context 'when passing a WikiPage' do
- it 'returns a proper URL' do
- wiki_page = build(:wiki_page)
- url = described_class.build(wiki_page)
-
- expect(url).to eq "#{Gitlab.config.gitlab.url}#{wiki_page.wiki.wiki_base_path}/#{wiki_page.slug}"
- end
- end
- end
-end
diff --git a/spec/lib/mattermost/command_spec.rb b/spec/lib/mattermost/command_spec.rb
index 4b5938edeb9..369e7b181b9 100644
--- a/spec/lib/mattermost/command_spec.rb
+++ b/spec/lib/mattermost/command_spec.rb
@@ -6,8 +6,8 @@ describe Mattermost::Command do
before do
Mattermost::Session.base_uri('http://mattermost.example.com')
- allow_any_instance_of(Mattermost::Client).to receive(:with_session).
- and_yield(Mattermost::Session.new(nil))
+ allow_any_instance_of(Mattermost::Client).to receive(:with_session)
+ .and_yield(Mattermost::Session.new(nil))
end
describe '#create' do
@@ -20,12 +20,12 @@ describe Mattermost::Command do
context 'for valid trigger word' do
before do
- stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create').
- with(body: {
+ stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create')
+ .with(body: {
team_id: 'abc',
trigger: 'gitlab'
- }.to_json).
- to_return(
+ }.to_json)
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: { token: 'token' }.to_json
@@ -39,8 +39,8 @@ describe Mattermost::Command do
context 'for error message' do
before do
- stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create').
- to_return(
+ stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create')
+ .to_return(
status: 500,
headers: { 'Content-Type' => 'application/json' },
body: {
diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb
index 74d12e37181..3db19d06305 100644
--- a/spec/lib/mattermost/session_spec.rb
+++ b/spec/lib/mattermost/session_spec.rb
@@ -20,9 +20,10 @@ describe Mattermost::Session, type: :request do
describe '#with session' do
let(:location) { 'http://location.tld' }
+ let(:cookie_header) {'MMOAUTH=taskik8az7rq8k6rkpuas7htia; Path=/;'}
let!(:stub) do
- WebMock.stub_request(:get, "#{mattermost_url}/api/v3/oauth/gitlab/login").
- to_return(headers: { 'location' => location }, status: 307)
+ WebMock.stub_request(:get, "#{mattermost_url}/api/v3/oauth/gitlab/login")
+ .to_return(headers: { 'location' => location, 'Set-Cookie' => cookie_header }, status: 307)
end
context 'without oauth uri' do
@@ -34,9 +35,9 @@ describe Mattermost::Session, type: :request do
context 'with oauth_uri' do
let!(:doorkeeper) do
Doorkeeper::Application.create(
- name: "GitLab Mattermost",
+ name: 'GitLab Mattermost',
redirect_uri: "#{mattermost_url}/signup/gitlab/complete\n#{mattermost_url}/login/gitlab/complete",
- scopes: "")
+ scopes: '')
end
context 'without token_uri' do
@@ -60,9 +61,9 @@ describe Mattermost::Session, type: :request do
end
before do
- WebMock.stub_request(:get, "#{mattermost_url}/signup/gitlab/complete").
- with(query: hash_including({ 'state' => state })).
- to_return do |request|
+ WebMock.stub_request(:get, "#{mattermost_url}/signup/gitlab/complete")
+ .with(query: hash_including({ 'state' => state }))
+ .to_return do |request|
post "/oauth/token",
client_id: doorkeeper.uid,
client_secret: doorkeeper.secret,
@@ -75,8 +76,8 @@ describe Mattermost::Session, type: :request do
end
end
- WebMock.stub_request(:post, "#{mattermost_url}/api/v3/users/logout").
- to_return(headers: { Authorization: 'token thisworksnow' }, status: 200)
+ WebMock.stub_request(:post, "#{mattermost_url}/api/v3/users/logout")
+ .to_return(headers: { Authorization: 'token thisworksnow' }, status: 200)
end
it 'can setup a session' do
diff --git a/spec/lib/mattermost/team_spec.rb b/spec/lib/mattermost/team_spec.rb
index ac493fdb20f..e638ad7a2c9 100644
--- a/spec/lib/mattermost/team_spec.rb
+++ b/spec/lib/mattermost/team_spec.rb
@@ -4,8 +4,8 @@ describe Mattermost::Team do
before do
Mattermost::Session.base_uri('http://mattermost.example.com')
- allow_any_instance_of(Mattermost::Client).to receive(:with_session).
- and_yield(Mattermost::Session.new(nil))
+ allow_any_instance_of(Mattermost::Client).to receive(:with_session)
+ .and_yield(Mattermost::Session.new(nil))
end
describe '#all' do
@@ -30,8 +30,8 @@ describe Mattermost::Team do
end
before do
- stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all').
- to_return(
+ stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all')
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: response.to_json
@@ -45,8 +45,8 @@ describe Mattermost::Team do
context 'for error message' do
before do
- stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all').
- to_return(
+ stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all')
+ .to_return(
status: 500,
headers: { 'Content-Type' => 'application/json' },
body: {
diff --git a/spec/lib/repository_cache_spec.rb b/spec/lib/repository_cache_spec.rb
index 5892f3481a4..8b0c7254b5e 100644
--- a/spec/lib/repository_cache_spec.rb
+++ b/spec/lib/repository_cache_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe RepositoryCache, lib: true do
- let(:project) { create(:empty_project) }
+describe RepositoryCache do
+ let(:project) { create(:project) }
let(:backend) { double('backend').as_null_object }
- let(:cache) { RepositoryCache.new('example', project.id, backend) }
+ let(:cache) { described_class.new('example', project.id, backend) }
describe '#cache_key' do
it 'includes the namespace' do
diff --git a/spec/lib/rspec_flaky/example_spec.rb b/spec/lib/rspec_flaky/example_spec.rb
new file mode 100644
index 00000000000..5b4fd5ddf3e
--- /dev/null
+++ b/spec/lib/rspec_flaky/example_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe RspecFlaky::Example do
+ let(:example_attrs) do
+ {
+ id: 'spec/foo/bar_spec.rb:2',
+ metadata: {
+ file_path: 'spec/foo/bar_spec.rb',
+ line_number: 2,
+ full_description: 'hello world'
+ },
+ execution_result: double(status: 'passed', exception: 'BOOM!'),
+ attempts: 1
+ }
+ end
+ let(:rspec_example) { double(example_attrs) }
+
+ describe '#initialize' do
+ shared_examples 'a valid Example instance' do
+ it 'returns valid attributes' do
+ example = described_class.new(args)
+
+ expect(example.example_id).to eq(example_attrs[:id])
+ end
+ end
+
+ context 'when given an Rspec::Core::Example that responds to #example' do
+ let(:args) { double(example: rspec_example) }
+
+ it_behaves_like 'a valid Example instance'
+ end
+
+ context 'when given an Rspec::Core::Example that does not respond to #example' do
+ let(:args) { rspec_example }
+
+ it_behaves_like 'a valid Example instance'
+ end
+ end
+
+ subject { described_class.new(rspec_example) }
+
+ describe '#uid' do
+ it 'returns a hash of the full description' do
+ expect(subject.uid).to eq(Digest::MD5.hexdigest("#{subject.description}-#{subject.file}"))
+ end
+ end
+
+ describe '#example_id' do
+ it 'returns the ID of the RSpec::Core::Example' do
+ expect(subject.example_id).to eq(rspec_example.id)
+ end
+ end
+
+ describe '#attempts' do
+ it 'returns the attempts of the RSpec::Core::Example' do
+ expect(subject.attempts).to eq(rspec_example.attempts)
+ end
+ end
+
+ describe '#file' do
+ it 'returns the metadata[:file_path] of the RSpec::Core::Example' do
+ expect(subject.file).to eq(rspec_example.metadata[:file_path])
+ end
+ end
+
+ describe '#line' do
+ it 'returns the metadata[:line_number] of the RSpec::Core::Example' do
+ expect(subject.line).to eq(rspec_example.metadata[:line_number])
+ end
+ end
+
+ describe '#description' do
+ it 'returns the metadata[:full_description] of the RSpec::Core::Example' do
+ expect(subject.description).to eq(rspec_example.metadata[:full_description])
+ end
+ end
+
+ describe '#status' do
+ it 'returns the execution_result.status of the RSpec::Core::Example' do
+ expect(subject.status).to eq(rspec_example.execution_result.status)
+ end
+ end
+
+ describe '#exception' do
+ it 'returns the execution_result.exception of the RSpec::Core::Example' do
+ expect(subject.exception).to eq(rspec_example.execution_result.exception)
+ end
+ end
+end
diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/lib/rspec_flaky/flaky_example_spec.rb
new file mode 100644
index 00000000000..cbfc1e538ab
--- /dev/null
+++ b/spec/lib/rspec_flaky/flaky_example_spec.rb
@@ -0,0 +1,104 @@
+require 'spec_helper'
+
+describe RspecFlaky::FlakyExample do
+ let(:flaky_example_attrs) do
+ {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 2345,
+ last_attempts_count: 2,
+ flaky_reports: 1
+ }
+ end
+ let(:example_attrs) do
+ {
+ uid: 'abc123',
+ example_id: flaky_example_attrs[:example_id],
+ file: flaky_example_attrs[:file],
+ line: flaky_example_attrs[:line],
+ description: flaky_example_attrs[:description],
+ status: 'passed',
+ exception: 'BOOM!',
+ attempts: flaky_example_attrs[:last_attempts_count]
+ }
+ end
+ let(:example) { double(example_attrs) }
+
+ describe '#initialize' do
+ shared_examples 'a valid FlakyExample instance' do
+ it 'returns valid attributes' do
+ flaky_example = described_class.new(args)
+
+ expect(flaky_example.uid).to eq(flaky_example_attrs[:uid])
+ expect(flaky_example.example_id).to eq(flaky_example_attrs[:example_id])
+ end
+ end
+
+ context 'when given an Rspec::Example' do
+ let(:args) { example }
+
+ it_behaves_like 'a valid FlakyExample instance'
+ end
+
+ context 'when given a hash' do
+ let(:args) { flaky_example_attrs }
+
+ it_behaves_like 'a valid FlakyExample instance'
+ end
+ end
+
+ describe '#to_h' do
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_PROJECT_URL', nil)
+ stub_env('CI_JOB_ID', nil)
+ end
+
+ shared_examples 'a valid FlakyExample hash' do
+ let(:additional_attrs) { {} }
+
+ it 'returns a valid hash' do
+ flaky_example = described_class.new(args)
+ final_hash = flaky_example_attrs
+ .merge(last_flaky_at: instance_of(Time), last_flaky_job: nil)
+ .merge(additional_attrs)
+
+ expect(flaky_example.to_h).to match(hash_including(final_hash))
+ end
+ end
+
+ context 'when given an Rspec::Example' do
+ let(:args) { example }
+
+ context 'when run locally' do
+ it_behaves_like 'a valid FlakyExample hash' do
+ let(:additional_attrs) do
+ { first_flaky_at: instance_of(Time) }
+ end
+ end
+ end
+
+ context 'when run on the CI' do
+ before do
+ stub_env('CI_PROJECT_URL', 'https://gitlab.com/gitlab-org/gitlab-ce')
+ stub_env('CI_JOB_ID', 42)
+ end
+
+ it_behaves_like 'a valid FlakyExample hash' do
+ let(:additional_attrs) do
+ { first_flaky_at: instance_of(Time), last_flaky_job: "https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/42" }
+ end
+ end
+ end
+ end
+
+ context 'when given a hash' do
+ let(:args) { flaky_example_attrs }
+
+ it_behaves_like 'a valid FlakyExample hash'
+ end
+ end
+end
diff --git a/spec/lib/rspec_flaky/listener_spec.rb b/spec/lib/rspec_flaky/listener_spec.rb
new file mode 100644
index 00000000000..0e193bf408b
--- /dev/null
+++ b/spec/lib/rspec_flaky/listener_spec.rb
@@ -0,0 +1,178 @@
+require 'spec_helper'
+
+describe RspecFlaky::Listener do
+ let(:flaky_example_report) do
+ {
+ 'abc123' => {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: instance_of(Time),
+ last_attempts_count: 2,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ }
+ end
+ let(:example_attrs) do
+ {
+ id: 'spec/foo/baz_spec.rb:3',
+ metadata: {
+ file_path: 'spec/foo/baz_spec.rb',
+ line_number: 3,
+ full_description: 'hello GitLab'
+ },
+ execution_result: double(status: 'passed', exception: nil)
+ }
+ end
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_PROJECT_URL', nil)
+ stub_env('CI_JOB_ID', nil)
+ end
+
+ describe '#initialize' do
+ shared_examples 'a valid Listener instance' do
+ let(:expected_all_flaky_examples) { {} }
+
+ it 'returns a valid Listener instance' do
+ listener = described_class.new
+
+ expect(listener.to_report(listener.all_flaky_examples))
+ .to match(hash_including(expected_all_flaky_examples))
+ expect(listener.new_flaky_examples).to eq({})
+ end
+ end
+
+ context 'when no report file exists' do
+ it_behaves_like 'a valid Listener instance'
+ end
+
+ context 'when a report file exists and set by ALL_FLAKY_RSPEC_REPORT_PATH' do
+ let(:report_file) do
+ Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
+ f.write(JSON.pretty_generate(flaky_example_report))
+ f.rewind
+ end
+ end
+
+ before do
+ stub_env('ALL_FLAKY_RSPEC_REPORT_PATH', report_file.path)
+ end
+
+ after do
+ report_file.close
+ report_file.unlink
+ end
+
+ it_behaves_like 'a valid Listener instance' do
+ let(:expected_all_flaky_examples) { flaky_example_report }
+ end
+ end
+ end
+
+ describe '#example_passed' do
+ let(:rspec_example) { double(example_attrs) }
+ let(:notification) { double(example: rspec_example) }
+
+ shared_examples 'a non-flaky example' do
+ it 'does not change the flaky examples hash' do
+ expect { subject.example_passed(notification) }
+ .not_to change { subject.all_flaky_examples }
+ end
+ end
+
+ describe 'when the RSpec example does not respond to attempts' do
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 1 attempt' do
+ let(:rspec_example) { double(example_attrs.merge(attempts: 1)) }
+
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 2 attempts' do
+ let(:rspec_example) { double(example_attrs.merge(attempts: 2)) }
+ let(:expected_new_flaky_example) do
+ {
+ example_id: 'spec/foo/baz_spec.rb:3',
+ file: 'spec/foo/baz_spec.rb',
+ line: 3,
+ description: 'hello GitLab',
+ first_flaky_at: instance_of(Time),
+ last_flaky_at: instance_of(Time),
+ last_attempts_count: 2,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ end
+
+ it 'does not change the flaky examples hash' do
+ expect { subject.example_passed(notification) }
+ .to change { subject.all_flaky_examples }
+
+ new_example = RspecFlaky::Example.new(rspec_example)
+
+ expect(subject.all_flaky_examples[new_example.uid].to_h)
+ .to match(hash_including(expected_new_flaky_example))
+ end
+ end
+ end
+
+ describe '#dump_summary' do
+ let(:rspec_example) { double(example_attrs) }
+ let(:notification) { double(example: rspec_example) }
+
+ context 'when a report file path is set by ALL_FLAKY_RSPEC_REPORT_PATH' do
+ let(:report_file_path) { Rails.root.join('tmp', 'rspec_flaky_report.json') }
+
+ before do
+ stub_env('ALL_FLAKY_RSPEC_REPORT_PATH', report_file_path)
+ FileUtils.rm(report_file_path) if File.exist?(report_file_path)
+ end
+
+ after do
+ FileUtils.rm(report_file_path) if File.exist?(report_file_path)
+ end
+
+ context 'when FLAKY_RSPEC_GENERATE_REPORT == "false"' do
+ before do
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', 'false')
+ end
+
+ it 'does not write the report file' do
+ subject.example_passed(notification)
+
+ subject.dump_summary(nil)
+
+ expect(File.exist?(report_file_path)).to be(false)
+ end
+ end
+
+ context 'when FLAKY_RSPEC_GENERATE_REPORT == "true"' do
+ before do
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', 'true')
+ end
+
+ it 'writes the report file' do
+ subject.example_passed(notification)
+
+ subject.dump_summary(nil)
+
+ expect(File.exist?(report_file_path)).to be(true)
+ end
+ end
+ end
+ end
+
+ describe '#to_report' do
+ it 'transforms the internal hash to a JSON-ready hash' do
+ expect(subject.to_report('abc123' => RspecFlaky::FlakyExample.new(flaky_example_report['abc123'])))
+ .to match(hash_including(flaky_example_report))
+ end
+ end
+end
diff --git a/spec/lib/system_check/simple_executor_spec.rb b/spec/lib/system_check/simple_executor_spec.rb
new file mode 100644
index 00000000000..4de5da984ba
--- /dev/null
+++ b/spec/lib/system_check/simple_executor_spec.rb
@@ -0,0 +1,247 @@
+require 'spec_helper'
+require 'rake_helper'
+
+describe SystemCheck::SimpleExecutor do
+ class SimpleCheck < SystemCheck::BaseCheck
+ set_name 'my simple check'
+
+ def check?
+ true
+ end
+ end
+
+ class OtherCheck < SystemCheck::BaseCheck
+ set_name 'other check'
+
+ def check?
+ false
+ end
+
+ def show_error
+ $stdout.puts 'this is an error text'
+ end
+ end
+
+ class SkipCheck < SystemCheck::BaseCheck
+ set_name 'skip check'
+ set_skip_reason 'this is a skip reason'
+
+ def skip?
+ true
+ end
+
+ def check?
+ raise 'should not execute this'
+ end
+ end
+
+ class MultiCheck < SystemCheck::BaseCheck
+ set_name 'multi check'
+
+ def multi_check
+ $stdout.puts 'this is a multi output check'
+ end
+
+ def check?
+ raise 'should not execute this'
+ end
+ end
+
+ class SkipMultiCheck < SystemCheck::BaseCheck
+ set_name 'skip multi check'
+
+ def skip?
+ true
+ end
+
+ def multi_check
+ raise 'should not execute this'
+ end
+ end
+
+ class RepairCheck < SystemCheck::BaseCheck
+ set_name 'repair check'
+
+ def check?
+ false
+ end
+
+ def repair!
+ true
+ end
+
+ def show_error
+ $stdout.puts 'this is an error message'
+ end
+ end
+
+ class BugousCheck < SystemCheck::BaseCheck
+ CustomError = Class.new(StandardError)
+ set_name 'my bugous check'
+
+ def check?
+ raise CustomError, 'omg'
+ end
+ end
+
+ before do
+ @rainbow = Rainbow.enabled
+ Rainbow.enabled = false
+ end
+
+ after do
+ Rainbow.enabled = @rainbow
+ end
+
+ describe '#component' do
+ it 'returns stored component name' do
+ expect(subject.component).to eq('Test')
+ end
+ end
+
+ describe '#checks' do
+ before do
+ subject << SimpleCheck
+ end
+
+ it 'returns a set of classes' do
+ expect(subject.checks).to include(SimpleCheck)
+ end
+ end
+
+ describe '#<<' do
+ before do
+ subject << SimpleCheck
+ end
+
+ it 'appends a new check to the Set' do
+ subject << OtherCheck
+ stored_checks = subject.checks.to_a
+
+ expect(stored_checks.first).to eq(SimpleCheck)
+ expect(stored_checks.last).to eq(OtherCheck)
+ end
+
+ it 'inserts unique itens only' do
+ subject << SimpleCheck
+
+ expect(subject.checks.size).to eq(1)
+ end
+ end
+
+ subject { described_class.new('Test') }
+
+ describe '#execute' do
+ before do
+ silence_output
+
+ subject << SimpleCheck
+ subject << OtherCheck
+ end
+
+ it 'runs included checks' do
+ expect(subject).to receive(:run_check).with(SimpleCheck)
+ expect(subject).to receive(:run_check).with(OtherCheck)
+
+ subject.execute
+ end
+ end
+
+ describe '#run_check' do
+ it 'prints check name' do
+ expect(SimpleCheck).to receive(:display_name).and_call_original
+ expect { subject.run_check(SimpleCheck) }.to output(/my simple check/).to_stdout
+ end
+
+ context 'when check pass' do
+ it 'prints yes' do
+ expect_any_instance_of(SimpleCheck).to receive(:check?).and_call_original
+ expect { subject.run_check(SimpleCheck) }.to output(/ \.\.\. yes/).to_stdout
+ end
+ end
+
+ context 'when check fails' do
+ it 'prints no' do
+ expect_any_instance_of(OtherCheck).to receive(:check?).and_call_original
+ expect { subject.run_check(OtherCheck) }.to output(/ \.\.\. no/).to_stdout
+ end
+
+ it 'displays error message from #show_error' do
+ expect_any_instance_of(OtherCheck).to receive(:show_error).and_call_original
+ expect { subject.run_check(OtherCheck) }.to output(/this is an error text/).to_stdout
+ end
+
+ context 'when check implements #repair!' do
+ it 'executes #repair!' do
+ expect_any_instance_of(RepairCheck).to receive(:repair!)
+
+ subject.run_check(RepairCheck)
+ end
+
+ context 'when repair succeeds' do
+ it 'does not execute #show_error' do
+ expect_any_instance_of(RepairCheck).to receive(:repair!).and_call_original
+ expect_any_instance_of(RepairCheck).not_to receive(:show_error)
+
+ subject.run_check(RepairCheck)
+ end
+ end
+
+ context 'when repair fails' do
+ it 'does not execute #show_error' do
+ expect_any_instance_of(RepairCheck).to receive(:repair!) { false }
+ expect_any_instance_of(RepairCheck).to receive(:show_error)
+
+ subject.run_check(RepairCheck)
+ end
+ end
+ end
+ end
+
+ context 'when check implements skip?' do
+ it 'executes #skip? method' do
+ expect_any_instance_of(SkipCheck).to receive(:skip?).and_call_original
+
+ subject.run_check(SkipCheck)
+ end
+
+ it 'displays #skip_reason' do
+ expect { subject.run_check(SkipCheck) }.to output(/this is a skip reason/).to_stdout
+ end
+
+ it 'does not execute #check when #skip? is true' do
+ expect_any_instance_of(SkipCheck).not_to receive(:check?)
+
+ subject.run_check(SkipCheck)
+ end
+ end
+
+ context 'when implements a #multi_check' do
+ it 'executes #multi_check method' do
+ expect_any_instance_of(MultiCheck).to receive(:multi_check)
+
+ subject.run_check(MultiCheck)
+ end
+
+ it 'does not execute #check method' do
+ expect_any_instance_of(MultiCheck).not_to receive(:check)
+
+ subject.run_check(MultiCheck)
+ end
+
+ context 'when check implements #skip?' do
+ it 'executes #skip? method' do
+ expect_any_instance_of(SkipMultiCheck).to receive(:skip?).and_call_original
+
+ subject.run_check(SkipMultiCheck)
+ end
+ end
+ end
+
+ context 'when there is an exception' do
+ it 'rescues the exception' do
+ expect { subject.run_check(BugousCheck) }.not_to raise_exception
+ end
+ end
+ end
+end
diff --git a/spec/lib/system_check_spec.rb b/spec/lib/system_check_spec.rb
new file mode 100644
index 00000000000..4d9e17fa6ec
--- /dev/null
+++ b/spec/lib/system_check_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+require 'rake_helper'
+
+describe SystemCheck do
+ class SimpleCheck < SystemCheck::BaseCheck
+ def check?
+ true
+ end
+ end
+
+ class OtherCheck < SystemCheck::BaseCheck
+ def check?
+ false
+ end
+ end
+
+ before do
+ silence_output
+ end
+
+ describe '.run' do
+ subject { described_class }
+
+ it 'detects execution of SimpleCheck' do
+ is_expected.to execute_check(SimpleCheck)
+
+ subject.run('Test', [SimpleCheck])
+ end
+
+ it 'detects exclusion of OtherCheck in execution' do
+ is_expected.not_to execute_check(OtherCheck)
+
+ subject.run('Test', [SimpleCheck])
+ end
+ end
+end
diff --git a/spec/mailers/abuse_report_mailer_spec.rb b/spec/mailers/abuse_report_mailer_spec.rb
index eb433c38873..bda892083b3 100644
--- a/spec/mailers/abuse_report_mailer_spec.rb
+++ b/spec/mailers/abuse_report_mailer_spec.rb
@@ -30,8 +30,8 @@ describe AbuseReportMailer do
it 'returns early' do
stub_application_setting(admin_notification_email: nil)
- expect { described_class.notify(spy).deliver_now }.
- not_to change { ActionMailer::Base.deliveries.count }
+ expect { described_class.notify(spy).deliver_now }
+ .not_to change { ActionMailer::Base.deliveries.count }
end
end
end
diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb
index e22858d1d8f..2ad572bb5c7 100644
--- a/spec/mailers/emails/merge_requests_spec.rb
+++ b/spec/mailers/emails/merge_requests_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require 'email_spec'
-describe Notify, "merge request notifications" do
+describe Emails::MergeRequests do
include EmailSpec::Matchers
describe "#resolved_all_discussions_email" do
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index 5ca936f28f0..09e5094cf84 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require 'email_spec'
-describe Notify do
+describe Emails::Profile do
include EmailSpec::Matchers
include_context 'gitlab email notification'
@@ -15,106 +15,134 @@ describe Notify do
end
end
- describe 'profile notifications' do
- describe 'for new users, the email' do
- let(:example_site_path) { root_path }
- let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) }
- let(:token) { 'kETLwRaayvigPq_x3SNM' }
+ describe 'for new users, the email' do
+ let(:example_site_path) { root_path }
+ let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) }
+ let(:token) { 'kETLwRaayvigPq_x3SNM' }
- subject { Notify.new_user_email(new_user.id, token) }
+ subject { Notify.new_user_email(new_user.id, token) }
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'a new user email'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like 'a user cannot unsubscribe through footer link'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'a new user email'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
- it 'contains the password text' do
- is_expected.to have_body_text /Click here to set your password/
- end
+ it 'contains the password text' do
+ is_expected.to have_body_text /Click here to set your password/
+ end
- it 'includes a link for user to set password' do
- params = "reset_password_token=#{token}"
- is_expected.to have_body_text(
- %r{http://#{Gitlab.config.gitlab.host}(:\d+)?/users/password/edit\?#{params}}
- )
- end
+ it 'includes a link for user to set password' do
+ params = "reset_password_token=#{token}"
+ is_expected.to have_body_text(
+ %r{http://#{Gitlab.config.gitlab.host}(:\d+)?/users/password/edit\?#{params}}
+ )
+ end
- it 'explains the reset link expiration' do
- is_expected.to have_body_text(/This link is valid for \d+ (hours?|days?)/)
- is_expected.to have_body_text(new_user_password_url)
- is_expected.to have_body_text(/\?user_email=.*%40.*/)
- end
+ it 'explains the reset link expiration' do
+ is_expected.to have_body_text(/This link is valid for \d+ (hours?|days?)/)
+ is_expected.to have_body_text(new_user_password_url)
+ is_expected.to have_body_text(/\?user_email=.*%40.*/)
end
+ end
- describe 'for users that signed up, the email' do
- let(:example_site_path) { root_path }
- let(:new_user) { create(:user, email: new_user_address, password: "securePassword") }
+ describe 'for users that signed up, the email' do
+ let(:example_site_path) { root_path }
+ let(:new_user) { create(:user, email: new_user_address, password: "securePassword") }
- subject { Notify.new_user_email(new_user.id) }
+ subject { Notify.new_user_email(new_user.id) }
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'a new user email'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like 'a user cannot unsubscribe through footer link'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'a new user email'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
- it 'does not contain the new user\'s password' do
- is_expected.not_to have_body_text /password/
- end
+ it 'does not contain the new user\'s password' do
+ is_expected.not_to have_body_text /password/
end
+ end
- describe 'user added ssh key' do
- let(:key) { create(:personal_key) }
+ describe 'user added ssh key' do
+ let(:key) { create(:personal_key) }
- subject { Notify.new_ssh_key_email(key.id) }
+ subject { Notify.new_ssh_key_email(key.id) }
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like 'a user cannot unsubscribe through footer link'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
- it 'is sent to the new user' do
- is_expected.to deliver_to key.user.email
- end
+ it 'is sent to the new user' do
+ is_expected.to deliver_to key.user.email
+ end
- it 'has the correct subject' do
- is_expected.to have_subject /^SSH key was added to your account$/i
- end
+ it 'has the correct subject' do
+ is_expected.to have_subject /^SSH key was added to your account$/i
+ end
- it 'contains the new ssh key title' do
- is_expected.to have_body_text /#{key.title}/
- end
+ it 'contains the new ssh key title' do
+ is_expected.to have_body_text /#{key.title}/
+ end
- it 'includes a link to ssh keys page' do
- is_expected.to have_body_text /#{profile_keys_path}/
- end
+ it 'includes a link to ssh keys page' do
+ is_expected.to have_body_text /#{profile_keys_path}/
+ end
- context 'with SSH key that does not exist' do
- it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error }
- end
+ context 'with SSH key that does not exist' do
+ it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error }
end
+ end
- describe 'user added email' do
- let(:email) { create(:email) }
+ describe 'user added gpg key' do
+ let(:gpg_key) { create(:gpg_key) }
- subject { Notify.new_email_email(email.id) }
+ subject { Notify.new_gpg_key_email(gpg_key.id) }
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like 'a user cannot unsubscribe through footer link'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
- it 'is sent to the new user' do
- is_expected.to deliver_to email.user.email
- end
+ it 'is sent to the new user' do
+ is_expected.to deliver_to gpg_key.user.email
+ end
- it 'has the correct subject' do
- is_expected.to have_subject /^Email was added to your account$/i
- end
+ it 'has the correct subject' do
+ is_expected.to have_subject /^GPG key was added to your account$/i
+ end
- it 'contains the new email address' do
- is_expected.to have_body_text /#{email.email}/
- end
+ it 'contains the new gpg key title' do
+ is_expected.to have_body_text /#{gpg_key.fingerprint}/
+ end
- it 'includes a link to emails page' do
- is_expected.to have_body_text /#{profile_emails_path}/
- end
+ it 'includes a link to gpg keys page' do
+ is_expected.to have_body_text /#{profile_gpg_keys_path}/
+ end
+
+ context 'with GPG key that does not exist' do
+ it { expect { Notify.new_gpg_key_email('foo') }.not_to raise_error }
+ end
+ end
+
+ describe 'user added email' do
+ let(:email) { create(:email) }
+
+ subject { Notify.new_email_email(email.id) }
+
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'is sent to the new user' do
+ is_expected.to deliver_to email.user.email
+ end
+
+ it 'has the correct subject' do
+ is_expected.to have_subject /^Email was added to your account$/i
+ end
+
+ it 'contains the new email address' do
+ is_expected.to have_body_text /#{email.email}/
+ end
+
+ it 'includes a link to emails page' do
+ is_expected.to have_body_text /#{profile_emails_path}/
end
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index e6f0a3b5920..1fa59ebd22b 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -36,11 +36,11 @@ describe Notify do
end
context 'for issues' do
- let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) }
- let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: 'My awesome description') }
+ let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) }
+ let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') }
describe 'that are new' do
- subject { Notify.new_issue_email(issue.assignee_id, issue.id) }
+ subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) }
it_behaves_like 'an assignee email'
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
@@ -52,7 +52,7 @@ describe Notify do
it 'has the correct subject and body' do
aggregate_failures do
is_expected.to have_referable_subject(issue)
- is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue))
+ is_expected.to have_body_text(project_issue_path(project, issue))
end
end
@@ -69,7 +69,7 @@ describe Notify do
end
describe 'that are new with a description' do
- subject { Notify.new_issue_email(issue_with_description.assignee_id, issue_with_description.id) }
+ subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) }
it_behaves_like 'it should show Gmail Actions View Issue link'
@@ -79,7 +79,7 @@ describe Notify do
end
describe 'that have been reassigned' do
- subject { Notify.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) }
+ subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -99,13 +99,13 @@ describe Notify do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_html_escaped_body_text(previous_assignee.name)
is_expected.to have_html_escaped_body_text(assignee.name)
- is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue))
+ is_expected.to have_body_text(project_issue_path(project, issue))
end
end
end
describe 'that have been relabeled' do
- subject { Notify.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) }
+ subject { described_class.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -125,14 +125,28 @@ describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_body_text('foo, bar, and baz')
- is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue))
+ is_expected.to have_body_text(project_issue_path(project, issue))
+ end
+ end
+
+ context 'with a preferred language' do
+ before do
+ Gitlab::I18n.locale = :es
+ end
+
+ after do
+ Gitlab::I18n.use_default_locale
+ end
+
+ it 'always generates the email using the default language' do
+ is_expected.to have_body_text('foo, bar, and baz')
end
end
end
describe 'status changed' do
let(:status) { 'closed' }
- subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) }
+ subject { described_class.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) }
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
@@ -151,14 +165,14 @@ describe Notify do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_body_text(status)
is_expected.to have_html_escaped_body_text(current_user.name)
- is_expected.to have_body_text(namespace_project_issue_path project.namespace, project, issue)
+ is_expected.to have_body_text(project_issue_path project, issue)
end
end
end
describe 'moved to another project' do
let(:new_issue) { create(:issue) }
- subject { Notify.issue_moved_email(recipient, issue, new_issue, current_user) }
+ subject { described_class.issue_moved_email(recipient, issue, new_issue, current_user) }
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
@@ -171,13 +185,12 @@ describe Notify do
end
it 'has the correct subject and body' do
- new_issue_url = namespace_project_issue_path(new_issue.project.namespace,
- new_issue.project, new_issue)
+ new_issue_url = project_issue_path(new_issue.project, new_issue)
aggregate_failures do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_body_text(new_issue_url)
- is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue))
+ is_expected.to have_body_text(project_issue_path(project, issue))
end
end
end
@@ -190,7 +203,7 @@ describe Notify do
let(:merge_request_with_description) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project, description: 'My awesome description') }
describe 'that are new' do
- subject { Notify.new_merge_request_email(merge_request.assignee_id, merge_request.id) }
+ subject { described_class.new_merge_request_email(merge_request.assignee_id, merge_request.id) }
it_behaves_like 'an assignee email'
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
@@ -202,7 +215,7 @@ describe Notify do
it 'has the correct subject and body' do
aggregate_failures do
is_expected.to have_referable_subject(merge_request)
- is_expected.to have_body_text(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
is_expected.to have_body_text(merge_request.source_branch)
is_expected.to have_body_text(merge_request.target_branch)
end
@@ -221,7 +234,7 @@ describe Notify do
end
describe 'that are new with a description' do
- subject { Notify.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) }
+ subject { described_class.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) }
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
@@ -232,7 +245,7 @@ describe Notify do
end
describe 'that are reassigned' do
- subject { Notify.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) }
+ subject { described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -251,14 +264,14 @@ describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_html_escaped_body_text(previous_assignee.name)
- is_expected.to have_body_text(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
is_expected.to have_html_escaped_body_text(assignee.name)
end
end
end
describe 'that have been relabeled' do
- subject { Notify.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) }
+ subject { described_class.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -277,13 +290,13 @@ describe Notify do
it 'has the correct subject and body' do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text('foo, bar, and baz')
- is_expected.to have_body_text(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
end
end
describe 'status changed' do
let(:status) { 'reopened' }
- subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
+ subject { described_class.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
@@ -302,13 +315,13 @@ describe Notify do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text(status)
is_expected.to have_html_escaped_body_text(current_user.name)
- is_expected.to have_body_text(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
end
end
end
describe 'that are merged' do
- subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
+ subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -327,7 +340,7 @@ describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text('merged')
- is_expected.to have_body_text(namespace_project_merge_request_path(project.namespace, project, merge_request))
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
end
end
end
@@ -335,9 +348,9 @@ describe Notify do
end
describe 'project was moved' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
- subject { Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
+ subject { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -353,7 +366,7 @@ describe Notify do
describe 'project access requested' do
context 'for a project in a user namespace' do
let(:project) do
- create(:empty_project, :public, :access_requestable) do |project|
+ create(:project, :public, :access_requestable) do |project|
project.team << [project.owner, :master, project.owner]
end
end
@@ -363,7 +376,7 @@ describe Notify do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
end
- subject { Notify.member_access_requested_email('project', project_member.id) }
+ subject { described_class.member_access_requested_email('project', project_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -376,7 +389,7 @@ describe Notify do
is_expected.to have_subject "Request to join the #{project.name_with_namespace} project"
is_expected.to have_html_escaped_body_text project.name_with_namespace
- is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project)
+ is_expected.to have_body_text project_project_members_url(project)
is_expected.to have_body_text project_member.human_access
end
end
@@ -384,13 +397,13 @@ describe Notify do
context 'for a project in a group' do
let(:group_owner) { create(:user) }
let(:group) { create(:group).tap { |g| g.add_owner(group_owner) } }
- let(:project) { create(:empty_project, :public, :access_requestable, namespace: group) }
+ let(:project) { create(:project, :public, :access_requestable, namespace: group) }
let(:user) { create(:user) }
let(:project_member) do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
end
- subject { Notify.member_access_requested_email('project', project_member.id) }
+ subject { described_class.member_access_requested_email('project', project_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -403,20 +416,20 @@ describe Notify do
is_expected.to have_subject "Request to join the #{project.name_with_namespace} project"
is_expected.to have_html_escaped_body_text project.name_with_namespace
- is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project)
+ is_expected.to have_body_text project_project_members_url(project)
is_expected.to have_body_text project_member.human_access
end
end
end
describe 'project access denied' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:user) { create(:user) }
let(:project_member) do
project.request_access(user)
project.requesters.find_by(user_id: user.id)
end
- subject { Notify.member_access_denied_email('project', project.id, user.id) }
+ subject { described_class.member_access_denied_email('project', project.id, user.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -431,10 +444,10 @@ describe Notify do
describe 'project access changed' do
let(:owner) { create(:user, name: "Chang O'Keefe") }
- let(:project) { create(:empty_project, :public, :access_requestable, namespace: owner.namespace) }
+ let(:project) { create(:project, :public, :access_requestable, namespace: owner.namespace) }
let(:user) { create(:user) }
let(:project_member) { create(:project_member, project: project, user: user) }
- subject { Notify.member_access_granted_email('project', project_member.id) }
+ subject { described_class.member_access_granted_email('project', project_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -461,11 +474,11 @@ describe Notify do
end
describe 'project invitation' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) { invite_to_project(project, inviter: master) }
- subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) }
+ subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -481,7 +494,7 @@ describe Notify do
end
describe 'project invitation accepted' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:invited_user) { create(:user, name: 'invited user') }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) do
@@ -490,7 +503,7 @@ describe Notify do
invitee
end
- subject { Notify.member_invite_accepted_email('project', project_member.id) }
+ subject { described_class.member_invite_accepted_email('project', project_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -506,7 +519,7 @@ describe Notify do
end
describe 'project invitation declined' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:project_member) do
invitee = invite_to_project(project, inviter: master)
@@ -514,7 +527,7 @@ describe Notify do
invitee
end
- subject { Notify.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) }
+ subject { described_class.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -532,7 +545,7 @@ describe Notify do
let(:note_author) { create(:user, name: 'author_name') }
let(:note) { create(:note, project: project, author: note_author) }
- before :each do
+ before do
allow(Note).to receive(:find).with(note.id).and_return(note)
end
@@ -572,9 +585,11 @@ describe Notify do
let(:project) { create(:project, :repository) }
let(:commit) { project.commit }
- before(:each) { allow(note).to receive(:noteable).and_return(commit) }
+ before do
+ allow(note).to receive(:noteable).and_return(commit)
+ end
- subject { Notify.note_commit_email(recipient.id, note.id) }
+ subject { described_class.note_commit_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -593,10 +608,13 @@ describe Notify do
describe 'on a merge request' do
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
- 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) }
+ let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") }
+
+ before do
+ allow(note).to receive(:noteable).and_return(merge_request)
+ end
- subject { Notify.note_merge_request_email(recipient.id, note.id) }
+ subject { described_class.note_merge_request_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -615,10 +633,13 @@ describe Notify do
describe 'on an issue' do
let(:issue) { create(:issue, project: project) }
- 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) }
+ let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") }
+
+ before do
+ allow(note).to receive(:noteable).and_return(issue)
+ end
- subject { Notify.note_issue_email(recipient.id, note.id) }
+ subject { described_class.note_issue_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
@@ -640,7 +661,7 @@ describe Notify do
let(:project) { create(:project, :repository) }
let(:note_author) { create(:user, name: 'author_name') }
- before :each do
+ before do
allow(Note).to receive(:find).with(note.id).and_return(note)
end
@@ -678,9 +699,11 @@ describe Notify 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) }
+ before do
+ allow(note).to receive(:noteable).and_return(commit)
+ end
- subject { Notify.note_commit_email(recipient.id, note.id) }
+ subject { described_class.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
@@ -701,10 +724,13 @@ describe Notify do
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) }
+ let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") }
- subject { Notify.note_merge_request_email(recipient.id, note.id) }
+ before do
+ allow(note).to receive(:noteable).and_return(merge_request)
+ end
+
+ subject { described_class.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
@@ -725,10 +751,13 @@ describe Notify do
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) }
+ let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") }
- subject { Notify.note_issue_email(recipient.id, note.id) }
+ before do
+ allow(note).to receive(:noteable).and_return(issue)
+ end
+
+ subject { described_class.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
@@ -750,7 +779,7 @@ describe Notify do
context 'items that are noteable, the email for a diff discussion note' do
let(:note_author) { create(:user, name: 'author_name') }
- before :each do
+ before do
allow(Note).to receive(:find).with(note.id).and_return(note)
end
@@ -798,7 +827,7 @@ describe Notify do
let(:commit) { project.commit }
let(:note) { create(:diff_note_on_commit) }
- subject { Notify.note_commit_email(recipient.id, note.id) }
+ subject { described_class.note_commit_email(recipient.id, note.id) }
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'
@@ -809,7 +838,7 @@ describe Notify do
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note) { create(:diff_note_on_merge_request) }
- subject { Notify.note_merge_request_email(recipient.id, note.id) }
+ subject { described_class.note_merge_request_email(recipient.id, note.id) }
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'
@@ -826,7 +855,7 @@ describe Notify do
group.request_access(user)
group.requesters.find_by(user_id: user.id)
end
- subject { Notify.member_access_requested_email('group', group_member.id) }
+ subject { described_class.member_access_requested_email('group', group_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -847,7 +876,7 @@ describe Notify do
group.request_access(user)
group.requesters.find_by(user_id: user.id)
end
- subject { Notify.member_access_denied_email('group', group.id, user.id) }
+ subject { described_class.member_access_denied_email('group', group.id, user.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -865,7 +894,7 @@ describe Notify do
let(:user) { create(:user) }
let(:group_member) { create(:group_member, group: group, user: user) }
- subject { Notify.member_access_granted_email('group', group_member.id) }
+ subject { described_class.member_access_granted_email('group', group_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -896,7 +925,7 @@ describe Notify do
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) { invite_to_group(group, inviter: owner) }
- subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) }
+ subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -921,7 +950,7 @@ describe Notify do
invitee
end
- subject { Notify.member_invite_accepted_email('group', group_member.id) }
+ subject { described_class.member_invite_accepted_email('group', group_member.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -945,7 +974,7 @@ describe Notify do
invitee
end
- subject { Notify.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) }
+ subject { described_class.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -992,9 +1021,9 @@ describe Notify do
describe 'email on push for a created branch' do
let(:example_site_path) { root_path }
let(:user) { create(:user) }
- let(:tree_path) { namespace_project_tree_path(project.namespace, project, "empty-branch") }
+ let(:tree_path) { project_tree_path(project, "empty-branch") }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) }
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
@@ -1018,9 +1047,9 @@ describe Notify do
describe 'email on push for a created tag' do
let(:example_site_path) { root_path }
let(:user) { create(:user) }
- let(:tree_path) { namespace_project_tree_path(project.namespace, project, "v1.0") }
+ let(:tree_path) { project_tree_path(project, "v1.0") }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) }
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like "a user cannot unsubscribe through footer link"
@@ -1045,7 +1074,7 @@ describe Notify do
let(:example_site_path) { root_path }
let(:user) { create(:user) }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) }
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
@@ -1067,7 +1096,7 @@ describe Notify do
let(:example_site_path) { root_path }
let(:user) { create(:user) }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) }
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
@@ -1092,11 +1121,11 @@ describe Notify do
let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_image_commit.id, sample_commit.id) }
let(:compare) { Compare.decorate(raw_compare, project) }
let(:commits) { compare.commits }
- let(:diff_path) { namespace_project_compare_path(project.namespace, project, from: Commit.new(compare.base, project), to: Commit.new(compare.head, project)) }
+ let(:diff_path) { project_compare_path(project, from: Commit.new(compare.base, project), to: Commit.new(compare.head, project)) }
let(:send_from_committer_email) { false }
let(:diff_refs) { Gitlab::Diff::DiffRefs.new(base_sha: project.merge_base_commit(sample_image_commit.id, sample_commit.id).id, head_sha: sample_commit.id) }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, reverse_compare: false, diff_refs: diff_refs, send_from_committer_email: send_from_committer_email) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, reverse_compare: false, diff_refs: diff_refs, send_from_committer_email: send_from_committer_email) }
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
@@ -1186,10 +1215,10 @@ describe Notify do
let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_commit.parent_id, sample_commit.id) }
let(:compare) { Compare.decorate(raw_compare, project) }
let(:commits) { compare.commits }
- let(:diff_path) { namespace_project_commit_path(project.namespace, project, commits.first) }
+ let(:diff_path) { project_commit_path(project, commits.first) }
let(:diff_refs) { Gitlab::Diff::DiffRefs.new(base_sha: project.merge_base_commit(sample_image_commit.id, sample_commit.id).id, head_sha: sample_commit.id) }
- subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, diff_refs: diff_refs) }
+ subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, diff_refs: diff_refs) }
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
@@ -1213,9 +1242,9 @@ describe Notify do
end
describe 'HTML emails setting' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:multipart_mail) { Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
+ let(:multipart_mail) { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
context 'when disabled' do
it 'only sends the text template' do
diff --git a/spec/migrations/README.md b/spec/migrations/README.md
new file mode 100644
index 00000000000..45cf25b96de
--- /dev/null
+++ b/spec/migrations/README.md
@@ -0,0 +1,93 @@
+# Testing migrations
+
+In order to reliably test a migration, we need to test it against a database
+schema that this migration has been written for. In order to achieve that we
+have some _migration helpers_ and RSpec test tag, called `:migration`.
+
+If you want to write a test for a migration consider adding `:migration` tag to
+the test signature, like `describe SomeMigrationClass, :migration`.
+
+## How does it work?
+
+Adding a `:migration` tag to a test signature injects a few before / after
+hooks to the test.
+
+The most important change is that adding a `:migration` tag adds a `before`
+hook that will revert all migrations to the point that a migration under test
+is not yet migrated.
+
+In other words, our custom RSpec hooks will find a previous migration, and
+migrate the database **down** to the previous migration version.
+
+With this approach you can test a migration against a database schema that this
+migration has been written for.
+
+Use `migrate!` helper to run the migration that is under test.
+
+The `after` hook will migrate the database **up** and reinstitutes the latest
+schema version, so that the process does not affect subsequent specs and
+ensures proper isolation.
+
+## Testing a class that is not an ActiveRecord::Migration
+
+In order to test a class that is not a migration itself, you will need to
+manually provide a required schema version. Please add a `schema` tag to a
+context that you want to switch the database schema within.
+
+Example: `describe SomeClass, :migration, schema: 20170608152748`.
+
+## Available helpers
+
+Use `table` helper to create a temporary `ActiveRecord::Base` derived model
+for a table.
+
+Use `migrate!` helper to run the migration that is under test. It will not only
+run migration, but will also bump the schema version in the `schema_migrations`
+table. It is necessary because in the `after` hook we trigger the rest of
+the migrations, and we need to know where to start.
+
+See `spec/support/migrations_helpers.rb` for all the available helpers.
+
+## An example
+
+```ruby
+require 'spec_helper'
+
+# Load a migration class.
+
+require Rails.root.join('db', 'post_migrate', '20170526185842_migrate_pipeline_stages.rb')
+
+describe MigratePipelineStages, :migration do
+
+ # Create test data - pipeline and CI/CD jobs.
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ end
+
+ # Test the migration.
+
+ it 'correctly migrates pipeline stages' do
+ expect(stages.count).to be_zero
+
+ migrate!
+
+ expect(stages.count).to eq 2
+ expect(stages.all.pluck(:name)).to match_array %w[test build]
+ end
+end
+```
+
+## Best practices
+
+1. Note that this type of tests do not run within the transaction, we use
+a truncation database cleanup strategy. Do not depend on transaction being
+present.
diff --git a/spec/migrations/schema_spec.rb b/spec/migrations/active_record/schema_spec.rb
index e132529d8d8..e132529d8d8 100644
--- a/spec/migrations/schema_spec.rb
+++ b/spec/migrations/active_record/schema_spec.rb
diff --git a/spec/migrations/add_foreign_key_to_merge_requests_spec.rb b/spec/migrations/add_foreign_key_to_merge_requests_spec.rb
new file mode 100644
index 00000000000..d9ad9a585f0
--- /dev/null
+++ b/spec/migrations/add_foreign_key_to_merge_requests_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20170713104829_add_foreign_key_to_merge_requests.rb')
+
+describe AddForeignKeyToMergeRequests, :migration do
+ let(:projects) { table(:projects) }
+ let(:merge_requests) { table(:merge_requests) }
+ let(:pipelines) { table(:ci_pipelines) }
+
+ before do
+ projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce')
+ pipelines.create!(project_id: projects.first.id,
+ ref: 'some-branch',
+ sha: 'abc12345')
+
+ # merge request without a pipeline
+ create_merge_request(head_pipeline_id: nil)
+
+ # merge request with non-existent pipeline
+ create_merge_request(head_pipeline_id: 1234)
+
+ # merge reqeust with existing pipeline assigned
+ create_merge_request(head_pipeline_id: pipelines.first.id)
+ end
+
+ it 'correctly adds a foreign key to head_pipeline_id' do
+ migrate!
+
+ expect(merge_requests.first.head_pipeline_id).to be_nil
+ expect(merge_requests.second.head_pipeline_id).to be_nil
+ expect(merge_requests.third.head_pipeline_id).to eq pipelines.first.id
+ end
+
+ def create_merge_request(**opts)
+ merge_requests.create!(source_project_id: projects.first.id,
+ target_project_id: projects.first.id,
+ source_branch: 'some-branch',
+ target_branch: 'master', **opts)
+ end
+end
diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
new file mode 100644
index 00000000000..862907c5d01
--- /dev/null
+++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb')
+
+describe AddHeadPipelineForEachMergeRequest, :truncate do
+ let(:migration) { described_class.new }
+
+ let!(:project) { create(:project) }
+ let!(:forked_project_link) { create(:forked_project_link, forked_from_project: project) }
+ let!(:other_project) { forked_project_link.forked_to_project }
+
+ let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") }
+ let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") }
+ let!(:pipeline_3) { create(:ci_pipeline, project: other_project, ref: "branch_1") }
+ let!(:pipeline_4) { create(:ci_pipeline, project: project, ref: "branch_2") }
+
+ let!(:mr_1) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_1", target_branch: "target_1") }
+ let!(:mr_2) { create(:merge_request, source_project: other_project, target_project: project, source_branch: "branch_1", target_branch: "target_2") }
+ let!(:mr_3) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_2", target_branch: "master") }
+ let!(:mr_4) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_3", target_branch: "master") }
+
+ context "#up" do
+ context "when source_project and source_branch of pipeline are the same of merge request" do
+ it "sets head_pipeline_id of given merge requests" do
+ migration.up
+
+ expect(mr_1.reload.head_pipeline_id).to eq(pipeline_1.id)
+ expect(mr_2.reload.head_pipeline_id).to eq(pipeline_3.id)
+ expect(mr_3.reload.head_pipeline_id).to eq(pipeline_4.id)
+ expect(mr_4.reload.head_pipeline_id).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/migrations/calculate_conv_dev_index_percentages_spec.rb b/spec/migrations/calculate_conv_dev_index_percentages_spec.rb
new file mode 100644
index 00000000000..597d8eab51c
--- /dev/null
+++ b/spec/migrations/calculate_conv_dev_index_percentages_spec.rb
@@ -0,0 +1,41 @@
+# encoding: utf-8
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170803090603_calculate_conv_dev_index_percentages.rb')
+
+describe CalculateConvDevIndexPercentages, truncate: true do
+ let(:migration) { described_class.new }
+ let!(:conv_dev_index) do
+ create(:conversational_development_index_metric,
+ leader_notes: 0,
+ instance_milestones: 0,
+ percentage_issues: 0,
+ percentage_notes: 0,
+ percentage_milestones: 0,
+ percentage_boards: 0,
+ percentage_merge_requests: 0,
+ percentage_ci_pipelines: 0,
+ percentage_environments: 0,
+ percentage_deployments: 0,
+ percentage_projects_prometheus_active: 0,
+ percentage_service_desk_issues: 0)
+ end
+
+ describe '#up' do
+ it 'calculates percentages correctly' do
+ migration.up
+ conv_dev_index.reload
+
+ expect(conv_dev_index.percentage_issues).to be_within(0.1).of(13.3)
+ expect(conv_dev_index.percentage_notes).to be_zero # leader 0
+ expect(conv_dev_index.percentage_milestones).to be_zero # instance 0
+ expect(conv_dev_index.percentage_boards).to be_within(0.1).of(62.4)
+ expect(conv_dev_index.percentage_merge_requests).to eq(50.0)
+ expect(conv_dev_index.percentage_ci_pipelines).to be_within(0.1).of(19.3)
+ expect(conv_dev_index.percentage_environments).to be_within(0.1).of(66.7)
+ expect(conv_dev_index.percentage_deployments).to be_within(0.1).of(64.2)
+ expect(conv_dev_index.percentage_projects_prometheus_active).to be_within(0.1).of(98.2)
+ expect(conv_dev_index.percentage_service_desk_issues).to be_within(0.1).of(84.0)
+ end
+ end
+end
diff --git a/spec/migrations/clean_appearance_symlinks_spec.rb b/spec/migrations/clean_appearance_symlinks_spec.rb
new file mode 100644
index 00000000000..9225dc0d894
--- /dev/null
+++ b/spec/migrations/clean_appearance_symlinks_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170613111224_clean_appearance_symlinks.rb')
+
+describe CleanAppearanceSymlinks do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "tests", "clean_appearance_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:new_uploads_dir) { File.join(uploads_dir, "system") }
+ let(:original_path) { File.join(new_uploads_dir, 'appearance') }
+ let(:symlink_path) { File.join(uploads_dir, 'appearance') }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ before do
+ FileUtils.mkdir_p(original_path)
+ FileUtils.ln_s(original_path, symlink_path)
+ end
+
+ it 'removes the symlink' do
+ migration.up
+
+ expect(File.symlink?(symlink_path)).to be(false)
+ end
+ end
+
+ describe '#down' do
+ before do
+ FileUtils.mkdir_p(File.join(original_path))
+ FileUtils.touch(File.join(original_path, 'dummy.file'))
+ end
+
+ it 'creates a symlink' do
+ expected_path = File.join(symlink_path, "dummy.file")
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(symlink_path)).to be(true)
+ end
+ end
+end
diff --git a/spec/migrations/clean_stage_id_reference_migration_spec.rb b/spec/migrations/clean_stage_id_reference_migration_spec.rb
new file mode 100644
index 00000000000..9a581df28a2
--- /dev/null
+++ b/spec/migrations/clean_stage_id_reference_migration_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20170710083355_clean_stage_id_reference_migration.rb')
+
+describe CleanStageIdReferenceMigration, :migration, :sidekiq, :redis do
+ let(:migration_class) { 'MigrateBuildStageIdReference' }
+ let(:migration) { spy('migration') }
+
+ before do
+ allow(Gitlab::BackgroundMigration.const_get(migration_class))
+ .to receive(:new).and_return(migration)
+ end
+
+ context 'when there are pending background migrations' do
+ it 'processes pending jobs synchronously' do
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker.perform_in(2.minutes, migration_class, [1, 1])
+ BackgroundMigrationWorker.perform_async(migration_class, [1, 1])
+
+ migrate!
+
+ expect(migration).to have_received(:perform).with(1, 1).twice
+ end
+ end
+ end
+ context 'when there are no background migrations pending' do
+ it 'does nothing' do
+ Sidekiq::Testing.disable! do
+ migrate!
+
+ expect(migration).not_to have_received(:perform)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/clean_upload_symlinks_spec.rb b/spec/migrations/clean_upload_symlinks_spec.rb
new file mode 100644
index 00000000000..26653b9c008
--- /dev/null
+++ b/spec/migrations/clean_upload_symlinks_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170406111121_clean_upload_symlinks.rb')
+
+describe CleanUploadSymlinks do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "tests", "move_uploads_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:new_uploads_dir) { File.join(uploads_dir, "-", "system") }
+ let(:original_path) { File.join(new_uploads_dir, 'user') }
+ let(:symlink_path) { File.join(uploads_dir, 'user') }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ before do
+ FileUtils.mkdir_p(original_path)
+ FileUtils.ln_s(original_path, symlink_path)
+ end
+
+ it 'removes the symlink' do
+ migration.up
+
+ expect(File.symlink?(symlink_path)).to be(false)
+ end
+ end
+
+ describe '#down' do
+ before do
+ FileUtils.mkdir_p(File.join(original_path))
+ FileUtils.touch(File.join(original_path, 'dummy.file'))
+ end
+
+ it 'creates a symlink' do
+ expected_path = File.join(symlink_path, "dummy.file")
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(symlink_path)).to be(true)
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_move_system_upload_folder_symlink_spec.rb b/spec/migrations/cleanup_move_system_upload_folder_symlink_spec.rb
new file mode 100644
index 00000000000..3a9fa8c7113
--- /dev/null
+++ b/spec/migrations/cleanup_move_system_upload_folder_symlink_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+require Rails.root.join("db", "post_migrate", "20170717111152_cleanup_move_system_upload_folder_symlink.rb")
+
+describe CleanupMoveSystemUploadFolderSymlink do
+ let(:migration) { described_class.new }
+ let(:test_base) { File.join(Rails.root, 'tmp', 'tests', 'move-system-upload-folder') }
+ let(:test_folder) { File.join(test_base, '-', 'system') }
+
+ before do
+ allow(migration).to receive(:base_directory).and_return(test_base)
+ FileUtils.rm_rf(test_base)
+ FileUtils.mkdir_p(test_folder)
+ allow(migration).to receive(:say)
+ end
+
+ describe '#up' do
+ before do
+ FileUtils.ln_s(test_folder, File.join(test_base, 'system'))
+ end
+
+ it 'removes the symlink' do
+ migration.up
+
+ expect(File.exist?(File.join(test_base, 'system'))).to be_falsey
+ end
+ end
+
+ describe '#down' do
+ it 'creates the symlink' do
+ migration.down
+
+ expect(File.symlink?(File.join(test_base, 'system'))).to be_truthy
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb
new file mode 100644
index 00000000000..12cac1d033d
--- /dev/null
+++ b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170502101023_cleanup_namespaceless_pending_delete_projects.rb')
+
+describe CleanupNamespacelessPendingDeleteProjects do
+ before do
+ # Stub after_save callbacks that will fail when Project has no namespace
+ allow_any_instance_of(Project).to receive(:ensure_storage_path_exist).and_return(nil)
+ allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil)
+ end
+
+ describe '#up' do
+ it 'only cleans up pending delete projects' do
+ create(:project)
+ create(:project, pending_delete: true)
+ project = build(:project, pending_delete: true, namespace_id: nil)
+ project.save(validate: false)
+
+ expect(NamespacelessProjectDestroyWorker).to receive(:bulk_perform_async).with([[project.id]])
+
+ described_class.new.up
+ end
+
+ it 'does nothing when no pending delete projects without namespace found' do
+ create(:project)
+ create(:project, pending_delete: true)
+
+ expect(NamespacelessProjectDestroyWorker).not_to receive(:bulk_perform_async)
+
+ described_class.new.up
+ end
+ end
+end
diff --git a/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb
new file mode 100644
index 00000000000..1396d12e5a9
--- /dev/null
+++ b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb
@@ -0,0 +1,118 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170607121233_convert_custom_notification_settings_to_columns')
+
+describe ConvertCustomNotificationSettingsToColumns, :migration do
+ let(:settings_params) do
+ [
+ { level: 0, events: [:new_note] }, # disabled, single event
+ { level: 3, events: [:new_issue, :reopen_issue, :close_issue, :reassign_issue] }, # global, multiple events
+ { level: 5, events: described_class::EMAIL_EVENTS }, # custom, all events
+ { level: 5, events: [] } # custom, no events
+ ]
+ end
+
+ let(:notification_settings_before) do
+ settings_params.map do |params|
+ events = {}
+
+ params[:events].each do |event|
+ events[event] = true
+ end
+
+ user = create(:user)
+ create_params = { user_id: user.id, level: params[:level], events: events }
+ notification_setting = described_class::NotificationSetting.create(create_params)
+
+ [notification_setting, params]
+ end
+ end
+
+ let(:notification_settings_after) do
+ settings_params.map do |params|
+ events = {}
+
+ params[:events].each do |event|
+ events[event] = true
+ end
+
+ user = create(:user)
+ create_params = events.merge(user_id: user.id, level: params[:level])
+ notification_setting = described_class::NotificationSetting.create(create_params)
+
+ [notification_setting, params]
+ end
+ end
+
+ describe '#up' do
+ it 'migrates all settings where a custom event is enabled, even if they are not currently using the custom level' do
+ notification_settings_before
+
+ described_class.new.up
+
+ notification_settings_before.each do |(notification_setting, params)|
+ notification_setting.reload
+
+ expect(notification_setting.read_attribute_before_type_cast(:events)).to be_nil
+ expect(notification_setting.level).to eq(params[:level])
+
+ described_class::EMAIL_EVENTS.each do |event|
+ # We don't set the others to false, just let them default to nil
+ expected = params[:events].include?(event) || nil
+
+ expect(notification_setting.read_attribute(event)).to eq(expected)
+ end
+ end
+ end
+ end
+
+ describe '#down' do
+ it 'creates a custom events hash for all settings where at least one event is enabled' do
+ notification_settings_after
+
+ described_class.new.down
+
+ notification_settings_after.each do |(notification_setting, params)|
+ notification_setting.reload
+
+ expect(notification_setting.level).to eq(params[:level])
+
+ if params[:events].empty?
+ # We don't migrate empty settings
+ expect(notification_setting.events).to eq({})
+ else
+ described_class::EMAIL_EVENTS.each do |event|
+ expected = params[:events].include?(event)
+
+ expect(notification_setting.events[event]).to eq(expected)
+ expect(notification_setting.read_attribute(event)).to be_nil
+ end
+ end
+ end
+ end
+
+ it 'reverts the database to the state it was in before' do
+ notification_settings_before
+
+ described_class.new.up
+ described_class.new.down
+
+ notification_settings_before.each do |(notification_setting, params)|
+ notification_setting.reload
+
+ expect(notification_setting.level).to eq(params[:level])
+
+ if params[:events].empty?
+ # We don't migrate empty settings
+ expect(notification_setting.events).to eq({})
+ else
+ described_class::EMAIL_EVENTS.each do |event|
+ expected = params[:events].include?(event)
+
+ expect(notification_setting.events[event]).to eq(expected)
+ expect(notification_setting.read_attribute(event)).to be_nil
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/fill_authorized_projects_spec.rb b/spec/migrations/fill_authorized_projects_spec.rb
deleted file mode 100644
index 99dc4195818..00000000000
--- a/spec/migrations/fill_authorized_projects_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20170106142508_fill_authorized_projects.rb')
-
-describe FillAuthorizedProjects do
- describe '#up' do
- it 'schedules the jobs in batches' do
- user1 = create(:user)
- user2 = create(:user)
-
- expect(Sidekiq::Client).to receive(:push_bulk).with(
- 'class' => 'AuthorizedProjectsWorker',
- 'args' => [[user1.id], [user2.id]]
- )
-
- described_class.new.up
- end
- end
-end
diff --git a/spec/migrations/fix_wrongly_renamed_routes_spec.rb b/spec/migrations/fix_wrongly_renamed_routes_spec.rb
new file mode 100644
index 00000000000..5ef10b92a3a
--- /dev/null
+++ b/spec/migrations/fix_wrongly_renamed_routes_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170518231126_fix_wrongly_renamed_routes.rb')
+
+describe FixWronglyRenamedRoutes, truncate: true do
+ let(:subject) { described_class.new }
+ let(:broken_namespace) do
+ namespace = create(:group, name: 'apiis')
+ namespace.route.update_attribute(:path, 'api0is')
+ namespace
+ end
+
+ describe '#wrongly_renamed' do
+ it "includes routes that have names that don't match their namespace" do
+ broken_namespace
+ _other_namespace = create(:group, name: 'api0')
+
+ expect(subject.wrongly_renamed.map(&:id))
+ .to contain_exactly(broken_namespace.route.id)
+ end
+ end
+
+ describe "#paths_and_corrections" do
+ it 'finds the wrong path and gets the correction from the namespace' do
+ broken_namespace
+ namespace = create(:group, name: 'uploads-test')
+ namespace.route.update_attribute(:path, 'uploads0-test')
+
+ expected_result = [
+ { 'namespace_path' => 'apiis', 'path' => 'api0is' },
+ { 'namespace_path' => 'uploads-test', 'path' => 'uploads0-test' }
+ ]
+
+ expect(subject.paths_and_corrections).to include(*expected_result)
+ end
+ end
+
+ describe '#routes_in_namespace_query' do
+ it 'includes only the required routes' do
+ namespace = create(:group, path: 'hello')
+ project = create(:project, namespace: namespace)
+ _other_namespace = create(:group, path: 'hello0')
+
+ result = Route.where(subject.routes_in_namespace_query('hello'))
+
+ expect(result).to contain_exactly(namespace.route, project.route)
+ end
+ end
+
+ describe '#up' do
+ let(:broken_project) do
+ project = create(:project, namespace: broken_namespace, path: 'broken-project')
+ project.route.update_attribute(:path, 'api0is/broken-project')
+ project
+ end
+
+ it 'renames incorrectly named routes' do
+ broken_project
+
+ subject.up
+
+ expect(broken_project.route.reload.path).to eq('apiis/broken-project')
+ expect(broken_namespace.route.reload.path).to eq('apiis')
+ end
+
+ it "doesn't touch namespaces that look like something that should be renamed" do
+ namespace = create(:group, path: 'api0')
+
+ subject.up
+
+ expect(namespace.route.reload.path).to eq('api0')
+ end
+ end
+end
diff --git a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb b/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb
deleted file mode 100644
index 57eb03e3c80..00000000000
--- a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20170301205640_migrate_build_events_to_pipeline_events.rb')
-
-# This migration uses multiple threads, and thus different transactions. This
-# means data created in this spec may not be visible to some threads. To work
-# around this we use the TRUNCATE cleaning strategy.
-describe MigrateBuildEventsToPipelineEvents, truncate: true do
- let(:migration) { described_class.new }
- let(:project_with_pipeline_service) { create(:empty_project) }
- let(:project_with_build_service) { create(:empty_project) }
-
- before do
- ActiveRecord::Base.connection.execute <<-SQL
- INSERT INTO services (properties, build_events, pipeline_events, type)
- VALUES
- ('{"notify_only_broken_builds":true}', true, false, 'SlackService')
- , ('{"notify_only_broken_builds":true}', true, false, 'MattermostService')
- , ('{"notify_only_broken_builds":true}', true, false, 'HipchatService')
- ;
- SQL
-
- ActiveRecord::Base.connection.execute <<-SQL
- INSERT INTO services
- (properties, build_events, pipeline_events, type, project_id)
- VALUES
- ('{"notify_only_broken_builds":true}', true, false,
- 'BuildsEmailService', #{project_with_pipeline_service.id})
- , ('{"notify_only_broken_pipelines":true}', false, true,
- 'PipelinesEmailService', #{project_with_pipeline_service.id})
- , ('{"notify_only_broken_builds":true}', true, false,
- 'BuildsEmailService', #{project_with_build_service.id})
- ;
- SQL
- end
-
- describe '#up' do
- before do
- silence_migration = Module.new do
- # rubocop:disable Rails/Delegate
- def execute(query)
- connection.execute(query)
- end
- end
-
- migration.extend(silence_migration)
- migration.up
- end
-
- it 'migrates chat service properly' do
- [SlackService, MattermostService, HipchatService].each do |service|
- expect(service.count).to eq(1)
-
- verify_service_record(service.first)
- end
- end
-
- it 'migrates pipelines email service only if it has none before' do
- Project.find_each do |project|
- pipeline_service_count =
- project.services.where(type: 'PipelinesEmailService').count
-
- expect(pipeline_service_count).to eq(1)
-
- verify_service_record(project.pipelines_email_service)
- end
- end
-
- def verify_service_record(service)
- expect(service.notify_only_broken_pipelines).to be(true)
- expect(service.build_events).to be(false)
- expect(service.pipeline_events).to be(true)
- end
- end
-end
diff --git a/spec/migrations/migrate_build_stage_reference_again_spec.rb b/spec/migrations/migrate_build_stage_reference_again_spec.rb
new file mode 100644
index 00000000000..6be480ce58e
--- /dev/null
+++ b/spec/migrations/migrate_build_stage_reference_again_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170526190000_migrate_build_stage_reference_again.rb')
+
+describe MigrateBuildStageReferenceAgain, :migration do
+ ##
+ # Create test data - pipeline and CI/CD jobs.
+ #
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ # Create projects
+ #
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2')
+
+ # Create CI/CD pipelines
+ #
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb')
+
+ # Create CI/CD jobs
+ #
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 5, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 8, commit_id: 3, project_id: 789, stage_idx: 3, stage: 'deploy')
+
+ # Create CI/CD stages
+ #
+ stages.create(id: 101, pipeline_id: 1, project_id: 123, name: 'test')
+ stages.create(id: 102, pipeline_id: 1, project_id: 123, name: 'build')
+ stages.create(id: 103, pipeline_id: 1, project_id: 123, name: 'deploy')
+ stages.create(id: 104, pipeline_id: 2, project_id: 456, name: 'test:1')
+ stages.create(id: 105, pipeline_id: 2, project_id: 456, name: 'test:2')
+ stages.create(id: 106, pipeline_id: 2, project_id: 456, name: 'deploy')
+ end
+
+ it 'correctly migrate build stage references' do
+ expect(jobs.where(stage_id: nil).count).to eq 8
+
+ migrate!
+
+ expect(jobs.where(stage_id: nil).count).to eq 1
+
+ expect(jobs.find(1).stage_id).to eq 102
+ expect(jobs.find(2).stage_id).to eq 102
+ expect(jobs.find(3).stage_id).to eq 101
+ expect(jobs.find(4).stage_id).to eq 103
+ expect(jobs.find(5).stage_id).to eq 105
+ expect(jobs.find(6).stage_id).to eq 104
+ expect(jobs.find(7).stage_id).to eq 104
+ expect(jobs.find(8).stage_id).to eq nil
+ end
+end
diff --git a/spec/migrations/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb
new file mode 100644
index 00000000000..81366d15b34
--- /dev/null
+++ b/spec/migrations/migrate_old_artifacts_spec.rb
@@ -0,0 +1,117 @@
+# encoding: utf-8
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170523083112_migrate_old_artifacts.rb')
+
+describe MigrateOldArtifacts do
+ let(:migration) { described_class.new }
+ let!(:directory) { Dir.mktmpdir }
+
+ before do
+ allow(Gitlab.config.artifacts).to receive(:path).and_return(directory)
+ end
+
+ after do
+ FileUtils.remove_entry_secure(directory)
+ end
+
+ context 'with migratable data' do
+ let(:project1) { create(:project, ci_id: 2) }
+ let(:project2) { create(:project, ci_id: 3) }
+ let(:project3) { create(:project) }
+
+ let(:pipeline1) { create(:ci_empty_pipeline, project: project1) }
+ let(:pipeline2) { create(:ci_empty_pipeline, project: project2) }
+ let(:pipeline3) { create(:ci_empty_pipeline, project: project3) }
+
+ let!(:build_with_legacy_artifacts) { create(:ci_build, pipeline: pipeline1) }
+ let!(:build_without_artifacts) { create(:ci_build, pipeline: pipeline1) }
+ let!(:build2) { create(:ci_build, :artifacts, pipeline: pipeline2) }
+ let!(:build3) { create(:ci_build, :artifacts, pipeline: pipeline3) }
+
+ before do
+ store_artifacts_in_legacy_path(build_with_legacy_artifacts)
+ end
+
+ it "legacy artifacts are not accessible" do
+ expect(build_with_legacy_artifacts.artifacts?).to be_falsey
+ end
+
+ it "legacy artifacts are set" do
+ expect(build_with_legacy_artifacts.artifacts_file_identifier).not_to be_nil
+ end
+
+ describe '#min_id' do
+ subject { migration.send(:min_id) }
+
+ it 'returns the newest build for which ci_id is not defined' do
+ is_expected.to eq(build3.id)
+ end
+ end
+
+ describe '#builds_with_artifacts' do
+ subject { migration.send(:builds_with_artifacts).map(&:id) }
+
+ it 'returns a list of builds that has artifacts and could be migrated' do
+ is_expected.to contain_exactly(build_with_legacy_artifacts.id, build2.id)
+ end
+ end
+
+ describe '#up' do
+ context 'when migrating artifacts' do
+ before do
+ migration.up
+ end
+
+ it 'all files do have artifacts' do
+ Ci::Build.with_artifacts do |build|
+ expect(build).to have_artifacts
+ end
+ end
+
+ it 'artifacts are no longer present on legacy path' do
+ expect(File.exist?(legacy_path(build_with_legacy_artifacts))).to eq(false)
+ end
+ end
+
+ context 'when there are aritfacts in old and new directory' do
+ before do
+ store_artifacts_in_legacy_path(build2)
+
+ migration.up
+ end
+
+ it 'does not move old files' do
+ expect(File.exist?(legacy_path(build2))).to eq(true)
+ end
+ end
+ end
+
+ private
+
+ def store_artifacts_in_legacy_path(build)
+ FileUtils.mkdir_p(legacy_path(build))
+
+ FileUtils.copy(
+ Rails.root.join('spec/fixtures/ci_build_artifacts.zip'),
+ File.join(legacy_path(build), "ci_build_artifacts.zip"))
+
+ FileUtils.copy(
+ Rails.root.join('spec/fixtures/ci_build_artifacts_metadata.gz'),
+ File.join(legacy_path(build), "ci_build_artifacts_metadata.gz"))
+
+ build.update_columns(
+ artifacts_file: 'ci_build_artifacts.zip',
+ artifacts_metadata: 'ci_build_artifacts_metadata.gz')
+
+ build.reload
+ end
+
+ def legacy_path(build)
+ File.join(directory,
+ build.created_at.utc.strftime('%Y_%m'),
+ build.project.ci_id.to_s,
+ build.id.to_s)
+ end
+ end
+end
diff --git a/spec/migrations/migrate_pipeline_stages_spec.rb b/spec/migrations/migrate_pipeline_stages_spec.rb
new file mode 100644
index 00000000000..c47f2bb8ff9
--- /dev/null
+++ b/spec/migrations/migrate_pipeline_stages_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170526185842_migrate_pipeline_stages.rb')
+
+describe MigratePipelineStages, :migration do
+ ##
+ # Create test data - pipeline and CI/CD jobs.
+ #
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ # Create projects
+ #
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2')
+
+ # Create CI/CD pipelines
+ #
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb')
+
+ # Create CI/CD jobs
+ #
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 5, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 8, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 9, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1')
+ jobs.create!(id: 10, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2')
+ jobs.create!(id: 11, commit_id: 3, project_id: 456, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 12, commit_id: 2, project_id: 789, stage_idx: 3, stage: 'deploy')
+ end
+
+ it 'correctly migrates pipeline stages' do
+ expect(stages.count).to be_zero
+
+ migrate!
+
+ expect(stages.count).to eq 6
+ expect(stages.all.pluck(:name))
+ .to match_array %w[test build deploy test:1 test:2 deploy]
+ expect(stages.where(pipeline_id: 1).order(:id).pluck(:name))
+ .to eq %w[test build deploy]
+ expect(stages.where(pipeline_id: 2).order(:id).pluck(:name))
+ .to eq %w[test:1 test:2 deploy]
+ expect(stages.where(pipeline_id: 3).count).to be_zero
+ expect(stages.where(project_id: 789).count).to be_zero
+ end
+end
diff --git a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb
index 3db57595fa6..e5793a3c0ee 100644
--- a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb
+++ b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb
@@ -6,38 +6,38 @@ require Rails.root.join('db', 'migrate', '20161124141322_migrate_process_commit_
describe MigrateProcessCommitWorkerJobs do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:commit) { project.commit.raw.raw_commit }
+ let(:commit) { project.commit.raw.rugged_commit }
describe 'Project' do
describe 'find_including_path' do
it 'returns Project instances' do
- expect(described_class::Project.find_including_path(project.id)).
- to be_an_instance_of(described_class::Project)
+ expect(described_class::Project.find_including_path(project.id))
+ .to be_an_instance_of(described_class::Project)
end
it 'selects the full path for every Project' do
- migration_project = described_class::Project.
- find_including_path(project.id)
+ migration_project = described_class::Project
+ .find_including_path(project.id)
- expect(migration_project[:path_with_namespace]).
- to eq(project.path_with_namespace)
+ expect(migration_project[:path_with_namespace])
+ .to eq(project.full_path)
end
end
describe '#repository_storage_path' do
it 'returns the storage path for the repository' do
- migration_project = described_class::Project.
- find_including_path(project.id)
+ migration_project = described_class::Project
+ .find_including_path(project.id)
- expect(File.directory?(migration_project.repository_storage_path)).
- to eq(true)
+ expect(File.directory?(migration_project.repository_storage_path))
+ .to eq(true)
end
end
describe '#repository_path' do
it 'returns the path to the repository' do
- migration_project = described_class::Project.
- find_including_path(project.id)
+ migration_project = described_class::Project
+ .find_including_path(project.id)
expect(File.directory?(migration_project.repository_path)).to eq(true)
end
@@ -45,16 +45,16 @@ describe MigrateProcessCommitWorkerJobs do
describe '#repository' do
it 'returns a Rugged::Repository' do
- migration_project = described_class::Project.
- find_including_path(project.id)
+ migration_project = described_class::Project
+ .find_including_path(project.id)
- expect(migration_project.repository).
- to be_an_instance_of(Rugged::Repository)
+ expect(migration_project.repository)
+ .to be_an_instance_of(Rugged::Repository)
end
end
end
- describe '#up', :redis do
+ describe '#up', :clean_gitlab_redis_shared_state do
let(:migration) { described_class.new }
def job_count
@@ -73,9 +73,9 @@ describe MigrateProcessCommitWorkerJobs do
end
it 'skips jobs using a project that no longer exists' do
- allow(described_class::Project).to receive(:find_including_path).
- with(project.id).
- and_return(nil)
+ allow(described_class::Project).to receive(:find_including_path)
+ .with(project.id)
+ .and_return(nil)
migration.up
@@ -83,9 +83,9 @@ describe MigrateProcessCommitWorkerJobs do
end
it 'skips jobs using commits that no longer exist' do
- allow_any_instance_of(Rugged::Repository).to receive(:lookup).
- with(commit.oid).
- and_raise(Rugged::OdbError)
+ allow_any_instance_of(Rugged::Repository).to receive(:lookup)
+ .with(commit.oid)
+ .and_raise(Rugged::OdbError)
migration.up
@@ -99,12 +99,12 @@ describe MigrateProcessCommitWorkerJobs do
end
it 'encodes data to UTF-8' do
- allow_any_instance_of(Rugged::Repository).to receive(:lookup).
- with(commit.oid).
- and_return(commit)
+ allow_any_instance_of(Rugged::Repository).to receive(:lookup)
+ .with(commit.oid)
+ .and_return(commit)
- allow(commit).to receive(:message).
- and_return('김치'.force_encoding('BINARY'))
+ allow(commit).to receive(:message)
+ .and_return('김치'.force_encoding('BINARY'))
migration.up
@@ -172,7 +172,7 @@ describe MigrateProcessCommitWorkerJobs do
end
end
- describe '#down', :redis do
+ describe '#down', :clean_gitlab_redis_shared_state do
let(:migration) { described_class.new }
def job_count
diff --git a/spec/migrations/migrate_stage_id_reference_in_background_spec.rb b/spec/migrations/migrate_stage_id_reference_in_background_spec.rb
new file mode 100644
index 00000000000..260378adaa7
--- /dev/null
+++ b/spec/migrations/migrate_stage_id_reference_in_background_spec.rb
@@ -0,0 +1,68 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170628080858_migrate_stage_id_reference_in_background')
+
+describe MigrateStageIdReferenceInBackground, :migration, :sidekiq do
+ matcher :be_scheduled_migration do |delay, *expected|
+ match do |migration|
+ BackgroundMigrationWorker.jobs.any? do |job|
+ job['args'] == [migration, expected] &&
+ job['at'].to_i == (delay.to_i + Time.now.to_i)
+ end
+ end
+
+ failure_message do |migration|
+ "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
+ end
+ end
+
+ let(:jobs) { table(:ci_builds) }
+ let(:stages) { table(:ci_stages) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:projects) { table(:projects) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 3)
+ stub_const("#{described_class.name}::RANGE_SIZE", 2)
+
+ projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 345, name: 'gitlab2', path: 'gitlab2')
+
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ pipelines.create!(id: 2, project_id: 345, ref: 'feature', sha: 'cdf43c3c')
+
+ jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build')
+ jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test')
+ jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy')
+ jobs.create!(id: 5, commit_id: 2, project_id: 345, stage_idx: 1, stage: 'test')
+
+ stages.create(id: 101, pipeline_id: 1, project_id: 123, name: 'test')
+ stages.create(id: 102, pipeline_id: 1, project_id: 123, name: 'build')
+ stages.create(id: 103, pipeline_id: 1, project_id: 123, name: 'deploy')
+
+ jobs.create!(id: 6, commit_id: 2, project_id: 345, stage_id: 101, stage_idx: 1, stage: 'test')
+ end
+
+ it 'correctly schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_migration(2.minutes, 1, 2)
+ expect(described_class::MIGRATION).to be_scheduled_migration(2.minutes, 3, 3)
+ expect(described_class::MIGRATION).to be_scheduled_migration(4.minutes, 4, 5)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.inline! do
+ expect(jobs.where(stage_id: nil).count).to eq 5
+
+ migrate!
+
+ expect(jobs.where(stage_id: nil).count).to eq 1
+ end
+ end
+end
diff --git a/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
index 1db9bc002ae..063829be546 100644
--- a/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
+++ b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb
@@ -3,13 +3,13 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170324160416_migrate_user_activities_to_users_last_activity_on.rb')
-describe MigrateUserActivitiesToUsersLastActivityOn, :redis do
+describe MigrateUserActivitiesToUsersLastActivityOn, :clean_gitlab_redis_shared_state, :truncate do
let(:migration) { described_class.new }
let!(:user_active_1) { create(:user) }
let!(:user_active_2) { create(:user) }
def record_activity(user, time)
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::SharedState.with do |redis|
redis.zadd(described_class::USER_ACTIVITY_SET_KEY, time.to_i, user.username)
end
end
diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb
index dacaa834aa9..afaa5d836a7 100644
--- a/spec/migrations/migrate_user_project_view_spec.rb
+++ b/spec/migrations/migrate_user_project_view_spec.rb
@@ -3,9 +3,14 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_project_view.rb')
-describe MigrateUserProjectView do
+describe MigrateUserProjectView, :truncate do
let(:migration) { described_class.new }
- let!(:user) { create(:user, project_view: 'readme') }
+ let!(:user) { create(:user) }
+
+ before do
+ # 0 is the numeric value for the old 'readme' option
+ user.update_column(:project_view, 0)
+ end
describe '#up' do
it 'updates project view setting with new value' do
diff --git a/spec/migrations/move_personal_snippets_files_spec.rb b/spec/migrations/move_personal_snippets_files_spec.rb
new file mode 100644
index 00000000000..1a319eccc0d
--- /dev/null
+++ b/spec/migrations/move_personal_snippets_files_spec.rb
@@ -0,0 +1,180 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170612071012_move_personal_snippets_files.rb')
+
+describe MovePersonalSnippetsFiles do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "tests", "move_snippet_files_test") }
+ let(:uploads_dir) { File.join(test_dir, 'uploads') }
+ let(:new_uploads_dir) { File.join(uploads_dir, '-', 'system') }
+
+ before do
+ allow(CarrierWave).to receive(:root).and_return(test_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ let(:snippet) do
+ snippet = create(:personal_snippet)
+ create_upload('picture.jpg', snippet)
+ snippet.update(description: markdown_linking_file('picture.jpg', snippet))
+ snippet
+ end
+
+ let(:snippet_with_missing_file) do
+ snippet = create(:snippet)
+ create_upload('picture.jpg', snippet, create_file: false)
+ snippet.update(description: markdown_linking_file('picture.jpg', snippet))
+ snippet
+ end
+
+ it 'moves the files' do
+ source_path = File.join(uploads_dir, model_file_path('picture.jpg', snippet))
+ destination_path = File.join(new_uploads_dir, model_file_path('picture.jpg', snippet))
+
+ migration.up
+
+ expect(File.exist?(source_path)).to be_falsy
+ expect(File.exist?(destination_path)).to be_truthy
+ end
+
+ describe 'updating the markdown' do
+ it 'includes the new path when the file exists' do
+ secret = "secret#{snippet.id}"
+ file_location = "/uploads/-/system/personal_snippet/#{snippet.id}/#{secret}/picture.jpg"
+
+ migration.up
+
+ expect(snippet.reload.description).to include(file_location)
+ end
+
+ it 'does not update the markdown when the file is missing' do
+ secret = "secret#{snippet_with_missing_file.id}"
+ file_location = "/uploads/personal_snippet/#{snippet_with_missing_file.id}/#{secret}/picture.jpg"
+
+ migration.up
+
+ expect(snippet_with_missing_file.reload.description).to include(file_location)
+ end
+
+ it 'updates the note markdown' do
+ secret = "secret#{snippet.id}"
+ file_location = "/uploads/-/system/personal_snippet/#{snippet.id}/#{secret}/picture.jpg"
+ markdown = markdown_linking_file('picture.jpg', snippet)
+ note = create(:note_on_personal_snippet, noteable: snippet, note: "with #{markdown}")
+
+ migration.up
+
+ expect(note.reload.note).to include(file_location)
+ end
+ end
+ end
+
+ describe "#down" do
+ let(:snippet) do
+ snippet = create(:personal_snippet)
+ create_upload('picture.jpg', snippet, in_new_path: true)
+ snippet.update(description: markdown_linking_file('picture.jpg', snippet, in_new_path: true))
+ snippet
+ end
+
+ let(:snippet_with_missing_file) do
+ snippet = create(:personal_snippet)
+ create_upload('picture.jpg', snippet, create_file: false, in_new_path: true)
+ snippet.update(description: markdown_linking_file('picture.jpg', snippet, in_new_path: true))
+ snippet
+ end
+
+ it 'moves the files' do
+ source_path = File.join(new_uploads_dir, model_file_path('picture.jpg', snippet))
+ destination_path = File.join(uploads_dir, model_file_path('picture.jpg', snippet))
+
+ migration.down
+
+ expect(File.exist?(source_path)).to be_falsey
+ expect(File.exist?(destination_path)).to be_truthy
+ end
+
+ describe 'updating the markdown' do
+ it 'includes the new path when the file exists' do
+ secret = "secret#{snippet.id}"
+ file_location = "/uploads/personal_snippet/#{snippet.id}/#{secret}/picture.jpg"
+
+ migration.down
+
+ expect(snippet.reload.description).to include(file_location)
+ end
+
+ it 'keeps the markdown as is when the file is missing' do
+ secret = "secret#{snippet_with_missing_file.id}"
+ file_location = "/uploads/-/system/personal_snippet/#{snippet_with_missing_file.id}/#{secret}/picture.jpg"
+
+ migration.down
+
+ expect(snippet_with_missing_file.reload.description).to include(file_location)
+ end
+
+ it 'updates the note markdown' do
+ markdown = markdown_linking_file('picture.jpg', snippet, in_new_path: true)
+ secret = "secret#{snippet.id}"
+ file_location = "/uploads/personal_snippet/#{snippet.id}/#{secret}/picture.jpg"
+ note = create(:note_on_personal_snippet, noteable: snippet, note: "with #{markdown}")
+
+ migration.down
+
+ expect(note.reload.note).to include(file_location)
+ end
+ end
+ end
+
+ describe '#update_markdown' do
+ it 'escapes sql in the snippet description' do
+ migration.instance_variable_set('@source_relative_location', '/uploads/personal_snippet')
+ migration.instance_variable_set('@destination_relative_location', '/uploads/system/personal_snippet')
+
+ secret = '123456789'
+ filename = 'hello.jpg'
+ snippet = create(:personal_snippet)
+
+ path_before = "/uploads/personal_snippet/#{snippet.id}/#{secret}/#{filename}"
+ path_after = "/uploads/system/personal_snippet/#{snippet.id}/#{secret}/#{filename}"
+ description_before = "Hello world; ![image](#{path_before})'; select * from users;"
+ description_after = "Hello world; ![image](#{path_after})'; select * from users;"
+
+ migration.update_markdown(snippet.id, secret, filename, description_before)
+
+ expect(snippet.reload.description).to eq(description_after)
+ end
+ end
+
+ def create_upload(filename, snippet, create_file: true, in_new_path: false)
+ secret = "secret#{snippet.id}"
+ absolute_path = if in_new_path
+ File.join(new_uploads_dir, model_file_path(filename, snippet))
+ else
+ File.join(uploads_dir, model_file_path(filename, snippet))
+ end
+
+ if create_file
+ FileUtils.mkdir_p(File.dirname(absolute_path))
+ FileUtils.touch(absolute_path)
+ end
+
+ create(:upload, model: snippet, path: "#{secret}/#{filename}", uploader: PersonalFileUploader)
+ end
+
+ def markdown_linking_file(filename, snippet, in_new_path: false)
+ markdown = "![#{filename.split('.')[0]}]"
+ markdown += '(/uploads'
+ markdown += '/-/system' if in_new_path
+ markdown += "/#{model_file_path(filename, snippet)})"
+ markdown
+ end
+
+ def model_file_path(filename, snippet)
+ secret = "secret#{snippet.id}"
+
+ File.join('personal_snippet', snippet.id.to_s, secret, filename)
+ end
+end
diff --git a/spec/migrations/move_system_upload_folder_spec.rb b/spec/migrations/move_system_upload_folder_spec.rb
new file mode 100644
index 00000000000..d3180477db3
--- /dev/null
+++ b/spec/migrations/move_system_upload_folder_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+require Rails.root.join("db", "migrate", "20170717074009_move_system_upload_folder.rb")
+
+describe MoveSystemUploadFolder do
+ let(:migration) { described_class.new }
+ let(:test_base) { File.join(Rails.root, 'tmp', 'tests', 'move-system-upload-folder') }
+
+ before do
+ allow(migration).to receive(:base_directory).and_return(test_base)
+ FileUtils.rm_rf(test_base)
+ FileUtils.mkdir_p(test_base)
+ allow(migration).to receive(:say)
+ end
+
+ describe '#up' do
+ let(:test_folder) { File.join(test_base, 'system') }
+ let(:test_file) { File.join(test_folder, 'file') }
+
+ before do
+ FileUtils.mkdir_p(test_folder)
+ FileUtils.touch(test_file)
+ end
+
+ it 'moves the related folder' do
+ migration.up
+
+ expect(File.exist?(File.join(test_base, '-', 'system', 'file'))).to be_truthy
+ end
+
+ it 'creates a symlink linking making the new folder available on the old path' do
+ migration.up
+
+ expect(File.symlink?(File.join(test_base, 'system'))).to be_truthy
+ expect(File.exist?(File.join(test_base, 'system', 'file'))).to be_truthy
+ end
+
+ it 'does not move if the target directory already exists' do
+ FileUtils.mkdir_p(File.join(test_base, '-', 'system'))
+
+ expect(FileUtils).not_to receive(:mv)
+ expect(migration).to receive(:say).with(/already exists. No need to redo the move/)
+
+ migration.up
+ end
+ end
+
+ describe '#down' do
+ let(:test_folder) { File.join(test_base, '-', 'system') }
+ let(:test_file) { File.join(test_folder, 'file') }
+
+ before do
+ FileUtils.mkdir_p(test_folder)
+ FileUtils.touch(test_file)
+ end
+
+ it 'moves the system folder back to the old location' do
+ migration.down
+
+ expect(File.exist?(File.join(test_base, 'system', 'file'))).to be_truthy
+ end
+
+ it 'removes the symlink if it existed' do
+ FileUtils.ln_s(test_folder, File.join(test_base, 'system'))
+
+ migration.down
+
+ expect(File.directory?(File.join(test_base, 'system'))).to be_truthy
+ expect(File.symlink?(File.join(test_base, 'system'))).to be_falsey
+ end
+
+ it 'does not move if the old directory already exists' do
+ FileUtils.mkdir_p(File.join(test_base, 'system'))
+
+ expect(FileUtils).not_to receive(:mv)
+ expect(migration).to receive(:say).with(/already exists and is not a symlink, no need to revert/)
+
+ migration.down
+ end
+ end
+end
diff --git a/spec/migrations/move_uploads_to_system_dir_spec.rb b/spec/migrations/move_uploads_to_system_dir_spec.rb
new file mode 100644
index 00000000000..ca11a2004c5
--- /dev/null
+++ b/spec/migrations/move_uploads_to_system_dir_spec.rb
@@ -0,0 +1,68 @@
+require "spec_helper"
+require Rails.root.join("db", "migrate", "20170316163845_move_uploads_to_system_dir.rb")
+
+describe MoveUploadsToSystemDir do
+ let(:migration) { described_class.new }
+ let(:test_dir) { File.join(Rails.root, "tmp", "move_uploads_test") }
+ let(:uploads_dir) { File.join(test_dir, "public", "uploads") }
+ let(:new_uploads_dir) { File.join(uploads_dir, "-", "system") }
+
+ before do
+ FileUtils.remove_dir(test_dir) if File.directory?(test_dir)
+ FileUtils.mkdir_p(uploads_dir)
+ allow(migration).to receive(:base_directory).and_return(test_dir)
+ allow(migration).to receive(:say)
+ end
+
+ describe "#up" do
+ before do
+ FileUtils.mkdir_p(File.join(uploads_dir, 'user'))
+ FileUtils.touch(File.join(uploads_dir, 'user', 'dummy.file'))
+ end
+
+ it 'moves the directory to the new path' do
+ expected_path = File.join(new_uploads_dir, 'user', 'dummy.file')
+
+ migration.up
+
+ expect(File.exist?(expected_path)).to be(true)
+ end
+
+ it 'creates a symlink in the old location' do
+ symlink_path = File.join(uploads_dir, 'user')
+ expected_path = File.join(symlink_path, 'dummy.file')
+
+ migration.up
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(symlink_path)).to be(true)
+ end
+ end
+
+ describe "#down" do
+ before do
+ FileUtils.mkdir_p(File.join(new_uploads_dir, 'user'))
+ FileUtils.touch(File.join(new_uploads_dir, 'user', 'dummy.file'))
+ end
+
+ it 'moves the directory to the old path' do
+ expected_path = File.join(uploads_dir, 'user', 'dummy.file')
+
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ end
+
+ it 'removes the symlink if it existed' do
+ FileUtils.ln_s(File.join(new_uploads_dir, 'user'), File.join(uploads_dir, 'user'))
+
+ directory = File.join(uploads_dir, 'user')
+ expected_path = File.join(directory, 'dummy.file')
+
+ migration.down
+
+ expect(File.exist?(expected_path)).to be(true)
+ expect(File.symlink?(directory)).to be(false)
+ end
+ end
+end
diff --git a/spec/migrations/remove_duplicate_mr_events_spec.rb b/spec/migrations/remove_duplicate_mr_events_spec.rb
new file mode 100644
index 00000000000..e393374028f
--- /dev/null
+++ b/spec/migrations/remove_duplicate_mr_events_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170815060945_remove_duplicate_mr_events.rb')
+
+describe RemoveDuplicateMrEvents, truncate: true do
+ let(:migration) { described_class.new }
+
+ describe '#up' do
+ let(:user) { create(:user) }
+ let(:merge_requests) { create_list(:merge_request, 2) }
+ let(:issue) { create(:issue) }
+ let!(:events) do
+ [
+ create(:event, :created, author: user, target: merge_requests.first),
+ create(:event, :created, author: user, target: merge_requests.first),
+ create(:event, :updated, author: user, target: merge_requests.first),
+ create(:event, :created, author: user, target: merge_requests.second),
+ create(:event, :created, author: user, target: issue),
+ create(:event, :created, author: user, target: issue)
+ ]
+ end
+
+ it 'removes duplicated merge request create records' do
+ expect { migration.up }.to change { Event.count }.from(6).to(5)
+ end
+ end
+end
diff --git a/spec/migrations/rename_duplicated_variable_key_spec.rb b/spec/migrations/rename_duplicated_variable_key_spec.rb
new file mode 100644
index 00000000000..11096564dfa
--- /dev/null
+++ b/spec/migrations/rename_duplicated_variable_key_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20170622135451_rename_duplicated_variable_key.rb')
+
+describe RenameDuplicatedVariableKey, :migration do
+ let(:variables) { table(:ci_variables) }
+ let(:projects) { table(:projects) }
+
+ before do
+ projects.create!(id: 1)
+ variables.create!(id: 1, key: 'key1', project_id: 1)
+ variables.create!(id: 2, key: 'key2', project_id: 1)
+ variables.create!(id: 3, key: 'keyX', project_id: 1)
+ variables.create!(id: 4, key: 'keyX', project_id: 1)
+ variables.create!(id: 5, key: 'keyY', project_id: 1)
+ variables.create!(id: 6, key: 'keyX', project_id: 1)
+ variables.create!(id: 7, key: 'key7', project_id: 1)
+ variables.create!(id: 8, key: 'keyY', project_id: 1)
+ end
+
+ it 'correctly remove duplicated records with smaller id' do
+ migrate!
+
+ expect(variables.pluck(:id, :key)).to contain_exactly(
+ [1, 'key1'],
+ [2, 'key2'],
+ [3, 'keyX_3'],
+ [4, 'keyX_4'],
+ [5, 'keyY_5'],
+ [6, 'keyX'],
+ [7, 'key7'],
+ [8, 'keyY']
+ )
+ end
+end
diff --git a/spec/migrations/rename_more_reserved_project_names_spec.rb b/spec/migrations/rename_more_reserved_project_names_spec.rb
index 36e82729c23..ae3a4cb9b29 100644
--- a/spec/migrations/rename_more_reserved_project_names_spec.rb
+++ b/spec/migrations/rename_more_reserved_project_names_spec.rb
@@ -8,7 +8,7 @@ require Rails.root.join('db', 'post_migrate', '20170313133418_rename_more_reserv
# around this we use the TRUNCATE cleaning strategy.
describe RenameMoreReservedProjectNames, truncate: true do
let(:migration) { described_class.new }
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
before do
project.path = 'artifacts'
@@ -17,7 +17,9 @@ describe RenameMoreReservedProjectNames, truncate: true do
describe '#up' do
context 'when project repository exists' do
- before { project.create_repository }
+ before do
+ project.create_repository
+ end
context 'when no exception is raised' do
it 'renames project with reserved names' do
diff --git a/spec/migrations/rename_reserved_project_names_spec.rb b/spec/migrations/rename_reserved_project_names_spec.rb
index 4fb7ed36884..462f4c08d63 100644
--- a/spec/migrations/rename_reserved_project_names_spec.rb
+++ b/spec/migrations/rename_reserved_project_names_spec.rb
@@ -8,7 +8,7 @@ require Rails.root.join('db', 'post_migrate', '20161221153951_rename_reserved_pr
# around this we use the TRUNCATE cleaning strategy.
describe RenameReservedProjectNames, truncate: true do
let(:migration) { described_class.new }
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
before do
project.path = 'projects'
@@ -17,7 +17,9 @@ describe RenameReservedProjectNames, truncate: true do
describe '#up' do
context 'when project repository exists' do
- before { project.create_repository }
+ before do
+ project.create_repository
+ end
context 'when no exception is raised' do
it 'renames project with reserved names' do
diff --git a/spec/migrations/rename_users_with_renamed_namespace_spec.rb b/spec/migrations/rename_users_with_renamed_namespace_spec.rb
new file mode 100644
index 00000000000..1e9aab3d9a1
--- /dev/null
+++ b/spec/migrations/rename_users_with_renamed_namespace_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170518200835_rename_users_with_renamed_namespace.rb')
+
+describe RenameUsersWithRenamedNamespace, truncate: true do
+ it 'renames a user that had their namespace renamed to the namespace path' do
+ other_user = create(:user, username: 'kodingu')
+ other_user1 = create(:user, username: 'api0')
+
+ user = create(:user, username: "Users0")
+ user.update_attribute(:username, 'Users')
+ user1 = create(:user, username: "import0")
+ user1.update_attribute(:username, 'import')
+
+ described_class.new.up
+
+ expect(user.reload.username).to eq('Users0')
+ expect(user1.reload.username).to eq('import0')
+
+ expect(other_user.reload.username).to eq('kodingu')
+ expect(other_user1.reload.username).to eq('api0')
+ end
+end
diff --git a/spec/migrations/schedule_merge_request_diff_migrations_spec.rb b/spec/migrations/schedule_merge_request_diff_migrations_spec.rb
new file mode 100644
index 00000000000..f95bd6e3511
--- /dev/null
+++ b/spec/migrations/schedule_merge_request_diff_migrations_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170703130158_schedule_merge_request_diff_migrations')
+
+describe ScheduleMergeRequestDiffMigrations, :migration, :sidekiq do
+ matcher :be_scheduled_migration do |time, *expected|
+ match do |migration|
+ BackgroundMigrationWorker.jobs.any? do |job|
+ job['args'] == [migration, expected] &&
+ job['at'].to_i == time.to_i
+ end
+ end
+
+ failure_message do |migration|
+ "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
+ end
+ end
+
+ let(:merge_request_diffs) { table(:merge_request_diffs) }
+ let(:merge_requests) { table(:merge_requests) }
+ let(:projects) { table(:projects) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ projects.create!(id: 1, name: 'gitlab', path: 'gitlab')
+
+ merge_requests.create!(id: 1, target_project_id: 1, source_project_id: 1, target_branch: 'feature', source_branch: 'master')
+
+ merge_request_diffs.create!(id: 1, merge_request_id: 1, st_commits: YAML.dump([]), st_diffs: nil)
+ merge_request_diffs.create!(id: 2, merge_request_id: 1, st_commits: nil, st_diffs: YAML.dump([]))
+ merge_request_diffs.create!(id: 3, merge_request_id: 1, st_commits: nil, st_diffs: nil)
+ merge_request_diffs.create!(id: 4, merge_request_id: 1, st_commits: YAML.dump([]), st_diffs: YAML.dump([]))
+ end
+
+ it 'correctly schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes.from_now, 1, 1)
+ expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes.from_now, 2, 2)
+ expect(described_class::MIGRATION).to be_scheduled_migration(15.minutes.from_now, 4, 4)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.inline! do
+ non_empty = 'st_commits IS NOT NULL OR st_diffs IS NOT NULL'
+
+ expect(merge_request_diffs.where(non_empty).count).to eq 3
+
+ migrate!
+
+ expect(merge_request_diffs.where(non_empty).count).to eq 0
+ end
+ end
+end
diff --git a/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb b/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb
new file mode 100644
index 00000000000..6f7a730edff
--- /dev/null
+++ b/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb')
+
+describe TurnNestedGroupsIntoRegularGroupsForMysql do
+ let!(:parent_group) { create(:group) }
+ let!(:child_group) { create(:group, parent: parent_group) }
+ let!(:project) { create(:project, :empty_repo, namespace: child_group) }
+ let!(:member) { create(:user) }
+ let(:migration) { described_class.new }
+
+ before do
+ parent_group.add_developer(member)
+
+ allow(migration).to receive(:run_migration?).and_return(true)
+ allow(migration).to receive(:verbose).and_return(false)
+ end
+
+ describe '#up' do
+ let(:updated_project) do
+ # path_with_namespace is memoized in an instance variable so we retrieve a
+ # new row here to work around that.
+ Project.find(project.id)
+ end
+
+ before do
+ migration.up
+ end
+
+ it 'unsets the parent_id column' do
+ expect(Namespace.where('parent_id IS NOT NULL').any?).to eq(false)
+ end
+
+ it 'adds members of parent groups as members to the migrated group' do
+ is_member = child_group.members
+ .where(user_id: member, access_level: Gitlab::Access::DEVELOPER).any?
+
+ expect(is_member).to eq(true)
+ end
+
+ it 'update the path of the nested group' do
+ child_group.reload
+
+ expect(child_group.path).to eq("#{parent_group.name}-#{child_group.name}")
+ end
+
+ it 'renames projects of the nested group' do
+ expect(updated_project.full_path)
+ .to eq("#{parent_group.name}-#{child_group.name}/#{updated_project.path}")
+ end
+
+ it 'renames the repository of any projects' do
+ expect(updated_project.repository.path)
+ .to end_with("#{parent_group.name}-#{child_group.name}/#{updated_project.path}.git")
+
+ expect(File.directory?(updated_project.repository.path)).to eq(true)
+ end
+
+ it 'creates a redirect route for renamed projects' do
+ exists = RedirectRoute
+ .where(source_type: 'Project', source_id: project.id)
+ .any?
+
+ expect(exists).to eq(true)
+ end
+ end
+end
diff --git a/spec/migrations/update_retried_for_ci_build_spec.rb b/spec/migrations/update_retried_for_ci_build_spec.rb
new file mode 100644
index 00000000000..3742b4dafe5
--- /dev/null
+++ b/spec/migrations/update_retried_for_ci_build_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170503004427_update_retried_for_ci_build.rb')
+
+describe UpdateRetriedForCiBuild, truncate: true do
+ let(:pipeline) { create(:ci_pipeline) }
+ let!(:build_old) { create(:ci_build, pipeline: pipeline, name: 'test') }
+ let!(:build_new) { create(:ci_build, pipeline: pipeline, name: 'test') }
+
+ before do
+ described_class.new.up
+ end
+
+ it 'updates ci_builds.is_retried' do
+ expect(build_old.reload).to be_retried
+ expect(build_new.reload).not_to be_retried
+ end
+end
diff --git a/spec/migrations/update_upload_paths_to_system_spec.rb b/spec/migrations/update_upload_paths_to_system_spec.rb
new file mode 100644
index 00000000000..0a45c5ea32d
--- /dev/null
+++ b/spec/migrations/update_upload_paths_to_system_spec.rb
@@ -0,0 +1,53 @@
+require "spec_helper"
+require Rails.root.join("db", "post_migrate", "20170317162059_update_upload_paths_to_system.rb")
+
+describe UpdateUploadPathsToSystem do
+ let(:migration) { described_class.new }
+
+ before do
+ allow(migration).to receive(:say)
+ end
+
+ describe "#uploads_to_switch_to_new_path" do
+ it "contains only uploads with the old path for the correct models" do
+ _upload_for_other_type = create(:upload, model: create(:ci_pipeline), path: "uploads/ci_pipeline/avatar.jpg")
+ _upload_with_system_path = create(:upload, model: create(:project), path: "uploads/-/system/project/avatar.jpg")
+ _upload_with_other_path = create(:upload, model: create(:project), path: "thelongsecretforafileupload/avatar.jpg")
+ old_upload = create(:upload, model: create(:project), path: "uploads/project/avatar.jpg")
+ group_upload = create(:upload, model: create(:group), path: "uploads/group/avatar.jpg")
+
+ expect(Upload.where(migration.uploads_to_switch_to_new_path)).to contain_exactly(old_upload, group_upload)
+ end
+ end
+
+ describe "#uploads_to_switch_to_old_path" do
+ it "contains only uploads with the new path for the correct models" do
+ _upload_for_other_type = create(:upload, model: create(:ci_pipeline), path: "uploads/ci_pipeline/avatar.jpg")
+ upload_with_system_path = create(:upload, model: create(:project), path: "uploads/-/system/project/avatar.jpg")
+ _upload_with_other_path = create(:upload, model: create(:project), path: "thelongsecretforafileupload/avatar.jpg")
+ _old_upload = create(:upload, model: create(:project), path: "uploads/project/avatar.jpg")
+
+ expect(Upload.where(migration.uploads_to_switch_to_old_path)).to contain_exactly(upload_with_system_path)
+ end
+ end
+
+ describe "#up", truncate: true do
+ it "updates old upload records to the new path" do
+ old_upload = create(:upload, model: create(:project), path: "uploads/project/avatar.jpg")
+
+ migration.up
+
+ expect(old_upload.reload.path).to eq("uploads/-/system/project/avatar.jpg")
+ end
+ end
+
+ describe "#down", truncate: true do
+ it "updates the new system patsh to the old paths" do
+ new_upload = create(:upload, model: create(:project), path: "uploads/-/system/project/avatar.jpg")
+
+ migration.down
+
+ expect(new_upload.reload.path).to eq("uploads/project/avatar.jpg")
+ end
+ end
+end
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 92d70cfc64c..71aa51e1857 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -1,14 +1,14 @@
require 'spec_helper'
-describe Ability, lib: true do
+describe Ability do
context 'using a nil subject' do
- it 'is always empty' do
- expect(Ability.allowed(nil, nil).to_set).to be_empty
+ it 'has no permissions' do
+ expect(described_class.policy_for(nil, nil)).to be_banned
end
end
describe '.can_edit_note?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:note) { create(:note_on_issue, project: project) }
context 'using an anonymous user' do
@@ -66,22 +66,22 @@ describe Ability, lib: true do
describe '.users_that_can_read_project' do
context 'using a public project' do
it 'returns all the users' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
user = build(:user)
- expect(described_class.users_that_can_read_project([user], project)).
- to eq([user])
+ expect(described_class.users_that_can_read_project([user], project))
+ .to eq([user])
end
end
context 'using an internal project' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
it 'returns users that are administrators' do
user = build(:user, admin: true)
- expect(described_class.users_that_can_read_project([user], project)).
- to eq([user])
+ expect(described_class.users_that_can_read_project([user], project))
+ .to eq([user])
end
it 'returns internal users while skipping external users' do
@@ -89,8 +89,8 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([user1])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([user1])
end
it 'returns external users if they are the project owner' do
@@ -98,10 +98,10 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(project).to receive(:owner).twice.and_return(user1)
+ expect(project).to receive(:owner).at_least(:once).and_return(user1)
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([user1])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([user1])
end
it 'returns external users if they are project members' do
@@ -109,10 +109,10 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(project.team).to receive(:members).twice.and_return([user1])
+ expect(project.team).to receive(:members).at_least(:once).and_return([user1])
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([user1])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([user1])
end
it 'returns an empty Array if all users are external users without access' do
@@ -120,19 +120,19 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([])
end
end
context 'using a private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it 'returns users that are administrators' do
user = build(:user, admin: true)
- expect(described_class.users_that_can_read_project([user], project)).
- to eq([user])
+ expect(described_class.users_that_can_read_project([user], project))
+ .to eq([user])
end
it 'returns external users if they are the project owner' do
@@ -140,10 +140,10 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(project).to receive(:owner).twice.and_return(user1)
+ expect(project).to receive(:owner).at_least(:once).and_return(user1)
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([user1])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([user1])
end
it 'returns external users if they are project members' do
@@ -151,10 +151,10 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(project.team).to receive(:members).twice.and_return([user1])
+ expect(project.team).to receive(:members).at_least(:once).and_return([user1])
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([user1])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([user1])
end
it 'returns an empty Array if all users are internal users without access' do
@@ -162,8 +162,8 @@ describe Ability, lib: true do
user2 = build(:user)
users = [user1, user2]
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([])
end
it 'returns an empty Array if all users are external users without access' do
@@ -171,8 +171,8 @@ describe Ability, lib: true do
user2 = build(:user, external: true)
users = [user1, user2]
- expect(described_class.users_that_can_read_project(users, project)).
- to eq([])
+ expect(described_class.users_that_can_read_project(users, project))
+ .to eq([])
end
end
end
@@ -210,8 +210,8 @@ describe Ability, lib: true do
user = build(:user, admin: true)
issue = build(:issue)
- expect(described_class.issues_readable_by_user([issue], user)).
- to eq([issue])
+ expect(described_class.issues_readable_by_user([issue], user))
+ .to eq([issue])
end
end
@@ -222,8 +222,8 @@ describe Ability, lib: true do
expect(issue).to receive(:readable_by?).with(user).and_return(true)
- expect(described_class.issues_readable_by_user([issue], user)).
- to eq([issue])
+ expect(described_class.issues_readable_by_user([issue], user))
+ .to eq([issue])
end
it 'returns an empty Array when no issues are readable' do
@@ -244,8 +244,8 @@ describe Ability, lib: true do
expect(hidden_issue).to receive(:publicly_visible?).and_return(false)
expect(visible_issue).to receive(:publicly_visible?).and_return(true)
- issues = described_class.
- issues_readable_by_user([hidden_issue, visible_issue])
+ issues = described_class
+ .issues_readable_by_user([hidden_issue, visible_issue])
expect(issues).to eq([visible_issue])
end
@@ -253,14 +253,17 @@ describe Ability, lib: true do
end
describe '.project_disabled_features_rules' do
- let(:project) { create(:empty_project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_disabled) }
- subject { described_class.allowed(project.owner, project) }
+ subject { described_class.policy_for(project.owner, project) }
context 'wiki named abilities' do
it 'disables wiki abilities if the project has no wiki' do
expect(project).to receive(:has_external_wiki?).and_return(false)
- expect(subject).not_to include(:read_wiki, :create_wiki, :update_wiki, :admin_wiki)
+ expect(subject).not_to be_allowed(:read_wiki)
+ expect(subject).not_to be_allowed(:create_wiki)
+ expect(subject).not_to be_allowed(:update_wiki)
+ expect(subject).not_to be_allowed(:admin_wiki)
end
end
end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index ced93c8f762..d4da30b1641 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe AbuseReport, type: :model do
+RSpec.describe AbuseReport do
subject { create(:abuse_report) }
let(:user) { create(:admin) }
@@ -28,9 +28,7 @@ RSpec.describe AbuseReport, type: :model do
end
it 'lets a worker delete the user' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id,
- delete_solo_owned_groups: true,
- hard_delete: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true)
subject.remove_user(deleted_by: user)
end
@@ -38,8 +36,8 @@ RSpec.describe AbuseReport, type: :model do
describe '#notify' do
it 'delivers' do
- expect(AbuseReportMailer).to receive(:notify).with(subject.id).
- and_return(spy)
+ expect(AbuseReportMailer).to receive(:notify).with(subject.id)
+ .and_return(spy)
subject.notify
end
diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb
index 1060bf3cbf4..b5d5d58697b 100644
--- a/spec/models/appearance_spec.rb
+++ b/spec/models/appearance_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe Appearance, type: :model do
+RSpec.describe Appearance do
subject { build(:appearance) }
it { is_expected.to be_valid }
@@ -9,4 +9,39 @@ RSpec.describe Appearance, type: :model do
it { is_expected.to validate_presence_of(:description) }
it { is_expected.to have_many(:uploads).dependent(:destroy) }
+
+ describe '.current', :use_clean_rails_memory_store_caching do
+ let!(:appearance) { create(:appearance) }
+
+ it 'returns the current appearance row' do
+ expect(described_class.current).to eq(appearance)
+ end
+
+ it 'caches the result' do
+ expect(described_class).to receive(:first).once
+
+ 2.times { described_class.current }
+ end
+ end
+
+ describe '#flush_redis_cache' do
+ it 'flushes the cache in Redis' do
+ appearance = create(:appearance)
+
+ expect(Rails.cache).to receive(:delete).with(described_class::CACHE_KEY)
+
+ appearance.flush_redis_cache
+ end
+ end
+
+ describe '#single_appearance_row' do
+ it 'adds an error when more than 1 row exists' do
+ create(:appearance)
+
+ new_row = build(:appearance)
+ new_row.save
+
+ expect(new_row.valid?).to eq(false)
+ end
+ end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index c2c19c62048..359753b600e 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe ApplicationSetting, models: true do
- let(:setting) { ApplicationSetting.create_from_defaults }
+describe ApplicationSetting do
+ let(:setting) { described_class.create_from_defaults }
it { expect(setting).to be_valid }
it { expect(setting.uuid).to be_present }
@@ -78,7 +78,9 @@ describe ApplicationSetting, models: true do
# Upgraded databases will have this sort of content
context 'repository_storages is a String, not an Array' do
- before { setting.__send__(:raw_write_attribute, :repository_storages, 'default') }
+ before do
+ setting.__send__(:raw_write_attribute, :repository_storages, 'default')
+ end
it { expect(setting.repository_storages_before_type_cast).to eq('default') }
it { expect(setting.repository_storages).to eq(['default']) }
@@ -89,7 +91,7 @@ describe ApplicationSetting, models: true do
storages = {
'custom1' => 'tmp/tests/custom_repositories_1',
'custom2' => 'tmp/tests/custom_repositories_2',
- 'custom3' => 'tmp/tests/custom_repositories_3',
+ 'custom3' => 'tmp/tests/custom_repositories_3'
}
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
@@ -153,6 +155,18 @@ describe ApplicationSetting, models: true do
end
end
+ describe '.current' do
+ context 'redis unavailable' do
+ it 'returns an ApplicationSetting' do
+ allow(Rails.cache).to receive(:fetch).and_call_original
+ allow(described_class).to receive(:last).and_return(:last)
+ expect(Rails.cache).to receive(:fetch).with(ApplicationSetting::CACHE_KEY).and_raise(ArgumentError)
+
+ expect(described_class.current).to eq(:last)
+ end
+ end
+ end
+
context 'restricted signup domains' do
it 'sets single domain' do
setting.domain_whitelist_raw = 'example.com'
@@ -211,4 +225,220 @@ describe ApplicationSetting, models: true do
expect(setting.domain_blacklist).to contain_exactly('example.com', 'test.com', 'foo.bar')
end
end
+
+ describe 'performance bar settings' do
+ describe 'performance_bar_allowed_group_id=' do
+ context 'with a blank path' do
+ before do
+ setting.performance_bar_allowed_group_id = create(:group).full_path
+ end
+
+ it 'persists nil for a "" path and clears allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_allowed_group_id = ''
+
+ expect(setting.performance_bar_allowed_group_id).to be_nil
+ end
+ end
+
+ context 'with an invalid path' do
+ it 'does not persist an invalid group path' do
+ setting.performance_bar_allowed_group_id = 'foo'
+
+ expect(setting.performance_bar_allowed_group_id).to be_nil
+ end
+ end
+
+ context 'with a path to an existing group' do
+ let(:group) { create(:group) }
+
+ it 'persists a valid group path and clears allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_allowed_group_id = group.full_path
+
+ expect(setting.performance_bar_allowed_group_id).to eq(group.id)
+ end
+
+ context 'when the given path is the same' do
+ context 'with a blank path' do
+ before do
+ setting.performance_bar_allowed_group_id = nil
+ end
+
+ it 'clears the cached allowed user IDs' do
+ expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_allowed_group_id = ''
+ end
+ end
+
+ context 'with a valid path' do
+ before do
+ setting.performance_bar_allowed_group_id = group.full_path
+ end
+
+ it 'clears the cached allowed user IDs' do
+ expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_allowed_group_id = group.full_path
+ end
+ end
+ end
+ end
+ end
+
+ describe 'performance_bar_allowed_group' do
+ context 'with no performance_bar_allowed_group_id saved' do
+ it 'returns nil' do
+ expect(setting.performance_bar_allowed_group).to be_nil
+ end
+ end
+
+ context 'with a performance_bar_allowed_group_id saved' do
+ let(:group) { create(:group) }
+
+ before do
+ setting.performance_bar_allowed_group_id = group.full_path
+ end
+
+ it 'returns the group' do
+ expect(setting.performance_bar_allowed_group).to eq(group)
+ end
+ end
+ end
+
+ describe 'performance_bar_enabled' do
+ context 'with the Performance Bar is enabled' do
+ let(:group) { create(:group) }
+
+ before do
+ setting.performance_bar_allowed_group_id = group.full_path
+ end
+
+ it 'returns true' do
+ expect(setting.performance_bar_enabled).to be_truthy
+ end
+ end
+ end
+
+ describe 'performance_bar_enabled=' do
+ context 'when the performance bar is enabled' do
+ let(:group) { create(:group) }
+
+ before do
+ setting.performance_bar_allowed_group_id = group.full_path
+ end
+
+ context 'when passing true' do
+ it 'does not clear allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_enabled = true
+
+ expect(setting.performance_bar_allowed_group_id).to eq(group.id)
+ expect(setting.performance_bar_enabled).to be_truthy
+ end
+ end
+
+ context 'when passing false' do
+ it 'disables the performance bar and clears allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_enabled = false
+
+ expect(setting.performance_bar_allowed_group_id).to be_nil
+ expect(setting.performance_bar_enabled).to be_falsey
+ end
+ end
+ end
+
+ context 'when the performance bar is disabled' do
+ context 'when passing true' do
+ it 'does nothing and does not clear allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_enabled = true
+
+ expect(setting.performance_bar_allowed_group_id).to be_nil
+ expect(setting.performance_bar_enabled).to be_falsey
+ end
+ end
+
+ context 'when passing false' do
+ it 'does nothing and does not clear allowed user IDs cache' do
+ expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
+
+ setting.performance_bar_enabled = false
+
+ expect(setting.performance_bar_allowed_group_id).to be_nil
+ expect(setting.performance_bar_enabled).to be_falsey
+ end
+ end
+ end
+ end
+ end
+
+ describe 'usage ping settings' do
+ context 'when the usage ping is disabled in gitlab.yml' do
+ before do
+ allow(Settings.gitlab).to receive(:usage_ping_enabled).and_return(false)
+ end
+
+ it 'does not allow the usage ping to be configured' do
+ expect(setting.usage_ping_can_be_configured?).to be_falsey
+ end
+
+ context 'when the usage ping is disabled in the DB' do
+ before do
+ setting.usage_ping_enabled = false
+ end
+
+ it 'returns false for usage_ping_enabled' do
+ expect(setting.usage_ping_enabled).to be_falsey
+ end
+ end
+
+ context 'when the usage ping is enabled in the DB' do
+ before do
+ setting.usage_ping_enabled = true
+ end
+
+ it 'returns false for usage_ping_enabled' do
+ expect(setting.usage_ping_enabled).to be_falsey
+ end
+ end
+ end
+
+ context 'when the usage ping is enabled in gitlab.yml' do
+ before do
+ allow(Settings.gitlab).to receive(:usage_ping_enabled).and_return(true)
+ end
+
+ it 'allows the usage ping to be configured' do
+ expect(setting.usage_ping_can_be_configured?).to be_truthy
+ end
+
+ context 'when the usage ping is disabled in the DB' do
+ before do
+ setting.usage_ping_enabled = false
+ end
+
+ it 'returns false for usage_ping_enabled' do
+ expect(setting.usage_ping_enabled).to be_falsey
+ end
+ end
+
+ context 'when the usage ping is enabled in the DB' do
+ before do
+ setting.usage_ping_enabled = true
+ end
+
+ it 'returns true for usage_ping_enabled' do
+ expect(setting.usage_ping_enabled).to be_truthy
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb
index 2a9a27752c1..87e60d9c16b 100644
--- a/spec/models/award_emoji_spec.rb
+++ b/spec/models/award_emoji_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe AwardEmoji, models: true do
+describe AwardEmoji do
describe 'Associations' do
it { is_expected.to belong_to(:awardable) }
it { is_expected.to belong_to(:user) }
diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb
index e5dd57fc4bb..47342f98283 100644
--- a/spec/models/blob_spec.rb
+++ b/spec/models/blob_spec.rb
@@ -2,6 +2,14 @@
require 'rails_helper'
describe Blob do
+ include FakeBlobHelpers
+
+ let(:project) { build(:project, lfs_enabled: true) }
+
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ end
+
describe '.decorate' do
it 'returns NilClass when given nil' do
expect(described_class.decorate(nil)).to be_nil
@@ -12,7 +20,7 @@ describe Blob do
context 'using a binary blob' do
it 'returns the data as-is' do
data = "\n\xFF\xB9\xC3"
- blob = described_class.new(double(binary?: true, data: data))
+ blob = fake_blob(binary: true, data: data)
expect(blob.data).to eq(data)
end
@@ -20,202 +28,338 @@ describe Blob do
context 'using a text blob' do
it 'converts the data to UTF-8' do
- blob = described_class.new(double(binary?: false, data: "\n\xFF\xB9\xC3"))
+ blob = fake_blob(binary: false, data: "\n\xFF\xB9\xC3")
expect(blob.data).to eq("\n���")
end
end
end
- describe '#svg?' do
- it 'is falsey when not text' do
- git_blob = double(text?: false)
+ describe '#external_storage_error?' do
+ context 'if the blob is stored in LFS' do
+ let(:blob) { fake_blob(path: 'file.pdf', lfs: true) }
- expect(described_class.decorate(git_blob)).not_to be_svg
- end
+ context 'when the project has LFS enabled' do
+ it 'returns false' do
+ expect(blob.external_storage_error?).to be_falsey
+ end
+ end
- it 'is falsey when no language is detected' do
- git_blob = double(text?: true, language: nil)
+ context 'when the project does not have LFS enabled' do
+ before do
+ project.lfs_enabled = false
+ end
- expect(described_class.decorate(git_blob)).not_to be_svg
+ it 'returns true' do
+ expect(blob.external_storage_error?).to be_truthy
+ end
+ end
end
- it' is falsey when language is not SVG' do
- git_blob = double(text?: true, language: double(name: 'XML'))
+ context 'if the blob is not stored in LFS' do
+ let(:blob) { fake_blob(path: 'file.md') }
- expect(described_class.decorate(git_blob)).not_to be_svg
+ it 'returns false' do
+ expect(blob.external_storage_error?).to be_falsey
+ end
end
+ end
- it 'is truthy when language is SVG' do
- git_blob = double(text?: true, language: double(name: 'SVG'))
+ describe '#stored_externally?' do
+ context 'if the blob is stored in LFS' do
+ let(:blob) { fake_blob(path: 'file.pdf', lfs: true) }
- expect(described_class.decorate(git_blob)).to be_svg
- end
- end
+ context 'when the project has LFS enabled' do
+ it 'returns true' do
+ expect(blob.stored_externally?).to be_truthy
+ end
+ end
- describe '#pdf?' do
- it 'is falsey when file extension is not .pdf' do
- git_blob = Gitlab::Git::Blob.new(name: 'git_blob.txt')
+ context 'when the project does not have LFS enabled' do
+ before do
+ project.lfs_enabled = false
+ end
- expect(described_class.decorate(git_blob)).not_to be_pdf
+ it 'returns false' do
+ expect(blob.stored_externally?).to be_falsey
+ end
+ end
end
- it 'is truthy when file extension is .pdf' do
- git_blob = Gitlab::Git::Blob.new(name: 'git_blob.pdf')
+ context 'if the blob is not stored in LFS' do
+ let(:blob) { fake_blob(path: 'file.md') }
- expect(described_class.decorate(git_blob)).to be_pdf
+ it 'returns false' do
+ expect(blob.stored_externally?).to be_falsey
+ end
end
end
- describe '#ipython_notebook?' do
- it 'is falsey when language is not Jupyter Notebook' do
- git_blob = double(text?: true, language: double(name: 'JSON'))
+ describe '#raw_binary?' do
+ context 'if the blob is stored externally' do
+ context 'if the extension has a rich viewer' do
+ context 'if the viewer is binary' do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.pdf', lfs: true)
- expect(described_class.decorate(git_blob)).not_to be_ipython_notebook
- end
+ expect(blob.raw_binary?).to be_truthy
+ end
+ end
+
+ context 'if the viewer is text-based' do
+ it 'return false' do
+ blob = fake_blob(path: 'file.md', lfs: true)
- it 'is truthy when language is Jupyter Notebook' do
- git_blob = double(text?: true, language: double(name: 'Jupyter Notebook'))
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
+ end
- expect(described_class.decorate(git_blob)).to be_ipython_notebook
+ context "if the extension doesn't have a rich viewer" do
+ context 'if the extension has a text mime type' do
+ context 'if the extension is for a programming language' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.txt', lfs: true)
+
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
+
+ context 'if the extension is not for a programming language' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.ics', lfs: true)
+
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
+ end
+
+ context 'if the extension has a binary mime type' do
+ context 'if the extension is for a programming language' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.rb', lfs: true)
+
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
+
+ context 'if the extension is not for a programming language' do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.exe', lfs: true)
+
+ expect(blob.raw_binary?).to be_truthy
+ end
+ end
+ end
+
+ context 'if the extension has an unknown mime type' do
+ context 'if the extension is for a programming language' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.ini', lfs: true)
+
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
+
+ context 'if the extension is not for a programming language' do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.wtf', lfs: true)
+
+ expect(blob.raw_binary?).to be_truthy
+ end
+ end
+ end
+ end
end
- end
- describe '#sketch?' do
- it 'is falsey with image extension' do
- git_blob = Gitlab::Git::Blob.new(name: "design.png")
+ context 'if the blob is not stored externally' do
+ context 'if the blob is binary' do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.pdf', binary: true)
+
+ expect(blob.raw_binary?).to be_truthy
+ end
+ end
+
+ context 'if the blob is text-based' do
+ it 'return false' do
+ blob = fake_blob(path: 'file.md')
- expect(described_class.decorate(git_blob)).not_to be_sketch
+ expect(blob.raw_binary?).to be_falsey
+ end
+ end
end
+ end
- it 'is truthy with sketch extension' do
- git_blob = Gitlab::Git::Blob.new(name: "design.sketch")
+ describe '#extension' do
+ it 'returns the extension' do
+ blob = fake_blob(path: 'file.md')
- expect(described_class.decorate(git_blob)).to be_sketch
+ expect(blob.extension).to eq('md')
end
end
- describe '#video?' do
- it 'is falsey with image extension' do
- git_blob = Gitlab::Git::Blob.new(name: 'image.png')
+ describe '#file_type' do
+ it 'returns the file type' do
+ blob = fake_blob(path: 'README.md')
- expect(described_class.decorate(git_blob)).not_to be_video
+ expect(blob.file_type).to eq(:readme)
end
+ end
- UploaderHelper::VIDEO_EXT.each do |ext|
- it "is truthy when extension is .#{ext}" do
- git_blob = Gitlab::Git::Blob.new(name: "video.#{ext}")
+ describe '#simple_viewer' do
+ context 'when the blob is empty' do
+ it 'returns an empty viewer' do
+ blob = fake_blob(data: '', size: 0)
- expect(described_class.decorate(git_blob)).to be_video
+ expect(blob.simple_viewer).to be_a(BlobViewer::Empty)
end
end
- end
- describe '#stl?' do
- it 'is falsey with image extension' do
- git_blob = Gitlab::Git::Blob.new(name: 'file.png')
+ context 'when the file represented by the blob is binary' do
+ it 'returns a download viewer' do
+ blob = fake_blob(binary: true)
- expect(described_class.decorate(git_blob)).not_to be_stl
+ expect(blob.simple_viewer).to be_a(BlobViewer::Download)
+ end
end
- it 'is truthy with STL extension' do
- git_blob = Gitlab::Git::Blob.new(name: 'file.stl')
+ context 'when the file represented by the blob is text-based' do
+ it 'returns a text viewer' do
+ blob = fake_blob
- expect(described_class.decorate(git_blob)).to be_stl
+ expect(blob.simple_viewer).to be_a(BlobViewer::Text)
+ end
end
end
- describe '#to_partial_path' do
- let(:project) { double(lfs_enabled?: true) }
+ describe '#rich_viewer' do
+ context 'when the blob has an external storage error' do
+ before do
+ project.lfs_enabled = false
+ end
- def stubbed_blob(overrides = {})
- overrides.reverse_merge!(
- name: nil,
- image?: false,
- language: nil,
- lfs_pointer?: false,
- svg?: false,
- text?: false,
- binary?: false,
- stl?: false
- )
+ it 'returns nil' do
+ blob = fake_blob(path: 'file.pdf', lfs: true)
- described_class.decorate(Gitlab::Git::Blob.new({})).tap do |blob|
- allow(blob).to receive_messages(overrides)
+ expect(blob.rich_viewer).to be_nil
end
end
- it 'handles LFS pointers with LFS enabled' do
- blob = stubbed_blob(lfs_pointer?: true, text?: true)
- expect(blob.to_partial_path(project)).to eq 'download'
- end
+ context 'when the blob is empty' do
+ it 'returns nil' do
+ blob = fake_blob(data: '')
- it 'handles LFS pointers with LFS disabled' do
- blob = stubbed_blob(lfs_pointer?: true, text?: true)
- project = double(lfs_enabled?: false)
- expect(blob.to_partial_path(project)).to eq 'text'
+ expect(blob.rich_viewer).to be_nil
+ end
end
- it 'handles SVGs' do
- blob = stubbed_blob(text?: true, svg?: true)
- expect(blob.to_partial_path(project)).to eq 'svg'
+ context 'when the blob is stored externally' do
+ it 'returns a matching viewer' do
+ blob = fake_blob(path: 'file.pdf', lfs: true)
+
+ expect(blob.rich_viewer).to be_a(BlobViewer::PDF)
+ end
end
- it 'handles images' do
- blob = stubbed_blob(image?: true)
- expect(blob.to_partial_path(project)).to eq 'image'
+ context 'when the blob is binary' do
+ it 'returns a matching binary viewer' do
+ blob = fake_blob(path: 'file.pdf', binary: true)
+
+ expect(blob.rich_viewer).to be_a(BlobViewer::PDF)
+ end
end
- it 'handles text' do
- blob = stubbed_blob(text?: true, name: 'test.txt')
- expect(blob.to_partial_path(project)).to eq 'text'
+ context 'when the blob is text-based' do
+ it 'returns a matching text-based viewer' do
+ blob = fake_blob(path: 'file.md')
+
+ expect(blob.rich_viewer).to be_a(BlobViewer::Markup)
+ end
end
+ end
- it 'defaults to download' do
- blob = stubbed_blob
- expect(blob.to_partial_path(project)).to eq 'download'
+ describe '#auxiliary_viewer' do
+ context 'when the blob has an external storage error' do
+ before do
+ project.lfs_enabled = false
+ end
+
+ it 'returns nil' do
+ blob = fake_blob(path: 'LICENSE', lfs: true)
+
+ expect(blob.auxiliary_viewer).to be_nil
+ end
end
- it 'handles PDFs' do
- blob = stubbed_blob(name: 'blob.pdf', pdf?: true)
- expect(blob.to_partial_path(project)).to eq 'pdf'
+ context 'when the blob is empty' do
+ it 'returns nil' do
+ blob = fake_blob(data: '')
+
+ expect(blob.auxiliary_viewer).to be_nil
+ end
end
- it 'handles iPython notebooks' do
- blob = stubbed_blob(text?: true, ipython_notebook?: true)
- expect(blob.to_partial_path(project)).to eq 'notebook'
+ context 'when the blob is stored externally' do
+ it 'returns a matching viewer' do
+ blob = fake_blob(path: 'LICENSE', lfs: true)
+
+ expect(blob.auxiliary_viewer).to be_a(BlobViewer::License)
+ end
end
- it 'handles Sketch files' do
- blob = stubbed_blob(text?: true, sketch?: true, binary?: true)
- expect(blob.to_partial_path(project)).to eq 'sketch'
+ context 'when the blob is binary' do
+ it 'returns nil' do
+ blob = fake_blob(path: 'LICENSE', binary: true)
+
+ expect(blob.auxiliary_viewer).to be_nil
+ end
end
- it 'handles STLs' do
- blob = stubbed_blob(text?: true, stl?: true)
- expect(blob.to_partial_path(project)).to eq 'stl'
+ context 'when the blob is text-based' do
+ it 'returns a matching text-based viewer' do
+ blob = fake_blob(path: 'LICENSE')
+
+ expect(blob.auxiliary_viewer).to be_a(BlobViewer::License)
+ end
end
end
- describe '#size_within_svg_limits?' do
- let(:blob) { described_class.decorate(double(:blob)) }
+ describe '#rendered_as_text?' do
+ context 'when ignoring errors' do
+ context 'when the simple viewer is text-based' do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.md', size: 100.megabytes)
+
+ expect(blob.rendered_as_text?).to be_truthy
+ end
+ end
- it 'returns true when the blob size is smaller than the SVG limit' do
- expect(blob).to receive(:size).and_return(42)
+ context 'when the simple viewer is binary' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes)
- expect(blob.size_within_svg_limits?).to eq(true)
+ expect(blob.rendered_as_text?).to be_falsey
+ end
+ end
end
- it 'returns true when the blob size is equal to the SVG limit' do
- expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE)
+ context 'when not ignoring errors' do
+ context 'when the viewer has render errors' do
+ it 'returns false' do
+ blob = fake_blob(path: 'file.md', size: 100.megabytes)
- expect(blob.size_within_svg_limits?).to eq(true)
- end
+ expect(blob.rendered_as_text?(ignore_errors: false)).to be_falsey
+ end
+ end
- it 'returns false when the blob size is larger than the SVG limit' do
- expect(blob).to receive(:size).and_return(1.terabyte)
+ context "when the viewer doesn't have render errors" do
+ it 'returns true' do
+ blob = fake_blob(path: 'file.md')
- expect(blob.size_within_svg_limits?).to eq(false)
+ expect(blob.rendered_as_text?(ignore_errors: false)).to be_truthy
+ end
+ end
end
end
end
diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb
new file mode 100644
index 00000000000..7ba28f72215
--- /dev/null
+++ b/spec/models/blob_viewer/base_spec.rb
@@ -0,0 +1,149 @@
+require 'spec_helper'
+
+describe BlobViewer::Base do
+ include FakeBlobHelpers
+
+ let(:project) { build(:project) }
+
+ let(:viewer_class) do
+ Class.new(described_class) do
+ include BlobViewer::ServerSide
+
+ self.extensions = %w(pdf)
+ self.binary = true
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 5.megabytes
+ end
+ end
+
+ let(:viewer) { viewer_class.new(blob) }
+
+ describe '.can_render?' do
+ context 'when the extension is supported' do
+ context 'when the binaryness matches' do
+ let(:blob) { fake_blob(path: 'file.pdf', binary: true) }
+
+ it 'returns true' do
+ expect(viewer_class.can_render?(blob)).to be_truthy
+ end
+ end
+
+ context 'when the binaryness does not match' do
+ let(:blob) { fake_blob(path: 'file.pdf', binary: false) }
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(blob)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the file type is supported' do
+ before do
+ viewer_class.file_types = %i(license)
+ viewer_class.binary = false
+ end
+
+ context 'when the binaryness matches' do
+ let(:blob) { fake_blob(path: 'LICENSE', binary: false) }
+
+ it 'returns true' do
+ expect(viewer_class.can_render?(blob)).to be_truthy
+ end
+ end
+
+ context 'when the binaryness does not match' do
+ let(:blob) { fake_blob(path: 'LICENSE', binary: true) }
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(blob)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the extension and file type are not supported' do
+ let(:blob) { fake_blob(path: 'file.txt') }
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(blob)).to be_falsey
+ end
+ end
+ end
+
+ describe '#collapsed?' do
+ context 'when the blob size is larger than the collapse limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) }
+
+ it 'returns true' do
+ expect(viewer.collapsed?).to be_truthy
+ end
+ end
+
+ context 'when the blob size is smaller than the collapse limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) }
+
+ it 'returns false' do
+ expect(viewer.collapsed?).to be_falsey
+ end
+ end
+ end
+
+ describe '#too_large?' do
+ context 'when the blob size is larger than the size limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) }
+
+ it 'returns true' do
+ expect(viewer.too_large?).to be_truthy
+ end
+ end
+
+ context 'when the blob size is smaller than the size limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) }
+
+ it 'returns false' do
+ expect(viewer.too_large?).to be_falsey
+ end
+ end
+ end
+
+ describe '#render_error' do
+ context 'when the blob is expanded' do
+ before do
+ blob.expand!
+ end
+
+ context 'when the blob size is larger than the size limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) }
+
+ it 'returns :too_large' do
+ expect(viewer.render_error).to eq(:too_large)
+ end
+ end
+
+ context 'when the blob size is smaller than the size limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) }
+
+ it 'returns nil' do
+ expect(viewer.render_error).to be_nil
+ end
+ end
+ end
+
+ context 'when not expanded' do
+ context 'when the blob size is larger than the collapse limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) }
+
+ it 'returns :collapsed' do
+ expect(viewer.render_error).to eq(:collapsed)
+ end
+ end
+
+ context 'when the blob size is smaller than the collapse limit' do
+ let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) }
+
+ it 'returns nil' do
+ expect(viewer.render_error).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/changelog_spec.rb b/spec/models/blob_viewer/changelog_spec.rb
new file mode 100644
index 00000000000..db41eca0fc8
--- /dev/null
+++ b/spec/models/blob_viewer/changelog_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe BlobViewer::Changelog do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:blob) { fake_blob(path: 'CHANGELOG') }
+ subject { described_class.new(blob) }
+
+ describe '#render_error' do
+ context 'when there are no tags' do
+ before do
+ allow(project.repository).to receive(:tag_count).and_return(0)
+ end
+
+ it 'returns :no_tags' do
+ expect(subject.render_error).to eq(:no_tags)
+ end
+ end
+
+ context 'when there are tags' do
+ it 'returns nil' do
+ expect(subject.render_error).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/composer_json_spec.rb b/spec/models/blob_viewer/composer_json_spec.rb
new file mode 100644
index 00000000000..85b0d9668a0
--- /dev/null
+++ b/spec/models/blob_viewer/composer_json_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe BlobViewer::ComposerJson do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-SPEC.strip_heredoc
+ {
+ "name": "laravel/laravel",
+ "homepage": "https://laravel.com/"
+ }
+ SPEC
+ end
+ let(:blob) { fake_blob(path: 'composer.json', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#package_name' do
+ it 'returns the package name' do
+ expect(subject).to receive(:prepare!)
+
+ expect(subject.package_name).to eq('laravel/laravel')
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/gemspec_spec.rb b/spec/models/blob_viewer/gemspec_spec.rb
new file mode 100644
index 00000000000..d8c4490637f
--- /dev/null
+++ b/spec/models/blob_viewer/gemspec_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe BlobViewer::Gemspec do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-SPEC.strip_heredoc
+ Gem::Specification.new do |s|
+ s.platform = Gem::Platform::RUBY
+ s.name = "activerecord"
+ end
+ SPEC
+ end
+ let(:blob) { fake_blob(path: 'activerecord.gemspec', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#package_name' do
+ it 'returns the package name' do
+ expect(subject).to receive(:prepare!)
+
+ expect(subject.package_name).to eq('activerecord')
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/gitlab_ci_yml_spec.rb b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb
new file mode 100644
index 00000000000..bed364a8c14
--- /dev/null
+++ b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe BlobViewer::GitlabCiYml do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) }
+ let(:blob) { fake_blob(path: '.gitlab-ci.yml', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#validation_message' do
+ it 'calls prepare! on the viewer' do
+ expect(subject).to receive(:prepare!)
+
+ subject.validation_message
+ end
+
+ context 'when the configuration is valid' do
+ it 'returns nil' do
+ expect(subject.validation_message).to be_nil
+ end
+ end
+
+ context 'when the configuration is invalid' do
+ let(:data) { 'oof' }
+
+ it 'returns the error message' do
+ expect(subject.validation_message).to eq('Invalid configuration format')
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/license_spec.rb b/spec/models/blob_viewer/license_spec.rb
new file mode 100644
index 00000000000..222ed166ee0
--- /dev/null
+++ b/spec/models/blob_viewer/license_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+describe BlobViewer::License do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:blob) { fake_blob(path: 'LICENSE') }
+ subject { described_class.new(blob) }
+
+ describe '#license' do
+ it 'returns the blob project repository license' do
+ expect(subject.license).not_to be_nil
+ expect(subject.license).to eq(project.repository.license)
+ end
+ end
+
+ describe '#render_error' do
+ context 'when there is no license' do
+ before do
+ allow(project.repository).to receive(:license).and_return(nil)
+ end
+
+ it 'returns :unknown_license' do
+ expect(subject.render_error).to eq(:unknown_license)
+ end
+ end
+
+ context 'when there is a license' do
+ it 'returns nil' do
+ expect(subject.render_error).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb
new file mode 100644
index 00000000000..0f8330e91c1
--- /dev/null
+++ b/spec/models/blob_viewer/package_json_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe BlobViewer::PackageJson do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-SPEC.strip_heredoc
+ {
+ "name": "module-name",
+ "version": "10.3.1"
+ }
+ SPEC
+ end
+ let(:blob) { fake_blob(path: 'package.json', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#package_name' do
+ it 'returns the package name' do
+ expect(subject).to receive(:prepare!)
+
+ expect(subject.package_name).to eq('module-name')
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/podspec_json_spec.rb b/spec/models/blob_viewer/podspec_json_spec.rb
new file mode 100644
index 00000000000..9a23877b23f
--- /dev/null
+++ b/spec/models/blob_viewer/podspec_json_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe BlobViewer::PodspecJson do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-SPEC.strip_heredoc
+ {
+ "name": "AFNetworking",
+ "version": "2.0.0"
+ }
+ SPEC
+ end
+ let(:blob) { fake_blob(path: 'AFNetworking.podspec.json', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#package_name' do
+ it 'returns the package name' do
+ expect(subject).to receive(:prepare!)
+
+ expect(subject.package_name).to eq('AFNetworking')
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/podspec_spec.rb b/spec/models/blob_viewer/podspec_spec.rb
new file mode 100644
index 00000000000..02d06ea24d6
--- /dev/null
+++ b/spec/models/blob_viewer/podspec_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe BlobViewer::Podspec do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-SPEC.strip_heredoc
+ Pod::Spec.new do |spec|
+ spec.name = 'Reachability'
+ spec.version = '3.1.0'
+ end
+ SPEC
+ end
+ let(:blob) { fake_blob(path: 'Reachability.podspec', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#package_name' do
+ it 'returns the package name' do
+ expect(subject).to receive(:prepare!)
+
+ expect(subject.package_name).to eq('Reachability')
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb
new file mode 100644
index 00000000000..926df21ffda
--- /dev/null
+++ b/spec/models/blob_viewer/readme_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe BlobViewer::Readme do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:blob) { fake_blob(path: 'README.md') }
+ subject { described_class.new(blob) }
+
+ describe '#render_error' do
+ context 'when there is no wiki' do
+ it 'returns :no_wiki' do
+ expect(subject.render_error).to eq(:no_wiki)
+ end
+ end
+
+ context 'when there is an external wiki' do
+ before do
+ project.has_external_wiki = true
+ end
+
+ it 'returns nil' do
+ expect(subject.render_error).to be_nil
+ end
+ end
+
+ context 'when there is a local wiki' do
+ before do
+ project.wiki_enabled = true
+ end
+
+ context 'when the wiki is empty' do
+ it 'returns :no_wiki' do
+ expect(subject.render_error).to eq(:no_wiki)
+ end
+ end
+
+ context 'when the wiki is not empty' do
+ before do
+ WikiPages::CreateService.new(project, project.owner, title: 'home', content: 'Home page').execute
+ end
+
+ it 'returns nil' do
+ expect(subject.render_error).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/route_map_spec.rb b/spec/models/blob_viewer/route_map_spec.rb
new file mode 100644
index 00000000000..c13662427b0
--- /dev/null
+++ b/spec/models/blob_viewer/route_map_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe BlobViewer::RouteMap do
+ include FakeBlobHelpers
+
+ let(:project) { build_stubbed(:project) }
+ let(:data) do
+ <<-MAP.strip_heredoc
+ # Team data
+ - source: 'data/team.yml'
+ public: 'team/'
+ MAP
+ end
+ let(:blob) { fake_blob(path: '.gitlab/route-map.yml', data: data) }
+ subject { described_class.new(blob) }
+
+ describe '#validation_message' do
+ it 'calls prepare! on the viewer' do
+ expect(subject).to receive(:prepare!)
+
+ subject.validation_message
+ end
+
+ context 'when the configuration is valid' do
+ it 'returns nil' do
+ expect(subject.validation_message).to be_nil
+ end
+ end
+
+ context 'when the configuration is invalid' do
+ let(:data) { 'oof' }
+
+ it 'returns the error message' do
+ expect(subject.validation_message).to eq('Route map is not an array')
+ end
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/server_side_spec.rb b/spec/models/blob_viewer/server_side_spec.rb
new file mode 100644
index 00000000000..63790486200
--- /dev/null
+++ b/spec/models/blob_viewer/server_side_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe BlobViewer::ServerSide do
+ include FakeBlobHelpers
+
+ let(:project) { build(:project) }
+
+ let(:viewer_class) do
+ Class.new(BlobViewer::Base) do
+ include BlobViewer::ServerSide
+ end
+ end
+
+ subject { viewer_class.new(blob) }
+
+ describe '#prepare!' do
+ let(:blob) { fake_blob(path: 'file.txt') }
+
+ it 'loads all blob data' do
+ expect(blob).to receive(:load_all_data!)
+
+ subject.prepare!
+ end
+ end
+
+ describe '#render_error' do
+ context 'when the blob is stored externally' do
+ let(:project) { build(:project, lfs_enabled: true) }
+
+ let(:blob) { fake_blob(path: 'file.pdf', lfs: true) }
+
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ end
+
+ it 'return :server_side_but_stored_externally' do
+ expect(subject.render_error).to eq(:server_side_but_stored_externally)
+ end
+ end
+ end
+end
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 219db365a91..3369aef1d3e 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe BroadcastMessage, models: true do
+describe BroadcastMessage do
subject { build(:broadcast_message) }
it { is_expected.to be_valid }
@@ -20,23 +20,38 @@ describe BroadcastMessage, models: true do
it { is_expected.not_to allow_value('000').for(:font) }
end
- describe '.current' do
- it "returns last message if time match" do
+ describe '.current', :use_clean_rails_memory_store_caching do
+ it 'returns message if time match' do
message = create(:broadcast_message)
- expect(BroadcastMessage.current).to eq message
+ expect(described_class.current).to include(message)
end
- it "returns nil if time not come" do
+ it 'returns multiple messages if time match' do
+ message1 = create(:broadcast_message)
+ message2 = create(:broadcast_message)
+
+ expect(described_class.current).to contain_exactly(message1, message2)
+ end
+
+ it 'returns empty list if time not come' do
create(:broadcast_message, :future)
- expect(BroadcastMessage.current).to be_nil
+ expect(described_class.current).to be_empty
end
- it "returns nil if time has passed" do
+ it 'returns empty list if time has passed' do
create(:broadcast_message, :expired)
- expect(BroadcastMessage.current).to be_nil
+ expect(described_class.current).to be_empty
+ end
+
+ it 'caches the output of the query' do
+ create(:broadcast_message)
+
+ expect(described_class).to receive(:where).and_call_original.once
+
+ 2.times { described_class.current }
end
end
@@ -95,4 +110,14 @@ describe BroadcastMessage, models: true do
end
end
end
+
+ describe '#flush_redis_cache' do
+ it 'flushes the Redis cache' do
+ message = create(:broadcast_message)
+
+ expect(Rails.cache).to receive(:delete).with(described_class::CACHE_KEY)
+
+ message.flush_redis_cache
+ end
+ end
end
diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb
index b02971cab82..8581bcbb08b 100644
--- a/spec/models/chat_name_spec.rb
+++ b/spec/models/chat_name_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatName, models: true do
+describe ChatName do
subject { create(:chat_name) }
it { is_expected.to belong_to(:service) }
diff --git a/spec/models/chat_team_spec.rb b/spec/models/chat_team_spec.rb
index 5283561a83f..e0e5f73e6fe 100644
--- a/spec/models/chat_team_spec.rb
+++ b/spec/models/chat_team_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatTeam, type: :model do
+describe ChatTeam do
subject { create(:chat_team) }
# Associations
diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb
new file mode 100644
index 00000000000..a10a8af5303
--- /dev/null
+++ b/spec/models/ci/artifact_blob_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe Ci::ArtifactBlob do
+ let(:build) { create(:ci_build, :artifacts) }
+ let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') }
+
+ subject { described_class.new(entry) }
+
+ describe '#id' do
+ it 'returns a hash of the path' do
+ expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path))
+ end
+ end
+
+ describe '#name' do
+ it 'returns the entry name' do
+ expect(subject.name).to eq(entry.name)
+ end
+ end
+
+ describe '#path' do
+ it 'returns the entry path' do
+ expect(subject.path).to eq(entry.path)
+ end
+ end
+
+ describe '#size' do
+ it 'returns the entry size' do
+ expect(subject.size).to eq(entry.metadata[:size])
+ end
+ end
+
+ describe '#mode' do
+ it 'returns the entry mode' do
+ expect(subject.mode).to eq(entry.metadata[:mode])
+ end
+ end
+
+ describe '#external_storage' do
+ it 'returns :build_artifact' do
+ expect(subject.external_storage).to eq(:build_artifact)
+ end
+ end
+end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 6e8845cdcf4..767f0ad9e65 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::Build, :models do
+describe Ci::Build do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, pipeline: pipeline) }
@@ -21,6 +21,18 @@ describe Ci::Build, :models do
it { is_expected.to respond_to(:has_trace?) }
it { is_expected.to respond_to(:trace) }
+ describe '.manual_actions' do
+ let!(:manual_but_created) { create(:ci_build, :manual, status: :created, pipeline: pipeline) }
+ let!(:manual_but_succeeded) { create(:ci_build, :manual, status: :success, pipeline: pipeline) }
+ let!(:manual_action) { create(:ci_build, :manual, pipeline: pipeline) }
+
+ subject { described_class.manual_actions }
+
+ it { is_expected.to include(manual_action) }
+ it { is_expected.to include(manual_but_succeeded) }
+ it { is_expected.not_to include(manual_but_created) }
+ end
+
describe '#actionize' do
context 'when build is a created' do
before do
@@ -95,12 +107,18 @@ describe Ci::Build, :models do
it { is_expected.to be_truthy }
context 'is expired' do
- before { build.update(artifacts_expire_at: Time.now - 7.days) }
+ before do
+ build.update(artifacts_expire_at: Time.now - 7.days)
+ end
+
it { is_expected.to be_falsy }
end
context 'is not expired' do
- before { build.update(artifacts_expire_at: Time.now + 7.days) }
+ before do
+ build.update(artifacts_expire_at: Time.now + 7.days)
+ end
+
it { is_expected.to be_truthy }
end
end
@@ -110,13 +128,17 @@ describe Ci::Build, :models do
subject { build.artifacts_expired? }
context 'is expired' do
- before { build.update(artifacts_expire_at: Time.now - 7.days) }
+ before do
+ build.update(artifacts_expire_at: Time.now - 7.days)
+ end
it { is_expected.to be_truthy }
end
context 'is not expired' do
- before { build.update(artifacts_expire_at: Time.now + 7.days) }
+ before do
+ build.update(artifacts_expire_at: Time.now + 7.days)
+ end
it { is_expected.to be_falsey }
end
@@ -141,7 +163,9 @@ describe Ci::Build, :models do
context 'when artifacts_expire_at is specified' do
let(:expire_at) { Time.now + 7.days }
- before { build.artifacts_expire_at = expire_at }
+ before do
+ build.artifacts_expire_at = expire_at
+ end
it { is_expected.to be_within(5).of(expire_at - Time.now) }
end
@@ -201,7 +225,7 @@ describe Ci::Build, :models do
it 'expects to have retried builds instead the original ones' do
project.add_developer(user)
- retried_rspec = Ci::Build.retry(rspec_test, user)
+ retried_rspec = described_class.retry(rspec_test, user)
expect(staging.depends_on_builds.map(&:id))
.to contain_exactly(build.id, retried_rspec.id, rubocop_test.id)
@@ -596,9 +620,9 @@ describe Ci::Build, :models do
describe '#first_pending' do
let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) }
let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') }
- subject { Ci::Build.first_pending }
+ subject { described_class.first_pending }
- it { is_expected.to be_a(Ci::Build) }
+ it { is_expected.to be_a(described_class) }
it('returns with the first pending build') { is_expected.to eq(first) }
end
@@ -778,6 +802,47 @@ describe Ci::Build, :models do
end
end
+ describe 'build auto retry feature' do
+ describe '#retries_count' do
+ subject { create(:ci_build, name: 'test', pipeline: pipeline) }
+
+ context 'when build has been retried several times' do
+ before do
+ create(:ci_build, :retried, name: 'test', pipeline: pipeline)
+ create(:ci_build, :retried, name: 'test', pipeline: pipeline)
+ end
+
+ it 'reports a correct retry count value' do
+ expect(subject.retries_count).to eq 2
+ end
+ end
+
+ context 'when build has not been retried' do
+ it 'returns zero' do
+ expect(subject.retries_count).to eq 0
+ end
+ end
+ end
+
+ describe '#retries_max' do
+ context 'when max retries value is defined' do
+ subject { create(:ci_build, options: { retry: 1 }) }
+
+ it 'returns a number of configured max retries' do
+ expect(subject.retries_max).to eq 1
+ end
+ end
+
+ context 'when max retries value is not defined' do
+ subject { create(:ci_build) }
+
+ it 'returns zero' do
+ expect(subject.retries_max).to eq 0
+ end
+ end
+ end
+ end
+
describe '#keep_artifacts!' do
let(:build) { create(:ci_build, artifacts_expire_at: Time.now + 7.days) }
@@ -839,8 +904,8 @@ describe Ci::Build, :models do
pipeline2 = create(:ci_pipeline, project: project)
@build2 = create(:ci_build, pipeline: pipeline2)
- allow(@merge_request).to receive(:commits_sha).
- and_return([pipeline.sha, pipeline2.sha])
+ allow(@merge_request).to receive(:commit_shas)
+ .and_return([pipeline.sha, pipeline2.sha])
allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request])
end
@@ -880,7 +945,7 @@ describe Ci::Build, :models do
end
context 'when build is retried' do
- let!(:new_build) { Ci::Build.retry(build, user) }
+ let!(:new_build) { described_class.retry(build, user) }
it 'does not return any of them' do
is_expected.not_to include(build, new_build)
@@ -888,7 +953,11 @@ describe Ci::Build, :models do
end
context 'when other build is retried' do
- let!(:retried_build) { Ci::Build.retry(other_build, user) }
+ let!(:retried_build) { described_class.retry(other_build, user) }
+
+ before do
+ retried_build.success
+ end
it 'returns a retried build' do
is_expected.to contain_exactly(retried_build)
@@ -897,22 +966,30 @@ describe Ci::Build, :models do
end
describe '#persisted_environment' do
- before do
- @environment = create(:environment, project: project, name: "foo-#{project.default_branch}")
+ let!(:environment) do
+ create(:environment, project: project, name: "foo-#{project.default_branch}")
end
subject { build.persisted_environment }
- context 'referenced literally' do
- let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") }
+ context 'when referenced literally' do
+ let(:build) do
+ create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}")
+ end
- it { is_expected.to eq(@environment) }
+ it { is_expected.to eq(environment) }
end
- context 'referenced with a variable' do
- let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") }
+ context 'when referenced with a variable' do
+ let(:build) do
+ create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME")
+ end
+
+ it { is_expected.to eq(environment) }
+ end
- it { is_expected.to eq(@environment) }
+ context 'when there is no environment' do
+ it { is_expected.to be_nil }
end
end
@@ -923,26 +1000,8 @@ describe Ci::Build, :models do
project.add_developer(user)
end
- context 'when build is manual' do
- it 'enqueues a build' do
- new_build = build.play(user)
-
- expect(new_build).to be_pending
- expect(new_build).to eq(build)
- end
- end
-
- context 'when build is passed' do
- before do
- build.update(status: 'success')
- end
-
- it 'creates a new build' do
- new_build = build.play(user)
-
- expect(new_build).to be_pending
- expect(new_build).not_to eq(build)
- end
+ it 'enqueues the build' do
+ expect(build.play(user)).to be_pending
end
end
@@ -980,13 +1039,17 @@ describe Ci::Build, :models do
describe '#ref_slug' do
{
- 'master' => 'master',
- '1-foo' => '1-foo',
- 'fix/1-foo' => 'fix-1-foo',
- 'fix-1-foo' => 'fix-1-foo',
- 'a' * 63 => 'a' * 63,
- 'a' * 64 => 'a' * 63,
- 'FOO' => 'foo',
+ 'master' => 'master',
+ '1-foo' => '1-foo',
+ 'fix/1-foo' => 'fix-1-foo',
+ 'fix-1-foo' => 'fix-1-foo',
+ 'a' * 63 => 'a' * 63,
+ 'a' * 64 => 'a' * 63,
+ 'FOO' => 'foo',
+ '-' + 'a' * 61 + '-' => 'a' * 61,
+ '-' + 'a' * 62 + '-' => 'a' * 62,
+ '-' + 'a' * 63 + '-' => 'a' * 62,
+ 'a' * 62 + ' ' => 'a' * 62
}.each do |ref, slug|
it "transforms #{ref} to #{slug}" do
build.ref = ref
@@ -1045,7 +1108,9 @@ describe Ci::Build, :models do
describe '#has_expiring_artifacts?' do
context 'when artifacts have expiration date set' do
- before { build.update(artifacts_expire_at: 1.day.from_now) }
+ before do
+ build.update(artifacts_expire_at: 1.day.from_now)
+ end
it 'has expiring artifacts' do
expect(build).to have_expiring_artifacts
@@ -1053,7 +1118,9 @@ describe Ci::Build, :models do
end
context 'when artifacts do not have expiration date set' do
- before { build.update(artifacts_expire_at: nil) }
+ before do
+ build.update(artifacts_expire_at: nil)
+ end
it 'does not have expiring artifacts' do
expect(build).not_to have_expiring_artifacts
@@ -1153,12 +1220,14 @@ describe Ci::Build, :models do
{ key: 'CI_PROJECT_ID', value: project.id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: project.path, public: true },
{ key: 'CI_PROJECT_PATH', value: project.full_path, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path_slug, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: project.web_url, public: true },
{ key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true },
+ { key: 'CI_CONFIG_PATH', value: pipeline.ci_yaml_file_path, public: true },
{ key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true },
{ key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false },
- { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false },
+ { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }
]
end
@@ -1190,11 +1259,6 @@ describe Ci::Build, :models do
end
context 'when build has an environment' do
- before do
- build.update(environment: 'production')
- create(:environment, project: build.project, name: 'production', slug: 'prod-slug')
- end
-
let(:environment_variables) do
[
{ key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true },
@@ -1202,7 +1266,66 @@ describe Ci::Build, :models do
]
end
- it { environment_variables.each { |v| is_expected.to include(v) } }
+ let!(:environment) do
+ create(:environment,
+ project: build.project,
+ name: 'production',
+ slug: 'prod-slug',
+ external_url: '')
+ end
+
+ before do
+ build.update(environment: 'production')
+ end
+
+ shared_examples 'containing environment variables' do
+ it { environment_variables.each { |v| is_expected.to include(v) } }
+ end
+
+ context 'when no URL was set' do
+ it_behaves_like 'containing environment variables'
+
+ it 'does not have CI_ENVIRONMENT_URL' do
+ keys = subject.map { |var| var[:key] }
+
+ expect(keys).not_to include('CI_ENVIRONMENT_URL')
+ end
+ end
+
+ context 'when an URL was set' do
+ let(:url) { 'http://host/test' }
+
+ before do
+ environment_variables <<
+ { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ context 'when the URL was set from the job' do
+ before do
+ build.update(options: { environment: { url: url } })
+ end
+
+ it_behaves_like 'containing environment variables'
+
+ context 'when variables are used in the URL, it does not expand' do
+ let(:url) { 'http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG' }
+
+ it_behaves_like 'containing environment variables'
+
+ it 'puts $CI_ENVIRONMENT_URL in the last so all other variables are available to be used when runners are trying to expand it' do
+ expect(subject.last).to eq(environment_variables.last)
+ end
+ end
+ end
+
+ context 'when the URL was not set from the job, but environment' do
+ before do
+ environment.update(external_url: url)
+ end
+
+ it_behaves_like 'containing environment variables'
+ end
+ end
end
context 'when build started manually' do
@@ -1229,16 +1352,102 @@ describe Ci::Build, :models do
it { is_expected.to include(tag_variable) }
end
- context 'when secure variable is defined' do
- let(:secure_variable) do
+ context 'when secret variable is defined' do
+ let(:secret_variable) do
+ { key: 'SECRET_KEY', value: 'secret_value', public: false }
+ end
+
+ before do
+ create(:ci_variable,
+ secret_variable.slice(:key, :value).merge(project: project))
+ end
+
+ it { is_expected.to include(secret_variable) }
+ end
+
+ context 'when protected variable is defined' do
+ let(:protected_variable) do
+ { key: 'PROTECTED_KEY', value: 'protected_value', public: false }
+ end
+
+ before do
+ create(:ci_variable,
+ :protected,
+ protected_variable.slice(:key, :value).merge(project: project))
+ end
+
+ context 'when the branch is protected' do
+ before do
+ create(:protected_branch, project: build.project, name: build.ref)
+ end
+
+ it { is_expected.to include(protected_variable) }
+ end
+
+ context 'when the tag is protected' do
+ before do
+ create(:protected_tag, project: build.project, name: build.ref)
+ end
+
+ it { is_expected.to include(protected_variable) }
+ end
+
+ context 'when the ref is not protected' do
+ it { is_expected.not_to include(protected_variable) }
+ end
+ end
+
+ context 'when group secret variable is defined' do
+ let(:secret_variable) do
{ key: 'SECRET_KEY', value: 'secret_value', public: false }
end
+ let(:group) { create(:group, :access_requestable) }
+
+ before do
+ build.project.update(group: group)
+
+ create(:ci_group_variable,
+ secret_variable.slice(:key, :value).merge(group: group))
+ end
+
+ it { is_expected.to include(secret_variable) }
+ end
+
+ context 'when group protected variable is defined' do
+ let(:protected_variable) do
+ { key: 'PROTECTED_KEY', value: 'protected_value', public: false }
+ end
+
+ let(:group) { create(:group, :access_requestable) }
+
before do
- build.project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value')
+ build.project.update(group: group)
+
+ create(:ci_group_variable,
+ :protected,
+ protected_variable.slice(:key, :value).merge(group: group))
+ end
+
+ context 'when the branch is protected' do
+ before do
+ create(:protected_branch, project: build.project, name: build.ref)
+ end
+
+ it { is_expected.to include(protected_variable) }
end
- it { is_expected.to include(secure_variable) }
+ context 'when the tag is protected' do
+ before do
+ create(:protected_tag, project: build.project, name: build.ref)
+ end
+
+ it { is_expected.to include(protected_variable) }
+ end
+
+ context 'when the ref is not protected' do
+ it { is_expected.not_to include(protected_variable) }
+ end
end
context 'when build is for triggers' do
@@ -1259,6 +1468,29 @@ describe Ci::Build, :models do
it { is_expected.to include(predefined_trigger_variable) }
end
+ context 'when pipeline has a variable' do
+ let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline) }
+
+ it { is_expected.to include(pipeline_variable.to_runner_variable) }
+ end
+
+ context 'when a job was triggered by a pipeline schedule' do
+ let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
+
+ let!(:pipeline_schedule_variable) do
+ create(:ci_pipeline_schedule_variable,
+ key: 'SCHEDULE_VARIABLE_KEY',
+ pipeline_schedule: pipeline_schedule)
+ end
+
+ before do
+ pipeline_schedule.pipelines << pipeline
+ pipeline_schedule.reload
+ end
+
+ it { is_expected.to include(pipeline_schedule_variable.to_runner_variable) }
+ end
+
context 'when yaml_variables are undefined' do
before do
build.yaml_variables = nil
@@ -1359,20 +1591,46 @@ describe Ci::Build, :models do
it { is_expected.to include(deployment_variable) }
end
+ context 'when project has custom CI config path' do
+ let(:ci_config_path) { { key: 'CI_CONFIG_PATH', value: 'custom', public: true } }
+
+ before do
+ project.update(ci_config_path: 'custom')
+ end
+
+ it { is_expected.to include(ci_config_path) }
+ end
+
context 'returns variables in valid order' do
+ let(:build_pre_var) { { key: 'build', value: 'value' } }
+ let(:project_pre_var) { { key: 'project', value: 'value' } }
+ let(:pipeline_pre_var) { { key: 'pipeline', value: 'value' } }
+ let(:build_yaml_var) { { key: 'yaml', value: 'value' } }
+
before do
- allow(build).to receive(:predefined_variables) { ['predefined'] }
- allow(project).to receive(:predefined_variables) { ['project'] }
- allow(pipeline).to receive(:predefined_variables) { ['pipeline'] }
- allow(build).to receive(:yaml_variables) { ['yaml'] }
- allow(project).to receive(:secret_variables) { ['secret'] }
+ allow(build).to receive(:predefined_variables) { [build_pre_var] }
+ allow(project).to receive(:predefined_variables) { [project_pre_var] }
+ allow(pipeline).to receive(:predefined_variables) { [pipeline_pre_var] }
+ allow(build).to receive(:yaml_variables) { [build_yaml_var] }
+
+ allow(project).to receive(:secret_variables_for)
+ .with(ref: 'master', environment: nil) do
+ [create(:ci_variable, key: 'secret', value: 'value')]
+ end
end
- it { is_expected.to eq(%w[predefined project pipeline yaml secret]) }
+ it do
+ is_expected.to eq(
+ [build_pre_var,
+ project_pre_var,
+ pipeline_pre_var,
+ build_yaml_var,
+ { key: 'secret', value: 'value', public: false }])
+ end
end
end
- describe 'State transition: any => [:pending]' do
+ describe 'state transition: any => [:pending]' do
let(:build) { create(:ci_build, :created) }
it 'queues BuildQueueWorker' do
@@ -1381,4 +1639,35 @@ describe Ci::Build, :models do
build.enqueue
end
end
+
+ describe 'state transition when build fails' do
+ context 'when build is configured to be retried' do
+ subject { create(:ci_build, :running, options: { retry: 3 }) }
+
+ it 'retries builds and assigns a same user to it' do
+ expect(described_class).to receive(:retry)
+ .with(subject, subject.user)
+
+ subject.drop!
+ end
+ end
+
+ context 'when build is not configured to be retried' do
+ subject { create(:ci_build, :running) }
+
+ it 'does not retry build' do
+ expect(described_class).not_to receive(:retry)
+
+ subject.drop!
+ end
+
+ it 'does not count retries when not necessary' do
+ expect(described_class).not_to receive(:retry)
+ expect_any_instance_of(described_class)
+ .not_to receive(:retries_count)
+
+ subject.drop!
+ end
+ end
+ end
end
diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb
new file mode 100644
index 00000000000..51123e73fe6
--- /dev/null
+++ b/spec/models/ci/group_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe Ci::Group do
+ subject do
+ described_class.new('test', name: 'rspec', jobs: jobs)
+ end
+
+ let!(:jobs) { build_list(:ci_build, 1, :success) }
+
+ it { is_expected.to include_module(StaticModel) }
+
+ it { is_expected.to respond_to(:stage) }
+ it { is_expected.to respond_to(:name) }
+ it { is_expected.to respond_to(:jobs) }
+ it { is_expected.to respond_to(:status) }
+
+ describe '#size' do
+ it 'returns the number of statuses in the group' do
+ expect(subject.size).to eq(1)
+ end
+ end
+
+ describe '#detailed_status' do
+ context 'when there is only one item in the group' do
+ it 'calls the status from the object itself' do
+ expect(jobs.first).to receive(:detailed_status)
+
+ expect(subject.detailed_status(double(:user)))
+ end
+ end
+
+ context 'when there are more than one commit status in the group' do
+ let(:jobs) do
+ [create(:ci_build, :failed),
+ create(:ci_build, :success)]
+ end
+
+ it 'fabricates a new detailed status object' do
+ expect(subject.detailed_status(double(:user)))
+ .to be_a(Gitlab::Ci::Status::Failed)
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb
new file mode 100644
index 00000000000..145189e7469
--- /dev/null
+++ b/spec/models/ci/group_variable_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe Ci::GroupVariable do
+ subject { build(:ci_group_variable) }
+
+ it { is_expected.to include_module(HasVariable) }
+ it { is_expected.to include_module(Presentable) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id) }
+
+ describe '.unprotected' do
+ subject { described_class.unprotected }
+
+ context 'when variable is protected' do
+ before do
+ create(:ci_group_variable, :protected)
+ end
+
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when variable is not protected' do
+ let(:variable) { create(:ci_group_variable, protected: false) }
+
+ it 'returns the variable' do
+ is_expected.to contain_exactly(variable)
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/legacy_stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb
new file mode 100644
index 00000000000..0c33c1466b7
--- /dev/null
+++ b/spec/models/ci/legacy_stage_spec.rb
@@ -0,0 +1,273 @@
+require 'spec_helper'
+
+describe Ci::LegacyStage do
+ let(:stage) { build(:ci_stage) }
+ let(:pipeline) { stage.pipeline }
+ let(:stage_name) { stage.name }
+
+ describe '#expectations' do
+ subject { stage }
+
+ it { is_expected.to include_module(StaticModel) }
+
+ it { is_expected.to respond_to(:pipeline) }
+ it { is_expected.to respond_to(:name) }
+
+ it { is_expected.to delegate_method(:project).to(:pipeline) }
+ end
+
+ describe '#statuses' do
+ let!(:stage_build) { create_job(:ci_build) }
+ let!(:commit_status) { create_job(:commit_status) }
+ let!(:other_build) { create_job(:ci_build, stage: 'other stage') }
+
+ subject { stage.statuses }
+
+ it "returns only matching statuses" do
+ is_expected.to contain_exactly(stage_build, commit_status)
+ end
+ end
+
+ describe '#groups' do
+ before do
+ create_job(:ci_build, name: 'rspec 0 2')
+ create_job(:ci_build, name: 'rspec 0 1')
+ create_job(:ci_build, name: 'spinach 0 1')
+ create_job(:commit_status, name: 'aaaaa')
+ end
+
+ it 'returns an array of three groups' do
+ expect(stage.groups).to be_a Array
+ expect(stage.groups).to all(be_a Ci::Group)
+ expect(stage.groups.size).to eq 3
+ end
+
+ it 'returns groups with correctly ordered statuses' do
+ expect(stage.groups.first.jobs.map(&:name))
+ .to eq ['aaaaa']
+ expect(stage.groups.second.jobs.map(&:name))
+ .to eq ['rspec 0 1', 'rspec 0 2']
+ expect(stage.groups.third.jobs.map(&:name))
+ .to eq ['spinach 0 1']
+ end
+
+ it 'returns groups with correct names' do
+ expect(stage.groups.map(&:name))
+ .to eq %w[aaaaa rspec spinach]
+ end
+
+ context 'when a name is nil on legacy pipelines' do
+ before do
+ pipeline.builds.first.update_attribute(:name, nil)
+ end
+
+ it 'returns an array of three groups' do
+ expect(stage.groups.map(&:name))
+ .to eq ['', 'aaaaa', 'rspec', 'spinach']
+ end
+ end
+ end
+
+ describe '#statuses_count' do
+ before do
+ create_job(:ci_build)
+ create_job(:ci_build, stage: 'other stage')
+ end
+
+ subject { stage.statuses_count }
+
+ it "counts statuses only from current stage" do
+ is_expected.to eq(1)
+ end
+ end
+
+ describe '#builds' do
+ let!(:stage_build) { create_job(:ci_build) }
+ let!(:commit_status) { create_job(:commit_status) }
+
+ subject { stage.builds }
+
+ it "returns only builds" do
+ is_expected.to contain_exactly(stage_build)
+ end
+ end
+
+ describe '#status' do
+ subject { stage.status }
+
+ context 'if status is already defined' do
+ let(:stage) { build(:ci_stage, status: 'success') }
+
+ it "returns defined status" do
+ is_expected.to eq('success')
+ end
+ end
+
+ context 'if status has to be calculated' do
+ let!(:stage_build) { create_job(:ci_build, status: :failed) }
+
+ it "returns status of a build" do
+ is_expected.to eq('failed')
+ end
+
+ context 'and builds are retried' do
+ let!(:new_build) { create_job(:ci_build, status: :success) }
+
+ before do
+ stage_build.update(retried: true)
+ end
+
+ it "returns status of latest build" do
+ is_expected.to eq('success')
+ end
+ end
+ end
+ end
+
+ describe '#detailed_status' do
+ let(:user) { create(:user) }
+
+ subject { stage.detailed_status(user) }
+
+ context 'when build is created' do
+ let!(:stage_build) { create_job(:ci_build, status: :created) }
+
+ it 'returns detailed status for created stage' do
+ expect(subject.text).to eq 'created'
+ end
+ end
+
+ context 'when build is pending' do
+ let!(:stage_build) { create_job(:ci_build, status: :pending) }
+
+ it 'returns detailed status for pending stage' do
+ expect(subject.text).to eq 'pending'
+ end
+ end
+
+ context 'when build is running' do
+ let!(:stage_build) { create_job(:ci_build, status: :running) }
+
+ it 'returns detailed status for running stage' do
+ expect(subject.text).to eq 'running'
+ end
+ end
+
+ context 'when build is successful' do
+ let!(:stage_build) { create_job(:ci_build, status: :success) }
+
+ it 'returns detailed status for successful stage' do
+ expect(subject.text).to eq 'passed'
+ end
+ end
+
+ context 'when build is failed' do
+ let!(:stage_build) { create_job(:ci_build, status: :failed) }
+
+ it 'returns detailed status for failed stage' do
+ expect(subject.text).to eq 'failed'
+ end
+ end
+
+ context 'when build is canceled' do
+ let!(:stage_build) { create_job(:ci_build, status: :canceled) }
+
+ it 'returns detailed status for canceled stage' do
+ expect(subject.text).to eq 'canceled'
+ end
+ end
+
+ context 'when build is skipped' do
+ let!(:stage_build) { create_job(:ci_build, status: :skipped) }
+
+ it 'returns detailed status for skipped stage' do
+ expect(subject.text).to eq 'skipped'
+ end
+ end
+ end
+
+ describe '#success?' do
+ context 'when stage is successful' do
+ before do
+ create_job(:ci_build, status: :success)
+ create_job(:generic_commit_status, status: :success)
+ end
+
+ it 'is successful' do
+ expect(stage).to be_success
+ end
+ end
+
+ context 'when stage is not successful' do
+ before do
+ create_job(:ci_build, status: :failed)
+ create_job(:generic_commit_status, status: :success)
+ end
+
+ it 'is not successful' do
+ expect(stage).not_to be_success
+ end
+ end
+ end
+
+ describe '#has_warnings?' do
+ context 'when stage has warnings' do
+ context 'when using memoized warnings flag' do
+ context 'when there are warnings' do
+ let(:stage) { build(:ci_stage, warnings: 2) }
+
+ it 'returns true using memoized value' do
+ expect(stage).not_to receive(:statuses)
+ expect(stage).to have_warnings
+ end
+ end
+
+ context 'when there are no warnings' do
+ let(:stage) { build(:ci_stage, warnings: 0) }
+
+ it 'returns false using memoized value' do
+ expect(stage).not_to receive(:statuses)
+ expect(stage).not_to have_warnings
+ end
+ end
+
+ context 'when number of warnings is not a valid value' do
+ let(:stage) { build(:ci_stage, warnings: true) }
+
+ it 'calculates statuses using database queries' do
+ expect(stage).to receive(:statuses).and_call_original
+ expect(stage).not_to have_warnings
+ end
+ end
+ end
+
+ context 'when calculating warnings from statuses' do
+ before do
+ create(:ci_build, :failed, :allowed_to_fail,
+ stage: stage_name, pipeline: pipeline)
+ end
+
+ it 'has warnings calculated from statuses' do
+ expect(stage).to receive(:statuses).and_call_original
+ expect(stage).to have_warnings
+ end
+ end
+ end
+
+ context 'when stage does not have warnings' do
+ before do
+ create(:ci_build, :success, stage: stage_name,
+ pipeline: pipeline)
+ end
+
+ it 'does not have warnings calculated from statuses' do
+ expect(stage).to receive(:statuses).and_call_original
+ expect(stage).not_to have_warnings
+ end
+ end
+ end
+
+ def create_job(type, status: 'success', stage: stage_name, **opts)
+ create(type, pipeline: pipeline, stage: stage, status: status, **opts)
+ end
+end
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
new file mode 100644
index 00000000000..9a278212efc
--- /dev/null
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -0,0 +1,137 @@
+require 'spec_helper'
+
+describe Ci::PipelineSchedule do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:owner) }
+
+ it { is_expected.to have_many(:pipelines) }
+ it { is_expected.to have_many(:variables) }
+
+ it { is_expected.to respond_to(:ref) }
+ it { is_expected.to respond_to(:cron) }
+ it { is_expected.to respond_to(:cron_timezone) }
+ it { is_expected.to respond_to(:description) }
+ it { is_expected.to respond_to(:next_run_at) }
+ it { is_expected.to respond_to(:deleted_at) }
+
+ describe 'validations' do
+ it 'does not allow invalid cron patters' do
+ pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *')
+
+ expect(pipeline_schedule).not_to be_valid
+ end
+
+ it 'does not allow invalid cron patters' do
+ pipeline_schedule = build(:ci_pipeline_schedule, cron_timezone: 'invalid')
+
+ expect(pipeline_schedule).not_to be_valid
+ end
+
+ context 'when active is false' do
+ it 'does not allow nullified ref' do
+ pipeline_schedule = build(:ci_pipeline_schedule, :inactive, ref: nil)
+
+ expect(pipeline_schedule).not_to be_valid
+ end
+ end
+ end
+
+ describe '#set_next_run_at' do
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) }
+
+ context 'when creates new pipeline schedule' do
+ let(:expected_next_run_at) do
+ Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone)
+ .next_time_from(Time.now)
+ end
+
+ it 'updates next_run_at automatically' do
+ expect(described_class.last.next_run_at).to eq(expected_next_run_at)
+ end
+ end
+
+ context 'when updates cron of exsisted pipeline schedule' do
+ let(:new_cron) { '0 0 1 1 *' }
+
+ let(:expected_next_run_at) do
+ Gitlab::Ci::CronParser.new(new_cron, pipeline_schedule.cron_timezone)
+ .next_time_from(Time.now)
+ end
+
+ it 'updates next_run_at automatically' do
+ pipeline_schedule.update!(cron: new_cron)
+
+ expect(described_class.last.next_run_at).to eq(expected_next_run_at)
+ end
+ end
+ end
+
+ describe '#schedule_next_run!' do
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) }
+
+ context 'when reschedules after 10 days from now' do
+ let(:future_time) { 10.days.from_now }
+
+ let(:expected_next_run_at) do
+ Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone)
+ .next_time_from(future_time)
+ end
+
+ it 'points to proper next_run_at' do
+ Timecop.freeze(future_time) do
+ pipeline_schedule.schedule_next_run!
+
+ expect(pipeline_schedule.next_run_at).to eq(expected_next_run_at)
+ end
+ end
+ end
+ end
+
+ describe '#real_next_run' do
+ subject do
+ described_class.last.real_next_run(worker_cron: worker_cron,
+ worker_time_zone: worker_time_zone)
+ end
+
+ context 'when GitLab time_zone is UTC' do
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone[worker_time_zone])
+ end
+
+ let(:worker_time_zone) { 'UTC' }
+
+ context 'when cron_timezone is Eastern Time (US & Canada)' do
+ before do
+ create(:ci_pipeline_schedule, :nightly,
+ cron_timezone: 'Eastern Time (US & Canada)')
+ end
+
+ let(:worker_cron) { '0 1 2 3 *' }
+
+ it 'returns the next time worker executes' do
+ expect(subject.min).to eq(0)
+ expect(subject.hour).to eq(1)
+ expect(subject.day).to eq(2)
+ expect(subject.month).to eq(3)
+ end
+ end
+ end
+ end
+
+ describe '#job_variables' do
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule) }
+
+ let!(:pipeline_schedule_variables) do
+ create_list(:ci_pipeline_schedule_variable, 2, pipeline_schedule: pipeline_schedule)
+ end
+
+ subject { pipeline_schedule.job_variables }
+
+ before do
+ pipeline_schedule.reload
+ end
+
+ it { is_expected.to contain_exactly(*pipeline_schedule_variables.map(&:to_runner_variable)) }
+ end
+end
diff --git a/spec/models/ci/pipeline_schedule_variable_spec.rb b/spec/models/ci/pipeline_schedule_variable_spec.rb
new file mode 100644
index 00000000000..dc8427f28bc
--- /dev/null
+++ b/spec/models/ci/pipeline_schedule_variable_spec.rb
@@ -0,0 +1,7 @@
+require 'spec_helper'
+
+describe Ci::PipelineScheduleVariable do
+ subject { build(:ci_pipeline_schedule_variable) }
+
+ it { is_expected.to include_module(HasVariable) }
+end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 3b222ea1c3d..ac75c6501ee 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1,10 +1,8 @@
require 'spec_helper'
-describe Ci::Pipeline, models: true do
- include EmailHelpers
-
+describe Ci::Pipeline, :mailer do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) do
create(:ci_empty_pipeline, status: :created, project: project)
@@ -13,20 +11,44 @@ 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 belong_to(:pipeline_schedule) }
it { is_expected.to have_many(:statuses) }
it { is_expected.to have_many(:trigger_requests) }
+ it { is_expected.to have_many(:variables) }
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 }
+ it { is_expected.to validate_presence_of(:sha) }
+ it { is_expected.to validate_presence_of(:status) }
it { is_expected.to respond_to :git_author_name }
it { is_expected.to respond_to :git_author_email }
it { is_expected.to respond_to :short_sha }
+ describe '#source' do
+ context 'when creating new pipeline' do
+ let(:pipeline) do
+ build(:ci_empty_pipeline, status: :created, project: project, source: nil)
+ end
+
+ it "prevents from creating an object" do
+ expect(pipeline).not_to be_valid
+ end
+ end
+
+ context 'when updating existing pipeline' do
+ before do
+ pipeline.update_attribute(:source, nil)
+ end
+
+ it "object is valid" do
+ expect(pipeline).to be_valid
+ end
+ end
+ end
+
describe '#block' do
it 'changes pipeline status to manual' do
expect(pipeline.block).to be true
@@ -59,8 +81,8 @@ describe Ci::Pipeline, models: true do
subject { pipeline.retried }
before do
- @build1 = FactoryGirl.create :ci_build, pipeline: pipeline, name: 'deploy'
- @build2 = FactoryGirl.create :ci_build, pipeline: pipeline, name: 'deploy'
+ @build1 = create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true)
+ @build2 = create(:ci_build, pipeline: pipeline, name: 'deploy')
end
it 'returns old builds' do
@@ -69,31 +91,31 @@ describe Ci::Pipeline, models: true do
end
describe "coverage" do
- let(:project) { FactoryGirl.create :empty_project, build_coverage_regex: "/.*/" }
- let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project }
+ let(:project) { create(:project, build_coverage_regex: "/.*/") }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
it "calculates average when there are two builds with coverage" do
- FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline
- FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline
+ create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
+ create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
expect(pipeline.coverage).to eq("35.00")
end
it "calculates average when there are two builds with coverage and one with nil" do
- FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline
- FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline
- FactoryGirl.create :ci_build, pipeline: pipeline
+ create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
+ create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
+ create(:ci_build, pipeline: pipeline)
expect(pipeline.coverage).to eq("35.00")
end
it "calculates average when there are two builds with coverage and one is retried" do
- FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline
- FactoryGirl.create :ci_build, name: "rubocop", coverage: 30, pipeline: pipeline
- FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, pipeline: pipeline
+ create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
+ create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true)
+ create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
expect(pipeline.coverage).to eq("35.00")
end
it "calculates average when there is one build without coverage" do
- FactoryGirl.create :ci_build, pipeline: pipeline
+ FactoryGirl.create(:ci_build, pipeline: pipeline)
expect(pipeline.coverage).to be_nil
end
end
@@ -201,8 +223,19 @@ describe Ci::Pipeline, models: true do
status: 'success')
end
- describe '#stages' do
- subject { pipeline.stages }
+ describe '#stage_seeds' do
+ let(:pipeline) do
+ create(:ci_pipeline, config: { rspec: { script: 'rake' } })
+ end
+
+ it 'returns preseeded stage seeds object' do
+ expect(pipeline.stage_seeds).to all(be_a Gitlab::Ci::Stage::Seed)
+ expect(pipeline.stage_seeds.count).to eq 1
+ end
+ end
+
+ describe '#legacy_stages' do
+ subject { pipeline.legacy_stages }
context 'stages list' do
it 'returns ordered list of stages' do
@@ -221,13 +254,15 @@ describe Ci::Pipeline, models: true do
%w(deploy running)])
end
- context 'when commit status is retried' do
+ context 'when commit status is retried' do
before do
create(:commit_status, pipeline: pipeline,
stage: 'build',
name: 'mac',
stage_idx: 0,
status: 'success')
+
+ pipeline.process!
end
it 'ignores the previous state' do
@@ -249,7 +284,7 @@ describe Ci::Pipeline, models: true do
end
it 'populates stage with correct number of warnings' do
- deploy_stage = pipeline.stages.third
+ deploy_stage = pipeline.legacy_stages.third
expect(deploy_stage).not_to receive(:statuses)
expect(deploy_stage).to have_warnings
@@ -263,22 +298,22 @@ describe Ci::Pipeline, models: true do
end
end
- describe '#stages_name' do
+ describe '#stages_names' do
it 'returns a valid names of stages' do
- expect(pipeline.stages_name).to eq(%w(build test deploy))
+ expect(pipeline.stages_names).to eq(%w(build test deploy))
end
end
end
- describe '#stage' do
- subject { pipeline.stage('test') }
+ describe '#legacy_stage' do
+ subject { pipeline.legacy_stage('test') }
context 'with status in stage' do
before do
create(:commit_status, pipeline: pipeline, stage: 'test')
end
- it { expect(subject).to be_a Ci::Stage }
+ it { expect(subject).to be_a Ci::LegacyStage }
it { expect(subject.name).to eq 'test' }
it { expect(subject.statuses).not_to be_empty }
end
@@ -488,6 +523,10 @@ describe Ci::Pipeline, models: true do
context 'there are multiple of the same name' do
let!(:manual2) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy') }
+ before do
+ manual.update(retried: true)
+ end
+
it 'returns latest one' do
is_expected.to contain_exactly(manual2)
end
@@ -495,6 +534,20 @@ describe Ci::Pipeline, models: true do
end
end
+ describe '#has_stage_seeds?' do
+ context 'when pipeline has stage seeds' do
+ subject { build(:ci_pipeline_with_one_job) }
+
+ it { is_expected.to have_stage_seeds }
+ end
+
+ context 'when pipeline does not have stage seeds' do
+ subject { create(:ci_pipeline_without_jobs) }
+
+ it { is_expected.not_to have_stage_seeds }
+ end
+ end
+
describe '#has_warnings?' do
subject { pipeline.has_warnings? }
@@ -554,8 +607,8 @@ describe Ci::Pipeline, models: true do
it 'returns the latest pipeline for the same ref and different sha' do
expect(pipelines.map(&:sha)).to contain_exactly('A', 'B', 'C')
- expect(pipelines.map(&:status)).
- to contain_exactly('success', 'failed', 'skipped')
+ expect(pipelines.map(&:status))
+ .to contain_exactly('success', 'failed', 'skipped')
end
end
@@ -564,8 +617,8 @@ describe Ci::Pipeline, models: true do
it 'returns the latest pipeline for ref and different sha' do
expect(pipelines.map(&:sha)).to contain_exactly('A', 'B')
- expect(pipelines.map(&:status)).
- to contain_exactly('success', 'failed')
+ expect(pipelines.map(&:status))
+ .to contain_exactly('success', 'failed')
end
end
end
@@ -600,8 +653,8 @@ describe Ci::Pipeline, models: true do
end
it 'returns the latest successful pipeline' do
- expect(described_class.latest_successful_for('ref')).
- to eq(latest_successful_pipeline)
+ expect(described_class.latest_successful_for('ref'))
+ .to eq(latest_successful_pipeline)
end
end
@@ -618,6 +671,12 @@ describe Ci::Pipeline, models: true do
end
end
+ describe '.internal_sources' do
+ subject { described_class.internal_sources }
+
+ it { is_expected.to be_an(Array) }
+ end
+
describe '#status' do
let(:build) do
create(:ci_build, :created, pipeline: pipeline, name: 'test')
@@ -674,6 +733,8 @@ describe Ci::Pipeline, models: true do
context 'on failure and build retry' do
before do
+ stub_not_protect_default_branch
+
build.drop
project.add_developer(user)
@@ -688,6 +749,39 @@ describe Ci::Pipeline, models: true do
end
end
+ describe '#ci_yaml_file_path' do
+ subject { pipeline.ci_yaml_file_path }
+
+ it 'returns the path from project' do
+ allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' }
+
+ is_expected.to eq('custom/path')
+ end
+
+ it 'returns default when custom path is nil' do
+ allow(pipeline.project).to receive(:ci_config_path) { nil }
+
+ is_expected.to eq('.gitlab-ci.yml')
+ end
+
+ it 'returns default when custom path is empty' do
+ allow(pipeline.project).to receive(:ci_config_path) { '' }
+
+ is_expected.to eq('.gitlab-ci.yml')
+ end
+ end
+
+ describe '#ci_yaml_file' do
+ it 'reports error if the file is not found' do
+ allow(pipeline.project).to receive(:ci_config_path) { 'custom' }
+
+ pipeline.ci_yaml_file
+
+ expect(pipeline.yaml_errors)
+ .to eq('Failed to load CI/CD config file at custom')
+ end
+ end
+
describe '#detailed_status' do
subject { pipeline.detailed_status(user) }
@@ -847,6 +941,16 @@ describe Ci::Pipeline, models: true do
end
end
end
+
+ context 'when there is a manual action present in the pipeline' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline)
+ end
+
+ it 'is not cancelable' do
+ expect(pipeline).not_to be_cancelable
+ end
+ end
end
describe '#cancel_running' do
@@ -896,6 +1000,8 @@ describe Ci::Pipeline, models: true do
let(:latest_status) { pipeline.statuses.latest.pluck(:status) }
before do
+ stub_not_protect_default_branch
+
project.add_developer(user)
end
@@ -948,7 +1054,7 @@ describe Ci::Pipeline, models: true do
end
before do
- ProjectWebHookWorker.drain
+ WebHookWorker.drain
end
context 'with pipeline hooks enabled' do
@@ -1039,12 +1145,12 @@ describe Ci::Pipeline, models: true do
end
describe "#merge_requests" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') }
it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do
- merge_request = create(:merge_request, source_project: project, source_branch: pipeline.ref)
allow_any_instance_of(MergeRequest).to receive(:diff_head_sha) { 'a288a022a53a5a944fae87bcec6efc87b7061808' }
+ merge_request = create(:merge_request, source_project: project, head_pipeline: pipeline, source_branch: pipeline.ref)
expect(pipeline.merge_requests).to eq([merge_request])
end
@@ -1064,7 +1170,7 @@ describe Ci::Pipeline, models: true do
end
describe "#all_merge_requests" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master') }
it "returns all merge requests having the same source branch" do
@@ -1092,7 +1198,9 @@ describe Ci::Pipeline, models: true do
end
context 'when pipeline is not stuck' do
- before { create(:ci_runner, :shared, :online) }
+ before do
+ create(:ci_runner, :shared, :online)
+ end
it 'is not stuck' do
expect(pipeline).not_to be_stuck
@@ -1135,10 +1243,8 @@ describe Ci::Pipeline, models: true do
before do
project.team << [pipeline.user, Gitlab::Access::DEVELOPER]
- pipeline.user.global_notification_setting.
- update(level: 'custom', failed_pipeline: true, success_pipeline: true)
-
- reset_delivered_emails!
+ pipeline.user.global_notification_setting
+ .update(level: 'custom', failed_pipeline: true, success_pipeline: true)
perform_enqueued_jobs do
pipeline.enqueue
diff --git a/spec/models/ci/pipeline_variable_spec.rb b/spec/models/ci/pipeline_variable_spec.rb
new file mode 100644
index 00000000000..2ce78e34b0c
--- /dev/null
+++ b/spec/models/ci/pipeline_variable_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+
+describe Ci::PipelineVariable, models: true do
+ subject { build(:ci_pipeline_variable) }
+
+ it { is_expected.to include_module(HasVariable) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to(:pipeline_id) }
+end
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 76ce558eea0..48f878bbee6 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::Runner, models: true do
+describe Ci::Runner do
describe 'validation' do
context 'when runner is not allowed to pick untagged jobs' do
context 'when runner does not have tags' do
@@ -37,7 +37,7 @@ describe Ci::Runner, models: true do
end
describe '#assign_to' do
- let!(:project) { FactoryGirl.create :empty_project }
+ let!(:project) { FactoryGirl.create :project }
let!(:shared_runner) { FactoryGirl.create(:ci_runner, :shared) }
before do
@@ -50,7 +50,7 @@ describe Ci::Runner, models: true do
end
describe '.online' do
- subject { Ci::Runner.online }
+ subject { described_class.online }
before do
@runner1 = FactoryGirl.create(:ci_runner, :shared, contacted_at: 1.year.ago)
@@ -276,14 +276,14 @@ describe Ci::Runner, models: true do
it 'sets a new last_update value when it is called the first time' do
last_update = runner.ensure_runner_queue_value
- expect_value_in_redis.to eq(last_update)
+ expect_value_in_queues.to eq(last_update)
end
it 'does not change if it is not expired and called again' do
last_update = runner.ensure_runner_queue_value
expect(runner.ensure_runner_queue_value).to eq(last_update)
- expect_value_in_redis.to eq(last_update)
+ expect_value_in_queues.to eq(last_update)
end
context 'updates runner queue after changing editable value' do
@@ -294,7 +294,7 @@ describe Ci::Runner, models: true do
end
it 'sets a new last_update value' do
- expect_value_in_redis.not_to eq(last_update)
+ expect_value_in_queues.not_to eq(last_update)
end
end
@@ -306,12 +306,12 @@ describe Ci::Runner, models: true do
end
it 'has an old last_update value' do
- expect_value_in_redis.to eq(last_update)
+ expect_value_in_queues.to eq(last_update)
end
end
- def expect_value_in_redis
- Gitlab::Redis.with do |redis|
+ def expect_value_in_queues
+ Gitlab::Redis::Queues.with do |redis|
runner_queue_key = runner.send(:runner_queue_key)
expect(redis.get(runner_queue_key))
end
@@ -330,7 +330,7 @@ describe Ci::Runner, models: true do
end
it 'cleans up the queue' do
- Gitlab::Redis.with do |redis|
+ Gitlab::Redis::Queues.with do |redis|
expect(redis.get(queue_key)).to be_nil
end
end
@@ -339,8 +339,8 @@ describe Ci::Runner, models: true do
describe '.assignable_for' do
let(:runner) { create(:ci_runner) }
- let(:project) { create(:empty_project) }
- let(:another_project) { create(:empty_project) }
+ let(:project) { create(:project) }
+ let(:another_project) { create(:project) }
before do
project.runners << runner
@@ -352,13 +352,13 @@ describe Ci::Runner, models: true do
end
context 'does not give owned runner' do
- subject { Ci::Runner.assignable_for(project) }
+ subject { described_class.assignable_for(project) }
it { is_expected.to be_empty }
end
context 'does not give shared runner' do
- subject { Ci::Runner.assignable_for(another_project) }
+ subject { described_class.assignable_for(another_project) }
it { is_expected.to be_empty }
end
@@ -366,13 +366,13 @@ describe Ci::Runner, models: true do
context 'with unlocked runner' do
context 'does not give owned runner' do
- subject { Ci::Runner.assignable_for(project) }
+ subject { described_class.assignable_for(project) }
it { is_expected.to be_empty }
end
context 'does give a specific runner' do
- subject { Ci::Runner.assignable_for(another_project) }
+ subject { described_class.assignable_for(another_project) }
it { is_expected.to contain_exactly(runner) }
end
@@ -384,13 +384,13 @@ describe Ci::Runner, models: true do
end
context 'does not give owned runner' do
- subject { Ci::Runner.assignable_for(project) }
+ subject { described_class.assignable_for(project) }
it { is_expected.to be_empty }
end
context 'does not give a locked runner' do
- subject { Ci::Runner.assignable_for(another_project) }
+ subject { described_class.assignable_for(another_project) }
it { is_expected.to be_empty }
end
@@ -400,8 +400,8 @@ describe Ci::Runner, models: true do
describe "belongs_to_one_project?" do
it "returns false if there are two projects runner assigned to" do
runner = FactoryGirl.create(:ci_runner)
- project = FactoryGirl.create(:empty_project)
- project1 = FactoryGirl.create(:empty_project)
+ project = FactoryGirl.create(:project)
+ project1 = FactoryGirl.create(:project)
project.runners << runner
project1.runners << runner
@@ -410,7 +410,7 @@ describe Ci::Runner, models: true do
it "returns true" do
runner = FactoryGirl.create(:ci_runner)
- project = FactoryGirl.create(:empty_project)
+ project = FactoryGirl.create(:project)
project.runners << runner
expect(runner.belongs_to_one_project?).to be_truthy
diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb
deleted file mode 100644
index c38faf32f7d..00000000000
--- a/spec/models/ci/stage_spec.rb
+++ /dev/null
@@ -1,229 +0,0 @@
-require 'spec_helper'
-
-describe Ci::Stage, models: true do
- let(:stage) { build(:ci_stage) }
- let(:pipeline) { stage.pipeline }
- let(:stage_name) { stage.name }
-
- describe '#expectations' do
- subject { stage }
-
- it { is_expected.to include_module(StaticModel) }
-
- it { is_expected.to respond_to(:pipeline) }
- it { is_expected.to respond_to(:name) }
-
- it { is_expected.to delegate_method(:project).to(:pipeline) }
- end
-
- describe '#statuses' do
- let!(:stage_build) { create_job(:ci_build) }
- let!(:commit_status) { create_job(:commit_status) }
- let!(:other_build) { create_job(:ci_build, stage: 'other stage') }
-
- subject { stage.statuses }
-
- it "returns only matching statuses" do
- is_expected.to contain_exactly(stage_build, commit_status)
- end
- end
-
- describe '#statuses_count' do
- before do
- create_job(:ci_build)
- create_job(:ci_build, stage: 'other stage')
- end
-
- subject { stage.statuses_count }
-
- it "counts statuses only from current stage" do
- is_expected.to eq(1)
- end
- end
-
- describe '#builds' do
- let!(:stage_build) { create_job(:ci_build) }
- let!(:commit_status) { create_job(:commit_status) }
-
- subject { stage.builds }
-
- it "returns only builds" do
- is_expected.to contain_exactly(stage_build)
- end
- end
-
- describe '#status' do
- subject { stage.status }
-
- context 'if status is already defined' do
- let(:stage) { build(:ci_stage, status: 'success') }
-
- it "returns defined status" do
- is_expected.to eq('success')
- end
- end
-
- context 'if status has to be calculated' do
- let!(:stage_build) { create_job(:ci_build, status: :failed) }
-
- it "returns status of a build" do
- is_expected.to eq('failed')
- end
-
- context 'and builds are retried' do
- let!(:new_build) { create_job(:ci_build, status: :success) }
-
- it "returns status of latest build" do
- is_expected.to eq('success')
- end
- end
- end
- end
-
- describe '#detailed_status' do
- let(:user) { create(:user) }
-
- subject { stage.detailed_status(user) }
-
- context 'when build is created' do
- let!(:stage_build) { create_job(:ci_build, status: :created) }
-
- it 'returns detailed status for created stage' do
- expect(subject.text).to eq 'created'
- end
- end
-
- context 'when build is pending' do
- let!(:stage_build) { create_job(:ci_build, status: :pending) }
-
- it 'returns detailed status for pending stage' do
- expect(subject.text).to eq 'pending'
- end
- end
-
- context 'when build is running' do
- let!(:stage_build) { create_job(:ci_build, status: :running) }
-
- it 'returns detailed status for running stage' do
- expect(subject.text).to eq 'running'
- end
- end
-
- context 'when build is successful' do
- let!(:stage_build) { create_job(:ci_build, status: :success) }
-
- it 'returns detailed status for successful stage' do
- expect(subject.text).to eq 'passed'
- end
- end
-
- context 'when build is failed' do
- let!(:stage_build) { create_job(:ci_build, status: :failed) }
-
- it 'returns detailed status for failed stage' do
- expect(subject.text).to eq 'failed'
- end
- end
-
- context 'when build is canceled' do
- let!(:stage_build) { create_job(:ci_build, status: :canceled) }
-
- it 'returns detailed status for canceled stage' do
- expect(subject.text).to eq 'canceled'
- end
- end
-
- context 'when build is skipped' do
- let!(:stage_build) { create_job(:ci_build, status: :skipped) }
-
- it 'returns detailed status for skipped stage' do
- expect(subject.text).to eq 'skipped'
- end
- end
- end
-
- describe '#success?' do
- context 'when stage is successful' do
- before do
- create_job(:ci_build, status: :success)
- create_job(:generic_commit_status, status: :success)
- end
-
- it 'is successful' do
- expect(stage).to be_success
- end
- end
-
- context 'when stage is not successful' do
- before do
- create_job(:ci_build, status: :failed)
- create_job(:generic_commit_status, status: :success)
- end
-
- it 'is not successful' do
- expect(stage).not_to be_success
- end
- end
- end
-
- describe '#has_warnings?' do
- context 'when stage has warnings' do
- context 'when using memoized warnings flag' do
- context 'when there are warnings' do
- let(:stage) { build(:ci_stage, warnings: 2) }
-
- it 'returns true using memoized value' do
- expect(stage).not_to receive(:statuses)
- expect(stage).to have_warnings
- end
- end
-
- context 'when there are no warnings' do
- let(:stage) { build(:ci_stage, warnings: 0) }
-
- it 'returns false using memoized value' do
- expect(stage).not_to receive(:statuses)
- expect(stage).not_to have_warnings
- end
- end
-
- context 'when number of warnings is not a valid value' do
- let(:stage) { build(:ci_stage, warnings: true) }
-
- it 'calculates statuses using database queries' do
- expect(stage).to receive(:statuses).and_call_original
- expect(stage).not_to have_warnings
- end
- end
- end
-
- context 'when calculating warnings from statuses' do
- before do
- create(:ci_build, :failed, :allowed_to_fail,
- stage: stage_name, pipeline: pipeline)
- end
-
- it 'has warnings calculated from statuses' do
- expect(stage).to receive(:statuses).and_call_original
- expect(stage).to have_warnings
- end
- end
- end
-
- context 'when stage does not have warnings' do
- before do
- create(:ci_build, :success, stage: stage_name,
- pipeline: pipeline)
- end
-
- it 'does not have warnings calculated from statuses' do
- expect(stage).to receive(:statuses).and_call_original
- expect(stage).not_to have_warnings
- end
- end
- end
-
- def create_job(type, status: 'success', stage: stage_name)
- create(type, pipeline: pipeline, stage: stage, status: status)
- end
-end
diff --git a/spec/models/ci/trigger_schedule_spec.rb b/spec/models/ci/trigger_schedule_spec.rb
deleted file mode 100644
index 75d21541cee..00000000000
--- a/spec/models/ci/trigger_schedule_spec.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-require 'spec_helper'
-
-describe Ci::TriggerSchedule, models: true do
- it { is_expected.to belong_to(:project) }
- it { is_expected.to belong_to(:trigger) }
- it { is_expected.to respond_to(:ref) }
-
- describe '#set_next_run_at' do
- context 'when creates new TriggerSchedule' do
- before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
- .next_time_from(Time.now)
- end
-
- it 'updates next_run_at automatically' do
- expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
- end
- end
-
- context 'when updates cron of exsisted TriggerSchedule' do
- before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- new_cron = '0 0 1 1 *'
- trigger_schedule.update!(cron: new_cron) # Subject
- @expected_next_run_at = Gitlab::Ci::CronParser.new(new_cron, trigger_schedule.cron_timezone)
- .next_time_from(Time.now)
- end
-
- it 'updates next_run_at automatically' do
- expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
- end
- end
- end
-
- describe '#schedule_next_run!' do
- context 'when reschedules after 10 days from now' do
- before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- time_future = Time.now + 10.days
- allow(Time).to receive(:now).and_return(time_future)
- trigger_schedule.schedule_next_run! # Subject
- @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
- .next_time_from(time_future)
- end
-
- it 'points to proper next_run_at' do
- expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
- end
- end
-
- context 'when cron is invalid' do
- before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- trigger_schedule.cron = 'Invalid-cron'
- trigger_schedule.schedule_next_run! # Subject
- end
-
- it 'sets nil to next_run_at' do
- expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
- end
- end
-
- context 'when cron_timezone is invalid' do
- before do
- trigger_schedule = create(:ci_trigger_schedule, :nightly)
- trigger_schedule.cron_timezone = 'Invalid-cron_timezone'
- trigger_schedule.schedule_next_run! # Subject
- end
-
- it 'sets nil to next_run_at' do
- expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
- end
- end
- end
-end
diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb
index d26121018ce..bd9c837402f 100644
--- a/spec/models/ci/trigger_spec.rb
+++ b/spec/models/ci/trigger_spec.rb
@@ -1,13 +1,12 @@
require 'spec_helper'
-describe Ci::Trigger, models: true do
- let(:project) { create :empty_project }
+describe Ci::Trigger do
+ let(:project) { create :project }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:owner) }
it { is_expected.to have_many(:trigger_requests) }
- it { is_expected.to have_one(:trigger_schedule) }
end
describe 'before_validation' do
diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb
index 048d25869bc..e4ff551151e 100644
--- a/spec/models/ci/variable_spec.rb
+++ b/spec/models/ci/variable_spec.rb
@@ -1,39 +1,33 @@
require 'spec_helper'
-describe Ci::Variable, models: true do
- subject { Ci::Variable.new }
+describe Ci::Variable do
+ subject { build(:ci_variable) }
- let(:secret_value) { 'secret' }
-
- it { is_expected.to validate_presence_of(:key) }
- it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id) }
- it { is_expected.to validate_length_of(:key).is_at_most(255) }
- it { is_expected.to allow_value('foo').for(:key) }
- it { is_expected.not_to allow_value('foo bar').for(:key) }
- it { is_expected.not_to allow_value('foo/bar').for(:key) }
-
- before :each do
- subject.value = secret_value
+ describe 'validations' do
+ it { is_expected.to include_module(HasVariable) }
+ it { is_expected.to include_module(Presentable) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope) }
end
- describe '#value' do
- it 'stores the encrypted value' do
- expect(subject.encrypted_value).not_to be_nil
- end
+ describe '.unprotected' do
+ subject { described_class.unprotected }
- it 'stores an iv for value' do
- expect(subject.encrypted_value_iv).not_to be_nil
- end
+ context 'when variable is protected' do
+ before do
+ create(:ci_variable, :protected)
+ end
- it 'stores a salt for value' do
- expect(subject.encrypted_value_salt).not_to be_nil
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
end
- it 'fails to decrypt if iv is incorrect' do
- subject.encrypted_value_iv = SecureRandom.hex
- subject.instance_variable_set(:@value, nil)
- expect { subject.value }.
- to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt')
+ context 'when variable is not protected' do
+ let(:variable) { create(:ci_variable, protected: false) }
+
+ it 'returns the variable' do
+ is_expected.to contain_exactly(variable)
+ end
end
end
end
diff --git a/spec/models/commit_range_spec.rb b/spec/models/commit_range_spec.rb
index e4bddf67096..38829773599 100644
--- a/spec/models/commit_range_spec.rb
+++ b/spec/models/commit_range_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CommitRange, models: true do
+describe CommitRange do
describe 'modules' do
subject { described_class }
@@ -45,7 +45,7 @@ describe CommitRange, models: true do
end
describe '#to_reference' do
- let(:cross) { create(:empty_project, namespace: project.namespace) }
+ let(:cross) { create(:project, namespace: project.namespace) }
it 'returns a String reference to the object' do
expect(range.to_reference).to eq "#{full_sha_from}...#{full_sha_to}"
@@ -61,7 +61,7 @@ describe CommitRange, models: true do
end
describe '#reference_link_text' do
- let(:cross) { create(:empty_project, namespace: project.namespace) }
+ let(:cross) { create(:project, namespace: project.namespace) }
it 'returns a String reference to the object' do
expect(range.reference_link_text).to eq "#{sha_from}...#{sha_to}"
@@ -147,9 +147,9 @@ describe CommitRange, models: true do
note: commit1.revert_description(user),
project: issue.project)
- expect_any_instance_of(Commit).to receive(:reverts_commit?).
- with(commit1, user).
- and_return(true)
+ expect_any_instance_of(Commit).to receive(:reverts_commit?)
+ .with(commit1, user)
+ .and_return(true)
expect(commit1.has_been_reverted?(user, issue)).to eq(true)
end
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index ce31c8ed94c..c18c635d811 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Commit, models: true do
+describe Commit do
let(:project) { create(:project, :public, :repository) }
let(:commit) { project.commit }
@@ -19,23 +19,20 @@ describe Commit, models: true do
expect(commit.author).to eq(user)
end
- it 'caches the author' do
+ it 'caches the author', :request_store do
user = create(:user, email: commit.author_email)
- expect(RequestStore).to receive(:active?).twice.and_return(true)
- expect_any_instance_of(Commit).to receive(:find_author_by_any_email).and_call_original
+ expect(User).to receive(:find_by_any_email).and_call_original
expect(commit.author).to eq(user)
- key = "commit_author:#{commit.author_email}"
+ key = "Commit:author:#{commit.author_email.downcase}"
expect(RequestStore.store[key]).to eq(user)
expect(commit.author).to eq(user)
- RequestStore.store.clear
end
end
describe '#to_reference' do
let(:project) { create(:project, :repository, path: 'sample-project') }
- let(:commit) { project.commit }
it 'returns a String reference to the object' do
expect(commit.to_reference).to eq commit.id
@@ -49,7 +46,6 @@ describe Commit, models: true do
describe '#reference_link_text' do
let(:project) { create(:project, :repository, path: 'sample-project') }
- let(:commit) { project.commit }
it 'returns a String reference to the object' do
expect(commit.reference_link_text).to eq commit.short_id
@@ -67,11 +63,11 @@ describe Commit, models: true do
expect(commit.title).to eq("--no commit message")
end
- it "truncates a message without a newline at 80 characters" do
+ it 'truncates a message without a newline at natural break to 80 characters' do
message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.'
allow(commit).to receive(:safe_message).and_return(message)
- expect(commit.title).to eq("#{message[0..79]}…")
+ expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis…')
end
it "truncates a message with a newline before 80 characters at the newline" do
@@ -113,6 +109,28 @@ eos
end
end
+ describe 'description' do
+ it 'returns description of commit message if title less than 100 characters' do
+ message = <<eos
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit.
+Vivamus egestas lacinia lacus, sed rutrum mauris.
+eos
+
+ allow(commit).to receive(:safe_message).and_return(message)
+ expect(commit.description).to eq('Vivamus egestas lacinia lacus, sed rutrum mauris.')
+ end
+
+ it 'returns full commit message if commit title more than 100 characters' do
+ message = <<eos
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.
+Vivamus egestas lacinia lacus, sed rutrum mauris.
+eos
+
+ allow(commit).to receive(:safe_message).and_return(message)
+ expect(commit.description).to eq(message)
+ end
+ end
+
describe "delegation" do
subject { commit }
@@ -131,7 +149,7 @@ eos
describe '#closes_issues' do
let(:issue) { create :issue, project: project }
- let(:other_project) { create(:empty_project, :public) }
+ let(:other_project) { create(:project, :public) }
let(:other_issue) { create :issue, project: other_project }
let(:commiter) { create :user }
@@ -141,7 +159,7 @@ eos
end
it 'detects issues that this commit is marked as closing' do
- ext_ref = "#{other_project.path_with_namespace}##{other_issue.iid}"
+ ext_ref = "#{other_project.full_path}##{other_issue.iid}"
allow(commit).to receive_messages(
safe_message: "Fixes ##{issue.iid} and #{ext_ref}",
@@ -171,7 +189,7 @@ eos
it { expect(data).to be_a(Hash) }
it { expect(data[:message]).to include('adds bar folder and branch-test text file to check Repository merged_to_root_ref method') }
- it { expect(data[:timestamp]).to eq('2016-09-27T14:37:46+00:00') }
+ it { expect(data[:timestamp]).to eq('2016-09-27T14:37:46Z') }
it { expect(data[:added]).to eq(["bar/branch-test.txt"]) }
it { expect(data[:modified]).to eq([]) }
it { expect(data[:removed]).to eq([]) }
@@ -184,19 +202,25 @@ eos
it { expect(commit.reverts_commit?(another_commit, user)).to be_falsy }
context 'commit has no description' do
- before { allow(commit).to receive(:description?).and_return(false) }
+ before do
+ allow(commit).to receive(:description?).and_return(false)
+ end
it { expect(commit.reverts_commit?(another_commit, user)).to be_falsy }
end
context "another_commit's description does not revert commit" do
- before { allow(commit).to receive(:description).and_return("Foo Bar") }
+ before do
+ allow(commit).to receive(:description).and_return("Foo Bar")
+ end
it { expect(commit.reverts_commit?(another_commit, user)).to be_falsy }
end
context "another_commit's description reverts commit" do
- before { allow(commit).to receive(:description).and_return("Foo #{another_commit.revert_description} Bar") }
+ before do
+ allow(commit).to receive(:description).and_return("Foo #{another_commit.revert_description} Bar")
+ end
it { expect(commit.reverts_commit?(another_commit, user)).to be_truthy }
end
@@ -212,7 +236,7 @@ eos
end
end
- describe '#latest_pipeline' do
+ describe '#last_pipeline' do
let!(:first_pipeline) do
create(:ci_empty_pipeline,
project: project,
@@ -226,8 +250,8 @@ eos
status: 'success')
end
- it 'returns latest pipeline' do
- expect(commit.latest_pipeline).to eq second_pipeline
+ it 'returns last pipeline' do
+ expect(commit.last_pipeline).to eq second_pipeline
end
end
@@ -388,33 +412,4 @@ eos
expect(described_class.valid_hash?('a' * 41)).to be false
end
end
-
- # describe '#raw_diffs' do
- # TODO: Uncomment when feature is reenabled
- # context 'Gitaly commit_raw_diffs feature enabled' do
- # before do
- # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true)
- # end
- #
- # context 'when a truthy deltas_only is not passed to args' do
- # it 'fetches diffs from Gitaly server' do
- # expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent).
- # with(commit)
- #
- # commit.raw_diffs
- # end
- # end
- #
- # context 'when a truthy deltas_only is passed to args' do
- # it 'fetches diffs using Rugged' do
- # opts = { deltas_only: true }
- #
- # expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent)
- # expect(commit.raw).to receive(:diffs).with(opts)
- #
- # commit.raw_diffs(opts)
- # end
- # end
- # end
- # end
end
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 0ee85489574..8c4a366ef8f 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CommitStatus, :models do
+describe CommitStatus do
let(:project) { create(:project, :repository) }
let(:pipeline) do
@@ -31,23 +31,40 @@ describe CommitStatus, :models do
describe '#author' do
subject { commit_status.author }
- before { commit_status.author = User.new }
+
+ before do
+ commit_status.author = User.new
+ end
it { is_expected.to eq(commit_status.user) }
end
+ describe 'status state machine' do
+ let!(:commit_status) { create(:commit_status, :running, project: project) }
+
+ it 'invalidates the cache after a transition' do
+ expect(ExpireJobCacheWorker).to receive(:perform_async).with(commit_status.id)
+
+ commit_status.success!
+ end
+ end
+
describe '#started?' do
subject { commit_status.started? }
context 'without started_at' do
- before { commit_status.started_at = nil }
+ before do
+ commit_status.started_at = nil
+ end
it { is_expected.to be_falsey }
end
%w[running success failed].each do |status|
context "if commit status is #{status}" do
- before { commit_status.status = status }
+ before do
+ commit_status.status = status
+ end
it { is_expected.to be_truthy }
end
@@ -55,7 +72,9 @@ describe CommitStatus, :models do
%w[pending canceled].each do |status|
context "if commit status is #{status}" do
- before { commit_status.status = status }
+ before do
+ commit_status.status = status
+ end
it { is_expected.to be_falsey }
end
@@ -67,7 +86,9 @@ describe CommitStatus, :models do
%w[pending running].each do |state|
context "if commit_status.status is #{state}" do
- before { commit_status.status = state }
+ before do
+ commit_status.status = state
+ end
it { is_expected.to be_truthy }
end
@@ -75,7 +96,9 @@ describe CommitStatus, :models do
%w[success failed canceled].each do |state|
context "if commit_status.status is #{state}" do
- before { commit_status.status = state }
+ before do
+ commit_status.status = state
+ end
it { is_expected.to be_falsey }
end
@@ -87,7 +110,9 @@ describe CommitStatus, :models do
%w[success failed canceled].each do |state|
context "if commit_status.status is #{state}" do
- before { commit_status.status = state }
+ before do
+ commit_status.status = state
+ end
it { is_expected.to be_truthy }
end
@@ -95,7 +120,9 @@ describe CommitStatus, :models do
%w[pending running].each do |state|
context "if commit_status.status is #{state}" do
- before { commit_status.status = state }
+ before do
+ commit_status.status = state
+ end
it { is_expected.to be_falsey }
end
@@ -157,9 +184,9 @@ describe CommitStatus, :models do
subject { described_class.latest.order(:id) }
let(:statuses) do
- [create_status(name: 'aa', ref: 'bb', status: 'running'),
- create_status(name: 'cc', ref: 'cc', status: 'pending'),
- create_status(name: 'aa', ref: 'cc', status: 'success'),
+ [create_status(name: 'aa', ref: 'bb', status: 'running', retried: true),
+ create_status(name: 'cc', ref: 'cc', status: 'pending', retried: true),
+ create_status(name: 'aa', ref: 'cc', status: 'success', retried: true),
create_status(name: 'cc', ref: 'bb', status: 'success'),
create_status(name: 'aa', ref: 'bb', status: 'success')]
end
@@ -169,6 +196,22 @@ describe CommitStatus, :models do
end
end
+ describe '.retried' do
+ subject { described_class.retried.order(:id) }
+
+ let(:statuses) do
+ [create_status(name: 'aa', ref: 'bb', status: 'running', retried: true),
+ create_status(name: 'cc', ref: 'cc', status: 'pending', retried: true),
+ create_status(name: 'aa', ref: 'cc', status: 'success', retried: true),
+ create_status(name: 'cc', ref: 'bb', status: 'success'),
+ create_status(name: 'aa', ref: 'bb', status: 'success')]
+ end
+
+ it 'returns unique statuses' do
+ is_expected.to contain_exactly(*statuses.values_at(0, 1, 2))
+ end
+ end
+
describe '.running_or_pending' do
subject { described_class.running_or_pending.order(:id) }
@@ -181,7 +224,7 @@ describe CommitStatus, :models do
end
it 'returns statuses that are running or pending' do
- is_expected.to eq(statuses.values_at(0, 1))
+ is_expected.to contain_exactly(*statuses.values_at(0, 1))
end
end
@@ -241,11 +284,48 @@ describe CommitStatus, :models do
end
end
+ describe '.status' do
+ context 'when there are multiple statuses present' do
+ before do
+ create_status(status: 'running')
+ create_status(status: 'success')
+ create_status(allow_failure: true, status: 'failed')
+ end
+
+ it 'returns a correct compound status' do
+ expect(described_class.all.status).to eq 'running'
+ end
+ end
+
+ context 'when there are only allowed to fail commit statuses present' do
+ before do
+ create_status(allow_failure: true, status: 'failed')
+ end
+
+ it 'returns status that indicates success' do
+ expect(described_class.all.status).to eq 'success'
+ end
+ end
+
+ context 'when using a scope to select latest statuses' do
+ before do
+ create_status(name: 'test', retried: true, status: 'failed')
+ create_status(allow_failure: true, name: 'test', status: 'failed')
+ end
+
+ it 'returns status according to the scope' do
+ expect(described_class.latest.status).to eq 'success'
+ end
+ end
+ end
+
describe '#before_sha' do
subject { commit_status.before_sha }
context 'when no before_sha is set for pipeline' do
- before { pipeline.before_sha = nil }
+ before do
+ pipeline.before_sha = nil
+ end
it 'returns blank sha' do
is_expected.to eq(Gitlab::Git::BLANK_SHA)
@@ -254,7 +334,10 @@ describe CommitStatus, :models do
context 'for before_sha set for pipeline' do
let(:value) { '1234' }
- before { pipeline.before_sha = value }
+
+ before do
+ pipeline.before_sha = value
+ end
it 'returns the set value' do
is_expected.to eq(value)
@@ -342,7 +425,7 @@ describe CommitStatus, :models do
end
it "raise exception when trying to update" do
- expect{ commit_status.save }.to raise_error(ActiveRecord::StaleObjectError)
+ expect { commit_status.save }.to raise_error(ActiveRecord::StaleObjectError)
end
end
diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb
index da003dbf794..04f3cecae00 100644
--- a/spec/models/compare_spec.rb
+++ b/spec/models/compare_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Compare, models: true do
+describe Compare do
include RepoHelpers
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb
index 4829ef17a20..04d6cfa2c02 100644
--- a/spec/models/concerns/access_requestable_spec.rb
+++ b/spec/models/concerns/access_requestable_spec.rb
@@ -14,7 +14,9 @@ describe AccessRequestable do
let(:group) { create(:group, :public, :access_requestable) }
let(:user) { create(:user) }
- before { group.request_access(user) }
+ before do
+ group.request_access(user)
+ end
it { expect(group.requesters.exists?(user_id: user)).to be_truthy }
end
@@ -22,17 +24,19 @@ describe AccessRequestable do
describe 'Project' do
describe '#request_access' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:user) { create(:user) }
it { expect(project.request_access(user)).to be_a(ProjectMember) }
end
describe '#access_requested?' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:user) { create(:user) }
- before { project.request_access(user) }
+ before do
+ project.request_access(user)
+ end
it { expect(project.requesters.exists?(user_id: user)).to be_truthy }
end
diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb
index de791abdf3d..63ad3a3630b 100644
--- a/spec/models/concerns/awardable_spec.rb
+++ b/spec/models/concerns/awardable_spec.rb
@@ -1,10 +1,12 @@
require 'spec_helper'
-describe Issue, "Awardable" do
+describe Awardable do
let!(:issue) { create(:issue) }
let!(:award_emoji) { create(:award_emoji, :downvote, awardable: issue) }
describe "Associations" do
+ subject { build(:issue) }
+
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
end
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index 4edafbc4e32..40bbb10eaac 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -170,6 +170,12 @@ describe CacheMarkdownField do
is_expected.to be_truthy
end
+
+ it 'returns false if the markdown field is set but the html is not' do
+ thing.foo_html = nil
+
+ is_expected.to be_falsy
+ end
end
describe '#refresh_markdown_cache!' do
diff --git a/spec/models/concerns/case_sensitivity_spec.rb b/spec/models/concerns/case_sensitivity_spec.rb
index 92fdc5cd65d..5c0dfaeb4d3 100644
--- a/spec/models/concerns/case_sensitivity_spec.rb
+++ b/spec/models/concerns/case_sensitivity_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CaseSensitivity, models: true do
+describe CaseSensitivity do
describe '.iwhere' do
let(:connection) { ActiveRecord::Base.connection }
let(:model) { Class.new { include CaseSensitivity } }
@@ -15,13 +15,13 @@ describe CaseSensitivity, models: true do
it 'returns the criteria for a column and a value' do
criteria = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:foo).
- and_return('"foo"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:foo)
+ .and_return('"foo"')
- expect(model).to receive(:where).
- with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar').
- and_return(criteria)
+ expect(model).to receive(:where)
+ .with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar')
+ .and_return(criteria)
expect(model.iwhere(foo: 'bar')).to eq(criteria)
end
@@ -29,13 +29,13 @@ describe CaseSensitivity, models: true do
it 'returns the criteria for a column with a table, and a value' do
criteria = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:'foo.bar').
- and_return('"foo"."bar"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.bar')
+ .and_return('"foo"."bar"')
- expect(model).to receive(:where).
- with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar').
- and_return(criteria)
+ expect(model).to receive(:where)
+ .with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar')
+ .and_return(criteria)
expect(model.iwhere('foo.bar'.to_sym => 'bar')).to eq(criteria)
end
@@ -46,21 +46,21 @@ describe CaseSensitivity, models: true do
initial = double(:criteria)
final = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:foo).
- and_return('"foo"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:foo)
+ .and_return('"foo"')
- expect(connection).to receive(:quote_table_name).
- with(:bar).
- and_return('"bar"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:bar)
+ .and_return('"bar"')
- expect(model).to receive(:where).
- with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar').
- and_return(initial)
+ expect(model).to receive(:where)
+ .with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar')
+ .and_return(initial)
- expect(initial).to receive(:where).
- with(%q{LOWER("bar") = LOWER(:value)}, value: 'baz').
- and_return(final)
+ expect(initial).to receive(:where)
+ .with(%q{LOWER("bar") = LOWER(:value)}, value: 'baz')
+ .and_return(final)
got = model.iwhere(foo: 'bar', bar: 'baz')
@@ -71,21 +71,21 @@ describe CaseSensitivity, models: true do
initial = double(:criteria)
final = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:'foo.bar').
- and_return('"foo"."bar"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.bar')
+ .and_return('"foo"."bar"')
- expect(connection).to receive(:quote_table_name).
- with(:'foo.baz').
- and_return('"foo"."baz"')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.baz')
+ .and_return('"foo"."baz"')
- expect(model).to receive(:where).
- with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar').
- and_return(initial)
+ expect(model).to receive(:where)
+ .with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar')
+ .and_return(initial)
- expect(initial).to receive(:where).
- with(%q{LOWER("foo"."baz") = LOWER(:value)}, value: 'baz').
- and_return(final)
+ expect(initial).to receive(:where)
+ .with(%q{LOWER("foo"."baz") = LOWER(:value)}, value: 'baz')
+ .and_return(final)
got = model.iwhere('foo.bar'.to_sym => 'bar',
'foo.baz'.to_sym => 'baz')
@@ -105,13 +105,13 @@ describe CaseSensitivity, models: true do
it 'returns the criteria for a column and a value' do
criteria = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:foo).
- and_return('`foo`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:foo)
+ .and_return('`foo`')
- expect(model).to receive(:where).
- with(%q{`foo` = :value}, value: 'bar').
- and_return(criteria)
+ expect(model).to receive(:where)
+ .with(%q{`foo` = :value}, value: 'bar')
+ .and_return(criteria)
expect(model.iwhere(foo: 'bar')).to eq(criteria)
end
@@ -119,16 +119,16 @@ describe CaseSensitivity, models: true do
it 'returns the criteria for a column with a table, and a value' do
criteria = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:'foo.bar').
- and_return('`foo`.`bar`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.bar')
+ .and_return('`foo`.`bar`')
- expect(model).to receive(:where).
- with(%q{`foo`.`bar` = :value}, value: 'bar').
- and_return(criteria)
+ expect(model).to receive(:where)
+ .with(%q{`foo`.`bar` = :value}, value: 'bar')
+ .and_return(criteria)
- expect(model.iwhere('foo.bar'.to_sym => 'bar')).
- to eq(criteria)
+ expect(model.iwhere('foo.bar'.to_sym => 'bar'))
+ .to eq(criteria)
end
end
@@ -137,21 +137,21 @@ describe CaseSensitivity, models: true do
initial = double(:criteria)
final = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:foo).
- and_return('`foo`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:foo)
+ .and_return('`foo`')
- expect(connection).to receive(:quote_table_name).
- with(:bar).
- and_return('`bar`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:bar)
+ .and_return('`bar`')
- expect(model).to receive(:where).
- with(%q{`foo` = :value}, value: 'bar').
- and_return(initial)
+ expect(model).to receive(:where)
+ .with(%q{`foo` = :value}, value: 'bar')
+ .and_return(initial)
- expect(initial).to receive(:where).
- with(%q{`bar` = :value}, value: 'baz').
- and_return(final)
+ expect(initial).to receive(:where)
+ .with(%q{`bar` = :value}, value: 'baz')
+ .and_return(final)
got = model.iwhere(foo: 'bar', bar: 'baz')
@@ -162,21 +162,21 @@ describe CaseSensitivity, models: true do
initial = double(:criteria)
final = double(:criteria)
- expect(connection).to receive(:quote_table_name).
- with(:'foo.bar').
- and_return('`foo`.`bar`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.bar')
+ .and_return('`foo`.`bar`')
- expect(connection).to receive(:quote_table_name).
- with(:'foo.baz').
- and_return('`foo`.`baz`')
+ expect(connection).to receive(:quote_table_name)
+ .with(:'foo.baz')
+ .and_return('`foo`.`baz`')
- expect(model).to receive(:where).
- with(%q{`foo`.`bar` = :value}, value: 'bar').
- and_return(initial)
+ expect(model).to receive(:where)
+ .with(%q{`foo`.`bar` = :value}, value: 'bar')
+ .and_return(initial)
- expect(initial).to receive(:where).
- with(%q{`foo`.`baz` = :value}, value: 'baz').
- and_return(final)
+ expect(initial).to receive(:where)
+ .with(%q{`foo`.`baz` = :value}, value: 'baz')
+ .and_return(final)
got = model.iwhere('foo.bar'.to_sym => 'bar',
'foo.baz'.to_sym => 'baz')
diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb
index 0002a00770f..2322eb206fb 100644
--- a/spec/models/concerns/discussion_on_diff_spec.rb
+++ b/spec/models/concerns/discussion_on_diff_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe DiffDiscussion, DiscussionOnDiff, model: true do
+describe DiscussionOnDiff do
subject { create(:diff_note_on_merge_request).to_discussion }
describe "#truncated_diff_lines" do
@@ -8,9 +8,9 @@ describe DiffDiscussion, DiscussionOnDiff, model: true do
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(subject.diff_lines.count).to be > DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES
- expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES
+ expect(truncated_lines.count).to be <= DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES
end
end
@@ -21,4 +21,30 @@ describe DiffDiscussion, DiscussionOnDiff, model: true do
end
end
end
+
+ describe '#line_code_in_diffs' do
+ context 'when the discussion is active in the diff' do
+ let(:diff_refs) { subject.position.diff_refs }
+
+ it 'returns the current line code' do
+ expect(subject.line_code_in_diffs(diff_refs)).to eq(subject.line_code)
+ end
+ end
+
+ context 'when the discussion was created in the diff' do
+ let(:diff_refs) { subject.original_position.diff_refs }
+
+ it 'returns the original line code' do
+ expect(subject.line_code_in_diffs(diff_refs)).to eq(subject.original_line_code)
+ end
+ end
+
+ context 'when the discussion is unrelated to the diff' do
+ let(:diff_refs) { subject.project.commit(RepoHelpers.sample_commit.id).diff_refs }
+
+ it 'returns nil' do
+ expect(subject.line_code_in_diffs(diff_refs)).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb
new file mode 100644
index 00000000000..951690a217b
--- /dev/null
+++ b/spec/models/concerns/each_batch_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+describe EachBatch do
+ describe '.each_batch' do
+ let(:model) do
+ Class.new(ActiveRecord::Base) do
+ include EachBatch
+
+ self.table_name = 'users'
+ end
+ end
+
+ before do
+ 5.times { create(:user, updated_at: 1.day.ago) }
+ end
+
+ it 'yields an ActiveRecord::Relation when a block is given' do
+ model.each_batch do |relation|
+ expect(relation).to be_a_kind_of(ActiveRecord::Relation)
+ end
+ end
+
+ it 'yields a batch index as the second argument' do
+ model.each_batch do |_, index|
+ expect(index).to eq(1)
+ end
+ end
+
+ it 'accepts a custom batch size' do
+ amount = 0
+
+ model.each_batch(of: 1) { amount += 1 }
+
+ expect(amount).to eq(5)
+ end
+
+ it 'does not include ORDER BYs in the yielded relations' do
+ model.each_batch do |relation|
+ expect(relation.to_sql).not_to include('ORDER BY')
+ end
+ end
+
+ it 'allows updating of the yielded relations' do
+ time = Time.now
+
+ model.each_batch do |relation|
+ relation.update_all(updated_at: time)
+ end
+
+ expect(model.where(updated_at: time).count).to eq(5)
+ end
+ end
+end
diff --git a/spec/models/concerns/editable_spec.rb b/spec/models/concerns/editable_spec.rb
new file mode 100644
index 00000000000..cd73af3b480
--- /dev/null
+++ b/spec/models/concerns/editable_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Editable do
+ describe '#is_edited?' do
+ let(:issue) { create(:issue, last_edited_at: nil) }
+ let(:edited_issue) { create(:issue, created_at: 3.days.ago, last_edited_at: 2.days.ago) }
+
+ it { expect(issue.is_edited?).to eq(false) }
+ it { expect(edited_issue.is_edited?).to eq(true) }
+ end
+end
diff --git a/spec/models/concerns/feature_gate_spec.rb b/spec/models/concerns/feature_gate_spec.rb
new file mode 100644
index 00000000000..3f601243245
--- /dev/null
+++ b/spec/models/concerns/feature_gate_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe FeatureGate do
+ describe 'User' do
+ describe '#flipper_id' do
+ context 'when user is not persisted' do
+ let(:user) { build(:user) }
+
+ it { expect(user.flipper_id).to be_nil }
+ end
+
+ context 'when user is persisted' do
+ let(:user) { create(:user) }
+
+ it { expect(user.flipper_id).to eq "User:#{user.id}" }
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb
index 67dae7cf4c0..a38f2553eb1 100644
--- a/spec/models/concerns/has_status_spec.rb
+++ b/spec/models/concerns/has_status_spec.rb
@@ -48,7 +48,7 @@ describe HasStatus do
[create(type, status: :failed, allow_failure: true)]
end
- it { is_expected.to eq 'skipped' }
+ it { is_expected.to eq 'success' }
end
context 'success and canceled' do
@@ -168,8 +168,8 @@ describe HasStatus do
describe ".#{status}" do
it 'contains the job' do
- expect(CommitStatus.public_send(status).all).
- to contain_exactly(job)
+ expect(CommitStatus.public_send(status).all)
+ .to contain_exactly(job)
end
end
diff --git a/spec/models/concerns/has_variable_spec.rb b/spec/models/concerns/has_variable_spec.rb
new file mode 100644
index 00000000000..f4b24e6d1d9
--- /dev/null
+++ b/spec/models/concerns/has_variable_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe HasVariable do
+ subject { build(:ci_variable) }
+
+ it { is_expected.to validate_presence_of(:key) }
+ it { is_expected.to validate_length_of(:key).is_at_most(255) }
+ it { is_expected.to allow_value('foo').for(:key) }
+ it { is_expected.not_to allow_value('foo bar').for(:key) }
+ it { is_expected.not_to allow_value('foo/bar').for(:key) }
+
+ describe '#value' do
+ before do
+ subject.value = 'secret'
+ end
+
+ it 'stores the encrypted value' do
+ expect(subject.encrypted_value).not_to be_nil
+ end
+
+ it 'stores an iv for value' do
+ expect(subject.encrypted_value_iv).not_to be_nil
+ end
+
+ it 'stores a salt for value' do
+ expect(subject.encrypted_value_salt).not_to be_nil
+ end
+
+ it 'fails to decrypt if iv is incorrect' do
+ subject.encrypted_value_iv = SecureRandom.hex
+ subject.instance_variable_set(:@value, nil)
+ expect { subject.value }
+ .to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt')
+ end
+ end
+
+ describe '#to_runner_variable' do
+ it 'returns a hash for the runner' do
+ expect(subject.to_runner_variable)
+ .to eq(key: subject.key, value: subject.value, public: false)
+ end
+ end
+end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 4522206fab1..dfbe1a7c192 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -1,13 +1,15 @@
require 'spec_helper'
-describe Issue, "Issuable" do
+describe Issuable do
+ let(:issuable_class) { Issue }
let(:issue) { create(:issue) }
let(:user) { create(:user) }
describe "Associations" do
+ subject { build(:issue) }
+
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:author) }
- it { is_expected.to belong_to(:assignee) }
it { is_expected.to have_many(:notes).dependent(:destroy) }
it { is_expected.to have_many(:todos).dependent(:destroy) }
@@ -23,10 +25,14 @@ describe Issue, "Issuable" do
end
describe 'Included modules' do
+ let(:described_class) { issuable_class }
+
it { is_expected.to include_module(Awardable) }
end
describe "Validation" do
+ subject { build(:issue) }
+
before do
allow(subject).to receive(:set_iid).and_return(false)
end
@@ -39,9 +45,11 @@ describe Issue, "Issuable" do
end
describe "Scope" do
- it { expect(described_class).to respond_to(:opened) }
- it { expect(described_class).to respond_to(:closed) }
- it { expect(described_class).to respond_to(:assigned) }
+ subject { build(:issue) }
+
+ it { expect(issuable_class).to respond_to(:opened) }
+ it { expect(issuable_class).to respond_to(:closed) }
+ it { expect(issuable_class).to respond_to(:assigned) }
end
describe 'author_name' do
@@ -57,75 +65,21 @@ describe Issue, "Issuable" do
end
end
- describe 'assignee_name' do
- it 'is delegated to assignee' do
- issue.update!(assignee: create(:user))
-
- expect(issue.assignee_name).to eq issue.assignee.name
- end
-
- it 'returns nil when assignee is nil' do
- issue.assignee_id = nil
- issue.save(validate: false)
-
- expect(issue.assignee_name).to eq nil
- end
- end
-
- describe "before_save" do
- describe "#update_cache_counts" do
- context "when previous assignee exists" do
- before do
- assignee = create(:user)
- issue.project.team << [assignee, :developer]
- issue.update(assignee: assignee)
- end
-
- it "updates cache counts for new assignee" do
- user = create(:user)
-
- expect(user).to receive(:update_cache_counts)
-
- issue.update(assignee: user)
- end
-
- it "updates cache counts for previous assignee" do
- old_assignee = issue.assignee
- allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee)
-
- expect(old_assignee).to receive(:update_cache_counts)
-
- issue.update(assignee: nil)
- end
- end
-
- context "when previous assignee does not exist" do
- before{ issue.update(assignee: nil) }
-
- it "updates cache count for the new assignee" do
- expect_any_instance_of(User).to receive(:update_cache_counts)
-
- issue.update(assignee: user)
- end
- end
- end
- end
-
describe ".search" do
let!(:searchable_issue) { create(:issue, title: "Searchable issue") }
it 'returns notes with a matching title' do
- expect(described_class.search(searchable_issue.title)).
- to eq([searchable_issue])
+ expect(issuable_class.search(searchable_issue.title))
+ .to eq([searchable_issue])
end
it 'returns notes with a partially matching title' do
- expect(described_class.search('able')).to eq([searchable_issue])
+ expect(issuable_class.search('able')).to eq([searchable_issue])
end
it 'returns notes with a matching title regardless of the casing' do
- expect(described_class.search(searchable_issue.title.upcase)).
- to eq([searchable_issue])
+ expect(issuable_class.search(searchable_issue.title.upcase))
+ .to eq([searchable_issue])
end
end
@@ -135,32 +89,32 @@ describe Issue, "Issuable" do
end
it 'returns notes with a matching title' do
- expect(described_class.full_search(searchable_issue.title)).
- to eq([searchable_issue])
+ expect(issuable_class.full_search(searchable_issue.title))
+ .to eq([searchable_issue])
end
it 'returns notes with a partially matching title' do
- expect(described_class.full_search('able')).to eq([searchable_issue])
+ expect(issuable_class.full_search('able')).to eq([searchable_issue])
end
it 'returns notes with a matching title regardless of the casing' do
- expect(described_class.full_search(searchable_issue.title.upcase)).
- to eq([searchable_issue])
+ expect(issuable_class.full_search(searchable_issue.title.upcase))
+ .to eq([searchable_issue])
end
it 'returns notes with a matching description' do
- expect(described_class.full_search(searchable_issue.description)).
- to eq([searchable_issue])
+ expect(issuable_class.full_search(searchable_issue.description))
+ .to eq([searchable_issue])
end
it 'returns notes with a partially matching description' do
- expect(described_class.full_search(searchable_issue.description)).
- to eq([searchable_issue])
+ expect(issuable_class.full_search(searchable_issue.description))
+ .to eq([searchable_issue])
end
it 'returns notes with a matching description regardless of the casing' do
- expect(described_class.full_search(searchable_issue.description.upcase)).
- to eq([searchable_issue])
+ expect(issuable_class.full_search(searchable_issue.description.upcase))
+ .to eq([searchable_issue])
end
end
@@ -201,7 +155,7 @@ describe Issue, "Issuable" do
end
describe "#sort" do
- let(:project) { build_stubbed(:empty_project) }
+ let(:project) { create(:project) }
context "by milestone due date" do
# Correct order is:
@@ -246,7 +200,9 @@ describe Issue, "Issuable" do
let(:project) { issue.project }
context 'user is not a participant in the issue' do
- before { allow(issue).to receive(:participants).with(user).and_return([]) }
+ before do
+ allow(issue).to receive(:participants).with(user).and_return([])
+ end
it 'returns false when no subcription exists' do
expect(issue.subscribed?(user, project)).to be_falsey
@@ -266,7 +222,9 @@ describe Issue, "Issuable" do
end
context 'user is a participant in the issue' do
- before { allow(issue).to receive(:participants).with(user).and_return([user]) }
+ before do
+ allow(issue).to receive(:participants).with(user).and_return([user])
+ end
it 'returns false when no subcription exists' do
expect(issue.subscribed?(user, project)).to be_truthy
@@ -298,7 +256,22 @@ describe Issue, "Issuable" do
end
context "issue is assigned" do
- before { issue.update_attribute(:assignee, user) }
+ before do
+ issue.assignees << user
+ end
+
+ it "returns correct hook data" do
+ expect(data[:assignees].first).to eq(user.hook_attrs)
+ end
+ end
+
+ context "merge_request is assigned" do
+ let(:merge_request) { create(:merge_request) }
+ let(:data) { merge_request.to_hook_data(user) }
+
+ before do
+ merge_request.update_attribute(:assignee, user)
+ end
it "returns correct hook data" do
expect(data[:object_attributes]['assignee_id']).to eq(user.id)
@@ -309,7 +282,9 @@ describe Issue, "Issuable" do
context 'issue has labels' do
let(:labels) { [create(:label), create(:label)] }
- before { issue.update_attribute(:labels, labels)}
+ before do
+ issue.update_attribute(:labels, labels)
+ end
it 'includes labels in the hook data' do
expect(data[:labels]).to eq(labels.map(&:hook_attrs))
@@ -320,30 +295,12 @@ describe Issue, "Issuable" do
include_examples 'deprecated repository hook data'
end
- describe '#card_attributes' do
- it 'includes the author name' do
- allow(issue).to receive(:author).and_return(double(name: 'Robert'))
- allow(issue).to receive(:assignee).and_return(nil)
-
- expect(issue.card_attributes).
- to eq({ 'Author' => 'Robert', 'Assignee' => nil })
- end
-
- it 'includes the assignee name' do
- allow(issue).to receive(:author).and_return(double(name: 'Robert'))
- allow(issue).to receive(:assignee).and_return(double(name: 'Douwe'))
-
- expect(issue.card_attributes).
- to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' })
- end
- end
-
describe '#labels_array' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:bug) { create(:label, project: project, title: 'bug') }
let(:issue) { create(:issue, project: project) }
- before(:each) do
+ before do
issue.labels << bug
end
@@ -353,7 +310,7 @@ describe Issue, "Issuable" do
end
describe '#user_notes_count' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
@@ -383,7 +340,7 @@ describe Issue, "Issuable" do
end
describe '.order_due_date_and_labels_priority' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
def create_issue(milestone, labels)
create(:labeled_issue, milestone: milestone, labels: labels, project: project)
@@ -437,7 +394,7 @@ describe Issue, "Issuable" do
end
describe ".with_label" do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:bug) { create(:label, project: project, title: 'bug') }
let(:feature) { create(:label, project: project, title: 'feature') }
let(:enhancement) { create(:label, project: project, title: 'enhancement') }
@@ -445,7 +402,7 @@ describe Issue, "Issuable" do
let(:issue2) { create(:issue, title: "Bugfix2", project: project) }
let(:issue3) { create(:issue, title: "Feature1", project: project) }
- before(:each) do
+ before do
issue1.labels << bug
issue1.labels << feature
issue2.labels << bug
@@ -466,27 +423,6 @@ describe Issue, "Issuable" do
end
end
- describe '#assignee_or_author?' do
- let(:user) { build(:user, id: 1) }
- let(:issue) { build(:issue) }
-
- it 'returns true for a user that is assigned to an issue' do
- issue.assignee = user
-
- expect(issue.assignee_or_author?(user)).to eq(true)
- end
-
- it 'returns true for a user that is the author of an issue' do
- issue.author = user
-
- expect(issue.assignee_or_author?(user)).to eq(true)
- end
-
- it 'returns false for a user that is not the assignee or author' do
- expect(issue.assignee_or_author?(user)).to eq(false)
- end
- end
-
describe '#spend_time' do
let(:user) { create(:user) }
let(:issue) { create(:issue) }
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 2092576e981..8b545aec7f5 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -13,7 +13,7 @@ describe Mentionable do
end
describe 'references' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:mentionable) { Example.new }
it 'excludes JIRA references' do
@@ -48,10 +48,10 @@ describe Issue, "Mentionable" do
describe '#referenced_mentionables' do
context 'with an issue on a private project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
let(:public_issue) { create(:issue, project: project) }
- let(:private_project) { create(:empty_project, :private) }
+ let(:private_project) { create(:project, :private) }
let(:private_issue) { create(:issue, project: private_project) }
let(:user) { create(:user) }
@@ -61,7 +61,9 @@ describe Issue, "Mentionable" do
end
context 'when the current user can see the issue' do
- before { private_project.team << [user, Gitlab::Access::DEVELOPER] }
+ before do
+ private_project.team << [user, Gitlab::Access::DEVELOPER]
+ end
it 'includes the reference' do
expect(referenced_issues(user)).to contain_exactly(private_issue, public_issue)
@@ -100,7 +102,7 @@ describe Issue, "Mentionable" do
end
describe '#create_new_cross_references!' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:author) { create(:author) }
let(:issues) { create_list(:issue, 2, project: project, author: author) }
@@ -163,3 +165,58 @@ describe Issue, "Mentionable" do
end
end
end
+
+describe Commit, 'Mentionable' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:commit) { project.commit }
+
+ describe '#matches_cross_reference_regex?' do
+ it "is false when message doesn't reference anything" do
+ allow(commit.raw).to receive(:message).and_return "WIP: Do something"
+
+ expect(commit.matches_cross_reference_regex?).to be_falsey
+ end
+
+ it 'is true if issue #number mentioned in title' do
+ allow(commit.raw).to receive(:message).and_return "#1"
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+
+ it 'is true if references an MR' do
+ allow(commit.raw).to receive(:message).and_return "See merge request !12"
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+
+ it 'is true if references a commit' do
+ allow(commit.raw).to receive(:message).and_return "a1b2c3d4"
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+
+ it 'is true if issue referenced by url' do
+ issue = create(:issue, project: project)
+
+ allow(commit.raw).to receive(:message).and_return Gitlab::UrlBuilder.build(issue)
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+
+ context 'with external issue tracker' do
+ let(:project) { create(:jira_project, :repository) }
+
+ it 'is true if external issues referenced' do
+ allow(commit.raw).to receive(:message).and_return 'JIRA-123'
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+
+ it 'is true if internal issues referenced' do
+ allow(commit.raw).to receive(:message).and_return '#123'
+
+ expect(commit.matches_cross_reference_regex?).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb
index 68e4c0a522b..66353935427 100644
--- a/spec/models/concerns/milestoneish_spec.rb
+++ b/spec/models/concerns/milestoneish_spec.rb
@@ -7,24 +7,55 @@ describe Milestone, 'Milestoneish' do
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:milestone) { create(:milestone, project: project) }
let!(:issue) { create(:issue, project: project, milestone: milestone) }
let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) }
- let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) }
+ let!(:security_issue_2) { create(:issue, :confidential, project: project, assignees: [assignee], milestone: milestone) }
let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) }
let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) }
let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) }
- let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) }
+ let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) }
let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) }
- let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) }
+ let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) }
+ let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) }
+ let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) }
+ let(:label_3) { create(:label, title: 'label_3', project: project) }
before do
project.team << [member, :developer]
project.team << [guest, :guest]
end
+ describe '#sorted_issues' do
+ it 'sorts issues by label priority' do
+ issue.labels << label_1
+ security_issue_1.labels << label_2
+ closed_issue_1.labels << label_3
+
+ issues = milestone.sorted_issues(member)
+
+ expect(issues.first).to eq(issue)
+ expect(issues.second).to eq(security_issue_1)
+ expect(issues.third).not_to eq(closed_issue_1)
+ end
+ end
+
+ describe '#sorted_merge_requests' do
+ it 'sorts merge requests by label priority' do
+ merge_request_1 = create(:labeled_merge_request, labels: [label_2], source_project: project, source_branch: 'branch_1', milestone: milestone)
+ merge_request_2 = create(:labeled_merge_request, labels: [label_1], source_project: project, source_branch: 'branch_2', milestone: milestone)
+ merge_request_3 = create(:labeled_merge_request, labels: [label_3], source_project: project, source_branch: 'branch_3', milestone: milestone)
+
+ merge_requests = milestone.sorted_merge_requests
+
+ expect(merge_requests.first).to eq(merge_request_2)
+ expect(merge_requests.second).to eq(merge_request_1)
+ expect(merge_requests.third).to eq(merge_request_3)
+ end
+ end
+
describe '#closed_items_count' do
it 'does not count confidential issues for non project members' do
expect(milestone.closed_items_count(non_member)).to eq 2
diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb
index 92cc8859a8c..485a6e165a1 100644
--- a/spec/models/concerns/noteable_spec.rb
+++ b/spec/models/concerns/noteable_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequest, Noteable, model: true do
+describe Noteable do
let!(:active_diff_note1) { create(:diff_note_on_merge_request) }
let(:project) { active_diff_note1.project }
subject { active_diff_note1.noteable }
diff --git a/spec/models/concerns/participable_spec.rb b/spec/models/concerns/participable_spec.rb
index a9f4ef9ee5e..431f1482615 100644
--- a/spec/models/concerns/participable_spec.rb
+++ b/spec/models/concerns/participable_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Participable, models: true do
+describe Participable do
let(:model) do
Class.new do
include Participable
diff --git a/spec/models/concerns/project_features_compatibility_spec.rb b/spec/models/concerns/project_features_compatibility_spec.rb
index 6cf5877424d..9041690023f 100644
--- a/spec/models/concerns/project_features_compatibility_spec.rb
+++ b/spec/models/concerns/project_features_compatibility_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe ProjectFeaturesCompatibility do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:features) { %w(issues wiki builds merge_requests snippets) }
# We had issues_enabled, snippets_enabled, builds_enabled, merge_requests_enabled and issues_enabled fields on projects table
diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb
index a0765a264cf..a5d505af001 100644
--- a/spec/models/concerns/reactive_caching_spec.rb
+++ b/spec/models/concerns/reactive_caching_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ReactiveCaching, caching: true do
+describe ReactiveCaching, :use_clean_rails_memory_store_caching do
include ReactiveCachingHelpers
class CacheTest
@@ -31,7 +31,7 @@ describe ReactiveCaching, caching: true do
let(:now) { Time.now.utc }
- around(:each) do |example|
+ around do |example|
Timecop.freeze(now) { example.run }
end
@@ -40,7 +40,10 @@ describe ReactiveCaching, caching: true do
let(:instance) { CacheTest.new(666, &calculation) }
describe '#with_reactive_cache' do
- before { stub_reactive_cache }
+ before do
+ stub_reactive_cache
+ end
+
subject(:go!) { instance.result }
context 'when cache is empty' do
@@ -60,12 +63,17 @@ describe ReactiveCaching, caching: true do
end
context 'when the cache is full' do
- before { stub_reactive_cache(instance, 4) }
+ before do
+ stub_reactive_cache(instance, 4)
+ end
it { is_expected.to eq(2) }
context 'and expired' do
- before { invalidate_reactive_cache(instance) }
+ before do
+ invalidate_reactive_cache(instance)
+ end
+
it { is_expected.to be_nil }
end
end
@@ -84,7 +92,9 @@ describe ReactiveCaching, caching: true do
subject(:go!) { instance.exclusively_update_reactive_cache! }
context 'when the lease is free and lifetime is not exceeded' do
- before { stub_reactive_cache(instance, "preexisting") }
+ before do
+ stub_reactive_cache(instance, "preexisting")
+ end
it 'takes and releases the lease' do
expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return("000000")
@@ -106,7 +116,10 @@ describe ReactiveCaching, caching: true do
end
context 'and #calculate_reactive_cache raises an exception' do
- before { stub_reactive_cache(instance, "preexisting") }
+ before do
+ stub_reactive_cache(instance, "preexisting")
+ end
+
let(:calculation) { -> { raise "foo"} }
it 'leaves the cache untouched' do
diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb
index 255b584a85e..729056b6abc 100644
--- a/spec/models/concerns/relative_positioning_spec.rb
+++ b/spec/models/concerns/relative_positioning_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Issue, 'RelativePositioning' do
- let(:project) { create(:empty_project) }
+describe RelativePositioning do
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:issue1) { create(:issue, project: project) }
let(:new_issue) { create(:issue, project: project) }
diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb
index 18327fe262d..1616c2ea985 100644
--- a/spec/models/concerns/resolvable_discussion_spec.rb
+++ b/spec/models/concerns/resolvable_discussion_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Discussion, ResolvableDiscussion, models: true do
+describe Discussion, ResolvableDiscussion do
subject { described_class.new([first_note, second_note, third_note]) }
let(:first_note) { create(:discussion_note_on_merge_request) }
@@ -306,22 +306,22 @@ describe Discussion, ResolvableDiscussion, models: true do
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 }
+ 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 }
+ 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? }
+ expect { subject.resolve!(current_user) }
+ .not_to change { first_note.reload && first_note.resolved? }
end
it "sets resolved_at on the unresolved note" do
diff --git a/spec/models/concerns/resolvable_note_spec.rb b/spec/models/concerns/resolvable_note_spec.rb
index 1503ccdff11..d00faa4f8be 100644
--- a/spec/models/concerns/resolvable_note_spec.rb
+++ b/spec/models/concerns/resolvable_note_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Note, ResolvableNote, models: true do
- let(:project) { create(:project) }
+describe Note, ResolvableNote do
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
subject { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb
index 221647d7a48..b463d12e448 100644
--- a/spec/models/concerns/routable_spec.rb
+++ b/spec/models/concerns/routable_spec.rb
@@ -9,6 +9,7 @@ describe Group, 'Routable' do
describe 'Associations' do
it { is_expected.to have_one(:route).dependent(:destroy) }
+ it { is_expected.to have_many(:redirect_routes).dependent(:destroy) }
end
describe 'Callbacks' do
@@ -35,10 +36,53 @@ describe Group, 'Routable' do
describe '.find_by_full_path' do
let!(:nested_group) { create(:group, parent: group) }
- it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) }
- it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) }
- it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) }
- it { expect(described_class.find_by_full_path('unknown')).to eq(nil) }
+ context 'without any redirect routes' do
+ it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) }
+ it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) }
+ it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) }
+ it { expect(described_class.find_by_full_path('unknown')).to eq(nil) }
+ end
+
+ context 'with redirect routes' do
+ let!(:group_redirect_route) { group.redirect_routes.create!(path: 'bar') }
+ let!(:nested_group_redirect_route) { nested_group.redirect_routes.create!(path: nested_group.path.sub('foo', 'bar')) }
+
+ context 'without follow_redirects option' do
+ context 'with the given path not matching any route' do
+ it { expect(described_class.find_by_full_path('unknown')).to eq(nil) }
+ end
+
+ context 'with the given path matching the canonical route' do
+ it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) }
+ it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) }
+ it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) }
+ end
+
+ context 'with the given path matching a redirect route' do
+ it { expect(described_class.find_by_full_path(group_redirect_route.path)).to eq(nil) }
+ it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase)).to eq(nil) }
+ it { expect(described_class.find_by_full_path(nested_group_redirect_route.path)).to eq(nil) }
+ end
+ end
+
+ context 'with follow_redirects option set to true' do
+ context 'with the given path not matching any route' do
+ it { expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) }
+ end
+
+ context 'with the given path matching the canonical route' do
+ it { expect(described_class.find_by_full_path(group.to_param, follow_redirects: true)).to eq(group) }
+ it { expect(described_class.find_by_full_path(group.to_param.upcase, follow_redirects: true)).to eq(group) }
+ it { expect(described_class.find_by_full_path(nested_group.to_param, follow_redirects: true)).to eq(nested_group) }
+ end
+
+ context 'with the given path matching a redirect route' do
+ it { expect(described_class.find_by_full_path(group_redirect_route.path, follow_redirects: true)).to eq(group) }
+ it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase, follow_redirects: true)).to eq(group) }
+ it { expect(described_class.find_by_full_path(nested_group_redirect_route.path, follow_redirects: true)).to eq(nested_group) }
+ end
+ end
+ end
end
describe '.where_full_path_in' do
@@ -71,123 +115,6 @@ describe Group, 'Routable' do
end
end
- describe '.member_descendants' do
- let!(:user) { create(:user) }
- let!(:nested_group) { create(:group, parent: group) }
-
- before { group.add_owner(user) }
- subject { described_class.member_descendants(user.id) }
-
- it { is_expected.to eq([nested_group]) }
- end
-
- describe '.member_self_and_descendants' do
- let!(:user) { create(:user) }
- let!(:nested_group) { create(:group, parent: group) }
-
- before { group.add_owner(user) }
- subject { described_class.member_self_and_descendants(user.id) }
-
- it { is_expected.to match_array [group, nested_group] }
- end
-
- describe '.member_hierarchy' do
- # foo/bar would also match foo/barbaz instead of just foo/bar and foo/bar/baz
- let!(:user) { create(:user) }
-
- # group
- # _______ (foo) _______
- # | |
- # | |
- # nested_group_1 nested_group_2
- # (bar) (barbaz)
- # | |
- # | |
- # nested_group_1_1 nested_group_2_1
- # (baz) (baz)
- #
- let!(:nested_group_1) { create :group, parent: group, name: 'bar' }
- let!(:nested_group_1_1) { create :group, parent: nested_group_1, name: 'baz' }
- let!(:nested_group_2) { create :group, parent: group, name: 'barbaz' }
- let!(:nested_group_2_1) { create :group, parent: nested_group_2, name: 'baz' }
-
- context 'user is not a member of any group' do
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns an empty array' do
- is_expected.to eq []
- end
- end
-
- context 'user is member of all groups' do
- before do
- group.add_owner(user)
- nested_group_1.add_owner(user)
- nested_group_1_1.add_owner(user)
- nested_group_2.add_owner(user)
- nested_group_2_1.add_owner(user)
- end
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns all groups' do
- is_expected.to match_array [
- group,
- nested_group_1, nested_group_1_1,
- nested_group_2, nested_group_2_1
- ]
- end
- end
-
- context 'user is member of the top group' do
- before { group.add_owner(user) }
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns all groups' do
- is_expected.to match_array [
- group,
- nested_group_1, nested_group_1_1,
- nested_group_2, nested_group_2_1
- ]
- end
- end
-
- context 'user is member of the first child (internal node), branch 1' do
- before { nested_group_1.add_owner(user) }
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns the groups in the hierarchy' do
- is_expected.to match_array [
- group,
- nested_group_1, nested_group_1_1
- ]
- end
- end
-
- context 'user is member of the first child (internal node), branch 2' do
- before { nested_group_2.add_owner(user) }
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns the groups in the hierarchy' do
- is_expected.to match_array [
- group,
- nested_group_2, nested_group_2_1
- ]
- end
- end
-
- context 'user is member of the last child (leaf node)' do
- before { nested_group_1_1.add_owner(user) }
- subject { described_class.member_hierarchy(user.id) }
-
- it 'returns the groups in the hierarchy' do
- is_expected.to match_array [
- group,
- nested_group_1, nested_group_1_1
- ]
- end
- end
- end
-
describe '#full_path' do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
@@ -195,16 +122,7 @@ describe Group, 'Routable' do
it { expect(group.full_path).to eq(group.path) }
it { expect(nested_group.full_path).to eq("#{group.full_path}/#{nested_group.path}") }
- context 'with RequestStore active' do
- before do
- RequestStore.begin!
- end
-
- after do
- RequestStore.end!
- RequestStore.clear!
- end
-
+ context 'with RequestStore active', :request_store do
it 'does not load the route table more than once' do
expect(group).to receive(:uncached_full_path).once.and_call_original
@@ -214,6 +132,19 @@ describe Group, 'Routable' do
end
end
+ describe '#expires_full_path_cache' do
+ context 'with RequestStore active', :request_store do
+ it 'expires the full_path cache' do
+ expect(group.full_path).to eq('foo')
+
+ group.route.update(path: 'bar', name: 'bar')
+ group.expires_full_path_cache
+
+ expect(group.full_path).to eq('bar')
+ end
+ end
+ end
+
describe '#full_name' do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
@@ -225,13 +156,13 @@ end
describe Project, 'Routable' do
describe '#full_path' do
- let(:project) { build_stubbed(:empty_project) }
+ let(:project) { build_stubbed(:project) }
it { expect(project.full_path).to eq "#{project.namespace.full_path}/#{project.path}" }
end
describe '#full_name' do
- let(:project) { build_stubbed(:empty_project) }
+ let(:project) { build_stubbed(:project) }
it { expect(project.full_name).to eq "#{project.namespace.human_name} / #{project.name}" }
end
diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb
new file mode 100644
index 00000000000..21893e0cbaa
--- /dev/null
+++ b/spec/models/concerns/sha_attribute_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe ShaAttribute do
+ let(:model) { Class.new { include ShaAttribute } }
+
+ before do
+ columns = [
+ double(:column, name: 'name', type: :text),
+ double(:column, name: 'sha1', type: :binary)
+ ]
+
+ allow(model).to receive(:columns).and_return(columns)
+ end
+
+ describe '#sha_attribute' do
+ context 'when the table exists' do
+ before do
+ allow(model).to receive(:table_exists?).and_return(true)
+ end
+
+ it 'defines a SHA attribute for a binary column' do
+ expect(model).to receive(:attribute)
+ .with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute))
+
+ model.sha_attribute(:sha1)
+ end
+
+ it 'raises ArgumentError when the column type is not :binary' do
+ expect { model.sha_attribute(:name) }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'when the table does not exist' do
+ before do
+ allow(model).to receive(:table_exists?).and_return(false)
+ end
+
+ it 'does nothing' do
+ expect(model).not_to receive(:columns)
+ expect(model).not_to receive(:attribute)
+
+ model.sha_attribute(:name)
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb
index fd3b8307571..e698207166c 100644
--- a/spec/models/concerns/spammable_spec.rb
+++ b/spec/models/concerns/spammable_spec.rb
@@ -1,9 +1,11 @@
require 'spec_helper'
-describe Issue, 'Spammable' do
+describe Spammable do
let(:issue) { create(:issue, description: 'Test Desc.') }
describe 'Associations' do
+ subject { build(:issue) }
+
it { is_expected.to have_one(:user_agent_detail).dependent(:destroy) }
end
diff --git a/spec/models/concerns/strip_attribute_spec.rb b/spec/models/concerns/strip_attribute_spec.rb
index c3af7a0960f..8c945686b66 100644
--- a/spec/models/concerns/strip_attribute_spec.rb
+++ b/spec/models/concerns/strip_attribute_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Milestone, "StripAttribute" do
+describe StripAttribute do
let(:milestone) { create(:milestone) }
describe ".strip_attributes" do
diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb
index 58f5c164116..28ff8158e0e 100644
--- a/spec/models/concerns/subscribable_spec.rb
+++ b/spec/models/concerns/subscribable_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Subscribable, 'Subscribable' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:resource) { create(:issue, project: project) }
let(:user_1) { create(:user) }
diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb
index 4b0bfa43abf..882afeccfc6 100644
--- a/spec/models/concerns/token_authenticatable_spec.rb
+++ b/spec/models/concerns/token_authenticatable_spec.rb
@@ -49,7 +49,10 @@ describe ApplicationSetting, 'TokenAuthenticatable' do
end
context 'token is generated' do
- before { subject.send("reset_#{token_field}!") }
+ before do
+ subject.send("reset_#{token_field}!")
+ end
+
it 'persists a new token' do
expect(subject.send(:read_attribute, token_field)).to be_a String
end
diff --git a/spec/models/concerns/uniquify_spec.rb b/spec/models/concerns/uniquify_spec.rb
index 83187d732e4..914730718e7 100644
--- a/spec/models/concerns/uniquify_spec.rb
+++ b/spec/models/concerns/uniquify_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Uniquify, models: true do
+describe Uniquify do
let(:uniquify) { described_class.new }
describe "#string" do
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index eff41d85972..bae88cb1d24 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe ContainerRepository do
let(:group) { create(:group, name: 'group') }
- let(:project) { create(:project, path: 'test', group: group) }
+ let(:project) { create(:project, :repository, path: 'test', group: group) }
let(:repository) do
create(:container_repository, name: 'my_image', project: project)
@@ -41,7 +41,7 @@ describe ContainerRepository do
end
context 'when path contains uppercase letters' do
- let(:project) { create(:project, path: 'MY_PROJECT', group: group) }
+ let(:project) { create(:project, :repository, path: 'MY_PROJECT', group: group) }
it 'returns a full path without capital letters' do
expect(repository.path).to eq('group/my_project/my_image')
diff --git a/spec/models/conversational_development_index/metric_spec.rb b/spec/models/conversational_development_index/metric_spec.rb
new file mode 100644
index 00000000000..b3193619503
--- /dev/null
+++ b/spec/models/conversational_development_index/metric_spec.rb
@@ -0,0 +1,11 @@
+require 'rails_helper'
+
+describe ConversationalDevelopmentIndex::Metric do
+ let(:conv_dev_index) { create(:conversational_development_index_metric) }
+
+ describe '#percentage_score' do
+ it 'returns stored percentage score' do
+ expect(conv_dev_index.percentage_score('issues')).to eq(13.331)
+ end
+ end
+end
diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb
index 9053485939e..f2f1928926c 100644
--- a/spec/models/cycle_analytics/code_spec.rb
+++ b/spec/models/cycle_analytics/code_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#code', feature: true do
+describe 'CycleAnalytics#code' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb
index fc7d18bd40e..985e1bf80be 100644
--- a/spec/models/cycle_analytics/issue_spec.rb
+++ b/spec/models/cycle_analytics/issue_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#issue', models: true do
+describe 'CycleAnalytics#issue' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb
index 4f33f3c6d69..6fbb2a2d102 100644
--- a/spec/models/cycle_analytics/plan_spec.rb
+++ b/spec/models/cycle_analytics/plan_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#plan', feature: true do
+describe 'CycleAnalytics#plan' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb
index 4744b9e05ea..f8681c0a2f9 100644
--- a/spec/models/cycle_analytics/production_spec.rb
+++ b/spec/models/cycle_analytics/production_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#production', feature: true do
+describe 'CycleAnalytics#production' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb
index febb18c9884..0ac58695b35 100644
--- a/spec/models/cycle_analytics/review_spec.rb
+++ b/spec/models/cycle_analytics/review_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#review', feature: true do
+describe 'CycleAnalytics#review' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb
index f78d7a23105..b66d5623910 100644
--- a/spec/models/cycle_analytics/staging_spec.rb
+++ b/spec/models/cycle_analytics/staging_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#staging', feature: true do
+describe 'CycleAnalytics#staging' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb
index c2ba012a0e6..690c09bc2dc 100644
--- a/spec/models/cycle_analytics/test_spec.rb
+++ b/spec/models/cycle_analytics/test_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'CycleAnalytics#test', feature: true do
+describe 'CycleAnalytics#test' do
extend CycleAnalyticsHelpers::TestGeneration
let(:project) { create(:project, :repository) }
@@ -13,7 +13,7 @@ describe 'CycleAnalytics#test', feature: true do
data_fn: lambda do |context|
issue = context.create(:issue, project: context.project)
merge_request = context.create_merge_request_closing_issue(issue)
- pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project)
+ pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project, head_pipeline_of: merge_request)
{ pipeline: pipeline, issue: issue }
end,
start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]],
diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb
index 8ef8218cf74..3d7283e2164 100644
--- a/spec/models/deploy_key_spec.rb
+++ b/spec/models/deploy_key_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
-describe DeployKey, models: true do
- include EmailHelpers
-
+describe DeployKey, :mailer do
describe "Associations" do
it { is_expected.to have_many(:deploy_keys_projects) }
it { is_expected.to have_many(:projects) }
diff --git a/spec/models/deploy_keys_project_spec.rb b/spec/models/deploy_keys_project_spec.rb
index aacc178a19e..0345fefb254 100644
--- a/spec/models/deploy_keys_project_spec.rb
+++ b/spec/models/deploy_keys_project_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe DeployKeysProject, models: true do
+describe DeployKeysProject do
describe "Associations" do
it { is_expected.to belong_to(:deploy_key) }
it { is_expected.to belong_to(:project) }
@@ -12,7 +12,7 @@ describe DeployKeysProject, models: true do
end
describe "Destroying" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { create(:deploy_keys_project, project: project) }
let(:deploy_key) { subject.deploy_key }
@@ -39,7 +39,7 @@ describe DeployKeysProject, models: true do
end
context "when the deploy key is used by more than one project" do
- let!(:other_project) { create(:empty_project) }
+ let!(:other_project) { create(:project) }
before do
other_project.deploy_keys << deploy_key
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index 080ff2f3f43..c5708e70ef9 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Deployment, models: true do
+describe Deployment do
subject { build(:deployment) }
it { is_expected.to belong_to(:project) }
@@ -16,8 +16,21 @@ describe Deployment, models: true do
it { is_expected.to validate_presence_of(:ref) }
it { is_expected.to validate_presence_of(:sha) }
+ describe 'after_create callbacks' do
+ let(:environment) { create(:environment) }
+ let(:store) { Gitlab::EtagCaching::Store.new }
+
+ it 'invalidates the environment etag cache' do
+ old_value = store.get(environment.etag_cache_key)
+
+ create(:deployment, environment: environment)
+
+ expect(store.get(environment.etag_cache_key)).not_to eq(old_value)
+ end
+ end
+
describe '#includes_commit?' do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) }
let(:deployment) do
create(:deployment, environment: environment, sha: project.commit.id)
@@ -49,6 +62,64 @@ describe Deployment, models: true do
end
end
+ describe '#metrics' do
+ let(:deployment) { create(:deployment) }
+
+ subject { deployment.metrics }
+
+ context 'metrics are disabled' do
+ it { is_expected.to eq({}) }
+ end
+
+ context 'metrics are enabled' do
+ let(:simple_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42,
+ deployment_time: 1494408956
+ }
+ end
+
+ before do
+ allow(deployment.project).to receive_message_chain(:monitoring_service, :deployment_metrics)
+ .with(any_args).and_return(simple_metrics)
+ end
+
+ it { is_expected.to eq(simple_metrics) }
+ end
+ end
+
+ describe '#additional_metrics' do
+ let(:project) { create(:project, :repository) }
+ let(:deployment) { create(:deployment, project: project) }
+
+ subject { deployment.additional_metrics }
+
+ context 'metrics are disabled' do
+ it { is_expected.to eq({}) }
+ end
+
+ context 'metrics are enabled' do
+ let(:simple_metrics) do
+ {
+ success: true,
+ metrics: {},
+ last_update: 42
+ }
+ end
+
+ let(:prometheus_service) { double('prometheus_service') }
+
+ before do
+ allow(project).to receive(:prometheus_service).and_return(prometheus_service)
+ allow(prometheus_service).to receive(:additional_deployment_metrics).and_return(simple_metrics)
+ end
+
+ it { is_expected.to eq(simple_metrics.merge({ deployment_time: deployment.created_at.to_i })) }
+ end
+ end
+
describe '#stop_action' do
let(:build) { create(:ci_build) }
@@ -61,7 +132,7 @@ describe Deployment, models: true do
end
context 'with other actions' do
- let!(:close_action) { create(:ci_build, pipeline: build.pipeline, name: 'close_app', when: :manual) }
+ let!(:close_action) { create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') }
context 'when matching action is defined' do
let(:deployment) { FactoryGirl.build(:deployment, deployable: build, on_stop: 'close_other_app') }
@@ -89,7 +160,7 @@ describe Deployment, models: true do
context 'when matching action is defined' do
let(:build) { create(:ci_build) }
let(:deployment) { FactoryGirl.build(:deployment, deployable: build, on_stop: 'close_app') }
- let!(:close_action) { create(:ci_build, pipeline: build.pipeline, name: 'close_app', when: :manual) }
+ let!(:close_action) { create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') }
it { is_expected.to be_truthy }
end
diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb
index 48e7c0a822c..fa02434b0fd 100644
--- a/spec/models/diff_discussion_spec.rb
+++ b/spec/models/diff_discussion_spec.rb
@@ -1,19 +1,91 @@
require 'spec_helper'
-describe DiffDiscussion, model: true do
- subject { described_class.new([first_note, second_note, third_note]) }
+describe DiffDiscussion do
+ include RepoHelpers
- 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) }
+ subject { described_class.new([diff_note]) }
+
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
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)
+ expect(attributes[:position]).to eq(diff_note.position.to_json)
+ expect(attributes[:original_position]).to eq(diff_note.original_position.to_json)
+ end
+ end
+
+ describe '#merge_request_version_params' do
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) }
+ let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
+
+ context 'when the discussion is active' do
+ it 'returns an empty hash, which will end up showing the latest version' do
+ expect(subject.merge_request_version_params).to eq({})
+ end
+ end
+
+ context 'when the discussion is on an older merge request version' do
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: nil,
+ new_line: 4,
+ diff_refs: merge_request_diff1.diff_refs
+ )
+ end
+
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) }
+
+ before do
+ diff_note.position = diff_note.original_position
+ diff_note.save!
+ end
+
+ it 'returns the diff ID for the version to show' do
+ expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff1.id)
+ end
+ end
+
+ context 'when the discussion is on a comparison between merge request versions' do
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: 4,
+ new_line: 4,
+ diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs
+ )
+ end
+
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) }
+
+ before do
+ diff_note.position = diff_note.original_position
+ diff_note.save!
+ end
+
+ it 'returns the diff ID and start sha of the versions to compare' do
+ expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha)
+ end
+ end
+
+ context 'when the discussion does not have a merge request version' do
+ let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) }
+
+ before do
+ diff_note.position = diff_note.original_position
+ diff_note.save!
+ end
+
+ it 'returns nil' do
+ expect(subject.merge_request_version_params).to be_nil
+ end
end
end
end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index f32b6b99b3d..4aa9ec789a3 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe DiffNote, models: true do
+describe DiffNote do
include RepoHelpers
let(:merge_request) { create(:merge_request) }
@@ -145,7 +145,7 @@ describe DiffNote, models: true do
context "when the merge request's diff refs don't match that of the diff note" do
before do
- allow(subject.noteable).to receive(:diff_sha_refs).and_return(commit.diff_refs)
+ allow(subject.noteable).to receive(:diff_refs).and_return(commit.diff_refs)
end
it "returns false" do
@@ -155,34 +155,11 @@ describe DiffNote, models: true do
end
end
- describe '#latest_merge_request_diff' do
- context 'when active' do
- it 'returns the current merge request diff' do
- expect(subject.latest_merge_request_diff).to eq(merge_request.merge_request_diff)
- end
- end
-
- context 'when outdated' do
- let!(:old_merge_request_diff) { merge_request.merge_request_diff }
- let!(:new_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: commit.diff_refs) }
-
- it 'returns the latest merge request diff that this diff note applied to' do
- expect(subject.latest_merge_request_diff).to eq(old_merge_request_diff)
- end
- end
- end
-
describe "creation" do
describe "updating of position" do
context "when noteable is a commit" do
let(:diff_note) { create(:diff_note_on_commit, project: project, position: position) }
- it "doesn't use the DiffPositionUpdateService" do
- expect(Notes::DiffPositionUpdateService).not_to receive(:new)
-
- diff_note
- end
-
it "doesn't update the position" do
diff_note
@@ -195,12 +172,6 @@ describe DiffNote, models: true do
let(:diff_note) { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
context "when the note is active" do
- it "doesn't use the DiffPositionUpdateService" do
- expect(Notes::DiffPositionUpdateService).not_to receive(:new)
-
- diff_note
- end
-
it "doesn't update the position" do
diff_note
@@ -211,21 +182,14 @@ describe DiffNote, models: true do
context "when the note is outdated" do
before do
- allow(merge_request).to receive(:diff_sha_refs).and_return(commit.diff_refs)
+ allow(merge_request).to receive(:diff_refs).and_return(commit.diff_refs)
end
- it "uses the DiffPositionUpdateService" do
- service = instance_double("Notes::DiffPositionUpdateService")
- expect(Notes::DiffPositionUpdateService).to receive(:new).with(
- project,
- nil,
- old_diff_refs: position.diff_refs,
- new_diff_refs: commit.diff_refs,
- paths: [path]
- ).and_return(service)
- expect(service).to receive(:execute)
-
+ it "updates the position" do
diff_note
+
+ expect(diff_note.original_position).to eq(position)
+ expect(diff_note.position).not_to eq(position)
end
end
end
@@ -256,4 +220,39 @@ describe DiffNote, models: true do
end
end
end
+
+ describe '#created_at_diff?' do
+ let(:diff_refs) { project.commit(sample_commit.id).diff_refs }
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 14,
+ diff_refs: diff_refs
+ )
+ end
+
+ context "when noteable is a commit" do
+ subject { build(:diff_note_on_commit, project: project, position: position) }
+
+ it "returns true" do
+ expect(subject.created_at_diff?(diff_refs)).to be true
+ end
+ end
+
+ context "when noteable is a merge request" do
+ context "when the diff refs match the original one of the diff note" do
+ it "returns true" do
+ expect(subject.created_at_diff?(diff_refs)).to be true
+ end
+ end
+
+ context "when the diff refs don't match the original one of the diff note" do
+ it "returns false" do
+ expect(subject.created_at_diff?(merge_request.diff_refs)).to be false
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/diff_viewer/base_spec.rb b/spec/models/diff_viewer/base_spec.rb
new file mode 100644
index 00000000000..b26de3f3b97
--- /dev/null
+++ b/spec/models/diff_viewer/base_spec.rb
@@ -0,0 +1,150 @@
+require 'spec_helper'
+
+describe DiffViewer::Base do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ let(:viewer_class) do
+ Class.new(described_class) do
+ include DiffViewer::ServerSide
+
+ self.extensions = %w(jpg)
+ self.binary = true
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 5.megabytes
+ end
+ end
+
+ let(:viewer) { viewer_class.new(diff_file) }
+
+ describe '.can_render?' do
+ context 'when the extension is supported' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ context 'when the binaryness matches' do
+ it 'returns true' do
+ expect(viewer_class.can_render?(diff_file)).to be_truthy
+ end
+ end
+
+ context 'when the binaryness does not match' do
+ before do
+ allow(diff_file.old_blob).to receive(:binary?).and_return(false)
+ allow(diff_file.new_blob).to receive(:binary?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(diff_file)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the file type is supported' do
+ let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('LICENSE') }
+
+ before do
+ viewer_class.file_types = %i(license)
+ viewer_class.binary = false
+ end
+
+ context 'when the binaryness matches' do
+ it 'returns true' do
+ expect(viewer_class.can_render?(diff_file)).to be_truthy
+ end
+ end
+
+ context 'when the binaryness does not match' do
+ before do
+ allow(diff_file.old_blob).to receive(:binary?).and_return(true)
+ allow(diff_file.new_blob).to receive(:binary?).and_return(true)
+ end
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(diff_file)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the extension and file type are not supported' do
+ it 'returns false' do
+ expect(viewer_class.can_render?(diff_file)).to be_falsey
+ end
+ end
+
+ context 'when the file was renamed and only the old blob is supported' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ before do
+ allow(diff_file).to receive(:renamed_file?).and_return(true)
+ allow(diff_file.new_blob).to receive(:extension).and_return('jpeg')
+ end
+
+ it 'returns false' do
+ expect(viewer_class.can_render?(diff_file)).to be_falsey
+ end
+ end
+ end
+
+ describe '#collapsed?' do
+ context 'when the combined blob size is larger than the collapse limit' do
+ before do
+ allow(diff_file.old_blob).to receive(:raw_size).and_return(512.kilobytes)
+ allow(diff_file.new_blob).to receive(:raw_size).and_return(513.kilobytes)
+ end
+
+ it 'returns true' do
+ expect(viewer.collapsed?).to be_truthy
+ end
+ end
+
+ context 'when the combined blob size is smaller than the collapse limit' do
+ it 'returns false' do
+ expect(viewer.collapsed?).to be_falsey
+ end
+ end
+ end
+
+ describe '#too_large?' do
+ context 'when the combined blob size is larger than the size limit' do
+ before do
+ allow(diff_file.old_blob).to receive(:raw_size).and_return(2.megabytes)
+ allow(diff_file.new_blob).to receive(:raw_size).and_return(4.megabytes)
+ end
+
+ it 'returns true' do
+ expect(viewer.too_large?).to be_truthy
+ end
+ end
+
+ context 'when the blob size is smaller than the size limit' do
+ it 'returns false' do
+ expect(viewer.too_large?).to be_falsey
+ end
+ end
+ end
+
+ describe '#render_error' do
+ context 'when the combined blob size is larger than the size limit' do
+ before do
+ allow(diff_file.old_blob).to receive(:raw_size).and_return(2.megabytes)
+ allow(diff_file.new_blob).to receive(:raw_size).and_return(4.megabytes)
+ end
+
+ it 'returns :too_large' do
+ expect(viewer.render_error).to eq(:too_large)
+ end
+ end
+
+ context 'when the combined blob size is smaller than the size limit' do
+ it 'returns nil' do
+ expect(viewer.render_error).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/models/diff_viewer/server_side_spec.rb b/spec/models/diff_viewer/server_side_spec.rb
new file mode 100644
index 00000000000..92e613f92de
--- /dev/null
+++ b/spec/models/diff_viewer/server_side_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe DiffViewer::ServerSide do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ let(:viewer_class) do
+ Class.new(DiffViewer::Base) do
+ include DiffViewer::ServerSide
+ end
+ end
+
+ subject { viewer_class.new(diff_file) }
+
+ describe '#prepare!' do
+ it 'loads all diff file data' do
+ expect(diff_file.old_blob).to receive(:load_all_data!)
+ expect(diff_file.new_blob).to receive(:load_all_data!)
+
+ subject.prepare!
+ end
+ end
+
+ describe '#render_error' do
+ context 'when the diff file is stored externally' do
+ before do
+ allow(diff_file).to receive(:stored_externally?).and_return(true)
+ end
+
+ it 'return :server_side_but_stored_externally' do
+ expect(subject.render_error).to eq(:server_side_but_stored_externally)
+ end
+ end
+ end
+end
diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb
index 0221e23ced8..a46f7ed6507 100644
--- a/spec/models/discussion_spec.rb
+++ b/spec/models/discussion_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Discussion, model: true do
+describe Discussion do
subject { described_class.new([first_note, second_note, third_note]) }
let(:first_note) { create(:diff_note_on_merge_request) }
diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb
index fe4de1b2afb..1d6fabe48b1 100644
--- a/spec/models/email_spec.rb
+++ b/spec/models/email_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Email, models: true do
+describe Email do
describe 'validations' do
it_behaves_like 'an object with email-formated attributes', :email do
subject { build(:email) }
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 070716e859a..ea8512a5eae 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Environment, models: true do
- let(:project) { create(:empty_project) }
+describe Environment do
+ set(:project) { create(:project) }
subject(:environment) { create(:environment, project: project) }
it { is_expected.to belong_to(:project) }
@@ -21,7 +21,7 @@ describe Environment, models: true do
it { is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) }
describe '.order_by_last_deployed_at' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let!(:environment1) { create(:environment, project: project) }
let!(:environment2) { create(:environment, project: project) }
let!(:environment3) { create(:environment, project: project) }
@@ -34,6 +34,26 @@ describe Environment, models: true do
end
end
+ describe 'state machine' do
+ it 'invalidates the cache after a change' do
+ expect(environment).to receive(:expire_etag_cache)
+
+ environment.stop
+ end
+ end
+
+ describe '#expire_etag_cache' do
+ let(:store) { Gitlab::EtagCaching::Store.new }
+
+ it 'changes the cached value' do
+ old_value = store.get(environment.etag_cache_key)
+
+ environment.stop
+
+ expect(store.get(environment.etag_cache_key)).not_to eq(old_value)
+ end
+ end
+
describe '#nullify_external_url' do
it 'replaces a blank url with nil' do
env = build(:environment, external_url: "")
@@ -100,28 +120,17 @@ describe Environment, models: true do
let(:head_commit) { project.commit }
let(:commit) { project.commit.parent }
- context 'Gitaly find_ref_name feature disabled' do
- it 'returns deployment id for the environment' do
- expect(environment.first_deployment_for(commit)).to eq deployment1
- end
+ it 'returns deployment id for the environment' do
+ expect(environment.first_deployment_for(commit)).to eq deployment1
+ end
- it 'return nil when no deployment is found' do
- expect(environment.first_deployment_for(head_commit)).to eq nil
- end
+ it 'return nil when no deployment is found' do
+ expect(environment.first_deployment_for(head_commit)).to eq nil
end
- # TODO: Uncomment when feature is reenabled
- # context 'Gitaly find_ref_name feature enabled' do
- # before do
- # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:find_ref_name).and_return(true)
- # end
- #
- # it 'calls GitalyClient' do
- # expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:find_ref_name)
- #
- # environment.first_deployment_for(commit)
- # end
- # end
+ it 'returns a UTF-8 ref' do
+ expect(environment.first_deployment_for(commit).ref).to be_utf8
+ end
end
describe '#environment_type' do
@@ -150,7 +159,7 @@ describe Environment, models: true do
context 'when matching action is defined' do
let(:build) { create(:ci_build) }
let!(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
- let!(:close_action) { create(:ci_build, pipeline: build.pipeline, name: 'close_app', when: :manual) }
+ let!(:close_action) { create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') }
context 'when environment is available' do
before do
@@ -206,25 +215,55 @@ describe Environment, models: true do
end
context 'when matching action is defined' do
- let(:build) { create(:ci_build) }
- let!(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
- context 'when action did not yet finish' do
- let!(:close_action) { create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') }
+ let!(:deployment) do
+ create(:deployment, environment: environment,
+ deployable: build,
+ on_stop: 'close_app')
+ end
+
+ context 'when user is not allowed to stop environment' do
+ let!(:close_action) do
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app')
+ end
- it 'returns the same action' do
- expect(subject).to eq(close_action)
- expect(subject.user).to eq(user)
+ it 'raises an exception' do
+ expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
- context 'if action did finish' do
- let!(:close_action) { create(:ci_build, :manual, :success, pipeline: build.pipeline, name: 'close_app') }
+ context 'when user is allowed to stop environment' do
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: 'master', project: project)
+ end
+
+ context 'when action did not yet finish' do
+ let!(:close_action) do
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app')
+ end
- it 'returns a new action of the same type' do
- is_expected.to be_persisted
- expect(subject.name).to eq(close_action.name)
- expect(subject.user).to eq(user)
+ it 'returns the same action' do
+ expect(subject).to eq(close_action)
+ expect(subject.user).to eq(user)
+ end
+ end
+
+ context 'if action did finish' do
+ let!(:close_action) do
+ create(:ci_build, :manual, :success,
+ pipeline: pipeline, name: 'close_app')
+ end
+
+ it 'returns a new action of the same type' do
+ expect(subject).to be_persisted
+ expect(subject.name).to eq(close_action.name)
+ expect(subject.user).to eq(user)
+ end
end
end
end
@@ -366,7 +405,7 @@ describe Environment, models: true do
it 'returns the metrics from the deployment service' do
expect(project.monitoring_service)
- .to receive(:metrics).with(environment)
+ .to receive(:environment_metrics).with(environment)
.and_return(:fake_metrics)
is_expected.to eq(:fake_metrics)
@@ -382,6 +421,99 @@ describe Environment, models: true do
end
end
+ describe '#has_metrics?' do
+ subject { environment.has_metrics? }
+
+ context 'when the enviroment is available' do
+ context 'with a deployment service' do
+ let(:project) { create(:prometheus_project) }
+
+ context 'and a deployment' do
+ let!(:deployment) { create(:deployment, environment: environment) }
+ it { is_expected.to be_truthy }
+ end
+
+ context 'but no deployments' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'without a monitoring service' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'when the environment is unavailable' do
+ let(:project) { create(:prometheus_project) }
+
+ before do
+ environment.stop
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ describe '#additional_metrics' do
+ let(:project) { create(:prometheus_project) }
+ subject { environment.additional_metrics }
+
+ context 'when the environment has additional metrics' do
+ before do
+ allow(environment).to receive(:has_additional_metrics?).and_return(true)
+ end
+
+ it 'returns the additional metrics from the deployment service' do
+ expect(project.prometheus_service).to receive(:additional_environment_metrics)
+ .with(environment)
+ .and_return(:fake_metrics)
+
+ is_expected.to eq(:fake_metrics)
+ end
+ end
+
+ context 'when the environment does not have metrics' do
+ before do
+ allow(environment).to receive(:has_additional_metrics?).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#has_additional_metrics??' do
+ subject { environment.has_additional_metrics? }
+
+ context 'when the enviroment is available' do
+ context 'with a deployment service' do
+ let(:project) { create(:prometheus_project) }
+
+ context 'and a deployment' do
+ let!(:deployment) { create(:deployment, environment: environment) }
+ it { is_expected.to be_truthy }
+ end
+
+ context 'but no deployments' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'without a monitoring service' do
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ context 'when the environment is unavailable' do
+ let(:project) { create(:prometheus_project) }
+
+ before do
+ environment.stop
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
+
describe '#slug' do
it "is automatically generated" do
expect(environment.slug).not_to be_nil
@@ -411,7 +543,7 @@ describe Environment, models: true do
"foo**bar" => "foo-bar" + SUFFIX,
"*-foo" => "env-foo" + SUFFIX,
"staging-12345678-" => "staging-12345678" + SUFFIX,
- "staging-12345678-01234567" => "staging-12345678" + SUFFIX,
+ "staging-12345678-01234567" => "staging-12345678" + SUFFIX
}.each do |name, matcher|
it "returns a slug matching #{matcher}, given #{name}" do
slug = described_class.new(name: name).generate_slug
diff --git a/spec/models/event_collection_spec.rb b/spec/models/event_collection_spec.rb
new file mode 100644
index 00000000000..e0a87c18cc7
--- /dev/null
+++ b/spec/models/event_collection_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe EventCollection do
+ describe '#to_a' do
+ let(:project) { create(:project_empty_repo) }
+ let(:projects) { Project.where(id: project.id) }
+ let(:user) { create(:user) }
+
+ before do
+ 20.times do
+ event = create(:push_event, project: project, author: user)
+
+ create(:push_event_payload, event: event)
+ end
+
+ create(:closed_issue_event, project: project, author: user)
+ end
+
+ it 'returns an Array of events' do
+ events = described_class.new(projects).to_a
+
+ expect(events).to be_an_instance_of(Array)
+ end
+
+ it 'applies a limit to the number of events' do
+ events = described_class.new(projects).to_a
+
+ expect(events.length).to eq(20)
+ end
+
+ it 'can paginate through events' do
+ events = described_class.new(projects, offset: 20).to_a
+
+ expect(events.length).to eq(1)
+ end
+
+ it 'returns an empty Array when crossing the maximum page number' do
+ events = described_class.new(projects, limit: 1, offset: 15).to_a
+
+ expect(events).to be_empty
+ end
+
+ it 'allows filtering of events using an EventFilter' do
+ filter = EventFilter.new(EventFilter.issue)
+ events = described_class.new(projects, filter: filter).to_a
+
+ expect(events.length).to eq(1)
+ expect(events[0].action).to eq(Event::CLOSED)
+ end
+ end
+end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 8c90a538f57..ff3224dd298 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Event, models: true do
+describe Event do
describe "Associations" do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:target) }
@@ -15,21 +15,47 @@ describe Event, models: true do
end
describe 'Callbacks' do
- describe 'after_create :reset_project_activity' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
+ describe 'after_create :reset_project_activity' do
it 'calls the reset_project_activity method' do
expect_any_instance_of(described_class).to receive(:reset_project_activity)
- create_event(project, project.owner)
+ create_push_event(project, project.owner)
+ end
+ end
+
+ describe 'after_create :set_last_repository_updated_at' do
+ context 'with a push event' do
+ it 'updates the project last_repository_updated_at' do
+ project.update(last_repository_updated_at: 1.year.ago)
+
+ create_push_event(project, project.owner)
+
+ project.reload
+
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
+ end
+ end
+
+ context 'without a push event' do
+ it 'does not update the project last_repository_updated_at' do
+ project.update(last_repository_updated_at: 1.year.ago)
+
+ create(:closed_issue_event, project: project, author: project.owner)
+
+ project.reload
+
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(1.year.ago)
+ end
end
end
end
describe "Push event" do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
let(:user) { project.owner }
- let(:event) { create_event(project, user) }
+ let(:event) { create_push_event(project, user) }
it do
expect(event.push?).to be_truthy
@@ -85,15 +111,15 @@ describe Event, models: true do
end
describe '#visible_to_user?' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:author) { create(:author) }
let(:assignee) { create(:user) }
let(:admin) { create(:admin) }
- let(:issue) { create(:issue, project: project, author: author, assignee: assignee) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) }
+ let(:issue) { create(:issue, project: project, author: author, assignees: [assignee]) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) }
let(:note_on_commit) { create(:note_on_commit, project: project) }
let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) }
let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) }
@@ -117,7 +143,7 @@ describe Event, models: true do
end
context 'private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it do
aggregate_failures do
@@ -187,7 +213,7 @@ describe Event, models: true do
end
context 'merge request diff note event' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, source_project: project, author: author, assignee: assignee) }
let(:note_on_merge_request) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project) }
let(:target) { note_on_merge_request }
@@ -202,7 +228,7 @@ describe Event, models: true do
end
context 'private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it do
expect(event.visible_to_user?(non_member)).to eq false
@@ -234,16 +260,16 @@ describe Event, models: true do
end
describe '#reset_project_activity' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when a project was updated less than 1 hour ago' do
it 'does not update the project' do
project.update(last_activity_at: Time.now)
- expect(project).not_to receive(:update_column).
- with(:last_activity_at, a_kind_of(Time))
+ expect(project).not_to receive(:update_column)
+ .with(:last_activity_at, a_kind_of(Time))
- create_event(project, project.owner)
+ create_push_event(project, project.owner)
end
end
@@ -251,11 +277,11 @@ describe Event, models: true do
it 'updates the project' do
project.update(last_activity_at: 1.year.ago)
- create_event(project, project.owner)
+ create_push_event(project, project.owner)
project.reload
- project.last_activity_at <= 1.minute.ago
+ expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
end
end
end
@@ -278,27 +304,15 @@ describe Event, models: true do
end
end
- def create_event(project, user, attrs = {})
- data = {
- before: Gitlab::Git::BLANK_SHA,
- after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e",
- ref: "refs/heads/master",
- user_id: user.id,
- user_name: user.name,
- repository: {
- name: project.name,
- url: "localhost/rubinius",
- description: "",
- homepage: "localhost/rubinius",
- private: true
- }
- }
-
- described_class.create({
- project: project,
- action: described_class::PUSHED,
- data: data,
- author_id: user.id
- }.merge!(attrs))
+ def create_push_event(project, user)
+ event = create(:push_event, project: project, author: user)
+
+ create(:push_event_payload,
+ event: event,
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 0,
+ ref: 'master')
+
+ event
end
end
diff --git a/spec/models/external_issue_spec.rb b/spec/models/external_issue_spec.rb
index cd50bda8996..c8748daf46b 100644
--- a/spec/models/external_issue_spec.rb
+++ b/spec/models/external_issue_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ExternalIssue, models: true do
+describe ExternalIssue do
let(:project) { double('project', id: 1, to_reference: 'namespace1/project1') }
let(:issue) { described_class.new('EXT-1234', project) }
diff --git a/spec/models/forked_project_link_spec.rb b/spec/models/forked_project_link_spec.rb
index 454550c9710..7dbeb4d2e74 100644
--- a/spec/models/forked_project_link_spec.rb
+++ b/spec/models/forked_project_link_spec.rb
@@ -2,53 +2,75 @@ require 'spec_helper'
describe ForkedProjectLink, "add link on fork" do
let(:project_from) { create(:project, :repository) }
- let(:namespace) { create(:namespace) }
- let(:user) { create(:user, namespace: namespace) }
+ let(:project_to) { fork_project(project_from, user) }
+ let(:user) { create(:user) }
+ let(:namespace) { user.namespace }
before do
- create(:project_member, :reporter, user: user, project: project_from)
- @project_to = fork_project(project_from, user)
+ project_from.add_reporter(user)
+ end
+
+ it 'project_from knows its forks' do
+ _ = project_to
+
+ expect(project_from.forks.count).to eq(1)
end
it "project_to knows it is forked" do
- expect(@project_to.forked?).to be_truthy
+ expect(project_to.forked?).to be_truthy
end
it "project knows who it is forked from" do
- expect(@project_to.forked_from_project).to eq(project_from)
+ expect(project_to.forked_from_project).to eq(project_from)
end
-end
-describe '#forked?' do
- let(:forked_project_link) { build(:forked_project_link) }
- let(:project_from) { create(:project, :repository) }
- let(:project_to) { create(:project, forked_project_link: forked_project_link) }
+ context 'project_to is pending_delete' do
+ before do
+ project_to.update!(pending_delete: true)
+ end
- before :each do
- forked_project_link.forked_from_project = project_from
- forked_project_link.forked_to_project = project_to
- forked_project_link.save!
+ it { expect(project_from.forks.count).to eq(0) }
end
- it "project_to knows it is forked" do
- expect(project_to.forked?).to be_truthy
- end
+ context 'project_from is pending_delete' do
+ before do
+ project_from.update!(pending_delete: true)
+ end
- it "project_from is not forked" do
- expect(project_from.forked?).to be_falsey
+ it { expect(project_to.forked_from_project).to be_nil }
end
- it "project_to.destroy destroys fork_link" do
- expect(forked_project_link).to receive(:destroy)
- project_to.destroy
+ describe '#forked?' do
+ let(:project_to) { create(:project, :repository, forked_project_link: forked_project_link) }
+ let(:forked_project_link) { create(:forked_project_link) }
+
+ before do
+ forked_project_link.forked_from_project = project_from
+ forked_project_link.forked_to_project = project_to
+ forked_project_link.save!
+ end
+
+ it "project_to knows it is forked" do
+ expect(project_to.forked?).to be_truthy
+ end
+
+ it "project_from is not forked" do
+ expect(project_from.forked?).to be_falsey
+ end
+
+ it "project_to.destroy destroys fork_link" do
+ project_to.destroy
+
+ expect(ForkedProjectLink.exists?(id: forked_project_link.id)).to eq(false)
+ end
end
-end
-def fork_project(from_project, user)
- shell = double('gitlab_shell', fork_repository: true)
+ def fork_project(from_project, user)
+ service = Projects::ForkService.new(from_project, user)
+ shell = double('gitlab_shell', fork_repository: true)
- service = Projects::ForkService.new(from_project, user)
- allow(service).to receive(:gitlab_shell).and_return(shell)
+ allow(service).to receive(:gitlab_shell).and_return(shell)
- service.execute
+ service.execute
+ end
end
diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb
index f4c3e6d503f..7f1909710d8 100644
--- a/spec/models/generic_commit_status_spec.rb
+++ b/spec/models/generic_commit_status_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe GenericCommitStatus, models: true do
- let(:project) { create(:empty_project) }
+describe GenericCommitStatus do
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:external_url) { 'http://example.gitlab.com/status' }
@@ -19,7 +19,10 @@ describe GenericCommitStatus, models: true do
describe '#context' do
subject { generic_commit_status.context }
- before { generic_commit_status.context = 'my_context' }
+
+ before do
+ generic_commit_status.context = 'my_context'
+ end
it { is_expected.to eq(generic_commit_status.name) }
end
@@ -39,7 +42,9 @@ describe GenericCommitStatus, models: true do
end
context 'when user has ability to see datails' do
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
it 'details path points to an external URL' do
expect(status).to have_details
diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb
index 55b87d1c48a..ab58f5c5021 100644
--- a/spec/models/global_milestone_spec.rb
+++ b/spec/models/global_milestone_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe GlobalMilestone, models: true do
+describe GlobalMilestone do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:group) { create(:group) }
- let(:project1) { create(:empty_project, group: group) }
- let(:project2) { create(:empty_project, path: 'gitlab-ci', group: group) }
- let(:project3) { create(:empty_project, path: 'cookbook-gitlab', group: group) }
+ let(:project1) { create(:project, group: group) }
+ let(:project2) { create(:project, path: 'gitlab-ci', group: group) }
+ let(:project3) { create(:project, path: 'cookbook-gitlab', group: group) }
describe '.build_collection' do
let(:milestone1_due_date) { 2.weeks.from_now.to_date }
@@ -72,7 +72,7 @@ describe GlobalMilestone, models: true do
project3
]
- @global_milestones = GlobalMilestone.build_collection(projects, {})
+ @global_milestones = described_class.build_collection(projects, {})
end
it 'has all project milestones' do
@@ -106,7 +106,7 @@ describe GlobalMilestone, models: true do
it 'returns the quantity of global milestones in each possible state' do
expected_count = { opened: 1, closed: 2, all: 2 }
- count = GlobalMilestone.states_count(Project.all)
+ count = described_class.states_count(Project.all)
expect(count).to eq(expected_count)
end
@@ -120,7 +120,7 @@ describe GlobalMilestone, models: true do
it 'returns 0 as the quantity of global milestones in each state' do
expected_count = { opened: 0, closed: 0, all: 0 }
- count = GlobalMilestone.states_count(Project.all)
+ count = described_class.states_count(Project.all)
expect(count).to eq(expected_count)
end
@@ -137,11 +137,11 @@ describe GlobalMilestone, models: true do
[
milestone1_project1,
milestone1_project2,
- milestone1_project3,
+ milestone1_project3
]
milestones_relation = Milestone.where(id: milestones.map(&:id))
- @global_milestone = GlobalMilestone.new(milestone1_project1.title, milestones_relation)
+ @global_milestone = described_class.new(milestone1_project1.title, milestones_relation)
end
it 'has exactly one group milestone' do
@@ -157,7 +157,7 @@ describe GlobalMilestone, models: true do
let(:milestone) { create(:milestone, title: "git / test", project: project1) }
it 'strips out slashes and spaces' do
- global_milestone = GlobalMilestone.new(milestone.title, Milestone.where(id: milestone.id))
+ global_milestone = described_class.new(milestone.title, Milestone.where(id: milestone.id))
expect(global_milestone.safe_title).to eq('git-test')
end
@@ -171,7 +171,7 @@ describe GlobalMilestone, models: true do
create(:active_milestone, title: title),
create(:closed_milestone, title: title)
]
- global_milestone = GlobalMilestone.new(title, milestones)
+ global_milestone = described_class.new(title, milestones)
expect(global_milestone.state).to eq('active')
end
@@ -184,7 +184,7 @@ describe GlobalMilestone, models: true do
create(:closed_milestone, title: title),
create(:closed_milestone, title: title)
]
- global_milestone = GlobalMilestone.new(title, milestones)
+ global_milestone = described_class.new(title, milestones)
expect(global_milestone.state).to eq('closed')
end
diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb
new file mode 100644
index 00000000000..e48f20bf53b
--- /dev/null
+++ b/spec/models/gpg_key_spec.rb
@@ -0,0 +1,155 @@
+require 'rails_helper'
+
+describe GpgKey do
+ describe "associations" do
+ it { is_expected.to belong_to(:user) }
+ end
+
+ describe "validation" do
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to validate_presence_of(:key) }
+ it { is_expected.to validate_uniqueness_of(:key) }
+
+ it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey\n-----END PGP PUBLIC KEY BLOCK-----").for(:key) }
+
+ it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) }
+ it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey\n-----BEGIN PGP PUBLIC KEY BLOCK-----").for(:key) }
+ it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK----------END PGP PUBLIC KEY BLOCK-----").for(:key) }
+ it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----").for(:key) }
+ it { is_expected.not_to allow_value("-----END PGP PUBLIC KEY BLOCK-----").for(:key) }
+ it { is_expected.not_to allow_value("key\n-----END PGP PUBLIC KEY BLOCK-----").for(:key) }
+ it { is_expected.not_to allow_value('BEGIN PGP').for(:key) }
+ end
+
+ context 'callbacks' do
+ describe 'extract_fingerprint' do
+ it 'extracts the fingerprint from the gpg key' do
+ gpg_key = described_class.new(key: GpgHelpers::User1.public_key)
+ gpg_key.valid?
+ expect(gpg_key.fingerprint).to eq GpgHelpers::User1.fingerprint
+ end
+ end
+
+ describe 'extract_primary_keyid' do
+ it 'extracts the primary keyid from the gpg key' do
+ gpg_key = described_class.new(key: GpgHelpers::User1.public_key)
+ gpg_key.valid?
+ expect(gpg_key.primary_keyid).to eq GpgHelpers::User1.primary_keyid
+ end
+ end
+ end
+
+ describe '#key=' do
+ it 'strips white spaces' do
+ key = <<~KEY.strip
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+ Version: GnuPG v1
+
+ mQENBFMOSOgBCADFCYxmnXFbrDhfvlf03Q/bQuT+nZu46BFGbo7XkUjDowFXJQhP
+ -----END PGP PUBLIC KEY BLOCK-----
+ KEY
+
+ expect(described_class.new(key: " #{key} ").key).to eq(key)
+ end
+
+ it 'does not strip when the key is nil' do
+ expect(described_class.new(key: nil).key).to be_nil
+ end
+ end
+
+ describe '#user_infos' do
+ it 'returns the user infos from the gpg key' do
+ gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key
+ expect(Gitlab::Gpg).to receive(:user_infos_from_key).with(gpg_key.key)
+
+ gpg_key.user_infos
+ end
+ end
+
+ describe '#verified_user_infos' do
+ it 'returns the user infos if it is verified' do
+ user = create :user, email: GpgHelpers::User1.emails.first
+ gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+
+ expect(gpg_key.verified_user_infos).to eq([{
+ name: GpgHelpers::User1.names.first,
+ email: GpgHelpers::User1.emails.first
+ }])
+ end
+
+ it 'returns an empty array if the user info is not verified' do
+ user = create :user, email: 'unrelated@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+
+ expect(gpg_key.verified_user_infos).to eq([])
+ end
+ end
+
+ describe '#emails_with_verified_status' do
+ it 'email is verified if the user has the matching email' do
+ user = create :user, email: 'bette.cartwright@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.emails_with_verified_status).to eq(
+ 'bette.cartwright@example.com' => true,
+ 'bette.cartwright@example.net' => false
+ )
+ end
+ end
+
+ describe '#verified?' do
+ it 'returns true one of the email addresses in the key belongs to the user' do
+ user = create :user, email: 'bette.cartwright@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.verified?).to be_truthy
+ end
+
+ it 'returns false if one of the email addresses in the key does not belong to the user' do
+ user = create :user, email: 'someone.else@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.verified?).to be_falsey
+ end
+ end
+
+ describe 'notification', :mailer do
+ let(:user) { create(:user) }
+
+ it 'sends a notification' do
+ perform_enqueued_jobs do
+ create(:gpg_key, user: user)
+ end
+
+ should_email(user)
+ end
+ end
+
+ describe '#revoke' do
+ it 'invalidates all associated gpg signatures and destroys the key' do
+ gpg_key = create :gpg_key
+ gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: gpg_key
+
+ unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key
+ unrelated_gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: unrelated_gpg_key
+
+ gpg_key.revoke
+
+ expect(gpg_signature.reload).to have_attributes(
+ valid_signature: false,
+ gpg_key: nil
+ )
+
+ expect(gpg_key.destroyed?).to be true
+
+ # unrelated signature is left untouched
+ expect(unrelated_gpg_signature.reload).to have_attributes(
+ valid_signature: true,
+ gpg_key: unrelated_gpg_key
+ )
+
+ expect(unrelated_gpg_key.destroyed?).to be false
+ end
+ end
+end
diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb
new file mode 100644
index 00000000000..c58fd46762a
--- /dev/null
+++ b/spec/models/gpg_signature_spec.rb
@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe GpgSignature do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:gpg_key) }
+ end
+
+ describe 'validation' do
+ subject { described_class.new }
+ it { is_expected.to validate_presence_of(:commit_sha) }
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) }
+ end
+
+ describe '#commit' do
+ it 'fetches the commit through the project' do
+ commit_sha = '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'
+ project = create :project, :repository
+ commit = create :commit, project: project
+ gpg_signature = create :gpg_signature, commit_sha: commit_sha
+
+ expect_any_instance_of(Project).to receive(:commit).with(commit_sha).and_return(commit)
+
+ gpg_signature.commit
+ end
+ end
+end
diff --git a/spec/models/group_label_spec.rb b/spec/models/group_label_spec.rb
index 555a876daeb..d0fc1eaa3ec 100644
--- a/spec/models/group_label_spec.rb
+++ b/spec/models/group_label_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GroupLabel, models: true do
+describe GroupLabel do
describe 'relationships' do
it { is_expected.to belong_to(:group) }
end
@@ -39,8 +39,8 @@ describe GroupLabel, models: true do
context 'cross-project' do
let(:namespace) { build_stubbed(:namespace) }
- let(:source_project) { build_stubbed(:empty_project, name: 'project-1', namespace: namespace) }
- let(:target_project) { build_stubbed(:empty_project, name: 'project-2', namespace: namespace) }
+ let(:source_project) { build_stubbed(:project, name: 'project-1', namespace: namespace) }
+ let(:target_project) { build_stubbed(:project, name: 'project-2', namespace: namespace) }
it 'returns a String reference to the object' do
expect(label.to_reference(source_project, target_project: target_project)).to eq %(project-1~#{label.id})
diff --git a/spec/models/group_milestone_spec.rb b/spec/models/group_milestone_spec.rb
index 916afb7aaf5..b60676afc91 100644
--- a/spec/models/group_milestone_spec.rb
+++ b/spec/models/group_milestone_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe GroupMilestone, models: true do
+describe GroupMilestone do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
let(:project_milestone) do
create(:milestone, title: "Milestone v1.2", project: project)
end
describe '.build' do
it 'returns milestone with group assigned' do
- milestone = GroupMilestone.build(
+ milestone = described_class.build(
group,
[project],
project_milestone.title
@@ -25,7 +25,7 @@ describe GroupMilestone, models: true do
end
it 'returns array of milestones, each with group assigned' do
- milestones = GroupMilestone.build_collection(group, [project], {})
+ milestones = described_class.build_collection(group, [project], {})
expect(milestones).to all(have_attributes(group: group))
end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 8ffde6f7fbb..c5bfae47606 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Group, models: true do
+describe Group do
let!(:group) { create(:group, :access_requestable) }
describe 'associations' do
@@ -13,6 +13,7 @@ describe Group, models: true do
it { is_expected.to have_many(:shared_projects).through(:project_group_links) }
it { is_expected.to have_many(:notification_settings).dependent(:destroy) }
it { is_expected.to have_many(:labels).class_name('GroupLabel') }
+ it { is_expected.to have_many(:variables).class_name('Ci::GroupVariable') }
it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_one(:chat_team) }
@@ -57,6 +58,32 @@ describe Group, models: true do
it { is_expected.not_to validate_presence_of :owner }
it { is_expected.to validate_presence_of :two_factor_grace_period }
it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) }
+
+ describe 'path validation' do
+ it 'rejects paths reserved on the root namespace when the group has no parent' do
+ group = build(:group, path: 'api')
+
+ expect(group).not_to be_valid
+ end
+
+ it 'allows root paths when the group has a parent' do
+ group = build(:group, path: 'api', parent: create(:group))
+
+ expect(group).to be_valid
+ end
+
+ it 'rejects any wildcard paths when not a top level group' do
+ group = build(:group, path: 'tree', parent: create(:group))
+
+ expect(group).not_to be_valid
+ end
+
+ it 'rejects reserved group paths' do
+ group = build(:group, path: 'activity', parent: create(:group))
+
+ expect(group).not_to be_valid
+ end
+ end
end
describe '.visible_to_user' do
@@ -117,14 +144,20 @@ describe Group, models: true do
describe '#add_user' do
let(:user) { create(:user) }
- before { group.add_user(user, GroupMember::MASTER) }
+
+ before do
+ group.add_user(user, GroupMember::MASTER)
+ end
it { expect(group.group_members.masters.map(&:user)).to include(user) }
end
describe '#add_users' do
let(:user) { create(:user) }
- before { group.add_users([user.id], GroupMember::GUEST) }
+
+ before do
+ group.add_users([user.id], GroupMember::GUEST)
+ end
it "updates the group permission" do
expect(group.group_members.guests.map(&:user)).to include(user)
@@ -136,7 +169,10 @@ describe Group, models: true do
describe '#avatar_type' do
let(:user) { create(:user) }
- before { group.add_user(user, GroupMember::MASTER) }
+
+ before do
+ group.add_user(user, GroupMember::MASTER)
+ end
it "is true if avatar is image" do
group.update_attribute(:avatar, 'uploads/avatar.png')
@@ -149,6 +185,28 @@ describe Group, models: true do
end
end
+ describe '#avatar_url' do
+ let!(:group) { create(:group, :access_requestable, :with_avatar) }
+ let(:user) { create(:user) }
+ let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
+ let(:avatar_path) { "/uploads/-/system/group/avatar/#{group.id}/dk.png" }
+
+ context 'when avatar file is uploaded' do
+ before do
+ group.add_master(user)
+ end
+
+ it 'shows correct avatar url' do
+ expect(group.avatar_url).to eq(avatar_path)
+ expect(group.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join)
+
+ allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
+
+ expect(group.avatar_url).to eq([gitlab_host, avatar_path].join)
+ end
+ end
+ end
+
describe '.search' do
it 'returns groups with a matching name' do
expect(described_class.search(group.name)).to eq([group])
@@ -176,7 +234,10 @@ describe Group, models: true do
end
describe '#has_owner?' do
- before { @members = setup_group_members(group) }
+ before do
+ @members = setup_group_members(group)
+ create(:group_member, :invited, :owner, group: group)
+ end
it { expect(group.has_owner?(@members[:owner])).to be_truthy }
it { expect(group.has_owner?(@members[:master])).to be_falsey }
@@ -184,10 +245,14 @@ describe Group, models: true do
it { expect(group.has_owner?(@members[:reporter])).to be_falsey }
it { expect(group.has_owner?(@members[:guest])).to be_falsey }
it { expect(group.has_owner?(@members[:requester])).to be_falsey }
+ it { expect(group.has_owner?(nil)).to be_falsey }
end
describe '#has_master?' do
- before { @members = setup_group_members(group) }
+ before do
+ @members = setup_group_members(group)
+ create(:group_member, :invited, :master, group: group)
+ end
it { expect(group.has_master?(@members[:owner])).to be_falsey }
it { expect(group.has_master?(@members[:master])).to be_truthy }
@@ -195,6 +260,7 @@ describe Group, models: true do
it { expect(group.has_master?(@members[:reporter])).to be_falsey }
it { expect(group.has_master?(@members[:guest])).to be_falsey }
it { expect(group.has_master?(@members[:requester])).to be_falsey }
+ it { expect(group.has_master?(nil)).to be_falsey }
end
describe '#lfs_enabled?' do
@@ -291,10 +357,10 @@ describe Group, models: true do
subject { build(:group, :nested) }
it { is_expected.to be_valid }
- it { expect(subject.parent).to be_kind_of(Group) }
+ it { expect(subject.parent).to be_kind_of(described_class) }
end
- describe '#members_with_parents' do
+ describe '#members_with_parents', :nested_groups do
let!(:group) { create(:group, :nested) }
let!(:master) { group.parent.add_user(create(:user), GroupMember::MASTER) }
let!(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
@@ -313,8 +379,8 @@ describe Group, models: true do
group.add_user(master, GroupMember::MASTER)
group.add_user(developer, GroupMember::DEVELOPER)
- expect(group.user_ids_for_project_authorizations).
- to include(master.id, developer.id)
+ expect(group.user_ids_for_project_authorizations)
+ .to include(master.id, developer.id)
end
end
@@ -357,4 +423,69 @@ describe Group, models: true do
expect(calls).to eq 2
end
end
+
+ describe '#secret_variables_for' do
+ let(:project) { create(:project, group: group) }
+
+ let!(:secret_variable) do
+ create(:ci_group_variable, value: 'secret', group: group)
+ end
+
+ let!(:protected_variable) do
+ create(:ci_group_variable, :protected, value: 'protected', group: group)
+ end
+
+ subject { group.secret_variables_for('ref', project) }
+
+ shared_examples 'ref is protected' do
+ it 'contains all the variables' do
+ is_expected.to contain_exactly(secret_variable, protected_variable)
+ end
+ end
+
+ context 'when the ref is not protected' do
+ before do
+ stub_application_setting(
+ default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+ end
+
+ it 'contains only the secret variables' do
+ is_expected.to contain_exactly(secret_variable)
+ end
+ end
+
+ context 'when the ref is a protected branch' do
+ before do
+ create(:protected_branch, name: 'ref', project: project)
+ end
+
+ it_behaves_like 'ref is protected'
+ end
+
+ context 'when the ref is a protected tag' do
+ before do
+ create(:protected_tag, name: 'ref', project: project)
+ end
+
+ it_behaves_like 'ref is protected'
+ end
+
+ context 'when group has children', :postgresql do
+ let(:group_child) { create(:group, parent: group) }
+ let(:group_child_2) { create(:group, parent: group_child) }
+ let(:group_child_3) { create(:group, parent: group_child_2) }
+ let(:variable_child) { create(:ci_group_variable, group: group_child) }
+ let(:variable_child_2) { create(:ci_group_variable, group: group_child_2) }
+ let(:variable_child_3) { create(:ci_group_variable, group: group_child_3) }
+
+ it 'returns all variables belong to the group and parent groups' do
+ expected_array1 = [protected_variable, secret_variable]
+ expected_array2 = [variable_child, variable_child_2, variable_child_3]
+ got_array = group_child_3.secret_variables_for('ref', project).to_a
+
+ expect(got_array.shift(2)).to contain_exactly(*expected_array1)
+ expect(got_array).to eq(expected_array2)
+ end
+ end
+ end
end
diff --git a/spec/models/guest_spec.rb b/spec/models/guest_spec.rb
index c60bd7af958..2afdd6751a4 100644
--- a/spec/models/guest_spec.rb
+++ b/spec/models/guest_spec.rb
@@ -1,20 +1,20 @@
require 'spec_helper'
-describe Guest, lib: true do
- let(:public_project) { build_stubbed(:empty_project, :public) }
- let(:private_project) { build_stubbed(:empty_project, :private) }
- let(:internal_project) { build_stubbed(:empty_project, :internal) }
+describe Guest do
+ let(:public_project) { build_stubbed(:project, :public) }
+ let(:private_project) { build_stubbed(:project, :private) }
+ let(:internal_project) { build_stubbed(:project, :internal) }
describe '.can_pull?' do
context 'when project is private' do
it 'does not allow to pull the repo' do
- expect(Guest.can?(:download_code, private_project)).to eq(false)
+ expect(described_class.can?(:download_code, private_project)).to eq(false)
end
end
context 'when project is internal' do
it 'does not allow to pull the repo' do
- expect(Guest.can?(:download_code, internal_project)).to eq(false)
+ expect(described_class.can?(:download_code, internal_project)).to eq(false)
end
end
@@ -23,7 +23,7 @@ describe Guest, lib: true do
it 'does not allow to pull the repo' do
public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- expect(Guest.can?(:download_code, public_project)).to eq(false)
+ expect(described_class.can?(:download_code, public_project)).to eq(false)
end
end
@@ -31,13 +31,13 @@ describe Guest, lib: true do
it 'does not allow to pull the repo' do
public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::PRIVATE)
- expect(Guest.can?(:download_code, public_project)).to eq(false)
+ expect(described_class.can?(:download_code, public_project)).to eq(false)
end
end
context 'when repository is enabled' do
it 'allows to pull the repo' do
- expect(Guest.can?(:download_code, public_project)).to eq(true)
+ expect(described_class.can?(:download_code, public_project)).to eq(true)
end
end
end
diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb
index 474ae62ccec..5dd31b1b5de 100644
--- a/spec/models/hooks/project_hook_spec.rb
+++ b/spec/models/hooks/project_hook_spec.rb
@@ -1,15 +1,19 @@
require 'spec_helper'
-describe ProjectHook, models: true do
- describe "Associations" do
+describe ProjectHook do
+ describe 'associations' do
it { is_expected.to belong_to :project }
end
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+
describe '.push_hooks' do
it 'returns hooks for push events only' do
hook = create(:project_hook, push_events: true)
create(:project_hook, push_events: false)
- expect(ProjectHook.push_hooks).to eq([hook])
+ expect(described_class.push_hooks).to eq([hook])
end
end
@@ -17,7 +21,7 @@ describe ProjectHook, models: true do
it 'returns hooks for tag push events only' do
hook = create(:project_hook, tag_push_events: true)
create(:project_hook, tag_push_events: false)
- expect(ProjectHook.tag_push_hooks).to eq([hook])
+ expect(described_class.tag_push_hooks).to eq([hook])
end
end
end
diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb
index 1a83c836652..e32eaafc13f 100644
--- a/spec/models/hooks/service_hook_spec.rb
+++ b/spec/models/hooks/service_hook_spec.rb
@@ -1,36 +1,23 @@
-require "spec_helper"
+require 'spec_helper'
-describe ServiceHook, models: true do
- describe "Associations" do
+describe ServiceHook do
+ describe 'associations' do
it { is_expected.to belong_to :service }
end
- describe "execute" do
- before(:each) do
- @service_hook = create(:service_hook)
- @data = { project_id: 1, data: {} }
-
- WebMock.stub_request(:post, @service_hook.url)
- end
-
- it "POSTs to the webhook URL" do
- @service_hook.execute(@data)
- expect(WebMock).to have_requested(:post, @service_hook.url).with(
- headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' }
- ).once
- end
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:service) }
+ end
- it "POSTs the data as JSON" do
- @service_hook.execute(@data)
- expect(WebMock).to have_requested(:post, @service_hook.url).with(
- headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' }
- ).once
- end
+ describe 'execute' do
+ let(:hook) { build(:service_hook) }
+ let(:data) { { key: 'value' } }
- it "catches exceptions" do
- expect(WebHook).to receive(:post).and_raise("Some HTTP Post error")
+ it '#execute' do
+ expect(WebHookService).to receive(:new).with(hook, data, 'service_hook').and_call_original
+ expect_any_instance_of(WebHookService).to receive(:execute)
- expect { @service_hook.execute(@data) }.to raise_error(RuntimeError)
+ hook.execute(data)
end
end
end
diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb
index 8acec805584..431e3db9f00 100644
--- a/spec/models/hooks/system_hook_spec.rb
+++ b/spec/models/hooks/system_hook_spec.rb
@@ -1,10 +1,22 @@
require "spec_helper"
-describe SystemHook, models: true do
+describe SystemHook do
+ context 'default attributes' do
+ let(:system_hook) { build(:system_hook) }
+
+ it 'sets defined default parameters' do
+ attrs = {
+ push_events: false,
+ repository_update_events: true
+ }
+ expect(system_hook).to have_attributes(attrs)
+ end
+ end
+
describe "execute" do
let(:system_hook) { create(:system_hook) }
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
let(:group) { create(:group) }
let(:params) do
{ name: 'John Doe', username: 'jduser', email: 'jg@example.com', password: 'mydummypass' }
@@ -105,4 +117,34 @@ describe SystemHook, models: true do
).once
end
end
+
+ describe '.repository_update_hooks' do
+ it 'returns hooks for repository update events only' do
+ hook = create(:system_hook, repository_update_events: true)
+ create(:system_hook, repository_update_events: false)
+ expect(described_class.repository_update_hooks).to eq([hook])
+ end
+ end
+
+ describe 'execute WebHookService' do
+ let(:hook) { build(:system_hook) }
+ let(:data) { { key: 'value' } }
+ let(:hook_name) { 'system_hook' }
+
+ before do
+ expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original
+ end
+
+ it '#execute' do
+ expect_any_instance_of(WebHookService).to receive(:execute)
+
+ hook.execute(data, hook_name)
+ end
+
+ it '#async_execute' do
+ expect_any_instance_of(WebHookService).to receive(:async_execute)
+
+ hook.async_execute(data, hook_name)
+ end
+ end
end
diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb
new file mode 100644
index 00000000000..19bc88b1333
--- /dev/null
+++ b/spec/models/hooks/web_hook_log_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+describe WebHookLog do
+ it { is_expected.to belong_to(:web_hook) }
+
+ it { is_expected.to serialize(:request_headers).as(Hash) }
+ it { is_expected.to serialize(:request_data).as(Hash) }
+ it { is_expected.to serialize(:response_headers).as(Hash) }
+
+ it { is_expected.to validate_presence_of(:web_hook) }
+
+ describe '#success?' do
+ let(:web_hook_log) { build(:web_hook_log, response_status: status) }
+
+ describe '2xx' do
+ let(:status) { '200' }
+ it { expect(web_hook_log.success?).to be_truthy }
+ end
+
+ describe 'not 2xx' do
+ let(:status) { '500' }
+ it { expect(web_hook_log.success?).to be_falsey }
+ end
+
+ describe 'internal erorr' do
+ let(:status) { 'internal error' }
+ it { expect(web_hook_log.success?).to be_falsey }
+ end
+ end
+end
diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb
index 9d4db1bfb52..388120160ab 100644
--- a/spec/models/hooks/web_hook_spec.rb
+++ b/spec/models/hooks/web_hook_spec.rb
@@ -1,89 +1,54 @@
require 'spec_helper'
-describe WebHook, models: true do
- describe "Validations" do
+describe WebHook do
+ let(:hook) { build(:project_hook) }
+
+ describe 'associations' do
+ it { is_expected.to have_many(:web_hook_logs).dependent(:destroy) }
+ end
+
+ describe 'validations' do
it { is_expected.to validate_presence_of(:url) }
describe 'url' do
- it { is_expected.to allow_value("http://example.com").for(:url) }
- it { is_expected.to allow_value("https://example.com").for(:url) }
- it { is_expected.to allow_value(" https://example.com ").for(:url) }
- it { is_expected.to allow_value("http://test.com/api").for(:url) }
- it { is_expected.to allow_value("http://test.com/api?key=abc").for(:url) }
- it { is_expected.to allow_value("http://test.com/api?key=abc&type=def").for(:url) }
+ it { is_expected.to allow_value('http://example.com').for(:url) }
+ it { is_expected.to allow_value('https://example.com').for(:url) }
+ it { is_expected.to allow_value(' https://example.com ').for(:url) }
+ it { is_expected.to allow_value('http://test.com/api').for(:url) }
+ it { is_expected.to allow_value('http://test.com/api?key=abc').for(:url) }
+ it { is_expected.to allow_value('http://test.com/api?key=abc&type=def').for(:url) }
- it { is_expected.not_to allow_value("example.com").for(:url) }
- it { is_expected.not_to allow_value("ftp://example.com").for(:url) }
- it { is_expected.not_to allow_value("herp-and-derp").for(:url) }
+ it { is_expected.not_to allow_value('example.com').for(:url) }
+ it { is_expected.not_to allow_value('ftp://example.com').for(:url) }
+ it { is_expected.not_to allow_value('herp-and-derp').for(:url) }
it 'strips :url before saving it' do
- hook = create(:project_hook, url: ' https://example.com ')
+ hook.url = ' https://example.com '
+ hook.save
expect(hook.url).to eq('https://example.com')
end
end
end
- describe "execute" do
- let(:project) { create(:empty_project) }
- let(:project_hook) { create(:project_hook) }
-
- before(:each) do
- project.hooks << [project_hook]
- @data = { before: 'oldrev', after: 'newrev', ref: 'ref' }
-
- WebMock.stub_request(:post, project_hook.url)
- end
-
- context 'when token is defined' do
- let(:project_hook) { create(:project_hook, :token) }
-
- it 'POSTs to the webhook URL' do
- project_hook.execute(@data, 'push_hooks')
- expect(WebMock).to have_requested(:post, project_hook.url).with(
- headers: { 'Content-Type' => 'application/json',
- 'X-Gitlab-Event' => 'Push Hook',
- 'X-Gitlab-Token' => project_hook.token }
- ).once
- end
- end
-
- it "POSTs to the webhook URL" do
- project_hook.execute(@data, 'push_hooks')
- expect(WebMock).to have_requested(:post, project_hook.url).with(
- headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' }
- ).once
- end
-
- it "POSTs the data as JSON" do
- project_hook.execute(@data, 'push_hooks')
- expect(WebMock).to have_requested(:post, project_hook.url).with(
- headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' }
- ).once
- end
-
- it "catches exceptions" do
- expect(WebHook).to receive(:post).and_raise("Some HTTP Post error")
-
- expect { project_hook.execute(@data, 'push_hooks') }.to raise_error(RuntimeError)
- end
-
- it "handles SSL exceptions" do
- expect(WebHook).to receive(:post).and_raise(OpenSSL::SSL::SSLError.new('SSL error'))
+ describe 'execute' do
+ let(:data) { { key: 'value' } }
+ let(:hook_name) { 'project hook' }
- expect(project_hook.execute(@data, 'push_hooks')).to eq([false, 'SSL error'])
+ before do
+ expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original
end
- it "handles 200 status code" do
- WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: "Success")
+ it '#execute' do
+ expect_any_instance_of(WebHookService).to receive(:execute)
- expect(project_hook.execute(@data, 'push_hooks')).to eq([200, 'Success'])
+ hook.execute(data, hook_name)
end
- it "handles 2xx status codes" do
- WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: "Success")
+ it '#async_execute' do
+ expect_any_instance_of(WebHookService).to receive(:async_execute)
- expect(project_hook.execute(@data, 'push_hooks')).to eq([201, 'Success'])
+ hook.async_execute(data, hook_name)
end
end
end
diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb
index b3aed66a5b6..4ca6556d0f4 100644
--- a/spec/models/identity_spec.rb
+++ b/spec/models/identity_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-RSpec.describe Identity, models: true do
+RSpec.describe Identity do
describe 'relations' do
it { is_expected.to belong_to(:user) }
end
diff --git a/spec/models/issue/metrics_spec.rb b/spec/models/issue/metrics_spec.rb
index 08712f2a768..1bf0ecb98ad 100644
--- a/spec/models/issue/metrics_spec.rb
+++ b/spec/models/issue/metrics_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Issue::Metrics, models: true do
- let(:project) { create(:empty_project) }
+describe Issue::Metrics do
+ let(:project) { create(:project) }
subject { create(:issue, project: project) }
diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb
index d8aed25c041..34d98a3c975 100644
--- a/spec/models/issue_collection_spec.rb
+++ b/spec/models/issue_collection_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe IssueCollection do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let(:collection) { described_class.new([issue1, issue2]) }
@@ -28,7 +28,7 @@ describe IssueCollection do
end
it 'returns the issues the user is assigned to' do
- issue1.assignee = user
+ issue1.assignees << user
expect(collection.updatable_by_user(user)).to eq([issue1])
end
@@ -50,8 +50,8 @@ describe IssueCollection do
context 'using a user that is the owner of a project' do
it 'returns the issues of the project' do
- expect(collection.updatable_by_user(project.namespace.owner)).
- to eq([issue1, issue2])
+ expect(collection.updatable_by_user(project.namespace.owner))
+ .to eq([issue1, issue2])
end
end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 11befd4edfe..9203f6562f2 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
-describe Issue, models: true do
+describe Issue do
describe "Associations" do
it { is_expected.to belong_to(:milestone) }
+ it { is_expected.to have_many(:assignees) }
end
describe 'modules' do
@@ -23,7 +24,7 @@ describe Issue, models: true do
end
describe '#order_by_position_and_priority' do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) }
@@ -32,30 +33,44 @@ describe Issue, models: true do
let!(:issue4) { create(:issue, project: project, relative_position: 200) }
it 'returns ordered list' do
- expect(project.issues.order_by_position_and_priority).
- to match [issue3, issue4, issue1, issue2]
+ expect(project.issues.order_by_position_and_priority)
+ .to match [issue3, issue4, issue1, issue2]
end
end
- describe '#closed_at' do
- after do
- Timecop.return
+ describe '#card_attributes' do
+ it 'includes the author name' do
+ allow(subject).to receive(:author).and_return(double(name: 'Robert'))
+ allow(subject).to receive(:assignees).and_return([])
+
+ expect(subject.card_attributes)
+ .to eq({ 'Author' => 'Robert', 'Assignee' => '' })
end
- let!(:now) { Timecop.freeze(Time.now) }
+ it 'includes the assignee name' do
+ allow(subject).to receive(:author).and_return(double(name: 'Robert'))
+ allow(subject).to receive(:assignees).and_return([double(name: 'Douwe')])
+
+ expect(subject.card_attributes)
+ .to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' })
+ end
+ end
+ describe '#closed_at' do
it 'sets closed_at to Time.now when issue is closed' do
issue = create(:issue, state: 'opened')
+ expect(issue.closed_at).to be_nil
+
issue.close
- expect(issue.closed_at).to eq(now)
+ expect(issue.closed_at).to be_present
end
end
describe '#to_reference' do
let(:namespace) { build(:namespace, path: 'sample-namespace') }
- let(:project) { build(:empty_project, name: 'sample-project', namespace: namespace) }
+ let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
let(:issue) { build(:issue, iid: 1, project: project) }
let(:group) { create(:group, name: 'Group', path: 'sample-group') }
@@ -80,7 +95,7 @@ describe Issue, models: true do
end
context 'when cross namespace project argument' do
- let(:another_namespace_project) { create(:empty_project, name: 'another-project') }
+ let(:another_namespace_project) { create(:project, name: 'another-project') }
it 'returns complete path to the issue' do
expect(issue.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project#1'
@@ -88,12 +103,12 @@ describe Issue, models: true do
end
it 'supports a cross-project reference' do
- another_project = build(:empty_project, name: 'another-project', namespace: project.namespace)
+ another_project = build(:project, name: 'another-project', namespace: project.namespace)
expect(issue.to_reference(another_project)).to eq "sample-project#1"
end
context 'when same namespace / cross-project argument' do
- let(:another_project) { create(:empty_project, namespace: namespace) }
+ let(:another_project) { create(:project, namespace: namespace) }
it 'returns path to the issue with the project name' do
expect(issue.to_reference(another_project)).to eq 'sample-project#1'
@@ -102,7 +117,7 @@ describe Issue, models: true do
context 'when different namespace / cross-project argument' do
let(:another_namespace) { create(:namespace, path: 'another-namespace') }
- let(:another_project) { create(:empty_project, path: 'another-project', namespace: another_namespace) }
+ let(:another_project) { create(:project, path: 'another-project', namespace: another_namespace) }
it 'returns full path to the issue' do
expect(issue.to_reference(another_project)).to eq 'sample-namespace/sample-project#1'
@@ -124,13 +139,24 @@ describe Issue, models: true do
end
end
- describe '#is_being_reassigned?' do
- it 'returns true if the issue assignee has changed' do
- subject.assignee = create(:user)
- expect(subject.is_being_reassigned?).to be_truthy
+ describe '#assignee_or_author?' do
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue) }
+
+ it 'returns true for a user that is assigned to an issue' do
+ issue.assignees << user
+
+ expect(issue.assignee_or_author?(user)).to be_truthy
end
- it 'returns false if the issue assignee has not changed' do
- expect(subject.is_being_reassigned?).to be_falsey
+
+ it 'returns true for a user that is the author of an issue' do
+ issue.update(author: user)
+
+ expect(issue.assignee_or_author?(user)).to be_truthy
+ end
+
+ it 'returns false for a user that is not the assignee or author' do
+ expect(issue.assignee_or_author?(user)).to be_falsey
end
end
@@ -161,14 +187,10 @@ describe Issue, models: true do
end
it 'returns the merge request to close this issue' do
- mr
-
expect(issue.closed_by_merge_requests(mr.author)).to eq([mr])
end
it "returns an empty array when the merge request is closed already" do
- closed_mr
-
expect(issue.closed_by_merge_requests(closed_mr.author)).to eq([])
end
@@ -179,7 +201,7 @@ describe Issue, models: true do
describe '#referenced_merge_requests' do
it 'returns the referenced merge requests' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
mr1 = create(:merge_request,
source_project: project,
@@ -212,10 +234,12 @@ describe Issue, models: true do
end
context 'user is reporter in project issue belongs to' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
- before { project.team << [user, :reporter] }
+ before do
+ project.team << [user, :reporter]
+ end
it { is_expected.to eq true }
@@ -226,15 +250,21 @@ describe Issue, models: true do
context 'checking destination project also' do
subject { issue.can_move?(user, to_project) }
- let(:to_project) { create(:empty_project) }
+ let(:to_project) { create(:project) }
context 'destination project allowed' do
- before { to_project.team << [user, :reporter] }
+ before do
+ to_project.team << [user, :reporter]
+ end
+
it { is_expected.to eq true }
end
context 'destination project not allowed' do
- before { to_project.team << [user, :guest] }
+ before do
+ to_project.team << [user, :guest]
+ end
+
it { is_expected.to eq false }
end
end
@@ -261,8 +291,8 @@ describe Issue, models: true do
let(:user) { build(:admin) }
before do
- allow(subject.project.repository).to receive(:branch_names).
- and_return(["mpempe", "#{subject.iid}mepmep", subject.to_branch_name, "#{subject.iid}-branch"])
+ allow(subject.project.repository).to receive(:branch_names)
+ .and_return(["mpempe", "#{subject.iid}mepmep", subject.to_branch_name, "#{subject.iid}-branch"])
# Without this stub, the `create(:merge_request)` above fails because it can't find
# the source branch. This seems like a reasonable compromise, in comparison with
@@ -284,18 +314,39 @@ describe Issue, models: true do
end
it 'excludes stable branches from the related branches' do
- allow(subject.project.repository).to receive(:branch_names).
- and_return(["#{subject.iid}-0-stable"])
+ allow(subject.project.repository).to receive(:branch_names)
+ .and_return(["#{subject.iid}-0-stable"])
expect(subject.related_branches(user)).to eq []
end
end
+ describe '#has_related_branch?' do
+ let(:issue) { create(:issue, title: "Blue Bell Knoll") }
+ subject { issue.has_related_branch? }
+
+ context 'branch found' do
+ before do
+ allow(issue.project.repository).to receive(:branch_names).and_return(["iceblink-luck", issue.to_branch_name])
+ end
+
+ it { is_expected.to eq true }
+ end
+
+ context 'branch not found' do
+ before do
+ allow(issue.project.repository).to receive(:branch_names).and_return(["lazy-calm"])
+ end
+
+ it { is_expected.to eq false }
+ end
+ end
+
it_behaves_like 'an editable mentionable' do
subject { create(:issue, project: create(:project, :repository)) }
let(:backref_text) { "issue #{subject.to_reference}" }
- let(:set_mentionable_text) { ->(txt){ subject.description = txt } }
+ let(:set_mentionable_text) { ->(txt) { subject.description = txt } }
end
it_behaves_like 'a Taskable' do
@@ -321,7 +372,7 @@ describe Issue, models: true do
describe '#participants' do
context 'using a public project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
let!(:note1) do
@@ -343,7 +394,7 @@ describe Issue, models: true do
context 'using a private project' do
it 'does not include mentioned users that do not have access to the project' do
- project = create(:empty_project)
+ project = create(:project)
user = create(:user)
issue = create(:issue, project: project)
@@ -361,15 +412,15 @@ describe Issue, models: true do
it 'updates when assignees change' do
user1 = create(:user)
user2 = create(:user)
- project = create(:empty_project)
- issue = create(:issue, assignee: user1, project: project)
+ project = create(:project)
+ issue = create(:issue, assignees: [user1], project: project)
project.add_developer(user1)
project.add_developer(user2)
expect(user1.assigned_open_issues_count).to eq(1)
expect(user2.assigned_open_issues_count).to eq(0)
- issue.assignee = user2
+ issue.assignees = [user2]
issue.save
expect(user1.assigned_open_issues_count).to eq(0)
@@ -431,7 +482,7 @@ describe Issue, models: true do
let(:user) { create(:user) }
context 'using a public project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
it 'returns true for a regular issue' do
issue = build(:issue, project: project)
@@ -447,7 +498,7 @@ describe Issue, models: true do
end
context 'using an internal project' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
context 'using an internal user' do
it 'returns true for a regular issue' do
@@ -483,7 +534,7 @@ describe Issue, models: true do
end
context 'using a private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it 'returns false for a regular issue' do
issue = build(:issue, project: project)
@@ -498,7 +549,9 @@ describe Issue, models: true do
end
context 'when the user is the project owner' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'returns true for a regular issue' do
issue = build(:issue, project: project)
@@ -517,7 +570,7 @@ describe Issue, models: true do
context 'with a regular user that is a team member' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'using a public project' do
before do
@@ -538,7 +591,7 @@ describe Issue, models: true do
end
context 'using an internal project' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
before do
project.team << [user, Gitlab::Access::DEVELOPER]
@@ -558,7 +611,7 @@ describe Issue, models: true do
end
context 'using a private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
before do
project.team << [user, Gitlab::Access::DEVELOPER]
@@ -579,7 +632,7 @@ describe Issue, models: true do
end
context 'with an admin user' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:admin) }
it 'returns true for a regular issue' do
@@ -598,7 +651,7 @@ describe Issue, models: true do
describe '#publicly_visible?' do
context 'using a public project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
it 'returns true for a regular issue' do
issue = build(:issue, project: project)
@@ -614,7 +667,7 @@ describe Issue, models: true do
end
context 'using an internal project' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
it 'returns false for a regular issue' do
issue = build(:issue, project: project)
@@ -630,7 +683,7 @@ describe Issue, models: true do
end
context 'using a private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it 'returns false for a regular issue' do
issue = build(:issue, project: project)
@@ -655,6 +708,11 @@ describe Issue, models: true do
expect(attrs_hash).to include(:human_total_time_spent)
expect(attrs_hash).to include('time_estimate')
end
+
+ it 'includes assignee_ids and deprecated assignee_id' do
+ expect(attrs_hash).to include(:assignee_id)
+ expect(attrs_hash).to include(:assignee_ids)
+ end
end
describe '#check_for_spam' do
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 7c40cfd8253..3508391c721 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
-describe Key, models: true do
- include EmailHelpers
-
+describe Key, :mailer do
describe "Associations" do
it { is_expected.to belong_to(:user) }
end
@@ -34,8 +32,8 @@ describe Key, models: true do
context 'when key was not updated during the last day' do
before do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
- and_return('000000')
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain)
+ .and_return('000000')
end
it 'enqueues a UseKeyWorker job' do
@@ -46,8 +44,8 @@ describe Key, models: true do
context 'when key was updated during the last day' do
before do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
- and_return(false)
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain)
+ .and_return(false)
end
it 'does not enqueue a UseKeyWorker job' do
@@ -66,14 +64,16 @@ describe Key, models: true do
end
it "does not accept the exact same key twice" do
- create(:key, user: user)
- expect(build(:key, user: user)).not_to be_valid
+ first_key = create(:key, user: user)
+
+ expect(build(:key, user: user, key: first_key.key)).not_to be_valid
end
it "does not accept a duplicate key with a different comment" do
- create(:key, user: user)
- duplicate = build(:key, user: user)
+ first_key = create(:key, user: user)
+ duplicate = build(:key, user: user, key: first_key.key)
duplicate.key << ' extra comment'
+
expect(duplicate).not_to be_valid
end
end
@@ -83,24 +83,16 @@ describe Key, models: true do
expect(build(:key)).to be_valid
end
- it 'rejects an unfingerprintable key that contains a space' do
+ it 'accepts a key with newline charecters after stripping them' do
key = build(:key)
-
- # Not always the middle, but close enough
- key.key = key.key[0..100] + ' ' + key.key[101..-1]
-
- expect(key).not_to be_valid
+ key.key = key.key.insert(100, "\n")
+ key.key = key.key.insert(40, "\r\n")
+ expect(key).to be_valid
end
it 'rejects the unfingerprintable key (not a key)' do
expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid
end
-
- it 'rejects the multiple line key' do
- key = build(:key)
- key.key.tr!(' ', "\n")
- expect(key).not_to be_valid
- end
end
context 'callbacks' do
diff --git a/spec/models/label_link_spec.rb b/spec/models/label_link_spec.rb
index c18ed8574b1..e2b49bc2de7 100644
--- a/spec/models/label_link_spec.rb
+++ b/spec/models/label_link_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe LabelLink, models: true do
+describe LabelLink do
it { expect(build(:label_link)).to be_valid }
it { is_expected.to belong_to(:label) }
diff --git a/spec/models/label_priority_spec.rb b/spec/models/label_priority_spec.rb
index d18c2f7949a..9dcb0f06b20 100644
--- a/spec/models/label_priority_spec.rb
+++ b/spec/models/label_priority_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe LabelPriority, models: true do
+describe LabelPriority do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:label) }
diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb
index 80ca19acdda..8914845ea82 100644
--- a/spec/models/label_spec.rb
+++ b/spec/models/label_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Label, models: true do
+describe Label do
describe 'modules' do
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Subscribable) }
@@ -49,6 +49,23 @@ describe Label, models: true do
expect(label.color).to eq('#abcdef')
end
+
+ it 'uses default color if color is missing' do
+ label = described_class.new(color: nil)
+
+ expect(label.color).to be(Label::DEFAULT_COLOR)
+ end
+ end
+
+ describe '#text_color' do
+ it 'uses default color if color is missing' do
+ expect(LabelsHelper).to receive(:text_color_for_bg).with(Label::DEFAULT_COLOR)
+ .and_return(spy)
+
+ label = described_class.new(color: nil)
+
+ label.text_color
+ end
end
describe '#title' do
diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb
index 153e757a0ef..dae97b69c84 100644
--- a/spec/models/legacy_diff_discussion_spec.rb
+++ b/spec/models/legacy_diff_discussion_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe LegacyDiffDiscussion, models: true do
+describe LegacyDiffDiscussion do
subject { create(:legacy_diff_note_on_merge_request).to_discussion }
describe '#reply_attributes' do
@@ -8,4 +8,26 @@ describe LegacyDiffDiscussion, models: true do
expect(subject.reply_attributes[:line_code]).to eq(subject.line_code)
end
end
+
+ describe '#merge_request_version_params' do
+ context 'when the discussion is active' do
+ before do
+ allow(subject).to receive(:active?).and_return(true)
+ end
+
+ it 'returns an empty hash, which will end up showing the latest version' do
+ expect(subject.merge_request_version_params).to eq({})
+ end
+ end
+
+ context 'when the discussion is outdated' do
+ before do
+ allow(subject).to receive(:active?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(subject.merge_request_version_params).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/models/lfs_objects_project_spec.rb b/spec/models/lfs_objects_project_spec.rb
index 7bc278e350f..d24d4cf7695 100644
--- a/spec/models/lfs_objects_project_spec.rb
+++ b/spec/models/lfs_objects_project_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe LfsObjectsProject, models: true do
+describe LfsObjectsProject do
subject { create(:lfs_objects_project, project: project) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb
index db2c2619968..a6cc01bea5f 100644
--- a/spec/models/list_spec.rb
+++ b/spec/models/list_spec.rb
@@ -13,12 +13,6 @@ describe List do
it { is_expected.to validate_presence_of(:position) }
it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) }
- it 'validates uniqueness of label scoped to board_id' do
- create(:list)
-
- expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id)
- end
-
context 'when list_type is set to closed' do
subject { described_class.new(list_type: :closed) }
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index b0f3657d3b5..87513e18b25 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Member, models: true do
+describe Member do
describe "Associations" do
it { is_expected.to belong_to(:user) }
end
describe "Validation" do
- subject { Member.new(access_level: Member::GUEST) }
+ subject { described_class.new(access_level: Member::GUEST) }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:source) }
@@ -57,7 +57,7 @@ describe Member, models: true do
describe 'Scopes & finders' do
before do
- project = create(:empty_project, :public, :access_requestable)
+ project = create(:project, :public, :access_requestable)
group = create(:group)
@owner_user = create(:user).tap { |u| group.add_owner(u) }
@owner = group.members.find_by(user_id: @owner_user.id)
@@ -83,8 +83,8 @@ describe Member, models: true do
@accepted_invite_member = create(:project_member, :developer,
project: project,
invite_token: '1234',
- invite_email: 'toto2@example.com').
- tap { |u| u.accept_invite!(accepted_invite_user) }
+ invite_email: 'toto2@example.com')
+ .tap { |u| u.accept_invite!(accepted_invite_user) }
requested_user = create(:user).tap { |u| project.request_access(u) }
@requested_member = project.requesters.find_by(user_id: requested_user.id)
@@ -265,8 +265,8 @@ describe Member, models: true do
expect(source.users).not_to include(user)
expect(source.requesters.exists?(user_id: user)).to be_truthy
- expect { described_class.add_user(source, user, :master) }.
- to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { described_class.add_user(source, user, :master) }
+ .to raise_error(Gitlab::Access::AccessDeniedError)
expect(source.users.reload).not_to include(user)
expect(source.requesters.reload.exists?(user_id: user)).to be_truthy
@@ -390,13 +390,15 @@ describe Member, models: true do
%w[project group].each do |source_type|
context "when source is a #{source_type}" do
let!(:source) { create(source_type, :public, :access_requestable) }
- let!(:user) { create(:user) }
let!(:admin) { create(:admin) }
+ let(:user1) { create(:user) }
+ let(:user2) { create(:user) }
it 'returns a <Source>Member objects' do
- members = described_class.add_users(source, [user], :master)
+ members = described_class.add_users(source, [user1, user2], :master)
expect(members).to be_a Array
+ expect(members.size).to eq(2)
expect(members.first).to be_a "#{source_type.classify}Member".constantize
expect(members.first).to be_persisted
end
@@ -514,7 +516,7 @@ describe Member, models: true do
describe "destroying a record", truncate: true do
it "refreshes user's authorized projects" do
- project = create(:empty_project, :private)
+ project = create(:project, :private)
user = create(:user)
member = project.team << [user, :reporter]
diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb
index 17765b25856..5a3b5b1f517 100644
--- a/spec/models/members/group_member_spec.rb
+++ b/spec/models/members/group_member_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GroupMember, models: true do
+describe GroupMember do
describe '.access_level_roles' do
it 'returns Gitlab::Access.options_with_owner' do
expect(described_class.access_level_roles).to eq(Gitlab::Access.options_with_owner)
@@ -33,8 +33,8 @@ describe GroupMember, models: true do
it "sends email to user" do
membership = build(:group_member)
- allow(membership).to receive(:notification_service).
- and_return(double('NotificationService').as_null_object)
+ allow(membership).to receive(:notification_service)
+ .and_return(double('NotificationService').as_null_object)
expect(membership).to receive(:notification_service)
membership.save
@@ -44,8 +44,8 @@ describe GroupMember, models: true do
describe "#after_update" do
before do
@group_member = create :group_member
- allow(@group_member).to receive(:notification_service).
- and_return(double('NotificationService').as_null_object)
+ allow(@group_member).to receive(:notification_service)
+ .and_return(double('NotificationService').as_null_object)
end
it "sends email to user" do
diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb
index 87ea2e70680..fa3e80ba062 100644
--- a/spec/models/members/project_member_spec.rb
+++ b/spec/models/members/project_member_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProjectMember, models: true do
+describe ProjectMember do
describe 'associations' do
it { is_expected.to belong_to(:project).with_foreign_key(:source_id) }
end
@@ -22,16 +22,15 @@ describe ProjectMember, models: true do
end
describe '.add_user' do
- context 'when called with the project owner' do
- it 'adds the user as a member' do
- project = create(:empty_project)
+ it 'adds the user as a member' do
+ user = create(:user)
+ project = create(:project)
- expect(project.users).not_to include(project.owner)
+ expect(project.users).not_to include(user)
- described_class.add_user(project, project.owner, :master, current_user: project.owner)
+ described_class.add_user(project, user, :master, current_user: project.owner)
- expect(project.users.reload).to include(project.owner)
- end
+ expect(project.users.reload).to include(user)
end
end
@@ -83,8 +82,8 @@ describe ProjectMember, models: true do
describe '.import_team' do
before do
- @project_1 = create(:empty_project)
- @project_2 = create(:empty_project)
+ @project_1 = create(:project)
+ @project_2 = create(:project)
@user_1 = create :user
@user_2 = create :user
@@ -113,7 +112,7 @@ describe ProjectMember, models: true do
describe '.add_users_to_projects' do
it 'adds the given users to the given projects' do
- projects = create_list(:empty_project, 2)
+ projects = create_list(:project, 2)
users = create_list(:user, 2)
described_class.add_users_to_projects(
@@ -131,8 +130,8 @@ describe ProjectMember, models: true do
describe '.truncate_teams' do
before do
- @project_1 = create(:empty_project)
- @project_2 = create(:empty_project)
+ @project_1 = create(:project)
+ @project_2 = create(:project)
@user_1 = create :user
@user_2 = create :user
@@ -140,7 +139,7 @@ describe ProjectMember, models: true do
@project_1.team << [@user_1, :developer]
@project_2.team << [@user_2, :reporter]
- ProjectMember.truncate_teams([@project_1.id, @project_2.id])
+ described_class.truncate_teams([@project_1.id, @project_2.id])
end
it { expect(@project_1.users).to be_empty }
@@ -150,7 +149,7 @@ describe ProjectMember, models: true do
describe 'notifications' do
describe '#after_accept_request' do
it 'calls NotificationService.new_project_member' do
- member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now)
+ member = create(:project_member, user: create(:user), requested_at: Time.now)
expect_any_instance_of(NotificationService).to receive(:new_project_member)
diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb
index 9afed311e27..9353d5c3c8a 100644
--- a/spec/models/merge_request/metrics_spec.rb
+++ b/spec/models/merge_request/metrics_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequest::Metrics, models: true do
+describe MergeRequest::Metrics do
subject { create(:merge_request) }
describe "when recording the default set of metrics on merge request save" do
diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb
new file mode 100644
index 00000000000..9d4a0ecf8c0
--- /dev/null
+++ b/spec/models/merge_request_diff_commit_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+describe MergeRequestDiffCommit do
+ let(:merge_request) { create(:merge_request) }
+ subject { merge_request.commits.first }
+
+ describe '#to_hash' do
+ it 'returns the same results as Commit#to_hash, except for parent_ids' do
+ commit_from_repo = merge_request.project.repository.commit(subject.sha)
+ commit_from_repo_hash = commit_from_repo.to_hash.merge(parent_ids: [])
+
+ expect(subject.to_hash).to eq(commit_from_repo_hash)
+ end
+ end
+end
diff --git a/spec/models/merge_request_diff_file_spec.rb b/spec/models/merge_request_diff_file_spec.rb
new file mode 100644
index 00000000000..faa47660a74
--- /dev/null
+++ b/spec/models/merge_request_diff_file_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe MergeRequestDiffFile do
+ describe '#diff' do
+ let(:unpacked) { 'unpacked' }
+ let(:packed) { [unpacked].pack('m0') }
+
+ before do
+ subject.diff = packed
+ end
+
+ context 'when the diff is marked as binary' do
+ before do
+ subject.binary = true
+ end
+
+ it 'unpacks from base 64' do
+ expect(subject.diff).to eq(unpacked)
+ end
+ end
+
+ context 'when the diff is not marked as binary' do
+ it 'returns the raw diff' do
+ expect(subject.diff).to eq(packed)
+ end
+ end
+ end
+
+ describe '#utf8_diff' do
+ it 'does not raise error when the diff is binary' do
+ subject.diff = "\x05\x00\x68\x65\x6c\x6c\x6f"
+
+ expect { subject.utf8_diff }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 0a10ee01506..0cfaa17676e 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequestDiff, models: true do
+describe MergeRequestDiff do
describe 'create new record' do
subject { create(:merge_request).merge_request_diff }
@@ -36,7 +36,9 @@ describe MergeRequestDiff, models: true do
end
context 'when the raw diffs are empty' do
- before { mr_diff.update_attributes(st_diffs: '') }
+ before do
+ MergeRequestDiffFile.delete_all(merge_request_diff_id: mr_diff.id)
+ end
it 'returns an empty DiffCollection' do
expect(mr_diff.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
@@ -45,7 +47,10 @@ describe MergeRequestDiff, models: true do
end
context 'when the raw diffs have invalid content' do
- before { mr_diff.update_attributes(st_diffs: ["--broken-diff"]) }
+ before do
+ MergeRequestDiffFile.delete_all(merge_request_diff_id: mr_diff.id)
+ mr_diff.update_attributes(st_diffs: ["--broken-diff"])
+ end
it 'returns an empty DiffCollection' do
expect(mr_diff.raw_diffs.to_a).to be_empty
@@ -93,23 +98,32 @@ describe MergeRequestDiff, models: true do
end
it 'saves empty state' do
- allow_any_instance_of(MergeRequestDiff).to receive(:commits)
+ allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
.and_return([])
mr_diff = create(:merge_request).merge_request_diff
expect(mr_diff.empty?).to be_truthy
end
+
+ it 'saves binary diffs correctly' do
+ path = 'files/images/icn-time-tracking.pdf'
+ mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+
+ expect(diff_file).to be_binary
+ expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ end
end
- describe '#commits_sha' do
+ describe '#commit_shas' do
it 'returns all commits SHA using serialized commits' do
subject.st_commits = [
{ id: 'sha1' },
{ id: 'sha2' }
]
- expect(subject.commits_sha).to eq(%w(sha1 sha2))
+ expect(subject.commit_shas).to eq(%w(sha1 sha2))
end
end
@@ -139,4 +153,15 @@ describe MergeRequestDiff, models: true do
expect(subject.commits_count).to eq 2
end
end
+
+ describe '#utf8_st_diffs' do
+ it 'does not raise error when a hash value is in binary' do
+ subject.st_diffs = [
+ { diff: "\0" },
+ { diff: "\x05\x00\x68\x65\x6c\x6c\x6f" }
+ ]
+
+ expect { subject.utf8_st_diffs }.not_to raise_error
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index be08b96641a..026bdbd26d1 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequest, models: true do
+describe MergeRequest do
include RepoHelpers
subject { create(:merge_request) }
@@ -9,7 +9,8 @@ describe MergeRequest, models: true do
it { is_expected.to belong_to(:target_project).class_name('Project') }
it { is_expected.to belong_to(:source_project).class_name('Project') }
it { is_expected.to belong_to(:merge_user).class_name("User") }
- it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) }
+ it { is_expected.to belong_to(:assignee) }
+ it { is_expected.to have_many(:merge_request_diffs) }
end
describe 'modules' do
@@ -86,6 +87,60 @@ describe MergeRequest, models: true do
end
end
+ describe '#card_attributes' do
+ it 'includes the author name' do
+ allow(subject).to receive(:author).and_return(double(name: 'Robert'))
+ allow(subject).to receive(:assignee).and_return(nil)
+
+ expect(subject.card_attributes)
+ .to eq({ 'Author' => 'Robert', 'Assignee' => nil })
+ end
+
+ it 'includes the assignee name' do
+ allow(subject).to receive(:author).and_return(double(name: 'Robert'))
+ allow(subject).to receive(:assignee).and_return(double(name: 'Douwe'))
+
+ expect(subject.card_attributes)
+ .to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' })
+ end
+ end
+
+ describe '#assignee_ids' do
+ it 'returns an array of the assigned user id' do
+ subject.assignee_id = 123
+
+ expect(subject.assignee_ids).to eq([123])
+ end
+ end
+
+ describe '#assignee_ids=' do
+ it 'sets assignee_id to the last id in the array' do
+ subject.assignee_ids = [123, 456]
+
+ expect(subject.assignee_id).to eq(456)
+ end
+ end
+
+ describe '#assignee_or_author?' do
+ let(:user) { create(:user) }
+
+ it 'returns true for a user that is assigned to a merge request' do
+ subject.assignee = user
+
+ expect(subject.assignee_or_author?(user)).to eq(true)
+ end
+
+ it 'returns true for a user that is the author of a merge request' do
+ subject.author = user
+
+ expect(subject.assignee_or_author?(user)).to eq(true)
+ end
+
+ it 'returns false for a user that is not the assignee or author' do
+ expect(subject.assignee_or_author?(user)).to eq(false)
+ end
+ end
+
describe '#cache_merge_request_closes_issues!' do
before do
subject.project.team << [subject.author, :developer]
@@ -100,13 +155,53 @@ describe MergeRequest, models: true do
expect { subject.cache_merge_request_closes_issues!(subject.author) }.to change(subject.merge_requests_closing_issues, :count).by(1)
end
- it 'does not cache issues from external trackers' do
- subject.project.update_attribute(:has_external_issue_tracker, true)
- issue = ExternalIssue.new('JIRA-123', subject.project)
- commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
- allow(subject).to receive(:commits).and_return([commit])
+ context 'when both internal and external issue trackers are enabled' do
+ before do
+ subject.project.has_external_issue_tracker = true
+ subject.project.save!
+ end
+
+ it 'does not cache issues from external trackers' do
+ issue = ExternalIssue.new('JIRA-123', subject.project)
+ commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
+ allow(subject).to receive(:commits).and_return([commit])
+
+ expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count)
+ end
- expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count)
+ it 'caches an internal issue' do
+ issue = create(:issue, project: subject.project)
+ commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
+ allow(subject).to receive(:commits).and_return([commit])
+
+ expect { subject.cache_merge_request_closes_issues!(subject.author) }
+ .to change(subject.merge_requests_closing_issues, :count).by(1)
+ end
+ end
+
+ context 'when only external issue tracker enabled' do
+ before do
+ subject.project.has_external_issue_tracker = true
+ subject.project.issues_enabled = false
+ subject.project.save!
+ end
+
+ it 'does not cache issues from external trackers' do
+ issue = ExternalIssue.new('JIRA-123', subject.project)
+ commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
+ allow(subject).to receive(:commits).and_return([commit])
+
+ expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count)
+ end
+
+ it 'does not cache an internal issue' do
+ issue = create(:issue, project: subject.project)
+ commit = double('commit1', safe_message: "Fixes #{issue.to_reference}")
+ allow(subject).to receive(:commits).and_return([commit])
+
+ expect { subject.cache_merge_request_closes_issues!(subject.author) }
+ .not_to change(subject.merge_requests_closing_issues, :count)
+ end
end
end
@@ -142,7 +237,7 @@ describe MergeRequest, models: true do
end
describe '#to_reference' do
- let(:project) { build(:empty_project, name: 'sample-project') }
+ let(:project) { build(:project, name: 'sample-project') }
let(:merge_request) { build(:merge_request, target_project: project, iid: 1) }
it 'returns a String reference to the object' do
@@ -150,12 +245,12 @@ describe MergeRequest, models: true do
end
it 'supports a cross-project reference' do
- another_project = build(:empty_project, name: 'another-project', namespace: project.namespace)
+ another_project = build(:project, name: 'another-project', namespace: project.namespace)
expect(merge_request.to_reference(another_project)).to eq "sample-project!1"
end
it 'returns a String reference with the full path' do
- expect(merge_request.to_reference(full: true)).to eq(project.path_with_namespace + '!1')
+ expect(merge_request.to_reference(full: true)).to eq(project.full_path + '!1')
end
end
@@ -199,10 +294,10 @@ describe MergeRequest, models: true do
end
context 'when there are no MR diffs' do
- it 'delegates to the compare object, setting no_collapse: true' do
+ it 'delegates to the compare object, setting expanded: true' do
merge_request.compare = double(:compare)
- expect(merge_request.compare).to receive(:diffs).with(options.merge(no_collapse: true))
+ expect(merge_request.compare).to receive(:diffs).with(options.merge(expanded: true))
merge_request.diffs(options)
end
@@ -295,20 +390,10 @@ describe MergeRequest, models: true do
end
end
- describe '#is_being_reassigned?' do
- it 'returns true if the merge_request assignee has changed' do
- subject.assignee = create(:user)
- expect(subject.is_being_reassigned?).to be_truthy
- end
- it 'returns false if the merge request assignee has not changed' do
- expect(subject.is_being_reassigned?).to be_falsey
- end
- end
-
describe '#for_fork?' do
it 'returns true if the merge request is for a fork' do
- subject.source_project = build_stubbed(:empty_project, namespace: create(:group))
- subject.target_project = build_stubbed(:empty_project, namespace: create(:group))
+ subject.source_project = build_stubbed(:project, namespace: create(:group))
+ subject.target_project = build_stubbed(:project, namespace: create(:group))
expect(subject.for_fork?).to be_truthy
end
@@ -332,8 +417,8 @@ describe MergeRequest, models: true do
end
it 'accesses the set of issues that will be closed on acceptance' do
- allow(subject.project).to receive(:default_branch).
- and_return(subject.target_branch)
+ allow(subject.project).to receive(:default_branch)
+ .and_return(subject.target_branch)
closed = subject.closes_issues
@@ -359,8 +444,8 @@ describe MergeRequest, models: true do
subject.description = "Is related to #{mentioned_issue.to_reference} and #{closing_issue.to_reference}"
allow(subject).to receive(:commits).and_return([commit])
- allow(subject.project).to receive(:default_branch).
- and_return(subject.target_branch)
+ allow(subject.project).to receive(:default_branch)
+ .and_return(subject.target_branch)
expect(subject.issues_mentioned_but_not_closing(subject.author)).to match_array([mentioned_issue])
end
@@ -508,8 +593,8 @@ describe MergeRequest, models: true do
subject.project.team << [subject.author, :developer]
subject.description = "This issue Closes #{issue.to_reference}"
- allow(subject.project).to receive(:default_branch).
- and_return(subject.target_branch)
+ allow(subject.project).to receive(:default_branch)
+ .and_return(subject.target_branch)
expect(subject.merge_commit_message)
.to match("Closes #{issue.to_reference}")
@@ -596,7 +681,7 @@ describe MergeRequest, models: true do
end
it 'does not crash' do
- expect{ subject.diverged_commits_count }.not_to raise_error
+ expect { subject.diverged_commits_count }.not_to raise_error
end
it 'returns 0' do
@@ -629,23 +714,23 @@ describe MergeRequest, models: true do
end
describe 'caching' do
- before(:example) do
+ before do
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
end
it 'caches the output' do
- expect(subject).to receive(:compute_diverged_commits_count).
- once.
- and_return(2)
+ expect(subject).to receive(:compute_diverged_commits_count)
+ .once
+ .and_return(2)
subject.diverged_commits_count
subject.diverged_commits_count
end
it 'invalidates the cache when the source sha changes' do
- expect(subject).to receive(:compute_diverged_commits_count).
- twice.
- and_return(2)
+ expect(subject).to receive(:compute_diverged_commits_count)
+ .twice
+ .and_return(2)
subject.diverged_commits_count
allow(subject).to receive(:source_branch_sha).and_return('123abc')
@@ -653,9 +738,9 @@ describe MergeRequest, models: true do
end
it 'invalidates the cache when the target sha changes' do
- expect(subject).to receive(:compute_diverged_commits_count).
- twice.
- and_return(2)
+ expect(subject).to receive(:compute_diverged_commits_count)
+ .twice
+ .and_return(2)
subject.diverged_commits_count
allow(subject).to receive(:target_branch_sha).and_return('123abc')
@@ -668,34 +753,28 @@ describe MergeRequest, models: true do
subject { create(:merge_request, :simple) }
let(:backref_text) { "merge request #{subject.to_reference}" }
- let(:set_mentionable_text) { ->(txt){ subject.description = txt } }
+ let(:set_mentionable_text) { ->(txt) { subject.description = txt } }
end
it_behaves_like 'a Taskable' do
subject { create :merge_request, :simple }
end
- describe '#commits_sha' do
+ describe '#commit_shas' do
before do
- allow(subject.merge_request_diff).to receive(:commits_sha).
- and_return(['sha1'])
+ allow(subject.merge_request_diff).to receive(:commit_shas)
+ .and_return(['sha1'])
end
it 'delegates to merge request diff' do
- expect(subject.commits_sha).to eq ['sha1']
+ expect(subject.commit_shas).to eq ['sha1']
end
end
describe '#head_pipeline' do
describe 'when the source project exists' do
it 'returns the latest pipeline' do
- pipeline = double(:ci_pipeline, ref: 'master')
-
- allow(subject).to receive(:diff_head_sha).and_return('123abc')
-
- expect(subject.source_project).to receive(:pipeline_for).
- with('master', '123abc').
- and_return(pipeline)
+ pipeline = create(:ci_empty_pipeline, project: subject.source_project, ref: 'master', status: 'running', sha: "123abc", head_pipeline_of: subject)
expect(subject.head_pipeline).to eq(pipeline)
end
@@ -713,7 +792,7 @@ describe MergeRequest, models: true do
describe '#all_pipelines' do
shared_examples 'returning pipelines with proper ordering' do
let!(:all_pipelines) do
- subject.all_commits_sha.map do |sha|
+ subject.all_commit_shas.map do |sha|
create(:ci_empty_pipeline,
project: subject.source_project,
sha: sha,
@@ -755,16 +834,16 @@ describe MergeRequest, models: true do
end
end
- describe '#all_commits_sha' do
+ describe '#all_commit_shas' do
context 'when merge request is persisted' do
- let(:all_commits_sha) do
+ let(:all_commit_shas) do
subject.merge_request_diffs.flat_map(&:commits).map(&:sha).uniq
end
shared_examples 'returning all SHA' do
it 'returns all SHA from all merge_request_diffs' do
expect(subject.merge_request_diffs.size).to eq(2)
- expect(subject.all_commits_sha).to eq(all_commits_sha)
+ expect(subject.all_commit_shas).to match_array(all_commit_shas)
end
end
@@ -795,7 +874,7 @@ describe MergeRequest, models: true do
end
it 'returns commits from compare commits temporary data' do
- expect(subject.all_commits_sha).to eq [commit, commit]
+ expect(subject.all_commit_shas).to eq [commit, commit]
end
end
@@ -803,14 +882,14 @@ describe MergeRequest, models: true do
subject { build(:merge_request) }
it 'returns array with diff head sha element only' do
- expect(subject.all_commits_sha).to eq [subject.diff_head_sha]
+ expect(subject.all_commit_shas).to eq [subject.diff_head_sha]
end
end
end
end
describe '#participants' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:mr) do
create(:merge_request, source_project: project, target_project: project)
@@ -853,7 +932,7 @@ describe MergeRequest, models: true do
end
describe '#check_if_can_be_merged' do
- let(:project) { create(:empty_project, only_allow_merge_if_pipeline_succeeds: true) }
+ let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true) }
subject { create(:merge_request, source_project: project, merge_status: :unchecked) }
@@ -869,7 +948,9 @@ describe MergeRequest, models: true do
end
context 'when broken' do
- before { allow(subject).to receive(:broken?) { true } }
+ before do
+ allow(subject).to receive(:broken?) { true }
+ end
it 'becomes unmergeable' do
expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('cannot_be_merged')
@@ -889,7 +970,7 @@ describe MergeRequest, models: true do
end
describe '#mergeable?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { create(:merge_request, source_project: project) }
@@ -921,7 +1002,9 @@ describe MergeRequest, models: true do
end
context 'when not open' do
- before { subject.close }
+ before do
+ subject.close
+ end
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
@@ -929,7 +1012,9 @@ describe MergeRequest, models: true do
end
context 'when working in progress' do
- before { subject.title = 'WIP MR' }
+ before do
+ subject.title = 'WIP MR'
+ end
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
@@ -937,7 +1022,9 @@ describe MergeRequest, models: true do
end
context 'when broken' do
- before { allow(subject).to receive(:broken?) { true } }
+ before do
+ allow(subject).to receive(:broken?) { true }
+ end
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
@@ -968,7 +1055,7 @@ describe MergeRequest, models: true do
end
describe '#mergeable_ci_state?' do
- let(:project) { create(:empty_project, only_allow_merge_if_pipeline_succeeds: true) }
+ let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true) }
let(:pipeline) { create(:ci_empty_pipeline) }
subject { build(:merge_request, target_project: project) }
@@ -1011,7 +1098,7 @@ describe MergeRequest, models: true do
end
context 'when merges are not restricted to green builds' do
- subject { build(:merge_request, target_project: build(:empty_project, only_allow_merge_if_pipeline_succeeds: false)) }
+ subject { build(:merge_request, target_project: build(:project, only_allow_merge_if_pipeline_succeeds: false)) }
context 'and a failed pipeline is associated' do
before do
@@ -1155,7 +1242,7 @@ describe MergeRequest, models: true do
end
describe "#reload_diff" do
- let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) }
+ let(:discussion) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject).to_discussion }
let(:commit) { subject.project.commit(sample_commit.id) }
@@ -1174,7 +1261,7 @@ describe MergeRequest, models: true do
subject.reload_diff
end
- it "updates diff note positions" do
+ it "updates diff discussion positions" do
old_diff_refs = subject.diff_refs
# Update merge_request_diff so that #diff_refs will return commit.diff_refs
@@ -1188,18 +1275,18 @@ describe MergeRequest, models: true do
subject.merge_request_diff(true)
end
- expect(Notes::DiffPositionUpdateService).to receive(:new).with(
+ expect(Discussions::UpdateDiffPositionService).to receive(:new).with(
subject.project,
- nil,
+ subject.author,
old_diff_refs: old_diff_refs,
new_diff_refs: commit.diff_refs,
- paths: note.position.paths
+ paths: discussion.position.paths
).and_call_original
- expect_any_instance_of(Notes::DiffPositionUpdateService).to receive(:execute).with(note)
+ expect_any_instance_of(Discussions::UpdateDiffPositionService).to receive(:execute).with(discussion).and_call_original
expect_any_instance_of(DiffNote).to receive(:save).once
- subject.reload_diff
+ subject.reload_diff(subject.author)
end
end
@@ -1220,7 +1307,7 @@ describe MergeRequest, models: true do
end
end
- describe "#diff_sha_refs" do
+ describe "#diff_refs" do
context "with diffs" do
subject { create(:merge_request, :with_diffs) }
@@ -1229,7 +1316,7 @@ describe MergeRequest, models: true do
expect_any_instance_of(Repository).not_to receive(:commit)
- subject.diff_sha_refs
+ subject.diff_refs
end
it "returns expected diff_refs" do
@@ -1239,79 +1326,14 @@ describe MergeRequest, models: true do
head_sha: subject.merge_request_diff.head_commit_sha
)
- expect(subject.diff_sha_refs).to eq(expected_diff_refs)
+ expect(subject.diff_refs).to eq(expected_diff_refs)
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|
- mr.mark_as_unmergeable
- end
- end
-
- it 'returns a falsey value when the MR can be merged without conflicts' do
- merge_request = create_merge_request('master')
- merge_request.mark_as_mergeable
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the MR is marked as having conflicts, but has none' do
- merge_request = create_merge_request('master')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the MR has a missing ref after a force push' do
- merge_request = create_merge_request('conflict-resolvable')
- allow(merge_request.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError)
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the MR does not support new diff notes' do
- merge_request = create_merge_request('conflict-resolvable')
- merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the conflicts contain a large file' do
- merge_request = create_merge_request('conflict-too-large')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the conflicts contain a binary file' do
- merge_request = create_merge_request('conflict-binary-file')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
- merge_request = create_merge_request('conflict-missing-side')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
- end
-
- it 'returns a truthy value when the conflicts are resolvable in the UI' do
- merge_request = create_merge_request('conflict-resolvable')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
- end
-
- it 'returns a truthy value when the conflicts have to be resolved in an editor' do
- merge_request = create_merge_request('conflict-contains-conflict-markers')
-
- expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
- end
- end
-
describe "#source_project_missing?" do
- let(:project) { create(:empty_project) }
- let(:fork_project) { create(:empty_project, forked_from_project: project) }
+ let(:project) { create(:project) }
+ let(:fork_project) { create(:project, forked_from_project: project) }
let(:user) { create(:user) }
let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) }
@@ -1347,9 +1369,35 @@ describe MergeRequest, models: true do
end
end
+ describe '#merge_ongoing?' do
+ it 'returns true when merge process is ongoing for merge_jid' do
+ merge_request = create(:merge_request, merge_jid: 'foo')
+
+ allow(Gitlab::SidekiqStatus).to receive(:num_running).with(['foo']).and_return(1)
+
+ expect(merge_request.merge_ongoing?).to be(true)
+ end
+
+ it 'returns false when no merge process running for merge_jid' do
+ merge_request = build(:merge_request, merge_jid: 'foo')
+
+ allow(Gitlab::SidekiqStatus).to receive(:num_running).with(['foo']).and_return(0)
+
+ expect(merge_request.merge_ongoing?).to be(false)
+ end
+
+ it 'returns false when merge_jid is nil' do
+ merge_request = build(:merge_request, merge_jid: nil)
+
+ expect(Gitlab::SidekiqStatus).not_to receive(:num_running)
+
+ expect(merge_request.merge_ongoing?).to be(false)
+ end
+ end
+
describe "#closed_without_fork?" do
- let(:project) { create(:empty_project) }
- let(:fork_project) { create(:empty_project, forked_from_project: project) }
+ let(:project) { create(:project) }
+ let(:fork_project) { create(:project, forked_from_project: project) }
let(:user) { create(:user) }
let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) }
@@ -1394,9 +1442,9 @@ describe MergeRequest, models: true do
end
context 'forked project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:fork_project) { create(:empty_project, forked_from_project: project, namespace: user.namespace) }
+ let(:fork_project) { create(:project, forked_from_project: project, namespace: user.namespace) }
let!(:merge_request) do
create(:closed_merge_request,
@@ -1431,13 +1479,16 @@ describe MergeRequest, models: true do
end
end
- describe '#mergeable_with_slash_command?' do
+ describe '#mergeable_with_quick_action?' do
def create_pipeline(status)
- create(:ci_pipeline_with_one_job,
+ pipeline = create(:ci_pipeline_with_one_job,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
- status: status)
+ status: status,
+ head_pipeline_of: merge_request)
+
+ pipeline
end
let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) }
@@ -1452,21 +1503,21 @@ describe MergeRequest, models: true do
context 'when autocomplete_precheck is set to true' do
it 'is mergeable by developer' do
- expect(merge_request.mergeable_with_slash_command?(developer, autocomplete_precheck: true)).to be_truthy
+ expect(merge_request.mergeable_with_quick_action?(developer, autocomplete_precheck: true)).to be_truthy
end
it 'is not mergeable by normal user' do
- expect(merge_request.mergeable_with_slash_command?(user, autocomplete_precheck: true)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(user, autocomplete_precheck: true)).to be_falsey
end
end
context 'when autocomplete_precheck is set to false' do
it 'is mergeable by developer' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy
end
it 'is not mergeable by normal user' do
- expect(merge_request.mergeable_with_slash_command?(user, last_diff_sha: mr_sha)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(user, last_diff_sha: mr_sha)).to be_falsey
end
context 'closed MR' do
@@ -1475,7 +1526,7 @@ describe MergeRequest, models: true do
end
it 'is not mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
@@ -1485,19 +1536,19 @@ describe MergeRequest, models: true do
end
it 'is not mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
context 'sha differs from the MR diff_head_sha' do
it 'is not mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: 'some other sha')).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: 'some other sha')).to be_falsey
end
end
context 'sha is not provided' do
it 'is not mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(developer)).to be_falsey
end
end
@@ -1507,7 +1558,7 @@ describe MergeRequest, models: true do
end
it 'is mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy
end
end
@@ -1517,7 +1568,7 @@ describe MergeRequest, models: true do
end
it 'is not mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
@@ -1527,7 +1578,7 @@ describe MergeRequest, models: true do
end
it 'is mergeable' do
- expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
+ expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy
end
end
end
@@ -1535,8 +1586,8 @@ describe MergeRequest, models: true do
describe '#has_commits?' do
before do
- allow(subject.merge_request_diff).to receive(:commits_count).
- and_return(2)
+ allow(subject.merge_request_diff).to receive(:commits_count)
+ .and_return(2)
end
it 'returns true when merge request diff has commits' do
@@ -1546,12 +1597,99 @@ describe MergeRequest, models: true do
describe '#has_no_commits?' do
before do
- allow(subject.merge_request_diff).to receive(:commits_count).
- and_return(0)
+ allow(subject.merge_request_diff).to receive(:commits_count)
+ .and_return(0)
end
it 'returns true when merge request diff has 0 commits' do
expect(subject.has_no_commits?).to be_truthy
end
end
+
+ describe '#merge_request_diff_for' do
+ subject { create(:merge_request, importing: true) }
+ let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
+
+ context 'with diff refs' do
+ it 'returns the diffs' do
+ expect(subject.merge_request_diff_for(merge_request_diff1.diff_refs)).to eq(merge_request_diff1)
+ end
+ end
+
+ context 'with a commit SHA' do
+ it 'returns the diffs' do
+ expect(subject.merge_request_diff_for(merge_request_diff3.head_commit_sha)).to eq(merge_request_diff3)
+ end
+ end
+ end
+
+ describe '#version_params_for' do
+ subject { create(:merge_request, importing: true) }
+ let(:project) { subject.project }
+ let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
+
+ context 'when the diff refs are for an older merge request version' do
+ let(:diff_refs) { merge_request_diff1.diff_refs }
+
+ it 'returns the diff ID for the version to show' do
+ expect(subject.version_params_for(diff_refs)).to eq(diff_id: merge_request_diff1.id)
+ end
+ end
+
+ context 'when the diff refs are for a comparison between merge request versions' do
+ let(:diff_refs) { merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs }
+
+ it 'returns the diff ID and start sha of the versions to compare' do
+ expect(subject.version_params_for(diff_refs)).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha)
+ end
+ end
+
+ context 'when the diff refs are not for a merge request version' do
+ let(:diff_refs) { project.commit(sample_commit.id).diff_refs }
+
+ it 'returns nil' do
+ expect(subject.version_params_for(diff_refs)).to be_nil
+ end
+ end
+ end
+
+ describe '#fetch_ref' do
+ it 'sets "ref_fetched" flag to true' do
+ subject.update!(ref_fetched: nil)
+
+ subject.fetch_ref
+
+ expect(subject.reload.ref_fetched).to be_truthy
+ end
+ end
+
+ describe '#ref_fetched?' do
+ it 'does not perform git operation when value is cached' do
+ subject.ref_fetched = true
+
+ expect_any_instance_of(Repository).not_to receive(:ref_exists?)
+ expect(subject.ref_fetched?).to be_truthy
+ end
+
+ it 'caches the value when ref exists but value is not cached' do
+ subject.update!(ref_fetched: nil)
+ allow_any_instance_of(Repository).to receive(:ref_exists?)
+ .and_return(true)
+
+ expect(subject.ref_fetched?).to be_truthy
+ expect(subject.reload.ref_fetched).to be_truthy
+ end
+
+ it 'returns false when ref does not exist' do
+ subject.update!(ref_fetched: nil)
+ allow_any_instance_of(Repository).to receive(:ref_exists?)
+ .and_return(false)
+
+ expect(subject.ref_fetched?).to be_falsey
+ end
+ end
end
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index e3e8e6d571c..d3da0107d5c 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -1,14 +1,11 @@
require 'spec_helper'
-describe Milestone, models: true do
+describe Milestone do
describe "Validation" do
before do
allow(subject).to receive(:set_iid).and_return(false)
end
- it { is_expected.to validate_presence_of(:title) }
- it { is_expected.to validate_presence_of(:project) }
-
describe 'start_date' do
it 'adds an error when start_date is greated then due_date' do
milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
@@ -24,7 +21,7 @@ describe Milestone, models: true do
it { is_expected.to have_many(:issues) }
end
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:milestone) { create(:milestone, project: project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
@@ -37,17 +34,42 @@ describe Milestone, models: true do
end
end
- describe "unique milestone title per project" do
- it "does not accept the same title in a project twice" do
- new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
- expect(new_milestone).not_to be_valid
+ describe "unique milestone title" do
+ context "per project" do
+ it "does not accept the same title in a project twice" do
+ new_milestone = described_class.new(project: milestone.project, title: milestone.title)
+ expect(new_milestone).not_to be_valid
+ end
+
+ it "accepts the same title in another project" do
+ project = create(:project)
+ new_milestone = described_class.new(project: project, title: milestone.title)
+
+ expect(new_milestone).to be_valid
+ end
end
- it "accepts the same title in another project" do
- project = build(:empty_project)
- new_milestone = Milestone.new(project: project, title: milestone.title)
+ context "per group" do
+ let(:group) { create(:group) }
+ let(:milestone) { create(:milestone, group: group) }
+
+ before do
+ project.update(group: group)
+ end
+
+ it "does not accept the same title in a group twice" do
+ new_milestone = described_class.new(group: group, title: milestone.title)
+
+ expect(new_milestone).not_to be_valid
+ end
+
+ it "does not accept the same title of a child project milestone" do
+ create(:milestone, project: group.projects.first)
+
+ new_milestone = described_class.new(group: group, title: milestone.title)
- expect(new_milestone).to be_valid
+ expect(new_milestone).not_to be_valid
+ end
end
end
@@ -144,35 +166,6 @@ describe Milestone, models: true do
end
end
- describe '#sort_issues' do
- let(:milestone) { create(:milestone) }
-
- let(:issue1) { create(:issue, milestone: milestone, position: 1) }
- let(:issue2) { create(:issue, milestone: milestone, position: 2) }
- let(:issue3) { create(:issue, milestone: milestone, position: 3) }
- let(:issue4) { create(:issue, position: 42) }
-
- it 'sorts the given issues' do
- milestone.sort_issues([issue3.id, issue2.id, issue1.id])
-
- issue1.reload
- issue2.reload
- issue3.reload
-
- expect(issue1.position).to eq(3)
- expect(issue2.position).to eq(2)
- expect(issue3.position).to eq(1)
- end
-
- it 'ignores issues not part of the milestone' do
- milestone.sort_issues([issue3.id, issue2.id, issue1.id, issue4.id])
-
- issue4.reload
-
- expect(issue4.position).to eq(42)
- end
- end
-
describe '.search' do
let(:milestone) { create(:milestone, title: 'foo', description: 'bar') }
@@ -193,20 +186,20 @@ describe Milestone, models: true do
end
it 'returns milestones with a partially matching description' do
- expect(described_class.search(milestone.description[0..2])).
- to eq([milestone])
+ expect(described_class.search(milestone.description[0..2]))
+ .to eq([milestone])
end
it 'returns milestones with a matching description regardless of the casing' do
- expect(described_class.search(milestone.description.upcase)).
- to eq([milestone])
+ expect(described_class.search(milestone.description.upcase))
+ .to eq([milestone])
end
end
describe '.upcoming_ids_by_projects' do
- let(:project_1) { create(:empty_project) }
- let(:project_2) { create(:empty_project) }
- let(:project_3) { create(:empty_project) }
+ let(:project_1) { create(:project) }
+ let(:project_2) { create(:project) }
+ let(:project_3) { create(:project) }
let(:projects) { [project_1, project_2, project_3] }
let!(:past_milestone_project_1) { create(:milestone, project: project_1, due_date: Time.now - 1.day) }
@@ -221,7 +214,7 @@ describe Milestone, models: true do
# The call to `#try` is because this returns a relation with a Postgres DB,
# and an array of IDs with a MySQL DB.
- let(:milestone_ids) { Milestone.upcoming_ids_by_projects(projects).map { |id| id.try(:id) || id } }
+ let(:milestone_ids) { described_class.upcoming_ids_by_projects(projects).map { |id| id.try(:id) || id } }
it 'returns the next upcoming open milestone ID for each project' do
expect(milestone_ids).to contain_exactly(current_milestone_project_1.id, current_milestone_project_2.id)
@@ -237,16 +230,53 @@ describe Milestone, models: true do
end
describe '#to_reference' do
- let(:project) { build(:empty_project, name: 'sample-project') }
- let(:milestone) { build(:milestone, iid: 1, project: project) }
+ let(:group) { build_stubbed(:group) }
+ let(:project) { build_stubbed(:project, name: 'sample-project') }
+ let(:another_project) { build_stubbed(:project, name: 'another-project', namespace: project.namespace) }
+
+ context 'for a project milestone' do
+ let(:milestone) { build_stubbed(:milestone, iid: 1, project: project, name: 'milestone') }
+
+ it 'returns a String reference to the object' do
+ expect(milestone.to_reference).to eq '%1'
+ end
+
+ it 'returns a reference by name when the format is set to :name' do
+ expect(milestone.to_reference(format: :name)).to eq '%"milestone"'
+ end
+
+ it 'supports a cross-project reference' do
+ expect(milestone.to_reference(another_project)).to eq 'sample-project%1'
+ end
+ end
+
+ context 'for a group milestone' do
+ let(:milestone) { build_stubbed(:milestone, iid: 1, group: group, name: 'milestone') }
+
+ it 'returns nil with the default format' do
+ expect(milestone.to_reference).to be_nil
+ end
+
+ it 'returns a reference by name when the format is set to :name' do
+ expect(milestone.to_reference(format: :name)).to eq '%"milestone"'
+ end
- it 'returns a String reference to the object' do
- expect(milestone.to_reference).to eq "%1"
+ it 'does not supports cross-project references' do
+ expect(milestone.to_reference(another_project, format: :name)).to eq '%"milestone"'
+ end
end
+ end
+
+ describe '#participants' do
+ let(:project) { build(:project, name: 'sample-project') }
+ let(:milestone) { build(:milestone, iid: 1, project: project) }
+
+ it 'returns participants without duplicates' do
+ user = create :user
+ create :issue, project: project, milestone: milestone, assignees: [user]
+ create :issue, project: project, milestone: milestone, assignees: [user]
- it 'supports a cross-project reference' do
- another_project = build(:empty_project, name: 'another-project', namespace: project.namespace)
- expect(milestone.to_reference(another_project)).to eq "sample-project%1"
+ expect(milestone.participants).to eq [user]
end
end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index e406d0a16bd..69286eff984 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Namespace, models: true do
+describe Namespace do
let!(:namespace) { create(:namespace) }
describe 'associations' do
@@ -34,6 +34,19 @@ describe Namespace, models: true do
let(:group) { build(:group, :nested, path: 'tree') }
it { expect(group).not_to be_valid }
+
+ it 'rejects nested paths' do
+ parent = create(:group, :nested, path: 'environments')
+ namespace = build(:group, path: 'folders', parent: parent)
+
+ expect(namespace).not_to be_valid
+ end
+ end
+
+ context "is case insensitive" do
+ let(:group) { build(:group, path: "Groups") }
+
+ it { expect(group).not_to be_valid }
end
context 'top-level group' do
@@ -47,6 +60,15 @@ describe Namespace, models: true do
describe "Respond to" do
it { is_expected.to respond_to(:human_name) }
it { is_expected.to respond_to(:to_param) }
+ it { is_expected.to respond_to(:has_parent?) }
+ end
+
+ describe 'inclusions' do
+ it { is_expected.to include_module(Gitlab::VisibilityLevel) }
+ end
+
+ describe '#visibility_level_field' do
+ it { expect(namespace.visibility_level_field).to eq(:visibility_level) }
end
describe '#to_param' do
@@ -89,7 +111,7 @@ describe Namespace, models: true do
let(:namespace) { create :namespace }
let(:project1) do
- create(:empty_project,
+ create(:project,
namespace: namespace,
statistics: build(:project_statistics,
storage_size: 606,
@@ -99,7 +121,7 @@ describe Namespace, models: true do
end
let(:project2) do
- create(:empty_project,
+ create(:project,
namespace: namespace,
statistics: build(:project_statistics,
storage_size: 60,
@@ -111,7 +133,7 @@ describe Namespace, models: true do
it "sums all project storage counters in the namespace" do
project1
project2
- statistics = Namespace.with_statistics.find(namespace.id)
+ statistics = described_class.with_statistics.find(namespace.id)
expect(statistics.storage_size).to eq 666
expect(statistics.repository_size).to eq 111
@@ -120,7 +142,7 @@ describe Namespace, models: true do
end
it "correctly handles namespaces without projects" do
- statistics = Namespace.with_statistics.find(namespace.id)
+ statistics = described_class.with_statistics.find(namespace.id)
expect(statistics.storage_size).to eq 0
expect(statistics.repository_size).to eq 0
@@ -129,7 +151,7 @@ describe Namespace, models: true do
end
end
- describe '#move_dir', repository: true do
+ describe '#move_dir' do
before do
@namespace = create :namespace
@project = create(:project_empty_repo, namespace: @namespace)
@@ -155,7 +177,7 @@ describe Namespace, models: true do
stub_container_registry_config(enabled: true)
stub_container_registry_tags(repository: :any, tags: ['tag'])
- create(:empty_project, namespace: @namespace, container_repositories: [container_repository])
+ create(:project, namespace: @namespace, container_repositories: [container_repository])
allow(@namespace).to receive(:path_was).and_return(@namespace.path)
allow(@namespace).to receive(:path).and_return('new_path')
@@ -170,8 +192,8 @@ describe Namespace, models: true do
let(:parent) { create(:group, name: 'parent', path: 'parent') }
let(:child) { create(:group, name: 'child', path: 'child', parent: parent) }
let!(:project) { create(:project_empty_repo, path: 'the-project', namespace: child) }
- let(:uploads_dir) { File.join(CarrierWave.root, 'uploads') }
- let(:pages_dir) { TestEnv.pages_path }
+ let(:uploads_dir) { File.join(CarrierWave.root, FileUploader.base_dir) }
+ let(:pages_dir) { File.join(TestEnv.pages_path) }
before do
FileUtils.mkdir_p(File.join(uploads_dir, 'parent', 'child', 'the-project'))
@@ -208,7 +230,7 @@ describe Namespace, models: true do
end
end
- describe '#rm_dir', 'callback', repository: true do
+ describe '#rm_dir', 'callback' do
let!(:project) { create(:project_empty_repo, namespace: namespace) }
let(:repository_storage_path) { Gitlab.config.repositories.storages.default['path'] }
let(:path_in_dir) { File.join(repository_storage_path, namespace.full_path) }
@@ -230,8 +252,8 @@ describe Namespace, models: true do
end
context 'in sub-groups' do
- let(:parent) { create(:namespace, path: 'parent') }
- let(:child) { create(:namespace, parent: parent, path: 'child') }
+ let(:parent) { create(:group, path: 'parent') }
+ let(:child) { create(:group, parent: parent, path: 'child') }
let!(:project) { create(:project_empty_repo, namespace: child) }
let(:path_in_dir) { File.join(repository_storage_path, 'parent', 'child') }
let(:deleted_path) { File.join('parent', "child+#{child.id}+deleted") }
@@ -264,9 +286,9 @@ describe Namespace, models: true do
@namespace = create(:namespace, name: 'WoW', path: 'woW')
end
- it { expect(Namespace.find_by_path_or_name('wow')).to eq(@namespace) }
- it { expect(Namespace.find_by_path_or_name('WOW')).to eq(@namespace) }
- it { expect(Namespace.find_by_path_or_name('unknown')).to eq(nil) }
+ it { expect(described_class.find_by_path_or_name('wow')).to eq(@namespace) }
+ it { expect(described_class.find_by_path_or_name('WOW')).to eq(@namespace) }
+ it { expect(described_class.find_by_path_or_name('unknown')).to eq(nil) }
end
describe ".clean_path" do
@@ -274,26 +296,40 @@ describe Namespace, models: true do
let!(:namespace) { create(:namespace, path: "JohnGitLab-etc1") }
it "cleans the path and makes sure it's available" do
- expect(Namespace.clean_path("-john+gitlab-ETC%.git@gmail.com")).to eq("johngitlab-ETC2")
- expect(Namespace.clean_path("--%+--valid_*&%name=.git.%.atom.atom.@email.com")).to eq("valid_name")
+ expect(described_class.clean_path("-john+gitlab-ETC%.git@gmail.com")).to eq("johngitlab-ETC2")
+ expect(described_class.clean_path("--%+--valid_*&%name=.git.%.atom.atom.@email.com")).to eq("valid_name")
end
end
- describe '#ancestors' do
+ describe '#ancestors', :nested_groups do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
let(:deep_nested_group) { create(:group, parent: nested_group) }
let(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
it 'returns the correct ancestors' do
- expect(very_deep_nested_group.ancestors).to eq([group, nested_group, deep_nested_group])
- expect(deep_nested_group.ancestors).to eq([group, nested_group])
- expect(nested_group.ancestors).to eq([group])
+ expect(very_deep_nested_group.ancestors).to include(group, nested_group, deep_nested_group)
+ expect(deep_nested_group.ancestors).to include(group, nested_group)
+ expect(nested_group.ancestors).to include(group)
expect(group.ancestors).to eq([])
end
end
- describe '#descendants' do
+ describe '#self_and_ancestors', :nested_groups do
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+ let(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+
+ it 'returns the correct ancestors' do
+ expect(very_deep_nested_group.self_and_ancestors).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
+ expect(deep_nested_group.self_and_ancestors).to contain_exactly(group, nested_group, deep_nested_group)
+ expect(nested_group.self_and_ancestors).to contain_exactly(group, nested_group)
+ expect(group.self_and_ancestors).to contain_exactly(group)
+ end
+ end
+
+ describe '#descendants', :nested_groups do
let!(:group) { create(:group, path: 'git_lab') }
let!(:nested_group) { create(:group, parent: group) }
let!(:deep_nested_group) { create(:group, parent: nested_group) }
@@ -303,16 +339,62 @@ describe Namespace, models: true do
it 'returns the correct descendants' do
expect(very_deep_nested_group.descendants.to_a).to eq([])
- expect(deep_nested_group.descendants.to_a).to eq([very_deep_nested_group])
- expect(nested_group.descendants.to_a).to eq([deep_nested_group, very_deep_nested_group])
- expect(group.descendants.to_a).to eq([nested_group, deep_nested_group, very_deep_nested_group])
+ expect(deep_nested_group.descendants.to_a).to include(very_deep_nested_group)
+ expect(nested_group.descendants.to_a).to include(deep_nested_group, very_deep_nested_group)
+ expect(group.descendants.to_a).to include(nested_group, deep_nested_group, very_deep_nested_group)
+ end
+ end
+
+ describe '#self_and_descendants', :nested_groups do
+ let!(:group) { create(:group, path: 'git_lab') }
+ let!(:nested_group) { create(:group, parent: group) }
+ let!(:deep_nested_group) { create(:group, parent: nested_group) }
+ let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+ let!(:another_group) { create(:group, path: 'gitllab') }
+ let!(:another_group_nested) { create(:group, path: 'foo', parent: another_group) }
+
+ it 'returns the correct descendants' do
+ expect(very_deep_nested_group.self_and_descendants).to contain_exactly(very_deep_nested_group)
+ expect(deep_nested_group.self_and_descendants).to contain_exactly(deep_nested_group, very_deep_nested_group)
+ expect(nested_group.self_and_descendants).to contain_exactly(nested_group, deep_nested_group, very_deep_nested_group)
+ expect(group.self_and_descendants).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
+ end
+ end
+
+ describe '#users_with_descendants', :nested_groups do
+ let(:user_a) { create(:user) }
+ let(:user_b) { create(:user) }
+
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+
+ it 'returns member users on every nest level without duplication' do
+ group.add_developer(user_a)
+ nested_group.add_developer(user_b)
+ deep_nested_group.add_developer(user_a)
+
+ expect(group.users_with_descendants).to contain_exactly(user_a, user_b)
+ expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b)
+ expect(deep_nested_group.users_with_descendants).to contain_exactly(user_a)
+ end
+ end
+
+ describe '#soft_delete_without_removing_associations' do
+ let(:project1) { create(:project_empty_repo, namespace: namespace) }
+
+ it 'updates the deleted_at timestamp but preserves projects' do
+ namespace.soft_delete_without_removing_associations
+
+ expect(Project.all).to include(project1)
+ expect(namespace.deleted_at).not_to be_nil
end
end
describe '#user_ids_for_project_authorizations' do
it 'returns the user IDs for which to refresh authorizations' do
- expect(namespace.user_ids_for_project_authorizations).
- to eq([namespace.owner_id])
+ expect(namespace.user_ids_for_project_authorizations)
+ .to eq([namespace.owner_id])
end
end
diff --git a/spec/models/network/graph_spec.rb b/spec/models/network/graph_spec.rb
index 46b36e11c23..c364dd6643b 100644
--- a/spec/models/network/graph_spec.rb
+++ b/spec/models/network/graph_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Network::Graph, models: true do
+describe Network::Graph do
let(:project) { create(:project, :repository) }
let!(:note_on_commit) { create(:note_on_commit, project: project) }
@@ -10,17 +10,17 @@ describe Network::Graph, models: true do
expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } )
end
- describe "#commits" do
+ describe '#commits' do
let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) }
- it "returns a list of commits" do
+ it 'returns a list of commits' do
commits = graph.commits
expect(commits).not_to be_empty
expect(commits).to all( be_kind_of(Network::Commit) )
end
- it "sorts the commits by commit date (descending)" do
+ it 'it the commits by commit date (descending)' do
# Remove duplicate timestamps because they make it harder to
# assert that the commits are sorted as expected.
commits = graph.commits.uniq(&:date)
@@ -29,5 +29,20 @@ describe Network::Graph, models: true do
expect(commits).not_to be_empty
expect(commits.map(&:id)).to eq(sorted_commits.map(&:id))
end
+
+ it 'sorts children before parents for commits with the same timestamp' do
+ commits_by_time = graph.commits.group_by(&:date)
+
+ commits_by_time.each do |time, commits|
+ commit_ids = commits.map(&:id)
+
+ commits.each_with_index do |commit, index|
+ parent_indexes = commit.parent_ids.map { |parent_id| commit_ids.find_index(parent_id) }.compact
+
+ # All parents of the current commit should appear after it
+ expect(parent_indexes).to all( be > index )
+ end
+ end
+ end
end
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 557ea97b008..b214074fdce 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Note, models: true do
+describe Note do
include RepoHelpers
describe 'associations' do
@@ -26,14 +26,18 @@ describe Note, models: true do
it { is_expected.to validate_presence_of(:project) }
context 'when note is on commit' do
- before { allow(subject).to receive(:for_commit?).and_return(true) }
+ before do
+ allow(subject).to receive(:for_commit?).and_return(true)
+ end
it { is_expected.to validate_presence_of(:commit_id) }
it { is_expected.not_to validate_presence_of(:noteable_id) }
end
context 'when note is not on commit' do
- before { allow(subject).to receive(:for_commit?).and_return(false) }
+ before do
+ allow(subject).to receive(:for_commit?).and_return(false)
+ end
it { is_expected.not_to validate_presence_of(:commit_id) }
it { is_expected.to validate_presence_of(:noteable_id) }
@@ -42,7 +46,7 @@ describe Note, models: true do
context 'when noteable and note project differ' do
subject do
build(:note, noteable: build_stubbed(:issue),
- project: build_stubbed(:empty_project))
+ project: build_stubbed(:project))
end
it { is_expected.to be_invalid }
@@ -93,8 +97,8 @@ describe Note, models: true do
describe 'authorization' do
before do
- @p1 = create(:empty_project)
- @p2 = create(:empty_project)
+ @p1 = create(:project)
+ @p2 = create(:project)
@u1 = create(:user)
@u2 = create(:user)
@u3 = create(:user)
@@ -148,8 +152,8 @@ describe Note, models: true do
let!(:note2) { create(:note_on_issue) }
it "reads the rendered note body from the cache" do
- expect(Banzai::Renderer).to receive(:cache_collection_render).
- with([{
+ expect(Banzai::Renderer).to receive(:cache_collection_render)
+ .with([{
text: note1.note,
context: {
skip_project_check: false,
@@ -160,8 +164,8 @@ describe Note, models: true do
}
}]).and_call_original
- expect(Banzai::Renderer).to receive(:cache_collection_render).
- with([{
+ expect(Banzai::Renderer).to receive(:cache_collection_render)
+ .with([{
text: note2.note,
context: {
skip_project_check: false,
@@ -191,10 +195,10 @@ describe Note, models: true do
describe "cross_reference_not_visible_for?" do
let(:private_user) { create(:user) }
- let(:private_project) { create(:empty_project, namespace: private_user.namespace) { |p| p.team << [private_user, :master] } }
+ let(:private_project) { create(:project, namespace: private_user.namespace) { |p| p.team << [private_user, :master] } }
let(:private_issue) { create(:issue, project: private_project) }
- let(:ext_proj) { create(:empty_project, :public) }
+ let(:ext_proj) { create(:project, :public) }
let(:ext_issue) { create(:issue, project: ext_proj) }
let(:note) do
@@ -237,7 +241,7 @@ describe Note, models: true do
describe '#participants' do
it 'includes the note author' do
- project = create(:empty_project, :public)
+ project = create(:project, :public)
issue = create(:issue, project: project)
note = create(:note_on_issue, noteable: issue, project: project)
@@ -272,9 +276,9 @@ describe Note, models: true 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
+ old_line: nil,
+ new_line: 13,
+ diff_refs: project.commit(sample_commit.id).diff_refs
)
end
@@ -288,26 +292,78 @@ describe Note, models: true do
)
end
- subject { merge_request.notes.grouped_diff_discussions }
+ context 'active diff discussions' do
+ subject { merge_request.notes.grouped_diff_discussions }
- it "includes active discussions" do
- discussions = subject.values.flatten
+ it "includes active discussions" do
+ 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])
- expect(discussions.all?(&:active?)).to be true
+ 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
+ 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(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
+ it "doesn't include outdated discussions" do
+ 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].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
- it "groups the discussions by line code" do
- 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)
+ context 'diff discussions for older diff refs' do
+ subject { merge_request.notes.grouped_diff_discussions(diff_refs) }
+
+ context 'for diff refs a discussion was created at' do
+ let(:diff_refs) { active_position2.diff_refs }
+
+ it "includes discussions that were created then" do
+ discussions = subject.values.flatten
+
+ expect(discussions.count).to eq(1)
+
+ discussion = discussions.first
+
+ expect(discussion.id).to eq(active_diff_note3.discussion_id)
+ expect(discussion.active?).to be true
+ expect(discussion.active?(diff_refs)).to be false
+ expect(discussion.created_at_diff?(diff_refs)).to be true
+
+ expect(discussion.notes).to eq([active_diff_note3])
+ end
+
+ it "groups the discussions by original line code" do
+ expect(subject[active_diff_note3.original_line_code].first.id).to eq(active_diff_note3.discussion_id)
+ end
+ end
+
+ context 'for diff refs a discussion was last active at' do
+ let(:diff_refs) { outdated_position.diff_refs }
+
+ it "includes discussions that were last active" do
+ discussions = subject.values.flatten
+
+ expect(discussions.count).to eq(1)
+
+ discussion = discussions.first
+
+ expect(discussion.id).to eq(outdated_diff_note1.discussion_id)
+ expect(discussion.active?).to be false
+ expect(discussion.active?(diff_refs)).to be true
+ expect(discussion.created_at_diff?(diff_refs)).to be true
+
+ expect(discussion.notes).to eq([outdated_diff_note1, outdated_diff_note2])
+ end
+
+ it "groups the discussions by line code" do
+ expect(subject[outdated_diff_note1.line_code].first.id).to eq(outdated_diff_note1.discussion_id)
+ end
+ end
end
end
@@ -350,8 +406,8 @@ describe Note, models: true do
let(:note) { build(:note_on_project_snippet) }
before do
- expect(Banzai::Renderer).to receive(:cacheless_render_field).
- with(note, :note, { skip_project_check: false }).and_return(html)
+ expect(Banzai::Renderer).to receive(:cacheless_render_field)
+ .with(note, :note, { skip_project_check: false }).and_return(html)
note.save
end
@@ -365,8 +421,8 @@ describe Note, models: true do
let(:note) { build(:note_on_personal_snippet) }
before do
- expect(Banzai::Renderer).to receive(:cacheless_render_field).
- with(note, :note, { skip_project_check: true }).and_return(html)
+ expect(Banzai::Renderer).to receive(:cacheless_render_field)
+ .with(note, :note, { skip_project_check: true }).and_return(html)
note.save
end
@@ -469,7 +525,7 @@ describe Note, models: true do
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)
+ reloaded_note = described_class.find(note.id)
expect(reloaded_note.discussion_id).not_to be_nil
expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb
index d58673413c8..2a0d102d3fe 100644
--- a/spec/models/notification_setting_spec.rb
+++ b/spec/models/notification_setting_spec.rb
@@ -1,21 +1,28 @@
require 'rails_helper'
-RSpec.describe NotificationSetting, type: :model do
+RSpec.describe NotificationSetting do
describe "Associations" do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:source) }
end
describe "Validation" do
- subject { NotificationSetting.new(source_id: 1, source_type: 'Project') }
+ subject { described_class.new(source_id: 1, source_type: 'Project') }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:level) }
- it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:source_id, :source_type]).with_message(/already exists in source/) }
+
+ describe 'user_id' do
+ before do
+ subject.user = create(:user)
+ end
+
+ it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:source_type, :source_id]).with_message(/already exists in source/) }
+ end
context "events" do
let(:user) { create(:user) }
- let(:notification_setting) { NotificationSetting.new(source_id: 1, source_type: 'Project', user_id: user.id) }
+ let(:notification_setting) { described_class.new(source_id: 1, source_type: 'Project', user_id: user.id) }
before do
notification_setting.level = "custom"
@@ -55,4 +62,30 @@ RSpec.describe NotificationSetting, type: :model do
expect(user.notification_settings.for_projects.map(&:project)).to all(have_attributes(pending_delete: false))
end
end
+
+ describe '#event_enabled?' do
+ before do
+ subject.update!(user: create(:user))
+ end
+
+ context 'for an event with a matching column name' do
+ it 'returns the value of the column' do
+ subject.update!(new_note: true)
+
+ expect(subject.event_enabled?(:new_note)).to be(true)
+ end
+
+ context 'when the column has a nil value' do
+ it 'returns false' do
+ expect(subject.event_enabled?(:new_note)).to be(false)
+ end
+ end
+ end
+
+ context 'for an event without a matching column name' do
+ it 'returns false' do
+ expect(subject.event_enabled?(:foo_event)).to be(false)
+ end
+ end
+ end
end
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index c6c45d78990..7d835511dfb 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -1,49 +1,38 @@
require 'spec_helper'
-describe PagesDomain, models: true do
+describe PagesDomain do
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validate domain' do
- subject { build(:pages_domain, domain: domain) }
+ subject(:pages_domain) { build(:pages_domain, domain: domain) }
context 'is unique' do
let(:domain) { 'my.domain.com' }
- it { is_expected.to validate_uniqueness_of(:domain) }
+ it { is_expected.to validate_uniqueness_of(:domain).case_insensitive }
end
- context 'valid domain' do
- let(:domain) { 'my.domain.com' }
-
- it { is_expected.to be_valid }
- end
-
- context 'valid hexadecimal-looking domain' do
- let(:domain) { '0x12345.com'}
+ {
+ 'my.domain.com' => true,
+ '123.456.789' => true,
+ '0x12345.com' => true,
+ '0123123' => true,
+ '_foo.com' => false,
+ 'reserved.com' => false,
+ 'a.reserved.com' => false,
+ nil => false
+ }.each do |value, validity|
+ context "domain #{value.inspect} validity" do
+ before do
+ allow(Settings.pages).to receive(:host).and_return('reserved.com')
+ end
- it { is_expected.to be_valid }
- end
-
- context 'no domain' do
- let(:domain) { nil }
+ let(:domain) { value }
- it { is_expected.not_to be_valid }
- end
-
- context 'invalid domain' do
- let(:domain) { '0123123' }
-
- it { is_expected.not_to be_valid }
- end
-
- context 'domain from .example.com' do
- let(:domain) { 'my.domain.com' }
-
- before { allow(Settings.pages).to receive(:host).and_return('domain.com') }
-
- it { is_expected.not_to be_valid }
+ it { expect(pages_domain.valid?).to eq(validity) }
+ end
end
end
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index 823623d96fa..b2f2a3ce914 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe PersonalAccessToken, models: true do
+describe PersonalAccessToken do
describe '.build' do
let(:personal_access_token) { build(:personal_access_token) }
let(:invalid_personal_access_token) { build(:personal_access_token, :invalid) }
@@ -35,6 +35,16 @@ describe PersonalAccessToken, models: true do
end
end
+ describe 'revoke!' do
+ let(:active_personal_access_token) { create(:personal_access_token) }
+
+ it 'revokes the token' do
+ active_personal_access_token.revoke!
+
+ expect(active_personal_access_token.revoked?).to be true
+ end
+ end
+
context "validations" do
let(:personal_access_token) { build(:personal_access_token) }
@@ -51,11 +61,17 @@ describe PersonalAccessToken, models: true do
expect(personal_access_token).to be_valid
end
- it "rejects creating a token with non-API scopes" do
+ it "allows creating a token with read_registry scope" do
+ personal_access_token.scopes = [:read_registry]
+
+ expect(personal_access_token).to be_valid
+ end
+
+ it "rejects creating a token with unavailable scopes" do
personal_access_token.scopes = [:openid, :api]
expect(personal_access_token).not_to be_valid
- expect(personal_access_token.errors[:scopes].first).to eq "can only contain API scopes"
+ expect(personal_access_token.errors[:scopes].first).to eq "can only contain available scopes"
end
end
end
diff --git a/spec/models/project_authorization_spec.rb b/spec/models/project_authorization_spec.rb
index 33ef67f97a7..9e7e525b2c0 100644
--- a/spec/models/project_authorization_spec.rb
+++ b/spec/models/project_authorization_spec.rb
@@ -2,13 +2,13 @@ require 'spec_helper'
describe ProjectAuthorization do
let(:user) { create(:user) }
- let(:project1) { create(:empty_project) }
- let(:project2) { create(:empty_project) }
+ let(:project1) { create(:project) }
+ let(:project2) { create(:project) }
describe '.insert_authorizations' do
it 'inserts the authorizations' do
- described_class.
- insert_authorizations([[user.id, project1.id, Gitlab::Access::MASTER]])
+ described_class
+ .insert_authorizations([[user.id, project1.id, Gitlab::Access::MASTER]])
expect(user.project_authorizations.count).to eq(1)
end
@@ -16,7 +16,7 @@ describe ProjectAuthorization do
it 'inserts rows in batches' do
described_class.insert_authorizations([
[user.id, project1.id, Gitlab::Access::MASTER],
- [user.id, project2.id, Gitlab::Access::MASTER],
+ [user.id, project2.id, Gitlab::Access::MASTER]
], 1)
expect(user.project_authorizations.count).to eq(2)
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index 09a4448d387..de3e86b627f 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -1,9 +1,21 @@
require 'spec_helper'
describe ProjectFeature do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
+ describe '.quoted_access_level_column' do
+ it 'returns the table name and quoted column name for a feature' do
+ expected = if Gitlab::Database.postgresql?
+ '"project_features"."issues_access_level"'
+ else
+ '`project_features`.`issues_access_level`'
+ end
+
+ expect(described_class.quoted_access_level_column(:issues)).to eq(expected)
+ end
+ end
+
describe '#feature_available?' do
let(:features) { %w(issues wiki builds merge_requests snippets repository) }
@@ -35,7 +47,7 @@ describe ProjectFeature do
it "returns true when user is a member of project group" do
group = create(:group)
- project = create(:empty_project, namespace: group)
+ project = create(:project, namespace: group)
group.add_developer(user)
features.each do |feature|
diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb
index 9b711bfc007..b3513c80150 100644
--- a/spec/models/project_group_link_spec.rb
+++ b/spec/models/project_group_link_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
describe ProjectGroupLink do
describe "Associations" do
- it { should belong_to(:group) }
- it { should belong_to(:project) }
+ it { is_expected.to belong_to(:group) }
+ it { is_expected.to belong_to(:project) }
end
describe "Validation" do
@@ -12,10 +12,10 @@ describe ProjectGroupLink do
let(:project) { create(:project, group: group) }
let!(:project_group_link) { create(:project_group_link, project: project) }
- it { should validate_presence_of(:project_id) }
- it { should validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) }
- it { should validate_presence_of(:group) }
- it { should validate_presence_of(:group_access) }
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) }
+ it { is_expected.to validate_presence_of(:group) }
+ it { is_expected.to validate_presence_of(:group_access) }
it "doesn't allow a project to be shared with the group it is in" do
project_group_link.group = group
@@ -23,7 +23,7 @@ describe ProjectGroupLink do
expect(project_group_link).not_to be_valid
end
- it "doesn't allow a project to be shared with an ancestor of the group it is in" do
+ it "doesn't allow a project to be shared with an ancestor of the group it is in", :nested_groups do
project_group_link.group = parent_group
expect(project_group_link).not_to be_valid
@@ -32,7 +32,7 @@ describe ProjectGroupLink do
describe "destroying a record", truncate: true do
it "refreshes group users' authorized projects" do
- project = create(:empty_project, :private)
+ project = create(:project, :private)
group = create(:group)
reporter = create(:user)
group_users = group.users
diff --git a/spec/models/project_label_spec.rb b/spec/models/project_label_spec.rb
index 9cdbfa44e5b..689d4e505e5 100644
--- a/spec/models/project_label_spec.rb
+++ b/spec/models/project_label_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProjectLabel, models: true do
+describe ProjectLabel do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
end
@@ -10,7 +10,7 @@ describe ProjectLabel, models: true do
context 'validates if title must not exist at group level' do
let(:group) { create(:group, name: 'gitlab-org') }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
before do
create(:group_label, group: group, title: 'Bug')
@@ -33,7 +33,7 @@ describe ProjectLabel, models: true do
end
it 'does not returns error if project does not belong to group' do
- another_project = create(:empty_project)
+ another_project = create(:project)
label = described_class.new(project: another_project, title: 'Bug')
label.valid?
@@ -66,7 +66,7 @@ describe ProjectLabel, models: true do
describe '#subject' do
it 'aliases project to subject' do
- subject = described_class.new(project: build(:empty_project))
+ subject = described_class.new(project: build(:project))
expect(subject.subject).to be(subject.project)
end
@@ -100,19 +100,19 @@ describe ProjectLabel, models: true do
end
context 'cross project reference' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'using name' do
it 'returns cross reference with label name' do
expect(label.to_reference(project, format: :name))
- .to eq %Q(#{label.project.path_with_namespace}~"#{label.name}")
+ .to eq %Q(#{label.project.full_path}~"#{label.name}")
end
end
context 'using id' do
it 'returns cross reference with label id' do
expect(label.to_reference(project, format: :id))
- .to eq %Q(#{label.project.path_with_namespace}~#{label.id})
+ .to eq %Q(#{label.project.full_path}~#{label.id})
end
end
end
diff --git a/spec/models/project_services/asana_service_spec.rb b/spec/models/project_services/asana_service_spec.rb
index 48aef3a93f2..04440d890aa 100644
--- a/spec/models/project_services/asana_service_spec.rb
+++ b/spec/models/project_services/asana_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe AsanaService, models: true do
+describe AsanaService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -18,7 +18,7 @@ describe AsanaService, models: true do
describe 'Execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
def create_data_for_commits(*messages)
{
@@ -28,14 +28,14 @@ describe AsanaService, models: true do
commits: messages.map do |m|
{
message: m,
- url: 'https://gitlab.com/',
+ url: 'https://gitlab.com/'
}
end
}
end
before do
- @asana = AsanaService.new
+ @asana = described_class.new
allow(@asana).to receive_messages(
project: project,
project_id: project.id,
diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb
index 96f00af898e..5cb6d63659e 100644
--- a/spec/models/project_services/assembla_service_spec.rb
+++ b/spec/models/project_services/assembla_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe AssemblaService, models: true do
+describe AssemblaService do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -11,7 +11,7 @@ describe AssemblaService, models: true do
let(:project) { create(:project, :repository) }
before do
- @assembla_service = AssemblaService.new
+ @assembla_service = described_class.new
allow(@assembla_service).to receive_messages(
project_id: project.id,
project: project,
diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb
index 4014d6129ee..85baaccf035 100644
--- a/spec/models/project_services/bamboo_service_spec.rb
+++ b/spec/models/project_services/bamboo_service_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe BambooService, models: true, caching: true do
+describe BambooService, :use_clean_rails_memory_store_caching do
include ReactiveCachingHelpers
let(:bamboo_url) { 'http://gitlab.com/bamboo' }
subject(:service) do
described_class.create(
- project: create(:empty_project),
+ project: create(:project),
properties: {
bamboo_url: bamboo_url,
username: 'mic',
@@ -24,7 +24,9 @@ describe BambooService, models: true, caching: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:build_key) }
it { is_expected.to validate_presence_of(:bamboo_url) }
@@ -60,7 +62,9 @@ describe BambooService, models: true, caching: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:build_key) }
it { is_expected.not_to validate_presence_of(:bamboo_url) }
@@ -213,13 +217,13 @@ describe BambooService, models: true, caching: true do
end
def stub_request(status: 200, body: nil)
- bamboo_full_url = 'http://mic:password@gitlab.com/bamboo/rest/api/latest/result?label=123&os_authType=basic'
+ bamboo_full_url = 'http://gitlab.com/bamboo/rest/api/latest/result?label=123&os_authType=basic'
WebMock.stub_request(:get, bamboo_full_url).to_return(
status: status,
headers: { 'Content-Type' => 'application/json' },
body: body
- )
+ ).with(basic_auth: %w(mic password))
end
def bamboo_response(result_key: 42, build_state: 'success', size: 1)
diff --git a/spec/models/project_services/bugzilla_service_spec.rb b/spec/models/project_services/bugzilla_service_spec.rb
index 739cc72b2ff..43f7bcb1a19 100644
--- a/spec/models/project_services/bugzilla_service_spec.rb
+++ b/spec/models/project_services/bugzilla_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe BugzillaService, models: true do
+describe BugzillaService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,7 +8,9 @@ describe BugzillaService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:project_url) }
it { is_expected.to validate_presence_of(:issues_url) }
@@ -19,7 +21,9 @@ describe BugzillaService, models: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:project_url) }
it { is_expected.not_to validate_presence_of(:issues_url) }
diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb
index 05b602d8106..1615a93a4ca 100644
--- a/spec/models/project_services/buildkite_service_spec.rb
+++ b/spec/models/project_services/buildkite_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe BuildkiteService, models: true, caching: true do
+describe BuildkiteService, :use_clean_rails_memory_store_caching do
include ReactiveCachingHelpers
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject(:service) do
described_class.create(
@@ -23,7 +23,9 @@ describe BuildkiteService, models: true, caching: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:project_url) }
it { is_expected.to validate_presence_of(:token) }
@@ -31,7 +33,9 @@ describe BuildkiteService, models: true, caching: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:project_url) }
it { is_expected.not_to validate_presence_of(:token) }
diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb
index 953e664fb66..ed8347edffd 100644
--- a/spec/models/project_services/campfire_service_spec.rb
+++ b/spec/models/project_services/campfire_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CampfireService, models: true do
+describe CampfireService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,13 +8,17 @@ describe CampfireService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
end
@@ -25,7 +29,7 @@ describe CampfireService, models: true do
let(:project) { create(:project, :repository) }
before do
- @campfire_service = CampfireService.new
+ @campfire_service = described_class.new
allow(@campfire_service).to receive_messages(
project_id: project.id,
project: project,
@@ -35,21 +39,22 @@ describe CampfireService, models: true do
room: 'test-room'
)
@sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
- @rooms_url = 'https://verySecret:X@project-name.campfirenow.com/rooms.json'
+ @rooms_url = 'https://project-name.campfirenow.com/rooms.json'
+ @auth = %w(verySecret X)
@headers = { 'Content-Type' => 'application/json; charset=utf-8' }
end
it "calls Campfire API to get a list of rooms and speak in a room" do
# make sure a valid list of rooms is returned
body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms.json')
- WebMock.stub_request(:get, @rooms_url).to_return(
+ WebMock.stub_request(:get, @rooms_url).with(basic_auth: @auth).to_return(
body: body,
status: 200,
headers: @headers
)
# stub the speak request with the room id found in the previous request's response
- speak_url = 'https://verySecret:X@project-name.campfirenow.com/room/123/speak.json'
- WebMock.stub_request(:post, speak_url)
+ speak_url = 'https://project-name.campfirenow.com/room/123/speak.json'
+ WebMock.stub_request(:post, speak_url).with(basic_auth: @auth)
@campfire_service.execute(@sample_data)
@@ -62,7 +67,7 @@ describe CampfireService, models: true do
it "calls Campfire API to get a list of rooms but shouldn't speak in a room" do
# return a list of rooms that do not contain a room named 'test-room'
body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms2.json')
- WebMock.stub_request(:get, @rooms_url).to_return(
+ WebMock.stub_request(:get, @rooms_url).with(basic_auth: @auth).to_return(
body: body,
status: 200,
headers: @headers
diff --git a/spec/models/project_services/chat_message/issue_message_spec.rb b/spec/models/project_services/chat_message/issue_message_spec.rb
index 34e2d94b1ed..4bb1db684e6 100644
--- a/spec/models/project_services/chat_message/issue_message_spec.rb
+++ b/spec/models/project_services/chat_message/issue_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatMessage::IssueMessage, models: true do
+describe ChatMessage::IssueMessage do
subject { described_class.new(args) }
let(:args) do
@@ -48,7 +48,7 @@ describe ChatMessage::IssueMessage, models: true do
title: "#100 Issue title",
title_link: "http://url.com",
text: "issue description",
- color: color,
+ color: color
}
])
end
diff --git a/spec/models/project_services/chat_message/merge_message_spec.rb b/spec/models/project_services/chat_message/merge_message_spec.rb
index fa0a1f4a5b7..b600a36f578 100644
--- a/spec/models/project_services/chat_message/merge_message_spec.rb
+++ b/spec/models/project_services/chat_message/merge_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatMessage::MergeMessage, models: true do
+describe ChatMessage::MergeMessage do
subject { described_class.new(args) }
let(:args) do
@@ -22,7 +22,7 @@ describe ChatMessage::MergeMessage, models: true do
state: 'opened',
description: 'merge request description',
source_branch: 'source_branch',
- target_branch: 'target_branch',
+ target_branch: 'target_branch'
}
}
end
diff --git a/spec/models/project_services/chat_message/note_message_spec.rb b/spec/models/project_services/chat_message/note_message_spec.rb
index 7cd9c61ee2b..a09c2f9935c 100644
--- a/spec/models/project_services/chat_message/note_message_spec.rb
+++ b/spec/models/project_services/chat_message/note_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatMessage::NoteMessage, models: true do
+describe ChatMessage::NoteMessage do
subject { described_class.new(args) }
let(:color) { '#345' }
@@ -15,7 +15,7 @@ describe ChatMessage::NoteMessage, models: true do
project_url: 'http://somewhere.com',
repository: {
name: 'project_name',
- url: 'http://somewhere.com',
+ url: 'http://somewhere.com'
},
object_attributes: {
id: 10,
diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb
index ec5c6c5e0ed..43b02568cb9 100644
--- a/spec/models/project_services/chat_message/pipeline_message_spec.rb
+++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb
@@ -4,6 +4,7 @@ describe ChatMessage::PipelineMessage do
subject { described_class.new(args) }
let(:user) { { name: 'hacker' } }
+ let(:duration) { 7210 }
let(:args) do
{
object_attributes: {
@@ -26,7 +27,6 @@ describe ChatMessage::PipelineMessage do
context 'pipeline succeeded' do
let(:status) { 'success' }
let(:color) { 'good' }
- let(:duration) { 10 }
let(:message) { build_message('passed') }
it 'returns a message with information about succeeded build' do
@@ -39,7 +39,6 @@ describe ChatMessage::PipelineMessage do
context 'pipeline failed' do
let(:status) { 'failed' }
let(:color) { 'danger' }
- let(:duration) { 10 }
let(:message) { build_message }
it 'returns a message with information about failed build' do
@@ -63,8 +62,8 @@ describe ChatMessage::PipelineMessage do
def build_message(status_text = status, name = user[:name])
"<http://example.gitlab.com|project_name>:" \
" Pipeline <http://example.gitlab.com/pipelines/123|#123>" \
- " of <http://example.gitlab.com/commits/develop|develop> branch" \
- " by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}"
+ " of branch <http://example.gitlab.com/commits/develop|develop>" \
+ " by #{name} #{status_text} in 02:00:10"
end
end
@@ -76,16 +75,15 @@ describe ChatMessage::PipelineMessage do
context 'pipeline succeeded' do
let(:status) { 'success' }
let(:color) { 'good' }
- let(:duration) { 10 }
let(:message) { build_markdown_message('passed') }
it 'returns a message with information about succeeded build' do
expect(subject.pretext).to be_empty
expect(subject.attachments).to eq(message)
expect(subject.activity).to eq({
- title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed',
+ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch [develop](http://example.gitlab.com/commits/develop) by hacker passed',
subtitle: 'in [project_name](http://example.gitlab.com)',
- text: 'in 10 seconds',
+ text: 'in 02:00:10',
image: ''
})
end
@@ -94,16 +92,15 @@ describe ChatMessage::PipelineMessage do
context 'pipeline failed' do
let(:status) { 'failed' }
let(:color) { 'danger' }
- let(:duration) { 10 }
let(:message) { build_markdown_message }
it 'returns a message with information about failed build' do
expect(subject.pretext).to be_empty
expect(subject.attachments).to eq(message)
expect(subject.activity).to eq({
- title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed',
+ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch [develop](http://example.gitlab.com/commits/develop) by hacker failed',
subtitle: 'in [project_name](http://example.gitlab.com)',
- text: 'in 10 seconds',
+ text: 'in 02:00:10',
image: ''
})
end
@@ -116,9 +113,9 @@ describe ChatMessage::PipelineMessage do
expect(subject.pretext).to be_empty
expect(subject.attachments).to eq(message)
expect(subject.activity).to eq({
- title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed',
+ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch [develop](http://example.gitlab.com/commits/develop) by API failed',
subtitle: 'in [project_name](http://example.gitlab.com)',
- text: 'in 10 seconds',
+ text: 'in 02:00:10',
image: ''
})
end
@@ -128,8 +125,8 @@ describe ChatMessage::PipelineMessage do
def build_markdown_message(status_text = status, name = user[:name])
"[project_name](http://example.gitlab.com):" \
" Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of [develop](http://example.gitlab.com/commits/develop)" \
- " branch by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}"
+ " of branch [develop](http://example.gitlab.com/commits/develop)" \
+ " by #{name} #{status_text} in 02:00:10"
end
end
end
diff --git a/spec/models/project_services/chat_message/push_message_spec.rb b/spec/models/project_services/chat_message/push_message_spec.rb
index 63eb078c44e..19c2862264f 100644
--- a/spec/models/project_services/chat_message/push_message_spec.rb
+++ b/spec/models/project_services/chat_message/push_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatMessage::PushMessage, models: true do
+describe ChatMessage::PushMessage do
subject { described_class.new(args) }
let(:args) do
@@ -21,7 +21,7 @@ describe ChatMessage::PushMessage, models: true do
before do
args[:commits] = [
{ message: 'message1', url: 'http://url1.com', id: 'abcdefghijkl', author: { name: 'author1' } },
- { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } },
+ { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } }
]
end
@@ -33,7 +33,7 @@ describe ChatMessage::PushMessage, models: true do
expect(subject.attachments).to eq([{
text: "<http://url1.com|abcdefgh>: message1 - author1\n\n"\
"<http://url2.com|12345678>: message2 - author2",
- color: color,
+ color: color
}])
end
end
diff --git a/spec/models/project_services/chat_message/wiki_page_message_spec.rb b/spec/models/project_services/chat_message/wiki_page_message_spec.rb
index 0df7db2abc2..c4adee4f489 100644
--- a/spec/models/project_services/chat_message/wiki_page_message_spec.rb
+++ b/spec/models/project_services/chat_message/wiki_page_message_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatMessage::WikiPageMessage, models: true do
+describe ChatMessage::WikiPageMessage do
subject { described_class.new(args) }
let(:args) do
@@ -23,7 +23,9 @@ describe ChatMessage::WikiPageMessage, models: true do
context 'without markdown' do
describe '#pretext' do
context 'when :action == "create"' do
- before { args[:object_attributes][:action] = 'create' }
+ before do
+ args[:object_attributes][:action] = 'create'
+ end
it 'returns a message that a new wiki page was created' do
expect(subject.pretext).to eq(
@@ -33,7 +35,9 @@ describe ChatMessage::WikiPageMessage, models: true do
end
context 'when :action == "update"' do
- before { args[:object_attributes][:action] = 'update' }
+ before do
+ args[:object_attributes][:action] = 'update'
+ end
it 'returns a message that a wiki page was updated' do
expect(subject.pretext).to eq(
@@ -47,26 +51,30 @@ describe ChatMessage::WikiPageMessage, models: true do
let(:color) { '#345' }
context 'when :action == "create"' do
- before { args[:object_attributes][:action] = 'create' }
+ before do
+ args[:object_attributes][:action] = 'create'
+ end
it 'returns the attachment for a new wiki page' do
expect(subject.attachments).to eq([
{
text: "Wiki page description",
- color: color,
+ color: color
}
])
end
end
context 'when :action == "update"' do
- before { args[:object_attributes][:action] = 'update' }
+ before do
+ args[:object_attributes][:action] = 'update'
+ end
it 'returns the attachment for an updated wiki page' do
expect(subject.attachments).to eq([
{
text: "Wiki page description",
- color: color,
+ color: color
}
])
end
@@ -81,7 +89,9 @@ describe ChatMessage::WikiPageMessage, models: true do
describe '#pretext' do
context 'when :action == "create"' do
- before { args[:object_attributes][:action] = 'create' }
+ before do
+ args[:object_attributes][:action] = 'create'
+ end
it 'returns a message that a new wiki page was created' do
expect(subject.pretext).to eq(
@@ -90,7 +100,9 @@ describe ChatMessage::WikiPageMessage, models: true do
end
context 'when :action == "update"' do
- before { args[:object_attributes][:action] = 'update' }
+ before do
+ args[:object_attributes][:action] = 'update'
+ end
it 'returns a message that a wiki page was updated' do
expect(subject.pretext).to eq(
@@ -101,7 +113,9 @@ describe ChatMessage::WikiPageMessage, models: true do
describe '#attachments' do
context 'when :action == "create"' do
- before { args[:object_attributes][:action] = 'create' }
+ before do
+ args[:object_attributes][:action] = 'create'
+ end
it 'returns the attachment for a new wiki page' do
expect(subject.attachments).to eq('Wiki page description')
@@ -109,7 +123,9 @@ describe ChatMessage::WikiPageMessage, models: true do
end
context 'when :action == "update"' do
- before { args[:object_attributes][:action] = 'update' }
+ before do
+ args[:object_attributes][:action] = 'update'
+ end
it 'returns the attachment for an updated wiki page' do
expect(subject.attachments).to eq('Wiki page description')
@@ -119,7 +135,9 @@ describe ChatMessage::WikiPageMessage, models: true do
describe '#activity' do
context 'when :action == "create"' do
- before { args[:object_attributes][:action] = 'create' }
+ before do
+ args[:object_attributes][:action] = 'create'
+ end
it 'returns the attachment for a new wiki page' do
expect(subject.activity).to eq({
@@ -132,7 +150,9 @@ describe ChatMessage::WikiPageMessage, models: true do
end
context 'when :action == "update"' do
- before { args[:object_attributes][:action] = 'update' }
+ before do
+ args[:object_attributes][:action] = 'update'
+ end
it 'returns the attachment for an updated wiki page' do
expect(subject.activity).to eq({
diff --git a/spec/models/project_services/chat_notification_service_spec.rb b/spec/models/project_services/chat_notification_service_spec.rb
index 8fbe42248ae..3aa1039d8bf 100644
--- a/spec/models/project_services/chat_notification_service_spec.rb
+++ b/spec/models/project_services/chat_notification_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatNotificationService, models: true do
+describe ChatNotificationService do
describe 'Associations' do
before do
allow(subject).to receive(:activated?).and_return(true)
@@ -12,7 +12,7 @@ describe ChatNotificationService, models: true do
describe '#can_test?' do
context 'with empty repository' do
it 'returns true' do
- subject.project = create(:empty_project, :empty_repo)
+ subject.project = create(:project, :empty_repo)
expect(subject.can_test?).to be true
end
@@ -20,7 +20,7 @@ describe ChatNotificationService, models: true do
context 'with repository' do
it 'returns true' do
- subject.project = create(:project)
+ subject.project = create(:project, :repository)
expect(subject.can_test?).to be true
end
diff --git a/spec/models/project_services/custom_issue_tracker_service_spec.rb b/spec/models/project_services/custom_issue_tracker_service_spec.rb
index 63320931e76..7e1b1a4f2af 100644
--- a/spec/models/project_services/custom_issue_tracker_service_spec.rb
+++ b/spec/models/project_services/custom_issue_tracker_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CustomIssueTrackerService, models: true do
+describe CustomIssueTrackerService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,7 +8,9 @@ describe CustomIssueTrackerService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:project_url) }
it { is_expected.to validate_presence_of(:issues_url) }
@@ -19,7 +21,9 @@ describe CustomIssueTrackerService, models: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:project_url) }
it { is_expected.not_to validate_presence_of(:issues_url) }
diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb
index 044737c6026..d8972aff407 100644
--- a/spec/models/project_services/drone_ci_service_spec.rb
+++ b/spec/models/project_services/drone_ci_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe DroneCiService, models: true, caching: true do
+describe DroneCiService, :use_clean_rails_memory_store_caching do
include ReactiveCachingHelpers
describe 'associations' do
@@ -10,7 +10,9 @@ describe DroneCiService, models: true, caching: true do
describe 'validations' do
context 'active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
it { is_expected.to validate_presence_of(:drone_url) }
@@ -18,7 +20,9 @@ describe DroneCiService, models: true, caching: true do
end
context 'inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
it { is_expected.not_to validate_presence_of(:drone_url) }
@@ -39,7 +43,7 @@ describe DroneCiService, models: true, caching: true do
let(:build_page) { "#{drone_url}/gitlab/#{path}/redirect/commits/#{sha}?branch=#{branch}" }
let(:commit_status_path) { "#{drone_url}/gitlab/#{path}/commits/#{sha}?branch=#{branch}&access_token=#{token}" }
- before(:each) do
+ before do
allow(drone).to receive_messages(
project_id: project.id,
project: project,
diff --git a/spec/models/project_services/emails_on_push_service_spec.rb b/spec/models/project_services/emails_on_push_service_spec.rb
index e6f78898c82..d9b7010e5e5 100644
--- a/spec/models/project_services/emails_on_push_service_spec.rb
+++ b/spec/models/project_services/emails_on_push_service_spec.rb
@@ -3,13 +3,17 @@ require 'spec_helper'
describe EmailsOnPushService do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:recipients) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:recipients) }
end
diff --git a/spec/models/project_services/external_wiki_service_spec.rb b/spec/models/project_services/external_wiki_service_spec.rb
index bdeea1db1e3..25e6ce7e804 100644
--- a/spec/models/project_services/external_wiki_service_spec.rb
+++ b/spec/models/project_services/external_wiki_service_spec.rb
@@ -1,29 +1,33 @@
require 'spec_helper'
-describe ExternalWikiService, models: true do
+describe ExternalWikiService do
include ExternalWikiHelper
describe "Associations" do
- it { should belong_to :project }
- it { should have_one :service_hook }
+ it { is_expected.to belong_to :project }
+ it { is_expected.to have_one :service_hook }
end
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:external_wiki_url) }
it_behaves_like 'issue tracker service URL attribute', :external_wiki_url
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:external_wiki_url) }
end
end
describe 'External wiki' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when it is active' do
before do
diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb
index a97e8c6e4ce..5e8e880985e 100644
--- a/spec/models/project_services/flowdock_service_spec.rb
+++ b/spec/models/project_services/flowdock_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe FlowdockService, models: true do
+describe FlowdockService do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,13 +8,17 @@ describe FlowdockService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
end
@@ -25,7 +29,7 @@ describe FlowdockService, models: true do
let(:project) { create(:project, :repository) }
before do
- @flowdock_service = FlowdockService.new
+ @flowdock_service = described_class.new
allow(@flowdock_service).to receive_messages(
project_id: project.id,
project: project,
diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb
index a13fbae03eb..4c61bc0af95 100644
--- a/spec/models/project_services/gemnasium_service_spec.rb
+++ b/spec/models/project_services/gemnasium_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GemnasiumService, models: true do
+describe GemnasiumService do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,14 +8,18 @@ describe GemnasiumService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
it { is_expected.to validate_presence_of(:api_key) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
it { is_expected.not_to validate_presence_of(:api_key) }
@@ -27,7 +31,7 @@ describe GemnasiumService, models: true do
let(:project) { create(:project, :repository) }
before do
- @gemnasium_service = GemnasiumService.new
+ @gemnasium_service = described_class.new
allow(@gemnasium_service).to receive_messages(
project_id: project.id,
project: project,
diff --git a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
index dcb70ee28a8..3237b660a16 100644
--- a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
+++ b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GitlabIssueTrackerService, models: true do
+describe GitlabIssueTrackerService do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,53 +8,44 @@ describe GitlabIssueTrackerService, models: true do
describe 'Validations' do
context 'when service is active' do
- subject { described_class.new(project: create(:empty_project), active: true) }
+ subject { described_class.new(project: create(:project), active: true) }
it { is_expected.to validate_presence_of(:issues_url) }
it_behaves_like 'issue tracker service URL attribute', :issues_url
end
context 'when service is inactive' do
- subject { described_class.new(project: create(:empty_project), active: false) }
+ subject { described_class.new(project: create(:project), active: false) }
it { is_expected.not_to validate_presence_of(:issues_url) }
end
end
describe 'project and issue urls' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
+ let(:service) { project.create_gitlab_issue_tracker_service(active: true) }
context 'with absolute urls' do
before do
- GitlabIssueTrackerService.default_url_options[:script_name] = "/gitlab/root"
- @service = project.create_gitlab_issue_tracker_service(active: true)
- end
-
- after do
- @service.destroy!
+ allow(described_class).to receive(:default_url_options).and_return(script_name: "/gitlab/root")
end
it 'gives the correct path' do
- expect(@service.project_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues")
- expect(@service.new_issue_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/new")
- expect(@service.issue_url(432)).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/432")
+ expect(service.project_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.full_path}/issues")
+ expect(service.new_issue_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.full_path}/issues/new")
+ expect(service.issue_url(432)).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.full_path}/issues/432")
end
end
context 'with relative urls' do
before do
- GitlabIssueTrackerService.default_url_options[:script_name] = "/gitlab/root"
- @service = project.create_gitlab_issue_tracker_service(active: true)
- end
-
- after do
- @service.destroy!
+ allow(described_class).to receive(:default_url_options).and_return(script_name: "/gitlab/root")
end
it 'gives the correct path' do
- expect(@service.project_path).to eq("/gitlab/root/#{project.path_with_namespace}/issues")
- expect(@service.new_issue_path).to eq("/gitlab/root/#{project.path_with_namespace}/issues/new")
- expect(@service.issue_path(432)).to eq("/gitlab/root/#{project.path_with_namespace}/issues/432")
+ expect(service.issue_tracker_path).to eq("/gitlab/root/#{project.full_path}/issues")
+ expect(service.new_issue_path).to eq("/gitlab/root/#{project.full_path}/issues/new")
+ expect(service.issue_path(432)).to eq("/gitlab/root/#{project.full_path}/issues/432")
end
end
end
diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb
index 1200ae7eb22..23db29cb541 100644
--- a/spec/models/project_services/hipchat_service_spec.rb
+++ b/spec/models/project_services/hipchat_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe HipchatService, models: true do
+describe HipchatService do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,20 +8,24 @@ describe HipchatService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
end
end
describe "Execute" do
- let(:hipchat) { HipchatService.new }
+ let(:hipchat) { described_class.new }
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:api_url) { 'https://hipchat.example.com/v2/room/123456/notification?auth_token=verySecret' }
@@ -32,7 +36,7 @@ describe HipchatService, models: true do
Gitlab::DataBuilder::Push.build_sample(project, user)
end
- before(:each) do
+ before do
allow(hipchat).to receive_messages(
project_id: project.id,
project: project,
diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb
index d5a16226d9d..cb9ca76fc3f 100644
--- a/spec/models/project_services/irker_service_spec.rb
+++ b/spec/models/project_services/irker_service_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
require 'socket'
require 'json'
-describe IrkerService, models: true do
+describe IrkerService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -10,20 +10,24 @@ describe IrkerService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:recipients) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:recipients) }
end
end
describe 'Execute' do
- let(:irker) { IrkerService.new }
+ let(:irker) { described_class.new }
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:sample_data) do
diff --git a/spec/models/project_services/issue_tracker_service_spec.rb b/spec/models/project_services/issue_tracker_service_spec.rb
index 869b25b933b..e6a1752576b 100644
--- a/spec/models/project_services/issue_tracker_service_spec.rb
+++ b/spec/models/project_services/issue_tracker_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe IssueTrackerService, models: true do
+describe IssueTrackerService do
describe 'Validations' do
let(:project) { create :project }
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 4bca0229e7a..63bf131cfc5 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe JiraService, models: true do
- include Gitlab::Routing.url_helpers
+describe JiraService do
+ include Gitlab::Routing
describe "Associations" do
it { is_expected.to belong_to :project }
@@ -10,72 +10,75 @@ describe JiraService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:url) }
- it { is_expected.to validate_presence_of(:project_key) }
it_behaves_like 'issue tracker service URL attribute', :url
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:url) }
end
- end
- describe '#reference_pattern' do
- it_behaves_like 'allows project key on reference pattern'
+ context 'validating urls' do
+ let(:service) do
+ described_class.new(
+ project: create(:project),
+ active: true,
+ username: 'username',
+ password: 'test',
+ jira_issue_transition_id: 24,
+ url: 'http://jira.test.com'
+ )
+ end
- it 'does not allow # on the code' do
- expect(subject.reference_pattern.match('#123')).to be_nil
- expect(subject.reference_pattern.match('1#23#12')).to be_nil
- end
- end
+ it 'is valid when all fields have required values' do
+ expect(service).to be_valid
+ end
- describe '#can_test?' do
- let(:jira_service) { described_class.new }
+ it 'is not valid when url is not a valid url' do
+ service.url = 'not valid'
- it 'returns false if username is blank' do
- allow(jira_service).to receive_messages(
- url: 'http://jira.example.com',
- username: '',
- password: '12345678'
- )
+ expect(service).not_to be_valid
+ end
- expect(jira_service.can_test?).to be_falsy
- end
+ it 'is not valid when api url is not a valid url' do
+ service.api_url = 'not valid'
- it 'returns false if password is blank' do
- allow(jira_service).to receive_messages(
- url: 'http://jira.example.com',
- username: 'tester',
- password: ''
- )
+ expect(service).not_to be_valid
+ end
+
+ it 'is valid when api url is a valid url' do
+ service.api_url = 'http://jira.test.com/api'
- expect(jira_service.can_test?).to be_falsy
+ expect(service).to be_valid
+ end
end
+ end
- it 'returns true if password and username are present' do
- jira_service = described_class.new
- allow(jira_service).to receive_messages(
- url: 'http://jira.example.com',
- username: 'tester',
- password: '12345678'
- )
+ describe '.reference_pattern' do
+ it_behaves_like 'allows project key on reference pattern'
- expect(jira_service.can_test?).to be_truthy
+ it 'does not allow # on the code' do
+ expect(described_class.reference_pattern.match('#123')).to be_nil
+ expect(described_class.reference_pattern.match('1#23#12')).to be_nil
end
end
describe '#close_issue' do
let(:custom_base_url) { 'http://custom_url' }
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:merge_request) { create(:merge_request) }
before do
- @jira_service = JiraService.new
+ @jira_service = described_class.new
allow(@jira_service).to receive_messages(
project_id: project.id,
project: project,
@@ -83,7 +86,6 @@ describe JiraService, models: true do
url: 'http://jira.example.com',
username: 'gitlab_jira_username',
password: 'gitlab_jira_password',
- project_key: 'GitLabProject',
jira_issue_transition_id: "custom-id"
)
@@ -97,18 +99,19 @@ describe JiraService, models: true do
allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue)
allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123")
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
@jira_service.save
- project_issues_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123'
- @transitions_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions'
- @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment'
- @remote_link_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/remotelink'
+ project_issues_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123'
+ @transitions_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/transitions'
+ @comment_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/comment'
+ @remote_link_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/remotelink'
- WebMock.stub_request(:get, project_issues_url)
- WebMock.stub_request(:post, @transitions_url)
- WebMock.stub_request(:post, @comment_url)
- WebMock.stub_request(:post, @remote_link_url)
+ WebMock.stub_request(:get, project_issues_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
+ WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
+ WebMock.stub_request(:post, @comment_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
+ WebMock.stub_request(:post, @remote_link_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
end
it "calls JIRA API" do
@@ -132,7 +135,7 @@ describe JiraService, models: true do
body: hash_including(
GlobalID: "GitLab",
object: {
- url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/#{merge_request.diff_head_sha}",
+ url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/#{merge_request.diff_head_sha}",
title: "GitLab: Solved by commit #{merge_request.diff_head_sha}.",
icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
status: { resolved: true }
@@ -150,13 +153,22 @@ describe JiraService, models: true do
expect(WebMock).not_to have_requested(:post, @remote_link_url)
end
+ it "does not send comment or remote links to issues with unknown resolution" do
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:respond_to?).with(:resolution).and_return(false)
+
+ @jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project))
+
+ expect(WebMock).not_to have_requested(:post, @comment_url)
+ expect(WebMock).not_to have_requested(:post, @remote_link_url)
+ end
+
it "references the GitLab commit/merge request" do
stub_config_setting(base_url: custom_base_url)
@jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project))
expect(WebMock).to have_requested(:post, @comment_url).with(
- body: /#{custom_base_url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/
+ body: /#{custom_base_url}\/#{project.full_path}\/commit\/#{merge_request.diff_head_sha}/
).once
end
@@ -164,14 +176,14 @@ describe JiraService, models: true do
stub_config_setting(relative_url_root: '/gitlab')
stub_config_setting(url: Settings.send(:build_gitlab_url))
- allow(JiraService).to receive(:default_url_options) do
+ allow(described_class).to receive(:default_url_options) do
{ script_name: '/gitlab' }
end
@jira_service.close_issue(merge_request, ExternalIssue.new("JIRA-123", project))
expect(WebMock).to have_requested(:post, @comment_url).with(
- body: /#{Gitlab.config.gitlab.url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/
+ body: /#{Gitlab.config.gitlab.url}\/#{project.full_path}\/commit\/#{merge_request.diff_head_sha}/
).once
end
@@ -187,61 +199,125 @@ describe JiraService, models: true do
describe '#test_settings' do
let(:jira_service) do
described_class.new(
+ project: create(:project),
url: 'http://jira.example.com',
- username: 'gitlab_jira_username',
- password: 'gitlab_jira_password',
- project_key: 'GitLabProject'
+ username: 'jira_username',
+ password: 'jira_password'
)
end
- let(:project_url) { 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject' }
- before do
- WebMock.stub_request(:get, project_url)
+ def test_settings(api_url = nil)
+ api_url ||= 'jira.example.com'
+ test_url = "http://#{api_url}/rest/api/2/serverInfo"
+
+ WebMock.stub_request(:get, test_url).with(basic_auth: %w(jira_username jira_password)).to_return(body: { url: 'http://url' }.to_json )
+
+ jira_service.test(nil)
+ end
+
+ context 'when the test succeeds' do
+ it 'tries to get JIRA project with URL when API URL not set' do
+ test_settings('jira.example.com')
+ end
+
+ it 'returns correct result' do
+ expect(test_settings).to eq( { success: true, result: { 'url' => 'http://url' } })
+ end
+
+ it 'tries to get JIRA project with API URL if set' do
+ jira_service.update(api_url: 'http://jira.api.com')
+ test_settings('jira.api.com')
+ end
end
- it 'tries to get JIRA project' do
- jira_service.test_settings
+ context 'when the test fails' do
+ it 'returns result with the error' do
+ test_url = 'http://jira.example.com/rest/api/2/serverInfo'
+ WebMock.stub_request(:get, test_url).with(basic_auth: %w(jira_username jira_password))
+ .to_raise(JIRA::HTTPError.new(double(message: 'Some specific failure.')))
- expect(WebMock).to have_requested(:get, project_url)
+ expect(jira_service.test(nil)).to eq( { success: false, result: 'Some specific failure.' })
+ end
end
end
describe "Stored password invalidation" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context "when a password was previously set" do
before do
- @jira_service = JiraService.create!(
+ @jira_service = described_class.create!(
project: project,
properties: {
- url: 'http://jira.example.com/rest/api/2',
+ url: 'http://jira.example.com/web',
username: 'mic',
password: "password"
}
)
end
- it "reset password if url changed" do
- @jira_service.url = 'http://jira_edited.example.com/rest/api/2'
- @jira_service.save
- expect(@jira_service.password).to be_nil
+ context 'when only web url present' do
+ it 'reset password if url changed' do
+ @jira_service.url = 'http://jira_edited.example.com/rest/api/2'
+ @jira_service.save
+
+ expect(@jira_service.password).to be_nil
+ end
+
+ it 'reset password if url not changed but api url added' do
+ @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2'
+ @jira_service.save
+
+ expect(@jira_service.password).to be_nil
+ end
end
- it "does not reset password if username changed" do
- @jira_service.username = "some_name"
+ context 'when both web and api url present' do
+ before do
+ @jira_service.api_url = 'http://jira.example.com/rest/api/2'
+ @jira_service.password = 'password'
+
+ @jira_service.save
+ end
+ it 'reset password if api url changed' do
+ @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2'
+ @jira_service.save
+
+ expect(@jira_service.password).to be_nil
+ end
+
+ it 'does not reset password if url changed' do
+ @jira_service.url = 'http://jira_edited.example.com/rweb'
+ @jira_service.save
+
+ expect(@jira_service.password).to eq("password")
+ end
+
+ it 'reset password if api url set to ""' do
+ @jira_service.api_url = ''
+ @jira_service.save
+
+ expect(@jira_service.password).to be_nil
+ end
+ end
+
+ it 'does not reset password if username changed' do
+ @jira_service.username = 'some_name'
@jira_service.save
- expect(@jira_service.password).to eq("password")
+
+ expect(@jira_service.password).to eq('password')
end
- it "does not reset password if new url is set together with password, even if it's the same password" do
+ it 'does not reset password if new url is set together with password, even if it\'s the same password' do
@jira_service.url = 'http://jira_edited.example.com/rest/api/2'
@jira_service.password = 'password'
@jira_service.save
- expect(@jira_service.password).to eq("password")
- expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2")
+
+ expect(@jira_service.password).to eq('password')
+ expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2')
end
- it "resets password if url changed, even if setter called multiple times" do
+ it 'resets password if url changed, even if setter called multiple times' do
@jira_service.url = 'http://jira1.example.com/rest/api/2'
@jira_service.url = 'http://jira1.example.com/rest/api/2'
@jira_service.save
@@ -249,9 +325,9 @@ describe JiraService, models: true do
end
end
- context "when no password was previously set" do
+ context 'when no password was previously set' do
before do
- @jira_service = JiraService.create(
+ @jira_service = described_class.create(
project: project,
properties: {
url: 'http://jira.example.com/rest/api/2',
@@ -260,28 +336,18 @@ describe JiraService, models: true do
)
end
- it "saves password if new url is set together with password" do
+ it 'saves password if new url is set together with password' do
@jira_service.url = 'http://jira_edited.example.com/rest/api/2'
@jira_service.password = 'password'
@jira_service.save
- expect(@jira_service.password).to eq("password")
- expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2")
- end
- end
- end
-
- describe "Validations" do
- context "active" do
- before do
- subject.active = true
+ expect(@jira_service.password).to eq('password')
+ expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2')
end
-
- it { is_expected.to validate_presence_of :url }
end
end
describe 'description and title' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when it is not set' do
before do
@@ -316,14 +382,15 @@ describe JiraService, models: true do
end
describe 'project and issue urls' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when gitlab.yml was initialized' do
before do
settings = {
- "jira" => {
- "title" => "Jira",
- "url" => "http://jira.sample/projects/project_a"
+ 'jira' => {
+ 'title' => 'Jira',
+ 'url' => 'http://jira.sample/projects/project_a',
+ 'api_url' => 'http://jira.sample/api'
}
}
allow(Gitlab.config).to receive(:issues_tracker).and_return(settings)
@@ -335,8 +402,9 @@ describe JiraService, models: true do
end
it 'is prepopulated with the settings' do
- expect(@service.properties["title"]).to eq('Jira')
- expect(@service.properties["url"]).to eq('http://jira.sample/projects/project_a')
+ expect(@service.properties['title']).to eq('Jira')
+ expect(@service.properties['url']).to eq('http://jira.sample/projects/project_a')
+ expect(@service.properties['api_url']).to eq('http://jira.sample/api')
end
end
end
diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb
index e69eb0098dd..55b96a0c12e 100644
--- a/spec/models/project_services/kubernetes_service_spec.rb
+++ b/spec/models/project_services/kubernetes_service_spec.rb
@@ -1,37 +1,21 @@
require 'spec_helper'
-describe KubernetesService, models: true, caching: true do
+describe KubernetesService, :use_clean_rails_memory_store_caching do
include KubernetesHelpers
include ReactiveCachingHelpers
let(:project) { build_stubbed(:kubernetes_project) }
let(:service) { project.kubernetes_service }
- # We use Kubeclient to interactive with the Kubernetes API. It will
- # GET /api/v1 for a list of resources the API supports. This must be stubbed
- # in addition to any other HTTP requests we expect it to perform.
- let(:discovery_url) { service.api_url + '/api/v1' }
- let(:discovery_response) { { body: kube_discovery_body.to_json } }
-
- let(:pods_url) { service.api_url + "/api/v1/namespaces/#{service.namespace}/pods" }
- let(:pods_response) { { body: kube_pods_body(kube_pod).to_json } }
-
- def stub_kubeclient_discover
- WebMock.stub_request(:get, discovery_url).to_return(discovery_response)
- end
-
- def stub_kubeclient_pods
- stub_kubeclient_discover
- WebMock.stub_request(:get, pods_url).to_return(pods_response)
- end
-
describe "Associations" do
it { is_expected.to belong_to :project }
end
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.not_to validate_presence_of(:namespace) }
it { is_expected.to validate_presence_of(:api_url) }
@@ -54,7 +38,7 @@ describe KubernetesService, models: true, caching: true do
'a' * 63 => true,
'a' * 64 => false,
'a.b' => false,
- 'a*b' => false,
+ 'a*b' => false
}.each do |namespace, validity|
it "validates #{namespace} as #{validity ? 'valid' : 'invalid'}" do
subject.namespace = namespace
@@ -66,7 +50,9 @@ describe KubernetesService, models: true, caching: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:api_url) }
it { is_expected.not_to validate_presence_of(:token) }
@@ -87,7 +73,9 @@ describe KubernetesService, models: true, caching: true do
end
context 'as template' do
- before { subject.template = true }
+ before do
+ subject.template = true
+ end
it 'sets the namespace to the default' do
expect(kube_namespace).not_to be_nil
@@ -96,16 +84,76 @@ describe KubernetesService, models: true, caching: true do
end
context 'with associated project' do
- before { subject.project = project }
+ before do
+ subject.project = project
+ end
it 'sets the namespace to the default' do
expect(kube_namespace).not_to be_nil
- expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/)
+ expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/)
+ end
+ end
+ end
+
+ describe '#actual_namespace' do
+ subject { service.actual_namespace }
+
+ it "returns the default namespace" do
+ is_expected.to eq(service.send(:default_namespace))
+ end
+
+ context 'when namespace is specified' do
+ before do
+ service.namespace = 'my-namespace'
+ end
+
+ it "returns the user-namespace" do
+ is_expected.to eq('my-namespace')
+ end
+ end
+
+ context 'when service is not assigned to project' do
+ before do
+ service.project = nil
+ end
+
+ it "does not return namespace" do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#actual_namespace' do
+ subject { service.actual_namespace }
+
+ it "returns the default namespace" do
+ is_expected.to eq(service.send(:default_namespace))
+ end
+
+ context 'when namespace is specified' do
+ before do
+ service.namespace = 'my-namespace'
+ end
+
+ it "returns the user-namespace" do
+ is_expected.to eq('my-namespace')
+ end
+ end
+
+ context 'when service is not assigned to project' do
+ before do
+ service.project = nil
+ end
+
+ it "does not return namespace" do
+ is_expected.to be_nil
end
end
end
describe '#test' do
+ let(:discovery_url) { 'https://kubernetes.example.com/api/v1' }
+
before do
stub_kubeclient_discover
end
@@ -114,7 +162,8 @@ describe KubernetesService, models: true, caching: true do
let(:discovery_url) { 'https://kubernetes.example.com/prefix/api/v1' }
it 'tests with the prefix' do
- service.api_url = 'https://kubernetes.example.com/prefix/'
+ service.api_url = 'https://kubernetes.example.com/prefix'
+ stub_kubeclient_discover
expect(service.test[:success]).to be_truthy
expect(WebMock).to have_requested(:get, discovery_url).once
@@ -142,9 +191,9 @@ describe KubernetesService, models: true, caching: true do
end
context 'failure' do
- let(:discovery_response) { { status: 404 } }
-
it 'fails to read the discovery endpoint' do
+ WebMock.stub_request(:get, service.api_url + '/api/v1').to_return(status: 404)
+
expect(service.test[:success]).to be_falsy
expect(WebMock).to have_requested(:get, discovery_url).once
end
@@ -152,6 +201,22 @@ describe KubernetesService, models: true, caching: true do
end
describe '#predefined_variables' do
+ let(:kubeconfig) do
+ config =
+ YAML.load(File.read(expand_fixture_path('config/kubeconfig.yml')))
+
+ config.dig('users', 0, 'user')['token'] =
+ 'token'
+
+ config.dig('clusters', 0, 'cluster')['certificate-authority-data'] =
+ Base64.encode64('CA PEM DATA')
+
+ config.dig('contexts', 0, 'context')['namespace'] =
+ namespace
+
+ YAML.dump(config)
+ end
+
before do
subject.api_url = 'https://kube.domain.com'
subject.token = 'token'
@@ -159,41 +224,46 @@ describe KubernetesService, models: true, caching: true do
subject.project = project
end
- context 'namespace is provided' do
- before { subject.namespace = 'my-project' }
-
+ shared_examples 'setting variables' do
it 'sets the variables' do
expect(subject.predefined_variables).to include(
{ key: 'KUBE_URL', value: 'https://kube.domain.com', public: true },
{ key: 'KUBE_TOKEN', value: 'token', public: false },
- { key: 'KUBE_NAMESPACE', value: 'my-project', public: true },
+ { key: 'KUBE_NAMESPACE', value: namespace, public: true },
+ { key: 'KUBECONFIG', value: kubeconfig, public: false, file: true },
{ key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true },
- { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true },
+ { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true }
)
end
end
- context 'no namespace provided' do
- it 'sets the variables' do
- expect(subject.predefined_variables).to include(
- { key: 'KUBE_URL', value: 'https://kube.domain.com', public: true },
- { key: 'KUBE_TOKEN', value: 'token', public: false },
- { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true },
- { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true },
- )
+ context 'namespace is provided' do
+ let(:namespace) { 'my-project' }
+
+ before do
+ subject.namespace = namespace
end
+ it_behaves_like 'setting variables'
+ end
+
+ context 'no namespace provided' do
+ let(:namespace) { subject.actual_namespace }
+
+ it_behaves_like 'setting variables'
+
it 'sets the KUBE_NAMESPACE' do
kube_namespace = subject.predefined_variables.find { |h| h[:key] == 'KUBE_NAMESPACE' }
expect(kube_namespace).not_to be_nil
- expect(kube_namespace[:value]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/)
+ expect(kube_namespace[:value]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/)
end
end
end
describe '#terminals' do
let(:environment) { build(:environment, project: project, name: "env", slug: "env-000000") }
+
subject { service.terminals(environment) }
context 'with invalid pods' do
@@ -229,27 +299,36 @@ describe KubernetesService, models: true, caching: true do
end
describe '#calculate_reactive_cache' do
- before { stub_kubeclient_pods }
subject { service.calculate_reactive_cache }
context 'when service is inactive' do
- before { service.active = false }
+ before do
+ service.active = false
+ end
it { is_expected.to be_nil }
end
context 'when kubernetes responds with valid pods' do
+ before do
+ stub_kubeclient_pods
+ end
+
it { is_expected.to eq(pods: [kube_pod]) }
end
- context 'when kubernetes responds with 500' do
- let(:pods_response) { { status: 500 } }
+ context 'when kubernetes responds with 500s' do
+ before do
+ stub_kubeclient_pods(status: 500)
+ end
it { expect { subject }.to raise_error(KubeException) }
end
- context 'when kubernetes responds with 404' do
- let(:pods_response) { { status: 404 } }
+ context 'when kubernetes responds with 404s' do
+ before do
+ stub_kubeclient_pods(status: 404)
+ end
it { is_expected.to eq(pods: []) }
end
diff --git a/spec/models/project_services/mattermost_service_spec.rb b/spec/models/project_services/mattermost_service_spec.rb
index 490d6aedffc..10c62ca55a7 100644
--- a/spec/models/project_services/mattermost_service_spec.rb
+++ b/spec/models/project_services/mattermost_service_spec.rb
@@ -1,5 +1,5 @@
require 'spec_helper'
-describe MattermostService, models: true do
+describe MattermostService do
it_behaves_like "slack or mattermost notifications"
end
diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
index f9531be5d25..522cf15f3ba 100644
--- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb
+++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
-describe MattermostSlashCommandsService, :models do
+describe MattermostSlashCommandsService do
it_behaves_like "chat slash commands service"
context 'Mattermost API' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:service) { project.build_mattermost_slash_commands_service }
let(:user) { create(:user) }
before do
Mattermost::Session.base_uri("http://mattermost.example.com")
- allow_any_instance_of(Mattermost::Client).to receive(:with_session).
- and_yield(Mattermost::Session.new(nil))
+ allow_any_instance_of(Mattermost::Client).to receive(:with_session)
+ .and_yield(Mattermost::Session.new(nil))
end
describe '#configure' do
@@ -24,8 +24,8 @@ describe MattermostSlashCommandsService, :models do
context 'the requests succeeds' do
before do
- stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create').
- with(body: {
+ stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create')
+ .with(body: {
team_id: 'abc',
trigger: 'gitlab',
url: 'http://trigger.url',
@@ -37,8 +37,8 @@ describe MattermostSlashCommandsService, :models do
display_name: "GitLab / #{project.name_with_namespace}",
method: 'P',
username: 'GitLab'
- }.to_json).
- to_return(
+ }.to_json)
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: { token: 'token' }.to_json
@@ -58,8 +58,8 @@ describe MattermostSlashCommandsService, :models do
context 'an error is received' do
before do
- stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create').
- to_return(
+ stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create')
+ .to_return(
status: 500,
headers: { 'Content-Type' => 'application/json' },
body: {
@@ -88,8 +88,8 @@ describe MattermostSlashCommandsService, :models do
context 'the requests succeeds' do
before do
- stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all').
- to_return(
+ stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all')
+ .to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: { 'list' => true }.to_json
@@ -103,8 +103,8 @@ describe MattermostSlashCommandsService, :models do
context 'an error is received' do
before do
- stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all').
- to_return(
+ stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all')
+ .to_return(
status: 500,
headers: { 'Content-Type' => 'application/json' },
body: {
diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb
index facc034f69c..f89be20ad78 100644
--- a/spec/models/project_services/microsoft_teams_service_spec.rb
+++ b/spec/models/project_services/microsoft_teams_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MicrosoftTeamsService, models: true do
+describe MicrosoftTeamsService do
let(:chat_service) { described_class.new }
let(:webhook_url) { 'https://example.gitlab.com/' }
@@ -11,14 +11,18 @@ describe MicrosoftTeamsService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:webhook) }
it_behaves_like 'issue tracker service URL attribute', :webhook
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:webhook) }
end
@@ -108,7 +112,7 @@ describe MicrosoftTeamsService, models: true do
let(:wiki_page_sample_data) do
service = WikiPages::CreateService.new(project, user, opts)
wiki_page = service.execute
- service.hook_data(wiki_page, 'create')
+ Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create')
end
it "calls Microsoft Teams API" do
diff --git a/spec/models/project_services/pipeline_email_service_spec.rb b/spec/models/project_services/pipeline_email_service_spec.rb
deleted file mode 100644
index 03932895b0e..00000000000
--- a/spec/models/project_services/pipeline_email_service_spec.rb
+++ /dev/null
@@ -1,175 +0,0 @@
-require 'spec_helper'
-
-describe PipelinesEmailService do
- include EmailHelpers
-
- let(:pipeline) do
- create(:ci_pipeline, project: project, sha: project.commit('master').sha)
- end
-
- let(:project) { create(:project, :repository) }
- let(:recipient) { 'test@gitlab.com' }
-
- let(:data) do
- Gitlab::DataBuilder::Pipeline.build(pipeline)
- end
-
- before do
- reset_delivered_emails!
- end
-
- describe 'Validations' do
- context 'when service is active' do
- before do
- subject.active = true
- end
-
- it { is_expected.to validate_presence_of(:recipients) }
- end
-
- context 'when service is inactive' do
- before do
- subject.active = false
- end
-
- it { is_expected.not_to validate_presence_of(:recipients) }
- end
- end
-
- describe '#test_data' do
- let(:build) { create(:ci_build) }
- let(:project) { build.project }
- let(:user) { create(:user) }
-
- before do
- project.team << [user, :developer]
- end
-
- it 'builds test data' do
- data = subject.test_data(project, user)
-
- expect(data[:object_kind]).to eq('pipeline')
- end
- end
-
- shared_examples 'sending email' do
- before do
- perform_enqueued_jobs do
- run
- end
- end
-
- it 'sends email' do
- should_only_email(double(notification_email: recipient), kind: :bcc)
- end
- end
-
- shared_examples 'not sending email' do
- before do
- perform_enqueued_jobs do
- run
- end
- end
-
- it 'does not send email' do
- should_not_email_anyone
- end
- end
-
- describe '#test' do
- def run
- subject.test(data)
- end
-
- before do
- subject.recipients = recipient
- end
-
- context 'when pipeline is failed' do
- before do
- data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
- end
-
- it_behaves_like 'sending email'
- end
-
- context 'when pipeline is succeeded' do
- before do
- data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
- end
-
- it_behaves_like 'sending email'
- end
- end
-
- describe '#execute' do
- def run
- subject.execute(data)
- end
-
- context 'with recipients' do
- before do
- subject.recipients = recipient
- end
-
- context 'with failed pipeline' do
- before do
- data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
- end
-
- it_behaves_like 'sending email'
- end
-
- context 'with succeeded pipeline' do
- before do
- data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
- end
-
- it_behaves_like 'not sending email'
- end
-
- context 'with notify_only_broken_pipelines on' do
- before do
- subject.notify_only_broken_pipelines = true
- end
-
- context 'with failed pipeline' do
- before do
- data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
- end
-
- it_behaves_like 'sending email'
- end
-
- context 'with succeeded pipeline' do
- before do
- data[:object_attributes][:status] = 'success'
- pipeline.update(status: 'success')
- end
-
- it_behaves_like 'not sending email'
- end
- end
- end
-
- context 'with empty recipients list' do
- before do
- subject.recipients = ' ,, '
- end
-
- context 'with failed pipeline' do
- before do
- data[:object_attributes][:status] = 'failed'
- pipeline.update(status: 'failed')
- end
-
- it_behaves_like 'not sending email'
- end
- end
- end
-end
diff --git a/spec/models/project_services/pipelines_email_service_spec.rb b/spec/models/project_services/pipelines_email_service_spec.rb
new file mode 100644
index 00000000000..5faab9ba38b
--- /dev/null
+++ b/spec/models/project_services/pipelines_email_service_spec.rb
@@ -0,0 +1,169 @@
+require 'spec_helper'
+
+describe PipelinesEmailService, :mailer do
+ let(:pipeline) do
+ create(:ci_pipeline, project: project, sha: project.commit('master').sha)
+ end
+
+ let(:project) { create(:project, :repository) }
+ let(:recipient) { 'test@gitlab.com' }
+
+ let(:data) do
+ Gitlab::DataBuilder::Pipeline.build(pipeline)
+ end
+
+ describe 'Validations' do
+ context 'when service is active' do
+ before do
+ subject.active = true
+ end
+
+ it { is_expected.to validate_presence_of(:recipients) }
+ end
+
+ context 'when service is inactive' do
+ before do
+ subject.active = false
+ end
+
+ it { is_expected.not_to validate_presence_of(:recipients) }
+ end
+ end
+
+ describe '#test_data' do
+ let(:build) { create(:ci_build) }
+ let(:project) { build.project }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'builds test data' do
+ data = subject.test_data(project, user)
+
+ expect(data[:object_kind]).to eq('pipeline')
+ end
+ end
+
+ shared_examples 'sending email' do
+ before do
+ perform_enqueued_jobs do
+ run
+ end
+ end
+
+ it 'sends email' do
+ should_only_email(double(notification_email: recipient), kind: :bcc)
+ end
+ end
+
+ shared_examples 'not sending email' do
+ before do
+ perform_enqueued_jobs do
+ run
+ end
+ end
+
+ it 'does not send email' do
+ should_not_email_anyone
+ end
+ end
+
+ describe '#test' do
+ def run
+ subject.test(data)
+ end
+
+ before do
+ subject.recipients = recipient
+ end
+
+ context 'when pipeline is failed' do
+ before do
+ data[:object_attributes][:status] = 'failed'
+ pipeline.update(status: 'failed')
+ end
+
+ it_behaves_like 'sending email'
+ end
+
+ context 'when pipeline is succeeded' do
+ before do
+ data[:object_attributes][:status] = 'success'
+ pipeline.update(status: 'success')
+ end
+
+ it_behaves_like 'sending email'
+ end
+ end
+
+ describe '#execute' do
+ def run
+ subject.execute(data)
+ end
+
+ context 'with recipients' do
+ before do
+ subject.recipients = recipient
+ end
+
+ context 'with failed pipeline' do
+ before do
+ data[:object_attributes][:status] = 'failed'
+ pipeline.update(status: 'failed')
+ end
+
+ it_behaves_like 'sending email'
+ end
+
+ context 'with succeeded pipeline' do
+ before do
+ data[:object_attributes][:status] = 'success'
+ pipeline.update(status: 'success')
+ end
+
+ it_behaves_like 'not sending email'
+ end
+
+ context 'with notify_only_broken_pipelines on' do
+ before do
+ subject.notify_only_broken_pipelines = true
+ end
+
+ context 'with failed pipeline' do
+ before do
+ data[:object_attributes][:status] = 'failed'
+ pipeline.update(status: 'failed')
+ end
+
+ it_behaves_like 'sending email'
+ end
+
+ context 'with succeeded pipeline' do
+ before do
+ data[:object_attributes][:status] = 'success'
+ pipeline.update(status: 'success')
+ end
+
+ it_behaves_like 'not sending email'
+ end
+ end
+ end
+
+ context 'with empty recipients list' do
+ before do
+ subject.recipients = ' ,, '
+ end
+
+ context 'with failed pipeline' do
+ before do
+ data[:object_attributes][:status] = 'failed'
+ pipeline.update(status: 'failed')
+ end
+
+ it_behaves_like 'not sending email'
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb
index 45b2f1068bf..f7d2372eca2 100644
--- a/spec/models/project_services/pivotaltracker_service_spec.rb
+++ b/spec/models/project_services/pivotaltracker_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe PivotaltrackerService, models: true do
+describe PivotaltrackerService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,13 +8,17 @@ describe PivotaltrackerService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:token) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:token) }
end
@@ -22,7 +26,7 @@ describe PivotaltrackerService, models: true do
describe 'Execute' do
let(:service) do
- PivotaltrackerService.new.tap do |service|
+ described_class.new.tap do |service|
service.token = 'secret_api_token'
end
end
@@ -40,7 +44,7 @@ describe PivotaltrackerService, models: true do
name: 'Some User'
},
url: 'https://example.com/commit',
- message: 'commit message',
+ message: 'commit message'
}
]
}
diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb
index d15079b686b..bf39e8d7a39 100644
--- a/spec/models/project_services/prometheus_service_spec.rb
+++ b/spec/models/project_services/prometheus_service_spec.rb
@@ -1,11 +1,12 @@
require 'spec_helper'
-describe PrometheusService, models: true, caching: true do
+describe PrometheusService, :use_clean_rails_memory_store_caching do
include PrometheusHelpers
include ReactiveCachingHelpers
let(:project) { create(:prometheus_project) }
let(:service) { project.prometheus_service }
+ let(:environment_query) { Gitlab::Prometheus::Queries::EnvironmentQuery }
describe "Associations" do
it { is_expected.to belong_to :project }
@@ -13,13 +14,17 @@ describe PrometheusService, models: true, caching: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:api_url) }
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:api_url) }
end
@@ -45,34 +50,59 @@ describe PrometheusService, models: true, caching: true do
end
end
- describe '#metrics' do
+ describe '#environment_metrics' do
let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
- subject { service.metrics(environment) }
around do |example|
Timecop.freeze { example.run }
end
context 'with valid data' do
+ subject { service.environment_metrics(environment) }
+
before do
- stub_reactive_cache(service, prometheus_data, 'env-slug')
+ stub_reactive_cache(service, prometheus_data, environment_query, environment.id)
end
it 'returns reactive data' do
- is_expected.to eq(prometheus_data)
+ is_expected.to eq(prometheus_metrics_data)
+ end
+ end
+ end
+
+ describe '#deployment_metrics' do
+ let(:deployment) { build_stubbed(:deployment) }
+ let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'with valid data' do
+ subject { service.deployment_metrics(deployment) }
+ let(:fake_deployment_time) { 10 }
+
+ before do
+ stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id)
+ end
+
+ it 'returns reactive data' do
+ expect(deployment).to receive(:created_at).and_return(fake_deployment_time)
+
+ expect(subject).to eq(prometheus_metrics_data.merge(deployment_time: fake_deployment_time))
end
end
end
describe '#calculate_reactive_cache' do
- let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
+ let(:environment) { create(:environment, slug: 'env-slug') }
around do |example|
Timecop.freeze { example.run }
end
subject do
- service.calculate_reactive_cache(environment.slug)
+ service.calculate_reactive_cache(environment_query.to_s, environment.id)
end
context 'when service is inactive' do
@@ -89,12 +119,13 @@ describe PrometheusService, models: true, caching: true do
end
it { expect(subject.to_json).to eq(prometheus_data.to_json) }
+ it { expect(subject.to_json).to eq(prometheus_data.to_json) }
end
[404, 500].each do |status|
context "when Prometheus responds with #{status}" do
before do
- stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!')
+ stub_all_prometheus_requests(environment.slug, status: status, body: "QUERY FAILED!")
end
it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) }
diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb
index a7e7594a7d5..54b8c658ff6 100644
--- a/spec/models/project_services/pushover_service_spec.rb
+++ b/spec/models/project_services/pushover_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe PushoverService, models: true do
+describe PushoverService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,7 +8,9 @@ describe PushoverService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:api_key) }
it { is_expected.to validate_presence_of(:user_key) }
@@ -16,7 +18,9 @@ describe PushoverService, models: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:api_key) }
it { is_expected.not_to validate_presence_of(:user_key) }
@@ -25,7 +29,7 @@ describe PushoverService, models: true do
end
describe 'Execute' do
- let(:pushover) { PushoverService.new }
+ let(:pushover) { described_class.new }
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:sample_data) do
diff --git a/spec/models/project_services/redmine_service_spec.rb b/spec/models/project_services/redmine_service_spec.rb
index 0a7b237a051..2ac14eab5e1 100644
--- a/spec/models/project_services/redmine_service_spec.rb
+++ b/spec/models/project_services/redmine_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe RedmineService, models: true do
+describe RedmineService do
describe 'Associations' do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -8,7 +8,9 @@ describe RedmineService, models: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:project_url) }
it { is_expected.to validate_presence_of(:issues_url) }
@@ -19,7 +21,9 @@ describe RedmineService, models: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:project_url) }
it { is_expected.not_to validate_presence_of(:issues_url) }
@@ -27,11 +31,11 @@ describe RedmineService, models: true do
end
end
- describe '#reference_pattern' do
+ describe '.reference_pattern' do
it_behaves_like 'allows project key on reference pattern'
it 'does allow # on the reference' do
- expect(subject.reference_pattern.match('#123')[:issue]).to eq('123')
+ expect(described_class.reference_pattern.match('#123')[:issue]).to eq('123')
end
end
end
diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb
index 9a3ecc66d83..13cf4d1915e 100644
--- a/spec/models/project_services/slack_service_spec.rb
+++ b/spec/models/project_services/slack_service_spec.rb
@@ -1,5 +1,5 @@
require 'spec_helper'
-describe SlackService, models: true do
+describe SlackService do
it_behaves_like "slack or mattermost notifications"
end
diff --git a/spec/models/project_services/slack_slash_commands_service_spec.rb b/spec/models/project_services/slack_slash_commands_service_spec.rb
index 5766aa340e2..0d95f454819 100644
--- a/spec/models/project_services/slack_slash_commands_service_spec.rb
+++ b/spec/models/project_services/slack_slash_commands_service_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe SlackSlashCommandsService, :models do
+describe SlackSlashCommandsService do
it_behaves_like "chat slash commands service"
describe '#trigger' do
context 'when an auth url is generated' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:params) do
{
team_domain: 'http://domain.tld',
diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb
index 77b18e1c7d0..43a0ed99296 100644
--- a/spec/models/project_services/teamcity_service_spec.rb
+++ b/spec/models/project_services/teamcity_service_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe TeamcityService, models: true, caching: true do
+describe TeamcityService, :use_clean_rails_memory_store_caching do
include ReactiveCachingHelpers
let(:teamcity_url) { 'http://gitlab.com/teamcity' }
subject(:service) do
described_class.create(
- project: create(:empty_project),
+ project: create(:project),
properties: {
teamcity_url: teamcity_url,
username: 'mic',
@@ -24,7 +24,9 @@ describe TeamcityService, models: true, caching: true do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:build_type) }
it { is_expected.to validate_presence_of(:teamcity_url) }
@@ -60,7 +62,9 @@ describe TeamcityService, models: true, caching: true do
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:build_type) }
it { is_expected.not_to validate_presence_of(:teamcity_url) }
@@ -201,10 +205,12 @@ describe TeamcityService, models: true, caching: true do
end
def stub_request(status: 200, body: nil, build_status: 'success')
- teamcity_full_url = 'http://mic:password@gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,number:123'
+ teamcity_full_url = 'http://gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,number:123'
+ auth = %w(mic password)
+
body ||= %Q({"build":{"status":"#{build_status}","id":"666"}})
- WebMock.stub_request(:get, teamcity_full_url).to_return(
+ WebMock.stub_request(:get, teamcity_full_url).with(basic_auth: auth).to_return(
status: status,
headers: { 'Content-Type' => 'application/json' },
body: body
diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb
index d9d7c0b0aaa..1b439bcfad1 100644
--- a/spec/models/project_snippet_spec.rb
+++ b/spec/models/project_snippet_spec.rb
@@ -1,13 +1,10 @@
require 'spec_helper'
-describe ProjectSnippet, models: true do
+describe ProjectSnippet do
describe "Associations" do
it { is_expected.to belong_to(:project) }
end
- describe "Mass assignment" do
- end
-
describe "Validation" do
it { is_expected.to validate_presence_of(:project) }
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 92d420337f9..5e60511f3a8 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1,56 +1,56 @@
require 'spec_helper'
-describe Project, models: true do
+describe Project do
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:namespace) }
it { is_expected.to belong_to(:creator).class_name('User') }
it { is_expected.to have_many(:users) }
it { is_expected.to have_many(:services) }
- it { is_expected.to have_many(:events).dependent(:destroy) }
- it { is_expected.to have_many(:merge_requests).dependent(:destroy) }
- it { is_expected.to have_many(:issues).dependent(:destroy) }
- it { is_expected.to have_many(:milestones).dependent(:destroy) }
- it { is_expected.to have_many(:project_members).dependent(:destroy) }
+ it { is_expected.to have_many(:events) }
+ it { is_expected.to have_many(:merge_requests) }
+ it { is_expected.to have_many(:issues) }
+ it { is_expected.to have_many(:milestones) }
+ it { is_expected.to have_many(:project_members).dependent(:delete_all) }
it { is_expected.to have_many(:users).through(:project_members) }
- it { is_expected.to have_many(:requesters).dependent(:destroy) }
- it { is_expected.to have_many(:notes).dependent(:destroy) }
- it { is_expected.to have_many(:snippets).class_name('ProjectSnippet').dependent(:destroy) }
- it { is_expected.to have_many(:deploy_keys_projects).dependent(:destroy) }
+ it { is_expected.to have_many(:requesters).dependent(:delete_all) }
+ it { is_expected.to have_many(:notes) }
+ it { is_expected.to have_many(:snippets).class_name('ProjectSnippet') }
+ it { is_expected.to have_many(:deploy_keys_projects) }
it { is_expected.to have_many(:deploy_keys) }
- it { is_expected.to have_many(:hooks).dependent(:destroy) }
- it { is_expected.to have_many(:protected_branches).dependent(:destroy) }
- it { is_expected.to have_one(:forked_project_link).dependent(:destroy) }
- it { is_expected.to have_one(:slack_service).dependent(:destroy) }
- it { is_expected.to have_one(:microsoft_teams_service).dependent(:destroy) }
- it { is_expected.to have_one(:mattermost_service).dependent(:destroy) }
- it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
- it { is_expected.to have_one(:asana_service).dependent(:destroy) }
- it { is_expected.to have_many(:boards).dependent(:destroy) }
- it { is_expected.to have_one(:campfire_service).dependent(:destroy) }
- it { is_expected.to have_one(:drone_ci_service).dependent(:destroy) }
- it { is_expected.to have_one(:emails_on_push_service).dependent(:destroy) }
- it { is_expected.to have_one(:pipelines_email_service).dependent(:destroy) }
- it { is_expected.to have_one(:irker_service).dependent(:destroy) }
- it { is_expected.to have_one(:pivotaltracker_service).dependent(:destroy) }
- it { is_expected.to have_one(:hipchat_service).dependent(:destroy) }
- it { is_expected.to have_one(:flowdock_service).dependent(:destroy) }
- it { is_expected.to have_one(:assembla_service).dependent(:destroy) }
- it { is_expected.to have_one(:slack_slash_commands_service).dependent(:destroy) }
- it { is_expected.to have_one(:mattermost_slash_commands_service).dependent(:destroy) }
- it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) }
- it { is_expected.to have_one(:buildkite_service).dependent(:destroy) }
- it { is_expected.to have_one(:bamboo_service).dependent(:destroy) }
- it { is_expected.to have_one(:teamcity_service).dependent(:destroy) }
- it { is_expected.to have_one(:jira_service).dependent(:destroy) }
- it { is_expected.to have_one(:redmine_service).dependent(:destroy) }
- it { is_expected.to have_one(:custom_issue_tracker_service).dependent(:destroy) }
- it { is_expected.to have_one(:bugzilla_service).dependent(:destroy) }
- it { is_expected.to have_one(:gitlab_issue_tracker_service).dependent(:destroy) }
- it { is_expected.to have_one(:external_wiki_service).dependent(:destroy) }
- it { is_expected.to have_one(:project_feature).dependent(:destroy) }
- it { is_expected.to have_one(:statistics).class_name('ProjectStatistics').dependent(:delete) }
- it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) }
+ it { is_expected.to have_many(:hooks) }
+ it { is_expected.to have_many(:protected_branches) }
+ it { is_expected.to have_one(:forked_project_link) }
+ it { is_expected.to have_one(:slack_service) }
+ it { is_expected.to have_one(:microsoft_teams_service) }
+ it { is_expected.to have_one(:mattermost_service) }
+ it { is_expected.to have_one(:pushover_service) }
+ it { is_expected.to have_one(:asana_service) }
+ it { is_expected.to have_many(:boards) }
+ it { is_expected.to have_one(:campfire_service) }
+ it { is_expected.to have_one(:drone_ci_service) }
+ it { is_expected.to have_one(:emails_on_push_service) }
+ it { is_expected.to have_one(:pipelines_email_service) }
+ it { is_expected.to have_one(:irker_service) }
+ it { is_expected.to have_one(:pivotaltracker_service) }
+ it { is_expected.to have_one(:hipchat_service) }
+ it { is_expected.to have_one(:flowdock_service) }
+ it { is_expected.to have_one(:assembla_service) }
+ it { is_expected.to have_one(:slack_slash_commands_service) }
+ it { is_expected.to have_one(:mattermost_slash_commands_service) }
+ it { is_expected.to have_one(:gemnasium_service) }
+ it { is_expected.to have_one(:buildkite_service) }
+ it { is_expected.to have_one(:bamboo_service) }
+ it { is_expected.to have_one(:teamcity_service) }
+ it { is_expected.to have_one(:jira_service) }
+ it { is_expected.to have_one(:redmine_service) }
+ it { is_expected.to have_one(:custom_issue_tracker_service) }
+ it { is_expected.to have_one(:bugzilla_service) }
+ it { is_expected.to have_one(:gitlab_issue_tracker_service) }
+ it { is_expected.to have_one(:external_wiki_service) }
+ it { is_expected.to have_one(:project_feature) }
+ it { is_expected.to have_one(:statistics).class_name('ProjectStatistics') }
+ it { is_expected.to have_one(:import_data).class_name('ProjectImportData') }
it { is_expected.to have_one(:last_event).class_name('Event') }
it { is_expected.to have_one(:forked_from_project).through(:forked_project_link) }
it { is_expected.to have_many(:commit_statuses) }
@@ -62,26 +62,27 @@ describe Project, models: true do
it { is_expected.to have_many(:variables) }
it { is_expected.to have_many(:triggers) }
it { is_expected.to have_many(:pages_domains) }
- it { is_expected.to have_many(:labels).class_name('ProjectLabel').dependent(:destroy) }
- it { is_expected.to have_many(:users_star_projects).dependent(:destroy) }
- it { is_expected.to have_many(:environments).dependent(:destroy) }
- it { is_expected.to have_many(:deployments).dependent(:destroy) }
- it { is_expected.to have_many(:todos).dependent(:destroy) }
- it { is_expected.to have_many(:releases).dependent(:destroy) }
- it { is_expected.to have_many(:lfs_objects_projects).dependent(:destroy) }
- it { is_expected.to have_many(:project_group_links).dependent(:destroy) }
- it { is_expected.to have_many(:notification_settings).dependent(:destroy) }
+ it { is_expected.to have_many(:labels).class_name('ProjectLabel') }
+ it { is_expected.to have_many(:users_star_projects) }
+ it { is_expected.to have_many(:environments) }
+ it { is_expected.to have_many(:deployments) }
+ it { is_expected.to have_many(:todos) }
+ it { is_expected.to have_many(:releases) }
+ it { is_expected.to have_many(:lfs_objects_projects) }
+ it { is_expected.to have_many(:project_group_links) }
+ it { is_expected.to have_many(:notification_settings).dependent(:delete_all) }
it { is_expected.to have_many(:forks).through(:forked_project_links) }
it { is_expected.to have_many(:uploads).dependent(:destroy) }
+ it { is_expected.to have_many(:pipeline_schedules) }
context 'after initialized' do
it "has a project_feature" do
- expect(Project.new.project_feature).to be_present
+ expect(described_class.new.project_feature).to be_present
end
end
describe '#members & #requesters' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:requester) { create(:user) }
let(:developer) { create(:user) }
before do
@@ -130,7 +131,7 @@ describe Project, models: true do
end
describe 'validation' do
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:namespace_id) }
@@ -142,6 +143,10 @@ describe Project, models: true do
it { is_expected.to validate_length_of(:description).is_at_most(2000) }
+ it { is_expected.to validate_length_of(:ci_config_path).is_at_most(255) }
+ it { is_expected.to allow_value('').for(:ci_config_path) }
+ it { is_expected.not_to allow_value('test/../foo').for(:ci_config_path) }
+
it { is_expected.to validate_presence_of(:creator) }
it { is_expected.to validate_presence_of(:namespace) }
@@ -149,7 +154,7 @@ describe Project, models: true do
it { is_expected.to validate_presence_of(:repository_storage) }
it 'does not allow new projects beyond user limits' do
- project2 = build(:empty_project)
+ project2 = build(:project)
allow(project2).to receive(:creator).and_return(double(can_create_project?: false, projects_limit: 0).as_null_object)
expect(project2).not_to be_valid
expect(project2.errors[:limit_reached].first).to match(/Personal project creation is not allowed/)
@@ -158,7 +163,7 @@ describe Project, models: true do
describe 'wiki path conflict' do
context "when the new path has been used by the wiki of other Project" do
it 'has an error on the name attribute' do
- new_project = build_stubbed(:empty_project, namespace_id: project.namespace_id, path: "#{project.path}.wiki")
+ new_project = build_stubbed(:project, namespace_id: project.namespace_id, path: "#{project.path}.wiki")
expect(new_project).not_to be_valid
expect(new_project.errors[:name].first).to eq('has already been taken')
@@ -167,8 +172,8 @@ describe Project, models: true do
context "when the new wiki path has been used by the path of other Project" do
it 'has an error on the name attribute' do
- project_with_wiki_suffix = create(:empty_project, path: 'foo.wiki')
- new_project = build_stubbed(:empty_project, namespace_id: project_with_wiki_suffix.namespace_id, path: 'foo')
+ project_with_wiki_suffix = create(:project, path: 'foo.wiki')
+ new_project = build_stubbed(:project, namespace_id: project_with_wiki_suffix.namespace_id, path: 'foo')
expect(new_project).not_to be_valid
expect(new_project.errors[:name].first).to eq('has already been taken')
@@ -177,7 +182,7 @@ describe Project, models: true do
end
context 'repository storages inclussion' do
- let(:project2) { build(:empty_project, repository_storage: 'missing') }
+ let(:project2) { build(:project, repository_storage: 'missing') }
before do
storages = { 'custom' => { 'path' => 'tmp/tests/custom_repositories' } }
@@ -191,44 +196,44 @@ describe Project, models: true do
end
it 'does not allow an invalid URI as import_url' do
- project2 = build(:empty_project, import_url: 'invalid://')
+ project2 = build(:project, import_url: 'invalid://')
expect(project2).not_to be_valid
end
it 'does allow a valid URI as import_url' do
- project2 = build(:empty_project, import_url: 'ssh://test@gitlab.com/project.git')
+ project2 = build(:project, import_url: 'ssh://test@gitlab.com/project.git')
expect(project2).to be_valid
end
it 'allows an empty URI' do
- project2 = build(:empty_project, import_url: '')
+ project2 = build(:project, import_url: '')
expect(project2).to be_valid
end
it 'does not produce import data on an empty URI' do
- project2 = build(:empty_project, import_url: '')
+ project2 = build(:project, import_url: '')
expect(project2.import_data).to be_nil
end
it 'does not produce import data on an invalid URI' do
- project2 = build(:empty_project, import_url: 'test://')
+ project2 = build(:project, import_url: 'test://')
expect(project2.import_data).to be_nil
end
it "does not allow blocked import_url localhost" do
- project2 = build(:empty_project, import_url: 'http://localhost:9000/t.git')
+ project2 = build(:project, import_url: 'http://localhost:9000/t.git')
expect(project2).to be_invalid
expect(project2.errors[:import_url]).to include('imports are not allowed from that URL')
end
it "does not allow blocked import_url port" do
- project2 = build(:empty_project, import_url: 'http://github.com:25/t.git')
+ project2 = build(:project, import_url: 'http://github.com:25/t.git')
expect(project2).to be_invalid
expect(project2.errors[:import_url]).to include('imports are not allowed from that URL')
@@ -236,11 +241,11 @@ describe Project, models: true do
describe 'project pending deletion' do
let!(:project_pending_deletion) do
- create(:empty_project,
+ create(:project,
pending_delete: true)
end
let(:new_project) do
- build(:empty_project,
+ build(:project,
name: project_pending_deletion.name,
namespace: project_pending_deletion.namespace)
end
@@ -253,25 +258,44 @@ describe Project, models: true do
expect(new_project.errors.full_messages.first).to eq('The project is still being deleted. Please try again later.')
end
end
- end
- describe 'default_scope' do
- it 'excludes projects pending deletion from the results' do
- project = create(:empty_project)
- create(:empty_project, pending_delete: true)
+ describe 'path validation' do
+ it 'allows paths reserved on the root namespace' do
+ project = build(:project, path: 'api')
+
+ expect(project).to be_valid
+ end
+
+ it 'rejects paths reserved on another level' do
+ project = build(:project, path: 'tree')
- expect(Project.all).to eq [project]
+ expect(project).not_to be_valid
+ end
+
+ it 'rejects nested paths' do
+ parent = create(:group, :nested, path: 'environments')
+ project = build(:project, path: 'folders', namespace: parent)
+
+ expect(project).not_to be_valid
+ end
+
+ it 'allows a reserved group name' do
+ parent = create(:group)
+ project = build(:project, path: 'avatar', namespace: parent)
+
+ expect(project).to be_valid
+ end
end
end
describe 'project token' do
it 'sets an random token if none provided' do
- project = FactoryGirl.create :empty_project, runners_token: ''
+ project = FactoryGirl.create :project, runners_token: ''
expect(project.runners_token).not_to eq('')
end
it 'does not set an random token if one provided' do
- project = FactoryGirl.create :empty_project, runners_token: 'my-token'
+ project = FactoryGirl.create :project, runners_token: 'my-token'
expect(project.runners_token).to eq('my-token')
end
end
@@ -282,19 +306,24 @@ describe Project, models: true do
it { is_expected.to respond_to(:execute_hooks) }
it { is_expected.to respond_to(:owner) }
it { is_expected.to respond_to(:path_with_namespace) }
+ it { is_expected.to respond_to(:full_path) }
end
describe 'delegation' do
- it { is_expected.to delegate_method(:add_guest).to(:team) }
- it { is_expected.to delegate_method(:add_reporter).to(:team) }
- it { is_expected.to delegate_method(:add_developer).to(:team) }
- it { is_expected.to delegate_method(:add_master).to(:team) }
+ [:add_guest, :add_reporter, :add_developer, :add_master, :add_user, :add_users].each do |method|
+ it { is_expected.to delegate_method(method).to(:team) }
+ end
+
+ it { is_expected.to delegate_method(:empty_repo?).to(:repository) }
+ it { is_expected.to delegate_method(:members).to(:team).with_prefix(true) }
+ it { is_expected.to delegate_method(:count).to(:forks).with_prefix(true) }
+ it { is_expected.to delegate_method(:name).to(:owner).with_prefix(true).with_arguments(allow_nil: true) }
end
describe '#to_reference' do
let(:owner) { create(:user, name: 'Gitlab') }
let(:namespace) { create(:namespace, path: 'sample-namespace', owner: owner) }
- let(:project) { create(:empty_project, path: 'sample-project', namespace: namespace) }
+ let(:project) { create(:project, path: 'sample-project', namespace: namespace) }
let(:group) { create(:group, name: 'Group', path: 'sample-group', owner: owner) }
context 'when nil argument' do
@@ -318,7 +347,7 @@ describe Project, models: true do
end
context 'when cross namespace project argument' do
- let(:another_namespace_project) { create(:empty_project, name: 'another-project') }
+ let(:another_namespace_project) { create(:project, name: 'another-project') }
it 'returns complete path to the project' do
expect(project.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project'
@@ -326,7 +355,7 @@ describe Project, models: true do
end
context 'when same namespace / cross-project argument' do
- let(:another_project) { create(:empty_project, namespace: namespace) }
+ let(:another_project) { create(:project, namespace: namespace) }
it 'returns path to the project' do
expect(project.to_reference(another_project)).to eq 'sample-project'
@@ -335,7 +364,7 @@ describe Project, models: true do
context 'when different namespace / cross-project argument' do
let(:another_namespace) { create(:namespace, path: 'another-namespace', owner: owner) }
- let(:another_project) { create(:empty_project, path: 'another-project', namespace: another_namespace) }
+ let(:another_project) { create(:project, path: 'another-project', namespace: another_namespace) }
it 'returns full path to the project' do
expect(project.to_reference(another_project)).to eq 'sample-namespace/sample-project'
@@ -360,7 +389,7 @@ describe Project, models: true do
describe '#to_human_reference' do
let(:owner) { create(:user, name: 'Gitlab') }
let(:namespace) { create(:namespace, name: 'Sample namespace', owner: owner) }
- let(:project) { create(:empty_project, name: 'Sample project', namespace: namespace) }
+ let(:project) { create(:project, name: 'Sample project', namespace: namespace) }
context 'when nil argument' do
it 'returns nil' do
@@ -375,7 +404,7 @@ describe Project, models: true do
end
context 'when cross namespace project argument' do
- let(:another_namespace_project) { create(:empty_project, name: 'another-project') }
+ let(:another_namespace_project) { create(:project, name: 'another-project') }
it 'returns complete name with namespace of the project' do
expect(project.to_human_reference(another_namespace_project)).to eq 'Gitlab / Sample project'
@@ -383,7 +412,7 @@ describe Project, models: true do
end
context 'when same namespace / cross-project argument' do
- let(:another_project) { create(:empty_project, namespace: namespace) }
+ let(:another_project) { create(:project, namespace: namespace) }
it 'returns name of the project' do
expect(project.to_human_reference(another_project)).to eq 'Sample project'
@@ -392,7 +421,7 @@ describe Project, models: true do
end
describe '#repository_storage_path' do
- let(:project) { create(:empty_project, repository_storage: 'custom') }
+ let(:project) { create(:project, repository_storage: 'custom') }
before do
FileUtils.mkdir('tmp/tests/custom_repositories')
@@ -410,12 +439,12 @@ describe Project, models: true do
end
it 'returns valid url to repo' do
- project = Project.new(path: 'somewhere')
+ project = described_class.new(path: 'somewhere')
expect(project.url_to_repo).to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + 'somewhere.git')
end
describe "#web_url" do
- let(:project) { create(:empty_project, path: "somewhere") }
+ let(:project) { create(:project, path: "somewhere") }
it 'returns the full web URL for this repo' do
expect(project.web_url).to eq("#{Gitlab.config.gitlab.url}/#{project.namespace.full_path}/somewhere")
@@ -423,7 +452,7 @@ describe Project, models: true do
end
describe "#new_issue_address" do
- let(:project) { create(:empty_project, path: "somewhere") }
+ let(:project) { create(:project, path: "somewhere") }
let(:user) { create(:user) }
context 'incoming email enabled' do
@@ -432,7 +461,7 @@ describe Project, models: true do
end
it 'returns the address to create a new issue' do
- address = "p+#{project.path_with_namespace}+#{user.incoming_email_token}@gl.ab"
+ address = "p+#{project.full_path}+#{user.incoming_email_token}@gl.ab"
expect(project.new_issue_address(user)).to eq(address)
end
@@ -452,11 +481,11 @@ describe Project, models: true do
describe 'last_activity methods' do
let(:timestamp) { 2.hours.ago }
# last_activity_at gets set to created_at upon creation
- let(:project) { create(:empty_project, created_at: timestamp, updated_at: timestamp) }
+ let(:project) { create(:project, created_at: timestamp, updated_at: timestamp) }
describe 'last_activity' do
it 'alias last_activity to last_event' do
- last_event = create(:event, project: project)
+ last_event = create(:event, :closed, project: project)
expect(project.last_activity).to eq(last_event)
end
@@ -464,7 +493,7 @@ describe Project, models: true do
describe 'last_activity_date' do
it 'returns the creation date of the project\'s last event if present' do
- new_event = create(:event, project: project, created_at: Time.now)
+ new_event = create(:event, :closed, project: project, created_at: Time.now)
project.reload
expect(project.last_activity_at.to_i).to eq(new_event.created_at.to_i)
@@ -477,7 +506,7 @@ describe Project, models: true do
end
describe '#get_issue' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let!(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
@@ -505,21 +534,54 @@ describe Project, models: true do
end
context 'with external issues tracker' do
+ let!(:internal_issue) { create(:issue, project: project) }
before do
- allow(project).to receive(:default_issues_tracker?).and_return(false)
+ allow(project).to receive(:external_issue_tracker).and_return(true)
end
- it 'returns an ExternalIssue' do
- issue = project.get_issue('FOO-1234', user)
- expect(issue).to be_kind_of(ExternalIssue)
- expect(issue.iid).to eq 'FOO-1234'
- expect(issue.project).to eq project
+ context 'when internal issues are enabled' do
+ it 'returns interlan issue' do
+ issue = project.get_issue(internal_issue.iid, user)
+
+ expect(issue).to be_kind_of(Issue)
+ expect(issue.iid).to eq(internal_issue.iid)
+ expect(issue.project).to eq(project)
+ end
+
+ it 'returns an ExternalIssue when internal issue does not exists' do
+ issue = project.get_issue('FOO-1234', user)
+
+ expect(issue).to be_kind_of(ExternalIssue)
+ expect(issue.iid).to eq('FOO-1234')
+ expect(issue.project).to eq(project)
+ end
+ end
+
+ context 'when internal issues are disabled' do
+ before do
+ project.issues_enabled = false
+ project.save!
+ end
+
+ it 'returns always an External issues' do
+ issue = project.get_issue(internal_issue.iid, user)
+ expect(issue).to be_kind_of(ExternalIssue)
+ expect(issue.iid).to eq(internal_issue.iid.to_s)
+ expect(issue.project).to eq(project)
+ end
+
+ it 'returns an ExternalIssue when internal issue does not exists' do
+ issue = project.get_issue('FOO-1234', user)
+ expect(issue).to be_kind_of(ExternalIssue)
+ expect(issue.iid).to eq('FOO-1234')
+ expect(issue.project).to eq(project)
+ end
end
end
end
describe '#issue_exists?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'is truthy when issue exists' do
expect(project).to receive(:get_issue).and_return(double)
@@ -536,7 +598,7 @@ describe Project, models: true do
context 'with namespace' do
before do
@group = create :group, name: 'gitlab'
- @project = create(:empty_project, name: 'gitlabhq', namespace: @group)
+ @project = create(:project, name: 'gitlabhq', namespace: @group)
end
it { expect(@project.to_param).to eq('gitlabhq') }
@@ -544,7 +606,7 @@ describe Project, models: true do
context 'with invalid path' do
it 'returns previous path to keep project suitable for use in URLs when persisted' do
- project = create(:empty_project, path: 'gitlab')
+ project = create(:project, path: 'gitlab')
project.path = 'foo&bar'
expect(project).not_to be_valid
@@ -552,7 +614,7 @@ describe Project, models: true do
end
it 'returns current path when new record' do
- project = build(:empty_project, path: 'gitlab')
+ project = build(:project, path: 'gitlab')
project.path = 'foo&bar'
expect(project).not_to be_valid
@@ -571,7 +633,7 @@ describe Project, models: true do
describe '#default_issues_tracker?' do
it "is true if used internal tracker" do
- project = build(:empty_project)
+ project = build(:project)
expect(project.default_issues_tracker?).to be_truthy
end
@@ -585,11 +647,11 @@ describe Project, models: true do
end
describe '#external_issue_tracker' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:ext_project) { create(:redmine_project) }
context 'on existing projects with no value for has_external_issue_tracker' do
- before(:each) do
+ before do
project.update_column(:has_external_issue_tracker, nil)
ext_project.update_column(:has_external_issue_tracker, nil)
end
@@ -620,7 +682,7 @@ describe Project, models: true do
end
describe '#cache_has_external_issue_tracker' do
- let(:project) { create(:empty_project, has_external_issue_tracker: nil) }
+ let(:project) { create(:project, has_external_issue_tracker: nil) }
it 'stores true if there is any external_issue_tracker' do
services = double(:service, external_issue_trackers: [RedmineService.new])
@@ -642,9 +704,9 @@ describe Project, models: true do
end
describe '#has_wiki?' do
- let(:no_wiki_project) { create(:empty_project, :wiki_disabled, has_external_wiki: false) }
- let(:wiki_enabled_project) { create(:empty_project) }
- let(:external_wiki_project) { create(:empty_project, has_external_wiki: true) }
+ let(:no_wiki_project) { create(:project, :wiki_disabled, has_external_wiki: false) }
+ let(:wiki_enabled_project) { create(:project) }
+ let(:external_wiki_project) { create(:project, has_external_wiki: true) }
it 'returns true if project is wiki enabled or has external wiki' do
expect(wiki_enabled_project).to have_wiki
@@ -654,7 +716,7 @@ describe Project, models: true do
end
describe '#external_wiki' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'with an active external wiki' do
before do
@@ -708,7 +770,7 @@ describe Project, models: true do
it 'counts stars from multiple users' do
user1 = create :user
user2 = create :user
- project = create(:empty_project, :public)
+ project = create(:project, :public)
expect(project.star_count).to eq(0)
@@ -730,8 +792,8 @@ describe Project, models: true do
it 'counts stars on the right project' do
user = create :user
- project1 = create(:empty_project, :public)
- project2 = create(:empty_project, :public)
+ project1 = create(:project, :public)
+ project2 = create(:project, :public)
expect(project1.star_count).to eq(0)
expect(project2.star_count).to eq(0)
@@ -763,7 +825,7 @@ describe Project, models: true do
end
describe '#avatar_type' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'is true if avatar is image' do
project.update_attribute(:avatar, 'uploads/avatar.png')
@@ -779,19 +841,21 @@ describe Project, models: true do
describe '#avatar_url' do
subject { project.avatar_url }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
- context 'When avatar file is uploaded' do
- before do
- project.update_columns(avatar: 'uploads/avatar.png')
- allow(project.avatar).to receive(:present?) { true }
- end
+ context 'when avatar file is uploaded' do
+ let(:project) { create(:project, :with_avatar) }
+ let(:avatar_path) { "/uploads/-/system/project/avatar/#{project.id}/dk.png" }
+ let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
- let(:avatar_path) do
- "/uploads/project/avatar/#{project.id}/uploads/avatar.png"
- end
+ it 'shows correct url' do
+ expect(project.avatar_url).to eq(avatar_path)
+ expect(project.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join)
+
+ allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
- it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" }
+ expect(project.avatar_url).to eq([gitlab_host, avatar_path].join)
+ end
end
context 'When avatar file in git' do
@@ -799,17 +863,15 @@ describe Project, models: true do
allow(project).to receive(:avatar_in_git) { true }
end
- let(:avatar_path) do
- "/#{project.full_path}/avatar"
- end
+ let(:avatar_path) { "/#{project.full_path}/avatar" }
- it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" }
+ it { is_expected.to eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" }
end
context 'when git repo is empty' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
- it { should eq nil }
+ it { is_expected.to eq nil }
end
end
@@ -848,7 +910,7 @@ describe Project, models: true do
end
describe '#builds_enabled' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { project.builds_enabled }
@@ -856,10 +918,10 @@ describe Project, models: true do
end
describe '.with_shared_runners' do
- subject { Project.with_shared_runners }
+ subject { described_class.with_shared_runners }
context 'when shared runners are enabled for project' do
- let!(:project) { create(:empty_project, shared_runners_enabled: true) }
+ let!(:project) { create(:project, shared_runners_enabled: true) }
it "returns a project" do
is_expected.to eq([project])
@@ -867,7 +929,7 @@ describe Project, models: true do
end
context 'when shared runners are disabled for project' do
- let!(:project) { create(:empty_project, shared_runners_enabled: false) }
+ let!(:project) { create(:project, shared_runners_enabled: false) }
it "returns an empty array" do
is_expected.to be_empty
@@ -875,24 +937,24 @@ describe Project, models: true do
end
end
- describe '.cached_count', caching: true do
+ describe '.cached_count', :use_clean_rails_memory_store_caching do
let(:group) { create(:group, :public) }
- let!(:project1) { create(:empty_project, :public, group: group) }
- let!(:project2) { create(:empty_project, :public, group: group) }
+ let!(:project1) { create(:project, :public, group: group) }
+ let!(:project2) { create(:project, :public, group: group) }
it 'returns total project count' do
- expect(Project).to receive(:count).once.and_call_original
+ expect(described_class).to receive(:count).once.and_call_original
3.times do
- expect(Project.cached_count).to eq(2)
+ expect(described_class.cached_count).to eq(2)
end
end
end
describe '.trending' do
let(:group) { create(:group, :public) }
- let(:project1) { create(:empty_project, :public, group: group) }
- let(:project2) { create(:empty_project, :public, group: group) }
+ let(:project1) { create(:project, :public, group: group) }
+ let(:project2) { create(:project, :public, group: group) }
before do
2.times do
@@ -919,8 +981,22 @@ describe Project, models: true do
end
end
+ describe '.starred_by' do
+ it 'returns only projects starred by the given user' do
+ user1 = create(:user)
+ user2 = create(:user)
+ project1 = create(:project)
+ project2 = create(:project)
+ create(:project)
+ user1.toggle_star(project1)
+ user2.toggle_star(project2)
+
+ expect(described_class.starred_by(user1)).to contain_exactly(project1)
+ end
+ end
+
describe '.visible_to_user' do
- let!(:project) { create(:empty_project, :private) }
+ let!(:project) { create(:project, :private) }
let!(:user) { create(:user) }
subject { described_class.visible_to_user(user) }
@@ -939,12 +1015,12 @@ describe Project, models: true do
end
context 'repository storage by default' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
storages = {
'default' => { 'path' => 'tmp/tests/repositories' },
- 'picked' => { 'path' => 'tmp/tests/repositories' },
+ 'picked' => { 'path' => 'tmp/tests/repositories' }
}
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
end
@@ -957,25 +1033,29 @@ describe Project, models: true do
end
context 'shared runners by default' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { project.shared_runners_enabled }
context 'are enabled' do
- before { stub_application_setting(shared_runners_enabled: true) }
+ before do
+ stub_application_setting(shared_runners_enabled: true)
+ end
it { is_expected.to be_truthy }
end
context 'are disabled' do
- before { stub_application_setting(shared_runners_enabled: false) }
+ before do
+ stub_application_setting(shared_runners_enabled: false)
+ end
it { is_expected.to be_falsey }
end
end
describe '#any_runners' do
- let(:project) { create(:empty_project, shared_runners_enabled: shared_runners_enabled) }
+ let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
let(:specific_runner) { create(:ci_runner) }
let(:shared_runner) { create(:ci_runner, :shared) }
@@ -1023,7 +1103,7 @@ describe Project, models: true do
subject { project.shared_runners }
context 'when shared runners are enabled for project' do
- let!(:project) { create(:empty_project, shared_runners_enabled: true) }
+ let!(:project) { create(:project, shared_runners_enabled: true) }
it "returns a list of shared runners" do
is_expected.to eq([runner])
@@ -1031,7 +1111,7 @@ describe Project, models: true do
end
context 'when shared runners are disabled for project' do
- let!(:project) { create(:empty_project, shared_runners_enabled: false) }
+ let!(:project) { create(:project, shared_runners_enabled: false) }
it "returns a empty list" do
is_expected.to be_empty
@@ -1040,7 +1120,7 @@ describe Project, models: true do
end
describe '#visibility_level_allowed?' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
context 'when checking on non-forked project' do
it { expect(project.visibility_level_allowed?(Gitlab::VisibilityLevel::PRIVATE)).to be_truthy }
@@ -1049,8 +1129,8 @@ describe Project, models: true do
end
context 'when checking on forked project' do
- let(:project) { create(:empty_project, :internal) }
- let(:forked_project) { create(:empty_project, forked_from_project: project) }
+ let(:project) { create(:project, :internal) }
+ let(:forked_project) { create(:project, forked_from_project: project) }
it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::PRIVATE)).to be_truthy }
it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::INTERNAL)).to be_truthy }
@@ -1059,12 +1139,14 @@ describe Project, models: true do
end
describe '#pages_deployed?' do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
subject { project.pages_deployed? }
context 'if public folder does exist' do
- before { allow(Dir).to receive(:exist?).with(project.public_pages_path).and_return(true) }
+ before do
+ allow(Dir).to receive(:exist?).with(project.public_pages_path).and_return(true)
+ end
it { is_expected.to be_truthy }
end
@@ -1074,8 +1156,35 @@ describe Project, models: true do
end
end
+ describe '#pages_url' do
+ let(:group) { create :group, name: group_name }
+ let(:project) { create :project, namespace: group, name: project_name }
+ let(:domain) { 'Example.com' }
+
+ subject { project.pages_url }
+
+ before do
+ allow(Settings.pages).to receive(:host).and_return(domain)
+ allow(Gitlab.config.pages).to receive(:url).and_return('http://example.com')
+ end
+
+ context 'group page' do
+ let(:group_name) { 'Group' }
+ let(:project_name) { 'group.example.com' }
+
+ it { is_expected.to eq("http://group.example.com") }
+ end
+
+ context 'project page' do
+ let(:group_name) { 'Group' }
+ let(:project_name) { 'Project' }
+
+ it { is_expected.to eq("http://group.example.com/project") }
+ end
+ end
+
describe '.search' do
- let(:project) { create(:empty_project, description: 'kitten mittens') }
+ let(:project) { create(:project, description: 'kitten mittens') }
it 'returns projects with a matching name' do
expect(described_class.search(project.name)).to eq([project])
@@ -1130,6 +1239,16 @@ describe Project, models: true do
expect(relation.search(project.namespace.name)).to eq([project])
end
+
+ describe 'with pending_delete project' do
+ let(:pending_delete_project) { create(:project, pending_delete: true) }
+
+ it 'shows pending deletion project' do
+ search_result = described_class.search(pending_delete_project.name)
+
+ expect(search_result).to eq([pending_delete_project])
+ end
+ end
end
describe '#rename_repo' do
@@ -1146,26 +1265,28 @@ describe Project, models: true do
it 'renames a repository' do
stub_container_registry_config(enabled: false)
- expect(gitlab_shell).to receive(:mv_repository).
- ordered.
- with(project.repository_storage_path, "#{project.namespace.full_path}/foo", "#{project.full_path}").
- and_return(true)
+ expect(gitlab_shell).to receive(:mv_repository)
+ .ordered
+ .with(project.repository_storage_path, "#{project.namespace.full_path}/foo", "#{project.full_path}")
+ .and_return(true)
- expect(gitlab_shell).to receive(:mv_repository).
- ordered.
- with(project.repository_storage_path, "#{project.namespace.full_path}/foo.wiki", "#{project.full_path}.wiki").
- and_return(true)
+ expect(gitlab_shell).to receive(:mv_repository)
+ .ordered
+ .with(project.repository_storage_path, "#{project.namespace.full_path}/foo.wiki", "#{project.full_path}.wiki")
+ .and_return(true)
- expect_any_instance_of(SystemHooksService).
- to receive(:execute_hooks_for).
- with(project, :rename)
+ expect_any_instance_of(SystemHooksService)
+ .to receive(:execute_hooks_for)
+ .with(project, :rename)
- expect_any_instance_of(Gitlab::UploadsTransfer).
- to receive(:rename_project).
- with('foo', project.path, project.namespace.full_path)
+ expect_any_instance_of(Gitlab::UploadsTransfer)
+ .to receive(:rename_project)
+ .with('foo', project.path, project.namespace.full_path)
expect(project).to receive(:expire_caches_before_rename)
+ expect(project).to receive(:expires_full_path_cache)
+
project.rename_repo
end
@@ -1180,7 +1301,7 @@ describe Project, models: true do
subject { project.rename_repo }
- it { expect{subject}.to raise_error(Exception) }
+ it { expect {subject}.to raise_error(StandardError) }
end
end
@@ -1190,13 +1311,13 @@ describe Project, models: true do
let(:wiki) { double(:wiki, exists?: true) }
it 'expires the caches of the repository and wiki' do
- allow(Repository).to receive(:new).
- with('foo', project).
- and_return(repo)
+ allow(Repository).to receive(:new)
+ .with('foo', project)
+ .and_return(repo)
- allow(Repository).to receive(:new).
- with('foo.wiki', project).
- and_return(wiki)
+ allow(Repository).to receive(:new)
+ .with('foo.wiki', project)
+ .and_return(wiki)
expect(repo).to receive(:before_delete)
expect(wiki).to receive(:before_delete)
@@ -1206,7 +1327,7 @@ describe Project, models: true do
end
describe '.search_by_title' do
- let(:project) { create(:empty_project, name: 'kittens') }
+ let(:project) { create(:project, name: 'kittens') }
it 'returns projects with a matching name' do
expect(described_class.search_by_title(project.name)).to eq([project])
@@ -1225,8 +1346,8 @@ describe Project, models: true do
let(:private_group) { create(:group, visibility_level: 0) }
let(:internal_group) { create(:group, visibility_level: 10) }
- let(:private_project) { create :empty_project, :private, group: private_group }
- let(:internal_project) { create :empty_project, :internal, group: internal_group }
+ let(:private_project) { create :project, :private, group: private_group }
+ let(:internal_project) { create :project, :internal, group: internal_group }
context 'when group is private project can not be internal' do
it { expect(private_project.visibility_level_allowed?(Gitlab::VisibilityLevel::INTERNAL)).to be_falsey }
@@ -1247,9 +1368,9 @@ describe Project, models: true do
context 'using a regular repository' do
it 'creates the repository' do
- expect(shell).to receive(:add_repository).
- with(project.repository_storage_path, project.path_with_namespace).
- and_return(true)
+ expect(shell).to receive(:add_repository)
+ .with(project.repository_storage_path, project.disk_path)
+ .and_return(true)
expect(project.repository).to receive(:after_create)
@@ -1257,9 +1378,9 @@ describe Project, models: true do
end
it 'adds an error if the repository could not be created' do
- expect(shell).to receive(:add_repository).
- with(project.repository_storage_path, project.path_with_namespace).
- and_return(false)
+ expect(shell).to receive(:add_repository)
+ .with(project.repository_storage_path, project.disk_path)
+ .and_return(false)
expect(project.repository).not_to receive(:after_create)
@@ -1278,8 +1399,52 @@ describe Project, models: true do
end
end
+ describe '#ensure_repository' do
+ let(:project) { create(:project, :repository) }
+ let(:shell) { Gitlab::Shell.new }
+
+ before do
+ allow(project).to receive(:gitlab_shell).and_return(shell)
+ end
+
+ it 'creates the repository if it not exist' do
+ allow(project).to receive(:repository_exists?)
+ .and_return(false)
+
+ allow(shell).to receive(:add_repository)
+ .with(project.repository_storage_path, project.disk_path)
+ .and_return(true)
+
+ expect(project).to receive(:create_repository).with(force: true)
+
+ project.ensure_repository
+ end
+
+ it 'does not create the repository if it exists' do
+ allow(project).to receive(:repository_exists?)
+ .and_return(true)
+
+ expect(project).not_to receive(:create_repository)
+
+ project.ensure_repository
+ end
+
+ it 'creates the repository if it is a fork' do
+ expect(project).to receive(:forked?).and_return(true)
+
+ allow(project).to receive(:repository_exists?)
+ .and_return(false)
+
+ expect(shell).to receive(:add_repository)
+ .with(project.repository_storage_path, project.disk_path)
+ .and_return(true)
+
+ project.ensure_repository
+ end
+ end
+
describe '#user_can_push_to_empty_repo?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
it 'returns false when default_branch_protection is in full protection and user is developer' do
@@ -1318,11 +1483,13 @@ describe Project, models: true do
end
describe '#container_registry_url' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject { project.container_registry_url }
- before { stub_container_registry_config(**registry_settings) }
+ before do
+ stub_container_registry_config(**registry_settings)
+ end
context 'for enabled registry' do
let(:registry_settings) do
@@ -1343,10 +1510,12 @@ describe Project, models: true do
end
describe '#has_container_registry_tags?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'when container registry is enabled' do
- before { stub_container_registry_config(enabled: true) }
+ before do
+ stub_container_registry_config(enabled: true)
+ end
context 'when tags are present for multi-level registries' do
before do
@@ -1384,7 +1553,9 @@ describe Project, models: true do
end
context 'when container registry is disabled' do
- before { stub_container_registry_config(enabled: false) }
+ before do
+ stub_container_registry_config(enabled: false)
+ end
it 'should not have image tags' do
expect(project).not_to have_container_registry_tags
@@ -1402,6 +1573,89 @@ describe Project, models: true do
end
end
+ describe '#ci_config_path=' do
+ let(:project) { create(:project) }
+
+ it 'sets nil' do
+ project.update!(ci_config_path: nil)
+
+ expect(project.ci_config_path).to be_nil
+ end
+
+ it 'sets a string' do
+ project.update!(ci_config_path: 'foo/.gitlab_ci.yml')
+
+ expect(project.ci_config_path).to eq('foo/.gitlab_ci.yml')
+ end
+
+ it 'sets a string but removes all leading slashes and null characters' do
+ project.update!(ci_config_path: "///f\0oo/\0/.gitlab_ci.yml")
+
+ expect(project.ci_config_path).to eq('foo//.gitlab_ci.yml')
+ end
+ end
+
+ describe 'Project import job' do
+ let(:project) { create(:project, import_url: generate(:url)) }
+
+ before do
+ allow_any_instance_of(Gitlab::Shell).to receive(:import_repository)
+ .with(project.repository_storage_path, project.disk_path, project.import_url)
+ .and_return(true)
+
+ expect_any_instance_of(Repository).to receive(:after_import)
+ .and_call_original
+ end
+
+ it 'imports a project' do
+ expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original
+
+ expect { project.import_schedule }.to change { project.import_jid }
+ expect(project.reload.import_status).to eq('finished')
+ end
+ end
+
+ describe 'project import state transitions' do
+ context 'state transition: [:started] => [:finished]' do
+ let(:housekeeping_service) { spy }
+
+ before do
+ allow(Projects::HousekeepingService).to receive(:new) { housekeeping_service }
+ end
+
+ it 'resets project import_error' do
+ error_message = 'Some error'
+ mirror = create(:project_empty_repo, :import_started, import_error: error_message)
+
+ expect { mirror.import_finish }.to change { mirror.import_error }.from(error_message).to(nil)
+ end
+
+ it 'performs housekeeping when an import of a fresh project is completed' do
+ project = create(:project_empty_repo, :import_started, import_type: :github)
+
+ project.import_finish
+
+ expect(housekeeping_service).to have_received(:execute)
+ end
+
+ it 'does not perform housekeeping when project repository does not exist' do
+ project = create(:project, :import_started, import_type: :github)
+
+ project.import_finish
+
+ expect(housekeeping_service).not_to have_received(:execute)
+ end
+
+ it 'does not perform housekeeping when project does not have a valid import type' do
+ project = create(:project, :import_started, import_type: nil)
+
+ project.import_finish
+
+ expect(housekeeping_service).not_to have_received(:execute)
+ end
+ end
+ end
+
describe '#latest_successful_builds_for' do
def create_pipeline(status = 'success')
create(:ci_pipeline, project: project,
@@ -1482,45 +1736,48 @@ describe Project, models: true do
end
describe '#add_import_job' do
+ let(:import_jid) { '123' }
+
context 'forked' do
- let(:forked_project_link) { create(:forked_project_link) }
+ let(:forked_project_link) { create(:forked_project_link, :forked_to_empty_project) }
let(:forked_from_project) { forked_project_link.forked_from_project }
let(:project) { forked_project_link.forked_to_project }
it 'schedules a RepositoryForkWorker job' do
- expect(RepositoryForkWorker).to receive(:perform_async).
- with(project.id, forked_from_project.repository_storage_path,
- forked_from_project.path_with_namespace, project.namespace.full_path)
+ expect(RepositoryForkWorker).to receive(:perform_async).with(
+ project.id,
+ forked_from_project.repository_storage_path,
+ forked_from_project.disk_path,
+ project.namespace.full_path).and_return(import_jid)
- project.add_import_job
+ expect(project.add_import_job).to eq(import_jid)
end
end
context 'not forked' do
- let(:project) { create(:empty_project) }
-
it 'schedules a RepositoryImportWorker job' do
- expect(RepositoryImportWorker).to receive(:perform_async).with(project.id)
+ project = create(:project, import_url: generate(:url))
- project.add_import_job
+ expect(RepositoryImportWorker).to receive(:perform_async).with(project.id).and_return(import_jid)
+ expect(project.add_import_job).to eq(import_jid)
end
end
end
describe '#gitlab_project_import?' do
- subject(:project) { build(:empty_project, import_type: 'gitlab_project') }
+ subject(:project) { build(:project, import_type: 'gitlab_project') }
it { expect(project.gitlab_project_import?).to be true }
end
describe '#gitea_import?' do
- subject(:project) { build(:empty_project, import_type: 'gitea') }
+ subject(:project) { build(:project, import_type: 'gitea') }
it { expect(project.gitea_import?).to be true }
end
describe '#lfs_enabled?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
shared_examples 'project overrides group' do
it 'returns true when enabled in project' do
@@ -1584,6 +1841,11 @@ describe Project, models: true do
describe '#change_head' do
let(:project) { create(:project, :repository) }
+ it 'returns error if branch does not exist' do
+ expect(project.change_head('unexisted-branch')).to be false
+ expect(project.errors.size).to eq(1)
+ end
+
it 'calls the before_change_head and after_change_head methods' do
expect(project.repository).to receive(:before_change_head)
expect(project.repository).to receive(:after_change_head)
@@ -1610,7 +1872,7 @@ describe Project, models: true do
end
describe '#pushes_since_gc' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
after do
project.reset_pushes_since_gc
@@ -1632,7 +1894,7 @@ describe Project, models: true do
end
describe '#increment_pushes_since_gc' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
after do
project.reset_pushes_since_gc
@@ -1646,7 +1908,7 @@ describe Project, models: true do
end
describe '#reset_pushes_since_gc' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
after do
project.reset_pushes_since_gc
@@ -1663,7 +1925,7 @@ describe Project, models: true do
describe '#deployment_variables' do
context 'when project has no deployment service' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'returns an empty array' do
expect(project.deployment_variables).to eq []
@@ -1681,8 +1943,92 @@ describe Project, models: true do
end
end
+ describe '#secret_variables_for' do
+ let(:project) { create(:project) }
+
+ let!(:secret_variable) do
+ create(:ci_variable, value: 'secret', project: project)
+ end
+
+ let!(:protected_variable) do
+ create(:ci_variable, :protected, value: 'protected', project: project)
+ end
+
+ subject { project.secret_variables_for(ref: 'ref') }
+
+ before do
+ stub_application_setting(
+ default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+ end
+
+ shared_examples 'ref is protected' do
+ it 'contains all the variables' do
+ is_expected.to contain_exactly(secret_variable, protected_variable)
+ end
+ end
+
+ context 'when the ref is not protected' do
+ it 'contains only the secret variables' do
+ is_expected.to contain_exactly(secret_variable)
+ end
+ end
+
+ context 'when the ref is a protected branch' do
+ before do
+ create(:protected_branch, name: 'ref', project: project)
+ end
+
+ it_behaves_like 'ref is protected'
+ end
+
+ context 'when the ref is a protected tag' do
+ before do
+ create(:protected_tag, name: 'ref', project: project)
+ end
+
+ it_behaves_like 'ref is protected'
+ end
+ end
+
+ describe '#protected_for?' do
+ let(:project) { create(:project) }
+
+ subject { project.protected_for?('ref') }
+
+ context 'when the ref is not protected' do
+ before do
+ stub_application_setting(
+ default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+ end
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when the ref is a protected branch' do
+ before do
+ create(:protected_branch, name: 'ref', project: project)
+ end
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when the ref is a protected tag' do
+ before do
+ create(:protected_tag, name: 'ref', project: project)
+ end
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+ end
+
describe '#update_project_statistics' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it "is called after creation" do
expect(project.statistics).to be_a ProjectStatistics
@@ -1702,18 +2048,18 @@ describe Project, models: true do
end
describe 'inside_path' do
- let!(:project1) { create(:empty_project, namespace: create(:namespace, path: 'name_pace')) }
- let!(:project2) { create(:empty_project) }
- let!(:project3) { create(:empty_project, namespace: create(:namespace, path: 'namespace')) }
+ let!(:project1) { create(:project, namespace: create(:namespace, path: 'name_pace')) }
+ let!(:project2) { create(:project) }
+ let!(:project3) { create(:project, namespace: create(:namespace, path: 'namespace')) }
let!(:path) { project1.namespace.full_path }
it 'returns correct project' do
- expect(Project.inside_path(path)).to eq([project1])
+ expect(described_class.inside_path(path)).to eq([project1])
end
end
describe '#route_map_for' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:route_map) do
<<-MAP.strip_heredoc
- source: /source/(.*)/
@@ -1750,7 +2096,7 @@ describe Project, models: true do
end
describe '#public_path_for_source_path' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:route_map) do
Gitlab::RouteMap.new(<<-MAP.strip_heredoc)
- source: /source/(.*)/
@@ -1789,15 +2135,17 @@ describe Project, models: true do
end
describe '#parent' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it { expect(project.parent).to eq(project.namespace) }
end
describe '#parent_changed?' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
- before { project.namespace_id = 7 }
+ before do
+ project.namespace_id = 7
+ end
it { expect(project.parent_changed?).to be_truthy }
end
@@ -1819,7 +2167,7 @@ describe Project, models: true do
end
context 'top-level group' do
- let(:project) { create :empty_project, namespace: group, name: project_name }
+ let(:project) { create :project, namespace: group, name: project_name }
context 'group page' do
let(:project_name) { 'group.example.com' }
@@ -1835,7 +2183,7 @@ describe Project, models: true do
end
context 'nested group' do
- let(:project) { create :empty_project, namespace: nested_group, name: project_name }
+ let(:project) { create :project, namespace: nested_group, name: project_name }
let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" }
context 'group page' do
@@ -1853,32 +2201,170 @@ describe Project, models: true do
end
describe '#http_url_to_repo' do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
+
+ it 'returns the url to the repo without a username' do
+ expect(project.http_url_to_repo).to eq("#{project.web_url}.git")
+ expect(project.http_url_to_repo).not_to include('@')
+ end
+ end
+
+ describe '#pipeline_status' do
+ let(:project) { create(:project, :repository) }
+ it 'builds a pipeline status' do
+ expect(project.pipeline_status).to be_a(Gitlab::Cache::Ci::ProjectPipelineStatus)
+ end
- context 'when no user is given' do
- it 'returns the url to the repo without a username' do
- expect(project.http_url_to_repo).to eq("#{project.web_url}.git")
- expect(project.http_url_to_repo).not_to include('@')
+ it 'hase a loaded pipeline status' do
+ expect(project.pipeline_status).to be_loaded
+ end
+ end
+
+ describe '#append_or_update_attribute' do
+ let(:project) { create(:project) }
+
+ it 'shows full error updating an invalid MR' do
+ error_message = 'Failed to replace merge_requests because one or more of the new records could not be saved.'\
+ ' Validate fork Source project is not a fork of the target project'
+
+ expect { project.append_or_update_attribute(:merge_requests, [create(:merge_request)]) }
+ .to raise_error(ActiveRecord::RecordNotSaved, error_message)
+ end
+
+ it 'updates the project succesfully' do
+ merge_request = create(:merge_request, target_project: project, source_project: project)
+
+ expect { project.append_or_update_attribute(:merge_requests, [merge_request]) }
+ .not_to raise_error
+ end
+ end
+
+ describe '#last_repository_updated_at' do
+ it 'sets to created_at upon creation' do
+ project = create(:project, created_at: 2.hours.ago)
+
+ expect(project.last_repository_updated_at.to_i).to eq(project.created_at.to_i)
+ end
+ end
+
+ describe '.public_or_visible_to_user' do
+ let!(:user) { create(:user) }
+
+ let!(:private_project) do
+ create(:project, :private, creator: user, namespace: user.namespace)
+ end
+
+ let!(:public_project) { create(:project, :public) }
+
+ context 'with a user' do
+ let(:projects) do
+ described_class.all.public_or_visible_to_user(user)
+ end
+
+ it 'includes projects the user has access to' do
+ expect(projects).to include(private_project)
+ end
+
+ it 'includes projects the user can see' do
+ expect(projects).to include(public_project)
end
end
- context 'when user is given' do
- it 'returns the url to the repo with the username' do
- user = build_stubbed(:user)
+ context 'without a user' do
+ it 'only includes public projects' do
+ projects = described_class.all.public_or_visible_to_user
- expect(project.http_url_to_repo(user)).to start_with("http://#{user.username}@")
+ expect(projects).to eq([public_project])
end
end
end
- describe '#pipeline_status' do
+ describe '#remove_private_deploy_keys' do
+ let!(:project) { create(:project) }
+
+ context 'for a private deploy key' do
+ let!(:key) { create(:deploy_key, public: false) }
+ let!(:deploy_keys_project) { create(:deploy_keys_project, deploy_key: key, project: project) }
+
+ context 'when the key is not linked to another project' do
+ it 'removes the key' do
+ project.remove_private_deploy_keys
+
+ expect(project.deploy_keys).not_to include(key)
+ end
+ end
+
+ context 'when the key is linked to another project' do
+ before do
+ another_project = create(:project)
+ create(:deploy_keys_project, deploy_key: key, project: another_project)
+ end
+
+ it 'does not remove the key' do
+ project.remove_private_deploy_keys
+
+ expect(project.deploy_keys).to include(key)
+ end
+ end
+ end
+
+ context 'for a public deploy key' do
+ let!(:key) { create(:deploy_key, public: true) }
+ let!(:deploy_keys_project) { create(:deploy_keys_project, deploy_key: key, project: project) }
+
+ it 'does not remove the key' do
+ project.remove_private_deploy_keys
+
+ expect(project.deploy_keys).to include(key)
+ end
+ end
+ end
+
+ describe '#remove_pages' do
let(:project) { create(:project) }
- it 'builds a pipeline status' do
- expect(project.pipeline_status).to be_a(Gitlab::Cache::Ci::ProjectPipelineStatus)
+ let(:namespace) { project.namespace }
+ let(:pages_path) { project.pages_path }
+
+ around do |example|
+ FileUtils.mkdir_p(pages_path)
+ begin
+ example.run
+ ensure
+ FileUtils.rm_rf(pages_path)
+ end
end
- it 'hase a loaded pipeline status' do
- expect(project.pipeline_status).to be_loaded
+ it 'removes the pages directory' do
+ expect_any_instance_of(Projects::UpdatePagesConfigurationService).to receive(:execute)
+ expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return(true)
+ expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, namespace.full_path, anything)
+
+ project.remove_pages
+ end
+
+ it 'is a no-op when there is no namespace' do
+ project.update_column(:namespace_id, nil)
+
+ expect_any_instance_of(Projects::UpdatePagesConfigurationService).not_to receive(:execute)
+ expect_any_instance_of(Gitlab::PagesTransfer).not_to receive(:rename_project)
+
+ project.remove_pages
+ end
+
+ it 'is run when the project is destroyed' do
+ expect(project).to receive(:remove_pages).and_call_original
+
+ project.destroy
+ end
+ end
+
+ describe '#forks_count' do
+ it 'returns the number of forks' do
+ project = build(:project)
+
+ allow(project.forks).to receive(:count).and_return(1)
+
+ expect(project.forks_count).to eq(1)
end
end
end
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index ff29f6f66ba..59e20e84c2f 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
-describe ProjectStatistics, models: true do
- let(:project) { create :empty_project }
+describe ProjectStatistics do
+ let(:project) { create :project }
let(:statistics) { project.statistics }
describe 'constants' do
@@ -35,7 +35,7 @@ describe ProjectStatistics, models: true do
commit_count: 8.exabytes - 1,
repository_size: 2.exabytes,
lfs_objects_size: 2.exabytes,
- build_artifacts_size: 4.exabytes - 1,
+ build_artifacts_size: 4.exabytes - 1
)
statistics.reload
@@ -149,7 +149,7 @@ describe ProjectStatistics, models: true do
it "sums all storage counters" do
statistics.update!(
repository_size: 2,
- lfs_objects_size: 3,
+ lfs_objects_size: 3
)
statistics.reload
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index 942eeab251d..314824b32da 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -1,13 +1,13 @@
require "spec_helper"
-describe ProjectTeam, models: true do
+describe ProjectTeam do
let(:master) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:nonmember) { create(:user) }
context 'personal project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
project.add_master(master)
@@ -37,7 +37,7 @@ describe ProjectTeam, models: true do
context 'group project' do
let(:group) { create(:group) }
- let!(:project) { create(:empty_project, group: group) }
+ let!(:project) { create(:project, group: group) }
before do
group.add_master(master)
@@ -75,13 +75,13 @@ describe ProjectTeam, models: true do
describe '#fetch_members' do
context 'personal project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'returns project members' do
user = create(:user)
project.add_guest(user)
- expect(project.team.members).to contain_exactly(user)
+ expect(project.team.members).to contain_exactly(user, project.owner)
end
it 'returns project members of a specified level' do
@@ -100,7 +100,8 @@ describe ProjectTeam, models: true do
group_access: Gitlab::Access::GUEST
)
- expect(project.team.members).to contain_exactly(group_member.user)
+ expect(project.team.members)
+ .to contain_exactly(group_member.user, project.owner)
end
it 'returns invited members of a group of a specified level' do
@@ -118,7 +119,7 @@ describe ProjectTeam, models: true do
context 'group project' do
let(:group) { create(:group) }
- let!(:project) { create(:empty_project, group: group) }
+ let!(:project) { create(:project, group: group) }
it 'returns project members' do
group_member = create(:group_member, group: group)
@@ -137,7 +138,10 @@ describe ProjectTeam, models: true do
describe '#find_member' do
context 'personal project' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) do
+ create(:project, :public, :access_requestable)
+ end
+
let(:requester) { create(:user) }
before do
@@ -156,7 +160,7 @@ describe ProjectTeam, models: true do
context 'group project' do
let(:group) { create(:group, :access_requestable) }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
let(:requester) { create(:user) }
before do
@@ -178,7 +182,7 @@ describe ProjectTeam, models: true do
it 'returns Master role' do
user = create(:user)
group = create(:group)
- project = create(:empty_project, namespace: group)
+ project = create(:project, namespace: group)
group.add_master(user)
@@ -188,7 +192,7 @@ describe ProjectTeam, models: true do
it 'returns Owner role' do
user = create(:user)
group = create(:group)
- project = create(:empty_project, namespace: group)
+ project = create(:project, namespace: group)
group.add_owner(user)
@@ -200,7 +204,9 @@ describe ProjectTeam, models: true do
let(:requester) { create(:user) }
context 'personal project' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) do
+ create(:project, :public, :access_requestable)
+ end
context 'when project is not shared with group' do
before do
@@ -234,7 +240,9 @@ describe ProjectTeam, models: true do
it { expect(project.team.max_member_access(requester.id)).to eq(Gitlab::Access::NO_ACCESS) }
context 'but share_with_group_lock is true' do
- before { project.namespace.update(share_with_group_lock: true) }
+ before do
+ project.namespace.update(share_with_group_lock: true)
+ end
it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::NO_ACCESS) }
it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::NO_ACCESS) }
@@ -244,7 +252,9 @@ describe ProjectTeam, models: true do
context 'group project' do
let(:group) { create(:group, :access_requestable) }
- let!(:project) { create(:empty_project, group: group) }
+ let!(:project) do
+ create(:project, group: group)
+ end
before do
group.add_master(master)
@@ -265,10 +275,17 @@ describe ProjectTeam, models: true do
let(:group) { create(:group) }
let(:developer) { create(:user) }
let(:master) { create(:user) }
- let(:personal_project) { create(:empty_project, namespace: developer.namespace) }
- let(:group_project) { create(:empty_project, namespace: group) }
- let(:members_project) { create(:empty_project) }
- let(:shared_project) { create(:empty_project) }
+
+ let(:personal_project) do
+ create(:project, namespace: developer.namespace)
+ end
+
+ let(:group_project) do
+ create(:project, namespace: group)
+ end
+
+ let(:members_project) { create(:project) }
+ let(:shared_project) { create(:project) }
before do
group.add_master(master)
@@ -312,69 +329,105 @@ describe ProjectTeam, models: true do
end
end
- shared_examples_for "#max_member_access_for_users" do |enable_request_store|
- describe "#max_member_access_for_users" do
- before do
- RequestStore.begin! if enable_request_store
+ shared_examples 'max member access for users' do
+ let(:project) { create(:project) }
+ let(:group) { create(:group) }
+ let(:second_group) { create(:group) }
+
+ let(:master) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:guest) { create(:user) }
+
+ let(:promoted_guest) { create(:user) }
+
+ let(:group_developer) { create(:user) }
+ let(:second_developer) { create(:user) }
+
+ let(:user_without_access) { create(:user) }
+ let(:second_user_without_access) { create(:user) }
+
+ let(:users) do
+ [master, reporter, promoted_guest, guest, group_developer, second_developer, user_without_access].map(&:id)
+ end
+
+ let(:expected) do
+ {
+ master.id => Gitlab::Access::MASTER,
+ reporter.id => Gitlab::Access::REPORTER,
+ promoted_guest.id => Gitlab::Access::DEVELOPER,
+ guest.id => Gitlab::Access::GUEST,
+ group_developer.id => Gitlab::Access::DEVELOPER,
+ second_developer.id => Gitlab::Access::MASTER,
+ user_without_access.id => Gitlab::Access::NO_ACCESS
+ }
+ end
+
+ before do
+ project.add_master(master)
+ project.add_reporter(reporter)
+ project.add_guest(promoted_guest)
+ project.add_guest(guest)
+
+ project.project_group_links.create(
+ group: group,
+ group_access: Gitlab::Access::DEVELOPER
+ )
+
+ group.add_master(promoted_guest)
+ group.add_developer(group_developer)
+ group.add_developer(second_developer)
+
+ project.project_group_links.create(
+ group: second_group,
+ group_access: Gitlab::Access::MASTER
+ )
+
+ second_group.add_master(second_developer)
+ end
+
+ it 'returns correct roles for different users' do
+ expect(project.team.max_member_access_for_user_ids(users)).to eq(expected)
+ end
+ end
+
+ describe '#max_member_access_for_user_ids' do
+ context 'with RequestStore enabled', :request_store do
+ include_examples 'max member access for users'
+
+ def access_levels(users)
+ project.team.max_member_access_for_user_ids(users)
end
- after do
- if enable_request_store
- RequestStore.end!
- RequestStore.clear!
- end
+ it 'does not perform extra queries when asked for users who have already been found' do
+ access_levels(users)
+
+ expect { access_levels(users) }.not_to exceed_query_limit(0)
+
+ expect(access_levels(users)).to eq(expected)
end
- it 'returns correct roles for different users' do
- master = create(:user)
- reporter = create(:user)
- promoted_guest = create(:user)
- guest = create(:user)
- project = create(:empty_project)
+ it 'only requests the extra users when uncached users are passed' do
+ new_user = create(:user)
+ second_new_user = create(:user)
+ all_users = users + [new_user.id, second_new_user.id]
- project.add_master(master)
- project.add_reporter(reporter)
- project.add_guest(promoted_guest)
- project.add_guest(guest)
+ expected_all = expected.merge(new_user.id => Gitlab::Access::NO_ACCESS,
+ second_new_user.id => Gitlab::Access::NO_ACCESS)
- group = create(:group)
- group_developer = create(:user)
- second_developer = create(:user)
- project.project_group_links.create(
- group: group,
- group_access: Gitlab::Access::DEVELOPER)
-
- group.add_master(promoted_guest)
- group.add_developer(group_developer)
- group.add_developer(second_developer)
-
- second_group = create(:group)
- project.project_group_links.create(
- group: second_group,
- group_access: Gitlab::Access::MASTER)
- second_group.add_master(second_developer)
-
- users = [master, reporter, promoted_guest, guest, group_developer, second_developer].map(&:id)
-
- expected = {
- master.id => Gitlab::Access::MASTER,
- reporter.id => Gitlab::Access::REPORTER,
- promoted_guest.id => Gitlab::Access::DEVELOPER,
- guest.id => Gitlab::Access::GUEST,
- group_developer.id => Gitlab::Access::DEVELOPER,
- second_developer.id => Gitlab::Access::MASTER
- }
-
- expect(project.team.max_member_access_for_user_ids(users)).to eq(expected)
+ access_levels(users)
+
+ queries = ActiveRecord::QueryRecorder.new { access_levels(all_users) }
+
+ expect(queries.count).to eq(1)
+ expect(queries.log_message).to match(/\W#{new_user.id}\W/)
+ expect(queries.log_message).to match(/\W#{second_new_user.id}\W/)
+ expect(queries.log_message).not_to match(/\W#{promoted_guest.id}\W/)
+ expect(access_levels(all_users)).to eq(expected_all)
end
end
- end
- describe '#max_member_access_for_users with RequestStore' do
- it_behaves_like "#max_member_access_for_users", true
- end
-
- describe '#max_member_access_for_users without RequestStore' do
- it_behaves_like "#max_member_access_for_users", false
+ context 'with RequestStore disabled' do
+ include_examples 'max member access for users'
+ end
end
end
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index b5b9cd024b0..953df7746eb 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -1,30 +1,37 @@
require "spec_helper"
-describe ProjectWiki, models: true do
- let(:project) { create(:empty_project) }
+describe ProjectWiki do
+ let(:project) { create(:project) }
let(:repository) { project.repository }
let(:user) { project.owner }
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project_wiki) { ProjectWiki.new(project, user) }
+ let(:project_wiki) { described_class.new(project, user) }
subject { project_wiki }
- before { project_wiki.wiki }
+
+ before do
+ project_wiki.wiki
+ end
describe "#path_with_namespace" do
it "returns the project path with namespace with the .wiki extension" do
- expect(subject.path_with_namespace).to eq(project.path_with_namespace + ".wiki")
+ expect(subject.path_with_namespace).to eq(project.full_path + '.wiki')
+ end
+
+ it 'returns the same value as #full_path' do
+ expect(subject.path_with_namespace).to eq(subject.full_path)
end
end
describe '#web_url' do
it 'returns the full web URL to the wiki' do
- expect(subject.web_url).to eq("#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/wikis/home")
+ expect(subject.web_url).to eq("#{Gitlab.config.gitlab.url}/#{project.full_path}/wikis/home")
end
end
describe "#url_to_repo" do
it "returns the correct ssh url to the repo" do
- expect(subject.url_to_repo).to eq(gitlab_shell.url_to_repo(subject.path_with_namespace))
+ expect(subject.url_to_repo).to eq(gitlab_shell.url_to_repo(subject.full_path))
end
end
@@ -35,29 +42,19 @@ describe ProjectWiki, models: true do
end
describe "#http_url_to_repo" do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
- context 'when no user is given' do
- it 'returns the url to the repo without a username' do
- expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git"
+ it 'returns the full http url to the repo' do
+ expected_url = "#{Gitlab.config.gitlab.url}/#{subject.full_path}.git"
- expect(project_wiki.http_url_to_repo).to eq(expected_url)
- expect(project_wiki.http_url_to_repo).not_to include('@')
- end
- end
-
- context 'when user is given' do
- it 'returns the url to the repo with the username' do
- user = build_stubbed(:user)
-
- expect(project_wiki.http_url_to_repo(user)).to start_with("http://#{user.username}@")
- end
+ expect(project_wiki.http_url_to_repo).to eq(expected_url)
+ expect(project_wiki.http_url_to_repo).not_to include('@')
end
end
describe "#wiki_base_path" do
it "returns the wiki base path" do
- wiki_base_path = "#{Gitlab.config.gitlab.relative_url_root}/#{project.path_with_namespace}/wikis"
+ wiki_base_path = "#{Gitlab.config.gitlab.relative_url_root}/#{project.full_path}/wikis"
expect(subject.wiki_base_path).to eq(wiki_base_path)
end
@@ -84,7 +81,7 @@ describe ProjectWiki, models: true do
allow_any_instance_of(Gitlab::Shell).to receive(:add_repository) do
create_temp_repo("#{Rails.root}/tmp/test-git-base-path/non-existant.wiki.git")
end
- allow(project).to receive(:path_with_namespace).and_return("non-existant")
+ allow(project).to receive(:full_path).and_return("non-existant")
end
describe '#empty?' do
@@ -156,15 +153,15 @@ describe ProjectWiki, models: true do
describe '#find_file' do
before do
file = Gollum::File.new(subject.wiki)
- allow_any_instance_of(Gollum::Wiki).
- to receive(:file).with('image.jpg', 'master', true).
- and_return(file)
- allow_any_instance_of(Gollum::File).
- to receive(:mime_type).
- and_return('image/jpeg')
- allow_any_instance_of(Gollum::Wiki).
- to receive(:file).with('non-existant', 'master', true).
- and_return(nil)
+ allow_any_instance_of(Gollum::Wiki)
+ .to receive(:file).with('image.jpg', 'master', true)
+ .and_return(file)
+ allow_any_instance_of(Gollum::File)
+ .to receive(:mime_type)
+ .and_return('image/jpeg')
+ allow_any_instance_of(Gollum::Wiki)
+ .to receive(:file).with('non-existant', 'master', true)
+ .and_return(nil)
end
after do
@@ -213,9 +210,12 @@ describe ProjectWiki, models: true do
end
it 'updates project activity' do
- expect(subject).to receive(:update_project_activity)
-
subject.create_page('Test Page', 'This is content')
+
+ project.reload
+
+ expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end
end
@@ -223,7 +223,12 @@ describe ProjectWiki, models: true do
before do
create_page("update-page", "some content")
@gollum_page = subject.wiki.paged("update-page")
- subject.update_page(@gollum_page, "some other content", :markdown, "updated page")
+ subject.update_page(
+ @gollum_page,
+ content: "some other content",
+ format: :markdown,
+ message: "updated page"
+ )
@page = subject.pages.first.page
end
@@ -240,9 +245,17 @@ describe ProjectWiki, models: true do
end
it 'updates project activity' do
- expect(subject).to receive(:update_project_activity)
+ subject.update_page(
+ @gollum_page,
+ content: 'Yet more content',
+ format: :markdown,
+ message: 'Updated page again'
+ )
+
+ project.reload
- subject.update_page(@gollum_page, 'Yet more content', :markdown, 'Updated page again')
+ expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end
end
@@ -258,17 +271,20 @@ describe ProjectWiki, models: true do
end
it 'updates project activity' do
- expect(subject).to receive(:update_project_activity)
-
subject.delete_page(@page)
+
+ project.reload
+
+ expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
+ expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end
end
describe '#create_repo!' do
it 'creates a repository' do
- expect(subject).to receive(:init_repo).
- with(subject.path_with_namespace).
- and_return(true)
+ expect(subject).to receive(:init_repo)
+ .with(subject.full_path)
+ .and_return(true)
expect(subject.repository).to receive(:after_create)
@@ -276,6 +292,24 @@ describe ProjectWiki, models: true do
end
end
+ describe '#ensure_repository' do
+ it 'creates the repository if it not exist' do
+ allow(subject).to receive(:repository_exists?).and_return(false)
+
+ expect(subject).to receive(:create_repo!)
+
+ subject.ensure_repository
+ end
+
+ it 'does not create the repository if it exists' do
+ allow(subject).to receive(:repository_exists?).and_return(true)
+
+ expect(subject).not_to receive(:create_repo!)
+
+ subject.ensure_repository
+ end
+ end
+
describe '#hook_attrs' do
it 'returns a hash with values' do
expect(subject.hook_attrs).to be_a Hash
diff --git a/spec/models/protectable_dropdown_spec.rb b/spec/models/protectable_dropdown_spec.rb
index 4c9bade592b..d4433a88a15 100644
--- a/spec/models/protectable_dropdown_spec.rb
+++ b/spec/models/protectable_dropdown_spec.rb
@@ -1,9 +1,16 @@
require 'spec_helper'
-describe ProtectableDropdown, models: true do
+describe ProtectableDropdown do
let(:project) { create(:project, :repository) }
let(:subject) { described_class.new(project, :branches) }
+ describe 'initialize' do
+ it 'raises ArgumentError for invalid ref type' do
+ expect { described_class.new(double, :foo) }
+ .to raise_error(ArgumentError, "invalid ref type `foo`")
+ end
+ end
+
describe '#protectable_ref_names' do
before do
project.protected_branches.create(name: 'master')
diff --git a/spec/models/protected_branch/merge_access_level_spec.rb b/spec/models/protected_branch/merge_access_level_spec.rb
new file mode 100644
index 00000000000..f70503eadbc
--- /dev/null
+++ b/spec/models/protected_branch/merge_access_level_spec.rb
@@ -0,0 +1,5 @@
+require 'spec_helper'
+
+describe ProtectedBranch::MergeAccessLevel do
+ it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) }
+end
diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb
new file mode 100644
index 00000000000..f161f345761
--- /dev/null
+++ b/spec/models/protected_branch/push_access_level_spec.rb
@@ -0,0 +1,5 @@
+require 'spec_helper'
+
+describe ProtectedBranch::PushAccessLevel do
+ it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) }
+end
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index 179a443c43d..4c677200ae2 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -1,15 +1,12 @@
require 'spec_helper'
-describe ProtectedBranch, models: true do
+describe ProtectedBranch do
subject { build_stubbed(:protected_branch) }
describe 'Associations' do
it { is_expected.to belong_to(:project) }
end
- describe "Mass assignment" do
- end
-
describe 'Validation' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }
@@ -104,17 +101,17 @@ describe ProtectedBranch, models: true do
production = create(:protected_branch, name: "production")
staging = create(:protected_branch, name: "staging")
- expect(ProtectedBranch.matching("production")).to include(production)
- expect(ProtectedBranch.matching("production")).not_to include(staging)
+ expect(described_class.matching("production")).to include(production)
+ expect(described_class.matching("production")).not_to include(staging)
end
it "accepts a list of protected branches to search from, so as to avoid a DB call" do
production = build(:protected_branch, name: "production")
staging = build(:protected_branch, name: "staging")
- expect(ProtectedBranch.matching("production")).to be_empty
- expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).to include(production)
- expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).not_to include(staging)
+ expect(described_class.matching("production")).to be_empty
+ expect(described_class.matching("production", protected_refs: [production, staging])).to include(production)
+ expect(described_class.matching("production", protected_refs: [production, staging])).not_to include(staging)
end
end
@@ -123,17 +120,17 @@ describe ProtectedBranch, models: true do
production = create(:protected_branch, name: "production/*")
staging = create(:protected_branch, name: "staging/*")
- expect(ProtectedBranch.matching("production/some-branch")).to include(production)
- expect(ProtectedBranch.matching("production/some-branch")).not_to include(staging)
+ expect(described_class.matching("production/some-branch")).to include(production)
+ expect(described_class.matching("production/some-branch")).not_to include(staging)
end
it "accepts a list of protected branches to search from, so as to avoid a DB call" do
production = build(:protected_branch, name: "production/*")
staging = build(:protected_branch, name: "staging/*")
- expect(ProtectedBranch.matching("production/some-branch")).to be_empty
- 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)
+ expect(described_class.matching("production/some-branch")).to be_empty
+ expect(described_class.matching("production/some-branch", protected_refs: [production, staging])).to include(production)
+ expect(described_class.matching("production/some-branch", protected_refs: [production, staging])).not_to include(staging)
end
end
end
@@ -145,51 +142,51 @@ describe ProtectedBranch, models: true do
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)
+ expect(described_class.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)
+ expect(described_class.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)
+ expect(described_class.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)
+ expect(described_class.protected?(project, 'staging/some-branch')).to eq(false)
end
end
context "new project" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(: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
+ expect(described_class.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
+ expect(described_class.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
+ expect(described_class.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
+ expect(described_class.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
index 51353852a93..e5a0f6ec23f 100644
--- a/spec/models/protected_tag_spec.rb
+++ b/spec/models/protected_tag_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProtectedTag, models: true do
+describe ProtectedTag do
describe 'Associations' do
it { is_expected.to belong_to(:project) }
end
diff --git a/spec/models/push_event_payload_spec.rb b/spec/models/push_event_payload_spec.rb
new file mode 100644
index 00000000000..a049ad35584
--- /dev/null
+++ b/spec/models/push_event_payload_spec.rb
@@ -0,0 +1,16 @@
+require 'spec_helper'
+
+describe PushEventPayload do
+ describe 'saving payloads' do
+ it 'does not allow commit messages longer than 70 characters' do
+ event = create(:push_event)
+ payload = build(:push_event_payload, event: event)
+
+ expect(payload).to be_valid
+
+ payload.commit_title = 'a' * 100
+
+ expect(payload).not_to be_valid
+ end
+ end
+end
diff --git a/spec/models/push_event_spec.rb b/spec/models/push_event_spec.rb
new file mode 100644
index 00000000000..532fb024261
--- /dev/null
+++ b/spec/models/push_event_spec.rb
@@ -0,0 +1,202 @@
+require 'spec_helper'
+
+describe PushEvent do
+ let(:payload) { PushEventPayload.new }
+
+ let(:event) do
+ event = described_class.new
+
+ allow(event).to receive(:push_event_payload).and_return(payload)
+
+ event
+ end
+
+ describe '.sti_name' do
+ it 'returns Event::PUSHED' do
+ expect(described_class.sti_name).to eq(Event::PUSHED)
+ end
+ end
+
+ describe '#push?' do
+ it 'returns true' do
+ expect(event).to be_push
+ end
+ end
+
+ describe '#push_with_commits?' do
+ it 'returns true when both the first and last commit are present' do
+ allow(event).to receive(:commit_from).and_return('123')
+ allow(event).to receive(:commit_to).and_return('456')
+
+ expect(event).to be_push_with_commits
+ end
+
+ it 'returns false when the first commit is missing' do
+ allow(event).to receive(:commit_to).and_return('456')
+
+ expect(event).not_to be_push_with_commits
+ end
+
+ it 'returns false when the last commit is missing' do
+ allow(event).to receive(:commit_from).and_return('123')
+
+ expect(event).not_to be_push_with_commits
+ end
+ end
+
+ describe '#tag?' do
+ it 'returns true when pushing to a tag' do
+ allow(payload).to receive(:tag?).and_return(true)
+
+ expect(event).to be_tag
+ end
+
+ it 'returns false when pushing to a branch' do
+ allow(payload).to receive(:tag?).and_return(false)
+
+ expect(event).not_to be_tag
+ end
+ end
+
+ describe '#branch?' do
+ it 'returns true when pushing to a branch' do
+ allow(payload).to receive(:branch?).and_return(true)
+
+ expect(event).to be_branch
+ end
+
+ it 'returns false when pushing to a tag' do
+ allow(payload).to receive(:branch?).and_return(false)
+
+ expect(event).not_to be_branch
+ end
+ end
+
+ describe '#valid_push?' do
+ it 'returns true if a ref exists' do
+ allow(payload).to receive(:ref).and_return('master')
+
+ expect(event).to be_valid_push
+ end
+
+ it 'returns false when no ref is present' do
+ expect(event).not_to be_valid_push
+ end
+ end
+
+ describe '#new_ref?' do
+ it 'returns true when pushing a new ref' do
+ allow(payload).to receive(:created?).and_return(true)
+
+ expect(event).to be_new_ref
+ end
+
+ it 'returns false when pushing to an existing ref' do
+ allow(payload).to receive(:created?).and_return(false)
+
+ expect(event).not_to be_new_ref
+ end
+ end
+
+ describe '#rm_ref?' do
+ it 'returns true when removing an existing ref' do
+ allow(payload).to receive(:removed?).and_return(true)
+
+ expect(event).to be_rm_ref
+ end
+
+ it 'returns false when pushing to an existing ref' do
+ allow(payload).to receive(:removed?).and_return(false)
+
+ expect(event).not_to be_rm_ref
+ end
+ end
+
+ describe '#commit_from' do
+ it 'returns the first commit SHA' do
+ allow(payload).to receive(:commit_from).and_return('123')
+
+ expect(event.commit_from).to eq('123')
+ end
+ end
+
+ describe '#commit_to' do
+ it 'returns the last commit SHA' do
+ allow(payload).to receive(:commit_to).and_return('123')
+
+ expect(event.commit_to).to eq('123')
+ end
+ end
+
+ describe '#ref_name' do
+ it 'returns the name of the ref' do
+ allow(payload).to receive(:ref).and_return('master')
+
+ expect(event.ref_name).to eq('master')
+ end
+ end
+
+ describe '#ref_type' do
+ it 'returns the type of the ref' do
+ allow(payload).to receive(:ref_type).and_return('branch')
+
+ expect(event.ref_type).to eq('branch')
+ end
+ end
+
+ describe '#branch_name' do
+ it 'returns the name of the branch' do
+ allow(payload).to receive(:ref).and_return('master')
+
+ expect(event.branch_name).to eq('master')
+ end
+ end
+
+ describe '#tag_name' do
+ it 'returns the name of the tag' do
+ allow(payload).to receive(:ref).and_return('1.2')
+
+ expect(event.tag_name).to eq('1.2')
+ end
+ end
+
+ describe '#commit_title' do
+ it 'returns the commit message' do
+ allow(payload).to receive(:commit_title).and_return('foo')
+
+ expect(event.commit_title).to eq('foo')
+ end
+ end
+
+ describe '#commit_id' do
+ it 'returns the SHA of the last commit if present' do
+ allow(event).to receive(:commit_to).and_return('123')
+
+ expect(event.commit_id).to eq('123')
+ end
+
+ it 'returns the SHA of the first commit if the last commit is not present' do
+ allow(event).to receive(:commit_to).and_return(nil)
+ allow(event).to receive(:commit_from).and_return('123')
+
+ expect(event.commit_id).to eq('123')
+ end
+ end
+
+ describe '#commits_count' do
+ it 'returns the number of commits' do
+ allow(payload).to receive(:commit_count).and_return(1)
+
+ expect(event.commits_count).to eq(1)
+ end
+ end
+
+ describe '#validate_push_action' do
+ it 'adds an error when the action is not PUSHED' do
+ event.action = Event::CREATED
+ event.validate_push_action
+
+ expect(event.errors.count).to eq(1)
+ end
+ end
+end
diff --git a/spec/models/redirect_route_spec.rb b/spec/models/redirect_route_spec.rb
new file mode 100644
index 00000000000..106ae59af29
--- /dev/null
+++ b/spec/models/redirect_route_spec.rb
@@ -0,0 +1,35 @@
+require 'rails_helper'
+
+describe RedirectRoute do
+ let(:group) { create(:group) }
+ let!(:redirect_route) { group.redirect_routes.create(path: 'gitlabb') }
+
+ describe 'relationships' do
+ it { is_expected.to belong_to(:source) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:source) }
+ it { is_expected.to validate_presence_of(:path) }
+ it { is_expected.to validate_uniqueness_of(:path).case_insensitive }
+ end
+
+ describe '.matching_path_and_descendants' do
+ let!(:redirect2) { group.redirect_routes.create(path: 'gitlabb/test') }
+ let!(:redirect3) { group.redirect_routes.create(path: 'gitlabb/test/foo') }
+ let!(:redirect4) { group.redirect_routes.create(path: 'gitlabb/test/foo/bar') }
+ let!(:redirect5) { group.redirect_routes.create(path: 'gitlabb/test/baz') }
+
+ context 'when the redirect route matches with same casing' do
+ it 'returns correct routes' do
+ expect(described_class.matching_path_and_descendants('gitlabb/test')).to match_array([redirect2, redirect3, redirect4, redirect5])
+ end
+ end
+
+ context 'when the redirect route matches with different casing' do
+ it 'returns correct routes' do
+ expect(described_class.matching_path_and_descendants('GitLABB/test')).to match_array([redirect2, redirect3, redirect4, redirect5])
+ end
+ end
+ end
+end
diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb
index 527005b2b69..3f86347c3ae 100644
--- a/spec/models/release_spec.rb
+++ b/spec/models/release_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe Release, type: :model do
+RSpec.describe Release do
let(:release) { create(:release) }
it { expect(release).to be_valid }
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 98d0641443e..4926d5d6c49 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -2,10 +2,11 @@ require 'spec_helper'
describe Repository, models: true do
include RepoHelpers
- TestBlob = Struct.new(:name)
+ TestBlob = Struct.new(:path)
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
+ let(:broken_repository) { create(:project, :broken_storage).repository }
let(:user) { create(:user) }
let(:commit_options) do
@@ -27,12 +28,27 @@ describe Repository, models: true do
let(:author_email) { 'user@example.org' }
let(:author_name) { 'John Doe' }
+ def expect_to_raise_storage_error
+ expect { yield }.to raise_error do |exception|
+ storage_exceptions = [Gitlab::Git::Storage::Inaccessible, Gitlab::Git::CommandError, GRPC::Unavailable]
+ expect(exception.class).to be_in(storage_exceptions)
+ end
+ end
+
describe '#branch_names_contains' do
subject { repository.branch_names_contains(sample_commit.id) }
it { is_expected.to include('master') }
it { is_expected.not_to include('feature') }
it { is_expected.not_to include('fix') }
+
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error do
+ broken_repository.branch_names_contains(sample_commit.id)
+ end
+ end
+ end
end
describe '#tag_names_contains' do
@@ -110,22 +126,11 @@ describe Repository, models: true do
end
describe '#ref_name_for_sha' do
- context 'ref found' do
- it 'returns the ref' do
- allow_any_instance_of(Gitlab::Popen).to receive(:popen).
- and_return(["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0])
-
- expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77'
- end
- end
-
- context 'ref not found' do
- it 'returns nil' do
- allow_any_instance_of(Gitlab::Popen).to receive(:popen).
- and_return(["", 0])
+ it 'returns the ref' do
+ allow(repository.raw_repository).to receive(:ref_name_for_sha)
+ .and_return('refs/environments/production/77')
- expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq nil
- end
+ expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77'
end
end
@@ -150,24 +155,60 @@ describe Repository, models: true do
end
describe '#last_commit_for_path' do
- subject { repository.last_commit_for_path(sample_commit.id, '.gitignore').id }
+ shared_examples 'getting last commit for path' do
+ subject { repository.last_commit_for_path(sample_commit.id, '.gitignore').id }
+
+ it { is_expected.to eq('c1acaa58bbcbc3eafe538cb8274ba387047b69f8') }
- it { is_expected.to eq('c1acaa58bbcbc3eafe538cb8274ba387047b69f8') }
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error do
+ broken_repository.last_commit_id_for_path(sample_commit.id, '.gitignore')
+ end
+ end
+ end
+ end
+
+ context 'when Gitaly feature last_commit_for_path is enabled' do
+ it_behaves_like 'getting last commit for path'
+ end
+
+ context 'when Gitaly feature last_commit_for_path is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'getting last commit for path'
+ end
end
describe '#last_commit_id_for_path' do
- subject { repository.last_commit_id_for_path(sample_commit.id, '.gitignore') }
+ shared_examples 'getting last commit ID for path' do
+ subject { repository.last_commit_id_for_path(sample_commit.id, '.gitignore') }
+
+ it "returns last commit id for a given path" do
+ is_expected.to eq('c1acaa58bbcbc3eafe538cb8274ba387047b69f8')
+ end
+
+ it "caches last commit id for a given path" do
+ cache = repository.send(:cache)
+ key = "last_commit_id_for_path:#{sample_commit.id}:#{Digest::SHA1.hexdigest('.gitignore')}"
+
+ expect(cache).to receive(:fetch).with(key).and_return('c1acaa5')
+ is_expected.to eq('c1acaa5')
+ end
- it "returns last commit id for a given path" do
- is_expected.to eq('c1acaa58bbcbc3eafe538cb8274ba387047b69f8')
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error do
+ broken_repository.last_commit_for_path(sample_commit.id, '.gitignore').id
+ end
+ end
+ end
end
- it "caches last commit id for a given path" do
- cache = repository.send(:cache)
- key = "last_commit_id_for_path:#{sample_commit.id}:#{Digest::SHA1.hexdigest('.gitignore')}"
+ context 'when Gitaly feature last_commit_for_path is enabled' do
+ it_behaves_like 'getting last commit ID for path'
+ end
- expect(cache).to receive(:fetch).with(key).and_return('c1acaa5')
- is_expected.to eq('c1acaa5')
+ context 'when Gitaly feature last_commit_for_path is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'getting last commit ID for path'
end
end
@@ -193,19 +234,37 @@ describe Repository, models: true do
end
describe '#find_commits_by_message' do
- it 'returns commits with messages containing a given string' do
- commit_ids = repository.find_commits_by_message('submodule').map(&:id)
+ shared_examples 'finding commits by message' do
+ it 'returns commits with messages containing a given string' do
+ commit_ids = repository.find_commits_by_message('submodule').map(&:id)
+
+ expect(commit_ids).to include(
+ '5937ac0a7beb003549fc5fd26fc247adbce4a52e',
+ '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9',
+ 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660'
+ )
+ expect(commit_ids).not_to include('913c66a37b4a45b9769037c55c2d238bd0942d2e')
+ end
+
+ it 'is case insensitive' do
+ commit_ids = repository.find_commits_by_message('SUBMODULE').map(&:id)
+
+ expect(commit_ids).to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ end
+ end
- expect(commit_ids).to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
- expect(commit_ids).to include('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
- expect(commit_ids).to include('cfe32cf61b73a0d5e9f13e774abde7ff789b1660')
- expect(commit_ids).not_to include('913c66a37b4a45b9769037c55c2d238bd0942d2e')
+ context 'when Gitaly commits_by_message feature is enabled' do
+ it_behaves_like 'finding commits by message'
end
- it 'is case insensitive' do
- commit_ids = repository.find_commits_by_message('SUBMODULE').map(&:id)
+ context 'when Gitaly commits_by_message feature is disabled', skip_gitaly_mock: true do
+ it_behaves_like 'finding commits by message'
+ end
- expect(commit_ids).to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error { broken_repository.find_commits_by_message('s') }
+ end
end
end
@@ -311,7 +370,7 @@ describe Repository, models: true do
end
context "when committing to another project" do
- let(:forked_project) { create(:project) }
+ let(:forked_project) { create(:project, :repository) }
it "creates a fork and commit to the forked project" do
expect do
@@ -358,6 +417,17 @@ describe Repository, models: true do
expect(blob.data).to eq('Changelog!')
end
+ it 'creates new file and dir when file_path has a forward slash' do
+ expect do
+ repository.create_file(user, 'new_dir/new_file.txt', 'File!',
+ message: 'Create new_file with new_dir',
+ branch_name: 'master')
+ end.to change { repository.commits('master').count }.by(1)
+
+ expect(repository.tree('master', 'new_dir').path).to eq('new_dir')
+ expect(repository.blob_at('master', 'new_dir/new_file.txt').data).to eq('File!')
+ end
+
it 'respects the autocrlf setting' do
repository.create_file(user, 'hello.txt', "Hello,\r\nWorld",
message: 'Add hello world',
@@ -515,12 +585,20 @@ describe Repository, models: true do
end
it 'properly handles query when repo is empty' do
- repository = create(:empty_project).repository
+ repository = create(:project).repository
results = repository.search_files_by_content('test', 'master')
expect(results).to match_array([])
end
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error do
+ broken_repository.search_files_by_content('feature', 'master')
+ end
+ end
+ end
+
describe 'result' do
subject { results.first }
@@ -543,12 +621,28 @@ describe Repository, models: true do
end
it 'properly handles query when repo is empty' do
- repository = create(:empty_project).repository
+ repository = create(:project).repository
results = repository.search_files_by_name('test', 'master')
expect(results).to match_array([])
end
+
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error { broken_repository.search_files_by_name('files', 'master') }
+ end
+ end
+ end
+
+ describe '#fetch_ref' do
+ describe 'when storage is broken', broken_storage: true do
+ it 'should raise a storage error' do
+ path = broken_repository.path_to_repo
+
+ expect_to_raise_storage_error { broken_repository.fetch_ref(path, '1', '2') }
+ end
+ end
end
describe '#create_ref' do
@@ -561,39 +655,39 @@ describe Repository, models: true do
end
end
- describe "#changelog", caching: true do
+ describe "#changelog", :use_clean_rails_memory_store_caching do
it 'accepts changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changelog')])
- expect(repository.changelog.name).to eq('changelog')
+ expect(repository.changelog.path).to eq('changelog')
end
it 'accepts news instead of changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('news')])
- expect(repository.changelog.name).to eq('news')
+ expect(repository.changelog.path).to eq('news')
end
it 'accepts history instead of changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('history')])
- expect(repository.changelog.name).to eq('history')
+ expect(repository.changelog.path).to eq('history')
end
it 'accepts changes instead of changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changes')])
- expect(repository.changelog.name).to eq('changes')
+ expect(repository.changelog.path).to eq('changes')
end
it 'is case-insensitive' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('CHANGELOG')])
- expect(repository.changelog.name).to eq('CHANGELOG')
+ expect(repository.changelog.path).to eq('CHANGELOG')
end
end
- describe "#license_blob", caching: true do
+ describe "#license_blob", :use_clean_rails_memory_store_caching do
before do
repository.delete_file(
user, 'LICENSE', message: 'Remove LICENSE', branch_name: 'master')
@@ -604,8 +698,8 @@ describe Repository, models: true do
user, 'LICENSE', 'Copyright!',
message: 'Add LICENSE', branch_name: 'master')
- allow(repository).to receive(:file_on_head).
- and_raise(Rugged::ReferenceError)
+ allow(repository).to receive(:file_on_head)
+ .and_raise(Rugged::ReferenceError)
expect(repository.license_blob).to be_nil
end
@@ -624,7 +718,7 @@ describe Repository, models: true do
repository.create_file(user, 'LICENSE', 'Copyright!',
message: 'Add LICENSE', branch_name: 'master')
- expect(repository.license_blob.name).to eq('LICENSE')
+ expect(repository.license_blob.path).to eq('LICENSE')
end
%w[LICENSE LICENCE LiCensE LICENSE.md LICENSE.foo COPYING COPYING.md].each do |filename|
@@ -638,7 +732,7 @@ describe Repository, models: true do
end
end
- describe '#license_key', caching: true do
+ describe '#license_key', :use_clean_rails_memory_store_caching do
before do
repository.delete_file(user, 'LICENSE',
message: 'Remove LICENSE', branch_name: 'master')
@@ -654,7 +748,7 @@ describe Repository, models: true do
expect(repository.license_key).to be_nil
end
- it 'detects license file with no recognizable open-source license content' do
+ it 'returns nil when the content is not recognizable' do
repository.create_file(user, 'LICENSE', 'Copyright!',
message: 'Add LICENSE', branch_name: 'master')
@@ -670,12 +764,45 @@ describe Repository, models: true do
end
end
- describe "#gitlab_ci_yml", caching: true do
+ describe '#license' do
+ before do
+ repository.delete_file(user, 'LICENSE',
+ message: 'Remove LICENSE', branch_name: 'master')
+ end
+
+ it 'returns nil when no license is detected' do
+ expect(repository.license).to be_nil
+ end
+
+ it 'returns nil when the repository does not exist' do
+ expect(repository).to receive(:exists?).and_return(false)
+
+ expect(repository.license).to be_nil
+ end
+
+ it 'returns nil when the content is not recognizable' do
+ repository.create_file(user, 'LICENSE', 'Copyright!',
+ message: 'Add LICENSE', branch_name: 'master')
+
+ expect(repository.license).to be_nil
+ end
+
+ it 'returns the license' do
+ license = Licensee::License.new('mit')
+ repository.create_file(user, 'LICENSE',
+ license.content,
+ message: 'Add LICENSE', branch_name: 'master')
+
+ expect(repository.license).to eq(license)
+ end
+ end
+
+ describe "#gitlab_ci_yml", :use_clean_rails_memory_store_caching do
it 'returns valid file' do
files = [TestBlob.new('file'), TestBlob.new('.gitlab-ci.yml'), TestBlob.new('copying')]
expect(repository.tree).to receive(:blobs).and_return(files)
- expect(repository.gitlab_ci_yml.name).to eq('.gitlab-ci.yml')
+ expect(repository.gitlab_ci_yml.path).to eq('.gitlab-ci.yml')
end
it 'returns nil if not exists' do
@@ -757,8 +884,8 @@ describe Repository, models: true do
context 'when pre hooks were successful' do
it 'runs without errors' do
- expect_any_instance_of(GitHooksService).to receive(:execute).
- with(user, project.repository.path_to_repo, old_rev, blank_sha, 'refs/heads/feature')
+ expect_any_instance_of(GitHooksService).to receive(:execute)
+ .with(user, project, old_rev, blank_sha, 'refs/heads/feature')
expect { repository.rm_branch(user, 'feature') }.not_to raise_error
end
@@ -800,14 +927,9 @@ describe Repository, models: true do
before do
service = GitHooksService.new
expect(GitHooksService).to receive(:new).and_return(service)
- expect(service).to receive(:execute).
- with(
- user,
- repository.path_to_repo,
- old_rev,
- new_rev,
- 'refs/heads/feature').
- and_yield(service).and_return(true)
+ expect(service).to receive(:execute)
+ .with(user, project, old_rev, new_rev, 'refs/heads/feature')
+ .and_yield(service).and_return(true)
end
it 'runs without errors' do
@@ -839,6 +961,27 @@ describe Repository, models: true do
end
end
+ context 'when temporary ref failed to be created from other project' do
+ let(:target_project) { create(:project, :empty_repo) }
+
+ before do
+ expect(target_project.repository).to receive(:run_git)
+ end
+
+ it 'raises Rugged::ReferenceError' do
+ raise_reference_error = raise_error(Rugged::ReferenceError) do |err|
+ expect(err.cause).to be_nil
+ end
+
+ expect do
+ GitOperationService.new(user, target_project.repository)
+ .with_branch('feature',
+ start_project: project,
+ &:itself)
+ end.to raise_reference_error
+ end
+ end
+
context 'when the update adds more than one commit' do
let(:old_rev) { '33f3729a45c02fc67d00adb1b8bca394b0e761d9' }
@@ -901,8 +1044,8 @@ describe Repository, models: true do
expect(repository).not_to receive(:expire_emptiness_caches)
expect(repository).to receive(:expire_branches_cache)
- GitOperationService.new(user, repository).
- with_branch('new-feature') do
+ GitOperationService.new(user, repository)
+ .with_branch('new-feature') do
new_rev
end
end
@@ -914,7 +1057,7 @@ describe Repository, models: true do
end
it 'expires creation and branch cache' do
- empty_repository = create(:empty_project, :empty_repo).repository
+ empty_repository = create(:project, :empty_repo).repository
expect(empty_repository).to receive(:expire_exists_cache)
expect(empty_repository).to receive(:expire_root_ref_cache)
@@ -928,21 +1071,31 @@ describe Repository, models: true do
end
end
- describe '#exists?' do
+ shared_examples 'repo exists check' do
it 'returns true when a repository exists' do
expect(repository.exists?).to eq(true)
end
- it 'returns false when a repository does not exist' do
- allow(repository).to receive(:refs_directory_exists?).and_return(false)
+ it 'returns false if no full path can be constructed' do
+ allow(repository).to receive(:full_path).and_return(nil)
expect(repository.exists?).to eq(false)
end
- it 'returns false when there is no namespace' do
- allow(repository).to receive(:path_with_namespace).and_return(nil)
+ context 'with broken storage', broken_storage: true do
+ it 'should raise a storage error' do
+ expect_to_raise_storage_error { broken_repository.exists? }
+ end
+ end
+ end
- expect(repository.exists?).to eq(false)
+ describe '#exists?' do
+ context 'when repository_exists is disabled' do
+ it_behaves_like 'repo exists check'
+ end
+
+ context 'when repository_exists is enabled', skip_gitaly_mock: true do
+ it_behaves_like 'repo exists check'
end
end
@@ -985,8 +1138,8 @@ describe Repository, models: true do
end
it 'does nothing' do
- expect(repository.raw_repository).not_to receive(:autocrlf=).
- with(:input)
+ expect(repository.raw_repository).not_to receive(:autocrlf=)
+ .with(:input)
GitOperationService.new(nil, repository).send(:update_autocrlf_option)
end
@@ -1005,9 +1158,9 @@ describe Repository, models: true do
end
it 'caches the output' do
- expect(repository.raw_repository).to receive(:empty?).
- once.
- and_return(false)
+ expect(repository.raw_repository).to receive(:empty?)
+ .once
+ .and_return(false)
repository.empty?
repository.empty?
@@ -1020,9 +1173,9 @@ describe Repository, models: true do
end
it 'caches the output' do
- expect(repository.raw_repository).to receive(:root_ref).
- once.
- and_return('master')
+ expect(repository.raw_repository).to receive(:root_ref)
+ .once
+ .and_return('master')
repository.root_ref
repository.root_ref
@@ -1033,9 +1186,9 @@ describe Repository, models: true do
it 'expires the root reference cache' do
repository.root_ref
- expect(repository.raw_repository).to receive(:root_ref).
- once.
- and_return('foo')
+ expect(repository.raw_repository).to receive(:root_ref)
+ .once
+ .and_return('foo')
repository.expire_root_ref_cache
@@ -1049,17 +1202,17 @@ describe Repository, models: true do
let(:cache) { repository.send(:cache) }
it 'expires the cache for all branches' do
- expect(cache).to receive(:expire).
- at_least(repository.branches.length * 2).
- times
+ expect(cache).to receive(:expire)
+ .at_least(repository.branches.length * 2)
+ .times
repository.expire_branch_cache
end
it 'expires the cache for all branches when the root branch is given' do
- expect(cache).to receive(:expire).
- at_least(repository.branches.length * 2).
- times
+ expect(cache).to receive(:expire)
+ .at_least(repository.branches.length * 2)
+ .times
repository.expire_branch_cache(repository.root_ref)
end
@@ -1092,27 +1245,39 @@ describe Repository, models: true do
end
describe 'skip_merges option' do
- subject { repository.commits(Gitlab::Git::BRANCH_REF_PREFIX + "'test'", limit: 100, skip_merges: true).map{ |k| k.id } }
+ subject { repository.commits(Gitlab::Git::BRANCH_REF_PREFIX + "'test'", limit: 100, skip_merges: true).map { |k| k.id } }
it { is_expected.not_to include('e56497bb5f03a90a51293fc6d516788730953899') }
end
describe '#merge' do
- it 'merges the code and return the commit id' do
+ let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) }
+
+ let(:commit_options) do
+ author = repository.user_to_committer(user)
+ { message: 'Test \r\n\r\n message', committer: author, author: author }
+ end
+
+ it 'merges the code and returns the commit id' do
expect(merge_commit).to be_present
expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present
end
it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do
- merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project)
-
- merge_commit_id = repository.merge(user,
- merge_request.diff_head_sha,
- merge_request,
- commit_options)
+ merge_commit_id = merge(repository, user, merge_request, commit_options)
expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id)
end
+
+ it 'removes carriage returns from commit message' do
+ merge_commit_id = merge(repository, user, merge_request, commit_options)
+
+ expect(repository.commit(merge_commit_id).message).to eq(commit_options[:message].delete("\r"))
+ end
+
+ def merge(repository, user, merge_request, options = {})
+ repository.merge(user, merge_request.diff_head_sha, merge_request, options)
+ end
end
describe '#revert' do
@@ -1310,12 +1475,12 @@ describe Repository, models: true do
describe '#after_push_commit' do
it 'expires statistics caches' do
- expect(repository).to receive(:expire_statistics_caches).
- and_call_original
+ expect(repository).to receive(:expire_statistics_caches)
+ .and_call_original
- expect(repository).to receive(:expire_branch_cache).
- with('master').
- and_call_original
+ expect(repository).to receive(:expire_branch_cache)
+ .with('master')
+ .and_call_original
repository.after_push_commit('master')
end
@@ -1379,20 +1544,30 @@ describe Repository, models: true do
describe '#branch_count' do
it 'returns the number of branches' do
expect(repository.branch_count).to be_an(Integer)
+
+ # NOTE: Until rugged goes away, make sure rugged and gitaly are in sync
+ rugged_count = repository.raw_repository.rugged.branches.count
+
+ expect(repository.branch_count).to eq(rugged_count)
end
end
describe '#tag_count' do
it 'returns the number of tags' do
expect(repository.tag_count).to be_an(Integer)
+
+ # NOTE: Until rugged goes away, make sure rugged and gitaly are in sync
+ rugged_count = repository.raw_repository.rugged.tags.count
+
+ expect(repository.tag_count).to eq(rugged_count)
end
end
describe '#expire_branches_cache' do
it 'expires the cache' do
- expect(repository).to receive(:expire_method_caches).
- with(%i(branch_names branch_count)).
- and_call_original
+ expect(repository).to receive(:expire_method_caches)
+ .with(%i(branch_names branch_count))
+ .and_call_original
repository.expire_branches_cache
end
@@ -1400,9 +1575,9 @@ describe Repository, models: true do
describe '#expire_tags_cache' do
it 'expires the cache' do
- expect(repository).to receive(:expire_method_caches).
- with(%i(tag_names tag_count)).
- and_call_original
+ expect(repository).to receive(:expire_method_caches)
+ .with(%i(tag_names tag_count))
+ .and_call_original
repository.expire_tags_cache
end
@@ -1413,11 +1588,11 @@ describe Repository, models: true do
let(:user) { build_stubbed(:user) }
it 'creates the tag using rugged' do
- expect(repository.rugged.tags).to receive(:create).
- with('8.5', repository.commit('master').id,
+ expect(repository.rugged.tags).to receive(:create)
+ .with('8.5', repository.commit('master').id,
hash_including(message: 'foo',
- tagger: hash_including(name: user.name, email: user.email))).
- and_call_original
+ tagger: hash_including(name: user.name, email: user.email)))
+ .and_call_original
repository.add_tag(user, '8.5', 'master', 'foo')
end
@@ -1430,12 +1605,12 @@ describe Repository, models: true do
it 'passes commit SHA to pre-receive and update hooks,\
and tag SHA to post-receive hook' do
- pre_receive_hook = Gitlab::Git::Hook.new('pre-receive', repository.path_to_repo)
- update_hook = Gitlab::Git::Hook.new('update', repository.path_to_repo)
- post_receive_hook = Gitlab::Git::Hook.new('post-receive', repository.path_to_repo)
+ pre_receive_hook = Gitlab::Git::Hook.new('pre-receive', project)
+ update_hook = Gitlab::Git::Hook.new('update', project)
+ post_receive_hook = Gitlab::Git::Hook.new('post-receive', project)
- allow(Gitlab::Git::Hook).to receive(:new).
- and_return(pre_receive_hook, update_hook, post_receive_hook)
+ allow(Gitlab::Git::Hook).to receive(:new)
+ .and_return(pre_receive_hook, update_hook, post_receive_hook)
allow(pre_receive_hook).to receive(:trigger).and_call_original
allow(update_hook).to receive(:trigger).and_call_original
@@ -1446,12 +1621,12 @@ describe Repository, models: true do
commit_sha = repository.commit('master').id
tag_sha = tag.target
- expect(pre_receive_hook).to have_received(:trigger).
- with(anything, anything, commit_sha, anything)
- expect(update_hook).to have_received(:trigger).
- with(anything, anything, commit_sha, anything)
- expect(post_receive_hook).to have_received(:trigger).
- with(anything, anything, tag_sha, anything)
+ expect(pre_receive_hook).to have_received(:trigger)
+ .with(anything, anything, commit_sha, anything)
+ expect(update_hook).to have_received(:trigger)
+ .with(anything, anything, commit_sha, anything)
+ expect(post_receive_hook).to have_received(:trigger)
+ .with(anything, anything, tag_sha, anything)
end
end
@@ -1485,25 +1660,25 @@ describe Repository, models: true do
describe '#avatar' do
it 'returns nil if repo does not exist' do
- expect(repository).to receive(:file_on_head).
- and_raise(Rugged::ReferenceError)
+ expect(repository).to receive(:file_on_head)
+ .and_raise(Rugged::ReferenceError)
expect(repository.avatar).to eq(nil)
end
it 'returns the first avatar file found in the repository' do
- expect(repository).to receive(:file_on_head).
- with(:avatar).
- and_return(double(:tree, path: 'logo.png'))
+ expect(repository).to receive(:file_on_head)
+ .with(:avatar)
+ .and_return(double(:tree, path: 'logo.png'))
expect(repository.avatar).to eq('logo.png')
end
it 'caches the output' do
- expect(repository).to receive(:file_on_head).
- with(:avatar).
- once.
- and_return(double(:tree, path: 'logo.png'))
+ expect(repository).to receive(:file_on_head)
+ .with(:avatar)
+ .once
+ .and_return(double(:tree, path: 'logo.png'))
2.times { expect(repository.avatar).to eq('logo.png') }
end
@@ -1561,26 +1736,26 @@ describe Repository, models: true do
end
end
- describe '#contribution_guide', caching: true do
+ describe '#contribution_guide', :use_clean_rails_memory_store_caching do
it 'returns and caches the output' do
- expect(repository).to receive(:file_on_head).
- with(:contributing).
- and_return(Gitlab::Git::Tree.new(path: 'CONTRIBUTING.md')).
- once
+ expect(repository).to receive(:file_on_head)
+ .with(:contributing)
+ .and_return(Gitlab::Git::Tree.new(path: 'CONTRIBUTING.md'))
+ .once
2.times do
- expect(repository.contribution_guide).
- to be_an_instance_of(Gitlab::Git::Tree)
+ expect(repository.contribution_guide)
+ .to be_an_instance_of(Gitlab::Git::Tree)
end
end
end
- describe '#gitignore', caching: true do
+ describe '#gitignore', :use_clean_rails_memory_store_caching do
it 'returns and caches the output' do
- expect(repository).to receive(:file_on_head).
- with(:gitignore).
- and_return(Gitlab::Git::Tree.new(path: '.gitignore')).
- once
+ expect(repository).to receive(:file_on_head)
+ .with(:gitignore)
+ .and_return(Gitlab::Git::Tree.new(path: '.gitignore'))
+ .once
2.times do
expect(repository.gitignore).to be_an_instance_of(Gitlab::Git::Tree)
@@ -1588,12 +1763,12 @@ describe Repository, models: true do
end
end
- describe '#koding_yml', caching: true do
+ describe '#koding_yml', :use_clean_rails_memory_store_caching do
it 'returns and caches the output' do
- expect(repository).to receive(:file_on_head).
- with(:koding).
- and_return(Gitlab::Git::Tree.new(path: '.koding.yml')).
- once
+ expect(repository).to receive(:file_on_head)
+ .with(:koding)
+ .and_return(Gitlab::Git::Tree.new(path: '.koding.yml'))
+ .once
2.times do
expect(repository.koding_yml).to be_an_instance_of(Gitlab::Git::Tree)
@@ -1601,26 +1776,36 @@ describe Repository, models: true do
end
end
- describe '#readme', caching: true do
+ describe '#readme', :use_clean_rails_memory_store_caching do
context 'with a non-existing repository' do
it 'returns nil' do
- expect(repository).to receive(:tree).with(:head).and_return(nil)
+ allow(repository).to receive(:tree).with(:head).and_return(nil)
expect(repository.readme).to be_nil
end
end
context 'with an existing repository' do
- it 'returns the README' do
- expect(repository.readme).to be_an_instance_of(Gitlab::Git::Blob)
+ context 'when no README exists' do
+ it 'returns nil' do
+ allow_any_instance_of(Tree).to receive(:readme).and_return(nil)
+
+ expect(repository.readme).to be_nil
+ end
+ end
+
+ context 'when a README exists' do
+ it 'returns the README' do
+ expect(repository.readme).to be_an_instance_of(ReadmeBlob)
+ end
end
end
end
describe '#expire_statistics_caches' do
it 'expires the caches' do
- expect(repository).to receive(:expire_method_caches).
- with(%i(size commit_count))
+ expect(repository).to receive(:expire_method_caches)
+ .with(%i(size commit_count))
repository.expire_statistics_caches
end
@@ -1637,8 +1822,8 @@ describe Repository, models: true do
describe '#expire_all_method_caches' do
it 'expires the caches of all methods' do
- expect(repository).to receive(:expire_method_caches).
- with(Repository::CACHED_METHODS)
+ expect(repository).to receive(:expire_method_caches)
+ .with(Repository::CACHED_METHODS)
repository.expire_all_method_caches
end
@@ -1663,8 +1848,8 @@ describe Repository, models: true do
context 'with an existing repository' do
it 'returns a Gitlab::Git::Tree' do
- expect(repository.file_on_head(:readme)).
- to be_an_instance_of(Gitlab::Git::Tree)
+ expect(repository.file_on_head(:readme))
+ .to be_an_instance_of(Gitlab::Git::Tree)
end
end
end
@@ -1740,7 +1925,7 @@ describe Repository, models: true do
end
describe '#commit_count_for_ref' do
- let(:project) { create :empty_project }
+ let(:project) { create :project }
context 'with a non-existing repository' do
it 'returns 0' do
@@ -1762,7 +1947,7 @@ describe Repository, models: true do
end
end
- describe '#cache_method_output', caching: true do
+ describe '#cache_method_output', :use_clean_rails_memory_store_caching do
context 'with a non-existing repository' do
let(:value) do
repository.cache_method_output(:cats, fallback: 10) do
@@ -1802,12 +1987,13 @@ describe Repository, models: true do
describe '#refresh_method_caches' do
it 'refreshes the caches of the given types' do
- expect(repository).to receive(:expire_method_caches).
- with(%i(rendered_readme license_blob license_key))
+ expect(repository).to receive(:expire_method_caches)
+ .with(%i(rendered_readme license_blob license_key license))
expect(repository).to receive(:rendered_readme)
expect(repository).to receive(:license_blob)
expect(repository).to receive(:license_key)
+ expect(repository).to receive(:license)
repository.refresh_method_caches(%i(readme license))
end
@@ -1849,17 +2035,45 @@ describe Repository, models: true do
end
end
- # TODO: Uncomment when feature is reenabled
- # describe '#is_ancestor?' do
- # context 'Gitaly is_ancestor feature enabled' do
- # it 'asks Gitaly server if it\'s an ancestor' do
- # commit = repository.commit
- # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(true)
- # expect(Gitlab::GitalyClient::Commit).to receive(:is_ancestor).
- # with(repository.raw_repository, commit.id, commit.id).and_return(true)
- #
- # expect(repository.is_ancestor?(commit.id, commit.id)).to be true
- # end
- # end
- # end
+ describe '#is_ancestor?' do
+ let(:commit) { repository.commit }
+ let(:ancestor) { commit.parents.first }
+
+ context 'with Gitaly enabled' do
+ it 'it is an ancestor' do
+ expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true)
+ end
+
+ it 'it is not an ancestor' do
+ expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false)
+ end
+
+ it 'returns false on nil-values' do
+ expect(repository.is_ancestor?(nil, commit.id)).to eq(false)
+ expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false)
+ expect(repository.is_ancestor?(nil, nil)).to eq(false)
+ end
+ end
+
+ context 'with Gitaly disabled' do
+ before do
+ allow(Gitlab::GitalyClient).to receive(:enabled?).and_return(false)
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(false)
+ end
+
+ it 'it is an ancestor' do
+ expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true)
+ end
+
+ it 'it is not an ancestor' do
+ expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false)
+ end
+
+ it 'returns false on nil-values' do
+ expect(repository.is_ancestor?(nil, commit.id)).to eq(false)
+ expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false)
+ expect(repository.is_ancestor?(nil, nil)).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb
index 171a51fcc5b..fece370c03f 100644
--- a/spec/models/route_spec.rb
+++ b/spec/models/route_spec.rb
@@ -1,17 +1,44 @@
require 'spec_helper'
-describe Route, models: true do
- let!(:group) { create(:group, path: 'git_lab', name: 'git_lab') }
- let!(:route) { group.route }
+describe Route do
+ let(:group) { create(:group, path: 'git_lab', name: 'git_lab') }
+ let(:route) { group.route }
describe 'relationships' do
it { is_expected.to belong_to(:source) }
end
describe 'validations' do
+ before do
+ expect(route).to be_persisted
+ end
+
it { is_expected.to validate_presence_of(:source) }
it { is_expected.to validate_presence_of(:path) }
- it { is_expected.to validate_uniqueness_of(:path) }
+ it { is_expected.to validate_uniqueness_of(:path).case_insensitive }
+ end
+
+ describe 'callbacks' do
+ context 'after update' do
+ it 'calls #create_redirect_for_old_path' do
+ expect(route).to receive(:create_redirect_for_old_path)
+ route.update_attributes(path: 'foo')
+ end
+
+ it 'calls #delete_conflicting_redirects' do
+ expect(route).to receive(:delete_conflicting_redirects)
+ route.update_attributes(path: 'foo')
+ end
+ end
+
+ context 'after create' do
+ it 'calls #delete_conflicting_redirects' do
+ route.destroy
+ new_route = described_class.new(source: group, path: group.path)
+ expect(new_route).to receive(:delete_conflicting_redirects)
+ new_route.save!
+ end
+ end
end
describe '.inside_path' do
@@ -22,7 +49,7 @@ describe Route, models: true do
let!(:another_group_nested) { create(:group, path: 'another', name: 'another', parent: similar_group) }
it 'returns correct routes' do
- expect(Route.inside_path('git_lab')).to match_array([nested_group.route, deep_nested_group.route])
+ expect(described_class.inside_path('git_lab')).to match_array([nested_group.route, deep_nested_group.route])
end
end
@@ -35,9 +62,11 @@ describe Route, models: true do
context 'path update' do
context 'when route name is set' do
- before { route.update_attributes(path: 'bar') }
+ before do
+ route.update_attributes(path: 'bar')
+ end
- it "updates children routes with new path" do
+ it 'updates children routes with new path' do
expect(described_class.exists?(path: 'bar')).to be_truthy
expect(described_class.exists?(path: 'bar/test')).to be_truthy
expect(described_class.exists?(path: 'bar/test/foo')).to be_truthy
@@ -56,10 +85,24 @@ describe Route, models: true do
expect(route.update_attributes(path: 'bar')).to be_truthy
end
end
+
+ context 'when conflicting redirects exist' do
+ let!(:conflicting_redirect1) { route.create_redirect('bar/test') }
+ let!(:conflicting_redirect2) { route.create_redirect('bar/test/foo') }
+ let!(:conflicting_redirect3) { route.create_redirect('gitlab-org') }
+
+ it 'deletes the conflicting redirects' do
+ route.update_attributes(path: 'bar')
+
+ expect(RedirectRoute.exists?(path: 'bar/test')).to be_falsey
+ expect(RedirectRoute.exists?(path: 'bar/test/foo')).to be_falsey
+ expect(RedirectRoute.exists?(path: 'gitlab-org')).to be_truthy
+ end
+ end
end
context 'name update' do
- it "updates children routes with new path" do
+ it 'updates children routes with new path' do
route.update_attributes(name: 'bar')
expect(described_class.exists?(name: 'bar')).to be_truthy
@@ -77,4 +120,98 @@ describe Route, models: true do
end
end
end
+
+ describe '#create_redirect_for_old_path' do
+ context 'if the path changed' do
+ it 'creates a RedirectRoute for the old path' do
+ redirect_scope = route.source.redirect_routes.where(path: 'git_lab')
+ expect(redirect_scope.exists?).to be_falsey
+ route.path = 'new-path'
+ route.save!
+ expect(redirect_scope.exists?).to be_truthy
+ end
+ end
+ end
+
+ describe '#create_redirect' do
+ it 'creates a RedirectRoute with the same source' do
+ redirect_route = route.create_redirect('foo')
+ expect(redirect_route).to be_a(RedirectRoute)
+ expect(redirect_route).to be_persisted
+ expect(redirect_route.source).to eq(route.source)
+ expect(redirect_route.path).to eq('foo')
+ end
+ end
+
+ describe '#delete_conflicting_redirects' do
+ context 'when a redirect route with the same path exists' do
+ context 'when the redirect route has matching case' do
+ let!(:redirect1) { route.create_redirect(route.path) }
+
+ it 'deletes the redirect' do
+ expect do
+ route.delete_conflicting_redirects
+ end.to change { RedirectRoute.count }.by(-1)
+ end
+
+ context 'when redirect routes with paths descending from the route path exists' do
+ let!(:redirect2) { route.create_redirect("#{route.path}/foo") }
+ let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") }
+ let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") }
+ let!(:other_redirect) { route.create_redirect("other") }
+
+ it 'deletes all redirects with paths that descend from the route path' do
+ expect do
+ route.delete_conflicting_redirects
+ end.to change { RedirectRoute.count }.by(-4)
+ end
+ end
+ end
+
+ context 'when the redirect route is differently cased' do
+ let!(:redirect1) { route.create_redirect(route.path.upcase) }
+
+ it 'deletes the redirect' do
+ expect do
+ route.delete_conflicting_redirects
+ end.to change { RedirectRoute.count }.by(-1)
+ end
+ end
+ end
+ end
+
+ describe '#conflicting_redirects' do
+ it 'returns an ActiveRecord::Relation' do
+ expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation)
+ end
+
+ context 'when a redirect route with the same path exists' do
+ context 'when the redirect route has matching case' do
+ let!(:redirect1) { route.create_redirect(route.path) }
+
+ it 'returns the redirect route' do
+ expect(route.conflicting_redirects).to match_array([redirect1])
+ end
+
+ context 'when redirect routes with paths descending from the route path exists' do
+ let!(:redirect2) { route.create_redirect("#{route.path}/foo") }
+ let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") }
+ let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") }
+ let!(:other_redirect) { route.create_redirect("other") }
+
+ it 'returns the redirect routes' do
+ expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3, redirect4])
+ end
+ end
+ end
+
+ context 'when the redirect route is differently cased' do
+ let!(:redirect1) { route.create_redirect(route.path.upcase) }
+
+ it 'returns the redirect route' do
+ expect(route.conflicting_redirects).to match_array([redirect1])
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
index 5710edbc9e0..8f05deb8b15 100644
--- a/spec/models/sent_notification_spec.rb
+++ b/spec/models/sent_notification_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe SentNotification, model: true do
+describe SentNotification do
describe 'validation' do
describe 'note validity' do
context "when the project doesn't match the noteable's project" do
@@ -21,7 +21,7 @@ describe SentNotification, model: true do
end
context "when the noteable project and discussion project match" do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
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) }
@@ -38,7 +38,7 @@ describe SentNotification, model: true do
let(:issue) { create(:issue) }
it 'creates a new SentNotification' do
- expect { described_class.record(issue, user.id) }.to change { SentNotification.count }.by(1)
+ expect { described_class.record(issue, user.id) }.to change { described_class.count }.by(1)
end
end
@@ -47,7 +47,7 @@ describe SentNotification, model: true do
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)
+ expect { described_class.record_note(note, user.id) }.to change { described_class.count }.by(1)
end
end
@@ -128,7 +128,7 @@ describe SentNotification, model: true do
end
context 'for commit' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:commit) { project.commit }
subject { described_class.record(commit, project.creator.id) }
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index 134882648b9..0f2f906c667 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Service, models: true do
+describe Service do
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -23,7 +23,7 @@ describe Service, models: true do
end
context 'when repository is empty' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'returns true' do
expect(service.can_test?).to be true
@@ -46,7 +46,7 @@ describe Service, models: true do
end
context 'when repository is empty' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'test runs execute' do
expect(service).to receive(:execute).with(data)
@@ -69,7 +69,7 @@ describe Service, models: true do
api_key: '123456789'
})
end
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe 'is prefilled for projects pushover service' do
it "has all fields prefilled" do
@@ -88,7 +88,7 @@ describe Service, models: true do
describe "{property}_changed?" do
let(:service) do
BambooService.create(
- project: create(:empty_project),
+ project: create(:project),
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -128,7 +128,7 @@ describe Service, models: true do
describe "{property}_touched?" do
let(:service) do
BambooService.create(
- project: create(:empty_project),
+ project: create(:project),
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -168,7 +168,7 @@ describe Service, models: true do
describe "{property}_was" do
let(:service) do
BambooService.create(
- project: create(:empty_project),
+ project: create(:project),
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
@@ -208,7 +208,7 @@ describe Service, models: true do
describe 'initialize service with no properties' do
let(:service) do
GitlabIssueTrackerService.create(
- project: create(:empty_project),
+ project: create(:project),
title: 'random title'
)
end
@@ -223,7 +223,7 @@ describe Service, models: true do
end
describe "callbacks" do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let!(:service) do
RedmineService.new(
project: project,
diff --git a/spec/models/snippet_blob_spec.rb b/spec/models/snippet_blob_spec.rb
new file mode 100644
index 00000000000..7c71c458fcc
--- /dev/null
+++ b/spec/models/snippet_blob_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+describe SnippetBlob do
+ let(:snippet) { create(:snippet) }
+
+ subject { described_class.new(snippet) }
+
+ describe '#id' do
+ it 'returns the snippet ID' do
+ expect(subject.id).to eq(snippet.id)
+ end
+ end
+
+ describe '#name' do
+ it 'returns the snippet file name' do
+ expect(subject.name).to eq(snippet.file_name)
+ end
+ end
+
+ describe '#size' do
+ it 'returns the data size' do
+ expect(subject.size).to eq(subject.data.bytesize)
+ end
+ end
+
+ describe '#data' do
+ it 'returns the snippet content' do
+ expect(subject.data).to eq(snippet.content)
+ end
+ end
+
+ describe '#rendered_markup' do
+ context 'when the content is GFM' do
+ let(:snippet) { create(:snippet, file_name: 'file.md') }
+
+ it 'returns the rendered GFM' do
+ expect(subject.rendered_markup).to eq(snippet.content_html)
+ end
+ end
+
+ context 'when the content is not GFM' do
+ it 'returns nil' do
+ expect(subject.rendered_markup).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 8095d01b69e..de3ca300ae3 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -1,11 +1,10 @@
require 'spec_helper'
-describe Snippet, models: true do
+describe Snippet do
describe 'modules' do
subject { described_class }
it { is_expected.to include_module(Gitlab::VisibilityLevel) }
- it { is_expected.to include_module(Linguist::BlobHelper) }
it { is_expected.to include_module(Participable) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
@@ -34,7 +33,7 @@ describe Snippet, models: true do
describe '#to_reference' do
context 'when snippet belongs to a project' do
- let(:project) { build(:empty_project, name: 'sample-project') }
+ let(:project) { build(:project, name: 'sample-project') }
let(:snippet) { build(:snippet, id: 1, project: project) }
it 'returns a String reference to the object' do
@@ -42,7 +41,7 @@ describe Snippet, models: true do
end
it 'supports a cross-project reference' do
- another_project = build(:empty_project, name: 'another-project', namespace: project.namespace)
+ another_project = build(:project, name: 'another-project', namespace: project.namespace)
expect(snippet.to_reference(another_project)).to eq "sample-project$1"
end
end
@@ -55,14 +54,14 @@ describe Snippet, models: true do
end
it 'still returns shortest reference when project arg present' do
- another_project = build(:empty_project, name: 'another-project')
+ another_project = build(:project, name: 'another-project')
expect(snippet.to_reference(another_project)).to eq "$1"
end
end
end
describe '#file_name' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'file_name is nil' do
let(:snippet) { create(:snippet, project: project, file_name: nil) }
@@ -132,48 +131,8 @@ describe Snippet, models: true do
end
end
- describe '.accessible_to' do
- let(:author) { create(:author) }
- let(:project) { create(:empty_project) }
-
- let!(:public_snippet) { create(:snippet, :public) }
- let!(:internal_snippet) { create(:snippet, :internal) }
- let!(:private_snippet) { create(:snippet, :private, author: author) }
-
- let!(:project_public_snippet) { create(:snippet, :public, project: project) }
- let!(:project_internal_snippet) { create(:snippet, :internal, project: project) }
- let!(:project_private_snippet) { create(:snippet, :private, project: project) }
-
- it 'returns only public snippets when user is blank' do
- expect(described_class.accessible_to(nil)).to match_array [public_snippet, project_public_snippet]
- end
-
- it 'returns only public, and internal snippets for regular users' do
- user = create(:user)
-
- expect(described_class.accessible_to(user)).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet]
- end
-
- it 'returns public, internal snippets and project private snippets for project members' do
- member = create(:user)
- project.team << [member, :developer]
-
- expect(described_class.accessible_to(member)).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
- end
-
- it 'returns private snippets where the user is the author' do
- expect(described_class.accessible_to(author)).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet]
- end
-
- it 'returns all snippets when for admins' do
- admin = create(:admin)
-
- expect(described_class.accessible_to(admin)).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
- end
- end
-
describe '#participants' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:snippet) { create(:snippet, content: 'foo', project: project) }
let!(:note1) do
@@ -241,4 +200,16 @@ describe Snippet, models: true do
end
end
end
+
+ describe '#blob' do
+ let(:snippet) { create(:snippet) }
+
+ it 'returns a blob representing the snippet data' do
+ blob = snippet.blob
+
+ expect(blob).to be_a(Blob)
+ expect(blob.path).to eq(snippet.file_name)
+ expect(blob.data).to eq(snippet.content)
+ end
+ end
end
diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb
index 838fba6c92d..0d6b4384ada 100644
--- a/spec/models/spam_log_spec.rb
+++ b/spec/models/spam_log_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe SpamLog, models: true do
+describe SpamLog do
let(:admin) { create(:admin) }
describe 'associations' do
diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb
index 9ab112bb2ee..9e4c2620d82 100644
--- a/spec/models/subscription_spec.rb
+++ b/spec/models/subscription_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Subscription, models: true do
+describe Subscription do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:subscribable) }
diff --git a/spec/models/system_note_metadata_spec.rb b/spec/models/system_note_metadata_spec.rb
index d238e28209a..1e3f587e460 100644
--- a/spec/models/system_note_metadata_spec.rb
+++ b/spec/models/system_note_metadata_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe SystemNoteMetadata, models: true do
+describe SystemNoteMetadata do
describe 'associations' do
it { is_expected.to belong_to(:note) }
end
diff --git a/spec/models/timelog_spec.rb b/spec/models/timelog_spec.rb
index ebc694213b6..6e30798356c 100644
--- a/spec/models/timelog_spec.rb
+++ b/spec/models/timelog_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe Timelog, type: :model do
+RSpec.describe Timelog do
subject { build(:timelog) }
let(:issue) { create(:issue) }
let(:merge_request) { create(:merge_request) }
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index 581305ad39f..3e8f3848eca 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Todo, models: true do
+describe Todo do
let(:issue) { create(:issue) }
describe 'relationships' do
@@ -125,4 +125,50 @@ describe Todo, models: true do
expect(subject.target_reference).to eq issue.to_reference(full: true)
end
end
+
+ describe '#self_added?' do
+ let(:user_1) { build(:user) }
+
+ before do
+ subject.user = user_1
+ end
+
+ it 'is true when the user is the author' do
+ subject.author = user_1
+
+ expect(subject).to be_self_added
+ end
+
+ it 'is false when the user is not the author' do
+ subject.author = build(:user)
+
+ expect(subject).not_to be_self_added
+ end
+ end
+
+ describe '#self_assigned?' do
+ let(:user_1) { build(:user) }
+
+ before do
+ subject.user = user_1
+ subject.author = user_1
+ subject.action = Todo::ASSIGNED
+ end
+
+ it 'is true when todo is ASSIGNED and self_added' do
+ expect(subject).to be_self_assigned
+ end
+
+ it 'is false when the todo is not ASSIGNED' do
+ subject.action = Todo::MENTIONED
+
+ expect(subject).not_to be_self_assigned
+ end
+
+ it 'is false when todo is not self_added' do
+ subject.author = build(:user)
+
+ expect(subject).not_to be_self_assigned
+ end
+ end
end
diff --git a/spec/models/tree_spec.rb b/spec/models/tree_spec.rb
index a87983b7492..6bdb62a0864 100644
--- a/spec/models/tree_spec.rb
+++ b/spec/models/tree_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Tree, models: true do
+describe Tree do
let(:repository) { create(:project, :repository).repository }
let(:sha) { repository.root_ref }
diff --git a/spec/models/trending_project_spec.rb b/spec/models/trending_project_spec.rb
index cc28c6d4004..3b5e7ca0d39 100644
--- a/spec/models/trending_project_spec.rb
+++ b/spec/models/trending_project_spec.rb
@@ -2,11 +2,11 @@ require 'spec_helper'
describe TrendingProject do
let(:user) { create(:user) }
- let(:public_project1) { create(:empty_project, :public) }
- let(:public_project2) { create(:empty_project, :public) }
- let(:public_project3) { create(:empty_project, :public) }
- let(:private_project) { create(:empty_project, :private) }
- let(:internal_project) { create(:empty_project, :internal) }
+ let(:public_project1) { create(:project, :public) }
+ let(:public_project2) { create(:project, :public) }
+ let(:public_project3) { create(:project, :public) }
+ let(:private_project) { create(:project, :private) }
+ let(:internal_project) { create(:project, :internal) }
before do
3.times do
diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb
index 4c832c87d6a..345382ea8c7 100644
--- a/spec/models/upload_spec.rb
+++ b/spec/models/upload_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe Upload, type: :model do
+describe Upload do
describe 'assocations' do
it { is_expected.to belong_to(:model) }
end
@@ -54,8 +54,8 @@ describe Upload, type: :model do
uploader: 'AvatarUploader'
)
- expect { described_class.remove_path(__FILE__) }.
- to change { described_class.count }.from(1).to(0)
+ expect { described_class.remove_path(__FILE__) }
+ .to change { described_class.count }.from(1).to(0)
end
end
diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb
index a8c25766e73..b4669f8c1c2 100644
--- a/spec/models/user_agent_detail_spec.rb
+++ b/spec/models/user_agent_detail_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe UserAgentDetail, type: :model do
+describe UserAgentDetail do
describe '.submittable?' do
it 'is submittable when not already submitted' do
detail = build(:user_agent_detail)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 0bcebc27598..97bb91a6ac8 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe User, models: true do
+describe User do
include Gitlab::CurrentSettings
describe 'modules' do
@@ -13,6 +13,10 @@ describe User, models: true do
it { is_expected.to include_module(TokenAuthenticatable) }
end
+ describe 'delegations' do
+ it { is_expected.to delegate_method(:path).to(:namespace).with_prefix }
+ end
+
describe 'associations' do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
@@ -22,7 +26,7 @@ describe User, models: true do
it { is_expected.to have_many(:deploy_keys).dependent(:destroy) }
it { is_expected.to have_many(:events).dependent(:destroy) }
it { is_expected.to have_many(:recent_events).class_name('Event') }
- it { is_expected.to have_many(:issues).dependent(:restrict_with_exception) }
+ it { is_expected.to have_many(:issues).dependent(:destroy) }
it { is_expected.to have_many(:notes).dependent(:destroy) }
it { is_expected.to have_many(:merge_requests).dependent(:destroy) }
it { is_expected.to have_many(:identities).dependent(:destroy) }
@@ -76,7 +80,7 @@ describe User, models: true do
describe '#project_members' do
it 'does not include project memberships for which user is a requester' do
user = create(:user)
- project = create(:empty_project, :public, :access_requestable)
+ project = create(:project, :public, :access_requestable)
project.request_access(user)
expect(user.project_members).to be_empty
@@ -97,8 +101,33 @@ describe User, models: true do
expect(user.errors.values).to eq [['dashboard is a reserved name']]
end
+ it 'allows child names' do
+ user = build(:user, username: 'avatar')
+
+ expect(user).to be_valid
+ end
+
+ it 'allows wildcard names' do
+ user = build(:user, username: 'blob')
+
+ expect(user).to be_valid
+ end
+
it 'validates uniqueness' do
- expect(subject).to validate_uniqueness_of(:username).case_insensitive
+ user = build(:user)
+
+ expect(user).to validate_uniqueness_of(:username).case_insensitive
+ end
+
+ context 'when username is changed' do
+ let(:user) { build_stubbed(:user, username: 'old_path', namespace: build_stubbed(:namespace)) }
+
+ it 'validates move_dir is allowed for the namespace' do
+ expect(user.namespace).to receive(:any_project_has_container_registry_tags?).and_return(true)
+ user.username = 'new_path'
+ expect(user).to be_invalid
+ expect(user.errors.messages[:username].first).to match('cannot be changed if a personal project has container registry tags')
+ end
end
end
@@ -243,7 +272,7 @@ describe User, models: true do
it "returns users with 2fa enabled via OTP" do
user_with_2fa = create(:user, :two_factor_via_otp)
user_without_2fa = create(:user)
- users_with_two_factor = User.with_two_factor.pluck(:id)
+ users_with_two_factor = described_class.with_two_factor.pluck(:id)
expect(users_with_two_factor).to include(user_with_2fa.id)
expect(users_with_two_factor).not_to include(user_without_2fa.id)
@@ -252,7 +281,7 @@ describe User, models: true do
it "returns users with 2fa enabled via U2F" do
user_with_2fa = create(:user, :two_factor_via_u2f)
user_without_2fa = create(:user)
- users_with_two_factor = User.with_two_factor.pluck(:id)
+ users_with_two_factor = described_class.with_two_factor.pluck(:id)
expect(users_with_two_factor).to include(user_with_2fa.id)
expect(users_with_two_factor).not_to include(user_without_2fa.id)
@@ -261,7 +290,7 @@ describe User, models: true do
it "returns users with 2fa enabled via OTP and U2F" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
user_without_2fa = create(:user)
- users_with_two_factor = User.with_two_factor.pluck(:id)
+ users_with_two_factor = described_class.with_two_factor.pluck(:id)
expect(users_with_two_factor).to eq([user_with_2fa.id])
expect(users_with_two_factor).not_to include(user_without_2fa.id)
@@ -272,7 +301,7 @@ describe User, models: true do
it "excludes users with 2fa enabled via OTP" do
user_with_2fa = create(:user, :two_factor_via_otp)
user_without_2fa = create(:user)
- users_without_two_factor = User.without_two_factor.pluck(:id)
+ users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
@@ -281,7 +310,7 @@ describe User, models: true do
it "excludes users with 2fa enabled via U2F" do
user_with_2fa = create(:user, :two_factor_via_u2f)
user_without_2fa = create(:user)
- users_without_two_factor = User.without_two_factor.pluck(:id)
+ users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
@@ -290,7 +319,7 @@ describe User, models: true do
it "excludes users with 2fa enabled via OTP and U2F" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
user_without_2fa = create(:user)
- users_without_two_factor = User.without_two_factor.pluck(:id)
+ users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
@@ -306,8 +335,8 @@ describe User, models: true do
create(:todo, user: current_user, author: user_2, state: :done)
create(:todo, user: current_user, author: user_3, state: :pending)
- expect(User.todo_authors(current_user.id, 'pending')).to eq [user_3]
- expect(User.todo_authors(current_user.id, 'done')).to eq [user_2]
+ expect(described_class.todo_authors(current_user.id, 'pending')).to eq [user_3]
+ expect(described_class.todo_authors(current_user.id, 'done')).to eq [user_2]
end
end
end
@@ -332,6 +361,55 @@ describe User, models: true do
end
end
+ describe 'after update hook' do
+ describe '.update_invalid_gpg_signatures' do
+ let(:user) do
+ create(:user, email: 'tula.torphy@abshire.ca').tap do |user|
+ user.skip_reconfirmation!
+ end
+ end
+
+ it 'does nothing when the name is updated' do
+ expect(user).not_to receive(:update_invalid_gpg_signatures)
+ user.update_attributes!(name: 'Bette')
+ end
+
+ it 'synchronizes the gpg keys when the email is updated' do
+ expect(user).to receive(:update_invalid_gpg_signatures)
+ user.update_attributes!(email: 'shawnee.ritchie@denesik.com')
+ end
+ end
+ end
+
+ describe '#update_tracked_fields!', :clean_gitlab_redis_shared_state do
+ let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") }
+ let(:user) { create(:user) }
+
+ it 'writes trackable attributes' do
+ expect do
+ user.update_tracked_fields!(request)
+ end.to change { user.reload.current_sign_in_at }
+ end
+
+ it 'does not write trackable attributes when called a second time within the hour' do
+ user.update_tracked_fields!(request)
+
+ expect do
+ user.update_tracked_fields!(request)
+ end.not_to change { user.reload.current_sign_in_at }
+ end
+
+ it 'writes trackable attributes for a different user' do
+ user2 = create(:user)
+
+ user.update_tracked_fields!(request)
+
+ expect do
+ user2.update_tracked_fields!(request)
+ end.to change { user2.reload.current_sign_in_at }
+ end
+ end
+
shared_context 'user keys' do
let(:user) { create(:user) }
let!(:key) { create(:key, user: user) }
@@ -399,6 +477,56 @@ describe User, models: true do
end
end
+ describe 'ensure incoming email token' do
+ it 'has incoming email token' do
+ user = create(:user)
+ expect(user.incoming_email_token).not_to be_blank
+ end
+ end
+
+ describe '#ensure_user_rights_and_limits' do
+ describe 'with external user' do
+ let(:user) { create(:user, external: true) }
+
+ it 'receives callback when external changes' do
+ expect(user).to receive(:ensure_user_rights_and_limits)
+
+ user.update_attributes(external: false)
+ end
+
+ it 'ensures correct rights and limits for user' do
+ stub_config_setting(default_can_create_group: true)
+
+ expect { user.update_attributes(external: false) }.to change { user.can_create_group }.to(true)
+ .and change { user.projects_limit }.to(current_application_settings.default_projects_limit)
+ end
+ end
+
+ describe 'without external user' do
+ let(:user) { create(:user, external: false) }
+
+ it 'receives callback when external changes' do
+ expect(user).to receive(:ensure_user_rights_and_limits)
+
+ user.update_attributes(external: true)
+ end
+
+ it 'ensures correct rights and limits for user' do
+ expect { user.update_attributes(external: true) }.to change { user.can_create_group }.to(false)
+ .and change { user.projects_limit }.to(0)
+ end
+ end
+ end
+
+ describe 'rss token' do
+ it 'ensures an rss token on read' do
+ user = create(:user, rss_token: nil)
+ rss_token = user.rss_token
+ expect(rss_token).not_to be_blank
+ expect(user.reload.rss_token).to eq rss_token
+ end
+ end
+
describe '#recently_sent_password_reset?' do
it 'is false when reset_password_sent_at is nil' do
user = build_stubbed(:user, reset_password_sent_at: nil)
@@ -443,11 +571,11 @@ describe User, models: true do
before do
@user = create(:user)
- @project = create(:empty_project, namespace: @user.namespace)
- @project_2 = create(:empty_project, group: create(:group)) do |project|
+ @project = create(:project, namespace: @user.namespace)
+ @project_2 = create(:project, group: create(:group)) do |project|
project.add_master(@user)
end
- @project_3 = create(:empty_project, group: create(:group)) do |project|
+ @project_3 = create(:project, group: create(:group)) do |project|
project.add_developer(@user)
end
end
@@ -492,7 +620,7 @@ describe User, models: true do
describe 'namespaced' do
before do
@user = create :user
- @project = create(:empty_project, namespace: @user.namespace)
+ @project = create(:project, namespace: @user.namespace)
end
it { expect(@user.several_namespaces?).to be_falsey }
@@ -512,44 +640,44 @@ describe User, models: true do
let(:user) { double }
it 'filters by active users by default' do
- expect(User).to receive(:active).and_return([user])
+ expect(described_class).to receive(:active).and_return([user])
- expect(User.filter(nil)).to include user
+ expect(described_class.filter(nil)).to include user
end
it 'filters by admins' do
- expect(User).to receive(:admins).and_return([user])
+ expect(described_class).to receive(:admins).and_return([user])
- expect(User.filter('admins')).to include user
+ expect(described_class.filter('admins')).to include user
end
it 'filters by blocked' do
- expect(User).to receive(:blocked).and_return([user])
+ expect(described_class).to receive(:blocked).and_return([user])
- expect(User.filter('blocked')).to include user
+ expect(described_class.filter('blocked')).to include user
end
it 'filters by two_factor_disabled' do
- expect(User).to receive(:without_two_factor).and_return([user])
+ expect(described_class).to receive(:without_two_factor).and_return([user])
- expect(User.filter('two_factor_disabled')).to include user
+ expect(described_class.filter('two_factor_disabled')).to include user
end
it 'filters by two_factor_enabled' do
- expect(User).to receive(:with_two_factor).and_return([user])
+ expect(described_class).to receive(:with_two_factor).and_return([user])
- expect(User.filter('two_factor_enabled')).to include user
+ expect(described_class.filter('two_factor_enabled')).to include user
end
it 'filters by wop' do
- expect(User).to receive(:without_projects).and_return([user])
+ expect(described_class).to receive(:without_projects).and_return([user])
- expect(User.filter('wop')).to include user
+ expect(described_class.filter('wop')).to include user
end
end
describe '.without_projects' do
- let!(:project) { create(:empty_project, :public, :access_requestable) }
+ let!(:project) { create(:project, :public, :access_requestable) }
let!(:user) { create(:user) }
let!(:user_without_project) { create(:user) }
let!(:user_without_project2) { create(:user) }
@@ -565,19 +693,9 @@ describe User, models: true do
project.request_access(user_without_project2)
end
- it { expect(User.without_projects).not_to include user }
- it { expect(User.without_projects).to include user_without_project }
- it { expect(User.without_projects).to include user_without_project2 }
- end
-
- describe '.not_in_project' do
- before do
- User.delete_all
- @user = create :user
- @project = create(:empty_project)
- end
-
- it { expect(User.not_in_project(@project)).to include(@user, @project.owner) }
+ it { expect(described_class.without_projects).not_to include user }
+ it { expect(described_class.without_projects).to include user_without_project }
+ it { expect(described_class.without_projects).to include user_without_project2 }
end
describe 'user creation' do
@@ -593,7 +711,7 @@ describe User, models: true do
end
describe 'with defaults' do
- let(:user) { User.new }
+ let(:user) { described_class.new }
it "applies defaults to user" do
expect(user.projects_limit).to eq(Gitlab.config.gitlab.default_projects_limit)
@@ -603,7 +721,7 @@ describe User, models: true do
end
describe 'with default overrides' do
- let(:user) { User.new(projects_limit: 123, can_create_group: false, can_create_team: true) }
+ let(:user) { described_class.new(projects_limit: 123, can_create_group: false, can_create_team: true) }
it "applies defaults to user" do
expect(user.projects_limit).to eq(123)
@@ -635,7 +753,7 @@ describe User, models: true do
protocol_and_expectation = {
'http' => false,
'ssh' => true,
- '' => true,
+ '' => true
}
protocol_and_expectation.each do |protocol, expected|
@@ -653,58 +771,65 @@ describe User, models: true do
it 'finds by primary email' do
user = create(:user, email: 'foo@example.com')
- expect(User.find_by_any_email(user.email)).to eq user
+ expect(described_class.find_by_any_email(user.email)).to eq user
end
it 'finds by secondary email' do
email = create(:email, email: 'foo@example.com')
user = email.user
- expect(User.find_by_any_email(email.email)).to eq user
+ expect(described_class.find_by_any_email(email.email)).to eq user
end
it 'returns nil when nothing found' do
- expect(User.find_by_any_email('')).to be_nil
+ expect(described_class.find_by_any_email('')).to be_nil
end
end
describe '.search' do
- let(:user) { create(:user) }
+ let!(:user) { create(:user, name: 'user', username: 'usern', email: 'email@gmail.com') }
+ let!(:user2) { create(:user, name: 'user name', username: 'username', email: 'someemail@gmail.com') }
- it 'returns users with a matching name' do
- expect(described_class.search(user.name)).to eq([user])
- end
+ describe 'name matching' do
+ it 'returns users with a matching name with exact match first' do
+ expect(described_class.search(user.name)).to eq([user, user2])
+ end
- it 'returns users with a partially matching name' do
- expect(described_class.search(user.name[0..2])).to eq([user])
- end
+ it 'returns users with a partially matching name' do
+ expect(described_class.search(user.name[0..2])).to eq([user, user2])
+ end
- it 'returns users with a matching name regardless of the casing' do
- expect(described_class.search(user.name.upcase)).to eq([user])
+ it 'returns users with a matching name regardless of the casing' do
+ expect(described_class.search(user2.name.upcase)).to eq([user2])
+ end
end
- it 'returns users with a matching Email' do
- expect(described_class.search(user.email)).to eq([user])
- end
+ describe 'email matching' do
+ it 'returns users with a matching Email' do
+ expect(described_class.search(user.email)).to eq([user, user2])
+ end
- it 'returns users with a partially matching Email' do
- expect(described_class.search(user.email[0..2])).to eq([user])
- end
+ it 'returns users with a partially matching Email' do
+ expect(described_class.search(user.email[0..2])).to eq([user, user2])
+ end
- it 'returns users with a matching Email regardless of the casing' do
- expect(described_class.search(user.email.upcase)).to eq([user])
+ it 'returns users with a matching Email regardless of the casing' do
+ expect(described_class.search(user2.email.upcase)).to eq([user2])
+ end
end
- it 'returns users with a matching username' do
- expect(described_class.search(user.username)).to eq([user])
- end
+ describe 'username matching' do
+ it 'returns users with a matching username' do
+ expect(described_class.search(user.username)).to eq([user, user2])
+ end
- it 'returns users with a partially matching username' do
- expect(described_class.search(user.username[0..2])).to eq([user])
- end
+ it 'returns users with a partially matching username' do
+ expect(described_class.search(user.username[0..2])).to eq([user, user2])
+ end
- it 'returns users with a matching username regardless of the casing' do
- expect(described_class.search(user.username.upcase)).to eq([user])
+ it 'returns users with a matching username regardless of the casing' do
+ expect(described_class.search(user2.username.upcase)).to eq([user2])
+ end
end
end
@@ -805,12 +930,12 @@ describe User, models: true do
let!(:user) { create(:user, username: username) }
it 'gets the correct user' do
- expect(User.by_login(user.email.upcase)).to eq user
- expect(User.by_login(user.email)).to eq user
- expect(User.by_login(username.downcase)).to eq user
- expect(User.by_login(username)).to eq user
- expect(User.by_login(nil)).to be_nil
- expect(User.by_login('')).to be_nil
+ expect(described_class.by_login(user.email.upcase)).to eq user
+ expect(described_class.by_login(user.email)).to eq user
+ expect(described_class.by_login(username.downcase)).to eq user
+ expect(described_class.by_login(username)).to eq user
+ expect(described_class.by_login(nil)).to be_nil
+ expect(described_class.by_login('')).to be_nil
end
end
@@ -827,8 +952,8 @@ describe User, models: true do
describe '.find_by_username!' do
it 'raises RecordNotFound' do
- expect { described_class.find_by_username!('JohnDoe') }.
- to raise_error(ActiveRecord::RecordNotFound)
+ expect { described_class.find_by_username!('JohnDoe') }
+ .to raise_error(ActiveRecord::RecordNotFound)
end
it 'is case-insensitive' do
@@ -837,6 +962,75 @@ describe User, models: true do
end
end
+ describe '.find_by_full_path' do
+ let!(:user) { create(:user) }
+
+ context 'with a route matching the given path' do
+ let!(:route) { user.namespace.route }
+
+ it 'returns the user' do
+ expect(described_class.find_by_full_path(route.path)).to eq(user)
+ end
+
+ it 'is case-insensitive' do
+ expect(described_class.find_by_full_path(route.path.upcase)).to eq(user)
+ expect(described_class.find_by_full_path(route.path.downcase)).to eq(user)
+ end
+ end
+
+ context 'with a redirect route matching the given path' do
+ let!(:redirect_route) { user.namespace.redirect_routes.create(path: 'foo') }
+
+ context 'without the follow_redirects option' do
+ it 'returns nil' do
+ expect(described_class.find_by_full_path(redirect_route.path)).to eq(nil)
+ end
+ end
+
+ context 'with the follow_redirects option set to true' do
+ it 'returns the user' do
+ expect(described_class.find_by_full_path(redirect_route.path, follow_redirects: true)).to eq(user)
+ end
+
+ it 'is case-insensitive' do
+ expect(described_class.find_by_full_path(redirect_route.path.upcase, follow_redirects: true)).to eq(user)
+ expect(described_class.find_by_full_path(redirect_route.path.downcase, follow_redirects: true)).to eq(user)
+ end
+ end
+ end
+
+ context 'without a route or a redirect route matching the given path' do
+ context 'without the follow_redirects option' do
+ it 'returns nil' do
+ expect(described_class.find_by_full_path('unknown')).to eq(nil)
+ end
+ end
+ context 'with the follow_redirects option set to true' do
+ it 'returns nil' do
+ expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil)
+ end
+ end
+ end
+
+ context 'with a group route matching the given path' do
+ context 'when the group namespace has an owner_id (legacy data)' do
+ let!(:group) { create(:group, path: 'group_path', owner: user) }
+
+ it 'returns nil' do
+ expect(described_class.find_by_full_path('group_path')).to eq(nil)
+ end
+ end
+
+ context 'when the group namespace does not have an owner_id' do
+ let!(:group) { create(:group, path: 'group_path') }
+
+ it 'returns nil' do
+ expect(described_class.find_by_full_path('group_path')).to eq(nil)
+ end
+ end
+ end
+ end
+
describe 'all_ssh_keys' do
it { is_expected.to have_many(:keys).dependent(:destroy) }
@@ -862,8 +1056,26 @@ describe User, models: true do
end
end
+ describe '#avatar_url' do
+ let(:user) { create(:user, :with_avatar) }
+
+ context 'when avatar file is uploaded' do
+ let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
+ let(:avatar_path) { "/uploads/-/system/user/avatar/#{user.id}/dk.png" }
+
+ it 'shows correct avatar url' do
+ expect(user.avatar_url).to eq(avatar_path)
+ expect(user.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join)
+
+ allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
+
+ expect(user.avatar_url).to eq([gitlab_host, avatar_path].join)
+ end
+ end
+ end
+
describe '#requires_ldap_check?' do
- let(:user) { User.new }
+ let(:user) { described_class.new }
it 'is false when LDAP is disabled' do
# Create a condition which would otherwise cause 'true' to be returned
@@ -980,11 +1192,23 @@ describe User, models: true do
end
end
+ describe '#sanitize_attrs' do
+ let(:user) { build(:user, name: 'test & user', skype: 'test&user') }
+
+ it 'encodes HTML entities in the Skype attribute' do
+ expect { user.sanitize_attrs }.to change { user.skype }.to('test&amp;user')
+ end
+
+ it 'does not encode HTML entities in the name attribute' do
+ expect { user.sanitize_attrs }.not_to change { user.name }
+ end
+ end
+
describe '#starred?' do
it 'determines if user starred a project' do
user = create :user
- project1 = create(:empty_project, :public)
- project2 = create(:empty_project, :public)
+ project1 = create(:project, :public)
+ project2 = create(:project, :public)
expect(user.starred?(project1)).to be_falsey
expect(user.starred?(project2)).to be_falsey
@@ -1010,7 +1234,7 @@ describe User, models: true do
describe '#toggle_star' do
it 'toggles stars' do
user = create :user
- project = create(:empty_project, :public)
+ project = create(:project, :public)
expect(user.starred?(project)).to be_falsey
user.toggle_star(project)
@@ -1022,7 +1246,7 @@ describe User, models: true do
describe '#sort' do
before do
- User.delete_all
+ described_class.delete_all
@user = create :user, created_at: Date.today, last_sign_in_at: Date.today, name: 'Alpha'
@user1 = create :user, created_at: Date.today - 1, last_sign_in_at: Date.today - 1, name: 'Omega'
@user2 = create :user, created_at: Date.today - 2, last_sign_in_at: nil, name: 'Beta'
@@ -1030,44 +1254,44 @@ describe User, models: true do
context 'when sort by recent_sign_in' do
it 'sorts users by the recent sign-in time' do
- expect(User.sort('recent_sign_in').first).to eq(@user)
+ expect(described_class.sort('recent_sign_in').first).to eq(@user)
end
it 'pushes users who never signed in to the end' do
- expect(User.sort('recent_sign_in').third).to eq(@user2)
+ expect(described_class.sort('recent_sign_in').third).to eq(@user2)
end
end
context 'when sort by oldest_sign_in' do
it 'sorts users by the oldest sign-in time' do
- expect(User.sort('oldest_sign_in').first).to eq(@user1)
+ expect(described_class.sort('oldest_sign_in').first).to eq(@user1)
end
it 'pushes users who never signed in to the end' do
- expect(User.sort('oldest_sign_in').third).to eq(@user2)
+ expect(described_class.sort('oldest_sign_in').third).to eq(@user2)
end
end
it 'sorts users in descending order by their creation time' do
- expect(User.sort('created_desc').first).to eq(@user)
+ expect(described_class.sort('created_desc').first).to eq(@user)
end
it 'sorts users in ascending order by their creation time' do
- expect(User.sort('created_asc').first).to eq(@user2)
+ expect(described_class.sort('created_asc').first).to eq(@user2)
end
it 'sorts users by id in descending order when nil is passed' do
- expect(User.sort(nil).first).to eq(@user2)
+ expect(described_class.sort(nil).first).to eq(@user2)
end
end
describe "#contributed_projects" do
subject { create(:user) }
- let!(:project1) { create(:empty_project) }
- let!(:project2) { create(:empty_project, forked_from_project: project3) }
- let!(:project3) { create(:empty_project) }
+ let!(:project1) { create(:project) }
+ let!(:project2) { create(:project, forked_from_project: project3) }
+ let!(:project3) { create(:project) }
let!(:merge_request) { create(:merge_request, source_project: project2, target_project: project3, author: subject) }
- let!(:push_event) { create(:event, :pushed, project: project1, target: project1, author: subject) }
+ let!(:push_event) { create(:push_event, project: project1, author: subject) }
let!(:merge_event) { create(:event, :created, project: project3, target: merge_request, author: subject) }
before do
@@ -1109,10 +1333,18 @@ describe User, models: true do
subject { create(:user) }
let!(:project1) { create(:project, :repository) }
let!(:project2) { create(:project, :repository, forked_from_project: project1) }
- let!(:push_data) do
- Gitlab::DataBuilder::Push.build_sample(project2, subject)
+
+ let!(:push_event) do
+ event = create(:push_event, project: project2, author: subject)
+
+ create(:push_event_payload,
+ event: event,
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 0,
+ ref: 'master')
+
+ event
end
- let!(:push_event) { create(:event, :pushed, project: project2, target: project1, author: subject, data: push_data) }
before do
project1.team << [subject, :master]
@@ -1139,8 +1371,13 @@ describe User, models: true do
expect(subject.recent_push(project1)).to eq(nil)
expect(subject.recent_push(project2)).to eq(push_event)
- push_data1 = Gitlab::DataBuilder::Push.build_sample(project1, subject)
- push_event1 = create(:event, :pushed, project: project1, target: project1, author: subject, data: push_data1)
+ push_event1 = create(:push_event, project: project1, author: subject)
+
+ create(:push_event_payload,
+ event: push_event1,
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 0,
+ ref: 'master')
expect(subject.recent_push([project1, project2])).to eq(push_event1) # Newest
end
@@ -1163,7 +1400,7 @@ describe User, models: true do
context 'with a minimum access level' do
it 'includes projects for which the user is an owner' do
user = create(:user)
- project = create(:empty_project, :private, namespace: user.namespace)
+ project = create(:project, :private, namespace: user.namespace)
expect(user.authorized_projects(Gitlab::Access::REPORTER))
.to contain_exactly(project)
@@ -1171,7 +1408,7 @@ describe User, models: true do
it 'includes projects for which the user is a master' do
user = create(:user)
- project = create(:empty_project, :private)
+ project = create(:project, :private)
project.team << [user, Gitlab::Access::MASTER]
@@ -1182,7 +1419,7 @@ describe User, models: true do
it "includes user's personal projects" do
user = create(:user)
- project = create(:empty_project, :private, namespace: user.namespace)
+ project = create(:project, :private, namespace: user.namespace)
expect(user.authorized_projects).to include(project)
end
@@ -1190,7 +1427,7 @@ describe User, models: true do
it "includes personal projects user has been given access to" do
user1 = create(:user)
user2 = create(:user)
- project = create(:empty_project, :private, namespace: user1.namespace)
+ project = create(:project, :private, namespace: user1.namespace)
project.team << [user2, Gitlab::Access::DEVELOPER]
@@ -1199,7 +1436,7 @@ describe User, models: true do
it "includes projects of groups user has been added to" do
group = create(:group)
- project = create(:empty_project, group: group)
+ project = create(:project, group: group)
user = create(:user)
group.add_developer(user)
@@ -1209,7 +1446,7 @@ describe User, models: true do
it "does not include projects of groups user has been removed from" do
group = create(:group)
- project = create(:empty_project, group: group)
+ project = create(:project, group: group)
user = create(:user)
member = group.add_developer(user)
@@ -1221,7 +1458,7 @@ describe User, models: true do
it "includes projects shared with user's group" do
user = create(:user)
- project = create(:empty_project, :private)
+ project = create(:project, :private)
group = create(:group)
group.add_reporter(user)
@@ -1233,7 +1470,7 @@ describe User, models: true do
it "does not include destroyed projects user had access to" do
user1 = create(:user)
user2 = create(:user)
- project = create(:empty_project, :private, namespace: user1.namespace)
+ project = create(:project, :private, namespace: user1.namespace)
project.team << [user2, Gitlab::Access::DEVELOPER]
expect(user2.authorized_projects).to include(project)
@@ -1244,7 +1481,7 @@ describe User, models: true do
it "does not include projects of destroyed groups user had access to" do
group = create(:group)
- project = create(:empty_project, namespace: group)
+ project = create(:project, namespace: group)
user = create(:user)
group.add_developer(user)
@@ -1259,9 +1496,9 @@ describe User, models: true do
let(:user) { create(:user) }
it 'includes projects for which the user access level is above or equal to reporter' do
- reporter_project = create(:empty_project) { |p| p.add_reporter(user) }
- developer_project = create(:empty_project) { |p| p.add_developer(user) }
- master_project = create(:empty_project) { |p| p.add_master(user) }
+ reporter_project = create(:project) { |p| p.add_reporter(user) }
+ developer_project = create(:project) { |p| p.add_developer(user) }
+ master_project = create(:project) { |p| p.add_master(user) }
expect(user.projects_where_can_admin_issues.to_a).to eq([master_project, developer_project, reporter_project])
expect(user.can?(:admin_issue, master_project)).to eq(true)
@@ -1270,8 +1507,8 @@ describe User, models: true do
end
it 'does not include for which the user access level is below reporter' do
- project = create(:empty_project)
- guest_project = create(:empty_project) { |p| p.add_guest(user) }
+ project = create(:project)
+ guest_project = create(:project) { |p| p.add_guest(user) }
expect(user.projects_where_can_admin_issues.to_a).to be_empty
expect(user.can?(:admin_issue, guest_project)).to eq(false)
@@ -1279,14 +1516,14 @@ describe User, models: true do
end
it 'does not include archived projects' do
- project = create(:empty_project, :archived)
+ project = create(:project, :archived)
expect(user.projects_where_can_admin_issues.to_a).to be_empty
expect(user.can?(:admin_issue, project)).to eq(false)
end
it 'does not include projects for which issues are disabled' do
- project = create(:empty_project, :issues_disabled)
+ project = create(:project, :issues_disabled)
expect(user.projects_where_can_admin_issues.to_a).to be_empty
expect(user.can?(:admin_issue, project)).to eq(false)
@@ -1302,7 +1539,7 @@ describe User, models: true do
end
context 'without any projects' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'does not load' do
expect(user.ci_authorized_runners).to be_empty
@@ -1311,7 +1548,7 @@ describe User, models: true do
context 'with personal projects runners' do
let(:namespace) { create(:namespace, owner: user) }
- let(:project) { create(:empty_project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
it 'loads' do
expect(user.ci_authorized_runners).to contain_exactly(runner)
@@ -1342,7 +1579,7 @@ describe User, models: true do
context 'with groups projects runners' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, group: group) }
+ let(:project) { create(:project, group: group) }
def add_user(access)
group.add_user(user, access)
@@ -1352,7 +1589,7 @@ describe User, models: true do
end
context 'with other projects runners' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
def add_user(access)
project.team << [user, access]
@@ -1362,28 +1599,9 @@ describe User, models: true do
end
end
- describe '#viewable_starred_projects' do
- let(:user) { create(:user) }
- let(:public_project) { create(:empty_project, :public) }
- let(:private_project) { create(:empty_project, :private) }
- let(:private_viewable_project) { create(:empty_project, :private) }
-
- before do
- private_viewable_project.team << [user, Gitlab::Access::MASTER]
-
- [public_project, private_project, private_viewable_project].each do |project|
- user.toggle_star(project)
- end
- end
-
- it 'returns only starred projects the user can view' do
- expect(user.viewable_starred_projects).not_to include(private_project)
- end
- end
-
describe '#projects_with_reporter_access_limited_to' do
- let(:project1) { create(:empty_project) }
- let(:project2) { create(:empty_project) }
+ let(:project1) { create(:project) }
+ let(:project2) { create(:project) }
let(:user) { create(:user) }
before do
@@ -1404,8 +1622,8 @@ describe User, models: true do
end
it 'returns the projects when using an ActiveRecord relation' do
- projects = user.
- projects_with_reporter_access_limited_to(Project.select(:id))
+ projects = user
+ .projects_with_reporter_access_limited_to(Project.select(:id))
expect(projects).to eq([project1])
end
@@ -1417,53 +1635,116 @@ describe User, models: true do
end
end
- describe '#nested_groups' do
+ describe '#all_expanded_groups' do
+ # foo/bar would also match foo/barbaz instead of just foo/bar and foo/bar/baz
let!(:user) { create(:user) }
- let!(:group) { create(:group) }
- let!(:nested_group) { create(:group, parent: group) }
- before do
- group.add_owner(user)
+ # group
+ # _______ (foo) _______
+ # | |
+ # | |
+ # nested_group_1 nested_group_2
+ # (bar) (barbaz)
+ # | |
+ # | |
+ # nested_group_1_1 nested_group_2_1
+ # (baz) (baz)
+ #
+ let!(:group) { create :group }
+ let!(:nested_group_1) { create :group, parent: group, name: 'bar' }
+ let!(:nested_group_1_1) { create :group, parent: nested_group_1, name: 'baz' }
+ let!(:nested_group_2) { create :group, parent: group, name: 'barbaz' }
+ let!(:nested_group_2_1) { create :group, parent: nested_group_2, name: 'baz' }
- # Add more data to ensure method does not include wrong groups
- create(:group).add_owner(create(:user))
+ subject { user.all_expanded_groups }
+
+ context 'user is not a member of any group' do
+ it 'returns an empty array' do
+ is_expected.to eq([])
+ end
end
- it { expect(user.nested_groups).to eq([nested_group]) }
- end
+ context 'user is member of all groups' do
+ before do
+ group.add_owner(user)
+ nested_group_1.add_owner(user)
+ nested_group_1_1.add_owner(user)
+ nested_group_2.add_owner(user)
+ nested_group_2_1.add_owner(user)
+ end
- describe '#all_expanded_groups' do
- let!(:user) { create(:user) }
- let!(:group) { create(:group) }
- let!(:nested_group_1) { create(:group, parent: group) }
- let!(:nested_group_2) { create(:group, parent: group) }
+ it 'returns all groups' do
+ is_expected.to match_array [
+ group,
+ nested_group_1, nested_group_1_1,
+ nested_group_2, nested_group_2_1
+ ]
+ end
+ end
- before { nested_group_1.add_owner(user) }
+ context 'user is member of the top group' do
+ before do
+ group.add_owner(user)
+ end
- it { expect(user.all_expanded_groups).to match_array [group, nested_group_1] }
- end
+ if Group.supports_nested_groups?
+ it 'returns all groups' do
+ is_expected.to match_array [
+ group,
+ nested_group_1, nested_group_1_1,
+ nested_group_2, nested_group_2_1
+ ]
+ end
+ else
+ it 'returns the top-level groups' do
+ is_expected.to match_array [group]
+ end
+ end
+ end
- describe '#nested_groups_projects' do
- let!(:user) { create(:user) }
- let!(:group) { create(:group) }
- let!(:nested_group) { create(:group, parent: group) }
- let!(:project) { create(:empty_project, namespace: group) }
- let!(:nested_project) { create(:empty_project, namespace: nested_group) }
+ context 'user is member of the first child (internal node), branch 1', :nested_groups do
+ before do
+ nested_group_1.add_owner(user)
+ end
- before do
- group.add_owner(user)
+ it 'returns the groups in the hierarchy' do
+ is_expected.to match_array [
+ group,
+ nested_group_1, nested_group_1_1
+ ]
+ end
+ end
- # Add more data to ensure method does not include wrong projects
- other_project = create(:empty_project, namespace: create(:group, :nested))
- other_project.add_developer(create(:user))
+ context 'user is member of the first child (internal node), branch 2', :nested_groups do
+ before do
+ nested_group_2.add_owner(user)
+ end
+
+ it 'returns the groups in the hierarchy' do
+ is_expected.to match_array [
+ group,
+ nested_group_2, nested_group_2_1
+ ]
+ end
end
- it { expect(user.nested_groups_projects).to eq([nested_project]) }
+ context 'user is member of the last child (leaf node)', :nested_groups do
+ before do
+ nested_group_1_1.add_owner(user)
+ end
+
+ it 'returns the groups in the hierarchy' do
+ is_expected.to match_array [
+ group,
+ nested_group_1, nested_group_1_1
+ ]
+ end
+ end
end
- describe '#refresh_authorized_projects', redis: true do
- let(:project1) { create(:empty_project) }
- let(:project2) { create(:empty_project) }
+ describe '#refresh_authorized_projects', clean_gitlab_redis_shared_state: true do
+ let(:project1) { create(:project) }
+ let(:project2) { create(:project) }
let(:user) { create(:user) }
before do
@@ -1478,10 +1759,6 @@ describe User, models: true do
expect(user.project_authorizations.count).to eq(2)
end
- it 'sets the authorized_projects_populated column' do
- expect(user.authorized_projects_populated).to eq(true)
- end
-
it 'stores the correct access levels' do
expect(user.project_authorizations.where(access_level: Gitlab::Access::GUEST).exists?).to eq(true)
expect(user.project_authorizations.where(access_level: Gitlab::Access::REPORTER).exists?).to eq(true)
@@ -1521,9 +1798,23 @@ describe User, models: true do
end
end
+ describe '#full_private_access?' do
+ it 'returns false for regular user' do
+ user = build(:user)
+
+ expect(user.full_private_access?).to be_falsy
+ end
+
+ it 'returns true for admin user' do
+ user = build(:user, :admin)
+
+ expect(user.full_private_access?).to be_truthy
+ end
+ end
+
describe '.ghost' do
it "creates a ghost user if one isn't already present" do
- ghost = User.ghost
+ ghost = described_class.ghost
expect(ghost).to be_ghost
expect(ghost).to be_persisted
@@ -1531,16 +1822,16 @@ describe User, models: true do
it "does not create a second ghost user if one is already present" do
expect do
- User.ghost
- User.ghost
- end.to change { User.count }.by(1)
- expect(User.ghost).to eq(User.ghost)
+ described_class.ghost
+ described_class.ghost
+ end.to change { described_class.count }.by(1)
+ expect(described_class.ghost).to eq(described_class.ghost)
end
context "when a regular user exists with the username 'ghost'" do
it "creates a ghost user with a non-conflicting username" do
create(:user, username: 'ghost')
- ghost = User.ghost
+ ghost = described_class.ghost
expect(ghost).to be_persisted
expect(ghost.username).to eq('ghost1')
@@ -1550,7 +1841,7 @@ describe User, models: true do
context "when a regular user exists with the email 'ghost@example.com'" do
it "creates a ghost user with a non-conflicting email" do
create(:user, email: 'ghost@example.com')
- ghost = User.ghost
+ ghost = described_class.ghost
expect(ghost).to be_persisted
expect(ghost.email).to eq('ghost1@example.com')
@@ -1563,7 +1854,7 @@ describe User, models: true do
end
it 'creates a ghost user' do
- expect(User.ghost).to be_persisted
+ expect(described_class.ghost).to be_persisted
end
end
end
@@ -1591,7 +1882,7 @@ describe User, models: true do
end
end
- context 'with 2FA requirement on nested parent group' do
+ context 'with 2FA requirement on nested parent group', :nested_groups do
let!(:group1) { create :group, require_two_factor_authentication: true }
let!(:group1a) { create :group, require_two_factor_authentication: false, parent: group1 }
@@ -1606,7 +1897,7 @@ describe User, models: true do
end
end
- context 'with 2FA requirement on nested child group' do
+ context 'with 2FA requirement on nested child group', :nested_groups do
let!(:group1) { create :group, require_two_factor_authentication: false }
let!(:group1a) { create :group, require_two_factor_authentication: true, parent: group1 }
@@ -1642,13 +1933,95 @@ describe User, models: true do
context '.active' do
before do
- User.ghost
+ described_class.ghost
create(:user, name: 'user', state: 'active')
create(:user, name: 'user', state: 'blocked')
end
it 'only counts active and non internal users' do
- expect(User.active.count).to eq(1)
+ expect(described_class.active.count).to eq(1)
+ end
+ end
+
+ describe 'preferred language' do
+ it 'is English by default' do
+ user = create(:user)
+
+ expect(user.preferred_language).to eq('en')
+ end
+ end
+
+ context '#invalidate_issue_cache_counts' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'invalidates cache for issue counter' do
+ cache_mock = double
+
+ expect(cache_mock).to receive(:delete).with(['users', user.id, 'assigned_open_issues_count'])
+
+ allow(Rails).to receive(:cache).and_return(cache_mock)
+
+ user.invalidate_issue_cache_counts
+ end
+ end
+
+ context '#invalidate_merge_request_cache_counts' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'invalidates cache for Merge Request counter' do
+ cache_mock = double
+
+ expect(cache_mock).to receive(:delete).with(['users', user.id, 'assigned_open_merge_requests_count'])
+
+ allow(Rails).to receive(:cache).and_return(cache_mock)
+
+ user.invalidate_merge_request_cache_counts
+ end
+ end
+
+ describe '#allow_password_authentication?' do
+ context 'regular user' do
+ let(:user) { build(:user) }
+
+ it 'returns true when sign-in is enabled' do
+ expect(user.allow_password_authentication?).to be_truthy
+ end
+
+ it 'returns false when sign-in is disabled' do
+ stub_application_setting(password_authentication_enabled: false)
+
+ expect(user.allow_password_authentication?).to be_falsey
+ end
+ end
+
+ it 'returns false for ldap user' do
+ user = create(:omniauth_user, provider: 'ldapmain')
+
+ expect(user.allow_password_authentication?).to be_falsey
+ end
+ end
+
+ describe '#personal_projects_count' do
+ it 'returns the number of personal projects using a single query' do
+ user = build(:user)
+ projects = double(:projects, count: 1)
+
+ expect(user).to receive(:personal_projects).once.and_return(projects)
+
+ 2.times do
+ expect(user.personal_projects_count).to eq(1)
+ end
+ end
+ end
+
+ describe '#projects_limit_left' do
+ it 'returns the number of projects that can be created by the user' do
+ user = build(:user)
+
+ allow(user).to receive(:projects_limit).and_return(10)
+ allow(user).to receive(:personal_projects_count).and_return(5)
+
+ expect(user.projects_limit_left).to eq(5)
end
end
end
diff --git a/spec/models/wiki_directory_spec.rb b/spec/models/wiki_directory_spec.rb
index 1caaa557085..fb8575cfe2b 100644
--- a/spec/models/wiki_directory_spec.rb
+++ b/spec/models/wiki_directory_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-RSpec.describe WikiDirectory, models: true do
+RSpec.describe WikiDirectory do
describe 'validations' do
subject { build(:wiki_directory) }
@@ -10,7 +10,7 @@ RSpec.describe WikiDirectory, models: true do
describe '#initialize' do
context 'when there are pages' do
let(:pages) { [build(:wiki_page)] }
- let(:directory) { WikiDirectory.new('/path_up_to/dir', pages) }
+ let(:directory) { described_class.new('/path_up_to/dir', pages) }
it 'sets the slug attribute' do
expect(directory.slug).to eq('/path_up_to/dir')
@@ -22,7 +22,7 @@ RSpec.describe WikiDirectory, models: true do
end
context 'when there are no pages' do
- let(:directory) { WikiDirectory.new('/path_up_to/dir') }
+ let(:directory) { described_class.new('/path_up_to/dir') }
it 'sets the slug attribute' do
expect(directory.slug).to eq('/path_up_to/dir')
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 753dc938c52..40a222be24d 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -1,17 +1,17 @@
require "spec_helper"
-describe WikiPage, models: true do
- let(:project) { create(:empty_project) }
+describe WikiPage do
+ let(:project) { create(:project) }
let(:user) { project.owner }
let(:wiki) { ProjectWiki.new(project, user) }
- subject { WikiPage.new(wiki) }
+ subject { described_class.new(wiki) }
describe '.group_by_directory' do
context 'when there are no pages' do
it 'returns an empty array' do
- expect(WikiPage.group_by_directory(nil)).to eq([])
- expect(WikiPage.group_by_directory([])).to eq([])
+ expect(described_class.group_by_directory(nil)).to eq([])
+ expect(described_class.group_by_directory([])).to eq([])
end
end
@@ -39,7 +39,7 @@ describe WikiPage, models: true do
it 'returns an array with pages and directories' do
expected_grouped_entries = [page_1, dir_1, dir_1_1, dir_2]
- grouped_entries = WikiPage.group_by_directory(wiki.pages)
+ grouped_entries = described_class.group_by_directory(wiki.pages)
grouped_entries.each_with_index do |page_or_dir, i|
expected_page_or_dir = expected_grouped_entries[i]
@@ -56,13 +56,13 @@ describe WikiPage, models: true do
expected_order = ['page_1', 'dir_1/page_2', 'dir_1/dir_1_1/page_3',
'dir_2/page_4', 'dir_2/page_5']
- grouped_entries = WikiPage.group_by_directory(wiki.pages)
+ grouped_entries = described_class.group_by_directory(wiki.pages)
actual_order =
grouped_entries.map do |page_or_dir|
get_slugs(page_or_dir)
- end.
- flatten
+ end
+ .flatten
expect(actual_order).to eq(expected_order)
end
end
@@ -72,7 +72,7 @@ describe WikiPage, models: true do
it 'removes hyphens from a name' do
name = 'a-name--with-hyphens'
- expect(WikiPage.unhyphenize(name)).to eq('a name with hyphens')
+ expect(described_class.unhyphenize(name)).to eq('a name with hyphens')
end
end
@@ -81,7 +81,7 @@ describe WikiPage, models: true do
before do
create_page("test page", "test content")
@page = wiki.wiki.paged("test page")
- @wiki_page = WikiPage.new(wiki, @page, true)
+ @wiki_page = described_class.new(wiki, @page, true)
end
it "sets the slug attribute" do
@@ -178,12 +178,12 @@ describe WikiPage, models: true do
end
it "updates the content of the page" do
- @page.update("new content")
+ @page.update(content: "new content")
@page = wiki.find_page(title)
end
it "returns true" do
- expect(@page.update("more content")).to be_truthy
+ expect(@page.update(content: "more content")).to be_truthy
end
end
end
@@ -195,17 +195,42 @@ describe WikiPage, models: true do
end
after do
- destroy_page("Update")
+ destroy_page(@page.title)
end
context "with valid attributes" do
it "updates the content of the page" do
- @page.update("new content")
+ new_content = "new content"
+
+ @page.update(content: new_content)
@page = wiki.find_page("Update")
+
+ expect(@page.content).to eq("new content")
+ end
+
+ it "updates the title of the page" do
+ new_title = "Index v.1.2.4"
+
+ @page.update(title: new_title)
+ @page = wiki.find_page(new_title)
+
+ expect(@page.title).to eq(new_title)
end
it "returns true" do
- expect(@page.update("more content")).to be_truthy
+ expect(@page.update(content: "more content")).to be_truthy
+ end
+ end
+
+ context 'with same last commit sha' do
+ it 'returns true' do
+ expect(@page.update(content: 'more content', last_commit_sha: @page.last_commit_sha)).to be_truthy
+ end
+ end
+
+ context 'with different last commit sha' do
+ it 'raises exception' do
+ expect { @page.update(content: 'more content', last_commit_sha: 'xxx') }.to raise_error(WikiPage::PageChangedError)
end
end
end
@@ -237,7 +262,7 @@ describe WikiPage, models: true do
end
it "returns an array of all commits for the page" do
- 3.times { |i| @page.update("content #{i}") }
+ 3.times { |i| @page.update(content: "content #{i}") }
expect(@page.versions.count).to eq(4)
end
end
@@ -282,7 +307,7 @@ describe WikiPage, models: true do
before do
create_page('Update', 'content')
@page = wiki.find_page('Update')
- 3.times { |i| @page.update("content #{i}") }
+ 3.times { |i| @page.update(content: "content #{i}") }
end
after do
@@ -326,11 +351,35 @@ describe WikiPage, models: true do
end
it 'returns false for updated wiki page' do
- updated_wiki_page = original_wiki_page.update("Updated content")
+ updated_wiki_page = original_wiki_page.update(content: "Updated content")
expect(original_wiki_page).not_to eq(updated_wiki_page)
end
end
+ describe '#last_commit_sha' do
+ before do
+ create_page("Update", "content")
+ @page = wiki.find_page("Update")
+ end
+
+ after do
+ destroy_page("Update")
+ end
+
+ it 'returns commit sha' do
+ expect(@page.last_commit_sha).to eq @page.commit.sha
+ end
+
+ it 'is changed after page updated' do
+ last_commit_sha_before_update = @page.last_commit_sha
+
+ @page.update(content: "new content")
+ @page = wiki.find_page("Update")
+
+ expect(@page.last_commit_sha).not_to eq last_commit_sha_before_update
+ end
+ end
+
private
def remove_temp_repo(path)
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index 02acdcb36df..c03d95b34db 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-describe BasePolicy, models: true do
+describe BasePolicy do
describe '.class_for' do
it 'detects policy class based on the subject ancestors' do
- expect(described_class.class_for(GenericCommitStatus.new)).to eq(CommitStatusPolicy)
+ expect(DeclarativePolicy.class_for(GenericCommitStatus.new)).to eq(CommitStatusPolicy)
end
it 'detects policy class for a presented subject' do
presentee = Ci::BuildPresenter.new(Ci::Build.new)
- expect(described_class.class_for(presentee)).to eq(Ci::BuildPolicy)
+ expect(DeclarativePolicy.class_for(presentee)).to eq(Ci::BuildPolicy)
end
it 'uses GlobalPolicy when :global is given' do
- expect(described_class.class_for(:global)).to eq(GlobalPolicy)
+ expect(DeclarativePolicy.class_for(:global)).to eq(GlobalPolicy)
end
end
end
diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb
index 0f280f32eac..8e1bc3d1543 100644
--- a/spec/policies/ci/build_policy_spec.rb
+++ b/spec/policies/ci/build_policy_spec.rb
@@ -1,25 +1,27 @@
require 'spec_helper'
-describe Ci::BuildPolicy, :models do
+describe Ci::BuildPolicy do
let(:user) { create(:user) }
let(:build) { create(:ci_build, pipeline: pipeline) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
- let(:policies) do
- described_class.abilities(user, build).to_set
+ let(:policy) do
+ described_class.new(user, build)
end
shared_context 'public pipelines disabled' do
- before { project.update_attribute(:public_builds, false) }
+ before do
+ project.update_attribute(:public_builds, false)
+ end
end
describe '#rules' do
context 'when user does not have access to the project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
context 'when public builds are enabled' do
it 'does not include ability to read build' do
- expect(policies).not_to include :read_build
+ expect(policy).not_to be_allowed :read_build
end
end
@@ -27,17 +29,17 @@ describe Ci::BuildPolicy, :models do
include_context 'public pipelines disabled'
it 'does not include ability to read build' do
- expect(policies).not_to include :read_build
+ expect(policy).not_to be_allowed :read_build
end
end
end
context 'when anonymous user has access to the project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'when public builds are enabled' do
it 'includes ability to read build' do
- expect(policies).to include :read_build
+ expect(policy).to be_allowed :read_build
end
end
@@ -45,20 +47,22 @@ describe Ci::BuildPolicy, :models do
include_context 'public pipelines disabled'
it 'does not include ability to read build' do
- expect(policies).not_to include :read_build
+ expect(policy).not_to be_allowed :read_build
end
end
end
context 'when team member has access to the project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'team member is a guest' do
- before { project.team << [user, :guest] }
+ before do
+ project.team << [user, :guest]
+ end
context 'when public builds are enabled' do
it 'includes ability to read build' do
- expect(policies).to include :read_build
+ expect(policy).to be_allowed :read_build
end
end
@@ -66,17 +70,19 @@ describe Ci::BuildPolicy, :models do
include_context 'public pipelines disabled'
it 'does not include ability to read build' do
- expect(policies).not_to include :read_build
+ expect(policy).not_to be_allowed :read_build
end
end
end
context 'team member is a reporter' do
- before { project.team << [user, :reporter] }
+ before do
+ project.team << [user, :reporter]
+ end
context 'when public builds are enabled' do
it 'includes ability to read build' do
- expect(policies).to include :read_build
+ expect(policy).to be_allowed :read_build
end
end
@@ -84,10 +90,65 @@ describe Ci::BuildPolicy, :models do
include_context 'public pipelines disabled'
it 'does not include ability to read build' do
- expect(policies).to include :read_build
+ expect(policy).to be_allowed :read_build
end
end
end
end
+
+ describe 'rules for protected ref' do
+ let(:project) { create(:project, :repository) }
+ let(:build) { create(:ci_build, ref: 'some-ref', pipeline: pipeline) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when no one can push or merge to the branch' do
+ before do
+ create(:protected_branch, :no_one_can_push,
+ name: build.ref, project: project)
+ end
+
+ it 'does not include ability to update build' do
+ expect(policy).to be_disallowed :update_build
+ end
+ end
+
+ context 'when developers can push to the branch' do
+ before do
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+ end
+
+ it 'includes ability to update build' do
+ expect(policy).to be_allowed :update_build
+ end
+ end
+
+ context 'when no one can create the tag' do
+ before do
+ create(:protected_tag, :no_one_can_create,
+ name: build.ref, project: project)
+
+ build.update(tag: true)
+ end
+
+ it 'does not include ability to update build' do
+ expect(policy).to be_disallowed :update_build
+ end
+ end
+
+ context 'when no one can create the tag but it is not a tag' do
+ before do
+ create(:protected_tag, :no_one_can_create,
+ name: build.ref, project: project)
+ end
+
+ it 'includes ability to update build' do
+ expect(policy).to be_allowed :update_build
+ end
+ end
+ end
end
end
diff --git a/spec/policies/ci/pipeline_policy_spec.rb b/spec/policies/ci/pipeline_policy_spec.rb
new file mode 100644
index 00000000000..48a8064c5fc
--- /dev/null
+++ b/spec/policies/ci/pipeline_policy_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+describe Ci::PipelinePolicy, :models do
+ let(:user) { create(:user) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+
+ let(:policy) do
+ described_class.new(user, pipeline)
+ end
+
+ describe 'rules' do
+ describe 'rules for protected ref' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when no one can push or merge to the branch' do
+ before do
+ create(:protected_branch, :no_one_can_push,
+ name: pipeline.ref, project: project)
+ end
+
+ it 'does not include ability to update pipeline' do
+ expect(policy).to be_disallowed :update_pipeline
+ end
+ end
+
+ context 'when developers can push to the branch' do
+ before do
+ create(:protected_branch, :developers_can_merge,
+ name: pipeline.ref, project: project)
+ end
+
+ it 'includes ability to update pipeline' do
+ expect(policy).to be_allowed :update_pipeline
+ end
+ end
+
+ context 'when no one can create the tag' do
+ before do
+ create(:protected_tag, :no_one_can_create,
+ name: pipeline.ref, project: project)
+
+ pipeline.update(tag: true)
+ end
+
+ it 'does not include ability to update pipeline' do
+ expect(policy).to be_disallowed :update_pipeline
+ end
+ end
+
+ context 'when no one can create the tag but it is not a tag' do
+ before do
+ create(:protected_tag, :no_one_can_create,
+ name: pipeline.ref, project: project)
+ end
+
+ it 'includes ability to update pipeline' do
+ expect(policy).to be_allowed :update_pipeline
+ end
+ end
+ end
+ end
+end
diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb
index 63ad5eb7322..be40dbb2aa9 100644
--- a/spec/policies/ci/trigger_policy_spec.rb
+++ b/spec/policies/ci/trigger_policy_spec.rb
@@ -1,41 +1,41 @@
require 'spec_helper'
-describe Ci::TriggerPolicy, :models do
+describe Ci::TriggerPolicy do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:trigger) { create(:ci_trigger, project: project, owner: owner) }
let(:policies) do
- described_class.abilities(user, trigger).to_set
+ described_class.new(user, trigger)
end
shared_examples 'allows to admin and manage trigger' do
it 'does include ability to admin trigger' do
- expect(policies).to include :admin_trigger
+ expect(policies).to be_allowed :admin_trigger
end
it 'does include ability to manage trigger' do
- expect(policies).to include :manage_trigger
+ expect(policies).to be_allowed :manage_trigger
end
end
shared_examples 'allows to manage trigger' do
it 'does not include ability to admin trigger' do
- expect(policies).not_to include :admin_trigger
+ expect(policies).not_to be_allowed :admin_trigger
end
it 'does include ability to manage trigger' do
- expect(policies).to include :manage_trigger
+ expect(policies).to be_allowed :manage_trigger
end
end
shared_examples 'disallows to admin and manage trigger' do
it 'does not include ability to admin trigger' do
- expect(policies).not_to include :admin_trigger
+ expect(policies).not_to be_allowed :admin_trigger
end
it 'does not include ability to manage trigger' do
- expect(policies).not_to include :manage_trigger
+ expect(policies).not_to be_allowed :manage_trigger
end
end
diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb
new file mode 100644
index 00000000000..ca7b7fe7ef7
--- /dev/null
+++ b/spec/policies/deploy_key_policy_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe DeployKeyPolicy do
+ subject { described_class.new(current_user, deploy_key) }
+
+ describe 'updating a deploy_key' do
+ context 'when a regular user' do
+ let(:current_user) { create(:user) }
+
+ context 'tries to update private deploy key attached to project' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+ let(:project) { create(:project_empty_repo) }
+
+ before do
+ project.add_master(current_user)
+ project.deploy_keys << deploy_key
+ end
+
+ it { is_expected.to be_allowed(:update_deploy_key) }
+ end
+
+ context 'tries to update private deploy key attached to other project' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+ let(:other_project) { create(:project_empty_repo) }
+
+ before do
+ other_project.deploy_keys << deploy_key
+ end
+
+ it { is_expected.to be_disallowed(:update_deploy_key) }
+ end
+
+ context 'tries to update public deploy key' do
+ let(:deploy_key) { create(:another_deploy_key, public: true) }
+
+ it { is_expected.to be_disallowed(:update_deploy_key) }
+ end
+ end
+
+ context 'when an admin user' do
+ let(:current_user) { create(:user, :admin) }
+
+ context ' tries to update private deploy key' do
+ let(:deploy_key) { create(:deploy_key, public: false) }
+
+ it { is_expected.to be_allowed(:update_deploy_key) }
+ end
+
+ context 'when an admin user tries to update public deploy key' do
+ let(:deploy_key) { create(:another_deploy_key, public: true) }
+
+ it { is_expected.to be_allowed(:update_deploy_key) }
+ end
+ end
+ end
+end
diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb
new file mode 100644
index 00000000000..de4cb5b30c5
--- /dev/null
+++ b/spec/policies/environment_policy_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe EnvironmentPolicy do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ let(:environment) do
+ create(:environment, :with_review_app, project: project)
+ end
+
+ let(:policy) do
+ described_class.new(user, environment)
+ end
+
+ describe '#rules' do
+ context 'when user does not have access to the project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it 'does not include ability to stop environment' do
+ expect(policy).to be_disallowed :stop_environment
+ end
+ end
+
+ context 'when anonymous user has access to the project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it 'does not include ability to stop environment' do
+ expect(policy).to be_disallowed :stop_environment
+ end
+ end
+
+ context 'when team member has access to the project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when team member has ability to stop environment' do
+ it 'does includes ability to stop environment' do
+ expect(policy).to be_allowed :stop_environment
+ end
+ end
+
+ context 'when team member has no ability to stop environment' do
+ before do
+ create(:protected_branch, :no_one_can_push,
+ name: 'master', project: project)
+ end
+
+ it 'does not include ability to stop environment' do
+ expect(policy).to be_disallowed :stop_environment
+ end
+ end
+ end
+ end
+end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
new file mode 100644
index 00000000000..a6bf70c1e09
--- /dev/null
+++ b/spec/policies/global_policy_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe GlobalPolicy do
+ let(:current_user) { create(:user) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new(current_user, [user]) }
+
+ describe "reading the list of users" do
+ context "for a logged in user" do
+ it { is_expected.to be_allowed(:read_users_list) }
+ end
+
+ context "for an anonymous user" do
+ let(:current_user) { nil }
+
+ context "when the public level is restricted" do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it { is_expected.not_to be_allowed(:read_users_list) }
+ end
+
+ context "when the public level is not restricted" do
+ before do
+ stub_application_setting(restricted_visibility_levels: [])
+ end
+
+ it { is_expected.to be_allowed(:read_users_list) }
+ end
+ end
+
+ context "for an admin" do
+ let(:current_user) { create(:admin) }
+
+ context "when the public level is restricted" do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it { is_expected.to be_allowed(:read_users_list) }
+ end
+
+ context "when the public level is not restricted" do
+ before do
+ stub_application_setting(restricted_visibility_levels: [])
+ end
+
+ it { is_expected.to be_allowed(:read_users_list) }
+ end
+ end
+ end
+end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 2077c14ff7a..b17a93e3fbe 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe GroupPolicy, models: true do
+describe GroupPolicy do
let(:guest) { create(:user) }
let(:reporter) { create(:user) }
let(:developer) { create(:user) }
@@ -9,11 +9,12 @@ describe GroupPolicy, models: true do
let(:admin) { create(:admin) }
let(:group) { create(:group) }
+ let(:reporter_permissions) { [:admin_label] }
+
let(:master_permissions) do
[
:create_projects,
- :admin_milestones,
- :admin_label
+ :admin_milestones
]
end
@@ -35,15 +36,24 @@ describe GroupPolicy, models: true do
group.add_owner(owner)
end
- subject { described_class.abilities(current_user, group).to_set }
+ subject { described_class.new(current_user, group) }
+
+ def expect_allowed(*permissions)
+ permissions.each { |p| is_expected.to be_allowed(p) }
+ end
+
+ def expect_disallowed(*permissions)
+ permissions.each { |p| is_expected.not_to be_allowed(p) }
+ end
context 'with no user' do
let(:current_user) { nil }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -51,9 +61,10 @@ describe GroupPolicy, models: true do
let(:current_user) { guest }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -61,9 +72,10 @@ describe GroupPolicy, models: true do
let(:current_user) { reporter }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -71,9 +83,10 @@ describe GroupPolicy, models: true do
let(:current_user) { developer }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -81,9 +94,10 @@ describe GroupPolicy, models: true do
let(:current_user) { master }
it do
- is_expected.to include(:read_group)
- is_expected.to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -91,9 +105,10 @@ describe GroupPolicy, models: true do
let(:current_user) { owner }
it do
- is_expected.to include(:read_group)
- is_expected.to include(*master_permissions)
- is_expected.to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*master_permissions)
+ expect_allowed(*owner_permissions)
end
end
@@ -101,24 +116,38 @@ describe GroupPolicy, models: true do
let(:current_user) { admin }
it do
- is_expected.to include(:read_group)
- is_expected.to include(*master_permissions)
- is_expected.to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*master_permissions)
+ expect_allowed(*owner_permissions)
end
end
- describe 'private nested group inherit permissions' do
+ describe 'private nested group use the highest access level from the group and inherited permissions', :nested_groups do
let(:nested_group) { create(:group, :private, parent: group) }
- subject { described_class.abilities(current_user, nested_group).to_set }
+ before do
+ nested_group.add_guest(guest)
+ nested_group.add_guest(reporter)
+ nested_group.add_guest(developer)
+ nested_group.add_guest(master)
+
+ group.owners.destroy_all
+
+ group.add_guest(owner)
+ nested_group.add_owner(owner)
+ end
+
+ subject { described_class.new(current_user, nested_group) }
context 'with no user' do
let(:current_user) { nil }
it do
- is_expected.not_to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_disallowed(:read_group)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -126,9 +155,10 @@ describe GroupPolicy, models: true do
let(:current_user) { guest }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -136,9 +166,10 @@ describe GroupPolicy, models: true do
let(:current_user) { reporter }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -146,9 +177,10 @@ describe GroupPolicy, models: true do
let(:current_user) { developer }
it do
- is_expected.to include(:read_group)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -156,9 +188,10 @@ describe GroupPolicy, models: true do
let(:current_user) { master }
it do
- is_expected.to include(:read_group)
- is_expected.to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -166,9 +199,10 @@ describe GroupPolicy, models: true do
let(:current_user) { owner }
it do
- is_expected.to include(:read_group)
- is_expected.to include(*master_permissions)
- is_expected.to include(*owner_permissions)
+ expect_allowed(:read_group)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*master_permissions)
+ expect_allowed(*owner_permissions)
end
end
end
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index 2905d5b26a5..be4c24c727c 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -1,118 +1,192 @@
require 'spec_helper'
-describe IssuePolicy, models: true do
- let(:user) { create(:user) }
-
- describe '#rules' do
- context 'using a regular issue' do
- let(:project) { create(:empty_project, :public) }
- let(:issue) { create(:issue, project: project) }
- let(:policies) { described_class.abilities(user, issue).to_set }
-
- context 'with a regular user' do
- it 'includes the read_issue permission' do
- expect(policies).to include(:read_issue)
- end
-
- it 'does not include the admin_issue permission' do
- expect(policies).not_to include(:admin_issue)
- end
-
- it 'does not include the update_issue permission' do
- expect(policies).not_to include(:update_issue)
- end
- end
+describe IssuePolicy do
+ let(:guest) { create(:user) }
+ let(:author) { create(:user) }
+ let(:assignee) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:group) { create(:group, :public) }
+ let(:reporter_from_group_link) { create(:user) }
+
+ def permissions(user, issue)
+ described_class.new(user, issue)
+ end
+
+ context 'a private project' do
+ let(:non_member) { create(:user) }
+ let(:project) { create(:project, :private) }
+ let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) }
+ let(:issue_no_assignee) { create(:issue, project: project) }
+
+ before do
+ project.team << [guest, :guest]
+ project.team << [author, :guest]
+ project.team << [assignee, :guest]
+ project.team << [reporter, :reporter]
+
+ group.add_reporter(reporter_from_group_link)
+
+ create(:project_group_link, group: group, project: project)
+ end
+
+ it 'does not allow non-members to read issues' do
+ expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'allows guests to read issues' do
+ expect(permissions(guest, issue)).to be_allowed(:read_issue)
+ expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue)
+
+ expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
+
+ it 'allows reporters to read, update, and admin issues' do
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'allows reporters from group links to read, update, and admin issues' do
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'allows issue authors to read and update their issues' do
+ expect(permissions(author, issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(author, issue)).to be_disallowed(:admin_issue)
+
+ expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
+
+ it 'allows issue assignees to read and update their issues' do
+ expect(permissions(assignee, issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(assignee, issue)).to be_disallowed(:admin_issue)
+
+ expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
- context 'with a user that is a project reporter' do
- before do
- project.team << [user, :reporter]
- end
+ context 'with confidential issues' do
+ let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) }
+ let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) }
- it 'includes the read_issue permission' do
- expect(policies).to include(:read_issue)
- end
+ it 'does not allow non-members to read confidential issues' do
+ expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'does not allow guests to read confidential issues' do
+ expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ end
- it 'includes the admin_issue permission' do
- expect(policies).to include(:admin_issue)
- end
+ it 'allows reporters to read, update, and admin confidential issues' do
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
- it 'includes the update_issue permission' do
- expect(policies).to include(:update_issue)
- end
+ it 'allows reporters from group links to read, update, and admin confidential issues' do
+ expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
end
- context 'with a user that is a project guest' do
- before do
- project.team << [user, :guest]
- end
+ it 'allows issue authors to read and update their confidential issues' do
+ expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue)
- it 'includes the read_issue permission' do
- expect(policies).to include(:read_issue)
- end
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ end
- it 'does not include the admin_issue permission' do
- expect(policies).not_to include(:admin_issue)
- end
+ it 'allows issue assignees to read and update their confidential issues' do
+ expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue)
- it 'does not include the update_issue permission' do
- expect(policies).not_to include(:update_issue)
- end
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
end
end
+ end
- context 'using a confidential issue' do
- let(:issue) { create(:issue, :confidential) }
+ context 'a public project' do
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) }
+ let(:issue_no_assignee) { create(:issue, project: project) }
- context 'with a regular user' do
- let(:policies) { described_class.abilities(user, issue).to_set }
+ before do
+ project.team << [guest, :guest]
+ project.team << [reporter, :reporter]
- it 'does not include the read_issue permission' do
- expect(policies).not_to include(:read_issue)
- end
+ group.add_reporter(reporter_from_group_link)
- it 'does not include the admin_issue permission' do
- expect(policies).not_to include(:admin_issue)
- end
+ create(:project_group_link, group: group, project: project)
+ end
- it 'does not include the update_issue permission' do
- expect(policies).not_to include(:update_issue)
- end
- end
+ it 'allows guests to read issues' do
+ expect(permissions(guest, issue)).to be_allowed(:read_issue)
+ expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue)
+
+ expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
+
+ it 'allows reporters to read, update, and admin issues' do
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'allows reporters from group links to read, update, and admin issues' do
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
- context 'with a user that is a project member' do
- let(:policies) { described_class.abilities(user, issue).to_set }
+ it 'allows issue authors to read and update their issues' do
+ expect(permissions(author, issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(author, issue)).to be_disallowed(:admin_issue)
- before do
- issue.project.team << [user, :reporter]
- end
+ expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
+
+ it 'allows issue assignees to read and update their issues' do
+ expect(permissions(assignee, issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(assignee, issue)).to be_disallowed(:admin_issue)
- it 'includes the read_issue permission' do
- expect(policies).to include(:read_issue)
- end
+ expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue)
+ expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
+ end
- it 'includes the admin_issue permission' do
- expect(policies).to include(:admin_issue)
- end
+ context 'with confidential issues' do
+ let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) }
+ let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) }
- it 'includes the update_issue permission' do
- expect(policies).to include(:update_issue)
- end
+ it 'does not allow guests to read confidential issues' do
+ expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
end
- context 'without a user' do
- let(:policies) { described_class.abilities(nil, issue).to_set }
+ it 'allows reporters to read, update, and admin confidential issues' do
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
+
+ it 'allows reporter from group links to read, update, and admin confidential issues' do
+ expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue)
+ end
- it 'does not include the read_issue permission' do
- expect(policies).not_to include(:read_issue)
- end
+ it 'allows issue authors to read and update their confidential issues' do
+ expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue)
+
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ end
- it 'does not include the admin_issue permission' do
- expect(policies).not_to include(:admin_issue)
- end
+ it 'allows issue assignees to read and update their confidential issues' do
+ expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :update_issue)
+ expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue)
- it 'does not include the update_issue permission' do
- expect(policies).not_to include(:update_issue)
- end
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
end
end
end
diff --git a/spec/policies/issues_policy_spec.rb b/spec/policies/issues_policy_spec.rb
deleted file mode 100644
index 2b7b6cad654..00000000000
--- a/spec/policies/issues_policy_spec.rb
+++ /dev/null
@@ -1,193 +0,0 @@
-require 'spec_helper'
-
-describe IssuePolicy, models: true do
- let(:guest) { create(:user) }
- let(:author) { create(:user) }
- let(:assignee) { create(:user) }
- let(:reporter) { create(:user) }
- let(:group) { create(:group, :public) }
- let(:reporter_from_group_link) { create(:user) }
-
- def permissions(user, issue)
- IssuePolicy.abilities(user, issue).to_set
- end
-
- context 'a private project' do
- let(:non_member) { create(:user) }
- let(:project) { create(:empty_project, :private) }
- let(:issue) { create(:issue, project: project, assignee: assignee, author: author) }
- let(:issue_no_assignee) { create(:issue, project: project) }
-
- before do
- project.team << [guest, :guest]
- project.team << [author, :guest]
- project.team << [assignee, :guest]
- project.team << [reporter, :reporter]
-
- group.add_reporter(reporter_from_group_link)
-
- create(:project_group_link, group: group, project: project)
- end
-
- it 'does not allow non-members to read issues' do
- expect(permissions(non_member, issue)).not_to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(non_member, issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows guests to read issues' do
- expect(permissions(guest, issue)).to include(:read_issue)
- expect(permissions(guest, issue)).not_to include(:update_issue, :admin_issue)
-
- expect(permissions(guest, issue_no_assignee)).to include(:read_issue)
- expect(permissions(guest, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- it 'allows reporters to read, update, and admin issues' do
- expect(permissions(reporter, issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporters from group links to read, update, and admin issues' do
- expect(permissions(reporter_from_group_link, issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter_from_group_link, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue authors to read and update their issues' do
- expect(permissions(author, issue)).to include(:read_issue, :update_issue)
- expect(permissions(author, issue)).not_to include(:admin_issue)
-
- expect(permissions(author, issue_no_assignee)).to include(:read_issue)
- expect(permissions(author, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- it 'allows issue assignees to read and update their issues' do
- expect(permissions(assignee, issue)).to include(:read_issue, :update_issue)
- expect(permissions(assignee, issue)).not_to include(:admin_issue)
-
- expect(permissions(assignee, issue_no_assignee)).to include(:read_issue)
- expect(permissions(assignee, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- context 'with confidential issues' do
- let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) }
- let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) }
-
- it 'does not allow non-members to read confidential issues' do
- expect(permissions(non_member, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(non_member, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'does not allow guests to read confidential issues' do
- expect(permissions(guest, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(guest, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporters to read, update, and admin confidential issues' do
- expect(permissions(reporter, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporters from group links to read, update, and admin confidential issues' do
- expect(permissions(reporter_from_group_link, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue authors to read and update their confidential issues' do
- expect(permissions(author, confidential_issue)).to include(:read_issue, :update_issue)
- expect(permissions(author, confidential_issue)).not_to include(:admin_issue)
-
- expect(permissions(author, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue assignees to read and update their confidential issues' do
- expect(permissions(assignee, confidential_issue)).to include(:read_issue, :update_issue)
- expect(permissions(assignee, confidential_issue)).not_to include(:admin_issue)
-
- expect(permissions(assignee, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
- end
- end
-
- context 'a public project' do
- let(:project) { create(:empty_project, :public) }
- let(:issue) { create(:issue, project: project, assignee: assignee, author: author) }
- let(:issue_no_assignee) { create(:issue, project: project) }
-
- before do
- project.team << [guest, :guest]
- project.team << [reporter, :reporter]
-
- group.add_reporter(reporter_from_group_link)
-
- create(:project_group_link, group: group, project: project)
- end
-
- it 'allows guests to read issues' do
- expect(permissions(guest, issue)).to include(:read_issue)
- expect(permissions(guest, issue)).not_to include(:update_issue, :admin_issue)
-
- expect(permissions(guest, issue_no_assignee)).to include(:read_issue)
- expect(permissions(guest, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- it 'allows reporters to read, update, and admin issues' do
- expect(permissions(reporter, issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporters from group links to read, update, and admin issues' do
- expect(permissions(reporter_from_group_link, issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter_from_group_link, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue authors to read and update their issues' do
- expect(permissions(author, issue)).to include(:read_issue, :update_issue)
- expect(permissions(author, issue)).not_to include(:admin_issue)
-
- expect(permissions(author, issue_no_assignee)).to include(:read_issue)
- expect(permissions(author, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- it 'allows issue assignees to read and update their issues' do
- expect(permissions(assignee, issue)).to include(:read_issue, :update_issue)
- expect(permissions(assignee, issue)).not_to include(:admin_issue)
-
- expect(permissions(assignee, issue_no_assignee)).to include(:read_issue)
- expect(permissions(assignee, issue_no_assignee)).not_to include(:update_issue, :admin_issue)
- end
-
- context 'with confidential issues' do
- let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) }
- let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) }
-
- it 'does not allow guests to read confidential issues' do
- expect(permissions(guest, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(guest, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporters to read, update, and admin confidential issues' do
- expect(permissions(reporter, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows reporter from group links to read, update, and admin confidential issues' do
- expect(permissions(reporter_from_group_link, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue)
- expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue authors to read and update their confidential issues' do
- expect(permissions(author, confidential_issue)).to include(:read_issue, :update_issue)
- expect(permissions(author, confidential_issue)).not_to include(:admin_issue)
-
- expect(permissions(author, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
-
- it 'allows issue assignees to read and update their confidential issues' do
- expect(permissions(assignee, confidential_issue)).to include(:read_issue, :update_issue)
- expect(permissions(assignee, confidential_issue)).not_to include(:admin_issue)
-
- expect(permissions(assignee, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue)
- end
- end
- end
-end
diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb
new file mode 100644
index 00000000000..b70c8646a3d
--- /dev/null
+++ b/spec/policies/personal_snippet_policy_spec.rb
@@ -0,0 +1,141 @@
+require 'spec_helper'
+
+describe PersonalSnippetPolicy do
+ let(:regular_user) { create(:user) }
+ let(:external_user) { create(:user, :external) }
+ let(:admin_user) { create(:user, :admin) }
+
+ let(:author_permissions) do
+ [
+ :update_personal_snippet,
+ :admin_personal_snippet,
+ :destroy_personal_snippet
+ ]
+ end
+
+ def permissions(user)
+ described_class.new(user, snippet)
+ end
+
+ context 'public snippet' do
+ let(:snippet) { create(:personal_snippet, :public) }
+
+ context 'no user' do
+ subject { permissions(nil) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'regular user' do
+ subject { permissions(regular_user) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'author' do
+ subject { permissions(snippet.author) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*author_permissions)
+ end
+ end
+ end
+
+ context 'internal snippet' do
+ let(:snippet) { create(:personal_snippet, :internal) }
+
+ context 'no user' do
+ subject { permissions(nil) }
+
+ it do
+ is_expected.to be_disallowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'regular user' do
+ subject { permissions(regular_user) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'external user' do
+ subject { permissions(external_user) }
+
+ it do
+ is_expected.to be_disallowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'snippet author' do
+ subject { permissions(snippet.author) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*author_permissions)
+ end
+ end
+ end
+
+ context 'private snippet' do
+ let(:snippet) { create(:project_snippet, :private) }
+
+ context 'no user' do
+ subject { permissions(nil) }
+
+ it do
+ is_expected.to be_disallowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'regular user' do
+ subject { permissions(regular_user) }
+
+ it do
+ is_expected.to be_disallowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'external user' do
+ subject { permissions(external_user) }
+
+ it do
+ is_expected.to be_disallowed(:read_personal_snippet)
+ is_expected.to be_disallowed(:comment_personal_snippet)
+ is_expected.to be_disallowed(*author_permissions)
+ end
+ end
+
+ context 'snippet author' do
+ subject { permissions(snippet.author) }
+
+ it do
+ is_expected.to be_allowed(:read_personal_snippet)
+ is_expected.to be_allowed(:comment_personal_snippet)
+ is_expected.to be_allowed(*author_permissions)
+ end
+ end
+ end
+end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 064847ee3dc..4dbaf7fb025 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe ProjectPolicy, models: true do
+describe ProjectPolicy do
let(:guest) { create(:user) }
let(:reporter) { create(:user) }
let(:dev) { create(:user) }
let(:master) { create(:user) }
let(:owner) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, :public, namespace: owner.namespace) }
+ let(:project) { create(:project, :public, namespace: owner.namespace) }
let(:guest_permissions) do
%i[
@@ -43,7 +43,7 @@ describe ProjectPolicy, models: true do
let(:master_permissions) do
%i[
- push_code_to_protected_branches update_project_snippet update_environment
+ delete_protected_branch update_project_snippet update_environment
update_deployment admin_milestone admin_project_snippet
admin_project_member admin_note admin_wiki admin_project
admin_commit_status admin_build admin_container_image
@@ -73,37 +73,87 @@ describe ProjectPolicy, models: true do
project.team << [reporter, :reporter]
end
+ def expect_allowed(*permissions)
+ permissions.each { |p| is_expected.to be_allowed(p) }
+ end
+
+ def expect_disallowed(*permissions)
+ permissions.each { |p| is_expected.not_to be_allowed(p) }
+ end
+
it 'does not include the read_issue permission when the issue author is not a member of the private project' do
- project = create(:empty_project, :private)
+ project = create(:project, :private)
issue = create(:issue, project: project)
user = issue.author
- expect(project.team.member?(issue.author)).to eq(false)
+ expect(project.team.member?(issue.author)).to be false
+
+ expect(Ability).not_to be_allowed(user, :read_issue, project)
+ end
+
+ context 'when the feature is disabled' do
+ subject { described_class.new(owner, project) }
+
+ before do
+ project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
+ end
+
+ it 'does not include the wiki permissions' do
+ expect_disallowed :read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code
+ end
+ end
+
+ context 'issues feature' do
+ subject { described_class.new(owner, project) }
+
+ context 'when the feature is disabled' do
+ it 'does not include the issues permissions' do
+ project.issues_enabled = false
+ project.save!
+
+ expect_disallowed :read_issue, :create_issue, :update_issue, :admin_issue
+ end
+ end
- expect(BasePolicy.class_for(project).abilities(user, project).can_set).
- not_to include(:read_issue)
+ context 'when the feature is disabled and external tracker configured' do
+ it 'does not include the issues permissions' do
+ create(:jira_service, project: project)
- expect(Ability.allowed?(user, :read_issue, project)).to be_falsy
+ project.issues_enabled = false
+ project.save!
+
+ expect_disallowed :read_issue, :create_issue, :update_issue, :admin_issue
+ end
+ end
end
- it 'does not include the wiki permissions when the feature is disabled' do
- project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
- wiki_permissions = [:read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code]
+ context 'when a project has pending invites, and the current user is anonymous' do
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:user_permissions) { [:create_project, :create_issue, :create_note, :upload_file] }
+ let(:anonymous_permissions) { guest_permissions - user_permissions }
+
+ subject { described_class.new(nil, project) }
- permissions = described_class.abilities(owner, project).to_set
+ before do
+ create(:group_member, :invited, group: group)
+ end
- expect(permissions).not_to include(*wiki_permissions)
+ it 'does not grant owner access' do
+ expect_allowed(*anonymous_permissions)
+ expect_disallowed(*user_permissions)
+ end
end
context 'abilities for non-public projects' do
- let(:project) { create(:empty_project, namespace: owner.namespace) }
+ let(:project) { create(:project, namespace: owner.namespace) }
- subject { described_class.abilities(current_user, project).to_set }
+ subject { described_class.new(current_user, project) }
context 'with no user' do
let(:current_user) { nil }
- it { is_expected.to be_empty }
+ it { is_expected.to be_banned }
end
context 'guests' do
@@ -114,18 +164,18 @@ describe ProjectPolicy, models: true do
end
it do
- is_expected.to include(*guest_permissions)
- is_expected.not_to include(*reporter_public_build_permissions)
- is_expected.not_to include(*team_member_reporter_permissions)
- is_expected.not_to include(*developer_permissions)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_disallowed(*reporter_public_build_permissions)
+ expect_disallowed(*team_member_reporter_permissions)
+ expect_disallowed(*developer_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
context 'public builds enabled' do
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(:read_build, :read_pipeline)
+ expect_allowed(*guest_permissions)
+ expect_allowed(:read_build, :read_pipeline)
end
end
@@ -135,8 +185,20 @@ describe ProjectPolicy, models: true do
end
it do
- is_expected.to include(*guest_permissions)
- is_expected.not_to include(:read_build, :read_pipeline)
+ expect_allowed(*guest_permissions)
+ expect_disallowed(:read_build, :read_pipeline)
+ end
+ end
+
+ context 'when builds are disabled' do
+ before do
+ project.project_feature.update(
+ builds_access_level: ProjectFeature::DISABLED)
+ end
+
+ it do
+ expect_disallowed(:read_build)
+ expect_allowed(:read_pipeline)
end
end
end
@@ -145,12 +207,13 @@ describe ProjectPolicy, models: true do
let(:current_user) { reporter }
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(*reporter_permissions)
- is_expected.to include(*team_member_reporter_permissions)
- is_expected.not_to include(*developer_permissions)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*team_member_reporter_permissions)
+ expect_disallowed(*developer_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -158,12 +221,12 @@ describe ProjectPolicy, models: true do
let(:current_user) { dev }
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(*reporter_permissions)
- is_expected.to include(*team_member_reporter_permissions)
- is_expected.to include(*developer_permissions)
- is_expected.not_to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*team_member_reporter_permissions)
+ expect_allowed(*developer_permissions)
+ expect_disallowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -171,12 +234,12 @@ describe ProjectPolicy, models: true do
let(:current_user) { master }
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(*reporter_permissions)
- is_expected.to include(*team_member_reporter_permissions)
- is_expected.to include(*developer_permissions)
- is_expected.to include(*master_permissions)
- is_expected.not_to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*team_member_reporter_permissions)
+ expect_allowed(*developer_permissions)
+ expect_allowed(*master_permissions)
+ expect_disallowed(*owner_permissions)
end
end
@@ -184,12 +247,12 @@ describe ProjectPolicy, models: true do
let(:current_user) { owner }
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(*reporter_permissions)
- is_expected.to include(*team_member_reporter_permissions)
- is_expected.to include(*developer_permissions)
- is_expected.to include(*master_permissions)
- is_expected.to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*team_member_reporter_permissions)
+ expect_allowed(*developer_permissions)
+ expect_allowed(*master_permissions)
+ expect_allowed(*owner_permissions)
end
end
@@ -197,12 +260,12 @@ describe ProjectPolicy, models: true do
let(:current_user) { admin }
it do
- is_expected.to include(*guest_permissions)
- is_expected.to include(*reporter_permissions)
- is_expected.not_to include(*team_member_reporter_permissions)
- is_expected.to include(*developer_permissions)
- is_expected.to include(*master_permissions)
- is_expected.to include(*owner_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_disallowed(*team_member_reporter_permissions)
+ expect_allowed(*developer_permissions)
+ expect_allowed(*master_permissions)
+ expect_allowed(*owner_permissions)
end
end
end
diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb
index d0758af57dd..f0bf46c480a 100644
--- a/spec/policies/project_snippet_policy_spec.rb
+++ b/spec/policies/project_snippet_policy_spec.rb
@@ -1,7 +1,9 @@
require 'spec_helper'
-describe ProjectSnippetPolicy, models: true do
- let(:current_user) { create(:user) }
+describe ProjectSnippetPolicy do
+ let(:regular_user) { create(:user) }
+ let(:external_user) { create(:user, :external) }
+ let(:project) { create(:project, :public) }
let(:author_permissions) do
[
@@ -10,91 +12,153 @@ describe ProjectSnippetPolicy, models: true do
]
end
- subject { described_class.abilities(current_user, project_snippet).to_set }
+ def abilities(user, snippet_visibility)
+ snippet = create(:project_snippet, snippet_visibility, project: project)
- context 'public snippet' do
- let(:project_snippet) { create(:project_snippet, :public) }
+ described_class.new(user, snippet)
+ end
+
+ def expect_allowed(*permissions)
+ permissions.each { |p| is_expected.to be_allowed(p) }
+ end
+
+ def expect_disallowed(*permissions)
+ permissions.each { |p| is_expected.not_to be_allowed(p) }
+ end
+ context 'public snippet' do
context 'no user' do
- let(:current_user) { nil }
+ subject { abilities(nil, :public) }
it do
- is_expected.to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
context 'regular user' do
+ subject { abilities(regular_user, :public) }
+
+ it do
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
+ end
+ end
+
+ context 'external user' do
+ subject { abilities(external_user, :public) }
+
it do
- is_expected.to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
end
context 'internal snippet' do
- let(:project_snippet) { create(:project_snippet, :internal) }
-
context 'no user' do
- let(:current_user) { nil }
+ subject { abilities(nil, :internal) }
it do
- is_expected.not_to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_disallowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
context 'regular user' do
+ subject { abilities(regular_user, :internal) }
+
+ it do
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
+ end
+ end
+
+ context 'external user' do
+ subject { abilities(external_user, :internal) }
+
it do
- is_expected.to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_disallowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
+ end
+ end
+
+ context 'project team member external user' do
+ subject { abilities(external_user, :internal) }
+
+ before do
+ project.team << [external_user, :developer]
+ end
+
+ it do
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
end
context 'private snippet' do
- let(:project_snippet) { create(:project_snippet, :private) }
-
context 'no user' do
- let(:current_user) { nil }
+ subject { abilities(nil, :private) }
it do
- is_expected.not_to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_disallowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
context 'regular user' do
+ subject { abilities(regular_user, :private) }
+
it do
- is_expected.not_to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_disallowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
context 'snippet author' do
- let(:project_snippet) { create(:project_snippet, :private, author: current_user) }
+ let(:snippet) { create(:project_snippet, :private, author: regular_user, project: project) }
+
+ subject { described_class.new(regular_user, snippet) }
it do
- is_expected.to include(:read_project_snippet)
- is_expected.to include(*author_permissions)
+ expect_allowed(:read_project_snippet)
+ expect_allowed(*author_permissions)
end
end
- context 'project team member' do
- before { project_snippet.project.team << [current_user, :developer] }
+ context 'project team member normal user' do
+ subject { abilities(regular_user, :private) }
+
+ before do
+ project.team << [regular_user, :developer]
+ end
+
+ it do
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
+ end
+ end
+
+ context 'project team member external user' do
+ subject { abilities(external_user, :private) }
+
+ before do
+ project.team << [external_user, :developer]
+ end
it do
- is_expected.to include(:read_project_snippet)
- is_expected.not_to include(*author_permissions)
+ expect_allowed(:read_project_snippet)
+ expect_disallowed(*author_permissions)
end
end
context 'admin user' do
- let(:current_user) { create(:admin) }
+ subject { abilities(create(:admin), :private) }
it do
- is_expected.to include(:read_project_snippet)
- is_expected.to include(*author_permissions)
+ expect_allowed(:read_project_snippet)
+ expect_allowed(*author_permissions)
end
end
end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index d5761390d39..6593a6ca3b9 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -1,37 +1,37 @@
require 'spec_helper'
-describe UserPolicy, models: true do
+describe UserPolicy do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
- subject { described_class.abilities(current_user, user).to_set }
+ subject { described_class.new(current_user, user) }
describe "reading a user's information" do
- it { is_expected.to include(:read_user) }
+ it { is_expected.to be_allowed(:read_user) }
end
describe "destroying a user" do
context "when a regular user tries to destroy another regular user" do
- it { is_expected.not_to include(:destroy_user) }
+ it { is_expected.not_to be_allowed(:destroy_user) }
end
context "when a regular user tries to destroy themselves" do
let(:current_user) { user }
- it { is_expected.to include(:destroy_user) }
+ it { is_expected.to be_allowed(:destroy_user) }
end
context "when an admin user tries to destroy a regular user" do
let(:current_user) { create(:user, :admin) }
- it { is_expected.to include(:destroy_user) }
+ it { is_expected.to be_allowed(:destroy_user) }
end
context "when an admin user tries to destroy a ghost user" do
let(:current_user) { create(:user, :admin) }
let(:user) { create(:user, :ghost) }
- it { is_expected.not_to include(:destroy_user) }
+ it { is_expected.not_to be_allowed(:destroy_user) }
end
end
end
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index 2190ab0e82e..a7a34ecac72 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Ci::BuildPresenter do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
@@ -47,8 +47,8 @@ describe Ci::BuildPresenter do
context 'when build is erased' do
before do
expect(presenter).to receive(:erased_by_user?).and_return(true)
- expect(build).to receive(:erased_by).
- and_return(double(:user, name: 'John Doe'))
+ expect(build).to receive(:erased_by)
+ .and_return(double(:user, name: 'John Doe'))
end
it 'returns the name of the eraser' do
@@ -85,7 +85,7 @@ describe Ci::BuildPresenter do
describe 'quack like a Ci::Build permission-wise' do
context 'user is not allowed' do
- let(:project) { build_stubbed(:empty_project, public_builds: false) }
+ let(:project) { create(:project, public_builds: false) }
it 'returns false' do
expect(presenter.can?(nil, :read_build)).to be_falsy
@@ -93,7 +93,7 @@ describe Ci::BuildPresenter do
end
context 'user is allowed' do
- let(:project) { build_stubbed(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
it 'returns true' do
expect(presenter.can?(nil, :read_build)).to be_truthy
diff --git a/spec/presenters/ci/group_variable_presenter_spec.rb b/spec/presenters/ci/group_variable_presenter_spec.rb
new file mode 100644
index 00000000000..d404028405b
--- /dev/null
+++ b/spec/presenters/ci/group_variable_presenter_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe Ci::GroupVariablePresenter do
+ include Gitlab::Routing.url_helpers
+
+ let(:group) { create(:group) }
+ let(:variable) { create(:ci_group_variable, group: group) }
+
+ subject(:presenter) do
+ described_class.new(variable)
+ 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 variable and optional params' do
+ expect { presenter }.not_to raise_error
+ end
+
+ it 'exposes variable' do
+ expect(presenter.variable).to eq(variable)
+ end
+
+ it 'forwards missing methods to variable' do
+ expect(presenter.key).to eq(variable.key)
+ end
+ end
+
+ describe '#placeholder' do
+ subject { described_class.new(variable).placeholder }
+
+ it { is_expected.to eq('GROUP_VARIABLE') }
+ end
+
+ describe '#form_path' do
+ context 'when variable is persisted' do
+ subject { described_class.new(variable).form_path }
+
+ it { is_expected.to eq(group_variable_path(group, variable)) }
+ end
+
+ context 'when variable is not persisted' do
+ let(:variable) { build(:ci_group_variable, group: group) }
+ subject { described_class.new(variable).form_path }
+
+ it { is_expected.to eq(group_variables_path(group)) }
+ end
+ end
+
+ describe '#edit_path' do
+ subject { described_class.new(variable).edit_path }
+
+ it { is_expected.to eq(group_variable_path(group, variable)) }
+ end
+
+ describe '#delete_path' do
+ subject { described_class.new(variable).delete_path }
+
+ it { is_expected.to eq(group_variable_path(group, variable)) }
+ end
+end
diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb
index 9134d1cc31c..e4886a8f019 100644
--- a/spec/presenters/ci/pipeline_presenter_spec.rb
+++ b/spec/presenters/ci/pipeline_presenter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Ci::PipelinePresenter do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
subject(:presenter) do
diff --git a/spec/presenters/ci/variable_presenter_spec.rb b/spec/presenters/ci/variable_presenter_spec.rb
new file mode 100644
index 00000000000..db62f86edb0
--- /dev/null
+++ b/spec/presenters/ci/variable_presenter_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe Ci::VariablePresenter do
+ include Gitlab::Routing.url_helpers
+
+ let(:project) { create(:project) }
+ let(:variable) { create(:ci_variable, project: project) }
+
+ subject(:presenter) do
+ described_class.new(variable)
+ 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 variable and optional params' do
+ expect { presenter }.not_to raise_error
+ end
+
+ it 'exposes variable' do
+ expect(presenter.variable).to eq(variable)
+ end
+
+ it 'forwards missing methods to variable' do
+ expect(presenter.key).to eq(variable.key)
+ end
+ end
+
+ describe '#placeholder' do
+ subject { described_class.new(variable).placeholder }
+
+ it { is_expected.to eq('PROJECT_VARIABLE') }
+ end
+
+ describe '#form_path' do
+ context 'when variable is persisted' do
+ subject { described_class.new(variable).form_path }
+
+ it { is_expected.to eq(project_variable_path(project, variable)) }
+ end
+
+ context 'when variable is not persisted' do
+ let(:variable) { build(:ci_variable, project: project) }
+ subject { described_class.new(variable).form_path }
+
+ it { is_expected.to eq(project_variables_path(project)) }
+ end
+ end
+
+ describe '#edit_path' do
+ subject { described_class.new(variable).edit_path }
+
+ it { is_expected.to eq(project_variable_path(project, variable)) }
+ end
+
+ describe '#delete_path' do
+ subject { described_class.new(variable).delete_path }
+
+ it { is_expected.to eq(project_variable_path(project, variable)) }
+ end
+end
diff --git a/spec/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
new file mode 100644
index 00000000000..81eb05a9a6b
--- /dev/null
+++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe ConversationalDevelopmentIndex::MetricPresenter do
+ subject { described_class.new(metric) }
+ let(:metric) { build(:conversational_development_index_metric) }
+
+ describe '#cards' do
+ it 'includes instance score, leader score and percentage score' do
+ issues_card = subject.cards.first
+
+ expect(issues_card.instance_score).to eq(1.234)
+ expect(issues_card.leader_score).to eq(9.256)
+ expect(issues_card.percentage_score).to eq(13.331)
+ end
+ end
+
+ describe '#idea_to_production_steps' do
+ it 'returns percentage score when it depends on a single feature' do
+ code_step = subject.idea_to_production_steps.fourth
+
+ expect(code_step.percentage_score).to be_within(0.1).of(50.0)
+ end
+
+ it 'returns percentage score when it depends on two features' do
+ issue_step = subject.idea_to_production_steps.second
+
+ expect(issue_step.percentage_score).to be_within(0.1).of(53.0)
+ end
+ end
+
+ describe '#average_percentage_score' do
+ it 'calculates an average value across all the features' do
+ expect(subject.average_percentage_score).to be_within(0.1).of(55.8)
+ end
+ end
+end
diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb
new file mode 100644
index 00000000000..2187be0190d
--- /dev/null
+++ b/spec/presenters/merge_request_presenter_spec.rb
@@ -0,0 +1,403 @@
+require 'spec_helper'
+
+describe MergeRequestPresenter do
+ let(:resource) { create :merge_request, source_project: project }
+ let(:project) { create :project }
+ let(:user) { create(:user) }
+
+ describe '#ci_status' do
+ subject { described_class.new(resource).ci_status }
+
+ context 'when no head pipeline' do
+ it 'return status using CiService' do
+ ci_service = double(MockCiService)
+ ci_status = double
+
+ allow(resource.source_project)
+ .to receive(:ci_service)
+ .and_return(ci_service)
+
+ allow(resource).to receive(:head_pipeline).and_return(nil)
+
+ expect(ci_service).to receive(:commit_status)
+ .with(resource.diff_head_sha, resource.source_branch)
+ .and_return(ci_status)
+
+ is_expected.to eq(ci_status)
+ end
+ end
+
+ context 'when head pipeline present' do
+ let(:pipeline) { build_stubbed(:ci_pipeline) }
+
+ before do
+ allow(resource).to receive(:head_pipeline).and_return(pipeline)
+ end
+
+ context 'success with warnings' do
+ before do
+ allow(pipeline).to receive(:success?) { true }
+ allow(pipeline).to receive(:has_warnings?) { true }
+ end
+
+ it 'returns "success_with_warnings"' do
+ is_expected.to eq('success_with_warnings')
+ end
+ end
+
+ context 'pipeline HAS status AND its not success with warnings' do
+ before do
+ allow(pipeline).to receive(:success?) { false }
+ allow(pipeline).to receive(:has_warnings?) { false }
+ end
+
+ it 'returns pipeline status' do
+ is_expected.to eq('pending')
+ end
+ end
+
+ context 'pipeline has NO status AND its not success with warnings' do
+ before do
+ allow(pipeline).to receive(:status) { nil }
+ allow(pipeline).to receive(:success?) { false }
+ allow(pipeline).to receive(:has_warnings?) { false }
+ end
+
+ it 'returns "preparing"' do
+ is_expected.to eq('preparing')
+ end
+ end
+ end
+ end
+
+ describe '#conflict_resolution_path' do
+ let(:project) { create :project }
+ let(:user) { create :user }
+ let(:presenter) { described_class.new(resource, current_user: user) }
+ let(:path) { presenter.conflict_resolution_path }
+
+ context 'when MR cannot be resolved in UI' do
+ it 'does not return conflict resolution path' do
+ allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { false }
+
+ expect(path).to be_nil
+ end
+ end
+
+ context 'when conflicts cannot be resolved by user' do
+ it 'does not return conflict resolution path' do
+ allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { true }
+ allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_by?).with(user) { false }
+
+ expect(path).to be_nil
+ end
+ end
+
+ context 'when able to access conflict resolution UI' do
+ it 'does return conflict resolution path' do
+ allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_in_ui?) { true }
+ allow(presenter).to receive_message_chain(:conflicts, :can_be_resolved_by?).with(user) { true }
+
+ expect(path)
+ .to eq("/#{project.full_path}/merge_requests/#{resource.iid}/conflicts")
+ end
+ end
+ end
+
+ context 'issues links' do
+ let(:project) { create(:project, :private, :repository, creator: user, namespace: user.namespace) }
+ let(:issue_a) { create(:issue, project: project) }
+ let(:issue_b) { create(:issue, project: project) }
+
+ let(:resource) do
+ create(:merge_request,
+ source_project: project, target_project: project,
+ description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}")
+ end
+
+ before do
+ project.team << [user, :developer]
+
+ allow(resource.project).to receive(:default_branch)
+ .and_return(resource.target_branch)
+ end
+
+ describe '#closing_issues_links' do
+ subject { described_class.new(resource, current_user: user).closing_issues_links }
+
+ it 'presents closing issues links' do
+ is_expected.to match("#{project.full_path}/issues/#{issue_a.iid}")
+ end
+
+ it 'does not present related issues links' do
+ is_expected.not_to match("#{project.full_path}/issues/#{issue_b.iid}")
+ end
+
+ it 'appends status when closing issue is already closed' do
+ issue_a.close
+ is_expected.to match('(closed)')
+ end
+ end
+
+ describe '#mentioned_issues_links' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .mentioned_issues_links
+ end
+
+ it 'presents related issues links' do
+ is_expected.to match("#{project.full_path}/issues/#{issue_b.iid}")
+ end
+
+ it 'does not present closing issues links' do
+ is_expected.not_to match("#{project.full_path}/issues/#{issue_a.iid}")
+ end
+
+ it 'appends status when mentioned issue is already closed' do
+ issue_b.close
+ is_expected.to match('(closed)')
+ end
+ end
+
+ describe '#assign_to_closing_issues_link' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .assign_to_closing_issues_link
+ end
+
+ before do
+ assign_issues_service = double(MergeRequests::AssignIssuesService, assignable_issues: assignable_issues)
+ allow(MergeRequests::AssignIssuesService).to receive(:new)
+ .and_return(assign_issues_service)
+ end
+
+ context 'single closing issue' do
+ let(:issue) { create(:issue) }
+ let(:assignable_issues) { [issue] }
+
+ it 'returns correct link with correct text' do
+ is_expected
+ .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues")
+
+ is_expected
+ .to match("Assign yourself to this issue")
+ end
+ end
+
+ context 'multiple closing issues' do
+ let(:issues) { create_list(:issue, 2) }
+ let(:assignable_issues) { issues }
+
+ it 'returns correct link with correct text' do
+ is_expected
+ .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues")
+
+ is_expected
+ .to match("Assign yourself to these issues")
+ end
+ end
+
+ context 'no closing issue' do
+ let(:assignable_issues) { [] }
+
+ it 'returns correct link with correct text' do
+ is_expected.to be_nil
+ end
+ end
+ end
+ end
+
+ describe '#cancel_merge_when_pipeline_succeeds_path' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .cancel_merge_when_pipeline_succeeds_path
+ end
+
+ context 'when can cancel mwps' do
+ it 'returns path' do
+ allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?)
+ .with(user)
+ .and_return(true)
+
+ is_expected.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/cancel_merge_when_pipeline_succeeds")
+ end
+ end
+
+ context 'when cannot cancel mwps' do
+ it 'returns nil' do
+ allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?)
+ .with(user)
+ .and_return(false)
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#merge_path' do
+ subject do
+ described_class.new(resource, current_user: user).merge_path
+ end
+
+ context 'when can be merged by user' do
+ it 'returns path' do
+ allow(resource).to receive(:can_be_merged_by?)
+ .with(user)
+ .and_return(true)
+
+ is_expected
+ .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/merge")
+ end
+ end
+
+ context 'when cannot be merged by user' do
+ it 'returns nil' do
+ allow(resource).to receive(:can_be_merged_by?)
+ .with(user)
+ .and_return(false)
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#create_issue_to_resolve_discussions_path' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .create_issue_to_resolve_discussions_path
+ end
+
+ context 'when can create issue and issues enabled' do
+ it 'returns path' do
+ allow(project).to receive(:issues_enabled?) { true }
+ project.team << [user, :master]
+
+ is_expected
+ .to eq("/#{resource.project.full_path}/issues/new?merge_request_to_resolve_discussions_of=#{resource.iid}")
+ end
+ end
+
+ context 'when cannot create issue' do
+ it 'returns nil' do
+ allow(project).to receive(:issues_enabled?) { true }
+
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when issues disabled' do
+ it 'returns nil' do
+ allow(project).to receive(:issues_enabled?) { false }
+ project.team << [user, :master]
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#remove_wip_path' do
+ subject do
+ described_class.new(resource, current_user: user).remove_wip_path
+ end
+
+ context 'when merge request enabled and has permission' do
+ it 'has remove_wip_path' do
+ allow(project).to receive(:merge_requests_enabled?) { true }
+ project.team << [user, :master]
+
+ is_expected
+ .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/remove_wip")
+ end
+ end
+
+ context 'when has no permission' do
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#target_branch_commits_path' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .target_branch_commits_path
+ end
+
+ context 'when target branch exists' do
+ it 'returns path' do
+ allow(resource).to receive(:target_branch_exists?) { true }
+
+ is_expected
+ .to eq("/#{resource.target_project.full_path}/commits/#{resource.target_branch}")
+ end
+ end
+
+ context 'when target branch does not exist' do
+ it 'returns nil' do
+ allow(resource).to receive(:target_branch_exists?) { false }
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#target_branch_tree_path' do
+ subject do
+ described_class.new(resource, current_user: user)
+ .target_branch_tree_path
+ end
+
+ context 'when target branch exists' do
+ it 'returns path' do
+ allow(resource).to receive(:target_branch_exists?) { true }
+
+ is_expected
+ .to eq("/#{resource.target_project.full_path}/tree/#{resource.target_branch}")
+ end
+ end
+
+ context 'when target branch does not exist' do
+ it 'returns nil' do
+ allow(resource).to receive(:target_branch_exists?) { false }
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#source_branch_path' do
+ subject do
+ described_class.new(resource, current_user: user).source_branch_path
+ end
+
+ context 'when source branch exists' do
+ it 'returns path' do
+ allow(resource).to receive(:source_branch_exists?) { true }
+
+ is_expected
+ .to eq("/#{resource.source_project.full_path}/branches/#{resource.source_branch}")
+ end
+ end
+
+ context 'when source branch does not exist' do
+ it 'returns nil' do
+ allow(resource).to receive(:source_branch_exists?) { false }
+
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#source_branch_with_namespace_link' do
+ subject do
+ described_class.new(resource, current_user: user).source_branch_with_namespace_link
+ end
+
+ it 'returns link' do
+ allow(resource).to receive(:source_branch_exists?) { true }
+
+ is_expected
+ .to eq("<a href=\"/#{resource.source_project.full_path}/tree/#{resource.source_branch}\">#{resource.source_branch}</a>")
+ end
+ end
+end
diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
index 6443f86b6a1..2cc0076d695 100644
--- a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
+++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::Settings::DeployKeysPresenter do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:deploy_key) { create(:deploy_key, public: true) }
@@ -51,10 +51,6 @@ describe Projects::Settings::DeployKeysPresenter do
expect(presenter.available_project_keys).not_to be_empty
end
- it 'returns false if any available_project_keys are enabled' do
- expect(presenter.any_available_project_keys_enabled?).to eq(true)
- end
-
it 'returns the available_project_keys size' do
expect(presenter.available_project_keys_size).to eq(1)
end
diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb
index c8eacb38e6f..6bd17697c33 100644
--- a/spec/requests/api/access_requests_spec.rb
+++ b/spec/requests/api/access_requests_spec.rb
@@ -7,7 +7,7 @@ describe API::AccessRequests do
let(:stranger) { create(:user) }
let(:project) do
- create(:empty_project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
+ create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
project.team << [developer, :developer]
project.team << [master, :master]
project.request_access(access_requester)
diff --git a/spec/requests/api/api_internal_helpers_spec.rb b/spec/requests/api/api_internal_helpers_spec.rb
deleted file mode 100644
index f5265ea60ff..00000000000
--- a/spec/requests/api/api_internal_helpers_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require 'spec_helper'
-
-describe ::API::Helpers::InternalHelpers do
- include ::API::Helpers::InternalHelpers
-
- describe '.clean_project_path' do
- project = 'namespace/project'
- namespaced = File.join('namespace2', project)
-
- {
- File.join(Dir.pwd, project) => project,
- File.join(Dir.pwd, namespaced) => namespaced,
- project => project,
- namespaced => namespaced,
- project + '.git' => project,
- namespaced + '.git' => namespaced,
- "/" + project => project,
- "/" + namespaced => namespaced,
- }.each do |project_path, expected|
- context project_path do
- # Relative and absolute storage paths, with and without trailing /
- ['.', './', Dir.pwd, Dir.pwd + '/'].each do |storage_path|
- context "storage path is #{storage_path}" do
- subject { clean_project_path(project_path, [{ 'path' => storage_path }]) }
-
- it { is_expected.to eq(expected) }
- end
- end
- end
- end
- end
-end
diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb
index bbdef0aeb1b..1dd9f3f6ddc 100644
--- a/spec/requests/api/award_emoji_spec.rb
+++ b/spec/requests/api/award_emoji_spec.rb
@@ -2,14 +2,16 @@ require 'spec_helper'
describe API::AwardEmoji do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let!(:award_emoji) { create(:award_emoji, awardable: issue, user: user) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request, user: user) }
let!(:note) { create(:note, project: project, noteable: issue) }
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do
context 'on an issue' do
diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb
index c27db716ef8..43b381c2219 100644
--- a/spec/requests/api/boards_spec.rb
+++ b/spec/requests/api/boards_spec.rb
@@ -6,7 +6,7 @@ describe API::Boards do
let(:non_member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:user, :admin) }
- let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) }
+ let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
let!(:dev_label) do
create(:label, title: 'Development', color: '#FFAABB', project: project)
@@ -188,7 +188,7 @@ describe API::Boards do
context "when the user is project owner" do
let(:owner) { create(:user) }
- let(:project) { create(:empty_project, namespace: owner.namespace) }
+ let(:project) { create(:project, namespace: owner.namespace) }
it "deletes the list if an admin requests it" do
delete api("#{base_url}/#{dev_list.id}", owner)
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 7eaa89837c8..5a2e1b2cf2d 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -1,25 +1,31 @@
require 'spec_helper'
-require 'mime/types'
describe API::Branches do
let(:user) { create(:user) }
- let!(:project) { create(:project, :repository, creator: user) }
- let!(:master) { create(:project_member, :master, user: user, project: project) }
- let(:guest) { create(:user).tap { |u| create(:project_member, :guest, user: u, project: project) } }
- let!(:branch_name) { 'feature' }
- let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
- let(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master")[:branch] }
+ let(:guest) { create(:user).tap { |u| project.add_guest(u) } }
+ let(:project) { create(:project, :repository, creator: user, path: 'my.project') }
+ let(:branch_name) { 'feature' }
+ let(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
+ let(:branch_with_dot) { project.repository.find_branch('ends-with.json') }
+ let(:branch_with_slash) { project.repository.find_branch('improve/awesome') }
+
+ let(:project_id) { project.id }
+ let(:current_user) { nil }
+
+ before do
+ project.add_master(user)
+ end
describe "GET /projects/:id/repository/branches" do
- let(:route) { "/projects/#{project.id}/repository/branches" }
+ let(:route) { "/projects/#{project_id}/repository/branches" }
shared_examples_for 'repository branches' do
it 'returns the repository branches' do
get api(route, current_user), per_page: 100
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branches')
expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
branch_names = json_response.map { |x| x['name'] }
expect(branch_names).to match_array(project.repository.branch_names)
end
@@ -34,10 +40,9 @@ describe API::Branches do
end
context 'when unauthenticated', 'and project is public' do
- it_behaves_like 'repository branches' do
- let(:project) { create(:project, :public, :repository) }
- let(:current_user) { nil }
- end
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'repository branches'
end
context 'when unauthenticated', 'and project is private' do
@@ -47,9 +52,15 @@ describe API::Branches do
end
end
- context 'when authenticated', 'as a developer' do
- it_behaves_like 'repository branches' do
- let(:current_user) { user }
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'repository branches'
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository branches'
end
end
@@ -61,31 +72,15 @@ describe API::Branches do
end
describe "GET /projects/:id/repository/branches/:branch" do
- let(:route) { "/projects/#{project.id}/repository/branches/#{branch_name}" }
+ let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}" }
- shared_examples_for 'repository branch' do |merged: false|
+ shared_examples_for 'repository branch' do
it 'returns the repository branch' do
get api(route, current_user)
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['merged']).to eq(merged)
- expect(json_response['protected']).to eq(false)
- expect(json_response['developers_can_push']).to eq(false)
- expect(json_response['developers_can_merge']).to eq(false)
-
- json_commit = json_response['commit']
- expect(json_commit['id']).to eq(branch_sha)
- expect(json_commit).to have_key('short_id')
- expect(json_commit).to have_key('title')
- expect(json_commit).to have_key('message')
- expect(json_commit).to have_key('author_name')
- expect(json_commit).to have_key('author_email')
- expect(json_commit).to have_key('authored_date')
- expect(json_commit).to have_key('committer_name')
- expect(json_commit).to have_key('committer_email')
- expect(json_commit).to have_key('committed_date')
- expect(json_commit).to have_key('parent_ids')
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
end
context 'when branch does not exist' do
@@ -107,10 +102,9 @@ describe API::Branches do
end
context 'when unauthenticated', 'and project is public' do
- it_behaves_like 'repository branch' do
- let(:project) { create(:project, :public, :repository) }
- let(:current_user) { nil }
- end
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'repository branch'
end
context 'when unauthenticated', 'and project is private' do
@@ -120,22 +114,41 @@ describe API::Branches do
end
end
- context 'when authenticated', 'as a developer' do
+ context 'when authenticated', 'as a master' do
let(:current_user) { user }
+
it_behaves_like 'repository branch'
context 'when branch contains a dot' do
let(:branch_name) { branch_with_dot.name }
- let(:branch_sha) { project.commit('master').sha }
it_behaves_like 'repository branch'
end
- context 'when branch is merged' do
- let(:branch_name) { 'merge-test' }
- let(:branch_sha) { project.commit('merge-test').sha }
+ context 'when branch contains a slash' do
+ let(:branch_name) { branch_with_slash.name }
- it_behaves_like 'repository branch', merged: true
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ end
+ end
+
+ context 'when branch contains an escaped slash' do
+ let(:branch_name) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'repository branch'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository branch'
+
+ context 'when branch contains a dot' do
+ let(:branch_name) { branch_with_dot.name }
+
+ it_behaves_like 'repository branch'
+ end
end
end
@@ -147,281 +160,348 @@ describe API::Branches do
end
describe 'PUT /projects/:id/repository/branches/:branch/protect' do
- context "when a protected branch doesn't already exist" do
- it 'protects a single branch' do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user)
+ let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}/protect" }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['commit']['id']).to eq(branch_sha)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(false)
- expect(json_response['developers_can_merge']).to eq(false)
- end
-
- it "protects a single branch with dots in the name" do
- put api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}/protect", user)
+ shared_examples_for 'repository new protected branch' do
+ it 'protects a single branch' do
+ put api(route, current_user)
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_with_dot.name)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
expect(json_response['protected']).to eq(true)
end
it 'protects a single branch and developers can push' do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user),
- developers_can_push: true
+ put api(route, current_user), developers_can_push: true
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['commit']['id']).to eq(branch_sha)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
expect(json_response['protected']).to eq(true)
expect(json_response['developers_can_push']).to eq(true)
expect(json_response['developers_can_merge']).to eq(false)
end
it 'protects a single branch and developers can merge' do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user),
- developers_can_merge: true
+ put api(route, current_user), developers_can_merge: true
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['commit']['id']).to eq(branch_sha)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
expect(json_response['protected']).to eq(true)
expect(json_response['developers_can_push']).to eq(false)
expect(json_response['developers_can_merge']).to eq(true)
end
it 'protects a single branch and developers can push and merge' do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user),
- developers_can_push: true, developers_can_merge: true
+ put api(route, current_user), developers_can_push: true, developers_can_merge: true
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['commit']['id']).to eq(branch_sha)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
expect(json_response['protected']).to eq(true)
expect(json_response['developers_can_push']).to eq(true)
expect(json_response['developers_can_merge']).to eq(true)
end
+
+ context 'when branch does not exist' do
+ let(:branch_name) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user) }
+ let(:message) { '404 Branch Not Found' }
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { put api(route, current_user) }
+ end
+ end
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { put api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
end
- context 'for an existing protected branch' do
- before do
- project.repository.add_branch(user, protected_branch.name, 'master')
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { put api(route, guest) }
end
+ end
+
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
- context "when developers can push and merge" do
- let(:protected_branch) { create(:protected_branch, :developers_can_push, :developers_can_merge, project: project, name: 'protected_branch') }
+ context "when a protected branch doesn't already exist" do
+ it_behaves_like 'repository new protected branch'
- it 'updates that a developer cannot push or merge' do
- put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
- developers_can_push: false, developers_can_merge: false
+ context 'when branch contains a dot' do
+ let(:branch_name) { branch_with_dot.name }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(protected_branch.name)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(false)
- expect(json_response['developers_can_merge']).to eq(false)
+ it_behaves_like 'repository new protected branch'
end
- it "doesn't result in 0 access levels when 'developers_can_push' is switched off" do
- put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
- developers_can_push: false
+ context 'when branch contains a slash' do
+ let(:branch_name) { branch_with_slash.name }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(protected_branch.name)
- expect(protected_branch.reload.push_access_levels.first).to be_present
- expect(protected_branch.reload.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER)
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user) }
+ end
end
- it "doesn't result in 0 access levels when 'developers_can_merge' is switched off" do
- put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
- developers_can_merge: false
+ context 'when branch contains an escaped slash' do
+ let(:branch_name) { CGI.escape(branch_with_slash.name) }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(protected_branch.name)
- expect(protected_branch.reload.merge_access_levels.first).to be_present
- expect(protected_branch.reload.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER)
+ it_behaves_like 'repository new protected branch'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository new protected branch'
+
+ context 'when branch contains a dot' do
+ let(:branch_name) { branch_with_dot.name }
+
+ it_behaves_like 'repository new protected branch'
+ end
end
end
- context "when developers cannot push or merge" do
- let(:protected_branch) { create(:protected_branch, project: project, name: 'protected_branch') }
+ context 'when protected branch already exists' do
+ before do
+ project.repository.add_branch(user, protected_branch.name, 'master')
+ end
- it 'updates that a developer can push and merge' do
- put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
- developers_can_push: true, developers_can_merge: true
+ context 'when developers can push and merge' do
+ let(:protected_branch) { create(:protected_branch, :developers_can_push, :developers_can_merge, project: project, name: 'protected_branch') }
+
+ it 'updates that a developer cannot push or merge' do
+ put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
+ developers_can_push: false, developers_can_merge: false
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(protected_branch.name)
+ expect(json_response['protected']).to eq(true)
+ expect(json_response['developers_can_push']).to eq(false)
+ expect(json_response['developers_can_merge']).to eq(false)
+ expect(protected_branch.reload.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER)
+ expect(protected_branch.reload.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER)
+ end
+ end
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(protected_branch.name)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(true)
- expect(json_response['developers_can_merge']).to eq(true)
+ context 'when developers cannot push or merge' do
+ let(:protected_branch) { create(:protected_branch, project: project, name: 'protected_branch') }
+
+ it 'updates that a developer can push and merge' do
+ put api("/projects/#{project.id}/repository/branches/#{protected_branch.name}/protect", user),
+ developers_can_push: true, developers_can_merge: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(protected_branch.name)
+ expect(json_response['protected']).to eq(true)
+ expect(json_response['developers_can_push']).to eq(true)
+ expect(json_response['developers_can_merge']).to eq(true)
+ end
end
end
end
+ end
- context "multiple API calls" do
- it "returns success when `protect` is called twice" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user)
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user)
+ describe 'PUT /projects/:id/repository/branches/:branch/unprotect' do
+ let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}/unprotect" }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(false)
- expect(json_response['developers_can_merge']).to eq(false)
+ shared_examples_for 'repository unprotected branch' do
+ it 'unprotects a single branch' do
+ put api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq(CGI.unescape(branch_name))
+ expect(json_response['protected']).to eq(false)
end
- it "returns success when `protect` is called twice with `developers_can_push` turned on" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user), developers_can_push: true
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user), developers_can_push: true
+ context 'when branch does not exist' do
+ let(:branch_name) { 'unknown' }
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(true)
- expect(json_response['developers_can_merge']).to eq(false)
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user) }
+ let(:message) { '404 Branch Not Found' }
+ end
end
- it "returns success when `protect` is called twice with `developers_can_merge` turned on" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user), developers_can_merge: true
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user), developers_can_merge: true
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['protected']).to eq(true)
- expect(json_response['developers_can_push']).to eq(false)
- expect(json_response['developers_can_merge']).to eq(true)
+ it_behaves_like '403 response' do
+ let(:request) { put api(route, current_user) }
+ end
end
end
- it "returns a 404 error if branch not found" do
- put api("/projects/#{project.id}/repository/branches/unknown/protect", user)
- expect(response).to have_http_status(404)
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { put api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
end
- it "returns a 403 error if guest" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", guest)
- expect(response).to have_http_status(403)
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { put api(route, guest) }
+ end
end
- end
- describe "PUT /projects/:id/repository/branches/:branch/unprotect" do
- it "unprotects a single branch" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user)
- expect(response).to have_http_status(200)
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ context "when a protected branch doesn't already exist" do
+ it_behaves_like 'repository unprotected branch'
+
+ context 'when branch contains a dot' do
+ let(:branch_name) { branch_with_dot.name }
+
+ it_behaves_like 'repository unprotected branch'
+ end
+
+ context 'when branch contains a slash' do
+ let(:branch_name) { branch_with_slash.name }
+
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user) }
+ end
+ end
+
+ context 'when branch contains an escaped slash' do
+ let(:branch_name) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'repository unprotected branch'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository unprotected branch'
+
+ context 'when branch contains a dot' do
+ let(:branch_name) { branch_with_dot.name }
- expect(json_response['name']).to eq(branch_name)
- expect(json_response['commit']['id']).to eq(branch_sha)
- expect(json_response['protected']).to eq(false)
+ it_behaves_like 'repository unprotected branch'
+ end
+ end
+ end
end
+ end
+
+ describe 'POST /projects/:id/repository/branches' do
+ let(:route) { "/projects/#{project_id}/repository/branches" }
+
+ shared_examples_for 'repository new branch' do
+ it 'creates a new branch' do
+ post api(route, current_user), branch: 'feature1', ref: branch_sha
- it "update branches with dots in branch name" do
- put api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}/unprotect", user)
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/branch')
+ expect(json_response['name']).to eq('feature1')
+ expect(json_response['commit']['id']).to eq(branch_sha)
+ end
- expect(response).to have_http_status(200)
- expect(json_response['name']).to eq(branch_with_dot.name)
- expect(json_response['protected']).to eq(false)
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, current_user) }
+ end
+ end
end
- it "returns success when unprotect branch" do
- put api("/projects/#{project.id}/repository/branches/unknown/unprotect", user)
- expect(response).to have_http_status(404)
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
end
- it "returns success when unprotect branch again" do
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user)
- put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user)
- expect(response).to have_http_status(200)
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, guest) }
+ end
end
- end
- describe "POST /projects/:id/repository/branches" do
- it "creates a new branch" do
- post api("/projects/#{project.id}/repository/branches", user),
- branch: 'feature1',
- ref: branch_sha
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
- expect(response).to have_http_status(201)
+ context "when a protected branch doesn't already exist" do
+ it_behaves_like 'repository new branch'
- expect(json_response['name']).to eq('feature1')
- expect(json_response['commit']['id']).to eq(branch_sha)
- end
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
- it "denies for user without push access" do
- post api("/projects/#{project.id}/repository/branches", guest),
- branch: branch_name,
- ref: branch_sha
- expect(response).to have_http_status(403)
+ it_behaves_like 'repository new branch'
+ end
+ end
end
it 'returns 400 if branch name is invalid' do
- post api("/projects/#{project.id}/repository/branches", user),
- branch: 'new design',
- ref: branch_sha
- expect(response).to have_http_status(400)
+ post api(route, user), branch: 'new design', ref: branch_sha
+
+ expect(response).to have_gitlab_http_status(400)
expect(json_response['message']).to eq('Branch name is invalid')
end
it 'returns 400 if branch already exists' do
- post api("/projects/#{project.id}/repository/branches", user),
- branch: 'new_design1',
- ref: branch_sha
- expect(response).to have_http_status(201)
-
- post api("/projects/#{project.id}/repository/branches", user),
- branch: 'new_design1',
- ref: branch_sha
- expect(response).to have_http_status(400)
+ post api(route, user), branch: 'new_design1', ref: branch_sha
+
+ expect(response).to have_gitlab_http_status(201)
+
+ post api(route, user), branch: 'new_design1', ref: branch_sha
+
+ expect(response).to have_gitlab_http_status(400)
expect(json_response['message']).to eq('Branch already exists')
end
it 'returns 400 if ref name is invalid' do
- post api("/projects/#{project.id}/repository/branches", user),
- branch: 'new_design3',
- ref: 'foo'
- expect(response).to have_http_status(400)
+ post api(route, user), branch: 'new_design3', ref: 'foo'
+
+ expect(response).to have_gitlab_http_status(400)
expect(json_response['message']).to eq('Invalid reference name')
end
end
- describe "DELETE /projects/:id/repository/branches/:branch" do
+ describe 'DELETE /projects/:id/repository/branches/:branch' do
before do
allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true)
end
- it "removes branch" do
+ it 'removes branch' do
delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user)
- expect(response).to have_http_status(204)
+ expect(response).to have_gitlab_http_status(204)
end
- it "removes a branch with dots in the branch name" do
+ it 'removes a branch with dots in the branch name' do
delete api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}", user)
- expect(response).to have_http_status(204)
+ expect(response).to have_gitlab_http_status(204)
end
it 'returns 404 if branch not exists' do
delete api("/projects/#{project.id}/repository/branches/foobar", user)
- expect(response).to have_http_status(404)
- end
- it "removes protected branch" do
- create(:protected_branch, project: project, name: branch_name)
- delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user)
- expect(response).to have_http_status(405)
- expect(json_response['message']).to eq('Protected branch cant be removed')
- end
-
- it "does not remove HEAD branch" do
- delete api("/projects/#{project.id}/repository/branches/master", user)
- expect(response).to have_http_status(405)
- expect(json_response['message']).to eq('Cannot remove HEAD branch')
+ expect(response).to have_gitlab_http_status(404)
end
end
- describe "DELETE /projects/:id/repository/merged_branches" do
+ describe 'DELETE /projects/:id/repository/merged_branches' do
before do
allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true)
end
@@ -429,13 +509,14 @@ describe API::Branches do
it 'returns 202 with json body' do
delete api("/projects/#{project.id}/repository/merged_branches", user)
- expect(response).to have_http_status(202)
+ expect(response).to have_gitlab_http_status(202)
expect(json_response['message']).to eql('202 Accepted')
end
it 'returns a 403 error if guest' do
delete api("/projects/#{project.id}/repository/merged_branches", guest)
- expect(response).to have_http_status(403)
+
+ expect(response).to have_gitlab_http_status(403)
end
end
end
diff --git a/spec/requests/api/circuit_breakers_spec.rb b/spec/requests/api/circuit_breakers_spec.rb
new file mode 100644
index 00000000000..76521e55994
--- /dev/null
+++ b/spec/requests/api/circuit_breakers_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe API::CircuitBreakers do
+ let(:user) { create(:user) }
+ let(:admin) { create(:admin) }
+
+ describe 'GET circuit_breakers/repository_storage' do
+ it 'returns a 401 for anonymous users' do
+ get api('/circuit_breakers/repository_storage')
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns a 403 for users' do
+ get api('/circuit_breakers/repository_storage', user)
+
+ expect(response).to have_http_status(403)
+ end
+
+ it 'returns an Array of storages' do
+ expect(Gitlab::Git::Storage::Health).to receive(:for_all_storages) do
+ [Gitlab::Git::Storage::Health.new('broken', [{ name: 'prefix:broken:web01', failure_count: 4 }])]
+ end
+
+ get api('/circuit_breakers/repository_storage', admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.first['storage_name']).to eq('broken')
+ expect(json_response.first['failing_on_hosts']).to eq(['web01'])
+ expect(json_response.first['total_failures']).to eq(4)
+ end
+
+ describe 'GET circuit_breakers/repository_storage/failing' do
+ it 'returns an array of failing storages' do
+ expect(Gitlab::Git::Storage::Health).to receive(:for_failing_storages) do
+ [Gitlab::Git::Storage::Health.new('broken', [{ name: 'prefix:broken:web01', failure_count: 4 }])]
+ end
+
+ get api('/circuit_breakers/repository_storage/failing', admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_kind_of(Array)
+ end
+ end
+ end
+
+ describe 'DELETE circuit_breakers/repository_storage' do
+ it 'clears all circuit_breakers' do
+ expect(Gitlab::Git::Storage::CircuitBreaker).to receive(:reset_all!)
+
+ delete api('/circuit_breakers/repository_storage', admin)
+
+ expect(response).to have_http_status(204)
+ end
+ end
+end
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index 1233cdc64c4..3c02e6302b4 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -16,8 +16,8 @@ describe API::CommitStatuses do
let(:get_url) { "/projects/#{project.id}/repository/commits/#{sha}/statuses" }
context 'ci commit exists' do
- let!(:master) { project.pipelines.create(sha: commit.id, ref: 'master') }
- let!(:develop) { project.pipelines.create(sha: commit.id, ref: 'develop') }
+ let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master') }
+ let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop') }
context "reporter user" do
let(:statuses_id) { json_response.map { |status| status['id'] } }
@@ -26,15 +26,17 @@ describe API::CommitStatuses do
create(:commit_status, { pipeline: commit, ref: commit.ref }.merge(opts))
end
- let!(:status1) { create_status(master, status: 'running') }
- let!(:status2) { create_status(master, name: 'coverage', status: 'pending') }
+ let!(:status1) { create_status(master, status: 'running', retried: true) }
+ let!(:status2) { create_status(master, name: 'coverage', status: 'pending', retried: true) }
let!(:status3) { create_status(develop, status: 'running', allow_failure: true) }
let!(:status4) { create_status(master, name: 'coverage', status: 'success') }
let!(:status5) { create_status(develop, name: 'coverage', status: 'success') }
let!(:status6) { create_status(master, status: 'success') }
context 'latest commit statuses' do
- before { get api(get_url, reporter) }
+ before do
+ get api(get_url, reporter)
+ end
it 'returns latest commit statuses' do
expect(response).to have_http_status(200)
@@ -42,13 +44,15 @@ describe API::CommitStatuses do
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(statuses_id).to contain_exactly(status3.id, status4.id, status5.id, status6.id)
- json_response.sort_by!{ |status| status['id'] }
- expect(json_response.map{ |status| status['allow_failure'] }).to eq([true, false, false, false])
+ json_response.sort_by! { |status| status['id'] }
+ expect(json_response.map { |status| status['allow_failure'] }).to eq([true, false, false, false])
end
end
context 'all commit statuses' do
- before { get api(get_url, reporter), all: 1 }
+ before do
+ get api(get_url, reporter), all: 1
+ end
it 'returns all commit statuses' do
expect(response).to have_http_status(200)
@@ -61,7 +65,9 @@ describe API::CommitStatuses do
end
context 'latest commit statuses for specific ref' do
- before { get api(get_url, reporter), ref: 'develop' }
+ before do
+ get api(get_url, reporter), ref: 'develop'
+ end
it 'returns latest commit statuses for specific ref' do
expect(response).to have_http_status(200)
@@ -72,7 +78,9 @@ describe API::CommitStatuses do
end
context 'latest commit statues for specific name' do
- before { get api(get_url, reporter), name: 'coverage' }
+ before do
+ get api(get_url, reporter), name: 'coverage'
+ end
it 'return latest commit statuses for specific name' do
expect(response).to have_http_status(200)
@@ -85,7 +93,9 @@ describe API::CommitStatuses do
end
context 'ci commit does not exist' do
- before { get api(get_url, reporter) }
+ before do
+ get api(get_url, reporter)
+ end
it 'returns empty array' do
expect(response.status).to eq 200
@@ -95,7 +105,9 @@ describe API::CommitStatuses do
end
context "guest user" do
- before { get api(get_url, guest) }
+ before do
+ get api(get_url, guest)
+ end
it "does not return project commits" do
expect(response).to have_http_status(403)
@@ -103,7 +115,9 @@ describe API::CommitStatuses do
end
context "unauthorized user" do
- before { get api(get_url) }
+ before do
+ get api(get_url)
+ end
it "does not return project commits" do
expect(response).to have_http_status(401)
@@ -150,25 +164,40 @@ describe API::CommitStatuses do
context 'with all optional parameters' do
context 'when creating a commit status' do
- it 'creates commit status' do
+ subject do
post api(post_url, developer), {
state: 'success',
context: 'coverage',
- ref: 'develop',
+ ref: 'master',
description: 'test',
coverage: 80.0,
target_url: 'http://gitlab.com/status'
}
+ end
+
+ it 'creates commit status' do
+ subject
expect(response).to have_http_status(201)
expect(json_response['sha']).to eq(commit.id)
expect(json_response['status']).to eq('success')
expect(json_response['name']).to eq('coverage')
- expect(json_response['ref']).to eq('develop')
+ expect(json_response['ref']).to eq('master')
expect(json_response['coverage']).to eq(80.0)
expect(json_response['description']).to eq('test')
expect(json_response['target_url']).to eq('http://gitlab.com/status')
end
+
+ context 'when merge request exists for given branch' do
+ let!(:merge_request) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'develop') }
+
+ it 'sets head pipeline' do
+ subject
+
+ expect(response).to have_http_status(201)
+ expect(merge_request.reload.head_pipeline).not_to be_nil
+ end
+ end
end
context 'when updatig a commit status' do
@@ -176,7 +205,7 @@ describe API::CommitStatuses do
post api(post_url, developer), {
state: 'running',
context: 'coverage',
- ref: 'develop',
+ ref: 'master',
description: 'coverage test',
coverage: 0.0,
target_url: 'http://gitlab.com/status'
@@ -185,7 +214,7 @@ describe API::CommitStatuses do
post api(post_url, developer), {
state: 'success',
name: 'coverage',
- ref: 'develop',
+ ref: 'master',
description: 'new description',
coverage: 90.0
}
@@ -196,7 +225,7 @@ describe API::CommitStatuses do
expect(json_response['sha']).to eq(commit.id)
expect(json_response['status']).to eq('success')
expect(json_response['name']).to eq('coverage')
- expect(json_response['ref']).to eq('develop')
+ expect(json_response['ref']).to eq('master')
expect(json_response['coverage']).to eq(90.0)
expect(json_response['description']).to eq('new description')
expect(json_response['target_url']).to eq('http://gitlab.com/status')
@@ -208,8 +237,32 @@ describe API::CommitStatuses do
end
end
+ context 'when retrying a commit status' do
+ before do
+ post api(post_url, developer),
+ { state: 'failed', name: 'test', ref: 'master' }
+
+ post api(post_url, developer),
+ { state: 'success', name: 'test', ref: 'master' }
+ end
+
+ it 'correctly posts a new commit status' do
+ expect(response).to have_http_status(201)
+ expect(json_response['sha']).to eq(commit.id)
+ expect(json_response['status']).to eq('success')
+ end
+
+ it 'retries a commit status' do
+ expect(CommitStatus.count).to eq 2
+ expect(CommitStatus.first).to be_retried
+ expect(CommitStatus.last.pipeline).to be_success
+ end
+ end
+
context 'when status is invalid' do
- before { post api(post_url, developer), state: 'invalid' }
+ before do
+ post api(post_url, developer), state: 'invalid'
+ end
it 'does not create commit status' do
expect(response).to have_http_status(400)
@@ -217,7 +270,9 @@ describe API::CommitStatuses do
end
context 'when request without a state made' do
- before { post api(post_url, developer) }
+ before do
+ post api(post_url, developer)
+ end
it 'does not create commit status' do
expect(response).to have_http_status(400)
@@ -226,7 +281,10 @@ describe API::CommitStatuses do
context 'when commit SHA is invalid' do
let(:sha) { 'invalid_sha' }
- before { post api(post_url, developer), state: 'running' }
+
+ before do
+ post api(post_url, developer), state: 'running'
+ end
it 'returns not found error' do
expect(response).to have_http_status(404)
@@ -248,7 +306,9 @@ describe API::CommitStatuses do
end
context 'reporter user' do
- before { post api(post_url, reporter), state: 'running' }
+ before do
+ post api(post_url, reporter), state: 'running'
+ end
it 'does not create commit status' do
expect(response).to have_http_status(403)
@@ -256,7 +316,9 @@ describe API::CommitStatuses do
end
context 'guest user' do
- before { post api(post_url, guest), state: 'running' }
+ before do
+ post api(post_url, guest), state: 'running'
+ end
it 'does not create commit status' do
expect(response).to have_http_status(403)
@@ -264,7 +326,9 @@ describe API::CommitStatuses do
end
context 'unauthorized user' do
- before { post api(post_url) }
+ before do
+ post api(post_url)
+ end
it 'does not create commit status' do
expect(response).to have_http_status(401)
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index 0b0e4c2b112..dafe3f466a2 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -3,26 +3,29 @@ require 'mime/types'
describe API::Commits do
let(:user) { create(:user) }
- let(:user2) { create(:user) }
- let!(:project) { create(:project, :repository, creator: user, namespace: user.namespace) }
- let!(:master) { create(:project_member, :master, user: user, project: project) }
- let!(:guest) { create(:project_member, :guest, user: user2, project: project) }
- let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') }
- let!(:another_note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'another comment on a commit') }
+ let(:guest) { create(:user).tap { |u| project.add_guest(u) } }
+ let(:project) { create(:project, :repository, creator: user, path: 'my.project') }
+ let(:branch_with_dot) { project.repository.find_branch('ends-with.json') }
+ let(:branch_with_slash) { project.repository.find_branch('improve/awesome') }
- before { project.team << [user, :reporter] }
+ let(:project_id) { project.id }
+ let(:current_user) { nil }
- describe "List repository commits" do
- context "authorized user" do
- before { project.team << [user2, :reporter] }
+ before do
+ project.add_master(user)
+ end
+
+ describe 'GET /projects/:id/repository/commits' do
+ let(:route) { "/projects/#{project_id}/repository/commits" }
+ shared_examples_for 'project commits' do
it "returns project commits" do
commit = project.repository.commit
- get api("/projects/#{project.id}/repository/commits", user)
+ get api(route, current_user)
expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
+ expect(response).to match_response_schema('public_api/v4/commits')
expect(json_response.first['id']).to eq(commit.id)
expect(json_response.first['committer_name']).to eq(commit.committer_name)
expect(json_response.first['committer_email']).to eq(commit.committer_email)
@@ -31,7 +34,7 @@ describe API::Commits do
it 'include correct pagination headers' do
commit_count = project.repository.count_commits(ref: 'master').to_s
- get api("/projects/#{project.id}/repository/commits", user)
+ get api(route, current_user)
expect(response).to include_pagination_headers
expect(response.headers['X-Total']).to eq(commit_count)
@@ -39,149 +42,161 @@ describe API::Commits do
end
end
- context "unauthorized user" do
- it "does not return project commits" do
- get api("/projects/#{project.id}/repository/commits")
- expect(response).to have_http_status(401)
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'project commits'
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
end
end
- context "since optional parameter" do
- it "returns project commits since provided parameter" do
- commits = project.repository.commits("master")
- after = commits.second.created_at
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
- get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user)
+ it_behaves_like 'project commits'
- expect(json_response.size).to eq 2
- expect(json_response.first["id"]).to eq(commits.first.id)
- expect(json_response.second["id"]).to eq(commits.second.id)
- end
+ context "since optional parameter" do
+ it "returns project commits since provided parameter" do
+ commits = project.repository.commits("master")
+ after = commits.second.created_at
- it 'include correct pagination headers' do
- commits = project.repository.commits("master")
- after = commits.second.created_at
- commit_count = project.repository.count_commits(ref: 'master', after: after).to_s
+ get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user)
- get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user)
+ expect(json_response.size).to eq 2
+ expect(json_response.first["id"]).to eq(commits.first.id)
+ expect(json_response.second["id"]).to eq(commits.second.id)
+ end
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- expect(response.headers['X-Page']).to eql('1')
+ it 'include correct pagination headers' do
+ commits = project.repository.commits("master")
+ after = commits.second.created_at
+ commit_count = project.repository.count_commits(ref: 'master', after: after).to_s
+
+ get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user)
+
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response.headers['X-Page']).to eql('1')
+ end
end
- end
- context "until optional parameter" do
- it "returns project commits until provided parameter" do
- commits = project.repository.commits("master")
- before = commits.second.created_at
+ context "until optional parameter" do
+ it "returns project commits until provided parameter" do
+ commits = project.repository.commits("master")
+ before = commits.second.created_at
+
+ get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user)
- get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user)
+ if commits.size >= 20
+ expect(json_response.size).to eq(20)
+ else
+ expect(json_response.size).to eq(commits.size - 1)
+ end
- if commits.size >= 20
- expect(json_response.size).to eq(20)
- else
- expect(json_response.size).to eq(commits.size - 1)
+ expect(json_response.first["id"]).to eq(commits.second.id)
+ expect(json_response.second["id"]).to eq(commits.third.id)
end
- expect(json_response.first["id"]).to eq(commits.second.id)
- expect(json_response.second["id"]).to eq(commits.third.id)
- end
+ it 'include correct pagination headers' do
+ commits = project.repository.commits("master")
+ before = commits.second.created_at
+ commit_count = project.repository.count_commits(ref: 'master', before: before).to_s
- it 'include correct pagination headers' do
- commits = project.repository.commits("master")
- before = commits.second.created_at
- commit_count = project.repository.count_commits(ref: 'master', before: before).to_s
+ get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user)
- get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user)
-
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- expect(response.headers['X-Page']).to eql('1')
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response.headers['X-Page']).to eql('1')
+ end
end
- end
- context "invalid xmlschema date parameters" do
- it "returns an invalid parameter error message" do
- get api("/projects/#{project.id}/repository/commits?since=invalid-date", user)
+ context "invalid xmlschema date parameters" do
+ it "returns an invalid parameter error message" do
+ get api("/projects/#{project_id}/repository/commits?since=invalid-date", user)
- expect(response).to have_http_status(400)
- expect(json_response['error']).to eq('since is invalid')
+ expect(response).to have_http_status(400)
+ expect(json_response['error']).to eq('since is invalid')
+ end
end
- end
- context "path optional parameter" do
- it "returns project commits matching provided path parameter" do
- path = 'files/ruby/popen.rb'
- commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
+ context "path optional parameter" do
+ it "returns project commits matching provided path parameter" do
+ path = 'files/ruby/popen.rb'
+ commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
- get api("/projects/#{project.id}/repository/commits?path=#{path}", user)
+ get api("/projects/#{project_id}/repository/commits?path=#{path}", user)
- expect(json_response.size).to eq(3)
- expect(json_response.first["id"]).to eq("570e7b2abdd848b95f2f578043fc23bd6f6fd24d")
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- end
+ expect(json_response.size).to eq(3)
+ expect(json_response.first["id"]).to eq("570e7b2abdd848b95f2f578043fc23bd6f6fd24d")
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count)
+ end
- it 'include correct pagination headers' do
- path = 'files/ruby/popen.rb'
- commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
+ it 'include correct pagination headers' do
+ path = 'files/ruby/popen.rb'
+ commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
- get api("/projects/#{project.id}/repository/commits?path=#{path}", user)
+ get api("/projects/#{project_id}/repository/commits?path=#{path}", user)
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- expect(response.headers['X-Page']).to eql('1')
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response.headers['X-Page']).to eql('1')
+ end
end
- end
- context 'with pagination params' do
- let(:page) { 1 }
- let(:per_page) { 5 }
- let(:ref_name) { 'master' }
- let!(:request) do
- get api("/projects/#{project.id}/repository/commits?page=#{page}&per_page=#{per_page}&ref_name=#{ref_name}", user)
- end
+ context 'with pagination params' do
+ let(:page) { 1 }
+ let(:per_page) { 5 }
+ let(:ref_name) { 'master' }
+ let!(:request) do
+ get api("/projects/#{project_id}/repository/commits?page=#{page}&per_page=#{per_page}&ref_name=#{ref_name}", user)
+ end
- it 'returns correct headers' do
- commit_count = project.repository.count_commits(ref: ref_name).to_s
+ it 'returns correct headers' do
+ commit_count = project.repository.count_commits(ref: ref_name).to_s
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- expect(response.headers['X-Page']).to eq('1')
- expect(response.headers['Link']).to match(/page=1&per_page=5/)
- expect(response.headers['Link']).to match(/page=2&per_page=5/)
- end
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response.headers['X-Page']).to eq('1')
+ expect(response.headers['Link']).to match(/page=1&per_page=5/)
+ expect(response.headers['Link']).to match(/page=2&per_page=5/)
+ end
- context 'viewing the first page' do
- it 'returns the first 5 commits' do
- commit = project.repository.commit
+ context 'viewing the first page' do
+ it 'returns the first 5 commits' do
+ commit = project.repository.commit
- expect(json_response.size).to eq(per_page)
- expect(json_response.first['id']).to eq(commit.id)
- expect(response.headers['X-Page']).to eq('1')
+ expect(json_response.size).to eq(per_page)
+ expect(json_response.first['id']).to eq(commit.id)
+ expect(response.headers['X-Page']).to eq('1')
+ end
end
- end
- context 'viewing the third page' do
- let(:page) { 3 }
+ context 'viewing the third page' do
+ let(:page) { 3 }
- it 'returns the third 5 commits' do
- commit = project.repository.commits('HEAD', offset: (page - 1) * per_page).first
+ it 'returns the third 5 commits' do
+ commit = project.repository.commits('HEAD', offset: (page - 1) * per_page).first
- expect(json_response.size).to eq(per_page)
- expect(json_response.first['id']).to eq(commit.id)
- expect(response.headers['X-Page']).to eq('3')
+ expect(json_response.size).to eq(per_page)
+ expect(json_response.first['id']).to eq(commit.id)
+ expect(response.headers['X-Page']).to eq('3')
+ end
end
end
end
end
describe "POST /projects/:id/repository/commits" do
- let!(:url) { "/projects/#{project.id}/repository/commits" }
+ let!(:url) { "/projects/#{project_id}/repository/commits" }
it 'returns a 403 unauthorized for user without permissions' do
- post api(url, user2)
+ post api(url, guest)
expect(response).to have_http_status(403)
end
@@ -224,7 +239,7 @@ describe API::Commits do
it 'a new file in project repo' do
post api(url, user), valid_c_params
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq(message)
expect(json_response['committer_name']).to eq(user.name)
expect(json_response['committer_email']).to eq(user.email)
@@ -450,13 +465,17 @@ describe API::Commits do
end
end
- describe "Get a single commit" do
- context "authorized user" do
- it "returns a commit by sha" do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
+ describe 'GET /projects/:id/repository/commits/:sha' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}" }
- expect(response).to have_http_status(200)
- commit = project.repository.commit
+ shared_examples_for 'ref commit' do
+ it 'returns the ref last commit' do
+ get api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/commit/detail')
expect(json_response['id']).to eq(commit.id)
expect(json_response['short_id']).to eq(commit.short_id)
expect(json_response['title']).to eq(commit.title)
@@ -471,220 +490,539 @@ describe API::Commits do
expect(json_response['stats']['additions']).to eq(commit.stats.additions)
expect(json_response['stats']['deletions']).to eq(commit.stats.deletions)
expect(json_response['stats']['total']).to eq(commit.stats.total)
+ expect(json_response['status']).to be_nil
end
- it "returns a 404 error if not found" do
- get api("/projects/#{project.id}/repository/commits/invalid_sha", user)
- expect(response).to have_http_status(404)
+ context 'when ref does not exist' do
+ let(:commit_id) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ let(:message) { '404 Commit Not Found' }
+ end
end
- it "returns nil for commit without CI" do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(200)
- expect(json_response['status']).to be_nil
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, current_user) }
+ end
end
+ end
- it "returns status for CI" do
- pipeline = project.ensure_pipeline('master', project.repository.commit.sha)
- pipeline.update(status: 'success')
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
+ it_behaves_like 'ref commit'
+ end
- expect(response).to have_http_status(200)
- expect(json_response['status']).to eq(pipeline.status)
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
end
+ end
- it "returns status for CI when pipeline is created" do
- project.ensure_pipeline('master', project.repository.commit.sha)
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
+ it_behaves_like 'ref commit'
- expect(response).to have_http_status(200)
- expect(json_response['status']).to eq("created")
+ context 'when branch contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref commit'
end
- end
- context "unauthorized user" do
- it "does not return the selected commit" do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}")
- expect(response).to have_http_status(401)
+ context 'when branch contains a slash' do
+ let(:commit_id) { branch_with_slash.name }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ end
+ end
+
+ context 'when branch contains an escaped slash' do
+ let(:commit) { project.repository.commit(branch_with_slash.name) }
+ let(:commit_id) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'ref commit'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'ref commit'
+
+ context 'when branch contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref commit'
+ end
+ end
+
+ context 'when the ref has a pipeline' do
+ let!(:pipeline) { project.pipelines.create(source: :push, ref: 'master', sha: commit.sha) }
+
+ it 'includes a "created" status' do
+ get api(route, current_user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/commit/detail')
+ expect(json_response['status']).to eq('created')
+ end
+
+ context 'when pipeline succeeds' do
+ before do
+ pipeline.update(status: 'success')
+ end
+
+ it 'includes a "success" status' do
+ get api(route, current_user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/commit/detail')
+ expect(json_response['status']).to eq('success')
+ end
+ end
end
end
end
- describe "Get the diff of a commit" do
- context "authorized user" do
- before { project.team << [user2, :reporter] }
+ describe 'GET /projects/:id/repository/commits/:sha/diff' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/diff" }
- it "returns the diff of the selected commit" do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff", user)
- expect(response).to have_http_status(200)
+ shared_examples_for 'ref diff' do
+ it 'returns the diff of the selected commit' do
+ get api(route, current_user)
- expect(json_response).to be_an Array
- expect(json_response.length).to be >= 1
- expect(json_response.first.keys).to include "diff"
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.size).to be >= 1
+ expect(json_response.first.keys).to include 'diff'
end
- it "returns a 404 error if invalid commit" do
- get api("/projects/#{project.id}/repository/commits/invalid_sha/diff", user)
- expect(response).to have_http_status(404)
+ context 'when ref does not exist' do
+ let(:commit_id) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ let(:message) { '404 Commit Not Found' }
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, current_user) }
+ end
end
end
- context "unauthorized user" do
- it "does not return the diff of the selected commit" do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff")
- expect(response).to have_http_status(401)
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'ref diff'
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
+ end
+
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'ref diff'
+
+ context 'when branch contains a dot' do
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref diff'
+ end
+
+ context 'when branch contains a slash' do
+ let(:commit_id) { branch_with_slash.name }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ end
+ end
+
+ context 'when branch contains an escaped slash' do
+ let(:commit_id) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'ref diff'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'ref diff'
+
+ context 'when branch contains a dot' do
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref diff'
+ end
end
end
end
- describe 'Get the comments of a commit' do
- context 'authorized user' do
- it 'returns merge_request comments' do
- get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user)
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.length).to eq(2)
- expect(json_response.first['note']).to eq('a comment on a commit')
- expect(json_response.first['author']['id']).to eq(user.id)
+ describe 'GET /projects/:id/repository/commits/:sha/comments' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/comments" }
+
+ shared_examples_for 'ref comments' do
+ context 'when ref exists' do
+ before do
+ create(:note_on_commit, author: user, project: project, commit_id: commit.id, note: 'a comment on a commit')
+ create(:note_on_commit, author: user, project: project, commit_id: commit.id, note: 'another comment on a commit')
+ end
+
+ it 'returns the diff of the selected commit' do
+ get api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/commit_notes')
+ expect(json_response.size).to eq(2)
+ expect(json_response.first['note']).to eq('a comment on a commit')
+ expect(json_response.first['author']['id']).to eq(user.id)
+ end
end
- it 'returns a 404 error if merge_request_id not found' do
- get api("/projects/#{project.id}/repository/commits/1234ab/comments", user)
- expect(response).to have_http_status(404)
+ context 'when ref does not exist' do
+ let(:commit_id) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ let(:message) { '404 Commit Not Found' }
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, current_user) }
+ end
end
end
- context 'unauthorized user' do
- it 'does not return the diff of the selected commit' do
- get api("/projects/#{project.id}/repository/commits/1234ab/comments")
- expect(response).to have_http_status(401)
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'ref comments'
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
+ end
+
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'ref comments'
+
+ context 'when branch contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref comments'
+ end
+
+ context 'when branch contains a slash' do
+ let(:commit) { project.repository.commit(branch_with_slash.name) }
+ let(:commit_id) { branch_with_slash.name }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ end
+ end
+
+ context 'when branch contains an escaped slash' do
+ let(:commit) { project.repository.commit(branch_with_slash.name) }
+ let(:commit_id) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'ref comments'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'ref comments'
+
+ context 'when branch contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref comments'
+ end
end
end
context 'when the commit is present on two projects' do
- let(:forked_project) { create(:project, :repository, creator: user2, namespace: user2.namespace) }
- let!(:forked_project_note) { create(:note_on_commit, author: user2, project: forked_project, commit_id: forked_project.repository.commit.id, note: 'a comment on a commit for fork') }
+ let(:forked_project) { create(:project, :repository, creator: guest, namespace: guest.namespace) }
+ let!(:forked_project_note) { create(:note_on_commit, author: guest, project: forked_project, commit_id: forked_project.repository.commit.id, note: 'a comment on a commit for fork') }
+ let(:project_id) { forked_project.id }
+ let(:commit_id) { forked_project.repository.commit.id }
it 'returns the comments for the target project' do
- get api("/projects/#{forked_project.id}/repository/commits/#{forked_project.repository.commit.id}/comments", user2)
+ get api(route, guest)
- expect(response).to have_http_status(200)
- expect(json_response.length).to eq(1)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/commit_notes')
+ expect(json_response.size).to eq(1)
expect(json_response.first['note']).to eq('a comment on a commit for fork')
- expect(json_response.first['author']['id']).to eq(user2.id)
+ expect(json_response.first['author']['id']).to eq(guest.id)
end
end
end
describe 'POST :id/repository/commits/:sha/cherry_pick' do
- let(:master_pickable_commit) { project.commit('7d3b0f7cff5f37573aea97cebfd5692ea1689924') }
+ let(:commit) { project.commit('7d3b0f7cff5f37573aea97cebfd5692ea1689924') }
+ let(:commit_id) { commit.id }
+ let(:branch) { 'master' }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/cherry_pick" }
+
+ shared_examples_for 'ref cherry-pick' do
+ context 'when ref exists' do
+ it 'cherry-picks the ref commit' do
+ post api(route, current_user), branch: branch
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/commit/basic')
+ expect(json_response['title']).to eq(commit.title)
+ expect(json_response['message']).to eq(commit.message)
+ expect(json_response['author_name']).to eq(commit.author_name)
+ expect(json_response['committer_name']).to eq(user.name)
+ end
+ end
- context 'authorized user' do
- it 'cherry picks a commit' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'master'
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(201)
- expect(json_response['title']).to eq(master_pickable_commit.title)
- expect(json_response['message']).to eq(master_pickable_commit.message)
- expect(json_response['author_name']).to eq(master_pickable_commit.author_name)
- expect(json_response['committer_name']).to eq(user.name)
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, current_user), branch: 'master' }
+ end
end
+ end
- it 'returns 400 if commit is already included in the target branch' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'markdown'
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
- expect(response).to have_http_status(400)
- expect(json_response['message']).to include('Sorry, we cannot cherry-pick this commit automatically.')
+ it_behaves_like '403 response' do
+ let(:request) { post api(route), branch: 'master' }
end
+ end
- it 'returns 400 if you are not allowed to push to the target branch' do
- project.team << [user2, :developer]
- protected_branch = create(:protected_branch, project: project, name: 'feature')
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(route), branch: 'master' }
+ let(:message) { '404 Project Not Found' }
+ end
+ end
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user2), branch: protected_branch.name
+ context 'when authenticated', 'as an owner' do
+ let(:current_user) { user }
- expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('You are not allowed to push into this branch')
+ it_behaves_like 'ref cherry-pick'
+
+ context 'when ref does not exist' do
+ let(:commit_id) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), branch: 'master' }
+ let(:message) { '404 Commit Not Found' }
+ end
+ end
+
+ context 'when branch is missing' do
+ it_behaves_like '400 response' do
+ let(:request) { post api(route, current_user) }
+ end
end
- it 'returns 400 for missing parameters' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user)
+ context 'when branch does not exist' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), branch: 'foo' }
+ let(:message) { '404 Branch Not Found' }
+ end
+ end
- expect(response).to have_http_status(400)
- expect(json_response['error']).to eq('branch is missing')
+ context 'when commit is already included in the target branch' do
+ it_behaves_like '400 response' do
+ let(:request) { post api(route, current_user), branch: 'markdown' }
+ end
end
- it 'returns 404 if commit is not found' do
- post api("/projects/#{project.id}/repository/commits/abcd0123/cherry_pick", user), branch: 'master'
+ context 'when ref contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Commit Not Found')
+ it_behaves_like 'ref cherry-pick'
end
- it 'returns 404 if branch is not found' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'foo'
+ context 'when ref contains a slash' do
+ let(:commit_id) { branch_with_slash.name }
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Branch Not Found')
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), branch: 'master' }
+ end
end
- it 'returns 400 for missing parameters' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user)
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
- expect(response).to have_http_status(400)
- expect(json_response['error']).to eq('branch is missing')
+ it_behaves_like 'ref cherry-pick'
+
+ context 'when ref contains a dot' do
+ let(:commit) { project.repository.commit(branch_with_dot.name) }
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref cherry-pick'
+ end
end
end
- context 'unauthorized user' do
- it 'does not cherry pick the commit' do
- post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick"), branch: 'master'
+ context 'when authenticated', 'as a developer' do
+ let(:current_user) { guest }
- expect(response).to have_http_status(401)
+ before do
+ project.add_developer(guest)
+ end
+
+ context 'when branch is protected' do
+ before do
+ create(:protected_branch, project: project, name: 'feature')
+ end
+
+ it 'returns 400 if you are not allowed to push to the target branch' do
+ post api(route, current_user), branch: 'feature'
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['message']).to eq('You are not allowed to push into this branch')
+ end
end
end
end
- describe 'Post comment to commit' do
- context 'authorized user' do
- it 'returns comment' do
- post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment'
- expect(response).to have_http_status(201)
- expect(json_response['note']).to eq('My comment')
- expect(json_response['path']).to be_nil
- expect(json_response['line']).to be_nil
- expect(json_response['line_type']).to be_nil
+ describe 'POST /projects/:id/repository/commits/:sha/comments' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+ let(:note) { 'My comment' }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/comments" }
+
+ shared_examples_for 'ref new comment' do
+ context 'when ref exists' do
+ it 'creates the comment' do
+ post api(route, current_user), note: note
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/commit_note')
+ expect(json_response['note']).to eq('My comment')
+ expect(json_response['path']).to be_nil
+ expect(json_response['line']).to be_nil
+ expect(json_response['line_type']).to be_nil
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, current_user), note: 'My comment' }
+ end
+ end
+ end
+
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like '400 response' do
+ let(:request) { post api(route), note: 'My comment' }
+ end
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(route), note: 'My comment' }
+ let(:message) { '404 Project Not Found' }
end
+ end
+
+ context 'when authenticated', 'as an owner' do
+ let(:current_user) { user }
+
+ it_behaves_like 'ref new comment'
it 'returns the inline comment' do
- post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 1, line_type: 'new'
+ post api(route, current_user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 1, line_type: 'new'
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/commit_note')
expect(json_response['note']).to eq('My comment')
expect(json_response['path']).to eq(project.repository.commit.raw_diffs.first.new_path)
expect(json_response['line']).to eq(1)
expect(json_response['line_type']).to eq('new')
end
+ context 'when ref does not exist' do
+ let(:commit_id) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), note: 'My comment' }
+ let(:message) { '404 Commit Not Found' }
+ end
+ end
+
it 'returns 400 if note is missing' do
- post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user)
- expect(response).to have_http_status(400)
+ post api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(400)
end
- it 'returns 404 if note is attached to non existent commit' do
- post api("/projects/#{project.id}/repository/commits/1234ab/comments", user), note: 'My comment'
- expect(response).to have_http_status(404)
+ context 'when ref contains a dot' do
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref new comment'
end
- end
- context 'unauthorized user' do
- it 'does not return the diff of the selected commit' do
- post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments")
- expect(response).to have_http_status(401)
+ context 'when ref contains a slash' do
+ let(:commit_id) { branch_with_slash.name }
+
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), note: 'My comment' }
+ end
+ end
+
+ context 'when ref contains an escaped slash' do
+ let(:commit_id) { CGI.escape(branch_with_slash.name) }
+
+ it_behaves_like 'ref new comment'
+ end
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'ref new comment'
+
+ context 'when ref contains a dot' do
+ let(:commit_id) { branch_with_dot.name }
+
+ it_behaves_like 'ref new comment'
+ end
end
end
end
diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb
index 843e9862b0c..e497ec333a2 100644
--- a/spec/requests/api/deploy_keys_spec.rb
+++ b/spec/requests/api/deploy_keys_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
describe API::DeployKeys do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, creator_id: user.id) }
- let(:project2) { create(:empty_project, creator_id: user.id) }
+ let(:project) { create(:project, creator_id: user.id) }
+ let(:project2) { create(:project, creator_id: user.id) }
let(:deploy_key) { create(:deploy_key, public: true) }
let!(:deploy_keys_project) do
@@ -13,7 +13,7 @@ describe API::DeployKeys do
describe 'GET /deploy_keys' do
context 'when unauthenticated' do
- it 'should return authentication error' do
+ it 'returns authentication error' do
get api('/deploy_keys')
expect(response.status).to eq(401)
@@ -21,7 +21,7 @@ describe API::DeployKeys do
end
context 'when authenticated as non-admin user' do
- it 'should return a 403 error' do
+ it 'returns a 403 error' do
get api('/deploy_keys', user)
expect(response.status).to eq(403)
@@ -29,7 +29,7 @@ describe API::DeployKeys do
end
context 'when authenticated as admin' do
- it 'should return all deploy keys' do
+ it 'returns all deploy keys' do
get api('/deploy_keys', admin)
expect(response.status).to eq(200)
@@ -41,9 +41,11 @@ describe API::DeployKeys do
end
describe 'GET /projects/:id/deploy_keys' do
- before { deploy_key }
+ before do
+ deploy_key
+ end
- it 'should return array of ssh keys' do
+ it 'returns array of ssh keys' do
get api("/projects/#{project.id}/deploy_keys", admin)
expect(response).to have_http_status(200)
@@ -54,14 +56,14 @@ describe API::DeployKeys do
end
describe 'GET /projects/:id/deploy_keys/:key_id' do
- it 'should return a single key' do
+ it 'returns a single key' do
get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
expect(response).to have_http_status(200)
expect(json_response['title']).to eq(deploy_key.title)
end
- it 'should return 404 Not Found with invalid ID' do
+ it 'returns 404 Not Found with invalid ID' do
get api("/projects/#{project.id}/deploy_keys/404", admin)
expect(response).to have_http_status(404)
@@ -69,26 +71,26 @@ describe API::DeployKeys do
end
describe 'POST /projects/:id/deploy_keys' do
- it 'should not create an invalid ssh key' do
+ it 'does not create an invalid ssh key' do
post api("/projects/#{project.id}/deploy_keys", admin), { title: 'invalid key' }
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('key is missing')
end
- it 'should not create a key without title' do
+ it 'does not create a key without title' do
post api("/projects/#{project.id}/deploy_keys", admin), key: 'some key'
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('title is missing')
end
- it 'should create new ssh key' do
+ it 'creates new ssh key' do
key_attrs = attributes_for :another_key
expect do
post api("/projects/#{project.id}/deploy_keys", admin), key_attrs
- end.to change{ project.deploy_keys.count }.by(1)
+ end.to change { project.deploy_keys.count }.by(1)
end
it 'returns an existing ssh key when attempting to add a duplicate' do
@@ -117,18 +119,73 @@ describe API::DeployKeys do
end
end
+ describe 'PUT /projects/:id/deploy_keys/:key_id' do
+ let(:private_deploy_key) { create(:another_deploy_key, public: false) }
+ let(:project_private_deploy_key) do
+ create(:deploy_keys_project, project: project, deploy_key: private_deploy_key)
+ end
+
+ it 'updates a public deploy key as admin' do
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin), { title: 'new title' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'does not update a public deploy key as non admin' do
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user), { title: 'new title' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'does not update a private key with invalid title' do
+ project_private_deploy_key
+
+ expect do
+ put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: '' }
+ end.not_to change(deploy_key, :title)
+
+ expect(response).to have_http_status(400)
+ end
+
+ it 'updates a private ssh key with correct attributes' do
+ project_private_deploy_key
+
+ put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: 'new title', can_push: true }
+
+ expect(json_response['id']).to eq(private_deploy_key.id)
+ expect(json_response['title']).to eq('new title')
+ expect(json_response['can_push']).to eq(true)
+ end
+
+ it 'updates a private ssh key from projects user has access with correct attributes' do
+ create(:deploy_keys_project, project: project2, deploy_key: private_deploy_key)
+
+ put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: 'new title', can_push: true }
+
+ expect(json_response['id']).to eq(private_deploy_key.id)
+ expect(json_response['title']).to eq('new title')
+ expect(json_response['can_push']).to eq(true)
+ end
+ end
+
describe 'DELETE /projects/:id/deploy_keys/:key_id' do
- before { deploy_key }
+ before do
+ deploy_key
+ end
- it 'should delete existing key' do
+ it 'deletes existing key' do
expect do
delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
expect(response).to have_http_status(204)
- end.to change{ project.deploy_keys.count }.by(-1)
+ end.to change { project.deploy_keys.count }.by(-1)
end
- it 'should return 404 Not Found with invalid ID' do
+ it 'returns 404 Not Found with invalid ID' do
delete api("/projects/#{project.id}/deploy_keys/404", admin)
expect(response).to have_http_status(404)
@@ -136,7 +193,7 @@ describe API::DeployKeys do
end
describe 'POST /projects/:id/deploy_keys/:key_id/enable' do
- let(:project2) { create(:empty_project) }
+ let(:project2) { create(:project) }
context 'when the user can admin the project' do
it 'enables the key' do
@@ -150,7 +207,7 @@ describe API::DeployKeys do
end
context 'when authenticated as non-admin user' do
- it 'should return a 404 error' do
+ it 'returns a 404 error' do
post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", user)
expect(response).to have_http_status(404)
diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb
index b5897b2e346..868fef65c1c 100644
--- a/spec/requests/api/doorkeeper_access_spec.rb
+++ b/spec/requests/api/doorkeeper_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe API::API do
+describe 'doorkeeper access' do
let!(:user) { create(:user) }
let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" }
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index aae03c84e1f..87716c6fe3a 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::Environments do
let(:user) { create(:user) }
let(:non_member) { create(:user) }
- let(:project) { create(:empty_project, :private, namespace: user.namespace) }
+ let(:project) { create(:project, :private, namespace: user.namespace) }
let!(:environment) { create(:environment, project: project) }
before do
@@ -13,7 +13,14 @@ describe API::Environments do
describe 'GET /projects/:id/environments' do
context 'as member of the project' do
it 'returns project environments' do
- project_data_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace)
+ project_data_keys = %w(
+ id description default_branch tag_list
+ ssh_url_to_repo http_url_to_repo web_url
+ name name_with_namespace
+ path path_with_namespace
+ star_count forks_count
+ created_at last_activity_at
+ )
get api("/projects/#{project.id}/environments", user)
diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb
new file mode 100644
index 00000000000..a23d28994ce
--- /dev/null
+++ b/spec/requests/api/events_spec.rb
@@ -0,0 +1,200 @@
+require 'spec_helper'
+
+describe API::Events do
+ include ApiHelpers
+ let(:user) { create(:user) }
+ let(:non_member) { create(:user) }
+ let(:other_user) { create(:user, username: 'otheruser') }
+ let(:private_project) { create(:project, :private, creator_id: user.id, namespace: user.namespace) }
+ let(:closed_issue) { create(:closed_issue, project: private_project, author: user) }
+ let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
+
+ describe 'GET /events' do
+ context 'when unauthenticated' do
+ it 'returns authentication error' do
+ get api('/events')
+
+ expect(response).to have_http_status(401)
+ end
+ end
+
+ context 'when authenticated' do
+ it 'returns users events' do
+ get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31', user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+ end
+ end
+
+ describe 'GET /users/:id/events' do
+ context "as a user that cannot see the event's project" do
+ it 'returns no events' do
+ get api("/users/#{user.id}/events", other_user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_empty
+ end
+ end
+
+ context "as a user that can see the event's project" do
+ it 'accepts a username' do
+ get api("/users/#{user.username}/events", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns the events' do
+ get api("/users/#{user.id}/events", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ context 'when the list of events includes push events' do
+ let(:event) do
+ create(:push_event, author: user, project: private_project)
+ end
+
+ let!(:payload) { create(:push_event_payload, event: event) }
+ let(:payload_hash) { json_response[0]['push_data'] }
+
+ before do
+ get api("/users/#{user.id}/events?action=pushed", user)
+ end
+
+ it 'responds with HTTP 200 OK' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'includes the push payload as a Hash' do
+ expect(payload_hash).to be_an_instance_of(Hash)
+ end
+
+ it 'includes the push payload details' do
+ expect(payload_hash['commit_count']).to eq(payload.commit_count)
+ expect(payload_hash['action']).to eq(payload.action)
+ expect(payload_hash['ref_type']).to eq(payload.ref_type)
+ expect(payload_hash['commit_to']).to eq(payload.commit_to)
+ end
+ end
+
+ context 'when there are multiple events from different projects' do
+ let(:second_note) { create(:note_on_issue, project: create(:project)) }
+
+ before do
+ second_note.project.add_user(user, :developer)
+
+ [second_note].each do |note|
+ EventCreateService.new.leave_note(note, user)
+ end
+ end
+
+ it 'returns events in the correct order (from newest to oldest)' do
+ get api("/users/#{user.id}/events", user)
+
+ comment_events = json_response.select { |e| e['action_name'] == 'commented on' }
+ close_events = json_response.select { |e| e['action_name'] == 'closed' }
+
+ expect(comment_events[0]['target_id']).to eq(second_note.id)
+ expect(close_events[0]['target_id']).to eq(closed_issue.id)
+ end
+
+ it 'accepts filter parameters' do
+ get api("/users/#{user.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response[0]['target_id']).to eq(closed_issue.id)
+ end
+ end
+ end
+
+ it 'returns a 404 error if not found' do
+ get api('/users/42/events', user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ describe 'GET /projects/:id/events' do
+ context 'when unauthenticated ' do
+ it 'returns 404 for private project' do
+ get api("/projects/#{private_project.id}/events")
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns 200 status for a public project' do
+ public_project = create(:project, :public)
+
+ get api("/projects/#{public_project.id}/events")
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when not permitted to read' do
+ it 'returns 404' do
+ get api("/projects/#{private_project.id}/events", non_member)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when authenticated' do
+ it 'returns project events' do
+ get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns 404 if project does not exist' do
+ get api("/projects/1234/events", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when exists some events' do
+ let(:merge_request1) { create(:merge_request, :closed, author: user, assignee: user, source_project: private_project, title: 'Test') }
+ let(:merge_request2) { create(:merge_request, :closed, author: user, assignee: user, source_project: private_project, title: 'Test') }
+
+ before do
+ create_event(merge_request1)
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{private_project.id}/events", user), target_type: :merge_request
+ end.count
+
+ create_event(merge_request2)
+
+ expect do
+ get api("/projects/#{private_project.id}/events", user), target_type: :merge_request
+ end.not_to exceed_query_limit(control_count)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |r| r['target_id'] }).to match_array([merge_request1.id, merge_request2.id])
+ end
+
+ def create_event(target)
+ create(:event, project: private_project, author: user, target: target)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb
new file mode 100644
index 00000000000..7e21006b254
--- /dev/null
+++ b/spec/requests/api/features_spec.rb
@@ -0,0 +1,252 @@
+require 'spec_helper'
+
+describe API::Features do
+ let(:user) { create(:user) }
+ let(:admin) { create(:admin) }
+
+ before do
+ Flipper.unregister_groups
+ Flipper.register(:perf_team) do |actor|
+ actor.respond_to?(:admin) && actor.admin?
+ end
+ end
+
+ describe 'GET /features' do
+ let(:expected_features) do
+ [
+ {
+ 'name' => 'feature_1',
+ 'state' => 'on',
+ 'gates' => [{ 'key' => 'boolean', 'value' => true }]
+ },
+ {
+ 'name' => 'feature_2',
+ 'state' => 'off',
+ 'gates' => [{ 'key' => 'boolean', 'value' => false }]
+ },
+ {
+ 'name' => 'feature_3',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'groups', 'value' => ['perf_team'] }
+ ]
+ }
+ ]
+ end
+
+ before do
+ Feature.get('feature_1').enable
+ Feature.get('feature_2').disable
+ Feature.get('feature_3').enable Feature.group(:perf_team)
+ end
+
+ it 'returns a 401 for anonymous users' do
+ get api('/features')
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns a 403 for users' do
+ get api('/features', user)
+
+ expect(response).to have_http_status(403)
+ end
+
+ it 'returns the feature list for admins' do
+ get api('/features', admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to match_array(expected_features)
+ end
+ end
+
+ describe 'POST /feature' do
+ let(:feature_name) { 'my_feature' }
+
+ context 'when the feature does not exist' do
+ it 'returns a 401 for anonymous users' do
+ post api("/features/#{feature_name}")
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns a 403 for users' do
+ post api("/features/#{feature_name}", user)
+
+ expect(response).to have_http_status(403)
+ end
+
+ context 'when passed value=true' do
+ it 'creates an enabled feature' do
+ post api("/features/#{feature_name}", admin), value: 'true'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'on',
+ 'gates' => [{ 'key' => 'boolean', 'value' => true }])
+ end
+
+ it 'creates an enabled feature for the given Flipper group when passed feature_group=perf_team' do
+ post api("/features/#{feature_name}", admin), value: 'true', feature_group: 'perf_team'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'groups', 'value' => ['perf_team'] }
+ ])
+ end
+
+ it 'creates an enabled feature for the given user when passed user=username' do
+ post api("/features/#{feature_name}", admin), value: 'true', user: user.username
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'actors', 'value' => ["User:#{user.id}"] }
+ ])
+ end
+
+ it 'creates an enabled feature for the given user and feature group when passed user=username and feature_group=perf_team' do
+ post api("/features/#{feature_name}", admin), value: 'true', user: user.username, feature_group: 'perf_team'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'groups', 'value' => ['perf_team'] },
+ { 'key' => 'actors', 'value' => ["User:#{user.id}"] }
+ ])
+ end
+ end
+
+ it 'creates a feature with the given percentage if passed an integer' do
+ post api("/features/#{feature_name}", admin), value: '50'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'percentage_of_time', 'value' => 50 }
+ ])
+ end
+ end
+
+ context 'when the feature exists' do
+ let(:feature) { Feature.get(feature_name) }
+
+ before do
+ feature.disable # This also persists the feature on the DB
+ end
+
+ context 'when passed value=true' do
+ it 'enables the feature' do
+ post api("/features/#{feature_name}", admin), value: 'true'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'on',
+ 'gates' => [{ 'key' => 'boolean', 'value' => true }])
+ end
+
+ it 'enables the feature for the given Flipper group when passed feature_group=perf_team' do
+ post api("/features/#{feature_name}", admin), value: 'true', feature_group: 'perf_team'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'groups', 'value' => ['perf_team'] }
+ ])
+ end
+
+ it 'enables the feature for the given user when passed user=username' do
+ post api("/features/#{feature_name}", admin), value: 'true', user: user.username
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'actors', 'value' => ["User:#{user.id}"] }
+ ])
+ end
+ end
+
+ context 'when feature is enabled and value=false is passed' do
+ it 'disables the feature' do
+ feature.enable
+ expect(feature).to be_enabled
+
+ post api("/features/#{feature_name}", admin), value: 'false'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'off',
+ 'gates' => [{ 'key' => 'boolean', 'value' => false }])
+ end
+
+ it 'disables the feature for the given Flipper group when passed feature_group=perf_team' do
+ feature.enable(Feature.group(:perf_team))
+ expect(Feature.get(feature_name).enabled?(admin)).to be_truthy
+
+ post api("/features/#{feature_name}", admin), value: 'false', feature_group: 'perf_team'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'off',
+ 'gates' => [{ 'key' => 'boolean', 'value' => false }])
+ end
+
+ it 'disables the feature for the given user when passed user=username' do
+ feature.enable(user)
+ expect(Feature.get(feature_name).enabled?(user)).to be_truthy
+
+ post api("/features/#{feature_name}", admin), value: 'false', user: user.username
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'off',
+ 'gates' => [{ 'key' => 'boolean', 'value' => false }])
+ end
+ end
+
+ context 'with a pre-existing percentage value' do
+ before do
+ feature.enable_percentage_of_time(50)
+ end
+
+ it 'updates the percentage of time if passed an integer' do
+ post api("/features/#{feature_name}", admin), value: '30'
+
+ expect(response).to have_http_status(201)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'percentage_of_time', 'value' => 30 }
+ ])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index fa28047d49c..ea97c556430 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -13,7 +13,9 @@ describe API::Files do
let(:author_email) { 'user@example.org' }
let(:author_name) { 'John Doe' }
- before { project.team << [user, :developer] }
+ before do
+ project.team << [user, :developer]
+ end
def route(file_path = nil)
"/projects/#{project.id}/repository/files/#{file_path}"
@@ -31,6 +33,15 @@ describe API::Files do
expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n")
end
+ it 'returns json when file has txt extension' do
+ file_path = "bar%2Fbranch-test.txt"
+
+ get api(route(file_path), current_user), params
+
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq('application/json')
+ end
+
it 'returns file by commit sha' do
# This file is deleted on HEAD
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
@@ -78,7 +89,7 @@ describe API::Files do
context 'when unauthenticated', 'and project is public' do
it_behaves_like 'repository files' do
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
end
end
@@ -151,7 +162,7 @@ describe API::Files do
context 'when unauthenticated', 'and project is public' do
it_behaves_like 'repository raw files' do
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
end
end
@@ -203,8 +214,8 @@ describe API::Files do
end
it "returns a 400 if editor fails to create file" do
- allow_any_instance_of(Repository).to receive(:create_file).
- and_raise(Repository::CommitError, 'Cannot create file')
+ allow_any_instance_of(Repository).to receive(:create_file)
+ .and_raise(Repository::CommitError, 'Cannot create file')
post api(route("any%2Etxt"), user), valid_params
@@ -218,6 +229,7 @@ describe API::Files do
post api(route("new_file_with_author%2Etxt"), user), valid_params
expect(response).to have_http_status(201)
+ expect(response.content_type).to eq('application/json')
last_commit = project.repository.commit.raw
expect(last_commit.author_email).to eq(author_email)
expect(last_commit.author_name).to eq(author_name)
@@ -258,6 +270,25 @@ describe API::Files do
expect(last_commit.author_name).to eq(user.name)
end
+ it "returns a 400 bad request if update existing file with stale last commit id" do
+ params_with_stale_id = valid_params.merge(last_commit_id: 'stale')
+
+ put api(route(file_path), user), params_with_stale_id
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']).to eq('You are attempting to update a file that has changed since you started editing it.')
+ end
+
+ it "updates existing file in project repo with accepts correct last commit id" do
+ last_commit = Gitlab::Git::Commit
+ .last_for_path(project.repository, 'master', URI.unescape(file_path))
+ params_with_correct_id = valid_params.merge(last_commit_id: last_commit.id)
+
+ put api(route(file_path), user), params_with_correct_id
+
+ expect(response).to have_http_status(200)
+ end
+
it "returns a 400 bad request if no params given" do
put api(route(file_path), user)
@@ -329,7 +360,7 @@ describe API::Files do
end
let(:get_params) do
{
- ref: 'master',
+ ref: 'master'
}
end
diff --git a/spec/requests/api/group_milestones_spec.rb b/spec/requests/api/group_milestones_spec.rb
new file mode 100644
index 00000000000..108721c6655
--- /dev/null
+++ b/spec/requests/api/group_milestones_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe API::GroupMilestones do
+ let(:user) { create(:user) }
+ let(:group) { create(:group, :private) }
+ let(:project) { create(:project, namespace: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let!(:closed_milestone) { create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone') }
+ let!(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone') }
+
+ it_behaves_like 'group and project milestones', "/groups/:id/milestones" do
+ let(:route) { "/groups/#{group.id}/milestones" }
+ end
+
+ def setup_for_group
+ context_group.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ context_group.add_developer(user)
+ public_project.update(namespace: context_group)
+ context_group.reload
+ end
+end
diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb
new file mode 100644
index 00000000000..2179790d098
--- /dev/null
+++ b/spec/requests/api/group_variables_spec.rb
@@ -0,0 +1,221 @@
+require 'spec_helper'
+
+describe API::GroupVariables do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ describe 'GET /groups/:id/variables' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+
+ context 'authorized user with proper permissions' do
+ before do
+ group.add_master(user)
+ end
+
+ it 'returns group variables' do
+ get api("/groups/#{group.id}/variables", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_a(Array)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not return group variables' do
+ get api("/groups/#{group.id}/variables", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not return group variables' do
+ get api("/groups/#{group.id}/variables")
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+
+ describe 'GET /groups/:id/variables/:key' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+
+ context 'authorized user with proper permissions' do
+ before do
+ group.add_master(user)
+ end
+
+ it 'returns group variable details' do
+ get api("/groups/#{group.id}/variables/#{variable.key}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['value']).to eq(variable.value)
+ expect(json_response['protected']).to eq(variable.protected?)
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ get api("/groups/#{group.id}/variables/non_existing_variable", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not return group variable details' do
+ get api("/groups/#{group.id}/variables/#{variable.key}", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not return group variable details' do
+ get api("/groups/#{group.id}/variables/#{variable.key}")
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+
+ describe 'POST /groups/:id/variables' do
+ context 'authorized user with proper permissions' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+
+ before do
+ group.add_master(user)
+ end
+
+ it 'creates variable' do
+ expect do
+ post api("/groups/#{group.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2', protected: true
+ end.to change {group.variables.count}.by(1)
+
+ expect(response).to have_http_status(201)
+ expect(json_response['key']).to eq('TEST_VARIABLE_2')
+ expect(json_response['value']).to eq('VALUE_2')
+ expect(json_response['protected']).to be_truthy
+ end
+
+ it 'creates variable with optional attributes' do
+ expect do
+ post api("/groups/#{group.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2'
+ end.to change {group.variables.count}.by(1)
+
+ expect(response).to have_http_status(201)
+ expect(json_response['key']).to eq('TEST_VARIABLE_2')
+ expect(json_response['value']).to eq('VALUE_2')
+ expect(json_response['protected']).to be_falsey
+ end
+
+ it 'does not allow to duplicate variable key' do
+ expect do
+ post api("/groups/#{group.id}/variables", user), key: variable.key, value: 'VALUE_2'
+ end.to change {group.variables.count}.by(0)
+
+ expect(response).to have_http_status(400)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not create variable' do
+ post api("/groups/#{group.id}/variables", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not create variable' do
+ post api("/groups/#{group.id}/variables")
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+
+ describe 'PUT /groups/:id/variables/:key' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+
+ context 'authorized user with proper permissions' do
+ before do
+ group.add_master(user)
+ end
+
+ it 'updates variable data' do
+ initial_variable = group.variables.first
+ value_before = initial_variable.value
+
+ put api("/groups/#{group.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true
+
+ updated_variable = group.variables.first
+
+ expect(response).to have_http_status(200)
+ expect(value_before).to eq(variable.value)
+ expect(updated_variable.value).to eq('VALUE_1_UP')
+ expect(updated_variable).to be_protected
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ put api("/groups/#{group.id}/variables/non_existing_variable", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not update variable' do
+ put api("/groups/#{group.id}/variables/#{variable.key}", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not update variable' do
+ put api("/groups/#{group.id}/variables/#{variable.key}")
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+
+ describe 'DELETE /groups/:id/variables/:key' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+
+ context 'authorized user with proper permissions' do
+ before do
+ group.add_master(user)
+ end
+
+ it 'deletes variable' do
+ expect do
+ delete api("/groups/#{group.id}/variables/#{variable.key}", user)
+
+ expect(response).to have_http_status(204)
+ end.to change {group.variables.count}.by(-1)
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ delete api("/groups/#{group.id}/variables/non_existing_variable", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not delete variable' do
+ delete api("/groups/#{group.id}/variables/#{variable.key}", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not delete variable' do
+ delete api("/groups/#{group.id}/variables/#{variable.key}")
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 3e27a3bee77..313c38cd29c 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -9,9 +9,9 @@ describe API::Groups do
let(:admin) { create(:admin) }
let!(:group1) { create(:group, avatar: File.open(uploaded_image_temp_path)) }
let!(:group2) { create(:group, :private) }
- let!(:project1) { create(:empty_project, namespace: group1) }
- let!(:project2) { create(:empty_project, namespace: group2) }
- let!(:project3) { create(:empty_project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+ let!(:project1) { create(:project, namespace: group1) }
+ let!(:project2) { create(:project, namespace: group2) }
+ let!(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
before do
group1.add_owner(user1)
@@ -73,7 +73,7 @@ describe API::Groups do
storage_size: 702,
repository_size: 123,
lfs_objects_size: 234,
- build_artifacts_size: 345,
+ build_artifacts_size: 345
}.stringify_keys
exposed_attributes = attributes.dup
exposed_attributes['job_artifacts_size'] = exposed_attributes.delete('build_artifacts_size')
@@ -167,7 +167,7 @@ describe API::Groups do
describe "GET /groups/:id" do
context "when authenticated as user" do
it "returns one of user1's groups" do
- project = create(:empty_project, namespace: group2, path: 'Foo')
+ project = create(:project, namespace: group2, path: 'Foo')
create(:project_group_link, project: project, group: group1)
get api("/groups/#{group1.id}", user1)
@@ -178,7 +178,7 @@ describe API::Groups do
expect(json_response['path']).to eq(group1.path)
expect(json_response['description']).to eq(group1.description)
expect(json_response['visibility']).to eq(Gitlab::VisibilityLevel.string_level(group1.visibility_level))
- expect(json_response['avatar_url']).to eq(group1.avatar_url)
+ expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false))
expect(json_response['web_url']).to eq(group1.web_url)
expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled)
expect(json_response['full_name']).to eq(group1.full_name)
@@ -311,7 +311,7 @@ describe API::Groups do
end
it 'filters the groups projects' do
- public_project = create(:empty_project, :public, path: 'test1', group: group1)
+ public_project = create(:project, :public, path: 'test1', group: group1)
get api("/groups/#{group1.id}/projects", user1), visibility: 'public'
@@ -429,7 +429,7 @@ describe API::Groups do
expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled])
end
- it "creates a nested group" do
+ it "creates a nested group", :nested_groups do
parent = create(:group)
parent.add_owner(user3)
group = attributes_for(:group, { parent_id: parent.id })
@@ -509,12 +509,12 @@ describe API::Groups do
end
describe "POST /groups/:id/projects/:project_id" do
- let(:project) { create(:empty_project) }
- let(:project_path) { project.full_path.gsub('/', '%2F') }
+ let(:project) { create(:project) }
+ let(:project_path) { CGI.escape(project.full_path) }
- before(:each) do
- allow_any_instance_of(Projects::TransferService).
- to receive(:execute).and_return(true)
+ before do
+ allow_any_instance_of(Projects::TransferService)
+ .to receive(:execute).and_return(true)
end
context "when authenticated as user" do
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 06c8eb1d0b7..d4006fe71a2 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::Helpers do
include API::APIGuard::HelperMethods
- include API::Helpers
+ include described_class
include SentryHelper
let(:user) { create(:user) }
@@ -10,10 +10,22 @@ describe API::Helpers do
let(:key) { create(:key, user: user) }
let(:params) { {} }
- let(:env) { { 'REQUEST_METHOD' => 'GET' } }
- let(:request) { Rack::Request.new(env) }
+ let(:csrf_token) { SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH) }
+ let(:env) do
+ {
+ 'rack.input' => '',
+ 'rack.session' => {
+ _csrf_token: csrf_token
+ },
+ 'REQUEST_METHOD' => 'GET'
+ }
+ end
let(:header) { }
+ before do
+ allow_any_instance_of(self.class).to receive(:options).and_return({})
+ end
+
def set_env(user_or_token, identifier)
clear_env
clear_param
@@ -44,7 +56,7 @@ describe API::Helpers do
end
def doorkeeper_guard_returns(value)
- allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ value }
+ allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { value }
end
def error!(message, status, header)
@@ -54,42 +66,94 @@ describe API::Helpers do
describe ".current_user" do
subject { current_user }
- describe "Warden authentication" do
- before { doorkeeper_guard_returns false }
+ describe "Warden authentication", :allow_forgery_protection do
+ before do
+ doorkeeper_guard_returns false
+ end
context "with invalid credentials" do
context "GET request" do
- before { env['REQUEST_METHOD'] = 'GET' }
+ before do
+ env['REQUEST_METHOD'] = 'GET'
+ end
+
it { is_expected.to be_nil }
end
end
context "with valid credentials" do
- before { warden_authenticate_returns user }
+ before do
+ warden_authenticate_returns user
+ end
context "GET request" do
- before { env['REQUEST_METHOD'] = 'GET' }
+ before do
+ env['REQUEST_METHOD'] = 'GET'
+ end
+
it { is_expected.to eq(user) }
end
context "HEAD request" do
- before { env['REQUEST_METHOD'] = 'HEAD' }
+ before do
+ env['REQUEST_METHOD'] = 'HEAD'
+ end
+
it { is_expected.to eq(user) }
end
context "PUT request" do
- before { env['REQUEST_METHOD'] = 'PUT' }
- it { is_expected.to be_nil }
+ before do
+ env['REQUEST_METHOD'] = 'PUT'
+ end
+
+ context 'without CSRF token' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with CSRF token' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = csrf_token
+ end
+
+ it { is_expected.to eq(user) }
+ end
end
context "POST request" do
- before { env['REQUEST_METHOD'] = 'POST' }
- it { is_expected.to be_nil }
+ before do
+ env['REQUEST_METHOD'] = 'POST'
+ end
+
+ context 'without CSRF token' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with CSRF token' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = csrf_token
+ end
+
+ it { is_expected.to eq(user) }
+ end
end
context "DELETE request" do
- before { env['REQUEST_METHOD'] = 'DELETE' }
- it { is_expected.to be_nil }
+ before do
+ env['REQUEST_METHOD'] = 'DELETE'
+ end
+
+ context 'without CSRF token' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'with CSRF token' do
+ before do
+ env['HTTP_X_CSRF_TOKEN'] = csrf_token
+ end
+
+ it { is_expected.to eq(user) }
+ end
end
end
end
@@ -97,7 +161,7 @@ describe API::Helpers do
describe "when authenticating using a user's private token" do
it "returns nil for an invalid token" do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token'
- allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false }
+ allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false }
expect(current_user).to be_nil
end
@@ -145,7 +209,6 @@ describe API::Helpers do
it "returns nil for a token without the appropriate scope" do
personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user'])
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
- allow_access_with_scope('write_user')
expect(current_user).to be_nil
end
diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb
index 429f1a4e375..e9c30dba8d4 100644
--- a/spec/requests/api/internal_spec.rb
+++ b/spec/requests/api/internal_spec.rb
@@ -6,7 +6,7 @@ describe API::Internal do
let(:project) { create(:project, :repository) }
let(:secret_token) { Gitlab::Shell.secret_token }
- describe "GET /internal/check", no_db: true do
+ describe "GET /internal/check" do
it do
get api("/internal/check"), secret_token: secret_token
@@ -15,21 +15,54 @@ describe API::Internal do
end
end
- describe "GET /internal/broadcast_message" do
- context "broadcast message exists" do
- let!(:broadcast_message) { create(:broadcast_message, starts_at: Time.now.yesterday, ends_at: Time.now.tomorrow ) }
+ describe 'GET /internal/broadcast_message' do
+ context 'broadcast message exists' do
+ let!(:broadcast_message) { create(:broadcast_message, starts_at: 1.day.ago, ends_at: 1.day.from_now ) }
- it do
- get api("/internal/broadcast_message"), secret_token: secret_token
+ it 'returns one broadcast message' do
+ get api('/internal/broadcast_message'), secret_token: secret_token
expect(response).to have_http_status(200)
- expect(json_response["message"]).to eq(broadcast_message.message)
+ expect(json_response['message']).to eq(broadcast_message.message)
end
end
- context "broadcast message doesn't exist" do
- it do
- get api("/internal/broadcast_message"), secret_token: secret_token
+ context 'broadcast message does not exist' do
+ it 'returns nothing' do
+ get api('/internal/broadcast_message'), secret_token: secret_token
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_empty
+ end
+ end
+
+ context 'nil broadcast message' do
+ it 'returns nothing' do
+ allow(BroadcastMessage).to receive(:current).and_return(nil)
+
+ get api('/internal/broadcast_message'), secret_token: secret_token
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ describe 'GET /internal/broadcast_messages' do
+ context 'broadcast message(s) exist' do
+ let!(:broadcast_message) { create(:broadcast_message, starts_at: 1.day.ago, ends_at: 1.day.from_now ) }
+
+ it 'returns active broadcast message(s)' do
+ get api('/internal/broadcast_messages'), secret_token: secret_token
+
+ expect(response).to have_http_status(200)
+ expect(json_response[0]['message']).to eq(broadcast_message.message)
+ end
+ end
+
+ context 'broadcast message does not exist' do
+ it 'returns nothing' do
+ get api('/internal/broadcast_messages'), secret_token: secret_token
expect(response).to have_http_status(200)
expect(json_response).to be_empty
@@ -146,15 +179,14 @@ describe API::Internal do
end
end
- describe "POST /internal/allowed", :redis do
+ describe "POST /internal/allowed", :clean_gitlab_redis_shared_state do
context "access granted" do
- before do
- project.team << [user, :developer]
- Timecop.freeze
+ around do |example|
+ Timecop.freeze { example.run }
end
- after do
- Timecop.return
+ before do
+ project.team << [user, :developer]
end
context 'with env passed as a JSON' do
@@ -180,6 +212,7 @@ describe API::Internal do
expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("wiki-#{project.id}")
expect(user).not_to have_an_activity_record
end
end
@@ -191,48 +224,99 @@ describe API::Internal do
expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("wiki-#{project.id}")
expect(user).to have_an_activity_record
end
end
context "git pull" do
- it do
- pull(key, project)
+ context "gitaly disabled" do
+ it "has the correct payload" do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_upload_pack).and_return(false)
+ pull(key, project)
- expect(response).to have_http_status(200)
- expect(json_response["status"]).to be_truthy
- expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
- expect(user).to have_an_activity_record
+ expect(response).to have_http_status(200)
+ expect(json_response["status"]).to be_truthy
+ expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
+ expect(json_response["gitaly"]).to be_nil
+ expect(user).to have_an_activity_record
+ end
+ end
+
+ context "gitaly enabled" do
+ it "has the correct payload" do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_upload_pack).and_return(true)
+ pull(key, project)
+
+ expect(response).to have_http_status(200)
+ expect(json_response["status"]).to be_truthy
+ expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
+ expect(json_response["gitaly"]).not_to be_nil
+ expect(json_response["gitaly"]["repository"]).not_to be_nil
+ expect(json_response["gitaly"]["repository"]["storage_name"]).to eq(project.repository.gitaly_repository.storage_name)
+ expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path)
+ expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage))
+ expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage))
+ expect(user).to have_an_activity_record
+ end
end
end
context "git push" do
- it do
- push(key, project)
+ context "gitaly disabled" do
+ it "has the correct payload" do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_receive_pack).and_return(false)
+ push(key, project)
- expect(response).to have_http_status(200)
- expect(json_response["status"]).to be_truthy
- expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
- expect(user).not_to have_an_activity_record
+ expect(response).to have_http_status(200)
+ expect(json_response["status"]).to be_truthy
+ expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
+ expect(json_response["gitaly"]).to be_nil
+ expect(user).not_to have_an_activity_record
+ end
+ end
+
+ context "gitaly enabled" do
+ it "has the correct payload" do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_receive_pack).and_return(true)
+ push(key, project)
+
+ expect(response).to have_http_status(200)
+ expect(json_response["status"]).to be_truthy
+ expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
+ expect(json_response["gitaly"]).not_to be_nil
+ expect(json_response["gitaly"]["repository"]).not_to be_nil
+ expect(json_response["gitaly"]["repository"]["storage_name"]).to eq(project.repository.gitaly_repository.storage_name)
+ expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path)
+ expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage))
+ expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage))
+ expect(user).not_to have_an_activity_record
+ end
end
context 'project as /namespace/project' do
it do
- pull(key, project_with_repo_path('/' + project.path_with_namespace))
+ pull(key, project_with_repo_path('/' + project.full_path))
expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
end
end
context 'project as namespace/project' do
it do
- pull(key, project_with_repo_path(project.path_with_namespace))
+ pull(key, project_with_repo_path(project.full_path))
expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
+ expect(json_response["gl_repository"]).to eq("project-#{project.id}")
end
end
end
@@ -265,7 +349,7 @@ describe API::Internal do
end
context "blocked user" do
- let(:personal_project) { create(:empty_project, namespace: user.namespace) }
+ let(:personal_project) { create(:project, namespace: user.namespace) }
before do
user.block
@@ -293,8 +377,6 @@ describe API::Internal do
end
context "archived project" do
- let(:personal_project) { create(:empty_project, namespace: user.namespace) }
-
before do
project.team << [user, :developer]
project.archive!
@@ -417,6 +499,42 @@ describe API::Internal do
expect(json_response['status']).to be_truthy
end
end
+
+ context 'the project path was changed' do
+ let!(:old_path_to_repo) { project.repository.path_to_repo }
+ let!(:old_full_path) { project.full_path }
+ let(:project_moved_message) do
+ <<-MSG.strip_heredoc
+ Project '#{old_full_path}' was moved to '#{project.full_path}'.
+
+ Please update your Git remote and try again:
+
+ git remote set-url origin #{project.ssh_url_to_repo}
+ MSG
+ end
+
+ before do
+ project.team << [user, :developer]
+ project.path = 'new_path'
+ project.save!
+ end
+
+ it 'rejects the push' do
+ push_with_path(key, old_path_to_repo)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['status']).to be_falsey
+ expect(json_response['message']).to eq(project_moved_message)
+ end
+
+ it 'rejects the SSH pull' do
+ pull_with_path(key, old_path_to_repo)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['status']).to be_falsey
+ expect(json_response['message']).to eq(project_moved_message)
+ end
+ end
end
describe 'GET /internal/merge_request_urls' do
@@ -444,36 +562,104 @@ describe API::Internal do
expect(json_response).to eq([])
end
- end
-
- describe 'POST /notify_post_receive' do
- let(:valid_params) do
- { repo_path: project.repository.path, secret_token: secret_token }
- end
-
- before do
- allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true)
- end
-
- it "calls the Gitaly client if it's enabled" do
- expect_any_instance_of(Gitlab::GitalyClient::Notifications).
- to receive(:post_receive)
- post api("/internal/notify_post_receive"), valid_params
+ context 'with a gl_repository parameter' do
+ let(:gl_repository) { "project-#{project.id}" }
- expect(response).to have_http_status(200)
- end
+ it 'returns link to create new merge request' do
+ get api("/internal/merge_request_urls?gl_repository=#{gl_repository}&changes=#{changes}"), secret_token: secret_token
- it "returns 500 if the gitaly call fails" do
- expect_any_instance_of(Gitlab::GitalyClient::Notifications).
- to receive(:post_receive).and_raise(GRPC::Unavailable)
-
- post api("/internal/notify_post_receive"), valid_params
-
- expect(response).to have_http_status(500)
+ expect(json_response).to match [{
+ "branch_name" => "new_branch",
+ "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch",
+ "new_merge_request" => true
+ }]
+ end
end
end
+ # TODO: Uncomment when the end-point is reenabled
+ # describe 'POST /notify_post_receive' do
+ # let(:valid_params) do
+ # { project: project.repository.path, secret_token: secret_token }
+ # end
+ #
+ # let(:valid_wiki_params) do
+ # { project: project.wiki.repository.path, secret_token: secret_token }
+ # end
+ #
+ # before do
+ # allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true)
+ # end
+ #
+ # it "calls the Gitaly client with the project's repository" do
+ # expect(Gitlab::GitalyClient::NotificationService).
+ # to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)).
+ # and_call_original
+ # expect_any_instance_of(Gitlab::GitalyClient::NotificationService).
+ # to receive(:post_receive)
+ #
+ # post api("/internal/notify_post_receive"), valid_params
+ #
+ # expect(response).to have_http_status(200)
+ # end
+ #
+ # it "calls the Gitaly client with the wiki's repository if it's a wiki" do
+ # expect(Gitlab::GitalyClient::NotificationService).
+ # to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)).
+ # and_call_original
+ # expect_any_instance_of(Gitlab::GitalyClient::NotificationService).
+ # to receive(:post_receive)
+ #
+ # post api("/internal/notify_post_receive"), valid_wiki_params
+ #
+ # expect(response).to have_http_status(200)
+ # end
+ #
+ # it "returns 500 if the gitaly call fails" do
+ # expect_any_instance_of(Gitlab::GitalyClient::NotificationService).
+ # to receive(:post_receive).and_raise(GRPC::Unavailable)
+ #
+ # post api("/internal/notify_post_receive"), valid_params
+ #
+ # expect(response).to have_http_status(500)
+ # end
+ #
+ # context 'with a gl_repository parameter' do
+ # let(:valid_params) do
+ # { gl_repository: "project-#{project.id}", secret_token: secret_token }
+ # end
+ #
+ # let(:valid_wiki_params) do
+ # { gl_repository: "wiki-#{project.id}", secret_token: secret_token }
+ # end
+ #
+ # it "calls the Gitaly client with the project's repository" do
+ # expect(Gitlab::GitalyClient::NotificationService).
+ # to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)).
+ # and_call_original
+ # expect_any_instance_of(Gitlab::GitalyClient::NotificationService).
+ # to receive(:post_receive)
+ #
+ # post api("/internal/notify_post_receive"), valid_params
+ #
+ # expect(response).to have_http_status(200)
+ # end
+ #
+ # it "calls the Gitaly client with the wiki's repository if it's a wiki" do
+ # expect(Gitlab::GitalyClient::NotificationService).
+ # to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)).
+ # and_call_original
+ # expect_any_instance_of(Gitlab::GitalyClient::NotificationService).
+ # to receive(:post_receive)
+ #
+ # post api("/internal/notify_post_receive"), valid_wiki_params
+ #
+ # expect(response).to have_http_status(200)
+ # end
+ # end
+ # end
+
def project_with_repo_path(path)
double().tap do |fake_project|
allow(fake_project).to receive_message_chain('repository.path_to_repo' => path)
@@ -491,6 +677,17 @@ describe API::Internal do
)
end
+ def pull_with_path(key, path_to_repo, protocol = 'ssh')
+ post(
+ api("/internal/allowed"),
+ key_id: key.id,
+ project: path_to_repo,
+ action: 'git-upload-pack',
+ secret_token: secret_token,
+ protocol: protocol
+ )
+ end
+
def push(key, project, protocol = 'ssh', env: nil)
post(
api("/internal/allowed"),
@@ -504,6 +701,19 @@ describe API::Internal do
)
end
+ def push_with_path(key, path_to_repo, protocol = 'ssh', env: nil)
+ post(
+ api("/internal/allowed"),
+ changes: 'd14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master',
+ key_id: key.id,
+ project: path_to_repo,
+ action: 'git-receive-pack',
+ secret_token: secret_token,
+ protocol: protocol,
+ env: env
+ )
+ end
+
def archive(key, project)
post(
api("/internal/allowed"),
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 3ca13111acb..7d120e4a234 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -1,11 +1,9 @@
require 'spec_helper'
-describe API::Issues do
- include EmailHelpers
-
+describe API::Issues, :mailer do
set(:user) { create(:user) }
set(:project) do
- create(:empty_project, :public, creator_id: user.id, namespace: user.namespace)
+ create(:project, :public, creator_id: user.id, namespace: user.namespace)
end
let(:user2) { create(:user) }
@@ -19,7 +17,7 @@ describe API::Issues do
let!(:closed_issue) do
create :closed_issue,
author: user,
- assignee: user,
+ assignees: [user],
project: project,
state: :closed,
milestone: milestone,
@@ -31,14 +29,14 @@ describe API::Issues do
:confidential,
project: project,
author: author,
- assignee: assignee,
+ assignees: [assignee],
created_at: generate(:past_time),
updated_at: 2.hours.ago
end
let!(:issue) do
create :issue,
author: user,
- assignee: user,
+ assignees: [user],
project: project,
milestone: milestone,
created_at: generate(:past_time),
@@ -71,7 +69,6 @@ describe API::Issues do
expect(response).to have_http_status(401)
end
end
-
context "when authenticated" do
let(:first_issue) { json_response.first }
@@ -105,6 +102,42 @@ describe API::Issues do
expect(json_response.second['id']).to eq(closed_issue.id)
end
+ it 'returns issues assigned to me' do
+ issue2 = create(:issue, assignees: [user2], project: project)
+
+ get api('/issues', user2), scope: 'assigned-to-me'
+
+ expect_paginated_array_response(size: 1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
+ it 'returns issues authored by the given author id' do
+ issue2 = create(:issue, author: user2, project: project)
+
+ get api('/issues', user), author_id: user2.id, scope: 'all'
+
+ expect_paginated_array_response(size: 1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
+ it 'returns issues assigned to the given assignee id' do
+ issue2 = create(:issue, assignees: [user2], project: project)
+
+ get api('/issues', user), assignee_id: user2.id, scope: 'all'
+
+ expect_paginated_array_response(size: 1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
+ it 'returns issues authored by the given author id and assigned to the given assignee id' do
+ issue2 = create(:issue, author: user2, assignees: [user2], project: project)
+
+ get api('/issues', user), author_id: user2.id, assignee_id: user2.id, scope: 'all'
+
+ expect_paginated_array_response(size: 1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
it 'returns issues matching given search string for title' do
get api("/issues", user), search: issue.title
@@ -261,11 +294,11 @@ describe API::Issues do
describe "GET /groups/:id/issues" do
let!(:group) { create(:group) }
- let!(:group_project) { create(:empty_project, :public, creator_id: user.id, namespace: group) }
+ let!(:group_project) { create(:project, :public, creator_id: user.id, namespace: group) }
let!(:group_closed_issue) do
create :closed_issue,
author: user,
- assignee: user,
+ assignees: [user],
project: group_project,
state: :closed,
milestone: group_milestone,
@@ -276,13 +309,13 @@ describe API::Issues do
:confidential,
project: group_project,
author: author,
- assignee: assignee,
+ assignees: [assignee],
updated_at: 2.hours.ago
end
let!(:group_issue) do
create :issue,
author: user,
- assignee: user,
+ assignees: [user],
project: group_project,
milestone: group_milestone,
updated_at: 1.hour.ago,
@@ -483,7 +516,7 @@ describe API::Issues do
end
it "returns 404 on private projects for other users" do
- private_project = create(:empty_project, :private)
+ private_project = create(:project, :private)
create(:issue, project: private_project)
get api("/projects/#{private_project.id}/issues", non_member)
@@ -492,7 +525,7 @@ describe API::Issues do
end
it 'returns no issues when user has access to project but not issues' do
- restricted_project = create(:empty_project, :public, :issues_private)
+ restricted_project = create(:project, :public, :issues_private)
create(:issue, project: restricted_project)
get api("/projects/#{restricted_project.id}/issues", non_member)
@@ -687,11 +720,25 @@ describe API::Issues do
expect(json_response['updated_at']).to be_present
expect(json_response['labels']).to eq(issue.label_names)
expect(json_response['milestone']).to be_a Hash
+ expect(json_response['assignees']).to be_a Array
expect(json_response['assignee']).to be_a Hash
expect(json_response['author']).to be_a Hash
expect(json_response['confidential']).to be_falsy
end
+ context 'links exposure' do
+ it 'exposes related resources full URIs' do
+ get api("/projects/#{project.id}/issues/#{issue.iid}", user)
+
+ links = json_response['_links']
+
+ expect(links['self']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}")
+ expect(links['notes']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/notes")
+ expect(links['award_emoji']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/award_emoji")
+ expect(links['project']).to end_with("/api/v4/projects/#{project.id}")
+ end
+ end
+
it "returns a project issue by internal id" do
get api("/projects/#{project.id}/issues/#{issue.iid}", user)
@@ -759,15 +806,41 @@ describe API::Issues do
end
describe "POST /projects/:id/issues" do
+ context 'support for deprecated assignee_id' do
+ it 'creates a new project issue' do
+ post api("/projects/#{project.id}/issues", user),
+ title: 'new issue', assignee_id: user2.id
+
+ expect(response).to have_http_status(201)
+ expect(json_response['title']).to eq('new issue')
+ expect(json_response['assignee']['name']).to eq(user2.name)
+ expect(json_response['assignees'].first['name']).to eq(user2.name)
+ end
+ end
+
+ context 'single assignee restrictions' do
+ it 'creates a new project issue with no more than one assignee' do
+ post api("/projects/#{project.id}/issues", user),
+ title: 'new issue', assignee_ids: [user2.id, guest.id]
+
+ expect(response).to have_http_status(201)
+ expect(json_response['title']).to eq('new issue')
+ expect(json_response['assignees'].count).to eq(1)
+ end
+ end
+
it 'creates a new project issue' do
post api("/projects/#{project.id}/issues", user),
- title: 'new issue', labels: 'label, label2'
+ title: 'new issue', labels: 'label, label2', weight: 3,
+ assignee_ids: [user2.id]
expect(response).to have_http_status(201)
expect(json_response['title']).to eq('new issue')
expect(json_response['description']).to be_nil
expect(json_response['labels']).to eq(%w(label label2))
expect(json_response['confidential']).to be_falsy
+ expect(json_response['assignee']['name']).to eq(user2.name)
+ expect(json_response['assignees'].first['name']).to eq(user2.name)
end
it 'creates a new confidential project issue' do
@@ -1057,6 +1130,57 @@ describe API::Issues do
end
end
+ describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do
+ context 'support for deprecated assignee_id' do
+ it 'removes assignee' do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", user),
+ assignee_id: 0
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['assignee']).to be_nil
+ end
+
+ it 'updates an issue with new assignee' do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", user),
+ assignee_id: user2.id
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['assignee']['name']).to eq(user2.name)
+ end
+ end
+
+ it 'removes assignee' do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", user),
+ assignee_ids: [0]
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['assignees']).to be_empty
+ end
+
+ it 'updates an issue with new assignee' do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", user),
+ assignee_ids: [user2.id]
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['assignees'].first['name']).to eq(user2.name)
+ end
+
+ context 'single assignee restrictions' do
+ it 'updates an issue with several assignees but only one has been applied' do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", user),
+ assignee_ids: [user2.id, guest.id]
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['assignees'].size).to eq(1)
+ end
+ end
+ end
+
describe 'PUT /projects/:id/issues/:issue_iid to update labels' do
let!(:label) { create(:label, title: 'dummy', project: project) }
let!(:label_link) { create(:label_link, label: label, target: issue) }
@@ -1133,7 +1257,7 @@ describe API::Issues do
put api("/projects/#{project.id}/issues/#{closed_issue.iid}", user), state_event: 'reopen'
expect(response).to have_http_status(200)
- expect(json_response['state']).to eq 'reopened'
+ expect(json_response['state']).to eq 'opened'
end
context 'when an admin or owner makes the request' do
@@ -1173,7 +1297,7 @@ describe API::Issues do
context "when the user is project owner" do
let(:owner) { create(:user) }
- let(:project) { create(:empty_project, namespace: owner.namespace) }
+ let(:project) { create(:project, namespace: owner.namespace) }
it "deletes the issue if an admin requests it" do
delete api("/projects/#{project.id}/issues/#{issue.iid}", owner)
@@ -1198,8 +1322,8 @@ describe API::Issues do
end
describe '/projects/:id/issues/:issue_iid/move' do
- let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
- let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) }
+ let!(:target_project) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
+ let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace ) }
it 'moves an issue' do
post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
@@ -1384,6 +1508,25 @@ describe API::Issues do
end
end
+ describe "GET /projects/:id/issues/:issue_iid/user_agent_detail" do
+ let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) }
+
+ it 'exposes known attributes' do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
+ expect(json_response['ip_address']).to eq(user_agent_detail.ip_address)
+ expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted)
+ end
+
+ it "returns unautorized for non-admin users" do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
def expect_paginated_array_response(size: nil)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index decb5b91941..f56baf9663d 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -1,14 +1,26 @@
require 'spec_helper'
describe API::Jobs do
+ let!(:project) do
+ create(:project, :repository, public_builds: false)
+ end
+
+ let!(:pipeline) do
+ create(:ci_empty_pipeline, project: project,
+ sha: project.commit.id,
+ ref: project.default_branch)
+ end
+
+ let!(:job) { create(:ci_build, pipeline: pipeline) }
+
let(:user) { create(:user) }
let(:api_user) { user }
- let!(:project) { create(:project, :repository, creator: user, public_builds: false) }
- let!(:developer) { create(:project_member, :developer, user: user, project: project) }
- let(:reporter) { create(:project_member, :reporter, project: project) }
- let(:guest) { create(:project_member, :guest, project: project) }
- let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) }
- let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:reporter) { create(:project_member, :reporter, project: project).user }
+ let(:guest) { create(:project_member, :guest, project: project).user }
+
+ before do
+ project.add_developer(user)
+ end
describe 'GET /projects/:id/jobs' do
let(:query) { Hash.new }
@@ -30,13 +42,13 @@ describe API::Jobs do
end
it 'returns pipeline data' do
- json_build = json_response.first
+ json_job = json_response.first
- expect(json_build['pipeline']).not_to be_empty
- expect(json_build['pipeline']['id']).to eq build.pipeline.id
- expect(json_build['pipeline']['ref']).to eq build.pipeline.ref
- expect(json_build['pipeline']['sha']).to eq build.pipeline.sha
- expect(json_build['pipeline']['status']).to eq build.pipeline.status
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
end
context 'filter project with one scope element' do
@@ -67,7 +79,7 @@ describe API::Jobs do
context 'unauthorized user' do
let(:api_user) { nil }
- it 'does not return project builds' do
+ it 'does not return project jobs' do
expect(response).to have_http_status(401)
end
end
@@ -93,13 +105,13 @@ describe API::Jobs do
end
it 'returns pipeline data' do
- json_build = json_response.first
+ json_job = json_response.first
- expect(json_build['pipeline']).not_to be_empty
- expect(json_build['pipeline']['id']).to eq build.pipeline.id
- expect(json_build['pipeline']['ref']).to eq build.pipeline.ref
- expect(json_build['pipeline']['sha']).to eq build.pipeline.sha
- expect(json_build['pipeline']['status']).to eq build.pipeline.status
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
end
context 'filter jobs with one scope element' do
@@ -128,7 +140,7 @@ describe API::Jobs do
context 'jobs in different pipelines' do
let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:build2) { create(:ci_build, pipeline: pipeline2) }
+ let!(:job2) { create(:ci_build, pipeline: pipeline2) }
it 'excludes jobs from other pipelines' do
json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
@@ -147,7 +159,7 @@ describe API::Jobs do
describe 'GET /projects/:id/jobs/:job_id' do
before do
- get api("/projects/#{project.id}/jobs/#{build.id}", api_user)
+ get api("/projects/#{project.id}/jobs/#{job.id}", api_user)
end
context 'authorized user' do
@@ -157,12 +169,13 @@ describe API::Jobs do
end
it 'returns pipeline data' do
- json_build = json_response
- expect(json_build['pipeline']).not_to be_empty
- expect(json_build['pipeline']['id']).to eq build.pipeline.id
- expect(json_build['pipeline']['ref']).to eq build.pipeline.ref
- expect(json_build['pipeline']['sha']).to eq build.pipeline.sha
- expect(json_build['pipeline']['status']).to eq build.pipeline.status
+ json_job = json_response
+
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
end
end
@@ -177,11 +190,11 @@ describe API::Jobs do
describe 'GET /projects/:id/jobs/:job_id/artifacts' do
before do
- get api("/projects/#{project.id}/jobs/#{build.id}/artifacts", api_user)
+ get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
end
context 'job with artifacts' do
- let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
context 'authorized user' do
let(:download_headers) do
@@ -192,7 +205,7 @@ describe API::Jobs do
it 'returns specific job artifacts' do
expect(response).to have_http_status(200)
expect(response.headers).to include(download_headers)
- expect(response.body).to match_file(build.artifacts_file.file.file)
+ expect(response.body).to match_file(job.artifacts_file.file.file)
end
end
@@ -211,15 +224,15 @@ describe API::Jobs do
end
describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do
- let(:api_user) { reporter.user }
- let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let(:api_user) { reporter }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
before do
- build.success
+ job.success
end
- def get_for_ref(ref = pipeline.ref, job = build.name)
- get api("/projects/#{project.id}/jobs/artifacts/#{ref}/download", api_user), job: job
+ def get_for_ref(ref = pipeline.ref, job_name = job.name)
+ get api("/projects/#{project.id}/jobs/artifacts/#{ref}/download", api_user), job: job_name
end
context 'when not logged in' do
@@ -235,7 +248,7 @@ describe API::Jobs do
end
context 'when logging as guest' do
- let(:api_user) { guest.user }
+ let(:api_user) { guest }
before do
get_for_ref
@@ -273,7 +286,7 @@ describe API::Jobs do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
'Content-Disposition' =>
- "attachment; filename=#{build.artifacts_file.filename}" }
+ "attachment; filename=#{job.artifacts_file.filename}" }
end
it { expect(response).to have_http_status(200) }
@@ -309,16 +322,16 @@ describe API::Jobs do
end
describe 'GET /projects/:id/jobs/:job_id/trace' do
- let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
before do
- get api("/projects/#{project.id}/jobs/#{build.id}/trace", api_user)
+ get api("/projects/#{project.id}/jobs/#{job.id}/trace", api_user)
end
context 'authorized user' do
it 'returns specific job trace' do
expect(response).to have_http_status(200)
- expect(response.body).to eq(build.trace.raw)
+ expect(response.body).to eq(job.trace.raw)
end
end
@@ -333,7 +346,7 @@ describe API::Jobs do
describe 'POST /projects/:id/jobs/:job_id/cancel' do
before do
- post api("/projects/#{project.id}/jobs/#{build.id}/cancel", api_user)
+ post api("/projects/#{project.id}/jobs/#{job.id}/cancel", api_user)
end
context 'authorized user' do
@@ -345,7 +358,7 @@ describe API::Jobs do
end
context 'user without :update_build permission' do
- let(:api_user) { reporter.user }
+ let(:api_user) { reporter }
it 'does not cancel job' do
expect(response).to have_http_status(403)
@@ -363,10 +376,10 @@ describe API::Jobs do
end
describe 'POST /projects/:id/jobs/:job_id/retry' do
- let(:build) { create(:ci_build, :canceled, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :canceled, pipeline: pipeline) }
before do
- post api("/projects/#{project.id}/jobs/#{build.id}/retry", api_user)
+ post api("/projects/#{project.id}/jobs/#{job.id}/retry", api_user)
end
context 'authorized user' do
@@ -379,7 +392,7 @@ describe API::Jobs do
end
context 'user without :update_build permission' do
- let(:api_user) { reporter.user }
+ let(:api_user) { reporter }
it 'does not retry job' do
expect(response).to have_http_status(403)
@@ -398,28 +411,29 @@ describe API::Jobs do
describe 'POST /projects/:id/jobs/:job_id/erase' do
before do
- post api("/projects/#{project.id}/jobs/#{build.id}/erase", user)
+ post api("/projects/#{project.id}/jobs/#{job.id}/erase", user)
end
context 'job is erasable' do
- let(:build) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) }
it 'erases job content' do
expect(response).to have_http_status(201)
- expect(build).not_to have_trace
- expect(build.artifacts_file.exists?).to be_falsy
- expect(build.artifacts_metadata.exists?).to be_falsy
+ expect(job).not_to have_trace
+ expect(job.artifacts_file.exists?).to be_falsy
+ expect(job.artifacts_metadata.exists?).to be_falsy
end
it 'updates job' do
- build.reload
- expect(build.erased_at).to be_truthy
- expect(build.erased_by).to eq(user)
+ job.reload
+
+ expect(job.erased_at).to be_truthy
+ expect(job.erased_by).to eq(user)
end
end
context 'job is not erasable' do
- let(:build) { create(:ci_build, :trace, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace, project: project, pipeline: pipeline) }
it 'responds with forbidden' do
expect(response).to have_http_status(403)
@@ -427,25 +441,25 @@ describe API::Jobs do
end
end
- describe 'POST /projects/:id/jobs/:build_id/artifacts/keep' do
+ describe 'POST /projects/:id/jobs/:job_id/artifacts/keep' do
before do
- post api("/projects/#{project.id}/jobs/#{build.id}/artifacts/keep", user)
+ post api("/projects/#{project.id}/jobs/#{job.id}/artifacts/keep", user)
end
context 'artifacts did not expire' do
- let(:build) do
+ let(:job) do
create(:ci_build, :trace, :artifacts, :success,
project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days)
end
it 'keeps artifacts' do
expect(response).to have_http_status(200)
- expect(build.reload.artifacts_expire_at).to be_nil
+ expect(job.reload.artifacts_expire_at).to be_nil
end
end
context 'no artifacts' do
- let(:build) { create(:ci_build, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, project: project, pipeline: pipeline) }
it 'responds with not found' do
expect(response).to have_http_status(404)
@@ -455,16 +469,39 @@ describe API::Jobs do
describe 'POST /projects/:id/jobs/:job_id/play' do
before do
- post api("/projects/#{project.id}/jobs/#{build.id}/play", user)
+ post api("/projects/#{project.id}/jobs/#{job.id}/play", api_user)
end
context 'on an playable job' do
- let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :manual, project: project, pipeline: pipeline) }
- it 'plays the job' do
- expect(response).to have_http_status(200)
- expect(json_response['user']['id']).to eq(user.id)
- expect(json_response['id']).to eq(build.id)
+ context 'when user is authorized to trigger a manual action' do
+ it 'plays the job' do
+ expect(response).to have_http_status(200)
+ expect(json_response['user']['id']).to eq(user.id)
+ expect(json_response['id']).to eq(job.id)
+ expect(job.reload).to be_pending
+ end
+ end
+
+ context 'when user is not authorized to trigger a manual action' do
+ context 'when user does not have access to the project' do
+ let(:api_user) { create(:user) }
+
+ it 'does not trigger a manual action' do
+ expect(job.reload).to be_manual
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'when user is not allowed to trigger the manual action' do
+ let(:api_user) { reporter }
+
+ it 'does not trigger a manual action' do
+ expect(job.reload).to be_manual
+ expect(response).to have_http_status(403)
+ end
+ end
end
end
diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb
index ab957c72984..f534332ca6c 100644
--- a/spec/requests/api/keys_spec.rb
+++ b/spec/requests/api/keys_spec.rb
@@ -4,11 +4,9 @@ describe API::Keys do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) }
- let(:email) { create(:email, user: user) }
+ let(:email) { create(:email, user: user) }
describe 'GET /keys/:uid' do
- before { admin }
-
context 'when unauthenticated' do
it 'returns authentication error' do
get api("/keys/#{key.id}")
diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb
index 0c6b55c1630..5a4257d1009 100644
--- a/spec/requests/api/labels_spec.rb
+++ b/spec/requests/api/labels_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::Labels do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:label1) { create(:label, title: 'label1', project: project) }
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
@@ -339,7 +339,9 @@ describe API::Labels do
end
context "when user is already subscribed to label" do
- before { label1.subscribe(user, project) }
+ before do
+ label1.subscribe(user, project)
+ end
it "returns 304" do
post api("/projects/#{project.id}/labels/#{label1.id}/subscribe", user)
@@ -358,7 +360,9 @@ describe API::Labels do
end
describe "POST /projects/:id/labels/:label_id/unsubscribe" do
- before { label1.subscribe(user, project) }
+ before do
+ label1.subscribe(user, project)
+ end
context "when label_id is a label title" do
it "unsubscribes from the label" do
@@ -381,7 +385,9 @@ describe API::Labels do
end
context "when user is already unsubscribed from label" do
- before { label1.unsubscribe(user, project) }
+ before do
+ label1.unsubscribe(user, project)
+ end
it "returns 304" do
post api("/projects/#{project.id}/labels/#{label1.id}/unsubscribe", user)
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index e095053fa03..06aca698c91 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -7,7 +7,7 @@ describe API::Members do
let(:stranger) { create(:user) }
let(:project) do
- create(:empty_project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
+ create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
project.team << [developer, :developer]
project.team << [master, :master]
project.request_access(access_requester)
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index c4bff1647b5..0db645863fb 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -16,26 +16,136 @@ describe API::MergeRequests do
let!(:label) do
create(:label, title: 'label', color: '#FFAABB', project: project)
end
+ let!(:label2) { create(:label, title: 'a-test', color: '#FFFFFF', project: project) }
let!(:label_link) { create(:label_link, label: label, target: merge_request) }
+ let!(:label_link2) { create(:label_link, label: label2, target: merge_request) }
+ let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request) }
+ let!(:upvote) { create(:award_emoji, :upvote, awardable: merge_request) }
before do
project.team << [user, :reporter]
end
+ describe 'GET /merge_requests' do
+ context 'when unauthenticated' do
+ it 'returns authentication error' do
+ get api('/merge_requests')
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+ end
+
+ context 'when authenticated' do
+ let!(:project2) { create(:project, :public, namespace: user.namespace) }
+ let!(:merge_request2) { create(:merge_request, :simple, author: user, assignee: user, source_project: project2, target_project: project2) }
+ let(:user2) { create(:user) }
+
+ it 'returns an array of all merge requests' do
+ get api('/merge_requests', user), scope: :all
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |mr| mr['id'] })
+ .to contain_exactly(merge_request.id, merge_request_closed.id, merge_request_merged.id, merge_request2.id)
+ end
+
+ it 'does not return unauthorized merge requests' do
+ private_project = create(:project, :private)
+ merge_request3 = create(:merge_request, :simple, source_project: private_project, target_project: private_project, source_branch: 'other-branch')
+
+ get api('/merge_requests', user), scope: :all
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |mr| mr['id'] })
+ .not_to include(merge_request3.id)
+ end
+
+ it 'returns an array of merge requests created by current user if no scope is given' do
+ merge_request3 = create(:merge_request, :simple, author: user2, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch')
+
+ get api('/merge_requests', user2)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request3.id)
+ end
+
+ it 'returns an array of merge requests authored by the given user' do
+ merge_request3 = create(:merge_request, :simple, author: user2, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch')
+
+ get api('/merge_requests', user), author_id: user2.id, scope: :all
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request3.id)
+ end
+
+ it 'returns an array of merge requests assigned to the given user' do
+ merge_request3 = create(:merge_request, :simple, author: user, assignee: user2, source_project: project2, target_project: project2, source_branch: 'other-branch')
+
+ get api('/merge_requests', user), assignee_id: user2.id, scope: :all
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request3.id)
+ end
+
+ it 'returns an array of merge requests assigned to me' do
+ merge_request3 = create(:merge_request, :simple, author: user, assignee: user2, source_project: project2, target_project: project2, source_branch: 'other-branch')
+
+ get api('/merge_requests', user2), scope: 'assigned-to-me'
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request3.id)
+ end
+
+ it 'returns an array of merge requests created by me' do
+ merge_request3 = create(:merge_request, :simple, author: user2, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch')
+
+ get api('/merge_requests', user2), scope: 'created-by-me'
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request3.id)
+ end
+ end
+ end
+
describe "GET /projects/:id/merge_requests" do
context "when unauthenticated" do
it "returns authentication error" do
get api("/projects/#{project.id}/merge_requests")
- expect(response).to have_http_status(401)
+ expect(response).to have_gitlab_http_status(401)
end
end
context "when authenticated" do
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}/merge_requests", user)
+ end.count
+
+ create(:merge_request, state: 'closed', milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time)
+
+ expect do
+ get api("/projects/#{project.id}/merge_requests", user)
+ end.not_to exceed_query_limit(control_count)
+ end
+
it "returns an array of all merge_requests" do
get api("/projects/#{project.id}/merge_requests", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
@@ -44,16 +154,35 @@ describe API::MergeRequests do
expect(json_response.last['sha']).to eq(merge_request.diff_head_sha)
expect(json_response.last['merge_commit_sha']).to be_nil
expect(json_response.last['merge_commit_sha']).to eq(merge_request.merge_commit_sha)
+ expect(json_response.last['downvotes']).to eq(1)
+ expect(json_response.last['upvotes']).to eq(1)
+ expect(json_response.last['labels']).to eq([label2.title, label.title])
expect(json_response.first['title']).to eq(merge_request_merged.title)
expect(json_response.first['sha']).to eq(merge_request_merged.diff_head_sha)
expect(json_response.first['merge_commit_sha']).not_to be_nil
expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha)
end
+ it "returns an array of all merge_requests using simple mode" do
+ get api("/projects/#{project.id}/merge_requests?view=simple", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response.last.keys).to match_array(%w(id iid title web_url created_at description project_id state updated_at))
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(3)
+ expect(json_response.last['iid']).to eq(merge_request.iid)
+ expect(json_response.last['title']).to eq(merge_request.title)
+ expect(json_response.last).to have_key('web_url')
+ expect(json_response.first['iid']).to eq(merge_request_merged.iid)
+ expect(json_response.first['title']).to eq(merge_request_merged.title)
+ expect(json_response.first).to have_key('web_url')
+ end
+
it "returns an array of all merge_requests" do
get api("/projects/#{project.id}/merge_requests?state", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
@@ -63,7 +192,7 @@ describe API::MergeRequests do
it "returns an array of open merge_requests" do
get api("/projects/#{project.id}/merge_requests?state=opened", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
@@ -73,7 +202,7 @@ describe API::MergeRequests do
it "returns an array of closed merge_requests" do
get api("/projects/#{project.id}/merge_requests?state=closed", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
@@ -83,7 +212,7 @@ describe API::MergeRequests do
it "returns an array of merged merge_requests" do
get api("/projects/#{project.id}/merge_requests?state=merged", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
@@ -93,7 +222,7 @@ describe API::MergeRequests do
it 'returns merge_request by "iids" array' do
get api("/projects/#{project.id}/merge_requests", user), iids: [merge_request.iid, merge_request_closed.iid]
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
expect(json_response.first['title']).to eq merge_request_closed.title
@@ -103,14 +232,14 @@ describe API::MergeRequests do
it 'matches V4 response schema' do
get api("/projects/#{project.id}/merge_requests", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/merge_requests')
end
it 'returns an empty array if no issue matches milestone' do
get api("/projects/#{project.id}/merge_requests", user), milestone: '1.0.0'
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
@@ -118,7 +247,7 @@ describe API::MergeRequests do
it 'returns an empty array if milestone does not exist' do
get api("/projects/#{project.id}/merge_requests", user), milestone: 'foo'
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
@@ -133,7 +262,7 @@ describe API::MergeRequests do
it 'returns an array of merge requests matching state in milestone' do
get api("/projects/#{project.id}/merge_requests", user), milestone: '0.9', state: 'closed'
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request_closed.id)
@@ -142,16 +271,16 @@ describe API::MergeRequests do
it 'returns an array of labeled merge requests' do
get api("/projects/#{project.id}/merge_requests?labels=#{label.title}", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
- expect(json_response.first['labels']).to eq([label.title])
+ expect(json_response.first['labels']).to eq([label2.title, label.title])
end
it 'returns an array of labeled merge requests where all labels match' do
get api("/projects/#{project.id}/merge_requests?labels=#{label.title},foo,bar", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
@@ -159,11 +288,31 @@ describe API::MergeRequests do
it 'returns an empty array if no merge request matches labels' do
get api("/projects/#{project.id}/merge_requests?labels=foo,bar", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
+ it 'returns an array of labeled merge requests that are merged for a milestone' do
+ bug_label = create(:label, title: 'bug', color: '#FFAABB', project: project)
+
+ mr1 = create(:merge_request, state: "merged", source_project: project, target_project: project, milestone: milestone)
+ mr2 = create(:merge_request, state: "merged", source_project: project, target_project: project, milestone: milestone1)
+ mr3 = create(:merge_request, state: "closed", source_project: project, target_project: project, milestone: milestone1)
+ _mr = create(:merge_request, state: "merged", source_project: project, target_project: project, milestone: milestone1)
+
+ create(:label_link, label: bug_label, target: mr1)
+ create(:label_link, label: bug_label, target: mr2)
+ create(:label_link, label: bug_label, target: mr3)
+
+ get api("/projects/#{project.id}/merge_requests?labels=#{bug_label.title}&milestone=#{milestone1.title}&state=merged", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(mr2.id)
+ end
+
context "with ordering" do
before do
@mr_later = mr_with_later_created_and_updated_at_time
@@ -173,44 +322,44 @@ describe API::MergeRequests do
it "returns an array of merge_requests in ascending order" do
get api("/projects/#{project.id}/merge_requests?sort=asc", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
end
it "returns an array of merge_requests in descending order" do
get api("/projects/#{project.id}/merge_requests?sort=desc", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort.reverse)
end
it "returns an array of merge_requests ordered by updated_at" do
get api("/projects/#{project.id}/merge_requests?order_by=updated_at", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['updated_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['updated_at'] }
expect(response_dates).to eq(response_dates.sort.reverse)
end
it "returns an array of merge_requests ordered by created_at" do
get api("/projects/#{project.id}/merge_requests?order_by=created_at&sort=asc", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
end
end
@@ -221,7 +370,7 @@ describe API::MergeRequests do
it 'exposes known attributes' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['id']).to eq(merge_request.id)
expect(json_response['iid']).to eq(merge_request.iid)
expect(json_response['project_id']).to eq(merge_request.project.id)
@@ -236,8 +385,8 @@ describe API::MergeRequests do
expect(json_response['author']).to be_a Hash
expect(json_response['target_branch']).to eq(merge_request.target_branch)
expect(json_response['source_branch']).to eq(merge_request.source_branch)
- expect(json_response['upvotes']).to eq(0)
- expect(json_response['downvotes']).to eq(0)
+ expect(json_response['upvotes']).to eq(1)
+ expect(json_response['downvotes']).to eq(1)
expect(json_response['source_project_id']).to eq(merge_request.source_project.id)
expect(json_response['target_project_id']).to eq(merge_request.target_project.id)
expect(json_response['work_in_progress']).to be_falsy
@@ -249,7 +398,7 @@ describe API::MergeRequests do
it "returns merge_request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(merge_request.title)
expect(json_response['iid']).to eq(merge_request.iid)
expect(json_response['work_in_progress']).to eq(false)
@@ -260,13 +409,13 @@ describe API::MergeRequests do
it "returns a 404 error if merge_request_iid not found" do
get api("/projects/#{project.id}/merge_requests/999", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it "returns a 404 error if merge_request `id` is used instead of iid" do
get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
context 'Work in Progress' do
@@ -274,7 +423,7 @@ describe API::MergeRequests do
it "returns merge_request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['work_in_progress']).to eq(true)
end
end
@@ -285,7 +434,7 @@ describe API::MergeRequests do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/commits", user)
commit = merge_request.commits.first
- expect(response.status).to eq 200
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(merge_request.commits.size)
@@ -295,13 +444,13 @@ describe API::MergeRequests do
it 'returns a 404 when merge_request_iid not found' do
get api("/projects/#{project.id}/merge_requests/999/commits", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns a 404 when merge_request id is used instead of iid' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
@@ -309,19 +458,19 @@ describe API::MergeRequests do
it 'returns the change information of the merge_request' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
- expect(response.status).to eq 200
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['changes'].size).to eq(merge_request.diffs.size)
end
it 'returns a 404 when merge_request_iid not found' do
get api("/projects/#{project.id}/merge_requests/999/changes", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns a 404 when merge_request id is used instead of iid' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
@@ -334,38 +483,37 @@ describe API::MergeRequests do
target_branch: 'master',
author: user,
labels: 'label, label2',
- milestone_id: milestone.id,
- remove_source_branch: true
+ milestone_id: milestone.id
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq('Test merge_request')
expect(json_response['labels']).to eq(%w(label label2))
expect(json_response['milestone']['id']).to eq(milestone.id)
- expect(json_response['force_remove_source_branch']).to be_truthy
+ expect(json_response['force_remove_source_branch']).to be_falsy
end
it "returns 422 when source_branch equals target_branch" do
post api("/projects/#{project.id}/merge_requests", user),
title: "Test merge_request", source_branch: "master", target_branch: "master", author: user
- expect(response).to have_http_status(422)
+ expect(response).to have_gitlab_http_status(422)
end
it "returns 400 when source_branch is missing" do
post api("/projects/#{project.id}/merge_requests", user),
title: "Test merge_request", target_branch: "master", author: user
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when target_branch is missing" do
post api("/projects/#{project.id}/merge_requests", user),
title: "Test merge_request", source_branch: "markdown", author: user
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when title is missing" do
post api("/projects/#{project.id}/merge_requests", user),
target_branch: 'master', source_branch: 'markdown'
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it 'allows special label names' do
@@ -375,7 +523,7 @@ describe API::MergeRequests do
target_branch: 'master',
author: user,
labels: 'label, label?, label&foo, ?, &'
- expect(response.status).to eq(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['labels']).to include 'label'
expect(json_response['labels']).to include 'label?'
expect(json_response['labels']).to include 'label&foo'
@@ -401,25 +549,48 @@ describe API::MergeRequests do
target_branch: 'master',
author: user
end.to change { MergeRequest.count }.by(0)
- expect(response).to have_http_status(409)
+ expect(response).to have_gitlab_http_status(409)
+ end
+ end
+
+ context 'accepts remove_source_branch parameter' do
+ let(:params) do
+ { title: 'Test merge_request',
+ source_branch: 'markdown',
+ target_branch: 'master',
+ author: user }
+ end
+
+ it 'sets force_remove_source_branch to false' do
+ post api("/projects/#{project.id}/merge_requests", user), params.merge(remove_source_branch: false)
+
+ expect(json_response['force_remove_source_branch']).to be_falsy
+ end
+
+ it 'sets force_remove_source_branch to true' do
+ post api("/projects/#{project.id}/merge_requests", user), params.merge(remove_source_branch: true)
+
+ expect(json_response['force_remove_source_branch']).to be_truthy
end
end
end
context 'forked projects' do
let!(:user2) { create(:user) }
- let!(:fork_project) { create(:empty_project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) }
- let!(:unrelated_project) { create(:empty_project, namespace: create(:user).namespace, creator_id: user2.id) }
+ let!(:fork_project) { create(:project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) }
+ let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) }
+
+ before do
+ fork_project.add_reporter(user2)
- before :each do |each|
- fork_project.team << [user2, :reporter]
+ allow_any_instance_of(MergeRequest).to receive(:write_ref)
end
it "returns merge_request" do
post api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master",
author: user2, target_project_id: project.id, description: 'Test description for Test merge_request'
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq('Test merge_request')
expect(json_response['description']).to eq('Test description for Test merge_request')
end
@@ -430,26 +601,39 @@ describe API::MergeRequests do
expect(fork_project.forked_from_project).to eq(project)
post api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq('Test merge_request')
end
+ it 'returns 422 when target project has disabled merge requests' do
+ project.project_feature.update(merge_requests_access_level: 0)
+
+ post api("/projects/#{fork_project.id}/merge_requests", user2),
+ title: 'Test',
+ target_branch: 'master',
+ source_branch: 'markdown',
+ author: user2,
+ target_project_id: project.id
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
it "returns 400 when source_branch is missing" do
post api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when target_branch is missing" do
post api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when title is missing" do
post api("/projects/#{fork_project.id}/merge_requests", user2),
target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
context 'when target_branch is specified' do
@@ -460,7 +644,7 @@ describe API::MergeRequests do
source_branch: 'markdown',
author: user,
target_project_id: fork_project.id
- expect(response).to have_http_status(422)
+ expect(response).to have_gitlab_http_status(422)
end
it 'returns 422 if targeting a different fork' do
@@ -470,14 +654,14 @@ describe API::MergeRequests do
source_branch: 'markdown',
author: user2,
target_project_id: unrelated_project.id
- expect(response).to have_http_status(422)
+ expect(response).to have_gitlab_http_status(422)
end
end
it "returns 201 when target_branch is specified and for the same project" do
post api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
end
end
end
@@ -492,7 +676,7 @@ describe API::MergeRequests do
it "denies the deletion of the merge request" do
delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", developer)
- expect(response).to have_http_status(403)
+ expect(response).to have_gitlab_http_status(403)
end
end
@@ -500,19 +684,19 @@ describe API::MergeRequests do
it "destroys the merge request owners can destroy" do
delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
- expect(response).to have_http_status(204)
+ expect(response).to have_gitlab_http_status(204)
end
it "returns 404 for an invalid merge request IID" do
delete api("/projects/#{project.id}/merge_requests/12345", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it "returns 404 if the merge request id is used instead of iid" do
delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
end
@@ -523,30 +707,30 @@ describe API::MergeRequests do
it "returns merge_request in case of success" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
end
it "returns 406 if branch can't be merged" do
- allow_any_instance_of(MergeRequest).
- to receive(:can_be_merged?).and_return(false)
+ allow_any_instance_of(MergeRequest)
+ .to receive(:can_be_merged?).and_return(false)
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
- expect(response).to have_http_status(406)
+ expect(response).to have_gitlab_http_status(406)
expect(json_response['message']).to eq('Branch cannot be merged')
end
it "returns 405 if merge_request is not open" do
merge_request.close
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
- expect(response).to have_http_status(405)
+ expect(response).to have_gitlab_http_status(405)
expect(json_response['message']).to eq('405 Method Not Allowed')
end
it "returns 405 if merge_request is a work in progress" do
merge_request.update_attribute(:title, "WIP: #{merge_request.title}")
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
- expect(response).to have_http_status(405)
+ expect(response).to have_gitlab_http_status(405)
expect(json_response['message']).to eq('405 Method Not Allowed')
end
@@ -555,7 +739,7 @@ describe API::MergeRequests do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user)
- expect(response).to have_http_status(405)
+ expect(response).to have_gitlab_http_status(405)
expect(json_response['message']).to eq('405 Method Not Allowed')
end
@@ -563,21 +747,21 @@ describe API::MergeRequests do
user2 = create(:user)
project.team << [user2, :reporter]
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user2)
- expect(response).to have_http_status(401)
+ expect(response).to have_gitlab_http_status(401)
expect(json_response['message']).to eq('401 Unauthorized')
end
it "returns 409 if the SHA parameter doesn't match" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha.reverse
- expect(response).to have_http_status(409)
+ expect(response).to have_gitlab_http_status(409)
expect(json_response['message']).to start_with('SHA does not match HEAD of source branch')
end
it "succeeds if the SHA parameter matches" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
end
it "enables merge when pipeline succeeds if the pipeline is active" do
@@ -586,7 +770,7 @@ describe API::MergeRequests do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), merge_when_pipeline_succeeds: true
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq('Test')
expect(json_response['merge_when_pipeline_succeeds']).to eq(true)
end
@@ -598,7 +782,7 @@ describe API::MergeRequests do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), merge_when_pipeline_succeeds: true
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq('Test')
expect(json_response['merge_when_pipeline_succeeds']).to eq(true)
end
@@ -606,13 +790,13 @@ describe API::MergeRequests do
it "returns 404 for an invalid merge request IID" do
put api("/projects/#{project.id}/merge_requests/12345/merge", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it "returns 404 if the merge request id is used instead of iid" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
@@ -621,39 +805,39 @@ describe API::MergeRequests do
it "returns merge_request" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: "close"
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['state']).to eq('closed')
end
end
it "updates title and returns merge_request" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), title: "New title"
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq('New title')
end
it "updates description and returns merge_request" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), description: "New description"
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['description']).to eq('New description')
end
it "updates milestone_id and returns merge_request" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), milestone_id: milestone.id
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['milestone']['id']).to eq(milestone.id)
end
it "returns merge_request with renamed target_branch" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), target_branch: "wiki"
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['target_branch']).to eq('wiki')
end
it "returns merge_request that removes the source branch" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), remove_source_branch: true
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['force_remove_source_branch']).to be_truthy
end
@@ -674,7 +858,7 @@ describe API::MergeRequests do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', title: nil
merge_request.reload
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
expect(merge_request.state).to eq('opened')
end
@@ -682,20 +866,20 @@ describe API::MergeRequests do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', target_branch: nil
merge_request.reload
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
expect(merge_request.state).to eq('opened')
end
it "returns 404 for an invalid merge request IID" do
put api("/projects/#{project.id}/merge_requests/12345", user), state_event: "close"
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it "returns 404 if the merge request id is used instead of iid" do
put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close"
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
@@ -708,7 +892,7 @@ describe API::MergeRequests do
get api("/projects/#{project.id}/merge_requests/#{mr.iid}/closes_issues", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
@@ -718,49 +902,55 @@ describe API::MergeRequests do
it 'returns an empty array when there are no issues to be closed' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
it 'handles external issues' do
- jira_project = create(:jira_project, :public, name: 'JIR_EXT1')
- issue = ExternalIssue.new("#{jira_project.name}-123", jira_project)
- merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project)
- merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}")
+ jira_project = create(:jira_project, :public, :repository, name: 'JIR_EXT1')
+ ext_issue = ExternalIssue.new("#{jira_project.name}-123", jira_project)
+ issue = create(:issue, project: jira_project)
+ description = "Closes #{ext_issue.to_reference(jira_project)}\ncloses #{issue.to_reference}"
+ merge_request = create(:merge_request,
+ :simple, author: user, assignee: user, source_project: jira_project, description: description)
get api("/projects/#{jira_project.id}/merge_requests/#{merge_request.iid}/closes_issues", user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
- expect(json_response.length).to eq(1)
+ expect(json_response.length).to eq(2)
+ expect(json_response.second['title']).to eq(ext_issue.title)
+ expect(json_response.second['id']).to eq(ext_issue.id)
+ expect(json_response.second['confidential']).to be_nil
expect(json_response.first['title']).to eq(issue.title)
expect(json_response.first['id']).to eq(issue.id)
+ expect(json_response.first['confidential']).not_to be_nil
end
it 'returns 403 if the user has no access to the merge request' do
- project = create(:empty_project, :private)
+ project = create(:project, :private)
merge_request = create(:merge_request, :simple, source_project: project)
guest = create(:user)
project.team << [guest, :guest]
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", guest)
- expect(response).to have_http_status(403)
+ expect(response).to have_gitlab_http_status(403)
end
it "returns 404 for an invalid merge request IID" do
get api("/projects/#{project.id}/merge_requests/12345/closes_issues", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it "returns 404 if the merge request id is used instead of iid" do
get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
end
@@ -768,26 +958,26 @@ describe API::MergeRequests do
it 'subscribes to a merge request' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", admin)
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['subscribed']).to eq(true)
end
it 'returns 304 if already subscribed' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", user)
- expect(response).to have_http_status(304)
+ expect(response).to have_gitlab_http_status(304)
end
it 'returns 404 if the merge request is not found' do
post api("/projects/#{project.id}/merge_requests/123/subscribe", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns 404 if the merge request id is used instead of iid' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns 403 if user has no access to read code' do
@@ -796,7 +986,7 @@ describe API::MergeRequests do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", guest)
- expect(response).to have_http_status(403)
+ expect(response).to have_gitlab_http_status(403)
end
end
@@ -804,26 +994,26 @@ describe API::MergeRequests do
it 'unsubscribes from a merge request' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", user)
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['subscribed']).to eq(false)
end
it 'returns 304 if not subscribed' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", admin)
- expect(response).to have_http_status(304)
+ expect(response).to have_gitlab_http_status(304)
end
it 'returns 404 if the merge request is not found' do
post api("/projects/#{project.id}/merge_requests/123/unsubscribe", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns 404 if the merge request id is used instead of iid' do
post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", user)
- expect(response).to have_http_status(404)
+ expect(response).to have_gitlab_http_status(404)
end
it 'returns 403 if user has no access to read code' do
@@ -832,7 +1022,7 @@ describe API::MergeRequests do
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", guest)
- expect(response).to have_http_status(403)
+ expect(response).to have_gitlab_http_status(403)
end
end
diff --git a/spec/requests/api/milestones_spec.rb b/spec/requests/api/milestones_spec.rb
deleted file mode 100644
index dd74351a2b1..00000000000
--- a/spec/requests/api/milestones_spec.rb
+++ /dev/null
@@ -1,351 +0,0 @@
-require 'spec_helper'
-
-describe API::Milestones do
- let(:user) { create(:user) }
- let!(:project) { create(:empty_project, namespace: user.namespace ) }
- let!(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') }
- let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
-
- before { project.team << [user, :developer] }
-
- describe 'GET /projects/:id/milestones' do
- it 'returns project milestones' do
- get api("/projects/#{project.id}/milestones", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['title']).to eq(milestone.title)
- end
-
- it 'returns a 401 error if user not authenticated' do
- get api("/projects/#{project.id}/milestones")
-
- expect(response).to have_http_status(401)
- end
-
- it 'returns an array of active milestones' do
- get api("/projects/#{project.id}/milestones?state=active", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.length).to eq(1)
- expect(json_response.first['id']).to eq(milestone.id)
- end
-
- it 'returns an array of closed milestones' do
- get api("/projects/#{project.id}/milestones?state=closed", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.length).to eq(1)
- expect(json_response.first['id']).to eq(closed_milestone.id)
- end
-
- it 'returns an array of milestones specified by iids' do
- other_milestone = create(:milestone, project: project)
-
- get api("/projects/#{project.id}/milestones", user), iids: [closed_milestone.iid, other_milestone.iid]
-
- expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
- expect(json_response.length).to eq(2)
- expect(json_response.map{ |m| m['id'] }).to match_array([closed_milestone.id, other_milestone.id])
- end
-
- it 'does not return any milestone if none found' do
- get api("/projects/#{project.id}/milestones", user), iids: [Milestone.maximum(:iid).succ]
-
- expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
- expect(json_response.length).to eq(0)
- end
- end
-
- describe 'GET /projects/:id/milestones/:milestone_id' do
- it 'returns a project milestone by id' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}", user)
-
- expect(response).to have_http_status(200)
- expect(json_response['title']).to eq(milestone.title)
- expect(json_response['iid']).to eq(milestone.iid)
- end
-
- it 'returns a project milestone by iids array' do
- get api("/projects/#{project.id}/milestones?iids=#{closed_milestone.iid}", user)
-
- expect(response.status).to eq 200
- expect(response).to include_pagination_headers
- expect(json_response.size).to eq(1)
- expect(json_response.size).to eq(1)
- expect(json_response.first['title']).to eq closed_milestone.title
- expect(json_response.first['id']).to eq closed_milestone.id
- end
-
- it 'returns a project milestone by searching for title' do
- get api("/projects/#{project.id}/milestones", user), search: 'version2'
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response.size).to eq(1)
- expect(json_response.first['title']).to eq milestone.title
- expect(json_response.first['id']).to eq milestone.id
- end
-
- it 'returns a project milestones by searching for description' do
- get api("/projects/#{project.id}/milestones", user), search: 'open'
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response.size).to eq(1)
- expect(json_response.first['title']).to eq milestone.title
- expect(json_response.first['id']).to eq milestone.id
- end
- end
-
- describe 'GET /projects/:id/milestones/:milestone_id' do
- it 'returns a project milestone by id' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}", user)
-
- expect(response).to have_http_status(200)
- expect(json_response['title']).to eq(milestone.title)
- expect(json_response['iid']).to eq(milestone.iid)
- end
-
- it 'returns 401 error if user not authenticated' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}")
-
- expect(response).to have_http_status(401)
- end
-
- it 'returns a 404 error if milestone id not found' do
- get api("/projects/#{project.id}/milestones/1234", user)
-
- expect(response).to have_http_status(404)
- end
- end
-
- describe 'POST /projects/:id/milestones' do
- it 'creates a new project milestone' do
- post api("/projects/#{project.id}/milestones", user), title: 'new milestone'
-
- expect(response).to have_http_status(201)
- expect(json_response['title']).to eq('new milestone')
- expect(json_response['description']).to be_nil
- end
-
- it 'creates a new project milestone with description and dates' do
- post api("/projects/#{project.id}/milestones", user),
- title: 'new milestone', description: 'release', due_date: '2013-03-02', start_date: '2013-02-02'
-
- expect(response).to have_http_status(201)
- expect(json_response['description']).to eq('release')
- expect(json_response['due_date']).to eq('2013-03-02')
- expect(json_response['start_date']).to eq('2013-02-02')
- end
-
- it 'returns a 400 error if title is missing' do
- post api("/projects/#{project.id}/milestones", user)
-
- expect(response).to have_http_status(400)
- end
-
- it 'returns a 400 error if params are invalid (duplicate title)' do
- post api("/projects/#{project.id}/milestones", user),
- title: milestone.title, description: 'release', due_date: '2013-03-02'
-
- expect(response).to have_http_status(400)
- end
-
- it 'creates a new project with reserved html characters' do
- post api("/projects/#{project.id}/milestones", user), title: 'foo & bar 1.1 -> 2.2'
-
- expect(response).to have_http_status(201)
- expect(json_response['title']).to eq('foo & bar 1.1 -> 2.2')
- expect(json_response['description']).to be_nil
- end
- end
-
- describe 'PUT /projects/:id/milestones/:milestone_id' do
- it 'updates a project milestone' do
- put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
- title: 'updated title'
-
- expect(response).to have_http_status(200)
- expect(json_response['title']).to eq('updated title')
- end
-
- it 'removes a due date if nil is passed' do
- milestone.update!(due_date: "2016-08-05")
-
- put api("/projects/#{project.id}/milestones/#{milestone.id}", user), due_date: nil
-
- expect(response).to have_http_status(200)
- expect(json_response['due_date']).to be_nil
- end
-
- it 'returns a 404 error if milestone id not found' do
- put api("/projects/#{project.id}/milestones/1234", user),
- title: 'updated title'
-
- expect(response).to have_http_status(404)
- end
- end
-
- describe 'PUT /projects/:id/milestones/:milestone_id to close milestone' do
- it 'updates a project milestone' do
- put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
- state_event: 'close'
- expect(response).to have_http_status(200)
-
- expect(json_response['state']).to eq('closed')
- end
- end
-
- describe 'PUT /projects/:id/milestones/:milestone_id to test observer on close' do
- it 'creates an activity event when an milestone is closed' do
- expect(Event).to receive(:create)
-
- put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
- state_event: 'close'
- end
- end
-
- describe 'GET /projects/:id/milestones/:milestone_id/issues' do
- before do
- milestone.issues << create(:issue, project: project)
- end
- it 'returns project issues for a particular milestone' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['milestone']['title']).to eq(milestone.title)
- end
-
- it 'matches V4 response schema for a list of issues' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user)
-
- expect(response).to have_http_status(200)
- expect(response).to match_response_schema('public_api/v4/issues')
- end
-
- it 'returns a 401 error if user not authenticated' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}/issues")
-
- expect(response).to have_http_status(401)
- end
-
- describe 'confidential issues' do
- let(:public_project) { create(:empty_project, :public) }
- let(:milestone) { create(:milestone, project: public_project) }
- let(:issue) { create(:issue, project: public_project, position: 2) }
- let(:confidential_issue) { create(:issue, confidential: true, project: public_project, position: 1) }
-
- before do
- public_project.team << [user, :developer]
- milestone.issues << issue << confidential_issue
- end
-
- it 'returns confidential issues to team members' do
- get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(2)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id, confidential_issue.id)
- end
-
- it 'does not return confidential issues to team members with guest role' do
- member = create(:user)
- project.team << [member, :guest]
-
- get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", member)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(1)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
- end
-
- it 'does not return confidential issues to regular users' do
- get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", create(:user))
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(1)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
- end
-
- it 'returns issues ordered by position asc' do
- get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(2)
- expect(json_response.first['id']).to eq(confidential_issue.id)
- expect(json_response.second['id']).to eq(issue.id)
- end
- end
- end
-
- describe 'GET /projects/:id/milestones/:milestone_id/merge_requests' do
- let(:merge_request) { create(:merge_request, source_project: project, position: 2) }
- let(:another_merge_request) { create(:merge_request, :simple, source_project: project, position: 1) }
-
- before do
- milestone.merge_requests << merge_request
- end
-
- it 'returns project merge_requests for a particular milestone' do
- # eager-load another_merge_request
- another_merge_request
- get api("/projects/#{project.id}/milestones/#{milestone.id}/merge_requests", user)
-
- expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(1)
- expect(json_response.first['title']).to eq(merge_request.title)
- expect(json_response.first['milestone']['title']).to eq(milestone.title)
- end
-
- it 'returns a 404 error if milestone id not found' do
- get api("/projects/#{project.id}/milestones/1234/merge_requests", user)
-
- expect(response).to have_http_status(404)
- end
-
- it 'returns a 404 if the user has no access to the milestone' do
- new_user = create :user
- get api("/projects/#{project.id}/milestones/#{milestone.id}/merge_requests", new_user)
-
- expect(response).to have_http_status(404)
- end
-
- it 'returns a 401 error if user not authenticated' do
- get api("/projects/#{project.id}/milestones/#{milestone.id}/merge_requests")
-
- expect(response).to have_http_status(401)
- end
-
- it 'returns merge_requests ordered by position asc' do
- milestone.merge_requests << another_merge_request
-
- get api("/projects/#{project.id}/milestones/#{milestone.id}/merge_requests", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(2)
- expect(json_response.first['id']).to eq(another_merge_request.id)
- expect(json_response.second['id']).to eq(merge_request.id)
- end
- end
-end
diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb
index 3bf16a3ae27..26cf653ca8e 100644
--- a/spec/requests/api/namespaces_spec.rb
+++ b/spec/requests/api/namespaces_spec.rb
@@ -15,6 +15,20 @@ describe API::Namespaces do
end
context "when authenticated as admin" do
+ it "returns correct attributes" do
+ get api("/namespaces", admin)
+
+ group_kind_json_response = json_response.find { |resource| resource['kind'] == 'group' }
+ user_kind_json_response = json_response.find { |resource| resource['kind'] == 'user' }
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(group_kind_json_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path',
+ 'parent_id', 'members_count_with_descendants')
+
+ expect(user_kind_json_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', 'parent_id')
+ end
+
it "admin: returns an array of all namespaces" do
get api("/namespaces", admin)
@@ -37,6 +51,27 @@ describe API::Namespaces do
end
context "when authenticated as a regular user" do
+ it "returns correct attributes when user can admin group" do
+ group1.add_owner(user)
+
+ get api("/namespaces", user)
+
+ owned_group_response = json_response.find { |resource| resource['id'] == group1.id }
+
+ expect(owned_group_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path',
+ 'parent_id', 'members_count_with_descendants')
+ end
+
+ it "returns correct attributes when user cannot admin group" do
+ group1.add_guest(user)
+
+ get api("/namespaces", user)
+
+ guest_group_response = json_response.find { |resource| resource['id'] == group1.id }
+
+ expect(guest_group_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', 'parent_id')
+ end
+
it "user: returns an array of namespaces" do
get api("/namespaces", user)
diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb
index 6afcd237c3c..75e5062a99c 100644
--- a/spec/requests/api/notes_spec.rb
+++ b/spec/requests/api/notes_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::Notes do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, :public, namespace: user.namespace) }
+ let!(:project) { create(:project, :public, namespace: user.namespace) }
let!(:issue) { create(:issue, project: project, author: user) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
let!(:snippet) { create(:project_snippet, project: project, author: user) }
@@ -13,12 +13,12 @@ describe API::Notes do
# For testing the cross-reference of a private issue in a public issue
let(:private_user) { create(:user) }
let(:private_project) do
- create(:empty_project, namespace: private_user.namespace).
- tap { |p| p.team << [private_user, :master] }
+ create(:project, namespace: private_user.namespace)
+ .tap { |p| p.team << [private_user, :master] }
end
let(:private_issue) { create(:issue, project: private_project) }
- let(:ext_proj) { create(:empty_project, :public) }
+ let(:ext_proj) { create(:project, :public) }
let(:ext_issue) { create(:issue, project: ext_proj) }
let!(:cross_reference_note) do
@@ -28,7 +28,9 @@ describe API::Notes do
system: true
end
- before { project.team << [user, :reporter] }
+ before do
+ project.team << [user, :reporter]
+ end
describe "GET /projects/:id/noteable/:noteable_id/notes" do
context "when noteable is an Issue" do
@@ -58,7 +60,9 @@ describe API::Notes do
end
context "and issue is confidential" do
- before { ext_issue.update_attributes(confidential: true) }
+ before do
+ ext_issue.update_attributes(confidential: true)
+ end
it "returns 404" do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
@@ -150,7 +154,9 @@ describe API::Notes do
end
context "when issue is confidential" do
- before { issue.update_attributes(confidential: true) }
+ before do
+ issue.update_attributes(confidential: true)
+ end
it "returns 404" do
get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", private_user)
@@ -266,7 +272,7 @@ describe API::Notes do
context 'when user does not have access to read the noteable' do
it 'responds with 404' do
- project = create(:empty_project, :private) { |p| p.add_guest(user) }
+ project = create(:project, :private) { |p| p.add_guest(user) }
issue = create(:issue, :confidential, project: project)
post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user),
@@ -277,7 +283,7 @@ describe API::Notes do
end
context 'when user does not have access to create noteable' do
- let(:private_issue) { create(:issue, project: create(:empty_project, :private)) }
+ let(:private_issue) { create(:issue, project: create(:project, :private)) }
##
# We are posting to project user has access to, but we use issue id
diff --git a/spec/requests/api/notification_settings_spec.rb b/spec/requests/api/notification_settings_spec.rb
index f619b7e6eaf..7968659a1ec 100644
--- a/spec/requests/api/notification_settings_spec.rb
+++ b/spec/requests/api/notification_settings_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::NotificationSettings do
let(:user) { create(:user) }
let!(:group) { create(:group) }
- let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: group) }
+ let!(:project) { create(:project, :public, creator_id: user.id, namespace: group) }
describe "GET /notification_settings" do
it "returns global notification settings for the current user" do
@@ -72,8 +72,8 @@ describe API::NotificationSettings do
expect(response).to have_http_status(200)
expect(json_response['level']).to eq(user.reload.notification_settings_for(project).level)
- expect(json_response['events']['new_note']).to eq(true)
- expect(json_response['events']['new_issue']).to eq(false)
+ expect(json_response['events']['new_note']).to be_truthy
+ expect(json_response['events']['new_issue']).to be_falsey
end
end
diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb
index 819df105960..0d56e1f732e 100644
--- a/spec/requests/api/oauth_tokens_spec.rb
+++ b/spec/requests/api/oauth_tokens_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe API::API do
+describe 'OAuth tokens' do
context 'Resource Owner Password Credentials' do
def request_oauth_token(user)
post '/oauth/token', username: user.username, password: user.password, grant_type: 'password'
diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb
new file mode 100644
index 00000000000..1fc0ec528b9
--- /dev/null
+++ b/spec/requests/api/pipeline_schedules_spec.rb
@@ -0,0 +1,299 @@
+require 'spec_helper'
+
+describe API::PipelineSchedules do
+ set(:developer) { create(:user) }
+ set(:user) { create(:user) }
+ set(:project) { create(:project, :repository) }
+
+ before do
+ project.add_developer(developer)
+ end
+
+ describe 'GET /projects/:id/pipeline_schedules' do
+ context 'authenticated user with valid permissions' do
+ let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) }
+
+ before do
+ pipeline_schedule.pipelines << build(:ci_pipeline, project: project)
+ end
+
+ it 'returns list of pipeline_schedules' do
+ get api("/projects/#{project.id}/pipeline_schedules", developer)
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('pipeline_schedules')
+ end
+
+ it 'avoids N + 1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}/pipeline_schedules", developer)
+ end.count
+
+ create_list(:ci_pipeline_schedule, 10, project: project)
+ .each do |pipeline_schedule|
+ create(:user).tap do |user|
+ project.add_developer(user)
+ pipeline_schedule.update_attributes(owner: user)
+ end
+ pipeline_schedule.pipelines << build(:ci_pipeline, project: project)
+ end
+
+ expect do
+ get api("/projects/#{project.id}/pipeline_schedules", developer)
+ end.not_to exceed_query_limit(control_count)
+ end
+
+ %w[active inactive].each do |target|
+ context "when scope is #{target}" do
+ before do
+ create(:ci_pipeline_schedule, project: project, active: active?(target))
+ end
+
+ it 'returns matched pipeline schedules' do
+ get api("/projects/#{project.id}/pipeline_schedules", developer), scope: target
+
+ expect(json_response.map { |r| r['active'] }).to all(eq(active?(target)))
+ end
+ end
+
+ def active?(str)
+ (str == 'active') ? true : false
+ end
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ it 'does not return pipeline_schedules list' do
+ get api("/projects/#{project.id}/pipeline_schedules", user)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not return pipeline_schedules list' do
+ get api("/projects/#{project.id}/pipeline_schedules")
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/pipeline_schedules/:pipeline_schedule_id' do
+ let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) }
+
+ before do
+ pipeline_schedule.pipelines << build(:ci_pipeline, project: project)
+ end
+
+ context 'authenticated user with valid permissions' do
+ it 'returns pipeline_schedule details' do
+ get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer)
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to match_response_schema('pipeline_schedule')
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do
+ get api("/projects/#{project.id}/pipeline_schedules/-5", developer)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ it 'does not return pipeline_schedules list' do
+ get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not return pipeline_schedules list' do
+ get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}")
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/pipeline_schedules' do
+ let(:params) { attributes_for(:ci_pipeline_schedule) }
+
+ context 'authenticated user with valid permissions' do
+ context 'with required parameters' do
+ it 'creates pipeline_schedule' do
+ expect do
+ post api("/projects/#{project.id}/pipeline_schedules", developer),
+ params
+ end.to change { project.pipeline_schedules.count }.by(1)
+
+ expect(response).to have_http_status(:created)
+ expect(response).to match_response_schema('pipeline_schedule')
+ expect(json_response['description']).to eq(params[:description])
+ expect(json_response['ref']).to eq(params[:ref])
+ expect(json_response['cron']).to eq(params[:cron])
+ expect(json_response['cron_timezone']).to eq(params[:cron_timezone])
+ expect(json_response['owner']['id']).to eq(developer.id)
+ end
+ end
+
+ context 'without required parameters' do
+ it 'does not create pipeline_schedule' do
+ post api("/projects/#{project.id}/pipeline_schedules", developer)
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ context 'when cron has validation error' do
+ it 'does not create pipeline_schedule' do
+ post api("/projects/#{project.id}/pipeline_schedules", developer),
+ params.merge('cron' => 'invalid-cron')
+
+ expect(response).to have_http_status(:bad_request)
+ expect(json_response['message']).to have_key('cron')
+ end
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ it 'does not create pipeline_schedule' do
+ post api("/projects/#{project.id}/pipeline_schedules", user), params
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not create pipeline_schedule' do
+ post api("/projects/#{project.id}/pipeline_schedules"), params
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id' do
+ let(:pipeline_schedule) do
+ create(:ci_pipeline_schedule, project: project, owner: developer)
+ end
+
+ context 'authenticated user with valid permissions' do
+ it 'updates cron' do
+ put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer),
+ cron: '1 2 3 4 *'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to match_response_schema('pipeline_schedule')
+ expect(json_response['cron']).to eq('1 2 3 4 *')
+ end
+
+ context 'when cron has validation error' do
+ it 'does not update pipeline_schedule' do
+ put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer),
+ cron: 'invalid-cron'
+
+ expect(response).to have_http_status(:bad_request)
+ expect(json_response['message']).to have_key('cron')
+ end
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ it 'does not update pipeline_schedule' do
+ put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not update pipeline_schedule' do
+ put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}")
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do
+ let(:pipeline_schedule) do
+ create(:ci_pipeline_schedule, project: project, owner: developer)
+ end
+
+ context 'authenticated user with valid permissions' do
+ it 'updates owner' do
+ post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", developer)
+
+ expect(response).to have_http_status(:created)
+ expect(response).to match_response_schema('pipeline_schedule')
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ it 'does not update owner' do
+ post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", user)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not update owner' do
+ post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership")
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id' do
+ let(:master) { create(:user) }
+
+ let!(:pipeline_schedule) do
+ create(:ci_pipeline_schedule, project: project, owner: developer)
+ end
+
+ before do
+ project.add_master(master)
+ end
+
+ context 'authenticated user with valid permissions' do
+ it 'deletes pipeline_schedule' do
+ expect do
+ delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", master)
+ end.to change { project.pipeline_schedules.count }.by(-1)
+
+ expect(response).to have_http_status(:accepted)
+ expect(response).to match_response_schema('pipeline_schedule')
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do
+ delete api("/projects/#{project.id}/pipeline_schedules/-5", master)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'authenticated user with invalid permissions' do
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: master) }
+
+ it 'does not delete pipeline_schedule' do
+ delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer)
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
+ context 'unauthenticated user' do
+ it 'does not delete pipeline_schedule' do
+ delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}")
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb
index 762345cd41c..258085e503f 100644
--- a/spec/requests/api/pipelines_spec.rb
+++ b/spec/requests/api/pipelines_spec.rb
@@ -7,10 +7,12 @@ describe API::Pipelines do
let!(:pipeline) do
create(:ci_empty_pipeline, project: project, sha: project.commit.id,
- ref: project.default_branch)
+ ref: project.default_branch, user: user)
end
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
describe 'GET /projects/:id/pipelines ' do
context 'authorized user' do
@@ -24,6 +26,251 @@ describe API::Pipelines do
expect(json_response.first['id']).to eq pipeline.id
expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status])
end
+
+ context 'when parameter is passed' do
+ %w[running pending].each do |target|
+ context "when scope is #{target}" do
+ before do
+ create(:ci_pipeline, project: project, status: target)
+ end
+
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), scope: target
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ json_response.each { |r| expect(r['status']).to eq(target) }
+ end
+ end
+ end
+
+ context 'when scope is finished' do
+ before do
+ create(:ci_pipeline, project: project, status: 'success')
+ create(:ci_pipeline, project: project, status: 'failed')
+ create(:ci_pipeline, project: project, status: 'canceled')
+ end
+
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), scope: 'finished'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ json_response.each { |r| expect(r['status']).to be_in(%w[success failed canceled]) }
+ end
+ end
+
+ context 'when scope is branches or tags' do
+ let!(:pipeline_branch) { create(:ci_pipeline, project: project) }
+ let!(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) }
+
+ context 'when scope is branches' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), scope: 'branches'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ expect(json_response.last['id']).to eq(pipeline_branch.id)
+ end
+ end
+
+ context 'when scope is tags' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), scope: 'tags'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ expect(json_response.last['id']).to eq(pipeline_tag.id)
+ end
+ end
+ end
+
+ context 'when scope is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), scope: 'invalid-scope'
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ HasStatus::AVAILABLE_STATUSES.each do |target|
+ context "when status is #{target}" do
+ before do
+ create(:ci_pipeline, project: project, status: target)
+ exception_status = HasStatus::AVAILABLE_STATUSES - [target]
+ create(:ci_pipeline, project: project, status: exception_status.sample)
+ end
+
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), status: target
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ json_response.each { |r| expect(r['status']).to eq(target) }
+ end
+ end
+ end
+
+ context 'when status is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), status: 'invalid-status'
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ context 'when ref is specified' do
+ before do
+ create(:ci_pipeline, project: project)
+ end
+
+ context 'when ref exists' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), ref: 'master'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ json_response.each { |r| expect(r['ref']).to eq('master') }
+ end
+ end
+
+ context 'when ref does not exist' do
+ it 'returns empty' do
+ get api("/projects/#{project.id}/pipelines", user), ref: 'invalid-ref'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ context 'when name is specified' do
+ let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+
+ context 'when name exists' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), name: user.name
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.first['id']).to eq(pipeline.id)
+ end
+ end
+
+ context 'when name does not exist' do
+ it 'returns empty' do
+ get api("/projects/#{project.id}/pipelines", user), name: 'invalid-name'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ context 'when username is specified' do
+ let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+
+ context 'when username exists' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), username: user.username
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.first['id']).to eq(pipeline.id)
+ end
+ end
+
+ context 'when username does not exist' do
+ it 'returns empty' do
+ get api("/projects/#{project.id}/pipelines", user), username: 'invalid-username'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ context 'when yaml_errors is specified' do
+ let!(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') }
+ let!(:pipeline2) { create(:ci_pipeline, project: project) }
+
+ context 'when yaml_errors is true' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), yaml_errors: true
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.first['id']).to eq(pipeline1.id)
+ end
+ end
+
+ context 'when yaml_errors is false' do
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), yaml_errors: false
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.first['id']).to eq(pipeline2.id)
+ end
+ end
+
+ context 'when yaml_errors is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), yaml_errors: 'invalid-yaml_errors'
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+ end
+
+ context 'when order_by and sort are specified' do
+ context 'when order_by user_id' do
+ before do
+ 3.times do
+ create(:ci_pipeline, project: project, user: create(:user))
+ end
+ end
+
+ context 'when sort parameter is valid' do
+ it 'sorts as user_id: :desc' do
+ get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'desc'
+
+ expect(response).to have_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+
+ pipeline_ids = Ci::Pipeline.all.order(user_id: :desc).pluck(:id)
+ expect(json_response.map { |r| r['id'] }).to eq(pipeline_ids)
+ end
+ end
+
+ context 'when sort parameter is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'invalid_sort'
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+ end
+
+ context 'when order_by is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), order_by: 'lock_version', sort: 'asc'
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+ end
+ end
end
context 'unauthorized user' do
@@ -40,7 +287,9 @@ describe API::Pipelines do
describe 'POST /projects/:id/pipeline ' do
context 'authorized user' do
context 'with gitlab-ci.yml' do
- before { stub_ci_pipeline_to_return_yaml_file }
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+ end
it 'creates and returns a new pipeline' do
expect do
@@ -174,7 +423,9 @@ describe API::Pipelines do
context 'user without proper access rights' do
let!(:reporter) { create(:user) }
- before { project.team << [reporter, :reporter] }
+ before do
+ project.team << [reporter, :reporter]
+ end
it 'rejects the action' do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter)
diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb
index aee0e17a153..2829c243af3 100644
--- a/spec/requests/api/project_hooks_spec.rb
+++ b/spec/requests/api/project_hooks_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::ProjectHooks, 'ProjectHooks' do
let(:user) { create(:user) }
let(:user3) { create(:user) }
- let!(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
+ let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:hook) do
create(:project_hook,
:all_events_enabled,
@@ -60,7 +60,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
expect(json_response['note_events']).to eq(hook.note_events)
- expect(json_response['job_events']).to eq(hook.build_events)
+ expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
@@ -148,7 +148,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
expect(json_response['note_events']).to eq(hook.note_events)
- expect(json_response['job_events']).to eq(hook.build_events)
+ expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
@@ -205,7 +205,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
it "returns a 404 if a user attempts to delete project hooks he/she does not own" do
test_user = create(:user)
- other_project = create(:empty_project)
+ other_project = create(:project)
other_project.team << [test_user, :master]
delete api("/projects/#{other_project.id}/hooks/#{hook.id}", test_user)
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
new file mode 100644
index 00000000000..72e1574b55f
--- /dev/null
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe API::ProjectMilestones do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, namespace: user.namespace ) }
+ let!(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') }
+ let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ it_behaves_like 'group and project milestones', "/projects/:id/milestones" do
+ let(:route) { "/projects/#{project.id}/milestones" }
+ end
+
+ describe 'PUT /projects/:id/milestones/:milestone_id to test observer on close' do
+ it 'creates an activity event when an milestone is closed' do
+ expect(Event).to receive(:create)
+
+ put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
+ state_event: 'close'
+ end
+ end
+end
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 3ab1764f5c3..2b541f5719e 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -1,10 +1,30 @@
require 'rails_helper'
describe API::ProjectSnippets do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:admin) { create(:admin) }
+ describe "GET /projects/:project_id/snippets/:id/user_agent_detail" do
+ let(:snippet) { create(:project_snippet, :public, project: project) }
+ let!(:user_agent_detail) { create(:user_agent_detail, subject: snippet) }
+
+ it 'exposes known attributes' do
+ get api("/projects/#{project.id}/snippets/#{snippet.id}/user_agent_detail", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
+ expect(json_response['ip_address']).to eq(user_agent_detail.ip_address)
+ expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted)
+ end
+
+ it "returns unautorized for non-admin users" do
+ get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/user_agent_detail", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
describe 'GET /projects/:project_id/snippets/' do
let(:user) { create(:user) }
@@ -20,7 +40,7 @@ describe API::ProjectSnippets do
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(3)
- expect(json_response.map{ |snippet| snippet['id']} ).to include(public_snippet.id, internal_snippet.id, private_snippet.id)
+ expect(json_response.map { |snippet| snippet['id'] }).to include(public_snippet.id, internal_snippet.id, private_snippet.id)
expect(json_response.last).to have_key('web_url')
end
@@ -36,11 +56,34 @@ describe API::ProjectSnippets do
end
end
+ describe 'GET /projects/:project_id/snippets/:id' do
+ let(:user) { create(:user) }
+ let(:snippet) { create(:project_snippet, :public, project: project) }
+
+ it 'returns snippet json' do
+ get api("/projects/#{project.id}/snippets/#{snippet.id}", user)
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['title']).to eq(snippet.title)
+ expect(json_response['description']).to eq(snippet.description)
+ expect(json_response['file_name']).to eq(snippet.file_name)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ get api("/projects/#{project.id}/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
describe 'POST /projects/:project_id/snippets/' do
let(:params) do
{
title: 'Test Title',
file_name: 'test.rb',
+ description: 'test description',
code: 'puts "hello world"',
visibility: 'public'
}
@@ -52,6 +95,7 @@ describe API::ProjectSnippets do
expect(response).to have_http_status(201)
snippet = ProjectSnippet.find(json_response['id'])
expect(snippet.content).to eq(params[:code])
+ expect(snippet.description).to eq(params[:description])
expect(snippet.title).to eq(params[:title])
expect(snippet.file_name).to eq(params[:file_name])
expect(snippet.visibility_level).to eq(Snippet::PUBLIC)
@@ -78,23 +122,23 @@ describe API::ProjectSnippets do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(project, visibility: 'private') }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(project, visibility: 'private') }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the snippet' do
- expect { create_snippet(project, visibility: 'public') }.
- not_to change { Snippet.count }
+ expect { create_snippet(project, visibility: 'public') }
+ .not_to change { Snippet.count }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { create_snippet(project, visibility: 'public') }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(project, visibility: 'public') }
+ .to change { SpamLog.count }.by(1)
end
end
end
@@ -106,12 +150,14 @@ describe API::ProjectSnippets do
it 'updates snippet' do
new_content = 'New content'
+ new_description = 'New description'
- put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content
+ put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content, description: new_description
expect(response).to have_http_status(200)
snippet.reload
expect(snippet.content).to eq(new_content)
+ expect(snippet.description).to eq(new_description)
end
it 'returns 404 for invalid snippet id' do
@@ -140,8 +186,8 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'creates the snippet' do
- expect { update_snippet(title: 'Foo') }.
- to change { snippet.reload.title }.to('Foo')
+ expect { update_snippet(title: 'Foo') }
+ .to change { snippet.reload.title }.to('Foo')
end
end
@@ -149,13 +195,13 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo') }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo') }
+ .to change { SpamLog.count }.by(1)
end
end
@@ -163,16 +209,16 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo', visibility: 'public') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo', visibility: 'public') }
+ .not_to change { snippet.reload.title }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo', visibility: 'public') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo', visibility: 'public') }
+ .to change { SpamLog.count }.by(1)
end
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index cc03d7a933b..a89a58ff713 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -8,11 +8,10 @@ describe API::Projects do
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
- let(:project2) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let(:project2) { create(:project, path: 'project2', namespace: user.namespace) }
let(:snippet) { create(:project_snippet, :public, author: user, project: project, title: 'example') }
- let(:project_member) { create(:project_member, :master, user: user, project: project) }
- let(:project_member2) { create(:project_member, :developer, user: user3, project: project) }
+ let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
let(:user4) { create(:user) }
let(:project3) do
create(:project,
@@ -27,14 +26,14 @@ describe API::Projects do
builds_enabled: false,
snippets_enabled: false)
end
- let(:project_member3) do
+ let(:project_member2) do
create(:project_member,
user: user4,
project: project3,
access_level: ProjectMember::MASTER)
end
let(:project4) do
- create(:empty_project,
+ create(:project,
name: 'third_project',
path: 'third_project',
creator_id: user4.id,
@@ -53,7 +52,25 @@ describe API::Projects do
end
end
- let!(:public_project) { create(:empty_project, :public, name: 'public_project') }
+ shared_examples_for 'projects response without N + 1 queries' do
+ it 'avoids N + 1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ get api('/projects', current_user)
+ end.count
+
+ if defined?(additional_project)
+ additional_project
+ else
+ create(:project, :public)
+ end
+
+ expect do
+ get api('/projects', current_user)
+ end.not_to exceed_query_limit(control_count + 8)
+ end
+ end
+
+ let!(:public_project) { create(:project, :public, name: 'public_project') }
before do
project
project2
@@ -63,9 +80,13 @@ describe API::Projects do
context 'when unauthenticated' do
it_behaves_like 'projects response' do
- let(:filter) { {} }
+ let(:filter) { { search: project.name } }
+ let(:current_user) { user }
+ let(:projects) { [project] }
+ end
+
+ it_behaves_like 'projects response without N + 1 queries' do
let(:current_user) { nil }
- let(:projects) { [public_project] }
end
end
@@ -76,6 +97,21 @@ describe API::Projects do
let(:projects) { [public_project, project, project2, project3] }
end
+ it_behaves_like 'projects response without N + 1 queries' do
+ let(:current_user) { user }
+ end
+
+ context 'when some projects are in a group' do
+ before do
+ create(:project, :public, group: create(:group))
+ end
+
+ it_behaves_like 'projects response without N + 1 queries' do
+ let(:current_user) { user }
+ let(:additional_project) { create(:project, :public, group: create(:group)) }
+ end
+ end
+
it 'includes the project labels as the tag_list' do
get api('/projects', user)
@@ -123,9 +159,41 @@ describe API::Projects do
expect(json_response.first).to include 'statistics'
end
+ context 'when external issue tracker is enabled' do
+ let!(:jira_service) { create(:jira_service, project: project) }
+
+ it 'includes open_issues_count' do
+ get api('/projects', user)
+
+ expect(response.status).to eq 200
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first.keys).to include('open_issues_count')
+ expect(json_response.find { |hash| hash['id'] == project.id }.keys).to include('open_issues_count')
+ end
+
+ it 'does not include open_issues_count if issues are disabled' do
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
+
+ get api('/projects', user)
+
+ expect(response.status).to eq 200
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.find { |hash| hash['id'] == project.id }.keys).not_to include('open_issues_count')
+ end
+ end
+
context 'and with simple=true' do
it 'returns a simplified version of all the projects' do
- expected_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace)
+ expected_keys = %w(
+ id description default_branch tag_list
+ ssh_url_to_repo http_url_to_repo web_url
+ name name_with_namespace
+ path path_with_namespace
+ star_count forks_count
+ created_at last_activity_at
+ )
get api('/projects?simple=true', user)
@@ -207,10 +275,10 @@ describe API::Projects do
end
context 'and with starred=true' do
- let(:public_project) { create(:empty_project, :public) }
+ let(:public_project) { create(:project, :public) }
before do
- project_member2
+ project_member
user3.update_attributes(starred_projects: [project, project2, project3, public_project])
end
@@ -225,11 +293,11 @@ describe API::Projects do
end
context 'and with all query parameters' do
- let!(:project5) { create(:empty_project, :public, path: 'gitlab5', namespace: create(:namespace)) }
- let!(:project6) { create(:empty_project, :public, path: 'project6', namespace: user.namespace) }
- let!(:project7) { create(:empty_project, :public, path: 'gitlab7', namespace: user.namespace) }
- let!(:project8) { create(:empty_project, path: 'gitlab8', namespace: user.namespace) }
- let!(:project9) { create(:empty_project, :public, path: 'gitlab9') }
+ let!(:project5) { create(:project, :public, path: 'gitlab5', namespace: create(:namespace)) }
+ let!(:project6) { create(:project, :public, path: 'project6', namespace: user.namespace) }
+ let!(:project7) { create(:project, :public, path: 'gitlab7', namespace: user.namespace) }
+ let!(:project8) { create(:project, path: 'gitlab8', namespace: user.namespace) }
+ let!(:project9) { create(:project, :public, path: 'gitlab9') }
before do
user.update_attributes(starred_projects: [project5, project7, project8, project9])
@@ -289,15 +357,15 @@ describe API::Projects do
context 'maximum number of projects reached' do
it 'does not create new project and respond with 403' do
allow_any_instance_of(User).to receive(:projects_limit_left).and_return(0)
- expect { post api('/projects', user2), name: 'foo' }.
- to change {Project.count}.by(0)
+ expect { post api('/projects', user2), name: 'foo' }
+ .to change {Project.count}.by(0)
expect(response).to have_http_status(403)
end
end
it 'creates new project without path but with name and returns 201' do
- expect { post api('/projects', user), name: 'Foo Project' }.
- to change { Project.count }.by(1)
+ expect { post api('/projects', user), name: 'Foo Project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
@@ -307,8 +375,8 @@ describe API::Projects do
end
it 'creates new project without name but with path and returns 201' do
- expect { post api('/projects', user), path: 'foo_project' }.
- to change { Project.count }.by(1)
+ expect { post api('/projects', user), path: 'foo_project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
@@ -317,15 +385,15 @@ describe API::Projects do
expect(project.path).to eq('foo_project')
end
- it 'creates new project name and path and returns 201' do
- expect { post api('/projects', user), path: 'foo-Project', name: 'Foo Project' }.
- to change { Project.count }.by(1)
+ it 'creates new project with name and path and returns 201' do
+ expect { post api('/projects', user), path: 'path-project-Foo', name: 'Foo Project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
expect(project.name).to eq('Foo Project')
- expect(project.path).to eq('foo-Project')
+ expect(project.path).to eq('path-project-Foo')
end
it 'creates last project before reaching project limit' do
@@ -348,7 +416,8 @@ describe API::Projects do
wiki_enabled: false,
only_allow_merge_if_pipeline_succeeds: false,
request_access_enabled: true,
- only_allow_merge_if_all_discussions_are_resolved: false
+ only_allow_merge_if_all_discussions_are_resolved: false,
+ ci_config_path: 'a/custom/path'
})
post api('/projects', user), project
@@ -391,6 +460,23 @@ describe API::Projects do
expect(json_response['visibility']).to eq('private')
end
+ it 'sets tag list to a project' do
+ project = attributes_for(:project, tag_list: %w[tagFirst tagSecond])
+
+ post api('/projects', user), project
+
+ expect(json_response['tag_list']).to eq(%w[tagFirst tagSecond])
+ end
+
+ it 'uploads avatar for project a project' do
+ project = attributes_for(:project, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif'))
+
+ post api('/projects', user), project
+
+ project_id = json_response['id']
+ expect(json_response['avatar_url']).to eq("http://localhost/uploads/-/system/project/avatar/#{project_id}/banana_sample.gif")
+ end
+
it 'sets a project as allowing merge even if build fails' do
project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false })
post api('/projects', user), project
@@ -459,18 +545,55 @@ describe API::Projects do
end
end
+ describe 'GET /users/:user_id/projects/' do
+ let!(:public_project) { create(:project, :public, name: 'public_project', creator_id: user4.id, namespace: user4.namespace) }
+
+ it 'returns error when user not found' do
+ get api('/users/9999/projects/')
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+
+ it 'returns projects filtered by user' do
+ get api("/users/#{user4.id}/projects/", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
+ end
+ end
+
describe 'POST /projects/user/:id' do
- before { project }
- before { admin }
+ before do
+ expect(project).to be_persisted
+ end
+
+ it 'creates new project without path but with name and return 201' do
+ expect { post api("/projects/user/#{user.id}", admin), name: 'Foo Project' }.to change {Project.count}.by(1)
+ expect(response).to have_http_status(201)
+
+ project = Project.first
+
+ expect(project.name).to eq('Foo Project')
+ expect(project.path).to eq('foo-project')
+ end
- it 'creates new project without path and return 201' do
- expect { post api("/projects/user/#{user.id}", admin), name: 'foo' }.to change {Project.count}.by(1)
+ it 'creates new project with name and path and returns 201' do
+ expect { post api("/projects/user/#{user.id}", admin), path: 'path-project-Foo', name: 'Foo Project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
+
+ project = Project.first
+
+ expect(project.name).to eq('Foo Project')
+ expect(project.path).to eq('path-project-Foo')
end
it 'responds with 400 on failure and not project' do
- expect { post api("/projects/user/#{user.id}", admin) }.
- not_to change { Project.count }
+ expect { post api("/projects/user/#{user.id}", admin) }
+ .not_to change { Project.count }
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('name is missing')
@@ -549,7 +672,9 @@ describe API::Projects do
end
describe "POST /projects/:id/uploads" do
- before { project }
+ before do
+ project
+ end
it "uploads the file and returns its info" do
post api("/projects/#{project.id}/uploads", user), file: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")
@@ -564,13 +689,14 @@ describe API::Projects do
describe 'GET /projects/:id' do
context 'when unauthenticated' do
it 'returns the public projects' do
- public_project = create(:empty_project, :public)
+ public_project = create(:project, :public)
get api("/projects/#{public_project.id}")
expect(response).to have_http_status(200)
expect(json_response['id']).to eq(public_project.id)
expect(json_response['description']).to eq(public_project.description)
+ expect(json_response['default_branch']).to eq(public_project.default_branch)
expect(json_response.keys).not_to include('permissions')
end
end
@@ -612,10 +738,13 @@ describe API::Projects do
expect(json_response['shared_runners_enabled']).to be_present
expect(json_response['creator_id']).to be_present
expect(json_response['namespace']).to be_present
+ expect(json_response['import_status']).to be_present
+ expect(json_response).to include("import_error")
expect(json_response['avatar_url']).to be_nil
expect(json_response['star_count']).to be_present
expect(json_response['forks_count']).to be_present
expect(json_response['public_jobs']).to be_present
+ expect(json_response['ci_config_path']).to be_nil
expect(json_response['shared_with_groups']).to be_an Array
expect(json_response['shared_with_groups'].length).to eq(1)
expect(json_response['shared_with_groups'][0]['group_id']).to eq(group.id)
@@ -645,9 +774,9 @@ describe API::Projects do
it 'handles users with dots' do
dot_user = create(:user, username: 'dot.user')
- project = create(:empty_project, creator_id: dot_user.id, namespace: dot_user.namespace)
+ project = create(:project, creator_id: dot_user.id, namespace: dot_user.namespace)
- get api("/projects/#{dot_user.namespace.name}%2F#{project.path}", dot_user)
+ get api("/projects/#{CGI.escape(project.full_path)}", dot_user)
expect(response).to have_http_status(200)
expect(json_response['name']).to eq(project.name)
end
@@ -662,19 +791,82 @@ describe API::Projects do
'path' => user.namespace.path,
'kind' => user.namespace.kind,
'full_path' => user.namespace.full_path,
+ 'parent_id' => nil
})
end
+ it "does not include statistics by default" do
+ get api("/projects/#{project.id}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).not_to include 'statistics'
+ end
+
+ it "includes statistics if requested" do
+ get api("/projects/#{project.id}", user), statistics: true
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to include 'statistics'
+ end
+
+ it "includes import_error if user can admin project" do
+ get api("/projects/#{project.id}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to include("import_error")
+ end
+
+ it "does not include import_error if user cannot admin project" do
+ get api("/projects/#{project.id}", user3)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).not_to include("import_error")
+ end
+
+ context 'links exposure' do
+ it 'exposes related resources full URIs' do
+ get api("/projects/#{project.id}", user)
+
+ links = json_response['_links']
+
+ expect(links['self']).to end_with("/api/v4/projects/#{project.id}")
+ expect(links['issues']).to end_with("/api/v4/projects/#{project.id}/issues")
+ expect(links['merge_requests']).to end_with("/api/v4/projects/#{project.id}/merge_requests")
+ expect(links['repo_branches']).to end_with("/api/v4/projects/#{project.id}/repository/branches")
+ expect(links['labels']).to end_with("/api/v4/projects/#{project.id}/labels")
+ expect(links['events']).to end_with("/api/v4/projects/#{project.id}/events")
+ expect(links['members']).to end_with("/api/v4/projects/#{project.id}/members")
+ end
+
+ it 'filters related URIs when their feature is not enabled' do
+ project = create(:project, :public,
+ :merge_requests_disabled,
+ :issues_disabled,
+ creator_id: user.id,
+ namespace: user.namespace)
+
+ get api("/projects/#{project.id}", user)
+
+ links = json_response['_links']
+
+ expect(links.has_key?('merge_requests')).to be_falsy
+ expect(links.has_key?('issues')).to be_falsy
+ expect(links['self']).to end_with("/api/v4/projects/#{project.id}")
+ end
+ end
+
describe 'permissions' do
context 'all projects' do
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'contains permission information' do
get api("/projects", user)
expect(response).to have_http_status(200)
- expect(json_response.first['permissions']['project_access']['access_level']).
- to eq(Gitlab::Access::MASTER)
+ expect(json_response.first['permissions']['project_access']['access_level'])
+ .to eq(Gitlab::Access::MASTER)
expect(json_response.first['permissions']['group_access']).to be_nil
end
end
@@ -685,111 +877,54 @@ describe API::Projects do
get api("/projects/#{project.id}", user)
expect(response).to have_http_status(200)
- expect(json_response['permissions']['project_access']['access_level']).
- to eq(Gitlab::Access::MASTER)
+ expect(json_response['permissions']['project_access']['access_level'])
+ .to eq(Gitlab::Access::MASTER)
expect(json_response['permissions']['group_access']).to be_nil
end
end
context 'group project' do
- let(:project2) { create(:empty_project, group: create(:group)) }
+ let(:project2) { create(:project, group: create(:group)) }
- before { project2.group.add_owner(user) }
+ before do
+ project2.group.add_owner(user)
+ end
it 'sets the owner and return 200' do
get api("/projects/#{project2.id}", user)
expect(response).to have_http_status(200)
expect(json_response['permissions']['project_access']).to be_nil
- expect(json_response['permissions']['group_access']['access_level']).
- to eq(Gitlab::Access::OWNER)
+ expect(json_response['permissions']['group_access']['access_level'])
+ .to eq(Gitlab::Access::OWNER)
end
end
end
end
end
- describe 'GET /projects/:id/events' do
- shared_examples_for 'project events response' do
- it 'returns the project events' do
- member = create(:user)
- create(:project_member, :developer, user: member, project: project)
- note = create(:note_on_issue, note: 'What an awesome day!', project: project)
- EventCreateService.new.leave_note(note, note.author)
-
- get api("/projects/#{project.id}/events", current_user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
-
- first_event = json_response.first
- expect(first_event['action_name']).to eq('commented on')
- expect(first_event['note']['body']).to eq('What an awesome day!')
-
- last_event = json_response.last
-
- expect(last_event['action_name']).to eq('joined')
- expect(last_event['project_id'].to_i).to eq(project.id)
- expect(last_event['author_username']).to eq(member.username)
- expect(last_event['author']['name']).to eq(member.name)
- end
- end
-
- context 'when unauthenticated' do
- it_behaves_like 'project events response' do
- let(:project) { create(:empty_project, :public) }
- let(:current_user) { nil }
- end
- end
-
- context 'when authenticated' do
- context 'valid request' do
- it_behaves_like 'project events response' do
- let(:current_user) { user }
- end
- end
-
- it 'returns a 404 error if not found' do
- get api('/projects/42/events', user)
-
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 Project Not Found')
- end
-
- it 'returns a 404 error if user is not a member' do
- other_user = create(:user)
-
- get api("/projects/#{project.id}/events", other_user)
-
- expect(response).to have_http_status(404)
- end
- end
- end
-
describe 'GET /projects/:id/users' do
shared_examples_for 'project users response' do
it 'returns the project users' do
- member = create(:user)
- create(:project_member, :developer, user: member, project: project)
-
get api("/projects/#{project.id}/users", current_user)
+ user = project.namespace.owner
+
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
first_user = json_response.first
- expect(first_user['username']).to eq(member.username)
- expect(first_user['name']).to eq(member.name)
+ expect(first_user['username']).to eq(user.username)
+ expect(first_user['name']).to eq(user.name)
expect(first_user.keys).to contain_exactly(*%w[name username id state avatar_url web_url])
end
end
context 'when unauthenticated' do
it_behaves_like 'project users response' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:current_user) { nil }
end
end
@@ -819,7 +954,9 @@ describe API::Projects do
end
describe 'GET /projects/:id/snippets' do
- before { snippet }
+ before do
+ snippet
+ end
it 'returns an array of project snippets' do
get api("/projects/#{project.id}/snippets", user)
@@ -876,7 +1013,9 @@ describe API::Projects do
end
describe 'DELETE /projects/:id/snippets/:snippet_id' do
- before { snippet }
+ before do
+ snippet
+ end
it 'deletes existing project snippet' do
expect do
@@ -905,11 +1044,11 @@ describe API::Projects do
end
describe 'fork management' do
- let(:project_fork_target) { create(:empty_project) }
- let(:project_fork_source) { create(:empty_project, :public) }
+ let(:project_fork_target) { create(:project) }
+ let(:project_fork_source) { create(:project, :public) }
describe 'POST /projects/:id/fork/:forked_from_id' do
- let(:new_project_fork_source) { create(:empty_project, :public) }
+ let(:new_project_fork_source) { create(:project, :public) }
it "is not available for non admin users" do
post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user)
@@ -926,6 +1065,14 @@ describe API::Projects do
expect(project_fork_target.forked?).to be_truthy
end
+ it 'refreshes the forks count cachce' do
+ expect(project_fork_source.forks_count).to be_zero
+
+ post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin)
+
+ expect(project_fork_source.forks_count).to eq(1)
+ end
+
it 'fails if forked_from project which does not exist' do
post api("/projects/#{project_fork_target.id}/fork/9999", admin)
expect(response).to have_http_status(404)
@@ -950,7 +1097,7 @@ describe API::Projects do
end
context 'when users belong to project group' do
- let(:project_fork_target) { create(:empty_project, group: create(:group)) }
+ let(:project_fork_target) { create(:project, group: create(:group)) }
before do
project_fork_target.group.add_owner user
@@ -1071,14 +1218,16 @@ describe API::Projects do
end
describe 'PUT /projects/:id' do
- before { project }
- before { user }
- before { user3 }
- before { user4 }
- before { project3 }
- before { project4 }
- before { project_member3 }
- before { project_member2 }
+ before do
+ expect(project).to be_persisted
+ expect(user).to be_persisted
+ expect(user3).to be_persisted
+ expect(user4).to be_persisted
+ expect(project3).to be_persisted
+ expect(project4).to be_persisted
+ expect(project_member2).to be_persisted
+ expect(project_member).to be_persisted
+ end
it 'returns 400 when nothing sent' do
project_param = {}
@@ -1428,6 +1577,8 @@ describe API::Projects do
expect(json_response['owner']['id']).to eq(user2.id)
expect(json_response['namespace']['id']).to eq(user2.namespace.id)
expect(json_response['forked_from_project']['id']).to eq(project.id)
+ expect(json_response['import_status']).to eq('scheduled')
+ expect(json_response).to include("import_error")
end
it 'forks if user is admin' do
@@ -1439,6 +1590,8 @@ describe API::Projects do
expect(json_response['owner']['id']).to eq(admin.id)
expect(json_response['namespace']['id']).to eq(admin.namespace.id)
expect(json_response['forked_from_project']['id']).to eq(project.id)
+ expect(json_response['import_status']).to eq('scheduled')
+ expect(json_response).to include("import_error")
end
it 'fails on missing project access for the project to fork' do
@@ -1559,7 +1712,7 @@ describe API::Projects do
context 'when authenticated as developer' do
before do
- project_member2
+ project_member
end
it 'returns forbidden error' do
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
new file mode 100644
index 00000000000..1aa8a95780e
--- /dev/null
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -0,0 +1,232 @@
+require 'spec_helper'
+
+describe API::ProtectedBranches do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :repository) }
+ let(:protected_name) { 'feature' }
+ let(:branch_name) { protected_name }
+ let!(:protected_branch) do
+ create(:protected_branch, project: project, name: protected_name)
+ end
+
+ describe "GET /projects/:id/protected_branches" do
+ let(:route) { "/projects/#{project.id}/protected_branches" }
+
+ shared_examples_for 'protected branches' do
+ it 'returns the protected branches' do
+ get api(route, user), per_page: 100
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+
+ protected_branch_names = json_response.map { |x| x['name'] }
+ expected_branch_names = project.protected_branches.map { |x| x['name'] }
+ expect(protected_branch_names).to match_array(expected_branch_names)
+ end
+ end
+
+ context 'when authenticated as a master' do
+ before do
+ project.add_master(user)
+ end
+
+ it_behaves_like 'protected branches'
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, user) }
+ end
+ end
+ end
+
+ describe "GET /projects/:id/protected_branches/:branch" do
+ let(:route) { "/projects/#{project.id}/protected_branches/#{branch_name}" }
+
+ shared_examples_for 'protected branch' do
+ it 'returns the protected branch' do
+ get api(route, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MASTER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MASTER)
+ end
+
+ context 'when protected branch does not exist' do
+ let(:branch_name) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, user) }
+ let(:message) { '404 Not found' }
+ end
+ end
+ end
+
+ context 'when authenticated as a master' do
+ before do
+ project.add_master(user)
+ end
+
+ it_behaves_like 'protected branch'
+
+ context 'when protected branch contains a wildcard' do
+ let(:protected_name) { 'feature*' }
+
+ it_behaves_like 'protected branch'
+ end
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, user) }
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/protected_branches' do
+ let(:branch_name) { 'new_branch' }
+
+ context 'when authenticated as a master' do
+ before do
+ project.add_master(user)
+ end
+
+ it 'protects a single branch' do
+ post api("/projects/#{project.id}/protected_branches", user), name: branch_name
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ end
+
+ it 'protects a single branch and developers can push' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, push_access_level: 30
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ end
+
+ it 'protects a single branch and developers can merge' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, merge_access_level: 30
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ it 'protects a single branch and developers can push and merge' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, push_access_level: 30, merge_access_level: 30
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ it 'protects a single branch and no one can push' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, push_access_level: 0
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ end
+
+ it 'protects a single branch and no one can merge' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, merge_access_level: 0
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
+ end
+
+ it 'protects a single branch and no one can push or merge' do
+ post api("/projects/#{project.id}/protected_branches", user),
+ name: branch_name, push_access_level: 0, merge_access_level: 0
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
+ end
+
+ it 'returns a 409 error if the same branch is protected twice' do
+ post api("/projects/#{project.id}/protected_branches", user), name: protected_name
+ expect(response).to have_gitlab_http_status(409)
+ end
+
+ context 'when branch has a wildcard in its name' do
+ let(:branch_name) { 'feature/*' }
+
+ it "protects multiple branches with a wildcard in the name" do
+ post api("/projects/#{project.id}/protected_branches", user), name: branch_name
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
+ end
+ end
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it "returns a 403 error if guest" do
+ post api("/projects/#{project.id}/protected_branches/", user), name: branch_name
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
+ describe "DELETE /projects/:id/protected_branches/unprotect/:branch" do
+ before do
+ project.add_master(user)
+ end
+
+ it "unprotects a single branch" do
+ delete api("/projects/#{project.id}/protected_branches/#{branch_name}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ it "returns 404 if branch does not exist" do
+ delete api("/projects/#{project.id}/protected_branches/barfoo", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ context 'when branch has a wildcard in its name' do
+ let(:protected_name) { 'feature*' }
+
+ it "unprotects a wildcard branch" do
+ delete api("/projects/#{project.id}/protected_branches/#{branch_name}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index be83514ed9c..e9ee3dd679d 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -42,7 +42,7 @@ describe API::Runner do
end
context 'when project token is used' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
it 'creates runner' do
post api('/runners'), token: project.runners_token
@@ -182,7 +182,7 @@ describe API::Runner do
end
describe '/api/v4/jobs' do
- let(:project) { create(:empty_project, shared_runners_enabled: false) }
+ let(:project) { create(:project, shared_runners_enabled: false) }
let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') }
let(:runner) { create(:ci_runner) }
let!(:job) do
@@ -190,17 +190,23 @@ describe API::Runner do
pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, commands: "ls\ndate")
end
- before { project.runners << runner }
+ before do
+ project.runners << runner
+ end
describe 'POST /api/v4/jobs/request' do
let!(:last_update) {}
let!(:new_update) { }
let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' }
- before { stub_container_registry_config(enabled: false) }
+ before do
+ stub_container_registry_config(enabled: false)
+ end
shared_examples 'no jobs available' do
- before { request_job }
+ before do
+ request_job
+ end
context 'when runner sends version in User-Agent' do
context 'for stable version' do
@@ -277,7 +283,9 @@ describe API::Runner do
end
context 'when jobs are finished' do
- before { job.success }
+ before do
+ job.success
+ end
it_behaves_like 'no jobs available'
end
@@ -343,7 +351,8 @@ describe API::Runner do
let(:expected_cache) do
[{ 'key' => 'cache_key',
'untracked' => false,
- 'paths' => ['vendor/*'] }]
+ 'paths' => ['vendor/*'],
+ 'policy' => 'pull-push' }]
end
it 'picks a job' do
@@ -356,8 +365,11 @@ describe API::Runner do
expect(json_response['token']).to eq(job.token)
expect(json_response['job_info']).to eq(expected_job_info)
expect(json_response['git_info']).to eq(expected_git_info)
- expect(json_response['image']).to eq({ 'name' => 'ruby:2.1' })
- expect(json_response['services']).to eq([{ 'name' => 'postgres' }])
+ expect(json_response['image']).to eq({ 'name' => 'ruby:2.1', 'entrypoint' => '/bin/sh' })
+ expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil,
+ 'alias' => nil, 'command' => nil },
+ { 'name' => 'docker:dind', 'entrypoint' => '/bin/sh',
+ 'alias' => 'docker', 'command' => 'sleep 30' }])
expect(json_response['steps']).to eq(expected_steps)
expect(json_response['artifacts']).to eq(expected_artifacts)
expect(json_response['cache']).to eq(expected_cache)
@@ -403,8 +415,8 @@ describe API::Runner do
context 'when concurrently updating a job' do
before do
- expect_any_instance_of(Ci::Build).to receive(:run!).
- and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
+ expect_any_instance_of(Ci::Build).to receive(:run!)
+ .and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
end
it 'returns a conflict' do
@@ -431,8 +443,29 @@ describe API::Runner do
expect(response).to have_http_status(201)
expect(json_response['id']).to eq(test_job.id)
expect(json_response['dependencies'].count).to eq(2)
- expect(json_response['dependencies']).to include({ 'id' => job.id, 'name' => job.name, 'token' => job.token },
- { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token })
+ expect(json_response['dependencies']).to include(
+ { 'id' => job.id, 'name' => job.name, 'token' => job.token },
+ { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token })
+ end
+ end
+
+ context 'when pipeline have jobs with artifacts' do
+ let!(:job) { create(:ci_build_tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
+
+ before do
+ job.success
+ end
+
+ it 'returns dependent jobs' do
+ request_job
+
+ expect(response).to have_http_status(201)
+ expect(json_response['id']).to eq(test_job.id)
+ expect(json_response['dependencies'].count).to eq(1)
+ expect(json_response['dependencies']).to include(
+ { 'id' => job.id, 'name' => job.name, 'token' => job.token,
+ 'artifacts_file' => { 'filename' => 'ci_build_artifacts.zip', 'size' => 106365 } })
end
end
@@ -484,10 +517,14 @@ describe API::Runner do
end
context 'when job has no tags' do
- before { job.update(tags: []) }
+ before do
+ job.update(tags: [])
+ end
context 'when runner is allowed to pick untagged jobs' do
- before { runner.update_column(:run_untagged, true) }
+ before do
+ runner.update_column(:run_untagged, true)
+ end
it 'picks job' do
request_job
@@ -497,7 +534,9 @@ describe API::Runner do
end
context 'when runner is not allowed to pick untagged jobs' do
- before { runner.update_column(:run_untagged, false) }
+ before do
+ runner.update_column(:run_untagged, false)
+ end
it_behaves_like 'no jobs available'
end
@@ -537,7 +576,9 @@ describe API::Runner do
end
context 'when registry is enabled' do
- before { stub_container_registry_config(enabled: true, host_port: registry_url) }
+ before do
+ stub_container_registry_config(enabled: true, host_port: registry_url)
+ end
it 'sends registry credentials key' do
request_job
@@ -548,7 +589,9 @@ describe API::Runner do
end
context 'when registry is disabled' do
- before { stub_container_registry_config(enabled: false, host_port: registry_url) }
+ before do
+ stub_container_registry_config(enabled: false, host_port: registry_url)
+ end
it 'does not send registry credentials' do
request_job
@@ -570,7 +613,9 @@ describe API::Runner do
describe 'PUT /api/v4/jobs/:id' do
let(:job) { create(:ci_build, :pending, :trace, pipeline: pipeline, runner_id: runner.id) }
- before { job.run! }
+ before do
+ job.run!
+ end
context 'when status is given' do
it 'mark job as succeeded' do
@@ -625,7 +670,9 @@ describe API::Runner do
let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) }
let(:update_interval) { 10.seconds.to_i }
- before { initial_patch_the_trace }
+ before do
+ initial_patch_the_trace
+ end
context 'when request is valid' do
it 'gets correct response' do
@@ -636,7 +683,7 @@ describe API::Runner do
end
context 'when job has been updated recently' do
- it { expect{ patch_the_trace }.not_to change { job.updated_at }}
+ it { expect { patch_the_trace }.not_to change { job.updated_at }}
it "changes the job's trace" do
patch_the_trace
@@ -645,7 +692,7 @@ describe API::Runner do
end
context 'when Runner makes a force-patch' do
- it { expect{ force_patch_the_trace }.not_to change { job.updated_at }}
+ it { expect { force_patch_the_trace }.not_to change { job.updated_at }}
it "doesn't change the build.trace" do
force_patch_the_trace
@@ -767,7 +814,9 @@ describe API::Runner do
let(:file_upload) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') }
let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') }
- before { job.run! }
+ before do
+ job.run!
+ end
describe 'POST /api/v4/jobs/:id/artifacts/authorize' do
context 'when using token as parameter' do
@@ -873,13 +922,17 @@ describe API::Runner do
end
context 'when uses regular file post' do
- before { upload_artifacts(file_upload, headers_with_token, false) }
+ before do
+ upload_artifacts(file_upload, headers_with_token, false)
+ end
it_behaves_like 'successful artifacts upload'
end
context 'when uses accelerated file post' do
- before { upload_artifacts(file_upload, headers_with_token, true) }
+ before do
+ upload_artifacts(file_upload, headers_with_token, true)
+ end
it_behaves_like 'successful artifacts upload'
end
@@ -1033,7 +1086,9 @@ describe API::Runner do
allow(ArtifactUploader).to receive(:artifacts_upload_path).and_return(@tmpdir)
end
- after { FileUtils.remove_entry @tmpdir }
+ after do
+ FileUtils.remove_entry @tmpdir
+ end
it' "fails to post artifacts for outside of tmp path"' do
upload_artifacts(file_upload, headers_with_token)
@@ -1055,7 +1110,9 @@ describe API::Runner do
describe 'GET /api/v4/jobs/:id/artifacts' do
let(:token) { job.token }
- before { download_artifact }
+ before do
+ download_artifact
+ end
context 'when job has artifacts' do
let(:job) { create(:ci_build, :artifacts) }
diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb
index 645a5389850..c8ff25f70fa 100644
--- a/spec/requests/api/runners_spec.rb
+++ b/spec/requests/api/runners_spec.rb
@@ -5,8 +5,8 @@ describe API::Runners do
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id) }
- let(:project2) { create(:empty_project, creator_id: user.id) }
+ let(:project) { create(:project, creator_id: user.id) }
+ let(:project2) { create(:project, creator_id: user.id) }
let!(:shared_runner) { create(:ci_runner, :shared) }
let!(:unused_specific_runner) { create(:ci_runner) }
@@ -36,7 +36,7 @@ describe API::Runners do
it 'returns user available runners' do
get api('/runners', user)
- shared = json_response.any?{ |r| r['is_shared'] }
+ shared = json_response.any? { |r| r['is_shared'] }
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -46,7 +46,7 @@ describe API::Runners do
it 'filters runners by scope' do
get api('/runners?scope=active', user)
- shared = json_response.any?{ |r| r['is_shared'] }
+ shared = json_response.any? { |r| r['is_shared'] }
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -74,7 +74,7 @@ describe API::Runners do
it 'returns all runners' do
get api('/runners/all', admin)
- shared = json_response.any?{ |r| r['is_shared'] }
+ shared = json_response.any? { |r| r['is_shared'] }
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -93,7 +93,7 @@ describe API::Runners do
it 'filters runners by scope' do
get api('/runners/all?scope=specific', admin)
- shared = json_response.any?{ |r| r['is_shared'] }
+ shared = json_response.any? { |r| r['is_shared'] }
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -277,7 +277,7 @@ describe API::Runners do
delete api("/runners/#{shared_runner.id}", admin)
expect(response).to have_http_status(204)
- end.to change{ Ci::Runner.shared.count }.by(-1)
+ end.to change { Ci::Runner.shared.count }.by(-1)
end
end
@@ -287,7 +287,7 @@ describe API::Runners do
delete api("/runners/#{unused_specific_runner.id}", admin)
expect(response).to have_http_status(204)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
it 'deletes used runner' do
@@ -295,7 +295,7 @@ describe API::Runners do
delete api("/runners/#{specific_runner.id}", admin)
expect(response).to have_http_status(204)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
end
@@ -330,7 +330,7 @@ describe API::Runners do
delete api("/runners/#{specific_runner.id}", user)
expect(response).to have_http_status(204)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
end
end
@@ -349,7 +349,7 @@ describe API::Runners do
it "returns project's runners" do
get api("/projects/#{project.id}/runners", user)
- shared = json_response.any?{ |r| r['is_shared'] }
+ shared = json_response.any? { |r| r['is_shared'] }
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -385,14 +385,14 @@ describe API::Runners do
it 'enables specific runner' do
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id
- end.to change{ project.runners.count }.by(+1)
+ end.to change { project.runners.count }.by(+1)
expect(response).to have_http_status(201)
end
it 'avoids changes when enabling already enabled runner' do
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id
- end.to change{ project.runners.count }.by(0)
+ end.to change { project.runners.count }.by(0)
expect(response).to have_http_status(409)
end
@@ -401,7 +401,7 @@ describe API::Runners do
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id
- end.to change{ project.runners.count }.by(0)
+ end.to change { project.runners.count }.by(0)
expect(response).to have_http_status(403)
end
@@ -416,7 +416,7 @@ describe API::Runners do
it 'enables any specific runner' do
expect do
post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id
- end.to change{ project.runners.count }.by(+1)
+ end.to change { project.runners.count }.by(+1)
expect(response).to have_http_status(201)
end
end
@@ -461,7 +461,7 @@ describe API::Runners do
delete api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user)
expect(response).to have_http_status(204)
- end.to change{ project.runners.count }.by(-1)
+ end.to change { project.runners.count }.by(-1)
end
end
@@ -469,7 +469,7 @@ describe API::Runners do
it "does not disable project's runner" do
expect do
delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user)
- end.to change{ project.runners.count }.by(0)
+ end.to change { project.runners.count }.by(0)
expect(response).to have_http_status(403)
end
end
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index 95df3429314..48d99841385 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -4,7 +4,7 @@ describe API::Services do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:user2) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
Service.available_services_names.each do |service|
describe "PUT /projects/:id/services/#{service.dasherize}" do
@@ -98,7 +98,7 @@ describe API::Services do
end
describe 'POST /projects/:id/services/:slug/trigger' do
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
describe 'Mattermost Service' do
let(:service_name) { 'mattermost_slash_commands' }
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 2398ae6219c..737c028ad53 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -10,8 +10,8 @@ describe API::Settings, 'Settings' do
expect(response).to have_http_status(200)
expect(json_response).to be_an Hash
expect(json_response['default_projects_limit']).to eq(42)
- expect(json_response['signin_enabled']).to be_truthy
- expect(json_response['repository_storage']).to eq('default')
+ expect(json_response['password_authentication_enabled']).to be_truthy
+ expect(json_response['repository_storages']).to eq(['default'])
expect(json_response['koding_enabled']).to be_falsey
expect(json_response['koding_url']).to be_nil
expect(json_response['plantuml_enabled']).to be_falsey
@@ -32,19 +32,23 @@ describe API::Settings, 'Settings' do
it "updates application settings" do
put api("/application/settings", admin),
default_projects_limit: 3,
- signin_enabled: false,
- repository_storage: 'custom',
+ password_authentication_enabled: false,
+ repository_storages: ['custom'],
koding_enabled: true,
koding_url: 'http://koding.example.com',
plantuml_enabled: true,
plantuml_url: 'http://plantuml.example.com',
default_snippet_visibility: 'internal',
restricted_visibility_levels: ['public'],
- default_artifacts_expire_in: '2 days'
+ default_artifacts_expire_in: '2 days',
+ help_page_text: 'custom help text',
+ help_page_hide_commercial_content: true,
+ help_page_support_url: 'http://example.com/help',
+ project_export_enabled: false
+
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
- expect(json_response['signin_enabled']).to be_falsey
- expect(json_response['repository_storage']).to eq('custom')
+ expect(json_response['password_authentication_enabled']).to be_falsey
expect(json_response['repository_storages']).to eq(['custom'])
expect(json_response['koding_enabled']).to be_truthy
expect(json_response['koding_url']).to eq('http://koding.example.com')
@@ -53,6 +57,10 @@ describe API::Settings, 'Settings' do
expect(json_response['default_snippet_visibility']).to eq('internal')
expect(json_response['restricted_visibility_levels']).to eq(['public'])
expect(json_response['default_artifacts_expire_in']).to eq('2 days')
+ expect(json_response['help_page_text']).to eq('custom help text')
+ expect(json_response['help_page_hide_commercial_content']).to be_truthy
+ expect(json_response['help_page_support_url']).to eq('http://example.com/help')
+ expect(json_response['project_export_enabled']).to be_falsey
end
end
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index e429cddcf6a..d09b8bc42f1 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -52,10 +52,10 @@ describe API::Snippets do
expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly(
public_snippet.id,
public_snippet_other.id)
- expect(json_response.map{ |snippet| snippet['web_url']} ).to include(
+ expect(json_response.map { |snippet| snippet['web_url']} ).to include(
"http://localhost/snippets/#{public_snippet.id}",
"http://localhost/snippets/#{public_snippet_other.id}")
- expect(json_response.map{ |snippet| snippet['raw_url']} ).to include(
+ expect(json_response.map { |snippet| snippet['raw_url']} ).to include(
"http://localhost/snippets/#{public_snippet.id}/raw",
"http://localhost/snippets/#{public_snippet_other.id}/raw")
end
@@ -80,11 +80,33 @@ describe API::Snippets do
end
end
+ describe 'GET /snippets/:id' do
+ let(:snippet) { create(:personal_snippet, author: user) }
+
+ it 'returns snippet json' do
+ get api("/snippets/#{snippet.id}", user)
+
+ expect(response).to have_http_status(200)
+
+ expect(json_response['title']).to eq(snippet.title)
+ expect(json_response['description']).to eq(snippet.description)
+ expect(json_response['file_name']).to eq(snippet.file_name)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ get api("/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Not found')
+ end
+ end
+
describe 'POST /snippets/' do
let(:params) do
{
title: 'Test Title',
file_name: 'test.rb',
+ description: 'test description',
content: 'puts "hello world"',
visibility: 'public'
}
@@ -97,6 +119,7 @@ describe API::Snippets do
expect(response).to have_http_status(201)
expect(json_response['title']).to eq(params[:title])
+ expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name])
end
@@ -119,23 +142,23 @@ describe API::Snippets do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(visibility: 'private') }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(visibility: 'private') }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the shippet' do
- expect { create_snippet(visibility: 'public') }.
- not_to change { Snippet.count }
+ expect { create_snippet(visibility: 'public') }
+ .not_to change { Snippet.count }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { create_snippet(visibility: 'public') }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(visibility: 'public') }
+ .to change { SpamLog.count }.by(1)
end
end
end
@@ -150,12 +173,14 @@ describe API::Snippets do
it 'updates snippet' do
new_content = 'New content'
+ new_description = 'New description'
- put api("/snippets/#{snippet.id}", user), content: new_content
+ put api("/snippets/#{snippet.id}", user), content: new_content, description: new_description
expect(response).to have_http_status(200)
snippet.reload
expect(snippet.content).to eq(new_content)
+ expect(snippet.description).to eq(new_description)
end
it 'returns 404 for invalid snippet id' do
@@ -191,8 +216,8 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'updates the snippet' do
- expect { update_snippet(title: 'Foo') }.
- to change { snippet.reload.title }.to('Foo')
+ expect { update_snippet(title: 'Foo') }
+ .to change { snippet.reload.title }.to('Foo')
end
end
@@ -200,16 +225,16 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
- expect { update_snippet(title: 'Foo') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo') }
+ .not_to change { snippet.reload.title }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo') }
+ .to change { SpamLog.count }.by(1)
end
end
@@ -217,13 +242,13 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo', visibility: 'public') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo', visibility: 'public') }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo', visibility: 'public') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo', visibility: 'public') }
+ .to change { SpamLog.count }.by(1)
end
end
end
@@ -246,4 +271,25 @@ describe API::Snippets do
expect(json_response['message']).to eq('404 Snippet Not Found')
end
end
+
+ describe "GET /snippets/:id/user_agent_detail" do
+ let(:admin) { create(:admin) }
+ let(:snippet) { create(:personal_snippet, :public, author: user) }
+ let!(:user_agent_detail) { create(:user_agent_detail, subject: snippet) }
+
+ it 'exposes known attributes' do
+ get api("/snippets/#{snippet.id}/user_agent_detail", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
+ expect(json_response['ip_address']).to eq(user_agent_detail.ip_address)
+ expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted)
+ end
+
+ it "returns unautorized for non-admin users" do
+ get api("/snippets/#{snippet.id}/user_agent_detail", user)
+
+ expect(response).to have_http_status(403)
+ end
+ end
end
diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb
index c7b84173570..f65b475fe44 100644
--- a/spec/requests/api/system_hooks_spec.rb
+++ b/spec/requests/api/system_hooks_spec.rb
@@ -5,7 +5,9 @@ describe API::SystemHooks do
let(:admin) { create(:admin) }
let!(:hook) { create(:system_hook, url: "http://example.com") }
- before { stub_request(:post, hook.url) }
+ before do
+ stub_request(:post, hook.url)
+ end
describe "GET /hooks" do
context "when no user" do
@@ -32,8 +34,9 @@ describe API::SystemHooks do
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['url']).to eq(hook.url)
- expect(json_response.first['push_events']).to be true
+ expect(json_response.first['push_events']).to be false
expect(json_response.first['tag_push_events']).to be false
+ expect(json_response.first['repository_update_events']).to be true
end
end
end
diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb
index ef7d0c3ee41..9884c1ec206 100644
--- a/spec/requests/api/tags_spec.rb
+++ b/spec/requests/api/tags_spec.rb
@@ -1,66 +1,85 @@
require 'spec_helper'
-require 'mime/types'
describe API::Tags do
- include RepoHelpers
-
let(:user) { create(:user) }
- let(:user2) { create(:user) }
- let!(:project) { create(:project, :repository, creator: user) }
- let!(:master) { create(:project_member, :master, user: user, project: project) }
- let!(:guest) { create(:project_member, :guest, user: user2, project: project) }
+ let(:guest) { create(:user).tap { |u| project.add_guest(u) } }
+ let(:project) { create(:project, :repository, creator: user, path: 'my.project') }
+ let(:tag_name) { project.repository.find_tag('v1.1.0').name }
- describe "GET /projects/:id/repository/tags" do
- let(:tag_name) { project.repository.tag_names.sort.reverse.first }
- let(:description) { 'Awesome release!' }
+ let(:project_id) { project.id }
+ let(:current_user) { nil }
+
+ before do
+ project.add_master(user)
+ end
+
+ describe 'GET /projects/:id/repository/tags' do
+ let(:route) { "/projects/#{project_id}/repository/tags" }
shared_examples_for 'repository tags' do
it 'returns the repository tags' do
- get api("/projects/#{project.id}/repository/tags", current_user)
+ get api(route, current_user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/tags')
expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
expect(json_response.first['name']).to eq(tag_name)
end
- end
- context 'when unauthenticated' do
- it_behaves_like 'repository tags' do
- let(:project) { create(:project, :public, :repository) }
- let(:current_user) { nil }
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, current_user) }
+ end
end
end
- context 'when authenticated' do
- it_behaves_like 'repository tags' do
- let(:current_user) { user }
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'repository tags'
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
end
end
- context 'without releases' do
- it "returns an array of project tags" do
- get api("/projects/#{project.id}/repository/tags", user)
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['name']).to eq(tag_name)
+ it_behaves_like 'repository tags'
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository tags'
+ end
+ end
+
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, guest) }
end
end
context 'with releases' do
+ let(:description) { 'Awesome release!' }
+
before do
release = project.releases.find_or_initialize_by(tag: tag_name)
release.update_attributes(description: description)
end
- it "returns an array of project tags with release info" do
- get api("/projects/#{project.id}/repository/tags", user)
+ it 'returns an array of project tags with release info' do
+ get api(route, user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/tags')
expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
expect(json_response.first['name']).to eq(tag_name)
expect(json_response.first['message']).to eq('Version 1.1.0')
expect(json_response.first['release']['description']).to eq(description)
@@ -69,210 +88,342 @@ describe API::Tags do
end
describe 'GET /projects/:id/repository/tags/:tag_name' do
- let(:tag_name) { project.repository.tag_names.sort.reverse.first }
+ let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}" }
shared_examples_for 'repository tag' do
- it 'returns the repository tag' do
- get api("/projects/#{project.id}/repository/tags/#{tag_name}", current_user)
-
- expect(response).to have_http_status(200)
+ it 'returns the repository branch' do
+ get api(route, current_user)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/tag')
expect(json_response['name']).to eq(tag_name)
end
- it 'returns 404 for an invalid tag name' do
- get api("/projects/#{project.id}/repository/tags/foobar", current_user)
+ context 'when tag does not exist' do
+ let(:tag_name) { 'unknown' }
- expect(response).to have_http_status(404)
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, current_user) }
+ let(:message) { '404 Tag Not Found' }
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, current_user) }
+ end
end
end
- context 'when unauthenticated' do
- it_behaves_like 'repository tag' do
- let(:project) { create(:project, :public, :repository) }
- let(:current_user) { nil }
+ context 'when unauthenticated', 'and project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it_behaves_like 'repository tag'
+ end
+
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(route) }
+ let(:message) { '404 Project Not Found' }
end
end
- context 'when authenticated' do
- it_behaves_like 'repository tag' do
- let(:current_user) { user }
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'repository tag'
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository tag'
+ end
+ end
+
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, guest) }
end
end
end
describe 'POST /projects/:id/repository/tags' do
- context 'lightweight tags' do
+ let(:tag_name) { 'new_tag' }
+ let(:route) { "/projects/#{project_id}/repository/tags" }
+
+ shared_examples_for 'repository new tag' do
it 'creates a new tag' do
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v7.0.1',
- ref: 'master'
+ post api(route, current_user), tag_name: tag_name, ref: 'master'
- expect(response).to have_http_status(201)
- expect(json_response['name']).to eq('v7.0.1')
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/tag')
+ expect(json_response['name']).to eq(tag_name)
end
- end
- context 'lightweight tags with release notes' do
- it 'creates a new tag' do
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v7.0.1',
- ref: 'master',
- release_description: 'Wow'
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(201)
- expect(json_response['name']).to eq('v7.0.1')
- expect(json_response['release']['description']).to eq('Wow')
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, current_user) }
+ end
end
end
- describe 'DELETE /projects/:id/repository/tags/:tag_name' do
- let(:tag_name) { project.repository.tag_names.sort.reverse.first }
+ context 'when unauthenticated', 'and project is private' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(route) }
+ let(:message) { '404 Project Not Found' }
+ end
+ end
- before do
- allow_any_instance_of(Repository).to receive(:rm_tag).and_return(true)
+ context 'when authenticated', 'as a guest' do
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, guest) }
end
+ end
+
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ context "when a protected branch doesn't already exist" do
+ it_behaves_like 'repository new tag'
- context 'delete tag' do
- it 'deletes an existing tag' do
- delete api("/projects/#{project.id}/repository/tags/#{tag_name}", user)
+ context 'when tag contains a dot' do
+ let(:tag_name) { 'v7.0.1' }
- expect(response).to have_http_status(204)
+ it_behaves_like 'repository new tag'
end
- it 'raises 404 if the tag does not exist' do
- delete api("/projects/#{project.id}/repository/tags/foobar", user)
- expect(response).to have_http_status(404)
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository new tag'
+
+ context 'when tag contains a dot' do
+ let(:tag_name) { 'v7.0.1' }
+
+ it_behaves_like 'repository new tag'
+ end
end
end
- end
- context 'annotated tag' do
- it 'creates a new annotated tag' do
- # Identity must be set in .gitconfig to create annotated tag.
- repo_path = project.repository.path_to_repo
- system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.name #{user.name}))
- system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.email #{user.email}))
+ it 'returns 400 if tag name is invalid' do
+ post api(route, current_user), tag_name: 'new design', ref: 'master'
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['message']).to eq('Tag name invalid')
+ end
+
+ it 'returns 400 if tag already exists' do
+ post api(route, current_user), tag_name: 'new_design1', ref: 'master'
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v7.1.0',
- ref: 'master',
- message: 'Release 7.1.0'
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/tag')
- expect(response).to have_http_status(201)
- expect(json_response['name']).to eq('v7.1.0')
- expect(json_response['message']).to eq('Release 7.1.0')
+ post api(route, current_user), tag_name: 'new_design1', ref: 'master'
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['message']).to eq('Tag new_design1 already exists')
end
- end
- it 'denies for user without push access' do
- post api("/projects/#{project.id}/repository/tags", user2),
- tag_name: 'v1.9.0',
- ref: '621491c677087aa243f165eab467bfdfbee00be1'
- expect(response).to have_http_status(403)
+ it 'returns 400 if ref name is invalid' do
+ post api(route, current_user), tag_name: 'new_design3', ref: 'foo'
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['message']).to eq('Target foo is invalid')
+ end
+
+ context 'lightweight tags with release notes' do
+ it 'creates a new tag' do
+ post api(route, current_user), tag_name: tag_name, ref: 'master', release_description: 'Wow'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/tag')
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['release']['description']).to eq('Wow')
+ end
+ end
+
+ context 'annotated tag' do
+ it 'creates a new annotated tag' do
+ # Identity must be set in .gitconfig to create annotated tag.
+ repo_path = project.repository.path_to_repo
+ system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.name #{user.name}))
+ system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.email #{user.email}))
+
+ post api(route, current_user), tag_name: 'v7.1.0', ref: 'master', message: 'Release 7.1.0'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/tag')
+ expect(json_response['name']).to eq('v7.1.0')
+ expect(json_response['message']).to eq('Release 7.1.0')
+ end
+ end
end
+ end
+
+ describe 'DELETE /projects/:id/repository/tags/:tag_name' do
+ let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}" }
- it 'returns 400 if tag name is invalid' do
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v 1.0.0',
- ref: 'master'
- expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('Tag name invalid')
+ before do
+ allow_any_instance_of(Repository).to receive(:rm_tag).and_return(true)
end
- it 'returns 400 if tag already exists' do
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v8.0.0',
- ref: 'master'
- expect(response).to have_http_status(201)
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'v8.0.0',
- ref: 'master'
- expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('Tag v8.0.0 already exists')
+ shared_examples_for 'repository delete tag' do
+ it 'deletes a tag' do
+ delete api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ context 'when tag does not exist' do
+ let(:tag_name) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { delete api(route, current_user) }
+ let(:message) { 'No such tag' }
+ end
+ end
+
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
+
+ it_behaves_like '403 response' do
+ let(:request) { delete api(route, current_user) }
+ end
+ end
end
- it 'returns 400 if ref name is invalid' do
- post api("/projects/#{project.id}/repository/tags", user),
- tag_name: 'mytag',
- ref: 'foo'
- expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('Target foo is invalid')
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'repository delete tag'
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository delete tag'
+ end
end
end
describe 'POST /projects/:id/repository/tags/:tag_name/release' do
- let(:tag_name) { project.repository.tag_names.first }
+ let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}/release" }
let(:description) { 'Awesome release!' }
- it 'creates description for existing git tag' do
- post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user),
- description: description
+ shared_examples_for 'repository new release' do
+ it 'creates description for existing git tag' do
+ post api(route, user), description: description
- expect(response).to have_http_status(201)
- expect(json_response['tag_name']).to eq(tag_name)
- expect(json_response['description']).to eq(description)
- end
+ expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/release')
+ expect(json_response['tag_name']).to eq(tag_name)
+ expect(json_response['description']).to eq(description)
+ end
+
+ context 'when tag does not exist' do
+ let(:tag_name) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { post api(route, current_user), description: description }
+ let(:message) { 'Tag does not exist' }
+ end
+ end
- it 'returns 404 if the tag does not exist' do
- post api("/projects/#{project.id}/repository/tags/foobar/release", user),
- description: description
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('Tag does not exist')
+ it_behaves_like '403 response' do
+ let(:request) { post api(route, current_user), description: description }
+ end
+ end
end
- context 'on tag with existing release' do
- before do
- release = project.releases.find_or_initialize_by(tag: tag_name)
- release.update_attributes(description: description)
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'repository new release'
+
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository new release'
end
- it 'returns 409 if there is already a release' do
- post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user),
- description: description
+ context 'on tag with existing release' do
+ before do
+ release = project.releases.find_or_initialize_by(tag: tag_name)
+ release.update_attributes(description: description)
+ end
+
+ it 'returns 409 if there is already a release' do
+ post api(route, user), description: description
- expect(response).to have_http_status(409)
- expect(json_response['message']).to eq('Release already exists')
+ expect(response).to have_gitlab_http_status(409)
+ expect(json_response['message']).to eq('Release already exists')
+ end
end
end
end
describe 'PUT id/repository/tags/:tag_name/release' do
- let(:tag_name) { project.repository.tag_names.first }
+ let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}/release" }
let(:description) { 'Awesome release!' }
let(:new_description) { 'The best release!' }
- context 'on tag with existing release' do
- before do
- release = project.releases.find_or_initialize_by(tag: tag_name)
- release.update_attributes(description: description)
+ shared_examples_for 'repository update release' do
+ context 'on tag with existing release' do
+ before do
+ release = project.releases.find_or_initialize_by(tag: tag_name)
+ release.update_attributes(description: description)
+ end
+
+ it 'updates the release description' do
+ put api(route, current_user), description: new_description
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['tag_name']).to eq(tag_name)
+ expect(json_response['description']).to eq(new_description)
+ end
end
- it 'updates the release description' do
- put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user),
- description: new_description
+ context 'when tag does not exist' do
+ let(:tag_name) { 'unknown' }
- expect(response).to have_http_status(200)
- expect(json_response['tag_name']).to eq(tag_name)
- expect(json_response['description']).to eq(new_description)
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user), description: new_description }
+ let(:message) { 'Tag does not exist' }
+ end
end
- end
- it 'returns 404 if the tag does not exist' do
- put api("/projects/#{project.id}/repository/tags/foobar/release", user),
- description: new_description
+ context 'when repository is disabled' do
+ include_context 'disabled repository'
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('Tag does not exist')
+ it_behaves_like '403 response' do
+ let(:request) { put api(route, current_user), description: new_description }
+ end
+ end
end
- it 'returns 404 if the release does not exist' do
- put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user),
- description: new_description
+ context 'when authenticated', 'as a master' do
+ let(:current_user) { user }
+
+ it_behaves_like 'repository update release'
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('Release does not exist')
+ context 'requesting with the escaped project full path' do
+ let(:project_id) { CGI.escape(project.full_path) }
+
+ it_behaves_like 'repository update release'
+ end
+
+ context 'when release does not exist' do
+ it_behaves_like '404 response' do
+ let(:request) { put api(route, current_user), description: new_description }
+ let(:message) { 'Release does not exist' }
+ end
+ end
end
end
end
diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb
index cb55985e3f5..f8af9295842 100644
--- a/spec/requests/api/templates_spec.rb
+++ b/spec/requests/api/templates_spec.rb
@@ -2,14 +2,18 @@ require 'spec_helper'
describe API::Templates do
context 'the Template Entity' do
- before { get api('/templates/gitignores/Ruby') }
+ before do
+ get api('/templates/gitignores/Ruby')
+ end
it { expect(json_response['name']).to eq('Ruby') }
it { expect(json_response['content']).to include('*.gem') }
end
context 'the TemplateList Entity' do
- before { get api('/templates/gitignores') }
+ before do
+ get api('/templates/gitignores')
+ end
it { expect(json_response.first['name']).not_to be_nil }
it { expect(json_response.first['content']).to be_nil }
@@ -47,7 +51,9 @@ describe API::Templates do
end
context 'the License Template Entity' do
- before { get api('/templates/licenses/mit') }
+ before do
+ get api('/templates/licenses/mit')
+ end
it 'returns a license template' do
expect(json_response['key']).to eq('mit')
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index 92533f4dfea..25d7f6dffcf 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
describe API::Todos do
- let(:project_1) { create(:empty_project, :test_repo) }
- let(:project_2) { create(:empty_project) }
+ let(:project_1) { create(:project, :repository) }
+ let(:project_2) { create(:project) }
let(:author_1) { create(:user) }
let(:author_2) { create(:user) }
let(:john_doe) { create(:user, username: 'john_doe') }
diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb
index 16ddade27d9..572e9a0fd07 100644
--- a/spec/requests/api/triggers_spec.rb
+++ b/spec/requests/api/triggers_spec.rb
@@ -13,7 +13,7 @@ describe API::Triggers do
let!(:trigger_request) { create(:ci_trigger_request, trigger: trigger, created_at: '2015-01-01 12:13:14') }
describe 'POST /projects/:project_id/trigger/pipeline' do
- let!(:project2) { create(:project) }
+ let!(:project2) { create(:project, :repository) }
let(:options) do
{
token: trigger_token
@@ -22,6 +22,7 @@ describe API::Triggers do
before do
stub_ci_pipeline_to_return_yaml_file
+ trigger.update(owner: user)
end
context 'Handles errors' do
@@ -36,12 +37,6 @@ describe API::Triggers do
expect(response).to have_http_status(404)
end
-
- it 'returns unauthorized if token is for different project' do
- post api("/projects/#{project2.id}/trigger/pipeline"), options.merge(ref: 'master')
-
- expect(response).to have_http_status(401)
- end
end
context 'Have a commit' do
@@ -61,7 +56,7 @@ describe API::Triggers do
post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'other-branch')
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('No pipeline created')
+ expect(json_response['message']).to eq('base' => ["Reference not found"])
end
context 'Validates variables' do
@@ -87,12 +82,18 @@ describe API::Triggers do
post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: variables, ref: 'master')
expect(response).to have_http_status(201)
- expect(pipeline.builds.reload.first.trigger_request.variables).to eq(variables)
+ expect(pipeline.variables.map { |v| { v.key => v.value } }.last).to eq(variables)
end
end
end
context 'when triggering a pipeline from a trigger token' do
+ it 'does not leak the presence of project when token is for different project' do
+ post api("/projects/#{project2.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' }
+
+ expect(response).to have_http_status(404)
+ end
+
it 'creates builds from the ref given in the URL, not in the body' do
expect do
post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' }
@@ -184,7 +185,7 @@ describe API::Triggers do
expect do
post api("/projects/#{project.id}/triggers", user),
description: 'trigger'
- end.to change{project.triggers.count}.by(1)
+ end.to change {project.triggers.count}.by(1)
expect(response).to have_http_status(201)
expect(json_response).to include('description' => 'trigger')
@@ -287,7 +288,7 @@ describe API::Triggers do
delete api("/projects/#{project.id}/triggers/#{trigger.id}", user)
expect(response).to have_http_status(204)
- end.to change{project.triggers.count}.by(-1)
+ end.to change {project.triggers.count}.by(-1)
end
it 'responds with 404 Not Found if requesting non-existing trigger' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 4919ad19833..49739a1601a 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -11,11 +11,48 @@ describe API::Users do
let(:not_existing_user_id) { (User.maximum('id') || 0 ) + 10 }
let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 }
- describe "GET /users" do
+ describe 'GET /users' do
context "when unauthenticated" do
- it "returns authentication error" do
+ it "returns authorization error when the `username` parameter is not passed" do
get api("/users")
- expect(response).to have_http_status(401)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it "returns the user when a valid `username` parameter is passed" do
+ get api("/users"), username: user.username
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ expect(json_response[0]['id']).to eq(user.id)
+ expect(json_response[0]['username']).to eq(user.username)
+ end
+
+ it "returns an empty response when an invalid `username` parameter is passed" do
+ get api("/users"), username: 'invalid'
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(0)
+ end
+
+ context "when public level is restricted" do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
+ end
+
+ it "returns authorization error when the `username` parameter refers to an inaccessible user" do
+ get api("/users"), username: user.username
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it "returns authorization error when the `username` parameter is not passed" do
+ get api("/users")
+
+ expect(response).to have_gitlab_http_status(403)
+ end
end
end
@@ -24,17 +61,22 @@ describe API::Users do
context "when public level is restricted" do
before do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
- allow_any_instance_of(API::Helpers).to receive(:authenticate!).and_return(true)
end
- it "renders 403" do
- get api("/users")
- expect(response).to have_http_status(403)
+ context 'when authenticate as a regular user' do
+ it "renders 200" do
+ get api("/users", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
end
- it "renders 404" do
- get api("/users/#{user.id}")
- expect(response).to have_http_status(404)
+ context 'when authenticate as an admin' do
+ it "renders 200" do
+ get api("/users", admin)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
end
end
@@ -76,6 +118,12 @@ describe API::Users do
expect(response).to have_http_status(403)
end
+
+ it 'does not reveal the `is_admin` flag of the user' do
+ get api('/users', user)
+
+ expect(json_response.first.keys).not_to include 'is_admin'
+ end
end
context "when admin" do
@@ -92,6 +140,7 @@ describe API::Users do
expect(json_response.first.keys).to include 'two_factor_enabled'
expect(json_response.first.keys).to include 'last_sign_in_at'
expect(json_response.first.keys).to include 'confirmed_at'
+ expect(json_response.first.keys).to include 'is_admin'
end
it "returns an array of external users" do
@@ -125,12 +174,42 @@ describe API::Users do
expect(response).to have_http_status(400)
end
+
+ it "returns a user created before a specific date" do
+ user = create(:user, created_at: Date.new(2000, 1, 1))
+
+ get api("/users?created_before=2000-01-02T00:00:00.060Z", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['username']).to eq(user.username)
+ end
+
+ it "returns no users created before a specific date" do
+ create(:user, created_at: Date.new(2001, 1, 1))
+
+ get api("/users?created_before=2000-01-02T00:00:00.060Z", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response.size).to eq(0)
+ end
+
+ it "returns users created before and after a specific date" do
+ user = create(:user, created_at: Date.new(2001, 1, 1))
+
+ get api("/users?created_before=2001-01-02T00:00:00.060Z&created_after=1999-01-02T00:00:00.060", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['username']).to eq(user.username)
+ end
end
end
describe "GET /users/:id" do
it "returns a user by id" do
get api("/users/#{user.id}", user)
+
expect(response).to have_http_status(200)
expect(json_response['username']).to eq(user.username)
end
@@ -138,12 +217,35 @@ describe API::Users do
it "does not return the user's `is_admin` flag" do
get api("/users/#{user.id}", user)
+ expect(response).to have_http_status(200)
expect(json_response['is_admin']).to be_nil
end
- it "returns a 401 if unauthenticated" do
- get api("/users/9998")
- expect(response).to have_http_status(401)
+ context 'when authenticated as admin' do
+ it 'includes the `is_admin` field' do
+ get api("/users/#{user.id}", admin)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['is_admin']).to be(false)
+ end
+ end
+
+ context 'for an anonymous user' do
+ it "returns a user by id" do
+ get api("/users/#{user.id}")
+
+ expect(response).to have_http_status(200)
+ expect(json_response['username']).to eq(user.username)
+ end
+
+ it "returns a 404 if the target user is present but inaccessible" do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(nil, :read_user, user).and_return(false)
+
+ get api("/users/#{user.id}")
+
+ expect(response).to have_http_status(404)
+ end
end
it "returns a 404 error if user id not found" do
@@ -160,7 +262,9 @@ describe API::Users do
end
describe "POST /users" do
- before { admin }
+ before do
+ admin
+ end
it "creates user" do
expect do
@@ -280,14 +384,14 @@ describe API::Users do
bio: 'g' * 256,
projects_limit: -1
expect(response).to have_http_status(400)
- expect(json_response['message']['password']).
- to eq(['is too short (minimum is 8 characters)'])
- expect(json_response['message']['bio']).
- to eq(['is too long (maximum is 255 characters)'])
- expect(json_response['message']['projects_limit']).
- to eq(['must be greater than or equal to 0'])
- expect(json_response['message']['username']).
- to eq([Gitlab::Regex.namespace_regex_message])
+ expect(json_response['message']['password'])
+ .to eq(['is too short (minimum is 8 characters)'])
+ expect(json_response['message']['bio'])
+ .to eq(['is too long (maximum is 255 characters)'])
+ expect(json_response['message']['projects_limit'])
+ .to eq(['must be greater than or equal to 0'])
+ expect(json_response['message']['username'])
+ .to eq([Gitlab::PathRegex.namespace_format_message])
end
it "is not available for non admin users" do
@@ -336,6 +440,14 @@ describe API::Users do
expect(json_response['identities'].first['provider']).to eq('github')
end
end
+
+ context "scopes" do
+ let(:user) { admin }
+ let(:path) { '/users' }
+ let(:api_call) { method(:api) }
+
+ include_examples 'does not allow the "read_user" scope'
+ end
end
describe "GET /users/sign_up" do
@@ -349,10 +461,13 @@ describe API::Users do
describe "PUT /users/:id" do
let!(:admin_user) { create(:admin) }
- before { admin }
+ before do
+ admin
+ end
it "updates user with new bio" do
put api("/users/#{user.id}", admin), { bio: 'new test bio' }
+
expect(response).to have_http_status(200)
expect(json_response['bio']).to eq('new test bio')
expect(user.reload.bio).to eq('new test bio')
@@ -373,15 +488,34 @@ describe API::Users do
expect(user.reload.organization).to eq('GitLab')
end
+ it 'updates user with avatar' do
+ put api("/users/#{user.id}", admin), { avatar: fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') }
+
+ user.reload
+
+ expect(user.avatar).to be_present
+ expect(response).to have_http_status(200)
+ expect(json_response['avatar_url']).to include(user.avatar_path)
+ end
+
it 'updates user with his own email' do
put api("/users/#{user.id}", admin), email: user.email
+
expect(response).to have_http_status(200)
expect(json_response['email']).to eq(user.email)
expect(user.reload.email).to eq(user.email)
end
+ it 'updates user with a new email' do
+ put api("/users/#{user.id}", admin), email: 'new@email.com'
+
+ expect(response).to have_http_status(200)
+ expect(user.reload.notification_email).to eq('new@email.com')
+ end
+
it 'updates user with his own username' do
put api("/users/#{user.id}", admin), username: user.username
+
expect(response).to have_http_status(200)
expect(json_response['username']).to eq(user.username)
expect(user.reload.username).to eq(user.username)
@@ -389,12 +523,14 @@ describe API::Users do
it "updates user's existing identity" do
put api("/users/#{omniauth_user.id}", admin), provider: 'ldapmain', extern_uid: '654321'
+
expect(response).to have_http_status(200)
expect(omniauth_user.reload.identities.first.extern_uid).to eq('654321')
end
it 'updates user with new identity' do
put api("/users/#{user.id}", admin), provider: 'github', extern_uid: 'john'
+
expect(response).to have_http_status(200)
expect(user.reload.identities.first.extern_uid).to eq('john')
expect(user.reload.identities.first.provider).to eq('github')
@@ -402,12 +538,14 @@ describe API::Users do
it "updates admin status" do
put api("/users/#{user.id}", admin), { admin: true }
+
expect(response).to have_http_status(200)
expect(user.reload.admin).to eq(true)
end
it "updates external status" do
put api("/users/#{user.id}", admin), { external: true }
+
expect(response.status).to eq 200
expect(json_response['external']).to eq(true)
expect(user.reload.external?).to be_truthy
@@ -415,6 +553,7 @@ describe API::Users do
it "does not update admin status" do
put api("/users/#{admin_user.id}", admin), { can_create_group: false }
+
expect(response).to have_http_status(200)
expect(admin_user.reload.admin).to eq(true)
expect(admin_user.can_create_group).to eq(false)
@@ -422,17 +561,24 @@ describe API::Users do
it "does not allow invalid update" do
put api("/users/#{user.id}", admin), { email: 'invalid email' }
+
expect(response).to have_http_status(400)
expect(user.reload.email).not_to eq('invalid email')
end
- it "is not available for non admin users" do
- put api("/users/#{user.id}", user), attributes_for(:user)
- expect(response).to have_http_status(403)
+ context 'when the current user is not an admin' do
+ it "is not available" do
+ expect do
+ put api("/users/#{user.id}", user), attributes_for(:user)
+ end.not_to change { user.reload.attributes }
+
+ expect(response).to have_http_status(403)
+ end
end
it "returns 404 for non-existing user" do
put api("/users/999999", admin), { bio: 'update should fail' }
+
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
@@ -452,14 +598,14 @@ describe API::Users do
bio: 'g' * 256,
projects_limit: -1
expect(response).to have_http_status(400)
- expect(json_response['message']['password']).
- to eq(['is too short (minimum is 8 characters)'])
- expect(json_response['message']['bio']).
- to eq(['is too long (maximum is 255 characters)'])
- expect(json_response['message']['projects_limit']).
- to eq(['must be greater than or equal to 0'])
- expect(json_response['message']['username']).
- to eq([Gitlab::Regex.namespace_regex_message])
+ expect(json_response['message']['password'])
+ .to eq(['is too short (minimum is 8 characters)'])
+ expect(json_response['message']['bio'])
+ .to eq(['is too long (maximum is 255 characters)'])
+ expect(json_response['message']['projects_limit'])
+ .to eq(['must be greater than or equal to 0'])
+ expect(json_response['message']['username'])
+ .to eq([Gitlab::PathRegex.namespace_format_message])
end
it 'returns 400 if provider is missing for identity update' do
@@ -483,6 +629,7 @@ describe API::Users do
it 'returns 409 conflict error if email address exists' do
put api("/users/#{@user.id}", admin), email: 'test@example.com'
+
expect(response).to have_http_status(409)
expect(@user.reload.email).to eq(@user.email)
end
@@ -490,6 +637,7 @@ describe API::Users do
it 'returns 409 conflict error if username taken' do
@user_id = User.all.last.id
put api("/users/#{@user.id}", admin), username: 'test'
+
expect(response).to have_http_status(409)
expect(@user.reload.username).to eq(@user.username)
end
@@ -497,7 +645,9 @@ describe API::Users do
end
describe "POST /users/:id/keys" do
- before { admin }
+ before do
+ admin
+ end
it "does not create invalid ssh key" do
post api("/users/#{user.id}/keys", admin), { title: "invalid key" }
@@ -527,7 +677,9 @@ describe API::Users do
end
describe 'GET /user/:id/keys' do
- before { admin }
+ before do
+ admin
+ end
context 'when unauthenticated' do
it 'returns authentication error' do
@@ -558,7 +710,9 @@ describe API::Users do
end
describe 'DELETE /user/:id/keys/:key_id' do
- before { admin }
+ before do
+ admin
+ end
context 'when unauthenticated' do
it 'returns authentication error' do
@@ -596,7 +750,9 @@ describe API::Users do
end
describe "POST /users/:id/emails" do
- before { admin }
+ before do
+ admin
+ end
it "does not create invalid email" do
post api("/users/#{user.id}/emails", admin), {}
@@ -620,7 +776,9 @@ describe API::Users do
end
describe 'GET /user/:id/emails' do
- before { admin }
+ before do
+ admin
+ end
context 'when unauthenticated' do
it 'returns authentication error' do
@@ -649,7 +807,7 @@ describe API::Users do
end
it "returns a 404 for invalid ID" do
- put api("/users/ASDF/emails", admin)
+ get api("/users/ASDF/emails", admin)
expect(response).to have_http_status(404)
end
@@ -657,7 +815,9 @@ describe API::Users do
end
describe 'DELETE /user/:id/emails/:email_id' do
- before { admin }
+ before do
+ admin
+ end
context 'when unauthenticated' do
it 'returns authentication error' do
@@ -702,7 +862,11 @@ describe API::Users do
describe "DELETE /users/:id" do
let!(:namespace) { user.namespace }
- before { admin }
+ let!(:issue) { create(:issue, author: user) }
+
+ before do
+ admin
+ end
it "deletes user" do
Sidekiq::Testing.inline! { delete api("/users/#{user.id}", admin) }
@@ -733,6 +897,25 @@ describe API::Users do
expect(response).to have_http_status(404)
end
+
+ context "hard delete disabled" do
+ it "moves contributions to the ghost user" do
+ Sidekiq::Testing.inline! { delete api("/users/#{user.id}", admin) }
+
+ expect(response).to have_http_status(204)
+ expect(issue.reload).to be_persisted
+ expect(issue.author.ghost?).to be_truthy
+ end
+ end
+
+ context "hard delete enabled" do
+ it "removes contributions" do
+ Sidekiq::Testing.inline! { delete api("/users/#{user.id}?hard_delete=true", admin) }
+
+ expect(response).to have_http_status(204)
+ expect(Issue.exists?(issue.id)).to be_falsy
+ end
+ end
end
describe "GET /user" do
@@ -762,6 +945,13 @@ describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/public')
expect(json_response['id']).to eq(user.id)
end
+
+ context "scopes" do
+ let(:path) { "/user" }
+ let(:api_call) { method(:api) }
+
+ include_examples 'allows the "read_user" scope'
+ end
end
context 'with admin' do
@@ -774,11 +964,11 @@ describe API::Users do
expect(response).to have_http_status(403)
end
- it 'returns initial current user without private token when sudo not defined' do
+ it 'returns initial current user without private token but with is_admin when sudo not defined' do
get api("/user?private_token=#{admin_personal_access_token}")
expect(response).to have_http_status(200)
- expect(response).to match_response_schema('public_api/v4/user/public')
+ expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['id']).to eq(admin.id)
end
end
@@ -792,11 +982,11 @@ describe API::Users do
expect(json_response['id']).to eq(user.id)
end
- it 'returns initial current user without private token when sudo not defined' do
+ it 'returns initial current user without private token but with is_admin when sudo not defined' do
get api("/user?private_token=#{admin.private_token}")
expect(response).to have_http_status(200)
- expect(response).to match_response_schema('public_api/v4/user/public')
+ expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['id']).to eq(admin.id)
end
end
@@ -831,6 +1021,13 @@ describe API::Users do
expect(json_response).to be_an Array
expect(json_response.first["title"]).to eq(key.title)
end
+
+ context "scopes" do
+ let(:path) { "/user/keys" }
+ let(:api_call) { method(:api) }
+
+ include_examples 'allows the "read_user" scope'
+ end
end
end
@@ -864,6 +1061,13 @@ describe API::Users do
expect(response).to have_http_status(404)
end
+
+ context "scopes" do
+ let(:path) { "/user/keys/#{key.id}" }
+ let(:api_call) { method(:api) }
+
+ include_examples 'allows the "read_user" scope'
+ end
end
describe "POST /user/keys" do
@@ -953,6 +1157,13 @@ describe API::Users do
expect(json_response).to be_an Array
expect(json_response.first["email"]).to eq(email.email)
end
+
+ context "scopes" do
+ let(:path) { "/user/emails" }
+ let(:api_call) { method(:api) }
+
+ include_examples 'allows the "read_user" scope'
+ end
end
end
@@ -985,6 +1196,13 @@ describe API::Users do
expect(response).to have_http_status(404)
end
+
+ context "scopes" do
+ let(:path) { "/user/emails/#{email.id}" }
+ let(:api_call) { method(:api) }
+
+ include_examples 'allows the "read_user" scope'
+ end
end
describe "POST /user/emails" do
@@ -1043,7 +1261,10 @@ describe API::Users do
end
describe 'POST /users/:id/block' do
- before { admin }
+ before do
+ admin
+ end
+
it 'blocks existing user' do
post api("/users/#{user.id}/block", admin)
expect(response).to have_http_status(201)
@@ -1071,7 +1292,10 @@ describe API::Users do
describe 'POST /users/:id/unblock' do
let(:blocked_user) { create(:user, state: 'blocked') }
- before { admin }
+
+ before do
+ admin
+ end
it 'unblocks existing user' do
post api("/users/#{user.id}/unblock", admin)
@@ -1110,84 +1334,7 @@ describe API::Users do
end
end
- describe 'GET /users/:id/events' do
- let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
- let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) }
-
- before do
- project.add_user(user, :developer)
- EventCreateService.new.leave_note(note, user)
- end
-
- context "as a user than cannot see the event's project" do
- it 'returns no events' do
- other_user = create(:user)
-
- get api("/users/#{user.id}/events", other_user)
-
- expect(response).to have_http_status(200)
- expect(json_response).to be_empty
- end
- end
-
- context "as a user than can see the event's project" do
- context 'joined event' do
- it 'returns the "joined" event' do
- get api("/users/#{user.id}/events", user)
-
- expect(response).to have_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
-
- comment_event = json_response.find { |e| e['action_name'] == 'commented on' }
-
- expect(comment_event['project_id'].to_i).to eq(project.id)
- expect(comment_event['author_username']).to eq(user.username)
- expect(comment_event['note']['id']).to eq(note.id)
- expect(comment_event['note']['body']).to eq('What an awesome day!')
-
- joined_event = json_response.find { |e| e['action_name'] == 'joined' }
-
- expect(joined_event['project_id'].to_i).to eq(project.id)
- expect(joined_event['author_username']).to eq(user.username)
- expect(joined_event['author']['name']).to eq(user.name)
- end
- end
-
- context 'when there are multiple events from different projects' do
- let(:second_note) { create(:note_on_issue, project: create(:empty_project)) }
- let(:third_note) { create(:note_on_issue, project: project) }
-
- before do
- second_note.project.add_user(user, :developer)
-
- [second_note, third_note].each do |note|
- EventCreateService.new.leave_note(note, user)
- end
- end
-
- it 'returns events in the correct order (from newest to oldest)' do
- get api("/users/#{user.id}/events", user)
-
- comment_events = json_response.select { |e| e['action_name'] == 'commented on' }
-
- expect(comment_events[0]['target_id']).to eq(third_note.id)
- expect(comment_events[1]['target_id']).to eq(second_note.id)
- expect(comment_events[2]['target_id']).to eq(note.id)
- end
- end
- end
-
- it 'returns a 404 error if not found' do
- get api('/users/42/events', user)
-
- expect(response).to have_http_status(404)
- expect(json_response['message']).to eq('404 User Not Found')
- end
- end
-
- context "user activities", :redis do
+ context "user activities", :clean_gitlab_redis_shared_state do
let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
diff --git a/spec/requests/api/v3/award_emoji_spec.rb b/spec/requests/api/v3/award_emoji_spec.rb
index 9234710f488..681e8e04295 100644
--- a/spec/requests/api/v3/award_emoji_spec.rb
+++ b/spec/requests/api/v3/award_emoji_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::V3::AwardEmoji do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project) }
+ let!(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let!(:award_emoji) { create(:award_emoji, awardable: issue, user: user) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
diff --git a/spec/requests/api/v3/boards_spec.rb b/spec/requests/api/v3/boards_spec.rb
index 4d786331d1b..b86aab2ec70 100644
--- a/spec/requests/api/v3/boards_spec.rb
+++ b/spec/requests/api/v3/boards_spec.rb
@@ -4,7 +4,7 @@ describe API::V3::Boards do
let(:user) { create(:user) }
let(:guest) { create(:user) }
let(:non_member) { create(:user) }
- let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) }
+ let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
let!(:dev_label) do
create(:label, title: 'Development', color: '#FFAABB', project: project)
@@ -99,7 +99,7 @@ describe API::V3::Boards do
context "when the user is project owner" do
let(:owner) { create(:user) }
- let(:project) { create(:empty_project, namespace: owner.namespace) }
+ let(:project) { create(:project, namespace: owner.namespace) }
it "deletes the list if an admin requests it" do
delete v3_api("#{base_url}/#{dev_list.id}", owner)
diff --git a/spec/requests/api/v3/branches_spec.rb b/spec/requests/api/v3/branches_spec.rb
index 72f8fbe71fb..c88f7788697 100644
--- a/spec/requests/api/v3/branches_spec.rb
+++ b/spec/requests/api/v3/branches_spec.rb
@@ -47,19 +47,6 @@ describe API::V3::Branches do
delete v3_api("/projects/#{project.id}/repository/branches/foobar", user)
expect(response).to have_http_status(404)
end
-
- it "removes protected branch" do
- create(:protected_branch, project: project, name: branch_name)
- delete v3_api("/projects/#{project.id}/repository/branches/#{branch_name}", user)
- expect(response).to have_http_status(405)
- expect(json_response['message']).to eq('Protected branch cant be removed')
- end
-
- it "does not remove HEAD branch" do
- delete v3_api("/projects/#{project.id}/repository/branches/master", user)
- expect(response).to have_http_status(405)
- expect(json_response['message']).to eq('Cannot remove HEAD branch')
- end
end
describe "DELETE /projects/:id/repository/merged_branches" do
diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb
index c2e8c3ae6f7..4a4a5dc5c7c 100644
--- a/spec/requests/api/v3/commits_spec.rb
+++ b/spec/requests/api/v3/commits_spec.rb
@@ -5,7 +5,6 @@ describe API::V3::Commits do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let!(:project) { create(:project, :repository, creator: user, namespace: user.namespace) }
- let!(:master) { create(:project_member, :master, user: user, project: project) }
let!(:guest) { create(:project_member, :guest, user: user2, project: project) }
let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') }
let!(:another_note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'another comment on a commit') }
@@ -387,7 +386,7 @@ describe API::V3::Commits do
end
it "returns status for CI" do
- pipeline = project.ensure_pipeline('master', project.repository.commit.sha)
+ pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha)
pipeline.update(status: 'success')
get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
@@ -397,7 +396,7 @@ describe API::V3::Commits do
end
it "returns status for CI when pipeline is created" do
- project.ensure_pipeline('master', project.repository.commit.sha)
+ project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha)
get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user)
diff --git a/spec/requests/api/v3/deploy_keys_spec.rb b/spec/requests/api/v3/deploy_keys_spec.rb
index b61b2b618a6..2affd0cfa51 100644
--- a/spec/requests/api/v3/deploy_keys_spec.rb
+++ b/spec/requests/api/v3/deploy_keys_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
describe API::V3::DeployKeys do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, creator_id: user.id) }
- let(:project2) { create(:empty_project, creator_id: user.id) }
+ let(:project) { create(:project, creator_id: user.id) }
+ let(:project2) { create(:project, creator_id: user.id) }
let(:deploy_key) { create(:deploy_key, public: true) }
let!(:deploy_keys_project) do
@@ -87,7 +87,7 @@ describe API::V3::DeployKeys do
expect do
post v3_api("/projects/#{project.id}/#{path}", admin), key_attrs
- end.to change{ project.deploy_keys.count }.by(1)
+ end.to change { project.deploy_keys.count }.by(1)
end
it 'returns an existing ssh key when attempting to add a duplicate' do
@@ -105,6 +105,15 @@ describe API::V3::DeployKeys do
expect(response).to have_http_status(201)
end
+
+ it 'accepts can_push parameter' do
+ key_attrs = attributes_for :write_access_key
+
+ post v3_api("/projects/#{project.id}/#{path}", admin), key_attrs
+
+ expect(response).to have_http_status(201)
+ expect(json_response['can_push']).to eq(true)
+ end
end
describe "DELETE /projects/:id/#{path}/:key_id" do
@@ -113,7 +122,7 @@ describe API::V3::DeployKeys do
it 'should delete existing key' do
expect do
delete v3_api("/projects/#{project.id}/#{path}/#{deploy_key.id}", admin)
- end.to change{ project.deploy_keys.count }.by(-1)
+ end.to change { project.deploy_keys.count }.by(-1)
end
it 'should return 404 Not Found with invalid ID' do
@@ -124,7 +133,7 @@ describe API::V3::DeployKeys do
end
describe "POST /projects/:id/#{path}/:key_id/enable" do
- let(:project2) { create(:empty_project) }
+ let(:project2) { create(:project) }
context 'when the user can admin the project' do
it 'enables the key' do
diff --git a/spec/requests/api/v3/environments_spec.rb b/spec/requests/api/v3/environments_spec.rb
index 99f35723974..39264e819a3 100644
--- a/spec/requests/api/v3/environments_spec.rb
+++ b/spec/requests/api/v3/environments_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::V3::Environments do
let(:user) { create(:user) }
let(:non_member) { create(:user) }
- let(:project) { create(:empty_project, :private, namespace: user.namespace) }
+ let(:project) { create(:project, :private, namespace: user.namespace) }
let!(:environment) { create(:environment, project: project) }
before do
diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb
index 5bcbb441979..4ffa5d1784e 100644
--- a/spec/requests/api/v3/files_spec.rb
+++ b/spec/requests/api/v3/files_spec.rb
@@ -53,7 +53,7 @@ describe API::V3::Files do
let(:params) do
{
file_path: 'app/models/application.rb',
- ref: 'master',
+ ref: 'master'
}
end
@@ -74,7 +74,7 @@ describe API::V3::Files do
context 'when unauthenticated', 'and project is public' do
it_behaves_like 'repository files' do
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
end
end
@@ -126,8 +126,8 @@ describe API::V3::Files do
end
it "returns a 400 if editor fails to create file" do
- allow_any_instance_of(Repository).to receive(:create_file).
- and_raise(Repository::CommitError, 'Cannot create file')
+ allow_any_instance_of(Repository).to receive(:create_file)
+ .and_raise(Repository::CommitError, 'Cannot create file')
post v3_api("/projects/#{project.id}/repository/files", user), valid_params
@@ -263,7 +263,7 @@ describe API::V3::Files do
let(:get_params) do
{
file_path: file_path,
- ref: 'master',
+ ref: 'master'
}
end
diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb
index 2862580cc70..778fcc73c30 100644
--- a/spec/requests/api/v3/groups_spec.rb
+++ b/spec/requests/api/v3/groups_spec.rb
@@ -9,9 +9,9 @@ describe API::V3::Groups do
let(:admin) { create(:admin) }
let!(:group1) { create(:group, avatar: File.open(uploaded_image_temp_path)) }
let!(:group2) { create(:group, :private) }
- let!(:project1) { create(:empty_project, namespace: group1) }
- let!(:project2) { create(:empty_project, namespace: group2) }
- let!(:project3) { create(:empty_project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+ let!(:project1) { create(:project, namespace: group1) }
+ let!(:project2) { create(:project, namespace: group2) }
+ let!(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
before do
group1.add_owner(user1)
@@ -69,7 +69,7 @@ describe API::V3::Groups do
storage_size: 702,
repository_size: 123,
lfs_objects_size: 234,
- build_artifacts_size: 345,
+ build_artifacts_size: 345
}.stringify_keys
project1.statistics.update!(attributes)
@@ -165,7 +165,7 @@ describe API::V3::Groups do
describe "GET /groups/:id" do
context "when authenticated as user" do
it "returns one of user1's groups" do
- project = create(:empty_project, namespace: group2, path: 'Foo')
+ project = create(:project, namespace: group2, path: 'Foo')
create(:project_group_link, project: project, group: group1)
get v3_api("/groups/#{group1.id}", user1)
@@ -176,7 +176,7 @@ describe API::V3::Groups do
expect(json_response['path']).to eq(group1.path)
expect(json_response['description']).to eq(group1.description)
expect(json_response['visibility_level']).to eq(group1.visibility_level)
- expect(json_response['avatar_url']).to eq(group1.avatar_url)
+ expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false))
expect(json_response['web_url']).to eq(group1.web_url)
expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled)
expect(json_response['full_name']).to eq(group1.full_name)
@@ -307,7 +307,7 @@ describe API::V3::Groups do
end
it 'filters the groups projects' do
- public_project = create(:empty_project, :public, path: 'test1', group: group1)
+ public_project = create(:project, :public, path: 'test1', group: group1)
get v3_api("/groups/#{group1.id}/projects", user1), visibility: 'public'
@@ -421,7 +421,7 @@ describe API::V3::Groups do
expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled])
end
- it "creates a nested group" do
+ it "creates a nested group", :nested_groups do
parent = create(:group)
parent.add_owner(user3)
group = attributes_for(:group, { parent_id: parent.id })
@@ -501,12 +501,12 @@ describe API::V3::Groups do
end
describe "POST /groups/:id/projects/:project_id" do
- let(:project) { create(:empty_project) }
- let(:project_path) { "#{project.namespace.path}%2F#{project.path}" }
+ let(:project) { create(:project) }
+ let(:project_path) { CGI.escape(project.full_path) }
- before(:each) do
- allow_any_instance_of(Projects::TransferService).
- to receive(:execute).and_return(true)
+ before do
+ allow_any_instance_of(Projects::TransferService)
+ .to receive(:execute).and_return(true)
end
context "when authenticated as user" do
diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb
index ef5b10a1615..9eb538c4b09 100644
--- a/spec/requests/api/v3/issues_spec.rb
+++ b/spec/requests/api/v3/issues_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
-describe API::V3::Issues do
- include EmailHelpers
-
+describe API::V3::Issues, :mailer do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:non_member) { create(:user) }
@@ -10,11 +8,11 @@ describe API::V3::Issues do
let(:author) { create(:author) }
let(:assignee) { create(:assignee) }
let(:admin) { create(:user, :admin) }
- let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) }
+ let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
let!(:closed_issue) do
create :closed_issue,
author: user,
- assignee: user,
+ assignees: [user],
project: project,
state: :closed,
milestone: milestone,
@@ -26,14 +24,14 @@ describe API::V3::Issues do
:confidential,
project: project,
author: author,
- assignee: assignee,
+ assignees: [assignee],
created_at: generate(:past_time),
updated_at: 2.hours.ago
end
let!(:issue) do
create :issue,
author: user,
- assignee: user,
+ assignees: [user],
project: project,
milestone: milestone,
created_at: generate(:past_time),
@@ -243,11 +241,11 @@ describe API::V3::Issues do
describe "GET /groups/:id/issues" do
let!(:group) { create(:group) }
- let!(:group_project) { create(:empty_project, :public, creator_id: user.id, namespace: group) }
+ let!(:group_project) { create(:project, :public, creator_id: user.id, namespace: group) }
let!(:group_closed_issue) do
create :closed_issue,
author: user,
- assignee: user,
+ assignees: [user],
project: group_project,
state: :closed,
milestone: group_milestone,
@@ -258,13 +256,13 @@ describe API::V3::Issues do
:confidential,
project: group_project,
author: author,
- assignee: assignee,
+ assignees: [assignee],
updated_at: 2.hours.ago
end
let!(:group_issue) do
create :issue,
author: user,
- assignee: user,
+ assignees: [user],
project: group_project,
milestone: group_milestone,
updated_at: 1.hour.ago
@@ -453,7 +451,7 @@ describe API::V3::Issues do
end
it "returns 404 on private projects for other users" do
- private_project = create(:empty_project, :private)
+ private_project = create(:project, :private)
create(:issue, project: private_project)
get v3_api("/projects/#{private_project.id}/issues", non_member)
@@ -462,7 +460,7 @@ describe API::V3::Issues do
end
it 'returns no issues when user has access to project but not issues' do
- restricted_project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE)
+ restricted_project = create(:project, :public, issues_access_level: ProjectFeature::PRIVATE)
create(:issue, project: restricted_project)
get v3_api("/projects/#{restricted_project.id}/issues", non_member)
@@ -737,13 +735,14 @@ describe API::V3::Issues do
describe "POST /projects/:id/issues" do
it 'creates a new project issue' do
post v3_api("/projects/#{project.id}/issues", user),
- title: 'new issue', labels: 'label, label2'
+ title: 'new issue', labels: 'label, label2', assignee_id: assignee.id
expect(response).to have_http_status(201)
expect(json_response['title']).to eq('new issue')
expect(json_response['description']).to be_nil
expect(json_response['labels']).to eq(%w(label label2))
expect(json_response['confidential']).to be_falsy
+ expect(json_response['assignee']['name']).to eq(assignee.name)
end
it 'creates a new confidential project issue' do
@@ -1113,7 +1112,7 @@ describe API::V3::Issues do
put v3_api("/projects/#{project.id}/issues/#{closed_issue.id}", user), state_event: 'reopen'
expect(response).to have_http_status(200)
- expect(json_response['state']).to eq 'reopened'
+ expect(json_response['state']).to eq 'opened'
end
context 'when an admin or owner makes the request' do
@@ -1140,6 +1139,22 @@ describe API::V3::Issues do
end
end
+ describe 'PUT /projects/:id/issues/:issue_id to update assignee' do
+ it 'updates an issue with no assignee' do
+ put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0
+
+ expect(response).to have_http_status(200)
+ expect(json_response['assignee']).to eq(nil)
+ end
+
+ it 'updates an issue with assignee' do
+ put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: user2.id
+
+ expect(response).to have_http_status(200)
+ expect(json_response['assignee']['name']).to eq(user2.name)
+ end
+ end
+
describe "DELETE /projects/:id/issues/:issue_id" do
it "rejects a non member from deleting an issue" do
delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member)
@@ -1155,7 +1170,7 @@ describe API::V3::Issues do
context "when the user is project owner" do
let(:owner) { create(:user) }
- let(:project) { create(:empty_project, namespace: owner.namespace) }
+ let(:project) { create(:project, namespace: owner.namespace) }
it "deletes the issue if an admin requests it" do
delete v3_api("/projects/#{project.id}/issues/#{issue.id}", owner)
@@ -1175,8 +1190,8 @@ describe API::V3::Issues do
end
describe '/projects/:id/issues/:issue_id/move' do
- let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
- let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) }
+ let!(:target_project) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
+ let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace ) }
it 'moves an issue' do
post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
diff --git a/spec/requests/api/v3/labels_spec.rb b/spec/requests/api/v3/labels_spec.rb
index 62faa1cb129..32f37a08024 100644
--- a/spec/requests/api/v3/labels_spec.rb
+++ b/spec/requests/api/v3/labels_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::V3::Labels do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:label1) { create(:label, title: 'label1', project: project) }
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
diff --git a/spec/requests/api/v3/members_spec.rb b/spec/requests/api/v3/members_spec.rb
index 623f02902b8..bc918a8eb02 100644
--- a/spec/requests/api/v3/members_spec.rb
+++ b/spec/requests/api/v3/members_spec.rb
@@ -7,7 +7,7 @@ describe API::V3::Members do
let(:stranger) { create(:user) }
let(:project) do
- create(:empty_project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
+ create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
project.team << [developer, :developer]
project.team << [master, :master]
project.request_access(access_requester)
diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb
index 6c2950a6e6f..86f38dd4ec1 100644
--- a/spec/requests/api/v3/merge_requests_spec.rb
+++ b/spec/requests/api/v3/merge_requests_spec.rb
@@ -90,7 +90,7 @@ describe API::MergeRequests do
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
end
@@ -99,7 +99,7 @@ describe API::MergeRequests do
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort.reverse)
end
@@ -108,7 +108,7 @@ describe API::MergeRequests do
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['updated_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['updated_at'] }
expect(response_dates).to eq(response_dates.sort.reverse)
end
@@ -117,7 +117,7 @@ describe API::MergeRequests do
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
- response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+ response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
end
end
@@ -312,18 +312,20 @@ describe API::MergeRequests do
context 'forked projects' do
let!(:user2) { create(:user) }
- let!(:fork_project) { create(:empty_project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) }
- let!(:unrelated_project) { create(:empty_project, namespace: create(:user).namespace, creator_id: user2.id) }
+ let!(:fork_project) { create(:project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) }
+ let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) }
- before :each do |each|
- fork_project.team << [user2, :reporter]
+ before do
+ fork_project.add_reporter(user2)
+
+ allow_any_instance_of(MergeRequest).to receive(:write_ref)
end
it "returns merge_request" do
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master",
author: user2, target_project_id: project.id, description: 'Test description for Test merge_request'
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq('Test merge_request')
expect(json_response['description']).to eq('Test description for Test merge_request')
end
@@ -334,26 +336,39 @@ describe API::MergeRequests do
expect(fork_project.forked_from_project).to eq(project)
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
expect(json_response['title']).to eq('Test merge_request')
end
+ it "returns 422 when target project has disabled merge requests" do
+ project.project_feature.update(merge_requests_access_level: 0)
+
+ post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+ title: 'Test',
+ target_branch: "master",
+ source_branch: 'markdown',
+ author: user2,
+ target_project_id: project.id
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
it "returns 400 when source_branch is missing" do
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when target_branch is missing" do
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
it "returns 400 when title is missing" do
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id
- expect(response).to have_http_status(400)
+ expect(response).to have_gitlab_http_status(400)
end
context 'when target_branch is specified' do
@@ -364,7 +379,7 @@ describe API::MergeRequests do
source_branch: 'markdown',
author: user,
target_project_id: fork_project.id
- expect(response).to have_http_status(422)
+ expect(response).to have_gitlab_http_status(422)
end
it 'returns 422 if targeting a different fork' do
@@ -374,14 +389,14 @@ describe API::MergeRequests do
source_branch: 'markdown',
author: user2,
target_project_id: unrelated_project.id
- expect(response).to have_http_status(422)
+ expect(response).to have_gitlab_http_status(422)
end
end
it "returns 201 when target_branch is specified and for the same project" do
post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id
- expect(response).to have_http_status(201)
+ expect(response).to have_gitlab_http_status(201)
end
end
end
@@ -419,8 +434,8 @@ describe API::MergeRequests do
end
it "returns 406 if branch can't be merged" do
- allow_any_instance_of(MergeRequest).
- to receive(:can_be_merged?).and_return(false)
+ allow_any_instance_of(MergeRequest)
+ .to receive(:can_be_merged?).and_return(false)
put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
@@ -622,7 +637,7 @@ describe API::MergeRequests do
end
it 'handles external issues' do
- jira_project = create(:jira_project, :public, name: 'JIR_EXT1')
+ jira_project = create(:jira_project, :public, :repository, name: 'JIR_EXT1')
issue = ExternalIssue.new("#{jira_project.name}-123", jira_project)
merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project)
merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}")
@@ -637,7 +652,7 @@ describe API::MergeRequests do
end
it 'returns 403 if the user has no access to the merge request' do
- project = create(:empty_project, :private)
+ project = create(:project, :private, :repository)
merge_request = create(:merge_request, :simple, source_project: project)
guest = create(:user)
project.team << [guest, :guest]
diff --git a/spec/requests/api/v3/milestones_spec.rb b/spec/requests/api/v3/milestones_spec.rb
index f04efc990a7..feaa87faec7 100644
--- a/spec/requests/api/v3/milestones_spec.rb
+++ b/spec/requests/api/v3/milestones_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::V3::Milestones do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, namespace: user.namespace ) }
+ let!(:project) { create(:project, namespace: user.namespace ) }
let!(:closed_milestone) { create(:closed_milestone, project: project) }
let!(:milestone) { create(:milestone, project: project) }
@@ -194,7 +194,7 @@ describe API::V3::Milestones do
end
describe 'confidential issues' do
- let(:public_project) { create(:empty_project, :public) }
+ let(:public_project) { create(:project, :public) }
let(:milestone) { create(:milestone, project: public_project) }
let(:issue) { create(:issue, project: public_project) }
let(:confidential_issue) { create(:issue, confidential: true, project: public_project) }
diff --git a/spec/requests/api/v3/notes_spec.rb b/spec/requests/api/v3/notes_spec.rb
index 2bae4a60931..56729692eed 100644
--- a/spec/requests/api/v3/notes_spec.rb
+++ b/spec/requests/api/v3/notes_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe API::V3::Notes do
let(:user) { create(:user) }
- let!(:project) { create(:empty_project, :public, namespace: user.namespace) }
+ let!(:project) { create(:project, :public, namespace: user.namespace) }
let!(:issue) { create(:issue, project: project, author: user) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
let!(:snippet) { create(:project_snippet, project: project, author: user) }
@@ -13,12 +13,12 @@ describe API::V3::Notes do
# For testing the cross-reference of a private issue in a public issue
let(:private_user) { create(:user) }
let(:private_project) do
- create(:empty_project, namespace: private_user.namespace).
- tap { |p| p.team << [private_user, :master] }
+ create(:project, namespace: private_user.namespace)
+ .tap { |p| p.team << [private_user, :master] }
end
let(:private_issue) { create(:issue, project: private_project) }
- let(:ext_proj) { create(:empty_project, :public) }
+ let(:ext_proj) { create(:project, :public) }
let(:ext_issue) { create(:issue, project: ext_proj) }
let!(:cross_reference_note) do
@@ -268,7 +268,7 @@ describe API::V3::Notes do
context 'when user does not have access to read the noteable' do
it 'responds with 404' do
- project = create(:empty_project, :private) { |p| p.add_guest(user) }
+ project = create(:project, :private) { |p| p.add_guest(user) }
issue = create(:issue, :confidential, project: project)
post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user),
@@ -279,7 +279,7 @@ describe API::V3::Notes do
end
context 'when user does not have access to create noteable' do
- let(:private_issue) { create(:issue, project: create(:empty_project, :private)) }
+ let(:private_issue) { create(:issue, project: create(:project, :private)) }
##
# We are posting to project user has access to, but we use issue id
diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb
index a3a4c77d09d..b0eddbb5dd2 100644
--- a/spec/requests/api/v3/project_hooks_spec.rb
+++ b/spec/requests/api/v3/project_hooks_spec.rb
@@ -58,7 +58,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
expect(json_response['note_events']).to eq(hook.note_events)
- expect(json_response['build_events']).to eq(hook.build_events)
+ expect(json_response['build_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
@@ -87,7 +87,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
it "adds hook to project" do
expect do
post v3_api("/projects/#{project.id}/hooks", user),
- url: "http://example.com", issues_events: true, wiki_page_events: true
+ url: "http://example.com", issues_events: true, wiki_page_events: true, build_events: true
end.to change {project.hooks.count}.by(1)
expect(response).to have_http_status(201)
@@ -97,7 +97,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['merge_requests_events']).to eq(false)
expect(json_response['tag_push_events']).to eq(false)
expect(json_response['note_events']).to eq(false)
- expect(json_response['build_events']).to eq(false)
+ expect(json_response['build_events']).to eq(true)
expect(json_response['pipeline_events']).to eq(false)
expect(json_response['wiki_page_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(true)
@@ -135,7 +135,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
describe "PUT /projects/:id/hooks/:hook_id" do
it "updates an existing project hook" do
put v3_api("/projects/#{project.id}/hooks/#{hook.id}", user),
- url: 'http://example.org', push_events: false
+ url: 'http://example.org', push_events: false, build_events: true
expect(response).to have_http_status(200)
expect(json_response['url']).to eq('http://example.org')
expect(json_response['issues_events']).to eq(hook.issues_events)
@@ -143,7 +143,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
expect(json_response['note_events']).to eq(hook.note_events)
- expect(json_response['build_events']).to eq(hook.build_events)
+ expect(json_response['build_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
diff --git a/spec/requests/api/v3/project_snippets_spec.rb b/spec/requests/api/v3/project_snippets_spec.rb
index 365e7365fda..3963924a066 100644
--- a/spec/requests/api/v3/project_snippets_spec.rb
+++ b/spec/requests/api/v3/project_snippets_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
describe API::ProjectSnippets do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:admin) { create(:admin) }
@@ -30,7 +30,7 @@ describe API::ProjectSnippets do
expect(response).to have_http_status(200)
expect(json_response.size).to eq(3)
- expect(json_response.map{ |snippet| snippet['id']} ).to include(public_snippet.id, internal_snippet.id, private_snippet.id)
+ expect(json_response.map { |snippet| snippet['id']} ).to include(public_snippet.id, internal_snippet.id, private_snippet.id)
expect(json_response.last).to have_key('web_url')
end
@@ -85,23 +85,23 @@ describe API::ProjectSnippets do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(project, visibility_level: Snippet::PRIVATE) }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(project, visibility_level: Snippet::PRIVATE) }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the shippet' do
- expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }.
- not_to change { Snippet.count }
+ expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }
+ .not_to change { Snippet.count }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
end
end
@@ -147,8 +147,8 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'creates the snippet' do
- expect { update_snippet(title: 'Foo') }.
- to change { snippet.reload.title }.to('Foo')
+ expect { update_snippet(title: 'Foo') }
+ .to change { snippet.reload.title }.to('Foo')
end
end
@@ -156,13 +156,13 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo') }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo') }
+ .not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo') }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo') }
+ .to change { SpamLog.count }.by(1)
end
end
@@ -170,16 +170,16 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- not_to change { snippet.reload.title }
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .not_to change { snippet.reload.title }
expect(response).to have_http_status(400)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
end
end
diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb
index e15b90d7a9e..a514166274a 100644
--- a/spec/requests/api/v3/projects_spec.rb
+++ b/spec/requests/api/v3/projects_spec.rb
@@ -7,11 +7,10 @@ describe API::V3::Projects do
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
- let(:project2) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
+ let(:project2) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace) }
let(:snippet) { create(:project_snippet, :public, author: user, project: project, title: 'example') }
- let(:project_member) { create(:project_member, :master, user: user, project: project) }
- let(:project_member2) { create(:project_member, :developer, user: user3, project: project) }
+ let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
let(:user4) { create(:user) }
let(:project3) do
create(:project,
@@ -25,14 +24,14 @@ describe API::V3::Projects do
issues_enabled: false, wiki_enabled: false,
snippets_enabled: false)
end
- let(:project_member3) do
+ let(:project_member2) do
create(:project_member,
user: user4,
project: project3,
access_level: ProjectMember::MASTER)
end
let(:project4) do
- create(:empty_project,
+ create(:project,
name: 'third_project',
path: 'third_project',
creator_id: user4.id,
@@ -83,7 +82,14 @@ describe API::V3::Projects do
context 'GET /projects?simple=true' do
it 'returns a simplified version of all the projects' do
- expected_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace)
+ expected_keys = %w(
+ id description default_branch tag_list
+ ssh_url_to_repo http_url_to_repo web_url
+ name name_with_namespace
+ path path_with_namespace
+ star_count forks_count
+ created_at last_activity_at
+ )
get v3_api('/projects?simple=true', user)
@@ -125,6 +131,36 @@ describe API::V3::Projects do
end
end
+ context 'and using archived' do
+ let!(:archived_project) { create(:project, creator_id: user.id, namespace: user.namespace, archived: true) }
+
+ it 'returns archived project' do
+ get v3_api('/projects?archived=true', user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(archived_project.id)
+ end
+
+ it 'returns non-archived project' do
+ get v3_api('/projects?archived=false', user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(project.id)
+ end
+
+ it 'returns all project' do
+ get v3_api('/projects', user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ end
+ end
+
context 'and using sorting' do
before do
project2
@@ -166,7 +202,7 @@ describe API::V3::Projects do
expect(json_response).to satisfy do |response|
response.one? do |entry|
- entry.has_key?('permissions') &&
+ entry.key?('permissions') &&
entry['name'] == project.name &&
entry['owner']['username'] == user.username
end
@@ -227,7 +263,7 @@ describe API::V3::Projects do
storage_size: 702,
repository_size: 123,
lfs_objects_size: 234,
- build_artifacts_size: 345,
+ build_artifacts_size: 345
}
project4.statistics.update!(attributes)
@@ -252,7 +288,7 @@ describe API::V3::Projects do
end
end
- let!(:public_project) { create(:empty_project, :public) }
+ let!(:public_project) { create(:project, :public) }
before do
project
project2
@@ -283,10 +319,10 @@ describe API::V3::Projects do
end
describe 'GET /projects/starred' do
- let(:public_project) { create(:empty_project, :public) }
+ let(:public_project) { create(:project, :public) }
before do
- project_member2
+ project_member
user3.update_attributes(starred_projects: [project, project2, project3, public_project])
end
@@ -302,15 +338,15 @@ describe API::V3::Projects do
context 'maximum number of projects reached' do
it 'does not create new project and respond with 403' do
allow_any_instance_of(User).to receive(:projects_limit_left).and_return(0)
- expect { post v3_api('/projects', user2), name: 'foo' }.
- to change {Project.count}.by(0)
+ expect { post v3_api('/projects', user2), name: 'foo' }
+ .to change {Project.count}.by(0)
expect(response).to have_http_status(403)
end
end
it 'creates new project without path but with name and returns 201' do
- expect { post v3_api('/projects', user), name: 'Foo Project' }.
- to change { Project.count }.by(1)
+ expect { post v3_api('/projects', user), name: 'Foo Project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
@@ -320,8 +356,8 @@ describe API::V3::Projects do
end
it 'creates new project without name but with path and returns 201' do
- expect { post v3_api('/projects', user), path: 'foo_project' }.
- to change { Project.count }.by(1)
+ expect { post v3_api('/projects', user), path: 'foo_project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
@@ -331,8 +367,8 @@ describe API::V3::Projects do
end
it 'creates new project name and path and returns 201' do
- expect { post v3_api('/projects', user), path: 'foo-Project', name: 'Foo Project' }.
- to change { Project.count }.by(1)
+ expect { post v3_api('/projects', user), path: 'foo-Project', name: 'Foo Project' }
+ .to change { Project.count }.by(1)
expect(response).to have_http_status(201)
project = Project.first
@@ -490,8 +526,8 @@ describe API::V3::Projects do
end
it 'responds with 400 on failure and not project' do
- expect { post v3_api("/projects/user/#{user.id}", admin) }.
- not_to change { Project.count }
+ expect { post v3_api("/projects/user/#{user.id}", admin) }
+ .not_to change { Project.count }
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('name is missing')
@@ -608,13 +644,14 @@ describe API::V3::Projects do
describe 'GET /projects/:id' do
context 'when unauthenticated' do
it 'returns the public projects' do
- public_project = create(:empty_project, :public)
+ public_project = create(:project, :public)
get v3_api("/projects/#{public_project.id}")
expect(response).to have_http_status(200)
expect(json_response['id']).to eq(public_project.id)
expect(json_response['description']).to eq(public_project.description)
+ expect(json_response['default_branch']).to eq(public_project.default_branch)
expect(json_response.keys).not_to include('permissions')
end
end
@@ -622,7 +659,6 @@ describe API::V3::Projects do
context 'when authenticated' do
before do
project
- project_member
end
it 'returns a project by id' do
@@ -690,9 +726,9 @@ describe API::V3::Projects do
it 'handles users with dots' do
dot_user = create(:user, username: 'dot.user')
- project = create(:empty_project, creator_id: dot_user.id, namespace: dot_user.namespace)
+ project = create(:project, creator_id: dot_user.id, namespace: dot_user.namespace)
- get v3_api("/projects/#{dot_user.namespace.name}%2F#{project.path}", dot_user)
+ get v3_api("/projects/#{CGI.escape(project.full_path)}", dot_user)
expect(response).to have_http_status(200)
expect(json_response['name']).to eq(project.name)
end
@@ -707,6 +743,7 @@ describe API::V3::Projects do
'path' => user.namespace.path,
'kind' => user.namespace.kind,
'full_path' => user.namespace.full_path,
+ 'parent_id' => nil
})
end
@@ -718,8 +755,8 @@ describe API::V3::Projects do
get v3_api("/projects", user)
expect(response).to have_http_status(200)
- expect(json_response.first['permissions']['project_access']['access_level']).
- to eq(Gitlab::Access::MASTER)
+ expect(json_response.first['permissions']['project_access']['access_level'])
+ .to eq(Gitlab::Access::MASTER)
expect(json_response.first['permissions']['group_access']).to be_nil
end
end
@@ -730,14 +767,14 @@ describe API::V3::Projects do
get v3_api("/projects/#{project.id}", user)
expect(response).to have_http_status(200)
- expect(json_response['permissions']['project_access']['access_level']).
- to eq(Gitlab::Access::MASTER)
+ expect(json_response['permissions']['project_access']['access_level'])
+ .to eq(Gitlab::Access::MASTER)
expect(json_response['permissions']['group_access']).to be_nil
end
end
context 'group project' do
- let(:project2) { create(:empty_project, group: create(:group)) }
+ let(:project2) { create(:project, group: create(:group)) }
before { project2.group.add_owner(user) }
@@ -746,8 +783,8 @@ describe API::V3::Projects do
expect(response).to have_http_status(200)
expect(json_response['permissions']['project_access']).to be_nil
- expect(json_response['permissions']['group_access']['access_level']).
- to eq(Gitlab::Access::OWNER)
+ expect(json_response['permissions']['group_access']['access_level'])
+ .to eq(Gitlab::Access::OWNER)
end
end
end
@@ -782,7 +819,7 @@ describe API::V3::Projects do
context 'when unauthenticated' do
it_behaves_like 'project events response' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:current_user) { nil }
end
end
@@ -814,8 +851,7 @@ describe API::V3::Projects do
describe 'GET /projects/:id/users' do
shared_examples_for 'project users response' do
it 'returns the project users' do
- member = create(:user)
- create(:project_member, :developer, user: member, project: project)
+ member = project.owner
get v3_api("/projects/#{project.id}/users", current_user)
@@ -833,7 +869,7 @@ describe API::V3::Projects do
context 'when unauthenticated' do
it_behaves_like 'project users response' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:current_user) { nil }
end
end
@@ -947,11 +983,11 @@ describe API::V3::Projects do
end
describe 'fork management' do
- let(:project_fork_target) { create(:empty_project) }
- let(:project_fork_source) { create(:empty_project, :public) }
+ let(:project_fork_target) { create(:project) }
+ let(:project_fork_source) { create(:project, :public) }
describe 'POST /projects/:id/fork/:forked_from_id' do
- let(:new_project_fork_source) { create(:empty_project, :public) }
+ let(:new_project_fork_source) { create(:project, :public) }
it "is not available for non admin users" do
post v3_api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user)
@@ -968,6 +1004,14 @@ describe API::V3::Projects do
expect(project_fork_target.forked?).to be_truthy
end
+ it 'refreshes the forks count cachce' do
+ expect(project_fork_source.forks_count).to be_zero
+
+ post v3_api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin)
+
+ expect(project_fork_source.forks_count).to eq(1)
+ end
+
it 'fails if forked_from project which does not exist' do
post v3_api("/projects/#{project_fork_target.id}/fork/9999", admin)
expect(response).to have_http_status(404)
@@ -992,7 +1036,7 @@ describe API::V3::Projects do
end
context 'when users belong to project group' do
- let(:project_fork_target) { create(:empty_project, group: create(:group)) }
+ let(:project_fork_target) { create(:project, group: create(:group)) }
before do
project_fork_target.group.add_owner user
@@ -1112,16 +1156,16 @@ describe API::V3::Projects do
describe 'GET /projects/search/:query' do
let!(:query) { 'query'}
- let!(:search) { create(:empty_project, name: query, creator_id: user.id, namespace: user.namespace) }
- let!(:pre) { create(:empty_project, name: "pre_#{query}", creator_id: user.id, namespace: user.namespace) }
- let!(:post) { create(:empty_project, name: "#{query}_post", creator_id: user.id, namespace: user.namespace) }
- let!(:pre_post) { create(:empty_project, name: "pre_#{query}_post", creator_id: user.id, namespace: user.namespace) }
- let!(:unfound) { create(:empty_project, name: 'unfound', creator_id: user.id, namespace: user.namespace) }
- let!(:internal) { create(:empty_project, :internal, name: "internal #{query}") }
- let!(:unfound_internal) { create(:empty_project, :internal, name: 'unfound internal') }
- let!(:public) { create(:empty_project, :public, name: "public #{query}") }
- let!(:unfound_public) { create(:empty_project, :public, name: 'unfound public') }
- let!(:one_dot_two) { create(:empty_project, :public, name: "one.dot.two") }
+ let!(:search) { create(:project, name: query, creator_id: user.id, namespace: user.namespace) }
+ let!(:pre) { create(:project, name: "pre_#{query}", creator_id: user.id, namespace: user.namespace) }
+ let!(:post) { create(:project, name: "#{query}_post", creator_id: user.id, namespace: user.namespace) }
+ let!(:pre_post) { create(:project, name: "pre_#{query}_post", creator_id: user.id, namespace: user.namespace) }
+ let!(:unfound) { create(:project, name: 'unfound', creator_id: user.id, namespace: user.namespace) }
+ let!(:internal) { create(:project, :internal, name: "internal #{query}") }
+ let!(:unfound_internal) { create(:project, :internal, name: 'unfound internal') }
+ let!(:public) { create(:project, :public, name: "public #{query}") }
+ let!(:unfound_public) { create(:project, :public, name: 'unfound public') }
+ let!(:one_dot_two) { create(:project, :public, name: "one.dot.two") }
shared_examples_for 'project search response' do |args = {}|
it 'returns project search responses' do
@@ -1163,8 +1207,8 @@ describe API::V3::Projects do
before { user4 }
before { project3 }
before { project4 }
- before { project_member3 }
before { project_member2 }
+ before { project_member }
context 'when unauthenticated' do
it 'returns authentication error' do
diff --git a/spec/requests/api/v3/runners_spec.rb b/spec/requests/api/v3/runners_spec.rb
index dbda2cf34c3..a31eb3f1d43 100644
--- a/spec/requests/api/v3/runners_spec.rb
+++ b/spec/requests/api/v3/runners_spec.rb
@@ -5,8 +5,8 @@ describe API::V3::Runners do
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id) }
- let(:project2) { create(:empty_project, creator_id: user.id) }
+ let(:project) { create(:project, creator_id: user.id) }
+ let(:project2) { create(:project, creator_id: user.id) }
let!(:shared_runner) { create(:ci_runner, :shared) }
let!(:unused_specific_runner) { create(:ci_runner) }
@@ -38,7 +38,7 @@ describe API::V3::Runners do
delete v3_api("/runners/#{shared_runner.id}", admin)
expect(response).to have_http_status(200)
- end.to change{ Ci::Runner.shared.count }.by(-1)
+ end.to change { Ci::Runner.shared.count }.by(-1)
end
end
@@ -48,7 +48,7 @@ describe API::V3::Runners do
delete v3_api("/runners/#{unused_specific_runner.id}", admin)
expect(response).to have_http_status(200)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
it 'deletes used runner' do
@@ -56,7 +56,7 @@ describe API::V3::Runners do
delete v3_api("/runners/#{specific_runner.id}", admin)
expect(response).to have_http_status(200)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
end
@@ -91,7 +91,7 @@ describe API::V3::Runners do
delete v3_api("/runners/#{specific_runner.id}", user)
expect(response).to have_http_status(200)
- end.to change{ Ci::Runner.specific.count }.by(-1)
+ end.to change { Ci::Runner.specific.count }.by(-1)
end
end
end
@@ -113,7 +113,7 @@ describe API::V3::Runners do
delete v3_api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user)
expect(response).to have_http_status(200)
- end.to change{ project.runners.count }.by(-1)
+ end.to change { project.runners.count }.by(-1)
end
end
@@ -121,7 +121,7 @@ describe API::V3::Runners do
it "does not disable project's runner" do
expect do
delete v3_api("/projects/#{project.id}/runners/#{specific_runner.id}", user)
- end.to change{ project.runners.count }.by(0)
+ end.to change { project.runners.count }.by(0)
expect(response).to have_http_status(403)
end
end
diff --git a/spec/requests/api/v3/services_spec.rb b/spec/requests/api/v3/services_spec.rb
index 3ba62de822a..f0fa48e22df 100644
--- a/spec/requests/api/v3/services_spec.rb
+++ b/spec/requests/api/v3/services_spec.rb
@@ -2,7 +2,7 @@ require "spec_helper"
describe API::V3::Services do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
+ let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
available_services = Service.available_services_names
available_services.delete('prometheus')
diff --git a/spec/requests/api/v3/settings_spec.rb b/spec/requests/api/v3/settings_spec.rb
index 41d039b7da0..291f6dcc2aa 100644
--- a/spec/requests/api/v3/settings_spec.rb
+++ b/spec/requests/api/v3/settings_spec.rb
@@ -10,7 +10,7 @@ describe API::V3::Settings, 'Settings' do
expect(response).to have_http_status(200)
expect(json_response).to be_an Hash
expect(json_response['default_projects_limit']).to eq(42)
- expect(json_response['signin_enabled']).to be_truthy
+ expect(json_response['password_authentication_enabled']).to be_truthy
expect(json_response['repository_storage']).to eq('default')
expect(json_response['koding_enabled']).to be_falsey
expect(json_response['koding_url']).to be_nil
@@ -28,11 +28,11 @@ describe API::V3::Settings, 'Settings' do
it "updates application settings" do
put v3_api("/application/settings", admin),
- default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com',
+ default_projects_limit: 3, password_authentication_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com',
plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com'
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
- expect(json_response['signin_enabled']).to be_falsey
+ expect(json_response['password_authentication_enabled']).to be_falsey
expect(json_response['repository_storage']).to eq('custom')
expect(json_response['repository_storages']).to eq(['custom'])
expect(json_response['koding_enabled']).to be_truthy
diff --git a/spec/requests/api/v3/snippets_spec.rb b/spec/requests/api/v3/snippets_spec.rb
index 4f02b7b1a54..9ead3cad8bb 100644
--- a/spec/requests/api/v3/snippets_spec.rb
+++ b/spec/requests/api/v3/snippets_spec.rb
@@ -45,10 +45,10 @@ describe API::V3::Snippets do
expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly(
public_snippet.id,
public_snippet_other.id)
- expect(json_response.map{ |snippet| snippet['web_url']} ).to include(
+ expect(json_response.map { |snippet| snippet['web_url']} ).to include(
"http://localhost/snippets/#{public_snippet.id}",
"http://localhost/snippets/#{public_snippet_other.id}")
- expect(json_response.map{ |snippet| snippet['raw_url']} ).to include(
+ expect(json_response.map { |snippet| snippet['raw_url']} ).to include(
"http://localhost/snippets/#{public_snippet.id}/raw",
"http://localhost/snippets/#{public_snippet_other.id}/raw")
end
@@ -112,21 +112,21 @@ describe API::V3::Snippets do
context 'when the snippet is private' do
it 'creates the snippet' do
- expect { create_snippet(visibility_level: Snippet::PRIVATE) }.
- to change { Snippet.count }.by(1)
+ expect { create_snippet(visibility_level: Snippet::PRIVATE) }
+ .to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
it 'rejects the shippet' do
- expect { create_snippet(visibility_level: Snippet::PUBLIC) }.
- not_to change { Snippet.count }
+ expect { create_snippet(visibility_level: Snippet::PUBLIC) }
+ .not_to change { Snippet.count }
expect(response).to have_http_status(400)
end
it 'creates a spam log' do
- expect { create_snippet(visibility_level: Snippet::PUBLIC) }.
- to change { SpamLog.count }.by(1)
+ expect { create_snippet(visibility_level: Snippet::PUBLIC) }
+ .to change { SpamLog.count }.by(1)
end
end
end
diff --git a/spec/requests/api/v3/system_hooks_spec.rb b/spec/requests/api/v3/system_hooks_spec.rb
index 72c7d14b8ba..ae427541abb 100644
--- a/spec/requests/api/v3/system_hooks_spec.rb
+++ b/spec/requests/api/v3/system_hooks_spec.rb
@@ -31,8 +31,9 @@ describe API::V3::SystemHooks do
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first['url']).to eq(hook.url)
- expect(json_response.first['push_events']).to be true
+ expect(json_response.first['push_events']).to be false
expect(json_response.first['tag_push_events']).to be false
+ expect(json_response.first['repository_update_events']).to be true
end
end
end
diff --git a/spec/requests/api/v3/todos_spec.rb b/spec/requests/api/v3/todos_spec.rb
index 9c2c4d64257..8f5c3fbf8dd 100644
--- a/spec/requests/api/v3/todos_spec.rb
+++ b/spec/requests/api/v3/todos_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
describe API::V3::Todos do
- let(:project_1) { create(:empty_project) }
- let(:project_2) { create(:empty_project) }
+ let(:project_1) { create(:project) }
+ let(:project_2) { create(:project) }
let(:author_1) { create(:user) }
let(:author_2) { create(:user) }
let(:john_doe) { create(:user, username: 'john_doe') }
diff --git a/spec/requests/api/v3/triggers_spec.rb b/spec/requests/api/v3/triggers_spec.rb
index d3de6bf13bc..075de2c0cba 100644
--- a/spec/requests/api/v3/triggers_spec.rb
+++ b/spec/requests/api/v3/triggers_spec.rb
@@ -52,7 +52,8 @@ describe API::V3::Triggers do
it 'returns bad request with no builds created if there\'s no commit for that ref' do
post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch')
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('No builds created')
+ expect(json_response['message']['base'])
+ .to contain_exactly('Reference not found')
end
context 'Validates variables' do
@@ -170,7 +171,7 @@ describe API::V3::Triggers do
it 'creates trigger' do
expect do
post v3_api("/projects/#{project.id}/triggers", user)
- end.to change{project.triggers.count}.by(1)
+ end.to change {project.triggers.count}.by(1)
expect(response).to have_http_status(201)
expect(json_response).to be_a(Hash)
@@ -201,7 +202,7 @@ describe API::V3::Triggers do
delete v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user)
expect(response).to have_http_status(200)
- end.to change{project.triggers.count}.by(-1)
+ end.to change {project.triggers.count}.by(-1)
end
it 'responds with 404 Not Found if requesting non-existing trigger' do
diff --git a/spec/requests/api/v3/users_spec.rb b/spec/requests/api/v3/users_spec.rb
index e9c57f7c6c3..227b8d1b0c1 100644
--- a/spec/requests/api/v3/users_spec.rb
+++ b/spec/requests/api/v3/users_spec.rb
@@ -7,6 +7,38 @@ describe API::V3::Users do
let(:email) { create(:email, user: user) }
let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') }
+ describe 'GET /users' do
+ context 'when authenticated' do
+ it 'returns an array of users' do
+ get v3_api('/users', user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ username = user.username
+ expect(json_response.detect do |user|
+ user['username'] == username
+ end['username']).to eq(username)
+ end
+ end
+
+ context 'when authenticated as user' do
+ it 'does not reveal the `is_admin` flag of the user' do
+ get v3_api('/users', user)
+
+ expect(json_response.first.keys).not_to include 'is_admin'
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'reveals the `is_admin` flag of the user' do
+ get v3_api('/users', admin)
+
+ expect(json_response.first.keys).to include 'is_admin'
+ end
+ end
+ end
+
describe 'GET /user/:id/keys' do
before { admin }
@@ -35,6 +67,19 @@ describe API::V3::Users do
expect(json_response.first['title']).to eq(key.title)
end
end
+
+ context "scopes" do
+ let(:user) { admin }
+ let(:path) { "/users/#{user.id}/keys" }
+ let(:api_call) { method(:v3_api) }
+
+ before do
+ user.keys << key
+ user.save
+ end
+
+ include_examples 'allows the "read_user" scope'
+ end
end
describe 'GET /user/:id/emails' do
@@ -187,7 +232,7 @@ describe API::V3::Users do
describe 'GET /users/:id/events' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) }
before do
@@ -207,6 +252,31 @@ describe API::V3::Users do
end
context "as a user than can see the event's project" do
+ context 'when the list of events includes push events' do
+ let(:event) { create(:push_event, author: user, project: project) }
+ let!(:payload) { create(:push_event_payload, event: event) }
+ let(:payload_hash) { json_response[0]['push_data'] }
+
+ before do
+ get api("/users/#{user.id}/events?action=pushed", user)
+ end
+
+ it 'responds with HTTP 200 OK' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'includes the push payload as a Hash' do
+ expect(payload_hash).to be_an_instance_of(Hash)
+ end
+
+ it 'includes the push payload details' do
+ expect(payload_hash['commit_count']).to eq(payload.commit_count)
+ expect(payload_hash['action']).to eq(payload.action)
+ expect(payload_hash['ref_type']).to eq(payload.ref_type)
+ expect(payload_hash['commit_to']).to eq(payload.commit_to)
+ end
+ end
+
context 'joined event' do
it 'returns the "joined" event' do
get v3_api("/users/#{user.id}/events", user)
@@ -231,7 +301,7 @@ describe API::V3::Users do
end
context 'when there are multiple events from different projects' do
- let(:second_note) { create(:note_on_issue, project: create(:empty_project)) }
+ let(:second_note) { create(:note_on_issue, project: create(:project)) }
let(:third_note) { create(:note_on_issue, project: project) }
before do
@@ -255,7 +325,7 @@ describe API::V3::Users do
end
it 'returns a 404 error if not found' do
- get v3_api('/users/42/events', user)
+ get v3_api('/users/420/events', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
@@ -280,5 +350,13 @@ describe API::V3::Users do
expect(json_response['is_admin']).to be_nil
end
+
+ context "scopes" do
+ let(:user) { admin }
+ let(:path) { '/users' }
+ let(:api_call) { method(:v3_api) }
+
+ include_examples 'does not allow the "read_user" scope'
+ end
end
end
diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb
index 63d6d3001ac..48592e12822 100644
--- a/spec/requests/api/variables_spec.rb
+++ b/spec/requests/api/variables_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::Variables do
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let!(:project) { create(:empty_project, creator_id: user.id) }
+ let!(:project) { create(:project, creator_id: user.id) }
let!(:master) { create(:project_member, :master, user: user, project: project) }
let!(:developer) { create(:project_member, :developer, user: user2, project: project) }
let!(:variable) { create(:ci_variable, project: project) }
@@ -42,6 +42,7 @@ describe API::Variables do
expect(response).to have_http_status(200)
expect(json_response['value']).to eq(variable.value)
+ expect(json_response['protected']).to eq(variable.protected?)
end
it 'responds with 404 Not Found if requesting non-existing variable' do
@@ -72,18 +73,30 @@ describe API::Variables do
context 'authorized user with proper permissions' do
it 'creates variable' do
expect do
+ post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2', protected: true
+ end.to change {project.variables.count}.by(1)
+
+ expect(response).to have_http_status(201)
+ expect(json_response['key']).to eq('TEST_VARIABLE_2')
+ expect(json_response['value']).to eq('VALUE_2')
+ expect(json_response['protected']).to be_truthy
+ end
+
+ it 'creates variable with optional attributes' do
+ expect do
post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2'
- end.to change{project.variables.count}.by(1)
+ end.to change {project.variables.count}.by(1)
expect(response).to have_http_status(201)
expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('VALUE_2')
+ expect(json_response['protected']).to be_falsey
end
it 'does not allow to duplicate variable key' do
expect do
post api("/projects/#{project.id}/variables", user), key: variable.key, value: 'VALUE_2'
- end.to change{project.variables.count}.by(0)
+ end.to change {project.variables.count}.by(0)
expect(response).to have_http_status(400)
end
@@ -112,13 +125,14 @@ describe API::Variables do
initial_variable = project.variables.first
value_before = initial_variable.value
- put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP'
+ put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true
updated_variable = project.variables.first
expect(response).to have_http_status(200)
expect(value_before).to eq(variable.value)
expect(updated_variable.value).to eq('VALUE_1_UP')
+ expect(updated_variable).to be_protected
end
it 'responds with 404 Not Found if requesting non-existing variable' do
@@ -152,7 +166,7 @@ describe API::Variables do
delete api("/projects/#{project.id}/variables/#{variable.key}", user)
expect(response).to have_http_status(204)
- end.to change{project.variables.count}.by(-1)
+ end.to change {project.variables.count}.by(-1)
end
it 'responds with 404 Not Found if requesting non-existing variable' do
diff --git a/spec/requests/api/version_spec.rb b/spec/requests/api/version_spec.rb
index 8870d48bbc9..7bbf34422b8 100644
--- a/spec/requests/api/version_spec.rb
+++ b/spec/requests/api/version_spec.rb
@@ -6,7 +6,7 @@ describe API::Version do
it 'returns authentication error' do
get api('/version')
- expect(response).to have_http_status(401)
+ expect(response).to have_gitlab_http_status(401)
end
end
@@ -16,7 +16,7 @@ describe API::Version do
it 'returns the version information' do
get api('/version', user)
- expect(response).to have_http_status(200)
+ expect(response).to have_gitlab_http_status(200)
expect(json_response['version']).to eq(Gitlab::VERSION)
expect(json_response['revision']).to eq(Gitlab::REVISION)
end
diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb
index 108f73bb965..7ccba4ba3ec 100644
--- a/spec/requests/ci/api/builds_spec.rb
+++ b/spec/requests/ci/api/builds_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Ci::API::Builds do
let(:runner) { FactoryGirl.create(:ci_runner, tag_list: %w(mysql ruby)) }
- let(:project) { FactoryGirl.create(:empty_project, shared_runners_enabled: false) }
+ let(:project) { FactoryGirl.create(:project, shared_runners_enabled: false) }
let(:last_update) { nil }
describe "Builds API for runners" do
@@ -69,6 +69,72 @@ describe Ci::API::Builds do
end
end
+ context 'when an old image syntax is used' do
+ before do
+ build.update!(options: { image: 'codeclimate' })
+ end
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+ expect(json_response["options"]).to eq({ "image" => "codeclimate" })
+ end
+ end
+
+ context 'when a new image syntax is used' do
+ before do
+ build.update!(options: { image: { name: 'codeclimate' } })
+ end
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+ expect(json_response["options"]).to eq({ "image" => "codeclimate" })
+ end
+ end
+
+ context 'when an old service syntax is used' do
+ before do
+ build.update!(options: { services: ['mysql'] })
+ end
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+ expect(json_response["options"]).to eq({ "services" => ["mysql"] })
+ end
+ end
+
+ context 'when a new service syntax is used' do
+ before do
+ build.update!(options: { services: [name: 'mysql'] })
+ end
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+ expect(json_response["options"]).to eq({ "services" => ["mysql"] })
+ end
+ end
+
+ context 'when no image or service is defined' do
+ before do
+ build.update!(options: {})
+ end
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+
+ expect(json_response["options"]).to be_empty
+ end
+ end
+
context 'when there is a pending build' do
it 'starts a build' do
register_builds info: { platform: :darwin }
@@ -91,8 +157,8 @@ describe Ci::API::Builds do
context 'when concurrently updating build' do
before do
- expect_any_instance_of(Ci::Build).to receive(:run!).
- and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
+ expect_any_instance_of(Ci::Build).to receive(:run!)
+ .and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
end
it 'returns a conflict' do
@@ -137,6 +203,18 @@ describe Ci::API::Builds do
end
end
end
+
+ context 'when docker configuration options are used' do
+ let!(:build) { create(:ci_build, :extended_options, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+
+ it 'starts a build' do
+ register_builds info: { platform: :darwin }
+
+ expect(response).to have_http_status(201)
+ expect(json_response['options']['image']).to eq('ruby:2.1')
+ expect(json_response['options']['services']).to eq(['postgres', 'docker:dind'])
+ end
+ end
end
context 'when builds are finished' do
@@ -185,7 +263,7 @@ describe Ci::API::Builds do
{ "key" => "CI_PIPELINE_TRIGGERED", "value" => "true", "public" => true },
{ "key" => "DB_NAME", "value" => "postgres", "public" => true },
{ "key" => "SECRET_KEY", "value" => "secret_value", "public" => false },
- { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false },
+ { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false }
)
end
end
@@ -229,7 +307,9 @@ describe Ci::API::Builds do
end
context 'when runner is allowed to pick untagged builds' do
- before { runner.update_column(:run_untagged, true) }
+ before do
+ runner.update_column(:run_untagged, true)
+ end
it 'picks build' do
register_builds
@@ -341,7 +421,7 @@ describe Ci::API::Builds do
end
context 'when build has been updated recently' do
- it { expect{ patch_the_trace }.not_to change { build.updated_at }}
+ it { expect { patch_the_trace }.not_to change { build.updated_at }}
it 'changes the build trace' do
patch_the_trace
@@ -350,7 +430,7 @@ describe Ci::API::Builds do
end
context 'when Runner makes a force-patch' do
- it { expect{ force_patch_the_trace }.not_to change { build.updated_at }}
+ it { expect { force_patch_the_trace }.not_to change { build.updated_at }}
it "doesn't change the build.trace" do
force_patch_the_trace
@@ -455,7 +535,9 @@ describe Ci::API::Builds do
let(:token) { build.token }
let(:headers_with_token) { headers.merge(Ci::API::Helpers::BUILD_TOKEN_HEADER => token) }
- before { build.run! }
+ before do
+ build.run!
+ end
describe "POST /builds/:id/artifacts/authorize" do
context "authorizes posting artifact to running build" do
@@ -511,7 +593,9 @@ describe Ci::API::Builds do
end
context 'authorization token is invalid' do
- before { post authorize_url, { token: 'invalid', filesize: 100 } }
+ before do
+ post authorize_url, { token: 'invalid', filesize: 100 }
+ end
it 'responds with forbidden' do
expect(response).to have_http_status(403)
@@ -652,8 +736,8 @@ describe Ci::API::Builds do
build.reload
expect(response).to have_http_status(201)
expect(json_response['artifacts_expire_at']).not_to be_empty
- expect(build.artifacts_expire_at).
- to be_within(5.minutes).of(7.days.from_now)
+ expect(build.artifacts_expire_at)
+ .to be_within(5.minutes).of(7.days.from_now)
end
end
diff --git a/spec/requests/ci/api/runners_spec.rb b/spec/requests/ci/api/runners_spec.rb
index 0b9733221d8..75059dd20a0 100644
--- a/spec/requests/ci/api/runners_spec.rb
+++ b/spec/requests/ci/api/runners_spec.rb
@@ -12,7 +12,9 @@ describe Ci::API::Runners do
describe "POST /runners/register" do
context 'when runner token is provided' do
- before { post ci_api("/runners/register"), token: registration_token }
+ before do
+ post ci_api("/runners/register"), token: registration_token
+ end
it 'creates runner with default values' do
expect(response).to have_http_status 201
@@ -68,8 +70,11 @@ describe Ci::API::Runners do
end
context 'when project token is provided' do
- let(:project) { FactoryGirl.create(:empty_project) }
- before { post ci_api("/runners/register"), token: project.runners_token }
+ let(:project) { FactoryGirl.create(:project) }
+
+ before do
+ post ci_api("/runners/register"), token: project.runners_token
+ end
it 'creates runner' do
expect(response).to have_http_status 201
diff --git a/spec/requests/ci/api/triggers_spec.rb b/spec/requests/ci/api/triggers_spec.rb
index 26b03c0f148..7c77ebb69a2 100644
--- a/spec/requests/ci/api/triggers_spec.rb
+++ b/spec/requests/ci/api/triggers_spec.rb
@@ -4,8 +4,15 @@ describe Ci::API::Triggers do
describe 'POST /projects/:project_id/refs/:ref/trigger' do
let!(:trigger_token) { 'secure token' }
let!(:project) { create(:project, :repository, ci_id: 10) }
- let!(:project2) { create(:empty_project, ci_id: 11) }
- let!(:trigger) { create(:ci_trigger, project: project, token: trigger_token) }
+ let!(:project2) { create(:project, ci_id: 11) }
+
+ let!(:trigger) do
+ create(:ci_trigger,
+ project: project,
+ token: trigger_token,
+ owner: create(:user))
+ end
+
let(:options) do
{
token: trigger_token
@@ -14,6 +21,8 @@ describe Ci::API::Triggers do
before do
stub_ci_pipeline_to_return_yaml_file
+
+ project.add_developer(trigger.owner)
end
context 'Handles errors' do
@@ -47,7 +56,8 @@ describe Ci::API::Triggers do
it 'returns bad request with no builds created if there\'s no commit for that ref' do
post ci_api("/projects/#{project.ci_id}/refs/other-branch/trigger"), options
expect(response).to have_http_status(400)
- expect(json_response['message']).to eq('No builds created')
+ expect(json_response['message']['base'])
+ .to contain_exactly('Reference not found')
end
context 'Validates variables' do
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 6ca3ef18fe6..ecac40e301b 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -1,132 +1,284 @@
require "spec_helper"
-describe 'Git HTTP requests', lib: true do
+describe 'Git HTTP requests' do
include GitHttpHelpers
include WorkhorseHelpers
include UserActivitiesHelpers
- it "gives WWW-Authenticate hints" do
- clone_get('doesnt/exist.git')
+ shared_examples 'pulls require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to downloads with status 401 Unauthorized (no project existence information leak)" do
+ download(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- expect(response.header['WWW-Authenticate']).to start_with('Basic ')
- end
+ context "when only username is provided" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- describe "User with no identities" do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository, path: 'project.git-project') }
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- context "when the project doesn't exist" do
- context "when no authentication is provided" do
- it "responds with status 401 (no project existence information leak)" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
+ context "when authentication succeeds" do
+ it "does not respond to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
+ end
+ end
+
+ shared_examples 'pushes require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do
+ upload(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- context "when username and password are provided" do
- context "when authentication fails" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
+ context "when only username is provided" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
+
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
end
end
+ end
- context "when authentication succeeds" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
- end
+ context "when authentication succeeds" do
+ it "does not respond to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
end
+ end
+
+ shared_examples_for 'pulls are allowed' do
+ it do
+ download(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
+
+ shared_examples_for 'pushes are allowed' do
+ it do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
+
+ describe "User with no identities" do
+ let(:user) { create(:user) }
- context "when the Wiki for a project exists" do
- it "responds with the right project" do
- wiki = ProjectWiki.new(project)
- project.update_attribute(:visibility_level, Project::PUBLIC)
+ context "when the project doesn't exist" do
+ let(:path) { 'doesnt/exist.git' }
- download("/#{wiki.repository.path_with_namespace}.git") do |response|
- json_body = ActiveSupport::JSON.decode(response.body)
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- expect(response).to have_http_status(200)
- expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ context 'when authenticated' do
+ it 'rejects downloads and uploads with 404 Not Found' do
+ download_or_upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ end
end
end
+ end
+
+ context "when requesting the Wiki" do
+ let(:wiki) { ProjectWiki.new(project) }
+ let(:path) { "/#{wiki.repository.full_path}.git" }
- context 'but the repo is disabled' do
- let(:project) { create(:project, :repository_disabled, :wiki_enabled) }
- let(:wiki) { ProjectWiki.new(project) }
- let(:path) { "/#{wiki.repository.path_with_namespace}.git" }
+ context "when the project is public" do
+ let(:project) { create(:project, :repository, :public, :wiki_enabled) }
+
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when unauthenticated' do
+ let(:env) { {} }
+
+ it_behaves_like 'pulls are allowed'
- before do
- project.team << [user, :developer]
+ it "responds to pulls with the wiki's repo" do
+ download(path) do |response|
+ json_body = ActiveSupport::JSON.decode(response.body)
+
+ expect(json_body['RepoPath']).to include(wiki.repository.full_path)
+ end
+ end
end
- it 'allows clones' do
- download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context 'when authenticated' do
+ let(:env) { { user: user.username, password: user.password } }
+
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) }
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
+ end
+ end
+
+ context 'and not on the team' do
+ it_behaves_like 'pulls are allowed'
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_wiki_error(:write_to_wiki))
+ end
+ end
end
end
+ end
- it 'allows pushes' do
- upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context "when the project is private" do
+ let(:project) { create(:project, :repository, :private, :wiki_enabled) }
+
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when authenticated' do
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) }
+
+ it 'allows clones' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ it 'pushes are allowed' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context 'and not on the team' do
+ it 'rejects clones with 404 Not Found' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
+
+ it 'rejects pushes with 404 Not Found' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
end
end
end
end
context "when the project exists" do
- let(:path) { "#{project.path_with_namespace}.git" }
+ let(:path) { "#{project.full_path}.git" }
context "when the project is public" do
- before do
- project.update_attribute(:visibility_level, Project::PUBLIC)
- end
+ let(:project) { create(:project, :repository, :public) }
- it "downloads get status 200" do
- download(path, {}) do |response|
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
- end
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- it "uploads get status 401" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
+ context 'when not authenticated' do
+ let(:env) { {} }
+
+ it_behaves_like 'pulls are allowed'
end
- context "with correct credentials" do
+ context "when authenticated" do
let(:env) { { user: user.username, password: user.password } }
- it "uploads get status 403" do
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'as a developer on the team' do
+ before do
+ project.team << [user, :developer]
end
- end
- context 'but git-receive-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'but git-receive-pack over HTTP is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http))
+ end
+ end
+ end
+
+ context 'but git-upload-pack over HTTP is disabled in config' do
+ it "rejects pushes with 403 Forbidden" do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+
+ download(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http))
+ end
end
end
end
- end
- context 'but git-upload-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ context 'and not a member of the team' do
+ it_behaves_like 'pulls are allowed'
- download(path, {}) do |response|
- expect(response).to have_http_status(404)
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(change_access_error(:push_code))
+ end
end
end
end
@@ -134,73 +286,68 @@ describe 'Git HTTP requests', lib: true do
context 'when the request is not from gitlab-workhorse' do
it 'raises an exception' do
expect do
- get("/#{project.path_with_namespace}.git/info/refs?service=git-upload-pack")
+ get("/#{project.full_path}.git/info/refs?service=git-upload-pack")
end.to raise_error(JWT::DecodeError)
end
end
context 'when the repo is public' do
context 'but the repo is disabled' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_disabled)
+ let(:project) { create(:project, :public, :repository, :repository_disabled) }
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
context 'but the repo is enabled' do
- it 'allows to clone the repo' do
- project = create(:project, :public, :repository_enabled)
+ let(:project) { create(:project, :public, :repository, :repository_enabled) }
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:ok)
- end
- end
+ it_behaves_like 'pulls are allowed'
end
context 'but only project members are allowed' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_private)
+ let(:project) { create(:project, :public, :repository, :repository_private) }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
end
- end
- context "when the project is private" do
- before do
- project.update_attribute(:visibility_level, Project::PRIVATE)
- end
+ context 'and the user requests a redirected path' do
+ let!(:redirect) { project.route.create_redirect('foo/bar') }
+ let(:path) { "#{redirect.path}.git" }
+ let(:project_moved_message) do
+ <<-MSG.strip_heredoc
+ Project '#{redirect.path}' was moved to '#{project.full_path}'.
- context "when no authentication is provided" do
- it "responds with status 401 to downloads" do
- download(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
+ Please update your Git remote and try again:
+
+ git remote set-url origin #{project.http_url_to_repo}
+ MSG
end
- it "responds with status 401 to uploads" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
+ it 'downloads get status 404 with "project was moved" message' do
+ clone_get(path, {})
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to match(project_moved_message)
end
end
+ end
+
+ context "when the project is private" do
+ let(:project) { create(:project, :repository, :private) }
+
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
context "when authentication fails" do
- it "responds with status 401" do
- download(path, env) do |response|
- expect(response).to have_http_status(401)
- end
- end
-
context "when the user is IP banned" do
it "responds with status 401" do
expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true)
@@ -208,7 +355,7 @@ describe 'Git HTTP requests', lib: true do
clone_get(path, env)
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
@@ -222,42 +369,44 @@ describe 'Git HTTP requests', lib: true do
end
context "when the user is blocked" do
- it "responds with status 401" do
+ it "rejects pulls with 401 Unauthorized" do
user.block
project.team << [user, :master]
download(path, env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
- it "responds with status 401 for unknown projects (no project existence information leak)" do
+ it "rejects pulls with 401 Unauthorized for unknown projects (no project existence information leak)" do
user.block
download('doesnt/exist.git', env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
context "when the user isn't blocked" do
- it "downloads get status 200" do
- expect(Rack::Attack::Allow2Ban).to receive(:reset)
+ it "resets the IP in Rack Attack on download" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
- clone_get(path, env)
-
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ download(path, env) do
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
end
- it "uploads get status 200" do
- upload(path, env) do |response|
- expect(response).to have_http_status(200)
+ it "resets the IP in Rack Attack on upload" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
+
+ upload(path, env) do
+ expect(response).to have_http_status(:ok)
expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
end
- it 'updates the user last activity', :redis do
+ it 'updates the user last activity', :clean_gitlab_redis_shared_state do
expect(user_activity(user)).to be_nil
download(path, env) do |response|
@@ -272,54 +421,76 @@ describe 'Git HTTP requests', lib: true do
@token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api")
end
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: 'oauth2', password: @token.token } }
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 200" do
- push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
-
- expect(response).to have_http_status(200)
- end
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
context 'when user has 2FA enabled' do
let(:user) { create(:user, :two_factor) }
let(:access_token) { create(:personal_access_token, user: user) }
+ let(:path) { "#{project.full_path}.git" }
before do
project.team << [user, :master]
end
context 'when username and password are provided' do
- it 'rejects the clone attempt' do
- download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ it 'rejects pulls with personal access token error message' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
- it 'rejects the push attempt' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ it 'rejects the push attempt with personal access token error message' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
end
context 'when username and personal access token are provided' do
- it 'allows clones' do
- download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
- end
+ let(:env) { { user: user.username, password: access_token.token } }
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
+ end
+ end
+
+ context 'when internal auth is disabled' do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled?) { false }
+ end
+
+ it 'rejects pulls with personal access token error message' do
+ download(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
+
+ it 'rejects pushes with personal access token error message' do
+ upload(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
+
+ context 'when LDAP is configured' do
+ before do
+ allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow_any_instance_of(Gitlab::LDAP::Authentication)
+ .to receive(:login).and_return(nil)
end
- it 'allows pushes' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
+ it 'does not display the personal access token error message' do
+ upload(path, user: 'foo', password: 'bar') do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
end
@@ -354,18 +525,45 @@ describe 'Git HTTP requests', lib: true do
Rack::Attack::Allow2Ban.reset(ip, options)
end
end
+
+ context 'and the user requests a redirected path' do
+ let!(:redirect) { project.route.create_redirect('foo/bar') }
+ let(:path) { "#{redirect.path}.git" }
+ let(:project_moved_message) do
+ <<-MSG.strip_heredoc
+ Project '#{redirect.path}' was moved to '#{project.full_path}'.
+
+ Please update your Git remote and try again:
+
+ git remote set-url origin #{project.http_url_to_repo}
+ MSG
+ end
+
+ it 'downloads get status 404 with "project was moved" message' do
+ clone_get(path, env)
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to match(project_moved_message)
+ end
+
+ it 'uploads get status 404 with "project was moved" message' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to match(project_moved_message)
+ end
+ end
+ end
end
context "when the user doesn't have access to the project" do
- it "downloads get status 404" do
+ it "pulls get status 404" do
download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
it "uploads get status 404" do
upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
@@ -373,28 +571,41 @@ describe 'Git HTTP requests', lib: true do
end
context "when a gitlab ci token is provided" do
+ let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, :running) }
- let(:project) { build.project }
- let(:other_project) { create(:empty_project) }
-
- context 'when build created by system is authenticated' do
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ let(:other_project) { create(:project) }
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 401 (no project existence information leak)" do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ before do
+ build.update!(project: project) # can't associate it on factory create
+ end
- expect(response).to have_http_status(401)
+ context 'when build created by system is authenticated' do
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
+
+ it_behaves_like 'pulls are allowed'
+
+ # A non-401 here is not an information leak since the system is
+ # "authenticated" as CI using the correct token. It does not have
+ # push access, so pushes should be rejected as forbidden, and giving
+ # a reason is fine.
+ #
+ # We know for sure it is not an information leak since pulls using
+ # the build token must be allowed.
+ it "rejects pushes with 403 Forbidden" do
+ push_get(path, env)
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
- it "downloads from other project get status 404" do
- clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ # We are "authenticated" as CI using a valid token here. But we are
+ # not authorized to see any other project, so return "not found".
+ it "rejects pulls for other project with 404 Not Found" do
+ clone_get("#{other_project.full_path}.git", env)
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
end
end
@@ -405,31 +616,27 @@ describe 'Git HTTP requests', lib: true do
end
shared_examples 'can download code only' do
- it 'downloads get status 200' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(true)
-
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
+ it_behaves_like 'pulls are allowed'
- it 'downloads from non-existing repository and gets 403' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(false)
+ context 'when the repo does not exist' do
+ let(:project) { create(:project) }
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ it 'rejects pulls with 403 Forbidden' do
+ clone_get path, env
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:no_repo))
+ end
end
- it 'uploads get status 403' do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ it 'rejects pushes with 403 Forbidden' do
+ push_get path, env
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
end
@@ -439,9 +646,9 @@ describe 'Git HTTP requests', lib: true do
it_behaves_like 'can download code only'
it 'downloads from other project get status 403' do
- clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ clone_get "#{other_project.full_path}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
end
end
@@ -451,93 +658,108 @@ describe 'Git HTTP requests', lib: true do
it_behaves_like 'can download code only'
it 'downloads from other project get status 404' do
- clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ clone_get "#{other_project.full_path}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
end
end
- end
- context "when the project path doesn't end in .git" do
- context "GET info/refs" do
- let(:path) { "/#{project.path_with_namespace}/info/refs" }
+ context "when the project path doesn't end in .git" do
+ let(:project) { create(:project, :repository, :public, path: 'project.git-project') }
- context "when no params are added" do
- before { get path }
+ context "GET info/refs" do
+ let(:path) { "/#{project.full_path}/info/refs" }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs")
+ context "when no params are added" do
+ before do
+ get path
+ end
+
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.full_path}.git/info/refs")
+ end
end
- end
- context "when the upload-pack service is requested" do
- let(:params) { { service: 'git-upload-pack' } }
- before { get path, params }
+ context "when the upload-pack service is requested" do
+ let(:params) { { service: 'git-upload-pack' } }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ before do
+ get path, params
+ end
+
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.full_path}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the receive-pack service is requested" do
- let(:params) { { service: 'git-receive-pack' } }
- before { get path, params }
+ context "when the receive-pack service is requested" do
+ let(:params) { { service: 'git-receive-pack' } }
+
+ before do
+ get path, params
+ end
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.full_path}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the params are anything else" do
- let(:params) { { service: 'git-implode-pack' } }
- before { get path, params }
+ context "when the params are anything else" do
+ let(:params) { { service: 'git-implode-pack' } }
- it "redirects to the sign-in page" do
- expect(response).to redirect_to(new_user_session_path)
+ before do
+ get path, params
+ end
+
+ it "redirects to the sign-in page" do
+ expect(response).to redirect_to(new_user_session_path)
+ end
end
end
- end
- context "POST git-upload-pack" do
- it "fails to find a route" do
- expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-upload-pack" do
+ it "fails to find a route" do
+ expect { clone_post(project.full_path) }.to raise_error(ActionController::RoutingError)
+ end
end
- end
- context "POST git-receive-pack" do
- it "failes to find a route" do
- expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-receive-pack" do
+ it "fails to find a route" do
+ expect { push_post(project.full_path) }.to raise_error(ActionController::RoutingError)
+ end
end
end
- end
- context "retrieving an info/refs file" do
- before { project.update_attribute(:visibility_level, Project::PUBLIC) }
+ context "retrieving an info/refs file" do
+ let(:project) { create(:project, :repository, :public) }
- context "when the file exists" do
- before do
- # Provide a dummy file in its place
- allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
- allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
- Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt')
- end
+ context "when the file exists" do
+ before do
+ # Provide a dummy file in its place
+ allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
+ allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
+ Blob.decorate(Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt'), project)
+ end
- get "/#{project.path_with_namespace}/blob/master/info/refs"
- end
+ get "/#{project.full_path}/blob/master/info/refs"
+ end
- it "returns the file" do
- expect(response).to have_http_status(200)
+ it "returns the file" do
+ expect(response).to have_http_status(:ok)
+ end
end
- end
- context "when the file does not exist" do
- before { get "/#{project.path_with_namespace}/blob/master/info/refs" }
+ context "when the file does not exist" do
+ before do
+ get "/#{project.full_path}/blob/master/info/refs"
+ end
- it "returns not found" do
- expect(response).to have_http_status(404)
+ it "returns not found" do
+ expect(response).to have_http_status(:not_found)
+ end
end
end
end
@@ -546,6 +768,7 @@ describe 'Git HTTP requests', lib: true do
describe "User with LDAP identity" do
let(:user) { create(:omniauth_user, extern_uid: dn) }
let(:dn) { 'uid=john,ou=people,dc=example,dc=com' }
+ let(:path) { 'doesnt/exist.git' }
before do
allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
@@ -553,44 +776,36 @@ describe 'Git HTTP requests', lib: true do
allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user)
end
- context "when authentication fails" do
- context "when no authentication is provided" do
- it "responds with status 401" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
-
- context "when username and invalid password are provided" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when authentication succeeds" do
context "when the project doesn't exist" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ it "responds with status 404 Not Found" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
end
end
end
context "when the project exists" do
- let(:project) { create(:project, path: 'project.git-project') }
+ let(:project) { create(:project, :repository) }
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: user.username, password: user.password } }
- before do
- project.team << [user, :master]
- end
+ context 'and the user is on the team' do
+ before do
+ project.team << [user, :master]
+ end
- it "responds with status 200" do
- clone_get(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ it "responds with status 200" do
+ clone_get(path, env) do |response|
+ expect(response).to have_http_status(200)
+ end
end
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
end
end
diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb
index a3e7844b2f3..8d79ea3dd40 100644
--- a/spec/requests/jwt_controller_spec.rb
+++ b/spec/requests/jwt_controller_spec.rb
@@ -6,7 +6,9 @@ describe JwtController do
let(:service_name) { 'test' }
let(:parameters) { { service: service_name } }
- before { stub_const('JwtController::SERVICES', service_name => service_class) }
+ before do
+ stub_const('JwtController::SERVICES', service_name => service_class)
+ end
context 'existing service' do
subject! { get '/jwt/auth', parameters }
@@ -41,6 +43,19 @@ describe JwtController do
it { expect(response).to have_http_status(401) }
end
+
+ context 'using personal access tokens' do
+ let(:user) { create(:user) }
+ let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) }
+ let(:headers) { { authorization: credentials('personal_access_token', pat.token) } }
+
+ subject! { get '/jwt/auth', parameters, headers }
+
+ it 'authenticates correctly' do
+ expect(response).to have_http_status(200)
+ expect(service_class).to have_received(:new).with(nil, user, parameters)
+ end
+ end
end
context 'using User login' do
@@ -57,7 +72,7 @@ describe JwtController do
context 'without personal token' do
it 'rejects the authorization attempt' do
expect(response).to have_http_status(401)
- expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
end
@@ -75,9 +90,24 @@ describe JwtController do
context 'using invalid login' do
let(:headers) { { authorization: credentials('invalid', 'password') } }
- subject! { get '/jwt/auth', parameters, headers }
+ context 'when internal auth is enabled' do
+ it 'rejects the authorization attempt' do
+ get '/jwt/auth', parameters, headers
+
+ expect(response).to have_http_status(401)
+ expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
- it { expect(response).to have_http_status(401) }
+ context 'when internal auth is disabled' do
+ it 'rejects the authorization attempt with personal access token message' do
+ allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled?) { false }
+ get '/jwt/auth', parameters, headers
+
+ expect(response).to have_http_status(401)
+ expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
+ end
+ end
end
end
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index 5d495bc9e7d..27d09b8202e 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -20,7 +20,7 @@ describe 'Git LFS API and storage' do
let(:sample_size) { lfs_object.size }
describe 'when lfs is disabled' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:body) do
{
'objects' => [
@@ -46,7 +46,7 @@ describe 'Git LFS API and storage' do
end
context 'project specific LFS settings' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:body) do
{
'objects' => [
@@ -151,7 +151,7 @@ describe 'Git LFS API and storage' do
end
describe 'deprecated API' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
enable_lfs
@@ -188,7 +188,7 @@ describe 'Git LFS API and storage' do
end
describe 'when fetching lfs object' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:update_permissions) { }
before do
@@ -281,7 +281,7 @@ describe 'Git LFS API and storage' do
shared_examples 'can download LFS only from own projects' do
context 'for owned project' do
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
let(:update_permissions) do
project.lfs_objects << lfs_object
@@ -302,7 +302,7 @@ describe 'Git LFS API and storage' do
end
context 'for other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:update_permissions) do
@@ -368,7 +368,7 @@ describe 'Git LFS API and storage' do
end
describe 'download' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:body) do
{
'operation' => 'download',
@@ -408,7 +408,7 @@ describe 'Git LFS API and storage' do
end
context 'when downloading an lfs object that is assigned to other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:update_lfs_permissions) do
other_project.lfs_objects << lfs_object
end
@@ -425,7 +425,7 @@ describe 'Git LFS API and storage' do
'size' => sample_size,
'error' => {
'code' => 404,
- 'message' => "Object does not exist on the server or you don't have permissions to access it",
+ 'message' => "Object does not exist on the server or you don't have permissions to access it"
}
}
]
@@ -456,7 +456,7 @@ describe 'Git LFS API and storage' do
'size' => 1575078,
'error' => {
'code' => 404,
- 'message' => "Object does not exist on the server or you don't have permissions to access it",
+ 'message' => "Object does not exist on the server or you don't have permissions to access it"
}
}
]
@@ -493,7 +493,7 @@ describe 'Git LFS API and storage' do
'size' => 1575078,
'error' => {
'code' => 404,
- 'message' => "Object does not exist on the server or you don't have permissions to access it",
+ 'message' => "Object does not exist on the server or you don't have permissions to access it"
}
},
{
@@ -559,7 +559,7 @@ describe 'Git LFS API and storage' do
end
context 'for other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
it 'rejects downloading code' do
@@ -662,7 +662,7 @@ describe 'Git LFS API and storage' do
end
context 'when pushing an lfs object that already exists' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:update_lfs_permissions) do
other_project.lfs_objects << lfs_object
end
@@ -701,7 +701,7 @@ describe 'Git LFS API and storage' do
expect(json_response['objects']).to be_kind_of(Array)
expect(json_response['objects'].first['oid']).to eq("91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897")
expect(json_response['objects'].first['size']).to eq(1575078)
- expect(json_response['objects'].first['actions']['upload']['href']).to eq("#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}.git/gitlab-lfs/objects/91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897/1575078")
+ expect(json_response['objects'].first['actions']['upload']['href']).to eq("#{Gitlab.config.gitlab.url}/#{project.full_path}.git/gitlab-lfs/objects/91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897/1575078")
expect(json_response['objects'].first['actions']['upload']['header']).to eq('Authorization' => authorization)
end
end
@@ -759,18 +759,19 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
context 'tries to push to other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -778,8 +779,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -805,7 +806,7 @@ describe 'Git LFS API and storage' do
end
describe 'unsupported' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:authorization) { authorize_user }
let(:body) do
{
@@ -893,7 +894,7 @@ describe 'Git LFS API and storage' do
end
describe 'to one project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe 'when user is authenticated' do
let(:authorization) { authorize_user }
@@ -979,13 +980,13 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because the build user can read the project)' do
+ expect(response).to have_http_status(403)
end
end
context 'tries to push to other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
@@ -993,8 +994,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1006,8 +1007,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1079,18 +1080,19 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
context 'tries to push to other project' do
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -1098,8 +1100,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -1109,7 +1111,7 @@ describe 'Git LFS API and storage' do
end
describe 'and second project not related to fork or a source project' do
- let(:second_project) { create(:empty_project) }
+ let(:second_project) { create(:project) }
let(:authorization) { authorize_user }
before do
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index a4f85c22943..a927de952d0 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -61,7 +61,7 @@ describe 'OpenID Connect requests' do
email: private_email.email,
public_email: public_email.email,
website_url: 'https://example.com',
- avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"),
+ avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")
)
end
@@ -79,7 +79,7 @@ describe 'OpenID Connect requests' do
'email_verified' => true,
'website' => 'https://example.com',
'profile' => 'http://localhost/alice',
- 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png",
+ 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png"
})
end
end
@@ -98,7 +98,7 @@ describe 'OpenID Connect requests' do
expect(@payload['sub']).to eq hashed_subject
end
- it 'includes the time of the last authentication' do
+ it 'includes the time of the last authentication', :clean_gitlab_redis_shared_state do
expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i
end
diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb
deleted file mode 100644
index d20866c0d44..00000000000
--- a/spec/requests/projects/artifacts_controller_spec.rb
+++ /dev/null
@@ -1,117 +0,0 @@
-require 'spec_helper'
-
-describe Projects::ArtifactsController do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- let(:pipeline) do
- create(:ci_pipeline,
- project: project,
- sha: project.commit.sha,
- ref: project.default_branch,
- status: 'success')
- end
-
- let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
-
- describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do
- before do
- project.team << [user, :developer]
-
- login_as(user)
- end
-
- def path_from_ref(
- ref = pipeline.ref, job = build.name, path = 'browse')
- latest_succeeded_namespace_project_artifacts_path(
- project.namespace,
- project,
- [ref, path].join('/'),
- job: job)
- end
-
- context 'cannot find the build' do
- shared_examples 'not found' do
- it { expect(response).to have_http_status(:not_found) }
- end
-
- context 'has no such ref' do
- before do
- get path_from_ref('TAIL', build.name)
- end
-
- it_behaves_like 'not found'
- end
-
- context 'has no such build' do
- before do
- get path_from_ref(pipeline.ref, 'NOBUILD')
- end
-
- it_behaves_like 'not found'
- end
-
- context 'has no path' do
- before do
- get path_from_ref(pipeline.sha, build.name, '')
- end
-
- it_behaves_like 'not found'
- end
- end
-
- context 'found the build and redirect' do
- shared_examples 'redirect to the build' do
- it 'redirects' do
- path = browse_namespace_project_build_artifacts_path(
- project.namespace,
- project,
- build)
-
- expect(response).to redirect_to(path)
- end
- end
-
- context 'with regular branch' do
- before do
- pipeline.update(ref: 'master',
- sha: project.commit('master').sha)
-
- get path_from_ref('master')
- end
-
- it_behaves_like 'redirect to the build'
- end
-
- context 'with branch name containing slash' do
- before do
- pipeline.update(ref: 'improve/awesome',
- sha: project.commit('improve/awesome').sha)
-
- get path_from_ref('improve/awesome')
- end
-
- it_behaves_like 'redirect to the build'
- end
-
- context 'with branch name and path containing slashes' do
- before do
- pipeline.update(ref: 'improve/awesome',
- sha: project.commit('improve/awesome').sha)
-
- get path_from_ref('improve/awesome', build.name, 'file/README.md')
- end
-
- it 'redirects' do
- path = file_namespace_project_build_artifacts_path(
- project.namespace,
- project,
- build,
- 'README.md')
-
- expect(response).to redirect_to(path)
- end
- end
- end
- end
-end
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
index 33940f70b1c..e5d9d3df5a8 100644
--- a/spec/requests/projects/cycle_analytics_events_spec.rb
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'cycle analytics events', api: true do
+describe 'cycle analytics events' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository, public_builds: false) }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
@@ -9,8 +9,6 @@ describe 'cycle analytics events', api: true do
before do
project.team << [user, :developer]
- allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
-
3.times do |count|
Timecop.freeze(Time.now + count.days) do
create_cycle
@@ -23,7 +21,7 @@ describe 'cycle analytics events', api: true do
end
it 'lists the issue events' do
- get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_issue_path(project, format: :json)
first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s
@@ -32,7 +30,7 @@ describe 'cycle analytics events', api: true do
end
it 'lists the plan events' do
- get namespace_project_cycle_analytics_plan_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_plan_path(project, format: :json)
first_mr_short_sha = project.merge_requests.sort(:created_asc).first.commits.first.short_id
@@ -41,7 +39,7 @@ describe 'cycle analytics events', api: true do
end
it 'lists the code events' do
- get namespace_project_cycle_analytics_code_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_code_path(project, format: :json)
expect(json_response['events']).not_to be_empty
@@ -51,14 +49,14 @@ describe 'cycle analytics events', api: true do
end
it 'lists the test events' do
- get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_test_path(project, format: :json)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
end
it 'lists the review events' do
- get namespace_project_cycle_analytics_review_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_review_path(project, format: :json)
first_mr_iid = project.merge_requests.sort(:created_desc).pluck(:iid).first.to_s
@@ -67,14 +65,14 @@ describe 'cycle analytics events', api: true do
end
it 'lists the staging events' do
- get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_staging_path(project, format: :json)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
end
it 'lists the production events' do
- get namespace_project_cycle_analytics_production_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_production_path(project, format: :json)
first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s
@@ -86,7 +84,7 @@ describe 'cycle analytics events', api: true do
it 'lists the test events' do
branch = project.merge_requests.first.source_branch
- get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json, branch: branch)
+ get project_cycle_analytics_test_path(project, format: :json, branch: branch)
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['date']).not_to be_empty
@@ -99,19 +97,19 @@ describe 'cycle analytics events', api: true do
end
it 'does not list the test events' do
- get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_test_path(project, format: :json)
expect(response).to have_http_status(:not_found)
end
it 'does not list the staging events' do
- get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_staging_path(project, format: :json)
expect(response).to have_http_status(:not_found)
end
it 'lists the issue events' do
- get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json)
+ get project_cycle_analytics_issue_path(project, format: :json)
expect(response).to have_http_status(:ok)
end
@@ -121,9 +119,9 @@ describe 'cycle analytics events', api: true do
def create_cycle
milestone = create(:milestone, project: project)
issue.update(milestone: milestone)
- mr = create_merge_request_closing_issue(issue)
+ mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}")
- pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha)
+ pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr)
pipeline.run
create(:ci_build, pipeline: pipeline, status: :success, author: user)
diff --git a/spec/requests/request_profiler_spec.rb b/spec/requests/request_profiler_spec.rb
new file mode 100644
index 00000000000..9afeb2983b0
--- /dev/null
+++ b/spec/requests/request_profiler_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe 'Request Profiler' do
+ let(:user) { create(:user) }
+
+ shared_examples 'profiling a request' do
+ before do
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(RubyProf::Profile).to receive(:profile) do |&blk|
+ blk.call
+ RubyProf::Profile.new
+ end
+ end
+
+ it 'creates a profile of the request' do
+ project = create(:project, namespace: user.namespace)
+ time = Time.now
+ path = "/#{project.full_path}"
+
+ Timecop.freeze(time) do
+ get path, nil, 'X-Profile-Token' => Gitlab::RequestProfiler.profile_token
+ end
+
+ profile_path = "#{Gitlab.config.shared.path}/tmp/requests_profiles/#{path.tr('/', '|')}_#{time.to_i}.html"
+ expect(File.exist?(profile_path)).to be true
+ end
+
+ after do
+ Gitlab::RequestProfiler.remove_all_profiles
+ end
+ end
+
+ context "when user is logged-in" do
+ before do
+ login_as(user)
+ end
+
+ include_examples 'profiling a request'
+ end
+
+ context "when user is not logged-in" do
+ include_examples 'profiling a request'
+ end
+end
diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb
index 99c44bde151..179fc9733ad 100644
--- a/spec/routing/admin_routing_spec.rb
+++ b/spec/routing/admin_routing_spec.rb
@@ -71,13 +71,15 @@ describe Admin::ProjectsController, "routing" do
end
end
-# admin_hook_test GET /admin/hooks/:hook_id/test(.:format) admin/hooks#test
+# admin_hook_test GET /admin/hooks/:id/test(.:format) admin/hooks#test
# admin_hooks GET /admin/hooks(.:format) admin/hooks#index
# POST /admin/hooks(.:format) admin/hooks#create
# admin_hook DELETE /admin/hooks/:id(.:format) admin/hooks#destroy
+# PUT /admin/hooks/:id(.:format) admin/hooks#update
+# edit_admin_hook GET /admin/hooks/:id(.:format) admin/hooks#edit
describe Admin::HooksController, "routing" do
it "to #test" do
- expect(get("/admin/hooks/1/test")).to route_to('admin/hooks#test', hook_id: '1')
+ expect(get("/admin/hooks/1/test")).to route_to('admin/hooks#test', id: '1')
end
it "to #index" do
@@ -88,11 +90,31 @@ describe Admin::HooksController, "routing" do
expect(post("/admin/hooks")).to route_to('admin/hooks#create')
end
+ it "to #edit" do
+ expect(get("/admin/hooks/1/edit")).to route_to('admin/hooks#edit', id: '1')
+ end
+
+ it "to #update" do
+ expect(put("/admin/hooks/1")).to route_to('admin/hooks#update', id: '1')
+ end
+
it "to #destroy" do
expect(delete("/admin/hooks/1")).to route_to('admin/hooks#destroy', id: '1')
end
end
+# admin_hook_hook_log_retry GET /admin/hooks/:hook_id/hook_logs/:id/retry(.:format) admin/hook_logs#retry
+# admin_hook_hook_log GET /admin/hooks/:hook_id/hook_logs/:id(.:format) admin/hook_logs#show
+describe Admin::HookLogsController, 'routing' do
+ it 'to #retry' do
+ expect(get('/admin/hooks/1/hook_logs/1/retry')).to route_to('admin/hook_logs#retry', hook_id: '1', id: '1')
+ end
+
+ it 'to #show' do
+ expect(get('/admin/hooks/1/hook_logs/1')).to route_to('admin/hook_logs#show', hook_id: '1', id: '1')
+ end
+end
+
# admin_logs GET /admin/logs(.:format) admin/logs#show
describe Admin::LogsController, "routing" do
it "to #show" do
diff --git a/spec/routing/environments_spec.rb b/spec/routing/environments_spec.rb
index ba124de70bb..aacbe300966 100644
--- a/spec/routing/environments_spec.rb
+++ b/spec/routing/environments_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Projects::EnvironmentsController, :routing do
- let(:project) { create(:empty_project) }
+describe 'environments routing' do
+ let(:project) { create(:project) }
let(:environment) do
create(:environment, project: project,
diff --git a/spec/routing/notifications_routing_spec.rb b/spec/routing/notifications_routing_spec.rb
index 24592942a96..54ed87b5520 100644
--- a/spec/routing/notifications_routing_spec.rb
+++ b/spec/routing/notifications_routing_spec.rb
@@ -1,13 +1,11 @@
require "spec_helper"
-describe Profiles::NotificationsController do
- describe "routing" do
- it "routes to #show" do
- expect(get("/profile/notifications")).to route_to("profiles/notifications#show")
- end
+describe "notifications routing" do
+ it "routes to #show" do
+ expect(get("/profile/notifications")).to route_to("profiles/notifications#show")
+ end
- it "routes to #update" do
- expect(put("/profile/notifications")).to route_to("profiles/notifications#update")
- end
+ it "routes to #update" do
+ expect(put("/profile/notifications")).to route_to("profiles/notifications#update")
end
end
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index a3de022d242..39d44245c3f 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe 'project routing' do
before do
allow(Project).to receive(:find_by_full_path).and_return(false)
- allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq').and_return(true)
+ allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq', any_args).and_return(true)
end
# Shared examples for a resource inside a Project
@@ -93,13 +93,17 @@ describe 'project routing' do
end
context 'name with dot' do
- before { allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys').and_return(true) }
+ before do
+ allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys', any_args).and_return(true)
+ end
it { expect(get('/gitlab/gitlabhq.keys')).to route_to('projects#show', namespace_id: 'gitlab', id: 'gitlabhq.keys') }
end
context 'with nested group' do
- before { allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq').and_return(true) }
+ before do
+ allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq', any_args).and_return(true)
+ end
it { expect(get('/gitlab/subgroup/gitlabhq')).to route_to('projects#show', namespace_id: 'gitlab/subgroup', id: 'gitlabhq') }
end
@@ -161,15 +165,19 @@ describe 'project routing' do
# edit_project_repository GET /:project_id/repository/edit(.:format) projects/repositories#edit
describe Projects::RepositoriesController, 'routing' do
it 'to #archive' do
- expect(get('/gitlab/gitlabhq/repository/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ expect(get('/gitlab/gitlabhq/repository/master/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', ref: 'master')
end
it 'to #archive format:zip' do
- expect(get('/gitlab/gitlabhq/repository/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip')
+ expect(get('/gitlab/gitlabhq/repository/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', ref: 'master')
end
it 'to #archive format:tar.bz2' do
- expect(get('/gitlab/gitlabhq/repository/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2')
+ expect(get('/gitlab/gitlabhq/repository/master/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2', ref: 'master')
+ end
+
+ it 'to #archive with "/" in route' do
+ expect(get('/gitlab/gitlabhq/repository/improve/awesome/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', ref: 'improve/awesome')
end
end
@@ -201,10 +209,12 @@ describe 'project routing' do
# POST /:project_id/deploy_keys(.:format) deploy_keys#create
# new_project_deploy_key GET /:project_id/deploy_keys/new(.:format) deploy_keys#new
# project_deploy_key GET /:project_id/deploy_keys/:id(.:format) deploy_keys#show
+ # edit_project_deploy_key GET /:project_id/deploy_keys/:id/edit(.:format) deploy_keys#edit
+ # project_deploy_key PATCH /:project_id/deploy_keys/:id(.:format) deploy_keys#update
# DELETE /:project_id/deploy_keys/:id(.:format) deploy_keys#destroy
describe Projects::DeployKeysController, 'routing' do
it_behaves_like 'RESTful project resources' do
- let(:actions) { [:index, :new, :create] }
+ let(:actions) { [:index, :new, :create, :edit, :update] }
let(:controller) { 'deploy_keys' }
end
end
@@ -240,29 +250,13 @@ describe 'project routing' do
end
end
- # diffs_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/diffs(.:format) projects/merge_requests#diffs
- # commits_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/commits(.:format) projects/merge_requests#commits
- # merge_namespace_project_merge_request POST /:namespace_id/:project_id/merge_requests/:id/merge(.:format) projects/merge_requests#merge
- # merge_check_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/merge_check(.:format) projects/merge_requests#merge_check
- # ci_status_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/ci_status(.:format) projects/merge_requests#ci_status
- # toggle_subscription_namespace_project_merge_request POST /:namespace_id/:project_id/merge_requests/:id/toggle_subscription(.:format) projects/merge_requests#toggle_subscription
- # branch_from_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/branch_from(.:format) projects/merge_requests#branch_from
- # branch_to_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/branch_to(.:format) projects/merge_requests#branch_to
- # update_branches_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/update_branches(.:format) projects/merge_requests#update_branches
- # namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests(.:format) projects/merge_requests#index
- # POST /:namespace_id/:project_id/merge_requests(.:format) projects/merge_requests#create
- # new_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/new(.:format) projects/merge_requests#new
- # edit_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/edit(.:format) projects/merge_requests#edit
- # namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#show
- # PATCH /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#update
- # PUT /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#update
describe Projects::MergeRequestsController, 'routing' do
- it 'to #diffs' do
- expect(get('/gitlab/gitlabhq/merge_requests/1/diffs')).to route_to('projects/merge_requests#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
+ it 'to #commits' do
+ expect(get('/gitlab/gitlabhq/merge_requests/1/commits.json')).to route_to('projects/merge_requests#commits', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json')
end
- it 'to #commits' do
- expect(get('/gitlab/gitlabhq/merge_requests/1/commits')).to route_to('projects/merge_requests#commits', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
+ it 'to #pipelines' do
+ expect(get('/gitlab/gitlabhq/merge_requests/1/pipelines.json')).to route_to('projects/merge_requests#pipelines', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json')
end
it 'to #merge' do
@@ -272,29 +266,59 @@ describe 'project routing' do
)
end
- it 'to #merge_check' do
- expect(get('/gitlab/gitlabhq/merge_requests/1/merge_check')).to route_to('projects/merge_requests#merge_check', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/merge_requests/1.diff')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'diff')
+ expect(get('/gitlab/gitlabhq/merge_requests/1.patch')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'patch')
+ expect(get('/gitlab/gitlabhq/merge_requests/1/diffs')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'diffs')
+ expect(get('/gitlab/gitlabhq/merge_requests/1/commits')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'commits')
+ expect(get('/gitlab/gitlabhq/merge_requests/1/pipelines')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'pipelines')
+ end
+
+ it_behaves_like 'RESTful project resources' do
+ let(:controller) { 'merge_requests' }
+ let(:actions) { [:index, :edit, :show, :update] }
+ end
+ end
+
+ describe Projects::MergeRequests::CreationsController, 'routing' do
+ it 'to #new' do
+ expect(get('/gitlab/gitlabhq/merge_requests/new')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ expect(get('/gitlab/gitlabhq/merge_requests/new/diffs')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq', tab: 'diffs')
+ expect(get('/gitlab/gitlabhq/merge_requests/new/pipelines')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq', tab: 'pipelines')
+ end
+
+ it 'to #create' do
+ expect(post('/gitlab/gitlabhq/merge_requests')).to route_to('projects/merge_requests/creations#create', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
it 'to #branch_from' do
- expect(get('/gitlab/gitlabhq/merge_requests/branch_from')).to route_to('projects/merge_requests#branch_from', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ expect(get('/gitlab/gitlabhq/merge_requests/new/branch_from')).to route_to('projects/merge_requests/creations#branch_from', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
it 'to #branch_to' do
- expect(get('/gitlab/gitlabhq/merge_requests/branch_to')).to route_to('projects/merge_requests#branch_to', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ expect(get('/gitlab/gitlabhq/merge_requests/new/branch_to')).to route_to('projects/merge_requests/creations#branch_to', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
- it 'to #show' do
- expect(get('/gitlab/gitlabhq/merge_requests/1.diff')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'diff')
- expect(get('/gitlab/gitlabhq/merge_requests/1.patch')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'patch')
+ it 'to #pipelines' do
+ expect(get('/gitlab/gitlabhq/merge_requests/new/pipelines.json')).to route_to('projects/merge_requests/creations#pipelines', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json')
end
- it_behaves_like 'RESTful project resources' do
- let(:controller) { 'merge_requests' }
- let(:actions) { [:index, :create, :new, :edit, :show, :update] }
+ it 'to #diffs' do
+ expect(get('/gitlab/gitlabhq/merge_requests/new/diffs.json')).to route_to('projects/merge_requests/creations#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json')
+ end
+ end
+
+ describe Projects::MergeRequests::DiffsController, 'routing' do
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/merge_requests/1/diffs.json')).to route_to('projects/merge_requests/diffs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json')
end
end
+ describe Projects::MergeRequests::ConflictsController, 'routing' do
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/merge_requests/1/conflicts')).to route_to('projects/merge_requests/conflicts#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
+ end
+ end
# raw_project_snippet GET /:project_id/snippets/:id/raw(.:format) snippets#raw
# project_snippets GET /:project_id/snippets(.:format) snippets#index
# POST /:project_id/snippets(.:format) snippets#create
@@ -340,18 +364,32 @@ describe 'project routing' do
# test_project_hook GET /:project_id/hooks/:id/test(.:format) hooks#test
# project_hooks GET /:project_id/hooks(.:format) hooks#index
# POST /:project_id/hooks(.:format) hooks#create
- # project_hook DELETE /:project_id/hooks/:id(.:format) hooks#destroy
+ # edit_project_hook GET /:project_id/hooks/:id/edit(.:format) hooks#edit
+ # project_hook PUT /:project_id/hooks/:id(.:format) hooks#update
+ # DELETE /:project_id/hooks/:id(.:format) hooks#destroy
describe Projects::HooksController, 'routing' do
it 'to #test' do
expect(get('/gitlab/gitlabhq/hooks/1/test')).to route_to('projects/hooks#test', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
end
it_behaves_like 'RESTful project resources' do
- let(:actions) { [:index, :create, :destroy] }
+ let(:actions) { [:index, :create, :destroy, :edit, :update] }
let(:controller) { 'hooks' }
end
end
+ # retry_namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id/retry(.:format) projects/hook_logs#retry
+ # namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id(.:format) projects/hook_logs#show
+ describe Projects::HookLogsController, 'routing' do
+ it 'to #retry' do
+ expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1/retry')).to route_to('projects/hook_logs#retry', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1')
+ end
+
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1')).to route_to('projects/hook_logs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1')
+ end
+ end
+
# project_commit GET /:project_id/commit/:id(.:format) commit#show {id: /\h{7,40}/, project_id: /[^\/]+/}
describe Projects::CommitController, 'routing' do
it 'to #show' do
@@ -465,6 +503,8 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/blob/master/app/models/compare.rb')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/compare.rb')
expect(get('/gitlab/gitlabhq/blob/master/app/models/diff.js')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/diff.js')
expect(get('/gitlab/gitlabhq/blob/master/files.scss')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/files.scss')
+ expect(get('/gitlab/gitlabhq/blob/master/blob/index.js')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/blob/index.js')
+ expect(get('/gitlab/gitlabhq/blob/blob/master/blob/index.js')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'blob/master/blob/index.js')
end
end
@@ -473,6 +513,8 @@ describe 'project routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/tree/master/app/models/project.rb')).to route_to('projects/tree#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/project.rb')
expect(get('/gitlab/gitlabhq/tree/master/files.scss')).to route_to('projects/tree#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/files.scss')
+ expect(get('/gitlab/gitlabhq/tree/master/tree/files')).to route_to('projects/tree#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/tree/files')
+ expect(get('/gitlab/gitlabhq/tree/tree/master/tree/files')).to route_to('projects/tree#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'tree/master/tree/files')
end
end
@@ -571,4 +613,26 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/pages/domains/my.domain.com')).to route_to('projects/pages_domains#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'my.domain.com')
end
end
+
+ describe Projects::Registry::TagsController, 'routing' do
+ describe '#destroy' do
+ it 'correctly routes to a destroy action' do
+ expect(delete('/gitlab/gitlabhq/registry/repository/1/tags/rc1'))
+ .to route_to('projects/registry/tags#destroy',
+ namespace_id: 'gitlab',
+ project_id: 'gitlabhq',
+ repository_id: '1',
+ id: 'rc1')
+ end
+
+ it 'takes registry tag name constrains into account' do
+ expect(delete('/gitlab/gitlabhq/registry/repository/1/tags/-rc1'))
+ .not_to route_to('projects/registry/tags#destroy',
+ namespace_id: 'gitlab',
+ project_id: 'gitlabhq',
+ repository_id: '1',
+ id: '-rc1')
+ end
+ end
+ end
end
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 9f6defe1450..a45839b16f5 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -151,6 +151,10 @@ describe ProfilesController, "routing" do
expect(put("/profile/reset_private_token")).to route_to('profiles#reset_private_token')
end
+ it "to #reset_rss_token" do
+ expect(put("/profile/reset_rss_token")).to route_to('profiles#reset_rss_token')
+ end
+
it "to #show" do
expect(get("/profile")).to route_to('profiles#show')
end
@@ -249,23 +253,42 @@ describe RootController, 'routing' do
end
end
-# new_user_session GET /users/sign_in(.:format) devise/sessions#new
-# user_session POST /users/sign_in(.:format) devise/sessions#create
-# destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy
-# user_omniauth_authorize /users/auth/:provider(.:format) omniauth_callbacks#passthru
-# user_omniauth_callback /users/auth/:action/callback(.:format) omniauth_callbacks#(?-mix:(?!))
-# user_password POST /users/password(.:format) devise/passwords#create
-# new_user_password GET /users/password/new(.:format) devise/passwords#new
-# edit_user_password GET /users/password/edit(.:format) devise/passwords#edit
-# PUT /users/password(.:format) devise/passwords#update
describe "Authentication", "routing" do
- # pending
+ it "GET /users/sign_in" do
+ expect(get("/users/sign_in")).to route_to('sessions#new')
+ end
+
+ it "POST /users/sign_in" do
+ expect(post("/users/sign_in")).to route_to('sessions#create')
+ end
+
+ it "DELETE /users/sign_out" do
+ expect(delete("/users/sign_out")).to route_to('sessions#destroy')
+ end
+
+ it "POST /users/password" do
+ expect(post("/users/password")).to route_to('passwords#create')
+ end
+
+ it "GET /users/password/new" do
+ expect(get("/users/password/new")).to route_to('passwords#new')
+ end
+
+ it "GET /users/password/edit" do
+ expect(get("/users/password/edit")).to route_to('passwords#edit')
+ end
+
+ it "PUT /users/password" do
+ expect(put("/users/password")).to route_to('passwords#update')
+ end
end
describe "Groups", "routing" do
let(:name) { 'complex.group-namegit' }
- before { allow_any_instance_of(GroupUrlConstrainer).to receive(:matches?).and_return(true) }
+ before do
+ allow_any_instance_of(GroupUrlConstrainer).to receive(:matches?).and_return(true)
+ end
it "to #show" do
expect(get("/groups/#{name}")).to route_to('groups#show', id: name)
diff --git a/spec/rubocop/cop/active_record_dependent_spec.rb b/spec/rubocop/cop/active_record_dependent_spec.rb
new file mode 100644
index 00000000000..599a032bfc5
--- /dev/null
+++ b/spec/rubocop/cop/active_record_dependent_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/active_record_dependent'
+
+describe RuboCop::Cop::ActiveRecordDependent do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'inside the app/models directory' do
+ it 'registers an offense when dependent: is used' do
+ allow(cop).to receive(:in_model?).and_return(true)
+
+ inspect_source(cop, 'belongs_to :foo, dependent: :destroy')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ context 'outside the app/models directory' do
+ it 'does nothing' do
+ allow(cop).to receive(:in_model?).and_return(false)
+
+ inspect_source(cop, 'belongs_to :foo, dependent: :destroy')
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/active_record_serialize_spec.rb b/spec/rubocop/cop/active_record_serialize_spec.rb
new file mode 100644
index 00000000000..b94b25cecd0
--- /dev/null
+++ b/spec/rubocop/cop/active_record_serialize_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/active_record_serialize'
+
+describe RuboCop::Cop::ActiveRecordSerialize do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'inside the app/models directory' do
+ it 'registers an offense when serialize is used' do
+ allow(cop).to receive(:in_model?).and_return(true)
+
+ inspect_source(cop, 'serialize :foo')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ context 'outside the app/models directory' do
+ it 'does nothing' do
+ allow(cop).to receive(:in_model?).and_return(false)
+
+ inspect_source(cop, 'serialize :foo')
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/in_batches_spec.rb b/spec/rubocop/cop/in_batches_spec.rb
new file mode 100644
index 00000000000..072481984c6
--- /dev/null
+++ b/spec/rubocop/cop/in_batches_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/in_batches'
+
+describe RuboCop::Cop::InBatches do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'registers an offense when in_batches is used' do
+ inspect_source(cop, 'foo.in_batches do; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb
deleted file mode 100644
index 6b9b6b19650..00000000000
--- a/spec/rubocop/cop/migration/add_column_with_default_spec.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-require 'spec_helper'
-
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../../rubocop/cop/migration/add_column_with_default'
-
-describe RuboCop::Cop::Migration::AddColumnWithDefault do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- context 'in migration' do
- before do
- allow(cop).to receive(:in_migration?).and_return(true)
- end
-
- it 'registers an offense when add_column_with_default is used inside a change method' do
- inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
- end
-
- it 'registers no offense when add_column_with_default is used inside an up method' do
- inspect_source(cop, 'def up; add_column_with_default :table, :column, default: false; end')
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-
- context 'outside of migration' do
- it 'registers no offense' do
- inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end')
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-end
diff --git a/spec/rubocop/cop/migration/add_column_with_default_to_large_table_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_to_large_table_spec.rb
new file mode 100644
index 00000000000..07cb3fc4a2e
--- /dev/null
+++ b/spec/rubocop/cop/migration/add_column_with_default_to_large_table_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/add_column_with_default_to_large_table'
+
+describe RuboCop::Cop::Migration::AddColumnWithDefaultToLargeTable do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ described_class::LARGE_TABLES.each do |table|
+ it "registers an offense for the #{table} table" do
+ inspect_source(cop, "add_column_with_default :#{table}, :column, default: true")
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ it 'registers no offense for non-blacklisted tables' do
+ inspect_source(cop, "add_column_with_default :table, :column, default: true")
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ table = described_class::LARGE_TABLES.sample
+ inspect_source(cop, "add_column_with_default :#{table}, :column, default: true")
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/add_timestamps_spec.rb b/spec/rubocop/cop/migration/add_timestamps_spec.rb
new file mode 100644
index 00000000000..18df62dec3e
--- /dev/null
+++ b/spec/rubocop/cop/migration/add_timestamps_spec.rb
@@ -0,0 +1,90 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/add_timestamps'
+
+describe RuboCop::Cop::Migration::AddTimestamps do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+ let(:migration_with_add_timestamps) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_timestamps(:users)
+ end
+ end
+ )
+ end
+
+ let(:migration_without_add_timestamps) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ end
+ end
+ )
+ end
+
+ let(:migration_with_add_timestamps_with_timezone) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_timestamps_with_timezone(:users)
+ end
+ end
+ )
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when the "add_timestamps" method is used' do
+ inspect_source(cop, migration_with_add_timestamps)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([7])
+ end
+ end
+
+ it 'does not register an offense when the "add_timestamps" method is not used' do
+ inspect_source(cop, migration_without_add_timestamps)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+
+ it 'does not register an offense when the "add_timestamps_with_timezone" method is used' do
+ inspect_source(cop, migration_with_add_timestamps_with_timezone)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source(cop, migration_with_add_timestamps)
+ inspect_source(cop, migration_without_add_timestamps)
+ inspect_source(cop, migration_with_add_timestamps_with_timezone)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/datetime_spec.rb b/spec/rubocop/cop/migration/datetime_spec.rb
new file mode 100644
index 00000000000..388b086ce6a
--- /dev/null
+++ b/spec/rubocop/cop/migration/datetime_spec.rb
@@ -0,0 +1,90 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/datetime'
+
+describe RuboCop::Cop::Migration::Datetime do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+ let(:migration_with_datetime) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_column(:users, :last_sign_in, :datetime)
+ end
+ end
+ )
+ end
+
+ let(:migration_without_datetime) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ end
+ end
+ )
+ end
+
+ let(:migration_with_datetime_with_timezone) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_column(:users, :last_sign_in, :datetime_with_timezone)
+ end
+ end
+ )
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when the ":datetime" data type is used' do
+ inspect_source(cop, migration_with_datetime)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([7])
+ end
+ end
+
+ it 'does not register an offense when the ":datetime" data type is not used' do
+ inspect_source(cop, migration_without_datetime)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+
+ it 'does not register an offense when the ":datetime_with_timezone" data type is used' do
+ inspect_source(cop, migration_with_datetime_with_timezone)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source(cop, migration_with_datetime)
+ inspect_source(cop, migration_without_datetime)
+ inspect_source(cop, migration_with_datetime_with_timezone)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb
new file mode 100644
index 00000000000..9a8576a19e5
--- /dev/null
+++ b/spec/rubocop/cop/migration/hash_index_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/hash_index'
+
+describe RuboCop::Cop::Migration::HashIndex do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when creating a hash index' do
+ inspect_source(cop, 'def change; add_index :table, :column, using: :hash; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+
+ it 'registers an offense when creating a concurrent hash index' do
+ inspect_source(cop, 'def change; add_concurrent_index :table, :column, using: :hash; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+
+ it 'registers an offense when creating a hash index using t.index' do
+ inspect_source(cop, 'def change; t.index :table, :column, using: :hash; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source(cop, 'def change; index :table, :column, using: :hash; end')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
new file mode 100644
index 00000000000..3723d635083
--- /dev/null
+++ b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/reversible_add_column_with_default'
+
+describe RuboCop::Cop::Migration::ReversibleAddColumnWithDefault do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when add_column_with_default is used inside a change method' do
+ inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+
+ it 'registers no offense when add_column_with_default is used inside an up method' do
+ inspect_source(cop, 'def up; add_column_with_default :table, :column, default: false; end')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
new file mode 100644
index 00000000000..1006594499a
--- /dev/null
+++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
@@ -0,0 +1,85 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/safer_boolean_column'
+
+describe RuboCop::Cop::Migration::SaferBooleanColumn do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ described_class::SMALL_TABLES.each do |table|
+ context "for the #{table} table" do
+ sources_and_offense = [
+ ["add_column :#{table}, :column, :boolean, default: true", 'should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean, default: false", 'should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean, default: nil", 'should have a default and should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean, null: false", 'should have a default'],
+ ["add_column :#{table}, :column, :boolean, null: true", 'should have a default and should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean", 'should have a default and should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean, default: nil, null: false", 'should have a default'],
+ ["add_column :#{table}, :column, :boolean, default: nil, null: true", 'should have a default and should disallow nulls'],
+ ["add_column :#{table}, :column, :boolean, default: false, null: true", 'should disallow nulls']
+ ]
+
+ sources_and_offense.each do |source, offense|
+ context "given the source \"#{source}\"" do
+ it "registers the offense matching \"#{offense}\"" do
+ inspect_source(cop, source)
+
+ aggregate_failures do
+ expect(cop.offenses.first.message).to match(offense)
+ end
+ end
+ end
+ end
+
+ inoffensive_sources = [
+ "add_column :#{table}, :column, :boolean, default: true, null: false",
+ "add_column :#{table}, :column, :boolean, default: false, null: false"
+ ]
+
+ inoffensive_sources.each do |source|
+ context "given the source \"#{source}\"" do
+ it "registers no offense" do
+ inspect_source(cop, source)
+
+ aggregate_failures do
+ expect(cop.offenses).to be_empty
+ end
+ end
+ end
+ end
+ end
+ end
+
+ it 'registers no offense for tables not listed in SMALL_TABLES' do
+ inspect_source(cop, "add_column :large_table, :column, :boolean")
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it 'registers no offense for non-boolean columns' do
+ table = described_class::SMALL_TABLES.sample
+ inspect_source(cop, "add_column :#{table}, :column, :string")
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ table = described_class::SMALL_TABLES.sample
+ inspect_source(cop, "add_column :#{table}, :column, :boolean")
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/timestamps_spec.rb b/spec/rubocop/cop/migration/timestamps_spec.rb
new file mode 100644
index 00000000000..cdf1423d0c5
--- /dev/null
+++ b/spec/rubocop/cop/migration/timestamps_spec.rb
@@ -0,0 +1,99 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/timestamps'
+
+describe RuboCop::Cop::Migration::Timestamps do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+ let(:migration_with_timestamps) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.timestamps null: true
+ t.string :password
+ end
+ end
+ end
+ )
+ end
+
+ let(:migration_without_timestamps) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.string :password
+ end
+ end
+ end
+ )
+ end
+
+ let(:migration_with_timestamps_with_timezone) do
+ %q(
+ class Users < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.timestamps_with_timezone null: true
+ t.string :password
+ end
+ end
+ end
+ )
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when the "timestamps" method is used' do
+ inspect_source(cop, migration_with_timestamps)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([8])
+ end
+ end
+
+ it 'does not register an offense when the "timestamps" method is not used' do
+ inspect_source(cop, migration_without_timestamps)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+
+ it 'does not register an offense when the "timestamps_with_timezone" method is used' do
+ inspect_source(cop, migration_with_timestamps_with_timezone)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source(cop, migration_with_timestamps)
+ inspect_source(cop, migration_without_timestamps)
+ inspect_source(cop, migration_with_timestamps_with_timezone)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
new file mode 100644
index 00000000000..38b8f439a55
--- /dev/null
+++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/migration/update_column_in_batches'
+
+describe RuboCop::Cop::Migration::UpdateColumnInBatches do
+ let(:cop) { described_class.new }
+ let(:tmp_rails_root) { Rails.root.join('tmp', 'rails_root') }
+ let(:migration_code) do
+ <<-END
+ def up
+ update_column_in_batches(:projects, :name, "foo") do |table, query|
+ query.where(table[:name].eq(nil))
+ end
+ end
+ END
+ end
+
+ before do
+ allow(cop).to receive(:rails_root).and_return(tmp_rails_root)
+ end
+ after do
+ FileUtils.rm_rf(tmp_rails_root)
+ end
+
+ context 'outside of a migration' do
+ it 'does not register any offenses' do
+ inspect_source(cop, migration_code)
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+
+ let(:spec_filepath) { tmp_rails_root.join('spec', 'migrations', 'my_super_migration_spec.rb') }
+
+ shared_context 'with a migration file' do
+ before do
+ FileUtils.mkdir_p(File.dirname(migration_filepath))
+ @migration_file = File.new(migration_filepath, 'w+')
+ end
+ after do
+ @migration_file.close
+ end
+ end
+
+ shared_examples 'a migration file with no spec file' do
+ include_context 'with a migration file'
+
+ let(:relative_spec_filepath) { Pathname.new(spec_filepath).relative_path_from(tmp_rails_root) }
+
+ it 'registers an offense when using update_column_in_batches' do
+ inspect_source(cop, migration_code, @migration_file)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([2])
+ expect(cop.offenses.first.message)
+ .to include("`#{relative_spec_filepath}`")
+ end
+ end
+ end
+
+ shared_examples 'a migration file with a spec file' do
+ include_context 'with a migration file'
+
+ before do
+ FileUtils.mkdir_p(File.dirname(spec_filepath))
+ @spec_file = File.new(spec_filepath, 'w+')
+ end
+ after do
+ @spec_file.close
+ end
+
+ it 'does not register any offenses' do
+ inspect_source(cop, migration_code, @migration_file)
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+
+ context 'in a migration' do
+ let(:migration_filepath) { tmp_rails_root.join('db', 'migrate', '20121220064453_my_super_migration.rb') }
+
+ it_behaves_like 'a migration file with no spec file'
+ it_behaves_like 'a migration file with a spec file'
+ end
+
+ context 'in a post migration' do
+ let(:migration_filepath) { tmp_rails_root.join('db', 'post_migrate', '20121220064453_my_super_migration.rb') }
+
+ it_behaves_like 'a migration file with no spec file'
+ it_behaves_like 'a migration file with a spec file'
+ end
+end
diff --git a/spec/rubocop/cop/polymorphic_associations_spec.rb b/spec/rubocop/cop/polymorphic_associations_spec.rb
new file mode 100644
index 00000000000..49959aa6419
--- /dev/null
+++ b/spec/rubocop/cop/polymorphic_associations_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/polymorphic_associations'
+
+describe RuboCop::Cop::PolymorphicAssociations do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'inside the app/models directory' do
+ it 'registers an offense when polymorphic: true is used' do
+ allow(cop).to receive(:in_model?).and_return(true)
+
+ inspect_source(cop, 'belongs_to :foo, polymorphic: true')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+ end
+
+ context 'outside the app/models directory' do
+ it 'does nothing' do
+ allow(cop).to receive(:in_model?).and_return(false)
+
+ inspect_source(cop, 'belongs_to :foo, polymorphic: true')
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+end
diff --git a/spec/rubocop/cop/project_path_helper_spec.rb b/spec/rubocop/cop/project_path_helper_spec.rb
new file mode 100644
index 00000000000..bc47b45cad7
--- /dev/null
+++ b/spec/rubocop/cop/project_path_helper_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../rubocop/cop/project_path_helper'
+
+describe RuboCop::Cop::ProjectPathHelper do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context "when using namespace_project with the project's namespace" do
+ let(:source) { 'edit_namespace_project_issue_path(@issue.project.namespace, @issue.project, @issue)' }
+ let(:correct_source) { 'edit_project_issue_path(@issue.project, @issue)' }
+
+ it 'registers an offense' do
+ inspect_source(cop, source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ expect(cop.highlights).to eq(['edit_namespace_project_issue_path'])
+ end
+ end
+
+ it 'autocorrects to the right version' do
+ autocorrected = autocorrect_source(cop, source)
+
+ expect(autocorrected).to eq(correct_source)
+ end
+ end
+
+ context 'when using namespace_project with a different namespace' do
+ it 'registers no offense' do
+ inspect_source(cop, 'edit_namespace_project_issue_path(namespace, project)')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/redirect_with_status_spec.rb b/spec/rubocop/cop/redirect_with_status_spec.rb
new file mode 100644
index 00000000000..5ad63567f84
--- /dev/null
+++ b/spec/rubocop/cop/redirect_with_status_spec.rb
@@ -0,0 +1,86 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../rubocop/cop/redirect_with_status'
+
+describe RuboCop::Cop::RedirectWithStatus do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+ let(:controller_fixture_without_status) do
+ %q(
+ class UserController < ApplicationController
+ def show
+ user = User.find(params[:id])
+ redirect_to user_path if user.name == 'John Wick'
+ end
+
+ def destroy
+ user = User.find(params[:id])
+
+ if user.destroy
+ redirect_to root_path
+ else
+ render :show
+ end
+ end
+ end
+ )
+ end
+
+ let(:controller_fixture_with_status) do
+ %q(
+ class UserController < ApplicationController
+ def show
+ user = User.find(params[:id])
+ redirect_to user_path if user.name == 'John Wick'
+ end
+
+ def destroy
+ user = User.find(params[:id])
+
+ if user.destroy
+ redirect_to root_path, status: 302
+ else
+ render :show
+ end
+ end
+ end
+ )
+ end
+
+ context 'in controller' do
+ before do
+ allow(cop).to receive(:in_controller?).and_return(true)
+ end
+
+ it 'registers an offense when a "destroy" action uses "redirect_to" without "status"' do
+ inspect_source(cop, controller_fixture_without_status)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([12]) # 'redirect_to' is located on 12th line in controller_fixture.
+ expect(cop.highlights).to eq(['redirect_to'])
+ end
+ end
+
+ it 'does not register an offense when a "destroy" action uses "redirect_to" with "status"' do
+ inspect_source(cop, controller_fixture_with_status)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+ end
+
+ context 'outside of controller' do
+ it 'registers no offense' do
+ inspect_source(cop, controller_fixture_without_status)
+ inspect_source(cop, controller_fixture_with_status)
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/rspec/single_line_hook_spec.rb b/spec/rubocop/cop/rspec/single_line_hook_spec.rb
new file mode 100644
index 00000000000..6cf0831d3ad
--- /dev/null
+++ b/spec/rubocop/cop/rspec/single_line_hook_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/rspec/single_line_hook'
+
+describe RuboCop::Cop::RSpec::SingleLineHook do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ # Override `CopHelper#inspect_source` to always appear to be in a spec file,
+ # so that our RSpec-only cop actually runs
+ def inspect_source(*args)
+ super(*args, 'foo_spec.rb')
+ end
+
+ it 'registers an offense for a single-line `before` block' do
+ inspect_source(cop, 'before { do_something }')
+
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ expect(cop.highlights).to eq(['before { do_something }'])
+ end
+
+ it 'registers an offense for a single-line `after` block' do
+ inspect_source(cop, 'after(:each) { undo_something }')
+
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ expect(cop.highlights).to eq(['after(:each) { undo_something }'])
+ end
+
+ it 'registers an offense for a single-line `around` block' do
+ inspect_source(cop, 'around { |ex| do_something_else }')
+
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ expect(cop.highlights).to eq(['around { |ex| do_something_else }'])
+ end
+
+ it 'ignores a multi-line `before` block' do
+ inspect_source(cop, ['before do',
+ ' do_something',
+ 'end'])
+
+ expect(cop.offenses.size).to eq(0)
+ end
+
+ it 'ignores a multi-line `after` block' do
+ inspect_source(cop, ['after(:each) do',
+ ' undo_something',
+ 'end'])
+
+ expect(cop.offenses.size).to eq(0)
+ end
+
+ it 'ignores a multi-line `around` block' do
+ inspect_source(cop, ['around do |ex|',
+ ' do_something_else',
+ 'end'])
+
+ expect(cop.offenses.size).to eq(0)
+ end
+end
diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb
index 86e703a6448..1ff4908972a 100644
--- a/spec/serializers/analytics_build_entity_spec.rb
+++ b/spec/serializers/analytics_build_entity_spec.rb
@@ -13,12 +13,8 @@ describe AnalyticsBuildEntity do
subject { entity.as_json }
- before do
- Timecop.freeze
- end
-
- after do
- Timecop.return
+ around do |example|
+ Timecop.freeze { example.run }
end
it 'contains the URL' do
@@ -47,7 +43,7 @@ describe AnalyticsBuildEntity do
let(:finished_at) { nil }
it 'does not blow up' do
- expect{ subject[:date] }.not_to raise_error
+ expect { subject[:date] }.not_to raise_error
end
it 'shows the right message' do
@@ -63,7 +59,7 @@ describe AnalyticsBuildEntity do
let(:started_at) { nil }
it 'does not blow up' do
- expect{ subject[:date] }.not_to raise_error
+ expect { subject[:date] }.not_to raise_error
end
it 'shows the right message' do
@@ -79,7 +75,7 @@ describe AnalyticsBuildEntity do
let(:finished_at) { nil }
it 'does not blow up' do
- expect{ subject[:date] }.not_to raise_error
+ expect { subject[:date] }.not_to raise_error
end
it 'shows the right message' do
diff --git a/spec/serializers/analytics_generic_entity_spec.rb b/spec/serializers/analytics_generic_entity_spec.rb
deleted file mode 100644
index 68086216ba9..00000000000
--- a/spec/serializers/analytics_generic_entity_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-require 'spec_helper'
-
-describe AnalyticsIssueEntity do
- let(:user) { create(:user) }
- let(:entity_hash) do
- {
- total_time: "172802.724419",
- title: "Eos voluptatem inventore in sed.",
- iid: "1",
- id: "1",
- created_at: "2016-11-12 15:04:02.948604",
- author: user,
- }
- end
-
- let(:project) { create(:empty_project) }
- let(:request) { EntityRequest.new(project: project, entity: :merge_request) }
-
- let(:entity) do
- described_class.new(entity_hash, request: request, project: project)
- end
-
- context 'generic entity' do
- subject { entity.as_json }
-
- it 'contains the entity URL' do
- expect(subject).to include(:url)
- end
-
- it 'contains the author' do
- expect(subject).to include(:author)
- end
-
- it 'does not contain sensitive information' do
- expect(subject).not_to include(/token/)
- expect(subject).not_to include(/variables/)
- end
- end
-end
diff --git a/spec/serializers/analytics_issue_entity_spec.rb b/spec/serializers/analytics_issue_entity_spec.rb
new file mode 100644
index 00000000000..89588b4df2b
--- /dev/null
+++ b/spec/serializers/analytics_issue_entity_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe AnalyticsIssueEntity do
+ let(:user) { create(:user) }
+ let(:entity_hash) do
+ {
+ total_time: "172802.724419",
+ title: "Eos voluptatem inventore in sed.",
+ iid: "1",
+ id: "1",
+ created_at: "2016-11-12 15:04:02.948604",
+ author: user
+ }
+ end
+
+ let(:project) { create(:project) }
+ let(:request) { EntityRequest.new(project: project, entity: :merge_request) }
+
+ let(:entity) do
+ described_class.new(entity_hash, request: request, project: project)
+ end
+
+ context 'generic entity' do
+ subject { entity.as_json }
+
+ it 'contains the entity URL' do
+ expect(subject).to include(:url)
+ end
+
+ it 'contains the author' do
+ expect(subject).to include(:author)
+ end
+
+ it 'does not contain sensitive information' do
+ expect(subject).not_to include(/token/)
+ expect(subject).not_to include(/variables/)
+ end
+ end
+end
diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb
index ba24cf8e481..5befc28f4fa 100644
--- a/spec/serializers/analytics_issue_serializer_spec.rb
+++ b/spec/serializers/analytics_issue_serializer_spec.rb
@@ -8,7 +8,7 @@ describe AnalyticsIssueSerializer do
end
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:resource) do
{
total_time: "172802.724419",
@@ -16,7 +16,7 @@ describe AnalyticsIssueSerializer do
iid: "1",
id: "1",
created_at: "2016-11-12 15:04:02.948604",
- author: user,
+ author: user
}
end
diff --git a/spec/serializers/analytics_merge_request_serializer_spec.rb b/spec/serializers/analytics_merge_request_serializer_spec.rb
index 56cb08acfc6..62067cc0ef2 100644
--- a/spec/serializers/analytics_merge_request_serializer_spec.rb
+++ b/spec/serializers/analytics_merge_request_serializer_spec.rb
@@ -8,7 +8,7 @@ describe AnalyticsMergeRequestSerializer do
end
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:resource) do
{
total_time: "172802.724419",
diff --git a/spec/serializers/analytics_summary_serializer_spec.rb b/spec/serializers/analytics_summary_serializer_spec.rb
index 5d7a94c2d02..236c244b402 100644
--- a/spec/serializers/analytics_summary_serializer_spec.rb
+++ b/spec/serializers/analytics_summary_serializer_spec.rb
@@ -5,7 +5,7 @@ describe AnalyticsSummarySerializer do
described_class.new.represent(resource)
end
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:resource) do
diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb
index 54ac17447b1..15720d86583 100644
--- a/spec/serializers/build_action_entity_spec.rb
+++ b/spec/serializers/build_action_entity_spec.rb
@@ -1,25 +1,26 @@
require 'spec_helper'
describe BuildActionEntity do
- let(:build) { create(:ci_build, name: 'test_build') }
+ let(:job) { create(:ci_build, name: 'test_job') }
+ let(:request) { double('request') }
let(:entity) do
- described_class.new(build, request: double)
+ described_class.new(job, request: spy('request'))
end
describe '#as_json' do
subject { entity.as_json }
- it 'contains original build name' do
- expect(subject[:name]).to eq 'test_build'
+ it 'contains original job name' do
+ expect(subject[:name]).to eq 'test_job'
end
it 'contains path to the action play' do
- expect(subject[:path]).to include "builds/#{build.id}/play"
+ expect(subject[:path]).to include "jobs/#{job.id}/play"
end
it 'contains whether it is playable' do
- expect(subject[:playable]).to eq build.playable?
+ expect(subject[:playable]).to eq job.playable?
end
end
end
diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb
index 2fc60aa9de6..ad0d3d3839e 100644
--- a/spec/serializers/build_artifact_entity_spec.rb
+++ b/spec/serializers/build_artifact_entity_spec.rb
@@ -1,22 +1,32 @@
require 'spec_helper'
describe BuildArtifactEntity do
- let(:build) { create(:ci_build, name: 'test:build') }
+ let(:job) { create(:ci_build, name: 'test:job', artifacts_expire_at: 1.hour.from_now) }
let(:entity) do
- described_class.new(build, request: double)
+ described_class.new(job, request: double)
end
describe '#as_json' do
subject { entity.as_json }
- it 'contains build name' do
- expect(subject[:name]).to eq 'test:build'
+ it 'contains job name' do
+ expect(subject[:name]).to eq 'test:job'
end
- it 'contains path to the artifacts' do
+ it 'exposes information about expiration of artifacts' do
+ expect(subject).to include(:expired, :expire_at)
+ end
+
+ it 'contains paths to the artifacts' do
expect(subject[:path])
- .to include "builds/#{build.id}/artifacts/download"
+ .to include "jobs/#{job.id}/artifacts/download"
+
+ expect(subject[:keep_path])
+ .to include "jobs/#{job.id}/artifacts/keep"
+
+ expect(subject[:browse_path])
+ .to include "jobs/#{job.id}/artifacts/browse"
end
end
end
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
new file mode 100644
index 00000000000..5b7822d5d8e
--- /dev/null
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -0,0 +1,116 @@
+require 'spec_helper'
+
+describe BuildDetailsEntity do
+ set(:user) { create(:admin) }
+
+ it 'inherits from JobEntity' do
+ expect(described_class).to be < JobEntity
+ end
+
+ describe '#as_json' do
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+ let(:request) { double('request') }
+
+ let(:entity) do
+ described_class.new(build, request: request,
+ current_user: user,
+ project: project)
+ end
+
+ subject { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ it 'contains the needed key value pairs' do
+ expect(subject).to include(:coverage, :erased_at, :duration)
+ expect(subject).to include(:runner, :pipeline)
+ expect(subject).to include(:raw_path, :new_issue_path)
+ end
+
+ context 'when the user has access to issues and merge requests' do
+ context 'when merge request orginates from the same project' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project, source_branch: build.ref)
+ end
+
+ before do
+ allow(build).to receive(:merge_request).and_return(merge_request)
+ end
+
+ it 'contains the needed key value pairs' do
+ expect(subject).to include(:merge_request)
+ expect(subject).to include(:new_issue_path)
+ end
+
+ it 'exposes correct details of the merge request' do
+ expect(subject[:merge_request][:iid]).to eq merge_request.iid
+ end
+
+ it 'has a correct merge request path' do
+ expect(subject[:merge_request][:path]).to include project.full_path
+ end
+ end
+
+ context 'when merge request is from a fork' do
+ let(:fork_project) do
+ create(:project, forked_from_project: project)
+ end
+
+ let(:pipeline) { create(:ci_pipeline, project: fork_project) }
+
+ before do
+ allow(build).to receive(:merge_request).and_return(merge_request)
+ end
+
+ let(:merge_request) do
+ create(:merge_request, source_project: fork_project,
+ target_project: project,
+ source_branch: build.ref)
+ end
+
+ it 'contains the needed key value pairs' do
+ expect(subject).to include(:merge_request)
+ expect(subject).to include(:new_issue_path)
+ end
+
+ it 'exposes details of the merge request' do
+ expect(subject[:merge_request][:iid]).to eq merge_request.iid
+ end
+
+ it 'has a merge request path to a target project' do
+ expect(subject[:merge_request][:path])
+ .to include project.full_path
+ end
+ end
+
+ context 'when the build has not been erased' do
+ let(:build) { create(:ci_build, :erasable, project: project) }
+
+ it 'exposes a build erase path' do
+ expect(subject).to include(:erase_path)
+ end
+ end
+
+ context 'when the build has been erased' do
+ let(:build) { create(:ci_build, :erased, project: project) }
+
+ it 'exposes the user who erased the build' do
+ expect(subject).to include(:erased_by)
+ end
+ end
+ end
+
+ context 'when the user can only read the build' do
+ let(:user) { create(:user) }
+
+ it "won't display the paths to issues and merge requests" do
+ expect(subject['new_issue_path']).to be_nil
+ expect(subject['merge_request_path']).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb
deleted file mode 100644
index f76a5cf72d1..00000000000
--- a/spec/serializers/build_entity_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-require 'spec_helper'
-
-describe BuildEntity do
- let(:user) { create(:user) }
- let(:build) { create(:ci_build) }
- let(:request) { double('request') }
-
- before do
- allow(request).to receive(:user).and_return(user)
- end
-
- let(:entity) do
- described_class.new(build, request: request)
- end
-
- subject { entity.as_json }
-
- it 'contains paths to build page and retry action' do
- expect(subject).to include(:build_path, :retry_path)
- end
-
- it 'does not contain sensitive information' do
- expect(subject).not_to include(/token/)
- expect(subject).not_to include(/variables/)
- end
-
- it 'contains whether it is playable' do
- expect(subject[:playable]).to eq build.playable?
- end
-
- it 'contains timestamps' do
- expect(subject).to include(:created_at, :updated_at)
- end
-
- it 'contains details' do
- expect(subject).to include :status
- expect(subject[:status]).to include :icon, :favicon, :text, :label
- end
-
- context 'when build is a regular job' do
- it 'does not contain path to play action' do
- expect(subject).not_to include(:play_path)
- end
- end
-
- context 'when build is a manual action' do
- let(:build) { create(:ci_build, :manual) }
-
- it 'contains path to play action' do
- expect(subject).to include(:play_path)
- end
- end
-end
diff --git a/spec/serializers/build_serializer_spec.rb b/spec/serializers/build_serializer_spec.rb
index 7f1abecfafe..01e2cfed6f8 100644
--- a/spec/serializers/build_serializer_spec.rb
+++ b/spec/serializers/build_serializer_spec.rb
@@ -4,7 +4,7 @@ describe BuildSerializer do
let(:user) { create(:user) }
let(:serializer) do
- described_class.new(user: user)
+ described_class.new(current_user: user)
end
subject { serializer.represent(resource) }
diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb
new file mode 100644
index 00000000000..d3aefa2c9eb
--- /dev/null
+++ b/spec/serializers/deploy_key_entity_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe DeployKeyEntity do
+ include RequestAwareEntity
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :internal)}
+ let(:project_private) { create(:project, :private)}
+ let!(:project_pending_delete) { create(:project, :internal, pending_delete: true) }
+ let(:deploy_key) { create(:deploy_key) }
+ let!(:deploy_key_internal) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) }
+ let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) }
+ let!(:deploy_key_pending_delete) { create(:deploy_keys_project, project: project_pending_delete, deploy_key: deploy_key) }
+
+ let(:entity) { described_class.new(deploy_key, user: user) }
+
+ describe 'returns deploy keys with projects a user can read' do
+ let(:expected_result) do
+ {
+ id: deploy_key.id,
+ user_id: deploy_key.user_id,
+ title: deploy_key.title,
+ fingerprint: deploy_key.fingerprint,
+ can_push: deploy_key.can_push,
+ destroyed_when_orphaned: true,
+ almost_orphaned: false,
+ created_at: deploy_key.created_at,
+ updated_at: deploy_key.updated_at,
+ can_edit: false,
+ projects: [
+ {
+ id: project.id,
+ name: project.name,
+ full_path: project_path(project),
+ full_name: project.full_name
+ }
+ ]
+ }
+ end
+
+ it { expect(entity.as_json).to eq(expected_result) }
+ end
+
+ describe 'returns can_edit true if user is a master of project' do
+ before do
+ project.add_master(user)
+ end
+
+ it { expect(entity.as_json).to include(can_edit: true) }
+ end
+
+ describe 'returns can_edit true if a user admin' do
+ let(:user) { create(:user, :admin) }
+
+ it { expect(entity.as_json).to include(can_edit: true) }
+ end
+end
diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb
index 95eca5463eb..522c92ce295 100644
--- a/spec/serializers/deployment_entity_spec.rb
+++ b/spec/serializers/deployment_entity_spec.rb
@@ -3,25 +3,23 @@ require 'spec_helper'
describe DeploymentEntity do
let(:user) { create(:user) }
let(:request) { double('request') }
+ let(:deployment) { create(:deployment) }
+ let(:entity) { described_class.new(deployment, request: request) }
+ subject { entity.as_json }
before do
- allow(request).to receive(:user).and_return(user)
+ allow(request).to receive(:current_user).and_return(user)
end
- let(:entity) do
- described_class.new(deployment, request: request)
- end
-
- let(:deployment) { create(:deployment) }
-
- subject { entity.as_json }
-
it 'exposes internal deployment id' do
expect(subject).to include(:iid)
end
it 'exposes nested information about branch' do
expect(subject[:ref][:name]).to eq 'master'
- expect(subject[:ref][:ref_path]).not_to be_empty
+ end
+
+ it 'exposes creation date' do
+ expect(subject).to include(:created_at)
end
end
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index 1909e6385b5..ca9b520fb38 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -2,11 +2,11 @@ require 'spec_helper'
describe EnvironmentSerializer do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:json) do
described_class
- .new(user: user, project: project)
+ .new(current_user: user, project: project)
.represent(resource)
end
@@ -39,7 +39,7 @@ describe EnvironmentSerializer do
end
context 'when there is a collection of objects provided' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:resource) { create_list(:environment, 2) }
it 'contains important elements of environment' do
@@ -62,7 +62,9 @@ describe EnvironmentSerializer do
subject { serializer.represent(resource) }
context 'when there is a single environment' do
- before { create(:environment, name: 'staging') }
+ before do
+ create(:environment, name: 'staging')
+ end
it 'represents one standalone environment' do
expect(subject.count).to eq 1
@@ -138,7 +140,9 @@ describe EnvironmentSerializer do
context 'when resource is paginatable relation' do
context 'when there is a single environment object in relation' do
- before { create(:environment) }
+ before do
+ create(:environment)
+ end
it 'serializes environments' do
expect(subject.first).to have_key :id
@@ -146,7 +150,9 @@ describe EnvironmentSerializer do
end
context 'when multiple environment objects are serialized' do
- before { create_list(:environment, 3) }
+ before do
+ create_list(:environment, 3)
+ end
it 'serializes appropriate number of objects' do
expect(subject.count).to be 2
diff --git a/spec/serializers/event_entity_spec.rb b/spec/serializers/event_entity_spec.rb
new file mode 100644
index 00000000000..bb54597c967
--- /dev/null
+++ b/spec/serializers/event_entity_spec.rb
@@ -0,0 +1,13 @@
+require 'spec_helper'
+
+describe EventEntity do
+ subject { described_class.represent(create(:event)).as_json }
+
+ it 'exposes author' do
+ expect(subject).to include(:author)
+ end
+
+ it 'exposes core elements of event' do
+ expect(subject).to include(:updated_at)
+ end
+end
diff --git a/spec/serializers/job_entity_spec.rb b/spec/serializers/job_entity_spec.rb
new file mode 100644
index 00000000000..026360e91a3
--- /dev/null
+++ b/spec/serializers/job_entity_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe JobEntity do
+ let(:user) { create(:user) }
+ let(:job) { create(:ci_build) }
+ let(:project) { job.project }
+ let(:request) { double('request') }
+
+ before do
+ stub_not_protect_default_branch
+ allow(request).to receive(:current_user).and_return(user)
+
+ project.add_developer(user)
+ end
+
+ let(:entity) do
+ described_class.new(job, request: request)
+ end
+
+ subject { entity.as_json }
+
+ it 'contains paths to job page action' do
+ expect(subject).to include(:build_path)
+ end
+
+ it 'does not contain sensitive information' do
+ expect(subject).not_to include(/token/)
+ expect(subject).not_to include(/variables/)
+ end
+
+ it 'contains whether it is playable' do
+ expect(subject[:playable]).to eq job.playable?
+ end
+
+ it 'contains timestamps' do
+ expect(subject).to include(:created_at, :updated_at)
+ end
+
+ it 'contains details' do
+ expect(subject).to include :status
+ expect(subject[:status]).to include :icon, :favicon, :text, :label
+ end
+
+ context 'when job is retryable' do
+ before do
+ job.update(status: :failed)
+ end
+
+ it 'contains cancel path' do
+ expect(subject).to include(:retry_path)
+ end
+ end
+
+ context 'when job is cancelable' do
+ before do
+ job.update(status: :running)
+ end
+
+ it 'contains cancel path' do
+ expect(subject).to include(:cancel_path)
+ end
+ end
+
+ context 'when job is a regular job' do
+ it 'does not contain path to play action' do
+ expect(subject).not_to include(:play_path)
+ end
+
+ it 'is not a playable build' do
+ expect(subject[:playable]).to be false
+ end
+ end
+
+ context 'when job is a manual action' do
+ let(:job) { create(:ci_build, :manual) }
+
+ context 'when user is allowed to trigger action' do
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: job.ref, project: job.project)
+ end
+
+ it 'contains path to play action' do
+ expect(subject).to include(:play_path)
+ end
+
+ it 'is a playable action' do
+ expect(subject[:playable]).to be true
+ end
+ end
+
+ context 'when user is not allowed to trigger action' do
+ before do
+ allow(job.project).to receive(:empty_repo?).and_return(false)
+
+ create(:protected_branch, :no_one_can_push,
+ name: job.ref, project: job.project)
+ end
+
+ it 'does not contain path to play action' do
+ expect(subject).not_to include(:play_path)
+ end
+
+ it 'is not a playable action' do
+ expect(subject[:playable]).to be false
+ end
+ end
+ end
+
+ context 'when job is generic commit status' do
+ let(:job) { create(:generic_commit_status, target_url: 'http://google.com') }
+
+ it 'contains paths to target action' do
+ expect(subject).to include(:build_path)
+ end
+
+ it 'does not contain paths to other action paths' do
+ expect(subject).not_to include(:retry_path, :cancel_path, :play_path)
+ end
+
+ it 'contains timestamps' do
+ expect(subject).to include(:created_at, :updated_at)
+ end
+
+ it 'contains details' do
+ expect(subject).to include :status
+ expect(subject[:status]).to include :icon, :favicon, :text, :label
+ end
+ end
+end
diff --git a/spec/serializers/label_serializer_spec.rb b/spec/serializers/label_serializer_spec.rb
new file mode 100644
index 00000000000..c58c7da1f9e
--- /dev/null
+++ b/spec/serializers/label_serializer_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe LabelSerializer do
+ let(:user) { create(:user) }
+
+ let(:serializer) do
+ described_class.new(user: user)
+ end
+
+ subject { serializer.represent(resource) }
+
+ describe '#represent' do
+ context 'when a single object is being serialized' do
+ let(:resource) { create(:label) }
+
+ it 'serializes the label object' do
+ expect(subject[:id]).to eq resource.id
+ end
+ end
+
+ context 'when multiple objects are being serialized' do
+ let(:num_labels) { 2 }
+ let(:resource) { create_list(:label, num_labels) }
+
+ it 'serializes the array of labels' do
+ expect(subject.size).to eq(num_labels)
+ end
+ end
+ end
+
+ describe '#represent_appearance' do
+ context 'when represents only appearance' do
+ let(:resource) { create(:label) }
+
+ subject { serializer.represent_appearance(resource) }
+
+ it 'serializes only attributes used for appearance' do
+ expect(subject.keys).to eq([:id, :title, :color, :text_color])
+ expect(subject[:id]).to eq(resource.id)
+ expect(subject[:title]).to eq(resource.title)
+ expect(subject[:color]).to eq(resource.color)
+ expect(subject[:text_color]).to eq(resource.text_color)
+ end
+ end
+ end
+end
diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb
new file mode 100644
index 00000000000..4daf5a59d0c
--- /dev/null
+++ b/spec/serializers/merge_request_basic_serializer_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe MergeRequestBasicSerializer do
+ let(:resource) { create(:merge_request) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new.represent(resource) }
+
+ it 'has important MergeRequest attributes' do
+ expect(subject).to include(:merge_status)
+ end
+end
diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb
new file mode 100644
index 00000000000..a2fd5b7daae
--- /dev/null
+++ b/spec/serializers/merge_request_entity_spec.rb
@@ -0,0 +1,145 @@
+require 'spec_helper'
+
+describe MergeRequestEntity do
+ let(:project) { create :project }
+ let(:resource) { create(:merge_request, source_project: project, target_project: project) }
+ let(:user) { create(:user) }
+
+ let(:request) { double('request', current_user: user) }
+
+ subject do
+ described_class.new(resource, request: request).as_json
+ end
+
+ it 'includes author' do
+ req = double('request')
+
+ author_payload = UserEntity
+ .represent(resource.author, request: req)
+ .as_json
+
+ expect(subject[:author]).to eq(author_payload)
+ end
+
+ it 'includes pipeline' do
+ req = double('request', current_user: user)
+ pipeline = build_stubbed(:ci_pipeline)
+ allow(resource).to receive(:head_pipeline).and_return(pipeline)
+
+ pipeline_payload = PipelineDetailsEntity
+ .represent(pipeline, request: req)
+ .as_json
+
+ expect(subject[:pipeline]).to eq(pipeline_payload)
+ end
+
+ it 'includes issues_links' do
+ issues_links = subject[:issues_links]
+
+ expect(issues_links).to include(:closing, :mentioned_but_not_closing,
+ :assign_to_closing)
+ end
+
+ it 'has important MergeRequest attributes' do
+ expect(subject).to include(:diff_head_sha, :merge_commit_message,
+ :has_conflicts, :has_ci, :merge_path,
+ :conflict_resolution_path,
+ :cancel_merge_when_pipeline_succeeds_path,
+ :create_issue_to_resolve_discussions_path,
+ :source_branch_path, :target_branch_commits_path,
+ :target_branch_tree_path, :commits_count, :merge_ongoing)
+ end
+
+ it 'has email_patches_path' do
+ expect(subject[:email_patches_path])
+ .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.patch")
+ end
+
+ it 'has plain_diff_path' do
+ expect(subject[:plain_diff_path])
+ .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff")
+ end
+
+ it 'has merge_commit_message_with_description' do
+ expect(subject[:merge_commit_message_with_description])
+ .to eq(resource.merge_commit_message(include_description: true))
+ end
+
+ describe 'new_blob_path' do
+ context 'when user can push to project' do
+ it 'returns path' do
+ project.add_developer(user)
+
+ expect(subject[:new_blob_path])
+ .to eq("/#{resource.project.full_path}/new/#{resource.source_branch}")
+ end
+ end
+
+ context 'when user cannot push to project' do
+ it 'returns nil' do
+ expect(subject[:new_blob_path]).to be_nil
+ end
+ end
+ end
+
+ describe 'diff_head_sha' do
+ before do
+ allow(resource).to receive(:diff_head_sha) { 'sha' }
+ end
+
+ context 'when no diff head commit' do
+ it 'returns nil' do
+ allow(resource).to receive(:diff_head_commit) { nil }
+
+ expect(subject[:diff_head_sha]).to be_nil
+ end
+ end
+
+ context 'when diff head commit present' do
+ it 'returns diff head commit short id' do
+ allow(resource).to receive(:diff_head_commit) { double }
+
+ expect(subject[:diff_head_sha]).to eq('sha')
+ end
+ end
+ end
+
+ it 'includes merge_event' do
+ create(:event, :merged, author: user, project: resource.project, target: resource)
+
+ expect(subject[:merge_event]).to include(:author, :updated_at)
+ end
+
+ it 'includes closed_event' do
+ create(:event, :closed, author: user, project: resource.project, target: resource)
+
+ expect(subject[:closed_event]).to include(:author, :updated_at)
+ end
+
+ describe 'diverged_commits_count' do
+ context 'when MR open and its diverging' do
+ it 'returns diverged commits count' do
+ allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: true,
+ diverged_commits_count: 10)
+
+ expect(subject[:diverged_commits_count]).to eq(10)
+ end
+ end
+
+ context 'when MR is not open' do
+ it 'returns 0' do
+ allow(resource).to receive_messages(open?: false)
+
+ expect(subject[:diverged_commits_count]).to be_zero
+ end
+ end
+
+ context 'when MR is not diverging' do
+ it 'returns 0' do
+ allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: false)
+
+ expect(subject[:diverged_commits_count]).to be_zero
+ end
+ end
+ end
+end
diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb
new file mode 100644
index 00000000000..73fbecc153d
--- /dev/null
+++ b/spec/serializers/merge_request_serializer_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe MergeRequestSerializer do
+ let(:user) { build_stubbed(:user) }
+ let(:merge_request) { build_stubbed(:merge_request) }
+
+ let(:serializer) do
+ described_class.new(current_user: user)
+ end
+
+ describe '#represent' do
+ let(:opts) { { basic: basic } }
+ subject { serializer.represent(merge_request, basic: basic) }
+
+ context 'when basic param is truthy' do
+ let(:basic) { true }
+
+ it 'calls super class #represent with correct params' do
+ expect_any_instance_of(BaseSerializer).to receive(:represent)
+ .with(merge_request, opts, MergeRequestBasicEntity)
+
+ subject
+ end
+ end
+
+ context 'when basic param is falsy' do
+ let(:basic) { false }
+
+ it 'calls super class #represent with correct params' do
+ expect_any_instance_of(BaseSerializer).to receive(:represent)
+ .with(merge_request, opts, MergeRequestEntity)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb
new file mode 100644
index 00000000000..f60d1843581
--- /dev/null
+++ b/spec/serializers/pipeline_details_entity_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe PipelineDetailsEntity do
+ set(:user) { create(:user) }
+ let(:request) { double('request') }
+
+ it 'inherrits from PipelineEntity' do
+ expect(described_class).to be < PipelineEntity
+ end
+
+ before do
+ stub_not_protect_default_branch
+
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ let(:entity) do
+ described_class.represent(pipeline, request: request)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when pipeline is empty' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'contains details' do
+ expect(subject).to include :details
+ expect(subject[:details])
+ .to include :duration, :finished_at
+ expect(subject[:details])
+ .to include :stages, :artifacts, :manual_actions
+ expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
+ end
+
+ it 'contains flags' do
+ expect(subject).to include :flags
+ expect(subject[:flags])
+ .to include :latest, :stuck,
+ :yaml_errors, :retryable, :cancelable
+ end
+ end
+
+ context 'when pipeline is retryable' do
+ let(:project) { create(:project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :success, project: project)
+ end
+
+ before do
+ create(:ci_build, :failed, pipeline: pipeline)
+ end
+
+ context 'user has ability to retry pipeline' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'retryable flag is true' do
+ expect(subject[:flags][:retryable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to retry pipeline' do
+ it 'retryable flag is false' do
+ expect(subject[:flags][:retryable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline is cancelable' do
+ let(:project) { create(:project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :running, project: project)
+ end
+
+ before do
+ create(:ci_build, :pending, pipeline: pipeline)
+ end
+
+ context 'user has ability to cancel pipeline' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'cancelable flag is true' do
+ expect(subject[:flags][:cancelable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to cancel pipeline' do
+ it 'cancelable flag is false' do
+ expect(subject[:flags][:cancelable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline has commit statuses' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ before do
+ create(:generic_commit_status, pipeline: pipeline)
+ end
+
+ it 'contains stages' do
+ expect(subject).to include(:details)
+ expect(subject[:details]).to include(:stages)
+ expect(subject[:details][:stages].first).to include(name: 'external')
+ end
+ end
+
+ context 'when pipeline has YAML errors' do
+ let(:pipeline) do
+ create(:ci_pipeline, config: { rspec: { invalid: :value } })
+ end
+
+ it 'contains information about error' do
+ expect(subject[:yaml_errors]).to be_present
+ end
+
+ it 'contains flag that indicates there are errors' do
+ expect(subject[:flags][:yaml_errors]).to be true
+ end
+ end
+
+ context 'when pipeline does not have YAML errors' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'does not contain field that normally holds an error' do
+ expect(subject).not_to have_key(:yaml_errors)
+ end
+
+ it 'contains flag that indicates there are no errors' do
+ expect(subject[:flags][:yaml_errors]).to be false
+ end
+ end
+ end
+end
diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb
index 93d5a21419d..881f2b6bfd8 100644
--- a/spec/serializers/pipeline_entity_spec.rb
+++ b/spec/serializers/pipeline_entity_spec.rb
@@ -1,11 +1,13 @@
require 'spec_helper'
describe PipelineEntity do
- let(:user) { create(:user) }
+ set(:user) { create(:user) }
let(:request) { double('request') }
before do
- allow(request).to receive(:user).and_return(user)
+ stub_not_protect_default_branch
+
+ allow(request).to receive(:current_user).and_return(user)
end
let(:entity) do
@@ -19,7 +21,7 @@ describe PipelineEntity do
let(:pipeline) { create(:ci_empty_pipeline) }
it 'contains required fields' do
- expect(subject).to include :id, :user, :path
+ expect(subject).to include :id, :user, :path, :coverage, :source
expect(subject).to include :ref, :commit
expect(subject).to include :updated_at, :created_at
end
@@ -28,21 +30,19 @@ describe PipelineEntity do
expect(subject).to include :details
expect(subject[:details])
.to include :duration, :finished_at
- expect(subject[:details])
- .to include :stages, :artifacts, :manual_actions
expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
end
it 'contains flags' do
expect(subject).to include :flags
expect(subject[:flags])
- .to include :latest, :triggered, :stuck,
+ .to include :latest, :stuck,
:yaml_errors, :retryable, :cancelable
end
end
context 'when pipeline is retryable' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) do
create(:ci_pipeline, status: :success, project: project)
@@ -53,10 +53,8 @@ describe PipelineEntity do
end
context 'user has ability to retry pipeline' do
- before { project.team << [user, :developer] }
-
- it 'retryable flag is true' do
- expect(subject[:flags][:retryable]).to eq true
+ before do
+ project.add_developer(user)
end
it 'contains retry path' do
@@ -65,10 +63,6 @@ describe PipelineEntity do
end
context 'user does not have ability to retry pipeline' do
- it 'retryable flag is false' do
- expect(subject[:flags][:retryable]).to eq false
- end
-
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
end
@@ -76,7 +70,7 @@ describe PipelineEntity do
end
context 'when pipeline is cancelable' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) do
create(:ci_pipeline, status: :running, project: project)
@@ -87,10 +81,8 @@ describe PipelineEntity do
end
context 'user has ability to cancel pipeline' do
- before { project.team << [user, :developer] }
-
- it 'cancelable flag is true' do
- expect(subject[:flags][:cancelable]).to eq true
+ before do
+ project.add_developer(user)
end
it 'contains cancel path' do
@@ -99,42 +91,12 @@ describe PipelineEntity do
end
context 'user does not have ability to cancel pipeline' do
- it 'cancelable flag is false' do
- expect(subject[:flags][:cancelable]).to eq false
- end
-
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
end
end
end
- context 'when pipeline has YAML errors' do
- let(:pipeline) do
- create(:ci_pipeline, config: { rspec: { invalid: :value } })
- end
-
- it 'contains flag that indicates there are errors' do
- expect(subject[:flags][:yaml_errors]).to be true
- end
-
- it 'contains information about error' do
- expect(subject[:yaml_errors]).to be_present
- end
- end
-
- context 'when pipeline does not have YAML errors' do
- let(:pipeline) { create(:ci_empty_pipeline) }
-
- it 'contains flag that indicates there are no errors' do
- expect(subject[:flags][:yaml_errors]).to be false
- end
-
- it 'does not contain field that normally holds an error' do
- expect(subject).not_to have_key(:yaml_errors)
- end
- end
-
context 'when pipeline ref is empty' do
let(:pipeline) { create(:ci_empty_pipeline) }
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index ecde45a6d44..2de8daba6b5 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -4,7 +4,7 @@ describe PipelineSerializer do
let(:user) { create(:user) }
let(:serializer) do
- described_class.new(user: user)
+ described_class.new(current_user: user)
end
subject { serializer.represent(resource) }
@@ -44,7 +44,7 @@ describe PipelineSerializer do
end
let(:serializer) do
- described_class.new(user: user)
+ described_class.new(current_user: user)
.with_pagination(request, response)
end
@@ -69,7 +69,9 @@ describe PipelineSerializer do
let(:pagination) { { page: 1, per_page: 2 } }
context 'when a single pipeline object is present in relation' do
- before { create(:ci_empty_pipeline) }
+ before do
+ create(:ci_empty_pipeline)
+ end
it 'serializes pipeline relation' do
expect(subject.first).to have_key :id
@@ -77,7 +79,9 @@ describe PipelineSerializer do
end
context 'when a multiple pipeline objects are being serialized' do
- before { create_list(:ci_empty_pipeline, 3) }
+ before do
+ create_list(:ci_empty_pipeline, 3)
+ end
it 'serializes appropriate number of objects' do
expect(subject.count).to be 2
@@ -96,29 +100,43 @@ describe PipelineSerializer do
context 'number of queries' do
let(:resource) { Ci::Pipeline.all }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
Ci::Pipeline::AVAILABLE_STATUSES.each do |status|
create_pipeline(status)
end
+ end
- RequestStore.begin!
+ shared_examples 'no N+1 queries' do
+ it 'verifies number of queries', :request_store do
+ recorded = ActiveRecord::QueryRecorder.new { subject }
+ expect(recorded.count).to be_within(1).of(57)
+ expect(recorded.cached_count).to eq(0)
+ end
end
- after do
- RequestStore.end!
- RequestStore.clear!
+ context 'with the same ref' do
+ let(:ref) { 'feature' }
+
+ it_behaves_like 'no N+1 queries'
end
- it "verifies number of queries" do
- recorded = ActiveRecord::QueryRecorder.new { subject }
- expect(recorded.count).to be_within(1).of(50)
- expect(recorded.cached_count).to eq(0)
+ context 'with different refs' do
+ def ref
+ @sequence ||= 0
+ @sequence += 1
+ "feature-#{@sequence}"
+ end
+
+ it_behaves_like 'no N+1 queries'
end
def create_pipeline(status)
- create(:ci_empty_pipeline, project: project, status: status).tap do |pipeline|
+ create(:ci_empty_pipeline,
+ project: project,
+ status: status,
+ ref: ref).tap do |pipeline|
Ci::Build::AVAILABLE_STATUSES.each do |status|
create_build(pipeline, status, status)
end
@@ -128,7 +146,7 @@ describe PipelineSerializer do
def create_build(pipeline, stage, status)
create(:ci_build, :tags, :triggered, :artifacts,
pipeline: pipeline, stage: stage,
- name: stage, status: status)
+ name: stage, status: status, ref: pipeline.ref)
end
end
end
diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb
new file mode 100644
index 00000000000..439ba2cbca2
--- /dev/null
+++ b/spec/serializers/runner_entity_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe RunnerEntity do
+ let(:runner) { create(:ci_runner, :specific) }
+ let(:entity) { described_class.new(runner, request: request, current_user: user) }
+ let(:request) { double('request') }
+ let(:project) { create(:project) }
+ let(:user) { create(:admin) }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains required fields' do
+ expect(subject).to include(:id, :description)
+ expect(subject).to include(:edit_path)
+ end
+ end
+end
diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb
index 4ab40d08432..40e303f7b89 100644
--- a/spec/serializers/stage_entity_spec.rb
+++ b/spec/serializers/stage_entity_spec.rb
@@ -14,7 +14,7 @@ describe StageEntity do
end
before do
- allow(request).to receive(:user).and_return(user)
+ allow(request).to receive(:current_user).and_return(user)
create(:ci_build, :success, pipeline: pipeline)
end
@@ -47,5 +47,24 @@ describe StageEntity do
it 'contains stage title' do
expect(subject[:title]).to eq 'test: passed'
end
+
+ context 'when the jobs should be grouped' do
+ let(:entity) { described_class.new(stage, request: request, grouped: true) }
+
+ it 'exposes the group key' do
+ expect(subject).to include :groups
+ end
+
+ context 'and contains commit status' do
+ before do
+ create(:generic_commit_status, pipeline: pipeline, stage: 'test')
+ end
+
+ it 'contains commit status' do
+ groups = subject[:groups].map { |group| group[:name] }
+ expect(groups).to include('generic')
+ end
+ end
+ end
end
end
diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb
index c94902dbab8..3964b998084 100644
--- a/spec/serializers/status_entity_spec.rb
+++ b/spec/serializers/status_entity_spec.rb
@@ -18,6 +18,12 @@ describe StatusEntity do
it 'contains status details' do
expect(subject).to include :text, :icon, :favicon, :label, :group
expect(subject).to include :has_details, :details_path
+ expect(subject[:favicon]).to eq('/assets/ci_favicons/favicon_status_success.ico')
+ end
+
+ it 'contains a dev namespaced favicon if dev env' do
+ allow(Rails.env).to receive(:development?) { true }
+ expect(entity.as_json[:favicon]).to eq('/assets/ci_favicons/dev/favicon_status_success.ico')
end
end
end
diff --git a/spec/serializers/user_entity_spec.rb b/spec/serializers/user_entity_spec.rb
index c5d11cbcf5e..cd778e49107 100644
--- a/spec/serializers/user_entity_spec.rb
+++ b/spec/serializers/user_entity_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe UserEntity do
+ include Gitlab::Routing
+
let(:entity) { described_class.new(user) }
let(:user) { create(:user) }
subject { entity.as_json }
@@ -20,4 +22,8 @@ describe UserEntity do
it 'does not expose 2FA OTPs' do
expect(subject).not_to include(/otp/)
end
+
+ it 'exposes user path' do
+ expect(subject[:path]).to eq user_path(user)
+ end
end
diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb
index 87f093ee8ce..38a3f522504 100644
--- a/spec/services/access_token_validation_service_spec.rb
+++ b/spec/services/access_token_validation_service_spec.rb
@@ -1,41 +1,72 @@
require 'spec_helper'
-describe AccessTokenValidationService, services: true do
+describe AccessTokenValidationService do
describe ".include_any_scope?" do
+ let(:request) { double("request") }
+
it "returns true if the required scope is present in the token's scopes" do
token = double("token", scopes: [:api, :read_user])
+ scopes = [:api]
- expect(described_class.new(token).include_any_scope?([:api])).to be(true)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if more than one of the required scopes is present in the token's scopes" do
token = double("token", scopes: [:api, :read_user, :other_scope])
+ scopes = [:api, :other_scope]
- expect(described_class.new(token).include_any_scope?([:api, :other_scope])).to be(true)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if the list of required scopes is an exact match for the token's scopes" do
token = double("token", scopes: [:api, :read_user, :other_scope])
+ scopes = [:api, :read_user, :other_scope]
- expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if the list of required scopes contains all of the token's scopes, in addition to others" do
token = double("token", scopes: [:api, :read_user])
+ scopes = [:api, :read_user, :other_scope]
- expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it 'returns true if the list of required scopes is blank' do
token = double("token", scopes: [])
+ scopes = []
- expect(described_class.new(token).include_any_scope?([])).to be(true)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns false if there are no scopes in common between the required scopes and the token scopes" do
token = double("token", scopes: [:api, :read_user])
+ scopes = [:other_scope]
+
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(false)
+ end
+
+ context "conditions" do
+ it "ignores any scopes whose `if` condition returns false" do
+ token = double("token", scopes: [:api, :read_user])
+ scopes = [API::Scope.new(:api, if: ->(_) { false })]
+
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(false)
+ end
+
+ it "does not ignore scopes whose `if` condition is not set" do
+ token = double("token", scopes: [:api, :read_user])
+ scopes = [API::Scope.new(:api, if: ->(_) { false }), :read_user]
+
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
+ end
+
+ it "does not ignore scopes whose `if` condition returns true" do
+ token = double("token", scopes: [:api, :read_user])
+ scopes = [API::Scope.new(:api, if: ->(_) { true }), API::Scope.new(:read_user, if: ->(_) { false })]
- expect(described_class.new(token).include_any_scope?([:other_scope])).to be(false)
+ expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
+ end
end
end
end
diff --git a/spec/services/after_branch_delete_service_spec.rb b/spec/services/after_branch_delete_service_spec.rb
index 77ca17bc82c..bc9747d1413 100644
--- a/spec/services/after_branch_delete_service_spec.rb
+++ b/spec/services/after_branch_delete_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe AfterBranchDeleteService, services: true do
+describe AfterBranchDeleteService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, user) }
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index e273dfe1552..1c2d0b3e0dc 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Auth::ContainerRegistryAuthenticationService, services: true do
+describe Auth::ContainerRegistryAuthenticationService do
let(:current_project) { nil }
let(:current_user) { nil }
let(:current_params) { {} }
@@ -8,7 +8,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
let(:payload) { JWT.decode(subject[:token], rsa_key).first }
let(:authentication_abilities) do
- [:read_container_image, :create_container_image]
+ [:read_container_image, :create_container_image, :admin_container_image]
end
subject do
@@ -34,7 +34,9 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for changed configuration' do
- before { stub_application_setting(container_registry_token_expire_delay: expire_delay) }
+ before do
+ stub_application_setting(container_registry_token_expire_delay: expire_delay)
+ end
it { expect(expires_at).to be_within(2.seconds).of(Time.now + expire_delay.minutes) }
end
@@ -44,7 +46,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
shared_examples 'an accessible' do
let(:access) do
[{ 'type' => 'repository',
- 'name' => project.path_with_namespace,
+ 'name' => project.full_path,
'actions' => actions }]
end
@@ -57,6 +59,12 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
it { expect(payload).to include('access' => []) }
end
+ shared_examples 'a deletable' do
+ it_behaves_like 'an accessible' do
+ let(:actions) { ['*'] }
+ end
+ end
+
shared_examples 'a pullable' do
it_behaves_like 'an accessible' do
let(:actions) { ['pull'] }
@@ -94,8 +102,8 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
describe '#full_access_token' do
- let(:project) { create(:empty_project) }
- let(:token) { described_class.full_access_token(project.path_with_namespace) }
+ let(:project) { create(:project) }
+ let(:token) { described_class.full_access_token(project.full_path) }
subject { { token: token } }
@@ -110,29 +118,46 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
let(:current_user) { create(:user) }
context 'for private project' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
context 'allow to use scope-less authentication' do
it_behaves_like 'a valid token'
end
context 'allow developer to push images' do
- before { project.team << [current_user, :developer] }
+ before do
+ project.add_developer(current_user)
+ end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push" }
+ { scope: "repository:#{project.full_path}:push" }
end
it_behaves_like 'a pushable'
it_behaves_like 'container repository factory'
end
+ context 'disallow developer to delete images' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+
context 'allow reporter to pull images' do
- before { project.team << [current_user, :reporter] }
+ before do
+ project.add_reporter(current_user)
+ end
context 'when pulling from root level repository' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
it_behaves_like 'a pullable'
@@ -140,11 +165,26 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
end
+ context 'disallow reporter to delete images' do
+ before do
+ project.add_reporter(current_user)
+ end
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+
context 'return a least of privileges' do
- before { project.team << [current_user, :reporter] }
+ before do
+ project.add_reporter(current_user)
+ end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push,pull" }
+ { scope: "repository:#{project.full_path}:push,pull" }
end
it_behaves_like 'a pullable'
@@ -152,10 +192,25 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'disallow guest to pull or push images' do
- before { project.team << [current_user, :guest] }
+ before do
+ project.add_guest(current_user)
+ end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ { scope: "repository:#{project.full_path}:pull,push" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+
+ context 'disallow guest to delete images' do
+ before do
+ project.add_guest(current_user)
+ end
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
end
it_behaves_like 'an inaccessible'
@@ -164,11 +219,11 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for public project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'allow anyone to pull images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
it_behaves_like 'a pullable'
@@ -177,7 +232,16 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'disallow anyone to push images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push" }
+ { scope: "repository:#{project.full_path}:push" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+
+ context 'disallow anyone to delete images' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
end
it_behaves_like 'an inaccessible'
@@ -195,12 +259,12 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for internal project' do
- let(:project) { create(:empty_project, :internal) }
+ let(:project) { create(:project, :internal) }
context 'for internal user' do
context 'allow anyone to pull images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
it_behaves_like 'a pullable'
@@ -209,7 +273,16 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'disallow anyone to push images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push" }
+ { scope: "repository:#{project.full_path}:push" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+
+ context 'disallow anyone to delete images' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
end
it_behaves_like 'an inaccessible'
@@ -218,19 +291,56 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for external user' do
- let(:current_user) { create(:user, external: true) }
- let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ context 'disallow anyone to pull or push images' do
+ let(:current_user) { create(:user, external: true) }
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
end
- it_behaves_like 'an inaccessible'
- it_behaves_like 'not a container repository factory'
+ context 'disallow anyone to delete images' do
+ let(:current_user) { create(:user, external: true) }
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:*" }
+ end
+
+ it_behaves_like 'an inaccessible'
+ it_behaves_like 'not a container repository factory'
+ end
+ end
+ end
+ end
+
+ context 'delete authorized as master' do
+ let(:current_project) { create(:project) }
+ let(:current_user) { create(:user) }
+
+ let(:authentication_abilities) do
+ [:admin_container_image]
+ end
+
+ before do
+ current_project.add_master(current_user)
+ end
+
+ it_behaves_like 'a valid token'
+
+ context 'allow to delete images' do
+ let(:current_params) do
+ { scope: "repository:#{current_project.path_with_namespace}:*" }
+ end
+
+ it_behaves_like 'a deletable' do
+ let(:project) { current_project }
end
end
end
context 'build authorized as user' do
- let(:current_project) { create(:empty_project) }
+ let(:current_project) { create(:project) }
let(:current_user) { create(:user) }
let(:authentication_abilities) do
@@ -238,14 +348,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
before do
- current_project.team << [current_user, :developer]
+ current_project.add_developer(current_user)
end
it_behaves_like 'a valid token'
context 'allow to pull and push images' do
let(:current_params) do
- { scope: "repository:#{current_project.path_with_namespace}:pull,push" }
+ { scope: "repository:#{current_project.full_path}:pull,push" }
end
it_behaves_like 'a pullable and pushable' do
@@ -257,14 +367,24 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
end
+ context 'disallow to delete images' do
+ let(:current_params) do
+ { scope: "repository:#{current_project.path_with_namespace}:*" }
+ end
+
+ it_behaves_like 'an inaccessible' do
+ let(:project) { current_project }
+ end
+ end
+
context 'for other projects' do
context 'when pulling' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
context 'allow for public' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -278,7 +398,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'when you are member' do
before do
- project.team << [current_user, :developer]
+ project.add_developer(current_user)
end
it_behaves_like 'a pullable'
@@ -286,7 +406,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'when you are owner' do
- let(:project) { create(:empty_project, namespace: current_user.namespace) }
+ let(:project) { create(:project, namespace: current_user.namespace) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -294,7 +414,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for private' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
it_behaves_like 'pullable for being team member'
@@ -308,7 +428,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'when you are member' do
before do
- project.team << [current_user, :developer]
+ project.add_developer(current_user)
end
it_behaves_like 'a pullable'
@@ -316,7 +436,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'when you are owner' do
- let(:project) { create(:empty_project, namespace: current_user.namespace) }
+ let(:project) { create(:project, namespace: current_user.namespace) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -327,15 +447,15 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'when pushing' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push" }
+ { scope: "repository:#{project.full_path}:push" }
end
context 'disallow for all' do
context 'when you are member' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
before do
- project.team << [current_user, :developer]
+ project.add_developer(current_user)
end
it_behaves_like 'an inaccessible'
@@ -343,7 +463,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'when you are owner' do
- let(:project) { create(:empty_project, :public, namespace: current_user.namespace) }
+ let(:project) { create(:project, :public, namespace: current_user.namespace) }
it_behaves_like 'an inaccessible'
it_behaves_like 'not a container repository factory'
@@ -353,13 +473,15 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for project without container registry' do
- let(:project) { create(:empty_project, :public, container_registry_enabled: false) }
+ let(:project) { create(:project, :public, container_registry_enabled: false) }
- before { project.update(container_registry_enabled: false) }
+ before do
+ project.update(container_registry_enabled: false)
+ end
context 'disallow when pulling' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
it_behaves_like 'an inaccessible'
@@ -384,21 +506,21 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
end
context 'for private project' do
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull" }
+ { scope: "repository:#{project.full_path}:pull" }
end
it_behaves_like 'a forbidden'
end
context 'for public project' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
context 'when pulling and pushing' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ { scope: "repository:#{project.full_path}:pull,push" }
end
it_behaves_like 'a pullable'
@@ -407,7 +529,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
context 'when pushing' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:push" }
+ { scope: "repository:#{project.full_path}:push" }
end
it_behaves_like 'a forbidden'
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
index a8555f5b4a0..db51a524e79 100644
--- a/spec/services/boards/create_service_spec.rb
+++ b/spec/services/boards/create_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::CreateService, services: true do
+describe Boards::CreateService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject(:service) { described_class.new(project, double) }
@@ -14,8 +14,9 @@ describe Boards::CreateService, services: true do
it 'creates the default lists' do
board = service.execute
- expect(board.lists.size).to eq 1
- expect(board.lists.first).to be_closed
+ expect(board.lists.size).to eq 2
+ expect(board.lists.first).to be_backlog
+ expect(board.lists.last).to be_closed
end
end
@@ -25,6 +26,8 @@ describe Boards::CreateService, services: true do
end
it 'does not create a new board' do
+ expect(service).to receive(:can_create_board?) { false }
+
expect { service.execute }.not_to change(project.boards, :count)
end
end
diff --git a/spec/services/boards/issues/create_service_spec.rb b/spec/services/boards/issues/create_service_spec.rb
index 360ee398f77..f2ddaa903da 100644
--- a/spec/services/boards/issues/create_service_spec.rb
+++ b/spec/services/boards/issues/create_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::Issues::CreateService, services: true do
+describe Boards::Issues::CreateService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') }
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index c982031c791..01ee3856c99 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Boards::Issues::ListService, services: true do
+describe Boards::Issues::ListService do
describe '#execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:bug) { create(:label, project: project, name: 'Bug') }
@@ -13,13 +13,14 @@ describe Boards::Issues::ListService, services: true do
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
+ let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) }
let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) }
- let!(:reopened_issue1) { create(:issue, :reopened, project: project) }
+ let!(:reopened_issue1) { create(:issue, :opened, project: project) }
let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) }
@@ -53,6 +54,14 @@ describe Boards::Issues::ListService, services: true do
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
+ it 'returns opened issues when listing issues from Backlog' do
+ params = { board_id: board.id, id: backlog.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
+ end
+
it 'returns closed issues when listing issues from Closed' do
params = { board_id: board.id, id: closed.id }
diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb
index 4ff7ac6bb2f..63dfe80d672 100644
--- a/spec/services/boards/issues/move_service_spec.rb
+++ b/spec/services/boards/issues/move_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Boards::Issues::MoveService, services: true do
+describe Boards::Issues::MoveService do
describe '#execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board1) { create(:board, project: project) }
let(:bug) { create(:label, project: project, name: 'Bug') }
@@ -73,7 +73,7 @@ describe Boards::Issues::MoveService, services: true do
issue.reload
expect(issue.labels).to contain_exactly(bug, testing)
- expect(issue).to be_reopened
+ expect(issue).to be_opened
end
end
diff --git a/spec/services/boards/list_service_spec.rb b/spec/services/boards/list_service_spec.rb
index dff33e4bcbb..1d0be99fb35 100644
--- a/spec/services/boards/list_service_spec.rb
+++ b/spec/services/boards/list_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::ListService, services: true do
+describe Boards::ListService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
subject(:service) { described_class.new(project, double) }
diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb
index ebac38e68f1..7d0b396cd06 100644
--- a/spec/services/boards/lists/create_service_spec.rb
+++ b/spec/services/boards/lists/create_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::Lists::CreateService, services: true do
+describe Boards::Lists::CreateService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') }
diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb
index af2d7c784bb..bd98625b44f 100644
--- a/spec/services/boards/lists/destroy_service_spec.rb
+++ b/spec/services/boards/lists/destroy_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::Lists::DestroyService, services: true do
+describe Boards::Lists::DestroyService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb
index ed0337662af..592f25059ac 100644
--- a/spec/services/boards/lists/generate_service_spec.rb
+++ b/spec/services/boards/lists/generate_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::Lists::GenerateService, services: true do
+describe Boards::Lists::GenerateService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb
index ab9fb1bc914..b189857e4f4 100644
--- a/spec/services/boards/lists/list_service_spec.rb
+++ b/spec/services/boards/lists/list_service_spec.rb
@@ -1,16 +1,33 @@
require 'spec_helper'
-describe Boards::Lists::ListService, services: true do
+describe Boards::Lists::ListService do
+ let(:project) { create(:project) }
+ let(:board) { create(:board, project: project) }
+ let(:label) { create(:label, project: project) }
+ let!(:list) { create(:list, board: board, label: label) }
+ let(:service) { described_class.new(project, double) }
+
describe '#execute' do
- it "returns board's lists" do
- project = create(:empty_project)
- board = create(:board, project: project)
- label = create(:label, project: project)
- list = create(:list, board: board, label: label)
+ context 'when the board has a backlog list' do
+ let!(:backlog_list) { create(:backlog_list, board: board) }
+
+ it 'does not create a backlog list' do
+ expect { service.execute(board) }.not_to change(board.lists, :count)
+ end
+
+ it "returns board's lists" do
+ expect(service.execute(board)).to eq [backlog_list, list, board.closed_list]
+ end
+ end
- service = described_class.new(project, double)
+ context 'when the board does not have a backlog list' do
+ it 'creates a backlog list' do
+ expect { service.execute(board) }.to change(board.lists, :count).by(1)
+ end
- expect(service.execute(board)).to eq [list, board.closed_list]
+ it "returns board's lists" do
+ expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
+ end
end
end
end
diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb
index 4b3bdd133f2..a9d218bad49 100644
--- a/spec/services/boards/lists/move_service_spec.rb
+++ b/spec/services/boards/lists/move_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Boards::Lists::MoveService, services: true do
+describe Boards::Lists::MoveService do
describe '#execute' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
diff --git a/spec/services/chat_names/authorize_user_service_spec.rb b/spec/services/chat_names/authorize_user_service_spec.rb
index d50bfb0492c..d88b2504133 100644
--- a/spec/services/chat_names/authorize_user_service_spec.rb
+++ b/spec/services/chat_names/authorize_user_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatNames::AuthorizeUserService, services: true do
+describe ChatNames::AuthorizeUserService do
describe '#execute' do
let(:service) { create(:service) }
diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb
index 0dc96521fa8..79aaac3aeb6 100644
--- a/spec/services/chat_names/find_user_service_spec.rb
+++ b/spec/services/chat_names/find_user_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ChatNames::FindUserService, services: true do
+describe ChatNames::FindUserService do
describe '#execute' do
let(:service) { create(:service) }
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index fa5014cee07..53d4fcfed18 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -1,21 +1,28 @@
require 'spec_helper'
-describe Ci::CreatePipelineService, services: true do
+describe Ci::CreatePipelineService do
let(:project) { create(:project, :repository) }
let(:user) { create(:admin) }
+ let(:ref_name) { 'refs/heads/master' }
before do
stub_ci_pipeline_to_return_yaml_file
end
describe '#execute' do
- def execute_service(after: project.commit.id, message: 'Message', ref: 'refs/heads/master')
+ def execute_service(
+ source: :push,
+ after: project.commit.id,
+ message: 'Message',
+ ref: ref_name,
+ trigger_request: nil)
params = { ref: ref,
before: '00000000',
after: after,
commits: [{ message: message }] }
- described_class.new(project, user, params).execute
+ described_class.new(project, user, params).execute(
+ source, trigger_request: trigger_request)
end
context 'valid params' do
@@ -27,12 +34,72 @@ describe Ci::CreatePipelineService, services: true do
)
end
- it { expect(pipeline).to be_kind_of(Ci::Pipeline) }
- it { expect(pipeline).to be_valid }
- 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) }
+ it 'creates a pipeline' do
+ expect(pipeline).to be_kind_of(Ci::Pipeline)
+ expect(pipeline).to be_valid
+ expect(pipeline).to be_persisted
+ expect(pipeline).to be_push
+ expect(pipeline).to eq(project.pipelines.last)
+ expect(pipeline).to have_attributes(user: user)
+ expect(pipeline).to have_attributes(status: 'pending')
+ expect(pipeline.builds.first).to be_kind_of(Ci::Build)
+ end
+
+ it 'increments the prometheus counter' do
+ expect(Gitlab::Metrics).to receive(:counter)
+ .with(:pipelines_created_total, "Counter of pipelines created")
+ .and_call_original
+
+ pipeline
+ end
+
+ context 'when merge requests already exist for this source branch' do
+ it 'updates head pipeline of each merge request' do
+ merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project)
+ merge_request_2 = create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project)
+
+ head_pipeline = pipeline
+
+ expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline)
+ expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline)
+ end
+
+ context 'when there is no pipeline for source branch' do
+ it "does not update merge request head pipeline" do
+ merge_request = create(:merge_request, source_branch: 'feature', target_branch: "branch_1", source_project: project)
+
+ head_pipeline = pipeline
+
+ expect(merge_request.reload.head_pipeline).not_to eq(head_pipeline)
+ end
+ end
+
+ context 'when merge request target project is different from source project' do
+ let!(:target_project) { create(:project, :repository) }
+ let!(:forked_project_link) { create(:forked_project_link, forked_to_project: project, forked_from_project: target_project) }
+
+ it 'updates head pipeline for merge request' do
+ merge_request =
+ create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project, target_project: target_project)
+
+ head_pipeline = pipeline
+
+ expect(merge_request.reload.head_pipeline).to eq(head_pipeline)
+ end
+ end
+
+ context 'when the pipeline is not the latest for the branch' do
+ it 'does not update merge request head pipeline' do
+ merge_request = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project)
+
+ allow_any_instance_of(Ci::Pipeline).to receive(:latest?).and_return(false)
+
+ pipeline
+
+ expect(merge_request.reload.head_pipeline).to be_nil
+ end
+ end
+ end
context 'auto-cancel enabled' do
before do
@@ -245,5 +312,238 @@ describe Ci::CreatePipelineService, services: true do
expect(Environment.find_by(name: "review/master")).not_to be_nil
end
end
+
+ context 'when environment with invalid name' do
+ before do
+ config = YAML.dump(deploy: { environment: { name: 'name,with,commas' }, script: 'ls' })
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'does not create an environment' do
+ expect do
+ result = execute_service
+
+ expect(result).to be_persisted
+ end.not_to change { Environment.count }
+ end
+ end
+
+ context 'when builds with auto-retries are configured' do
+ before do
+ config = YAML.dump(rspec: { script: 'rspec', retry: 2 })
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'correctly creates builds with auto-retry value configured' do
+ pipeline = execute_service
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.builds.find_by(name: 'rspec').retries_max).to eq 2
+ end
+ end
+
+ shared_examples 'when ref is protected' do
+ let(:user) { create(:user) }
+
+ context 'when user is developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not create a pipeline' do
+ expect(execute_service).not_to be_persisted
+ expect(Ci::Pipeline.count).to eq(0)
+ end
+ end
+
+ context 'when user is master' do
+ before do
+ project.add_master(user)
+ end
+
+ it 'creates a pipeline' do
+ expect(execute_service).to be_persisted
+ expect(Ci::Pipeline.count).to eq(1)
+ end
+ end
+
+ context 'when trigger belongs to no one' do
+ let(:user) {}
+ let(:trigger_request) { create(:ci_trigger_request) }
+
+ it 'does not create a pipeline' do
+ expect(execute_service(trigger_request: trigger_request))
+ .not_to be_persisted
+ expect(Ci::Pipeline.count).to eq(0)
+ end
+ end
+
+ context 'when trigger belongs to a developer' do
+ let(:user) {}
+
+ let(:trigger_request) do
+ create(:ci_trigger_request).tap do |request|
+ user = create(:user)
+ project.add_developer(user)
+ request.trigger.update(owner: user)
+ end
+ end
+
+ it 'does not create a pipeline' do
+ expect(execute_service(trigger_request: trigger_request))
+ .not_to be_persisted
+ expect(Ci::Pipeline.count).to eq(0)
+ end
+ end
+
+ context 'when trigger belongs to a master' do
+ let(:user) {}
+
+ let(:trigger_request) do
+ create(:ci_trigger_request).tap do |request|
+ user = create(:user)
+ project.add_master(user)
+ request.trigger.update(owner: user)
+ end
+ end
+
+ it 'does not create a pipeline' do
+ expect(execute_service(trigger_request: trigger_request))
+ .to be_persisted
+ expect(Ci::Pipeline.count).to eq(1)
+ end
+ end
+ end
+
+ context 'when ref is a protected branch' do
+ before do
+ create(:protected_branch, project: project, name: 'master')
+ end
+
+ it_behaves_like 'when ref is protected'
+ end
+
+ context 'when ref is a protected tag' do
+ let(:ref_name) { 'refs/tags/v1.0.0' }
+
+ before do
+ create(:protected_tag, project: project, name: '*')
+ end
+
+ it_behaves_like 'when ref is protected'
+ end
+
+ context 'when ref is not protected' do
+ context 'when trigger belongs to no one' do
+ let(:user) {}
+ let(:trigger_request) { create(:ci_trigger_request) }
+
+ it 'creates a pipeline' do
+ expect(execute_service(trigger_request: trigger_request))
+ .to be_persisted
+ expect(Ci::Pipeline.count).to eq(1)
+ end
+ end
+ end
+ end
+
+ describe '#allowed_to_create?' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:ref) { 'master' }
+
+ subject do
+ described_class.new(project, user, ref: ref)
+ .send(:allowed_to_create?, user)
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when the branch is protected' do
+ let!(:protected_branch) do
+ create(:protected_branch, project: project, name: ref)
+ end
+
+ it { is_expected.to be_falsey }
+
+ context 'when developers are allowed to merge' do
+ let!(:protected_branch) do
+ create(:protected_branch,
+ :developers_can_merge,
+ project: project,
+ name: ref)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'when the tag is protected' do
+ let(:ref) { 'v1.0.0' }
+
+ let!(:protected_tag) do
+ create(:protected_tag, project: project, name: ref)
+ end
+
+ it { is_expected.to be_falsey }
+
+ context 'when developers are allowed to create the tag' do
+ let!(:protected_tag) do
+ create(:protected_tag,
+ :developers_can_create,
+ project: project,
+ name: ref)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+
+ context 'when user is a master' do
+ before do
+ project.add_master(user)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when the branch is protected' do
+ let!(:protected_branch) do
+ create(:protected_branch, project: project, name: ref)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when the tag is protected' do
+ let(:ref) { 'v1.0.0' }
+
+ let!(:protected_tag) do
+ create(:protected_tag, project: project, name: ref)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when no one can create the tag' do
+ let!(:protected_tag) do
+ create(:protected_tag,
+ :no_one_can_create,
+ project: project,
+ name: ref)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+ end
+
+ context 'when owner cannot create pipeline' do
+ it { is_expected.to be_falsey }
+ end
end
end
diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb
index 5a20102872a..8295813a1ca 100644
--- a/spec/services/ci/create_trigger_request_service_spec.rb
+++ b/spec/services/ci/create_trigger_request_service_spec.rb
@@ -1,12 +1,15 @@
require 'spec_helper'
-describe Ci::CreateTriggerRequestService, services: true do
- let(:service) { described_class.new }
+describe Ci::CreateTriggerRequestService do
+ let(:service) { described_class }
let(:project) { create(:project, :repository) }
- let(:trigger) { create(:ci_trigger, project: project) }
+ let(:trigger) { create(:ci_trigger, project: project, owner: owner) }
+ let(:owner) { create(:user) }
before do
stub_ci_pipeline_to_return_yaml_file
+
+ project.add_developer(owner)
end
describe '#execute' do
@@ -14,27 +17,26 @@ describe Ci::CreateTriggerRequestService, services: true do
subject { service.execute(project, trigger, 'master') }
context 'without owner' do
- it { expect(subject).to be_kind_of(Ci::TriggerRequest) }
+ it { expect(subject.trigger_request).to be_kind_of(Ci::TriggerRequest) }
+ it { expect(subject.trigger_request.builds.first).to be_kind_of(Ci::Build) }
it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) }
- it { expect(subject.builds.first).to be_kind_of(Ci::Build) }
+ it { expect(subject.pipeline).to be_trigger }
end
context 'with owner' do
- let(:owner) { create(:user) }
- let(:trigger) { create(:ci_trigger, project: project, owner: owner) }
-
- it { expect(subject).to be_kind_of(Ci::TriggerRequest) }
+ it { expect(subject.trigger_request).to be_kind_of(Ci::TriggerRequest) }
+ it { expect(subject.trigger_request.builds.first).to be_kind_of(Ci::Build) }
+ it { expect(subject.trigger_request.builds.first.user).to eq(owner) }
it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) }
+ it { expect(subject.pipeline).to be_trigger }
it { expect(subject.pipeline.user).to eq(owner) }
- it { expect(subject.builds.first).to be_kind_of(Ci::Build) }
- it { expect(subject.builds.first.user).to eq(owner) }
end
end
context 'no commit for ref' do
subject { service.execute(project, trigger, 'other-branch') }
- it { expect(subject).to be_nil }
+ it { expect(subject.pipeline).not_to be_persisted }
end
context 'no builds created' do
@@ -44,7 +46,7 @@ describe Ci::CreateTriggerRequestService, services: true do
stub_ci_pipeline_yaml_file('script: { only: [develop], script: hello World }')
end
- it { expect(subject).to be_nil }
+ it { expect(subject.pipeline).not_to be_persisted }
end
end
end
diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb
new file mode 100644
index 00000000000..9a6875e448c
--- /dev/null
+++ b/spec/services/ci/pipeline_trigger_service_spec.rb
@@ -0,0 +1,83 @@
+require 'spec_helper'
+
+describe Ci::PipelineTriggerService do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+ end
+
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:trigger) { create(:ci_trigger, project: project, owner: user) }
+ let(:result) { described_class.new(project, user, params).execute }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when trigger belongs to a different project' do
+ let(:params) { { token: trigger.token, ref: 'master', variables: nil } }
+ let(:trigger) { create(:ci_trigger, project: create(:project), owner: user) }
+
+ it 'does nothing' do
+ expect { result }.not_to change { Ci::Pipeline.count }
+ end
+ end
+
+ context 'when params have an existsed trigger token' do
+ context 'when params have an existsed ref' do
+ let(:params) { { token: trigger.token, ref: 'master', variables: nil } }
+
+ it 'triggers a pipeline' do
+ expect { result }.to change { Ci::Pipeline.count }.by(1)
+ expect(result[:pipeline].ref).to eq('master')
+ expect(result[:pipeline].project).to eq(project)
+ expect(result[:pipeline].user).to eq(trigger.owner)
+ expect(result[:status]).to eq(:success)
+ end
+
+ context 'when commit message has [ci skip]' do
+ before do
+ allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { '[ci skip]' }
+ end
+
+ it 'ignores [ci skip] and create as general' do
+ expect { result }.to change { Ci::Pipeline.count }.by(1)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'when params have a variable' do
+ let(:params) { { token: trigger.token, ref: 'master', variables: variables } }
+ let(:variables) { { 'AAA' => 'AAA123' } }
+
+ it 'has a variable' do
+ expect { result }.to change { Ci::PipelineVariable.count }.by(1)
+ .and change { Ci::TriggerRequest.count }.by(1)
+ expect(result[:pipeline].variables.map { |v| { v.key => v.value } }.first).to eq(variables)
+ expect(result[:pipeline].trigger_requests.last.variables).to be_nil
+ end
+ end
+ end
+
+ context 'when params have a non-existsed ref' do
+ let(:params) { { token: trigger.token, ref: 'invalid-ref', variables: nil } }
+
+ it 'does not trigger a pipeline' do
+ expect { result }.not_to change { Ci::Pipeline.count }
+ expect(result[:http_status]).to eq(400)
+ end
+ end
+ end
+
+ context 'when params have a non-existsed trigger token' do
+ let(:params) { { token: 'invalid-token', ref: nil, variables: nil } }
+
+ it 'does not trigger a pipeline' do
+ expect { result }.not_to change { Ci::Pipeline.count }
+ expect(result).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb
new file mode 100644
index 00000000000..330ec81e87d
--- /dev/null
+++ b/spec/services/ci/play_build_service_spec.rb
@@ -0,0 +1,114 @@
+require 'spec_helper'
+
+describe Ci::PlayBuildService, '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
+
+ let(:service) do
+ described_class.new(project, user)
+ end
+
+ context 'when project does not have repository yet' do
+ let(:project) { create(:project) }
+
+ it 'allows user to play build if protected branch rules are met' do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+
+ service.execute(build)
+
+ expect(build.reload).to be_pending
+ end
+
+ it 'does not allow user with developer role to play build' do
+ project.add_developer(user)
+
+ expect { service.execute(build) }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+
+ context 'when project has repository' do
+ let(:project) { create(:project, :repository) }
+
+ it 'allows user with developer role to play a build' do
+ project.add_developer(user)
+
+ service.execute(build)
+
+ expect(build.reload).to be_pending
+ end
+ end
+
+ context 'when build is a playable manual action' do
+ let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
+
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+ end
+
+ it 'enqueues the build' do
+ expect(service.execute(build)).to eq build
+ expect(build.reload).to be_pending
+ end
+
+ it 'reassignes build user correctly' do
+ service.execute(build)
+
+ expect(build.reload.user).to eq user
+ end
+ end
+
+ context 'when build is not a playable manual action' do
+ let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) }
+
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: build.ref, project: project)
+ end
+
+ it 'duplicates the build' do
+ duplicate = service.execute(build)
+
+ expect(duplicate).not_to eq build
+ expect(duplicate).to be_pending
+ end
+
+ it 'assigns users correctly' do
+ duplicate = service.execute(build)
+
+ expect(build.user).not_to eq user
+ expect(duplicate.user).to eq user
+ end
+ end
+
+ context 'when build is not action' do
+ let(:build) { create(:ci_build, :success, pipeline: pipeline) }
+
+ it 'raises an error' do
+ expect { service.execute(build) }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+
+ context 'when user does not have ability to trigger action' do
+ before do
+ create(:protected_branch, :no_one_can_push,
+ name: build.ref, project: project)
+ end
+
+ it 'raises an error' do
+ expect { service.execute(build) }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+end
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index 245e19822f3..214adc9960f 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -1,14 +1,16 @@
require 'spec_helper'
-describe Ci::ProcessPipelineService, '#execute', :services do
+describe Ci::ProcessPipelineService, '#execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) do
create(:ci_empty_pipeline, ref: 'master', project: project)
end
before do
+ stub_not_protect_default_branch
+
project.add_developer(user)
end
@@ -62,6 +64,10 @@ describe Ci::ProcessPipelineService, '#execute', :services do
fail_running_or_pending
expect(builds_statuses).to eq %w(failed pending)
+
+ fail_running_or_pending
+
+ expect(pipeline.reload).to be_success
end
end
@@ -268,6 +274,24 @@ describe Ci::ProcessPipelineService, '#execute', :services do
end
end
+ context 'when there are only manual actions in stages' do
+ before do
+ create_build('image', stage_idx: 0, when: 'manual', allow_failure: true)
+ create_build('build', stage_idx: 1, when: 'manual', allow_failure: true)
+ create_build('deploy', stage_idx: 2, when: 'manual')
+ create_build('check', stage_idx: 3)
+
+ process_pipeline
+ end
+
+ it 'processes all jobs until blocking actions encountered' do
+ expect(all_builds_statuses).to eq(%w[manual manual manual created])
+ expect(all_builds_names).to eq(%w[image build deploy check])
+
+ expect(pipeline.reload).to be_blocked
+ end
+ end
+
context 'when blocking manual actions are defined' do
before do
create_build('code:test', stage_idx: 0)
@@ -314,6 +338,14 @@ describe Ci::ProcessPipelineService, '#execute', :services do
end
context 'when pipeline is promoted sequentially up to the end' do
+ before do
+ # Users need ability to merge into a branch in order to trigger
+ # protected manual actions.
+ #
+ create(:protected_branch, :developers_can_merge,
+ name: 'master', project: project)
+ end
+
it 'properly processes entire pipeline' do
process_pipeline
@@ -418,6 +450,50 @@ describe Ci::ProcessPipelineService, '#execute', :services do
end
end
+ context 'updates a list of retried builds' do
+ subject { described_class.retried.order(:id) }
+
+ let!(:build_retried) { create_build('build') }
+ let!(:build) { create_build('build') }
+ let!(:test) { create_build('test') }
+
+ it 'returns unique statuses' do
+ process_pipeline
+
+ expect(all_builds.latest).to contain_exactly(build, test)
+ expect(all_builds.retried).to contain_exactly(build_retried)
+ end
+ end
+
+ context 'when builds with auto-retries are configured' do
+ before do
+ create_build('build:1', stage_idx: 0, user: user, options: { retry: 2 })
+ create_build('test:1', stage_idx: 1, user: user, when: :on_failure)
+ create_build('test:2', stage_idx: 1, user: user, options: { retry: 1 })
+ end
+
+ it 'automatically retries builds in a valid order' do
+ expect(process_pipeline).to be_truthy
+
+ fail_running_or_pending
+
+ expect(builds_names).to eq %w[build:1 build:1]
+ expect(builds_statuses).to eq %w[failed pending]
+
+ succeed_running_or_pending
+
+ expect(builds_names).to eq %w[build:1 build:1 test:2]
+ expect(builds_statuses).to eq %w[failed success pending]
+
+ succeed_running_or_pending
+
+ expect(builds_names).to eq %w[build:1 build:1 test:2]
+ expect(builds_statuses).to eq %w[failed success success]
+
+ expect(pipeline.reload).to be_success
+ end
+ end
+
def process_pipeline
described_class.new(pipeline.project, user).execute(pipeline)
end
@@ -434,6 +510,10 @@ describe Ci::ProcessPipelineService, '#execute', :services do
builds.pluck(:name)
end
+ def all_builds_names
+ all_builds.pluck(:name)
+ end
+
def builds_statuses
builds.pluck(:status)
end
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 62ba0b01339..8eb0d2d10a4 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
module Ci
- describe RegisterJobService, services: true do
- let!(:project) { FactoryGirl.create :empty_project, shared_runners_enabled: false }
+ describe RegisterJobService do
+ let!(:project) { FactoryGirl.create :project, shared_runners_enabled: false }
let!(:pipeline) { FactoryGirl.create :ci_pipeline, project: project }
let!(:pending_build) { FactoryGirl.create :ci_build, pipeline: pipeline }
let!(:shared_runner) { FactoryGirl.create(:ci_runner, is_shared: true) }
@@ -72,9 +72,9 @@ module Ci
end
context 'for multiple builds' do
- let!(:project2) { create :empty_project, shared_runners_enabled: true }
+ let!(:project2) { create :project, shared_runners_enabled: true }
let!(:pipeline2) { create :ci_pipeline, project: project2 }
- let!(:project3) { create :empty_project, shared_runners_enabled: true }
+ let!(:project3) { create :project, shared_runners_enabled: true }
let!(:pipeline3) { create :ci_pipeline, project: project3 }
let!(:build1_project1) { pending_build }
let!(:build2_project1) { FactoryGirl.create :ci_build, pipeline: pipeline }
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index b2d37657770..cec667071cc 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Ci::RetryBuildService, :services do
+describe Ci::RetryBuildService do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
@@ -22,15 +22,27 @@ describe Ci::RetryBuildService, :services do
%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 auto_canceled_by_id].freeze
+ user_id auto_canceled_by_id retried].freeze
shared_examples 'build duplication' do
+ let(:stage) do
+ # TODO, we still do not have factory for new stages, we will need to
+ # switch existing factory to persist stages, instead of using LegacyStage
+ #
+ Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test')
+ end
+
let(:build) do
create(:ci_build, :failed, :artifacts_expired, :erased,
:queued, :coverage, :tags, :allowed_to_fail, :on_tag,
- :teardown_environment, :triggered, :trace,
- description: 'some build', pipeline: pipeline,
- auto_canceled_by: create(:ci_empty_pipeline))
+ :triggered, :trace, :teardown_environment,
+ description: 'my-job', stage: 'test', pipeline: pipeline,
+ auto_canceled_by: create(:ci_empty_pipeline)) do |build|
+ ##
+ # TODO, workaround for FactoryGirl limitation when having both
+ # stage (text) and stage_id (integer) columns in the table.
+ build.stage_id = stage.id
+ end
end
describe 'clone accessors' do
@@ -73,6 +85,8 @@ describe Ci::RetryBuildService, :services do
context 'when user has ability to execute build' do
before do
+ stub_not_protect_default_branch
+
project.add_developer(user)
end
@@ -115,10 +129,12 @@ describe Ci::RetryBuildService, :services do
end
describe '#reprocess' do
- let(:new_build) { service.reprocess(build) }
+ let(:new_build) { service.reprocess!(build) }
context 'when user has ability to execute build' do
before do
+ stub_not_protect_default_branch
+
project.add_developer(user)
end
@@ -131,11 +147,16 @@ describe Ci::RetryBuildService, :services do
it 'does not enqueue the new build' do
expect(new_build).to be_created
end
+
+ it 'does mark old build as retried' do
+ expect(new_build).to be_latest
+ expect(build.reload).to be_retried
+ end
end
context 'when user does not have ability to execute build' do
it 'raises an error' do
- expect { service.reprocess(build) }
+ expect { service.reprocess!(build) }
.to raise_error Gitlab::Access::AccessDeniedError
end
end
diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb
index f1b2d3a4798..6ce75c65c8c 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -1,17 +1,22 @@
require 'spec_helper'
-describe Ci::RetryPipelineService, '#execute', :services do
+describe Ci::RetryPipelineService, '#execute' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:service) { described_class.new(project, user) }
- context 'when user has ability to modify pipeline' do
- let(:user) { create(:admin) }
+ context 'when user has full ability to modify pipeline' do
+ before do
+ project.add_developer(user)
+
+ create(:protected_branch, :developers_can_merge,
+ name: pipeline.ref, project: project)
+ end
context 'when there are already retried jobs present' do
before do
- create_build('rspec', :canceled, 0)
+ create_build('rspec', :canceled, 0, retried: true)
create_build('rspec', :failed, 0)
end
@@ -227,6 +232,38 @@ describe Ci::RetryPipelineService, '#execute', :services do
end
end
+ context 'when user is not allowed to trigger manual action' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when there is a failed manual action present' do
+ before do
+ create_build('test', :failed, 0)
+ create_build('deploy', :failed, 0, when: :manual)
+ create_build('verify', :canceled, 1)
+ end
+
+ it 'raises an error' do
+ expect { service.execute(pipeline) }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+
+ context 'when there is a failed manual action in later stage' do
+ before do
+ create_build('test', :failed, 0)
+ create_build('deploy', :failed, 1, when: :manual)
+ create_build('verify', :canceled, 2)
+ end
+
+ it 'raises an error' do
+ expect { service.execute(pipeline) }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+ end
+
def statuses
pipeline.reload.statuses
end
diff --git a/spec/services/ci/stop_environments_service_spec.rb b/spec/services/ci/stop_environments_service_spec.rb
index 32c72a9cf5e..e2a9ed27e87 100644
--- a/spec/services/ci/stop_environments_service_spec.rb
+++ b/spec/services/ci/stop_environments_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::StopEnvironmentsService, services: true do
+describe Ci::StopEnvironmentsService do
let(:project) { create(:project, :private, :repository) }
let(:user) { create(:user) }
@@ -55,8 +55,22 @@ describe Ci::StopEnvironmentsService, services: true do
end
context 'when user does not have permission to stop environment' do
+ context 'when user has no access to manage deployments' do
+ before do
+ project.team << [user, :guest]
+ end
+
+ it 'does not stop environment' do
+ expect_environment_not_stopped_on('master')
+ end
+ end
+ end
+
+ context 'when branch for stop action is protected' do
before do
- project.team << [user, :guest]
+ project.add_developer(user)
+ create(:protected_branch, :no_one_can_push,
+ name: 'master', project: project)
end
it 'does not stop environment' do
diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb
index c44e6b2a48b..0da0e57dbcd 100644
--- a/spec/services/ci/update_build_queue_service_spec.rb
+++ b/spec/services/ci/update_build_queue_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::UpdateBuildQueueService, :services do
+describe Ci::UpdateBuildQueueService do
let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, pipeline: pipeline) }
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -9,7 +9,9 @@ describe Ci::UpdateBuildQueueService, :services do
let(:runner) { create(:ci_runner) }
context 'when there are runner that can pick build' do
- before { build.project.runners << runner }
+ before do
+ build.project.runners << runner
+ end
it 'ticks runner queue value' do
expect { subject.execute(build) }
@@ -36,7 +38,9 @@ describe Ci::UpdateBuildQueueService, :services do
end
context 'when there are no runners that can pick build' do
- before { build.tag_list = [:docker] }
+ before do
+ build.tag_list = [:docker]
+ end
it 'does not tick runner queue value' do
expect { subject.execute(build) }
diff --git a/spec/services/ci/update_runner_service_spec.rb b/spec/services/ci/update_runner_service_spec.rb
index e429fcfc72f..7cc04c92d27 100644
--- a/spec/services/ci/update_runner_service_spec.rb
+++ b/spec/services/ci/update_runner_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Ci::UpdateRunnerService, :services do
+describe Ci::UpdateRunnerService do
let(:runner) { create(:ci_runner) }
describe '#update' do
diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb
index 1e99442fdcb..77595d7ba2d 100644
--- a/spec/services/cohorts_service_spec.rb
+++ b/spec/services/cohorts_service_spec.rb
@@ -89,7 +89,7 @@ describe CohortsService do
activity_months: [{ total: 2, percentage: 100 }],
total: 2,
inactive: 1
- },
+ }
]
expect(described_class.new.execute).to eq(months_included: 12,
diff --git a/spec/services/compare_service_spec.rb b/spec/services/compare_service_spec.rb
index bea7c965233..9e15eae8c13 100644
--- a/spec/services/compare_service_spec.rb
+++ b/spec/services/compare_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CompareService, services: true do
+describe CompareService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, 'feature') }
diff --git a/spec/services/create_branch_service_spec.rb b/spec/services/create_branch_service_spec.rb
index 3f548688c20..38096a080a7 100644
--- a/spec/services/create_branch_service_spec.rb
+++ b/spec/services/create_branch_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CreateBranchService, services: true do
+describe CreateBranchService do
let(:user) { create(:user) }
let(:service) { described_class.new(project, user) }
diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb
index a883705bd45..08267d6e6a0 100644
--- a/spec/services/create_deployment_service_spec.rb
+++ b/spec/services/create_deployment_service_spec.rb
@@ -1,195 +1,207 @@
require 'spec_helper'
-describe CreateDeploymentService, services: true do
- let(:project) { create(:empty_project) }
+describe CreateDeploymentService do
let(:user) { create(:user) }
+ let(:options) { nil }
+
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ tag: false,
+ environment: 'production',
+ options: { environment: options })
+ end
- let(:service) { described_class.new(project, user, params) }
+ let(:project) { job.project }
- describe '#execute' do
- let(:options) { nil }
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: options
- }
- end
+ let!(:environment) do
+ create(:environment, project: project, name: 'production')
+ end
- subject { service.execute }
+ let(:service) { described_class.new(job) }
- context 'when no environments exist' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
+ before do
+ allow_any_instance_of(Deployment).to receive(:create_ref)
+ end
- it 'does create a deployment' do
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'when environment exists' do
+ it 'creates a deployment' do
expect(subject).to be_persisted
end
end
- context 'when environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'production') }
-
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ context 'when environment does not exist' do
+ let(:environment) {}
- it 'does create a deployment' do
- expect(subject).to be_persisted
+ it 'does not create a deployment' do
+ expect do
+ expect(subject).to be_nil
+ end.not_to change { Deployment.count }
end
+ end
- context 'and start action is defined' do
- let(:options) { { action: 'start' } }
+ context 'when start action is defined' do
+ let(:options) { { action: 'start' } }
- context 'and environment is stopped' do
- before do
- environment.stop
- end
+ context 'and environment is stopped' do
+ before do
+ environment.stop
+ end
- it 'makes environment available' do
- subject
+ it 'makes environment available' do
+ subject
- expect(environment.reload).to be_available
- end
+ expect(environment.reload).to be_available
+ end
- it 'does create a deployment' do
- expect(subject).to be_persisted
- end
+ it 'creates a deployment' do
+ expect(subject).to be_persisted
end
end
+ end
- context 'and stop action is defined' do
- let(:options) { { action: 'stop' } }
+ context 'when stop action is defined' do
+ let(:options) { { action: 'stop' } }
- context 'and environment is available' do
- before do
- environment.start
- end
+ context 'and environment is available' do
+ before do
+ environment.start
+ end
- it 'makes environment stopped' do
- subject
+ it 'makes environment stopped' do
+ subject
- expect(environment.reload).to be_stopped
- end
+ expect(environment.reload).to be_stopped
+ end
- it 'does not create a deployment' do
- expect(subject).to be_nil
- end
+ it 'does not create a deployment' do
+ expect(subject).to be_nil
end
end
end
- context 'for environment with invalid name' do
- let(:params) do
- {
- environment: 'name,with,commas',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142'
- }
+ context 'when variables are used' do
+ let(:options) do
+ { name: 'review-apps/$CI_COMMIT_REF_NAME',
+ url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' }
+ end
+
+ before do
+ environment.update(name: 'review-apps/master')
+ job.update(environment: 'review-apps/$CI_COMMIT_REF_NAME')
+ end
+
+ it 'creates a new deployment' do
+ expect(subject).to be_persisted
end
it 'does not create a new environment' do
expect { subject }.not_to change { Environment.count }
end
- it 'does not create a deployment' do
- expect(subject).to be_nil
+ it 'updates external url' do
+ subject
+
+ expect(subject.environment.name).to eq('review-apps/master')
+ expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com')
end
end
- context 'when variables are used' do
- let(:params) do
- {
- environment: 'review-apps/$CI_COMMIT_REF_NAME',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: {
- name: 'review-apps/$CI_COMMIT_REF_NAME',
- url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com'
- },
- variables: [
- { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' }
- ]
- }
+ context 'when project was removed' do
+ let(:environment) {}
+
+ before do
+ job.update(project: nil)
end
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
+ it 'does not create deployment or environment' do
+ expect { subject }.not_to raise_error
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
+ expect(Environment.count).to be_zero
+ expect(Deployment.count).to be_zero
end
+ end
+ end
- it 'does create a new deployment' do
- expect(subject).to be_persisted
+ describe '#expanded_environment_url' do
+ subject { service.send(:expanded_environment_url) }
+
+ context 'when yaml environment uses $CI_COMMIT_REF_NAME' do
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ options: { environment: { url: 'http://review/$CI_COMMIT_REF_NAME' } })
end
- context 'and environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'review-apps/feature-review-apps') }
+ it { is_expected.to eq('http://review/master') }
+ end
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ context 'when yaml environment uses $CI_ENVIRONMENT_SLUG' do
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ environment: 'production',
+ options: { environment: { url: 'http://review/$CI_ENVIRONMENT_SLUG' } })
+ end
- it 'updates external url' do
- subject
+ let!(:environment) do
+ create(:environment,
+ project: job.project,
+ name: 'production',
+ slug: 'prod-slug',
+ external_url: 'http://review/old')
+ end
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
- end
+ it { is_expected.to eq('http://review/prod-slug') }
+ end
- it 'does create a new deployment' do
- expect(subject).to be_persisted
- end
+ context 'when yaml environment uses yaml_variables containing symbol keys' do
+ let(:job) do
+ create(:ci_build,
+ yaml_variables: [{ key: :APP_HOST, value: 'host' }],
+ options: { environment: { url: 'http://review/$APP_HOST' } })
end
+
+ it { is_expected.to eq('http://review/host') }
end
- context 'when project was removed' do
- let(:project) { nil }
+ context 'when yaml environment does not have url' do
+ let(:job) { create(:ci_build, environment: 'staging') }
- it 'does not create deployment or environment' do
- expect { subject }.not_to raise_error
+ let!(:environment) do
+ create(:environment, project: job.project, name: job.environment)
+ end
- expect(Environment.count).to be_zero
- expect(Deployment.count).to be_zero
+ it 'returns the external_url from persisted environment' do
+ is_expected.to be_nil
end
end
end
describe 'processing of builds' do
- let(:environment) { nil }
-
- shared_examples 'does not create environment and deployment' do
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
-
+ shared_examples 'does not create deployment' do
it 'does not create a new deployment' do
expect { subject }.not_to change { Deployment.count }
end
it 'does not call a service' do
expect_any_instance_of(described_class).not_to receive(:execute)
+
subject
end
end
- shared_examples 'does create environment and deployment' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
-
- it 'does create a new deployment' do
+ shared_examples 'creates deployment' do
+ it 'creates a new deployment' do
expect { subject }.to change { Deployment.count }.by(1)
end
- it 'does call a service' do
+ it 'calls a service' do
expect_any_instance_of(described_class).to receive(:execute)
+
subject
end
@@ -199,7 +211,7 @@ describe CreateDeploymentService, services: true do
expect(Deployment.last.deployable).to eq(deployable)
end
- it 'create environment has URL set' do
+ it 'updates environment URL' do
subject
expect(Deployment.last.environment.external_url).not_to be_nil
@@ -207,41 +219,41 @@ describe CreateDeploymentService, services: true do
end
context 'without environment specified' do
- let(:build) { create(:ci_build, project: project) }
+ let(:job) { create(:ci_build) }
- it_behaves_like 'does not create environment and deployment' do
- subject { build.success }
+ it_behaves_like 'does not create deployment' do
+ subject { job.success }
end
end
context 'when environment is specified' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) }
+ let(:deployable) { job }
+
let(:options) do
{ environment: { name: 'production', url: 'http://gitlab.com' } }
end
- context 'when build succeeds' do
- it_behaves_like 'does create environment and deployment' do
- let(:deployable) { build }
-
- subject { build.success }
+ context 'when job succeeds' do
+ it_behaves_like 'creates deployment' do
+ subject { job.success }
end
end
- context 'when build fails' do
- it_behaves_like 'does not create environment and deployment' do
- subject { build.drop }
+ context 'when job fails' do
+ it_behaves_like 'does not create deployment' do
+ subject { job.drop }
end
end
- context 'when build is retried' do
- it_behaves_like 'does create environment and deployment' do
+ context 'when job is retried' do
+ it_behaves_like 'creates deployment' do
before do
+ stub_not_protect_default_branch
+
project.add_developer(user)
end
- let(:deployable) { Ci::Build.retry(build, user) }
+ let(:deployable) { Ci::Build.retry(job, user) }
subject { deployable.success }
end
@@ -250,19 +262,12 @@ describe CreateDeploymentService, services: true do
end
describe "merge request metrics" do
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142b',
- }
- end
-
let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) }
context "while updating the 'first_deployed_to_production_at' time" do
- before { merge_request.mark_as_merged }
+ before do
+ merge_request.mark_as_merged
+ end
context "for merge requests merged before the current deploy" do
it "sets the time if the deploy's environment is 'production'" do
@@ -273,8 +278,8 @@ describe CreateDeploymentService, services: true do
end
it "doesn't set the time if the deploy's environment is not 'production'" do
- staging_params = params.merge(environment: 'staging')
- service = described_class.new(project, user, staging_params)
+ job.update(environment: 'staging')
+ service = described_class.new(job)
service.execute
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
@@ -298,7 +303,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
@@ -318,7 +323,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
diff --git a/spec/services/create_release_service_spec.rb b/spec/services/create_release_service_spec.rb
index 271ccfe7968..ac0a0458f56 100644
--- a/spec/services/create_release_service_spec.rb
+++ b/spec/services/create_release_service_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe CreateReleaseService, services: true do
+describe CreateReleaseService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:tag_name) { project.repository.tag_names.first }
let(:description) { 'Awesome release!' }
- let(:service) { CreateReleaseService.new(project, user) }
+ let(:service) { described_class.new(project, user) }
it 'creates a new release' do
result = service.execute(tag_name, description)
diff --git a/spec/services/create_snippet_service_spec.rb b/spec/services/create_snippet_service_spec.rb
index d81d0fd76c9..b6ab6b8271c 100644
--- a/spec/services/create_snippet_service_spec.rb
+++ b/spec/services/create_snippet_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe CreateSnippetService, services: true do
+describe CreateSnippetService do
before do
@user = create :user
@admin = create :user, admin: true
diff --git a/spec/services/delete_branch_service_spec.rb b/spec/services/delete_branch_service_spec.rb
index c4685c9aa31..19855c9bee2 100644
--- a/spec/services/delete_branch_service_spec.rb
+++ b/spec/services/delete_branch_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe DeleteBranchService, services: true do
+describe DeleteBranchService do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
diff --git a/spec/services/delete_merged_branches_service_spec.rb b/spec/services/delete_merged_branches_service_spec.rb
index 7b921f606f8..03c682ae0d7 100644
--- a/spec/services/delete_merged_branches_service_spec.rb
+++ b/spec/services/delete_merged_branches_service_spec.rb
@@ -1,38 +1,43 @@
require 'spec_helper'
-describe DeleteMergedBranchesService, services: true do
+describe DeleteMergedBranchesService do
subject(:service) { described_class.new(project, project.owner) }
let(:project) { create(:project, :repository) }
context '#execute' do
- context 'unprotected branches' do
- before do
- service.execute
- end
+ it 'deletes a branch that was merged' do
+ service.execute
- it 'deletes a branch that was merged' do
- expect(project.repository.branch_names).not_to include('improve/awesome')
- end
+ expect(project.repository.branch_names).not_to include('improve/awesome')
+ end
- it 'keeps branch that is unmerged' do
- expect(project.repository.branch_names).to include('feature')
- end
+ it 'keeps branch that is unmerged' do
+ service.execute
- it 'keeps "master"' do
- expect(project.repository.branch_names).to include('master')
- end
+ expect(project.repository.branch_names).to include('feature')
end
- context 'protected branches' do
- before do
- create(:protected_branch, name: 'improve/awesome', project: project)
- service.execute
- end
+ it 'keeps "master"' do
+ service.execute
- it 'keeps protected branch' do
- expect(project.repository.branch_names).to include('improve/awesome')
- end
+ expect(project.repository.branch_names).to include('master')
+ end
+
+ it 'keeps protected branches' do
+ create(:protected_branch, project: project, name: 'improve/awesome')
+
+ service.execute
+
+ expect(project.repository.branch_names).to include('improve/awesome')
+ end
+
+ it 'keeps wildcard protected branches' do
+ create(:protected_branch, project: project, name: 'improve/*')
+
+ service.execute
+
+ expect(project.repository.branch_names).to include('improve/awesome')
end
context 'user without rights' do
@@ -46,7 +51,7 @@ describe DeleteMergedBranchesService, services: true do
context 'open merge requests' do
it 'does not delete branches from open merge requests' do
fork_link = create(:forked_project_link, forked_from_project: project)
- create(:merge_request, :reopened, source_project: project, target_project: project, source_branch: 'branch-merged', target_branch: 'master')
+ create(:merge_request, :opened, source_project: project, target_project: project, source_branch: 'branch-merged', target_branch: 'master')
create(:merge_request, :opened, source_project: fork_link.forked_to_project, target_project: project, target_branch: 'improve/awesome', source_branch: 'master')
service.execute
diff --git a/spec/services/discussions/update_diff_position_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb
new file mode 100644
index 00000000000..c239494298b
--- /dev/null
+++ b/spec/services/discussions/update_diff_position_service_spec.rb
@@ -0,0 +1,193 @@
+require 'spec_helper'
+
+describe Discussions::UpdateDiffPositionService do
+ let(:project) { create(:project, :repository) }
+ let(:current_user) { project.owner }
+ let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") }
+ let(:modify_commit) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e") }
+ let(:edit_commit) { project.commit("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") }
+
+ let(:path) { "files/ruby/popen.rb" }
+
+ let(:old_diff_refs) do
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: create_commit.parent_id,
+ head_sha: modify_commit.sha
+ )
+ end
+
+ let(:new_diff_refs) do
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: create_commit.parent_id,
+ head_sha: edit_commit.sha
+ )
+ end
+
+ subject do
+ described_class.new(
+ project,
+ current_user,
+ old_diff_refs: old_diff_refs,
+ new_diff_refs: new_diff_refs,
+ paths: [path]
+ )
+ end
+
+ # old diff:
+ # 1 + require 'fileutils'
+ # 2 + require 'open3'
+ # 3 +
+ # 4 + module Popen
+ # 5 + extend self
+ # 6 +
+ # 7 + def popen(cmd, path=nil)
+ # 8 + unless cmd.is_a?(Array)
+ # 9 + raise "System commands must be given as an array of strings"
+ # 10 + end
+ # 11 +
+ # 12 + path ||= Dir.pwd
+ # 13 + vars = { "PWD" => path }
+ # 14 + options = { chdir: path }
+ # 15 +
+ # 16 + unless File.directory?(path)
+ # 17 + FileUtils.mkdir_p(path)
+ # 18 + end
+ # 19 +
+ # 20 + @cmd_output = ""
+ # 21 + @cmd_status = 0
+ # 22 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ # 23 + @cmd_output << stdout.read
+ # 24 + @cmd_output << stderr.read
+ # 25 + @cmd_status = wait_thr.value.exitstatus
+ # 26 + end
+ # 27 +
+ # 28 + return @cmd_output, @cmd_status
+ # 29 + end
+ # 30 + end
+ #
+ # new diff:
+ # 1 + require 'fileutils'
+ # 2 + require 'open3'
+ # 3 +
+ # 4 + module Popen
+ # 5 + extend self
+ # 6 +
+ # 7 + def popen(cmd, path=nil)
+ # 8 + unless cmd.is_a?(Array)
+ # 9 + raise RuntimeError, "System commands must be given as an array of strings"
+ # 10 + end
+ # 11 +
+ # 12 + path ||= Dir.pwd
+ # 13 +
+ # 14 + vars = {
+ # 15 + "PWD" => path
+ # 16 + }
+ # 17 +
+ # 18 + options = {
+ # 19 + chdir: path
+ # 20 + }
+ # 21 +
+ # 22 + unless File.directory?(path)
+ # 23 + FileUtils.mkdir_p(path)
+ # 24 + end
+ # 25 +
+ # 26 + @cmd_output = ""
+ # 27 + @cmd_status = 0
+ # 28 +
+ # 29 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ # 30 + @cmd_output << stdout.read
+ # 31 + @cmd_output << stderr.read
+ # 32 + @cmd_status = wait_thr.value.exitstatus
+ # 33 + end
+ # 34 +
+ # 35 + return @cmd_output, @cmd_status
+ # 36 + end
+ # 37 + end
+ #
+ # old->new diff:
+ # .. .. @@ -6,12 +6,18 @@ module Popen
+ # 6 6
+ # 7 7 def popen(cmd, path=nil)
+ # 8 8 unless cmd.is_a?(Array)
+ # 9 - raise "System commands must be given as an array of strings"
+ # 9 + raise RuntimeError, "System commands must be given as an array of strings"
+ # 10 10 end
+ # 11 11
+ # 12 12 path ||= Dir.pwd
+ # 13 - vars = { "PWD" => path }
+ # 14 - options = { chdir: path }
+ # 13 +
+ # 14 + vars = {
+ # 15 + "PWD" => path
+ # 16 + }
+ # 17 +
+ # 18 + options = {
+ # 19 + chdir: path
+ # 20 + }
+ # 15 21
+ # 16 22 unless File.directory?(path)
+ # 17 23 FileUtils.mkdir_p(path)
+ # 18 24 end
+ # 19 25
+ # 20 26 @cmd_output = ""
+ # 21 27 @cmd_status = 0
+ # 28 +
+ # 22 29 Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ # 23 30 @cmd_output << stdout.read
+ # 24 31 @cmd_output << stderr.read
+ # .. ..
+
+ describe "#execute" do
+ let(:discussion) { create(:diff_note_on_merge_request, project: project, position: old_position).to_discussion }
+
+ let(:old_position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ old_line: nil,
+ new_line: line,
+ diff_refs: old_diff_refs
+ )
+ end
+
+ context "when the diff line is the same" do
+ let(:line) { 16 }
+
+ it "updates the position" do
+ subject.execute(discussion)
+
+ expect(discussion.original_position).to eq(old_position)
+ expect(discussion.position).not_to eq(old_position)
+ expect(discussion.position.new_line).to eq(22)
+ end
+ end
+
+ context "when the diff line has changed" do
+ let(:line) { 9 }
+
+ it "doesn't update the position" do
+ subject.execute(discussion)
+
+ expect(discussion.original_position).to eq(old_position)
+ expect(discussion.position).to eq(old_position)
+ end
+
+ it 'sets the change position' do
+ subject.execute(discussion)
+
+ change_position = discussion.change_position
+ expect(change_position.start_sha).to eq(old_diff_refs.head_sha)
+ expect(change_position.head_sha).to eq(new_diff_refs.head_sha)
+ expect(change_position.old_line).to eq(9)
+ expect(change_position.new_line).to be_nil
+ end
+
+ it 'creates a system discussion' do
+ expect(SystemNoteService).to receive(:diff_discussion_outdated).with(
+ discussion, project, current_user, instance_of(Gitlab::Diff::Position))
+
+ subject.execute(discussion)
+ end
+ end
+ end
+end
diff --git a/spec/services/emails/create_service_spec.rb b/spec/services/emails/create_service_spec.rb
new file mode 100644
index 00000000000..641d5538de8
--- /dev/null
+++ b/spec/services/emails/create_service_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Emails::CreateService do
+ let(:user) { create(:user) }
+ let(:opts) { { email: 'new@email.com' } }
+
+ subject(:service) { described_class.new(user, opts) }
+
+ describe '#execute' do
+ it 'creates an email with valid attributes' do
+ expect { service.execute }.to change { Email.count }.by(1)
+ expect(Email.where(opts)).not_to be_empty
+ end
+
+ it 'has the right user association' do
+ service.execute
+
+ expect(user.emails).to eq(Email.where(opts))
+ end
+ end
+end
diff --git a/spec/services/emails/destroy_service_spec.rb b/spec/services/emails/destroy_service_spec.rb
new file mode 100644
index 00000000000..1f4294dd905
--- /dev/null
+++ b/spec/services/emails/destroy_service_spec.rb
@@ -0,0 +1,14 @@
+require 'spec_helper'
+
+describe Emails::DestroyService do
+ let!(:user) { create(:user) }
+ let!(:email) { create(:email, user: user) }
+
+ subject(:service) { described_class.new(user, email: email.email) }
+
+ describe '#execute' do
+ it 'removes an email' do
+ expect { service.execute }.to change { user.emails.count }.by(-1)
+ end
+ end
+end
diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb
index b06cefe071d..02d7ddeb86b 100644
--- a/spec/services/event_create_service_spec.rb
+++ b/spec/services/event_create_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe EventCreateService, services: true do
+describe EventCreateService do
include UserActivitiesHelpers
- let(:service) { EventCreateService.new }
+ let(:service) { described_class.new }
describe 'Issues' do
describe '#open_issue' do
@@ -113,22 +113,62 @@ describe EventCreateService, services: true do
end
end
- describe '#push', :redis do
- let(:project) { create(:empty_project) }
+ describe '#push', :clean_gitlab_redis_shared_state do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
+ let(:push_data) do
+ {
+ commits: [
+ {
+ id: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ message: 'This is a commit'
+ }
+ ],
+ before: '0000000000000000000000000000000000000000',
+ after: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ total_commits_count: 1,
+ ref: 'refs/heads/my-branch'
+ }
+ end
+
it 'creates a new event' do
- expect { service.push(project, user, {}) }.to change { Event.count }
+ expect { service.push(project, user, push_data) }.to change { Event.count }
+ end
+
+ it 'creates the push event payload' do
+ expect(PushEventPayloadService).to receive(:new)
+ .with(an_instance_of(PushEvent), push_data)
+ .and_call_original
+
+ service.push(project, user, push_data)
end
it 'updates user last activity' do
- expect { service.push(project, user, {}) }.to change { user_activity(user) }
+ expect { service.push(project, user, push_data) }
+ .to change { user_activity(user) }
+ end
+
+ it 'does not create any event data when an error is raised' do
+ payload_service = double(:service)
+
+ allow(payload_service).to receive(:execute)
+ .and_raise(RuntimeError)
+
+ allow(PushEventPayloadService).to receive(:new)
+ .and_return(payload_service)
+
+ expect { service.push(project, user, push_data) }
+ .to raise_error(RuntimeError)
+
+ expect(Event.count).to eq(0)
+ expect(PushEventPayload.count).to eq(0)
end
end
describe 'Project' do
let(:user) { create :user }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe '#join_project' do
subject { service.join_project(project, user) }
diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb
index 16bca66766a..cc950ae6bb3 100644
--- a/spec/services/files/update_service_spec.rb
+++ b/spec/services/files/update_service_spec.rb
@@ -32,8 +32,8 @@ describe Files::UpdateService do
let(:last_commit_sha) { "foo" }
it "returns a hash with the correct error message and a :error status " do
- expect { subject.execute }.
- to raise_error(Files::UpdateService::FileChangedError,
+ expect { subject.execute }
+ .to raise_error(Files::UpdateService::FileChangedError,
"You are attempting to update a file that has changed since you started editing it.")
end
end
diff --git a/spec/services/git_hooks_service_spec.rb b/spec/services/git_hooks_service_spec.rb
index ac7ccfbaab0..3ce01a995b4 100644
--- a/spec/services/git_hooks_service_spec.rb
+++ b/spec/services/git_hooks_service_spec.rb
@@ -1,18 +1,17 @@
require 'spec_helper'
-describe GitHooksService, services: true do
+describe GitHooksService do
include RepoHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
- let(:service) { GitHooksService.new }
+ let(:service) { described_class.new }
before do
@blankrev = Gitlab::Git::BLANK_SHA
@oldrev = sample_commit.parent_id
@newrev = sample_commit.id
@ref = 'refs/heads/feature'
- @repo_path = project.repository.path_to_repo
end
describe '#execute' do
@@ -21,7 +20,7 @@ describe GitHooksService, services: true do
hook = double(trigger: [true, nil])
expect(Gitlab::Git::Hook).to receive(:new).exactly(3).times.and_return(hook)
- service.execute(user, @repo_path, @blankrev, @newrev, @ref) { }
+ service.execute(user, project, @blankrev, @newrev, @ref) { }
end
end
@@ -31,7 +30,7 @@ describe GitHooksService, services: true do
expect(service).not_to receive(:run_hook).with('post-receive')
expect do
- service.execute(user, @repo_path, @blankrev, @newrev, @ref)
+ service.execute(user, project, @blankrev, @newrev, @ref)
end.to raise_error(GitHooksService::PreReceiveError)
end
end
@@ -43,7 +42,7 @@ describe GitHooksService, services: true do
expect(service).not_to receive(:run_hook).with('post-receive')
expect do
- service.execute(user, @repo_path, @blankrev, @newrev, @ref)
+ service.execute(user, project, @blankrev, @newrev, @ref)
end.to raise_error(GitHooksService::PreReceiveError)
end
end
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index 0477cac6677..e3c1bdce300 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -3,27 +3,24 @@ require 'spec_helper'
describe GitPushService, services: true do
include RepoHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:blankrev) { Gitlab::Git::BLANK_SHA }
+ let(:oldrev) { sample_commit.parent_id }
+ let(:newrev) { sample_commit.id }
+ let(:ref) { 'refs/heads/master' }
before do
project.team << [user, :master]
- @blankrev = Gitlab::Git::BLANK_SHA
- @oldrev = sample_commit.parent_id
- @newrev = sample_commit.id
- @ref = 'refs/heads/master'
end
describe 'Push branches' do
- let(:oldrev) { @oldrev }
- let(:newrev) { @newrev }
-
subject do
- execute_service(project, user, oldrev, newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
end
context 'new branch' do
- let(:oldrev) { @blankrev }
+ let(:oldrev) { blankrev }
it { is_expected.to be_truthy }
@@ -51,7 +48,7 @@ describe GitPushService, services: true do
end
context 'rm branch' do
- let(:newrev) { @blankrev }
+ let(:newrev) { blankrev }
it { is_expected.to be_truthy }
@@ -70,24 +67,20 @@ describe GitPushService, services: true do
end
describe "Git Push Data" do
- before do
- service = execute_service(project, user, @oldrev, @newrev, @ref )
- @push_data = service.push_data
- @commit = project.commit(@newrev)
- end
+ let(:commit) { project.commit(newrev) }
- subject { @push_data }
+ subject { push_data_from_service(project, user, oldrev, newrev, ref) }
it { is_expected.to include(object_kind: 'push') }
- it { is_expected.to include(before: @oldrev) }
- it { is_expected.to include(after: @newrev) }
- it { is_expected.to include(ref: @ref) }
+ it { is_expected.to include(before: oldrev) }
+ it { is_expected.to include(after: newrev) }
+ it { is_expected.to include(ref: ref) }
it { is_expected.to include(user_id: user.id) }
it { is_expected.to include(user_name: user.name) }
it { is_expected.to include(project_id: project.id) }
context "with repository data" do
- subject { @push_data[:repository] }
+ subject { push_data_from_service(project, user, oldrev, newrev, ref)[:repository] }
it { is_expected.to include(name: project.name) }
it { is_expected.to include(url: project.url_to_repo) }
@@ -96,7 +89,7 @@ describe GitPushService, services: true do
end
context "with commits" do
- subject { @push_data[:commits] }
+ subject { push_data_from_service(project, user, oldrev, newrev, ref)[:commits] }
it { is_expected.to be_an(Array) }
it 'has 1 element' do
@@ -104,11 +97,11 @@ describe GitPushService, services: true do
end
context "the commit" do
- subject { @push_data[:commits].first }
+ subject { push_data_from_service(project, user, oldrev, newrev, ref)[:commits].first }
- it { is_expected.to include(id: @commit.id) }
- it { is_expected.to include(message: @commit.safe_message) }
- it { is_expected.to include(timestamp: @commit.date.xmlschema) }
+ it { is_expected.to include(id: commit.id) }
+ it { is_expected.to include(message: commit.safe_message) }
+ it { expect(subject[:timestamp].in_time_zone).to eq(commit.date.in_time_zone) }
it do
is_expected.to include(
url: [
@@ -116,45 +109,58 @@ describe GitPushService, services: true do
project.namespace.to_param,
project.to_param,
'commit',
- @commit.id
+ commit.id
].join('/')
)
end
context "with a author" do
- subject { @push_data[:commits].first[:author] }
+ subject { push_data_from_service(project, user, oldrev, newrev, ref)[:commits].first[:author] }
- it { is_expected.to include(name: @commit.author_name) }
- it { is_expected.to include(email: @commit.author_email) }
+ it { is_expected.to include(name: commit.author_name) }
+ it { is_expected.to include(email: commit.author_email) }
end
end
end
end
- describe "Push Event" do
+ describe "Pipelines" do
+ subject { execute_service(project, user, oldrev, newrev, ref) }
+
before do
- service = execute_service(project, user, @oldrev, @newrev, @ref )
- @event = Event.find_by_action(Event::PUSHED)
- @push_data = service.push_data
+ stub_ci_pipeline_to_return_yaml_file
+ end
+
+ it "creates a new pipeline" do
+ expect { subject }.to change { Ci::Pipeline.count }
+ expect(Ci::Pipeline.last).to be_push
end
+ end
+
+ describe "Push Event" do
+ let!(:push_data) { push_data_from_service(project, user, oldrev, newrev, ref) }
+ let(:event) { Event.find_by_action(Event::PUSHED) }
- it { expect(@event).not_to be_nil }
- it { expect(@event.project).to eq(project) }
- it { expect(@event.action).to eq(Event::PUSHED) }
- it { expect(@event.data).to eq(@push_data) }
+ it { expect(event).to be_an_instance_of(PushEvent) }
+ it { expect(event.project).to eq(project) }
+ it { expect(event.action).to eq(Event::PUSHED) }
+ it { expect(event.push_event_payload).to be_an_instance_of(PushEventPayload) }
+ it { expect(event.push_event_payload.commit_from).to eq(oldrev) }
+ it { expect(event.push_event_payload.commit_to).to eq(newrev) }
+ it { expect(event.push_event_payload.ref).to eq('master') }
context "Updates merge requests" do
it "when pushing a new branch for the first time" do
- expect(UpdateMergeRequestsWorker).to receive(:perform_async).
- with(project.id, user.id, @blankrev, 'newrev', 'refs/heads/master')
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ expect(UpdateMergeRequestsWorker).to receive(:perform_async)
+ .with(project.id, user.id, blankrev, 'newrev', ref)
+ execute_service(project, user, blankrev, 'newrev', ref )
end
end
-
+
context "Sends System Push data" do
it "when pushing on a branch" do
- expect(SystemHookPushWorker).to receive(:perform_async).with(@push_data, :push_hooks)
- execute_service(project, user, @oldrev, @newrev, @ref )
+ expect(SystemHookPushWorker).to receive(:perform_async).with(push_data, :push_hooks)
+ execute_service(project, user, oldrev, newrev, ref)
end
end
end
@@ -164,13 +170,13 @@ describe GitPushService, services: true do
it "calls the copy attributes method for the first push to the default branch" do
expect(project.repository).to receive(:copy_gitattributes).with('master')
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master')
+ execute_service(project, user, blankrev, 'newrev', ref)
end
it "calls the copy attributes method for changes to the default branch" do
- expect(project.repository).to receive(:copy_gitattributes).with('refs/heads/master')
+ expect(project.repository).to receive(:copy_gitattributes).with(ref)
- execute_service(project, user, 'oldrev', 'newrev', 'refs/heads/master')
+ execute_service(project, user, 'oldrev', 'newrev', ref)
end
end
@@ -183,7 +189,7 @@ describe GitPushService, services: true do
it "does not call copy attributes method" do
expect(project.repository).not_to receive(:copy_gitattributes)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
end
end
end
@@ -193,7 +199,7 @@ describe GitPushService, services: true do
it "when pushing a branch for the first time" do
expect(project).to receive(:execute_hooks)
expect(project.default_branch).to eq("master")
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ execute_service(project, user, blankrev, 'newrev', ref)
expect(project.protected_branches).not_to be_empty
expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
@@ -204,7 +210,7 @@ describe GitPushService, services: true do
expect(project).to receive(:execute_hooks)
expect(project.default_branch).to eq("master")
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ execute_service(project, user, blankrev, 'newrev', ref)
expect(project.protected_branches).to be_empty
end
@@ -214,7 +220,7 @@ describe GitPushService, services: true do
expect(project).to receive(:execute_hooks)
expect(project.default_branch).to eq("master")
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ execute_service(project, user, blankrev, 'newrev', ref)
expect(project.protected_branches).not_to be_empty
expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
@@ -229,7 +235,7 @@ describe GitPushService, services: true do
expect(project.default_branch).to eq("master")
expect_any_instance_of(ProtectedBranches::CreateService).not_to receive(:execute)
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ execute_service(project, user, blankrev, 'newrev', ref)
expect(project.protected_branches).not_to be_empty
expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::NO_ACCESS])
@@ -241,7 +247,7 @@ describe GitPushService, services: true do
expect(project).to receive(:execute_hooks)
expect(project.default_branch).to eq("master")
- execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
+ execute_service(project, user, blankrev, 'newrev', ref)
expect(project.protected_branches).not_to be_empty
expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
@@ -249,7 +255,7 @@ describe GitPushService, services: true do
it "when pushing new commits to existing branch" do
expect(project).to receive(:execute_hooks)
- execute_service(project, user, 'oldrev', 'newrev', 'refs/heads/master' )
+ execute_service(project, user, 'oldrev', 'newrev', ref)
end
end
end
@@ -270,8 +276,8 @@ describe GitPushService, services: true do
author_email: commit_author.email
)
- allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit).
- and_return(commit)
+ allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit)
+ .and_return(commit)
allow(project.repository).to receive(:commits_between).and_return([commit])
end
@@ -279,7 +285,7 @@ describe GitPushService, services: true do
it "creates a note if a pushed commit mentions an issue" do
expect(SystemNoteService).to receive(:cross_reference).with(issue, commit, commit_author)
- execute_service(project, user, @oldrev, @newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
end
it "only creates a cross-reference note if one doesn't already exist" do
@@ -287,7 +293,7 @@ describe GitPushService, services: true do
expect(SystemNoteService).not_to receive(:cross_reference).with(issue, commit, commit_author)
- execute_service(project, user, @oldrev, @newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
end
it "defaults to the pushing user if the commit's author is not known" do
@@ -297,16 +303,16 @@ describe GitPushService, services: true do
)
expect(SystemNoteService).to receive(:cross_reference).with(issue, commit, user)
- execute_service(project, user, @oldrev, @newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
end
it "finds references in the first push to a non-default branch" do
- allow(project.repository).to receive(:commits_between).with(@blankrev, @newrev).and_return([])
- allow(project.repository).to receive(:commits_between).with("master", @newrev).and_return([commit])
+ allow(project.repository).to receive(:commits_between).with(blankrev, newrev).and_return([])
+ allow(project.repository).to receive(:commits_between).with("master", newrev).and_return([commit])
expect(SystemNoteService).to receive(:cross_reference).with(issue, commit, commit_author)
- execute_service(project, user, @blankrev, @newrev, 'refs/heads/other' )
+ execute_service(project, user, blankrev, newrev, 'refs/heads/other')
end
end
@@ -328,22 +334,22 @@ describe GitPushService, services: true do
committed_date: commit_time
)
- allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit).
- and_return(commit)
+ allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit)
+ .and_return(commit)
allow(project.repository).to receive(:commits_between).and_return([commit])
end
context "while saving the 'first_mentioned_in_commit_at' metric for an issue" do
it 'sets the metric for referenced issues' do
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
expect(issue.reload.metrics.first_mentioned_in_commit_at).to be_like_time(commit_time)
end
it 'does not set the metric for non-referenced issues' do
non_referenced_issue = create(:issue, project: project)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
expect(non_referenced_issue.reload.metrics.first_mentioned_in_commit_at).to be_nil
end
@@ -364,41 +370,29 @@ describe GitPushService, services: true do
author_email: commit_author.email
)
- allow(project.repository).to receive(:commits_between).
- and_return([closing_commit])
+ allow(project.repository).to receive(:commits_between)
+ .and_return([closing_commit])
- allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit).
- and_return(closing_commit)
+ allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit)
+ .and_return(closing_commit)
project.team << [commit_author, :master]
end
context "to default branches" do
it "closes issues" do
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ execute_service(project, commit_author, oldrev, newrev, ref)
expect(Issue.find(issue.id)).to be_closed
end
it "adds a note indicating that the issue is now closed" do
expect(SystemNoteService).to receive(:change_status).with(issue, project, commit_author, "closed", closing_commit)
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ execute_service(project, commit_author, oldrev, newrev, ref)
end
it "doesn't create additional cross-reference notes" do
expect(SystemNoteService).not_to receive(:cross_reference)
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
- end
-
- it "doesn't close issues when external issue tracker is in use" do
- allow_any_instance_of(Project).to receive(:default_issues_tracker?).
- and_return(false)
- external_issue_tracker = double(title: 'My Tracker', issue_path: issue.iid, reference_pattern: project.issue_reference_pattern)
- allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(external_issue_tracker)
-
- # The push still shouldn't create cross-reference notes.
- expect do
- execute_service(project, commit_author, @oldrev, @newrev, 'refs/heads/hurf' )
- end.not_to change { Note.where(project_id: project.id, system: true).count }
+ execute_service(project, commit_author, oldrev, newrev, ref)
end
end
@@ -410,11 +404,11 @@ describe GitPushService, services: true do
it "creates cross-reference notes" do
expect(SystemNoteService).to receive(:cross_reference).with(issue, closing_commit, commit_author)
- execute_service(project, user, @oldrev, @newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
end
it "doesn't close issues" do
- execute_service(project, user, @oldrev, @newrev, @ref )
+ execute_service(project, user, oldrev, newrev, ref)
expect(Issue.find(issue.id)).to be_opened
end
end
@@ -431,11 +425,13 @@ describe GitPushService, services: true do
stub_jira_urls("JIRA-1")
allow(closing_commit).to receive_messages({
- issue_closing_regex: Regexp.new(Gitlab.config.gitlab.issue_closing_pattern),
- safe_message: message,
- author_name: commit_author.name,
- author_email: commit_author.email
- })
+ issue_closing_regex: Regexp.new(Gitlab.config.gitlab.issue_closing_pattern),
+ safe_message: message,
+ author_name: commit_author.name,
+ author_email: commit_author.email
+ })
+
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
allow(project.repository).to receive_messages(commits_between: [closing_commit])
end
@@ -448,7 +444,7 @@ describe GitPushService, services: true do
let(:message) { "this is some work.\n\nrelated to JIRA-1" }
it "initiates one api call to jira server to mention the issue" do
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
expect(WebMock).to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
body: /mentioned this issue in/
@@ -458,7 +454,11 @@ describe GitPushService, services: true do
context "closing an issue" do
let(:message) { "this is some work.\n\ncloses JIRA-1" }
- let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://#{Gitlab.config.gitlab.host}/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json }
+ let(:comment_body) do
+ {
+ body: "Issue solved with [#{closing_commit.id}|http://#{Gitlab.config.gitlab.host}/#{project.full_path}/commit/#{closing_commit.id}]."
+ }.to_json
+ end
before do
open_issue = JIRA::Resource::Issue.new(jira_tracker.client, attrs: { "id" => "JIRA-1" })
@@ -472,13 +472,13 @@ describe GitPushService, services: true do
context "using right markdown" do
it "initiates one api call to jira server to close the issue" do
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ execute_service(project, commit_author, oldrev, newrev, ref)
expect(WebMock).to have_requested(:post, jira_api_transition_url('JIRA-1')).once
end
it "initiates one api call to jira server to comment on the issue" do
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ execute_service(project, commit_author, oldrev, newrev, ref)
expect(WebMock).to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
body: comment_body
@@ -486,21 +486,57 @@ describe GitPushService, services: true do
end
end
- context "using wrong markdown" do
- let(:message) { "this is some work.\n\ncloses #1" }
+ context "using internal issue reference" do
+ context 'when internal issues are disabled' do
+ before do
+ project.issues_enabled = false
+ project.save!
+ end
+ let(:message) { "this is some work.\n\ncloses #1" }
+
+ it "does not initiates one api call to jira server to close the issue" do
+ execute_service(project, commit_author, oldrev, newrev, ref)
+
+ expect(WebMock).not_to have_requested(:post, jira_api_transition_url('JIRA-1'))
+ end
- it "does not initiates one api call to jira server to close the issue" do
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ it "does not initiates one api call to jira server to comment on the issue" do
+ execute_service(project, commit_author, oldrev, newrev, ref)
- expect(WebMock).not_to have_requested(:post, jira_api_transition_url('JIRA-1'))
+ expect(WebMock).not_to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
+ body: comment_body
+ ).once
+ end
end
- it "does not initiates one api call to jira server to comment on the issue" do
- execute_service(project, commit_author, @oldrev, @newrev, @ref )
+ context 'when internal issues are enabled' do
+ let(:issue) { create(:issue, project: project) }
+ let(:message) { "this is some work.\n\ncloses JIRA-1 \n\n closes #{issue.to_reference}" }
- expect(WebMock).not_to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
- body: comment_body
- ).once
+ it "initiates one api call to jira server to close the jira issue" do
+ execute_service(project, commit_author, oldrev, newrev, ref)
+
+ expect(WebMock).to have_requested(:post, jira_api_transition_url('JIRA-1')).once
+ end
+
+ it "initiates one api call to jira server to comment on the jira issue" do
+ execute_service(project, commit_author, oldrev, newrev, ref)
+
+ expect(WebMock).to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
+ body: comment_body
+ ).once
+ end
+
+ it "closes the internal issue" do
+ execute_service(project, commit_author, oldrev, newrev, ref)
+ expect(issue.reload).to be_closed
+ end
+
+ it "adds a note indicating that the issue is now closed" do
+ expect(SystemNoteService).to receive(:change_status)
+ .with(issue, project, commit_author, "closed", closing_commit)
+ execute_service(project, commit_author, oldrev, newrev, ref)
+ end
end
end
end
@@ -509,7 +545,7 @@ describe GitPushService, services: true do
describe "empty project" do
let(:project) { create(:project_empty_repo) }
- let(:new_ref) { 'refs/heads/feature'}
+ let(:new_ref) { 'refs/heads/feature' }
before do
allow(project).to receive(:default_branch).and_return('feature')
@@ -517,7 +553,7 @@ describe GitPushService, services: true do
end
it 'push to first branch updates HEAD' do
- execute_service(project, user, @blankrev, @newrev, new_ref )
+ execute_service(project, user, blankrev, newrev, new_ref)
end
end
@@ -525,20 +561,24 @@ describe GitPushService, services: true do
let(:housekeeping) { Projects::HousekeepingService.new(project) }
before do
- # Flush any raw Redis data stored by the housekeeping code.
- Gitlab::Redis.with { |conn| conn.flushall }
+ # Flush any raw key-value data stored by the housekeeping code.
+ Gitlab::Redis::Cache.with { |conn| conn.flushall }
+ Gitlab::Redis::Queues.with { |conn| conn.flushall }
+ Gitlab::Redis::SharedState.with { |conn| conn.flushall }
allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping)
end
after do
- Gitlab::Redis.with { |conn| conn.flushall }
+ Gitlab::Redis::Cache.with { |conn| conn.flushall }
+ Gitlab::Redis::Queues.with { |conn| conn.flushall }
+ Gitlab::Redis::SharedState.with { |conn| conn.flushall }
end
it 'does not perform housekeeping when not needed' do
expect(housekeeping).not_to receive(:execute)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
end
context 'when housekeeping is needed' do
@@ -549,20 +589,20 @@ describe GitPushService, services: true do
it 'performs housekeeping' do
expect(housekeeping).to receive(:execute)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
end
it 'does not raise an exception' do
allow(housekeeping).to receive(:try_obtain_lease).and_return(false)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
end
end
it 'increments the push counter' do
expect(housekeeping).to receive(:increment!)
- execute_service(project, user, @oldrev, @newrev, @ref)
+ execute_service(project, user, oldrev, newrev, ref)
end
end
@@ -570,9 +610,9 @@ describe GitPushService, services: true do
let(:service) do
described_class.new(project,
user,
- oldrev: sample_commit.parent_id,
- newrev: sample_commit.id,
- ref: 'refs/heads/master')
+ oldrev: oldrev,
+ newrev: newrev,
+ ref: ref)
end
context 'on the default branch' do
@@ -584,13 +624,13 @@ describe GitPushService, services: true do
commit = double(:commit)
diff = double(:diff, new_path: 'README.md')
- expect(commit).to receive(:raw_diffs).with(deltas_only: true).
- and_return([diff])
+ expect(commit).to receive(:raw_deltas)
+ .and_return([diff])
service.push_commits = [commit]
- expect(ProjectCacheWorker).to receive(:perform_async).
- with(project.id, %i(readme), %i(commit_count repository_size))
+ expect(ProjectCacheWorker).to receive(:perform_async)
+ .with(project.id, %i(readme), %i(commit_count repository_size))
service.update_caches
end
@@ -602,9 +642,9 @@ describe GitPushService, services: true do
end
it 'does not flush any conditional caches' do
- expect(ProjectCacheWorker).to receive(:perform_async).
- with(project.id, [], %i(commit_count repository_size)).
- and_call_original
+ expect(ProjectCacheWorker).to receive(:perform_async)
+ .with(project.id, [], %i(commit_count repository_size))
+ .and_call_original
service.update_caches
end
@@ -615,24 +655,81 @@ describe GitPushService, services: true do
let(:service) do
described_class.new(project,
user,
- oldrev: sample_commit.parent_id,
- newrev: sample_commit.id,
- ref: 'refs/heads/master')
+ oldrev: oldrev,
+ newrev: newrev,
+ ref: ref)
end
it 'only schedules a limited number of commits' do
- allow(service).to receive(:push_commits).
- and_return(Array.new(1000, double(:commit, to_hash: {})))
+ service.push_commits = Array.new(1000, double(:commit, to_hash: {}, matches_cross_reference_regex?: true))
expect(ProcessCommitWorker).to receive(:perform_async).exactly(100).times
service.process_commit_messages
end
+
+ it "skips commits which don't include cross-references" do
+ service.push_commits = [double(:commit, to_hash: {}, matches_cross_reference_regex?: false)]
+
+ expect(ProcessCommitWorker).not_to receive(:perform_async)
+
+ service.process_commit_messages
+ end
+ end
+
+ describe '#update_signatures' do
+ let(:service) do
+ described_class.new(
+ project,
+ user,
+ oldrev: oldrev,
+ newrev: newrev,
+ ref: 'refs/heads/master'
+ )
+ end
+
+ context 'when the commit has a signature' do
+ context 'when the signature is already cached' do
+ before do
+ create(:gpg_signature, commit_sha: sample_commit.id)
+ end
+
+ it 'does not queue a CreateGpgSignatureWorker' do
+ expect(CreateGpgSignatureWorker).not_to receive(:perform_async).with(sample_commit.id, project.id)
+
+ execute_service(project, user, oldrev, newrev, ref)
+ end
+ end
+
+ context 'when the signature is not yet cached' do
+ it 'queues a CreateGpgSignatureWorker' do
+ expect(CreateGpgSignatureWorker).to receive(:perform_async).with(sample_commit.id, project.id)
+
+ execute_service(project, user, oldrev, newrev, ref)
+ end
+ end
+ end
+
+ context 'when the commit does not have a signature' do
+ before do
+ allow(Gitlab::Git::Commit).to receive(:shas_with_signatures).with(project.repository, [sample_commit.id]).and_return([])
+ end
+
+ it 'does not queue a CreateGpgSignatureWorker' do
+ expect(CreateGpgSignatureWorker).not_to receive(:perform_async).with(sample_commit.id, project.id)
+
+ execute_service(project, user, oldrev, newrev, ref)
+ end
+ end
end
def execute_service(project, user, oldrev, newrev, ref)
- service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref )
+ service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
service.execute
service
end
+
+ def push_data_from_service(project, user, oldrev, newrev, ref)
+ execute_service(project, user, oldrev, newrev, ref).push_data
+ end
end
diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb
index b73beb3f6fc..05695aa8188 100644
--- a/spec/services/git_tag_push_service_spec.rb
+++ b/spec/services/git_tag_push_service_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe GitTagPushService, services: true do
+describe GitTagPushService do
include RepoHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
- let(:service) { GitTagPushService.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref) }
+ let(:service) { described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref) }
let(:oldrev) { Gitlab::Git::BLANK_SHA }
let(:newrev) { "8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b" } # gitlab-test: git rev-parse refs/tags/v1.1.0
@@ -30,6 +30,20 @@ describe GitTagPushService, services: true do
end
end
+ describe "Pipelines" do
+ subject { service.execute }
+
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+ project.team << [user, :developer]
+ end
+
+ it "creates a new pipeline" do
+ expect { subject }.to change { Ci::Pipeline.count }
+ expect(Ci::Pipeline.last).to be_push
+ end
+ end
+
describe "Git Tag Push Data" do
subject { @push_data }
let(:tag) { project.repository.find_tag(tag_name) }
@@ -170,7 +184,7 @@ describe GitTagPushService, services: true do
describe "Webhooks" do
context "execute webhooks" do
- let(:service) { GitTagPushService.new(project, user, oldrev: 'oldrev', newrev: 'newrev', ref: 'refs/tags/v1.0.0') }
+ let(:service) { described_class.new(project, user, oldrev: 'oldrev', newrev: 'newrev', ref: 'refs/tags/v1.0.0') }
it "when pushing tags" do
expect(project).to receive(:execute_hooks)
diff --git a/spec/services/gravatar_service_spec.rb b/spec/services/gravatar_service_spec.rb
new file mode 100644
index 00000000000..d2cc53fe0ee
--- /dev/null
+++ b/spec/services/gravatar_service_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe GravatarService do
+ describe '#execute' do
+ let(:url) { 'http://example.com/avatar?hash=%{hash}&size=%{size}&email=%{email}&username=%{username}' }
+
+ before do
+ allow(Gitlab.config.gravatar).to receive(:plain_url).and_return(url)
+ end
+
+ it 'replaces the placeholders' do
+ avatar_url = described_class.new.execute('user@example.com', 100, 2, username: 'user')
+
+ expect(avatar_url).to include("hash=#{Digest::MD5.hexdigest('user@example.com')}")
+ expect(avatar_url).to include("size=200")
+ expect(avatar_url).to include("email=user%40example.com")
+ expect(avatar_url).to include("username=user")
+ end
+ end
+end
diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb
index bcb62429275..b2175717a70 100644
--- a/spec/services/groups/create_service_spec.rb
+++ b/spec/services/groups/create_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Groups::CreateService, '#execute', services: true do
+describe Groups::CreateService, '#execute' do
let!(:user) { create(:user) }
let!(:group_params) { { path: "group_path", visibility_level: Gitlab::VisibilityLevel::PUBLIC } }
@@ -14,7 +14,9 @@ describe Groups::CreateService, '#execute', services: true do
end
context "cannot create group with restricted visibility level" do
- before { allow_any_instance_of(ApplicationSetting).to receive(:restricted_visibility_levels).and_return([Gitlab::VisibilityLevel::PUBLIC]) }
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:restricted_visibility_levels).and_return([Gitlab::VisibilityLevel::PUBLIC])
+ end
it { is_expected.not_to be_persisted }
end
@@ -25,7 +27,9 @@ describe Groups::CreateService, '#execute', services: true do
let!(:service) { described_class.new(user, group_params.merge(parent_id: group.id)) }
context 'as group owner' do
- before { group.add_owner(user) }
+ before do
+ group.add_owner(user)
+ end
it { is_expected.to be_persisted }
end
diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb
index a37257d1bf4..1b2ce3cd03e 100644
--- a/spec/services/groups/destroy_service_spec.rb
+++ b/spec/services/groups/destroy_service_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Groups::DestroyService, services: true do
+describe Groups::DestroyService do
include DatabaseConnectionHelpers
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
- let!(:project) { create(:empty_project, namespace: group) }
+ let!(:project) { create(:project, namespace: group) }
let!(:notification_setting) { create(:notification_setting, source: group)}
let!(:gitlab_shell) { Gitlab::Shell.new }
let!(:remove_path) { group.path + "+#{group.id}+deleted" }
@@ -15,6 +15,14 @@ describe Groups::DestroyService, services: true do
group.add_user(user, Gitlab::Access::OWNER)
end
+ def destroy_group(group, user, async)
+ if async
+ Groups::DestroyService.new(group, user).async_execute
+ else
+ Groups::DestroyService.new(group, user).execute
+ end
+ end
+
shared_examples 'group destruction' do |async|
context 'database records' do
before do
@@ -27,33 +35,27 @@ describe Groups::DestroyService, services: true do
it { expect(NotificationSetting.unscoped.all).not_to include(notification_setting) }
end
+ context 'mattermost team' do
+ let!(:chat_team) { create(:chat_team, namespace: group) }
+
+ it 'destroys the team too' do
+ expect_any_instance_of(Mattermost::Team).to receive(:destroy)
+
+ destroy_group(group, user, async)
+ end
+ end
+
context 'file system' do
context 'Sidekiq inline' do
before do
- # Run sidekiq immediatly to check that renamed dir will be removed
+ # Run sidekiq immediately to check that renamed dir will be removed
Sidekiq::Testing.inline! { destroy_group(group, user, async) }
end
- it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey }
- it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey }
- end
-
- context 'Sidekiq fake' do
- before do
- # Don't run sidekiq to check if renamed repository exists
- Sidekiq::Testing.fake! { destroy_group(group, user, async) }
+ it 'verifies that paths have been deleted' do
+ expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey
+ expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey
end
-
- it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey }
- it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_truthy }
- end
- end
-
- def destroy_group(group, user, async)
- if async
- Groups::DestroyService.new(group, user).async_execute
- else
- Groups::DestroyService.new(group, user).execute
end
end
end
@@ -61,6 +63,26 @@ describe Groups::DestroyService, services: true do
describe 'asynchronous delete' do
it_behaves_like 'group destruction', true
+ context 'Sidekiq fake' do
+ before do
+ # Don't run Sidekiq to verify that group and projects are not actually destroyed
+ Sidekiq::Testing.fake! { destroy_group(group, user, true) }
+ end
+
+ after do
+ # Clean up stale directories
+ gitlab_shell.rm_namespace(project.repository_storage_path, group.path)
+ gitlab_shell.rm_namespace(project.repository_storage_path, remove_path)
+ end
+
+ it 'verifies original paths and projects still exist' do
+ expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_truthy
+ expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey
+ expect(Project.unscoped.count).to eq(1)
+ expect(Group.unscoped.count).to eq(2)
+ end
+ end
+
context 'potential race conditions' do
context "when the `GroupDestroyWorker` task runs immediately" do
it "deletes the group" do
@@ -88,7 +110,7 @@ describe Groups::DestroyService, services: true do
# Kick off the initial group destroy in a new thread, so that
# it doesn't share this spec's database transaction.
- Thread.new { Groups::DestroyService.new(group, user).async_execute }.join(5)
+ Thread.new { described_class.new(group, user).async_execute }.join(5)
group_record = run_with_new_database_connection do |conn|
conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index f6ad5cebd2c..44f22a3b37b 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Groups::UpdateService, services: true do
+describe Groups::UpdateService do
let!(:user) { create(:user) }
let!(:private_group) { create(:group, :private) }
let!(:internal_group) { create(:group, :internal) }
@@ -13,7 +13,7 @@ describe Groups::UpdateService, services: true do
before do
public_group.add_user(user, Gitlab::Access::MASTER)
- create(:empty_project, :public, group: public_group)
+ create(:project, :public, group: public_group)
end
it "does not change permission level" do
@@ -27,7 +27,7 @@ describe Groups::UpdateService, services: true do
before do
internal_group.add_user(user, Gitlab::Access::MASTER)
- create(:empty_project, :internal, group: internal_group)
+ create(:project, :internal, group: internal_group)
end
it "does not change permission level" do
@@ -69,7 +69,7 @@ describe Groups::UpdateService, services: true do
before do
internal_group.add_user(user, Gitlab::Access::MASTER)
- create(:empty_project, :internal, group: internal_group)
+ create(:project, :internal, group: internal_group)
end
it 'returns true' do
diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb
index 81b1d327696..1875d0448cd 100644
--- a/spec/services/import_export_clean_up_service_spec.rb
+++ b/spec/services/import_export_clean_up_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ImportExportCleanUpService, services: true do
+describe ImportExportCleanUpService do
describe '#execute' do
let(:service) { described_class.new }
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index 7a1ac027310..bdaab88d673 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -1,14 +1,15 @@
require 'spec_helper'
-describe Issuable::BulkUpdateService, services: true do
+describe Issuable::BulkUpdateService do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace) }
- def bulk_update(issues, extra_params = {})
+ def bulk_update(issuables, extra_params = {})
bulk_update_params = extra_params
- .reverse_merge(issuable_ids: Array(issues).map(&:id).join(','))
+ .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(','))
- Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue')
+ type = Array(issuables).first.model_name.param_key
+ Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type)
end
describe 'close issues' do
@@ -47,40 +48,77 @@ describe Issuable::BulkUpdateService, services: true do
end
end
- describe 'updating assignee' do
- let(:issue) { create(:issue, project: project, assignee: user) }
+ describe 'updating merge request assignee' do
+ let(:merge_request) { create(:merge_request, target_project: project, source_project: project, assignee: user) }
context 'when the new assignee ID is a valid user' do
it 'succeeds' do
new_assignee = create(:user)
project.team << [new_assignee, :developer]
- result = bulk_update(issue, assignee_id: new_assignee.id)
+ result = bulk_update(merge_request, assignee_id: new_assignee.id)
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(1)
end
- it 'updates the assignee to the use ID passed' do
+ it 'updates the assignee to the user ID passed' do
assignee = create(:user)
project.team << [assignee, :developer]
- expect { bulk_update(issue, assignee_id: assignee.id) }
- .to change { issue.reload.assignee }.from(user).to(assignee)
+ expect { bulk_update(merge_request, assignee_id: assignee.id) }
+ .to change { merge_request.reload.assignee }.from(user).to(assignee)
+ end
+ end
+
+ context "when the new assignee ID is #{IssuableFinder::NONE}" do
+ it 'unassigns the issues' do
+ expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) }
+ .to change { merge_request.reload.assignee }.to(nil)
+ end
+ end
+
+ context 'when the new assignee ID is not present' do
+ it 'does not unassign' do
+ expect { bulk_update(merge_request, assignee_id: nil) }
+ .not_to change { merge_request.reload.assignee }
+ end
+ end
+ end
+
+ describe 'updating issue assignee' do
+ let(:issue) { create(:issue, project: project, assignees: [user]) }
+
+ context 'when the new assignee ID is a valid user' do
+ it 'succeeds' do
+ new_assignee = create(:user)
+ project.team << [new_assignee, :developer]
+
+ result = bulk_update(issue, assignee_ids: [new_assignee.id])
+
+ expect(result[:success]).to be_truthy
+ expect(result[:count]).to eq(1)
+ end
+
+ it 'updates the assignee to the user ID passed' do
+ assignee = create(:user)
+ project.team << [assignee, :developer]
+ expect { bulk_update(issue, assignee_ids: [assignee.id]) }
+ .to change { issue.reload.assignees.first }.from(user).to(assignee)
end
end
context "when the new assignee ID is #{IssuableFinder::NONE}" do
it "unassigns the issues" do
- expect { bulk_update(issue, assignee_id: IssuableFinder::NONE) }
- .to change { issue.reload.assignee }.to(nil)
+ expect { bulk_update(issue, assignee_ids: [IssuableFinder::NONE.to_s]) }
+ .to change { issue.reload.assignees.count }.from(1).to(0)
end
end
context 'when the new assignee ID is not present' do
it 'does not unassign' do
- expect { bulk_update(issue, assignee_id: nil) }
- .not_to change { issue.reload.assignee }
+ expect { bulk_update(issue, assignee_ids: []) }
+ .not_to change { issue.reload.assignees }
end
end
end
@@ -125,7 +163,7 @@ describe Issuable::BulkUpdateService, services: true do
{
label_ids: labels.map(&:id),
add_label_ids: add_labels.map(&:id),
- remove_label_ids: remove_labels.map(&:id),
+ remove_label_ids: remove_labels.map(&:id)
}
end
diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb
index 55d635235b0..03f76bd428d 100644
--- a/spec/services/issues/build_service_spec.rb
+++ b/spec/services/issues/build_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper.rb'
-describe Issues::BuildService, services: true do
+describe Issues::BuildService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -136,7 +136,7 @@ describe Issues::BuildService, services: true do
user,
title: 'Issue #1',
description: 'Issue description',
- milestone_id: milestone.id,
+ milestone_id: milestone.id
).execute
expect(issue.title).to eq('Issue #1')
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 7a54373963e..a03f68434de 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Issues::CloseService, services: true do
+describe Issues::CloseService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
- let(:issue) { create(:issue, assignee: user2) }
+ let(:issue) { create(:issue, assignees: [user2]) }
let(:project) { issue.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
@@ -18,26 +18,32 @@ describe Issues::CloseService, services: true do
let(:service) { described_class.new(project, user) }
it 'checks if the user is authorized to update the issue' do
- expect(service).to receive(:can?).with(user, :update_issue, issue).
- and_call_original
+ expect(service).to receive(:can?).with(user, :update_issue, issue)
+ .and_call_original
service.execute(issue)
end
it 'does not close the issue when the user is not authorized to do so' do
- allow(service).to receive(:can?).with(user, :update_issue, issue).
- and_return(false)
+ allow(service).to receive(:can?).with(user, :update_issue, issue)
+ .and_return(false)
expect(service).not_to receive(:close_issue)
expect(service.execute(issue)).to eq(issue)
end
it 'closes the issue when the user is authorized to do so' do
- allow(service).to receive(:can?).with(user, :update_issue, issue).
- and_return(true)
+ allow(service).to receive(:can?).with(user, :update_issue, issue)
+ .and_return(true)
- expect(service).to receive(:close_issue).
- with(issue, commit: nil, notifications: true, system_note: true)
+ expect(service).to receive(:close_issue)
+ .with(issue, commit: nil, notifications: true, system_note: true)
+
+ service.execute(issue)
+ end
+
+ it 'invalidates counter cache for assignees' do
+ expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts)
service.execute(issue)
end
@@ -51,8 +57,10 @@ describe Issues::CloseService, services: true do
end
end
- it { expect(issue).to be_valid }
- it { expect(issue).to be_closed }
+ it 'closes the issue' do
+ expect(issue).to be_valid
+ expect(issue).to be_closed
+ end
it 'sends email to user2 about assign of new issue' do
email = ActionMailer::Base.deliveries.last
@@ -90,15 +98,17 @@ describe Issues::CloseService, services: true do
end
end
- context 'external issue tracker' do
+ context 'internal issues disabled' do
before do
- allow(project).to receive(:default_issues_tracker?).and_return(false)
- described_class.new(project, user).close_issue(issue)
+ project.issues_enabled = false
+ project.save!
end
- it { expect(issue).to be_valid }
- it { expect(issue).to be_opened }
- it { expect(todo.reload).to be_pending }
+ it 'does not close the issue' do
+ expect(issue).to be_valid
+ expect(issue).to be_opened
+ expect(todo.reload).to be_pending
+ end
end
end
end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 80bfb731550..9b2d9e79f4f 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
-describe Issues::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe Issues::CreateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
describe '#execute' do
let(:issue) { described_class.new(project, user, opts).execute }
+ let(:assignee) { create(:user) }
+ let(:milestone) { create(:milestone, project: project) }
context 'when params are valid' do
- let(:assignee) { create(:user) }
- let(:milestone) { create(:milestone, project: project) }
let(:labels) { create_pair(:label, project: project) }
before do
@@ -20,7 +20,7 @@ describe Issues::CreateService, services: true do
let(:opts) do
{ title: 'Awesome issue',
description: 'please fix',
- assignee_id: assignee.id,
+ assignee_ids: [assignee.id],
label_ids: labels.map(&:id),
milestone_id: milestone.id,
due_date: Date.tomorrow }
@@ -29,7 +29,7 @@ describe Issues::CreateService, services: true do
it 'creates the issue with the given params' do
expect(issue).to be_persisted
expect(issue.title).to eq('Awesome issue')
- expect(issue.assignee).to eq assignee
+ expect(issue.assignees).to eq [assignee]
expect(issue.labels).to match_array labels
expect(issue.milestone).to eq milestone
expect(issue.due_date).to eq Date.tomorrow
@@ -37,6 +37,7 @@ describe Issues::CreateService, services: true do
context 'when current user cannot admin issues in the project' do
let(:guest) { create(:user) }
+
before do
project.team << [guest, :guest]
end
@@ -47,7 +48,7 @@ describe Issues::CreateService, services: true do
expect(issue).to be_persisted
expect(issue.title).to eq('Awesome issue')
expect(issue.description).to eq('please fix')
- expect(issue.assignee).to be_nil
+ expect(issue.assignees).to be_empty
expect(issue.labels).to be_empty
expect(issue.milestone).to be_nil
expect(issue.due_date).to be_nil
@@ -117,6 +118,22 @@ describe Issues::CreateService, services: true do
end
end
+ context 'when assignee is set' do
+ let(:opts) do
+ { title: 'Title',
+ description: 'Description',
+ assignees: [assignee] }
+ end
+
+ it 'invalidates open issues counter for assignees when issue is assigned' do
+ project.team << [assignee, :master]
+
+ described_class.new(project, user, opts).execute
+
+ expect(assignee.assigned_open_issues_count).to eq 1
+ end
+ end
+
it 'executes issue hooks when issue is not confidential' do
opts = { title: 'Title', description: 'Description', confidential: false }
@@ -136,9 +153,84 @@ describe Issues::CreateService, services: true do
end
end
- it_behaves_like 'issuable create service'
+ context 'issue create service' do
+ context 'assignees' do
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'removes assignee when user id is invalid' do
+ opts = { title: 'Title', description: 'Description', assignee_ids: [-1] }
+
+ issue = described_class.new(project, user, opts).execute
+
+ expect(issue.assignees).to be_empty
+ end
+
+ it 'removes assignee when user id is 0' do
+ opts = { title: 'Title', description: 'Description', assignee_ids: [0] }
+
+ issue = described_class.new(project, user, opts).execute
+
+ expect(issue.assignees).to be_empty
+ end
+
+ it 'saves assignee when user id is valid' do
+ project.team << [assignee, :master]
+ opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
+
+ issue = described_class.new(project, user, opts).execute
+
+ expect(issue.assignees).to eq([assignee])
+ end
+
+ context "when issuable feature is private" do
+ before do
+ project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE,
+ merge_requests_access_level: ProjectFeature::PRIVATE)
+ end
+
+ levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
+
+ levels.each do |level|
+ it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
+ project.update(visibility_level: level)
+ opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
+
+ issue = described_class.new(project, user, opts).execute
+
+ expect(issue.assignees).to be_empty
+ end
+ end
+ end
+ end
+ end
+
+ it_behaves_like 'new issuable record that supports quick actions'
- it_behaves_like 'new issuable record that supports slash commands'
+ context 'Quick actions' do
+ context 'with assignee and milestone in params and command' do
+ let(:opts) do
+ {
+ assignee_ids: [create(:user).id],
+ milestone_id: 1,
+ title: 'Title',
+ description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+ }
+ end
+
+ before do
+ project.team << [user, :master]
+ project.team << [assignee, :master]
+ end
+
+ it 'assigns and sets milestone to issuable from command' do
+ expect(issue).to be_persisted
+ expect(issue.assignees).to eq([assignee])
+ expect(issue.milestone).to eq(milestone)
+ end
+ end
+ end
context 'resolving discussions' do
let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
@@ -259,14 +351,14 @@ describe Issues::CreateService, services: true do
end
it 'marks related spam_log as recaptcha_verified' do
- expect { issue }.to change{SpamLog.last.recaptcha_verified}.from(false).to(true)
+ expect { issue }.to change {SpamLog.last.recaptcha_verified}.from(false).to(true)
end
context 'when spam log does not belong to a user' do
let(:log_user) { create(:user) }
it 'does not mark spam_log as recaptcha_verified' do
- expect { issue }.not_to change{SpamLog.last.recaptcha_verified}
+ expect { issue }.not_to change {SpamLog.last.recaptcha_verified}
end
end
end
@@ -286,7 +378,7 @@ describe Issues::CreateService, services: true do
end
it 'creates a new spam_log' do
- expect{issue}.to change{SpamLog.count}.from(0).to(1)
+ expect {issue}.to change {SpamLog.count}.from(0).to(1)
end
it 'assigns a spam_log to an issue' do
diff --git a/spec/services/issues/duplicate_service_spec.rb b/spec/services/issues/duplicate_service_spec.rb
new file mode 100644
index 00000000000..089e77cc88b
--- /dev/null
+++ b/spec/services/issues/duplicate_service_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+
+describe Issues::DuplicateService do
+ let(:user) { create(:user) }
+ let(:canonical_project) { create(:project) }
+ let(:duplicate_project) { create(:project) }
+
+ let(:canonical_issue) { create(:issue, project: canonical_project) }
+ let(:duplicate_issue) { create(:issue, project: duplicate_project) }
+
+ subject { described_class.new(duplicate_project, user, {}) }
+
+ describe '#execute' do
+ context 'when the issues passed are the same' do
+ it 'does nothing' do
+ expect(subject).not_to receive(:close_service)
+ expect(SystemNoteService).not_to receive(:mark_duplicate_issue)
+ expect(SystemNoteService).not_to receive(:mark_canonical_issue_of_duplicate)
+
+ subject.execute(duplicate_issue, duplicate_issue)
+ end
+ end
+
+ context 'when the user cannot update the duplicate issue' do
+ before do
+ canonical_project.add_reporter(user)
+ end
+
+ it 'does nothing' do
+ expect(subject).not_to receive(:close_service)
+ expect(SystemNoteService).not_to receive(:mark_duplicate_issue)
+ expect(SystemNoteService).not_to receive(:mark_canonical_issue_of_duplicate)
+
+ subject.execute(duplicate_issue, canonical_issue)
+ end
+ end
+
+ context 'when the user cannot comment on the canonical issue' do
+ before do
+ duplicate_project.add_reporter(user)
+ end
+
+ it 'does nothing' do
+ expect(subject).not_to receive(:close_service)
+ expect(SystemNoteService).not_to receive(:mark_duplicate_issue)
+ expect(SystemNoteService).not_to receive(:mark_canonical_issue_of_duplicate)
+
+ subject.execute(duplicate_issue, canonical_issue)
+ end
+ end
+
+ context 'when the user can mark the issue as a duplicate' do
+ before do
+ canonical_project.add_reporter(user)
+ duplicate_project.add_reporter(user)
+ end
+
+ it 'closes the duplicate issue' do
+ subject.execute(duplicate_issue, canonical_issue)
+
+ expect(duplicate_issue.reload).to be_closed
+ expect(canonical_issue.reload).to be_open
+ end
+
+ it 'adds a system note to the duplicate issue' do
+ expect(SystemNoteService)
+ .to receive(:mark_duplicate_issue).with(duplicate_issue, duplicate_project, user, canonical_issue)
+
+ subject.execute(duplicate_issue, canonical_issue)
+ end
+
+ it 'adds a system note to the canonical issue' do
+ expect(SystemNoteService)
+ .to receive(:mark_canonical_issue_of_duplicate).with(canonical_issue, canonical_project, user, duplicate_issue)
+
+ subject.execute(duplicate_issue, canonical_issue)
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index 9f8346d52bb..f2b35a8fadf 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe Issues::MoveService, services: true do
+describe Issues::MoveService do
let(:user) { create(:user) }
let(:author) { create(:user) }
let(:title) { 'Some issue' }
let(:description) { 'Some issue description' }
- let(:old_project) { create(:empty_project) }
- let(:new_project) { create(:empty_project) }
+ let(:old_project) { create(:project) }
+ let(:new_project) { create(:project) }
let(:milestone1) { create(:milestone, project_id: old_project.id, title: 'v9.0') }
let(:old_issue) do
@@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do
describe '#execute' do
shared_context 'issue move executed' do
- let!(:milestone2) do
- create(:milestone, project_id: new_project.id, title: 'v9.0')
- end
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
let!(:new_issue) { move_service.execute(old_issue, new_project) }
@@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do
context 'issue movable' do
include_context 'user can move issue'
+ context 'move to new milestone' do
+ let(:new_issue) { move_service.execute(old_issue, new_project) }
+
+ context 'project milestone' do
+ let!(:milestone2) do
+ create(:milestone, project_id: new_project.id, title: 'v9.0')
+ end
+
+ it 'assigns milestone to new issue' do
+ expect(new_issue.reload.milestone.title).to eq 'v9.0'
+ expect(new_issue.reload.milestone).to eq(milestone2)
+ end
+ end
+
+ context 'group milestones' do
+ let!(:group) { create(:group, :private) }
+ let!(:group_milestone_1) do
+ create(:milestone, group_id: group.id, title: 'v9.0_group')
+ end
+
+ before do
+ old_issue.update(milestone: group_milestone_1)
+ old_project.update(namespace: group)
+ new_project.update(namespace: group)
+
+ group.add_users([user], GroupMember::DEVELOPER)
+ end
+
+ context 'when moving to a project of the same group' do
+ it 'keeps the same group milestone' do
+ expect(new_issue.reload.project).to eq(new_project)
+ expect(new_issue.reload.milestone).to eq(group_milestone_1)
+ end
+ end
+
+ context 'when moving to a project of a different group' do
+ let!(:group_2) { create(:group, :private) }
+
+ let!(:group_milestone_2) do
+ create(:milestone, group_id: group_2.id, title: 'v9.0_group')
+ end
+
+ before do
+ old_issue.update(milestone: group_milestone_1)
+ new_project.update(namespace: group_2)
+
+ group_2.add_users([user], GroupMember::DEVELOPER)
+ end
+
+ it 'assigns to new group milestone of same title' do
+ expect(new_issue.reload.project).to eq(new_project)
+ expect(new_issue.reload.milestone).to eq(group_milestone_2)
+ end
+ end
+ end
+ end
+
context 'generic issue' do
include_context 'issue move executed'
@@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do
expect(new_issue.project).to eq new_project
end
- it 'assigns milestone to new issue' do
- expect(new_issue.reload.milestone.title).to eq 'v9.0'
- expect(new_issue.reload.milestone).to eq(milestone2)
- end
-
it 'assign labels to new issue' do
expected_label_titles = new_issue.reload.labels.map(&:title)
expect(expected_label_titles).to include 'label1'
@@ -251,12 +300,18 @@ describe Issues::MoveService, services: true do
end
context 'user is reporter only in new project' do
- before { new_project.team << [user, :reporter] }
+ before do
+ new_project.team << [user, :reporter]
+ end
+
it { expect { move }.to raise_error(StandardError, /permissions/) }
end
context 'user is reporter only in old project' do
- before { old_project.team << [user, :reporter] }
+ before do
+ old_project.team << [user, :reporter]
+ end
+
it { expect { move }.to raise_error(StandardError, /permissions/) }
end
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index 93a8270fd16..205e9ebd237 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Issues::ReopenService, services: true do
- let(:project) { create(:empty_project) }
+describe Issues::ReopenService do
+ let(:project) { create(:project) }
let(:issue) { create(:issue, :closed, project: project) }
describe '#execute' do
@@ -27,6 +27,13 @@ describe Issues::ReopenService, services: true do
project.team << [user, :master]
end
+ it 'invalidates counter cache for assignees' do
+ issue.assignees << user
+ expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts)
+
+ described_class.new(project, user).execute(issue)
+ end
+
context 'when issue is not confidential' do
it 'executes issue hooks' do
expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index 4a4929daefc..67a86c50fc1 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper.rb'
-class DummyService < Issues::BaseService
- include ::Issues::ResolveDiscussions
+describe Issues::ResolveDiscussions do
+ class DummyService < Issues::BaseService
+ include ::Issues::ResolveDiscussions
- def initialize(*args)
- super
- filter_resolve_discussion_params
+ def initialize(*args)
+ super
+ filter_resolve_discussion_params
+ end
end
-end
-describe DummyService, services: true do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -20,10 +20,10 @@ describe DummyService, services: true do
describe "for resolving discussions" do
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") }
+ let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "fix") }
describe "#merge_request_for_resolving_discussion" do
- let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) }
+ let(:service) { DummyService.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) }
it "finds the merge request" do
expect(service.merge_request_to_resolve_discussions_of).to eq(merge_request)
@@ -43,7 +43,7 @@ describe DummyService, services: true do
describe "#discussions_to_resolve" do
it "contains a single discussion when matching merge request and discussion are passed" do
- service = described_class.new(
+ service = DummyService.new(
project,
user,
discussion_to_resolve: discussion.id,
@@ -61,7 +61,7 @@ describe DummyService, services: true do
noteable: merge_request,
project: merge_request.target_project,
line_number: 15)])
- service = described_class.new(
+ service = DummyService.new(
project,
user,
merge_request_to_resolve_discussions_of: merge_request.iid
@@ -77,9 +77,9 @@ describe DummyService, services: true do
_second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved,
noteable: merge_request,
project: merge_request.target_project,
- line_number: 15,
+ line_number: 15
)])
- service = described_class.new(
+ service = DummyService.new(
project,
user,
merge_request_to_resolve_discussions_of: merge_request.iid
@@ -92,7 +92,7 @@ describe DummyService, services: true do
end
it "is empty when a discussion and another merge request are passed" do
- service = described_class.new(
+ service = DummyService.new(
project,
user,
discussion_to_resolve: discussion.id,
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 5b324f3c706..34fb16edc84 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -1,20 +1,18 @@
# coding: utf-8
require 'spec_helper'
-describe Issues::UpdateService, services: true do
- include EmailHelpers
-
+describe Issues::UpdateService, :mailer do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:label) { create(:label, project: project) }
let(:label2) { create(:label) }
let(:issue) do
create(:issue, title: 'Old title',
description: "for #{user2.to_reference}",
- assignee_id: user3.id,
+ assignee_ids: [user3.id],
project: project)
end
@@ -31,6 +29,13 @@ describe Issues::UpdateService, services: true do
end
end
+ def find_notes(action)
+ issue
+ .notes
+ .joins(:system_note_metadata)
+ .where(system_note_metadata: { action: action })
+ end
+
def update_issue(opts)
described_class.new(project, user, opts).execute(issue)
end
@@ -40,7 +45,7 @@ describe Issues::UpdateService, services: true do
{
title: 'New title',
description: 'Also please fix',
- assignee_id: user2.id,
+ assignee_ids: [user2.id],
state_event: 'close',
label_ids: [label.id],
due_date: Date.tomorrow
@@ -53,15 +58,22 @@ describe Issues::UpdateService, services: true do
expect(issue).to be_valid
expect(issue.title).to eq 'New title'
expect(issue.description).to eq 'Also please fix'
- expect(issue.assignee).to eq user2
+ expect(issue.assignees).to match_array([user2])
expect(issue).to be_closed
expect(issue.labels).to match_array [label]
expect(issue.due_date).to eq Date.tomorrow
end
+ it 'updates open issue counter for assignees when issue is reassigned' do
+ update_issue(assignee_ids: [user2.id])
+
+ expect(user3.assigned_open_issues_count).to eq 0
+ expect(user2.assigned_open_issues_count).to eq 1
+ end
+
it 'sorts issues as specified by parameters' do
- issue1 = create(:issue, project: project, assignee_id: user3.id)
- issue2 = create(:issue, project: project, assignee_id: user3.id)
+ issue1 = create(:issue, project: project, assignees: [user3])
+ issue2 = create(:issue, project: project, assignees: [user3])
[issue, issue1, issue2].each do |issue|
issue.move_to_end
@@ -87,7 +99,7 @@ describe Issues::UpdateService, services: true do
expect(issue).to be_valid
expect(issue.title).to eq 'New title'
expect(issue.description).to eq 'Also please fix'
- expect(issue.assignee).to eq user3
+ expect(issue.assignees).to match_array [user3]
expect(issue.labels).to be_empty
expect(issue.milestone).to be_nil
expect(issue.due_date).to be_nil
@@ -132,12 +144,23 @@ describe Issues::UpdateService, services: true do
end
end
+ context 'when description changed' do
+ it 'creates system note about description change' do
+ update_issue(description: 'Changed description')
+
+ note = find_note('changed the description')
+
+ expect(note).not_to be_nil
+ expect(note.note).to eq('changed the description')
+ end
+ end
+
context 'when issue turns confidential' do
let(:opts) do
{
title: 'New title',
description: 'Also please fix',
- assignee_id: user2.id,
+ assignee_ids: [user2],
state_event: 'close',
label_ids: [label.id],
confidential: true
@@ -163,12 +186,12 @@ describe Issues::UpdateService, services: true do
it 'does not update assignee_id with unauthorized users' do
project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
update_issue(confidential: true)
- non_member = create(:user)
- original_assignee = issue.assignee
+ non_member = create(:user)
+ original_assignees = issue.assignees
- update_issue(assignee_id: non_member.id)
+ update_issue(assignee_ids: [non_member.id])
- expect(issue.reload.assignee_id).to eq(original_assignee.id)
+ expect(issue.reload.assignees).to eq(original_assignees)
end
end
@@ -205,7 +228,7 @@ describe Issues::UpdateService, services: true do
context 'when is reassigned' do
before do
- update_issue(assignee: user2)
+ update_issue(assignees: [user2])
end
it 'marks previous assignee todos as done' do
@@ -228,13 +251,13 @@ describe Issues::UpdateService, services: true do
end
context 'when the milestone change' do
- before do
+ it 'marks todos as done' do
update_issue(milestone: create(:milestone))
- end
- it 'marks todos as done' do
expect(todo.reload.done?).to eq true
end
+
+ it_behaves_like 'system notes for milestones'
end
context 'when the labels change' do
@@ -270,7 +293,9 @@ describe Issues::UpdateService, services: true do
end
context 'when issue has the `label` label' do
- before { issue.labels << label }
+ before do
+ issue.labels << label
+ end
it 'does not send notifications for existing labels' do
opts = { label_ids: [label.id, label2.id] }
@@ -304,7 +329,9 @@ describe Issues::UpdateService, services: true do
it { expect(issue.tasks?).to eq(true) }
context 'when tasks are marked as completed' do
- before { update_issue(description: "- [x] Task 1\n- [X] Task 2") }
+ before do
+ update_issue(description: "- [x] Task 1\n- [X] Task 2")
+ end
it 'creates system note about task status change' do
note1 = find_note('marked the task **Task 1** as completed')
@@ -312,6 +339,9 @@ describe Issues::UpdateService, services: true do
expect(note1).not_to be_nil
expect(note2).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
end
end
@@ -327,6 +357,9 @@ describe Issues::UpdateService, services: true do
expect(note1).not_to be_nil
expect(note2).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
end
end
@@ -336,10 +369,12 @@ describe Issues::UpdateService, services: true do
update_issue(description: "- [x] Task 1\n- [ ] Task 3\n- [ ] Task 2")
end
- it 'does not create a system note' do
- note = find_note('marked the task **Task 2** as incomplete')
+ it 'does not create a system note for the task' do
+ task_note = find_note('marked the task **Task 2** as incomplete')
+ description_notes = find_notes('description')
- expect(note).to be_nil
+ expect(task_note).to be_nil
+ expect(description_notes.length).to eq(2)
end
end
@@ -350,9 +385,11 @@ describe Issues::UpdateService, services: true do
end
it 'does not create a system note referencing the position the old item' do
- note = find_note('marked the task **Two** as incomplete')
+ task_note = find_note('marked the task **Two** as incomplete')
+ description_notes = find_notes('description')
- expect(note).to be_nil
+ expect(task_note).to be_nil
+ expect(description_notes.length).to eq(2)
end
it 'does not generate a new note at all' do
@@ -382,7 +419,9 @@ describe Issues::UpdateService, services: true do
context 'when remove_label_ids and label_ids are passed' do
let(:params) { { label_ids: [], remove_label_ids: [label.id] } }
- before { issue.update_attributes(labels: [label, label3]) }
+ before do
+ issue.update_attributes(labels: [label, label3])
+ end
it 'ignores the label_ids parameter' do
expect(result.label_ids).not_to be_empty
@@ -396,7 +435,9 @@ describe Issues::UpdateService, services: true do
context 'when add_label_ids and remove_label_ids are passed' do
let(:params) { { add_label_ids: [label3.id], remove_label_ids: [label.id] } }
- before { issue.update_attributes(labels: [label]) }
+ before do
+ issue.update_attributes(labels: [label])
+ end
it 'adds the passed labels' do
expect(result.label_ids).to include(label3.id)
@@ -408,9 +449,65 @@ describe Issues::UpdateService, services: true do
end
end
+ context 'updating asssignee_id' do
+ it 'does not update assignee when assignee_id is invalid' do
+ update_issue(assignee_ids: [-1])
+
+ expect(issue.reload.assignees).to eq([user3])
+ end
+
+ it 'unassigns assignee when user id is 0' do
+ update_issue(assignee_ids: [0])
+
+ expect(issue.reload.assignees).to be_empty
+ end
+
+ it 'does not update assignee_id when user cannot read issue' do
+ update_issue(assignee_ids: [create(:user).id])
+
+ expect(issue.reload.assignees).to eq([user3])
+ end
+
+ context "when issuable feature is private" do
+ levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
+
+ levels.each do |level|
+ it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
+ assignee = create(:user)
+ project.update(visibility_level: level)
+ feature_visibility_attr = :"#{issue.model_name.plural}_access_level"
+ project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE)
+
+ expect { update_issue(assignee_ids: [assignee.id]) }.not_to change { issue.assignees }
+ end
+ end
+ end
+ end
+
context 'updating mentions' do
let(:mentionable) { issue }
- include_examples 'updating mentions', Issues::UpdateService
+ include_examples 'updating mentions', described_class
+ end
+
+ context 'duplicate issue' do
+ let(:canonical_issue) { create(:issue, project: project) }
+
+ context 'invalid canonical_issue_id' do
+ it 'does not call the duplicate service' do
+ expect(Issues::DuplicateService).not_to receive(:new)
+
+ update_issue(canonical_issue_id: 123456789)
+ end
+ end
+
+ context 'valid canonical_issue_id' do
+ it 'calls the duplicate service with both issues' do
+ expect_any_instance_of(Issues::DuplicateService)
+ .to receive(:execute).with(issue, canonical_issue)
+
+ update_issue(canonical_issue_id: canonical_issue.id)
+ end
+ end
end
include_examples 'issuable update service' do
diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb
index 0ecab0c3475..438e6dbc628 100644
--- a/spec/services/labels/create_service_spec.rb
+++ b/spec/services/labels/create_service_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Labels::CreateService, services: true do
+describe Labels::CreateService do
describe '#execute' do
let(:project) { create(:project) }
let(:group) { create(:group) }
-
+
let(:hex_color) { '#FF0000' }
let(:named_color) { 'red' }
let(:upcase_color) { 'RED' }
@@ -17,7 +17,7 @@ describe Labels::CreateService, services: true do
context 'in a project' do
context 'with color in hex-code' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(hex_color)).execute(project: project)
+ label = described_class.new(params_with(hex_color)).execute(project: project)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -26,7 +26,7 @@ describe Labels::CreateService, services: true do
context 'with color in allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(named_color)).execute(project: project)
+ label = described_class.new(params_with(named_color)).execute(project: project)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -35,7 +35,7 @@ describe Labels::CreateService, services: true do
context 'with color in up-case allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(upcase_color)).execute(project: project)
+ label = described_class.new(params_with(upcase_color)).execute(project: project)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -44,7 +44,7 @@ describe Labels::CreateService, services: true do
context 'with color surrounded by spaces' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(spaced_color)).execute(project: project)
+ label = described_class.new(params_with(spaced_color)).execute(project: project)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -53,7 +53,7 @@ describe Labels::CreateService, services: true do
context 'with unknown color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(unknown_color)).execute(project: project)
+ label = described_class.new(params_with(unknown_color)).execute(project: project)
expect(label).not_to be_persisted
end
@@ -61,7 +61,7 @@ describe Labels::CreateService, services: true do
context 'with no color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(no_color)).execute(project: project)
+ label = described_class.new(params_with(no_color)).execute(project: project)
expect(label).not_to be_persisted
end
@@ -71,7 +71,7 @@ describe Labels::CreateService, services: true do
context 'in a group' do
context 'with color in hex-code' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(hex_color)).execute(group: group)
+ label = described_class.new(params_with(hex_color)).execute(group: group)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -80,7 +80,7 @@ describe Labels::CreateService, services: true do
context 'with color in allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(named_color)).execute(group: group)
+ label = described_class.new(params_with(named_color)).execute(group: group)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -89,7 +89,7 @@ describe Labels::CreateService, services: true do
context 'with color in up-case allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(upcase_color)).execute(group: group)
+ label = described_class.new(params_with(upcase_color)).execute(group: group)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -98,7 +98,7 @@ describe Labels::CreateService, services: true do
context 'with color surrounded by spaces' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(spaced_color)).execute(group: group)
+ label = described_class.new(params_with(spaced_color)).execute(group: group)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -107,7 +107,7 @@ describe Labels::CreateService, services: true do
context 'with unknown color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(unknown_color)).execute(group: group)
+ label = described_class.new(params_with(unknown_color)).execute(group: group)
expect(label).not_to be_persisted
end
@@ -115,7 +115,7 @@ describe Labels::CreateService, services: true do
context 'with no color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(no_color)).execute(group: group)
+ label = described_class.new(params_with(no_color)).execute(group: group)
expect(label).not_to be_persisted
end
@@ -125,7 +125,7 @@ describe Labels::CreateService, services: true do
context 'in admin area' do
context 'with color in hex-code' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(hex_color)).execute(template: true)
+ label = described_class.new(params_with(hex_color)).execute(template: true)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -134,7 +134,7 @@ describe Labels::CreateService, services: true do
context 'with color in allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(named_color)).execute(template: true)
+ label = described_class.new(params_with(named_color)).execute(template: true)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -143,7 +143,7 @@ describe Labels::CreateService, services: true do
context 'with color in up-case allowed name' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(upcase_color)).execute(template: true)
+ label = described_class.new(params_with(upcase_color)).execute(template: true)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -152,7 +152,7 @@ describe Labels::CreateService, services: true do
context 'with color surrounded by spaces' do
it 'creates a label' do
- label = Labels::CreateService.new(params_with(spaced_color)).execute(template: true)
+ label = described_class.new(params_with(spaced_color)).execute(template: true)
expect(label).to be_persisted
expect(label.color).to eq expected_saved_color
@@ -161,7 +161,7 @@ describe Labels::CreateService, services: true do
context 'with unknown color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(unknown_color)).execute(template: true)
+ label = described_class.new(params_with(unknown_color)).execute(template: true)
expect(label).not_to be_persisted
end
@@ -169,7 +169,7 @@ describe Labels::CreateService, services: true do
context 'with no color' do
it 'doesn\'t create a label' do
- label = Labels::CreateService.new(params_with(no_color)).execute(template: true)
+ label = described_class.new(params_with(no_color)).execute(template: true)
expect(label).not_to be_persisted
end
diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb
index de8f1827cce..a781fbc7f7d 100644
--- a/spec/services/labels/find_or_create_service_spec.rb
+++ b/spec/services/labels/find_or_create_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Labels::FindOrCreateService, services: true do
+describe Labels::FindOrCreateService do
describe '#execute' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, namespace: group) }
+ let(:project) { create(:project, namespace: group) }
let(:params) do
{
diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb
index 4b90ad19640..8809b282127 100644
--- a/spec/services/labels/promote_service_spec.rb
+++ b/spec/services/labels/promote_service_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Labels::PromoteService, services: true do
+describe Labels::PromoteService do
describe '#execute' do
let!(:user) { create(:user) }
context 'project without group' do
- let!(:project_1) { create(:empty_project) }
+ let!(:project_1) { create(:project) }
let!(:project_label_1_1) { create(:label, project: project_1) }
@@ -27,10 +27,10 @@ describe Labels::PromoteService, services: true do
let!(:group_1) { create(:group) }
let!(:group_2) { create(:group) }
- let!(:project_1) { create(:empty_project, namespace: group_1) }
- let!(:project_2) { create(:empty_project, namespace: group_1) }
- let!(:project_3) { create(:empty_project, namespace: group_1) }
- let!(:project_4) { create(:empty_project, namespace: group_2) }
+ let!(:project_1) { create(:project, namespace: group_1) }
+ let!(:project_2) { create(:project, namespace: group_1) }
+ let!(:project_3) { create(:project, namespace: group_1) }
+ let!(:project_4) { create(:project, namespace: group_2) }
# Labels/issues can't be lazily created so we might as well eager initialize
# all other objects too since we use them inside
@@ -66,9 +66,9 @@ describe Labels::PromoteService, services: true do
end
it 'recreates the label as a group label' do
- expect { service.execute(project_label_1_1) }.
- to change(project_1.labels, :count).by(-1).
- and change(group_1.labels, :count).by(1)
+ expect { service.execute(project_label_1_1) }
+ .to change(project_1.labels, :count).by(-1)
+ .and change(group_1.labels, :count).by(1)
expect(new_label).not_to be_nil
end
diff --git a/spec/services/labels/transfer_service_spec.rb b/spec/services/labels/transfer_service_spec.rb
index 11d5f1cad5e..ae819c011de 100644
--- a/spec/services/labels/transfer_service_spec.rb
+++ b/spec/services/labels/transfer_service_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Labels::TransferService, services: true do
+describe Labels::TransferService do
describe '#execute' do
let(:user) { create(:admin) }
let(:group_1) { create(:group) }
let(:group_2) { create(:group) }
let(:group_3) { create(:group) }
- let(:project_1) { create(:empty_project, namespace: group_2) }
- let(:project_2) { create(:empty_project, namespace: group_3) }
+ let(:project_1) { create(:project, namespace: group_2) }
+ let(:project_2) { create(:project, namespace: group_3) }
let(:group_label_1) { create(:group_label, group: group_1, name: 'Group Label 1') }
let(:group_label_2) { create(:group_label, group: group_1, name: 'Group Label 2') }
diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb
index f2498ea6990..c3fe33045fa 100644
--- a/spec/services/labels/update_service_spec.rb
+++ b/spec/services/labels/update_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Labels::UpdateService, services: true do
+describe Labels::UpdateService do
describe '#execute' do
let(:project) { create(:project) }
@@ -13,14 +13,14 @@ describe Labels::UpdateService, services: true do
let(:expected_saved_color) { hex_color }
- before(:each) do
+ before do
@label = Labels::CreateService.new(title: 'Initial', color: '#000000').execute(project: project)
expect(@label).to be_persisted
end
context 'with color in hex-code' do
it 'updates the label' do
- label = Labels::UpdateService.new(params_with(hex_color)).execute(@label)
+ label = described_class.new(params_with(hex_color)).execute(@label)
expect(label).to be_valid
expect(label.reload.color).to eq expected_saved_color
@@ -29,7 +29,7 @@ describe Labels::UpdateService, services: true do
context 'with color in allowed name' do
it 'updates the label' do
- label = Labels::UpdateService.new(params_with(named_color)).execute(@label)
+ label = described_class.new(params_with(named_color)).execute(@label)
expect(label).to be_valid
expect(label.reload.color).to eq expected_saved_color
@@ -38,7 +38,7 @@ describe Labels::UpdateService, services: true do
context 'with color in up-case allowed name' do
it 'updates the label' do
- label = Labels::UpdateService.new(params_with(upcase_color)).execute(@label)
+ label = described_class.new(params_with(upcase_color)).execute(@label)
expect(label).to be_valid
expect(label.reload.color).to eq expected_saved_color
@@ -47,7 +47,7 @@ describe Labels::UpdateService, services: true do
context 'with color surrounded by spaces' do
it 'updates the label' do
- label = Labels::UpdateService.new(params_with(spaced_color)).execute(@label)
+ label = described_class.new(params_with(spaced_color)).execute(@label)
expect(label).to be_valid
expect(label.reload.color).to eq expected_saved_color
@@ -56,7 +56,7 @@ describe Labels::UpdateService, services: true do
context 'with unknown color' do
it 'doesn\'t update the label' do
- label = Labels::UpdateService.new(params_with(unknown_color)).execute(@label)
+ label = described_class.new(params_with(unknown_color)).execute(@label)
expect(label).not_to be_valid
end
@@ -64,7 +64,7 @@ describe Labels::UpdateService, services: true do
context 'with no color' do
it 'doesn\'t update the label' do
- label = Labels::UpdateService.new(params_with(no_color)).execute(@label)
+ label = described_class.new(params_with(no_color)).execute(@label)
expect(label).not_to be_valid
end
diff --git a/spec/services/members/approve_access_request_service_spec.rb b/spec/services/members/approve_access_request_service_spec.rb
index 7d5a66801db..302c488d6c6 100644
--- a/spec/services/members/approve_access_request_service_spec.rb
+++ b/spec/services/members/approve_access_request_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Members::ApproveAccessRequestService, services: true do
+describe Members::ApproveAccessRequestService do
let(:user) { create(:user) }
let(:access_requester) { create(:user) }
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:project) { create(:project, :public, :access_requestable) }
let(:group) { create(:group, :public, :access_requestable) }
let(:opts) { {} }
diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb
index 3b35a3b8e3a..2d04d824180 100644
--- a/spec/services/members/authorized_destroy_service_spec.rb
+++ b/spec/services/members/authorized_destroy_service_spec.rb
@@ -1,21 +1,42 @@
require 'spec_helper'
-describe Members::AuthorizedDestroyService, services: true do
+describe Members::AuthorizedDestroyService do
let(:member_user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:group) { create(:group, :public) }
- let(:group_project) { create(:empty_project, :public, group: group) }
+ let(:group_project) { create(:project, :public, group: group) }
def number_of_assigned_issuables(user)
Issue.assigned_to(user).count + MergeRequest.assigned_to(user).count
end
+ context 'Invited users' do
+ # Regression spec for issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/32504
+ it 'destroys invited project member' do
+ project.team << [member_user, :developer]
+
+ member = create :project_member, :invited, project: project
+
+ expect { described_class.new(member, member_user).execute }
+ .to change { Member.count }.from(3).to(2)
+ end
+
+ it 'destroys invited group member' do
+ group.add_developer(member_user)
+
+ member = create :group_member, :invited, group: group
+
+ expect { described_class.new(member, member_user).execute }
+ .to change { Member.count }.from(2).to(1)
+ end
+ end
+
context 'Group member' do
it "unassigns issues and merge requests" do
group.add_developer(member_user)
- issue = create :issue, project: group_project, assignee: member_user
- create :issue, assignee: member_user
+ issue = create :issue, project: group_project, assignees: [member_user]
+ create :issue, assignees: [member_user]
merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user
create :merge_request, target_project: project, source_project: project, assignee: member_user
@@ -33,7 +54,7 @@ describe Members::AuthorizedDestroyService, services: true do
it "unassigns issues and merge requests" do
project.team << [member_user, :developer]
- create :issue, project: project, assignee: member_user
+ create :issue, project: project, assignees: [member_user]
create :merge_request, target_project: project, source_project: project, assignee: member_user
member = project.members.find_by(user_id: member_user.id)
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 0670ac2faa2..2a793e0eb4d 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -1,17 +1,19 @@
require 'spec_helper'
-describe Members::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe Members::CreateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:project_user) { create(:user) }
- before { project.team << [user, :master] }
+ before do
+ project.team << [user, :master]
+ end
it 'adds user to members' do
params = { user_ids: project_user.id.to_s, access_level: Gitlab::Access::GUEST }
result = described_class.new(project, user, params).execute
- expect(result).to be_truthy
+ expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
end
@@ -19,7 +21,19 @@ describe Members::CreateService, services: true do
params = { user_ids: '', access_level: Gitlab::Access::GUEST }
result = described_class.new(project, user, params).execute
- expect(result).to be_falsey
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to be_present
+ expect(project.users).not_to include project_user
+ end
+
+ it 'limits the number of users to 100' do
+ user_ids = 1.upto(101).to_a.join(',')
+ params = { user_ids: user_ids, access_level: Gitlab::Access::GUEST }
+
+ result = described_class.new(project, user, params).execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to be_present
expect(project.users).not_to include project_user
end
end
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 41450c67d7e..72f5e27180d 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Members::DestroyService, services: true do
+describe Members::DestroyService do
let(:user) { create(:user) }
let(:member_user) { create(:user) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:group) { create(:group, :public) }
shared_examples 'a service raising ActiveRecord::RecordNotFound' do
@@ -104,8 +104,8 @@ describe Members::DestroyService, services: true do
let(:params) { { id: project.members.find_by!(user_id: user.id).id } }
it 'destroys the member' do
- expect { described_class.new(project, user, params).execute }.
- to change { project.members.count }.by(-1)
+ expect { described_class.new(project, user, params).execute }
+ .to change { project.members.count }.by(-1)
end
end
end
diff --git a/spec/services/members/request_access_service_spec.rb b/spec/services/members/request_access_service_spec.rb
index 814c17b9ad0..0a704bba521 100644
--- a/spec/services/members/request_access_service_spec.rb
+++ b/spec/services/members/request_access_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Members::RequestAccessService, services: true do
+describe Members::RequestAccessService do
let(:user) { create(:user) }
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
@@ -29,7 +29,7 @@ describe Members::RequestAccessService, services: true do
end
context 'when current user cannot request access to the project' do
- %i[empty_project group].each do |source_type|
+ %i[project group].each do |source_type|
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { create(source_type, :private) }
end
@@ -37,7 +37,7 @@ describe Members::RequestAccessService, services: true do
end
context 'when access requests are disabled' do
- %i[empty_project group].each do |source_type|
+ %i[project group].each do |source_type|
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { create(source_type, :public) }
end
@@ -45,7 +45,7 @@ describe Members::RequestAccessService, services: true do
end
context 'when current user can request access to the project' do
- %i[empty_project group].each do |source_type|
+ %i[project group].each do |source_type|
it_behaves_like 'a service creating a access request' do
let(:source) { create(source_type, :public, :access_requestable) }
end
diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb
index fe75757dd29..fcbe0e5985f 100644
--- a/spec/services/merge_requests/assign_issues_service_spec.rb
+++ b/spec/services/merge_requests/assign_issues_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequests::AssignIssuesService, services: true do
+describe MergeRequests::AssignIssuesService do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let(:issue) { create(:issue, project: project) }
@@ -15,14 +15,14 @@ describe MergeRequests::AssignIssuesService, services: true do
expect(service.assignable_issues.map(&:id)).to include(issue.id)
end
- it 'ignores issues already assigned to any user' do
- issue.update!(assignee: create(:user))
+ it 'ignores issues the user cannot update assignee on' do
+ project.team.truncate
expect(service.assignable_issues).to be_empty
end
- it 'ignores issues the user cannot update assignee on' do
- project.team.truncate
+ it 'ignores issues already assigned to any user' do
+ issue.assignees = [create(:user)]
expect(service.assignable_issues).to be_empty
end
@@ -44,7 +44,7 @@ describe MergeRequests::AssignIssuesService, services: true do
end
it 'assigns these to the merge request owner' do
- expect { service.execute }.to change { issue.reload.assignee }.to(user)
+ expect { service.execute }.to change { issue.assignees.first }.to(user)
end
it 'ignores external issues' do
diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb
index be9f9ea2dec..b46c419de14 100644
--- a/spec/services/merge_requests/build_service_spec.rb
+++ b/spec/services/merge_requests/build_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequests::BuildService, services: true do
+describe MergeRequests::BuildService do
include RepoHelpers
let(:project) { create(:project, :repository) }
@@ -19,7 +19,7 @@ describe MergeRequests::BuildService, services: true do
let(:commits) { nil }
let(:service) do
- MergeRequests::BuildService.new(project, user,
+ described_class.new(project, user,
description: description,
source_branch: source_branch,
target_branch: target_branch,
@@ -206,7 +206,9 @@ describe MergeRequests::BuildService, services: true do
context 'branch starts with external issue IID followed by a hyphen' do
let(:source_branch) { '12345-fix-issue' }
- before { allow(project).to receive(:default_issues_tracker?).and_return(false) }
+ before do
+ allow(project).to receive(:external_issue_tracker).and_return(true)
+ end
it 'sets the title to: Resolves External Issue $issue-iid' do
expect(merge_request.title).to eq('Resolve External Issue 12345')
@@ -261,6 +263,16 @@ describe MergeRequests::BuildService, services: true do
end
end
+ context 'upstream project has disabled merge requests' do
+ let(:upstream_project) { create(:project, :merge_requests_disabled) }
+ let(:project) { create(:project, forked_from_project: upstream_project) }
+ let(:commits) { Commit.decorate([commit_1], project) }
+
+ it 'sets target project correctly' do
+ expect(merge_request.target_project).to eq(project)
+ end
+ end
+
context 'target_project is set and accessible by current_user' do
let(:target_project) { create(:project, :public, :repository)}
let(:commits) { Commit.decorate([commit_1], project) }
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index d55a7657c0e..04bf267d167 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequests::CloseService, services: true do
+describe MergeRequests::CloseService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
@@ -15,6 +15,8 @@ describe MergeRequests::CloseService, services: true do
end
describe '#execute' do
+ it_behaves_like 'cache counters invalidator'
+
context 'valid params' do
let(:service) { described_class.new(project, user, {}) }
@@ -30,8 +32,8 @@ describe MergeRequests::CloseService, services: true do
it { expect(@merge_request).to be_closed }
it 'executes hooks with close action' do
- expect(service).to have_received(:execute_hooks).
- with(@merge_request, 'close')
+ expect(service).to have_received(:execute_hooks)
+ .with(@merge_request, 'close')
end
it 'sends email to user2 about assign of new merge_request' do
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
new file mode 100644
index 00000000000..23982b9e6e1
--- /dev/null
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+
+describe MergeRequests::Conflicts::ListService do
+ describe '#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|
+ mr.mark_as_unmergeable
+ end
+ end
+
+ def conflicts_service(merge_request)
+ described_class.new(merge_request)
+ end
+
+ it 'returns a falsey value when the MR can be merged without conflicts' do
+ merge_request = create_merge_request('master')
+ merge_request.mark_as_mergeable
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR is marked as having conflicts, but has none' do
+ merge_request = create_merge_request('master')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when one of the MR branches is missing' do
+ merge_request = create_merge_request('conflict-resolvable')
+ merge_request.project.repository.rm_branch(merge_request.author, 'conflict-resolvable')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR has a missing ref after a force push' do
+ merge_request = create_merge_request('conflict-resolvable')
+ service = conflicts_service(merge_request)
+ allow(service.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError)
+
+ expect(service.can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR does not support new diff notes' do
+ merge_request = create_merge_request('conflict-resolvable')
+ merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a large file' do
+ merge_request = create_merge_request('conflict-too-large')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a binary file' do
+ merge_request = create_merge_request('conflict-binary-file')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
+ merge_request = create_merge_request('conflict-missing-side')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a truthy value when the conflicts are resolvable in the UI' do
+ merge_request = create_merge_request('conflict-resolvable')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_truthy
+ end
+
+ it 'returns a truthy value when the conflicts have to be resolved in an editor' do
+ merge_request = create_merge_request('conflict-contains-conflict-markers')
+
+ expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_truthy
+ end
+ end
+end
diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
new file mode 100644
index 00000000000..6f49a65d795
--- /dev/null
+++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
@@ -0,0 +1,257 @@
+require 'spec_helper'
+
+describe MergeRequests::Conflicts::ResolveService do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ let(:fork_project) do
+ create(:forked_project_with_submodules) do |fork_project|
+ fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id)
+ fork_project.save
+ end
+ end
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_branch: 'conflict-resolvable', source_project: project,
+ target_branch: 'conflict-start')
+ end
+
+ let(:merge_request_from_fork) do
+ create(:merge_request,
+ source_branch: 'conflict-resolvable-fork', source_project: fork_project,
+ target_branch: 'conflict-start', target_project: project)
+ end
+
+ describe '#execute' do
+ let(:service) { described_class.new(merge_request) }
+
+ def blob_content(project, ref, path)
+ project.repository.blob_at(ref, path).data
+ end
+
+ context 'with section params' do
+ let(:params) do
+ {
+ files: [
+ {
+ old_path: 'files/ruby/popen.rb',
+ new_path: 'files/ruby/popen.rb',
+ sections: {
+ '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
+ }
+ }, {
+ old_path: 'files/ruby/regex.rb',
+ new_path: 'files/ruby/regex.rb',
+ sections: {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
+ }
+ }
+ ],
+ commit_message: 'This is a commit message!'
+ }
+ end
+
+ context 'when the source and target project are the same' do
+ before do
+ service.execute(user, params)
+ end
+
+ it 'creates a commit with the message' do
+ expect(merge_request.source_branch_head.message).to eq(params[:commit_message])
+ end
+
+ it 'creates a commit with the correct parents' do
+ expect(merge_request.source_branch_head.parents.map(&:id))
+ .to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06
+ 824be604a34828eb682305f0d963056cfac87b2d))
+ end
+ end
+
+ context 'when some files have trailing newlines' do
+ let!(:source_head) do
+ branch = 'conflict-resolvable'
+ path = 'files/ruby/popen.rb'
+ popen_content = blob_content(project, branch, path)
+
+ project.repository.update_file(
+ user,
+ path,
+ popen_content.chomp("\n"),
+ message: 'Remove trailing newline from popen.rb',
+ branch_name: branch
+ )
+ end
+
+ before do
+ service.execute(user, params)
+ end
+
+ it 'preserves trailing newlines from our side of the conflicts' do
+ head_sha = merge_request.source_branch_head.sha
+ popen_content = blob_content(project, head_sha, 'files/ruby/popen.rb')
+ regex_content = blob_content(project, head_sha, 'files/ruby/regex.rb')
+
+ expect(popen_content).not_to end_with("\n")
+ expect(regex_content).to end_with("\n")
+ end
+ end
+
+ context 'when the source project is a fork and does not contain the HEAD of the target branch' do
+ let!(:target_head) do
+ project.repository.create_file(
+ user,
+ 'new-file-in-target',
+ '',
+ message: 'Add new file in target',
+ branch_name: 'conflict-start')
+ end
+
+ def resolve_conflicts
+ described_class.new(merge_request_from_fork).execute(user, params)
+ end
+
+ it 'gets conflicts from the source project' do
+ expect(fork_project.repository.rugged).to receive(:merge_commits).and_call_original
+ expect(project.repository.rugged).not_to receive(:merge_commits)
+
+ resolve_conflicts
+ end
+
+ it 'creates a commit with the message' do
+ resolve_conflicts
+
+ expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message])
+ end
+
+ it 'creates a commit with the correct parents' do
+ resolve_conflicts
+
+ expect(merge_request_from_fork.source_branch_head.parents.map(&:id))
+ .to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head])
+ end
+ end
+ end
+
+ context 'with content and sections params' do
+ let(:popen_content) { "class Popen\nend" }
+
+ let(:params) do
+ {
+ files: [
+ {
+ old_path: 'files/ruby/popen.rb',
+ new_path: 'files/ruby/popen.rb',
+ content: popen_content
+ }, {
+ old_path: 'files/ruby/regex.rb',
+ new_path: 'files/ruby/regex.rb',
+ sections: {
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
+ }
+ }
+ ],
+ commit_message: 'This is a commit message!'
+ }
+ end
+
+ before do
+ service.execute(user, params)
+ end
+
+ it 'creates a commit with the message' do
+ expect(merge_request.source_branch_head.message).to eq(params[:commit_message])
+ end
+
+ it 'creates a commit with the correct parents' do
+ expect(merge_request.source_branch_head.parents.map(&:id))
+ .to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06
+ 824be604a34828eb682305f0d963056cfac87b2d))
+ end
+
+ it 'sets the content to the content given' do
+ blob = blob_content(
+ merge_request.source_project,
+ merge_request.source_branch_head.sha,
+ 'files/ruby/popen.rb'
+ )
+
+ expect(blob).to eq(popen_content)
+ end
+ end
+
+ context 'when a resolution section is missing' do
+ let(:invalid_params) do
+ {
+ files: [
+ {
+ old_path: 'files/ruby/popen.rb',
+ new_path: 'files/ruby/popen.rb',
+ content: ''
+ }, {
+ old_path: 'files/ruby/regex.rb',
+ new_path: 'files/ruby/regex.rb',
+ sections: { '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head' }
+ }
+ ],
+ commit_message: 'This is a commit message!'
+ }
+ end
+
+ it 'raises a MissingResolution error' do
+ expect { service.execute(user, invalid_params) }
+ .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ end
+ end
+
+ context 'when the content of a file is unchanged' do
+ let(:list_service) { MergeRequests::Conflicts::ListService.new(merge_request) }
+
+ let(:invalid_params) do
+ {
+ files: [
+ {
+ old_path: 'files/ruby/popen.rb',
+ new_path: 'files/ruby/popen.rb',
+ content: ''
+ }, {
+ old_path: 'files/ruby/regex.rb',
+ new_path: 'files/ruby/regex.rb',
+ content: list_service.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content
+ }
+ ],
+ commit_message: 'This is a commit message!'
+ }
+ end
+
+ it 'raises a MissingResolution error' do
+ expect { service.execute(user, invalid_params) }
+ .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ end
+ end
+
+ context 'when a file is missing' do
+ let(:invalid_params) do
+ {
+ files: [
+ {
+ old_path: 'files/ruby/popen.rb',
+ new_path: 'files/ruby/popen.rb',
+ content: ''
+ }
+ ],
+ commit_message: 'This is a commit message!'
+ }
+ end
+
+ it 'raises a MissingFiles error' do
+ expect { service.execute(user, invalid_params) }
+ .to raise_error(described_class::MissingFiles)
+ end
+ end
+ end
+end
diff --git a/spec/services/merge_requests/create_from_issue_service_spec.rb b/spec/services/merge_requests/create_from_issue_service_spec.rb
new file mode 100644
index 00000000000..492b55cdece
--- /dev/null
+++ b/spec/services/merge_requests/create_from_issue_service_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+describe MergeRequests::CreateFromIssueService do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue, project: project) }
+
+ subject(:service) { described_class.new(project, user, issue_iid: issue.iid) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ it 'returns an error with invalid issue iid' do
+ result = described_class.new(project, user, issue_iid: -1).execute
+
+ expect(result[:status]).to eq :error
+ expect(result[:message]).to eq 'Invalid issue iid'
+ end
+
+ it 'delegates issue search to IssuesFinder' do
+ expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+
+ described_class.new(project, user, issue_iid: -1).execute
+ end
+
+ it 'delegates the branch creation to CreateBranchService' do
+ expect_any_instance_of(CreateBranchService).to receive(:execute).once.and_call_original
+
+ service.execute
+ end
+
+ it 'creates a branch based on issue title' do
+ service.execute
+
+ expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy
+ end
+
+ it 'creates a system note' do
+ expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name)
+
+ service.execute
+ end
+
+ it 'creates a merge request' do
+ expect { service.execute }.to change(project.merge_requests, :count).by(1)
+ end
+
+ it 'sets the merge request title to: "WIP: Resolves "$issue-title"' do
+ result = service.execute
+
+ expect(result[:merge_request].title).to eq("WIP: Resolve \"#{issue.title}\"")
+ end
+
+ it 'sets the merge request author to current user' do
+ result = service.execute
+
+ expect(result[:merge_request].author).to eq user
+ end
+
+ it 'sets the merge request source branch to the new issue branch' do
+ result = service.execute
+
+ expect(result[:merge_request].source_branch).to eq issue.to_branch_name
+ end
+
+ it 'sets the merge request target branch to the project default branch' do
+ result = service.execute
+
+ expect(result[:merge_request].target_branch).to eq project.default_branch
+ end
+ end
+end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index 0e16c7cc94b..a1f3bec42cc 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequests::CreateService, services: true do
+describe MergeRequests::CreateService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:assignee) { create(:user) }
@@ -27,10 +27,12 @@ describe MergeRequests::CreateService, services: true do
@merge_request = service.execute
end
- it { expect(@merge_request).to be_valid }
- it { expect(@merge_request.title).to eq('Awesome merge_request') }
- it { expect(@merge_request.assignee).to be_nil }
- it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') }
+ it 'creates an MR' do
+ expect(@merge_request).to be_valid
+ expect(@merge_request.title).to eq('Awesome merge_request')
+ expect(@merge_request.assignee).to be_nil
+ expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1')
+ end
it 'executes hooks with default action' do
expect(service).to have_received(:execute_hooks).with(@merge_request)
@@ -46,6 +48,16 @@ describe MergeRequests::CreateService, services: true do
expect(Todo.where(attributes).count).to be_zero
end
+ it 'creates exactly 1 create MR event' do
+ attributes = {
+ action: Event::CREATED,
+ target_id: @merge_request.id,
+ target_type: @merge_request.class.name
+ }
+
+ expect(Event.where(attributes).count).to eq(1)
+ end
+
context 'when merge request is assigned to someone' do
let(:opts) do
{
@@ -73,9 +85,40 @@ describe MergeRequests::CreateService, services: true do
expect(Todo.where(attributes).count).to eq 1
end
end
+
+ context 'when head pipelines already exist for merge request source branch' do
+ let(:sha) { project.commit(opts[:source_branch]).id }
+ let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) }
+ let!(:pipeline_2) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) }
+ let!(:pipeline_3) { create(:ci_pipeline, project: project, ref: "other_branch", project_id: project.id) }
+
+ before do
+ project.merge_requests
+ .where(source_branch: opts[:source_branch], target_branch: opts[:target_branch])
+ .destroy_all
+ end
+
+ it 'sets head pipeline' do
+ merge_request = service.execute
+
+ expect(merge_request.head_pipeline).to eq(pipeline_2)
+ expect(merge_request).to be_persisted
+ end
+
+ context 'when merge request head commit sha does not match pipeline sha' do
+ it 'sets the head pipeline correctly' do
+ pipeline_2.update(sha: 1234)
+
+ merge_request = service.execute
+
+ expect(merge_request.head_pipeline).to eq(pipeline_1)
+ expect(merge_request).to be_persisted
+ end
+ end
+ end
end
- it_behaves_like 'new issuable record that supports slash commands' do
+ it_behaves_like 'new issuable record that supports quick actions' do
let(:default_params) do
{
source_branch: 'feature',
@@ -84,7 +127,109 @@ describe MergeRequests::CreateService, services: true do
end
end
- it_behaves_like 'issuable create service'
+ context 'Quick actions' do
+ context 'with assignee and milestone in params and command' do
+ let(:merge_request) { described_class.new(project, user, opts).execute }
+ let(:milestone) { create(:milestone, project: project) }
+
+ let(:opts) do
+ {
+ assignee_id: create(:user).id,
+ milestone_id: 1,
+ title: 'Title',
+ description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"),
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+
+ before do
+ project.team << [user, :master]
+ project.team << [assignee, :master]
+ end
+
+ it 'assigns and sets milestone to issuable from command' do
+ expect(merge_request).to be_persisted
+ expect(merge_request.assignee).to eq(assignee)
+ expect(merge_request.milestone).to eq(milestone)
+ end
+ end
+ end
+
+ context 'merge request create service' do
+ context 'asssignee_id' do
+ let(:assignee) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'removes assignee_id when user id is invalid' do
+ opts = { title: 'Title', description: 'Description', assignee_id: -1 }
+
+ merge_request = described_class.new(project, user, opts).execute
+
+ expect(merge_request.assignee_id).to be_nil
+ end
+
+ it 'removes assignee_id when user id is 0' do
+ opts = { title: 'Title', description: 'Description', assignee_id: 0 }
+
+ merge_request = described_class.new(project, user, opts).execute
+
+ expect(merge_request.assignee_id).to be_nil
+ end
+
+ it 'saves assignee when user id is valid' do
+ project.team << [assignee, :master]
+ opts = { title: 'Title', description: 'Description', assignee_id: assignee.id }
+
+ merge_request = described_class.new(project, user, opts).execute
+
+ expect(merge_request.assignee).to eq(assignee)
+ end
+
+ context 'when assignee is set' do
+ let(:opts) do
+ {
+ title: 'Title',
+ description: 'Description',
+ assignee_id: assignee.id,
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+
+ it 'invalidates open merge request counter for assignees when merge request is assigned' do
+ project.team << [assignee, :master]
+
+ described_class.new(project, user, opts).execute
+
+ expect(assignee.assigned_open_merge_requests_count).to eq 1
+ end
+ end
+
+ context "when issuable feature is private" do
+ before do
+ project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE,
+ merge_requests_access_level: ProjectFeature::PRIVATE)
+ end
+
+ levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
+
+ levels.each do |level|
+ it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
+ project.update(visibility_level: level)
+ opts = { title: 'Title', description: 'Description', assignee_id: assignee.id }
+
+ merge_request = described_class.new(project, user, opts).execute
+
+ expect(merge_request.assignee_id).to be_nil
+ end
+ end
+ end
+ end
+ end
context 'while saving references to issues that the created merge request closes' do
let(:first_issue) { create(:issue, project: project) }
diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb
index 290e00ea1ba..25599dea19f 100644
--- a/spec/services/merge_requests/get_urls_service_spec.rb
+++ b/spec/services/merge_requests/get_urls_service_spec.rb
@@ -2,8 +2,8 @@ require "spec_helper"
describe MergeRequests::GetUrlsService do
let(:project) { create(:project, :public, :repository) }
- let(:service) { MergeRequests::GetUrlsService.new(project) }
- let(:source_branch) { "my_branch" }
+ let(:service) { described_class.new(project) }
+ let(:source_branch) { "merge-test" }
let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" }
let(:show_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" }
let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" }
@@ -78,7 +78,7 @@ describe MergeRequests::GetUrlsService do
end
context 'pushing to existing branch and merge request is reopened' do
- let!(:merge_request) { create(:merge_request, :reopened, source_project: project, source_branch: source_branch) }
+ let!(:merge_request) { create(:merge_request, :opened, source_project: project, source_branch: source_branch) }
let(:changes) { existing_branch_changes }
it_behaves_like 'show_merge_request_url'
end
@@ -89,7 +89,7 @@ describe MergeRequests::GetUrlsService do
let!(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project, source_branch: source_branch) }
let(:changes) { existing_branch_changes }
# Source project is now the forked one
- let(:service) { MergeRequests::GetUrlsService.new(forked_project) }
+ let(:service) { described_class.new(forked_project) }
before do
allow(forked_project).to receive(:empty_repo?).and_return(false)
@@ -111,9 +111,9 @@ describe MergeRequests::GetUrlsService do
end
context 'pushing new branch and existing branch (with merge request created) at once' do
- let!(:merge_request) { create(:merge_request, source_project: project, source_branch: "existing_branch") }
+ let!(:merge_request) { create(:merge_request, source_project: project, source_branch: "markdown") }
let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" }
- let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/existing_branch" }
+ let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/markdown" }
let(:changes) { "#{new_branch_changes}\n#{existing_branch_changes}" }
let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" }
@@ -124,7 +124,7 @@ describe MergeRequests::GetUrlsService do
url: new_merge_request_url,
new_merge_request: true
}, {
- branch_name: "existing_branch",
+ branch_name: "markdown",
url: show_merge_request_url,
new_merge_request: false
}])
diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
index 35804d41b46..bb46e1dd9ab 100644
--- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
+++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe MergeRequests::MergeRequestDiffCacheService do
- let(:subject) { MergeRequests::MergeRequestDiffCacheService.new }
+ let(:subject) { described_class.new }
describe '#execute' do
it 'retrieves the diff files to cache the highlighted result' do
@@ -10,8 +10,8 @@ describe MergeRequests::MergeRequestDiffCacheService do
expect(Rails.cache).to receive(:read).with(cache_key).and_return({})
expect(Rails.cache).to receive(:write).with(cache_key, anything)
- allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(double("text?" => true))
- allow_any_instance_of(Repository).to receive(:diffable?).and_return(true)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(true)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(true)
subject.execute(merge_request)
end
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index d96f819e66a..e593bfeeaf7 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe MergeRequests::MergeService, services: true do
+describe MergeRequests::MergeService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let(:merge_request) { create(:merge_request, assignee: user2) }
+ let(:merge_request) { create(:merge_request, :simple, author: user2, assignee: user2) }
let(:project) { merge_request.project }
before do
@@ -13,7 +13,7 @@ describe MergeRequests::MergeService, services: true do
describe '#execute' do
context 'valid params' do
- let(:service) { MergeRequests::MergeService.new(project, user, commit_message: 'Awesome message') }
+ let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
before do
allow(service).to receive(:execute_hooks)
@@ -81,7 +81,9 @@ describe MergeRequests::MergeService, services: true do
end
context "when jira_issue_transition_id is not present" do
- before { allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(nil) }
+ before do
+ allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(nil)
+ end
it "does not close issue" do
allow(jira_tracker).to receive_messages(jira_issue_transition_id: nil)
@@ -110,7 +112,7 @@ describe MergeRequests::MergeService, services: true do
context 'closes related todos' do
let(:merge_request) { create(:merge_request, assignee: user, author: user) }
let(:project) { merge_request.project }
- let(:service) { MergeRequests::MergeService.new(project, user, commit_message: 'Awesome message') }
+ let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
let!(:todo) do
create(:todo, :assigned,
project: project,
@@ -131,23 +133,70 @@ describe MergeRequests::MergeService, services: true do
it { expect(todo).to be_done }
end
- context 'remove source branch by author' do
- let(:service) do
- merge_request.merge_params['force_remove_source_branch'] = '1'
- merge_request.save!
- MergeRequests::MergeService.new(project, user, commit_message: 'Awesome message')
+ context 'source branch removal' do
+ context 'when the source branch is protected' do
+ let(:service) do
+ described_class.new(project, user, should_remove_source_branch: '1')
+ end
+
+ before do
+ create(:protected_branch, project: project, name: merge_request.source_branch)
+ end
+
+ it 'does not delete the source branch' do
+ expect(DeleteBranchService).not_to receive(:new)
+ service.execute(merge_request)
+ end
end
- it 'removes the source branch' do
- expect(DeleteBranchService).to receive(:new).
- with(merge_request.source_project, merge_request.author).
- and_call_original
- service.execute(merge_request)
+ context 'when the source branch is the default branch' do
+ let(:service) do
+ described_class.new(project, user, should_remove_source_branch: '1')
+ end
+
+ before do
+ allow(project).to receive(:root_ref?).with(merge_request.source_branch).and_return(true)
+ end
+
+ it 'does not delete the source branch' do
+ expect(DeleteBranchService).not_to receive(:new)
+ service.execute(merge_request)
+ end
+ end
+
+ context 'when the source branch can be removed' do
+ context 'when MR author set the source branch to be removed' do
+ let(:service) do
+ merge_request.merge_params['force_remove_source_branch'] = '1'
+ merge_request.save!
+ described_class.new(project, user, commit_message: 'Awesome message')
+ end
+
+ it 'removes the source branch using the author user' do
+ expect(DeleteBranchService).to receive(:new)
+ .with(merge_request.source_project, merge_request.author)
+ .and_call_original
+ service.execute(merge_request)
+ end
+ end
+
+ context 'when MR merger set the source branch to be removed' do
+ let(:service) do
+ described_class.new(project, user, commit_message: 'Awesome message', should_remove_source_branch: '1')
+ end
+
+ it 'removes the source branch using the current user' do
+ expect(DeleteBranchService).to receive(:new)
+ .with(merge_request.source_project, user)
+ .and_call_original
+ service.execute(merge_request)
+ end
+ end
end
end
context "error handling" do
- let(:service) { MergeRequests::MergeService.new(project, user, commit_message: 'Awesome message') }
+ let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
before do
allow(Rails.logger).to receive(:error)
diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb
index 769b3193275..f17db70faf6 100644
--- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb
+++ b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb
@@ -79,7 +79,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do
context 'when triggered by pipeline with valid ref and sha' do
let(:triggering_pipeline) do
create(:ci_pipeline, project: project, ref: merge_request_ref,
- sha: merge_request_head, status: 'success')
+ sha: merge_request_head, status: 'success',
+ head_pipeline_of: mr_merge_if_green_enabled)
end
it "merges all merge requests with merge when the pipeline succeeds enabled" do
@@ -121,7 +122,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do
let(:conflict_pipeline) do
create(:ci_pipeline, project: project, ref: mr_conflict.source_branch,
- sha: mr_conflict.diff_head_sha, status: 'success')
+ sha: mr_conflict.diff_head_sha, status: 'success',
+ head_pipeline_of: mr_conflict)
end
it 'does not merge the merge request' do
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
new file mode 100644
index 00000000000..a37cdab8928
--- /dev/null
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe MergeRequests::PostMergeService do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, assignee: user) }
+ let(:project) { merge_request.project }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ describe '#execute' do
+ it_behaves_like 'cache counters invalidator'
+ end
+end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 03215a4624a..2af2485eeed 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe MergeRequests::RefreshService, services: true do
+describe MergeRequests::RefreshService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:service) { MergeRequests::RefreshService }
+ let(:service) { described_class }
describe '#execute' do
before do
@@ -57,8 +57,8 @@ describe MergeRequests::RefreshService, services: true do
end
it 'executes hooks with update action' do
- expect(refresh_service).to have_received(:execute_hooks).
- with(@merge_request, 'update', @oldrev)
+ expect(refresh_service).to have_received(:execute_hooks)
+ .with(@merge_request, 'update', @oldrev)
expect(@merge_request.notes).not_to be_empty
expect(@merge_request).to be_open
@@ -83,8 +83,8 @@ describe MergeRequests::RefreshService, services: true do
end
it 'executes hooks with update action' do
- expect(refresh_service).to have_received(:execute_hooks).
- with(@merge_request, 'update', @oldrev)
+ expect(refresh_service).to have_received(:execute_hooks)
+ .with(@merge_request, 'update', @oldrev)
expect(@merge_request.notes).not_to be_empty
expect(@merge_request).to be_open
@@ -98,18 +98,52 @@ describe MergeRequests::RefreshService, services: true do
end
context 'push to origin repo target branch' do
- before do
- service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
- reload_mrs
+ context 'when all MRs to the target branch had diffs' do
+ before do
+ service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
+ reload_mrs
+ end
+
+ it 'updates the merge state' do
+ expect(@merge_request.notes.last.note).to include('merged')
+ expect(@merge_request).to be_merged
+ expect(@fork_merge_request).to be_merged
+ expect(@fork_merge_request.notes.last.note).to include('merged')
+ expect(@build_failed_todo).to be_done
+ expect(@fork_build_failed_todo).to be_done
+ end
end
- it 'updates the merge state' do
- expect(@merge_request.notes.last.note).to include('merged')
- expect(@merge_request).to be_merged
- expect(@fork_merge_request).to be_merged
- expect(@fork_merge_request.notes.last.note).to include('merged')
- expect(@build_failed_todo).to be_done
- expect(@fork_build_failed_todo).to be_done
+ context 'when an MR to be closed was empty already' do
+ let!(:empty_fork_merge_request) do
+ create(:merge_request,
+ source_project: @fork_project,
+ source_branch: 'master',
+ target_branch: 'master',
+ target_project: @project)
+ end
+
+ before do
+ # This spec already has a fake push, so pretend that we were targeting
+ # feature all along.
+ empty_fork_merge_request.update_columns(target_branch: 'feature')
+
+ service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
+ reload_mrs
+ empty_fork_merge_request.reload
+ end
+
+ it 'only updates the non-empty MRs' do
+ expect(@merge_request).to be_merged
+ expect(@merge_request.notes.last.note).to include('merged')
+
+ expect(@fork_merge_request).to be_merged
+ expect(@fork_merge_request.notes.last.note).to include('merged')
+
+ expect(empty_fork_merge_request).to be_open
+ expect(empty_fork_merge_request.merge_request_diff.state).to eq('empty')
+ expect(empty_fork_merge_request.notes).to be_empty
+ end
end
end
@@ -146,8 +180,8 @@ describe MergeRequests::RefreshService, services: true do
end
it 'executes hooks with update action' do
- expect(refresh_service).to have_received(:execute_hooks).
- with(@fork_merge_request, 'update', @oldrev)
+ expect(refresh_service).to have_received(:execute_hooks)
+ .with(@fork_merge_request, 'update', @oldrev)
expect(@merge_request.notes).to be_empty
expect(@merge_request).to be_open
@@ -228,8 +262,8 @@ describe MergeRequests::RefreshService, services: true do
let(:refresh_service) { service.new(@fork_project, @user) }
it 'refreshes the merge request' do
- expect(refresh_service).to receive(:execute_hooks).
- with(@fork_merge_request, 'update', Gitlab::Git::BLANK_SHA)
+ expect(refresh_service).to receive(:execute_hooks)
+ .with(@fork_merge_request, 'update', Gitlab::Git::BLANK_SHA)
allow_any_instance_of(Repository).to receive(:merge_base).and_return(@oldrev)
refresh_service.execute(Gitlab::Git::BLANK_SHA, @newrev, 'refs/heads/master')
@@ -348,7 +382,7 @@ describe MergeRequests::RefreshService, services: true do
title: 'fixup! Fix issue',
work_in_progress?: true,
to_reference: 'ccccccc'
- ),
+ )
])
refresh_service.execute(@oldrev, @newrev, 'refs/heads/wip')
reload_mrs
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index a99d4eac9bd..f02af0c582e 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe MergeRequests::ReopenService, services: true do
+describe MergeRequests::ReopenService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
@@ -14,6 +14,8 @@ describe MergeRequests::ReopenService, services: true do
end
describe '#execute' do
+ it_behaves_like 'cache counters invalidator'
+
context 'valid params' do
let(:service) { described_class.new(project, user, {}) }
@@ -26,11 +28,11 @@ describe MergeRequests::ReopenService, services: true do
end
it { expect(merge_request).to be_valid }
- it { expect(merge_request).to be_reopened }
+ it { expect(merge_request).to be_opened }
it 'executes hooks with reopen action' do
- expect(service).to have_received(:execute_hooks).
- with(merge_request, 'reopen')
+ expect(service).to have_received(:execute_hooks)
+ .with(merge_request, 'reopen')
end
it 'sends email to user2 about reopen of merge_request' do
diff --git a/spec/services/merge_requests/resolve_service_spec.rb b/spec/services/merge_requests/resolve_service_spec.rb
deleted file mode 100644
index eaf7785e549..00000000000
--- a/spec/services/merge_requests/resolve_service_spec.rb
+++ /dev/null
@@ -1,213 +0,0 @@
-require 'spec_helper'
-
-describe MergeRequests::ResolveService do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- let(:fork_project) do
- create(:forked_project_with_submodules) do |fork_project|
- fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id)
- fork_project.save
- end
- end
-
- let(:merge_request) do
- create(:merge_request,
- source_branch: 'conflict-resolvable', source_project: project,
- target_branch: 'conflict-start')
- end
-
- let(:merge_request_from_fork) do
- create(:merge_request,
- source_branch: 'conflict-resolvable-fork', source_project: fork_project,
- target_branch: 'conflict-start', target_project: project)
- end
-
- describe '#execute' do
- context 'with section params' do
- let(:params) do
- {
- files: [
- {
- old_path: 'files/ruby/popen.rb',
- new_path: 'files/ruby/popen.rb',
- sections: {
- '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head'
- }
- }, {
- old_path: 'files/ruby/regex.rb',
- new_path: 'files/ruby/regex.rb',
- sections: {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
- }
- }
- ],
- commit_message: 'This is a commit message!'
- }
- end
-
- context 'when the source and target project are the same' do
- before do
- MergeRequests::ResolveService.new(project, user, params).execute(merge_request)
- end
-
- it 'creates a commit with the message' do
- expect(merge_request.source_branch_head.message).to eq(params[:commit_message])
- end
-
- it 'creates a commit with the correct parents' do
- expect(merge_request.source_branch_head.parents.map(&:id)).
- to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06
- 824be604a34828eb682305f0d963056cfac87b2d))
- end
- end
-
- context 'when the source project is a fork and does not contain the HEAD of the target branch' do
- let!(:target_head) do
- project.repository.create_file(
- user,
- 'new-file-in-target',
- '',
- message: 'Add new file in target',
- branch_name: 'conflict-start')
- end
-
- before do
- MergeRequests::ResolveService.new(fork_project, user, params).execute(merge_request_from_fork)
- end
-
- it 'creates a commit with the message' do
- expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message])
- end
-
- it 'creates a commit with the correct parents' do
- expect(merge_request_from_fork.source_branch_head.parents.map(&:id)).
- to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813',
- target_head])
- end
- end
- end
-
- context 'with content and sections params' do
- let(:popen_content) { "class Popen\nend" }
-
- let(:params) do
- {
- files: [
- {
- old_path: 'files/ruby/popen.rb',
- new_path: 'files/ruby/popen.rb',
- content: popen_content
- }, {
- old_path: 'files/ruby/regex.rb',
- new_path: 'files/ruby/regex.rb',
- sections: {
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
- '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin'
- }
- }
- ],
- commit_message: 'This is a commit message!'
- }
- end
-
- before do
- MergeRequests::ResolveService.new(project, user, params).execute(merge_request)
- end
-
- it 'creates a commit with the message' do
- expect(merge_request.source_branch_head.message).to eq(params[:commit_message])
- end
-
- it 'creates a commit with the correct parents' do
- expect(merge_request.source_branch_head.parents.map(&:id)).
- to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06
- 824be604a34828eb682305f0d963056cfac87b2d))
- end
-
- it 'sets the content to the content given' do
- blob = merge_request.source_project.repository.blob_at(merge_request.source_branch_head.sha,
- 'files/ruby/popen.rb')
-
- expect(blob.data).to eq(popen_content)
- end
- end
-
- context 'when a resolution section is missing' do
- let(:invalid_params) do
- {
- files: [
- {
- old_path: 'files/ruby/popen.rb',
- new_path: 'files/ruby/popen.rb',
- content: ''
- }, {
- old_path: 'files/ruby/regex.rb',
- new_path: 'files/ruby/regex.rb',
- sections: { '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head' }
- }
- ],
- commit_message: 'This is a commit message!'
- }
- end
-
- let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) }
-
- it 'raises a MissingResolution error' do
- expect { service.execute(merge_request) }.
- to raise_error(Gitlab::Conflict::File::MissingResolution)
- end
- end
-
- context 'when the content of a file is unchanged' do
- let(:invalid_params) do
- {
- files: [
- {
- old_path: 'files/ruby/popen.rb',
- new_path: 'files/ruby/popen.rb',
- content: ''
- }, {
- old_path: 'files/ruby/regex.rb',
- new_path: 'files/ruby/regex.rb',
- content: merge_request.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content
- }
- ],
- commit_message: 'This is a commit message!'
- }
- end
-
- let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) }
-
- it 'raises a MissingResolution error' do
- expect { service.execute(merge_request) }.
- to raise_error(Gitlab::Conflict::File::MissingResolution)
- end
- end
-
- context 'when a file is missing' do
- let(:invalid_params) do
- {
- files: [
- {
- old_path: 'files/ruby/popen.rb',
- new_path: 'files/ruby/popen.rb',
- content: ''
- }
- ],
- commit_message: 'This is a commit message!'
- }
- end
-
- let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) }
-
- it 'raises a MissingFiles error' do
- expect { service.execute(merge_request) }.
- to raise_error(MergeRequests::ResolveService::MissingFiles)
- end
- end
- end
-end
diff --git a/spec/services/merge_requests/resolved_discussion_notification_service.rb b/spec/services/merge_requests/resolved_discussion_notification_service.rb
deleted file mode 100644
index 7ddd812e513..00000000000
--- a/spec/services/merge_requests/resolved_discussion_notification_service.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-require 'spec_helper'
-
-describe MergeRequests::ResolvedDiscussionNotificationService, services: true do
- let(:merge_request) { create(:merge_request) }
- let(:user) { create(:user) }
- let(:project) { merge_request.project }
- subject { described_class.new(project, user) }
-
- describe "#execute" do
- context "when not all discussions are resolved" do
- before do
- allow(merge_request).to receive(:discussions_resolved?).and_return(false)
- end
-
- it "doesn't add a system note" do
- expect(SystemNoteService).not_to receive(:resolve_all_discussions)
-
- subject.execute(merge_request)
- end
-
- it "doesn't send a notification email" do
- expect_any_instance_of(NotificationService).not_to receive(:resolve_all_discussions)
-
- subject.execute(merge_request)
- end
- end
-
- context "when all discussions are resolved" do
- before do
- allow(merge_request).to receive(:discussions_resolved?).and_return(true)
- end
-
- it "adds a system note" do
- expect(SystemNoteService).to receive(:resolve_all_discussions).with(merge_request, project, user)
-
- subject.execute(merge_request)
- end
-
- it "sends a notification email" do
- expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user)
-
- subject.execute(merge_request)
- end
- end
- end
-end
diff --git a/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb
new file mode 100644
index 00000000000..e3fd906fe7b
--- /dev/null
+++ b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe MergeRequests::ResolvedDiscussionNotificationService do
+ let(:merge_request) { create(:merge_request) }
+ let(:user) { create(:user) }
+ let(:project) { merge_request.project }
+ subject { described_class.new(project, user) }
+
+ describe "#execute" do
+ context "when not all discussions are resolved" do
+ before do
+ allow(merge_request).to receive(:discussions_resolved?).and_return(false)
+ end
+
+ it "doesn't add a system note" do
+ expect(SystemNoteService).not_to receive(:resolve_all_discussions)
+
+ subject.execute(merge_request)
+ end
+
+ it "doesn't send a notification email" do
+ expect_any_instance_of(NotificationService).not_to receive(:resolve_all_discussions)
+
+ subject.execute(merge_request)
+ end
+ end
+
+ context "when all discussions are resolved" do
+ before do
+ allow(merge_request).to receive(:discussions_resolved?).and_return(true)
+ end
+
+ it "adds a system note" do
+ expect(SystemNoteService).to receive(:resolve_all_discussions).with(merge_request, project, user)
+
+ subject.execute(merge_request)
+ end
+
+ it "sends a notification email" do
+ expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user)
+
+ subject.execute(merge_request)
+ end
+ end
+ end
+end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index f2ca1e6fcbd..681feee61d1 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
-describe MergeRequests::UpdateService, services: true do
- include EmailHelpers
-
+describe MergeRequests::UpdateService, :mailer do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
@@ -30,6 +28,13 @@ describe MergeRequests::UpdateService, services: true do
end
end
+ def find_notes(action)
+ @merge_request
+ .notes
+ .joins(:system_note_metadata)
+ .where(system_note_metadata: { action: action })
+ end
+
def update_merge_request(opts)
@merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
@merge_request.reload
@@ -48,7 +53,7 @@ describe MergeRequests::UpdateService, services: true do
}
end
- let(:service) { MergeRequests::UpdateService.new(project, user, opts) }
+ let(:service) { described_class.new(project, user, opts) }
before do
allow(service).to receive(:execute_hooks)
@@ -59,18 +64,20 @@ describe MergeRequests::UpdateService, services: true do
end
end
- it { expect(@merge_request).to be_valid }
- it { expect(@merge_request.title).to eq('New title') }
- it { expect(@merge_request.assignee).to eq(user2) }
- it { expect(@merge_request).to be_closed }
- it { expect(@merge_request.labels.count).to eq(1) }
- it { expect(@merge_request.labels.first.title).to eq(label.name) }
- it { expect(@merge_request.target_branch).to eq('target') }
- it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') }
+ it 'mathces base expectations' do
+ expect(@merge_request).to be_valid
+ expect(@merge_request.title).to eq('New title')
+ expect(@merge_request.assignee).to eq(user2)
+ expect(@merge_request).to be_closed
+ expect(@merge_request.labels.count).to eq(1)
+ expect(@merge_request.labels.first.title).to eq(label.name)
+ expect(@merge_request.target_branch).to eq('target')
+ expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1')
+ end
it 'executes hooks with update action' do
- expect(service).to have_received(:execute_hooks).
- with(@merge_request, 'update')
+ expect(service).to have_received(:execute_hooks)
+ .with(@merge_request, 'update')
end
it 'sends email to user2 about assign of new merge request and email to user3 about merge request unassignment' do
@@ -102,6 +109,13 @@ describe MergeRequests::UpdateService, services: true do
expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**'
end
+ it 'creates system note about description change' do
+ note = find_note('changed the description')
+
+ expect(note).not_to be_nil
+ expect(note.note).to eq('changed the description')
+ end
+
it 'creates system note about branch change' do
note = find_note('changed target')
@@ -129,7 +143,7 @@ describe MergeRequests::UpdateService, services: true do
}
end
- let(:service) { MergeRequests::UpdateService.new(project, user, opts) }
+ let(:service) { described_class.new(project, user, opts) }
context 'without pipeline' do
before do
@@ -141,9 +155,11 @@ describe MergeRequests::UpdateService, services: true do
end
end
- it { expect(@merge_request).to be_valid }
- it { expect(@merge_request.state).to eq('merged') }
- it { expect(@merge_request.merge_error).to be_nil }
+ it 'merges the MR' do
+ expect(@merge_request).to be_valid
+ expect(@merge_request.state).to eq('merged')
+ expect(@merge_request.merge_error).to be_nil
+ end
end
context 'with finished pipeline' do
@@ -160,20 +176,25 @@ describe MergeRequests::UpdateService, services: true do
end
end
- it { expect(@merge_request).to be_valid }
- it { expect(@merge_request.state).to eq('merged') }
+ it 'merges the MR' do
+ expect(@merge_request).to be_valid
+ expect(@merge_request.state).to eq('merged')
+ end
end
context 'with active pipeline' do
before do
service_mock = double
- create(:ci_pipeline_with_one_job,
+ create(
+ :ci_pipeline_with_one_job,
project: project,
- ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha)
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha,
+ head_pipeline_of: merge_request
+ )
- expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user).
- and_return(service_mock)
+ expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user)
+ .and_return(service_mock)
expect(service_mock).to receive(:execute).with(merge_request)
end
@@ -182,7 +203,7 @@ describe MergeRequests::UpdateService, services: true do
context 'with a non-authorised user' do
let(:visitor) { create(:user) }
- let(:service) { MergeRequests::UpdateService.new(project, visitor, opts) }
+ let(:service) { described_class.new(project, visitor, opts) }
before do
merge_request.update_attribute(:merge_error, 'Error')
@@ -193,8 +214,10 @@ describe MergeRequests::UpdateService, services: true do
end
end
- it { expect(@merge_request.state).to eq('opened') }
- it { expect(@merge_request.merge_error).not_to be_nil }
+ it 'does not merge the MR' do
+ expect(@merge_request.state).to eq('opened')
+ expect(@merge_request.merge_error).not_to be_nil
+ end
end
context 'MR can not be merged when note sha != MR sha' do
@@ -271,13 +294,13 @@ describe MergeRequests::UpdateService, services: true do
end
context 'when the milestone change' do
- before do
+ it 'marks pending todos as done' do
update_merge_request({ milestone: create(:milestone) })
- end
- it 'marks pending todos as done' do
expect(pending_todo.reload).to be_done
end
+
+ it_behaves_like 'system notes for milestones'
end
context 'when the labels change' do
@@ -290,6 +313,15 @@ describe MergeRequests::UpdateService, services: true do
end
end
+ context 'when the assignee changes' do
+ it 'updates open merge request counter for assignees when merge request is reassigned' do
+ update_merge_request(assignee_id: user2.id)
+
+ expect(user3.assigned_open_merge_requests_count).to eq 0
+ expect(user2.assigned_open_merge_requests_count).to eq 1
+ end
+ end
+
context 'when the target branch change' do
before do
update_merge_request({ target_branch: 'target' })
@@ -314,7 +346,7 @@ describe MergeRequests::UpdateService, services: true do
opts = { label_ids: [label.id] }
perform_enqueued_jobs do
- @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ @merge_request = described_class.new(project, user, opts).execute(merge_request)
end
should_email(subscriber)
@@ -322,13 +354,15 @@ describe MergeRequests::UpdateService, services: true do
end
context 'when issue has the `label` label' do
- before { merge_request.labels << label }
+ before do
+ merge_request.labels << label
+ end
it 'does not send notifications for existing labels' do
opts = { label_ids: [label.id, label2.id] }
perform_enqueued_jobs do
- @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ @merge_request = described_class.new(project, user, opts).execute(merge_request)
end
should_not_email(subscriber)
@@ -339,7 +373,7 @@ describe MergeRequests::UpdateService, services: true do
opts = { label_ids: [label2.id] }
perform_enqueued_jobs do
- @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ @merge_request = described_class.new(project, user, opts).execute(merge_request)
end
should_not_email(subscriber)
@@ -350,16 +384,20 @@ describe MergeRequests::UpdateService, services: true do
context 'updating mentions' do
let(:mentionable) { merge_request }
- include_examples 'updating mentions', MergeRequests::UpdateService
+ include_examples 'updating mentions', described_class
end
context 'when MergeRequest has tasks' do
- before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) }
+ before do
+ update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" })
+ end
it { expect(@merge_request.tasks?).to eq(true) }
context 'when tasks are marked as completed' do
- before { update_merge_request({ description: "- [x] Task 1\n- [X] Task 2" }) }
+ before do
+ update_merge_request({ description: "- [x] Task 1\n- [X] Task 2" })
+ end
it 'creates system note about task status change' do
note1 = find_note('marked the task **Task 1** as completed')
@@ -367,6 +405,9 @@ describe MergeRequests::UpdateService, services: true do
expect(note1).not_to be_nil
expect(note2).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
end
end
@@ -382,6 +423,9 @@ describe MergeRequests::UpdateService, services: true do
expect(note1).not_to be_nil
expect(note2).not_to be_nil
+
+ description_notes = find_notes('description')
+ expect(description_notes.length).to eq(1)
end
end
end
@@ -423,6 +467,54 @@ describe MergeRequests::UpdateService, services: true do
end
end
+ context 'updating asssignee_id' do
+ it 'does not update assignee when assignee_id is invalid' do
+ merge_request.update(assignee_id: user.id)
+
+ update_merge_request(assignee_id: -1)
+
+ expect(merge_request.reload.assignee).to eq(user)
+ end
+
+ it 'unassigns assignee when user id is 0' do
+ merge_request.update(assignee_id: user.id)
+
+ update_merge_request(assignee_id: 0)
+
+ expect(merge_request.assignee_id).to be_nil
+ end
+
+ it 'saves assignee when user id is valid' do
+ update_merge_request(assignee_id: user.id)
+
+ expect(merge_request.assignee_id).to eq(user.id)
+ end
+
+ it 'does not update assignee_id when user cannot read issue' do
+ non_member = create(:user)
+ original_assignee = merge_request.assignee
+
+ update_merge_request(assignee_id: non_member.id)
+
+ expect(merge_request.assignee_id).to eq(original_assignee.id)
+ end
+
+ context "when issuable feature is private" do
+ levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
+
+ levels.each do |level|
+ it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
+ assignee = create(:user)
+ project.update(visibility_level: level)
+ feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level"
+ project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE)
+
+ expect { update_merge_request(assignee_id: assignee) }.not_to change { merge_request.assignee }
+ end
+ end
+ end
+ end
+
include_examples 'issuable update service' do
let(:open_issuable) { merge_request }
let(:closed_issuable) { create(:closed_merge_request, source_project: project) }
diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb
index d581b94ff43..2bdf224804d 100644
--- a/spec/services/milestones/close_service_spec.rb
+++ b/spec/services/milestones/close_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Milestones::CloseService, services: true do
+describe Milestones::CloseService do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:milestone) { create(:milestone, title: "Milestone v1.2", project: project) }
before do
@@ -11,7 +11,7 @@ describe Milestones::CloseService, services: true do
describe '#execute' do
before do
- Milestones::CloseService.new(project, user, {}).execute(milestone)
+ described_class.new(project, user, {}).execute(milestone)
end
it { expect(milestone).to be_valid }
diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb
index 6d29edb449a..8837b91051d 100644
--- a/spec/services/milestones/create_service_spec.rb
+++ b/spec/services/milestones/create_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Milestones::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe Milestones::CreateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
describe '#execute' do
@@ -14,7 +14,7 @@ describe Milestones::CreateService, services: true do
description: 'Patch release to fix security issue'
}
- @milestone = Milestones::CreateService.new(project, user, opts).execute
+ @milestone = described_class.new(project, user, opts).execute
end
it { expect(@milestone).to be_valid }
diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb
new file mode 100644
index 00000000000..5739386dd0d
--- /dev/null
+++ b/spec/services/milestones/destroy_service_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Milestones::DestroyService do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) }
+ let(:issue) { create(:issue, project: project, milestone: milestone) }
+ let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ def service
+ described_class.new(project, user, {})
+ end
+
+ describe '#execute' do
+ it 'deletes milestone' do
+ service.execute(milestone)
+
+ expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+
+ it 'deletes milestone id from issuables' do
+ service.execute(milestone)
+
+ expect(issue.reload.milestone).to be_nil
+ expect(merge_request.reload.milestone).to be_nil
+ end
+
+ context 'group milestones' do
+ let(:group) { create(:group) }
+ let(:group_milestone) { create(:milestone, group: group) }
+
+ before do
+ project.update(namespace: group)
+ group.add_developer(user)
+ end
+
+ it { expect(service.execute(group_milestone)).to be_nil }
+
+ it 'does not update milestone issuables' do
+ expect(MergeRequests::UpdateService).not_to receive(:new)
+ expect(Issues::UpdateService).not_to receive(:new)
+
+ service.execute(group_milestone)
+ end
+ end
+ end
+end
diff --git a/spec/services/note_summary_spec.rb b/spec/services/note_summary_spec.rb
index 39f06f8f8e7..a6cc2251e48 100644
--- a/spec/services/note_summary_spec.rb
+++ b/spec/services/note_summary_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe NoteSummary, services: true do
- let(:project) { build(:empty_project) }
+describe NoteSummary do
+ let(:project) { build(:project) }
let(:noteable) { build(:issue) }
let(:user) { build(:user) }
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index f9dd5541b10..6e1c1fe6c02 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Notes::BuildService, services: true do
+describe Notes::BuildService do
let(:note) { create(:discussion_note_on_issue) }
let(:project) { note.project }
let(:author) { note.author }
@@ -29,10 +29,82 @@ describe Notes::BuildService, services: true do
expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
end
end
+
+ context 'personal snippet note' do
+ def reply(note, user = nil)
+ user ||= create(:user)
+
+ described_class.new(nil,
+ user,
+ note: 'Test',
+ in_reply_to_discussion_id: note.discussion_id).execute
+ end
+
+ let(:snippet_author) { create(:user) }
+
+ context 'when a snippet is public' do
+ it 'creates a reply note' do
+ snippet = create(:personal_snippet, :public)
+ note = create(:discussion_note_on_personal_snippet, noteable: snippet)
+
+ new_note = reply(note)
+
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+ end
+
+ context 'when a snippet is private' do
+ let(:snippet) { create(:personal_snippet, :private, author: snippet_author) }
+ let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
+
+ it 'creates a reply note when the author replies' do
+ new_note = reply(note, snippet_author)
+
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+
+ it 'sets an error when another user replies' do
+ new_note = reply(note)
+
+ expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
+ end
+ end
+
+ context 'when a snippet is internal' do
+ let(:snippet) { create(:personal_snippet, :internal, author: snippet_author) }
+ let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
+
+ it 'creates a reply note when the author replies' do
+ new_note = reply(note, snippet_author)
+
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+
+ it 'creates a reply note when a regular user replies' do
+ new_note = reply(note)
+
+ expect(new_note).to be_valid
+ expect(new_note.in_reply_to?(note)).to be_truthy
+ end
+
+ it 'sets an error when an external user replies' do
+ new_note = reply(note, create(:user, :external))
+
+ expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
+ end
+ 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
+ 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
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 152c6d20daa..661d26946e7 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Notes::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe Notes::CreateService do
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
let(:opts) do
diff --git a/spec/services/notes/destroy_service_spec.rb b/spec/services/notes/destroy_service_spec.rb
index f53f96e0c2b..c9a99a43edb 100644
--- a/spec/services/notes/destroy_service_spec.rb
+++ b/spec/services/notes/destroy_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe Notes::DestroyService, services: true do
+describe Notes::DestroyService do
describe '#execute' do
it 'deletes a note' do
- project = create(:empty_project)
+ project = create(:project)
issue = create(:issue, project: project)
note = create(:note, project: project, noteable: issue)
diff --git a/spec/services/notes/diff_position_update_service_spec.rb b/spec/services/notes/diff_position_update_service_spec.rb
deleted file mode 100644
index d73ae51fbc3..00000000000
--- a/spec/services/notes/diff_position_update_service_spec.rb
+++ /dev/null
@@ -1,175 +0,0 @@
-require 'spec_helper'
-
-describe Notes::DiffPositionUpdateService, services: true do
- let(:project) { create(:project, :repository) }
- let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") }
- let(:modify_commit) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e") }
- let(:edit_commit) { project.commit("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") }
-
- let(:path) { "files/ruby/popen.rb" }
-
- let(:old_diff_refs) do
- Gitlab::Diff::DiffRefs.new(
- base_sha: create_commit.parent_id,
- head_sha: modify_commit.sha
- )
- end
-
- let(:new_diff_refs) do
- Gitlab::Diff::DiffRefs.new(
- base_sha: create_commit.parent_id,
- head_sha: edit_commit.sha
- )
- end
-
- subject do
- described_class.new(
- project,
- nil,
- old_diff_refs: old_diff_refs,
- new_diff_refs: new_diff_refs,
- paths: [path]
- )
- end
-
- # old diff:
- # 1 + require 'fileutils'
- # 2 + require 'open3'
- # 3 +
- # 4 + module Popen
- # 5 + extend self
- # 6 +
- # 7 + def popen(cmd, path=nil)
- # 8 + unless cmd.is_a?(Array)
- # 9 + raise "System commands must be given as an array of strings"
- # 10 + end
- # 11 +
- # 12 + path ||= Dir.pwd
- # 13 + vars = { "PWD" => path }
- # 14 + options = { chdir: path }
- # 15 +
- # 16 + unless File.directory?(path)
- # 17 + FileUtils.mkdir_p(path)
- # 18 + end
- # 19 +
- # 20 + @cmd_output = ""
- # 21 + @cmd_status = 0
- # 22 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
- # 23 + @cmd_output << stdout.read
- # 24 + @cmd_output << stderr.read
- # 25 + @cmd_status = wait_thr.value.exitstatus
- # 26 + end
- # 27 +
- # 28 + return @cmd_output, @cmd_status
- # 29 + end
- # 30 + end
- #
- # new diff:
- # 1 + require 'fileutils'
- # 2 + require 'open3'
- # 3 +
- # 4 + module Popen
- # 5 + extend self
- # 6 +
- # 7 + def popen(cmd, path=nil)
- # 8 + unless cmd.is_a?(Array)
- # 9 + raise RuntimeError, "System commands must be given as an array of strings"
- # 10 + end
- # 11 +
- # 12 + path ||= Dir.pwd
- # 13 +
- # 14 + vars = {
- # 15 + "PWD" => path
- # 16 + }
- # 17 +
- # 18 + options = {
- # 19 + chdir: path
- # 20 + }
- # 21 +
- # 22 + unless File.directory?(path)
- # 23 + FileUtils.mkdir_p(path)
- # 24 + end
- # 25 +
- # 26 + @cmd_output = ""
- # 27 + @cmd_status = 0
- # 28 +
- # 29 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
- # 30 + @cmd_output << stdout.read
- # 31 + @cmd_output << stderr.read
- # 32 + @cmd_status = wait_thr.value.exitstatus
- # 33 + end
- # 34 +
- # 35 + return @cmd_output, @cmd_status
- # 36 + end
- # 37 + end
- #
- # old->new diff:
- # .. .. @@ -6,12 +6,18 @@ module Popen
- # 6 6
- # 7 7 def popen(cmd, path=nil)
- # 8 8 unless cmd.is_a?(Array)
- # 9 - raise "System commands must be given as an array of strings"
- # 9 + raise RuntimeError, "System commands must be given as an array of strings"
- # 10 10 end
- # 11 11
- # 12 12 path ||= Dir.pwd
- # 13 - vars = { "PWD" => path }
- # 14 - options = { chdir: path }
- # 13 +
- # 14 + vars = {
- # 15 + "PWD" => path
- # 16 + }
- # 17 +
- # 18 + options = {
- # 19 + chdir: path
- # 20 + }
- # 15 21
- # 16 22 unless File.directory?(path)
- # 17 23 FileUtils.mkdir_p(path)
- # 18 24 end
- # 19 25
- # 20 26 @cmd_output = ""
- # 21 27 @cmd_status = 0
- # 28 +
- # 22 29 Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
- # 23 30 @cmd_output << stdout.read
- # 24 31 @cmd_output << stderr.read
- # .. ..
-
- describe "#execute" do
- let(:note) { create(:diff_note_on_merge_request, project: project, position: old_position) }
-
- let(:old_position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- old_line: nil,
- new_line: line,
- diff_refs: old_diff_refs
- )
- end
-
- context "when the diff line is the same" do
- let(:line) { 16 }
-
- it "updates the position" do
- subject.execute(note)
-
- expect(note.original_position).to eq(old_position)
- expect(note.position).not_to eq(old_position)
- expect(note.position.new_line).to eq(22)
- end
- end
-
- context "when the diff line has changed" do
- let(:line) { 9 }
-
- it "doesn't update the position" do
- subject.execute(note)
-
- expect(note.original_position).to eq(old_position)
- expect(note.position).to eq(old_position)
- end
- end
- end
-end
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index e33a611929b..a2b3638059f 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Notes::PostProcessService, services: true do
- let(:project) { create(:empty_project) }
+describe Notes::PostProcessService do
+ let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
@@ -21,7 +21,7 @@ describe Notes::PostProcessService, services: true do
expect(project).to receive(:execute_hooks)
expect(project).to receive(:execute_services)
- Notes::PostProcessService.new(@note).execute
+ described_class.new(@note).execute
end
end
end
diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb
new file mode 100644
index 00000000000..0280a19098b
--- /dev/null
+++ b/spec/services/notes/quick_actions_service_spec.rb
@@ -0,0 +1,252 @@
+require 'spec_helper'
+
+describe Notes::QuickActionsService do
+ shared_context 'note on noteable' do
+ let(:project) { create(:project) }
+ let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:assignee) { create(:user) }
+
+ before do
+ project.team << [assignee, :master]
+ end
+ end
+
+ shared_examples 'note on noteable that does not support quick actions' do
+ include_context 'note on noteable'
+
+ before do
+ note.note = note_text
+ end
+
+ describe 'note with only command' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) { %(/close\n/assign @#{assignee.username}") }
+
+ it 'saves the note and does not alter the note text' do
+ content, command_params = service.extract_commands(note)
+
+ expect(content).to eq note_text
+ expect(command_params).to be_empty
+ end
+ end
+ end
+
+ describe 'note with command & text' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) }
+
+ it 'saves the note and does not alter the note text' do
+ content, command_params = service.extract_commands(note)
+
+ expect(content).to eq note_text
+ expect(command_params).to be_empty
+ end
+ end
+ end
+ end
+
+ shared_examples 'note on noteable that supports quick actions' do
+ include_context 'note on noteable'
+
+ before do
+ note.note = note_text
+ end
+
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:labels) { create_pair(:label, project: project) }
+
+ describe 'note with only command' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) do
+ %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+ end
+
+ it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq ''
+ expect(note.noteable).to be_closed
+ expect(note.noteable.labels).to match_array(labels)
+ expect(note.noteable.assignees).to eq([assignee])
+ expect(note.noteable.milestone).to eq(milestone)
+ end
+ end
+
+ describe '/reopen' do
+ before do
+ note.noteable.close!
+ expect(note.noteable).to be_closed
+ end
+ let(:note_text) { '/reopen' }
+
+ it 'opens the noteable, and leave no note' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq ''
+ expect(note.noteable).to be_open
+ end
+ end
+
+ describe '/spend' do
+ let(:note_text) { '/spend 1h' }
+
+ it 'updates the spent time on the noteable' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq ''
+ expect(note.noteable.time_spent).to eq(3600)
+ end
+ end
+ end
+
+ describe 'note with command & text' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) do
+ %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD)
+ end
+
+ it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq "HELLO\nWORLD"
+ expect(note.noteable).to be_closed
+ expect(note.noteable.labels).to match_array(labels)
+ expect(note.noteable.assignees).to eq([assignee])
+ expect(note.noteable.milestone).to eq(milestone)
+ end
+ end
+
+ describe '/reopen' do
+ before do
+ note.noteable.close
+ expect(note.noteable).to be_closed
+ end
+ let(:note_text) { "HELLO\n/reopen\nWORLD" }
+
+ it 'opens the noteable' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq "HELLO\nWORLD"
+ expect(note.noteable).to be_open
+ end
+ end
+ end
+ end
+
+ describe '.noteable_update_service' do
+ include_context 'note on noteable'
+
+ it 'returns Issues::UpdateService for a note on an issue' do
+ note = create(:note_on_issue, project: project)
+
+ expect(described_class.noteable_update_service(note)).to eq(Issues::UpdateService)
+ end
+
+ it 'returns Issues::UpdateService for a note on a merge request' do
+ note = create(:note_on_merge_request, project: project)
+
+ expect(described_class.noteable_update_service(note)).to eq(MergeRequests::UpdateService)
+ end
+
+ it 'returns nil for a note on a commit' do
+ note = create(:note_on_commit, project: project)
+
+ expect(described_class.noteable_update_service(note)).to be_nil
+ end
+ end
+
+ describe '.supported?' do
+ include_context 'note on noteable'
+
+ let(:note) { create(:note_on_issue, project: project) }
+
+ context 'with no current_user' do
+ it 'returns false' do
+ expect(described_class.supported?(note, nil)).to be_falsy
+ end
+ end
+
+ context 'when current_user cannot update the noteable' do
+ it 'returns false' do
+ user = create(:user)
+
+ expect(described_class.supported?(note, user)).to be_falsy
+ end
+ end
+
+ context 'when current_user can update the noteable' do
+ it 'returns true' do
+ expect(described_class.supported?(note, master)).to be_truthy
+ end
+
+ context 'with a note on a commit' do
+ let(:note) { create(:note_on_commit, project: project) }
+
+ it 'returns false' do
+ expect(described_class.supported?(note, nil)).to be_falsy
+ end
+ end
+ end
+ end
+
+ describe '#supported?' do
+ include_context 'note on noteable'
+
+ it 'delegates to the class method' do
+ service = described_class.new(project, master)
+ note = create(:note_on_issue, project: project)
+
+ expect(described_class).to receive(:supported?).with(note, master)
+
+ service.supported?(note)
+ end
+ end
+
+ describe '#execute' do
+ let(:service) { described_class.new(project, master) }
+
+ it_behaves_like 'note on noteable that supports quick actions' do
+ let(:note) { build(:note_on_issue, project: project) }
+ end
+
+ it_behaves_like 'note on noteable that supports quick actions' do
+ let(:note) { build(:note_on_merge_request, project: project) }
+ end
+
+ it_behaves_like 'note on noteable that does not support quick actions' do
+ let(:note) { build(:note_on_commit, project: project) }
+ end
+ end
+
+ context 'CE restriction for issue assignees' do
+ describe '/assign' do
+ let(:project) { create(:project) }
+ let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:assignee) { create(:user) }
+ let(:master) { create(:user) }
+ let(:service) { described_class.new(project, master) }
+ let(:note) { create(:note_on_issue, note: note_text, project: project) }
+
+ let(:note_text) do
+ %(/assign @#{assignee.username} @#{master.username}\n")
+ end
+
+ before do
+ project.team << [master, :master]
+ project.team << [assignee, :master]
+ end
+
+ it 'adds only one assignee from the list' do
+ _, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(note.noteable.assignees.count).to eq(1)
+ end
+ end
+ end
+end
diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb
deleted file mode 100644
index 1a64c8bbf00..00000000000
--- a/spec/services/notes/slash_commands_service_spec.rb
+++ /dev/null
@@ -1,223 +0,0 @@
-require 'spec_helper'
-
-describe Notes::SlashCommandsService, services: true do
- shared_context 'note on noteable' do
- let(:project) { create(:empty_project) }
- let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
- let(:assignee) { create(:user) }
-
- before { project.team << [assignee, :master] }
- end
-
- shared_examples 'note on noteable that does not support slash commands' do
- include_context 'note on noteable'
-
- before do
- note.note = note_text
- end
-
- describe 'note with only command' do
- describe '/close, /label, /assign & /milestone' do
- let(:note_text) { %(/close\n/assign @#{assignee.username}") }
-
- it 'saves the note and does not alter the note text' do
- content, command_params = service.extract_commands(note)
-
- expect(content).to eq note_text
- expect(command_params).to be_empty
- end
- end
- end
-
- describe 'note with command & text' do
- describe '/close, /label, /assign & /milestone' do
- let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) }
-
- it 'saves the note and does not alter the note text' do
- content, command_params = service.extract_commands(note)
-
- expect(content).to eq note_text
- expect(command_params).to be_empty
- end
- end
- end
- end
-
- shared_examples 'note on noteable that supports slash commands' do
- include_context 'note on noteable'
-
- before do
- note.note = note_text
- end
-
- let!(:milestone) { create(:milestone, project: project) }
- let!(:labels) { create_pair(:label, project: project) }
-
- describe 'note with only command' do
- describe '/close, /label, /assign & /milestone' do
- let(:note_text) do
- %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
- end
-
- it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
- content, command_params = service.extract_commands(note)
- service.execute(command_params, note)
-
- expect(content).to eq ''
- expect(note.noteable).to be_closed
- expect(note.noteable.labels).to match_array(labels)
- expect(note.noteable.assignee).to eq(assignee)
- expect(note.noteable.milestone).to eq(milestone)
- end
- end
-
- describe '/reopen' do
- before do
- note.noteable.close!
- expect(note.noteable).to be_closed
- end
- let(:note_text) { '/reopen' }
-
- it 'opens the noteable, and leave no note' do
- content, command_params = service.extract_commands(note)
- service.execute(command_params, note)
-
- expect(content).to eq ''
- expect(note.noteable).to be_open
- end
- end
-
- describe '/spend' do
- let(:note_text) { '/spend 1h' }
-
- it 'updates the spent time on the noteable' do
- content, command_params = service.extract_commands(note)
- service.execute(command_params, note)
-
- expect(content).to eq ''
- expect(note.noteable.time_spent).to eq(3600)
- end
- end
- end
-
- describe 'note with command & text' do
- describe '/close, /label, /assign & /milestone' do
- let(:note_text) do
- %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD)
- end
-
- it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
- content, command_params = service.extract_commands(note)
- service.execute(command_params, note)
-
- expect(content).to eq "HELLO\nWORLD"
- expect(note.noteable).to be_closed
- expect(note.noteable.labels).to match_array(labels)
- expect(note.noteable.assignee).to eq(assignee)
- expect(note.noteable.milestone).to eq(milestone)
- end
- end
-
- describe '/reopen' do
- before do
- note.noteable.close
- expect(note.noteable).to be_closed
- end
- let(:note_text) { "HELLO\n/reopen\nWORLD" }
-
- it 'opens the noteable' do
- content, command_params = service.extract_commands(note)
- service.execute(command_params, note)
-
- expect(content).to eq "HELLO\nWORLD"
- expect(note.noteable).to be_open
- end
- end
- end
- end
-
- describe '.noteable_update_service' do
- include_context 'note on noteable'
-
- it 'returns Issues::UpdateService for a note on an issue' do
- note = create(:note_on_issue, project: project)
-
- expect(described_class.noteable_update_service(note)).to eq(Issues::UpdateService)
- end
-
- it 'returns Issues::UpdateService for a note on a merge request' do
- note = create(:note_on_merge_request, project: project)
-
- expect(described_class.noteable_update_service(note)).to eq(MergeRequests::UpdateService)
- end
-
- it 'returns nil for a note on a commit' do
- note = create(:note_on_commit, project: project)
-
- expect(described_class.noteable_update_service(note)).to be_nil
- end
- end
-
- describe '.supported?' do
- include_context 'note on noteable'
-
- let(:note) { create(:note_on_issue, project: project) }
-
- context 'with no current_user' do
- it 'returns false' do
- expect(described_class.supported?(note, nil)).to be_falsy
- end
- end
-
- context 'when current_user cannot update the noteable' do
- it 'returns false' do
- user = create(:user)
-
- expect(described_class.supported?(note, user)).to be_falsy
- end
- end
-
- context 'when current_user can update the noteable' do
- it 'returns true' do
- expect(described_class.supported?(note, master)).to be_truthy
- end
-
- context 'with a note on a commit' do
- let(:note) { create(:note_on_commit, project: project) }
-
- it 'returns false' do
- expect(described_class.supported?(note, nil)).to be_falsy
- end
- end
- end
- end
-
- describe '#supported?' do
- include_context 'note on noteable'
-
- it 'delegates to the class method' do
- service = described_class.new(project, master)
- note = create(:note_on_issue, project: project)
-
- expect(described_class).to receive(:supported?).with(note, master)
-
- service.supported?(note)
- end
- end
-
- describe '#execute' do
- let(:service) { described_class.new(project, master) }
-
- it_behaves_like 'note on noteable that supports slash commands' do
- let(:note) { build(:note_on_issue, project: project) }
- end
-
- it_behaves_like 'note on noteable that supports slash commands' do
- let(:note) { build(:note_on_merge_request, project: project) }
- end
-
- it_behaves_like 'note on noteable that does not support slash commands' do
- let(:note) { build(:note_on_commit, project: project) }
- end
- end
-end
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 905e2f46bde..3210539f3ee 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Notes::UpdateService, services: true do
- let(:project) { create(:empty_project) }
+describe Notes::UpdateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 989fd90cda9..44b2d28d1d4 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -1,11 +1,10 @@
require 'spec_helper'
-describe NotificationService, services: true do
- include EmailHelpers
+describe NotificationService, :mailer do
+ let(:notification) { described_class.new }
+ let(:assignee) { create(:user) }
- let(:notification) { NotificationService.new }
-
- around(:each) do |example|
+ around do |example|
perform_enqueued_jobs do
example.run
end
@@ -13,7 +12,6 @@ describe NotificationService, services: true do
shared_examples 'notifications for new mentions' do
def send_notifications(*new_mentions)
- reset_delivered_emails!
notification.send(notification_method, mentionable, new_mentions, @u_disabled)
end
@@ -52,7 +50,11 @@ describe NotificationService, services: true do
shared_examples 'participating by assignee notification' do
it 'emails the participant' do
- issuable.update_attribute(:assignee, participant)
+ if issuable.is_a?(Issue)
+ issuable.assignees << participant
+ else
+ issuable.update_attribute(:assignee, participant)
+ end
notification_trigger
@@ -78,12 +80,28 @@ describe NotificationService, services: true do
describe 'Keys' do
describe '#new_key' do
- let!(:key) { create(:personal_key) }
+ let(:key_options) { {} }
+ let!(:key) { create(:personal_key, key_options) }
it { expect(notification.new_key(key)).to be_truthy }
+ it { should_email(key.user) }
+
+ describe 'never emails the ghost user' do
+ let(:key_options) { { user: User.ghost } }
+
+ it { should_not_email_anyone }
+ end
+ end
+ end
+
+ describe 'GpgKeys' do
+ describe '#new_gpg_key' do
+ let!(:key) { create(:gpg_key) }
+
+ it { expect(notification.new_gpg_key(key)).to be_truthy }
it 'sends email to key owner' do
- expect{ notification.new_key(key) }.to change{ ActionMailer::Base.deliveries.size }.by(1)
+ expect { notification.new_gpg_key(key) }.to change { ActionMailer::Base.deliveries.size }.by(1)
end
end
end
@@ -95,22 +113,22 @@ describe NotificationService, services: true do
it { expect(notification.new_email(email)).to be_truthy }
it 'sends email to email owner' do
- expect{ notification.new_email(email) }.to change{ ActionMailer::Base.deliveries.size }.by(1)
+ expect { notification.new_email(email) }.to change { ActionMailer::Base.deliveries.size }.by(1)
end
end
end
describe 'Notes' do
context 'issue note' do
- let(:project) { create(:empty_project, :private) }
- let(:issue) { create(:issue, project: project, assignee: create(:user)) }
- let(:mentioned_issue) { create(:issue, assignee: issue.assignee) }
+ let(:project) { create(:project, :private) }
+ let(:issue) { create(:issue, project: project, assignees: [assignee]) }
+ let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') }
before do
build_team(note.project)
project.add_master(issue.author)
- project.add_master(issue.assignee)
+ project.add_master(assignee)
project.add_master(note.author)
create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@subscribed_participant cc this guy')
update_custom_notification(:new_note, @u_guest_custom, resource: project)
@@ -120,17 +138,16 @@ describe NotificationService, services: true do
describe '#new_note' do
it do
add_users_with_subscription(note.project, issue)
+ reset_delivered_emails!
# Ensure create SentNotification by noteable = issue 6 times, not noteable = note
expect(SentNotification).to receive(:record).with(issue, any_args).exactly(8).times
- reset_delivered_emails!
-
notification.new_note(note)
should_email(@u_watcher)
should_email(note.noteable.author)
- should_email(note.noteable.assignee)
+ should_email(note.noteable.assignees.first)
should_email(@u_custom_global)
should_email(@u_mentioned)
should_email(@subscriber)
@@ -148,9 +165,10 @@ describe NotificationService, services: true do
it "emails the note author if they've opted into notifications about their activity" do
add_users_with_subscription(note.project, issue)
- note.author.notified_of_own_activity = true
reset_delivered_emails!
+ note.author.notified_of_own_activity = true
+
notification.new_note(note)
should_email(note.author)
@@ -196,7 +214,7 @@ describe NotificationService, services: true do
notification.new_note(note)
should_email(note.noteable.author)
- should_email(note.noteable.assignee)
+ should_email(note.noteable.assignees.first)
should_email(@u_mentioned)
should_email(@u_custom_global)
should_not_email(@u_guest_custom)
@@ -211,14 +229,14 @@ describe NotificationService, services: true do
end
context 'confidential issue note' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:author) { create(:user) }
let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) }
let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") }
let(:guest_watcher) { create_user_with_notification(:watch, "guest-watcher-confidential") }
@@ -243,9 +261,9 @@ describe NotificationService, services: true do
end
context 'issue note mention' do
- let(:project) { create(:empty_project, :public) }
- let(:issue) { create(:issue, project: project, assignee: create(:user)) }
- let(:mentioned_issue) { create(:issue, assignee: issue.assignee) }
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project, assignees: [assignee]) }
+ let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') }
before do
@@ -269,7 +287,7 @@ describe NotificationService, services: true do
should_email(@u_guest_watcher)
should_email(note.noteable.author)
- should_email(note.noteable.assignee)
+ should_email(note.noteable.assignees.first)
should_not_email(note.author)
should_email(@u_mentioned)
should_not_email(@u_disabled)
@@ -286,12 +304,17 @@ describe NotificationService, services: true do
end
context 'project snippet note' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:snippet) { create(:project_snippet, project: project, author: create(:user)) }
let(:note) { create(:note_on_project_snippet, noteable: snippet, project_id: snippet.project.id, note: '@all mentioned') }
before do
build_team(note.project)
+
+ # make sure these users can read the project snippet!
+ project.add_guest(@u_guest_watcher)
+ project.add_guest(@u_guest_custom)
+
note.project.add_master(note.author)
reset_delivered_emails!
end
@@ -345,7 +368,7 @@ describe NotificationService, services: true do
create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_participant),
create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_mentioned),
create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_disabled),
- create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author),
+ create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author)
]
end
@@ -378,7 +401,7 @@ describe NotificationService, services: true do
before do
build_team(note.project)
reset_delivered_emails!
- allow_any_instance_of(Commit).to receive(:author).and_return(@u_committer)
+ allow(note.noteable).to receive(:author).and_return(@u_committer)
update_custom_notification(:new_note, @u_guest_custom, resource: project)
update_custom_notification(:new_note, @u_custom_global)
end
@@ -447,9 +470,9 @@ describe NotificationService, services: true do
describe 'Issues' do
let(:group) { create(:group) }
- let(:project) { create(:empty_project, :public, namespace: group) }
- let(:another_project) { create(:empty_project, :public, namespace: group) }
- let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:another_project) { create(:project, :public, namespace: group) }
+ let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' }
before do
build_team(issue.project)
@@ -465,7 +488,7 @@ describe NotificationService, services: true do
it do
notification.new_issue(issue, @u_disabled)
- should_email(issue.assignee)
+ should_email(assignee)
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
@@ -480,10 +503,10 @@ describe NotificationService, services: true do
end
it do
- create_global_setting_for(issue.assignee, :mention)
+ create_global_setting_for(issue.assignees.first, :mention)
notification.new_issue(issue, @u_disabled)
- should_not_email(issue.assignee)
+ should_not_email(issue.assignees.first)
end
it "emails the author if they've opted into notifications about their activity" do
@@ -528,7 +551,7 @@ describe NotificationService, services: true do
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) }
it "emails subscribers of the issue's labels that can read the issue" do
project.add_developer(member)
@@ -572,9 +595,9 @@ describe NotificationService, services: true do
end
it 'emails new assignee' do
- notification.reassigned_issue(issue, @u_disabled)
+ notification.reassigned_issue(issue, @u_disabled, [assignee])
- should_email(issue.assignee)
+ should_email(issue.assignees.first)
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
@@ -588,9 +611,8 @@ describe NotificationService, services: true do
end
it 'emails previous assignee even if he has the "on mention" notif level' do
- issue.update_attribute(:assignee, @u_mentioned)
- issue.update_attributes(assignee: @u_watcher)
- notification.reassigned_issue(issue, @u_disabled)
+ issue.assignees = [@u_mentioned]
+ notification.reassigned_issue(issue, @u_disabled, [@u_watcher])
should_email(@u_mentioned)
should_email(@u_watcher)
@@ -606,11 +628,11 @@ describe NotificationService, services: true do
end
it 'emails new assignee even if he has the "on mention" notif level' do
- issue.update_attributes(assignee: @u_mentioned)
- notification.reassigned_issue(issue, @u_disabled)
+ issue.assignees = [@u_mentioned]
+ notification.reassigned_issue(issue, @u_disabled, [@u_mentioned])
- expect(issue.assignee).to be @u_mentioned
- should_email(issue.assignee)
+ expect(issue.assignees.first).to be @u_mentioned
+ should_email(issue.assignees.first)
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
@@ -624,11 +646,11 @@ describe NotificationService, services: true do
end
it 'emails new assignee' do
- issue.update_attribute(:assignee, @u_mentioned)
- notification.reassigned_issue(issue, @u_disabled)
+ issue.assignees = [@u_mentioned]
+ notification.reassigned_issue(issue, @u_disabled, [@u_mentioned])
- expect(issue.assignee).to be @u_mentioned
- should_email(issue.assignee)
+ expect(issue.assignees.first).to be @u_mentioned
+ should_email(issue.assignees.first)
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
@@ -642,17 +664,17 @@ describe NotificationService, services: true do
end
it 'does not email new assignee if they are the current user' do
- issue.update_attribute(:assignee, @u_mentioned)
- notification.reassigned_issue(issue, @u_mentioned)
+ issue.assignees = [@u_mentioned]
+ notification.reassigned_issue(issue, @u_mentioned, [@u_mentioned])
- expect(issue.assignee).to be @u_mentioned
+ expect(issue.assignees.first).to be @u_mentioned
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
should_email(@u_participant_mentioned)
should_email(@subscriber)
should_email(@u_custom_global)
- should_not_email(issue.assignee)
+ should_not_email(issue.assignees.first)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_disabled)
@@ -662,7 +684,7 @@ describe NotificationService, services: true do
it_behaves_like 'participating notifications' do
let(:participant) { create(:user, username: 'user-participant') }
let(:issuable) { issue }
- let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled) }
+ let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) }
end
end
@@ -678,17 +700,6 @@ describe NotificationService, services: true do
let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } }
let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } }
- it "emails subscribers of the issue's added labels only" do
- notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled)
-
- should_not_email(subscriber_to_label_1)
- should_not_email(subscriber_to_group_label_1)
- should_not_email(subscriber_to_group_label_2_on_another_project)
- should_email(subscriber_1_to_group_label_2)
- should_email(subscriber_2_to_group_label_2)
- should_email(subscriber_to_label_2)
- end
-
it "emails the current user if they've opted into notifications about their activity" do
subscriber_to_label_2.notified_of_own_activity = true
notification.relabeled_issue(issue, [group_label_2, label_2], subscriber_to_label_2)
@@ -705,7 +716,13 @@ describe NotificationService, services: true do
it "doesn't send email to anyone but subscribers of the given labels" do
notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled)
- should_not_email(issue.assignee)
+ should_not_email(subscriber_to_label_1)
+ should_not_email(subscriber_to_group_label_1)
+ should_not_email(subscriber_to_group_label_2_on_another_project)
+ should_email(subscriber_1_to_group_label_2)
+ should_email(subscriber_2_to_group_label_2)
+ should_email(subscriber_to_label_2)
+ should_not_email(issue.assignees.first)
should_not_email(issue.author)
should_not_email(@u_watcher)
should_not_email(@u_guest_watcher)
@@ -714,12 +731,6 @@ describe NotificationService, services: true do
should_not_email(@watcher_and_subscriber)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
- should_not_email(subscriber_to_label_1)
- should_not_email(subscriber_to_group_label_1)
- should_not_email(subscriber_to_group_label_2_on_another_project)
- should_email(subscriber_1_to_group_label_2)
- should_email(subscriber_2_to_group_label_2)
- should_email(subscriber_to_label_2)
end
context 'confidential issues' do
@@ -729,7 +740,7 @@ describe NotificationService, services: true do
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) }
let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) }
let!(:label_2) { create(:label, project: project) }
@@ -767,7 +778,7 @@ describe NotificationService, services: true do
it 'sends email to issue assignee and issue author' do
notification.close_issue(issue, @u_disabled)
- should_email(issue.assignee)
+ should_email(issue.assignees.first)
should_email(issue.author)
should_email(@u_watcher)
should_email(@u_guest_watcher)
@@ -798,7 +809,7 @@ describe NotificationService, services: true do
it 'sends email to issue notification recipients' do
notification.reopen_issue(issue, @u_disabled)
- should_email(issue.assignee)
+ should_email(issue.assignees.first)
should_email(issue.author)
should_email(@u_watcher)
should_email(@u_guest_watcher)
@@ -826,7 +837,7 @@ describe NotificationService, services: true do
it 'sends email to issue notification recipients' do
notification.issue_moved(issue, new_issue, @u_disabled)
- should_email(issue.assignee)
+ should_email(issue.assignees.first)
should_email(issue.author)
should_email(@u_watcher)
should_email(@u_guest_watcher)
@@ -850,7 +861,7 @@ describe NotificationService, services: true do
describe 'Merge Requests' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, :repository, namespace: group) }
- let(:another_project) { create(:empty_project, :public, namespace: group) }
+ let(:another_project) { create(:project, :public, namespace: group) }
let(:merge_request) { create :merge_request, source_project: project, assignee: create(:user), description: 'cc @participant' }
before do
@@ -862,11 +873,6 @@ describe NotificationService, services: true do
end
describe '#new_merge_request' do
- before do
- update_custom_notification(:new_merge_request, @u_guest_custom, resource: project)
- update_custom_notification(:new_merge_request, @u_custom_global)
- end
-
it do
notification.new_merge_request(merge_request, @u_disabled)
@@ -992,7 +998,7 @@ describe NotificationService, services: true do
let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } }
let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } }
- it "emails subscribers of the merge request's added labels only" do
+ it "doesn't send email to anyone but subscribers of the given labels" do
notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled)
should_not_email(subscriber_to_label_1)
@@ -1001,11 +1007,6 @@ describe NotificationService, services: true do
should_email(subscriber_1_to_group_label_2)
should_email(subscriber_2_to_group_label_2)
should_email(subscriber_to_label_2)
- end
-
- it "doesn't send email to anyone but subscribers of the given labels" do
- notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled)
-
should_not_email(merge_request.assignee)
should_not_email(merge_request.author)
should_not_email(@u_watcher)
@@ -1015,12 +1016,6 @@ describe NotificationService, services: true do
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_lazy_participant)
- should_not_email(subscriber_to_label_1)
- should_not_email(subscriber_to_group_label_1)
- should_not_email(subscriber_to_group_label_2_on_another_project)
- should_email(subscriber_1_to_group_label_2)
- should_email(subscriber_2_to_group_label_2)
- should_email(subscriber_to_label_2)
end
end
@@ -1065,12 +1060,12 @@ describe NotificationService, services: true do
should_email(merge_request.assignee)
should_email(@u_watcher)
+ should_email(@u_guest_watcher)
+ should_email(@u_guest_custom)
+ should_email(@u_custom_global)
should_email(@u_participant_mentioned)
should_email(@subscriber)
should_email(@watcher_and_subscriber)
- should_email(@u_guest_watcher)
- should_email(@u_custom_global)
- should_email(@u_guest_custom)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_disabled)
@@ -1161,7 +1156,7 @@ describe NotificationService, services: true do
end
describe 'Projects' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
before do
build_team(project)
@@ -1182,19 +1177,39 @@ describe NotificationService, services: true do
end
end
- describe '#project_exported' do
- it do
- notification.project_exported(project, @u_disabled)
+ context 'user with notifications disabled' do
+ describe '#project_exported' do
+ it do
+ notification.project_exported(project, @u_disabled)
- should_only_email(@u_disabled)
+ should_not_email_anyone
+ end
+ end
+
+ describe '#project_not_exported' do
+ it do
+ notification.project_not_exported(project, @u_disabled, ['error'])
+
+ should_not_email_anyone
+ end
end
end
- describe '#project_not_exported' do
- it do
- notification.project_not_exported(project, @u_disabled, ['error'])
+ context 'user with notifications enabled' do
+ describe '#project_exported' do
+ it do
+ notification.project_exported(project, @u_participating)
- should_only_email(@u_disabled)
+ should_only_email(@u_participating)
+ end
+ end
+
+ describe '#project_not_exported' do
+ it do
+ notification.project_not_exported(project, @u_participating, ['error'])
+
+ should_only_email(@u_participating)
+ end
end
end
end
@@ -1205,7 +1220,7 @@ describe NotificationService, services: true do
let(:group) { create(:group) }
let(:member) { create(:user) }
- before(:each) do
+ before do
group.add_owner(creator)
group.add_developer(member, creator)
end
@@ -1218,14 +1233,43 @@ describe NotificationService, services: true do
end.to change { ActionMailer::Base.deliveries.size }.by(1)
end
end
+
+ describe '#new_group_member' do
+ let(:group) { create(:group) }
+ let(:added_user) { create(:user) }
+
+ def create_member!
+ GroupMember.create(
+ group: group,
+ user: added_user,
+ access_level: Gitlab::Access::GUEST
+ )
+ end
+
+ it 'sends a notification' do
+ create_member!
+ should_only_email(added_user)
+ end
+
+ describe 'when notifications are disabled' do
+ before do
+ create_global_setting_for(added_user, :disabled)
+ end
+
+ it 'does not send a notification' do
+ create_member!
+ should_not_email_anyone
+ end
+ end
+ end
end
describe 'ProjectMember' do
describe '#decline_group_invite' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:member) { create(:user) }
- before(:each) do
+ before do
project.add_developer(member, current_user: project.owner)
end
@@ -1237,10 +1281,35 @@ describe NotificationService, services: true do
end.to change { ActionMailer::Base.deliveries.size }.by(1)
end
end
+
+ describe '#new_project_member' do
+ let(:project) { create(:project) }
+ let(:added_user) { create(:user) }
+
+ def create_member!
+ create(:project_member, user: added_user, project: project)
+ end
+
+ it do
+ create_member!
+ should_only_email(added_user)
+ end
+
+ describe 'when notifications are disabled' do
+ before do
+ create_global_setting_for(added_user, :disabled)
+ end
+
+ it do
+ create_member!
+ should_not_email_anyone
+ end
+ end
+ end
end
context 'guest user in private project' do
- let(:private_project) { create(:empty_project, :private) }
+ let(:private_project) { create(:project, :private) }
let(:guest) { create(:user) }
let(:developer) { create(:user) }
let(:assignee) { create(:user) }
@@ -1535,8 +1604,7 @@ describe NotificationService, services: true do
# When resource is nil it means global notification
def update_custom_notification(event, user, resource: nil, value: true)
setting = user.notification_settings_for(resource)
- setting.events[event] = value
- setting.save
+ setting.update!(event => value)
end
def add_users_with_subscription(project, issuable)
diff --git a/spec/services/pages_service_spec.rb b/spec/services/pages_service_spec.rb
index aa63fe3a5c1..f8db6900a0a 100644
--- a/spec/services/pages_service_spec.rb
+++ b/spec/services/pages_service_spec.rb
@@ -1,19 +1,23 @@
require 'spec_helper'
-describe PagesService, services: true do
+describe PagesService do
let(:build) { create(:ci_build) }
let(:data) { Gitlab::DataBuilder::Build.build(build) }
- let(:service) { PagesService.new(data) }
+ let(:service) { described_class.new(data) }
before do
allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
end
context 'execute asynchronously for pages job' do
- before { build.name = 'pages' }
+ before do
+ build.name = 'pages'
+ end
context 'on success' do
- before { build.success }
+ before do
+ build.success
+ end
it 'executes worker' do
expect(PagesWorker).to receive(:perform_async)
@@ -23,7 +27,9 @@ describe PagesService, services: true do
%w(pending running failed canceled).each do |status|
context "on #{status}" do
- before { build.status = status }
+ before do
+ build.status = status
+ end
it 'does not execute worker' do
expect(PagesWorker).not_to receive(:perform_async)
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
new file mode 100644
index 00000000000..64a9559791f
--- /dev/null
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe PreviewMarkdownService do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe 'user references' do
+ let(:params) { { text: "Take a look #{user.to_reference}" } }
+ let(:service) { described_class.new(project, user, params) }
+
+ it 'returns users referenced in text' do
+ result = service.execute
+
+ expect(result[:users]).to eq [user.username]
+ end
+ end
+
+ context 'new note with quick actions' do
+ let(:issue) { create(:issue, project: project) }
+ let(:params) do
+ {
+ text: "Please do it\n/assign #{user.to_reference}",
+ quick_actions_target_type: 'Issue',
+ quick_actions_target_id: issue.id
+ }
+ end
+ let(:service) { described_class.new(project, user, params) }
+
+ it 'removes quick actions from text' do
+ result = service.execute
+
+ expect(result[:text]).to eq 'Please do it'
+ end
+
+ it 'explains quick actions effect' do
+ result = service.execute
+
+ expect(result[:commands]).to eq "Assigns #{user.to_reference}."
+ end
+ end
+
+ context 'merge request description' do
+ let(:params) do
+ {
+ text: "My work\n/estimate 2y",
+ quick_actions_target_type: 'MergeRequest'
+ }
+ end
+ let(:service) { described_class.new(project, user, params) }
+
+ it 'removes quick actions from text' do
+ result = service.execute
+
+ expect(result[:text]).to eq 'My work'
+ end
+
+ it 'explains quick actions effect' do
+ result = service.execute
+
+ expect(result[:commands]).to eq 'Sets time estimate to 2y.'
+ end
+ end
+end
diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb
index 7916c2d957c..426593be428 100644
--- a/spec/services/projects/autocomplete_service_spec.rb
+++ b/spec/services/projects/autocomplete_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Projects::AutocompleteService, services: true do
+describe Projects::AutocompleteService do
describe '#issues' do
describe 'confidential issues' do
let(:author) { create(:user) }
@@ -8,10 +8,10 @@ describe Projects::AutocompleteService, services: true do
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let!(:issue) { create(:issue, project: project, title: 'Issue 1') }
let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) }
- let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) }
+ let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
it 'does not list project confidential issues for guests' do
autocomplete = described_class.new(project, nil)
@@ -88,4 +88,31 @@ describe Projects::AutocompleteService, services: true do
end
end
end
+
+ describe '#milestones' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
+ let!(:group_milestone) { create(:milestone, group: group) }
+ let!(:project_milestone) { create(:milestone, project: project) }
+
+ let(:milestone_titles) { described_class.new(project, user).milestones.map(&:title) }
+
+ it 'includes project and group milestones' do
+ expect(milestone_titles).to eq([group_milestone.title, project_milestone.title])
+ end
+
+ it 'does not include closed milestones' do
+ group_milestone.close
+
+ expect(milestone_titles).to eq([project_milestone.title])
+ end
+
+ it 'does not include milestones from other projects in the group' do
+ other_project = create(:project, group: group)
+ project_milestone.update!(project: other_project)
+
+ expect(milestone_titles).to eq([group_milestone.title])
+ end
+ end
end
diff --git a/spec/services/projects/create_from_template_service_spec.rb b/spec/services/projects/create_from_template_service_spec.rb
new file mode 100644
index 00000000000..9919ec254c6
--- /dev/null
+++ b/spec/services/projects/create_from_template_service_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Projects::CreateFromTemplateService do
+ let(:user) { create(:user) }
+ let(:project_params) do
+ {
+ path: user.to_param,
+ template_name: 'rails'
+ }
+ end
+
+ subject { described_class.new(user, project_params) }
+
+ it 'calls the importer service' do
+ expect_any_instance_of(Projects::GitlabProjectsImportService).to receive(:execute)
+
+ subject.execute
+ end
+
+ it 'returns the project thats created' do
+ project = subject.execute
+
+ expect(project).to be_saved
+ expect(project.scheduled?).to be(true)
+ end
+end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 033e6ecd18c..b0dc7488b5f 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Projects::CreateService, '#execute', services: true do
+describe Projects::CreateService, '#execute' do
let(:user) { create :user }
let(:opts) do
{
@@ -115,7 +115,7 @@ describe Projects::CreateService, '#execute', services: true do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
opts.merge!(
- visibility_level: Gitlab::VisibilityLevel.options['Public']
+ visibility_level: Gitlab::VisibilityLevel::PUBLIC
)
end
@@ -161,15 +161,13 @@ describe Projects::CreateService, '#execute', services: true do
end
context 'when a bad service template is created' do
- before do
- create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
- end
-
it 'reports an error in the imported project' do
opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce'
+ create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
+
project = create_project(user, opts)
- expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/
+ expect(project.errors.full_messages_for(:base).first).to match(/Unable to save project. Error: Unable to save DroneCiService/)
expect(project.services.count).to eq 0
end
end
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index 4b8589b2736..c867139d1de 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Projects::DestroyService, services: true do
+describe Projects::DestroyService do
let!(:user) { create(:user) }
let!(:project) { create(:project, :repository, namespace: user.namespace) }
let!(:path) { project.repository.path_to_repo }
@@ -15,8 +15,9 @@ describe Projects::DestroyService, services: true do
shared_examples 'deleting the project' do
it 'deletes the project' do
expect(Project.unscoped.all).not_to include(project)
- expect(Dir.exist?(path)).to be_falsey
- expect(Dir.exist?(remove_path)).to be_falsey
+
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, path + '.git')).to be_falsey
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, remove_path + '.git')).to be_falsey
end
end
@@ -35,6 +36,27 @@ describe Projects::DestroyService, services: true do
end
end
+ shared_examples 'handles errors thrown during async destroy' do |error_message|
+ it 'does not allow the error to bubble up' do
+ expect do
+ Sidekiq::Testing.inline! { destroy_project(project, user, {}) }
+ end.not_to raise_error
+ end
+
+ it 'unmarks the project as "pending deletion"' do
+ Sidekiq::Testing.inline! { destroy_project(project, user, {}) }
+
+ expect(project.reload.pending_delete).to be(false)
+ end
+
+ it 'stores an error message in `projects.delete_error`' do
+ Sidekiq::Testing.inline! { destroy_project(project, user, {}) }
+
+ expect(project.reload.delete_error).to be_present
+ expect(project.delete_error).to include(error_message)
+ end
+ end
+
context 'Sidekiq inline' do
before do
# Run sidekiq immediatly to check that renamed repository will be removed
@@ -59,18 +81,18 @@ describe Projects::DestroyService, services: true do
before do
new_user = create(:user)
project.team.add_user(new_user, Gitlab::Access::DEVELOPER)
- allow_any_instance_of(Projects::DestroyService).to receive(:flush_caches).and_raise(Redis::CannotConnectError)
+ allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError)
end
it 'keeps project team intact upon an error' do
Sidekiq::Testing.inline! do
begin
destroy_project(project, user, {})
- rescue Redis::CannotConnectError
+ rescue ::Redis::CannotConnectError
end
end
- expect(project.team.members.count).to eq 1
+ expect(project.team.members.count).to eq 2
end
end
@@ -88,10 +110,51 @@ describe Projects::DestroyService, services: true do
end
it_behaves_like 'deleting the project with pipeline and build'
- end
- context 'with execute' do
- it_behaves_like 'deleting the project with pipeline and build'
+ context 'errors' do
+ context 'when `remove_legacy_registry_tags` fails' do
+ before do
+ expect_any_instance_of(described_class)
+ .to receive(:remove_legacy_registry_tags).and_return(false)
+ end
+
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags"
+ end
+
+ context 'when `remove_repository` fails' do
+ before do
+ expect_any_instance_of(described_class)
+ .to receive(:remove_repository).and_return(false)
+ end
+
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository"
+ end
+
+ context 'when `execute` raises expected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(StandardError.new("Other error message"))
+ end
+
+ it_behaves_like 'handles errors thrown during async destroy', "Other error message"
+ end
+
+ context 'when `execute` raises unexpected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(Exception.new('Other error message'))
+ end
+
+ it 'allows error to bubble up and rolls back project deletion' do
+ expect do
+ Sidekiq::Testing.inline! { destroy_project(project, user, {}) }
+ end.to raise_error(Exception, 'Other error message')
+
+ expect(project.reload.pending_delete).to be(false)
+ expect(project.delete_error).to include("Other error message")
+ end
+ end
+ end
end
describe 'container registry' do
@@ -118,8 +181,7 @@ describe Projects::DestroyService, services: true do
expect_any_instance_of(ContainerRepository)
.to receive(:delete_tags!).and_return(false)
- expect{ destroy_project(project, user) }
- .to raise_error(ActiveRecord::RecordNotDestroyed)
+ expect(destroy_project(project, user)).to be false
end
end
end
@@ -144,8 +206,7 @@ describe Projects::DestroyService, services: true do
expect_any_instance_of(ContainerRepository)
.to receive(:delete_tags!).and_return(false)
- expect { destroy_project(project, user) }
- .to raise_error(Projects::DestroyService::DestroyError)
+ expect(destroy_project(project, user)).to be false
end
end
end
diff --git a/spec/services/projects/download_service_spec.rb b/spec/services/projects/download_service_spec.rb
index 33b267c069c..da236052ebf 100644
--- a/spec/services/projects/download_service_spec.rb
+++ b/spec/services/projects/download_service_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Projects::DownloadService, services: true do
+describe Projects::DownloadService do
describe 'File service' do
before do
@user = create(:user)
- @project = create(:empty_project, creator_id: @user.id, namespace: @user.namespace)
+ @project = create(:project, creator_id: @user.id, namespace: @user.namespace)
end
context 'for a URL that is not on whitelist' do
diff --git a/spec/services/projects/enable_deploy_key_service_spec.rb b/spec/services/projects/enable_deploy_key_service_spec.rb
index a37510cf159..835dae68fcd 100644
--- a/spec/services/projects/enable_deploy_key_service_spec.rb
+++ b/spec/services/projects/enable_deploy_key_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Projects::EnableDeployKeyService, services: true do
+describe Projects::EnableDeployKeyService do
let(:deploy_key) { create(:deploy_key, public: true) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { project.creator}
let!(:params) { { key_id: deploy_key.id } }
@@ -21,6 +21,16 @@ describe Projects::EnableDeployKeyService, services: true do
end
end
+ context 'add the same key twice' do
+ before do
+ project.deploy_keys << deploy_key
+ end
+
+ it 'returns existing key' do
+ expect(service.execute).to eq(deploy_key)
+ end
+ end
+
def service
Projects::EnableDeployKeyService.new(project, user, params)
end
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index f8eb34f2ef4..21c4b30734c 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -1,10 +1,10 @@
require 'spec_helper'
-describe Projects::ForkService, services: true do
+describe Projects::ForkService do
describe 'fork by user' do
before do
- @from_namespace = create(:namespace)
- @from_user = create(:user, namespace: @from_namespace )
+ @from_user = create(:user)
+ @from_namespace = @from_user.namespace
avatar = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")
@from_project = create(:project,
:repository,
@@ -13,8 +13,8 @@ describe Projects::ForkService, services: true do
star_count: 107,
avatar: avatar,
description: 'wow such project')
- @to_namespace = create(:namespace)
- @to_user = create(:user, namespace: @to_namespace)
+ @to_user = create(:user)
+ @to_namespace = @to_user.namespace
@from_project.add_user(@to_user, :developer)
end
@@ -50,6 +50,14 @@ describe Projects::ForkService, services: true do
expect(@from_project.avatar.file).to be_exists
end
+
+ it 'flushes the forks count cache of the source project' do
+ expect(@from_project.forks_count).to be_zero
+
+ fork_project(@from_project, @to_user)
+
+ expect(@from_project.forks_count).to eq(1)
+ end
end
end
diff --git a/spec/services/projects/forks_count_service_spec.rb b/spec/services/projects/forks_count_service_spec.rb
new file mode 100644
index 00000000000..cf299c5d09b
--- /dev/null
+++ b/spec/services/projects/forks_count_service_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Projects::ForksCountService do
+ let(:project) { build(:project, id: 42) }
+ let(:service) { described_class.new(project) }
+
+ describe '#count' do
+ it 'returns the number of forks' do
+ allow(service).to receive(:uncached_count).and_return(1)
+
+ expect(service.count).to eq(1)
+ end
+
+ it 'caches the forks count', :use_clean_rails_memory_store_caching do
+ expect(service).to receive(:uncached_count).once.and_return(1)
+
+ 2.times { service.count }
+ end
+ end
+
+ describe '#refresh_cache', :use_clean_rails_memory_store_caching do
+ it 'refreshes the cache' do
+ expect(service).to receive(:uncached_count).once.and_return(1)
+
+ service.refresh_cache
+
+ expect(service.count).to eq(1)
+ end
+ end
+
+ describe '#delete_cache', :use_clean_rails_memory_store_caching do
+ it 'removes the cache' do
+ expect(service).to receive(:uncached_count).twice.and_return(1)
+
+ service.count
+ service.delete_cache
+ service.count
+ end
+ end
+end
diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb
index eaf63457b32..385f56e447f 100644
--- a/spec/services/projects/housekeeping_service_spec.rb
+++ b/spec/services/projects/housekeeping_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::HousekeepingService do
- subject { Projects::HousekeepingService.new(project) }
+ subject { described_class.new(project) }
let(:project) { create(:project, :repository) }
before do
@@ -24,7 +24,7 @@ describe Projects::HousekeepingService do
end
context 'when no lease can be obtained' do
- before(:each) do
+ before do
expect(subject).to receive(:try_obtain_lease).and_return(false)
end
@@ -66,14 +66,14 @@ describe Projects::HousekeepingService do
allow(subject).to receive(:lease_key).and_return(:the_lease_key)
# At push 200
- expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :gc, :the_lease_key, :the_uuid).
- exactly(1).times
+ expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :gc, :the_lease_key, :the_uuid)
+ .exactly(1).times
# At push 50, 100, 150
- expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :full_repack, :the_lease_key, :the_uuid).
- exactly(3).times
+ expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :full_repack, :the_lease_key, :the_uuid)
+ .exactly(3).times
# At push 10, 20, ... (except those above)
- expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :incremental_repack, :the_lease_key, :the_uuid).
- exactly(16).times
+ expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id, :incremental_repack, :the_lease_key, :the_uuid)
+ .exactly(16).times
201.times do
subject.increment!
diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb
index 852a4ac852f..034065aab00 100644
--- a/spec/services/projects/import_service_spec.rb
+++ b/spec/services/projects/import_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Projects::ImportService, services: true do
- let!(:project) { create(:empty_project) }
+describe Projects::ImportService do
+ let!(:project) { create(:project) }
let(:user) { project.creator }
subject { described_class.new(project, user) }
@@ -26,7 +26,7 @@ describe Projects::ImportService, services: true do
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - The repository could not be created."
+ expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.full_path} - The repository could not be created."
end
end
@@ -38,8 +38,7 @@ describe Projects::ImportService, services: true do
context 'with a Github repository' do
it 'succeeds if repository import is successfully' do
- expect_any_instance_of(Repository).to receive(:fetch_remote).and_return(true)
- expect_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).and_return(true)
+ expect_any_instance_of(Github::Import).to receive(:execute).and_return(true)
result = subject.execute
@@ -52,16 +51,7 @@ describe Projects::ImportService, services: true do
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - Failed to import the repository"
- end
-
- it 'does not remove the GitHub remote' do
- expect_any_instance_of(Repository).to receive(:fetch_remote).and_return(true)
- expect_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).and_return(true)
-
- subject.execute
-
- expect(project.repository.raw_repository.remote_names).to include('github')
+ expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - The remote data could not be imported."
end
end
@@ -86,7 +76,7 @@ describe Projects::ImportService, services: true do
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - Failed to import the repository"
+ expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.full_path} - Failed to import the repository"
end
end
end
@@ -102,8 +92,7 @@ describe Projects::ImportService, services: true do
end
it 'succeeds if importer succeeds' do
- allow_any_instance_of(Repository).to receive(:fetch_remote).and_return(true)
- allow_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).and_return(true)
+ allow_any_instance_of(Github::Import).to receive(:execute).and_return(true)
result = subject.execute
@@ -111,11 +100,8 @@ describe Projects::ImportService, services: true do
end
it 'flushes various caches' do
- allow_any_instance_of(Repository).to receive(:fetch_remote).
- and_return(true)
-
- allow_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).
- and_return(true)
+ allow_any_instance_of(Github::Import).to receive(:execute)
+ .and_return(true)
expect_any_instance_of(Repository).to receive(:expire_content_cache)
@@ -123,29 +109,27 @@ describe Projects::ImportService, services: true do
end
it 'fails if importer fails' do
- allow_any_instance_of(Repository).to receive(:fetch_remote).and_return(true)
- allow_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).and_return(false)
+ allow_any_instance_of(Github::Import).to receive(:execute).and_return(false)
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - The remote data could not be imported."
+ expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.full_path} - The remote data could not be imported."
end
it 'fails if importer raise an error' do
- allow_any_instance_of(Gitlab::Shell).to receive(:fetch_remote).and_return(true)
- allow_any_instance_of(Gitlab::GithubImport::Importer).to receive(:execute).and_raise(Projects::ImportService::Error.new('Github: failed to connect API'))
+ allow_any_instance_of(Github::Import).to receive(:execute).and_raise(Projects::ImportService::Error.new('Github: failed to connect API'))
result = subject.execute
expect(result[:status]).to eq :error
- expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.path_with_namespace} - Github: failed to connect API"
+ expect(result[:message]).to eq "Error importing repository #{project.import_url} into #{project.full_path} - Github: failed to connect API"
end
it 'expires content cache after error' do
- allow_any_instance_of(Project).to receive(:repository_exists?).and_return(false, true)
+ allow_any_instance_of(Project).to receive(:repository_exists?).and_return(false)
- expect_any_instance_of(Gitlab::Shell).to receive(:fetch_remote).and_raise(Gitlab::Shell::Error.new('Failed to import the repository'))
+ expect_any_instance_of(Repository).to receive(:fetch_remote).and_raise(Gitlab::Shell::Error.new)
expect_any_instance_of(Repository).to receive(:expire_content_cache)
subject.execute
@@ -186,7 +170,7 @@ describe Projects::ImportService, services: true do
}
)
- allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider])
+ stub_omniauth_setting(providers: [provider])
end
end
end
diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb
index 063b3bd76eb..0d18ceb8ff8 100644
--- a/spec/services/projects/participants_service_spec.rb
+++ b/spec/services/projects/participants_service_spec.rb
@@ -1,12 +1,11 @@
require 'spec_helper'
-describe Projects::ParticipantsService, services: true do
+describe Projects::ParticipantsService do
describe '#groups' do
describe 'avatar_url' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) }
let(:user) { create(:user) }
- let(:base_url) { Settings.send(:build_base_gitlab_url) }
let!(:group_member) { create(:group_member, group: group, user: user) }
it 'should return an url for the avatar' do
@@ -14,7 +13,7 @@ describe Projects::ParticipantsService, services: true do
groups = participants.groups
expect(groups.size).to eq 1
- expect(groups.first[:avatar_url]).to eq "#{base_url}/uploads/group/avatar/#{group.id}/dk.png"
+ expect(groups.first[:avatar_url]).to eq("/uploads/-/system/group/avatar/#{group.id}/dk.png")
end
it 'should return an url for the avatar with relative url' do
@@ -25,7 +24,7 @@ describe Projects::ParticipantsService, services: true do
groups = participants.groups
expect(groups.size).to eq 1
- expect(groups.first[:avatar_url]).to eq "#{base_url}/gitlab/uploads/group/avatar/#{group.id}/dk.png"
+ expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/-/system/group/avatar/#{group.id}/dk.png")
end
end
end
diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/projects/propagate_service_template_spec.rb
new file mode 100644
index 00000000000..f4c59735c43
--- /dev/null
+++ b/spec/services/projects/propagate_service_template_spec.rb
@@ -0,0 +1,107 @@
+require 'spec_helper'
+
+describe Projects::PropagateServiceTemplate do
+ describe '.propagate' do
+ let!(:service_template) do
+ PushoverService.create(
+ template: true,
+ active: true,
+ properties: {
+ device: 'MyDevice',
+ sound: 'mic',
+ priority: 4,
+ user_key: 'asdf',
+ api_key: '123456789'
+ })
+ end
+
+ let!(:project) { create(:project) }
+
+ it 'creates services for projects' do
+ expect(project.pushover_service).to be_nil
+
+ described_class.propagate(service_template)
+
+ expect(project.reload.pushover_service).to be_present
+ end
+
+ it 'creates services for a project that has another service' do
+ BambooService.create(
+ template: true,
+ active: true,
+ project: project,
+ properties: {
+ bamboo_url: 'http://gitlab.com',
+ username: 'mic',
+ password: "password",
+ build_key: 'build'
+ }
+ )
+
+ expect(project.pushover_service).to be_nil
+
+ described_class.propagate(service_template)
+
+ expect(project.reload.pushover_service).to be_present
+ end
+
+ it 'does not create the service if it exists already' do
+ other_service = BambooService.create(
+ template: true,
+ active: true,
+ properties: {
+ bamboo_url: 'http://gitlab.com',
+ username: 'mic',
+ password: "password",
+ build_key: 'build'
+ }
+ )
+
+ Service.build_from_template(project.id, service_template).save!
+ Service.build_from_template(project.id, other_service).save!
+
+ expect { described_class.propagate(service_template) }
+ .not_to change { Service.count }
+ end
+
+ it 'creates the service containing the template attributes' do
+ described_class.propagate(service_template)
+
+ expect(project.pushover_service.properties).to eq(service_template.properties)
+ end
+
+ describe 'bulk update' do
+ let(:project_total) { 5 }
+
+ before do
+ stub_const 'Projects::PropagateServiceTemplate::BATCH_SIZE', 3
+
+ project_total.times { create(:project) }
+
+ described_class.propagate(service_template)
+ end
+
+ it 'creates services for all projects' do
+ expect(Service.all.reload.count).to eq(project_total + 2)
+ end
+ end
+
+ describe 'external tracker' do
+ it 'updates the project external tracker' do
+ service_template.update!(category: 'issue_tracker', default: false)
+
+ expect { described_class.propagate(service_template) }
+ .to change { project.reload.has_external_issue_tracker }.to(true)
+ end
+ end
+
+ describe 'external wiki' do
+ it 'updates the project external tracker' do
+ service_template.update!(type: 'ExternalWikiService')
+
+ expect { described_class.propagate(service_template) }
+ .to change { project.reload.has_external_wiki }.to(true)
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index 29ccce59c53..2cb60cbcfc4 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -1,16 +1,16 @@
require 'spec_helper'
-describe Projects::TransferService, services: true do
+describe Projects::TransferService do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :repository, namespace: user.namespace) }
context 'namespace -> namespace' do
before do
- allow_any_instance_of(Gitlab::UploadsTransfer).
- to receive(:move_project).and_return(true)
- allow_any_instance_of(Gitlab::PagesTransfer).
- to receive(:move_project).and_return(true)
+ allow_any_instance_of(Gitlab::UploadsTransfer)
+ .to receive(:move_project).and_return(true)
+ allow_any_instance_of(Gitlab::PagesTransfer)
+ .to receive(:move_project).and_return(true)
group.add_owner(user)
@result = transfer_project(project, user, group)
end
@@ -19,6 +19,73 @@ describe Projects::TransferService, services: true do
it { expect(project.namespace).to eq(group) }
end
+ context 'when transfer succeeds' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'sends notifications' do
+ expect_any_instance_of(NotificationService).to receive(:project_was_moved)
+
+ transfer_project(project, user, group)
+ end
+
+ it 'expires full_path cache' do
+ expect(project).to receive(:expires_full_path_cache)
+
+ transfer_project(project, user, group)
+ end
+
+ it 'executes system hooks' do
+ transfer_project(project, user, group) do |service|
+ expect(service).to receive(:execute_system_hooks)
+ end
+ end
+ end
+
+ context 'when transfer fails' do
+ let!(:original_path) { project_path(project) }
+
+ def attempt_project_transfer(&block)
+ expect do
+ transfer_project(project, user, group, &block)
+ end.to raise_error(ActiveRecord::ActiveRecordError)
+ end
+
+ before do
+ group.add_owner(user)
+
+ expect_any_instance_of(Labels::TransferService).to receive(:execute).and_raise(ActiveRecord::StatementInvalid, "PG ERROR")
+ end
+
+ def project_path(project)
+ File.join(project.repository_storage_path, "#{project.disk_path}.git")
+ end
+
+ def current_path
+ project_path(project)
+ end
+
+ it 'rolls back repo location' do
+ attempt_project_transfer
+
+ expect(Dir.exist?(original_path)).to be_truthy
+ expect(original_path).to eq current_path
+ end
+
+ it "doesn't send move notifications" do
+ expect_any_instance_of(NotificationService).not_to receive(:project_was_moved)
+
+ attempt_project_transfer
+ end
+
+ it "doesn't run system hooks" do
+ attempt_project_transfer do |service|
+ expect(service).not_to receive(:execute_system_hooks)
+ end
+ end
+ end
+
context 'namespace -> no namespace' do
before do
@result = transfer_project(project, user, nil)
@@ -26,6 +93,7 @@ describe Projects::TransferService, services: true do
it { expect(@result).to eq false }
it { expect(project.namespace).to eq(user.namespace) }
+ it { expect(project.errors.messages[:new_namespace].first).to eq 'Please select a new namespace for your project.' }
end
context 'disallow transfering of project with tags' do
@@ -52,18 +120,26 @@ describe Projects::TransferService, services: true do
end
def transfer_project(project, user, new_namespace)
- Projects::TransferService.new(project, user).execute(new_namespace)
+ service = Projects::TransferService.new(project, user)
+
+ yield(service) if block_given?
+
+ service.execute(new_namespace)
end
context 'visibility level' do
let(:internal_group) { create(:group, :internal) }
- before { internal_group.add_owner(user) }
+ before do
+ internal_group.add_owner(user)
+ end
context 'when namespace visibility level < project visibility level' do
let(:public_project) { create(:project, :public, :repository, namespace: user.namespace) }
- before { transfer_project(public_project, user, internal_group) }
+ before do
+ transfer_project(public_project, user, internal_group)
+ end
it { expect(public_project.visibility_level).to eq(internal_group.visibility_level) }
end
@@ -71,7 +147,9 @@ describe Projects::TransferService, services: true do
context 'when namespace visibility level > project visibility level' do
let(:private_project) { create(:project, :private, :repository, namespace: user.namespace) }
- before { transfer_project(private_project, user, internal_group) }
+ before do
+ transfer_project(private_project, user, internal_group)
+ end
it { expect(private_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) }
end
@@ -105,9 +183,9 @@ describe Projects::TransferService, services: true do
end
it 'only schedules a single job for every user' do
- expect(UserProjectAccessChangedService).to receive(:new).
- with([owner.id, group_member.id]).
- and_call_original
+ expect(UserProjectAccessChangedService).to receive(:new)
+ .with([owner.id, group_member.id])
+ .and_call_original
transfer_project(project, owner, group)
end
diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb
index 23f5555d3e0..4f1ab697460 100644
--- a/spec/services/projects/unlink_fork_service_spec.rb
+++ b/spec/services/projects/unlink_fork_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Projects::UnlinkForkService, services: true do
- subject { Projects::UnlinkForkService.new(fork_project, user) }
+describe Projects::UnlinkForkService do
+ subject { described_class.new(fork_project, user) }
let(:fork_link) { create(:forked_project_link) }
let(:fork_project) { fork_link.forked_to_project }
@@ -12,9 +12,9 @@ describe Projects::UnlinkForkService, services: true do
let(:mr_close_service) { MergeRequests::CloseService.new(fork_project, user) }
before do
- allow(MergeRequests::CloseService).to receive(:new).
- with(fork_project, user).
- and_return(mr_close_service)
+ allow(MergeRequests::CloseService).to receive(:new)
+ .with(fork_project, user)
+ .and_return(mr_close_service)
end
it 'close all pending merge requests' do
@@ -29,4 +29,14 @@ describe Projects::UnlinkForkService, services: true do
subject.execute
end
+
+ it 'refreshes the forks count cache of the source project' do
+ source = fork_project.forked_from_project
+
+ expect(source.forks_count).to eq(1)
+
+ subject.execute
+
+ expect(source.forks_count).to be_zero
+ end
end
diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb
index 8b329bc21c3..e4d4e6ff3dd 100644
--- a/spec/services/projects/update_pages_configuration_service_spec.rb
+++ b/spec/services/projects/update_pages_configuration_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe Projects::UpdatePagesConfigurationService, services: true do
- let(:project) { create(:empty_project) }
+describe Projects::UpdatePagesConfigurationService do
+ let(:project) { create(:project) }
subject { described_class.new(project) }
describe "#update" do
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index fc0a17296f3..aa6ad6340f5 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -96,6 +96,78 @@ describe Projects::UpdatePagesService do
expect(execute).not_to eq(:success)
end
+ describe 'maximum pages artifacts size' do
+ let(:metadata) { spy('metadata') }
+
+ before do
+ file = fixture_file_upload(Rails.root + 'spec/fixtures/pages.zip')
+ metafile = fixture_file_upload(Rails.root + 'spec/fixtures/pages.zip.meta')
+
+ build.update_attributes(artifacts_file: file)
+ build.update_attributes(artifacts_metadata: metafile)
+
+ allow(build).to receive(:artifacts_metadata_entry)
+ .and_return(metadata)
+ end
+
+ shared_examples 'pages size limit exceeded' do
+ it 'limits the maximum size of gitlab pages' do
+ subject.execute
+
+ expect(deploy_status.description)
+ .to match(/artifacts for pages are too large/)
+ end
+ end
+
+ context 'when maximum pages size is set to zero' do
+ before do
+ stub_application_setting(max_pages_size: 0)
+ end
+
+ context 'when page size does not exceed internal maximum' do
+ before do
+ allow(metadata).to receive(:total_size).and_return(200.megabytes)
+ end
+
+ it 'updates pages correctly' do
+ subject.execute
+
+ expect(deploy_status.description).not_to be_present
+ end
+ end
+
+ context 'when pages size does exceed internal maximum' do
+ before do
+ allow(metadata).to receive(:total_size).and_return(2.terabytes)
+ end
+
+ it_behaves_like 'pages size limit exceeded'
+ end
+ end
+
+ context 'when pages size is greater than max size setting' do
+ before do
+ stub_application_setting(max_pages_size: 200)
+ allow(metadata).to receive(:total_size).and_return(201.megabytes)
+ end
+
+ it_behaves_like 'pages size limit exceeded'
+ end
+
+ context 'when max size setting is greater than internal max size' do
+ before do
+ stub_application_setting(max_pages_size: 3.terabytes / 1.megabyte)
+ allow(metadata).to receive(:total_size).and_return(2.terabytes)
+ end
+
+ it_behaves_like 'pages size limit exceeded'
+ end
+ end
+
+ def deploy_status
+ GenericCommitStatus.find_by(name: 'pages:deploy')
+ end
+
def execute
subject.execute[:status]
end
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 05b18fef061..1b282e82187 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -1,11 +1,14 @@
require 'spec_helper'
-describe Projects::UpdateService, services: true do
+describe Projects::UpdateService, '#execute' do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
- let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) }
- describe 'update_by_user' do
+ let(:project) do
+ create(:project, creator: user, namespace: user.namespace)
+ end
+
+ context 'when changing visibility level' do
context 'when visibility_level is INTERNAL' do
it 'updates the project to internal' do
result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL)
@@ -40,7 +43,7 @@ describe Projects::UpdateService, services: true do
it 'does not update the project to public' do
result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- expect(result).to eq({ status: :error, message: 'Visibility level unallowed' })
+ expect(result).to eq({ status: :error, message: 'New visibility level not allowed!' })
expect(project).to be_private
end
@@ -55,12 +58,13 @@ describe Projects::UpdateService, services: true do
end
end
- describe 'visibility_level' do
- let(:project) { create(:empty_project, :internal) }
+ describe 'when updating project that has forks' do
+ let(:project) { create(:project, :internal) }
let(:forked_project) { create(:forked_project_with_submodules, :internal) }
before do
- forked_project.build_forked_project_link(forked_to_project_id: forked_project.id, forked_from_project_id: project.id)
+ forked_project.build_forked_project_link(forked_to_project_id: forked_project.id,
+ forked_from_project_id: project.id)
forked_project.save
end
@@ -89,10 +93,52 @@ describe Projects::UpdateService, services: true do
end
end
- it 'returns an error result when record cannot be updated' do
- result = update_project(project, admin, { name: 'foo&bar' })
+ context 'when updating a default branch' do
+ let(:project) { create(:project, :repository) }
+
+ it 'changes a default branch' do
+ update_project(project, admin, default_branch: 'feature')
+
+ expect(Project.find(project.id).default_branch).to eq 'feature'
+ end
+
+ it 'does not change a default branch' do
+ # The branch 'unexisted-branch' does not exist.
+ update_project(project, admin, default_branch: 'unexisted-branch')
- expect(result).to eq({ status: :error, message: 'Project could not be updated' })
+ expect(Project.find(project.id).default_branch).to eq 'master'
+ end
+ end
+
+ context 'when updating a project that contains container images' do
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(repository: /image/, tags: %w[rc1])
+ create(:container_repository, project: project, name: :image)
+ end
+
+ it 'does not allow to rename the project' do
+ result = update_project(project, admin, path: 'renamed')
+
+ expect(result).to include(status: :error)
+ expect(result[:message]).to match(/contains container registry tags/)
+ end
+
+ it 'allows to update other settings' do
+ result = update_project(project, admin, public_builds: true)
+
+ expect(result[:status]).to eq :success
+ expect(project.reload.public_builds).to be true
+ end
+ end
+
+ context 'when passing invalid parameters' do
+ it 'returns an error result when record cannot be updated' do
+ result = update_project(project, admin, { name: 'foo&bar' })
+
+ expect(result).to eq({ status: :error,
+ message: 'Project could not be updated!' })
+ end
end
def update_project(project, user, opts)
diff --git a/spec/services/projects/upload_service_spec.rb b/spec/services/projects/upload_service_spec.rb
deleted file mode 100644
index d2cefa46bfa..00000000000
--- a/spec/services/projects/upload_service_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'spec_helper'
-
-describe Projects::UploadService, services: true do
- describe 'File service' do
- before do
- @user = create(:user)
- @project = create(:empty_project, creator_id: @user.id, namespace: @user.namespace)
- end
-
- context 'for valid gif file' do
- before do
- gif = fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif')
- @link_to_file = upload_file(@project, gif)
- end
-
- it { expect(@link_to_file).to have_key(:alt) }
- it { expect(@link_to_file).to have_key(:url) }
- it { expect(@link_to_file).to have_value('banana_sample') }
- it { expect(@link_to_file[:url]).to match('banana_sample.gif') }
- end
-
- context 'for valid png file' do
- before do
- png = fixture_file_upload(Rails.root + 'spec/fixtures/dk.png',
- 'image/png')
- @link_to_file = upload_file(@project, png)
- end
-
- it { expect(@link_to_file).to have_key(:alt) }
- it { expect(@link_to_file).to have_key(:url) }
- it { expect(@link_to_file).to have_value('dk') }
- it { expect(@link_to_file[:url]).to match('dk.png') }
- end
-
- context 'for valid jpg file' do
- before do
- jpg = fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg')
- @link_to_file = upload_file(@project, jpg)
- end
-
- it { expect(@link_to_file).to have_key(:alt) }
- it { expect(@link_to_file).to have_key(:url) }
- it { expect(@link_to_file).to have_value('rails_sample') }
- it { expect(@link_to_file[:url]).to match('rails_sample.jpg') }
- end
-
- context 'for txt file' do
- before do
- txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain')
- @link_to_file = upload_file(@project, txt)
- end
-
- it { expect(@link_to_file).to have_key(:alt) }
- it { expect(@link_to_file).to have_key(:url) }
- it { expect(@link_to_file).to have_value('doc_sample.txt') }
- it { expect(@link_to_file[:url]).to match('doc_sample.txt') }
- end
-
- context 'for too large a file' do
- before do
- txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain')
- allow(txt).to receive(:size) { 1000.megabytes.to_i }
- @link_to_file = upload_file(@project, txt)
- end
-
- it { expect(@link_to_file).to eq(nil) }
- end
- end
-
- def upload_file(project, file)
- Projects::UploadService.new(project, file).execute
- end
-end
diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb
index 6ea8f309981..835e83d6dba 100644
--- a/spec/services/protected_branches/create_service_spec.rb
+++ b/spec/services/protected_branches/create_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe ProtectedBranches::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe ProtectedBranches::CreateService do
+ let(:project) { create(:project) }
let(:user) { project.owner }
let(:params) do
{
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
index 62bdd49a4d7..9fa5983db66 100644
--- a/spec/services/protected_branches/update_service_spec.rb
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProtectedBranches::UpdateService, services: true do
+describe ProtectedBranches::UpdateService do
let(:protected_branch) { create(:protected_branch) }
let(:project) { protected_branch.project }
let(:user) { project.owner }
@@ -19,7 +19,7 @@ describe ProtectedBranches::UpdateService, services: true do
let(:user) { create(:user) }
it "raises error" do
- expect{ service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
end
diff --git a/spec/services/protected_tags/create_service_spec.rb b/spec/services/protected_tags/create_service_spec.rb
index d91a58e8de5..c3ed95aaebf 100644
--- a/spec/services/protected_tags/create_service_spec.rb
+++ b/spec/services/protected_tags/create_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe ProtectedTags::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe ProtectedTags::CreateService do
+ let(:project) { create(:project) }
let(:user) { project.owner }
let(:params) do
{
diff --git a/spec/services/protected_tags/update_service_spec.rb b/spec/services/protected_tags/update_service_spec.rb
index e78fde4c48d..2ece4e3b07b 100644
--- a/spec/services/protected_tags/update_service_spec.rb
+++ b/spec/services/protected_tags/update_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProtectedTags::UpdateService, services: true do
+describe ProtectedTags::UpdateService do
let(:protected_tag) { create(:protected_tag) }
let(:project) { protected_tag.project }
let(:user) { project.owner }
@@ -19,7 +19,7 @@ describe ProtectedTags::UpdateService, services: true do
let(:user) { create(:user) }
it "raises error" do
- expect{ service.execute(protected_tag) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { service.execute(protected_tag) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
end
diff --git a/spec/services/push_event_payload_service_spec.rb b/spec/services/push_event_payload_service_spec.rb
new file mode 100644
index 00000000000..81956200bff
--- /dev/null
+++ b/spec/services/push_event_payload_service_spec.rb
@@ -0,0 +1,218 @@
+require 'spec_helper'
+
+describe PushEventPayloadService do
+ let(:event) { create(:push_event) }
+
+ describe '#execute' do
+ let(:push_data) do
+ {
+ commits: [
+ {
+ id: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ message: 'This is a commit'
+ }
+ ],
+ before: '0000000000000000000000000000000000000000',
+ after: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ total_commits_count: 1,
+ ref: 'refs/heads/my-branch'
+ }
+ end
+
+ it 'creates a new PushEventPayload row' do
+ payload = described_class.new(event, push_data).execute
+
+ expect(payload.commit_count).to eq(1)
+ expect(payload.action).to eq('created')
+ expect(payload.ref_type).to eq('branch')
+ expect(payload.commit_from).to be_nil
+ expect(payload.commit_to).to eq(push_data[:after])
+ expect(payload.ref).to eq('my-branch')
+ expect(payload.commit_title).to eq('This is a commit')
+ expect(payload.event_id).to eq(event.id)
+ end
+
+ it 'sets the push_event_payload association of the used event' do
+ payload = described_class.new(event, push_data).execute
+
+ expect(event.push_event_payload).to eq(payload)
+ end
+ end
+
+ describe '#commit_title' do
+ it 'returns nil if no commits were pushed' do
+ service = described_class.new(event, commits: [])
+
+ expect(service.commit_title).to be_nil
+ end
+
+ it 'returns a String limited to 70 characters' do
+ service = described_class.new(event, commits: [{ message: 'a' * 100 }])
+
+ expect(service.commit_title).to eq(('a' * 67) + '...')
+ end
+
+ it 'does not truncate the commit message if it is shorter than 70 characters' do
+ service = described_class.new(event, commits: [{ message: 'Hello' }])
+
+ expect(service.commit_title).to eq('Hello')
+ end
+
+ it 'includes the first line of a commit message if the message spans multiple lines' do
+ service = described_class
+ .new(event, commits: [{ message: "Hello\n\nworld" }])
+
+ expect(service.commit_title).to eq('Hello')
+ end
+ end
+
+ describe '#commit_from_id' do
+ it 'returns nil when creating a new ref' do
+ service = described_class.new(event, before: Gitlab::Git::BLANK_SHA)
+
+ expect(service.commit_from_id).to be_nil
+ end
+
+ it 'returns the ID of the first commit when pushing to an existing ref' do
+ service = described_class.new(event, before: '123')
+
+ expect(service.commit_from_id).to eq('123')
+ end
+ end
+
+ describe '#commit_to_id' do
+ it 'returns nil when removing an existing ref' do
+ service = described_class.new(event, after: Gitlab::Git::BLANK_SHA)
+
+ expect(service.commit_to_id).to be_nil
+ end
+ end
+
+ describe '#commit_count' do
+ it 'returns the number of commits' do
+ service = described_class.new(event, total_commits_count: 1)
+
+ expect(service.commit_count).to eq(1)
+ end
+
+ it 'raises when the push data does not contain the commits count' do
+ service = described_class.new(event, {})
+
+ expect { service.commit_count }.to raise_error(KeyError)
+ end
+ end
+
+ describe '#ref' do
+ it 'returns the name of the ref' do
+ service = described_class.new(event, ref: 'refs/heads/foo')
+
+ expect(service.ref).to eq('refs/heads/foo')
+ end
+
+ it 'raises when the push data does not contain the ref name' do
+ service = described_class.new(event, {})
+
+ expect { service.ref }.to raise_error(KeyError)
+ end
+ end
+
+ describe '#revision_before' do
+ it 'returns the revision from before the push' do
+ service = described_class.new(event, before: 'foo')
+
+ expect(service.revision_before).to eq('foo')
+ end
+
+ it 'raises when the push data does not contain the before revision' do
+ service = described_class.new(event, {})
+
+ expect { service.revision_before }.to raise_error(KeyError)
+ end
+ end
+
+ describe '#revision_after' do
+ it 'returns the revision from after the push' do
+ service = described_class.new(event, after: 'foo')
+
+ expect(service.revision_after).to eq('foo')
+ end
+
+ it 'raises when the push data does not contain the after revision' do
+ service = described_class.new(event, {})
+
+ expect { service.revision_after }.to raise_error(KeyError)
+ end
+ end
+
+ describe '#trimmed_ref' do
+ it 'returns the ref name without its prefix' do
+ service = described_class.new(event, ref: 'refs/heads/foo')
+
+ expect(service.trimmed_ref).to eq('foo')
+ end
+ end
+
+ describe '#create?' do
+ it 'returns true when creating a new ref' do
+ service = described_class.new(event, before: Gitlab::Git::BLANK_SHA)
+
+ expect(service.create?).to eq(true)
+ end
+
+ it 'returns false when pushing to an existing ref' do
+ service = described_class.new(event, before: 'foo')
+
+ expect(service.create?).to eq(false)
+ end
+ end
+
+ describe '#remove?' do
+ it 'returns true when removing an existing ref' do
+ service = described_class.new(event, after: Gitlab::Git::BLANK_SHA)
+
+ expect(service.remove?).to eq(true)
+ end
+
+ it 'returns false pushing to an existing ref' do
+ service = described_class.new(event, after: 'foo')
+
+ expect(service.remove?).to eq(false)
+ end
+ end
+
+ describe '#action' do
+ it 'returns :created when creating a ref' do
+ service = described_class.new(event, before: Gitlab::Git::BLANK_SHA)
+
+ expect(service.action).to eq(:created)
+ end
+
+ it 'returns :removed when removing an existing ref' do
+ service = described_class.new(event,
+ before: '123',
+ after: Gitlab::Git::BLANK_SHA)
+
+ expect(service.action).to eq(:removed)
+ end
+
+ it 'returns :pushed when pushing to an existing ref' do
+ service = described_class.new(event, before: '123', after: '456')
+
+ expect(service.action).to eq(:pushed)
+ end
+ end
+
+ describe '#ref_type' do
+ it 'returns :tag for a tag' do
+ service = described_class.new(event, ref: 'refs/tags/1.2')
+
+ expect(service.ref_type).to eq(:tag)
+ end
+
+ it 'returns :branch for a branch' do
+ service = described_class.new(event, ref: 'refs/heads/master')
+
+ expect(service.ref_type).to eq(:branch)
+ end
+ end
+end
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
new file mode 100644
index 00000000000..30fa0ee6873
--- /dev/null
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -0,0 +1,1151 @@
+require 'spec_helper'
+
+describe QuickActions::InterpretService do
+ let(:project) { create(:project, :public) }
+ let(:developer) { create(:user) }
+ let(:developer2) { create(:user) }
+ let(:issue) { create(:issue, project: project) }
+ let(:milestone) { create(:milestone, project: project, title: '9.10') }
+ let(:inprogress) { create(:label, project: project, title: 'In Progress') }
+ let(:bug) { create(:label, project: project, title: 'Bug') }
+ let(:note) { build(:note, commit_id: merge_request.diff_head_sha) }
+ let(:service) { described_class.new(project, developer) }
+
+ before do
+ project.team << [developer, :developer]
+ end
+
+ describe '#execute' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ shared_examples 'reopen command' do
+ it 'returns state_event: "reopen" if content contains /reopen' do
+ issuable.close!
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(state_event: 'reopen')
+ end
+ end
+
+ shared_examples 'close command' do
+ it 'returns state_event: "close" if content contains /close' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(state_event: 'close')
+ end
+ end
+
+ shared_examples 'title command' do
+ it 'populates title: "A brand new title" if content contains /title A brand new title' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(title: 'A brand new title')
+ end
+ end
+
+ shared_examples 'milestone command' do
+ it 'fetches milestone and populates milestone_id if content contains /milestone' do
+ milestone # populate the milestone
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(milestone_id: milestone.id)
+ end
+ end
+
+ shared_examples 'remove_milestone command' do
+ it 'populates milestone_id: nil if content contains /remove_milestone' do
+ issuable.update!(milestone_id: milestone.id)
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(milestone_id: nil)
+ end
+ end
+
+ shared_examples 'label command' do
+ it 'fetches label ids and populates add_label_ids if content contains /label' do
+ bug # populate the label
+ inprogress # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(add_label_ids: [bug.id, inprogress.id])
+ end
+ end
+
+ shared_examples 'multiple label command' do
+ it 'fetches label ids and populates add_label_ids if content contains multiple /label' do
+ bug # populate the label
+ inprogress # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(add_label_ids: [inprogress.id, bug.id])
+ end
+ end
+
+ shared_examples 'multiple label with same argument' do
+ it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do
+ inprogress # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(add_label_ids: [inprogress.id])
+ end
+ end
+
+ shared_examples 'unlabel command' do
+ it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do
+ issuable.update!(label_ids: [inprogress.id]) # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(remove_label_ids: [inprogress.id])
+ end
+ end
+
+ shared_examples 'multiple unlabel command' do
+ it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do
+ issuable.update!(label_ids: [inprogress.id, bug.id]) # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id])
+ end
+ end
+
+ shared_examples 'unlabel command with no argument' do
+ it 'populates label_ids: [] if content contains /unlabel with no arguments' do
+ issuable.update!(label_ids: [inprogress.id]) # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(label_ids: [])
+ end
+ end
+
+ shared_examples 'relabel command' do
+ it 'populates label_ids: [] if content contains /relabel' do
+ issuable.update!(label_ids: [bug.id]) # populate the label
+ inprogress # populate the label
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(label_ids: [inprogress.id])
+ end
+ end
+
+ shared_examples 'todo command' do
+ it 'populates todo_event: "add" if content contains /todo' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(todo_event: 'add')
+ end
+ end
+
+ shared_examples 'done command' do
+ it 'populates todo_event: "done" if content contains /done' do
+ TodoService.new.mark_todo(issuable, developer)
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(todo_event: 'done')
+ end
+ end
+
+ shared_examples 'subscribe command' do
+ it 'populates subscription_event: "subscribe" if content contains /subscribe' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(subscription_event: 'subscribe')
+ end
+ end
+
+ shared_examples 'unsubscribe command' do
+ it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do
+ issuable.subscribe(developer, project)
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(subscription_event: 'unsubscribe')
+ end
+ end
+
+ shared_examples 'due command' do
+ it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28))
+ end
+ end
+
+ shared_examples 'remove_due_date command' do
+ it 'populates due_date: nil if content contains /remove_due_date' do
+ issuable.update!(due_date: Date.today)
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(due_date: nil)
+ end
+ end
+
+ shared_examples 'wip command' do
+ it 'returns wip_event: "wip" if content contains /wip' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(wip_event: 'wip')
+ end
+ end
+
+ shared_examples 'unwip command' do
+ it 'returns wip_event: "unwip" if content contains /wip' do
+ issuable.update!(title: issuable.wip_title)
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(wip_event: 'unwip')
+ end
+ end
+
+ shared_examples 'estimate command' do
+ it 'populates time_estimate: 3600 if content contains /estimate 1h' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(time_estimate: 3600)
+ end
+ end
+
+ shared_examples 'spend command' do
+ it 'populates spend_time: 3600 if content contains /spend 1h' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: 3600, user: developer })
+ end
+ end
+
+ shared_examples 'spend command with negative time' do
+ it 'populates spend_time: -1800 if content contains /spend -30m' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: -1800, user: developer })
+ end
+ end
+
+ shared_examples 'remove_estimate command' do
+ it 'populates time_estimate: 0 if content contains /remove_estimate' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(time_estimate: 0)
+ end
+ end
+
+ shared_examples 'remove_time_spent command' do
+ it 'populates spend_time: :reset if content contains /remove_time_spent' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: :reset, user: developer })
+ end
+ end
+
+ shared_examples 'empty command' do
+ it 'populates {} if content contains an unsupported command' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to be_empty
+ end
+ end
+
+ shared_examples 'merge command' do
+ let(:project) { create(:project, :repository) }
+
+ it 'runs merge command if content contains /merge' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(merge: merge_request.diff_head_sha)
+ end
+ end
+
+ shared_examples 'award command' do
+ it 'toggle award 100 emoji if content containts /award :100:' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(emoji_award: "100")
+ end
+ end
+
+ shared_examples 'duplicate command' do
+ it 'fetches issue and populates canonical_issue_id if content contains /duplicate issue_reference' do
+ issue_duplicate # populate the issue
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(canonical_issue_id: issue_duplicate.id)
+ end
+ end
+
+ shared_examples 'shrug command' do
+ it 'appends ¯\_(ツ)_/¯ to the comment' do
+ new_content, _ = service.execute(content, issuable)
+
+ expect(new_content).to end_with(described_class::SHRUG)
+ end
+ end
+
+ shared_examples 'tableflip command' do
+ it 'appends (╯°□°)╯︵ â”»â”â”» to the comment' do
+ new_content, _ = service.execute(content, issuable)
+
+ expect(new_content).to end_with(described_class::TABLEFLIP)
+ end
+ end
+
+ it_behaves_like 'reopen command' do
+ let(:content) { '/reopen' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'reopen command' do
+ let(:content) { '/reopen' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'close command' do
+ let(:content) { '/close' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'close command' do
+ let(:content) { '/close' }
+ let(:issuable) { merge_request }
+ end
+
+ context 'merge command' do
+ let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) }
+
+ it_behaves_like 'merge command' do
+ let(:content) { '/merge' }
+ let(:issuable) { merge_request }
+ end
+
+ context 'can not be merged when logged user does not have permissions' do
+ let(:service) { described_class.new(project, create(:user)) }
+
+ it_behaves_like 'empty command' do
+ let(:content) { "/merge" }
+ let(:issuable) { merge_request }
+ end
+ end
+
+ context 'can not be merged when sha does not match' do
+ let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) }
+
+ it_behaves_like 'empty command' do
+ let(:content) { "/merge" }
+ let(:issuable) { merge_request }
+ end
+ end
+
+ context 'when sha is missing' do
+ let(:project) { create(:project, :repository) }
+ let(:service) { described_class.new(project, developer, {}) }
+
+ it 'precheck passes and returns merge command' do
+ _, updates = service.execute('/merge', merge_request)
+
+ expect(updates).to eq(merge: nil)
+ end
+ end
+
+ context 'issue can not be merged' do
+ it_behaves_like 'empty command' do
+ let(:content) { "/merge" }
+ let(:issuable) { issue }
+ end
+ end
+
+ context 'non persisted merge request cant be merged' do
+ it_behaves_like 'empty command' do
+ let(:content) { "/merge" }
+ let(:issuable) { build(:merge_request) }
+ end
+ end
+
+ context 'not persisted merge request can not be merged' do
+ it_behaves_like 'empty command' do
+ let(:content) { "/merge" }
+ let(:issuable) { build(:merge_request, source_project: project) }
+ end
+ end
+ end
+
+ it_behaves_like 'title command' do
+ let(:content) { '/title A brand new title' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'title command' do
+ let(:content) { '/title A brand new title' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/title' }
+ let(:issuable) { issue }
+ end
+
+ context 'assign command' do
+ let(:content) { "/assign @#{developer.username}" }
+
+ context 'Issue' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:assignee_ids]).to match_array([developer.id])
+ end
+ end
+
+ context 'Merge Request' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, merge_request)
+
+ expect(updates).to eq(assignee_ids: [developer.id])
+ end
+ end
+ end
+
+ context 'assign command with multiple assignees' do
+ let(:content) { "/assign @#{developer.username} @#{developer2.username}" }
+
+ before do
+ project.team << [developer2, :developer]
+ end
+
+ context 'Issue' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:assignee_ids]).to match_array([developer.id])
+ end
+ end
+
+ context 'Merge Request' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, merge_request)
+
+ expect(updates).to eq(assignee_ids: [developer.id])
+ end
+ end
+ end
+
+ context 'assign command with me alias' do
+ let(:content) { "/assign me" }
+
+ context 'Issue' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, issue)
+
+ expect(updates).to eq(assignee_ids: [developer.id])
+ end
+ end
+
+ context 'Merge Request' do
+ it 'fetches assignee and populates assignee_ids if content contains /assign' do
+ _, updates = service.execute(content, merge_request)
+
+ expect(updates).to eq(assignee_ids: [developer.id])
+ end
+ end
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/assign @abcd1234' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/assign' }
+ let(:issuable) { issue }
+ end
+
+ context 'unassign command' do
+ let(:content) { '/unassign' }
+
+ context 'Issue' do
+ it 'populates assignee_ids: [] if content contains /unassign' do
+ issue.update(assignee_ids: [developer.id])
+ _, updates = service.execute(content, issue)
+
+ expect(updates).to eq(assignee_ids: [])
+ end
+ end
+
+ context 'Merge Request' do
+ it 'populates assignee_ids: [] if content contains /unassign' do
+ merge_request.update(assignee_ids: [developer.id])
+ _, updates = service.execute(content, merge_request)
+
+ expect(updates).to eq(assignee_ids: [])
+ end
+ end
+ end
+
+ it_behaves_like 'milestone command' do
+ let(:content) { "/milestone %#{milestone.title}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'milestone command' do
+ let(:content) { "/milestone %#{milestone.title}" }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'remove_milestone command' do
+ let(:content) { '/remove_milestone' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'remove_milestone command' do
+ let(:content) { '/remove_milestone' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'label command' do
+ let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'label command' do
+ let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'multiple label command' do
+ let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'multiple label with same argument' do
+ let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'unlabel command' do
+ let(:content) { %(/unlabel ~"#{inprogress.title}") }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'unlabel command' do
+ let(:content) { %(/unlabel ~"#{inprogress.title}") }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'multiple unlabel command' do
+ let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'unlabel command with no argument' do
+ let(:content) { %(/unlabel) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'unlabel command with no argument' do
+ let(:content) { %(/unlabel) }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'relabel command' do
+ let(:content) { %(/relabel ~"#{inprogress.title}") }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'relabel command' do
+ let(:content) { %(/relabel ~"#{inprogress.title}") }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'todo command' do
+ let(:content) { '/todo' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'todo command' do
+ let(:content) { '/todo' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'done command' do
+ let(:content) { '/done' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'done command' do
+ let(:content) { '/done' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'subscribe command' do
+ let(:content) { '/subscribe' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'subscribe command' do
+ let(:content) { '/subscribe' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'unsubscribe command' do
+ let(:content) { '/unsubscribe' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'unsubscribe command' do
+ let(:content) { '/unsubscribe' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'due command' do
+ let(:content) { '/due 2016-08-28' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'due command' do
+ let(:content) { '/due tomorrow' }
+ let(:issuable) { issue }
+ let(:expected_date) { Date.tomorrow }
+ end
+
+ it_behaves_like 'due command' do
+ let(:content) { '/due 5 days from now' }
+ let(:issuable) { issue }
+ let(:expected_date) { 5.days.from_now.to_date }
+ end
+
+ it_behaves_like 'due command' do
+ let(:content) { '/due in 2 days' }
+ let(:issuable) { issue }
+ let(:expected_date) { 2.days.from_now.to_date }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/due foo bar' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/due 2016-08-28' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'remove_due_date command' do
+ let(:content) { '/remove_due_date' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'wip command' do
+ let(:content) { '/wip' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'unwip command' do
+ let(:content) { '/wip' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/remove_due_date' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'estimate command' do
+ let(:content) { '/estimate 1h' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/estimate' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/estimate abc' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command' do
+ let(:content) { '/spend 1h' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command with negative time' do
+ let(:content) { '/spend -30m' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/spend' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/spend abc' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'remove_estimate command' do
+ let(:content) { '/remove_estimate' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'remove_time_spent command' do
+ let(:content) { '/remove_time_spent' }
+ let(:issuable) { issue }
+ end
+
+ context '/duplicate command' do
+ it_behaves_like 'duplicate command' do
+ let(:issue_duplicate) { create(:issue, project: project) }
+ let(:content) { "/duplicate #{issue_duplicate.to_reference}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/duplicate' }
+ let(:issuable) { issue }
+ end
+
+ context 'cross project references' do
+ it_behaves_like 'duplicate command' do
+ let(:other_project) { create(:project, :public) }
+ let(:issue_duplicate) { create(:issue, project: other_project) }
+ let(:content) { "/duplicate #{issue_duplicate.to_reference(project)}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { "/duplicate imaginary#1234" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:other_project) { create(:project, :private) }
+ let(:issue_duplicate) { create(:issue, project: other_project) }
+
+ let(:content) { "/duplicate #{issue_duplicate.to_reference(project)}" }
+ let(:issuable) { issue }
+ end
+ end
+ end
+
+ context 'when current_user cannot :admin_issue' do
+ let(:visitor) { create(:user) }
+ let(:issue) { create(:issue, project: project, author: visitor) }
+ let(:service) { described_class.new(project, visitor) }
+
+ it_behaves_like 'empty command' do
+ let(:content) { "/assign @#{developer.username}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/unassign' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { "/milestone %#{milestone.title}" }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/remove_milestone' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { %(/unlabel ~"#{inprogress.title}") }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { %(/relabel ~"#{inprogress.title}") }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/due tomorrow' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/remove_due_date' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/duplicate #{issue.to_reference}' }
+ let(:issuable) { issue }
+ end
+ end
+
+ context '/award command' do
+ it_behaves_like 'award command' do
+ let(:content) { '/award :100:' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'award command' do
+ let(:content) { '/award :100:' }
+ let(:issuable) { merge_request }
+ end
+
+ context 'ignores command with no argument' do
+ it_behaves_like 'empty command' do
+ let(:content) { '/award' }
+ let(:issuable) { issue }
+ end
+ end
+
+ context 'ignores non-existing / invalid emojis' do
+ it_behaves_like 'empty command' do
+ let(:content) { '/award noop' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/award :lorem_ipsum:' }
+ let(:issuable) { issue }
+ end
+ end
+ end
+
+ context '/shrug command' do
+ it_behaves_like 'shrug command' do
+ let(:content) { '/shrug people are people' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'shrug command' do
+ let(:content) { '/shrug' }
+ let(:issuable) { issue }
+ end
+ end
+
+ context '/tableflip command' do
+ it_behaves_like 'tableflip command' do
+ let(:content) { '/tableflip curse your sudden but enviable betrayal' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'tableflip command' do
+ let(:content) { '/tableflip' }
+ let(:issuable) { issue }
+ end
+ end
+
+ context '/target_branch command' do
+ let(:non_empty_project) { create(:project, :repository) }
+ let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) }
+ let(:service) { described_class.new(non_empty_project, developer)}
+
+ it 'updates target_branch if /target_branch command is executed' do
+ _, updates = service.execute('/target_branch merge-test', merge_request)
+
+ expect(updates).to eq(target_branch: 'merge-test')
+ end
+
+ it 'handles blanks around param' do
+ _, updates = service.execute('/target_branch merge-test ', merge_request)
+
+ expect(updates).to eq(target_branch: 'merge-test')
+ end
+
+ context 'ignores command with no argument' do
+ it_behaves_like 'empty command' do
+ let(:content) { '/target_branch' }
+ let(:issuable) { another_merge_request }
+ end
+ end
+
+ context 'ignores non-existing target branch' do
+ it_behaves_like 'empty command' do
+ let(:content) { '/target_branch totally_non_existing_branch' }
+ let(:issuable) { another_merge_request }
+ end
+ end
+ end
+
+ context '/board_move command' do
+ let(:todo) { create(:label, project: project, title: 'To Do') }
+ let(:inreview) { create(:label, project: project, title: 'In Review') }
+ let(:content) { %{/board_move ~"#{inreview.title}"} }
+
+ let!(:board) { create(:board, project: project) }
+ let!(:todo_list) { create(:list, board: board, label: todo) }
+ let!(:inreview_list) { create(:list, board: board, label: inreview) }
+ let!(:inprogress_list) { create(:list, board: board, label: inprogress) }
+
+ it 'populates remove_label_ids for all current board columns' do
+ issue.update!(label_ids: [todo.id, inprogress.id])
+
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:remove_label_ids]).to match_array([todo.id, inprogress.id])
+ end
+
+ it 'populates add_label_ids with the id of the given label' do
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:add_label_ids]).to eq([inreview.id])
+ end
+
+ it 'does not include the given label id in remove_label_ids' do
+ issue.update!(label_ids: [todo.id, inreview.id])
+
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:remove_label_ids]).to match_array([todo.id])
+ end
+
+ it 'does not remove label ids that are not lists on the board' do
+ issue.update!(label_ids: [todo.id, bug.id])
+
+ _, updates = service.execute(content, issue)
+
+ expect(updates[:remove_label_ids]).to match_array([todo.id])
+ end
+
+ context 'if the project has multiple boards' do
+ let(:issuable) { issue }
+
+ before do
+ create(:board, project: project)
+ end
+
+ it_behaves_like 'empty command'
+ end
+
+ context 'if the given label does not exist' do
+ let(:issuable) { issue }
+ let(:content) { '/board_move ~"Fake Label"' }
+ it_behaves_like 'empty command'
+ end
+
+ context 'if multiple labels are given' do
+ let(:issuable) { issue }
+ let(:content) { %{/board_move ~"#{inreview.title}" ~"#{todo.title}"} }
+ it_behaves_like 'empty command'
+ end
+
+ context 'if the given label is not a list on the board' do
+ let(:issuable) { issue }
+ let(:content) { %{/board_move ~"#{bug.title}"} }
+ it_behaves_like 'empty command'
+ end
+
+ context 'if issuable is not an Issue' do
+ let(:issuable) { merge_request }
+ it_behaves_like 'empty command'
+ end
+ end
+ end
+
+ describe '#explain' do
+ let(:service) { described_class.new(project, developer) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ describe 'close command' do
+ let(:content) { '/close' }
+
+ it 'includes issuable name' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Closes this issue.'])
+ end
+ end
+
+ describe 'reopen command' do
+ let(:content) { '/reopen' }
+ let(:merge_request) { create(:merge_request, :closed, source_project: project) }
+
+ it 'includes issuable name' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Reopens this merge request.'])
+ end
+ end
+
+ describe 'title command' do
+ let(:content) { '/title This is new title' }
+
+ it 'includes new title' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Changes the title to "This is new title".'])
+ end
+ end
+
+ describe 'assign command' do
+ let(:content) { "/assign @#{developer.username} do it!" }
+
+ it 'includes only the user reference' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(["Assigns @#{developer.username}."])
+ end
+ end
+
+ describe 'unassign command' do
+ let(:content) { '/unassign' }
+ let(:issue) { create(:issue, project: project, assignees: [developer]) }
+
+ it 'includes current assignee reference' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(["Removes assignee @#{developer.username}."])
+ end
+ end
+
+ describe 'milestone command' do
+ let(:content) { '/milestone %wrong-milestone' }
+ let!(:milestone) { create(:milestone, project: project, title: '9.10') }
+
+ it 'is empty when milestone reference is wrong' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq([])
+ end
+ end
+
+ describe 'remove milestone command' do
+ let(:content) { '/remove_milestone' }
+ let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) }
+
+ it 'includes current milestone name' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Removes %"9.10" milestone.'])
+ end
+ end
+
+ describe 'label command' do
+ let(:content) { '/label ~missing' }
+ let!(:label) { create(:label, project: project) }
+
+ it 'is empty when there are no correct labels' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq([])
+ end
+ end
+
+ describe 'unlabel command' do
+ let(:content) { '/unlabel' }
+
+ it 'says all labels if no parameter provided' do
+ merge_request.update!(label_ids: [bug.id])
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Removes all labels.'])
+ end
+ end
+
+ describe 'relabel command' do
+ let(:content) { '/relabel Bug' }
+ let!(:bug) { create(:label, project: project, title: 'Bug') }
+ let(:feature) { create(:label, project: project, title: 'Feature') }
+
+ it 'includes label name' do
+ issue.update!(label_ids: [feature.id])
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."])
+ end
+ end
+
+ describe 'subscribe command' do
+ let(:content) { '/subscribe' }
+
+ it 'includes issuable name' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Subscribes to this issue.'])
+ end
+ end
+
+ describe 'unsubscribe command' do
+ let(:content) { '/unsubscribe' }
+
+ it 'includes issuable name' do
+ merge_request.subscribe(developer, project)
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Unsubscribes from this merge request.'])
+ end
+ end
+
+ describe 'due command' do
+ let(:content) { '/due April 1st 2016' }
+
+ it 'includes the date' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Sets the due date to Apr 1, 2016.'])
+ end
+ end
+
+ describe 'wip command' do
+ let(:content) { '/wip' }
+
+ it 'includes the new status' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Marks this merge request as Work In Progress.'])
+ end
+ end
+
+ describe 'award command' do
+ let(:content) { '/award :confetti_ball: ' }
+
+ it 'includes the emoji' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Toggles :confetti_ball: emoji award.'])
+ end
+ end
+
+ describe 'estimate command' do
+ let(:content) { '/estimate 79d' }
+
+ it 'includes the formatted duration' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.'])
+ end
+ end
+
+ describe 'spend command' do
+ let(:content) { '/spend -120m' }
+
+ it 'includes the formatted duration and proper verb' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(['Substracts 2h spent time.'])
+ end
+ end
+
+ describe 'target branch command' do
+ let(:content) { '/target_branch my-feature ' }
+
+ it 'includes the branch name' do
+ _, explanations = service.explain(content, merge_request)
+
+ expect(explanations).to eq(['Sets target branch to my-feature.'])
+ end
+ end
+
+ describe 'board move command' do
+ let(:content) { '/board_move ~bug' }
+ let!(:bug) { create(:label, project: project, title: 'bug') }
+ let!(:board) { create(:board, project: project) }
+
+ it 'includes the label name' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."])
+ end
+ end
+ end
+end
diff --git a/spec/services/repair_ldap_blocked_user_service_spec.rb b/spec/services/repair_ldap_blocked_user_service_spec.rb
index 87192457298..bf79cfe74b7 100644
--- a/spec/services/repair_ldap_blocked_user_service_spec.rb
+++ b/spec/services/repair_ldap_blocked_user_service_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe RepairLdapBlockedUserService, services: true do
+describe RepairLdapBlockedUserService do
let(:user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') }
let(:identity) { user.ldap_identity }
- subject(:service) { RepairLdapBlockedUserService.new(user) }
+ subject(:service) { described_class.new(user) }
describe '#execute' do
it 'changes to normal block after destroying last ldap identity' do
diff --git a/spec/services/repository_archive_clean_up_service_spec.rb b/spec/services/repository_archive_clean_up_service_spec.rb
index 842585f9e54..2d7fa3f80f7 100644
--- a/spec/services/repository_archive_clean_up_service_spec.rb
+++ b/spec/services/repository_archive_clean_up_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe RepositoryArchiveCleanUpService, services: true do
+describe RepositoryArchiveCleanUpService do
describe '#execute' do
subject(:service) { described_class.new }
diff --git a/spec/services/search/global_service_spec.rb b/spec/services/search/global_service_spec.rb
index cbf4f56213d..1309240b430 100644
--- a/spec/services/search/global_service_spec.rb
+++ b/spec/services/search/global_service_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Search::GlobalService, services: true do
+describe Search::GlobalService do
let(:user) { create(:user) }
let(:internal_user) { create(:user) }
- let!(:found_project) { create(:empty_project, :private, name: 'searchable_project') }
- let!(:unfound_project) { create(:empty_project, :private, name: 'unfound_project') }
- let!(:internal_project) { create(:empty_project, :internal, name: 'searchable_internal_project') }
- let!(:public_project) { create(:empty_project, :public, name: 'searchable_public_project') }
+ let!(:found_project) { create(:project, :private, name: 'searchable_project') }
+ let!(:unfound_project) { create(:project, :private, name: 'unfound_project') }
+ let!(:internal_project) { create(:project, :internal, name: 'searchable_internal_project') }
+ let!(:public_project) { create(:project, :public, name: 'searchable_public_project') }
before do
found_project.add_master(user)
@@ -16,7 +16,7 @@ describe Search::GlobalService, services: true do
describe '#execute' do
context 'unauthenticated' do
it 'returns public projects only' do
- results = Search::GlobalService.new(nil, search: "searchable").execute
+ results = described_class.new(nil, search: "searchable").execute
expect(results.objects('projects')).to match_array [public_project]
end
@@ -24,19 +24,19 @@ describe Search::GlobalService, services: true do
context 'authenticated' do
it 'returns public, internal and private projects' do
- results = Search::GlobalService.new(user, search: "searchable").execute
+ results = described_class.new(user, search: "searchable").execute
expect(results.objects('projects')).to match_array [public_project, found_project, internal_project]
end
it 'returns only public & internal projects' do
- results = Search::GlobalService.new(internal_user, search: "searchable").execute
+ results = described_class.new(internal_user, search: "searchable").execute
expect(results.objects('projects')).to match_array [internal_project, public_project]
end
it 'namespace name is searchable' do
- results = Search::GlobalService.new(user, search: found_project.namespace.path).execute
+ results = described_class.new(user, search: found_project.namespace.path).execute
expect(results.objects('projects')).to match_array [found_project]
end
diff --git a/spec/services/search/group_service_spec.rb b/spec/services/search/group_service_spec.rb
index 38f264f6e7b..cbc553a60cf 100644
--- a/spec/services/search/group_service_spec.rb
+++ b/spec/services/search/group_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Search::GroupService, services: true do
+describe Search::GroupService do
shared_examples_for 'group search' do
context 'finding projects by name' do
let(:user) { create(:user) }
@@ -8,16 +8,16 @@ describe Search::GroupService, services: true do
let(:nested_group) { create(:group, :nested) }
# These projects shouldn't be found
- let!(:outside_project) { create(:empty_project, :public, name: "Outside #{term}") }
- let!(:private_project) { create(:empty_project, :private, namespace: nested_group, name: "Private #{term}" )}
- let!(:other_project) { create(:empty_project, :public, namespace: nested_group, name: term.reverse) }
+ let!(:outside_project) { create(:project, :public, name: "Outside #{term}") }
+ let!(:private_project) { create(:project, :private, namespace: nested_group, name: "Private #{term}" )}
+ let!(:other_project) { create(:project, :public, namespace: nested_group, name: term.reverse) }
# These projects should be found
- let!(:project1) { create(:empty_project, :internal, namespace: nested_group, name: "Inner #{term} 1") }
- let!(:project2) { create(:empty_project, :internal, namespace: nested_group, name: "Inner #{term} 2") }
- let!(:project3) { create(:empty_project, :internal, namespace: nested_group.parent, name: "Outer #{term}") }
+ let!(:project1) { create(:project, :internal, namespace: nested_group, name: "Inner #{term} 1") }
+ let!(:project2) { create(:project, :internal, namespace: nested_group, name: "Inner #{term} 2") }
+ let!(:project3) { create(:project, :internal, namespace: nested_group.parent, name: "Outer #{term}") }
- let(:results) { Search::GroupService.new(user, search_group, search: term).execute }
+ let(:results) { described_class.new(user, search_group, search: term).execute }
subject { results.objects('projects') }
context 'in parent group' do
diff --git a/spec/services/search/snippet_service_spec.rb b/spec/services/search/snippet_service_spec.rb
index 14f3301d9f4..eae9bd4f5cf 100644
--- a/spec/services/search/snippet_service_spec.rb
+++ b/spec/services/search/snippet_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe Search::SnippetService, services: true do
+describe Search::SnippetService do
let(:author) { create(:author) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let!(:public_snippet) { create(:snippet, :public, content: 'password: XXX') }
let!(:internal_snippet) { create(:snippet, :internal, content: 'password: XXX') }
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 2112f1cf9ea..02de83a2df8 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -1,19 +1,19 @@
require 'spec_helper'
-describe SearchService, services: true do
+describe SearchService do
let(:user) { create(:user) }
let(:accessible_group) { create(:group, :private) }
let(:inaccessible_group) { create(:group, :private) }
let!(:group_member) { create(:group_member, group: accessible_group, user: user) }
- let!(:accessible_project) { create(:empty_project, :private, name: 'accessible_project') }
- let!(:inaccessible_project) { create(:empty_project, :private, name: 'inaccessible_project') }
+ let!(:accessible_project) { create(:project, :private, name: 'accessible_project') }
+ let!(:inaccessible_project) { create(:project, :private, name: 'inaccessible_project') }
let(:note) { create(:note_on_issue, project: accessible_project) }
let(:snippet) { create(:snippet, author: user) }
- let(:group_project) { create(:empty_project, group: accessible_group, name: 'group_project') }
- let(:public_project) { create(:empty_project, :public, name: 'public_project') }
+ let(:group_project) { create(:project, group: accessible_group, name: 'group_project') }
+ let(:public_project) { create(:project, :public, name: 'public_project') }
before do
accessible_project.add_master(user)
@@ -22,15 +22,24 @@ describe SearchService, services: true do
describe '#project' do
context 'when the project is accessible' do
it 'returns the project' do
- project = SearchService.new(user, project_id: accessible_project.id).project
+ project = described_class.new(user, project_id: accessible_project.id).project
expect(project).to eq accessible_project
end
+
+ it 'returns the project for guests' do
+ search_project = create :project
+ search_project.add_guest(user)
+
+ project = described_class.new(user, project_id: search_project.id).project
+
+ expect(project).to eq search_project
+ end
end
context 'when the project is not accessible' do
it 'returns nil' do
- project = SearchService.new(user, project_id: inaccessible_project.id).project
+ project = described_class.new(user, project_id: inaccessible_project.id).project
expect(project).to be_nil
end
@@ -38,7 +47,7 @@ describe SearchService, services: true do
context 'when there is no project_id' do
it 'returns nil' do
- project = SearchService.new(user).project
+ project = described_class.new(user).project
expect(project).to be_nil
end
@@ -48,7 +57,7 @@ describe SearchService, services: true do
describe '#group' do
context 'when the group is accessible' do
it 'returns the group' do
- group = SearchService.new(user, group_id: accessible_group.id).group
+ group = described_class.new(user, group_id: accessible_group.id).group
expect(group).to eq accessible_group
end
@@ -56,7 +65,7 @@ describe SearchService, services: true do
context 'when the group is not accessible' do
it 'returns nil' do
- group = SearchService.new(user, group_id: inaccessible_group.id).group
+ group = described_class.new(user, group_id: inaccessible_group.id).group
expect(group).to be_nil
end
@@ -64,7 +73,7 @@ describe SearchService, services: true do
context 'when there is no group_id' do
it 'returns nil' do
- group = SearchService.new(user).group
+ group = described_class.new(user).group
expect(group).to be_nil
end
@@ -74,7 +83,7 @@ describe SearchService, services: true do
describe '#show_snippets?' do
context 'when :snippets is \'true\'' do
it 'returns true' do
- show_snippets = SearchService.new(user, snippets: 'true').show_snippets?
+ show_snippets = described_class.new(user, snippets: 'true').show_snippets?
expect(show_snippets).to be_truthy
end
@@ -82,7 +91,7 @@ describe SearchService, services: true do
context 'when :snippets is not \'true\'' do
it 'returns false' do
- show_snippets = SearchService.new(user, snippets: 'tru').show_snippets?
+ show_snippets = described_class.new(user, snippets: 'tru').show_snippets?
expect(show_snippets).to be_falsey
end
@@ -90,7 +99,7 @@ describe SearchService, services: true do
context 'when :snippets is missing' do
it 'returns false' do
- show_snippets = SearchService.new(user).show_snippets?
+ show_snippets = described_class.new(user).show_snippets?
expect(show_snippets).to be_falsey
end
@@ -101,7 +110,7 @@ describe SearchService, services: true do
context 'with accessible project_id' do
context 'and allowed scope' do
it 'returns the specified scope' do
- scope = SearchService.new(user, project_id: accessible_project.id, scope: 'notes').scope
+ scope = described_class.new(user, project_id: accessible_project.id, scope: 'notes').scope
expect(scope).to eq 'notes'
end
@@ -109,7 +118,7 @@ describe SearchService, services: true do
context 'and disallowed scope' do
it 'returns the default scope' do
- scope = SearchService.new(user, project_id: accessible_project.id, scope: 'projects').scope
+ scope = described_class.new(user, project_id: accessible_project.id, scope: 'projects').scope
expect(scope).to eq 'blobs'
end
@@ -117,7 +126,7 @@ describe SearchService, services: true do
context 'and no scope' do
it 'returns the default scope' do
- scope = SearchService.new(user, project_id: accessible_project.id).scope
+ scope = described_class.new(user, project_id: accessible_project.id).scope
expect(scope).to eq 'blobs'
end
@@ -127,7 +136,7 @@ describe SearchService, services: true do
context 'with \'true\' snippets' do
context 'and allowed scope' do
it 'returns the specified scope' do
- scope = SearchService.new(user, snippets: 'true', scope: 'snippet_titles').scope
+ scope = described_class.new(user, snippets: 'true', scope: 'snippet_titles').scope
expect(scope).to eq 'snippet_titles'
end
@@ -135,7 +144,7 @@ describe SearchService, services: true do
context 'and disallowed scope' do
it 'returns the default scope' do
- scope = SearchService.new(user, snippets: 'true', scope: 'projects').scope
+ scope = described_class.new(user, snippets: 'true', scope: 'projects').scope
expect(scope).to eq 'snippet_blobs'
end
@@ -143,7 +152,7 @@ describe SearchService, services: true do
context 'and no scope' do
it 'returns the default scope' do
- scope = SearchService.new(user, snippets: 'true').scope
+ scope = described_class.new(user, snippets: 'true').scope
expect(scope).to eq 'snippet_blobs'
end
@@ -153,7 +162,7 @@ describe SearchService, services: true do
context 'with no project_id, no snippets' do
context 'and allowed scope' do
it 'returns the specified scope' do
- scope = SearchService.new(user, scope: 'issues').scope
+ scope = described_class.new(user, scope: 'issues').scope
expect(scope).to eq 'issues'
end
@@ -161,7 +170,7 @@ describe SearchService, services: true do
context 'and disallowed scope' do
it 'returns the default scope' do
- scope = SearchService.new(user, scope: 'blobs').scope
+ scope = described_class.new(user, scope: 'blobs').scope
expect(scope).to eq 'projects'
end
@@ -169,7 +178,7 @@ describe SearchService, services: true do
context 'and no scope' do
it 'returns the default scope' do
- scope = SearchService.new(user).scope
+ scope = described_class.new(user).scope
expect(scope).to eq 'projects'
end
@@ -180,7 +189,7 @@ describe SearchService, services: true do
describe '#search_results' do
context 'with accessible project_id' do
it 'returns an instance of Gitlab::ProjectSearchResults' do
- search_results = SearchService.new(
+ search_results = described_class.new(
user,
project_id: accessible_project.id,
scope: 'notes',
@@ -192,7 +201,7 @@ describe SearchService, services: true do
context 'with accessible project_id and \'true\' snippets' do
it 'returns an instance of Gitlab::ProjectSearchResults' do
- search_results = SearchService.new(
+ search_results = described_class.new(
user,
project_id: accessible_project.id,
snippets: 'true',
@@ -205,7 +214,7 @@ describe SearchService, services: true do
context 'with \'true\' snippets' do
it 'returns an instance of Gitlab::SnippetSearchResults' do
- search_results = SearchService.new(
+ search_results = described_class.new(
user,
snippets: 'true',
search: snippet.content).search_results
@@ -216,7 +225,7 @@ describe SearchService, services: true do
context 'with no project_id and no snippets' do
it 'returns an instance of Gitlab::SearchResults' do
- search_results = SearchService.new(
+ search_results = described_class.new(
user,
search: public_project.name).search_results
@@ -228,7 +237,7 @@ describe SearchService, services: true do
describe '#search_objects' do
context 'with accessible project_id' do
it 'returns objects in the project' do
- search_objects = SearchService.new(
+ search_objects = described_class.new(
user,
project_id: accessible_project.id,
scope: 'notes',
@@ -240,7 +249,7 @@ describe SearchService, services: true do
context 'with accessible project_id and \'true\' snippets' do
it 'returns objects in the project' do
- search_objects = SearchService.new(
+ search_objects = described_class.new(
user,
project_id: accessible_project.id,
snippets: 'true',
@@ -253,7 +262,7 @@ describe SearchService, services: true do
context 'with \'true\' snippets' do
it 'returns objects in snippets' do
- search_objects = SearchService.new(
+ search_objects = described_class.new(
user,
snippets: 'true',
search: snippet.content).search_objects
@@ -264,7 +273,7 @@ describe SearchService, services: true do
context 'with accessible group_id' do
it 'returns objects in the group' do
- search_objects = SearchService.new(
+ search_objects = described_class.new(
user,
group_id: accessible_group.id,
search: group_project.name).search_objects
@@ -275,7 +284,7 @@ describe SearchService, services: true do
context 'with no project_id, group_id or snippets' do
it 'returns objects in global' do
- search_objects = SearchService.new(
+ search_objects = described_class.new(
user,
search: public_project.name).search_objects
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
deleted file mode 100644
index a63281f0eab..00000000000
--- a/spec/services/slash_commands/interpret_service_spec.rb
+++ /dev/null
@@ -1,731 +0,0 @@
-require 'spec_helper'
-
-describe SlashCommands::InterpretService, services: true do
- let(:project) { create(:empty_project, :public) }
- let(:developer) { create(:user) }
- let(:issue) { create(:issue, project: project) }
- let(:milestone) { create(:milestone, project: project, title: '9.10') }
- let(:inprogress) { create(:label, project: project, title: 'In Progress') }
- let(:bug) { create(:label, project: project, title: 'Bug') }
- let(:note) { build(:note, commit_id: merge_request.diff_head_sha) }
-
- before do
- project.team << [developer, :developer]
- end
-
- describe '#execute' do
- let(:service) { described_class.new(project, developer) }
- let(:merge_request) { create(:merge_request, source_project: project) }
-
- shared_examples 'reopen command' do
- it 'returns state_event: "reopen" if content contains /reopen' do
- issuable.close!
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(state_event: 'reopen')
- end
- end
-
- shared_examples 'close command' do
- it 'returns state_event: "close" if content contains /close' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(state_event: 'close')
- end
- end
-
- shared_examples 'title command' do
- it 'populates title: "A brand new title" if content contains /title A brand new title' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(title: 'A brand new title')
- end
- end
-
- shared_examples 'assign command' do
- it 'fetches assignee and populates assignee_id if content contains /assign' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(assignee_id: developer.id)
- end
- end
-
- shared_examples 'unassign command' do
- it 'populates assignee_id: nil if content contains /unassign' do
- issuable.update(assignee_id: developer.id)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(assignee_id: nil)
- end
- end
-
- shared_examples 'milestone command' do
- it 'fetches milestone and populates milestone_id if content contains /milestone' do
- milestone # populate the milestone
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(milestone_id: milestone.id)
- end
- end
-
- shared_examples 'remove_milestone command' do
- it 'populates milestone_id: nil if content contains /remove_milestone' do
- issuable.update(milestone_id: milestone.id)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(milestone_id: nil)
- end
- end
-
- shared_examples 'label command' do
- it 'fetches label ids and populates add_label_ids if content contains /label' do
- bug # populate the label
- inprogress # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(add_label_ids: [bug.id, inprogress.id])
- end
- end
-
- shared_examples 'multiple label command' do
- it 'fetches label ids and populates add_label_ids if content contains multiple /label' do
- bug # populate the label
- inprogress # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(add_label_ids: [inprogress.id, bug.id])
- end
- end
-
- shared_examples 'multiple label with same argument' do
- it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do
- inprogress # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(add_label_ids: [inprogress.id])
- end
- end
-
- shared_examples 'unlabel command' do
- it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do
- issuable.update(label_ids: [inprogress.id]) # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(remove_label_ids: [inprogress.id])
- end
- end
-
- shared_examples 'multiple unlabel command' do
- it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do
- issuable.update(label_ids: [inprogress.id, bug.id]) # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id])
- end
- end
-
- shared_examples 'unlabel command with no argument' do
- it 'populates label_ids: [] if content contains /unlabel with no arguments' do
- issuable.update(label_ids: [inprogress.id]) # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(label_ids: [])
- end
- end
-
- shared_examples 'relabel command' do
- it 'populates label_ids: [] if content contains /relabel' do
- issuable.update(label_ids: [bug.id]) # populate the label
- inprogress # populate the label
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(label_ids: [inprogress.id])
- end
- end
-
- shared_examples 'todo command' do
- it 'populates todo_event: "add" if content contains /todo' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(todo_event: 'add')
- end
- end
-
- shared_examples 'done command' do
- it 'populates todo_event: "done" if content contains /done' do
- TodoService.new.mark_todo(issuable, developer)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(todo_event: 'done')
- end
- end
-
- shared_examples 'subscribe command' do
- it 'populates subscription_event: "subscribe" if content contains /subscribe' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(subscription_event: 'subscribe')
- end
- end
-
- shared_examples 'unsubscribe command' do
- it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do
- issuable.subscribe(developer, project)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(subscription_event: 'unsubscribe')
- end
- end
-
- shared_examples 'due command' do
- it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28))
- end
- end
-
- shared_examples 'remove_due_date command' do
- it 'populates due_date: nil if content contains /remove_due_date' do
- issuable.update(due_date: Date.today)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(due_date: nil)
- end
- end
-
- shared_examples 'wip command' do
- it 'returns wip_event: "wip" if content contains /wip' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(wip_event: 'wip')
- end
- end
-
- shared_examples 'unwip command' do
- it 'returns wip_event: "unwip" if content contains /wip' do
- issuable.update(title: issuable.wip_title)
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(wip_event: 'unwip')
- end
- end
-
- shared_examples 'estimate command' do
- it 'populates time_estimate: 3600 if content contains /estimate 1h' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(time_estimate: 3600)
- end
- end
-
- shared_examples 'spend command' do
- it 'populates spend_time: 3600 if content contains /spend 1h' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(spend_time: { duration: 3600, user: developer })
- end
- end
-
- shared_examples 'spend command with negative time' do
- it 'populates spend_time: -1800 if content contains /spend -30m' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(spend_time: { duration: -1800, user: developer })
- end
- end
-
- shared_examples 'remove_estimate command' do
- it 'populates time_estimate: 0 if content contains /remove_estimate' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(time_estimate: 0)
- end
- end
-
- shared_examples 'remove_time_spent command' do
- it 'populates spend_time: :reset if content contains /remove_time_spent' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(spend_time: { duration: :reset, user: developer })
- end
- end
-
- shared_examples 'empty command' do
- it 'populates {} if content contains an unsupported command' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to be_empty
- end
- end
-
- shared_examples 'merge command' do
- let(:project) { create(:project, :repository) }
-
- it 'runs merge command if content contains /merge' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(merge: merge_request.diff_head_sha)
- end
- end
-
- shared_examples 'award command' do
- it 'toggle award 100 emoji if content containts /award :100:' do
- _, updates = service.execute(content, issuable)
-
- expect(updates).to eq(emoji_award: "100")
- end
- end
-
- it_behaves_like 'reopen command' do
- let(:content) { '/reopen' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'reopen command' do
- let(:content) { '/reopen' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'close command' do
- let(:content) { '/close' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'close command' do
- let(:content) { '/close' }
- let(:issuable) { merge_request }
- end
-
- context 'merge command' do
- let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) }
-
- it_behaves_like 'merge command' do
- let(:content) { '/merge' }
- let(:issuable) { merge_request }
- end
-
- context 'can not be merged when logged user does not have permissions' do
- let(:service) { described_class.new(project, create(:user)) }
-
- it_behaves_like 'empty command' do
- let(:content) { "/merge" }
- let(:issuable) { merge_request }
- end
- end
-
- context 'can not be merged when sha does not match' do
- let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) }
-
- it_behaves_like 'empty command' do
- let(:content) { "/merge" }
- let(:issuable) { merge_request }
- end
- end
-
- context 'when sha is missing' do
- let(:project) { create(:project, :repository) }
- let(:service) { described_class.new(project, developer, {}) }
-
- it 'precheck passes and returns merge command' do
- _, updates = service.execute('/merge', merge_request)
-
- expect(updates).to eq(merge: nil)
- end
- end
-
- context 'issue can not be merged' do
- it_behaves_like 'empty command' do
- let(:content) { "/merge" }
- let(:issuable) { issue }
- end
- end
-
- context 'non persisted merge request cant be merged' do
- it_behaves_like 'empty command' do
- let(:content) { "/merge" }
- let(:issuable) { build(:merge_request) }
- end
- end
-
- context 'not persisted merge request can not be merged' do
- it_behaves_like 'empty command' do
- let(:content) { "/merge" }
- let(:issuable) { build(:merge_request, source_project: project) }
- end
- end
- end
-
- it_behaves_like 'title command' do
- let(:content) { '/title A brand new title' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'title command' do
- let(:content) { '/title A brand new title' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/title' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'assign command' do
- let(:content) { "/assign @#{developer.username}" }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'assign command' do
- let(:content) { "/assign @#{developer.username}" }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/assign @abcd1234' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/assign' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unassign command' do
- let(:content) { '/unassign' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unassign command' do
- let(:content) { '/unassign' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'milestone command' do
- let(:content) { "/milestone %#{milestone.title}" }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'milestone command' do
- let(:content) { "/milestone %#{milestone.title}" }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'remove_milestone command' do
- let(:content) { '/remove_milestone' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'remove_milestone command' do
- let(:content) { '/remove_milestone' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'label command' do
- let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'label command' do
- let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'multiple label command' do
- let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'multiple label with same argument' do
- let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unlabel command' do
- let(:content) { %(/unlabel ~"#{inprogress.title}") }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unlabel command' do
- let(:content) { %(/unlabel ~"#{inprogress.title}") }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'multiple unlabel command' do
- let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unlabel command with no argument' do
- let(:content) { %(/unlabel) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unlabel command with no argument' do
- let(:content) { %(/unlabel) }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'relabel command' do
- let(:content) { %(/relabel ~"#{inprogress.title}") }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'relabel command' do
- let(:content) { %(/relabel ~"#{inprogress.title}") }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'todo command' do
- let(:content) { '/todo' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'todo command' do
- let(:content) { '/todo' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'done command' do
- let(:content) { '/done' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'done command' do
- let(:content) { '/done' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'subscribe command' do
- let(:content) { '/subscribe' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'subscribe command' do
- let(:content) { '/subscribe' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'unsubscribe command' do
- let(:content) { '/unsubscribe' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'unsubscribe command' do
- let(:content) { '/unsubscribe' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'due command' do
- let(:content) { '/due 2016-08-28' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'due command' do
- let(:content) { '/due tomorrow' }
- let(:issuable) { issue }
- let(:expected_date) { Date.tomorrow }
- end
-
- it_behaves_like 'due command' do
- let(:content) { '/due 5 days from now' }
- let(:issuable) { issue }
- let(:expected_date) { 5.days.from_now.to_date }
- end
-
- it_behaves_like 'due command' do
- let(:content) { '/due in 2 days' }
- let(:issuable) { issue }
- let(:expected_date) { 2.days.from_now.to_date }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/due foo bar' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/due 2016-08-28' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'remove_due_date command' do
- let(:content) { '/remove_due_date' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'wip command' do
- let(:content) { '/wip' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'unwip command' do
- let(:content) { '/wip' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/remove_due_date' }
- let(:issuable) { merge_request }
- end
-
- it_behaves_like 'estimate command' do
- let(:content) { '/estimate 1h' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/estimate' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/estimate abc' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'spend command' do
- let(:content) { '/spend 1h' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'spend command with negative time' do
- let(:content) { '/spend -30m' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/spend' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/spend abc' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'remove_estimate command' do
- let(:content) { '/remove_estimate' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'remove_time_spent command' do
- let(:content) { '/remove_time_spent' }
- let(:issuable) { issue }
- end
-
- context 'when current_user cannot :admin_issue' do
- let(:visitor) { create(:user) }
- let(:issue) { create(:issue, project: project, author: visitor) }
- let(:service) { described_class.new(project, visitor) }
-
- it_behaves_like 'empty command' do
- let(:content) { "/assign @#{developer.username}" }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/unassign' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { "/milestone %#{milestone.title}" }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/remove_milestone' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { %(/unlabel ~"#{inprogress.title}") }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { %(/relabel ~"#{inprogress.title}") }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/due tomorrow' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/remove_due_date' }
- let(:issuable) { issue }
- end
- end
-
- context '/award command' do
- it_behaves_like 'award command' do
- let(:content) { '/award :100:' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'award command' do
- let(:content) { '/award :100:' }
- let(:issuable) { merge_request }
- end
-
- context 'ignores command with no argument' do
- it_behaves_like 'empty command' do
- let(:content) { '/award' }
- let(:issuable) { issue }
- end
- end
-
- context 'ignores non-existing / invalid emojis' do
- it_behaves_like 'empty command' do
- let(:content) { '/award noop' }
- let(:issuable) { issue }
- end
-
- it_behaves_like 'empty command' do
- let(:content) { '/award :lorem_ipsum:' }
- let(:issuable) { issue }
- end
- end
- end
-
- context '/target_branch command' do
- let(:non_empty_project) { create(:project, :repository) }
- let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) }
- let(:service) { described_class.new(non_empty_project, developer)}
-
- it 'updates target_branch if /target_branch command is executed' do
- _, updates = service.execute('/target_branch merge-test', merge_request)
-
- expect(updates).to eq(target_branch: 'merge-test')
- end
-
- it 'handles blanks around param' do
- _, updates = service.execute('/target_branch merge-test ', merge_request)
-
- expect(updates).to eq(target_branch: 'merge-test')
- end
-
- context 'ignores command with no argument' do
- it_behaves_like 'empty command' do
- let(:content) { '/target_branch' }
- let(:issuable) { another_merge_request }
- end
- end
-
- context 'ignores non-existing target branch' do
- it_behaves_like 'empty command' do
- let(:content) { '/target_branch totally_non_existing_branch' }
- let(:issuable) { another_merge_request }
- end
- end
- end
- end
-end
diff --git a/spec/services/spam_service_spec.rb b/spec/services/spam_service_spec.rb
index 74cba8c014b..a14dfa3f01f 100644
--- a/spec/services/spam_service_spec.rb
+++ b/spec/services/spam_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe SpamService, services: true do
+describe SpamService do
describe '#when_recaptcha_verified' do
def check_spam(issue, request, recaptcha_verified)
described_class.new(issue, request).when_recaptcha_verified(recaptcha_verified) do
@@ -15,7 +15,7 @@ describe SpamService, services: true do
end
context 'when recaptcha was not verified' do
- let(:project) { create(:empty_project, :public) }
+ let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
let(:request) { double(:request, env: {}) }
@@ -70,7 +70,9 @@ describe SpamService, services: true do
end
context 'when not indicated as spam by akismet' do
- before { allow(AkismetService).to receive(:new).and_return(double(is_spam?: false)) }
+ before do
+ allow(AkismetService).to receive(:new).and_return(double(is_spam?: false))
+ end
it 'returns false' do
expect(check_spam(issue, request, false)).to be_falsey
diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb
new file mode 100644
index 00000000000..c8a6fc1a99b
--- /dev/null
+++ b/spec/services/submit_usage_ping_service_spec.rb
@@ -0,0 +1,106 @@
+require 'spec_helper'
+
+describe SubmitUsagePingService do
+ context 'when usage ping is disabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: false)
+ end
+
+ it 'does not run' do
+ expect(HTTParty).not_to receive(:post)
+
+ result = subject.execute
+
+ expect(result).to eq false
+ end
+ end
+
+ context 'when usage ping is enabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ it 'sends a POST request' do
+ response = stub_response(without_conv_index_params)
+
+ subject.execute
+
+ expect(response).to have_been_requested
+ end
+
+ it 'refreshes usage data statistics before submitting' do
+ stub_response(without_conv_index_params)
+
+ expect(Gitlab::UsageData).to receive(:to_json)
+ .with(force_refresh: true)
+ .and_call_original
+
+ subject.execute
+ end
+
+ it 'saves conversational development index data from the response' do
+ stub_response(with_conv_index_params)
+
+ expect { subject.execute }
+ .to change { ConversationalDevelopmentIndex::Metric.count }
+ .by(1)
+
+ expect(ConversationalDevelopmentIndex::Metric.last.leader_issues).to eq 10.2
+ expect(ConversationalDevelopmentIndex::Metric.last.instance_issues).to eq 3.2
+ expect(ConversationalDevelopmentIndex::Metric.last.percentage_issues).to eq 31.37
+ end
+ end
+
+ def without_conv_index_params
+ {
+ conv_index: {}
+ }
+ end
+
+ def with_conv_index_params
+ {
+ conv_index: {
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+ percentage_issues: 31.37,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1,
+
+ non_existing_column: 'value'
+ }
+ }
+ end
+
+ def stub_response(body)
+ stub_request(:post, 'https://version.gitlab.com/usage_data')
+ .to_return(
+ headers: { 'Content-Type' => 'application/json' },
+ body: body.to_json
+ )
+ end
+end
diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb
index 667059f230c..8b5d9187785 100644
--- a/spec/services/system_hooks_service_spec.rb
+++ b/spec/services/system_hooks_service_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe SystemHooksService, services: true do
+describe SystemHooksService do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:project_member) { create(:project_member) }
let(:key) { create(:key, user: user) }
let(:deploy_key) { create(:key) }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 75d7caf2508..6d36affa9dc 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -1,11 +1,13 @@
require 'spec_helper'
-describe SystemNoteService, services: true do
- include Gitlab::Routing.url_helpers
+describe SystemNoteService do
+ include Gitlab::Routing
- let(:project) { create(:empty_project) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
let(:author) { create(:user) }
let(:noteable) { create(:issue, project: project) }
+ let(:issue) { noteable }
shared_examples_for 'a system note' do
let(:expected_noteable) { noteable }
@@ -33,7 +35,7 @@ describe SystemNoteService, services: true do
context 'metadata' do
it 'creates a new system note metadata record' do
- expect { subject }.to change{ SystemNoteMetadata.count }.from(0).to(1)
+ expect { subject }.to change { SystemNoteMetadata.count }.from(0).to(1)
end
it 'creates a record correctly' do
@@ -155,6 +157,52 @@ describe SystemNoteService, services: true do
end
end
+ describe '.change_issue_assignees' do
+ subject { described_class.change_issue_assignees(noteable, project, author, [assignee]) }
+
+ let(:assignee) { create(:user) }
+ let(:assignee1) { create(:user) }
+ let(:assignee2) { create(:user) }
+ let(:assignee3) { create(:user) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'assignee' }
+ end
+
+ def build_note(old_assignees, new_assignees)
+ issue.assignees = new_assignees
+ described_class.change_issue_assignees(issue, project, author, old_assignees).note
+ end
+
+ it 'builds a correct phrase when an assignee is added to a non-assigned issue' do
+ expect(build_note([], [assignee1])).to eq "assigned to @#{assignee1.username}"
+ end
+
+ it 'builds a correct phrase when assignee removed' do
+ expect(build_note([assignee1], [])).to eq 'removed assignee'
+ end
+
+ it 'builds a correct phrase when assignees changed' do
+ expect(build_note([assignee1], [assignee2])).to eq \
+ "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}"
+ end
+
+ it 'builds a correct phrase when three assignees removed and one added' do
+ expect(build_note([assignee, assignee1, assignee2], [assignee3])).to eq \
+ "assigned to @#{assignee3.username} and unassigned @#{assignee.username}, @#{assignee1.username}, and @#{assignee2.username}"
+ end
+
+ it 'builds a correct phrase when one assignee changed from a set' do
+ expect(build_note([assignee, assignee1], [assignee, assignee2])).to eq \
+ "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}"
+ end
+
+ it 'builds a correct phrase when one assignee removed from a set' do
+ expect(build_note([assignee, assignee1, assignee2], [assignee, assignee1])).to eq \
+ "unassigned @#{assignee2.username}"
+ end
+ end
+
describe '.change_label' do
subject { described_class.change_label(noteable, project, author, added, removed) }
@@ -195,25 +243,51 @@ describe SystemNoteService, services: true do
end
describe '.change_milestone' do
- subject { described_class.change_milestone(noteable, project, author, milestone) }
+ context 'for a project milestone' do
+ subject { described_class.change_milestone(noteable, project, author, milestone) }
- let(:milestone) { create(:milestone, project: project) }
+ let(:milestone) { create(:milestone, project: project) }
- it_behaves_like 'a system note' do
- let(:action) { 'milestone' }
- end
+ it_behaves_like 'a system note' do
+ let(:action) { 'milestone' }
+ end
- context 'when milestone added' do
- it 'sets the note text' do
- expect(subject.note).to eq "changed milestone to #{milestone.to_reference}"
+ context 'when milestone added' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "changed milestone to #{milestone.to_reference}"
+ end
+ end
+
+ context 'when milestone removed' do
+ let(:milestone) { nil }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq 'removed milestone'
+ end
end
end
- context 'when milestone removed' do
- let(:milestone) { nil }
+ context 'for a group milestone' do
+ subject { described_class.change_milestone(noteable, project, author, milestone) }
- it 'sets the note text' do
- expect(subject.note).to eq 'removed milestone'
+ let(:milestone) { create(:milestone, group: group) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'milestone' }
+ end
+
+ context 'when milestone added' do
+ it 'sets the note text to use the milestone name' do
+ expect(subject.note).to eq "changed milestone to #{milestone.to_reference(format: :name)}"
+ end
+ end
+
+ context 'when milestone removed' do
+ let(:milestone) { nil }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq 'removed milestone'
+ end
end
end
end
@@ -286,8 +360,22 @@ describe SystemNoteService, services: true do
end
it 'sets the note text' do
- expect(subject.note).
- to eq "changed title from **{-Old title-}** to **{+Lorem ipsum+}**"
+ expect(subject.note)
+ .to eq "changed title from **{-Old title-}** to **{+Lorem ipsum+}**"
+ end
+ end
+ end
+
+ describe '.change_description' do
+ subject { described_class.change_description(noteable, project, author) }
+
+ context 'when noteable responds to `description`' do
+ it_behaves_like 'a system note' do
+ let(:action) { 'description' }
+ end
+
+ it 'sets the note text' do
+ expect(subject.note).to eq('changed the description')
end
end
end
@@ -389,7 +477,7 @@ describe SystemNoteService, services: true do
end
it 'does not create a system note metadata record' do
- expect { subject }.not_to change{ SystemNoteMetadata.count }
+ expect { subject }.not_to change { SystemNoteMetadata.count }
end
end
@@ -460,8 +548,8 @@ describe SystemNoteService, services: true do
context 'when mentioner is not a MergeRequest' do
it 'is falsey' do
mentioner = noteable.dup
- expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
- to be_falsey
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner))
+ .to be_falsey
end
end
@@ -472,14 +560,14 @@ describe SystemNoteService, services: true do
it 'is truthy when noteable is in commits' do
expect(mentioner).to receive(:commits).and_return([noteable])
- expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
- to be_truthy
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner))
+ .to be_truthy
end
it 'is falsey when noteable is not in commits' do
expect(mentioner).to receive(:commits).and_return([])
- expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
- to be_falsey
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner))
+ .to be_falsey
end
end
@@ -487,8 +575,8 @@ describe SystemNoteService, services: true do
let(:noteable) { ExternalIssue.new('EXT-1234', project) }
it 'is truthy' do
mentioner = noteable.dup
- expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
- to be_truthy
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner))
+ .to be_truthy
end
end
end
@@ -505,13 +593,13 @@ describe SystemNoteService, services: true do
end
it 'is truthy when already mentioned' do
- expect(described_class.cross_reference_exists?(noteable, commit0)).
- to be_truthy
+ expect(described_class.cross_reference_exists?(noteable, commit0))
+ .to be_truthy
end
it 'is falsey when not already mentioned' do
- expect(described_class.cross_reference_exists?(noteable, commit1)).
- to be_falsey
+ expect(described_class.cross_reference_exists?(noteable, commit1))
+ .to be_falsey
end
context 'legacy capitalized cross reference' do
@@ -522,8 +610,8 @@ describe SystemNoteService, services: true do
end
it 'is truthy when already mentioned' do
- expect(described_class.cross_reference_exists?(noteable, commit0)).
- to be_truthy
+ expect(described_class.cross_reference_exists?(noteable, commit0))
+ .to be_truthy
end
end
end
@@ -535,13 +623,13 @@ describe SystemNoteService, services: true do
end
it 'is truthy when already mentioned' do
- expect(described_class.cross_reference_exists?(commit0, commit1)).
- to be_truthy
+ expect(described_class.cross_reference_exists?(commit0, commit1))
+ .to be_truthy
end
it 'is falsey when not already mentioned' do
- expect(described_class.cross_reference_exists?(commit1, commit0)).
- to be_falsey
+ expect(described_class.cross_reference_exists?(commit1, commit0))
+ .to be_falsey
end
context 'legacy capitalized cross reference' do
@@ -552,8 +640,8 @@ describe SystemNoteService, services: true do
end
it 'is truthy when already mentioned' do
- expect(described_class.cross_reference_exists?(commit0, commit1)).
- to be_truthy
+ expect(described_class.cross_reference_exists?(commit0, commit1))
+ .to be_truthy
end
end
end
@@ -568,8 +656,8 @@ describe SystemNoteService, services: true do
end
it 'is true when a fork mentions an external issue' do
- expect(described_class.cross_reference_exists?(noteable, commit2)).
- to be true
+ expect(described_class.cross_reference_exists?(noteable, commit2))
+ .to be true
end
context 'legacy capitalized cross reference' do
@@ -579,15 +667,15 @@ describe SystemNoteService, services: true do
end
it 'is true when a fork mentions an external issue' do
- expect(described_class.cross_reference_exists?(noteable, commit2)).
- to be true
+ expect(described_class.cross_reference_exists?(noteable, commit2))
+ .to be true
end
end
end
end
describe '.noteable_moved' do
- let(:new_project) { create(:empty_project) }
+ let(:new_project) { create(:project) }
let(:new_noteable) { create(:issue, project: new_project) }
subject do
@@ -606,7 +694,7 @@ describe SystemNoteService, services: true do
end
it 'mentions referenced project' do
- expect(subject.note).to include new_project.path_with_namespace
+ expect(subject.note).to include new_project.full_path
end
end
@@ -657,7 +745,7 @@ describe SystemNoteService, services: true do
describe 'JIRA integration' do
include JiraServiceHelper
- let(:project) { create(:jira_project) }
+ let(:project) { create(:jira_project, :repository) }
let(:author) { create(:user) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) }
@@ -672,6 +760,26 @@ describe SystemNoteService, services: true do
jira_service_settings
end
+ def cross_reference(type, link_exists = false)
+ noteable = type == 'commit' ? commit : merge_request
+
+ links = []
+ if link_exists
+ url = if type == 'commit'
+ "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/commit/#{commit.id}"
+ else
+ "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/merge_requests/#{merge_request.iid}"
+ end
+ link = double(object: { 'url' => url })
+ links << link
+ expect(link).to receive(:save!)
+ end
+
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return(links)
+
+ described_class.cross_reference(jira_issue, noteable, author)
+ end
+
noteable_types = %w(merge_requests commit)
noteable_types.each do |type|
@@ -679,24 +787,39 @@ describe SystemNoteService, services: true do
it "blocks cross reference when #{type.underscore}_events is false" do
jira_tracker.update("#{type}_events" => false)
- noteable = type == "commit" ? commit : merge_request
- result = described_class.cross_reference(jira_issue, noteable, author)
-
- expect(result).to eq("Events for #{noteable.class.to_s.underscore.humanize.pluralize.downcase} are disabled.")
+ expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.")
end
it "blocks cross reference when #{type.underscore}_events is true" do
jira_tracker.update("#{type}_events" => true)
- noteable = type == "commit" ? commit : merge_request
- result = described_class.cross_reference(jira_issue, noteable, author)
+ expect(cross_reference(type)).to eq(success_message)
+ end
+ end
+
+ context 'when a new cross reference is created' do
+ it 'creates a new comment and remote link' do
+ cross_reference(type)
- expect(result).to eq(success_message)
+ expect(WebMock).to have_requested(:post, jira_api_comment_url(jira_issue))
+ expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue))
+ end
+ end
+
+ context 'when a link exists' do
+ it 'updates a link but does not create a new comment' do
+ expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue))
+
+ cross_reference(type, true)
end
end
end
describe "new reference" do
+ before do
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
+ end
+
context 'for commits' do
it "creates comment" do
result = described_class.cross_reference(jira_issue, commit, author)
@@ -711,7 +834,7 @@ describe SystemNoteService, services: true do
body: hash_including(
GlobalID: "GitLab",
object: {
- url: namespace_project_commit_url(project.namespace, project, commit),
+ url: project_commit_url(project, commit),
title: "GitLab: Mentioned on commit - #{commit.title}",
icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
status: { resolved: false }
@@ -737,7 +860,7 @@ describe SystemNoteService, services: true do
body: hash_including(
GlobalID: "GitLab",
object: {
- url: namespace_project_issue_url(project.namespace, project, issue),
+ url: project_issue_url(project, issue),
title: "GitLab: Mentioned on issue - #{issue.title}",
icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
status: { resolved: false }
@@ -763,7 +886,7 @@ describe SystemNoteService, services: true do
body: hash_including(
GlobalID: "GitLab",
object: {
- url: namespace_project_snippet_url(project.namespace, project, snippet),
+ url: project_snippet_url(project, snippet),
title: "GitLab: Mentioned on snippet - #{snippet.title}",
icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
status: { resolved: false }
@@ -776,7 +899,8 @@ describe SystemNoteService, services: true do
describe "existing reference" do
before do
- message = "[#{author.name}|http://localhost/#{author.username}] mentioned this issue in [a commit of #{project.path_with_namespace}|http://localhost/#{project.path_with_namespace}/commit/#{commit.id}]:\n'#{commit.title.chomp}'"
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
+ message = "[#{author.name}|http://localhost/#{author.username}] mentioned this issue in [a commit of #{project.full_path}|http://localhost/#{project.full_path}/commit/#{commit.id}]:\n'#{commit.title.chomp}'"
allow_any_instance_of(JIRA::Resource::Issue).to receive(:comments).and_return([OpenStruct.new(body: message)])
end
@@ -955,7 +1079,7 @@ describe SystemNoteService, services: true do
let(:action) { 'task' }
end
- it "posts the 'marked as a Work In Progress from commit' system note" do
+ it "posts the 'marked the task as complete' system note" do
expect(subject.note).to eq("marked the task **task** as completed")
end
end
@@ -973,4 +1097,85 @@ describe SystemNoteService, services: true do
expect(subject.note).to eq 'resolved all discussions'
end
end
+
+ describe '.diff_discussion_outdated' do
+ let(:discussion) { create(:diff_note_on_merge_request).to_discussion }
+ let(:merge_request) { discussion.noteable }
+ let(:project) { merge_request.source_project }
+ let(:change_position) { discussion.position }
+
+ def reloaded_merge_request
+ MergeRequest.find(merge_request.id)
+ end
+
+ subject { described_class.diff_discussion_outdated(discussion, project, author, change_position) }
+
+ it_behaves_like 'a system note' do
+ let(:expected_noteable) { discussion.first_note.noteable }
+ let(:action) { 'outdated' }
+ end
+
+ it 'creates a new note in the discussion' do
+ # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded.
+ expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1)
+ end
+
+ it 'links to the diff in the system note' do
+ expect(subject.note).to include('version 1')
+
+ diff_id = merge_request.merge_request_diff.id
+ line_code = change_position.line_code(project.repository)
+ expect(subject.note).to include(diffs_project_merge_request_url(project, merge_request, diff_id: diff_id, anchor: line_code))
+ end
+ end
+
+ describe '.mark_duplicate_issue' do
+ subject { described_class.mark_duplicate_issue(noteable, project, author, canonical_issue) }
+
+ context 'within the same project' do
+ let(:canonical_issue) { create(:issue, project: project) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'duplicate' }
+ end
+
+ it { expect(subject.note).to eq "marked this issue as a duplicate of #{canonical_issue.to_reference}" }
+ end
+
+ context 'across different projects' do
+ let(:other_project) { create(:project) }
+ let(:canonical_issue) { create(:issue, project: other_project) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'duplicate' }
+ end
+
+ it { expect(subject.note).to eq "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" }
+ end
+ end
+
+ describe '.mark_canonical_issue_of_duplicate' do
+ subject { described_class.mark_canonical_issue_of_duplicate(noteable, project, author, duplicate_issue) }
+
+ context 'within the same project' do
+ let(:duplicate_issue) { create(:issue, project: project) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'duplicate' }
+ end
+
+ it { expect(subject.note).to eq "marked #{duplicate_issue.to_reference} as a duplicate of this issue" }
+ end
+
+ context 'across different projects' do
+ let(:other_project) { create(:project) }
+ let(:duplicate_issue) { create(:issue, project: other_project) }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'duplicate' }
+ end
+
+ it { expect(subject.note).to eq "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue" }
+ end
+ end
end
diff --git a/spec/services/tags/create_service_spec.rb b/spec/services/tags/create_service_spec.rb
index b9121b1de49..1b31ce29f7a 100644
--- a/spec/services/tags/create_service_spec.rb
+++ b/spec/services/tags/create_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Tags::CreateService, services: true do
+describe Tags::CreateService do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
@@ -26,9 +26,9 @@ describe Tags::CreateService, services: true do
context 'when tag already exists' do
it 'returns an error' do
- expect(repository).to receive(:add_tag).
- with(user, 'v1.1.0', 'master', 'Foo').
- and_raise(Rugged::TagError)
+ expect(repository).to receive(:add_tag)
+ .with(user, 'v1.1.0', 'master', 'Foo')
+ .and_raise(Rugged::TagError)
response = service.execute('v1.1.0', 'master', 'Foo')
@@ -39,9 +39,9 @@ describe Tags::CreateService, services: true do
context 'when pre-receive hook fails' do
it 'returns an error' do
- expect(repository).to receive(:add_tag).
- with(user, 'v1.1.0', 'master', 'Foo').
- and_raise(GitHooksService::PreReceiveError, 'something went wrong')
+ expect(repository).to receive(:add_tag)
+ .with(user, 'v1.1.0', 'master', 'Foo')
+ .and_raise(GitHooksService::PreReceiveError, 'something went wrong')
response = service.execute('v1.1.0', 'master', 'Foo')
diff --git a/spec/services/tags/destroy_service_spec.rb b/spec/services/tags/destroy_service_spec.rb
index 28396fc3658..7c8c1dd0d3a 100644
--- a/spec/services/tags/destroy_service_spec.rb
+++ b/spec/services/tags/destroy_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Tags::DestroyService, services: true do
+describe Tags::DestroyService do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
diff --git a/spec/services/test_hook_service_spec.rb b/spec/services/test_hook_service_spec.rb
deleted file mode 100644
index f99fd8434c2..00000000000
--- a/spec/services/test_hook_service_spec.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-require 'spec_helper'
-
-describe TestHookService, services: true do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:hook) { create(:project_hook, project: project) }
-
- describe '#execute' do
- it "executes successfully" do
- stub_request(:post, hook.url).to_return(status: 200)
- expect(TestHookService.new.execute(hook, user)).to be_truthy
- end
- end
-end
diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb
new file mode 100644
index 00000000000..4218c15a3ce
--- /dev/null
+++ b/spec/services/test_hooks/project_service_spec.rb
@@ -0,0 +1,188 @@
+require 'spec_helper'
+
+describe TestHooks::ProjectService do
+ let(:current_user) { create(:user) }
+
+ describe '#execute' do
+ let(:project) { create(:project, :repository) }
+ let(:hook) { create(:project_hook, project: project) }
+ let(:service) { described_class.new(hook, current_user, trigger) }
+ let(:sample_data) { { data: 'sample' } }
+ let(:success_result) { { status: :success, http_status: 200, message: 'ok' } }
+
+ context 'hook with not implemented test' do
+ let(:trigger) { 'not_implemented_events' }
+
+ it 'returns error message' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Testing not available for this hook' })
+ end
+ end
+
+ context 'push_events' do
+ let(:trigger) { 'push_events' }
+
+ it 'returns error message if not enough data' do
+ allow(project).to receive(:empty_repo?).and_return(true)
+
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has at least one commit.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::DataBuilder::Push).to receive(:build_sample).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'tag_push_events' do
+ let(:trigger) { 'tag_push_events' }
+
+ it 'returns error message if not enough data' do
+ allow(project).to receive(:empty_repo?).and_return(true)
+
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has at least one commit.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::DataBuilder::Push).to receive(:build_sample).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'note_events' do
+ let(:trigger) { 'note_events' }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has notes.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:notes).and_return([Note.new])
+ allow(Gitlab::DataBuilder::Note).to receive(:build).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'issues_events' do
+ let(:trigger) { 'issues_events' }
+ let(:issue) { build(:issue) }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has issues.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:issues).and_return([issue])
+ allow(issue).to receive(:to_hook_data).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'confidential_issues_events' do
+ let(:trigger) { 'confidential_issues_events' }
+ let(:issue) { build(:issue) }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has issues.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:issues).and_return([issue])
+ allow(issue).to receive(:to_hook_data).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'merge_requests_events' do
+ let(:trigger) { 'merge_requests_events' }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has merge requests.' })
+ end
+
+ it 'executes hook' do
+ create(:merge_request, source_project: project)
+ allow_any_instance_of(MergeRequest).to receive(:to_hook_data).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'job_events' do
+ let(:trigger) { 'job_events' }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has CI jobs.' })
+ end
+
+ it 'executes hook' do
+ create(:ci_build, project: project)
+ allow(Gitlab::DataBuilder::Build).to receive(:build).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'pipeline_events' do
+ let(:trigger) { 'pipeline_events' }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has CI pipelines.' })
+ end
+
+ it 'executes hook' do
+ create(:ci_empty_pipeline, project: project)
+ allow(Gitlab::DataBuilder::Pipeline).to receive(:build).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'wiki_page_events' do
+ let(:trigger) { 'wiki_page_events' }
+
+ it 'returns error message if wiki disabled' do
+ allow(project).to receive(:wiki_enabled?).and_return(false)
+
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the wiki is enabled and has pages.' })
+ end
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the wiki is enabled and has pages.' })
+ end
+
+ it 'executes hook' do
+ create(:wiki_page, wiki: project.wiki)
+ allow(Gitlab::DataBuilder::WikiPage).to receive(:build).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+ end
+end
diff --git a/spec/services/test_hooks/system_service_spec.rb b/spec/services/test_hooks/system_service_spec.rb
new file mode 100644
index 00000000000..00d89924766
--- /dev/null
+++ b/spec/services/test_hooks/system_service_spec.rb
@@ -0,0 +1,82 @@
+require 'spec_helper'
+
+describe TestHooks::SystemService do
+ let(:current_user) { create(:user) }
+
+ describe '#execute' do
+ let(:project) { create(:project, :repository) }
+ let(:hook) { create(:system_hook) }
+ let(:service) { described_class.new(hook, current_user, trigger) }
+ let(:sample_data) { { data: 'sample' }}
+ let(:success_result) { { status: :success, http_status: 200, message: 'ok' } }
+
+ before do
+ allow(Project).to receive(:first).and_return(project)
+ end
+
+ context 'hook with not implemented test' do
+ let(:trigger) { 'not_implemented_events' }
+
+ it 'returns error message' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Testing not available for this hook' })
+ end
+ end
+
+ context 'push_events' do
+ let(:trigger) { 'push_events' }
+
+ it 'returns error message if not enough data' do
+ allow(project).to receive(:empty_repo?).and_return(true)
+
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: "Ensure project \"#{project.human_name}\" has commits." })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::DataBuilder::Push).to receive(:build_sample).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'tag_push_events' do
+ let(:trigger) { 'tag_push_events' }
+
+ it 'returns error message if not enough data' do
+ allow(project.repository).to receive(:tags).and_return([])
+
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: "Ensure project \"#{project.human_name}\" has tags." })
+ end
+
+ it 'executes hook' do
+ allow(project.repository).to receive(:tags).and_return(['tag'])
+ allow(Gitlab::DataBuilder::Push).to receive(:build_sample).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+
+ context 'repository_update_events' do
+ let(:trigger) { 'repository_update_events' }
+
+ it 'returns error message if not enough data' do
+ allow(project).to receive(:commit).and_return(nil)
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: "Ensure project \"#{project.human_name}\" has commits." })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:empty_repo?).and_return(false)
+ allow(Gitlab::DataBuilder::Repository).to receive(:update).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
+ end
+end
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 89b3b6aad10..a9b34a5258a 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe TodoService, services: true do
+describe TodoService do
let(:author) { create(:user) }
let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
@@ -10,7 +10,7 @@ describe TodoService, services: true do
let(:john_doe) { create(:user) }
let(:skipped) { create(:user) }
let(:skip_users) { [skipped] }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:mentions) { 'FYI: ' + [author, assignee, john_doe, member, guest, non_member, admin, skipped].map(&:to_reference).join(' ') }
let(:directly_addressed) { [author, assignee, john_doe, member, guest, non_member, admin, skipped].map(&:to_reference).join(' ') }
let(:directly_addressed_and_mentioned) { member.to_reference + ", what do you think? cc: " + [guest, admin, skipped].map(&:to_reference).join(' ') }
@@ -25,11 +25,11 @@ describe TodoService, services: true do
end
describe 'Issues' do
- let(:issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") }
- let(:addressed_issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") }
- let(:unassigned_issue) { create(:issue, project: project, assignee: nil) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: mentions) }
- let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: directly_addressed) }
+ let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") }
+ let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") }
+ let(:unassigned_issue) { create(:issue, project: project, assignees: []) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: mentions) }
+ let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: directly_addressed) }
describe '#new_issue' do
it 'creates a todo if assigned' do
@@ -43,7 +43,7 @@ describe TodoService, services: true do
end
it 'creates a todo if assignee is the current user' do
- unassigned_issue.update_attribute(:assignee, john_doe)
+ unassigned_issue.assignees = [john_doe]
service.new_issue(unassigned_issue, john_doe)
should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED)
@@ -103,7 +103,7 @@ describe TodoService, services: true do
context 'when a private group is mentioned' do
let(:group) { create(:group, :private) }
- let(:project) { create(:empty_project, :private, group: group) }
+ let(:project) { create(:project, :private, group: group) }
let(:issue) { create(:issue, author: author, project: project, description: group.to_reference) }
before do
@@ -258,20 +258,20 @@ describe TodoService, services: true do
describe '#reassigned_issue' do
it 'creates a pending todo for new assignee' do
- unassigned_issue.update_attribute(:assignee, john_doe)
+ unassigned_issue.assignees << john_doe
service.reassigned_issue(unassigned_issue, author)
should_create_todo(user: john_doe, target: unassigned_issue, action: Todo::ASSIGNED)
end
it 'does not create a todo if unassigned' do
- issue.update_attribute(:assignee, nil)
+ issue.assignees.destroy_all
should_not_create_any_todo { service.reassigned_issue(issue, author) }
end
it 'creates a todo if new assignee is the current user' do
- unassigned_issue.update_attribute(:assignee, john_doe)
+ unassigned_issue.assignees << john_doe
service.reassigned_issue(unassigned_issue, john_doe)
should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED)
@@ -336,7 +336,7 @@ describe TodoService, services: true do
describe '#mark_todos_as_done' do
it_behaves_like 'updating todos state', :mark_todos_as_done, :pending, :done do
- let(:collection) { [first_todo, second_todo] }
+ let(:collection) { Todo.all }
end
end
@@ -348,7 +348,7 @@ describe TodoService, services: true do
describe '#mark_todos_as_pending' do
it_behaves_like 'updating todos state', :mark_todos_as_pending, :done, :pending do
- let(:collection) { [first_todo, second_todo] }
+ let(:collection) { Todo.all }
end
end
@@ -361,7 +361,7 @@ describe TodoService, services: true do
describe '#new_note' do
let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
- let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) }
+ let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) }
let(:note) { create(:note, project: project, noteable: issue, author: john_doe, note: mentions) }
let(:addressed_note) { create(:note, project: project, noteable: issue, author: john_doe, note: directly_addressed) }
let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) }
@@ -586,13 +586,13 @@ describe TodoService, services: true do
it 'does not create a directly addressed todo if user was already mentioned or addressed and todo is pending' do
create(:todo, :directly_addressed, user: member, project: project, target: addressed_mr_assigned, author: author)
- expect{ service.update_merge_request(addressed_mr_assigned, author) }.not_to change(member.todos, :count)
+ expect { service.update_merge_request(addressed_mr_assigned, author) }.not_to change(member.todos, :count)
end
it 'does not create a directly addressed todo if user was already mentioned or addressed and todo is done' do
create(:todo, :directly_addressed, user: skipped, project: project, target: addressed_mr_assigned, author: author)
- expect{ service.update_merge_request(addressed_mr_assigned, author, skip_users) }.not_to change(skipped.todos, :count)
+ expect { service.update_merge_request(addressed_mr_assigned, author, skip_users) }.not_to change(skipped.todos, :count)
end
context 'with a task list' do
@@ -854,7 +854,7 @@ describe TodoService, services: true do
end
it 'updates cached counts when a todo is created' do
- issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions)
+ issue = create(:issue, project: project, assignees: [john_doe], author: author, description: mentions)
expect(john_doe.todos_pending_count).to eq(0)
expect(john_doe).to receive(:update_todos_count_cache).and_call_original
@@ -866,28 +866,30 @@ describe TodoService, services: true do
end
describe '#mark_todos_as_done' do
- let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) }
- let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) }
+ let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) }
+ let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) }
it 'marks a relation of todos as done' do
create(:todo, :mentioned, user: john_doe, target: issue, project: project)
todos = TodosFinder.new(john_doe, {}).execute
- expect { TodoService.new.mark_todos_as_done(todos, john_doe) }
+ expect { described_class.new.mark_todos_as_done(todos, john_doe) }
.to change { john_doe.todos.done.count }.from(0).to(1)
end
it 'marks an array of todos as done' do
todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project)
- expect { TodoService.new.mark_todos_as_done([todo], john_doe) }
+ todos = TodosFinder.new(john_doe, {}).execute
+ expect { described_class.new.mark_todos_as_done(todos, john_doe) }
.to change { todo.reload.state }.from('pending').to('done')
end
it 'returns the ids of updated todos' do # Needed on API
todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project)
- expect(TodoService.new.mark_todos_as_done([todo], john_doe)).to eq([todo.id])
+ todos = TodosFinder.new(john_doe, {}).execute
+ expect(described_class.new.mark_todos_as_done(todos, john_doe)).to eq([todo.id])
end
context 'when some of the todos are done already' do
@@ -895,23 +897,44 @@ describe TodoService, services: true do
let!(:second_todo) { create(:todo, :mentioned, user: john_doe, target: another_issue, project: project) }
it 'returns the ids of those still pending' do
- TodoService.new.mark_pending_todos_as_done(issue, john_doe)
+ described_class.new.mark_pending_todos_as_done(issue, john_doe)
- expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq([second_todo.id])
+ expect(described_class.new.mark_todos_as_done(Todo.all, john_doe)).to eq([second_todo.id])
end
it 'returns an empty array if all are done' do
- TodoService.new.mark_pending_todos_as_done(issue, john_doe)
- TodoService.new.mark_pending_todos_as_done(another_issue, john_doe)
+ described_class.new.mark_pending_todos_as_done(issue, john_doe)
+ described_class.new.mark_pending_todos_as_done(another_issue, john_doe)
- expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq([])
+ expect(described_class.new.mark_todos_as_done(Todo.all, john_doe)).to eq([])
end
end
+ end
+
+ describe '#mark_todos_as_done_by_ids' do
+ let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) }
+ let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) }
+
+ it 'marks an array of todo ids as done' do
+ todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project)
+ another_todo = create(:todo, :mentioned, user: john_doe, target: another_issue, project: project)
+
+ expect { described_class.new.mark_todos_as_done_by_ids([todo.id, another_todo.id], john_doe) }
+ .to change { john_doe.todos.done.count }.from(0).to(2)
+ end
+
+ it 'marks a single todo id as done' do
+ todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project)
- it 'caches the number of todos of a user', :caching do
+ expect { described_class.new.mark_todos_as_done_by_ids(todo.id, john_doe) }
+ .to change { todo.reload.state }.from('pending').to('done')
+ end
+
+ it 'caches the number of todos of a user', :use_clean_rails_memory_store_caching do
create(:todo, :mentioned, user: john_doe, target: issue, project: project)
todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project)
- TodoService.new.mark_todos_as_done([todo], john_doe)
+
+ described_class.new.mark_todos_as_done_by_ids(todo, john_doe)
expect_any_instance_of(TodosFinder).not_to receive(:execute)
diff --git a/spec/services/update_release_service_spec.rb b/spec/services/update_release_service_spec.rb
index 69ed8de9c31..dc2d0e2d47a 100644
--- a/spec/services/update_release_service_spec.rb
+++ b/spec/services/update_release_service_spec.rb
@@ -1,12 +1,12 @@
require 'spec_helper'
-describe UpdateReleaseService, services: true do
+describe UpdateReleaseService do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:tag_name) { project.repository.tag_names.first }
let(:description) { 'Awesome release!' }
let(:new_description) { 'The best release!' }
- let(:service) { UpdateReleaseService.new(project, user) }
+ let(:service) { described_class.new(project, user) }
context 'with an existing release' do
let(:create_service) { CreateReleaseService.new(project, user) }
diff --git a/spec/services/update_snippet_service_spec.rb b/spec/services/update_snippet_service_spec.rb
index 37c2e861362..ef535c5cf1f 100644
--- a/spec/services/update_snippet_service_spec.rb
+++ b/spec/services/update_snippet_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe UpdateSnippetService, services: true do
+describe UpdateSnippetService do
before do
@user = create :user
@admin = create :user, admin: true
diff --git a/spec/services/upload_service_spec.rb b/spec/services/upload_service_spec.rb
new file mode 100644
index 00000000000..24f3a5c5ff0
--- /dev/null
+++ b/spec/services/upload_service_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+describe UploadService do
+ describe 'File service' do
+ before do
+ @user = create(:user)
+ @project = create(:project, creator_id: @user.id, namespace: @user.namespace)
+ end
+
+ context 'for valid gif file' do
+ before do
+ gif = fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif')
+ @link_to_file = upload_file(@project, gif)
+ end
+
+ it { expect(@link_to_file).to have_key(:alt) }
+ it { expect(@link_to_file).to have_key(:url) }
+ it { expect(@link_to_file).to have_value('banana_sample') }
+ it { expect(@link_to_file[:url]).to match('banana_sample.gif') }
+ end
+
+ context 'for valid png file' do
+ before do
+ png = fixture_file_upload(Rails.root + 'spec/fixtures/dk.png',
+ 'image/png')
+ @link_to_file = upload_file(@project, png)
+ end
+
+ it { expect(@link_to_file).to have_key(:alt) }
+ it { expect(@link_to_file).to have_key(:url) }
+ it { expect(@link_to_file).to have_value('dk') }
+ it { expect(@link_to_file[:url]).to match('dk.png') }
+ end
+
+ context 'for valid jpg file' do
+ before do
+ jpg = fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg')
+ @link_to_file = upload_file(@project, jpg)
+ end
+
+ it { expect(@link_to_file).to have_key(:alt) }
+ it { expect(@link_to_file).to have_key(:url) }
+ it { expect(@link_to_file).to have_value('rails_sample') }
+ it { expect(@link_to_file[:url]).to match('rails_sample.jpg') }
+ end
+
+ context 'for txt file' do
+ before do
+ txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain')
+ @link_to_file = upload_file(@project, txt)
+ end
+
+ it { expect(@link_to_file).to have_key(:alt) }
+ it { expect(@link_to_file).to have_key(:url) }
+ it { expect(@link_to_file).to have_value('doc_sample.txt') }
+ it { expect(@link_to_file[:url]).to match('doc_sample.txt') }
+ end
+
+ context 'for too large a file' do
+ before do
+ txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain')
+ allow(txt).to receive(:size) { 1000.megabytes.to_i }
+ @link_to_file = upload_file(@project, txt)
+ end
+
+ it { expect(@link_to_file).to eq(nil) }
+ end
+ end
+
+ def upload_file(project, file)
+ described_class.new(project, file, FileUploader).execute
+ end
+end
diff --git a/spec/services/user_project_access_changed_service_spec.rb b/spec/services/user_project_access_changed_service_spec.rb
index b4efe7de431..14a5e40350a 100644
--- a/spec/services/user_project_access_changed_service_spec.rb
+++ b/spec/services/user_project_access_changed_service_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
describe UserProjectAccessChangedService do
describe '#execute' do
it 'schedules the user IDs' do
- expect(AuthorizedProjectsWorker).to receive(:bulk_perform_and_wait).
- with([[1], [2]])
+ expect(AuthorizedProjectsWorker).to receive(:bulk_perform_and_wait)
+ .with([[1], [2]])
described_class.new([1, 2]).execute
end
diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb
index 8d67ebe3231..fef4da0c76e 100644
--- a/spec/services/users/activity_service_spec.rb
+++ b/spec/services/users/activity_service_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
-describe Users::ActivityService, services: true do
+describe Users::ActivityService do
include UserActivitiesHelpers
let(:user) { create(:user) }
subject(:service) { described_class.new(user, 'type') }
- describe '#execute', :redis do
+ describe '#execute', :clean_gitlab_redis_shared_state do
context 'when last activity is nil' do
before do
service.execute
@@ -41,8 +41,8 @@ describe Users::ActivityService, services: true do
end
def last_hour_user_ids
- Gitlab::UserActivities.new.
- select { |k, v| v >= 1.hour.ago.to_i.to_s }.
- map { |k, _| k.to_i }
+ Gitlab::UserActivities.new
+ .select { |k, v| v >= 1.hour.ago.to_i.to_s }
+ .map { |k, _| k.to_i }
end
end
diff --git a/spec/services/users/build_service_spec.rb b/spec/services/users/build_service_spec.rb
index 2a6bfc1b3a0..677d4a622e1 100644
--- a/spec/services/users/build_service_spec.rb
+++ b/spec/services/users/build_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Users::BuildService, services: true do
+describe Users::BuildService do
describe '#execute' do
let(:params) do
{ name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass' }
diff --git a/spec/services/users/create_service_spec.rb b/spec/services/users/create_service_spec.rb
index 75746278573..24dac569678 100644
--- a/spec/services/users/create_service_spec.rb
+++ b/spec/services/users/create_service_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Users::CreateService, services: true do
+describe Users::CreateService do
describe '#execute' do
let(:admin_user) { create(:admin) }
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 4bc30018ebd..a82567f6f43 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe Users::DestroyService, services: true do
+describe Users::DestroyService do
describe "Deletes a user and all their personal projects" do
let!(:user) { create(:user) }
let!(:admin) { create(:admin) }
let!(:namespace) { create(:namespace, owner: user) }
- let!(:project) { create(:empty_project, namespace: namespace) }
+ let!(:project) { create(:project, namespace: namespace) }
let(:service) { described_class.new(admin) }
context 'no options are given' do
@@ -47,7 +47,7 @@ describe Users::DestroyService, services: true do
end
context "for an issue the user was assigned to" do
- let!(:issue) { create(:issue, project: project, assignee: user) }
+ let!(:issue) { create(:issue, project: project, assignees: [user]) }
before do
service.execute(user)
@@ -60,13 +60,13 @@ describe Users::DestroyService, services: true do
it 'migrates the issue so that it is "Unassigned"' do
migrated_issue = Issue.find_by_id(issue.id)
- expect(migrated_issue.assignee).to be_nil
+ expect(migrated_issue.assignees).to be_empty
end
end
end
context "a deleted user's merge_requests" do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
before do
project.add_developer(user)
@@ -147,16 +147,22 @@ describe Users::DestroyService, services: true do
end
context "migrating associated records" do
+ let!(:issue) { create(:issue, author: user) }
+
it 'delegates to the `MigrateToGhostUser` service to move associated records to the ghost user' do
- expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once
+ expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once.and_call_original
service.execute(user)
+
+ expect(issue.reload.author).to be_ghost
end
it 'does not run `MigrateToGhostUser` if hard_delete option is given' do
expect_any_instance_of(Users::MigrateToGhostUserService).not_to receive(:execute)
service.execute(user, hard_delete: true)
+
+ expect(Issue.exists?(issue.id)).to be_falsy
end
end
end
diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb
index 9e1edf1ac30..ac3a8738cac 100644
--- a/spec/services/users/migrate_to_ghost_user_service_spec.rb
+++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb
@@ -1,22 +1,38 @@
require 'spec_helper'
-describe Users::MigrateToGhostUserService, services: true do
+describe Users::MigrateToGhostUserService do
let!(:user) { create(:user) }
- let!(:project) { create(:project) }
+ let!(:project) { create(:project, :repository) }
let(:service) { described_class.new(user) }
context "migrating a user's associated records to the ghost user" do
context 'issues' do
- include_examples "migrating a deleted user's associated records to the ghost user", Issue do
- let(:created_record) { create(:issue, project: project, author: user) }
- let(:assigned_record) { create(:issue, project: project, assignee: user) }
+ context 'deleted user is present as both author and edited_user' do
+ include_examples "migrating a deleted user's associated records to the ghost user", Issue, [:author, :last_edited_by] do
+ let(:created_record) do
+ create(:issue, project: project, author: user, last_edited_by: user)
+ end
+ end
+ end
+
+ context 'deleted user is present only as edited_user' do
+ include_examples "migrating a deleted user's associated records to the ghost user", Issue, [:last_edited_by] do
+ let(:created_record) { create(:issue, project: project, author: create(:user), last_edited_by: user) }
+ end
end
end
context 'merge requests' do
- include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest do
- let(:created_record) { create(:merge_request, source_project: project, author: user, target_branch: "first") }
- let(:assigned_record) { create(:merge_request, source_project: project, assignee: user, target_branch: 'second') }
+ context 'deleted user is present as both author and merge_user' do
+ include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest, [:author, :merge_user] do
+ let(:created_record) { create(:merge_request, source_project: project, author: user, merge_user: user, target_branch: "first") }
+ end
+ end
+
+ context 'deleted user is present only as both merge_user' do
+ include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest, [:merge_user] do
+ let(:created_record) { create(:merge_request, source_project: project, merge_user: user, target_branch: "first") }
+ end
end
end
@@ -33,9 +49,8 @@ describe Users::MigrateToGhostUserService, services: true do
end
context 'award emoji' do
- include_examples "migrating a deleted user's associated records to the ghost user", AwardEmoji do
+ include_examples "migrating a deleted user's associated records to the ghost user", AwardEmoji, [:user] do
let(:created_record) { create(:award_emoji, user: user) }
- let(:author_alias) { :user }
context "when the awardable already has an award emoji of the same name assigned to the ghost user" do
let(:awardable) { create(:issue) }
diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb
index b19374ef1a2..08fd26d67fd 100644
--- a/spec/services/users/refresh_authorized_projects_service_spec.rb
+++ b/spec/services/users/refresh_authorized_projects_service_spec.rb
@@ -1,22 +1,20 @@
require 'spec_helper'
describe Users::RefreshAuthorizedProjectsService do
- let(:project) { create(:empty_project) }
+ # We're using let! here so that any expectations for the service class are not
+ # triggered twice.
+ let!(:project) { create(:project) }
+
let(:user) { project.namespace.owner }
let(:service) { described_class.new(user) }
- def create_authorization(project, user, access_level = Gitlab::Access::MASTER)
- ProjectAuthorization.
- create!(project: project, user: user, access_level: access_level)
- end
-
- describe '#execute', :redis do
+ describe '#execute', :clean_gitlab_redis_shared_state do
it 'refreshes the authorizations using a lease' do
- expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
- and_return('foo')
+ expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain)
+ .and_return('foo')
- expect(Gitlab::ExclusiveLease).to receive(:cancel).
- with(an_instance_of(String), 'foo')
+ expect(Gitlab::ExclusiveLease).to receive(:cancel)
+ .with(an_instance_of(String), 'foo')
expect(service).to receive(:execute_without_lease)
@@ -30,20 +28,24 @@ describe Users::RefreshAuthorizedProjectsService do
end
it 'updates the authorized projects of the user' do
- project2 = create(:empty_project)
- to_remove = create_authorization(project2, user)
+ project2 = create(:project)
+ to_remove = user.project_authorizations
+ .create!(project: project2, access_level: Gitlab::Access::MASTER)
- expect(service).to receive(:update_authorizations).
- with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]])
+ expect(service).to receive(:update_authorizations)
+ .with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]])
service.execute_without_lease
end
it 'sets the access level of a project to the highest available level' do
- to_remove = create_authorization(project, user, Gitlab::Access::DEVELOPER)
+ user.project_authorizations.delete_all
+
+ to_remove = user.project_authorizations
+ .create!(project: project, access_level: Gitlab::Access::DEVELOPER)
- expect(service).to receive(:update_authorizations).
- with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]])
+ expect(service).to receive(:update_authorizations)
+ .with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]])
service.execute_without_lease
end
@@ -61,34 +63,10 @@ describe Users::RefreshAuthorizedProjectsService do
service.update_authorizations([], [])
end
-
- context 'when the authorized projects column is not set' do
- before do
- user.update!(authorized_projects_populated: nil)
- end
-
- it 'populates the authorized projects column' do
- service.update_authorizations([], [])
-
- expect(user.authorized_projects_populated).to eq true
- end
- end
-
- context 'when the authorized projects column is set' do
- before do
- user.update!(authorized_projects_populated: true)
- end
-
- it 'does nothing' do
- expect(user).not_to receive(:set_authorized_projects_column)
-
- service.update_authorizations([], [])
- end
- end
end
it 'removes authorizations that should be removed' do
- authorization = create_authorization(project, user)
+ authorization = user.project_authorizations.find_by(project_id: project.id)
service.update_authorizations([authorization.project_id])
@@ -96,6 +74,8 @@ describe Users::RefreshAuthorizedProjectsService do
end
it 'inserts authorizations that should be added' do
+ user.project_authorizations.delete_all
+
service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MASTER]])
authorizations = user.project_authorizations
@@ -105,16 +85,6 @@ describe Users::RefreshAuthorizedProjectsService do
expect(authorizations[0].project_id).to eq(project.id)
expect(authorizations[0].access_level).to eq(Gitlab::Access::MASTER)
end
-
- it 'populates the authorized projects column' do
- # make sure we start with a nil value no matter what the default in the
- # factory may be.
- user.update!(authorized_projects_populated: nil)
-
- service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MASTER]])
-
- expect(user.authorized_projects_populated).to eq(true)
- end
end
describe '#fresh_access_levels_per_project' do
@@ -139,7 +109,7 @@ describe Users::RefreshAuthorizedProjectsService do
end
context 'projects the user is a member of' do
- let!(:other_project) { create(:empty_project) }
+ let!(:other_project) { create(:project) }
before do
other_project.team.add_reporter(user)
@@ -152,7 +122,7 @@ describe Users::RefreshAuthorizedProjectsService do
context 'projects of groups the user is a member of' do
let(:group) { create(:group) }
- let!(:other_project) { create(:empty_project, group: group) }
+ let!(:other_project) { create(:project, group: group) }
before do
group.add_owner(user)
@@ -163,10 +133,10 @@ describe Users::RefreshAuthorizedProjectsService do
end
end
- context 'projects of subgroups of groups the user is a member of' do
+ context 'projects of subgroups of groups the user is a member of', :nested_groups do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
- let!(:other_project) { create(:empty_project, group: nested_group) }
+ let!(:other_project) { create(:project, group: nested_group) }
before do
group.add_master(user)
@@ -179,7 +149,7 @@ describe Users::RefreshAuthorizedProjectsService do
context 'projects shared with groups the user is a member of' do
let(:group) { create(:group) }
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let!(:project_group_link) { create(:project_group_link, project: other_project, group: group, group_access: Gitlab::Access::GUEST) }
before do
@@ -191,10 +161,10 @@ describe Users::RefreshAuthorizedProjectsService do
end
end
- context 'projects shared with subgroups of groups the user is a member of' do
+ context 'projects shared with subgroups of groups the user is a member of', :nested_groups do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
- let(:other_project) { create(:empty_project) }
+ let(:other_project) { create(:project) }
let!(:project_group_link) { create(:project_group_link, project: other_project, group: nested_group, group_access: Gitlab::Access::DEVELOPER) }
before do
@@ -208,8 +178,6 @@ describe Users::RefreshAuthorizedProjectsService do
end
describe '#current_authorizations_per_project' do
- before { create_authorization(project, user) }
-
let(:hash) { service.current_authorizations_per_project }
it 'returns a Hash' do
@@ -233,13 +201,13 @@ describe Users::RefreshAuthorizedProjectsService do
describe '#current_authorizations' do
context 'without authorizations' do
it 'returns an empty list' do
+ user.project_authorizations.delete_all
+
expect(service.current_authorizations.empty?).to eq(true)
end
end
context 'with an authorization' do
- before { create_authorization(project, user) }
-
let(:row) { service.current_authorizations.take }
it 'returns the currently authorized projects' do
diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb
new file mode 100644
index 00000000000..343804e3de0
--- /dev/null
+++ b/spec/services/users/update_service_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Users::UpdateService do
+ let(:user) { create(:user) }
+
+ describe '#execute' do
+ it 'updates the name' do
+ result = update_user(user, name: 'New Name')
+
+ expect(result).to eq(status: :success)
+ expect(user.name).to eq('New Name')
+ end
+
+ it 'returns an error result when record cannot be updated' do
+ expect do
+ update_user(user, { email: 'invalid' })
+ end.not_to change { user.reload.email }
+ end
+
+ def update_user(user, opts)
+ described_class.new(user, opts).execute
+ end
+ end
+
+ describe '#execute!' do
+ it 'updates the name' do
+ result = update_user(user, name: 'New Name')
+
+ expect(result).to be true
+ expect(user.name).to eq('New Name')
+ end
+
+ it 'raises an error when record cannot be updated' do
+ expect do
+ update_user(user, email: 'invalid')
+ end.to raise_error(ActiveRecord::RecordInvalid)
+ end
+
+ def update_user(user, opts)
+ described_class.new(user, opts).execute!
+ end
+ end
+end
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
new file mode 100644
index 00000000000..0726e135b20
--- /dev/null
+++ b/spec/services/web_hook_service_spec.rb
@@ -0,0 +1,154 @@
+require 'spec_helper'
+
+describe WebHookService do
+ let(:project) { create(:project) }
+ let(:project_hook) { create(:project_hook) }
+ let(:headers) do
+ {
+ 'Content-Type' => 'application/json',
+ 'X-Gitlab-Event' => 'Push Hook'
+ }
+ end
+ let(:data) do
+ { before: 'oldrev', after: 'newrev', ref: 'ref' }
+ end
+ let(:service_instance) { described_class.new(project_hook, data, 'push_hooks') }
+
+ describe '#execute' do
+ before do
+ project.hooks << [project_hook]
+
+ WebMock.stub_request(:post, project_hook.url)
+ end
+
+ context 'when token is defined' do
+ let(:project_hook) { create(:project_hook, :token) }
+
+ it 'POSTs to the webhook URL' do
+ service_instance.execute
+ expect(WebMock).to have_requested(:post, project_hook.url).with(
+ headers: headers.merge({ 'X-Gitlab-Token' => project_hook.token })
+ ).once
+ end
+ end
+
+ it 'POSTs to the webhook URL' do
+ service_instance.execute
+ expect(WebMock).to have_requested(:post, project_hook.url).with(
+ headers: headers
+ ).once
+ end
+
+ it 'POSTs the data as JSON' do
+ service_instance.execute
+ expect(WebMock).to have_requested(:post, project_hook.url).with(
+ headers: headers
+ ).once
+ end
+
+ it 'catches exceptions' do
+ WebMock.stub_request(:post, project_hook.url).to_raise(StandardError.new('Some error'))
+
+ expect { service_instance.execute }.to raise_error(StandardError)
+ end
+
+ it 'handles exceptions' do
+ exceptions = [SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout]
+ exceptions.each do |exception_class|
+ exception = exception_class.new('Exception message')
+
+ WebMock.stub_request(:post, project_hook.url).to_raise(exception)
+ expect(service_instance.execute).to eq({ status: :error, message: exception.message })
+ expect { service_instance.execute }.not_to raise_error
+ end
+ end
+
+ it 'handles 200 status code' do
+ WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success')
+
+ expect(service_instance.execute).to include({ status: :success, http_status: 200, message: 'Success' })
+ end
+
+ it 'handles 2xx status codes' do
+ WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: 'Success')
+
+ expect(service_instance.execute).to include({ status: :success, http_status: 201, message: 'Success' })
+ end
+
+ context 'execution logging' do
+ let(:hook_log) { project_hook.web_hook_logs.last }
+
+ context 'with success' do
+ before do
+ WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success')
+ service_instance.execute
+ end
+
+ it 'log successful execution' do
+ expect(hook_log.trigger).to eq('push_hooks')
+ expect(hook_log.url).to eq(project_hook.url)
+ expect(hook_log.request_headers).to eq(headers)
+ expect(hook_log.response_body).to eq('Success')
+ expect(hook_log.response_status).to eq('200')
+ expect(hook_log.execution_duration).to be > 0
+ expect(hook_log.internal_error_message).to be_nil
+ end
+ end
+
+ context 'with exception' do
+ before do
+ WebMock.stub_request(:post, project_hook.url).to_raise(SocketError.new('Some HTTP Post error'))
+ service_instance.execute
+ end
+
+ it 'log failed execution' do
+ expect(hook_log.trigger).to eq('push_hooks')
+ expect(hook_log.url).to eq(project_hook.url)
+ expect(hook_log.request_headers).to eq(headers)
+ expect(hook_log.response_body).to eq('')
+ expect(hook_log.response_status).to eq('internal error')
+ expect(hook_log.execution_duration).to be > 0
+ expect(hook_log.internal_error_message).to eq('Some HTTP Post error')
+ end
+ end
+
+ context 'with unsafe response body' do
+ before do
+ WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: "\xBB")
+ service_instance.execute
+ end
+
+ it 'log successful execution' do
+ expect(hook_log.trigger).to eq('push_hooks')
+ expect(hook_log.url).to eq(project_hook.url)
+ expect(hook_log.request_headers).to eq(headers)
+ expect(hook_log.response_body).to eq('')
+ expect(hook_log.response_status).to eq('200')
+ expect(hook_log.execution_duration).to be > 0
+ expect(hook_log.internal_error_message).to be_nil
+ end
+ end
+
+ context 'should not log ServiceHooks' do
+ let(:service_hook) { create(:service_hook) }
+ let(:service_instance) { described_class.new(service_hook, data, 'service_hook') }
+
+ before do
+ WebMock.stub_request(:post, service_hook.url).to_return(status: 200, body: 'Success')
+ end
+
+ it { expect { service_instance.execute }.not_to change(WebHookLog, :count) }
+ end
+ end
+ end
+
+ describe '#async_execute' do
+ let(:system_hook) { create(:system_hook) }
+
+ it 'enqueue WebHookWorker' do
+ expect(Sidekiq::Client).to receive(:enqueue).with(WebHookWorker, project_hook.id, data, 'push_hooks')
+
+ described_class.new(project_hook, data, 'push_hooks').async_execute
+ end
+ end
+end
diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb
index 5341ba3d261..b270194d9b8 100644
--- a/spec/services/wiki_pages/create_service_spec.rb
+++ b/spec/services/wiki_pages/create_service_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
-describe WikiPages::CreateService, services: true do
- let(:project) { create(:empty_project) }
+describe WikiPages::CreateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
+
let(:opts) do
{
title: 'Title',
@@ -10,27 +11,28 @@ describe WikiPages::CreateService, services: true do
format: 'markdown'
}
end
- let(:service) { described_class.new(project, user, opts) }
+
+ subject(:service) { described_class.new(project, user, opts) }
+
+ before do
+ project.add_developer(user)
+ end
describe '#execute' do
- context "valid params" do
- before do
- allow(service).to receive(:execute_hooks)
- project.add_master(user)
- end
-
- subject { service.execute }
-
- it 'creates a valid wiki page' do
- is_expected.to be_valid
- expect(subject.title).to eq(opts[:title])
- expect(subject.content).to eq(opts[:content])
- expect(subject.format).to eq(opts[:format].to_sym)
- end
-
- it 'executes webhooks' do
- expect(service).to have_received(:execute_hooks).once.with(subject, 'create')
- end
+ it 'creates wiki page with valid attributes' do
+ page = service.execute
+
+ expect(page).to be_valid
+ expect(page.title).to eq(opts[:title])
+ expect(page.content).to eq(opts[:content])
+ expect(page.format).to eq(opts[:format].to_sym)
+ end
+
+ it 'executes webhooks' do
+ expect(service).to receive(:execute_hooks).once
+ .with(instance_of(WikiPage), 'create')
+
+ service.execute
end
end
end
diff --git a/spec/services/wiki_pages/destroy_service_spec.rb b/spec/services/wiki_pages/destroy_service_spec.rb
index a4b9a390fe2..2938126914b 100644
--- a/spec/services/wiki_pages/destroy_service_spec.rb
+++ b/spec/services/wiki_pages/destroy_service_spec.rb
@@ -1,21 +1,22 @@
require 'spec_helper'
-describe WikiPages::DestroyService, services: true do
- let(:project) { create(:empty_project) }
+describe WikiPages::DestroyService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:wiki_page) { create(:wiki_page) }
- let(:service) { described_class.new(project, user) }
+ let(:page) { create(:wiki_page) }
- describe '#execute' do
- before do
- allow(service).to receive(:execute_hooks)
- project.add_master(user)
- end
+ subject(:service) { described_class.new(project, user) }
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
it 'executes webhooks' do
- service.execute(wiki_page)
+ expect(service).to receive(:execute_hooks).once
+ .with(instance_of(WikiPage), 'delete')
- expect(service).to have_received(:execute_hooks).once.with(wiki_page, 'delete')
+ service.execute(page)
end
end
end
diff --git a/spec/services/wiki_pages/update_service_spec.rb b/spec/services/wiki_pages/update_service_spec.rb
index 2bccca764d7..2399db7d3d4 100644
--- a/spec/services/wiki_pages/update_service_spec.rb
+++ b/spec/services/wiki_pages/update_service_spec.rb
@@ -1,37 +1,41 @@
require 'spec_helper'
-describe WikiPages::UpdateService, services: true do
- let(:project) { create(:empty_project) }
+describe WikiPages::UpdateService do
+ let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:wiki_page) { create(:wiki_page) }
+ let(:page) { create(:wiki_page) }
+
let(:opts) do
{
content: 'New content for wiki page',
format: 'markdown',
- message: 'New wiki message'
+ message: 'New wiki message',
+ title: 'New Title'
}
end
- let(:service) { described_class.new(project, user, opts) }
+
+ subject(:service) { described_class.new(project, user, opts) }
+
+ before do
+ project.add_developer(user)
+ end
describe '#execute' do
- context "valid params" do
- before do
- allow(service).to receive(:execute_hooks)
- project.add_master(user)
- end
-
- subject { service.execute(wiki_page) }
-
- it 'updates the wiki page' do
- is_expected.to be_valid
- expect(subject.content).to eq(opts[:content])
- expect(subject.format).to eq(opts[:format].to_sym)
- expect(subject.message).to eq(opts[:message])
- end
-
- it 'executes webhooks' do
- expect(service).to have_received(:execute_hooks).once.with(subject, 'update')
- end
+ it 'updates the wiki page' do
+ updated_page = service.execute(page)
+
+ expect(updated_page).to be_valid
+ expect(updated_page.message).to eq(opts[:message])
+ expect(updated_page.content).to eq(opts[:content])
+ expect(updated_page.format).to eq(opts[:format].to_sym)
+ expect(updated_page.title).to eq(opts[:title])
+ end
+
+ it 'executes webhooks' do
+ expect(service).to receive(:execute_hooks).once
+ .with(instance_of(WikiPage), 'update')
+
+ service.execute(page)
end
end
end
diff --git a/spec/sidekiq/cron/job_gem_dependency_spec.rb b/spec/sidekiq/cron/job_gem_dependency_spec.rb
new file mode 100644
index 00000000000..2e30cf025b0
--- /dev/null
+++ b/spec/sidekiq/cron/job_gem_dependency_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe Sidekiq::Cron::Job do
+ describe 'cron jobs' do
+ context 'when rufus-scheduler depends on ZoTime or EoTime' do
+ before do
+ described_class
+ .create(name: 'TestCronWorker',
+ cron: Settings.cron_jobs[:pipeline_schedule_worker]['cron'],
+ class: Settings.cron_jobs[:pipeline_schedule_worker]['job_class'])
+ end
+
+ it 'does not get "Rufus::Scheduler::ZoTime/EtOrbi::EoTime into an exact number"' do
+ expect { described_class.all.first.should_enque?(Time.now) }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb
index ac2c89b3ff9..25ddf932d42 100644
--- a/spec/simplecov_env.rb
+++ b/spec/simplecov_env.rb
@@ -36,18 +36,25 @@ module SimpleCovEnv
track_files '{app,lib}/**/*.rb'
add_filter '/vendor/ruby/'
+ add_filter 'app/controllers/sherlock/'
add_filter 'config/initializers/'
+ add_filter 'db/fixtures/'
+ add_filter 'lib/gitlab/sidekiq_middleware/'
+ add_filter 'lib/system_check/'
add_group 'Controllers', 'app/controllers'
- add_group 'Models', 'app/models'
- add_group 'Mailers', 'app/mailers'
- add_group 'Helpers', 'app/helpers'
- add_group 'Workers', %w(app/jobs app/workers)
- add_group 'Libraries', 'lib'
- add_group 'Services', 'app/services'
- add_group 'Finders', 'app/finders'
- add_group 'Uploaders', 'app/uploaders'
- add_group 'Validators', 'app/validators'
+ add_group 'Finders', 'app/finders'
+ add_group 'Helpers', 'app/helpers'
+ add_group 'Libraries', 'lib'
+ add_group 'Mailers', 'app/mailers'
+ add_group 'Models', 'app/models'
+ add_group 'Policies', 'app/policies'
+ add_group 'Presenters', 'app/presenters'
+ add_group 'Serializers', 'app/serializers'
+ add_group 'Services', 'app/services'
+ add_group 'Uploaders', 'app/uploaders'
+ add_group 'Validators', 'app/validators'
+ add_group 'Workers', %w(app/jobs app/workers)
merge_timeout 365.days
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index e67ad8f3455..c10197ff651 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -8,13 +8,15 @@ require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
require 'shoulda/matchers'
require 'rspec/retry'
+require 'rspec-parameterized'
rspec_profiling_is_configured =
- ENV['RSPEC_PROFILING_POSTGRES_URL'] ||
+ ENV['RSPEC_PROFILING_POSTGRES_URL'].present? ||
ENV['RSPEC_PROFILING']
branch_can_be_profiled =
- ENV['CI_COMMIT_REF_NAME'] == 'master' ||
- ENV['CI_COMMIT_REF_NAME'] =~ /rspec-profile/
+ ENV['GITLAB_DATABASE'] == 'postgresql' &&
+ (ENV['CI_COMMIT_REF_NAME'] == 'master' ||
+ ENV['CI_COMMIT_REF_NAME'] =~ /rspec-profile/)
if rspec_profiling_is_configured && (!ENV.key?('CI') || branch_can_be_profiled)
require 'rspec_profiling/rspec'
@@ -25,6 +27,9 @@ if ENV['CI'] && !ENV['NO_KNAPSACK']
Knapsack::Adapters::RSpecAdapter.bind
end
+# require rainbow gem String monkeypatch, so we can test SystemChecks
+require 'rainbow/ext/string'
+
# Requires supporting ruby files with custom matchers and macros, etc,
# in spec/support/ and its subdirectories.
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
@@ -39,19 +44,23 @@ RSpec.configure do |config|
config.include Devise::Test::ControllerHelpers, type: :controller
config.include Devise::Test::ControllerHelpers, type: :view
+ config.include Devise::Test::IntegrationHelpers, type: :feature
config.include Warden::Test::Helpers, type: :request
config.include LoginHelpers, type: :feature
config.include SearchHelpers, type: :feature
config.include WaitForRequests, :js
- config.include WaitForAjax, :js
config.include StubConfiguration
- config.include EmailHelpers, type: :mailer
+ config.include EmailHelpers, :mailer, type: :mailer
config.include TestEnv
config.include ActiveJob::TestHelper
config.include ActiveSupport::Testing::TimeHelpers
config.include StubGitlabCalls
config.include StubGitlabData
config.include ApiHelpers, :api
+ config.include Gitlab::Routing, type: :routing
+ config.include MigrationsHelpers, :migration
+ config.include StubFeatureFlags
+ config.include StubENV
config.infer_spec_type_from_file_location!
@@ -61,7 +70,14 @@ RSpec.configure do |config|
config.raise_errors_for_deprecations!
+ if ENV['CI']
+ # This includes the first try, i.e. tests will be run 4 times before failing.
+ config.default_retry_count = 4
+ config.reporter.register_listener(RspecFlaky::Listener.new, :example_passed, :dump_summary)
+ end
+
config.before(:suite) do
+ Timecop.safe_mode = true
TestEnv.init
end
@@ -69,29 +85,68 @@ RSpec.configure do |config|
TestEnv.cleanup
end
- if ENV['CI']
- # Retry only on feature specs that use JS
- config.around :each, :js do |ex|
- ex.run_with_retry retry: 3
- end
+ config.before(:example) do
+ # Skip pre-receive hook check so we can use the web editor and merge.
+ allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil])
+ # Enable all features by default for testing
+ allow(Feature).to receive(:enabled?) { true }
+ end
+
+ config.before(:example, :request_store) do
+ RequestStore.begin!
end
- config.around(:each, :caching) do |example|
+ config.after(:example, :request_store) do
+ RequestStore.end!
+ RequestStore.clear!
+ end
+
+ config.before(:example, :mailer) do
+ reset_delivered_emails!
+ end
+
+ config.around(:each, :use_clean_rails_memory_store_caching) do |example|
caching_store = Rails.cache
- Rails.cache = ActiveSupport::Cache::MemoryStore.new if example.metadata[:caching]
+ Rails.cache = ActiveSupport::Cache::MemoryStore.new
+
example.run
+
Rails.cache = caching_store
end
- config.around(:each, :redis) do |example|
- Gitlab::Redis.with(&:flushall)
+ config.around(:each, :clean_gitlab_redis_cache) do |example|
+ Gitlab::Redis::Cache.with(&:flushall)
+
+ example.run
+
+ Gitlab::Redis::Cache.with(&:flushall)
+ end
+
+ config.around(:each, :clean_gitlab_redis_shared_state) do |example|
+ Gitlab::Redis::SharedState.with(&:flushall)
Sidekiq.redis(&:flushall)
example.run
- Gitlab::Redis.with(&:flushall)
+ Gitlab::Redis::SharedState.with(&:flushall)
Sidekiq.redis(&:flushall)
end
+
+ config.before(:each, :migration) do
+ schema_migrate_down!
+ end
+
+ config.after(:context, :migration) do
+ schema_migrate_up!
+ end
+
+ config.around(:each, :nested_groups) do |example|
+ example.run if Group.supports_nested_groups?
+ end
+
+ config.around(:each, :postgresql) do |example|
+ example.run if Gitlab::Database.postgresql?
+ end
end
FactoryGirl::SyntaxRunner.class_eval do
@@ -99,3 +154,10 @@ FactoryGirl::SyntaxRunner.class_eval do
end
ActiveRecord::Migration.maintain_test_schema!
+
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ with.test_framework :rspec
+ with.library :rails
+ end
+end
diff --git a/spec/support/api/milestones_shared_examples.rb b/spec/support/api/milestones_shared_examples.rb
new file mode 100644
index 00000000000..4bb5113957e
--- /dev/null
+++ b/spec/support/api/milestones_shared_examples.rb
@@ -0,0 +1,383 @@
+shared_examples_for 'group and project milestones' do |route_definition|
+ let(:resource_route) { "#{route}/#{milestone.id}" }
+ let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) }
+ let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) }
+ let(:label_3) { create(:label, title: 'label_3', project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:another_merge_request) { create(:merge_request, :simple, source_project: project) }
+
+ describe "GET #{route_definition}" do
+ it 'returns milestones list' do
+ get api(route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['title']).to eq(milestone.title)
+ end
+
+ it 'returns a 401 error if user not authenticated' do
+ get api(route)
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns an array of active milestones' do
+ get api("#{route}/?state=active", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(milestone.id)
+ end
+
+ it 'returns an array of closed milestones' do
+ get api("#{route}/?state=closed", user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(closed_milestone.id)
+ end
+
+ it 'returns an array of milestones specified by iids' do
+ other_milestone = create(:milestone, project: try(:project), group: try(:group))
+
+ get api(route, user), iids: [closed_milestone.iid, other_milestone.iid]
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ expect(json_response.map { |m| m['id'] }).to match_array([closed_milestone.id, other_milestone.id])
+ end
+
+ it 'does not return any milestone if none found' do
+ get api(route, user), iids: [Milestone.maximum(:iid).succ]
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(0)
+ end
+
+ it 'returns a milestone by iids array' do
+ get api("#{route}?iids=#{closed_milestone.iid}", user)
+
+ expect(response.status).to eq 200
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(1)
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['title']).to eq closed_milestone.title
+ expect(json_response.first['id']).to eq closed_milestone.id
+ end
+
+ it 'returns a milestone by searching for title' do
+ get api(route, user), search: 'version2'
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['title']).to eq milestone.title
+ expect(json_response.first['id']).to eq milestone.id
+ end
+
+ it 'returns a milestones by searching for description' do
+ get api(route, user), search: 'open'
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['title']).to eq milestone.title
+ expect(json_response.first['id']).to eq milestone.id
+ end
+ end
+
+ describe "GET #{route_definition}/:milestone_id" do
+ it 'returns a milestone by id' do
+ get api(resource_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['title']).to eq(milestone.title)
+ expect(json_response['iid']).to eq(milestone.iid)
+ end
+
+ it 'returns a milestone by id' do
+ get api(resource_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['title']).to eq(milestone.title)
+ expect(json_response['iid']).to eq(milestone.iid)
+ end
+
+ it 'returns 401 error if user not authenticated' do
+ get api(resource_route)
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns a 404 error if milestone id not found' do
+ get api("#{route}/1234", user)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ describe "POST #{route_definition}" do
+ it 'creates a new milestone' do
+ post api(route, user), title: 'new milestone'
+
+ expect(response).to have_http_status(201)
+ expect(json_response['title']).to eq('new milestone')
+ expect(json_response['description']).to be_nil
+ end
+
+ it 'creates a new milestone with description and dates' do
+ post api(route, user),
+ title: 'new milestone', description: 'release', due_date: '2013-03-02', start_date: '2013-02-02'
+
+ expect(response).to have_http_status(201)
+ expect(json_response['description']).to eq('release')
+ expect(json_response['due_date']).to eq('2013-03-02')
+ expect(json_response['start_date']).to eq('2013-02-02')
+ end
+
+ it 'returns a 400 error if title is missing' do
+ post api(route, user)
+
+ expect(response).to have_http_status(400)
+ end
+
+ it 'returns a 400 error if params are invalid (duplicate title)' do
+ post api(route, user),
+ title: milestone.title, description: 'release', due_date: '2013-03-02'
+
+ expect(response).to have_http_status(400)
+ end
+
+ it 'creates a new milestone with reserved html characters' do
+ post api(route, user), title: 'foo & bar 1.1 -> 2.2'
+
+ expect(response).to have_http_status(201)
+ expect(json_response['title']).to eq('foo & bar 1.1 -> 2.2')
+ expect(json_response['description']).to be_nil
+ end
+ end
+
+ describe "PUT #{route_definition}/:milestone_id" do
+ it 'updates a milestone' do
+ put api(resource_route, user),
+ title: 'updated title'
+
+ expect(response).to have_http_status(200)
+ expect(json_response['title']).to eq('updated title')
+ end
+
+ it 'removes a due date if nil is passed' do
+ milestone.update!(due_date: "2016-08-05")
+
+ put api(resource_route, user), due_date: nil
+
+ expect(response).to have_http_status(200)
+ expect(json_response['due_date']).to be_nil
+ end
+
+ it 'returns a 404 error if milestone id not found' do
+ put api("#{route}/1234", user),
+ title: 'updated title'
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'closes milestone' do
+ put api(resource_route, user),
+ state_event: 'close'
+ expect(response).to have_http_status(200)
+
+ expect(json_response['state']).to eq('closed')
+ end
+ end
+
+ describe "GET #{route_definition}/:milestone_id/issues" do
+ let(:issues_route) { "#{route}/#{milestone.id}/issues" }
+
+ before do
+ milestone.issues << create(:issue, project: project)
+ end
+ it 'returns issues for a particular milestone' do
+ get api(issues_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['milestone']['title']).to eq(milestone.title)
+ end
+
+ it 'returns issues sorted by label priority' do
+ issue_1 = create(:labeled_issue, project: project, milestone: milestone, labels: [label_3])
+ issue_2 = create(:labeled_issue, project: project, milestone: milestone, labels: [label_1])
+ issue_3 = create(:labeled_issue, project: project, milestone: milestone, labels: [label_2])
+
+ get api(issues_route, user)
+
+ expect(json_response.first['id']).to eq(issue_2.id)
+ expect(json_response.second['id']).to eq(issue_3.id)
+ expect(json_response.third['id']).to eq(issue_1.id)
+ end
+
+ it 'matches V4 response schema for a list of issues' do
+ get api(issues_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/issues')
+ end
+
+ it 'returns a 401 error if user not authenticated' do
+ get api(issues_route)
+
+ expect(response).to have_http_status(401)
+ end
+
+ describe 'confidential issues' do
+ let!(:public_project) { create(:project, :public) }
+ let!(:context_group) { try(:group) }
+ let!(:milestone) do
+ context_group ? create(:milestone, group: context_group) : create(:milestone, project: public_project)
+ end
+ let!(:issue) { create(:issue, project: public_project) }
+ let!(:confidential_issue) { create(:issue, confidential: true, project: public_project) }
+ let!(:issues_route) do
+ if context_group
+ "#{route}/#{milestone.id}/issues"
+ else
+ "/projects/#{public_project.id}/milestones/#{milestone.id}/issues"
+ end
+ end
+
+ before do
+ # Add public project to the group in context
+ setup_for_group if context_group
+
+ public_project.team << [user, :developer]
+ milestone.issues << issue << confidential_issue
+ end
+
+ it 'returns confidential issues to team members' do
+ get api(issues_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ # 2 for projects, 3 for group(which has another project with an issue)
+ expect(json_response.size).to be_between(2, 3)
+ expect(json_response.map { |issue| issue['id'] }).to include(issue.id, confidential_issue.id)
+ end
+
+ it 'does not return confidential issues to team members with guest role' do
+ member = create(:user)
+ public_project.team << [member, :guest]
+
+ get api(issues_route, member)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
+ end
+
+ it 'does not return confidential issues to regular users' do
+ get api(issues_route, create(:user))
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
+ end
+
+ it 'returns issues ordered by label priority' do
+ issue.labels << label_2
+ confidential_issue.labels << label_1
+
+ get api(issues_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ # 2 for projects, 3 for group(which has another project with an issue)
+ expect(json_response.size).to be_between(2, 3)
+ expect(json_response.first['id']).to eq(confidential_issue.id)
+ expect(json_response.second['id']).to eq(issue.id)
+ end
+ end
+ end
+
+ describe "GET #{route_definition}/:milestone_id/merge_requests" do
+ let(:merge_requests_route) { "#{route}/#{milestone.id}/merge_requests" }
+
+ before do
+ milestone.merge_requests << merge_request
+ end
+
+ it 'returns merge_requests for a particular milestone' do
+ # eager-load another_merge_request
+ another_merge_request
+ get api(merge_requests_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['title']).to eq(merge_request.title)
+ expect(json_response.first['milestone']['title']).to eq(milestone.title)
+ end
+
+ it 'returns merge_requests sorted by label priority' do
+ merge_request_1 = create(:labeled_merge_request, source_branch: 'branch_1', source_project: project, milestone: milestone, labels: [label_2])
+ merge_request_2 = create(:labeled_merge_request, source_branch: 'branch_2', source_project: project, milestone: milestone, labels: [label_1])
+ merge_request_3 = create(:labeled_merge_request, source_branch: 'branch_3', source_project: project, milestone: milestone, labels: [label_3])
+
+ get api(merge_requests_route, user)
+
+ expect(json_response.first['id']).to eq(merge_request_2.id)
+ expect(json_response.second['id']).to eq(merge_request_1.id)
+ expect(json_response.third['id']).to eq(merge_request_3.id)
+ end
+
+ it 'returns a 404 error if milestone id not found' do
+ not_found_route = "#{route}/1234/merge_requests"
+
+ get api(not_found_route, user)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns a 404 if the user has no access to the milestone' do
+ new_user = create :user
+ get api(merge_requests_route, new_user)
+
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns a 401 error if user not authenticated' do
+ get api(merge_requests_route)
+
+ expect(response).to have_http_status(401)
+ end
+
+ it 'returns merge_requests ordered by position asc' do
+ milestone.merge_requests << another_merge_request
+ another_merge_request.labels << label_1
+ merge_request.labels << label_2
+
+ get api(merge_requests_route, user)
+
+ expect(response).to have_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(2)
+ expect(json_response.first['id']).to eq(another_merge_request.id)
+ expect(json_response.second['id']).to eq(merge_request.id)
+ end
+ end
+end
diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb
index e42d727672b..6591d56e473 100644
--- a/spec/support/api/schema_matcher.rb
+++ b/spec/support/api/schema_matcher.rb
@@ -1,8 +1,25 @@
-RSpec::Matchers.define :match_response_schema do |schema, **options|
+module SchemaPath
+ def self.expand(schema, dir = '')
+ Rails.root.join('spec', dir, "fixtures/api/schemas/#{schema}.json").to_s
+ end
+end
+
+RSpec::Matchers.define :match_response_schema do |schema, dir: '', **options|
match do |response|
- schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
- schema_path = "#{schema_directory}/#{schema}.json"
+ @errors = JSON::Validator.fully_validate(
+ SchemaPath.expand(schema, dir), response.body, options)
+
+ @errors.empty?
+ end
+
+ failure_message do |response|
+ "didn't match the schema defined by #{SchemaPath.expand(schema, dir)}" \
+ " The validation errors were:\n#{@errors.join("\n")}"
+ end
+end
- JSON::Validator.validate!(schema_path, response.body, options)
+RSpec::Matchers.define :match_schema do |schema, dir: '', **options|
+ match do |data|
+ JSON::Validator.validate!(SchemaPath.expand(schema, dir), data, options)
end
end
diff --git a/spec/support/api/scopes/read_user_shared_examples.rb b/spec/support/api/scopes/read_user_shared_examples.rb
new file mode 100644
index 00000000000..3bd589d64b9
--- /dev/null
+++ b/spec/support/api/scopes/read_user_shared_examples.rb
@@ -0,0 +1,79 @@
+shared_examples_for 'allows the "read_user" scope' do
+ context 'for personal access tokens' do
+ context 'when the requesting token has the "api" scope' do
+ let(:token) { create(:personal_access_token, scopes: ['api'], user: user) }
+
+ it 'returns a "200" response' do
+ get api_call.call(path, user, personal_access_token: token)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when the requesting token has the "read_user" scope' do
+ let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) }
+
+ it 'returns a "200" response' do
+ get api_call.call(path, user, personal_access_token: token)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when the requesting token does not have any required scope' do
+ let(:token) { create(:personal_access_token, scopes: ['read_registry'], user: user) }
+
+ it 'returns a "401" response' do
+ get api_call.call(path, user, personal_access_token: token)
+
+ expect(response).to have_http_status(401)
+ end
+ end
+ end
+
+ context 'for doorkeeper (OAuth) tokens' do
+ let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
+
+ context 'when the requesting token has the "api" scope' do
+ let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" }
+
+ it 'returns a "200" response' do
+ get api_call.call(path, user, oauth_access_token: token)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when the requesting token has the "read_user" scope' do
+ let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "read_user" }
+
+ it 'returns a "200" response' do
+ get api_call.call(path, user, oauth_access_token: token)
+
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ context 'when the requesting token does not have any required scope' do
+ let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "invalid" }
+
+ it 'returns a "403" response' do
+ get api_call.call(path, user, oauth_access_token: token)
+
+ expect(response).to have_http_status(403)
+ end
+ end
+ end
+end
+
+shared_examples_for 'does not allow the "read_user" scope' do
+ context 'when the requesting token has the "read_user" scope' do
+ let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) }
+
+ it 'returns a "401" response' do
+ post api_call.call(path, user, personal_access_token: token), attributes_for(:user, projects_limit: 3)
+
+ expect(response).to have_http_status(401)
+ end
+ end
+end
diff --git a/spec/support/api/status_shared_examples.rb b/spec/support/api/status_shared_examples.rb
deleted file mode 100644
index 3481749a7f0..00000000000
--- a/spec/support/api/status_shared_examples.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# Specs for status checking.
-#
-# Requires an API request:
-# let(:request) { get api("/projects/#{project.id}/repository/branches", user) }
-shared_examples_for '400 response' do
- before do
- # Fires the request
- request
- end
-
- it 'returns 400' do
- expect(response).to have_http_status(400)
- end
-end
-
-shared_examples_for '403 response' do
- before do
- # Fires the request
- request
- end
-
- it 'returns 403' do
- expect(response).to have_http_status(403)
- end
-end
-
-shared_examples_for '404 response' do
- let(:message) { nil }
- before do
- # Fires the request
- request
- end
-
- it 'returns 404' do
- expect(response).to have_http_status(404)
- expect(json_response).to be_an Object
-
- if message.present?
- expect(json_response['message']).to eq(message)
- end
- end
-end
diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb
index 35d1e1cfc7d..ac0aaa524b7 100644
--- a/spec/support/api_helpers.rb
+++ b/spec/support/api_helpers.rb
@@ -17,14 +17,18 @@ module ApiHelpers
# => "/api/v2/issues?foo=bar&private_token=..."
#
# Returns the relative path to the requested API resource
- def api(path, user = nil, version: API::API.version)
+ def api(path, user = nil, version: API::API.version, personal_access_token: nil, oauth_access_token: nil)
"/api/#{version}#{path}" +
# Normalize query string
(path.index('?') ? '' : '?') +
+ if personal_access_token.present?
+ "&private_token=#{personal_access_token.token}"
+ elsif oauth_access_token.present?
+ "&access_token=#{oauth_access_token.token}"
# Append private_token if given a User object
- if user.respond_to?(:private_token)
+ elsif user.respond_to?(:private_token)
"&private_token=#{user.private_token}"
else
''
@@ -32,8 +36,14 @@ module ApiHelpers
end
# Temporary helper method for simplifying V3 exclusive API specs
- def v3_api(path, user = nil)
- api(path, user, version: 'v3')
+ def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil)
+ api(
+ path,
+ user,
+ version: 'v3',
+ personal_access_token: personal_access_token,
+ oauth_access_token: oauth_access_token
+ )
end
def ci_api(path, user = nil)
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index b8ca8f22a3d..c45c4a4310d 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -14,8 +14,10 @@ Capybara.register_driver :poltergeist do |app|
js_errors: true,
timeout: timeout,
window_size: [1366, 768],
+ url_whitelist: %w[localhost 127.0.0.1],
+ url_blacklist: %w[.mp4 .png .gif .avi .bmp .jpg .jpeg],
phantomjs_options: [
- '--load-images=no'
+ '--load-images=yes'
]
)
end
@@ -33,4 +35,21 @@ RSpec.configure do |config|
TestEnv.eager_load_driver_server
$capybara_server_already_started = true
end
+
+ config.before(:example, :js) do
+ allow(Gitlab::Application.routes).to receive(:default_url_options).and_return(
+ host: Capybara.current_session.server.host,
+ port: Capybara.current_session.server.port,
+ protocol: 'http')
+ end
+
+ config.after(:example, :js) do |example|
+ # capybara/rspec already calls Capybara.reset_sessions! in an `after` hook,
+ # but `block_and_wait_for_requests_complete` is called before it so by
+ # calling it explicitely here, we prevent any new requests from being fired
+ # See https://github.com/teamcapybara/capybara/blob/ffb41cfad620de1961bb49b1562a9fa9b28c0903/lib/capybara/rspec.rb#L20-L25
+ # We don't reset the session when the example failed, because we need capybara-screenshot to have access to it.
+ Capybara.reset_sessions! unless example.exception
+ block_and_wait_for_requests_complete
+ end
end
diff --git a/spec/support/capybara_helpers.rb b/spec/support/capybara_helpers.rb
index b57a3493aff..3eb7bea3227 100644
--- a/spec/support/capybara_helpers.rb
+++ b/spec/support/capybara_helpers.rb
@@ -35,6 +35,11 @@ module CapybaraHelpers
visit 'about:blank'
visit url
end
+
+ # Simulate a browser restart by clearing the session cookie.
+ def clear_browser_session
+ page.driver.remove_cookie('_gitlab_session')
+ end
end
RSpec.configure do |config|
diff --git a/spec/support/chat_slash_commands_shared_examples.rb b/spec/support/chat_slash_commands_shared_examples.rb
index 4dfa29849ee..dc97a39f051 100644
--- a/spec/support/chat_slash_commands_shared_examples.rb
+++ b/spec/support/chat_slash_commands_shared_examples.rb
@@ -36,7 +36,7 @@ RSpec.shared_examples 'chat slash commands service' do
end
context 'with a token passed' do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:params) { { token: 'token' } }
before do
@@ -87,7 +87,7 @@ RSpec.shared_examples 'chat slash commands service' do
end
it 'triggers the command' do
- expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute)
+ expect_any_instance_of(Gitlab::SlashCommands::Command).to receive(:execute)
subject.trigger(params)
end
diff --git a/spec/support/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb
index c59b30c772d..4eec3127464 100644
--- a/spec/support/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb
@@ -14,8 +14,8 @@ shared_examples 'a GitHub-ish import controller: POST personal_access_token' do
it "updates access token" do
token = 'asdfasdf9876'
- allow_any_instance_of(Gitlab::GithubImport::Client).
- to receive(:user).and_return(true)
+ allow_any_instance_of(Gitlab::GithubImport::Client)
+ .to receive(:user).and_return(true)
post :personal_access_token, personal_access_token: token
@@ -56,7 +56,7 @@ shared_examples 'a GitHub-ish import controller: GET status' do
end
it "assigns variables" do
- project = create(:empty_project, import_type: provider, creator_id: user.id)
+ project = create(:project, import_type: provider, creator_id: user.id)
stub_client(repos: [repo, org_repo], orgs: [org], org_repos: [org_repo])
get :status
@@ -69,7 +69,7 @@ shared_examples 'a GitHub-ish import controller: GET status' do
end
it "does not show already added project" do
- project = create(:empty_project, import_type: provider, creator_id: user.id, import_source: 'asd/vim')
+ project = create(:project, import_type: provider, creator_id: user.id, import_source: 'asd/vim')
stub_client(repos: [repo], orgs: [])
get :status
@@ -79,8 +79,8 @@ shared_examples 'a GitHub-ish import controller: GET status' do
end
it "handles an invalid access token" do
- allow_any_instance_of(Gitlab::GithubImport::Client).
- to receive(:repos).and_raise(Octokit::Unauthorized)
+ allow_any_instance_of(Gitlab::GithubImport::Client)
+ .to receive(:repos).and_raise(Octokit::Unauthorized)
get :status
@@ -110,9 +110,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
context "when the repository owner is the provider user" do
context "when the provider user and GitLab user's usernames match" do
it "takes the current user's namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -122,9 +122,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
let(:provider_username) { "someone_else" }
it "takes the current user's namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -144,9 +144,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
context "when the namespace is owned by the GitLab user" do
it "takes the existing namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, existing_namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, existing_namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -159,9 +159,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
end
it "creates a project using user's namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -171,16 +171,16 @@ shared_examples 'a GitHub-ish import controller: POST create' do
context "when a namespace with the provider user's username doesn't exist" do
context "when current user can create namespaces" do
it "creates the namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, target_namespace: provider_repo.name, format: :js }.to change(Namespace, :count).by(1)
end
it "takes the new namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, an_instance_of(Group), user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, an_instance_of(Group), user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, target_namespace: provider_repo.name, format: :js
end
@@ -192,16 +192,16 @@ shared_examples 'a GitHub-ish import controller: POST create' do
end
it "doesn't create the namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).and_return(double(execute: true))
expect { post :create, format: :js }.not_to change(Namespace, :count)
end
it "takes the current user's namespace" do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, format: :js
end
@@ -209,35 +209,43 @@ shared_examples 'a GitHub-ish import controller: POST create' do
end
context 'user has chosen a namespace and name for the project' do
- let(:test_namespace) { create(:namespace, name: 'test_namespace', owner: user) }
+ let(:test_namespace) { create(:group, name: 'test_namespace') }
let(:test_name) { 'test_name' }
+ before do
+ test_namespace.add_owner(user)
+ end
+
it 'takes the selected namespace and name' do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { target_namespace: test_namespace.name, new_name: test_name, format: :js }
end
it 'takes the selected name and default namespace' do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { new_name: test_name, format: :js }
end
end
context 'user has chosen an existing nested namespace and name for the project' do
- let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
- let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) }
+ let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
let(:test_name) { 'test_name' }
+ before do
+ nested_namespace.add_owner(user)
+ end
+
it 'takes the selected namespace and name' do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :js }
end
@@ -247,26 +255,26 @@ shared_examples 'a GitHub-ish import controller: POST create' do
let(:test_name) { 'test_name' }
it 'takes the selected namespace and name' do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider).
- and_return(double(execute: true))
+ allow(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js } }
.to change { Namespace.count }.by(2)
end
it 'new namespace has the right parent' do
- allow(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider).
- and_return(double(execute: true))
+ allow(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
@@ -276,20 +284,20 @@ shared_examples 'a GitHub-ish import controller: POST create' do
context 'user has chosen existent and non-existent nested namespaces and name for the project' do
let(:test_name) { 'test_name' }
- let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) }
+ let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
it 'takes the selected namespace and name' do
- expect(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider).
- and_return(double(execute: true))
+ expect(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: true))
post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js }
end
it 'creates the namespaces' do
- allow(Gitlab::GithubImport::ProjectCreator).
- to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider).
- and_return(double(execute: true))
+ allow(Gitlab::GithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: true))
expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js } }
.to change { Namespace.count }.by(2)
diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb
index 8ad042f5e3b..30911e7fa86 100644
--- a/spec/support/cycle_analytics_helpers.rb
+++ b/spec/support/cycle_analytics_helpers.rb
@@ -20,7 +20,7 @@ module CycleAnalyticsHelpers
ref: 'refs/heads/master').execute
end
- def create_merge_request_closing_issue(issue, message: nil, source_branch: nil)
+ def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message')
if !source_branch || project.repository.commit(source_branch).blank?
source_branch = generate(:branch)
project.repository.add_branch(user, source_branch, 'master')
@@ -30,7 +30,7 @@ module CycleAnalyticsHelpers
user,
generate(:branch),
'content',
- message: 'commit message',
+ message: commit_message,
branch_name: source_branch)
project.repository.commit(sha)
@@ -41,7 +41,9 @@ module CycleAnalyticsHelpers
target_branch: 'master'
}
- MergeRequests::CreateService.new(project, user, opts).execute
+ mr = MergeRequests::CreateService.new(project, user, opts).execute
+ NewMergeRequestWorker.new.perform(mr, user)
+ mr
end
def merge_merge_requests_closing_issue(issue)
@@ -51,12 +53,45 @@ module CycleAnalyticsHelpers
end
def deploy_master(environment: 'production')
- CreateDeploymentService.new(project, user, {
- environment: environment,
- ref: 'master',
- tag: false,
- sha: project.repository.commit('master').sha
- }).execute
+ dummy_job =
+ case environment
+ when 'production'
+ dummy_production_job
+ when 'staging'
+ dummy_staging_job
+ else
+ raise ArgumentError
+ end
+
+ CreateDeploymentService.new(dummy_job).execute
+ end
+
+ def dummy_production_job
+ @dummy_job ||= new_dummy_job('production')
+ end
+
+ def dummy_staging_job
+ @dummy_job ||= new_dummy_job('staging')
+ end
+
+ def dummy_pipeline
+ @dummy_pipeline ||=
+ Ci::Pipeline.new(
+ sha: project.repository.commit('master').sha,
+ project: project)
+ end
+
+ def new_dummy_job(environment)
+ project.environments.find_or_create_by(name: environment)
+
+ Ci::Build.new(
+ project: project,
+ user: user,
+ environment: environment,
+ ref: 'master',
+ tag: false,
+ name: 'dummy',
+ pipeline: dummy_pipeline)
end
end
diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb
index 6f31828b825..b0f520d08e8 100644
--- a/spec/support/db_cleaner.rb
+++ b/spec/support/db_cleaner.rb
@@ -19,6 +19,10 @@ RSpec.configure do |config|
DatabaseCleaner.strategy = :truncation
end
+ config.before(:each, :migration) do
+ DatabaseCleaner.strategy = :truncation, { cache_tables: false }
+ end
+
config.before(:each) do
DatabaseCleaner.start
end
diff --git a/spec/support/devise_helpers.rb b/spec/support/devise_helpers.rb
new file mode 100644
index 00000000000..890a2d9d287
--- /dev/null
+++ b/spec/support/devise_helpers.rb
@@ -0,0 +1,14 @@
+module DeviseHelpers
+ # explicitly tells Devise which mapping to use
+ # this is needed when we are testing a Devise controller bypassing the router
+ def set_devise_mapping(context:)
+ env =
+ if context.respond_to?(:env_config)
+ context.env_config
+ elsif context.respond_to?(:env)
+ context.env
+ end
+
+ env['devise.mapping'] = Devise.mappings[:user] if env
+ end
+end
diff --git a/spec/support/dropzone_helper.rb b/spec/support/dropzone_helper.rb
index 984ec7d2741..fe72d320fcf 100644
--- a/spec/support/dropzone_helper.rb
+++ b/spec/support/dropzone_helper.rb
@@ -6,32 +6,71 @@ module DropzoneHelper
# Dropzone events to perform the actual upload.
#
# This method waits for the upload to complete before returning.
- def dropzone_file(file_path)
+ # max_file_size is an optional parameter.
+ # If it's not 0, then it used in dropzone.maxFilesize parameter.
+ # wait_for_queuecomplete is an optional parameter.
+ # If it's 'false', then the helper will NOT wait for backend response
+ # It lets to test behaviors while AJAX is processing.
+ def dropzone_file(files, max_file_size = 0, wait_for_queuecomplete = true)
# Generate a fake file input that Capybara can attach to
page.execute_script <<-JS.strip_heredoc
+ $('#fakeFileInput').remove();
var fakeFileInput = window.$('<input/>').attr(
- {id: 'fakeFileInput', type: 'file'}
+ {id: 'fakeFileInput', type: 'file', multiple: true}
).appendTo('body');
window._dropzoneComplete = false;
JS
- # Attach the file to the fake input selector with Capybara
- attach_file('fakeFileInput', file_path)
+ # Attach files to the fake input selector with Capybara
+ attach_file('fakeFileInput', files)
# Manually trigger a Dropzone "drop" event with the fake input's file list
page.execute_script <<-JS.strip_heredoc
- var fileList = [$('#fakeFileInput')[0].files[0]];
- var e = jQuery.Event('drop', { dataTransfer : { files : fileList } });
-
var dropzone = $('.div-dropzone')[0].dropzone;
+ dropzone.options.autoProcessQueue = false;
+
+ if (#{max_file_size} > 0) {
+ dropzone.options.maxFilesize = #{max_file_size};
+ }
+
dropzone.on('queuecomplete', function() {
window._dropzoneComplete = true;
});
- dropzone.listeners[0].events.drop(e);
+
+ var fileList = [$('#fakeFileInput')[0].files];
+
+ $.map(fileList, function(file){
+ var e = jQuery.Event('drop', { dataTransfer : { files : file } });
+
+ dropzone.listeners[0].events.drop(e);
+ });
+
+ dropzone.processQueue();
JS
- # Wait until Dropzone's fired `queuecomplete`
- loop until page.evaluate_script('window._dropzoneComplete === true')
+ if wait_for_queuecomplete
+ # Wait until Dropzone's fired `queuecomplete`
+ loop until page.evaluate_script('window._dropzoneComplete === true')
+ end
+ end
+
+ def drop_in_dropzone(file_path)
+ # Generate a fake input selector
+ page.execute_script <<-JS
+ var fakeFileInput = window.$('<input/>').attr(
+ {id: 'fakeFileInput', type: 'file'}
+ ).appendTo('body');
+ JS
+
+ # Attach the file to the fake input selector with Capybara
+ attach_file('fakeFileInput', file_path)
+
+ # Add the file to a fileList array and trigger the fake drop event
+ page.execute_script <<-JS
+ var fileList = [$('#fakeFileInput')[0].files[0]];
+ var e = jQuery.Event('drop', { dataTransfer : { files : fileList } });
+ $('.dropzone')[0].dropzone.listeners[0].events.drop(e);
+ JS
end
end
diff --git a/spec/support/fake_migration_classes.rb b/spec/support/fake_migration_classes.rb
new file mode 100644
index 00000000000..b0fc8422857
--- /dev/null
+++ b/spec/support/fake_migration_classes.rb
@@ -0,0 +1,11 @@
+class FakeRenameReservedPathMigrationV1 < ActiveRecord::Migration
+ include Gitlab::Database::RenameReservedPathsMigration::V1
+
+ def version
+ '20170316163845'
+ end
+
+ def name
+ "FakeRenameReservedPathMigrationV1"
+ end
+end
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 5bbe36d9b7f..68f0ce8afb3 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -1,13 +1,18 @@
# Specifications for behavior common to all objects with executable attributes.
# It takes a `issuable_type`, and expect an `issuable`.
-shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type|
- include SlashCommandsHelpers
+shared_examples 'issuable record that supports quick actions in its description and notes' do |issuable_type|
+ include QuickActionsHelpers
let(:master) { create(:user) }
- let(:assignee) { create(:user, username: 'bob') }
- let(:guest) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) do
+ case issuable_type
+ when :merge_request
+ create(:project, :public, :repository)
+ when :issue
+ create(:project, :public)
+ end
+ end
let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
let!(:label_bug) { create(:label, project: project, title: 'bug') }
let!(:label_feature) { create(:label, project: project, title: 'feature') }
@@ -15,20 +20,24 @@ shared_examples 'issuable record that supports slash commands in its description
before do
project.team << [master, :master]
- project.team << [assignee, :developer]
- project.team << [guest, :guest]
- login_with(master)
+
+ sign_in(master)
end
after do
# Ensure all outstanding Ajax requests are complete to avoid database deadlocks
- wait_for_ajax
+ wait_for_requests
end
- describe "new #{issuable_type}" do
+ describe "new #{issuable_type}", js: true do
context 'with commands in the description' do
it "creates the #{issuable_type} and interpret commands accordingly" do
- visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts)
+ case issuable_type
+ when :merge_request
+ visit public_send("namespace_project_new_merge_request_path", project.namespace, project, new_url_opts)
+ when :issue
+ visit public_send("new_namespace_project_issue_path", project.namespace, project, new_url_opts)
+ end
fill_in "#{issuable_type}_title", with: 'bug 345'
fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\""
click_button "Submit #{issuable_type}".humanize
@@ -44,13 +53,14 @@ shared_examples 'issuable record that supports slash commands in its description
end
end
- describe "note on #{issuable_type}" do
+ describe "note on #{issuable_type}", js: true do
before do
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
context 'with a note containing commands' do
it 'creates a note without the commands and interpret the commands accordingly' do
+ assignee = create(:user, username: 'bob')
write_note("Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\"")
expect(page).to have_content 'Awesome!'
@@ -58,11 +68,12 @@ shared_examples 'issuable record that supports slash commands in its description
expect(page).not_to have_content '/label ~bug'
expect(page).not_to have_content '/milestone %"ASAP"'
+ wait_for_requests
issuable.reload
note = issuable.notes.user.first
expect(note.note).to eq "Awesome!"
- expect(issuable.assignee).to eq assignee
+ expect(issuable.assignees).to eq [assignee]
expect(issuable.labels).to eq [label_bug]
expect(issuable.milestone).to eq milestone
end
@@ -70,6 +81,7 @@ shared_examples 'issuable record that supports slash commands in its description
context 'with a note containing only commands' do
it 'does not create a note but interpret the commands accordingly' do
+ assignee = create(:user, username: 'bob')
write_note("/assign @bob\n/label ~bug\n/milestone %\"ASAP\"")
expect(page).not_to have_content '/assign @bob'
@@ -80,7 +92,7 @@ shared_examples 'issuable record that supports slash commands in its description
issuable.reload
expect(issuable.notes.user).to be_empty
- expect(issuable.assignee).to eq assignee
+ expect(issuable.assignees).to eq [assignee]
expect(issuable.labels).to eq [label_bug]
expect(issuable.milestone).to eq milestone
end
@@ -104,8 +116,12 @@ shared_examples 'issuable record that supports slash commands in its description
context "when current user cannot close #{issuable_type}" do
before do
- logout
- login_with(guest)
+ guest = create(:user)
+ project.add_guest(guest)
+
+ sign_out(:user)
+ sign_in(guest)
+
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
@@ -139,8 +155,12 @@ shared_examples 'issuable record that supports slash commands in its description
context "when current user cannot reopen #{issuable_type}" do
before do
- logout
- login_with(guest)
+ guest = create(:user)
+ project.add_guest(guest)
+
+ sign_out(:user)
+ sign_in(guest)
+
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
@@ -169,8 +189,11 @@ shared_examples 'issuable record that supports slash commands in its description
context "when current user cannot change title of #{issuable_type}" do
before do
- logout
- login_with(guest)
+ guest = create(:user)
+ project.add_guest(guest)
+
+ sign_out(:user)
+ sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
@@ -256,5 +279,33 @@ shared_examples 'issuable record that supports slash commands in its description
expect(issuable.subscribed?(master, project)).to be_falsy
end
end
+
+ context "with a note assigning the #{issuable_type} to the current user" do
+ it "assigns the #{issuable_type} to the current user" do
+ write_note("/assign me")
+
+ expect(page).not_to have_content '/assign me'
+ expect(page).to have_content 'Commands applied'
+
+ expect(issuable.reload.assignees).to eq [master]
+ end
+ end
+ end
+
+ describe "preview of note on #{issuable_type}" do
+ it 'removes quick actions from note and explains them' do
+ create(:user, username: 'bob')
+
+ visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "Awesome!\n/assign @bob "
+ click_on 'Preview'
+
+ expect(page).to have_content 'Awesome!'
+ expect(page).not_to have_content '/assign @bob'
+ expect(page).to have_content 'Assigns @bob.'
+ end
+ end
end
end
diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb
new file mode 100644
index 00000000000..cb483ae9a5a
--- /dev/null
+++ b/spec/support/features/reportable_note_shared_examples.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+shared_examples 'reportable note' do
+ include NotesHelper
+
+ let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") }
+ let(:more_actions_selector) { '.more-actions.dropdown' }
+ let(:abuse_report_path) { new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) }
+
+ it 'has an edit button' do
+ expect(comment).to have_selector('.js-note-edit')
+ end
+
+ it 'has a `More actions` dropdown' do
+ expect(comment).to have_selector(more_actions_selector)
+ end
+
+ it 'dropdown has Report and Delete links' do
+ dropdown = comment.find(more_actions_selector)
+ open_dropdown(dropdown)
+
+ expect(dropdown).to have_link('Report as abuse', href: abuse_report_path)
+ expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
+ end
+
+ it 'Report button links to a report page' do
+ dropdown = comment.find(more_actions_selector)
+ open_dropdown(dropdown)
+
+ dropdown.click_link('Report as abuse')
+
+ expect(find('#user_name')['value']).to match(note.author.username)
+ expect(find('#abuse_report_message')['value']).to match(noteable_note_url(note))
+ end
+
+ def open_dropdown(dropdown)
+ dropdown.click
+ dropdown.find('.dropdown-menu li', match: :first)
+ end
+end
diff --git a/spec/support/features/rss_shared_examples.rb b/spec/support/features/rss_shared_examples.rb
index 9a3b0a731ad..50fbbc7f55b 100644
--- a/spec/support/features/rss_shared_examples.rb
+++ b/spec/support/features/rss_shared_examples.rb
@@ -1,23 +1,23 @@
-shared_examples "an autodiscoverable RSS feed with current_user's private token" do
- it "has an RSS autodiscovery link tag with current_user's private token" do
- expect(page).to have_css("link[type*='atom+xml'][href*='private_token=#{Thread.current[:current_user].private_token}']", visible: false)
+shared_examples "an autodiscoverable RSS feed with current_user's RSS token" do
+ it "has an RSS autodiscovery link tag with current_user's RSS token" do
+ expect(page).to have_css("link[type*='atom+xml'][href*='rss_token=#{user.rss_token}']", visible: false)
end
end
-shared_examples "it has an RSS button with current_user's private token" do
- it "shows the RSS button with current_user's private token" do
- expect(page).to have_css("a:has(.fa-rss)[href*='private_token=#{Thread.current[:current_user].private_token}']")
+shared_examples "it has an RSS button with current_user's RSS token" do
+ it "shows the RSS button with current_user's RSS token" do
+ expect(page).to have_css("a:has(.fa-rss)[href*='rss_token=#{user.rss_token}']")
end
end
-shared_examples "an autodiscoverable RSS feed without a private token" do
- it "has an RSS autodiscovery link tag without a private token" do
- expect(page).to have_css("link[type*='atom+xml']:not([href*='private_token'])", visible: false)
+shared_examples "an autodiscoverable RSS feed without an RSS token" do
+ it "has an RSS autodiscovery link tag without an RSS token" do
+ expect(page).to have_css("link[type*='atom+xml']:not([href*='rss_token'])", visible: false)
end
end
-shared_examples "it has an RSS button without a private token" do
- it "shows the RSS button without a private token" do
- expect(page).to have_css("a:has(.fa-rss):not([href*='private_token'])")
+shared_examples "it has an RSS button without an RSS token" do
+ it "shows the RSS button without an RSS token" do
+ expect(page).to have_css("a:has(.fa-rss):not([href*='rss_token'])")
end
end
diff --git a/spec/support/filter_item_select_helper.rb b/spec/support/filter_item_select_helper.rb
new file mode 100644
index 00000000000..519e84d359e
--- /dev/null
+++ b/spec/support/filter_item_select_helper.rb
@@ -0,0 +1,19 @@
+# Helper allows you to select value from filter-items
+#
+# Params
+# value - value for select
+# selector - css selector of item
+#
+# Usage:
+#
+# filter_item_select('Any Author', '.js-author-search')
+#
+module FilterItemSelectHelper
+ def filter_item_select(value, selector)
+ find(selector).click
+ wait_for_requests
+ page.within('.dropdown-content') do
+ click_link value
+ end
+ end
+end
diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb
index 36be0bb6bf8..99b8b6b7ea4 100644
--- a/spec/support/filtered_search_helpers.rb
+++ b/spec/support/filtered_search_helpers.rb
@@ -14,6 +14,9 @@ module FilteredSearchHelpers
filtered_search.set(search)
if submit
+ # Wait for the lazy author/assignee tokens that
+ # swap out the username with an avatar and name
+ wait_for_requests
filtered_search.send_keys(:enter)
end
end
@@ -51,8 +54,8 @@ module FilteredSearchHelpers
# Iterates through each visual token inside
# .tokens-container to make sure the correct names and values are rendered
def expect_tokens(tokens)
- page.find '.filtered-search-box .tokens-container' do
- page.all(:css, '.tokens-container li').each_with_index do |el, index|
+ page.within '.filtered-search-box .tokens-container' do
+ page.all(:css, '.tokens-container li .selectable').each_with_index do |el, index|
token_name = tokens[index][:name]
token_value = tokens[index][:value]
@@ -64,6 +67,28 @@ module FilteredSearchHelpers
end
end
+ def create_token(token_name, token_value = nil, symbol = nil)
+ { name: token_name, value: "#{symbol}#{token_value}" }
+ end
+
+ def author_token(author_name = nil)
+ create_token('Author', author_name)
+ end
+
+ def assignee_token(assignee_name = nil)
+ create_token('Assignee', assignee_name)
+ end
+
+ def milestone_token(milestone_name = nil, has_symbol = true)
+ symbol = has_symbol ? '%' : nil
+ create_token('Milestone', milestone_name, symbol)
+ end
+
+ def label_token(label_name = nil, has_symbol = true)
+ symbol = has_symbol ? '~' : nil
+ create_token('Label', label_name, symbol)
+ end
+
def default_placeholder
'Search or filter results...'
end
@@ -73,11 +98,11 @@ module FilteredSearchHelpers
end
def remove_recent_searches
- execute_script('window.localStorage.removeItem(\'issue-recent-searches\');')
+ execute_script('window.localStorage.clear();')
end
- def set_recent_searches(input)
- execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');")
+ def set_recent_searches(key, input)
+ execute_script("window.localStorage.setItem('#{key}', '#{input}');")
end
def wait_for_filtered_search(text)
diff --git a/spec/support/forgery_protection.rb b/spec/support/forgery_protection.rb
new file mode 100644
index 00000000000..a5e7b761651
--- /dev/null
+++ b/spec/support/forgery_protection.rb
@@ -0,0 +1,11 @@
+RSpec.configure do |config|
+ config.around(:each, :allow_forgery_protection) do |example|
+ begin
+ ActionController::Base.allow_forgery_protection = true
+
+ example.call
+ ensure
+ ActionController::Base.allow_forgery_protection = false
+ end
+ end
+end
diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb
new file mode 100755
index 00000000000..ef3c8e7087f
--- /dev/null
+++ b/spec/support/generate-seed-repo-rb
@@ -0,0 +1,162 @@
+#!/usr/bin/env ruby
+#
+# # generate-seed-repo-rb
+#
+# This script generates the seed_repo.rb file used by lib/gitlab/git
+# tests. The seed_repo.rb file needs to be updated anytime there is a
+# Git push to https://gitlab.com/gitlab-org/gitlab-git-test.
+#
+# Usage:
+#
+# ./spec/support/generate-seed-repo-rb > spec/support/seed_repo.rb
+#
+#
+
+require 'erb'
+require 'tempfile'
+
+SOURCE = File.expand_path('../gitlab-git-test.git', __FILE__).freeze
+SCRIPT_NAME = 'generate-seed-repo-rb'.freeze
+REPO_NAME = 'gitlab-git-test.git'.freeze
+
+def main
+ Dir.mktmpdir do |dir|
+ unless system(*%W[git clone --bare #{SOURCE} #{REPO_NAME}], chdir: dir)
+ abort "git clone failed"
+ end
+ repo = File.join(dir, REPO_NAME)
+ erb = ERB.new(DATA.read)
+ erb.run(binding)
+ end
+end
+
+def capture!(cmd, dir)
+ output = IO.popen(cmd, 'r', chdir: dir) { |io| io.read }
+ raise "command failed with #{$?}: #{cmd.join(' ')}" unless $?.success?
+ output.chomp
+end
+
+main
+
+__END__
+# This file is generated by <%= SCRIPT_NAME %>. Do not edit this file manually.
+#
+# Seed repo:
+<%= capture!(%w{git log --format=#\ %H\ %s}, repo) %>
+
+module SeedRepo
+ module BigCommit
+ ID = "913c66a37b4a45b9769037c55c2d238bd0942d2e".freeze
+ PARENT_ID = "cfe32cf61b73a0d5e9f13e774abde7ff789b1660".freeze
+ MESSAGE = "Files, encoding and much more".freeze
+ AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze
+ FILES_COUNT = 2
+ end
+
+ module Commit
+ ID = "570e7b2abdd848b95f2f578043fc23bd6f6fd24d".freeze
+ PARENT_ID = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9".freeze
+ MESSAGE = "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n".freeze
+ AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze
+ FILES = ["files/ruby/popen.rb", "files/ruby/regex.rb"].freeze
+ FILES_COUNT = 2
+ C_FILE_PATH = "files/ruby".freeze
+ C_FILES = ["popen.rb", "regex.rb", "version_info.rb"].freeze
+ BLOB_FILE = %{%h3= @key.title\n%hr\n%pre= @key.key\n.actions\n = link_to 'Remove', @key, :confirm => 'Are you sure?', :method => :delete, :class => \"btn danger delete-key\"\n\n\n}.freeze
+ BLOB_FILE_PATH = "app/views/keys/show.html.haml".freeze
+ end
+
+ module EmptyCommit
+ ID = "b0e52af38d7ea43cf41d8a6f2471351ac036d6c9".freeze
+ PARENT_ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze
+ MESSAGE = "Empty commit".freeze
+ AUTHOR_FULL_NAME = "Rémy Coutable".freeze
+ FILES = [].freeze
+ FILES_COUNT = FILES.count
+ end
+
+ module EncodingCommit
+ ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze
+ PARENT_ID = "66028349a123e695b589e09a36634d976edcc5e8".freeze
+ MESSAGE = "Add ISO-8859-encoded file".freeze
+ AUTHOR_FULL_NAME = "Stan Hu".freeze
+ FILES = ["encoding/iso8859.txt"].freeze
+ FILES_COUNT = FILES.count
+ end
+
+ module FirstCommit
+ ID = "1a0b36b3cdad1d2ee32457c102a8c0b7056fa863".freeze
+ PARENT_ID = nil
+ MESSAGE = "Initial commit".freeze
+ AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze
+ FILES = ["LICENSE", ".gitignore", "README.md"].freeze
+ FILES_COUNT = 3
+ end
+
+ module LastCommit
+ ID = <%= capture!(%w[git show -s --format=%H HEAD], repo).inspect %>.freeze
+ PARENT_ID = <%= capture!(%w[git show -s --format=%P HEAD], repo).split.last.inspect %>.freeze
+ MESSAGE = <%= capture!(%w[git show -s --format=%s HEAD], repo).inspect %>.freeze
+ AUTHOR_FULL_NAME = <%= capture!(%w[git show -s --format=%an HEAD], repo).inspect %>.freeze
+ FILES = <%=
+ parents = capture!(%w[git show -s --format=%P HEAD], repo).split
+ merge_base = parents.size > 1 ? capture!(%w[git merge-base] + parents, repo) : parents.first
+ capture!( %W[git diff --name-only #{merge_base}..HEAD --], repo).split("\n").inspect
+ %>.freeze
+ FILES_COUNT = FILES.count
+ end
+
+ module Repo
+ HEAD = "master".freeze
+ BRANCHES = %w[
+<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/heads/], repo) %>
+ ].freeze
+ TAGS = %w[
+<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/tags/], repo) %>
+ ].freeze
+ end
+
+ module RubyBlob
+ ID = "7e3e39ebb9b2bf433b4ad17313770fbe4051649c".freeze
+ NAME = "popen.rb".freeze
+ CONTENT = <<-eos.freeze
+require 'fileutils'
+require 'open3'
+
+module Popen
+ extend self
+
+ def popen(cmd, path=nil)
+ unless cmd.is_a?(Array)
+ raise RuntimeError, "System commands must be given as an array of strings"
+ end
+
+ path ||= Dir.pwd
+
+ vars = {
+ "PWD" => path
+ }
+
+ options = {
+ chdir: path
+ }
+
+ unless File.directory?(path)
+ FileUtils.mkdir_p(path)
+ end
+
+ @cmd_output = ""
+ @cmd_status = 0
+
+ Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ @cmd_output << stdout.read
+ @cmd_output << stderr.read
+ @cmd_status = wait_thr.value.exitstatus
+ end
+
+ return @cmd_output, @cmd_status
+ end
+end
+ eos
+ end
+end
diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb
index 46b686fce94..b8289e6c5f1 100644
--- a/spec/support/git_http_helpers.rb
+++ b/spec/support/git_http_helpers.rb
@@ -35,9 +35,14 @@ module GitHttpHelpers
yield response
end
+ def download_or_upload(*args, &block)
+ download(*args, &block)
+ upload(*args, &block)
+ end
+
def auth_env(user, password, spnego_request_token)
env = workhorse_internal_api_request_header
- if user && password
+ if user
env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password)
elsif spnego_request_token
env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}"
@@ -45,4 +50,19 @@ module GitHttpHelpers
env
end
+
+ def git_access_error(error_key)
+ message = Gitlab::GitAccess::ERROR_MESSAGES[error_key]
+ message || raise("GitAccess error message key '#{error_key}' not found")
+ end
+
+ def git_access_wiki_error(error_key)
+ message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key]
+ message || raise("GitAccessWiki error message key '#{error_key}' not found")
+ end
+
+ def change_access_error(error_key)
+ message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key]
+ message || raise("ChangeAccess error message key '#{error_key}' not found")
+ end
end
diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb
index 7aca902fc61..89fb362cf14 100644
--- a/spec/support/gitaly.rb
+++ b/spec/support/gitaly.rb
@@ -1,7 +1,6 @@
-if Gitlab::GitalyClient.enabled?
- RSpec.configure do |config|
- config.before(:each) do
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
- end
+RSpec.configure do |config|
+ config.before(:each) do |example|
+ next if example.metadata[:skip_gitaly_mock]
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
end
end
diff --git a/spec/support/gitlab-git-test.git/HEAD b/spec/support/gitlab-git-test.git/HEAD
new file mode 100644
index 00000000000..cb089cd89a7
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/spec/support/gitlab-git-test.git/README.md b/spec/support/gitlab-git-test.git/README.md
new file mode 100644
index 00000000000..f072cd421be
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/README.md
@@ -0,0 +1,16 @@
+# Gitlab::Git test repository
+
+This repository is used by (some of) the tests in spec/lib/gitlab/git.
+
+Do not add new large files to this repository. Otherwise we needlessly
+inflate the size of the gitlab-ce repository.
+
+## How to make changes to this repository
+
+- (if needed) clone `https://gitlab.com/gitlab-org/gitlab-ce.git` to your local machine
+- clone `gitlab-ce/spec/support/gitlab-git-test.git` locally (i.e. clone from your hard drive, not from the internet)
+- make changes in your local clone of gitlab-git-test
+- run `git push` which will push to your local source `gitlab-ce/spec/support/gitlab-git-test.git`
+- in gitlab-ce: run `spec/support/prepare-gitlab-git-test-for-commit`
+- in gitlab-ce: `git add spec/support/seed_repo.rb spec/support/gitlab-git-test.git`
+- commit your changes in gitlab-ce
diff --git a/spec/support/gitlab-git-test.git/config b/spec/support/gitlab-git-test.git/config
new file mode 100644
index 00000000000..03e2d1b1e0f
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ precomposeunicode = true
+[remote "origin"]
+ url = https://gitlab.com/gitlab-org/gitlab-git-test.git
diff --git a/spec/support/gitlab-git-test.git/objects/3e/20715310a699808282e772720b9c04a0696bcc b/spec/support/gitlab-git-test.git/objects/3e/20715310a699808282e772720b9c04a0696bcc
new file mode 100644
index 00000000000..86bf37ac887
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/objects/3e/20715310a699808282e772720b9c04a0696bcc
Binary files differ
diff --git a/spec/support/gitlab-git-test.git/objects/95/96bc54a6f0c0c98248fe97077eb5ccf48a98d0 b/spec/support/gitlab-git-test.git/objects/95/96bc54a6f0c0c98248fe97077eb5ccf48a98d0
new file mode 100644
index 00000000000..d90cb028e9b
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/objects/95/96bc54a6f0c0c98248fe97077eb5ccf48a98d0
@@ -0,0 +1,2 @@
+x•OËn1 䜯ð 9&O ¡ž¹"õnoYD6Õ’ýÒª?Ðã¼ì™Òj;w»¾ªÂQ £õG‹rN˜(‘ÆH‘PrAÇrRÌ7¯ºtpâ²Mì#MœÂ”c´ê…NrsI­
+Ž“%Þú½­páÒ>Û«²®pzüÀ¯¹?Y¥Õ3XäBBÌ°GB4ƒ ûpÿ?k®›ÔvÛžú‚y~Wø])[‡¥Áa<þ“ÍCP_ \ No newline at end of file
diff --git a/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx
new file mode 100644
index 00000000000..2253da798c4
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx
Binary files differ
diff --git a/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack
new file mode 100644
index 00000000000..3a61107c5b1
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack
Binary files differ
diff --git a/spec/support/gitlab-git-test.git/packed-refs b/spec/support/gitlab-git-test.git/packed-refs
new file mode 100644
index 00000000000..507e4ce785a
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/packed-refs
@@ -0,0 +1,19 @@
+# pack-refs with: peeled fully-peeled
+0b4bc9a49b562e85de7cc9e834518ea6828729b9 refs/heads/feature
+12d65c8dd2b2676fa3ac47d955accc085a37a9c1 refs/heads/fix
+6473c90867124755509e100d0d35ebdc85a0b6ae refs/heads/fix-blob-path
+58fa1a3af4de73ea83fe25a1ef1db8e0c56f67e5 refs/heads/fix-existing-submodule-dir
+40f4a7a617393735a95a0bb67b08385bc1e7c66d refs/heads/fix-mode
+9abd6a8c113a2dd76df3fdb3d58a8cec6db75f8d refs/heads/gitattributes
+46e1395e609395de004cacd4b142865ab0e52a29 refs/heads/gitattributes-updated
+4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6 refs/heads/master
+5937ac0a7beb003549fc5fd26fc247adbce4a52e refs/heads/merge-test
+9596bc54a6f0c0c98248fe97077eb5ccf48a98d0 refs/heads/missing-gitmodules
+f4e6814c3e4e7a0de82a9e7cd20c626cc963a2f8 refs/tags/v1.0.0
+^6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9
+8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b refs/tags/v1.1.0
+^5937ac0a7beb003549fc5fd26fc247adbce4a52e
+10d64eed7760f2811ee2d64b44f1f7d3b364f17b refs/tags/v1.2.0
+^eb49186cfa5c4338011f5f590fac11bd66c5c631
+2ac1f24e253e08135507d0830508febaaccf02ee refs/tags/v1.2.1
+^fa1b1e6c004a68b7d8763b86455da9e6b23e36d6
diff --git a/spec/support/gitlab-git-test.git/refs/heads/.gitkeep b/spec/support/gitlab-git-test.git/refs/heads/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/refs/heads/.gitkeep
diff --git a/spec/support/gitlab-git-test.git/refs/tags/.gitkeep b/spec/support/gitlab-git-test.git/refs/tags/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/support/gitlab-git-test.git/refs/tags/.gitkeep
diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb
new file mode 100644
index 00000000000..65b38626a51
--- /dev/null
+++ b/spec/support/gpg_helpers.rb
@@ -0,0 +1,204 @@
+module GpgHelpers
+ SIGNED_COMMIT_SHA = '8a852d50dda17cc8fd1408d2fd0c5b0f24c76ca4'.freeze
+
+ module User1
+ extend self
+
+ def signed_commit_signature
+ <<~SIGNATURE
+ -----BEGIN PGP SIGNATURE-----
+ Version: GnuPG v1
+
+ iJwEAAECAAYFAliu264ACgkQzPvhnwCsix1VXgP9F6zwAMb3OXKZzqGxJ4MQIBoL
+ OdiUSJpL/4sIA9uhFeIv3GIA+uhsG1BHHsG627+sDy7b8W9VWEd7tbcoz4Mvhf3P
+ 8g0AIt9/KJuStQZDrXwP1uP6Rrl759nDcNpoOKdSQ5EZ1zlRzeDROlZeDp7Ckfvw
+ GLmN/74Gl3pk0wfgHFY=
+ =wSgS
+ -----END PGP SIGNATURE-----
+ SIGNATURE
+ end
+
+ def signed_commit_base_data
+ <<~SIGNEDDATA
+ tree ed60cfd202644fda1abaf684e7d965052db18c13
+ parent caf6a0334a855e12f30205fff3d7333df1f65127
+ author Nannie Bernhard <nannie.bernhard@example.com> 1487854510 +0100
+ committer Nannie Bernhard <nannie.bernhard@example.com> 1487854510 +0100
+
+ signed commit, verified key/email
+ SIGNEDDATA
+ end
+
+ def secret_key
+ <<~KEY.strip
+ -----BEGIN PGP PRIVATE KEY BLOCK-----
+ Version: GnuPG v1
+
+ lQHYBFiu1ScBBADUhWsrlWHp5e7ASlI5iMcA0XN43fivhVlGYJJy4Ii3Hr2i4f5s
+ VffHS8QyhgxxzSnPwe2OKnZWWL9cHzUFbiG3fHalEBTjpB+7pG4HBgU8R/tiDOu8
+ vkAR+tfJbkuRs9XeG3dGKBX/8WRhIfRucYnM+04l2Myyo5zIx7thJmxXjwARAQAB
+ AAP/XUtcqrtfSnDYCK4Xvo4e3msUSAEZxOPDNzP51lhfbBQgp7qSGDj9Fw5ZyNwz
+ 5llse3nksT5OyMUY7HX+rq2UOs12a/piLqvhtX1okp/oTAETmKXNYkZLenv6t94P
+ NqLi0o2AnXAvL9ueXa7WUY3l4DkvuLcjT4+9Ut2Y71zIjeECAN7q9ohNL7E8tNkf
+ Elsbx+8KfyHRQXiSUYaQLlvDRq2lYCKIS7sogTqjZMEgbZx2mRX1fakRlvcmqOwB
+ QoX34zcCAPQPd+yTteNUV12uvDaj8V9DICktPPhbHdYYaUoHjF8RrIHCTRUPzk9E
+ KzCL9dUP8eXPPBV/ty+zjUwl69IgCmkB/3pnNZ0D4EJsNgu24UgI0N+c8H/PE1D6
+ K+bGQ/jK83uYPMXJUsiojssCHLGNp7eBGHFn1PpEqZphgVI50ZMrZQWhJbQtTmFu
+ bmllIEJlcm5oYXJkIDxuYW5uaWUuYmVybmhhcmRAZXhhbXBsZS5jb20+iLgEEwEC
+ ACIFAliu1ScCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEMz74Z8ArIsd
+ p5ID/32hRalvTY+V+QAtzHlGdxugweSBzNgRT3A4UiC9chF6zBOEIw689lqmK6L4
+ i3Il9XeKMl87wi9tsVy9TuOMYDTvcFvu1vMAQ5AsDXqZaAEtCUZpFZscNbi7AXG+
+ QkoDQbMSxp0Rd6eIRJpk9zis5co87f78xJBZLZua+8awFMS6nQHYBFiu1ScBBADI
+ XkITf+kKCkD+n8tMsdTLInefu8KrJ8p7YRYCCabEXnWRsDb5zxUAG2VXCVUhYl6Q
+ XQybkNiBaduS+uxilz7gtYZUMFJvQ09+fV7D2N9B7u/1bGdIYz+cDFJnEJitLY4w
+ /nju2Sno5CL5Ead8sZuslKetSXPYHR/kbW462EOw5wARAQABAAP+IoZfU1XUdVbr
+ +RPWp3ny5SekviDPu8co9BZ4ANTh5+8wyfA3oNbGUxTlYthoU07MZYqq+/k63R28
+ 6HgVGC3gdvCiRMGmryIQ6roLLRXkfzjXrI7Lgnhx4OtVjo62pAKDqdl45wEa1Q+M
+ v08CQF6XNpb5R9Xszz4aBC4eV0KjtjkCANlGSQHZ1B81g+iltj1FAhRHkyUFlrc1
+ cqLVhNgxtHZ96+R57Uk2A7dIJBsE00eIYaHOfk5X5GD/95s1QvPcQskCAOwUk5xj
+ NeQ6VV/1+cI91TrWU6VnT2Yj8632fM/JlKKfaS15pp8t5Ha6pNFr3xD4KgQutchq
+ fPsEOjaU7nwQ/i8B/1rDPTYfNXFpRNt33WAB1XtpgOIHlpmOfaYYqf6lneTlZWBc
+ TgyO+j+ZsHAvP18ugIRkU8D192NflzgAGwXLryijyYifBBgBAgAJBQJYrtUnAhsM
+ AAoJEMz74Z8ArIsdlkUEALTl6QUutJsqwVF4ZXKmmw0IEk8PkqW4G+tYRDHJMs6Z
+ O0nzDS89BG2DL4/UlOs5wRvERnlJYz01TMTxq/ciKaBTEjygFIv9CgIEZh97VacZ
+ TIqcF40k9SbpJNnh3JLf94xsNxNRJTEhbVC3uruaeILue/IR7pBMEyCs49Gcguwy
+ =b6UD
+ -----END PGP PRIVATE KEY BLOCK-----
+ KEY
+ end
+
+ def public_key
+ <<~KEY.strip
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+ Version: GnuPG v1
+
+ mI0EWK7VJwEEANSFayuVYenl7sBKUjmIxwDRc3jd+K+FWUZgknLgiLcevaLh/mxV
+ 98dLxDKGDHHNKc/B7Y4qdlZYv1wfNQVuIbd8dqUQFOOkH7ukbgcGBTxH+2IM67y+
+ QBH618luS5Gz1d4bd0YoFf/xZGEh9G5xicz7TiXYzLKjnMjHu2EmbFePABEBAAG0
+ LU5hbm5pZSBCZXJuaGFyZCA8bmFubmllLmJlcm5oYXJkQGV4YW1wbGUuY29tPoi4
+ BBMBAgAiBQJYrtUnAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDM++Gf
+ AKyLHaeSA/99oUWpb02PlfkALcx5RncboMHkgczYEU9wOFIgvXIReswThCMOvPZa
+ piui+ItyJfV3ijJfO8IvbbFcvU7jjGA073Bb7tbzAEOQLA16mWgBLQlGaRWbHDW4
+ uwFxvkJKA0GzEsadEXeniESaZPc4rOXKPO3+/MSQWS2bmvvGsBTEuriNBFiu1ScB
+ BADIXkITf+kKCkD+n8tMsdTLInefu8KrJ8p7YRYCCabEXnWRsDb5zxUAG2VXCVUh
+ Yl6QXQybkNiBaduS+uxilz7gtYZUMFJvQ09+fV7D2N9B7u/1bGdIYz+cDFJnEJit
+ LY4w/nju2Sno5CL5Ead8sZuslKetSXPYHR/kbW462EOw5wARAQABiJ8EGAECAAkF
+ Aliu1ScCGwwACgkQzPvhnwCsix2WRQQAtOXpBS60myrBUXhlcqabDQgSTw+Spbgb
+ 61hEMckyzpk7SfMNLz0EbYMvj9SU6znBG8RGeUljPTVMxPGr9yIpoFMSPKAUi/0K
+ AgRmH3tVpxlMipwXjST1Jukk2eHckt/3jGw3E1ElMSFtULe6u5p4gu578hHukEwT
+ IKzj0ZyC7DI=
+ =Ug0r
+ -----END PGP PUBLIC KEY BLOCK-----
+ KEY
+ end
+
+ def primary_keyid
+ fingerprint[-16..-1]
+ end
+
+ def fingerprint
+ '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D'
+ end
+
+ def names
+ ['Nannie Bernhard']
+ end
+
+ def emails
+ ['nannie.bernhard@example.com']
+ end
+ end
+
+ module User2
+ extend self
+
+ def private_key
+ <<~KEY.strip
+ -----BEGIN PGP PRIVATE KEY BLOCK-----
+ Version: GnuPG v1
+
+ lQHYBFiuqioBBADg46jkiATWMy9t1npxFWJ77xibPXdUo36LAZgZ6uGungSzcFL4
+ 50bdEyMMGm5RJp6DCYkZlwQDlM//YEqwf0Cmq/AibC5m9bHr7hf5sMxl40ssJ4fj
+ dzT6odihO0vxD2ARSrtiwkESzFxjJ51mjOfdPvAGf0ucxzgeRfUlCrM3kwARAQAB
+ AAP8CJlDFnbywR9dWfqBxi19sFMOk/smCObNQanuTcx6CDcu4zHi0Yxx6BoNCQES
+ cDRCLX5HevnpZngzQB3qa7dga+yqxKzwO8v0P0hliL81B1ZVXUk9TWhBj3NS3m3v
+ +kf2XeTxuZFb9fj44/4HpfbQ2yazTs/Xa+/ZeMqFPCYSNEECAOtjIbwHdfjkpVWR
+ uiwphRkNimv5hdObufs63m9uqhpKPdPKmr2IXgahPZg5PooxqE0k9IXaX2pBsJUF
+ DyuL1dsCAPSVL+YAOviP8ecM1jvdKpkFDd67kR5C+7jEvOGl+c2aX3qLvKt62HPR
+ +DxvYE0Oy0xfoHT14zNSfqthmlhIPqkB/i4WyJaafQVvkkoA9+A5aXbyihOR+RTx
+ p+CMNYvaAplFAyey7nv8l5+la/N+Sv86utjaenLZmCf34nDQEZy7rFWny7QvQmV0
+ dGUgQ2FydHdyaWdodCA8YmV0dGUuY2FydHdyaWdodEBleGFtcGxlLmNvbT6IuAQT
+ AQIAIgUCWK6qKgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQv52SX5Ee
+ /WVCGwP/QsOLTTyEJ6hl0Yy7DLY3kUxS6xiD9fW1FDoTQlxhiO+8TmghmhdtU3TI
+ ssP30/Su3pNKW3TkILtE9U8I2krEpsX5NkyMwmI6LXdeZjli2Lvtkx0Fm0Psd4HO
+ ORYJW5HqTx4jDLzeeIcYjqnobztDpfG8ONDvB0EI0GnCTOZNggG0L0JldHRlIENh
+ cnR3cmlnaHQgPGJldHRlLmNhcnR3cmlnaHRAZXhhbXBsZS5uZXQ+iLgEEwECACIF
+ AlivAsUCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEL+dkl+RHv1lXOwE
+ ANh7ce/vUjv6VMkO8o5OZXszhKE5+MSmYO8v/kkHcXNccC5wI6VF4K//r41p8Cyk
+ 9NzW7Kzjt2+14/BBqWugCx3xjWCuf88KH5PHbuSmfVYbzJmNSy6rfPmusZG5ePqD
+ xp5l2qQxMdRUX0Z36D/koM4N0ls6PAf6Xrdv9s6IBMMVnQHYBFiuqioBBADe5nUd
+ VOcbZlnxOjl0KBAT+A5bmyBLUT0BmLPsmA4PuXDSth7WvibPC8wcCdCYVk0IRMYn
+ eZUiWq/o5c4rthfLR4jg8kruvomQ4E4d4hyI6R0MLxXYZ3XMu67VuScFgbLURw1e
+ RZ16ANd3Nc1VuFW7ms0vCG0idB8iSZBoULaK8QARAQABAAP5AdCfUT/y2kmi75iF
+ ZX1ahSkax9LraEWW8TOCuolR6v2b7jFKrr2xX/P1A2DulID2Y1v4/5MJPHR/1G4D
+ l95Fkw+iGsTvKB5rPG5xye0vOYbbujRa6B9LL6s4Taf486shEegOrdjN9FIweM6f
+ vuVaDYzIk8Qwv5/sStEBxx8rxIkCAOBftFi56AY0gLniyEMAvVRjyVeOZPPJbS8i
+ v6L9asJB5wdsGJxJVyUZ/ylar5aCS7sroOcYTN2b1tOPoWuGqIkCAP5RlDRgm3Zg
+ xL6hXejqZp3G1/DXhKBSI/yUTR/D89H5/qNQe3W7dZqns9mSAJNtqOu+UMZ5UreY
+ Ond0/dmL5SkCAOO5r6gXM8ZDcNjydlQexCLnH70yVkCL6hG9Va1gOuFyUztRnCd+
+ E35YRCEwZREZDr87BRr2Aak5t+lb1EFVqV+nvYifBBgBAgAJBQJYrqoqAhsMAAoJ
+ EL+dkl+RHv1lQggEANWwQwrlT2BFLWV8Fx+wlg31+mcjkTq0LaWu3oueAluoSl93
+ 2B6ToruMh66JoxpSDU44x3JbCaZ/6poiYs5Aff8ZeyEVlfkVaQ7IWd5spjpXaS4i
+ oCOfkZepmbTuE7TPQWM4iBAtuIfiJGiwcpWWM+KIH281yhfCcbRzzFLsCVQx
+ =yEqv
+ -----END PGP PRIVATE KEY BLOCK-----
+ KEY
+ end
+
+ def public_key
+ <<~KEY.strip
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+ Version: GnuPG v1
+
+ mI0EWK6qKgEEAODjqOSIBNYzL23WenEVYnvvGJs9d1SjfosBmBnq4a6eBLNwUvjn
+ Rt0TIwwablEmnoMJiRmXBAOUz/9gSrB/QKar8CJsLmb1sevuF/mwzGXjSywnh+N3
+ NPqh2KE7S/EPYBFKu2LCQRLMXGMnnWaM590+8AZ/S5zHOB5F9SUKszeTABEBAAG0
+ L0JldHRlIENhcnR3cmlnaHQgPGJldHRlLmNhcnR3cmlnaHRAZXhhbXBsZS5jb20+
+ iLgEEwECACIFAliuqioCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEL+d
+ kl+RHv1lQhsD/0LDi008hCeoZdGMuwy2N5FMUusYg/X1tRQ6E0JcYYjvvE5oIZoX
+ bVN0yLLD99P0rt6TSlt05CC7RPVPCNpKxKbF+TZMjMJiOi13XmY5Yti77ZMdBZtD
+ 7HeBzjkWCVuR6k8eIwy83niHGI6p6G87Q6XxvDjQ7wdBCNBpwkzmTYIBtC9CZXR0
+ ZSBDYXJ0d3JpZ2h0IDxiZXR0ZS5jYXJ0d3JpZ2h0QGV4YW1wbGUubmV0Poi4BBMB
+ AgAiBQJYrwLFAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRC/nZJfkR79
+ ZVzsBADYe3Hv71I7+lTJDvKOTmV7M4ShOfjEpmDvL/5JB3FzXHAucCOlReCv/6+N
+ afAspPTc1uys47dvtePwQalroAsd8Y1grn/PCh+Tx27kpn1WG8yZjUsuq3z5rrGR
+ uXj6g8aeZdqkMTHUVF9Gd+g/5KDODdJbOjwH+l63b/bOiATDFbiNBFiuqioBBADe
+ 5nUdVOcbZlnxOjl0KBAT+A5bmyBLUT0BmLPsmA4PuXDSth7WvibPC8wcCdCYVk0I
+ RMYneZUiWq/o5c4rthfLR4jg8kruvomQ4E4d4hyI6R0MLxXYZ3XMu67VuScFgbLU
+ Rw1eRZ16ANd3Nc1VuFW7ms0vCG0idB8iSZBoULaK8QARAQABiJ8EGAECAAkFAliu
+ qioCGwwACgkQv52SX5Ee/WVCCAQA1bBDCuVPYEUtZXwXH7CWDfX6ZyOROrQtpa7e
+ i54CW6hKX3fYHpOiu4yHromjGlINTjjHclsJpn/qmiJizkB9/xl7IRWV+RVpDshZ
+ 3mymOldpLiKgI5+Rl6mZtO4TtM9BYziIEC24h+IkaLBylZYz4ogfbzXKF8JxtHPM
+ UuwJVDE=
+ =0vYo
+ -----END PGP PUBLIC KEY BLOCK-----
+ KEY
+ end
+
+ def primary_keyid
+ fingerprint[-16..-1]
+ end
+
+ def fingerprint
+ '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65'
+ end
+
+ def names
+ ['Bette Cartwright', 'Bette Cartwright']
+ end
+
+ def emails
+ ['bette.cartwright@example.com', 'bette.cartwright@example.net']
+ end
+ end
+end
diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb
new file mode 100644
index 00000000000..bc9686ed9cf
--- /dev/null
+++ b/spec/support/helpers/fake_blob_helpers.rb
@@ -0,0 +1,40 @@
+module FakeBlobHelpers
+ class FakeBlob
+ include BlobLike
+
+ attr_reader :path, :size, :data, :lfs_oid, :lfs_size
+
+ def initialize(path: 'file.txt', size: 1.kilobyte, data: 'foo', binary: false, lfs: nil)
+ @path = path
+ @size = size
+ @data = data
+ @binary = binary
+
+ @lfs_pointer = lfs.present?
+ if @lfs_pointer
+ @lfs_oid = SecureRandom.hex(20)
+ @lfs_size = 1.megabyte
+ end
+ end
+
+ alias_method :name, :path
+
+ def id
+ 0
+ end
+
+ def binary?
+ @binary
+ end
+
+ def external_storage
+ :lfs if @lfs_pointer
+ end
+
+ alias_method :external_size, :lfs_size
+ end
+
+ def fake_blob(**kwargs)
+ Blob.decorate(FakeBlob.new(**kwargs), project)
+ end
+end
diff --git a/spec/support/helpers/key_generator_helper.rb b/spec/support/helpers/key_generator_helper.rb
new file mode 100644
index 00000000000..b1c289ffef7
--- /dev/null
+++ b/spec/support/helpers/key_generator_helper.rb
@@ -0,0 +1,41 @@
+module Spec
+ module Support
+ module Helpers
+ class KeyGeneratorHelper
+ # The components in a openssh .pub / known_host RSA public key.
+ RSA_COMPONENTS = ['ssh-rsa', :e, :n].freeze
+
+ attr_reader :size
+
+ def initialize(size = 2048)
+ @size = size
+ end
+
+ def generate
+ key = OpenSSL::PKey::RSA.generate(size)
+ components = RSA_COMPONENTS.map do |component|
+ key.respond_to?(component) ? encode_mpi(key.public_send(component)) : component
+ end
+
+ # Ruby tries to be helpful and adds new lines every 60 bytes :(
+ 'ssh-rsa ' + [pack_pubkey_components(components)].pack('m').delete("\n")
+ end
+
+ private
+
+ # Encodes an openssh-mpi-encoded integer.
+ def encode_mpi(n)
+ chars, n = [], n.to_i
+ chars << (n & 0xff) && n >>= 8 while n != 0
+ chars << 0 if chars.empty? || chars.last >= 0x80
+ chars.reverse.pack('C*')
+ end
+
+ # Packs string components into an openssh-encoded pubkey.
+ def pack_pubkey_components(strings)
+ (strings.map { |s| [s.length].pack('N') }).zip(strings).flatten.join
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/note_interaction_helpers.rb b/spec/support/helpers/note_interaction_helpers.rb
new file mode 100644
index 00000000000..551c759133c
--- /dev/null
+++ b/spec/support/helpers/note_interaction_helpers.rb
@@ -0,0 +1,8 @@
+module NoteInteractionHelpers
+ def open_more_actions_dropdown(note)
+ note_element = find("#note_#{note.id}")
+
+ note_element.find('.more-actions').click
+ note_element.find('.more-actions .dropdown-menu li', match: :first)
+ end
+end
diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb
index 944ea30656f..2011408be93 100644
--- a/spec/support/import_export/export_file_helper.rb
+++ b/spec/support/import_export/export_file_helper.rb
@@ -6,11 +6,11 @@ module ExportFileHelper
ObjectWithParent = Struct.new(:object, :parent, :key_found)
def setup_project
- project = create(:project, :public)
+ project = create(:project, :public, :repository)
create(:release, project: project)
- issue = create(:issue, assignee: user, project: project)
+ issue = create(:issue, assignees: [user], project: project)
snippet = create(:project_snippet, project: project)
label = create(:label, project: project)
milestone = create(:milestone, project: project)
diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml
index 17136dee000..734d6838f4d 100644
--- a/spec/support/import_export/import_export.yml
+++ b/spec/support/import_export/import_export.yml
@@ -11,9 +11,6 @@ project_tree:
- :user
included_attributes:
- project:
- - :name
- - :path
merge_requests:
- :id
user:
@@ -21,4 +18,7 @@ included_attributes:
excluded_attributes:
merge_requests:
- - :iid \ No newline at end of file
+ - :iid
+ project:
+ - :id
+ - :created_at \ No newline at end of file
diff --git a/spec/support/import_spec_helper.rb b/spec/support/import_spec_helper.rb
index 6710962f082..d4eced724fa 100644
--- a/spec/support/import_spec_helper.rb
+++ b/spec/support/import_spec_helper.rb
@@ -28,6 +28,6 @@ module ImportSpecHelper
app_id: 'asd123',
app_secret: 'asd123'
)
- allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider])
+ stub_omniauth_setting(providers: [provider])
end
end
diff --git a/spec/support/issuable_shared_examples.rb b/spec/support/issuable_shared_examples.rb
new file mode 100644
index 00000000000..42f3b4db23c
--- /dev/null
+++ b/spec/support/issuable_shared_examples.rb
@@ -0,0 +1,38 @@
+shared_examples 'cache counters invalidator' do
+ it 'invalidates counter cache for assignees' do
+ expect_any_instance_of(User).to receive(:invalidate_merge_request_cache_counts)
+
+ described_class.new(project, user, {}).execute(merge_request)
+ end
+end
+
+shared_examples 'system notes for milestones' do
+ def update_issuable(opts)
+ issuable = try(:issue) || try(:merge_request)
+ described_class.new(project, user, opts).execute(issuable)
+ end
+
+ context 'group milestones' do
+ let(:group) { create(:group) }
+ let(:group_milestone) { create(:milestone, group: group) }
+
+ before do
+ project.update(namespace: group)
+ create(:group_member, group: group, user: user)
+ end
+
+ it 'creates a system note' do
+ expect do
+ update_issuable(milestone: group_milestone)
+ end.to change { Note.system.count }.by(1)
+ end
+ end
+
+ context 'project milestones' do
+ it 'creates a system note' do
+ expect do
+ update_issuable(milestone: create(:milestone))
+ end.to change { Note.system.count }.by(1)
+ end
+ end
+end
diff --git a/spec/support/issuables_list_metadata_shared_examples.rb b/spec/support/issuables_list_metadata_shared_examples.rb
index 3406e4c3161..75982432ab4 100644
--- a/spec/support/issuables_list_metadata_shared_examples.rb
+++ b/spec/support/issuables_list_metadata_shared_examples.rb
@@ -2,19 +2,15 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
before do
@issuable_ids = []
- 2.times do |n|
+ %w[fix improve/awesome].each do |source_branch|
issuable =
if issuable_type == :issue
create(issuable_type, project: project)
else
- create(issuable_type, source_project: project, source_branch: "#{n}-feature")
+ create(issuable_type, source_project: project, source_branch: source_branch)
end
@issuable_ids << issuable.id
-
- issuable.id.times { create(:note, noteable: issuable, project: issuable.project) }
- (issuable.id + 1).times { create(:award_emoji, :downvote, awardable: issuable) }
- (issuable.id + 2).times { create(:award_emoji, :upvote, awardable: issuable) }
end
end
@@ -27,15 +23,14 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
meta_data = assigns(:issuable_meta_data)
- @issuable_ids.each do |id|
- expect(meta_data[id].notes_count).to eq(id)
- expect(meta_data[id].downvotes).to eq(id + 1)
- expect(meta_data[id].upvotes).to eq(id + 2)
+ aggregate_failures do
+ expect(meta_data.keys).to match_array(@issuable_ids)
+ expect(meta_data.values).to all(be_kind_of(Issuable::IssuableMeta))
end
end
describe "when given empty collection" do
- let(:project2) { create(:empty_project, :public) }
+ let(:project2) { create(:project, :public) }
it "doesn't execute any queries with false conditions" do
get_action =
diff --git a/spec/support/issue_helpers.rb b/spec/support/issue_helpers.rb
index 85241793743..ffd72515f37 100644
--- a/spec/support/issue_helpers.rb
+++ b/spec/support/issue_helpers.rb
@@ -1,6 +1,6 @@
module IssueHelpers
def visit_issues(project, opts = {})
- visit namespace_project_issues_path project.namespace, project, opts
+ visit project_issues_path project, opts
end
def first_issue
diff --git a/spec/support/issue_tracker_service_shared_example.rb b/spec/support/issue_tracker_service_shared_example.rb
index e70b3963d9d..a6ab03cb808 100644
--- a/spec/support/issue_tracker_service_shared_example.rb
+++ b/spec/support/issue_tracker_service_shared_example.rb
@@ -8,15 +8,15 @@ end
RSpec.shared_examples 'allows project key on reference pattern' do |url_attr|
it 'allows underscores in the project name' do
- expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
+ expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
it 'allows numbers in the project name' do
- expect(subject.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
+ expect(described_class.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
end
it 'requires the project name to begin with A-Z' do
- expect(subject.reference_pattern.match('3EXT_EXT-1234')).to eq nil
- expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
+ expect(described_class.reference_pattern.match('3EXT_EXT-1234')).to eq nil
+ expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
end
diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb
index a982b159b48..aace4b3adee 100644
--- a/spec/support/javascript_fixtures_helpers.rb
+++ b/spec/support/javascript_fixtures_helpers.rb
@@ -48,7 +48,7 @@ module JavaScriptFixturesHelpers
link_tags = doc.css('link')
link_tags.remove
- scripts = doc.css("script:not([type='text/template'])")
+ scripts = doc.css("script:not([type='text/template']):not([type='text/x-template'])")
scripts.remove
fixture = doc.to_html
diff --git a/spec/support/jira_service_helper.rb b/spec/support/jira_service_helper.rb
index 97ae0b6afc5..0b5f66597fd 100644
--- a/spec/support/jira_service_helper.rb
+++ b/spec/support/jira_service_helper.rb
@@ -51,7 +51,7 @@ module JiraServiceHelper
end
def jira_project_url
- JIRA_API + "/project/#{jira_tracker.project_key}"
+ JIRA_API + "/project"
end
def jira_api_comment_url(issue_id)
diff --git a/spec/support/json_response_helpers.rb b/spec/support/json_response_helpers.rb
index e8d2ef2d7f0..aa235529c56 100644
--- a/spec/support/json_response_helpers.rb
+++ b/spec/support/json_response_helpers.rb
@@ -3,7 +3,7 @@ shared_context 'JSON response' do
end
RSpec.configure do |config|
- config.include_context 'JSON response', type: :controller
+ config.include_context 'JSON response'
config.include_context 'JSON response', type: :request
config.include_context 'JSON response', :api
end
diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb
index b5ed71ba3be..c92f78b324c 100644
--- a/spec/support/kubernetes_helpers.rb
+++ b/spec/support/kubernetes_helpers.rb
@@ -1,34 +1,55 @@
module KubernetesHelpers
include Gitlab::Kubernetes
- def kube_discovery_body
+ def kube_response(body)
+ { body: body.to_json }
+ end
+
+ def kube_pods_response
+ kube_response(kube_pods_body)
+ end
+
+ def stub_kubeclient_discover
+ WebMock.stub_request(:get, service.api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body))
+ end
+
+ def stub_kubeclient_pods(response = nil)
+ stub_kubeclient_discover
+ pods_url = service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods"
+
+ WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response)
+ end
+
+ def kube_v1_discovery_body
{
"kind" => "APIResourceList",
"resources" => [
- { "name" => "pods", "namespaced" => true, "kind" => "Pod" },
+ { "name" => "pods", "namespaced" => true, "kind" => "Pod" }
]
}
end
- def kube_pods_body(*pods)
- { "kind" => "PodList",
- "items" => [kube_pod] }
+ def kube_pods_body
+ {
+ "kind" => "PodList",
+ "items" => [kube_pod]
+ }
end
# This is a partial response, it will have many more elements in reality but
# these are the ones we care about at the moment
- def kube_pod(app: "valid-pod-label")
+ def kube_pod(name: "kube-pod", app: "valid-pod-label")
{
"metadata" => {
- "name" => "kube-pod",
+ "name" => name,
"creationTimestamp" => "2016-11-25T19:55:19Z",
- "labels" => { "app" => app },
+ "labels" => { "app" => app }
},
"spec" => {
"containers" => [
{ "name" => "container-0" },
- { "name" => "container-1" },
- ],
+ { "name" => "container-1" }
+ ]
},
"status" => { "phase" => "Running" }
}
@@ -41,7 +62,7 @@ module KubernetesHelpers
containers.map do |container|
terminal = {
selectors: { pod: pod_name, container: container['name'] },
- url: container_exec_url(service.api_url, service.namespace, pod_name, container['name']),
+ url: container_exec_url(service.api_url, service.actual_namespace, pod_name, container['name']),
subprotocols: ['channel.k8s.io'],
headers: { 'Authorization' => ["Bearer #{service.token}"] },
created_at: DateTime.parse(pod['metadata']['creationTimestamp']),
diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb
index e6da852e728..3e117530151 100644
--- a/spec/support/login_helpers.rb
+++ b/spec/support/login_helpers.rb
@@ -1,4 +1,8 @@
+require_relative 'devise_helpers'
+
module LoginHelpers
+ include DeviseHelpers
+
# Internal: Log in as a specific user or a new user of a specific role
#
# user_or_role - User object, or a role to create (e.g., :admin, :user)
@@ -6,42 +10,65 @@ module LoginHelpers
# Examples:
#
# # Create a user automatically
- # login_as(:user)
+ # gitlab_sign_in(:user)
#
# # Create an admin automatically
- # login_as(:admin)
+ # gitlab_sign_in(:admin)
#
# # Provide an existing User record
# user = create(:user)
- # login_as(user)
- def login_as(user_or_role)
- @user =
+ # gitlab_sign_in(user)
+ def gitlab_sign_in(user_or_role, **kwargs)
+ user =
if user_or_role.is_a?(User)
user_or_role
else
create(user_or_role)
end
- login_with(@user)
+ gitlab_sign_in_with(user, **kwargs)
+
+ user
+ end
+
+ def gitlab_sign_in_via(provider, user, uid)
+ mock_auth_hash(provider, uid, user.email)
+ visit new_user_session_path
+ click_link provider
+ end
+
+ # Requires Javascript driver.
+ def gitlab_sign_out
+ find(".header-user-dropdown-toggle").click
+ click_link "Sign out"
+
+ expect(page).to have_button('Sign in')
end
- # Internal: Login as the specified user
+ private
+
+ # Private: Login as the specified user
#
# user - User instance to login with
# remember - Whether or not to check "Remember me" (default: false)
- def login_with(user, remember: false)
+ def gitlab_sign_in_with(user, remember: false)
visit new_user_session_path
+
fill_in "user_login", with: user.email
fill_in "user_password", with: "12345678"
check 'user_remember_me' if remember
+
click_button "Sign in"
- Thread.current[:current_user] = user
end
- def login_via(provider, user, uid)
+ def login_via(provider, user, uid, remember_me: false)
mock_auth_hash(provider, uid, user.email)
visit new_user_session_path
- click_link provider
+ expect(page).to have_content('Sign in with')
+
+ check 'remember_me' if remember_me
+
+ click_link "oauth-login-#{provider}"
end
def mock_auth_hash(provider, uid, email)
@@ -72,16 +99,25 @@ module LoginHelpers
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:saml]
end
- # Requires Javascript driver.
- def logout
- find(".header-user-dropdown-toggle").click
- click_link "Sign out"
- # check the sign_in button
- expect(page).to have_button('Sign in')
+ def mock_saml_config
+ OpenStruct.new(name: 'saml', label: 'saml', args: {
+ assertion_consumer_service_url: 'https://localhost:3443/users/auth/saml/callback',
+ idp_cert_fingerprint: '26:43:2C:47:AF:F0:6B:D0:07:9C:AD:A3:74:FE:5D:94:5F:4E:9E:52',
+ idp_sso_target_url: 'https://idp.example.com/sso/saml',
+ issuer: 'https://localhost:3443/',
+ name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
+ })
end
- # Logout without JavaScript driver
- def logout_direct
- page.driver.submit :delete, '/users/sign_out', {}
+ def stub_omniauth_saml_config(messages)
+ set_devise_mapping(context: Rails.application)
+ Rails.application.routes.disable_clear_and_finalize = true
+ Rails.application.routes.draw do
+ post '/users/auth/saml' => 'omniauth_callbacks#saml'
+ end
+ allow(Gitlab::OAuth::Provider).to receive_messages(providers: [:saml], config_for: mock_saml_config)
+ stub_omniauth_setting(messages)
+ allow_any_instance_of(Object).to receive(:user_saml_omniauth_authorize_path).and_return('/users/auth/saml')
+ allow_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml')
end
end
diff --git a/spec/support/malicious_regexp_shared_examples.rb b/spec/support/malicious_regexp_shared_examples.rb
new file mode 100644
index 00000000000..ac5d22298bb
--- /dev/null
+++ b/spec/support/malicious_regexp_shared_examples.rb
@@ -0,0 +1,8 @@
+shared_examples 'malicious regexp' do
+ let(:malicious_text) { 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' }
+ let(:malicious_regexp) { '(?i)^(([a-z])+.)+[A-Z]([a-z])+$' }
+
+ it 'takes under a second' do
+ expect { Timeout.timeout(1) { subject } }.not_to raise_error
+ end
+end
diff --git a/spec/support/markdown_feature.rb b/spec/support/markdown_feature.rb
index 21a054af4e1..c90359d7cfa 100644
--- a/spec/support/markdown_feature.rb
+++ b/spec/support/markdown_feature.rb
@@ -23,7 +23,7 @@ class MarkdownFeature
# Direct references ----------------------------------------------------------
def project
- @project ||= create(:project, :repository).tap do |project|
+ @project ||= create(:project, :repository, group: group).tap do |project|
project.team << [user, :master]
end
end
@@ -75,6 +75,10 @@ class MarkdownFeature
@milestone ||= create(:milestone, name: 'next goal', project: project)
end
+ def group_milestone
+ @group_milestone ||= create(:milestone, name: 'group-milestone', group: group)
+ end
+
# Cross-references -----------------------------------------------------------
def xproject
diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb
index 7d238850520..3e4ca8b7ab0 100644
--- a/spec/support/matchers/access_matchers.rb
+++ b/spec/support/matchers/access_matchers.rb
@@ -51,7 +51,7 @@ module AccessMatchers
emulate_user(user, @membership)
visit(url)
- status_code != 404 && current_path != new_user_session_path
+ status_code == 200 && current_path != new_user_session_path
end
chain :of do |membership|
@@ -66,7 +66,7 @@ module AccessMatchers
emulate_user(user, @membership)
visit(url)
- status_code == 404 || current_path == new_user_session_path
+ [401, 404].include?(status_code) || current_path == new_user_session_path
end
chain :of do |membership|
diff --git a/spec/support/matchers/access_matchers_for_controller.rb b/spec/support/matchers/access_matchers_for_controller.rb
new file mode 100644
index 00000000000..bb6b7c63ee9
--- /dev/null
+++ b/spec/support/matchers/access_matchers_for_controller.rb
@@ -0,0 +1,108 @@
+# AccessMatchersForController
+#
+# For testing authorize_xxx in controller.
+module AccessMatchersForController
+ extend RSpec::Matchers::DSL
+ include Warden::Test::Helpers
+
+ EXPECTED_STATUS_CODE_ALLOWED = [200, 201, 302].freeze
+ EXPECTED_STATUS_CODE_DENIED = [401, 404].freeze
+
+ def emulate_user(role, membership = nil)
+ case role
+ when :admin
+ user = create(:admin)
+ sign_in(user)
+ when :user
+ user = create(:user)
+ sign_in(user)
+ when :external
+ user = create(:user, external: true)
+ sign_in(user)
+ when :visitor
+ user = nil
+ when User
+ user = role
+ sign_in(user)
+ when *Gitlab::Access.sym_options_with_owner.keys # owner, master, developer, reporter, guest
+ raise ArgumentError, "cannot emulate #{role} without membership parent" unless membership
+
+ user = create_user_by_membership(role, membership)
+ sign_in(user)
+ else
+ raise ArgumentError, "cannot emulate user #{role}"
+ end
+
+ user
+ end
+
+ def create_user_by_membership(role, membership)
+ if role == :owner && membership.owner
+ user = membership.owner
+ else
+ user = create(:user)
+ membership.public_send(:"add_#{role}", user)
+ end
+ user
+ end
+
+ def description_for(role, type, expected, result)
+ "be #{type} for #{role}. Expected: #{expected.join(',')} Got: #{result}"
+ end
+
+ def update_owner(objects, user)
+ return unless objects
+
+ objects.each do |object|
+ if object.respond_to?(:owner)
+ object.update_attribute(:owner, user)
+ elsif object.respond_to?(:user)
+ object.update_attribute(:user, user)
+ else
+ raise ArgumentError, "cannot own this object #{object}"
+ end
+ end
+ end
+
+ matcher :be_allowed_for do |role|
+ match do |action|
+ user = emulate_user(role, @membership)
+ update_owner(@objects, user)
+ action.call
+
+ EXPECTED_STATUS_CODE_ALLOWED.include?(response.status)
+ end
+
+ chain :of do |membership|
+ @membership = membership
+ end
+
+ chain :own do |*objects|
+ @objects = objects
+ end
+
+ description { description_for(role, 'allowed', EXPECTED_STATUS_CODE_ALLOWED, response.status) }
+ supports_block_expectations
+ end
+
+ matcher :be_denied_for do |role|
+ match do |action|
+ user = emulate_user(role, @membership)
+ update_owner(@objects, user)
+ action.call
+
+ EXPECTED_STATUS_CODE_DENIED.include?(response.status)
+ end
+
+ chain :of do |membership|
+ @membership = membership
+ end
+
+ chain :own do |*objects|
+ @objects = objects
+ end
+
+ description { description_for(role, 'denied', EXPECTED_STATUS_CODE_DENIED, response.status) }
+ supports_block_expectations
+ end
+end
diff --git a/spec/support/matchers/be_utf8.rb b/spec/support/matchers/be_utf8.rb
new file mode 100644
index 00000000000..ea806352422
--- /dev/null
+++ b/spec/support/matchers/be_utf8.rb
@@ -0,0 +1,9 @@
+RSpec::Matchers.define :be_utf8 do |_|
+ match do |actual|
+ actual.is_a?(String) && actual.encoding == Encoding.find('UTF-8')
+ end
+
+ description do
+ "be a String with encoding UTF-8"
+ end
+end
diff --git a/spec/support/matchers/execute_check.rb b/spec/support/matchers/execute_check.rb
new file mode 100644
index 00000000000..7232fad52fb
--- /dev/null
+++ b/spec/support/matchers/execute_check.rb
@@ -0,0 +1,23 @@
+RSpec::Matchers.define :execute_check do |expected|
+ match do |actual|
+ expect(actual).to eq(SystemCheck)
+ expect(actual).to receive(:run) do |*args|
+ expect(args[1]).to include(expected)
+ end
+ end
+
+ match_when_negated do |actual|
+ expect(actual).to eq(SystemCheck)
+ expect(actual).to receive(:run) do |*args|
+ expect(args[1]).not_to include(expected)
+ end
+ end
+
+ failure_message do |actual|
+ 'This matcher must be used with SystemCheck' unless actual == SystemCheck
+ end
+
+ failure_message_when_negated do |actual|
+ 'This matcher must be used with SystemCheck' unless actual == SystemCheck
+ end
+end
diff --git a/spec/support/matchers/gitaly_matchers.rb b/spec/support/matchers/gitaly_matchers.rb
index 65dbc01f6e4..ebfabcd8f24 100644
--- a/spec/support/matchers/gitaly_matchers.rb
+++ b/spec/support/matchers/gitaly_matchers.rb
@@ -1,3 +1,14 @@
-RSpec::Matchers.define :gitaly_request_with_repo_path do |path|
- match { |actual| actual.repository.path == path }
+RSpec::Matchers.define :gitaly_request_with_path do |storage_name, relative_path|
+ match do |actual|
+ repository = actual.repository
+
+ repository.storage_name == storage_name &&
+ repository.relative_path == relative_path
+ end
+end
+
+RSpec::Matchers.define :gitaly_request_with_params do |params|
+ match do |actual|
+ params.reduce(true) { |r, (key, val)| r && actual.send(key) == val }
+ end
end
diff --git a/spec/support/matchers/gitlab_git_matchers.rb b/spec/support/matchers/gitlab_git_matchers.rb
new file mode 100644
index 00000000000..c840cd4bf2d
--- /dev/null
+++ b/spec/support/matchers/gitlab_git_matchers.rb
@@ -0,0 +1,6 @@
+RSpec::Matchers.define :gitlab_git_repository_with do |values|
+ match do |actual|
+ actual.is_a?(Gitlab::Git::Repository) &&
+ values.all? { |k, v| actual.send(k) == v }
+ end
+end
diff --git a/spec/support/matchers/have_gitlab_http_status.rb b/spec/support/matchers/have_gitlab_http_status.rb
new file mode 100644
index 00000000000..3198f1b9edd
--- /dev/null
+++ b/spec/support/matchers/have_gitlab_http_status.rb
@@ -0,0 +1,14 @@
+RSpec::Matchers.define :have_gitlab_http_status do |expected|
+ match do |actual|
+ expect(actual).to have_http_status(expected)
+ end
+
+ description do
+ "respond with numeric status code #{expected}"
+ end
+
+ failure_message do |actual|
+ "expected the response to have status code #{expected.inspect}" \
+ " but it was #{actual.response_code}. The response was: #{actual.body}"
+ end
+end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index bbbbaf4c5e8..d12b2757427 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -17,7 +17,7 @@ module MarkdownMatchers
image = actual.at_css('img[alt="Relative Image"]')
expect(link['href']).to end_with('master/doc/README.md')
- expect(image['src']).to end_with('master/app/assets/images/touch-icon-ipad.png')
+ expect(image['data-src']).to end_with('master/app/assets/images/touch-icon-ipad.png')
end
end
@@ -70,7 +70,7 @@ module MarkdownMatchers
# GollumTagsFilter
matcher :parse_gollum_tags do
def have_image(src)
- have_css("img[src$='#{src}']")
+ have_css("img[data-src$='#{src}']")
end
prefix = '/namespace1/gitlabhq/wikis'
@@ -155,7 +155,7 @@ module MarkdownMatchers
set_default_markdown_messages
match do |actual|
- expect(actual).to have_selector('a.gfm.gfm-milestone', count: 6)
+ expect(actual).to have_selector('a.gfm.gfm-milestone', count: 8)
end
end
diff --git a/spec/support/matchers/query_matcher.rb b/spec/support/matchers/query_matcher.rb
index ac8c4ab91d9..bb0feca7c43 100644
--- a/spec/support/matchers/query_matcher.rb
+++ b/spec/support/matchers/query_matcher.rb
@@ -28,6 +28,6 @@ RSpec::Matchers.define :make_queries_matching do |matcher, expected_count = nil|
def query_count(regex, &block)
@recorder = ActiveRecord::QueryRecorder.new(&block).log
- @recorder.select{ |q| q.match(regex) }
+ @recorder.select { |q| q.match(regex) }
end
end
diff --git a/spec/support/mentionable_shared_examples.rb b/spec/support/mentionable_shared_examples.rb
index 87936bb4859..3ac201f1fb1 100644
--- a/spec/support/mentionable_shared_examples.rb
+++ b/spec/support/mentionable_shared_examples.rb
@@ -81,8 +81,8 @@ shared_examples 'a mentionable' do
ext_issue, ext_mr, ext_commit]
mentioned_objects.each do |referenced|
- expect(SystemNoteService).to receive(:cross_reference).
- with(referenced, subject.local_reference, author)
+ expect(SystemNoteService).to receive(:cross_reference)
+ .with(referenced, subject.local_reference, author)
end
subject.create_cross_references!
@@ -127,15 +127,15 @@ shared_examples 'an editable mentionable' do
# These three objects were already referenced, and should not receive new
# notes
[mentioned_issue, mentioned_commit, ext_issue].each do |oldref|
- expect(SystemNoteService).not_to receive(:cross_reference).
- with(oldref, any_args)
+ expect(SystemNoteService).not_to receive(:cross_reference)
+ .with(oldref, any_args)
end
# These two issues are new and should receive reference notes
# In the case of MergeRequests remember that cannot mention commits included in the MergeRequest
new_issues.each do |newref|
- expect(SystemNoteService).to receive(:cross_reference).
- with(newref, subject.local_reference, author)
+ expect(SystemNoteService).to receive(:cross_reference)
+ .with(newref, subject.local_reference, author)
end
set_mentionable_text.call(new_text)
diff --git a/spec/support/merge_request_helpers.rb b/spec/support/merge_request_helpers.rb
index 326b85eabd0..772adff4626 100644
--- a/spec/support/merge_request_helpers.rb
+++ b/spec/support/merge_request_helpers.rb
@@ -1,6 +1,6 @@
module MergeRequestHelpers
def visit_merge_requests(project, opts = {})
- visit namespace_project_merge_requests_path project.namespace, project, opts
+ visit project_merge_requests_path project, opts
end
def first_merge_request
diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb
new file mode 100644
index 00000000000..255b3d96a62
--- /dev/null
+++ b/spec/support/migrations_helpers.rb
@@ -0,0 +1,68 @@
+module MigrationsHelpers
+ def table(name)
+ Class.new(ActiveRecord::Base) { self.table_name = name }
+ end
+
+ def migrations_paths
+ ActiveRecord::Migrator.migrations_paths
+ end
+
+ def table_exists?(name)
+ ActiveRecord::Base.connection.table_exists?(name)
+ end
+
+ def migrations
+ ActiveRecord::Migrator.migrations(migrations_paths)
+ end
+
+ def reset_column_in_migration_models
+ described_class.constants.sort.each do |name|
+ const = described_class.const_get(name)
+
+ if const.is_a?(Class) && const < ActiveRecord::Base
+ const.reset_column_information
+ end
+ end
+ end
+
+ def previous_migration
+ migrations.each_cons(2) do |previous, migration|
+ break previous if migration.name == described_class.name
+ end
+ end
+
+ def migration_schema_version
+ self.class.metadata[:schema] || previous_migration.version
+ end
+
+ def schema_migrate_down!
+ disable_migrations_output do
+ ActiveRecord::Migrator.migrate(migrations_paths,
+ migration_schema_version)
+ end
+
+ reset_column_in_migration_models
+ end
+
+ def schema_migrate_up!
+ disable_migrations_output do
+ ActiveRecord::Migrator.migrate(migrations_paths)
+ end
+
+ reset_column_in_migration_models
+ end
+
+ def disable_migrations_output
+ ActiveRecord::Migration.verbose = false
+
+ yield
+ ensure
+ ActiveRecord::Migration.verbose = true
+ end
+
+ def migrate!
+ ActiveRecord::Migrator.up(migrations_paths) do |migration|
+ migration.name == described_class.name
+ end
+ end
+end
diff --git a/spec/support/milestone_tabs_examples.rb b/spec/support/milestone_tabs_examples.rb
new file mode 100644
index 00000000000..70b499198bf
--- /dev/null
+++ b/spec/support/milestone_tabs_examples.rb
@@ -0,0 +1,84 @@
+shared_examples 'milestone tabs' do
+ def go(path, extra_params = {})
+ params =
+ case milestone
+ when DashboardMilestone
+ { id: milestone.safe_title, title: milestone.title }
+ when GroupMilestone
+ { group_id: group.to_param, id: milestone.safe_title, title: milestone.title }
+ else
+ { namespace_id: project.namespace.to_param, project_id: project, id: milestone.iid }
+ end
+
+ get path, params.merge(extra_params)
+ end
+
+ describe '#merge_requests' do
+ context 'as html' do
+ before do
+ go(:merge_requests, format: 'html')
+ end
+
+ it 'redirects to milestone#show' do
+ expect(response).to redirect_to(milestone_path)
+ end
+ end
+
+ context 'as json' do
+ before do
+ go(:merge_requests, format: 'json')
+ end
+
+ it 'renders the merge requests tab template to a string' do
+ expect(response).to render_template('shared/milestones/_merge_requests_tab')
+ expect(json_response).to have_key('html')
+ end
+ end
+ end
+
+ describe '#participants' do
+ context 'as html' do
+ before do
+ go(:participants, format: 'html')
+ end
+
+ it 'redirects to milestone#show' do
+ expect(response).to redirect_to(milestone_path)
+ end
+ end
+
+ context 'as json' do
+ before do
+ go(:participants, format: 'json')
+ end
+
+ it 'renders the participants tab template to a string' do
+ expect(response).to render_template('shared/milestones/_participants_tab')
+ expect(json_response).to have_key('html')
+ end
+ end
+ end
+
+ describe '#labels' do
+ context 'as html' do
+ before do
+ go(:labels, format: 'html')
+ end
+
+ it 'redirects to milestone#show' do
+ expect(response).to redirect_to(milestone_path)
+ end
+ end
+
+ context 'as json' do
+ before do
+ go(:labels, format: 'json')
+ end
+
+ it 'renders the labels tab template to a string' do
+ expect(response).to render_template('shared/milestones/_labels_tab')
+ expect(json_response).to have_key('html')
+ end
+ end
+ end
+end
diff --git a/spec/support/notify_shared_examples.rb b/spec/support/notify_shared_examples.rb
index 76411065265..136f92c6419 100644
--- a/spec/support/notify_shared_examples.rb
+++ b/spec/support/notify_shared_examples.rb
@@ -3,11 +3,10 @@ shared_context 'gitlab email notification' do
let(:gitlab_sender) { Gitlab.config.gitlab.email_from }
let(:gitlab_sender_reply_to) { Gitlab.config.gitlab.email_reply_to }
let(:recipient) { create(:user, email: 'recipient@example.com') }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:new_user_address) { 'newguy@example.com' }
before do
- reset_delivered_emails!
email = recipient.emails.create(email: "notifications@example.com")
recipient.update_attribute(:notification_email, email.email)
stub_incoming_email_setting(enabled: true, address: "reply+%{key}@#{Gitlab.config.gitlab.host}")
@@ -50,7 +49,7 @@ shared_examples 'an email with X-GitLab headers containing project details' do
aggregate_failures do
is_expected.to have_header('X-GitLab-Project', /#{project.name}/)
is_expected.to have_header('X-GitLab-Project-Id', /#{project.id}/)
- is_expected.to have_header('X-GitLab-Project-Path', /#{project.path_with_namespace}/)
+ is_expected.to have_header('X-GitLab-Project-Path', /#{project.full_path}/)
end
end
end
diff --git a/spec/support/prepare-gitlab-git-test-for-commit b/spec/support/prepare-gitlab-git-test-for-commit
new file mode 100755
index 00000000000..3047786a599
--- /dev/null
+++ b/spec/support/prepare-gitlab-git-test-for-commit
@@ -0,0 +1,17 @@
+#!/usr/bin/env ruby
+
+abort unless [
+ system('spec/support/generate-seed-repo-rb', out: 'spec/support/seed_repo.rb'),
+ system('spec/support/unpack-gitlab-git-test')
+].all?
+
+exit if ARGV.first != '--check-for-changes'
+
+git_status = IO.popen(%w[git status --porcelain], &:read)
+abort unless $?.success?
+
+puts git_status
+
+if git_status.lines.grep(%r{^.. spec/support/gitlab-git-test.git}).any?
+ abort "error: detected changes in gitlab-git-test.git"
+end
diff --git a/spec/support/project_features_apply_to_issuables_shared_examples.rb b/spec/support/project_features_apply_to_issuables_shared_examples.rb
index f8b7d0527ba..639b0924197 100644
--- a/spec/support/project_features_apply_to_issuables_shared_examples.rb
+++ b/spec/support/project_features_apply_to_issuables_shared_examples.rb
@@ -5,7 +5,7 @@ shared_examples 'project features apply to issuables' do |klass|
let(:user_in_group) { create(:group_member, :developer, user: create(:user), group: group ).user }
let(:user_outside_group) { create(:user) }
- let(:project) { create(:empty_project, :public, project_args) }
+ let(:project) { create(:project, :public, project_args) }
def project_args
feature = "#{described_class.model_name.plural}_access_level".to_sym
@@ -18,7 +18,7 @@ shared_examples 'project features apply to issuables' do |klass|
before do
_ = issuable
- login_as(user) if user
+ gitlab_sign_in(user) if user
visit path
end
diff --git a/spec/support/project_hook_data_shared_example.rb b/spec/support/project_hook_data_shared_example.rb
index 7dbaa6a6459..1eb405d4be8 100644
--- a/spec/support/project_hook_data_shared_example.rb
+++ b/spec/support/project_hook_data_shared_example.rb
@@ -8,7 +8,7 @@ RSpec.shared_examples 'project hook data with deprecateds' do |project_key: :pro
expect(data[project_key][:git_ssh_url]).to eq(project.ssh_url_to_repo)
expect(data[project_key][:namespace]).to eq(project.namespace.name)
expect(data[project_key][:visibility_level]).to eq(project.visibility_level)
- expect(data[project_key][:path_with_namespace]).to eq(project.path_with_namespace)
+ expect(data[project_key][:path_with_namespace]).to eq(project.full_path)
expect(data[project_key][:default_branch]).to eq(project.default_branch)
expect(data[project_key][:homepage]).to eq(project.web_url)
expect(data[project_key][:url]).to eq(project.url_to_repo)
@@ -27,7 +27,7 @@ RSpec.shared_examples 'project hook data' do |project_key: :project|
expect(data[project_key][:git_ssh_url]).to eq(project.ssh_url_to_repo)
expect(data[project_key][:namespace]).to eq(project.namespace.name)
expect(data[project_key][:visibility_level]).to eq(project.visibility_level)
- expect(data[project_key][:path_with_namespace]).to eq(project.path_with_namespace)
+ expect(data[project_key][:path_with_namespace]).to eq(project.full_path)
expect(data[project_key][:default_branch]).to eq(project.default_branch)
end
end
diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb
new file mode 100644
index 00000000000..620fa37d455
--- /dev/null
+++ b/spec/support/prometheus/additional_metrics_shared_examples.rb
@@ -0,0 +1,152 @@
+RSpec.shared_examples 'additional metrics query' do
+ include Prometheus::MetricBuilders
+
+ let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
+ let(:metric_class) { Gitlab::Prometheus::Metric }
+
+ let(:metric_names) { %w{metric_a metric_b} }
+
+ let(:query_range_result) do
+ [{ 'metric': {}, 'values': [[1488758662.506, '0.00002996364761904785'], [1488758722.506, '0.00003090239047619091']] }]
+ end
+
+ let(:client) { double('prometheus_client') }
+ let(:query_result) { described_class.new(client).query(*query_params) }
+ let(:environment) { create(:environment, slug: 'environment-slug') }
+
+ before do
+ allow(client).to receive(:label_values).and_return(metric_names)
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group(metrics: [simple_metric])])
+ end
+
+ context 'metrics query context' do
+ subject! { described_class.new(client) }
+
+ shared_examples 'query context containing environment slug and filter' do
+ it 'contains ci_environment_slug' do
+ expect(subject).to receive(:query_metrics).with(hash_including(ci_environment_slug: environment.slug))
+
+ subject.query(*query_params)
+ end
+
+ it 'contains environment filter' do
+ expect(subject).to receive(:query_metrics).with(
+ hash_including(
+ environment_filter: "container_name!=\"POD\",environment=\"#{environment.slug}\""
+ )
+ )
+
+ subject.query(*query_params)
+ end
+ end
+
+ describe 'project has Kubernetes service' do
+ let(:project) { create(:kubernetes_project) }
+ let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
+ let(:kube_namespace) { project.kubernetes_service.actual_namespace }
+
+ it_behaves_like 'query context containing environment slug and filter'
+
+ it 'query context contains kube_namespace' do
+ expect(subject).to receive(:query_metrics).with(hash_including(kube_namespace: kube_namespace))
+
+ subject.query(*query_params)
+ end
+ end
+
+ describe 'project without Kubernetes service' do
+ it_behaves_like 'query context containing environment slug and filter'
+
+ it 'query context contains empty kube_namespace' do
+ expect(subject).to receive(:query_metrics).with(hash_including(kube_namespace: ''))
+
+ subject.query(*query_params)
+ end
+ end
+ end
+
+ context 'with one group where two metrics is found' do
+ before do
+ allow(metric_group_class).to receive(:all).and_return([simple_metric_group])
+ end
+
+ context 'some queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_empty', any_args).and_return([])
+ end
+
+ it 'return group data only for queries with results' do
+ expected = [
+ {
+ group: 'name',
+ priority: 1,
+ metrics: [
+ {
+ title: 'title', weight: 1, y_label: 'Values', queries: [
+ { query_range: 'query_range_a', result: query_range_result },
+ { query_range: 'query_range_b', label: 'label', unit: 'unit', result: query_range_result }
+ ]
+ }
+ ]
+ }
+ ]
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+ expect(query_result).to eq(expected)
+ end
+ end
+ end
+
+ context 'with two groups with one metric each' do
+ let(:metrics) { [simple_metric(queries: [simple_query])] }
+
+ before do
+ allow(metric_group_class).to receive(:all).and_return(
+ [
+ simple_metric_group(name: 'group_a', metrics: [simple_metric(queries: [simple_query])]),
+ simple_metric_group(name: 'group_b', metrics: [simple_metric(title: 'title_b', queries: [simple_query('b')])])
+ ])
+ allow(client).to receive(:label_values).and_return(metric_names)
+ end
+
+ context 'both queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ end
+
+ it 'return group data both queries' do
+ queries_with_result_a = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+ queries_with_result_b = { queries: [{ query_range: 'query_range_b', result: query_range_result }] }
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(2)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result[0][:metrics].first).to include(queries_with_result_a)
+ expect(query_result[1][:metrics].first).to include(queries_with_result_b)
+ end
+ end
+
+ context 'one query returns result' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return([])
+ end
+
+ it 'return group data only for query with results' do
+ queries_with_result = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+
+ expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(1)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result.first[:metrics].first).to include(queries_with_result)
+ end
+ end
+ end
+end
diff --git a/spec/support/prometheus/metric_builders.rb b/spec/support/prometheus/metric_builders.rb
new file mode 100644
index 00000000000..c8d056d3fc8
--- /dev/null
+++ b/spec/support/prometheus/metric_builders.rb
@@ -0,0 +1,27 @@
+module Prometheus
+ module MetricBuilders
+ def simple_query(suffix = 'a', **opts)
+ { query_range: "query_range_#{suffix}" }.merge(opts)
+ end
+
+ def simple_queries
+ [simple_query, simple_query('b', label: 'label', unit: 'unit')]
+ end
+
+ def simple_metric(title: 'title', required_metrics: [], queries: [simple_query])
+ Gitlab::Prometheus::Metric.new(title: title, required_metrics: required_metrics, weight: 1, queries: queries)
+ end
+
+ def simple_metrics(added_metric_name: 'metric_a')
+ [
+ simple_metric(required_metrics: %W(#{added_metric_name} metric_b), queries: simple_queries),
+ simple_metric(required_metrics: [added_metric_name], queries: [simple_query('empty')]),
+ simple_metric(required_metrics: %w{metric_c})
+ ]
+ end
+
+ def simple_metric_group(name: 'name', metrics: simple_metrics)
+ Gitlab::Prometheus::MetricGroup.new(name: name, priority: 1, metrics: metrics)
+ end
+ end
+end
diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb
index cc79b11616a..4212be2cc88 100644
--- a/spec/support/prometheus_helpers.rb
+++ b/spec/support/prometheus_helpers.rb
@@ -1,10 +1,16 @@
module PrometheusHelpers
def prometheus_memory_query(environment_slug)
- %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024}
+ %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20}
end
def prometheus_cpu_query(environment_slug)
- %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100}
+ %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
+ end
+
+ def prometheus_ping_url(prometheus_query)
+ query = { query: prometheus_query }.to_query
+
+ "https://prometheus.example.com/api/v1/query?#{query}"
end
def prometheus_query_url(prometheus_query)
@@ -13,17 +19,36 @@ module PrometheusHelpers
"https://prometheus.example.com/api/v1/query?#{query}"
end
- def prometheus_query_range_url(prometheus_query, start: 8.hours.ago)
+ def prometheus_query_with_time_url(prometheus_query, time)
+ query = { query: prometheus_query, time: time.to_f }.to_query
+
+ "https://prometheus.example.com/api/v1/query?#{query}"
+ end
+
+ def prometheus_query_range_url(prometheus_query, start: 8.hours.ago, stop: Time.now.to_f)
query = {
query: prometheus_query,
start: start.to_f,
- end: Time.now.utc.to_f,
+ end: stop,
step: 1.minute.to_i
}.to_query
"https://prometheus.example.com/api/v1/query_range?#{query}"
end
+ def prometheus_label_values_url(name)
+ "https://prometheus.example.com/api/v1/label/#{name}/values"
+ end
+
+ def prometheus_series_url(*matches, start: 8.hours.ago, stop: Time.now)
+ query = {
+ match: matches,
+ start: start.to_f,
+ end: stop.to_f
+ }.to_query
+ "https://prometheus.example.com/api/v1/series?#{query}"
+ end
+
def stub_prometheus_request(url, body: {}, status: 200)
WebMock.stub_request(:get, url)
.to_return({
@@ -33,9 +58,18 @@ module PrometheusHelpers
})
end
+ def stub_prometheus_request_with_exception(url, exception_type)
+ WebMock.stub_request(:get, url).to_raise(exception_type)
+ end
+
def stub_all_prometheus_requests(environment_slug, body: nil, status: 200)
stub_prometheus_request(
- prometheus_query_url(prometheus_memory_query(environment_slug)),
+ prometheus_query_with_time_url(prometheus_memory_query(environment_slug), Time.now.utc),
+ status: status,
+ body: body || prometheus_value_body
+ )
+ stub_prometheus_request(
+ prometheus_query_with_time_url(prometheus_memory_query(environment_slug), 8.hours.ago),
status: status,
body: body || prometheus_value_body
)
@@ -45,7 +79,12 @@ module PrometheusHelpers
body: body || prometheus_values_body
)
stub_prometheus_request(
- prometheus_query_url(prometheus_cpu_query(environment_slug)),
+ prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), Time.now.utc),
+ status: status,
+ body: body || prometheus_value_body
+ )
+ stub_prometheus_request(
+ prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), 8.hours.ago),
status: status,
body: body || prometheus_value_body
)
@@ -59,6 +98,19 @@ module PrometheusHelpers
def prometheus_data(last_update: Time.now.utc)
{
success: true,
+ data: {
+ memory_values: prometheus_values_body('matrix').dig(:data, :result),
+ memory_current: prometheus_value_body('vector').dig(:data, :result),
+ cpu_values: prometheus_values_body('matrix').dig(:data, :result),
+ cpu_current: prometheus_value_body('vector').dig(:data, :result)
+ },
+ last_update: last_update
+ }
+ end
+
+ def prometheus_metrics_data(last_update: Time.now.utc)
+ {
+ success: true,
metrics: {
memory_values: prometheus_values_body('matrix').dig(:data, :result),
memory_current: prometheus_value_body('vector').dig(:data, :result),
@@ -114,4 +166,37 @@ module PrometheusHelpers
}
}
end
+
+ def prometheus_label_values
+ {
+ 'status': 'success',
+ 'data': %w(job_adds job_controller_rate_limiter_use job_depth job_queue_latency job_work_duration_sum up)
+ }
+ end
+
+ def prometheus_series(name)
+ {
+ 'status': 'success',
+ 'data': [
+ {
+ '__name__': name,
+ 'container_name': 'gitlab',
+ 'environment': 'mattermost',
+ 'id': '/docker/9953982f95cf5010dfc59d7864564d5f188aaecddeda343699783009f89db667',
+ 'image': 'gitlab/gitlab-ce:8.15.4-ce.1',
+ 'instance': 'minikube',
+ 'job': 'kubernetes-nodes',
+ 'name': 'k8s_gitlab.e6611886_mattermost-4210310111-77z8r_gitlab_2298ae6b-da24-11e6-baee-8e7f67d0eb3a_43536cb6',
+ 'namespace': 'gitlab',
+ 'pod_name': 'mattermost-4210310111-77z8r'
+ },
+ {
+ '__name__': name,
+ 'id': '/docker',
+ 'instance': 'minikube',
+ 'job': 'kubernetes-nodes'
+ }
+ ]
+ }
+ end
end
diff --git a/spec/support/protected_tags/access_control_ce_shared_examples.rb b/spec/support/protected_tags/access_control_ce_shared_examples.rb
new file mode 100644
index 00000000000..421a51fc336
--- /dev/null
+++ b/spec/support/protected_tags/access_control_ce_shared_examples.rb
@@ -0,0 +1,47 @@
+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 project_protected_tags_path(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.trigger('click')
+ find('.create_access_levels-container .dropdown-menu li', match: :first)
+ within('.create_access_levels-container .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 project_protected_tags_path(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_requests
+
+ expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+end
diff --git a/spec/support/quick_actions_helpers.rb b/spec/support/quick_actions_helpers.rb
new file mode 100644
index 00000000000..d2aaae7518f
--- /dev/null
+++ b/spec/support/quick_actions_helpers.rb
@@ -0,0 +1,10 @@
+module QuickActionsHelpers
+ def write_note(text)
+ Sidekiq::Testing.fake! do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: text
+ find('.js-comment-submit-button').trigger('click')
+ end
+ end
+ end
+end
diff --git a/spec/support/rake_helpers.rb b/spec/support/rake_helpers.rb
index 4a8158ed79b..86bfeed107c 100644
--- a/spec/support/rake_helpers.rb
+++ b/spec/support/rake_helpers.rb
@@ -5,6 +5,15 @@ module RakeHelpers
end
def stub_warn_user_is_not_gitlab
- allow_any_instance_of(Object).to receive(:warn_user_is_not_gitlab)
+ allow(main_object).to receive(:warn_user_is_not_gitlab)
+ end
+
+ def silence_output
+ allow(main_object).to receive(:puts)
+ allow(main_object).to receive(:print)
+ end
+
+ def main_object
+ @main_object ||= TOPLEVEL_BINDING.eval('self')
end
end
diff --git a/spec/support/reactive_caching_helpers.rb b/spec/support/reactive_caching_helpers.rb
index 98eb57f8b54..34124f02133 100644
--- a/spec/support/reactive_caching_helpers.rb
+++ b/spec/support/reactive_caching_helpers.rb
@@ -35,8 +35,8 @@ module ReactiveCachingHelpers
end
def expect_reactive_cache_update_queued(subject)
- expect(ReactiveCachingWorker).
- to receive(:perform_in).
- with(subject.class.reactive_cache_refresh_interval, subject.class, subject.id)
+ expect(ReactiveCachingWorker)
+ .to receive(:perform_in)
+ .with(subject.class.reactive_cache_refresh_interval, subject.class, subject.id)
end
end
diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb
new file mode 100644
index 00000000000..8676f895a83
--- /dev/null
+++ b/spec/support/redis/redis_shared_examples.rb
@@ -0,0 +1,214 @@
+RSpec.shared_examples "redis_shared_examples" do
+ include StubENV
+
+ let(:test_redis_url) { "redis://redishost:#{redis_port}"}
+
+ before do
+ stub_env(environment_config_file_name, Rails.root.join(config_file_name))
+ clear_raw_config
+ end
+
+ after do
+ clear_raw_config
+ end
+
+ describe '.params' do
+ subject { described_class.params }
+
+ it 'withstands mutation' do
+ params1 = described_class.params
+ params2 = described_class.params
+ params1[:foo] = :bar
+
+ expect(params2).not_to have_key(:foo)
+ end
+
+ context 'when url contains unix socket reference' do
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_socket }
+
+ it 'returns path key instead' do
+ is_expected.to include(path: old_socket_path)
+ is_expected.not_to have_key(:url)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_socket }
+
+ it 'returns path key instead' do
+ is_expected.to include(path: new_socket_path)
+ is_expected.not_to have_key(:url)
+ end
+ end
+ end
+
+ context 'when url is host based' do
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns hash with host, port, db, and password' do
+ is_expected.to include(host: 'localhost', password: 'mypassword', port: redis_port, db: redis_database)
+ is_expected.not_to have_key(:url)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns hash with host, port, db, and password' do
+ is_expected.to include(host: 'localhost', password: 'mynewpassword', port: redis_port, db: redis_database)
+ is_expected.not_to have_key(:url)
+ end
+ end
+ end
+ end
+
+ describe '.url' do
+ context 'when yml file with env variable' do
+ let(:config_file_name) { config_with_environment_variable_inside }
+
+ before do
+ stub_env(config_env_variable_url, test_redis_url)
+ end
+
+ it 'reads redis url from env variable' do
+ expect(described_class.url).to eq test_redis_url
+ end
+ end
+ end
+
+ describe '._raw_config' do
+ subject { described_class._raw_config }
+ let(:config_file_name) { '/var/empty/doesnotexist' }
+
+ it 'should be frozen' do
+ expect(subject).to be_frozen
+ end
+
+ it 'returns false when the file does not exist' do
+ expect(subject).to eq(false)
+ end
+
+ it "returns false when the filename can't be determined" do
+ expect(described_class).to receive(:config_file_name).and_return(nil)
+
+ expect(subject).to eq(false)
+ end
+ end
+
+ describe '.with' do
+ before do
+ clear_pool
+ end
+
+ after do
+ clear_pool
+ end
+
+ context 'when running not on sidekiq workers' do
+ before do
+ allow(Sidekiq).to receive(:server?).and_return(false)
+ end
+
+ it 'instantiates a connection pool with size 5' do
+ expect(ConnectionPool).to receive(:new).with(size: 5).and_call_original
+
+ described_class.with { |_redis_shared_example| true }
+ end
+ end
+
+ context 'when running on sidekiq workers' do
+ before do
+ allow(Sidekiq).to receive(:server?).and_return(true)
+ allow(Sidekiq).to receive(:options).and_return({ concurrency: 18 })
+ end
+
+ it 'instantiates a connection pool with a size based on the concurrency of the worker' do
+ expect(ConnectionPool).to receive(:new).with(size: 18 + 5).and_call_original
+
+ described_class.with { |_redis_shared_example| true }
+ end
+ end
+ end
+
+ describe '#sentinels' do
+ subject { described_class.new(Rails.env).sentinels }
+
+ context 'when sentinels are defined' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns an array of hashes with host and port keys' do
+ is_expected.to include(host: 'localhost', port: sentinel_port)
+ is_expected.to include(host: 'slave2', port: sentinel_port)
+ end
+ end
+
+ context 'when sentinels are not defined' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#sentinels?' do
+ subject { described_class.new(Rails.env).sentinels? }
+
+ context 'when sentinels are defined' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when sentinels are not defined' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+
+ describe '#raw_config_hash' do
+ it 'returns default redis url when no config file is present' do
+ expect(subject).to receive(:fetch_config) { false }
+
+ expect(subject.send(:raw_config_hash)).to eq(url: class_redis_url )
+ end
+
+ it 'returns old-style single url config in a hash' do
+ expect(subject).to receive(:fetch_config) { test_redis_url }
+ expect(subject.send(:raw_config_hash)).to eq(url: test_redis_url)
+ end
+ end
+
+ describe '#fetch_config' do
+ it 'returns false when no config file is present' do
+ allow(described_class).to receive(:_raw_config) { false }
+
+ expect(subject.send(:fetch_config)).to eq false
+ end
+
+ it 'returns false when config file is present but has invalid YAML' do
+ allow(described_class).to receive(:_raw_config) { "# development: true" }
+
+ expect(subject.send(:fetch_config)).to eq false
+ end
+ end
+
+ def clear_raw_config
+ described_class.remove_instance_variable(:@_raw_config)
+ rescue NameError
+ # raised if @_raw_config was not set; ignore
+ end
+
+ def clear_pool
+ described_class.remove_instance_variable(:@pool)
+ rescue NameError
+ # raised if @pool was not set; ignore
+ end
+end
diff --git a/spec/support/reference_parser_shared_examples.rb b/spec/support/reference_parser_shared_examples.rb
index 8eb74635a60..bd83cb88058 100644
--- a/spec/support/reference_parser_shared_examples.rb
+++ b/spec/support/reference_parser_shared_examples.rb
@@ -3,7 +3,9 @@ RSpec.shared_examples "referenced feature visibility" do |*related_features|
related_features.map { |feature| (feature + "_access_level").to_sym }
end
- before { link['data-project'] = project.id.to_s }
+ before do
+ link['data-project'] = project.id.to_s
+ end
context "when feature is disabled" do
it "does not create reference" do
@@ -13,7 +15,9 @@ RSpec.shared_examples "referenced feature visibility" do |*related_features|
end
context "when feature is enabled only for team members" do
- before { set_features_fields_to(ProjectFeature::PRIVATE) }
+ before do
+ set_features_fields_to(ProjectFeature::PRIVATE)
+ end
it "does not create reference for non member" do
non_member = create(:user)
diff --git a/spec/support/repo_helpers.rb b/spec/support/repo_helpers.rb
index e9d5c7b12ae..3c6956cf5e0 100644
--- a/spec/support/repo_helpers.rb
+++ b/spec/support/repo_helpers.rb
@@ -92,11 +92,11 @@ eos
changes = [
{
line_code: 'a5cc2925ca8258af241be7e5b0381edf30266302_20_20',
- file_path: '.gitignore',
+ file_path: '.gitignore'
},
{
line_code: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_6',
- file_path: '.gitmodules',
+ file_path: '.gitmodules'
}
]
diff --git a/spec/support/routing_helpers.rb b/spec/support/routing_helpers.rb
new file mode 100644
index 00000000000..af1f4760804
--- /dev/null
+++ b/spec/support/routing_helpers.rb
@@ -0,0 +1,3 @@
+RSpec.configure do |config|
+ config.include GitlabRoutingHelper
+end
diff --git a/spec/support/seed_helper.rb b/spec/support/seed_helper.rb
index 47b5f556e66..8731847592b 100644
--- a/spec/support/seed_helper.rb
+++ b/spec/support/seed_helper.rb
@@ -9,7 +9,7 @@ TEST_MUTABLE_REPO_PATH = 'mutable-repo.git'.freeze
TEST_BROKEN_REPO_PATH = 'broken-repo.git'.freeze
module SeedHelper
- GITLAB_GIT_TEST_REPO_URL = ENV.fetch('GITLAB_GIT_TEST_REPO_URL', 'https://gitlab.com/gitlab-org/gitlab-git-test.git').freeze
+ GITLAB_GIT_TEST_REPO_URL = File.expand_path('../gitlab-git-test.git', __FILE__).freeze
def ensure_seeds
if File.exist?(SEED_STORAGE_PATH)
diff --git a/spec/support/seed_repo.rb b/spec/support/seed_repo.rb
index 99a500bbbb1..b4868e82cd7 100644
--- a/spec/support/seed_repo.rb
+++ b/spec/support/seed_repo.rb
@@ -1,4 +1,8 @@
+# This file is generated by generate-seed-repo-rb. Do not edit this file manually.
+#
# Seed repo:
+# 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6 Merge branch 'master' into 'master'
+# 0e1b353b348f8477bdbec1ef47087171c5032cd9 adds an executable with different permissions
# 0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326 Merge branch 'lfs_pointers' into 'master'
# 33bcff41c232a11727ac6d660bd4b0c2ba86d63d Add valid and invalid lfs pointers
# 732401c65e924df81435deb12891ef570167d2e2 Update year in license file
@@ -93,8 +97,14 @@ module SeedRepo
gitattributes-updated
master
merge-test
+ missing-gitmodules
+ ].freeze
+ TAGS = %w[
+ v1.0.0
+ v1.1.0
+ v1.2.0
+ v1.2.1
].freeze
- TAGS = %w[v1.0.0 v1.1.0 v1.2.0 v1.2.1].freeze
end
module RubyBlob
diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb
deleted file mode 100644
index 4f0c745b7ee..00000000000
--- a/spec/support/services/issuable_create_service_shared_examples.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-shared_examples 'issuable create service' do
- context 'asssignee_id' do
- let(:assignee) { create(:user) }
-
- before { project.team << [user, :master] }
-
- it 'removes assignee_id when user id is invalid' do
- opts = { title: 'Title', description: 'Description', assignee_id: -1 }
-
- issuable = described_class.new(project, user, opts).execute
-
- expect(issuable.assignee_id).to be_nil
- end
-
- it 'removes assignee_id when user id is 0' do
- opts = { title: 'Title', description: 'Description', assignee_id: 0 }
-
- issuable = described_class.new(project, user, opts).execute
-
- expect(issuable.assignee_id).to be_nil
- end
-
- it 'saves assignee when user id is valid' do
- project.team << [assignee, :master]
- opts = { title: 'Title', description: 'Description', assignee_id: assignee.id }
-
- issuable = described_class.new(project, user, opts).execute
-
- expect(issuable.assignee_id).to eq(assignee.id)
- end
-
- context "when issuable feature is private" do
- before do
- project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE,
- merge_requests_access_level: ProjectFeature::PRIVATE)
- end
-
- levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
-
- levels.each do |level|
- it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
- project.update(visibility_level: level)
- opts = { title: 'Title', description: 'Description', assignee_id: assignee.id }
-
- issuable = described_class.new(project, user, opts).execute
-
- expect(issuable.assignee_id).to be_nil
- end
- end
- end
- end
-end
diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb
index 9e9cdf3e48b..9399745f900 100644
--- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb
+++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb
@@ -1,7 +1,7 @@
# Specifications for behavior common to all objects with executable attributes.
# It can take a `default_params`.
-shared_examples 'new issuable record that supports slash commands' do
+shared_examples 'new issuable record that supports quick actions' do
let!(:project) { create(:project, :repository) }
let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
let(:assignee) { create(:user) }
@@ -11,7 +11,9 @@ shared_examples 'new issuable record that supports slash commands' do
let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) }
let(:issuable) { described_class.new(project, user, params).execute }
- before { project.team << [assignee, :master] }
+ before do
+ project.team << [assignee, :master]
+ end
context 'with labels in command only' do
let(:example_params) do
@@ -49,23 +51,7 @@ shared_examples 'new issuable record that supports slash commands' do
it 'assigns and sets milestone to issuable' do
expect(issuable).to be_persisted
- expect(issuable.assignee).to eq(assignee)
- expect(issuable.milestone).to eq(milestone)
- end
- end
-
- context 'with assignee and milestone in params and command' do
- let(:example_params) do
- {
- assignee: create(:user),
- milestone_id: 1,
- description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
- }
- end
-
- it 'assigns and sets milestone to issuable from command' do
- expect(issuable).to be_persisted
- expect(issuable.assignee).to eq(assignee)
+ expect(issuable.assignees).to eq([assignee])
expect(issuable.milestone).to eq(milestone)
end
end
diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb
index 49cea1e608c..ffbce6c42bf 100644
--- a/spec/support/services/issuable_update_service_shared_examples.rb
+++ b/spec/support/services/issuable_update_service_shared_examples.rb
@@ -4,7 +4,9 @@ shared_examples 'issuable update service' do
end
context 'changing state' do
- before { expect(project).to receive(:execute_hooks).once }
+ before do
+ expect(project).to receive(:execute_hooks).once
+ end
context 'to reopened' do
it 'executes hooks only once' do
@@ -18,52 +20,4 @@ shared_examples 'issuable update service' do
end
end
end
-
- context 'asssignee_id' do
- it 'does not update assignee when assignee_id is invalid' do
- open_issuable.update(assignee_id: user.id)
-
- update_issuable(assignee_id: -1)
-
- expect(open_issuable.reload.assignee).to eq(user)
- end
-
- it 'unassigns assignee when user id is 0' do
- open_issuable.update(assignee_id: user.id)
-
- update_issuable(assignee_id: 0)
-
- expect(open_issuable.assignee_id).to be_nil
- end
-
- it 'saves assignee when user id is valid' do
- update_issuable(assignee_id: user.id)
-
- expect(open_issuable.assignee_id).to eq(user.id)
- end
-
- it 'does not update assignee_id when user cannot read issue' do
- non_member = create(:user)
- original_assignee = open_issuable.assignee
-
- update_issuable(assignee_id: non_member.id)
-
- expect(open_issuable.assignee_id).to eq(original_assignee.id)
- end
-
- context "when issuable feature is private" do
- levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
-
- levels.each do |level|
- it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
- assignee = create(:user)
- project.update(visibility_level: level)
- feature_visibility_attr = :"#{open_issuable.model_name.plural}_access_level"
- project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE)
-
- expect{ update_issuable(assignee_id: assignee) }.not_to change{ open_issuable.assignee }
- end
- end
- end
- end
end
diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
index dcc562c684b..adfd256dff1 100644
--- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
+++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
@@ -1,9 +1,16 @@
require "spec_helper"
-shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class|
+shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class, fields|
record_class_name = record_class.to_s.titleize.downcase
- let(:project) { create(:project) }
+ let(:project) do
+ case record_class
+ when MergeRequest
+ create(:project, :repository)
+ else
+ create(:project)
+ end
+ end
before do
project.add_developer(user)
@@ -11,6 +18,7 @@ shared_examples "migrating a deleted user's associated records to the ghost user
context "for a #{record_class_name} the user has created" do
let!(:record) { created_record }
+ let(:migrated_fields) { fields || [:author] }
it "does not delete the #{record_class_name}" do
service.execute
@@ -18,22 +26,20 @@ shared_examples "migrating a deleted user's associated records to the ghost user
expect(record_class.find_by_id(record.id)).to be_present
end
- it "migrates the #{record_class_name} so that the 'Ghost User' is the #{record_class_name} owner" do
+ it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do
service.execute
- migrated_record = record_class.find_by_id(record.id)
-
- if migrated_record.respond_to?(:author)
- expect(migrated_record.author).to eq(User.ghost)
- else
- expect(migrated_record.send(author_alias)).to eq(User.ghost)
- end
+ expect(user).to be_blocked
end
- it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do
+ it 'migrates all associated fields to te "Ghost user"' do
service.execute
- expect(user).to be_blocked
+ migrated_record = record_class.find_by_id(record.id)
+
+ migrated_fields.each do |field|
+ expect(migrated_record.public_send(field)).to eq(User.ghost)
+ end
end
context "race conditions" do
diff --git a/spec/support/services_shared_context.rb b/spec/support/services_shared_context.rb
index 66c93890e31..7457484a932 100644
--- a/spec/support/services_shared_context.rb
+++ b/spec/support/services_shared_context.rb
@@ -6,9 +6,9 @@ Service.available_services_names.each do |service|
let(:service_fields) { service_klass.new.fields }
let(:service_attrs_list) { service_fields.inject([]) {|arr, hash| arr << hash[:name].to_sym } }
let(:service_attrs_list_without_passwords) do
- service_fields.
- select { |field| field[:type] != 'password' }.
- map { |field| field[:name].to_sym}
+ service_fields
+ .select { |field| field[:type] != 'password' }
+ .map { |field| field[:name].to_sym}
end
let(:service_attrs) do
service_attrs_list.inject({}) do |hash, k|
diff --git a/spec/support/shared_examples/features/issuable_sidebar_shared_examples.rb b/spec/support/shared_examples/features/issuable_sidebar_shared_examples.rb
new file mode 100644
index 00000000000..96c821b26f7
--- /dev/null
+++ b/spec/support/shared_examples/features/issuable_sidebar_shared_examples.rb
@@ -0,0 +1,9 @@
+shared_examples 'issue sidebar stays collapsed on mobile' do
+ before do
+ resize_screen_xs
+ end
+
+ it 'keeps the sidebar collapsed' do
+ expect(page).not_to have_css('.right-sidebar.right-sidebar-collapsed')
+ end
+end
diff --git a/spec/support/shared_examples/features/protected_branches_access_control_ce.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb
new file mode 100644
index 00000000000..d5bc12f3bc5
--- /dev/null
+++ b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb
@@ -0,0 +1,91 @@
+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 project_protected_branches_path(project)
+
+ set_protected_branch_name('master')
+
+ within('.js-new-protected-branch') do
+ allowed_to_push_button = find(".js-allowed-to-push")
+
+ unless allowed_to_push_button.text == access_type_name
+ allowed_to_push_button.trigger('click')
+ within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ end
+ end
+
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id])
+ end
+
+ it "allows updating protected branches so that #{access_type_name} can push to them" do
+ visit project_protected_branches_path(project)
+
+ set_protected_branch_name('master')
+
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+
+ within(".protected-branches-list") do
+ find(".js-allowed-to-push").click
+
+ within('.js-allowed-to-push-container') do
+ expect(first("li")).to have_content("Roles")
+ click_on access_type_name
+ end
+ end
+
+ wait_for_requests
+
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+
+ 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 project_protected_branches_path(project)
+
+ set_protected_branch_name('master')
+
+ within('.js-new-protected-branch') do
+ allowed_to_merge_button = find(".js-allowed-to-merge")
+
+ unless allowed_to_merge_button.text == access_type_name
+ allowed_to_merge_button.click
+ within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ end
+ end
+
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
+ end
+
+ it "allows updating protected branches so that #{access_type_name} can merge to them" do
+ visit project_protected_branches_path(project)
+
+ set_protected_branch_name('master')
+
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+
+ within(".protected-branches-list") do
+ find(".js-allowed-to-merge").click
+
+ within('.js-allowed-to-merge-container') do
+ expect(first("li")).to have_content("Roles")
+ click_on access_type_name
+ end
+ end
+
+ wait_for_requests
+
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb
new file mode 100644
index 00000000000..226277411d6
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb
@@ -0,0 +1,42 @@
+# Specs for status checking.
+#
+# Requires an API request:
+# let(:request) { get api("/projects/#{project.id}/repository/branches", user) }
+shared_examples_for '400 response' do
+ before do
+ # Fires the request
+ request
+ end
+
+ it 'returns 400' do
+ expect(response).to have_gitlab_http_status(400)
+ end
+end
+
+shared_examples_for '403 response' do
+ before do
+ # Fires the request
+ request
+ end
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(403)
+ end
+end
+
+shared_examples_for '404 response' do
+ let(:message) { nil }
+ before do
+ # Fires the request
+ request
+ end
+
+ it 'returns 404' do
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response).to be_an Object
+
+ if message.present?
+ expect(json_response['message']).to eq(message)
+ end
+ end
+end
diff --git a/spec/support/sidekiq.rb b/spec/support/sidekiq.rb
index 575d3451150..d143014692d 100644
--- a/spec/support/sidekiq.rb
+++ b/spec/support/sidekiq.rb
@@ -3,3 +3,13 @@ require 'sidekiq/testing/inline'
Sidekiq::Testing.server_middleware do |chain|
chain.add Gitlab::SidekiqStatus::ServerMiddleware
end
+
+RSpec.configure do |config|
+ config.after(:each, :sidekiq) do
+ Sidekiq::Worker.clear_all
+ end
+
+ config.after(:each, :sidekiq, :redis) do
+ Sidekiq.redis { |redis| redis.flushdb }
+ end
+end
diff --git a/spec/support/slack_mattermost_notifications_shared_examples.rb b/spec/support/slack_mattermost_notifications_shared_examples.rb
index b902fe90707..6accf16bea4 100644
--- a/spec/support/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/slack_mattermost_notifications_shared_examples.rb
@@ -11,14 +11,18 @@ RSpec.shared_examples 'slack or mattermost notifications' do
describe 'Validations' do
context 'when service is active' do
- before { subject.active = true }
+ before do
+ subject.active = true
+ end
it { is_expected.to validate_presence_of(:webhook) }
it_behaves_like 'issue tracker service URL attribute', :webhook
end
context 'when service is inactive' do
- before { subject.active = false }
+ before do
+ subject.active = false
+ end
it { is_expected.not_to validate_presence_of(:webhook) }
end
@@ -74,7 +78,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
wiki_page_service = WikiPages::CreateService.new(project, user, opts)
@wiki_page = wiki_page_service.execute
- @wiki_page_sample_data = wiki_page_service.hook_data(@wiki_page, 'create')
+ @wiki_page_sample_data = Gitlab::DataBuilder::WikiPage.build(@wiki_page, user, 'create')
end
it "calls Slack/Mattermost API for push events" do
@@ -104,9 +108,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it 'uses the username as an option for slack when configured' do
allow(chat_service).to receive(:username).and_return(username)
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, username: username).
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, username: username)
+ .and_return(
double(:slack_service).as_null_object
)
@@ -115,9 +119,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it 'uses the channel as an option when it is configured' do
allow(chat_service).to receive(:channel).and_return(channel)
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: channel).
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: channel)
+ .and_return(
double(:slack_service).as_null_object
)
chat_service.execute(push_sample_data)
@@ -127,9 +131,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it "uses the right channel for push event" do
chat_service.update_attributes(push_channel: "random")
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: "random").
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: "random")
+ .and_return(
double(:slack_service).as_null_object
)
@@ -139,9 +143,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it "uses the right channel for merge request event" do
chat_service.update_attributes(merge_request_channel: "random")
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: "random").
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: "random")
+ .and_return(
double(:slack_service).as_null_object
)
@@ -151,9 +155,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it "uses the right channel for issue event" do
chat_service.update_attributes(issue_channel: "random")
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: "random").
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: "random")
+ .and_return(
double(:slack_service).as_null_object
)
@@ -163,9 +167,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
it "uses the right channel for wiki event" do
chat_service.update_attributes(wiki_page_channel: "random")
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: "random").
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: "random")
+ .and_return(
double(:slack_service).as_null_object
)
@@ -182,9 +186,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
- expect(Slack::Notifier).to receive(:new).
- with(webhook_url, channel: "random").
- and_return(
+ expect(Slack::Notifier).to receive(:new)
+ .with(webhook_url, channel: "random")
+ .and_return(
double(:slack_service).as_null_object
)
@@ -328,7 +332,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
context 'only notify for the default branch' do
context 'when enabled' do
let(:pipeline) do
- create(:ci_pipeline, project: project, status: 'failed', ref: 'not-the-default-branch')
+ create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch')
end
before do
@@ -342,6 +346,18 @@ RSpec.shared_examples 'slack or mattermost notifications' do
expect(result).to be_falsy
end
end
+
+ context 'when disabled' do
+ let(:pipeline) do
+ create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch')
+ end
+
+ before do
+ chat_service.notify_only_default_branch = false
+ end
+
+ it_behaves_like 'call Slack/Mattermost API'
+ end
end
end
end
diff --git a/spec/support/slash_commands_helpers.rb b/spec/support/slash_commands_helpers.rb
deleted file mode 100644
index 4bfe481115f..00000000000
--- a/spec/support/slash_commands_helpers.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-module SlashCommandsHelpers
- def write_note(text)
- Sidekiq::Testing.fake! do
- page.within('.js-main-target-form') do
- fill_in 'note[note]', with: text
- find('.js-comment-submit-button').trigger('click')
- end
- end
- end
-end
diff --git a/spec/support/snippets_shared_examples.rb b/spec/support/snippets_shared_examples.rb
index 57dfff3471f..85f0facd5c3 100644
--- a/spec/support/snippets_shared_examples.rb
+++ b/spec/support/snippets_shared_examples.rb
@@ -7,7 +7,7 @@ RSpec.shared_examples 'paginated snippets' do |remote: false|
context 'clicking on the link to the second page' do
before do
click_link('2')
- wait_for_ajax if remote
+ wait_for_requests if remote
end
it 'shows the remaining snippets' do
diff --git a/spec/support/sorting_helper.rb b/spec/support/sorting_helper.rb
new file mode 100644
index 00000000000..577518d726c
--- /dev/null
+++ b/spec/support/sorting_helper.rb
@@ -0,0 +1,18 @@
+# Helper allows you to sort items
+#
+# Params
+# value - value for sorting
+#
+# Usage:
+# include SortingHelper
+#
+# sorting_by('Oldest updated')
+#
+module SortingHelper
+ def sorting_by(value)
+ find('button.dropdown-toggle').click
+ page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do
+ click_link value
+ end
+ end
+end
diff --git a/spec/support/stored_repositories.rb b/spec/support/stored_repositories.rb
index df18926d58c..f3deae0f455 100644
--- a/spec/support/stored_repositories.rb
+++ b/spec/support/stored_repositories.rb
@@ -2,4 +2,16 @@ RSpec.configure do |config|
config.before(:each, :repository) do
TestEnv.clean_test_path
end
+
+ config.before(:all, :broken_storage) do
+ FileUtils.rm_rf Gitlab.config.repositories.storages.broken['path']
+ end
+
+ config.before(:each, :broken_storage) do
+ allow(Gitlab::GitalyClient).to receive(:call) do
+ raise GRPC::Unavailable.new('Gitaly broken in this spec')
+ end
+
+ Gitlab::Git::Storage::CircuitBreaker.reset_all!
+ end
end
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 444adcc1906..45c10e78789 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -4,25 +4,52 @@ module StubConfiguration
# Stubbing both of these because we're not yet consistent with how we access
# current application settings
- allow_any_instance_of(ApplicationSetting).to receive_messages(messages)
- allow(Gitlab::CurrentSettings.current_application_settings).
- to receive_messages(messages)
+ allow_any_instance_of(ApplicationSetting).to receive_messages(to_settings(messages))
+ allow(Gitlab::CurrentSettings.current_application_settings)
+ .to receive_messages(to_settings(messages))
+ end
+
+ def stub_not_protect_default_branch
+ stub_application_setting(
+ default_branch_protection: Gitlab::Access::PROTECTION_NONE)
end
def stub_config_setting(messages)
- allow(Gitlab.config.gitlab).to receive_messages(messages)
+ allow(Gitlab.config.gitlab).to receive_messages(to_settings(messages))
end
def stub_gravatar_setting(messages)
- allow(Gitlab.config.gravatar).to receive_messages(messages)
+ allow(Gitlab.config.gravatar).to receive_messages(to_settings(messages))
end
def stub_incoming_email_setting(messages)
- allow(Gitlab.config.incoming_email).to receive_messages(messages)
+ allow(Gitlab.config.incoming_email).to receive_messages(to_settings(messages))
end
def stub_mattermost_setting(messages)
- allow(Gitlab.config.mattermost).to receive_messages(messages)
+ allow(Gitlab.config.mattermost).to receive_messages(to_settings(messages))
+ end
+
+ def stub_omniauth_setting(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(to_settings(messages))
+ end
+
+ def stub_backup_setting(messages)
+ allow(Gitlab.config.backup).to receive_messages(to_settings(messages))
+ end
+
+ def stub_storage_settings(messages)
+ # Default storage is always required
+ messages['default'] ||= Gitlab.config.repositories.storages.default
+ messages.each do |storage_name, storage_settings|
+ storage_settings['path'] ||= TestEnv.repos_path
+ storage_settings['failure_count_threshold'] ||= 10
+ storage_settings['failure_wait_time'] ||= 30
+ storage_settings['failure_reset_time'] ||= 1800
+ storage_settings['storage_timeout'] ||= 5
+ end
+
+ allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
end
private
@@ -45,4 +72,15 @@ module StubConfiguration
messages[predicate.to_sym] = messages[key.to_sym]
end
end
+
+ # Support nested hashes by converting all values into Settingslogic objects
+ def to_settings(hash)
+ hash.transform_values do |value|
+ if value.is_a? Hash
+ Settingslogic.new(value.deep_stringify_keys)
+ else
+ value
+ end
+ end
+ end
end
diff --git a/spec/support/stub_env.rb b/spec/support/stub_env.rb
index 18597b5c71f..b8928867174 100644
--- a/spec/support/stub_env.rb
+++ b/spec/support/stub_env.rb
@@ -1,7 +1,33 @@
+# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb
module StubENV
- def stub_env(key, value)
- allow(ENV).to receive(:[]).and_call_original unless @env_already_stubbed
- @env_already_stubbed ||= true
+ def stub_env(key_or_hash, value = nil)
+ init_stub unless env_stubbed?
+ if key_or_hash.is_a? Hash
+ key_or_hash.each { |k, v| add_stubbed_value(k, v) }
+ else
+ add_stubbed_value key_or_hash, value
+ end
+ end
+
+ private
+
+ STUBBED_KEY = '__STUBBED__'.freeze
+
+ def add_stubbed_value(key, value)
allow(ENV).to receive(:[]).with(key).and_return(value)
+ allow(ENV).to receive(:fetch).with(key).and_return(value)
+ allow(ENV).to receive(:fetch).with(key, anything()) do |_, default_val|
+ value || default_val
+ end
+ end
+
+ def env_stubbed?
+ ENV[STUBBED_KEY]
+ end
+
+ def init_stub
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:fetch).and_call_original
+ add_stubbed_value(STUBBED_KEY, true)
end
end
diff --git a/spec/support/stub_feature_flags.rb b/spec/support/stub_feature_flags.rb
new file mode 100644
index 00000000000..b96338bf548
--- /dev/null
+++ b/spec/support/stub_feature_flags.rb
@@ -0,0 +1,8 @@
+module StubFeatureFlags
+ def stub_feature_flags(features)
+ features.each do |feature_name, enabled|
+ allow(Feature).to receive(:enabled?).with(feature_name) { enabled }
+ allow(Feature).to receive(:enabled?).with(feature_name.to_s) { enabled }
+ end
+ end
+end
diff --git a/spec/support/stub_gitlab_calls.rb b/spec/support/stub_gitlab_calls.rb
index ded2d593059..78a2ff73746 100644
--- a/spec/support/stub_gitlab_calls.rb
+++ b/spec/support/stub_gitlab_calls.rb
@@ -68,22 +68,22 @@ module StubGitlabCalls
def stub_session
f = File.read(Rails.root.join('spec/support/gitlab_stubs/session.json'))
- stub_request(:post, "#{gitlab_url}api/v3/session.json").
- with(body: "{\"email\":\"test@test.com\",\"password\":\"123456\"}",
- headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 201, body: f, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:post, "#{gitlab_url}api/v3/session.json")
+ .with(body: "{\"email\":\"test@test.com\",\"password\":\"123456\"}",
+ headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 201, body: f, headers: { 'Content-Type' => 'application/json' })
end
def stub_user
f = File.read(Rails.root.join('spec/support/gitlab_stubs/user.json'))
- stub_request(:get, "#{gitlab_url}api/v3/user?private_token=Wvjy2Krpb7y8xi93owUz").
- with(headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:get, "#{gitlab_url}api/v3/user?private_token=Wvjy2Krpb7y8xi93owUz")
+ .with(headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
- stub_request(:get, "#{gitlab_url}api/v3/user?access_token=some_token").
- with(headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:get, "#{gitlab_url}api/v3/user?access_token=some_token")
+ .with(headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
end
def stub_project_8
@@ -99,21 +99,21 @@ module StubGitlabCalls
def stub_projects
f = File.read(Rails.root.join('spec/support/gitlab_stubs/projects.json'))
- stub_request(:get, "#{gitlab_url}api/v3/projects.json?archived=false&ci_enabled_first=true&private_token=Wvjy2Krpb7y8xi93owUz").
- with(headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:get, "#{gitlab_url}api/v3/projects.json?archived=false&ci_enabled_first=true&private_token=Wvjy2Krpb7y8xi93owUz")
+ .with(headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: f, headers: { 'Content-Type' => 'application/json' })
end
def stub_projects_owned
- stub_request(:get, "#{gitlab_url}api/v3/projects/owned.json?archived=false&ci_enabled_first=true&private_token=Wvjy2Krpb7y8xi93owUz").
- with(headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 200, body: "", headers: {})
+ stub_request(:get, "#{gitlab_url}api/v3/projects/owned.json?archived=false&ci_enabled_first=true&private_token=Wvjy2Krpb7y8xi93owUz")
+ .with(headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: "", headers: {})
end
def stub_ci_enable
- stub_request(:put, "#{gitlab_url}api/v3/projects/2/services/gitlab-ci.json?private_token=Wvjy2Krpb7y8xi93owUz").
- with(headers: { 'Content-Type' => 'application/json' }).
- to_return(status: 200, body: "", headers: {})
+ stub_request(:put, "#{gitlab_url}api/v3/projects/2/services/gitlab-ci.json?private_token=Wvjy2Krpb7y8xi93owUz")
+ .with(headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: "", headers: {})
end
def project_hash_array
diff --git a/spec/support/target_branch_helpers.rb b/spec/support/target_branch_helpers.rb
deleted file mode 100644
index 3ee8f0f657e..00000000000
--- a/spec/support/target_branch_helpers.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-module TargetBranchHelpers
- def select_branch(name)
- first('button.js-target-branch').click
- wait_for_ajax
- all('a[data-group="Branches"]').find do |el|
- el.text == name
- end.click
- end
-
- def create_new_branch(name)
- first('button.js-target-branch').click
- click_link 'Create new branch'
- fill_in 'new_branch_name', with: name
- click_button 'Create'
- end
-end
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 5c8ee8d62f5..1e39f80699c 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -5,6 +5,7 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = {
+ 'signed-commits' => '5d4a1cb',
'not-merged-branch' => 'b83d6e3',
'branch-merged' => '498214d',
'empty-branch' => '7efb185',
@@ -27,6 +28,7 @@ module TestEnv
'expand-collapse-files' => '025db92',
'expand-collapse-lines' => '238e82d',
'video' => '8879059',
+ 'add-balsamiq-file' => 'b89b56d',
'crlf-diff' => '5938907',
'conflict-start' => '824be60',
'conflict-resolvable' => '1450cd6',
@@ -39,7 +41,9 @@ module TestEnv
'wip' => 'b9238ee',
'csv' => '3dd0896',
'v1.1.0' => 'b83d6e3',
- 'add-ipython-files' => '6d85bb69'
+ 'add-ipython-files' => '93ee732',
+ 'add-pdf-file' => 'e774ebd',
+ 'add-pdf-text-binary' => '79faa7b'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
@@ -52,6 +56,8 @@ module TestEnv
'conflict-resolvable-fork' => '404fa3f'
}.freeze
+ TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**')
+
# Test environment
#
# See gitlab.yml.example test section for paths
@@ -65,7 +71,7 @@ module TestEnv
# Setup GitLab shell for test instance
setup_gitlab_shell
- setup_gitaly if Gitlab::GitalyClient.enabled?
+ setup_gitaly
# Create repository for FactoryGirl.create(:project)
setup_factory_repo
@@ -79,13 +85,13 @@ module TestEnv
end
def disable_mailer
- allow_any_instance_of(NotificationService).to receive(:mailer).
- and_return(double.as_null_object)
+ allow_any_instance_of(NotificationService).to receive(:mailer)
+ .and_return(double.as_null_object)
end
def enable_mailer
- allow_any_instance_of(NotificationService).to receive(:mailer).
- and_call_original
+ allow_any_instance_of(NotificationService).to receive(:mailer)
+ .and_call_original
end
def disable_pre_receive
@@ -96,9 +102,7 @@ module TestEnv
#
# Keeps gitlab-shell and gitlab-test
def clean_test_path
- tmp_test_path = Rails.root.join('tmp', 'tests', '**')
-
- Dir[tmp_test_path].each do |entry|
+ Dir[TMP_TEST_PATH].each do |entry|
unless File.basename(entry) =~ /\A(gitaly|gitlab-(shell|test|test_bare|test-fork|test-fork_bare))\z/
FileUtils.rm_rf(entry)
end
@@ -109,29 +113,69 @@ module TestEnv
FileUtils.mkdir_p(pages_path)
end
- def setup_gitlab_shell
- unless File.directory?(Gitlab.config.gitlab_shell.path)
- unless system('rake', 'gitlab:shell:install')
- raise 'Can`t clone gitlab-shell'
+ def clean_gitlab_test_path
+ Dir[TMP_TEST_PATH].each do |entry|
+ if File.basename(entry) =~ /\A(gitlab-(test|test_bare|test-fork|test-fork_bare))\z/
+ FileUtils.rm_rf(entry)
end
end
end
+ def setup_gitlab_shell
+ puts "\n==> Setting up Gitlab Shell..."
+ start = Time.now
+ gitlab_shell_dir = Gitlab.config.gitlab_shell.path
+ shell_needs_update = component_needs_update?(gitlab_shell_dir,
+ Gitlab::Shell.version_required)
+
+ unless !shell_needs_update || system('rake', 'gitlab:shell:install')
+ puts "\nGitLab Shell failed to install, cleaning up #{gitlab_shell_dir}!\n"
+ FileUtils.rm_rf(gitlab_shell_dir)
+ exit 1
+ end
+
+ puts " GitLab Shell setup in #{Time.now - start} seconds...\n"
+ end
+
def setup_gitaly
- socket_path = Gitlab::GitalyClient.get_address('default').sub(/\Aunix:/, '')
+ puts "\n==> Setting up Gitaly..."
+ start = Time.now
+ socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '')
gitaly_dir = File.dirname(socket_path)
- unless File.directory?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]")
- raise "Can't clone gitaly"
+ if gitaly_dir_stale?(gitaly_dir)
+ puts " Gitaly is outdated, cleaning up #{gitaly_dir}!"
+ FileUtils.rm_rf(gitaly_dir)
+ end
+
+ gitaly_needs_update = component_needs_update?(gitaly_dir,
+ Gitlab::GitalyClient.expected_server_version)
+
+ unless !gitaly_needs_update || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]")
+ puts "\nGitaly failed to install, cleaning up #{gitaly_dir}!\n"
+ FileUtils.rm_rf(gitaly_dir)
+ exit 1
end
start_gitaly(gitaly_dir)
+ puts " Gitaly setup in #{Time.now - start} seconds...\n"
+ end
+
+ def gitaly_dir_stale?(dir)
+ gitaly_executable = File.join(dir, 'gitaly')
+ return false unless File.exist?(gitaly_executable)
+
+ File.mtime(gitaly_executable) < File.mtime(Rails.root.join('GITALY_SERVER_VERSION'))
end
def start_gitaly(gitaly_dir)
- gitaly_exec = File.join(gitaly_dir, 'gitaly')
- gitaly_config = File.join(gitaly_dir, 'config.toml')
- @gitaly_pid = spawn(gitaly_exec, gitaly_config, [:out, :err] => '/dev/null')
+ if ENV['CI'].present?
+ # Gitaly has been spawned outside this process already
+ return
+ end
+
+ spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s
+ @gitaly_pid = Bundler.with_original_env { IO.popen([spawn_script], &:read).to_i }
end
def stop_gitaly
@@ -152,14 +196,14 @@ module TestEnv
FORKED_BRANCH_SHA)
end
- def setup_repo(repo_path, repo_path_bare, repo_name, branch_sha)
+ def setup_repo(repo_path, repo_path_bare, repo_name, refs)
clone_url = "https://gitlab.com/gitlab-org/#{repo_name}.git"
unless File.directory?(repo_path)
system(*%W(#{Gitlab.config.git.bin_path} clone -q #{clone_url} #{repo_path}))
end
- set_repo_refs(repo_path, branch_sha)
+ set_repo_refs(repo_path, refs)
unless File.directory?(repo_path_bare)
# We must copy bare repositories because we will push to them.
@@ -167,13 +211,12 @@ module TestEnv
end
end
- def copy_repo(project)
- base_repo_path = File.expand_path(factory_repo_path_bare)
+ def copy_repo(project, bare_repo:, refs:)
target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git")
FileUtils.mkdir_p(target_repo_path)
- FileUtils.cp_r("#{base_repo_path}/.", target_repo_path)
+ FileUtils.cp_r("#{File.expand_path(bare_repo)}/.", target_repo_path)
FileUtils.chmod_R 0755, target_repo_path
- set_repo_refs(target_repo_path, BRANCH_SHA)
+ set_repo_refs(target_repo_path, refs)
end
def repos_path
@@ -188,15 +231,6 @@ module TestEnv
Gitlab.config.pages.path
end
- def copy_forked_repo_with_submodules(project)
- base_repo_path = File.expand_path(forked_repo_path_bare)
- target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git")
- FileUtils.mkdir_p(target_repo_path)
- FileUtils.cp_r("#{base_repo_path}/.", target_repo_path)
- FileUtils.chmod_R 0755, target_repo_path
- set_repo_refs(target_repo_path, FORKED_BRANCH_SHA)
- end
-
# When no cached assets exist, manually hit the root path to create them
#
# Otherwise they'd be created by the first test, often timing out and
@@ -208,16 +242,28 @@ module TestEnv
Capybara.current_session.visit '/'
end
+ def factory_repo_path_bare
+ "#{factory_repo_path}_bare"
+ end
+
+ def forked_repo_path_bare
+ "#{forked_repo_path}_bare"
+ end
+
+ def with_empty_bare_repository(name = nil)
+ path = Rails.root.join('tmp/tests', name || 'empty-bare-repository').to_s
+
+ yield(Rugged::Repository.init_at(path, :bare))
+ ensure
+ FileUtils.rm_rf(path)
+ end
+
private
def factory_repo_path
@factory_repo_path ||= Rails.root.join('tmp', 'tests', factory_repo_name)
end
- def factory_repo_path_bare
- "#{factory_repo_path}_bare"
- end
-
def factory_repo_name
'gitlab-test'
end
@@ -226,10 +272,6 @@ module TestEnv
@forked_repo_path ||= Rails.root.join('tmp', 'tests', forked_repo_name)
end
- def forked_repo_path_bare
- "#{forked_repo_path}_bare"
- end
-
def forked_repo_name
'gitlab-test-fork'
end
@@ -241,19 +283,33 @@ module TestEnv
end
def set_repo_refs(repo_path, branch_sha)
- instructions = branch_sha.map {|branch, sha| "update refs/heads/#{branch}\x00#{sha}\x00" }.join("\x00") << "\x00"
+ instructions = branch_sha.map { |branch, sha| "update refs/heads/#{branch}\x00#{sha}\x00" }.join("\x00") << "\x00"
update_refs = %W(#{Gitlab.config.git.bin_path} update-ref --stdin -z)
reset = proc do
- IO.popen(update_refs, "w") {|io| io.write(instructions) }
- $?.success?
+ Dir.chdir(repo_path) do
+ IO.popen(update_refs, "w") { |io| io.write(instructions) }
+ $?.success?
+ end
end
- Dir.chdir(repo_path) do
- # Try to reset without fetching to avoid using the network.
- unless reset.call
- raise 'Could not fetch test seed repository.' unless system(*%W(#{Gitlab.config.git.bin_path} fetch origin))
- raise 'The fetched test seed does not contain the required revision.' unless reset.call
- end
+ # Try to reset without fetching to avoid using the network.
+ unless reset.call
+ raise 'Could not fetch test seed repository.' unless system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} fetch origin))
+
+ # Before we used Git clone's --mirror option, bare repos could end up
+ # with missing refs, clearing them and retrying should fix the issue.
+ cleanup && clean_gitlab_test_path && init unless reset.call
end
end
+
+ def component_needs_update?(component_folder, expected_version)
+ version = File.read(File.join(component_folder, 'VERSION')).strip
+
+ # Notice that this will always yield true when using branch versions
+ # (`=branch_name`), but that actually makes sure the server is always based
+ # on the latest branch revision.
+ version != expected_version
+ rescue Errno::ENOENT
+ true
+ end
end
diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb
index 01bc80f957e..0fa74f911f6 100644
--- a/spec/support/time_tracking_shared_examples.rb
+++ b/spec/support/time_tracking_shared_examples.rb
@@ -8,6 +8,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when estimate is added' do
submit_time('/estimate 3w 1d 1h')
+ wait_for_requests
page.within '.time-tracking-estimate-only-pane' do
expect(page).to have_content '3w 1d 1h'
end
@@ -16,6 +17,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when spent is added' do
submit_time('/spend 3w 1d 1h')
+ wait_for_requests
page.within '.time-tracking-spend-only-pane' do
expect(page).to have_content '3w 1d 1h'
end
@@ -25,6 +27,7 @@ shared_examples 'issuable time tracker' do
submit_time('/estimate 3w 1d 1h')
submit_time('/spend 3w 1d 1h')
+ wait_for_requests
page.within '.time-tracking-comparison-pane' do
expect(page).to have_content '3w 1d 1h'
end
@@ -34,7 +37,7 @@ shared_examples 'issuable time tracker' do
submit_time('/estimate 3w 1d 1h')
submit_time('/remove_estimate')
- page.within '#issuable-time-tracker' do
+ page.within '.time-tracking-component-wrap' do
expect(page).to have_content 'No estimate or time spent'
end
end
@@ -43,31 +46,31 @@ shared_examples 'issuable time tracker' do
submit_time('/spend 3w 1d 1h')
submit_time('/remove_time_spent')
- page.within '#issuable-time-tracker' do
+ page.within '.time-tracking-component-wrap' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'shows the help state when icon is clicked' do
- page.within '#issuable-time-tracker' do
+ page.within '.time-tracking-component-wrap' do
find('.help-button').click
- expect(page).to have_content 'Track time with slash commands'
+ expect(page).to have_content 'Track time with quick actions'
expect(page).to have_content 'Learn more'
end
end
it 'hides the help state when close icon is clicked' do
- page.within '#issuable-time-tracker' do
+ page.within '.time-tracking-component-wrap' do
find('.help-button').click
find('.close-help-button').click
- expect(page).not_to have_content 'Track time with slash commands'
+ expect(page).not_to have_content 'Track time with quick actions'
expect(page).not_to have_content 'Learn more'
end
end
it 'displays the correct help url' do
- page.within '#issuable-time-tracker' do
+ page.within '.time-tracking-component-wrap' do
find('.help-button').click
expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md')
@@ -75,8 +78,8 @@ shared_examples 'issuable time tracker' do
end
end
-def submit_time(slash_command)
- fill_in 'note[note]', with: slash_command
+def submit_time(quick_action)
+ fill_in 'note[note]', with: quick_action
find('.js-comment-submit-button').trigger('click')
- wait_for_ajax
+ wait_for_requests
end
diff --git a/spec/support/unique_ip_check_shared_examples.rb b/spec/support/unique_ip_check_shared_examples.rb
index 7cf5a65eeed..2dfa5fbecea 100644
--- a/spec/support/unique_ip_check_shared_examples.rb
+++ b/spec/support/unique_ip_check_shared_examples.rb
@@ -1,7 +1,9 @@
shared_context 'unique ips sign in limit' do
include StubENV
- before(:each) do
- Gitlab::Redis.with(&:flushall)
+ before do
+ Gitlab::Redis::Cache.with(&:flushall)
+ Gitlab::Redis::Queues.with(&:flushall)
+ Gitlab::Redis::SharedState.with(&:flushall)
end
before do
@@ -31,7 +33,9 @@ end
shared_examples 'user login operation with unique ip limit' do
include_context 'unique ips sign in limit' do
- before { current_application_settings.update!(unique_ips_limit_per_user: 1) }
+ before do
+ current_application_settings.update!(unique_ips_limit_per_user: 1)
+ end
it 'allows user authenticating from the same ip' do
expect { operation_from_ip('ip') }.not_to raise_error
@@ -47,7 +51,9 @@ end
shared_examples 'user login request with unique ip limit' do |success_status = 200|
include_context 'unique ips sign in limit' do
- before { current_application_settings.update!(unique_ips_limit_per_user: 1) }
+ before do
+ current_application_settings.update!(unique_ips_limit_per_user: 1)
+ end
it 'allows user authenticating from the same ip' do
expect(request_from_ip('ip')).to have_http_status(success_status)
diff --git a/spec/support/unpack-gitlab-git-test b/spec/support/unpack-gitlab-git-test
new file mode 100755
index 00000000000..d5b4912457d
--- /dev/null
+++ b/spec/support/unpack-gitlab-git-test
@@ -0,0 +1,38 @@
+#!/usr/bin/env ruby
+require 'fileutils'
+
+REPO = 'spec/support/gitlab-git-test.git'.freeze
+PACK_DIR = REPO + '/objects/pack'
+GIT = %W[git --git-dir=#{REPO}].freeze
+BASE_PACK = 'pack-691247af2a6acb0b63b73ac0cb90540e93614043'.freeze
+
+def main
+ unpack
+ # We want to store the refs in a packed-refs file because if we don't
+ # they can get mangled by filesystems.
+ abort unless system(*GIT, *%w[pack-refs --all])
+ abort unless system(*GIT, 'fsck')
+end
+
+# We don't want contributors to commit new pack files because those
+# create unnecessary churn.
+def unpack
+ pack_files = Dir[File.join(PACK_DIR, '*')].reject do |pack|
+ pack.start_with?(File.join(PACK_DIR, BASE_PACK))
+ end
+ return if pack_files.empty?
+
+ pack_files.each do |pack|
+ unless pack.end_with?('.pack')
+ FileUtils.rm(pack)
+ next
+ end
+
+ File.open(pack, 'rb') do |open_pack|
+ File.unlink(pack)
+ abort unless system(*GIT, 'unpack-objects', in: open_pack)
+ end
+ end
+end
+
+main
diff --git a/spec/support/update_invalid_issuable.rb b/spec/support/update_invalid_issuable.rb
index 365c34448ac..1490287681b 100644
--- a/spec/support/update_invalid_issuable.rb
+++ b/spec/support/update_invalid_issuable.rb
@@ -21,8 +21,8 @@ shared_examples 'update invalid issuable' do |klass|
context 'when updating causes conflicts' do
before do
- allow_any_instance_of(issuable.class).to receive(:save).
- and_raise(ActiveRecord::StaleObjectError.new(issuable, :save))
+ allow_any_instance_of(issuable.class).to receive(:save)
+ .and_raise(ActiveRecord::StaleObjectError.new(issuable, :save))
end
it 'renders edit when format is html' do
diff --git a/spec/support/updating_mentions_shared_examples.rb b/spec/support/updating_mentions_shared_examples.rb
index e0c59a5c280..565d3203e4f 100644
--- a/spec/support/updating_mentions_shared_examples.rb
+++ b/spec/support/updating_mentions_shared_examples.rb
@@ -2,11 +2,11 @@ RSpec.shared_examples 'updating mentions' do |service_class|
let(:mentioned_user) { create(:user) }
let(:service_class) { service_class }
- before { project.team << [mentioned_user, :developer] }
+ before do
+ project.team << [mentioned_user, :developer]
+ end
def update_mentionable(opts)
- reset_delivered_emails!
-
perform_enqueued_jobs do
service_class.new(project, user, opts).execute(mentionable)
end
@@ -15,7 +15,9 @@ RSpec.shared_examples 'updating mentions' do |service_class|
end
context 'in title' do
- before { update_mentionable(title: mentioned_user.to_reference) }
+ before do
+ update_mentionable(title: mentioned_user.to_reference)
+ end
it 'emails only the newly-mentioned user' do
should_only_email(mentioned_user)
@@ -23,7 +25,9 @@ RSpec.shared_examples 'updating mentions' do |service_class|
end
context 'in description' do
- before { update_mentionable(description: mentioned_user.to_reference) }
+ before do
+ update_mentionable(description: mentioned_user.to_reference)
+ end
it 'emails only the newly-mentioned user' do
should_only_email(mentioned_user)
diff --git a/spec/support/user_activities_helpers.rb b/spec/support/user_activities_helpers.rb
index f7ca9a31edd..44feb104644 100644
--- a/spec/support/user_activities_helpers.rb
+++ b/spec/support/user_activities_helpers.rb
@@ -1,7 +1,7 @@
module UserActivitiesHelpers
def user_activity(user)
- Gitlab::UserActivities.new.
- find { |k, _| k == user.id.to_s }&.
+ Gitlab::UserActivities.new
+ .find { |k, _| k == user.id.to_s }&.
second
end
end
diff --git a/spec/support/wait_for_ajax.rb b/spec/support/wait_for_ajax.rb
deleted file mode 100644
index 508de2ee8e1..00000000000
--- a/spec/support/wait_for_ajax.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-module WaitForAjax
- def wait_for_ajax
- Timeout.timeout(Capybara.default_max_wait_time) do
- loop until finished_all_ajax_requests?
- end
- end
-
- def finished_all_ajax_requests?
- return true unless javascript_test?
- return true if page.evaluate_script('typeof jQuery === "undefined"')
-
- page.evaluate_script('jQuery.active').zero?
- end
-
- def javascript_test?
- Capybara.current_driver == Capybara.javascript_driver
- end
-end
diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb
index 0bfa7f72ff8..b5c3c0f55b8 100644
--- a/spec/support/wait_for_requests.rb
+++ b/spec/support/wait_for_requests.rb
@@ -1,16 +1,31 @@
+require_relative './wait_for_requests'
+
module WaitForRequests
extend self
# This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests
- def wait_for_requests_complete
+ def block_and_wait_for_requests_complete
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
- wait_for('pending AJAX requests complete') do
- Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero?
+ wait_for('pending requests complete') do
+ Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? && finished_all_requests?
end
ensure
Gitlab::Testing::RequestBlockerMiddleware.allow_requests!
end
+ def wait_for_requests
+ wait_for('JS requests') { finished_all_requests? }
+ end
+
+ private
+
+ def finished_all_requests?
+ return true unless javascript_test?
+
+ finished_all_ajax_requests? &&
+ finished_all_vue_resource_requests?
+ end
+
# Waits until the passed block returns true
def wait_for(condition_name, max_wait_time: Capybara.default_max_wait_time, polling_interval: 0.01)
wait_until = Time.now + max_wait_time.seconds
@@ -23,10 +38,18 @@ module WaitForRequests
end
end
end
-end
-RSpec.configure do |config|
- config.after(:each, :js) do
- wait_for_requests_complete
+ def finished_all_vue_resource_requests?
+ Capybara.page.evaluate_script('window.activeVueResources || 0').zero?
+ end
+
+ def finished_all_ajax_requests?
+ return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"')
+
+ Capybara.page.evaluate_script('jQuery.active').zero?
+ end
+
+ def javascript_test?
+ Capybara.current_driver == Capybara.javascript_driver
end
end
diff --git a/spec/support/wait_for_vue_resource.rb b/spec/support/wait_for_vue_resource.rb
deleted file mode 100644
index 4a4e2e16ee7..00000000000
--- a/spec/support/wait_for_vue_resource.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-module WaitForVueResource
- def wait_for_vue_resource(spinner: true)
- Timeout.timeout(Capybara.default_max_wait_time) do
- loop until page.evaluate_script('window.activeVueResources').zero?
- end
- end
-end
diff --git a/spec/support/workhorse_helpers.rb b/spec/support/workhorse_helpers.rb
index 47673cd4c3a..ef1f9f68671 100644
--- a/spec/support/workhorse_helpers.rb
+++ b/spec/support/workhorse_helpers.rb
@@ -9,7 +9,7 @@ module WorkhorseHelpers
header = split_header.join(':')
[
type,
- JSON.parse(Base64.urlsafe_decode64(header)),
+ JSON.parse(Base64.urlsafe_decode64(header))
]
end
end
diff --git a/spec/tasks/config_lint_spec.rb b/spec/tasks/config_lint_spec.rb
index c32f9a740b7..83d54259dfa 100644
--- a/spec/tasks/config_lint_spec.rb
+++ b/spec/tasks/config_lint_spec.rb
@@ -2,19 +2,19 @@ require 'rake_helper'
Rake.application.rake_require 'tasks/config_lint'
describe ConfigLint do
- let(:files){ ['lib/support/fake.sh'] }
+ let(:files) { ['lib/support/fake.sh'] }
it 'errors out if any bash scripts have errors' do
- expect { ConfigLint.run(files){ system('exit 1') } }.to raise_error(SystemExit)
+ expect { described_class.run(files) { system('exit 1') } }.to raise_error(SystemExit)
end
it 'passes if all scripts are fine' do
- expect { ConfigLint.run(files){ system('exit 0') } }.not_to raise_error
+ expect { described_class.run(files) { system('exit 0') } }.not_to raise_error
end
end
describe 'config_lint rake task' do
- before(:each) do
+ before do
# Prevent `system` from actually being called
allow(Kernel).to receive(:system).and_return(true)
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 0a4a6ed8145..0c8c8a2ab05 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -47,24 +47,24 @@ describe 'gitlab:app namespace rake task' do
allow(Kernel).to receive(:system).and_return(true)
allow(FileUtils).to receive(:cp_r).and_return(true)
allow(FileUtils).to receive(:mv).and_return(true)
- allow(Rake::Task["gitlab:shell:setup"]).
- to receive(:invoke).and_return(true)
+ allow(Rake::Task["gitlab:shell:setup"])
+ .to receive(:invoke).and_return(true)
ENV['force'] = 'yes'
end
let(:gitlab_version) { Gitlab::VERSION }
it 'fails on mismatch' do
- allow(YAML).to receive(:load_file).
- and_return({ gitlab_version: "not #{gitlab_version}" })
+ allow(YAML).to receive(:load_file)
+ .and_return({ gitlab_version: "not #{gitlab_version}" })
- expect { run_rake_task('gitlab:backup:restore') }.
- to raise_error(SystemExit)
+ expect { run_rake_task('gitlab:backup:restore') }
+ .to raise_error(SystemExit)
end
it 'invokes restoration on match' do
- allow(YAML).to receive(:load_file).
- and_return({ gitlab_version: gitlab_version })
+ allow(YAML).to receive(:load_file)
+ .and_return({ gitlab_version: gitlab_version })
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke)
expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke)
@@ -117,9 +117,9 @@ describe 'gitlab:app namespace rake task' do
describe 'backup creation and deletion using custom_hooks' do
let(:project) { create(:project, :repository) }
- let(:user_backup_path) { "repositories/#{project.path_with_namespace}" }
+ let(:user_backup_path) { "repositories/#{project.disk_path}" }
- before(:each) do
+ before do
@origin_cd = Dir.pwd
path = File.join(project.repository.path_to_repo, filename)
@@ -130,7 +130,7 @@ describe 'gitlab:app namespace rake task' do
create_backup
end
- after(:each) do
+ after do
ENV["SKIP"] = ""
FileUtils.rm(@backup_tar)
Dir.chdir(@origin_cd)
@@ -230,9 +230,10 @@ describe 'gitlab:app namespace rake task' do
before do
FileUtils.mkdir('tmp/tests/default_storage')
FileUtils.mkdir('tmp/tests/custom_storage')
+ gitaly_address = Gitlab.config.repositories.storages.default.gitaly_address
storages = {
- 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage') },
- 'custom' => { 'path' => Settings.absolute('tmp/tests/custom_storage') }
+ 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address },
+ 'custom' => { 'path' => Settings.absolute('tmp/tests/custom_storage'), 'gitaly_address' => gitaly_address }
}
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
@@ -240,6 +241,10 @@ describe 'gitlab:app namespace rake task' do
project_a
project_b
+ # Avoid asking gitaly about the root ref (which will fail beacuse of the
+ # mocked storages)
+ allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false)
+
# We only need a backup of the repositories for this test
ENV["SKIP"] = "db,uploads,builds,artifacts,lfs,registry"
create_backup
@@ -256,8 +261,8 @@ describe 'gitlab:app namespace rake task' do
%W{tar -tvf #{@backup_tar} repositories}
)
expect(exit_status).to eq(0)
- expect(tar_contents).to match("repositories/#{project_a.path_with_namespace}.bundle")
- expect(tar_contents).to match("repositories/#{project_b.path_with_namespace}.bundle")
+ expect(tar_contents).to match("repositories/#{project_a.disk_path}.bundle")
+ expect(tar_contents).to match("repositories/#{project_b.disk_path}.bundle")
end
end
end # backup_create task
@@ -305,8 +310,8 @@ describe 'gitlab:app namespace rake task' do
end
it 'does not invoke repositories restore' do
- allow(Rake::Task['gitlab:shell:setup']).
- to receive(:invoke).and_return(true)
+ allow(Rake::Task['gitlab:shell:setup'])
+ .to receive(:invoke).and_return(true)
allow($stdout).to receive :write
expect(Rake::Task['gitlab:db:drop_tables']).to receive :invoke
@@ -350,7 +355,7 @@ describe 'gitlab:app namespace rake task' do
end
it 'name has human readable time' do
- expect(@backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+(-pre)?_gitlab_backup.tar$/)
+ expect(@backup_tar).to match(/\d+_\d{4}_\d{2}_\d{2}_\d+\.\d+\.\d+.*_gitlab_backup.tar$/)
end
end
end # gitlab:app namespace
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index aaf998a546f..1e9b20435ec 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -20,8 +20,8 @@ describe 'gitlab:gitaly namespace rake task' do
context 'when an underlying Git command fail' do
it 'aborts and display a help message' do
- expect_any_instance_of(Object).
- to receive(:checkout_or_clone_version).and_raise 'Git error'
+ expect(main_object)
+ .to receive(:checkout_or_clone_version).and_raise 'Git error'
expect { run_rake_task('gitlab:gitaly:install', clone_path) }.to raise_error 'Git error'
end
@@ -33,28 +33,38 @@ describe 'gitlab:gitaly namespace rake task' do
end
it 'calls checkout_or_clone_version with the right arguments' do
- expect_any_instance_of(Object).
- to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
+ expect(main_object)
+ .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
run_rake_task('gitlab:gitaly:install', clone_path)
end
end
describe 'gmake/make' do
+ let(:command_preamble) { %w[/usr/bin/env -u RUBYOPT -u BUNDLE_GEMFILE] }
+
+ before(:all) do
+ @old_env_ci = ENV.delete('CI')
+ end
+
+ after(:all) do
+ ENV['CI'] = @old_env_ci if @old_env_ci
+ end
+
before do
FileUtils.mkdir_p(clone_path)
expect(Dir).to receive(:chdir).with(clone_path).and_call_original
+ allow(Bundler).to receive(:bundle_path).and_return('/fake/bundle_path')
end
context 'gmake is available' do
before do
- expect_any_instance_of(Object).to receive(:checkout_or_clone_version)
- allow_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true)
+ expect(main_object).to receive(:checkout_or_clone_version)
end
it 'calls gmake in the gitaly directory' do
expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['/usr/bin/gmake', 0])
- expect_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true)
+ expect(main_object).to receive(:run_command!).with(command_preamble + %w[gmake BUNDLE_PATH=/fake/bundle_path]).and_return(true)
run_rake_task('gitlab:gitaly:install', clone_path)
end
@@ -62,16 +72,27 @@ describe 'gitlab:gitaly namespace rake task' do
context 'gmake is not available' do
before do
- expect_any_instance_of(Object).to receive(:checkout_or_clone_version)
- allow_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true)
+ expect(main_object).to receive(:checkout_or_clone_version)
+ expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['', 42])
end
it 'calls make in the gitaly directory' do
- expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['', 42])
- expect_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true)
+ expect(main_object).to receive(:run_command!).with(command_preamble + %w[make BUNDLE_PATH=/fake/bundle_path]).and_return(true)
run_rake_task('gitlab:gitaly:install', clone_path)
end
+
+ context 'when Rails.env is not "test"' do
+ before do
+ allow(Rails.env).to receive(:test?).and_return(false)
+ end
+
+ it 'calls make in the gitaly directory without BUNDLE_PATH' do
+ expect(main_object).to receive(:run_command!).with(command_preamble + ['make']).and_return(true)
+
+ run_rake_task('gitlab:gitaly:install', clone_path)
+ end
+ end
end
end
end
@@ -79,29 +100,39 @@ describe 'gitlab:gitaly namespace rake task' do
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' },
+ 'default' => {
+ 'path' => '/path/to/default',
+ 'gitaly_address' => 'unix:/path/to/my.socket'
+ },
+ 'nfs_01' => {
+ 'path' => '/path/to/nfs_01',
+ 'gitaly_address' => 'unix:/path/to/my.socket'
+ }
}
allow(Gitlab.config.repositories).to receive(:storages).and_return(config)
+ allow(Rails.env).to receive(:test?).and_return(false)
- orig_stdout = $stdout
- $stdout = StringIO.new
-
- header = ''
+ expected_output = ''
Timecop.freeze do
- header = <<~TOML
+ expected_output = <<~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.
+ socket_path = "/path/to/my.socket"
+ [gitlab-shell]
+ dir = "#{Gitlab.config.gitlab_shell.path}"
+ [[storage]]
+ name = "default"
+ path = "/path/to/default"
+ [[storage]]
+ name = "nfs_01"
+ path = "/path/to/nfs_01"
TOML
- run_rake_task('gitlab:gitaly:storage_config')
end
- output = $stdout.string
- $stdout = orig_stdout
-
- expect(output).to include(header)
+ expect { run_rake_task('gitlab:gitaly:storage_config')}
+ .to output(expected_output).to_stdout
- parsed_output = TOML.parse(output)
+ parsed_output = TOML.parse(expected_output)
config.each do |name, params|
expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] })
end
diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb
index 226d34fe2c9..65155cb044d 100644
--- a/spec/tasks/gitlab/shell_rake_spec.rb
+++ b/spec/tasks/gitlab/shell_rake_spec.rb
@@ -11,6 +11,10 @@ describe 'gitlab:shell rake tasks' do
it 'invokes create_hooks task' do
expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke)
+ storages = Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
+ expect(Kernel).to receive(:system).with('bin/install', *storages).and_call_original
+ expect(Kernel).to receive(:system).with('bin/compile').and_call_original
+
run_rake_task('gitlab:shell:install')
end
end
@@ -18,7 +22,8 @@ describe 'gitlab:shell rake tasks' do
describe 'create_hooks task' do
it 'calls gitlab-shell bin/create_hooks' do
expect_any_instance_of(Object).to receive(:system)
- .with("#{Gitlab.config.gitlab_shell.path}/bin/create-hooks", *repository_storage_paths_args)
+ .with("#{Gitlab.config.gitlab_shell.path}/bin/create-hooks",
+ *Gitlab::TaskHelpers.repository_storage_paths_args)
run_rake_task('gitlab:shell:create_hooks')
end
diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb
index 3d9ba7cdc6f..d34617be474 100644
--- a/spec/tasks/gitlab/task_helpers_spec.rb
+++ b/spec/tasks/gitlab/task_helpers_spec.rb
@@ -20,7 +20,6 @@ describe Gitlab::TaskHelpers do
it 'checkout the version and reset to it' do
expect(subject).to receive(:checkout_version).with(tag, clone_path)
- expect(subject).to receive(:reset_to_version).with(tag, clone_path)
subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path)
end
@@ -31,7 +30,6 @@ describe Gitlab::TaskHelpers do
it 'checkout the version and reset to it with a branch name' do
expect(subject).to receive(:checkout_version).with(branch, clone_path)
- expect(subject).to receive(:reset_to_version).with(branch, clone_path)
subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path)
end
@@ -60,8 +58,8 @@ describe Gitlab::TaskHelpers do
describe '#clone_repo' do
it 'clones the repo in the target dir' do
- expect(subject).
- to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} clone -- #{repo} #{clone_path}])
+ expect(subject)
+ .to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} clone -- #{repo} #{clone_path}])
subject.clone_repo(repo, clone_path)
end
@@ -69,21 +67,12 @@ describe Gitlab::TaskHelpers do
describe '#checkout_version' do
it 'clones the repo in the target dir' do
- expect(subject).
- to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} fetch --quiet])
- expect(subject).
- to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} checkout --quiet #{tag}])
+ expect(subject)
+ .to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} fetch --quiet origin #{tag}])
+ expect(subject)
+ .to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} checkout -f --quiet FETCH_HEAD --])
subject.checkout_version(tag, clone_path)
end
end
-
- describe '#reset_to_version' do
- it 'resets --hard to the given version' do
- expect(subject).
- to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} -C #{clone_path} reset --hard #{tag}])
-
- subject.reset_to_version(tag, clone_path)
- end
- end
end
diff --git a/spec/tasks/gitlab/workhorse_rake_spec.rb b/spec/tasks/gitlab/workhorse_rake_spec.rb
index 63d1cf2bbe5..42516d36c67 100644
--- a/spec/tasks/gitlab/workhorse_rake_spec.rb
+++ b/spec/tasks/gitlab/workhorse_rake_spec.rb
@@ -20,8 +20,8 @@ describe 'gitlab:workhorse namespace rake task' do
context 'when an underlying Git command fail' do
it 'aborts and display a help message' do
- expect_any_instance_of(Object).
- to receive(:checkout_or_clone_version).and_raise 'Git error'
+ expect(main_object)
+ .to receive(:checkout_or_clone_version).and_raise 'Git error'
expect { run_rake_task('gitlab:workhorse:install', clone_path) }.to raise_error 'Git error'
end
@@ -33,8 +33,8 @@ describe 'gitlab:workhorse namespace rake task' do
end
it 'calls checkout_or_clone_version with the right arguments' do
- expect_any_instance_of(Object).
- to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
+ expect(main_object)
+ .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
run_rake_task('gitlab:workhorse:install', clone_path)
end
@@ -48,13 +48,13 @@ describe 'gitlab:workhorse namespace rake task' do
context 'gmake is available' do
before do
- expect_any_instance_of(Object).to receive(:checkout_or_clone_version)
- allow_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true)
+ expect(main_object).to receive(:checkout_or_clone_version)
+ allow(Object).to receive(:run_command!).with(['gmake']).and_return(true)
end
it 'calls gmake in the gitlab-workhorse directory' do
expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['/usr/bin/gmake', 0])
- expect_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true)
+ expect(main_object).to receive(:run_command!).with(['gmake']).and_return(true)
run_rake_task('gitlab:workhorse:install', clone_path)
end
@@ -62,13 +62,13 @@ describe 'gitlab:workhorse namespace rake task' do
context 'gmake is not available' do
before do
- expect_any_instance_of(Object).to receive(:checkout_or_clone_version)
- allow_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true)
+ expect(main_object).to receive(:checkout_or_clone_version)
+ allow(main_object).to receive(:run_command!).with(['make']).and_return(true)
end
it 'calls make in the gitlab-workhorse directory' do
expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['', 42])
- expect_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true)
+ expect(main_object).to receive(:run_command!).with(['make']).and_return(true)
run_rake_task('gitlab:workhorse:install', clone_path)
end
diff --git a/spec/tasks/tokens_spec.rb b/spec/tasks/tokens_spec.rb
index 19036c7677c..b84137eb365 100644
--- a/spec/tasks/tokens_spec.rb
+++ b/spec/tasks/tokens_spec.rb
@@ -18,4 +18,10 @@ describe 'tokens rake tasks' do
expect { run_rake_task('tokens:reset_all_email') }.to change { user.reload.incoming_email_token }
end
end
+
+ describe 'reset_all_rss task' do
+ it 'invokes create_hooks task' do
+ expect { run_rake_task('tokens:reset_all_rss') }.to change { user.reload.rss_token }
+ end
+ end
end
diff --git a/spec/unicorn/unicorn_spec.rb b/spec/unicorn/unicorn_spec.rb
new file mode 100644
index 00000000000..41de94d35c2
--- /dev/null
+++ b/spec/unicorn/unicorn_spec.rb
@@ -0,0 +1,98 @@
+require 'fileutils'
+
+require 'excon'
+
+require 'spec_helper'
+
+describe 'Unicorn' do
+ before(:all) do
+ config_lines = File.read('config/unicorn.rb.example').split("\n")
+
+ # Remove these because they make setup harder.
+ config_lines = config_lines.reject do |line|
+ %w[
+ working_directory
+ worker_processes
+ listen
+ pid
+ stderr_path
+ stdout_path
+ ].any? { |prefix| line.start_with?(prefix) }
+ end
+
+ config_lines << "working_directory '#{Rails.root}'"
+
+ # We want to have exactly 1 worker process because that makes it
+ # predictable which process will handle our requests.
+ config_lines << 'worker_processes 1'
+
+ @socket_path = File.join(Dir.pwd, 'tmp/tests/unicorn.socket')
+ config_lines << "listen '#{@socket_path}'"
+
+ ready_file = 'tmp/tests/unicorn-worker-ready'
+ FileUtils.rm_f(ready_file)
+ after_fork_index = config_lines.index { |l| l.start_with?('after_fork') }
+ config_lines.insert(after_fork_index + 1, "File.write('#{ready_file}', Process.pid)")
+
+ config_path = 'tmp/tests/unicorn.rb'
+ File.write(config_path, config_lines.join("\n") + "\n")
+
+ cmd = %W[unicorn -E test -c #{config_path} #{Rails.root.join('config.ru')}]
+ @unicorn_master_pid = spawn(*cmd)
+ wait_unicorn_boot!(@unicorn_master_pid, ready_file)
+ WebMock.allow_net_connect!
+ end
+
+ %w[SIGQUIT SIGTERM SIGKILL].each do |signal|
+ it "has a worker that self-terminates on signal #{signal}" do
+ response = Excon.get('unix:///unicorn_test/pid', socket: @socket_path)
+ expect(response.status).to eq(200)
+
+ worker_pid = response.body.to_i
+ expect(worker_pid).to be > 0
+
+ begin
+ Excon.post('unix:///unicorn_test/kill', socket: @socket_path, body: "signal=#{signal}")
+ rescue Excon::Error::Socket
+ # The connection may be closed abruptly
+ end
+
+ expect(pid_gone?(worker_pid)).to eq(true)
+ end
+ end
+
+ after(:all) do
+ WebMock.disable_net_connect!(allow_localhost: true)
+ Process.kill('TERM', @unicorn_master_pid)
+ end
+
+ def wait_unicorn_boot!(master_pid, ready_file)
+ # We have seen the boot timeout after 2 minutes in CI so let's set it to 5 minutes.
+ timeout = 5 * 60
+ timeout.times do
+ return if File.exist?(ready_file)
+ pid = Process.waitpid(master_pid, Process::WNOHANG)
+ raise "unicorn failed to boot: #{$?}" unless pid.nil?
+
+ sleep 1
+ end
+
+ raise "unicorn boot timed out after #{timeout} seconds"
+ end
+
+ def pid_gone?(pid)
+ # Worker termination should take less than a second. That makes 10
+ # seconds a generous timeout.
+ 10.times do
+ begin
+ Process.kill(0, pid)
+ rescue Errno::ESRCH
+ return true
+ end
+
+ sleep 1
+ end
+
+ false
+ end
+end
diff --git a/spec/uploaders/artifact_uploader_spec.rb b/spec/uploaders/artifact_uploader_spec.rb
new file mode 100644
index 00000000000..2a3bd0e3bb2
--- /dev/null
+++ b/spec/uploaders/artifact_uploader_spec.rb
@@ -0,0 +1,61 @@
+require 'rails_helper'
+
+describe ArtifactUploader do
+ let(:job) { create(:ci_build) }
+ let(:uploader) { described_class.new(job, :artifacts_file) }
+ let(:path) { Gitlab.config.artifacts.path }
+
+ describe '.local_artifacts_store' do
+ subject { described_class.local_artifacts_store }
+
+ it "delegate to artifacts path" do
+ expect(Gitlab.config.artifacts).to receive(:path)
+
+ subject
+ end
+ end
+
+ describe '.artifacts_upload_path' do
+ subject { described_class.artifacts_upload_path }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with('tmp/uploads/') }
+ end
+
+ describe '#store_dir' do
+ subject { uploader.store_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with("#{job.project_id}/#{job.id}") }
+ end
+
+ describe '#cache_dir' do
+ subject { uploader.cache_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with('/tmp/cache') }
+ end
+
+ describe '#work_dir' do
+ subject { uploader.work_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with('/tmp/work') }
+ end
+
+ describe '#filename' do
+ # we need to use uploader, as this makes to use mounter
+ # which initialises uploader.file object
+ let(:uploader) { job.artifacts_file }
+
+ subject { uploader.filename }
+
+ it { is_expected.to be_nil }
+
+ context 'with artifacts' do
+ let(:job) { create(:ci_build, :artifacts) }
+
+ it { is_expected.not_to be_nil }
+ end
+ end
+end
diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb
index ea714fb08f0..04ee6e9bfad 100644
--- a/spec/uploaders/attachment_uploader_spec.rb
+++ b/spec/uploaders/attachment_uploader_spec.rb
@@ -3,6 +3,17 @@ require 'spec_helper'
describe AttachmentUploader do
let(:uploader) { described_class.new(build_stubbed(:user)) }
+ describe "#store_dir" do
+ it "stores in the system dir" do
+ expect(uploader.store_dir).to start_with("uploads/-/system/user")
+ end
+
+ it "uses the old path when using object storage" do
+ expect(described_class).to receive(:file_storage?).and_return(false)
+ expect(uploader.store_dir).to start_with("uploads/user")
+ end
+ end
+
describe '#move_to_cache' do
it 'is true' do
expect(uploader.move_to_cache).to eq(true)
diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb
index c4d558805ab..1dc574699d8 100644
--- a/spec/uploaders/avatar_uploader_spec.rb
+++ b/spec/uploaders/avatar_uploader_spec.rb
@@ -3,6 +3,17 @@ require 'spec_helper'
describe AvatarUploader do
let(:uploader) { described_class.new(build_stubbed(:user)) }
+ describe "#store_dir" do
+ it "stores in the system dir" do
+ expect(uploader.store_dir).to start_with("uploads/-/system/user")
+ end
+
+ it "uses the old path when using object storage" do
+ expect(described_class).to receive(:file_storage?).and_return(false)
+ expect(uploader.store_dir).to start_with("uploads/user")
+ end
+ end
+
describe '#move_to_cache' do
it 'is false' do
expect(uploader.move_to_cache).to eq(false)
diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb
new file mode 100644
index 00000000000..0cf462e9553
--- /dev/null
+++ b/spec/uploaders/file_mover_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe FileMover do
+ let(:filename) { 'banana_sample.gif' }
+ let(:file) { fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) }
+ let(:temp_description) do
+ 'test ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif) same ![banana_sample]'\
+ '(/uploads/-/system/temp/secret55/banana_sample.gif)'
+ end
+ let(:temp_file_path) { File.join('secret55', filename).to_s }
+ let(:file_path) { File.join('uploads', '-', 'system', 'personal_snippet', snippet.id.to_s, 'secret55', filename).to_s }
+
+ let(:snippet) { create(:personal_snippet, description: temp_description) }
+
+ subject { described_class.new(file_path, snippet).execute }
+
+ describe '#execute' do
+ before do
+ expect(FileUtils).to receive(:mkdir_p).with(a_string_including(File.dirname(file_path)))
+ expect(FileUtils).to receive(:move).with(a_string_including(temp_file_path), a_string_including(file_path))
+ allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:exists?).and_return(true)
+ allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:size).and_return(10)
+ end
+
+ context 'when move and field update successful' do
+ it 'updates the description correctly' do
+ subject
+
+ expect(snippet.reload.description)
+ .to eq(
+ "test ![banana_sample](/uploads/-/system/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"\
+ " same ![banana_sample](/uploads/-/system/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"
+ )
+ end
+
+ it 'creates a new update record' do
+ expect { subject }.to change { Upload.count }.by(1)
+ end
+ end
+
+ context 'when update_markdown fails' do
+ before do
+ expect(FileUtils).to receive(:move).with(a_string_including(file_path), a_string_including(temp_file_path))
+ end
+
+ subject { described_class.new(file_path, snippet, :non_existing_field).execute }
+
+ it 'does not update the description' do
+ subject
+
+ expect(snippet.reload.description)
+ .to eq(
+ "test ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif)"\
+ " same ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif)"
+ )
+ end
+
+ it 'does not create a new update record' do
+ expect { subject }.not_to change { Upload.count }
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb
index d9113ef4095..2492d56a5cf 100644
--- a/spec/uploaders/file_uploader_spec.rb
+++ b/spec/uploaders/file_uploader_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe FileUploader do
- let(:uploader) { described_class.new(build_stubbed(:empty_project)) }
+ let(:uploader) { described_class.new(build_stubbed(:project)) }
describe '.absolute_path' do
it 'returns the correct absolute path by building it dynamically' do
@@ -15,6 +15,16 @@ describe FileUploader do
end
end
+ describe "#store_dir" do
+ it "stores in the namespace path" do
+ project = build_stubbed(:project)
+ uploader = described_class.new(project)
+
+ expect(uploader.store_dir).to include(project.path_with_namespace)
+ expect(uploader.store_dir).not_to include("system")
+ end
+ end
+
describe 'initialize' do
it 'generates a secret if none is provided' do
expect(SecureRandom).to receive(:hex).and_return('secret')
diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb
new file mode 100644
index 00000000000..a144b39f74f
--- /dev/null
+++ b/spec/uploaders/gitlab_uploader_spec.rb
@@ -0,0 +1,71 @@
+require 'rails_helper'
+require 'carrierwave/storage/fog'
+
+describe GitlabUploader do
+ let(:uploader_class) { Class.new(described_class) }
+
+ subject { uploader_class.new }
+
+ describe '#file_storage?' do
+ context 'when file storage is used' do
+ before do
+ uploader_class.storage(:file)
+ end
+
+ it { is_expected.to be_file_storage }
+ end
+
+ context 'when is remote storage' do
+ before do
+ uploader_class.storage(:fog)
+ end
+
+ it { is_expected.not_to be_file_storage }
+ end
+ end
+
+ describe '#file_cache_storage?' do
+ context 'when file storage is used' do
+ before do
+ uploader_class.cache_storage(:file)
+ end
+
+ it { is_expected.to be_file_cache_storage }
+ end
+
+ context 'when is remote storage' do
+ before do
+ uploader_class.cache_storage(:fog)
+ end
+
+ it { is_expected.not_to be_file_cache_storage }
+ end
+ end
+
+ describe '#move_to_cache' do
+ it 'is true' do
+ expect(subject.move_to_cache).to eq(true)
+ end
+ end
+
+ describe '#move_to_store' do
+ it 'is true' do
+ expect(subject.move_to_store).to eq(true)
+ end
+ end
+
+ describe '#cache!' do
+ it 'moves the file from the working directory to the cache directory' do
+ # One to get the work dir, the other to remove it
+ expect(subject).to receive(:workfile_path).exactly(2).times.and_call_original
+ # Test https://github.com/carrierwavesubject/carrierwave/blob/v1.0.0/lib/carrierwave/sanitized_file.rb#L200
+ expect(FileUtils).to receive(:mv).with(anything, /^#{subject.work_dir}/).and_call_original
+ expect(FileUtils).to receive(:mv).with(/^#{subject.work_dir}/, /#{subject.cache_dir}/).and_call_original
+
+ fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg')
+ subject.cache!(fixture_file_upload(fixture))
+
+ expect(subject.file.path).to match(/#{subject.cache_dir}/)
+ end
+ end
+end
diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb
new file mode 100644
index 00000000000..7088bc23334
--- /dev/null
+++ b/spec/uploaders/lfs_object_uploader_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe LfsObjectUploader do
+ let(:lfs_object) { create(:lfs_object, :with_file) }
+ let(:uploader) { described_class.new(lfs_object) }
+ let(:path) { Gitlab.config.lfs.storage_path }
+
+ describe '#move_to_cache' do
+ it 'is true' do
+ expect(uploader.move_to_cache).to eq(true)
+ end
+ end
+
+ describe '#move_to_store' do
+ it 'is true' do
+ expect(uploader.move_to_store).to eq(true)
+ end
+ end
+
+ describe '#store_dir' do
+ subject { uploader.store_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with("#{lfs_object.oid[0, 2]}/#{lfs_object.oid[2, 2]}") }
+ end
+
+ describe '#cache_dir' do
+ subject { uploader.cache_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with('/tmp/cache') }
+ end
+
+ describe '#work_dir' do
+ subject { uploader.work_dir }
+
+ it { is_expected.to start_with(path) }
+ it { is_expected.to end_with('/tmp/work') }
+ end
+end
diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb
new file mode 100644
index 00000000000..cbafa9f478d
--- /dev/null
+++ b/spec/uploaders/personal_file_uploader_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe PersonalFileUploader do
+ let(:uploader) { described_class.new(build_stubbed(:project)) }
+ let(:snippet) { create(:personal_snippet) }
+
+ describe '.absolute_path' do
+ it 'returns the correct absolute path by building it dynamically' do
+ upload = double(model: snippet, path: 'secret/foo.jpg')
+
+ dynamic_segment = "personal_snippet/#{snippet.id}"
+
+ expect(described_class.absolute_path(upload)).to end_with("/-/system/#{dynamic_segment}/secret/foo.jpg")
+ end
+ end
+
+ describe '#to_h' do
+ it 'returns the hass' do
+ uploader = described_class.new(snippet, 'secret')
+
+ allow(uploader).to receive(:file).and_return(double(extension: 'txt', filename: 'file_name'))
+ expected_url = "/uploads/-/system/personal_snippet/#{snippet.id}/secret/file_name"
+
+ expect(uploader.to_h).to eq(
+ alt: 'file_name',
+ url: expected_url,
+ markdown: "[file_name](#{expected_url})"
+ )
+ end
+ end
+end
diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb
index 5c26e334a6e..bb32ee62ccb 100644
--- a/spec/uploaders/records_uploads_spec.rb
+++ b/spec/uploaders/records_uploads_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
describe RecordsUploads do
- let(:uploader) do
+ let!(:uploader) do
class RecordsUploadsExampleUploader < GitlabUploader
include RecordsUploads
@@ -57,6 +57,13 @@ describe RecordsUploads do
uploader.store!(upload_fixture('rails_sample.jpg'))
end
+ it 'does not create an Upload record if model is missing' do
+ expect_any_instance_of(RecordsUploadsExampleUploader).to receive(:model).and_return(nil)
+ expect(Upload).not_to receive(:record).with(uploader)
+
+ uploader.store!(upload_fixture('rails_sample.jpg'))
+ end
+
it 'it destroys Upload records at the same path before recording' do
existing = Upload.create!(
path: File.join('uploads', 'rails_sample.jpg'),
diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb
new file mode 100644
index 00000000000..08e1c5a728a
--- /dev/null
+++ b/spec/validators/dynamic_path_validator_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe DynamicPathValidator do
+ let(:validator) { described_class.new(attributes: [:path]) }
+
+ def expect_handles_invalid_utf8
+ expect { yield('\255invalid') }.to be_falsey
+ end
+
+ describe '.valid_user_path' do
+ it 'handles invalid utf8' do
+ expect(described_class.valid_user_path?("a\0weird\255path")).to be_falsey
+ end
+ end
+
+ describe '.valid_group_path' do
+ it 'handles invalid utf8' do
+ expect(described_class.valid_group_path?("a\0weird\255path")).to be_falsey
+ end
+ end
+
+ describe '.valid_project_path' do
+ it 'handles invalid utf8' do
+ expect(described_class.valid_project_path?("a\0weird\255path")).to be_falsey
+ end
+ end
+
+ describe '#path_valid_for_record?' do
+ context 'for project' do
+ it 'calls valid_project_path?' do
+ project = build(:project, path: 'activity')
+
+ expect(described_class).to receive(:valid_project_path?).with(project.full_path).and_call_original
+
+ expect(validator.path_valid_for_record?(project, 'activity')).to be_truthy
+ end
+ end
+
+ context 'for group' do
+ it 'calls valid_group_path?' do
+ group = build(:group, :nested, path: 'activity')
+
+ expect(described_class).to receive(:valid_group_path?).with(group.full_path).and_call_original
+
+ expect(validator.path_valid_for_record?(group, 'activity')).to be_falsey
+ end
+ end
+
+ context 'for user' do
+ it 'calls valid_user_path?' do
+ user = build(:user, username: 'activity')
+
+ expect(described_class).to receive(:valid_user_path?).with(user.full_path).and_call_original
+
+ expect(validator.path_valid_for_record?(user, 'activity')).to be_truthy
+ end
+ end
+
+ context 'for user namespace' do
+ it 'calls valid_user_path?' do
+ user = create(:user, username: 'activity')
+ namespace = user.namespace
+
+ expect(described_class).to receive(:valid_user_path?).with(namespace.full_path).and_call_original
+
+ expect(validator.path_valid_for_record?(namespace, 'activity')).to be_truthy
+ end
+ end
+ end
+
+ describe '#validates_each' do
+ it 'adds a message when the path is not in the correct format' do
+ group = build(:group)
+
+ validator.validate_each(group, :path, "Path with spaces, and comma's!")
+
+ expect(group.errors[:path]).to include(Gitlab::PathRegex.namespace_format_message)
+ end
+
+ it 'adds a message when the path is not in the correct format' do
+ group = build(:group, path: 'users')
+
+ validator.validate_each(group, :path, 'users')
+
+ expect(group.errors[:path]).to include('users is a reserved name')
+ end
+
+ it 'updating to an invalid path is not allowed' do
+ project = create(:project)
+ project.path = 'update'
+
+ validator.validate_each(project, :path, 'update')
+
+ expect(project.errors[:path]).to include('update is a reserved name')
+ end
+ end
+end
diff --git a/spec/views/admin/dashboard/index.html.haml_spec.rb b/spec/views/admin/dashboard/index.html.haml_spec.rb
index 68d2d72876e..df742bf6848 100644
--- a/spec/views/admin/dashboard/index.html.haml_spec.rb
+++ b/spec/views/admin/dashboard/index.html.haml_spec.rb
@@ -4,7 +4,7 @@ describe 'admin/dashboard/index.html.haml' do
include Devise::Test::ControllerHelpers
before do
- assign(:projects, create_list(:empty_project, 1))
+ assign(:projects, create_list(:project, 1))
assign(:users, create_list(:user, 1))
assign(:groups, create_list(:group, 1))
diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb
index c62450fb8e2..49f57969239 100644
--- a/spec/views/ci/status/_badge.html.haml_spec.rb
+++ b/spec/views/ci/status/_badge.html.haml_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe 'ci/status/_badge', :view do
+describe 'ci/status/_badge' do
let(:user) { create(:user) }
- let(:project) { create(:empty_project, :private) }
+ let(:project) { create(:project, :private) }
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when rendering status for build' do
@@ -16,8 +16,7 @@ describe 'ci/status/_badge', :view do
end
it 'has link to build details page' do
- details_path = namespace_project_build_path(
- project.namespace, project, build)
+ details_path = project_job_path(project, build)
render_status(build)
diff --git a/spec/views/devise/shared/_signin_box.html.haml_spec.rb b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
index 1397bfa5864..9adbb0476be 100644
--- a/spec/views/devise/shared/_signin_box.html.haml_spec.rb
+++ b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
@@ -31,7 +31,7 @@ describe 'devise/shared/_signin_box' do
def enable_crowd
allow(view).to receive(:form_based_providers).and_return([:crowd])
allow(view).to receive(:crowd_enabled?).and_return(true)
- allow(view).to receive(:omniauth_authorize_path).with(:user, :crowd).
- and_return('/crowd')
+ allow(view).to receive(:omniauth_authorize_path).with(:user, :crowd)
+ .and_return('/crowd')
end
end
diff --git a/spec/views/help/index.html.haml_spec.rb b/spec/views/help/index.html.haml_spec.rb
index 6b07fcfc987..1f8261cc46b 100644
--- a/spec/views/help/index.html.haml_spec.rb
+++ b/spec/views/help/index.html.haml_spec.rb
@@ -21,7 +21,7 @@ describe 'help/index' do
render
expect(rendered).to match '8.0.2'
- expect(rendered).to match 'abcdefg'
+ expect(rendered).to have_link('abcdefg', 'https://gitlab.com/gitlab-org/gitlab-ce/commits/abcdefg')
end
end
diff --git a/spec/views/layouts/nav/_project.html.haml_spec.rb b/spec/views/layouts/nav/_project.html.haml_spec.rb
index fd1637ca91b..faea2505e40 100644
--- a/spec/views/layouts/nav/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/_project.html.haml_spec.rb
@@ -5,7 +5,7 @@ describe 'layouts/nav/_project' do
before do
stub_container_registry_config(enabled: true)
- assign(:project, create(:project))
+ assign(:project, create(:project, :repository))
allow(view).to receive(:current_ref).and_return('master')
allow(view).to receive(:can?).and_return(true)
diff --git a/spec/views/notify/pipeline_failed_email.html.haml_spec.rb b/spec/views/notify/pipeline_failed_email.html.haml_spec.rb
index f627f9165fb..d9d73f789c5 100644
--- a/spec/views/notify/pipeline_failed_email.html.haml_spec.rb
+++ b/spec/views/notify/pipeline_failed_email.html.haml_spec.rb
@@ -4,7 +4,7 @@ describe 'notify/pipeline_failed_email.html.haml' do
include Devise::Test::ControllerHelpers
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:pipeline) do
diff --git a/spec/views/notify/pipeline_success_email.html.haml_spec.rb b/spec/views/notify/pipeline_success_email.html.haml_spec.rb
index ecd096ee579..a793b37e412 100644
--- a/spec/views/notify/pipeline_success_email.html.haml_spec.rb
+++ b/spec/views/notify/pipeline_success_email.html.haml_spec.rb
@@ -4,7 +4,7 @@ describe 'notify/pipeline_success_email.html.haml' do
include Devise::Test::ControllerHelpers
let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:pipeline) do
diff --git a/spec/views/profiles/show.html.haml_spec.rb b/spec/views/profiles/show.html.haml_spec.rb
new file mode 100644
index 00000000000..e89a8cb9626
--- /dev/null
+++ b/spec/views/profiles/show.html.haml_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe 'profiles/show' do
+ let(:user) { create(:user) }
+
+ before do
+ assign(:user, user)
+ allow(controller).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the profile page is opened' do
+ it 'displays the correct elements' do
+ render
+
+ expect(rendered).to have_field('user_name', user.name)
+ expect(rendered).to have_field('user_id', user.id)
+ end
+ end
+end
diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb
index f5381a48207..62af946dcab 100644
--- a/spec/views/projects/_home_panel.html.haml_spec.rb
+++ b/spec/views/projects/_home_panel.html.haml_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
-describe 'projects/_home_panel', :view do
- let(:project) { create(:empty_project, :public) }
+describe 'projects/_home_panel' do
+ let(:project) { create(:project, :public) }
let(:notification_settings) do
user&.notification_settings_for(project)
diff --git a/spec/views/projects/_last_commit.html.haml_spec.rb b/spec/views/projects/_last_commit.html.haml_spec.rb
deleted file mode 100644
index eea1695b171..00000000000
--- a/spec/views/projects/_last_commit.html.haml_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/_last_commit', :view do
- let(:project) { create(:project, :repository) }
-
- context 'when there is a pipeline present for the commit' do
- context 'when pipeline is blocked' do
- let!(:pipeline) do
- create(:ci_pipeline, :blocked, project: project,
- sha: project.commit.id)
- end
-
- it 'shows correct pipeline badge' do
- render 'projects/last_commit', commit: project.commit,
- project: project,
- ref: :master
-
- expect(rendered).to have_text "blocked #{project.commit.short_id}"
- end
- end
- end
-end
diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb
new file mode 100644
index 00000000000..aedbaa66d34
--- /dev/null
+++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe 'projects/blob/_viewer.html.haml' do
+ include FakeBlobHelpers
+
+ let(:project) { build(:project) }
+
+ let(:viewer_class) do
+ Class.new(BlobViewer::Base) do
+ include BlobViewer::Rich
+
+ self.partial_name = 'text'
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 5.megabytes
+ self.load_async = true
+ end
+ end
+
+ let(:viewer) { viewer_class.new(blob) }
+ let(:blob) { fake_blob }
+
+ before do
+ assign(:project, project)
+ assign(:blob, blob)
+ assign(:id, File.join('master', blob.path))
+
+ controller.params[:controller] = 'projects/blob'
+ controller.params[:action] = 'show'
+ controller.params[:namespace_id] = project.namespace.to_param
+ controller.params[:project_id] = project.to_param
+ controller.params[:id] = File.join('master', blob.path)
+ end
+
+ def render_view
+ render partial: 'projects/blob/viewer', locals: { viewer: viewer }
+ end
+
+ context 'when the viewer is loaded asynchronously' do
+ before do
+ viewer_class.load_async = true
+ end
+
+ context 'when there is no render error' do
+ it 'adds a URL to the blob viewer element' do
+ render_view
+
+ expect(rendered).to have_css('.blob-viewer[data-url]')
+ end
+
+ it 'renders the loading indicator' do
+ render_view
+
+ expect(view).to render_template('projects/blob/viewers/_loading')
+ end
+ end
+
+ context 'when there is a render error' do
+ let(:blob) { fake_blob(size: 10.megabytes) }
+
+ it 'renders the error' do
+ render_view
+
+ expect(view).to render_template('projects/blob/_render_error')
+ end
+ end
+ end
+
+ context 'when the viewer is loaded synchronously' do
+ before do
+ viewer_class.load_async = false
+ end
+
+ context 'when there is no render error' do
+ it 'prepares the viewer' do
+ expect(viewer).to receive(:prepare!)
+
+ render_view
+ end
+
+ it 'renders the viewer' do
+ render_view
+
+ expect(view).to render_template('projects/blob/viewers/_text')
+ end
+ end
+
+ context 'when there is a render error' do
+ let(:blob) { fake_blob(size: 10.megabytes) }
+
+ it 'renders the error' do
+ render_view
+
+ expect(view).to render_template('projects/blob/_render_error')
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/builds/_build.html.haml_spec.rb b/spec/views/projects/builds/_build.html.haml_spec.rb
deleted file mode 100644
index 751482cac42..00000000000
--- a/spec/views/projects/builds/_build.html.haml_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/ci/builds/_build' do
- include Devise::Test::ControllerHelpers
-
- let(:project) { create(:project, :repository) }
- let(:pipeline) { create(:ci_empty_pipeline, id: 1337, project: project, sha: project.commit.id) }
- let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', stage_idx: 1, name: 'rspec 0:2', status: :pending) }
-
- before do
- controller.prepend_view_path('app/views/projects')
- allow(view).to receive(:can?).and_return(true)
- end
-
- it 'won\'t include a column with a link to its pipeline by default' do
- render partial: 'projects/ci/builds/build', locals: { build: build }
-
- expect(rendered).not_to have_link('#1337')
- expect(rendered).not_to have_text('#1337 by API')
- end
-
- it 'can include a column with a link to its pipeline' do
- render partial: 'projects/ci/builds/build', locals: { build: build, pipeline_link: true }
-
- expect(rendered).to have_link('#1337')
- expect(rendered).to have_text('#1337 by API')
- end
-end
diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb
deleted file mode 100644
index 0f39df0f250..00000000000
--- a/spec/views/projects/builds/show.html.haml_spec.rb
+++ /dev/null
@@ -1,293 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/builds/show', :view do
- let(:project) { create(:project, :repository) }
- let(:build) { create(:ci_build, pipeline: pipeline) }
-
- let(:pipeline) do
- create(:ci_pipeline, project: project, sha: project.commit.id)
- end
-
- before do
- assign(:build, build.present)
- assign(:project, project)
-
- allow(view).to receive(:can?).and_return(true)
- end
-
- describe 'job information in header' do
- let(:build) do
- create(:ci_build, :success, environment: 'staging')
- end
-
- before do
- render
- end
-
- it 'shows status name' do
- expect(rendered).to have_css('.ci-status.ci-success', text: 'passed')
- end
-
- it 'does not render a link to the job' do
- expect(rendered).not_to have_link('passed')
- end
-
- it 'shows job id' do
- expect(rendered).to have_css('.js-build-id', text: build.id)
- end
-
- it 'shows a link to the pipeline' do
- expect(rendered).to have_link(build.pipeline.id)
- end
-
- it 'shows a link to the commit' do
- expect(rendered).to have_link(build.pipeline.short_sha)
- end
- end
-
- describe 'environment info in job view' do
- context 'job with latest deployment' do
- let(:build) do
- create(:ci_build, :success, environment: 'staging')
- end
-
- before do
- create(:environment, name: 'staging')
- create(:deployment, deployable: build)
- end
-
- it 'shows deployment message' do
- expected_text = 'This job is the most recent deployment'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- end
- end
-
- context 'job with outdated deployment' do
- let(:build) do
- create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
- end
-
- let(:second_build) do
- create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
- end
-
- let(:environment) do
- create(:environment, name: 'staging', project: project)
- end
-
- let!(:first_deployment) do
- create(:deployment, environment: environment, deployable: build)
- end
-
- let!(:second_deployment) do
- create(:deployment, environment: environment, deployable: second_build)
- end
-
- it 'shows deployment message' do
- expected_text = 'This job is an out-of-date deployment ' \
- "to staging.\nView the most recent deployment ##{second_deployment.iid}."
- render
-
- expect(rendered).to have_css('.environment-information', text: expected_text)
- end
- end
-
- context 'job failed to deploy' do
- let(:build) do
- create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
- end
-
- let!(:environment) do
- create(:environment, name: 'staging', project: project)
- end
-
- it 'shows deployment message' do
- expected_text = 'The deployment of this job to staging did not succeed.'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- end
- end
-
- context 'job will deploy' do
- let(:build) do
- create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
- end
-
- context 'when environment exists' do
- let!(:environment) do
- create(:environment, name: 'staging', project: project)
- end
-
- it 'shows deployment message' do
- expected_text = 'This job is creating a deployment to staging'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- end
-
- context 'when it has deployment' do
- let!(:deployment) do
- create(:deployment, environment: environment)
- end
-
- it 'shows that deployment will be overwritten' do
- expected_text = 'This job is creating a deployment to staging'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- expect(rendered).to have_css(
- '.environment-information', text: 'latest deployment')
- end
- end
- end
-
- context 'when environment does not exist' do
- it 'shows deployment message' do
- expected_text = 'This job is creating a deployment to staging'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- expect(rendered).not_to have_css(
- '.environment-information', text: 'latest deployment')
- end
- end
- end
-
- context 'job that failed to deploy and environment has not been created' do
- let(:build) do
- create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
- end
-
- let!(:environment) do
- create(:environment, name: 'staging', project: project)
- end
-
- it 'shows deployment message' do
- expected_text = 'The deployment of this job to staging did not succeed'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- end
- end
-
- context 'job that will deploy and environment has not been created' do
- let(:build) do
- create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
- end
-
- let!(:environment) do
- create(:environment, name: 'staging', project: project)
- end
-
- it 'shows deployment message' do
- expected_text = 'This job is creating a deployment to staging'
- render
-
- expect(rendered).to have_css(
- '.environment-information', text: expected_text)
- expect(rendered).not_to have_css(
- '.environment-information', text: 'latest deployment')
- end
- end
- end
-
- context 'when job is running' do
- before do
- build.run!
- render
- end
-
- it 'does not show retry button' do
- expect(rendered).not_to have_link('Retry')
- end
-
- it 'does not show New issue button' do
- expect(rendered).not_to have_link('New issue')
- end
- end
-
- context 'when job is not running' do
- before do
- build.success!
- render
- end
-
- it 'shows retry button' do
- expect(rendered).to have_link('Retry')
- end
-
- context 'if build passed' do
- it 'does not show New issue button' do
- expect(rendered).not_to have_link('New issue')
- end
- end
-
- context 'if build failed' do
- before do
- build.status = 'failed'
- render
- end
-
- it 'shows New issue button' do
- expect(rendered).to have_link('New issue')
- end
- end
- end
-
- describe 'commit title in sidebar' do
- let(:commit_title) { project.commit.title }
-
- it 'shows commit title and not show commit message' do
- render
-
- expect(rendered).to have_css('p.build-light-text.append-bottom-0',
- text: /\A\n#{Regexp.escape(commit_title)}\n\Z/)
- end
- end
-
- describe 'shows trigger variables in sidebar' do
- let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline) }
-
- before do
- build.trigger_request = trigger_request
- render
- end
-
- it 'shows trigger variables in separate lines' do
- expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_1')
- expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_2')
- expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_1')
- expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
- end
- end
-
- describe 'New issue button' do
- before do
- build.status = 'failed'
- render
- end
-
- it 'links to issues/new with the title and description filled in' do
- title = "Build Failed ##{build.id}"
- build_url = namespace_project_build_url(project.namespace, project, build)
- href = new_namespace_project_issue_path(
- project.namespace,
- project,
- issue: {
- title: title,
- description: build_url
- }
- )
- expect(rendered).to have_link('New issue', href: href)
- end
- end
-end
diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
index ab120929c6c..448b925cf34 100644
--- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb
+++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'projects/commit/_commit_box.html.haml', :view do
+describe 'projects/commit/_commit_box.html.haml' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb
new file mode 100644
index 00000000000..32c95c6bb0d
--- /dev/null
+++ b/spec/views/projects/commit/show.html.haml_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe 'projects/commit/show.html.haml' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ assign(:project, project)
+ assign(:repository, project.repository)
+ assign(:commit, project.commit)
+ assign(:noteable, project.commit)
+ assign(:notes, [])
+ assign(:diffs, project.commit.diffs)
+
+ allow(view).to receive(:current_user).and_return(nil)
+ allow(view).to receive(:can?).and_return(false)
+ allow(view).to receive(:can_collaborate_with_project?).and_return(false)
+ allow(view).to receive(:current_ref).and_return(project.repository.root_ref)
+ allow(view).to receive(:diff_btn).and_return('')
+ end
+
+ context 'inline diff view' do
+ before do
+ allow(view).to receive(:diff_view).and_return(:inline)
+ allow(view).to receive(:diff_view).and_return(:inline)
+
+ render
+ end
+
+ it 'has limited width' do
+ expect(rendered).to have_selector('.limit-container-width')
+ end
+ end
+
+ context 'parallel diff view' do
+ before do
+ allow(view).to receive(:diff_view).and_return(:parallel)
+ allow(view).to receive(:fluid_layout).and_return(true)
+
+ render
+ end
+
+ it 'spans full width' do
+ expect(rendered).not_to have_selector('.limit-container-width')
+ end
+ end
+end
diff --git a/spec/views/projects/commits/_commit.html.haml_spec.rb b/spec/views/projects/commits/_commit.html.haml_spec.rb
new file mode 100644
index 00000000000..4c247361bd7
--- /dev/null
+++ b/spec/views/projects/commits/_commit.html.haml_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe 'projects/commits/_commit.html.haml' do
+ context 'with a singed commit' do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:ref) { GpgHelpers::SIGNED_COMMIT_SHA }
+ let(:commit) { repository.commit(ref) }
+
+ it 'does not display a loading spinner for GPG status' do
+ render partial: 'projects/commits/commit', locals: {
+ project: project,
+ ref: ref,
+ commit: commit
+ }
+
+ within '.gpg-status-box' do
+ expect(page).not_to have_css('i.fa.fa-spinner.fa-spin')
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/diffs/_viewer.html.haml_spec.rb b/spec/views/projects/diffs/_viewer.html.haml_spec.rb
new file mode 100644
index 00000000000..8ac32db5585
--- /dev/null
+++ b/spec/views/projects/diffs/_viewer.html.haml_spec.rb
@@ -0,0 +1,71 @@
+require 'spec_helper'
+
+describe 'projects/diffs/_viewer.html.haml' do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') }
+
+ let(:viewer_class) do
+ Class.new(DiffViewer::Base) do
+ include DiffViewer::Rich
+
+ self.partial_name = 'text'
+ end
+ end
+
+ let(:viewer) { viewer_class.new(diff_file) }
+
+ before do
+ assign(:project, project)
+
+ controller.params[:controller] = 'projects/commit'
+ controller.params[:action] = 'show'
+ controller.params[:namespace_id] = project.namespace.to_param
+ controller.params[:project_id] = project.to_param
+ controller.params[:id] = commit.id
+ end
+
+ def render_view
+ render partial: 'projects/diffs/viewer', locals: { viewer: viewer }
+ end
+
+ context 'when there is a render error' do
+ before do
+ allow(viewer).to receive(:render_error).and_return(:too_large)
+ end
+
+ it 'renders the error' do
+ render_view
+
+ expect(view).to render_template('projects/diffs/_render_error')
+ end
+ end
+
+ context 'when the viewer is collapsed' do
+ before do
+ allow(diff_file).to receive(:collapsed?).and_return(true)
+ end
+
+ it 'renders the collapsed view' do
+ render_view
+
+ expect(view).to render_template('projects/diffs/_collapsed')
+ end
+ end
+
+ context 'when there is no render error' do
+ it 'prepares the viewer' do
+ expect(viewer).to receive(:prepare!)
+
+ render_view
+ end
+
+ it 'renders the viewer' do
+ render_view
+
+ expect(view).to render_template('projects/diffs/viewers/_text')
+ end
+ end
+end
diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb
index d2575702ecc..1af422941d7 100644
--- a/spec/views/projects/edit.html.haml_spec.rb
+++ b/spec/views/projects/edit.html.haml_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe 'projects/edit' do
include Devise::Test::ControllerHelpers
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:user) { create(:admin) }
before do
@@ -11,14 +11,26 @@ describe 'projects/edit' do
allow(controller).to receive(:current_user).and_return(user)
allow(view).to receive_messages(current_user: user, can?: true)
- allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
end
context 'LFS enabled setting' do
it 'displays the correct elements' do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+
render
+
expect(rendered).to have_select('project_lfs_enabled')
expect(rendered).to have_content('Git Large File Storage')
end
end
+
+ context 'project export disabled' do
+ it 'does not display the project export option' do
+ stub_application_setting(project_export_enabled?: false)
+
+ render
+
+ expect(rendered).not_to have_content('Export project')
+ end
+ end
end
diff --git a/spec/views/projects/environments/terminal.html.haml_spec.rb b/spec/views/projects/environments/terminal.html.haml_spec.rb
new file mode 100644
index 00000000000..d2e47225226
--- /dev/null
+++ b/spec/views/projects/environments/terminal.html.haml_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe 'projects/environments/terminal' do
+ let!(:environment) { create(:environment, :with_review_app) }
+
+ before do
+ assign(:environment, environment)
+ assign(:project, environment.project)
+
+ allow(view).to receive(:can?).and_return(true)
+ end
+
+ context 'when environment has external URL' do
+ it 'shows external URL button' do
+ environment.update_attribute(:external_url, 'https://gitlab.com')
+
+ render
+
+ expect(rendered).to have_link(nil, href: 'https://gitlab.com')
+ end
+ end
+
+ context 'when environment does not have external URL' do
+ it 'shows external URL button' do
+ environment.update_attribute(:external_url, nil)
+
+ render
+
+ expect(rendered).not_to have_link(nil, href: 'https://gitlab.com')
+ end
+ end
+end
diff --git a/spec/views/projects/imports/new.html.haml_spec.rb b/spec/views/projects/imports/new.html.haml_spec.rb
new file mode 100644
index 00000000000..9b293065797
--- /dev/null
+++ b/spec/views/projects/imports/new.html.haml_spec.rb
@@ -0,0 +1,22 @@
+require "spec_helper"
+
+describe "projects/imports/new.html.haml" do
+ let(:user) { create(:user) }
+
+ context 'when import fails' do
+ let(:project) { create(:project_empty_repo, import_status: :failed, import_error: '<a href="http://googl.com">Foo</a>', import_type: :gitlab_project, import_source: '/var/opt/gitlab/gitlab-rails/shared/tmp/project_exports/uploads/t.tar.gz', import_url: nil) }
+
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ it "escapes HTML in import errors" do
+ assign(:project, project)
+
+ render
+
+ expect(rendered).not_to have_link('Foo', href: "http://googl.com")
+ end
+ end
+end
diff --git a/spec/views/projects/jobs/_build.html.haml_spec.rb b/spec/views/projects/jobs/_build.html.haml_spec.rb
new file mode 100644
index 00000000000..1d58891036e
--- /dev/null
+++ b/spec/views/projects/jobs/_build.html.haml_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe 'projects/ci/jobs/_build' do
+ include Devise::Test::ControllerHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { create(:ci_empty_pipeline, id: 1337, project: project, sha: project.commit.id) }
+ let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', stage_idx: 1, name: 'rspec 0:2', status: :pending) }
+
+ before do
+ controller.prepend_view_path('app/views/projects')
+ allow(view).to receive(:can?).and_return(true)
+ end
+
+ it 'won\'t include a column with a link to its pipeline by default' do
+ render partial: 'projects/ci/builds/build', locals: { build: build }
+
+ expect(rendered).not_to have_link('#1337')
+ expect(rendered).not_to have_text('#1337 by API')
+ end
+
+ it 'can include a column with a link to its pipeline' do
+ render partial: 'projects/ci/builds/build', locals: { build: build, pipeline_link: true }
+
+ expect(rendered).to have_link('#1337')
+ expect(rendered).to have_text('#1337 by API')
+ end
+end
diff --git a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb
index dc2ffc9dc47..dc2ffc9dc47 100644
--- a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb
+++ b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb
diff --git a/spec/views/projects/jobs/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb
new file mode 100644
index 00000000000..117f48450e2
--- /dev/null
+++ b/spec/views/projects/jobs/show.html.haml_spec.rb
@@ -0,0 +1,214 @@
+require 'spec_helper'
+
+describe 'projects/jobs/show' do
+ let(:project) { create(:project, :repository) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, project: project, sha: project.commit.id)
+ end
+
+ before do
+ assign(:build, build.present)
+ assign(:project, project)
+
+ allow(view).to receive(:can?).and_return(true)
+ end
+
+ describe 'environment info in job view' do
+ context 'job with latest deployment' do
+ let(:build) do
+ create(:ci_build, :success, environment: 'staging')
+ end
+
+ before do
+ create(:environment, name: 'staging')
+ create(:deployment, deployable: build)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This job is the most recent deployment'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'job with outdated deployment' do
+ let(:build) do
+ create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ end
+
+ let(:second_build) do
+ create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ end
+
+ let(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ let!(:first_deployment) do
+ create(:deployment, environment: environment, deployable: build)
+ end
+
+ let!(:second_deployment) do
+ create(:deployment, environment: environment, deployable: second_build)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This job is an out-of-date deployment ' \
+ "to staging.\nView the most recent deployment ##{second_deployment.iid}."
+ render
+
+ expect(rendered).to have_css('.environment-information', text: expected_text)
+ end
+ end
+
+ context 'job failed to deploy' do
+ let(:build) do
+ create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'The deployment of this job to staging did not succeed.'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'job will deploy' do
+ let(:build) do
+ create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ end
+
+ context 'when environment exists' do
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This job is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+
+ context 'when it has deployment' do
+ let!(:deployment) do
+ create(:deployment, environment: environment)
+ end
+
+ it 'shows that deployment will be overwritten' do
+ expected_text = 'This job is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ expect(rendered).to have_css(
+ '.environment-information', text: 'latest deployment')
+ end
+ end
+ end
+
+ context 'when environment does not exist' do
+ it 'shows deployment message' do
+ expected_text = 'This job is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ expect(rendered).not_to have_css(
+ '.environment-information', text: 'latest deployment')
+ end
+ end
+ end
+
+ context 'job that failed to deploy and environment has not been created' do
+ let(:build) do
+ create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'The deployment of this job to staging did not succeed'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ end
+ end
+
+ context 'job that will deploy and environment has not been created' do
+ let(:build) do
+ create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ end
+
+ let!(:environment) do
+ create(:environment, name: 'staging', project: project)
+ end
+
+ it 'shows deployment message' do
+ expected_text = 'This job is creating a deployment to staging'
+ render
+
+ expect(rendered).to have_css(
+ '.environment-information', text: expected_text)
+ expect(rendered).not_to have_css(
+ '.environment-information', text: 'latest deployment')
+ end
+ end
+ end
+
+ context 'when job is running' do
+ before do
+ build.run!
+ render
+ end
+
+ it 'does not show retry button' do
+ expect(rendered).not_to have_link('Retry')
+ end
+
+ it 'does not show New issue button' do
+ expect(rendered).not_to have_link('New issue')
+ end
+ end
+
+ describe 'commit title in sidebar' do
+ let(:commit_title) { project.commit.title }
+
+ it 'shows commit title and not show commit message' do
+ render
+
+ expect(rendered).to have_css('p.build-light-text.append-bottom-0',
+ text: /\A\n#{Regexp.escape(commit_title)}\n\Z/)
+ end
+ end
+
+ describe 'shows trigger variables in sidebar' do
+ let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline) }
+
+ before do
+ build.trigger_request = trigger_request
+ render
+ end
+
+ it 'shows trigger variables in separate lines' do
+ expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_1')
+ expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_2')
+ expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_1')
+ expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
+ end
+ end
+end
diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
index 4052dbf8df3..98c7de9b709 100644
--- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'projects/merge_requests/show/_commits.html.haml' do
+describe 'projects/merge_requests/_commits.html.haml' do
include Devise::Test::ControllerHelpers
let(:user) { create(:user) }
@@ -25,10 +25,7 @@ describe 'projects/merge_requests/show/_commits.html.haml' do
render
commit = source_project.commit(merge_request.source_branch)
- href = namespace_project_commit_path(
- source_project.namespace,
- source_project,
- commit)
+ href = project_commit_path(source_project, commit)
expect(rendered).to have_link(Commit.truncate_sha(commit.sha), href: href)
end
diff --git a/spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb b/spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb
deleted file mode 100644
index 4f698a34ab5..00000000000
--- a/spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/merge_requests/_new_submit.html.haml', :view do
- let(:merge_request) { create(:merge_request) }
- let!(:pipeline) { create(:ci_empty_pipeline) }
-
- before do
- controller.prepend_view_path('app/views/projects')
-
- assign(:merge_request, merge_request)
- assign(:commits, merge_request.commits)
- assign(:project, merge_request.target_project)
-
- allow(view).to receive(:can?).and_return(true)
- allow(view).to receive(:url_for).and_return('#')
- allow(view).to receive(:current_user).and_return(merge_request.author)
- end
-
- context 'when there are pipelines for merge request but no pipeline for last commit' do
- before do
- assign(:pipelines, Ci::Pipeline.all)
- assign(:pipeline, nil)
- end
-
- it 'shows <<Pipelines>> tab and hides <<Builds>> tab' do
- render
- expect(rendered).to have_text('Pipelines 1')
- expect(rendered).not_to have_text('Builds')
- end
- end
-end
diff --git a/spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb b/spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb
new file mode 100644
index 00000000000..5770cf92b4e
--- /dev/null
+++ b/spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe 'projects/merge_requests/creations/_new_submit.html.haml' do
+ let(:merge_request) { create(:merge_request) }
+ let!(:pipeline) { create(:ci_empty_pipeline) }
+
+ before do
+ controller.prepend_view_path('app/views/projects')
+
+ assign(:merge_request, merge_request)
+ assign(:commits, merge_request.commits)
+ assign(:project, merge_request.target_project)
+
+ allow(view).to receive(:can?).and_return(true)
+ allow(view).to receive(:url_for).and_return('#')
+ allow(view).to receive(:current_user).and_return(merge_request.author)
+ end
+
+ context 'when there are pipelines for merge request but no pipeline for last commit' do
+ before do
+ assign(:pipelines, Ci::Pipeline.all)
+ assign(:pipeline, nil)
+ end
+
+ it 'shows <<Pipelines>> tab and hides <<Builds>> tab' do
+ render
+ expect(rendered).to have_text('Pipelines 1')
+ expect(rendered).not_to have_text('Builds')
+ end
+ end
+end
diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/projects/notes/_form.html.haml_spec.rb
deleted file mode 100644
index a364f9bce92..00000000000
--- a/spec/views/projects/notes/_form.html.haml_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/notes/_form' do
- include Devise::Test::ControllerHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- before do
- project.team << [user, :master]
- assign(:project, project)
- assign(:note, note)
-
- allow(view).to receive(:current_user).and_return(user)
-
- render
- end
-
- %w[issue merge_request].each do |noteable|
- context "with a note on #{noteable}" do
- let(:note) { build(:"note_on_#{noteable}", project: project) }
-
- it 'says that markdown and slash commands are supported' do
- expect(rendered).to have_content('Markdown and slash commands are supported')
- end
- end
- end
-
- context 'with a note on a commit' do
- let(:note) { build(:note_on_commit, project: project) }
-
- it 'says that only markdown is supported, not slash commands' do
- expect(rendered).to have_content('Markdown is supported')
- end
- end
-end
diff --git a/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb b/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb
new file mode 100644
index 00000000000..9c0be249a50
--- /dev/null
+++ b/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe 'projects/notes/_more_actions_dropdown' do
+ let(:author_user) { create(:user) }
+ let(:not_author_user) { create(:user) }
+
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let!(:note) { create(:note_on_issue, author: author_user, noteable: issue, project: project) }
+
+ before do
+ assign(:project, project)
+ end
+
+ it 'shows Report as abuse button if not editable and not current users comment' do
+ render 'projects/notes/more_actions_dropdown', current_user: not_author_user, note_editable: false, note: note
+
+ expect(rendered).to have_link('Report as abuse')
+ end
+
+ it 'does not show the More actions button if not editable and current users comment' do
+ render 'projects/notes/more_actions_dropdown', current_user: author_user, note_editable: false, note: note
+
+ expect(rendered).not_to have_selector('.dropdown.more-actions')
+ end
+
+ it 'shows Report as abuse and Delete buttons if editable and not current users comment' do
+ render 'projects/notes/more_actions_dropdown', current_user: not_author_user, note_editable: true, note: note
+
+ expect(rendered).to have_link('Report as abuse')
+ expect(rendered).to have_link('Delete comment')
+ end
+
+ it 'shows Delete button if editable and current users comment' do
+ render 'projects/notes/more_actions_dropdown', current_user: author_user, note_editable: true, note: note
+
+ expect(rendered).to have_link('Delete comment')
+ end
+end
diff --git a/spec/views/projects/pipelines/_stage.html.haml_spec.rb b/spec/views/projects/pipelines/_stage.html.haml_spec.rb
index 10095ad7694..e40e16e742b 100644
--- a/spec/views/projects/pipelines/_stage.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/_stage.html.haml_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'projects/pipelines/_stage', :view do
+describe 'projects/pipelines/_stage' do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:stage) { build(:ci_stage, pipeline: pipeline) }
@@ -39,9 +39,8 @@ describe 'projects/pipelines/_stage', :view do
context 'when there are retried builds present' do
before do
- create_list(:ci_build, 2, name: 'test:build',
- stage: stage.name,
- pipeline: pipeline)
+ create(:ci_build, name: 'test:build', stage: stage.name, pipeline: pipeline, retried: true)
+ create(:ci_build, name: 'test:build', stage: stage.name, pipeline: pipeline)
end
it 'shows only latest builds' do
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
deleted file mode 100644
index bb39ec8efbf..00000000000
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/pipelines/show' do
- include Devise::Test::ControllerHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- 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')
-
- create_build('build', 0, 'build', :success)
- create_build('test', 1, 'rspec 0:2', :pending)
- create_build('test', 1, 'rspec 1:2', :running)
- create_build('test', 1, 'spinach 0:2', :created)
- create_build('test', 1, 'spinach 1:2', :created)
- create_build('test', 1, 'audit', :created)
- create_build('deploy', 2, 'production', :created)
-
- create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
-
- assign(:project, project)
- assign(:pipeline, pipeline.present(current_user: user))
- assign(:commit, project.commit)
-
- allow(view).to receive(:can?).and_return(true)
- end
-
- it 'shows a graph with grouped stages' do
- render
-
- expect(rendered).to have_css('.js-pipeline-graph')
- expect(rendered).to have_css('.js-grouped-pipeline-dropdown')
-
- # header
- expect(rendered).to have_text("##{pipeline.id}")
- expect(rendered).to have_css('time', text: pipeline.created_at.strftime("%b %d, %Y"))
- expect(rendered).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"]))
- expect(rendered).to have_link(pipeline.user.name, href: user_path(pipeline.user))
-
- # stages
- expect(rendered).to have_text('Build')
- expect(rendered).to have_text('Test')
- expect(rendered).to have_text('Deploy')
- expect(rendered).to have_text('External')
-
- # builds
- expect(rendered).to have_text('rspec')
- expect(rendered).to have_text('spinach')
- expect(rendered).to have_text('rspec 0:2')
- expect(rendered).to have_text('production')
- expect(rendered).to have_text('jenkins')
- end
-
- private
-
- def create_build(stage, stage_idx, name, status)
- create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status)
- end
-end
diff --git a/spec/views/projects/registry/repositories/index.html.haml_spec.rb b/spec/views/projects/registry/repositories/index.html.haml_spec.rb
index ceeace3dc8d..cf0aa44a4a2 100644
--- a/spec/views/projects/registry/repositories/index.html.haml_spec.rb
+++ b/spec/views/projects/registry/repositories/index.html.haml_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
-describe 'projects/registry/repositories/index', :view do
+describe 'projects/registry/repositories/index' do
let(:group) { create(:group, path: 'group') }
- let(:project) { create(:empty_project, group: group, path: 'test') }
+ let(:project) { create(:project, group: group, path: 'test') }
let(:repository) do
create(:container_repository, project: project, name: 'image')
diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb
new file mode 100644
index 00000000000..cb97d17988c
--- /dev/null
+++ b/spec/views/projects/tags/index.html.haml_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe 'projects/tags/index' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ assign(:project, project)
+ assign(:repository, project.repository)
+ assign(:tags, [])
+
+ allow(view).to receive(:current_ref).and_return('master')
+ allow(view).to receive(:can?).and_return(false)
+ end
+
+ it 'defaults sort dropdown toggle to last updated' do
+ render
+
+ expect(rendered).to have_button('Last updated')
+ end
+end
diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb
index 900f8d4732f..33eba3e6d3d 100644
--- a/spec/views/projects/tree/show.html.haml_spec.rb
+++ b/spec/views/projects/tree/show.html.haml_spec.rb
@@ -21,17 +21,17 @@ describe 'projects/tree/show' do
let(:tree) { repository.tree(commit.id, path) }
before do
+ assign(:id, File.join(ref, path))
assign(:ref, ref)
- assign(:commit, commit)
- assign(:id, commit.id)
- assign(:tree, tree)
assign(:path, path)
+ assign(:last_commit, commit)
+ assign(:tree, tree)
end
it 'displays correctly' do
render
expect(rendered).to have_css('.js-project-refs-dropdown .dropdown-toggle-text', text: ref)
- expect(rendered).to have_css('.readme-holder .file-content', text: ref)
+ expect(rendered).to have_css('.readme-holder')
end
end
end
diff --git a/spec/views/shared/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb
new file mode 100644
index 00000000000..cae6bee2776
--- /dev/null
+++ b/spec/views/shared/notes/_form.html.haml_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe 'shared/notes/_form' do
+ include Devise::Test::ControllerHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.team << [user, :master]
+ assign(:project, project)
+ assign(:note, note)
+
+ allow(view).to receive(:current_user).and_return(user)
+
+ render
+ end
+
+ %w[issue merge_request].each do |noteable|
+ context "with a note on #{noteable}" do
+ let(:note) { build(:"note_on_#{noteable}", project: project) }
+
+ it 'says that markdown and quick actions are supported' do
+ expect(rendered).to have_content('Markdown and quick actions are supported')
+ end
+ end
+ end
+
+ context 'with a note on a commit' do
+ let(:note) { build(:note_on_commit, project: project) }
+
+ it 'says that only markdown is supported, not quick actions' do
+ expect(rendered).to have_content('Markdown is supported')
+ end
+ end
+end
diff --git a/spec/views/shared/projects/_project.html.haml_spec.rb b/spec/views/shared/projects/_project.html.haml_spec.rb
new file mode 100644
index 00000000000..b500016016a
--- /dev/null
+++ b/spec/views/shared/projects/_project.html.haml_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe 'shared/projects/_project.html.haml' do
+ let(:project) { create(:project) }
+
+ it 'should render creator avatar if project has a creator' do
+ render 'shared/projects/project', use_creator_avatar: true, project: project
+
+ expect(rendered).to have_selector('img.avatar')
+ end
+
+ it 'should render a generic avatar if project does not have a creator' do
+ project.creator = nil
+
+ render 'shared/projects/project', use_creator_avatar: true, project: project
+
+ expect(rendered).to have_selector('.project-avatar')
+ end
+end
diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb
index bd5cc651c2b..03b9b99e263 100644
--- a/spec/workers/authorized_projects_worker_spec.rb
+++ b/spec/workers/authorized_projects_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe AuthorizedProjectsWorker do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
describe '.bulk_perform_and_wait' do
it 'schedules the ids and waits for the jobs to complete' do
diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb
new file mode 100644
index 00000000000..4f6e3474634
--- /dev/null
+++ b/spec/workers/background_migration_worker_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe BackgroundMigrationWorker, :sidekiq do
+ describe '.perform' do
+ it 'performs a background migration' do
+ expect(Gitlab::BackgroundMigration)
+ .to receive(:perform)
+ .with('Foo', [10, 20])
+
+ described_class.new.perform('Foo', [10, 20])
+ end
+ end
+
+ describe '.perform_bulk' do
+ it 'enqueues background migrations in bulk' do
+ Sidekiq::Testing.fake! do
+ described_class.perform_bulk([['Foo', [1]], ['Foo', [2]]])
+
+ expect(described_class.jobs.count).to eq 2
+ expect(described_class.jobs).to all(include('enqueued_at'))
+ end
+ end
+ end
+
+ describe '.perform_bulk_in' do
+ context 'when delay is valid' do
+ it 'correctly schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ described_class.perform_bulk_in(1.minute, [['Foo', [1]], ['Foo', [2]]])
+
+ expect(described_class.jobs.count).to eq 2
+ expect(described_class.jobs).to all(include('at'))
+ end
+ end
+ end
+
+ context 'when delay is invalid' do
+ it 'raises an ArgumentError exception' do
+ expect { described_class.perform_bulk_in(-60, [['Foo']]) }
+ .to raise_error(ArgumentError)
+ end
+ end
+ end
+end
diff --git a/spec/workers/create_gpg_signature_worker_spec.rb b/spec/workers/create_gpg_signature_worker_spec.rb
new file mode 100644
index 00000000000..54978baca88
--- /dev/null
+++ b/spec/workers/create_gpg_signature_worker_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe CreateGpgSignatureWorker do
+ let(:project) { create(:project, :repository) }
+
+ context 'when GpgKey is found' do
+ let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
+
+ it 'calls Gitlab::Gpg::Commit#signature' do
+ expect(Gitlab::Gpg::Commit).to receive(:new).with(project, commit_sha).and_call_original
+
+ expect_any_instance_of(Gitlab::Gpg::Commit).to receive(:signature)
+
+ described_class.new.perform(commit_sha, project.id)
+ end
+ end
+
+ context 'when Commit is not found' do
+ let(:nonexisting_commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a34' }
+
+ it 'does not raise errors' do
+ expect { described_class.new.perform(nonexisting_commit_sha, project.id) }.not_to raise_error
+ end
+ end
+
+ context 'when Project is not found' do
+ let(:nonexisting_project_id) { -1 }
+
+ it 'does not raise errors' do
+ expect { described_class.new.perform(anything, nonexisting_project_id) }.not_to raise_error
+ end
+
+ it 'does not call Gitlab::Gpg::Commit#signature' do
+ expect_any_instance_of(Gitlab::Gpg::Commit).not_to receive(:signature)
+
+ described_class.new.perform(anything, nonexisting_project_id)
+ end
+ end
+end
diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb
index 0765573408c..36594515005 100644
--- a/spec/workers/delete_user_worker_spec.rb
+++ b/spec/workers/delete_user_worker_spec.rb
@@ -5,16 +5,16 @@ describe DeleteUserWorker do
let!(:current_user) { create(:user) }
it "calls the DeleteUserWorker with the params it was given" do
- expect_any_instance_of(Users::DestroyService).to receive(:execute).
- with(user, {})
+ expect_any_instance_of(Users::DestroyService).to receive(:execute)
+ .with(user, {})
- DeleteUserWorker.new.perform(current_user.id, user.id)
+ described_class.new.perform(current_user.id, user.id)
end
it "uses symbolized keys" do
- expect_any_instance_of(Users::DestroyService).to receive(:execute).
- with(user, test: "test")
+ expect_any_instance_of(Users::DestroyService).to receive(:execute)
+ .with(user, test: "test")
- DeleteUserWorker.new.perform(current_user.id, user.id, "test" => "test")
+ described_class.new.perform(current_user.id, user.id, "test" => "test")
end
end
diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb
index fe70501eeac..e4e77c667b3 100644
--- a/spec/workers/email_receiver_worker_spec.rb
+++ b/spec/workers/email_receiver_worker_spec.rb
@@ -1,6 +1,6 @@
require "spec_helper"
-describe EmailReceiverWorker do
+describe EmailReceiverWorker, :mailer do
let(:raw_message) { fixture_file('emails/valid_reply.eml') }
context "when reply by email is enabled" do
@@ -17,12 +17,16 @@ describe EmailReceiverWorker do
context "when an error occurs" do
before do
- allow_any_instance_of(Gitlab::Email::Receiver).to receive(:execute).and_raise(Gitlab::Email::EmptyEmailError)
+ allow_any_instance_of(Gitlab::Email::Receiver).to receive(:execute).and_raise(error)
end
- it "sends out a rejection email" do
- perform_enqueued_jobs do
- described_class.new.perform(raw_message)
+ context 'when the error is Gitlab::Email::EmptyEmailError' do
+ let(:error) { Gitlab::Email::EmptyEmailError }
+
+ it 'sends out a rejection email' do
+ perform_enqueued_jobs do
+ described_class.new.perform(raw_message)
+ end
email = ActionMailer::Base.deliveries.last
expect(email).not_to be_nil
@@ -30,6 +34,18 @@ describe EmailReceiverWorker do
expect(email.subject).to include("Rejected")
end
end
+
+ context 'when the error is Gitlab::Email::AutoGeneratedEmailError' do
+ let(:error) { Gitlab::Email::AutoGeneratedEmailError }
+
+ it 'does not send out any rejection email' do
+ perform_enqueued_jobs do
+ described_class.new.perform(raw_message)
+ end
+
+ should_not_email_anyone
+ end
+ end
end
end
diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb
index 8cf2b888f9a..318aad4bc1e 100644
--- a/spec/workers/emails_on_push_worker_spec.rb
+++ b/spec/workers/emails_on_push_worker_spec.rb
@@ -1,8 +1,7 @@
require 'spec_helper'
-describe EmailsOnPushWorker do
+describe EmailsOnPushWorker, :mailer do
include RepoHelpers
- include EmailHelpers
include EmailSpec::Matchers
let(:project) { create(:project, :repository) }
@@ -12,7 +11,7 @@ describe EmailsOnPushWorker do
let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) }
let(:email) { ActionMailer::Base.deliveries.last }
- subject { EmailsOnPushWorker.new }
+ subject { described_class.new }
describe "#perform" do
context "when push is a new branch" do
@@ -71,7 +70,9 @@ describe EmailsOnPushWorker do
end
context "when there are no errors in sending" do
- before { perform }
+ before do
+ perform
+ end
it "sends a mail with the correct subject" do
expect(email.subject).to include('adds bar folder and branch-test text file')
@@ -88,7 +89,6 @@ describe EmailsOnPushWorker do
context "when there is an SMTP error" do
before do
- reset_delivered_emails!
allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError)
allow(subject).to receive_message_chain(:logger, :info)
perform
@@ -112,8 +112,6 @@ describe EmailsOnPushWorker do
allow_any_instance_of(Mail::TestMailer).to receive(:deliver!).and_wrap_original do |original, mail|
original.call(Mail.new(mail.encoded))
end
-
- reset_delivered_emails!
end
it "sends the mail to each of the recipients" do
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index fc9adf47c1e..30908534eb3 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -5,8 +5,8 @@ describe 'Every Sidekiq worker' do
root = Rails.root.join('app', 'workers')
concerns = root.join('concerns').to_s
- workers = Dir[root.join('**', '*.rb')].
- reject { |path| path.start_with?(concerns) }
+ workers = Dir[root.join('**', '*.rb')]
+ .reject { |path| path.start_with?(concerns) }
workers.map do |path|
ns = Pathname.new(path).relative_path_from(root).to_s.gsub('.rb', '')
@@ -22,9 +22,9 @@ describe 'Every Sidekiq worker' do
end
it 'uses the cronjob queue when the worker runs as a cronjob' do
- cron_workers = Settings.cron_jobs.
- map { |job_name, options| options['job_class'].constantize }.
- to_set
+ cron_workers = Settings.cron_jobs
+ .map { |job_name, options| options['job_class'].constantize }
+ .to_set
workers.each do |worker|
next unless cron_workers.include?(worker)
diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb
index 73cbadc13d9..b47b4a02a68 100644
--- a/spec/workers/expire_build_artifacts_worker_spec.rb
+++ b/spec/workers/expire_build_artifacts_worker_spec.rb
@@ -5,10 +5,14 @@ describe ExpireBuildArtifactsWorker do
let(:worker) { described_class.new }
- before { Sidekiq::Worker.clear_all }
+ before do
+ Sidekiq::Worker.clear_all
+ end
describe '#perform' do
- before { build }
+ before do
+ build
+ end
subject! do
Sidekiq::Testing.fake! { worker.perform }
diff --git a/spec/workers/expire_build_instance_artifacts_worker_spec.rb b/spec/workers/expire_build_instance_artifacts_worker_spec.rb
index d202b3de77e..bed5c5e2ecb 100644
--- a/spec/workers/expire_build_instance_artifacts_worker_spec.rb
+++ b/spec/workers/expire_build_instance_artifacts_worker_spec.rb
@@ -30,18 +30,6 @@ describe ExpireBuildInstanceArtifactsWorker do
expect(build.reload.artifacts_file_identifier).to be_nil
end
end
-
- context 'when associated project was removed' do
- let(:build) do
- create(:ci_build, :artifacts, artifacts_expiry) do |build|
- build.project.delete
- end
- end
-
- it 'does not remove artifacts' do
- expect(build.reload.artifacts_file.exists?).to be_truthy
- end
- end
end
context 'with not yet expired artifacts' do
diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb
new file mode 100644
index 00000000000..1b614342a18
--- /dev/null
+++ b/spec/workers/expire_job_cache_worker_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe ExpireJobCacheWorker do
+ set(:pipeline) { create(:ci_empty_pipeline) }
+ let(:project) { pipeline.project }
+ subject { described_class.new }
+
+ describe '#perform' do
+ context 'with a job in the pipeline' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'invalidates Etag caching for the job path' do
+ pipeline_path = "/#{project.full_path}/pipelines/#{pipeline.id}.json"
+ job_path = "/#{project.full_path}/builds/#{job.id}.json"
+
+ expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipeline_path)
+ expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(job_path)
+
+ subject.perform(job.id)
+ end
+ end
+
+ context 'when there is no job in the pipeline' do
+ it 'does not change the etag store' do
+ expect(Gitlab::EtagCaching::Store).not_to receive(:new)
+
+ subject.perform(9999)
+ end
+ end
+ end
+end
diff --git a/spec/workers/expire_pipeline_cache_worker_spec.rb b/spec/workers/expire_pipeline_cache_worker_spec.rb
index ceba604dea2..54c9a69d329 100644
--- a/spec/workers/expire_pipeline_cache_worker_spec.rb
+++ b/spec/workers/expire_pipeline_cache_worker_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe ExpirePipelineCacheWorker do
let(:user) { create(:user) }
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
subject { described_class.new }
@@ -10,9 +10,11 @@ describe ExpirePipelineCacheWorker do
it 'invalidates Etag caching for project pipelines path' do
pipelines_path = "/#{project.full_path}/pipelines.json"
new_mr_pipelines_path = "/#{project.full_path}/merge_requests/new.json"
+ pipeline_path = "/#{project.full_path}/pipelines/#{pipeline.id}.json"
expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipelines_path)
expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(new_mr_pipelines_path)
+ expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipeline_path)
subject.perform(pipeline.id)
end
@@ -35,8 +37,8 @@ describe ExpirePipelineCacheWorker do
end
it 'updates the cached status for a project' do
- expect(Gitlab::Cache::Ci::ProjectPipelineStatus).to receive(:update_for_pipeline).
- with(pipeline)
+ expect(Gitlab::Cache::Ci::ProjectPipelineStatus).to receive(:update_for_pipeline)
+ .with(pipeline)
subject.perform(pipeline.id)
end
diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb
index 029f35512e0..05f971dfd13 100644
--- a/spec/workers/git_garbage_collect_worker_spec.rb
+++ b/spec/workers/git_garbage_collect_worker_spec.rb
@@ -6,24 +6,60 @@ describe GitGarbageCollectWorker do
let(:project) { create(:project, :repository) }
let(:shell) { Gitlab::Shell.new }
- subject { GitGarbageCollectWorker.new }
+ subject { described_class.new }
describe "#perform" do
- it "flushes ref caches when the task is 'gc'" do
- expect(subject).to receive(:command).with(:gc).and_return([:the, :command])
- expect(Gitlab::Popen).to receive(:popen).
- with([:the, :command], project.repository.path_to_repo).and_return(["", 0])
+ shared_examples 'flushing ref caches' do |gitaly|
+ it "flushes ref caches when the task if 'gc'" do
+ expect(subject).to receive(:command).with(:gc).and_return([:the, :command])
+
+ if gitaly
+ expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:garbage_collect)
+ .and_return(nil)
+ else
+ expect(Gitlab::Popen).to receive(:popen)
+ .with([:the, :command], project.repository.path_to_repo).and_return(["", 0])
+ end
+
+ expect_any_instance_of(Repository).to receive(:after_create_branch).and_call_original
+ expect_any_instance_of(Repository).to receive(:branch_names).and_call_original
+ expect_any_instance_of(Repository).to receive(:branch_count).and_call_original
+ expect_any_instance_of(Repository).to receive(:has_visible_content?).and_call_original
+
+ subject.perform(project.id)
+ end
+ end
+
+ context "with Gitaly turned on" do
+ it_should_behave_like 'flushing ref caches', true
+ end
+
+ context "with Gitaly turned off", skip_gitaly_mock: true do
+ it_should_behave_like 'flushing ref caches', false
+ end
- expect_any_instance_of(Repository).to receive(:after_create_branch).and_call_original
- expect_any_instance_of(Repository).to receive(:branch_names).and_call_original
- expect_any_instance_of(Repository).to receive(:branch_count).and_call_original
- expect_any_instance_of(Repository).to receive(:has_visible_content?).and_call_original
+ context "repack_full" do
+ it "calls Gitaly" do
+ expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:repack_full)
+ .and_return(nil)
- subject.perform(project.id)
+ subject.perform(project.id, :full_repack)
+ end
+ end
+
+ context "repack_incremental" do
+ it "calls Gitaly" do
+ expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:repack_incremental)
+ .and_return(nil)
+
+ subject.perform(project.id, :incremental_repack)
+ end
end
shared_examples 'gc tasks' do
- before { allow(subject).to receive(:bitmaps_enabled?).and_return(bitmaps_enabled) }
+ before do
+ allow(subject).to receive(:bitmaps_enabled?).and_return(bitmaps_enabled)
+ end
it 'incremental repack adds a new packfile' do
create_objects(project)
@@ -105,7 +141,7 @@ describe GitGarbageCollectWorker do
author: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'),
committer: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'),
tree: old_commit.tree,
- parents: [old_commit],
+ parents: [old_commit]
)
GitOperationService.new(nil, project.repository).send(
:update_ref,
diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb
index b6c080f36f4..49b4e04dc7c 100644
--- a/spec/workers/gitlab_usage_ping_worker_spec.rb
+++ b/spec/workers/gitlab_usage_ping_worker_spec.rb
@@ -1,23 +1,13 @@
require 'spec_helper'
describe GitlabUsagePingWorker do
- subject { GitlabUsagePingWorker.new }
+ subject { described_class.new }
- it "sends POST request" do
- stub_application_setting(usage_ping_enabled: true)
+ it 'delegates to SubmitUsagePingService' do
+ allow(subject).to receive(:try_obtain_lease).and_return(true)
- stub_request(:post, "https://version.gitlab.com/usage_data").
- to_return(status: 200, body: '', headers: {})
- expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original
- expect(subject).to receive(:try_obtain_lease).and_return(true)
+ expect_any_instance_of(SubmitUsagePingService).to receive(:execute)
- expect(subject.perform.response.code.to_i).to eq(200)
- end
-
- it "does not run if usage ping is disabled" do
- stub_application_setting(usage_ping_enabled: false)
-
- expect(subject).not_to receive(:try_obtain_lease)
- expect(subject).not_to receive(:perform)
+ subject.perform
end
end
diff --git a/spec/workers/group_destroy_worker_spec.rb b/spec/workers/group_destroy_worker_spec.rb
index 1ff5a3b9034..a170c84ab12 100644
--- a/spec/workers/group_destroy_worker_spec.rb
+++ b/spec/workers/group_destroy_worker_spec.rb
@@ -3,9 +3,9 @@ require 'spec_helper'
describe GroupDestroyWorker do
let(:group) { create(:group) }
let(:user) { create(:admin) }
- let!(:project) { create(:empty_project, namespace: group) }
+ let!(:project) { create(:project, namespace: group) }
- subject { GroupDestroyWorker.new }
+ subject { described_class.new }
describe "#perform" do
it "deletes the project" do
diff --git a/spec/workers/invalid_gpg_signature_update_worker_spec.rb b/spec/workers/invalid_gpg_signature_update_worker_spec.rb
new file mode 100644
index 00000000000..5972696515b
--- /dev/null
+++ b/spec/workers/invalid_gpg_signature_update_worker_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe InvalidGpgSignatureUpdateWorker do
+ context 'when GpgKey is found' do
+ it 'calls NotificationService.new.run' do
+ gpg_key = create(:gpg_key)
+ invalid_signature_updater = double(:invalid_signature_updater)
+
+ expect(Gitlab::Gpg::InvalidGpgSignatureUpdater).to receive(:new).with(gpg_key).and_return(invalid_signature_updater)
+ expect(invalid_signature_updater).to receive(:run)
+
+ described_class.new.perform(gpg_key.id)
+ end
+ end
+
+ context 'when GpgKey is not found' do
+ let(:nonexisting_gpg_key_id) { -1 }
+
+ it 'does not raise errors' do
+ expect { described_class.new.perform(nonexisting_gpg_key_id) }.not_to raise_error
+ end
+
+ it 'does not call NotificationService.new.run' do
+ expect(Gitlab::Gpg::InvalidGpgSignatureUpdater).not_to receive(:new)
+
+ described_class.new.perform(nonexisting_gpg_key_id)
+ end
+ end
+end
diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb
index b5e1fdb8ded..ee51000161a 100644
--- a/spec/workers/merge_worker_spec.rb
+++ b/spec/workers/merge_worker_spec.rb
@@ -15,7 +15,7 @@ describe MergeWorker do
it 'clears cache of source repo after removing source branch' do
expect(source_project.repository.branch_names).to include('markdown')
- MergeWorker.new.perform(
+ described_class.new.perform(
merge_request.id, merge_request.author_id,
commit_message: 'wow such merge',
should_remove_source_branch: true)
@@ -27,4 +27,15 @@ describe MergeWorker do
expect(source_project.repository.branch_names).not_to include('markdown')
end
end
+
+ it 'persists merge_jid' do
+ merge_request = create(:merge_request, merge_jid: nil)
+ user = create(:user)
+ worker = described_class.new
+
+ allow(worker).to receive(:jid) { '999' }
+
+ expect { worker.perform(merge_request.id, user.id, {}) }
+ .to change { merge_request.reload.merge_jid }.from(nil).to('999')
+ end
end
diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb
new file mode 100644
index 00000000000..f2706254284
--- /dev/null
+++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe NamespacelessProjectDestroyWorker do
+ subject { described_class.new }
+
+ before do
+ # Stub after_save callbacks that will fail when Project has no namespace
+ allow_any_instance_of(Project).to receive(:ensure_storage_path_exist).and_return(nil)
+ allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil)
+ end
+
+ describe '#perform' do
+ context 'project has namespace' do
+ it 'does not do anything' do
+ project = create(:project)
+
+ subject.perform(project.id)
+
+ expect(Project.unscoped.all).to include(project)
+ end
+ end
+
+ context 'project has no namespace' do
+ let!(:project) do
+ project = build(:project, namespace_id: nil)
+ project.save(validate: false)
+ project
+ end
+
+ context 'project not a fork of another project' do
+ it "truncates the project's team" do
+ expect_any_instance_of(ProjectTeam).to receive(:truncate)
+
+ subject.perform(project.id)
+ end
+
+ it 'deletes the project' do
+ subject.perform(project.id)
+
+ expect(Project.unscoped.all).not_to include(project)
+ end
+
+ it 'does not call unlink_fork' do
+ is_expected.not_to receive(:unlink_fork)
+
+ subject.perform(project.id)
+ end
+
+ it 'does not do anything in Project#remove_pages method' do
+ expect(Gitlab::PagesTransfer).not_to receive(:new)
+
+ subject.perform(project.id)
+ end
+ end
+
+ context 'project forked from another' do
+ let!(:parent_project) { create(:project) }
+
+ before do
+ create(:forked_project_link, forked_to_project: project, forked_from_project: parent_project)
+ end
+
+ it 'closes open merge requests' do
+ merge_request = create(:merge_request, source_project: project, target_project: parent_project)
+
+ subject.perform(project.id)
+
+ expect(merge_request.reload).to be_closed
+ end
+
+ it 'destroys the link' do
+ subject.perform(project.id)
+
+ expect(parent_project.forked_project_links).to be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb
new file mode 100644
index 00000000000..4e15ccc534b
--- /dev/null
+++ b/spec/workers/new_issue_worker_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe NewIssueWorker do
+ describe '#perform' do
+ let(:worker) { described_class.new }
+
+ context 'when an issue not found' do
+ it 'does not call Services' do
+ expect(EventCreateService).not_to receive(:new)
+ expect(NotificationService).not_to receive(:new)
+
+ worker.perform(99, create(:user).id)
+ end
+
+ it 'logs an error' do
+ expect(Rails.logger).to receive(:error).with('NewIssueWorker: couldn\'t find Issue with ID=99, skipping job')
+
+ worker.perform(99, create(:user).id)
+ end
+ end
+
+ context 'when a user not found' do
+ it 'does not call Services' do
+ expect(EventCreateService).not_to receive(:new)
+ expect(NotificationService).not_to receive(:new)
+
+ worker.perform(create(:issue).id, 99)
+ end
+
+ it 'logs an error' do
+ expect(Rails.logger).to receive(:error).with('NewIssueWorker: couldn\'t find User with ID=99, skipping job')
+
+ worker.perform(create(:issue).id, 99)
+ end
+ end
+
+ context 'when everything is ok' do
+ let(:project) { create(:project, :public) }
+ let(:mentioned) { create(:user) }
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue, project: project, description: "issue for #{mentioned.to_reference}") }
+
+ it 'creates a new event record' do
+ expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1)
+ end
+
+ it 'creates a notification for the assignee' do
+ expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id).and_return(double(deliver_later: true))
+
+ worker.perform(issue.id, user.id)
+ end
+ end
+ end
+end
diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb
new file mode 100644
index 00000000000..9e0cbde45b1
--- /dev/null
+++ b/spec/workers/new_merge_request_worker_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe NewMergeRequestWorker do
+ describe '#perform' do
+ let(:worker) { described_class.new }
+
+ context 'when a merge request not found' do
+ it 'does not call Services' do
+ expect(EventCreateService).not_to receive(:new)
+ expect(NotificationService).not_to receive(:new)
+
+ worker.perform(99, create(:user).id)
+ end
+
+ it 'logs an error' do
+ expect(Rails.logger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find MergeRequest with ID=99, skipping job')
+
+ worker.perform(99, create(:user).id)
+ end
+ end
+
+ context 'when a user not found' do
+ it 'does not call Services' do
+ expect(EventCreateService).not_to receive(:new)
+ expect(NotificationService).not_to receive(:new)
+
+ worker.perform(create(:merge_request).id, 99)
+ end
+
+ it 'logs an error' do
+ expect(Rails.logger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find User with ID=99, skipping job')
+
+ worker.perform(create(:merge_request).id, 99)
+ end
+ end
+
+ context 'when everything is ok' do
+ let(:project) { create(:project, :public) }
+ let(:mentioned) { create(:user) }
+ let(:user) { create(:user) }
+ let(:merge_request) do
+ create(:merge_request, source_project: project, description: "mr for #{mentioned.to_reference}")
+ end
+
+ it 'creates a new event record' do
+ expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1)
+ end
+
+ it 'creates a notification for the assignee' do
+ expect(Notify).to receive(:new_merge_request_email).with(mentioned.id, merge_request.id).and_return(double(deliver_later: true))
+
+ worker.perform(merge_request.id, user.id)
+ end
+ end
+ end
+end
diff --git a/spec/workers/new_note_worker_spec.rb b/spec/workers/new_note_worker_spec.rb
index 8fdbb35afd0..575361c93d4 100644
--- a/spec/workers/new_note_worker_spec.rb
+++ b/spec/workers/new_note_worker_spec.rb
@@ -24,8 +24,8 @@ describe NewNoteWorker do
let(:unexistent_note_id) { 999 }
it 'logs NewNoteWorker process skipping' do
- expect(Rails.logger).to receive(:error).
- with("NewNoteWorker: couldn't find note with ID=999, skipping job")
+ expect(Rails.logger).to receive(:error)
+ .with("NewNoteWorker: couldn't find note with ID=999, skipping job")
described_class.new.perform(unexistent_note_id)
end
diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb
index 5dbc0da95c2..ef71125c0b6 100644
--- a/spec/workers/pipeline_metrics_worker_spec.rb
+++ b/spec/workers/pipeline_metrics_worker_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe PipelineMetricsWorker do
let(:project) { create(:project, :repository) }
- let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref) }
+ let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref, head_pipeline: pipeline) }
let(:pipeline) do
create(:ci_empty_pipeline,
diff --git a/spec/workers/pipeline_notification_worker_spec.rb b/spec/workers/pipeline_notification_worker_spec.rb
index 139032d77bd..eb539ffd893 100644
--- a/spec/workers/pipeline_notification_worker_spec.rb
+++ b/spec/workers/pipeline_notification_worker_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
-describe PipelineNotificationWorker do
- include EmailHelpers
-
+describe PipelineNotificationWorker, :mailer do
let(:pipeline) { create(:ci_pipeline) }
describe '#execute' do
diff --git a/spec/workers/pipeline_proccess_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb
index 86e9d7f6684..86e9d7f6684 100644
--- a/spec/workers/pipeline_proccess_worker_spec.rb
+++ b/spec/workers/pipeline_process_worker_spec.rb
diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb
new file mode 100644
index 00000000000..75197039f5a
--- /dev/null
+++ b/spec/workers/pipeline_schedule_worker_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe PipelineScheduleWorker do
+ subject { described_class.new.perform }
+
+ set(:project) { create(:project, :repository) }
+ set(:user) { create(:user) }
+
+ let!(:pipeline_schedule) do
+ create(:ci_pipeline_schedule, :nightly, project: project, owner: user)
+ end
+
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+
+ pipeline_schedule.update_column(:next_run_at, 1.day.ago)
+ end
+
+ context 'when the schedule is runnable by the user' do
+ before do
+ project.add_master(user)
+ end
+
+ context 'when there is a scheduled pipeline within next_run_at' do
+ it 'creates a new pipeline' do
+ expect { subject }.to change { project.pipelines.count }.by(1)
+ expect(Ci::Pipeline.last).to be_schedule
+ end
+
+ it 'updates the next_run_at field' do
+ subject
+
+ expect(pipeline_schedule.reload.next_run_at).to be > Time.now
+ end
+
+ it 'sets the schedule on the pipeline' do
+ subject
+
+ expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule)
+ end
+ end
+
+ context 'inactive schedule' do
+ before do
+ pipeline_schedule.deactivate!
+ end
+
+ it 'does not creates a new pipeline' do
+ expect { subject }.not_to change { project.pipelines.count }
+ end
+ end
+ end
+
+ context 'when the schedule is not runnable by the user' do
+ it 'deactivates the schedule' do
+ subject
+
+ expect(pipeline_schedule.reload.active).to be_falsy
+ end
+
+ it 'does not schedule a pipeline' do
+ expect { subject }.not_to change { project.pipelines.count }
+ end
+ end
+end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index a2a559a2369..af6a3c9f6c7 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -4,13 +4,29 @@ describe PostReceive do
let(:changes) { "123456 789012 refs/heads/tést\n654321 210987 refs/tags/tag" }
let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") }
let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) }
- let(:project) { create(:project, :repository) }
+ let(:gl_repository) { "project-#{project.id}" }
let(:key) { create(:key, user: project.owner) }
let(:key_id) { key.shell_id }
- context "as a resque worker" do
- it "reponds to #perform" do
- expect(PostReceive.new).to respond_to(:perform)
+ let(:project) do
+ create(:project, :repository, auto_cancel_pending_pipelines: 'disabled')
+ end
+
+ context "as a sidekiq worker" do
+ it "responds to #perform" do
+ expect(described_class.new).to respond_to(:perform)
+ end
+ end
+
+ context 'with a non-existing project' do
+ let(:gl_repository) { "project-123456789" }
+ let(:error_message) do
+ "Triggered hook for non-existing project with gl_repository \"#{gl_repository}\""
+ end
+
+ it "returns false and logs an error" do
+ expect(Gitlab::GitLogger).to receive(:error).with("POST-RECEIVE: #{error_message}")
+ expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be(false)
end
end
@@ -25,7 +41,7 @@ describe PostReceive do
it "calls GitTagPushService" do
expect_any_instance_of(GitPushService).to receive(:execute).and_return(true)
expect_any_instance_of(GitTagPushService).not_to receive(:execute)
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
@@ -35,7 +51,7 @@ describe PostReceive do
it "calls GitTagPushService" do
expect_any_instance_of(GitPushService).not_to receive(:execute)
expect_any_instance_of(GitTagPushService).to receive(:execute).and_return(true)
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
@@ -45,12 +61,12 @@ describe PostReceive do
it "does not call any of the services" do
expect_any_instance_of(GitPushService).not_to receive(:execute)
expect_any_instance_of(GitTagPushService).not_to receive(:execute)
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
context "gitlab-ci.yml" do
- subject { PostReceive.new.perform(pwd(project), key_id, base64_changes) }
+ subject { described_class.new.perform(gl_repository, key_id, base64_changes) }
context "creates a Ci::Pipeline for every change" do
before do
@@ -58,53 +74,70 @@ describe PostReceive do
OpenStruct.new(id: '123456')
end
allow_any_instance_of(Ci::CreatePipelineService).to receive(:branch?).and_return(true)
+ allow_any_instance_of(Repository).to receive(:ref_exists?).and_return(true)
stub_ci_pipeline_to_return_yaml_file
end
- it { expect{ subject }.to change{ Ci::Pipeline.count }.by(2) }
+ it { expect { subject }.to change { Ci::Pipeline.count }.by(2) }
end
context "does not create a Ci::Pipeline" do
- before { stub_ci_pipeline_yaml_file(nil) }
+ before do
+ stub_ci_pipeline_yaml_file(nil)
+ end
- it { expect{ subject }.not_to change{ Ci::Pipeline.count } }
+ it { expect { subject }.not_to change { Ci::Pipeline.count } }
+ end
+ end
+
+ context 'after project changes hooks' do
+ let(:changes) { '123456 789012 refs/heads/tést' }
+ let(:fake_hook_data) { Hash.new(event_name: 'repository_update') }
+
+ before do
+ allow_any_instance_of(Gitlab::DataBuilder::Repository).to receive(:update).and_return(fake_hook_data)
+ # silence hooks so we can isolate
+ allow_any_instance_of(Key).to receive(:post_create_hook).and_return(true)
+ allow_any_instance_of(GitPushService).to receive(:execute).and_return(true)
+ end
+
+ it 'calls SystemHooksService' do
+ expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true)
+
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
end
context "webhook" do
it "fetches the correct project" do
- expect(Project).to receive(:find_by_full_path).with(project.path_with_namespace).and_return(project)
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ expect(Project).to receive(:find_by).with(id: project.id.to_s)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
it "does not run if the author is not in the project" do
- allow_any_instance_of(Gitlab::GitPostReceive).
- to receive(:identify_using_ssh_key).
- and_return(nil)
+ allow_any_instance_of(Gitlab::GitPostReceive)
+ .to receive(:identify_using_ssh_key)
+ .and_return(nil)
expect(project).not_to receive(:execute_hooks)
- expect(PostReceive.new.perform(pwd(project), key_id, base64_changes)).to be_falsey
+ expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be_falsey
end
it "asks the project to trigger all hooks" do
- allow(Project).to receive(:find_by_full_path).and_return(project)
+ allow(Project).to receive(:find_by).and_return(project)
expect(project).to receive(:execute_hooks).twice
expect(project).to receive(:execute_services).twice
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
it "enqueues a UpdateMergeRequestsWorker job" do
- allow(Project).to receive(:find_by_full_path).and_return(project)
+ allow(Project).to receive(:find_by).and_return(project)
expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args)
- PostReceive.new.perform(pwd(project), key_id, base64_changes)
+ described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
-
- def pwd(project)
- File.join(Gitlab.config.repositories.storages.default['path'], project.path_with_namespace)
- end
end
diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb
index 9afe2e610b9..24f8ca67594 100644
--- a/spec/workers/process_commit_worker_spec.rb
+++ b/spec/workers/process_commit_worker_spec.rb
@@ -31,16 +31,28 @@ describe ProcessCommitWorker do
worker.perform(project.id, user.id, commit.to_hash)
end
+
+ context 'when commit already exists in upstream project' do
+ let(:forked) { create(:project, :public, :repository) }
+
+ it 'does not process commit message' do
+ create(:forked_project_link, forked_to_project: forked, forked_from_project: project)
+
+ expect(worker).not_to receive(:process_commit_message)
+
+ worker.perform(forked.id, user.id, forked.commit.to_hash)
+ end
+ end
end
describe '#process_commit_message' do
context 'when pushing to the default branch' do
it 'closes issues that should be closed per the commit message' do
- allow(commit).to receive(:safe_message).
- and_return("Closes #{issue.to_reference}")
+ allow(commit).to receive(:safe_message)
+ .and_return("Closes #{issue.to_reference}")
- expect(worker).to receive(:close_issues).
- with(project, user, user, commit, [issue])
+ expect(worker).to receive(:close_issues)
+ .with(project, user, user, commit, [issue])
worker.process_commit_message(project, commit, user, user, true)
end
@@ -48,8 +60,8 @@ describe ProcessCommitWorker do
context 'when pushing to a non-default branch' do
it 'does not close any issues' do
- allow(commit).to receive(:safe_message).
- and_return("Closes #{issue.to_reference}")
+ allow(commit).to receive(:safe_message)
+ .and_return("Closes #{issue.to_reference}")
expect(worker).not_to receive(:close_issues)
@@ -90,8 +102,8 @@ describe ProcessCommitWorker do
describe '#update_issue_metrics' do
it 'updates any existing issue metrics' do
- allow(commit).to receive(:safe_message).
- and_return("Closes #{issue.to_reference}")
+ allow(commit).to receive(:safe_message)
+ .and_return("Closes #{issue.to_reference}")
worker.update_issue_metrics(commit, user)
@@ -101,8 +113,8 @@ describe ProcessCommitWorker do
end
it "doesn't execute any queries with false conditions" do
- allow(commit).to receive(:safe_message).
- and_return("Lorem Ipsum")
+ allow(commit).to receive(:safe_message)
+ .and_return("Lorem Ipsum")
expect { worker.update_issue_metrics(commit, user) }.not_to make_queries_matching(/WHERE (?:1=0|0=1)/)
end
@@ -116,8 +128,8 @@ describe ProcessCommitWorker do
end
it 'parses date strings into Time instances' do
- commit = worker.
- build_commit(project, id: '123', authored_date: Time.now.to_s)
+ commit = worker
+ .build_commit(project, id: '123', authored_date: Time.now.to_s)
expect(commit.authored_date).to be_an_instance_of(Time)
end
diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb
index c23ffdf99c0..6b1f2ff3227 100644
--- a/spec/workers/project_cache_worker_spec.rb
+++ b/spec/workers/project_cache_worker_spec.rb
@@ -7,8 +7,8 @@ describe ProjectCacheWorker do
describe '#perform' do
before do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
- and_return(true)
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain)
+ .and_return(true)
end
context 'with a non-existing project' do
@@ -39,21 +39,33 @@ describe ProjectCacheWorker do
end
it 'refreshes the method caches' do
- expect_any_instance_of(Repository).to receive(:refresh_method_caches).
- with(%i(readme)).
- and_call_original
+ expect_any_instance_of(Repository).to receive(:refresh_method_caches)
+ .with(%i(readme))
+ .and_call_original
worker.perform(project.id, %w(readme))
end
+
+ context 'with plain readme' do
+ it 'refreshes the method caches' do
+ allow(MarkupHelper).to receive(:gitlab_markdown?).and_return(false)
+ allow(MarkupHelper).to receive(:plain?).and_return(true)
+
+ expect_any_instance_of(Repository).to receive(:refresh_method_caches)
+ .with(%i(readme))
+ .and_call_original
+ worker.perform(project.id, %w(readme))
+ end
+ end
end
end
describe '#update_statistics' do
context 'when a lease could not be obtained' do
it 'does not update the repository size' do
- allow(worker).to receive(:try_obtain_lease_for).
- with(project.id, :update_statistics).
- and_return(false)
+ allow(worker).to receive(:try_obtain_lease_for)
+ .with(project.id, :update_statistics)
+ .and_return(false)
expect(statistics).not_to receive(:refresh!)
@@ -63,9 +75,9 @@ describe ProjectCacheWorker do
context 'when a lease could be obtained' do
it 'updates the project statistics' do
- allow(worker).to receive(:try_obtain_lease_for).
- with(project.id, :update_statistics).
- and_return(true)
+ allow(worker).to receive(:try_obtain_lease_for)
+ .with(project.id, :update_statistics)
+ .and_return(true)
expect(statistics).to receive(:refresh!)
.with(only: %i(repository_size))
diff --git a/spec/workers/project_destroy_worker_spec.rb b/spec/workers/project_destroy_worker_spec.rb
index 0ab42f99510..f19c9dff941 100644
--- a/spec/workers/project_destroy_worker_spec.rb
+++ b/spec/workers/project_destroy_worker_spec.rb
@@ -1,24 +1,36 @@
require 'spec_helper'
describe ProjectDestroyWorker do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, pending_delete: true) }
let(:path) { project.repository.path_to_repo }
- subject { ProjectDestroyWorker.new }
+ subject { described_class.new }
- describe "#perform" do
- it "deletes the project" do
+ describe '#perform' do
+ it 'deletes the project' do
subject.perform(project.id, project.owner.id, {})
expect(Project.all).not_to include(project)
expect(Dir.exist?(path)).to be_falsey
end
- it "deletes the project but skips repo deletion" do
+ it 'deletes the project but skips repo deletion' do
subject.perform(project.id, project.owner.id, { "skip_repo" => true })
expect(Project.all).not_to include(project)
expect(Dir.exist?(path)).to be_truthy
end
+
+ it 'does not raise error when project could not be found' do
+ expect do
+ subject.perform(-1, project.owner.id, {})
+ end.not_to raise_error
+ end
+
+ it 'does not raise error when user could not be found' do
+ expect do
+ subject.perform(project.id, -1, {})
+ end.not_to raise_error
+ end
end
end
diff --git a/spec/workers/propagate_service_template_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb
new file mode 100644
index 00000000000..b8b65ead9b3
--- /dev/null
+++ b/spec/workers/propagate_service_template_worker_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe PropagateServiceTemplateWorker do
+ let!(:service_template) do
+ PushoverService.create(
+ template: true,
+ active: true,
+ properties: {
+ device: 'MyDevice',
+ sound: 'mic',
+ priority: 4,
+ user_key: 'asdf',
+ api_key: '123456789'
+ })
+ end
+
+ before do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain)
+ .and_return(true)
+ end
+
+ describe '#perform' do
+ it 'calls the propagate service with the template' do
+ expect(Projects::PropagateServiceTemplate).to receive(:propagate).with(service_template)
+
+ subject.perform(service_template.id)
+ end
+ end
+end
diff --git a/spec/workers/prune_old_events_worker_spec.rb b/spec/workers/prune_old_events_worker_spec.rb
index 35e1518a35e..ea974355050 100644
--- a/spec/workers/prune_old_events_worker_spec.rb
+++ b/spec/workers/prune_old_events_worker_spec.rb
@@ -2,9 +2,11 @@ require 'spec_helper'
describe PruneOldEventsWorker do
describe '#perform' do
- let!(:expired_event) { create(:event, author_id: 0, created_at: 13.months.ago) }
- let!(:not_expired_event) { create(:event, author_id: 0, created_at: 1.day.ago) }
- let!(:exactly_12_months_event) { create(:event, author_id: 0, created_at: 12.months.ago) }
+ let(:user) { create(:user) }
+
+ let!(:expired_event) { create(:event, :closed, author: user, created_at: 13.months.ago) }
+ let!(:not_expired_event) { create(:event, :closed, author: user, created_at: 1.day.ago) }
+ let!(:exactly_12_months_event) { create(:event, :closed, author: user, created_at: 12.months.ago) }
it 'prunes events older than 12 months' do
expect { subject.perform }.to change { Event.count }.by(-1)
diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb
index 402aa1e714e..058fdf4c009 100644
--- a/spec/workers/remove_expired_members_worker_spec.rb
+++ b/spec/workers/remove_expired_members_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe RemoveExpiredMembersWorker do
- let(:worker) { RemoveExpiredMembersWorker.new }
+ let(:worker) { described_class.new }
describe '#perform' do
context 'project members' do
diff --git a/spec/workers/remove_old_web_hook_logs_worker_spec.rb b/spec/workers/remove_old_web_hook_logs_worker_spec.rb
new file mode 100644
index 00000000000..6d26ba5dfa0
--- /dev/null
+++ b/spec/workers/remove_old_web_hook_logs_worker_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe RemoveOldWebHookLogsWorker do
+ subject { described_class.new }
+
+ describe '#perform' do
+ let!(:week_old_record) { create(:web_hook_log, created_at: Time.now - 1.week) }
+ let!(:three_days_old_record) { create(:web_hook_log, created_at: Time.now - 3.days) }
+ let!(:one_day_old_record) { create(:web_hook_log, created_at: Time.now - 1.day) }
+
+ it 'removes web hook logs older than 2 days' do
+ subject.perform
+
+ expect(WebHookLog.all).to include(one_day_old_record)
+ expect(WebHookLog.all).not_to include(week_old_record, three_days_old_record)
+ end
+ end
+end
diff --git a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
index 6d42946de38..57f83c1dbe9 100644
--- a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
+++ b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
describe RemoveUnreferencedLfsObjectsWorker do
- let(:worker) { RemoveUnreferencedLfsObjectsWorker.new }
+ let(:worker) { described_class.new }
describe '#perform' do
let!(:unreferenced_lfs_object1) { create(:lfs_object, oid: '1') }
let!(:unreferenced_lfs_object2) { create(:lfs_object, oid: '2') }
- let!(:project1) { create(:empty_project, lfs_enabled: true) }
- let!(:project2) { create(:empty_project, lfs_enabled: true) }
+ let!(:project1) { create(:project, lfs_enabled: true) }
+ let!(:project2) { create(:project, lfs_enabled: true) }
let!(:referenced_lfs_object1) { create(:lfs_object, oid: '3') }
let!(:referenced_lfs_object2) { create(:lfs_object, oid: '4') }
let!(:lfs_objects_project1_1) do
diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb
index bcd97a4f6ef..850b8cd8f5c 100644
--- a/spec/workers/repository_check/batch_worker_spec.rb
+++ b/spec/workers/repository_check/batch_worker_spec.rb
@@ -4,7 +4,7 @@ describe RepositoryCheck::BatchWorker do
subject { described_class.new }
it 'prefers projects that have never been checked' do
- projects = create_list(:empty_project, 3, created_at: 1.week.ago)
+ projects = create_list(:project, 3, created_at: 1.week.ago)
projects[0].update_column(:last_repository_check_at, 4.months.ago)
projects[2].update_column(:last_repository_check_at, 3.months.ago)
@@ -12,7 +12,7 @@ describe RepositoryCheck::BatchWorker do
end
it 'sorts projects by last_repository_check_at' do
- projects = create_list(:empty_project, 3, created_at: 1.week.ago)
+ projects = create_list(:project, 3, created_at: 1.week.ago)
projects[0].update_column(:last_repository_check_at, 2.months.ago)
projects[1].update_column(:last_repository_check_at, 4.months.ago)
projects[2].update_column(:last_repository_check_at, 3.months.ago)
@@ -21,7 +21,7 @@ describe RepositoryCheck::BatchWorker do
end
it 'excludes projects that were checked recently' do
- projects = create_list(:empty_project, 3, created_at: 1.week.ago)
+ projects = create_list(:project, 3, created_at: 1.week.ago)
projects[0].update_column(:last_repository_check_at, 2.days.ago)
projects[1].update_column(:last_repository_check_at, 2.months.ago)
projects[2].update_column(:last_repository_check_at, 3.days.ago)
@@ -30,7 +30,7 @@ describe RepositoryCheck::BatchWorker do
end
it 'does nothing when repository checks are disabled' do
- create(:empty_project, created_at: 1.week.ago)
+ create(:project, created_at: 1.week.ago)
current_settings = double('settings', repository_checks_enabled: false)
expect(subject).to receive(:current_settings) { current_settings }
@@ -38,7 +38,7 @@ describe RepositoryCheck::BatchWorker do
end
it 'skips projects created less than 24 hours ago' do
- project = create(:empty_project)
+ project = create(:project)
project.update_column(:created_at, 23.hours.ago)
expect(subject.perform).to eq([])
diff --git a/spec/workers/repository_check/clear_worker_spec.rb b/spec/workers/repository_check/clear_worker_spec.rb
index a3b70c74787..1c49415d46c 100644
--- a/spec/workers/repository_check/clear_worker_spec.rb
+++ b/spec/workers/repository_check/clear_worker_spec.rb
@@ -2,10 +2,10 @@ require 'spec_helper'
describe RepositoryCheck::ClearWorker do
it 'clears repository check columns' do
- project = create(:empty_project)
+ project = create(:project)
project.update_columns(
last_repository_check_failed: true,
- last_repository_check_at: Time.now,
+ last_repository_check_at: Time.now
)
described_class.new.perform
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 7d6a2db2972..d9e9409840f 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
describe RepositoryForkWorker do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :import_scheduled) }
let(:fork_project) { create(:project, :repository, forked_from_project: project) }
let(:shell) { Gitlab::Shell.new }
- subject { RepositoryForkWorker.new }
+ subject { described_class.new }
before do
allow(subject).to receive(:gitlab_shell).and_return(shell)
@@ -35,26 +35,38 @@ describe RepositoryForkWorker do
fork_project.namespace.full_path
).and_return(true)
- expect_any_instance_of(Repository).to receive(:expire_emptiness_caches).
- and_call_original
+ expect_any_instance_of(Repository).to receive(:expire_emptiness_caches)
+ .and_call_original
- expect_any_instance_of(Repository).to receive(:expire_exists_cache).
- and_call_original
+ expect_any_instance_of(Repository).to receive(:expire_exists_cache)
+ .and_call_original
subject.perform(project.id, '/test/path', project.full_path,
fork_project.namespace.full_path)
end
it "handles bad fork" do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+ error_message = "Unable to fork project #{project.id} for repository #{source_path} -> #{target_path}"
+
expect(shell).to receive(:fork_repository).and_return(false)
- expect(subject.logger).to receive(:error)
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError, error_message)
+ end
- subject.perform(
- project.id,
- '/test/path',
- project.full_path,
- fork_project.namespace.full_path)
+ it 'handles unexpected error' do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+
+ allow_any_instance_of(Gitlab::Shell).to receive(:fork_repository).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError)
+ expect(project.reload.import_status).to eq('failed')
end
end
end
diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb
index 5a2c0671dac..100dfc32bbe 100644
--- a/spec/workers/repository_import_worker_spec.rb
+++ b/spec/workers/repository_import_worker_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
describe RepositoryImportWorker do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:project, :import_scheduled) }
subject { described_class.new }
describe '#perform' do
context 'when the import was successful' do
it 'imports a project' do
- expect_any_instance_of(Projects::ImportService).to receive(:execute).
- and_return({ status: :ok })
+ expect_any_instance_of(Projects::ImportService).to receive(:execute)
+ .and_return({ status: :ok })
expect_any_instance_of(Repository).to receive(:expire_emptiness_caches)
expect_any_instance_of(Project).to receive(:import_finish)
@@ -21,15 +21,26 @@ describe RepositoryImportWorker do
context 'when the import has failed' do
it 'hide the credentials that were used in the import URL' do
error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found }
- expect_any_instance_of(Projects::ImportService).to receive(:execute).
- and_return({ status: :error, message: error })
- allow(subject).to receive(:jid).and_return('123')
- subject.perform(project.id)
+ project.update_attributes(import_jid: '123')
+ expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error })
- expect(project.reload.import_error).to include("https://*****:*****@test.com/root/repoC.git/")
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError, error)
expect(project.reload.import_jid).not_to be_nil
end
end
+
+ context 'with unexpected error' do
+ it 'marks import as failed' do
+ allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError)
+ expect(project.reload.import_status).to eq('failed')
+ end
+ end
end
end
diff --git a/spec/workers/schedule_update_user_activity_worker_spec.rb b/spec/workers/schedule_update_user_activity_worker_spec.rb
index e583c3203aa..32c59381b01 100644
--- a/spec/workers/schedule_update_user_activity_worker_spec.rb
+++ b/spec/workers/schedule_update_user_activity_worker_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ScheduleUpdateUserActivityWorker, :redis do
+describe ScheduleUpdateUserActivityWorker, :clean_gitlab_redis_shared_state do
let(:now) { Time.now }
before do
diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb
index 8434b0c8e5b..549635f7f33 100644
--- a/spec/workers/stuck_ci_jobs_worker_spec.rb
+++ b/spec/workers/stuck_ci_jobs_worker_spec.rb
@@ -34,7 +34,9 @@ describe StuckCiJobsWorker do
let(:status) { 'pending' }
context 'when job is not stuck' do
- before { allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(false) }
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(false)
+ end
context 'when job was not updated for more than 1 day ago' do
let(:updated_at) { 2.days.ago }
@@ -53,7 +55,9 @@ describe StuckCiJobsWorker do
end
context 'when job is stuck' do
- before { allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(true) }
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(true)
+ end
context 'when job was not updated for more than 1 hour ago' do
let(:updated_at) { 2.hours.ago }
@@ -93,7 +97,9 @@ describe StuckCiJobsWorker do
let(:status) { 'running' }
let(:updated_at) { 2.days.ago }
- before { job.project.update(pending_delete: true) }
+ before do
+ job.project.update(pending_delete: true)
+ end
it 'does not drop job' do
expect_any_instance_of(Ci::Build).not_to receive(:drop)
diff --git a/spec/workers/stuck_import_jobs_worker_spec.rb b/spec/workers/stuck_import_jobs_worker_spec.rb
index 466277a5e5e..a82eb54ffe4 100644
--- a/spec/workers/stuck_import_jobs_worker_spec.rb
+++ b/spec/workers/stuck_import_jobs_worker_spec.rb
@@ -8,29 +8,29 @@ describe StuckImportJobsWorker do
allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid)
end
- describe 'long running import' do
- let(:project) { create(:empty_project, import_jid: '123', import_status: 'started') }
+ describe 'with started import_status' do
+ let(:project) { create(:project, :import_started, import_jid: '123') }
- before do
- allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(['123'])
- end
+ describe 'long running import' do
+ it 'marks the project as failed' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(['123'])
- it 'marks the project as failed' do
- expect { worker.perform }.to change { project.reload.import_status }.to('failed')
+ expect { worker.perform }.to change { project.reload.import_status }.to('failed')
+ end
end
- end
- describe 'running import' do
- let(:project) { create(:empty_project, import_jid: '123', import_status: 'started') }
-
- before do
- allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
- end
+ describe 'running import' do
+ it 'does not mark the project as failed' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
- it 'does not mark the project as failed' do
- worker.perform
+ expect { worker.perform }.not_to change { project.reload.import_status }
+ end
- expect(project.reload.import_status).to eq('started')
+ describe 'import without import_jid' do
+ it 'marks the project as failed' do
+ expect { worker.perform }.to change { project.reload.import_status }.to('failed')
+ end
+ end
end
end
end
diff --git a/spec/workers/stuck_merge_jobs_worker_spec.rb b/spec/workers/stuck_merge_jobs_worker_spec.rb
new file mode 100644
index 00000000000..a5ad78393c9
--- /dev/null
+++ b/spec/workers/stuck_merge_jobs_worker_spec.rb
@@ -0,0 +1,50 @@
+require 'spec_helper'
+
+describe StuckMergeJobsWorker do
+ describe 'perform' do
+ let(:worker) { described_class.new }
+
+ context 'merge job identified as completed' do
+ it 'updates merge request to merged when locked but has merge_commit_sha' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123 456))
+ mr_with_sha = create(:merge_request, :locked, merge_jid: '123', state: :locked, merge_commit_sha: 'foo-bar-baz')
+ mr_without_sha = create(:merge_request, :locked, merge_jid: '123', state: :locked, merge_commit_sha: nil)
+
+ worker.perform
+
+ expect(mr_with_sha.reload).to be_merged
+ expect(mr_without_sha.reload).to be_opened
+ end
+
+ it 'updates merge request to opened when locked but has not been merged' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123))
+ merge_request = create(:merge_request, :locked, merge_jid: '123', state: :locked)
+
+ worker.perform
+
+ expect(merge_request.reload).to be_opened
+ end
+
+ it 'logs updated stuck merge job ids' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123 456))
+
+ create(:merge_request, :locked, merge_jid: '123')
+ create(:merge_request, :locked, merge_jid: '456')
+
+ expect(Rails).to receive_message_chain(:logger, :info).with('Updated state of locked merge jobs. JIDs: 123, 456')
+
+ worker.perform
+ end
+ end
+
+ context 'merge job not identified as completed' do
+ it 'does not change merge request state when job is not completed yet' do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
+
+ merge_request = create(:merge_request, :locked, merge_jid: '123')
+
+ expect { worker.perform }.not_to change { merge_request.reload.state }.from('locked')
+ end
+ end
+ end
+end
diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb
deleted file mode 100644
index 861bed4442e..00000000000
--- a/spec/workers/trigger_schedule_worker_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'spec_helper'
-
-describe TriggerScheduleWorker do
- let(:worker) { described_class.new }
-
- before do
- stub_ci_pipeline_to_return_yaml_file
- 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.update_column(:next_run_at, next_run_at)
- end
-
- it 'creates a new trigger request' do
- expect { worker.perform }.to change { Ci::TriggerRequest.count }
- end
-
- it 'creates a new pipeline' do
- expect { worker.perform }.to change { Ci::Pipeline.count }
- expect(Ci::Pipeline.last).to be_pending
- end
-
- it 'updates next_run_at' do
- 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
-
- context 'when there are no scheduled triggers within next_run_at' do
- before { create(:ci_trigger_schedule, :nightly) }
-
- it 'does not create a new pipeline' do
- expect { worker.perform }.not_to change { Ci::Pipeline.count }
- end
-
- it 'does not update next_run_at' do
- expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
- end
- end
-
- context 'when next_run_at is nil' do
- before do
- schedule = create(:ci_trigger_schedule, :nightly)
- schedule.update_column(:next_run_at, nil)
- end
-
- it 'does not create a new pipeline' do
- expect { worker.perform }.not_to change { Ci::Pipeline.count }
- end
-
- it 'does not update next_run_at' do
- expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
- end
- end
-end
diff --git a/spec/workers/update_user_activity_worker_spec.rb b/spec/workers/update_user_activity_worker_spec.rb
index 43e9511f116..268ca1d81f2 100644
--- a/spec/workers/update_user_activity_worker_spec.rb
+++ b/spec/workers/update_user_activity_worker_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe UpdateUserActivityWorker, :redis do
+describe UpdateUserActivityWorker, :clean_gitlab_redis_shared_state do
let(:user_active_2_days_ago) { create(:user, current_sign_in_at: 10.months.ago) }
let(:user_active_yesterday_1) { create(:user) }
let(:user_active_yesterday_2) { create(:user) }
@@ -25,7 +25,7 @@ describe UpdateUserActivityWorker, :redis do
end
end
- it 'deletes the pairs from Redis' do
+ it 'deletes the pairs from SharedState' do
data.each { |id, time| Gitlab::UserActivities.record(id, time) }
subject.perform(data)
diff --git a/tmp/prometheus_multiproc_dir/.gitkeep b/tmp/prometheus_multiproc_dir/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/tmp/prometheus_multiproc_dir/.gitkeep
diff --git a/vendor/Dockerfile/Binary-alpine.Dockerfile b/vendor/Dockerfile/Binary-alpine.Dockerfile
new file mode 100644
index 00000000000..5a9eb2b4716
--- /dev/null
+++ b/vendor/Dockerfile/Binary-alpine.Dockerfile
@@ -0,0 +1,14 @@
+# This Dockerfile installs a compiled binary into a bare system.
+# You must either commit your compiled binary into source control (not recommended)
+# or build the binary first as part of a CI/CD pipeline.
+
+FROM alpine:3.5
+
+# We'll likely need to add SSL root certificates
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /usr/local/bin
+
+# Change `app` to whatever your binary is called
+Add app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Binary-scratch.Dockerfile b/vendor/Dockerfile/Binary-scratch.Dockerfile
new file mode 100644
index 00000000000..5e2de2ead61
--- /dev/null
+++ b/vendor/Dockerfile/Binary-scratch.Dockerfile
@@ -0,0 +1,17 @@
+# This Dockerfile installs a compiled binary into an image with no system at all.
+# You must either commit your compiled binary into source control (not recommended)
+# or build the binary first as part of a CI/CD pipeline.
+# Your binary must be statically compiled with no dynamic dependencies on system libraries.
+# e.g. for Docker:
+# CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
+
+FROM scratch
+
+# Since we started from scratch, we'll likely need to add SSL root certificates
+ADD /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+
+WORKDIR /usr/local/bin
+
+# Change `app` to whatever your binary is called
+Add app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Binary.Dockerfile b/vendor/Dockerfile/Binary.Dockerfile
new file mode 100644
index 00000000000..e7d560da9ac
--- /dev/null
+++ b/vendor/Dockerfile/Binary.Dockerfile
@@ -0,0 +1,11 @@
+# This Dockerfile installs a compiled binary into a bare system.
+# You must either commit your compiled binary into source control (not recommended)
+# or build the binary first as part of a CI/CD pipeline.
+
+FROM buildpack-deps:jessie
+
+WORKDIR /usr/local/bin
+
+# Change `app` to whatever your binary is called
+Add app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Golang-alpine.Dockerfile b/vendor/Dockerfile/Golang-alpine.Dockerfile
new file mode 100644
index 00000000000..0287315219b
--- /dev/null
+++ b/vendor/Dockerfile/Golang-alpine.Dockerfile
@@ -0,0 +1,17 @@
+FROM golang:1.8-alpine AS builder
+
+WORKDIR /usr/src/app
+
+COPY . .
+RUN go-wrapper download
+RUN go build -v
+
+FROM alpine:3.5
+
+# We'll likely need to add SSL root certificates
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /usr/local/bin
+
+COPY --from=builder /usr/src/app/app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Golang-scratch.Dockerfile b/vendor/Dockerfile/Golang-scratch.Dockerfile
new file mode 100644
index 00000000000..9057a2d0e51
--- /dev/null
+++ b/vendor/Dockerfile/Golang-scratch.Dockerfile
@@ -0,0 +1,20 @@
+FROM golang:1.8-alpine AS builder
+
+# We'll likely need to add SSL root certificates
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /usr/src/app
+
+COPY . .
+RUN go-wrapper download
+RUN CGO_ENABLED=0 GOOS=linux go build -v -a -installsuffix cgo -o app .
+
+FROM scratch
+
+# Since we started from scratch, we'll copy the SSL root certificates from the builder
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+
+WORKDIR /usr/local/bin
+
+COPY --from=builder /usr/src/app/app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Golang.Dockerfile b/vendor/Dockerfile/Golang.Dockerfile
new file mode 100644
index 00000000000..ec94914be19
--- /dev/null
+++ b/vendor/Dockerfile/Golang.Dockerfile
@@ -0,0 +1,14 @@
+FROM golang:1.8 AS builder
+
+WORKDIR /usr/src/app
+
+COPY . .
+RUN go-wrapper download
+RUN go build -v
+
+FROM buildpack-deps:jessie
+
+WORKDIR /usr/local/bin
+
+COPY --from=builder /usr/src/app/app .
+CMD ["./app"]
diff --git a/vendor/Dockerfile/Node-alpine.Dockerfile b/vendor/Dockerfile/Node-alpine.Dockerfile
new file mode 100644
index 00000000000..9776b1336b5
--- /dev/null
+++ b/vendor/Dockerfile/Node-alpine.Dockerfile
@@ -0,0 +1,14 @@
+FROM node:7.9-alpine
+
+WORKDIR /usr/src/app
+
+ARG NODE_ENV
+ENV NODE_ENV $NODE_ENV
+COPY package.json /usr/src/app/
+RUN npm install && npm cache clean
+COPY . /usr/src/app
+
+CMD [ "npm", "start" ]
+
+# replace this with your application's default port
+EXPOSE 8888
diff --git a/vendor/Dockerfile/Node.Dockerfile b/vendor/Dockerfile/Node.Dockerfile
new file mode 100644
index 00000000000..7e936d5e887
--- /dev/null
+++ b/vendor/Dockerfile/Node.Dockerfile
@@ -0,0 +1,14 @@
+FROM node:7.9
+
+WORKDIR /usr/src/app
+
+ARG NODE_ENV
+ENV NODE_ENV $NODE_ENV
+COPY package.json /usr/src/app/
+RUN npm install && npm cache clean
+COPY . /usr/src/app
+
+CMD [ "npm", "start" ]
+
+# replace this with your application's default port
+EXPOSE 8888
diff --git a/vendor/Dockerfile/OpenJDK-alpine.Dockerfile b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile
new file mode 100644
index 00000000000..ee853d9cfd2
--- /dev/null
+++ b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile
@@ -0,0 +1,8 @@
+FROM openjdk:8-alpine
+
+COPY . /usr/src/myapp
+WORKDIR /usr/src/myapp
+
+RUN javac Main.java
+
+CMD ["java", "Main"]
diff --git a/vendor/Dockerfile/OpenJDK.Dockerfile b/vendor/Dockerfile/OpenJDK.Dockerfile
new file mode 100644
index 00000000000..8a2ae62d93b
--- /dev/null
+++ b/vendor/Dockerfile/OpenJDK.Dockerfile
@@ -0,0 +1,8 @@
+FROM openjdk:9
+
+COPY . /usr/src/myapp
+WORKDIR /usr/src/myapp
+
+RUN javac Main.java
+
+CMD ["java", "Main"]
diff --git a/vendor/Dockerfile/Python-alpine.Dockerfile b/vendor/Dockerfile/Python-alpine.Dockerfile
new file mode 100644
index 00000000000..59ac9f504de
--- /dev/null
+++ b/vendor/Dockerfile/Python-alpine.Dockerfile
@@ -0,0 +1,19 @@
+FROM python:3.6-alpine
+
+# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs.
+# Or delete entirely if not needed.
+RUN apk --no-cache add postgresql-client
+
+WORKDIR /usr/src/app
+
+COPY requirements.txt /usr/src/app/
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . /usr/src/app
+
+# For Django
+EXPOSE 8000
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+
+# For some other command
+# CMD ["python", "app.py"]
diff --git a/vendor/Dockerfile/Python.Dockerfile b/vendor/Dockerfile/Python.Dockerfile
new file mode 100644
index 00000000000..7c43ad99060
--- /dev/null
+++ b/vendor/Dockerfile/Python.Dockerfile
@@ -0,0 +1,22 @@
+FROM python:3.6
+
+# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs.
+# Or delete entirely if not needed.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ postgresql-client \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src/app
+
+COPY requirements.txt /usr/src/app/
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . /usr/src/app
+
+# For Django
+EXPOSE 8000
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+
+# For some other command
+# CMD ["python", "app.py"]
diff --git a/vendor/Dockerfile/Ruby-alpine.Dockerfile b/vendor/Dockerfile/Ruby-alpine.Dockerfile
new file mode 100644
index 00000000000..9db4e2130f2
--- /dev/null
+++ b/vendor/Dockerfile/Ruby-alpine.Dockerfile
@@ -0,0 +1,24 @@
+FROM ruby:2.4-alpine
+
+# Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs.
+# Or delete entirely if not needed.
+RUN apk --no-cache add nodejs postgresql-client
+
+# throw errors if Gemfile has been modified since Gemfile.lock
+RUN bundle config --global frozen 1
+
+RUN mkdir -p /usr/src/app
+WORKDIR /usr/src/app
+
+COPY Gemfile Gemfile.lock /usr/src/app/
+RUN bundle install
+
+COPY . /usr/src/app
+
+# For Sinatra
+#EXPOSE 4567
+#CMD ["ruby", "./config.rb"]
+
+# For Rails
+EXPOSE 3000
+CMD ["rails", "server"]
diff --git a/vendor/Dockerfile/Ruby.Dockerfile b/vendor/Dockerfile/Ruby.Dockerfile
new file mode 100644
index 00000000000..feb880ee4b2
--- /dev/null
+++ b/vendor/Dockerfile/Ruby.Dockerfile
@@ -0,0 +1,27 @@
+FROM ruby:2.4
+
+# Edit with nodejs, mysql-client, postgresql-client, sqlite3, etc. for your needs.
+# Or delete entirely if not needed.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ nodejs \
+ postgresql-client \
+ && rm -rf /var/lib/apt/lists/*
+
+# throw errors if Gemfile has been modified since Gemfile.lock
+RUN bundle config --global frozen 1
+
+WORKDIR /usr/src/app
+
+COPY Gemfile Gemfile.lock /usr/src/app/
+RUN bundle install -j $(nproc)
+
+COPY . /usr/src/app
+
+# For Sinatra
+#EXPOSE 4567
+#CMD ["ruby", "./config.rb"]
+
+# For Rails
+EXPOSE 3000
+CMD ["rails", "server", "-b", "0.0.0.0"]
diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js
deleted file mode 100644
index 805485904a5..00000000000
--- a/vendor/assets/javascripts/cropper.js
+++ /dev/null
@@ -1,2993 +0,0 @@
-/*!
- * Cropper v2.3.0
- * https://github.com/fengyuanchen/cropper
- *
- * Copyright (c) 2014-2016 Fengyuan Chen and contributors
- * Released under the MIT license
- *
- * Date: 2016-02-22T02:13:13.332Z
- */
-
-(function (factory) {
- if (typeof define === 'function' && define.amd) {
- // AMD. Register as anonymous module.
- define(['jquery'], factory);
- } else if (typeof exports === 'object') {
- // Node / CommonJS
- factory(require('jquery'));
- } else {
- // Browser globals.
- factory(jQuery);
- }
-})(function ($) {
-
- 'use strict';
-
- // Globals
- var $window = $(window);
- var $document = $(document);
- var location = window.location;
- var navigator = window.navigator;
- var ArrayBuffer = window.ArrayBuffer;
- var Uint8Array = window.Uint8Array;
- var DataView = window.DataView;
- var btoa = window.btoa;
-
- // Constants
- var NAMESPACE = 'cropper';
-
- // Classes
- var CLASS_MODAL = 'cropper-modal';
- var CLASS_HIDE = 'cropper-hide';
- var CLASS_HIDDEN = 'cropper-hidden';
- var CLASS_INVISIBLE = 'cropper-invisible';
- var CLASS_MOVE = 'cropper-move';
- var CLASS_CROP = 'cropper-crop';
- var CLASS_DISABLED = 'cropper-disabled';
- var CLASS_BG = 'cropper-bg';
-
- // Events
- var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown';
- var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove';
- var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel';
- var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll';
- var EVENT_DBLCLICK = 'dblclick';
- var EVENT_LOAD = 'load.' + NAMESPACE;
- var EVENT_ERROR = 'error.' + NAMESPACE;
- var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace
- var EVENT_BUILD = 'build.' + NAMESPACE;
- var EVENT_BUILT = 'built.' + NAMESPACE;
- var EVENT_CROP_START = 'cropstart.' + NAMESPACE;
- var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE;
- var EVENT_CROP_END = 'cropend.' + NAMESPACE;
- var EVENT_CROP = 'crop.' + NAMESPACE;
- var EVENT_ZOOM = 'zoom.' + NAMESPACE;
-
- // RegExps
- var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/;
- var REGEXP_DATA_URL = /^data\:/;
- var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/;
- var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/;
-
- // Data keys
- var DATA_PREVIEW = 'preview';
- var DATA_ACTION = 'action';
-
- // Actions
- var ACTION_EAST = 'e';
- var ACTION_WEST = 'w';
- var ACTION_SOUTH = 's';
- var ACTION_NORTH = 'n';
- var ACTION_SOUTH_EAST = 'se';
- var ACTION_SOUTH_WEST = 'sw';
- var ACTION_NORTH_EAST = 'ne';
- var ACTION_NORTH_WEST = 'nw';
- var ACTION_ALL = 'all';
- var ACTION_CROP = 'crop';
- var ACTION_MOVE = 'move';
- var ACTION_ZOOM = 'zoom';
- var ACTION_NONE = 'none';
-
- // Supports
- var SUPPORT_CANVAS = $.isFunction($('<canvas>')[0].getContext);
- var IS_SAFARI = navigator && /safari/i.test(navigator.userAgent) && /apple computer/i.test(navigator.vendor);
-
- // Maths
- var num = Number;
- var min = Math.min;
- var max = Math.max;
- var abs = Math.abs;
- var sin = Math.sin;
- var cos = Math.cos;
- var sqrt = Math.sqrt;
- var round = Math.round;
- var floor = Math.floor;
-
- // Utilities
- var fromCharCode = String.fromCharCode;
-
- function isNumber(n) {
- return typeof n === 'number' && !isNaN(n);
- }
-
- function isUndefined(n) {
- return typeof n === 'undefined';
- }
-
- function toArray(obj, offset) {
- var args = [];
-
- // This is necessary for IE8
- if (isNumber(offset)) {
- args.push(offset);
- }
-
- return args.slice.apply(obj, args);
- }
-
- // Custom proxy to avoid jQuery's guid
- function proxy(fn, context) {
- var args = toArray(arguments, 2);
-
- return function () {
- return fn.apply(context, args.concat(toArray(arguments)));
- };
- }
-
- function isCrossOriginURL(url) {
- var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);
-
- return parts && (
- parts[1] !== location.protocol ||
- parts[2] !== location.hostname ||
- parts[3] !== location.port
- );
- }
-
- function addTimestamp(url) {
- var timestamp = 'timestamp=' + (new Date()).getTime();
-
- return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp);
- }
-
- function getCrossOrigin(crossOrigin) {
- return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : '';
- }
-
- function getImageSize(image, callback) {
- var newImage;
-
- // Modern browsers (ignore Safari, #120 & #509)
- if (image.naturalWidth && !IS_SAFARI) {
- return callback(image.naturalWidth, image.naturalHeight);
- }
-
- // IE8: Don't use `new Image()` here (#319)
- newImage = document.createElement('img');
-
- newImage.onload = function () {
- callback(this.width, this.height);
- };
-
- newImage.src = image.src;
- }
-
- function getTransform(options) {
- var transforms = [];
- var rotate = options.rotate;
- var scaleX = options.scaleX;
- var scaleY = options.scaleY;
-
- if (isNumber(rotate)) {
- transforms.push('rotate(' + rotate + 'deg)');
- }
-
- if (isNumber(scaleX) && isNumber(scaleY)) {
- transforms.push('scale(' + scaleX + ',' + scaleY + ')');
- }
-
- return transforms.length ? transforms.join(' ') : 'none';
- }
-
- function getRotatedSizes(data, isReversed) {
- var deg = abs(data.degree) % 180;
- var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180;
- var sinArc = sin(arc);
- var cosArc = cos(arc);
- var width = data.width;
- var height = data.height;
- var aspectRatio = data.aspectRatio;
- var newWidth;
- var newHeight;
-
- if (!isReversed) {
- newWidth = width * cosArc + height * sinArc;
- newHeight = width * sinArc + height * cosArc;
- } else {
- newWidth = width / (cosArc + sinArc / aspectRatio);
- newHeight = newWidth / aspectRatio;
- }
-
- return {
- width: newWidth,
- height: newHeight
- };
- }
-
- function getSourceCanvas(image, data) {
- var canvas = $('<canvas>')[0];
- var context = canvas.getContext('2d');
- var dstX = 0;
- var dstY = 0;
- var dstWidth = data.naturalWidth;
- var dstHeight = data.naturalHeight;
- var rotate = data.rotate;
- var scaleX = data.scaleX;
- var scaleY = data.scaleY;
- var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1);
- var rotatable = isNumber(rotate) && rotate !== 0;
- var advanced = rotatable || scalable;
- var canvasWidth = dstWidth * abs(scaleX || 1);
- var canvasHeight = dstHeight * abs(scaleY || 1);
- var translateX;
- var translateY;
- var rotated;
-
- if (scalable) {
- translateX = canvasWidth / 2;
- translateY = canvasHeight / 2;
- }
-
- if (rotatable) {
- rotated = getRotatedSizes({
- width: canvasWidth,
- height: canvasHeight,
- degree: rotate
- });
-
- canvasWidth = rotated.width;
- canvasHeight = rotated.height;
- translateX = canvasWidth / 2;
- translateY = canvasHeight / 2;
- }
-
- canvas.width = canvasWidth;
- canvas.height = canvasHeight;
-
- if (advanced) {
- dstX = -dstWidth / 2;
- dstY = -dstHeight / 2;
-
- context.save();
- context.translate(translateX, translateY);
- }
-
- if (rotatable) {
- context.rotate(rotate * Math.PI / 180);
- }
-
- // Should call `scale` after rotated
- if (scalable) {
- context.scale(scaleX, scaleY);
- }
-
- context.drawImage(image, floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight));
-
- if (advanced) {
- context.restore();
- }
-
- return canvas;
- }
-
- function getTouchesCenter(touches) {
- var length = touches.length;
- var pageX = 0;
- var pageY = 0;
-
- if (length) {
- $.each(touches, function (i, touch) {
- pageX += touch.pageX;
- pageY += touch.pageY;
- });
-
- pageX /= length;
- pageY /= length;
- }
-
- return {
- pageX: pageX,
- pageY: pageY
- };
- }
-
- function getStringFromCharCode(dataView, start, length) {
- var str = '';
- var i;
-
- for (i = start, length += start; i < length; i++) {
- str += fromCharCode(dataView.getUint8(i));
- }
-
- return str;
- }
-
- function getOrientation(arrayBuffer) {
- var dataView = new DataView(arrayBuffer);
- var length = dataView.byteLength;
- var orientation;
- var exifIDCode;
- var tiffOffset;
- var firstIFDOffset;
- var littleEndian;
- var endianness;
- var app1Start;
- var ifdStart;
- var offset;
- var i;
-
- // Only handle JPEG image (start by 0xFFD8)
- if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) {
- offset = 2;
-
- while (offset < length) {
- if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) {
- app1Start = offset;
- break;
- }
-
- offset++;
- }
- }
-
- if (app1Start) {
- exifIDCode = app1Start + 4;
- tiffOffset = app1Start + 10;
-
- if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') {
- endianness = dataView.getUint16(tiffOffset);
- littleEndian = endianness === 0x4949;
-
- if (littleEndian || endianness === 0x4D4D /* bigEndian */) {
- if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) {
- firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
-
- if (firstIFDOffset >= 0x00000008) {
- ifdStart = tiffOffset + firstIFDOffset;
- }
- }
- }
- }
- }
-
- if (ifdStart) {
- length = dataView.getUint16(ifdStart, littleEndian);
-
- for (i = 0; i < length; i++) {
- offset = ifdStart + i * 12 + 2;
-
- if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) {
-
- // 8 is the offset of the current tag's value
- offset += 8;
-
- // Get the original orientation value
- orientation = dataView.getUint16(offset, littleEndian);
-
- // Override the orientation with its default value for Safari (#120)
- if (IS_SAFARI) {
- dataView.setUint16(offset, 1, littleEndian);
- }
-
- break;
- }
- }
- }
-
- return orientation;
- }
-
- function dataURLToArrayBuffer(dataURL) {
- var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, '');
- var binary = atob(base64);
- var length = binary.length;
- var arrayBuffer = new ArrayBuffer(length);
- var dataView = new Uint8Array(arrayBuffer);
- var i;
-
- for (i = 0; i < length; i++) {
- dataView[i] = binary.charCodeAt(i);
- }
-
- return arrayBuffer;
- }
-
- // Only available for JPEG image
- function arrayBufferToDataURL(arrayBuffer) {
- var dataView = new Uint8Array(arrayBuffer);
- var length = dataView.length;
- var base64 = '';
- var i;
-
- for (i = 0; i < length; i++) {
- base64 += fromCharCode(dataView[i]);
- }
-
- return 'data:image/jpeg;base64,' + btoa(base64);
- }
-
- function Cropper(element, options) {
- this.$element = $(element);
- this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options);
- this.isLoaded = false;
- this.isBuilt = false;
- this.isCompleted = false;
- this.isRotated = false;
- this.isCropped = false;
- this.isDisabled = false;
- this.isReplaced = false;
- this.isLimited = false;
- this.wheeling = false;
- this.isImg = false;
- this.originalUrl = '';
- this.canvas = null;
- this.cropBox = null;
- this.init();
- }
-
- Cropper.prototype = {
- constructor: Cropper,
-
- init: function () {
- var $this = this.$element;
- var url;
-
- if ($this.is('img')) {
- this.isImg = true;
-
- // Should use `$.fn.attr` here. e.g.: "img/picture.jpg"
- this.originalUrl = url = $this.attr('src');
-
- // Stop when it's a blank image
- if (!url) {
- return;
- }
-
- // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg"
- url = $this.prop('src');
- } else if ($this.is('canvas') && SUPPORT_CANVAS) {
- url = $this[0].toDataURL();
- }
-
- this.load(url);
- },
-
- // A shortcut for triggering custom events
- trigger: function (type, data) {
- var e = $.Event(type, data);
-
- this.$element.trigger(e);
-
- return e;
- },
-
- load: function (url) {
- var options = this.options;
- var $this = this.$element;
- var read;
- var xhr;
-
- if (!url) {
- return;
- }
-
- // Trigger build event first
- $this.one(EVENT_BUILD, options.build);
-
- if (this.trigger(EVENT_BUILD).isDefaultPrevented()) {
- return;
- }
-
- this.url = url;
- this.image = {};
-
- if (!options.checkOrientation || !ArrayBuffer) {
- return this.clone();
- }
-
- read = $.proxy(this.read, this);
-
- // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari
- if (REGEXP_DATA_URL.test(url)) {
- return REGEXP_DATA_URL_JPEG.test(url) ?
- read(dataURLToArrayBuffer(url)) :
- this.clone();
- }
-
- xhr = new XMLHttpRequest();
-
- xhr.onerror = xhr.onabort = $.proxy(function () {
- this.clone();
- }, this);
-
- xhr.onload = function () {
- read(this.response);
- };
-
- xhr.open('get', url);
- xhr.responseType = 'arraybuffer';
- xhr.send();
- },
-
- read: function (arrayBuffer) {
- var options = this.options;
- var orientation = getOrientation(arrayBuffer);
- var image = this.image;
- var rotate;
- var scaleX;
- var scaleY;
-
- if (orientation > 1) {
- this.url = arrayBufferToDataURL(arrayBuffer);
-
- switch (orientation) {
-
- // flip horizontal
- case 2:
- scaleX = -1;
- break;
-
- // rotate left 180°
- case 3:
- rotate = -180;
- break;
-
- // flip vertical
- case 4:
- scaleY = -1;
- break;
-
- // flip vertical + rotate right 90°
- case 5:
- rotate = 90;
- scaleY = -1;
- break;
-
- // rotate right 90°
- case 6:
- rotate = 90;
- break;
-
- // flip horizontal + rotate right 90°
- case 7:
- rotate = 90;
- scaleX = -1;
- break;
-
- // rotate left 90°
- case 8:
- rotate = -90;
- break;
- }
- }
-
- if (options.rotatable) {
- image.rotate = rotate;
- }
-
- if (options.scalable) {
- image.scaleX = scaleX;
- image.scaleY = scaleY;
- }
-
- this.clone();
- },
-
- clone: function () {
- var options = this.options;
- var $this = this.$element;
- var url = this.url;
- var crossOrigin = '';
- var crossOriginUrl;
- var $clone;
-
- if (options.checkCrossOrigin && isCrossOriginURL(url)) {
- crossOrigin = $this.prop('crossOrigin');
-
- if (crossOrigin) {
- crossOriginUrl = url;
- } else {
- crossOrigin = 'anonymous';
-
- // Bust cache (#148) when there is not a "crossOrigin" property
- crossOriginUrl = addTimestamp(url);
- }
- }
-
- this.crossOrigin = crossOrigin;
- this.crossOriginUrl = crossOriginUrl;
- this.$clone = $clone = $('<img' + getCrossOrigin(crossOrigin) + ' src="' + (crossOriginUrl || url) + '">');
-
- if (this.isImg) {
- if ($this[0].complete) {
- this.start();
- } else {
- $this.one(EVENT_LOAD, $.proxy(this.start, this));
- }
- } else {
- $clone.
- one(EVENT_LOAD, $.proxy(this.start, this)).
- one(EVENT_ERROR, $.proxy(this.stop, this)).
- addClass(CLASS_HIDE).
- insertAfter($this);
- }
- },
-
- start: function () {
- var $image = this.$element;
- var $clone = this.$clone;
-
- if (!this.isImg) {
- $clone.off(EVENT_ERROR, this.stop);
- $image = $clone;
- }
-
- getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) {
- $.extend(this.image, {
- naturalWidth: naturalWidth,
- naturalHeight: naturalHeight,
- aspectRatio: naturalWidth / naturalHeight
- });
-
- this.isLoaded = true;
- this.build();
- }, this));
- },
-
- stop: function () {
- this.$clone.remove();
- this.$clone = null;
- },
-
- build: function () {
- var options = this.options;
- var $this = this.$element;
- var $clone = this.$clone;
- var $cropper;
- var $cropBox;
- var $face;
-
- if (!this.isLoaded) {
- return;
- }
-
- // Unbuild first when replace
- if (this.isBuilt) {
- this.unbuild();
- }
-
- // Create cropper elements
- this.$container = $this.parent();
- this.$cropper = $cropper = $(Cropper.TEMPLATE);
- this.$canvas = $cropper.find('.cropper-canvas').append($clone);
- this.$dragBox = $cropper.find('.cropper-drag-box');
- this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box');
- this.$viewBox = $cropper.find('.cropper-view-box');
- this.$face = $face = $cropBox.find('.cropper-face');
-
- // Hide the original image
- $this.addClass(CLASS_HIDDEN).after($cropper);
-
- // Show the clone image if is hidden
- if (!this.isImg) {
- $clone.removeClass(CLASS_HIDE);
- }
-
- this.initPreview();
- this.bind();
-
- options.aspectRatio = max(0, options.aspectRatio) || NaN;
- options.viewMode = max(0, min(3, round(options.viewMode))) || 0;
-
- if (options.autoCrop) {
- this.isCropped = true;
-
- if (options.modal) {
- this.$dragBox.addClass(CLASS_MODAL);
- }
- } else {
- $cropBox.addClass(CLASS_HIDDEN);
- }
-
- if (!options.guides) {
- $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN);
- }
-
- if (!options.center) {
- $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN);
- }
-
- if (options.cropBoxMovable) {
- $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL);
- }
-
- if (!options.highlight) {
- $face.addClass(CLASS_INVISIBLE);
- }
-
- if (options.background) {
- $cropper.addClass(CLASS_BG);
- }
-
- if (!options.cropBoxResizable) {
- $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN);
- }
-
- this.setDragMode(options.dragMode);
- this.render();
- this.isBuilt = true;
- this.setData(options.data);
- $this.one(EVENT_BUILT, options.built);
-
- // Trigger the built event asynchronously to keep `data('cropper')` is defined
- setTimeout($.proxy(function () {
- this.trigger(EVENT_BUILT);
- this.isCompleted = true;
- }, this), 0);
- },
-
- unbuild: function () {
- if (!this.isBuilt) {
- return;
- }
-
- this.isBuilt = false;
- this.isCompleted = false;
- this.initialImage = null;
-
- // Clear `initialCanvas` is necessary when replace
- this.initialCanvas = null;
- this.initialCropBox = null;
- this.container = null;
- this.canvas = null;
-
- // Clear `cropBox` is necessary when replace
- this.cropBox = null;
- this.unbind();
-
- this.resetPreview();
- this.$preview = null;
-
- this.$viewBox = null;
- this.$cropBox = null;
- this.$dragBox = null;
- this.$canvas = null;
- this.$container = null;
-
- this.$cropper.remove();
- this.$cropper = null;
- },
-
- render: function () {
- this.initContainer();
- this.initCanvas();
- this.initCropBox();
-
- this.renderCanvas();
-
- if (this.isCropped) {
- this.renderCropBox();
- }
- },
-
- initContainer: function () {
- var options = this.options;
- var $this = this.$element;
- var $container = this.$container;
- var $cropper = this.$cropper;
-
- $cropper.addClass(CLASS_HIDDEN);
- $this.removeClass(CLASS_HIDDEN);
-
- $cropper.css((this.container = {
- width: max($container.width(), num(options.minContainerWidth) || 200),
- height: max($container.height(), num(options.minContainerHeight) || 100)
- }));
-
- $this.addClass(CLASS_HIDDEN);
- $cropper.removeClass(CLASS_HIDDEN);
- },
-
- // Canvas (image wrapper)
- initCanvas: function () {
- var viewMode = this.options.viewMode;
- var container = this.container;
- var containerWidth = container.width;
- var containerHeight = container.height;
- var image = this.image;
- var imageNaturalWidth = image.naturalWidth;
- var imageNaturalHeight = image.naturalHeight;
- var is90Degree = abs(image.rotate) === 90;
- var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth;
- var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight;
- var aspectRatio = naturalWidth / naturalHeight;
- var canvasWidth = containerWidth;
- var canvasHeight = containerHeight;
- var canvas;
-
- if (containerHeight * aspectRatio > containerWidth) {
- if (viewMode === 3) {
- canvasWidth = containerHeight * aspectRatio;
- } else {
- canvasHeight = containerWidth / aspectRatio;
- }
- } else {
- if (viewMode === 3) {
- canvasHeight = containerWidth / aspectRatio;
- } else {
- canvasWidth = containerHeight * aspectRatio;
- }
- }
-
- canvas = {
- naturalWidth: naturalWidth,
- naturalHeight: naturalHeight,
- aspectRatio: aspectRatio,
- width: canvasWidth,
- height: canvasHeight
- };
-
- canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2;
- canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2;
-
- this.canvas = canvas;
- this.isLimited = (viewMode === 1 || viewMode === 2);
- this.limitCanvas(true, true);
- this.initialImage = $.extend({}, image);
- this.initialCanvas = $.extend({}, canvas);
- },
-
- limitCanvas: function (isSizeLimited, isPositionLimited) {
- var options = this.options;
- var viewMode = options.viewMode;
- var container = this.container;
- var containerWidth = container.width;
- var containerHeight = container.height;
- var canvas = this.canvas;
- var aspectRatio = canvas.aspectRatio;
- var cropBox = this.cropBox;
- var isCropped = this.isCropped && cropBox;
- var minCanvasWidth;
- var minCanvasHeight;
- var newCanvasLeft;
- var newCanvasTop;
-
- if (isSizeLimited) {
- minCanvasWidth = num(options.minCanvasWidth) || 0;
- minCanvasHeight = num(options.minCanvasHeight) || 0;
-
- if (viewMode) {
- if (viewMode > 1) {
- minCanvasWidth = max(minCanvasWidth, containerWidth);
- minCanvasHeight = max(minCanvasHeight, containerHeight);
-
- if (viewMode === 3) {
- if (minCanvasHeight * aspectRatio > minCanvasWidth) {
- minCanvasWidth = minCanvasHeight * aspectRatio;
- } else {
- minCanvasHeight = minCanvasWidth / aspectRatio;
- }
- }
- } else {
- if (minCanvasWidth) {
- minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0);
- } else if (minCanvasHeight) {
- minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0);
- } else if (isCropped) {
- minCanvasWidth = cropBox.width;
- minCanvasHeight = cropBox.height;
-
- if (minCanvasHeight * aspectRatio > minCanvasWidth) {
- minCanvasWidth = minCanvasHeight * aspectRatio;
- } else {
- minCanvasHeight = minCanvasWidth / aspectRatio;
- }
- }
- }
- }
-
- if (minCanvasWidth && minCanvasHeight) {
- if (minCanvasHeight * aspectRatio > minCanvasWidth) {
- minCanvasHeight = minCanvasWidth / aspectRatio;
- } else {
- minCanvasWidth = minCanvasHeight * aspectRatio;
- }
- } else if (minCanvasWidth) {
- minCanvasHeight = minCanvasWidth / aspectRatio;
- } else if (minCanvasHeight) {
- minCanvasWidth = minCanvasHeight * aspectRatio;
- }
-
- canvas.minWidth = minCanvasWidth;
- canvas.minHeight = minCanvasHeight;
- canvas.maxWidth = Infinity;
- canvas.maxHeight = Infinity;
- }
-
- if (isPositionLimited) {
- if (viewMode) {
- newCanvasLeft = containerWidth - canvas.width;
- newCanvasTop = containerHeight - canvas.height;
-
- canvas.minLeft = min(0, newCanvasLeft);
- canvas.minTop = min(0, newCanvasTop);
- canvas.maxLeft = max(0, newCanvasLeft);
- canvas.maxTop = max(0, newCanvasTop);
-
- if (isCropped && this.isLimited) {
- canvas.minLeft = min(
- cropBox.left,
- cropBox.left + cropBox.width - canvas.width
- );
- canvas.minTop = min(
- cropBox.top,
- cropBox.top + cropBox.height - canvas.height
- );
- canvas.maxLeft = cropBox.left;
- canvas.maxTop = cropBox.top;
-
- if (viewMode === 2) {
- if (canvas.width >= containerWidth) {
- canvas.minLeft = min(0, newCanvasLeft);
- canvas.maxLeft = max(0, newCanvasLeft);
- }
-
- if (canvas.height >= containerHeight) {
- canvas.minTop = min(0, newCanvasTop);
- canvas.maxTop = max(0, newCanvasTop);
- }
- }
- }
- } else {
- canvas.minLeft = -canvas.width;
- canvas.minTop = -canvas.height;
- canvas.maxLeft = containerWidth;
- canvas.maxTop = containerHeight;
- }
- }
- },
-
- renderCanvas: function (isChanged) {
- var canvas = this.canvas;
- var image = this.image;
- var rotate = image.rotate;
- var naturalWidth = image.naturalWidth;
- var naturalHeight = image.naturalHeight;
- var aspectRatio;
- var rotated;
-
- if (this.isRotated) {
- this.isRotated = false;
-
- // Computes rotated sizes with image sizes
- rotated = getRotatedSizes({
- width: image.width,
- height: image.height,
- degree: rotate
- });
-
- aspectRatio = rotated.width / rotated.height;
-
- if (aspectRatio !== canvas.aspectRatio) {
- canvas.left -= (rotated.width - canvas.width) / 2;
- canvas.top -= (rotated.height - canvas.height) / 2;
- canvas.width = rotated.width;
- canvas.height = rotated.height;
- canvas.aspectRatio = aspectRatio;
- canvas.naturalWidth = naturalWidth;
- canvas.naturalHeight = naturalHeight;
-
- // Computes rotated sizes with natural image sizes
- if (rotate % 180) {
- rotated = getRotatedSizes({
- width: naturalWidth,
- height: naturalHeight,
- degree: rotate
- });
-
- canvas.naturalWidth = rotated.width;
- canvas.naturalHeight = rotated.height;
- }
-
- this.limitCanvas(true, false);
- }
- }
-
- if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) {
- canvas.left = canvas.oldLeft;
- }
-
- if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) {
- canvas.top = canvas.oldTop;
- }
-
- canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth);
- canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight);
-
- this.limitCanvas(false, true);
-
- canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft);
- canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop);
-
- this.$canvas.css({
- width: canvas.width,
- height: canvas.height,
- left: canvas.left,
- top: canvas.top
- });
-
- this.renderImage();
-
- if (this.isCropped && this.isLimited) {
- this.limitCropBox(true, true);
- }
-
- if (isChanged) {
- this.output();
- }
- },
-
- renderImage: function (isChanged) {
- var canvas = this.canvas;
- var image = this.image;
- var reversed;
-
- if (image.rotate) {
- reversed = getRotatedSizes({
- width: canvas.width,
- height: canvas.height,
- degree: image.rotate,
- aspectRatio: image.aspectRatio
- }, true);
- }
-
- $.extend(image, reversed ? {
- width: reversed.width,
- height: reversed.height,
- left: (canvas.width - reversed.width) / 2,
- top: (canvas.height - reversed.height) / 2
- } : {
- width: canvas.width,
- height: canvas.height,
- left: 0,
- top: 0
- });
-
- this.$clone.css({
- width: image.width,
- height: image.height,
- marginLeft: image.left,
- marginTop: image.top,
- transform: getTransform(image)
- });
-
- if (isChanged) {
- this.output();
- }
- },
-
- initCropBox: function () {
- var options = this.options;
- var canvas = this.canvas;
- var aspectRatio = options.aspectRatio;
- var autoCropArea = num(options.autoCropArea) || 0.8;
- var cropBox = {
- width: canvas.width,
- height: canvas.height
- };
-
- if (aspectRatio) {
- if (canvas.height * aspectRatio > canvas.width) {
- cropBox.height = cropBox.width / aspectRatio;
- } else {
- cropBox.width = cropBox.height * aspectRatio;
- }
- }
-
- this.cropBox = cropBox;
- this.limitCropBox(true, true);
-
- // Initialize auto crop area
- cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth);
- cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight);
-
- // The width of auto crop area must large than "minWidth", and the height too. (#164)
- cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea);
- cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea);
- cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2;
- cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2;
-
- this.initialCropBox = $.extend({}, cropBox);
- },
-
- limitCropBox: function (isSizeLimited, isPositionLimited) {
- var options = this.options;
- var aspectRatio = options.aspectRatio;
- var container = this.container;
- var containerWidth = container.width;
- var containerHeight = container.height;
- var canvas = this.canvas;
- var cropBox = this.cropBox;
- var isLimited = this.isLimited;
- var minCropBoxWidth;
- var minCropBoxHeight;
- var maxCropBoxWidth;
- var maxCropBoxHeight;
-
- if (isSizeLimited) {
- minCropBoxWidth = num(options.minCropBoxWidth) || 0;
- minCropBoxHeight = num(options.minCropBoxHeight) || 0;
-
- // The min/maxCropBoxWidth/Height must be less than containerWidth/Height
- minCropBoxWidth = min(minCropBoxWidth, containerWidth);
- minCropBoxHeight = min(minCropBoxHeight, containerHeight);
- maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth);
- maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight);
-
- if (aspectRatio) {
- if (minCropBoxWidth && minCropBoxHeight) {
- if (minCropBoxHeight * aspectRatio > minCropBoxWidth) {
- minCropBoxHeight = minCropBoxWidth / aspectRatio;
- } else {
- minCropBoxWidth = minCropBoxHeight * aspectRatio;
- }
- } else if (minCropBoxWidth) {
- minCropBoxHeight = minCropBoxWidth / aspectRatio;
- } else if (minCropBoxHeight) {
- minCropBoxWidth = minCropBoxHeight * aspectRatio;
- }
-
- if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) {
- maxCropBoxHeight = maxCropBoxWidth / aspectRatio;
- } else {
- maxCropBoxWidth = maxCropBoxHeight * aspectRatio;
- }
- }
-
- // The minWidth/Height must be less than maxWidth/Height
- cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth);
- cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight);
- cropBox.maxWidth = maxCropBoxWidth;
- cropBox.maxHeight = maxCropBoxHeight;
- }
-
- if (isPositionLimited) {
- if (isLimited) {
- cropBox.minLeft = max(0, canvas.left);
- cropBox.minTop = max(0, canvas.top);
- cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width;
- cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height;
- } else {
- cropBox.minLeft = 0;
- cropBox.minTop = 0;
- cropBox.maxLeft = containerWidth - cropBox.width;
- cropBox.maxTop = containerHeight - cropBox.height;
- }
- }
- },
-
- renderCropBox: function () {
- var options = this.options;
- var container = this.container;
- var containerWidth = container.width;
- var containerHeight = container.height;
- var cropBox = this.cropBox;
-
- if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) {
- cropBox.left = cropBox.oldLeft;
- }
-
- if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) {
- cropBox.top = cropBox.oldTop;
- }
-
- cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth);
- cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight);
-
- this.limitCropBox(false, true);
-
- cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft);
- cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop);
-
- if (options.movable && options.cropBoxMovable) {
-
- // Turn to move the canvas when the crop box is equal to the container
- this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL);
- }
-
- this.$cropBox.css({
- width: cropBox.width,
- height: cropBox.height,
- left: cropBox.left,
- top: cropBox.top
- });
-
- if (this.isCropped && this.isLimited) {
- this.limitCanvas(true, true);
- }
-
- if (!this.isDisabled) {
- this.output();
- }
- },
-
- output: function () {
- this.preview();
-
- if (this.isCompleted) {
- this.trigger(EVENT_CROP, this.getData());
- } else if (!this.isBuilt) {
-
- // Only trigger one crop event before complete
- this.$element.one(EVENT_BUILT, $.proxy(function () {
- this.trigger(EVENT_CROP, this.getData());
- }, this));
- }
- },
-
- initPreview: function () {
- var crossOrigin = getCrossOrigin(this.crossOrigin);
- var url = crossOrigin ? this.crossOriginUrl : this.url;
- var $clone2;
-
- this.$preview = $(this.options.preview);
- this.$clone2 = $clone2 = $('<img' + crossOrigin + ' src="' + url + '">');
- this.$viewBox.html($clone2);
- this.$preview.each(function () {
- var $this = $(this);
-
- // Save the original size for recover
- $this.data(DATA_PREVIEW, {
- width: $this.width(),
- height: $this.height(),
- html: $this.html()
- });
-
- /**
- * Override img element styles
- * Add `display:block` to avoid margin top issue
- * (Occur only when margin-top <= -height)
- */
- $this.html(
- '<img' + crossOrigin + ' src="' + url + '" style="' +
- 'display:block;width:100%;height:auto;' +
- 'min-width:0!important;min-height:0!important;' +
- 'max-width:none!important;max-height:none!important;' +
- 'image-orientation:0deg!important;">'
- );
- });
- },
-
- resetPreview: function () {
- this.$preview.each(function () {
- var $this = $(this);
- var data = $this.data(DATA_PREVIEW);
-
- $this.css({
- width: data.width,
- height: data.height
- }).html(data.html).removeData(DATA_PREVIEW);
- });
- },
-
- preview: function () {
- var image = this.image;
- var canvas = this.canvas;
- var cropBox = this.cropBox;
- var cropBoxWidth = cropBox.width;
- var cropBoxHeight = cropBox.height;
- var width = image.width;
- var height = image.height;
- var left = cropBox.left - canvas.left - image.left;
- var top = cropBox.top - canvas.top - image.top;
-
- if (!this.isCropped || this.isDisabled) {
- return;
- }
-
- this.$clone2.css({
- width: width,
- height: height,
- marginLeft: -left,
- marginTop: -top,
- transform: getTransform(image)
- });
-
- this.$preview.each(function () {
- var $this = $(this);
- var data = $this.data(DATA_PREVIEW);
- var originalWidth = data.width;
- var originalHeight = data.height;
- var newWidth = originalWidth;
- var newHeight = originalHeight;
- var ratio = 1;
-
- if (cropBoxWidth) {
- ratio = originalWidth / cropBoxWidth;
- newHeight = cropBoxHeight * ratio;
- }
-
- if (cropBoxHeight && newHeight > originalHeight) {
- ratio = originalHeight / cropBoxHeight;
- newWidth = cropBoxWidth * ratio;
- newHeight = originalHeight;
- }
-
- $this.css({
- width: newWidth,
- height: newHeight
- }).find('img').css({
- width: width * ratio,
- height: height * ratio,
- marginLeft: -left * ratio,
- marginTop: -top * ratio,
- transform: getTransform(image)
- });
- });
- },
-
- bind: function () {
- var options = this.options;
- var $this = this.$element;
- var $cropper = this.$cropper;
-
- if ($.isFunction(options.cropstart)) {
- $this.on(EVENT_CROP_START, options.cropstart);
- }
-
- if ($.isFunction(options.cropmove)) {
- $this.on(EVENT_CROP_MOVE, options.cropmove);
- }
-
- if ($.isFunction(options.cropend)) {
- $this.on(EVENT_CROP_END, options.cropend);
- }
-
- if ($.isFunction(options.crop)) {
- $this.on(EVENT_CROP, options.crop);
- }
-
- if ($.isFunction(options.zoom)) {
- $this.on(EVENT_ZOOM, options.zoom);
- }
-
- $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this));
-
- if (options.zoomable && options.zoomOnWheel) {
- $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this));
- }
-
- if (options.toggleDragModeOnDblclick) {
- $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this));
- }
-
- $document.
- on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))).
- on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this)));
-
- if (options.responsive) {
- $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this)));
- }
- },
-
- unbind: function () {
- var options = this.options;
- var $this = this.$element;
- var $cropper = this.$cropper;
-
- if ($.isFunction(options.cropstart)) {
- $this.off(EVENT_CROP_START, options.cropstart);
- }
-
- if ($.isFunction(options.cropmove)) {
- $this.off(EVENT_CROP_MOVE, options.cropmove);
- }
-
- if ($.isFunction(options.cropend)) {
- $this.off(EVENT_CROP_END, options.cropend);
- }
-
- if ($.isFunction(options.crop)) {
- $this.off(EVENT_CROP, options.crop);
- }
-
- if ($.isFunction(options.zoom)) {
- $this.off(EVENT_ZOOM, options.zoom);
- }
-
- $cropper.off(EVENT_MOUSE_DOWN, this.cropStart);
-
- if (options.zoomable && options.zoomOnWheel) {
- $cropper.off(EVENT_WHEEL, this.wheel);
- }
-
- if (options.toggleDragModeOnDblclick) {
- $cropper.off(EVENT_DBLCLICK, this.dblclick);
- }
-
- $document.
- off(EVENT_MOUSE_MOVE, this._cropMove).
- off(EVENT_MOUSE_UP, this._cropEnd);
-
- if (options.responsive) {
- $window.off(EVENT_RESIZE, this._resize);
- }
- },
-
- resize: function () {
- var restore = this.options.restore;
- var $container = this.$container;
- var container = this.container;
- var canvasData;
- var cropBoxData;
- var ratio;
-
- // Check `container` is necessary for IE8
- if (this.isDisabled || !container) {
- return;
- }
-
- ratio = $container.width() / container.width;
-
- // Resize when width changed or height changed
- if (ratio !== 1 || $container.height() !== container.height) {
- if (restore) {
- canvasData = this.getCanvasData();
- cropBoxData = this.getCropBoxData();
- }
-
- this.render();
-
- if (restore) {
- this.setCanvasData($.each(canvasData, function (i, n) {
- canvasData[i] = n * ratio;
- }));
- this.setCropBoxData($.each(cropBoxData, function (i, n) {
- cropBoxData[i] = n * ratio;
- }));
- }
- }
- },
-
- dblclick: function () {
- if (this.isDisabled) {
- return;
- }
-
- if (this.$dragBox.hasClass(CLASS_CROP)) {
- this.setDragMode(ACTION_MOVE);
- } else {
- this.setDragMode(ACTION_CROP);
- }
- },
-
- wheel: function (event) {
- var e = event.originalEvent || event;
- var ratio = num(this.options.wheelZoomRatio) || 0.1;
- var delta = 1;
-
- if (this.isDisabled) {
- return;
- }
-
- event.preventDefault();
-
- // Limit wheel speed to prevent zoom too fast
- if (this.wheeling) {
- return;
- }
-
- this.wheeling = true;
-
- setTimeout($.proxy(function () {
- this.wheeling = false;
- }, this), 50);
-
- if (e.deltaY) {
- delta = e.deltaY > 0 ? 1 : -1;
- } else if (e.wheelDelta) {
- delta = -e.wheelDelta / 120;
- } else if (e.detail) {
- delta = e.detail > 0 ? 1 : -1;
- }
-
- this.zoom(-delta * ratio, event);
- },
-
- cropStart: function (event) {
- var options = this.options;
- var originalEvent = event.originalEvent;
- var touches = originalEvent && originalEvent.touches;
- var e = event;
- var touchesLength;
- var action;
-
- if (this.isDisabled) {
- return;
- }
-
- if (touches) {
- touchesLength = touches.length;
-
- if (touchesLength > 1) {
- if (options.zoomable && options.zoomOnTouch && touchesLength === 2) {
- e = touches[1];
- this.startX2 = e.pageX;
- this.startY2 = e.pageY;
- action = ACTION_ZOOM;
- } else {
- return;
- }
- }
-
- e = touches[0];
- }
-
- action = action || $(e.target).data(DATA_ACTION);
-
- if (REGEXP_ACTIONS.test(action)) {
- if (this.trigger(EVENT_CROP_START, {
- originalEvent: originalEvent,
- action: action
- }).isDefaultPrevented()) {
- return;
- }
-
- event.preventDefault();
-
- this.action = action;
- this.cropping = false;
-
- // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y`
- // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y`
- this.startX = e.pageX || originalEvent && originalEvent.pageX;
- this.startY = e.pageY || originalEvent && originalEvent.pageY;
-
- if (action === ACTION_CROP) {
- this.cropping = true;
- this.$dragBox.addClass(CLASS_MODAL);
- }
- }
- },
-
- cropMove: function (event) {
- var options = this.options;
- var originalEvent = event.originalEvent;
- var touches = originalEvent && originalEvent.touches;
- var e = event;
- var action = this.action;
- var touchesLength;
-
- if (this.isDisabled) {
- return;
- }
-
- if (touches) {
- touchesLength = touches.length;
-
- if (touchesLength > 1) {
- if (options.zoomable && options.zoomOnTouch && touchesLength === 2) {
- e = touches[1];
- this.endX2 = e.pageX;
- this.endY2 = e.pageY;
- } else {
- return;
- }
- }
-
- e = touches[0];
- }
-
- if (action) {
- if (this.trigger(EVENT_CROP_MOVE, {
- originalEvent: originalEvent,
- action: action
- }).isDefaultPrevented()) {
- return;
- }
-
- event.preventDefault();
-
- this.endX = e.pageX || originalEvent && originalEvent.pageX;
- this.endY = e.pageY || originalEvent && originalEvent.pageY;
-
- this.change(e.shiftKey, action === ACTION_ZOOM ? event : null);
- }
- },
-
- cropEnd: function (event) {
- var originalEvent = event.originalEvent;
- var action = this.action;
-
- if (this.isDisabled) {
- return;
- }
-
- if (action) {
- event.preventDefault();
-
- if (this.cropping) {
- this.cropping = false;
- this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal);
- }
-
- this.action = '';
-
- this.trigger(EVENT_CROP_END, {
- originalEvent: originalEvent,
- action: action
- });
- }
- },
-
- change: function (shiftKey, event) {
- var options = this.options;
- var aspectRatio = options.aspectRatio;
- var action = this.action;
- var container = this.container;
- var canvas = this.canvas;
- var cropBox = this.cropBox;
- var width = cropBox.width;
- var height = cropBox.height;
- var left = cropBox.left;
- var top = cropBox.top;
- var right = left + width;
- var bottom = top + height;
- var minLeft = 0;
- var minTop = 0;
- var maxWidth = container.width;
- var maxHeight = container.height;
- var renderable = true;
- var offset;
- var range;
-
- // Locking aspect ratio in "free mode" by holding shift key (#259)
- if (!aspectRatio && shiftKey) {
- aspectRatio = width && height ? width / height : 1;
- }
-
- if (this.limited) {
- minLeft = cropBox.minLeft;
- minTop = cropBox.minTop;
- maxWidth = minLeft + min(container.width, canvas.left + canvas.width);
- maxHeight = minTop + min(container.height, canvas.top + canvas.height);
- }
-
- range = {
- x: this.endX - this.startX,
- y: this.endY - this.startY
- };
-
- if (aspectRatio) {
- range.X = range.y * aspectRatio;
- range.Y = range.x / aspectRatio;
- }
-
- switch (action) {
- // Move crop box
- case ACTION_ALL:
- left += range.x;
- top += range.y;
- break;
-
- // Resize crop box
- case ACTION_EAST:
- if (range.x >= 0 && (right >= maxWidth || aspectRatio &&
- (top <= minTop || bottom >= maxHeight))) {
-
- renderable = false;
- break;
- }
-
- width += range.x;
-
- if (aspectRatio) {
- height = width / aspectRatio;
- top -= range.Y / 2;
- }
-
- if (width < 0) {
- action = ACTION_WEST;
- width = 0;
- }
-
- break;
-
- case ACTION_NORTH:
- if (range.y <= 0 && (top <= minTop || aspectRatio &&
- (left <= minLeft || right >= maxWidth))) {
-
- renderable = false;
- break;
- }
-
- height -= range.y;
- top += range.y;
-
- if (aspectRatio) {
- width = height * aspectRatio;
- left += range.X / 2;
- }
-
- if (height < 0) {
- action = ACTION_SOUTH;
- height = 0;
- }
-
- break;
-
- case ACTION_WEST:
- if (range.x <= 0 && (left <= minLeft || aspectRatio &&
- (top <= minTop || bottom >= maxHeight))) {
-
- renderable = false;
- break;
- }
-
- width -= range.x;
- left += range.x;
-
- if (aspectRatio) {
- height = width / aspectRatio;
- top += range.Y / 2;
- }
-
- if (width < 0) {
- action = ACTION_EAST;
- width = 0;
- }
-
- break;
-
- case ACTION_SOUTH:
- if (range.y >= 0 && (bottom >= maxHeight || aspectRatio &&
- (left <= minLeft || right >= maxWidth))) {
-
- renderable = false;
- break;
- }
-
- height += range.y;
-
- if (aspectRatio) {
- width = height * aspectRatio;
- left -= range.X / 2;
- }
-
- if (height < 0) {
- action = ACTION_NORTH;
- height = 0;
- }
-
- break;
-
- case ACTION_NORTH_EAST:
- if (aspectRatio) {
- if (range.y <= 0 && (top <= minTop || right >= maxWidth)) {
- renderable = false;
- break;
- }
-
- height -= range.y;
- top += range.y;
- width = height * aspectRatio;
- } else {
- if (range.x >= 0) {
- if (right < maxWidth) {
- width += range.x;
- } else if (range.y <= 0 && top <= minTop) {
- renderable = false;
- }
- } else {
- width += range.x;
- }
-
- if (range.y <= 0) {
- if (top > minTop) {
- height -= range.y;
- top += range.y;
- }
- } else {
- height -= range.y;
- top += range.y;
- }
- }
-
- if (width < 0 && height < 0) {
- action = ACTION_SOUTH_WEST;
- height = 0;
- width = 0;
- } else if (width < 0) {
- action = ACTION_NORTH_WEST;
- width = 0;
- } else if (height < 0) {
- action = ACTION_SOUTH_EAST;
- height = 0;
- }
-
- break;
-
- case ACTION_NORTH_WEST:
- if (aspectRatio) {
- if (range.y <= 0 && (top <= minTop || left <= minLeft)) {
- renderable = false;
- break;
- }
-
- height -= range.y;
- top += range.y;
- width = height * aspectRatio;
- left += range.X;
- } else {
- if (range.x <= 0) {
- if (left > minLeft) {
- width -= range.x;
- left += range.x;
- } else if (range.y <= 0 && top <= minTop) {
- renderable = false;
- }
- } else {
- width -= range.x;
- left += range.x;
- }
-
- if (range.y <= 0) {
- if (top > minTop) {
- height -= range.y;
- top += range.y;
- }
- } else {
- height -= range.y;
- top += range.y;
- }
- }
-
- if (width < 0 && height < 0) {
- action = ACTION_SOUTH_EAST;
- height = 0;
- width = 0;
- } else if (width < 0) {
- action = ACTION_NORTH_EAST;
- width = 0;
- } else if (height < 0) {
- action = ACTION_SOUTH_WEST;
- height = 0;
- }
-
- break;
-
- case ACTION_SOUTH_WEST:
- if (aspectRatio) {
- if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) {
- renderable = false;
- break;
- }
-
- width -= range.x;
- left += range.x;
- height = width / aspectRatio;
- } else {
- if (range.x <= 0) {
- if (left > minLeft) {
- width -= range.x;
- left += range.x;
- } else if (range.y >= 0 && bottom >= maxHeight) {
- renderable = false;
- }
- } else {
- width -= range.x;
- left += range.x;
- }
-
- if (range.y >= 0) {
- if (bottom < maxHeight) {
- height += range.y;
- }
- } else {
- height += range.y;
- }
- }
-
- if (width < 0 && height < 0) {
- action = ACTION_NORTH_EAST;
- height = 0;
- width = 0;
- } else if (width < 0) {
- action = ACTION_SOUTH_EAST;
- width = 0;
- } else if (height < 0) {
- action = ACTION_NORTH_WEST;
- height = 0;
- }
-
- break;
-
- case ACTION_SOUTH_EAST:
- if (aspectRatio) {
- if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) {
- renderable = false;
- break;
- }
-
- width += range.x;
- height = width / aspectRatio;
- } else {
- if (range.x >= 0) {
- if (right < maxWidth) {
- width += range.x;
- } else if (range.y >= 0 && bottom >= maxHeight) {
- renderable = false;
- }
- } else {
- width += range.x;
- }
-
- if (range.y >= 0) {
- if (bottom < maxHeight) {
- height += range.y;
- }
- } else {
- height += range.y;
- }
- }
-
- if (width < 0 && height < 0) {
- action = ACTION_NORTH_WEST;
- height = 0;
- width = 0;
- } else if (width < 0) {
- action = ACTION_SOUTH_WEST;
- width = 0;
- } else if (height < 0) {
- action = ACTION_NORTH_EAST;
- height = 0;
- }
-
- break;
-
- // Move canvas
- case ACTION_MOVE:
- this.move(range.x, range.y);
- renderable = false;
- break;
-
- // Zoom canvas
- case ACTION_ZOOM:
- this.zoom((function (x1, y1, x2, y2) {
- var z1 = sqrt(x1 * x1 + y1 * y1);
- var z2 = sqrt(x2 * x2 + y2 * y2);
-
- return (z2 - z1) / z1;
- })(
- abs(this.startX - this.startX2),
- abs(this.startY - this.startY2),
- abs(this.endX - this.endX2),
- abs(this.endY - this.endY2)
- ), event);
- this.startX2 = this.endX2;
- this.startY2 = this.endY2;
- renderable = false;
- break;
-
- // Create crop box
- case ACTION_CROP:
- if (!range.x || !range.y) {
- renderable = false;
- break;
- }
-
- offset = this.$cropper.offset();
- left = this.startX - offset.left;
- top = this.startY - offset.top;
- width = cropBox.minWidth;
- height = cropBox.minHeight;
-
- if (range.x > 0) {
- action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST;
- } else if (range.x < 0) {
- left -= width;
- action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST;
- }
-
- if (range.y < 0) {
- top -= height;
- }
-
- // Show the crop box if is hidden
- if (!this.isCropped) {
- this.$cropBox.removeClass(CLASS_HIDDEN);
- this.isCropped = true;
-
- if (this.limited) {
- this.limitCropBox(true, true);
- }
- }
-
- break;
-
- // No default
- }
-
- if (renderable) {
- cropBox.width = width;
- cropBox.height = height;
- cropBox.left = left;
- cropBox.top = top;
- this.action = action;
-
- this.renderCropBox();
- }
-
- // Override
- this.startX = this.endX;
- this.startY = this.endY;
- },
-
- // Show the crop box manually
- crop: function () {
- if (!this.isBuilt || this.isDisabled) {
- return;
- }
-
- if (!this.isCropped) {
- this.isCropped = true;
- this.limitCropBox(true, true);
-
- if (this.options.modal) {
- this.$dragBox.addClass(CLASS_MODAL);
- }
-
- this.$cropBox.removeClass(CLASS_HIDDEN);
- }
-
- this.setCropBoxData(this.initialCropBox);
- },
-
- // Reset the image and crop box to their initial states
- reset: function () {
- if (!this.isBuilt || this.isDisabled) {
- return;
- }
-
- this.image = $.extend({}, this.initialImage);
- this.canvas = $.extend({}, this.initialCanvas);
- this.cropBox = $.extend({}, this.initialCropBox);
-
- this.renderCanvas();
-
- if (this.isCropped) {
- this.renderCropBox();
- }
- },
-
- // Clear the crop box
- clear: function () {
- if (!this.isCropped || this.isDisabled) {
- return;
- }
-
- $.extend(this.cropBox, {
- left: 0,
- top: 0,
- width: 0,
- height: 0
- });
-
- this.isCropped = false;
- this.renderCropBox();
-
- this.limitCanvas(true, true);
-
- // Render canvas after crop box rendered
- this.renderCanvas();
-
- this.$dragBox.removeClass(CLASS_MODAL);
- this.$cropBox.addClass(CLASS_HIDDEN);
- },
-
- /**
- * Replace the image's src and rebuild the cropper
- *
- * @param {String} url
- * @param {Boolean} onlyColorChanged (optional)
- */
- replace: function (url, onlyColorChanged) {
- if (!this.isDisabled && url) {
- if (this.isImg) {
- this.$element.attr('src', url);
- }
-
- if (onlyColorChanged) {
- this.url = url;
- this.$clone.attr('src', url);
-
- if (this.isBuilt) {
- this.$preview.find('img').add(this.$clone2).attr('src', url);
- }
- } else {
- if (this.isImg) {
- this.isReplaced = true;
- }
-
- // Clear previous data
- this.options.data = null;
- this.load(url);
- }
- }
- },
-
- // Enable (unfreeze) the cropper
- enable: function () {
- if (this.isBuilt) {
- this.isDisabled = false;
- this.$cropper.removeClass(CLASS_DISABLED);
- }
- },
-
- // Disable (freeze) the cropper
- disable: function () {
- if (this.isBuilt) {
- this.isDisabled = true;
- this.$cropper.addClass(CLASS_DISABLED);
- }
- },
-
- // Destroy the cropper and remove the instance from the image
- destroy: function () {
- var $this = this.$element;
-
- if (this.isLoaded) {
- if (this.isImg && this.isReplaced) {
- $this.attr('src', this.originalUrl);
- }
-
- this.unbuild();
- $this.removeClass(CLASS_HIDDEN);
- } else {
- if (this.isImg) {
- $this.off(EVENT_LOAD, this.start);
- } else if (this.$clone) {
- this.$clone.remove();
- }
- }
-
- $this.removeData(NAMESPACE);
- },
-
- /**
- * Move the canvas with relative offsets
- *
- * @param {Number} offsetX
- * @param {Number} offsetY (optional)
- */
- move: function (offsetX, offsetY) {
- var canvas = this.canvas;
-
- this.moveTo(
- isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX),
- isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY)
- );
- },
-
- /**
- * Move the canvas to an absolute point
- *
- * @param {Number} x
- * @param {Number} y (optional)
- */
- moveTo: function (x, y) {
- var canvas = this.canvas;
- var isChanged = false;
-
- // If "y" is not present, its default value is "x"
- if (isUndefined(y)) {
- y = x;
- }
-
- x = num(x);
- y = num(y);
-
- if (this.isBuilt && !this.isDisabled && this.options.movable) {
- if (isNumber(x)) {
- canvas.left = x;
- isChanged = true;
- }
-
- if (isNumber(y)) {
- canvas.top = y;
- isChanged = true;
- }
-
- if (isChanged) {
- this.renderCanvas(true);
- }
- }
- },
-
- /**
- * Zoom the canvas with a relative ratio
- *
- * @param {Number} ratio
- * @param {jQuery Event} _event (private)
- */
- zoom: function (ratio, _event) {
- var canvas = this.canvas;
-
- ratio = num(ratio);
-
- if (ratio < 0) {
- ratio = 1 / (1 - ratio);
- } else {
- ratio = 1 + ratio;
- }
-
- this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event);
- },
-
- /**
- * Zoom the canvas to an absolute ratio
- *
- * @param {Number} ratio
- * @param {jQuery Event} _event (private)
- */
- zoomTo: function (ratio, _event) {
- var options = this.options;
- var canvas = this.canvas;
- var width = canvas.width;
- var height = canvas.height;
- var naturalWidth = canvas.naturalWidth;
- var naturalHeight = canvas.naturalHeight;
- var originalEvent;
- var newWidth;
- var newHeight;
- var offset;
- var center;
-
- ratio = num(ratio);
-
- if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) {
- newWidth = naturalWidth * ratio;
- newHeight = naturalHeight * ratio;
-
- if (_event) {
- originalEvent = _event.originalEvent;
- }
-
- if (this.trigger(EVENT_ZOOM, {
- originalEvent: originalEvent,
- oldRatio: width / naturalWidth,
- ratio: newWidth / naturalWidth
- }).isDefaultPrevented()) {
- return;
- }
-
- if (originalEvent) {
- offset = this.$cropper.offset();
- center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : {
- pageX: _event.pageX || originalEvent.pageX || 0,
- pageY: _event.pageY || originalEvent.pageY || 0
- };
-
- // Zoom from the triggering point of the event
- canvas.left -= (newWidth - width) * (
- ((center.pageX - offset.left) - canvas.left) / width
- );
- canvas.top -= (newHeight - height) * (
- ((center.pageY - offset.top) - canvas.top) / height
- );
- } else {
-
- // Zoom from the center of the canvas
- canvas.left -= (newWidth - width) / 2;
- canvas.top -= (newHeight - height) / 2;
- }
-
- canvas.width = newWidth;
- canvas.height = newHeight;
- this.renderCanvas(true);
- }
- },
-
- /**
- * Rotate the canvas with a relative degree
- *
- * @param {Number} degree
- */
- rotate: function (degree) {
- this.rotateTo((this.image.rotate || 0) + num(degree));
- },
-
- /**
- * Rotate the canvas to an absolute degree
- * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate()
- *
- * @param {Number} degree
- */
- rotateTo: function (degree) {
- degree = num(degree);
-
- if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) {
- this.image.rotate = degree % 360;
- this.isRotated = true;
- this.renderCanvas(true);
- }
- },
-
- /**
- * Scale the image
- * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale()
- *
- * @param {Number} scaleX
- * @param {Number} scaleY (optional)
- */
- scale: function (scaleX, scaleY) {
- var image = this.image;
- var isChanged = false;
-
- // If "scaleY" is not present, its default value is "scaleX"
- if (isUndefined(scaleY)) {
- scaleY = scaleX;
- }
-
- scaleX = num(scaleX);
- scaleY = num(scaleY);
-
- if (this.isBuilt && !this.isDisabled && this.options.scalable) {
- if (isNumber(scaleX)) {
- image.scaleX = scaleX;
- isChanged = true;
- }
-
- if (isNumber(scaleY)) {
- image.scaleY = scaleY;
- isChanged = true;
- }
-
- if (isChanged) {
- this.renderImage(true);
- }
- }
- },
-
- /**
- * Scale the abscissa of the image
- *
- * @param {Number} scaleX
- */
- scaleX: function (scaleX) {
- var scaleY = this.image.scaleY;
-
- this.scale(scaleX, isNumber(scaleY) ? scaleY : 1);
- },
-
- /**
- * Scale the ordinate of the image
- *
- * @param {Number} scaleY
- */
- scaleY: function (scaleY) {
- var scaleX = this.image.scaleX;
-
- this.scale(isNumber(scaleX) ? scaleX : 1, scaleY);
- },
-
- /**
- * Get the cropped area position and size data (base on the original image)
- *
- * @param {Boolean} isRounded (optional)
- * @return {Object} data
- */
- getData: function (isRounded) {
- var options = this.options;
- var image = this.image;
- var canvas = this.canvas;
- var cropBox = this.cropBox;
- var ratio;
- var data;
-
- if (this.isBuilt && this.isCropped) {
- data = {
- x: cropBox.left - canvas.left,
- y: cropBox.top - canvas.top,
- width: cropBox.width,
- height: cropBox.height
- };
-
- ratio = image.width / image.naturalWidth;
-
- $.each(data, function (i, n) {
- n = n / ratio;
- data[i] = isRounded ? round(n) : n;
- });
-
- } else {
- data = {
- x: 0,
- y: 0,
- width: 0,
- height: 0
- };
- }
-
- if (options.rotatable) {
- data.rotate = image.rotate || 0;
- }
-
- if (options.scalable) {
- data.scaleX = image.scaleX || 1;
- data.scaleY = image.scaleY || 1;
- }
-
- return data;
- },
-
- /**
- * Set the cropped area position and size with new data
- *
- * @param {Object} data
- */
- setData: function (data) {
- var options = this.options;
- var image = this.image;
- var canvas = this.canvas;
- var cropBoxData = {};
- var isRotated;
- var isScaled;
- var ratio;
-
- if ($.isFunction(data)) {
- data = data.call(this.element);
- }
-
- if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) {
- if (options.rotatable) {
- if (isNumber(data.rotate) && data.rotate !== image.rotate) {
- image.rotate = data.rotate;
- this.isRotated = isRotated = true;
- }
- }
-
- if (options.scalable) {
- if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) {
- image.scaleX = data.scaleX;
- isScaled = true;
- }
-
- if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) {
- image.scaleY = data.scaleY;
- isScaled = true;
- }
- }
-
- if (isRotated) {
- this.renderCanvas();
- } else if (isScaled) {
- this.renderImage();
- }
-
- ratio = image.width / image.naturalWidth;
-
- if (isNumber(data.x)) {
- cropBoxData.left = data.x * ratio + canvas.left;
- }
-
- if (isNumber(data.y)) {
- cropBoxData.top = data.y * ratio + canvas.top;
- }
-
- if (isNumber(data.width)) {
- cropBoxData.width = data.width * ratio;
- }
-
- if (isNumber(data.height)) {
- cropBoxData.height = data.height * ratio;
- }
-
- this.setCropBoxData(cropBoxData);
- }
- },
-
- /**
- * Get the container size data
- *
- * @return {Object} data
- */
- getContainerData: function () {
- return this.isBuilt ? this.container : {};
- },
-
- /**
- * Get the image position and size data
- *
- * @return {Object} data
- */
- getImageData: function () {
- return this.isLoaded ? this.image : {};
- },
-
- /**
- * Get the canvas position and size data
- *
- * @return {Object} data
- */
- getCanvasData: function () {
- var canvas = this.canvas;
- var data = {};
-
- if (this.isBuilt) {
- $.each([
- 'left',
- 'top',
- 'width',
- 'height',
- 'naturalWidth',
- 'naturalHeight'
- ], function (i, n) {
- data[n] = canvas[n];
- });
- }
-
- return data;
- },
-
- /**
- * Set the canvas position and size with new data
- *
- * @param {Object} data
- */
- setCanvasData: function (data) {
- var canvas = this.canvas;
- var aspectRatio = canvas.aspectRatio;
-
- if ($.isFunction(data)) {
- data = data.call(this.$element);
- }
-
- if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) {
- if (isNumber(data.left)) {
- canvas.left = data.left;
- }
-
- if (isNumber(data.top)) {
- canvas.top = data.top;
- }
-
- if (isNumber(data.width)) {
- canvas.width = data.width;
- canvas.height = data.width / aspectRatio;
- } else if (isNumber(data.height)) {
- canvas.height = data.height;
- canvas.width = data.height * aspectRatio;
- }
-
- this.renderCanvas(true);
- }
- },
-
- /**
- * Get the crop box position and size data
- *
- * @return {Object} data
- */
- getCropBoxData: function () {
- var cropBox = this.cropBox;
- var data;
-
- if (this.isBuilt && this.isCropped) {
- data = {
- left: cropBox.left,
- top: cropBox.top,
- width: cropBox.width,
- height: cropBox.height
- };
- }
-
- return data || {};
- },
-
- /**
- * Set the crop box position and size with new data
- *
- * @param {Object} data
- */
- setCropBoxData: function (data) {
- var cropBox = this.cropBox;
- var aspectRatio = this.options.aspectRatio;
- var isWidthChanged;
- var isHeightChanged;
-
- if ($.isFunction(data)) {
- data = data.call(this.$element);
- }
-
- if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) {
-
- if (isNumber(data.left)) {
- cropBox.left = data.left;
- }
-
- if (isNumber(data.top)) {
- cropBox.top = data.top;
- }
-
- if (isNumber(data.width)) {
- isWidthChanged = true;
- cropBox.width = data.width;
- }
-
- if (isNumber(data.height)) {
- isHeightChanged = true;
- cropBox.height = data.height;
- }
-
- if (aspectRatio) {
- if (isWidthChanged) {
- cropBox.height = cropBox.width / aspectRatio;
- } else if (isHeightChanged) {
- cropBox.width = cropBox.height * aspectRatio;
- }
- }
-
- this.renderCropBox();
- }
- },
-
- /**
- * Get a canvas drawn the cropped image
- *
- * @param {Object} options (optional)
- * @return {HTMLCanvasElement} canvas
- */
- getCroppedCanvas: function (options) {
- var originalWidth;
- var originalHeight;
- var canvasWidth;
- var canvasHeight;
- var scaledWidth;
- var scaledHeight;
- var scaledRatio;
- var aspectRatio;
- var canvas;
- var context;
- var data;
-
- if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) {
- return;
- }
-
- if (!$.isPlainObject(options)) {
- options = {};
- }
-
- data = this.getData();
- originalWidth = data.width;
- originalHeight = data.height;
- aspectRatio = originalWidth / originalHeight;
-
- if ($.isPlainObject(options)) {
- scaledWidth = options.width;
- scaledHeight = options.height;
-
- if (scaledWidth) {
- scaledHeight = scaledWidth / aspectRatio;
- scaledRatio = scaledWidth / originalWidth;
- } else if (scaledHeight) {
- scaledWidth = scaledHeight * aspectRatio;
- scaledRatio = scaledHeight / originalHeight;
- }
- }
-
- // The canvas element will use `Math.floor` on a float number, so floor first
- canvasWidth = floor(scaledWidth || originalWidth);
- canvasHeight = floor(scaledHeight || originalHeight);
-
- canvas = $('<canvas>')[0];
- canvas.width = canvasWidth;
- canvas.height = canvasHeight;
- context = canvas.getContext('2d');
-
- if (options.fillColor) {
- context.fillStyle = options.fillColor;
- context.fillRect(0, 0, canvasWidth, canvasHeight);
- }
-
- // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage
- context.drawImage.apply(context, (function () {
- var source = getSourceCanvas(this.$clone[0], this.image);
- var sourceWidth = source.width;
- var sourceHeight = source.height;
- var canvas = this.canvas;
- var params = [source];
-
- // Source canvas
- var srcX = data.x + canvas.naturalWidth * (abs(data.scaleX || 1) - 1) / 2;
- var srcY = data.y + canvas.naturalHeight * (abs(data.scaleY || 1) - 1) / 2;
- var srcWidth;
- var srcHeight;
-
- // Destination canvas
- var dstX;
- var dstY;
- var dstWidth;
- var dstHeight;
-
- if (srcX <= -originalWidth || srcX > sourceWidth) {
- srcX = srcWidth = dstX = dstWidth = 0;
- } else if (srcX <= 0) {
- dstX = -srcX;
- srcX = 0;
- srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX);
- } else if (srcX <= sourceWidth) {
- dstX = 0;
- srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX);
- }
-
- if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) {
- srcY = srcHeight = dstY = dstHeight = 0;
- } else if (srcY <= 0) {
- dstY = -srcY;
- srcY = 0;
- srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY);
- } else if (srcY <= sourceHeight) {
- dstY = 0;
- srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY);
- }
-
- // All the numerical parameters should be integer for `drawImage` (#476)
- params.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight));
-
- // Scale destination sizes
- if (scaledRatio) {
- dstX *= scaledRatio;
- dstY *= scaledRatio;
- dstWidth *= scaledRatio;
- dstHeight *= scaledRatio;
- }
-
- // Avoid "IndexSizeError" in IE and Firefox
- if (dstWidth > 0 && dstHeight > 0) {
- params.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight));
- }
-
- return params;
- }).call(this));
-
- return canvas;
- },
-
- /**
- * Change the aspect ratio of the crop box
- *
- * @param {Number} aspectRatio
- */
- setAspectRatio: function (aspectRatio) {
- var options = this.options;
-
- if (!this.isDisabled && !isUndefined(aspectRatio)) {
-
- // 0 -> NaN
- options.aspectRatio = max(0, aspectRatio) || NaN;
-
- if (this.isBuilt) {
- this.initCropBox();
-
- if (this.isCropped) {
- this.renderCropBox();
- }
- }
- }
- },
-
- /**
- * Change the drag mode
- *
- * @param {String} mode (optional)
- */
- setDragMode: function (mode) {
- var options = this.options;
- var croppable;
- var movable;
-
- if (this.isLoaded && !this.isDisabled) {
- croppable = mode === ACTION_CROP;
- movable = options.movable && mode === ACTION_MOVE;
- mode = (croppable || movable) ? mode : ACTION_NONE;
-
- this.$dragBox.
- data(DATA_ACTION, mode).
- toggleClass(CLASS_CROP, croppable).
- toggleClass(CLASS_MOVE, movable);
-
- if (!options.cropBoxMovable) {
-
- // Sync drag mode to crop box when it is not movable(#300)
- this.$face.
- data(DATA_ACTION, mode).
- toggleClass(CLASS_CROP, croppable).
- toggleClass(CLASS_MOVE, movable);
- }
- }
- }
- };
-
- Cropper.DEFAULTS = {
-
- // Define the view mode of the cropper
- viewMode: 0, // 0, 1, 2, 3
-
- // Define the dragging mode of the cropper
- dragMode: 'crop', // 'crop', 'move' or 'none'
-
- // Define the aspect ratio of the crop box
- aspectRatio: NaN,
-
- // An object with the previous cropping result data
- data: null,
-
- // A jQuery selector for adding extra containers to preview
- preview: '',
-
- // Re-render the cropper when resize the window
- responsive: true,
-
- // Restore the cropped area after resize the window
- restore: true,
-
- // Check if the current image is a cross-origin image
- checkCrossOrigin: true,
-
- // Check the current image's Exif Orientation information
- checkOrientation: true,
-
- // Show the black modal
- modal: true,
-
- // Show the dashed lines for guiding
- guides: true,
-
- // Show the center indicator for guiding
- center: true,
-
- // Show the white modal to highlight the crop box
- highlight: true,
-
- // Show the grid background
- background: true,
-
- // Enable to crop the image automatically when initialize
- autoCrop: true,
-
- // Define the percentage of automatic cropping area when initializes
- autoCropArea: 0.8,
-
- // Enable to move the image
- movable: true,
-
- // Enable to rotate the image
- rotatable: true,
-
- // Enable to scale the image
- scalable: true,
-
- // Enable to zoom the image
- zoomable: true,
-
- // Enable to zoom the image by dragging touch
- zoomOnTouch: true,
-
- // Enable to zoom the image by wheeling mouse
- zoomOnWheel: true,
-
- // Define zoom ratio when zoom the image by wheeling mouse
- wheelZoomRatio: 0.1,
-
- // Enable to move the crop box
- cropBoxMovable: true,
-
- // Enable to resize the crop box
- cropBoxResizable: true,
-
- // Toggle drag mode between "crop" and "move" when click twice on the cropper
- toggleDragModeOnDblclick: true,
-
- // Size limitation
- minCanvasWidth: 0,
- minCanvasHeight: 0,
- minCropBoxWidth: 0,
- minCropBoxHeight: 0,
- minContainerWidth: 200,
- minContainerHeight: 100,
-
- // Shortcuts of events
- build: null,
- built: null,
- cropstart: null,
- cropmove: null,
- cropend: null,
- crop: null,
- zoom: null
- };
-
- Cropper.setDefaults = function (options) {
- $.extend(Cropper.DEFAULTS, options);
- };
-
- Cropper.TEMPLATE = (
- '<div class="cropper-container">' +
- '<div class="cropper-wrap-box">' +
- '<div class="cropper-canvas"></div>' +
- '</div>' +
- '<div class="cropper-drag-box"></div>' +
- '<div class="cropper-crop-box">' +
- '<span class="cropper-view-box"></span>' +
- '<span class="cropper-dashed dashed-h"></span>' +
- '<span class="cropper-dashed dashed-v"></span>' +
- '<span class="cropper-center"></span>' +
- '<span class="cropper-face"></span>' +
- '<span class="cropper-line line-e" data-action="e"></span>' +
- '<span class="cropper-line line-n" data-action="n"></span>' +
- '<span class="cropper-line line-w" data-action="w"></span>' +
- '<span class="cropper-line line-s" data-action="s"></span>' +
- '<span class="cropper-point point-e" data-action="e"></span>' +
- '<span class="cropper-point point-n" data-action="n"></span>' +
- '<span class="cropper-point point-w" data-action="w"></span>' +
- '<span class="cropper-point point-s" data-action="s"></span>' +
- '<span class="cropper-point point-ne" data-action="ne"></span>' +
- '<span class="cropper-point point-nw" data-action="nw"></span>' +
- '<span class="cropper-point point-sw" data-action="sw"></span>' +
- '<span class="cropper-point point-se" data-action="se"></span>' +
- '</div>' +
- '</div>'
- );
-
- // Save the other cropper
- Cropper.other = $.fn.cropper;
-
- // Register as jQuery plugin
- $.fn.cropper = function (option) {
- var args = toArray(arguments, 1);
- var result;
-
- this.each(function () {
- var $this = $(this);
- var data = $this.data(NAMESPACE);
- var options;
- var fn;
-
- if (!data) {
- if (/destroy/.test(option)) {
- return;
- }
-
- options = $.extend({}, $this.data(), $.isPlainObject(option) && option);
- $this.data(NAMESPACE, (data = new Cropper(this, options)));
- }
-
- if (typeof option === 'string' && $.isFunction(fn = data[option])) {
- result = fn.apply(data, args);
- }
- });
-
- return isUndefined(result) ? this : result;
- };
-
- $.fn.cropper.Constructor = Cropper;
- $.fn.cropper.setDefaults = Cropper.setDefaults;
-
- // No conflict
- $.fn.cropper.noConflict = function () {
- $.fn.cropper = Cropper.other;
- return this;
- };
-
-});
diff --git a/vendor/assets/javascripts/jquery.nicescroll.js b/vendor/assets/javascripts/jquery.nicescroll.js
deleted file mode 100644
index 7653f25df4b..00000000000
--- a/vendor/assets/javascripts/jquery.nicescroll.js
+++ /dev/null
@@ -1,3634 +0,0 @@
-/* jquery.nicescroll
--- version 3.6.0
--- copyright 2014-11-21 InuYaksa*2014
--- licensed under the MIT
---
--- http://nicescroll.areaaperta.com/
--- https://github.com/inuyaksa/jquery.nicescroll
---
-*/
-
-(function(factory) {
- if (typeof define === 'function' && define.amd) {
- // AMD. Register as anonymous module.
- define(['jquery'], factory);
- } else {
- // Browser globals.
- factory(jQuery);
- }
-}(function(jQuery) {
- "use strict";
-
- // globals
- var domfocus = false;
- var mousefocus = false;
- var tabindexcounter = 0;
- var ascrailcounter = 2000;
- var globalmaxzindex = 0;
-
- var $ = jQuery; // sandbox
-
- // http://stackoverflow.com/questions/2161159/get-script-path
- function getScriptPath() {
- var scripts = document.getElementsByTagName('script');
- var path = scripts[scripts.length - 1].src.split('?')[0];
- return (path.split('/').length > 0) ? path.split('/').slice(0, -1).join('/') + '/' : '';
- }
-
- var vendors = ['webkit','ms','moz','o'];
-
- var setAnimationFrame = window.requestAnimationFrame || false;
- var clearAnimationFrame = window.cancelAnimationFrame || false;
-
- if (!setAnimationFrame) { // legacy detection
- for (var vx in vendors) {
- var v = vendors[vx];
- if (!setAnimationFrame) setAnimationFrame = window[v + 'RequestAnimationFrame'];
- if (!clearAnimationFrame) clearAnimationFrame = window[v + 'CancelAnimationFrame'] || window[v + 'CancelRequestAnimationFrame'];
- }
- }
-
- var ClsMutationObserver = window.MutationObserver || window.WebKitMutationObserver || false;
-
- var _globaloptions = {
- zindex: "auto",
- cursoropacitymin: 0,
- cursoropacitymax: 1,
- cursorcolor: "#424242",
- cursorwidth: "5px",
- cursorborder: "1px solid #fff",
- cursorborderradius: "5px",
- scrollspeed: 60,
- mousescrollstep: 8 * 3,
- touchbehavior: false,
- hwacceleration: true,
- usetransition: true,
- boxzoom: false,
- dblclickzoom: true,
- gesturezoom: true,
- grabcursorenabled: true,
- autohidemode: true,
- background: "",
- iframeautoresize: true,
- cursorminheight: 32,
- preservenativescrolling: true,
- railoffset: false,
- railhoffset: false,
- bouncescroll: true,
- spacebarenabled: true,
- railpadding: {
- top: 0,
- right: 0,
- left: 0,
- bottom: 0
- },
- disableoutline: true,
- horizrailenabled: true,
- railalign: "right",
- railvalign: "bottom",
- enabletranslate3d: true,
- enablemousewheel: true,
- enablekeyboard: true,
- smoothscroll: true,
- sensitiverail: true,
- enablemouselockapi: true,
- // cursormaxheight:false,
- cursorfixedheight: false,
- directionlockdeadzone: 6,
- hidecursordelay: 400,
- nativeparentscrolling: true,
- enablescrollonselection: true,
- overflowx: true,
- overflowy: true,
- cursordragspeed: 0.3,
- rtlmode: "auto",
- cursordragontouch: false,
- oneaxismousemode: "auto",
- scriptpath: getScriptPath(),
- preventmultitouchscrolling: true
- };
-
- var browserdetected = false;
-
- var getBrowserDetection = function() {
-
- if (browserdetected) return browserdetected;
-
- var _el = document.createElement('DIV'),
- _style = _el.style,
- _agent = navigator.userAgent,
- _platform = navigator.platform,
- d = {};
-
- d.haspointerlock = "pointerLockElement" in document || "webkitPointerLockElement" in document || "mozPointerLockElement" in document;
-
- d.isopera = ("opera" in window); // 12-
- d.isopera12 = (d.isopera && ("getUserMedia" in navigator));
- d.isoperamini = (Object.prototype.toString.call(window.operamini) === "[object OperaMini]");
-
- d.isie = (("all" in document) && ("attachEvent" in _el) && !d.isopera); //IE10-
- d.isieold = (d.isie && !("msInterpolationMode" in _style)); // IE6 and older
- d.isie7 = d.isie && !d.isieold && (!("documentMode" in document) || (document.documentMode == 7));
- d.isie8 = d.isie && ("documentMode" in document) && (document.documentMode == 8);
- d.isie9 = d.isie && ("performance" in window) && (document.documentMode >= 9);
- d.isie10 = d.isie && ("performance" in window) && (document.documentMode == 10);
- d.isie11 = ("msRequestFullscreen" in _el) && (document.documentMode >= 11); // IE11+
-
- d.isie9mobile = /iemobile.9/i.test(_agent); //wp 7.1 mango
- if (d.isie9mobile) d.isie9 = false;
- d.isie7mobile = (!d.isie9mobile && d.isie7) && /iemobile/i.test(_agent); //wp 7.0
-
- d.ismozilla = ("MozAppearance" in _style);
-
- d.iswebkit = ("WebkitAppearance" in _style);
-
- d.ischrome = ("chrome" in window);
- d.ischrome22 = (d.ischrome && d.haspointerlock);
- d.ischrome26 = (d.ischrome && ("transition" in _style)); // issue with transform detection (maintain prefix)
-
- d.cantouch = ("ontouchstart" in document.documentElement) || ("ontouchstart" in window); // detection for Chrome Touch Emulation
- d.hasmstouch = (window.MSPointerEvent || false); // IE10 pointer events
- d.hasw3ctouch = (window.PointerEvent || false); //IE11 pointer events, following W3C Pointer Events spec
-
- d.ismac = /^mac$/i.test(_platform);
-
- d.isios = (d.cantouch && /iphone|ipad|ipod/i.test(_platform));
- d.isios4 = ((d.isios) && !("seal" in Object));
- d.isios7 = ((d.isios)&&("webkitHidden" in document)); //iOS 7+
-
- d.isandroid = (/android/i.test(_agent));
-
- d.haseventlistener = ("addEventListener" in _el);
-
- d.trstyle = false;
- d.hastransform = false;
- d.hastranslate3d = false;
- d.transitionstyle = false;
- d.hastransition = false;
- d.transitionend = false;
-
- var a;
- var check = ['transform', 'msTransform', 'webkitTransform', 'MozTransform', 'OTransform'];
- for (a = 0; a < check.length; a++) {
- if (typeof _style[check[a]] != "undefined") {
- d.trstyle = check[a];
- break;
- }
- }
- d.hastransform = (!!d.trstyle);
- if (d.hastransform) {
- _style[d.trstyle] = "translate3d(1px,2px,3px)";
- d.hastranslate3d = /translate3d/.test(_style[d.trstyle]);
- }
-
- d.transitionstyle = false;
- d.prefixstyle = '';
- d.transitionend = false;
- check = ['transition', 'webkitTransition', 'msTransition', 'MozTransition', 'OTransition', 'OTransition', 'KhtmlTransition'];
- var prefix = ['', '-webkit-', '-ms-', '-moz-', '-o-', '-o', '-khtml-'];
- var evs = ['transitionend', 'webkitTransitionEnd', 'msTransitionEnd', 'transitionend', 'otransitionend', 'oTransitionEnd', 'KhtmlTransitionEnd'];
- for (a = 0; a < check.length; a++) {
- if (check[a] in _style) {
- d.transitionstyle = check[a];
- d.prefixstyle = prefix[a];
- d.transitionend = evs[a];
- break;
- }
- }
- if (d.ischrome26) { // always use prefix
- d.prefixstyle = prefix[1];
- }
-
- d.hastransition = (d.transitionstyle);
-
- function detectCursorGrab() {
- var lst = ['-webkit-grab', '-moz-grab', 'grab'];
- if ((d.ischrome && !d.ischrome22) || d.isie) lst = []; // force setting for IE returns false positive and chrome cursor bug
- for (var a = 0; a < lst.length; a++) {
- var p = lst[a];
- _style.cursor = p;
- if (_style.cursor == p) return p;
- }
- return 'url(//mail.google.com/mail/images/2/openhand.cur),n-resize'; // thank you google for custom cursor!
- }
- d.cursorgrabvalue = detectCursorGrab();
-
- d.hasmousecapture = ("setCapture" in _el);
-
- d.hasMutationObserver = (ClsMutationObserver !== false);
-
- _el = null; //memory released
-
- browserdetected = d;
-
- return d;
- };
-
- var NiceScrollClass = function(myopt, me) {
-
- var self = this;
-
- this.version = '3.6.0';
- this.name = 'nicescroll';
-
- this.me = me;
-
- this.opt = {
- doc: $("body"),
- win: false
- };
-
- $.extend(this.opt, _globaloptions); // clone opts
-
- // Options for internal use
- this.opt.snapbackspeed = 80;
-
- if (myopt || false) {
- for (var a in self.opt) {
- if (typeof myopt[a] != "undefined") self.opt[a] = myopt[a];
- }
- }
-
- this.doc = self.opt.doc;
- this.iddoc = (this.doc && this.doc[0]) ? this.doc[0].id || '' : '';
- this.ispage = /^BODY|HTML/.test((self.opt.win) ? self.opt.win[0].nodeName : this.doc[0].nodeName);
- this.haswrapper = (self.opt.win !== false);
- this.win = self.opt.win || (this.ispage ? $(window) : this.doc);
- this.docscroll = (this.ispage && !this.haswrapper) ? $(window) : this.win;
- this.body = $("body");
- this.viewport = false;
-
- this.isfixed = false;
-
- this.iframe = false;
- this.isiframe = ((this.doc[0].nodeName == 'IFRAME') && (this.win[0].nodeName == 'IFRAME'));
-
- this.istextarea = (this.win[0].nodeName == 'TEXTAREA');
-
- this.forcescreen = false; //force to use screen position on events
-
- this.canshowonmouseevent = (self.opt.autohidemode != "scroll");
-
- // Events jump table
- this.onmousedown = false;
- this.onmouseup = false;
- this.onmousemove = false;
- this.onmousewheel = false;
- this.onkeypress = false;
- this.ongesturezoom = false;
- this.onclick = false;
-
- // Nicescroll custom events
- this.onscrollstart = false;
- this.onscrollend = false;
- this.onscrollcancel = false;
-
- this.onzoomin = false;
- this.onzoomout = false;
-
- // Let's start!
- this.view = false;
- this.page = false;
-
- this.scroll = {
- x: 0,
- y: 0
- };
- this.scrollratio = {
- x: 0,
- y: 0
- };
- this.cursorheight = 20;
- this.scrollvaluemax = 0;
-
- this.isrtlmode = (this.opt.rtlmode == "auto") ? ((this.win[0] == window ? this.body : this.win).css("direction") == "rtl") : (this.opt.rtlmode === true);
- // this.checkrtlmode = false;
-
- this.scrollrunning = false;
-
- this.scrollmom = false;
-
- this.observer = false; // observer div changes
- this.observerremover = false; // observer on parent for remove detection
- this.observerbody = false; // observer on body for position change
-
- do {
- this.id = "ascrail" + (ascrailcounter++);
- } while (document.getElementById(this.id));
-
- this.rail = false;
- this.cursor = false;
- this.cursorfreezed = false;
- this.selectiondrag = false;
-
- this.zoom = false;
- this.zoomactive = false;
-
- this.hasfocus = false;
- this.hasmousefocus = false;
-
- this.visibility = true;
- this.railslocked = false; // locked by resize
- this.locked = false; // prevent lost of locked status sets by user
- this.hidden = false; // rails always hidden
- this.cursoractive = true; // user can interact with cursors
-
- this.wheelprevented = false; //prevent mousewheel event
-
- this.overflowx = self.opt.overflowx;
- this.overflowy = self.opt.overflowy;
-
- this.nativescrollingarea = false;
- this.checkarea = 0;
-
- this.events = []; // event list for unbind
-
- this.saved = {}; // style saved
-
- this.delaylist = {};
- this.synclist = {};
-
- this.lastdeltax = 0;
- this.lastdeltay = 0;
-
- this.detected = getBrowserDetection();
-
- var cap = $.extend({}, this.detected);
-
- this.canhwscroll = (cap.hastransform && self.opt.hwacceleration);
- this.ishwscroll = (this.canhwscroll && self.haswrapper);
-
- this.hasreversehr = (this.isrtlmode&&!cap.iswebkit); //RTL mode with reverse horizontal axis
-
- this.istouchcapable = false; // desktop devices with touch screen support
-
- //## Check WebKit-based desktop with touch support
- //## + Firefox 18 nightly build (desktop) false positive (or desktop with touch support)
- if (cap.cantouch && !cap.isios && !cap.isandroid && (cap.iswebkit || cap.ismozilla)) {
- this.istouchcapable = true;
- cap.cantouch = false; // parse normal desktop events
- }
-
- //## disable MouseLock API on user request
- if (!self.opt.enablemouselockapi) {
- cap.hasmousecapture = false;
- cap.haspointerlock = false;
- }
-
-/* deprecated
- this.delayed = function(name, fn, tm, lazy) {
- };
-*/
-
- this.debounced = function(name, fn, tm) {
- var dd = self.delaylist[name];
- self.delaylist[name] = fn;
- if (!dd) {
- setTimeout(function() {
- var fn = self.delaylist[name];
- self.delaylist[name] = false;
- fn.call(self);
- }, tm);
- }
- };
-
- var _onsync = false;
-
- this.synched = function(name, fn) {
-
- function requestSync() {
- if (_onsync) return;
- setAnimationFrame(function() {
- _onsync = false;
- for (var nn in self.synclist) {
- var fn = self.synclist[nn];
- if (fn) fn.call(self);
- self.synclist[nn] = false;
- }
- });
- _onsync = true;
- }
-
- self.synclist[name] = fn;
- requestSync();
- return name;
- };
-
- this.unsynched = function(name) {
- if (self.synclist[name]) self.synclist[name] = false;
- };
-
- this.css = function(el, pars) { // save & set
- for (var n in pars) {
- self.saved.css.push([el, n, el.css(n)]);
- el.css(n, pars[n]);
- }
- };
-
- this.scrollTop = function(val) {
- return (typeof val == "undefined") ? self.getScrollTop() : self.setScrollTop(val);
- };
-
- this.scrollLeft = function(val) {
- return (typeof val == "undefined") ? self.getScrollLeft() : self.setScrollLeft(val);
- };
-
- // derived by by Dan Pupius www.pupius.net
- var BezierClass = function(st, ed, spd, p1, p2, p3, p4) {
-
- this.st = st;
- this.ed = ed;
- this.spd = spd;
-
- this.p1 = p1 || 0;
- this.p2 = p2 || 1;
- this.p3 = p3 || 0;
- this.p4 = p4 || 1;
-
- this.ts = (new Date()).getTime();
- this.df = this.ed - this.st;
- };
- BezierClass.prototype = {
- B2: function(t) {
- return 3 * t * t * (1 - t);
- },
- B3: function(t) {
- return 3 * t * (1 - t) * (1 - t);
- },
- B4: function(t) {
- return (1 - t) * (1 - t) * (1 - t);
- },
- getNow: function() {
- var nw = (new Date()).getTime();
- var pc = 1 - ((nw - this.ts) / this.spd);
- var bz = this.B2(pc) + this.B3(pc) + this.B4(pc);
- return (pc < 0) ? this.ed : this.st + Math.round(this.df * bz);
- },
- update: function(ed, spd) {
- this.st = this.getNow();
- this.ed = ed;
- this.spd = spd;
- this.ts = (new Date()).getTime();
- this.df = this.ed - this.st;
- return this;
- }
- };
-
- //derived from http://stackoverflow.com/questions/11236090/
- function getMatrixValues() {
- var tr = self.doc.css(cap.trstyle);
- if (tr && (tr.substr(0, 6) == "matrix")) {
- return tr.replace(/^.*\((.*)\)$/g, "$1").replace(/px/g, '').split(/, +/);
- }
- return false;
- }
-
- if (this.ishwscroll) {
- // hw accelerated scroll
- this.doc.translate = {
- x: 0,
- y: 0,
- tx: "0px",
- ty: "0px"
- };
-
- //this one can help to enable hw accel on ios6 http://indiegamr.com/ios6-html-hardware-acceleration-changes-and-how-to-fix-them/
- if (cap.hastranslate3d && cap.isios) this.doc.css("-webkit-backface-visibility", "hidden"); // prevent flickering http://stackoverflow.com/questions/3461441/
-
- this.getScrollTop = function(last) {
- if (!last) {
- var mtx = getMatrixValues();
- if (mtx) return (mtx.length == 16) ? -mtx[13] : -mtx[5]; //matrix3d 16 on IE10
- if (self.timerscroll && self.timerscroll.bz) return self.timerscroll.bz.getNow();
- }
- return self.doc.translate.y;
- };
-
- this.getScrollLeft = function(last) {
- if (!last) {
- var mtx = getMatrixValues();
- if (mtx) return (mtx.length == 16) ? -mtx[12] : -mtx[4]; //matrix3d 16 on IE10
- if (self.timerscroll && self.timerscroll.bh) return self.timerscroll.bh.getNow();
- }
- return self.doc.translate.x;
- };
-
- this.notifyScrollEvent = function(el) {
- var e = document.createEvent("UIEvents");
- e.initUIEvent("scroll", false, true, window, 1);
- e.niceevent = true;
- el.dispatchEvent(e);
- };
-
- var cxscrollleft = (this.isrtlmode) ? 1 : -1;
-
- if (cap.hastranslate3d && self.opt.enabletranslate3d) {
- this.setScrollTop = function(val, silent) {
- self.doc.translate.y = val;
- self.doc.translate.ty = (val * -1) + "px";
- self.doc.css(cap.trstyle, "translate3d(" + self.doc.translate.tx + "," + self.doc.translate.ty + ",0px)");
- if (!silent) self.notifyScrollEvent(self.win[0]);
- };
- this.setScrollLeft = function(val, silent) {
- self.doc.translate.x = val;
- self.doc.translate.tx = (val * cxscrollleft) + "px";
- self.doc.css(cap.trstyle, "translate3d(" + self.doc.translate.tx + "," + self.doc.translate.ty + ",0px)");
- if (!silent) self.notifyScrollEvent(self.win[0]);
- };
- } else {
- this.setScrollTop = function(val, silent) {
- self.doc.translate.y = val;
- self.doc.translate.ty = (val * -1) + "px";
- self.doc.css(cap.trstyle, "translate(" + self.doc.translate.tx + "," + self.doc.translate.ty + ")");
- if (!silent) self.notifyScrollEvent(self.win[0]);
- };
- this.setScrollLeft = function(val, silent) {
- self.doc.translate.x = val;
- self.doc.translate.tx = (val * cxscrollleft) + "px";
- self.doc.css(cap.trstyle, "translate(" + self.doc.translate.tx + "," + self.doc.translate.ty + ")");
- if (!silent) self.notifyScrollEvent(self.win[0]);
- };
- }
- } else {
- // native scroll
- this.getScrollTop = function() {
- return self.docscroll.scrollTop();
- };
- this.setScrollTop = function(val) {
- return self.docscroll.scrollTop(val);
- };
- this.getScrollLeft = function() {
- if (self.detected.ismozilla && self.isrtlmode)
- return Math.abs(self.docscroll.scrollLeft());
- return self.docscroll.scrollLeft();
- };
- this.setScrollLeft = function(val) {
- return self.docscroll.scrollLeft((self.detected.ismozilla && self.isrtlmode) ? -val : val);
- };
- }
-
- this.getTarget = function(e) {
- if (!e) return false;
- if (e.target) return e.target;
- if (e.srcElement) return e.srcElement;
- return false;
- };
-
- this.hasParent = function(e, id) {
- if (!e) return false;
- var el = e.target || e.srcElement || e || false;
- while (el && el.id != id) {
- el = el.parentNode || false;
- }
- return (el !== false);
- };
-
- function getZIndex() {
- var dom = self.win;
- if ("zIndex" in dom) return dom.zIndex(); // use jQuery UI method when available
- while (dom.length > 0) {
- if (dom[0].nodeType == 9) return false;
- var zi = dom.css('zIndex');
- if (!isNaN(zi) && zi != 0) return parseInt(zi);
- dom = dom.parent();
- }
- return false;
- }
-
- //inspired by http://forum.jquery.com/topic/width-includes-border-width-when-set-to-thin-medium-thick-in-ie
- var _convertBorderWidth = {
- "thin": 1,
- "medium": 3,
- "thick": 5
- };
-
- function getWidthToPixel(dom, prop, chkheight) {
- var wd = dom.css(prop);
- var px = parseFloat(wd);
- if (isNaN(px)) {
- px = _convertBorderWidth[wd] || 0;
- var brd = (px == 3) ? ((chkheight) ? (self.win.outerHeight() - self.win.innerHeight()) : (self.win.outerWidth() - self.win.innerWidth())) : 1; //DON'T TRUST CSS
- if (self.isie8 && px) px += 1;
- return (brd) ? px : 0;
- }
- return px;
- }
-
- this.getDocumentScrollOffset = function() {
- return {top:window.pageYOffset||document.documentElement.scrollTop,
- left:window.pageXOffset||document.documentElement.scrollLeft};
- }
-
- this.getOffset = function() {
- if (self.isfixed) {
- var ofs = self.win.offset(); // fix Chrome auto issue (when right/bottom props only)
- var scrl = self.getDocumentScrollOffset();
- ofs.top-=scrl.top;
- ofs.left-=scrl.left;
- return ofs;
- }
- var ww = self.win.offset();
- if (!self.viewport) return ww;
- var vp = self.viewport.offset();
- return {
- top: ww.top - vp.top,// + self.viewport.scrollTop(),
- left: ww.left - vp.left // + self.viewport.scrollLeft()
- };
- };
-
- this.updateScrollBar = function(len) {
- if (self.ishwscroll) {
- self.rail.css({ //**
- height: self.win.innerHeight() - (self.opt.railpadding.top + self.opt.railpadding.bottom)
- });
- if (self.railh) self.railh.css({ //**
- width: self.win.innerWidth() - (self.opt.railpadding.left + self.opt.railpadding.right)
- });
-
- } else {
- var wpos = self.getOffset();
- var pos = {
- top: wpos.top,
- left: wpos.left - (self.opt.railpadding.left + self.opt.railpadding.right)
- };
- pos.top += getWidthToPixel(self.win, 'border-top-width', true);
- pos.left += (self.rail.align) ? self.win.outerWidth() - getWidthToPixel(self.win, 'border-right-width') - self.rail.width : getWidthToPixel(self.win, 'border-left-width');
-
- var off = self.opt.railoffset;
- if (off) {
- if (off.top) pos.top += off.top;
- if (self.rail.align && off.left) pos.left += off.left;
- }
-
- if (!self.railslocked) self.rail.css({
- top: pos.top,
- left: pos.left,
- height: ((len) ? len.h : self.win.innerHeight()) - (self.opt.railpadding.top + self.opt.railpadding.bottom)
- });
-
- if (self.zoom) {
- self.zoom.css({
- top: pos.top + 1,
- left: (self.rail.align == 1) ? pos.left - 20 : pos.left + self.rail.width + 4
- });
- }
-
- if (self.railh && !self.railslocked) {
- var pos = {
- top: wpos.top,
- left: wpos.left
- };
- var off = self.opt.railhoffset;
- if (!!off) {
- if (!!off.top) pos.top += off.top;
- if (!!off.left) pos.left += off.left;
- }
- var y = (self.railh.align) ? pos.top + getWidthToPixel(self.win, 'border-top-width', true) + self.win.innerHeight() - self.railh.height : pos.top + getWidthToPixel(self.win, 'border-top-width', true);
- var x = pos.left + getWidthToPixel(self.win, 'border-left-width');
- self.railh.css({
- top: y - (self.opt.railpadding.top + self.opt.railpadding.bottom),
- left: x,
- width: self.railh.width
- });
- }
-
-
- }
- };
-
- this.doRailClick = function(e, dbl, hr) {
- var fn, pg, cur, pos;
-
- if (self.railslocked) return;
- self.cancelEvent(e);
-
- if (dbl) {
- fn = (hr) ? self.doScrollLeft : self.doScrollTop;
- cur = (hr) ? ((e.pageX - self.railh.offset().left - (self.cursorwidth / 2)) * self.scrollratio.x) : ((e.pageY - self.rail.offset().top - (self.cursorheight / 2)) * self.scrollratio.y);
- fn(cur);
- } else {
- fn = (hr) ? self.doScrollLeftBy : self.doScrollBy;
- cur = (hr) ? self.scroll.x : self.scroll.y;
- pos = (hr) ? e.pageX - self.railh.offset().left : e.pageY - self.rail.offset().top;
- pg = (hr) ? self.view.w : self.view.h;
- fn((cur >= pos) ? pg: -pg);// (cur >= pos) ? fn(pg): fn(-pg);
- }
-
- };
-
- self.hasanimationframe = (setAnimationFrame);
- self.hascancelanimationframe = (clearAnimationFrame);
-
- if (!self.hasanimationframe) {
- setAnimationFrame = function(fn) {
- return setTimeout(fn, 15 - Math.floor((+new Date()) / 1000) % 16);
- }; // 1000/60)};
- clearAnimationFrame = clearInterval;
- } else if (!self.hascancelanimationframe) clearAnimationFrame = function() {
- self.cancelAnimationFrame = true;
- };
-
- this.init = function() {
-
- self.saved.css = [];
-
- if (cap.isie7mobile) return true; // SORRY, DO NOT WORK!
- if (cap.isoperamini) return true; // SORRY, DO NOT WORK!
-
- if (cap.hasmstouch) self.css((self.ispage) ? $("html") : self.win, {
- '-ms-touch-action': 'none'
- });
-
- self.zindex = "auto";
- if (!self.ispage && self.opt.zindex == "auto") {
- self.zindex = getZIndex() || "auto";
- } else {
- self.zindex = self.opt.zindex;
- }
-
- if (!self.ispage && self.zindex != "auto") {
- if (self.zindex > globalmaxzindex) globalmaxzindex = self.zindex;
- }
-
- if (self.isie && self.zindex == 0 && self.opt.zindex == "auto") { // fix IE auto == 0
- self.zindex = "auto";
- }
-
- if (!self.ispage || (!cap.cantouch && !cap.isieold && !cap.isie9mobile)) {
-
- var cont = self.docscroll;
- if (self.ispage) cont = (self.haswrapper) ? self.win : self.doc;
-
- if (!cap.isie9mobile) self.css(cont, {
- 'overflow-y': 'hidden'
- });
-
- if (self.ispage && cap.isie7) {
- if (self.doc[0].nodeName == 'BODY') self.css($("html"), {
- 'overflow-y': 'hidden'
- }); //IE7 double scrollbar issue
- else if (self.doc[0].nodeName == 'HTML') self.css($("body"), {
- 'overflow-y': 'hidden'
- }); //IE7 double scrollbar issue
- }
-
- if (cap.isios && !self.ispage && !self.haswrapper) self.css($("body"), {
- "-webkit-overflow-scrolling": "touch"
- }); //force hw acceleration
-
- var cursor = $(document.createElement('div'));
- cursor.css({
- position: "relative",
- top: 0,
- "float": "right",
- width: self.opt.cursorwidth,
- height: "0px",
- 'background-color': self.opt.cursorcolor,
- border: self.opt.cursorborder,
- 'background-clip': 'padding-box',
- '-webkit-border-radius': self.opt.cursorborderradius,
- '-moz-border-radius': self.opt.cursorborderradius,
- 'border-radius': self.opt.cursorborderradius
- });
-
- cursor.hborder = parseFloat(cursor.outerHeight() - cursor.innerHeight());
-
- cursor.addClass('nicescroll-cursors');
-
- self.cursor = cursor;
-
- var rail = $(document.createElement('div'));
- rail.attr('id', self.id);
- rail.addClass('nicescroll-rails nicescroll-rails-vr');
-
- var v, a, kp = ["left","right","top","bottom"]; //**
- for (var n in kp) {
- a = kp[n];
- v = self.opt.railpadding[a];
- (v) ? rail.css("padding-"+a,v+"px") : self.opt.railpadding[a] = 0;
- }
-
- rail.append(cursor);
-
- rail.width = Math.max(parseFloat(self.opt.cursorwidth), cursor.outerWidth());
- rail.css({
- width: rail.width + "px",
- 'zIndex': self.zindex,
- "background": self.opt.background,
- cursor: "default"
- });
-
- rail.visibility = true;
- rail.scrollable = true;
-
- rail.align = (self.opt.railalign == "left") ? 0 : 1;
-
- self.rail = rail;
-
- self.rail.drag = false;
-
- var zoom = false;
- if (self.opt.boxzoom && !self.ispage && !cap.isieold) {
- zoom = document.createElement('div');
-
- self.bind(zoom, "click", self.doZoom);
- self.bind(zoom, "mouseenter", function() {
- self.zoom.css('opacity', self.opt.cursoropacitymax);
- });
- self.bind(zoom, "mouseleave", function() {
- self.zoom.css('opacity', self.opt.cursoropacitymin);
- });
-
- self.zoom = $(zoom);
- self.zoom.css({
- "cursor": "pointer",
- 'z-index': self.zindex,
- 'backgroundImage': 'url(' + self.opt.scriptpath + 'zoomico.png)',
- 'height': 18,
- 'width': 18,
- 'backgroundPosition': '0px 0px'
- });
- if (self.opt.dblclickzoom) self.bind(self.win, "dblclick", self.doZoom);
- if (cap.cantouch && self.opt.gesturezoom) {
- self.ongesturezoom = function(e) {
- if (e.scale > 1.5) self.doZoomIn(e);
- if (e.scale < 0.8) self.doZoomOut(e);
- return self.cancelEvent(e);
- };
- self.bind(self.win, "gestureend", self.ongesturezoom);
- }
- }
-
- // init HORIZ
-
- self.railh = false;
- var railh;
-
- if (self.opt.horizrailenabled) {
-
- self.css(cont, {
- 'overflow-x': 'hidden'
- });
-
- var cursor = $(document.createElement('div'));
- cursor.css({
- position: "absolute",
- top: 0,
- height: self.opt.cursorwidth,
- width: "0px",
- 'background-color': self.opt.cursorcolor,
- border: self.opt.cursorborder,
- 'background-clip': 'padding-box',
- '-webkit-border-radius': self.opt.cursorborderradius,
- '-moz-border-radius': self.opt.cursorborderradius,
- 'border-radius': self.opt.cursorborderradius
- });
-
- if (cap.isieold) cursor.css({'overflow':'hidden'}); //IE6 horiz scrollbar issue
-
- cursor.wborder = parseFloat(cursor.outerWidth() - cursor.innerWidth());
-
- cursor.addClass('nicescroll-cursors');
-
- self.cursorh = cursor;
-
- railh = $(document.createElement('div'));
- railh.attr('id', self.id + '-hr');
- railh.addClass('nicescroll-rails nicescroll-rails-hr');
- railh.height = Math.max(parseFloat(self.opt.cursorwidth), cursor.outerHeight());
- railh.css({
- height: railh.height + "px",
- 'zIndex': self.zindex,
- "background": self.opt.background
- });
-
- railh.append(cursor);
-
- railh.visibility = true;
- railh.scrollable = true;
-
- railh.align = (self.opt.railvalign == "top") ? 0 : 1;
-
- self.railh = railh;
-
- self.railh.drag = false;
-
- }
-
- //
-
- if (self.ispage) {
- rail.css({
- position: "fixed",
- top: "0px",
- height: "100%"
- });
- (rail.align) ? rail.css({
- right: "0px"
- }): rail.css({
- left: "0px"
- });
- self.body.append(rail);
- if (self.railh) {
- railh.css({
- position: "fixed",
- left: "0px",
- width: "100%"
- });
- (railh.align) ? railh.css({
- bottom: "0px"
- }): railh.css({
- top: "0px"
- });
- self.body.append(railh);
- }
- } else {
- if (self.ishwscroll) {
- if (self.win.css('position') == 'static') self.css(self.win, {
- 'position': 'relative'
- });
- var bd = (self.win[0].nodeName == 'HTML') ? self.body : self.win;
- $(bd).scrollTop(0).scrollLeft(0); // fix rail position if content already scrolled
- if (self.zoom) {
- self.zoom.css({
- position: "absolute",
- top: 1,
- right: 0,
- "margin-right": rail.width + 4
- });
- bd.append(self.zoom);
- }
- rail.css({
- position: "absolute",
- top: 0
- });
- (rail.align) ? rail.css({
- right: 0
- }): rail.css({
- left: 0
- });
- bd.append(rail);
- if (railh) {
- railh.css({
- position: "absolute",
- left: 0,
- bottom: 0
- });
- (railh.align) ? railh.css({
- bottom: 0
- }): railh.css({
- top: 0
- });
- bd.append(railh);
- }
- } else {
- self.isfixed = (self.win.css("position") == "fixed");
- var rlpos = (self.isfixed) ? "fixed" : "absolute";
-
- if (!self.isfixed) self.viewport = self.getViewport(self.win[0]);
- if (self.viewport) {
- self.body = self.viewport;
- if ((/fixed|absolute/.test(self.viewport.css("position"))) == false) self.css(self.viewport, {
- "position": "relative"
- });
- }
-
- rail.css({
- position: rlpos
- });
- if (self.zoom) self.zoom.css({
- position: rlpos
- });
- self.updateScrollBar();
- self.body.append(rail);
- if (self.zoom) self.body.append(self.zoom);
- if (self.railh) {
- railh.css({
- position: rlpos
- });
- self.body.append(railh);
- }
- }
-
- if (cap.isios) self.css(self.win, {
- '-webkit-tap-highlight-color': 'rgba(0,0,0,0)',
- '-webkit-touch-callout': 'none'
- }); // prevent grey layer on click
-
- if (cap.isie && self.opt.disableoutline) self.win.attr("hideFocus", "true"); // IE, prevent dotted rectangle on focused div
- if (cap.iswebkit && self.opt.disableoutline) self.win.css({"outline": "none"}); // Webkit outline
- //if (cap.isopera&&self.opt.disableoutline) self.win.css({"outline":"0"}); // Opera 12- to test [TODO]
-
- }
-
- if (self.opt.autohidemode === false) {
- self.autohidedom = false;
- self.rail.css({
- opacity: self.opt.cursoropacitymax
- });
- if (self.railh) self.railh.css({
- opacity: self.opt.cursoropacitymax
- });
- } else if ((self.opt.autohidemode === true) || (self.opt.autohidemode === "leave")) {
- self.autohidedom = $().add(self.rail);
- if (cap.isie8) self.autohidedom = self.autohidedom.add(self.cursor);
- if (self.railh) self.autohidedom = self.autohidedom.add(self.railh);
- if (self.railh && cap.isie8) self.autohidedom = self.autohidedom.add(self.cursorh);
- } else if (self.opt.autohidemode == "scroll") {
- self.autohidedom = $().add(self.rail);
- if (self.railh) self.autohidedom = self.autohidedom.add(self.railh);
- } else if (self.opt.autohidemode == "cursor") {
- self.autohidedom = $().add(self.cursor);
- if (self.railh) self.autohidedom = self.autohidedom.add(self.cursorh);
- } else if (self.opt.autohidemode == "hidden") {
- self.autohidedom = false;
- self.hide();
- self.railslocked = false;
- }
-
- if (cap.isie9mobile) {
-
- self.scrollmom = new ScrollMomentumClass2D(self);
-
- self.onmangotouch = function() {
- var py = self.getScrollTop();
- var px = self.getScrollLeft();
-
- if ((py == self.scrollmom.lastscrolly) && (px == self.scrollmom.lastscrollx)) return true;
-
- var dfy = py - self.mangotouch.sy;
- var dfx = px - self.mangotouch.sx;
- var df = Math.round(Math.sqrt(Math.pow(dfx, 2) + Math.pow(dfy, 2)));
- if (df == 0) return;
-
- var dry = (dfy < 0) ? -1 : 1;
- var drx = (dfx < 0) ? -1 : 1;
-
- var tm = +new Date();
- if (self.mangotouch.lazy) clearTimeout(self.mangotouch.lazy);
-
- if (((tm - self.mangotouch.tm) > 80) || (self.mangotouch.dry != dry) || (self.mangotouch.drx != drx)) {
- self.scrollmom.stop();
- self.scrollmom.reset(px, py);
- self.mangotouch.sy = py;
- self.mangotouch.ly = py;
- self.mangotouch.sx = px;
- self.mangotouch.lx = px;
- self.mangotouch.dry = dry;
- self.mangotouch.drx = drx;
- self.mangotouch.tm = tm;
- } else {
-
- self.scrollmom.stop();
- self.scrollmom.update(self.mangotouch.sx - dfx, self.mangotouch.sy - dfy);
- self.mangotouch.tm = tm;
-
- var ds = Math.max(Math.abs(self.mangotouch.ly - py), Math.abs(self.mangotouch.lx - px));
- self.mangotouch.ly = py;
- self.mangotouch.lx = px;
-
- if (ds > 2) {
- self.mangotouch.lazy = setTimeout(function() {
- self.mangotouch.lazy = false;
- self.mangotouch.dry = 0;
- self.mangotouch.drx = 0;
- self.mangotouch.tm = 0;
- self.scrollmom.doMomentum(30);
- }, 100);
- }
- }
- };
-
- var top = self.getScrollTop();
- var lef = self.getScrollLeft();
- self.mangotouch = {
- sy: top,
- ly: top,
- dry: 0,
- sx: lef,
- lx: lef,
- drx: 0,
- lazy: false,
- tm: 0
- };
-
- self.bind(self.docscroll, "scroll", self.onmangotouch);
-
- } else {
-
- if (cap.cantouch || self.istouchcapable || self.opt.touchbehavior || cap.hasmstouch) {
-
- self.scrollmom = new ScrollMomentumClass2D(self);
-
- self.ontouchstart = function(e) {
- if (e.pointerType && e.pointerType != 2 && e.pointerType != "touch") return false;
-
- self.hasmoving = false;
-
- if (!self.railslocked) {
-
- var tg;
- if (cap.hasmstouch) {
- tg = (e.target) ? e.target : false;
- while (tg) {
- var nc = $(tg).getNiceScroll();
- if ((nc.length > 0) && (nc[0].me == self.me)) break;
- if (nc.length > 0) return false;
- if ((tg.nodeName == 'DIV') && (tg.id == self.id)) break;
- tg = (tg.parentNode) ? tg.parentNode : false;
- }
- }
-
- self.cancelScroll();
-
- tg = self.getTarget(e);
-
- if (tg) {
- var skp = (/INPUT/i.test(tg.nodeName)) && (/range/i.test(tg.type));
- if (skp) return self.stopPropagation(e);
- }
-
- if (!("clientX" in e) && ("changedTouches" in e)) {
- e.clientX = e.changedTouches[0].clientX;
- e.clientY = e.changedTouches[0].clientY;
- }
-
- if (self.forcescreen) {
- var le = e;
- e = {
- "original": (e.original) ? e.original : e
- };
- e.clientX = le.screenX;
- e.clientY = le.screenY;
- }
-
- self.rail.drag = {
- x: e.clientX,
- y: e.clientY,
- sx: self.scroll.x,
- sy: self.scroll.y,
- st: self.getScrollTop(),
- sl: self.getScrollLeft(),
- pt: 2,
- dl: false
- };
-
- if (self.ispage || !self.opt.directionlockdeadzone) {
- self.rail.drag.dl = "f";
- } else {
-
- var view = {
- w: $(window).width(),
- h: $(window).height()
- };
-
- var page = {
- w: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth),
- h: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
- };
-
- var maxh = Math.max(0, page.h - view.h);
- var maxw = Math.max(0, page.w - view.w);
-
- if (!self.rail.scrollable && self.railh.scrollable) self.rail.drag.ck = (maxh > 0) ? "v" : false;
- else if (self.rail.scrollable && !self.railh.scrollable) self.rail.drag.ck = (maxw > 0) ? "h" : false;
- else self.rail.drag.ck = false;
- if (!self.rail.drag.ck) self.rail.drag.dl = "f";
- }
-
- if (self.opt.touchbehavior && self.isiframe && cap.isie) {
- var wp = self.win.position();
- self.rail.drag.x += wp.left;
- self.rail.drag.y += wp.top;
- }
-
- self.hasmoving = false;
- self.lastmouseup = false;
- self.scrollmom.reset(e.clientX, e.clientY);
-
- if (!cap.cantouch && !this.istouchcapable && !e.pointerType) {
-
- var ip = (tg) ? /INPUT|SELECT|TEXTAREA/i.test(tg.nodeName) : false;
- if (!ip) {
- if (!self.ispage && cap.hasmousecapture) tg.setCapture();
- if (self.opt.touchbehavior) {
- if (tg.onclick && !(tg._onclick || false)) { // intercept DOM0 onclick event
- tg._onclick = tg.onclick;
- tg.onclick = function(e) {
- if (self.hasmoving) return false;
- tg._onclick.call(this, e);
- };
- }
- return self.cancelEvent(e);
- }
- return self.stopPropagation(e);
- }
-
- if (/SUBMIT|CANCEL|BUTTON/i.test($(tg).attr('type'))) {
- pc = {
- "tg": tg,
- "click": false
- };
- self.preventclick = pc;
- }
-
- }
- }
-
- };
-
- self.ontouchend = function(e) {
- if (!self.rail.drag) return true;
- if (self.rail.drag.pt == 2) {
- if (e.pointerType && e.pointerType != 2 && e.pointerType != "touch") return false;
- self.scrollmom.doMomentum();
- self.rail.drag = false;
- if (self.hasmoving) {
- self.lastmouseup = true;
- self.hideCursor();
- if (cap.hasmousecapture) document.releaseCapture();
- if (!cap.cantouch) return self.cancelEvent(e);
- }
- }
- else if (self.rail.drag.pt == 1) {
- return self.onmouseup(e);
- }
-
- };
-
- var moveneedoffset = (self.opt.touchbehavior && self.isiframe && !cap.hasmousecapture);
-
- self.ontouchmove = function(e, byiframe) {
-
- if (!self.rail.drag) return false;
-
- if (e.targetTouches && self.opt.preventmultitouchscrolling) {
- if (e.targetTouches.length > 1) return false; // multitouch
- }
-
- if (e.pointerType && e.pointerType != 2 && e.pointerType != "touch") return false;
-
- if (self.rail.drag.pt == 2) {
- if (cap.cantouch && (cap.isios) && (typeof e.original == "undefined")) return true; // prevent ios "ghost" events by clickable elements
-
- self.hasmoving = true;
-
- if (self.preventclick && !self.preventclick.click) {
- self.preventclick.click = self.preventclick.tg.onclick || false;
- self.preventclick.tg.onclick = self.onpreventclick;
- }
-
- var ev = $.extend({
- "original": e
- }, e);
- e = ev;
-
- if (("changedTouches" in e)) {
- e.clientX = e.changedTouches[0].clientX;
- e.clientY = e.changedTouches[0].clientY;
- }
-
- if (self.forcescreen) {
- var le = e;
- e = {
- "original": (e.original) ? e.original : e
- };
- e.clientX = le.screenX;
- e.clientY = le.screenY;
- }
-
- var ofy,ofx;
- ofx = ofy = 0;
-
- if (moveneedoffset && !byiframe) {
- var wp = self.win.position();
- ofx = -wp.left;
- ofy = -wp.top;
- }
-
- var fy = e.clientY + ofy;
- var my = (fy - self.rail.drag.y);
- var fx = e.clientX + ofx;
- var mx = (fx - self.rail.drag.x);
-
- var ny = self.rail.drag.st - my;
-
- if (self.ishwscroll && self.opt.bouncescroll) {
- if (ny < 0) {
- ny = Math.round(ny / 2);
- // fy = 0;
- } else if (ny > self.page.maxh) {
- ny = self.page.maxh + Math.round((ny - self.page.maxh) / 2);
- // fy = 0;
- }
- } else {
- if (ny < 0) {
- ny = 0;
- fy = 0;
- }
- if (ny > self.page.maxh) {
- ny = self.page.maxh;
- fy = 0;
- }
- }
-
- var nx;
- if (self.railh && self.railh.scrollable) {
- nx = (self.isrtlmode) ? mx - self.rail.drag.sl : self.rail.drag.sl - mx;
-
- if (self.ishwscroll && self.opt.bouncescroll) {
- if (nx < 0) {
- nx = Math.round(nx / 2);
- // fx = 0;
- } else if (nx > self.page.maxw) {
- nx = self.page.maxw + Math.round((nx - self.page.maxw) / 2);
- // fx = 0;
- }
- } else {
- if (nx < 0) {
- nx = 0;
- fx = 0;
- }
- if (nx > self.page.maxw) {
- nx = self.page.maxw;
- fx = 0;
- }
- }
-
- }
-
- var grabbed = false;
- if (self.rail.drag.dl) {
- grabbed = true;
- if (self.rail.drag.dl == "v") nx = self.rail.drag.sl;
- else if (self.rail.drag.dl == "h") ny = self.rail.drag.st;
- } else {
- var ay = Math.abs(my);
- var ax = Math.abs(mx);
- var dz = self.opt.directionlockdeadzone;
- if (self.rail.drag.ck == "v") {
- if (ay > dz && (ax <= (ay * 0.3))) {
- self.rail.drag = false;
- return true;
- } else if (ax > dz) {
- self.rail.drag.dl = "f";
- $("body").scrollTop($("body").scrollTop()); // stop iOS native scrolling (when active javascript has blocked)
- }
- } else if (self.rail.drag.ck == "h") {
- if (ax > dz && (ay <= (ax * 0.3))) {
- self.rail.drag = false;
- return true;
- } else if (ay > dz) {
- self.rail.drag.dl = "f";
- $("body").scrollLeft($("body").scrollLeft()); // stop iOS native scrolling (when active javascript has blocked)
- }
- }
- }
-
- self.synched("touchmove", function() {
- if (self.rail.drag && (self.rail.drag.pt == 2)) {
- if (self.prepareTransition) self.prepareTransition(0);
- if (self.rail.scrollable) self.setScrollTop(ny);
- self.scrollmom.update(fx, fy);
- if (self.railh && self.railh.scrollable) {
- self.setScrollLeft(nx);
- self.showCursor(ny, nx);
- } else {
- self.showCursor(ny);
- }
- if (cap.isie10) document.selection.clear();
- }
- });
-
- if (cap.ischrome && self.istouchcapable) grabbed = false; //chrome touch emulation doesn't like!
- if (grabbed) return self.cancelEvent(e);
- }
- else if (self.rail.drag.pt == 1) { // drag on cursor
- return self.onmousemove(e);
- }
-
- };
-
- }
-
- self.onmousedown = function(e, hronly) {
- if (self.rail.drag && self.rail.drag.pt != 1) return;
- if (self.railslocked) return self.cancelEvent(e);
- self.cancelScroll();
- self.rail.drag = {
- x: e.clientX,
- y: e.clientY,
- sx: self.scroll.x,
- sy: self.scroll.y,
- pt: 1,
- hr: (!!hronly)
- };
- var tg = self.getTarget(e);
- if (!self.ispage && cap.hasmousecapture) tg.setCapture();
- if (self.isiframe && !cap.hasmousecapture) {
- self.saved.csspointerevents = self.doc.css("pointer-events");
- self.css(self.doc, {
- "pointer-events": "none"
- });
- }
- self.hasmoving = false;
- return self.cancelEvent(e);
- };
-
- self.onmouseup = function(e) {
- if (self.rail.drag) {
- if (self.rail.drag.pt != 1) return true;
- if (cap.hasmousecapture) document.releaseCapture();
- if (self.isiframe && !cap.hasmousecapture) self.doc.css("pointer-events", self.saved.csspointerevents);
- self.rail.drag = false;
- //if (!self.rail.active) self.hideCursor();
- if (self.hasmoving) self.triggerScrollEnd(); // TODO - check &&!self.scrollrunning
- return self.cancelEvent(e);
- }
- };
-
- self.onmousemove = function(e) {
- if (self.rail.drag) {
- if (self.rail.drag.pt != 1) return;
-
- if (cap.ischrome && e.which == 0) return self.onmouseup(e);
-
- self.cursorfreezed = true;
- self.hasmoving = true;
-
- if (self.rail.drag.hr) {
- self.scroll.x = self.rail.drag.sx + (e.clientX - self.rail.drag.x);
- if (self.scroll.x < 0) self.scroll.x = 0;
- var mw = self.scrollvaluemaxw;
- if (self.scroll.x > mw) self.scroll.x = mw;
- } else {
- self.scroll.y = self.rail.drag.sy + (e.clientY - self.rail.drag.y);
- if (self.scroll.y < 0) self.scroll.y = 0;
- var my = self.scrollvaluemax;
- if (self.scroll.y > my) self.scroll.y = my;
- }
-
- self.synched('mousemove', function() {
- if (self.rail.drag && (self.rail.drag.pt == 1)) {
- self.showCursor();
- if (self.rail.drag.hr) {
- if (self.hasreversehr) {
- self.doScrollLeft(self.scrollvaluemaxw-Math.round(self.scroll.x * self.scrollratio.x), self.opt.cursordragspeed);
- } else {
- self.doScrollLeft(Math.round(self.scroll.x * self.scrollratio.x), self.opt.cursordragspeed);
- }
- }
- else self.doScrollTop(Math.round(self.scroll.y * self.scrollratio.y), self.opt.cursordragspeed);
- }
- });
-
- return self.cancelEvent(e);
- }
- /*
- else {
- self.checkarea = true;
- }
-*/
- };
-
- if (cap.cantouch || self.opt.touchbehavior) {
-
- self.onpreventclick = function(e) {
- if (self.preventclick) {
- self.preventclick.tg.onclick = self.preventclick.click;
- self.preventclick = false;
- return self.cancelEvent(e);
- }
- }
-
- self.bind(self.win, "mousedown", self.ontouchstart); // control content dragging
-
- self.onclick = (cap.isios) ? false : function(e) {
- if (self.lastmouseup) {
- self.lastmouseup = false;
- return self.cancelEvent(e);
- } else {
- return true;
- }
- };
-
- if (self.opt.grabcursorenabled && cap.cursorgrabvalue) {
- self.css((self.ispage) ? self.doc : self.win, {
- 'cursor': cap.cursorgrabvalue
- });
- self.css(self.rail, {
- 'cursor': cap.cursorgrabvalue
- });
- }
-
- } else {
-
- var checkSelectionScroll = function(e) {
- if (!self.selectiondrag) return;
-
- if (e) {
- var ww = self.win.outerHeight();
- var df = (e.pageY - self.selectiondrag.top);
- if (df > 0 && df < ww) df = 0;
- if (df >= ww) df -= ww;
- self.selectiondrag.df = df;
- }
- if (self.selectiondrag.df == 0) return;
-
- var rt = -Math.floor(self.selectiondrag.df / 6) * 2;
- self.doScrollBy(rt);
-
- self.debounced("doselectionscroll", function() {
- checkSelectionScroll()
- }, 50);
- };
-
- if ("getSelection" in document) { // A grade - Major browsers
- self.hasTextSelected = function() {
- return (document.getSelection().rangeCount > 0);
- };
- } else if ("selection" in document) { //IE9-
- self.hasTextSelected = function() {
- return (document.selection.type != "None");
- };
- } else {
- self.hasTextSelected = function() { // no support
- return false;
- };
- }
-
- self.onselectionstart = function(e) {
-/* More testing - severe chrome issues
- if (!self.haswrapper&&(e.which&&e.which==2)) { // fool browser to manage middle button scrolling
- self.win.css({'overflow':'auto'});
- setTimeout(function(){
- self.win.css({'overflow':''});
- },10);
- return true;
- }
-*/
- if (self.ispage) return;
- self.selectiondrag = self.win.offset();
- };
-
- self.onselectionend = function(e) {
- self.selectiondrag = false;
- };
- self.onselectiondrag = function(e) {
- if (!self.selectiondrag) return;
- if (self.hasTextSelected()) self.debounced("selectionscroll", function() {
- checkSelectionScroll(e)
- }, 250);
- };
-
-
- }
-
- if (cap.hasw3ctouch) { //IE11+
- self.css(self.rail, {
- 'touch-action': 'none'
- });
- self.css(self.cursor, {
- 'touch-action': 'none'
- });
- self.bind(self.win, "pointerdown", self.ontouchstart);
- self.bind(document, "pointerup", self.ontouchend);
- self.bind(document, "pointermove", self.ontouchmove);
- } else if (cap.hasmstouch) { //IE10
- self.css(self.rail, {
- '-ms-touch-action': 'none'
- });
- self.css(self.cursor, {
- '-ms-touch-action': 'none'
- });
- self.bind(self.win, "MSPointerDown", self.ontouchstart);
- self.bind(document, "MSPointerUp", self.ontouchend);
- self.bind(document, "MSPointerMove", self.ontouchmove);
- self.bind(self.cursor, "MSGestureHold", function(e) {
- e.preventDefault()
- });
- self.bind(self.cursor, "contextmenu", function(e) {
- e.preventDefault()
- });
- } else if (this.istouchcapable) { //desktop with screen touch enabled
- self.bind(self.win, "touchstart", self.ontouchstart);
- self.bind(document, "touchend", self.ontouchend);
- self.bind(document, "touchcancel", self.ontouchend);
- self.bind(document, "touchmove", self.ontouchmove);
- }
-
-
- if (self.opt.cursordragontouch || (!cap.cantouch && !self.opt.touchbehavior)) {
-
- self.rail.css({
- "cursor": "default"
- });
- self.railh && self.railh.css({
- "cursor": "default"
- });
-
- self.jqbind(self.rail, "mouseenter", function() {
- if (!self.ispage && !self.win.is(":visible")) return false;
- if (self.canshowonmouseevent) self.showCursor();
- self.rail.active = true;
- });
- self.jqbind(self.rail, "mouseleave", function() {
- self.rail.active = false;
- if (!self.rail.drag) self.hideCursor();
- });
-
- if (self.opt.sensitiverail) {
- self.bind(self.rail, "click", function(e) {
- self.doRailClick(e, false, false)
- });
- self.bind(self.rail, "dblclick", function(e) {
- self.doRailClick(e, true, false)
- });
- self.bind(self.cursor, "click", function(e) {
- self.cancelEvent(e)
- });
- self.bind(self.cursor, "dblclick", function(e) {
- self.cancelEvent(e)
- });
- }
-
- if (self.railh) {
- self.jqbind(self.railh, "mouseenter", function() {
- if (!self.ispage && !self.win.is(":visible")) return false;
- if (self.canshowonmouseevent) self.showCursor();
- self.rail.active = true;
- });
- self.jqbind(self.railh, "mouseleave", function() {
- self.rail.active = false;
- if (!self.rail.drag) self.hideCursor();
- });
-
- if (self.opt.sensitiverail) {
- self.bind(self.railh, "click", function(e) {
- self.doRailClick(e, false, true)
- });
- self.bind(self.railh, "dblclick", function(e) {
- self.doRailClick(e, true, true)
- });
- self.bind(self.cursorh, "click", function(e) {
- self.cancelEvent(e)
- });
- self.bind(self.cursorh, "dblclick", function(e) {
- self.cancelEvent(e)
- });
- }
-
- }
-
- }
-
- if (!cap.cantouch && !self.opt.touchbehavior) {
-
- self.bind((cap.hasmousecapture) ? self.win : document, "mouseup", self.onmouseup);
- self.bind(document, "mousemove", self.onmousemove);
- if (self.onclick) self.bind(document, "click", self.onclick);
-
- self.bind(self.cursor, "mousedown", self.onmousedown);
- self.bind(self.cursor, "mouseup", self.onmouseup);
-
- if (self.railh) {
- self.bind(self.cursorh, "mousedown", function(e) {
- self.onmousedown(e, true)
- });
- self.bind(self.cursorh, "mouseup", self.onmouseup);
- }
-
- if (!self.ispage && self.opt.enablescrollonselection) {
- self.bind(self.win[0], "mousedown", self.onselectionstart);
- self.bind(document, "mouseup", self.onselectionend);
- self.bind(self.cursor, "mouseup", self.onselectionend);
- if (self.cursorh) self.bind(self.cursorh, "mouseup", self.onselectionend);
- self.bind(document, "mousemove", self.onselectiondrag);
- }
-
- if (self.zoom) {
- self.jqbind(self.zoom, "mouseenter", function() {
- if (self.canshowonmouseevent) self.showCursor();
- self.rail.active = true;
- });
- self.jqbind(self.zoom, "mouseleave", function() {
- self.rail.active = false;
- if (!self.rail.drag) self.hideCursor();
- });
- }
-
- } else {
-
- self.bind((cap.hasmousecapture) ? self.win : document, "mouseup", self.ontouchend);
- self.bind(document, "mousemove", self.ontouchmove);
- if (self.onclick) self.bind(document, "click", self.onclick);
-
- if (self.opt.cursordragontouch) {
- self.bind(self.cursor, "mousedown", self.onmousedown);
- self.bind(self.cursor, "mouseup", self.onmouseup);
- //self.bind(self.cursor, "mousemove", self.onmousemove);
- self.cursorh && self.bind(self.cursorh, "mousedown", function(e) {
- self.onmousedown(e, true)
- });
- //self.cursorh && self.bind(self.cursorh, "mousemove", self.onmousemove);
- self.cursorh && self.bind(self.cursorh, "mouseup", self.onmouseup);
- }
-
- }
-
- if (self.opt.enablemousewheel) {
- if (!self.isiframe) self.bind((cap.isie && self.ispage) ? document : self.win /*self.docscroll*/ , "mousewheel", self.onmousewheel);
- self.bind(self.rail, "mousewheel", self.onmousewheel);
- if (self.railh) self.bind(self.railh, "mousewheel", self.onmousewheelhr);
- }
-
- if (!self.ispage && !cap.cantouch && !(/HTML|^BODY/.test(self.win[0].nodeName))) {
- if (!self.win.attr("tabindex")) self.win.attr({
- "tabindex": tabindexcounter++
- });
-
- self.jqbind(self.win, "focus", function(e) {
- domfocus = (self.getTarget(e)).id || true;
- self.hasfocus = true;
- if (self.canshowonmouseevent) self.noticeCursor();
- });
- self.jqbind(self.win, "blur", function(e) {
- domfocus = false;
- self.hasfocus = false;
- });
-
- self.jqbind(self.win, "mouseenter", function(e) {
- mousefocus = (self.getTarget(e)).id || true;
- self.hasmousefocus = true;
- if (self.canshowonmouseevent) self.noticeCursor();
- });
- self.jqbind(self.win, "mouseleave", function() {
- mousefocus = false;
- self.hasmousefocus = false;
- if (!self.rail.drag) self.hideCursor();
- });
-
- }
-
- } // !ie9mobile
-
- //Thanks to http://www.quirksmode.org !!
- self.onkeypress = function(e) {
- if (self.railslocked && self.page.maxh == 0) return true;
-
- e = (e) ? e : window.e;
- var tg = self.getTarget(e);
- if (tg && /INPUT|TEXTAREA|SELECT|OPTION/.test(tg.nodeName)) {
- var tp = tg.getAttribute('type') || tg.type || false;
- if ((!tp) || !(/submit|button|cancel/i.tp)) return true;
- }
-
- if ($(tg).attr('contenteditable')) return true;
-
- if (self.hasfocus || (self.hasmousefocus && !domfocus) || (self.ispage && !domfocus && !mousefocus)) {
- var key = e.keyCode;
-
- if (self.railslocked && key != 27) return self.cancelEvent(e);
-
- var ctrl = e.ctrlKey || false;
- var shift = e.shiftKey || false;
-
- var ret = false;
- switch (key) {
- case 38:
- case 63233: //safari
- self.doScrollBy(24 * 3);
- ret = true;
- break;
- case 40:
- case 63235: //safari
- self.doScrollBy(-24 * 3);
- ret = true;
- break;
- case 37:
- case 63232: //safari
- if (self.railh) {
- (ctrl) ? self.doScrollLeft(0): self.doScrollLeftBy(24 * 3);
- ret = true;
- }
- break;
- case 39:
- case 63234: //safari
- if (self.railh) {
- (ctrl) ? self.doScrollLeft(self.page.maxw): self.doScrollLeftBy(-24 * 3);
- ret = true;
- }
- break;
- case 33:
- case 63276: // safari
- self.doScrollBy(self.view.h);
- ret = true;
- break;
- case 34:
- case 63277: // safari
- self.doScrollBy(-self.view.h);
- ret = true;
- break;
- case 36:
- case 63273: // safari
- (self.railh && ctrl) ? self.doScrollPos(0, 0): self.doScrollTo(0);
- ret = true;
- break;
- case 35:
- case 63275: // safari
- (self.railh && ctrl) ? self.doScrollPos(self.page.maxw, self.page.maxh): self.doScrollTo(self.page.maxh);
- ret = true;
- break;
- case 32:
- if (self.opt.spacebarenabled) {
- (shift) ? self.doScrollBy(self.view.h): self.doScrollBy(-self.view.h);
- ret = true;
- }
- break;
- case 27: // ESC
- if (self.zoomactive) {
- self.doZoom();
- ret = true;
- }
- break;
- }
- if (ret) return self.cancelEvent(e);
- }
- };
-
- if (self.opt.enablekeyboard) self.bind(document, (cap.isopera && !cap.isopera12) ? "keypress" : "keydown", self.onkeypress);
-
- self.bind(document, "keydown", function(e) {
- var ctrl = e.ctrlKey || false;
- if (ctrl) self.wheelprevented = true;
- });
- self.bind(document, "keyup", function(e) {
- var ctrl = e.ctrlKey || false;
- if (!ctrl) self.wheelprevented = false;
- });
- self.bind(window,"blur",function(e){
- self.wheelprevented = false;
- });
-
- self.bind(window, 'resize', self.lazyResize);
- self.bind(window, 'orientationchange', self.lazyResize);
-
- self.bind(window, "load", self.lazyResize);
-
- if (cap.ischrome && !self.ispage && !self.haswrapper) { //chrome void scrollbar bug - it persists in version 26
- var tmp = self.win.attr("style");
- var ww = parseFloat(self.win.css("width")) + 1;
- self.win.css('width', ww);
- self.synched("chromefix", function() {
- self.win.attr("style", tmp)
- });
- }
-
-
- // Trying a cross-browser implementation - good luck!
-
- self.onAttributeChange = function(e) {
- self.lazyResize(self.isieold ? 250 : 30);
- };
-
- if (ClsMutationObserver !== false) {
- self.observerbody = new ClsMutationObserver(function(mutations) {
- mutations.forEach(function(mut){
- if (mut.type=="attributes") {
- return ($("body").hasClass("modal-open")) ? self.hide() : self.show(); // Support for Bootstrap modal
- }
- });
- if (document.body.scrollHeight!=self.page.maxh) return self.lazyResize(30);
- });
- self.observerbody.observe(document.body, {
- childList: true,
- subtree: true,
- characterData: false,
- attributes: true,
- attributeFilter: ['class']
- });
- }
-
- if (!self.ispage && !self.haswrapper) {
- // redesigned MutationObserver for Chrome18+/Firefox14+/iOS6+ with support for: remove div, add/remove content
- if (ClsMutationObserver !== false) {
- self.observer = new ClsMutationObserver(function(mutations) {
- mutations.forEach(self.onAttributeChange);
- });
- self.observer.observe(self.win[0], {
- childList: true,
- characterData: false,
- attributes: true,
- subtree: false
- });
- self.observerremover = new ClsMutationObserver(function(mutations) {
- mutations.forEach(function(mo) {
- if (mo.removedNodes.length > 0) {
- for (var dd in mo.removedNodes) {
- if (!!self && (mo.removedNodes[dd] == self.win[0])) return self.remove();
- }
- }
- });
- });
- self.observerremover.observe(self.win[0].parentNode, {
- childList: true,
- characterData: false,
- attributes: false,
- subtree: false
- });
- } else {
- self.bind(self.win, (cap.isie && !cap.isie9) ? "propertychange" : "DOMAttrModified", self.onAttributeChange);
- if (cap.isie9) self.win[0].attachEvent("onpropertychange", self.onAttributeChange); //IE9 DOMAttrModified bug
- self.bind(self.win, "DOMNodeRemoved", function(e) {
- if (e.target == self.win[0]) self.remove();
- });
- }
- }
-
- //
-
- if (!self.ispage && self.opt.boxzoom) self.bind(window, "resize", self.resizeZoom);
- if (self.istextarea) self.bind(self.win, "mouseup", self.lazyResize);
-
- // self.checkrtlmode = true;
- self.lazyResize(30);
-
- }
-
- if (this.doc[0].nodeName == 'IFRAME') {
- var oniframeload = function() {
- self.iframexd = false;
- var doc;
- try {
- doc = 'contentDocument' in this ? this.contentDocument : this.contentWindow.document;
- var a = doc.domain;
- } catch (e) {
- self.iframexd = true;
- doc = false
- }
-
- if (self.iframexd) {
- if ("console" in window) console.log('NiceScroll error: policy restriced iframe');
- return true; //cross-domain - I can't manage this
- }
-
- self.forcescreen = true;
-
- if (self.isiframe) {
- self.iframe = {
- "doc": $(doc),
- "html": self.doc.contents().find('html')[0],
- "body": self.doc.contents().find('body')[0]
- };
- self.getContentSize = function() {
- return {
- w: Math.max(self.iframe.html.scrollWidth, self.iframe.body.scrollWidth),
- h: Math.max(self.iframe.html.scrollHeight, self.iframe.body.scrollHeight)
- };
- };
- self.docscroll = $(self.iframe.body); //$(this.contentWindow);
- }
-
- if (!cap.isios && self.opt.iframeautoresize && !self.isiframe) {
- self.win.scrollTop(0); // reset position
- self.doc.height(""); //reset height to fix browser bug
- var hh = Math.max(doc.getElementsByTagName('html')[0].scrollHeight, doc.body.scrollHeight);
- self.doc.height(hh);
- }
- self.lazyResize(30);
-
- if (cap.isie7) self.css($(self.iframe.html), {
- 'overflow-y': 'hidden'
- });
- self.css($(self.iframe.body), {
- 'overflow-y': 'hidden'
- });
-
- if (cap.isios && self.haswrapper) {
- self.css($(doc.body), {
- '-webkit-transform': 'translate3d(0,0,0)'
- }); // avoid iFrame content clipping - thanks to http://blog.derraab.com/2012/04/02/avoid-iframe-content-clipping-with-css-transform-on-ios/
- }
-
- if ('contentWindow' in this) {
- self.bind(this.contentWindow, "scroll", self.onscroll); //IE8 & minor
- } else {
- self.bind(doc, "scroll", self.onscroll);
- }
-
- if (self.opt.enablemousewheel) {
- self.bind(doc, "mousewheel", self.onmousewheel);
- }
-
- if (self.opt.enablekeyboard) self.bind(doc, (cap.isopera) ? "keypress" : "keydown", self.onkeypress);
-
- if (cap.cantouch || self.opt.touchbehavior) {
- self.bind(doc, "mousedown", self.ontouchstart);
- self.bind(doc, "mousemove", function(e) {
- return self.ontouchmove(e, true)
- });
- if (self.opt.grabcursorenabled && cap.cursorgrabvalue) self.css($(doc.body), {
- 'cursor': cap.cursorgrabvalue
- });
- }
-
- self.bind(doc, "mouseup", self.ontouchend);
-
- if (self.zoom) {
- if (self.opt.dblclickzoom) self.bind(doc, 'dblclick', self.doZoom);
- if (self.ongesturezoom) self.bind(doc, "gestureend", self.ongesturezoom);
- }
- };
-
- if (this.doc[0].readyState && this.doc[0].readyState == "complete") {
- setTimeout(function() {
- oniframeload.call(self.doc[0], false)
- }, 500);
- }
- self.bind(this.doc, "load", oniframeload);
-
- }
-
- };
-
- this.showCursor = function(py, px) {
- if (self.cursortimeout) {
- clearTimeout(self.cursortimeout);
- self.cursortimeout = 0;
- }
- if (!self.rail) return;
- if (self.autohidedom) {
- self.autohidedom.stop().css({
- opacity: self.opt.cursoropacitymax
- });
- self.cursoractive = true;
- }
-
- if (!self.rail.drag || self.rail.drag.pt != 1) {
- if ((typeof py != "undefined") && (py !== false)) {
- self.scroll.y = Math.round(py * 1 / self.scrollratio.y);
- }
- if (typeof px != "undefined") {
- self.scroll.x = Math.round(px * 1 / self.scrollratio.x);
- }
- }
-
- self.cursor.css({
- height: self.cursorheight,
- top: self.scroll.y
- });
- if (self.cursorh) {
- var lx = (self.hasreversehr) ? self.scrollvaluemaxw-self.scroll.x : self.scroll.x;
- (!self.rail.align && self.rail.visibility) ? self.cursorh.css({
- width: self.cursorwidth,
- left: lx + self.rail.width
- }): self.cursorh.css({
- width: self.cursorwidth,
- left: lx
- });
- self.cursoractive = true;
- }
-
- if (self.zoom) self.zoom.stop().css({
- opacity: self.opt.cursoropacitymax
- });
- };
-
- this.hideCursor = function(tm) {
- if (self.cursortimeout) return;
- if (!self.rail) return;
- if (!self.autohidedom) return;
- if (self.hasmousefocus && self.opt.autohidemode == "leave") return;
- self.cursortimeout = setTimeout(function() {
- if (!self.rail.active || !self.showonmouseevent) {
- self.autohidedom.stop().animate({
- opacity: self.opt.cursoropacitymin
- });
- if (self.zoom) self.zoom.stop().animate({
- opacity: self.opt.cursoropacitymin
- });
- self.cursoractive = false;
- }
- self.cursortimeout = 0;
- }, tm || self.opt.hidecursordelay);
- };
-
- this.noticeCursor = function(tm, py, px) {
- self.showCursor(py, px);
- if (!self.rail.active) self.hideCursor(tm);
- };
-
- this.getContentSize =
- (self.ispage) ?
- function() {
- return {
- w: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth),
- h: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
- }
- } : (self.haswrapper) ?
- function() {
- return {
- w: self.doc.outerWidth() + parseInt(self.win.css('paddingLeft')) + parseInt(self.win.css('paddingRight')),
- h: self.doc.outerHeight() + parseInt(self.win.css('paddingTop')) + parseInt(self.win.css('paddingBottom'))
- }
- } : function() {
- return {
- w: self.docscroll[0].scrollWidth,
- h: self.docscroll[0].scrollHeight
- }
- };
-
- this.onResize = function(e, page) {
-
- if (!self || !self.win) return false;
-
- if (!self.haswrapper && !self.ispage) {
- if (self.win.css('display') == 'none') {
- if (self.visibility) self.hideRail().hideRailHr();
- return false;
- } else {
- if (!self.hidden && !self.visibility) self.showRail().showRailHr();
- }
- }
-
- var premaxh = self.page.maxh;
- var premaxw = self.page.maxw;
-
- var preview = {
- h: self.view.h,
- w: self.view.w
- };
-
- self.view = {
- w: (self.ispage) ? self.win.width() : parseInt(self.win[0].clientWidth),
- h: (self.ispage) ? self.win.height() : parseInt(self.win[0].clientHeight)
- };
-
- self.page = (page) ? page : self.getContentSize();
-
- self.page.maxh = Math.max(0, self.page.h - self.view.h);
- self.page.maxw = Math.max(0, self.page.w - self.view.w);
-
- if ((self.page.maxh == premaxh) && (self.page.maxw == premaxw) && (self.view.w == preview.w) && (self.view.h == preview.h)) {
- // test position
- if (!self.ispage) {
- var pos = self.win.offset();
- if (self.lastposition) {
- var lst = self.lastposition;
- if ((lst.top == pos.top) && (lst.left == pos.left)) return self; //nothing to do
- }
- self.lastposition = pos;
- } else {
- return self; //nothing to do
- }
- }
-
- if (self.page.maxh == 0) {
- self.hideRail();
- self.scrollvaluemax = 0;
- self.scroll.y = 0;
- self.scrollratio.y = 0;
- self.cursorheight = 0;
- self.setScrollTop(0);
- self.rail.scrollable = false;
- } else {
- self.page.maxh -= (self.opt.railpadding.top + self.opt.railpadding.bottom); //**
- self.rail.scrollable = true;
- }
-
- if (self.page.maxw == 0) {
- self.hideRailHr();
- self.scrollvaluemaxw = 0;
- self.scroll.x = 0;
- self.scrollratio.x = 0;
- self.cursorwidth = 0;
- self.setScrollLeft(0);
- self.railh.scrollable = false;
- } else {
- self.page.maxw -= (self.opt.railpadding.left + self.opt.railpadding.right); //**
- self.railh.scrollable = true;
- }
-
- self.railslocked = (self.locked) || ((self.page.maxh == 0) && (self.page.maxw == 0));
- if (self.railslocked) {
- if (!self.ispage) self.updateScrollBar(self.view);
- return false;
- }
-
- if (!self.hidden && !self.visibility) {
- self.showRail().showRailHr();
- }
- else if (!self.hidden && !self.railh.visibility) self.showRailHr();
-
- if (self.istextarea && self.win.css('resize') && self.win.css('resize') != 'none') self.view.h -= 20;
-
- self.cursorheight = Math.min(self.view.h, Math.round(self.view.h * (self.view.h / self.page.h)));
- self.cursorheight = (self.opt.cursorfixedheight) ? self.opt.cursorfixedheight : Math.max(self.opt.cursorminheight, self.cursorheight);
-
- self.cursorwidth = Math.min(self.view.w, Math.round(self.view.w * (self.view.w / self.page.w)));
- self.cursorwidth = (self.opt.cursorfixedheight) ? self.opt.cursorfixedheight : Math.max(self.opt.cursorminheight, self.cursorwidth);
-
- self.scrollvaluemax = self.view.h - self.cursorheight - self.cursor.hborder - (self.opt.railpadding.top + self.opt.railpadding.bottom); //**
-
- if (self.railh) {
- self.railh.width = (self.page.maxh > 0) ? (self.view.w - self.rail.width) : self.view.w;
- self.scrollvaluemaxw = self.railh.width - self.cursorwidth - self.cursorh.wborder - (self.opt.railpadding.left + self.opt.railpadding.right); //**
- }
-
- /*
- if (self.checkrtlmode&&self.railh) {
- self.checkrtlmode = false;
- if (self.opt.rtlmode&&self.scroll.x==0) self.setScrollLeft(self.page.maxw);
- }
-*/
-
- if (!self.ispage) self.updateScrollBar(self.view);
-
- self.scrollratio = {
- x: (self.page.maxw / self.scrollvaluemaxw),
- y: (self.page.maxh / self.scrollvaluemax)
- };
-
- var sy = self.getScrollTop();
- if (sy > self.page.maxh) {
- self.doScrollTop(self.page.maxh);
- } else {
- self.scroll.y = Math.round(self.getScrollTop() * (1 / self.scrollratio.y));
- self.scroll.x = Math.round(self.getScrollLeft() * (1 / self.scrollratio.x));
- if (self.cursoractive) self.noticeCursor();
- }
-
- if (self.scroll.y && (self.getScrollTop() == 0)) self.doScrollTo(Math.floor(self.scroll.y * self.scrollratio.y));
-
- return self;
- };
-
- this.resize = self.onResize;
-
- this.lazyResize = function(tm) { // event debounce
- tm = (isNaN(tm)) ? 30 : tm;
- self.debounced('resize', self.resize, tm);
- return self;
- };
-
- // modified by MDN https://developer.mozilla.org/en-US/docs/DOM/Mozilla_event_reference/wheel
- function _modernWheelEvent(dom, name, fn, bubble) {
- self._bind(dom, name, function(e) {
- var e = (e) ? e : window.event;
- var event = {
- original: e,
- target: e.target || e.srcElement,
- type: "wheel",
- deltaMode: e.type == "MozMousePixelScroll" ? 0 : 1,
- deltaX: 0,
- deltaZ: 0,
- preventDefault: function() {
- e.preventDefault ? e.preventDefault() : e.returnValue = false;
- return false;
- },
- stopImmediatePropagation: function() {
- (e.stopImmediatePropagation) ? e.stopImmediatePropagation(): e.cancelBubble = true;
- }
- };
-
- if (name == "mousewheel") {
- event.deltaY = -1 / 40 * e.wheelDelta;
- e.wheelDeltaX && (event.deltaX = -1 / 40 * e.wheelDeltaX);
- } else {
- event.deltaY = e.detail;
- }
-
- return fn.call(dom, event);
- }, bubble);
- };
-
-
-
- this.jqbind = function(dom, name, fn) { // use jquery bind for non-native events (mouseenter/mouseleave)
- self.events.push({
- e: dom,
- n: name,
- f: fn,
- q: true
- });
- $(dom).bind(name, fn);
- };
-
- this.bind = function(dom, name, fn, bubble) { // touch-oriented & fixing jquery bind
- var el = ("jquery" in dom) ? dom[0] : dom;
-
- if (name == 'mousewheel') {
- if (window.addEventListener||'onwheel' in document) { // modern brosers & IE9 detection fix
- self._bind(el, "wheel", fn, bubble || false);
- } else {
- var wname = (typeof document.onmousewheel != "undefined") ? "mousewheel" : "DOMMouseScroll"; // older IE/Firefox
- _modernWheelEvent(el, wname, fn, bubble || false);
- if (wname == "DOMMouseScroll") _modernWheelEvent(el, "MozMousePixelScroll", fn, bubble || false); // Firefox legacy
- }
- } else if (el.addEventListener) {
- if (cap.cantouch && /mouseup|mousedown|mousemove/.test(name)) { // touch device support
- var tt = (name == 'mousedown') ? 'touchstart' : (name == 'mouseup') ? 'touchend' : 'touchmove';
- self._bind(el, tt, function(e) {
- if (e.touches) {
- if (e.touches.length < 2) {
- var ev = (e.touches.length) ? e.touches[0] : e;
- ev.original = e;
- fn.call(this, ev);
- }
- } else if (e.changedTouches) {
- var ev = e.changedTouches[0];
- ev.original = e;
- fn.call(this, ev);
- } //blackberry
- }, bubble || false);
- }
- self._bind(el, name, fn, bubble || false);
- if (cap.cantouch && name == "mouseup") self._bind(el, "touchcancel", fn, bubble || false);
- } else {
- self._bind(el, name, function(e) {
- e = e || window.event || false;
- if (e) {
- if (e.srcElement) e.target = e.srcElement;
- }
- if (!("pageY" in e)) {
- e.pageX = e.clientX + document.documentElement.scrollLeft;
- e.pageY = e.clientY + document.documentElement.scrollTop;
- }
- return ((fn.call(el, e) === false) || bubble === false) ? self.cancelEvent(e) : true;
- });
- }
- };
-
- if (cap.haseventlistener) { // W3C standard model
- this._bind = function(el, name, fn, bubble) { // primitive bind
- self.events.push({
- e: el,
- n: name,
- f: fn,
- b: bubble,
- q: false
- });
- el.addEventListener(name, fn, bubble || false);
- };
- this.cancelEvent = function(e) {
- if (!e) return false;
- var e = (e.original) ? e.original : e;
- e.preventDefault();
- e.stopPropagation();
- if (e.preventManipulation) e.preventManipulation(); //IE10
- return false;
- };
- this.stopPropagation = function(e) {
- if (!e) return false;
- var e = (e.original) ? e.original : e;
- e.stopPropagation();
- return false;
- };
- this._unbind = function(el, name, fn, bub) { // primitive unbind
- el.removeEventListener(name, fn, bub);
- };
- } else { // old IE model
- this._bind = function(el, name, fn, bubble) { // primitive bind
- self.events.push({
- e: el,
- n: name,
- f: fn,
- b: bubble,
- q: false
- });
- if (el.attachEvent) {
- el.attachEvent("on" + name, fn);
- } else {
- el["on" + name] = fn;
- }
- };
- // Thanks to http://www.switchonthecode.com !!
- this.cancelEvent = function(e) {
- var e = window.event || false;
- if (!e) return false;
- e.cancelBubble = true;
- e.cancel = true;
- e.returnValue = false;
- return false;
- };
- this.stopPropagation = function(e) {
- var e = window.event || false;
- if (!e) return false;
- e.cancelBubble = true;
- return false;
- };
- this._unbind = function(el, name, fn, bub) { // primitive unbind IE old
- if (el.detachEvent) {
- el.detachEvent('on' + name, fn);
- } else {
- el['on' + name] = false;
- }
- };
- }
-
- this.unbindAll = function() {
- for (var a = 0; a < self.events.length; a++) {
- var r = self.events[a];
- (r.q) ? r.e.unbind(r.n, r.f): self._unbind(r.e, r.n, r.f, r.b);
- }
- };
-
- this.showRail = function() {
- if ((self.page.maxh != 0) && (self.ispage || self.win.css('display') != 'none')) {
- self.visibility = true;
- self.rail.visibility = true;
- self.rail.css('display', 'block');
- }
- return self;
- };
-
- this.showRailHr = function() {
- if (!self.railh) return self;
- if ((self.page.maxw != 0) && (self.ispage || self.win.css('display') != 'none')) {
- self.railh.visibility = true;
- self.railh.css('display', 'block');
- }
- return self;
- };
-
- this.hideRail = function() {
- self.visibility = false;
- self.rail.visibility = false;
- self.rail.css('display', 'none');
- return self;
- };
-
- this.hideRailHr = function() {
- if (!self.railh) return self;
- self.railh.visibility = false;
- self.railh.css('display', 'none');
- return self;
- };
-
- this.show = function() {
- self.hidden = false;
- self.railslocked = false;
- return self.showRail().showRailHr();
- };
-
- this.hide = function() {
- self.hidden = true;
- self.railslocked = true;
- return self.hideRail().hideRailHr();
- };
-
- this.toggle = function() {
- return (self.hidden) ? self.show() : self.hide();
- };
-
- this.remove = function() {
- self.stop();
- if (self.cursortimeout) clearTimeout(self.cursortimeout);
- self.doZoomOut();
- self.unbindAll();
-
- if (cap.isie9) self.win[0].detachEvent("onpropertychange", self.onAttributeChange); //IE9 DOMAttrModified bug
-
- if (self.observer !== false) self.observer.disconnect();
- if (self.observerremover !== false) self.observerremover.disconnect();
- if (self.observerbody !== false) self.observerbody.disconnect();
-
- self.events = null;
-
- if (self.cursor) {
- self.cursor.remove();
- }
- if (self.cursorh) {
- self.cursorh.remove();
- }
- if (self.rail) {
- self.rail.remove();
- }
- if (self.railh) {
- self.railh.remove();
- }
- if (self.zoom) {
- self.zoom.remove();
- }
- for (var a = 0; a < self.saved.css.length; a++) {
- var d = self.saved.css[a];
- d[0].css(d[1], (typeof d[2] == "undefined") ? '' : d[2]);
- }
- self.saved = false;
- self.me.data('__nicescroll', ''); //erase all traces
-
- // memory leak fixed by GianlucaGuarini - thanks a lot!
- // remove the current nicescroll from the $.nicescroll array & normalize array
- var lst = $.nicescroll;
- lst.each(function(i) {
- if (!this) return;
- if (this.id === self.id) {
- delete lst[i];
- for (var b = ++i; b < lst.length; b++, i++) lst[i] = lst[b];
- lst.length--;
- if (lst.length) delete lst[lst.length];
- }
- });
-
- for (var i in self) {
- self[i] = null;
- delete self[i];
- }
-
- self = null;
-
- };
-
- this.scrollstart = function(fn) {
- this.onscrollstart = fn;
- return self;
- };
- this.scrollend = function(fn) {
- this.onscrollend = fn;
- return self;
- };
- this.scrollcancel = function(fn) {
- this.onscrollcancel = fn;
- return self;
- };
-
- this.zoomin = function(fn) {
- this.onzoomin = fn;
- return self;
- };
- this.zoomout = function(fn) {
- this.onzoomout = fn;
- return self;
- };
-
- this.isScrollable = function(e) {
- var dom = (e.target) ? e.target : e;
- if (dom.nodeName == 'OPTION') return true;
- while (dom && (dom.nodeType == 1) && !(/^BODY|HTML/.test(dom.nodeName))) {
- var dd = $(dom);
- var ov = dd.css('overflowY') || dd.css('overflowX') || dd.css('overflow') || '';
- if (/scroll|auto/.test(ov)) return (dom.clientHeight != dom.scrollHeight);
- dom = (dom.parentNode) ? dom.parentNode : false;
- }
- return false;
- };
-
- this.getViewport = function(me) {
- var dom = (me && me.parentNode) ? me.parentNode : false;
- while (dom && (dom.nodeType == 1) && !(/^BODY|HTML/.test(dom.nodeName))) {
- var dd = $(dom);
- if (/fixed|absolute/.test(dd.css("position"))) return dd;
- var ov = dd.css('overflowY') || dd.css('overflowX') || dd.css('overflow') || '';
- if ((/scroll|auto/.test(ov)) && (dom.clientHeight != dom.scrollHeight)) return dd;
- if (dd.getNiceScroll().length > 0) return dd;
- dom = (dom.parentNode) ? dom.parentNode : false;
- }
- return false; //(dom) ? $(dom) : false;
- };
-
- this.triggerScrollEnd = function() {
- if (!self.onscrollend) return;
-
- var px = self.getScrollLeft();
- var py = self.getScrollTop();
-
- var info = {
- "type": "scrollend",
- "current": {
- "x": px,
- "y": py
- },
- "end": {
- "x": px,
- "y": py
- }
- };
- self.onscrollend.call(self, info);
- }
-
- function execScrollWheel(e, hr, chkscroll) {
- var px, py;
-
- if (e.deltaMode == 0) { // PIXEL
- px = -Math.floor(e.deltaX * (self.opt.mousescrollstep / (18 * 3)));
- py = -Math.floor(e.deltaY * (self.opt.mousescrollstep / (18 * 3)));
- } else if (e.deltaMode == 1) { // LINE
- px = -Math.floor(e.deltaX * self.opt.mousescrollstep);
- py = -Math.floor(e.deltaY * self.opt.mousescrollstep);
- }
-
- if (hr && self.opt.oneaxismousemode && (px == 0) && py) { // classic vertical-only mousewheel + browser with x/y support
- px = py;
- py = 0;
-
- if (chkscroll) {
- var hrend = (px < 0) ? (self.getScrollLeft() >= self.page.maxw) : (self.getScrollLeft() <= 0);
- if (hrend) { // preserve vertical scrolling
- py = px;
- px = 0;
- }
- }
-
- }
-
- if (px) {
- if (self.scrollmom) {
- self.scrollmom.stop()
- }
- self.lastdeltax += px;
- self.debounced("mousewheelx", function() {
- var dt = self.lastdeltax;
- self.lastdeltax = 0;
- if (!self.rail.drag) {
- self.doScrollLeftBy(dt)
- }
- }, 15);
- }
- if (py) {
- if (self.opt.nativeparentscrolling && chkscroll && !self.ispage && !self.zoomactive) {
- if (py < 0) {
- if (self.getScrollTop() >= self.page.maxh) return true;
- } else {
- if (self.getScrollTop() <= 0) return true;
- }
- }
- if (self.scrollmom) {
- self.scrollmom.stop()
- }
- self.lastdeltay += py;
- self.debounced("mousewheely", function() {
- var dt = self.lastdeltay;
- self.lastdeltay = 0;
- if (!self.rail.drag) {
- self.doScrollBy(dt)
- }
- }, 15);
- }
-
- e.stopImmediatePropagation();
- return e.preventDefault();
- };
-
- this.onmousewheel = function(e) {
- if (self.wheelprevented) return;
- if (self.railslocked) {
- self.debounced("checkunlock", self.resize, 250);
- return true;
- }
- if (self.rail.drag) return self.cancelEvent(e);
-
- if (self.opt.oneaxismousemode == "auto" && e.deltaX != 0) self.opt.oneaxismousemode = false; // check two-axis mouse support (not very elegant)
-
- if (self.opt.oneaxismousemode && e.deltaX == 0) {
- if (!self.rail.scrollable) {
- if (self.railh && self.railh.scrollable) {
- return self.onmousewheelhr(e);
- } else {
- return true;
- }
- }
- }
-
- var nw = +(new Date());
- var chk = false;
- if (self.opt.preservenativescrolling && ((self.checkarea + 600) < nw)) {
- self.nativescrollingarea = self.isScrollable(e);
- chk = true;
- }
- self.checkarea = nw;
- if (self.nativescrollingarea) return true; // this isn't my business
- var ret = execScrollWheel(e, false, chk);
- if (ret) self.checkarea = 0;
- return ret;
- };
-
- this.onmousewheelhr = function(e) {
- if (self.wheelprevented) return;
- if (self.railslocked || !self.railh.scrollable) return true;
- if (self.rail.drag) return self.cancelEvent(e);
-
- var nw = +(new Date());
- var chk = false;
- if (self.opt.preservenativescrolling && ((self.checkarea + 600) < nw)) {
- self.nativescrollingarea = self.isScrollable(e);
- chk = true;
- }
- self.checkarea = nw;
- if (self.nativescrollingarea) return true; // this isn't my business
- if (self.railslocked) return self.cancelEvent(e);
-
- return execScrollWheel(e, true, chk);
- };
-
- this.stop = function() {
- self.cancelScroll();
- if (self.scrollmon) self.scrollmon.stop();
- self.cursorfreezed = false;
- self.scroll.y = Math.round(self.getScrollTop() * (1 / self.scrollratio.y));
- self.noticeCursor();
- return self;
- };
-
- this.getTransitionSpeed = function(dif) {
- var sp = Math.round(self.opt.scrollspeed * 10);
- var ex = Math.min(sp, Math.round((dif / 20) * self.opt.scrollspeed));
- return (ex > 20) ? ex : 0;
- };
-
- if (!self.opt.smoothscroll) {
- this.doScrollLeft = function(x, spd) { //direct
- var y = self.getScrollTop();
- self.doScrollPos(x, y, spd);
- };
- this.doScrollTop = function(y, spd) { //direct
- var x = self.getScrollLeft();
- self.doScrollPos(x, y, spd);
- };
- this.doScrollPos = function(x, y, spd) { //direct
- var nx = (x > self.page.maxw) ? self.page.maxw : x;
- if (nx < 0) nx = 0;
- var ny = (y > self.page.maxh) ? self.page.maxh : y;
- if (ny < 0) ny = 0;
- self.synched('scroll', function() {
- self.setScrollTop(ny);
- self.setScrollLeft(nx);
- });
- };
- this.cancelScroll = function() {}; // direct
- } else if (self.ishwscroll && cap.hastransition && self.opt.usetransition && !!self.opt.smoothscroll) {
- this.prepareTransition = function(dif, istime) {
- var ex = (istime) ? ((dif > 20) ? dif : 0) : self.getTransitionSpeed(dif);
- var trans = (ex) ? cap.prefixstyle + 'transform ' + ex + 'ms ease-out' : '';
- if (!self.lasttransitionstyle || self.lasttransitionstyle != trans) {
- self.lasttransitionstyle = trans;
- self.doc.css(cap.transitionstyle, trans);
- }
- return ex;
- };
-
- this.doScrollLeft = function(x, spd) { //trans
- var y = (self.scrollrunning) ? self.newscrolly : self.getScrollTop();
- self.doScrollPos(x, y, spd);
- };
-
- this.doScrollTop = function(y, spd) { //trans
- var x = (self.scrollrunning) ? self.newscrollx : self.getScrollLeft();
- self.doScrollPos(x, y, spd);
- };
-
- this.doScrollPos = function(x, y, spd) { //trans
-
- var py = self.getScrollTop();
- var px = self.getScrollLeft();
-
- if (((self.newscrolly - py) * (y - py) < 0) || ((self.newscrollx - px) * (x - px) < 0)) self.cancelScroll(); //inverted movement detection
-
- if (self.opt.bouncescroll == false) {
- if (y < 0) y = 0;
- else if (y > self.page.maxh) y = self.page.maxh;
- if (x < 0) x = 0;
- else if (x > self.page.maxw) x = self.page.maxw;
- }
-
- if (self.scrollrunning && x == self.newscrollx && y == self.newscrolly) return false;
-
- self.newscrolly = y;
- self.newscrollx = x;
-
- self.newscrollspeed = spd || false;
-
- if (self.timer) return false;
-
- self.timer = setTimeout(function() {
-
- var top = self.getScrollTop();
- var lft = self.getScrollLeft();
-
- var dst = {};
- dst.x = x - lft;
- dst.y = y - top;
- dst.px = lft;
- dst.py = top;
-
- var dd = Math.round(Math.sqrt(Math.pow(dst.x, 2) + Math.pow(dst.y, 2)));
- var ms = (self.newscrollspeed && self.newscrollspeed > 1) ? self.newscrollspeed : self.getTransitionSpeed(dd);
- if (self.newscrollspeed && self.newscrollspeed <= 1) ms *= self.newscrollspeed;
-
- self.prepareTransition(ms, true);
-
- if (self.timerscroll && self.timerscroll.tm) clearInterval(self.timerscroll.tm);
-
- if (ms > 0) {
-
- if (!self.scrollrunning && self.onscrollstart) {
- var info = {
- "type": "scrollstart",
- "current": {
- "x": lft,
- "y": top
- },
- "request": {
- "x": x,
- "y": y
- },
- "end": {
- "x": self.newscrollx,
- "y": self.newscrolly
- },
- "speed": ms
- };
- self.onscrollstart.call(self, info);
- }
-
- if (cap.transitionend) {
- if (!self.scrollendtrapped) {
- self.scrollendtrapped = true;
- self.bind(self.doc, cap.transitionend, self.onScrollTransitionEnd, false); //I have got to do something usefull!!
- }
- } else {
- if (self.scrollendtrapped) clearTimeout(self.scrollendtrapped);
- self.scrollendtrapped = setTimeout(self.onScrollTransitionEnd, ms); // simulate transitionend event
- }
-
- var py = top;
- var px = lft;
- self.timerscroll = {
- bz: new BezierClass(py, self.newscrolly, ms, 0, 0, 0.58, 1),
- bh: new BezierClass(px, self.newscrollx, ms, 0, 0, 0.58, 1)
- };
- if (!self.cursorfreezed) self.timerscroll.tm = setInterval(function() {
- self.showCursor(self.getScrollTop(), self.getScrollLeft())
- }, 60);
-
- }
-
- self.synched("doScroll-set", function() {
- self.timer = 0;
- if (self.scrollendtrapped) self.scrollrunning = true;
- self.setScrollTop(self.newscrolly);
- self.setScrollLeft(self.newscrollx);
- if (!self.scrollendtrapped) self.onScrollTransitionEnd();
- });
-
-
- }, 50);
-
- };
-
- this.cancelScroll = function() {
- if (!self.scrollendtrapped) return true;
- var py = self.getScrollTop();
- var px = self.getScrollLeft();
- self.scrollrunning = false;
- if (!cap.transitionend) clearTimeout(cap.transitionend);
- self.scrollendtrapped = false;
- self._unbind(self.doc[0], cap.transitionend, self.onScrollTransitionEnd);
- self.prepareTransition(0);
- self.setScrollTop(py); // fire event onscroll
- if (self.railh) self.setScrollLeft(px);
- if (self.timerscroll && self.timerscroll.tm) clearInterval(self.timerscroll.tm);
- self.timerscroll = false;
-
- self.cursorfreezed = false;
-
- self.showCursor(py, px);
- return self;
- };
- this.onScrollTransitionEnd = function() {
- if (self.scrollendtrapped) self._unbind(self.doc[0], cap.transitionend, self.onScrollTransitionEnd);
- self.scrollendtrapped = false;
- self.prepareTransition(0);
- if (self.timerscroll && self.timerscroll.tm) clearInterval(self.timerscroll.tm);
- self.timerscroll = false;
- var py = self.getScrollTop();
- var px = self.getScrollLeft();
- self.setScrollTop(py); // fire event onscroll
- if (self.railh) self.setScrollLeft(px); // fire event onscroll left
-
- self.noticeCursor(false, py, px);
-
- self.cursorfreezed = false;
-
- if (py < 0) py = 0
- else if (py > self.page.maxh) py = self.page.maxh;
- if (px < 0) px = 0
- else if (px > self.page.maxw) px = self.page.maxw;
- if ((py != self.newscrolly) || (px != self.newscrollx)) return self.doScrollPos(px, py, self.opt.snapbackspeed);
-
- if (self.onscrollend && self.scrollrunning) {
- self.triggerScrollEnd();
- }
- self.scrollrunning = false;
-
- };
-
- } else {
-
- this.doScrollLeft = function(x, spd) { //no-trans
- var y = (self.scrollrunning) ? self.newscrolly : self.getScrollTop();
- self.doScrollPos(x, y, spd);
- };
-
- this.doScrollTop = function(y, spd) { //no-trans
- var x = (self.scrollrunning) ? self.newscrollx : self.getScrollLeft();
- self.doScrollPos(x, y, spd);
- };
-
- this.doScrollPos = function(x, y, spd) { //no-trans
- var y = ((typeof y == "undefined") || (y === false)) ? self.getScrollTop(true) : y;
-
- if ((self.timer) && (self.newscrolly == y) && (self.newscrollx == x)) return true;
-
- if (self.timer) clearAnimationFrame(self.timer);
- self.timer = 0;
-
- var py = self.getScrollTop();
- var px = self.getScrollLeft();
-
- if (((self.newscrolly - py) * (y - py) < 0) || ((self.newscrollx - px) * (x - px) < 0)) self.cancelScroll(); //inverted movement detection
-
- self.newscrolly = y;
- self.newscrollx = x;
-
- if (!self.bouncescroll || !self.rail.visibility) {
- if (self.newscrolly < 0) {
- self.newscrolly = 0;
- } else if (self.newscrolly > self.page.maxh) {
- self.newscrolly = self.page.maxh;
- }
- }
- if (!self.bouncescroll || !self.railh.visibility) {
- if (self.newscrollx < 0) {
- self.newscrollx = 0;
- } else if (self.newscrollx > self.page.maxw) {
- self.newscrollx = self.page.maxw;
- }
- }
-
- self.dst = {};
- self.dst.x = x - px;
- self.dst.y = y - py;
- self.dst.px = px;
- self.dst.py = py;
-
- var dst = Math.round(Math.sqrt(Math.pow(self.dst.x, 2) + Math.pow(self.dst.y, 2)));
-
- self.dst.ax = self.dst.x / dst;
- self.dst.ay = self.dst.y / dst;
-
- var pa = 0;
- var pe = dst;
-
- if (self.dst.x == 0) {
- pa = py;
- pe = y;
- self.dst.ay = 1;
- self.dst.py = 0;
- } else if (self.dst.y == 0) {
- pa = px;
- pe = x;
- self.dst.ax = 1;
- self.dst.px = 0;
- }
-
- var ms = self.getTransitionSpeed(dst);
- if (spd && spd <= 1) ms *= spd;
- if (ms > 0) {
- self.bzscroll = (self.bzscroll) ? self.bzscroll.update(pe, ms) : new BezierClass(pa, pe, ms, 0, 1, 0, 1);
- } else {
- self.bzscroll = false;
- }
-
- if (self.timer) return;
-
- if ((py == self.page.maxh && y >= self.page.maxh) || (px == self.page.maxw && x >= self.page.maxw)) self.checkContentSize();
-
- var sync = 1;
-
- function scrolling() {
- if (self.cancelAnimationFrame) return true;
-
- self.scrollrunning = true;
-
- sync = 1 - sync;
- if (sync) return (self.timer = setAnimationFrame(scrolling) || 1);
-
- var done = 0;
- var sx, sy;
-
- var sc = sy = self.getScrollTop();
- if (self.dst.ay) {
- sc = (self.bzscroll) ? self.dst.py + (self.bzscroll.getNow() * self.dst.ay) : self.newscrolly;
- var dr = sc - sy;
- if ((dr < 0 && sc < self.newscrolly) || (dr > 0 && sc > self.newscrolly)) sc = self.newscrolly;
- self.setScrollTop(sc);
- if (sc == self.newscrolly) done = 1;
- } else {
- done = 1;
- }
-
- var scx = sx = self.getScrollLeft();
- if (self.dst.ax) {
- scx = (self.bzscroll) ? self.dst.px + (self.bzscroll.getNow() * self.dst.ax) : self.newscrollx;
- var dr = scx - sx;
- if ((dr < 0 && scx < self.newscrollx) || (dr > 0 && scx > self.newscrollx)) scx = self.newscrollx;
- self.setScrollLeft(scx);
- if (scx == self.newscrollx) done += 1;
- } else {
- done += 1;
- }
-
- if (done == 2) {
- self.timer = 0;
- self.cursorfreezed = false;
- self.bzscroll = false;
- self.scrollrunning = false;
- if (sc < 0) sc = 0;
- else if (sc > self.page.maxh) sc = self.page.maxh;
- if (scx < 0) scx = 0;
- else if (scx > self.page.maxw) scx = self.page.maxw;
- if ((scx != self.newscrollx) || (sc != self.newscrolly)) self.doScrollPos(scx, sc);
- else {
- if (self.onscrollend) {
- self.triggerScrollEnd();
- }
- }
- } else {
- self.timer = setAnimationFrame(scrolling) || 1;
- }
- };
- self.cancelAnimationFrame = false;
- self.timer = 1;
-
- if (self.onscrollstart && !self.scrollrunning) {
- var info = {
- "type": "scrollstart",
- "current": {
- "x": px,
- "y": py
- },
- "request": {
- "x": x,
- "y": y
- },
- "end": {
- "x": self.newscrollx,
- "y": self.newscrolly
- },
- "speed": ms
- };
- self.onscrollstart.call(self, info);
- }
-
- scrolling();
-
- if ((py == self.page.maxh && y >= py) || (px == self.page.maxw && x >= px)) self.checkContentSize();
-
- self.noticeCursor();
- };
-
- this.cancelScroll = function() {
- if (self.timer) clearAnimationFrame(self.timer);
- self.timer = 0;
- self.bzscroll = false;
- self.scrollrunning = false;
- return self;
- };
-
- }
-
- this.doScrollBy = function(stp, relative) {
- var ny = 0;
- if (relative) {
- ny = Math.floor((self.scroll.y - stp) * self.scrollratio.y)
- } else {
- var sy = (self.timer) ? self.newscrolly : self.getScrollTop(true);
- ny = sy - stp;
- }
- if (self.bouncescroll) {
- var haf = Math.round(self.view.h / 2);
- if (ny < -haf) ny = -haf
- else if (ny > (self.page.maxh + haf)) ny = (self.page.maxh + haf);
- }
- self.cursorfreezed = false;
-
- var py = self.getScrollTop(true);
- if (ny < 0 && py <= 0) return self.noticeCursor();
- else if (ny > self.page.maxh && py >= self.page.maxh) {
- self.checkContentSize();
- return self.noticeCursor();
- }
-
- self.doScrollTop(ny);
- };
-
- this.doScrollLeftBy = function(stp, relative) {
- var nx = 0;
- if (relative) {
- nx = Math.floor((self.scroll.x - stp) * self.scrollratio.x)
- } else {
- var sx = (self.timer) ? self.newscrollx : self.getScrollLeft(true);
- nx = sx - stp;
- }
- if (self.bouncescroll) {
- var haf = Math.round(self.view.w / 2);
- if (nx < -haf) nx = -haf;
- else if (nx > (self.page.maxw + haf)) nx = (self.page.maxw + haf);
- }
- self.cursorfreezed = false;
-
- var px = self.getScrollLeft(true);
- if (nx < 0 && px <= 0) return self.noticeCursor();
- else if (nx > self.page.maxw && px >= self.page.maxw) return self.noticeCursor();
-
- self.doScrollLeft(nx);
- };
-
- this.doScrollTo = function(pos, relative) {
- var ny = (relative) ? Math.round(pos * self.scrollratio.y) : pos;
- if (ny < 0) ny = 0;
- else if (ny > self.page.maxh) ny = self.page.maxh;
- self.cursorfreezed = false;
- self.doScrollTop(pos);
- };
-
- this.checkContentSize = function() {
- var pg = self.getContentSize();
- if ((pg.h != self.page.h) || (pg.w != self.page.w)) self.resize(false, pg);
- };
-
- self.onscroll = function(e) {
- if (self.rail.drag) return;
- if (!self.cursorfreezed) {
- self.synched('scroll', function() {
- self.scroll.y = Math.round(self.getScrollTop() * (1 / self.scrollratio.y));
- if (self.railh) self.scroll.x = Math.round(self.getScrollLeft() * (1 / self.scrollratio.x));
- self.noticeCursor();
- });
- }
- };
- self.bind(self.docscroll, "scroll", self.onscroll);
-
- this.doZoomIn = function(e) {
- if (self.zoomactive) return;
- self.zoomactive = true;
-
- self.zoomrestore = {
- style: {}
- };
- var lst = ['position', 'top', 'left', 'zIndex', 'backgroundColor', 'marginTop', 'marginBottom', 'marginLeft', 'marginRight'];
- var win = self.win[0].style;
- for (var a in lst) {
- var pp = lst[a];
- self.zoomrestore.style[pp] = (typeof win[pp] != "undefined") ? win[pp] : '';
- }
-
- self.zoomrestore.style.width = self.win.css('width');
- self.zoomrestore.style.height = self.win.css('height');
-
- self.zoomrestore.padding = {
- w: self.win.outerWidth() - self.win.width(),
- h: self.win.outerHeight() - self.win.height()
- };
-
- if (cap.isios4) {
- self.zoomrestore.scrollTop = $(window).scrollTop();
- $(window).scrollTop(0);
- }
-
- self.win.css({
- "position": (cap.isios4) ? "absolute" : "fixed",
- "top": 0,
- "left": 0,
- "z-index": globalmaxzindex + 100,
- "margin": "0px"
- });
- var bkg = self.win.css("backgroundColor");
- if (bkg == "" || /transparent|rgba\(0, 0, 0, 0\)|rgba\(0,0,0,0\)/.test(bkg)) self.win.css("backgroundColor", "#fff");
- self.rail.css({
- "z-index": globalmaxzindex + 101
- });
- self.zoom.css({
- "z-index": globalmaxzindex + 102
- });
- self.zoom.css('backgroundPosition', '0px -18px');
- self.resizeZoom();
-
- if (self.onzoomin) self.onzoomin.call(self);
-
- return self.cancelEvent(e);
- };
-
- this.doZoomOut = function(e) {
- if (!self.zoomactive) return;
- self.zoomactive = false;
-
- self.win.css("margin", "");
- self.win.css(self.zoomrestore.style);
-
- if (cap.isios4) {
- $(window).scrollTop(self.zoomrestore.scrollTop);
- }
-
- self.rail.css({
- "z-index": self.zindex
- });
- self.zoom.css({
- "z-index": self.zindex
- });
- self.zoomrestore = false;
- self.zoom.css('backgroundPosition', '0px 0px');
- self.onResize();
-
- if (self.onzoomout) self.onzoomout.call(self);
-
- return self.cancelEvent(e);
- };
-
- this.doZoom = function(e) {
- return (self.zoomactive) ? self.doZoomOut(e) : self.doZoomIn(e);
- };
-
- this.resizeZoom = function() {
- if (!self.zoomactive) return;
-
- var py = self.getScrollTop(); //preserve scrolling position
- self.win.css({
- width: $(window).width() - self.zoomrestore.padding.w + "px",
- height: $(window).height() - self.zoomrestore.padding.h + "px"
- });
- self.onResize();
-
- self.setScrollTop(Math.min(self.page.maxh, py));
- };
-
- this.init();
-
- $.nicescroll.push(this);
-
- };
-
- // Inspired by the work of Kin Blas
- // http://webpro.host.adobe.com/people/jblas/momentum/includes/jquery.momentum.0.7.js
-
-
- var ScrollMomentumClass2D = function(nc) {
- var self = this;
- this.nc = nc;
-
- this.lastx = 0;
- this.lasty = 0;
- this.speedx = 0;
- this.speedy = 0;
- this.lasttime = 0;
- this.steptime = 0;
- this.snapx = false;
- this.snapy = false;
- this.demulx = 0;
- this.demuly = 0;
-
- this.lastscrollx = -1;
- this.lastscrolly = -1;
-
- this.chkx = 0;
- this.chky = 0;
-
- this.timer = 0;
-
- this.time = function() {
- return +new Date(); //beautifull hack
- };
-
- this.reset = function(px, py) {
- self.stop();
- var now = self.time();
- self.steptime = 0;
- self.lasttime = now;
- self.speedx = 0;
- self.speedy = 0;
- self.lastx = px;
- self.lasty = py;
- self.lastscrollx = -1;
- self.lastscrolly = -1;
- };
-
- this.update = function(px, py) {
- var now = self.time();
- self.steptime = now - self.lasttime;
- self.lasttime = now;
- var dy = py - self.lasty;
- var dx = px - self.lastx;
- var sy = self.nc.getScrollTop();
- var sx = self.nc.getScrollLeft();
- var newy = sy + dy;
- var newx = sx + dx;
- self.snapx = (newx < 0) || (newx > self.nc.page.maxw);
- self.snapy = (newy < 0) || (newy > self.nc.page.maxh);
- self.speedx = dx;
- self.speedy = dy;
- self.lastx = px;
- self.lasty = py;
- };
-
- this.stop = function() {
- self.nc.unsynched("domomentum2d");
- if (self.timer) clearTimeout(self.timer);
- self.timer = 0;
- self.lastscrollx = -1;
- self.lastscrolly = -1;
- };
-
- this.doSnapy = function(nx, ny) {
- var snap = false;
-
- if (ny < 0) {
- ny = 0;
- snap = true;
- } else if (ny > self.nc.page.maxh) {
- ny = self.nc.page.maxh;
- snap = true;
- }
-
- if (nx < 0) {
- nx = 0;
- snap = true;
- } else if (nx > self.nc.page.maxw) {
- nx = self.nc.page.maxw;
- snap = true;
- }
-
- (snap) ? self.nc.doScrollPos(nx, ny, self.nc.opt.snapbackspeed): self.nc.triggerScrollEnd();
- };
-
- this.doMomentum = function(gp) {
- var t = self.time();
- var l = (gp) ? t + gp : self.lasttime;
-
- var sl = self.nc.getScrollLeft();
- var st = self.nc.getScrollTop();
-
- var pageh = self.nc.page.maxh;
- var pagew = self.nc.page.maxw;
-
- self.speedx = (pagew > 0) ? Math.min(60, self.speedx) : 0;
- self.speedy = (pageh > 0) ? Math.min(60, self.speedy) : 0;
-
- var chk = l && (t - l) <= 60;
-
- if ((st < 0) || (st > pageh) || (sl < 0) || (sl > pagew)) chk = false;
-
- var sy = (self.speedy && chk) ? self.speedy : false;
- var sx = (self.speedx && chk) ? self.speedx : false;
-
- if (sy || sx) {
- var tm = Math.max(16, self.steptime); //timeout granularity
-
- if (tm > 50) { // do smooth
- var xm = tm / 50;
- self.speedx *= xm;
- self.speedy *= xm;
- tm = 50;
- }
-
- self.demulxy = 0;
-
- self.lastscrollx = self.nc.getScrollLeft();
- self.chkx = self.lastscrollx;
- self.lastscrolly = self.nc.getScrollTop();
- self.chky = self.lastscrolly;
-
- var nx = self.lastscrollx;
- var ny = self.lastscrolly;
-
- var onscroll = function() {
- var df = ((self.time() - t) > 600) ? 0.04 : 0.02;
-
- if (self.speedx) {
- nx = Math.floor(self.lastscrollx - (self.speedx * (1 - self.demulxy)));
- self.lastscrollx = nx;
- if ((nx < 0) || (nx > pagew)) df = 0.10;
- }
-
- if (self.speedy) {
- ny = Math.floor(self.lastscrolly - (self.speedy * (1 - self.demulxy)));
- self.lastscrolly = ny;
- if ((ny < 0) || (ny > pageh)) df = 0.10;
- }
-
- self.demulxy = Math.min(1, self.demulxy + df);
-
- self.nc.synched("domomentum2d", function() {
-
- if (self.speedx) {
- var scx = self.nc.getScrollLeft();
- if (scx != self.chkx) self.stop();
- self.chkx = nx;
- self.nc.setScrollLeft(nx);
- }
-
- if (self.speedy) {
- var scy = self.nc.getScrollTop();
- if (scy != self.chky) self.stop();
- self.chky = ny;
- self.nc.setScrollTop(ny);
- }
-
- if (!self.timer) {
- self.nc.hideCursor();
- self.doSnapy(nx, ny);
- }
-
- });
-
- if (self.demulxy < 1) {
- self.timer = setTimeout(onscroll, tm);
- } else {
- self.stop();
- self.nc.hideCursor();
- self.doSnapy(nx, ny);
- }
- };
-
- onscroll();
-
- } else {
- self.doSnapy(self.nc.getScrollLeft(), self.nc.getScrollTop());
- }
-
- }
-
- };
-
-
- // override jQuery scrollTop
-
- var _scrollTop = jQuery.fn.scrollTop; // preserve original function
-
- jQuery.cssHooks["pageYOffset"] = {
- get: function(elem, computed, extra) {
- var nice = $.data(elem, '__nicescroll') || false;
- return (nice && nice.ishwscroll) ? nice.getScrollTop() : _scrollTop.call(elem);
- },
- set: function(elem, value) {
- var nice = $.data(elem, '__nicescroll') || false;
- (nice && nice.ishwscroll) ? nice.setScrollTop(parseInt(value)): _scrollTop.call(elem, value);
- return this;
- }
- };
-
- /*
- $.fx.step["scrollTop"] = function(fx){
- $.cssHooks["scrollTop"].set( fx.elem, fx.now + fx.unit );
- };
-*/
-
- jQuery.fn.scrollTop = function(value) {
- if (typeof value == "undefined") {
- var nice = (this[0]) ? $.data(this[0], '__nicescroll') || false : false;
- return (nice && nice.ishwscroll) ? nice.getScrollTop() : _scrollTop.call(this);
- } else {
- return this.each(function() {
- var nice = $.data(this, '__nicescroll') || false;
- (nice && nice.ishwscroll) ? nice.setScrollTop(parseInt(value)): _scrollTop.call($(this), value);
- });
- }
- };
-
- // override jQuery scrollLeft
-
- var _scrollLeft = jQuery.fn.scrollLeft; // preserve original function
-
- $.cssHooks.pageXOffset = {
- get: function(elem, computed, extra) {
- var nice = $.data(elem, '__nicescroll') || false;
- return (nice && nice.ishwscroll) ? nice.getScrollLeft() : _scrollLeft.call(elem);
- },
- set: function(elem, value) {
- var nice = $.data(elem, '__nicescroll') || false;
- (nice && nice.ishwscroll) ? nice.setScrollLeft(parseInt(value)): _scrollLeft.call(elem, value);
- return this;
- }
- };
-
- /*
- $.fx.step["scrollLeft"] = function(fx){
- $.cssHooks["scrollLeft"].set( fx.elem, fx.now + fx.unit );
- };
-*/
-
- jQuery.fn.scrollLeft = function(value) {
- if (typeof value == "undefined") {
- var nice = (this[0]) ? $.data(this[0], '__nicescroll') || false : false;
- return (nice && nice.ishwscroll) ? nice.getScrollLeft() : _scrollLeft.call(this);
- } else {
- return this.each(function() {
- var nice = $.data(this, '__nicescroll') || false;
- (nice && nice.ishwscroll) ? nice.setScrollLeft(parseInt(value)): _scrollLeft.call($(this), value);
- });
- }
- };
-
- var NiceScrollArray = function(doms) {
- var self = this;
- this.length = 0;
- this.name = "nicescrollarray";
-
- this.each = function(fn) {
- for (var a = 0, i = 0; a < self.length; a++) fn.call(self[a], i++);
- return self;
- };
-
- this.push = function(nice) {
- self[self.length] = nice;
- self.length++;
- };
-
- this.eq = function(idx) {
- return self[idx];
- };
-
- if (doms) {
- for (var a = 0; a < doms.length; a++) {
- var nice = $.data(doms[a], '__nicescroll') || false;
- if (nice) {
- this[this.length] = nice;
- this.length++;
- }
- };
- }
-
- return this;
- };
-
- function mplex(el, lst, fn) {
- for (var a = 0; a < lst.length; a++) fn(el, lst[a]);
- };
- mplex(
- NiceScrollArray.prototype, ['show', 'hide', 'toggle', 'onResize', 'resize', 'remove', 'stop', 'doScrollPos'],
- function(e, n) {
- e[n] = function() {
- var args = arguments;
- return this.each(function() {
- this[n].apply(this, args);
- });
- };
- }
- );
-
- jQuery.fn.getNiceScroll = function(index) {
- if (typeof index == "undefined") {
- return new NiceScrollArray(this);
- } else {
- var nice = this[index] && $.data(this[index], '__nicescroll') || false;
- return nice;
- }
- };
-
- jQuery.extend(jQuery.expr[':'], {
- nicescroll: function(a) {
- return ($.data(a, '__nicescroll')) ? true : false;
- }
- });
-
- $.fn.niceScroll = function(wrapper, opt) {
- if (typeof opt == "undefined") {
- if ((typeof wrapper == "object") && !("jquery" in wrapper)) {
- opt = wrapper;
- wrapper = false;
- }
- }
- opt = $.extend({},opt); // cloning
- var ret = new NiceScrollArray();
- if (typeof opt == "undefined") opt = {};
-
- if (wrapper || false) {
- opt.doc = $(wrapper);
- opt.win = $(this);
- }
- var docundef = !("doc" in opt);
- if (!docundef && !("win" in opt)) opt.win = $(this);
-
- this.each(function() {
- var nice = $(this).data('__nicescroll') || false;
- if (!nice) {
- opt.doc = (docundef) ? $(this) : opt.doc;
- nice = new NiceScrollClass(opt, $(this));
- $(this).data('__nicescroll', nice);
- }
- ret.push(nice);
- });
- return (ret.length == 1) ? ret[0] : ret;
- };
-
- window.NiceScroll = {
- getjQuery: function() {
- return jQuery
- }
- };
-
- if (!$.nicescroll) {
- $.nicescroll = new NiceScrollArray();
- $.nicescroll.options = _globaloptions;
- }
-
-})); \ No newline at end of file
diff --git a/vendor/assets/javascripts/pdf.js b/vendor/assets/javascripts/pdf.js
new file mode 100755
index 00000000000..91c43b12716
--- /dev/null
+++ b/vendor/assets/javascripts/pdf.js
@@ -0,0 +1,9365 @@
+/* Copyright 2017 Mozilla Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+(function webpackUniversalModuleDefinition(root, factory) {
+ if(typeof exports === 'object' && typeof module === 'object')
+ module.exports = factory();
+ else if(typeof define === 'function' && define.amd)
+ define("pdfjs-dist/build/pdf", [], factory);
+ else if(typeof exports === 'object')
+ exports["pdfjs-dist/build/pdf"] = factory();
+ else
+ root["pdfjs-dist/build/pdf"] = root.pdfjsDistBuildPdf = factory();
+})(this, function() {
+return /******/ (function(modules) { // webpackBootstrap
+/******/ // The module cache
+/******/ var installedModules = {};
+/******/
+/******/ // The require function
+/******/ function __w_pdfjs_require__(moduleId) {
+/******/
+/******/ // Check if module is in cache
+/******/ if(installedModules[moduleId])
+/******/ return installedModules[moduleId].exports;
+/******/
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = installedModules[moduleId] = {
+/******/ i: moduleId,
+/******/ l: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ modules[moduleId].call(module.exports, module, module.exports, __w_pdfjs_require__);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.l = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __w_pdfjs_require__.m = modules;
+/******/
+/******/ // expose the module cache
+/******/ __w_pdfjs_require__.c = installedModules;
+/******/
+/******/ // identity function for calling harmony imports with the correct context
+/******/ __w_pdfjs_require__.i = function(value) { return value; };
+/******/
+/******/ // define getter function for harmony exports
+/******/ __w_pdfjs_require__.d = function(exports, name, getter) {
+/******/ if(!__w_pdfjs_require__.o(exports, name)) {
+/******/ Object.defineProperty(exports, name, {
+/******/ configurable: false,
+/******/ enumerable: true,
+/******/ get: getter
+/******/ });
+/******/ }
+/******/ };
+/******/
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __w_pdfjs_require__.n = function(module) {
+/******/ var getter = module && module.__esModule ?
+/******/ function getDefault() { return module['default']; } :
+/******/ function getModuleExports() { return module; };
+/******/ __w_pdfjs_require__.d(getter, 'a', getter);
+/******/ return getter;
+/******/ };
+/******/
+/******/ // Object.prototype.hasOwnProperty.call
+/******/ __w_pdfjs_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ // __webpack_public_path__
+/******/ __w_pdfjs_require__.p = "";
+/******/
+/******/ // Load entry module and return exports
+/******/ return __w_pdfjs_require__(__w_pdfjs_require__.s = 13);
+/******/ })
+/************************************************************************/
+/******/ ([
+/* 0 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+/* WEBPACK VAR INJECTION */(function(global) {
+
+var compatibility = __w_pdfjs_require__(14);
+var globalScope = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : undefined;
+var FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0];
+var TextRenderingMode = {
+ FILL: 0,
+ STROKE: 1,
+ FILL_STROKE: 2,
+ INVISIBLE: 3,
+ FILL_ADD_TO_PATH: 4,
+ STROKE_ADD_TO_PATH: 5,
+ FILL_STROKE_ADD_TO_PATH: 6,
+ ADD_TO_PATH: 7,
+ FILL_STROKE_MASK: 3,
+ ADD_TO_PATH_FLAG: 4
+};
+var ImageKind = {
+ GRAYSCALE_1BPP: 1,
+ RGB_24BPP: 2,
+ RGBA_32BPP: 3
+};
+var AnnotationType = {
+ TEXT: 1,
+ LINK: 2,
+ FREETEXT: 3,
+ LINE: 4,
+ SQUARE: 5,
+ CIRCLE: 6,
+ POLYGON: 7,
+ POLYLINE: 8,
+ HIGHLIGHT: 9,
+ UNDERLINE: 10,
+ SQUIGGLY: 11,
+ STRIKEOUT: 12,
+ STAMP: 13,
+ CARET: 14,
+ INK: 15,
+ POPUP: 16,
+ FILEATTACHMENT: 17,
+ SOUND: 18,
+ MOVIE: 19,
+ WIDGET: 20,
+ SCREEN: 21,
+ PRINTERMARK: 22,
+ TRAPNET: 23,
+ WATERMARK: 24,
+ THREED: 25,
+ REDACT: 26
+};
+var AnnotationFlag = {
+ INVISIBLE: 0x01,
+ HIDDEN: 0x02,
+ PRINT: 0x04,
+ NOZOOM: 0x08,
+ NOROTATE: 0x10,
+ NOVIEW: 0x20,
+ READONLY: 0x40,
+ LOCKED: 0x80,
+ TOGGLENOVIEW: 0x100,
+ LOCKEDCONTENTS: 0x200
+};
+var AnnotationFieldFlag = {
+ READONLY: 0x0000001,
+ REQUIRED: 0x0000002,
+ NOEXPORT: 0x0000004,
+ MULTILINE: 0x0001000,
+ PASSWORD: 0x0002000,
+ NOTOGGLETOOFF: 0x0004000,
+ RADIO: 0x0008000,
+ PUSHBUTTON: 0x0010000,
+ COMBO: 0x0020000,
+ EDIT: 0x0040000,
+ SORT: 0x0080000,
+ FILESELECT: 0x0100000,
+ MULTISELECT: 0x0200000,
+ DONOTSPELLCHECK: 0x0400000,
+ DONOTSCROLL: 0x0800000,
+ COMB: 0x1000000,
+ RICHTEXT: 0x2000000,
+ RADIOSINUNISON: 0x2000000,
+ COMMITONSELCHANGE: 0x4000000
+};
+var AnnotationBorderStyleType = {
+ SOLID: 1,
+ DASHED: 2,
+ BEVELED: 3,
+ INSET: 4,
+ UNDERLINE: 5
+};
+var StreamType = {
+ UNKNOWN: 0,
+ FLATE: 1,
+ LZW: 2,
+ DCT: 3,
+ JPX: 4,
+ JBIG: 5,
+ A85: 6,
+ AHX: 7,
+ CCF: 8,
+ RL: 9
+};
+var FontType = {
+ UNKNOWN: 0,
+ TYPE1: 1,
+ TYPE1C: 2,
+ CIDFONTTYPE0: 3,
+ CIDFONTTYPE0C: 4,
+ TRUETYPE: 5,
+ CIDFONTTYPE2: 6,
+ TYPE3: 7,
+ OPENTYPE: 8,
+ TYPE0: 9,
+ MMTYPE1: 10
+};
+var VERBOSITY_LEVELS = {
+ errors: 0,
+ warnings: 1,
+ infos: 5
+};
+var CMapCompressionType = {
+ NONE: 0,
+ BINARY: 1,
+ STREAM: 2
+};
+var OPS = {
+ dependency: 1,
+ setLineWidth: 2,
+ setLineCap: 3,
+ setLineJoin: 4,
+ setMiterLimit: 5,
+ setDash: 6,
+ setRenderingIntent: 7,
+ setFlatness: 8,
+ setGState: 9,
+ save: 10,
+ restore: 11,
+ transform: 12,
+ moveTo: 13,
+ lineTo: 14,
+ curveTo: 15,
+ curveTo2: 16,
+ curveTo3: 17,
+ closePath: 18,
+ rectangle: 19,
+ stroke: 20,
+ closeStroke: 21,
+ fill: 22,
+ eoFill: 23,
+ fillStroke: 24,
+ eoFillStroke: 25,
+ closeFillStroke: 26,
+ closeEOFillStroke: 27,
+ endPath: 28,
+ clip: 29,
+ eoClip: 30,
+ beginText: 31,
+ endText: 32,
+ setCharSpacing: 33,
+ setWordSpacing: 34,
+ setHScale: 35,
+ setLeading: 36,
+ setFont: 37,
+ setTextRenderingMode: 38,
+ setTextRise: 39,
+ moveText: 40,
+ setLeadingMoveText: 41,
+ setTextMatrix: 42,
+ nextLine: 43,
+ showText: 44,
+ showSpacedText: 45,
+ nextLineShowText: 46,
+ nextLineSetSpacingShowText: 47,
+ setCharWidth: 48,
+ setCharWidthAndBounds: 49,
+ setStrokeColorSpace: 50,
+ setFillColorSpace: 51,
+ setStrokeColor: 52,
+ setStrokeColorN: 53,
+ setFillColor: 54,
+ setFillColorN: 55,
+ setStrokeGray: 56,
+ setFillGray: 57,
+ setStrokeRGBColor: 58,
+ setFillRGBColor: 59,
+ setStrokeCMYKColor: 60,
+ setFillCMYKColor: 61,
+ shadingFill: 62,
+ beginInlineImage: 63,
+ beginImageData: 64,
+ endInlineImage: 65,
+ paintXObject: 66,
+ markPoint: 67,
+ markPointProps: 68,
+ beginMarkedContent: 69,
+ beginMarkedContentProps: 70,
+ endMarkedContent: 71,
+ beginCompat: 72,
+ endCompat: 73,
+ paintFormXObjectBegin: 74,
+ paintFormXObjectEnd: 75,
+ beginGroup: 76,
+ endGroup: 77,
+ beginAnnotations: 78,
+ endAnnotations: 79,
+ beginAnnotation: 80,
+ endAnnotation: 81,
+ paintJpegXObject: 82,
+ paintImageMaskXObject: 83,
+ paintImageMaskXObjectGroup: 84,
+ paintImageXObject: 85,
+ paintInlineImageXObject: 86,
+ paintInlineImageXObjectGroup: 87,
+ paintImageXObjectRepeat: 88,
+ paintImageMaskXObjectRepeat: 89,
+ paintSolidColorImageMask: 90,
+ constructPath: 91
+};
+var verbosity = VERBOSITY_LEVELS.warnings;
+function setVerbosityLevel(level) {
+ verbosity = level;
+}
+function getVerbosityLevel() {
+ return verbosity;
+}
+function info(msg) {
+ if (verbosity >= VERBOSITY_LEVELS.infos) {
+ console.log('Info: ' + msg);
+ }
+}
+function warn(msg) {
+ if (verbosity >= VERBOSITY_LEVELS.warnings) {
+ console.log('Warning: ' + msg);
+ }
+}
+function deprecated(details) {
+ console.log('Deprecated API usage: ' + details);
+}
+function error(msg) {
+ if (verbosity >= VERBOSITY_LEVELS.errors) {
+ console.log('Error: ' + msg);
+ console.log(backtrace());
+ }
+ throw new Error(msg);
+}
+function backtrace() {
+ try {
+ throw new Error();
+ } catch (e) {
+ return e.stack ? e.stack.split('\n').slice(2).join('\n') : '';
+ }
+}
+function assert(cond, msg) {
+ if (!cond) {
+ error(msg);
+ }
+}
+var UNSUPPORTED_FEATURES = {
+ unknown: 'unknown',
+ forms: 'forms',
+ javaScript: 'javaScript',
+ smask: 'smask',
+ shadingPattern: 'shadingPattern',
+ font: 'font'
+};
+function isSameOrigin(baseUrl, otherUrl) {
+ try {
+ var base = new URL(baseUrl);
+ if (!base.origin || base.origin === 'null') {
+ return false;
+ }
+ } catch (e) {
+ return false;
+ }
+ var other = new URL(otherUrl, base);
+ return base.origin === other.origin;
+}
+function isValidProtocol(url) {
+ if (!url) {
+ return false;
+ }
+ switch (url.protocol) {
+ case 'http:':
+ case 'https:':
+ case 'ftp:':
+ case 'mailto:':
+ case 'tel:':
+ return true;
+ default:
+ return false;
+ }
+}
+function createValidAbsoluteUrl(url, baseUrl) {
+ if (!url) {
+ return null;
+ }
+ try {
+ var absoluteUrl = baseUrl ? new URL(url, baseUrl) : new URL(url);
+ if (isValidProtocol(absoluteUrl)) {
+ return absoluteUrl;
+ }
+ } catch (ex) {}
+ return null;
+}
+function shadow(obj, prop, value) {
+ Object.defineProperty(obj, prop, {
+ value: value,
+ enumerable: true,
+ configurable: true,
+ writable: false
+ });
+ return value;
+}
+function getLookupTableFactory(initializer) {
+ var lookup;
+ return function () {
+ if (initializer) {
+ lookup = Object.create(null);
+ initializer(lookup);
+ initializer = null;
+ }
+ return lookup;
+ };
+}
+var PasswordResponses = {
+ NEED_PASSWORD: 1,
+ INCORRECT_PASSWORD: 2
+};
+var PasswordException = function PasswordExceptionClosure() {
+ function PasswordException(msg, code) {
+ this.name = 'PasswordException';
+ this.message = msg;
+ this.code = code;
+ }
+ PasswordException.prototype = new Error();
+ PasswordException.constructor = PasswordException;
+ return PasswordException;
+}();
+var UnknownErrorException = function UnknownErrorExceptionClosure() {
+ function UnknownErrorException(msg, details) {
+ this.name = 'UnknownErrorException';
+ this.message = msg;
+ this.details = details;
+ }
+ UnknownErrorException.prototype = new Error();
+ UnknownErrorException.constructor = UnknownErrorException;
+ return UnknownErrorException;
+}();
+var InvalidPDFException = function InvalidPDFExceptionClosure() {
+ function InvalidPDFException(msg) {
+ this.name = 'InvalidPDFException';
+ this.message = msg;
+ }
+ InvalidPDFException.prototype = new Error();
+ InvalidPDFException.constructor = InvalidPDFException;
+ return InvalidPDFException;
+}();
+var MissingPDFException = function MissingPDFExceptionClosure() {
+ function MissingPDFException(msg) {
+ this.name = 'MissingPDFException';
+ this.message = msg;
+ }
+ MissingPDFException.prototype = new Error();
+ MissingPDFException.constructor = MissingPDFException;
+ return MissingPDFException;
+}();
+var UnexpectedResponseException = function UnexpectedResponseExceptionClosure() {
+ function UnexpectedResponseException(msg, status) {
+ this.name = 'UnexpectedResponseException';
+ this.message = msg;
+ this.status = status;
+ }
+ UnexpectedResponseException.prototype = new Error();
+ UnexpectedResponseException.constructor = UnexpectedResponseException;
+ return UnexpectedResponseException;
+}();
+var NotImplementedException = function NotImplementedExceptionClosure() {
+ function NotImplementedException(msg) {
+ this.message = msg;
+ }
+ NotImplementedException.prototype = new Error();
+ NotImplementedException.prototype.name = 'NotImplementedException';
+ NotImplementedException.constructor = NotImplementedException;
+ return NotImplementedException;
+}();
+var MissingDataException = function MissingDataExceptionClosure() {
+ function MissingDataException(begin, end) {
+ this.begin = begin;
+ this.end = end;
+ this.message = 'Missing data [' + begin + ', ' + end + ')';
+ }
+ MissingDataException.prototype = new Error();
+ MissingDataException.prototype.name = 'MissingDataException';
+ MissingDataException.constructor = MissingDataException;
+ return MissingDataException;
+}();
+var XRefParseException = function XRefParseExceptionClosure() {
+ function XRefParseException(msg) {
+ this.message = msg;
+ }
+ XRefParseException.prototype = new Error();
+ XRefParseException.prototype.name = 'XRefParseException';
+ XRefParseException.constructor = XRefParseException;
+ return XRefParseException;
+}();
+var NullCharactersRegExp = /\x00/g;
+function removeNullCharacters(str) {
+ if (typeof str !== 'string') {
+ warn('The argument for removeNullCharacters must be a string.');
+ return str;
+ }
+ return str.replace(NullCharactersRegExp, '');
+}
+function bytesToString(bytes) {
+ assert(bytes !== null && typeof bytes === 'object' && bytes.length !== undefined, 'Invalid argument for bytesToString');
+ var length = bytes.length;
+ var MAX_ARGUMENT_COUNT = 8192;
+ if (length < MAX_ARGUMENT_COUNT) {
+ return String.fromCharCode.apply(null, bytes);
+ }
+ var strBuf = [];
+ for (var i = 0; i < length; i += MAX_ARGUMENT_COUNT) {
+ var chunkEnd = Math.min(i + MAX_ARGUMENT_COUNT, length);
+ var chunk = bytes.subarray(i, chunkEnd);
+ strBuf.push(String.fromCharCode.apply(null, chunk));
+ }
+ return strBuf.join('');
+}
+function stringToBytes(str) {
+ assert(typeof str === 'string', 'Invalid argument for stringToBytes');
+ var length = str.length;
+ var bytes = new Uint8Array(length);
+ for (var i = 0; i < length; ++i) {
+ bytes[i] = str.charCodeAt(i) & 0xFF;
+ }
+ return bytes;
+}
+function arrayByteLength(arr) {
+ if (arr.length !== undefined) {
+ return arr.length;
+ }
+ assert(arr.byteLength !== undefined);
+ return arr.byteLength;
+}
+function arraysToBytes(arr) {
+ if (arr.length === 1 && arr[0] instanceof Uint8Array) {
+ return arr[0];
+ }
+ var resultLength = 0;
+ var i,
+ ii = arr.length;
+ var item, itemLength;
+ for (i = 0; i < ii; i++) {
+ item = arr[i];
+ itemLength = arrayByteLength(item);
+ resultLength += itemLength;
+ }
+ var pos = 0;
+ var data = new Uint8Array(resultLength);
+ for (i = 0; i < ii; i++) {
+ item = arr[i];
+ if (!(item instanceof Uint8Array)) {
+ if (typeof item === 'string') {
+ item = stringToBytes(item);
+ } else {
+ item = new Uint8Array(item);
+ }
+ }
+ itemLength = item.byteLength;
+ data.set(item, pos);
+ pos += itemLength;
+ }
+ return data;
+}
+function string32(value) {
+ return String.fromCharCode(value >> 24 & 0xff, value >> 16 & 0xff, value >> 8 & 0xff, value & 0xff);
+}
+function log2(x) {
+ var n = 1,
+ i = 0;
+ while (x > n) {
+ n <<= 1;
+ i++;
+ }
+ return i;
+}
+function readInt8(data, start) {
+ return data[start] << 24 >> 24;
+}
+function readUint16(data, offset) {
+ return data[offset] << 8 | data[offset + 1];
+}
+function readUint32(data, offset) {
+ return (data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]) >>> 0;
+}
+function isLittleEndian() {
+ var buffer8 = new Uint8Array(2);
+ buffer8[0] = 1;
+ var buffer16 = new Uint16Array(buffer8.buffer);
+ return buffer16[0] === 1;
+}
+function isEvalSupported() {
+ try {
+ new Function('');
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+var Uint32ArrayView = function Uint32ArrayViewClosure() {
+ function Uint32ArrayView(buffer, length) {
+ this.buffer = buffer;
+ this.byteLength = buffer.length;
+ this.length = length === undefined ? this.byteLength >> 2 : length;
+ ensureUint32ArrayViewProps(this.length);
+ }
+ Uint32ArrayView.prototype = Object.create(null);
+ var uint32ArrayViewSetters = 0;
+ function createUint32ArrayProp(index) {
+ return {
+ get: function () {
+ var buffer = this.buffer,
+ offset = index << 2;
+ return (buffer[offset] | buffer[offset + 1] << 8 | buffer[offset + 2] << 16 | buffer[offset + 3] << 24) >>> 0;
+ },
+ set: function (value) {
+ var buffer = this.buffer,
+ offset = index << 2;
+ buffer[offset] = value & 255;
+ buffer[offset + 1] = value >> 8 & 255;
+ buffer[offset + 2] = value >> 16 & 255;
+ buffer[offset + 3] = value >>> 24 & 255;
+ }
+ };
+ }
+ function ensureUint32ArrayViewProps(length) {
+ while (uint32ArrayViewSetters < length) {
+ Object.defineProperty(Uint32ArrayView.prototype, uint32ArrayViewSetters, createUint32ArrayProp(uint32ArrayViewSetters));
+ uint32ArrayViewSetters++;
+ }
+ }
+ return Uint32ArrayView;
+}();
+exports.Uint32ArrayView = Uint32ArrayView;
+var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
+var Util = function UtilClosure() {
+ function Util() {}
+ var rgbBuf = ['rgb(', 0, ',', 0, ',', 0, ')'];
+ Util.makeCssRgb = function Util_makeCssRgb(r, g, b) {
+ rgbBuf[1] = r;
+ rgbBuf[3] = g;
+ rgbBuf[5] = b;
+ return rgbBuf.join('');
+ };
+ Util.transform = function Util_transform(m1, m2) {
+ return [m1[0] * m2[0] + m1[2] * m2[1], m1[1] * m2[0] + m1[3] * m2[1], m1[0] * m2[2] + m1[2] * m2[3], m1[1] * m2[2] + m1[3] * m2[3], m1[0] * m2[4] + m1[2] * m2[5] + m1[4], m1[1] * m2[4] + m1[3] * m2[5] + m1[5]];
+ };
+ Util.applyTransform = function Util_applyTransform(p, m) {
+ var xt = p[0] * m[0] + p[1] * m[2] + m[4];
+ var yt = p[0] * m[1] + p[1] * m[3] + m[5];
+ return [xt, yt];
+ };
+ Util.applyInverseTransform = function Util_applyInverseTransform(p, m) {
+ var d = m[0] * m[3] - m[1] * m[2];
+ var xt = (p[0] * m[3] - p[1] * m[2] + m[2] * m[5] - m[4] * m[3]) / d;
+ var yt = (-p[0] * m[1] + p[1] * m[0] + m[4] * m[1] - m[5] * m[0]) / d;
+ return [xt, yt];
+ };
+ Util.getAxialAlignedBoundingBox = function Util_getAxialAlignedBoundingBox(r, m) {
+ var p1 = Util.applyTransform(r, m);
+ var p2 = Util.applyTransform(r.slice(2, 4), m);
+ var p3 = Util.applyTransform([r[0], r[3]], m);
+ var p4 = Util.applyTransform([r[2], r[1]], m);
+ return [Math.min(p1[0], p2[0], p3[0], p4[0]), Math.min(p1[1], p2[1], p3[1], p4[1]), Math.max(p1[0], p2[0], p3[0], p4[0]), Math.max(p1[1], p2[1], p3[1], p4[1])];
+ };
+ Util.inverseTransform = function Util_inverseTransform(m) {
+ var d = m[0] * m[3] - m[1] * m[2];
+ return [m[3] / d, -m[1] / d, -m[2] / d, m[0] / d, (m[2] * m[5] - m[4] * m[3]) / d, (m[4] * m[1] - m[5] * m[0]) / d];
+ };
+ Util.apply3dTransform = function Util_apply3dTransform(m, v) {
+ return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
+ };
+ Util.singularValueDecompose2dScale = function Util_singularValueDecompose2dScale(m) {
+ var transpose = [m[0], m[2], m[1], m[3]];
+ var a = m[0] * transpose[0] + m[1] * transpose[2];
+ var b = m[0] * transpose[1] + m[1] * transpose[3];
+ var c = m[2] * transpose[0] + m[3] * transpose[2];
+ var d = m[2] * transpose[1] + m[3] * transpose[3];
+ var first = (a + d) / 2;
+ var second = Math.sqrt((a + d) * (a + d) - 4 * (a * d - c * b)) / 2;
+ var sx = first + second || 1;
+ var sy = first - second || 1;
+ return [Math.sqrt(sx), Math.sqrt(sy)];
+ };
+ Util.normalizeRect = function Util_normalizeRect(rect) {
+ var r = rect.slice(0);
+ if (rect[0] > rect[2]) {
+ r[0] = rect[2];
+ r[2] = rect[0];
+ }
+ if (rect[1] > rect[3]) {
+ r[1] = rect[3];
+ r[3] = rect[1];
+ }
+ return r;
+ };
+ Util.intersect = function Util_intersect(rect1, rect2) {
+ function compare(a, b) {
+ return a - b;
+ }
+ var orderedX = [rect1[0], rect1[2], rect2[0], rect2[2]].sort(compare),
+ orderedY = [rect1[1], rect1[3], rect2[1], rect2[3]].sort(compare),
+ result = [];
+ rect1 = Util.normalizeRect(rect1);
+ rect2 = Util.normalizeRect(rect2);
+ if (orderedX[0] === rect1[0] && orderedX[1] === rect2[0] || orderedX[0] === rect2[0] && orderedX[1] === rect1[0]) {
+ result[0] = orderedX[1];
+ result[2] = orderedX[2];
+ } else {
+ return false;
+ }
+ if (orderedY[0] === rect1[1] && orderedY[1] === rect2[1] || orderedY[0] === rect2[1] && orderedY[1] === rect1[1]) {
+ result[1] = orderedY[1];
+ result[3] = orderedY[2];
+ } else {
+ return false;
+ }
+ return result;
+ };
+ Util.sign = function Util_sign(num) {
+ return num < 0 ? -1 : 1;
+ };
+ var ROMAN_NUMBER_MAP = ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM', '', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC', '', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'];
+ Util.toRoman = function Util_toRoman(number, lowerCase) {
+ assert(isInt(number) && number > 0, 'The number should be a positive integer.');
+ var pos,
+ romanBuf = [];
+ while (number >= 1000) {
+ number -= 1000;
+ romanBuf.push('M');
+ }
+ pos = number / 100 | 0;
+ number %= 100;
+ romanBuf.push(ROMAN_NUMBER_MAP[pos]);
+ pos = number / 10 | 0;
+ number %= 10;
+ romanBuf.push(ROMAN_NUMBER_MAP[10 + pos]);
+ romanBuf.push(ROMAN_NUMBER_MAP[20 + number]);
+ var romanStr = romanBuf.join('');
+ return lowerCase ? romanStr.toLowerCase() : romanStr;
+ };
+ Util.appendToArray = function Util_appendToArray(arr1, arr2) {
+ Array.prototype.push.apply(arr1, arr2);
+ };
+ Util.prependToArray = function Util_prependToArray(arr1, arr2) {
+ Array.prototype.unshift.apply(arr1, arr2);
+ };
+ Util.extendObj = function extendObj(obj1, obj2) {
+ for (var key in obj2) {
+ obj1[key] = obj2[key];
+ }
+ };
+ Util.getInheritableProperty = function Util_getInheritableProperty(dict, name, getArray) {
+ while (dict && !dict.has(name)) {
+ dict = dict.get('Parent');
+ }
+ if (!dict) {
+ return null;
+ }
+ return getArray ? dict.getArray(name) : dict.get(name);
+ };
+ Util.inherit = function Util_inherit(sub, base, prototype) {
+ sub.prototype = Object.create(base.prototype);
+ sub.prototype.constructor = sub;
+ for (var prop in prototype) {
+ sub.prototype[prop] = prototype[prop];
+ }
+ };
+ Util.loadScript = function Util_loadScript(src, callback) {
+ var script = document.createElement('script');
+ var loaded = false;
+ script.setAttribute('src', src);
+ if (callback) {
+ script.onload = function () {
+ if (!loaded) {
+ callback();
+ }
+ loaded = true;
+ };
+ }
+ document.getElementsByTagName('head')[0].appendChild(script);
+ };
+ return Util;
+}();
+var PageViewport = function PageViewportClosure() {
+ function PageViewport(viewBox, scale, rotation, offsetX, offsetY, dontFlip) {
+ this.viewBox = viewBox;
+ this.scale = scale;
+ this.rotation = rotation;
+ this.offsetX = offsetX;
+ this.offsetY = offsetY;
+ var centerX = (viewBox[2] + viewBox[0]) / 2;
+ var centerY = (viewBox[3] + viewBox[1]) / 2;
+ var rotateA, rotateB, rotateC, rotateD;
+ rotation = rotation % 360;
+ rotation = rotation < 0 ? rotation + 360 : rotation;
+ switch (rotation) {
+ case 180:
+ rotateA = -1;
+ rotateB = 0;
+ rotateC = 0;
+ rotateD = 1;
+ break;
+ case 90:
+ rotateA = 0;
+ rotateB = 1;
+ rotateC = 1;
+ rotateD = 0;
+ break;
+ case 270:
+ rotateA = 0;
+ rotateB = -1;
+ rotateC = -1;
+ rotateD = 0;
+ break;
+ default:
+ rotateA = 1;
+ rotateB = 0;
+ rotateC = 0;
+ rotateD = -1;
+ break;
+ }
+ if (dontFlip) {
+ rotateC = -rotateC;
+ rotateD = -rotateD;
+ }
+ var offsetCanvasX, offsetCanvasY;
+ var width, height;
+ if (rotateA === 0) {
+ offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
+ offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
+ width = Math.abs(viewBox[3] - viewBox[1]) * scale;
+ height = Math.abs(viewBox[2] - viewBox[0]) * scale;
+ } else {
+ offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
+ offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
+ width = Math.abs(viewBox[2] - viewBox[0]) * scale;
+ height = Math.abs(viewBox[3] - viewBox[1]) * scale;
+ }
+ this.transform = [rotateA * scale, rotateB * scale, rotateC * scale, rotateD * scale, offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY];
+ this.width = width;
+ this.height = height;
+ this.fontScale = scale;
+ }
+ PageViewport.prototype = {
+ clone: function PageViewPort_clone(args) {
+ args = args || {};
+ var scale = 'scale' in args ? args.scale : this.scale;
+ var rotation = 'rotation' in args ? args.rotation : this.rotation;
+ return new PageViewport(this.viewBox.slice(), scale, rotation, this.offsetX, this.offsetY, args.dontFlip);
+ },
+ convertToViewportPoint: function PageViewport_convertToViewportPoint(x, y) {
+ return Util.applyTransform([x, y], this.transform);
+ },
+ convertToViewportRectangle: function PageViewport_convertToViewportRectangle(rect) {
+ var tl = Util.applyTransform([rect[0], rect[1]], this.transform);
+ var br = Util.applyTransform([rect[2], rect[3]], this.transform);
+ return [tl[0], tl[1], br[0], br[1]];
+ },
+ convertToPdfPoint: function PageViewport_convertToPdfPoint(x, y) {
+ return Util.applyInverseTransform([x, y], this.transform);
+ }
+ };
+ return PageViewport;
+}();
+var PDFStringTranslateTable = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2D8, 0x2C7, 0x2C6, 0x2D9, 0x2DD, 0x2DB, 0x2DA, 0x2DC, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x192, 0x2044, 0x2039, 0x203A, 0x2212, 0x2030, 0x201E, 0x201C, 0x201D, 0x2018, 0x2019, 0x201A, 0x2122, 0xFB01, 0xFB02, 0x141, 0x152, 0x160, 0x178, 0x17D, 0x131, 0x142, 0x153, 0x161, 0x17E, 0, 0x20AC];
+function stringToPDFString(str) {
+ var i,
+ n = str.length,
+ strBuf = [];
+ if (str[0] === '\xFE' && str[1] === '\xFF') {
+ for (i = 2; i < n; i += 2) {
+ strBuf.push(String.fromCharCode(str.charCodeAt(i) << 8 | str.charCodeAt(i + 1)));
+ }
+ } else {
+ for (i = 0; i < n; ++i) {
+ var code = PDFStringTranslateTable[str.charCodeAt(i)];
+ strBuf.push(code ? String.fromCharCode(code) : str.charAt(i));
+ }
+ }
+ return strBuf.join('');
+}
+function stringToUTF8String(str) {
+ return decodeURIComponent(escape(str));
+}
+function utf8StringToString(str) {
+ return unescape(encodeURIComponent(str));
+}
+function isEmptyObj(obj) {
+ for (var key in obj) {
+ return false;
+ }
+ return true;
+}
+function isBool(v) {
+ return typeof v === 'boolean';
+}
+function isInt(v) {
+ return typeof v === 'number' && (v | 0) === v;
+}
+function isNum(v) {
+ return typeof v === 'number';
+}
+function isString(v) {
+ return typeof v === 'string';
+}
+function isArray(v) {
+ return v instanceof Array;
+}
+function isArrayBuffer(v) {
+ return typeof v === 'object' && v !== null && v.byteLength !== undefined;
+}
+function isSpace(ch) {
+ return ch === 0x20 || ch === 0x09 || ch === 0x0D || ch === 0x0A;
+}
+function isNodeJS() {
+ if (typeof __pdfjsdev_webpack__ === 'undefined') {
+ return typeof process === 'object' && process + '' === '[object process]';
+ }
+ return false;
+}
+function createPromiseCapability() {
+ var capability = {};
+ capability.promise = new Promise(function (resolve, reject) {
+ capability.resolve = resolve;
+ capability.reject = reject;
+ });
+ return capability;
+}
+var StatTimer = function StatTimerClosure() {
+ function rpad(str, pad, length) {
+ while (str.length < length) {
+ str += pad;
+ }
+ return str;
+ }
+ function StatTimer() {
+ this.started = Object.create(null);
+ this.times = [];
+ this.enabled = true;
+ }
+ StatTimer.prototype = {
+ time: function StatTimer_time(name) {
+ if (!this.enabled) {
+ return;
+ }
+ if (name in this.started) {
+ warn('Timer is already running for ' + name);
+ }
+ this.started[name] = Date.now();
+ },
+ timeEnd: function StatTimer_timeEnd(name) {
+ if (!this.enabled) {
+ return;
+ }
+ if (!(name in this.started)) {
+ warn('Timer has not been started for ' + name);
+ }
+ this.times.push({
+ 'name': name,
+ 'start': this.started[name],
+ 'end': Date.now()
+ });
+ delete this.started[name];
+ },
+ toString: function StatTimer_toString() {
+ var i, ii;
+ var times = this.times;
+ var out = '';
+ var longest = 0;
+ for (i = 0, ii = times.length; i < ii; ++i) {
+ var name = times[i]['name'];
+ if (name.length > longest) {
+ longest = name.length;
+ }
+ }
+ for (i = 0, ii = times.length; i < ii; ++i) {
+ var span = times[i];
+ var duration = span.end - span.start;
+ out += rpad(span['name'], ' ', longest) + ' ' + duration + 'ms\n';
+ }
+ return out;
+ }
+ };
+ return StatTimer;
+}();
+var createBlob = function createBlob(data, contentType) {
+ if (typeof Blob !== 'undefined') {
+ return new Blob([data], { type: contentType });
+ }
+ warn('The "Blob" constructor is not supported.');
+};
+var createObjectURL = function createObjectURLClosure() {
+ var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
+ return function createObjectURL(data, contentType, forceDataSchema) {
+ if (!forceDataSchema && typeof URL !== 'undefined' && URL.createObjectURL) {
+ var blob = createBlob(data, contentType);
+ return URL.createObjectURL(blob);
+ }
+ var buffer = 'data:' + contentType + ';base64,';
+ for (var i = 0, ii = data.length; i < ii; i += 3) {
+ var b1 = data[i] & 0xFF;
+ var b2 = data[i + 1] & 0xFF;
+ var b3 = data[i + 2] & 0xFF;
+ var d1 = b1 >> 2,
+ d2 = (b1 & 3) << 4 | b2 >> 4;
+ var d3 = i + 1 < ii ? (b2 & 0xF) << 2 | b3 >> 6 : 64;
+ var d4 = i + 2 < ii ? b3 & 0x3F : 64;
+ buffer += digits[d1] + digits[d2] + digits[d3] + digits[d4];
+ }
+ return buffer;
+ };
+}();
+function MessageHandler(sourceName, targetName, comObj) {
+ this.sourceName = sourceName;
+ this.targetName = targetName;
+ this.comObj = comObj;
+ this.callbackIndex = 1;
+ this.postMessageTransfers = true;
+ var callbacksCapabilities = this.callbacksCapabilities = Object.create(null);
+ var ah = this.actionHandler = Object.create(null);
+ this._onComObjOnMessage = function messageHandlerComObjOnMessage(event) {
+ var data = event.data;
+ if (data.targetName !== this.sourceName) {
+ return;
+ }
+ if (data.isReply) {
+ var callbackId = data.callbackId;
+ if (data.callbackId in callbacksCapabilities) {
+ var callback = callbacksCapabilities[callbackId];
+ delete callbacksCapabilities[callbackId];
+ if ('error' in data) {
+ callback.reject(data.error);
+ } else {
+ callback.resolve(data.data);
+ }
+ } else {
+ error('Cannot resolve callback ' + callbackId);
+ }
+ } else if (data.action in ah) {
+ var action = ah[data.action];
+ if (data.callbackId) {
+ var sourceName = this.sourceName;
+ var targetName = data.sourceName;
+ Promise.resolve().then(function () {
+ return action[0].call(action[1], data.data);
+ }).then(function (result) {
+ comObj.postMessage({
+ sourceName: sourceName,
+ targetName: targetName,
+ isReply: true,
+ callbackId: data.callbackId,
+ data: result
+ });
+ }, function (reason) {
+ if (reason instanceof Error) {
+ reason = reason + '';
+ }
+ comObj.postMessage({
+ sourceName: sourceName,
+ targetName: targetName,
+ isReply: true,
+ callbackId: data.callbackId,
+ error: reason
+ });
+ });
+ } else {
+ action[0].call(action[1], data.data);
+ }
+ } else {
+ error('Unknown action from worker: ' + data.action);
+ }
+ }.bind(this);
+ comObj.addEventListener('message', this._onComObjOnMessage);
+}
+MessageHandler.prototype = {
+ on: function messageHandlerOn(actionName, handler, scope) {
+ var ah = this.actionHandler;
+ if (ah[actionName]) {
+ error('There is already an actionName called "' + actionName + '"');
+ }
+ ah[actionName] = [handler, scope];
+ },
+ send: function messageHandlerSend(actionName, data, transfers) {
+ var message = {
+ sourceName: this.sourceName,
+ targetName: this.targetName,
+ action: actionName,
+ data: data
+ };
+ this.postMessage(message, transfers);
+ },
+ sendWithPromise: function messageHandlerSendWithPromise(actionName, data, transfers) {
+ var callbackId = this.callbackIndex++;
+ var message = {
+ sourceName: this.sourceName,
+ targetName: this.targetName,
+ action: actionName,
+ data: data,
+ callbackId: callbackId
+ };
+ var capability = createPromiseCapability();
+ this.callbacksCapabilities[callbackId] = capability;
+ try {
+ this.postMessage(message, transfers);
+ } catch (e) {
+ capability.reject(e);
+ }
+ return capability.promise;
+ },
+ postMessage: function (message, transfers) {
+ if (transfers && this.postMessageTransfers) {
+ this.comObj.postMessage(message, transfers);
+ } else {
+ this.comObj.postMessage(message);
+ }
+ },
+ destroy: function () {
+ this.comObj.removeEventListener('message', this._onComObjOnMessage);
+ }
+};
+function loadJpegStream(id, imageUrl, objs) {
+ var img = new Image();
+ img.onload = function loadJpegStream_onloadClosure() {
+ objs.resolve(id, img);
+ };
+ img.onerror = function loadJpegStream_onerrorClosure() {
+ objs.resolve(id, null);
+ warn('Error during JPEG image loading');
+ };
+ img.src = imageUrl;
+}
+exports.FONT_IDENTITY_MATRIX = FONT_IDENTITY_MATRIX;
+exports.IDENTITY_MATRIX = IDENTITY_MATRIX;
+exports.OPS = OPS;
+exports.VERBOSITY_LEVELS = VERBOSITY_LEVELS;
+exports.UNSUPPORTED_FEATURES = UNSUPPORTED_FEATURES;
+exports.AnnotationBorderStyleType = AnnotationBorderStyleType;
+exports.AnnotationFieldFlag = AnnotationFieldFlag;
+exports.AnnotationFlag = AnnotationFlag;
+exports.AnnotationType = AnnotationType;
+exports.FontType = FontType;
+exports.ImageKind = ImageKind;
+exports.CMapCompressionType = CMapCompressionType;
+exports.InvalidPDFException = InvalidPDFException;
+exports.MessageHandler = MessageHandler;
+exports.MissingDataException = MissingDataException;
+exports.MissingPDFException = MissingPDFException;
+exports.NotImplementedException = NotImplementedException;
+exports.PageViewport = PageViewport;
+exports.PasswordException = PasswordException;
+exports.PasswordResponses = PasswordResponses;
+exports.StatTimer = StatTimer;
+exports.StreamType = StreamType;
+exports.TextRenderingMode = TextRenderingMode;
+exports.UnexpectedResponseException = UnexpectedResponseException;
+exports.UnknownErrorException = UnknownErrorException;
+exports.Util = Util;
+exports.XRefParseException = XRefParseException;
+exports.arrayByteLength = arrayByteLength;
+exports.arraysToBytes = arraysToBytes;
+exports.assert = assert;
+exports.bytesToString = bytesToString;
+exports.createBlob = createBlob;
+exports.createPromiseCapability = createPromiseCapability;
+exports.createObjectURL = createObjectURL;
+exports.deprecated = deprecated;
+exports.error = error;
+exports.getLookupTableFactory = getLookupTableFactory;
+exports.getVerbosityLevel = getVerbosityLevel;
+exports.globalScope = globalScope;
+exports.info = info;
+exports.isArray = isArray;
+exports.isArrayBuffer = isArrayBuffer;
+exports.isBool = isBool;
+exports.isEmptyObj = isEmptyObj;
+exports.isInt = isInt;
+exports.isNum = isNum;
+exports.isString = isString;
+exports.isSpace = isSpace;
+exports.isNodeJS = isNodeJS;
+exports.isSameOrigin = isSameOrigin;
+exports.createValidAbsoluteUrl = createValidAbsoluteUrl;
+exports.isLittleEndian = isLittleEndian;
+exports.isEvalSupported = isEvalSupported;
+exports.loadJpegStream = loadJpegStream;
+exports.log2 = log2;
+exports.readInt8 = readInt8;
+exports.readUint16 = readUint16;
+exports.readUint32 = readUint32;
+exports.removeNullCharacters = removeNullCharacters;
+exports.setVerbosityLevel = setVerbosityLevel;
+exports.shadow = shadow;
+exports.string32 = string32;
+exports.stringToBytes = stringToBytes;
+exports.stringToPDFString = stringToPDFString;
+exports.stringToUTF8String = stringToUTF8String;
+exports.utf8StringToString = utf8StringToString;
+exports.warn = warn;
+/* WEBPACK VAR INJECTION */}.call(exports, __w_pdfjs_require__(6)))
+
+/***/ }),
+/* 1 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var assert = sharedUtil.assert;
+var removeNullCharacters = sharedUtil.removeNullCharacters;
+var warn = sharedUtil.warn;
+var deprecated = sharedUtil.deprecated;
+var createValidAbsoluteUrl = sharedUtil.createValidAbsoluteUrl;
+var stringToBytes = sharedUtil.stringToBytes;
+var CMapCompressionType = sharedUtil.CMapCompressionType;
+var DEFAULT_LINK_REL = 'noopener noreferrer nofollow';
+function DOMCanvasFactory() {}
+DOMCanvasFactory.prototype = {
+ create: function DOMCanvasFactory_create(width, height) {
+ assert(width > 0 && height > 0, 'invalid canvas size');
+ var canvas = document.createElement('canvas');
+ var context = canvas.getContext('2d');
+ canvas.width = width;
+ canvas.height = height;
+ return {
+ canvas: canvas,
+ context: context
+ };
+ },
+ reset: function DOMCanvasFactory_reset(canvasAndContextPair, width, height) {
+ assert(canvasAndContextPair.canvas, 'canvas is not specified');
+ assert(width > 0 && height > 0, 'invalid canvas size');
+ canvasAndContextPair.canvas.width = width;
+ canvasAndContextPair.canvas.height = height;
+ },
+ destroy: function DOMCanvasFactory_destroy(canvasAndContextPair) {
+ assert(canvasAndContextPair.canvas, 'canvas is not specified');
+ canvasAndContextPair.canvas.width = 0;
+ canvasAndContextPair.canvas.height = 0;
+ canvasAndContextPair.canvas = null;
+ canvasAndContextPair.context = null;
+ }
+};
+var DOMCMapReaderFactory = function DOMCMapReaderFactoryClosure() {
+ function DOMCMapReaderFactory(params) {
+ this.baseUrl = params.baseUrl || null;
+ this.isCompressed = params.isCompressed || false;
+ }
+ DOMCMapReaderFactory.prototype = {
+ fetch: function (params) {
+ var name = params.name;
+ if (!name) {
+ return Promise.reject(new Error('CMap name must be specified.'));
+ }
+ return new Promise(function (resolve, reject) {
+ var url = this.baseUrl + name + (this.isCompressed ? '.bcmap' : '');
+ var request = new XMLHttpRequest();
+ request.open('GET', url, true);
+ if (this.isCompressed) {
+ request.responseType = 'arraybuffer';
+ }
+ request.onreadystatechange = function () {
+ if (request.readyState !== XMLHttpRequest.DONE) {
+ return;
+ }
+ if (request.status === 200 || request.status === 0) {
+ var data;
+ if (this.isCompressed && request.response) {
+ data = new Uint8Array(request.response);
+ } else if (!this.isCompressed && request.responseText) {
+ data = stringToBytes(request.responseText);
+ }
+ if (data) {
+ resolve({
+ cMapData: data,
+ compressionType: this.isCompressed ? CMapCompressionType.BINARY : CMapCompressionType.NONE
+ });
+ return;
+ }
+ }
+ reject(new Error('Unable to load ' + (this.isCompressed ? 'binary ' : '') + 'CMap at: ' + url));
+ }.bind(this);
+ request.send(null);
+ }.bind(this));
+ }
+ };
+ return DOMCMapReaderFactory;
+}();
+var CustomStyle = function CustomStyleClosure() {
+ var prefixes = ['ms', 'Moz', 'Webkit', 'O'];
+ var _cache = Object.create(null);
+ function CustomStyle() {}
+ CustomStyle.getProp = function get(propName, element) {
+ if (arguments.length === 1 && typeof _cache[propName] === 'string') {
+ return _cache[propName];
+ }
+ element = element || document.documentElement;
+ var style = element.style,
+ prefixed,
+ uPropName;
+ if (typeof style[propName] === 'string') {
+ return _cache[propName] = propName;
+ }
+ uPropName = propName.charAt(0).toUpperCase() + propName.slice(1);
+ for (var i = 0, l = prefixes.length; i < l; i++) {
+ prefixed = prefixes[i] + uPropName;
+ if (typeof style[prefixed] === 'string') {
+ return _cache[propName] = prefixed;
+ }
+ }
+ return _cache[propName] = 'undefined';
+ };
+ CustomStyle.setProp = function set(propName, element, str) {
+ var prop = this.getProp(propName);
+ if (prop !== 'undefined') {
+ element.style[prop] = str;
+ }
+ };
+ return CustomStyle;
+}();
+var RenderingCancelledException = function RenderingCancelledException() {
+ function RenderingCancelledException(msg, type) {
+ this.message = msg;
+ this.type = type;
+ }
+ RenderingCancelledException.prototype = new Error();
+ RenderingCancelledException.prototype.name = 'RenderingCancelledException';
+ RenderingCancelledException.constructor = RenderingCancelledException;
+ return RenderingCancelledException;
+}();
+var hasCanvasTypedArrays;
+hasCanvasTypedArrays = function hasCanvasTypedArrays() {
+ var canvas = document.createElement('canvas');
+ canvas.width = canvas.height = 1;
+ var ctx = canvas.getContext('2d');
+ var imageData = ctx.createImageData(1, 1);
+ return typeof imageData.data.buffer !== 'undefined';
+};
+var LinkTarget = {
+ NONE: 0,
+ SELF: 1,
+ BLANK: 2,
+ PARENT: 3,
+ TOP: 4
+};
+var LinkTargetStringMap = ['', '_self', '_blank', '_parent', '_top'];
+function addLinkAttributes(link, params) {
+ var url = params && params.url;
+ link.href = link.title = url ? removeNullCharacters(url) : '';
+ if (url) {
+ var target = params.target;
+ if (typeof target === 'undefined') {
+ target = getDefaultSetting('externalLinkTarget');
+ }
+ link.target = LinkTargetStringMap[target];
+ var rel = params.rel;
+ if (typeof rel === 'undefined') {
+ rel = getDefaultSetting('externalLinkRel');
+ }
+ link.rel = rel;
+ }
+}
+function getFilenameFromUrl(url) {
+ var anchor = url.indexOf('#');
+ var query = url.indexOf('?');
+ var end = Math.min(anchor > 0 ? anchor : url.length, query > 0 ? query : url.length);
+ return url.substring(url.lastIndexOf('/', end) + 1, end);
+}
+function getDefaultSetting(id) {
+ var globalSettings = sharedUtil.globalScope.PDFJS;
+ switch (id) {
+ case 'pdfBug':
+ return globalSettings ? globalSettings.pdfBug : false;
+ case 'disableAutoFetch':
+ return globalSettings ? globalSettings.disableAutoFetch : false;
+ case 'disableStream':
+ return globalSettings ? globalSettings.disableStream : false;
+ case 'disableRange':
+ return globalSettings ? globalSettings.disableRange : false;
+ case 'disableFontFace':
+ return globalSettings ? globalSettings.disableFontFace : false;
+ case 'disableCreateObjectURL':
+ return globalSettings ? globalSettings.disableCreateObjectURL : false;
+ case 'disableWebGL':
+ return globalSettings ? globalSettings.disableWebGL : true;
+ case 'cMapUrl':
+ return globalSettings ? globalSettings.cMapUrl : null;
+ case 'cMapPacked':
+ return globalSettings ? globalSettings.cMapPacked : false;
+ case 'postMessageTransfers':
+ return globalSettings ? globalSettings.postMessageTransfers : true;
+ case 'workerPort':
+ return globalSettings ? globalSettings.workerPort : null;
+ case 'workerSrc':
+ return globalSettings ? globalSettings.workerSrc : null;
+ case 'disableWorker':
+ return globalSettings ? globalSettings.disableWorker : false;
+ case 'maxImageSize':
+ return globalSettings ? globalSettings.maxImageSize : -1;
+ case 'imageResourcesPath':
+ return globalSettings ? globalSettings.imageResourcesPath : '';
+ case 'isEvalSupported':
+ return globalSettings ? globalSettings.isEvalSupported : true;
+ case 'externalLinkTarget':
+ if (!globalSettings) {
+ return LinkTarget.NONE;
+ }
+ switch (globalSettings.externalLinkTarget) {
+ case LinkTarget.NONE:
+ case LinkTarget.SELF:
+ case LinkTarget.BLANK:
+ case LinkTarget.PARENT:
+ case LinkTarget.TOP:
+ return globalSettings.externalLinkTarget;
+ }
+ warn('PDFJS.externalLinkTarget is invalid: ' + globalSettings.externalLinkTarget);
+ globalSettings.externalLinkTarget = LinkTarget.NONE;
+ return LinkTarget.NONE;
+ case 'externalLinkRel':
+ return globalSettings ? globalSettings.externalLinkRel : DEFAULT_LINK_REL;
+ case 'enableStats':
+ return !!(globalSettings && globalSettings.enableStats);
+ case 'pdfjsNext':
+ return !!(globalSettings && globalSettings.pdfjsNext);
+ default:
+ throw new Error('Unknown default setting: ' + id);
+ }
+}
+function isExternalLinkTargetSet() {
+ var externalLinkTarget = getDefaultSetting('externalLinkTarget');
+ switch (externalLinkTarget) {
+ case LinkTarget.NONE:
+ return false;
+ case LinkTarget.SELF:
+ case LinkTarget.BLANK:
+ case LinkTarget.PARENT:
+ case LinkTarget.TOP:
+ return true;
+ }
+}
+function isValidUrl(url, allowRelative) {
+ deprecated('isValidUrl(), please use createValidAbsoluteUrl() instead.');
+ var baseUrl = allowRelative ? 'http://example.com' : null;
+ return createValidAbsoluteUrl(url, baseUrl) !== null;
+}
+exports.CustomStyle = CustomStyle;
+exports.addLinkAttributes = addLinkAttributes;
+exports.isExternalLinkTargetSet = isExternalLinkTargetSet;
+exports.isValidUrl = isValidUrl;
+exports.getFilenameFromUrl = getFilenameFromUrl;
+exports.LinkTarget = LinkTarget;
+exports.RenderingCancelledException = RenderingCancelledException;
+exports.hasCanvasTypedArrays = hasCanvasTypedArrays;
+exports.getDefaultSetting = getDefaultSetting;
+exports.DEFAULT_LINK_REL = DEFAULT_LINK_REL;
+exports.DOMCanvasFactory = DOMCanvasFactory;
+exports.DOMCMapReaderFactory = DOMCMapReaderFactory;
+
+/***/ }),
+/* 2 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var AnnotationBorderStyleType = sharedUtil.AnnotationBorderStyleType;
+var AnnotationType = sharedUtil.AnnotationType;
+var stringToPDFString = sharedUtil.stringToPDFString;
+var Util = sharedUtil.Util;
+var addLinkAttributes = displayDOMUtils.addLinkAttributes;
+var LinkTarget = displayDOMUtils.LinkTarget;
+var getFilenameFromUrl = displayDOMUtils.getFilenameFromUrl;
+var warn = sharedUtil.warn;
+var CustomStyle = displayDOMUtils.CustomStyle;
+var getDefaultSetting = displayDOMUtils.getDefaultSetting;
+function AnnotationElementFactory() {}
+AnnotationElementFactory.prototype = {
+ create: function AnnotationElementFactory_create(parameters) {
+ var subtype = parameters.data.annotationType;
+ switch (subtype) {
+ case AnnotationType.LINK:
+ return new LinkAnnotationElement(parameters);
+ case AnnotationType.TEXT:
+ return new TextAnnotationElement(parameters);
+ case AnnotationType.WIDGET:
+ var fieldType = parameters.data.fieldType;
+ switch (fieldType) {
+ case 'Tx':
+ return new TextWidgetAnnotationElement(parameters);
+ case 'Btn':
+ if (parameters.data.radioButton) {
+ return new RadioButtonWidgetAnnotationElement(parameters);
+ } else if (parameters.data.checkBox) {
+ return new CheckboxWidgetAnnotationElement(parameters);
+ }
+ warn('Unimplemented button widget annotation: pushbutton');
+ break;
+ case 'Ch':
+ return new ChoiceWidgetAnnotationElement(parameters);
+ }
+ return new WidgetAnnotationElement(parameters);
+ case AnnotationType.POPUP:
+ return new PopupAnnotationElement(parameters);
+ case AnnotationType.HIGHLIGHT:
+ return new HighlightAnnotationElement(parameters);
+ case AnnotationType.UNDERLINE:
+ return new UnderlineAnnotationElement(parameters);
+ case AnnotationType.SQUIGGLY:
+ return new SquigglyAnnotationElement(parameters);
+ case AnnotationType.STRIKEOUT:
+ return new StrikeOutAnnotationElement(parameters);
+ case AnnotationType.FILEATTACHMENT:
+ return new FileAttachmentAnnotationElement(parameters);
+ default:
+ return new AnnotationElement(parameters);
+ }
+ }
+};
+var AnnotationElement = function AnnotationElementClosure() {
+ function AnnotationElement(parameters, isRenderable) {
+ this.isRenderable = isRenderable || false;
+ this.data = parameters.data;
+ this.layer = parameters.layer;
+ this.page = parameters.page;
+ this.viewport = parameters.viewport;
+ this.linkService = parameters.linkService;
+ this.downloadManager = parameters.downloadManager;
+ this.imageResourcesPath = parameters.imageResourcesPath;
+ this.renderInteractiveForms = parameters.renderInteractiveForms;
+ if (isRenderable) {
+ this.container = this._createContainer();
+ }
+ }
+ AnnotationElement.prototype = {
+ _createContainer: function AnnotationElement_createContainer() {
+ var data = this.data,
+ page = this.page,
+ viewport = this.viewport;
+ var container = document.createElement('section');
+ var width = data.rect[2] - data.rect[0];
+ var height = data.rect[3] - data.rect[1];
+ container.setAttribute('data-annotation-id', data.id);
+ var rect = Util.normalizeRect([data.rect[0], page.view[3] - data.rect[1] + page.view[1], data.rect[2], page.view[3] - data.rect[3] + page.view[1]]);
+ CustomStyle.setProp('transform', container, 'matrix(' + viewport.transform.join(',') + ')');
+ CustomStyle.setProp('transformOrigin', container, -rect[0] + 'px ' + -rect[1] + 'px');
+ if (data.borderStyle.width > 0) {
+ container.style.borderWidth = data.borderStyle.width + 'px';
+ if (data.borderStyle.style !== AnnotationBorderStyleType.UNDERLINE) {
+ width = width - 2 * data.borderStyle.width;
+ height = height - 2 * data.borderStyle.width;
+ }
+ var horizontalRadius = data.borderStyle.horizontalCornerRadius;
+ var verticalRadius = data.borderStyle.verticalCornerRadius;
+ if (horizontalRadius > 0 || verticalRadius > 0) {
+ var radius = horizontalRadius + 'px / ' + verticalRadius + 'px';
+ CustomStyle.setProp('borderRadius', container, radius);
+ }
+ switch (data.borderStyle.style) {
+ case AnnotationBorderStyleType.SOLID:
+ container.style.borderStyle = 'solid';
+ break;
+ case AnnotationBorderStyleType.DASHED:
+ container.style.borderStyle = 'dashed';
+ break;
+ case AnnotationBorderStyleType.BEVELED:
+ warn('Unimplemented border style: beveled');
+ break;
+ case AnnotationBorderStyleType.INSET:
+ warn('Unimplemented border style: inset');
+ break;
+ case AnnotationBorderStyleType.UNDERLINE:
+ container.style.borderBottomStyle = 'solid';
+ break;
+ default:
+ break;
+ }
+ if (data.color) {
+ container.style.borderColor = Util.makeCssRgb(data.color[0] | 0, data.color[1] | 0, data.color[2] | 0);
+ } else {
+ container.style.borderWidth = 0;
+ }
+ }
+ container.style.left = rect[0] + 'px';
+ container.style.top = rect[1] + 'px';
+ container.style.width = width + 'px';
+ container.style.height = height + 'px';
+ return container;
+ },
+ _createPopup: function AnnotationElement_createPopup(container, trigger, data) {
+ if (!trigger) {
+ trigger = document.createElement('div');
+ trigger.style.height = container.style.height;
+ trigger.style.width = container.style.width;
+ container.appendChild(trigger);
+ }
+ var popupElement = new PopupElement({
+ container: container,
+ trigger: trigger,
+ color: data.color,
+ title: data.title,
+ contents: data.contents,
+ hideWrapper: true
+ });
+ var popup = popupElement.render();
+ popup.style.left = container.style.width;
+ container.appendChild(popup);
+ },
+ render: function AnnotationElement_render() {
+ throw new Error('Abstract method AnnotationElement.render called');
+ }
+ };
+ return AnnotationElement;
+}();
+var LinkAnnotationElement = function LinkAnnotationElementClosure() {
+ function LinkAnnotationElement(parameters) {
+ AnnotationElement.call(this, parameters, true);
+ }
+ Util.inherit(LinkAnnotationElement, AnnotationElement, {
+ render: function LinkAnnotationElement_render() {
+ this.container.className = 'linkAnnotation';
+ var link = document.createElement('a');
+ addLinkAttributes(link, {
+ url: this.data.url,
+ target: this.data.newWindow ? LinkTarget.BLANK : undefined
+ });
+ if (!this.data.url) {
+ if (this.data.action) {
+ this._bindNamedAction(link, this.data.action);
+ } else {
+ this._bindLink(link, this.data.dest);
+ }
+ }
+ this.container.appendChild(link);
+ return this.container;
+ },
+ _bindLink: function LinkAnnotationElement_bindLink(link, destination) {
+ var self = this;
+ link.href = this.linkService.getDestinationHash(destination);
+ link.onclick = function () {
+ if (destination) {
+ self.linkService.navigateTo(destination);
+ }
+ return false;
+ };
+ if (destination) {
+ link.className = 'internalLink';
+ }
+ },
+ _bindNamedAction: function LinkAnnotationElement_bindNamedAction(link, action) {
+ var self = this;
+ link.href = this.linkService.getAnchorUrl('');
+ link.onclick = function () {
+ self.linkService.executeNamedAction(action);
+ return false;
+ };
+ link.className = 'internalLink';
+ }
+ });
+ return LinkAnnotationElement;
+}();
+var TextAnnotationElement = function TextAnnotationElementClosure() {
+ function TextAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(TextAnnotationElement, AnnotationElement, {
+ render: function TextAnnotationElement_render() {
+ this.container.className = 'textAnnotation';
+ var image = document.createElement('img');
+ image.style.height = this.container.style.height;
+ image.style.width = this.container.style.width;
+ image.src = this.imageResourcesPath + 'annotation-' + this.data.name.toLowerCase() + '.svg';
+ image.alt = '[{{type}} Annotation]';
+ image.dataset.l10nId = 'text_annotation_type';
+ image.dataset.l10nArgs = JSON.stringify({ type: this.data.name });
+ if (!this.data.hasPopup) {
+ this._createPopup(this.container, image, this.data);
+ }
+ this.container.appendChild(image);
+ return this.container;
+ }
+ });
+ return TextAnnotationElement;
+}();
+var WidgetAnnotationElement = function WidgetAnnotationElementClosure() {
+ function WidgetAnnotationElement(parameters, isRenderable) {
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(WidgetAnnotationElement, AnnotationElement, {
+ render: function WidgetAnnotationElement_render() {
+ return this.container;
+ }
+ });
+ return WidgetAnnotationElement;
+}();
+var TextWidgetAnnotationElement = function TextWidgetAnnotationElementClosure() {
+ var TEXT_ALIGNMENT = ['left', 'center', 'right'];
+ function TextWidgetAnnotationElement(parameters) {
+ var isRenderable = parameters.renderInteractiveForms || !parameters.data.hasAppearance && !!parameters.data.fieldValue;
+ WidgetAnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(TextWidgetAnnotationElement, WidgetAnnotationElement, {
+ render: function TextWidgetAnnotationElement_render() {
+ this.container.className = 'textWidgetAnnotation';
+ var element = null;
+ if (this.renderInteractiveForms) {
+ if (this.data.multiLine) {
+ element = document.createElement('textarea');
+ element.textContent = this.data.fieldValue;
+ } else {
+ element = document.createElement('input');
+ element.type = 'text';
+ element.setAttribute('value', this.data.fieldValue);
+ }
+ element.disabled = this.data.readOnly;
+ if (this.data.maxLen !== null) {
+ element.maxLength = this.data.maxLen;
+ }
+ if (this.data.comb) {
+ var fieldWidth = this.data.rect[2] - this.data.rect[0];
+ var combWidth = fieldWidth / this.data.maxLen;
+ element.classList.add('comb');
+ element.style.letterSpacing = 'calc(' + combWidth + 'px - 1ch)';
+ }
+ } else {
+ element = document.createElement('div');
+ element.textContent = this.data.fieldValue;
+ element.style.verticalAlign = 'middle';
+ element.style.display = 'table-cell';
+ var font = null;
+ if (this.data.fontRefName) {
+ font = this.page.commonObjs.getData(this.data.fontRefName);
+ }
+ this._setTextStyle(element, font);
+ }
+ if (this.data.textAlignment !== null) {
+ element.style.textAlign = TEXT_ALIGNMENT[this.data.textAlignment];
+ }
+ this.container.appendChild(element);
+ return this.container;
+ },
+ _setTextStyle: function TextWidgetAnnotationElement_setTextStyle(element, font) {
+ var style = element.style;
+ style.fontSize = this.data.fontSize + 'px';
+ style.direction = this.data.fontDirection < 0 ? 'rtl' : 'ltr';
+ if (!font) {
+ return;
+ }
+ style.fontWeight = font.black ? font.bold ? '900' : 'bold' : font.bold ? 'bold' : 'normal';
+ style.fontStyle = font.italic ? 'italic' : 'normal';
+ var fontFamily = font.loadedName ? '"' + font.loadedName + '", ' : '';
+ var fallbackName = font.fallbackName || 'Helvetica, sans-serif';
+ style.fontFamily = fontFamily + fallbackName;
+ }
+ });
+ return TextWidgetAnnotationElement;
+}();
+var CheckboxWidgetAnnotationElement = function CheckboxWidgetAnnotationElementClosure() {
+ function CheckboxWidgetAnnotationElement(parameters) {
+ WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
+ }
+ Util.inherit(CheckboxWidgetAnnotationElement, WidgetAnnotationElement, {
+ render: function CheckboxWidgetAnnotationElement_render() {
+ this.container.className = 'buttonWidgetAnnotation checkBox';
+ var element = document.createElement('input');
+ element.disabled = this.data.readOnly;
+ element.type = 'checkbox';
+ if (this.data.fieldValue && this.data.fieldValue !== 'Off') {
+ element.setAttribute('checked', true);
+ }
+ this.container.appendChild(element);
+ return this.container;
+ }
+ });
+ return CheckboxWidgetAnnotationElement;
+}();
+var RadioButtonWidgetAnnotationElement = function RadioButtonWidgetAnnotationElementClosure() {
+ function RadioButtonWidgetAnnotationElement(parameters) {
+ WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
+ }
+ Util.inherit(RadioButtonWidgetAnnotationElement, WidgetAnnotationElement, {
+ render: function RadioButtonWidgetAnnotationElement_render() {
+ this.container.className = 'buttonWidgetAnnotation radioButton';
+ var element = document.createElement('input');
+ element.disabled = this.data.readOnly;
+ element.type = 'radio';
+ element.name = this.data.fieldName;
+ if (this.data.fieldValue === this.data.buttonValue) {
+ element.setAttribute('checked', true);
+ }
+ this.container.appendChild(element);
+ return this.container;
+ }
+ });
+ return RadioButtonWidgetAnnotationElement;
+}();
+var ChoiceWidgetAnnotationElement = function ChoiceWidgetAnnotationElementClosure() {
+ function ChoiceWidgetAnnotationElement(parameters) {
+ WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
+ }
+ Util.inherit(ChoiceWidgetAnnotationElement, WidgetAnnotationElement, {
+ render: function ChoiceWidgetAnnotationElement_render() {
+ this.container.className = 'choiceWidgetAnnotation';
+ var selectElement = document.createElement('select');
+ selectElement.disabled = this.data.readOnly;
+ if (!this.data.combo) {
+ selectElement.size = this.data.options.length;
+ if (this.data.multiSelect) {
+ selectElement.multiple = true;
+ }
+ }
+ for (var i = 0, ii = this.data.options.length; i < ii; i++) {
+ var option = this.data.options[i];
+ var optionElement = document.createElement('option');
+ optionElement.textContent = option.displayValue;
+ optionElement.value = option.exportValue;
+ if (this.data.fieldValue.indexOf(option.displayValue) >= 0) {
+ optionElement.setAttribute('selected', true);
+ }
+ selectElement.appendChild(optionElement);
+ }
+ this.container.appendChild(selectElement);
+ return this.container;
+ }
+ });
+ return ChoiceWidgetAnnotationElement;
+}();
+var PopupAnnotationElement = function PopupAnnotationElementClosure() {
+ function PopupAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(PopupAnnotationElement, AnnotationElement, {
+ render: function PopupAnnotationElement_render() {
+ this.container.className = 'popupAnnotation';
+ var selector = '[data-annotation-id="' + this.data.parentId + '"]';
+ var parentElement = this.layer.querySelector(selector);
+ if (!parentElement) {
+ return this.container;
+ }
+ var popup = new PopupElement({
+ container: this.container,
+ trigger: parentElement,
+ color: this.data.color,
+ title: this.data.title,
+ contents: this.data.contents
+ });
+ var parentLeft = parseFloat(parentElement.style.left);
+ var parentWidth = parseFloat(parentElement.style.width);
+ CustomStyle.setProp('transformOrigin', this.container, -(parentLeft + parentWidth) + 'px -' + parentElement.style.top);
+ this.container.style.left = parentLeft + parentWidth + 'px';
+ this.container.appendChild(popup.render());
+ return this.container;
+ }
+ });
+ return PopupAnnotationElement;
+}();
+var PopupElement = function PopupElementClosure() {
+ var BACKGROUND_ENLIGHT = 0.7;
+ function PopupElement(parameters) {
+ this.container = parameters.container;
+ this.trigger = parameters.trigger;
+ this.color = parameters.color;
+ this.title = parameters.title;
+ this.contents = parameters.contents;
+ this.hideWrapper = parameters.hideWrapper || false;
+ this.pinned = false;
+ }
+ PopupElement.prototype = {
+ render: function PopupElement_render() {
+ var wrapper = document.createElement('div');
+ wrapper.className = 'popupWrapper';
+ this.hideElement = this.hideWrapper ? wrapper : this.container;
+ this.hideElement.setAttribute('hidden', true);
+ var popup = document.createElement('div');
+ popup.className = 'popup';
+ var color = this.color;
+ if (color) {
+ var r = BACKGROUND_ENLIGHT * (255 - color[0]) + color[0];
+ var g = BACKGROUND_ENLIGHT * (255 - color[1]) + color[1];
+ var b = BACKGROUND_ENLIGHT * (255 - color[2]) + color[2];
+ popup.style.backgroundColor = Util.makeCssRgb(r | 0, g | 0, b | 0);
+ }
+ var contents = this._formatContents(this.contents);
+ var title = document.createElement('h1');
+ title.textContent = this.title;
+ this.trigger.addEventListener('click', this._toggle.bind(this));
+ this.trigger.addEventListener('mouseover', this._show.bind(this, false));
+ this.trigger.addEventListener('mouseout', this._hide.bind(this, false));
+ popup.addEventListener('click', this._hide.bind(this, true));
+ popup.appendChild(title);
+ popup.appendChild(contents);
+ wrapper.appendChild(popup);
+ return wrapper;
+ },
+ _formatContents: function PopupElement_formatContents(contents) {
+ var p = document.createElement('p');
+ var lines = contents.split(/(?:\r\n?|\n)/);
+ for (var i = 0, ii = lines.length; i < ii; ++i) {
+ var line = lines[i];
+ p.appendChild(document.createTextNode(line));
+ if (i < ii - 1) {
+ p.appendChild(document.createElement('br'));
+ }
+ }
+ return p;
+ },
+ _toggle: function PopupElement_toggle() {
+ if (this.pinned) {
+ this._hide(true);
+ } else {
+ this._show(true);
+ }
+ },
+ _show: function PopupElement_show(pin) {
+ if (pin) {
+ this.pinned = true;
+ }
+ if (this.hideElement.hasAttribute('hidden')) {
+ this.hideElement.removeAttribute('hidden');
+ this.container.style.zIndex += 1;
+ }
+ },
+ _hide: function PopupElement_hide(unpin) {
+ if (unpin) {
+ this.pinned = false;
+ }
+ if (!this.hideElement.hasAttribute('hidden') && !this.pinned) {
+ this.hideElement.setAttribute('hidden', true);
+ this.container.style.zIndex -= 1;
+ }
+ }
+ };
+ return PopupElement;
+}();
+var HighlightAnnotationElement = function HighlightAnnotationElementClosure() {
+ function HighlightAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(HighlightAnnotationElement, AnnotationElement, {
+ render: function HighlightAnnotationElement_render() {
+ this.container.className = 'highlightAnnotation';
+ if (!this.data.hasPopup) {
+ this._createPopup(this.container, null, this.data);
+ }
+ return this.container;
+ }
+ });
+ return HighlightAnnotationElement;
+}();
+var UnderlineAnnotationElement = function UnderlineAnnotationElementClosure() {
+ function UnderlineAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(UnderlineAnnotationElement, AnnotationElement, {
+ render: function UnderlineAnnotationElement_render() {
+ this.container.className = 'underlineAnnotation';
+ if (!this.data.hasPopup) {
+ this._createPopup(this.container, null, this.data);
+ }
+ return this.container;
+ }
+ });
+ return UnderlineAnnotationElement;
+}();
+var SquigglyAnnotationElement = function SquigglyAnnotationElementClosure() {
+ function SquigglyAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(SquigglyAnnotationElement, AnnotationElement, {
+ render: function SquigglyAnnotationElement_render() {
+ this.container.className = 'squigglyAnnotation';
+ if (!this.data.hasPopup) {
+ this._createPopup(this.container, null, this.data);
+ }
+ return this.container;
+ }
+ });
+ return SquigglyAnnotationElement;
+}();
+var StrikeOutAnnotationElement = function StrikeOutAnnotationElementClosure() {
+ function StrikeOutAnnotationElement(parameters) {
+ var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
+ AnnotationElement.call(this, parameters, isRenderable);
+ }
+ Util.inherit(StrikeOutAnnotationElement, AnnotationElement, {
+ render: function StrikeOutAnnotationElement_render() {
+ this.container.className = 'strikeoutAnnotation';
+ if (!this.data.hasPopup) {
+ this._createPopup(this.container, null, this.data);
+ }
+ return this.container;
+ }
+ });
+ return StrikeOutAnnotationElement;
+}();
+var FileAttachmentAnnotationElement = function FileAttachmentAnnotationElementClosure() {
+ function FileAttachmentAnnotationElement(parameters) {
+ AnnotationElement.call(this, parameters, true);
+ var file = this.data.file;
+ this.filename = getFilenameFromUrl(file.filename);
+ this.content = file.content;
+ this.linkService.onFileAttachmentAnnotation({
+ id: stringToPDFString(file.filename),
+ filename: file.filename,
+ content: file.content
+ });
+ }
+ Util.inherit(FileAttachmentAnnotationElement, AnnotationElement, {
+ render: function FileAttachmentAnnotationElement_render() {
+ this.container.className = 'fileAttachmentAnnotation';
+ var trigger = document.createElement('div');
+ trigger.style.height = this.container.style.height;
+ trigger.style.width = this.container.style.width;
+ trigger.addEventListener('dblclick', this._download.bind(this));
+ if (!this.data.hasPopup && (this.data.title || this.data.contents)) {
+ this._createPopup(this.container, trigger, this.data);
+ }
+ this.container.appendChild(trigger);
+ return this.container;
+ },
+ _download: function FileAttachmentAnnotationElement_download() {
+ if (!this.downloadManager) {
+ warn('Download cannot be started due to unavailable download manager');
+ return;
+ }
+ this.downloadManager.downloadData(this.content, this.filename, '');
+ }
+ });
+ return FileAttachmentAnnotationElement;
+}();
+var AnnotationLayer = function AnnotationLayerClosure() {
+ return {
+ render: function AnnotationLayer_render(parameters) {
+ var annotationElementFactory = new AnnotationElementFactory();
+ for (var i = 0, ii = parameters.annotations.length; i < ii; i++) {
+ var data = parameters.annotations[i];
+ if (!data) {
+ continue;
+ }
+ var element = annotationElementFactory.create({
+ data: data,
+ layer: parameters.div,
+ page: parameters.page,
+ viewport: parameters.viewport,
+ linkService: parameters.linkService,
+ downloadManager: parameters.downloadManager,
+ imageResourcesPath: parameters.imageResourcesPath || getDefaultSetting('imageResourcesPath'),
+ renderInteractiveForms: parameters.renderInteractiveForms || false
+ });
+ if (element.isRenderable) {
+ parameters.div.appendChild(element.render());
+ }
+ }
+ },
+ update: function AnnotationLayer_update(parameters) {
+ for (var i = 0, ii = parameters.annotations.length; i < ii; i++) {
+ var data = parameters.annotations[i];
+ var element = parameters.div.querySelector('[data-annotation-id="' + data.id + '"]');
+ if (element) {
+ CustomStyle.setProp('transform', element, 'matrix(' + parameters.viewport.transform.join(',') + ')');
+ }
+ }
+ parameters.div.removeAttribute('hidden');
+ }
+ };
+}();
+exports.AnnotationLayer = AnnotationLayer;
+
+/***/ }),
+/* 3 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayFontLoader = __w_pdfjs_require__(11);
+var displayCanvas = __w_pdfjs_require__(10);
+var displayMetadata = __w_pdfjs_require__(7);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var amdRequire;
+var InvalidPDFException = sharedUtil.InvalidPDFException;
+var MessageHandler = sharedUtil.MessageHandler;
+var MissingPDFException = sharedUtil.MissingPDFException;
+var PageViewport = sharedUtil.PageViewport;
+var PasswordException = sharedUtil.PasswordException;
+var StatTimer = sharedUtil.StatTimer;
+var UnexpectedResponseException = sharedUtil.UnexpectedResponseException;
+var UnknownErrorException = sharedUtil.UnknownErrorException;
+var Util = sharedUtil.Util;
+var createPromiseCapability = sharedUtil.createPromiseCapability;
+var error = sharedUtil.error;
+var deprecated = sharedUtil.deprecated;
+var getVerbosityLevel = sharedUtil.getVerbosityLevel;
+var info = sharedUtil.info;
+var isInt = sharedUtil.isInt;
+var isArray = sharedUtil.isArray;
+var isArrayBuffer = sharedUtil.isArrayBuffer;
+var isSameOrigin = sharedUtil.isSameOrigin;
+var loadJpegStream = sharedUtil.loadJpegStream;
+var stringToBytes = sharedUtil.stringToBytes;
+var globalScope = sharedUtil.globalScope;
+var warn = sharedUtil.warn;
+var FontFaceObject = displayFontLoader.FontFaceObject;
+var FontLoader = displayFontLoader.FontLoader;
+var CanvasGraphics = displayCanvas.CanvasGraphics;
+var Metadata = displayMetadata.Metadata;
+var RenderingCancelledException = displayDOMUtils.RenderingCancelledException;
+var getDefaultSetting = displayDOMUtils.getDefaultSetting;
+var DOMCanvasFactory = displayDOMUtils.DOMCanvasFactory;
+var DOMCMapReaderFactory = displayDOMUtils.DOMCMapReaderFactory;
+var DEFAULT_RANGE_CHUNK_SIZE = 65536;
+var isWorkerDisabled = false;
+var workerSrc;
+var isPostMessageTransfersDisabled = false;
+var pdfjsFilePath = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : null;
+var fakeWorkerFilesLoader = null;
+var useRequireEnsure = false;
+if (typeof __pdfjsdev_webpack__ === 'undefined') {
+ if (typeof window === 'undefined') {
+ isWorkerDisabled = true;
+ if (typeof require.ensure === 'undefined') {
+ require.ensure = require('node-ensure');
+ }
+ useRequireEnsure = true;
+ } else if (typeof require !== 'undefined' && typeof require.ensure === 'function') {
+ useRequireEnsure = true;
+ }
+ if (typeof requirejs !== 'undefined' && requirejs.toUrl) {
+ workerSrc = requirejs.toUrl('pdfjs-dist/build/pdf.worker.js');
+ }
+ var dynamicLoaderSupported = typeof requirejs !== 'undefined' && requirejs.load;
+ fakeWorkerFilesLoader = useRequireEnsure ? function (callback) {
+ require.ensure([], function () {
+ var worker = require('./pdf.worker.js');
+ callback(worker.WorkerMessageHandler);
+ });
+ } : dynamicLoaderSupported ? function (callback) {
+ requirejs(['pdfjs-dist/build/pdf.worker'], function (worker) {
+ callback(worker.WorkerMessageHandler);
+ });
+ } : null;
+}
+function getDocument(src, pdfDataRangeTransport, passwordCallback, progressCallback) {
+ var task = new PDFDocumentLoadingTask();
+ if (arguments.length > 1) {
+ deprecated('getDocument is called with pdfDataRangeTransport, ' + 'passwordCallback or progressCallback argument');
+ }
+ if (pdfDataRangeTransport) {
+ if (!(pdfDataRangeTransport instanceof PDFDataRangeTransport)) {
+ pdfDataRangeTransport = Object.create(pdfDataRangeTransport);
+ pdfDataRangeTransport.length = src.length;
+ pdfDataRangeTransport.initialData = src.initialData;
+ if (!pdfDataRangeTransport.abort) {
+ pdfDataRangeTransport.abort = function () {};
+ }
+ }
+ src = Object.create(src);
+ src.range = pdfDataRangeTransport;
+ }
+ task.onPassword = passwordCallback || null;
+ task.onProgress = progressCallback || null;
+ var source;
+ if (typeof src === 'string') {
+ source = { url: src };
+ } else if (isArrayBuffer(src)) {
+ source = { data: src };
+ } else if (src instanceof PDFDataRangeTransport) {
+ source = { range: src };
+ } else {
+ if (typeof src !== 'object') {
+ error('Invalid parameter in getDocument, need either Uint8Array, ' + 'string or a parameter object');
+ }
+ if (!src.url && !src.data && !src.range) {
+ error('Invalid parameter object: need either .data, .range or .url');
+ }
+ source = src;
+ }
+ var params = {};
+ var rangeTransport = null;
+ var worker = null;
+ for (var key in source) {
+ if (key === 'url' && typeof window !== 'undefined') {
+ params[key] = new URL(source[key], window.location).href;
+ continue;
+ } else if (key === 'range') {
+ rangeTransport = source[key];
+ continue;
+ } else if (key === 'worker') {
+ worker = source[key];
+ continue;
+ } else if (key === 'data' && !(source[key] instanceof Uint8Array)) {
+ var pdfBytes = source[key];
+ if (typeof pdfBytes === 'string') {
+ params[key] = stringToBytes(pdfBytes);
+ } else if (typeof pdfBytes === 'object' && pdfBytes !== null && !isNaN(pdfBytes.length)) {
+ params[key] = new Uint8Array(pdfBytes);
+ } else if (isArrayBuffer(pdfBytes)) {
+ params[key] = new Uint8Array(pdfBytes);
+ } else {
+ error('Invalid PDF binary data: either typed array, string or ' + 'array-like object is expected in the data property.');
+ }
+ continue;
+ }
+ params[key] = source[key];
+ }
+ params.rangeChunkSize = params.rangeChunkSize || DEFAULT_RANGE_CHUNK_SIZE;
+ params.disableNativeImageDecoder = params.disableNativeImageDecoder === true;
+ var CMapReaderFactory = params.CMapReaderFactory || DOMCMapReaderFactory;
+ if (!worker) {
+ var workerPort = getDefaultSetting('workerPort');
+ worker = workerPort ? new PDFWorker(null, workerPort) : new PDFWorker();
+ task._worker = worker;
+ }
+ var docId = task.docId;
+ worker.promise.then(function () {
+ if (task.destroyed) {
+ throw new Error('Loading aborted');
+ }
+ return _fetchDocument(worker, params, rangeTransport, docId).then(function (workerId) {
+ if (task.destroyed) {
+ throw new Error('Loading aborted');
+ }
+ var messageHandler = new MessageHandler(docId, workerId, worker.port);
+ var transport = new WorkerTransport(messageHandler, task, rangeTransport, CMapReaderFactory);
+ task._transport = transport;
+ messageHandler.send('Ready', null);
+ });
+ }).catch(task._capability.reject);
+ return task;
+}
+function _fetchDocument(worker, source, pdfDataRangeTransport, docId) {
+ if (worker.destroyed) {
+ return Promise.reject(new Error('Worker was destroyed'));
+ }
+ source.disableAutoFetch = getDefaultSetting('disableAutoFetch');
+ source.disableStream = getDefaultSetting('disableStream');
+ source.chunkedViewerLoading = !!pdfDataRangeTransport;
+ if (pdfDataRangeTransport) {
+ source.length = pdfDataRangeTransport.length;
+ source.initialData = pdfDataRangeTransport.initialData;
+ }
+ return worker.messageHandler.sendWithPromise('GetDocRequest', {
+ docId: docId,
+ source: source,
+ disableRange: getDefaultSetting('disableRange'),
+ maxImageSize: getDefaultSetting('maxImageSize'),
+ disableFontFace: getDefaultSetting('disableFontFace'),
+ disableCreateObjectURL: getDefaultSetting('disableCreateObjectURL'),
+ postMessageTransfers: getDefaultSetting('postMessageTransfers') && !isPostMessageTransfersDisabled,
+ docBaseUrl: source.docBaseUrl,
+ disableNativeImageDecoder: source.disableNativeImageDecoder
+ }).then(function (workerId) {
+ if (worker.destroyed) {
+ throw new Error('Worker was destroyed');
+ }
+ return workerId;
+ });
+}
+var PDFDocumentLoadingTask = function PDFDocumentLoadingTaskClosure() {
+ var nextDocumentId = 0;
+ function PDFDocumentLoadingTask() {
+ this._capability = createPromiseCapability();
+ this._transport = null;
+ this._worker = null;
+ this.docId = 'd' + nextDocumentId++;
+ this.destroyed = false;
+ this.onPassword = null;
+ this.onProgress = null;
+ this.onUnsupportedFeature = null;
+ }
+ PDFDocumentLoadingTask.prototype = {
+ get promise() {
+ return this._capability.promise;
+ },
+ destroy: function () {
+ this.destroyed = true;
+ var transportDestroyed = !this._transport ? Promise.resolve() : this._transport.destroy();
+ return transportDestroyed.then(function () {
+ this._transport = null;
+ if (this._worker) {
+ this._worker.destroy();
+ this._worker = null;
+ }
+ }.bind(this));
+ },
+ then: function PDFDocumentLoadingTask_then(onFulfilled, onRejected) {
+ return this.promise.then.apply(this.promise, arguments);
+ }
+ };
+ return PDFDocumentLoadingTask;
+}();
+var PDFDataRangeTransport = function pdfDataRangeTransportClosure() {
+ function PDFDataRangeTransport(length, initialData) {
+ this.length = length;
+ this.initialData = initialData;
+ this._rangeListeners = [];
+ this._progressListeners = [];
+ this._progressiveReadListeners = [];
+ this._readyCapability = createPromiseCapability();
+ }
+ PDFDataRangeTransport.prototype = {
+ addRangeListener: function PDFDataRangeTransport_addRangeListener(listener) {
+ this._rangeListeners.push(listener);
+ },
+ addProgressListener: function PDFDataRangeTransport_addProgressListener(listener) {
+ this._progressListeners.push(listener);
+ },
+ addProgressiveReadListener: function PDFDataRangeTransport_addProgressiveReadListener(listener) {
+ this._progressiveReadListeners.push(listener);
+ },
+ onDataRange: function PDFDataRangeTransport_onDataRange(begin, chunk) {
+ var listeners = this._rangeListeners;
+ for (var i = 0, n = listeners.length; i < n; ++i) {
+ listeners[i](begin, chunk);
+ }
+ },
+ onDataProgress: function PDFDataRangeTransport_onDataProgress(loaded) {
+ this._readyCapability.promise.then(function () {
+ var listeners = this._progressListeners;
+ for (var i = 0, n = listeners.length; i < n; ++i) {
+ listeners[i](loaded);
+ }
+ }.bind(this));
+ },
+ onDataProgressiveRead: function PDFDataRangeTransport_onDataProgress(chunk) {
+ this._readyCapability.promise.then(function () {
+ var listeners = this._progressiveReadListeners;
+ for (var i = 0, n = listeners.length; i < n; ++i) {
+ listeners[i](chunk);
+ }
+ }.bind(this));
+ },
+ transportReady: function PDFDataRangeTransport_transportReady() {
+ this._readyCapability.resolve();
+ },
+ requestDataRange: function PDFDataRangeTransport_requestDataRange(begin, end) {
+ throw new Error('Abstract method PDFDataRangeTransport.requestDataRange');
+ },
+ abort: function PDFDataRangeTransport_abort() {}
+ };
+ return PDFDataRangeTransport;
+}();
+var PDFDocumentProxy = function PDFDocumentProxyClosure() {
+ function PDFDocumentProxy(pdfInfo, transport, loadingTask) {
+ this.pdfInfo = pdfInfo;
+ this.transport = transport;
+ this.loadingTask = loadingTask;
+ }
+ PDFDocumentProxy.prototype = {
+ get numPages() {
+ return this.pdfInfo.numPages;
+ },
+ get fingerprint() {
+ return this.pdfInfo.fingerprint;
+ },
+ getPage: function PDFDocumentProxy_getPage(pageNumber) {
+ return this.transport.getPage(pageNumber);
+ },
+ getPageIndex: function PDFDocumentProxy_getPageIndex(ref) {
+ return this.transport.getPageIndex(ref);
+ },
+ getDestinations: function PDFDocumentProxy_getDestinations() {
+ return this.transport.getDestinations();
+ },
+ getDestination: function PDFDocumentProxy_getDestination(id) {
+ return this.transport.getDestination(id);
+ },
+ getPageLabels: function PDFDocumentProxy_getPageLabels() {
+ return this.transport.getPageLabels();
+ },
+ getAttachments: function PDFDocumentProxy_getAttachments() {
+ return this.transport.getAttachments();
+ },
+ getJavaScript: function PDFDocumentProxy_getJavaScript() {
+ return this.transport.getJavaScript();
+ },
+ getOutline: function PDFDocumentProxy_getOutline() {
+ return this.transport.getOutline();
+ },
+ getMetadata: function PDFDocumentProxy_getMetadata() {
+ return this.transport.getMetadata();
+ },
+ getData: function PDFDocumentProxy_getData() {
+ return this.transport.getData();
+ },
+ getDownloadInfo: function PDFDocumentProxy_getDownloadInfo() {
+ return this.transport.downloadInfoCapability.promise;
+ },
+ getStats: function PDFDocumentProxy_getStats() {
+ return this.transport.getStats();
+ },
+ cleanup: function PDFDocumentProxy_cleanup() {
+ this.transport.startCleanup();
+ },
+ destroy: function PDFDocumentProxy_destroy() {
+ return this.loadingTask.destroy();
+ }
+ };
+ return PDFDocumentProxy;
+}();
+var PDFPageProxy = function PDFPageProxyClosure() {
+ function PDFPageProxy(pageIndex, pageInfo, transport) {
+ this.pageIndex = pageIndex;
+ this.pageInfo = pageInfo;
+ this.transport = transport;
+ this.stats = new StatTimer();
+ this.stats.enabled = getDefaultSetting('enableStats');
+ this.commonObjs = transport.commonObjs;
+ this.objs = new PDFObjects();
+ this.cleanupAfterRender = false;
+ this.pendingCleanup = false;
+ this.intentStates = Object.create(null);
+ this.destroyed = false;
+ }
+ PDFPageProxy.prototype = {
+ get pageNumber() {
+ return this.pageIndex + 1;
+ },
+ get rotate() {
+ return this.pageInfo.rotate;
+ },
+ get ref() {
+ return this.pageInfo.ref;
+ },
+ get userUnit() {
+ return this.pageInfo.userUnit;
+ },
+ get view() {
+ return this.pageInfo.view;
+ },
+ getViewport: function PDFPageProxy_getViewport(scale, rotate) {
+ if (arguments.length < 2) {
+ rotate = this.rotate;
+ }
+ return new PageViewport(this.view, scale, rotate, 0, 0);
+ },
+ getAnnotations: function PDFPageProxy_getAnnotations(params) {
+ var intent = params && params.intent || null;
+ if (!this.annotationsPromise || this.annotationsIntent !== intent) {
+ this.annotationsPromise = this.transport.getAnnotations(this.pageIndex, intent);
+ this.annotationsIntent = intent;
+ }
+ return this.annotationsPromise;
+ },
+ render: function PDFPageProxy_render(params) {
+ var stats = this.stats;
+ stats.time('Overall');
+ this.pendingCleanup = false;
+ var renderingIntent = params.intent === 'print' ? 'print' : 'display';
+ var renderInteractiveForms = params.renderInteractiveForms === true ? true : false;
+ var canvasFactory = params.canvasFactory || new DOMCanvasFactory();
+ if (!this.intentStates[renderingIntent]) {
+ this.intentStates[renderingIntent] = Object.create(null);
+ }
+ var intentState = this.intentStates[renderingIntent];
+ if (!intentState.displayReadyCapability) {
+ intentState.receivingOperatorList = true;
+ intentState.displayReadyCapability = createPromiseCapability();
+ intentState.operatorList = {
+ fnArray: [],
+ argsArray: [],
+ lastChunk: false
+ };
+ this.stats.time('Page Request');
+ this.transport.messageHandler.send('RenderPageRequest', {
+ pageIndex: this.pageNumber - 1,
+ intent: renderingIntent,
+ renderInteractiveForms: renderInteractiveForms
+ });
+ }
+ var internalRenderTask = new InternalRenderTask(complete, params, this.objs, this.commonObjs, intentState.operatorList, this.pageNumber, canvasFactory);
+ internalRenderTask.useRequestAnimationFrame = renderingIntent !== 'print';
+ if (!intentState.renderTasks) {
+ intentState.renderTasks = [];
+ }
+ intentState.renderTasks.push(internalRenderTask);
+ var renderTask = internalRenderTask.task;
+ if (params.continueCallback) {
+ deprecated('render is used with continueCallback parameter');
+ renderTask.onContinue = params.continueCallback;
+ }
+ var self = this;
+ intentState.displayReadyCapability.promise.then(function pageDisplayReadyPromise(transparency) {
+ if (self.pendingCleanup) {
+ complete();
+ return;
+ }
+ stats.time('Rendering');
+ internalRenderTask.initializeGraphics(transparency);
+ internalRenderTask.operatorListChanged();
+ }, function pageDisplayReadPromiseError(reason) {
+ complete(reason);
+ });
+ function complete(error) {
+ var i = intentState.renderTasks.indexOf(internalRenderTask);
+ if (i >= 0) {
+ intentState.renderTasks.splice(i, 1);
+ }
+ if (self.cleanupAfterRender) {
+ self.pendingCleanup = true;
+ }
+ self._tryCleanup();
+ if (error) {
+ internalRenderTask.capability.reject(error);
+ } else {
+ internalRenderTask.capability.resolve();
+ }
+ stats.timeEnd('Rendering');
+ stats.timeEnd('Overall');
+ }
+ return renderTask;
+ },
+ getOperatorList: function PDFPageProxy_getOperatorList() {
+ function operatorListChanged() {
+ if (intentState.operatorList.lastChunk) {
+ intentState.opListReadCapability.resolve(intentState.operatorList);
+ var i = intentState.renderTasks.indexOf(opListTask);
+ if (i >= 0) {
+ intentState.renderTasks.splice(i, 1);
+ }
+ }
+ }
+ var renderingIntent = 'oplist';
+ if (!this.intentStates[renderingIntent]) {
+ this.intentStates[renderingIntent] = Object.create(null);
+ }
+ var intentState = this.intentStates[renderingIntent];
+ var opListTask;
+ if (!intentState.opListReadCapability) {
+ opListTask = {};
+ opListTask.operatorListChanged = operatorListChanged;
+ intentState.receivingOperatorList = true;
+ intentState.opListReadCapability = createPromiseCapability();
+ intentState.renderTasks = [];
+ intentState.renderTasks.push(opListTask);
+ intentState.operatorList = {
+ fnArray: [],
+ argsArray: [],
+ lastChunk: false
+ };
+ this.transport.messageHandler.send('RenderPageRequest', {
+ pageIndex: this.pageIndex,
+ intent: renderingIntent
+ });
+ }
+ return intentState.opListReadCapability.promise;
+ },
+ getTextContent: function PDFPageProxy_getTextContent(params) {
+ return this.transport.messageHandler.sendWithPromise('GetTextContent', {
+ pageIndex: this.pageNumber - 1,
+ normalizeWhitespace: params && params.normalizeWhitespace === true ? true : false,
+ combineTextItems: params && params.disableCombineTextItems === true ? false : true
+ });
+ },
+ _destroy: function PDFPageProxy_destroy() {
+ this.destroyed = true;
+ this.transport.pageCache[this.pageIndex] = null;
+ var waitOn = [];
+ Object.keys(this.intentStates).forEach(function (intent) {
+ if (intent === 'oplist') {
+ return;
+ }
+ var intentState = this.intentStates[intent];
+ intentState.renderTasks.forEach(function (renderTask) {
+ var renderCompleted = renderTask.capability.promise.catch(function () {});
+ waitOn.push(renderCompleted);
+ renderTask.cancel();
+ });
+ }, this);
+ this.objs.clear();
+ this.annotationsPromise = null;
+ this.pendingCleanup = false;
+ return Promise.all(waitOn);
+ },
+ destroy: function () {
+ deprecated('page destroy method, use cleanup() instead');
+ this.cleanup();
+ },
+ cleanup: function PDFPageProxy_cleanup() {
+ this.pendingCleanup = true;
+ this._tryCleanup();
+ },
+ _tryCleanup: function PDFPageProxy_tryCleanup() {
+ if (!this.pendingCleanup || Object.keys(this.intentStates).some(function (intent) {
+ var intentState = this.intentStates[intent];
+ return intentState.renderTasks.length !== 0 || intentState.receivingOperatorList;
+ }, this)) {
+ return;
+ }
+ Object.keys(this.intentStates).forEach(function (intent) {
+ delete this.intentStates[intent];
+ }, this);
+ this.objs.clear();
+ this.annotationsPromise = null;
+ this.pendingCleanup = false;
+ },
+ _startRenderPage: function PDFPageProxy_startRenderPage(transparency, intent) {
+ var intentState = this.intentStates[intent];
+ if (intentState.displayReadyCapability) {
+ intentState.displayReadyCapability.resolve(transparency);
+ }
+ },
+ _renderPageChunk: function PDFPageProxy_renderPageChunk(operatorListChunk, intent) {
+ var intentState = this.intentStates[intent];
+ var i, ii;
+ for (i = 0, ii = operatorListChunk.length; i < ii; i++) {
+ intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
+ intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
+ }
+ intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
+ for (i = 0; i < intentState.renderTasks.length; i++) {
+ intentState.renderTasks[i].operatorListChanged();
+ }
+ if (operatorListChunk.lastChunk) {
+ intentState.receivingOperatorList = false;
+ this._tryCleanup();
+ }
+ }
+ };
+ return PDFPageProxy;
+}();
+var PDFWorker = function PDFWorkerClosure() {
+ var nextFakeWorkerId = 0;
+ function getWorkerSrc() {
+ if (typeof workerSrc !== 'undefined') {
+ return workerSrc;
+ }
+ if (getDefaultSetting('workerSrc')) {
+ return getDefaultSetting('workerSrc');
+ }
+ if (pdfjsFilePath) {
+ return pdfjsFilePath.replace(/\.js$/i, '.worker.js');
+ }
+ error('No PDFJS.workerSrc specified');
+ }
+ var fakeWorkerFilesLoadedCapability;
+ function setupFakeWorkerGlobal() {
+ var WorkerMessageHandler;
+ if (fakeWorkerFilesLoadedCapability) {
+ return fakeWorkerFilesLoadedCapability.promise;
+ }
+ fakeWorkerFilesLoadedCapability = createPromiseCapability();
+ var loader = fakeWorkerFilesLoader || function (callback) {
+ Util.loadScript(getWorkerSrc(), function () {
+ callback(window.pdfjsDistBuildPdfWorker.WorkerMessageHandler);
+ });
+ };
+ loader(fakeWorkerFilesLoadedCapability.resolve);
+ return fakeWorkerFilesLoadedCapability.promise;
+ }
+ function FakeWorkerPort(defer) {
+ this._listeners = [];
+ this._defer = defer;
+ this._deferred = Promise.resolve(undefined);
+ }
+ FakeWorkerPort.prototype = {
+ postMessage: function (obj, transfers) {
+ function cloneValue(value) {
+ if (typeof value !== 'object' || value === null) {
+ return value;
+ }
+ if (cloned.has(value)) {
+ return cloned.get(value);
+ }
+ var result;
+ var buffer;
+ if ((buffer = value.buffer) && isArrayBuffer(buffer)) {
+ var transferable = transfers && transfers.indexOf(buffer) >= 0;
+ if (value === buffer) {
+ result = value;
+ } else if (transferable) {
+ result = new value.constructor(buffer, value.byteOffset, value.byteLength);
+ } else {
+ result = new value.constructor(value);
+ }
+ cloned.set(value, result);
+ return result;
+ }
+ result = isArray(value) ? [] : {};
+ cloned.set(value, result);
+ for (var i in value) {
+ var desc,
+ p = value;
+ while (!(desc = Object.getOwnPropertyDescriptor(p, i))) {
+ p = Object.getPrototypeOf(p);
+ }
+ if (typeof desc.value === 'undefined' || typeof desc.value === 'function') {
+ continue;
+ }
+ result[i] = cloneValue(desc.value);
+ }
+ return result;
+ }
+ if (!this._defer) {
+ this._listeners.forEach(function (listener) {
+ listener.call(this, { data: obj });
+ }, this);
+ return;
+ }
+ var cloned = new WeakMap();
+ var e = { data: cloneValue(obj) };
+ this._deferred.then(function () {
+ this._listeners.forEach(function (listener) {
+ listener.call(this, e);
+ }, this);
+ }.bind(this));
+ },
+ addEventListener: function (name, listener) {
+ this._listeners.push(listener);
+ },
+ removeEventListener: function (name, listener) {
+ var i = this._listeners.indexOf(listener);
+ this._listeners.splice(i, 1);
+ },
+ terminate: function () {
+ this._listeners = [];
+ }
+ };
+ function createCDNWrapper(url) {
+ var wrapper = 'importScripts(\'' + url + '\');';
+ return URL.createObjectURL(new Blob([wrapper]));
+ }
+ function PDFWorker(name, port) {
+ this.name = name;
+ this.destroyed = false;
+ this._readyCapability = createPromiseCapability();
+ this._port = null;
+ this._webWorker = null;
+ this._messageHandler = null;
+ if (port) {
+ this._initializeFromPort(port);
+ return;
+ }
+ this._initialize();
+ }
+ PDFWorker.prototype = {
+ get promise() {
+ return this._readyCapability.promise;
+ },
+ get port() {
+ return this._port;
+ },
+ get messageHandler() {
+ return this._messageHandler;
+ },
+ _initializeFromPort: function PDFWorker_initializeFromPort(port) {
+ this._port = port;
+ this._messageHandler = new MessageHandler('main', 'worker', port);
+ this._messageHandler.on('ready', function () {});
+ this._readyCapability.resolve();
+ },
+ _initialize: function PDFWorker_initialize() {
+ if (!isWorkerDisabled && !getDefaultSetting('disableWorker') && typeof Worker !== 'undefined') {
+ var workerSrc = getWorkerSrc();
+ try {
+ if (!isSameOrigin(window.location.href, workerSrc)) {
+ workerSrc = createCDNWrapper(new URL(workerSrc, window.location).href);
+ }
+ var worker = new Worker(workerSrc);
+ var messageHandler = new MessageHandler('main', 'worker', worker);
+ var terminateEarly = function () {
+ worker.removeEventListener('error', onWorkerError);
+ messageHandler.destroy();
+ worker.terminate();
+ if (this.destroyed) {
+ this._readyCapability.reject(new Error('Worker was destroyed'));
+ } else {
+ this._setupFakeWorker();
+ }
+ }.bind(this);
+ var onWorkerError = function (event) {
+ if (!this._webWorker) {
+ terminateEarly();
+ }
+ }.bind(this);
+ worker.addEventListener('error', onWorkerError);
+ messageHandler.on('test', function PDFWorker_test(data) {
+ worker.removeEventListener('error', onWorkerError);
+ if (this.destroyed) {
+ terminateEarly();
+ return;
+ }
+ var supportTypedArray = data && data.supportTypedArray;
+ if (supportTypedArray) {
+ this._messageHandler = messageHandler;
+ this._port = worker;
+ this._webWorker = worker;
+ if (!data.supportTransfers) {
+ isPostMessageTransfersDisabled = true;
+ }
+ this._readyCapability.resolve();
+ messageHandler.send('configure', { verbosity: getVerbosityLevel() });
+ } else {
+ this._setupFakeWorker();
+ messageHandler.destroy();
+ worker.terminate();
+ }
+ }.bind(this));
+ messageHandler.on('console_log', function (data) {
+ console.log.apply(console, data);
+ });
+ messageHandler.on('console_error', function (data) {
+ console.error.apply(console, data);
+ });
+ messageHandler.on('ready', function (data) {
+ worker.removeEventListener('error', onWorkerError);
+ if (this.destroyed) {
+ terminateEarly();
+ return;
+ }
+ try {
+ sendTest();
+ } catch (e) {
+ this._setupFakeWorker();
+ }
+ }.bind(this));
+ var sendTest = function () {
+ var postMessageTransfers = getDefaultSetting('postMessageTransfers') && !isPostMessageTransfersDisabled;
+ var testObj = new Uint8Array([postMessageTransfers ? 255 : 0]);
+ try {
+ messageHandler.send('test', testObj, [testObj.buffer]);
+ } catch (ex) {
+ info('Cannot use postMessage transfers');
+ testObj[0] = 0;
+ messageHandler.send('test', testObj);
+ }
+ };
+ sendTest();
+ return;
+ } catch (e) {
+ info('The worker has been disabled.');
+ }
+ }
+ this._setupFakeWorker();
+ },
+ _setupFakeWorker: function PDFWorker_setupFakeWorker() {
+ if (!isWorkerDisabled && !getDefaultSetting('disableWorker')) {
+ warn('Setting up fake worker.');
+ isWorkerDisabled = true;
+ }
+ setupFakeWorkerGlobal().then(function (WorkerMessageHandler) {
+ if (this.destroyed) {
+ this._readyCapability.reject(new Error('Worker was destroyed'));
+ return;
+ }
+ var isTypedArraysPresent = Uint8Array !== Float32Array;
+ var port = new FakeWorkerPort(isTypedArraysPresent);
+ this._port = port;
+ var id = 'fake' + nextFakeWorkerId++;
+ var workerHandler = new MessageHandler(id + '_worker', id, port);
+ WorkerMessageHandler.setup(workerHandler, port);
+ var messageHandler = new MessageHandler(id, id + '_worker', port);
+ this._messageHandler = messageHandler;
+ this._readyCapability.resolve();
+ }.bind(this));
+ },
+ destroy: function PDFWorker_destroy() {
+ this.destroyed = true;
+ if (this._webWorker) {
+ this._webWorker.terminate();
+ this._webWorker = null;
+ }
+ this._port = null;
+ if (this._messageHandler) {
+ this._messageHandler.destroy();
+ this._messageHandler = null;
+ }
+ }
+ };
+ return PDFWorker;
+}();
+var WorkerTransport = function WorkerTransportClosure() {
+ function WorkerTransport(messageHandler, loadingTask, pdfDataRangeTransport, CMapReaderFactory) {
+ this.messageHandler = messageHandler;
+ this.loadingTask = loadingTask;
+ this.pdfDataRangeTransport = pdfDataRangeTransport;
+ this.commonObjs = new PDFObjects();
+ this.fontLoader = new FontLoader(loadingTask.docId);
+ this.CMapReaderFactory = new CMapReaderFactory({
+ baseUrl: getDefaultSetting('cMapUrl'),
+ isCompressed: getDefaultSetting('cMapPacked')
+ });
+ this.destroyed = false;
+ this.destroyCapability = null;
+ this._passwordCapability = null;
+ this.pageCache = [];
+ this.pagePromises = [];
+ this.downloadInfoCapability = createPromiseCapability();
+ this.setupMessageHandler();
+ }
+ WorkerTransport.prototype = {
+ destroy: function WorkerTransport_destroy() {
+ if (this.destroyCapability) {
+ return this.destroyCapability.promise;
+ }
+ this.destroyed = true;
+ this.destroyCapability = createPromiseCapability();
+ if (this._passwordCapability) {
+ this._passwordCapability.reject(new Error('Worker was destroyed during onPassword callback'));
+ }
+ var waitOn = [];
+ this.pageCache.forEach(function (page) {
+ if (page) {
+ waitOn.push(page._destroy());
+ }
+ });
+ this.pageCache = [];
+ this.pagePromises = [];
+ var self = this;
+ var terminated = this.messageHandler.sendWithPromise('Terminate', null);
+ waitOn.push(terminated);
+ Promise.all(waitOn).then(function () {
+ self.fontLoader.clear();
+ if (self.pdfDataRangeTransport) {
+ self.pdfDataRangeTransport.abort();
+ self.pdfDataRangeTransport = null;
+ }
+ if (self.messageHandler) {
+ self.messageHandler.destroy();
+ self.messageHandler = null;
+ }
+ self.destroyCapability.resolve();
+ }, this.destroyCapability.reject);
+ return this.destroyCapability.promise;
+ },
+ setupMessageHandler: function WorkerTransport_setupMessageHandler() {
+ var messageHandler = this.messageHandler;
+ var loadingTask = this.loadingTask;
+ var pdfDataRangeTransport = this.pdfDataRangeTransport;
+ if (pdfDataRangeTransport) {
+ pdfDataRangeTransport.addRangeListener(function (begin, chunk) {
+ messageHandler.send('OnDataRange', {
+ begin: begin,
+ chunk: chunk
+ });
+ });
+ pdfDataRangeTransport.addProgressListener(function (loaded) {
+ messageHandler.send('OnDataProgress', { loaded: loaded });
+ });
+ pdfDataRangeTransport.addProgressiveReadListener(function (chunk) {
+ messageHandler.send('OnDataRange', { chunk: chunk });
+ });
+ messageHandler.on('RequestDataRange', function transportDataRange(data) {
+ pdfDataRangeTransport.requestDataRange(data.begin, data.end);
+ }, this);
+ }
+ messageHandler.on('GetDoc', function transportDoc(data) {
+ var pdfInfo = data.pdfInfo;
+ this.numPages = data.pdfInfo.numPages;
+ var loadingTask = this.loadingTask;
+ var pdfDocument = new PDFDocumentProxy(pdfInfo, this, loadingTask);
+ this.pdfDocument = pdfDocument;
+ loadingTask._capability.resolve(pdfDocument);
+ }, this);
+ messageHandler.on('PasswordRequest', function transportPasswordRequest(exception) {
+ this._passwordCapability = createPromiseCapability();
+ if (loadingTask.onPassword) {
+ var updatePassword = function (password) {
+ this._passwordCapability.resolve({ password: password });
+ }.bind(this);
+ loadingTask.onPassword(updatePassword, exception.code);
+ } else {
+ this._passwordCapability.reject(new PasswordException(exception.message, exception.code));
+ }
+ return this._passwordCapability.promise;
+ }, this);
+ messageHandler.on('PasswordException', function transportPasswordException(exception) {
+ loadingTask._capability.reject(new PasswordException(exception.message, exception.code));
+ }, this);
+ messageHandler.on('InvalidPDF', function transportInvalidPDF(exception) {
+ this.loadingTask._capability.reject(new InvalidPDFException(exception.message));
+ }, this);
+ messageHandler.on('MissingPDF', function transportMissingPDF(exception) {
+ this.loadingTask._capability.reject(new MissingPDFException(exception.message));
+ }, this);
+ messageHandler.on('UnexpectedResponse', function transportUnexpectedResponse(exception) {
+ this.loadingTask._capability.reject(new UnexpectedResponseException(exception.message, exception.status));
+ }, this);
+ messageHandler.on('UnknownError', function transportUnknownError(exception) {
+ this.loadingTask._capability.reject(new UnknownErrorException(exception.message, exception.details));
+ }, this);
+ messageHandler.on('DataLoaded', function transportPage(data) {
+ this.downloadInfoCapability.resolve(data);
+ }, this);
+ messageHandler.on('PDFManagerReady', function transportPage(data) {
+ if (this.pdfDataRangeTransport) {
+ this.pdfDataRangeTransport.transportReady();
+ }
+ }, this);
+ messageHandler.on('StartRenderPage', function transportRender(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var page = this.pageCache[data.pageIndex];
+ page.stats.timeEnd('Page Request');
+ page._startRenderPage(data.transparency, data.intent);
+ }, this);
+ messageHandler.on('RenderPageChunk', function transportRender(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var page = this.pageCache[data.pageIndex];
+ page._renderPageChunk(data.operatorList, data.intent);
+ }, this);
+ messageHandler.on('commonobj', function transportObj(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var id = data[0];
+ var type = data[1];
+ if (this.commonObjs.hasData(id)) {
+ return;
+ }
+ switch (type) {
+ case 'Font':
+ var exportedData = data[2];
+ if ('error' in exportedData) {
+ var exportedError = exportedData.error;
+ warn('Error during font loading: ' + exportedError);
+ this.commonObjs.resolve(id, exportedError);
+ break;
+ }
+ var fontRegistry = null;
+ if (getDefaultSetting('pdfBug') && globalScope.FontInspector && globalScope['FontInspector'].enabled) {
+ fontRegistry = {
+ registerFont: function (font, url) {
+ globalScope['FontInspector'].fontAdded(font, url);
+ }
+ };
+ }
+ var font = new FontFaceObject(exportedData, {
+ isEvalSuported: getDefaultSetting('isEvalSupported'),
+ disableFontFace: getDefaultSetting('disableFontFace'),
+ fontRegistry: fontRegistry
+ });
+ this.fontLoader.bind([font], function fontReady(fontObjs) {
+ this.commonObjs.resolve(id, font);
+ }.bind(this));
+ break;
+ case 'FontPath':
+ this.commonObjs.resolve(id, data[2]);
+ break;
+ default:
+ error('Got unknown common object type ' + type);
+ }
+ }, this);
+ messageHandler.on('obj', function transportObj(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var id = data[0];
+ var pageIndex = data[1];
+ var type = data[2];
+ var pageProxy = this.pageCache[pageIndex];
+ var imageData;
+ if (pageProxy.objs.hasData(id)) {
+ return;
+ }
+ switch (type) {
+ case 'JpegStream':
+ imageData = data[3];
+ loadJpegStream(id, imageData, pageProxy.objs);
+ break;
+ case 'Image':
+ imageData = data[3];
+ pageProxy.objs.resolve(id, imageData);
+ var MAX_IMAGE_SIZE_TO_STORE = 8000000;
+ if (imageData && 'data' in imageData && imageData.data.length > MAX_IMAGE_SIZE_TO_STORE) {
+ pageProxy.cleanupAfterRender = true;
+ }
+ break;
+ default:
+ error('Got unknown object type ' + type);
+ }
+ }, this);
+ messageHandler.on('DocProgress', function transportDocProgress(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var loadingTask = this.loadingTask;
+ if (loadingTask.onProgress) {
+ loadingTask.onProgress({
+ loaded: data.loaded,
+ total: data.total
+ });
+ }
+ }, this);
+ messageHandler.on('PageError', function transportError(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var page = this.pageCache[data.pageNum - 1];
+ var intentState = page.intentStates[data.intent];
+ if (intentState.displayReadyCapability) {
+ intentState.displayReadyCapability.reject(data.error);
+ } else {
+ error(data.error);
+ }
+ if (intentState.operatorList) {
+ intentState.operatorList.lastChunk = true;
+ for (var i = 0; i < intentState.renderTasks.length; i++) {
+ intentState.renderTasks[i].operatorListChanged();
+ }
+ }
+ }, this);
+ messageHandler.on('UnsupportedFeature', function transportUnsupportedFeature(data) {
+ if (this.destroyed) {
+ return;
+ }
+ var featureId = data.featureId;
+ var loadingTask = this.loadingTask;
+ if (loadingTask.onUnsupportedFeature) {
+ loadingTask.onUnsupportedFeature(featureId);
+ }
+ _UnsupportedManager.notify(featureId);
+ }, this);
+ messageHandler.on('JpegDecode', function (data) {
+ if (this.destroyed) {
+ return Promise.reject(new Error('Worker was destroyed'));
+ }
+ if (typeof document === 'undefined') {
+ return Promise.reject(new Error('"document" is not defined.'));
+ }
+ var imageUrl = data[0];
+ var components = data[1];
+ if (components !== 3 && components !== 1) {
+ return Promise.reject(new Error('Only 3 components or 1 component can be returned'));
+ }
+ return new Promise(function (resolve, reject) {
+ var img = new Image();
+ img.onload = function () {
+ var width = img.width;
+ var height = img.height;
+ var size = width * height;
+ var rgbaLength = size * 4;
+ var buf = new Uint8Array(size * components);
+ var tmpCanvas = document.createElement('canvas');
+ tmpCanvas.width = width;
+ tmpCanvas.height = height;
+ var tmpCtx = tmpCanvas.getContext('2d');
+ tmpCtx.drawImage(img, 0, 0);
+ var data = tmpCtx.getImageData(0, 0, width, height).data;
+ var i, j;
+ if (components === 3) {
+ for (i = 0, j = 0; i < rgbaLength; i += 4, j += 3) {
+ buf[j] = data[i];
+ buf[j + 1] = data[i + 1];
+ buf[j + 2] = data[i + 2];
+ }
+ } else if (components === 1) {
+ for (i = 0, j = 0; i < rgbaLength; i += 4, j++) {
+ buf[j] = data[i];
+ }
+ }
+ resolve({
+ data: buf,
+ width: width,
+ height: height
+ });
+ };
+ img.onerror = function () {
+ reject(new Error('JpegDecode failed to load image'));
+ };
+ img.src = imageUrl;
+ });
+ }, this);
+ messageHandler.on('FetchBuiltInCMap', function (data) {
+ if (this.destroyed) {
+ return Promise.reject(new Error('Worker was destroyed'));
+ }
+ return this.CMapReaderFactory.fetch({ name: data.name });
+ }, this);
+ },
+ getData: function WorkerTransport_getData() {
+ return this.messageHandler.sendWithPromise('GetData', null);
+ },
+ getPage: function WorkerTransport_getPage(pageNumber, capability) {
+ if (!isInt(pageNumber) || pageNumber <= 0 || pageNumber > this.numPages) {
+ return Promise.reject(new Error('Invalid page request'));
+ }
+ var pageIndex = pageNumber - 1;
+ if (pageIndex in this.pagePromises) {
+ return this.pagePromises[pageIndex];
+ }
+ var promise = this.messageHandler.sendWithPromise('GetPage', { pageIndex: pageIndex }).then(function (pageInfo) {
+ if (this.destroyed) {
+ throw new Error('Transport destroyed');
+ }
+ var page = new PDFPageProxy(pageIndex, pageInfo, this);
+ this.pageCache[pageIndex] = page;
+ return page;
+ }.bind(this));
+ this.pagePromises[pageIndex] = promise;
+ return promise;
+ },
+ getPageIndex: function WorkerTransport_getPageIndexByRef(ref) {
+ return this.messageHandler.sendWithPromise('GetPageIndex', { ref: ref }).catch(function (reason) {
+ return Promise.reject(new Error(reason));
+ });
+ },
+ getAnnotations: function WorkerTransport_getAnnotations(pageIndex, intent) {
+ return this.messageHandler.sendWithPromise('GetAnnotations', {
+ pageIndex: pageIndex,
+ intent: intent
+ });
+ },
+ getDestinations: function WorkerTransport_getDestinations() {
+ return this.messageHandler.sendWithPromise('GetDestinations', null);
+ },
+ getDestination: function WorkerTransport_getDestination(id) {
+ return this.messageHandler.sendWithPromise('GetDestination', { id: id });
+ },
+ getPageLabels: function WorkerTransport_getPageLabels() {
+ return this.messageHandler.sendWithPromise('GetPageLabels', null);
+ },
+ getAttachments: function WorkerTransport_getAttachments() {
+ return this.messageHandler.sendWithPromise('GetAttachments', null);
+ },
+ getJavaScript: function WorkerTransport_getJavaScript() {
+ return this.messageHandler.sendWithPromise('GetJavaScript', null);
+ },
+ getOutline: function WorkerTransport_getOutline() {
+ return this.messageHandler.sendWithPromise('GetOutline', null);
+ },
+ getMetadata: function WorkerTransport_getMetadata() {
+ return this.messageHandler.sendWithPromise('GetMetadata', null).then(function transportMetadata(results) {
+ return {
+ info: results[0],
+ metadata: results[1] ? new Metadata(results[1]) : null
+ };
+ });
+ },
+ getStats: function WorkerTransport_getStats() {
+ return this.messageHandler.sendWithPromise('GetStats', null);
+ },
+ startCleanup: function WorkerTransport_startCleanup() {
+ this.messageHandler.sendWithPromise('Cleanup', null).then(function endCleanup() {
+ for (var i = 0, ii = this.pageCache.length; i < ii; i++) {
+ var page = this.pageCache[i];
+ if (page) {
+ page.cleanup();
+ }
+ }
+ this.commonObjs.clear();
+ this.fontLoader.clear();
+ }.bind(this));
+ }
+ };
+ return WorkerTransport;
+}();
+var PDFObjects = function PDFObjectsClosure() {
+ function PDFObjects() {
+ this.objs = Object.create(null);
+ }
+ PDFObjects.prototype = {
+ ensureObj: function PDFObjects_ensureObj(objId) {
+ if (this.objs[objId]) {
+ return this.objs[objId];
+ }
+ var obj = {
+ capability: createPromiseCapability(),
+ data: null,
+ resolved: false
+ };
+ this.objs[objId] = obj;
+ return obj;
+ },
+ get: function PDFObjects_get(objId, callback) {
+ if (callback) {
+ this.ensureObj(objId).capability.promise.then(callback);
+ return null;
+ }
+ var obj = this.objs[objId];
+ if (!obj || !obj.resolved) {
+ error('Requesting object that isn\'t resolved yet ' + objId);
+ }
+ return obj.data;
+ },
+ resolve: function PDFObjects_resolve(objId, data) {
+ var obj = this.ensureObj(objId);
+ obj.resolved = true;
+ obj.data = data;
+ obj.capability.resolve(data);
+ },
+ isResolved: function PDFObjects_isResolved(objId) {
+ var objs = this.objs;
+ if (!objs[objId]) {
+ return false;
+ }
+ return objs[objId].resolved;
+ },
+ hasData: function PDFObjects_hasData(objId) {
+ return this.isResolved(objId);
+ },
+ getData: function PDFObjects_getData(objId) {
+ var objs = this.objs;
+ if (!objs[objId] || !objs[objId].resolved) {
+ return null;
+ }
+ return objs[objId].data;
+ },
+ clear: function PDFObjects_clear() {
+ this.objs = Object.create(null);
+ }
+ };
+ return PDFObjects;
+}();
+var RenderTask = function RenderTaskClosure() {
+ function RenderTask(internalRenderTask) {
+ this._internalRenderTask = internalRenderTask;
+ this.onContinue = null;
+ }
+ RenderTask.prototype = {
+ get promise() {
+ return this._internalRenderTask.capability.promise;
+ },
+ cancel: function RenderTask_cancel() {
+ this._internalRenderTask.cancel();
+ },
+ then: function RenderTask_then(onFulfilled, onRejected) {
+ return this.promise.then.apply(this.promise, arguments);
+ }
+ };
+ return RenderTask;
+}();
+var InternalRenderTask = function InternalRenderTaskClosure() {
+ function InternalRenderTask(callback, params, objs, commonObjs, operatorList, pageNumber, canvasFactory) {
+ this.callback = callback;
+ this.params = params;
+ this.objs = objs;
+ this.commonObjs = commonObjs;
+ this.operatorListIdx = null;
+ this.operatorList = operatorList;
+ this.pageNumber = pageNumber;
+ this.canvasFactory = canvasFactory;
+ this.running = false;
+ this.graphicsReadyCallback = null;
+ this.graphicsReady = false;
+ this.useRequestAnimationFrame = false;
+ this.cancelled = false;
+ this.capability = createPromiseCapability();
+ this.task = new RenderTask(this);
+ this._continueBound = this._continue.bind(this);
+ this._scheduleNextBound = this._scheduleNext.bind(this);
+ this._nextBound = this._next.bind(this);
+ }
+ InternalRenderTask.prototype = {
+ initializeGraphics: function InternalRenderTask_initializeGraphics(transparency) {
+ if (this.cancelled) {
+ return;
+ }
+ if (getDefaultSetting('pdfBug') && globalScope.StepperManager && globalScope.StepperManager.enabled) {
+ this.stepper = globalScope.StepperManager.create(this.pageNumber - 1);
+ this.stepper.init(this.operatorList);
+ this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
+ }
+ var params = this.params;
+ this.gfx = new CanvasGraphics(params.canvasContext, this.commonObjs, this.objs, this.canvasFactory, params.imageLayer);
+ this.gfx.beginDrawing(params.transform, params.viewport, transparency);
+ this.operatorListIdx = 0;
+ this.graphicsReady = true;
+ if (this.graphicsReadyCallback) {
+ this.graphicsReadyCallback();
+ }
+ },
+ cancel: function InternalRenderTask_cancel() {
+ this.running = false;
+ this.cancelled = true;
+ if (getDefaultSetting('pdfjsNext')) {
+ this.callback(new RenderingCancelledException('Rendering cancelled, page ' + this.pageNumber, 'canvas'));
+ } else {
+ this.callback('cancelled');
+ }
+ },
+ operatorListChanged: function InternalRenderTask_operatorListChanged() {
+ if (!this.graphicsReady) {
+ if (!this.graphicsReadyCallback) {
+ this.graphicsReadyCallback = this._continueBound;
+ }
+ return;
+ }
+ if (this.stepper) {
+ this.stepper.updateOperatorList(this.operatorList);
+ }
+ if (this.running) {
+ return;
+ }
+ this._continue();
+ },
+ _continue: function InternalRenderTask__continue() {
+ this.running = true;
+ if (this.cancelled) {
+ return;
+ }
+ if (this.task.onContinue) {
+ this.task.onContinue(this._scheduleNextBound);
+ } else {
+ this._scheduleNext();
+ }
+ },
+ _scheduleNext: function InternalRenderTask__scheduleNext() {
+ if (this.useRequestAnimationFrame && typeof window !== 'undefined') {
+ window.requestAnimationFrame(this._nextBound);
+ } else {
+ Promise.resolve(undefined).then(this._nextBound);
+ }
+ },
+ _next: function InternalRenderTask__next() {
+ if (this.cancelled) {
+ return;
+ }
+ this.operatorListIdx = this.gfx.executeOperatorList(this.operatorList, this.operatorListIdx, this._continueBound, this.stepper);
+ if (this.operatorListIdx === this.operatorList.argsArray.length) {
+ this.running = false;
+ if (this.operatorList.lastChunk) {
+ this.gfx.endDrawing();
+ this.callback();
+ }
+ }
+ }
+ };
+ return InternalRenderTask;
+}();
+var _UnsupportedManager = function UnsupportedManagerClosure() {
+ var listeners = [];
+ return {
+ listen: function (cb) {
+ deprecated('Global UnsupportedManager.listen is used: ' + ' use PDFDocumentLoadingTask.onUnsupportedFeature instead');
+ listeners.push(cb);
+ },
+ notify: function (featureId) {
+ for (var i = 0, ii = listeners.length; i < ii; i++) {
+ listeners[i](featureId);
+ }
+ }
+ };
+}();
+exports.version = '1.8.172';
+exports.build = '8ff1fbe7';
+exports.getDocument = getDocument;
+exports.PDFDataRangeTransport = PDFDataRangeTransport;
+exports.PDFWorker = PDFWorker;
+exports.PDFDocumentProxy = PDFDocumentProxy;
+exports.PDFPageProxy = PDFPageProxy;
+exports._UnsupportedManager = _UnsupportedManager;
+
+/***/ }),
+/* 4 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var FONT_IDENTITY_MATRIX = sharedUtil.FONT_IDENTITY_MATRIX;
+var IDENTITY_MATRIX = sharedUtil.IDENTITY_MATRIX;
+var ImageKind = sharedUtil.ImageKind;
+var OPS = sharedUtil.OPS;
+var Util = sharedUtil.Util;
+var isNum = sharedUtil.isNum;
+var isArray = sharedUtil.isArray;
+var warn = sharedUtil.warn;
+var createObjectURL = sharedUtil.createObjectURL;
+var SVG_DEFAULTS = {
+ fontStyle: 'normal',
+ fontWeight: 'normal',
+ fillColor: '#000000'
+};
+var convertImgDataToPng = function convertImgDataToPngClosure() {
+ var PNG_HEADER = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ var CHUNK_WRAPPER_SIZE = 12;
+ var crcTable = new Int32Array(256);
+ for (var i = 0; i < 256; i++) {
+ var c = i;
+ for (var h = 0; h < 8; h++) {
+ if (c & 1) {
+ c = 0xedB88320 ^ c >> 1 & 0x7fffffff;
+ } else {
+ c = c >> 1 & 0x7fffffff;
+ }
+ }
+ crcTable[i] = c;
+ }
+ function crc32(data, start, end) {
+ var crc = -1;
+ for (var i = start; i < end; i++) {
+ var a = (crc ^ data[i]) & 0xff;
+ var b = crcTable[a];
+ crc = crc >>> 8 ^ b;
+ }
+ return crc ^ -1;
+ }
+ function writePngChunk(type, body, data, offset) {
+ var p = offset;
+ var len = body.length;
+ data[p] = len >> 24 & 0xff;
+ data[p + 1] = len >> 16 & 0xff;
+ data[p + 2] = len >> 8 & 0xff;
+ data[p + 3] = len & 0xff;
+ p += 4;
+ data[p] = type.charCodeAt(0) & 0xff;
+ data[p + 1] = type.charCodeAt(1) & 0xff;
+ data[p + 2] = type.charCodeAt(2) & 0xff;
+ data[p + 3] = type.charCodeAt(3) & 0xff;
+ p += 4;
+ data.set(body, p);
+ p += body.length;
+ var crc = crc32(data, offset + 4, p);
+ data[p] = crc >> 24 & 0xff;
+ data[p + 1] = crc >> 16 & 0xff;
+ data[p + 2] = crc >> 8 & 0xff;
+ data[p + 3] = crc & 0xff;
+ }
+ function adler32(data, start, end) {
+ var a = 1;
+ var b = 0;
+ for (var i = start; i < end; ++i) {
+ a = (a + (data[i] & 0xff)) % 65521;
+ b = (b + a) % 65521;
+ }
+ return b << 16 | a;
+ }
+ function encode(imgData, kind, forceDataSchema) {
+ var width = imgData.width;
+ var height = imgData.height;
+ var bitDepth, colorType, lineSize;
+ var bytes = imgData.data;
+ switch (kind) {
+ case ImageKind.GRAYSCALE_1BPP:
+ colorType = 0;
+ bitDepth = 1;
+ lineSize = width + 7 >> 3;
+ break;
+ case ImageKind.RGB_24BPP:
+ colorType = 2;
+ bitDepth = 8;
+ lineSize = width * 3;
+ break;
+ case ImageKind.RGBA_32BPP:
+ colorType = 6;
+ bitDepth = 8;
+ lineSize = width * 4;
+ break;
+ default:
+ throw new Error('invalid format');
+ }
+ var literals = new Uint8Array((1 + lineSize) * height);
+ var offsetLiterals = 0,
+ offsetBytes = 0;
+ var y, i;
+ for (y = 0; y < height; ++y) {
+ literals[offsetLiterals++] = 0;
+ literals.set(bytes.subarray(offsetBytes, offsetBytes + lineSize), offsetLiterals);
+ offsetBytes += lineSize;
+ offsetLiterals += lineSize;
+ }
+ if (kind === ImageKind.GRAYSCALE_1BPP) {
+ offsetLiterals = 0;
+ for (y = 0; y < height; y++) {
+ offsetLiterals++;
+ for (i = 0; i < lineSize; i++) {
+ literals[offsetLiterals++] ^= 0xFF;
+ }
+ }
+ }
+ var ihdr = new Uint8Array([width >> 24 & 0xff, width >> 16 & 0xff, width >> 8 & 0xff, width & 0xff, height >> 24 & 0xff, height >> 16 & 0xff, height >> 8 & 0xff, height & 0xff, bitDepth, colorType, 0x00, 0x00, 0x00]);
+ var len = literals.length;
+ var maxBlockLength = 0xFFFF;
+ var deflateBlocks = Math.ceil(len / maxBlockLength);
+ var idat = new Uint8Array(2 + len + deflateBlocks * 5 + 4);
+ var pi = 0;
+ idat[pi++] = 0x78;
+ idat[pi++] = 0x9c;
+ var pos = 0;
+ while (len > maxBlockLength) {
+ idat[pi++] = 0x00;
+ idat[pi++] = 0xff;
+ idat[pi++] = 0xff;
+ idat[pi++] = 0x00;
+ idat[pi++] = 0x00;
+ idat.set(literals.subarray(pos, pos + maxBlockLength), pi);
+ pi += maxBlockLength;
+ pos += maxBlockLength;
+ len -= maxBlockLength;
+ }
+ idat[pi++] = 0x01;
+ idat[pi++] = len & 0xff;
+ idat[pi++] = len >> 8 & 0xff;
+ idat[pi++] = ~len & 0xffff & 0xff;
+ idat[pi++] = (~len & 0xffff) >> 8 & 0xff;
+ idat.set(literals.subarray(pos), pi);
+ pi += literals.length - pos;
+ var adler = adler32(literals, 0, literals.length);
+ idat[pi++] = adler >> 24 & 0xff;
+ idat[pi++] = adler >> 16 & 0xff;
+ idat[pi++] = adler >> 8 & 0xff;
+ idat[pi++] = adler & 0xff;
+ var pngLength = PNG_HEADER.length + CHUNK_WRAPPER_SIZE * 3 + ihdr.length + idat.length;
+ var data = new Uint8Array(pngLength);
+ var offset = 0;
+ data.set(PNG_HEADER, offset);
+ offset += PNG_HEADER.length;
+ writePngChunk('IHDR', ihdr, data, offset);
+ offset += CHUNK_WRAPPER_SIZE + ihdr.length;
+ writePngChunk('IDATA', idat, data, offset);
+ offset += CHUNK_WRAPPER_SIZE + idat.length;
+ writePngChunk('IEND', new Uint8Array(0), data, offset);
+ return createObjectURL(data, 'image/png', forceDataSchema);
+ }
+ return function convertImgDataToPng(imgData, forceDataSchema) {
+ var kind = imgData.kind === undefined ? ImageKind.GRAYSCALE_1BPP : imgData.kind;
+ return encode(imgData, kind, forceDataSchema);
+ };
+}();
+var SVGExtraState = function SVGExtraStateClosure() {
+ function SVGExtraState() {
+ this.fontSizeScale = 1;
+ this.fontWeight = SVG_DEFAULTS.fontWeight;
+ this.fontSize = 0;
+ this.textMatrix = IDENTITY_MATRIX;
+ this.fontMatrix = FONT_IDENTITY_MATRIX;
+ this.leading = 0;
+ this.x = 0;
+ this.y = 0;
+ this.lineX = 0;
+ this.lineY = 0;
+ this.charSpacing = 0;
+ this.wordSpacing = 0;
+ this.textHScale = 1;
+ this.textRise = 0;
+ this.fillColor = SVG_DEFAULTS.fillColor;
+ this.strokeColor = '#000000';
+ this.fillAlpha = 1;
+ this.strokeAlpha = 1;
+ this.lineWidth = 1;
+ this.lineJoin = '';
+ this.lineCap = '';
+ this.miterLimit = 0;
+ this.dashArray = [];
+ this.dashPhase = 0;
+ this.dependencies = [];
+ this.activeClipUrl = null;
+ this.clipGroup = null;
+ this.maskId = '';
+ }
+ SVGExtraState.prototype = {
+ clone: function SVGExtraState_clone() {
+ return Object.create(this);
+ },
+ setCurrentPoint: function SVGExtraState_setCurrentPoint(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+ };
+ return SVGExtraState;
+}();
+var SVGGraphics = function SVGGraphicsClosure() {
+ function opListToTree(opList) {
+ var opTree = [];
+ var tmp = [];
+ var opListLen = opList.length;
+ for (var x = 0; x < opListLen; x++) {
+ if (opList[x].fn === 'save') {
+ opTree.push({
+ 'fnId': 92,
+ 'fn': 'group',
+ 'items': []
+ });
+ tmp.push(opTree);
+ opTree = opTree[opTree.length - 1].items;
+ continue;
+ }
+ if (opList[x].fn === 'restore') {
+ opTree = tmp.pop();
+ } else {
+ opTree.push(opList[x]);
+ }
+ }
+ return opTree;
+ }
+ function pf(value) {
+ if (value === (value | 0)) {
+ return value.toString();
+ }
+ var s = value.toFixed(10);
+ var i = s.length - 1;
+ if (s[i] !== '0') {
+ return s;
+ }
+ do {
+ i--;
+ } while (s[i] === '0');
+ return s.substr(0, s[i] === '.' ? i : i + 1);
+ }
+ function pm(m) {
+ if (m[4] === 0 && m[5] === 0) {
+ if (m[1] === 0 && m[2] === 0) {
+ if (m[0] === 1 && m[3] === 1) {
+ return '';
+ }
+ return 'scale(' + pf(m[0]) + ' ' + pf(m[3]) + ')';
+ }
+ if (m[0] === m[3] && m[1] === -m[2]) {
+ var a = Math.acos(m[0]) * 180 / Math.PI;
+ return 'rotate(' + pf(a) + ')';
+ }
+ } else {
+ if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
+ return 'translate(' + pf(m[4]) + ' ' + pf(m[5]) + ')';
+ }
+ }
+ return 'matrix(' + pf(m[0]) + ' ' + pf(m[1]) + ' ' + pf(m[2]) + ' ' + pf(m[3]) + ' ' + pf(m[4]) + ' ' + pf(m[5]) + ')';
+ }
+ function SVGGraphics(commonObjs, objs, forceDataSchema) {
+ this.current = new SVGExtraState();
+ this.transformMatrix = IDENTITY_MATRIX;
+ this.transformStack = [];
+ this.extraStack = [];
+ this.commonObjs = commonObjs;
+ this.objs = objs;
+ this.pendingEOFill = false;
+ this.embedFonts = false;
+ this.embeddedFonts = Object.create(null);
+ this.cssStyle = null;
+ this.forceDataSchema = !!forceDataSchema;
+ }
+ var NS = 'http://www.w3.org/2000/svg';
+ var XML_NS = 'http://www.w3.org/XML/1998/namespace';
+ var XLINK_NS = 'http://www.w3.org/1999/xlink';
+ var LINE_CAP_STYLES = ['butt', 'round', 'square'];
+ var LINE_JOIN_STYLES = ['miter', 'round', 'bevel'];
+ var clipCount = 0;
+ var maskCount = 0;
+ SVGGraphics.prototype = {
+ save: function SVGGraphics_save() {
+ this.transformStack.push(this.transformMatrix);
+ var old = this.current;
+ this.extraStack.push(old);
+ this.current = old.clone();
+ },
+ restore: function SVGGraphics_restore() {
+ this.transformMatrix = this.transformStack.pop();
+ this.current = this.extraStack.pop();
+ this.tgrp = null;
+ },
+ group: function SVGGraphics_group(items) {
+ this.save();
+ this.executeOpTree(items);
+ this.restore();
+ },
+ loadDependencies: function SVGGraphics_loadDependencies(operatorList) {
+ var fnArray = operatorList.fnArray;
+ var fnArrayLen = fnArray.length;
+ var argsArray = operatorList.argsArray;
+ var self = this;
+ for (var i = 0; i < fnArrayLen; i++) {
+ if (OPS.dependency === fnArray[i]) {
+ var deps = argsArray[i];
+ for (var n = 0, nn = deps.length; n < nn; n++) {
+ var obj = deps[n];
+ var common = obj.substring(0, 2) === 'g_';
+ var promise;
+ if (common) {
+ promise = new Promise(function (resolve) {
+ self.commonObjs.get(obj, resolve);
+ });
+ } else {
+ promise = new Promise(function (resolve) {
+ self.objs.get(obj, resolve);
+ });
+ }
+ this.current.dependencies.push(promise);
+ }
+ }
+ }
+ return Promise.all(this.current.dependencies);
+ },
+ transform: function SVGGraphics_transform(a, b, c, d, e, f) {
+ var transformMatrix = [a, b, c, d, e, f];
+ this.transformMatrix = Util.transform(this.transformMatrix, transformMatrix);
+ this.tgrp = null;
+ },
+ getSVG: function SVGGraphics_getSVG(operatorList, viewport) {
+ this.viewport = viewport;
+ var svgElement = this._initialize(viewport);
+ return this.loadDependencies(operatorList).then(function () {
+ this.transformMatrix = IDENTITY_MATRIX;
+ var opTree = this.convertOpList(operatorList);
+ this.executeOpTree(opTree);
+ return svgElement;
+ }.bind(this));
+ },
+ convertOpList: function SVGGraphics_convertOpList(operatorList) {
+ var argsArray = operatorList.argsArray;
+ var fnArray = operatorList.fnArray;
+ var fnArrayLen = fnArray.length;
+ var REVOPS = [];
+ var opList = [];
+ for (var op in OPS) {
+ REVOPS[OPS[op]] = op;
+ }
+ for (var x = 0; x < fnArrayLen; x++) {
+ var fnId = fnArray[x];
+ opList.push({
+ 'fnId': fnId,
+ 'fn': REVOPS[fnId],
+ 'args': argsArray[x]
+ });
+ }
+ return opListToTree(opList);
+ },
+ executeOpTree: function SVGGraphics_executeOpTree(opTree) {
+ var opTreeLen = opTree.length;
+ for (var x = 0; x < opTreeLen; x++) {
+ var fn = opTree[x].fn;
+ var fnId = opTree[x].fnId;
+ var args = opTree[x].args;
+ switch (fnId | 0) {
+ case OPS.beginText:
+ this.beginText();
+ break;
+ case OPS.setLeading:
+ this.setLeading(args);
+ break;
+ case OPS.setLeadingMoveText:
+ this.setLeadingMoveText(args[0], args[1]);
+ break;
+ case OPS.setFont:
+ this.setFont(args);
+ break;
+ case OPS.showText:
+ this.showText(args[0]);
+ break;
+ case OPS.showSpacedText:
+ this.showText(args[0]);
+ break;
+ case OPS.endText:
+ this.endText();
+ break;
+ case OPS.moveText:
+ this.moveText(args[0], args[1]);
+ break;
+ case OPS.setCharSpacing:
+ this.setCharSpacing(args[0]);
+ break;
+ case OPS.setWordSpacing:
+ this.setWordSpacing(args[0]);
+ break;
+ case OPS.setHScale:
+ this.setHScale(args[0]);
+ break;
+ case OPS.setTextMatrix:
+ this.setTextMatrix(args[0], args[1], args[2], args[3], args[4], args[5]);
+ break;
+ case OPS.setLineWidth:
+ this.setLineWidth(args[0]);
+ break;
+ case OPS.setLineJoin:
+ this.setLineJoin(args[0]);
+ break;
+ case OPS.setLineCap:
+ this.setLineCap(args[0]);
+ break;
+ case OPS.setMiterLimit:
+ this.setMiterLimit(args[0]);
+ break;
+ case OPS.setFillRGBColor:
+ this.setFillRGBColor(args[0], args[1], args[2]);
+ break;
+ case OPS.setStrokeRGBColor:
+ this.setStrokeRGBColor(args[0], args[1], args[2]);
+ break;
+ case OPS.setDash:
+ this.setDash(args[0], args[1]);
+ break;
+ case OPS.setGState:
+ this.setGState(args[0]);
+ break;
+ case OPS.fill:
+ this.fill();
+ break;
+ case OPS.eoFill:
+ this.eoFill();
+ break;
+ case OPS.stroke:
+ this.stroke();
+ break;
+ case OPS.fillStroke:
+ this.fillStroke();
+ break;
+ case OPS.eoFillStroke:
+ this.eoFillStroke();
+ break;
+ case OPS.clip:
+ this.clip('nonzero');
+ break;
+ case OPS.eoClip:
+ this.clip('evenodd');
+ break;
+ case OPS.paintSolidColorImageMask:
+ this.paintSolidColorImageMask();
+ break;
+ case OPS.paintJpegXObject:
+ this.paintJpegXObject(args[0], args[1], args[2]);
+ break;
+ case OPS.paintImageXObject:
+ this.paintImageXObject(args[0]);
+ break;
+ case OPS.paintInlineImageXObject:
+ this.paintInlineImageXObject(args[0]);
+ break;
+ case OPS.paintImageMaskXObject:
+ this.paintImageMaskXObject(args[0]);
+ break;
+ case OPS.paintFormXObjectBegin:
+ this.paintFormXObjectBegin(args[0], args[1]);
+ break;
+ case OPS.paintFormXObjectEnd:
+ this.paintFormXObjectEnd();
+ break;
+ case OPS.closePath:
+ this.closePath();
+ break;
+ case OPS.closeStroke:
+ this.closeStroke();
+ break;
+ case OPS.closeFillStroke:
+ this.closeFillStroke();
+ break;
+ case OPS.nextLine:
+ this.nextLine();
+ break;
+ case OPS.transform:
+ this.transform(args[0], args[1], args[2], args[3], args[4], args[5]);
+ break;
+ case OPS.constructPath:
+ this.constructPath(args[0], args[1]);
+ break;
+ case OPS.endPath:
+ this.endPath();
+ break;
+ case 92:
+ this.group(opTree[x].items);
+ break;
+ default:
+ warn('Unimplemented operator ' + fn);
+ break;
+ }
+ }
+ },
+ setWordSpacing: function SVGGraphics_setWordSpacing(wordSpacing) {
+ this.current.wordSpacing = wordSpacing;
+ },
+ setCharSpacing: function SVGGraphics_setCharSpacing(charSpacing) {
+ this.current.charSpacing = charSpacing;
+ },
+ nextLine: function SVGGraphics_nextLine() {
+ this.moveText(0, this.current.leading);
+ },
+ setTextMatrix: function SVGGraphics_setTextMatrix(a, b, c, d, e, f) {
+ var current = this.current;
+ this.current.textMatrix = this.current.lineMatrix = [a, b, c, d, e, f];
+ this.current.x = this.current.lineX = 0;
+ this.current.y = this.current.lineY = 0;
+ current.xcoords = [];
+ current.tspan = document.createElementNS(NS, 'svg:tspan');
+ current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
+ current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
+ current.tspan.setAttributeNS(null, 'y', pf(-current.y));
+ current.txtElement = document.createElementNS(NS, 'svg:text');
+ current.txtElement.appendChild(current.tspan);
+ },
+ beginText: function SVGGraphics_beginText() {
+ this.current.x = this.current.lineX = 0;
+ this.current.y = this.current.lineY = 0;
+ this.current.textMatrix = IDENTITY_MATRIX;
+ this.current.lineMatrix = IDENTITY_MATRIX;
+ this.current.tspan = document.createElementNS(NS, 'svg:tspan');
+ this.current.txtElement = document.createElementNS(NS, 'svg:text');
+ this.current.txtgrp = document.createElementNS(NS, 'svg:g');
+ this.current.xcoords = [];
+ },
+ moveText: function SVGGraphics_moveText(x, y) {
+ var current = this.current;
+ this.current.x = this.current.lineX += x;
+ this.current.y = this.current.lineY += y;
+ current.xcoords = [];
+ current.tspan = document.createElementNS(NS, 'svg:tspan');
+ current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
+ current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
+ current.tspan.setAttributeNS(null, 'y', pf(-current.y));
+ },
+ showText: function SVGGraphics_showText(glyphs) {
+ var current = this.current;
+ var font = current.font;
+ var fontSize = current.fontSize;
+ if (fontSize === 0) {
+ return;
+ }
+ var charSpacing = current.charSpacing;
+ var wordSpacing = current.wordSpacing;
+ var fontDirection = current.fontDirection;
+ var textHScale = current.textHScale * fontDirection;
+ var glyphsLength = glyphs.length;
+ var vertical = font.vertical;
+ var widthAdvanceScale = fontSize * current.fontMatrix[0];
+ var x = 0,
+ i;
+ for (i = 0; i < glyphsLength; ++i) {
+ var glyph = glyphs[i];
+ if (glyph === null) {
+ x += fontDirection * wordSpacing;
+ continue;
+ } else if (isNum(glyph)) {
+ x += -glyph * fontSize * 0.001;
+ continue;
+ }
+ current.xcoords.push(current.x + x * textHScale);
+ var width = glyph.width;
+ var character = glyph.fontChar;
+ var charWidth = width * widthAdvanceScale + charSpacing * fontDirection;
+ x += charWidth;
+ current.tspan.textContent += character;
+ }
+ if (vertical) {
+ current.y -= x * textHScale;
+ } else {
+ current.x += x * textHScale;
+ }
+ current.tspan.setAttributeNS(null, 'x', current.xcoords.map(pf).join(' '));
+ current.tspan.setAttributeNS(null, 'y', pf(-current.y));
+ current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
+ current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
+ if (current.fontStyle !== SVG_DEFAULTS.fontStyle) {
+ current.tspan.setAttributeNS(null, 'font-style', current.fontStyle);
+ }
+ if (current.fontWeight !== SVG_DEFAULTS.fontWeight) {
+ current.tspan.setAttributeNS(null, 'font-weight', current.fontWeight);
+ }
+ if (current.fillColor !== SVG_DEFAULTS.fillColor) {
+ current.tspan.setAttributeNS(null, 'fill', current.fillColor);
+ }
+ current.txtElement.setAttributeNS(null, 'transform', pm(current.textMatrix) + ' scale(1, -1)');
+ current.txtElement.setAttributeNS(XML_NS, 'xml:space', 'preserve');
+ current.txtElement.appendChild(current.tspan);
+ current.txtgrp.appendChild(current.txtElement);
+ this._ensureTransformGroup().appendChild(current.txtElement);
+ },
+ setLeadingMoveText: function SVGGraphics_setLeadingMoveText(x, y) {
+ this.setLeading(-y);
+ this.moveText(x, y);
+ },
+ addFontStyle: function SVGGraphics_addFontStyle(fontObj) {
+ if (!this.cssStyle) {
+ this.cssStyle = document.createElementNS(NS, 'svg:style');
+ this.cssStyle.setAttributeNS(null, 'type', 'text/css');
+ this.defs.appendChild(this.cssStyle);
+ }
+ var url = createObjectURL(fontObj.data, fontObj.mimetype, this.forceDataSchema);
+ this.cssStyle.textContent += '@font-face { font-family: "' + fontObj.loadedName + '";' + ' src: url(' + url + '); }\n';
+ },
+ setFont: function SVGGraphics_setFont(details) {
+ var current = this.current;
+ var fontObj = this.commonObjs.get(details[0]);
+ var size = details[1];
+ this.current.font = fontObj;
+ if (this.embedFonts && fontObj.data && !this.embeddedFonts[fontObj.loadedName]) {
+ this.addFontStyle(fontObj);
+ this.embeddedFonts[fontObj.loadedName] = fontObj;
+ }
+ current.fontMatrix = fontObj.fontMatrix ? fontObj.fontMatrix : FONT_IDENTITY_MATRIX;
+ var bold = fontObj.black ? fontObj.bold ? 'bolder' : 'bold' : fontObj.bold ? 'bold' : 'normal';
+ var italic = fontObj.italic ? 'italic' : 'normal';
+ if (size < 0) {
+ size = -size;
+ current.fontDirection = -1;
+ } else {
+ current.fontDirection = 1;
+ }
+ current.fontSize = size;
+ current.fontFamily = fontObj.loadedName;
+ current.fontWeight = bold;
+ current.fontStyle = italic;
+ current.tspan = document.createElementNS(NS, 'svg:tspan');
+ current.tspan.setAttributeNS(null, 'y', pf(-current.y));
+ current.xcoords = [];
+ },
+ endText: function SVGGraphics_endText() {},
+ setLineWidth: function SVGGraphics_setLineWidth(width) {
+ this.current.lineWidth = width;
+ },
+ setLineCap: function SVGGraphics_setLineCap(style) {
+ this.current.lineCap = LINE_CAP_STYLES[style];
+ },
+ setLineJoin: function SVGGraphics_setLineJoin(style) {
+ this.current.lineJoin = LINE_JOIN_STYLES[style];
+ },
+ setMiterLimit: function SVGGraphics_setMiterLimit(limit) {
+ this.current.miterLimit = limit;
+ },
+ setStrokeRGBColor: function SVGGraphics_setStrokeRGBColor(r, g, b) {
+ var color = Util.makeCssRgb(r, g, b);
+ this.current.strokeColor = color;
+ },
+ setFillRGBColor: function SVGGraphics_setFillRGBColor(r, g, b) {
+ var color = Util.makeCssRgb(r, g, b);
+ this.current.fillColor = color;
+ this.current.tspan = document.createElementNS(NS, 'svg:tspan');
+ this.current.xcoords = [];
+ },
+ setDash: function SVGGraphics_setDash(dashArray, dashPhase) {
+ this.current.dashArray = dashArray;
+ this.current.dashPhase = dashPhase;
+ },
+ constructPath: function SVGGraphics_constructPath(ops, args) {
+ var current = this.current;
+ var x = current.x,
+ y = current.y;
+ current.path = document.createElementNS(NS, 'svg:path');
+ var d = [];
+ var opLength = ops.length;
+ for (var i = 0, j = 0; i < opLength; i++) {
+ switch (ops[i] | 0) {
+ case OPS.rectangle:
+ x = args[j++];
+ y = args[j++];
+ var width = args[j++];
+ var height = args[j++];
+ var xw = x + width;
+ var yh = y + height;
+ d.push('M', pf(x), pf(y), 'L', pf(xw), pf(y), 'L', pf(xw), pf(yh), 'L', pf(x), pf(yh), 'Z');
+ break;
+ case OPS.moveTo:
+ x = args[j++];
+ y = args[j++];
+ d.push('M', pf(x), pf(y));
+ break;
+ case OPS.lineTo:
+ x = args[j++];
+ y = args[j++];
+ d.push('L', pf(x), pf(y));
+ break;
+ case OPS.curveTo:
+ x = args[j + 4];
+ y = args[j + 5];
+ d.push('C', pf(args[j]), pf(args[j + 1]), pf(args[j + 2]), pf(args[j + 3]), pf(x), pf(y));
+ j += 6;
+ break;
+ case OPS.curveTo2:
+ x = args[j + 2];
+ y = args[j + 3];
+ d.push('C', pf(x), pf(y), pf(args[j]), pf(args[j + 1]), pf(args[j + 2]), pf(args[j + 3]));
+ j += 4;
+ break;
+ case OPS.curveTo3:
+ x = args[j + 2];
+ y = args[j + 3];
+ d.push('C', pf(args[j]), pf(args[j + 1]), pf(x), pf(y), pf(x), pf(y));
+ j += 4;
+ break;
+ case OPS.closePath:
+ d.push('Z');
+ break;
+ }
+ }
+ current.path.setAttributeNS(null, 'd', d.join(' '));
+ current.path.setAttributeNS(null, 'stroke-miterlimit', pf(current.miterLimit));
+ current.path.setAttributeNS(null, 'stroke-linecap', current.lineCap);
+ current.path.setAttributeNS(null, 'stroke-linejoin', current.lineJoin);
+ current.path.setAttributeNS(null, 'stroke-width', pf(current.lineWidth) + 'px');
+ current.path.setAttributeNS(null, 'stroke-dasharray', current.dashArray.map(pf).join(' '));
+ current.path.setAttributeNS(null, 'stroke-dashoffset', pf(current.dashPhase) + 'px');
+ current.path.setAttributeNS(null, 'fill', 'none');
+ this._ensureTransformGroup().appendChild(current.path);
+ current.element = current.path;
+ current.setCurrentPoint(x, y);
+ },
+ endPath: function SVGGraphics_endPath() {},
+ clip: function SVGGraphics_clip(type) {
+ var current = this.current;
+ var clipId = 'clippath' + clipCount;
+ clipCount++;
+ var clipPath = document.createElementNS(NS, 'svg:clipPath');
+ clipPath.setAttributeNS(null, 'id', clipId);
+ clipPath.setAttributeNS(null, 'transform', pm(this.transformMatrix));
+ var clipElement = current.element.cloneNode();
+ if (type === 'evenodd') {
+ clipElement.setAttributeNS(null, 'clip-rule', 'evenodd');
+ } else {
+ clipElement.setAttributeNS(null, 'clip-rule', 'nonzero');
+ }
+ clipPath.appendChild(clipElement);
+ this.defs.appendChild(clipPath);
+ if (current.activeClipUrl) {
+ current.clipGroup = null;
+ this.extraStack.forEach(function (prev) {
+ prev.clipGroup = null;
+ });
+ }
+ current.activeClipUrl = 'url(#' + clipId + ')';
+ this.tgrp = null;
+ },
+ closePath: function SVGGraphics_closePath() {
+ var current = this.current;
+ var d = current.path.getAttributeNS(null, 'd');
+ d += 'Z';
+ current.path.setAttributeNS(null, 'd', d);
+ },
+ setLeading: function SVGGraphics_setLeading(leading) {
+ this.current.leading = -leading;
+ },
+ setTextRise: function SVGGraphics_setTextRise(textRise) {
+ this.current.textRise = textRise;
+ },
+ setHScale: function SVGGraphics_setHScale(scale) {
+ this.current.textHScale = scale / 100;
+ },
+ setGState: function SVGGraphics_setGState(states) {
+ for (var i = 0, ii = states.length; i < ii; i++) {
+ var state = states[i];
+ var key = state[0];
+ var value = state[1];
+ switch (key) {
+ case 'LW':
+ this.setLineWidth(value);
+ break;
+ case 'LC':
+ this.setLineCap(value);
+ break;
+ case 'LJ':
+ this.setLineJoin(value);
+ break;
+ case 'ML':
+ this.setMiterLimit(value);
+ break;
+ case 'D':
+ this.setDash(value[0], value[1]);
+ break;
+ case 'Font':
+ this.setFont(value);
+ break;
+ default:
+ warn('Unimplemented graphic state ' + key);
+ break;
+ }
+ }
+ },
+ fill: function SVGGraphics_fill() {
+ var current = this.current;
+ current.element.setAttributeNS(null, 'fill', current.fillColor);
+ },
+ stroke: function SVGGraphics_stroke() {
+ var current = this.current;
+ current.element.setAttributeNS(null, 'stroke', current.strokeColor);
+ current.element.setAttributeNS(null, 'fill', 'none');
+ },
+ eoFill: function SVGGraphics_eoFill() {
+ var current = this.current;
+ current.element.setAttributeNS(null, 'fill', current.fillColor);
+ current.element.setAttributeNS(null, 'fill-rule', 'evenodd');
+ },
+ fillStroke: function SVGGraphics_fillStroke() {
+ this.stroke();
+ this.fill();
+ },
+ eoFillStroke: function SVGGraphics_eoFillStroke() {
+ this.current.element.setAttributeNS(null, 'fill-rule', 'evenodd');
+ this.fillStroke();
+ },
+ closeStroke: function SVGGraphics_closeStroke() {
+ this.closePath();
+ this.stroke();
+ },
+ closeFillStroke: function SVGGraphics_closeFillStroke() {
+ this.closePath();
+ this.fillStroke();
+ },
+ paintSolidColorImageMask: function SVGGraphics_paintSolidColorImageMask() {
+ var current = this.current;
+ var rect = document.createElementNS(NS, 'svg:rect');
+ rect.setAttributeNS(null, 'x', '0');
+ rect.setAttributeNS(null, 'y', '0');
+ rect.setAttributeNS(null, 'width', '1px');
+ rect.setAttributeNS(null, 'height', '1px');
+ rect.setAttributeNS(null, 'fill', current.fillColor);
+ this._ensureTransformGroup().appendChild(rect);
+ },
+ paintJpegXObject: function SVGGraphics_paintJpegXObject(objId, w, h) {
+ var imgObj = this.objs.get(objId);
+ var imgEl = document.createElementNS(NS, 'svg:image');
+ imgEl.setAttributeNS(XLINK_NS, 'xlink:href', imgObj.src);
+ imgEl.setAttributeNS(null, 'width', imgObj.width + 'px');
+ imgEl.setAttributeNS(null, 'height', imgObj.height + 'px');
+ imgEl.setAttributeNS(null, 'x', '0');
+ imgEl.setAttributeNS(null, 'y', pf(-h));
+ imgEl.setAttributeNS(null, 'transform', 'scale(' + pf(1 / w) + ' ' + pf(-1 / h) + ')');
+ this._ensureTransformGroup().appendChild(imgEl);
+ },
+ paintImageXObject: function SVGGraphics_paintImageXObject(objId) {
+ var imgData = this.objs.get(objId);
+ if (!imgData) {
+ warn('Dependent image isn\'t ready yet');
+ return;
+ }
+ this.paintInlineImageXObject(imgData);
+ },
+ paintInlineImageXObject: function SVGGraphics_paintInlineImageXObject(imgData, mask) {
+ var width = imgData.width;
+ var height = imgData.height;
+ var imgSrc = convertImgDataToPng(imgData, this.forceDataSchema);
+ var cliprect = document.createElementNS(NS, 'svg:rect');
+ cliprect.setAttributeNS(null, 'x', '0');
+ cliprect.setAttributeNS(null, 'y', '0');
+ cliprect.setAttributeNS(null, 'width', pf(width));
+ cliprect.setAttributeNS(null, 'height', pf(height));
+ this.current.element = cliprect;
+ this.clip('nonzero');
+ var imgEl = document.createElementNS(NS, 'svg:image');
+ imgEl.setAttributeNS(XLINK_NS, 'xlink:href', imgSrc);
+ imgEl.setAttributeNS(null, 'x', '0');
+ imgEl.setAttributeNS(null, 'y', pf(-height));
+ imgEl.setAttributeNS(null, 'width', pf(width) + 'px');
+ imgEl.setAttributeNS(null, 'height', pf(height) + 'px');
+ imgEl.setAttributeNS(null, 'transform', 'scale(' + pf(1 / width) + ' ' + pf(-1 / height) + ')');
+ if (mask) {
+ mask.appendChild(imgEl);
+ } else {
+ this._ensureTransformGroup().appendChild(imgEl);
+ }
+ },
+ paintImageMaskXObject: function SVGGraphics_paintImageMaskXObject(imgData) {
+ var current = this.current;
+ var width = imgData.width;
+ var height = imgData.height;
+ var fillColor = current.fillColor;
+ current.maskId = 'mask' + maskCount++;
+ var mask = document.createElementNS(NS, 'svg:mask');
+ mask.setAttributeNS(null, 'id', current.maskId);
+ var rect = document.createElementNS(NS, 'svg:rect');
+ rect.setAttributeNS(null, 'x', '0');
+ rect.setAttributeNS(null, 'y', '0');
+ rect.setAttributeNS(null, 'width', pf(width));
+ rect.setAttributeNS(null, 'height', pf(height));
+ rect.setAttributeNS(null, 'fill', fillColor);
+ rect.setAttributeNS(null, 'mask', 'url(#' + current.maskId + ')');
+ this.defs.appendChild(mask);
+ this._ensureTransformGroup().appendChild(rect);
+ this.paintInlineImageXObject(imgData, mask);
+ },
+ paintFormXObjectBegin: function SVGGraphics_paintFormXObjectBegin(matrix, bbox) {
+ if (isArray(matrix) && matrix.length === 6) {
+ this.transform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
+ }
+ if (isArray(bbox) && bbox.length === 4) {
+ var width = bbox[2] - bbox[0];
+ var height = bbox[3] - bbox[1];
+ var cliprect = document.createElementNS(NS, 'svg:rect');
+ cliprect.setAttributeNS(null, 'x', bbox[0]);
+ cliprect.setAttributeNS(null, 'y', bbox[1]);
+ cliprect.setAttributeNS(null, 'width', pf(width));
+ cliprect.setAttributeNS(null, 'height', pf(height));
+ this.current.element = cliprect;
+ this.clip('nonzero');
+ this.endPath();
+ }
+ },
+ paintFormXObjectEnd: function SVGGraphics_paintFormXObjectEnd() {},
+ _initialize: function SVGGraphics_initialize(viewport) {
+ var svg = document.createElementNS(NS, 'svg:svg');
+ svg.setAttributeNS(null, 'version', '1.1');
+ svg.setAttributeNS(null, 'width', viewport.width + 'px');
+ svg.setAttributeNS(null, 'height', viewport.height + 'px');
+ svg.setAttributeNS(null, 'preserveAspectRatio', 'none');
+ svg.setAttributeNS(null, 'viewBox', '0 0 ' + viewport.width + ' ' + viewport.height);
+ var definitions = document.createElementNS(NS, 'svg:defs');
+ svg.appendChild(definitions);
+ this.defs = definitions;
+ var rootGroup = document.createElementNS(NS, 'svg:g');
+ rootGroup.setAttributeNS(null, 'transform', pm(viewport.transform));
+ svg.appendChild(rootGroup);
+ this.svg = rootGroup;
+ return svg;
+ },
+ _ensureClipGroup: function SVGGraphics_ensureClipGroup() {
+ if (!this.current.clipGroup) {
+ var clipGroup = document.createElementNS(NS, 'svg:g');
+ clipGroup.setAttributeNS(null, 'clip-path', this.current.activeClipUrl);
+ this.svg.appendChild(clipGroup);
+ this.current.clipGroup = clipGroup;
+ }
+ return this.current.clipGroup;
+ },
+ _ensureTransformGroup: function SVGGraphics_ensureTransformGroup() {
+ if (!this.tgrp) {
+ this.tgrp = document.createElementNS(NS, 'svg:g');
+ this.tgrp.setAttributeNS(null, 'transform', pm(this.transformMatrix));
+ if (this.current.activeClipUrl) {
+ this._ensureClipGroup().appendChild(this.tgrp);
+ } else {
+ this.svg.appendChild(this.tgrp);
+ }
+ }
+ return this.tgrp;
+ }
+ };
+ return SVGGraphics;
+}();
+exports.SVGGraphics = SVGGraphics;
+
+/***/ }),
+/* 5 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var Util = sharedUtil.Util;
+var createPromiseCapability = sharedUtil.createPromiseCapability;
+var CustomStyle = displayDOMUtils.CustomStyle;
+var getDefaultSetting = displayDOMUtils.getDefaultSetting;
+var renderTextLayer = function renderTextLayerClosure() {
+ var MAX_TEXT_DIVS_TO_RENDER = 100000;
+ var NonWhitespaceRegexp = /\S/;
+ function isAllWhitespace(str) {
+ return !NonWhitespaceRegexp.test(str);
+ }
+ var styleBuf = ['left: ', 0, 'px; top: ', 0, 'px; font-size: ', 0, 'px; font-family: ', '', ';'];
+ function appendText(task, geom, styles) {
+ var textDiv = document.createElement('div');
+ var textDivProperties = {
+ style: null,
+ angle: 0,
+ canvasWidth: 0,
+ isWhitespace: false,
+ originalTransform: null,
+ paddingBottom: 0,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ scale: 1
+ };
+ task._textDivs.push(textDiv);
+ if (isAllWhitespace(geom.str)) {
+ textDivProperties.isWhitespace = true;
+ task._textDivProperties.set(textDiv, textDivProperties);
+ return;
+ }
+ var tx = Util.transform(task._viewport.transform, geom.transform);
+ var angle = Math.atan2(tx[1], tx[0]);
+ var style = styles[geom.fontName];
+ if (style.vertical) {
+ angle += Math.PI / 2;
+ }
+ var fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
+ var fontAscent = fontHeight;
+ if (style.ascent) {
+ fontAscent = style.ascent * fontAscent;
+ } else if (style.descent) {
+ fontAscent = (1 + style.descent) * fontAscent;
+ }
+ var left;
+ var top;
+ if (angle === 0) {
+ left = tx[4];
+ top = tx[5] - fontAscent;
+ } else {
+ left = tx[4] + fontAscent * Math.sin(angle);
+ top = tx[5] - fontAscent * Math.cos(angle);
+ }
+ styleBuf[1] = left;
+ styleBuf[3] = top;
+ styleBuf[5] = fontHeight;
+ styleBuf[7] = style.fontFamily;
+ textDivProperties.style = styleBuf.join('');
+ textDiv.setAttribute('style', textDivProperties.style);
+ textDiv.textContent = geom.str;
+ if (getDefaultSetting('pdfBug')) {
+ textDiv.dataset.fontName = geom.fontName;
+ }
+ if (angle !== 0) {
+ textDivProperties.angle = angle * (180 / Math.PI);
+ }
+ if (geom.str.length > 1) {
+ if (style.vertical) {
+ textDivProperties.canvasWidth = geom.height * task._viewport.scale;
+ } else {
+ textDivProperties.canvasWidth = geom.width * task._viewport.scale;
+ }
+ }
+ task._textDivProperties.set(textDiv, textDivProperties);
+ if (task._enhanceTextSelection) {
+ var angleCos = 1,
+ angleSin = 0;
+ if (angle !== 0) {
+ angleCos = Math.cos(angle);
+ angleSin = Math.sin(angle);
+ }
+ var divWidth = (style.vertical ? geom.height : geom.width) * task._viewport.scale;
+ var divHeight = fontHeight;
+ var m, b;
+ if (angle !== 0) {
+ m = [angleCos, angleSin, -angleSin, angleCos, left, top];
+ b = Util.getAxialAlignedBoundingBox([0, 0, divWidth, divHeight], m);
+ } else {
+ b = [left, top, left + divWidth, top + divHeight];
+ }
+ task._bounds.push({
+ left: b[0],
+ top: b[1],
+ right: b[2],
+ bottom: b[3],
+ div: textDiv,
+ size: [divWidth, divHeight],
+ m: m
+ });
+ }
+ }
+ function render(task) {
+ if (task._canceled) {
+ return;
+ }
+ var textLayerFrag = task._container;
+ var textDivs = task._textDivs;
+ var capability = task._capability;
+ var textDivsLength = textDivs.length;
+ if (textDivsLength > MAX_TEXT_DIVS_TO_RENDER) {
+ task._renderingDone = true;
+ capability.resolve();
+ return;
+ }
+ var canvas = document.createElement('canvas');
+ canvas.mozOpaque = true;
+ var ctx = canvas.getContext('2d', { alpha: false });
+ var lastFontSize;
+ var lastFontFamily;
+ for (var i = 0; i < textDivsLength; i++) {
+ var textDiv = textDivs[i];
+ var textDivProperties = task._textDivProperties.get(textDiv);
+ if (textDivProperties.isWhitespace) {
+ continue;
+ }
+ var fontSize = textDiv.style.fontSize;
+ var fontFamily = textDiv.style.fontFamily;
+ if (fontSize !== lastFontSize || fontFamily !== lastFontFamily) {
+ ctx.font = fontSize + ' ' + fontFamily;
+ lastFontSize = fontSize;
+ lastFontFamily = fontFamily;
+ }
+ var width = ctx.measureText(textDiv.textContent).width;
+ textLayerFrag.appendChild(textDiv);
+ var transform = '';
+ if (textDivProperties.canvasWidth !== 0 && width > 0) {
+ textDivProperties.scale = textDivProperties.canvasWidth / width;
+ transform = 'scaleX(' + textDivProperties.scale + ')';
+ }
+ if (textDivProperties.angle !== 0) {
+ transform = 'rotate(' + textDivProperties.angle + 'deg) ' + transform;
+ }
+ if (transform !== '') {
+ textDivProperties.originalTransform = transform;
+ CustomStyle.setProp('transform', textDiv, transform);
+ }
+ task._textDivProperties.set(textDiv, textDivProperties);
+ }
+ task._renderingDone = true;
+ capability.resolve();
+ }
+ function expand(task) {
+ var bounds = task._bounds;
+ var viewport = task._viewport;
+ var expanded = expandBounds(viewport.width, viewport.height, bounds);
+ for (var i = 0; i < expanded.length; i++) {
+ var div = bounds[i].div;
+ var divProperties = task._textDivProperties.get(div);
+ if (divProperties.angle === 0) {
+ divProperties.paddingLeft = bounds[i].left - expanded[i].left;
+ divProperties.paddingTop = bounds[i].top - expanded[i].top;
+ divProperties.paddingRight = expanded[i].right - bounds[i].right;
+ divProperties.paddingBottom = expanded[i].bottom - bounds[i].bottom;
+ task._textDivProperties.set(div, divProperties);
+ continue;
+ }
+ var e = expanded[i],
+ b = bounds[i];
+ var m = b.m,
+ c = m[0],
+ s = m[1];
+ var points = [[0, 0], [0, b.size[1]], [b.size[0], 0], b.size];
+ var ts = new Float64Array(64);
+ points.forEach(function (p, i) {
+ var t = Util.applyTransform(p, m);
+ ts[i + 0] = c && (e.left - t[0]) / c;
+ ts[i + 4] = s && (e.top - t[1]) / s;
+ ts[i + 8] = c && (e.right - t[0]) / c;
+ ts[i + 12] = s && (e.bottom - t[1]) / s;
+ ts[i + 16] = s && (e.left - t[0]) / -s;
+ ts[i + 20] = c && (e.top - t[1]) / c;
+ ts[i + 24] = s && (e.right - t[0]) / -s;
+ ts[i + 28] = c && (e.bottom - t[1]) / c;
+ ts[i + 32] = c && (e.left - t[0]) / -c;
+ ts[i + 36] = s && (e.top - t[1]) / -s;
+ ts[i + 40] = c && (e.right - t[0]) / -c;
+ ts[i + 44] = s && (e.bottom - t[1]) / -s;
+ ts[i + 48] = s && (e.left - t[0]) / s;
+ ts[i + 52] = c && (e.top - t[1]) / -c;
+ ts[i + 56] = s && (e.right - t[0]) / s;
+ ts[i + 60] = c && (e.bottom - t[1]) / -c;
+ });
+ var findPositiveMin = function (ts, offset, count) {
+ var result = 0;
+ for (var i = 0; i < count; i++) {
+ var t = ts[offset++];
+ if (t > 0) {
+ result = result ? Math.min(t, result) : t;
+ }
+ }
+ return result;
+ };
+ var boxScale = 1 + Math.min(Math.abs(c), Math.abs(s));
+ divProperties.paddingLeft = findPositiveMin(ts, 32, 16) / boxScale;
+ divProperties.paddingTop = findPositiveMin(ts, 48, 16) / boxScale;
+ divProperties.paddingRight = findPositiveMin(ts, 0, 16) / boxScale;
+ divProperties.paddingBottom = findPositiveMin(ts, 16, 16) / boxScale;
+ task._textDivProperties.set(div, divProperties);
+ }
+ }
+ function expandBounds(width, height, boxes) {
+ var bounds = boxes.map(function (box, i) {
+ return {
+ x1: box.left,
+ y1: box.top,
+ x2: box.right,
+ y2: box.bottom,
+ index: i,
+ x1New: undefined,
+ x2New: undefined
+ };
+ });
+ expandBoundsLTR(width, bounds);
+ var expanded = new Array(boxes.length);
+ bounds.forEach(function (b) {
+ var i = b.index;
+ expanded[i] = {
+ left: b.x1New,
+ top: 0,
+ right: b.x2New,
+ bottom: 0
+ };
+ });
+ boxes.map(function (box, i) {
+ var e = expanded[i],
+ b = bounds[i];
+ b.x1 = box.top;
+ b.y1 = width - e.right;
+ b.x2 = box.bottom;
+ b.y2 = width - e.left;
+ b.index = i;
+ b.x1New = undefined;
+ b.x2New = undefined;
+ });
+ expandBoundsLTR(height, bounds);
+ bounds.forEach(function (b) {
+ var i = b.index;
+ expanded[i].top = b.x1New;
+ expanded[i].bottom = b.x2New;
+ });
+ return expanded;
+ }
+ function expandBoundsLTR(width, bounds) {
+ bounds.sort(function (a, b) {
+ return a.x1 - b.x1 || a.index - b.index;
+ });
+ var fakeBoundary = {
+ x1: -Infinity,
+ y1: -Infinity,
+ x2: 0,
+ y2: Infinity,
+ index: -1,
+ x1New: 0,
+ x2New: 0
+ };
+ var horizon = [{
+ start: -Infinity,
+ end: Infinity,
+ boundary: fakeBoundary
+ }];
+ bounds.forEach(function (boundary) {
+ var i = 0;
+ while (i < horizon.length && horizon[i].end <= boundary.y1) {
+ i++;
+ }
+ var j = horizon.length - 1;
+ while (j >= 0 && horizon[j].start >= boundary.y2) {
+ j--;
+ }
+ var horizonPart, affectedBoundary;
+ var q,
+ k,
+ maxXNew = -Infinity;
+ for (q = i; q <= j; q++) {
+ horizonPart = horizon[q];
+ affectedBoundary = horizonPart.boundary;
+ var xNew;
+ if (affectedBoundary.x2 > boundary.x1) {
+ xNew = affectedBoundary.index > boundary.index ? affectedBoundary.x1New : boundary.x1;
+ } else if (affectedBoundary.x2New === undefined) {
+ xNew = (affectedBoundary.x2 + boundary.x1) / 2;
+ } else {
+ xNew = affectedBoundary.x2New;
+ }
+ if (xNew > maxXNew) {
+ maxXNew = xNew;
+ }
+ }
+ boundary.x1New = maxXNew;
+ for (q = i; q <= j; q++) {
+ horizonPart = horizon[q];
+ affectedBoundary = horizonPart.boundary;
+ if (affectedBoundary.x2New === undefined) {
+ if (affectedBoundary.x2 > boundary.x1) {
+ if (affectedBoundary.index > boundary.index) {
+ affectedBoundary.x2New = affectedBoundary.x2;
+ }
+ } else {
+ affectedBoundary.x2New = maxXNew;
+ }
+ } else if (affectedBoundary.x2New > maxXNew) {
+ affectedBoundary.x2New = Math.max(maxXNew, affectedBoundary.x2);
+ }
+ }
+ var changedHorizon = [],
+ lastBoundary = null;
+ for (q = i; q <= j; q++) {
+ horizonPart = horizon[q];
+ affectedBoundary = horizonPart.boundary;
+ var useBoundary = affectedBoundary.x2 > boundary.x2 ? affectedBoundary : boundary;
+ if (lastBoundary === useBoundary) {
+ changedHorizon[changedHorizon.length - 1].end = horizonPart.end;
+ } else {
+ changedHorizon.push({
+ start: horizonPart.start,
+ end: horizonPart.end,
+ boundary: useBoundary
+ });
+ lastBoundary = useBoundary;
+ }
+ }
+ if (horizon[i].start < boundary.y1) {
+ changedHorizon[0].start = boundary.y1;
+ changedHorizon.unshift({
+ start: horizon[i].start,
+ end: boundary.y1,
+ boundary: horizon[i].boundary
+ });
+ }
+ if (boundary.y2 < horizon[j].end) {
+ changedHorizon[changedHorizon.length - 1].end = boundary.y2;
+ changedHorizon.push({
+ start: boundary.y2,
+ end: horizon[j].end,
+ boundary: horizon[j].boundary
+ });
+ }
+ for (q = i; q <= j; q++) {
+ horizonPart = horizon[q];
+ affectedBoundary = horizonPart.boundary;
+ if (affectedBoundary.x2New !== undefined) {
+ continue;
+ }
+ var used = false;
+ for (k = i - 1; !used && k >= 0 && horizon[k].start >= affectedBoundary.y1; k--) {
+ used = horizon[k].boundary === affectedBoundary;
+ }
+ for (k = j + 1; !used && k < horizon.length && horizon[k].end <= affectedBoundary.y2; k++) {
+ used = horizon[k].boundary === affectedBoundary;
+ }
+ for (k = 0; !used && k < changedHorizon.length; k++) {
+ used = changedHorizon[k].boundary === affectedBoundary;
+ }
+ if (!used) {
+ affectedBoundary.x2New = maxXNew;
+ }
+ }
+ Array.prototype.splice.apply(horizon, [i, j - i + 1].concat(changedHorizon));
+ });
+ horizon.forEach(function (horizonPart) {
+ var affectedBoundary = horizonPart.boundary;
+ if (affectedBoundary.x2New === undefined) {
+ affectedBoundary.x2New = Math.max(width, affectedBoundary.x2);
+ }
+ });
+ }
+ function TextLayerRenderTask(textContent, container, viewport, textDivs, enhanceTextSelection) {
+ this._textContent = textContent;
+ this._container = container;
+ this._viewport = viewport;
+ this._textDivs = textDivs || [];
+ this._textDivProperties = new WeakMap();
+ this._renderingDone = false;
+ this._canceled = false;
+ this._capability = createPromiseCapability();
+ this._renderTimer = null;
+ this._bounds = [];
+ this._enhanceTextSelection = !!enhanceTextSelection;
+ }
+ TextLayerRenderTask.prototype = {
+ get promise() {
+ return this._capability.promise;
+ },
+ cancel: function TextLayer_cancel() {
+ this._canceled = true;
+ if (this._renderTimer !== null) {
+ clearTimeout(this._renderTimer);
+ this._renderTimer = null;
+ }
+ this._capability.reject('canceled');
+ },
+ _render: function TextLayer_render(timeout) {
+ var textItems = this._textContent.items;
+ var textStyles = this._textContent.styles;
+ for (var i = 0, len = textItems.length; i < len; i++) {
+ appendText(this, textItems[i], textStyles);
+ }
+ if (!timeout) {
+ render(this);
+ } else {
+ var self = this;
+ this._renderTimer = setTimeout(function () {
+ render(self);
+ self._renderTimer = null;
+ }, timeout);
+ }
+ },
+ expandTextDivs: function TextLayer_expandTextDivs(expandDivs) {
+ if (!this._enhanceTextSelection || !this._renderingDone) {
+ return;
+ }
+ if (this._bounds !== null) {
+ expand(this);
+ this._bounds = null;
+ }
+ for (var i = 0, ii = this._textDivs.length; i < ii; i++) {
+ var div = this._textDivs[i];
+ var divProperties = this._textDivProperties.get(div);
+ if (divProperties.isWhitespace) {
+ continue;
+ }
+ if (expandDivs) {
+ var transform = '',
+ padding = '';
+ if (divProperties.scale !== 1) {
+ transform = 'scaleX(' + divProperties.scale + ')';
+ }
+ if (divProperties.angle !== 0) {
+ transform = 'rotate(' + divProperties.angle + 'deg) ' + transform;
+ }
+ if (divProperties.paddingLeft !== 0) {
+ padding += ' padding-left: ' + divProperties.paddingLeft / divProperties.scale + 'px;';
+ transform += ' translateX(' + -divProperties.paddingLeft / divProperties.scale + 'px)';
+ }
+ if (divProperties.paddingTop !== 0) {
+ padding += ' padding-top: ' + divProperties.paddingTop + 'px;';
+ transform += ' translateY(' + -divProperties.paddingTop + 'px)';
+ }
+ if (divProperties.paddingRight !== 0) {
+ padding += ' padding-right: ' + divProperties.paddingRight / divProperties.scale + 'px;';
+ }
+ if (divProperties.paddingBottom !== 0) {
+ padding += ' padding-bottom: ' + divProperties.paddingBottom + 'px;';
+ }
+ if (padding !== '') {
+ div.setAttribute('style', divProperties.style + padding);
+ }
+ if (transform !== '') {
+ CustomStyle.setProp('transform', div, transform);
+ }
+ } else {
+ div.style.padding = 0;
+ CustomStyle.setProp('transform', div, divProperties.originalTransform || '');
+ }
+ }
+ }
+ };
+ function renderTextLayer(renderParameters) {
+ var task = new TextLayerRenderTask(renderParameters.textContent, renderParameters.container, renderParameters.viewport, renderParameters.textDivs, renderParameters.enhanceTextSelection);
+ task._render(renderParameters.timeout);
+ return task;
+ }
+ return renderTextLayer;
+}();
+exports.renderTextLayer = renderTextLayer;
+
+/***/ }),
+/* 6 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var g;
+g = function () {
+ return this;
+}();
+try {
+ g = g || Function("return this")() || (1, eval)("this");
+} catch (e) {
+ if (typeof window === "object") g = window;
+}
+module.exports = g;
+
+/***/ }),
+/* 7 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var error = sharedUtil.error;
+function fixMetadata(meta) {
+ return meta.replace(/>\\376\\377([^<]+)/g, function (all, codes) {
+ var bytes = codes.replace(/\\([0-3])([0-7])([0-7])/g, function (code, d1, d2, d3) {
+ return String.fromCharCode(d1 * 64 + d2 * 8 + d3 * 1);
+ });
+ var chars = '';
+ for (var i = 0; i < bytes.length; i += 2) {
+ var code = bytes.charCodeAt(i) * 256 + bytes.charCodeAt(i + 1);
+ chars += code >= 32 && code < 127 && code !== 60 && code !== 62 && code !== 38 ? String.fromCharCode(code) : '&#x' + (0x10000 + code).toString(16).substring(1) + ';';
+ }
+ return '>' + chars;
+ });
+}
+function Metadata(meta) {
+ if (typeof meta === 'string') {
+ meta = fixMetadata(meta);
+ var parser = new DOMParser();
+ meta = parser.parseFromString(meta, 'application/xml');
+ } else if (!(meta instanceof Document)) {
+ error('Metadata: Invalid metadata object');
+ }
+ this.metaDocument = meta;
+ this.metadata = Object.create(null);
+ this.parse();
+}
+Metadata.prototype = {
+ parse: function Metadata_parse() {
+ var doc = this.metaDocument;
+ var rdf = doc.documentElement;
+ if (rdf.nodeName.toLowerCase() !== 'rdf:rdf') {
+ rdf = rdf.firstChild;
+ while (rdf && rdf.nodeName.toLowerCase() !== 'rdf:rdf') {
+ rdf = rdf.nextSibling;
+ }
+ }
+ var nodeName = rdf ? rdf.nodeName.toLowerCase() : null;
+ if (!rdf || nodeName !== 'rdf:rdf' || !rdf.hasChildNodes()) {
+ return;
+ }
+ var children = rdf.childNodes,
+ desc,
+ entry,
+ name,
+ i,
+ ii,
+ length,
+ iLength;
+ for (i = 0, length = children.length; i < length; i++) {
+ desc = children[i];
+ if (desc.nodeName.toLowerCase() !== 'rdf:description') {
+ continue;
+ }
+ for (ii = 0, iLength = desc.childNodes.length; ii < iLength; ii++) {
+ if (desc.childNodes[ii].nodeName.toLowerCase() !== '#text') {
+ entry = desc.childNodes[ii];
+ name = entry.nodeName.toLowerCase();
+ this.metadata[name] = entry.textContent.trim();
+ }
+ }
+ }
+ },
+ get: function Metadata_get(name) {
+ return this.metadata[name] || null;
+ },
+ has: function Metadata_has(name) {
+ return typeof this.metadata[name] !== 'undefined';
+ }
+};
+exports.Metadata = Metadata;
+
+/***/ }),
+/* 8 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var shadow = sharedUtil.shadow;
+var getDefaultSetting = displayDOMUtils.getDefaultSetting;
+var WebGLUtils = function WebGLUtilsClosure() {
+ function loadShader(gl, code, shaderType) {
+ var shader = gl.createShader(shaderType);
+ gl.shaderSource(shader, code);
+ gl.compileShader(shader);
+ var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
+ if (!compiled) {
+ var errorMsg = gl.getShaderInfoLog(shader);
+ throw new Error('Error during shader compilation: ' + errorMsg);
+ }
+ return shader;
+ }
+ function createVertexShader(gl, code) {
+ return loadShader(gl, code, gl.VERTEX_SHADER);
+ }
+ function createFragmentShader(gl, code) {
+ return loadShader(gl, code, gl.FRAGMENT_SHADER);
+ }
+ function createProgram(gl, shaders) {
+ var program = gl.createProgram();
+ for (var i = 0, ii = shaders.length; i < ii; ++i) {
+ gl.attachShader(program, shaders[i]);
+ }
+ gl.linkProgram(program);
+ var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
+ if (!linked) {
+ var errorMsg = gl.getProgramInfoLog(program);
+ throw new Error('Error during program linking: ' + errorMsg);
+ }
+ return program;
+ }
+ function createTexture(gl, image, textureId) {
+ gl.activeTexture(textureId);
+ var texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+ return texture;
+ }
+ var currentGL, currentCanvas;
+ function generateGL() {
+ if (currentGL) {
+ return;
+ }
+ currentCanvas = document.createElement('canvas');
+ currentGL = currentCanvas.getContext('webgl', { premultipliedalpha: false });
+ }
+ var smaskVertexShaderCode = '\
+ attribute vec2 a_position; \
+ attribute vec2 a_texCoord; \
+ \
+ uniform vec2 u_resolution; \
+ \
+ varying vec2 v_texCoord; \
+ \
+ void main() { \
+ vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0; \
+ gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); \
+ \
+ v_texCoord = a_texCoord; \
+ } ';
+ var smaskFragmentShaderCode = '\
+ precision mediump float; \
+ \
+ uniform vec4 u_backdrop; \
+ uniform int u_subtype; \
+ uniform sampler2D u_image; \
+ uniform sampler2D u_mask; \
+ \
+ varying vec2 v_texCoord; \
+ \
+ void main() { \
+ vec4 imageColor = texture2D(u_image, v_texCoord); \
+ vec4 maskColor = texture2D(u_mask, v_texCoord); \
+ if (u_backdrop.a > 0.0) { \
+ maskColor.rgb = maskColor.rgb * maskColor.a + \
+ u_backdrop.rgb * (1.0 - maskColor.a); \
+ } \
+ float lum; \
+ if (u_subtype == 0) { \
+ lum = maskColor.a; \
+ } else { \
+ lum = maskColor.r * 0.3 + maskColor.g * 0.59 + \
+ maskColor.b * 0.11; \
+ } \
+ imageColor.a *= lum; \
+ imageColor.rgb *= imageColor.a; \
+ gl_FragColor = imageColor; \
+ } ';
+ var smaskCache = null;
+ function initSmaskGL() {
+ var canvas, gl;
+ generateGL();
+ canvas = currentCanvas;
+ currentCanvas = null;
+ gl = currentGL;
+ currentGL = null;
+ var vertexShader = createVertexShader(gl, smaskVertexShaderCode);
+ var fragmentShader = createFragmentShader(gl, smaskFragmentShaderCode);
+ var program = createProgram(gl, [vertexShader, fragmentShader]);
+ gl.useProgram(program);
+ var cache = {};
+ cache.gl = gl;
+ cache.canvas = canvas;
+ cache.resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
+ cache.positionLocation = gl.getAttribLocation(program, 'a_position');
+ cache.backdropLocation = gl.getUniformLocation(program, 'u_backdrop');
+ cache.subtypeLocation = gl.getUniformLocation(program, 'u_subtype');
+ var texCoordLocation = gl.getAttribLocation(program, 'a_texCoord');
+ var texLayerLocation = gl.getUniformLocation(program, 'u_image');
+ var texMaskLocation = gl.getUniformLocation(program, 'u_mask');
+ var texCoordBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]), gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(texCoordLocation);
+ gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
+ gl.uniform1i(texLayerLocation, 0);
+ gl.uniform1i(texMaskLocation, 1);
+ smaskCache = cache;
+ }
+ function composeSMask(layer, mask, properties) {
+ var width = layer.width,
+ height = layer.height;
+ if (!smaskCache) {
+ initSmaskGL();
+ }
+ var cache = smaskCache,
+ canvas = cache.canvas,
+ gl = cache.gl;
+ canvas.width = width;
+ canvas.height = height;
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+ gl.uniform2f(cache.resolutionLocation, width, height);
+ if (properties.backdrop) {
+ gl.uniform4f(cache.resolutionLocation, properties.backdrop[0], properties.backdrop[1], properties.backdrop[2], 1);
+ } else {
+ gl.uniform4f(cache.resolutionLocation, 0, 0, 0, 0);
+ }
+ gl.uniform1i(cache.subtypeLocation, properties.subtype === 'Luminosity' ? 1 : 0);
+ var texture = createTexture(gl, layer, gl.TEXTURE0);
+ var maskTexture = createTexture(gl, mask, gl.TEXTURE1);
+ var buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, width, 0, 0, height, 0, height, width, 0, width, height]), gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(cache.positionLocation);
+ gl.vertexAttribPointer(cache.positionLocation, 2, gl.FLOAT, false, 0, 0);
+ gl.clearColor(0, 0, 0, 0);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ gl.flush();
+ gl.deleteTexture(texture);
+ gl.deleteTexture(maskTexture);
+ gl.deleteBuffer(buffer);
+ return canvas;
+ }
+ var figuresVertexShaderCode = '\
+ attribute vec2 a_position; \
+ attribute vec3 a_color; \
+ \
+ uniform vec2 u_resolution; \
+ uniform vec2 u_scale; \
+ uniform vec2 u_offset; \
+ \
+ varying vec4 v_color; \
+ \
+ void main() { \
+ vec2 position = (a_position + u_offset) * u_scale; \
+ vec2 clipSpace = (position / u_resolution) * 2.0 - 1.0; \
+ gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); \
+ \
+ v_color = vec4(a_color / 255.0, 1.0); \
+ } ';
+ var figuresFragmentShaderCode = '\
+ precision mediump float; \
+ \
+ varying vec4 v_color; \
+ \
+ void main() { \
+ gl_FragColor = v_color; \
+ } ';
+ var figuresCache = null;
+ function initFiguresGL() {
+ var canvas, gl;
+ generateGL();
+ canvas = currentCanvas;
+ currentCanvas = null;
+ gl = currentGL;
+ currentGL = null;
+ var vertexShader = createVertexShader(gl, figuresVertexShaderCode);
+ var fragmentShader = createFragmentShader(gl, figuresFragmentShaderCode);
+ var program = createProgram(gl, [vertexShader, fragmentShader]);
+ gl.useProgram(program);
+ var cache = {};
+ cache.gl = gl;
+ cache.canvas = canvas;
+ cache.resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
+ cache.scaleLocation = gl.getUniformLocation(program, 'u_scale');
+ cache.offsetLocation = gl.getUniformLocation(program, 'u_offset');
+ cache.positionLocation = gl.getAttribLocation(program, 'a_position');
+ cache.colorLocation = gl.getAttribLocation(program, 'a_color');
+ figuresCache = cache;
+ }
+ function drawFigures(width, height, backgroundColor, figures, context) {
+ if (!figuresCache) {
+ initFiguresGL();
+ }
+ var cache = figuresCache,
+ canvas = cache.canvas,
+ gl = cache.gl;
+ canvas.width = width;
+ canvas.height = height;
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+ gl.uniform2f(cache.resolutionLocation, width, height);
+ var count = 0;
+ var i, ii, rows;
+ for (i = 0, ii = figures.length; i < ii; i++) {
+ switch (figures[i].type) {
+ case 'lattice':
+ rows = figures[i].coords.length / figures[i].verticesPerRow | 0;
+ count += (rows - 1) * (figures[i].verticesPerRow - 1) * 6;
+ break;
+ case 'triangles':
+ count += figures[i].coords.length;
+ break;
+ }
+ }
+ var coords = new Float32Array(count * 2);
+ var colors = new Uint8Array(count * 3);
+ var coordsMap = context.coords,
+ colorsMap = context.colors;
+ var pIndex = 0,
+ cIndex = 0;
+ for (i = 0, ii = figures.length; i < ii; i++) {
+ var figure = figures[i],
+ ps = figure.coords,
+ cs = figure.colors;
+ switch (figure.type) {
+ case 'lattice':
+ var cols = figure.verticesPerRow;
+ rows = ps.length / cols | 0;
+ for (var row = 1; row < rows; row++) {
+ var offset = row * cols + 1;
+ for (var col = 1; col < cols; col++, offset++) {
+ coords[pIndex] = coordsMap[ps[offset - cols - 1]];
+ coords[pIndex + 1] = coordsMap[ps[offset - cols - 1] + 1];
+ coords[pIndex + 2] = coordsMap[ps[offset - cols]];
+ coords[pIndex + 3] = coordsMap[ps[offset - cols] + 1];
+ coords[pIndex + 4] = coordsMap[ps[offset - 1]];
+ coords[pIndex + 5] = coordsMap[ps[offset - 1] + 1];
+ colors[cIndex] = colorsMap[cs[offset - cols - 1]];
+ colors[cIndex + 1] = colorsMap[cs[offset - cols - 1] + 1];
+ colors[cIndex + 2] = colorsMap[cs[offset - cols - 1] + 2];
+ colors[cIndex + 3] = colorsMap[cs[offset - cols]];
+ colors[cIndex + 4] = colorsMap[cs[offset - cols] + 1];
+ colors[cIndex + 5] = colorsMap[cs[offset - cols] + 2];
+ colors[cIndex + 6] = colorsMap[cs[offset - 1]];
+ colors[cIndex + 7] = colorsMap[cs[offset - 1] + 1];
+ colors[cIndex + 8] = colorsMap[cs[offset - 1] + 2];
+ coords[pIndex + 6] = coords[pIndex + 2];
+ coords[pIndex + 7] = coords[pIndex + 3];
+ coords[pIndex + 8] = coords[pIndex + 4];
+ coords[pIndex + 9] = coords[pIndex + 5];
+ coords[pIndex + 10] = coordsMap[ps[offset]];
+ coords[pIndex + 11] = coordsMap[ps[offset] + 1];
+ colors[cIndex + 9] = colors[cIndex + 3];
+ colors[cIndex + 10] = colors[cIndex + 4];
+ colors[cIndex + 11] = colors[cIndex + 5];
+ colors[cIndex + 12] = colors[cIndex + 6];
+ colors[cIndex + 13] = colors[cIndex + 7];
+ colors[cIndex + 14] = colors[cIndex + 8];
+ colors[cIndex + 15] = colorsMap[cs[offset]];
+ colors[cIndex + 16] = colorsMap[cs[offset] + 1];
+ colors[cIndex + 17] = colorsMap[cs[offset] + 2];
+ pIndex += 12;
+ cIndex += 18;
+ }
+ }
+ break;
+ case 'triangles':
+ for (var j = 0, jj = ps.length; j < jj; j++) {
+ coords[pIndex] = coordsMap[ps[j]];
+ coords[pIndex + 1] = coordsMap[ps[j] + 1];
+ colors[cIndex] = colorsMap[cs[j]];
+ colors[cIndex + 1] = colorsMap[cs[j] + 1];
+ colors[cIndex + 2] = colorsMap[cs[j] + 2];
+ pIndex += 2;
+ cIndex += 3;
+ }
+ break;
+ }
+ }
+ if (backgroundColor) {
+ gl.clearColor(backgroundColor[0] / 255, backgroundColor[1] / 255, backgroundColor[2] / 255, 1.0);
+ } else {
+ gl.clearColor(0, 0, 0, 0);
+ }
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ var coordsBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, coordsBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, coords, gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(cache.positionLocation);
+ gl.vertexAttribPointer(cache.positionLocation, 2, gl.FLOAT, false, 0, 0);
+ var colorsBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, colorsBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(cache.colorLocation);
+ gl.vertexAttribPointer(cache.colorLocation, 3, gl.UNSIGNED_BYTE, false, 0, 0);
+ gl.uniform2f(cache.scaleLocation, context.scaleX, context.scaleY);
+ gl.uniform2f(cache.offsetLocation, context.offsetX, context.offsetY);
+ gl.drawArrays(gl.TRIANGLES, 0, count);
+ gl.flush();
+ gl.deleteBuffer(coordsBuffer);
+ gl.deleteBuffer(colorsBuffer);
+ return canvas;
+ }
+ function cleanup() {
+ if (smaskCache && smaskCache.canvas) {
+ smaskCache.canvas.width = 0;
+ smaskCache.canvas.height = 0;
+ }
+ if (figuresCache && figuresCache.canvas) {
+ figuresCache.canvas.width = 0;
+ figuresCache.canvas.height = 0;
+ }
+ smaskCache = null;
+ figuresCache = null;
+ }
+ return {
+ get isEnabled() {
+ if (getDefaultSetting('disableWebGL')) {
+ return false;
+ }
+ var enabled = false;
+ try {
+ generateGL();
+ enabled = !!currentGL;
+ } catch (e) {}
+ return shadow(this, 'isEnabled', enabled);
+ },
+ composeSMask: composeSMask,
+ drawFigures: drawFigures,
+ clear: cleanup
+ };
+}();
+exports.WebGLUtils = WebGLUtils;
+
+/***/ }),
+/* 9 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var displayAPI = __w_pdfjs_require__(3);
+var displayAnnotationLayer = __w_pdfjs_require__(2);
+var displayTextLayer = __w_pdfjs_require__(5);
+var displayMetadata = __w_pdfjs_require__(7);
+var displaySVG = __w_pdfjs_require__(4);
+var globalScope = sharedUtil.globalScope;
+var deprecated = sharedUtil.deprecated;
+var warn = sharedUtil.warn;
+var LinkTarget = displayDOMUtils.LinkTarget;
+var DEFAULT_LINK_REL = displayDOMUtils.DEFAULT_LINK_REL;
+var isWorker = typeof window === 'undefined';
+if (!globalScope.PDFJS) {
+ globalScope.PDFJS = {};
+}
+var PDFJS = globalScope.PDFJS;
+PDFJS.version = '1.8.172';
+PDFJS.build = '8ff1fbe7';
+PDFJS.pdfBug = false;
+if (PDFJS.verbosity !== undefined) {
+ sharedUtil.setVerbosityLevel(PDFJS.verbosity);
+}
+delete PDFJS.verbosity;
+Object.defineProperty(PDFJS, 'verbosity', {
+ get: function () {
+ return sharedUtil.getVerbosityLevel();
+ },
+ set: function (level) {
+ sharedUtil.setVerbosityLevel(level);
+ },
+ enumerable: true,
+ configurable: true
+});
+PDFJS.VERBOSITY_LEVELS = sharedUtil.VERBOSITY_LEVELS;
+PDFJS.OPS = sharedUtil.OPS;
+PDFJS.UNSUPPORTED_FEATURES = sharedUtil.UNSUPPORTED_FEATURES;
+PDFJS.isValidUrl = displayDOMUtils.isValidUrl;
+PDFJS.shadow = sharedUtil.shadow;
+PDFJS.createBlob = sharedUtil.createBlob;
+PDFJS.createObjectURL = function PDFJS_createObjectURL(data, contentType) {
+ return sharedUtil.createObjectURL(data, contentType, PDFJS.disableCreateObjectURL);
+};
+Object.defineProperty(PDFJS, 'isLittleEndian', {
+ configurable: true,
+ get: function PDFJS_isLittleEndian() {
+ var value = sharedUtil.isLittleEndian();
+ return sharedUtil.shadow(PDFJS, 'isLittleEndian', value);
+ }
+});
+PDFJS.removeNullCharacters = sharedUtil.removeNullCharacters;
+PDFJS.PasswordResponses = sharedUtil.PasswordResponses;
+PDFJS.PasswordException = sharedUtil.PasswordException;
+PDFJS.UnknownErrorException = sharedUtil.UnknownErrorException;
+PDFJS.InvalidPDFException = sharedUtil.InvalidPDFException;
+PDFJS.MissingPDFException = sharedUtil.MissingPDFException;
+PDFJS.UnexpectedResponseException = sharedUtil.UnexpectedResponseException;
+PDFJS.Util = sharedUtil.Util;
+PDFJS.PageViewport = sharedUtil.PageViewport;
+PDFJS.createPromiseCapability = sharedUtil.createPromiseCapability;
+PDFJS.maxImageSize = PDFJS.maxImageSize === undefined ? -1 : PDFJS.maxImageSize;
+PDFJS.cMapUrl = PDFJS.cMapUrl === undefined ? null : PDFJS.cMapUrl;
+PDFJS.cMapPacked = PDFJS.cMapPacked === undefined ? false : PDFJS.cMapPacked;
+PDFJS.disableFontFace = PDFJS.disableFontFace === undefined ? false : PDFJS.disableFontFace;
+PDFJS.imageResourcesPath = PDFJS.imageResourcesPath === undefined ? '' : PDFJS.imageResourcesPath;
+PDFJS.disableWorker = PDFJS.disableWorker === undefined ? false : PDFJS.disableWorker;
+PDFJS.workerSrc = PDFJS.workerSrc === undefined ? null : PDFJS.workerSrc;
+PDFJS.workerPort = PDFJS.workerPort === undefined ? null : PDFJS.workerPort;
+PDFJS.disableRange = PDFJS.disableRange === undefined ? false : PDFJS.disableRange;
+PDFJS.disableStream = PDFJS.disableStream === undefined ? false : PDFJS.disableStream;
+PDFJS.disableAutoFetch = PDFJS.disableAutoFetch === undefined ? false : PDFJS.disableAutoFetch;
+PDFJS.pdfBug = PDFJS.pdfBug === undefined ? false : PDFJS.pdfBug;
+PDFJS.postMessageTransfers = PDFJS.postMessageTransfers === undefined ? true : PDFJS.postMessageTransfers;
+PDFJS.disableCreateObjectURL = PDFJS.disableCreateObjectURL === undefined ? false : PDFJS.disableCreateObjectURL;
+PDFJS.disableWebGL = PDFJS.disableWebGL === undefined ? true : PDFJS.disableWebGL;
+PDFJS.externalLinkTarget = PDFJS.externalLinkTarget === undefined ? LinkTarget.NONE : PDFJS.externalLinkTarget;
+PDFJS.externalLinkRel = PDFJS.externalLinkRel === undefined ? DEFAULT_LINK_REL : PDFJS.externalLinkRel;
+PDFJS.isEvalSupported = PDFJS.isEvalSupported === undefined ? true : PDFJS.isEvalSupported;
+PDFJS.pdfjsNext = PDFJS.pdfjsNext === undefined ? false : PDFJS.pdfjsNext;
+var savedOpenExternalLinksInNewWindow = PDFJS.openExternalLinksInNewWindow;
+delete PDFJS.openExternalLinksInNewWindow;
+Object.defineProperty(PDFJS, 'openExternalLinksInNewWindow', {
+ get: function () {
+ return PDFJS.externalLinkTarget === LinkTarget.BLANK;
+ },
+ set: function (value) {
+ if (value) {
+ deprecated('PDFJS.openExternalLinksInNewWindow, please use ' + '"PDFJS.externalLinkTarget = PDFJS.LinkTarget.BLANK" instead.');
+ }
+ if (PDFJS.externalLinkTarget !== LinkTarget.NONE) {
+ warn('PDFJS.externalLinkTarget is already initialized');
+ return;
+ }
+ PDFJS.externalLinkTarget = value ? LinkTarget.BLANK : LinkTarget.NONE;
+ },
+ enumerable: true,
+ configurable: true
+});
+if (savedOpenExternalLinksInNewWindow) {
+ PDFJS.openExternalLinksInNewWindow = savedOpenExternalLinksInNewWindow;
+}
+PDFJS.getDocument = displayAPI.getDocument;
+PDFJS.PDFDataRangeTransport = displayAPI.PDFDataRangeTransport;
+PDFJS.PDFWorker = displayAPI.PDFWorker;
+Object.defineProperty(PDFJS, 'hasCanvasTypedArrays', {
+ configurable: true,
+ get: function PDFJS_hasCanvasTypedArrays() {
+ var value = displayDOMUtils.hasCanvasTypedArrays();
+ return sharedUtil.shadow(PDFJS, 'hasCanvasTypedArrays', value);
+ }
+});
+PDFJS.CustomStyle = displayDOMUtils.CustomStyle;
+PDFJS.LinkTarget = LinkTarget;
+PDFJS.addLinkAttributes = displayDOMUtils.addLinkAttributes;
+PDFJS.getFilenameFromUrl = displayDOMUtils.getFilenameFromUrl;
+PDFJS.isExternalLinkTargetSet = displayDOMUtils.isExternalLinkTargetSet;
+PDFJS.AnnotationLayer = displayAnnotationLayer.AnnotationLayer;
+PDFJS.renderTextLayer = displayTextLayer.renderTextLayer;
+PDFJS.Metadata = displayMetadata.Metadata;
+PDFJS.SVGGraphics = displaySVG.SVGGraphics;
+PDFJS.UnsupportedManager = displayAPI._UnsupportedManager;
+exports.globalScope = globalScope;
+exports.isWorker = isWorker;
+exports.PDFJS = globalScope.PDFJS;
+
+/***/ }),
+/* 10 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayDOMUtils = __w_pdfjs_require__(1);
+var displayPatternHelper = __w_pdfjs_require__(12);
+var displayWebGL = __w_pdfjs_require__(8);
+var FONT_IDENTITY_MATRIX = sharedUtil.FONT_IDENTITY_MATRIX;
+var IDENTITY_MATRIX = sharedUtil.IDENTITY_MATRIX;
+var ImageKind = sharedUtil.ImageKind;
+var OPS = sharedUtil.OPS;
+var TextRenderingMode = sharedUtil.TextRenderingMode;
+var Uint32ArrayView = sharedUtil.Uint32ArrayView;
+var Util = sharedUtil.Util;
+var assert = sharedUtil.assert;
+var info = sharedUtil.info;
+var isNum = sharedUtil.isNum;
+var isArray = sharedUtil.isArray;
+var isLittleEndian = sharedUtil.isLittleEndian;
+var error = sharedUtil.error;
+var shadow = sharedUtil.shadow;
+var warn = sharedUtil.warn;
+var TilingPattern = displayPatternHelper.TilingPattern;
+var getShadingPatternFromIR = displayPatternHelper.getShadingPatternFromIR;
+var WebGLUtils = displayWebGL.WebGLUtils;
+var hasCanvasTypedArrays = displayDOMUtils.hasCanvasTypedArrays;
+var MIN_FONT_SIZE = 16;
+var MAX_FONT_SIZE = 100;
+var MAX_GROUP_SIZE = 4096;
+var MIN_WIDTH_FACTOR = 0.65;
+var COMPILE_TYPE3_GLYPHS = true;
+var MAX_SIZE_TO_COMPILE = 1000;
+var FULL_CHUNK_HEIGHT = 16;
+var HasCanvasTypedArraysCached = {
+ get value() {
+ return shadow(HasCanvasTypedArraysCached, 'value', hasCanvasTypedArrays());
+ }
+};
+var IsLittleEndianCached = {
+ get value() {
+ return shadow(IsLittleEndianCached, 'value', isLittleEndian());
+ }
+};
+function addContextCurrentTransform(ctx) {
+ if (!ctx.mozCurrentTransform) {
+ ctx._originalSave = ctx.save;
+ ctx._originalRestore = ctx.restore;
+ ctx._originalRotate = ctx.rotate;
+ ctx._originalScale = ctx.scale;
+ ctx._originalTranslate = ctx.translate;
+ ctx._originalTransform = ctx.transform;
+ ctx._originalSetTransform = ctx.setTransform;
+ ctx._transformMatrix = ctx._transformMatrix || [1, 0, 0, 1, 0, 0];
+ ctx._transformStack = [];
+ Object.defineProperty(ctx, 'mozCurrentTransform', {
+ get: function getCurrentTransform() {
+ return this._transformMatrix;
+ }
+ });
+ Object.defineProperty(ctx, 'mozCurrentTransformInverse', {
+ get: function getCurrentTransformInverse() {
+ var m = this._transformMatrix;
+ var a = m[0],
+ b = m[1],
+ c = m[2],
+ d = m[3],
+ e = m[4],
+ f = m[5];
+ var ad_bc = a * d - b * c;
+ var bc_ad = b * c - a * d;
+ return [d / ad_bc, b / bc_ad, c / bc_ad, a / ad_bc, (d * e - c * f) / bc_ad, (b * e - a * f) / ad_bc];
+ }
+ });
+ ctx.save = function ctxSave() {
+ var old = this._transformMatrix;
+ this._transformStack.push(old);
+ this._transformMatrix = old.slice(0, 6);
+ this._originalSave();
+ };
+ ctx.restore = function ctxRestore() {
+ var prev = this._transformStack.pop();
+ if (prev) {
+ this._transformMatrix = prev;
+ this._originalRestore();
+ }
+ };
+ ctx.translate = function ctxTranslate(x, y) {
+ var m = this._transformMatrix;
+ m[4] = m[0] * x + m[2] * y + m[4];
+ m[5] = m[1] * x + m[3] * y + m[5];
+ this._originalTranslate(x, y);
+ };
+ ctx.scale = function ctxScale(x, y) {
+ var m = this._transformMatrix;
+ m[0] = m[0] * x;
+ m[1] = m[1] * x;
+ m[2] = m[2] * y;
+ m[3] = m[3] * y;
+ this._originalScale(x, y);
+ };
+ ctx.transform = function ctxTransform(a, b, c, d, e, f) {
+ var m = this._transformMatrix;
+ this._transformMatrix = [m[0] * a + m[2] * b, m[1] * a + m[3] * b, m[0] * c + m[2] * d, m[1] * c + m[3] * d, m[0] * e + m[2] * f + m[4], m[1] * e + m[3] * f + m[5]];
+ ctx._originalTransform(a, b, c, d, e, f);
+ };
+ ctx.setTransform = function ctxSetTransform(a, b, c, d, e, f) {
+ this._transformMatrix = [a, b, c, d, e, f];
+ ctx._originalSetTransform(a, b, c, d, e, f);
+ };
+ ctx.rotate = function ctxRotate(angle) {
+ var cosValue = Math.cos(angle);
+ var sinValue = Math.sin(angle);
+ var m = this._transformMatrix;
+ this._transformMatrix = [m[0] * cosValue + m[2] * sinValue, m[1] * cosValue + m[3] * sinValue, m[0] * -sinValue + m[2] * cosValue, m[1] * -sinValue + m[3] * cosValue, m[4], m[5]];
+ this._originalRotate(angle);
+ };
+ }
+}
+var CachedCanvases = function CachedCanvasesClosure() {
+ function CachedCanvases(canvasFactory) {
+ this.canvasFactory = canvasFactory;
+ this.cache = Object.create(null);
+ }
+ CachedCanvases.prototype = {
+ getCanvas: function CachedCanvases_getCanvas(id, width, height, trackTransform) {
+ var canvasEntry;
+ if (this.cache[id] !== undefined) {
+ canvasEntry = this.cache[id];
+ this.canvasFactory.reset(canvasEntry, width, height);
+ canvasEntry.context.setTransform(1, 0, 0, 1, 0, 0);
+ } else {
+ canvasEntry = this.canvasFactory.create(width, height);
+ this.cache[id] = canvasEntry;
+ }
+ if (trackTransform) {
+ addContextCurrentTransform(canvasEntry.context);
+ }
+ return canvasEntry;
+ },
+ clear: function () {
+ for (var id in this.cache) {
+ var canvasEntry = this.cache[id];
+ this.canvasFactory.destroy(canvasEntry);
+ delete this.cache[id];
+ }
+ }
+ };
+ return CachedCanvases;
+}();
+function compileType3Glyph(imgData) {
+ var POINT_TO_PROCESS_LIMIT = 1000;
+ var width = imgData.width,
+ height = imgData.height;
+ var i,
+ j,
+ j0,
+ width1 = width + 1;
+ var points = new Uint8Array(width1 * (height + 1));
+ var POINT_TYPES = new Uint8Array([0, 2, 4, 0, 1, 0, 5, 4, 8, 10, 0, 8, 0, 2, 1, 0]);
+ var lineSize = width + 7 & ~7,
+ data0 = imgData.data;
+ var data = new Uint8Array(lineSize * height),
+ pos = 0,
+ ii;
+ for (i = 0, ii = data0.length; i < ii; i++) {
+ var mask = 128,
+ elem = data0[i];
+ while (mask > 0) {
+ data[pos++] = elem & mask ? 0 : 255;
+ mask >>= 1;
+ }
+ }
+ var count = 0;
+ pos = 0;
+ if (data[pos] !== 0) {
+ points[0] = 1;
+ ++count;
+ }
+ for (j = 1; j < width; j++) {
+ if (data[pos] !== data[pos + 1]) {
+ points[j] = data[pos] ? 2 : 1;
+ ++count;
+ }
+ pos++;
+ }
+ if (data[pos] !== 0) {
+ points[j] = 2;
+ ++count;
+ }
+ for (i = 1; i < height; i++) {
+ pos = i * lineSize;
+ j0 = i * width1;
+ if (data[pos - lineSize] !== data[pos]) {
+ points[j0] = data[pos] ? 1 : 8;
+ ++count;
+ }
+ var sum = (data[pos] ? 4 : 0) + (data[pos - lineSize] ? 8 : 0);
+ for (j = 1; j < width; j++) {
+ sum = (sum >> 2) + (data[pos + 1] ? 4 : 0) + (data[pos - lineSize + 1] ? 8 : 0);
+ if (POINT_TYPES[sum]) {
+ points[j0 + j] = POINT_TYPES[sum];
+ ++count;
+ }
+ pos++;
+ }
+ if (data[pos - lineSize] !== data[pos]) {
+ points[j0 + j] = data[pos] ? 2 : 4;
+ ++count;
+ }
+ if (count > POINT_TO_PROCESS_LIMIT) {
+ return null;
+ }
+ }
+ pos = lineSize * (height - 1);
+ j0 = i * width1;
+ if (data[pos] !== 0) {
+ points[j0] = 8;
+ ++count;
+ }
+ for (j = 1; j < width; j++) {
+ if (data[pos] !== data[pos + 1]) {
+ points[j0 + j] = data[pos] ? 4 : 8;
+ ++count;
+ }
+ pos++;
+ }
+ if (data[pos] !== 0) {
+ points[j0 + j] = 4;
+ ++count;
+ }
+ if (count > POINT_TO_PROCESS_LIMIT) {
+ return null;
+ }
+ var steps = new Int32Array([0, width1, -1, 0, -width1, 0, 0, 0, 1]);
+ var outlines = [];
+ for (i = 0; count && i <= height; i++) {
+ var p = i * width1;
+ var end = p + width;
+ while (p < end && !points[p]) {
+ p++;
+ }
+ if (p === end) {
+ continue;
+ }
+ var coords = [p % width1, i];
+ var type = points[p],
+ p0 = p,
+ pp;
+ do {
+ var step = steps[type];
+ do {
+ p += step;
+ } while (!points[p]);
+ pp = points[p];
+ if (pp !== 5 && pp !== 10) {
+ type = pp;
+ points[p] = 0;
+ } else {
+ type = pp & 0x33 * type >> 4;
+ points[p] &= type >> 2 | type << 2;
+ }
+ coords.push(p % width1);
+ coords.push(p / width1 | 0);
+ --count;
+ } while (p0 !== p);
+ outlines.push(coords);
+ --i;
+ }
+ var drawOutline = function (c) {
+ c.save();
+ c.scale(1 / width, -1 / height);
+ c.translate(0, -height);
+ c.beginPath();
+ for (var i = 0, ii = outlines.length; i < ii; i++) {
+ var o = outlines[i];
+ c.moveTo(o[0], o[1]);
+ for (var j = 2, jj = o.length; j < jj; j += 2) {
+ c.lineTo(o[j], o[j + 1]);
+ }
+ }
+ c.fill();
+ c.beginPath();
+ c.restore();
+ };
+ return drawOutline;
+}
+var CanvasExtraState = function CanvasExtraStateClosure() {
+ function CanvasExtraState(old) {
+ this.alphaIsShape = false;
+ this.fontSize = 0;
+ this.fontSizeScale = 1;
+ this.textMatrix = IDENTITY_MATRIX;
+ this.textMatrixScale = 1;
+ this.fontMatrix = FONT_IDENTITY_MATRIX;
+ this.leading = 0;
+ this.x = 0;
+ this.y = 0;
+ this.lineX = 0;
+ this.lineY = 0;
+ this.charSpacing = 0;
+ this.wordSpacing = 0;
+ this.textHScale = 1;
+ this.textRenderingMode = TextRenderingMode.FILL;
+ this.textRise = 0;
+ this.fillColor = '#000000';
+ this.strokeColor = '#000000';
+ this.patternFill = false;
+ this.fillAlpha = 1;
+ this.strokeAlpha = 1;
+ this.lineWidth = 1;
+ this.activeSMask = null;
+ this.resumeSMaskCtx = null;
+ this.old = old;
+ }
+ CanvasExtraState.prototype = {
+ clone: function CanvasExtraState_clone() {
+ return Object.create(this);
+ },
+ setCurrentPoint: function CanvasExtraState_setCurrentPoint(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+ };
+ return CanvasExtraState;
+}();
+var CanvasGraphics = function CanvasGraphicsClosure() {
+ var EXECUTION_TIME = 15;
+ var EXECUTION_STEPS = 10;
+ function CanvasGraphics(canvasCtx, commonObjs, objs, canvasFactory, imageLayer) {
+ this.ctx = canvasCtx;
+ this.current = new CanvasExtraState();
+ this.stateStack = [];
+ this.pendingClip = null;
+ this.pendingEOFill = false;
+ this.res = null;
+ this.xobjs = null;
+ this.commonObjs = commonObjs;
+ this.objs = objs;
+ this.canvasFactory = canvasFactory;
+ this.imageLayer = imageLayer;
+ this.groupStack = [];
+ this.processingType3 = null;
+ this.baseTransform = null;
+ this.baseTransformStack = [];
+ this.groupLevel = 0;
+ this.smaskStack = [];
+ this.smaskCounter = 0;
+ this.tempSMask = null;
+ this.cachedCanvases = new CachedCanvases(this.canvasFactory);
+ if (canvasCtx) {
+ addContextCurrentTransform(canvasCtx);
+ }
+ this.cachedGetSinglePixelWidth = null;
+ }
+ function putBinaryImageData(ctx, imgData) {
+ if (typeof ImageData !== 'undefined' && imgData instanceof ImageData) {
+ ctx.putImageData(imgData, 0, 0);
+ return;
+ }
+ var height = imgData.height,
+ width = imgData.width;
+ var partialChunkHeight = height % FULL_CHUNK_HEIGHT;
+ var fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
+ var totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;
+ var chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
+ var srcPos = 0,
+ destPos;
+ var src = imgData.data;
+ var dest = chunkImgData.data;
+ var i, j, thisChunkHeight, elemsInThisChunk;
+ if (imgData.kind === ImageKind.GRAYSCALE_1BPP) {
+ var srcLength = src.byteLength;
+ var dest32 = HasCanvasTypedArraysCached.value ? new Uint32Array(dest.buffer) : new Uint32ArrayView(dest);
+ var dest32DataLength = dest32.length;
+ var fullSrcDiff = width + 7 >> 3;
+ var white = 0xFFFFFFFF;
+ var black = IsLittleEndianCached.value || !HasCanvasTypedArraysCached.value ? 0xFF000000 : 0x000000FF;
+ for (i = 0; i < totalChunks; i++) {
+ thisChunkHeight = i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;
+ destPos = 0;
+ for (j = 0; j < thisChunkHeight; j++) {
+ var srcDiff = srcLength - srcPos;
+ var k = 0;
+ var kEnd = srcDiff > fullSrcDiff ? width : srcDiff * 8 - 7;
+ var kEndUnrolled = kEnd & ~7;
+ var mask = 0;
+ var srcByte = 0;
+ for (; k < kEndUnrolled; k += 8) {
+ srcByte = src[srcPos++];
+ dest32[destPos++] = srcByte & 128 ? white : black;
+ dest32[destPos++] = srcByte & 64 ? white : black;
+ dest32[destPos++] = srcByte & 32 ? white : black;
+ dest32[destPos++] = srcByte & 16 ? white : black;
+ dest32[destPos++] = srcByte & 8 ? white : black;
+ dest32[destPos++] = srcByte & 4 ? white : black;
+ dest32[destPos++] = srcByte & 2 ? white : black;
+ dest32[destPos++] = srcByte & 1 ? white : black;
+ }
+ for (; k < kEnd; k++) {
+ if (mask === 0) {
+ srcByte = src[srcPos++];
+ mask = 128;
+ }
+ dest32[destPos++] = srcByte & mask ? white : black;
+ mask >>= 1;
+ }
+ }
+ while (destPos < dest32DataLength) {
+ dest32[destPos++] = 0;
+ }
+ ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
+ }
+ } else if (imgData.kind === ImageKind.RGBA_32BPP) {
+ j = 0;
+ elemsInThisChunk = width * FULL_CHUNK_HEIGHT * 4;
+ for (i = 0; i < fullChunks; i++) {
+ dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));
+ srcPos += elemsInThisChunk;
+ ctx.putImageData(chunkImgData, 0, j);
+ j += FULL_CHUNK_HEIGHT;
+ }
+ if (i < totalChunks) {
+ elemsInThisChunk = width * partialChunkHeight * 4;
+ dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));
+ ctx.putImageData(chunkImgData, 0, j);
+ }
+ } else if (imgData.kind === ImageKind.RGB_24BPP) {
+ thisChunkHeight = FULL_CHUNK_HEIGHT;
+ elemsInThisChunk = width * thisChunkHeight;
+ for (i = 0; i < totalChunks; i++) {
+ if (i >= fullChunks) {
+ thisChunkHeight = partialChunkHeight;
+ elemsInThisChunk = width * thisChunkHeight;
+ }
+ destPos = 0;
+ for (j = elemsInThisChunk; j--;) {
+ dest[destPos++] = src[srcPos++];
+ dest[destPos++] = src[srcPos++];
+ dest[destPos++] = src[srcPos++];
+ dest[destPos++] = 255;
+ }
+ ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
+ }
+ } else {
+ error('bad image kind: ' + imgData.kind);
+ }
+ }
+ function putBinaryImageMask(ctx, imgData) {
+ var height = imgData.height,
+ width = imgData.width;
+ var partialChunkHeight = height % FULL_CHUNK_HEIGHT;
+ var fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
+ var totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;
+ var chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
+ var srcPos = 0;
+ var src = imgData.data;
+ var dest = chunkImgData.data;
+ for (var i = 0; i < totalChunks; i++) {
+ var thisChunkHeight = i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;
+ var destPos = 3;
+ for (var j = 0; j < thisChunkHeight; j++) {
+ var mask = 0;
+ for (var k = 0; k < width; k++) {
+ if (!mask) {
+ var elem = src[srcPos++];
+ mask = 128;
+ }
+ dest[destPos] = elem & mask ? 0 : 255;
+ destPos += 4;
+ mask >>= 1;
+ }
+ }
+ ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
+ }
+ }
+ function copyCtxState(sourceCtx, destCtx) {
+ var properties = ['strokeStyle', 'fillStyle', 'fillRule', 'globalAlpha', 'lineWidth', 'lineCap', 'lineJoin', 'miterLimit', 'globalCompositeOperation', 'font'];
+ for (var i = 0, ii = properties.length; i < ii; i++) {
+ var property = properties[i];
+ if (sourceCtx[property] !== undefined) {
+ destCtx[property] = sourceCtx[property];
+ }
+ }
+ if (sourceCtx.setLineDash !== undefined) {
+ destCtx.setLineDash(sourceCtx.getLineDash());
+ destCtx.lineDashOffset = sourceCtx.lineDashOffset;
+ }
+ }
+ function composeSMaskBackdrop(bytes, r0, g0, b0) {
+ var length = bytes.length;
+ for (var i = 3; i < length; i += 4) {
+ var alpha = bytes[i];
+ if (alpha === 0) {
+ bytes[i - 3] = r0;
+ bytes[i - 2] = g0;
+ bytes[i - 1] = b0;
+ } else if (alpha < 255) {
+ var alpha_ = 255 - alpha;
+ bytes[i - 3] = bytes[i - 3] * alpha + r0 * alpha_ >> 8;
+ bytes[i - 2] = bytes[i - 2] * alpha + g0 * alpha_ >> 8;
+ bytes[i - 1] = bytes[i - 1] * alpha + b0 * alpha_ >> 8;
+ }
+ }
+ }
+ function composeSMaskAlpha(maskData, layerData, transferMap) {
+ var length = maskData.length;
+ var scale = 1 / 255;
+ for (var i = 3; i < length; i += 4) {
+ var alpha = transferMap ? transferMap[maskData[i]] : maskData[i];
+ layerData[i] = layerData[i] * alpha * scale | 0;
+ }
+ }
+ function composeSMaskLuminosity(maskData, layerData, transferMap) {
+ var length = maskData.length;
+ for (var i = 3; i < length; i += 4) {
+ var y = maskData[i - 3] * 77 + maskData[i - 2] * 152 + maskData[i - 1] * 28;
+ layerData[i] = transferMap ? layerData[i] * transferMap[y >> 8] >> 8 : layerData[i] * y >> 16;
+ }
+ }
+ function genericComposeSMask(maskCtx, layerCtx, width, height, subtype, backdrop, transferMap) {
+ var hasBackdrop = !!backdrop;
+ var r0 = hasBackdrop ? backdrop[0] : 0;
+ var g0 = hasBackdrop ? backdrop[1] : 0;
+ var b0 = hasBackdrop ? backdrop[2] : 0;
+ var composeFn;
+ if (subtype === 'Luminosity') {
+ composeFn = composeSMaskLuminosity;
+ } else {
+ composeFn = composeSMaskAlpha;
+ }
+ var PIXELS_TO_PROCESS = 1048576;
+ var chunkSize = Math.min(height, Math.ceil(PIXELS_TO_PROCESS / width));
+ for (var row = 0; row < height; row += chunkSize) {
+ var chunkHeight = Math.min(chunkSize, height - row);
+ var maskData = maskCtx.getImageData(0, row, width, chunkHeight);
+ var layerData = layerCtx.getImageData(0, row, width, chunkHeight);
+ if (hasBackdrop) {
+ composeSMaskBackdrop(maskData.data, r0, g0, b0);
+ }
+ composeFn(maskData.data, layerData.data, transferMap);
+ maskCtx.putImageData(layerData, 0, row);
+ }
+ }
+ function composeSMask(ctx, smask, layerCtx) {
+ var mask = smask.canvas;
+ var maskCtx = smask.context;
+ ctx.setTransform(smask.scaleX, 0, 0, smask.scaleY, smask.offsetX, smask.offsetY);
+ var backdrop = smask.backdrop || null;
+ if (!smask.transferMap && WebGLUtils.isEnabled) {
+ var composed = WebGLUtils.composeSMask(layerCtx.canvas, mask, {
+ subtype: smask.subtype,
+ backdrop: backdrop
+ });
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.drawImage(composed, smask.offsetX, smask.offsetY);
+ return;
+ }
+ genericComposeSMask(maskCtx, layerCtx, mask.width, mask.height, smask.subtype, backdrop, smask.transferMap);
+ ctx.drawImage(mask, 0, 0);
+ }
+ var LINE_CAP_STYLES = ['butt', 'round', 'square'];
+ var LINE_JOIN_STYLES = ['miter', 'round', 'bevel'];
+ var NORMAL_CLIP = {};
+ var EO_CLIP = {};
+ CanvasGraphics.prototype = {
+ beginDrawing: function CanvasGraphics_beginDrawing(transform, viewport, transparency) {
+ var width = this.ctx.canvas.width;
+ var height = this.ctx.canvas.height;
+ this.ctx.save();
+ this.ctx.fillStyle = 'rgb(255, 255, 255)';
+ this.ctx.fillRect(0, 0, width, height);
+ this.ctx.restore();
+ if (transparency) {
+ var transparentCanvas = this.cachedCanvases.getCanvas('transparent', width, height, true);
+ this.compositeCtx = this.ctx;
+ this.transparentCanvas = transparentCanvas.canvas;
+ this.ctx = transparentCanvas.context;
+ this.ctx.save();
+ this.ctx.transform.apply(this.ctx, this.compositeCtx.mozCurrentTransform);
+ }
+ this.ctx.save();
+ if (transform) {
+ this.ctx.transform.apply(this.ctx, transform);
+ }
+ this.ctx.transform.apply(this.ctx, viewport.transform);
+ this.baseTransform = this.ctx.mozCurrentTransform.slice();
+ if (this.imageLayer) {
+ this.imageLayer.beginLayout();
+ }
+ },
+ executeOperatorList: function CanvasGraphics_executeOperatorList(operatorList, executionStartIdx, continueCallback, stepper) {
+ var argsArray = operatorList.argsArray;
+ var fnArray = operatorList.fnArray;
+ var i = executionStartIdx || 0;
+ var argsArrayLen = argsArray.length;
+ if (argsArrayLen === i) {
+ return i;
+ }
+ var chunkOperations = argsArrayLen - i > EXECUTION_STEPS && typeof continueCallback === 'function';
+ var endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
+ var steps = 0;
+ var commonObjs = this.commonObjs;
+ var objs = this.objs;
+ var fnId;
+ while (true) {
+ if (stepper !== undefined && i === stepper.nextBreakPoint) {
+ stepper.breakIt(i, continueCallback);
+ return i;
+ }
+ fnId = fnArray[i];
+ if (fnId !== OPS.dependency) {
+ this[fnId].apply(this, argsArray[i]);
+ } else {
+ var deps = argsArray[i];
+ for (var n = 0, nn = deps.length; n < nn; n++) {
+ var depObjId = deps[n];
+ var common = depObjId[0] === 'g' && depObjId[1] === '_';
+ var objsPool = common ? commonObjs : objs;
+ if (!objsPool.isResolved(depObjId)) {
+ objsPool.get(depObjId, continueCallback);
+ return i;
+ }
+ }
+ }
+ i++;
+ if (i === argsArrayLen) {
+ return i;
+ }
+ if (chunkOperations && ++steps > EXECUTION_STEPS) {
+ if (Date.now() > endTime) {
+ continueCallback();
+ return i;
+ }
+ steps = 0;
+ }
+ }
+ },
+ endDrawing: function CanvasGraphics_endDrawing() {
+ if (this.current.activeSMask !== null) {
+ this.endSMaskGroup();
+ }
+ this.ctx.restore();
+ if (this.transparentCanvas) {
+ this.ctx = this.compositeCtx;
+ this.ctx.save();
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.drawImage(this.transparentCanvas, 0, 0);
+ this.ctx.restore();
+ this.transparentCanvas = null;
+ }
+ this.cachedCanvases.clear();
+ WebGLUtils.clear();
+ if (this.imageLayer) {
+ this.imageLayer.endLayout();
+ }
+ },
+ setLineWidth: function CanvasGraphics_setLineWidth(width) {
+ this.current.lineWidth = width;
+ this.ctx.lineWidth = width;
+ },
+ setLineCap: function CanvasGraphics_setLineCap(style) {
+ this.ctx.lineCap = LINE_CAP_STYLES[style];
+ },
+ setLineJoin: function CanvasGraphics_setLineJoin(style) {
+ this.ctx.lineJoin = LINE_JOIN_STYLES[style];
+ },
+ setMiterLimit: function CanvasGraphics_setMiterLimit(limit) {
+ this.ctx.miterLimit = limit;
+ },
+ setDash: function CanvasGraphics_setDash(dashArray, dashPhase) {
+ var ctx = this.ctx;
+ if (ctx.setLineDash !== undefined) {
+ ctx.setLineDash(dashArray);
+ ctx.lineDashOffset = dashPhase;
+ }
+ },
+ setRenderingIntent: function CanvasGraphics_setRenderingIntent(intent) {},
+ setFlatness: function CanvasGraphics_setFlatness(flatness) {},
+ setGState: function CanvasGraphics_setGState(states) {
+ for (var i = 0, ii = states.length; i < ii; i++) {
+ var state = states[i];
+ var key = state[0];
+ var value = state[1];
+ switch (key) {
+ case 'LW':
+ this.setLineWidth(value);
+ break;
+ case 'LC':
+ this.setLineCap(value);
+ break;
+ case 'LJ':
+ this.setLineJoin(value);
+ break;
+ case 'ML':
+ this.setMiterLimit(value);
+ break;
+ case 'D':
+ this.setDash(value[0], value[1]);
+ break;
+ case 'RI':
+ this.setRenderingIntent(value);
+ break;
+ case 'FL':
+ this.setFlatness(value);
+ break;
+ case 'Font':
+ this.setFont(value[0], value[1]);
+ break;
+ case 'CA':
+ this.current.strokeAlpha = state[1];
+ break;
+ case 'ca':
+ this.current.fillAlpha = state[1];
+ this.ctx.globalAlpha = state[1];
+ break;
+ case 'BM':
+ if (value && value.name && value.name !== 'Normal') {
+ var mode = value.name.replace(/([A-Z])/g, function (c) {
+ return '-' + c.toLowerCase();
+ }).substring(1);
+ this.ctx.globalCompositeOperation = mode;
+ if (this.ctx.globalCompositeOperation !== mode) {
+ warn('globalCompositeOperation "' + mode + '" is not supported');
+ }
+ } else {
+ this.ctx.globalCompositeOperation = 'source-over';
+ }
+ break;
+ case 'SMask':
+ if (this.current.activeSMask) {
+ if (this.stateStack.length > 0 && this.stateStack[this.stateStack.length - 1].activeSMask === this.current.activeSMask) {
+ this.suspendSMaskGroup();
+ } else {
+ this.endSMaskGroup();
+ }
+ }
+ this.current.activeSMask = value ? this.tempSMask : null;
+ if (this.current.activeSMask) {
+ this.beginSMaskGroup();
+ }
+ this.tempSMask = null;
+ break;
+ }
+ }
+ },
+ beginSMaskGroup: function CanvasGraphics_beginSMaskGroup() {
+ var activeSMask = this.current.activeSMask;
+ var drawnWidth = activeSMask.canvas.width;
+ var drawnHeight = activeSMask.canvas.height;
+ var cacheId = 'smaskGroupAt' + this.groupLevel;
+ var scratchCanvas = this.cachedCanvases.getCanvas(cacheId, drawnWidth, drawnHeight, true);
+ var currentCtx = this.ctx;
+ var currentTransform = currentCtx.mozCurrentTransform;
+ this.ctx.save();
+ var groupCtx = scratchCanvas.context;
+ groupCtx.scale(1 / activeSMask.scaleX, 1 / activeSMask.scaleY);
+ groupCtx.translate(-activeSMask.offsetX, -activeSMask.offsetY);
+ groupCtx.transform.apply(groupCtx, currentTransform);
+ activeSMask.startTransformInverse = groupCtx.mozCurrentTransformInverse;
+ copyCtxState(currentCtx, groupCtx);
+ this.ctx = groupCtx;
+ this.setGState([['BM', 'Normal'], ['ca', 1], ['CA', 1]]);
+ this.groupStack.push(currentCtx);
+ this.groupLevel++;
+ },
+ suspendSMaskGroup: function CanvasGraphics_endSMaskGroup() {
+ var groupCtx = this.ctx;
+ this.groupLevel--;
+ this.ctx = this.groupStack.pop();
+ composeSMask(this.ctx, this.current.activeSMask, groupCtx);
+ this.ctx.restore();
+ this.ctx.save();
+ copyCtxState(groupCtx, this.ctx);
+ this.current.resumeSMaskCtx = groupCtx;
+ var deltaTransform = Util.transform(this.current.activeSMask.startTransformInverse, groupCtx.mozCurrentTransform);
+ this.ctx.transform.apply(this.ctx, deltaTransform);
+ groupCtx.save();
+ groupCtx.setTransform(1, 0, 0, 1, 0, 0);
+ groupCtx.clearRect(0, 0, groupCtx.canvas.width, groupCtx.canvas.height);
+ groupCtx.restore();
+ },
+ resumeSMaskGroup: function CanvasGraphics_endSMaskGroup() {
+ var groupCtx = this.current.resumeSMaskCtx;
+ var currentCtx = this.ctx;
+ this.ctx = groupCtx;
+ this.groupStack.push(currentCtx);
+ this.groupLevel++;
+ },
+ endSMaskGroup: function CanvasGraphics_endSMaskGroup() {
+ var groupCtx = this.ctx;
+ this.groupLevel--;
+ this.ctx = this.groupStack.pop();
+ composeSMask(this.ctx, this.current.activeSMask, groupCtx);
+ this.ctx.restore();
+ copyCtxState(groupCtx, this.ctx);
+ var deltaTransform = Util.transform(this.current.activeSMask.startTransformInverse, groupCtx.mozCurrentTransform);
+ this.ctx.transform.apply(this.ctx, deltaTransform);
+ },
+ save: function CanvasGraphics_save() {
+ this.ctx.save();
+ var old = this.current;
+ this.stateStack.push(old);
+ this.current = old.clone();
+ this.current.resumeSMaskCtx = null;
+ },
+ restore: function CanvasGraphics_restore() {
+ if (this.current.resumeSMaskCtx) {
+ this.resumeSMaskGroup();
+ }
+ if (this.current.activeSMask !== null && (this.stateStack.length === 0 || this.stateStack[this.stateStack.length - 1].activeSMask !== this.current.activeSMask)) {
+ this.endSMaskGroup();
+ }
+ if (this.stateStack.length !== 0) {
+ this.current = this.stateStack.pop();
+ this.ctx.restore();
+ this.pendingClip = null;
+ this.cachedGetSinglePixelWidth = null;
+ }
+ },
+ transform: function CanvasGraphics_transform(a, b, c, d, e, f) {
+ this.ctx.transform(a, b, c, d, e, f);
+ this.cachedGetSinglePixelWidth = null;
+ },
+ constructPath: function CanvasGraphics_constructPath(ops, args) {
+ var ctx = this.ctx;
+ var current = this.current;
+ var x = current.x,
+ y = current.y;
+ for (var i = 0, j = 0, ii = ops.length; i < ii; i++) {
+ switch (ops[i] | 0) {
+ case OPS.rectangle:
+ x = args[j++];
+ y = args[j++];
+ var width = args[j++];
+ var height = args[j++];
+ if (width === 0) {
+ width = this.getSinglePixelWidth();
+ }
+ if (height === 0) {
+ height = this.getSinglePixelWidth();
+ }
+ var xw = x + width;
+ var yh = y + height;
+ this.ctx.moveTo(x, y);
+ this.ctx.lineTo(xw, y);
+ this.ctx.lineTo(xw, yh);
+ this.ctx.lineTo(x, yh);
+ this.ctx.lineTo(x, y);
+ this.ctx.closePath();
+ break;
+ case OPS.moveTo:
+ x = args[j++];
+ y = args[j++];
+ ctx.moveTo(x, y);
+ break;
+ case OPS.lineTo:
+ x = args[j++];
+ y = args[j++];
+ ctx.lineTo(x, y);
+ break;
+ case OPS.curveTo:
+ x = args[j + 4];
+ y = args[j + 5];
+ ctx.bezierCurveTo(args[j], args[j + 1], args[j + 2], args[j + 3], x, y);
+ j += 6;
+ break;
+ case OPS.curveTo2:
+ ctx.bezierCurveTo(x, y, args[j], args[j + 1], args[j + 2], args[j + 3]);
+ x = args[j + 2];
+ y = args[j + 3];
+ j += 4;
+ break;
+ case OPS.curveTo3:
+ x = args[j + 2];
+ y = args[j + 3];
+ ctx.bezierCurveTo(args[j], args[j + 1], x, y, x, y);
+ j += 4;
+ break;
+ case OPS.closePath:
+ ctx.closePath();
+ break;
+ }
+ }
+ current.setCurrentPoint(x, y);
+ },
+ closePath: function CanvasGraphics_closePath() {
+ this.ctx.closePath();
+ },
+ stroke: function CanvasGraphics_stroke(consumePath) {
+ consumePath = typeof consumePath !== 'undefined' ? consumePath : true;
+ var ctx = this.ctx;
+ var strokeColor = this.current.strokeColor;
+ ctx.lineWidth = Math.max(this.getSinglePixelWidth() * MIN_WIDTH_FACTOR, this.current.lineWidth);
+ ctx.globalAlpha = this.current.strokeAlpha;
+ if (strokeColor && strokeColor.hasOwnProperty('type') && strokeColor.type === 'Pattern') {
+ ctx.save();
+ ctx.strokeStyle = strokeColor.getPattern(ctx, this);
+ ctx.stroke();
+ ctx.restore();
+ } else {
+ ctx.stroke();
+ }
+ if (consumePath) {
+ this.consumePath();
+ }
+ ctx.globalAlpha = this.current.fillAlpha;
+ },
+ closeStroke: function CanvasGraphics_closeStroke() {
+ this.closePath();
+ this.stroke();
+ },
+ fill: function CanvasGraphics_fill(consumePath) {
+ consumePath = typeof consumePath !== 'undefined' ? consumePath : true;
+ var ctx = this.ctx;
+ var fillColor = this.current.fillColor;
+ var isPatternFill = this.current.patternFill;
+ var needRestore = false;
+ if (isPatternFill) {
+ ctx.save();
+ if (this.baseTransform) {
+ ctx.setTransform.apply(ctx, this.baseTransform);
+ }
+ ctx.fillStyle = fillColor.getPattern(ctx, this);
+ needRestore = true;
+ }
+ if (this.pendingEOFill) {
+ ctx.fill('evenodd');
+ this.pendingEOFill = false;
+ } else {
+ ctx.fill();
+ }
+ if (needRestore) {
+ ctx.restore();
+ }
+ if (consumePath) {
+ this.consumePath();
+ }
+ },
+ eoFill: function CanvasGraphics_eoFill() {
+ this.pendingEOFill = true;
+ this.fill();
+ },
+ fillStroke: function CanvasGraphics_fillStroke() {
+ this.fill(false);
+ this.stroke(false);
+ this.consumePath();
+ },
+ eoFillStroke: function CanvasGraphics_eoFillStroke() {
+ this.pendingEOFill = true;
+ this.fillStroke();
+ },
+ closeFillStroke: function CanvasGraphics_closeFillStroke() {
+ this.closePath();
+ this.fillStroke();
+ },
+ closeEOFillStroke: function CanvasGraphics_closeEOFillStroke() {
+ this.pendingEOFill = true;
+ this.closePath();
+ this.fillStroke();
+ },
+ endPath: function CanvasGraphics_endPath() {
+ this.consumePath();
+ },
+ clip: function CanvasGraphics_clip() {
+ this.pendingClip = NORMAL_CLIP;
+ },
+ eoClip: function CanvasGraphics_eoClip() {
+ this.pendingClip = EO_CLIP;
+ },
+ beginText: function CanvasGraphics_beginText() {
+ this.current.textMatrix = IDENTITY_MATRIX;
+ this.current.textMatrixScale = 1;
+ this.current.x = this.current.lineX = 0;
+ this.current.y = this.current.lineY = 0;
+ },
+ endText: function CanvasGraphics_endText() {
+ var paths = this.pendingTextPaths;
+ var ctx = this.ctx;
+ if (paths === undefined) {
+ ctx.beginPath();
+ return;
+ }
+ ctx.save();
+ ctx.beginPath();
+ for (var i = 0; i < paths.length; i++) {
+ var path = paths[i];
+ ctx.setTransform.apply(ctx, path.transform);
+ ctx.translate(path.x, path.y);
+ path.addToPath(ctx, path.fontSize);
+ }
+ ctx.restore();
+ ctx.clip();
+ ctx.beginPath();
+ delete this.pendingTextPaths;
+ },
+ setCharSpacing: function CanvasGraphics_setCharSpacing(spacing) {
+ this.current.charSpacing = spacing;
+ },
+ setWordSpacing: function CanvasGraphics_setWordSpacing(spacing) {
+ this.current.wordSpacing = spacing;
+ },
+ setHScale: function CanvasGraphics_setHScale(scale) {
+ this.current.textHScale = scale / 100;
+ },
+ setLeading: function CanvasGraphics_setLeading(leading) {
+ this.current.leading = -leading;
+ },
+ setFont: function CanvasGraphics_setFont(fontRefName, size) {
+ var fontObj = this.commonObjs.get(fontRefName);
+ var current = this.current;
+ if (!fontObj) {
+ error('Can\'t find font for ' + fontRefName);
+ }
+ current.fontMatrix = fontObj.fontMatrix ? fontObj.fontMatrix : FONT_IDENTITY_MATRIX;
+ if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
+ warn('Invalid font matrix for font ' + fontRefName);
+ }
+ if (size < 0) {
+ size = -size;
+ current.fontDirection = -1;
+ } else {
+ current.fontDirection = 1;
+ }
+ this.current.font = fontObj;
+ this.current.fontSize = size;
+ if (fontObj.isType3Font) {
+ return;
+ }
+ var name = fontObj.loadedName || 'sans-serif';
+ var bold = fontObj.black ? '900' : fontObj.bold ? 'bold' : 'normal';
+ var italic = fontObj.italic ? 'italic' : 'normal';
+ var typeface = '"' + name + '", ' + fontObj.fallbackName;
+ var browserFontSize = size < MIN_FONT_SIZE ? MIN_FONT_SIZE : size > MAX_FONT_SIZE ? MAX_FONT_SIZE : size;
+ this.current.fontSizeScale = size / browserFontSize;
+ var rule = italic + ' ' + bold + ' ' + browserFontSize + 'px ' + typeface;
+ this.ctx.font = rule;
+ },
+ setTextRenderingMode: function CanvasGraphics_setTextRenderingMode(mode) {
+ this.current.textRenderingMode = mode;
+ },
+ setTextRise: function CanvasGraphics_setTextRise(rise) {
+ this.current.textRise = rise;
+ },
+ moveText: function CanvasGraphics_moveText(x, y) {
+ this.current.x = this.current.lineX += x;
+ this.current.y = this.current.lineY += y;
+ },
+ setLeadingMoveText: function CanvasGraphics_setLeadingMoveText(x, y) {
+ this.setLeading(-y);
+ this.moveText(x, y);
+ },
+ setTextMatrix: function CanvasGraphics_setTextMatrix(a, b, c, d, e, f) {
+ this.current.textMatrix = [a, b, c, d, e, f];
+ this.current.textMatrixScale = Math.sqrt(a * a + b * b);
+ this.current.x = this.current.lineX = 0;
+ this.current.y = this.current.lineY = 0;
+ },
+ nextLine: function CanvasGraphics_nextLine() {
+ this.moveText(0, this.current.leading);
+ },
+ paintChar: function CanvasGraphics_paintChar(character, x, y) {
+ var ctx = this.ctx;
+ var current = this.current;
+ var font = current.font;
+ var textRenderingMode = current.textRenderingMode;
+ var fontSize = current.fontSize / current.fontSizeScale;
+ var fillStrokeMode = textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
+ var isAddToPathSet = !!(textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG);
+ var addToPath;
+ if (font.disableFontFace || isAddToPathSet) {
+ addToPath = font.getPathGenerator(this.commonObjs, character);
+ }
+ if (font.disableFontFace) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.beginPath();
+ addToPath(ctx, fontSize);
+ if (fillStrokeMode === TextRenderingMode.FILL || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
+ ctx.fill();
+ }
+ if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
+ ctx.stroke();
+ }
+ ctx.restore();
+ } else {
+ if (fillStrokeMode === TextRenderingMode.FILL || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
+ ctx.fillText(character, x, y);
+ }
+ if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
+ ctx.strokeText(character, x, y);
+ }
+ }
+ if (isAddToPathSet) {
+ var paths = this.pendingTextPaths || (this.pendingTextPaths = []);
+ paths.push({
+ transform: ctx.mozCurrentTransform,
+ x: x,
+ y: y,
+ fontSize: fontSize,
+ addToPath: addToPath
+ });
+ }
+ },
+ get isFontSubpixelAAEnabled() {
+ var ctx = this.canvasFactory.create(10, 10).context;
+ ctx.scale(1.5, 1);
+ ctx.fillText('I', 0, 10);
+ var data = ctx.getImageData(0, 0, 10, 10).data;
+ var enabled = false;
+ for (var i = 3; i < data.length; i += 4) {
+ if (data[i] > 0 && data[i] < 255) {
+ enabled = true;
+ break;
+ }
+ }
+ return shadow(this, 'isFontSubpixelAAEnabled', enabled);
+ },
+ showText: function CanvasGraphics_showText(glyphs) {
+ var current = this.current;
+ var font = current.font;
+ if (font.isType3Font) {
+ return this.showType3Text(glyphs);
+ }
+ var fontSize = current.fontSize;
+ if (fontSize === 0) {
+ return;
+ }
+ var ctx = this.ctx;
+ var fontSizeScale = current.fontSizeScale;
+ var charSpacing = current.charSpacing;
+ var wordSpacing = current.wordSpacing;
+ var fontDirection = current.fontDirection;
+ var textHScale = current.textHScale * fontDirection;
+ var glyphsLength = glyphs.length;
+ var vertical = font.vertical;
+ var spacingDir = vertical ? 1 : -1;
+ var defaultVMetrics = font.defaultVMetrics;
+ var widthAdvanceScale = fontSize * current.fontMatrix[0];
+ var simpleFillText = current.textRenderingMode === TextRenderingMode.FILL && !font.disableFontFace;
+ ctx.save();
+ ctx.transform.apply(ctx, current.textMatrix);
+ ctx.translate(current.x, current.y + current.textRise);
+ if (current.patternFill) {
+ ctx.fillStyle = current.fillColor.getPattern(ctx, this);
+ }
+ if (fontDirection > 0) {
+ ctx.scale(textHScale, -1);
+ } else {
+ ctx.scale(textHScale, 1);
+ }
+ var lineWidth = current.lineWidth;
+ var scale = current.textMatrixScale;
+ if (scale === 0 || lineWidth === 0) {
+ var fillStrokeMode = current.textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
+ if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
+ this.cachedGetSinglePixelWidth = null;
+ lineWidth = this.getSinglePixelWidth() * MIN_WIDTH_FACTOR;
+ }
+ } else {
+ lineWidth /= scale;
+ }
+ if (fontSizeScale !== 1.0) {
+ ctx.scale(fontSizeScale, fontSizeScale);
+ lineWidth /= fontSizeScale;
+ }
+ ctx.lineWidth = lineWidth;
+ var x = 0,
+ i;
+ for (i = 0; i < glyphsLength; ++i) {
+ var glyph = glyphs[i];
+ if (isNum(glyph)) {
+ x += spacingDir * glyph * fontSize / 1000;
+ continue;
+ }
+ var restoreNeeded = false;
+ var spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
+ var character = glyph.fontChar;
+ var accent = glyph.accent;
+ var scaledX, scaledY, scaledAccentX, scaledAccentY;
+ var width = glyph.width;
+ if (vertical) {
+ var vmetric, vx, vy;
+ vmetric = glyph.vmetric || defaultVMetrics;
+ vx = glyph.vmetric ? vmetric[1] : width * 0.5;
+ vx = -vx * widthAdvanceScale;
+ vy = vmetric[2] * widthAdvanceScale;
+ width = vmetric ? -vmetric[0] : width;
+ scaledX = vx / fontSizeScale;
+ scaledY = (x + vy) / fontSizeScale;
+ } else {
+ scaledX = x / fontSizeScale;
+ scaledY = 0;
+ }
+ if (font.remeasure && width > 0) {
+ var measuredWidth = ctx.measureText(character).width * 1000 / fontSize * fontSizeScale;
+ if (width < measuredWidth && this.isFontSubpixelAAEnabled) {
+ var characterScaleX = width / measuredWidth;
+ restoreNeeded = true;
+ ctx.save();
+ ctx.scale(characterScaleX, 1);
+ scaledX /= characterScaleX;
+ } else if (width !== measuredWidth) {
+ scaledX += (width - measuredWidth) / 2000 * fontSize / fontSizeScale;
+ }
+ }
+ if (glyph.isInFont || font.missingFile) {
+ if (simpleFillText && !accent) {
+ ctx.fillText(character, scaledX, scaledY);
+ } else {
+ this.paintChar(character, scaledX, scaledY);
+ if (accent) {
+ scaledAccentX = scaledX + accent.offset.x / fontSizeScale;
+ scaledAccentY = scaledY - accent.offset.y / fontSizeScale;
+ this.paintChar(accent.fontChar, scaledAccentX, scaledAccentY);
+ }
+ }
+ }
+ var charWidth = width * widthAdvanceScale + spacing * fontDirection;
+ x += charWidth;
+ if (restoreNeeded) {
+ ctx.restore();
+ }
+ }
+ if (vertical) {
+ current.y -= x * textHScale;
+ } else {
+ current.x += x * textHScale;
+ }
+ ctx.restore();
+ },
+ showType3Text: function CanvasGraphics_showType3Text(glyphs) {
+ var ctx = this.ctx;
+ var current = this.current;
+ var font = current.font;
+ var fontSize = current.fontSize;
+ var fontDirection = current.fontDirection;
+ var spacingDir = font.vertical ? 1 : -1;
+ var charSpacing = current.charSpacing;
+ var wordSpacing = current.wordSpacing;
+ var textHScale = current.textHScale * fontDirection;
+ var fontMatrix = current.fontMatrix || FONT_IDENTITY_MATRIX;
+ var glyphsLength = glyphs.length;
+ var isTextInvisible = current.textRenderingMode === TextRenderingMode.INVISIBLE;
+ var i, glyph, width, spacingLength;
+ if (isTextInvisible || fontSize === 0) {
+ return;
+ }
+ this.cachedGetSinglePixelWidth = null;
+ ctx.save();
+ ctx.transform.apply(ctx, current.textMatrix);
+ ctx.translate(current.x, current.y);
+ ctx.scale(textHScale, fontDirection);
+ for (i = 0; i < glyphsLength; ++i) {
+ glyph = glyphs[i];
+ if (isNum(glyph)) {
+ spacingLength = spacingDir * glyph * fontSize / 1000;
+ this.ctx.translate(spacingLength, 0);
+ current.x += spacingLength * textHScale;
+ continue;
+ }
+ var spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
+ var operatorList = font.charProcOperatorList[glyph.operatorListId];
+ if (!operatorList) {
+ warn('Type3 character \"' + glyph.operatorListId + '\" is not available');
+ continue;
+ }
+ this.processingType3 = glyph;
+ this.save();
+ ctx.scale(fontSize, fontSize);
+ ctx.transform.apply(ctx, fontMatrix);
+ this.executeOperatorList(operatorList);
+ this.restore();
+ var transformed = Util.applyTransform([glyph.width, 0], fontMatrix);
+ width = transformed[0] * fontSize + spacing;
+ ctx.translate(width, 0);
+ current.x += width * textHScale;
+ }
+ ctx.restore();
+ this.processingType3 = null;
+ },
+ setCharWidth: function CanvasGraphics_setCharWidth(xWidth, yWidth) {},
+ setCharWidthAndBounds: function CanvasGraphics_setCharWidthAndBounds(xWidth, yWidth, llx, lly, urx, ury) {
+ this.ctx.rect(llx, lly, urx - llx, ury - lly);
+ this.clip();
+ this.endPath();
+ },
+ getColorN_Pattern: function CanvasGraphics_getColorN_Pattern(IR) {
+ var pattern;
+ if (IR[0] === 'TilingPattern') {
+ var color = IR[1];
+ var baseTransform = this.baseTransform || this.ctx.mozCurrentTransform.slice();
+ var self = this;
+ var canvasGraphicsFactory = {
+ createCanvasGraphics: function (ctx) {
+ return new CanvasGraphics(ctx, self.commonObjs, self.objs, self.canvasFactory);
+ }
+ };
+ pattern = new TilingPattern(IR, color, this.ctx, canvasGraphicsFactory, baseTransform);
+ } else {
+ pattern = getShadingPatternFromIR(IR);
+ }
+ return pattern;
+ },
+ setStrokeColorN: function CanvasGraphics_setStrokeColorN() {
+ this.current.strokeColor = this.getColorN_Pattern(arguments);
+ },
+ setFillColorN: function CanvasGraphics_setFillColorN() {
+ this.current.fillColor = this.getColorN_Pattern(arguments);
+ this.current.patternFill = true;
+ },
+ setStrokeRGBColor: function CanvasGraphics_setStrokeRGBColor(r, g, b) {
+ var color = Util.makeCssRgb(r, g, b);
+ this.ctx.strokeStyle = color;
+ this.current.strokeColor = color;
+ },
+ setFillRGBColor: function CanvasGraphics_setFillRGBColor(r, g, b) {
+ var color = Util.makeCssRgb(r, g, b);
+ this.ctx.fillStyle = color;
+ this.current.fillColor = color;
+ this.current.patternFill = false;
+ },
+ shadingFill: function CanvasGraphics_shadingFill(patternIR) {
+ var ctx = this.ctx;
+ this.save();
+ var pattern = getShadingPatternFromIR(patternIR);
+ ctx.fillStyle = pattern.getPattern(ctx, this, true);
+ var inv = ctx.mozCurrentTransformInverse;
+ if (inv) {
+ var canvas = ctx.canvas;
+ var width = canvas.width;
+ var height = canvas.height;
+ var bl = Util.applyTransform([0, 0], inv);
+ var br = Util.applyTransform([0, height], inv);
+ var ul = Util.applyTransform([width, 0], inv);
+ var ur = Util.applyTransform([width, height], inv);
+ var x0 = Math.min(bl[0], br[0], ul[0], ur[0]);
+ var y0 = Math.min(bl[1], br[1], ul[1], ur[1]);
+ var x1 = Math.max(bl[0], br[0], ul[0], ur[0]);
+ var y1 = Math.max(bl[1], br[1], ul[1], ur[1]);
+ this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
+ } else {
+ this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10);
+ }
+ this.restore();
+ },
+ beginInlineImage: function CanvasGraphics_beginInlineImage() {
+ error('Should not call beginInlineImage');
+ },
+ beginImageData: function CanvasGraphics_beginImageData() {
+ error('Should not call beginImageData');
+ },
+ paintFormXObjectBegin: function CanvasGraphics_paintFormXObjectBegin(matrix, bbox) {
+ this.save();
+ this.baseTransformStack.push(this.baseTransform);
+ if (isArray(matrix) && matrix.length === 6) {
+ this.transform.apply(this, matrix);
+ }
+ this.baseTransform = this.ctx.mozCurrentTransform;
+ if (isArray(bbox) && bbox.length === 4) {
+ var width = bbox[2] - bbox[0];
+ var height = bbox[3] - bbox[1];
+ this.ctx.rect(bbox[0], bbox[1], width, height);
+ this.clip();
+ this.endPath();
+ }
+ },
+ paintFormXObjectEnd: function CanvasGraphics_paintFormXObjectEnd() {
+ this.restore();
+ this.baseTransform = this.baseTransformStack.pop();
+ },
+ beginGroup: function CanvasGraphics_beginGroup(group) {
+ this.save();
+ var currentCtx = this.ctx;
+ if (!group.isolated) {
+ info('TODO: Support non-isolated groups.');
+ }
+ if (group.knockout) {
+ warn('Knockout groups not supported.');
+ }
+ var currentTransform = currentCtx.mozCurrentTransform;
+ if (group.matrix) {
+ currentCtx.transform.apply(currentCtx, group.matrix);
+ }
+ assert(group.bbox, 'Bounding box is required.');
+ var bounds = Util.getAxialAlignedBoundingBox(group.bbox, currentCtx.mozCurrentTransform);
+ var canvasBounds = [0, 0, currentCtx.canvas.width, currentCtx.canvas.height];
+ bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
+ var offsetX = Math.floor(bounds[0]);
+ var offsetY = Math.floor(bounds[1]);
+ var drawnWidth = Math.max(Math.ceil(bounds[2]) - offsetX, 1);
+ var drawnHeight = Math.max(Math.ceil(bounds[3]) - offsetY, 1);
+ var scaleX = 1,
+ scaleY = 1;
+ if (drawnWidth > MAX_GROUP_SIZE) {
+ scaleX = drawnWidth / MAX_GROUP_SIZE;
+ drawnWidth = MAX_GROUP_SIZE;
+ }
+ if (drawnHeight > MAX_GROUP_SIZE) {
+ scaleY = drawnHeight / MAX_GROUP_SIZE;
+ drawnHeight = MAX_GROUP_SIZE;
+ }
+ var cacheId = 'groupAt' + this.groupLevel;
+ if (group.smask) {
+ cacheId += '_smask_' + this.smaskCounter++ % 2;
+ }
+ var scratchCanvas = this.cachedCanvases.getCanvas(cacheId, drawnWidth, drawnHeight, true);
+ var groupCtx = scratchCanvas.context;
+ groupCtx.scale(1 / scaleX, 1 / scaleY);
+ groupCtx.translate(-offsetX, -offsetY);
+ groupCtx.transform.apply(groupCtx, currentTransform);
+ if (group.smask) {
+ this.smaskStack.push({
+ canvas: scratchCanvas.canvas,
+ context: groupCtx,
+ offsetX: offsetX,
+ offsetY: offsetY,
+ scaleX: scaleX,
+ scaleY: scaleY,
+ subtype: group.smask.subtype,
+ backdrop: group.smask.backdrop,
+ transferMap: group.smask.transferMap || null,
+ startTransformInverse: null
+ });
+ } else {
+ currentCtx.setTransform(1, 0, 0, 1, 0, 0);
+ currentCtx.translate(offsetX, offsetY);
+ currentCtx.scale(scaleX, scaleY);
+ }
+ copyCtxState(currentCtx, groupCtx);
+ this.ctx = groupCtx;
+ this.setGState([['BM', 'Normal'], ['ca', 1], ['CA', 1]]);
+ this.groupStack.push(currentCtx);
+ this.groupLevel++;
+ this.current.activeSMask = null;
+ },
+ endGroup: function CanvasGraphics_endGroup(group) {
+ this.groupLevel--;
+ var groupCtx = this.ctx;
+ this.ctx = this.groupStack.pop();
+ if (this.ctx.imageSmoothingEnabled !== undefined) {
+ this.ctx.imageSmoothingEnabled = false;
+ } else {
+ this.ctx.mozImageSmoothingEnabled = false;
+ }
+ if (group.smask) {
+ this.tempSMask = this.smaskStack.pop();
+ } else {
+ this.ctx.drawImage(groupCtx.canvas, 0, 0);
+ }
+ this.restore();
+ },
+ beginAnnotations: function CanvasGraphics_beginAnnotations() {
+ this.save();
+ this.current = new CanvasExtraState();
+ if (this.baseTransform) {
+ this.ctx.setTransform.apply(this.ctx, this.baseTransform);
+ }
+ },
+ endAnnotations: function CanvasGraphics_endAnnotations() {
+ this.restore();
+ },
+ beginAnnotation: function CanvasGraphics_beginAnnotation(rect, transform, matrix) {
+ this.save();
+ if (isArray(rect) && rect.length === 4) {
+ var width = rect[2] - rect[0];
+ var height = rect[3] - rect[1];
+ this.ctx.rect(rect[0], rect[1], width, height);
+ this.clip();
+ this.endPath();
+ }
+ this.transform.apply(this, transform);
+ this.transform.apply(this, matrix);
+ },
+ endAnnotation: function CanvasGraphics_endAnnotation() {
+ this.restore();
+ },
+ paintJpegXObject: function CanvasGraphics_paintJpegXObject(objId, w, h) {
+ var domImage = this.objs.get(objId);
+ if (!domImage) {
+ warn('Dependent image isn\'t ready yet');
+ return;
+ }
+ this.save();
+ var ctx = this.ctx;
+ ctx.scale(1 / w, -1 / h);
+ ctx.drawImage(domImage, 0, 0, domImage.width, domImage.height, 0, -h, w, h);
+ if (this.imageLayer) {
+ var currentTransform = ctx.mozCurrentTransformInverse;
+ var position = this.getCanvasPosition(0, 0);
+ this.imageLayer.appendImage({
+ objId: objId,
+ left: position[0],
+ top: position[1],
+ width: w / currentTransform[0],
+ height: h / currentTransform[3]
+ });
+ }
+ this.restore();
+ },
+ paintImageMaskXObject: function CanvasGraphics_paintImageMaskXObject(img) {
+ var ctx = this.ctx;
+ var width = img.width,
+ height = img.height;
+ var fillColor = this.current.fillColor;
+ var isPatternFill = this.current.patternFill;
+ var glyph = this.processingType3;
+ if (COMPILE_TYPE3_GLYPHS && glyph && glyph.compiled === undefined) {
+ if (width <= MAX_SIZE_TO_COMPILE && height <= MAX_SIZE_TO_COMPILE) {
+ glyph.compiled = compileType3Glyph({
+ data: img.data,
+ width: width,
+ height: height
+ });
+ } else {
+ glyph.compiled = null;
+ }
+ }
+ if (glyph && glyph.compiled) {
+ glyph.compiled(ctx);
+ return;
+ }
+ var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
+ var maskCtx = maskCanvas.context;
+ maskCtx.save();
+ putBinaryImageMask(maskCtx, img);
+ maskCtx.globalCompositeOperation = 'source-in';
+ maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
+ maskCtx.fillRect(0, 0, width, height);
+ maskCtx.restore();
+ this.paintInlineImageXObject(maskCanvas.canvas);
+ },
+ paintImageMaskXObjectRepeat: function CanvasGraphics_paintImageMaskXObjectRepeat(imgData, scaleX, scaleY, positions) {
+ var width = imgData.width;
+ var height = imgData.height;
+ var fillColor = this.current.fillColor;
+ var isPatternFill = this.current.patternFill;
+ var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
+ var maskCtx = maskCanvas.context;
+ maskCtx.save();
+ putBinaryImageMask(maskCtx, imgData);
+ maskCtx.globalCompositeOperation = 'source-in';
+ maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
+ maskCtx.fillRect(0, 0, width, height);
+ maskCtx.restore();
+ var ctx = this.ctx;
+ for (var i = 0, ii = positions.length; i < ii; i += 2) {
+ ctx.save();
+ ctx.transform(scaleX, 0, 0, scaleY, positions[i], positions[i + 1]);
+ ctx.scale(1, -1);
+ ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1);
+ ctx.restore();
+ }
+ },
+ paintImageMaskXObjectGroup: function CanvasGraphics_paintImageMaskXObjectGroup(images) {
+ var ctx = this.ctx;
+ var fillColor = this.current.fillColor;
+ var isPatternFill = this.current.patternFill;
+ for (var i = 0, ii = images.length; i < ii; i++) {
+ var image = images[i];
+ var width = image.width,
+ height = image.height;
+ var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
+ var maskCtx = maskCanvas.context;
+ maskCtx.save();
+ putBinaryImageMask(maskCtx, image);
+ maskCtx.globalCompositeOperation = 'source-in';
+ maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
+ maskCtx.fillRect(0, 0, width, height);
+ maskCtx.restore();
+ ctx.save();
+ ctx.transform.apply(ctx, image.transform);
+ ctx.scale(1, -1);
+ ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1);
+ ctx.restore();
+ }
+ },
+ paintImageXObject: function CanvasGraphics_paintImageXObject(objId) {
+ var imgData = this.objs.get(objId);
+ if (!imgData) {
+ warn('Dependent image isn\'t ready yet');
+ return;
+ }
+ this.paintInlineImageXObject(imgData);
+ },
+ paintImageXObjectRepeat: function CanvasGraphics_paintImageXObjectRepeat(objId, scaleX, scaleY, positions) {
+ var imgData = this.objs.get(objId);
+ if (!imgData) {
+ warn('Dependent image isn\'t ready yet');
+ return;
+ }
+ var width = imgData.width;
+ var height = imgData.height;
+ var map = [];
+ for (var i = 0, ii = positions.length; i < ii; i += 2) {
+ map.push({
+ transform: [scaleX, 0, 0, scaleY, positions[i], positions[i + 1]],
+ x: 0,
+ y: 0,
+ w: width,
+ h: height
+ });
+ }
+ this.paintInlineImageXObjectGroup(imgData, map);
+ },
+ paintInlineImageXObject: function CanvasGraphics_paintInlineImageXObject(imgData) {
+ var width = imgData.width;
+ var height = imgData.height;
+ var ctx = this.ctx;
+ this.save();
+ ctx.scale(1 / width, -1 / height);
+ var currentTransform = ctx.mozCurrentTransformInverse;
+ var a = currentTransform[0],
+ b = currentTransform[1];
+ var widthScale = Math.max(Math.sqrt(a * a + b * b), 1);
+ var c = currentTransform[2],
+ d = currentTransform[3];
+ var heightScale = Math.max(Math.sqrt(c * c + d * d), 1);
+ var imgToPaint, tmpCanvas;
+ if (imgData instanceof HTMLElement || !imgData.data) {
+ imgToPaint = imgData;
+ } else {
+ tmpCanvas = this.cachedCanvases.getCanvas('inlineImage', width, height);
+ var tmpCtx = tmpCanvas.context;
+ putBinaryImageData(tmpCtx, imgData);
+ imgToPaint = tmpCanvas.canvas;
+ }
+ var paintWidth = width,
+ paintHeight = height;
+ var tmpCanvasId = 'prescale1';
+ while (widthScale > 2 && paintWidth > 1 || heightScale > 2 && paintHeight > 1) {
+ var newWidth = paintWidth,
+ newHeight = paintHeight;
+ if (widthScale > 2 && paintWidth > 1) {
+ newWidth = Math.ceil(paintWidth / 2);
+ widthScale /= paintWidth / newWidth;
+ }
+ if (heightScale > 2 && paintHeight > 1) {
+ newHeight = Math.ceil(paintHeight / 2);
+ heightScale /= paintHeight / newHeight;
+ }
+ tmpCanvas = this.cachedCanvases.getCanvas(tmpCanvasId, newWidth, newHeight);
+ tmpCtx = tmpCanvas.context;
+ tmpCtx.clearRect(0, 0, newWidth, newHeight);
+ tmpCtx.drawImage(imgToPaint, 0, 0, paintWidth, paintHeight, 0, 0, newWidth, newHeight);
+ imgToPaint = tmpCanvas.canvas;
+ paintWidth = newWidth;
+ paintHeight = newHeight;
+ tmpCanvasId = tmpCanvasId === 'prescale1' ? 'prescale2' : 'prescale1';
+ }
+ ctx.drawImage(imgToPaint, 0, 0, paintWidth, paintHeight, 0, -height, width, height);
+ if (this.imageLayer) {
+ var position = this.getCanvasPosition(0, -height);
+ this.imageLayer.appendImage({
+ imgData: imgData,
+ left: position[0],
+ top: position[1],
+ width: width / currentTransform[0],
+ height: height / currentTransform[3]
+ });
+ }
+ this.restore();
+ },
+ paintInlineImageXObjectGroup: function CanvasGraphics_paintInlineImageXObjectGroup(imgData, map) {
+ var ctx = this.ctx;
+ var w = imgData.width;
+ var h = imgData.height;
+ var tmpCanvas = this.cachedCanvases.getCanvas('inlineImage', w, h);
+ var tmpCtx = tmpCanvas.context;
+ putBinaryImageData(tmpCtx, imgData);
+ for (var i = 0, ii = map.length; i < ii; i++) {
+ var entry = map[i];
+ ctx.save();
+ ctx.transform.apply(ctx, entry.transform);
+ ctx.scale(1, -1);
+ ctx.drawImage(tmpCanvas.canvas, entry.x, entry.y, entry.w, entry.h, 0, -1, 1, 1);
+ if (this.imageLayer) {
+ var position = this.getCanvasPosition(entry.x, entry.y);
+ this.imageLayer.appendImage({
+ imgData: imgData,
+ left: position[0],
+ top: position[1],
+ width: w,
+ height: h
+ });
+ }
+ ctx.restore();
+ }
+ },
+ paintSolidColorImageMask: function CanvasGraphics_paintSolidColorImageMask() {
+ this.ctx.fillRect(0, 0, 1, 1);
+ },
+ paintXObject: function CanvasGraphics_paintXObject() {
+ warn('Unsupported \'paintXObject\' command.');
+ },
+ markPoint: function CanvasGraphics_markPoint(tag) {},
+ markPointProps: function CanvasGraphics_markPointProps(tag, properties) {},
+ beginMarkedContent: function CanvasGraphics_beginMarkedContent(tag) {},
+ beginMarkedContentProps: function CanvasGraphics_beginMarkedContentProps(tag, properties) {},
+ endMarkedContent: function CanvasGraphics_endMarkedContent() {},
+ beginCompat: function CanvasGraphics_beginCompat() {},
+ endCompat: function CanvasGraphics_endCompat() {},
+ consumePath: function CanvasGraphics_consumePath() {
+ var ctx = this.ctx;
+ if (this.pendingClip) {
+ if (this.pendingClip === EO_CLIP) {
+ ctx.clip('evenodd');
+ } else {
+ ctx.clip();
+ }
+ this.pendingClip = null;
+ }
+ ctx.beginPath();
+ },
+ getSinglePixelWidth: function CanvasGraphics_getSinglePixelWidth(scale) {
+ if (this.cachedGetSinglePixelWidth === null) {
+ this.ctx.save();
+ var inverse = this.ctx.mozCurrentTransformInverse;
+ this.ctx.restore();
+ this.cachedGetSinglePixelWidth = Math.sqrt(Math.max(inverse[0] * inverse[0] + inverse[1] * inverse[1], inverse[2] * inverse[2] + inverse[3] * inverse[3]));
+ }
+ return this.cachedGetSinglePixelWidth;
+ },
+ getCanvasPosition: function CanvasGraphics_getCanvasPosition(x, y) {
+ var transform = this.ctx.mozCurrentTransform;
+ return [transform[0] * x + transform[2] * y + transform[4], transform[1] * x + transform[3] * y + transform[5]];
+ }
+ };
+ for (var op in OPS) {
+ CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
+ }
+ return CanvasGraphics;
+}();
+exports.CanvasGraphics = CanvasGraphics;
+
+/***/ }),
+/* 11 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var assert = sharedUtil.assert;
+var bytesToString = sharedUtil.bytesToString;
+var string32 = sharedUtil.string32;
+var shadow = sharedUtil.shadow;
+var warn = sharedUtil.warn;
+function FontLoader(docId) {
+ this.docId = docId;
+ this.styleElement = null;
+ this.nativeFontFaces = [];
+ this.loadTestFontId = 0;
+ this.loadingContext = {
+ requests: [],
+ nextRequestId: 0
+ };
+}
+FontLoader.prototype = {
+ insertRule: function fontLoaderInsertRule(rule) {
+ var styleElement = this.styleElement;
+ if (!styleElement) {
+ styleElement = this.styleElement = document.createElement('style');
+ styleElement.id = 'PDFJS_FONT_STYLE_TAG_' + this.docId;
+ document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement);
+ }
+ var styleSheet = styleElement.sheet;
+ styleSheet.insertRule(rule, styleSheet.cssRules.length);
+ },
+ clear: function fontLoaderClear() {
+ if (this.styleElement) {
+ this.styleElement.remove();
+ this.styleElement = null;
+ }
+ this.nativeFontFaces.forEach(function (nativeFontFace) {
+ document.fonts.delete(nativeFontFace);
+ });
+ this.nativeFontFaces.length = 0;
+ }
+};
+var getLoadTestFont = function () {
+ return atob('T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQ' + 'AABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwA' + 'AAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbm' + 'FtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAA' + 'AADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6A' + 'ABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAA' + 'MQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAA' + 'AAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAA' + 'AAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQ' + 'AAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMA' + 'AQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAA' + 'EAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAA' + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAA' + 'AAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgc' + 'A/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWF' + 'hYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQA' + 'AAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAg' + 'ABAAAAAAAAAAAD6AAAAAAAAA==');
+};
+Object.defineProperty(FontLoader.prototype, 'loadTestFont', {
+ get: function () {
+ return shadow(this, 'loadTestFont', getLoadTestFont());
+ },
+ configurable: true
+});
+FontLoader.prototype.addNativeFontFace = function fontLoader_addNativeFontFace(nativeFontFace) {
+ this.nativeFontFaces.push(nativeFontFace);
+ document.fonts.add(nativeFontFace);
+};
+FontLoader.prototype.bind = function fontLoaderBind(fonts, callback) {
+ var rules = [];
+ var fontsToLoad = [];
+ var fontLoadPromises = [];
+ var getNativeFontPromise = function (nativeFontFace) {
+ return nativeFontFace.loaded.catch(function (e) {
+ warn('Failed to load font "' + nativeFontFace.family + '": ' + e);
+ });
+ };
+ var isFontLoadingAPISupported = FontLoader.isFontLoadingAPISupported && !FontLoader.isSyncFontLoadingSupported;
+ for (var i = 0, ii = fonts.length; i < ii; i++) {
+ var font = fonts[i];
+ if (font.attached || font.loading === false) {
+ continue;
+ }
+ font.attached = true;
+ if (isFontLoadingAPISupported) {
+ var nativeFontFace = font.createNativeFontFace();
+ if (nativeFontFace) {
+ this.addNativeFontFace(nativeFontFace);
+ fontLoadPromises.push(getNativeFontPromise(nativeFontFace));
+ }
+ } else {
+ var rule = font.createFontFaceRule();
+ if (rule) {
+ this.insertRule(rule);
+ rules.push(rule);
+ fontsToLoad.push(font);
+ }
+ }
+ }
+ var request = this.queueLoadingCallback(callback);
+ if (isFontLoadingAPISupported) {
+ Promise.all(fontLoadPromises).then(function () {
+ request.complete();
+ });
+ } else if (rules.length > 0 && !FontLoader.isSyncFontLoadingSupported) {
+ this.prepareFontLoadEvent(rules, fontsToLoad, request);
+ } else {
+ request.complete();
+ }
+};
+FontLoader.prototype.queueLoadingCallback = function FontLoader_queueLoadingCallback(callback) {
+ function LoadLoader_completeRequest() {
+ assert(!request.end, 'completeRequest() cannot be called twice');
+ request.end = Date.now();
+ while (context.requests.length > 0 && context.requests[0].end) {
+ var otherRequest = context.requests.shift();
+ setTimeout(otherRequest.callback, 0);
+ }
+ }
+ var context = this.loadingContext;
+ var requestId = 'pdfjs-font-loading-' + context.nextRequestId++;
+ var request = {
+ id: requestId,
+ complete: LoadLoader_completeRequest,
+ callback: callback,
+ started: Date.now()
+ };
+ context.requests.push(request);
+ return request;
+};
+FontLoader.prototype.prepareFontLoadEvent = function fontLoaderPrepareFontLoadEvent(rules, fonts, request) {
+ function int32(data, offset) {
+ return data.charCodeAt(offset) << 24 | data.charCodeAt(offset + 1) << 16 | data.charCodeAt(offset + 2) << 8 | data.charCodeAt(offset + 3) & 0xff;
+ }
+ function spliceString(s, offset, remove, insert) {
+ var chunk1 = s.substr(0, offset);
+ var chunk2 = s.substr(offset + remove);
+ return chunk1 + insert + chunk2;
+ }
+ var i, ii;
+ var canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ var ctx = canvas.getContext('2d');
+ var called = 0;
+ function isFontReady(name, callback) {
+ called++;
+ if (called > 30) {
+ warn('Load test font never loaded.');
+ callback();
+ return;
+ }
+ ctx.font = '30px ' + name;
+ ctx.fillText('.', 0, 20);
+ var imageData = ctx.getImageData(0, 0, 1, 1);
+ if (imageData.data[3] > 0) {
+ callback();
+ return;
+ }
+ setTimeout(isFontReady.bind(null, name, callback));
+ }
+ var loadTestFontId = 'lt' + Date.now() + this.loadTestFontId++;
+ var data = this.loadTestFont;
+ var COMMENT_OFFSET = 976;
+ data = spliceString(data, COMMENT_OFFSET, loadTestFontId.length, loadTestFontId);
+ var CFF_CHECKSUM_OFFSET = 16;
+ var XXXX_VALUE = 0x58585858;
+ var checksum = int32(data, CFF_CHECKSUM_OFFSET);
+ for (i = 0, ii = loadTestFontId.length - 3; i < ii; i += 4) {
+ checksum = checksum - XXXX_VALUE + int32(loadTestFontId, i) | 0;
+ }
+ if (i < loadTestFontId.length) {
+ checksum = checksum - XXXX_VALUE + int32(loadTestFontId + 'XXX', i) | 0;
+ }
+ data = spliceString(data, CFF_CHECKSUM_OFFSET, 4, string32(checksum));
+ var url = 'url(data:font/opentype;base64,' + btoa(data) + ');';
+ var rule = '@font-face { font-family:"' + loadTestFontId + '";src:' + url + '}';
+ this.insertRule(rule);
+ var names = [];
+ for (i = 0, ii = fonts.length; i < ii; i++) {
+ names.push(fonts[i].loadedName);
+ }
+ names.push(loadTestFontId);
+ var div = document.createElement('div');
+ div.setAttribute('style', 'visibility: hidden;' + 'width: 10px; height: 10px;' + 'position: absolute; top: 0px; left: 0px;');
+ for (i = 0, ii = names.length; i < ii; ++i) {
+ var span = document.createElement('span');
+ span.textContent = 'Hi';
+ span.style.fontFamily = names[i];
+ div.appendChild(span);
+ }
+ document.body.appendChild(div);
+ isFontReady(loadTestFontId, function () {
+ document.body.removeChild(div);
+ request.complete();
+ });
+};
+FontLoader.isFontLoadingAPISupported = typeof document !== 'undefined' && !!document.fonts;
+var isSyncFontLoadingSupported = function isSyncFontLoadingSupported() {
+ if (typeof navigator === 'undefined') {
+ return true;
+ }
+ var supported = false;
+ var m = /Mozilla\/5.0.*?rv:(\d+).*? Gecko/.exec(navigator.userAgent);
+ if (m && m[1] >= 14) {
+ supported = true;
+ }
+ return supported;
+};
+Object.defineProperty(FontLoader, 'isSyncFontLoadingSupported', {
+ get: function () {
+ return shadow(FontLoader, 'isSyncFontLoadingSupported', isSyncFontLoadingSupported());
+ },
+ enumerable: true,
+ configurable: true
+});
+var IsEvalSupportedCached = {
+ get value() {
+ return shadow(this, 'value', sharedUtil.isEvalSupported());
+ }
+};
+var FontFaceObject = function FontFaceObjectClosure() {
+ function FontFaceObject(translatedData, options) {
+ this.compiledGlyphs = Object.create(null);
+ for (var i in translatedData) {
+ this[i] = translatedData[i];
+ }
+ this.options = options;
+ }
+ FontFaceObject.prototype = {
+ createNativeFontFace: function FontFaceObject_createNativeFontFace() {
+ if (!this.data) {
+ return null;
+ }
+ if (this.options.disableFontFace) {
+ this.disableFontFace = true;
+ return null;
+ }
+ var nativeFontFace = new FontFace(this.loadedName, this.data, {});
+ if (this.options.fontRegistry) {
+ this.options.fontRegistry.registerFont(this);
+ }
+ return nativeFontFace;
+ },
+ createFontFaceRule: function FontFaceObject_createFontFaceRule() {
+ if (!this.data) {
+ return null;
+ }
+ if (this.options.disableFontFace) {
+ this.disableFontFace = true;
+ return null;
+ }
+ var data = bytesToString(new Uint8Array(this.data));
+ var fontName = this.loadedName;
+ var url = 'url(data:' + this.mimetype + ';base64,' + btoa(data) + ');';
+ var rule = '@font-face { font-family:"' + fontName + '";src:' + url + '}';
+ if (this.options.fontRegistry) {
+ this.options.fontRegistry.registerFont(this, url);
+ }
+ return rule;
+ },
+ getPathGenerator: function FontFaceObject_getPathGenerator(objs, character) {
+ if (!(character in this.compiledGlyphs)) {
+ var cmds = objs.get(this.loadedName + '_path_' + character);
+ var current, i, len;
+ if (this.options.isEvalSupported && IsEvalSupportedCached.value) {
+ var args,
+ js = '';
+ for (i = 0, len = cmds.length; i < len; i++) {
+ current = cmds[i];
+ if (current.args !== undefined) {
+ args = current.args.join(',');
+ } else {
+ args = '';
+ }
+ js += 'c.' + current.cmd + '(' + args + ');\n';
+ }
+ this.compiledGlyphs[character] = new Function('c', 'size', js);
+ } else {
+ this.compiledGlyphs[character] = function (c, size) {
+ for (i = 0, len = cmds.length; i < len; i++) {
+ current = cmds[i];
+ if (current.cmd === 'scale') {
+ current.args = [size, -size];
+ }
+ c[current.cmd].apply(c, current.args);
+ }
+ };
+ }
+ }
+ return this.compiledGlyphs[character];
+ }
+ };
+ return FontFaceObject;
+}();
+exports.FontFaceObject = FontFaceObject;
+exports.FontLoader = FontLoader;
+
+/***/ }),
+/* 12 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var sharedUtil = __w_pdfjs_require__(0);
+var displayWebGL = __w_pdfjs_require__(8);
+var Util = sharedUtil.Util;
+var info = sharedUtil.info;
+var isArray = sharedUtil.isArray;
+var error = sharedUtil.error;
+var WebGLUtils = displayWebGL.WebGLUtils;
+var ShadingIRs = {};
+ShadingIRs.RadialAxial = {
+ fromIR: function RadialAxial_fromIR(raw) {
+ var type = raw[1];
+ var colorStops = raw[2];
+ var p0 = raw[3];
+ var p1 = raw[4];
+ var r0 = raw[5];
+ var r1 = raw[6];
+ return {
+ type: 'Pattern',
+ getPattern: function RadialAxial_getPattern(ctx) {
+ var grad;
+ if (type === 'axial') {
+ grad = ctx.createLinearGradient(p0[0], p0[1], p1[0], p1[1]);
+ } else if (type === 'radial') {
+ grad = ctx.createRadialGradient(p0[0], p0[1], r0, p1[0], p1[1], r1);
+ }
+ for (var i = 0, ii = colorStops.length; i < ii; ++i) {
+ var c = colorStops[i];
+ grad.addColorStop(c[0], c[1]);
+ }
+ return grad;
+ }
+ };
+ }
+};
+var createMeshCanvas = function createMeshCanvasClosure() {
+ function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) {
+ var coords = context.coords,
+ colors = context.colors;
+ var bytes = data.data,
+ rowSize = data.width * 4;
+ var tmp;
+ if (coords[p1 + 1] > coords[p2 + 1]) {
+ tmp = p1;
+ p1 = p2;
+ p2 = tmp;
+ tmp = c1;
+ c1 = c2;
+ c2 = tmp;
+ }
+ if (coords[p2 + 1] > coords[p3 + 1]) {
+ tmp = p2;
+ p2 = p3;
+ p3 = tmp;
+ tmp = c2;
+ c2 = c3;
+ c3 = tmp;
+ }
+ if (coords[p1 + 1] > coords[p2 + 1]) {
+ tmp = p1;
+ p1 = p2;
+ p2 = tmp;
+ tmp = c1;
+ c1 = c2;
+ c2 = tmp;
+ }
+ var x1 = (coords[p1] + context.offsetX) * context.scaleX;
+ var y1 = (coords[p1 + 1] + context.offsetY) * context.scaleY;
+ var x2 = (coords[p2] + context.offsetX) * context.scaleX;
+ var y2 = (coords[p2 + 1] + context.offsetY) * context.scaleY;
+ var x3 = (coords[p3] + context.offsetX) * context.scaleX;
+ var y3 = (coords[p3 + 1] + context.offsetY) * context.scaleY;
+ if (y1 >= y3) {
+ return;
+ }
+ var c1r = colors[c1],
+ c1g = colors[c1 + 1],
+ c1b = colors[c1 + 2];
+ var c2r = colors[c2],
+ c2g = colors[c2 + 1],
+ c2b = colors[c2 + 2];
+ var c3r = colors[c3],
+ c3g = colors[c3 + 1],
+ c3b = colors[c3 + 2];
+ var minY = Math.round(y1),
+ maxY = Math.round(y3);
+ var xa, car, cag, cab;
+ var xb, cbr, cbg, cbb;
+ var k;
+ for (var y = minY; y <= maxY; y++) {
+ if (y < y2) {
+ k = y < y1 ? 0 : y1 === y2 ? 1 : (y1 - y) / (y1 - y2);
+ xa = x1 - (x1 - x2) * k;
+ car = c1r - (c1r - c2r) * k;
+ cag = c1g - (c1g - c2g) * k;
+ cab = c1b - (c1b - c2b) * k;
+ } else {
+ k = y > y3 ? 1 : y2 === y3 ? 0 : (y2 - y) / (y2 - y3);
+ xa = x2 - (x2 - x3) * k;
+ car = c2r - (c2r - c3r) * k;
+ cag = c2g - (c2g - c3g) * k;
+ cab = c2b - (c2b - c3b) * k;
+ }
+ k = y < y1 ? 0 : y > y3 ? 1 : (y1 - y) / (y1 - y3);
+ xb = x1 - (x1 - x3) * k;
+ cbr = c1r - (c1r - c3r) * k;
+ cbg = c1g - (c1g - c3g) * k;
+ cbb = c1b - (c1b - c3b) * k;
+ var x1_ = Math.round(Math.min(xa, xb));
+ var x2_ = Math.round(Math.max(xa, xb));
+ var j = rowSize * y + x1_ * 4;
+ for (var x = x1_; x <= x2_; x++) {
+ k = (xa - x) / (xa - xb);
+ k = k < 0 ? 0 : k > 1 ? 1 : k;
+ bytes[j++] = car - (car - cbr) * k | 0;
+ bytes[j++] = cag - (cag - cbg) * k | 0;
+ bytes[j++] = cab - (cab - cbb) * k | 0;
+ bytes[j++] = 255;
+ }
+ }
+ }
+ function drawFigure(data, figure, context) {
+ var ps = figure.coords;
+ var cs = figure.colors;
+ var i, ii;
+ switch (figure.type) {
+ case 'lattice':
+ var verticesPerRow = figure.verticesPerRow;
+ var rows = Math.floor(ps.length / verticesPerRow) - 1;
+ var cols = verticesPerRow - 1;
+ for (i = 0; i < rows; i++) {
+ var q = i * verticesPerRow;
+ for (var j = 0; j < cols; j++, q++) {
+ drawTriangle(data, context, ps[q], ps[q + 1], ps[q + verticesPerRow], cs[q], cs[q + 1], cs[q + verticesPerRow]);
+ drawTriangle(data, context, ps[q + verticesPerRow + 1], ps[q + 1], ps[q + verticesPerRow], cs[q + verticesPerRow + 1], cs[q + 1], cs[q + verticesPerRow]);
+ }
+ }
+ break;
+ case 'triangles':
+ for (i = 0, ii = ps.length; i < ii; i += 3) {
+ drawTriangle(data, context, ps[i], ps[i + 1], ps[i + 2], cs[i], cs[i + 1], cs[i + 2]);
+ }
+ break;
+ default:
+ error('illigal figure');
+ break;
+ }
+ }
+ function createMeshCanvas(bounds, combinesScale, coords, colors, figures, backgroundColor, cachedCanvases) {
+ var EXPECTED_SCALE = 1.1;
+ var MAX_PATTERN_SIZE = 3000;
+ var BORDER_SIZE = 2;
+ var offsetX = Math.floor(bounds[0]);
+ var offsetY = Math.floor(bounds[1]);
+ var boundsWidth = Math.ceil(bounds[2]) - offsetX;
+ var boundsHeight = Math.ceil(bounds[3]) - offsetY;
+ var width = Math.min(Math.ceil(Math.abs(boundsWidth * combinesScale[0] * EXPECTED_SCALE)), MAX_PATTERN_SIZE);
+ var height = Math.min(Math.ceil(Math.abs(boundsHeight * combinesScale[1] * EXPECTED_SCALE)), MAX_PATTERN_SIZE);
+ var scaleX = boundsWidth / width;
+ var scaleY = boundsHeight / height;
+ var context = {
+ coords: coords,
+ colors: colors,
+ offsetX: -offsetX,
+ offsetY: -offsetY,
+ scaleX: 1 / scaleX,
+ scaleY: 1 / scaleY
+ };
+ var paddedWidth = width + BORDER_SIZE * 2;
+ var paddedHeight = height + BORDER_SIZE * 2;
+ var canvas, tmpCanvas, i, ii;
+ if (WebGLUtils.isEnabled) {
+ canvas = WebGLUtils.drawFigures(width, height, backgroundColor, figures, context);
+ tmpCanvas = cachedCanvases.getCanvas('mesh', paddedWidth, paddedHeight, false);
+ tmpCanvas.context.drawImage(canvas, BORDER_SIZE, BORDER_SIZE);
+ canvas = tmpCanvas.canvas;
+ } else {
+ tmpCanvas = cachedCanvases.getCanvas('mesh', paddedWidth, paddedHeight, false);
+ var tmpCtx = tmpCanvas.context;
+ var data = tmpCtx.createImageData(width, height);
+ if (backgroundColor) {
+ var bytes = data.data;
+ for (i = 0, ii = bytes.length; i < ii; i += 4) {
+ bytes[i] = backgroundColor[0];
+ bytes[i + 1] = backgroundColor[1];
+ bytes[i + 2] = backgroundColor[2];
+ bytes[i + 3] = 255;
+ }
+ }
+ for (i = 0; i < figures.length; i++) {
+ drawFigure(data, figures[i], context);
+ }
+ tmpCtx.putImageData(data, BORDER_SIZE, BORDER_SIZE);
+ canvas = tmpCanvas.canvas;
+ }
+ return {
+ canvas: canvas,
+ offsetX: offsetX - BORDER_SIZE * scaleX,
+ offsetY: offsetY - BORDER_SIZE * scaleY,
+ scaleX: scaleX,
+ scaleY: scaleY
+ };
+ }
+ return createMeshCanvas;
+}();
+ShadingIRs.Mesh = {
+ fromIR: function Mesh_fromIR(raw) {
+ var coords = raw[2];
+ var colors = raw[3];
+ var figures = raw[4];
+ var bounds = raw[5];
+ var matrix = raw[6];
+ var background = raw[8];
+ return {
+ type: 'Pattern',
+ getPattern: function Mesh_getPattern(ctx, owner, shadingFill) {
+ var scale;
+ if (shadingFill) {
+ scale = Util.singularValueDecompose2dScale(ctx.mozCurrentTransform);
+ } else {
+ scale = Util.singularValueDecompose2dScale(owner.baseTransform);
+ if (matrix) {
+ var matrixScale = Util.singularValueDecompose2dScale(matrix);
+ scale = [scale[0] * matrixScale[0], scale[1] * matrixScale[1]];
+ }
+ }
+ var temporaryPatternCanvas = createMeshCanvas(bounds, scale, coords, colors, figures, shadingFill ? null : background, owner.cachedCanvases);
+ if (!shadingFill) {
+ ctx.setTransform.apply(ctx, owner.baseTransform);
+ if (matrix) {
+ ctx.transform.apply(ctx, matrix);
+ }
+ }
+ ctx.translate(temporaryPatternCanvas.offsetX, temporaryPatternCanvas.offsetY);
+ ctx.scale(temporaryPatternCanvas.scaleX, temporaryPatternCanvas.scaleY);
+ return ctx.createPattern(temporaryPatternCanvas.canvas, 'no-repeat');
+ }
+ };
+ }
+};
+ShadingIRs.Dummy = {
+ fromIR: function Dummy_fromIR() {
+ return {
+ type: 'Pattern',
+ getPattern: function Dummy_fromIR_getPattern() {
+ return 'hotpink';
+ }
+ };
+ }
+};
+function getShadingPatternFromIR(raw) {
+ var shadingIR = ShadingIRs[raw[0]];
+ if (!shadingIR) {
+ error('Unknown IR type: ' + raw[0]);
+ }
+ return shadingIR.fromIR(raw);
+}
+var TilingPattern = function TilingPatternClosure() {
+ var PaintType = {
+ COLORED: 1,
+ UNCOLORED: 2
+ };
+ var MAX_PATTERN_SIZE = 3000;
+ function TilingPattern(IR, color, ctx, canvasGraphicsFactory, baseTransform) {
+ this.operatorList = IR[2];
+ this.matrix = IR[3] || [1, 0, 0, 1, 0, 0];
+ this.bbox = Util.normalizeRect(IR[4]);
+ this.xstep = IR[5];
+ this.ystep = IR[6];
+ this.paintType = IR[7];
+ this.tilingType = IR[8];
+ this.color = color;
+ this.canvasGraphicsFactory = canvasGraphicsFactory;
+ this.baseTransform = baseTransform;
+ this.type = 'Pattern';
+ this.ctx = ctx;
+ }
+ TilingPattern.prototype = {
+ createPatternCanvas: function TilinPattern_createPatternCanvas(owner) {
+ var operatorList = this.operatorList;
+ var bbox = this.bbox;
+ var xstep = this.xstep;
+ var ystep = this.ystep;
+ var paintType = this.paintType;
+ var tilingType = this.tilingType;
+ var color = this.color;
+ var canvasGraphicsFactory = this.canvasGraphicsFactory;
+ info('TilingType: ' + tilingType);
+ var x0 = bbox[0],
+ y0 = bbox[1],
+ x1 = bbox[2],
+ y1 = bbox[3];
+ var topLeft = [x0, y0];
+ var botRight = [x0 + xstep, y0 + ystep];
+ var width = botRight[0] - topLeft[0];
+ var height = botRight[1] - topLeft[1];
+ var matrixScale = Util.singularValueDecompose2dScale(this.matrix);
+ var curMatrixScale = Util.singularValueDecompose2dScale(this.baseTransform);
+ var combinedScale = [matrixScale[0] * curMatrixScale[0], matrixScale[1] * curMatrixScale[1]];
+ width = Math.min(Math.ceil(Math.abs(width * combinedScale[0])), MAX_PATTERN_SIZE);
+ height = Math.min(Math.ceil(Math.abs(height * combinedScale[1])), MAX_PATTERN_SIZE);
+ var tmpCanvas = owner.cachedCanvases.getCanvas('pattern', width, height, true);
+ var tmpCtx = tmpCanvas.context;
+ var graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx);
+ graphics.groupLevel = owner.groupLevel;
+ this.setFillAndStrokeStyleToContext(tmpCtx, paintType, color);
+ this.setScale(width, height, xstep, ystep);
+ this.transformToScale(graphics);
+ var tmpTranslate = [1, 0, 0, 1, -topLeft[0], -topLeft[1]];
+ graphics.transform.apply(graphics, tmpTranslate);
+ this.clipBbox(graphics, bbox, x0, y0, x1, y1);
+ graphics.executeOperatorList(operatorList);
+ return tmpCanvas.canvas;
+ },
+ setScale: function TilingPattern_setScale(width, height, xstep, ystep) {
+ this.scale = [width / xstep, height / ystep];
+ },
+ transformToScale: function TilingPattern_transformToScale(graphics) {
+ var scale = this.scale;
+ var tmpScale = [scale[0], 0, 0, scale[1], 0, 0];
+ graphics.transform.apply(graphics, tmpScale);
+ },
+ scaleToContext: function TilingPattern_scaleToContext() {
+ var scale = this.scale;
+ this.ctx.scale(1 / scale[0], 1 / scale[1]);
+ },
+ clipBbox: function clipBbox(graphics, bbox, x0, y0, x1, y1) {
+ if (isArray(bbox) && bbox.length === 4) {
+ var bboxWidth = x1 - x0;
+ var bboxHeight = y1 - y0;
+ graphics.ctx.rect(x0, y0, bboxWidth, bboxHeight);
+ graphics.clip();
+ graphics.endPath();
+ }
+ },
+ setFillAndStrokeStyleToContext: function setFillAndStrokeStyleToContext(context, paintType, color) {
+ switch (paintType) {
+ case PaintType.COLORED:
+ var ctx = this.ctx;
+ context.fillStyle = ctx.fillStyle;
+ context.strokeStyle = ctx.strokeStyle;
+ break;
+ case PaintType.UNCOLORED:
+ var cssColor = Util.makeCssRgb(color[0], color[1], color[2]);
+ context.fillStyle = cssColor;
+ context.strokeStyle = cssColor;
+ break;
+ default:
+ error('Unsupported paint type: ' + paintType);
+ }
+ },
+ getPattern: function TilingPattern_getPattern(ctx, owner) {
+ var temporaryPatternCanvas = this.createPatternCanvas(owner);
+ ctx = this.ctx;
+ ctx.setTransform.apply(ctx, this.baseTransform);
+ ctx.transform.apply(ctx, this.matrix);
+ this.scaleToContext();
+ return ctx.createPattern(temporaryPatternCanvas, 'repeat');
+ }
+ };
+ return TilingPattern;
+}();
+exports.getShadingPatternFromIR = getShadingPatternFromIR;
+exports.TilingPattern = TilingPattern;
+
+/***/ }),
+/* 13 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+
+
+var pdfjsVersion = '1.8.172';
+var pdfjsBuild = '8ff1fbe7';
+var pdfjsSharedUtil = __w_pdfjs_require__(0);
+var pdfjsDisplayGlobal = __w_pdfjs_require__(9);
+var pdfjsDisplayAPI = __w_pdfjs_require__(3);
+var pdfjsDisplayTextLayer = __w_pdfjs_require__(5);
+var pdfjsDisplayAnnotationLayer = __w_pdfjs_require__(2);
+var pdfjsDisplayDOMUtils = __w_pdfjs_require__(1);
+var pdfjsDisplaySVG = __w_pdfjs_require__(4);
+exports.PDFJS = pdfjsDisplayGlobal.PDFJS;
+exports.build = pdfjsDisplayAPI.build;
+exports.version = pdfjsDisplayAPI.version;
+exports.getDocument = pdfjsDisplayAPI.getDocument;
+exports.PDFDataRangeTransport = pdfjsDisplayAPI.PDFDataRangeTransport;
+exports.PDFWorker = pdfjsDisplayAPI.PDFWorker;
+exports.renderTextLayer = pdfjsDisplayTextLayer.renderTextLayer;
+exports.AnnotationLayer = pdfjsDisplayAnnotationLayer.AnnotationLayer;
+exports.CustomStyle = pdfjsDisplayDOMUtils.CustomStyle;
+exports.createPromiseCapability = pdfjsSharedUtil.createPromiseCapability;
+exports.PasswordResponses = pdfjsSharedUtil.PasswordResponses;
+exports.InvalidPDFException = pdfjsSharedUtil.InvalidPDFException;
+exports.MissingPDFException = pdfjsSharedUtil.MissingPDFException;
+exports.SVGGraphics = pdfjsDisplaySVG.SVGGraphics;
+exports.UnexpectedResponseException = pdfjsSharedUtil.UnexpectedResponseException;
+exports.OPS = pdfjsSharedUtil.OPS;
+exports.UNSUPPORTED_FEATURES = pdfjsSharedUtil.UNSUPPORTED_FEATURES;
+exports.isValidUrl = pdfjsDisplayDOMUtils.isValidUrl;
+exports.createValidAbsoluteUrl = pdfjsSharedUtil.createValidAbsoluteUrl;
+exports.createObjectURL = pdfjsSharedUtil.createObjectURL;
+exports.removeNullCharacters = pdfjsSharedUtil.removeNullCharacters;
+exports.shadow = pdfjsSharedUtil.shadow;
+exports.createBlob = pdfjsSharedUtil.createBlob;
+exports.RenderingCancelledException = pdfjsDisplayDOMUtils.RenderingCancelledException;
+exports.getFilenameFromUrl = pdfjsDisplayDOMUtils.getFilenameFromUrl;
+exports.addLinkAttributes = pdfjsDisplayDOMUtils.addLinkAttributes;
+
+/***/ }),
+/* 14 */
+/***/ (function(module, exports, __w_pdfjs_require__) {
+
+"use strict";
+/* WEBPACK VAR INJECTION */(function(global) {
+
+if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) {
+ var globalScope = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : undefined;
+ var userAgent = typeof navigator !== 'undefined' && navigator.userAgent || '';
+ var isAndroid = /Android/.test(userAgent);
+ var isAndroidPre3 = /Android\s[0-2][^\d]/.test(userAgent);
+ var isAndroidPre5 = /Android\s[0-4][^\d]/.test(userAgent);
+ var isChrome = userAgent.indexOf('Chrom') >= 0;
+ var isChromeWithRangeBug = /Chrome\/(39|40)\./.test(userAgent);
+ var isIOSChrome = userAgent.indexOf('CriOS') >= 0;
+ var isIE = userAgent.indexOf('Trident') >= 0;
+ var isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
+ var isOpera = userAgent.indexOf('Opera') >= 0;
+ var isSafari = /Safari\//.test(userAgent) && !/(Chrome\/|Android\s)/.test(userAgent);
+ var hasDOM = typeof window === 'object' && typeof document === 'object';
+ if (typeof PDFJS === 'undefined') {
+ globalScope.PDFJS = {};
+ }
+ PDFJS.compatibilityChecked = true;
+ (function checkTypedArrayCompatibility() {
+ if (typeof Uint8Array !== 'undefined') {
+ if (typeof Uint8Array.prototype.subarray === 'undefined') {
+ Uint8Array.prototype.subarray = function subarray(start, end) {
+ return new Uint8Array(this.slice(start, end));
+ };
+ Float32Array.prototype.subarray = function subarray(start, end) {
+ return new Float32Array(this.slice(start, end));
+ };
+ }
+ if (typeof Float64Array === 'undefined') {
+ globalScope.Float64Array = Float32Array;
+ }
+ return;
+ }
+ function subarray(start, end) {
+ return new TypedArray(this.slice(start, end));
+ }
+ function setArrayOffset(array, offset) {
+ if (arguments.length < 2) {
+ offset = 0;
+ }
+ for (var i = 0, n = array.length; i < n; ++i, ++offset) {
+ this[offset] = array[i] & 0xFF;
+ }
+ }
+ function TypedArray(arg1) {
+ var result, i, n;
+ if (typeof arg1 === 'number') {
+ result = [];
+ for (i = 0; i < arg1; ++i) {
+ result[i] = 0;
+ }
+ } else if ('slice' in arg1) {
+ result = arg1.slice(0);
+ } else {
+ result = [];
+ for (i = 0, n = arg1.length; i < n; ++i) {
+ result[i] = arg1[i];
+ }
+ }
+ result.subarray = subarray;
+ result.buffer = result;
+ result.byteLength = result.length;
+ result.set = setArrayOffset;
+ if (typeof arg1 === 'object' && arg1.buffer) {
+ result.buffer = arg1.buffer;
+ }
+ return result;
+ }
+ globalScope.Uint8Array = TypedArray;
+ globalScope.Int8Array = TypedArray;
+ globalScope.Uint32Array = TypedArray;
+ globalScope.Int32Array = TypedArray;
+ globalScope.Uint16Array = TypedArray;
+ globalScope.Float32Array = TypedArray;
+ globalScope.Float64Array = TypedArray;
+ })();
+ (function normalizeURLObject() {
+ if (!globalScope.URL) {
+ globalScope.URL = globalScope.webkitURL;
+ }
+ })();
+ (function checkObjectDefinePropertyCompatibility() {
+ if (typeof Object.defineProperty !== 'undefined') {
+ var definePropertyPossible = true;
+ try {
+ if (hasDOM) {
+ Object.defineProperty(new Image(), 'id', { value: 'test' });
+ }
+ var Test = function Test() {};
+ Test.prototype = {
+ get id() {}
+ };
+ Object.defineProperty(new Test(), 'id', {
+ value: '',
+ configurable: true,
+ enumerable: true,
+ writable: false
+ });
+ } catch (e) {
+ definePropertyPossible = false;
+ }
+ if (definePropertyPossible) {
+ return;
+ }
+ }
+ Object.defineProperty = function objectDefineProperty(obj, name, def) {
+ delete obj[name];
+ if ('get' in def) {
+ obj.__defineGetter__(name, def['get']);
+ }
+ if ('set' in def) {
+ obj.__defineSetter__(name, def['set']);
+ }
+ if ('value' in def) {
+ obj.__defineSetter__(name, function objectDefinePropertySetter(value) {
+ this.__defineGetter__(name, function objectDefinePropertyGetter() {
+ return value;
+ });
+ return value;
+ });
+ obj[name] = def.value;
+ }
+ };
+ })();
+ (function checkXMLHttpRequestResponseCompatibility() {
+ if (typeof XMLHttpRequest === 'undefined') {
+ return;
+ }
+ var xhrPrototype = XMLHttpRequest.prototype;
+ var xhr = new XMLHttpRequest();
+ if (!('overrideMimeType' in xhr)) {
+ Object.defineProperty(xhrPrototype, 'overrideMimeType', {
+ value: function xmlHttpRequestOverrideMimeType(mimeType) {}
+ });
+ }
+ if ('responseType' in xhr) {
+ return;
+ }
+ Object.defineProperty(xhrPrototype, 'responseType', {
+ get: function xmlHttpRequestGetResponseType() {
+ return this._responseType || 'text';
+ },
+ set: function xmlHttpRequestSetResponseType(value) {
+ if (value === 'text' || value === 'arraybuffer') {
+ this._responseType = value;
+ if (value === 'arraybuffer' && typeof this.overrideMimeType === 'function') {
+ this.overrideMimeType('text/plain; charset=x-user-defined');
+ }
+ }
+ }
+ });
+ if (typeof VBArray !== 'undefined') {
+ Object.defineProperty(xhrPrototype, 'response', {
+ get: function xmlHttpRequestResponseGet() {
+ if (this.responseType === 'arraybuffer') {
+ return new Uint8Array(new VBArray(this.responseBody).toArray());
+ }
+ return this.responseText;
+ }
+ });
+ return;
+ }
+ Object.defineProperty(xhrPrototype, 'response', {
+ get: function xmlHttpRequestResponseGet() {
+ if (this.responseType !== 'arraybuffer') {
+ return this.responseText;
+ }
+ var text = this.responseText;
+ var i,
+ n = text.length;
+ var result = new Uint8Array(n);
+ for (i = 0; i < n; ++i) {
+ result[i] = text.charCodeAt(i) & 0xFF;
+ }
+ return result.buffer;
+ }
+ });
+ })();
+ (function checkWindowBtoaCompatibility() {
+ if ('btoa' in globalScope) {
+ return;
+ }
+ var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
+ globalScope.btoa = function (chars) {
+ var buffer = '';
+ var i, n;
+ for (i = 0, n = chars.length; i < n; i += 3) {
+ var b1 = chars.charCodeAt(i) & 0xFF;
+ var b2 = chars.charCodeAt(i + 1) & 0xFF;
+ var b3 = chars.charCodeAt(i + 2) & 0xFF;
+ var d1 = b1 >> 2,
+ d2 = (b1 & 3) << 4 | b2 >> 4;
+ var d3 = i + 1 < n ? (b2 & 0xF) << 2 | b3 >> 6 : 64;
+ var d4 = i + 2 < n ? b3 & 0x3F : 64;
+ buffer += digits.charAt(d1) + digits.charAt(d2) + digits.charAt(d3) + digits.charAt(d4);
+ }
+ return buffer;
+ };
+ })();
+ (function checkWindowAtobCompatibility() {
+ if ('atob' in globalScope) {
+ return;
+ }
+ var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
+ globalScope.atob = function (input) {
+ input = input.replace(/=+$/, '');
+ if (input.length % 4 === 1) {
+ throw new Error('bad atob input');
+ }
+ for (var bc = 0, bs, buffer, idx = 0, output = ''; buffer = input.charAt(idx++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
+ buffer = digits.indexOf(buffer);
+ }
+ return output;
+ };
+ })();
+ (function checkFunctionPrototypeBindCompatibility() {
+ if (typeof Function.prototype.bind !== 'undefined') {
+ return;
+ }
+ Function.prototype.bind = function functionPrototypeBind(obj) {
+ var fn = this,
+ headArgs = Array.prototype.slice.call(arguments, 1);
+ var bound = function functionPrototypeBindBound() {
+ var args = headArgs.concat(Array.prototype.slice.call(arguments));
+ return fn.apply(obj, args);
+ };
+ return bound;
+ };
+ })();
+ (function checkDatasetProperty() {
+ if (!hasDOM) {
+ return;
+ }
+ var div = document.createElement('div');
+ if ('dataset' in div) {
+ return;
+ }
+ Object.defineProperty(HTMLElement.prototype, 'dataset', {
+ get: function () {
+ if (this._dataset) {
+ return this._dataset;
+ }
+ var dataset = {};
+ for (var j = 0, jj = this.attributes.length; j < jj; j++) {
+ var attribute = this.attributes[j];
+ if (attribute.name.substring(0, 5) !== 'data-') {
+ continue;
+ }
+ var key = attribute.name.substring(5).replace(/\-([a-z])/g, function (all, ch) {
+ return ch.toUpperCase();
+ });
+ dataset[key] = attribute.value;
+ }
+ Object.defineProperty(this, '_dataset', {
+ value: dataset,
+ writable: false,
+ enumerable: false
+ });
+ return dataset;
+ },
+ enumerable: true
+ });
+ })();
+ (function checkClassListProperty() {
+ function changeList(element, itemName, add, remove) {
+ var s = element.className || '';
+ var list = s.split(/\s+/g);
+ if (list[0] === '') {
+ list.shift();
+ }
+ var index = list.indexOf(itemName);
+ if (index < 0 && add) {
+ list.push(itemName);
+ }
+ if (index >= 0 && remove) {
+ list.splice(index, 1);
+ }
+ element.className = list.join(' ');
+ return index >= 0;
+ }
+ if (!hasDOM) {
+ return;
+ }
+ var div = document.createElement('div');
+ if ('classList' in div) {
+ return;
+ }
+ var classListPrototype = {
+ add: function (name) {
+ changeList(this.element, name, true, false);
+ },
+ contains: function (name) {
+ return changeList(this.element, name, false, false);
+ },
+ remove: function (name) {
+ changeList(this.element, name, false, true);
+ },
+ toggle: function (name) {
+ changeList(this.element, name, true, true);
+ }
+ };
+ Object.defineProperty(HTMLElement.prototype, 'classList', {
+ get: function () {
+ if (this._classList) {
+ return this._classList;
+ }
+ var classList = Object.create(classListPrototype, {
+ element: {
+ value: this,
+ writable: false,
+ enumerable: true
+ }
+ });
+ Object.defineProperty(this, '_classList', {
+ value: classList,
+ writable: false,
+ enumerable: false
+ });
+ return classList;
+ },
+ enumerable: true
+ });
+ })();
+ (function checkWorkerConsoleCompatibility() {
+ if (typeof importScripts === 'undefined' || 'console' in globalScope) {
+ return;
+ }
+ var consoleTimer = {};
+ var workerConsole = {
+ log: function log() {
+ var args = Array.prototype.slice.call(arguments);
+ globalScope.postMessage({
+ targetName: 'main',
+ action: 'console_log',
+ data: args
+ });
+ },
+ error: function error() {
+ var args = Array.prototype.slice.call(arguments);
+ globalScope.postMessage({
+ targetName: 'main',
+ action: 'console_error',
+ data: args
+ });
+ },
+ time: function time(name) {
+ consoleTimer[name] = Date.now();
+ },
+ timeEnd: function timeEnd(name) {
+ var time = consoleTimer[name];
+ if (!time) {
+ throw new Error('Unknown timer name ' + name);
+ }
+ this.log('Timer:', name, Date.now() - time);
+ }
+ };
+ globalScope.console = workerConsole;
+ })();
+ (function checkConsoleCompatibility() {
+ if (!hasDOM) {
+ return;
+ }
+ if (!('console' in window)) {
+ window.console = {
+ log: function () {},
+ error: function () {},
+ warn: function () {}
+ };
+ return;
+ }
+ if (!('bind' in console.log)) {
+ console.log = function (fn) {
+ return function (msg) {
+ return fn(msg);
+ };
+ }(console.log);
+ console.error = function (fn) {
+ return function (msg) {
+ return fn(msg);
+ };
+ }(console.error);
+ console.warn = function (fn) {
+ return function (msg) {
+ return fn(msg);
+ };
+ }(console.warn);
+ return;
+ }
+ })();
+ (function checkOnClickCompatibility() {
+ function ignoreIfTargetDisabled(event) {
+ if (isDisabled(event.target)) {
+ event.stopPropagation();
+ }
+ }
+ function isDisabled(node) {
+ return node.disabled || node.parentNode && isDisabled(node.parentNode);
+ }
+ if (isOpera) {
+ document.addEventListener('click', ignoreIfTargetDisabled, true);
+ }
+ })();
+ (function checkOnBlobSupport() {
+ if (isIE || isIOSChrome) {
+ PDFJS.disableCreateObjectURL = true;
+ }
+ })();
+ (function checkNavigatorLanguage() {
+ if (typeof navigator === 'undefined') {
+ return;
+ }
+ if ('language' in navigator) {
+ return;
+ }
+ PDFJS.locale = navigator.userLanguage || 'en-US';
+ })();
+ (function checkRangeRequests() {
+ if (isSafari || isAndroidPre3 || isChromeWithRangeBug || isIOS) {
+ PDFJS.disableRange = true;
+ PDFJS.disableStream = true;
+ }
+ })();
+ (function checkHistoryManipulation() {
+ if (!hasDOM) {
+ return;
+ }
+ if (!history.pushState || isAndroidPre3) {
+ PDFJS.disableHistory = true;
+ }
+ })();
+ (function checkSetPresenceInImageData() {
+ if (!hasDOM) {
+ return;
+ }
+ if (window.CanvasPixelArray) {
+ if (typeof window.CanvasPixelArray.prototype.set !== 'function') {
+ window.CanvasPixelArray.prototype.set = function (arr) {
+ for (var i = 0, ii = this.length; i < ii; i++) {
+ this[i] = arr[i];
+ }
+ };
+ }
+ } else {
+ var polyfill = false,
+ versionMatch;
+ if (isChrome) {
+ versionMatch = userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
+ polyfill = versionMatch && parseInt(versionMatch[2]) < 21;
+ } else if (isAndroid) {
+ polyfill = isAndroidPre5;
+ } else if (isSafari) {
+ versionMatch = userAgent.match(/Version\/([0-9]+)\.([0-9]+)\.([0-9]+) Safari\//);
+ polyfill = versionMatch && parseInt(versionMatch[1]) < 6;
+ }
+ if (polyfill) {
+ var contextPrototype = window.CanvasRenderingContext2D.prototype;
+ var createImageData = contextPrototype.createImageData;
+ contextPrototype.createImageData = function (w, h) {
+ var imageData = createImageData.call(this, w, h);
+ imageData.data.set = function (arr) {
+ for (var i = 0, ii = this.length; i < ii; i++) {
+ this[i] = arr[i];
+ }
+ };
+ return imageData;
+ };
+ contextPrototype = null;
+ }
+ }
+ })();
+ (function checkRequestAnimationFrame() {
+ function installFakeAnimationFrameFunctions() {
+ window.requestAnimationFrame = function (callback) {
+ return window.setTimeout(callback, 20);
+ };
+ window.cancelAnimationFrame = function (timeoutID) {
+ window.clearTimeout(timeoutID);
+ };
+ }
+ if (!hasDOM) {
+ return;
+ }
+ if (isIOS) {
+ installFakeAnimationFrameFunctions();
+ return;
+ }
+ if ('requestAnimationFrame' in window) {
+ return;
+ }
+ window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame;
+ if (!('requestAnimationFrame' in window)) {
+ installFakeAnimationFrameFunctions();
+ }
+ })();
+ (function checkCanvasSizeLimitation() {
+ if (isIOS || isAndroid) {
+ PDFJS.maxCanvasPixels = 5242880;
+ }
+ })();
+ (function checkFullscreenSupport() {
+ if (!hasDOM) {
+ return;
+ }
+ if (isIE && window.parent !== window) {
+ PDFJS.disableFullscreen = true;
+ }
+ })();
+ (function checkCurrentScript() {
+ if (!hasDOM) {
+ return;
+ }
+ if ('currentScript' in document) {
+ return;
+ }
+ Object.defineProperty(document, 'currentScript', {
+ get: function () {
+ var scripts = document.getElementsByTagName('script');
+ return scripts[scripts.length - 1];
+ },
+ enumerable: true,
+ configurable: true
+ });
+ })();
+ (function checkInputTypeNumberAssign() {
+ if (!hasDOM) {
+ return;
+ }
+ var el = document.createElement('input');
+ try {
+ el.type = 'number';
+ } catch (ex) {
+ var inputProto = el.constructor.prototype;
+ var typeProperty = Object.getOwnPropertyDescriptor(inputProto, 'type');
+ Object.defineProperty(inputProto, 'type', {
+ get: function () {
+ return typeProperty.get.call(this);
+ },
+ set: function (value) {
+ typeProperty.set.call(this, value === 'number' ? 'text' : value);
+ },
+ enumerable: true,
+ configurable: true
+ });
+ }
+ })();
+ (function checkDocumentReadyState() {
+ if (!hasDOM) {
+ return;
+ }
+ if (!document.attachEvent) {
+ return;
+ }
+ var documentProto = document.constructor.prototype;
+ var readyStateProto = Object.getOwnPropertyDescriptor(documentProto, 'readyState');
+ Object.defineProperty(documentProto, 'readyState', {
+ get: function () {
+ var value = readyStateProto.get.call(this);
+ return value === 'interactive' ? 'loading' : value;
+ },
+ set: function (value) {
+ readyStateProto.set.call(this, value);
+ },
+ enumerable: true,
+ configurable: true
+ });
+ })();
+ (function checkChildNodeRemove() {
+ if (!hasDOM) {
+ return;
+ }
+ if (typeof Element.prototype.remove !== 'undefined') {
+ return;
+ }
+ Element.prototype.remove = function () {
+ if (this.parentNode) {
+ this.parentNode.removeChild(this);
+ }
+ };
+ })();
+ (function checkPromise() {
+ if (globalScope.Promise) {
+ if (typeof globalScope.Promise.all !== 'function') {
+ globalScope.Promise.all = function (iterable) {
+ var count = 0,
+ results = [],
+ resolve,
+ reject;
+ var promise = new globalScope.Promise(function (resolve_, reject_) {
+ resolve = resolve_;
+ reject = reject_;
+ });
+ iterable.forEach(function (p, i) {
+ count++;
+ p.then(function (result) {
+ results[i] = result;
+ count--;
+ if (count === 0) {
+ resolve(results);
+ }
+ }, reject);
+ });
+ if (count === 0) {
+ resolve(results);
+ }
+ return promise;
+ };
+ }
+ if (typeof globalScope.Promise.resolve !== 'function') {
+ globalScope.Promise.resolve = function (value) {
+ return new globalScope.Promise(function (resolve) {
+ resolve(value);
+ });
+ };
+ }
+ if (typeof globalScope.Promise.reject !== 'function') {
+ globalScope.Promise.reject = function (reason) {
+ return new globalScope.Promise(function (resolve, reject) {
+ reject(reason);
+ });
+ };
+ }
+ if (typeof globalScope.Promise.prototype.catch !== 'function') {
+ globalScope.Promise.prototype.catch = function (onReject) {
+ return globalScope.Promise.prototype.then(undefined, onReject);
+ };
+ }
+ return;
+ }
+ var STATUS_PENDING = 0;
+ var STATUS_RESOLVED = 1;
+ var STATUS_REJECTED = 2;
+ var REJECTION_TIMEOUT = 500;
+ var HandlerManager = {
+ handlers: [],
+ running: false,
+ unhandledRejections: [],
+ pendingRejectionCheck: false,
+ scheduleHandlers: function scheduleHandlers(promise) {
+ if (promise._status === STATUS_PENDING) {
+ return;
+ }
+ this.handlers = this.handlers.concat(promise._handlers);
+ promise._handlers = [];
+ if (this.running) {
+ return;
+ }
+ this.running = true;
+ setTimeout(this.runHandlers.bind(this), 0);
+ },
+ runHandlers: function runHandlers() {
+ var RUN_TIMEOUT = 1;
+ var timeoutAt = Date.now() + RUN_TIMEOUT;
+ while (this.handlers.length > 0) {
+ var handler = this.handlers.shift();
+ var nextStatus = handler.thisPromise._status;
+ var nextValue = handler.thisPromise._value;
+ try {
+ if (nextStatus === STATUS_RESOLVED) {
+ if (typeof handler.onResolve === 'function') {
+ nextValue = handler.onResolve(nextValue);
+ }
+ } else if (typeof handler.onReject === 'function') {
+ nextValue = handler.onReject(nextValue);
+ nextStatus = STATUS_RESOLVED;
+ if (handler.thisPromise._unhandledRejection) {
+ this.removeUnhandeledRejection(handler.thisPromise);
+ }
+ }
+ } catch (ex) {
+ nextStatus = STATUS_REJECTED;
+ nextValue = ex;
+ }
+ handler.nextPromise._updateStatus(nextStatus, nextValue);
+ if (Date.now() >= timeoutAt) {
+ break;
+ }
+ }
+ if (this.handlers.length > 0) {
+ setTimeout(this.runHandlers.bind(this), 0);
+ return;
+ }
+ this.running = false;
+ },
+ addUnhandledRejection: function addUnhandledRejection(promise) {
+ this.unhandledRejections.push({
+ promise: promise,
+ time: Date.now()
+ });
+ this.scheduleRejectionCheck();
+ },
+ removeUnhandeledRejection: function removeUnhandeledRejection(promise) {
+ promise._unhandledRejection = false;
+ for (var i = 0; i < this.unhandledRejections.length; i++) {
+ if (this.unhandledRejections[i].promise === promise) {
+ this.unhandledRejections.splice(i);
+ i--;
+ }
+ }
+ },
+ scheduleRejectionCheck: function scheduleRejectionCheck() {
+ if (this.pendingRejectionCheck) {
+ return;
+ }
+ this.pendingRejectionCheck = true;
+ setTimeout(function rejectionCheck() {
+ this.pendingRejectionCheck = false;
+ var now = Date.now();
+ for (var i = 0; i < this.unhandledRejections.length; i++) {
+ if (now - this.unhandledRejections[i].time > REJECTION_TIMEOUT) {
+ var unhandled = this.unhandledRejections[i].promise._value;
+ var msg = 'Unhandled rejection: ' + unhandled;
+ if (unhandled.stack) {
+ msg += '\n' + unhandled.stack;
+ }
+ try {
+ throw new Error(msg);
+ } catch (_) {
+ console.warn(msg);
+ }
+ this.unhandledRejections.splice(i);
+ i--;
+ }
+ }
+ if (this.unhandledRejections.length) {
+ this.scheduleRejectionCheck();
+ }
+ }.bind(this), REJECTION_TIMEOUT);
+ }
+ };
+ var Promise = function Promise(resolver) {
+ this._status = STATUS_PENDING;
+ this._handlers = [];
+ try {
+ resolver.call(this, this._resolve.bind(this), this._reject.bind(this));
+ } catch (e) {
+ this._reject(e);
+ }
+ };
+ Promise.all = function Promise_all(promises) {
+ var resolveAll, rejectAll;
+ var deferred = new Promise(function (resolve, reject) {
+ resolveAll = resolve;
+ rejectAll = reject;
+ });
+ var unresolved = promises.length;
+ var results = [];
+ if (unresolved === 0) {
+ resolveAll(results);
+ return deferred;
+ }
+ function reject(reason) {
+ if (deferred._status === STATUS_REJECTED) {
+ return;
+ }
+ results = [];
+ rejectAll(reason);
+ }
+ for (var i = 0, ii = promises.length; i < ii; ++i) {
+ var promise = promises[i];
+ var resolve = function (i) {
+ return function (value) {
+ if (deferred._status === STATUS_REJECTED) {
+ return;
+ }
+ results[i] = value;
+ unresolved--;
+ if (unresolved === 0) {
+ resolveAll(results);
+ }
+ };
+ }(i);
+ if (Promise.isPromise(promise)) {
+ promise.then(resolve, reject);
+ } else {
+ resolve(promise);
+ }
+ }
+ return deferred;
+ };
+ Promise.isPromise = function Promise_isPromise(value) {
+ return value && typeof value.then === 'function';
+ };
+ Promise.resolve = function Promise_resolve(value) {
+ return new Promise(function (resolve) {
+ resolve(value);
+ });
+ };
+ Promise.reject = function Promise_reject(reason) {
+ return new Promise(function (resolve, reject) {
+ reject(reason);
+ });
+ };
+ Promise.prototype = {
+ _status: null,
+ _value: null,
+ _handlers: null,
+ _unhandledRejection: null,
+ _updateStatus: function Promise__updateStatus(status, value) {
+ if (this._status === STATUS_RESOLVED || this._status === STATUS_REJECTED) {
+ return;
+ }
+ if (status === STATUS_RESOLVED && Promise.isPromise(value)) {
+ value.then(this._updateStatus.bind(this, STATUS_RESOLVED), this._updateStatus.bind(this, STATUS_REJECTED));
+ return;
+ }
+ this._status = status;
+ this._value = value;
+ if (status === STATUS_REJECTED && this._handlers.length === 0) {
+ this._unhandledRejection = true;
+ HandlerManager.addUnhandledRejection(this);
+ }
+ HandlerManager.scheduleHandlers(this);
+ },
+ _resolve: function Promise_resolve(value) {
+ this._updateStatus(STATUS_RESOLVED, value);
+ },
+ _reject: function Promise_reject(reason) {
+ this._updateStatus(STATUS_REJECTED, reason);
+ },
+ then: function Promise_then(onResolve, onReject) {
+ var nextPromise = new Promise(function (resolve, reject) {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+ this._handlers.push({
+ thisPromise: this,
+ onResolve: onResolve,
+ onReject: onReject,
+ nextPromise: nextPromise
+ });
+ HandlerManager.scheduleHandlers(this);
+ return nextPromise;
+ },
+ catch: function Promise_catch(onReject) {
+ return this.then(undefined, onReject);
+ }
+ };
+ globalScope.Promise = Promise;
+ })();
+ (function checkWeakMap() {
+ if (globalScope.WeakMap) {
+ return;
+ }
+ var id = 0;
+ function WeakMap() {
+ this.id = '$weakmap' + id++;
+ }
+ WeakMap.prototype = {
+ has: function (obj) {
+ return !!Object.getOwnPropertyDescriptor(obj, this.id);
+ },
+ get: function (obj, defaultValue) {
+ return this.has(obj) ? obj[this.id] : defaultValue;
+ },
+ set: function (obj, value) {
+ Object.defineProperty(obj, this.id, {
+ value: value,
+ enumerable: false,
+ configurable: true
+ });
+ },
+ delete: function (obj) {
+ delete obj[this.id];
+ }
+ };
+ globalScope.WeakMap = WeakMap;
+ })();
+ (function checkURLConstructor() {
+ var hasWorkingUrl = false;
+ try {
+ if (typeof URL === 'function' && typeof URL.prototype === 'object' && 'origin' in URL.prototype) {
+ var u = new URL('b', 'http://a');
+ u.pathname = 'c%20d';
+ hasWorkingUrl = u.href === 'http://a/c%20d';
+ }
+ } catch (e) {}
+ if (hasWorkingUrl) {
+ return;
+ }
+ var relative = Object.create(null);
+ relative['ftp'] = 21;
+ relative['file'] = 0;
+ relative['gopher'] = 70;
+ relative['http'] = 80;
+ relative['https'] = 443;
+ relative['ws'] = 80;
+ relative['wss'] = 443;
+ var relativePathDotMapping = Object.create(null);
+ relativePathDotMapping['%2e'] = '.';
+ relativePathDotMapping['.%2e'] = '..';
+ relativePathDotMapping['%2e.'] = '..';
+ relativePathDotMapping['%2e%2e'] = '..';
+ function isRelativeScheme(scheme) {
+ return relative[scheme] !== undefined;
+ }
+ function invalid() {
+ clear.call(this);
+ this._isInvalid = true;
+ }
+ function IDNAToASCII(h) {
+ if (h === '') {
+ invalid.call(this);
+ }
+ return h.toLowerCase();
+ }
+ function percentEscape(c) {
+ var unicode = c.charCodeAt(0);
+ if (unicode > 0x20 && unicode < 0x7F && [0x22, 0x23, 0x3C, 0x3E, 0x3F, 0x60].indexOf(unicode) === -1) {
+ return c;
+ }
+ return encodeURIComponent(c);
+ }
+ function percentEscapeQuery(c) {
+ var unicode = c.charCodeAt(0);
+ if (unicode > 0x20 && unicode < 0x7F && [0x22, 0x23, 0x3C, 0x3E, 0x60].indexOf(unicode) === -1) {
+ return c;
+ }
+ return encodeURIComponent(c);
+ }
+ var EOF,
+ ALPHA = /[a-zA-Z]/,
+ ALPHANUMERIC = /[a-zA-Z0-9\+\-\.]/;
+ function parse(input, stateOverride, base) {
+ function err(message) {
+ errors.push(message);
+ }
+ var state = stateOverride || 'scheme start',
+ cursor = 0,
+ buffer = '',
+ seenAt = false,
+ seenBracket = false,
+ errors = [];
+ loop: while ((input[cursor - 1] !== EOF || cursor === 0) && !this._isInvalid) {
+ var c = input[cursor];
+ switch (state) {
+ case 'scheme start':
+ if (c && ALPHA.test(c)) {
+ buffer += c.toLowerCase();
+ state = 'scheme';
+ } else if (!stateOverride) {
+ buffer = '';
+ state = 'no scheme';
+ continue;
+ } else {
+ err('Invalid scheme.');
+ break loop;
+ }
+ break;
+ case 'scheme':
+ if (c && ALPHANUMERIC.test(c)) {
+ buffer += c.toLowerCase();
+ } else if (c === ':') {
+ this._scheme = buffer;
+ buffer = '';
+ if (stateOverride) {
+ break loop;
+ }
+ if (isRelativeScheme(this._scheme)) {
+ this._isRelative = true;
+ }
+ if (this._scheme === 'file') {
+ state = 'relative';
+ } else if (this._isRelative && base && base._scheme === this._scheme) {
+ state = 'relative or authority';
+ } else if (this._isRelative) {
+ state = 'authority first slash';
+ } else {
+ state = 'scheme data';
+ }
+ } else if (!stateOverride) {
+ buffer = '';
+ cursor = 0;
+ state = 'no scheme';
+ continue;
+ } else if (c === EOF) {
+ break loop;
+ } else {
+ err('Code point not allowed in scheme: ' + c);
+ break loop;
+ }
+ break;
+ case 'scheme data':
+ if (c === '?') {
+ this._query = '?';
+ state = 'query';
+ } else if (c === '#') {
+ this._fragment = '#';
+ state = 'fragment';
+ } else {
+ if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
+ this._schemeData += percentEscape(c);
+ }
+ }
+ break;
+ case 'no scheme':
+ if (!base || !isRelativeScheme(base._scheme)) {
+ err('Missing scheme.');
+ invalid.call(this);
+ } else {
+ state = 'relative';
+ continue;
+ }
+ break;
+ case 'relative or authority':
+ if (c === '/' && input[cursor + 1] === '/') {
+ state = 'authority ignore slashes';
+ } else {
+ err('Expected /, got: ' + c);
+ state = 'relative';
+ continue;
+ }
+ break;
+ case 'relative':
+ this._isRelative = true;
+ if (this._scheme !== 'file') {
+ this._scheme = base._scheme;
+ }
+ if (c === EOF) {
+ this._host = base._host;
+ this._port = base._port;
+ this._path = base._path.slice();
+ this._query = base._query;
+ this._username = base._username;
+ this._password = base._password;
+ break loop;
+ } else if (c === '/' || c === '\\') {
+ if (c === '\\') {
+ err('\\ is an invalid code point.');
+ }
+ state = 'relative slash';
+ } else if (c === '?') {
+ this._host = base._host;
+ this._port = base._port;
+ this._path = base._path.slice();
+ this._query = '?';
+ this._username = base._username;
+ this._password = base._password;
+ state = 'query';
+ } else if (c === '#') {
+ this._host = base._host;
+ this._port = base._port;
+ this._path = base._path.slice();
+ this._query = base._query;
+ this._fragment = '#';
+ this._username = base._username;
+ this._password = base._password;
+ state = 'fragment';
+ } else {
+ var nextC = input[cursor + 1];
+ var nextNextC = input[cursor + 2];
+ if (this._scheme !== 'file' || !ALPHA.test(c) || nextC !== ':' && nextC !== '|' || nextNextC !== EOF && nextNextC !== '/' && nextNextC !== '\\' && nextNextC !== '?' && nextNextC !== '#') {
+ this._host = base._host;
+ this._port = base._port;
+ this._username = base._username;
+ this._password = base._password;
+ this._path = base._path.slice();
+ this._path.pop();
+ }
+ state = 'relative path';
+ continue;
+ }
+ break;
+ case 'relative slash':
+ if (c === '/' || c === '\\') {
+ if (c === '\\') {
+ err('\\ is an invalid code point.');
+ }
+ if (this._scheme === 'file') {
+ state = 'file host';
+ } else {
+ state = 'authority ignore slashes';
+ }
+ } else {
+ if (this._scheme !== 'file') {
+ this._host = base._host;
+ this._port = base._port;
+ this._username = base._username;
+ this._password = base._password;
+ }
+ state = 'relative path';
+ continue;
+ }
+ break;
+ case 'authority first slash':
+ if (c === '/') {
+ state = 'authority second slash';
+ } else {
+ err('Expected \'/\', got: ' + c);
+ state = 'authority ignore slashes';
+ continue;
+ }
+ break;
+ case 'authority second slash':
+ state = 'authority ignore slashes';
+ if (c !== '/') {
+ err('Expected \'/\', got: ' + c);
+ continue;
+ }
+ break;
+ case 'authority ignore slashes':
+ if (c !== '/' && c !== '\\') {
+ state = 'authority';
+ continue;
+ } else {
+ err('Expected authority, got: ' + c);
+ }
+ break;
+ case 'authority':
+ if (c === '@') {
+ if (seenAt) {
+ err('@ already seen.');
+ buffer += '%40';
+ }
+ seenAt = true;
+ for (var i = 0; i < buffer.length; i++) {
+ var cp = buffer[i];
+ if (cp === '\t' || cp === '\n' || cp === '\r') {
+ err('Invalid whitespace in authority.');
+ continue;
+ }
+ if (cp === ':' && this._password === null) {
+ this._password = '';
+ continue;
+ }
+ var tempC = percentEscape(cp);
+ if (this._password !== null) {
+ this._password += tempC;
+ } else {
+ this._username += tempC;
+ }
+ }
+ buffer = '';
+ } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
+ cursor -= buffer.length;
+ buffer = '';
+ state = 'host';
+ continue;
+ } else {
+ buffer += c;
+ }
+ break;
+ case 'file host':
+ if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
+ if (buffer.length === 2 && ALPHA.test(buffer[0]) && (buffer[1] === ':' || buffer[1] === '|')) {
+ state = 'relative path';
+ } else if (buffer.length === 0) {
+ state = 'relative path start';
+ } else {
+ this._host = IDNAToASCII.call(this, buffer);
+ buffer = '';
+ state = 'relative path start';
+ }
+ continue;
+ } else if (c === '\t' || c === '\n' || c === '\r') {
+ err('Invalid whitespace in file host.');
+ } else {
+ buffer += c;
+ }
+ break;
+ case 'host':
+ case 'hostname':
+ if (c === ':' && !seenBracket) {
+ this._host = IDNAToASCII.call(this, buffer);
+ buffer = '';
+ state = 'port';
+ if (stateOverride === 'hostname') {
+ break loop;
+ }
+ } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
+ this._host = IDNAToASCII.call(this, buffer);
+ buffer = '';
+ state = 'relative path start';
+ if (stateOverride) {
+ break loop;
+ }
+ continue;
+ } else if (c !== '\t' && c !== '\n' && c !== '\r') {
+ if (c === '[') {
+ seenBracket = true;
+ } else if (c === ']') {
+ seenBracket = false;
+ }
+ buffer += c;
+ } else {
+ err('Invalid code point in host/hostname: ' + c);
+ }
+ break;
+ case 'port':
+ if (/[0-9]/.test(c)) {
+ buffer += c;
+ } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#' || stateOverride) {
+ if (buffer !== '') {
+ var temp = parseInt(buffer, 10);
+ if (temp !== relative[this._scheme]) {
+ this._port = temp + '';
+ }
+ buffer = '';
+ }
+ if (stateOverride) {
+ break loop;
+ }
+ state = 'relative path start';
+ continue;
+ } else if (c === '\t' || c === '\n' || c === '\r') {
+ err('Invalid code point in port: ' + c);
+ } else {
+ invalid.call(this);
+ }
+ break;
+ case 'relative path start':
+ if (c === '\\') {
+ err('\'\\\' not allowed in path.');
+ }
+ state = 'relative path';
+ if (c !== '/' && c !== '\\') {
+ continue;
+ }
+ break;
+ case 'relative path':
+ if (c === EOF || c === '/' || c === '\\' || !stateOverride && (c === '?' || c === '#')) {
+ if (c === '\\') {
+ err('\\ not allowed in relative path.');
+ }
+ var tmp;
+ if (tmp = relativePathDotMapping[buffer.toLowerCase()]) {
+ buffer = tmp;
+ }
+ if (buffer === '..') {
+ this._path.pop();
+ if (c !== '/' && c !== '\\') {
+ this._path.push('');
+ }
+ } else if (buffer === '.' && c !== '/' && c !== '\\') {
+ this._path.push('');
+ } else if (buffer !== '.') {
+ if (this._scheme === 'file' && this._path.length === 0 && buffer.length === 2 && ALPHA.test(buffer[0]) && buffer[1] === '|') {
+ buffer = buffer[0] + ':';
+ }
+ this._path.push(buffer);
+ }
+ buffer = '';
+ if (c === '?') {
+ this._query = '?';
+ state = 'query';
+ } else if (c === '#') {
+ this._fragment = '#';
+ state = 'fragment';
+ }
+ } else if (c !== '\t' && c !== '\n' && c !== '\r') {
+ buffer += percentEscape(c);
+ }
+ break;
+ case 'query':
+ if (!stateOverride && c === '#') {
+ this._fragment = '#';
+ state = 'fragment';
+ } else if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
+ this._query += percentEscapeQuery(c);
+ }
+ break;
+ case 'fragment':
+ if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
+ this._fragment += c;
+ }
+ break;
+ }
+ cursor++;
+ }
+ }
+ function clear() {
+ this._scheme = '';
+ this._schemeData = '';
+ this._username = '';
+ this._password = null;
+ this._host = '';
+ this._port = '';
+ this._path = [];
+ this._query = '';
+ this._fragment = '';
+ this._isInvalid = false;
+ this._isRelative = false;
+ }
+ function JURL(url, base) {
+ if (base !== undefined && !(base instanceof JURL)) {
+ base = new JURL(String(base));
+ }
+ this._url = url;
+ clear.call(this);
+ var input = url.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '');
+ parse.call(this, input, null, base);
+ }
+ JURL.prototype = {
+ toString: function () {
+ return this.href;
+ },
+ get href() {
+ if (this._isInvalid) {
+ return this._url;
+ }
+ var authority = '';
+ if (this._username !== '' || this._password !== null) {
+ authority = this._username + (this._password !== null ? ':' + this._password : '') + '@';
+ }
+ return this.protocol + (this._isRelative ? '//' + authority + this.host : '') + this.pathname + this._query + this._fragment;
+ },
+ set href(href) {
+ clear.call(this);
+ parse.call(this, href);
+ },
+ get protocol() {
+ return this._scheme + ':';
+ },
+ set protocol(protocol) {
+ if (this._isInvalid) {
+ return;
+ }
+ parse.call(this, protocol + ':', 'scheme start');
+ },
+ get host() {
+ return this._isInvalid ? '' : this._port ? this._host + ':' + this._port : this._host;
+ },
+ set host(host) {
+ if (this._isInvalid || !this._isRelative) {
+ return;
+ }
+ parse.call(this, host, 'host');
+ },
+ get hostname() {
+ return this._host;
+ },
+ set hostname(hostname) {
+ if (this._isInvalid || !this._isRelative) {
+ return;
+ }
+ parse.call(this, hostname, 'hostname');
+ },
+ get port() {
+ return this._port;
+ },
+ set port(port) {
+ if (this._isInvalid || !this._isRelative) {
+ return;
+ }
+ parse.call(this, port, 'port');
+ },
+ get pathname() {
+ return this._isInvalid ? '' : this._isRelative ? '/' + this._path.join('/') : this._schemeData;
+ },
+ set pathname(pathname) {
+ if (this._isInvalid || !this._isRelative) {
+ return;
+ }
+ this._path = [];
+ parse.call(this, pathname, 'relative path start');
+ },
+ get search() {
+ return this._isInvalid || !this._query || this._query === '?' ? '' : this._query;
+ },
+ set search(search) {
+ if (this._isInvalid || !this._isRelative) {
+ return;
+ }
+ this._query = '?';
+ if (search[0] === '?') {
+ search = search.slice(1);
+ }
+ parse.call(this, search, 'query');
+ },
+ get hash() {
+ return this._isInvalid || !this._fragment || this._fragment === '#' ? '' : this._fragment;
+ },
+ set hash(hash) {
+ if (this._isInvalid) {
+ return;
+ }
+ this._fragment = '#';
+ if (hash[0] === '#') {
+ hash = hash.slice(1);
+ }
+ parse.call(this, hash, 'fragment');
+ },
+ get origin() {
+ var host;
+ if (this._isInvalid || !this._scheme) {
+ return '';
+ }
+ switch (this._scheme) {
+ case 'data':
+ case 'file':
+ case 'javascript':
+ case 'mailto':
+ return 'null';
+ }
+ host = this.host;
+ if (!host) {
+ return '';
+ }
+ return this._scheme + '://' + host;
+ }
+ };
+ var OriginalURL = globalScope.URL;
+ if (OriginalURL) {
+ JURL.createObjectURL = function (blob) {
+ return OriginalURL.createObjectURL.apply(OriginalURL, arguments);
+ };
+ JURL.revokeObjectURL = function (url) {
+ OriginalURL.revokeObjectURL(url);
+ };
+ }
+ globalScope.URL = JURL;
+ })();
+}
+/* WEBPACK VAR INJECTION */}.call(exports, __w_pdfjs_require__(6)))
+
+/***/ })
+/******/ ]);
+}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/pdf.min.js b/vendor/assets/javascripts/pdf.min.js
new file mode 100755
index 00000000000..271a471fd49
--- /dev/null
+++ b/vendor/assets/javascripts/pdf.min.js
@@ -0,0 +1,6 @@
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],e):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=e():t["pdfjs-dist/build/pdf"]=t.pdfjsDistBuildPdf=e()}(this,function(){return function(t){function e(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,e),i.l=!0,i.exports}var n={};return e.m=t,e.c=n,e.i=function(t){return t},e.d=function(t,n,r){e.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=13)}([function(t,e,n){"use strict";(function(t){function r(t){nt=t}function i(){return nt}function a(t){nt>=$.infos&&console.log("Info: "+t)}function s(t){nt>=$.warnings&&console.log("Warning: "+t)}function o(t){console.log("Deprecated API usage: "+t)}function c(t){throw nt>=$.errors&&(console.log("Error: "+t),console.log(l())),new Error(t)}function l(){try{throw new Error}catch(t){return t.stack?t.stack.split("\n").slice(2).join("\n"):""}}function h(t,e){t||c(e)}function u(t,e){try{var n=new URL(t);if(!n.origin||"null"===n.origin)return!1}catch(t){return!1}var r=new URL(e,n);return n.origin===r.origin}function d(t){if(!t)return!1;switch(t.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}function f(t,e){if(!t)return null;try{var n=e?new URL(t,e):new URL(t);if(d(n))return n}catch(t){}return null}function p(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!1}),n}function g(t){var e;return function(){return t&&(e=Object.create(null),t(e),t=null),e}}function m(t){return"string"!=typeof t?(s("The argument for removeNullCharacters must be a string."),t):t.replace(ft,"")}function A(t){h(null!==t&&"object"==typeof t&&void 0!==t.length,"Invalid argument for bytesToString");var e=t.length;if(e<8192)return String.fromCharCode.apply(null,t);for(var n=[],r=0;r<e;r+=8192){var i=Math.min(r+8192,e),a=t.subarray(r,i);n.push(String.fromCharCode.apply(null,a))}return n.join("")}function v(t){h("string"==typeof t,"Invalid argument for stringToBytes");for(var e=t.length,n=new Uint8Array(e),r=0;r<e;++r)n[r]=255&t.charCodeAt(r);return n}function b(t){return void 0!==t.length?t.length:(h(void 0!==t.byteLength),t.byteLength)}function y(t){if(1===t.length&&t[0]instanceof Uint8Array)return t[0];var e,n,r,i=0,a=t.length;for(e=0;e<a;e++)n=t[e],r=b(n),i+=r;var s=0,o=new Uint8Array(i);for(e=0;e<a;e++)n=t[e],n instanceof Uint8Array||(n="string"==typeof n?v(n):new Uint8Array(n)),r=n.byteLength,o.set(n,s),s+=r;return o}function x(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)}function S(t){for(var e=1,n=0;t>e;)e<<=1,n++;return n}function w(t,e){return t[e]<<24>>24}function k(t,e){return t[e]<<8|t[e+1]}function C(t,e){return(t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3])>>>0}function _(){var t=new Uint8Array(2);return t[0]=1,1===new Uint16Array(t.buffer)[0]}function T(){try{return new Function(""),!0}catch(t){return!1}}function P(t){var e,n=t.length,r=[];if("þ"===t[0]&&"ÿ"===t[1])for(e=2;e<n;e+=2)r.push(String.fromCharCode(t.charCodeAt(e)<<8|t.charCodeAt(e+1)));else for(e=0;e<n;++e){var i=vt[t.charCodeAt(e)];r.push(i?String.fromCharCode(i):t.charAt(e))}return r.join("")}function L(t){return decodeURIComponent(escape(t))}function E(t){return unescape(encodeURIComponent(t))}function R(t){for(var e in t)return!1;return!0}function I(t){return"boolean"==typeof t}function F(t){return"number"==typeof t&&(0|t)===t}function O(t){return"number"==typeof t}function M(t){return"string"==typeof t}function D(t){return t instanceof Array}function N(t){return"object"==typeof t&&null!==t&&void 0!==t.byteLength}function j(t){return 32===t||9===t||13===t||10===t}function U(){return"undefined"==typeof __pdfjsdev_webpack__&&("object"==typeof process&&process+""=="[object process]")}function B(){var t={};return t.promise=new Promise(function(e,n){t.resolve=e,t.reject=n}),t}function W(t,e,n){this.sourceName=t,this.targetName=e,this.comObj=n,this.callbackIndex=1,this.postMessageTransfers=!0;var r=this.callbacksCapabilities=Object.create(null),i=this.actionHandler=Object.create(null);this._onComObjOnMessage=function(t){var e=t.data;if(e.targetName===this.sourceName)if(e.isReply){var a=e.callbackId;if(e.callbackId in r){var s=r[a];delete r[a],"error"in e?s.reject(e.error):s.resolve(e.data)}else c("Cannot resolve callback "+a)}else if(e.action in i){var o=i[e.action];if(e.callbackId){var l=this.sourceName,h=e.sourceName;Promise.resolve().then(function(){return o[0].call(o[1],e.data)}).then(function(t){n.postMessage({sourceName:l,targetName:h,isReply:!0,callbackId:e.callbackId,data:t})},function(t){t instanceof Error&&(t+=""),n.postMessage({sourceName:l,targetName:h,isReply:!0,callbackId:e.callbackId,error:t})})}else o[0].call(o[1],e.data)}else c("Unknown action from worker: "+e.action)}.bind(this),n.addEventListener("message",this._onComObjOnMessage)}function G(t,e,n){var r=new Image;r.onload=function(){n.resolve(t,r)},r.onerror=function(){n.resolve(t,null),s("Error during JPEG image loading")},r.src=e}var X=(n(14),"undefined"!=typeof window?window:void 0!==t?t:"undefined"!=typeof self?self:void 0),H=[.001,0,0,.001,0,0],z={FILL:0,STROKE:1,FILL_STROKE:2,INVISIBLE:3,FILL_ADD_TO_PATH:4,STROKE_ADD_TO_PATH:5,FILL_STROKE_ADD_TO_PATH:6,ADD_TO_PATH:7,FILL_STROKE_MASK:3,ADD_TO_PATH_FLAG:4},Y={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},q={TEXT:1,LINK:2,FREETEXT:3,LINE:4,SQUARE:5,CIRCLE:6,POLYGON:7,POLYLINE:8,HIGHLIGHT:9,UNDERLINE:10,SQUIGGLY:11,STRIKEOUT:12,STAMP:13,CARET:14,INK:15,POPUP:16,FILEATTACHMENT:17,SOUND:18,MOVIE:19,WIDGET:20,SCREEN:21,PRINTERMARK:22,TRAPNET:23,WATERMARK:24,THREED:25,REDACT:26},V={INVISIBLE:1,HIDDEN:2,PRINT:4,NOZOOM:8,NOROTATE:16,NOVIEW:32,READONLY:64,LOCKED:128,TOGGLENOVIEW:256,LOCKEDCONTENTS:512},J={READONLY:1,REQUIRED:2,NOEXPORT:4,MULTILINE:4096,PASSWORD:8192,NOTOGGLETOOFF:16384,RADIO:32768,PUSHBUTTON:65536,COMBO:131072,EDIT:262144,SORT:524288,FILESELECT:1048576,MULTISELECT:2097152,DONOTSPELLCHECK:4194304,DONOTSCROLL:8388608,COMB:16777216,RICHTEXT:33554432,RADIOSINUNISON:33554432,COMMITONSELCHANGE:67108864},Q={SOLID:1,DASHED:2,BEVELED:3,INSET:4,UNDERLINE:5},K={UNKNOWN:0,FLATE:1,LZW:2,DCT:3,JPX:4,JBIG:5,A85:6,AHX:7,CCF:8,RL:9},Z={UNKNOWN:0,TYPE1:1,TYPE1C:2,CIDFONTTYPE0:3,CIDFONTTYPE0C:4,TRUETYPE:5,CIDFONTTYPE2:6,TYPE3:7,OPENTYPE:8,TYPE0:9,MMTYPE1:10},$={errors:0,warnings:1,infos:5},tt={NONE:0,BINARY:1,STREAM:2},et={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotations:78,endAnnotations:79,beginAnnotation:80,endAnnotation:81,paintJpegXObject:82,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91},nt=$.warnings,rt={unknown:"unknown",forms:"forms",javaScript:"javaScript",smask:"smask",shadingPattern:"shadingPattern",font:"font"},it={NEED_PASSWORD:1,INCORRECT_PASSWORD:2},at=function(){function t(t,e){this.name="PasswordException",this.message=t,this.code=e}return t.prototype=new Error,t.constructor=t,t}(),st=function(){function t(t,e){this.name="UnknownErrorException",this.message=t,this.details=e}return t.prototype=new Error,t.constructor=t,t}(),ot=function(){function t(t){this.name="InvalidPDFException",this.message=t}return t.prototype=new Error,t.constructor=t,t}(),ct=function(){function t(t){this.name="MissingPDFException",this.message=t}return t.prototype=new Error,t.constructor=t,t}(),lt=function(){function t(t,e){this.name="UnexpectedResponseException",this.message=t,this.status=e}return t.prototype=new Error,t.constructor=t,t}(),ht=function(){function t(t){this.message=t}return t.prototype=new Error,t.prototype.name="NotImplementedException",t.constructor=t,t}(),ut=function(){function t(t,e){this.begin=t,this.end=e,this.message="Missing data ["+t+", "+e+")"}return t.prototype=new Error,t.prototype.name="MissingDataException",t.constructor=t,t}(),dt=function(){function t(t){this.message=t}return t.prototype=new Error,t.prototype.name="XRefParseException",t.constructor=t,t}(),ft=/\x00/g,pt=function(){function t(t,e){this.buffer=t,this.byteLength=t.length,this.length=void 0===e?this.byteLength>>2:e,n(this.length)}function e(t){return{get:function(){var e=this.buffer,n=t<<2;return(e[n]|e[n+1]<<8|e[n+2]<<16|e[n+3]<<24)>>>0},set:function(e){var n=this.buffer,r=t<<2;n[r]=255&e,n[r+1]=e>>8&255,n[r+2]=e>>16&255,n[r+3]=e>>>24&255}}}function n(n){for(;r<n;)Object.defineProperty(t.prototype,r,e(r)),r++}t.prototype=Object.create(null);var r=0;return t}();e.Uint32ArrayView=pt;var gt=[1,0,0,1,0,0],mt=function(){function t(){}var e=["rgb(",0,",",0,",",0,")"];t.makeCssRgb=function(t,n,r){return e[1]=t,e[3]=n,e[5]=r,e.join("")},t.transform=function(t,e){return[t[0]*e[0]+t[2]*e[1],t[1]*e[0]+t[3]*e[1],t[0]*e[2]+t[2]*e[3],t[1]*e[2]+t[3]*e[3],t[0]*e[4]+t[2]*e[5]+t[4],t[1]*e[4]+t[3]*e[5]+t[5]]},t.applyTransform=function(t,e){return[t[0]*e[0]+t[1]*e[2]+e[4],t[0]*e[1]+t[1]*e[3]+e[5]]},t.applyInverseTransform=function(t,e){var n=e[0]*e[3]-e[1]*e[2];return[(t[0]*e[3]-t[1]*e[2]+e[2]*e[5]-e[4]*e[3])/n,(-t[0]*e[1]+t[1]*e[0]+e[4]*e[1]-e[5]*e[0])/n]},t.getAxialAlignedBoundingBox=function(e,n){var r=t.applyTransform(e,n),i=t.applyTransform(e.slice(2,4),n),a=t.applyTransform([e[0],e[3]],n),s=t.applyTransform([e[2],e[1]],n);return[Math.min(r[0],i[0],a[0],s[0]),Math.min(r[1],i[1],a[1],s[1]),Math.max(r[0],i[0],a[0],s[0]),Math.max(r[1],i[1],a[1],s[1])]},t.inverseTransform=function(t){var e=t[0]*t[3]-t[1]*t[2];return[t[3]/e,-t[1]/e,-t[2]/e,t[0]/e,(t[2]*t[5]-t[4]*t[3])/e,(t[4]*t[1]-t[5]*t[0])/e]},t.apply3dTransform=function(t,e){return[t[0]*e[0]+t[1]*e[1]+t[2]*e[2],t[3]*e[0]+t[4]*e[1]+t[5]*e[2],t[6]*e[0]+t[7]*e[1]+t[8]*e[2]]},t.singularValueDecompose2dScale=function(t){var e=[t[0],t[2],t[1],t[3]],n=t[0]*e[0]+t[1]*e[2],r=t[0]*e[1]+t[1]*e[3],i=t[2]*e[0]+t[3]*e[2],a=t[2]*e[1]+t[3]*e[3],s=(n+a)/2,o=Math.sqrt((n+a)*(n+a)-4*(n*a-i*r))/2,c=s+o||1,l=s-o||1;return[Math.sqrt(c),Math.sqrt(l)]},t.normalizeRect=function(t){var e=t.slice(0);return t[0]>t[2]&&(e[0]=t[2],e[2]=t[0]),t[1]>t[3]&&(e[1]=t[3],e[3]=t[1]),e},t.intersect=function(e,n){function r(t,e){return t-e}var i=[e[0],e[2],n[0],n[2]].sort(r),a=[e[1],e[3],n[1],n[3]].sort(r),s=[];return e=t.normalizeRect(e),n=t.normalizeRect(n),(i[0]===e[0]&&i[1]===n[0]||i[0]===n[0]&&i[1]===e[0])&&(s[0]=i[1],s[2]=i[2],(a[0]===e[1]&&a[1]===n[1]||a[0]===n[1]&&a[1]===e[1])&&(s[1]=a[1],s[3]=a[2],s))},t.sign=function(t){return t<0?-1:1};var n=["","C","CC","CCC","CD","D","DC","DCC","DCCC","CM","","X","XX","XXX","XL","L","LX","LXX","LXXX","XC","","I","II","III","IV","V","VI","VII","VIII","IX"];return t.toRoman=function(t,e){h(F(t)&&t>0,"The number should be a positive integer.");for(var r,i=[];t>=1e3;)t-=1e3,i.push("M");r=t/100|0,t%=100,i.push(n[r]),r=t/10|0,t%=10,i.push(n[10+r]),i.push(n[20+t]);var a=i.join("");return e?a.toLowerCase():a},t.appendToArray=function(t,e){Array.prototype.push.apply(t,e)},t.prependToArray=function(t,e){Array.prototype.unshift.apply(t,e)},t.extendObj=function(t,e){for(var n in e)t[n]=e[n]},t.getInheritableProperty=function(t,e,n){for(;t&&!t.has(e);)t=t.get("Parent");return t?n?t.getArray(e):t.get(e):null},t.inherit=function(t,e,n){t.prototype=Object.create(e.prototype),t.prototype.constructor=t;for(var r in n)t.prototype[r]=n[r]},t.loadScript=function(t,e){var n=document.createElement("script"),r=!1;n.setAttribute("src",t),e&&(n.onload=function(){r||e(),r=!0}),document.getElementsByTagName("head")[0].appendChild(n)},t}(),At=function(){function t(t,e,n,r,i,a){this.viewBox=t,this.scale=e,this.rotation=n,this.offsetX=r,this.offsetY=i;var s,o,c,l,h=(t[2]+t[0])/2,u=(t[3]+t[1])/2;switch(n%=360,n=n<0?n+360:n){case 180:s=-1,o=0,c=0,l=1;break;case 90:s=0,o=1,c=1,l=0;break;case 270:s=0,o=-1,c=-1,l=0;break;default:s=1,o=0,c=0,l=-1}a&&(c=-c,l=-l);var d,f,p,g;0===s?(d=Math.abs(u-t[1])*e+r,f=Math.abs(h-t[0])*e+i,p=Math.abs(t[3]-t[1])*e,g=Math.abs(t[2]-t[0])*e):(d=Math.abs(h-t[0])*e+r,f=Math.abs(u-t[1])*e+i,p=Math.abs(t[2]-t[0])*e,g=Math.abs(t[3]-t[1])*e),this.transform=[s*e,o*e,c*e,l*e,d-s*e*h-c*e*u,f-o*e*h-l*e*u],this.width=p,this.height=g,this.fontScale=e}return t.prototype={clone:function(e){e=e||{};var n="scale"in e?e.scale:this.scale,r="rotation"in e?e.rotation:this.rotation;return new t(this.viewBox.slice(),n,r,this.offsetX,this.offsetY,e.dontFlip)},convertToViewportPoint:function(t,e){return mt.applyTransform([t,e],this.transform)},convertToViewportRectangle:function(t){var e=mt.applyTransform([t[0],t[1]],this.transform),n=mt.applyTransform([t[2],t[3]],this.transform);return[e[0],e[1],n[0],n[1]]},convertToPdfPoint:function(t,e){return mt.applyInverseTransform([t,e],this.transform)}},t}(),vt=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,728,711,710,729,733,731,730,732,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8226,8224,8225,8230,8212,8211,402,8260,8249,8250,8722,8240,8222,8220,8221,8216,8217,8218,8482,64257,64258,321,338,352,376,381,305,322,339,353,382,0,8364],bt=function(){function t(t,e,n){for(;t.length<n;)t+=e;return t}function e(){this.started=Object.create(null),this.times=[],this.enabled=!0}return e.prototype={time:function(t){this.enabled&&(t in this.started&&s("Timer is already running for "+t),this.started[t]=Date.now())},timeEnd:function(t){this.enabled&&(t in this.started||s("Timer has not been started for "+t),this.times.push({name:t,start:this.started[t],end:Date.now()}),delete this.started[t])},toString:function(){var e,n,r=this.times,i="",a=0;for(e=0,n=r.length;e<n;++e){var s=r[e].name;s.length>a&&(a=s.length)}for(e=0,n=r.length;e<n;++e){var o=r[e],c=o.end-o.start;i+=t(o.name," ",a)+" "+c+"ms\n"}return i}},e}(),yt=function(t,e){if("undefined"!=typeof Blob)return new Blob([t],{type:e});s('The "Blob" constructor is not supported.')},xt=function(){var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";return function(e,n,r){if(!r&&"undefined"!=typeof URL&&URL.createObjectURL){var i=yt(e,n);return URL.createObjectURL(i)}for(var a="data:"+n+";base64,",s=0,o=e.length;s<o;s+=3){var c=255&e[s],l=255&e[s+1],h=255&e[s+2],u=c>>2,d=(3&c)<<4|l>>4,f=s+1<o?(15&l)<<2|h>>6:64,p=s+2<o?63&h:64;a+=t[u]+t[d]+t[f]+t[p]}return a}}();W.prototype={on:function(t,e,n){var r=this.actionHandler;r[t]&&c('There is already an actionName called "'+t+'"'),r[t]=[e,n]},send:function(t,e,n){var r={sourceName:this.sourceName,targetName:this.targetName,action:t,data:e};this.postMessage(r,n)},sendWithPromise:function(t,e,n){var r=this.callbackIndex++,i={sourceName:this.sourceName,targetName:this.targetName,action:t,data:e,callbackId:r},a=B();this.callbacksCapabilities[r]=a;try{this.postMessage(i,n)}catch(t){a.reject(t)}return a.promise},postMessage:function(t,e){e&&this.postMessageTransfers?this.comObj.postMessage(t,e):this.comObj.postMessage(t)},destroy:function(){this.comObj.removeEventListener("message",this._onComObjOnMessage)}},e.FONT_IDENTITY_MATRIX=H,e.IDENTITY_MATRIX=gt,e.OPS=et,e.VERBOSITY_LEVELS=$,e.UNSUPPORTED_FEATURES=rt,e.AnnotationBorderStyleType=Q,e.AnnotationFieldFlag=J,e.AnnotationFlag=V,e.AnnotationType=q,e.FontType=Z,e.ImageKind=Y,e.CMapCompressionType=tt,e.InvalidPDFException=ot,e.MessageHandler=W,e.MissingDataException=ut,e.MissingPDFException=ct,e.NotImplementedException=ht,e.PageViewport=At,e.PasswordException=at,e.PasswordResponses=it,e.StatTimer=bt,e.StreamType=K,e.TextRenderingMode=z,e.UnexpectedResponseException=lt,e.UnknownErrorException=st,e.Util=mt,e.XRefParseException=dt,e.arrayByteLength=b,e.arraysToBytes=y,e.assert=h,e.bytesToString=A,e.createBlob=yt,e.createPromiseCapability=B,e.createObjectURL=xt,e.deprecated=o,e.error=c,e.getLookupTableFactory=g,e.getVerbosityLevel=i,e.globalScope=X,e.info=a,e.isArray=D,e.isArrayBuffer=N,e.isBool=I,e.isEmptyObj=R,e.isInt=F,e.isNum=O,e.isString=M,e.isSpace=j,e.isNodeJS=U,e.isSameOrigin=u,e.createValidAbsoluteUrl=f,e.isLittleEndian=_,e.isEvalSupported=T,e.loadJpegStream=G,e.log2=S,e.readInt8=w,e.readUint16=k,e.readUint32=C,e.removeNullCharacters=m,e.setVerbosityLevel=r,e.shadow=p,e.string32=x,e.stringToBytes=v,e.stringToPDFString=P,e.stringToUTF8String=L,e.utf8StringToString=E,e.warn=s}).call(e,n(6))},function(t,e,n){"use strict";function r(){}function i(t,e){var n=e&&e.url;if(t.href=t.title=n?u(n):"",n){var r=e.target;void 0===r&&(r=s("externalLinkTarget")),t.target=w[r];var i=e.rel;void 0===i&&(i=s("externalLinkRel")),t.rel=i}}function a(t){var e=t.indexOf("#"),n=t.indexOf("?"),r=Math.min(e>0?e:t.length,n>0?n:t.length);return t.substring(t.lastIndexOf("/",r)+1,r)}function s(t){var e=l.globalScope.PDFJS;switch(t){case"pdfBug":return!!e&&e.pdfBug;case"disableAutoFetch":return!!e&&e.disableAutoFetch;case"disableStream":return!!e&&e.disableStream;case"disableRange":return!!e&&e.disableRange;case"disableFontFace":return!!e&&e.disableFontFace;case"disableCreateObjectURL":return!!e&&e.disableCreateObjectURL;case"disableWebGL":return!e||e.disableWebGL;case"cMapUrl":return e?e.cMapUrl:null;case"cMapPacked":return!!e&&e.cMapPacked;case"postMessageTransfers":return!e||e.postMessageTransfers;case"workerPort":return e?e.workerPort:null;case"workerSrc":return e?e.workerSrc:null;case"disableWorker":return!!e&&e.disableWorker;case"maxImageSize":return e?e.maxImageSize:-1;case"imageResourcesPath":return e?e.imageResourcesPath:"";case"isEvalSupported":return!e||e.isEvalSupported;case"externalLinkTarget":if(!e)return S.NONE;switch(e.externalLinkTarget){case S.NONE:case S.SELF:case S.BLANK:case S.PARENT:case S.TOP:return e.externalLinkTarget}return d("PDFJS.externalLinkTarget is invalid: "+e.externalLinkTarget),e.externalLinkTarget=S.NONE,S.NONE;case"externalLinkRel":return e?e.externalLinkRel:A;case"enableStats":return!(!e||!e.enableStats);case"pdfjsNext":return!(!e||!e.pdfjsNext);default:throw new Error("Unknown default setting: "+t)}}function o(){switch(s("externalLinkTarget")){case S.NONE:return!1;case S.SELF:case S.BLANK:case S.PARENT:case S.TOP:return!0}}function c(t,e){return f("isValidUrl(), please use createValidAbsoluteUrl() instead."),null!==p(t,e?"http://example.com":null)}var l=n(0),h=l.assert,u=l.removeNullCharacters,d=l.warn,f=l.deprecated,p=l.createValidAbsoluteUrl,g=l.stringToBytes,m=l.CMapCompressionType,A="noopener noreferrer nofollow";r.prototype={create:function(t,e){h(t>0&&e>0,"invalid canvas size");var n=document.createElement("canvas"),r=n.getContext("2d");return n.width=t,n.height=e,{canvas:n,context:r}},reset:function(t,e,n){h(t.canvas,"canvas is not specified"),h(e>0&&n>0,"invalid canvas size"),t.canvas.width=e,t.canvas.height=n},destroy:function(t){h(t.canvas,"canvas is not specified"),t.canvas.width=0,t.canvas.height=0,t.canvas=null,t.context=null}};var v,b=function(){function t(t){this.baseUrl=t.baseUrl||null,this.isCompressed=t.isCompressed||!1}return t.prototype={fetch:function(t){var e=t.name;return e?new Promise(function(t,n){var r=this.baseUrl+e+(this.isCompressed?".bcmap":""),i=new XMLHttpRequest;i.open("GET",r,!0),this.isCompressed&&(i.responseType="arraybuffer"),i.onreadystatechange=function(){if(i.readyState===XMLHttpRequest.DONE){if(200===i.status||0===i.status){var e;if(this.isCompressed&&i.response?e=new Uint8Array(i.response):!this.isCompressed&&i.responseText&&(e=g(i.responseText)),e)return void t({cMapData:e,compressionType:this.isCompressed?m.BINARY:m.NONE})}n(new Error("Unable to load "+(this.isCompressed?"binary ":"")+"CMap at: "+r))}}.bind(this),i.send(null)}.bind(this)):Promise.reject(new Error("CMap name must be specified."))}},t}(),y=function(){function t(){}var e=["ms","Moz","Webkit","O"],n=Object.create(null);return t.getProp=function(t,r){if(1===arguments.length&&"string"==typeof n[t])return n[t];r=r||document.documentElement;var i,a,s=r.style;if("string"==typeof s[t])return n[t]=t;a=t.charAt(0).toUpperCase()+t.slice(1);for(var o=0,c=e.length;o<c;o++)if(i=e[o]+a,"string"==typeof s[i])return n[t]=i;return n[t]="undefined"},t.setProp=function(t,e,n){var r=this.getProp(t);"undefined"!==r&&(e.style[r]=n)},t}(),x=function(){function t(t,e){this.message=t,this.type=e}return t.prototype=new Error,t.prototype.name="RenderingCancelledException",t.constructor=t,t}();v=function(){var t=document.createElement("canvas");return t.width=t.height=1,void 0!==t.getContext("2d").createImageData(1,1).data.buffer};var S={NONE:0,SELF:1,BLANK:2,PARENT:3,TOP:4},w=["","_self","_blank","_parent","_top"];e.CustomStyle=y,e.addLinkAttributes=i,e.isExternalLinkTargetSet=o,e.isValidUrl=c,e.getFilenameFromUrl=a,e.LinkTarget=S,e.RenderingCancelledException=x,e.hasCanvasTypedArrays=v,e.getDefaultSetting=s,e.DEFAULT_LINK_REL=A,e.DOMCanvasFactory=r,e.DOMCMapReaderFactory=b},function(t,e,n){"use strict";function r(){}var i=n(0),a=n(1),s=i.AnnotationBorderStyleType,o=i.AnnotationType,c=i.stringToPDFString,l=i.Util,h=a.addLinkAttributes,u=a.LinkTarget,d=a.getFilenameFromUrl,f=i.warn,p=a.CustomStyle,g=a.getDefaultSetting;r.prototype={create:function(t){switch(t.data.annotationType){case o.LINK:return new A(t);case o.TEXT:return new v(t);case o.WIDGET:switch(t.data.fieldType){case"Tx":return new y(t);case"Btn":if(t.data.radioButton)return new S(t);if(t.data.checkBox)return new x(t);f("Unimplemented button widget annotation: pushbutton");break;case"Ch":return new w(t)}return new b(t);case o.POPUP:return new k(t);case o.HIGHLIGHT:return new _(t);case o.UNDERLINE:return new T(t);case o.SQUIGGLY:return new P(t);case o.STRIKEOUT:return new L(t);case o.FILEATTACHMENT:return new E(t);default:return new m(t)}}};var m=function(){function t(t,e){this.isRenderable=e||!1,this.data=t.data,this.layer=t.layer,this.page=t.page,this.viewport=t.viewport,this.linkService=t.linkService,this.downloadManager=t.downloadManager,this.imageResourcesPath=t.imageResourcesPath,this.renderInteractiveForms=t.renderInteractiveForms,e&&(this.container=this._createContainer())}return t.prototype={_createContainer:function(){var t=this.data,e=this.page,n=this.viewport,r=document.createElement("section"),i=t.rect[2]-t.rect[0],a=t.rect[3]-t.rect[1];r.setAttribute("data-annotation-id",t.id);var o=l.normalizeRect([t.rect[0],e.view[3]-t.rect[1]+e.view[1],t.rect[2],e.view[3]-t.rect[3]+e.view[1]]);if(p.setProp("transform",r,"matrix("+n.transform.join(",")+")"),p.setProp("transformOrigin",r,-o[0]+"px "+-o[1]+"px"),t.borderStyle.width>0){r.style.borderWidth=t.borderStyle.width+"px",t.borderStyle.style!==s.UNDERLINE&&(i-=2*t.borderStyle.width,a-=2*t.borderStyle.width);var c=t.borderStyle.horizontalCornerRadius,h=t.borderStyle.verticalCornerRadius;if(c>0||h>0){var u=c+"px / "+h+"px";p.setProp("borderRadius",r,u)}switch(t.borderStyle.style){case s.SOLID:r.style.borderStyle="solid";break;case s.DASHED:r.style.borderStyle="dashed";break;case s.BEVELED:f("Unimplemented border style: beveled");break;case s.INSET:f("Unimplemented border style: inset");break;case s.UNDERLINE:r.style.borderBottomStyle="solid"}t.color?r.style.borderColor=l.makeCssRgb(0|t.color[0],0|t.color[1],0|t.color[2]):r.style.borderWidth=0}return r.style.left=o[0]+"px",r.style.top=o[1]+"px",r.style.width=i+"px",r.style.height=a+"px",r},_createPopup:function(t,e,n){e||(e=document.createElement("div"),e.style.height=t.style.height,e.style.width=t.style.width,t.appendChild(e));var r=new C({container:t,trigger:e,color:n.color,title:n.title,contents:n.contents,hideWrapper:!0}),i=r.render();i.style.left=t.style.width,t.appendChild(i)},render:function(){throw new Error("Abstract method AnnotationElement.render called")}},t}(),A=function(){function t(t){m.call(this,t,!0)}return l.inherit(t,m,{render:function(){this.container.className="linkAnnotation";var t=document.createElement("a");return h(t,{url:this.data.url,target:this.data.newWindow?u.BLANK:void 0}),this.data.url||(this.data.action?this._bindNamedAction(t,this.data.action):this._bindLink(t,this.data.dest)),this.container.appendChild(t),this.container},_bindLink:function(t,e){var n=this;t.href=this.linkService.getDestinationHash(e),t.onclick=function(){return e&&n.linkService.navigateTo(e),!1},e&&(t.className="internalLink")},_bindNamedAction:function(t,e){var n=this;t.href=this.linkService.getAnchorUrl(""),t.onclick=function(){return n.linkService.executeNamedAction(e),!1},t.className="internalLink"}}),t}(),v=function(){function t(t){var e=!!(t.data.hasPopup||t.data.title||t.data.contents);m.call(this,t,e)}return l.inherit(t,m,{render:function(){this.container.className="textAnnotation";var t=document.createElement("img");return t.style.height=this.container.style.height,t.style.width=this.container.style.width,t.src=this.imageResourcesPath+"annotation-"+this.data.name.toLowerCase()+".svg",t.alt="[{{type}} Annotation]",t.dataset.l10nId="text_annotation_type",t.dataset.l10nArgs=JSON.stringify({type:this.data.name}),this.data.hasPopup||this._createPopup(this.container,t,this.data),this.container.appendChild(t),this.container}}),t}(),b=function(){function t(t,e){m.call(this,t,e)}return l.inherit(t,m,{render:function(){return this.container}}),t}(),y=function(){function t(t){var e=t.renderInteractiveForms||!t.data.hasAppearance&&!!t.data.fieldValue;b.call(this,t,e)}var e=["left","center","right"];return l.inherit(t,b,{render:function(){this.container.className="textWidgetAnnotation";var t=null;if(this.renderInteractiveForms){if(this.data.multiLine?(t=document.createElement("textarea"),t.textContent=this.data.fieldValue):(t=document.createElement("input"),t.type="text",t.setAttribute("value",this.data.fieldValue)),t.disabled=this.data.readOnly,null!==this.data.maxLen&&(t.maxLength=this.data.maxLen),this.data.comb){var n=this.data.rect[2]-this.data.rect[0],r=n/this.data.maxLen;t.classList.add("comb"),t.style.letterSpacing="calc("+r+"px - 1ch)"}}else{t=document.createElement("div"),t.textContent=this.data.fieldValue,t.style.verticalAlign="middle",t.style.display="table-cell";var i=null;this.data.fontRefName&&(i=this.page.commonObjs.getData(this.data.fontRefName)),this._setTextStyle(t,i)}return null!==this.data.textAlignment&&(t.style.textAlign=e[this.data.textAlignment]),this.container.appendChild(t),this.container},_setTextStyle:function(t,e){var n=t.style;if(n.fontSize=this.data.fontSize+"px",n.direction=this.data.fontDirection<0?"rtl":"ltr",e){n.fontWeight=e.black?e.bold?"900":"bold":e.bold?"bold":"normal",n.fontStyle=e.italic?"italic":"normal";var r=e.loadedName?'"'+e.loadedName+'", ':"",i=e.fallbackName||"Helvetica, sans-serif";n.fontFamily=r+i}}}),t}(),x=function(){function t(t){b.call(this,t,t.renderInteractiveForms)}return l.inherit(t,b,{render:function(){this.container.className="buttonWidgetAnnotation checkBox";var t=document.createElement("input");return t.disabled=this.data.readOnly,t.type="checkbox",this.data.fieldValue&&"Off"!==this.data.fieldValue&&t.setAttribute("checked",!0),this.container.appendChild(t),this.container}}),t}(),S=function(){function t(t){b.call(this,t,t.renderInteractiveForms)}return l.inherit(t,b,{render:function(){this.container.className="buttonWidgetAnnotation radioButton";var t=document.createElement("input");return t.disabled=this.data.readOnly,t.type="radio",t.name=this.data.fieldName,this.data.fieldValue===this.data.buttonValue&&t.setAttribute("checked",!0),this.container.appendChild(t),this.container}}),t}(),w=function(){function t(t){b.call(this,t,t.renderInteractiveForms)}return l.inherit(t,b,{render:function(){this.container.className="choiceWidgetAnnotation";var t=document.createElement("select");t.disabled=this.data.readOnly,this.data.combo||(t.size=this.data.options.length,this.data.multiSelect&&(t.multiple=!0));for(var e=0,n=this.data.options.length;e<n;e++){var r=this.data.options[e],i=document.createElement("option");i.textContent=r.displayValue,i.value=r.exportValue,this.data.fieldValue.indexOf(r.displayValue)>=0&&i.setAttribute("selected",!0),t.appendChild(i)}return this.container.appendChild(t),this.container}}),t}(),k=function(){function t(t){var e=!(!t.data.title&&!t.data.contents);m.call(this,t,e)}return l.inherit(t,m,{render:function(){this.container.className="popupAnnotation";var t='[data-annotation-id="'+this.data.parentId+'"]',e=this.layer.querySelector(t);if(!e)return this.container;var n=new C({container:this.container,trigger:e,color:this.data.color,title:this.data.title,contents:this.data.contents}),r=parseFloat(e.style.left),i=parseFloat(e.style.width);return p.setProp("transformOrigin",this.container,-(r+i)+"px -"+e.style.top),this.container.style.left=r+i+"px",this.container.appendChild(n.render()),this.container}}),t}(),C=function(){function t(t){this.container=t.container,this.trigger=t.trigger,this.color=t.color,this.title=t.title,this.contents=t.contents,this.hideWrapper=t.hideWrapper||!1,this.pinned=!1}return t.prototype={render:function(){var t=document.createElement("div");t.className="popupWrapper",this.hideElement=this.hideWrapper?t:this.container,this.hideElement.setAttribute("hidden",!0);var e=document.createElement("div");e.className="popup";var n=this.color;if(n){var r=.7*(255-n[0])+n[0],i=.7*(255-n[1])+n[1],a=.7*(255-n[2])+n[2];e.style.backgroundColor=l.makeCssRgb(0|r,0|i,0|a)}var s=this._formatContents(this.contents),o=document.createElement("h1");return o.textContent=this.title,this.trigger.addEventListener("click",this._toggle.bind(this)),this.trigger.addEventListener("mouseover",this._show.bind(this,!1)),this.trigger.addEventListener("mouseout",this._hide.bind(this,!1)),e.addEventListener("click",this._hide.bind(this,!0)),e.appendChild(o),e.appendChild(s),t.appendChild(e),t},_formatContents:function(t){for(var e=document.createElement("p"),n=t.split(/(?:\r\n?|\n)/),r=0,i=n.length;r<i;++r){var a=n[r];e.appendChild(document.createTextNode(a)),r<i-1&&e.appendChild(document.createElement("br"))}return e},_toggle:function(){this.pinned?this._hide(!0):this._show(!0)},_show:function(t){t&&(this.pinned=!0),this.hideElement.hasAttribute("hidden")&&(this.hideElement.removeAttribute("hidden"),this.container.style.zIndex+=1)},_hide:function(t){t&&(this.pinned=!1),this.hideElement.hasAttribute("hidden")||this.pinned||(this.hideElement.setAttribute("hidden",!0),this.container.style.zIndex-=1)}},t}(),_=function(){function t(t){var e=!!(t.data.hasPopup||t.data.title||t.data.contents);m.call(this,t,e)}return l.inherit(t,m,{render:function(){return this.container.className="highlightAnnotation",this.data.hasPopup||this._createPopup(this.container,null,this.data),this.container}}),t}(),T=function(){function t(t){var e=!!(t.data.hasPopup||t.data.title||t.data.contents)
+;m.call(this,t,e)}return l.inherit(t,m,{render:function(){return this.container.className="underlineAnnotation",this.data.hasPopup||this._createPopup(this.container,null,this.data),this.container}}),t}(),P=function(){function t(t){var e=!!(t.data.hasPopup||t.data.title||t.data.contents);m.call(this,t,e)}return l.inherit(t,m,{render:function(){return this.container.className="squigglyAnnotation",this.data.hasPopup||this._createPopup(this.container,null,this.data),this.container}}),t}(),L=function(){function t(t){var e=!!(t.data.hasPopup||t.data.title||t.data.contents);m.call(this,t,e)}return l.inherit(t,m,{render:function(){return this.container.className="strikeoutAnnotation",this.data.hasPopup||this._createPopup(this.container,null,this.data),this.container}}),t}(),E=function(){function t(t){m.call(this,t,!0);var e=this.data.file;this.filename=d(e.filename),this.content=e.content,this.linkService.onFileAttachmentAnnotation({id:c(e.filename),filename:e.filename,content:e.content})}return l.inherit(t,m,{render:function(){this.container.className="fileAttachmentAnnotation";var t=document.createElement("div");return t.style.height=this.container.style.height,t.style.width=this.container.style.width,t.addEventListener("dblclick",this._download.bind(this)),this.data.hasPopup||!this.data.title&&!this.data.contents||this._createPopup(this.container,t,this.data),this.container.appendChild(t),this.container},_download:function(){if(!this.downloadManager)return void f("Download cannot be started due to unavailable download manager");this.downloadManager.downloadData(this.content,this.filename,"")}}),t}(),R=function(){return{render:function(t){for(var e=new r,n=0,i=t.annotations.length;n<i;n++){var a=t.annotations[n];if(a){var s=e.create({data:a,layer:t.div,page:t.page,viewport:t.viewport,linkService:t.linkService,downloadManager:t.downloadManager,imageResourcesPath:t.imageResourcesPath||g("imageResourcesPath"),renderInteractiveForms:t.renderInteractiveForms||!1});s.isRenderable&&t.div.appendChild(s.render())}}},update:function(t){for(var e=0,n=t.annotations.length;e<n;e++){var r=t.annotations[e],i=t.div.querySelector('[data-annotation-id="'+r.id+'"]');i&&p.setProp("transform",i,"matrix("+t.viewport.transform.join(",")+")")}t.div.removeAttribute("hidden")}}}();e.AnnotationLayer=R},function(t,e,n){"use strict";function r(t,e,n,r){var a=new V;arguments.length>1&&S("getDocument is called with pdfDataRangeTransport, passwordCallback or progressCallback argument"),e&&(e instanceof J||(e=Object.create(e),e.length=t.length,e.initialData=t.initialData,e.abort||(e.abort=function(){})),t=Object.create(t),t.range=e),a.onPassword=n||null,a.onProgress=r||null;var s;"string"==typeof t?s={url:t}:T(t)?s={data:t}:t instanceof J?s={range:t}:("object"!=typeof t&&x("Invalid parameter in getDocument, need either Uint8Array, string or a parameter object"),t.url||t.data||t.range||x("Invalid parameter object: need either .data, .range or .url"),s=t);var o={},c=null,l=null;for(var h in s)if("url"!==h||"undefined"==typeof window)if("range"!==h)if("worker"!==h)if("data"!==h||s[h]instanceof Uint8Array)o[h]=s[h];else{var u=s[h];"string"==typeof u?o[h]=E(u):"object"!=typeof u||null===u||isNaN(u.length)?T(u)?o[h]=new Uint8Array(u):x("Invalid PDF binary data: either typed array, string or array-like object is expected in the data property."):o[h]=new Uint8Array(u)}else l=s[h];else c=s[h];else o[h]=new URL(s[h],window.location).href;o.rangeChunkSize=o.rangeChunkSize||W,o.disableNativeImageDecoder=!0===o.disableNativeImageDecoder;var f=o.CMapReaderFactory||B;if(!l){var p=j("workerPort");l=p?new Z(null,p):new Z,a._worker=l}var g=a.docId;return l.promise.then(function(){if(a.destroyed)throw new Error("Loading aborted");return i(l,o,c,g).then(function(t){if(a.destroyed)throw new Error("Loading aborted");var e=new d(g,t,l.port),n=new $(e,a,c,f);a._transport=n,e.send("Ready",null)})}).catch(a._capability.reject),a}function i(t,e,n,r){return t.destroyed?Promise.reject(new Error("Worker was destroyed")):(e.disableAutoFetch=j("disableAutoFetch"),e.disableStream=j("disableStream"),e.chunkedViewerLoading=!!n,n&&(e.length=n.length,e.initialData=n.initialData),t.messageHandler.sendWithPromise("GetDocRequest",{docId:r,source:e,disableRange:j("disableRange"),maxImageSize:j("maxImageSize"),disableFontFace:j("disableFontFace"),disableCreateObjectURL:j("disableCreateObjectURL"),postMessageTransfers:j("postMessageTransfers")&&!X,docBaseUrl:e.docBaseUrl,disableNativeImageDecoder:e.disableNativeImageDecoder}).then(function(e){if(t.destroyed)throw new Error("Worker was destroyed");return e}))}var a,s=n(0),o=n(11),c=n(10),l=n(7),h=n(1),u=s.InvalidPDFException,d=s.MessageHandler,f=s.MissingPDFException,p=s.PageViewport,g=s.PasswordException,m=s.StatTimer,A=s.UnexpectedResponseException,v=s.UnknownErrorException,b=s.Util,y=s.createPromiseCapability,x=s.error,S=s.deprecated,w=s.getVerbosityLevel,k=s.info,C=s.isInt,_=s.isArray,T=s.isArrayBuffer,P=s.isSameOrigin,L=s.loadJpegStream,E=s.stringToBytes,R=s.globalScope,I=s.warn,F=o.FontFaceObject,O=o.FontLoader,M=c.CanvasGraphics,D=l.Metadata,N=h.RenderingCancelledException,j=h.getDefaultSetting,U=h.DOMCanvasFactory,B=h.DOMCMapReaderFactory,W=65536,G=!1,X=!1,H="undefined"!=typeof document&&document.currentScript?document.currentScript.src:null,z=null,Y=!1;if("undefined"==typeof __pdfjsdev_webpack__){"undefined"==typeof window?(G=!0,void 0===require.ensure&&(require.ensure=require("node-ensure")),Y=!0):"undefined"!=typeof require&&"function"==typeof require.ensure&&(Y=!0),"undefined"!=typeof requirejs&&requirejs.toUrl&&(a=requirejs.toUrl("pdfjs-dist/build/pdf.worker.js"));var q="undefined"!=typeof requirejs&&requirejs.load;z=Y?function(t){require.ensure([],function(){var e=require("./pdf.worker.js");t(e.WorkerMessageHandler)})}:q?function(t){requirejs(["pdfjs-dist/build/pdf.worker"],function(e){t(e.WorkerMessageHandler)})}:null}var V=function(){function t(){this._capability=y(),this._transport=null,this._worker=null,this.docId="d"+e++,this.destroyed=!1,this.onPassword=null,this.onProgress=null,this.onUnsupportedFeature=null}var e=0;return t.prototype={get promise(){return this._capability.promise},destroy:function(){return this.destroyed=!0,(this._transport?this._transport.destroy():Promise.resolve()).then(function(){this._transport=null,this._worker&&(this._worker.destroy(),this._worker=null)}.bind(this))},then:function(t,e){return this.promise.then.apply(this.promise,arguments)}},t}(),J=function(){function t(t,e){this.length=t,this.initialData=e,this._rangeListeners=[],this._progressListeners=[],this._progressiveReadListeners=[],this._readyCapability=y()}return t.prototype={addRangeListener:function(t){this._rangeListeners.push(t)},addProgressListener:function(t){this._progressListeners.push(t)},addProgressiveReadListener:function(t){this._progressiveReadListeners.push(t)},onDataRange:function(t,e){for(var n=this._rangeListeners,r=0,i=n.length;r<i;++r)n[r](t,e)},onDataProgress:function(t){this._readyCapability.promise.then(function(){for(var e=this._progressListeners,n=0,r=e.length;n<r;++n)e[n](t)}.bind(this))},onDataProgressiveRead:function(t){this._readyCapability.promise.then(function(){for(var e=this._progressiveReadListeners,n=0,r=e.length;n<r;++n)e[n](t)}.bind(this))},transportReady:function(){this._readyCapability.resolve()},requestDataRange:function(t,e){throw new Error("Abstract method PDFDataRangeTransport.requestDataRange")},abort:function(){}},t}(),Q=function(){function t(t,e,n){this.pdfInfo=t,this.transport=e,this.loadingTask=n}return t.prototype={get numPages(){return this.pdfInfo.numPages},get fingerprint(){return this.pdfInfo.fingerprint},getPage:function(t){return this.transport.getPage(t)},getPageIndex:function(t){return this.transport.getPageIndex(t)},getDestinations:function(){return this.transport.getDestinations()},getDestination:function(t){return this.transport.getDestination(t)},getPageLabels:function(){return this.transport.getPageLabels()},getAttachments:function(){return this.transport.getAttachments()},getJavaScript:function(){return this.transport.getJavaScript()},getOutline:function(){return this.transport.getOutline()},getMetadata:function(){return this.transport.getMetadata()},getData:function(){return this.transport.getData()},getDownloadInfo:function(){return this.transport.downloadInfoCapability.promise},getStats:function(){return this.transport.getStats()},cleanup:function(){this.transport.startCleanup()},destroy:function(){return this.loadingTask.destroy()}},t}(),K=function(){function t(t,e,n){this.pageIndex=t,this.pageInfo=e,this.transport=n,this.stats=new m,this.stats.enabled=j("enableStats"),this.commonObjs=n.commonObjs,this.objs=new tt,this.cleanupAfterRender=!1,this.pendingCleanup=!1,this.intentStates=Object.create(null),this.destroyed=!1}return t.prototype={get pageNumber(){return this.pageIndex+1},get rotate(){return this.pageInfo.rotate},get ref(){return this.pageInfo.ref},get userUnit(){return this.pageInfo.userUnit},get view(){return this.pageInfo.view},getViewport:function(t,e){return arguments.length<2&&(e=this.rotate),new p(this.view,t,e,0,0)},getAnnotations:function(t){var e=t&&t.intent||null;return this.annotationsPromise&&this.annotationsIntent===e||(this.annotationsPromise=this.transport.getAnnotations(this.pageIndex,e),this.annotationsIntent=e),this.annotationsPromise},render:function(t){function e(t){var e=s.renderTasks.indexOf(o);e>=0&&s.renderTasks.splice(e,1),l.cleanupAfterRender&&(l.pendingCleanup=!0),l._tryCleanup(),t?o.capability.reject(t):o.capability.resolve(),n.timeEnd("Rendering"),n.timeEnd("Overall")}var n=this.stats;n.time("Overall"),this.pendingCleanup=!1;var r="print"===t.intent?"print":"display",i=!0===t.renderInteractiveForms,a=t.canvasFactory||new U;this.intentStates[r]||(this.intentStates[r]=Object.create(null));var s=this.intentStates[r];s.displayReadyCapability||(s.receivingOperatorList=!0,s.displayReadyCapability=y(),s.operatorList={fnArray:[],argsArray:[],lastChunk:!1},this.stats.time("Page Request"),this.transport.messageHandler.send("RenderPageRequest",{pageIndex:this.pageNumber-1,intent:r,renderInteractiveForms:i}));var o=new nt(e,t,this.objs,this.commonObjs,s.operatorList,this.pageNumber,a);o.useRequestAnimationFrame="print"!==r,s.renderTasks||(s.renderTasks=[]),s.renderTasks.push(o);var c=o.task;t.continueCallback&&(S("render is used with continueCallback parameter"),c.onContinue=t.continueCallback);var l=this;return s.displayReadyCapability.promise.then(function(t){if(l.pendingCleanup)return void e();n.time("Rendering"),o.initializeGraphics(t),o.operatorListChanged()},function(t){e(t)}),c},getOperatorList:function(){function t(){if(n.operatorList.lastChunk){n.opListReadCapability.resolve(n.operatorList);var t=n.renderTasks.indexOf(e);t>=0&&n.renderTasks.splice(t,1)}}this.intentStates.oplist||(this.intentStates.oplist=Object.create(null));var e,n=this.intentStates.oplist;return n.opListReadCapability||(e={},e.operatorListChanged=t,n.receivingOperatorList=!0,n.opListReadCapability=y(),n.renderTasks=[],n.renderTasks.push(e),n.operatorList={fnArray:[],argsArray:[],lastChunk:!1},this.transport.messageHandler.send("RenderPageRequest",{pageIndex:this.pageIndex,intent:"oplist"})),n.opListReadCapability.promise},getTextContent:function(t){return this.transport.messageHandler.sendWithPromise("GetTextContent",{pageIndex:this.pageNumber-1,normalizeWhitespace:!(!t||!0!==t.normalizeWhitespace),combineTextItems:!t||!0!==t.disableCombineTextItems})},_destroy:function(){this.destroyed=!0,this.transport.pageCache[this.pageIndex]=null;var t=[];return Object.keys(this.intentStates).forEach(function(e){if("oplist"!==e){this.intentStates[e].renderTasks.forEach(function(e){var n=e.capability.promise.catch(function(){});t.push(n),e.cancel()})}},this),this.objs.clear(),this.annotationsPromise=null,this.pendingCleanup=!1,Promise.all(t)},destroy:function(){S("page destroy method, use cleanup() instead"),this.cleanup()},cleanup:function(){this.pendingCleanup=!0,this._tryCleanup()},_tryCleanup:function(){this.pendingCleanup&&!Object.keys(this.intentStates).some(function(t){var e=this.intentStates[t];return 0!==e.renderTasks.length||e.receivingOperatorList},this)&&(Object.keys(this.intentStates).forEach(function(t){delete this.intentStates[t]},this),this.objs.clear(),this.annotationsPromise=null,this.pendingCleanup=!1)},_startRenderPage:function(t,e){var n=this.intentStates[e];n.displayReadyCapability&&n.displayReadyCapability.resolve(t)},_renderPageChunk:function(t,e){var n,r,i=this.intentStates[e];for(n=0,r=t.length;n<r;n++)i.operatorList.fnArray.push(t.fnArray[n]),i.operatorList.argsArray.push(t.argsArray[n]);for(i.operatorList.lastChunk=t.lastChunk,n=0;n<i.renderTasks.length;n++)i.renderTasks[n].operatorListChanged();t.lastChunk&&(i.receivingOperatorList=!1,this._tryCleanup())}},t}(),Z=function(){function t(){return void 0!==a?a:j("workerSrc")?j("workerSrc"):H?H.replace(/\.js$/i,".worker.js"):void x("No PDFJS.workerSrc specified")}function e(){return s?s.promise:(s=y(),(z||function(e){b.loadScript(t(),function(){e(window.pdfjsDistBuildPdfWorker.WorkerMessageHandler)})})(s.resolve),s.promise)}function n(t){this._listeners=[],this._defer=t,this._deferred=Promise.resolve(void 0)}function r(t){var e="importScripts('"+t+"');";return URL.createObjectURL(new Blob([e]))}function i(t,e){if(this.name=t,this.destroyed=!1,this._readyCapability=y(),this._port=null,this._webWorker=null,this._messageHandler=null,e)return void this._initializeFromPort(e);this._initialize()}var s,o=0;return n.prototype={postMessage:function(t,e){function n(t){if("object"!=typeof t||null===t)return t;if(r.has(t))return r.get(t);var i,a;if((a=t.buffer)&&T(a)){var s=e&&e.indexOf(a)>=0;return i=t===a?t:s?new t.constructor(a,t.byteOffset,t.byteLength):new t.constructor(t),r.set(t,i),i}i=_(t)?[]:{},r.set(t,i);for(var o in t){for(var c,l=t;!(c=Object.getOwnPropertyDescriptor(l,o));)l=Object.getPrototypeOf(l);void 0!==c.value&&"function"!=typeof c.value&&(i[o]=n(c.value))}return i}if(!this._defer)return void this._listeners.forEach(function(e){e.call(this,{data:t})},this);var r=new WeakMap,i={data:n(t)};this._deferred.then(function(){this._listeners.forEach(function(t){t.call(this,i)},this)}.bind(this))},addEventListener:function(t,e){this._listeners.push(e)},removeEventListener:function(t,e){var n=this._listeners.indexOf(e);this._listeners.splice(n,1)},terminate:function(){this._listeners=[]}},i.prototype={get promise(){return this._readyCapability.promise},get port(){return this._port},get messageHandler(){return this._messageHandler},_initializeFromPort:function(t){this._port=t,this._messageHandler=new d("main","worker",t),this._messageHandler.on("ready",function(){}),this._readyCapability.resolve()},_initialize:function(){if(!G&&!j("disableWorker")&&"undefined"!=typeof Worker){var e=t();try{P(window.location.href,e)||(e=r(new URL(e,window.location).href));var n=new Worker(e),i=new d("main","worker",n),a=function(){n.removeEventListener("error",s),i.destroy(),n.terminate(),this.destroyed?this._readyCapability.reject(new Error("Worker was destroyed")):this._setupFakeWorker()}.bind(this),s=function(t){this._webWorker||a()}.bind(this);n.addEventListener("error",s),i.on("test",function(t){if(n.removeEventListener("error",s),this.destroyed)return void a();t&&t.supportTypedArray?(this._messageHandler=i,this._port=n,this._webWorker=n,t.supportTransfers||(X=!0),this._readyCapability.resolve(),i.send("configure",{verbosity:w()})):(this._setupFakeWorker(),i.destroy(),n.terminate())}.bind(this)),i.on("console_log",function(t){console.log.apply(console,t)}),i.on("console_error",function(t){console.error.apply(console,t)}),i.on("ready",function(t){if(n.removeEventListener("error",s),this.destroyed)return void a();try{o()}catch(t){this._setupFakeWorker()}}.bind(this));var o=function(){var t=j("postMessageTransfers")&&!X,e=new Uint8Array([t?255:0]);try{i.send("test",e,[e.buffer])}catch(t){k("Cannot use postMessage transfers"),e[0]=0,i.send("test",e)}};return void o()}catch(t){k("The worker has been disabled.")}}this._setupFakeWorker()},_setupFakeWorker:function(){G||j("disableWorker")||(I("Setting up fake worker."),G=!0),e().then(function(t){if(this.destroyed)return void this._readyCapability.reject(new Error("Worker was destroyed"));var e=Uint8Array!==Float32Array,r=new n(e);this._port=r;var i="fake"+o++,a=new d(i+"_worker",i,r);t.setup(a,r);var s=new d(i,i+"_worker",r);this._messageHandler=s,this._readyCapability.resolve()}.bind(this))},destroy:function(){this.destroyed=!0,this._webWorker&&(this._webWorker.terminate(),this._webWorker=null),this._port=null,this._messageHandler&&(this._messageHandler.destroy(),this._messageHandler=null)}},i}(),$=function(){function t(t,e,n,r){this.messageHandler=t,this.loadingTask=e,this.pdfDataRangeTransport=n,this.commonObjs=new tt,this.fontLoader=new O(e.docId),this.CMapReaderFactory=new r({baseUrl:j("cMapUrl"),isCompressed:j("cMapPacked")}),this.destroyed=!1,this.destroyCapability=null,this._passwordCapability=null,this.pageCache=[],this.pagePromises=[],this.downloadInfoCapability=y(),this.setupMessageHandler()}return t.prototype={destroy:function(){if(this.destroyCapability)return this.destroyCapability.promise;this.destroyed=!0,this.destroyCapability=y(),this._passwordCapability&&this._passwordCapability.reject(new Error("Worker was destroyed during onPassword callback"));var t=[];this.pageCache.forEach(function(e){e&&t.push(e._destroy())}),this.pageCache=[],this.pagePromises=[];var e=this,n=this.messageHandler.sendWithPromise("Terminate",null);return t.push(n),Promise.all(t).then(function(){e.fontLoader.clear(),e.pdfDataRangeTransport&&(e.pdfDataRangeTransport.abort(),e.pdfDataRangeTransport=null),e.messageHandler&&(e.messageHandler.destroy(),e.messageHandler=null),e.destroyCapability.resolve()},this.destroyCapability.reject),this.destroyCapability.promise},setupMessageHandler:function(){var t=this.messageHandler,e=this.loadingTask,n=this.pdfDataRangeTransport;n&&(n.addRangeListener(function(e,n){t.send("OnDataRange",{begin:e,chunk:n})}),n.addProgressListener(function(e){t.send("OnDataProgress",{loaded:e})}),n.addProgressiveReadListener(function(e){t.send("OnDataRange",{chunk:e})}),t.on("RequestDataRange",function(t){n.requestDataRange(t.begin,t.end)},this)),t.on("GetDoc",function(t){var e=t.pdfInfo;this.numPages=t.pdfInfo.numPages;var n=this.loadingTask,r=new Q(e,this,n);this.pdfDocument=r,n._capability.resolve(r)},this),t.on("PasswordRequest",function(t){if(this._passwordCapability=y(),e.onPassword){var n=function(t){this._passwordCapability.resolve({password:t})}.bind(this);e.onPassword(n,t.code)}else this._passwordCapability.reject(new g(t.message,t.code));return this._passwordCapability.promise},this),t.on("PasswordException",function(t){e._capability.reject(new g(t.message,t.code))},this),t.on("InvalidPDF",function(t){this.loadingTask._capability.reject(new u(t.message))},this),t.on("MissingPDF",function(t){this.loadingTask._capability.reject(new f(t.message))},this),t.on("UnexpectedResponse",function(t){this.loadingTask._capability.reject(new A(t.message,t.status))},this),t.on("UnknownError",function(t){this.loadingTask._capability.reject(new v(t.message,t.details))},this),t.on("DataLoaded",function(t){this.downloadInfoCapability.resolve(t)},this),t.on("PDFManagerReady",function(t){this.pdfDataRangeTransport&&this.pdfDataRangeTransport.transportReady()},this),t.on("StartRenderPage",function(t){if(!this.destroyed){var e=this.pageCache[t.pageIndex];e.stats.timeEnd("Page Request"),e._startRenderPage(t.transparency,t.intent)}},this),t.on("RenderPageChunk",function(t){if(!this.destroyed){this.pageCache[t.pageIndex]._renderPageChunk(t.operatorList,t.intent)}},this),t.on("commonobj",function(t){if(!this.destroyed){var e=t[0],n=t[1];if(!this.commonObjs.hasData(e))switch(n){case"Font":var r=t[2];if("error"in r){var i=r.error;I("Error during font loading: "+i),this.commonObjs.resolve(e,i);break}var a=null;j("pdfBug")&&R.FontInspector&&R.FontInspector.enabled&&(a={registerFont:function(t,e){R.FontInspector.fontAdded(t,e)}});var s=new F(r,{isEvalSuported:j("isEvalSupported"),disableFontFace:j("disableFontFace"),fontRegistry:a});this.fontLoader.bind([s],function(t){this.commonObjs.resolve(e,s)}.bind(this));break;case"FontPath":this.commonObjs.resolve(e,t[2]);break;default:x("Got unknown common object type "+n)}}},this),t.on("obj",function(t){if(!this.destroyed){var e,n=t[0],r=t[1],i=t[2],a=this.pageCache[r];if(!a.objs.hasData(n))switch(i){case"JpegStream":e=t[3],L(n,e,a.objs);break;case"Image":e=t[3],a.objs.resolve(n,e);e&&"data"in e&&e.data.length>8e6&&(a.cleanupAfterRender=!0);break;default:x("Got unknown object type "+i)}}},this),t.on("DocProgress",function(t){if(!this.destroyed){var e=this.loadingTask;e.onProgress&&e.onProgress({loaded:t.loaded,total:t.total})}},this),t.on("PageError",function(t){if(!this.destroyed){var e=this.pageCache[t.pageNum-1],n=e.intentStates[t.intent];if(n.displayReadyCapability?n.displayReadyCapability.reject(t.error):x(t.error),n.operatorList){n.operatorList.lastChunk=!0;for(var r=0;r<n.renderTasks.length;r++)n.renderTasks[r].operatorListChanged()}}},this),t.on("UnsupportedFeature",function(t){if(!this.destroyed){var e=t.featureId,n=this.loadingTask;n.onUnsupportedFeature&&n.onUnsupportedFeature(e),rt.notify(e)}},this),t.on("JpegDecode",function(t){if(this.destroyed)return Promise.reject(new Error("Worker was destroyed"));if("undefined"==typeof document)return Promise.reject(new Error('"document" is not defined.'));var e=t[0],n=t[1];return 3!==n&&1!==n?Promise.reject(new Error("Only 3 components or 1 component can be returned")):new Promise(function(t,r){var i=new Image;i.onload=function(){var e=i.width,r=i.height,a=e*r,s=4*a,o=new Uint8Array(a*n),c=document.createElement("canvas");c.width=e,c.height=r;var l=c.getContext("2d");l.drawImage(i,0,0);var h,u,d=l.getImageData(0,0,e,r).data;if(3===n)for(h=0,u=0;h<s;h+=4,u+=3)o[u]=d[h],o[u+1]=d[h+1],o[u+2]=d[h+2];else if(1===n)for(h=0,u=0;h<s;h+=4,u++)o[u]=d[h];t({data:o,width:e,height:r})},i.onerror=function(){r(new Error("JpegDecode failed to load image"))},i.src=e})},this),t.on("FetchBuiltInCMap",function(t){return this.destroyed?Promise.reject(new Error("Worker was destroyed")):this.CMapReaderFactory.fetch({name:t.name})},this)},getData:function(){return this.messageHandler.sendWithPromise("GetData",null)},getPage:function(t,e){if(!C(t)||t<=0||t>this.numPages)return Promise.reject(new Error("Invalid page request"));var n=t-1;if(n in this.pagePromises)return this.pagePromises[n];var r=this.messageHandler.sendWithPromise("GetPage",{pageIndex:n}).then(function(t){if(this.destroyed)throw new Error("Transport destroyed");var e=new K(n,t,this);return this.pageCache[n]=e,e}.bind(this));return this.pagePromises[n]=r,r},getPageIndex:function(t){return this.messageHandler.sendWithPromise("GetPageIndex",{ref:t}).catch(function(t){return Promise.reject(new Error(t))})},getAnnotations:function(t,e){return this.messageHandler.sendWithPromise("GetAnnotations",{pageIndex:t,intent:e})},getDestinations:function(){return this.messageHandler.sendWithPromise("GetDestinations",null)},getDestination:function(t){return this.messageHandler.sendWithPromise("GetDestination",{id:t})},getPageLabels:function(){return this.messageHandler.sendWithPromise("GetPageLabels",null)},getAttachments:function(){return this.messageHandler.sendWithPromise("GetAttachments",null)},getJavaScript:function(){return this.messageHandler.sendWithPromise("GetJavaScript",null)},getOutline:function(){return this.messageHandler.sendWithPromise("GetOutline",null)},getMetadata:function(){return this.messageHandler.sendWithPromise("GetMetadata",null).then(function(t){return{info:t[0],metadata:t[1]?new D(t[1]):null}})},getStats:function(){return this.messageHandler.sendWithPromise("GetStats",null)},startCleanup:function(){this.messageHandler.sendWithPromise("Cleanup",null).then(function(){for(var t=0,e=this.pageCache.length;t<e;t++){var n=this.pageCache[t];n&&n.cleanup()}this.commonObjs.clear(),this.fontLoader.clear()}.bind(this))}},t}(),tt=function(){function t(){this.objs=Object.create(null)}return t.prototype={ensureObj:function(t){if(this.objs[t])return this.objs[t];var e={capability:y(),data:null,resolved:!1};return this.objs[t]=e,e},get:function(t,e){if(e)return this.ensureObj(t).capability.promise.then(e),null;var n=this.objs[t];return n&&n.resolved||x("Requesting object that isn't resolved yet "+t),n.data},resolve:function(t,e){var n=this.ensureObj(t);n.resolved=!0,n.data=e,n.capability.resolve(e)},isResolved:function(t){var e=this.objs;return!!e[t]&&e[t].resolved},hasData:function(t){return this.isResolved(t)},getData:function(t){var e=this.objs;return e[t]&&e[t].resolved?e[t].data:null},clear:function(){this.objs=Object.create(null)}},t}(),et=function(){function t(t){this._internalRenderTask=t,this.onContinue=null}return t.prototype={get promise(){return this._internalRenderTask.capability.promise},cancel:function(){this._internalRenderTask.cancel()},then:function(t,e){return this.promise.then.apply(this.promise,arguments)}},t}(),nt=function(){function t(t,e,n,r,i,a,s){this.callback=t,this.params=e,this.objs=n,this.commonObjs=r,this.operatorListIdx=null,this.operatorList=i,this.pageNumber=a,this.canvasFactory=s,this.running=!1,this.graphicsReadyCallback=null,this.graphicsReady=!1,this.useRequestAnimationFrame=!1,this.cancelled=!1,this.capability=y(),this.task=new et(this),this._continueBound=this._continue.bind(this),this._scheduleNextBound=this._scheduleNext.bind(this),this._nextBound=this._next.bind(this)}return t.prototype={initializeGraphics:function(t){if(!this.cancelled){j("pdfBug")&&R.StepperManager&&R.StepperManager.enabled&&(this.stepper=R.StepperManager.create(this.pageNumber-1),this.stepper.init(this.operatorList),this.stepper.nextBreakPoint=this.stepper.getNextBreakPoint());var e=this.params;this.gfx=new M(e.canvasContext,this.commonObjs,this.objs,this.canvasFactory,e.imageLayer),this.gfx.beginDrawing(e.transform,e.viewport,t),this.operatorListIdx=0,this.graphicsReady=!0,this.graphicsReadyCallback&&this.graphicsReadyCallback()}},cancel:function(){this.running=!1,this.cancelled=!0,j("pdfjsNext")?this.callback(new N("Rendering cancelled, page "+this.pageNumber,"canvas")):this.callback("cancelled")},operatorListChanged:function(){if(!this.graphicsReady)return void(this.graphicsReadyCallback||(this.graphicsReadyCallback=this._continueBound));this.stepper&&this.stepper.updateOperatorList(this.operatorList),this.running||this._continue()},_continue:function(){this.running=!0,this.cancelled||(this.task.onContinue?this.task.onContinue(this._scheduleNextBound):this._scheduleNext())},_scheduleNext:function(){this.useRequestAnimationFrame&&"undefined"!=typeof window?window.requestAnimationFrame(this._nextBound):Promise.resolve(void 0).then(this._nextBound)},_next:function(){this.cancelled||(this.operatorListIdx=this.gfx.executeOperatorList(this.operatorList,this.operatorListIdx,this._continueBound,this.stepper),this.operatorListIdx===this.operatorList.argsArray.length&&(this.running=!1,this.operatorList.lastChunk&&(this.gfx.endDrawing(),this.callback())))}},t}(),rt=function(){var t=[];return{listen:function(e){S("Global UnsupportedManager.listen is used: use PDFDocumentLoadingTask.onUnsupportedFeature instead"),t.push(e)},notify:function(e){for(var n=0,r=t.length;n<r;n++)t[n](e)}}}();e.version="1.8.172",e.build="8ff1fbe7",e.getDocument=r,e.PDFDataRangeTransport=J,e.PDFWorker=Z,e.PDFDocumentProxy=Q,e.PDFPageProxy=K,e._UnsupportedManager=rt},function(t,e,n){"use strict";var r=n(0),i=r.FONT_IDENTITY_MATRIX,a=r.IDENTITY_MATRIX,s=r.ImageKind,o=r.OPS,c=r.Util,l=r.isNum,h=r.isArray,u=r.warn,d=r.createObjectURL,f={fontStyle:"normal",fontWeight:"normal",fillColor:"#000000"},p=function(){function t(t,e,n){for(var r=-1,i=e;i<n;i++){var a=255&(r^t[i]);r=r>>>8^o[a]}return-1^r}function e(e,n,r,i){var a=i,s=n.length;r[a]=s>>24&255,r[a+1]=s>>16&255,r[a+2]=s>>8&255,r[a+3]=255&s,a+=4,r[a]=255&e.charCodeAt(0),r[a+1]=255&e.charCodeAt(1),r[a+2]=255&e.charCodeAt(2),r[a+3]=255&e.charCodeAt(3),a+=4,r.set(n,a),a+=n.length;var o=t(r,i+4,a);r[a]=o>>24&255,r[a+1]=o>>16&255,r[a+2]=o>>8&255,r[a+3]=255&o}function n(t,e,n){for(var r=1,i=0,a=e;a<n;++a)r=(r+(255&t[a]))%65521,i=(i+r)%65521;return i<<16|r}function r(t,r,o){var c,l,h,u=t.width,f=t.height,p=t.data;switch(r){case s.GRAYSCALE_1BPP:l=0,c=1,h=u+7>>3;break;case s.RGB_24BPP:l=2,c=8,h=3*u;break;case s.RGBA_32BPP:l=6,c=8,h=4*u;break;default:throw new Error("invalid format")}var g,m,A=new Uint8Array((1+h)*f),v=0,b=0;for(g=0;g<f;++g)A[v++]=0,A.set(p.subarray(b,b+h),v),b+=h,v+=h;if(r===s.GRAYSCALE_1BPP)for(v=0,g=0;g<f;g++)for(v++,m=0;m<h;m++)A[v++]^=255;var y=new Uint8Array([u>>24&255,u>>16&255,u>>8&255,255&u,f>>24&255,f>>16&255,f>>8&255,255&f,c,l,0,0,0]),x=A.length,S=Math.ceil(x/65535),w=new Uint8Array(2+x+5*S+4),k=0;w[k++]=120,w[k++]=156;for(var C=0;x>65535;)w[k++]=0,w[k++]=255,w[k++]=255,w[k++]=0,w[k++]=0,w.set(A.subarray(C,C+65535),k),k+=65535,C+=65535,x-=65535;w[k++]=1,w[k++]=255&x,w[k++]=x>>8&255,w[k++]=255&~x,w[k++]=(65535&~x)>>8&255,w.set(A.subarray(C),k),k+=A.length-C;var _=n(A,0,A.length);w[k++]=_>>24&255,w[k++]=_>>16&255,w[k++]=_>>8&255,w[k++]=255&_;var T=i.length+3*a+y.length+w.length,P=new Uint8Array(T),L=0;return P.set(i,L),L+=i.length,e("IHDR",y,P,L),L+=a+y.length,e("IDATA",w,P,L),L+=a+w.length,e("IEND",new Uint8Array(0),P,L),d(P,"image/png",o)}for(var i=new Uint8Array([137,80,78,71,13,10,26,10]),a=12,o=new Int32Array(256),c=0;c<256;c++){for(var l=c,h=0;h<8;h++)l=1&l?3988292384^l>>1&2147483647:l>>1&2147483647;o[c]=l}return function(t,e){return r(t,void 0===t.kind?s.GRAYSCALE_1BPP:t.kind,e)}}(),g=function(){function t(){this.fontSizeScale=1,this.fontWeight=f.fontWeight,this.fontSize=0,this.textMatrix=a,this.fontMatrix=i,this.leading=0,this.x=0,this.y=0,this.lineX=0,this.lineY=0,this.charSpacing=0,this.wordSpacing=0,this.textHScale=1,this.textRise=0,this.fillColor=f.fillColor,this.strokeColor="#000000",this.fillAlpha=1,this.strokeAlpha=1,this.lineWidth=1,this.lineJoin="",this.lineCap="",this.miterLimit=0,this.dashArray=[],this.dashPhase=0,this.dependencies=[],this.activeClipUrl=null,this.clipGroup=null,this.maskId=""}return t.prototype={clone:function(){return Object.create(this)},setCurrentPoint:function(t,e){this.x=t,this.y=e}},t}(),m=function(){function t(t){for(var e=[],n=[],r=t.length,i=0;i<r;i++)"save"!==t[i].fn?"restore"===t[i].fn?e=n.pop():e.push(t[i]):(e.push({fnId:92,fn:"group",items:[]}),n.push(e),e=e[e.length-1].items);return e}function e(t){if(t===(0|t))return t.toString();var e=t.toFixed(10),n=e.length-1;if("0"!==e[n])return e;do{n--}while("0"===e[n]);return e.substr(0,"."===e[n]?n:n+1)}function n(t){if(0===t[4]&&0===t[5]){if(0===t[1]&&0===t[2])return 1===t[0]&&1===t[3]?"":"scale("+e(t[0])+" "+e(t[3])+")";if(t[0]===t[3]&&t[1]===-t[2]){return"rotate("+e(180*Math.acos(t[0])/Math.PI)+")"}}else if(1===t[0]&&0===t[1]&&0===t[2]&&1===t[3])return"translate("+e(t[4])+" "+e(t[5])+")";return"matrix("+e(t[0])+" "+e(t[1])+" "+e(t[2])+" "+e(t[3])+" "+e(t[4])+" "+e(t[5])+")"}function r(t,e,n){this.current=new g,this.transformMatrix=a,this.transformStack=[],this.extraStack=[],this.commonObjs=t,this.objs=e,this.pendingEOFill=!1,this.embedFonts=!1,this.embeddedFonts=Object.create(null),this.cssStyle=null,this.forceDataSchema=!!n}var s="http://www.w3.org/2000/svg",m="http://www.w3.org/1999/xlink",A=["butt","round","square"],v=["miter","round","bevel"],b=0,y=0;return r.prototype={save:function(){this.transformStack.push(this.transformMatrix);var t=this.current;this.extraStack.push(t),this.current=t.clone()},restore:function(){this.transformMatrix=this.transformStack.pop(),this.current=this.extraStack.pop(),this.tgrp=null},group:function(t){this.save(),this.executeOpTree(t),this.restore()},loadDependencies:function(t){
+for(var e=t.fnArray,n=e.length,r=t.argsArray,i=this,a=0;a<n;a++)if(o.dependency===e[a])for(var s=r[a],c=0,l=s.length;c<l;c++){var h,u=s[c],d="g_"===u.substring(0,2);h=d?new Promise(function(t){i.commonObjs.get(u,t)}):new Promise(function(t){i.objs.get(u,t)}),this.current.dependencies.push(h)}return Promise.all(this.current.dependencies)},transform:function(t,e,n,r,i,a){var s=[t,e,n,r,i,a];this.transformMatrix=c.transform(this.transformMatrix,s),this.tgrp=null},getSVG:function(t,e){this.viewport=e;var n=this._initialize(e);return this.loadDependencies(t).then(function(){this.transformMatrix=a;var e=this.convertOpList(t);return this.executeOpTree(e),n}.bind(this))},convertOpList:function(e){var n=e.argsArray,r=e.fnArray,i=r.length,a=[],s=[];for(var c in o)a[o[c]]=c;for(var l=0;l<i;l++){var h=r[l];s.push({fnId:h,fn:a[h],args:n[l]})}return t(s)},executeOpTree:function(t){for(var e=t.length,n=0;n<e;n++){var r=t[n].fn,i=t[n].fnId,a=t[n].args;switch(0|i){case o.beginText:this.beginText();break;case o.setLeading:this.setLeading(a);break;case o.setLeadingMoveText:this.setLeadingMoveText(a[0],a[1]);break;case o.setFont:this.setFont(a);break;case o.showText:case o.showSpacedText:this.showText(a[0]);break;case o.endText:this.endText();break;case o.moveText:this.moveText(a[0],a[1]);break;case o.setCharSpacing:this.setCharSpacing(a[0]);break;case o.setWordSpacing:this.setWordSpacing(a[0]);break;case o.setHScale:this.setHScale(a[0]);break;case o.setTextMatrix:this.setTextMatrix(a[0],a[1],a[2],a[3],a[4],a[5]);break;case o.setLineWidth:this.setLineWidth(a[0]);break;case o.setLineJoin:this.setLineJoin(a[0]);break;case o.setLineCap:this.setLineCap(a[0]);break;case o.setMiterLimit:this.setMiterLimit(a[0]);break;case o.setFillRGBColor:this.setFillRGBColor(a[0],a[1],a[2]);break;case o.setStrokeRGBColor:this.setStrokeRGBColor(a[0],a[1],a[2]);break;case o.setDash:this.setDash(a[0],a[1]);break;case o.setGState:this.setGState(a[0]);break;case o.fill:this.fill();break;case o.eoFill:this.eoFill();break;case o.stroke:this.stroke();break;case o.fillStroke:this.fillStroke();break;case o.eoFillStroke:this.eoFillStroke();break;case o.clip:this.clip("nonzero");break;case o.eoClip:this.clip("evenodd");break;case o.paintSolidColorImageMask:this.paintSolidColorImageMask();break;case o.paintJpegXObject:this.paintJpegXObject(a[0],a[1],a[2]);break;case o.paintImageXObject:this.paintImageXObject(a[0]);break;case o.paintInlineImageXObject:this.paintInlineImageXObject(a[0]);break;case o.paintImageMaskXObject:this.paintImageMaskXObject(a[0]);break;case o.paintFormXObjectBegin:this.paintFormXObjectBegin(a[0],a[1]);break;case o.paintFormXObjectEnd:this.paintFormXObjectEnd();break;case o.closePath:this.closePath();break;case o.closeStroke:this.closeStroke();break;case o.closeFillStroke:this.closeFillStroke();break;case o.nextLine:this.nextLine();break;case o.transform:this.transform(a[0],a[1],a[2],a[3],a[4],a[5]);break;case o.constructPath:this.constructPath(a[0],a[1]);break;case o.endPath:this.endPath();break;case 92:this.group(t[n].items);break;default:u("Unimplemented operator "+r)}}},setWordSpacing:function(t){this.current.wordSpacing=t},setCharSpacing:function(t){this.current.charSpacing=t},nextLine:function(){this.moveText(0,this.current.leading)},setTextMatrix:function(t,n,r,i,a,o){var c=this.current;this.current.textMatrix=this.current.lineMatrix=[t,n,r,i,a,o],this.current.x=this.current.lineX=0,this.current.y=this.current.lineY=0,c.xcoords=[],c.tspan=document.createElementNS(s,"svg:tspan"),c.tspan.setAttributeNS(null,"font-family",c.fontFamily),c.tspan.setAttributeNS(null,"font-size",e(c.fontSize)+"px"),c.tspan.setAttributeNS(null,"y",e(-c.y)),c.txtElement=document.createElementNS(s,"svg:text"),c.txtElement.appendChild(c.tspan)},beginText:function(){this.current.x=this.current.lineX=0,this.current.y=this.current.lineY=0,this.current.textMatrix=a,this.current.lineMatrix=a,this.current.tspan=document.createElementNS(s,"svg:tspan"),this.current.txtElement=document.createElementNS(s,"svg:text"),this.current.txtgrp=document.createElementNS(s,"svg:g"),this.current.xcoords=[]},moveText:function(t,n){var r=this.current;this.current.x=this.current.lineX+=t,this.current.y=this.current.lineY+=n,r.xcoords=[],r.tspan=document.createElementNS(s,"svg:tspan"),r.tspan.setAttributeNS(null,"font-family",r.fontFamily),r.tspan.setAttributeNS(null,"font-size",e(r.fontSize)+"px"),r.tspan.setAttributeNS(null,"y",e(-r.y))},showText:function(t){var r=this.current,i=r.font,a=r.fontSize;if(0!==a){var s,o=r.charSpacing,c=r.wordSpacing,h=r.fontDirection,u=r.textHScale*h,d=t.length,p=i.vertical,g=a*r.fontMatrix[0],m=0;for(s=0;s<d;++s){var A=t[s];if(null!==A)if(l(A))m+=-A*a*.001;else{r.xcoords.push(r.x+m*u);var v=A.width,b=A.fontChar,y=v*g+o*h;m+=y,r.tspan.textContent+=b}else m+=h*c}p?r.y-=m*u:r.x+=m*u,r.tspan.setAttributeNS(null,"x",r.xcoords.map(e).join(" ")),r.tspan.setAttributeNS(null,"y",e(-r.y)),r.tspan.setAttributeNS(null,"font-family",r.fontFamily),r.tspan.setAttributeNS(null,"font-size",e(r.fontSize)+"px"),r.fontStyle!==f.fontStyle&&r.tspan.setAttributeNS(null,"font-style",r.fontStyle),r.fontWeight!==f.fontWeight&&r.tspan.setAttributeNS(null,"font-weight",r.fontWeight),r.fillColor!==f.fillColor&&r.tspan.setAttributeNS(null,"fill",r.fillColor),r.txtElement.setAttributeNS(null,"transform",n(r.textMatrix)+" scale(1, -1)"),r.txtElement.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),r.txtElement.appendChild(r.tspan),r.txtgrp.appendChild(r.txtElement),this._ensureTransformGroup().appendChild(r.txtElement)}},setLeadingMoveText:function(t,e){this.setLeading(-e),this.moveText(t,e)},addFontStyle:function(t){this.cssStyle||(this.cssStyle=document.createElementNS(s,"svg:style"),this.cssStyle.setAttributeNS(null,"type","text/css"),this.defs.appendChild(this.cssStyle));var e=d(t.data,t.mimetype,this.forceDataSchema);this.cssStyle.textContent+='@font-face { font-family: "'+t.loadedName+'"; src: url('+e+"); }\n"},setFont:function(t){var n=this.current,r=this.commonObjs.get(t[0]),a=t[1];this.current.font=r,this.embedFonts&&r.data&&!this.embeddedFonts[r.loadedName]&&(this.addFontStyle(r),this.embeddedFonts[r.loadedName]=r),n.fontMatrix=r.fontMatrix?r.fontMatrix:i;var o=r.black?r.bold?"bolder":"bold":r.bold?"bold":"normal",c=r.italic?"italic":"normal";a<0?(a=-a,n.fontDirection=-1):n.fontDirection=1,n.fontSize=a,n.fontFamily=r.loadedName,n.fontWeight=o,n.fontStyle=c,n.tspan=document.createElementNS(s,"svg:tspan"),n.tspan.setAttributeNS(null,"y",e(-n.y)),n.xcoords=[]},endText:function(){},setLineWidth:function(t){this.current.lineWidth=t},setLineCap:function(t){this.current.lineCap=A[t]},setLineJoin:function(t){this.current.lineJoin=v[t]},setMiterLimit:function(t){this.current.miterLimit=t},setStrokeRGBColor:function(t,e,n){var r=c.makeCssRgb(t,e,n);this.current.strokeColor=r},setFillRGBColor:function(t,e,n){var r=c.makeCssRgb(t,e,n);this.current.fillColor=r,this.current.tspan=document.createElementNS(s,"svg:tspan"),this.current.xcoords=[]},setDash:function(t,e){this.current.dashArray=t,this.current.dashPhase=e},constructPath:function(t,n){var r=this.current,i=r.x,a=r.y;r.path=document.createElementNS(s,"svg:path");for(var c=[],l=t.length,h=0,u=0;h<l;h++)switch(0|t[h]){case o.rectangle:i=n[u++],a=n[u++];var d=n[u++],f=n[u++],p=i+d,g=a+f;c.push("M",e(i),e(a),"L",e(p),e(a),"L",e(p),e(g),"L",e(i),e(g),"Z");break;case o.moveTo:i=n[u++],a=n[u++],c.push("M",e(i),e(a));break;case o.lineTo:i=n[u++],a=n[u++],c.push("L",e(i),e(a));break;case o.curveTo:i=n[u+4],a=n[u+5],c.push("C",e(n[u]),e(n[u+1]),e(n[u+2]),e(n[u+3]),e(i),e(a)),u+=6;break;case o.curveTo2:i=n[u+2],a=n[u+3],c.push("C",e(i),e(a),e(n[u]),e(n[u+1]),e(n[u+2]),e(n[u+3])),u+=4;break;case o.curveTo3:i=n[u+2],a=n[u+3],c.push("C",e(n[u]),e(n[u+1]),e(i),e(a),e(i),e(a)),u+=4;break;case o.closePath:c.push("Z")}r.path.setAttributeNS(null,"d",c.join(" ")),r.path.setAttributeNS(null,"stroke-miterlimit",e(r.miterLimit)),r.path.setAttributeNS(null,"stroke-linecap",r.lineCap),r.path.setAttributeNS(null,"stroke-linejoin",r.lineJoin),r.path.setAttributeNS(null,"stroke-width",e(r.lineWidth)+"px"),r.path.setAttributeNS(null,"stroke-dasharray",r.dashArray.map(e).join(" ")),r.path.setAttributeNS(null,"stroke-dashoffset",e(r.dashPhase)+"px"),r.path.setAttributeNS(null,"fill","none"),this._ensureTransformGroup().appendChild(r.path),r.element=r.path,r.setCurrentPoint(i,a)},endPath:function(){},clip:function(t){var e=this.current,r="clippath"+b;b++;var i=document.createElementNS(s,"svg:clipPath");i.setAttributeNS(null,"id",r),i.setAttributeNS(null,"transform",n(this.transformMatrix));var a=e.element.cloneNode();"evenodd"===t?a.setAttributeNS(null,"clip-rule","evenodd"):a.setAttributeNS(null,"clip-rule","nonzero"),i.appendChild(a),this.defs.appendChild(i),e.activeClipUrl&&(e.clipGroup=null,this.extraStack.forEach(function(t){t.clipGroup=null})),e.activeClipUrl="url(#"+r+")",this.tgrp=null},closePath:function(){var t=this.current,e=t.path.getAttributeNS(null,"d");e+="Z",t.path.setAttributeNS(null,"d",e)},setLeading:function(t){this.current.leading=-t},setTextRise:function(t){this.current.textRise=t},setHScale:function(t){this.current.textHScale=t/100},setGState:function(t){for(var e=0,n=t.length;e<n;e++){var r=t[e],i=r[0],a=r[1];switch(i){case"LW":this.setLineWidth(a);break;case"LC":this.setLineCap(a);break;case"LJ":this.setLineJoin(a);break;case"ML":this.setMiterLimit(a);break;case"D":this.setDash(a[0],a[1]);break;case"Font":this.setFont(a);break;default:u("Unimplemented graphic state "+i)}}},fill:function(){var t=this.current;t.element.setAttributeNS(null,"fill",t.fillColor)},stroke:function(){var t=this.current;t.element.setAttributeNS(null,"stroke",t.strokeColor),t.element.setAttributeNS(null,"fill","none")},eoFill:function(){var t=this.current;t.element.setAttributeNS(null,"fill",t.fillColor),t.element.setAttributeNS(null,"fill-rule","evenodd")},fillStroke:function(){this.stroke(),this.fill()},eoFillStroke:function(){this.current.element.setAttributeNS(null,"fill-rule","evenodd"),this.fillStroke()},closeStroke:function(){this.closePath(),this.stroke()},closeFillStroke:function(){this.closePath(),this.fillStroke()},paintSolidColorImageMask:function(){var t=this.current,e=document.createElementNS(s,"svg:rect");e.setAttributeNS(null,"x","0"),e.setAttributeNS(null,"y","0"),e.setAttributeNS(null,"width","1px"),e.setAttributeNS(null,"height","1px"),e.setAttributeNS(null,"fill",t.fillColor),this._ensureTransformGroup().appendChild(e)},paintJpegXObject:function(t,n,r){var i=this.objs.get(t),a=document.createElementNS(s,"svg:image");a.setAttributeNS(m,"xlink:href",i.src),a.setAttributeNS(null,"width",i.width+"px"),a.setAttributeNS(null,"height",i.height+"px"),a.setAttributeNS(null,"x","0"),a.setAttributeNS(null,"y",e(-r)),a.setAttributeNS(null,"transform","scale("+e(1/n)+" "+e(-1/r)+")"),this._ensureTransformGroup().appendChild(a)},paintImageXObject:function(t){var e=this.objs.get(t);if(!e)return void u("Dependent image isn't ready yet");this.paintInlineImageXObject(e)},paintInlineImageXObject:function(t,n){var r=t.width,i=t.height,a=p(t,this.forceDataSchema),o=document.createElementNS(s,"svg:rect");o.setAttributeNS(null,"x","0"),o.setAttributeNS(null,"y","0"),o.setAttributeNS(null,"width",e(r)),o.setAttributeNS(null,"height",e(i)),this.current.element=o,this.clip("nonzero");var c=document.createElementNS(s,"svg:image");c.setAttributeNS(m,"xlink:href",a),c.setAttributeNS(null,"x","0"),c.setAttributeNS(null,"y",e(-i)),c.setAttributeNS(null,"width",e(r)+"px"),c.setAttributeNS(null,"height",e(i)+"px"),c.setAttributeNS(null,"transform","scale("+e(1/r)+" "+e(-1/i)+")"),n?n.appendChild(c):this._ensureTransformGroup().appendChild(c)},paintImageMaskXObject:function(t){var n=this.current,r=t.width,i=t.height,a=n.fillColor;n.maskId="mask"+y++;var o=document.createElementNS(s,"svg:mask");o.setAttributeNS(null,"id",n.maskId);var c=document.createElementNS(s,"svg:rect");c.setAttributeNS(null,"x","0"),c.setAttributeNS(null,"y","0"),c.setAttributeNS(null,"width",e(r)),c.setAttributeNS(null,"height",e(i)),c.setAttributeNS(null,"fill",a),c.setAttributeNS(null,"mask","url(#"+n.maskId+")"),this.defs.appendChild(o),this._ensureTransformGroup().appendChild(c),this.paintInlineImageXObject(t,o)},paintFormXObjectBegin:function(t,n){if(h(t)&&6===t.length&&this.transform(t[0],t[1],t[2],t[3],t[4],t[5]),h(n)&&4===n.length){var r=n[2]-n[0],i=n[3]-n[1],a=document.createElementNS(s,"svg:rect");a.setAttributeNS(null,"x",n[0]),a.setAttributeNS(null,"y",n[1]),a.setAttributeNS(null,"width",e(r)),a.setAttributeNS(null,"height",e(i)),this.current.element=a,this.clip("nonzero"),this.endPath()}},paintFormXObjectEnd:function(){},_initialize:function(t){var e=document.createElementNS(s,"svg:svg");e.setAttributeNS(null,"version","1.1"),e.setAttributeNS(null,"width",t.width+"px"),e.setAttributeNS(null,"height",t.height+"px"),e.setAttributeNS(null,"preserveAspectRatio","none"),e.setAttributeNS(null,"viewBox","0 0 "+t.width+" "+t.height);var r=document.createElementNS(s,"svg:defs");e.appendChild(r),this.defs=r;var i=document.createElementNS(s,"svg:g");return i.setAttributeNS(null,"transform",n(t.transform)),e.appendChild(i),this.svg=i,e},_ensureClipGroup:function(){if(!this.current.clipGroup){var t=document.createElementNS(s,"svg:g");t.setAttributeNS(null,"clip-path",this.current.activeClipUrl),this.svg.appendChild(t),this.current.clipGroup=t}return this.current.clipGroup},_ensureTransformGroup:function(){return this.tgrp||(this.tgrp=document.createElementNS(s,"svg:g"),this.tgrp.setAttributeNS(null,"transform",n(this.transformMatrix)),this.current.activeClipUrl?this._ensureClipGroup().appendChild(this.tgrp):this.svg.appendChild(this.tgrp)),this.tgrp}},r}();e.SVGGraphics=m},function(t,e,n){"use strict";var r=n(0),i=n(1),a=r.Util,s=r.createPromiseCapability,o=i.CustomStyle,c=i.getDefaultSetting,l=function(){function t(t){return!f.test(t)}function e(e,n,r){var i=document.createElement("div"),s={style:null,angle:0,canvasWidth:0,isWhitespace:!1,originalTransform:null,paddingBottom:0,paddingLeft:0,paddingRight:0,paddingTop:0,scale:1};if(e._textDivs.push(i),t(n.str))return s.isWhitespace=!0,void e._textDivProperties.set(i,s);var o=a.transform(e._viewport.transform,n.transform),l=Math.atan2(o[1],o[0]),h=r[n.fontName];h.vertical&&(l+=Math.PI/2);var u=Math.sqrt(o[2]*o[2]+o[3]*o[3]),d=u;h.ascent?d=h.ascent*d:h.descent&&(d=(1+h.descent)*d);var f,g;if(0===l?(f=o[4],g=o[5]-d):(f=o[4]+d*Math.sin(l),g=o[5]-d*Math.cos(l)),p[1]=f,p[3]=g,p[5]=u,p[7]=h.fontFamily,s.style=p.join(""),i.setAttribute("style",s.style),i.textContent=n.str,c("pdfBug")&&(i.dataset.fontName=n.fontName),0!==l&&(s.angle=l*(180/Math.PI)),n.str.length>1&&(h.vertical?s.canvasWidth=n.height*e._viewport.scale:s.canvasWidth=n.width*e._viewport.scale),e._textDivProperties.set(i,s),e._enhanceTextSelection){var m=1,A=0;0!==l&&(m=Math.cos(l),A=Math.sin(l));var v,b,y=(h.vertical?n.height:n.width)*e._viewport.scale,x=u;0!==l?(v=[m,A,-A,m,f,g],b=a.getAxialAlignedBoundingBox([0,0,y,x],v)):b=[f,g,f+y,g+x],e._bounds.push({left:b[0],top:b[1],right:b[2],bottom:b[3],div:i,size:[y,x],m:v})}}function n(t){if(!t._canceled){var e=t._container,n=t._textDivs,r=t._capability,i=n.length;if(i>d)return t._renderingDone=!0,void r.resolve();var a=document.createElement("canvas");a.mozOpaque=!0;for(var s,c,l=a.getContext("2d",{alpha:!1}),h=0;h<i;h++){var u=n[h],f=t._textDivProperties.get(u);if(!f.isWhitespace){var p=u.style.fontSize,g=u.style.fontFamily;p===s&&g===c||(l.font=p+" "+g,s=p,c=g);var m=l.measureText(u.textContent).width;e.appendChild(u);var A="";0!==f.canvasWidth&&m>0&&(f.scale=f.canvasWidth/m,A="scaleX("+f.scale+")"),0!==f.angle&&(A="rotate("+f.angle+"deg) "+A),""!==A&&(f.originalTransform=A,o.setProp("transform",u,A)),t._textDivProperties.set(u,f)}}t._renderingDone=!0,r.resolve()}}function r(t){for(var e=t._bounds,n=t._viewport,r=i(n.width,n.height,e),s=0;s<r.length;s++){var o=e[s].div,c=t._textDivProperties.get(o);if(0!==c.angle){var l=r[s],h=e[s],u=h.m,d=u[0],f=u[1],p=[[0,0],[0,h.size[1]],[h.size[0],0],h.size],g=new Float64Array(64);p.forEach(function(t,e){var n=a.applyTransform(t,u);g[e+0]=d&&(l.left-n[0])/d,g[e+4]=f&&(l.top-n[1])/f,g[e+8]=d&&(l.right-n[0])/d,g[e+12]=f&&(l.bottom-n[1])/f,g[e+16]=f&&(l.left-n[0])/-f,g[e+20]=d&&(l.top-n[1])/d,g[e+24]=f&&(l.right-n[0])/-f,g[e+28]=d&&(l.bottom-n[1])/d,g[e+32]=d&&(l.left-n[0])/-d,g[e+36]=f&&(l.top-n[1])/-f,g[e+40]=d&&(l.right-n[0])/-d,g[e+44]=f&&(l.bottom-n[1])/-f,g[e+48]=f&&(l.left-n[0])/f,g[e+52]=d&&(l.top-n[1])/-d,g[e+56]=f&&(l.right-n[0])/f,g[e+60]=d&&(l.bottom-n[1])/-d});var m=function(t,e,n){for(var r=0,i=0;i<n;i++){var a=t[e++];a>0&&(r=r?Math.min(a,r):a)}return r},A=1+Math.min(Math.abs(d),Math.abs(f));c.paddingLeft=m(g,32,16)/A,c.paddingTop=m(g,48,16)/A,c.paddingRight=m(g,0,16)/A,c.paddingBottom=m(g,16,16)/A,t._textDivProperties.set(o,c)}else c.paddingLeft=e[s].left-r[s].left,c.paddingTop=e[s].top-r[s].top,c.paddingRight=r[s].right-e[s].right,c.paddingBottom=r[s].bottom-e[s].bottom,t._textDivProperties.set(o,c)}}function i(t,e,n){var r=n.map(function(t,e){return{x1:t.left,y1:t.top,x2:t.right,y2:t.bottom,index:e,x1New:void 0,x2New:void 0}});l(t,r);var i=new Array(n.length);return r.forEach(function(t){var e=t.index;i[e]={left:t.x1New,top:0,right:t.x2New,bottom:0}}),n.map(function(e,n){var a=i[n],s=r[n];s.x1=e.top,s.y1=t-a.right,s.x2=e.bottom,s.y2=t-a.left,s.index=n,s.x1New=void 0,s.x2New=void 0}),l(e,r),r.forEach(function(t){var e=t.index;i[e].top=t.x1New,i[e].bottom=t.x2New}),i}function l(t,e){e.sort(function(t,e){return t.x1-e.x1||t.index-e.index});var n={x1:-1/0,y1:-1/0,x2:0,y2:1/0,index:-1,x1New:0,x2New:0},r=[{start:-1/0,end:1/0,boundary:n}];e.forEach(function(t){for(var e=0;e<r.length&&r[e].end<=t.y1;)e++;for(var n=r.length-1;n>=0&&r[n].start>=t.y2;)n--;var i,a,s,o,c=-1/0;for(s=e;s<=n;s++){i=r[s],a=i.boundary;var l;l=a.x2>t.x1?a.index>t.index?a.x1New:t.x1:void 0===a.x2New?(a.x2+t.x1)/2:a.x2New,l>c&&(c=l)}for(t.x1New=c,s=e;s<=n;s++)i=r[s],a=i.boundary,void 0===a.x2New?a.x2>t.x1?a.index>t.index&&(a.x2New=a.x2):a.x2New=c:a.x2New>c&&(a.x2New=Math.max(c,a.x2));var h=[],u=null;for(s=e;s<=n;s++){i=r[s],a=i.boundary;var d=a.x2>t.x2?a:t;u===d?h[h.length-1].end=i.end:(h.push({start:i.start,end:i.end,boundary:d}),u=d)}for(r[e].start<t.y1&&(h[0].start=t.y1,h.unshift({start:r[e].start,end:t.y1,boundary:r[e].boundary})),t.y2<r[n].end&&(h[h.length-1].end=t.y2,h.push({start:t.y2,end:r[n].end,boundary:r[n].boundary})),s=e;s<=n;s++)if(i=r[s],a=i.boundary,void 0===a.x2New){var f=!1;for(o=e-1;!f&&o>=0&&r[o].start>=a.y1;o--)f=r[o].boundary===a;for(o=n+1;!f&&o<r.length&&r[o].end<=a.y2;o++)f=r[o].boundary===a;for(o=0;!f&&o<h.length;o++)f=h[o].boundary===a;f||(a.x2New=c)}Array.prototype.splice.apply(r,[e,n-e+1].concat(h))}),r.forEach(function(e){var n=e.boundary;void 0===n.x2New&&(n.x2New=Math.max(t,n.x2))})}function h(t,e,n,r,i){this._textContent=t,this._container=e,this._viewport=n,this._textDivs=r||[],this._textDivProperties=new WeakMap,this._renderingDone=!1,this._canceled=!1,this._capability=s(),this._renderTimer=null,this._bounds=[],this._enhanceTextSelection=!!i}function u(t){var e=new h(t.textContent,t.container,t.viewport,t.textDivs,t.enhanceTextSelection);return e._render(t.timeout),e}var d=1e5,f=/\S/,p=["left: ",0,"px; top: ",0,"px; font-size: ",0,"px; font-family: ","",";"];return h.prototype={get promise(){return this._capability.promise},cancel:function(){this._canceled=!0,null!==this._renderTimer&&(clearTimeout(this._renderTimer),this._renderTimer=null),this._capability.reject("canceled")},_render:function(t){for(var r=this._textContent.items,i=this._textContent.styles,a=0,s=r.length;a<s;a++)e(this,r[a],i);if(t){var o=this;this._renderTimer=setTimeout(function(){n(o),o._renderTimer=null},t)}else n(this)},expandTextDivs:function(t){if(this._enhanceTextSelection&&this._renderingDone){null!==this._bounds&&(r(this),this._bounds=null);for(var e=0,n=this._textDivs.length;e<n;e++){var i=this._textDivs[e],a=this._textDivProperties.get(i);if(!a.isWhitespace)if(t){var s="",c="";1!==a.scale&&(s="scaleX("+a.scale+")"),0!==a.angle&&(s="rotate("+a.angle+"deg) "+s),0!==a.paddingLeft&&(c+=" padding-left: "+a.paddingLeft/a.scale+"px;",s+=" translateX("+-a.paddingLeft/a.scale+"px)"),0!==a.paddingTop&&(c+=" padding-top: "+a.paddingTop+"px;",s+=" translateY("+-a.paddingTop+"px)"),0!==a.paddingRight&&(c+=" padding-right: "+a.paddingRight/a.scale+"px;"),0!==a.paddingBottom&&(c+=" padding-bottom: "+a.paddingBottom+"px;"),""!==c&&i.setAttribute("style",a.style+c),""!==s&&o.setProp("transform",i,s)}else i.style.padding=0,o.setProp("transform",i,a.originalTransform||"")}}}},u}();e.renderTextLayer=l},function(t,e,n){"use strict";var r;r=function(){return this}();try{r=r||Function("return this")()||(0,eval)("this")}catch(t){"object"==typeof window&&(r=window)}t.exports=r},function(t,e,n){"use strict";function r(t){return t.replace(/>\\376\\377([^<]+)/g,function(t,e){for(var n=e.replace(/\\([0-3])([0-7])([0-7])/g,function(t,e,n,r){return String.fromCharCode(64*e+8*n+1*r)}),r="",i=0;i<n.length;i+=2){var a=256*n.charCodeAt(i)+n.charCodeAt(i+1);r+=a>=32&&a<127&&60!==a&&62!==a&&38!==a?String.fromCharCode(a):"&#x"+(65536+a).toString(16).substring(1)+";"}return">"+r})}function i(t){if("string"==typeof t){t=r(t);t=(new DOMParser).parseFromString(t,"application/xml")}else t instanceof Document||s("Metadata: Invalid metadata object");this.metaDocument=t,this.metadata=Object.create(null),this.parse()}var a=n(0),s=a.error;i.prototype={parse:function(){var t=this.metaDocument,e=t.documentElement;if("rdf:rdf"!==e.nodeName.toLowerCase())for(e=e.firstChild;e&&"rdf:rdf"!==e.nodeName.toLowerCase();)e=e.nextSibling;var n=e?e.nodeName.toLowerCase():null;if(e&&"rdf:rdf"===n&&e.hasChildNodes()){var r,i,a,s,o,c,l,h=e.childNodes;for(s=0,c=h.length;s<c;s++)if(r=h[s],"rdf:description"===r.nodeName.toLowerCase())for(o=0,l=r.childNodes.length;o<l;o++)"#text"!==r.childNodes[o].nodeName.toLowerCase()&&(i=r.childNodes[o],a=i.nodeName.toLowerCase(),this.metadata[a]=i.textContent.trim())}},get:function(t){return this.metadata[t]||null},has:function(t){return void 0!==this.metadata[t]}},e.Metadata=i},function(t,e,n){"use strict";var r=n(0),i=n(1),a=r.shadow,s=i.getDefaultSetting,o=function(){function t(t,e,n){var r=t.createShader(n);if(t.shaderSource(r,e),t.compileShader(r),!t.getShaderParameter(r,t.COMPILE_STATUS)){var i=t.getShaderInfoLog(r);throw new Error("Error during shader compilation: "+i)}return r}function e(e,n){return t(e,n,e.VERTEX_SHADER)}function n(e,n){return t(e,n,e.FRAGMENT_SHADER)}function r(t,e){for(var n=t.createProgram(),r=0,i=e.length;r<i;++r)t.attachShader(n,e[r]);if(t.linkProgram(n),!t.getProgramParameter(n,t.LINK_STATUS)){var a=t.getProgramInfoLog(n);throw new Error("Error during program linking: "+a)}return n}function i(t,e,n){t.activeTexture(n);var r=t.createTexture();return t.bindTexture(t.TEXTURE_2D,r),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.NEAREST),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.NEAREST),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,e),r}function o(){f||(p=document.createElement("canvas"),f=p.getContext("webgl",{premultipliedalpha:!1}))}function c(){var t,i;o(),t=p,p=null,i=f,f=null;var a=e(i,g),s=n(i,m),c=r(i,[a,s]);i.useProgram(c);var l={};l.gl=i,l.canvas=t,l.resolutionLocation=i.getUniformLocation(c,"u_resolution"),l.positionLocation=i.getAttribLocation(c,"a_position"),l.backdropLocation=i.getUniformLocation(c,"u_backdrop"),l.subtypeLocation=i.getUniformLocation(c,"u_subtype");var h=i.getAttribLocation(c,"a_texCoord"),u=i.getUniformLocation(c,"u_image"),d=i.getUniformLocation(c,"u_mask"),v=i.createBuffer();i.bindBuffer(i.ARRAY_BUFFER,v),i.bufferData(i.ARRAY_BUFFER,new Float32Array([0,0,1,0,0,1,0,1,1,0,1,1]),i.STATIC_DRAW),i.enableVertexAttribArray(h),i.vertexAttribPointer(h,2,i.FLOAT,!1,0,0),i.uniform1i(u,0),i.uniform1i(d,1),A=l}function l(t,e,n){var r=t.width,a=t.height;A||c();var s=A,o=s.canvas,l=s.gl;o.width=r,o.height=a,l.viewport(0,0,l.drawingBufferWidth,l.drawingBufferHeight),l.uniform2f(s.resolutionLocation,r,a),n.backdrop?l.uniform4f(s.resolutionLocation,n.backdrop[0],n.backdrop[1],n.backdrop[2],1):l.uniform4f(s.resolutionLocation,0,0,0,0),l.uniform1i(s.subtypeLocation,"Luminosity"===n.subtype?1:0);var h=i(l,t,l.TEXTURE0),u=i(l,e,l.TEXTURE1),d=l.createBuffer();return l.bindBuffer(l.ARRAY_BUFFER,d),l.bufferData(l.ARRAY_BUFFER,new Float32Array([0,0,r,0,0,a,0,a,r,0,r,a]),l.STATIC_DRAW),l.enableVertexAttribArray(s.positionLocation),l.vertexAttribPointer(s.positionLocation,2,l.FLOAT,!1,0,0),l.clearColor(0,0,0,0),l.enable(l.BLEND),l.blendFunc(l.ONE,l.ONE_MINUS_SRC_ALPHA),l.clear(l.COLOR_BUFFER_BIT),l.drawArrays(l.TRIANGLES,0,6),l.flush(),l.deleteTexture(h),l.deleteTexture(u),l.deleteBuffer(d),o}function h(){var t,i;o(),t=p,p=null,i=f,f=null;var a=e(i,v),s=n(i,b),c=r(i,[a,s]);i.useProgram(c);var l={};l.gl=i,l.canvas=t,l.resolutionLocation=i.getUniformLocation(c,"u_resolution"),l.scaleLocation=i.getUniformLocation(c,"u_scale"),l.offsetLocation=i.getUniformLocation(c,"u_offset"),l.positionLocation=i.getAttribLocation(c,"a_position"),l.colorLocation=i.getAttribLocation(c,"a_color"),y=l}function u(t,e,n,r,i){y||h();var a=y,s=a.canvas,o=a.gl;s.width=t,s.height=e,o.viewport(0,0,o.drawingBufferWidth,o.drawingBufferHeight),o.uniform2f(a.resolutionLocation,t,e);var c,l,u,d=0;for(c=0,l=r.length;c<l;c++)switch(r[c].type){case"lattice":u=r[c].coords.length/r[c].verticesPerRow|0,d+=(u-1)*(r[c].verticesPerRow-1)*6;break;case"triangles":d+=r[c].coords.length}var f=new Float32Array(2*d),p=new Uint8Array(3*d),g=i.coords,m=i.colors,A=0,v=0;for(c=0,l=r.length;c<l;c++){var b=r[c],x=b.coords,S=b.colors;switch(b.type){case"lattice":var w=b.verticesPerRow;u=x.length/w|0;for(var k=1;k<u;k++)for(var C=k*w+1,_=1;_<w;_++,C++)f[A]=g[x[C-w-1]],f[A+1]=g[x[C-w-1]+1],f[A+2]=g[x[C-w]],f[A+3]=g[x[C-w]+1],f[A+4]=g[x[C-1]],f[A+5]=g[x[C-1]+1],p[v]=m[S[C-w-1]],p[v+1]=m[S[C-w-1]+1],p[v+2]=m[S[C-w-1]+2],p[v+3]=m[S[C-w]],p[v+4]=m[S[C-w]+1],p[v+5]=m[S[C-w]+2],p[v+6]=m[S[C-1]],p[v+7]=m[S[C-1]+1],p[v+8]=m[S[C-1]+2],f[A+6]=f[A+2],f[A+7]=f[A+3],f[A+8]=f[A+4],f[A+9]=f[A+5],f[A+10]=g[x[C]],f[A+11]=g[x[C]+1],p[v+9]=p[v+3],p[v+10]=p[v+4],p[v+11]=p[v+5],p[v+12]=p[v+6],p[v+13]=p[v+7],p[v+14]=p[v+8],p[v+15]=m[S[C]],p[v+16]=m[S[C]+1],p[v+17]=m[S[C]+2],A+=12,v+=18;break;case"triangles":for(var T=0,P=x.length;T<P;T++)f[A]=g[x[T]],f[A+1]=g[x[T]+1],p[v]=m[S[T]],p[v+1]=m[S[T]+1],p[v+2]=m[S[T]+2],A+=2,v+=3}}n?o.clearColor(n[0]/255,n[1]/255,n[2]/255,1):o.clearColor(0,0,0,0),o.clear(o.COLOR_BUFFER_BIT);var L=o.createBuffer();o.bindBuffer(o.ARRAY_BUFFER,L),o.bufferData(o.ARRAY_BUFFER,f,o.STATIC_DRAW),o.enableVertexAttribArray(a.positionLocation),o.vertexAttribPointer(a.positionLocation,2,o.FLOAT,!1,0,0);var E=o.createBuffer();return o.bindBuffer(o.ARRAY_BUFFER,E),o.bufferData(o.ARRAY_BUFFER,p,o.STATIC_DRAW),o.enableVertexAttribArray(a.colorLocation),o.vertexAttribPointer(a.colorLocation,3,o.UNSIGNED_BYTE,!1,0,0),o.uniform2f(a.scaleLocation,i.scaleX,i.scaleY),o.uniform2f(a.offsetLocation,i.offsetX,i.offsetY),o.drawArrays(o.TRIANGLES,0,d),o.flush(),o.deleteBuffer(L),o.deleteBuffer(E),s}function d(){A&&A.canvas&&(A.canvas.width=0,A.canvas.height=0),y&&y.canvas&&(y.canvas.width=0,y.canvas.height=0),A=null,y=null}var f,p,g=" attribute vec2 a_position; attribute vec2 a_texCoord; uniform vec2 u_resolution; varying vec2 v_texCoord; void main() { vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0; gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); v_texCoord = a_texCoord; } ",m=" precision mediump float; uniform vec4 u_backdrop; uniform int u_subtype; uniform sampler2D u_image; uniform sampler2D u_mask; varying vec2 v_texCoord; void main() { vec4 imageColor = texture2D(u_image, v_texCoord); vec4 maskColor = texture2D(u_mask, v_texCoord); if (u_backdrop.a > 0.0) { maskColor.rgb = maskColor.rgb * maskColor.a + u_backdrop.rgb * (1.0 - maskColor.a); } float lum; if (u_subtype == 0) { lum = maskColor.a; } else { lum = maskColor.r * 0.3 + maskColor.g * 0.59 + maskColor.b * 0.11; } imageColor.a *= lum; imageColor.rgb *= imageColor.a; gl_FragColor = imageColor; } ",A=null,v=" attribute vec2 a_position; attribute vec3 a_color; uniform vec2 u_resolution; uniform vec2 u_scale; uniform vec2 u_offset; varying vec4 v_color; void main() { vec2 position = (a_position + u_offset) * u_scale; vec2 clipSpace = (position / u_resolution) * 2.0 - 1.0; gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); v_color = vec4(a_color / 255.0, 1.0); } ",b=" precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; } ",y=null;return{get isEnabled(){if(s("disableWebGL"))return!1;var t=!1;try{o(),t=!!f}catch(t){}return a(this,"isEnabled",t)},composeSMask:l,drawFigures:u,clear:d}}();e.WebGLUtils=o},function(t,e,n){"use strict";var r=n(0),i=n(1),a=n(3),s=n(2),o=n(5),c=n(7),l=n(4),h=r.globalScope,u=r.deprecated,d=r.warn,f=i.LinkTarget,p=i.DEFAULT_LINK_REL,g="undefined"==typeof window;h.PDFJS||(h.PDFJS={});var m=h.PDFJS;m.version="1.8.172",m.build="8ff1fbe7",m.pdfBug=!1,
+void 0!==m.verbosity&&r.setVerbosityLevel(m.verbosity),delete m.verbosity,Object.defineProperty(m,"verbosity",{get:function(){return r.getVerbosityLevel()},set:function(t){r.setVerbosityLevel(t)},enumerable:!0,configurable:!0}),m.VERBOSITY_LEVELS=r.VERBOSITY_LEVELS,m.OPS=r.OPS,m.UNSUPPORTED_FEATURES=r.UNSUPPORTED_FEATURES,m.isValidUrl=i.isValidUrl,m.shadow=r.shadow,m.createBlob=r.createBlob,m.createObjectURL=function(t,e){return r.createObjectURL(t,e,m.disableCreateObjectURL)},Object.defineProperty(m,"isLittleEndian",{configurable:!0,get:function(){var t=r.isLittleEndian();return r.shadow(m,"isLittleEndian",t)}}),m.removeNullCharacters=r.removeNullCharacters,m.PasswordResponses=r.PasswordResponses,m.PasswordException=r.PasswordException,m.UnknownErrorException=r.UnknownErrorException,m.InvalidPDFException=r.InvalidPDFException,m.MissingPDFException=r.MissingPDFException,m.UnexpectedResponseException=r.UnexpectedResponseException,m.Util=r.Util,m.PageViewport=r.PageViewport,m.createPromiseCapability=r.createPromiseCapability,m.maxImageSize=void 0===m.maxImageSize?-1:m.maxImageSize,m.cMapUrl=void 0===m.cMapUrl?null:m.cMapUrl,m.cMapPacked=void 0!==m.cMapPacked&&m.cMapPacked,m.disableFontFace=void 0!==m.disableFontFace&&m.disableFontFace,m.imageResourcesPath=void 0===m.imageResourcesPath?"":m.imageResourcesPath,m.disableWorker=void 0!==m.disableWorker&&m.disableWorker,m.workerSrc=void 0===m.workerSrc?null:m.workerSrc,m.workerPort=void 0===m.workerPort?null:m.workerPort,m.disableRange=void 0!==m.disableRange&&m.disableRange,m.disableStream=void 0!==m.disableStream&&m.disableStream,m.disableAutoFetch=void 0!==m.disableAutoFetch&&m.disableAutoFetch,m.pdfBug=void 0!==m.pdfBug&&m.pdfBug,m.postMessageTransfers=void 0===m.postMessageTransfers||m.postMessageTransfers,m.disableCreateObjectURL=void 0!==m.disableCreateObjectURL&&m.disableCreateObjectURL,m.disableWebGL=void 0===m.disableWebGL||m.disableWebGL,m.externalLinkTarget=void 0===m.externalLinkTarget?f.NONE:m.externalLinkTarget,m.externalLinkRel=void 0===m.externalLinkRel?p:m.externalLinkRel,m.isEvalSupported=void 0===m.isEvalSupported||m.isEvalSupported,m.pdfjsNext=void 0!==m.pdfjsNext&&m.pdfjsNext;var A=m.openExternalLinksInNewWindow;delete m.openExternalLinksInNewWindow,Object.defineProperty(m,"openExternalLinksInNewWindow",{get:function(){return m.externalLinkTarget===f.BLANK},set:function(t){if(t&&u('PDFJS.openExternalLinksInNewWindow, please use "PDFJS.externalLinkTarget = PDFJS.LinkTarget.BLANK" instead.'),m.externalLinkTarget!==f.NONE)return void d("PDFJS.externalLinkTarget is already initialized");m.externalLinkTarget=t?f.BLANK:f.NONE},enumerable:!0,configurable:!0}),A&&(m.openExternalLinksInNewWindow=A),m.getDocument=a.getDocument,m.PDFDataRangeTransport=a.PDFDataRangeTransport,m.PDFWorker=a.PDFWorker,Object.defineProperty(m,"hasCanvasTypedArrays",{configurable:!0,get:function(){var t=i.hasCanvasTypedArrays();return r.shadow(m,"hasCanvasTypedArrays",t)}}),m.CustomStyle=i.CustomStyle,m.LinkTarget=f,m.addLinkAttributes=i.addLinkAttributes,m.getFilenameFromUrl=i.getFilenameFromUrl,m.isExternalLinkTargetSet=i.isExternalLinkTargetSet,m.AnnotationLayer=s.AnnotationLayer,m.renderTextLayer=o.renderTextLayer,m.Metadata=c.Metadata,m.SVGGraphics=l.SVGGraphics,m.UnsupportedManager=a._UnsupportedManager,e.globalScope=h,e.isWorker=g,e.PDFJS=h.PDFJS},function(t,e,n){"use strict";function r(t){t.mozCurrentTransform||(t._originalSave=t.save,t._originalRestore=t.restore,t._originalRotate=t.rotate,t._originalScale=t.scale,t._originalTranslate=t.translate,t._originalTransform=t.transform,t._originalSetTransform=t.setTransform,t._transformMatrix=t._transformMatrix||[1,0,0,1,0,0],t._transformStack=[],Object.defineProperty(t,"mozCurrentTransform",{get:function(){return this._transformMatrix}}),Object.defineProperty(t,"mozCurrentTransformInverse",{get:function(){var t=this._transformMatrix,e=t[0],n=t[1],r=t[2],i=t[3],a=t[4],s=t[5],o=e*i-n*r,c=n*r-e*i;return[i/o,n/c,r/c,e/o,(i*a-r*s)/c,(n*a-e*s)/o]}}),t.save=function(){var t=this._transformMatrix;this._transformStack.push(t),this._transformMatrix=t.slice(0,6),this._originalSave()},t.restore=function(){var t=this._transformStack.pop();t&&(this._transformMatrix=t,this._originalRestore())},t.translate=function(t,e){var n=this._transformMatrix;n[4]=n[0]*t+n[2]*e+n[4],n[5]=n[1]*t+n[3]*e+n[5],this._originalTranslate(t,e)},t.scale=function(t,e){var n=this._transformMatrix;n[0]=n[0]*t,n[1]=n[1]*t,n[2]=n[2]*e,n[3]=n[3]*e,this._originalScale(t,e)},t.transform=function(e,n,r,i,a,s){var o=this._transformMatrix;this._transformMatrix=[o[0]*e+o[2]*n,o[1]*e+o[3]*n,o[0]*r+o[2]*i,o[1]*r+o[3]*i,o[0]*a+o[2]*s+o[4],o[1]*a+o[3]*s+o[5]],t._originalTransform(e,n,r,i,a,s)},t.setTransform=function(e,n,r,i,a,s){this._transformMatrix=[e,n,r,i,a,s],t._originalSetTransform(e,n,r,i,a,s)},t.rotate=function(t){var e=Math.cos(t),n=Math.sin(t),r=this._transformMatrix;this._transformMatrix=[r[0]*e+r[2]*n,r[1]*e+r[3]*n,r[0]*-n+r[2]*e,r[1]*-n+r[3]*e,r[4],r[5]],this._originalRotate(t)})}function i(t){var e,n,r,i,a=t.width,s=t.height,o=a+1,c=new Uint8Array(o*(s+1)),l=new Uint8Array([0,2,4,0,1,0,5,4,8,10,0,8,0,2,1,0]),h=a+7&-8,u=t.data,d=new Uint8Array(h*s),f=0;for(e=0,i=u.length;e<i;e++)for(var p=128,g=u[e];p>0;)d[f++]=g&p?0:255,p>>=1;var m=0;for(f=0,0!==d[f]&&(c[0]=1,++m),n=1;n<a;n++)d[f]!==d[f+1]&&(c[n]=d[f]?2:1,++m),f++;for(0!==d[f]&&(c[n]=2,++m),e=1;e<s;e++){f=e*h,r=e*o,d[f-h]!==d[f]&&(c[r]=d[f]?1:8,++m);var A=(d[f]?4:0)+(d[f-h]?8:0);for(n=1;n<a;n++)A=(A>>2)+(d[f+1]?4:0)+(d[f-h+1]?8:0),l[A]&&(c[r+n]=l[A],++m),f++;if(d[f-h]!==d[f]&&(c[r+n]=d[f]?2:4,++m),m>1e3)return null}for(f=h*(s-1),r=e*o,0!==d[f]&&(c[r]=8,++m),n=1;n<a;n++)d[f]!==d[f+1]&&(c[r+n]=d[f]?4:8,++m),f++;if(0!==d[f]&&(c[r+n]=4,++m),m>1e3)return null;var v=new Int32Array([0,o,-1,0,-o,0,0,0,1]),b=[];for(e=0;m&&e<=s;e++){for(var y=e*o,x=y+a;y<x&&!c[y];)y++;if(y!==x){var S,w=[y%o,e],k=c[y],C=y;do{var _=v[k];do{y+=_}while(!c[y]);S=c[y],5!==S&&10!==S?(k=S,c[y]=0):(k=S&51*k>>4,c[y]&=k>>2|k<<2),w.push(y%o),w.push(y/o|0),--m}while(C!==y);b.push(w),--e}}return function(t){t.save(),t.scale(1/a,-1/s),t.translate(0,-s),t.beginPath();for(var e=0,n=b.length;e<n;e++){var r=b[e];t.moveTo(r[0],r[1]);for(var i=2,o=r.length;i<o;i+=2)t.lineTo(r[i],r[i+1])}t.fill(),t.beginPath(),t.restore()}}var a=n(0),s=n(1),o=n(12),c=n(8),l=a.FONT_IDENTITY_MATRIX,h=a.IDENTITY_MATRIX,u=a.ImageKind,d=a.OPS,f=a.TextRenderingMode,p=a.Uint32ArrayView,g=a.Util,m=a.assert,A=a.info,v=a.isNum,b=a.isArray,y=a.isLittleEndian,x=a.error,S=a.shadow,w=a.warn,k=o.TilingPattern,C=o.getShadingPatternFromIR,_=c.WebGLUtils,T=s.hasCanvasTypedArrays,P=16,L={get value(){return S(L,"value",T())}},E={get value(){return S(E,"value",y())}},R=function(){function t(t){this.canvasFactory=t,this.cache=Object.create(null)}return t.prototype={getCanvas:function(t,e,n,i){var a;return void 0!==this.cache[t]?(a=this.cache[t],this.canvasFactory.reset(a,e,n),a.context.setTransform(1,0,0,1,0,0)):(a=this.canvasFactory.create(e,n),this.cache[t]=a),i&&r(a.context),a},clear:function(){for(var t in this.cache){var e=this.cache[t];this.canvasFactory.destroy(e),delete this.cache[t]}}},t}(),I=function(){function t(t){this.alphaIsShape=!1,this.fontSize=0,this.fontSizeScale=1,this.textMatrix=h,this.textMatrixScale=1,this.fontMatrix=l,this.leading=0,this.x=0,this.y=0,this.lineX=0,this.lineY=0,this.charSpacing=0,this.wordSpacing=0,this.textHScale=1,this.textRenderingMode=f.FILL,this.textRise=0,this.fillColor="#000000",this.strokeColor="#000000",this.patternFill=!1,this.fillAlpha=1,this.strokeAlpha=1,this.lineWidth=1,this.activeSMask=null,this.resumeSMaskCtx=null,this.old=t}return t.prototype={clone:function(){return Object.create(this)},setCurrentPoint:function(t,e){this.x=t,this.y=e}},t}(),F=function(){function t(t,e,n,i,a){this.ctx=t,this.current=new I,this.stateStack=[],this.pendingClip=null,this.pendingEOFill=!1,this.res=null,this.xobjs=null,this.commonObjs=e,this.objs=n,this.canvasFactory=i,this.imageLayer=a,this.groupStack=[],this.processingType3=null,this.baseTransform=null,this.baseTransformStack=[],this.groupLevel=0,this.smaskStack=[],this.smaskCounter=0,this.tempSMask=null,this.cachedCanvases=new R(this.canvasFactory),t&&r(t),this.cachedGetSinglePixelWidth=null}function e(t,e){if("undefined"!=typeof ImageData&&e instanceof ImageData)return void t.putImageData(e,0,0);var n,r,i,a,s,o=e.height,c=e.width,l=o%P,h=(o-l)/P,d=0===l?h:h+1,f=t.createImageData(c,P),g=0,m=e.data,A=f.data;if(e.kind===u.GRAYSCALE_1BPP){var v=m.byteLength,b=L.value?new Uint32Array(A.buffer):new p(A),y=b.length,S=c+7>>3,w=4294967295,k=E.value||!L.value?4278190080:255;for(r=0;r<d;r++){for(a=r<h?P:l,n=0,i=0;i<a;i++){for(var C=v-g,_=0,T=C>S?c:8*C-7,R=-8&T,I=0,F=0;_<R;_+=8)F=m[g++],b[n++]=128&F?w:k,b[n++]=64&F?w:k,b[n++]=32&F?w:k,b[n++]=16&F?w:k,b[n++]=8&F?w:k,b[n++]=4&F?w:k,b[n++]=2&F?w:k,b[n++]=1&F?w:k;for(;_<T;_++)0===I&&(F=m[g++],I=128),b[n++]=F&I?w:k,I>>=1}for(;n<y;)b[n++]=0;t.putImageData(f,0,r*P)}}else if(e.kind===u.RGBA_32BPP){for(i=0,s=c*P*4,r=0;r<h;r++)A.set(m.subarray(g,g+s)),g+=s,t.putImageData(f,0,i),i+=P;r<d&&(s=c*l*4,A.set(m.subarray(g,g+s)),t.putImageData(f,0,i))}else if(e.kind===u.RGB_24BPP)for(a=P,s=c*a,r=0;r<d;r++){for(r>=h&&(a=l,s=c*a),n=0,i=s;i--;)A[n++]=m[g++],A[n++]=m[g++],A[n++]=m[g++],A[n++]=255;t.putImageData(f,0,r*P)}else x("bad image kind: "+e.kind)}function n(t,e){for(var n=e.height,r=e.width,i=n%P,a=(n-i)/P,s=0===i?a:a+1,o=t.createImageData(r,P),c=0,l=e.data,h=o.data,u=0;u<s;u++){for(var d=u<a?P:i,f=3,p=0;p<d;p++)for(var g=0,m=0;m<r;m++){if(!g){var A=l[c++];g=128}h[f]=A&g?0:255,f+=4,g>>=1}t.putImageData(o,0,u*P)}}function a(t,e){for(var n=["strokeStyle","fillStyle","fillRule","globalAlpha","lineWidth","lineCap","lineJoin","miterLimit","globalCompositeOperation","font"],r=0,i=n.length;r<i;r++){var a=n[r];void 0!==t[a]&&(e[a]=t[a])}void 0!==t.setLineDash&&(e.setLineDash(t.getLineDash()),e.lineDashOffset=t.lineDashOffset)}function s(t,e,n,r){for(var i=t.length,a=3;a<i;a+=4){var s=t[a];if(0===s)t[a-3]=e,t[a-2]=n,t[a-1]=r;else if(s<255){var o=255-s;t[a-3]=t[a-3]*s+e*o>>8,t[a-2]=t[a-2]*s+n*o>>8,t[a-1]=t[a-1]*s+r*o>>8}}}function o(t,e,n){for(var r=t.length,i=3;i<r;i+=4){var a=n?n[t[i]]:t[i];e[i]=e[i]*a*(1/255)|0}}function c(t,e,n){for(var r=t.length,i=3;i<r;i+=4){var a=77*t[i-3]+152*t[i-2]+28*t[i-1];e[i]=n?e[i]*n[a>>8]>>8:e[i]*a>>16}}function y(t,e,n,r,i,a,l){var h,u=!!a,d=u?a[0]:0,f=u?a[1]:0,p=u?a[2]:0;h="Luminosity"===i?c:o;for(var g=Math.min(r,Math.ceil(1048576/n)),m=0;m<r;m+=g){var A=Math.min(g,r-m),v=t.getImageData(0,m,n,A),b=e.getImageData(0,m,n,A);u&&s(v.data,d,f,p),h(v.data,b.data,l),t.putImageData(b,0,m)}}function T(t,e,n){var r=e.canvas,i=e.context;t.setTransform(e.scaleX,0,0,e.scaleY,e.offsetX,e.offsetY);var a=e.backdrop||null;if(!e.transferMap&&_.isEnabled){var s=_.composeSMask(n.canvas,r,{subtype:e.subtype,backdrop:a});return t.setTransform(1,0,0,1,0,0),void t.drawImage(s,e.offsetX,e.offsetY)}y(i,n,r.width,r.height,e.subtype,a,e.transferMap),t.drawImage(r,0,0)}var F=["butt","round","square"],O=["miter","round","bevel"],M={},D={};t.prototype={beginDrawing:function(t,e,n){var r=this.ctx.canvas.width,i=this.ctx.canvas.height;if(this.ctx.save(),this.ctx.fillStyle="rgb(255, 255, 255)",this.ctx.fillRect(0,0,r,i),this.ctx.restore(),n){var a=this.cachedCanvases.getCanvas("transparent",r,i,!0);this.compositeCtx=this.ctx,this.transparentCanvas=a.canvas,this.ctx=a.context,this.ctx.save(),this.ctx.transform.apply(this.ctx,this.compositeCtx.mozCurrentTransform)}this.ctx.save(),t&&this.ctx.transform.apply(this.ctx,t),this.ctx.transform.apply(this.ctx,e.transform),this.baseTransform=this.ctx.mozCurrentTransform.slice(),this.imageLayer&&this.imageLayer.beginLayout()},executeOperatorList:function(t,e,n,r){var i=t.argsArray,a=t.fnArray,s=e||0,o=i.length;if(o===s)return s;for(var c,l=o-s>10&&"function"==typeof n,h=l?Date.now()+15:0,u=0,f=this.commonObjs,p=this.objs;;){if(void 0!==r&&s===r.nextBreakPoint)return r.breakIt(s,n),s;if((c=a[s])!==d.dependency)this[c].apply(this,i[s]);else for(var g=i[s],m=0,A=g.length;m<A;m++){var v=g[m],b="g"===v[0]&&"_"===v[1],y=b?f:p;if(!y.isResolved(v))return y.get(v,n),s}if(++s===o)return s;if(l&&++u>10){if(Date.now()>h)return n(),s;u=0}}},endDrawing:function(){null!==this.current.activeSMask&&this.endSMaskGroup(),this.ctx.restore(),this.transparentCanvas&&(this.ctx=this.compositeCtx,this.ctx.save(),this.ctx.setTransform(1,0,0,1,0,0),this.ctx.drawImage(this.transparentCanvas,0,0),this.ctx.restore(),this.transparentCanvas=null),this.cachedCanvases.clear(),_.clear(),this.imageLayer&&this.imageLayer.endLayout()},setLineWidth:function(t){this.current.lineWidth=t,this.ctx.lineWidth=t},setLineCap:function(t){this.ctx.lineCap=F[t]},setLineJoin:function(t){this.ctx.lineJoin=O[t]},setMiterLimit:function(t){this.ctx.miterLimit=t},setDash:function(t,e){var n=this.ctx;void 0!==n.setLineDash&&(n.setLineDash(t),n.lineDashOffset=e)},setRenderingIntent:function(t){},setFlatness:function(t){},setGState:function(t){for(var e=0,n=t.length;e<n;e++){var r=t[e],i=r[0],a=r[1];switch(i){case"LW":this.setLineWidth(a);break;case"LC":this.setLineCap(a);break;case"LJ":this.setLineJoin(a);break;case"ML":this.setMiterLimit(a);break;case"D":this.setDash(a[0],a[1]);break;case"RI":this.setRenderingIntent(a);break;case"FL":this.setFlatness(a);break;case"Font":this.setFont(a[0],a[1]);break;case"CA":this.current.strokeAlpha=r[1];break;case"ca":this.current.fillAlpha=r[1],this.ctx.globalAlpha=r[1];break;case"BM":if(a&&a.name&&"Normal"!==a.name){var s=a.name.replace(/([A-Z])/g,function(t){return"-"+t.toLowerCase()}).substring(1);this.ctx.globalCompositeOperation=s,this.ctx.globalCompositeOperation!==s&&w('globalCompositeOperation "'+s+'" is not supported')}else this.ctx.globalCompositeOperation="source-over";break;case"SMask":this.current.activeSMask&&(this.stateStack.length>0&&this.stateStack[this.stateStack.length-1].activeSMask===this.current.activeSMask?this.suspendSMaskGroup():this.endSMaskGroup()),this.current.activeSMask=a?this.tempSMask:null,this.current.activeSMask&&this.beginSMaskGroup(),this.tempSMask=null}}},beginSMaskGroup:function(){var t=this.current.activeSMask,e=t.canvas.width,n=t.canvas.height,r="smaskGroupAt"+this.groupLevel,i=this.cachedCanvases.getCanvas(r,e,n,!0),s=this.ctx,o=s.mozCurrentTransform;this.ctx.save();var c=i.context;c.scale(1/t.scaleX,1/t.scaleY),c.translate(-t.offsetX,-t.offsetY),c.transform.apply(c,o),t.startTransformInverse=c.mozCurrentTransformInverse,a(s,c),this.ctx=c,this.setGState([["BM","Normal"],["ca",1],["CA",1]]),this.groupStack.push(s),this.groupLevel++},suspendSMaskGroup:function(){var t=this.ctx;this.groupLevel--,this.ctx=this.groupStack.pop(),T(this.ctx,this.current.activeSMask,t),this.ctx.restore(),this.ctx.save(),a(t,this.ctx),this.current.resumeSMaskCtx=t;var e=g.transform(this.current.activeSMask.startTransformInverse,t.mozCurrentTransform);this.ctx.transform.apply(this.ctx,e),t.save(),t.setTransform(1,0,0,1,0,0),t.clearRect(0,0,t.canvas.width,t.canvas.height),t.restore()},resumeSMaskGroup:function(){var t=this.current.resumeSMaskCtx,e=this.ctx;this.ctx=t,this.groupStack.push(e),this.groupLevel++},endSMaskGroup:function(){var t=this.ctx;this.groupLevel--,this.ctx=this.groupStack.pop(),T(this.ctx,this.current.activeSMask,t),this.ctx.restore(),a(t,this.ctx);var e=g.transform(this.current.activeSMask.startTransformInverse,t.mozCurrentTransform);this.ctx.transform.apply(this.ctx,e)},save:function(){this.ctx.save();var t=this.current;this.stateStack.push(t),this.current=t.clone(),this.current.resumeSMaskCtx=null},restore:function(){this.current.resumeSMaskCtx&&this.resumeSMaskGroup(),null===this.current.activeSMask||0!==this.stateStack.length&&this.stateStack[this.stateStack.length-1].activeSMask===this.current.activeSMask||this.endSMaskGroup(),0!==this.stateStack.length&&(this.current=this.stateStack.pop(),this.ctx.restore(),this.pendingClip=null,this.cachedGetSinglePixelWidth=null)},transform:function(t,e,n,r,i,a){this.ctx.transform(t,e,n,r,i,a),this.cachedGetSinglePixelWidth=null},constructPath:function(t,e){for(var n=this.ctx,r=this.current,i=r.x,a=r.y,s=0,o=0,c=t.length;s<c;s++)switch(0|t[s]){case d.rectangle:i=e[o++],a=e[o++];var l=e[o++],h=e[o++];0===l&&(l=this.getSinglePixelWidth()),0===h&&(h=this.getSinglePixelWidth());var u=i+l,f=a+h;this.ctx.moveTo(i,a),this.ctx.lineTo(u,a),this.ctx.lineTo(u,f),this.ctx.lineTo(i,f),this.ctx.lineTo(i,a),this.ctx.closePath();break;case d.moveTo:i=e[o++],a=e[o++],n.moveTo(i,a);break;case d.lineTo:i=e[o++],a=e[o++],n.lineTo(i,a);break;case d.curveTo:i=e[o+4],a=e[o+5],n.bezierCurveTo(e[o],e[o+1],e[o+2],e[o+3],i,a),o+=6;break;case d.curveTo2:n.bezierCurveTo(i,a,e[o],e[o+1],e[o+2],e[o+3]),i=e[o+2],a=e[o+3],o+=4;break;case d.curveTo3:i=e[o+2],a=e[o+3],n.bezierCurveTo(e[o],e[o+1],i,a,i,a),o+=4;break;case d.closePath:n.closePath()}r.setCurrentPoint(i,a)},closePath:function(){this.ctx.closePath()},stroke:function(t){t=void 0===t||t;var e=this.ctx,n=this.current.strokeColor;e.lineWidth=Math.max(.65*this.getSinglePixelWidth(),this.current.lineWidth),e.globalAlpha=this.current.strokeAlpha,n&&n.hasOwnProperty("type")&&"Pattern"===n.type?(e.save(),e.strokeStyle=n.getPattern(e,this),e.stroke(),e.restore()):e.stroke(),t&&this.consumePath(),e.globalAlpha=this.current.fillAlpha},closeStroke:function(){this.closePath(),this.stroke()},fill:function(t){t=void 0===t||t;var e=this.ctx,n=this.current.fillColor,r=this.current.patternFill,i=!1;r&&(e.save(),this.baseTransform&&e.setTransform.apply(e,this.baseTransform),e.fillStyle=n.getPattern(e,this),i=!0),this.pendingEOFill?(e.fill("evenodd"),this.pendingEOFill=!1):e.fill(),i&&e.restore(),t&&this.consumePath()},eoFill:function(){this.pendingEOFill=!0,this.fill()},fillStroke:function(){this.fill(!1),this.stroke(!1),this.consumePath()},eoFillStroke:function(){this.pendingEOFill=!0,this.fillStroke()},closeFillStroke:function(){this.closePath(),this.fillStroke()},closeEOFillStroke:function(){this.pendingEOFill=!0,this.closePath(),this.fillStroke()},endPath:function(){this.consumePath()},clip:function(){this.pendingClip=M},eoClip:function(){this.pendingClip=D},beginText:function(){this.current.textMatrix=h,this.current.textMatrixScale=1,this.current.x=this.current.lineX=0,this.current.y=this.current.lineY=0},endText:function(){var t=this.pendingTextPaths,e=this.ctx;if(void 0===t)return void e.beginPath();e.save(),e.beginPath();for(var n=0;n<t.length;n++){var r=t[n];e.setTransform.apply(e,r.transform),e.translate(r.x,r.y),r.addToPath(e,r.fontSize)}e.restore(),e.clip(),e.beginPath(),delete this.pendingTextPaths},setCharSpacing:function(t){this.current.charSpacing=t},setWordSpacing:function(t){this.current.wordSpacing=t},setHScale:function(t){this.current.textHScale=t/100},setLeading:function(t){this.current.leading=-t},setFont:function(t,e){var n=this.commonObjs.get(t),r=this.current;if(n||x("Can't find font for "+t),r.fontMatrix=n.fontMatrix?n.fontMatrix:l,0!==r.fontMatrix[0]&&0!==r.fontMatrix[3]||w("Invalid font matrix for font "+t),e<0?(e=-e,r.fontDirection=-1):r.fontDirection=1,this.current.font=n,this.current.fontSize=e,!n.isType3Font){var i=n.loadedName||"sans-serif",a=n.black?"900":n.bold?"bold":"normal",s=n.italic?"italic":"normal",o='"'+i+'", '+n.fallbackName,c=e<16?16:e>100?100:e;this.current.fontSizeScale=e/c;var h=s+" "+a+" "+c+"px "+o;this.ctx.font=h}},setTextRenderingMode:function(t){this.current.textRenderingMode=t},setTextRise:function(t){this.current.textRise=t},moveText:function(t,e){this.current.x=this.current.lineX+=t,this.current.y=this.current.lineY+=e},setLeadingMoveText:function(t,e){this.setLeading(-e),this.moveText(t,e)},setTextMatrix:function(t,e,n,r,i,a){this.current.textMatrix=[t,e,n,r,i,a],this.current.textMatrixScale=Math.sqrt(t*t+e*e),this.current.x=this.current.lineX=0,this.current.y=this.current.lineY=0},nextLine:function(){this.moveText(0,this.current.leading)},paintChar:function(t,e,n){var r,i=this.ctx,a=this.current,s=a.font,o=a.textRenderingMode,c=a.fontSize/a.fontSizeScale,l=o&f.FILL_STROKE_MASK,h=!!(o&f.ADD_TO_PATH_FLAG);if((s.disableFontFace||h)&&(r=s.getPathGenerator(this.commonObjs,t)),s.disableFontFace?(i.save(),i.translate(e,n),i.beginPath(),r(i,c),l!==f.FILL&&l!==f.FILL_STROKE||i.fill(),l!==f.STROKE&&l!==f.FILL_STROKE||i.stroke(),i.restore()):(l!==f.FILL&&l!==f.FILL_STROKE||i.fillText(t,e,n),l!==f.STROKE&&l!==f.FILL_STROKE||i.strokeText(t,e,n)),h){(this.pendingTextPaths||(this.pendingTextPaths=[])).push({transform:i.mozCurrentTransform,x:e,y:n,fontSize:c,addToPath:r})}},get isFontSubpixelAAEnabled(){var t=this.canvasFactory.create(10,10).context;t.scale(1.5,1),t.fillText("I",0,10);for(var e=t.getImageData(0,0,10,10).data,n=!1,r=3;r<e.length;r+=4)if(e[r]>0&&e[r]<255){n=!0;break}return S(this,"isFontSubpixelAAEnabled",n)},showText:function(t){var e=this.current,n=e.font;if(n.isType3Font)return this.showType3Text(t);var r=e.fontSize;if(0!==r){var i=this.ctx,a=e.fontSizeScale,s=e.charSpacing,o=e.wordSpacing,c=e.fontDirection,l=e.textHScale*c,h=t.length,u=n.vertical,d=u?1:-1,p=n.defaultVMetrics,g=r*e.fontMatrix[0],m=e.textRenderingMode===f.FILL&&!n.disableFontFace;i.save(),i.transform.apply(i,e.textMatrix),i.translate(e.x,e.y+e.textRise),e.patternFill&&(i.fillStyle=e.fillColor.getPattern(i,this)),c>0?i.scale(l,-1):i.scale(l,1);var A=e.lineWidth,b=e.textMatrixScale;if(0===b||0===A){var y=e.textRenderingMode&f.FILL_STROKE_MASK;y!==f.STROKE&&y!==f.FILL_STROKE||(this.cachedGetSinglePixelWidth=null,A=.65*this.getSinglePixelWidth())}else A/=b;1!==a&&(i.scale(a,a),A/=a),i.lineWidth=A;var x,S=0;for(x=0;x<h;++x){var w=t[x];if(v(w))S+=d*w*r/1e3;else{var k,C,_,T,P=!1,L=(w.isSpace?o:0)+s,E=w.fontChar,R=w.accent,I=w.width;if(u){var F,O,M;F=w.vmetric||p,O=w.vmetric?F[1]:.5*I,O=-O*g,M=F[2]*g,I=F?-F[0]:I,k=O/a,C=(S+M)/a}else k=S/a,C=0;if(n.remeasure&&I>0){var D=1e3*i.measureText(E).width/r*a;if(I<D&&this.isFontSubpixelAAEnabled){var N=I/D;P=!0,i.save(),i.scale(N,1),k/=N}else I!==D&&(k+=(I-D)/2e3*r/a)}(w.isInFont||n.missingFile)&&(m&&!R?i.fillText(E,k,C):(this.paintChar(E,k,C),R&&(_=k+R.offset.x/a,T=C-R.offset.y/a,this.paintChar(R.fontChar,_,T))));S+=I*g+L*c,P&&i.restore()}}u?e.y-=S*l:e.x+=S*l,i.restore()}},showType3Text:function(t){var e,n,r,i,a=this.ctx,s=this.current,o=s.font,c=s.fontSize,h=s.fontDirection,u=o.vertical?1:-1,d=s.charSpacing,p=s.wordSpacing,m=s.textHScale*h,A=s.fontMatrix||l,b=t.length,y=s.textRenderingMode===f.INVISIBLE;if(!y&&0!==c){for(this.cachedGetSinglePixelWidth=null,a.save(),a.transform.apply(a,s.textMatrix),a.translate(s.x,s.y),a.scale(m,h),e=0;e<b;++e)if(n=t[e],v(n))i=u*n*c/1e3,this.ctx.translate(i,0),s.x+=i*m;else{var x=(n.isSpace?p:0)+d,S=o.charProcOperatorList[n.operatorListId];if(S){this.processingType3=n,this.save(),a.scale(c,c),a.transform.apply(a,A),this.executeOperatorList(S),this.restore();var k=g.applyTransform([n.width,0],A);r=k[0]*c+x,a.translate(r,0),s.x+=r*m}else w('Type3 character "'+n.operatorListId+'" is not available')}a.restore(),this.processingType3=null}},setCharWidth:function(t,e){},setCharWidthAndBounds:function(t,e,n,r,i,a){this.ctx.rect(n,r,i-n,a-r),this.clip(),this.endPath()},getColorN_Pattern:function(e){var n;if("TilingPattern"===e[0]){var r=e[1],i=this.baseTransform||this.ctx.mozCurrentTransform.slice(),a=this,s={createCanvasGraphics:function(e){return new t(e,a.commonObjs,a.objs,a.canvasFactory)}};n=new k(e,r,this.ctx,s,i)}else n=C(e);return n},setStrokeColorN:function(){this.current.strokeColor=this.getColorN_Pattern(arguments)},setFillColorN:function(){this.current.fillColor=this.getColorN_Pattern(arguments),this.current.patternFill=!0},setStrokeRGBColor:function(t,e,n){var r=g.makeCssRgb(t,e,n);this.ctx.strokeStyle=r,this.current.strokeColor=r},setFillRGBColor:function(t,e,n){var r=g.makeCssRgb(t,e,n);this.ctx.fillStyle=r,this.current.fillColor=r,this.current.patternFill=!1},shadingFill:function(t){var e=this.ctx;this.save();var n=C(t);e.fillStyle=n.getPattern(e,this,!0);var r=e.mozCurrentTransformInverse;if(r){var i=e.canvas,a=i.width,s=i.height,o=g.applyTransform([0,0],r),c=g.applyTransform([0,s],r),l=g.applyTransform([a,0],r),h=g.applyTransform([a,s],r),u=Math.min(o[0],c[0],l[0],h[0]),d=Math.min(o[1],c[1],l[1],h[1]),f=Math.max(o[0],c[0],l[0],h[0]),p=Math.max(o[1],c[1],l[1],h[1]);this.ctx.fillRect(u,d,f-u,p-d)}else this.ctx.fillRect(-1e10,-1e10,2e10,2e10);this.restore()},beginInlineImage:function(){x("Should not call beginInlineImage")},beginImageData:function(){x("Should not call beginImageData")},paintFormXObjectBegin:function(t,e){if(this.save(),this.baseTransformStack.push(this.baseTransform),b(t)&&6===t.length&&this.transform.apply(this,t),this.baseTransform=this.ctx.mozCurrentTransform,b(e)&&4===e.length){var n=e[2]-e[0],r=e[3]-e[1];this.ctx.rect(e[0],e[1],n,r),this.clip(),this.endPath()}},paintFormXObjectEnd:function(){this.restore(),this.baseTransform=this.baseTransformStack.pop()},beginGroup:function(t){this.save();var e=this.ctx;t.isolated||A("TODO: Support non-isolated groups."),t.knockout&&w("Knockout groups not supported.");var n=e.mozCurrentTransform;t.matrix&&e.transform.apply(e,t.matrix),m(t.bbox,"Bounding box is required.");var r=g.getAxialAlignedBoundingBox(t.bbox,e.mozCurrentTransform),i=[0,0,e.canvas.width,e.canvas.height];r=g.intersect(r,i)||[0,0,0,0];var s=Math.floor(r[0]),o=Math.floor(r[1]),c=Math.max(Math.ceil(r[2])-s,1),l=Math.max(Math.ceil(r[3])-o,1),h=1,u=1;c>4096&&(h=c/4096,c=4096),l>4096&&(u=l/4096,l=4096);var d="groupAt"+this.groupLevel;t.smask&&(d+="_smask_"+this.smaskCounter++%2);var f=this.cachedCanvases.getCanvas(d,c,l,!0),p=f.context;p.scale(1/h,1/u),p.translate(-s,-o),p.transform.apply(p,n),t.smask?this.smaskStack.push({canvas:f.canvas,context:p,offsetX:s,offsetY:o,scaleX:h,scaleY:u,subtype:t.smask.subtype,backdrop:t.smask.backdrop,transferMap:t.smask.transferMap||null,startTransformInverse:null}):(e.setTransform(1,0,0,1,0,0),e.translate(s,o),e.scale(h,u)),a(e,p),this.ctx=p,this.setGState([["BM","Normal"],["ca",1],["CA",1]]),this.groupStack.push(e),this.groupLevel++,this.current.activeSMask=null},endGroup:function(t){this.groupLevel--;var e=this.ctx;this.ctx=this.groupStack.pop(),void 0!==this.ctx.imageSmoothingEnabled?this.ctx.imageSmoothingEnabled=!1:this.ctx.mozImageSmoothingEnabled=!1,t.smask?this.tempSMask=this.smaskStack.pop():this.ctx.drawImage(e.canvas,0,0),this.restore()},beginAnnotations:function(){this.save(),this.current=new I,this.baseTransform&&this.ctx.setTransform.apply(this.ctx,this.baseTransform)},endAnnotations:function(){this.restore()},beginAnnotation:function(t,e,n){if(this.save(),b(t)&&4===t.length){var r=t[2]-t[0],i=t[3]-t[1];this.ctx.rect(t[0],t[1],r,i),this.clip(),this.endPath()}this.transform.apply(this,e),this.transform.apply(this,n)},endAnnotation:function(){this.restore()},paintJpegXObject:function(t,e,n){var r=this.objs.get(t);if(!r)return void w("Dependent image isn't ready yet");this.save();var i=this.ctx;if(i.scale(1/e,-1/n),i.drawImage(r,0,0,r.width,r.height,0,-n,e,n),this.imageLayer){var a=i.mozCurrentTransformInverse,s=this.getCanvasPosition(0,0);this.imageLayer.appendImage({objId:t,left:s[0],top:s[1],width:e/a[0],height:n/a[3]})}this.restore()},paintImageMaskXObject:function(t){var e=this.ctx,r=t.width,a=t.height,s=this.current.fillColor,o=this.current.patternFill,c=this.processingType3;if(c&&void 0===c.compiled&&(c.compiled=r<=1e3&&a<=1e3?i({data:t.data,width:r,height:a}):null),c&&c.compiled)return void c.compiled(e);var l=this.cachedCanvases.getCanvas("maskCanvas",r,a),h=l.context;h.save(),n(h,t),h.globalCompositeOperation="source-in",h.fillStyle=o?s.getPattern(h,this):s,h.fillRect(0,0,r,a),h.restore(),this.paintInlineImageXObject(l.canvas)},paintImageMaskXObjectRepeat:function(t,e,r,i){var a=t.width,s=t.height,o=this.current.fillColor,c=this.current.patternFill,l=this.cachedCanvases.getCanvas("maskCanvas",a,s),h=l.context;h.save(),n(h,t),h.globalCompositeOperation="source-in",h.fillStyle=c?o.getPattern(h,this):o,h.fillRect(0,0,a,s),h.restore();for(var u=this.ctx,d=0,f=i.length;d<f;d+=2)u.save(),u.transform(e,0,0,r,i[d],i[d+1]),u.scale(1,-1),u.drawImage(l.canvas,0,0,a,s,0,-1,1,1),u.restore()},paintImageMaskXObjectGroup:function(t){for(var e=this.ctx,r=this.current.fillColor,i=this.current.patternFill,a=0,s=t.length;a<s;a++){var o=t[a],c=o.width,l=o.height,h=this.cachedCanvases.getCanvas("maskCanvas",c,l),u=h.context;u.save(),n(u,o),u.globalCompositeOperation="source-in",u.fillStyle=i?r.getPattern(u,this):r,u.fillRect(0,0,c,l),u.restore(),e.save(),e.transform.apply(e,o.transform),e.scale(1,-1),e.drawImage(h.canvas,0,0,c,l,0,-1,1,1),e.restore()}},paintImageXObject:function(t){var e=this.objs.get(t);if(!e)return void w("Dependent image isn't ready yet");this.paintInlineImageXObject(e)},paintImageXObjectRepeat:function(t,e,n,r){var i=this.objs.get(t);if(!i)return void w("Dependent image isn't ready yet");for(var a=i.width,s=i.height,o=[],c=0,l=r.length;c<l;c+=2)o.push({transform:[e,0,0,n,r[c],r[c+1]],x:0,y:0,w:a,h:s});this.paintInlineImageXObjectGroup(i,o)},paintInlineImageXObject:function(t){var n=t.width,r=t.height,i=this.ctx;this.save(),i.scale(1/n,-1/r);var a,s,o=i.mozCurrentTransformInverse,c=o[0],l=o[1],h=Math.max(Math.sqrt(c*c+l*l),1),u=o[2],d=o[3],f=Math.max(Math.sqrt(u*u+d*d),1);if(t instanceof HTMLElement||!t.data)a=t;else{s=this.cachedCanvases.getCanvas("inlineImage",n,r);var p=s.context;e(p,t),a=s.canvas}for(var g=n,m=r,A="prescale1";h>2&&g>1||f>2&&m>1;){var v=g,b=m;h>2&&g>1&&(v=Math.ceil(g/2),h/=g/v),f>2&&m>1&&(b=Math.ceil(m/2),f/=m/b),s=this.cachedCanvases.getCanvas(A,v,b),p=s.context,p.clearRect(0,0,v,b),p.drawImage(a,0,0,g,m,0,0,v,b),a=s.canvas,g=v,m=b,A="prescale1"===A?"prescale2":"prescale1"}if(i.drawImage(a,0,0,g,m,0,-r,n,r),this.imageLayer){var y=this.getCanvasPosition(0,-r);this.imageLayer.appendImage({imgData:t,left:y[0],top:y[1],width:n/o[0],height:r/o[3]})}this.restore()},paintInlineImageXObjectGroup:function(t,n){var r=this.ctx,i=t.width,a=t.height,s=this.cachedCanvases.getCanvas("inlineImage",i,a);e(s.context,t);for(var o=0,c=n.length;o<c;o++){var l=n[o];if(r.save(),r.transform.apply(r,l.transform),r.scale(1,-1),r.drawImage(s.canvas,l.x,l.y,l.w,l.h,0,-1,1,1),this.imageLayer){var h=this.getCanvasPosition(l.x,l.y);this.imageLayer.appendImage({imgData:t,left:h[0],top:h[1],width:i,height:a})}r.restore()}},paintSolidColorImageMask:function(){this.ctx.fillRect(0,0,1,1)},paintXObject:function(){w("Unsupported 'paintXObject' command.")},markPoint:function(t){},markPointProps:function(t,e){},beginMarkedContent:function(t){},beginMarkedContentProps:function(t,e){},endMarkedContent:function(){},beginCompat:function(){},endCompat:function(){},consumePath:function(){var t=this.ctx;this.pendingClip&&(this.pendingClip===D?t.clip("evenodd"):t.clip(),this.pendingClip=null),t.beginPath()},getSinglePixelWidth:function(t){if(null===this.cachedGetSinglePixelWidth){this.ctx.save();var e=this.ctx.mozCurrentTransformInverse;this.ctx.restore(),this.cachedGetSinglePixelWidth=Math.sqrt(Math.max(e[0]*e[0]+e[1]*e[1],e[2]*e[2]+e[3]*e[3]))}return this.cachedGetSinglePixelWidth},getCanvasPosition:function(t,e){var n=this.ctx.mozCurrentTransform;return[n[0]*t+n[2]*e+n[4],n[1]*t+n[3]*e+n[5]]}};for(var N in d)t.prototype[d[N]]=t.prototype[N];return t}();e.CanvasGraphics=F},function(t,e,n){"use strict";function r(t){this.docId=t,this.styleElement=null,this.nativeFontFaces=[],this.loadTestFontId=0,this.loadingContext={requests:[],nextRequestId:0}}var i=n(0),a=i.assert,s=i.bytesToString,o=i.string32,c=i.shadow,l=i.warn;r.prototype={insertRule:function(t){var e=this.styleElement;e||(e=this.styleElement=document.createElement("style"),e.id="PDFJS_FONT_STYLE_TAG_"+this.docId,document.documentElement.getElementsByTagName("head")[0].appendChild(e));var n=e.sheet;n.insertRule(t,n.cssRules.length)},clear:function(){this.styleElement&&(this.styleElement.remove(),this.styleElement=null),this.nativeFontFaces.forEach(function(t){document.fonts.delete(t)}),this.nativeFontFaces.length=0}};var h=function(){
+return atob("T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA==")};Object.defineProperty(r.prototype,"loadTestFont",{get:function(){return c(this,"loadTestFont",h())},configurable:!0}),r.prototype.addNativeFontFace=function(t){this.nativeFontFaces.push(t),document.fonts.add(t)},r.prototype.bind=function(t,e){for(var n=[],i=[],a=[],s=r.isFontLoadingAPISupported&&!r.isSyncFontLoadingSupported,o=0,c=t.length;o<c;o++){var h=t[o];if(!h.attached&&!1!==h.loading)if(h.attached=!0,s){var u=h.createNativeFontFace();u&&(this.addNativeFontFace(u),a.push(function(t){return t.loaded.catch(function(e){l('Failed to load font "'+t.family+'": '+e)})}(u)))}else{var d=h.createFontFaceRule();d&&(this.insertRule(d),n.push(d),i.push(h))}}var f=this.queueLoadingCallback(e);s?Promise.all(a).then(function(){f.complete()}):n.length>0&&!r.isSyncFontLoadingSupported?this.prepareFontLoadEvent(n,i,f):f.complete()},r.prototype.queueLoadingCallback=function(t){function e(){for(a(!i.end,"completeRequest() cannot be called twice"),i.end=Date.now();n.requests.length>0&&n.requests[0].end;){var t=n.requests.shift();setTimeout(t.callback,0)}}var n=this.loadingContext,r="pdfjs-font-loading-"+n.nextRequestId++,i={id:r,complete:e,callback:t,started:Date.now()};return n.requests.push(i),i},r.prototype.prepareFontLoadEvent=function(t,e,n){function r(t,e){return t.charCodeAt(e)<<24|t.charCodeAt(e+1)<<16|t.charCodeAt(e+2)<<8|255&t.charCodeAt(e+3)}function i(t,e,n,r){return t.substr(0,e)+r+t.substr(e+n)}function a(t,e){return++d>30?(l("Load test font never loaded."),void e()):(u.font="30px "+t,u.fillText(".",0,20),u.getImageData(0,0,1,1).data[3]>0?void e():void setTimeout(a.bind(null,t,e)))}var s,c,h=document.createElement("canvas");h.width=1,h.height=1;var u=h.getContext("2d"),d=0,f="lt"+Date.now()+this.loadTestFontId++,p=this.loadTestFont;p=i(p,976,f.length,f);var g=r(p,16);for(s=0,c=f.length-3;s<c;s+=4)g=g-1482184792+r(f,s)|0;s<f.length&&(g=g-1482184792+r(f+"XXX",s)|0),p=i(p,16,4,o(g));var m="url(data:font/opentype;base64,"+btoa(p)+");",A='@font-face { font-family:"'+f+'";src:'+m+"}";this.insertRule(A);var v=[];for(s=0,c=e.length;s<c;s++)v.push(e[s].loadedName);v.push(f);var b=document.createElement("div");for(b.setAttribute("style","visibility: hidden;width: 10px; height: 10px;position: absolute; top: 0px; left: 0px;"),s=0,c=v.length;s<c;++s){var y=document.createElement("span");y.textContent="Hi",y.style.fontFamily=v[s],b.appendChild(y)}document.body.appendChild(b),a(f,function(){document.body.removeChild(b),n.complete()})},r.isFontLoadingAPISupported="undefined"!=typeof document&&!!document.fonts;var u=function(){if("undefined"==typeof navigator)return!0;var t=!1,e=/Mozilla\/5.0.*?rv:(\d+).*? Gecko/.exec(navigator.userAgent);return e&&e[1]>=14&&(t=!0),t};Object.defineProperty(r,"isSyncFontLoadingSupported",{get:function(){return c(r,"isSyncFontLoadingSupported",u())},enumerable:!0,configurable:!0});var d={get value(){return c(this,"value",i.isEvalSupported())}},f=function(){function t(t,e){this.compiledGlyphs=Object.create(null);for(var n in t)this[n]=t[n];this.options=e}return t.prototype={createNativeFontFace:function(){if(!this.data)return null;if(this.options.disableFontFace)return this.disableFontFace=!0,null;var t=new FontFace(this.loadedName,this.data,{});return this.options.fontRegistry&&this.options.fontRegistry.registerFont(this),t},createFontFaceRule:function(){if(!this.data)return null;if(this.options.disableFontFace)return this.disableFontFace=!0,null;var t=s(new Uint8Array(this.data)),e=this.loadedName,n="url(data:"+this.mimetype+";base64,"+btoa(t)+");",r='@font-face { font-family:"'+e+'";src:'+n+"}";return this.options.fontRegistry&&this.options.fontRegistry.registerFont(this,n),r},getPathGenerator:function(t,e){if(!(e in this.compiledGlyphs)){var n,r,i,a=t.get(this.loadedName+"_path_"+e);if(this.options.isEvalSupported&&d.value){var s,o="";for(r=0,i=a.length;r<i;r++)n=a[r],s=void 0!==n.args?n.args.join(","):"",o+="c."+n.cmd+"("+s+");\n";this.compiledGlyphs[e]=new Function("c","size",o)}else this.compiledGlyphs[e]=function(t,e){for(r=0,i=a.length;r<i;r++)n=a[r],"scale"===n.cmd&&(n.args=[e,-e]),t[n.cmd].apply(t,n.args)}}return this.compiledGlyphs[e]}},t}();e.FontFaceObject=f,e.FontLoader=r},function(t,e,n){"use strict";function r(t){var e=u[t[0]];return e||l("Unknown IR type: "+t[0]),e.fromIR(t)}var i=n(0),a=n(8),s=i.Util,o=i.info,c=i.isArray,l=i.error,h=a.WebGLUtils,u={};u.RadialAxial={fromIR:function(t){var e=t[1],n=t[2],r=t[3],i=t[4],a=t[5],s=t[6];return{type:"Pattern",getPattern:function(t){var o;"axial"===e?o=t.createLinearGradient(r[0],r[1],i[0],i[1]):"radial"===e&&(o=t.createRadialGradient(r[0],r[1],a,i[0],i[1],s));for(var c=0,l=n.length;c<l;++c){var h=n[c];o.addColorStop(h[0],h[1])}return o}}}};var d=function(){function t(t,e,n,r,i,a,s,o){var c,l=e.coords,h=e.colors,u=t.data,d=4*t.width;l[n+1]>l[r+1]&&(c=n,n=r,r=c,c=a,a=s,s=c),l[r+1]>l[i+1]&&(c=r,r=i,i=c,c=s,s=o,o=c),l[n+1]>l[r+1]&&(c=n,n=r,r=c,c=a,a=s,s=c);var f=(l[n]+e.offsetX)*e.scaleX,p=(l[n+1]+e.offsetY)*e.scaleY,g=(l[r]+e.offsetX)*e.scaleX,m=(l[r+1]+e.offsetY)*e.scaleY,A=(l[i]+e.offsetX)*e.scaleX,v=(l[i+1]+e.offsetY)*e.scaleY;if(!(p>=v))for(var b,y,x,S,w,k,C,_,T,P=h[a],L=h[a+1],E=h[a+2],R=h[s],I=h[s+1],F=h[s+2],O=h[o],M=h[o+1],D=h[o+2],N=Math.round(p),j=Math.round(v),U=N;U<=j;U++){U<m?(T=U<p?0:p===m?1:(p-U)/(p-m),b=f-(f-g)*T,y=P-(P-R)*T,x=L-(L-I)*T,S=E-(E-F)*T):(T=U>v?1:m===v?0:(m-U)/(m-v),b=g-(g-A)*T,y=R-(R-O)*T,x=I-(I-M)*T,S=F-(F-D)*T),T=U<p?0:U>v?1:(p-U)/(p-v),w=f-(f-A)*T,k=P-(P-O)*T,C=L-(L-M)*T,_=E-(E-D)*T;for(var B=Math.round(Math.min(b,w)),W=Math.round(Math.max(b,w)),G=d*U+4*B,X=B;X<=W;X++)T=(b-X)/(b-w),T=T<0?0:T>1?1:T,u[G++]=y-(y-k)*T|0,u[G++]=x-(x-C)*T|0,u[G++]=S-(S-_)*T|0,u[G++]=255}}function e(e,n,r){var i,a,s=n.coords,o=n.colors;switch(n.type){case"lattice":var c=n.verticesPerRow,h=Math.floor(s.length/c)-1,u=c-1;for(i=0;i<h;i++)for(var d=i*c,f=0;f<u;f++,d++)t(e,r,s[d],s[d+1],s[d+c],o[d],o[d+1],o[d+c]),t(e,r,s[d+c+1],s[d+1],s[d+c],o[d+c+1],o[d+1],o[d+c]);break;case"triangles":for(i=0,a=s.length;i<a;i+=3)t(e,r,s[i],s[i+1],s[i+2],o[i],o[i+1],o[i+2]);break;default:l("illigal figure")}}function n(t,n,r,i,a,s,o){var c,l,u,d,f=Math.floor(t[0]),p=Math.floor(t[1]),g=Math.ceil(t[2])-f,m=Math.ceil(t[3])-p,A=Math.min(Math.ceil(Math.abs(g*n[0]*1.1)),3e3),v=Math.min(Math.ceil(Math.abs(m*n[1]*1.1)),3e3),b=g/A,y=m/v,x={coords:r,colors:i,offsetX:-f,offsetY:-p,scaleX:1/b,scaleY:1/y},S=A+4,w=v+4;if(h.isEnabled)c=h.drawFigures(A,v,s,a,x),l=o.getCanvas("mesh",S,w,!1),l.context.drawImage(c,2,2),c=l.canvas;else{l=o.getCanvas("mesh",S,w,!1);var k=l.context,C=k.createImageData(A,v);if(s){var _=C.data;for(u=0,d=_.length;u<d;u+=4)_[u]=s[0],_[u+1]=s[1],_[u+2]=s[2],_[u+3]=255}for(u=0;u<a.length;u++)e(C,a[u],x);k.putImageData(C,2,2),c=l.canvas}return{canvas:c,offsetX:f-2*b,offsetY:p-2*y,scaleX:b,scaleY:y}}return n}();u.Mesh={fromIR:function(t){var e=t[2],n=t[3],r=t[4],i=t[5],a=t[6],o=t[8];return{type:"Pattern",getPattern:function(t,c,l){var h;if(l)h=s.singularValueDecompose2dScale(t.mozCurrentTransform);else if(h=s.singularValueDecompose2dScale(c.baseTransform),a){var u=s.singularValueDecompose2dScale(a);h=[h[0]*u[0],h[1]*u[1]]}var f=d(i,h,e,n,r,l?null:o,c.cachedCanvases);return l||(t.setTransform.apply(t,c.baseTransform),a&&t.transform.apply(t,a)),t.translate(f.offsetX,f.offsetY),t.scale(f.scaleX,f.scaleY),t.createPattern(f.canvas,"no-repeat")}}}},u.Dummy={fromIR:function(){return{type:"Pattern",getPattern:function(){return"hotpink"}}}};var f=function(){function t(t,e,n,r,i){this.operatorList=t[2],this.matrix=t[3]||[1,0,0,1,0,0],this.bbox=s.normalizeRect(t[4]),this.xstep=t[5],this.ystep=t[6],this.paintType=t[7],this.tilingType=t[8],this.color=e,this.canvasGraphicsFactory=r,this.baseTransform=i,this.type="Pattern",this.ctx=n}var e={COLORED:1,UNCOLORED:2};return t.prototype={createPatternCanvas:function(t){var e=this.operatorList,n=this.bbox,r=this.xstep,i=this.ystep,a=this.paintType,c=this.tilingType,l=this.color,h=this.canvasGraphicsFactory;o("TilingType: "+c);var u=n[0],d=n[1],f=n[2],p=n[3],g=[u,d],m=[u+r,d+i],A=m[0]-g[0],v=m[1]-g[1],b=s.singularValueDecompose2dScale(this.matrix),y=s.singularValueDecompose2dScale(this.baseTransform),x=[b[0]*y[0],b[1]*y[1]];A=Math.min(Math.ceil(Math.abs(A*x[0])),3e3),v=Math.min(Math.ceil(Math.abs(v*x[1])),3e3);var S=t.cachedCanvases.getCanvas("pattern",A,v,!0),w=S.context,k=h.createCanvasGraphics(w);k.groupLevel=t.groupLevel,this.setFillAndStrokeStyleToContext(w,a,l),this.setScale(A,v,r,i),this.transformToScale(k);var C=[1,0,0,1,-g[0],-g[1]];return k.transform.apply(k,C),this.clipBbox(k,n,u,d,f,p),k.executeOperatorList(e),S.canvas},setScale:function(t,e,n,r){this.scale=[t/n,e/r]},transformToScale:function(t){var e=this.scale,n=[e[0],0,0,e[1],0,0];t.transform.apply(t,n)},scaleToContext:function(){var t=this.scale;this.ctx.scale(1/t[0],1/t[1])},clipBbox:function(t,e,n,r,i,a){if(c(e)&&4===e.length){var s=i-n,o=a-r;t.ctx.rect(n,r,s,o),t.clip(),t.endPath()}},setFillAndStrokeStyleToContext:function(t,n,r){switch(n){case e.COLORED:var i=this.ctx;t.fillStyle=i.fillStyle,t.strokeStyle=i.strokeStyle;break;case e.UNCOLORED:var a=s.makeCssRgb(r[0],r[1],r[2]);t.fillStyle=a,t.strokeStyle=a;break;default:l("Unsupported paint type: "+n)}},getPattern:function(t,e){var n=this.createPatternCanvas(e);return t=this.ctx,t.setTransform.apply(t,this.baseTransform),t.transform.apply(t,this.matrix),this.scaleToContext(),t.createPattern(n,"repeat")}},t}();e.getShadingPatternFromIR=r,e.TilingPattern=f},function(t,e,n){"use strict";var r=n(0),i=n(9),a=n(3),s=n(5),o=n(2),c=n(1),l=n(4);e.PDFJS=i.PDFJS,e.build=a.build,e.version=a.version,e.getDocument=a.getDocument,e.PDFDataRangeTransport=a.PDFDataRangeTransport,e.PDFWorker=a.PDFWorker,e.renderTextLayer=s.renderTextLayer,e.AnnotationLayer=o.AnnotationLayer,e.CustomStyle=c.CustomStyle,e.createPromiseCapability=r.createPromiseCapability,e.PasswordResponses=r.PasswordResponses,e.InvalidPDFException=r.InvalidPDFException,e.MissingPDFException=r.MissingPDFException,e.SVGGraphics=l.SVGGraphics,e.UnexpectedResponseException=r.UnexpectedResponseException,e.OPS=r.OPS,e.UNSUPPORTED_FEATURES=r.UNSUPPORTED_FEATURES,e.isValidUrl=c.isValidUrl,e.createValidAbsoluteUrl=r.createValidAbsoluteUrl,e.createObjectURL=r.createObjectURL,e.removeNullCharacters=r.removeNullCharacters,e.shadow=r.shadow,e.createBlob=r.createBlob,e.RenderingCancelledException=c.RenderingCancelledException,e.getFilenameFromUrl=c.getFilenameFromUrl,e.addLinkAttributes=c.addLinkAttributes},function(t,e,n){"use strict";(function(t){if("undefined"==typeof PDFJS||!PDFJS.compatibilityChecked){var e="undefined"!=typeof window?window:void 0!==t?t:"undefined"!=typeof self?self:void 0,n="undefined"!=typeof navigator&&navigator.userAgent||"",r=/Android/.test(n),i=/Android\s[0-2][^\d]/.test(n),a=/Android\s[0-4][^\d]/.test(n),s=n.indexOf("Chrom")>=0,o=/Chrome\/(39|40)\./.test(n),c=n.indexOf("CriOS")>=0,l=n.indexOf("Trident")>=0,h=/\b(iPad|iPhone|iPod)(?=;)/.test(n),u=n.indexOf("Opera")>=0,d=/Safari\//.test(n)&&!/(Chrome\/|Android\s)/.test(n),f="object"==typeof window&&"object"==typeof document;"undefined"==typeof PDFJS&&(e.PDFJS={}),PDFJS.compatibilityChecked=!0,function(){function t(t,e){return new r(this.slice(t,e))}function n(t,e){arguments.length<2&&(e=0);for(var n=0,r=t.length;n<r;++n,++e)this[e]=255&t[n]}function r(e){var r,i,a;if("number"==typeof e)for(r=[],i=0;i<e;++i)r[i]=0;else if("slice"in e)r=e.slice(0);else for(r=[],i=0,a=e.length;i<a;++i)r[i]=e[i];return r.subarray=t,r.buffer=r,r.byteLength=r.length,r.set=n,"object"==typeof e&&e.buffer&&(r.buffer=e.buffer),r}if("undefined"!=typeof Uint8Array)return void 0===Uint8Array.prototype.subarray&&(Uint8Array.prototype.subarray=function(t,e){return new Uint8Array(this.slice(t,e))},Float32Array.prototype.subarray=function(t,e){return new Float32Array(this.slice(t,e))}),void("undefined"==typeof Float64Array&&(e.Float64Array=Float32Array));e.Uint8Array=r,e.Int8Array=r,e.Uint32Array=r,e.Int32Array=r,e.Uint16Array=r,e.Float32Array=r,e.Float64Array=r}(),function(){e.URL||(e.URL=e.webkitURL)}(),function(){if(void 0!==Object.defineProperty){var t=!0;try{f&&Object.defineProperty(new Image,"id",{value:"test"});var e=function(){};e.prototype={get id(){}},Object.defineProperty(new e,"id",{value:"",configurable:!0,enumerable:!0,writable:!1})}catch(e){t=!1}if(t)return}Object.defineProperty=function(t,e,n){delete t[e],"get"in n&&t.__defineGetter__(e,n.get),"set"in n&&t.__defineSetter__(e,n.set),"value"in n&&(t.__defineSetter__(e,function(t){return this.__defineGetter__(e,function(){return t}),t}),t[e]=n.value)}}(),function(){if("undefined"!=typeof XMLHttpRequest){var t=XMLHttpRequest.prototype,e=new XMLHttpRequest;if("overrideMimeType"in e||Object.defineProperty(t,"overrideMimeType",{value:function(t){}}),!("responseType"in e)){if(Object.defineProperty(t,"responseType",{get:function(){return this._responseType||"text"},set:function(t){"text"!==t&&"arraybuffer"!==t||(this._responseType=t,"arraybuffer"===t&&"function"==typeof this.overrideMimeType&&this.overrideMimeType("text/plain; charset=x-user-defined"))}}),"undefined"!=typeof VBArray)return void Object.defineProperty(t,"response",{get:function(){return"arraybuffer"===this.responseType?new Uint8Array(new VBArray(this.responseBody).toArray()):this.responseText}});Object.defineProperty(t,"response",{get:function(){if("arraybuffer"!==this.responseType)return this.responseText;var t,e=this.responseText,n=e.length,r=new Uint8Array(n);for(t=0;t<n;++t)r[t]=255&e.charCodeAt(t);return r.buffer}})}}}(),function(){if(!("btoa"in e)){var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";e.btoa=function(e){var n,r,i="";for(n=0,r=e.length;n<r;n+=3){var a=255&e.charCodeAt(n),s=255&e.charCodeAt(n+1),o=255&e.charCodeAt(n+2),c=a>>2,l=(3&a)<<4|s>>4,h=n+1<r?(15&s)<<2|o>>6:64,u=n+2<r?63&o:64;i+=t.charAt(c)+t.charAt(l)+t.charAt(h)+t.charAt(u)}return i}}}(),function(){if(!("atob"in e)){e.atob=function(t){if(t=t.replace(/=+$/,""),t.length%4==1)throw new Error("bad atob input");for(var e,n,r=0,i=0,a="";n=t.charAt(i++);~n&&(e=r%4?64*e+n:n,r++%4)?a+=String.fromCharCode(255&e>>(-2*r&6)):0)n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".indexOf(n);return a}}}(),function(){void 0===Function.prototype.bind&&(Function.prototype.bind=function(t){var e=this,n=Array.prototype.slice.call(arguments,1);return function(){var r=n.concat(Array.prototype.slice.call(arguments));return e.apply(t,r)}})}(),function(){if(f){"dataset"in document.createElement("div")||Object.defineProperty(HTMLElement.prototype,"dataset",{get:function(){if(this._dataset)return this._dataset;for(var t={},e=0,n=this.attributes.length;e<n;e++){var r=this.attributes[e];if("data-"===r.name.substring(0,5)){t[r.name.substring(5).replace(/\-([a-z])/g,function(t,e){return e.toUpperCase()})]=r.value}}return Object.defineProperty(this,"_dataset",{value:t,writable:!1,enumerable:!1}),t},enumerable:!0})}}(),function(){function t(t,e,n,r){var i=t.className||"",a=i.split(/\s+/g);""===a[0]&&a.shift();var s=a.indexOf(e);return s<0&&n&&a.push(e),s>=0&&r&&a.splice(s,1),t.className=a.join(" "),s>=0}if(f){if(!("classList"in document.createElement("div"))){var e={add:function(e){t(this.element,e,!0,!1)},contains:function(e){return t(this.element,e,!1,!1)},remove:function(e){t(this.element,e,!1,!0)},toggle:function(e){t(this.element,e,!0,!0)}};Object.defineProperty(HTMLElement.prototype,"classList",{get:function(){if(this._classList)return this._classList;var t=Object.create(e,{element:{value:this,writable:!1,enumerable:!0}});return Object.defineProperty(this,"_classList",{value:t,writable:!1,enumerable:!1}),t},enumerable:!0})}}}(),function(){if(!("undefined"==typeof importScripts||"console"in e)){var t={},n={log:function(){var t=Array.prototype.slice.call(arguments);e.postMessage({targetName:"main",action:"console_log",data:t})},error:function(){var t=Array.prototype.slice.call(arguments);e.postMessage({targetName:"main",action:"console_error",data:t})},time:function(e){t[e]=Date.now()},timeEnd:function(e){var n=t[e];if(!n)throw new Error("Unknown timer name "+e);this.log("Timer:",e,Date.now()-n)}};e.console=n}}(),function(){if(f)"console"in window?"bind"in console.log||(console.log=function(t){return function(e){return t(e)}}(console.log),console.error=function(t){return function(e){return t(e)}}(console.error),console.warn=function(t){return function(e){return t(e)}}(console.warn)):window.console={log:function(){},error:function(){},warn:function(){}}}(),function(){function t(t){e(t.target)&&t.stopPropagation()}function e(t){return t.disabled||t.parentNode&&e(t.parentNode)}u&&document.addEventListener("click",t,!0)}(),function(){(l||c)&&(PDFJS.disableCreateObjectURL=!0)}(),function(){"undefined"!=typeof navigator&&("language"in navigator||(PDFJS.locale=navigator.userLanguage||"en-US"))}(),function(){(d||i||o||h)&&(PDFJS.disableRange=!0,PDFJS.disableStream=!0)}(),function(){f&&(history.pushState&&!i||(PDFJS.disableHistory=!0))}(),function(){if(f)if(window.CanvasPixelArray)"function"!=typeof window.CanvasPixelArray.prototype.set&&(window.CanvasPixelArray.prototype.set=function(t){for(var e=0,n=this.length;e<n;e++)this[e]=t[e]});else{var t,e=!1;if(s?(t=n.match(/Chrom(e|ium)\/([0-9]+)\./),e=t&&parseInt(t[2])<21):r?e=a:d&&(t=n.match(/Version\/([0-9]+)\.([0-9]+)\.([0-9]+) Safari\//),e=t&&parseInt(t[1])<6),e){var i=window.CanvasRenderingContext2D.prototype,o=i.createImageData;i.createImageData=function(t,e){var n=o.call(this,t,e);return n.data.set=function(t){for(var e=0,n=this.length;e<n;e++)this[e]=t[e]},n},i=null}}}(),function(){function t(){window.requestAnimationFrame=function(t){return window.setTimeout(t,20)},window.cancelAnimationFrame=function(t){window.clearTimeout(t)}}if(f)h?t():"requestAnimationFrame"in window||(window.requestAnimationFrame=window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame,"requestAnimationFrame"in window||t())}(),function(){(h||r)&&(PDFJS.maxCanvasPixels=5242880)}(),function(){f&&l&&window.parent!==window&&(PDFJS.disableFullscreen=!0)}(),function(){f&&("currentScript"in document||Object.defineProperty(document,"currentScript",{get:function(){var t=document.getElementsByTagName("script");return t[t.length-1]},enumerable:!0,configurable:!0}))}(),function(){if(f){var t=document.createElement("input");try{t.type="number"}catch(r){var e=t.constructor.prototype,n=Object.getOwnPropertyDescriptor(e,"type");Object.defineProperty(e,"type",{get:function(){return n.get.call(this)},set:function(t){n.set.call(this,"number"===t?"text":t)},enumerable:!0,configurable:!0})}}}(),function(){if(f&&document.attachEvent){var t=document.constructor.prototype,e=Object.getOwnPropertyDescriptor(t,"readyState");Object.defineProperty(t,"readyState",{get:function(){var t=e.get.call(this);return"interactive"===t?"loading":t},set:function(t){e.set.call(this,t)},enumerable:!0,configurable:!0})}}(),function(){f&&void 0===Element.prototype.remove&&(Element.prototype.remove=function(){this.parentNode&&this.parentNode.removeChild(this)})}(),function(){if(e.Promise)return"function"!=typeof e.Promise.all&&(e.Promise.all=function(t){var n,r,i=0,a=[],s=new e.Promise(function(t,e){n=t,r=e});return t.forEach(function(t,e){i++,t.then(function(t){a[e]=t,0===--i&&n(a)},r)}),0===i&&n(a),s}),"function"!=typeof e.Promise.resolve&&(e.Promise.resolve=function(t){return new e.Promise(function(e){e(t)})}),"function"!=typeof e.Promise.reject&&(e.Promise.reject=function(t){return new e.Promise(function(e,n){n(t)})}),void("function"!=typeof e.Promise.prototype.catch&&(e.Promise.prototype.catch=function(t){return e.Promise.prototype.then(void 0,t)}));var t=2,n={handlers:[],running:!1,unhandledRejections:[],pendingRejectionCheck:!1,scheduleHandlers:function(t){0!==t._status&&(this.handlers=this.handlers.concat(t._handlers),t._handlers=[],this.running||(this.running=!0,setTimeout(this.runHandlers.bind(this),0)))},runHandlers:function(){for(var e=Date.now()+1;this.handlers.length>0;){var n=this.handlers.shift(),r=n.thisPromise._status,i=n.thisPromise._value;try{1===r?"function"==typeof n.onResolve&&(i=n.onResolve(i)):"function"==typeof n.onReject&&(i=n.onReject(i),r=1,n.thisPromise._unhandledRejection&&this.removeUnhandeledRejection(n.thisPromise))}catch(e){r=t,i=e}if(n.nextPromise._updateStatus(r,i),Date.now()>=e)break}if(this.handlers.length>0)return void setTimeout(this.runHandlers.bind(this),0);this.running=!1},addUnhandledRejection:function(t){this.unhandledRejections.push({promise:t,time:Date.now()}),this.scheduleRejectionCheck()},removeUnhandeledRejection:function(t){t._unhandledRejection=!1;for(var e=0;e<this.unhandledRejections.length;e++)this.unhandledRejections[e].promise===t&&(this.unhandledRejections.splice(e),e--)},scheduleRejectionCheck:function(){this.pendingRejectionCheck||(this.pendingRejectionCheck=!0,setTimeout(function(){this.pendingRejectionCheck=!1;for(var t=Date.now(),e=0;e<this.unhandledRejections.length;e++)if(t-this.unhandledRejections[e].time>500){var n=this.unhandledRejections[e].promise._value,r="Unhandled rejection: "+n;n.stack&&(r+="\n"+n.stack);try{throw new Error(r)}catch(t){console.warn(r)}this.unhandledRejections.splice(e),e--}this.unhandledRejections.length&&this.scheduleRejectionCheck()}.bind(this),500))}},r=function(t){this._status=0,this._handlers=[];try{t.call(this,this._resolve.bind(this),this._reject.bind(this))}catch(t){this._reject(t)}};r.all=function(e){function n(e){s._status!==t&&(c=[],a(e))}var i,a,s=new r(function(t,e){i=t,a=e}),o=e.length,c=[];if(0===o)return i(c),s;for(var l=0,h=e.length;l<h;++l){var u=e[l],d=function(e){return function(n){s._status!==t&&(c[e]=n,0===--o&&i(c))}}(l);r.isPromise(u)?u.then(d,n):d(u)}return s},r.isPromise=function(t){return t&&"function"==typeof t.then},r.resolve=function(t){return new r(function(e){e(t)})},r.reject=function(t){return new r(function(e,n){n(t)})},r.prototype={_status:null,_value:null,_handlers:null,_unhandledRejection:null,_updateStatus:function(e,i){if(1!==this._status&&this._status!==t){if(1===e&&r.isPromise(i))return void i.then(this._updateStatus.bind(this,1),this._updateStatus.bind(this,t));this._status=e,this._value=i,e===t&&0===this._handlers.length&&(this._unhandledRejection=!0,n.addUnhandledRejection(this)),n.scheduleHandlers(this)}},_resolve:function(t){this._updateStatus(1,t)},_reject:function(e){this._updateStatus(t,e)},then:function(t,e){var i=new r(function(t,e){this.resolve=t,this.reject=e});return this._handlers.push({thisPromise:this,onResolve:t,onReject:e,nextPromise:i}),n.scheduleHandlers(this),i},catch:function(t){return this.then(void 0,t)}},e.Promise=r}(),function(){function t(){this.id="$weakmap"+n++}if(!e.WeakMap){var n=0;t.prototype={has:function(t){return!!Object.getOwnPropertyDescriptor(t,this.id)},get:function(t,e){return this.has(t)?t[this.id]:e},set:function(t,e){Object.defineProperty(t,this.id,{value:e,enumerable:!1,configurable:!0})},delete:function(t){delete t[this.id]}},e.WeakMap=t}}(),function(){function t(t){return void 0!==u[t]}function n(){o.call(this),this._isInvalid=!0}function r(t){return""===t&&n.call(this),t.toLowerCase()}function i(t){var e=t.charCodeAt(0);return e>32&&e<127&&-1===[34,35,60,62,63,96].indexOf(e)?t:encodeURIComponent(t)}function a(t){var e=t.charCodeAt(0);return e>32&&e<127&&-1===[34,35,60,62,96].indexOf(e)?t:encodeURIComponent(t)}function s(e,s,o){function c(t){b.push(t)}var l=s||"scheme start",h=0,m="",A=!1,v=!1,b=[];t:for(;(e[h-1]!==f||0===h)&&!this._isInvalid;){var y=e[h];switch(l){case"scheme start":if(!y||!p.test(y)){if(s){c("Invalid scheme.");break t}m="",l="no scheme";continue}m+=y.toLowerCase(),l="scheme";break;case"scheme":if(y&&g.test(y))m+=y.toLowerCase();else{if(":"!==y){if(s){if(y===f)break t;c("Code point not allowed in scheme: "+y);break t}m="",h=0,l="no scheme";continue}if(this._scheme=m,m="",s)break t;t(this._scheme)&&(this._isRelative=!0),l="file"===this._scheme?"relative":this._isRelative&&o&&o._scheme===this._scheme?"relative or authority":this._isRelative?"authority first slash":"scheme data"}break;case"scheme data":"?"===y?(this._query="?",l="query"):"#"===y?(this._fragment="#",l="fragment"):y!==f&&"\t"!==y&&"\n"!==y&&"\r"!==y&&(this._schemeData+=i(y));break;case"no scheme":if(o&&t(o._scheme)){l="relative";continue}c("Missing scheme."),n.call(this);break;case"relative or authority":if("/"!==y||"/"!==e[h+1]){c("Expected /, got: "+y),l="relative";continue}l="authority ignore slashes";break;case"relative":if(this._isRelative=!0,"file"!==this._scheme&&(this._scheme=o._scheme),y===f){this._host=o._host,this._port=o._port,this._path=o._path.slice(),this._query=o._query,this._username=o._username,this._password=o._password;break t}if("/"===y||"\\"===y)"\\"===y&&c("\\ is an invalid code point."),l="relative slash";else if("?"===y)this._host=o._host,this._port=o._port,this._path=o._path.slice(),this._query="?",this._username=o._username,this._password=o._password,l="query";else{if("#"!==y){var x=e[h+1],S=e[h+2];("file"!==this._scheme||!p.test(y)||":"!==x&&"|"!==x||S!==f&&"/"!==S&&"\\"!==S&&"?"!==S&&"#"!==S)&&(this._host=o._host,this._port=o._port,this._username=o._username,this._password=o._password,this._path=o._path.slice(),this._path.pop()),l="relative path";continue}this._host=o._host,this._port=o._port,this._path=o._path.slice(),this._query=o._query,this._fragment="#",this._username=o._username,this._password=o._password,l="fragment"}break;case"relative slash":if("/"!==y&&"\\"!==y){"file"!==this._scheme&&(this._host=o._host,this._port=o._port,this._username=o._username,this._password=o._password),l="relative path";continue}"\\"===y&&c("\\ is an invalid code point."),l="file"===this._scheme?"file host":"authority ignore slashes";break;case"authority first slash":if("/"!==y){c("Expected '/', got: "+y),l="authority ignore slashes";continue}l="authority second slash";break;case"authority second slash":if(l="authority ignore slashes","/"!==y){c("Expected '/', got: "+y);continue}break;case"authority ignore slashes":if("/"!==y&&"\\"!==y){l="authority";continue}c("Expected authority, got: "+y);break;case"authority":if("@"===y){A&&(c("@ already seen."),m+="%40"),A=!0;for(var w=0;w<m.length;w++){var k=m[w];if("\t"!==k&&"\n"!==k&&"\r"!==k)if(":"!==k||null!==this._password){var C=i(k);null!==this._password?this._password+=C:this._username+=C}else this._password="";else c("Invalid whitespace in authority.")}m=""}else{if(y===f||"/"===y||"\\"===y||"?"===y||"#"===y){h-=m.length,m="",l="host";continue}m+=y}break;case"file host":if(y===f||"/"===y||"\\"===y||"?"===y||"#"===y){2!==m.length||!p.test(m[0])||":"!==m[1]&&"|"!==m[1]?0===m.length?l="relative path start":(this._host=r.call(this,m),m="",l="relative path start"):l="relative path";continue}"\t"===y||"\n"===y||"\r"===y?c("Invalid whitespace in file host."):m+=y;break;case"host":case"hostname":if(":"!==y||v){if(y===f||"/"===y||"\\"===y||"?"===y||"#"===y){if(this._host=r.call(this,m),m="",l="relative path start",s)break t;continue}"\t"!==y&&"\n"!==y&&"\r"!==y?("["===y?v=!0:"]"===y&&(v=!1),m+=y):c("Invalid code point in host/hostname: "+y)}else if(this._host=r.call(this,m),m="",l="port","hostname"===s)break t;break;case"port":if(/[0-9]/.test(y))m+=y;else{if(y===f||"/"===y||"\\"===y||"?"===y||"#"===y||s){if(""!==m){var _=parseInt(m,10);_!==u[this._scheme]&&(this._port=_+""),m=""}if(s)break t;l="relative path start";continue}"\t"===y||"\n"===y||"\r"===y?c("Invalid code point in port: "+y):n.call(this)}break;case"relative path start":if("\\"===y&&c("'\\' not allowed in path."),l="relative path","/"!==y&&"\\"!==y)continue;break;case"relative path":if(y!==f&&"/"!==y&&"\\"!==y&&(s||"?"!==y&&"#"!==y))"\t"!==y&&"\n"!==y&&"\r"!==y&&(m+=i(y));else{"\\"===y&&c("\\ not allowed in relative path.");var T;(T=d[m.toLowerCase()])&&(m=T),".."===m?(this._path.pop(),"/"!==y&&"\\"!==y&&this._path.push("")):"."===m&&"/"!==y&&"\\"!==y?this._path.push(""):"."!==m&&("file"===this._scheme&&0===this._path.length&&2===m.length&&p.test(m[0])&&"|"===m[1]&&(m=m[0]+":"),this._path.push(m)),m="","?"===y?(this._query="?",l="query"):"#"===y&&(this._fragment="#",l="fragment")}break;case"query":s||"#"!==y?y!==f&&"\t"!==y&&"\n"!==y&&"\r"!==y&&(this._query+=a(y)):(this._fragment="#",l="fragment");break;case"fragment":y!==f&&"\t"!==y&&"\n"!==y&&"\r"!==y&&(this._fragment+=y)}h++}}function o(){this._scheme="",this._schemeData="",this._username="",this._password=null,this._host="",this._port="",this._path=[],this._query="",this._fragment="",this._isInvalid=!1,this._isRelative=!1}function c(t,e){void 0===e||e instanceof c||(e=new c(String(e))),this._url=t,o.call(this);var n=t.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g,"");s.call(this,n,null,e)}var l=!1;try{if("function"==typeof URL&&"object"==typeof URL.prototype&&"origin"in URL.prototype){var h=new URL("b","http://a");h.pathname="c%20d",l="http://a/c%20d"===h.href}}catch(t){}if(!l){var u=Object.create(null);u.ftp=21,u.file=0,u.gopher=70,u.http=80,u.https=443,u.ws=80,u.wss=443;var d=Object.create(null);d["%2e"]=".",d[".%2e"]="..",d["%2e."]="..",d["%2e%2e"]="..";var f,p=/[a-zA-Z]/,g=/[a-zA-Z0-9\+\-\.]/;c.prototype={toString:function(){return this.href},get href(){if(this._isInvalid)return this._url;var t="";return""===this._username&&null===this._password||(t=this._username+(null!==this._password?":"+this._password:"")+"@"),this.protocol+(this._isRelative?"//"+t+this.host:"")+this.pathname+this._query+this._fragment},set href(t){o.call(this),s.call(this,t)},get protocol(){return this._scheme+":"},set protocol(t){this._isInvalid||s.call(this,t+":","scheme start")},get host(){return this._isInvalid?"":this._port?this._host+":"+this._port:this._host},set host(t){!this._isInvalid&&this._isRelative&&s.call(this,t,"host")},get hostname(){return this._host},set hostname(t){!this._isInvalid&&this._isRelative&&s.call(this,t,"hostname")},get port(){return this._port},set port(t){!this._isInvalid&&this._isRelative&&s.call(this,t,"port")},get pathname(){return this._isInvalid?"":this._isRelative?"/"+this._path.join("/"):this._schemeData},set pathname(t){!this._isInvalid&&this._isRelative&&(this._path=[],s.call(this,t,"relative path start"))},get search(){return this._isInvalid||!this._query||"?"===this._query?"":this._query},set search(t){!this._isInvalid&&this._isRelative&&(this._query="?","?"===t[0]&&(t=t.slice(1)),s.call(this,t,"query"))},get hash(){return this._isInvalid||!this._fragment||"#"===this._fragment?"":this._fragment},set hash(t){this._isInvalid||(this._fragment="#","#"===t[0]&&(t=t.slice(1)),
+s.call(this,t,"fragment"))},get origin(){var t;if(this._isInvalid||!this._scheme)return"";switch(this._scheme){case"data":case"file":case"javascript":case"mailto":return"null"}return t=this.host,t?this._scheme+"://"+t:""}};var m=e.URL;m&&(c.createObjectURL=function(t){return m.createObjectURL.apply(m,arguments)},c.revokeObjectURL=function(t){m.revokeObjectURL(t)}),e.URL=c}}()}}).call(e,n(6))}])}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/pdf.worker.js b/vendor/assets/javascripts/pdf.worker.js
index 970caaaba86..6caaf016ebc 100644..100755
--- a/vendor/assets/javascripts/pdf.worker.js
+++ b/vendor/assets/javascripts/pdf.worker.js
@@ -1,274 +1,4 @@
-(function webpackUniversalModuleDefinition(root, factory) {
- if(typeof exports === 'object' && typeof module === 'object')
- module.exports = factory();
- else if(typeof define === 'function' && define.amd)
- define("PDFLab", [], factory);
- else if(typeof exports === 'object')
- exports["PDFLab"] = factory();
- else
- root["PDFLab"] = factory();
-})(this, function() {
-return /******/ (function(modules) { // webpackBootstrap
-/******/ // The module cache
-/******/ var installedModules = {};
-/******/
-/******/ // The require function
-/******/ function __webpack_require__(moduleId) {
-/******/
-/******/ // Check if module is in cache
-/******/ if(installedModules[moduleId])
-/******/ return installedModules[moduleId].exports;
-/******/
-/******/ // Create a new module (and put it into the cache)
-/******/ var module = installedModules[moduleId] = {
-/******/ i: moduleId,
-/******/ l: false,
-/******/ exports: {}
-/******/ };
-/******/
-/******/ // Execute the module function
-/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
-/******/
-/******/ // Flag the module as loaded
-/******/ module.l = true;
-/******/
-/******/ // Return the exports of the module
-/******/ return module.exports;
-/******/ }
-/******/
-/******/
-/******/ // expose the modules object (__webpack_modules__)
-/******/ __webpack_require__.m = modules;
-/******/
-/******/ // expose the module cache
-/******/ __webpack_require__.c = installedModules;
-/******/
-/******/ // identity function for calling harmony imports with the correct context
-/******/ __webpack_require__.i = function(value) { return value; };
-/******/
-/******/ // define getter function for harmony exports
-/******/ __webpack_require__.d = function(exports, name, getter) {
-/******/ if(!__webpack_require__.o(exports, name)) {
-/******/ Object.defineProperty(exports, name, {
-/******/ configurable: false,
-/******/ enumerable: true,
-/******/ get: getter
-/******/ });
-/******/ }
-/******/ };
-/******/
-/******/ // getDefaultExport function for compatibility with non-harmony modules
-/******/ __webpack_require__.n = function(module) {
-/******/ var getter = module && module.__esModule ?
-/******/ function getDefault() { return module['default']; } :
-/******/ function getModuleExports() { return module; };
-/******/ __webpack_require__.d(getter, 'a', getter);
-/******/ return getter;
-/******/ };
-/******/
-/******/ // Object.prototype.hasOwnProperty.call
-/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
-/******/
-/******/ // __webpack_public_path__
-/******/ __webpack_require__.p = "";
-/******/
-/******/ // Load entry module and return exports
-/******/ return __webpack_require__(__webpack_require__.s = 24);
-/******/ })
-/************************************************************************/
-/******/ ({
-
-/***/ 0:
-/***/ (function(module, exports) {
-
-// shim for using process in browser
-var process = module.exports = {};
-
-// cached from whatever global is present so that test runners that stub it
-// don't break things. But we need to wrap it in a try catch in case it is
-// wrapped in strict mode code which doesn't define any globals. It's inside a
-// function because try/catches deoptimize in certain engines.
-
-var cachedSetTimeout;
-var cachedClearTimeout;
-
-function defaultSetTimout() {
- throw new Error('setTimeout has not been defined');
-}
-function defaultClearTimeout () {
- throw new Error('clearTimeout has not been defined');
-}
-(function () {
- try {
- if (typeof setTimeout === 'function') {
- cachedSetTimeout = setTimeout;
- } else {
- cachedSetTimeout = defaultSetTimout;
- }
- } catch (e) {
- cachedSetTimeout = defaultSetTimout;
- }
- try {
- if (typeof clearTimeout === 'function') {
- cachedClearTimeout = clearTimeout;
- } else {
- cachedClearTimeout = defaultClearTimeout;
- }
- } catch (e) {
- cachedClearTimeout = defaultClearTimeout;
- }
-} ())
-function runTimeout(fun) {
- if (cachedSetTimeout === setTimeout) {
- //normal enviroments in sane situations
- return setTimeout(fun, 0);
- }
- // if setTimeout wasn't available but was latter defined
- if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
- cachedSetTimeout = setTimeout;
- return setTimeout(fun, 0);
- }
- try {
- // when when somebody has screwed with setTimeout but no I.E. maddness
- return cachedSetTimeout(fun, 0);
- } catch(e){
- try {
- // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
- return cachedSetTimeout.call(null, fun, 0);
- } catch(e){
- // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error
- return cachedSetTimeout.call(this, fun, 0);
- }
- }
-
-
-}
-function runClearTimeout(marker) {
- if (cachedClearTimeout === clearTimeout) {
- //normal enviroments in sane situations
- return clearTimeout(marker);
- }
- // if clearTimeout wasn't available but was latter defined
- if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
- cachedClearTimeout = clearTimeout;
- return clearTimeout(marker);
- }
- try {
- // when when somebody has screwed with setTimeout but no I.E. maddness
- return cachedClearTimeout(marker);
- } catch (e){
- try {
- // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
- return cachedClearTimeout.call(null, marker);
- } catch (e){
- // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error.
- // Some versions of I.E. have different rules for clearTimeout vs setTimeout
- return cachedClearTimeout.call(this, marker);
- }
- }
-
-
-
-}
-var queue = [];
-var draining = false;
-var currentQueue;
-var queueIndex = -1;
-
-function cleanUpNextTick() {
- if (!draining || !currentQueue) {
- return;
- }
- draining = false;
- if (currentQueue.length) {
- queue = currentQueue.concat(queue);
- } else {
- queueIndex = -1;
- }
- if (queue.length) {
- drainQueue();
- }
-}
-
-function drainQueue() {
- if (draining) {
- return;
- }
- var timeout = runTimeout(cleanUpNextTick);
- draining = true;
-
- var len = queue.length;
- while(len) {
- currentQueue = queue;
- queue = [];
- while (++queueIndex < len) {
- if (currentQueue) {
- currentQueue[queueIndex].run();
- }
- }
- queueIndex = -1;
- len = queue.length;
- }
- currentQueue = null;
- draining = false;
- runClearTimeout(timeout);
-}
-
-process.nextTick = function (fun) {
- var args = new Array(arguments.length - 1);
- if (arguments.length > 1) {
- for (var i = 1; i < arguments.length; i++) {
- args[i - 1] = arguments[i];
- }
- }
- queue.push(new Item(fun, args));
- if (queue.length === 1 && !draining) {
- runTimeout(drainQueue);
- }
-};
-
-// v8 likes predictible objects
-function Item(fun, array) {
- this.fun = fun;
- this.array = array;
-}
-Item.prototype.run = function () {
- this.fun.apply(null, this.array);
-};
-process.title = 'browser';
-process.browser = true;
-process.env = {};
-process.argv = [];
-process.version = ''; // empty string to avoid regexp issues
-process.versions = {};
-
-function noop() {}
-
-process.on = noop;
-process.addListener = noop;
-process.once = noop;
-process.off = noop;
-process.removeListener = noop;
-process.removeAllListeners = noop;
-process.emit = noop;
-
-process.binding = function (name) {
- throw new Error('process.binding is not supported');
-};
-
-process.cwd = function () { return '/' };
-process.chdir = function (dir) {
- throw new Error('process.chdir is not supported');
-};
-process.umask = function() { return 0; };
-
-
-/***/ }),
-
-/***/ 1:
-/***/ (function(module, exports, __webpack_require__) {
-
-/* WEBPACK VAR INJECTION */(function(process) {/* Copyright 2017 Mozilla Foundation
+/* Copyright 2017 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -284,7 +14,7 @@ process.umask = function() { return 0; };
*/
(function webpackUniversalModuleDefinition(root, factory) {
- if(true)
+ if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("pdfjs-dist/build/pdf.worker", [], factory);
@@ -38605,35 +38335,4 @@ if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) {
/***/ })
/******/ ]);
-});
-/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(0)))
-
-/***/ }),
-
-/***/ 24:
-/***/ (function(module, exports, __webpack_require__) {
-
-/* Copyright 2016 Mozilla Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/* eslint-disable strict */
-
-(typeof window !== 'undefined' ? window : {}).pdfjsDistBuildPdfWorker =
- __webpack_require__(1);
-
-
-/***/ })
-
-/******/ });
}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/pdf.worker.min.js b/vendor/assets/javascripts/pdf.worker.min.js
new file mode 100755
index 00000000000..3503a8f46ca
--- /dev/null
+++ b/vendor/assets/javascripts/pdf.worker.min.js
@@ -0,0 +1,19 @@
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf.worker",[],t):"object"==typeof exports?exports["pdfjs-dist/build/pdf.worker"]=t():e["pdfjs-dist/build/pdf.worker"]=e.pdfjsDistBuildPdfWorker=t()}(this,function(){return function(e){function t(r){if(a[r])return a[r].exports;var i=a[r]={i:r,l:!1,exports:{}};e[r].call(i.exports,i,i.exports,t);i.l=!0;return i.exports}var a={};t.m=e;t.c=a;t.i=function(e){return e};t.d=function(e,a,r){t.o(e,a)||Object.defineProperty(e,a,{configurable:!1,enumerable:!0,get:r})};t.n=function(e){var a=e&&e.__esModule?function(){return e.default}:function(){return e};t.d(a,"a",a);return a};t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};t.p="";return t(t.s=36)}([function(e,t,a){"use strict";(function(e){function r(e){ae=e}function i(){return ae}function n(e){ae>=$.infos&&console.log("Info: "+e)}function s(e){ae>=$.warnings&&console.log("Warning: "+e)}function o(e){console.log("Deprecated API usage: "+e)}function c(e){if(ae>=$.errors){console.log("Error: "+e);console.log(l())}throw new Error(e)}function l(){try{throw new Error}catch(e){return e.stack?e.stack.split("\n").slice(2).join("\n"):""}}function h(e,t){e||c(t)}function u(e,t){try{var a=new URL(e);if(!a.origin||"null"===a.origin)return!1}catch(e){return!1}var r=new URL(t,a);return a.origin===r.origin}function f(e){if(!e)return!1;switch(e.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}function d(e,t){if(!e)return null;try{var a=t?new URL(e,t):new URL(e);if(f(a))return a}catch(e){}return null}function g(e,t,a){Object.defineProperty(e,t,{value:a,enumerable:!0,configurable:!0,writable:!1});return a}function p(e){var t;return function(){if(e){t=Object.create(null);e(t);e=null}return t}}function m(e){if("string"!=typeof e){s("The argument for removeNullCharacters must be a string.");return e}return e.replace(de,"")}function b(e){h(null!==e&&"object"==typeof e&&void 0!==e.length,"Invalid argument for bytesToString");var t=e.length;if(t<8192)return String.fromCharCode.apply(null,e);for(var a=[],r=0;r<t;r+=8192){var i=Math.min(r+8192,t),n=e.subarray(r,i);a.push(String.fromCharCode.apply(null,n))}return a.join("")}function v(e){h("string"==typeof e,"Invalid argument for stringToBytes");for(var t=e.length,a=new Uint8Array(t),r=0;r<t;++r)a[r]=255&e.charCodeAt(r);return a}function y(e){if(void 0!==e.length)return e.length;h(void 0!==e.byteLength);return e.byteLength}function k(e){if(1===e.length&&e[0]instanceof Uint8Array)return e[0];var t,a,r,i=0,n=e.length;for(t=0;t<n;t++){a=e[t];r=y(a);i+=r}var s=0,o=new Uint8Array(i);for(t=0;t<n;t++){a=e[t];a instanceof Uint8Array||(a="string"==typeof a?v(a):new Uint8Array(a));r=a.byteLength;o.set(a,s);s+=r}return o}function w(e){return String.fromCharCode(e>>24&255,e>>16&255,e>>8&255,255&e)}function C(e){for(var t=1,a=0;e>t;){t<<=1;a++}return a}function x(e,t){return e[t]<<24>>24}function S(e,t){return e[t]<<8|e[t+1]}function A(e,t){return(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0}function I(){var e=new Uint8Array(2);e[0]=1;return 1===new Uint16Array(e.buffer)[0]}function B(){try{new Function("");return!0}catch(e){return!1}}function R(e){var t,a=e.length,r=[];if("þ"===e[0]&&"ÿ"===e[1])for(t=2;t<a;t+=2)r.push(String.fromCharCode(e.charCodeAt(t)<<8|e.charCodeAt(t+1)));else for(t=0;t<a;++t){var i=ve[e.charCodeAt(t)];r.push(i?String.fromCharCode(i):e.charAt(t))}return r.join("")}function T(e){return decodeURIComponent(escape(e))}function O(e){return unescape(encodeURIComponent(e))}function P(e){for(var t in e)return!1;return!0}function M(e){return"boolean"==typeof e}function E(e){return"number"==typeof e&&(0|e)===e}function L(e){return"number"==typeof e}function D(e){return"string"==typeof e}function F(e){return e instanceof Array}function q(e){return"object"==typeof e&&null!==e&&void 0!==e.byteLength}function U(e){return 32===e||9===e||13===e||10===e}function N(){return"undefined"==typeof __pdfjsdev_webpack__&&("object"==typeof process&&process+""=="[object process]")}function j(){var e={};e.promise=new Promise(function(t,a){e.resolve=t;e.reject=a});return e}function _(e,t,a){this.sourceName=e;this.targetName=t;this.comObj=a;this.callbackIndex=1;this.postMessageTransfers=!0;var r=this.callbacksCapabilities=Object.create(null),i=this.actionHandler=Object.create(null);this._onComObjOnMessage=function(e){var t=e.data;if(t.targetName===this.sourceName)if(t.isReply){var n=t.callbackId;if(t.callbackId in r){var s=r[n];delete r[n];"error"in t?s.reject(t.error):s.resolve(t.data)}else c("Cannot resolve callback "+n)}else if(t.action in i){var o=i[t.action];if(t.callbackId){var l=this.sourceName,h=t.sourceName;Promise.resolve().then(function(){return o[0].call(o[1],t.data)}).then(function(e){a.postMessage({sourceName:l,targetName:h,isReply:!0,callbackId:t.callbackId,data:e})},function(e){e instanceof Error&&(e+="");a.postMessage({sourceName:l,targetName:h,isReply:!0,callbackId:t.callbackId,error:e})})}else o[0].call(o[1],t.data)}else c("Unknown action from worker: "+t.action)}.bind(this);a.addEventListener("message",this._onComObjOnMessage)}function z(e,t,a){var r=new Image;r.onload=function(){a.resolve(e,r)};r.onerror=function(){a.resolve(e,null);s("Error during JPEG image loading")};r.src=t}var H=(a(37),"undefined"!=typeof window?window:void 0!==e?e:"undefined"!=typeof self?self:void 0),G=[.001,0,0,.001,0,0],X={FILL:0,STROKE:1,FILL_STROKE:2,INVISIBLE:3,FILL_ADD_TO_PATH:4,STROKE_ADD_TO_PATH:5,FILL_STROKE_ADD_TO_PATH:6,ADD_TO_PATH:7,FILL_STROKE_MASK:3,ADD_TO_PATH_FLAG:4},V={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},W={TEXT:1,LINK:2,FREETEXT:3,LINE:4,SQUARE:5,CIRCLE:6,POLYGON:7,POLYLINE:8,HIGHLIGHT:9,UNDERLINE:10,SQUIGGLY:11,STRIKEOUT:12,STAMP:13,CARET:14,INK:15,POPUP:16,FILEATTACHMENT:17,SOUND:18,MOVIE:19,WIDGET:20,SCREEN:21,PRINTERMARK:22,TRAPNET:23,WATERMARK:24,THREED:25,REDACT:26},K={INVISIBLE:1,HIDDEN:2,PRINT:4,NOZOOM:8,NOROTATE:16,NOVIEW:32,READONLY:64,LOCKED:128,TOGGLENOVIEW:256,LOCKEDCONTENTS:512},Y={READONLY:1,REQUIRED:2,NOEXPORT:4,MULTILINE:4096,PASSWORD:8192,NOTOGGLETOOFF:16384,RADIO:32768,PUSHBUTTON:65536,COMBO:131072,EDIT:262144,SORT:524288,FILESELECT:1048576,MULTISELECT:2097152,DONOTSPELLCHECK:4194304,DONOTSCROLL:8388608,COMB:16777216,RICHTEXT:33554432,RADIOSINUNISON:33554432,COMMITONSELCHANGE:67108864},J={SOLID:1,DASHED:2,BEVELED:3,INSET:4,UNDERLINE:5},Z={UNKNOWN:0,FLATE:1,LZW:2,DCT:3,JPX:4,JBIG:5,A85:6,AHX:7,CCF:8,RL:9},Q={UNKNOWN:0,TYPE1:1,TYPE1C:2,CIDFONTTYPE0:3,CIDFONTTYPE0C:4,TRUETYPE:5,CIDFONTTYPE2:6,TYPE3:7,OPENTYPE:8,TYPE0:9,MMTYPE1:10},$={errors:0,warnings:1,infos:5},ee={NONE:0,BINARY:1,STREAM:2},te={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotations:78,endAnnotations:79,beginAnnotation:80,endAnnotation:81,paintJpegXObject:82,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91},ae=$.warnings,re={unknown:"unknown",forms:"forms",javaScript:"javaScript",smask:"smask",shadingPattern:"shadingPattern",font:"font"},ie={NEED_PASSWORD:1,INCORRECT_PASSWORD:2},ne=function(){function e(e,t){this.name="PasswordException";this.message=e;this.code=t}e.prototype=new Error;e.constructor=e;return e}(),se=function(){function e(e,t){this.name="UnknownErrorException";this.message=e;this.details=t}e.prototype=new Error;e.constructor=e;return e}(),oe=function(){function e(e){this.name="InvalidPDFException";this.message=e}e.prototype=new Error;e.constructor=e;return e}(),ce=function(){function e(e){this.name="MissingPDFException";this.message=e}e.prototype=new Error;e.constructor=e;return e}(),le=function(){function e(e,t){this.name="UnexpectedResponseException";this.message=e;this.status=t}e.prototype=new Error;e.constructor=e;return e}(),he=function(){function e(e){this.message=e}e.prototype=new Error;e.prototype.name="NotImplementedException";e.constructor=e;return e}(),ue=function(){function e(e,t){this.begin=e;this.end=t;this.message="Missing data ["+e+", "+t+")"}e.prototype=new Error;e.prototype.name="MissingDataException";e.constructor=e;return e}(),fe=function(){function e(e){this.message=e}e.prototype=new Error;e.prototype.name="XRefParseException";e.constructor=e;return e}(),de=/\x00/g,ge=function(){function e(e,t){this.buffer=e;this.byteLength=e.length;this.length=void 0===t?this.byteLength>>2:t;a(this.length)}function t(e){return{get:function(){var t=this.buffer,a=e<<2;return(t[a]|t[a+1]<<8|t[a+2]<<16|t[a+3]<<24)>>>0},set:function(t){var a=this.buffer,r=e<<2;a[r]=255&t;a[r+1]=t>>8&255;a[r+2]=t>>16&255;a[r+3]=t>>>24&255}}}function a(a){for(;r<a;){Object.defineProperty(e.prototype,r,t(r));r++}}e.prototype=Object.create(null);var r=0;return e}();t.Uint32ArrayView=ge;var pe=[1,0,0,1,0,0],me=function(){function e(){}var t=["rgb(",0,",",0,",",0,")"];e.makeCssRgb=function(e,a,r){t[1]=e;t[3]=a;t[5]=r;return t.join("")};e.transform=function(e,t){return[e[0]*t[0]+e[2]*t[1],e[1]*t[0]+e[3]*t[1],e[0]*t[2]+e[2]*t[3],e[1]*t[2]+e[3]*t[3],e[0]*t[4]+e[2]*t[5]+e[4],e[1]*t[4]+e[3]*t[5]+e[5]]};e.applyTransform=function(e,t){return[e[0]*t[0]+e[1]*t[2]+t[4],e[0]*t[1]+e[1]*t[3]+t[5]]};e.applyInverseTransform=function(e,t){var a=t[0]*t[3]-t[1]*t[2];return[(e[0]*t[3]-e[1]*t[2]+t[2]*t[5]-t[4]*t[3])/a,(-e[0]*t[1]+e[1]*t[0]+t[4]*t[1]-t[5]*t[0])/a]};e.getAxialAlignedBoundingBox=function(t,a){var r=e.applyTransform(t,a),i=e.applyTransform(t.slice(2,4),a),n=e.applyTransform([t[0],t[3]],a),s=e.applyTransform([t[2],t[1]],a);return[Math.min(r[0],i[0],n[0],s[0]),Math.min(r[1],i[1],n[1],s[1]),Math.max(r[0],i[0],n[0],s[0]),Math.max(r[1],i[1],n[1],s[1])]};e.inverseTransform=function(e){var t=e[0]*e[3]-e[1]*e[2];return[e[3]/t,-e[1]/t,-e[2]/t,e[0]/t,(e[2]*e[5]-e[4]*e[3])/t,(e[4]*e[1]-e[5]*e[0])/t]};e.apply3dTransform=function(e,t){return[e[0]*t[0]+e[1]*t[1]+e[2]*t[2],e[3]*t[0]+e[4]*t[1]+e[5]*t[2],e[6]*t[0]+e[7]*t[1]+e[8]*t[2]]};e.singularValueDecompose2dScale=function(e){var t=[e[0],e[2],e[1],e[3]],a=e[0]*t[0]+e[1]*t[2],r=e[0]*t[1]+e[1]*t[3],i=e[2]*t[0]+e[3]*t[2],n=e[2]*t[1]+e[3]*t[3],s=(a+n)/2,o=Math.sqrt((a+n)*(a+n)-4*(a*n-i*r))/2,c=s+o||1,l=s-o||1;return[Math.sqrt(c),Math.sqrt(l)]};e.normalizeRect=function(e){var t=e.slice(0);if(e[0]>e[2]){t[0]=e[2];t[2]=e[0]}if(e[1]>e[3]){t[1]=e[3];t[3]=e[1]}return t};e.intersect=function(t,a){function r(e,t){return e-t}var i=[t[0],t[2],a[0],a[2]].sort(r),n=[t[1],t[3],a[1],a[3]].sort(r),s=[];t=e.normalizeRect(t);a=e.normalizeRect(a);if(!(i[0]===t[0]&&i[1]===a[0]||i[0]===a[0]&&i[1]===t[0]))return!1;s[0]=i[1];s[2]=i[2];if(!(n[0]===t[1]&&n[1]===a[1]||n[0]===a[1]&&n[1]===t[1]))return!1;s[1]=n[1];s[3]=n[2];return s};e.sign=function(e){return e<0?-1:1};var a=["","C","CC","CCC","CD","D","DC","DCC","DCCC","CM","","X","XX","XXX","XL","L","LX","LXX","LXXX","XC","","I","II","III","IV","V","VI","VII","VIII","IX"];e.toRoman=function(e,t){h(E(e)&&e>0,"The number should be a positive integer.");for(var r,i=[];e>=1e3;){e-=1e3;i.push("M")}r=e/100|0;e%=100;i.push(a[r]);r=e/10|0;e%=10;i.push(a[10+r]);i.push(a[20+e]);var n=i.join("");return t?n.toLowerCase():n};e.appendToArray=function(e,t){Array.prototype.push.apply(e,t)};e.prependToArray=function(e,t){Array.prototype.unshift.apply(e,t)};e.extendObj=function(e,t){for(var a in t)e[a]=t[a]};e.getInheritableProperty=function(e,t,a){for(;e&&!e.has(t);)e=e.get("Parent");return e?a?e.getArray(t):e.get(t):null};e.inherit=function(e,t,a){e.prototype=Object.create(t.prototype);e.prototype.constructor=e;for(var r in a)e.prototype[r]=a[r]};e.loadScript=function(e,t){var a=document.createElement("script"),r=!1;a.setAttribute("src",e);t&&(a.onload=function(){r||t();r=!0});document.getElementsByTagName("head")[0].appendChild(a)};return e}(),be=function(){function e(e,t,a,r,i,n){this.viewBox=e;this.scale=t;this.rotation=a;this.offsetX=r;this.offsetY=i;var s,o,c,l,h=(e[2]+e[0])/2,u=(e[3]+e[1])/2;a%=360;a=a<0?a+360:a;switch(a){case 180:s=-1;o=0;c=0;l=1;break;case 90:s=0;o=1;c=1;l=0;break;case 270:s=0;o=-1;c=-1;l=0;break;default:s=1;o=0;c=0;l=-1}if(n){c=-c;l=-l}var f,d,g,p;if(0===s){f=Math.abs(u-e[1])*t+r;d=Math.abs(h-e[0])*t+i;g=Math.abs(e[3]-e[1])*t;p=Math.abs(e[2]-e[0])*t}else{f=Math.abs(h-e[0])*t+r;d=Math.abs(u-e[1])*t+i;g=Math.abs(e[2]-e[0])*t;p=Math.abs(e[3]-e[1])*t}this.transform=[s*t,o*t,c*t,l*t,f-s*t*h-c*t*u,d-o*t*h-l*t*u];this.width=g;this.height=p;this.fontScale=t}e.prototype={clone:function(t){t=t||{};var a="scale"in t?t.scale:this.scale,r="rotation"in t?t.rotation:this.rotation;return new e(this.viewBox.slice(),a,r,this.offsetX,this.offsetY,t.dontFlip)},convertToViewportPoint:function(e,t){return me.applyTransform([e,t],this.transform)},convertToViewportRectangle:function(e){var t=me.applyTransform([e[0],e[1]],this.transform),a=me.applyTransform([e[2],e[3]],this.transform);return[t[0],t[1],a[0],a[1]]},convertToPdfPoint:function(e,t){return me.applyInverseTransform([e,t],this.transform)}};return e}(),ve=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,728,711,710,729,733,731,730,732,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8226,8224,8225,8230,8212,8211,402,8260,8249,8250,8722,8240,8222,8220,8221,8216,8217,8218,8482,64257,64258,321,338,352,376,381,305,322,339,353,382,0,8364],ye=function(){function e(e,t,a){for(;e.length<a;)e+=t;return e}function t(){this.started=Object.create(null);this.times=[];this.enabled=!0}t.prototype={time:function(e){if(this.enabled){e in this.started&&s("Timer is already running for "+e);this.started[e]=Date.now()}},timeEnd:function(e){if(this.enabled){e in this.started||s("Timer has not been started for "+e);this.times.push({name:e,start:this.started[e],end:Date.now()});delete this.started[e]}},toString:function(){var t,a,r=this.times,i="",n=0;for(t=0,a=r.length;t<a;++t){var s=r[t].name;s.length>n&&(n=s.length)}for(t=0,a=r.length;t<a;++t){var o=r[t],c=o.end-o.start;i+=e(o.name," ",n)+" "+c+"ms\n"}return i}};return t}(),ke=function(e,t){if("undefined"!=typeof Blob)return new Blob([e],{type:t});s('The "Blob" constructor is not supported.')},we=function(){var e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";return function(t,a,r){if(!r&&"undefined"!=typeof URL&&URL.createObjectURL){var i=ke(t,a);return URL.createObjectURL(i)}for(var n="data:"+a+";base64,",s=0,o=t.length;s<o;s+=3){var c=255&t[s],l=255&t[s+1],h=255&t[s+2],u=c>>2,f=(3&c)<<4|l>>4,d=s+1<o?(15&l)<<2|h>>6:64,g=s+2<o?63&h:64;n+=e[u]+e[f]+e[d]+e[g]}return n}}();_.prototype={on:function(e,t,a){var r=this.actionHandler;r[e]&&c('There is already an actionName called "'+e+'"');r[e]=[t,a]},send:function(e,t,a){var r={sourceName:this.sourceName,targetName:this.targetName,action:e,data:t};this.postMessage(r,a)},sendWithPromise:function(e,t,a){var r=this.callbackIndex++,i={sourceName:this.sourceName,targetName:this.targetName,action:e,data:t,callbackId:r},n=j();this.callbacksCapabilities[r]=n;try{this.postMessage(i,a)}catch(e){n.reject(e)}return n.promise},postMessage:function(e,t){t&&this.postMessageTransfers?this.comObj.postMessage(e,t):this.comObj.postMessage(e)},destroy:function(){this.comObj.removeEventListener("message",this._onComObjOnMessage)}};t.FONT_IDENTITY_MATRIX=G;t.IDENTITY_MATRIX=pe;t.OPS=te;t.VERBOSITY_LEVELS=$;t.UNSUPPORTED_FEATURES=re;t.AnnotationBorderStyleType=J;t.AnnotationFieldFlag=Y;t.AnnotationFlag=K;t.AnnotationType=W;t.FontType=Q;t.ImageKind=V;t.CMapCompressionType=ee;t.InvalidPDFException=oe;t.MessageHandler=_;t.MissingDataException=ue;t.MissingPDFException=ce;t.NotImplementedException=he;t.PageViewport=be;t.PasswordException=ne;t.PasswordResponses=ie;t.StatTimer=ye;t.StreamType=Z;t.TextRenderingMode=X;t.UnexpectedResponseException=le;t.UnknownErrorException=se;t.Util=me;t.XRefParseException=fe;t.arrayByteLength=y;t.arraysToBytes=k;t.assert=h;t.bytesToString=b;t.createBlob=ke;t.createPromiseCapability=j;t.createObjectURL=we;t.deprecated=o;t.error=c;t.getLookupTableFactory=p;t.getVerbosityLevel=i;t.globalScope=H;t.info=n;t.isArray=F;t.isArrayBuffer=q;t.isBool=M;t.isEmptyObj=P;t.isInt=E;t.isNum=L;t.isString=D;t.isSpace=U;t.isNodeJS=N;t.isSameOrigin=u;t.createValidAbsoluteUrl=d;t.isLittleEndian=I;t.isEvalSupported=B;t.loadJpegStream=z;t.log2=C;t.readInt8=x;t.readUint16=S;t.readUint32=A;t.removeNullCharacters=m;t.setVerbosityLevel=r;t.shadow=g;t.string32=w;t.stringToBytes=v;t.stringToPDFString=R;t.stringToUTF8String=T;t.utf8StringToString=O;t.warn=s}).call(t,a(9))},function(e,t,a){"use strict";function r(e){return e===f}function i(e,t){return e instanceof d&&(void 0===t||e.name===t)}function n(e,t){return e instanceof g&&(void 0===t||e.cmd===t)}function s(e,t){return e instanceof p&&(void 0===t||i(e.get("Type"),t))}function o(e){return e instanceof m}function c(e,t){return e.num===t.num&&e.gen===t.gen}function l(e){return"object"==typeof e&&null!==e&&void 0!==e.getBytes}var h=a(0),u=h.isArray,f={},d=function(){function e(e){this.name=e}e.prototype={};var t=Object.create(null);e.get=function(a){var r=t[a];return r||(t[a]=new e(a))};return e}(),g=function(){function e(e){this.cmd=e}e.prototype={};var t=Object.create(null);e.get=function(a){var r=t[a];return r||(t[a]=new e(a))};return e}(),p=function(){function e(e){this.map=Object.create(null);this.xref=e;this.objId=null;this.suppressEncryption=!1;this.__nonSerializable__=t}var t=function(){return t};e.prototype={assignXref:function(e){this.xref=e},get:function(e,t,a){var r,i=this.xref,n=this.suppressEncryption;if(void 0!==(r=this.map[e])||e in this.map||void 0===t)return i?i.fetchIfRef(r,n):r;if(void 0!==(r=this.map[t])||t in this.map||void 0===a)return i?i.fetchIfRef(r,n):r;r=this.map[a]||null;return i?i.fetchIfRef(r,n):r},getAsync:function(e,t,a){var r,i=this.xref,n=this.suppressEncryption;if(void 0!==(r=this.map[e])||e in this.map||void 0===t)return i?i.fetchIfRefAsync(r,n):Promise.resolve(r);if(void 0!==(r=this.map[t])||t in this.map||void 0===a)return i?i.fetchIfRefAsync(r,n):Promise.resolve(r);r=this.map[a]||null;return i?i.fetchIfRefAsync(r,n):Promise.resolve(r)},getArray:function(e,t,a){var r=this.get(e,t,a),i=this.xref,n=this.suppressEncryption;if(!u(r)||!i)return r;r=r.slice();for(var s=0,c=r.length;s<c;s++)o(r[s])&&(r[s]=i.fetch(r[s],n));return r},getRaw:function(e){return this.map[e]},getKeys:function(){return Object.keys(this.map)},set:function(e,t){this.map[e]=t},has:function(e){return e in this.map},forEach:function(e){for(var t in this.map)e(t,this.get(t))}};e.empty=new e(null);e.merge=function(t,a){for(var r=new e(t),i=0,n=a.length;i<n;i++){var o=a[i];if(s(o))for(var c in o.map)r.map[c]||(r.map[c]=o.map[c])}return r};return e}(),m=function(){function e(e,t){this.num=e;this.gen=t}e.prototype={toString:function(){var e=this.num+"R";0!==this.gen&&(e+=this.gen);return e}};return e}(),b=function(){function e(){this.dict=Object.create(null)}e.prototype={has:function(e){return e.toString()in this.dict},put:function(e){this.dict[e.toString()]=!0},remove:function(e){delete this.dict[e.toString()]}};return e}(),v=function(){function e(){this.dict=Object.create(null)}e.prototype={get:function(e){return this.dict[e.toString()]},has:function(e){return e.toString()in this.dict},put:function(e,t){this.dict[e.toString()]=t},putAlias:function(e,t){this.dict[e.toString()]=this.get(t)},forEach:function(e,t){for(var a in this.dict)e.call(t,this.dict[a])},clear:function(){this.dict=Object.create(null)}};return e}();t.EOF=f;t.Cmd=g;t.Dict=p;t.Name=d;t.Ref=m;t.RefSet=b;t.RefSetCache=v;t.isEOF=r;t.isCmd=n;t.isDict=s;t.isName=i;t.isRef=o;t.isRefsEqual=c;t.isStream=l},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(28),s=a(29),o=a(15),c=r.Util,l=r.error,h=r.info,u=r.isInt,f=r.isArray,d=r.createObjectURL,g=r.shadow,p=r.isSpace,m=i.Dict,b=i.isDict,v=i.isStream,y=n.Jbig2Image,k=s.JpegImage,w=o.JpxImage,C=function(){function e(e,t,a,r){this.bytes=e instanceof Uint8Array?e:new Uint8Array(e);this.start=t||0;this.pos=this.start;this.end=t+a||this.bytes.length;this.dict=r}e.prototype={get length(){return this.end-this.start},get isEmpty(){return 0===this.length},getByte:function(){return this.pos>=this.end?-1:this.bytes[this.pos++]},getUint16:function(){var e=this.getByte(),t=this.getByte();return-1===e||-1===t?-1:(e<<8)+t},getInt32:function(){return(this.getByte()<<24)+(this.getByte()<<16)+(this.getByte()<<8)+this.getByte()},getBytes:function(e){var t=this.bytes,a=this.pos,r=this.end;if(!e)return t.subarray(a,r);var i=a+e;i>r&&(i=r);this.pos=i;return t.subarray(a,i)},peekByte:function(){var e=this.getByte();this.pos--;return e},peekBytes:function(e){var t=this.getBytes(e);this.pos-=t.length;return t},skip:function(e){e||(e=1);this.pos+=e},reset:function(){this.pos=this.start},moveStart:function(){this.start=this.pos},makeSubStream:function(t,a,r){return new e(this.bytes.buffer,t,a,r)}};return e}(),x=function(){function e(e){for(var t=e.length,a=new Uint8Array(t),r=0;r<t;++r)a[r]=e.charCodeAt(r);C.call(this,a)}e.prototype=C.prototype;return e}(),S=function(){function e(e){this.pos=0;this.bufferLength=0;this.eof=!1;this.buffer=t;this.minBufferLength=512;if(e)for(;this.minBufferLength<e;)this.minBufferLength*=2}var t=new Uint8Array(0);e.prototype={get isEmpty(){for(;!this.eof&&0===this.bufferLength;)this.readBlock();return 0===this.bufferLength},ensureBuffer:function(e){var t=this.buffer;if(e<=t.byteLength)return t;for(var a=this.minBufferLength;a<e;)a*=2;var r=new Uint8Array(a);r.set(t);return this.buffer=r},getByte:function(){for(var e=this.pos;this.bufferLength<=e;){if(this.eof)return-1;this.readBlock()}return this.buffer[this.pos++]},getUint16:function(){var e=this.getByte(),t=this.getByte();return-1===e||-1===t?-1:(e<<8)+t},getInt32:function(){return(this.getByte()<<24)+(this.getByte()<<16)+(this.getByte()<<8)+this.getByte()},getBytes:function(e){var t,a=this.pos;if(e){this.ensureBuffer(a+e);t=a+e;for(;!this.eof&&this.bufferLength<t;)this.readBlock();var r=this.bufferLength;t>r&&(t=r)}else{for(;!this.eof;)this.readBlock();t=this.bufferLength}this.pos=t;return this.buffer.subarray(a,t)},peekByte:function(){var e=this.getByte();this.pos--;return e},peekBytes:function(e){var t=this.getBytes(e);this.pos-=t.length;return t},makeSubStream:function(e,t,a){for(var r=e+t;this.bufferLength<=r&&!this.eof;)this.readBlock();return new C(this.buffer,e,t,a)},skip:function(e){e||(e=1);this.pos+=e},reset:function(){this.pos=0},getBaseStreams:function(){return this.str&&this.str.getBaseStreams?this.str.getBaseStreams():[]}};return e}(),A=function(){function e(e){this.streams=e;S.call(this,null)}e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){var e=this.streams;if(0!==e.length){var t=e.shift(),a=t.getBytes(),r=this.bufferLength,i=r+a.length;this.ensureBuffer(i).set(a,r);this.bufferLength=i}else this.eof=!0};e.prototype.getBaseStreams=function(){for(var e=[],t=0,a=this.streams.length;t<a;t++){var r=this.streams[t];r.getBaseStreams&&c.appendToArray(e,r.getBaseStreams())}return e};return e}(),I=function(){function e(e,t){this.str=e;this.dict=e.dict;var a=e.getByte(),r=e.getByte();-1!==a&&-1!==r||l("Invalid header in flate stream: "+a+", "+r);8!=(15&a)&&l("Unknown compression method in flate stream: "+a+", "+r);((a<<8)+r)%31!=0&&l("Bad FCHECK in flate stream: "+a+", "+r);32&r&&l("FDICT bit set in flate stream: "+a+", "+r);this.codeSize=0;this.codeBuf=0;S.call(this,t)}var t=new Int32Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=new Int32Array([3,4,5,6,7,8,9,10,65547,65549,65551,65553,131091,131095,131099,131103,196643,196651,196659,196667,262211,262227,262243,262259,327811,327843,327875,327907,258,258,258]),r=new Int32Array([1,2,3,4,65541,65543,131081,131085,196625,196633,262177,262193,327745,327777,393345,393409,459009,459137,524801,525057,590849,591361,657409,658433,724993,727041,794625,798721,868353,876545]),i=[new Int32Array([459008,524368,524304,524568,459024,524400,524336,590016,459016,524384,524320,589984,524288,524416,524352,590048,459012,524376,524312,589968,459028,524408,524344,590032,459020,524392,524328,59e4,524296,524424,524360,590064,459010,524372,524308,524572,459026,524404,524340,590024,459018,524388,524324,589992,524292,524420,524356,590056,459014,524380,524316,589976,459030,524412,524348,590040,459022,524396,524332,590008,524300,524428,524364,590072,459009,524370,524306,524570,459025,524402,524338,590020,459017,524386,524322,589988,524290,524418,524354,590052,459013,524378,524314,589972,459029,524410,524346,590036,459021,524394,524330,590004,524298,524426,524362,590068,459011,524374,524310,524574,459027,524406,524342,590028,459019,524390,524326,589996,524294,524422,524358,590060,459015,524382,524318,589980,459031,524414,524350,590044,459023,524398,524334,590012,524302,524430,524366,590076,459008,524369,524305,524569,459024,524401,524337,590018,459016,524385,524321,589986,524289,524417,524353,590050,459012,524377,524313,589970,459028,524409,524345,590034,459020,524393,524329,590002,524297,524425,524361,590066,459010,524373,524309,524573,459026,524405,524341,590026,459018,524389,524325,589994,524293,524421,524357,590058,459014,524381,524317,589978,459030,524413,524349,590042,459022,524397,524333,590010,524301,524429,524365,590074,459009,524371,524307,524571,459025,524403,524339,590022,459017,524387,524323,589990,524291,524419,524355,590054,459013,524379,524315,589974,459029,524411,524347,590038,459021,524395,524331,590006,524299,524427,524363,590070,459011,524375,524311,524575,459027,524407,524343,590030,459019,524391,524327,589998,524295,524423,524359,590062,459015,524383,524319,589982,459031,524415,524351,590046,459023,524399,524335,590014,524303,524431,524367,590078,459008,524368,524304,524568,459024,524400,524336,590017,459016,524384,524320,589985,524288,524416,524352,590049,459012,524376,524312,589969,459028,524408,524344,590033,459020,524392,524328,590001,524296,524424,524360,590065,459010,524372,524308,524572,459026,524404,524340,590025,459018,524388,524324,589993,524292,524420,524356,590057,459014,524380,524316,589977,459030,524412,524348,590041,459022,524396,524332,590009,524300,524428,524364,590073,459009,524370,524306,524570,459025,524402,524338,590021,459017,524386,524322,589989,524290,524418,524354,590053,459013,524378,524314,589973,459029,524410,524346,590037,459021,524394,524330,590005,524298,524426,524362,590069,459011,524374,524310,524574,459027,524406,524342,590029,459019,524390,524326,589997,524294,524422,524358,590061,459015,524382,524318,589981,459031,524414,524350,590045,459023,524398,524334,590013,524302,524430,524366,590077,459008,524369,524305,524569,459024,524401,524337,590019,459016,524385,524321,589987,524289,524417,524353,590051,459012,524377,524313,589971,459028,524409,524345,590035,459020,524393,524329,590003,524297,524425,524361,590067,459010,524373,524309,524573,459026,524405,524341,590027,459018,524389,524325,589995,524293,524421,524357,590059,459014,524381,524317,589979,459030,524413,524349,590043,459022,524397,524333,590011,524301,524429,524365,590075,459009,524371,524307,524571,459025,524403,524339,590023,459017,524387,524323,589991,524291,524419,524355,590055,459013,524379,524315,589975,459029,524411,524347,590039,459021,524395,524331,590007,524299,524427,524363,590071,459011,524375,524311,524575,459027,524407,524343,590031,459019,524391,524327,589999,524295,524423,524359,590063,459015,524383,524319,589983,459031,524415,524351,590047,459023,524399,524335,590015,524303,524431,524367,590079]),9],n=[new Int32Array([327680,327696,327688,327704,327684,327700,327692,327708,327682,327698,327690,327706,327686,327702,327694,0,327681,327697,327689,327705,327685,327701,327693,327709,327683,327699,327691,327707,327687,327703,327695,0]),5];e.prototype=Object.create(S.prototype);e.prototype.getBits=function(e){for(var t,a=this.str,r=this.codeSize,i=this.codeBuf;r<e;){-1===(t=a.getByte())&&l("Bad encoding in flate stream");i|=t<<r;r+=8}t=i&(1<<e)-1;this.codeBuf=i>>e;this.codeSize=r-=e;return t};e.prototype.getCode=function(e){for(var t,a=this.str,r=e[0],i=e[1],n=this.codeSize,s=this.codeBuf;n<i&&-1!==(t=a.getByte());){s|=t<<n;n+=8}var o=r[s&(1<<i)-1],c=o>>16,h=65535&o;(c<1||n<c)&&l("Bad encoding in flate stream");this.codeBuf=s>>c;this.codeSize=n-c;return h};e.prototype.generateHuffmanTable=function(e){var t,a=e.length,r=0;for(t=0;t<a;++t)e[t]>r&&(r=e[t]);for(var i=1<<r,n=new Int32Array(i),s=1,o=0,c=2;s<=r;++s,o<<=1,c<<=1)for(var l=0;l<a;++l)if(e[l]===s){var h=0,u=o;for(t=0;t<s;++t){h=h<<1|1&u;u>>=1}for(t=h;t<i;t+=c)n[t]=s<<16|l;++o}return[n,r]};e.prototype.readBlock=function(){var e,s,o=this.str,c=this.getBits(3);1&c&&(this.eof=!0);c>>=1;if(0!==c){var h,u;if(1===c){h=i;u=n}else if(2===c){var f,d=this.getBits(5)+257,g=this.getBits(5)+1,p=this.getBits(4)+4,m=new Uint8Array(t.length);for(f=0;f<p;++f)m[t[f]]=this.getBits(3);var b=this.generateHuffmanTable(m);s=0;f=0;for(var v,y,k,w=d+g,C=new Uint8Array(w);f<w;){var x=this.getCode(b);if(16===x){v=2;y=3;k=s}else if(17===x){v=3;y=3;k=s=0}else{if(18!==x){C[f++]=s=x;continue}v=7;y=11;k=s=0}for(var S=this.getBits(v)+y;S-- >0;)C[f++]=k}h=this.generateHuffmanTable(C.subarray(0,d));u=this.generateHuffmanTable(C.subarray(d,w))}else l("Unknown block type in flate stream");e=this.buffer;for(var A=e?e.length:0,I=this.bufferLength;;){var B=this.getCode(h);if(B<256){if(I+1>=A){e=this.ensureBuffer(I+1);A=e.length}e[I++]=B}else{if(256===B){this.bufferLength=I;return}B-=257;B=a[B];var R=B>>16;R>0&&(R=this.getBits(R));s=(65535&B)+R;B=this.getCode(u);B=r[B];R=B>>16;R>0&&(R=this.getBits(R));var T=(65535&B)+R;if(I+s>=A){e=this.ensureBuffer(I+s);A=e.length}for(var O=0;O<s;++O,++I)e[I]=e[I-T]}}}else{var P;-1===(P=o.getByte())&&l("Bad block header in flate stream");var M=P;-1===(P=o.getByte())&&l("Bad block header in flate stream");M|=P<<8;-1===(P=o.getByte())&&l("Bad block header in flate stream");var E=P;-1===(P=o.getByte())&&l("Bad block header in flate stream");E|=P<<8;E===(65535&~M)||0===M&&0===E||l("Bad uncompressed block length in flate stream");this.codeBuf=0;this.codeSize=0;var L=this.bufferLength;e=this.ensureBuffer(L+M);var D=L+M;this.bufferLength=D;if(0===M)-1===o.peekByte()&&(this.eof=!0);else for(var F=L;F<D;++F){if(-1===(P=o.getByte())){this.eof=!0;break}e[F]=P}}};return e}(),B=function(){function e(e,t,a){if(!b(a))return e;var r=this.predictor=a.get("Predictor")||1;if(r<=1)return e;2!==r&&(r<10||r>15)&&l("Unsupported predictor: "+r);this.readBlock=2===r?this.readBlockTiff:this.readBlockPng;this.str=e;this.dict=e.dict;var i=this.colors=a.get("Colors")||1,n=this.bits=a.get("BitsPerComponent")||8,s=this.columns=a.get("Columns")||1;this.pixBytes=i*n+7>>3;this.rowBytes=s*i*n+7>>3;S.call(this,t);return this}e.prototype=Object.create(S.prototype)
+;e.prototype.readBlockTiff=function(){var e=this.rowBytes,t=this.bufferLength,a=this.ensureBuffer(t+e),r=this.bits,i=this.colors,n=this.str.getBytes(e);this.eof=!n.length;if(!this.eof){var s,o=0,c=0,l=0,h=0,u=t;if(1===r&&1===i)for(s=0;s<e;++s){var f=n[s]^o;f^=f>>1;f^=f>>2;f^=f>>4;o=(1&f)<<7;a[u++]=f}else if(8===r){for(s=0;s<i;++s)a[u++]=n[s];for(;s<e;++s){a[u]=a[u-i]+n[s];u++}}else{var d=new Uint8Array(i+1),g=(1<<r)-1,p=0,m=t,b=this.columns;for(s=0;s<b;++s)for(var v=0;v<i;++v){if(l<r){o=o<<8|255&n[p++];l+=8}d[v]=d[v]+(o>>l-r)&g;l-=r;c=c<<r|d[v];h+=r;if(h>=8){a[m++]=c>>h-8&255;h-=8}}h>0&&(a[m++]=(c<<8-h)+(o&(1<<8-h)-1))}this.bufferLength+=e}};e.prototype.readBlockPng=function(){var e=this.rowBytes,t=this.pixBytes,a=this.str.getByte(),r=this.str.getBytes(e);this.eof=!r.length;if(!this.eof){var i=this.bufferLength,n=this.ensureBuffer(i+e),s=n.subarray(i-e,i);0===s.length&&(s=new Uint8Array(e));var o,c,h,u=i;switch(a){case 0:for(o=0;o<e;++o)n[u++]=r[o];break;case 1:for(o=0;o<t;++o)n[u++]=r[o];for(;o<e;++o){n[u]=n[u-t]+r[o]&255;u++}break;case 2:for(o=0;o<e;++o)n[u++]=s[o]+r[o]&255;break;case 3:for(o=0;o<t;++o)n[u++]=(s[o]>>1)+r[o];for(;o<e;++o){n[u]=(s[o]+n[u-t]>>1)+r[o]&255;u++}break;case 4:for(o=0;o<t;++o){c=s[o];h=r[o];n[u++]=c+h}for(;o<e;++o){c=s[o];var f=s[o-t],d=n[u-t],g=d+c-f,p=g-d;p<0&&(p=-p);var m=g-c;m<0&&(m=-m);var b=g-f;b<0&&(b=-b);h=r[o];n[u++]=p<=m&&p<=b?d+h:m<=b?c+h:f+h}break;default:l("Unsupported predictor: "+a)}this.bufferLength+=e}};return e}(),R=function(){function e(e,t,a,r){for(var i;-1!==(i=e.getByte());)if(255===i){e.skip(-1);break}this.stream=e;this.maybeLength=t;this.dict=a;this.params=r;S.call(this,t)}e.prototype=Object.create(S.prototype);Object.defineProperty(e.prototype,"bytes",{get:function(){return g(this,"bytes",this.stream.getBytes(this.maybeLength))},configurable:!0});e.prototype.ensureBuffer=function(e){if(!this.bufferLength){var t=new k,a=this.dict.getArray("Decode","D");if(this.forceRGB&&f(a)){for(var r=this.dict.get("BitsPerComponent")||8,i=a.length,n=new Int32Array(i),s=!1,o=(1<<r)-1,c=0;c<i;c+=2){n[c]=256*(a[c+1]-a[c])|0;n[c+1]=a[c]*o|0;256===n[c]&&0===n[c+1]||(s=!0)}s&&(t.decodeTransform=n)}if(b(this.params)){var l=this.params.get("ColorTransform");u(l)&&(t.colorTransform=l)}t.parse(this.bytes);var h=t.getData(this.drawWidth,this.drawHeight,this.forceRGB);this.buffer=h;this.bufferLength=h.length;this.eof=!0}};e.prototype.getBytes=function(e){this.ensureBuffer();return this.buffer};e.prototype.getIR=function(e){return d(this.bytes,"image/jpeg",e)};return e}(),T=function(){function e(e,t,a,r){this.stream=e;this.maybeLength=t;this.dict=a;this.params=r;S.call(this,t)}e.prototype=Object.create(S.prototype);Object.defineProperty(e.prototype,"bytes",{get:function(){return g(this,"bytes",this.stream.getBytes(this.maybeLength))},configurable:!0});e.prototype.ensureBuffer=function(e){if(!this.bufferLength){var t=new w;t.parse(this.bytes);var a=t.width,r=t.height,i=t.componentsCount,n=t.tiles.length;if(1===n)this.buffer=t.tiles[0].items;else{for(var s=new Uint8Array(a*r*i),o=0;o<n;o++)for(var c=t.tiles[o],l=c.width,h=c.height,u=c.left,f=c.top,d=c.items,g=0,p=(a*f+u)*i,m=a*i,b=l*i,v=0;v<h;v++){var y=d.subarray(g,g+b);s.set(y,p);g+=b;p+=m}this.buffer=s}this.bufferLength=this.buffer.length;this.eof=!0}};return e}(),O=function(){function e(e,t,a,r){this.stream=e;this.maybeLength=t;this.dict=a;this.params=r;S.call(this,t)}e.prototype=Object.create(S.prototype);Object.defineProperty(e.prototype,"bytes",{get:function(){return g(this,"bytes",this.stream.getBytes(this.maybeLength))},configurable:!0});e.prototype.ensureBuffer=function(e){if(!this.bufferLength){var t=new y,a=[];if(b(this.params)){var r=this.params.get("JBIG2Globals");if(v(r)){var i=r.getBytes();a.push({data:i,start:0,end:i.length})}}a.push({data:this.bytes,start:0,end:this.bytes.length});for(var n=t.parseChunks(a),s=n.length,o=0;o<s;o++)n[o]^=255;this.buffer=n;this.bufferLength=s;this.eof=!0}};return e}(),P=function(){function e(e,t,a){this.str=e;this.dict=e.dict;this.decrypt=a;this.nextChunk=null;this.initialized=!1;S.call(this,t)}e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){var e;if(this.initialized)e=this.nextChunk;else{e=this.str.getBytes(512);this.initialized=!0}if(e&&0!==e.length){this.nextChunk=this.str.getBytes(512);var t=this.nextChunk&&this.nextChunk.length>0;e=(0,this.decrypt)(e,!t);var a,r=this.bufferLength,i=e.length,n=this.ensureBuffer(r+i);for(a=0;a<i;a++)n[r++]=e[a];this.bufferLength=r}else this.eof=!0};return e}(),M=function(){function e(e,t){this.str=e;this.dict=e.dict;this.input=new Uint8Array(5);t&&(t*=.8);S.call(this,t)}e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){for(var e=this.str,t=e.getByte();p(t);)t=e.getByte();if(-1!==t&&126!==t){var a,r,i=this.bufferLength;if(122===t){a=this.ensureBuffer(i+4);for(r=0;r<4;++r)a[i+r]=0;this.bufferLength+=4}else{var n=this.input;n[0]=t;for(r=1;r<5;++r){t=e.getByte();for(;p(t);)t=e.getByte();n[r]=t;if(-1===t||126===t)break}a=this.ensureBuffer(i+r-1);this.bufferLength+=r-1;if(r<5){for(;r<5;++r)n[r]=117;this.eof=!0}var s=0;for(r=0;r<5;++r)s=85*s+(n[r]-33);for(r=3;r>=0;--r){a[i+r]=255&s;s>>=8}}}else this.eof=!0};return e}(),E=function(){function e(e,t){this.str=e;this.dict=e.dict;this.firstDigit=-1;t&&(t*=.5);S.call(this,t)}e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){var e=this.str.getBytes(8e3);if(e.length){for(var t=e.length+1>>1,a=this.ensureBuffer(this.bufferLength+t),r=this.bufferLength,i=this.firstDigit,n=0,s=e.length;n<s;n++){var o,c=e[n];if(c>=48&&c<=57)o=15&c;else{if(!(c>=65&&c<=70||c>=97&&c<=102)){if(62===c){this.eof=!0;break}continue}o=9+(15&c)}if(i<0)i=o;else{a[r++]=i<<4|o;i=-1}}if(i>=0&&this.eof){a[r++]=i<<4;i=-1}this.firstDigit=i;this.bufferLength=r}else this.eof=!0};return e}(),L=function(){function e(e,t){this.str=e;this.dict=e.dict;S.call(this,t)}e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){var e=this.str.getBytes(2);if(!e||e.length<2||128===e[0])this.eof=!0;else{var t,a=this.bufferLength,r=e[0];if(r<128){t=this.ensureBuffer(a+r+1);t[a++]=e[1];if(r>0){var i=this.str.getBytes(r);t.set(i,a);a+=r}}else{r=257-r;var n=e[1];t=this.ensureBuffer(a+r+1);for(var s=0;s<r;s++)t[a++]=n}this.bufferLength=a}};return e}(),D=function(){function e(e,t,a){this.str=e;this.dict=e.dict;a=a||m.empty;this.encoding=a.get("K")||0;this.eoline=a.get("EndOfLine")||!1;this.byteAlign=a.get("EncodedByteAlign")||!1;this.columns=a.get("Columns")||1728;this.rows=a.get("Rows")||0;var r=a.get("EndOfBlock");null!==r&&void 0!==r||(r=!0);this.eoblock=r;this.black=a.get("BlackIs1")||!1;this.codingLine=new Uint32Array(this.columns+1);this.refLine=new Uint32Array(this.columns+2);this.codingLine[0]=this.columns;this.codingPos=0;this.row=0;this.nextLine2D=this.encoding<0;this.inputBits=0;this.inputBuf=0;this.outputBits=0;for(var i;0===(i=this.lookBits(12));)this.eatBits(1);1===i&&this.eatBits(12);if(this.encoding>0){this.nextLine2D=!this.lookBits(1);this.eatBits(1)}S.call(this,t)}var t=[[-1,-1],[-1,-1],[7,8],[7,7],[6,6],[6,6],[6,5],[6,5],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2]],a=[[-1,-1],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[12,1984],[12,2048],[12,2112],[12,2176],[12,2240],[12,2304],[11,1856],[11,1856],[11,1920],[11,1920],[12,2368],[12,2432],[12,2496],[12,2560]],r=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[8,29],[8,29],[8,30],[8,30],[8,45],[8,45],[8,46],[8,46],[7,22],[7,22],[7,22],[7,22],[7,23],[7,23],[7,23],[7,23],[8,47],[8,47],[8,48],[8,48],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[7,20],[7,20],[7,20],[7,20],[8,33],[8,33],[8,34],[8,34],[8,35],[8,35],[8,36],[8,36],[8,37],[8,37],[8,38],[8,38],[7,19],[7,19],[7,19],[7,19],[8,31],[8,31],[8,32],[8,32],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[8,53],[8,53],[8,54],[8,54],[7,26],[7,26],[7,26],[7,26],[8,39],[8,39],[8,40],[8,40],[8,41],[8,41],[8,42],[8,42],[8,43],[8,43],[8,44],[8,44],[7,21],[7,21],[7,21],[7,21],[7,28],[7,28],[7,28],[7,28],[8,61],[8,61],[8,62],[8,62],[8,63],[8,63],[8,0],[8,0],[8,320],[8,320],[8,384],[8,384],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[7,27],[7,27],[7,27],[7,27],[8,59],[8,59],[8,60],[8,60],[9,1472],[9,1536],[9,1600],[9,1728],[7,18],[7,18],[7,18],[7,18],[7,24],[7,24],[7,24],[7,24],[8,49],[8,49],[8,50],[8,50],[8,51],[8,51],[8,52],[8,52],[7,25],[7,25],[7,25],[7,25],[8,55],[8,55],[8,56],[8,56],[8,57],[8,57],[8,58],[8,58],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[8,448],[8,448],[8,512],[8,512],[9,704],[9,768],[8,640],[8,640],[8,576],[8,576],[9,832],[9,896],[9,960],[9,1024],[9,1088],[9,1152],[9,1216],[9,1280],[9,1344],[9,1408],[7,256],[7,256],[7,256],[7,256],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7]],i=[[-1,-1],[-1,-1],[12,-2],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[11,1792],[11,1792],[12,1984],[12,1984],[12,2048],[12,2048],[12,2112],[12,2112],[12,2176],[12,2176],[12,2240],[12,2240],[12,2304],[12,2304],[11,1856],[11,1856],[11,1856],[11,1856],[11,1920],[11,1920],[11,1920],[11,1920],[12,2368],[12,2368],[12,2432],[12,2432],[12,2496],[12,2496],[12,2560],[12,2560],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[12,52],[12,52],[13,640],[13,704],[13,768],[13,832],[12,55],[12,55],[12,56],[12,56],[13,1280],[13,1344],[13,1408],[13,1472],[12,59],[12,59],[12,60],[12,60],[13,1536],[13,1600],[11,24],[11,24],[11,24],[11,24],[11,25],[11,25],[11,25],[11,25],[13,1664],[13,1728],[12,320],[12,320],[12,384],[12,384],[12,448],[12,448],[13,512],[13,576],[12,53],[12,53],[12,54],[12,54],[13,896],[13,960],[13,1024],[13,1088],[13,1152],[13,1216],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64]],n=[[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[11,23],[11,23],[12,50],[12,51],[12,44],[12,45],[12,46],[12,47],[12,57],[12,58],[12,61],[12,256],[10,16],[10,16],[10,16],[10,16],[10,17],[10,17],[10,17],[10,17],[12,48],[12,49],[12,62],[12,63],[12,30],[12,31],[12,32],[12,33],[12,40],[12,41],[11,22],[11,22],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[12,128],[12,192],[12,26],[12,27],[12,28],[12,29],[11,19],[11,19],[11,20],[11,20],[12,34],[12,35],[12,36],[12,37],[12,38],[12,39],[11,21],[11,21],[12,42],[12,43],[10,0],[10,0],[10,0],[10,0],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12]],s=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[6,9],[6,8],[5,7],[5,7],[4,6],[4,6],[4,6],[4,6],[4,5],[4,5],[4,5],[4,5],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2]];e.prototype=Object.create(S.prototype);e.prototype.readBlock=function(){for(;!this.eof;){var e=this.lookChar();this.ensureBuffer(this.bufferLength+1);this.buffer[this.bufferLength++]=e}};e.prototype.addPixels=function(e,t){var a=this.codingLine,r=this.codingPos;if(e>a[r]){if(e>this.columns){h("row is wrong length");this.err=!0;e=this.columns}1&r^t&&++r;a[r]=e}this.codingPos=r};e.prototype.addPixelsNeg=function(e,t){var a=this.codingLine,r=this.codingPos;if(e>a[r]){if(e>this.columns){h("row is wrong length");this.err=!0;e=this.columns}1&r^t&&++r;a[r]=e}else if(e<a[r]){if(e<0){h("invalid code");this.err=!0;e=0}for(;r>0&&e<a[r-1];)--r;a[r]=e}this.codingPos=r};e.prototype.lookChar=function(){var e,t,a,r,i=this.refLine,n=this.codingLine,s=this.columns;if(0===this.outputBits){if(this.eof)return null;this.err=!1;var o,c,l;if(this.nextLine2D){for(r=0;n[r]<s;++r)i[r]=n[r];i[r++]=s;i[r]=s;n[0]=0;this.codingPos=0;e=0;t=0;for(;n[this.codingPos]<s;){o=this.getTwoDimCode();switch(o){case 0:this.addPixels(i[e+1],t);i[e+1]<s&&(e+=2);break;case 1:o=c=0;if(t){do{o+=l=this.getBlackCode()}while(l>=64);do{c+=l=this.getWhiteCode()}while(l>=64)}else{do{o+=l=this.getWhiteCode()}while(l>=64);do{c+=l=this.getBlackCode()}while(l>=64)}this.addPixels(n[this.codingPos]+o,t);n[this.codingPos]<s&&this.addPixels(n[this.codingPos]+c,1^t);for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2;break;case 7:this.addPixels(i[e]+3,t);t^=1;if(n[this.codingPos]<s){++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 5:this.addPixels(i[e]+2,t);t^=1;if(n[this.codingPos]<s){++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 3:this.addPixels(i[e]+1,t);t^=1;if(n[this.codingPos]<s){++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 2:this.addPixels(i[e],t);t^=1;if(n[this.codingPos]<s){++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 8:this.addPixelsNeg(i[e]-3,t);t^=1;if(n[this.codingPos]<s){e>0?--e:++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 6:this.addPixelsNeg(i[e]-2,t);t^=1;if(n[this.codingPos]<s){e>0?--e:++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case 4:this.addPixelsNeg(i[e]-1,t);t^=1;if(n[this.codingPos]<s){e>0?--e:++e;for(;i[e]<=n[this.codingPos]&&i[e]<s;)e+=2}break;case-1:this.addPixels(s,0);this.eof=!0;break;default:h("bad 2d code");this.addPixels(s,0);this.err=!0}}}else{n[0]=0;this.codingPos=0;t=0;for(;n[this.codingPos]<s;){o=0;if(t)do{o+=l=this.getBlackCode()}while(l>=64);else do{o+=l=this.getWhiteCode()}while(l>=64);this.addPixels(n[this.codingPos]+o,t);t^=1}}var u=!1;this.byteAlign&&(this.inputBits&=-8);if(this.eoblock||this.row!==this.rows-1){o=this.lookBits(12);if(this.eoline)for(;-1!==o&&1!==o;){this.eatBits(1);o=this.lookBits(12)}else for(;0===o;){this.eatBits(1);o=this.lookBits(12)}if(1===o){this.eatBits(12);u=!0}else-1===o&&(this.eof=!0)}else this.eof=!0;if(!this.eof&&this.encoding>0){this.nextLine2D=!this.lookBits(1);this.eatBits(1)}if(this.eoblock&&u&&this.byteAlign){o=this.lookBits(12);if(1===o){this.eatBits(12);if(this.encoding>0){this.lookBits(1);this.eatBits(1)}if(this.encoding>=0)for(r=0;r<4;++r){o=this.lookBits(12);1!==o&&h("bad rtc code: "+o);this.eatBits(12);if(this.encoding>0){this.lookBits(1);this.eatBits(1)}}this.eof=!0}}else if(this.err&&this.eoline){for(;;){o=this.lookBits(13);if(-1===o){this.eof=!0;return null}if(o>>1==1)break;this.eatBits(1)}this.eatBits(12);if(this.encoding>0){this.eatBits(1);this.nextLine2D=!(1&o)}}n[0]>0?this.outputBits=n[this.codingPos=0]:this.outputBits=n[this.codingPos=1];this.row++}var f;if(this.outputBits>=8){f=1&this.codingPos?0:255;this.outputBits-=8;if(0===this.outputBits&&n[this.codingPos]<s){this.codingPos++;this.outputBits=n[this.codingPos]-n[this.codingPos-1]}}else{a=8;f=0;do{if(this.outputBits>a){f<<=a;1&this.codingPos||(f|=255>>8-a);this.outputBits-=a;a=0}else{f<<=this.outputBits;1&this.codingPos||(f|=255>>8-this.outputBits);a-=this.outputBits;this.outputBits=0;if(n[this.codingPos]<s){this.codingPos++;this.outputBits=n[this.codingPos]-n[this.codingPos-1]}else if(a>0){f<<=a;a=0}}}while(a)}this.black&&(f^=255);return f};e.prototype.findTableCode=function(e,t,a,r){for(var i=r||0,n=e;n<=t;++n){var s=this.lookBits(n);if(-1===s)return[!0,1,!1];n<t&&(s<<=t-n);if(!i||s>=i){var o=a[s-i];if(o[0]===n){this.eatBits(n);return[!0,o[1],!0]}}}return[!1,0,!1]};e.prototype.getTwoDimCode=function(){var e,a=0;if(this.eoblock){a=this.lookBits(7);e=t[a];if(e&&e[0]>0){this.eatBits(e[0]);return e[1]}}else{var r=this.findTableCode(1,7,t);if(r[0]&&r[2])return r[1]}h("Bad two dim code");return-1};e.prototype.getWhiteCode=function(){var e,t=0;if(this.eoblock){t=this.lookBits(12);if(-1===t)return 1;e=t>>5==0?a[t]:r[t>>3];if(e[0]>0){this.eatBits(e[0]);return e[1]}}else{var i=this.findTableCode(1,9,r);if(i[0])return i[1];i=this.findTableCode(11,12,a);if(i[0])return i[1]}h("bad white code");this.eatBits(1);return 1};e.prototype.getBlackCode=function(){var e,t;if(this.eoblock){e=this.lookBits(13);if(-1===e)return 1;t=e>>7==0?i[e]:e>>9==0&&e>>7!=0?n[(e>>1)-64]:s[e>>7];if(t[0]>0){this.eatBits(t[0]);return t[1]}}else{var a=this.findTableCode(2,6,s);if(a[0])return a[1];a=this.findTableCode(7,12,n,64);if(a[0])return a[1];a=this.findTableCode(10,13,i);if(a[0])return a[1]}h("bad black code");this.eatBits(1);return 1};e.prototype.lookBits=function(e){for(var t;this.inputBits<e;){if(-1===(t=this.str.getByte()))return 0===this.inputBits?-1:this.inputBuf<<e-this.inputBits&65535>>16-e;this.inputBuf=this.inputBuf<<8|t;this.inputBits+=8}return this.inputBuf>>this.inputBits-e&65535>>16-e};e.prototype.eatBits=function(e){(this.inputBits-=e)<0&&(this.inputBits=0)};return e}(),F=function(){function e(e,t,a){this.str=e;this.dict=e.dict;this.cachedData=0;this.bitsCached=0;for(var r={earlyChange:a,codeLength:9,nextCode:258,dictionaryValues:new Uint8Array(4096),dictionaryLengths:new Uint16Array(4096),dictionaryPrevCodes:new Uint16Array(4096),currentSequence:new Uint8Array(4096),currentSequenceLength:0},i=0;i<256;++i){r.dictionaryValues[i]=i;r.dictionaryLengths[i]=1}this.lzwState=r;S.call(this,t)}e.prototype=Object.create(S.prototype);e.prototype.readBits=function(e){for(var t=this.bitsCached,a=this.cachedData;t<e;){var r=this.str.getByte();if(-1===r){this.eof=!0;return null}a=a<<8|r;t+=8}this.bitsCached=t-=e;this.cachedData=a;this.lastCode=null;return a>>>t&(1<<e)-1};e.prototype.readBlock=function(){var e,t,a,r=1024,i=this.lzwState;if(i){var n=i.earlyChange,s=i.nextCode,o=i.dictionaryValues,c=i.dictionaryLengths,l=i.dictionaryPrevCodes,h=i.codeLength,u=i.prevCode,f=i.currentSequence,d=i.currentSequenceLength,g=0,p=this.bufferLength,m=this.ensureBuffer(this.bufferLength+r);for(e=0;e<512;e++){var b=this.readBits(h),v=d>0;if(b<256){f[0]=b;d=1}else{if(!(b>=258)){if(256===b){h=9;s=258;d=0;continue}this.eof=!0;delete this.lzwState;break}if(b<s){d=c[b];for(t=d-1,a=b;t>=0;t--){f[t]=o[a];a=l[a]}}else f[d++]=f[0]}if(v){l[s]=u;c[s]=c[u]+1;o[s]=f[0];s++;h=s+n&s+n-1?h:0|Math.min(Math.log(s+n)/.6931471805599453+1,12)}u=b;g+=d;if(r<g){do{r+=512}while(r<g);m=this.ensureBuffer(this.bufferLength+r)}for(t=0;t<d;t++)m[p++]=f[t]}i.nextCode=s;i.codeLength=h;i.prevCode=u;i.currentSequenceLength=d;this.bufferLength=p}};return e}(),q=function(){function e(){C.call(this,new Uint8Array(0))}e.prototype=C.prototype;return e}();t.Ascii85Stream=M;t.AsciiHexStream=E;t.CCITTFaxStream=D;t.DecryptStream=P;t.DecodeStream=S;t.FlateStream=I;t.Jbig2Stream=O;t.JpegStream=R;t.JpxStream=T;t.NullStream=q;t.PredictorStream=B;t.RunLengthStream=L;t.Stream=C;t.StreamsSequenceStream=A;t.StringStream=x;t.LZWStream=F},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(6),s=r.error,o=r.info,c=r.isArray,l=r.isString,h=r.shadow,u=r.warn,f=i.isDict,d=i.isName,g=i.isStream,p=n.PDFFunction,m=function(){function e(e,t,a,r,i,n,s,o){s=1!==s?0:s;var c,l,h,u,f=a/i,d=r/n,g=0,p=new Uint16Array(i),m=3*a;for(c=0;c<i;c++)p[c]=3*Math.floor(c*f);for(c=0;c<n;c++){h=Math.floor(c*d)*m;for(l=0;l<i;l++){u=h+p[l];o[g++]=e[u++];o[g++]=e[u++];o[g++]=e[u++];g+=s}}}function t(){s("should not call ColorSpace constructor")}t.prototype={getRgb:function(e,t){var a=new Uint8Array(3);this.getRgbItem(e,t,a,0);return a},getRgbItem:function(e,t,a,r){s("Should not call ColorSpace.getRgbItem")},getRgbBuffer:function(e,t,a,r,i,n,o){s("Should not call ColorSpace.getRgbBuffer")},getOutputLength:function(e,t){s("Should not call ColorSpace.getOutputLength")},isPassthrough:function(e){return!1},fillRgb:function(t,a,r,i,n,s,o,c,l){var h,u,f=a*r,d=null,g=1<<o,p=r!==n||a!==i;if(this.isPassthrough(o))d=c;else if(1===this.numComps&&f>g&&"DeviceGray"!==this.name&&"DeviceRGB"!==this.name){var m,b=o<=8?new Uint8Array(g):new Uint16Array(g);for(h=0;h<g;h++)b[h]=h;var v=new Uint8Array(3*g);this.getRgbBuffer(b,0,g,v,0,o,0);var y,k;if(p){d=new Uint8Array(3*f);k=0;for(h=0;h<f;++h){m=3*c[h];d[k++]=v[m];d[k++]=v[m+1];d[k++]=v[m+2]}}else{y=0;for(h=0;h<f;++h){m=3*c[h];t[y++]=v[m];t[y++]=v[m+1];t[y++]=v[m+2];y+=l}}}else if(p){d=new Uint8Array(3*f);this.getRgbBuffer(c,0,f,d,0,o,0)}else this.getRgbBuffer(c,0,i*s,t,0,o,l);if(d)if(p)e(d,o,a,r,i,n,l,t);else{k=0;y=0;for(h=0,u=i*s;h<u;h++){t[y++]=d[k++];t[y++]=d[k++];t[y++]=d[k++];y+=l}}},usesZeroToOneRange:!0};t.parse=function(e,a,r){var i=t.parseToIR(e,a,r);return i instanceof b?i:t.fromIR(i)};t.fromIR=function(e){var a,r,i,n=c(e)?e[0]:e;switch(n){case"DeviceGrayCS":return this.singletons.gray;case"DeviceRgbCS":return this.singletons.rgb;case"DeviceCmykCS":return this.singletons.cmyk;case"CalGrayCS":a=e[1];r=e[2];i=e[3];return new x(a,r,i);case"CalRGBCS":a=e[1];r=e[2];i=e[3];var o=e[4];return new S(a,r,i,o);case"PatternCS":var l=e[1];l&&(l=t.fromIR(l));return new v(l);case"IndexedCS":var h=e[1],u=e[2],f=e[3];return new y(t.fromIR(h),u,f);case"AlternateCS":var d=e[1],g=e[2],m=e[3];return new b(d,t.fromIR(g),p.fromIR(m));case"LabCS":a=e[1];r=e[2];var k=e[3];return new A(a,r,k);default:s("Unknown name "+n)}return null};t.parseToIR=function(e,a,r){if(d(e)){var i=r.get("ColorSpace");if(f(i)){var n=i.get(e.name);n&&(e=n)}}e=a.fetchIfRef(e);if(d(e))switch(e.name){case"DeviceGray":case"G":return"DeviceGrayCS";case"DeviceRGB":case"RGB":return"DeviceRgbCS";case"DeviceCMYK":case"CMYK":return"DeviceCmykCS";case"Pattern":return["PatternCS",null];default:s("unrecognized colorspace "+e.name)}else if(c(e)){var o,l,h,m,b,v,y=a.fetchIfRef(e[0]).name;switch(y){case"DeviceGray":case"G":return"DeviceGrayCS";case"DeviceRGB":case"RGB":return"DeviceRgbCS";case"DeviceCMYK":case"CMYK":return"DeviceCmykCS";case"CalGray":l=a.fetchIfRef(e[1]);m=l.getArray("WhitePoint");b=l.getArray("BlackPoint");v=l.get("Gamma");return["CalGrayCS",m,b,v];case"CalRGB":l=a.fetchIfRef(e[1]);m=l.getArray("WhitePoint");b=l.getArray("BlackPoint");v=l.getArray("Gamma");var k=l.getArray("Matrix");return["CalRGBCS",m,b,v,k];case"ICCBased":var w=a.fetchIfRef(e[1]),C=w.dict;o=C.get("N");h=C.get("Alternate");if(h){var x=t.parseToIR(h,a,r),S=t.fromIR(x);if(S.numComps===o)return x;u("ICCBased color space: Ignoring incorrect /Alternate entry.")}if(1===o)return"DeviceGrayCS";if(3===o)return"DeviceRgbCS";if(4===o)return"DeviceCmykCS";break;case"Pattern":var A=e[1]||null;A&&(A=t.parseToIR(A,a,r));return["PatternCS",A];case"Indexed":case"I":var I=t.parseToIR(e[1],a,r),B=a.fetchIfRef(e[2])+1,R=a.fetchIfRef(e[3]);g(R)&&(R=R.getBytes());return["IndexedCS",I,B,R];case"Separation":case"DeviceN":var T=a.fetchIfRef(e[1]);o=c(T)?T.length:1;h=t.parseToIR(e[2],a,r);var O=p.getIR(a,a.fetchIfRef(e[3]));return["AlternateCS",o,h,O];case"Lab":l=a.fetchIfRef(e[1]);m=l.getArray("WhitePoint");b=l.getArray("BlackPoint");var P=l.getArray("Range");return["LabCS",m,b,P];default:s('unimplemented color space object "'+y+'"')}}else s('unrecognized color space object: "'+e+'"');return null};t.isDefaultDecode=function(e,t){if(!c(e))return!0;if(2*t!==e.length){u("The decode map is not the correct length");return!0}for(var a=0,r=e.length;a<r;a+=2)if(0!==e[a]||1!==e[a+1])return!1;return!0};t.singletons={get gray(){return h(this,"gray",new k)},get rgb(){return h(this,"rgb",new w)},get cmyk(){return h(this,"cmyk",new C)}};return t}(),b=function(){function e(e,t,a){this.name="Alternate";this.numComps=e;this.defaultColor=new Float32Array(e);for(var r=0;r<e;++r)this.defaultColor[r]=1;this.base=t;this.tintFn=a;this.tmpBuf=new Float32Array(t.numComps)}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,t,a,r){var i=this.tmpBuf;this.tintFn(e,t,i,0);this.base.getRgbItem(i,0,a,r)},getRgbBuffer:function(e,t,a,r,i,n,s){var o,c,l=this.tintFn,h=this.base,u=1/((1<<n)-1),f=h.numComps,d=h.usesZeroToOneRange,g=(h.isPassthrough(8)||!d)&&0===s,p=g?i:0,m=g?r:new Uint8Array(f*a),b=this.numComps,v=new Float32Array(b),y=new Float32Array(f);for(o=0;o<a;o++){for(c=0;c<b;c++)v[c]=e[t++]*u;l(v,0,y,0);if(d)for(c=0;c<f;c++)m[p++]=255*y[c];else{h.getRgbItem(y,0,m,p);p+=f}}g||h.getRgbBuffer(m,0,a,r,i,8,s)},getOutputLength:function(e,t){return this.base.getOutputLength(e*this.base.numComps/this.numComps,t)},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return e}(),v=function(){function e(e){this.name="Pattern";this.base=e}e.prototype={};return e}(),y=function(){function e(e,t,a){this.name="Indexed";this.numComps=1;this.defaultColor=new Uint8Array(this.numComps);this.base=e;this.highVal=t;var r=e.numComps,i=r*t;if(g(a)){this.lookup=new Uint8Array(i);var n=a.getBytes(i);this.lookup.set(n)}else if(l(a)){this.lookup=new Uint8Array(i);for(var o=0;o<i;++o)this.lookup[o]=a.charCodeAt(o)}else a instanceof Uint8Array||a instanceof Array?this.lookup=a:s("Unrecognized lookup table: "+a)}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,t,a,r){var i=this.base.numComps,n=e[t]*i;this.base.getRgbItem(this.lookup,n,a,r)},getRgbBuffer:function(e,t,a,r,i,n,s){for(var o=this.base,c=o.numComps,l=o.getOutputLength(c,s),h=this.lookup,u=0;u<a;++u){var f=e[t++]*c;o.getRgbBuffer(h,f,1,r,i,8,s);i+=l}},getOutputLength:function(e,t){return this.base.getOutputLength(e*this.base.numComps,t)},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return!0},usesZeroToOneRange:!0};return e}(),k=function(){function e(){this.name="DeviceGray";this.numComps=1;this.defaultColor=new Float32Array(this.numComps)}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,t,a,r){var i=255*e[t]|0;i=i<0?0:i>255?255:i;a[r]=a[r+1]=a[r+2]=i},getRgbBuffer:function(e,t,a,r,i,n,s){for(var o=255/((1<<n)-1),c=t,l=i,h=0;h<a;++h){var u=o*e[c++]|0;r[l++]=u;r[l++]=u;r[l++]=u;l+=s}},getOutputLength:function(e,t){return e*(3+t)},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return e}(),w=function(){function e(){this.name="DeviceRGB";this.numComps=3;this.defaultColor=new Float32Array(this.numComps)}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,t,a,r){var i=255*e[t]|0,n=255*e[t+1]|0,s=255*e[t+2]|0;a[r]=i<0?0:i>255?255:i;a[r+1]=n<0?0:n>255?255:n;a[r+2]=s<0?0:s>255?255:s},getRgbBuffer:function(e,t,a,r,i,n,s){if(8!==n||0!==s)for(var o=255/((1<<n)-1),c=t,l=i,h=0;h<a;++h){r[l++]=o*e[c++]|0;r[l++]=o*e[c++]|0;r[l++]=o*e[c++]|0;l+=s}else r.set(e.subarray(t,t+3*a),i)},getOutputLength:function(e,t){return e*(3+t)/3|0},isPassthrough:function(e){return 8===e},fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return e}(),C=function(){function e(e,t,a,r,i){var n=e[t+0]*a,s=e[t+1]*a,o=e[t+2]*a,c=e[t+3]*a,l=n*(-4.387332384609988*n+54.48615194189176*s+18.82290502165302*o+212.25662451639585*c-285.2331026137004)+s*(1.7149763477362134*s-5.6096736904047315*o+-17.873870861415444*c-5.497006427196366)+o*(-2.5217340131683033*o-21.248923337353073*c+17.5119270841813)+c*(-21.86122147463605*c-189.48180835922747)+255|0,h=n*(8.841041422036149*n+60.118027045597366*s+6.871425592049007*o+31.159100130055922*c-79.2970844816548)+s*(-15.310361306967817*s+17.575251261109482*o+131.35250912493976*c-190.9453302588951)+o*(4.444339102852739*o+9.8632861493405*c-24.86741582555878)+c*(-20.737325471181034*c-187.80453709719578)+255|0,u=n*(.8842522430003296*n+8.078677503112928*s+30.89978309703729*o-.23883238689178934*c-14.183576799673286)+s*(10.49593273432072*s+63.02378494754052*o+50.606957656360734*c-112.23884253719248)+o*(.03296041114873217*o+115.60384449646641*c-193.58209356861505)+c*(-22.33816807309886*c-180.12613974708367)+255|0;r[i]=l>255?255:l<0?0:l;r[i+1]=h>255?255:h<0?0:h;r[i+2]=u>255?255:u<0?0:u}function t(){this.name="DeviceCMYK";this.numComps=4;this.defaultColor=new Float32Array(this.numComps);this.defaultColor[3]=1}t.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(t,a,r,i){e(t,a,1,r,i)},getRgbBuffer:function(t,a,r,i,n,s,o){for(var c=1/((1<<s)-1),l=0;l<r;l++){e(t,a,c,i,n);a+=4;n+=3+o}},getOutputLength:function(e,t){return e/4*(3+t)|0},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return t}(),x=function(){function e(e,t,a){this.name="CalGray";this.numComps=1;this.defaultColor=new Float32Array(this.numComps);e||s("WhitePoint missing - required for color space CalGray");t=t||[0,0,0];a=a||1;this.XW=e[0];this.YW=e[1];this.ZW=e[2];this.XB=t[0];this.YB=t[1];this.ZB=t[2];this.G=a;(this.XW<0||this.ZW<0||1!==this.YW)&&s("Invalid WhitePoint components for "+this.name+", no fallback available");if(this.XB<0||this.YB<0||this.ZB<0){
+o("Invalid BlackPoint for "+this.name+", falling back to default");this.XB=this.YB=this.ZB=0}0===this.XB&&0===this.YB&&0===this.ZB||u(this.name+", BlackPoint: XB: "+this.XB+", YB: "+this.YB+", ZB: "+this.ZB+", only default values are supported.");if(this.G<1){o("Invalid Gamma: "+this.G+" for "+this.name+", falling back to default");this.G=1}}function t(e,t,a,r,i,n){var s=t[a]*n,o=Math.pow(s,e.G),c=e.YW*o,l=0|Math.max(295.8*Math.pow(c,.3333333333333333)-40.8,0);r[i]=l;r[i+1]=l;r[i+2]=l}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,a,r,i){t(this,e,a,r,i,1)},getRgbBuffer:function(e,a,r,i,n,s,o){for(var c=1/((1<<s)-1),l=0;l<r;++l){t(this,e,a,i,n,c);a+=1;n+=3+o}},getOutputLength:function(e,t){return e*(3+t)},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return e}(),S=function(){function e(e,t,a,r){this.name="CalRGB";this.numComps=3;this.defaultColor=new Float32Array(this.numComps);e||s("WhitePoint missing - required for color space CalRGB");t=t||new Float32Array(3);a=a||new Float32Array([1,1,1]);r=r||new Float32Array([1,0,0,0,1,0,0,0,1]);var i=e[0],n=e[1],c=e[2];this.whitePoint=e;var l=t[0],h=t[1],u=t[2];this.blackPoint=t;this.GR=a[0];this.GG=a[1];this.GB=a[2];this.MXA=r[0];this.MYA=r[1];this.MZA=r[2];this.MXB=r[3];this.MYB=r[4];this.MZB=r[5];this.MXC=r[6];this.MYC=r[7];this.MZC=r[8];(i<0||c<0||1!==n)&&s("Invalid WhitePoint components for "+this.name+", no fallback available");if(l<0||h<0||u<0){o("Invalid BlackPoint for "+this.name+" ["+l+", "+h+", "+u+"], falling back to default");this.blackPoint=new Float32Array(3)}if(this.GR<0||this.GG<0||this.GB<0){o("Invalid Gamma ["+this.GR+", "+this.GG+", "+this.GB+"] for "+this.name+", falling back to default");this.GR=this.GG=this.GB=1}if(this.MXA<0||this.MYA<0||this.MZA<0||this.MXB<0||this.MYB<0||this.MZB<0||this.MXC<0||this.MYC<0||this.MZC<0){o("Invalid Matrix for "+this.name+" ["+this.MXA+", "+this.MYA+", "+this.MZA+this.MXB+", "+this.MYB+", "+this.MZB+this.MXC+", "+this.MYC+", "+this.MZC+"], falling back to default");this.MXA=this.MYB=this.MZC=1;this.MXB=this.MYA=this.MZA=this.MXC=this.MYC=this.MZB=0}}function t(e,t,a){a[0]=e[0]*t[0]+e[1]*t[1]+e[2]*t[2];a[1]=e[3]*t[0]+e[4]*t[1]+e[5]*t[2];a[2]=e[6]*t[0]+e[7]*t[1]+e[8]*t[2]}function a(e,t,a){a[0]=1*t[0]/e[0];a[1]=1*t[1]/e[1];a[2]=1*t[2]/e[2]}function r(e,t,a){a[0]=.95047*t[0]/e[0];a[1]=1*t[1]/e[1];a[2]=1.08883*t[2]/e[2]}function i(e){return e<=.0031308?n(0,1,12.92*e):n(0,1,1.055*Math.pow(e,1/2.4)-.055)}function n(e,t,a){return Math.max(e,Math.min(t,a))}function c(e){return e<0?-c(-e):e>8?Math.pow((e+16)/116,3):e*w}function l(e,t,a){if(0!==e[0]||0!==e[1]||0!==e[2]){var r=c(0),i=r,n=c(e[0]),s=r,o=c(e[1]),l=r,h=c(e[2]),u=(1-i)/(1-n),f=1-u,d=(1-s)/(1-o),g=1-d,p=(1-l)/(1-h),m=1-p;a[0]=t[0]*u+f;a[1]=t[1]*d+g;a[2]=t[2]*p+m}else{a[0]=t[0];a[1]=t[1];a[2]=t[2]}}function h(e,r,i){if(1!==e[0]||1!==e[2]){var n=i;t(d,r,n);var s=v;a(e,n,s);t(g,s,i)}else{i[0]=r[0];i[1]=r[1];i[2]=r[2]}}function u(e,a,i){var n=i;t(d,a,n);var s=v;r(e,n,s);t(g,s,i)}function f(e,a,r,s,o,c){var f=n(0,1,a[r]*c),d=n(0,1,a[r+1]*c),g=n(0,1,a[r+2]*c),m=Math.pow(f,e.GR),v=Math.pow(d,e.GG),w=Math.pow(g,e.GB),C=e.MXA*m+e.MXB*v+e.MXC*w,x=e.MYA*m+e.MYB*v+e.MYC*w,S=e.MZA*m+e.MZB*v+e.MZC*w,A=y;A[0]=C;A[1]=x;A[2]=S;var I=k;h(e.whitePoint,A,I);var B=y;l(e.blackPoint,I,B);var R=k;u(b,B,R);var T=y;t(p,R,T);var O=i(T[0]),P=i(T[1]),M=i(T[2]);s[o]=Math.round(255*O);s[o+1]=Math.round(255*P);s[o+2]=Math.round(255*M)}var d=new Float32Array([.8951,.2664,-.1614,-.7502,1.7135,.0367,.0389,-.0685,1.0296]),g=new Float32Array([.9869929,-.1470543,.1599627,.4323053,.5183603,.0492912,-.0085287,.0400428,.9684867]),p=new Float32Array([3.2404542,-1.5371385,-.4985314,-.969266,1.8760108,.041556,.0556434,-.2040259,1.0572252]),b=new Float32Array([1,1,1]),v=new Float32Array(3),y=new Float32Array(3),k=new Float32Array(3),w=Math.pow(24/116,3)/8;e.prototype={getRgb:function(e,t){var a=new Uint8Array(3);this.getRgbItem(e,t,a,0);return a},getRgbItem:function(e,t,a,r){f(this,e,t,a,r,1)},getRgbBuffer:function(e,t,a,r,i,n,s){for(var o=1/((1<<n)-1),c=0;c<a;++c){f(this,e,t,r,i,o);t+=3;i+=3+s}},getOutputLength:function(e,t){return e*(3+t)/3|0},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return m.isDefaultDecode(e,this.numComps)},usesZeroToOneRange:!0};return e}(),A=function(){function e(e,t,a){this.name="Lab";this.numComps=3;this.defaultColor=new Float32Array(this.numComps);e||s("WhitePoint missing - required for color space Lab");t=t||[0,0,0];a=a||[-100,100,-100,100];this.XW=e[0];this.YW=e[1];this.ZW=e[2];this.amin=a[0];this.amax=a[1];this.bmin=a[2];this.bmax=a[3];this.XB=t[0];this.YB=t[1];this.ZB=t[2];(this.XW<0||this.ZW<0||1!==this.YW)&&s("Invalid WhitePoint components, no fallback available");if(this.XB<0||this.YB<0||this.ZB<0){o("Invalid BlackPoint, falling back to default");this.XB=this.YB=this.ZB=0}if(this.amin>this.amax||this.bmin>this.bmax){o("Invalid Range, falling back to defaults");this.amin=-100;this.amax=100;this.bmin=-100;this.bmax=100}}function t(e){var t;t=e>=6/29?e*e*e:108/841*(e-4/29);return t}function a(e,t,a,r){return a+e*(r-a)/t}function r(e,r,i,n,s,o){var c=r[i],l=r[i+1],h=r[i+2];if(!1!==n){c=a(c,n,0,100);l=a(l,n,e.amin,e.amax);h=a(h,n,e.bmin,e.bmax)}l=l>e.amax?e.amax:l<e.amin?e.amin:l;h=h>e.bmax?e.bmax:h<e.bmin?e.bmin:h;var u,f,d,g=(c+16)/116,p=g+l/500,m=g-h/200,b=e.XW*t(p),v=e.YW*t(g),y=e.ZW*t(m);if(e.ZW<1){u=3.1339*b+-1.617*v+-.4906*y;f=-.9785*b+1.916*v+.0333*y;d=.072*b+-.229*v+1.4057*y}else{u=3.2406*b+-1.5372*v+-.4986*y;f=-.9689*b+1.8758*v+.0415*y;d=.0557*b+-.204*v+1.057*y}s[o]=u<=0?0:u>=1?255:255*Math.sqrt(u)|0;s[o+1]=f<=0?0:f>=1?255:255*Math.sqrt(f)|0;s[o+2]=d<=0?0:d>=1?255:255*Math.sqrt(d)|0}e.prototype={getRgb:m.prototype.getRgb,getRgbItem:function(e,t,a,i){r(this,e,t,!1,a,i)},getRgbBuffer:function(e,t,a,i,n,s,o){for(var c=(1<<s)-1,l=0;l<a;l++){r(this,e,t,c,i,n);t+=3;n+=3+o}},getOutputLength:function(e,t){return e*(3+t)/3|0},isPassthrough:m.prototype.isPassthrough,fillRgb:m.prototype.fillRgb,isDefaultDecode:function(e){return!0},usesZeroToOneRange:!1};return e}();t.ColorSpace=m},function(e,t,a){"use strict";function r(e){switch(e){case"WinAnsiEncoding":return c;case"StandardEncoding":return o;case"MacRomanEncoding":return s;case"SymbolSetEncoding":return l;case"ZapfDingbatsEncoding":return h;case"ExpertEncoding":return i;case"MacExpertEncoding":return n;default:return null}}var i=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","","asuperior","bsuperior","centsuperior","dsuperior","esuperior","","","isuperior","","","lsuperior","msuperior","nsuperior","osuperior","","","rsuperior","ssuperior","tsuperior","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdownsmall","centoldstyle","Lslashsmall","","","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","","Dotaccentsmall","","","Macronsmall","","","figuredash","hypheninferior","","","Ogoneksmall","Ringsmall","Cedillasmall","","","","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],n=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","centoldstyle","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","","threequartersemdash","","questionsmall","","","","","Ethsmall","","","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","","","","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hypheninferior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","asuperior","centsuperior","","","","","Aacutesmall","Agravesmall","Acircumflexsmall","Adieresissmall","Atildesmall","Aringsmall","Ccedillasmall","Eacutesmall","Egravesmall","Ecircumflexsmall","Edieresissmall","Iacutesmall","Igravesmall","Icircumflexsmall","Idieresissmall","Ntildesmall","Oacutesmall","Ogravesmall","Ocircumflexsmall","Odieresissmall","Otildesmall","Uacutesmall","Ugravesmall","Ucircumflexsmall","Udieresissmall","","eightsuperior","fourinferior","threeinferior","sixinferior","eightinferior","seveninferior","Scaronsmall","","centinferior","twoinferior","","Dieresissmall","","Caronsmall","osuperior","fiveinferior","","commainferior","periodinferior","Yacutesmall","","dollarinferior","","Thornsmall","","nineinferior","zeroinferior","Zcaronsmall","AEsmall","Oslashsmall","questiondownsmall","oneinferior","Lslashsmall","","","","","","","Cedillasmall","","","","","","OEsmall","figuredash","hyphensuperior","","","","","exclamdownsmall","","Ydieresissmall","","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","ninesuperior","zerosuperior","","esuperior","rsuperior","tsuperior","","","isuperior","ssuperior","dsuperior","","","","","","lsuperior","Ogoneksmall","Brevesmall","Macronsmall","bsuperior","nsuperior","msuperior","commasuperior","periodsuperior","Dotaccentsmall","Ringsmall"],s=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","space","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron"],o=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","","endash","dagger","daggerdbl","periodcentered","","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","","questiondown","","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","","ring","cedilla","","hungarumlaut","ogonek","caron","emdash","","","","","","","","","","","","","","","","","AE","","ordfeminine","","","","","Lslash","Oslash","OE","ordmasculine","","","","","","ae","","","","dotlessi","","","lslash","oslash","oe","germandbls"],c=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","bullet","Euro","bullet","quotesinglbase","florin","quotedblbase","ellipsis","dagger","daggerdbl","circumflex","perthousand","Scaron","guilsinglleft","OE","bullet","Zcaron","bullet","bullet","quoteleft","quoteright","quotedblleft","quotedblright","bullet","endash","emdash","tilde","trademark","scaron","guilsinglright","oe","bullet","zcaron","Ydieresis","space","exclamdown","cent","sterling","currency","yen","brokenbar","section","dieresis","copyright","ordfeminine","guillemotleft","logicalnot","hyphen","registered","macron","degree","plusminus","twosuperior","threesuperior","acute","mu","paragraph","periodcentered","cedilla","onesuperior","ordmasculine","guillemotright","onequarter","onehalf","threequarters","questiondown","Agrave","Aacute","Acircumflex","Atilde","Adieresis","Aring","AE","Ccedilla","Egrave","Eacute","Ecircumflex","Edieresis","Igrave","Iacute","Icircumflex","Idieresis","Eth","Ntilde","Ograve","Oacute","Ocircumflex","Otilde","Odieresis","multiply","Oslash","Ugrave","Uacute","Ucircumflex","Udieresis","Yacute","Thorn","germandbls","agrave","aacute","acircumflex","atilde","adieresis","aring","ae","ccedilla","egrave","eacute","ecircumflex","edieresis","igrave","iacute","icircumflex","idieresis","eth","ntilde","ograve","oacute","ocircumflex","otilde","odieresis","divide","oslash","ugrave","uacute","ucircumflex","udieresis","yacute","thorn","ydieresis"],l=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","universal","numbersign","existential","percent","ampersand","suchthat","parenleft","parenright","asteriskmath","plus","comma","minus","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","congruent","Alpha","Beta","Chi","Delta","Epsilon","Phi","Gamma","Eta","Iota","theta1","Kappa","Lambda","Mu","Nu","Omicron","Pi","Theta","Rho","Sigma","Tau","Upsilon","sigma1","Omega","Xi","Psi","Zeta","bracketleft","therefore","bracketright","perpendicular","underscore","radicalex","alpha","beta","chi","delta","epsilon","phi","gamma","eta","iota","phi1","kappa","lambda","mu","nu","omicron","pi","theta","rho","sigma","tau","upsilon","omega1","omega","xi","psi","zeta","braceleft","bar","braceright","similar","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","Euro","Upsilon1","minute","lessequal","fraction","infinity","florin","club","diamond","heart","spade","arrowboth","arrowleft","arrowup","arrowright","arrowdown","degree","plusminus","second","greaterequal","multiply","proportional","partialdiff","bullet","divide","notequal","equivalence","approxequal","ellipsis","arrowvertex","arrowhorizex","carriagereturn","aleph","Ifraktur","Rfraktur","weierstrass","circlemultiply","circleplus","emptyset","intersection","union","propersuperset","reflexsuperset","notsubset","propersubset","reflexsubset","element","notelement","angle","gradient","registerserif","copyrightserif","trademarkserif","product","radical","dotmath","logicalnot","logicaland","logicalor","arrowdblboth","arrowdblleft","arrowdblup","arrowdblright","arrowdbldown","lozenge","angleleft","registersans","copyrightsans","trademarksans","summation","parenlefttp","parenleftex","parenleftbt","bracketlefttp","bracketleftex","bracketleftbt","bracelefttp","braceleftmid","braceleftbt","braceex","","angleright","integral","integraltp","integralex","integralbt","parenrighttp","parenrightex","parenrightbt","bracketrighttp","bracketrightex","bracketrightbt","bracerighttp","bracerightmid","bracerightbt"],h=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","a1","a2","a202","a3","a4","a5","a119","a118","a117","a11","a12","a13","a14","a15","a16","a105","a17","a18","a19","a20","a21","a22","a23","a24","a25","a26","a27","a28","a6","a7","a8","a9","a10","a29","a30","a31","a32","a33","a34","a35","a36","a37","a38","a39","a40","a41","a42","a43","a44","a45","a46","a47","a48","a49","a50","a51","a52","a53","a54","a55","a56","a57","a58","a59","a60","a61","a62","a63","a64","a65","a66","a67","a68","a69","a70","a71","a72","a73","a74","a203","a75","a204","a76","a77","a78","a79","a81","a82","a83","a84","a97","a98","a99","a100","","a89","a90","a93","a94","a91","a92","a205","a85","a206","a86","a87","a88","a95","a96","","","","","","","","","","","","","","","","","","","","a101","a102","a103","a104","a106","a107","a108","a112","a111","a110","a109","a120","a121","a122","a123","a124","a125","a126","a127","a128","a129","a130","a131","a132","a133","a134","a135","a136","a137","a138","a139","a140","a141","a142","a143","a144","a145","a146","a147","a148","a149","a150","a151","a152","a153","a154","a155","a156","a157","a158","a159","a160","a161","a163","a164","a196","a165","a192","a166","a167","a168","a169","a170","a171","a172","a173","a162","a174","a175","a176","a177","a178","a179","a193","a180","a199","a181","a200","a182","","a201","a183","a184","a197","a185","a194","a198","a186","a195","a187","a188","a189","a190","a191"];t.WinAnsiEncoding=c;t.StandardEncoding=o;t.MacRomanEncoding=s;t.SymbolSetEncoding=l;t.ZapfDingbatsEncoding=h;t.ExpertEncoding=i;t.getEncoding=r},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(2),s=r.MissingDataException,o=r.StreamType,c=r.assert,l=r.error,h=r.info,u=r.isArray,f=r.isInt,d=r.isNum,g=r.isString,p=r.warn,m=i.EOF,b=i.Cmd,v=i.Dict,y=i.Name,k=i.Ref,w=i.isEOF,C=i.isCmd,x=i.isDict,S=i.isName,A=n.Ascii85Stream,I=n.AsciiHexStream,B=n.CCITTFaxStream,R=n.FlateStream,T=n.Jbig2Stream,O=n.JpegStream,P=n.JpxStream,M=n.LZWStream,E=n.NullStream,L=n.PredictorStream,D=n.RunLengthStream,F=function(){function e(e,t,a,r){this.lexer=e;this.allowStreams=t;this.xref=a;this.recoveryMode=r||!1;this.imageCache=Object.create(null);this.refill()}e.prototype={refill:function(){this.buf1=this.lexer.getObj();this.buf2=this.lexer.getObj()},shift:function(){if(C(this.buf2,"ID")){this.buf1=this.buf2;this.buf2=null}else{this.buf1=this.buf2;this.buf2=this.lexer.getObj()}},tryShift:function(){try{this.shift();return!0}catch(e){if(e instanceof s)throw e;return!1}},getObj:function(e){var t=this.buf1;this.shift();if(t instanceof b)switch(t.cmd){case"BI":return this.makeInlineImage(e);case"[":for(var a=[];!C(this.buf1,"]")&&!w(this.buf1);)a.push(this.getObj(e));if(w(this.buf1)){this.recoveryMode||l("End of file inside array");return a}this.shift();return a;case"<<":for(var r=new v(this.xref);!C(this.buf1,">>")&&!w(this.buf1);)if(S(this.buf1)){var i=this.buf1.name;this.shift();if(w(this.buf1))break;r.set(i,this.getObj(e))}else{h("Malformed dictionary: key must be a name object");this.shift()}if(w(this.buf1)){this.recoveryMode||l("End of file inside dictionary");return r}if(C(this.buf2,"stream"))return this.allowStreams?this.makeStream(r,e):r;this.shift();return r;default:return t}if(f(t)){var n=t;if(f(this.buf1)&&C(this.buf2,"R")){var s=new k(n,this.buf1);this.shift();this.shift();return s}return n}if(g(t)){var o=t;e&&(o=e.decryptString(o));return o}return t},findDefaultInlineStreamEnd:function(e){for(var t,a,r,i,n=e.pos,s=0;-1!==(t=e.getByte());)if(0===s)s=69===t?1:0;else if(1===s)s=73===t?2:0;else{c(2===s);if(32===t||10===t||13===t){r=5;i=e.peekBytes(r);for(a=0;a<r;a++){t=i[a];if(10!==t&&13!==t&&(t<32||t>127)){s=0;break}}if(2===s)break}else s=0}return e.pos-4-n},findDCTDecodeInlineStreamEnd:function(e){for(var t,a,r,i=e.pos,n=!1;-1!==(t=e.getByte());)if(255===t){switch(e.getByte()){case 0:break;case 255:e.skip(-1);break;case 217:n=!0;break;case 192:case 193:case 194:case 195:case 197:case 198:case 199:case 201:case 202:case 203:case 205:case 206:case 207:case 196:case 204:case 218:case 219:case 220:case 221:case 222:case 223:case 224:case 225:case 226:case 227:case 228:case 229:case 230:case 231:case 232:case 233:case 234:case 235:case 236:case 237:case 238:case 239:case 254:a=e.getUint16();a>2?e.skip(a-2):e.skip(-2)}if(n)break}r=e.pos-i;if(-1===t){p("Inline DCTDecode image stream: EOI marker not found, searching for /EI/ instead.");e.skip(-r);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return r},findASCII85DecodeInlineStreamEnd:function(e){for(var t,a,r=e.pos;-1!==(t=e.getByte());)if(126===t&&62===e.peekByte()){e.skip();break}a=e.pos-r;if(-1===t){p("Inline ASCII85Decode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a},findASCIIHexDecodeInlineStreamEnd:function(e){for(var t,a,r=e.pos;-1!==(t=e.getByte())&&62!==t;);a=e.pos-r;if(-1===t){p("Inline ASCIIHexDecode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a},inlineStreamSkipEI:function(e){for(var t,a=0;-1!==(t=e.getByte());)if(0===a)a=69===t?1:0;else if(1===a)a=73===t?2:0;else if(2===a)break},makeInlineImage:function(e){for(var t=this.lexer,a=t.stream,r=new v(this.xref);!C(this.buf1,"ID")&&!w(this.buf1);){S(this.buf1)||l("Dictionary key must be a name object");var i=this.buf1.name;this.shift();if(w(this.buf1))break;r.set(i,this.getObj(e))}var n,s=r.get("Filter","F");if(S(s))n=s.name;else if(u(s)){var o=this.xref.fetchIfRef(s[0]);S(o)&&(n=o.name)}var c,h,f,d=a.pos;c="DCTDecode"===n||"DCT"===n?this.findDCTDecodeInlineStreamEnd(a):"ASCII85Decide"===n||"A85"===n?this.findASCII85DecodeInlineStreamEnd(a):"ASCIIHexDecode"===n||"AHx"===n?this.findASCIIHexDecodeInlineStreamEnd(a):this.findDefaultInlineStreamEnd(a);var g,p=a.makeSubStream(d,c,r);if(c<1e3){var m=p.getBytes();p.reset();var y=1,k=0;for(h=0,f=m.length;h<f;++h){y+=255&m[h];k+=y}g=k%65521<<16|y%65521;if(this.imageCache.adler32===g){this.buf2=b.get("EI");this.shift();this.imageCache[g].reset();return this.imageCache[g]}}e&&(p=e.createStream(p,c));p=this.filter(p,r,c);p.dict=r;if(void 0!==g){p.cacheKey="inline_"+c+"_"+g;this.imageCache[g]=p}this.buf2=b.get("EI");this.shift();return p},makeStream:function(e,t){var a=this.lexer,r=a.stream;a.skipToNextLine();var i=r.pos-1,n=e.get("Length");if(!f(n)){h("Bad "+n+" attribute in stream");n=0}r.pos=i+n;a.nextChar();if(this.tryShift()&&C(this.buf2,"endstream"))this.shift();else{r.pos=i;for(var s,o,c=[101,110,100,115,116,114,101,97,109],u=0,d=!1;r.pos<r.end;){var g=r.peekBytes(2048),p=g.length-9;if(p<=0)break;d=!1;s=0;for(;s<p;){o=0;for(;o<9&&g[s+o]===c[o];)o++;if(o>=9){d=!0;break}s++}if(d){u+=s;r.pos+=s;break}u+=p;r.pos+=p}d||l("Missing endstream");n=u;a.nextChar();this.shift();this.shift()}this.shift();r=r.makeSubStream(i,n,e);t&&(r=t.createStream(r,n));r=this.filter(r,e,n);r.dict=e;return r},filter:function(e,t,a){var r=t.get("Filter","F"),i=t.get("DecodeParms","DP");if(S(r)){u(i)&&(i=this.xref.fetchIfRef(i[0]));return this.makeFilter(e,r.name,a,i)}var n=a;if(u(r))for(var s=r,o=i,c=0,h=s.length;c<h;++c){r=this.xref.fetchIfRef(s[c]);S(r)||l("Bad filter name: "+r);i=null;u(o)&&c in o&&(i=this.xref.fetchIfRef(o[c]));e=this.makeFilter(e,r.name,n,i);n=null}return e},makeFilter:function(e,t,a,r){if(0===a){p('Empty "'+t+'" stream.');return new E(e)}try{var i=this.xref.stats.streamTypes;if("FlateDecode"===t||"Fl"===t){i[o.FLATE]=!0;return r?new L(new R(e,a),a,r):new R(e,a)}if("LZWDecode"===t||"LZW"===t){i[o.LZW]=!0;var n=1;if(r){r.has("EarlyChange")&&(n=r.get("EarlyChange"));return new L(new M(e,a,n),a,r)}return new M(e,a,n)}if("DCTDecode"===t||"DCT"===t){i[o.DCT]=!0;return new O(e,a,e.dict,r)}if("JPXDecode"===t||"JPX"===t){i[o.JPX]=!0;return new P(e,a,e.dict,r)}if("ASCII85Decode"===t||"A85"===t){i[o.A85]=!0;return new A(e,a)}if("ASCIIHexDecode"===t||"AHx"===t){i[o.AHX]=!0;return new I(e,a)}if("CCITTFaxDecode"===t||"CCF"===t){i[o.CCF]=!0;return new B(e,a,r)}if("RunLengthDecode"===t||"RL"===t){i[o.RL]=!0;return new D(e,a)}if("JBIG2Decode"===t){i[o.JBIG]=!0;return new T(e,a,e.dict,r)}p('filter "'+t+'" not supported yet');return e}catch(t){if(t instanceof s)throw t;p('Invalid stream: "'+t+'"');return new E(e)}}};return e}(),q=function(){function e(e,t){this.stream=e;this.nextChar();this.strBuf=[];this.knownCommands=t}function t(e){return e>=48&&e<=57?15&e:e>=65&&e<=70||e>=97&&e<=102?9+(15&e):-1}var a=[1,0,0,0,0,0,0,0,0,1,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,2,0,0,2,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];e.prototype={nextChar:function(){return this.currentChar=this.stream.getByte()},peekChar:function(){return this.stream.peekByte()},getNumber:function(){var e=this.currentChar,t=!1,a=0,r=1;if(45===e){r=-1;e=this.nextChar();45===e&&(e=this.nextChar())}else 43===e&&(e=this.nextChar());if(46===e){a=10;e=this.nextChar()}if(e<48||e>57){l("Invalid number: "+String.fromCharCode(e));return 0}for(var i=e-48,n=0,s=1;(e=this.nextChar())>=0;)if(48<=e&&e<=57){var o=e-48;if(t)n=10*n+o;else{0!==a&&(a*=10);i=10*i+o}}else if(46===e){if(0!==a)break;a=1}else if(45===e)p("Badly formatted number");else{if(69!==e&&101!==e)break;e=this.peekChar();if(43===e||45===e){s=45===e?-1:1;this.nextChar()}else if(e<48||e>57)break;t=!0}0!==a&&(i/=a);t&&(i*=Math.pow(10,s*n));return r*i},getString:function(){var e=1,t=!1,a=this.strBuf;a.length=0;for(var r=this.nextChar();;){var i=!1;switch(0|r){case-1:p("Unterminated string");t=!0;break;case 40:++e;a.push("(");break;case 41:if(0==--e){this.nextChar();t=!0}else a.push(")");break;case 92:r=this.nextChar();switch(r){case-1:p("Unterminated string");t=!0;break;case 110:a.push("\n");break;case 114:a.push("\r");break;case 116:a.push("\t");break;case 98:a.push("\b");break;case 102:a.push("\f");break;case 92:case 40:case 41:a.push(String.fromCharCode(r));break;case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:var n=15&r;r=this.nextChar();i=!0;if(r>=48&&r<=55){n=(n<<3)+(15&r);r=this.nextChar();if(r>=48&&r<=55){i=!1;n=(n<<3)+(15&r)}}a.push(String.fromCharCode(n));break;case 13:10===this.peekChar()&&this.nextChar();break;case 10:break;default:a.push(String.fromCharCode(r))}break;default:a.push(String.fromCharCode(r))}if(t)break;i||(r=this.nextChar())}return a.join("")},getName:function(){var e,r,i=this.strBuf;i.length=0;for(;(e=this.nextChar())>=0&&!a[e];)if(35===e){e=this.nextChar();if(a[e]){p("Lexer_getName: NUMBER SIGN (#) should be followed by a hexadecimal number.");i.push("#");break}var n=t(e);if(-1!==n){r=e;e=this.nextChar();var s=t(e);if(-1===s){p("Lexer_getName: Illegal digit ("+String.fromCharCode(e)+") in hexadecimal number.");i.push("#",String.fromCharCode(r));if(a[e])break;i.push(String.fromCharCode(e));continue}i.push(String.fromCharCode(n<<4|s))}else i.push("#",String.fromCharCode(e))}else i.push(String.fromCharCode(e));i.length>127&&p("name token is longer than allowed by the spec: "+i.length);return y.get(i.join(""))},getHexString:function(){var e=this.strBuf;e.length=0;for(var r,i,n=this.currentChar,s=!0;;){if(n<0){p("Unterminated hex string");break}if(62===n){this.nextChar();break}if(1!==a[n]){if(s){r=t(n);if(-1===r){p('Ignoring invalid character "'+n+'" in hex string');n=this.nextChar();continue}}else{i=t(n);if(-1===i){p('Ignoring invalid character "'+n+'" in hex string');n=this.nextChar();continue}e.push(String.fromCharCode(r<<4|i))}s=!s;n=this.nextChar()}else n=this.nextChar()}return e.join("")},getObj:function(){for(var e=!1,t=this.currentChar;;){if(t<0)return m;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(1!==a[t])break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return this.getNumber();case 40:return this.getString();case 47:return this.getName();case 91:this.nextChar();return b.get("[");case 93:this.nextChar();return b.get("]");case 60:t=this.nextChar();if(60===t){this.nextChar();return b.get("<<")}return this.getHexString();case 62:t=this.nextChar();if(62===t){this.nextChar();return b.get(">>")}
+return b.get(">");case 123:this.nextChar();return b.get("{");case 125:this.nextChar();return b.get("}");case 41:this.nextChar();l("Illegal character: "+t)}for(var r=String.fromCharCode(t),i=this.knownCommands,n=i&&void 0!==i[r];(t=this.nextChar())>=0&&!a[t];){var s=r+String.fromCharCode(t);if(n&&void 0===i[s])break;128===r.length&&l("Command token too long: "+r.length);r=s;n=i&&void 0!==i[r]}return"true"===r||"false"!==r&&("null"===r?null:b.get(r))},skipToNextLine:function(){for(var e=this.currentChar;e>=0;){if(13===e){e=this.nextChar();10===e&&this.nextChar();break}if(10===e){this.nextChar();break}e=this.nextChar()}}};return e}(),U={create:function(e){function t(e,t){var a=c.get(e);if(f(a)&&(t?a>=0:a>0))return a;throw new Error('The "'+e+'" parameter in the linearization dictionary is invalid.')}var a,r,i=new F(new q(e),!1,null),n=i.getObj(),s=i.getObj(),o=i.getObj(),c=i.getObj();if(!(f(n)&&f(s)&&C(o,"obj")&&x(c)&&d(a=c.get("Linearized"))&&a>0))return null;if((r=t("L"))!==e.length)throw new Error('The "L" parameter in the linearization dictionary does not equal the stream length.');return{length:r,hints:function(){var e,t,a=c.get("H");if(u(a)&&(2===(e=a.length)||4===e)){for(var r=0;r<e;r++)if(!(f(t=a[r])&&t>0))throw new Error("Hint ("+r+") in the linearization dictionary is invalid.");return a}throw new Error("Hint array in the linearization dictionary is invalid.")}(),objectNumberFirst:t("O"),endFirst:t("E"),numPages:t("N"),mainXRefEntriesOffset:t("T"),pageFirst:c.has("P")?t("P",!0):0}}};t.Lexer=q;t.Linearization=U;t.Parser=F},function(e,t,a){"use strict";function r(e){var t;if("object"!=typeof e)return!1;if(u(e))t=e;else{if(!f(e))return!1;t=e.dict}return t.has("FunctionType")}var i=a(0),n=a(1),s=a(34),o=i.error,c=i.info,l=i.isArray,h=i.isBool,u=n.isDict,f=n.isStream,d=s.PostScriptLexer,g=s.PostScriptParser,p=function(){return{getSampleArray:function(e,t,a,r){var i,n,s=1;for(i=0,n=e.length;i<n;i++)s*=e[i];s*=t;var o=new Array(s),c=0,l=0,h=1/(Math.pow(2,a)-1),u=r.getBytes((s*a+7)/8),f=0;for(i=0;i<s;i++){for(;c<a;){l<<=8;l|=u[f++];c+=8}c-=a;o[i]=(l>>c)*h;l&=(1<<c)-1}return o},getIR:function(e,t){var a=t.dict;a||(a=t);var r=[this.constructSampled,null,this.constructInterpolated,this.constructStiched,this.constructPostScript],i=a.get("FunctionType"),n=r[i];n||o("Unknown type of function");return n.call(this,t,a,e)},fromIR:function(e){switch(e[0]){case 0:return this.constructSampledFromIR(e);case 2:return this.constructInterpolatedFromIR(e);case 3:return this.constructStichedFromIR(e);default:return this.constructPostScriptFromIR(e)}},parse:function(e,t){var a=this.getIR(e,t);return this.fromIR(a)},parseArray:function(e,t){if(!l(t))return this.parse(e,t);for(var a=[],r=0,i=t.length;r<i;r++){var n=e.fetchIfRef(t[r]);a.push(p.parse(e,n))}return function(e,t,r,i){for(var n=0,s=a.length;n<s;n++)a[n](e,t,r,i+n)}},constructSampled:function(e,t){function a(e){for(var t=e.length,a=[],r=0,i=0;i<t;i+=2){a[r]=[e[i],e[i+1]];++r}return a}var r=t.getArray("Domain"),i=t.getArray("Range");r&&i||o("No domain or range");var n=r.length/2,s=i.length/2;r=a(r);i=a(i);var l=t.get("Size"),h=t.get("BitsPerSample"),u=t.get("Order")||1;1!==u&&c("No support for cubic spline interpolation: "+u);var f=t.getArray("Encode");if(!f){f=[];for(var d=0;d<n;++d){f.push(0);f.push(l[d]-1)}}f=a(f);var g=t.getArray("Decode");g=g?a(g):i;return[0,n,r,f,g,this.getSampleArray(l,s,h,e),l,s,Math.pow(2,h)-1,i]},constructSampledFromIR:function(e){function t(e,t,a,r,i){return r+(i-r)/(a-t)*(e-t)}return function(a,r,i,n){var s,o,c=e[1],l=e[2],h=e[3],u=e[4],f=e[5],d=e[6],g=e[7],p=e[9],m=1<<c,b=new Float64Array(m),v=new Uint32Array(m);for(o=0;o<m;o++)b[o]=1;var y=g,k=1;for(s=0;s<c;++s){var w=l[s][0],C=l[s][1],x=Math.min(Math.max(a[r+s],w),C),S=t(x,w,C,h[s][0],h[s][1]),A=d[s];S=Math.min(Math.max(S,0),A-1);var I=S<A-1?Math.floor(S):S-1,B=I+1-S,R=S-I,T=I*y,O=T+y;for(o=0;o<m;o++)if(o&k){b[o]*=R;v[o]+=O}else{b[o]*=B;v[o]+=T}y*=A;k<<=1}for(o=0;o<g;++o){var P=0;for(s=0;s<m;s++)P+=f[v[s]+o]*b[s];P=t(P,0,1,u[o][0],u[o][1]);i[n+o]=Math.min(Math.max(P,p[o][0]),p[o][1])}}},constructInterpolated:function(e,t){var a=t.getArray("C0")||[0],r=t.getArray("C1")||[1],i=t.get("N");l(a)&&l(r)||o("Illegal dictionary for interpolated function");for(var n=a.length,s=[],c=0;c<n;++c)s.push(r[c]-a[c]);return[2,a,s,i]},constructInterpolatedFromIR:function(e){var t=e[1],a=e[2],r=e[3],i=a.length;return function(e,n,s,o){for(var c=1===r?e[n]:Math.pow(e[n],r),l=0;l<i;++l)s[o+l]=t[l]+c*a[l]}},constructStiched:function(e,t,a){var r=t.getArray("Domain");r||o("No domain");1!=r.length/2&&o("Bad domain for stiched function");for(var i=t.get("Functions"),n=[],s=0,c=i.length;s<c;++s)n.push(p.getIR(a,a.fetchIfRef(i[s])));return[3,r,t.getArray("Bounds"),t.getArray("Encode"),n]},constructStichedFromIR:function(e){for(var t=e[1],a=e[2],r=e[3],i=e[4],n=[],s=new Float32Array(1),o=0,c=i.length;o<c;o++)n.push(p.fromIR(i[o]));return function(e,i,o,c){for(var l=function(e,t,a){e>a?e=a:e<t&&(e=t);return e}(e[i],t[0],t[1]),h=0,u=a.length;h<u&&!(l<a[h]);++h);var f=t[0];h>0&&(f=a[h-1]);var d=t[1];h<a.length&&(d=a[h]);var g=r[2*h],p=r[2*h+1];s[0]=f===d?g:g+(l-f)*(p-g)/(d-f);n[h](s,0,o,c)}},constructPostScript:function(e,t,a){var r=t.getArray("Domain"),i=t.getArray("Range");r||o("No domain.");i||o("No range.");var n=new d(e);return[4,r,i,new g(n).parse()]},constructPostScriptFromIR:function(e){var t=e[1],a=e[2],r=e[3],i=(new v).compile(r,t,a);if(i)return new Function("src","srcOffset","dest","destOffset",i);c("Unable to compile PS function");var n=a.length>>1,s=t.length>>1,o=new b(r),l=Object.create(null),h=8192,u=new Float32Array(s);return function(e,t,r,i){var c,f,d="",g=u;for(c=0;c<s;c++){f=e[t+c];g[c]=f;d+=f+"_"}var p=l[d];if(void 0===p){var m=new Float32Array(n),b=o.execute(g),v=b.length-n;for(c=0;c<n;c++){f=b[v+c];var y=a[2*c];if(f<y)f=y;else{y=a[2*c+1];f>y&&(f=y)}m[c]=f}if(h>0){h--;l[d]=m}r.set(m,i)}else r.set(p,i)}}}}(),m=function(){function e(e){this.stack=e?Array.prototype.slice.call(e,0):[]}e.prototype={push:function(e){this.stack.length>=100&&o("PostScript function stack overflow.");this.stack.push(e)},pop:function(){this.stack.length<=0&&o("PostScript function stack underflow.");return this.stack.pop()},copy:function(e){this.stack.length+e>=100&&o("PostScript function stack overflow.");for(var t=this.stack,a=t.length-e,r=e-1;r>=0;r--,a++)t.push(t[a])},index:function(e){this.push(this.stack[this.stack.length-e-1])},roll:function(e,t){var a,r,i,n=this.stack,s=n.length-e,o=n.length-1,c=s+(t-Math.floor(t/e)*e);for(a=s,r=o;a<r;a++,r--){i=n[a];n[a]=n[r];n[r]=i}for(a=s,r=c-1;a<r;a++,r--){i=n[a];n[a]=n[r];n[r]=i}for(a=c,r=o;a<r;a++,r--){i=n[a];n[a]=n[r];n[r]=i}}};return e}(),b=function(){function e(e){this.operators=e}e.prototype={execute:function(e){for(var t,a,r,i=new m(e),n=0,s=this.operators,c=s.length;n<c;){t=s[n++];if("number"!=typeof t)switch(t){case"jz":r=i.pop();a=i.pop();a||(n=r);break;case"j":a=i.pop();n=a;break;case"abs":a=i.pop();i.push(Math.abs(a));break;case"add":r=i.pop();a=i.pop();i.push(a+r);break;case"and":r=i.pop();a=i.pop();h(a)&&h(r)?i.push(a&&r):i.push(a&r);break;case"atan":a=i.pop();i.push(Math.atan(a));break;case"bitshift":r=i.pop();a=i.pop();a>0?i.push(a<<r):i.push(a>>r);break;case"ceiling":a=i.pop();i.push(Math.ceil(a));break;case"copy":a=i.pop();i.copy(a);break;case"cos":a=i.pop();i.push(Math.cos(a));break;case"cvi":a=0|i.pop();i.push(a);break;case"cvr":break;case"div":r=i.pop();a=i.pop();i.push(a/r);break;case"dup":i.copy(1);break;case"eq":r=i.pop();a=i.pop();i.push(a===r);break;case"exch":i.roll(2,1);break;case"exp":r=i.pop();a=i.pop();i.push(Math.pow(a,r));break;case"false":i.push(!1);break;case"floor":a=i.pop();i.push(Math.floor(a));break;case"ge":r=i.pop();a=i.pop();i.push(a>=r);break;case"gt":r=i.pop();a=i.pop();i.push(a>r);break;case"idiv":r=i.pop();a=i.pop();i.push(a/r|0);break;case"index":a=i.pop();i.index(a);break;case"le":r=i.pop();a=i.pop();i.push(a<=r);break;case"ln":a=i.pop();i.push(Math.log(a));break;case"log":a=i.pop();i.push(Math.log(a)/Math.LN10);break;case"lt":r=i.pop();a=i.pop();i.push(a<r);break;case"mod":r=i.pop();a=i.pop();i.push(a%r);break;case"mul":r=i.pop();a=i.pop();i.push(a*r);break;case"ne":r=i.pop();a=i.pop();i.push(a!==r);break;case"neg":a=i.pop();i.push(-a);break;case"not":a=i.pop();h(a)?i.push(!a):i.push(~a);break;case"or":r=i.pop();a=i.pop();h(a)&&h(r)?i.push(a||r):i.push(a|r);break;case"pop":i.pop();break;case"roll":r=i.pop();a=i.pop();i.roll(a,r);break;case"round":a=i.pop();i.push(Math.round(a));break;case"sin":a=i.pop();i.push(Math.sin(a));break;case"sqrt":a=i.pop();i.push(Math.sqrt(a));break;case"sub":r=i.pop();a=i.pop();i.push(a-r);break;case"true":i.push(!0);break;case"truncate":a=i.pop();a=a<0?Math.ceil(a):Math.floor(a);i.push(a);break;case"xor":r=i.pop();a=i.pop();h(a)&&h(r)?i.push(a!==r):i.push(a^r);break;default:o("Unknown operator "+t)}else i.push(t)}return i.stack}};return e}(),v=function(){function e(e){this.type=e}function t(t,a,r){e.call(this,"args");this.index=t;this.min=a;this.max=r}function a(t){e.call(this,"literal");this.number=t;this.min=t;this.max=t}function r(t,a,r,i,n){e.call(this,"binary");this.op=t;this.arg1=a;this.arg2=r;this.min=i;this.max=n}function i(t,a){e.call(this,"max");this.arg=t;this.min=t.min;this.max=a}function n(t,a,r){e.call(this,"var");this.index=t;this.min=a;this.max=r}function s(t,a){e.call(this,"definition");this.variable=t;this.arg=a}function o(){this.parts=[]}function c(e,t){return"literal"===t.type&&0===t.number?e:"literal"===e.type&&0===e.number?t:"literal"===t.type&&"literal"===e.type?new a(e.number+t.number):new r("+",e,t,e.min+t.min,e.max+t.max)}function l(e,t){if("literal"===t.type){if(0===t.number)return new a(0);if(1===t.number)return e;if("literal"===e.type)return new a(e.number*t.number)}if("literal"===e.type){if(0===e.number)return new a(0);if(1===e.number)return t}return new r("*",e,t,Math.min(e.min*t.min,e.min*t.max,e.max*t.min,e.max*t.max),Math.max(e.min*t.min,e.min*t.max,e.max*t.min,e.max*t.max))}function h(e,t){if("literal"===t.type){if(0===t.number)return e;if("literal"===e.type)return new a(e.number-t.number)}return"binary"===t.type&&"-"===t.op&&"literal"===e.type&&1===e.number&&"literal"===t.arg1.type&&1===t.arg1.number?t.arg2:new r("-",e,t,e.min-t.max,e.max-t.min)}function u(e,t){return e.min>=t?new a(t):e.max<=t?e:new i(e,t)}function f(){}e.prototype.visit=function(e){throw new Error("abstract method")};t.prototype=Object.create(e.prototype);t.prototype.visit=function(e){e.visitArgument(this)};a.prototype=Object.create(e.prototype);a.prototype.visit=function(e){e.visitLiteral(this)};r.prototype=Object.create(e.prototype);r.prototype.visit=function(e){e.visitBinaryOperation(this)};i.prototype=Object.create(e.prototype);i.prototype.visit=function(e){e.visitMin(this)};n.prototype=Object.create(e.prototype);n.prototype.visit=function(e){e.visitVariable(this)};s.prototype=Object.create(e.prototype);s.prototype.visit=function(e){e.visitVariableDefinition(this)};o.prototype={visitArgument:function(e){this.parts.push("Math.max(",e.min,", Math.min(",e.max,", src[srcOffset + ",e.index,"]))")},visitVariable:function(e){this.parts.push("v",e.index)},visitLiteral:function(e){this.parts.push(e.number)},visitBinaryOperation:function(e){this.parts.push("(");e.arg1.visit(this);this.parts.push(" ",e.op," ");e.arg2.visit(this);this.parts.push(")")},visitVariableDefinition:function(e){this.parts.push("var ");e.variable.visit(this);this.parts.push(" = ");e.arg.visit(this);this.parts.push(";")},visitMin:function(e){this.parts.push("Math.min(");e.arg.visit(this);this.parts.push(", ",e.max,")")},toString:function(){return this.parts.join("")}};f.prototype={compile:function(e,r,i){var f,d,g,p,m,b,v,y,k,w,C=[],x=[],S=r.length>>1,A=i.length>>1,I=0;for(f=0;f<S;f++)C.push(new t(f,r[2*f],r[2*f+1]));for(f=0,d=e.length;f<d;f++){w=e[f];if("number"!=typeof w)switch(w){case"add":if(C.length<2)return null;b=C.pop();m=C.pop();C.push(c(m,b));break;case"cvr":if(C.length<1)return null;break;case"mul":if(C.length<2)return null;b=C.pop();m=C.pop();C.push(l(m,b));break;case"sub":if(C.length<2)return null;b=C.pop();m=C.pop();C.push(h(m,b));break;case"exch":if(C.length<2)return null;v=C.pop();y=C.pop();C.push(v,y);break;case"pop":if(C.length<1)return null;C.pop();break;case"index":if(C.length<1)return null;m=C.pop();if("literal"!==m.type)return null;g=m.number;if(g<0||(0|g)!==g||C.length<g)return null;v=C[C.length-g-1];if("literal"===v.type||"var"===v.type){C.push(v);break}k=new n(I++,v.min,v.max);C[C.length-g-1]=k;C.push(k);x.push(new s(k,v));break;case"dup":if(C.length<1)return null;if("number"==typeof e[f+1]&&"gt"===e[f+2]&&e[f+3]===f+7&&"jz"===e[f+4]&&"pop"===e[f+5]&&e[f+6]===e[f+1]){m=C.pop();C.push(u(m,e[f+1]));f+=6;break}v=C[C.length-1];if("literal"===v.type||"var"===v.type){C.push(v);break}k=new n(I++,v.min,v.max);C[C.length-1]=k;C.push(k);x.push(new s(k,v));break;case"roll":if(C.length<2)return null;b=C.pop();m=C.pop();if("literal"!==b.type||"literal"!==m.type)return null;p=b.number;g=m.number;if(g<=0||(0|g)!==g||(0|p)!==p||C.length<g)return null;p=(p%g+g)%g;if(0===p)break;Array.prototype.push.apply(C,C.splice(C.length-g,g-p));break;default:return null}else C.push(new a(w))}if(C.length!==A)return null;var B=[];x.forEach(function(e){var t=new o;e.visit(t);B.push(t.toString())});C.forEach(function(e,t){var a=new o;e.visit(a);var r=i[2*t],n=i[2*t+1],s=[a.toString()];if(r>e.min){s.unshift("Math.max(",r,", ");s.push(")")}if(n<e.max){s.unshift("Math.min(",n,", ");s.push(")")}s.unshift("dest[destOffset + ",t,"] = ");s.push(";");B.push(s.join(""))});return B.join("\n")}};return f}();t.isPDFFunction=r;t.PDFFunction=p;t.PostScriptEvaluator=b;t.PostScriptCompiler=v},function(e,t,a){"use strict";var r=a(0),i=r.getLookupTableFactory,n=i(function(e){e.A=65;e.AE=198;e.AEacute=508;e.AEmacron=482;e.AEsmall=63462;e.Aacute=193;e.Aacutesmall=63457;e.Abreve=258;e.Abreveacute=7854;e.Abrevecyrillic=1232;e.Abrevedotbelow=7862;e.Abrevegrave=7856;e.Abrevehookabove=7858;e.Abrevetilde=7860;e.Acaron=461;e.Acircle=9398;e.Acircumflex=194;e.Acircumflexacute=7844;e.Acircumflexdotbelow=7852;e.Acircumflexgrave=7846;e.Acircumflexhookabove=7848;e.Acircumflexsmall=63458;e.Acircumflextilde=7850;e.Acute=63177;e.Acutesmall=63412;e.Acyrillic=1040;e.Adblgrave=512;e.Adieresis=196;e.Adieresiscyrillic=1234;e.Adieresismacron=478;e.Adieresissmall=63460;e.Adotbelow=7840;e.Adotmacron=480;e.Agrave=192;e.Agravesmall=63456;e.Ahookabove=7842;e.Aiecyrillic=1236;e.Ainvertedbreve=514;e.Alpha=913;e.Alphatonos=902;e.Amacron=256;e.Amonospace=65313;e.Aogonek=260;e.Aring=197;e.Aringacute=506;e.Aringbelow=7680;e.Aringsmall=63461;e.Asmall=63329;e.Atilde=195;e.Atildesmall=63459;e.Aybarmenian=1329;e.B=66;e.Bcircle=9399;e.Bdotaccent=7682;e.Bdotbelow=7684;e.Becyrillic=1041;e.Benarmenian=1330;e.Beta=914;e.Bhook=385;e.Blinebelow=7686;e.Bmonospace=65314;e.Brevesmall=63220;e.Bsmall=63330;e.Btopbar=386;e.C=67;e.Caarmenian=1342;e.Cacute=262;e.Caron=63178;e.Caronsmall=63221;e.Ccaron=268;e.Ccedilla=199;e.Ccedillaacute=7688;e.Ccedillasmall=63463;e.Ccircle=9400;e.Ccircumflex=264;e.Cdot=266;e.Cdotaccent=266;e.Cedillasmall=63416;e.Chaarmenian=1353;e.Cheabkhasiancyrillic=1212;e.Checyrillic=1063;e.Chedescenderabkhasiancyrillic=1214;e.Chedescendercyrillic=1206;e.Chedieresiscyrillic=1268;e.Cheharmenian=1347;e.Chekhakassiancyrillic=1227;e.Cheverticalstrokecyrillic=1208;e.Chi=935;e.Chook=391;e.Circumflexsmall=63222;e.Cmonospace=65315;e.Coarmenian=1361;e.Csmall=63331;e.D=68;e.DZ=497;e.DZcaron=452;e.Daarmenian=1332;e.Dafrican=393;e.Dcaron=270;e.Dcedilla=7696;e.Dcircle=9401;e.Dcircumflexbelow=7698;e.Dcroat=272;e.Ddotaccent=7690;e.Ddotbelow=7692;e.Decyrillic=1044;e.Deicoptic=1006;e.Delta=8710;e.Deltagreek=916;e.Dhook=394;e.Dieresis=63179;e.DieresisAcute=63180;e.DieresisGrave=63181;e.Dieresissmall=63400;e.Digammagreek=988;e.Djecyrillic=1026;e.Dlinebelow=7694;e.Dmonospace=65316;e.Dotaccentsmall=63223;e.Dslash=272;e.Dsmall=63332;e.Dtopbar=395;e.Dz=498;e.Dzcaron=453;e.Dzeabkhasiancyrillic=1248;e.Dzecyrillic=1029;e.Dzhecyrillic=1039;e.E=69;e.Eacute=201;e.Eacutesmall=63465;e.Ebreve=276;e.Ecaron=282;e.Ecedillabreve=7708;e.Echarmenian=1333;e.Ecircle=9402;e.Ecircumflex=202;e.Ecircumflexacute=7870;e.Ecircumflexbelow=7704;e.Ecircumflexdotbelow=7878;e.Ecircumflexgrave=7872;e.Ecircumflexhookabove=7874;e.Ecircumflexsmall=63466;e.Ecircumflextilde=7876;e.Ecyrillic=1028;e.Edblgrave=516;e.Edieresis=203;e.Edieresissmall=63467;e.Edot=278;e.Edotaccent=278;e.Edotbelow=7864;e.Efcyrillic=1060;e.Egrave=200;e.Egravesmall=63464;e.Eharmenian=1335;e.Ehookabove=7866;e.Eightroman=8551;e.Einvertedbreve=518;e.Eiotifiedcyrillic=1124;e.Elcyrillic=1051;e.Elevenroman=8554;e.Emacron=274;e.Emacronacute=7702;e.Emacrongrave=7700;e.Emcyrillic=1052;e.Emonospace=65317;e.Encyrillic=1053;e.Endescendercyrillic=1186;e.Eng=330;e.Enghecyrillic=1188;e.Enhookcyrillic=1223;e.Eogonek=280;e.Eopen=400;e.Epsilon=917;e.Epsilontonos=904;e.Ercyrillic=1056;e.Ereversed=398;e.Ereversedcyrillic=1069;e.Escyrillic=1057;e.Esdescendercyrillic=1194;e.Esh=425;e.Esmall=63333;e.Eta=919;e.Etarmenian=1336;e.Etatonos=905;e.Eth=208;e.Ethsmall=63472;e.Etilde=7868;e.Etildebelow=7706;e.Euro=8364;e.Ezh=439;e.Ezhcaron=494;e.Ezhreversed=440;e.F=70;e.Fcircle=9403;e.Fdotaccent=7710;e.Feharmenian=1366;e.Feicoptic=996;e.Fhook=401;e.Fitacyrillic=1138;e.Fiveroman=8548;e.Fmonospace=65318;e.Fourroman=8547;e.Fsmall=63334;e.G=71;e.GBsquare=13191;e.Gacute=500;e.Gamma=915;e.Gammaafrican=404;e.Gangiacoptic=1002;e.Gbreve=286;e.Gcaron=486;e.Gcedilla=290;e.Gcircle=9404;e.Gcircumflex=284;e.Gcommaaccent=290;e.Gdot=288;e.Gdotaccent=288;e.Gecyrillic=1043;e.Ghadarmenian=1346;e.Ghemiddlehookcyrillic=1172;e.Ghestrokecyrillic=1170;e.Gheupturncyrillic=1168;e.Ghook=403;e.Gimarmenian=1331;e.Gjecyrillic=1027;e.Gmacron=7712;e.Gmonospace=65319;e.Grave=63182;e.Gravesmall=63328;e.Gsmall=63335;e.Gsmallhook=667;e.Gstroke=484;e.H=72;e.H18533=9679;e.H18543=9642;e.H18551=9643;e.H22073=9633;e.HPsquare=13259;e.Haabkhasiancyrillic=1192;e.Hadescendercyrillic=1202;e.Hardsigncyrillic=1066;e.Hbar=294;e.Hbrevebelow=7722;e.Hcedilla=7720;e.Hcircle=9405;e.Hcircumflex=292;e.Hdieresis=7718;e.Hdotaccent=7714;e.Hdotbelow=7716;e.Hmonospace=65320;e.Hoarmenian=1344;e.Horicoptic=1e3;e.Hsmall=63336;e.Hungarumlaut=63183;e.Hungarumlautsmall=63224;e.Hzsquare=13200;e.I=73;e.IAcyrillic=1071;e.IJ=306;e.IUcyrillic=1070;e.Iacute=205;e.Iacutesmall=63469;e.Ibreve=300;e.Icaron=463;e.Icircle=9406;e.Icircumflex=206;e.Icircumflexsmall=63470;e.Icyrillic=1030;e.Idblgrave=520;e.Idieresis=207;e.Idieresisacute=7726;e.Idieresiscyrillic=1252;e.Idieresissmall=63471;e.Idot=304;e.Idotaccent=304;e.Idotbelow=7882;e.Iebrevecyrillic=1238;e.Iecyrillic=1045;e.Ifraktur=8465;e.Igrave=204;e.Igravesmall=63468;e.Ihookabove=7880;e.Iicyrillic=1048;e.Iinvertedbreve=522;e.Iishortcyrillic=1049;e.Imacron=298;e.Imacroncyrillic=1250;e.Imonospace=65321;e.Iniarmenian=1339;e.Iocyrillic=1025;e.Iogonek=302;e.Iota=921;e.Iotaafrican=406;e.Iotadieresis=938;e.Iotatonos=906;e.Ismall=63337;e.Istroke=407;e.Itilde=296;e.Itildebelow=7724;e.Izhitsacyrillic=1140;e.Izhitsadblgravecyrillic=1142;e.J=74;e.Jaarmenian=1345;e.Jcircle=9407;e.Jcircumflex=308;e.Jecyrillic=1032;e.Jheharmenian=1355;e.Jmonospace=65322;e.Jsmall=63338;e.K=75;e.KBsquare=13189;e.KKsquare=13261;e.Kabashkircyrillic=1184;e.Kacute=7728;e.Kacyrillic=1050;e.Kadescendercyrillic=1178;e.Kahookcyrillic=1219;e.Kappa=922;e.Kastrokecyrillic=1182;e.Kaverticalstrokecyrillic=1180;e.Kcaron=488;e.Kcedilla=310;e.Kcircle=9408;e.Kcommaaccent=310;e.Kdotbelow=7730;e.Keharmenian=1364;e.Kenarmenian=1343;e.Khacyrillic=1061;e.Kheicoptic=998;e.Khook=408;e.Kjecyrillic=1036;e.Klinebelow=7732;e.Kmonospace=65323;e.Koppacyrillic=1152;e.Koppagreek=990;e.Ksicyrillic=1134;e.Ksmall=63339;e.L=76;e.LJ=455;e.LL=63167;e.Lacute=313;e.Lambda=923;e.Lcaron=317;e.Lcedilla=315;e.Lcircle=9409;e.Lcircumflexbelow=7740;e.Lcommaaccent=315;e.Ldot=319;e.Ldotaccent=319;e.Ldotbelow=7734;e.Ldotbelowmacron=7736;e.Liwnarmenian=1340;e.Lj=456;e.Ljecyrillic=1033;e.Llinebelow=7738;e.Lmonospace=65324;e.Lslash=321;e.Lslashsmall=63225;e.Lsmall=63340;e.M=77;e.MBsquare=13190;e.Macron=63184;e.Macronsmall=63407;e.Macute=7742;e.Mcircle=9410;e.Mdotaccent=7744;e.Mdotbelow=7746;e.Menarmenian=1348;e.Mmonospace=65325;e.Msmall=63341;e.Mturned=412;e.Mu=924;e.N=78;e.NJ=458;e.Nacute=323;e.Ncaron=327;e.Ncedilla=325;e.Ncircle=9411;e.Ncircumflexbelow=7754;e.Ncommaaccent=325;e.Ndotaccent=7748;e.Ndotbelow=7750;e.Nhookleft=413;e.Nineroman=8552;e.Nj=459;e.Njecyrillic=1034;e.Nlinebelow=7752;e.Nmonospace=65326;e.Nowarmenian=1350;e.Nsmall=63342;e.Ntilde=209;e.Ntildesmall=63473;e.Nu=925;e.O=79;e.OE=338;e.OEsmall=63226;e.Oacute=211;e.Oacutesmall=63475;e.Obarredcyrillic=1256;e.Obarreddieresiscyrillic=1258;e.Obreve=334;e.Ocaron=465;e.Ocenteredtilde=415;e.Ocircle=9412;e.Ocircumflex=212;e.Ocircumflexacute=7888;e.Ocircumflexdotbelow=7896;e.Ocircumflexgrave=7890;e.Ocircumflexhookabove=7892;e.Ocircumflexsmall=63476;e.Ocircumflextilde=7894;e.Ocyrillic=1054;e.Odblacute=336;e.Odblgrave=524;e.Odieresis=214;e.Odieresiscyrillic=1254;e.Odieresissmall=63478;e.Odotbelow=7884;e.Ogoneksmall=63227;e.Ograve=210;e.Ogravesmall=63474;e.Oharmenian=1365;e.Ohm=8486;e.Ohookabove=7886;e.Ohorn=416;e.Ohornacute=7898;e.Ohorndotbelow=7906;e.Ohorngrave=7900;e.Ohornhookabove=7902;e.Ohorntilde=7904;e.Ohungarumlaut=336;e.Oi=418;e.Oinvertedbreve=526;e.Omacron=332;e.Omacronacute=7762;e.Omacrongrave=7760;e.Omega=8486;e.Omegacyrillic=1120;e.Omegagreek=937;e.Omegaroundcyrillic=1146;e.Omegatitlocyrillic=1148;e.Omegatonos=911;e.Omicron=927;e.Omicrontonos=908;e.Omonospace=65327;e.Oneroman=8544;e.Oogonek=490;e.Oogonekmacron=492;e.Oopen=390;e.Oslash=216;e.Oslashacute=510;e.Oslashsmall=63480;e.Osmall=63343;e.Ostrokeacute=510;e.Otcyrillic=1150;e.Otilde=213;e.Otildeacute=7756;e.Otildedieresis=7758;e.Otildesmall=63477;e.P=80;e.Pacute=7764;e.Pcircle=9413;e.Pdotaccent=7766;e.Pecyrillic=1055;e.Peharmenian=1354;e.Pemiddlehookcyrillic=1190;e.Phi=934;e.Phook=420;e.Pi=928;e.Piwrarmenian=1363;e.Pmonospace=65328;e.Psi=936;e.Psicyrillic=1136;e.Psmall=63344;e.Q=81;e.Qcircle=9414;e.Qmonospace=65329;e.Qsmall=63345;e.R=82;e.Raarmenian=1356;e.Racute=340;e.Rcaron=344;e.Rcedilla=342;e.Rcircle=9415;e.Rcommaaccent=342;e.Rdblgrave=528;e.Rdotaccent=7768;e.Rdotbelow=7770;e.Rdotbelowmacron=7772;e.Reharmenian=1360;e.Rfraktur=8476;e.Rho=929;e.Ringsmall=63228;e.Rinvertedbreve=530;e.Rlinebelow=7774;e.Rmonospace=65330;e.Rsmall=63346;e.Rsmallinverted=641;e.Rsmallinvertedsuperior=694;e.S=83;e.SF010000=9484;e.SF020000=9492;e.SF030000=9488;e.SF040000=9496;e.SF050000=9532;e.SF060000=9516;e.SF070000=9524;e.SF080000=9500;e.SF090000=9508;e.SF100000=9472;e.SF110000=9474;e.SF190000=9569;e.SF200000=9570;e.SF210000=9558;e.SF220000=9557;e.SF230000=9571;e.SF240000=9553;e.SF250000=9559;e.SF260000=9565;e.SF270000=9564;e.SF280000=9563;e.SF360000=9566;e.SF370000=9567;e.SF380000=9562;e.SF390000=9556;e.SF400000=9577;e.SF410000=9574;e.SF420000=9568;e.SF430000=9552;e.SF440000=9580;e.SF450000=9575;e.SF460000=9576;e.SF470000=9572;e.SF480000=9573;e.SF490000=9561;e.SF500000=9560;e.SF510000=9554;e.SF520000=9555;e.SF530000=9579;e.SF540000=9578;e.Sacute=346;e.Sacutedotaccent=7780;e.Sampigreek=992;e.Scaron=352;e.Scarondotaccent=7782;e.Scaronsmall=63229;e.Scedilla=350;e.Schwa=399;e.Schwacyrillic=1240;e.Schwadieresiscyrillic=1242;e.Scircle=9416;e.Scircumflex=348;e.Scommaaccent=536;e.Sdotaccent=7776;e.Sdotbelow=7778;e.Sdotbelowdotaccent=7784;e.Seharmenian=1357;e.Sevenroman=8550;e.Shaarmenian=1351;e.Shacyrillic=1064;e.Shchacyrillic=1065;e.Sheicoptic=994;e.Shhacyrillic=1210;e.Shimacoptic=1004;e.Sigma=931;e.Sixroman=8549;e.Smonospace=65331;e.Softsigncyrillic=1068;e.Ssmall=63347;e.Stigmagreek=986;e.T=84;e.Tau=932;e.Tbar=358;e.Tcaron=356;e.Tcedilla=354;e.Tcircle=9417;e.Tcircumflexbelow=7792;e.Tcommaaccent=354;e.Tdotaccent=7786;e.Tdotbelow=7788;e.Tecyrillic=1058;e.Tedescendercyrillic=1196;e.Tenroman=8553;e.Tetsecyrillic=1204;e.Theta=920;e.Thook=428;e.Thorn=222;e.Thornsmall=63486;e.Threeroman=8546;e.Tildesmall=63230;e.Tiwnarmenian=1359;e.Tlinebelow=7790;e.Tmonospace=65332;e.Toarmenian=1337;e.Tonefive=444;e.Tonesix=388;e.Tonetwo=423;e.Tretroflexhook=430;e.Tsecyrillic=1062;e.Tshecyrillic=1035;e.Tsmall=63348;e.Twelveroman=8555;e.Tworoman=8545;e.U=85;e.Uacute=218;e.Uacutesmall=63482;e.Ubreve=364;e.Ucaron=467;e.Ucircle=9418;e.Ucircumflex=219;e.Ucircumflexbelow=7798;e.Ucircumflexsmall=63483;e.Ucyrillic=1059;e.Udblacute=368;e.Udblgrave=532;e.Udieresis=220;e.Udieresisacute=471;e.Udieresisbelow=7794;e.Udieresiscaron=473;e.Udieresiscyrillic=1264;e.Udieresisgrave=475;e.Udieresismacron=469;e.Udieresissmall=63484;e.Udotbelow=7908;e.Ugrave=217;e.Ugravesmall=63481;e.Uhookabove=7910;e.Uhorn=431;e.Uhornacute=7912;e.Uhorndotbelow=7920;e.Uhorngrave=7914;e.Uhornhookabove=7916;e.Uhorntilde=7918;e.Uhungarumlaut=368;e.Uhungarumlautcyrillic=1266;e.Uinvertedbreve=534;e.Ukcyrillic=1144;e.Umacron=362;e.Umacroncyrillic=1262;e.Umacrondieresis=7802;e.Umonospace=65333;e.Uogonek=370;e.Upsilon=933;e.Upsilon1=978;e.Upsilonacutehooksymbolgreek=979;e.Upsilonafrican=433;e.Upsilondieresis=939;e.Upsilondieresishooksymbolgreek=980;e.Upsilonhooksymbol=978;e.Upsilontonos=910;e.Uring=366;e.Ushortcyrillic=1038;e.Usmall=63349;e.Ustraightcyrillic=1198;e.Ustraightstrokecyrillic=1200;e.Utilde=360;e.Utildeacute=7800;e.Utildebelow=7796;e.V=86;e.Vcircle=9419;e.Vdotbelow=7806;e.Vecyrillic=1042;e.Vewarmenian=1358;e.Vhook=434;e.Vmonospace=65334;e.Voarmenian=1352;e.Vsmall=63350;e.Vtilde=7804;e.W=87;e.Wacute=7810;e.Wcircle=9420;e.Wcircumflex=372;e.Wdieresis=7812;e.Wdotaccent=7814;e.Wdotbelow=7816;e.Wgrave=7808;e.Wmonospace=65335;e.Wsmall=63351;e.X=88;e.Xcircle=9421;e.Xdieresis=7820;e.Xdotaccent=7818;e.Xeharmenian=1341;e.Xi=926;e.Xmonospace=65336;e.Xsmall=63352;e.Y=89;e.Yacute=221;e.Yacutesmall=63485;e.Yatcyrillic=1122;e.Ycircle=9422;e.Ycircumflex=374;e.Ydieresis=376;e.Ydieresissmall=63487;e.Ydotaccent=7822;e.Ydotbelow=7924;e.Yericyrillic=1067;e.Yerudieresiscyrillic=1272;e.Ygrave=7922;e.Yhook=435;e.Yhookabove=7926;e.Yiarmenian=1349;e.Yicyrillic=1031;e.Yiwnarmenian=1362;e.Ymonospace=65337;e.Ysmall=63353;e.Ytilde=7928;e.Yusbigcyrillic=1130;e.Yusbigiotifiedcyrillic=1132;e.Yuslittlecyrillic=1126;e.Yuslittleiotifiedcyrillic=1128;e.Z=90;e.Zaarmenian=1334;e.Zacute=377;e.Zcaron=381;e.Zcaronsmall=63231;e.Zcircle=9423;e.Zcircumflex=7824;e.Zdot=379;e.Zdotaccent=379;e.Zdotbelow=7826;e.Zecyrillic=1047;e.Zedescendercyrillic=1176;e.Zedieresiscyrillic=1246;e.Zeta=918;e.Zhearmenian=1338;e.Zhebrevecyrillic=1217;e.Zhecyrillic=1046;e.Zhedescendercyrillic=1174;e.Zhedieresiscyrillic=1244;e.Zlinebelow=7828;e.Zmonospace=65338;e.Zsmall=63354;e.Zstroke=437;e.a=97;e.aabengali=2438;e.aacute=225;e.aadeva=2310;e.aagujarati=2694;e.aagurmukhi=2566;e.aamatragurmukhi=2622;e.aarusquare=13059;e.aavowelsignbengali=2494;e.aavowelsigndeva=2366;e.aavowelsigngujarati=2750;e.abbreviationmarkarmenian=1375;e.abbreviationsigndeva=2416;e.abengali=2437;e.abopomofo=12570;e.abreve=259;e.abreveacute=7855;e.abrevecyrillic=1233;e.abrevedotbelow=7863;e.abrevegrave=7857;e.abrevehookabove=7859;e.abrevetilde=7861;e.acaron=462;e.acircle=9424;e.acircumflex=226;e.acircumflexacute=7845;e.acircumflexdotbelow=7853;e.acircumflexgrave=7847;e.acircumflexhookabove=7849;e.acircumflextilde=7851;e.acute=180;e.acutebelowcmb=791;e.acutecmb=769;e.acutecomb=769;e.acutedeva=2388;e.acutelowmod=719;e.acutetonecmb=833;e.acyrillic=1072;e.adblgrave=513;e.addakgurmukhi=2673;e.adeva=2309;e.adieresis=228;e.adieresiscyrillic=1235;e.adieresismacron=479;e.adotbelow=7841;e.adotmacron=481;e.ae=230;e.aeacute=509;e.aekorean=12624;e.aemacron=483;e.afii00208=8213;e.afii08941=8356;e.afii10017=1040;e.afii10018=1041;e.afii10019=1042;e.afii10020=1043;e.afii10021=1044;e.afii10022=1045;e.afii10023=1025;e.afii10024=1046;e.afii10025=1047;e.afii10026=1048;e.afii10027=1049;e.afii10028=1050;e.afii10029=1051;e.afii10030=1052;e.afii10031=1053;e.afii10032=1054;e.afii10033=1055;e.afii10034=1056;e.afii10035=1057;e.afii10036=1058;e.afii10037=1059;e.afii10038=1060;e.afii10039=1061;e.afii10040=1062;e.afii10041=1063;e.afii10042=1064;e.afii10043=1065;e.afii10044=1066;e.afii10045=1067;e.afii10046=1068;e.afii10047=1069;e.afii10048=1070;e.afii10049=1071;e.afii10050=1168;e.afii10051=1026;e.afii10052=1027;e.afii10053=1028;e.afii10054=1029;e.afii10055=1030;e.afii10056=1031;e.afii10057=1032;e.afii10058=1033;e.afii10059=1034;e.afii10060=1035;e.afii10061=1036;e.afii10062=1038;e.afii10063=63172;e.afii10064=63173;e.afii10065=1072;e.afii10066=1073;e.afii10067=1074;e.afii10068=1075;e.afii10069=1076;e.afii10070=1077;e.afii10071=1105;e.afii10072=1078;e.afii10073=1079;e.afii10074=1080;e.afii10075=1081;e.afii10076=1082;e.afii10077=1083;e.afii10078=1084;e.afii10079=1085;e.afii10080=1086;e.afii10081=1087;e.afii10082=1088;e.afii10083=1089;e.afii10084=1090;e.afii10085=1091;e.afii10086=1092;e.afii10087=1093;e.afii10088=1094;e.afii10089=1095;e.afii10090=1096;e.afii10091=1097;e.afii10092=1098;e.afii10093=1099;e.afii10094=1100;e.afii10095=1101;e.afii10096=1102;e.afii10097=1103;e.afii10098=1169;e.afii10099=1106;e.afii10100=1107;e.afii10101=1108;e.afii10102=1109;e.afii10103=1110;e.afii10104=1111;e.afii10105=1112;e.afii10106=1113;e.afii10107=1114;e.afii10108=1115;e.afii10109=1116;e.afii10110=1118;e.afii10145=1039;e.afii10146=1122;e.afii10147=1138;e.afii10148=1140;e.afii10192=63174;e.afii10193=1119;e.afii10194=1123;e.afii10195=1139;e.afii10196=1141;e.afii10831=63175;e.afii10832=63176;e.afii10846=1241;e.afii299=8206;e.afii300=8207;e.afii301=8205;e.afii57381=1642;e.afii57388=1548;e.afii57392=1632;e.afii57393=1633;e.afii57394=1634;e.afii57395=1635;e.afii57396=1636;e.afii57397=1637;e.afii57398=1638;e.afii57399=1639;e.afii57400=1640;e.afii57401=1641;e.afii57403=1563;e.afii57407=1567;e.afii57409=1569;e.afii57410=1570;e.afii57411=1571;e.afii57412=1572;e.afii57413=1573;e.afii57414=1574;e.afii57415=1575;e.afii57416=1576;e.afii57417=1577;e.afii57418=1578;e.afii57419=1579;e.afii57420=1580;e.afii57421=1581;e.afii57422=1582;e.afii57423=1583;e.afii57424=1584;e.afii57425=1585;e.afii57426=1586;e.afii57427=1587;e.afii57428=1588;e.afii57429=1589;e.afii57430=1590;e.afii57431=1591;e.afii57432=1592;e.afii57433=1593;e.afii57434=1594;e.afii57440=1600;e.afii57441=1601;e.afii57442=1602;e.afii57443=1603;e.afii57444=1604;e.afii57445=1605;e.afii57446=1606;e.afii57448=1608;e.afii57449=1609;e.afii57450=1610;e.afii57451=1611;e.afii57452=1612;e.afii57453=1613;e.afii57454=1614;e.afii57455=1615;e.afii57456=1616;e.afii57457=1617;e.afii57458=1618;e.afii57470=1607;e.afii57505=1700;e.afii57506=1662;e.afii57507=1670;e.afii57508=1688;e.afii57509=1711;e.afii57511=1657;e.afii57512=1672;e.afii57513=1681;e.afii57514=1722;e.afii57519=1746;e.afii57534=1749;e.afii57636=8362;e.afii57645=1470;e.afii57658=1475;e.afii57664=1488;e.afii57665=1489;e.afii57666=1490;e.afii57667=1491;e.afii57668=1492;e.afii57669=1493;e.afii57670=1494;e.afii57671=1495;e.afii57672=1496;e.afii57673=1497;e.afii57674=1498;e.afii57675=1499;e.afii57676=1500;e.afii57677=1501;e.afii57678=1502;e.afii57679=1503;e.afii57680=1504;e.afii57681=1505;e.afii57682=1506;e.afii57683=1507;e.afii57684=1508;e.afii57685=1509;e.afii57686=1510;e.afii57687=1511;e.afii57688=1512;e.afii57689=1513;e.afii57690=1514;e.afii57694=64298;e.afii57695=64299;e.afii57700=64331;e.afii57705=64287;e.afii57716=1520;e.afii57717=1521;e.afii57718=1522;e.afii57723=64309;e.afii57793=1460;e.afii57794=1461;e.afii57795=1462;e.afii57796=1467;e.afii57797=1464;e.afii57798=1463;e.afii57799=1456;e.afii57800=1458;e.afii57801=1457;e.afii57802=1459;e.afii57803=1474;e.afii57804=1473;e.afii57806=1465;e.afii57807=1468;e.afii57839=1469;e.afii57841=1471;e.afii57842=1472;e.afii57929=700;e.afii61248=8453;e.afii61289=8467;e.afii61352=8470;e.afii61573=8236;e.afii61574=8237;e.afii61575=8238;e.afii61664=8204;e.afii63167=1645;e.afii64937=701;e.agrave=224;e.agujarati=2693;e.agurmukhi=2565;e.ahiragana=12354;e.ahookabove=7843;e.aibengali=2448;e.aibopomofo=12574;e.aideva=2320;e.aiecyrillic=1237;e.aigujarati=2704;e.aigurmukhi=2576;e.aimatragurmukhi=2632;e.ainarabic=1593;e.ainfinalarabic=65226;e.aininitialarabic=65227;e.ainmedialarabic=65228
+;e.ainvertedbreve=515;e.aivowelsignbengali=2504;e.aivowelsigndeva=2376;e.aivowelsigngujarati=2760;e.akatakana=12450;e.akatakanahalfwidth=65393;e.akorean=12623;e.alef=1488;e.alefarabic=1575;e.alefdageshhebrew=64304;e.aleffinalarabic=65166;e.alefhamzaabovearabic=1571;e.alefhamzaabovefinalarabic=65156;e.alefhamzabelowarabic=1573;e.alefhamzabelowfinalarabic=65160;e.alefhebrew=1488;e.aleflamedhebrew=64335;e.alefmaddaabovearabic=1570;e.alefmaddaabovefinalarabic=65154;e.alefmaksuraarabic=1609;e.alefmaksurafinalarabic=65264;e.alefmaksurainitialarabic=65267;e.alefmaksuramedialarabic=65268;e.alefpatahhebrew=64302;e.alefqamatshebrew=64303;e.aleph=8501;e.allequal=8780;e.alpha=945;e.alphatonos=940;e.amacron=257;e.amonospace=65345;e.ampersand=38;e.ampersandmonospace=65286;e.ampersandsmall=63270;e.amsquare=13250;e.anbopomofo=12578;e.angbopomofo=12580;e.angbracketleft=12296;e.angbracketright=12297;e.angkhankhuthai=3674;e.angle=8736;e.anglebracketleft=12296;e.anglebracketleftvertical=65087;e.anglebracketright=12297;e.anglebracketrightvertical=65088;e.angleleft=9001;e.angleright=9002;e.angstrom=8491;e.anoteleia=903;e.anudattadeva=2386;e.anusvarabengali=2434;e.anusvaradeva=2306;e.anusvaragujarati=2690;e.aogonek=261;e.apaatosquare=13056;e.aparen=9372;e.apostrophearmenian=1370;e.apostrophemod=700;e.apple=63743;e.approaches=8784;e.approxequal=8776;e.approxequalorimage=8786;e.approximatelyequal=8773;e.araeaekorean=12686;e.araeakorean=12685;e.arc=8978;e.arighthalfring=7834;e.aring=229;e.aringacute=507;e.aringbelow=7681;e.arrowboth=8596;e.arrowdashdown=8675;e.arrowdashleft=8672;e.arrowdashright=8674;e.arrowdashup=8673;e.arrowdblboth=8660;e.arrowdbldown=8659;e.arrowdblleft=8656;e.arrowdblright=8658;e.arrowdblup=8657;e.arrowdown=8595;e.arrowdownleft=8601;e.arrowdownright=8600;e.arrowdownwhite=8681;e.arrowheaddownmod=709;e.arrowheadleftmod=706;e.arrowheadrightmod=707;e.arrowheadupmod=708;e.arrowhorizex=63719;e.arrowleft=8592;e.arrowleftdbl=8656;e.arrowleftdblstroke=8653;e.arrowleftoverright=8646;e.arrowleftwhite=8678;e.arrowright=8594;e.arrowrightdblstroke=8655;e.arrowrightheavy=10142;e.arrowrightoverleft=8644;e.arrowrightwhite=8680;e.arrowtableft=8676;e.arrowtabright=8677;e.arrowup=8593;e.arrowupdn=8597;e.arrowupdnbse=8616;e.arrowupdownbase=8616;e.arrowupleft=8598;e.arrowupleftofdown=8645;e.arrowupright=8599;e.arrowupwhite=8679;e.arrowvertex=63718;e.asciicircum=94;e.asciicircummonospace=65342;e.asciitilde=126;e.asciitildemonospace=65374;e.ascript=593;e.ascriptturned=594;e.asmallhiragana=12353;e.asmallkatakana=12449;e.asmallkatakanahalfwidth=65383;e.asterisk=42;e.asteriskaltonearabic=1645;e.asteriskarabic=1645;e.asteriskmath=8727;e.asteriskmonospace=65290;e.asterisksmall=65121;e.asterism=8258;e.asuperior=63209;e.asymptoticallyequal=8771;e.at=64;e.atilde=227;e.atmonospace=65312;e.atsmall=65131;e.aturned=592;e.aubengali=2452;e.aubopomofo=12576;e.audeva=2324;e.augujarati=2708;e.augurmukhi=2580;e.aulengthmarkbengali=2519;e.aumatragurmukhi=2636;e.auvowelsignbengali=2508;e.auvowelsigndeva=2380;e.auvowelsigngujarati=2764;e.avagrahadeva=2365;e.aybarmenian=1377;e.ayin=1506;e.ayinaltonehebrew=64288;e.ayinhebrew=1506;e.b=98;e.babengali=2476;e.backslash=92;e.backslashmonospace=65340;e.badeva=2348;e.bagujarati=2732;e.bagurmukhi=2604;e.bahiragana=12400;e.bahtthai=3647;e.bakatakana=12496;e.bar=124;e.barmonospace=65372;e.bbopomofo=12549;e.bcircle=9425;e.bdotaccent=7683;e.bdotbelow=7685;e.beamedsixteenthnotes=9836;e.because=8757;e.becyrillic=1073;e.beharabic=1576;e.behfinalarabic=65168;e.behinitialarabic=65169;e.behiragana=12409;e.behmedialarabic=65170;e.behmeeminitialarabic=64671;e.behmeemisolatedarabic=64520;e.behnoonfinalarabic=64621;e.bekatakana=12505;e.benarmenian=1378;e.bet=1489;e.beta=946;e.betasymbolgreek=976;e.betdagesh=64305;e.betdageshhebrew=64305;e.bethebrew=1489;e.betrafehebrew=64332;e.bhabengali=2477;e.bhadeva=2349;e.bhagujarati=2733;e.bhagurmukhi=2605;e.bhook=595;e.bihiragana=12403;e.bikatakana=12499;e.bilabialclick=664;e.bindigurmukhi=2562;e.birusquare=13105;e.blackcircle=9679;e.blackdiamond=9670;e.blackdownpointingtriangle=9660;e.blackleftpointingpointer=9668;e.blackleftpointingtriangle=9664;e.blacklenticularbracketleft=12304;e.blacklenticularbracketleftvertical=65083;e.blacklenticularbracketright=12305;e.blacklenticularbracketrightvertical=65084;e.blacklowerlefttriangle=9699;e.blacklowerrighttriangle=9698;e.blackrectangle=9644;e.blackrightpointingpointer=9658;e.blackrightpointingtriangle=9654;e.blacksmallsquare=9642;e.blacksmilingface=9787;e.blacksquare=9632;e.blackstar=9733;e.blackupperlefttriangle=9700;e.blackupperrighttriangle=9701;e.blackuppointingsmalltriangle=9652;e.blackuppointingtriangle=9650;e.blank=9251;e.blinebelow=7687;e.block=9608;e.bmonospace=65346;e.bobaimaithai=3610;e.bohiragana=12412;e.bokatakana=12508;e.bparen=9373;e.bqsquare=13251;e.braceex=63732;e.braceleft=123;e.braceleftbt=63731;e.braceleftmid=63730;e.braceleftmonospace=65371;e.braceleftsmall=65115;e.bracelefttp=63729;e.braceleftvertical=65079;e.braceright=125;e.bracerightbt=63742;e.bracerightmid=63741;e.bracerightmonospace=65373;e.bracerightsmall=65116;e.bracerighttp=63740;e.bracerightvertical=65080;e.bracketleft=91;e.bracketleftbt=63728;e.bracketleftex=63727;e.bracketleftmonospace=65339;e.bracketlefttp=63726;e.bracketright=93;e.bracketrightbt=63739;e.bracketrightex=63738;e.bracketrightmonospace=65341;e.bracketrighttp=63737;e.breve=728;e.brevebelowcmb=814;e.brevecmb=774;e.breveinvertedbelowcmb=815;e.breveinvertedcmb=785;e.breveinverteddoublecmb=865;e.bridgebelowcmb=810;e.bridgeinvertedbelowcmb=826;e.brokenbar=166;e.bstroke=384;e.bsuperior=63210;e.btopbar=387;e.buhiragana=12406;e.bukatakana=12502;e.bullet=8226;e.bulletinverse=9688;e.bulletoperator=8729;e.bullseye=9678;e.c=99;e.caarmenian=1390;e.cabengali=2458;e.cacute=263;e.cadeva=2330;e.cagujarati=2714;e.cagurmukhi=2586;e.calsquare=13192;e.candrabindubengali=2433;e.candrabinducmb=784;e.candrabindudeva=2305;e.candrabindugujarati=2689;e.capslock=8682;e.careof=8453;e.caron=711;e.caronbelowcmb=812;e.caroncmb=780;e.carriagereturn=8629;e.cbopomofo=12568;e.ccaron=269;e.ccedilla=231;e.ccedillaacute=7689;e.ccircle=9426;e.ccircumflex=265;e.ccurl=597;e.cdot=267;e.cdotaccent=267;e.cdsquare=13253;e.cedilla=184;e.cedillacmb=807;e.cent=162;e.centigrade=8451;e.centinferior=63199;e.centmonospace=65504;e.centoldstyle=63394;e.centsuperior=63200;e.chaarmenian=1401;e.chabengali=2459;e.chadeva=2331;e.chagujarati=2715;e.chagurmukhi=2587;e.chbopomofo=12564;e.cheabkhasiancyrillic=1213;e.checkmark=10003;e.checyrillic=1095;e.chedescenderabkhasiancyrillic=1215;e.chedescendercyrillic=1207;e.chedieresiscyrillic=1269;e.cheharmenian=1395;e.chekhakassiancyrillic=1228;e.cheverticalstrokecyrillic=1209;e.chi=967;e.chieuchacirclekorean=12919;e.chieuchaparenkorean=12823;e.chieuchcirclekorean=12905;e.chieuchkorean=12618;e.chieuchparenkorean=12809;e.chochangthai=3594;e.chochanthai=3592;e.chochingthai=3593;e.chochoethai=3596;e.chook=392;e.cieucacirclekorean=12918;e.cieucaparenkorean=12822;e.cieuccirclekorean=12904;e.cieuckorean=12616;e.cieucparenkorean=12808;e.cieucuparenkorean=12828;e.circle=9675;e.circlecopyrt=169;e.circlemultiply=8855;e.circleot=8857;e.circleplus=8853;e.circlepostalmark=12342;e.circlewithlefthalfblack=9680;e.circlewithrighthalfblack=9681;e.circumflex=710;e.circumflexbelowcmb=813;e.circumflexcmb=770;e.clear=8999;e.clickalveolar=450;e.clickdental=448;e.clicklateral=449;e.clickretroflex=451;e.club=9827;e.clubsuitblack=9827;e.clubsuitwhite=9831;e.cmcubedsquare=13220;e.cmonospace=65347;e.cmsquaredsquare=13216;e.coarmenian=1409;e.colon=58;e.colonmonetary=8353;e.colonmonospace=65306;e.colonsign=8353;e.colonsmall=65109;e.colontriangularhalfmod=721;e.colontriangularmod=720;e.comma=44;e.commaabovecmb=787;e.commaaboverightcmb=789;e.commaaccent=63171;e.commaarabic=1548;e.commaarmenian=1373;e.commainferior=63201;e.commamonospace=65292;e.commareversedabovecmb=788;e.commareversedmod=701;e.commasmall=65104;e.commasuperior=63202;e.commaturnedabovecmb=786;e.commaturnedmod=699;e.compass=9788;e.congruent=8773;e.contourintegral=8750;e.control=8963;e.controlACK=6;e.controlBEL=7;e.controlBS=8;e.controlCAN=24;e.controlCR=13;e.controlDC1=17;e.controlDC2=18;e.controlDC3=19;e.controlDC4=20;e.controlDEL=127;e.controlDLE=16;e.controlEM=25;e.controlENQ=5;e.controlEOT=4;e.controlESC=27;e.controlETB=23;e.controlETX=3;e.controlFF=12;e.controlFS=28;e.controlGS=29;e.controlHT=9;e.controlLF=10;e.controlNAK=21;e.controlNULL=0;e.controlRS=30;e.controlSI=15;e.controlSO=14;e.controlSOT=2;e.controlSTX=1;e.controlSUB=26;e.controlSYN=22;e.controlUS=31;e.controlVT=11;e.copyright=169;e.copyrightsans=63721;e.copyrightserif=63193;e.cornerbracketleft=12300;e.cornerbracketlefthalfwidth=65378;e.cornerbracketleftvertical=65089;e.cornerbracketright=12301;e.cornerbracketrighthalfwidth=65379;e.cornerbracketrightvertical=65090;e.corporationsquare=13183;e.cosquare=13255;e.coverkgsquare=13254;e.cparen=9374;e.cruzeiro=8354;e.cstretched=663;e.curlyand=8911;e.curlyor=8910;e.currency=164;e.cyrBreve=63185;e.cyrFlex=63186;e.cyrbreve=63188;e.cyrflex=63189;e.d=100;e.daarmenian=1380;e.dabengali=2470;e.dadarabic=1590;e.dadeva=2342;e.dadfinalarabic=65214;e.dadinitialarabic=65215;e.dadmedialarabic=65216;e.dagesh=1468;e.dageshhebrew=1468;e.dagger=8224;e.daggerdbl=8225;e.dagujarati=2726;e.dagurmukhi=2598;e.dahiragana=12384;e.dakatakana=12480;e.dalarabic=1583;e.dalet=1491;e.daletdagesh=64307;e.daletdageshhebrew=64307;e.dalethebrew=1491;e.dalfinalarabic=65194;e.dammaarabic=1615;e.dammalowarabic=1615;e.dammatanaltonearabic=1612;e.dammatanarabic=1612;e.danda=2404;e.dargahebrew=1447;e.dargalefthebrew=1447;e.dasiapneumatacyrilliccmb=1157;e.dblGrave=63187;e.dblanglebracketleft=12298;e.dblanglebracketleftvertical=65085;e.dblanglebracketright=12299;e.dblanglebracketrightvertical=65086;e.dblarchinvertedbelowcmb=811;e.dblarrowleft=8660;e.dblarrowright=8658;e.dbldanda=2405;e.dblgrave=63190;e.dblgravecmb=783;e.dblintegral=8748;e.dbllowline=8215;e.dbllowlinecmb=819;e.dbloverlinecmb=831;e.dblprimemod=698;e.dblverticalbar=8214;e.dblverticallineabovecmb=782;e.dbopomofo=12553;e.dbsquare=13256;e.dcaron=271;e.dcedilla=7697;e.dcircle=9427;e.dcircumflexbelow=7699;e.dcroat=273;e.ddabengali=2465;e.ddadeva=2337;e.ddagujarati=2721;e.ddagurmukhi=2593;e.ddalarabic=1672;e.ddalfinalarabic=64393;e.dddhadeva=2396;e.ddhabengali=2466;e.ddhadeva=2338;e.ddhagujarati=2722;e.ddhagurmukhi=2594;e.ddotaccent=7691;e.ddotbelow=7693;e.decimalseparatorarabic=1643;e.decimalseparatorpersian=1643;e.decyrillic=1076;e.degree=176;e.dehihebrew=1453;e.dehiragana=12391;e.deicoptic=1007;e.dekatakana=12487;e.deleteleft=9003;e.deleteright=8998;e.delta=948;e.deltaturned=397;e.denominatorminusonenumeratorbengali=2552;e.dezh=676;e.dhabengali=2471;e.dhadeva=2343;e.dhagujarati=2727;e.dhagurmukhi=2599;e.dhook=599;e.dialytikatonos=901;e.dialytikatonoscmb=836;e.diamond=9830;e.diamondsuitwhite=9826;e.dieresis=168;e.dieresisacute=63191;e.dieresisbelowcmb=804;e.dieresiscmb=776;e.dieresisgrave=63192;e.dieresistonos=901;e.dihiragana=12386;e.dikatakana=12482;e.dittomark=12291;e.divide=247;e.divides=8739;e.divisionslash=8725;e.djecyrillic=1106;e.dkshade=9619;e.dlinebelow=7695;e.dlsquare=13207;e.dmacron=273;e.dmonospace=65348;e.dnblock=9604;e.dochadathai=3598;e.dodekthai=3604;e.dohiragana=12393;e.dokatakana=12489;e.dollar=36;e.dollarinferior=63203;e.dollarmonospace=65284;e.dollaroldstyle=63268;e.dollarsmall=65129;e.dollarsuperior=63204;e.dong=8363;e.dorusquare=13094;e.dotaccent=729;e.dotaccentcmb=775;e.dotbelowcmb=803;e.dotbelowcomb=803;e.dotkatakana=12539;e.dotlessi=305;e.dotlessj=63166;e.dotlessjstrokehook=644;e.dotmath=8901;e.dottedcircle=9676;e.doubleyodpatah=64287;e.doubleyodpatahhebrew=64287;e.downtackbelowcmb=798;e.downtackmod=725;e.dparen=9375;e.dsuperior=63211;e.dtail=598;e.dtopbar=396;e.duhiragana=12389;e.dukatakana=12485;e.dz=499;e.dzaltone=675;e.dzcaron=454;e.dzcurl=677;e.dzeabkhasiancyrillic=1249;e.dzecyrillic=1109;e.dzhecyrillic=1119;e.e=101;e.eacute=233;e.earth=9793;e.ebengali=2447;e.ebopomofo=12572;e.ebreve=277;e.ecandradeva=2317;e.ecandragujarati=2701;e.ecandravowelsigndeva=2373;e.ecandravowelsigngujarati=2757;e.ecaron=283;e.ecedillabreve=7709;e.echarmenian=1381;e.echyiwnarmenian=1415;e.ecircle=9428;e.ecircumflex=234;e.ecircumflexacute=7871;e.ecircumflexbelow=7705;e.ecircumflexdotbelow=7879;e.ecircumflexgrave=7873;e.ecircumflexhookabove=7875;e.ecircumflextilde=7877;e.ecyrillic=1108;e.edblgrave=517;e.edeva=2319;e.edieresis=235;e.edot=279;e.edotaccent=279;e.edotbelow=7865;e.eegurmukhi=2575;e.eematragurmukhi=2631;e.efcyrillic=1092;e.egrave=232;e.egujarati=2703;e.eharmenian=1383;e.ehbopomofo=12573;e.ehiragana=12360;e.ehookabove=7867;e.eibopomofo=12575;e.eight=56;e.eightarabic=1640;e.eightbengali=2542;e.eightcircle=9319;e.eightcircleinversesansserif=10129;e.eightdeva=2414;e.eighteencircle=9329;e.eighteenparen=9349;e.eighteenperiod=9369;e.eightgujarati=2798;e.eightgurmukhi=2670;e.eighthackarabic=1640;e.eighthangzhou=12328;e.eighthnotebeamed=9835;e.eightideographicparen=12839;e.eightinferior=8328;e.eightmonospace=65304;e.eightoldstyle=63288;e.eightparen=9339;e.eightperiod=9359;e.eightpersian=1784;e.eightroman=8567;e.eightsuperior=8312;e.eightthai=3672;e.einvertedbreve=519;e.eiotifiedcyrillic=1125;e.ekatakana=12456;e.ekatakanahalfwidth=65396;e.ekonkargurmukhi=2676;e.ekorean=12628;e.elcyrillic=1083;e.element=8712;e.elevencircle=9322;e.elevenparen=9342;e.elevenperiod=9362;e.elevenroman=8570;e.ellipsis=8230;e.ellipsisvertical=8942;e.emacron=275;e.emacronacute=7703;e.emacrongrave=7701;e.emcyrillic=1084;e.emdash=8212;e.emdashvertical=65073;e.emonospace=65349;e.emphasismarkarmenian=1371;e.emptyset=8709;e.enbopomofo=12579;e.encyrillic=1085;e.endash=8211;e.endashvertical=65074;e.endescendercyrillic=1187;e.eng=331;e.engbopomofo=12581;e.enghecyrillic=1189;e.enhookcyrillic=1224;e.enspace=8194;e.eogonek=281;e.eokorean=12627;e.eopen=603;e.eopenclosed=666;e.eopenreversed=604;e.eopenreversedclosed=606;e.eopenreversedhook=605;e.eparen=9376;e.epsilon=949;e.epsilontonos=941;e.equal=61;e.equalmonospace=65309;e.equalsmall=65126;e.equalsuperior=8316;e.equivalence=8801;e.erbopomofo=12582;e.ercyrillic=1088;e.ereversed=600;e.ereversedcyrillic=1101;e.escyrillic=1089;e.esdescendercyrillic=1195;e.esh=643;e.eshcurl=646;e.eshortdeva=2318;e.eshortvowelsigndeva=2374;e.eshreversedloop=426;e.eshsquatreversed=645;e.esmallhiragana=12359;e.esmallkatakana=12455;e.esmallkatakanahalfwidth=65386;e.estimated=8494;e.esuperior=63212;e.eta=951;e.etarmenian=1384;e.etatonos=942;e.eth=240;e.etilde=7869;e.etildebelow=7707;e.etnahtafoukhhebrew=1425;e.etnahtafoukhlefthebrew=1425;e.etnahtahebrew=1425;e.etnahtalefthebrew=1425;e.eturned=477;e.eukorean=12641;e.euro=8364;e.evowelsignbengali=2503;e.evowelsigndeva=2375;e.evowelsigngujarati=2759;e.exclam=33;e.exclamarmenian=1372;e.exclamdbl=8252;e.exclamdown=161;e.exclamdownsmall=63393;e.exclammonospace=65281;e.exclamsmall=63265;e.existential=8707;e.ezh=658;e.ezhcaron=495;e.ezhcurl=659;e.ezhreversed=441;e.ezhtail=442;e.f=102;e.fadeva=2398;e.fagurmukhi=2654;e.fahrenheit=8457;e.fathaarabic=1614;e.fathalowarabic=1614;e.fathatanarabic=1611;e.fbopomofo=12552;e.fcircle=9429;e.fdotaccent=7711;e.feharabic=1601;e.feharmenian=1414;e.fehfinalarabic=65234;e.fehinitialarabic=65235;e.fehmedialarabic=65236;e.feicoptic=997;e.female=9792;e.ff=64256;e.ffi=64259;e.ffl=64260;e.fi=64257;e.fifteencircle=9326;e.fifteenparen=9346;e.fifteenperiod=9366;e.figuredash=8210;e.filledbox=9632;e.filledrect=9644;e.finalkaf=1498;e.finalkafdagesh=64314;e.finalkafdageshhebrew=64314;e.finalkafhebrew=1498;e.finalmem=1501;e.finalmemhebrew=1501;e.finalnun=1503;e.finalnunhebrew=1503;e.finalpe=1507;e.finalpehebrew=1507;e.finaltsadi=1509;e.finaltsadihebrew=1509;e.firsttonechinese=713;e.fisheye=9673;e.fitacyrillic=1139;e.five=53;e.fivearabic=1637;e.fivebengali=2539;e.fivecircle=9316;e.fivecircleinversesansserif=10126;e.fivedeva=2411;e.fiveeighths=8541;e.fivegujarati=2795;e.fivegurmukhi=2667;e.fivehackarabic=1637;e.fivehangzhou=12325;e.fiveideographicparen=12836;e.fiveinferior=8325;e.fivemonospace=65301;e.fiveoldstyle=63285;e.fiveparen=9336;e.fiveperiod=9356;e.fivepersian=1781;e.fiveroman=8564;e.fivesuperior=8309;e.fivethai=3669;e.fl=64258;e.florin=402;e.fmonospace=65350;e.fmsquare=13209;e.fofanthai=3615;e.fofathai=3613;e.fongmanthai=3663;e.forall=8704;e.four=52;e.fourarabic=1636;e.fourbengali=2538;e.fourcircle=9315;e.fourcircleinversesansserif=10125;e.fourdeva=2410;e.fourgujarati=2794;e.fourgurmukhi=2666;e.fourhackarabic=1636;e.fourhangzhou=12324;e.fourideographicparen=12835;e.fourinferior=8324;e.fourmonospace=65300;e.fournumeratorbengali=2551;e.fouroldstyle=63284;e.fourparen=9335;e.fourperiod=9355;e.fourpersian=1780;e.fourroman=8563;e.foursuperior=8308;e.fourteencircle=9325;e.fourteenparen=9345;e.fourteenperiod=9365;e.fourthai=3668;e.fourthtonechinese=715;e.fparen=9377;e.fraction=8260;e.franc=8355;e.g=103;e.gabengali=2455;e.gacute=501;e.gadeva=2327;e.gafarabic=1711;e.gaffinalarabic=64403;e.gafinitialarabic=64404;e.gafmedialarabic=64405;e.gagujarati=2711;e.gagurmukhi=2583;e.gahiragana=12364;e.gakatakana=12460;e.gamma=947;e.gammalatinsmall=611;e.gammasuperior=736;e.gangiacoptic=1003;e.gbopomofo=12557;e.gbreve=287;e.gcaron=487;e.gcedilla=291;e.gcircle=9430;e.gcircumflex=285;e.gcommaaccent=291;e.gdot=289;e.gdotaccent=289;e.gecyrillic=1075;e.gehiragana=12370;e.gekatakana=12466;e.geometricallyequal=8785;e.gereshaccenthebrew=1436;e.gereshhebrew=1523;e.gereshmuqdamhebrew=1437;e.germandbls=223;e.gershayimaccenthebrew=1438;e.gershayimhebrew=1524;e.getamark=12307;e.ghabengali=2456;e.ghadarmenian=1394;e.ghadeva=2328;e.ghagujarati=2712;e.ghagurmukhi=2584;e.ghainarabic=1594;e.ghainfinalarabic=65230;e.ghaininitialarabic=65231;e.ghainmedialarabic=65232;e.ghemiddlehookcyrillic=1173;e.ghestrokecyrillic=1171;e.gheupturncyrillic=1169;e.ghhadeva=2394;e.ghhagurmukhi=2650;e.ghook=608;e.ghzsquare=13203;e.gihiragana=12366;e.gikatakana=12462;e.gimarmenian=1379;e.gimel=1490;e.gimeldagesh=64306;e.gimeldageshhebrew=64306;e.gimelhebrew=1490;e.gjecyrillic=1107;e.glottalinvertedstroke=446;e.glottalstop=660;e.glottalstopinverted=662;e.glottalstopmod=704;e.glottalstopreversed=661;e.glottalstopreversedmod=705;e.glottalstopreversedsuperior=740;e.glottalstopstroke=673;e.glottalstopstrokereversed=674;e.gmacron=7713;e.gmonospace=65351;e.gohiragana=12372;e.gokatakana=12468;e.gparen=9378;e.gpasquare=13228;e.gradient=8711;e.grave=96;e.gravebelowcmb=790;e.gravecmb=768;e.gravecomb=768;e.gravedeva=2387;e.gravelowmod=718;e.gravemonospace=65344;e.gravetonecmb=832;e.greater=62;e.greaterequal=8805;e.greaterequalorless=8923;e.greatermonospace=65310;e.greaterorequivalent=8819;e.greaterorless=8823;e.greateroverequal=8807;e.greatersmall=65125;e.gscript=609;e.gstroke=485;e.guhiragana=12368;e.guillemotleft=171;e.guillemotright=187;e.guilsinglleft=8249;e.guilsinglright=8250;e.gukatakana=12464;e.guramusquare=13080;e.gysquare=13257;e.h=104;e.haabkhasiancyrillic=1193;e.haaltonearabic=1729;e.habengali=2489;e.hadescendercyrillic=1203;e.hadeva=2361;e.hagujarati=2745;e.hagurmukhi=2617;e.haharabic=1581;e.hahfinalarabic=65186;e.hahinitialarabic=65187;e.hahiragana=12399;e.hahmedialarabic=65188;e.haitusquare=13098;e.hakatakana=12495;e.hakatakanahalfwidth=65418;e.halantgurmukhi=2637;e.hamzaarabic=1569;e.hamzalowarabic=1569;e.hangulfiller=12644;e.hardsigncyrillic=1098;e.harpoonleftbarbup=8636;e.harpoonrightbarbup=8640;e.hasquare=13258;e.hatafpatah=1458;e.hatafpatah16=1458;e.hatafpatah23=1458;e.hatafpatah2f=1458;e.hatafpatahhebrew=1458;e.hatafpatahnarrowhebrew=1458;e.hatafpatahquarterhebrew=1458;e.hatafpatahwidehebrew=1458;e.hatafqamats=1459;e.hatafqamats1b=1459;e.hatafqamats28=1459;e.hatafqamats34=1459;e.hatafqamatshebrew=1459;e.hatafqamatsnarrowhebrew=1459;e.hatafqamatsquarterhebrew=1459;e.hatafqamatswidehebrew=1459;e.hatafsegol=1457;e.hatafsegol17=1457;e.hatafsegol24=1457;e.hatafsegol30=1457;e.hatafsegolhebrew=1457;e.hatafsegolnarrowhebrew=1457;e.hatafsegolquarterhebrew=1457;e.hatafsegolwidehebrew=1457;e.hbar=295;e.hbopomofo=12559;e.hbrevebelow=7723;e.hcedilla=7721;e.hcircle=9431;e.hcircumflex=293;e.hdieresis=7719;e.hdotaccent=7715;e.hdotbelow=7717;e.he=1492;e.heart=9829;e.heartsuitblack=9829;e.heartsuitwhite=9825;e.hedagesh=64308;e.hedageshhebrew=64308;e.hehaltonearabic=1729;e.heharabic=1607;e.hehebrew=1492;e.hehfinalaltonearabic=64423;e.hehfinalalttwoarabic=65258;e.hehfinalarabic=65258;e.hehhamzaabovefinalarabic=64421;e.hehhamzaaboveisolatedarabic=64420;e.hehinitialaltonearabic=64424;e.hehinitialarabic=65259;e.hehiragana=12408;e.hehmedialaltonearabic=64425;e.hehmedialarabic=65260;e.heiseierasquare=13179;e.hekatakana=12504;e.hekatakanahalfwidth=65421;e.hekutaarusquare=13110;e.henghook=615;e.herutusquare=13113;e.het=1495;e.hethebrew=1495;e.hhook=614;e.hhooksuperior=689;e.hieuhacirclekorean=12923;e.hieuhaparenkorean=12827;e.hieuhcirclekorean=12909;e.hieuhkorean=12622;e.hieuhparenkorean=12813;e.hihiragana=12402;e.hikatakana=12498;e.hikatakanahalfwidth=65419;e.hiriq=1460;e.hiriq14=1460;e.hiriq21=1460;e.hiriq2d=1460;e.hiriqhebrew=1460;e.hiriqnarrowhebrew=1460;e.hiriqquarterhebrew=1460;e.hiriqwidehebrew=1460;e.hlinebelow=7830;e.hmonospace=65352;e.hoarmenian=1392;e.hohipthai=3627;e.hohiragana=12411;e.hokatakana=12507;e.hokatakanahalfwidth=65422;e.holam=1465;e.holam19=1465;e.holam26=1465;e.holam32=1465;e.holamhebrew=1465;e.holamnarrowhebrew=1465;e.holamquarterhebrew=1465;e.holamwidehebrew=1465;e.honokhukthai=3630;e.hookabovecomb=777;e.hookcmb=777;e.hookpalatalizedbelowcmb=801;e.hookretroflexbelowcmb=802;e.hoonsquare=13122;e.horicoptic=1001;e.horizontalbar=8213;e.horncmb=795;e.hotsprings=9832;e.house=8962;e.hparen=9379;e.hsuperior=688;e.hturned=613;e.huhiragana=12405;e.huiitosquare=13107;e.hukatakana=12501;e.hukatakanahalfwidth=65420;e.hungarumlaut=733;e.hungarumlautcmb=779;e.hv=405;e.hyphen=45;e.hypheninferior=63205;e.hyphenmonospace=65293;e.hyphensmall=65123;e.hyphensuperior=63206;e.hyphentwo=8208;e.i=105;e.iacute=237;e.iacyrillic=1103;e.ibengali=2439;e.ibopomofo=12583;e.ibreve=301;e.icaron=464;e.icircle=9432;e.icircumflex=238;e.icyrillic=1110;e.idblgrave=521;e.ideographearthcircle=12943;e.ideographfirecircle=12939;e.ideographicallianceparen=12863;e.ideographiccallparen=12858;e.ideographiccentrecircle=12965;e.ideographicclose=12294;e.ideographiccomma=12289;e.ideographiccommaleft=65380;e.ideographiccongratulationparen=12855;e.ideographiccorrectcircle=12963;e.ideographicearthparen=12847;e.ideographicenterpriseparen=12861;e.ideographicexcellentcircle=12957;e.ideographicfestivalparen=12864;e.ideographicfinancialcircle=12950;e.ideographicfinancialparen=12854;e.ideographicfireparen=12843;e.ideographichaveparen=12850;e.ideographichighcircle=12964;e.ideographiciterationmark=12293;e.ideographiclaborcircle=12952;e.ideographiclaborparen=12856;e.ideographicleftcircle=12967;e.ideographiclowcircle=12966;e.ideographicmedicinecircle=12969;e.ideographicmetalparen=12846;e.ideographicmoonparen=12842;e.ideographicnameparen=12852;e.ideographicperiod=12290;e.ideographicprintcircle=12958;e.ideographicreachparen=12867;e.ideographicrepresentparen=12857;e.ideographicresourceparen=12862;e.ideographicrightcircle=12968;e.ideographicsecretcircle=12953;e.ideographicselfparen=12866;e.ideographicsocietyparen=12851;e.ideographicspace=12288;e.ideographicspecialparen=12853;e.ideographicstockparen=12849;e.ideographicstudyparen=12859;e.ideographicsunparen=12848;e.ideographicsuperviseparen=12860;e.ideographicwaterparen=12844;e.ideographicwoodparen=12845;e.ideographiczero=12295;e.ideographmetalcircle=12942;e.ideographmooncircle=12938;e.ideographnamecircle=12948;e.ideographsuncircle=12944;e.ideographwatercircle=12940;e.ideographwoodcircle=12941;e.ideva=2311;e.idieresis=239;e.idieresisacute=7727;e.idieresiscyrillic=1253;e.idotbelow=7883;e.iebrevecyrillic=1239;e.iecyrillic=1077;e.ieungacirclekorean=12917;e.ieungaparenkorean=12821;e.ieungcirclekorean=12903;e.ieungkorean=12615;e.ieungparenkorean=12807;e.igrave=236;e.igujarati=2695;e.igurmukhi=2567;e.ihiragana=12356;e.ihookabove=7881;e.iibengali=2440;e.iicyrillic=1080;e.iideva=2312;e.iigujarati=2696;e.iigurmukhi=2568;e.iimatragurmukhi=2624;e.iinvertedbreve=523;e.iishortcyrillic=1081;e.iivowelsignbengali=2496;e.iivowelsigndeva=2368;e.iivowelsigngujarati=2752;e.ij=307;e.ikatakana=12452;e.ikatakanahalfwidth=65394;e.ikorean=12643;e.ilde=732;e.iluyhebrew=1452;e.imacron=299;e.imacroncyrillic=1251;e.imageorapproximatelyequal=8787;e.imatragurmukhi=2623;e.imonospace=65353;e.increment=8710;e.infinity=8734;e.iniarmenian=1387;e.integral=8747;e.integralbottom=8993;e.integralbt=8993;e.integralex=63733;e.integraltop=8992;e.integraltp=8992;e.intersection=8745;e.intisquare=13061;e.invbullet=9688;e.invcircle=9689;e.invsmileface=9787;e.iocyrillic=1105;e.iogonek=303;e.iota=953;e.iotadieresis=970;e.iotadieresistonos=912;e.iotalatin=617;e.iotatonos=943;e.iparen=9380;e.irigurmukhi=2674;e.ismallhiragana=12355;e.ismallkatakana=12451;e.ismallkatakanahalfwidth=65384;e.issharbengali=2554;e.istroke=616;e.isuperior=63213;e.iterationhiragana=12445;e.iterationkatakana=12541;e.itilde=297;e.itildebelow=7725;e.iubopomofo=12585;e.iucyrillic=1102;e.ivowelsignbengali=2495;e.ivowelsigndeva=2367;e.ivowelsigngujarati=2751;e.izhitsacyrillic=1141;e.izhitsadblgravecyrillic=1143;e.j=106;e.jaarmenian=1393;e.jabengali=2460;e.jadeva=2332;e.jagujarati=2716;e.jagurmukhi=2588;e.jbopomofo=12560;e.jcaron=496;e.jcircle=9433;e.jcircumflex=309;e.jcrossedtail=669;e.jdotlessstroke=607;e.jecyrillic=1112;e.jeemarabic=1580;e.jeemfinalarabic=65182;e.jeeminitialarabic=65183;e.jeemmedialarabic=65184;e.jeharabic=1688;e.jehfinalarabic=64395;e.jhabengali=2461;e.jhadeva=2333;e.jhagujarati=2717;e.jhagurmukhi=2589;e.jheharmenian=1403;e.jis=12292;e.jmonospace=65354;e.jparen=9381;e.jsuperior=690;e.k=107;e.kabashkircyrillic=1185;e.kabengali=2453;e.kacute=7729;e.kacyrillic=1082;e.kadescendercyrillic=1179;e.kadeva=2325;e.kaf=1499;e.kafarabic=1603;e.kafdagesh=64315;e.kafdageshhebrew=64315;e.kaffinalarabic=65242;e.kafhebrew=1499;e.kafinitialarabic=65243;e.kafmedialarabic=65244;e.kafrafehebrew=64333;e.kagujarati=2709;e.kagurmukhi=2581;e.kahiragana=12363;e.kahookcyrillic=1220;e.kakatakana=12459;e.kakatakanahalfwidth=65398;e.kappa=954;e.kappasymbolgreek=1008;e.kapyeounmieumkorean=12657;e.kapyeounphieuphkorean=12676;e.kapyeounpieupkorean=12664;e.kapyeounssangpieupkorean=12665;e.karoriisquare=13069;e.kashidaautoarabic=1600;e.kashidaautonosidebearingarabic=1600;e.kasmallkatakana=12533;e.kasquare=13188;e.kasraarabic=1616;e.kasratanarabic=1613;e.kastrokecyrillic=1183;e.katahiraprolongmarkhalfwidth=65392;e.kaverticalstrokecyrillic=1181;e.kbopomofo=12558;e.kcalsquare=13193;e.kcaron=489;e.kcedilla=311;e.kcircle=9434;e.kcommaaccent=311;e.kdotbelow=7731;e.keharmenian=1412;e.kehiragana=12369;e.kekatakana=12465;e.kekatakanahalfwidth=65401;e.kenarmenian=1391;e.kesmallkatakana=12534;e.kgreenlandic=312;e.khabengali=2454;e.khacyrillic=1093;e.khadeva=2326;e.khagujarati=2710;e.khagurmukhi=2582;e.khaharabic=1582;e.khahfinalarabic=65190;e.khahinitialarabic=65191;e.khahmedialarabic=65192;e.kheicoptic=999;e.khhadeva=2393;e.khhagurmukhi=2649;e.khieukhacirclekorean=12920;e.khieukhaparenkorean=12824;e.khieukhcirclekorean=12906;e.khieukhkorean=12619;e.khieukhparenkorean=12810;e.khokhaithai=3586;e.khokhonthai=3589;e.khokhuatthai=3587;e.khokhwaithai=3588;e.khomutthai=3675;e.khook=409;e.khorakhangthai=3590;e.khzsquare=13201;e.kihiragana=12365;e.kikatakana=12461;e.kikatakanahalfwidth=65399;e.kiroguramusquare=13077;e.kiromeetorusquare=13078;e.kirosquare=13076;e.kiyeokacirclekorean=12910;e.kiyeokaparenkorean=12814;e.kiyeokcirclekorean=12896;e.kiyeokkorean=12593;e.kiyeokparenkorean=12800;e.kiyeoksioskorean=12595;e.kjecyrillic=1116;e.klinebelow=7733;e.klsquare=13208;e.kmcubedsquare=13222;e.kmonospace=65355;e.kmsquaredsquare=13218;e.kohiragana=12371;e.kohmsquare=13248;e.kokaithai=3585;e.kokatakana=12467;e.kokatakanahalfwidth=65402;e.kooposquare=13086;e.koppacyrillic=1153;e.koreanstandardsymbol=12927;e.koroniscmb=835;e.kparen=9382;e.kpasquare=13226;e.ksicyrillic=1135;e.ktsquare=13263;e.kturned=670;e.kuhiragana=12367;e.kukatakana=12463;e.kukatakanahalfwidth=65400;e.kvsquare=13240;e.kwsquare=13246;e.l=108;e.labengali=2482;e.lacute=314;e.ladeva=2354;e.lagujarati=2738;e.lagurmukhi=2610;e.lakkhangyaothai=3653;e.lamaleffinalarabic=65276;e.lamalefhamzaabovefinalarabic=65272;e.lamalefhamzaaboveisolatedarabic=65271;e.lamalefhamzabelowfinalarabic=65274;e.lamalefhamzabelowisolatedarabic=65273;e.lamalefisolatedarabic=65275;e.lamalefmaddaabovefinalarabic=65270;e.lamalefmaddaaboveisolatedarabic=65269;e.lamarabic=1604;e.lambda=955;e.lambdastroke=411;e.lamed=1500;e.lameddagesh=64316;e.lameddageshhebrew=64316;e.lamedhebrew=1500;e.lamfinalarabic=65246;e.lamhahinitialarabic=64714;e.laminitialarabic=65247;e.lamjeeminitialarabic=64713;e.lamkhahinitialarabic=64715;e.lamlamhehisolatedarabic=65010;e.lammedialarabic=65248;e.lammeemhahinitialarabic=64904;e.lammeeminitialarabic=64716;e.largecircle=9711;e.lbar=410;e.lbelt=620;e.lbopomofo=12556;e.lcaron=318;e.lcedilla=316;e.lcircle=9435;e.lcircumflexbelow=7741;e.lcommaaccent=316;e.ldot=320;e.ldotaccent=320;e.ldotbelow=7735;e.ldotbelowmacron=7737;e.leftangleabovecmb=794;e.lefttackbelowcmb=792;e.less=60;e.lessequal=8804;e.lessequalorgreater=8922;e.lessmonospace=65308;e.lessorequivalent=8818;e.lessorgreater=8822;e.lessoverequal=8806;e.lesssmall=65124;e.lezh=622;e.lfblock=9612;e.lhookretroflex=621;e.lira=8356;e.liwnarmenian=1388;e.lj=457;e.ljecyrillic=1113;e.ll=63168;e.lladeva=2355;e.llagujarati=2739;e.llinebelow=7739;e.llladeva=2356;e.llvocalicbengali=2529;e.llvocalicdeva=2401;e.llvocalicvowelsignbengali=2531;e.llvocalicvowelsigndeva=2403;e.lmiddletilde=619;e.lmonospace=65356;e.lmsquare=13264;e.lochulathai=3628;e.logicaland=8743;e.logicalnot=172;e.logicalnotreversed=8976;e.logicalor=8744;e.lolingthai=3621;e.longs=383;e.lowlinecenterline=65102;e.lowlinecmb=818;e.lowlinedashed=65101;e.lozenge=9674;e.lparen=9383;e.lslash=322;e.lsquare=8467;e.lsuperior=63214;e.ltshade=9617;e.luthai=3622;e.lvocalicbengali=2444;e.lvocalicdeva=2316;e.lvocalicvowelsignbengali=2530;e.lvocalicvowelsigndeva=2402;e.lxsquare=13267;e.m=109;e.mabengali=2478;e.macron=175;e.macronbelowcmb=817;e.macroncmb=772;e.macronlowmod=717;e.macronmonospace=65507;e.macute=7743;e.madeva=2350;e.magujarati=2734;e.magurmukhi=2606;e.mahapakhhebrew=1444;e.mahapakhlefthebrew=1444;e.mahiragana=12414;e.maichattawalowleftthai=63637;e.maichattawalowrightthai=63636;e.maichattawathai=3659;e.maichattawaupperleftthai=63635;e.maieklowleftthai=63628;e.maieklowrightthai=63627;e.maiekthai=3656;e.maiekupperleftthai=63626;e.maihanakatleftthai=63620;e.maihanakatthai=3633;e.maitaikhuleftthai=63625;e.maitaikhuthai=3655;e.maitholowleftthai=63631;e.maitholowrightthai=63630;e.maithothai=3657;e.maithoupperleftthai=63629;e.maitrilowleftthai=63634;e.maitrilowrightthai=63633;e.maitrithai=3658;e.maitriupperleftthai=63632;e.maiyamokthai=3654;e.makatakana=12510;e.makatakanahalfwidth=65423;e.male=9794;e.mansyonsquare=13127;e.maqafhebrew=1470;e.mars=9794;e.masoracirclehebrew=1455;e.masquare=13187;e.mbopomofo=12551;e.mbsquare=13268;e.mcircle=9436;e.mcubedsquare=13221;e.mdotaccent=7745;e.mdotbelow=7747;e.meemarabic=1605;e.meemfinalarabic=65250;e.meeminitialarabic=65251;e.meemmedialarabic=65252;e.meemmeeminitialarabic=64721;e.meemmeemisolatedarabic=64584;e.meetorusquare=13133;e.mehiragana=12417;e.meizierasquare=13182;e.mekatakana=12513;e.mekatakanahalfwidth=65426;e.mem=1502;e.memdagesh=64318;e.memdageshhebrew=64318;e.memhebrew=1502;e.menarmenian=1396;e.merkhahebrew=1445;e.merkhakefulahebrew=1446;e.merkhakefulalefthebrew=1446;e.merkhalefthebrew=1445;e.mhook=625;e.mhzsquare=13202;e.middledotkatakanahalfwidth=65381;e.middot=183;e.mieumacirclekorean=12914;e.mieumaparenkorean=12818;e.mieumcirclekorean=12900;e.mieumkorean=12609;e.mieumpansioskorean=12656;e.mieumparenkorean=12804;e.mieumpieupkorean=12654;e.mieumsioskorean=12655;e.mihiragana=12415;e.mikatakana=12511;e.mikatakanahalfwidth=65424;e.minus=8722;e.minusbelowcmb=800;e.minuscircle=8854;e.minusmod=727;e.minusplus=8723;e.minute=8242;e.miribaarusquare=13130;e.mirisquare=13129
+;e.mlonglegturned=624;e.mlsquare=13206;e.mmcubedsquare=13219;e.mmonospace=65357;e.mmsquaredsquare=13215;e.mohiragana=12418;e.mohmsquare=13249;e.mokatakana=12514;e.mokatakanahalfwidth=65427;e.molsquare=13270;e.momathai=3617;e.moverssquare=13223;e.moverssquaredsquare=13224;e.mparen=9384;e.mpasquare=13227;e.mssquare=13235;e.msuperior=63215;e.mturned=623;e.mu=181;e.mu1=181;e.muasquare=13186;e.muchgreater=8811;e.muchless=8810;e.mufsquare=13196;e.mugreek=956;e.mugsquare=13197;e.muhiragana=12416;e.mukatakana=12512;e.mukatakanahalfwidth=65425;e.mulsquare=13205;e.multiply=215;e.mumsquare=13211;e.munahhebrew=1443;e.munahlefthebrew=1443;e.musicalnote=9834;e.musicalnotedbl=9835;e.musicflatsign=9837;e.musicsharpsign=9839;e.mussquare=13234;e.muvsquare=13238;e.muwsquare=13244;e.mvmegasquare=13241;e.mvsquare=13239;e.mwmegasquare=13247;e.mwsquare=13245;e.n=110;e.nabengali=2472;e.nabla=8711;e.nacute=324;e.nadeva=2344;e.nagujarati=2728;e.nagurmukhi=2600;e.nahiragana=12394;e.nakatakana=12490;e.nakatakanahalfwidth=65413;e.napostrophe=329;e.nasquare=13185;e.nbopomofo=12555;e.nbspace=160;e.ncaron=328;e.ncedilla=326;e.ncircle=9437;e.ncircumflexbelow=7755;e.ncommaaccent=326;e.ndotaccent=7749;e.ndotbelow=7751;e.nehiragana=12397;e.nekatakana=12493;e.nekatakanahalfwidth=65416;e.newsheqelsign=8362;e.nfsquare=13195;e.ngabengali=2457;e.ngadeva=2329;e.ngagujarati=2713;e.ngagurmukhi=2585;e.ngonguthai=3591;e.nhiragana=12435;e.nhookleft=626;e.nhookretroflex=627;e.nieunacirclekorean=12911;e.nieunaparenkorean=12815;e.nieuncieuckorean=12597;e.nieuncirclekorean=12897;e.nieunhieuhkorean=12598;e.nieunkorean=12596;e.nieunpansioskorean=12648;e.nieunparenkorean=12801;e.nieunsioskorean=12647;e.nieuntikeutkorean=12646;e.nihiragana=12395;e.nikatakana=12491;e.nikatakanahalfwidth=65414;e.nikhahitleftthai=63641;e.nikhahitthai=3661;e.nine=57;e.ninearabic=1641;e.ninebengali=2543;e.ninecircle=9320;e.ninecircleinversesansserif=10130;e.ninedeva=2415;e.ninegujarati=2799;e.ninegurmukhi=2671;e.ninehackarabic=1641;e.ninehangzhou=12329;e.nineideographicparen=12840;e.nineinferior=8329;e.ninemonospace=65305;e.nineoldstyle=63289;e.nineparen=9340;e.nineperiod=9360;e.ninepersian=1785;e.nineroman=8568;e.ninesuperior=8313;e.nineteencircle=9330;e.nineteenparen=9350;e.nineteenperiod=9370;e.ninethai=3673;e.nj=460;e.njecyrillic=1114;e.nkatakana=12531;e.nkatakanahalfwidth=65437;e.nlegrightlong=414;e.nlinebelow=7753;e.nmonospace=65358;e.nmsquare=13210;e.nnabengali=2467;e.nnadeva=2339;e.nnagujarati=2723;e.nnagurmukhi=2595;e.nnnadeva=2345;e.nohiragana=12398;e.nokatakana=12494;e.nokatakanahalfwidth=65417;e.nonbreakingspace=160;e.nonenthai=3603;e.nonuthai=3609;e.noonarabic=1606;e.noonfinalarabic=65254;e.noonghunnaarabic=1722;e.noonghunnafinalarabic=64415;e.nooninitialarabic=65255;e.noonjeeminitialarabic=64722;e.noonjeemisolatedarabic=64587;e.noonmedialarabic=65256;e.noonmeeminitialarabic=64725;e.noonmeemisolatedarabic=64590;e.noonnoonfinalarabic=64653;e.notcontains=8716;e.notelement=8713;e.notelementof=8713;e.notequal=8800;e.notgreater=8815;e.notgreaternorequal=8817;e.notgreaternorless=8825;e.notidentical=8802;e.notless=8814;e.notlessnorequal=8816;e.notparallel=8742;e.notprecedes=8832;e.notsubset=8836;e.notsucceeds=8833;e.notsuperset=8837;e.nowarmenian=1398;e.nparen=9385;e.nssquare=13233;e.nsuperior=8319;e.ntilde=241;e.nu=957;e.nuhiragana=12396;e.nukatakana=12492;e.nukatakanahalfwidth=65415;e.nuktabengali=2492;e.nuktadeva=2364;e.nuktagujarati=2748;e.nuktagurmukhi=2620;e.numbersign=35;e.numbersignmonospace=65283;e.numbersignsmall=65119;e.numeralsigngreek=884;e.numeralsignlowergreek=885;e.numero=8470;e.nun=1504;e.nundagesh=64320;e.nundageshhebrew=64320;e.nunhebrew=1504;e.nvsquare=13237;e.nwsquare=13243;e.nyabengali=2462;e.nyadeva=2334;e.nyagujarati=2718;e.nyagurmukhi=2590;e.o=111;e.oacute=243;e.oangthai=3629;e.obarred=629;e.obarredcyrillic=1257;e.obarreddieresiscyrillic=1259;e.obengali=2451;e.obopomofo=12571;e.obreve=335;e.ocandradeva=2321;e.ocandragujarati=2705;e.ocandravowelsigndeva=2377;e.ocandravowelsigngujarati=2761;e.ocaron=466;e.ocircle=9438;e.ocircumflex=244;e.ocircumflexacute=7889;e.ocircumflexdotbelow=7897;e.ocircumflexgrave=7891;e.ocircumflexhookabove=7893;e.ocircumflextilde=7895;e.ocyrillic=1086;e.odblacute=337;e.odblgrave=525;e.odeva=2323;e.odieresis=246;e.odieresiscyrillic=1255;e.odotbelow=7885;e.oe=339;e.oekorean=12634;e.ogonek=731;e.ogonekcmb=808;e.ograve=242;e.ogujarati=2707;e.oharmenian=1413;e.ohiragana=12362;e.ohookabove=7887;e.ohorn=417;e.ohornacute=7899;e.ohorndotbelow=7907;e.ohorngrave=7901;e.ohornhookabove=7903;e.ohorntilde=7905;e.ohungarumlaut=337;e.oi=419;e.oinvertedbreve=527;e.okatakana=12458;e.okatakanahalfwidth=65397;e.okorean=12631;e.olehebrew=1451;e.omacron=333;e.omacronacute=7763;e.omacrongrave=7761;e.omdeva=2384;e.omega=969;e.omega1=982;e.omegacyrillic=1121;e.omegalatinclosed=631;e.omegaroundcyrillic=1147;e.omegatitlocyrillic=1149;e.omegatonos=974;e.omgujarati=2768;e.omicron=959;e.omicrontonos=972;e.omonospace=65359;e.one=49;e.onearabic=1633;e.onebengali=2535;e.onecircle=9312;e.onecircleinversesansserif=10122;e.onedeva=2407;e.onedotenleader=8228;e.oneeighth=8539;e.onefitted=63196;e.onegujarati=2791;e.onegurmukhi=2663;e.onehackarabic=1633;e.onehalf=189;e.onehangzhou=12321;e.oneideographicparen=12832;e.oneinferior=8321;e.onemonospace=65297;e.onenumeratorbengali=2548;e.oneoldstyle=63281;e.oneparen=9332;e.oneperiod=9352;e.onepersian=1777;e.onequarter=188;e.oneroman=8560;e.onesuperior=185;e.onethai=3665;e.onethird=8531;e.oogonek=491;e.oogonekmacron=493;e.oogurmukhi=2579;e.oomatragurmukhi=2635;e.oopen=596;e.oparen=9386;e.openbullet=9702;e.option=8997;e.ordfeminine=170;e.ordmasculine=186;e.orthogonal=8735;e.oshortdeva=2322;e.oshortvowelsigndeva=2378;e.oslash=248;e.oslashacute=511;e.osmallhiragana=12361;e.osmallkatakana=12457;e.osmallkatakanahalfwidth=65387;e.ostrokeacute=511;e.osuperior=63216;e.otcyrillic=1151;e.otilde=245;e.otildeacute=7757;e.otildedieresis=7759;e.oubopomofo=12577;e.overline=8254;e.overlinecenterline=65098;e.overlinecmb=773;e.overlinedashed=65097;e.overlinedblwavy=65100;e.overlinewavy=65099;e.overscore=175;e.ovowelsignbengali=2507;e.ovowelsigndeva=2379;e.ovowelsigngujarati=2763;e.p=112;e.paampssquare=13184;e.paasentosquare=13099;e.pabengali=2474;e.pacute=7765;e.padeva=2346;e.pagedown=8671;e.pageup=8670;e.pagujarati=2730;e.pagurmukhi=2602;e.pahiragana=12401;e.paiyannoithai=3631;e.pakatakana=12497;e.palatalizationcyrilliccmb=1156;e.palochkacyrillic=1216;e.pansioskorean=12671;e.paragraph=182;e.parallel=8741;e.parenleft=40;e.parenleftaltonearabic=64830;e.parenleftbt=63725;e.parenleftex=63724;e.parenleftinferior=8333;e.parenleftmonospace=65288;e.parenleftsmall=65113;e.parenleftsuperior=8317;e.parenlefttp=63723;e.parenleftvertical=65077;e.parenright=41;e.parenrightaltonearabic=64831;e.parenrightbt=63736;e.parenrightex=63735;e.parenrightinferior=8334;e.parenrightmonospace=65289;e.parenrightsmall=65114;e.parenrightsuperior=8318;e.parenrighttp=63734;e.parenrightvertical=65078;e.partialdiff=8706;e.paseqhebrew=1472;e.pashtahebrew=1433;e.pasquare=13225;e.patah=1463;e.patah11=1463;e.patah1d=1463;e.patah2a=1463;e.patahhebrew=1463;e.patahnarrowhebrew=1463;e.patahquarterhebrew=1463;e.patahwidehebrew=1463;e.pazerhebrew=1441;e.pbopomofo=12550;e.pcircle=9439;e.pdotaccent=7767;e.pe=1508;e.pecyrillic=1087;e.pedagesh=64324;e.pedageshhebrew=64324;e.peezisquare=13115;e.pefinaldageshhebrew=64323;e.peharabic=1662;e.peharmenian=1402;e.pehebrew=1508;e.pehfinalarabic=64343;e.pehinitialarabic=64344;e.pehiragana=12410;e.pehmedialarabic=64345;e.pekatakana=12506;e.pemiddlehookcyrillic=1191;e.perafehebrew=64334;e.percent=37;e.percentarabic=1642;e.percentmonospace=65285;e.percentsmall=65130;e.period=46;e.periodarmenian=1417;e.periodcentered=183;e.periodhalfwidth=65377;e.periodinferior=63207;e.periodmonospace=65294;e.periodsmall=65106;e.periodsuperior=63208;e.perispomenigreekcmb=834;e.perpendicular=8869;e.perthousand=8240;e.peseta=8359;e.pfsquare=13194;e.phabengali=2475;e.phadeva=2347;e.phagujarati=2731;e.phagurmukhi=2603;e.phi=966;e.phi1=981;e.phieuphacirclekorean=12922;e.phieuphaparenkorean=12826;e.phieuphcirclekorean=12908;e.phieuphkorean=12621;e.phieuphparenkorean=12812;e.philatin=632;e.phinthuthai=3642;e.phisymbolgreek=981;e.phook=421;e.phophanthai=3614;e.phophungthai=3612;e.phosamphaothai=3616;e.pi=960;e.pieupacirclekorean=12915;e.pieupaparenkorean=12819;e.pieupcieuckorean=12662;e.pieupcirclekorean=12901;e.pieupkiyeokkorean=12658;e.pieupkorean=12610;e.pieupparenkorean=12805;e.pieupsioskiyeokkorean=12660;e.pieupsioskorean=12612;e.pieupsiostikeutkorean=12661;e.pieupthieuthkorean=12663;e.pieuptikeutkorean=12659;e.pihiragana=12404;e.pikatakana=12500;e.pisymbolgreek=982;e.piwrarmenian=1411;e.plus=43;e.plusbelowcmb=799;e.pluscircle=8853;e.plusminus=177;e.plusmod=726;e.plusmonospace=65291;e.plussmall=65122;e.plussuperior=8314;e.pmonospace=65360;e.pmsquare=13272;e.pohiragana=12413;e.pointingindexdownwhite=9759;e.pointingindexleftwhite=9756;e.pointingindexrightwhite=9758;e.pointingindexupwhite=9757;e.pokatakana=12509;e.poplathai=3611;e.postalmark=12306;e.postalmarkface=12320;e.pparen=9387;e.precedes=8826;e.prescription=8478;e.primemod=697;e.primereversed=8245;e.product=8719;e.projective=8965;e.prolongedkana=12540;e.propellor=8984;e.propersubset=8834;e.propersuperset=8835;e.proportion=8759;e.proportional=8733;e.psi=968;e.psicyrillic=1137;e.psilipneumatacyrilliccmb=1158;e.pssquare=13232;e.puhiragana=12407;e.pukatakana=12503;e.pvsquare=13236;e.pwsquare=13242;e.q=113;e.qadeva=2392;e.qadmahebrew=1448;e.qafarabic=1602;e.qaffinalarabic=65238;e.qafinitialarabic=65239;e.qafmedialarabic=65240;e.qamats=1464;e.qamats10=1464;e.qamats1a=1464;e.qamats1c=1464;e.qamats27=1464;e.qamats29=1464;e.qamats33=1464;e.qamatsde=1464;e.qamatshebrew=1464;e.qamatsnarrowhebrew=1464;e.qamatsqatanhebrew=1464;e.qamatsqatannarrowhebrew=1464;e.qamatsqatanquarterhebrew=1464;e.qamatsqatanwidehebrew=1464;e.qamatsquarterhebrew=1464;e.qamatswidehebrew=1464;e.qarneyparahebrew=1439;e.qbopomofo=12561;e.qcircle=9440;e.qhook=672;e.qmonospace=65361;e.qof=1511;e.qofdagesh=64327;e.qofdageshhebrew=64327;e.qofhebrew=1511;e.qparen=9388;e.quarternote=9833;e.qubuts=1467;e.qubuts18=1467;e.qubuts25=1467;e.qubuts31=1467;e.qubutshebrew=1467;e.qubutsnarrowhebrew=1467;e.qubutsquarterhebrew=1467;e.qubutswidehebrew=1467;e.question=63;e.questionarabic=1567;e.questionarmenian=1374;e.questiondown=191;e.questiondownsmall=63423;e.questiongreek=894;e.questionmonospace=65311;e.questionsmall=63295;e.quotedbl=34;e.quotedblbase=8222;e.quotedblleft=8220;e.quotedblmonospace=65282;e.quotedblprime=12318;e.quotedblprimereversed=12317;e.quotedblright=8221;e.quoteleft=8216;e.quoteleftreversed=8219;e.quotereversed=8219;e.quoteright=8217;e.quoterightn=329;e.quotesinglbase=8218;e.quotesingle=39;e.quotesinglemonospace=65287;e.r=114;e.raarmenian=1404;e.rabengali=2480;e.racute=341;e.radeva=2352;e.radical=8730;e.radicalex=63717;e.radoverssquare=13230;e.radoverssquaredsquare=13231;e.radsquare=13229;e.rafe=1471;e.rafehebrew=1471;e.ragujarati=2736;e.ragurmukhi=2608;e.rahiragana=12425;e.rakatakana=12521;e.rakatakanahalfwidth=65431;e.ralowerdiagonalbengali=2545;e.ramiddlediagonalbengali=2544;e.ramshorn=612;e.ratio=8758;e.rbopomofo=12566;e.rcaron=345;e.rcedilla=343;e.rcircle=9441;e.rcommaaccent=343;e.rdblgrave=529;e.rdotaccent=7769;e.rdotbelow=7771;e.rdotbelowmacron=7773;e.referencemark=8251;e.reflexsubset=8838;e.reflexsuperset=8839;e.registered=174;e.registersans=63720;e.registerserif=63194;e.reharabic=1585;e.reharmenian=1408;e.rehfinalarabic=65198;e.rehiragana=12428;e.rekatakana=12524;e.rekatakanahalfwidth=65434;e.resh=1512;e.reshdageshhebrew=64328;e.reshhebrew=1512;e.reversedtilde=8765;e.reviahebrew=1431;e.reviamugrashhebrew=1431;e.revlogicalnot=8976;e.rfishhook=638;e.rfishhookreversed=639;e.rhabengali=2525;e.rhadeva=2397;e.rho=961;e.rhook=637;e.rhookturned=635;e.rhookturnedsuperior=693;e.rhosymbolgreek=1009;e.rhotichookmod=734;e.rieulacirclekorean=12913;e.rieulaparenkorean=12817;e.rieulcirclekorean=12899;e.rieulhieuhkorean=12608;e.rieulkiyeokkorean=12602;e.rieulkiyeoksioskorean=12649;e.rieulkorean=12601;e.rieulmieumkorean=12603;e.rieulpansioskorean=12652;e.rieulparenkorean=12803;e.rieulphieuphkorean=12607;e.rieulpieupkorean=12604;e.rieulpieupsioskorean=12651;e.rieulsioskorean=12605;e.rieulthieuthkorean=12606;e.rieultikeutkorean=12650;e.rieulyeorinhieuhkorean=12653;e.rightangle=8735;e.righttackbelowcmb=793;e.righttriangle=8895;e.rihiragana=12426;e.rikatakana=12522;e.rikatakanahalfwidth=65432;e.ring=730;e.ringbelowcmb=805;e.ringcmb=778;e.ringhalfleft=703;e.ringhalfleftarmenian=1369;e.ringhalfleftbelowcmb=796;e.ringhalfleftcentered=723;e.ringhalfright=702;e.ringhalfrightbelowcmb=825;e.ringhalfrightcentered=722;e.rinvertedbreve=531;e.rittorusquare=13137;e.rlinebelow=7775;e.rlongleg=636;e.rlonglegturned=634;e.rmonospace=65362;e.rohiragana=12429;e.rokatakana=12525;e.rokatakanahalfwidth=65435;e.roruathai=3619;e.rparen=9389;e.rrabengali=2524;e.rradeva=2353;e.rragurmukhi=2652;e.rreharabic=1681;e.rrehfinalarabic=64397;e.rrvocalicbengali=2528;e.rrvocalicdeva=2400;e.rrvocalicgujarati=2784;e.rrvocalicvowelsignbengali=2500;e.rrvocalicvowelsigndeva=2372;e.rrvocalicvowelsigngujarati=2756;e.rsuperior=63217;e.rtblock=9616;e.rturned=633;e.rturnedsuperior=692;e.ruhiragana=12427;e.rukatakana=12523;e.rukatakanahalfwidth=65433;e.rupeemarkbengali=2546;e.rupeesignbengali=2547;e.rupiah=63197;e.ruthai=3620;e.rvocalicbengali=2443;e.rvocalicdeva=2315;e.rvocalicgujarati=2699;e.rvocalicvowelsignbengali=2499;e.rvocalicvowelsigndeva=2371;e.rvocalicvowelsigngujarati=2755;e.s=115;e.sabengali=2488;e.sacute=347;e.sacutedotaccent=7781;e.sadarabic=1589;e.sadeva=2360;e.sadfinalarabic=65210;e.sadinitialarabic=65211;e.sadmedialarabic=65212;e.sagujarati=2744;e.sagurmukhi=2616;e.sahiragana=12373;e.sakatakana=12469;e.sakatakanahalfwidth=65403;e.sallallahoualayhewasallamarabic=65018;e.samekh=1505;e.samekhdagesh=64321;e.samekhdageshhebrew=64321;e.samekhhebrew=1505;e.saraaathai=3634;e.saraaethai=3649;e.saraaimaimalaithai=3652;e.saraaimaimuanthai=3651;e.saraamthai=3635;e.saraathai=3632;e.saraethai=3648;e.saraiileftthai=63622;e.saraiithai=3637;e.saraileftthai=63621;e.saraithai=3636;e.saraothai=3650;e.saraueeleftthai=63624;e.saraueethai=3639;e.saraueleftthai=63623;e.sarauethai=3638;e.sarauthai=3640;e.sarauuthai=3641;e.sbopomofo=12569;e.scaron=353;e.scarondotaccent=7783;e.scedilla=351;e.schwa=601;e.schwacyrillic=1241;e.schwadieresiscyrillic=1243;e.schwahook=602;e.scircle=9442;e.scircumflex=349;e.scommaaccent=537;e.sdotaccent=7777;e.sdotbelow=7779;e.sdotbelowdotaccent=7785;e.seagullbelowcmb=828;e.second=8243;e.secondtonechinese=714;e.section=167;e.seenarabic=1587;e.seenfinalarabic=65202;e.seeninitialarabic=65203;e.seenmedialarabic=65204;e.segol=1462;e.segol13=1462;e.segol1f=1462;e.segol2c=1462;e.segolhebrew=1462;e.segolnarrowhebrew=1462;e.segolquarterhebrew=1462;e.segoltahebrew=1426;e.segolwidehebrew=1462;e.seharmenian=1405;e.sehiragana=12379;e.sekatakana=12475;e.sekatakanahalfwidth=65406;e.semicolon=59;e.semicolonarabic=1563;e.semicolonmonospace=65307;e.semicolonsmall=65108;e.semivoicedmarkkana=12444;e.semivoicedmarkkanahalfwidth=65439;e.sentisquare=13090;e.sentosquare=13091;e.seven=55;e.sevenarabic=1639;e.sevenbengali=2541;e.sevencircle=9318;e.sevencircleinversesansserif=10128;e.sevendeva=2413;e.seveneighths=8542;e.sevengujarati=2797;e.sevengurmukhi=2669;e.sevenhackarabic=1639;e.sevenhangzhou=12327;e.sevenideographicparen=12838;e.seveninferior=8327;e.sevenmonospace=65303;e.sevenoldstyle=63287;e.sevenparen=9338;e.sevenperiod=9358;e.sevenpersian=1783;e.sevenroman=8566;e.sevensuperior=8311;e.seventeencircle=9328;e.seventeenparen=9348;e.seventeenperiod=9368;e.seventhai=3671;e.sfthyphen=173;e.shaarmenian=1399;e.shabengali=2486;e.shacyrillic=1096;e.shaddaarabic=1617;e.shaddadammaarabic=64609;e.shaddadammatanarabic=64606;e.shaddafathaarabic=64608;e.shaddakasraarabic=64610;e.shaddakasratanarabic=64607;e.shade=9618;e.shadedark=9619;e.shadelight=9617;e.shademedium=9618;e.shadeva=2358;e.shagujarati=2742;e.shagurmukhi=2614;e.shalshelethebrew=1427;e.shbopomofo=12565;e.shchacyrillic=1097;e.sheenarabic=1588;e.sheenfinalarabic=65206;e.sheeninitialarabic=65207;e.sheenmedialarabic=65208;e.sheicoptic=995;e.sheqel=8362;e.sheqelhebrew=8362;e.sheva=1456;e.sheva115=1456;e.sheva15=1456;e.sheva22=1456;e.sheva2e=1456;e.shevahebrew=1456;e.shevanarrowhebrew=1456;e.shevaquarterhebrew=1456;e.shevawidehebrew=1456;e.shhacyrillic=1211;e.shimacoptic=1005;e.shin=1513;e.shindagesh=64329;e.shindageshhebrew=64329;e.shindageshshindot=64300;e.shindageshshindothebrew=64300;e.shindageshsindot=64301;e.shindageshsindothebrew=64301;e.shindothebrew=1473;e.shinhebrew=1513;e.shinshindot=64298;e.shinshindothebrew=64298;e.shinsindot=64299;e.shinsindothebrew=64299;e.shook=642;e.sigma=963;e.sigma1=962;e.sigmafinal=962;e.sigmalunatesymbolgreek=1010;e.sihiragana=12375;e.sikatakana=12471;e.sikatakanahalfwidth=65404;e.siluqhebrew=1469;e.siluqlefthebrew=1469;e.similar=8764;e.sindothebrew=1474;e.siosacirclekorean=12916;e.siosaparenkorean=12820;e.sioscieuckorean=12670;e.sioscirclekorean=12902;e.sioskiyeokkorean=12666;e.sioskorean=12613;e.siosnieunkorean=12667;e.siosparenkorean=12806;e.siospieupkorean=12669;e.siostikeutkorean=12668;e.six=54;e.sixarabic=1638;e.sixbengali=2540;e.sixcircle=9317;e.sixcircleinversesansserif=10127;e.sixdeva=2412;e.sixgujarati=2796;e.sixgurmukhi=2668;e.sixhackarabic=1638;e.sixhangzhou=12326;e.sixideographicparen=12837;e.sixinferior=8326;e.sixmonospace=65302;e.sixoldstyle=63286;e.sixparen=9337;e.sixperiod=9357;e.sixpersian=1782;e.sixroman=8565;e.sixsuperior=8310;e.sixteencircle=9327;e.sixteencurrencydenominatorbengali=2553;e.sixteenparen=9347;e.sixteenperiod=9367;e.sixthai=3670;e.slash=47;e.slashmonospace=65295;e.slong=383;e.slongdotaccent=7835;e.smileface=9786;e.smonospace=65363;e.sofpasuqhebrew=1475;e.softhyphen=173;e.softsigncyrillic=1100;e.sohiragana=12381;e.sokatakana=12477;e.sokatakanahalfwidth=65407;e.soliduslongoverlaycmb=824;e.solidusshortoverlaycmb=823;e.sorusithai=3625;e.sosalathai=3624;e.sosothai=3595;e.sosuathai=3626;e.space=32;e.spacehackarabic=32;e.spade=9824;e.spadesuitblack=9824;e.spadesuitwhite=9828;e.sparen=9390;e.squarebelowcmb=827;e.squarecc=13252;e.squarecm=13213;e.squarediagonalcrosshatchfill=9641;e.squarehorizontalfill=9636;e.squarekg=13199;e.squarekm=13214;e.squarekmcapital=13262;e.squareln=13265;e.squarelog=13266;e.squaremg=13198;e.squaremil=13269;e.squaremm=13212;e.squaremsquared=13217;e.squareorthogonalcrosshatchfill=9638;e.squareupperlefttolowerrightfill=9639;e.squareupperrighttolowerleftfill=9640;e.squareverticalfill=9637;e.squarewhitewithsmallblack=9635;e.srsquare=13275;e.ssabengali=2487;e.ssadeva=2359;e.ssagujarati=2743;e.ssangcieuckorean=12617;e.ssanghieuhkorean=12677;e.ssangieungkorean=12672;e.ssangkiyeokkorean=12594;e.ssangnieunkorean=12645;e.ssangpieupkorean=12611;e.ssangsioskorean=12614;e.ssangtikeutkorean=12600;e.ssuperior=63218;e.sterling=163;e.sterlingmonospace=65505;e.strokelongoverlaycmb=822;e.strokeshortoverlaycmb=821;e.subset=8834;e.subsetnotequal=8842;e.subsetorequal=8838;e.succeeds=8827;e.suchthat=8715;e.suhiragana=12377;e.sukatakana=12473;e.sukatakanahalfwidth=65405;e.sukunarabic=1618;e.summation=8721;e.sun=9788;e.superset=8835;e.supersetnotequal=8843;e.supersetorequal=8839;e.svsquare=13276;e.syouwaerasquare=13180;e.t=116;e.tabengali=2468;e.tackdown=8868;e.tackleft=8867;e.tadeva=2340;e.tagujarati=2724;e.tagurmukhi=2596;e.taharabic=1591;e.tahfinalarabic=65218;e.tahinitialarabic=65219;e.tahiragana=12383;e.tahmedialarabic=65220;e.taisyouerasquare=13181;e.takatakana=12479;e.takatakanahalfwidth=65408;e.tatweelarabic=1600;e.tau=964;e.tav=1514;e.tavdages=64330;e.tavdagesh=64330;e.tavdageshhebrew=64330;e.tavhebrew=1514;e.tbar=359;e.tbopomofo=12554;e.tcaron=357;e.tccurl=680;e.tcedilla=355;e.tcheharabic=1670;e.tchehfinalarabic=64379;e.tchehinitialarabic=64380;e.tchehmedialarabic=64381;e.tcircle=9443;e.tcircumflexbelow=7793;e.tcommaaccent=355;e.tdieresis=7831;e.tdotaccent=7787;e.tdotbelow=7789;e.tecyrillic=1090;e.tedescendercyrillic=1197;e.teharabic=1578;e.tehfinalarabic=65174;e.tehhahinitialarabic=64674;e.tehhahisolatedarabic=64524;e.tehinitialarabic=65175;e.tehiragana=12390;e.tehjeeminitialarabic=64673;e.tehjeemisolatedarabic=64523;e.tehmarbutaarabic=1577;e.tehmarbutafinalarabic=65172;e.tehmedialarabic=65176;e.tehmeeminitialarabic=64676;e.tehmeemisolatedarabic=64526;e.tehnoonfinalarabic=64627;e.tekatakana=12486;e.tekatakanahalfwidth=65411;e.telephone=8481;e.telephoneblack=9742;e.telishagedolahebrew=1440;e.telishaqetanahebrew=1449;e.tencircle=9321;e.tenideographicparen=12841;e.tenparen=9341;e.tenperiod=9361;e.tenroman=8569;e.tesh=679;e.tet=1496;e.tetdagesh=64312;e.tetdageshhebrew=64312;e.tethebrew=1496;e.tetsecyrillic=1205;e.tevirhebrew=1435;e.tevirlefthebrew=1435;e.thabengali=2469;e.thadeva=2341;e.thagujarati=2725;e.thagurmukhi=2597;e.thalarabic=1584;e.thalfinalarabic=65196;e.thanthakhatlowleftthai=63640;e.thanthakhatlowrightthai=63639;e.thanthakhatthai=3660;e.thanthakhatupperleftthai=63638;e.theharabic=1579;e.thehfinalarabic=65178;e.thehinitialarabic=65179;e.thehmedialarabic=65180;e.thereexists=8707;e.therefore=8756;e.theta=952;e.theta1=977;e.thetasymbolgreek=977;e.thieuthacirclekorean=12921;e.thieuthaparenkorean=12825;e.thieuthcirclekorean=12907;e.thieuthkorean=12620;e.thieuthparenkorean=12811;e.thirteencircle=9324;e.thirteenparen=9344;e.thirteenperiod=9364;e.thonangmonthothai=3601;e.thook=429;e.thophuthaothai=3602;e.thorn=254;e.thothahanthai=3607;e.thothanthai=3600;e.thothongthai=3608;e.thothungthai=3606;e.thousandcyrillic=1154;e.thousandsseparatorarabic=1644;e.thousandsseparatorpersian=1644;e.three=51;e.threearabic=1635;e.threebengali=2537;e.threecircle=9314;e.threecircleinversesansserif=10124;e.threedeva=2409;e.threeeighths=8540;e.threegujarati=2793;e.threegurmukhi=2665;e.threehackarabic=1635;e.threehangzhou=12323;e.threeideographicparen=12834;e.threeinferior=8323;e.threemonospace=65299;e.threenumeratorbengali=2550;e.threeoldstyle=63283;e.threeparen=9334;e.threeperiod=9354;e.threepersian=1779;e.threequarters=190;e.threequartersemdash=63198;e.threeroman=8562;e.threesuperior=179;e.threethai=3667;e.thzsquare=13204;e.tihiragana=12385;e.tikatakana=12481;e.tikatakanahalfwidth=65409;e.tikeutacirclekorean=12912;e.tikeutaparenkorean=12816;e.tikeutcirclekorean=12898;e.tikeutkorean=12599;e.tikeutparenkorean=12802;e.tilde=732;e.tildebelowcmb=816;e.tildecmb=771;e.tildecomb=771;e.tildedoublecmb=864;e.tildeoperator=8764;e.tildeoverlaycmb=820;e.tildeverticalcmb=830;e.timescircle=8855;e.tipehahebrew=1430;e.tipehalefthebrew=1430;e.tippigurmukhi=2672;e.titlocyrilliccmb=1155;e.tiwnarmenian=1407;e.tlinebelow=7791;e.tmonospace=65364;e.toarmenian=1385;e.tohiragana=12392;e.tokatakana=12488;e.tokatakanahalfwidth=65412;e.tonebarextrahighmod=741;e.tonebarextralowmod=745;e.tonebarhighmod=742;e.tonebarlowmod=744;e.tonebarmidmod=743;e.tonefive=445;e.tonesix=389;e.tonetwo=424;e.tonos=900;e.tonsquare=13095;e.topatakthai=3599;e.tortoiseshellbracketleft=12308;e.tortoiseshellbracketleftsmall=65117;e.tortoiseshellbracketleftvertical=65081;e.tortoiseshellbracketright=12309;e.tortoiseshellbracketrightsmall=65118;e.tortoiseshellbracketrightvertical=65082;e.totaothai=3605;e.tpalatalhook=427;e.tparen=9391;e.trademark=8482;e.trademarksans=63722;e.trademarkserif=63195;e.tretroflexhook=648;e.triagdn=9660;e.triaglf=9668;e.triagrt=9658;e.triagup=9650;e.ts=678;e.tsadi=1510;e.tsadidagesh=64326;e.tsadidageshhebrew=64326;e.tsadihebrew=1510;e.tsecyrillic=1094;e.tsere=1461;e.tsere12=1461;e.tsere1e=1461;e.tsere2b=1461;e.tserehebrew=1461;e.tserenarrowhebrew=1461;e.tserequarterhebrew=1461;e.tserewidehebrew=1461;e.tshecyrillic=1115;e.tsuperior=63219;e.ttabengali=2463;e.ttadeva=2335;e.ttagujarati=2719;e.ttagurmukhi=2591;e.tteharabic=1657;e.ttehfinalarabic=64359;e.ttehinitialarabic=64360;e.ttehmedialarabic=64361;e.tthabengali=2464;e.tthadeva=2336;e.tthagujarati=2720;e.tthagurmukhi=2592;e.tturned=647;e.tuhiragana=12388;e.tukatakana=12484;e.tukatakanahalfwidth=65410;e.tusmallhiragana=12387;e.tusmallkatakana=12483;e.tusmallkatakanahalfwidth=65391;e.twelvecircle=9323;e.twelveparen=9343;e.twelveperiod=9363;e.twelveroman=8571;e.twentycircle=9331;e.twentyhangzhou=21316;e.twentyparen=9351;e.twentyperiod=9371;e.two=50;e.twoarabic=1634;e.twobengali=2536;e.twocircle=9313;e.twocircleinversesansserif=10123;e.twodeva=2408;e.twodotenleader=8229;e.twodotleader=8229;e.twodotleadervertical=65072;e.twogujarati=2792;e.twogurmukhi=2664;e.twohackarabic=1634;e.twohangzhou=12322;e.twoideographicparen=12833;e.twoinferior=8322;e.twomonospace=65298;e.twonumeratorbengali=2549;e.twooldstyle=63282;e.twoparen=9333;e.twoperiod=9353;e.twopersian=1778;e.tworoman=8561;e.twostroke=443;e.twosuperior=178;e.twothai=3666;e.twothirds=8532;e.u=117;e.uacute=250;e.ubar=649;e.ubengali=2441;e.ubopomofo=12584;e.ubreve=365;e.ucaron=468;e.ucircle=9444;e.ucircumflex=251;e.ucircumflexbelow=7799;e.ucyrillic=1091;e.udattadeva=2385;e.udblacute=369;e.udblgrave=533;e.udeva=2313;e.udieresis=252;e.udieresisacute=472;e.udieresisbelow=7795;e.udieresiscaron=474;e.udieresiscyrillic=1265;e.udieresisgrave=476;e.udieresismacron=470;e.udotbelow=7909;e.ugrave=249;e.ugujarati=2697;e.ugurmukhi=2569;e.uhiragana=12358;e.uhookabove=7911;e.uhorn=432;e.uhornacute=7913;e.uhorndotbelow=7921;e.uhorngrave=7915;e.uhornhookabove=7917;e.uhorntilde=7919;e.uhungarumlaut=369;e.uhungarumlautcyrillic=1267;e.uinvertedbreve=535;e.ukatakana=12454;e.ukatakanahalfwidth=65395;e.ukcyrillic=1145;e.ukorean=12636;e.umacron=363;e.umacroncyrillic=1263;e.umacrondieresis=7803;e.umatragurmukhi=2625;e.umonospace=65365;e.underscore=95;e.underscoredbl=8215;e.underscoremonospace=65343;e.underscorevertical=65075;e.underscorewavy=65103;e.union=8746;e.universal=8704;e.uogonek=371;e.uparen=9392;e.upblock=9600;e.upperdothebrew=1476;e.upsilon=965;e.upsilondieresis=971;e.upsilondieresistonos=944;e.upsilonlatin=650;e.upsilontonos=973;e.uptackbelowcmb=797;e.uptackmod=724;e.uragurmukhi=2675;e.uring=367;e.ushortcyrillic=1118;e.usmallhiragana=12357;e.usmallkatakana=12453;e.usmallkatakanahalfwidth=65385;e.ustraightcyrillic=1199;e.ustraightstrokecyrillic=1201;e.utilde=361;e.utildeacute=7801;e.utildebelow=7797;e.uubengali=2442;e.uudeva=2314;e.uugujarati=2698;e.uugurmukhi=2570;e.uumatragurmukhi=2626;e.uuvowelsignbengali=2498;e.uuvowelsigndeva=2370;e.uuvowelsigngujarati=2754;e.uvowelsignbengali=2497;e.uvowelsigndeva=2369;e.uvowelsigngujarati=2753;e.v=118;e.vadeva=2357;e.vagujarati=2741;e.vagurmukhi=2613;e.vakatakana=12535;e.vav=1493;e.vavdagesh=64309;e.vavdagesh65=64309;e.vavdageshhebrew=64309;e.vavhebrew=1493;e.vavholam=64331;e.vavholamhebrew=64331;e.vavvavhebrew=1520;e.vavyodhebrew=1521;e.vcircle=9445;e.vdotbelow=7807;e.vecyrillic=1074;e.veharabic=1700;e.vehfinalarabic=64363;e.vehinitialarabic=64364;e.vehmedialarabic=64365;e.vekatakana=12537;e.venus=9792;e.verticalbar=124;e.verticallineabovecmb=781;e.verticallinebelowcmb=809;e.verticallinelowmod=716;e.verticallinemod=712;e.vewarmenian=1406;e.vhook=651;e.vikatakana=12536;e.viramabengali=2509;e.viramadeva=2381;e.viramagujarati=2765;e.visargabengali=2435;e.visargadeva=2307;e.visargagujarati=2691;e.vmonospace=65366;e.voarmenian=1400;e.voicediterationhiragana=12446;e.voicediterationkatakana=12542;e.voicedmarkkana=12443;e.voicedmarkkanahalfwidth=65438;e.vokatakana=12538;e.vparen=9393;e.vtilde=7805;e.vturned=652;e.vuhiragana=12436;e.vukatakana=12532;e.w=119;e.wacute=7811;e.waekorean=12633;e.wahiragana=12431;e.wakatakana=12527;e.wakatakanahalfwidth=65436;e.wakorean=12632;e.wasmallhiragana=12430;e.wasmallkatakana=12526;e.wattosquare=13143;e.wavedash=12316;e.wavyunderscorevertical=65076;e.wawarabic=1608;e.wawfinalarabic=65262;e.wawhamzaabovearabic=1572;e.wawhamzaabovefinalarabic=65158;e.wbsquare=13277;e.wcircle=9446;e.wcircumflex=373;e.wdieresis=7813;e.wdotaccent=7815;e.wdotbelow=7817;e.wehiragana=12433;e.weierstrass=8472;e.wekatakana=12529;e.wekorean=12638;e.weokorean=12637;e.wgrave=7809;e.whitebullet=9702;e.whitecircle=9675;e.whitecircleinverse=9689;e.whitecornerbracketleft=12302;e.whitecornerbracketleftvertical=65091;e.whitecornerbracketright=12303;e.whitecornerbracketrightvertical=65092;e.whitediamond=9671;e.whitediamondcontainingblacksmalldiamond=9672;e.whitedownpointingsmalltriangle=9663;e.whitedownpointingtriangle=9661;e.whiteleftpointingsmalltriangle=9667;e.whiteleftpointingtriangle=9665;e.whitelenticularbracketleft=12310;e.whitelenticularbracketright=12311;e.whiterightpointingsmalltriangle=9657;e.whiterightpointingtriangle=9655;e.whitesmallsquare=9643;e.whitesmilingface=9786;e.whitesquare=9633;e.whitestar=9734;e.whitetelephone=9743;e.whitetortoiseshellbracketleft=12312;e.whitetortoiseshellbracketright=12313;e.whiteuppointingsmalltriangle=9653;e.whiteuppointingtriangle=9651;e.wihiragana=12432;e.wikatakana=12528;e.wikorean=12639;e.wmonospace=65367;e.wohiragana=12434;e.wokatakana=12530;e.wokatakanahalfwidth=65382;e.won=8361;e.wonmonospace=65510;e.wowaenthai=3623;e.wparen=9394;e.wring=7832;e.wsuperior=695;e.wturned=653;e.wynn=447;e.x=120;e.xabovecmb=829;e.xbopomofo=12562;e.xcircle=9447;e.xdieresis=7821;e.xdotaccent=7819;e.xeharmenian=1389;e.xi=958;e.xmonospace=65368;e.xparen=9395;e.xsuperior=739;e.y=121;e.yaadosquare=13134;e.yabengali=2479;e.yacute=253;e.yadeva=2351;e.yaekorean=12626;e.yagujarati=2735;e.yagurmukhi=2607;e.yahiragana=12420;e.yakatakana=12516;e.yakatakanahalfwidth=65428;e.yakorean=12625;e.yamakkanthai=3662;e.yasmallhiragana=12419;e.yasmallkatakana=12515;e.yasmallkatakanahalfwidth=65388;e.yatcyrillic=1123;e.ycircle=9448;e.ycircumflex=375;e.ydieresis=255;e.ydotaccent=7823;e.ydotbelow=7925;e.yeharabic=1610;e.yehbarreearabic=1746;e.yehbarreefinalarabic=64431;e.yehfinalarabic=65266;e.yehhamzaabovearabic=1574;e.yehhamzaabovefinalarabic=65162;e.yehhamzaaboveinitialarabic=65163;e.yehhamzaabovemedialarabic=65164;e.yehinitialarabic=65267;e.yehmedialarabic=65268;e.yehmeeminitialarabic=64733;e.yehmeemisolatedarabic=64600;e.yehnoonfinalarabic=64660;e.yehthreedotsbelowarabic=1745;e.yekorean=12630;e.yen=165;e.yenmonospace=65509;e.yeokorean=12629;e.yeorinhieuhkorean=12678;e.yerahbenyomohebrew=1450;e.yerahbenyomolefthebrew=1450;e.yericyrillic=1099;e.yerudieresiscyrillic=1273;e.yesieungkorean=12673;e.yesieungpansioskorean=12675;e.yesieungsioskorean=12674;e.yetivhebrew=1434;e.ygrave=7923;e.yhook=436;e.yhookabove=7927;e.yiarmenian=1397;e.yicyrillic=1111;e.yikorean=12642;e.yinyang=9775;e.yiwnarmenian=1410;e.ymonospace=65369;e.yod=1497;e.yoddagesh=64313;e.yoddageshhebrew=64313;e.yodhebrew=1497;e.yodyodhebrew=1522;e.yodyodpatahhebrew=64287;e.yohiragana=12424;e.yoikorean=12681;e.yokatakana=12520;e.yokatakanahalfwidth=65430;e.yokorean=12635;e.yosmallhiragana=12423;e.yosmallkatakana=12519;e.yosmallkatakanahalfwidth=65390;e.yotgreek=1011;e.yoyaekorean=12680;e.yoyakorean=12679;e.yoyakthai=3618;e.yoyingthai=3597;e.yparen=9396;e.ypogegrammeni=890;e.ypogegrammenigreekcmb=837;e.yr=422;e.yring=7833;e.ysuperior=696;e.ytilde=7929;e.yturned=654;e.yuhiragana=12422;e.yuikorean=12684;e.yukatakana=12518;e.yukatakanahalfwidth=65429;e.yukorean=12640;e.yusbigcyrillic=1131;e.yusbigiotifiedcyrillic=1133;e.yuslittlecyrillic=1127;e.yuslittleiotifiedcyrillic=1129;e.yusmallhiragana=12421;e.yusmallkatakana=12517;e.yusmallkatakanahalfwidth=65389;e.yuyekorean=12683;e.yuyeokorean=12682;e.yyabengali=2527;e.yyadeva=2399;e.z=122;e.zaarmenian=1382;e.zacute=378;e.zadeva=2395;e.zagurmukhi=2651;e.zaharabic=1592;e.zahfinalarabic=65222;e.zahinitialarabic=65223;e.zahiragana=12374;e.zahmedialarabic=65224;e.zainarabic=1586;e.zainfinalarabic=65200;e.zakatakana=12470;e.zaqefgadolhebrew=1429;e.zaqefqatanhebrew=1428;e.zarqahebrew=1432;e.zayin=1494;e.zayindagesh=64310;e.zayindageshhebrew=64310;e.zayinhebrew=1494;e.zbopomofo=12567;e.zcaron=382;e.zcircle=9449;e.zcircumflex=7825;e.zcurl=657;e.zdot=380;e.zdotaccent=380;e.zdotbelow=7827;e.zecyrillic=1079;e.zedescendercyrillic=1177;e.zedieresiscyrillic=1247;e.zehiragana=12380;e.zekatakana=12476;e.zero=48;e.zeroarabic=1632;e.zerobengali=2534;e.zerodeva=2406;e.zerogujarati=2790;e.zerogurmukhi=2662;e.zerohackarabic=1632
+;e.zeroinferior=8320;e.zeromonospace=65296;e.zerooldstyle=63280;e.zeropersian=1776;e.zerosuperior=8304;e.zerothai=3664;e.zerowidthjoiner=65279;e.zerowidthnonjoiner=8204;e.zerowidthspace=8203;e.zeta=950;e.zhbopomofo=12563;e.zhearmenian=1386;e.zhebrevecyrillic=1218;e.zhecyrillic=1078;e.zhedescendercyrillic=1175;e.zhedieresiscyrillic=1245;e.zihiragana=12376;e.zikatakana=12472;e.zinorhebrew=1454;e.zlinebelow=7829;e.zmonospace=65370;e.zohiragana=12382;e.zokatakana=12478;e.zparen=9397;e.zretroflexhook=656;e.zstroke=438;e.zuhiragana=12378;e.zukatakana=12474;e[".notdef"]=0;e.angbracketleftbig=9001;e.angbracketleftBig=9001;e.angbracketleftbigg=9001;e.angbracketleftBigg=9001;e.angbracketrightBig=9002;e.angbracketrightbig=9002;e.angbracketrightBigg=9002;e.angbracketrightbigg=9002;e.arrowhookleft=8618;e.arrowhookright=8617;e.arrowlefttophalf=8636;e.arrowleftbothalf=8637;e.arrownortheast=8599;e.arrownorthwest=8598;e.arrowrighttophalf=8640;e.arrowrightbothalf=8641;e.arrowsoutheast=8600;e.arrowsouthwest=8601;e.backslashbig=8726;e.backslashBig=8726;e.backslashBigg=8726;e.backslashbigg=8726;e.bardbl=8214;e.bracehtipdownleft=65079;e.bracehtipdownright=65079;e.bracehtipupleft=65080;e.bracehtipupright=65080;e.braceleftBig=123;e.braceleftbig=123;e.braceleftbigg=123;e.braceleftBigg=123;e.bracerightBig=125;e.bracerightbig=125;e.bracerightbigg=125;e.bracerightBigg=125;e.bracketleftbig=91;e.bracketleftBig=91;e.bracketleftbigg=91;e.bracketleftBigg=91;e.bracketrightBig=93;e.bracketrightbig=93;e.bracketrightbigg=93;e.bracketrightBigg=93;e.ceilingleftbig=8968;e.ceilingleftBig=8968;e.ceilingleftBigg=8968;e.ceilingleftbigg=8968;e.ceilingrightbig=8969;e.ceilingrightBig=8969;e.ceilingrightbigg=8969;e.ceilingrightBigg=8969;e.circledotdisplay=8857;e.circledottext=8857;e.circlemultiplydisplay=8855;e.circlemultiplytext=8855;e.circleplusdisplay=8853;e.circleplustext=8853;e.contintegraldisplay=8750;e.contintegraltext=8750;e.coproductdisplay=8720;e.coproducttext=8720;e.floorleftBig=8970;e.floorleftbig=8970;e.floorleftbigg=8970;e.floorleftBigg=8970;e.floorrightbig=8971;e.floorrightBig=8971;e.floorrightBigg=8971;e.floorrightbigg=8971;e.hatwide=770;e.hatwider=770;e.hatwidest=770;e.intercal=7488;e.integraldisplay=8747;e.integraltext=8747;e.intersectiondisplay=8898;e.intersectiontext=8898;e.logicalanddisplay=8743;e.logicalandtext=8743;e.logicalordisplay=8744;e.logicalortext=8744;e.parenleftBig=40;e.parenleftbig=40;e.parenleftBigg=40;e.parenleftbigg=40;e.parenrightBig=41;e.parenrightbig=41;e.parenrightBigg=41;e.parenrightbigg=41;e.prime=8242;e.productdisplay=8719;e.producttext=8719;e.radicalbig=8730;e.radicalBig=8730;e.radicalBigg=8730;e.radicalbigg=8730;e.radicalbt=8730;e.radicaltp=8730;e.radicalvertex=8730;e.slashbig=47;e.slashBig=47;e.slashBigg=47;e.slashbigg=47;e.summationdisplay=8721;e.summationtext=8721;e.tildewide=732;e.tildewider=732;e.tildewidest=732;e.uniondisplay=8899;e.unionmultidisplay=8846;e.unionmultitext=8846;e.unionsqdisplay=8852;e.unionsqtext=8852;e.uniontext=8899;e.vextenddouble=8741;e.vextendsingle=8739}),s=i(function(e){e.space=32;e.a1=9985;e.a2=9986;e.a202=9987;e.a3=9988;e.a4=9742;e.a5=9990;e.a119=9991;e.a118=9992;e.a117=9993;e.a11=9755;e.a12=9758;e.a13=9996;e.a14=9997;e.a15=9998;e.a16=9999;e.a105=1e4;e.a17=10001;e.a18=10002;e.a19=10003;e.a20=10004;e.a21=10005;e.a22=10006;e.a23=10007;e.a24=10008;e.a25=10009;e.a26=10010;e.a27=10011;e.a28=10012;e.a6=10013;e.a7=10014;e.a8=10015;e.a9=10016;e.a10=10017;e.a29=10018;e.a30=10019;e.a31=10020;e.a32=10021;e.a33=10022;e.a34=10023;e.a35=9733;e.a36=10025;e.a37=10026;e.a38=10027;e.a39=10028;e.a40=10029;e.a41=10030;e.a42=10031;e.a43=10032;e.a44=10033;e.a45=10034;e.a46=10035;e.a47=10036;e.a48=10037;e.a49=10038;e.a50=10039;e.a51=10040;e.a52=10041;e.a53=10042;e.a54=10043;e.a55=10044;e.a56=10045;e.a57=10046;e.a58=10047;e.a59=10048;e.a60=10049;e.a61=10050;e.a62=10051;e.a63=10052;e.a64=10053;e.a65=10054;e.a66=10055;e.a67=10056;e.a68=10057;e.a69=10058;e.a70=10059;e.a71=9679;e.a72=10061;e.a73=9632;e.a74=10063;e.a203=10064;e.a75=10065;e.a204=10066;e.a76=9650;e.a77=9660;e.a78=9670;e.a79=10070;e.a81=9687;e.a82=10072;e.a83=10073;e.a84=10074;e.a97=10075;e.a98=10076;e.a99=10077;e.a100=10078;e.a101=10081;e.a102=10082;e.a103=10083;e.a104=10084;e.a106=10085;e.a107=10086;e.a108=10087;e.a112=9827;e.a111=9830;e.a110=9829;e.a109=9824;e.a120=9312;e.a121=9313;e.a122=9314;e.a123=9315;e.a124=9316;e.a125=9317;e.a126=9318;e.a127=9319;e.a128=9320;e.a129=9321;e.a130=10102;e.a131=10103;e.a132=10104;e.a133=10105;e.a134=10106;e.a135=10107;e.a136=10108;e.a137=10109;e.a138=10110;e.a139=10111;e.a140=10112;e.a141=10113;e.a142=10114;e.a143=10115;e.a144=10116;e.a145=10117;e.a146=10118;e.a147=10119;e.a148=10120;e.a149=10121;e.a150=10122;e.a151=10123;e.a152=10124;e.a153=10125;e.a154=10126;e.a155=10127;e.a156=10128;e.a157=10129;e.a158=10130;e.a159=10131;e.a160=10132;e.a161=8594;e.a163=8596;e.a164=8597;e.a196=10136;e.a165=10137;e.a192=10138;e.a166=10139;e.a167=10140;e.a168=10141;e.a169=10142;e.a170=10143;e.a171=10144;e.a172=10145;e.a173=10146;e.a162=10147;e.a174=10148;e.a175=10149;e.a176=10150;e.a177=10151;e.a178=10152;e.a179=10153;e.a193=10154;e.a180=10155;e.a199=10156;e.a181=10157;e.a200=10158;e.a182=10159;e.a201=10161;e.a183=10162;e.a184=10163;e.a197=10164;e.a185=10165;e.a194=10166;e.a198=10167;e.a186=10168;e.a195=10169;e.a187=10170;e.a188=10171;e.a189=10172;e.a190=10173;e.a191=10174;e.a89=10088;e.a90=10089;e.a93=10090;e.a94=10091;e.a91=10092;e.a92=10093;e.a205=10094;e.a85=10095;e.a206=10096;e.a86=10097;e.a87=10098;e.a88=10099;e.a95=10100;e.a96=10101;e[".notdef"]=0});t.getGlyphsUnicode=n;t.getDingbatsGlyphsUnicode=s},function(e,t,a){"use strict";function r(e){i=e}var i,n=a(0),s=a(1),o=a(33),c=n.UNSUPPORTED_FEATURES,l=n.InvalidPDFException,h=n.MessageHandler,u=n.MissingPDFException,f=n.UnexpectedResponseException,d=n.PasswordException,g=n.UnknownErrorException,p=n.XRefParseException,m=n.arrayByteLength,b=n.arraysToBytes,v=n.assert,y=n.createPromiseCapability,k=n.info,w=n.warn,C=n.setVerbosityLevel,x=n.isNodeJS,S=s.Ref,A=o.LocalPdfManager,I=o.NetworkPdfManager,B=function(){function e(e){this.name=e;this.terminated=!1;this._capability=y()}e.prototype={get finished(){return this._capability.promise},finish:function(){this._capability.resolve()},terminate:function(){this.terminated=!0},ensureNotTerminated:function(){if(this.terminated)throw new Error("Worker task was terminated")}};return e}(),R=function(){function e(e,t){this._queuedChunks=[];var a=e.initialData;a&&a.length>0&&this._queuedChunks.push(a);this._msgHandler=t;this._isRangeSupported=!e.disableRange;this._isStreamingSupported=!e.disableStream;this._contentLength=e.length;this._fullRequestReader=null;this._rangeReaders=[];t.on("OnDataRange",this._onReceiveData.bind(this));t.on("OnDataProgress",this._onProgress.bind(this))}function t(e,t){this._stream=e;this._done=!1;this._queuedChunks=t||[];this._requests=[];this._headersReady=Promise.resolve();e._fullRequestReader=this;this.onProgress=null}function a(e,t,a){this._stream=e;this._begin=t;this._end=a;this._queuedChunk=null;this._requests=[];this._done=!1;this.onProgress=null}e.prototype={_onReceiveData:function(e){if(void 0===e.begin)this._fullRequestReader?this._fullRequestReader._enqueue(e.chunk):this._queuedChunks.push(e.chunk);else{var t=this._rangeReaders.some(function(t){if(t._begin!==e.begin)return!1;t._enqueue(e.chunk);return!0});v(t)}},_onProgress:function(e){if(this._rangeReaders.length>0){var t=this._rangeReaders[0];t.onProgress&&t.onProgress({loaded:e.loaded})}},_removeRangeReader:function(e){var t=this._rangeReaders.indexOf(e);t>=0&&this._rangeReaders.splice(t,1)},getFullReader:function(){v(!this._fullRequestReader);var e=this._queuedChunks;this._queuedChunks=null;return new t(this,e)},getRangeReader:function(e,t){var r=new a(this,e,t);this._msgHandler.send("RequestDataRange",{begin:e,end:t});this._rangeReaders.push(r);return r},cancelAllRequests:function(e){this._fullRequestReader&&this._fullRequestReader.cancel(e);this._rangeReaders.slice(0).forEach(function(t){t.cancel(e)})}};t.prototype={_enqueue:function(e){if(!this._done)if(this._requests.length>0){var t=this._requests.shift();t.resolve({value:e,done:!1})}else this._queuedChunks.push(e)},get headersReady(){return this._headersReady},get isRangeSupported(){return this._stream._isRangeSupported},get isStreamingSupported(){return this._stream._isStreamingSupported},get contentLength(){return this._stream._contentLength},read:function(){if(this._queuedChunks.length>0){var e=this._queuedChunks.shift();return Promise.resolve({value:e,done:!1})}if(this._done)return Promise.resolve({value:void 0,done:!0});var t=y();this._requests.push(t);return t.promise},cancel:function(e){this._done=!0;this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[]}};a.prototype={_enqueue:function(e){if(!this._done){if(0===this._requests.length)this._queuedChunk=e;else{this._requests.shift().resolve({value:e,done:!1});this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[]}this._done=!0;this._stream._removeRangeReader(this)}},get isStreamingSupported(){return!1},read:function(){if(this._queuedChunk)return Promise.resolve({value:this._queuedChunk,done:!1});if(this._done)return Promise.resolve({value:void 0,done:!0});var e=y();this._requests.push(e);return e.promise},cancel:function(e){this._done=!0;this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[];this._stream._removeRangeReader(this)}};return e}(),T={setup:function(e,t){var a=!1;e.on("test",function(t){if(!a){a=!0;if(t instanceof Uint8Array){var r=255===t[0];e.postMessageTransfers=r;var i=new XMLHttpRequest,n="response"in i;try{i.responseType}catch(e){n=!1}n?e.send("test",{supportTypedArray:!0,supportTransfers:r}):e.send("test",!1)}else e.send("test","main",!1)}});e.on("configure",function(e){C(e.verbosity)});e.on("GetDocRequest",function(e){return T.createDocumentHandler(e,t)})},createDocumentHandler:function(e,t){function a(){if(T)throw new Error("Worker was terminated")}function r(e){P.push(e)}function n(e){e.finish();var t=P.indexOf(e);P.splice(t,1)}function s(e){var t=y(),a=function(){var e=x.ensureDoc("numPages"),a=x.ensureDoc("fingerprint"),i=x.ensureXRef("encrypt");Promise.all([e,a,i]).then(function(e){var a={numPages:e[0],fingerprint:e[1],encrypted:!!e[2]};t.resolve(a)},r)},r=function(e){t.reject(e)};x.ensureDoc("checkHeader",[]).then(function(){x.ensureDoc("parseStartXRef",[]).then(function(){x.ensureDoc("parse",[e]).then(a,r)},r)},r);return t.promise}function o(e,t){var r,n=y(),s=e.source;if(s.data){try{r=new A(M,s.data,s.password,t,E);n.resolve(r)}catch(e){n.reject(e)}return n.promise}var o;try{if(s.chunkedViewerLoading)o=new R(s,D);else{v(i,"pdfjs/core/network module is not loaded");o=new i(e)}}catch(e){n.reject(e);return n.promise}var c=o.getFullReader();c.headersReady.then(function(){c.isStreamingSupported&&c.isRangeSupported||(c.onProgress=function(e){D.send("DocProgress",{loaded:e.loaded,total:e.total})});if(c.isRangeSupported){var e=s.disableAutoFetch||c.isStreamingSupported;r=new I(M,o,{msgHandler:D,url:s.url,password:s.password,length:c.contentLength,disableAutoFetch:e,rangeChunkSize:s.rangeChunkSize},t,E);n.resolve(r);O=null}}).catch(function(e){n.reject(e);O=null});var l=[],h=0,u=function(){var e=b(l);s.length&&e.length!==s.length&&w("reported HTTP length is different from actual");try{r=new A(M,e,s.password,t,E);n.resolve(r)}catch(e){n.reject(e)}l=[]};new Promise(function(e,t){var i=function(e){try{a();if(e.done){r||u();O=null;return}var n=e.value;h+=m(n);c.isStreamingSupported||D.send("DocProgress",{loaded:h,total:Math.max(h,c.contentLength||0)});r?r.sendProgressiveData(n):l.push(n);c.read().then(i,t)}catch(e){t(e)}};c.read().then(i,t)}).catch(function(e){n.reject(e);O=null});O=function(){o.cancelAllRequests("abort")};return n.promise}function C(e){function t(e){a();D.send("GetDoc",{pdfInfo:e})}function i(e){if(e instanceof d){var t=new B("PasswordException: response "+e.code);r(t);D.sendWithPromise("PasswordRequest",e).then(function(e){n(t);x.updatePassword(e.password);c()}).catch(function(e){n(t);D.send("PasswordException",e)}.bind(null,e))}else e instanceof l?D.send("InvalidPDF",e):e instanceof u?D.send("MissingPDF",e):e instanceof f?D.send("UnexpectedResponse",e):D.send("UnknownError",new g(e.message,e.toString()))}function c(){a();s(!1).then(t,function(e){a();if(e instanceof p){x.requestLoadedStream();x.onLoadedStream().then(function(){a();s(!0).then(t,i)})}else i(e)},i)}a();o(e,{forceDataSchema:e.disableCreateObjectURL,maxImageSize:void 0===e.maxImageSize?-1:e.maxImageSize,disableFontFace:e.disableFontFace,disableNativeImageDecoder:e.disableNativeImageDecoder}).then(function(e){if(T){e.terminate();throw new Error("Worker was terminated")}x=e;D.send("PDFManagerReady",null);x.onLoadedStream().then(function(e){D.send("DataLoaded",{length:e.bytes.byteLength})})}).then(c,i)}var x,T=!1,O=null,P=[],M=e.docId,E=e.docBaseUrl,L=e.docId+"_worker",D=new h(L,M,t);D.postMessageTransfers=e.postMessageTransfers;D.on("GetPage",function(e){return x.getPage(e.pageIndex).then(function(e){var t=x.ensure(e,"rotate"),a=x.ensure(e,"ref"),r=x.ensure(e,"userUnit"),i=x.ensure(e,"view");return Promise.all([t,a,r,i]).then(function(e){return{rotate:e[0],ref:e[1],userUnit:e[2],view:e[3]}})})});D.on("GetPageIndex",function(e){var t=new S(e.ref.num,e.ref.gen);return x.pdfDocument.catalog.getPageIndex(t)});D.on("GetDestinations",function(e){return x.ensureCatalog("destinations")});D.on("GetDestination",function(e){return x.ensureCatalog("getDestination",[e.id])});D.on("GetPageLabels",function(e){return x.ensureCatalog("pageLabels")});D.on("GetAttachments",function(e){return x.ensureCatalog("attachments")});D.on("GetJavaScript",function(e){return x.ensureCatalog("javaScript")});D.on("GetOutline",function(e){return x.ensureCatalog("documentOutline")});D.on("GetMetadata",function(e){return Promise.all([x.ensureDoc("documentInfo"),x.ensureCatalog("metadata")])});D.on("GetData",function(e){x.requestLoadedStream();return x.onLoadedStream().then(function(e){return e.bytes})});D.on("GetStats",function(e){return x.pdfDocument.xref.stats});D.on("GetAnnotations",function(e){return x.getPage(e.pageIndex).then(function(t){return x.ensure(t,"getAnnotationsData",[e.intent])})});D.on("RenderPageRequest",function(e){var t=e.pageIndex;x.getPage(t).then(function(a){var i=new B("RenderPageRequest: page "+t);r(i);var s=t+1,o=Date.now();a.getOperatorList(D,i,e.intent,e.renderInteractiveForms).then(function(e){n(i);k("page="+s+" - getOperatorList: time="+(Date.now()-o)+"ms, len="+e.totalLength)},function(t){n(i);if(!i.terminated){D.send("UnsupportedFeature",{featureId:c.unknown});var a,r="worker.js: while trying to getPage() and getOperatorList()";a="string"==typeof t?{message:t,stack:r}:"object"==typeof t?{message:t.message||t.toString(),stack:t.stack||r}:{message:"Unknown exception type: "+typeof t,stack:r};D.send("PageError",{pageNum:s,error:a,intent:e.intent})}})})},this);D.on("GetTextContent",function(e){var t=e.pageIndex,a=e.normalizeWhitespace,i=e.combineTextItems;return x.getPage(t).then(function(e){var s=new B("GetTextContent: page "+t);r(s);var o=t+1,c=Date.now();return e.extractTextContent(D,s,a,i).then(function(e){n(s);k("text indexing: page="+o+" - time="+(Date.now()-c)+"ms");return e},function(e){n(s);if(!s.terminated)throw e})})});D.on("Cleanup",function(e){return x.cleanup()});D.on("Terminate",function(e){T=!0;if(x){x.terminate();x=null}O&&O();var t=[];P.forEach(function(e){t.push(e.finished);e.terminate()});return Promise.all(t).then(function(){D.destroy();D=null})});D.on("Ready",function(t){C(e);e=null});return L}};"undefined"!=typeof window||x()||function(){var e=new h("worker","main",self);T.setup(e,self);e.send("ready",null)}();t.setPDFNetworkStreamClass=r;t.WorkerTask=B;t.WorkerMessageHandler=T},function(e,t,a){"use strict";var r;r=function(){return this}();try{r=r||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(r=window)}e.exports=r},function(e,t,a){"use strict";var r=function(){function e(e,t,a){this.data=e;this.bp=t;this.dataEnd=a;this.chigh=e[t];this.clow=0;this.byteIn();this.chigh=this.chigh<<7&65535|this.clow>>9&127;this.clow=this.clow<<7&65535;this.ct-=7;this.a=32768}var t=[{qe:22017,nmps:1,nlps:1,switchFlag:1},{qe:13313,nmps:2,nlps:6,switchFlag:0},{qe:6145,nmps:3,nlps:9,switchFlag:0},{qe:2753,nmps:4,nlps:12,switchFlag:0},{qe:1313,nmps:5,nlps:29,switchFlag:0},{qe:545,nmps:38,nlps:33,switchFlag:0},{qe:22017,nmps:7,nlps:6,switchFlag:1},{qe:21505,nmps:8,nlps:14,switchFlag:0},{qe:18433,nmps:9,nlps:14,switchFlag:0},{qe:14337,nmps:10,nlps:14,switchFlag:0},{qe:12289,nmps:11,nlps:17,switchFlag:0},{qe:9217,nmps:12,nlps:18,switchFlag:0},{qe:7169,nmps:13,nlps:20,switchFlag:0},{qe:5633,nmps:29,nlps:21,switchFlag:0},{qe:22017,nmps:15,nlps:14,switchFlag:1},{qe:21505,nmps:16,nlps:14,switchFlag:0},{qe:20737,nmps:17,nlps:15,switchFlag:0},{qe:18433,nmps:18,nlps:16,switchFlag:0},{qe:14337,nmps:19,nlps:17,switchFlag:0},{qe:13313,nmps:20,nlps:18,switchFlag:0},{qe:12289,nmps:21,nlps:19,switchFlag:0},{qe:10241,nmps:22,nlps:19,switchFlag:0},{qe:9217,nmps:23,nlps:20,switchFlag:0},{qe:8705,nmps:24,nlps:21,switchFlag:0},{qe:7169,nmps:25,nlps:22,switchFlag:0},{qe:6145,nmps:26,nlps:23,switchFlag:0},{qe:5633,nmps:27,nlps:24,switchFlag:0},{qe:5121,nmps:28,nlps:25,switchFlag:0},{qe:4609,nmps:29,nlps:26,switchFlag:0},{qe:4353,nmps:30,nlps:27,switchFlag:0},{qe:2753,nmps:31,nlps:28,switchFlag:0},{qe:2497,nmps:32,nlps:29,switchFlag:0},{qe:2209,nmps:33,nlps:30,switchFlag:0},{qe:1313,nmps:34,nlps:31,switchFlag:0},{qe:1089,nmps:35,nlps:32,switchFlag:0},{qe:673,nmps:36,nlps:33,switchFlag:0},{qe:545,nmps:37,nlps:34,switchFlag:0},{qe:321,nmps:38,nlps:35,switchFlag:0},{qe:273,nmps:39,nlps:36,switchFlag:0},{qe:133,nmps:40,nlps:37,switchFlag:0},{qe:73,nmps:41,nlps:38,switchFlag:0},{qe:37,nmps:42,nlps:39,switchFlag:0},{qe:21,nmps:43,nlps:40,switchFlag:0},{qe:9,nmps:44,nlps:41,switchFlag:0},{qe:5,nmps:45,nlps:42,switchFlag:0},{qe:1,nmps:45,nlps:43,switchFlag:0},{qe:22017,nmps:46,nlps:46,switchFlag:0}];e.prototype={byteIn:function(){var e=this.data,t=this.bp;if(255===e[t]){if(e[t+1]>143){this.clow+=65280;this.ct=8}else{t++;this.clow+=e[t]<<9;this.ct=7;this.bp=t}}else{t++;this.clow+=t<this.dataEnd?e[t]<<8:65280;this.ct=8;this.bp=t}if(this.clow>65535){this.chigh+=this.clow>>16;this.clow&=65535}},readBit:function(e,a){var r,i=e[a]>>1,n=1&e[a],s=t[i],o=s.qe,c=this.a-o;if(this.chigh<o)if(c<o){c=o;r=n;i=s.nmps}else{c=o;r=1^n;1===s.switchFlag&&(n=r);i=s.nlps}else{this.chigh-=o;if(0!=(32768&c)){this.a=c;return n}if(c<o){r=1^n;1===s.switchFlag&&(n=r);i=s.nlps}else{r=n;i=s.nmps}}do{0===this.ct&&this.byteIn();c<<=1;this.chigh=this.chigh<<1&65535|this.clow>>15&1;this.clow=this.clow<<1&65535;this.ct--}while(0==(32768&c));this.a=c;e[a]=i<<1|n;return r}};return e}();t.ArithmeticDecoder=r},function(e,t,a){"use strict";var r=a(0),i=a(22),n=a(4),s=r.error,o=r.info,c=r.bytesToString,l=r.warn,h=r.isArray,u=r.Util,f=r.stringToBytes,d=r.assert,g=i.ISOAdobeCharset,p=i.ExpertCharset,m=i.ExpertSubsetCharset,b=n.StandardEncoding,v=n.ExpertEncoding,y=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall","001.000","001.001","001.002","001.003","Black","Bold","Book","Light","Medium","Regular","Roman","Semibold"],k=function(){function e(e,t,a){this.bytes=e.getBytes();this.properties=t;this.seacAnalysisEnabled=!!a}var t=[null,{id:"hstem",min:2,stackClearing:!0,stem:!0},null,{id:"vstem",min:2,stackClearing:!0,stem:!0},{id:"vmoveto",min:1,stackClearing:!0},{id:"rlineto",min:2,resetStack:!0},{id:"hlineto",min:1,resetStack:!0},{id:"vlineto",min:1,resetStack:!0},{id:"rrcurveto",min:6,resetStack:!0},null,{id:"callsubr",min:1,undefStack:!0},{id:"return",min:0,undefStack:!0},null,null,{id:"endchar",min:0,stackClearing:!0},null,null,null,{id:"hstemhm",min:2,stackClearing:!0,stem:!0},{id:"hintmask",min:0,stackClearing:!0},{id:"cntrmask",min:0,stackClearing:!0},{id:"rmoveto",min:2,stackClearing:!0},{id:"hmoveto",min:1,stackClearing:!0},{id:"vstemhm",min:2,stackClearing:!0,stem:!0},{id:"rcurveline",min:8,resetStack:!0},{id:"rlinecurve",min:8,resetStack:!0},{id:"vvcurveto",min:4,resetStack:!0},{id:"hhcurveto",min:4,resetStack:!0},null,{id:"callgsubr",min:1,undefStack:!0},{id:"vhcurveto",min:4,resetStack:!0},{id:"hvcurveto",min:4,resetStack:!0}],a=[null,null,null,{id:"and",min:2,stackDelta:-1},{id:"or",min:2,stackDelta:-1},{id:"not",min:1,stackDelta:0},null,null,null,{id:"abs",min:1,stackDelta:0},{id:"add",min:2,stackDelta:-1,stackFn:function(e,t){e[t-2]=e[t-2]+e[t-1]}},{id:"sub",min:2,stackDelta:-1,stackFn:function(e,t){e[t-2]=e[t-2]-e[t-1]}},{id:"div",min:2,stackDelta:-1,stackFn:function(e,t){e[t-2]=e[t-2]/e[t-1]}},null,{id:"neg",min:1,stackDelta:0,stackFn:function(e,t){e[t-1]=-e[t-1]}},{id:"eq",min:2,stackDelta:-1},null,null,{id:"drop",min:1,stackDelta:-1},null,{id:"put",min:2,stackDelta:-2},{id:"get",min:1,stackDelta:0},{id:"ifelse",min:4,stackDelta:-3},{id:"random",min:0,stackDelta:1},{id:"mul",min:2,stackDelta:-1,stackFn:function(e,t){e[t-2]=e[t-2]*e[t-1]}},null,{id:"sqrt",min:1,stackDelta:0},{id:"dup",min:1,stackDelta:1},{id:"exch",min:2,stackDelta:0},{id:"index",min:2,stackDelta:0},{id:"roll",min:3,stackDelta:-2},null,null,null,{id:"hflex",min:7,resetStack:!0},{id:"flex",min:13,resetStack:!0},{id:"hflex1",min:9,resetStack:!0},{id:"flex1",min:11,resetStack:!0}];e.prototype={parse:function(){var e=this.properties,t=new w;this.cff=t;var a=this.parseHeader(),r=this.parseIndex(a.endPos),i=this.parseIndex(r.endPos),n=this.parseIndex(i.endPos),s=this.parseIndex(n.endPos),o=this.parseDict(i.obj.get(0)),c=this.createDict(I,o,t.strings);t.header=a.obj;t.names=this.parseNameIndex(r.obj);t.strings=this.parseStringIndex(n.obj);t.topDict=c;t.globalSubrIndex=s.obj;this.parsePrivateDict(t.topDict);t.isCIDFont=c.hasName("ROS");var l=c.getByName("CharStrings"),h=this.parseIndex(l).obj,u=c.getByName("FontMatrix");u&&(e.fontMatrix=u);var f=c.getByName("FontBBox");if(f){e.ascent=Math.max(f[3],f[1]);e.descent=Math.min(f[1],f[3]);e.ascentScaled=!0}var d,g;if(t.isCIDFont){for(var p=this.parseIndex(c.getByName("FDArray")).obj,m=0,b=p.count;m<b;++m){var v=p.get(m),y=this.createDict(I,this.parseDict(v),t.strings);this.parsePrivateDict(y);t.fdArray.push(y)}g=null;d=this.parseCharsets(c.getByName("charset"),h.count,t.strings,!0);t.fdSelect=this.parseFDSelect(c.getByName("FDSelect"),h.count)}else{d=this.parseCharsets(c.getByName("charset"),h.count,t.strings,!1);g=this.parseEncoding(c.getByName("Encoding"),e,t.strings,d.charset)}t.charset=d;t.encoding=g;var k=this.parseCharStrings(h,c.privateDict.subrsIndex,s.obj,t.fdSelect,t.fdArray);t.charStrings=k.charStrings;t.seacs=k.seacs;t.widths=k.widths;return t},parseHeader:function(){for(var e=this.bytes,t=e.length,a=0;a<t&&1!==e[a];)++a;if(a>=t)s("Invalid CFF header");else if(0!==a){o("cff data is shifted");e=e.subarray(a);this.bytes=e}var r=e[0],i=e[1],n=e[2],c=e[3];return{obj:new C(r,i,n,c),endPos:n}},parseDict:function(e){function t(){for(var t="",r=["0","1","2","3","4","5","6","7","8","9",".","E","E-",null,"-"],i=e.length;a<i;){var n=e[a++],s=n>>4,o=15&n;if(15===s)break;t+=r[s];if(15===o)break;t+=r[o]}return parseFloat(t)}var a=0,r=[],i=[];a=0;for(var n=e.length;a<n;){var s=e[a];if(s<=21){12===s&&(s=s<<8|e[++a]);i.push([s,r]);r=[];++a}else r.push(function(){var r=e[a++];if(30===r)return t();if(28===r){r=e[a++];r=(r<<24|e[a++]<<16)>>16;return r}if(29===r){r=e[a++];r=r<<8|e[a++];r=r<<8|e[a++];r=r<<8|e[a++];return r}if(r>=32&&r<=246)return r-139;if(r>=247&&r<=250)return 256*(r-247)+e[a++]+108;if(r>=251&&r<=254)return-256*(r-251)-e[a++]-108;l('CFFParser_parseDict: "'+r+'" is a reserved command.');return NaN}())}return i},parseIndex:function(e){var t,a,r=new S,i=this.bytes,n=i[e++]<<8|i[e++],s=[],o=e;if(0!==n){var c=i[e++],l=e+(n+1)*c-1;for(t=0,a=n+1;t<a;++t){for(var h=0,u=0;u<c;++u){h<<=8;h+=i[e++]}s.push(l+h)}o=s[n]}for(t=0,a=s.length-1;t<a;++t){var f=s[t],d=s[t+1];r.add(i.subarray(f,d))}return{obj:r,endPos:o}},parseNameIndex:function(e){for(var t=[],a=0,r=e.count;a<r;++a){for(var i=e.get(a),n=Math.min(i.length,127),s=[],o=0;o<n;++o){var l=i[o];0!==o||0!==l?s[o]=l<33||l>126||91===l||93===l||40===l||41===l||123===l||125===l||60===l||62===l||47===l||37===l||35===l?95:l:s[o]=l}t.push(c(s))}return t},parseStringIndex:function(e){for(var t=new x,a=0,r=e.count;a<r;++a){var i=e.get(a);t.add(c(i))}return t},createDict:function(e,t,a){for(var r=new e(a),i=0,n=t.length;i<n;++i){var s=t[i],o=s[0],c=s[1];r.setByKey(o,c)}return r},parseCharString:function(e,r,i,n){if(!r||e.callDepth>10)return!1;for(var s=e.stackSize,o=e.stack,c=r.length,h=0;h<c;){var u=r[h++],f=null;if(12===u){var d=r[h++];if(0===d){r[h-2]=139;r[h-1]=22;s=0}else f=a[d]}else if(28===u){o[s]=(r[h]<<24|r[h+1]<<16)>>16;h+=2;s++}else if(14===u){if(s>=4){s-=4;if(this.seacAnalysisEnabled){e.seac=o.slice(s,s+4);return!1}}f=t[u]}else if(u>=32&&u<=246){o[s]=u-139;s++}else if(u>=247&&u<=254){o[s]=u<251?(u-247<<8)+r[h]+108:-(u-251<<8)-r[h]-108;h++;s++}else if(255===u){o[s]=(r[h]<<24|r[h+1]<<16|r[h+2]<<8|r[h+3])/65536;h+=4;s++}else if(19===u||20===u){e.hints+=s>>1;h+=e.hints+7>>3;s%=2;f=t[u]}else{if(10===u||29===u){var g;g=10===u?i:n;if(!g){f=t[u];l("Missing subrsIndex for "+f.id);return!1}var p=32768;g.count<1240?p=107:g.count<33900&&(p=1131);var m=o[--s]+p;if(m<0||m>=g.count||isNaN(m)){f=t[u];l("Out of bounds subrIndex for "+f.id);return!1}e.stackSize=s;e.callDepth++;var b=this.parseCharString(e,g.get(m),i,n);if(!b)return!1;e.callDepth--;s=e.stackSize;continue}if(11===u){e.stackSize=s;return!0}f=t[u]}if(f){f.stem&&(e.hints+=s>>1);if("min"in f&&!e.undefStack&&s<f.min){l("Not enough parameters for "+f.id+"; actual: "+s+", expected: "+f.min);return!1}if(e.firstStackClearing&&f.stackClearing){e.firstStackClearing=!1;s-=f.min;s>=2&&f.stem?s%=2:s>1&&l("Found too many parameters for stack-clearing command");s>0&&o[s-1]>=0&&(e.width=o[s-1])}if("stackDelta"in f){"stackFn"in f&&f.stackFn(o,s);s+=f.stackDelta}else if(f.stackClearing)s=0;else if(f.resetStack){s=0;e.undefStack=!1}else if(f.undefStack){s=0;e.undefStack=!0;e.firstStackClearing=!1}}}e.stackSize=s;return!0},parseCharStrings:function(e,t,a,r,i){for(var n=[],s=[],o=e.count,c=0;c<o;c++){var h=e.get(c),u={callDepth:0,stackSize:0,stack:[],undefStack:!0,hints:0,firstStackClearing:!0,seac:null,width:null},f=!0,d=null;if(r&&i.length){var g=r.getFDIndex(c);if(-1===g){l("Glyph index is not in fd select.");f=!1}if(g>=i.length){l("Invalid fd index for glyph index.");f=!1}f&&(d=i[g].privateDict.subrsIndex)}else t&&(d=t);f&&(f=this.parseCharString(u,h,d,a));null!==u.width&&(s[c]=u.width);null!==u.seac&&(n[c]=u.seac);f||e.set(c,new Uint8Array([14]))}return{charStrings:e,seacs:n,widths:s}},emptyPrivateDictionary:function(e){var t=this.createDict(B,[],e.strings);e.setByKey(18,[0,0]);e.privateDict=t},parsePrivateDict:function(e){if(e.hasName("Private")){var t=e.getByName("Private");if(h(t)&&2===t.length){var a=t[0],r=t[1];if(0===a||r>=this.bytes.length)this.emptyPrivateDictionary(e);else{var i=r+a,n=this.bytes.subarray(r,i),s=this.parseDict(n),o=this.createDict(B,s,e.strings);e.privateDict=o;if(o.getByName("Subrs")){var c=o.getByName("Subrs"),l=r+c;if(0===c||l>=this.bytes.length)this.emptyPrivateDictionary(e);else{var u=this.parseIndex(l);o.subrsIndex=u.obj}}}}else e.removeByName("Private")}else this.emptyPrivateDictionary(e)},parseCharsets:function(e,t,a,r){if(0===e)return new T(!0,R.ISO_ADOBE,g);if(1===e)return new T(!0,R.EXPERT,p);if(2===e)return new T(!0,R.EXPERT_SUBSET,m);var i,n,o,c=this.bytes,l=e,h=c[e++],u=[".notdef"];t-=1;switch(h){case 0:for(o=0;o<t;o++){i=c[e++]<<8|c[e++];u.push(r?i:a.get(i))}break;case 1:for(;u.length<=t;){i=c[e++]<<8|c[e++];n=c[e++];for(o=0;o<=n;o++)u.push(r?i++:a.get(i++))}break;case 2:for(;u.length<=t;){i=c[e++]<<8|c[e++];n=c[e++]<<8|c[e++];for(o=0;o<=n;o++)u.push(r?i++:a.get(i++))}break;default:s("Unknown charset format")}var f=e,d=c.subarray(l,f)
+;return new T(!1,h,u,d)},parseEncoding:function(e,t,a,r){var i,n,o,c=Object.create(null),l=this.bytes,h=!1,u=null;if(0===e||1===e){h=!0;i=e;var f=e?v:b;for(n=0,o=r.length;n<o;n++){var d=f.indexOf(r[n]);-1!==d&&(c[d]=n)}}else{var g=e;i=l[e++];switch(127&i){case 0:var p=l[e++];for(n=1;n<=p;n++)c[l[e++]]=n;break;case 1:var m=l[e++],y=1;for(n=0;n<m;n++)for(var k=l[e++],w=l[e++],C=k;C<=k+w;C++)c[C]=y++;break;default:s("Unknown encoding format: "+i+" in CFF")}var x=e;if(128&i){l[g]&=127;!function(){var t=l[e++];for(n=0;n<t;n++){var i=l[e++],s=(l[e++]<<8)+(255&l[e++]);c[i]=r.indexOf(a.get(s))}}()}u=l.subarray(g,x)}i&=127;return new O(h,i,c,u)},parseFDSelect:function(e,t){var a,r,i=e,n=this.bytes,o=n[e++],c=[],h=!1;switch(o){case 0:for(r=0;r<t;++r){var u=n[e++];c.push(u)}a=n.subarray(i,e);break;case 3:var f=n[e++]<<8|n[e++];for(r=0;r<f;++r){var g=n[e++]<<8|n[e++];if(0===r&&0!==g){l("parseFDSelect: The first range must have a first GID of 0 -- trying to recover.");h=!0;g=0}for(var p=n[e++],m=n[e]<<8|n[e+1],b=g;b<m;++b)c.push(p)}e+=2;a=n.subarray(i,e);h&&(a[3]=a[4]=0);break;default:s('parseFDSelect: Unknown format "'+o+'".')}d(c.length===t,"parseFDSelect: Invalid font data.");return new P(c,a)}};return e}(),w=function(){function e(){this.header=null;this.names=[];this.topDict=null;this.strings=new x;this.globalSubrIndex=null;this.encoding=null;this.charset=null;this.charStrings=null;this.fdArray=[];this.fdSelect=null;this.isCIDFont=!1}return e}(),C=function(){function e(e,t,a,r){this.major=e;this.minor=t;this.hdrSize=a;this.offSize=r}return e}(),x=function(){function e(){this.strings=[]}e.prototype={get:function(e){return e>=0&&e<=390?y[e]:e-391<=this.strings.length?this.strings[e-391]:y[0]},add:function(e){this.strings.push(e)},get count(){return this.strings.length}};return e}(),S=function(){function e(){this.objects=[];this.length=0}e.prototype={add:function(e){this.length+=e.length;this.objects.push(e)},set:function(e,t){this.length+=t.length-this.objects[e].length;this.objects[e]=t},get:function(e){return this.objects[e]},get count(){return this.objects.length}};return e}(),A=function(){function e(e,t){this.keyToNameMap=e.keyToNameMap;this.nameToKeyMap=e.nameToKeyMap;this.defaults=e.defaults;this.types=e.types;this.opcodes=e.opcodes;this.order=e.order;this.strings=t;this.values=Object.create(null)}e.prototype={setByKey:function(e,t){if(!(e in this.keyToNameMap))return!1;var a=t.length;if(0===a)return!0;for(var r=0;r<a;r++)if(isNaN(t[r])){l('Invalid CFFDict value: "'+t+'" for key "'+e+'".');return!0}var i=this.types[e];"num"!==i&&"sid"!==i&&"offset"!==i||(t=t[0]);this.values[e]=t;return!0},setByName:function(e,t){e in this.nameToKeyMap||s('Invalid dictionary name "'+e+'"');this.values[this.nameToKeyMap[e]]=t},hasName:function(e){return this.nameToKeyMap[e]in this.values},getByName:function(e){e in this.nameToKeyMap||s('Invalid dictionary name "'+e+'"');var t=this.nameToKeyMap[e];return t in this.values?this.values[t]:this.defaults[t]},removeByName:function(e){delete this.values[this.nameToKeyMap[e]]}};e.createTables=function(e){for(var t={keyToNameMap:{},nameToKeyMap:{},defaults:{},types:{},opcodes:{},order:[]},a=0,r=e.length;a<r;++a){var i=e[a],n=h(i[0])?(i[0][0]<<8)+i[0][1]:i[0];t.keyToNameMap[n]=i[1];t.nameToKeyMap[i[1]]=n;t.types[n]=i[2];t.defaults[n]=i[3];t.opcodes[n]=h(i[0])?i[0]:[i[0]];t.order.push(n)}return t};return e}(),I=function(){function e(e){null===a&&(a=A.createTables(t));A.call(this,a,e);this.privateDict=null}var t=[[[12,30],"ROS",["sid","sid","num"],null],[[12,20],"SyntheticBase","num",null],[0,"version","sid",null],[1,"Notice","sid",null],[[12,0],"Copyright","sid",null],[2,"FullName","sid",null],[3,"FamilyName","sid",null],[4,"Weight","sid",null],[[12,1],"isFixedPitch","num",0],[[12,2],"ItalicAngle","num",0],[[12,3],"UnderlinePosition","num",-100],[[12,4],"UnderlineThickness","num",50],[[12,5],"PaintType","num",0],[[12,6],"CharstringType","num",2],[[12,7],"FontMatrix",["num","num","num","num","num","num"],[.001,0,0,.001,0,0]],[13,"UniqueID","num",null],[5,"FontBBox",["num","num","num","num"],[0,0,0,0]],[[12,8],"StrokeWidth","num",0],[14,"XUID","array",null],[15,"charset","offset",0],[16,"Encoding","offset",0],[17,"CharStrings","offset",0],[18,"Private",["offset","offset"],null],[[12,21],"PostScript","sid",null],[[12,22],"BaseFontName","sid",null],[[12,23],"BaseFontBlend","delta",null],[[12,31],"CIDFontVersion","num",0],[[12,32],"CIDFontRevision","num",0],[[12,33],"CIDFontType","num",0],[[12,34],"CIDCount","num",8720],[[12,35],"UIDBase","num",null],[[12,37],"FDSelect","offset",null],[[12,36],"FDArray","offset",null],[[12,38],"FontName","sid",null]],a=null;e.prototype=Object.create(A.prototype);return e}(),B=function(){function e(e){null===a&&(a=A.createTables(t));A.call(this,a,e);this.subrsIndex=null}var t=[[6,"BlueValues","delta",null],[7,"OtherBlues","delta",null],[8,"FamilyBlues","delta",null],[9,"FamilyOtherBlues","delta",null],[[12,9],"BlueScale","num",.039625],[[12,10],"BlueShift","num",7],[[12,11],"BlueFuzz","num",1],[10,"StdHW","num",null],[11,"StdVW","num",null],[[12,12],"StemSnapH","delta",null],[[12,13],"StemSnapV","delta",null],[[12,14],"ForceBold","num",0],[[12,17],"LanguageGroup","num",0],[[12,18],"ExpansionFactor","num",.06],[[12,19],"initialRandomSeed","num",0],[20,"defaultWidthX","num",0],[21,"nominalWidthX","num",0],[19,"Subrs","offset",null]],a=null;e.prototype=Object.create(A.prototype);return e}(),R={ISO_ADOBE:0,EXPERT:1,EXPERT_SUBSET:2},T=function(){function e(e,t,a,r){this.predefined=e;this.format=t;this.charset=a;this.raw=r}return e}(),O=function(){function e(e,t,a,r){this.predefined=e;this.format=t;this.encoding=a;this.raw=r}return e}(),P=function(){function e(e,t){this.fdSelect=e;this.raw=t}e.prototype={getFDIndex:function(e){return e<0||e>=this.fdSelect.length?-1:this.fdSelect[e]}};return e}(),M=function(){function e(){this.offsets=Object.create(null)}e.prototype={isTracking:function(e){return e in this.offsets},track:function(e,t){e in this.offsets&&s("Already tracking location of "+e);this.offsets[e]=t},offset:function(e){for(var t in this.offsets)this.offsets[t]+=e},setEntryLocation:function(e,t,a){e in this.offsets||s("Not tracking location of "+e);for(var r=a.data,i=this.offsets[e],n=0,o=t.length;n<o;++n){var c=5*n+i,l=c+1,h=c+2,u=c+3,f=c+4;29===r[c]&&0===r[l]&&0===r[h]&&0===r[u]&&0===r[f]||s("writing to an offset that is not empty");var d=t[n];r[c]=29;r[l]=d>>24&255;r[h]=d>>16&255;r[u]=d>>8&255;r[f]=255&d}}};return e}(),E=function(){function e(e){this.cff=e}e.prototype={compile:function(){var e=this.cff,t={data:[],length:0,add:function(e){this.data=this.data.concat(e);this.length=this.data.length}},a=this.compileHeader(e.header);t.add(a);var r=this.compileNameIndex(e.names);t.add(r);if(e.isCIDFont&&e.topDict.hasName("FontMatrix")){var i=e.topDict.getByName("FontMatrix");e.topDict.removeByName("FontMatrix");for(var n=0,s=e.fdArray.length;n<s;n++){var o=e.fdArray[n],c=i.slice(0);o.hasName("FontMatrix")&&(c=u.transform(c,o.getByName("FontMatrix")));o.setByName("FontMatrix",c)}}var l=this.compileTopDicts([e.topDict],t.length,e.isCIDFont);t.add(l.output);var h=l.trackers[0],f=this.compileStringIndex(e.strings.strings);t.add(f);var d=this.compileIndex(e.globalSubrIndex);t.add(d);if(e.encoding&&e.topDict.hasName("Encoding"))if(e.encoding.predefined)h.setEntryLocation("Encoding",[e.encoding.format],t);else{var g=this.compileEncoding(e.encoding);h.setEntryLocation("Encoding",[t.length],t);t.add(g)}if(e.charset&&e.topDict.hasName("charset"))if(e.charset.predefined)h.setEntryLocation("charset",[e.charset.format],t);else{var p=this.compileCharset(e.charset);h.setEntryLocation("charset",[t.length],t);t.add(p)}var m=this.compileCharStrings(e.charStrings);h.setEntryLocation("CharStrings",[t.length],t);t.add(m);if(e.isCIDFont){h.setEntryLocation("FDSelect",[t.length],t);var b=this.compileFDSelect(e.fdSelect.raw);t.add(b);l=this.compileTopDicts(e.fdArray,t.length,!0);h.setEntryLocation("FDArray",[t.length],t);t.add(l.output);var v=l.trackers;this.compilePrivateDicts(e.fdArray,v,t)}this.compilePrivateDicts([e.topDict],[h],t);t.add([0]);return t.data},encodeNumber:function(e){return parseFloat(e)!==parseInt(e,10)||isNaN(e)?this.encodeFloat(e):this.encodeInteger(e)},encodeFloat:function(e){var t=e.toString(),a=/\.(\d*?)(?:9{5,20}|0{5,20})\d{0,2}(?:e(.+)|$)/.exec(t);if(a){var r=parseFloat("1e"+((a[2]?+a[2]:0)+a[1].length));t=(Math.round(e*r)/r).toString()}var i,n,s="";for(i=0,n=t.length;i<n;++i){var o=t[i];s+="e"===o?"-"===t[++i]?"c":"b":"."===o?"a":"-"===o?"e":o}s+=1&s.length?"f":"ff";var c=[30];for(i=0,n=s.length;i<n;i+=2)c.push(parseInt(s.substr(i,2),16));return c},encodeInteger:function(e){var t;if(e>=-107&&e<=107)t=[e+139];else if(e>=108&&e<=1131){e-=108;t=[247+(e>>8),255&e]}else if(e>=-1131&&e<=-108){e=-e-108;t=[251+(e>>8),255&e]}else t=e>=-32768&&e<=32767?[28,e>>8&255,255&e]:[29,e>>24&255,e>>16&255,e>>8&255,255&e];return t},compileHeader:function(e){return[e.major,e.minor,e.hdrSize,e.offSize]},compileNameIndex:function(e){for(var t=new S,a=0,r=e.length;a<r;++a)t.add(f(e[a]));return this.compileIndex(t)},compileTopDicts:function(e,t,a){for(var r=[],i=new S,n=0,s=e.length;n<s;++n){var o=e[n];if(a){o.removeByName("CIDFontVersion");o.removeByName("CIDFontRevision");o.removeByName("CIDFontType");o.removeByName("CIDCount");o.removeByName("UIDBase")}var c=new M,l=this.compileDict(o,c);r.push(c);i.add(l);c.offset(t)}i=this.compileIndex(i,r);return{trackers:r,output:i}},compilePrivateDicts:function(e,t,a){for(var r=0,i=e.length;r<i;++r){var n=e[r];d(n.privateDict&&n.hasName("Private"),"There must be an private dictionary.");var s=n.privateDict,o=new M,c=this.compileDict(s,o),l=a.length;o.offset(l);c.length||(l=0);t[r].setEntryLocation("Private",[c.length,l],a);a.add(c);if(s.subrsIndex&&s.hasName("Subrs")){var h=this.compileIndex(s.subrsIndex);o.setEntryLocation("Subrs",[c.length],a);a.add(h)}}},compileDict:function(e,t){for(var a=[],r=e.order,i=0;i<r.length;++i){var n=r[i];if(n in e.values){var o=e.values[n],c=e.types[n];h(c)||(c=[c]);h(o)||(o=[o]);if(0!==o.length){for(var l=0,u=c.length;l<u;++l){var f=c[l],d=o[l];switch(f){case"num":case"sid":a=a.concat(this.encodeNumber(d));break;case"offset":var g=e.keyToNameMap[n];t.isTracking(g)||t.track(g,a.length);a=a.concat([29,0,0,0,0]);break;case"array":case"delta":a=a.concat(this.encodeNumber(d));for(var p=1,m=o.length;p<m;++p)a=a.concat(this.encodeNumber(o[p]));break;default:s("Unknown data type of "+f)}}a=a.concat(e.opcodes[n])}}}return a},compileStringIndex:function(e){for(var t=new S,a=0,r=e.length;a<r;++a)t.add(f(e[a]));return this.compileIndex(t)},compileGlobalSubrIndex:function(){var e=this.cff.globalSubrIndex;this.out.writeByteArray(this.compileIndex(e))},compileCharStrings:function(e){return this.compileIndex(e)},compileCharset:function(e){return this.compileTypedArray(e.raw)},compileEncoding:function(e){return this.compileTypedArray(e.raw)},compileFDSelect:function(e){return this.compileTypedArray(e)},compileTypedArray:function(e){for(var t=[],a=0,r=e.length;a<r;++a)t[a]=e[a];return t},compileIndex:function(e,t){t=t||[];var a=e.objects,r=a.length;if(0===r)return[0,0,0];var i,n=[r>>8&255,255&r],s=1;for(i=0;i<r;++i)s+=a[i].length;var o;o=s<256?1:s<65536?2:s<16777216?3:4;n.push(o);var c=1;for(i=0;i<r+1;i++){1===o?n.push(255&c):2===o?n.push(c>>8&255,255&c):3===o?n.push(c>>16&255,c>>8&255,255&c):n.push(c>>>24&255,c>>16&255,c>>8&255,255&c);a[i]&&(c+=a[i].length)}for(i=0;i<r;i++){t[i]&&t[i].offset(n.length);for(var l=0,h=a[i].length;l<h;l++)n.push(a[i][l])}return n}};return e}();t.CFFStandardStrings=y;t.CFFParser=k;t.CFF=w;t.CFFHeader=C;t.CFFStrings=x;t.CFFIndex=S;t.CFFCharset=T;t.CFFTopDict=I;t.CFFPrivateDict=B;t.CFFCompiler=E},function(e,t,a){"use strict";var r=a(0),i=r.MissingDataException,n=r.arrayByteLength,s=r.arraysToBytes,o=r.assert,c=r.createPromiseCapability,l=r.isInt,h=r.isEmptyObj,u=function(){function e(e,t,a){this.bytes=new Uint8Array(e);this.start=0;this.pos=0;this.end=e;this.chunkSize=t;this.loadedChunks=[];this.numChunksLoaded=0;this.numChunks=Math.ceil(e/t);this.manager=a;this.progressiveDataLength=0;this.lastSuccessfulEnsureByteChunk=-1}e.prototype={getMissingChunks:function(){for(var e=[],t=0,a=this.numChunks;t<a;++t)this.loadedChunks[t]||e.push(t);return e},getBaseStreams:function(){return[this]},allChunksLoaded:function(){return this.numChunksLoaded===this.numChunks},onReceiveData:function(e,t){var a=e+t.byteLength;o(e%this.chunkSize==0,"Bad begin offset: "+e);var r=this.bytes.length;o(a%this.chunkSize==0||a===r,"Bad end offset: "+a);this.bytes.set(new Uint8Array(t),e);var i,n=this.chunkSize,s=Math.floor(e/n),c=Math.floor((a-1)/n)+1;for(i=s;i<c;++i)if(!this.loadedChunks[i]){this.loadedChunks[i]=!0;++this.numChunksLoaded}},onReceiveProgressiveData:function(e){var t=this.progressiveDataLength,a=Math.floor(t/this.chunkSize);this.bytes.set(new Uint8Array(e),t);t+=e.byteLength;this.progressiveDataLength=t;var r,i=t>=this.end?this.numChunks:Math.floor(t/this.chunkSize);for(r=a;r<i;++r)if(!this.loadedChunks[r]){this.loadedChunks[r]=!0;++this.numChunksLoaded}},ensureByte:function(e){var t=Math.floor(e/this.chunkSize);if(t!==this.lastSuccessfulEnsureByteChunk){if(!this.loadedChunks[t])throw new i(e,e+1);this.lastSuccessfulEnsureByteChunk=t}},ensureRange:function(e,t){if(!(e>=t||t<=this.progressiveDataLength))for(var a=this.chunkSize,r=Math.floor(e/a),n=Math.floor((t-1)/a)+1,s=r;s<n;++s)if(!this.loadedChunks[s])throw new i(e,t)},nextEmptyChunk:function(e){for(var t,a=this.numChunks,r=0;r<a;++r){t=(e+r)%a;if(!this.loadedChunks[t])return t}return null},hasChunk:function(e){return!!this.loadedChunks[e]},get length(){return this.end-this.start},get isEmpty(){return 0===this.length},getByte:function(){var e=this.pos;if(e>=this.end)return-1;this.ensureByte(e);return this.bytes[this.pos++]},getUint16:function(){var e=this.getByte(),t=this.getByte();return-1===e||-1===t?-1:(e<<8)+t},getInt32:function(){return(this.getByte()<<24)+(this.getByte()<<16)+(this.getByte()<<8)+this.getByte()},getBytes:function(e){var t=this.bytes,a=this.pos,r=this.end;if(!e){this.ensureRange(a,r);return t.subarray(a,r)}var i=a+e;i>r&&(i=r);this.ensureRange(a,i);this.pos=i;return t.subarray(a,i)},peekByte:function(){var e=this.getByte();this.pos--;return e},peekBytes:function(e){var t=this.getBytes(e);this.pos-=t.length;return t},getByteRange:function(e,t){this.ensureRange(e,t);return this.bytes.subarray(e,t)},skip:function(e){e||(e=1);this.pos+=e},reset:function(){this.pos=this.start},moveStart:function(){this.start=this.pos},makeSubStream:function(e,t,a){function r(){}this.ensureRange(e,e+t);r.prototype=Object.create(this);r.prototype.getMissingChunks=function(){for(var e=this.chunkSize,t=Math.floor(this.start/e),a=Math.floor((this.end-1)/e)+1,r=[],i=t;i<a;++i)this.loadedChunks[i]||r.push(i);return r};var i=new r;i.pos=i.start=e;i.end=e+t||this.end;i.dict=a;return i}};return e}(),f=function(){function e(e,t){var a=t.rangeChunkSize,r=t.length;this.stream=new u(r,a,this);this.length=r;this.chunkSize=a;this.pdfNetworkStream=e;this.url=t.url;this.disableAutoFetch=t.disableAutoFetch;this.msgHandler=t.msgHandler;this.currRequestId=0;this.chunksNeededByRequest=Object.create(null);this.requestsByChunk=Object.create(null);this.promisesByRequest=Object.create(null);this.progressiveDataLength=0;this.aborted=!1;this._loadedStreamCapability=c()}e.prototype={onLoadedStream:function(){return this._loadedStreamCapability.promise},sendRequest:function(e,t){var a=this.pdfNetworkStream.getRangeReader(e,t);a.isStreamingSupported||(a.onProgress=this.onProgress.bind(this));var r=[],i=0,o=this;new Promise(function(e,t){var c=function(l){try{if(!l.done){var h=l.value;r.push(h);i+=n(h);a.isStreamingSupported&&o.onProgress({loaded:i});a.read().then(c,t);return}var u=s(r);r=null;e(u)}catch(e){t(e)}};a.read().then(c,t)}).then(function(t){this.aborted||this.onReceiveData({chunk:t,begin:e})}.bind(this))},requestAllChunks:function(){var e=this.stream.getMissingChunks();this._requestChunks(e);return this._loadedStreamCapability.promise},_requestChunks:function(e){var t,a,r=this.currRequestId++,i=Object.create(null);this.chunksNeededByRequest[r]=i;for(t=0,a=e.length;t<a;t++)this.stream.hasChunk(e[t])||(i[e[t]]=!0);if(h(i))return Promise.resolve();var n=c();this.promisesByRequest[r]=n;var s=[];for(var o in i){o|=0;if(!(o in this.requestsByChunk)){this.requestsByChunk[o]=[];s.push(o)}this.requestsByChunk[o].push(r)}if(!s.length)return n.promise;var l=this.groupChunks(s);for(t=0;t<l.length;++t){var u=l[t],f=u.beginChunk*this.chunkSize,d=Math.min(u.endChunk*this.chunkSize,this.length);this.sendRequest(f,d)}return n.promise},getStream:function(){return this.stream},requestRange:function(e,t){t=Math.min(t,this.length);for(var a=this.getBeginChunk(e),r=this.getEndChunk(t),i=[],n=a;n<r;++n)i.push(n);return this._requestChunks(i)},requestRanges:function(e){e=e||[];for(var t=[],a=0;a<e.length;a++)for(var r=this.getBeginChunk(e[a].begin),i=this.getEndChunk(e[a].end),n=r;n<i;++n)t.indexOf(n)<0&&t.push(n);t.sort(function(e,t){return e-t});return this._requestChunks(t)},groupChunks:function(e){for(var t=[],a=-1,r=-1,i=0;i<e.length;++i){var n=e[i];a<0&&(a=n);if(r>=0&&r+1!==n){t.push({beginChunk:a,endChunk:r+1});a=n}i+1===e.length&&t.push({beginChunk:a,endChunk:n+1});r=n}return t},onProgress:function(e){var t=this.stream.numChunksLoaded*this.chunkSize+e.loaded;this.msgHandler.send("DocProgress",{loaded:t,total:this.length})},onReceiveData:function(e){var t=e.chunk,a=void 0===e.begin,r=a?this.progressiveDataLength:e.begin,i=r+t.byteLength,n=Math.floor(r/this.chunkSize),s=i<this.length?Math.floor(i/this.chunkSize):Math.ceil(i/this.chunkSize);if(a){this.stream.onReceiveProgressiveData(t);this.progressiveDataLength=i}else this.stream.onReceiveData(r,t);this.stream.allChunksLoaded()&&this._loadedStreamCapability.resolve(this.stream);var o,c,u=[];for(t=n;t<s;++t){var f=this.requestsByChunk[t]||[];delete this.requestsByChunk[t];for(o=0;o<f.length;++o){c=f[o];var d=this.chunksNeededByRequest[c];t in d&&delete d[t];h(d)&&u.push(c)}}if(!this.disableAutoFetch&&h(this.requestsByChunk)){var g;if(1===this.stream.numChunksLoaded){var p=this.stream.numChunks-1;this.stream.hasChunk(p)||(g=p)}else g=this.stream.nextEmptyChunk(s);l(g)&&this._requestChunks([g])}for(o=0;o<u.length;++o){c=u[o];var m=this.promisesByRequest[c];delete this.promisesByRequest[c];m.resolve()}this.msgHandler.send("DocProgress",{loaded:this.stream.numChunksLoaded*this.chunkSize,total:this.length})},onError:function(e){this._loadedStreamCapability.reject(e)},getBeginChunk:function(e){return Math.floor(e/this.chunkSize)},getEndChunk:function(e){return Math.floor((e-1)/this.chunkSize)+1},abort:function(){this.aborted=!0;this.pdfNetworkStream&&this.pdfNetworkStream.cancelAllRequests("abort");for(var e in this.promisesByRequest){this.promisesByRequest[e].reject(new Error("Request was aborted"))}}};return e}();t.ChunkedStream=u;t.ChunkedStreamManager=f},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(2),s=r.PasswordException,o=r.PasswordResponses,c=r.bytesToString,l=r.warn,h=r.error,u=r.assert,f=r.isInt,d=r.stringToBytes,g=r.utf8StringToString,p=i.Name,m=i.isName,b=i.isDict,v=n.DecryptStream,y=function(){function e(e){this.a=0;this.b=0;var t,a,r=new Uint8Array(256),i=0,n=e.length;for(t=0;t<256;++t)r[t]=t;for(t=0;t<256;++t){a=r[t];i=i+a+e[t%n]&255;r[t]=r[i];r[i]=a}this.s=r}e.prototype={encryptBlock:function(e){var t,a,r,i=e.length,n=this.a,s=this.b,o=this.s,c=new Uint8Array(i);for(t=0;t<i;++t){n=n+1&255;a=o[n];s=s+a&255;r=o[s];o[n]=r;o[s]=a;c[t]=e[t]^o[a+r&255]}this.a=n;this.b=s;return c}};e.prototype.decryptBlock=e.prototype.encryptBlock;return e}(),k=function(){function e(e,r,i){var n,s,o,c=1732584193,l=-271733879,h=-1732584194,u=271733878,f=i+72&-64,d=new Uint8Array(f);for(n=0;n<i;++n)d[n]=e[r++];d[n++]=128;o=f-8;for(;n<o;)d[n++]=0;d[n++]=i<<3&255;d[n++]=i>>5&255;d[n++]=i>>13&255;d[n++]=i>>21&255;d[n++]=i>>>29&255;d[n++]=0;d[n++]=0;d[n++]=0;var g=new Int32Array(16);for(n=0;n<f;){for(s=0;s<16;++s,n+=4)g[s]=d[n]|d[n+1]<<8|d[n+2]<<16|d[n+3]<<24;var p,m,b=c,v=l,y=h,k=u;for(s=0;s<64;++s){if(s<16){p=v&y|~v&k;m=s}else if(s<32){p=k&v|~k&y;m=5*s+1&15}else if(s<48){p=v^y^k;m=3*s+5&15}else{p=y^(v|~k);m=7*s&15}var w=k,C=b+p+a[s]+g[m]|0,x=t[s];k=y;y=v;v=v+(C<<x|C>>>32-x)|0;b=w}c=c+b|0;l=l+v|0;h=h+y|0;u=u+k|0}return new Uint8Array([255&c,c>>8&255,c>>16&255,c>>>24&255,255&l,l>>8&255,l>>16&255,l>>>24&255,255&h,h>>8&255,h>>16&255,h>>>24&255,255&u,u>>8&255,u>>16&255,u>>>24&255])}var t=new Uint8Array([7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21]),a=new Int32Array([-680876936,-389564586,606105819,-1044525330,-176418897,1200080426,-1473231341,-45705983,1770035416,-1958414417,-42063,-1990404162,1804603682,-40341101,-1502002290,1236535329,-165796510,-1069501632,643717713,-373897302,-701558691,38016083,-660478335,-405537848,568446438,-1019803690,-187363961,1163531501,-1444681467,-51403784,1735328473,-1926607734,-378558,-2022574463,1839030562,-35309556,-1530992060,1272893353,-155497632,-1094730640,681279174,-358537222,-722521979,76029189,-640364487,-421815835,530742520,-995338651,-198630844,1126891415,-1416354905,-57434055,1700485571,-1894986606,-1051523,-2054922799,1873313359,-30611744,-1560198380,1309151649,-145523070,-1120210379,718787259,-343485551]);return e}(),w=function(){function e(e,t){this.high=0|e;this.low=0|t}e.prototype={and:function(e){this.high&=e.high;this.low&=e.low},xor:function(e){this.high^=e.high;this.low^=e.low},or:function(e){this.high|=e.high;this.low|=e.low},shiftRight:function(e){if(e>=32){this.low=this.high>>>e-32|0;this.high=0}else{this.low=this.low>>>e|this.high<<32-e;this.high=this.high>>>e|0}},shiftLeft:function(e){if(e>=32){this.high=this.low<<e-32;this.low=0}else{this.high=this.high<<e|this.low>>>32-e;this.low=this.low<<e}},rotateRight:function(e){var t,a;if(32&e){a=this.low;t=this.high}else{t=this.low;a=this.high}e&=31;this.low=t>>>e|a<<32-e;this.high=a>>>e|t<<32-e},not:function(){this.high=~this.high;this.low=~this.low},add:function(e){var t=(this.low>>>0)+(e.low>>>0),a=(this.high>>>0)+(e.high>>>0);t>4294967295&&(a+=1);this.low=0|t;this.high=0|a},copyTo:function(e,t){e[t]=this.high>>>24&255;e[t+1]=this.high>>16&255;e[t+2]=this.high>>8&255;e[t+3]=255&this.high;e[t+4]=this.low>>>24&255;e[t+5]=this.low>>16&255;e[t+6]=this.low>>8&255;e[t+7]=255&this.low},assign:function(e){this.high=e.high;this.low=e.low}};return e}(),C=function(){function e(e,t){return e>>>t|e<<32-t}function t(e,t,a){return e&t^~e&a}function a(e,t,a){return e&t^e&a^t&a}function r(t){return e(t,2)^e(t,13)^e(t,22)}function i(t){return e(t,6)^e(t,11)^e(t,25)}function n(t){return e(t,7)^e(t,18)^t>>>3}function s(t){return e(t,17)^e(t,19)^t>>>10}function o(e,o,l){var h,u,f,d=1779033703,g=3144134277,p=1013904242,m=2773480762,b=1359893119,v=2600822924,y=528734635,k=1541459225,w=64*Math.ceil((l+9)/64),C=new Uint8Array(w);for(h=0;h<l;++h)C[h]=e[o++];C[h++]=128;f=w-8;for(;h<f;)C[h++]=0;C[h++]=0;C[h++]=0;C[h++]=0;C[h++]=l>>>29&255;C[h++]=l>>21&255;C[h++]=l>>13&255;C[h++]=l>>5&255;C[h++]=l<<3&255;var x=new Uint32Array(64);for(h=0;h<w;){for(u=0;u<16;++u){x[u]=C[h]<<24|C[h+1]<<16|C[h+2]<<8|C[h+3];h+=4}for(u=16;u<64;++u)x[u]=s(x[u-2])+x[u-7]+n(x[u-15])+x[u-16]|0;var S,A,I=d,B=g,R=p,T=m,O=b,P=v,M=y,E=k;for(u=0;u<64;++u){S=E+i(O)+t(O,P,M)+c[u]+x[u];A=r(I)+a(I,B,R);E=M;M=P;P=O;O=T+S|0;T=R;R=B;B=I;I=S+A|0}d=d+I|0;g=g+B|0;p=p+R|0;m=m+T|0;b=b+O|0;v=v+P|0;y=y+M|0;k=k+E|0}return new Uint8Array([d>>24&255,d>>16&255,d>>8&255,255&d,g>>24&255,g>>16&255,g>>8&255,255&g,p>>24&255,p>>16&255,p>>8&255,255&p,m>>24&255,m>>16&255,m>>8&255,255&m,b>>24&255,b>>16&255,b>>8&255,255&b,v>>24&255,v>>16&255,v>>8&255,255&v,y>>24&255,y>>16&255,y>>8&255,255&y,k>>24&255,k>>16&255,k>>8&255,255&k])}var c=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298];return o}(),x=function(){function e(e,t,a,r,i){e.assign(t);e.and(a);i.assign(t);i.not();i.and(r);e.xor(i)}function t(e,t,a,r,i){e.assign(t);e.and(a);i.assign(t);i.and(r);e.xor(i);i.assign(a);i.and(r);e.xor(i)}function a(e,t,a){e.assign(t);e.rotateRight(28);a.assign(t);a.rotateRight(34);e.xor(a);a.assign(t);a.rotateRight(39);e.xor(a)}function r(e,t,a){e.assign(t);e.rotateRight(14);a.assign(t);a.rotateRight(18);e.xor(a);a.assign(t);a.rotateRight(41);e.xor(a)}function i(e,t,a){e.assign(t);e.rotateRight(1);a.assign(t);a.rotateRight(8);e.xor(a);a.assign(t);a.shiftRight(7);e.xor(a)}function n(e,t,a){e.assign(t);e.rotateRight(19);a.assign(t);a.rotateRight(61);e.xor(a);a.assign(t);a.shiftRight(6);e.xor(a)}function s(s,c,l,h){h=!!h;var u,f,d,g,p,m,b,v;if(h){u=new w(3418070365,3238371032);f=new w(1654270250,914150663);d=new w(2438529370,812702999);g=new w(355462360,4144912697);p=new w(1731405415,4290775857);m=new w(2394180231,1750603025);b=new w(3675008525,1694076839);v=new w(1203062813,3204075428)}else{u=new w(1779033703,4089235720);f=new w(3144134277,2227873595);d=new w(1013904242,4271175723);g=new w(2773480762,1595750129);p=new w(1359893119,2917565137);m=new w(2600822924,725511199);b=new w(528734635,4215389547);v=new w(1541459225,327033209)}var y,k,C,x=128*Math.ceil((l+17)/128),S=new Uint8Array(x);for(y=0;y<l;++y)S[y]=s[c++];S[y++]=128;C=x-16;for(;y<C;)S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=0;S[y++]=l>>>29&255;S[y++]=l>>21&255;S[y++]=l>>13&255;S[y++]=l>>5&255;S[y++]=l<<3&255;var A=new Array(80);for(y=0;y<80;y++)A[y]=new w(0,0);var I,B=new w(0,0),R=new w(0,0),T=new w(0,0),O=new w(0,0),P=new w(0,0),M=new w(0,0),E=new w(0,0),L=new w(0,0),D=new w(0,0),F=new w(0,0),q=new w(0,0),U=new w(0,0);for(y=0;y<x;){for(k=0;k<16;++k){A[k].high=S[y]<<24|S[y+1]<<16|S[y+2]<<8|S[y+3];A[k].low=S[y+4]<<24|S[y+5]<<16|S[y+6]<<8|S[y+7];y+=8}for(k=16;k<80;++k){I=A[k];n(I,A[k-2],U);I.add(A[k-7]);i(q,A[k-15],U);I.add(q);I.add(A[k-16])}B.assign(u);R.assign(f);T.assign(d);O.assign(g);P.assign(p);M.assign(m);E.assign(b);L.assign(v);for(k=0;k<80;++k){D.assign(L);r(q,P,U);D.add(q);e(q,P,M,E,U);D.add(q);D.add(o[k]);D.add(A[k]);a(F,B,U);t(q,B,R,T,U);F.add(q);I=L;L=E;E=M;M=P;O.add(D);P=O;O=T;T=R;R=B;I.assign(D);I.add(F);B=I}u.add(B);f.add(R);d.add(T);g.add(O);p.add(P);m.add(M);b.add(E);v.add(L)}var N;if(h){N=new Uint8Array(48);u.copyTo(N,0);f.copyTo(N,8);d.copyTo(N,16);g.copyTo(N,24);p.copyTo(N,32);m.copyTo(N,40)}else{N=new Uint8Array(64);u.copyTo(N,0);f.copyTo(N,8);d.copyTo(N,16);g.copyTo(N,24);p.copyTo(N,32);m.copyTo(N,40);b.copyTo(N,48);v.copyTo(N,56)}return N}var o=[new w(1116352408,3609767458),new w(1899447441,602891725),new w(3049323471,3964484399),new w(3921009573,2173295548),new w(961987163,4081628472),new w(1508970993,3053834265),new w(2453635748,2937671579),new w(2870763221,3664609560),new w(3624381080,2734883394),new w(310598401,1164996542),new w(607225278,1323610764),new w(1426881987,3590304994),new w(1925078388,4068182383),new w(2162078206,991336113),new w(2614888103,633803317),new w(3248222580,3479774868),new w(3835390401,2666613458),new w(4022224774,944711139),new w(264347078,2341262773),new w(604807628,2007800933),new w(770255983,1495990901),new w(1249150122,1856431235),new w(1555081692,3175218132),new w(1996064986,2198950837),new w(2554220882,3999719339),new w(2821834349,766784016),new w(2952996808,2566594879),new w(3210313671,3203337956),new w(3336571891,1034457026),new w(3584528711,2466948901),new w(113926993,3758326383),new w(338241895,168717936),new w(666307205,1188179964),new w(773529912,1546045734),new w(1294757372,1522805485),new w(1396182291,2643833823),new w(1695183700,2343527390),new w(1986661051,1014477480),new w(2177026350,1206759142),new w(2456956037,344077627),new w(2730485921,1290863460),new w(2820302411,3158454273),new w(3259730800,3505952657),new w(3345764771,106217008),new w(3516065817,3606008344),new w(3600352804,1432725776),new w(4094571909,1467031594),new w(275423344,851169720),new w(430227734,3100823752),new w(506948616,1363258195),new w(659060556,3750685593),new w(883997877,3785050280),new w(958139571,3318307427),new w(1322822218,3812723403),new w(1537002063,2003034995),new w(1747873779,3602036899),new w(1955562222,1575990012),new w(2024104815,1125592928),new w(2227730452,2716904306),new w(2361852424,442776044),new w(2428436474,593698344),new w(2756734187,3733110249),new w(3204031479,2999351573),new w(3329325298,3815920427),new w(3391569614,3928383900),new w(3515267271,566280711),new w(3940187606,3454069534),new w(4118630271,4000239992),new w(116418474,1914138554),new w(174292421,2731055270),new w(289380356,3203993006),new w(460393269,320620315),new w(685471733,587496836),new w(852142971,1086792851),new w(1017036298,365543100),new w(1126000580,2618297676),new w(1288033470,3409855158),new w(1501505948,4234509866),new w(1607167915,987167468),new w(1816402316,1246189591)];return s}(),S=function(){function e(e,t,a){return x(e,t,a,!0)}return e}(),A=function(){function e(){}e.prototype={decryptBlock:function(e){return e}};return e}(),I=function(){function e(e){var t=new Uint8Array(176);t.set(e);for(var a=16,r=1;a<176;++r){var i=t[a-3],o=t[a-2],c=t[a-1],l=t[a-4];i=s[i];o=s[o];c=s[c];l=s[l];i^=n[r];for(var h=0;h<4;++h){t[a]=i^=t[a-16];a++;t[a]=o^=t[a-16];a++;t[a]=c^=t[a-16];a++;t[a]=l^=t[a-16];a++}}return t}function t(e,t){var a=new Uint8Array(16);a.set(e);var r,i,n,s,c,l;for(i=0,n=160;i<16;++i,++n)a[i]^=t[n];for(r=9;r>=1;--r){s=a[13];a[13]=a[9];a[9]=a[5];a[5]=a[1];a[1]=s;s=a[14];c=a[10];a[14]=a[6];a[10]=a[2];a[6]=s;a[2]=c;s=a[15];c=a[11];l=a[7];a[15]=a[3];a[11]=s;a[7]=c;a[3]=l;for(i=0;i<16;++i)a[i]=o[a[i]];for(i=0,n=16*r;i<16;++i,++n)a[i]^=t[n];for(i=0;i<16;i+=4){var u=h[a[i]],f=h[a[i+1]],d=h[a[i+2]],g=h[a[i+3]];s=u^f>>>8^f<<24^d>>>16^d<<16^g>>>24^g<<8;a[i]=s>>>24&255;a[i+1]=s>>16&255;a[i+2]=s>>8&255;a[i+3]=255&s}}s=a[13];a[13]=a[9];a[9]=a[5];a[5]=a[1];a[1]=s;s=a[14];c=a[10];a[14]=a[6];a[10]=a[2];a[6]=s;a[2]=c;s=a[15];c=a[11];l=a[7];a[15]=a[3];a[11]=s;a[7]=c;a[3]=l;for(i=0;i<16;++i){a[i]=o[a[i]];a[i]^=t[i]}return a}function a(e,t){var a,r,i,n,o=new Uint8Array(16);o.set(e);for(h=0;h<16;++h)o[h]^=t[h];for(l=1;l<10;l++){for(h=0;h<16;++h)o[h]=s[o[h]];i=o[1];o[1]=o[5];o[5]=o[9];o[9]=o[13];o[13]=i;i=o[2];r=o[6];o[2]=o[10];o[6]=o[14];o[10]=i;o[14]=r;i=o[3];r=o[7];a=o[11];o[3]=o[15];o[7]=i;o[11]=r;o[15]=a;for(var h=0;h<16;h+=4){var u=o[h+0],f=o[h+1],d=o[h+2],g=o[h+3];a=u^f^d^g;o[h+0]^=a^c[u^f];o[h+1]^=a^c[f^d];o[h+2]^=a^c[d^g];o[h+3]^=a^c[g^u]}for(h=0,n=16*l;h<16;++h,++n)o[h]^=t[n]}for(h=0;h<16;++h)o[h]=s[o[h]];i=o[1];o[1]=o[5];o[5]=o[9];o[9]=o[13];o[13]=i;i=o[2];r=o[6];o[2]=o[10];o[6]=o[14];o[10]=i;o[14]=r;i=o[3];r=o[7];a=o[11];o[3]=o[15];o[7]=i;o[11]=r;o[15]=a;for(h=0,n=160;h<16;++h,++n)o[h]^=t[n];return o}function r(t){this.key=e(t);this.buffer=new Uint8Array(16);this.bufferPosition=0}function i(e,a){var r,i,n,s=e.length,o=this.buffer,c=this.bufferPosition,l=[],h=this.iv;for(r=0;r<s;++r){o[c]=e[r];++c;if(!(c<16)){var u=t(o,this.key);for(i=0;i<16;++i)u[i]^=h[i];h=o;l.push(u);o=new Uint8Array(16);c=0}}this.buffer=o;this.bufferLength=c;this.iv=h;if(0===l.length)return new Uint8Array([]);var f=16*l.length;if(a){var d=l[l.length-1],g=d[15];if(g<=16){for(r=15,n=16-g;r>=n;--r)if(d[r]!==g){g=0;break}f-=g;l[l.length-1]=d.subarray(0,16-g)}}var p=new Uint8Array(f);for(r=0,i=0,n=l.length;r<n;++r,i+=16)p.set(l[r],i);return p}
+for(var n=new Uint8Array([141,1,2,4,8,16,32,64,128,27,54,108,216,171,77,154,47,94,188,99,198,151,53,106,212,179,125,250,239,197,145,57,114,228,211,189,97,194,159,37,74,148,51,102,204,131,29,58,116,232,203,141,1,2,4,8,16,32,64,128,27,54,108,216,171,77,154,47,94,188,99,198,151,53,106,212,179,125,250,239,197,145,57,114,228,211,189,97,194,159,37,74,148,51,102,204,131,29,58,116,232,203,141,1,2,4,8,16,32,64,128,27,54,108,216,171,77,154,47,94,188,99,198,151,53,106,212,179,125,250,239,197,145,57,114,228,211,189,97,194,159,37,74,148,51,102,204,131,29,58,116,232,203,141,1,2,4,8,16,32,64,128,27,54,108,216,171,77,154,47,94,188,99,198,151,53,106,212,179,125,250,239,197,145,57,114,228,211,189,97,194,159,37,74,148,51,102,204,131,29,58,116,232,203,141,1,2,4,8,16,32,64,128,27,54,108,216,171,77,154,47,94,188,99,198,151,53,106,212,179,125,250,239,197,145,57,114,228,211,189,97,194,159,37,74,148,51,102,204,131,29,58,116,232,203,141]),s=new Uint8Array([99,124,119,123,242,107,111,197,48,1,103,43,254,215,171,118,202,130,201,125,250,89,71,240,173,212,162,175,156,164,114,192,183,253,147,38,54,63,247,204,52,165,229,241,113,216,49,21,4,199,35,195,24,150,5,154,7,18,128,226,235,39,178,117,9,131,44,26,27,110,90,160,82,59,214,179,41,227,47,132,83,209,0,237,32,252,177,91,106,203,190,57,74,76,88,207,208,239,170,251,67,77,51,133,69,249,2,127,80,60,159,168,81,163,64,143,146,157,56,245,188,182,218,33,16,255,243,210,205,12,19,236,95,151,68,23,196,167,126,61,100,93,25,115,96,129,79,220,34,42,144,136,70,238,184,20,222,94,11,219,224,50,58,10,73,6,36,92,194,211,172,98,145,149,228,121,231,200,55,109,141,213,78,169,108,86,244,234,101,122,174,8,186,120,37,46,28,166,180,198,232,221,116,31,75,189,139,138,112,62,181,102,72,3,246,14,97,53,87,185,134,193,29,158,225,248,152,17,105,217,142,148,155,30,135,233,206,85,40,223,140,161,137,13,191,230,66,104,65,153,45,15,176,84,187,22]),o=new Uint8Array([82,9,106,213,48,54,165,56,191,64,163,158,129,243,215,251,124,227,57,130,155,47,255,135,52,142,67,68,196,222,233,203,84,123,148,50,166,194,35,61,238,76,149,11,66,250,195,78,8,46,161,102,40,217,36,178,118,91,162,73,109,139,209,37,114,248,246,100,134,104,152,22,212,164,92,204,93,101,182,146,108,112,72,80,253,237,185,218,94,21,70,87,167,141,157,132,144,216,171,0,140,188,211,10,247,228,88,5,184,179,69,6,208,44,30,143,202,63,15,2,193,175,189,3,1,19,138,107,58,145,17,65,79,103,220,234,151,242,207,206,240,180,230,115,150,172,116,34,231,173,53,133,226,249,55,232,28,117,223,110,71,241,26,113,29,41,197,137,111,183,98,14,170,24,190,27,252,86,62,75,198,210,121,32,154,219,192,254,120,205,90,244,31,221,168,51,136,7,199,49,177,18,16,89,39,128,236,95,96,81,127,169,25,181,74,13,45,229,122,159,147,201,156,239,160,224,59,77,174,42,245,176,200,235,187,60,131,83,153,97,23,43,4,126,186,119,214,38,225,105,20,99,85,33,12,125]),c=new Uint8Array(256),l=0;l<256;l++)c[l]=l<128?l<<1:l<<1^27;var h=new Uint32Array([0,235474187,470948374,303765277,941896748,908933415,607530554,708780849,1883793496,2118214995,1817866830,1649639237,1215061108,1181045119,1417561698,1517767529,3767586992,4003061179,4236429990,4069246893,3635733660,3602770327,3299278474,3400528769,2430122216,2664543715,2362090238,2193862645,2835123396,2801107407,3035535058,3135740889,3678124923,3576870512,3341394285,3374361702,3810496343,3977675356,4279080257,4043610186,2876494627,2776292904,3076639029,3110650942,2472011535,2640243204,2403728665,2169303058,1001089995,899835584,666464733,699432150,59727847,226906860,530400753,294930682,1273168787,1172967064,1475418501,1509430414,1942435775,2110667444,1876241833,1641816226,2910219766,2743034109,2976151520,3211623147,2505202138,2606453969,2302690252,2269728455,3711829422,3543599269,3240894392,3475313331,3843699074,3943906441,4178062228,4144047775,1306967366,1139781709,1374988112,1610459739,1975683434,2076935265,1775276924,1742315127,1034867998,866637845,566021896,800440835,92987698,193195065,429456164,395441711,1984812685,2017778566,1784663195,1683407248,1315562145,1080094634,1383856311,1551037884,101039829,135050206,437757123,337553864,1042385657,807962610,573804783,742039012,2531067453,2564033334,2328828971,2227573024,2935566865,2700099354,3001755655,3168937228,3868552805,3902563182,4203181171,4102977912,3736164937,3501741890,3265478751,3433712980,1106041591,1340463100,1576976609,1408749034,2043211483,2009195472,1708848333,1809054150,832877231,1068351396,766945465,599762354,159417987,126454664,361929877,463180190,2709260871,2943682380,3178106961,3009879386,2572697195,2538681184,2236228733,2336434550,3509871135,3745345300,3441850377,3274667266,3910161971,3877198648,4110568485,4211818798,2597806476,2497604743,2261089178,2295101073,2733856160,2902087851,3202437046,2968011453,3936291284,3835036895,4136440770,4169408201,3535486456,3702665459,3467192302,3231722213,2051518780,1951317047,1716890410,1750902305,1113818384,1282050075,1584504582,1350078989,168810852,67556463,371049330,404016761,841739592,1008918595,775550814,540080725,3969562369,3801332234,4035489047,4269907996,3569255213,3669462566,3366754619,3332740144,2631065433,2463879762,2160117071,2395588676,2767645557,2868897406,3102011747,3069049960,202008497,33778362,270040487,504459436,875451293,975658646,675039627,641025152,2084704233,1917518562,1615861247,1851332852,1147550661,1248802510,1484005843,1451044056,933301370,967311729,733156972,632953703,260388950,25965917,328671808,496906059,1206477858,1239443753,1543208500,1441952575,2144161806,1908694277,1675577880,1842759443,3610369226,3644379585,3408119516,3307916247,4011190502,3776767469,4077384432,4245618683,2809771154,2842737049,3144396420,3043140495,2673705150,2438237621,2203032232,2370213795]);r.prototype={decryptBlock:function(e,t){var a,r=e.length,n=this.buffer,s=this.bufferPosition;for(a=0;s<16&&a<r;++a,++s)n[s]=e[a];if(s<16){this.bufferLength=s;return new Uint8Array([])}this.iv=n;this.buffer=new Uint8Array(16);this.bufferLength=0;this.decryptBlock=i;return this.decryptBlock(e.subarray(16),t)},encrypt:function(e,t){var r,i,n,s=e.length,o=this.buffer,c=this.bufferPosition,l=[];t||(t=new Uint8Array(16));for(r=0;r<s;++r){o[c]=e[r];++c;if(!(c<16)){for(i=0;i<16;++i)o[i]^=t[i];var h=a(o,this.key);t=h;l.push(h);o=new Uint8Array(16);c=0}}this.buffer=o;this.bufferLength=c;this.iv=t;if(0===l.length)return new Uint8Array([]);var u=16*l.length,f=new Uint8Array(u);for(r=0,i=0,n=l.length;r<n;++r,i+=16)f.set(l[r],i);return f}};return r}(),B=function(){function e(e){var t=new Uint8Array(240),a=1;t.set(e);for(var r=32,i=1;r<240;++i){if(r%32==16){s=n[s];o=n[o];c=n[c];l=n[l]}else if(r%32==0){var s=t[r-3],o=t[r-2],c=t[r-1],l=t[r-4];s=n[s];o=n[o];c=n[c];l=n[l];s^=a;(a<<=1)>=256&&(a=255&(27^a))}for(var h=0;h<4;++h){t[r]=s^=t[r-32];r++;t[r]=o^=t[r-32];r++;t[r]=c^=t[r-32];r++;t[r]=l^=t[r-32];r++}}return t}function t(e,t){var a=new Uint8Array(16);a.set(e);var r,i,n,o,c,h;for(i=0,n=224;i<16;++i,++n)a[i]^=t[n];for(r=13;r>=1;--r){o=a[13];a[13]=a[9];a[9]=a[5];a[5]=a[1];a[1]=o;o=a[14];c=a[10];a[14]=a[6];a[10]=a[2];a[6]=o;a[2]=c;o=a[15];c=a[11];h=a[7];a[15]=a[3];a[11]=o;a[7]=c;a[3]=h;for(i=0;i<16;++i)a[i]=s[a[i]];for(i=0,n=16*r;i<16;++i,++n)a[i]^=t[n];for(i=0;i<16;i+=4){var u=l[a[i]],f=l[a[i+1]],d=l[a[i+2]],g=l[a[i+3]];o=u^f>>>8^f<<24^d>>>16^d<<16^g>>>24^g<<8;a[i]=o>>>24&255;a[i+1]=o>>16&255;a[i+2]=o>>8&255;a[i+3]=255&o}}o=a[13];a[13]=a[9];a[9]=a[5];a[5]=a[1];a[1]=o;o=a[14];c=a[10];a[14]=a[6];a[10]=a[2];a[6]=o;a[2]=c;o=a[15];c=a[11];h=a[7];a[15]=a[3];a[11]=o;a[7]=c;a[3]=h;for(i=0;i<16;++i){a[i]=s[a[i]];a[i]^=t[i]}return a}function a(e,t){var a,r,i,s,l=new Uint8Array(16);l.set(e);for(h=0;h<16;++h)l[h]^=t[h];for(c=1;c<14;c++){for(h=0;h<16;++h)l[h]=n[l[h]];i=l[1];l[1]=l[5];l[5]=l[9];l[9]=l[13];l[13]=i;i=l[2];r=l[6];l[2]=l[10];l[6]=l[14];l[10]=i;l[14]=r;i=l[3];r=l[7];a=l[11];l[3]=l[15];l[7]=i;l[11]=r;l[15]=a;for(var h=0;h<16;h+=4){var u=l[h+0],f=l[h+1],d=l[h+2],g=l[h+3];a=u^f^d^g;l[h+0]^=a^o[u^f];l[h+1]^=a^o[f^d];l[h+2]^=a^o[d^g];l[h+3]^=a^o[g^u]}for(h=0,s=16*c;h<16;++h,++s)l[h]^=t[s]}for(h=0;h<16;++h)l[h]=n[l[h]];i=l[1];l[1]=l[5];l[5]=l[9];l[9]=l[13];l[13]=i;i=l[2];r=l[6];l[2]=l[10];l[6]=l[14];l[10]=i;l[14]=r;i=l[3];r=l[7];a=l[11];l[3]=l[15];l[7]=i;l[11]=r;l[15]=a;for(h=0,s=224;h<16;++h,++s)l[h]^=t[s];return l}function r(t){this.key=e(t);this.buffer=new Uint8Array(16);this.bufferPosition=0}function i(e,a){var r,i,n,s=e.length,o=this.buffer,c=this.bufferPosition,l=[],h=this.iv;for(r=0;r<s;++r){o[c]=e[r];++c;if(!(c<16)){var u=t(o,this.key);for(i=0;i<16;++i)u[i]^=h[i];h=o;l.push(u);o=new Uint8Array(16);c=0}}this.buffer=o;this.bufferLength=c;this.iv=h;if(0===l.length)return new Uint8Array([]);var f=16*l.length;if(a){var d=l[l.length-1],g=d[15];if(g<=16){for(r=15,n=16-g;r>=n;--r)if(d[r]!==g){g=0;break}f-=g;l[l.length-1]=d.subarray(0,16-g)}}var p=new Uint8Array(f);for(r=0,i=0,n=l.length;r<n;++r,i+=16)p.set(l[r],i);return p}for(var n=new Uint8Array([99,124,119,123,242,107,111,197,48,1,103,43,254,215,171,118,202,130,201,125,250,89,71,240,173,212,162,175,156,164,114,192,183,253,147,38,54,63,247,204,52,165,229,241,113,216,49,21,4,199,35,195,24,150,5,154,7,18,128,226,235,39,178,117,9,131,44,26,27,110,90,160,82,59,214,179,41,227,47,132,83,209,0,237,32,252,177,91,106,203,190,57,74,76,88,207,208,239,170,251,67,77,51,133,69,249,2,127,80,60,159,168,81,163,64,143,146,157,56,245,188,182,218,33,16,255,243,210,205,12,19,236,95,151,68,23,196,167,126,61,100,93,25,115,96,129,79,220,34,42,144,136,70,238,184,20,222,94,11,219,224,50,58,10,73,6,36,92,194,211,172,98,145,149,228,121,231,200,55,109,141,213,78,169,108,86,244,234,101,122,174,8,186,120,37,46,28,166,180,198,232,221,116,31,75,189,139,138,112,62,181,102,72,3,246,14,97,53,87,185,134,193,29,158,225,248,152,17,105,217,142,148,155,30,135,233,206,85,40,223,140,161,137,13,191,230,66,104,65,153,45,15,176,84,187,22]),s=new Uint8Array([82,9,106,213,48,54,165,56,191,64,163,158,129,243,215,251,124,227,57,130,155,47,255,135,52,142,67,68,196,222,233,203,84,123,148,50,166,194,35,61,238,76,149,11,66,250,195,78,8,46,161,102,40,217,36,178,118,91,162,73,109,139,209,37,114,248,246,100,134,104,152,22,212,164,92,204,93,101,182,146,108,112,72,80,253,237,185,218,94,21,70,87,167,141,157,132,144,216,171,0,140,188,211,10,247,228,88,5,184,179,69,6,208,44,30,143,202,63,15,2,193,175,189,3,1,19,138,107,58,145,17,65,79,103,220,234,151,242,207,206,240,180,230,115,150,172,116,34,231,173,53,133,226,249,55,232,28,117,223,110,71,241,26,113,29,41,197,137,111,183,98,14,170,24,190,27,252,86,62,75,198,210,121,32,154,219,192,254,120,205,90,244,31,221,168,51,136,7,199,49,177,18,16,89,39,128,236,95,96,81,127,169,25,181,74,13,45,229,122,159,147,201,156,239,160,224,59,77,174,42,245,176,200,235,187,60,131,83,153,97,23,43,4,126,186,119,214,38,225,105,20,99,85,33,12,125]),o=new Uint8Array(256),c=0;c<256;c++)o[c]=c<128?c<<1:c<<1^27;var l=new Uint32Array([0,235474187,470948374,303765277,941896748,908933415,607530554,708780849,1883793496,2118214995,1817866830,1649639237,1215061108,1181045119,1417561698,1517767529,3767586992,4003061179,4236429990,4069246893,3635733660,3602770327,3299278474,3400528769,2430122216,2664543715,2362090238,2193862645,2835123396,2801107407,3035535058,3135740889,3678124923,3576870512,3341394285,3374361702,3810496343,3977675356,4279080257,4043610186,2876494627,2776292904,3076639029,3110650942,2472011535,2640243204,2403728665,2169303058,1001089995,899835584,666464733,699432150,59727847,226906860,530400753,294930682,1273168787,1172967064,1475418501,1509430414,1942435775,2110667444,1876241833,1641816226,2910219766,2743034109,2976151520,3211623147,2505202138,2606453969,2302690252,2269728455,3711829422,3543599269,3240894392,3475313331,3843699074,3943906441,4178062228,4144047775,1306967366,1139781709,1374988112,1610459739,1975683434,2076935265,1775276924,1742315127,1034867998,866637845,566021896,800440835,92987698,193195065,429456164,395441711,1984812685,2017778566,1784663195,1683407248,1315562145,1080094634,1383856311,1551037884,101039829,135050206,437757123,337553864,1042385657,807962610,573804783,742039012,2531067453,2564033334,2328828971,2227573024,2935566865,2700099354,3001755655,3168937228,3868552805,3902563182,4203181171,4102977912,3736164937,3501741890,3265478751,3433712980,1106041591,1340463100,1576976609,1408749034,2043211483,2009195472,1708848333,1809054150,832877231,1068351396,766945465,599762354,159417987,126454664,361929877,463180190,2709260871,2943682380,3178106961,3009879386,2572697195,2538681184,2236228733,2336434550,3509871135,3745345300,3441850377,3274667266,3910161971,3877198648,4110568485,4211818798,2597806476,2497604743,2261089178,2295101073,2733856160,2902087851,3202437046,2968011453,3936291284,3835036895,4136440770,4169408201,3535486456,3702665459,3467192302,3231722213,2051518780,1951317047,1716890410,1750902305,1113818384,1282050075,1584504582,1350078989,168810852,67556463,371049330,404016761,841739592,1008918595,775550814,540080725,3969562369,3801332234,4035489047,4269907996,3569255213,3669462566,3366754619,3332740144,2631065433,2463879762,2160117071,2395588676,2767645557,2868897406,3102011747,3069049960,202008497,33778362,270040487,504459436,875451293,975658646,675039627,641025152,2084704233,1917518562,1615861247,1851332852,1147550661,1248802510,1484005843,1451044056,933301370,967311729,733156972,632953703,260388950,25965917,328671808,496906059,1206477858,1239443753,1543208500,1441952575,2144161806,1908694277,1675577880,1842759443,3610369226,3644379585,3408119516,3307916247,4011190502,3776767469,4077384432,4245618683,2809771154,2842737049,3144396420,3043140495,2673705150,2438237621,2203032232,2370213795]);r.prototype={decryptBlock:function(e,t,a){var r,n=e.length,s=this.buffer,o=this.bufferPosition;if(a)this.iv=a;else{for(r=0;o<16&&r<n;++r,++o)s[o]=e[r];if(o<16){this.bufferLength=o;return new Uint8Array([])}this.iv=s;e=e.subarray(16)}this.buffer=new Uint8Array(16);this.bufferLength=0;this.decryptBlock=i;return this.decryptBlock(e,t)},encrypt:function(e,t){var r,i,n,s=e.length,o=this.buffer,c=this.bufferPosition,l=[];t||(t=new Uint8Array(16));for(r=0;r<s;++r){o[c]=e[r];++c;if(!(c<16)){for(i=0;i<16;++i)o[i]^=t[i];var h=a(o,this.key);this.iv=h;l.push(h);o=new Uint8Array(16);c=0}}this.buffer=o;this.bufferLength=c;this.iv=t;if(0===l.length)return new Uint8Array([]);var u=16*l.length,f=new Uint8Array(u);for(r=0,i=0,n=l.length;r<n;++r,i+=16)f.set(l[r],i);return f}};return r}(),R=function(){function e(e,t){if(e.length!==t.length)return!1;for(var a=0;a<e.length;a++)if(e[a]!==t[a])return!1;return!0}function t(){}t.prototype={checkOwnerPassword:function(t,a,r,i){var n=new Uint8Array(t.length+56);n.set(t,0);n.set(a,t.length);n.set(r,t.length+a.length);return e(C(n,0,n.length),i)},checkUserPassword:function(t,a,r){var i=new Uint8Array(t.length+8);i.set(t,0);i.set(a,t.length);return e(C(i,0,i.length),r)},getOwnerKey:function(e,t,a,r){var i=new Uint8Array(e.length+56);i.set(e,0);i.set(t,e.length);i.set(a,e.length+t.length);var n=C(i,0,i.length);return new B(n).decryptBlock(r,!1,new Uint8Array(16))},getUserKey:function(e,t,a){var r=new Uint8Array(e.length+8);r.set(e,0);r.set(t,e.length);var i=C(r,0,r.length);return new B(i).decryptBlock(a,!1,new Uint8Array(16))}};return t}(),T=function(){function e(e,t){var a=new Uint8Array(e.length+t.length);a.set(e,0);a.set(t,e.length);return a}function t(t,a,r){for(var i=C(a,0,a.length).subarray(0,32),n=[0],s=0;s<64||n[n.length-1]>s-32;){var o=t.length+i.length+r.length,c=new Uint8Array(64*o),l=e(t,i);l=e(l,r);for(var h=0,u=0;h<64;h++,u+=o)c.set(l,u);n=new I(i.subarray(0,16)).encrypt(c,i.subarray(16,32));for(var f=0,d=0;d<16;d++){f*=1;f%=3;f+=(n[d]>>>0)%3;f%=3}0===f?i=C(n,0,n.length):1===f?i=S(n,0,n.length):2===f&&(i=x(n,0,n.length));s++}return i.subarray(0,32)}function a(){}function r(e,t){if(e.length!==t.length)return!1;for(var a=0;a<e.length;a++)if(e[a]!==t[a])return!1;return!0}a.prototype={hash:function(e,a,r){return t(e,a,r)},checkOwnerPassword:function(e,a,i,n){var s=new Uint8Array(e.length+56);s.set(e,0);s.set(a,e.length);s.set(i,e.length+a.length);return r(t(e,s,i),n)},checkUserPassword:function(e,a,i){var n=new Uint8Array(e.length+8);n.set(e,0);n.set(a,e.length);return r(t(e,n,[]),i)},getOwnerKey:function(e,a,r,i){var n=new Uint8Array(e.length+56);n.set(e,0);n.set(a,e.length);n.set(r,e.length+a.length);var s=t(e,n,r);return new B(s).decryptBlock(i,!1,new Uint8Array(16))},getUserKey:function(e,a,r){var i=new Uint8Array(e.length+8);i.set(e,0);i.set(a,e.length);var n=t(e,i,[]);return new B(n).decryptBlock(r,!1,new Uint8Array(16))}};return a}(),O=function(){function e(e,t){this.StringCipherConstructor=e;this.StreamCipherConstructor=t}e.prototype={createStream:function(e,t){var a=new this.StreamCipherConstructor;return new v(e,t,function(e,t){return a.decryptBlock(e,t)})},decryptString:function(e){var t=new this.StringCipherConstructor,a=d(e);a=t.decryptBlock(a,!0);return c(a)}};return e}(),P=function(){function e(e,t,a,r,i,n,s,o,c,l,h,u){if(t){var f=Math.min(127,t.length);t=t.subarray(0,f)}else t=[];var d;d=6===e?new T:new R;return d.checkUserPassword(t,o,s)?d.getUserKey(t,c,h):t.length&&d.checkOwnerPassword(t,r,n,a)?d.getOwnerKey(t,i,n,l):null}function t(e,t,a,r,i,n,s,o){var l,h,u=40+a.length+e.length,f=new Uint8Array(u),d=0;if(t){h=Math.min(32,t.length);for(;d<h;++d)f[d]=t[d]}l=0;for(;d<32;)f[d++]=c[l++];for(l=0,h=a.length;l<h;++l)f[d++]=a[l];f[d++]=255&i;f[d++]=i>>8&255;f[d++]=i>>16&255;f[d++]=i>>>24&255;for(l=0,h=e.length;l<h;++l)f[d++]=e[l];if(n>=4&&!o){f[d++]=255;f[d++]=255;f[d++]=255;f[d++]=255}var g=k(f,0,d),p=s>>3;if(n>=3)for(l=0;l<50;++l)g=k(g,0,p);var m,b,v=g.subarray(0,p);if(n>=3){for(d=0;d<32;++d)f[d]=c[d];for(l=0,h=e.length;l<h;++l)f[d++]=e[l];m=new y(v);b=m.encryptBlock(k(f,0,d));h=v.length;var w,C=new Uint8Array(h);for(l=1;l<=19;++l){for(w=0;w<h;++w)C[w]=v[w]^l;m=new y(C);b=m.encryptBlock(b)}for(l=0,h=b.length;l<h;++l)if(r[l]!==b[l])return null}else{m=new y(v);b=m.encryptBlock(c);for(l=0,h=b.length;l<h;++l)if(r[l]!==b[l])return null}return v}function a(e,t,a,r){var i,n,s=new Uint8Array(32),o=0;n=Math.min(32,e.length);for(;o<n;++o)s[o]=e[o];i=0;for(;o<32;)s[o++]=c[i++];var l=k(s,0,o),h=r>>3;if(a>=3)for(i=0;i<50;++i)l=k(l,0,l.length);var u,f;if(a>=3){f=t;var d,g=new Uint8Array(h);for(i=19;i>=0;i--){for(d=0;d<h;++d)g[d]=l[d]^i;u=new y(g);f=u.encryptBlock(f)}}else{u=new y(l.subarray(0,h));f=u.encryptBlock(t)}return f}function r(r,i,n){var c=r.get("Filter");m(c,"Standard")||h("unknown encryption method");this.dict=r;var u=r.get("V");(!f(u)||1!==u&&2!==u&&4!==u&&5!==u)&&h("unsupported encryption algorithm");this.algorithm=u;var p=r.get("Length");if(!p)if(u<=3)p=40;else{var y=r.get("CF"),k=r.get("StmF");if(b(y)&&m(k)){y.suppressEncryption=!0;var w=y.get(k.name);p=w&&w.get("Length")||128;p<40&&(p<<=3)}}(!f(p)||p<40||p%8!=0)&&h("invalid key length");var C=d(r.get("O")).subarray(0,32),x=d(r.get("U")).subarray(0,32),S=r.get("P"),A=r.get("R"),I=(4===u||5===u)&&!1!==r.get("EncryptMetadata");this.encryptMetadata=I;var B,R=d(i);if(n){if(6===A)try{n=g(n)}catch(e){l("CipherTransformFactory: Unable to convert UTF8 encoded password.")}B=d(n)}var T;if(5!==u)T=t(R,B,C,x,S,A,p,I);else{T=e(A,B,C,d(r.get("O")).subarray(32,40),d(r.get("O")).subarray(40,48),d(r.get("U")).subarray(0,48),x,d(r.get("U")).subarray(32,40),d(r.get("U")).subarray(40,48),d(r.get("OE")),d(r.get("UE")),d(r.get("Perms")))}if(!T&&!n)throw new s("No password given",o.NEED_PASSWORD);if(!T&&n){T=t(R,a(B,C,A,p),C,x,S,A,p,I)}if(!T)throw new s("Incorrect Password",o.INCORRECT_PASSWORD);this.encryptionKey=T;if(u>=4){var O=r.get("CF");b(O)&&(O.suppressEncryption=!0);this.cf=O;this.stmf=r.get("StmF")||v;this.strf=r.get("StrF")||v;this.eff=r.get("EFF")||this.stmf}}function i(e,t,a,r){var i,n,s=new Uint8Array(a.length+9);for(i=0,n=a.length;i<n;++i)s[i]=a[i];s[i++]=255&e;s[i++]=e>>8&255;s[i++]=e>>16&255;s[i++]=255&t;s[i++]=t>>8&255;if(r){s[i++]=115;s[i++]=65;s[i++]=108;s[i++]=84}return k(s,0,i).subarray(0,Math.min(a.length+5,16))}function n(e,t,a,r,n){u(m(t),"Invalid crypt filter name.");var s,o=e.get(t.name);null!==o&&void 0!==o&&(s=o.get("CFM"));if(!s||"None"===s.name)return function(){return new A};if("V2"===s.name)return function(){return new y(i(a,r,n,!1))};if("AESV2"===s.name)return function(){return new I(i(a,r,n,!0))};if("AESV3"===s.name)return function(){return new B(n)};h("Unknown crypto method")}var c=new Uint8Array([40,191,78,94,78,117,138,65,100,0,78,86,255,250,1,8,46,46,0,182,208,104,62,128,47,12,169,254,100,83,105,122]),v=p.get("Identity");r.prototype={createCipherTransform:function(e,t){if(4===this.algorithm||5===this.algorithm)return new O(n(this.cf,this.stmf,e,t,this.encryptionKey),n(this.cf,this.strf,e,t,this.encryptionKey));var a=i(e,t,this.encryptionKey,!1),r=function(){return new y(a)};return new O(r,r)}};return r}();t.AES128Cipher=I;t.AES256Cipher=B;t.ARCFourCipher=y;t.CipherTransformFactory=P;t.PDF17=R;t.PDF20=T;t.calculateMD5=k;t.calculateSHA256=C;t.calculateSHA384=S;t.calculateSHA512=x},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(2),s=a(5),o=a(27),c=a(3),l=a(31),h=a(26),u=a(6),f=a(32),d=a(23),g=a(30),p=a(21),m=a(4),b=a(17),v=a(18),y=a(7),k=r.FONT_IDENTITY_MATRIX,w=r.IDENTITY_MATRIX,C=r.UNSUPPORTED_FEATURES,x=r.ImageKind,S=r.OPS,A=r.TextRenderingMode,I=r.CMapCompressionType,B=r.Util,R=r.assert,T=r.createPromiseCapability,O=r.error,P=r.info,M=r.isArray,E=r.isNum,L=r.isString,D=r.getLookupTableFactory,F=r.warn,q=i.Dict,U=i.Name,N=i.isEOF,j=i.isCmd,_=i.isDict,z=i.isName,H=i.isRef,G=i.isStream,X=n.DecodeStream,V=n.JpegStream,W=n.Stream,K=s.Lexer,Y=s.Parser,J=o.PDFImage,Z=c.ColorSpace,Q=l.MurmurHash3_64,$=h.ErrorFont,ee=h.FontFlags,te=h.Font,ae=h.IdentityToUnicodeMap,re=h.ToUnicodeMap,ie=h.getFontType,ne=u.isPDFFunction,se=u.PDFFunction,oe=f.Pattern,ce=f.getTilingPatternIR,le=d.CMapFactory,he=d.IdentityCMap,ue=g.getMetrics,fe=p.bidi,de=m.WinAnsiEncoding,ge=m.StandardEncoding,pe=m.MacRomanEncoding,me=m.SymbolSetEncoding,be=m.ZapfDingbatsEncoding,ve=m.getEncoding,ye=b.getStdFontMap,ke=b.getSerifFonts,we=b.getSymbolsFonts,Ce=v.getNormalizedUnicodes,xe=v.reverseIfRtl,Se=v.getUnicodeForGlyph,Ae=y.getGlyphsUnicode,Ie=function(){function e(e,t,a,r){this.xref=e;this.resources=t;this.handler=a;this.forceDataSchema=r}function t(e,t,a,i,n,s,o,c){this.pdfManager=e;this.xref=t;this.handler=a;this.pageIndex=i;this.idFactory=n;this.fontCache=s;this.builtInCMapCache=o;this.options=c||r;this.fetchBuiltInCMap=function(e){var t=o[e];return t?Promise.resolve(t):a.sendWithPromise("FetchBuiltInCMap",{name:e}).then(function(t){t.compressionType!==I.NONE&&(o[e]=t);return t})}}function a(){this.reset()}var r={forceDataSchema:!1,maxImageSize:-1,disableFontFace:!1,disableNativeImageDecoder:!1};e.prototype={canDecode:function(t){return t instanceof V&&e.isDecodable(t,this.xref,this.resources)},decode:function(e){var t=e.dict,a=t.get("ColorSpace","CS");a=Z.parse(a,this.xref,this.resources);var r=a.numComps;return this.handler.sendWithPromise("JpegDecode",[e.getIR(this.forceDataSchema),r]).then(function(t){var a=t.data;return new W(a,0,a.length,e.dict)})}};e.isSupported=function(e,t,a){var r=e.dict;if(r.has("DecodeParms")||r.has("DP"))return!1;var i=Z.parse(r.get("ColorSpace","CS"),t,a);return("DeviceGray"===i.name||"DeviceRGB"===i.name)&&i.isDefaultDecode(r.getArray("Decode","D"))};e.isDecodable=function(e,t,a){var r=e.dict;if(r.has("DecodeParms")||r.has("DP"))return!1;var i=Z.parse(r.get("ColorSpace","CS"),t,a);return(1===i.numComps||3===i.numComps)&&i.isDefaultDecode(r.getArray("Decode","D"))};a.prototype={check:function(){if(++this.checked<100)return!1;this.checked=0;return this.endTime<=Date.now()},reset:function(){this.endTime=Date.now()+20;this.checked=0}};var i=Promise.resolve();t.prototype={hasBlendModes:function(e){if(!_(e))return!1;var t=Object.create(null);e.objId&&(t[e.objId]=!0);for(var a=[e],r=this.xref;a.length;){var i,n,s,o=a.shift(),c=o.get("ExtGState");if(_(c)){var l=c.getKeys();for(n=0,s=l.length;n<s;n++){i=l[n];var h=c.get(i),u=h.get("BM");if(z(u)&&"Normal"!==u.name)return!0}}var f=o.get("XObject");if(_(f)){var d=f.getKeys();for(n=0,s=d.length;n<s;n++){i=d[n];var g=f.getRaw(i);if(H(g)){if(t[g.toString()])continue;g=r.fetch(g)}if(G(g)){if(g.dict.objId){if(t[g.dict.objId])continue;t[g.dict.objId]=!0}var p=g.dict.get("Resources");if(_(p)&&(!p.objId||!t[p.objId])){a.push(p);p.objId&&(t[p.objId]=!0)}}}}}return!1},buildFormXObject:function(e,t,a,r,i,n){var s=t.dict.getArray("Matrix"),o=t.dict.getArray("BBox"),c=t.dict.get("Group");if(c){var l,h={matrix:s,bbox:o,smask:a,isolated:!1,knockout:!1},u=c.get("S");if(z(u,"Transparency")){h.isolated=c.get("I")||!1;h.knockout=c.get("K")||!1;l=c.has("CS")?Z.parse(c.get("CS"),this.xref,e):null}if(a&&a.backdrop){l=l||Z.singletons.rgb;a.backdrop=l.getRgb(a.backdrop,0)}r.addOp(S.beginGroup,[h])}r.addOp(S.paintFormXObjectBegin,[s,o]);return this.getOperatorList(t,i,t.dict.get("Resources")||e,r,n).then(function(){r.addOp(S.paintFormXObjectEnd,[]);c&&r.addOp(S.endGroup,[h])})},buildPaintImageXObject:function(t,a,r,i,n,s){var o=this,c=a.dict,l=c.get("Width","W"),h=c.get("Height","H");if(l&&E(l)&&h&&E(h)){var u=this.options.maxImageSize;if(-1!==u&&l*h>u)F("Image exceeded maximum allowed size and was removed.");else{var f,d,g=c.get("ImageMask","IM")||!1;if(g){var p=c.get("Width","W"),m=c.get("Height","H"),b=p+7>>3,v=a.getBytes(b*m),y=c.getArray("Decode","D"),k=!!y&&y[0]>0;f=J.createMask(v,p,m,a instanceof X,k);f.cached=!0;d=[f];i.addOp(S.paintImageMaskXObject,d);n&&(s[n]={fn:S.paintImageMaskXObject,args:d})}else{var w=c.get("SMask","SM")||!1,C=c.get("Mask")||!1;if(!r||w||C||a instanceof V||!(l+h<200)){var x=!this.options.disableNativeImageDecoder,A="img_"+this.idFactory.createObjId();i.addDependency(A);d=[A,l,h];if(x&&!w&&!C&&a instanceof V&&e.isSupported(a,this.xref,t)){i.addOp(S.paintJpegXObject,d);this.handler.send("obj",[A,this.pageIndex,"JpegStream",a.getIR(this.options.forceDataSchema)])}else{var I=null;x&&(a instanceof V||C instanceof V||w instanceof V)&&(I=new e(o.xref,t,o.handler,o.options.forceDataSchema));J.buildImage(o.handler,o.xref,t,a,r,I).then(function(e){var t=e.createImageData(!1);o.handler.send("obj",[A,o.pageIndex,"Image",t],[t.data.buffer])}).then(void 0,function(e){F("Unable to decode image: "+e);o.handler.send("obj",[A,o.pageIndex,"Image",null])});i.addOp(S.paintImageXObject,d);n&&(s[n]={fn:S.paintImageXObject,args:d})}}else{f=new J(this.xref,t,a,r,null,null).createImageData(!0);i.addOp(S.paintInlineImageXObject,[f])}}}}else F("Image dimensions are missing, or not numbers.")},handleSMask:function(e,t,a,r,i){var n=e.get("G"),s={subtype:e.get("S").name,backdrop:e.get("BC")},o=e.get("TR");if(ne(o)){for(var c=se.parse(this.xref,o),l=new Uint8Array(256),h=new Float32Array(1),u=0;u<256;u++){h[0]=u/255;c(h,0,h,0);l[u]=255*h[0]|0}s.transferMap=l}return this.buildFormXObject(t,n,s,a,r,i.state.clone())},handleTilingType:function(e,t,a,r,i,n,s){var o=new Re,c=[i.get("Resources"),a],l=q.merge(this.xref,c);return this.getOperatorList(r,s,l,o).then(function(){n.addDependencies(o.dependencies);n.addOp(e,ce({fnArray:o.fnArray,argsArray:o.argsArray},i,t))})},handleSetFont:function(e,t,a,r,i,n){var s;if(t){t=t.slice();s=t[0].name}var o=this;return this.loadFont(s,a,e).then(function(t){return t.font.isType3Font?t.loadType3Data(o,e,r,i).then(function(){return t},function(e){o.handler.send("UnsupportedFeature",{featureId:C.font});return new Be("g_font_error",new $("Type3 font load error: "+e),t.font)}):t}).then(function(e){n.font=e.font;e.send(o.handler);return e.loadedName})},handleText:function(e,t){var a=t.font,r=a.charsToGlyphs(e),i=!!(t.textRenderingMode&A.ADD_TO_PATH_FLAG);if(a.data&&(i||this.options.disableFontFace))for(var n=function(e){if(!a.renderer.hasBuiltPath(e)){var t=a.renderer.getPathJs(e);this.handler.send("commonobj",[a.loadedName+"_path_"+e,"FontPath",t])}}.bind(this),s=0,o=r.length;s<o;s++){var c=r[s];n(c.fontChar);var l=c.accent;l&&l.fontChar&&n(l.fontChar)}return r},setGState:function(e,t,a,r,i){for(var n=[],s=t.getKeys(),o=this,c=Promise.resolve(),l=0,h=s.length;l<h;l++){var u=s[l],f=t.get(u);switch(u){case"Type":break;case"LW":case"LC":case"LJ":case"ML":case"D":case"RI":case"FL":case"CA":case"ca":n.push([u,f]);break;case"Font":c=c.then(function(){return o.handleSetFont(e,null,f[0],a,r,i.state).then(function(e){a.addDependency(e);n.push([u,[e,f[1]]])})});break;case"BM":n.push([u,f]);break;case"SMask":if(z(f,"None")){n.push([u,!1]);break}if(_(f)){c=c.then(function(t){return o.handleSMask(t,e,a,r,i)}.bind(this,f));n.push([u,!0])}else F("Unsupported SMask type");break;case"OP":case"op":case"OPM":case"BG":case"BG2":case"UCR":case"UCR2":case"TR":case"TR2":case"HT":case"SM":case"SA":case"AIS":case"TK":P("graphic state operator "+u);break;default:P("Unknown graphic state operator "+u)}}return c.then(function(){n.length>0&&a.addOp(S.setGState,[n])})},loadFont:function(e,t,a){function r(){return Promise.resolve(new Be("g_font_error",new $("Font "+e+" is not available"),t))}var i,n=this.xref;if(t){R(H(t));i=t}else{var s=a.get("Font");if(!s){F("fontRes not available");return r()}i=s.getRaw(e)}if(!i){F("fontRef not available");return r()}if(this.fontCache.has(i))return this.fontCache.get(i);t=n.fetchIfRef(i);if(!_(t))return r();if(t.translated)return t.translated;var o,c=T(),l=this.preEvaluateFont(t),h=l.descriptor,u=H(i);u&&(o=i.toString());if(_(h)){h.fontAliases||(h.fontAliases=Object.create(null));var f=h.fontAliases,d=l.hash;if(f[d]){var g=f[d].aliasRef;if(u&&g&&this.fontCache.has(g)){this.fontCache.putAlias(i,g);return this.fontCache.get(i)}}else f[d]={fontID:te.getFontID()};u&&(f[d].aliasRef=i);o=f[d].fontID}if(u)this.fontCache.put(i,c.promise);else{o||(o=this.idFactory.createObjId());this.fontCache.put("id_"+o,c.promise)}R(o,'The "fontID" must be defined.');t.loadedName="g_"+this.pdfManager.docId+"_f"+o;t.translated=c.promise;var p;try{p=this.translateFont(l)}catch(e){p=Promise.reject(e)}var m=this;p.then(function(e){if(void 0!==e.fontType){n.stats.fontTypes[e.fontType]=!0}c.resolve(new Be(t.loadedName,e,t))},function(e){m.handler.send("UnsupportedFeature",{featureId:C.font});try{var a=l.descriptor,r=a&&a.get("FontFile3"),i=r&&r.get("Subtype"),s=ie(l.type,i&&i.name);n.stats.fontTypes[s]=!0}catch(e){}c.resolve(new Be(t.loadedName,new $(e instanceof Error?e.message:e),t))});return c.promise},buildPath:function(e,t,a){var r=e.length-1;a||(a=[]);if(r<0||e.fnArray[r]!==S.constructPath)e.addOp(S.constructPath,[[t],a]);else{var i=e.argsArray[r];i[0].push(t);Array.prototype.push.apply(i[1],a)}},handleColorN:function(e,t,a,r,i,n,s){var o,c=a[a.length-1];if(z(c)&&(o=i.get(c.name))){var l=G(o)?o.dict:o,h=l.get("PatternType");if(1===h){var u=r.base?r.base.getRgb(a,0):null;return this.handleTilingType(t,u,n,o,l,e,s)}if(2===h){var f=l.get("Shading"),d=l.getArray("Matrix");o=oe.parseShading(f,d,this.xref,n,this.handler);e.addOp(t,o.getIR());return Promise.resolve()}return Promise.reject("Unknown PatternType: "+h)}e.addOp(t,a);return Promise.resolve()},getOperatorList:function(e,t,r,n,s){var o=this,c=this.xref,l=Object.create(null);R(n);r=r||q.empty;var h=r.get("XObject")||q.empty,u=r.get("Pattern")||q.empty,f=new Te(s||new Pe),d=new Me(e,c,f),g=new a;return new Promise(function e(a,s){var p=function(t){t.then(function(){try{e(a,s)}catch(e){s(e)}},s)};t.ensureNotTerminated();g.reset();for(var m,b,v,y,k={};!(m=g.check());){k.args=null;if(!d.read(k))break;var w=k.args,C=k.fn;switch(0|C){case S.paintXObject:if(w[0].code)break;var x=w[0].name;if(!x){F("XObject must be referred to by name.");continue}if(void 0!==l[x]){n.addOp(l[x].fn,l[x].args);w=null;continue}var A=h.get(x);if(A){R(G(A),"XObject should be a stream");var I=A.dict.get("Subtype");R(z(I),"XObject should have a Name subtype");if("Form"===I.name){f.save();p(o.buildFormXObject(r,A,null,n,t,f.state.clone()).then(function(){f.restore()}));return}if("Image"===I.name){o.buildPaintImageXObject(r,A,!1,n,x,l);w=null;continue}if("PS"===I.name){P("Ignored XObject subtype PS");continue}O("Unhandled XObject subtype "+I.name)}break;case S.setFont:var B=w[1];p(o.handleSetFont(r,w,null,n,t,f.state).then(function(e){n.addDependency(e);n.addOp(S.setFont,[e,B])}));return;case S.endInlineImage:
+var T=w[0].cacheKey;if(T){var M=l[T];if(void 0!==M){n.addOp(M.fn,M.args);w=null;continue}}o.buildPaintImageXObject(r,w[0],!0,n,T,l);w=null;continue;case S.showText:w[0]=o.handleText(w[0],f.state);break;case S.showSpacedText:var D=w[0],U=[],N=D.length,j=f.state;for(b=0;b<N;++b){var H=D[b];L(H)?Array.prototype.push.apply(U,o.handleText(H,j)):E(H)&&U.push(H)}w[0]=U;C=S.showText;break;case S.nextLineShowText:n.addOp(S.nextLine);w[0]=o.handleText(w[0],f.state);C=S.showText;break;case S.nextLineSetSpacingShowText:n.addOp(S.nextLine);n.addOp(S.setWordSpacing,[w.shift()]);n.addOp(S.setCharSpacing,[w.shift()]);w[0]=o.handleText(w[0],f.state);C=S.showText;break;case S.setTextRenderingMode:f.state.textRenderingMode=w[0];break;case S.setFillColorSpace:f.state.fillColorSpace=Z.parse(w[0],c,r);continue;case S.setStrokeColorSpace:f.state.strokeColorSpace=Z.parse(w[0],c,r);continue;case S.setFillColor:y=f.state.fillColorSpace;w=y.getRgb(w,0);C=S.setFillRGBColor;break;case S.setStrokeColor:y=f.state.strokeColorSpace;w=y.getRgb(w,0);C=S.setStrokeRGBColor;break;case S.setFillGray:f.state.fillColorSpace=Z.singletons.gray;w=Z.singletons.gray.getRgb(w,0);C=S.setFillRGBColor;break;case S.setStrokeGray:f.state.strokeColorSpace=Z.singletons.gray;w=Z.singletons.gray.getRgb(w,0);C=S.setStrokeRGBColor;break;case S.setFillCMYKColor:f.state.fillColorSpace=Z.singletons.cmyk;w=Z.singletons.cmyk.getRgb(w,0);C=S.setFillRGBColor;break;case S.setStrokeCMYKColor:f.state.strokeColorSpace=Z.singletons.cmyk;w=Z.singletons.cmyk.getRgb(w,0);C=S.setStrokeRGBColor;break;case S.setFillRGBColor:f.state.fillColorSpace=Z.singletons.rgb;w=Z.singletons.rgb.getRgb(w,0);break;case S.setStrokeRGBColor:f.state.strokeColorSpace=Z.singletons.rgb;w=Z.singletons.rgb.getRgb(w,0);break;case S.setFillColorN:y=f.state.fillColorSpace;if("Pattern"===y.name){p(o.handleColorN(n,S.setFillColorN,w,y,u,r,t));return}w=y.getRgb(w,0);C=S.setFillRGBColor;break;case S.setStrokeColorN:y=f.state.strokeColorSpace;if("Pattern"===y.name){p(o.handleColorN(n,S.setStrokeColorN,w,y,u,r,t));return}w=y.getRgb(w,0);C=S.setStrokeRGBColor;break;case S.shadingFill:var X=r.get("Shading");R(X,"No shading resource found");var V=X.get(w[0].name);R(V,"No shading object found");w=[oe.parseShading(V,null,c,r,o.handler).getIR()];C=S.shadingFill;break;case S.setGState:var W=w[0],K=r.get("ExtGState");if(!_(K)||!K.has(W.name))break;var Y=K.get(W.name);p(o.setGState(r,Y,n,t,f));return;case S.moveTo:case S.lineTo:case S.curveTo:case S.curveTo2:case S.curveTo3:case S.closePath:case S.rectangle:o.buildPath(n,C,w);continue;case S.markPoint:case S.markPointProps:case S.beginMarkedContent:case S.beginMarkedContentProps:case S.endMarkedContent:case S.beginCompat:case S.endCompat:continue;default:if(null!==w){for(b=0,v=w.length;b<v&&!(w[b]instanceof q);b++);if(b<v){F("getOperatorList - ignoring operator: "+C);continue}}}n.addOp(C,w)}if(m)p(i);else{for(b=0,v=d.savedStatesDepth;b<v;b++)n.addOp(S.restore,[]);a()}})},getTextContent:function(e,t,r,n,s,o){function c(){if(b.initialized)return b;var e=I.font;e.loadedName in m.styles||(m.styles[e.loadedName]={fontFamily:e.fallbackName,ascent:e.ascent,descent:e.descent,vertical:e.vertical});b.fontName=e.loadedName;var t=[I.fontSize*I.textHScale,0,0,I.fontSize,0,I.textRise];if(e.isType3Font&&I.fontMatrix!==k&&1===I.fontSize){var a=e.bbox[3]-e.bbox[1];if(a>0){a*=I.fontMatrix[3];t[3]*=a}}var r=B.transform(I.ctm,B.transform(I.textMatrix,t));b.transform=r;if(e.vertical){b.width=Math.sqrt(r[0]*r[0]+r[1]*r[1]);b.height=0;b.vertical=!0}else{b.width=0;b.height=Math.sqrt(r[2]*r[2]+r[3]*r[3]);b.vertical=!1}var i=I.textLineMatrix[0],n=I.textLineMatrix[1],s=Math.sqrt(i*i+n*n);i=I.ctm[0];n=I.ctm[1];var o=Math.sqrt(i*i+n*n);b.textAdvanceScale=o*s;b.lastAdvanceWidth=0;b.lastAdvanceHeight=0;var c=e.spaceWidth/1e3*I.fontSize;if(c){b.spaceWidth=c;b.fakeSpaceMin=c*v;b.fakeMultiSpaceMin=c*y;b.fakeMultiSpaceMax=c*C;b.textRunBreakAllowed=!e.isMonospace}else{b.spaceWidth=0;b.fakeSpaceMin=1/0;b.fakeMultiSpaceMin=1/0;b.fakeMultiSpaceMax=0;b.textRunBreakAllowed=!1}b.initialized=!0;return b}function l(e){for(var t,a=0,r=e.length;a<r&&(t=e.charCodeAt(a))>=32&&t<=127;)a++;return a<r?e.replace(p," "):e}function h(e){var t=e.str.join(""),a=fe(t,-1,e.vertical);return{str:s?l(a.str):a.str,dir:a.dir,width:e.width,height:e.height,transform:e.transform,fontName:e.fontName}}function u(e,t){return x.loadFont(e,t,r).then(function(e){I.font=e.font;I.fontMatrix=e.font.fontMatrix||k})}function f(e){for(var t=I.font,a=c(),r=0,i=0,n=t.charsToGlyphs(e),s=0;s<n.length;s++){var o=n[s],l=null;l=t.vertical&&o.vmetric?o.vmetric[0]:o.width;var h=o.unicode,u=Ce();void 0!==u[h]&&(h=u[h]);h=xe(h);var f=I.charSpacing;if(o.isSpace){var g=I.wordSpacing;f+=g;g>0&&d(g,a.str)}var p=0,m=0;if(t.vertical){m=l*I.fontMatrix[0]*I.fontSize+f;i+=m}else{p=(l*I.fontMatrix[0]*I.fontSize+f)*I.textHScale;r+=p}I.translateTextMatrix(p,m);a.str.push(h)}if(t.vertical){a.lastAdvanceHeight=i;a.height+=Math.abs(i)}else{a.lastAdvanceWidth=r;a.width+=r}return a}function d(e,t){if(!(e<b.fakeSpaceMin))if(e<b.fakeMultiSpaceMin)t.push(" ");else for(var a=Math.round(e/b.spaceWidth);a-- >0;)t.push(" ")}function g(){if(b.initialized){b.width*=b.textAdvanceScale;b.height*=b.textAdvanceScale;m.items.push(h(b));b.initialized=!1;b.str.length=0}}n=n||new Te(new Oe);var p=/\s/g,m={items:[],styles:Object.create(null)},b={initialized:!1,str:[],width:0,height:0,vertical:!1,lastAdvanceWidth:0,lastAdvanceHeight:0,textAdvanceScale:0,spaceWidth:0,fakeSpaceMin:1/0,fakeMultiSpaceMin:1/0,fakeMultiSpaceMax:-0,textRunBreakAllowed:!1,transform:null,fontName:null},v=.3,y=1.5,C=4,x=this,A=this.xref;r=A.fetchIfRef(r)||q.empty;var I,T=null,O=Object.create(null),P=new Me(e,A,n),L=new a;return new Promise(function e(a,l){var h=function(t){t.then(function(){try{e(a,l)}catch(e){l(e)}},l)};t.ensureNotTerminated();L.reset();for(var p,v={},y=[];!(p=L.check());){y.length=0;v.args=y;if(!P.read(v))break;I=n.state;var k=v.fn;y=v.args;var C,A;switch(0|k){case S.setFont:var D=y[0].name,F=y[1];if(I.font&&D===I.fontName&&F===I.fontSize)break;g();I.fontName=D;I.fontSize=F;h(u(D,null));return;case S.setTextRise:g();I.textRise=y[0];break;case S.setHScale:g();I.textHScale=y[0]/100;break;case S.setLeading:g();I.leading=y[0];break;case S.moveText:var U=!!I.font&&0===(I.font.vertical?y[0]:y[1]);C=y[0]-y[1];if(o&&U&&b.initialized&&C>0&&C<=b.fakeMultiSpaceMax){I.translateTextLineMatrix(y[0],y[1]);b.width+=y[0]-b.lastAdvanceWidth;b.height+=y[1]-b.lastAdvanceHeight;A=y[0]-b.lastAdvanceWidth-(y[1]-b.lastAdvanceHeight);d(A,b.str);break}g();I.translateTextLineMatrix(y[0],y[1]);I.textMatrix=I.textLineMatrix.slice();break;case S.setLeadingMoveText:g();I.leading=-y[1];I.translateTextLineMatrix(y[0],y[1]);I.textMatrix=I.textLineMatrix.slice();break;case S.nextLine:g();I.carriageReturn();break;case S.setTextMatrix:C=I.calcTextLineMatrixAdvance(y[0],y[1],y[2],y[3],y[4],y[5]);if(o&&null!==C&&b.initialized&&C.value>0&&C.value<=b.fakeMultiSpaceMax){I.translateTextLineMatrix(C.width,C.height);b.width+=C.width-b.lastAdvanceWidth;b.height+=C.height-b.lastAdvanceHeight;A=C.width-b.lastAdvanceWidth-(C.height-b.lastAdvanceHeight);d(A,b.str);break}g();I.setTextMatrix(y[0],y[1],y[2],y[3],y[4],y[5]);I.setTextLineMatrix(y[0],y[1],y[2],y[3],y[4],y[5]);break;case S.setCharSpacing:I.charSpacing=y[0];break;case S.setWordSpacing:I.wordSpacing=y[0];break;case S.beginText:g();I.textMatrix=w.slice();I.textLineMatrix=w.slice();break;case S.showSpacedText:for(var N,j=y[0],H=0,X=j.length;H<X;H++)if("string"==typeof j[H])f(j[H]);else if(E(j[H])){c();C=j[H]*I.fontSize/1e3;var V=!1;if(I.font.vertical){N=C;I.translateTextMatrix(0,N);V=b.textRunBreakAllowed&&C>b.fakeMultiSpaceMax;V||(b.height+=N)}else{C=-C;N=C*I.textHScale;I.translateTextMatrix(N,0);V=b.textRunBreakAllowed&&C>b.fakeMultiSpaceMax;V||(b.width+=N)}V?g():C>0&&d(C,b.str)}break;case S.showText:f(y[0]);break;case S.nextLineShowText:g();I.carriageReturn();f(y[0]);break;case S.nextLineSetSpacingShowText:g();I.wordSpacing=y[0];I.charSpacing=y[1];I.carriageReturn();f(y[2]);break;case S.paintXObject:g();if(y[0].code)break;T||(T=r.get("XObject")||q.empty);var W=y[0].name;if(O.key===W){if(O.texts){B.appendToArray(m.items,O.texts.items);B.extendObj(m.styles,O.texts.styles)}break}var K=T.get(W);if(!K)break;R(G(K),"XObject should be a stream");var Y=K.dict.get("Subtype");R(z(Y),"XObject should have a Name subtype");if("Form"!==Y.name){O.key=W;O.texts=null;break}n.save();var J=K.dict.getArray("Matrix");M(J)&&6===J.length&&n.transform(J);h(x.getTextContent(K,t,K.dict.get("Resources")||r,n,s,o).then(function(e){B.appendToArray(m.items,e.items);B.extendObj(m.styles,e.styles);n.restore();O.key=W;O.texts=e}));return;case S.setGState:g();var Z=y[0],Q=r.get("ExtGState");if(!_(Q)||!z(Z))break;var $=Q.get(Z.name);if(!_($))break;var ee=$.get("Font");if(ee){I.fontName=null;I.fontSize=ee[1];h(u(null,ee[0]));return}}}if(p)h(i);else{g();a(m)}})},extractDataStructures:function(e,t,a){var r=this.xref,i=e.get("ToUnicode")||t.get("ToUnicode"),n=i?this.readToUnicode(i):Promise.resolve(void 0);if(a.composite){var s=e.get("CIDSystemInfo");_(s)&&(a.cidSystemInfo={registry:s.get("Registry"),ordering:s.get("Ordering"),supplement:s.get("Supplement")});var o=e.get("CIDToGIDMap");G(o)&&(a.cidToGidMap=this.readCidToGidMap(o))}var c,l=[],h=null;if(e.has("Encoding")){c=e.get("Encoding");if(_(c)){h=c.get("BaseEncoding");h=z(h)?h.name:null;if(c.has("Differences"))for(var u=c.get("Differences"),f=0,d=0,g=u.length;d<g;d++){var p=r.fetchIfRef(u[d]);E(p)?f=p:z(p)?l[f++]=p.name:O("Invalid entry in 'Differences' array: "+p)}}else z(c)?h=c.name:O("Encoding is not a Name nor a Dict");"MacRomanEncoding"!==h&&"MacExpertEncoding"!==h&&"WinAnsiEncoding"!==h&&(h=null)}if(h)a.defaultEncoding=ve(h).slice();else{var m=!!(a.flags&ee.Symbolic),b=!!(a.flags&ee.Nonsymbolic);c=ge;"TrueType"!==a.type||b||(c=de);if(m){c=pe;a.file||(/Symbol/i.test(a.name)?c=me:/Dingbats/i.test(a.name)&&(c=be))}a.defaultEncoding=c}a.differences=l;a.baseEncodingName=h;a.hasEncoding=!!h||l.length>0;a.dict=e;return n.then(function(e){a.toUnicode=e;return this.buildToUnicode(a)}.bind(this)).then(function(e){a.toUnicode=e;return a})},buildToUnicode:function(e){e.hasIncludedToUnicodeMap=!!e.toUnicode&&e.toUnicode.length>0;if(e.hasIncludedToUnicodeMap)return Promise.resolve(e.toUnicode);var t,a,r;if(!e.composite){t=[];var i=e.defaultEncoding.slice(),n=e.baseEncodingName,s=e.differences;for(a in s){r=s[a];".notdef"!==r&&(i[a]=r)}var o=Ae();for(a in i){r=i[a];if(""!==r)if(void 0!==o[r])t[a]=String.fromCharCode(o[r]);else{var c=0;switch(r[0]){case"G":3===r.length&&(c=parseInt(r.substr(1),16));break;case"g":5===r.length&&(c=parseInt(r.substr(1),16));break;case"C":case"c":r.length>=3&&(c=+r.substr(1));break;default:var l=Se(r,o);-1!==l&&(c=l)}if(c){if(n&&c===+a){var h=ve(n);if(h&&(r=h[a])){t[a]=String.fromCharCode(o[r]);continue}}t[a]=String.fromCharCode(c)}}}return Promise.resolve(new re(t))}if(e.composite&&(e.cMap.builtInCMap&&!(e.cMap instanceof he)||"Adobe"===e.cidSystemInfo.registry&&("GB1"===e.cidSystemInfo.ordering||"CNS1"===e.cidSystemInfo.ordering||"Japan1"===e.cidSystemInfo.ordering||"Korea1"===e.cidSystemInfo.ordering))){var u=e.cidSystemInfo.registry,f=e.cidSystemInfo.ordering,d=U.get(u+"-"+f+"-UCS2");return le.create({encoding:d,fetchBuiltInCMap:this.fetchBuiltInCMap,useCMap:null}).then(function(a){var r=e.cMap;t=[];r.forEach(function(e,r){R(r<=65535,"Max size of CID is 65,535");var i=a.lookup(r);i&&(t[e]=String.fromCharCode((i.charCodeAt(0)<<8)+i.charCodeAt(1)))});return new re(t)})}return Promise.resolve(new ae(e.firstChar,e.lastChar))},readToUnicode:function(e){var t=e;return z(t)?le.create({encoding:t,fetchBuiltInCMap:this.fetchBuiltInCMap,useCMap:null}).then(function(e){return e instanceof he?new ae(0,65535):new re(e.getMap())}):G(t)?le.create({encoding:t,fetchBuiltInCMap:this.fetchBuiltInCMap,useCMap:null}).then(function(e){if(e instanceof he)return new ae(0,65535);var t=new Array(e.length);e.forEach(function(e,a){for(var r=[],i=0;i<a.length;i+=2){var n=a.charCodeAt(i)<<8|a.charCodeAt(i+1);if(55296==(63488&n)){i+=2;var s=a.charCodeAt(i)<<8|a.charCodeAt(i+1);r.push(((1023&n)<<10)+(1023&s)+65536)}else r.push(n)}t[e]=String.fromCharCode.apply(String,r)});return new re(t)}):Promise.resolve(null)},readCidToGidMap:function(e){for(var t=e.getBytes(),a=[],r=0,i=t.length;r<i;r++){var n=t[r++]<<8|t[r];if(0!==n){a[r>>1]=n}}return a},extractWidths:function(e,t,a){var r,i,n,s,o,c,l,h,u=this.xref,f=[],d=0,g=[];if(a.composite){d=e.get("DW")||1e3;h=e.get("W");if(h)for(i=0,n=h.length;i<n;i++){c=u.fetchIfRef(h[i++]);l=u.fetchIfRef(h[i]);if(M(l))for(s=0,o=l.length;s<o;s++)f[c++]=u.fetchIfRef(l[s]);else{var p=u.fetchIfRef(h[++i]);for(s=c;s<=l;s++)f[s]=p}}if(a.vertical){var m=e.getArray("DW2")||[880,-1e3];r=[m[1],.5*d,m[0]];m=e.get("W2");if(m)for(i=0,n=m.length;i<n;i++){c=u.fetchIfRef(m[i++]);l=u.fetchIfRef(m[i]);if(M(l))for(s=0,o=l.length;s<o;s++)g[c++]=[u.fetchIfRef(l[s++]),u.fetchIfRef(l[s++]),u.fetchIfRef(l[s])];else{var b=[u.fetchIfRef(m[++i]),u.fetchIfRef(m[++i]),u.fetchIfRef(m[++i])];for(s=c;s<=l;s++)g[s]=b}}}}else{var v=a.firstChar;h=e.get("Widths");if(h){s=v;for(i=0,n=h.length;i<n;i++)f[s++]=u.fetchIfRef(h[i]);d=parseFloat(t.get("MissingWidth"))||0}else{var y=e.get("BaseFont");if(z(y)){var k=this.getBaseFontMetrics(y.name);f=this.buildCharCodeToWidth(k.widths,a);d=k.defaultWidth}}}var w=!0,C=d;for(var x in f){var S=f[x];if(S)if(C){if(C!==S){w=!1;break}}else C=S}w&&(a.flags|=ee.FixedPitch);a.defaultWidth=d;a.widths=f;a.defaultVMetrics=r;a.vmetrics=g},isSerifFont:function(e){var t=e.split("-")[0];return t in ke()||-1!==t.search(/serif/gi)},getBaseFontMetrics:function(e){var t=0,a=[],r=!1,i=ye(),n=i[e]||e,s=ue();n in s||(n=this.isSerifFont(e)?"Times-Roman":"Helvetica");var o=s[n];if(E(o)){t=o;r=!0}else a=o();return{defaultWidth:t,monospace:r,widths:a}},buildCharCodeToWidth:function(e,t){for(var a=Object.create(null),r=t.differences,i=t.defaultEncoding,n=0;n<256;n++)n in r&&e[r[n]]?a[n]=e[r[n]]:n in i&&e[i[n]]&&(a[n]=e[i[n]]);return a},preEvaluateFont:function(e){var t=e,a=e.get("Subtype");R(z(a),"invalid font Subtype");var r,i=!1;if("Type0"===a.name){var n=e.get("DescendantFonts");R(n,"Descendant fonts are not specified");e=M(n)?this.xref.fetchIfRef(n[0]):n;a=e.get("Subtype");R(z(a),"invalid font Subtype");i=!0}var s=e.get("FontDescriptor");if(s){var o=new Q,c=t.getRaw("Encoding");if(z(c))o.update(c.name);else if(H(c))o.update(c.toString());else if(_(c))for(var l=c.getKeys(),h=0,u=l.length;h<u;h++){var f=c.getRaw(l[h]);if(z(f))o.update(f.name);else if(H(f))o.update(f.toString());else if(M(f)){for(var d=f.length,g=new Array(d),p=0;p<d;p++){var m=f[p];z(m)?g[p]=m.name:(E(m)||H(m))&&(g[p]=m.toString())}o.update(g.join())}}var b=e.get("ToUnicode")||t.get("ToUnicode");if(G(b)){var v=b.str||b;r=v.buffer?new Uint8Array(v.buffer.buffer,0,v.bufferLength):new Uint8Array(v.bytes.buffer,v.start,v.end-v.start);o.update(r)}else z(b)&&o.update(b.name);var y=e.get("Widths")||t.get("Widths");if(y){r=new Uint8Array(new Uint32Array(y).buffer);o.update(r)}}return{descriptor:s,dict:e,baseDict:t,composite:i,type:a.name,hash:o?o.hexdigest():""}},translateFont:function(e){var t,a=e.baseDict,r=e.dict,i=e.composite,n=e.descriptor,s=e.type,o=i?65535:255;if(!n){if("Type3"!==s){var c=r.get("BaseFont");R(z(c),"Base font is not specified");c=c.name.replace(/[,_]/g,"-");var l=this.getBaseFontMetrics(c),h=c.split("-")[0],u=(this.isSerifFont(h)?ee.Serif:0)|(l.monospace?ee.FixedPitch:0)|(we()[h]?ee.Symbolic:ee.Nonsymbolic);t={type:s,name:c,widths:l.widths,defaultWidth:l.defaultWidth,flags:u,firstChar:0,lastChar:o};return this.extractDataStructures(r,r,t).then(function(e){e.widths=this.buildCharCodeToWidth(l.widths,e);return new te(c,null,e)}.bind(this))}n=new q(null);n.set("FontName",U.get(s));n.set("FontBBox",r.getArray("FontBBox"))}var f=r.get("FirstChar")||0,d=r.get("LastChar")||o,g=n.get("FontName"),p=r.get("BaseFont");L(g)&&(g=U.get(g));L(p)&&(p=U.get(p));if("Type3"!==s){var m=g&&g.name,b=p&&p.name;if(m!==b){P("The FontDescriptor's FontName is \""+m+'" but should be the same as the Font\'s BaseFont "'+b+'"');m&&b&&0===b.indexOf(m)&&(g=p)}}g=g||p;R(z(g),"invalid font name");var v=n.get("FontFile","FontFile2","FontFile3");if(v&&v.dict){var y=v.dict.get("Subtype");y&&(y=y.name);var w=v.dict.get("Length1"),C=v.dict.get("Length2"),x=v.dict.get("Length3")}t={type:s,name:g.name,subtype:y,file:v,length1:w,length2:C,length3:x,loadedName:a.loadedName,composite:i,wideChars:i,fixedPitch:!1,fontMatrix:r.getArray("FontMatrix")||k,firstChar:f||0,lastChar:d||o,bbox:n.getArray("FontBBox"),ascent:n.get("Ascent"),descent:n.get("Descent"),xHeight:n.get("XHeight"),capHeight:n.get("CapHeight"),flags:n.get("Flags"),italicAngle:n.get("ItalicAngle"),coded:!1};var S;if(i){var A=a.get("Encoding");z(A)&&(t.cidEncoding=A.name);S=le.create({encoding:A,fetchBuiltInCMap:this.fetchBuiltInCMap,useCMap:null}).then(function(e){t.cMap=e;t.vertical=t.cMap.vertical})}else S=Promise.resolve(void 0);return S.then(function(){return this.extractDataStructures(r,a,t)}.bind(this)).then(function(e){this.extractWidths(r,n,e);"Type3"===s&&(e.isType3Font=!0);return new te(g.name,v,e)}.bind(this))}};return t}(),Be=function(){function e(e,t,a){this.loadedName=e;this.font=t;this.dict=a;this.type3Loaded=null;this.sent=!1}e.prototype={send:function(e){if(!this.sent){var t=this.font.exportData();e.send("commonobj",[this.loadedName,"Font",t]);this.sent=!0}},loadType3Data:function(e,t,a,r){R(this.font.isType3Font);if(this.type3Loaded)return this.type3Loaded;for(var i=this.font,n=Promise.resolve(),s=this.dict.get("CharProcs"),o=this.dict.get("Resources")||t,c=s.getKeys(),l=Object.create(null),h=0,u=c.length;h<u;++h)n=n.then(function(t){var i=s.get(t),n=new Re;return e.getOperatorList(i,r,o,n).then(function(){l[t]=n.getIR();a.addDependencies(n.dependencies)},function(e){F('Type3 font resource "'+t+'" is not available');var a=new Re;l[t]=a.getIR()})}.bind(this,c[h]));this.type3Loaded=n.then(function(){i.charProcOperatorList=l});return this.type3Loaded}};return e}(),Re=function(){function e(e){for(var t=[],a=e.fnArray,r=e.argsArray,i=0,n=e.length;i<n;i++)switch(a[i]){case S.paintInlineImageXObject:case S.paintInlineImageXObjectGroup:case S.paintImageMaskXObject:var s=r[i][0];s.cached||t.push(s.data.buffer)}return t}function t(e,t,a){this.messageHandler=t;this.fnArray=[];this.argsArray=[];this.dependencies=Object.create(null);this._totalLength=0;this.pageIndex=a;this.intent=e}t.prototype={get length(){return this.argsArray.length},get totalLength(){return this._totalLength+this.length},addOp:function(e,t){this.fnArray.push(e);this.argsArray.push(t);this.messageHandler&&(this.fnArray.length>=1e3?this.flush():this.fnArray.length>=995&&(e===S.restore||e===S.endText)&&this.flush())},addDependency:function(e){if(!(e in this.dependencies)){this.dependencies[e]=!0;this.addOp(S.dependency,[e])}},addDependencies:function(e){for(var t in e)this.addDependency(t)},addOpList:function(e){B.extendObj(this.dependencies,e.dependencies);for(var t=0,a=e.length;t<a;t++)this.addOp(e.fnArray[t],e.argsArray[t])},getIR:function(){return{fnArray:this.fnArray,argsArray:this.argsArray,length:this.length}},flush:function(t){"oplist"!==this.intent&&(new Ee).optimize(this);var a=e(this),r=this.length;this._totalLength+=r;this.messageHandler.send("RenderPageChunk",{operatorList:{fnArray:this.fnArray,argsArray:this.argsArray,lastChunk:t,length:r},pageIndex:this.pageIndex,intent:this.intent},a);this.dependencies=Object.create(null);this.fnArray.length=0;this.argsArray.length=0}};return t}(),Te=function(){function e(e){this.state=e;this.stateStack=[]}e.prototype={save:function(){var e=this.state;this.stateStack.push(this.state);this.state=e.clone()},restore:function(){var e=this.stateStack.pop();e&&(this.state=e)},transform:function(e){this.state.ctm=B.transform(this.state.ctm,e)}};return e}(),Oe=function(){function e(){this.ctm=new Float32Array(w);this.fontName=null;this.fontSize=0;this.font=null;this.fontMatrix=k;this.textMatrix=w.slice();this.textLineMatrix=w.slice();this.charSpacing=0;this.wordSpacing=0;this.leading=0;this.textHScale=1;this.textRise=0}e.prototype={setTextMatrix:function(e,t,a,r,i,n){var s=this.textMatrix;s[0]=e;s[1]=t;s[2]=a;s[3]=r;s[4]=i;s[5]=n},setTextLineMatrix:function(e,t,a,r,i,n){var s=this.textLineMatrix;s[0]=e;s[1]=t;s[2]=a;s[3]=r;s[4]=i;s[5]=n},translateTextMatrix:function(e,t){var a=this.textMatrix;a[4]=a[0]*e+a[2]*t+a[4];a[5]=a[1]*e+a[3]*t+a[5]},translateTextLineMatrix:function(e,t){var a=this.textLineMatrix;a[4]=a[0]*e+a[2]*t+a[4];a[5]=a[1]*e+a[3]*t+a[5]},calcTextLineMatrixAdvance:function(e,t,a,r,i,n){var s=this.font;if(!s)return null;var o=this.textLineMatrix;if(e!==o[0]||t!==o[1]||a!==o[2]||r!==o[3])return null;var c=i-o[4],l=n-o[5];if(s.vertical&&0!==c||!s.vertical&&0!==l)return null;var h,u,f=e*r-t*a;if(s.vertical){h=-l*a/f;u=l*e/f}else{h=c*r/f;u=-c*t/f}return{width:h,height:u,value:s.vertical?u:h}},calcRenderMatrix:function(e){var t=[this.fontSize*this.textHScale,0,0,this.fontSize,0,this.textRise];return B.transform(e,B.transform(this.textMatrix,t))},carriageReturn:function(){this.translateTextLineMatrix(0,-this.leading);this.textMatrix=this.textLineMatrix.slice()},clone:function(){var e=Object.create(this);e.textMatrix=this.textMatrix.slice();e.textLineMatrix=this.textLineMatrix.slice();e.fontMatrix=this.fontMatrix.slice();return e}};return e}(),Pe=function(){function e(){this.ctm=new Float32Array(w);this.font=null;this.textRenderingMode=A.FILL;this.fillColorSpace=Z.singletons.gray;this.strokeColorSpace=Z.singletons.gray}e.prototype={clone:function(){return Object.create(this)}};return e}(),Me=function(){function e(e,a,r){this.opMap=t();this.parser=new Y(new K(e,this.opMap),!1,a);this.stateManager=r;this.nonProcessedArgs=[]}var t=D(function(e){e.w={id:S.setLineWidth,numArgs:1,variableArgs:!1};e.J={id:S.setLineCap,numArgs:1,variableArgs:!1};e.j={id:S.setLineJoin,numArgs:1,variableArgs:!1};e.M={id:S.setMiterLimit,numArgs:1,variableArgs:!1};e.d={id:S.setDash,numArgs:2,variableArgs:!1};e.ri={id:S.setRenderingIntent,numArgs:1,variableArgs:!1};e.i={id:S.setFlatness,numArgs:1,variableArgs:!1};e.gs={id:S.setGState,numArgs:1,variableArgs:!1};e.q={id:S.save,numArgs:0,variableArgs:!1};e.Q={id:S.restore,numArgs:0,variableArgs:!1};e.cm={id:S.transform,numArgs:6,variableArgs:!1};e.m={id:S.moveTo,numArgs:2,variableArgs:!1};e.l={id:S.lineTo,numArgs:2,variableArgs:!1};e.c={id:S.curveTo,numArgs:6,variableArgs:!1};e.v={id:S.curveTo2,numArgs:4,variableArgs:!1};e.y={id:S.curveTo3,numArgs:4,variableArgs:!1};e.h={id:S.closePath,numArgs:0,variableArgs:!1};e.re={id:S.rectangle,numArgs:4,variableArgs:!1};e.S={id:S.stroke,numArgs:0,variableArgs:!1};e.s={id:S.closeStroke,numArgs:0,variableArgs:!1};e.f={id:S.fill,numArgs:0,variableArgs:!1};e.F={id:S.fill,numArgs:0,variableArgs:!1};e["f*"]={id:S.eoFill,numArgs:0,variableArgs:!1};e.B={id:S.fillStroke,numArgs:0,variableArgs:!1};e["B*"]={id:S.eoFillStroke,numArgs:0,variableArgs:!1};e.b={id:S.closeFillStroke,numArgs:0,variableArgs:!1};e["b*"]={id:S.closeEOFillStroke,numArgs:0,variableArgs:!1};e.n={id:S.endPath,numArgs:0,variableArgs:!1};e.W={id:S.clip,numArgs:0,variableArgs:!1};e["W*"]={id:S.eoClip,numArgs:0,variableArgs:!1};e.BT={id:S.beginText,numArgs:0,variableArgs:!1};e.ET={id:S.endText,numArgs:0,variableArgs:!1};e.Tc={id:S.setCharSpacing,numArgs:1,variableArgs:!1};e.Tw={id:S.setWordSpacing,numArgs:1,variableArgs:!1};e.Tz={id:S.setHScale,numArgs:1,variableArgs:!1};e.TL={id:S.setLeading,numArgs:1,variableArgs:!1};e.Tf={id:S.setFont,numArgs:2,variableArgs:!1};e.Tr={id:S.setTextRenderingMode,numArgs:1,variableArgs:!1};e.Ts={id:S.setTextRise,numArgs:1,variableArgs:!1};e.Td={id:S.moveText,numArgs:2,variableArgs:!1};e.TD={id:S.setLeadingMoveText,numArgs:2,variableArgs:!1};e.Tm={id:S.setTextMatrix,numArgs:6,variableArgs:!1};e["T*"]={id:S.nextLine,numArgs:0,variableArgs:!1};e.Tj={id:S.showText,numArgs:1,variableArgs:!1};e.TJ={id:S.showSpacedText,numArgs:1,variableArgs:!1};e["'"]={id:S.nextLineShowText,numArgs:1,variableArgs:!1};e['"']={id:S.nextLineSetSpacingShowText,numArgs:3,variableArgs:!1};e.d0={id:S.setCharWidth,numArgs:2,variableArgs:!1};e.d1={id:S.setCharWidthAndBounds,numArgs:6,variableArgs:!1};e.CS={id:S.setStrokeColorSpace,numArgs:1,variableArgs:!1};e.cs={id:S.setFillColorSpace,numArgs:1,variableArgs:!1};e.SC={id:S.setStrokeColor,numArgs:4,variableArgs:!0};e.SCN={id:S.setStrokeColorN,numArgs:33,variableArgs:!0};e.sc={id:S.setFillColor,numArgs:4,variableArgs:!0};e.scn={id:S.setFillColorN,numArgs:33,variableArgs:!0};e.G={id:S.setStrokeGray,numArgs:1,variableArgs:!1};e.g={id:S.setFillGray,numArgs:1,variableArgs:!1};e.RG={id:S.setStrokeRGBColor,numArgs:3,variableArgs:!1};e.rg={id:S.setFillRGBColor,numArgs:3,variableArgs:!1};e.K={id:S.setStrokeCMYKColor,numArgs:4,variableArgs:!1};e.k={id:S.setFillCMYKColor,numArgs:4,variableArgs:!1};e.sh={id:S.shadingFill,numArgs:1,variableArgs:!1};e.BI={id:S.beginInlineImage,numArgs:0,variableArgs:!1};e.ID={id:S.beginImageData,numArgs:0,variableArgs:!1};e.EI={id:S.endInlineImage,numArgs:1,variableArgs:!1};e.Do={id:S.paintXObject,numArgs:1,variableArgs:!1};e.MP={id:S.markPoint,numArgs:1,variableArgs:!1};e.DP={id:S.markPointProps,numArgs:2,variableArgs:!1};e.BMC={id:S.beginMarkedContent,numArgs:1,variableArgs:!1};e.BDC={id:S.beginMarkedContentProps,numArgs:2,variableArgs:!1};e.EMC={id:S.endMarkedContent,numArgs:0,variableArgs:!1};e.BX={id:S.beginCompat,numArgs:0,variableArgs:!1};e.EX={id:S.endCompat,numArgs:0,variableArgs:!1};e.BM=null;e.BD=null;e.true=null;e.fa=null;e.fal=null;e.fals=null;e.false=null;e.nu=null;e.nul=null;e.null=null});e.prototype={get savedStatesDepth(){return this.stateManager.stateStack.length},read:function(e){for(var t=e.args;;){var a=this.parser.getObj();if(j(a)){var r=a.cmd,i=this.opMap[r];if(!i){F('Unknown command "'+r+'"');continue}var n=i.id,s=i.numArgs,o=null!==t?t.length:0;if(i.variableArgs)o>s&&P("Command "+n+": expected [0,"+s+"] args, but received "+o+" args.");else{if(o!==s){for(var c=this.nonProcessedArgs;o>s;){c.push(t.shift());o--}for(;o<s&&0!==c.length;){null===t&&(t=[]);t.unshift(c.pop());o++}}if(o<s){F("Skipping command "+n+": expected "+s+" args, but received "+o+" args.");null!==t&&(t.length=0);continue}}this.preprocessCommand(n,t);e.fn=n;e.args=t;return!0}if(N(a))return!1;if(null!==a){null===t&&(t=[]);t.push(a);R(t.length<=33,"Too many arguments")}}},preprocessCommand:function(e,t){switch(0|e){case S.save:this.stateManager.save();break;case S.restore:this.stateManager.restore();break;case S.transform:this.stateManager.transform(t)}}};return e}(),Ee=function(){function e(e,t,a){for(var r=e,i=0,n=t.length-1;i<n;i++){var s=t[i];r=r[s]||(r[s]=[])}r[t[t.length-1]]=a}function t(e,t,a,r){for(var i=e+2,n=0;n<t;n++){var s=r[i+4*n],o=1===s.length&&s[0];if(!o||1!==o.width||1!==o.height||o.data.length&&(1!==o.data.length||0!==o.data[0]))break;a[i+4*n]=S.paintSolidColorImageMask}return t-n}function a(){}var r=[];e(r,[S.save,S.transform,S.paintInlineImageXObject,S.restore],function(e){for(var t=e.fnArray,a=e.argsArray,r=e.iCurr,i=r-3,n=r-2,s=r-1,o=i+4,c=t.length;o+3<c&&t[o]===S.save&&t[o+1]===S.transform&&t[o+2]===S.paintInlineImageXObject&&t[o+3]===S.restore;)o+=4;var l=Math.min((o-i)/4,200);if(l<10)return o;var h,u=0,f=[],d=0,g=1,p=1;for(h=0;h<l;h++){var m=a[n+(h<<2)],b=a[s+(h<<2)][0];if(g+b.width>1e3){u=Math.max(u,g);p+=d+2;g=0;d=0}f.push({transform:m,x:g,y:p,w:b.width,h:b.height});g+=b.width+2;d=Math.max(d,b.height)}var v=Math.max(u,g)+1,y=p+d+1,k=new Uint8Array(v*y*4),w=v<<2;for(h=0;h<l;h++){var C=a[s+(h<<2)][0].data,A=f[h].w<<2,I=0,B=f[h].x+f[h].y*v<<2;k.set(C.subarray(0,A),B-w);for(var R=0,T=f[h].h;R<T;R++){k.set(C.subarray(I,I+A),B);I+=A;B+=w}k.set(C.subarray(I-A,I),B);for(;B>=0;){C[B-4]=C[B];C[B-3]=C[B+1];C[B-2]=C[B+2];C[B-1]=C[B+3];C[B+A]=C[B+A-4];C[B+A+1]=C[B+A-3];C[B+A+2]=C[B+A-2];C[B+A+3]=C[B+A-1];B-=w}}t.splice(i,4*l,S.paintInlineImageXObjectGroup);a.splice(i,4*l,[{width:v,height:y,kind:x.RGBA_32BPP,data:k},f]);return i+1});e(r,[S.save,S.transform,S.paintImageMaskXObject,S.restore],function(e){for(var a=e.fnArray,r=e.argsArray,i=e.iCurr,n=i-3,s=i-2,o=i-1,c=n+4,l=a.length;c+3<l&&a[c]===S.save&&a[c+1]===S.transform&&a[c+2]===S.paintImageMaskXObject&&a[c+3]===S.restore;)c+=4;var h=(c-n)/4;h=t(n,h,a,r);if(h<10)return c;var u,f,d,g=!1,p=r[o][0];if(0===r[s][1]&&0===r[s][2]){g=!0;var m=r[s][0],b=r[s][3];f=s+4;var v=o+4;for(u=1;u<h;u++,f+=4,v+=4){d=r[f];if(r[v][0]!==p||d[0]!==m||0!==d[1]||0!==d[2]||d[3]!==b){u<10?g=!1:h=u;break}}}if(g){h=Math.min(h,1e3);var y=new Float32Array(2*h);f=s;for(u=0;u<h;u++,f+=4){d=r[f];y[u<<1]=d[4];y[1+(u<<1)]=d[5]}a.splice(n,4*h,S.paintImageMaskXObjectRepeat);r.splice(n,4*h,[p,m,b,y])}else{h=Math.min(h,100);var k=[];for(u=0;u<h;u++){d=r[s+(u<<2)];var w=r[o+(u<<2)][0];k.push({data:w.data,width:w.width,height:w.height,transform:d})}a.splice(n,4*h,S.paintImageMaskXObjectGroup);r.splice(n,4*h,[k])}return n+1});e(r,[S.save,S.transform,S.paintImageXObject,S.restore],function(e){var t=e.fnArray,a=e.argsArray,r=e.iCurr,i=r-3,n=r-2,s=r-1,o=r;if(0!==a[n][1]||0!==a[n][2])return o+1;for(var c=a[s][0],l=a[n][0],h=a[n][3],u=i+4,f=t.length;u+3<f&&t[u]===S.save&&t[u+1]===S.transform&&t[u+2]===S.paintImageXObject&&t[u+3]===S.restore&&a[u+1][0]===l&&0===a[u+1][1]&&0===a[u+1][2]&&a[u+1][3]===h&&a[u+2][0]===c;)u+=4;var d=Math.min((u-i)/4,1e3);if(d<3)return u;for(var g=new Float32Array(2*d),p=n,m=0;m<d;m++,p+=4){var b=a[p];g[m<<1]=b[4];g[1+(m<<1)]=b[5]}var v=[c,l,h,g];t.splice(i,4*d,S.paintImageXObjectRepeat);a.splice(i,4*d,v);return i+1});e(r,[S.beginText,S.setFont,S.setTextMatrix,S.showText,S.endText],function(e){for(var t=e.fnArray,a=e.argsArray,r=e.iCurr,i=r-4,n=r-3,s=r-2,o=r-1,c=r,l=a[n][0],h=a[n][1],u=i+5,f=t.length;u+4<f&&t[u]===S.beginText&&t[u+1]===S.setFont&&t[u+2]===S.setTextMatrix&&t[u+3]===S.showText&&t[u+4]===S.endText&&a[u+1][0]===l&&a[u+1][1]===h;)u+=5;var d=Math.min((u-i)/5,1e3);if(d<3)return u;var g=i;if(i>=4&&t[i-4]===t[n]&&t[i-3]===t[s]&&t[i-2]===t[o]&&t[i-1]===t[c]&&a[i-4][0]===l&&a[i-4][1]===h){d++;g-=5}for(var p=g+4,m=1;m<d;m++){t.splice(p,3);a.splice(p,3);p+=2}return p+1});a.prototype={optimize:function(e){for(var t,a=e.fnArray,i=e.argsArray,n={iCurr:0,fnArray:a,argsArray:i},s=0,o=a.length;s<o;){t=(t||r)[a[s]];if("function"==typeof t){n.iCurr=s;s=t(n);t=void 0;o=n.fnArray.length}else s++}}};return a}();t.OperatorList=Re;t.PartialEvaluator=Ie},function(e,t,a){"use strict";var r=a(0),i=a(10),n=r.info,s=r.warn,o=r.error,c=r.log2,l=r.readUint16,h=r.readUint32,u=i.ArithmeticDecoder,f=function(){function e(){this.failOnCorruptedImage=!1}function t(e,t){e.x0=Math.ceil(t.XOsiz/e.XRsiz);e.x1=Math.ceil(t.Xsiz/e.XRsiz);e.y0=Math.ceil(t.YOsiz/e.YRsiz);e.y1=Math.ceil(t.Ysiz/e.YRsiz);e.width=e.x1-e.x0;e.height=e.y1-e.y0}function a(e,t){for(var a,r=e.SIZ,i=[],n=Math.ceil((r.Xsiz-r.XTOsiz)/r.XTsiz),s=Math.ceil((r.Ysiz-r.YTOsiz)/r.YTsiz),o=0;o<s;o++)for(var c=0;c<n;c++){a={};a.tx0=Math.max(r.XTOsiz+c*r.XTsiz,r.XOsiz);a.ty0=Math.max(r.YTOsiz+o*r.YTsiz,r.YOsiz);a.tx1=Math.min(r.XTOsiz+(c+1)*r.XTsiz,r.Xsiz);a.ty1=Math.min(r.YTOsiz+(o+1)*r.YTsiz,r.Ysiz);a.width=a.tx1-a.tx0;a.height=a.ty1-a.ty0;a.components=[];i.push(a)}e.tiles=i;for(var l=r.Csiz,h=0,u=l;h<u;h++)for(var f=t[h],d=0,g=i.length;d<g;d++){var p={};a=i[d];p.tcx0=Math.ceil(a.tx0/f.XRsiz);p.tcy0=Math.ceil(a.ty0/f.YRsiz);p.tcx1=Math.ceil(a.tx1/f.XRsiz);p.tcy1=Math.ceil(a.ty1/f.YRsiz);p.width=p.tcx1-p.tcx0;p.height=p.tcy1-p.tcy0;a.components[h]=p}}function r(e,t,a){var r=t.codingStyleParameters,i={};if(r.entropyCoderWithCustomPrecincts){i.PPx=r.precinctsSizes[a].PPx;i.PPy=r.precinctsSizes[a].PPy}else{i.PPx=15;i.PPy=15}i.xcb_=a>0?Math.min(r.xcb,i.PPx-1):Math.min(r.xcb,i.PPx);i.ycb_=a>0?Math.min(r.ycb,i.PPy-1):Math.min(r.ycb,i.PPy);return i}function i(e,t,a){var r=1<<a.PPx,i=1<<a.PPy,n=0===t.resLevel,s=1<<a.PPx+(n?0:-1),o=1<<a.PPy+(n?0:-1),c=t.trx1>t.trx0?Math.ceil(t.trx1/r)-Math.floor(t.trx0/r):0,l=t.try1>t.try0?Math.ceil(t.try1/i)-Math.floor(t.try0/i):0,h=c*l;t.precinctParameters={
+precinctWidth:r,precinctHeight:i,numprecinctswide:c,numprecinctshigh:l,numprecincts:h,precinctWidthInSubband:s,precinctHeightInSubband:o}}function f(e,t,a){var r,i,n,s,o=a.xcb_,c=a.ycb_,l=1<<o,h=1<<c,u=t.tbx0>>o,f=t.tby0>>c,d=t.tbx1+l-1>>o,g=t.tby1+h-1>>c,p=t.resolution.precinctParameters,m=[],b=[];for(i=f;i<g;i++)for(r=u;r<d;r++){n={cbx:r,cby:i,tbx0:l*r,tby0:h*i,tbx1:l*(r+1),tby1:h*(i+1)};n.tbx0_=Math.max(t.tbx0,n.tbx0);n.tby0_=Math.max(t.tby0,n.tby0);n.tbx1_=Math.min(t.tbx1,n.tbx1);n.tby1_=Math.min(t.tby1,n.tby1);var v=Math.floor((n.tbx0_-t.tbx0)/p.precinctWidthInSubband),y=Math.floor((n.tby0_-t.tby0)/p.precinctHeightInSubband);s=v+y*p.numprecinctswide;n.precinctNumber=s;n.subbandType=t.type;n.Lblock=3;if(!(n.tbx1_<=n.tbx0_||n.tby1_<=n.tby0_)){m.push(n);var k=b[s];if(void 0!==k){r<k.cbxMin?k.cbxMin=r:r>k.cbxMax&&(k.cbxMax=r);i<k.cbyMin?k.cbxMin=i:i>k.cbyMax&&(k.cbyMax=i)}else b[s]=k={cbxMin:r,cbyMin:i,cbxMax:r,cbyMax:i};n.precinct=k}}t.codeblockParameters={codeblockWidth:o,codeblockHeight:c,numcodeblockwide:d-u+1,numcodeblockhigh:g-f+1};t.codeblocks=m;t.precincts=b}function d(e,t,a){for(var r=[],i=e.subbands,n=0,s=i.length;n<s;n++)for(var o=i[n],c=o.codeblocks,l=0,h=c.length;l<h;l++){var u=c[l];u.precinctNumber===t&&r.push(u)}return{layerNumber:a,codeblocks:r}}function g(e){for(var t=e.SIZ,a=e.currentTile.index,r=e.tiles[a],i=r.codingStyleDefaultParameters.layersCount,n=t.Csiz,s=0,c=0;c<n;c++)s=Math.max(s,r.components[c].codingStyleParameters.decompositionLevelsCount);var l=0,h=0,u=0,f=0;this.nextPacket=function(){for(;l<i;l++){for(;h<=s;h++){for(;u<n;u++){var e=r.components[u];if(!(h>e.codingStyleParameters.decompositionLevelsCount)){for(var t=e.resolutions[h],a=t.precinctParameters.numprecincts;f<a;){var c=d(t,f,l);f++;return c}f=0}}u=0}h=0}o("JPX Error: Out of packets")}}function p(e){for(var t=e.SIZ,a=e.currentTile.index,r=e.tiles[a],i=r.codingStyleDefaultParameters.layersCount,n=t.Csiz,s=0,c=0;c<n;c++)s=Math.max(s,r.components[c].codingStyleParameters.decompositionLevelsCount);var l=0,h=0,u=0,f=0;this.nextPacket=function(){for(;l<=s;l++){for(;h<i;h++){for(;u<n;u++){var e=r.components[u];if(!(l>e.codingStyleParameters.decompositionLevelsCount)){for(var t=e.resolutions[l],a=t.precinctParameters.numprecincts;f<a;){var c=d(t,f,h);f++;return c}f=0}}u=0}h=0}o("JPX Error: Out of packets")}}function m(e){var t,a,r,i,n=e.SIZ,s=e.currentTile.index,c=e.tiles[s],l=c.codingStyleDefaultParameters.layersCount,h=n.Csiz,u=0;for(r=0;r<h;r++){var f=c.components[r];u=Math.max(u,f.codingStyleParameters.decompositionLevelsCount)}var g=new Int32Array(u+1);for(a=0;a<=u;++a){var p=0;for(r=0;r<h;++r){var m=c.components[r].resolutions;a<m.length&&(p=Math.max(p,m[a].precinctParameters.numprecincts))}g[a]=p}t=0;a=0;r=0;i=0;this.nextPacket=function(){for(;a<=u;a++){for(;i<g[a];i++){for(;r<h;r++){var e=c.components[r];if(!(a>e.codingStyleParameters.decompositionLevelsCount)){var n=e.resolutions[a],s=n.precinctParameters.numprecincts;if(!(i>=s)){for(;t<l;){var f=d(n,i,t);t++;return f}t=0}}}r=0}i=0}o("JPX Error: Out of packets")}}function b(e){var t=e.SIZ,a=e.currentTile.index,r=e.tiles[a],i=r.codingStyleDefaultParameters.layersCount,n=t.Csiz,s=k(r),c=s,l=0,h=0,u=0,f=0,g=0;this.nextPacket=function(){for(;g<c.maxNumHigh;g++){for(;f<c.maxNumWide;f++){for(;u<n;u++){for(var e=r.components[u],t=e.codingStyleParameters.decompositionLevelsCount;h<=t;h++){var a=e.resolutions[h],p=s.components[u].resolutions[h],m=y(f,g,p,c,a);if(null!==m){for(;l<i;){var b=d(a,m,l);l++;return b}l=0}}h=0}u=0}f=0}o("JPX Error: Out of packets")}}function v(e){var t=e.SIZ,a=e.currentTile.index,r=e.tiles[a],i=r.codingStyleDefaultParameters.layersCount,n=t.Csiz,s=k(r),c=0,l=0,h=0,u=0,f=0;this.nextPacket=function(){for(;h<n;++h){for(var e=r.components[h],t=s.components[h],a=e.codingStyleParameters.decompositionLevelsCount;f<t.maxNumHigh;f++){for(;u<t.maxNumWide;u++){for(;l<=a;l++){var g=e.resolutions[l],p=t.resolutions[l],m=y(u,f,p,t,g);if(null!==m){for(;c<i;){var b=d(g,m,c);c++;return b}c=0}}l=0}u=0}f=0}o("JPX Error: Out of packets")}}function y(e,t,a,r,i){var n=e*r.minWidth,s=t*r.minHeight;if(n%a.width!=0||s%a.height!=0)return null;var o=s/a.width*i.precinctParameters.numprecinctswide;return n/a.height+o}function k(e){for(var t=e.components.length,a=Number.MAX_VALUE,r=Number.MAX_VALUE,i=0,n=0,s=new Array(t),o=0;o<t;o++){for(var c=e.components[o],l=c.codingStyleParameters.decompositionLevelsCount,h=new Array(l+1),u=Number.MAX_VALUE,f=Number.MAX_VALUE,d=0,g=0,p=1,m=l;m>=0;--m){var b=c.resolutions[m],v=p*b.precinctParameters.precinctWidth,y=p*b.precinctParameters.precinctHeight;u=Math.min(u,v);f=Math.min(f,y);d=Math.max(d,b.precinctParameters.numprecinctswide);g=Math.max(g,b.precinctParameters.numprecinctshigh);h[m]={width:v,height:y};p<<=1}a=Math.min(a,u);r=Math.min(r,f);i=Math.max(i,d);n=Math.max(n,g);s[o]={resolutions:h,minWidth:u,minHeight:f,maxNumWide:d,maxNumHigh:g}}return{components:s,minWidth:a,minHeight:r,maxNumWide:i,maxNumHigh:n}}function w(e){for(var t=e.SIZ,a=e.currentTile.index,n=e.tiles[a],s=t.Csiz,c=0;c<s;c++){for(var l=n.components[c],h=l.codingStyleParameters.decompositionLevelsCount,u=[],d=[],y=0;y<=h;y++){var k=r(e,l,y),w={},C=1<<h-y;w.trx0=Math.ceil(l.tcx0/C);w.try0=Math.ceil(l.tcy0/C);w.trx1=Math.ceil(l.tcx1/C);w.try1=Math.ceil(l.tcy1/C);w.resLevel=y;i(e,w,k);u.push(w);var x;if(0===y){x={};x.type="LL";x.tbx0=Math.ceil(l.tcx0/C);x.tby0=Math.ceil(l.tcy0/C);x.tbx1=Math.ceil(l.tcx1/C);x.tby1=Math.ceil(l.tcy1/C);x.resolution=w;f(e,x,k);d.push(x);w.subbands=[x]}else{var S=1<<h-y+1,A=[];x={};x.type="HL";x.tbx0=Math.ceil(l.tcx0/S-.5);x.tby0=Math.ceil(l.tcy0/S);x.tbx1=Math.ceil(l.tcx1/S-.5);x.tby1=Math.ceil(l.tcy1/S);x.resolution=w;f(e,x,k);d.push(x);A.push(x);x={};x.type="LH";x.tbx0=Math.ceil(l.tcx0/S);x.tby0=Math.ceil(l.tcy0/S-.5);x.tbx1=Math.ceil(l.tcx1/S);x.tby1=Math.ceil(l.tcy1/S-.5);x.resolution=w;f(e,x,k);d.push(x);A.push(x);x={};x.type="HH";x.tbx0=Math.ceil(l.tcx0/S-.5);x.tby0=Math.ceil(l.tcy0/S-.5);x.tbx1=Math.ceil(l.tcx1/S-.5);x.tby1=Math.ceil(l.tcy1/S-.5);x.resolution=w;f(e,x,k);d.push(x);A.push(x);w.subbands=A}}l.resolutions=u;l.subbands=d}var I=n.codingStyleDefaultParameters.progressionOrder;switch(I){case 0:n.packetsIterator=new g(e);break;case 1:n.packetsIterator=new p(e);break;case 2:n.packetsIterator=new m(e);break;case 3:n.packetsIterator=new b(e);break;case 4:n.packetsIterator=new v(e);break;default:o("JPX Error: Unsupported progression order "+I)}}function C(e,t,a,r){function i(e){for(;u<e;){var r=t[a+h];h++;if(f){l=l<<7|r;u+=7;f=!1}else{l=l<<8|r;u+=8}255===r&&(f=!0)}u-=e;return l>>>u&(1<<e)-1}function n(e){if(255===t[a+h-1]&&t[a+h]===e){s(1);return!0}if(255===t[a+h]&&t[a+h+1]===e){s(2);return!0}return!1}function s(e){h+=e}function o(){u=0;if(f){h++;f=!1}}for(var l,h=0,u=0,f=!1,d=e.currentTile.index,g=e.tiles[d],p=e.COD.sopMarkerUsed,m=e.COD.ephMarkerUsed,b=g.packetsIterator;h<r;){o();p&&n(145)&&s(4);var v=b.nextPacket();if(i(1)){for(var y,k=v.layerNumber,w=[],C=0,x=v.codeblocks.length;C<x;C++){y=v.codeblocks[C];var S,A=y.precinct,I=y.cbx-A.cbxMin,B=y.cby-A.cbyMin,O=!1,P=!1;if(void 0!==y.included)O=!!i(1);else{A=y.precinct;var M,E;if(void 0!==A.inclusionTree)M=A.inclusionTree;else{var L=A.cbxMax-A.cbxMin+1,D=A.cbyMax-A.cbyMin+1;M=new T(L,D,k);E=new R(L,D);A.inclusionTree=M;A.zeroBitPlanesTree=E}if(M.reset(I,B,k))for(;;){if(!i(1)){M.incrementValue(k);break}S=!M.nextLevel();if(S){y.included=!0;O=P=!0;break}}}if(O){if(P){E=A.zeroBitPlanesTree;E.reset(I,B);for(;;)if(i(1)){S=!E.nextLevel();if(S)break}else E.incrementValue();y.zeroBitPlanes=E.value}for(var F=function(){if(0===i(1))return 1;if(0===i(1))return 2;var e=i(2);if(e<3)return e+3;e=i(5);if(e<31)return e+6;e=i(7);return e+37}();i(1);)y.Lblock++;var q=c(F),U=(F<1<<q?q-1:q)+y.Lblock,N=i(U);w.push({codeblock:y,codingpasses:F,dataLength:N})}}o();m&&n(146);for(;w.length>0;){var j=w.shift();y=j.codeblock;void 0===y.data&&(y.data=[]);y.data.push({data:t,start:a+h,end:a+h+j.dataLength,codingpasses:j.codingpasses});h+=j.dataLength}}}return h}function x(e,t,a,r,i,n,s,o){for(var c=r.tbx0,l=r.tby0,h=r.tbx1-r.tbx0,f=r.codeblocks,d="H"===r.type.charAt(0)?1:0,g="H"===r.type.charAt(1)?t:0,p=0,m=f.length;p<m;++p){var b=f[p],v=b.tbx1_-b.tbx0_,y=b.tby1_-b.tby0_;if(0!==v&&0!==y&&void 0!==b.data){var k,w;k=new O(v,y,b.subbandType,b.zeroBitPlanes,n);w=2;var C,x,S,A=b.data,I=0,B=0;for(C=0,x=A.length;C<x;C++){S=A[C];I+=S.end-S.start;B+=S.codingpasses}var R=new Uint8Array(I),T=0;for(C=0,x=A.length;C<x;C++){S=A[C];var P=S.data.subarray(S.start,S.end);R.set(P,T);T+=P.length}var M=new u(R,0,I);k.setDecoder(M);for(C=0;C<B;C++){switch(w){case 0:k.runSignificancePropagationPass();break;case 1:k.runMagnitudeRefinementPass();break;case 2:k.runCleanupPass();o&&k.checkSegmentationSymbol()}w=(w+1)%3}var E,L,D,F=b.tbx0_-c+(b.tby0_-l)*h,q=k.coefficentsSign,U=k.coefficentsMagnitude,N=k.bitsDecoded,j=s?0:.5;T=0;var _="LL"!==r.type;for(C=0;C<y;C++){var z=F/h|0,H=2*z*(t-h)+d+g;for(E=0;E<v;E++){L=U[T];if(0!==L){L=(L+j)*i;0!==q[T]&&(L=-L);D=N[T];var G=_?H+(F<<1):F;e[G]=s&&D>=n?L:L*(1<<n-D)}F++;T++}F+=h-v}}}}function S(e,t,a){for(var r=t.components[a],i=r.codingStyleParameters,n=r.quantizationParameters,s=i.decompositionLevelsCount,o=n.SPqcds,c=n.scalarExpounded,l=n.guardBits,h=i.segmentationSymbolUsed,u=e.components[a].precision,f=i.reversibleTransformation,d=f?new E:new M,g=[],p=0,m=0;m<=s;m++){for(var b=r.resolutions[m],v=b.trx1-b.trx0,y=b.try1-b.try0,k=new Float32Array(v*y),w=0,C=b.subbands.length;w<C;w++){var S,A;if(c){S=o[p].mu;A=o[p].epsilon;p++}else{S=o[0].mu;A=o[0].epsilon+(m>0?1-m:0)}var I=b.subbands[w],R=B[I.type];x(k,v,y,I,f?1:Math.pow(2,u+R-A)*(1+S/2048),l+A-1,f,h)}g.push({width:v,height:y,items:k})}var T=d.calculate(g,r.tcx0,r.tcy0);return{left:r.tcx0,top:r.tcy0,width:T.width,height:T.height,items:T.items}}function A(e){for(var t=e.SIZ,a=e.components,r=t.Csiz,i=[],n=0,s=e.tiles.length;n<s;n++){var o,c=e.tiles[n],l=[];for(o=0;o<r;o++)l[o]=S(e,c,o);var h,u,f,d,g,p,m,b,v,y,k,w,C,x,A,I=l[0],B=new Uint8Array(I.items.length*r),R={left:I.left,top:I.top,width:I.width,height:I.height,items:B},T=0;if(c.codingStyleDefaultParameters.multipleComponentTransform){var O=4===r,P=l[0].items,M=l[1].items,E=l[2].items,L=O?l[3].items:null;h=a[0].precision-8;u=.5+(128<<h);f=255*(1<<h);g=.5*f;d=-g;var D=c.components[0],F=r-3;m=P.length;if(D.codingStyleParameters.reversibleTransformation)for(p=0;p<m;p++,T+=F){b=P[p]+u;v=M[p];y=E[p];w=b-(y+v>>2);k=w+y;C=w+v;B[T++]=k<=0?0:k>=f?255:k>>h;B[T++]=w<=0?0:w>=f?255:w>>h;B[T++]=C<=0?0:C>=f?255:C>>h}else for(p=0;p<m;p++,T+=F){b=P[p]+u;v=M[p];y=E[p];k=b+1.402*y;w=b-.34413*v-.71414*y;C=b+1.772*v;B[T++]=k<=0?0:k>=f?255:k>>h;B[T++]=w<=0?0:w>=f?255:w>>h;B[T++]=C<=0?0:C>=f?255:C>>h}if(O)for(p=0,T=3;p<m;p++,T+=4){x=L[p];B[T]=x<=d?0:x>=g?255:x+u>>h}}else for(o=0;o<r;o++){var q=l[o].items;h=a[o].precision-8;u=.5+(128<<h);f=127.5*(1<<h);d=-f;for(T=o,p=0,m=q.length;p<m;p++){A=q[p];B[T]=A<=d?0:A>=f?255:A+u>>h;T+=r}}i.push(R)}return i}function I(e,t){for(var a=e.SIZ,r=a.Csiz,i=e.tiles[t],n=0;n<r;n++){var s=i.components[n],o=void 0!==e.currentTile.QCC[n]?e.currentTile.QCC[n]:e.currentTile.QCD;s.quantizationParameters=o;var c=void 0!==e.currentTile.COC[n]?e.currentTile.COC[n]:e.currentTile.COD;s.codingStyleParameters=c}i.codingStyleDefaultParameters=e.currentTile.COD}var B={LL:0,LH:1,HL:1,HH:2};e.prototype={parse:function(e){if(65359!==l(e,0))for(var t=0,a=e.length;t<a;){var r=8,i=h(e,t),c=h(e,t+4);t+=r;if(1===i){i=4294967296*h(e,t)+h(e,t+4);t+=8;r+=8}0===i&&(i=a-t+r);i<r&&o("JPX Error: Invalid box field size");var u=i-r,f=!0;switch(c){case 1785737832:f=!1;break;case 1668246642:var d=e[t];if(1===d){var g=h(e,t+3);switch(g){case 16:case 17:case 18:break;default:s("Unknown colorspace "+g)}}else 2===d&&n("ICC profile not supported");break;case 1785737827:this.parseCodestream(e,t,t+u);break;case 1783636e3:218793738!==h(e,t)&&s("Invalid JP2 signature");break;case 1783634458:case 1718909296:case 1920099697:case 1919251232:case 1768449138:break;default:var p=String.fromCharCode(c>>24&255,c>>16&255,c>>8&255,255&c);s("Unsupported header type "+c+" ("+p+")")}f&&(t+=u)}else this.parseCodestream(e,0,e.length)},parseImageProperties:function(e){for(var t=e.getByte();t>=0;){var a=t;t=e.getByte();if(65361===(a<<8|t)){e.skip(4);var r=e.getInt32()>>>0,i=e.getInt32()>>>0,n=e.getInt32()>>>0,s=e.getInt32()>>>0;e.skip(16);var c=e.getUint16();this.width=r-n;this.height=i-s;this.componentsCount=c;this.bitsPerComponent=8;return}}o("JPX Error: No size marker found in JPX stream")},parseCodestream:function(e,r,i){var n={},c=!1;try{for(var u=r;u+1<i;){var f=l(e,u);u+=2;var d,g,p,m,b,v,y=0;switch(f){case 65359:n.mainHeader=!0;break;case 65497:break;case 65361:y=l(e,u);var k={};k.Xsiz=h(e,u+4);k.Ysiz=h(e,u+8);k.XOsiz=h(e,u+12);k.YOsiz=h(e,u+16);k.XTsiz=h(e,u+20);k.YTsiz=h(e,u+24);k.XTOsiz=h(e,u+28);k.YTOsiz=h(e,u+32);var x=l(e,u+36);k.Csiz=x;var S=[];d=u+38;for(var B=0;B<x;B++){var R={precision:1+(127&e[d]),isSigned:!!(128&e[d]),XRsiz:e[d+1],YRsiz:e[d+1]};t(R,k);S.push(R)}n.SIZ=k;n.components=S;a(n,S);n.QCC=[];n.COC=[];break;case 65372:y=l(e,u);var T={};d=u+2;g=e[d++];switch(31&g){case 0:m=8;b=!0;break;case 1:m=16;b=!1;break;case 2:m=16;b=!0;break;default:throw new Error("Invalid SQcd value "+g)}T.noQuantization=8===m;T.scalarExpounded=b;T.guardBits=g>>5;p=[];for(;d<y+u;){var O={};if(8===m){O.epsilon=e[d++]>>3;O.mu=0}else{O.epsilon=e[d]>>3;O.mu=(7&e[d])<<8|e[d+1];d+=2}p.push(O)}T.SPqcds=p;if(n.mainHeader)n.QCD=T;else{n.currentTile.QCD=T;n.currentTile.QCC=[]}break;case 65373:y=l(e,u);var P={};d=u+2;var M;if(n.SIZ.Csiz<257)M=e[d++];else{M=l(e,d);d+=2}g=e[d++];switch(31&g){case 0:m=8;b=!0;break;case 1:m=16;b=!1;break;case 2:m=16;b=!0;break;default:throw new Error("Invalid SQcd value "+g)}P.noQuantization=8===m;P.scalarExpounded=b;P.guardBits=g>>5;p=[];for(;d<y+u;){O={};if(8===m){O.epsilon=e[d++]>>3;O.mu=0}else{O.epsilon=e[d]>>3;O.mu=(7&e[d])<<8|e[d+1];d+=2}p.push(O)}P.SPqcds=p;n.mainHeader?n.QCC[M]=P:n.currentTile.QCC[M]=P;break;case 65362:y=l(e,u);var E={};d=u+2;var L=e[d++];E.entropyCoderWithCustomPrecincts=!!(1&L);E.sopMarkerUsed=!!(2&L);E.ephMarkerUsed=!!(4&L);E.progressionOrder=e[d++];E.layersCount=l(e,d);d+=2;E.multipleComponentTransform=e[d++];E.decompositionLevelsCount=e[d++];E.xcb=2+(15&e[d++]);E.ycb=2+(15&e[d++]);var D=e[d++];E.selectiveArithmeticCodingBypass=!!(1&D);E.resetContextProbabilities=!!(2&D);E.terminationOnEachCodingPass=!!(4&D);E.verticalyStripe=!!(8&D);E.predictableTermination=!!(16&D);E.segmentationSymbolUsed=!!(32&D);E.reversibleTransformation=e[d++];if(E.entropyCoderWithCustomPrecincts){for(var F=[];d<y+u;){var q=e[d++];F.push({PPx:15&q,PPy:q>>4})}E.precinctsSizes=F}var U=[];E.selectiveArithmeticCodingBypass&&U.push("selectiveArithmeticCodingBypass");E.resetContextProbabilities&&U.push("resetContextProbabilities");E.terminationOnEachCodingPass&&U.push("terminationOnEachCodingPass");E.verticalyStripe&&U.push("verticalyStripe");E.predictableTermination&&U.push("predictableTermination");if(U.length>0){c=!0;throw new Error("Unsupported COD options ("+U.join(", ")+")")}if(n.mainHeader)n.COD=E;else{n.currentTile.COD=E;n.currentTile.COC=[]}break;case 65424:y=l(e,u);v={};v.index=l(e,u+2);v.length=h(e,u+4);v.dataEnd=v.length+u-2;v.partIndex=e[u+8];v.partsCount=e[u+9];n.mainHeader=!1;if(0===v.partIndex){v.COD=n.COD;v.COC=n.COC.slice(0);v.QCD=n.QCD;v.QCC=n.QCC.slice(0)}n.currentTile=v;break;case 65427:v=n.currentTile;if(0===v.partIndex){I(n,v.index);w(n)}y=v.dataEnd-u;C(n,e,u,y);break;case 65365:case 65367:case 65368:case 65380:y=l(e,u);break;case 65363:throw new Error("Codestream code 0xFF53 (COC) is not implemented");default:throw new Error("Unknown codestream code: "+f.toString(16))}u+=y}}catch(e){c||this.failOnCorruptedImage?o("JPX Error: "+e.message):s("JPX: Trying to recover from: "+e.message)}this.tiles=A(n);this.width=n.SIZ.Xsiz-n.SIZ.XOsiz;this.height=n.SIZ.Ysiz-n.SIZ.YOsiz;this.componentsCount=n.SIZ.Csiz}};var R=function(){function e(e,t){var a=c(Math.max(e,t))+1;this.levels=[];for(var r=0;r<a;r++){var i={width:e,height:t,items:[]};this.levels.push(i);e=Math.ceil(e/2);t=Math.ceil(t/2)}}e.prototype={reset:function(e,t){for(var a,r=0,i=0;r<this.levels.length;){a=this.levels[r];var n=e+t*a.width;if(void 0!==a.items[n]){i=a.items[n];break}a.index=n;e>>=1;t>>=1;r++}r--;a=this.levels[r];a.items[a.index]=i;this.currentLevel=r;delete this.value},incrementValue:function(){var e=this.levels[this.currentLevel];e.items[e.index]++},nextLevel:function(){var e=this.currentLevel,t=this.levels[e],a=t.items[t.index];e--;if(e<0){this.value=a;return!1}this.currentLevel=e;t=this.levels[e];t.items[t.index]=a;return!0}};return e}(),T=function(){function e(e,t,a){var r=c(Math.max(e,t))+1;this.levels=[];for(var i=0;i<r;i++){for(var n=new Uint8Array(e*t),s=0,o=n.length;s<o;s++)n[s]=a;var l={width:e,height:t,items:n};this.levels.push(l);e=Math.ceil(e/2);t=Math.ceil(t/2)}}e.prototype={reset:function(e,t,a){for(var r=0;r<this.levels.length;){var i=this.levels[r],n=e+t*i.width;i.index=n;var s=i.items[n];if(255===s)break;if(s>a){this.currentLevel=r;this.propagateValues();return!1}e>>=1;t>>=1;r++}this.currentLevel=r-1;return!0},incrementValue:function(e){var t=this.levels[this.currentLevel];t.items[t.index]=e+1;this.propagateValues()},propagateValues:function(){for(var e=this.currentLevel,t=this.levels[e],a=t.items[t.index];--e>=0;){t=this.levels[e];t.items[t.index]=a}},nextLevel:function(){var e=this.currentLevel,t=this.levels[e],a=t.items[t.index];t.items[t.index]=255;e--;if(e<0)return!1;this.currentLevel=e;t=this.levels[e];t.items[t.index]=a;return!0}};return e}(),O=function(){function e(e,i,n,s,o){this.width=e;this.height=i;this.contextLabelTable="HH"===n?r:"HL"===n?a:t;var c=e*i;this.neighborsSignificance=new Uint8Array(c);this.coefficentsSign=new Uint8Array(c);this.coefficentsMagnitude=o>14?new Uint32Array(c):o>6?new Uint16Array(c):new Uint8Array(c);this.processingFlags=new Uint8Array(c);var l=new Uint8Array(c);if(0!==s)for(var h=0;h<c;h++)l[h]=s;this.bitsDecoded=l;this.reset()}var t=new Uint8Array([0,5,8,0,3,7,8,0,4,7,8,0,0,0,0,0,1,6,8,0,3,7,8,0,4,7,8,0,0,0,0,0,2,6,8,0,3,7,8,0,4,7,8,0,0,0,0,0,2,6,8,0,3,7,8,0,4,7,8,0,0,0,0,0,2,6,8,0,3,7,8,0,4,7,8]),a=new Uint8Array([0,3,4,0,5,7,7,0,8,8,8,0,0,0,0,0,1,3,4,0,6,7,7,0,8,8,8,0,0,0,0,0,2,3,4,0,6,7,7,0,8,8,8,0,0,0,0,0,2,3,4,0,6,7,7,0,8,8,8,0,0,0,0,0,2,3,4,0,6,7,7,0,8,8,8]),r=new Uint8Array([0,1,2,0,1,2,2,0,2,2,2,0,0,0,0,0,3,4,5,0,4,5,5,0,5,5,5,0,0,0,0,0,6,7,7,0,7,7,7,0,7,7,7,0,0,0,0,0,8,8,8,0,8,8,8,0,8,8,8,0,0,0,0,0,8,8,8,0,8,8,8,0,8,8,8]);e.prototype={setDecoder:function(e){this.decoder=e},reset:function(){this.contexts=new Int8Array(19);this.contexts[0]=8;this.contexts[17]=92;this.contexts[18]=6},setNeighborsSignificance:function(e,t,a){var r,i=this.neighborsSignificance,n=this.width,s=this.height,o=t>0,c=t+1<n;if(e>0){r=a-n;o&&(i[r-1]+=16);c&&(i[r+1]+=16);i[r]+=4}if(e+1<s){r=a+n;o&&(i[r-1]+=16);c&&(i[r+1]+=16);i[r]+=4}o&&(i[a-1]+=1);c&&(i[a+1]+=1);i[a]|=128},runSignificancePropagationPass:function(){for(var e=this.decoder,t=this.width,a=this.height,r=this.coefficentsMagnitude,i=this.coefficentsSign,n=this.neighborsSignificance,s=this.processingFlags,o=this.contexts,c=this.contextLabelTable,l=this.bitsDecoded,h=0;h<a;h+=4)for(var u=0;u<t;u++)for(var f=h*t+u,d=0;d<4;d++,f+=t){var g=h+d;if(g>=a)break;s[f]&=-2;if(!r[f]&&n[f]){var p=c[n[f]],m=e.readBit(o,p);if(m){var b=this.decodeSignBit(g,u,f);i[f]=b;r[f]=1;this.setNeighborsSignificance(g,u,f);s[f]|=2}l[f]++;s[f]|=1}}},decodeSignBit:function(e,t,a){var r,i,n,s,o,c,l=this.width,h=this.height,u=this.coefficentsMagnitude,f=this.coefficentsSign;s=t>0&&0!==u[a-1];if(t+1<l&&0!==u[a+1]){n=f[a+1];if(s){i=f[a-1];r=1-n-i}else r=1-n-n}else if(s){i=f[a-1];r=1-i-i}else r=0;var d=3*r;s=e>0&&0!==u[a-l];if(e+1<h&&0!==u[a+l]){n=f[a+l];if(s){i=f[a-l];r=1-n-i+d}else r=1-n-n+d}else if(s){i=f[a-l];r=1-i-i+d}else r=d;if(r>=0){o=9+r;c=this.decoder.readBit(this.contexts,o)}else{o=9-r;c=1^this.decoder.readBit(this.contexts,o)}return c},runMagnitudeRefinementPass:function(){for(var e,t=this.decoder,a=this.width,r=this.height,i=this.coefficentsMagnitude,n=this.neighborsSignificance,s=this.contexts,o=this.bitsDecoded,c=this.processingFlags,l=a*r,h=4*a,u=0;u<l;u=e){e=Math.min(l,u+h);for(var f=0;f<a;f++)for(var d=u+f;d<e;d+=a)if(i[d]&&0==(1&c[d])){var g=16;if(0!=(2&c[d])){c[d]^=2;var p=127&n[d];g=0===p?15:14}var m=t.readBit(s,g);i[d]=i[d]<<1|m;o[d]++;c[d]|=1}}},runCleanupPass:function(){for(var e,t=this.decoder,a=this.width,r=this.height,i=this.neighborsSignificance,n=this.coefficentsMagnitude,s=this.coefficentsSign,o=this.contexts,c=this.contextLabelTable,l=this.bitsDecoded,h=this.processingFlags,u=a,f=2*a,d=3*a,g=0;g<r;g=e){e=Math.min(g+4,r);for(var p=g*a,m=g+3<r,b=0;b<a;b++){var v,y=p+b,k=m&&0===h[y]&&0===h[y+u]&&0===h[y+f]&&0===h[y+d]&&0===i[y]&&0===i[y+u]&&0===i[y+f]&&0===i[y+d],w=0,C=y,x=g;if(k){if(!t.readBit(o,18)){l[y]++;l[y+u]++;l[y+f]++;l[y+d]++;continue}w=t.readBit(o,17)<<1|t.readBit(o,17);if(0!==w){x=g+w;C+=w*a}v=this.decodeSignBit(x,b,C);s[C]=v;n[C]=1;this.setNeighborsSignificance(x,b,C);h[C]|=2;C=y;for(var S=g;S<=x;S++,C+=a)l[C]++;w++}for(x=g+w;x<e;x++,C+=a)if(!n[C]&&0==(1&h[C])){var A=c[i[C]],I=t.readBit(o,A);if(1===I){v=this.decodeSignBit(x,b,C);s[C]=v;n[C]=1;this.setNeighborsSignificance(x,b,C);h[C]|=2}l[C]++}}}},checkSegmentationSymbol:function(){var e=this.decoder,t=this.contexts;10!=(e.readBit(t,17)<<3|e.readBit(t,17)<<2|e.readBit(t,17)<<1|e.readBit(t,17))&&o("JPX Error: Invalid segmentation symbol")}};return e}(),P=function(){function e(){}e.prototype.calculate=function(e,t,a){for(var r=e[0],i=1,n=e.length;i<n;i++)r=this.iterate(r,e[i],t,a);return r};e.prototype.extend=function(e,t,a){var r=t-1,i=t+1,n=t+a-2,s=t+a;e[r--]=e[i++];e[s++]=e[n--];e[r--]=e[i++];e[s++]=e[n--];e[r--]=e[i++];e[s++]=e[n--];e[r]=e[i];e[s]=e[n]};e.prototype.iterate=function(e,t,a,r){var i,n,s,o,c,l,h=e.width,u=e.height,f=e.items,d=t.width,g=t.height,p=t.items;for(s=0,i=0;i<u;i++){o=2*i*d;for(n=0;n<h;n++,s++,o+=2)p[o]=f[s]}f=e.items=null;var m=new Float32Array(d+8);if(1===d){if(0!=(1&a))for(l=0,s=0;l<g;l++,s+=d)p[s]*=.5}else for(l=0,s=0;l<g;l++,s+=d){m.set(p.subarray(s,s+d),4);this.extend(m,4,d);this.filter(m,4,d);p.set(m.subarray(4,4+d),s)}var b=16,v=[];for(i=0;i<b;i++)v.push(new Float32Array(g+8));var y,k=0;e=4+g;if(1===g){if(0!=(1&r))for(c=0;c<d;c++)p[c]*=.5}else for(c=0;c<d;c++){if(0===k){b=Math.min(d-c,b);for(s=c,o=4;o<e;s+=d,o++)for(y=0;y<b;y++)v[y][o]=p[s+y];k=b}k--;var w=v[k];this.extend(w,4,g);this.filter(w,4,g);if(0===k){s=c-b+1;for(o=4;o<e;s+=d,o++)for(y=0;y<b;y++)p[s+y]=v[y][o]}}return{width:d,height:g,items:p}};return e}(),M=function(){function e(){P.call(this)}e.prototype=Object.create(P.prototype);e.prototype.filter=function(e,t,a){var r=a>>1;t|=0;var i,n,s,o,c=-1.586134342059924,l=-.052980118572961,h=.882911075530934,u=.443506852043971,f=1.230174104914001;i=t-3;for(n=r+4;n--;i+=2)e[i]*=1/f;i=t-2;s=u*e[i-1];for(n=r+3;n--;i+=2){o=u*e[i+1];e[i]=f*e[i]-s-o;if(!n--)break;i+=2;s=u*e[i+1];e[i]=f*e[i]-s-o}i=t-1;s=h*e[i-1];for(n=r+2;n--;i+=2){o=h*e[i+1];e[i]-=s+o;if(!n--)break;i+=2;s=h*e[i+1];e[i]-=s+o}i=t;s=l*e[i-1];for(n=r+1;n--;i+=2){o=l*e[i+1];e[i]-=s+o;if(!n--)break;i+=2;s=l*e[i+1];e[i]-=s+o}if(0!==r){i=t+1;s=c*e[i-1];for(n=r;n--;i+=2){o=c*e[i+1];e[i]-=s+o;if(!n--)break;i+=2;s=c*e[i+1];e[i]-=s+o}}};return e}(),E=function(){function e(){P.call(this)}e.prototype=Object.create(P.prototype);e.prototype.filter=function(e,t,a){var r=a>>1;t|=0;var i,n;for(i=t,n=r+1;n--;i+=2)e[i]-=e[i-1]+e[i+1]+2>>2;for(i=t+1,n=r;n--;i+=2)e[i]+=e[i-1]+e[i+1]>>1};return e}();return e}();t.JpxImage=f},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(13),s=a(5),o=a(12),c=a(3),l=r.InvalidPDFException,h=r.MissingDataException,u=r.XRefParseException,f=r.assert,d=r.bytesToString,g=r.createPromiseCapability,p=r.error,m=r.info,b=r.isArray,v=r.isBool,y=r.isInt,k=r.isString,w=r.shadow,C=r.stringToPDFString,x=r.stringToUTF8String,S=r.warn,A=r.createValidAbsoluteUrl,I=r.Util,B=i.Dict,R=i.Ref,T=i.RefSet,O=i.RefSetCache,P=i.isName,M=i.isCmd,E=i.isDict,L=i.isRef,D=i.isRefsEqual,F=i.isStream,q=n.CipherTransformFactory,U=s.Lexer,N=s.Parser,j=o.ChunkedStream,_=c.ColorSpace,z=function(){function e(e,t,a){this.pdfManager=e;this.xref=t;this.catDict=t.getCatalogObj();f(E(this.catDict),"catalog object is not a dictionary");this.fontCache=new O;this.builtInCMapCache=Object.create(null);this.pageKidsCountCache=new O;this.pageFactory=a;this.pagePromises=[]}e.prototype={get metadata(){var e=this.catDict.getRaw("Metadata");if(!L(e))return w(this,"metadata",null);var t,a=!!this.xref.encrypt&&this.xref.encrypt.encryptMetadata,r=this.xref.fetch(e,!a);if(r&&E(r.dict)){var i=r.dict.get("Type"),n=r.dict.get("Subtype");if(P(i,"Metadata")&&P(n,"XML"))try{t=x(d(r.getBytes()))}catch(e){if(e instanceof h)throw e;m("Skipping invalid metadata.")}}return w(this,"metadata",t)},get toplevelPagesDict(){var e=this.catDict.get("Pages");f(E(e),"invalid top-level pages dictionary");return w(this,"toplevelPagesDict",e)},get documentOutline(){var e=null;try{e=this.readDocumentOutline()}catch(e){if(e instanceof h)throw e;S("Unable to read document outline")}return w(this,"documentOutline",e)},readDocumentOutline:function(){var t=this.catDict.get("Outlines");if(!E(t))return null;t=t.getRaw("First");if(!L(t))return null;var a={items:[]},r=[{obj:t,parent:a}],i=new T;i.put(t);for(var n=this.xref,s=new Uint8Array(3);r.length>0;){var o=r.shift(),c=n.fetchIfRef(o.obj);if(null!==c){f(c.has("Title"),"Invalid outline item");var l={url:null,dest:null};e.parseDestDictionary({destDict:c,resultObj:l,docBaseUrl:this.pdfManager.docBaseUrl});var h=c.get("Title"),u=c.get("F")||0,d=c.getArray("C"),g=s;!b(d)||3!==d.length||0===d[0]&&0===d[1]&&0===d[2]||(g=_.singletons.rgb.getRgb(d,0));var p={dest:l.dest,url:l.url,unsafeUrl:l.unsafeUrl,newWindow:l.newWindow,title:C(h),color:g,count:c.get("Count"),bold:!!(2&u),italic:!!(1&u),items:[]};o.parent.items.push(p);t=c.getRaw("First");if(L(t)&&!i.has(t)){r.push({obj:t,parent:p});i.put(t)}t=c.getRaw("Next");if(L(t)&&!i.has(t)){r.push({obj:t,parent:o.parent});i.put(t)}}}return a.items.length>0?a.items:null},get numPages(){var e=this.toplevelPagesDict.get("Count");f(y(e),"page count in top level pages object is not an integer");return w(this,"num",e)},get destinations(){function e(e){return E(e)?e.get("D"):e}var t,a,r=this.xref,i={},n=this.catDict.get("Names");n&&n.has("Dests")?t=n.getRaw("Dests"):this.catDict.has("Dests")&&(a=this.catDict.get("Dests"));if(a){n=a;n.forEach(function(t,a){a&&(i[t]=e(a))})}if(t){var s=new X(t,r),o=s.getAll();for(var c in o)i[c]=e(o[c])}return w(this,"destinations",i)},getDestination:function(e){function t(e){return E(e)?e.get("D"):e}var a,r,i=this.xref,n=null,s=this.catDict.get("Names");s&&s.has("Dests")?a=s.getRaw("Dests"):this.catDict.has("Dests")&&(r=this.catDict.get("Dests"));if(r){var o=r.get(e);o&&(n=t(o))}if(a){n=t(new X(a,i).get(e))}return n},get pageLabels(){var e=null;try{e=this.readPageLabels()}catch(e){if(e instanceof h)throw e;S("Unable to read page labels.")}return w(this,"pageLabels",e)},readPageLabels:function(){var e=this.catDict.getRaw("PageLabels");if(!e)return null;for(var t=new Array(this.numPages),a=null,r="",i=new V(e,this.xref),n=i.getAll(),s="",o=1,c=0,l=this.numPages;c<l;c++){if(c in n){var h=n[c];f(E(h),"The PageLabel is not a dictionary.");var u=h.get("Type");f(!u||P(u,"PageLabel"),"Invalid type in PageLabel dictionary.");var d=h.get("S");f(!d||P(d),"Invalid style in PageLabel dictionary.");a=d?d.name:null;var g=h.get("P");f(!g||k(g),"Invalid prefix in PageLabel dictionary.");r=g?C(g):"";var p=h.get("St");f(!p||y(p)&&p>=1,"Invalid start in PageLabel dictionary.");o=p||1}switch(a){case"D":s=o;break;case"R":case"r":s=I.toRoman(o,"r"===a);break;case"A":case"a":for(var m="a"===a?97:65,b=o-1,v=String.fromCharCode(m+b%26),w=[],x=0,S=b/26|0;x<=S;x++)w.push(v);s=w.join("");break;default:f(!a,'Invalid style "'+a+'" in PageLabel dictionary.')}t[c]=r+s;s="";o++}return t},get attachments(){var e,t=this.xref,a=null,r=this.catDict.get("Names");r&&(e=r.getRaw("EmbeddedFiles"));if(e){var i=new X(e,t),n=i.getAll();for(var s in n){var o=new W(n[s],t);a||(a=Object.create(null));a[C(s)]=o.serializable}}return w(this,"attachments",a)},get javaScript(){function e(e){var t=e.get("S");if(P(t,"JavaScript")){var a=e.get("JS");if(F(a))a=d(a.getBytes());else if(!k(a))return;r.push(C(a))}}var t=this.xref,a=this.catDict.get("Names"),r=[];if(a&&a.has("JavaScript")){var i=new X(a.getRaw("JavaScript"),t),n=i.getAll();for(var s in n){var o=n[s];E(o)&&e(o)}}var c=this.catDict.get("OpenAction");if(E(c,"Action")){var l=c.get("S");if(P(l,"Named")){var h=c.get("N");P(h,"Print")&&r.push("print({});")}else e(c)}return w(this,"javaScript",r)},cleanup:function(){this.pageKidsCountCache.clear();var e=[];this.fontCache.forEach(function(t){e.push(t)});return Promise.all(e).then(function(e){for(var t=0,a=e.length;t<a;t++){delete e[t].dict.translated}this.fontCache.clear();this.builtInCMapCache=Object.create(null)}.bind(this))},getPage:function(e){e in this.pagePromises||(this.pagePromises[e]=this.getPageDict(e).then(function(t){var a=t[0],r=t[1];return this.pageFactory.createPage(e,a,r,this.fontCache,this.builtInCMapCache)}.bind(this)));return this.pagePromises[e]},getPageDict:function(e){function t(){for(;i.length;){var c=i.pop();if(L(c)){a=o.get(c);if(a>0&&n+a<e){n+=a;continue}s.fetchAsync(c).then(function(a){if(E(a,"Page")||E(a)&&!a.has("Kids"))if(e===n){c&&!o.has(c)&&o.put(c,1);r.resolve([a,c])}else{n++;t()}else{i.push(a);t()}},r.reject);return}f(E(c),"page dictionary kid reference points to wrong type of object");a=c.get("Count");var l=c.objId;l&&!o.has(l)&&o.put(l,a);if(n+a<=e)n+=a;else{var h=c.get("Kids");f(b(h),"page dictionary kids object is not an array");for(var u=h.length-1;u>=0;u--)i.push(h[u])}}r.reject("Page index "+e+" not found.")}var a,r=g(),i=[this.catDict.getRaw("Pages")],n=0,s=this.xref,o=this.pageKidsCountCache;t();return r.promise},getPageIndex:function(e){function t(t){var a,i=0;return r.fetchAsync(t).then(function(r){if(D(t,e)&&!E(r,"Page")&&(!E(r)||r.has("Type")||!r.has("Contents")))throw new Error("The reference does not point to a /Page Dict.");if(!r)return null;f(E(r),"node must be a Dict.");a=r.getRaw("Parent");return r.getAsync("Parent")}).then(function(e){if(!e)return null;f(E(e),"parent must be a Dict.");return e.getAsync("Kids")}).then(function(e){if(!e)return null;for(var n=[],s=!1,o=0;o<e.length;o++){var c=e[o];f(L(c),"kid must be a Ref.");if(c.num===t.num){s=!0;break}n.push(r.fetchAsync(c).then(function(e){if(e.has("Count")){var t=e.get("Count");i+=t}else i++}))}s||p("kid ref not found in parents kids");return Promise.all(n).then(function(){return[i,a]})})}function a(e){return t(e).then(function(e){if(!e)return i;var t=e[0],r=e[1];i+=t;return a(r)})}var r=this.xref,i=0;return a(e)}};e.parseDestDictionary=function(e){var t=e.destDict;if(E(t)){var a=e.resultObj;if("object"==typeof a){var r,i,n=e.docBaseUrl||null,s=t.get("A");if(E(s)){var o=s.get("S").name;switch(o){case"URI":r=s.get("URI");P(r)?r="/"+r.name:k(r)&&(r=function(e){return 0===e.indexOf("www.")?"http://"+e:e}(r));break;case"GoTo":i=s.get("D");break;case"Launch":case"GoToR":var c=s.get("F");E(c)?r=c.get("F")||null:k(c)&&(r=c);var l=s.get("D");if(l){P(l)&&(l=l.name);if(k(r)){var h=r.split("#")[0];k(l)?r=h+"#"+(/^\d+$/.test(l)?"nameddest=":"")+l:b(l)&&(r=h+"#"+JSON.stringify(l))}}var u=s.get("NewWindow");v(u)&&(a.newWindow=u);break;case"Named":var f=s.get("N");P(f)&&(a.action=f.name);break;case"JavaScript":var g,p=s.get("JS");F(p)?g=d(p.getBytes()):k(p)&&(g=p);if(g){var m=["app.launchURL","window.open"],y=new RegExp("^\\s*("+m.join("|").split(".").join("\\.")+")\\((?:'|\")([^'\"]*)(?:'|\")(?:,\\s*(\\w+)\\)|\\))","i"),w=y.exec(C(g));if(w&&w[2]){r=w[2];"true"===w[3]&&"app.launchURL"===w[1]&&(a.newWindow=!0);break}}default:S('Catalog_parseDestDictionary: Unrecognized link type "'+o+'".')}}else t.has("Dest")&&(i=t.get("Dest"));if(k(r)){r=function(e){try{return x(e)}catch(t){return e}}(r);var I=A(r,n)
+;I&&(a.url=I.href);a.unsafeUrl=r}if(i){P(i)&&(i=i.name);(k(i)||b(i))&&(a.dest=i)}}else S('Catalog_parseDestDictionary: "resultObj" must be an object.')}else S('Catalog_parseDestDictionary: "destDict" must be a dictionary.')};return e}(),H=function(){function e(e,t){this.stream=e;this.pdfManager=t;this.entries=[];this.xrefstms=Object.create(null);this.cache=[];this.stats={streamTypes:[],fontTypes:[]}}e.prototype={setStartXRef:function(e){this.startXRefQueue=[e]},parse:function(e){var t;if(e){S("Indexing all PDF objects");t=this.indexObjects()}else t=this.readXRef();t.assignXref(this);this.trailer=t;var a=t.get("Encrypt");if(E(a)){var r=t.get("ID"),i=r&&r.length?r[0]:"";a.suppressEncryption=!0;this.encrypt=new q(a,i,this.pdfManager.password)}(this.root=t.get("Root"))||p("Invalid root reference")},processXRefTable:function(e){"tableState"in this||(this.tableState={entryNum:0,streamPos:e.lexer.stream.pos,parserBuf1:e.buf1,parserBuf2:e.buf2});var t=this.readXRefTable(e);M(t,"trailer")||p("Invalid XRef table: could not find trailer dictionary");var a=e.getObj();!E(a)&&a.dict&&(a=a.dict);E(a)||p("Invalid XRef table: could not parse trailer dictionary");delete this.tableState;return a},readXRefTable:function(e){var t=e.lexer.stream,a=this.tableState;t.pos=a.streamPos;e.buf1=a.parserBuf1;e.buf2=a.parserBuf2;for(var r;;){if(!("firstEntryNum"in a&&"entryCount"in a)){if(M(r=e.getObj(),"trailer"))break;a.firstEntryNum=r;a.entryCount=e.getObj()}var i=a.firstEntryNum,n=a.entryCount;y(i)&&y(n)||p("Invalid XRef table: wrong types in subsection header");for(var s=a.entryNum;s<n;s++){a.streamPos=t.pos;a.entryNum=s;a.parserBuf1=e.buf1;a.parserBuf2=e.buf2;var o={};o.offset=e.getObj();o.gen=e.getObj();var c=e.getObj();M(c,"f")?o.free=!0:M(c,"n")&&(o.uncompressed=!0);y(o.offset)&&y(o.gen)&&(o.free||o.uncompressed)||p("Invalid entry in XRef subsection: "+i+", "+n);0===s&&o.free&&1===i&&(i=0);this.entries[s+i]||(this.entries[s+i]=o)}a.entryNum=0;a.streamPos=t.pos;a.parserBuf1=e.buf1;a.parserBuf2=e.buf2;delete a.firstEntryNum;delete a.entryCount}this.entries[0]&&!this.entries[0].free&&p("Invalid XRef table: unexpected first object");return r},processXRefStream:function(e){if(!("streamState"in this)){var t=e.dict,a=t.get("W"),r=t.get("Index");r||(r=[0,t.get("Size")]);this.streamState={entryRanges:r,byteWidths:a,entryNum:0,streamPos:e.pos}}this.readXRefStream(e);delete this.streamState;return e.dict},readXRefStream:function(e){var t,a,r=this.streamState;e.pos=r.streamPos;for(var i=r.byteWidths,n=i[0],s=i[1],o=i[2],c=r.entryRanges;c.length>0;){var l=c[0],h=c[1];y(l)&&y(h)||p("Invalid XRef range fields: "+l+", "+h);y(n)&&y(s)&&y(o)||p("Invalid XRef entry fields length: "+l+", "+h);for(t=r.entryNum;t<h;++t){r.entryNum=t;r.streamPos=e.pos;var u=0,f=0,d=0;for(a=0;a<n;++a)u=u<<8|e.getByte();0===n&&(u=1);for(a=0;a<s;++a)f=f<<8|e.getByte();for(a=0;a<o;++a)d=d<<8|e.getByte();var g={};g.offset=f;g.gen=d;switch(u){case 0:g.free=!0;break;case 1:g.uncompressed=!0;break;case 2:break;default:p("Invalid XRef entry type: "+u)}this.entries[l+t]||(this.entries[l+t]=g)}r.entryNum=0;r.streamPos=e.pos;c.splice(0,2)}},indexObjects:function(){function e(e,t,a){for(var r=a.length,i=e.length,n=0;t<i;){for(var s=0;s<r&&e[t+s]===a[s];)++s;if(s>=r)break;t++;n++}return n}var t=10,a=13,r=60,i=/^(\d+)\s+(\d+)\s+obj\b/,n=new Uint8Array([116,114,97,105,108,101,114]),s=new Uint8Array([115,116,97,114,116,120,114,101,102]),o=new Uint8Array([101,110,100,111,98,106]),c=new Uint8Array([47,88,82,101,102]);this.entries.length=0;var h=this.stream;h.pos=0;for(var u=h.getBytes(),f=h.start,d=u.length,g=[],p=[];f<d;){var m=u[f];if(9!==m&&m!==t&&m!==a&&32!==m)if(37!==m){var b,v=function(e,i){for(var n="",s=e[i];s!==t&&s!==a&&s!==r&&!(++i>=e.length);){n+=String.fromCharCode(s);s=e[i]}return n}(u,f);if(0!==v.indexOf("xref")||4!==v.length&&!/\s/.test(v[4]))if(b=i.exec(v)){void 0===this.entries[b[1]]&&(this.entries[b[1]]={offset:f-h.start,gen:0|b[2],uncompressed:!0});var y=e(u,f,o)+7,k=u.subarray(f,f+y),w=e(k,0,c);if(w<y&&k[w+5]<64){p.push(f-h.start);this.xrefstms[f-h.start]=1}f+=y}else if(0!==v.indexOf("trailer")||7!==v.length&&!/\s/.test(v[7]))f+=v.length+1;else{g.push(f);f+=e(u,f,s)}else{f+=e(u,f,n);g.push(f);f+=e(u,f,s)}}else do{++f;if(f>=d)break;m=u[f]}while(m!==t&&m!==a);else++f}var C,x;for(C=0,x=p.length;C<x;++C){this.startXRefQueue.push(p[C]);this.readXRef(!0)}var S;for(C=0,x=g.length;C<x;++C){h.pos=g[C];var A=new N(new U(h),!0,this,!0),I=A.getObj();if(M(I,"trailer")){S=A.getObj();if(E(S)&&S.has("ID"))return S}}if(S)return S;throw new l("Invalid PDF structure")},readXRef:function(e){var t=this.stream;try{for(;this.startXRefQueue.length;){var a=this.startXRefQueue[0];t.pos=a+t.start;var r,i=new N(new U(t),!0,this),n=i.getObj();if(M(n,"xref")){r=this.processXRefTable(i);this.topDict||(this.topDict=r);n=r.get("XRefStm");if(y(n)){var s=n;if(!(s in this.xrefstms)){this.xrefstms[s]=1;this.startXRefQueue.push(s)}}}else if(y(n)){y(i.getObj())&&M(i.getObj(),"obj")&&F(n=i.getObj())||p("Invalid XRef stream");r=this.processXRefStream(n);this.topDict||(this.topDict=r);r||p("Failed to read XRef stream")}else p("Invalid XRef stream header");n=r.get("Prev");y(n)?this.startXRefQueue.push(n):L(n)&&this.startXRefQueue.push(n.num);this.startXRefQueue.shift()}return this.topDict}catch(e){if(e instanceof h)throw e;m("(while reading XRef): "+e)}if(!e)throw new u},getEntry:function(e){var t=this.entries[e];return t&&!t.free&&t.offset?t:null},fetchIfRef:function(e,t){return L(e)?this.fetch(e,t):e},fetch:function(e,t){f(L(e),"ref object is not a reference");var a=e.num;if(a in this.cache){var r=this.cache[a];r instanceof B&&!r.objId&&(r.objId=e.toString());return r}var i=this.getEntry(a);if(null===i)return this.cache[a]=null;i=i.uncompressed?this.fetchUncompressed(e,i,t):this.fetchCompressed(i,t);E(i)?i.objId=e.toString():F(i)&&(i.dict.objId=e.toString());return i},fetchUncompressed:function(e,t,a){var r=e.gen,i=e.num;t.gen!==r&&p("inconsistent generation in XRef");var n=this.stream.makeSubStream(t.offset+this.stream.start),s=new N(new U(n),!0,this),o=s.getObj(),c=s.getObj(),l=s.getObj();y(o)&&parseInt(o,10)===i&&y(c)&&parseInt(c,10)===r&&M(l)||p("bad XRef entry");if(!M(l,"obj")){if(0===l.cmd.indexOf("obj")){i=parseInt(l.cmd.substring(3),10);if(!isNaN(i))return i}p("bad XRef entry")}t=this.encrypt&&!a?s.getObj(this.encrypt.createCipherTransform(i,r)):s.getObj();F(t)||(this.cache[i]=t);return t},fetchCompressed:function(e,t){var a=e.offset,r=this.fetch(new R(a,0));F(r)||p("bad ObjStm stream");var i=r.dict.get("First"),n=r.dict.get("N");y(i)&&y(n)||p("invalid first and n parameters for ObjStm stream");var s=new N(new U(r),!1,this);s.allowStreams=!0;var o,c,l=[],h=[];for(o=0;o<n;++o){c=s.getObj();y(c)||p("invalid object number in the ObjStm stream: "+c);h.push(c);var u=s.getObj();y(u)||p("invalid object offset in the ObjStm stream: "+u)}for(o=0;o<n;++o){l.push(s.getObj());M(s.buf1,"endobj")&&s.shift();c=h[o];var f=this.entries[c];f&&f.offset===a&&f.gen===o&&(this.cache[c]=l[o])}e=l[e.gen];void 0===e&&p("bad XRef entry for compressed object");return e},fetchIfRefAsync:function(e,t){return L(e)?this.fetchAsync(e,t):Promise.resolve(e)},fetchAsync:function(e,t){var a=this.stream.manager,r=this;return new Promise(function i(n,s){try{n(r.fetch(e,t))}catch(e){if(e instanceof h){a.requestRange(e.begin,e.end).then(function(){i(n,s)},s);return}s(e)}})},getCatalogObj:function(){return this.root}};return e}(),G=function(){function e(e,t){throw new Error("Cannot initialize NameOrNumberTree.")}e.prototype={getAll:function(){var e=Object.create(null);if(!this.root)return e;var t=this.xref,a=new T;a.put(this.root);for(var r=[this.root];r.length>0;){var i,n,s=t.fetchIfRef(r.shift());if(E(s))if(s.has("Kids")){var o=s.get("Kids");for(i=0,n=o.length;i<n;i++){var c=o[i];f(!a.has(c),'Duplicate entry in "'+this._type+'" tree.');r.push(c);a.put(c)}}else{var l=s.get(this._type);if(b(l))for(i=0,n=l.length;i<n;i+=2)e[t.fetchIfRef(l[i])]=t.fetchIfRef(l[i+1])}}return e},get:function(e){if(!this.root)return null;for(var t,a,r,i=this.xref,n=i.fetchIfRef(this.root),s=0;n.has("Kids");){if(++s>10){S('Search depth limit reached for "'+this._type+'" tree.');return null}var o=n.get("Kids");if(!b(o))return null;t=0;a=o.length-1;for(;t<=a;){r=t+a>>1;var c=i.fetchIfRef(o[r]),l=c.get("Limits");if(e<i.fetchIfRef(l[0]))a=r-1;else{if(!(e>i.fetchIfRef(l[1]))){n=i.fetchIfRef(o[r]);break}t=r+1}}if(t>a)return null}var h=n.get(this._type);if(b(h)){t=0;a=h.length-2;for(;t<=a;){r=t+a&-2;var u=i.fetchIfRef(h[r]);if(e<u)a=r-2;else{if(!(e>u))return i.fetchIfRef(h[r+1]);t=r+2}}}return null}};return e}(),X=function(){function e(e,t){this.root=e;this.xref=t;this._type="Names"}I.inherit(e,G,{});return e}(),V=function(){function e(e,t){this.root=e;this.xref=t;this._type="Nums"}I.inherit(e,G,{});return e}(),W=function(){function e(e,t){if(e&&E(e)){this.xref=t;this.root=e;e.has("FS")&&(this.fs=e.get("FS"));this.description=e.has("Desc")?C(e.get("Desc")):"";e.has("RF")&&S("Related file specifications are not supported");this.contentAvailable=!0;if(!e.has("EF")){this.contentAvailable=!1;S("Non-embedded file specifications are not supported")}}}function t(e){return e.has("UF")?e.get("UF"):e.has("F")?e.get("F"):e.has("Unix")?e.get("Unix"):e.has("Mac")?e.get("Mac"):e.has("DOS")?e.get("DOS"):null}e.prototype={get filename(){if(!this._filename&&this.root){var e=t(this.root)||"unnamed";this._filename=C(e).replace(/\\\\/g,"\\").replace(/\\\//g,"/").replace(/\\/g,"/")}return this._filename},get content(){if(!this.contentAvailable)return null;!this.contentRef&&this.root&&(this.contentRef=t(this.root.get("EF")));var e=null;if(this.contentRef){var a=this.xref,r=a.fetchIfRef(this.contentRef);r&&F(r)?e=r.getBytes():S("Embedded file specification points to non-existing/invalid content")}else S("Embedded file specification does not have a content");return e},get serializable(){return{filename:this.filename,content:this.content}}};return e}(),K=function(){function e(e){return L(e)||E(e)||b(e)||F(e)}function t(t,a){var r;if(E(t)||F(t)){var i;i=E(t)?t.map:t.dict.map;for(var n in i){r=i[n];e(r)&&a.push(r)}}else if(b(t))for(var s=0,o=t.length;s<o;s++){r=t[s];e(r)&&a.push(r)}}function a(e,t,a){this.obj=e;this.keys=t;this.xref=a;this.refSet=null;this.capability=null}a.prototype={load:function(){var e=this.keys;this.capability=g();if(!(this.xref.stream instanceof j)||0===this.xref.stream.getMissingChunks().length){this.capability.resolve();return this.capability.promise}this.refSet=new T;for(var t=[],a=0;a<e.length;a++)t.push(this.obj[e[a]]);this._walk(t);return this.capability.promise},_walk:function(e){for(var a=[],r=[];e.length;){var i=e.pop();if(L(i)){if(this.refSet.has(i))continue;try{var n=i;this.refSet.put(n);i=this.xref.fetch(i)}catch(e){if(!(e instanceof h))throw e;a.push(i);r.push({begin:e.begin,end:e.end})}}if(i&&i.getBaseStreams){for(var s=i.getBaseStreams(),o=!1,c=0;c<s.length;c++){var l=s[c];if(l.getMissingChunks&&l.getMissingChunks().length){o=!0;r.push({begin:l.start,end:l.end})}}o&&a.push(i)}t(i,e)}if(r.length)this.xref.stream.manager.requestRanges(r).then(function(){e=a;for(var t=0;t<a.length;t++){var r=a[t];L(r)&&this.refSet.remove(r)}this._walk(e)}.bind(this),this.capability.reject);else{this.refSet=null;this.capability.resolve()}}};return a}();t.Catalog=z;t.ObjectLoader=K;t.XRef=H;t.FileSpec=W},function(e,t,a){"use strict";var r=a(0),i=r.getLookupTableFactory,n=i(function(e){e.ArialNarrow="Helvetica";e["ArialNarrow-Bold"]="Helvetica-Bold";e["ArialNarrow-BoldItalic"]="Helvetica-BoldOblique";e["ArialNarrow-Italic"]="Helvetica-Oblique";e.ArialBlack="Helvetica";e["ArialBlack-Bold"]="Helvetica-Bold";e["ArialBlack-BoldItalic"]="Helvetica-BoldOblique";e["ArialBlack-Italic"]="Helvetica-Oblique";e["Arial-Black"]="Helvetica";e["Arial-Black-Bold"]="Helvetica-Bold";e["Arial-Black-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Black-Italic"]="Helvetica-Oblique";e.Arial="Helvetica";e["Arial-Bold"]="Helvetica-Bold";e["Arial-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Italic"]="Helvetica-Oblique";e["Arial-BoldItalicMT"]="Helvetica-BoldOblique";e["Arial-BoldMT"]="Helvetica-Bold";e["Arial-ItalicMT"]="Helvetica-Oblique";e.ArialMT="Helvetica";e["Courier-Bold"]="Courier-Bold";e["Courier-BoldItalic"]="Courier-BoldOblique";e["Courier-Italic"]="Courier-Oblique";e.CourierNew="Courier";e["CourierNew-Bold"]="Courier-Bold";e["CourierNew-BoldItalic"]="Courier-BoldOblique";e["CourierNew-Italic"]="Courier-Oblique";e["CourierNewPS-BoldItalicMT"]="Courier-BoldOblique";e["CourierNewPS-BoldMT"]="Courier-Bold";e["CourierNewPS-ItalicMT"]="Courier-Oblique";e.CourierNewPSMT="Courier";e.Helvetica="Helvetica";e["Helvetica-Bold"]="Helvetica-Bold";e["Helvetica-BoldItalic"]="Helvetica-BoldOblique";e["Helvetica-BoldOblique"]="Helvetica-BoldOblique";e["Helvetica-Italic"]="Helvetica-Oblique";e["Helvetica-Oblique"]="Helvetica-Oblique";e["Symbol-Bold"]="Symbol";e["Symbol-BoldItalic"]="Symbol";e["Symbol-Italic"]="Symbol";e.TimesNewRoman="Times-Roman";e["TimesNewRoman-Bold"]="Times-Bold";e["TimesNewRoman-BoldItalic"]="Times-BoldItalic";e["TimesNewRoman-Italic"]="Times-Italic";e.TimesNewRomanPS="Times-Roman";e["TimesNewRomanPS-Bold"]="Times-Bold";e["TimesNewRomanPS-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPS-BoldItalicMT"]="Times-BoldItalic";e["TimesNewRomanPS-BoldMT"]="Times-Bold";e["TimesNewRomanPS-Italic"]="Times-Italic";e["TimesNewRomanPS-ItalicMT"]="Times-Italic";e.TimesNewRomanPSMT="Times-Roman";e["TimesNewRomanPSMT-Bold"]="Times-Bold";e["TimesNewRomanPSMT-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPSMT-Italic"]="Times-Italic"}),s=i(function(e){e.CenturyGothic="Helvetica";e["CenturyGothic-Bold"]="Helvetica-Bold";e["CenturyGothic-BoldItalic"]="Helvetica-BoldOblique";e["CenturyGothic-Italic"]="Helvetica-Oblique";e.ComicSansMS="Comic Sans MS";e["ComicSansMS-Bold"]="Comic Sans MS-Bold";e["ComicSansMS-BoldItalic"]="Comic Sans MS-BoldItalic";e["ComicSansMS-Italic"]="Comic Sans MS-Italic";e.LucidaConsole="Courier";e["LucidaConsole-Bold"]="Courier-Bold";e["LucidaConsole-BoldItalic"]="Courier-BoldOblique";e["LucidaConsole-Italic"]="Courier-Oblique";e["MS-Gothic"]="MS Gothic";e["MS-Gothic-Bold"]="MS Gothic-Bold";e["MS-Gothic-BoldItalic"]="MS Gothic-BoldItalic";e["MS-Gothic-Italic"]="MS Gothic-Italic";e["MS-Mincho"]="MS Mincho";e["MS-Mincho-Bold"]="MS Mincho-Bold";e["MS-Mincho-BoldItalic"]="MS Mincho-BoldItalic";e["MS-Mincho-Italic"]="MS Mincho-Italic";e["MS-PGothic"]="MS PGothic";e["MS-PGothic-Bold"]="MS PGothic-Bold";e["MS-PGothic-BoldItalic"]="MS PGothic-BoldItalic";e["MS-PGothic-Italic"]="MS PGothic-Italic";e["MS-PMincho"]="MS PMincho";e["MS-PMincho-Bold"]="MS PMincho-Bold";e["MS-PMincho-BoldItalic"]="MS PMincho-BoldItalic";e["MS-PMincho-Italic"]="MS PMincho-Italic";e.NuptialScript="Times-Italic";e.Wingdings="ZapfDingbats"}),o=i(function(e){e["Adobe Jenson"]=!0;e["Adobe Text"]=!0;e.Albertus=!0;e.Aldus=!0;e.Alexandria=!0;e.Algerian=!0;e["American Typewriter"]=!0;e.Antiqua=!0;e.Apex=!0;e.Arno=!0;e.Aster=!0;e.Aurora=!0;e.Baskerville=!0;e.Bell=!0;e.Bembo=!0;e["Bembo Schoolbook"]=!0;e.Benguiat=!0;e["Berkeley Old Style"]=!0;e["Bernhard Modern"]=!0;e["Berthold City"]=!0;e.Bodoni=!0;e["Bauer Bodoni"]=!0;e["Book Antiqua"]=!0;e.Bookman=!0;e["Bordeaux Roman"]=!0;e["Californian FB"]=!0;e.Calisto=!0;e.Calvert=!0;e.Capitals=!0;e.Cambria=!0;e.Cartier=!0;e.Caslon=!0;e.Catull=!0;e.Centaur=!0;e["Century Old Style"]=!0;e["Century Schoolbook"]=!0;e.Chaparral=!0;e["Charis SIL"]=!0;e.Cheltenham=!0;e["Cholla Slab"]=!0;e.Clarendon=!0;e.Clearface=!0;e.Cochin=!0;e.Colonna=!0;e["Computer Modern"]=!0;e["Concrete Roman"]=!0;e.Constantia=!0;e["Cooper Black"]=!0;e.Corona=!0;e.Ecotype=!0;e.Egyptienne=!0;e.Elephant=!0;e.Excelsior=!0;e.Fairfield=!0;e["FF Scala"]=!0;e.Folkard=!0;e.Footlight=!0;e.FreeSerif=!0;e["Friz Quadrata"]=!0;e.Garamond=!0;e.Gentium=!0;e.Georgia=!0;e.Gloucester=!0;e["Goudy Old Style"]=!0;e["Goudy Schoolbook"]=!0;e["Goudy Pro Font"]=!0;e.Granjon=!0;e["Guardian Egyptian"]=!0;e.Heather=!0;e.Hercules=!0;e["High Tower Text"]=!0;e.Hiroshige=!0;e["Hoefler Text"]=!0;e["Humana Serif"]=!0;e.Imprint=!0;e["Ionic No. 5"]=!0;e.Janson=!0;e.Joanna=!0;e.Korinna=!0;e.Lexicon=!0;e["Liberation Serif"]=!0;e["Linux Libertine"]=!0;e.Literaturnaya=!0;e.Lucida=!0;e["Lucida Bright"]=!0;e.Melior=!0;e.Memphis=!0;e.Miller=!0;e.Minion=!0;e.Modern=!0;e["Mona Lisa"]=!0;e["Mrs Eaves"]=!0;e["MS Serif"]=!0;e["Museo Slab"]=!0;e["New York"]=!0;e["Nimbus Roman"]=!0;e["NPS Rawlinson Roadway"]=!0;e.NuptialScript=!0;e.Palatino=!0;e.Perpetua=!0;e.Plantin=!0;e["Plantin Schoolbook"]=!0;e.Playbill=!0;e["Poor Richard"]=!0;e["Rawlinson Roadway"]=!0;e.Renault=!0;e.Requiem=!0;e.Rockwell=!0;e.Roman=!0;e["Rotis Serif"]=!0;e.Sabon=!0;e.Scala=!0;e.Seagull=!0;e.Sistina=!0;e.Souvenir=!0;e.STIX=!0;e["Stone Informal"]=!0;e["Stone Serif"]=!0;e.Sylfaen=!0;e.Times=!0;e.Trajan=!0;e["Trinité"]=!0;e["Trump Mediaeval"]=!0;e.Utopia=!0;e["Vale Type"]=!0;e["Bitstream Vera"]=!0;e["Vera Serif"]=!0;e.Versailles=!0;e.Wanted=!0;e.Weiss=!0;e["Wide Latin"]=!0;e.Windsor=!0;e.XITS=!0}),c=i(function(e){e.Dingbats=!0;e.Symbol=!0;e.ZapfDingbats=!0}),l=i(function(e){e[2]=10;e[3]=32;e[4]=33;e[5]=34;e[6]=35;e[7]=36;e[8]=37;e[9]=38;e[10]=39;e[11]=40;e[12]=41;e[13]=42;e[14]=43;e[15]=44;e[16]=45;e[17]=46;e[18]=47;e[19]=48;e[20]=49;e[21]=50;e[22]=51;e[23]=52;e[24]=53;e[25]=54;e[26]=55;e[27]=56;e[28]=57;e[29]=58;e[30]=894;e[31]=60;e[32]=61;e[33]=62;e[34]=63;e[35]=64;e[36]=65;e[37]=66;e[38]=67;e[39]=68;e[40]=69;e[41]=70;e[42]=71;e[43]=72;e[44]=73;e[45]=74;e[46]=75;e[47]=76;e[48]=77;e[49]=78;e[50]=79;e[51]=80;e[52]=81;e[53]=82;e[54]=83;e[55]=84;e[56]=85;e[57]=86;e[58]=87;e[59]=88;e[60]=89;e[61]=90;e[62]=91;e[63]=92;e[64]=93;e[65]=94;e[66]=95;e[67]=96;e[68]=97;e[69]=98;e[70]=99;e[71]=100;e[72]=101;e[73]=102;e[74]=103;e[75]=104;e[76]=105;e[77]=106;e[78]=107;e[79]=108;e[80]=109;e[81]=110;e[82]=111;e[83]=112;e[84]=113;e[85]=114;e[86]=115;e[87]=116;e[88]=117;e[89]=118;e[90]=119;e[91]=120;e[92]=121;e[93]=122;e[94]=123;e[95]=124;e[96]=125;e[97]=126;e[98]=196;e[99]=197;e[100]=199;e[101]=201;e[102]=209;e[103]=214;e[104]=220;e[105]=225;e[106]=224;e[107]=226;e[108]=228;e[109]=227;e[110]=229;e[111]=231;e[112]=233;e[113]=232;e[114]=234;e[115]=235;e[116]=237;e[117]=236;e[118]=238;e[119]=239;e[120]=241;e[121]=243;e[122]=242;e[123]=244;e[124]=246;e[125]=245;e[126]=250;e[127]=249;e[128]=251;e[129]=252;e[130]=8224;e[131]=176;e[132]=162;e[133]=163;e[134]=167;e[135]=8226;e[136]=182;e[137]=223;e[138]=174;e[139]=169;e[140]=8482;e[141]=180;e[142]=168;e[143]=8800;e[144]=198;e[145]=216;e[146]=8734;e[147]=177;e[148]=8804;e[149]=8805;e[150]=165;e[151]=181;e[152]=8706;e[153]=8721;e[154]=8719;e[156]=8747;e[157]=170;e[158]=186;e[159]=8486;e[160]=230;e[161]=248;e[162]=191;e[163]=161;e[164]=172;e[165]=8730;e[166]=402;e[167]=8776;e[168]=8710;e[169]=171;e[170]=187;e[171]=8230;e[210]=218;e[223]=711;e[224]=321;e[225]=322;e[227]=353;e[229]=382;e[234]=253;e[252]=263;e[253]=268;e[254]=269;e[258]=258;e[260]=260;e[261]=261;e[265]=280;e[266]=281;e[268]=283;e[269]=313;e[275]=323;e[276]=324;e[278]=328;e[284]=345;e[285]=346;e[286]=347;e[292]=367;e[295]=377;e[296]=378;e[298]=380;e[305]=963;e[306]=964;e[307]=966;e[308]=8215;e[309]=8252;e[310]=8319;e[311]=8359;e[312]=8592;e[313]=8593;e[337]=9552;e[493]=1039;e[494]=1040;e[705]=1524;e[706]=8362;e[710]=64288;e[711]=64298;e[759]=1617;e[761]=1776;e[763]=1778;e[775]=1652;e[777]=1764;e[778]=1780;e[779]=1781;e[780]=1782;e[782]=771;e[783]=64726;e[786]=8363;e[788]=8532;e[790]=768;e[791]=769;e[792]=768;e[795]=803;e[797]=64336;e[798]=64337;e[799]=64342;e[800]=64343;e[801]=64344;e[802]=64345;e[803]=64362;e[804]=64363;e[805]=64364;e[2424]=7821;e[2425]=7822;e[2426]=7823;e[2427]=7824;e[2428]=7825;e[2429]=7826;e[2430]=7827;e[2433]=7682;e[2678]=8045;e[2679]=8046;e[2830]=1552;e[2838]=686;e[2840]=751;e[2842]=753;e[2843]=754;e[2844]=755;e[2846]=757;e[2856]=767;e[2857]=848;e[2858]=849;e[2862]=853;e[2863]=854;e[2864]=855;e[2865]=861;e[2866]=862;e[2906]=7460;e[2908]=7462;e[2909]=7463;e[2910]=7464;e[2912]=7466;e[2913]=7467;e[2914]=7468;e[2916]=7470;e[2917]=7471;e[2918]=7472;e[2920]=7474;e[2921]=7475;e[2922]=7476;e[2924]=7478;e[2925]=7479;e[2926]=7480;e[2928]=7482;e[2929]=7483;e[2930]=7484;e[2932]=7486;e[2933]=7487;e[2934]=7488;e[2936]=7490;e[2937]=7491;e[2938]=7492;e[2940]=7494;e[2941]=7495;e[2942]=7496;e[2944]=7498;e[2946]=7500;e[2948]=7502;e[2950]=7504;e[2951]=7505;e[2952]=7506;e[2954]=7508;e[2955]=7509;e[2956]=7510;e[2958]=7512;e[2959]=7513;e[2960]=7514;e[2962]=7516;e[2963]=7517;e[2964]=7518;e[2966]=7520;e[2967]=7521;e[2968]=7522;e[2970]=7524;e[2971]=7525;e[2972]=7526;e[2974]=7528;e[2975]=7529;e[2976]=7530;e[2978]=1537;e[2979]=1538;e[2980]=1539;e[2982]=1549;e[2983]=1551;e[2984]=1552;e[2986]=1554;e[2987]=1555;e[2988]=1556;e[2990]=1623;e[2991]=1624;e[2995]=1775;e[2999]=1791;e[3002]=64290;e[3003]=64291;e[3004]=64292;e[3006]=64294;e[3007]=64295;e[3008]=64296;e[3011]=1900;e[3014]=8223;e[3015]=8244;e[3017]=7532;e[3018]=7533;e[3019]=7534;e[3075]=7590;e[3076]=7591;e[3079]=7594;e[3080]=7595;e[3083]=7598;e[3084]=7599;e[3087]=7602;e[3088]=7603;e[3091]=7606;e[3092]=7607;e[3095]=7610;e[3096]=7611;e[3099]=7614;e[3100]=7615;e[3103]=7618;e[3104]=7619;e[3107]=8337;e[3108]=8338;e[3116]=1884;e[3119]=1885;e[3120]=1885;e[3123]=1886;e[3124]=1886;e[3127]=1887;e[3128]=1887;e[3131]=1888;e[3132]=1888;e[3135]=1889;e[3136]=1889;e[3139]=1890;e[3140]=1890;e[3143]=1891;e[3144]=1891;e[3147]=1892;e[3148]=1892;e[3153]=580;e[3154]=581;e[3157]=584;e[3158]=585;e[3161]=588;e[3162]=589;e[3165]=891;e[3166]=892;e[3169]=1274;e[3170]=1275;e[3173]=1278;e[3174]=1279;e[3181]=7622;e[3182]=7623;e[3282]=11799;e[3316]=578;e[3379]=42785;e[3393]=1159;e[3416]=8377}),h=i(function(e){e[227]=322;e[264]=261;e[291]=346});t.getStdFontMap=n;t.getNonStdFontMap=s;t.getSerifFonts=o;t.getSymbolsFonts=c;t.getGlyphMapForStandardFonts=l;t.getSupplementalGlyphMapForArialBlack=h},function(e,t,a){"use strict";function r(e){return e>=65520&&e<=65535?0:e>=62976&&e<=63743?h()[e]||e:e}function i(e,t){var a=t[e];if(void 0!==a)return a;if(!e)return-1;if("u"===e[0]){var r,i=e.length;if(7===i&&"n"===e[1]&&"i"===e[2])r=e.substr(3);else{if(!(i>=5&&i<=7))return-1;r=e.substr(1)}if(r===r.toUpperCase()){a=parseInt(r,16);if(a>=0)return a}}return-1}function n(e){for(var t=0,a=u.length;t<a;t++){var r=u[t];if(e>=r.begin&&e<r.end)return t}return-1}function s(e){var t=u[13];if(e>=t.begin&&e<t.end)return!0;t=u[11];return e>=t.begin&&e<t.end}function o(e){var t=e.length;if(t<=1||!s(e.charCodeAt(0)))return e;for(var a="",r=t-1;r>=0;r--)a+=e[r];return a}var c=a(0),l=c.getLookupTableFactory,h=l(function(e){e[63721]=169;e[63193]=169;e[63720]=174;e[63194]=174;e[63722]=8482;e[63195]=8482;e[63729]=9127;e[63730]=9128;e[63731]=9129;e[63740]=9131;e[63741]=9132;e[63742]=9133;e[63726]=9121;e[63727]=9122;e[63728]=9123;e[63737]=9124;e[63738]=9125;e[63739]=9126;e[63723]=9115;e[63724]=9116;e[63725]=9117;e[63734]=9118;e[63735]=9119;e[63736]=9120}),u=[{begin:0,end:127},{begin:128,end:255},{begin:256,end:383},{begin:384,end:591},{begin:592,end:687},{begin:688,end:767},{begin:768,end:879},{begin:880,end:1023},{begin:11392,end:11519},{begin:1024,end:1279},{begin:1328,end:1423},{begin:1424,end:1535},{begin:42240,end:42559},{begin:1536,end:1791},{begin:1984,end:2047},{begin:2304,end:2431},{begin:2432,end:2559},{begin:2560,end:2687},{begin:2688,end:2815},{begin:2816,end:2943},{begin:2944,end:3071},{begin:3072,end:3199},{begin:3200,end:3327},{begin:3328,end:3455},{begin:3584,end:3711},{begin:3712,end:3839},{begin:4256,end:4351},{begin:6912,end:7039},{begin:4352,end:4607},{begin:7680,end:7935},{begin:7936,end:8191},{begin:8192,end:8303},{begin:8304,end:8351},{begin:8352,end:8399},{begin:8400,end:8447},{begin:8448,end:8527},{begin:8528,end:8591},{begin:8592,end:8703},{begin:8704,end:8959},{begin:8960,end:9215},{begin:9216,end:9279},{begin:9280,end:9311},{begin:9312,end:9471},{begin:9472,end:9599},{begin:9600,end:9631},{begin:9632,end:9727},{begin:9728,end:9983},{begin:9984,end:10175},{begin:12288,end:12351},{begin:12352,end:12447},{begin:12448,end:12543},{begin:12544,end:12591},{begin:12592,end:12687},{begin:43072,end:43135},{begin:12800,end:13055},{begin:13056,end:13311},{begin:44032,end:55215},{begin:55296,end:57343},{begin:67840,end:67871},{begin:19968,end:40959},{begin:57344,end:63743},{begin:12736,end:12783},{begin:64256,end:64335},{begin:64336,end:65023},{begin:65056,end:65071},{begin:65040,end:65055},{begin:65104,end:65135},{begin:65136,end:65279},{begin:65280,end:65519},{begin:65520,end:65535},{begin:3840,end:4095},{begin:1792,end:1871},{begin:1920,end:1983},{begin:3456,end:3583},{begin:4096,end:4255},{begin:4608,end:4991},{begin:5024,end:5119},{begin:5120,end:5759},{begin:5760,end:5791},{begin:5792,end:5887},{begin:6016,end:6143},{begin:6144,end:6319},{begin:10240,end:10495},{begin:40960,end:42127},{begin:5888,end:5919},{begin:66304,end:66351},{begin:66352,end:66383},{begin:66560,end:66639},{begin:118784,end:119039},{begin:119808,end:120831},{begin:1044480,end:1048573},{begin:65024,end:65039},{begin:917504,end:917631},{begin:6400,end:6479},{begin:6480,end:6527},{begin:6528,end:6623},{begin:6656,end:6687},{begin:11264,end:11359},{begin:11568,end:11647},{begin:19904,end:19967},{begin:43008,end:43055},{begin:65536,end:65663},{begin:65856,end:65935},{begin:66432,end:66463},{begin:66464,end:66527},{begin:66640,end:66687},{begin:66688,end:66735},{begin:67584,end:67647},{begin:68096,end:68191},{begin:119552,end:119647},{begin:73728,end:74751},{begin:119648,end:119679},{begin:7040,end:7103},{begin:7168,end:7247},{begin:7248,end:7295},{begin:43136,end:43231},{begin:43264,end:43311},{begin:43312,end:43359},{begin:43520,end:43615},{begin:65936,end:65999},{begin:66e3,end:66047},{begin:66208,end:66271},{begin:127024,end:127135}],f=l(function(e){e["¨"]=" ̈";e["¯"]=" Ì„";e["´"]=" Ì";e["µ"]="μ";e["¸"]=" ̧";e["IJ"]="IJ";e["ij"]="ij";e["Ä¿"]="L·";e["Å€"]="l·";e["ʼn"]="ʼn";e["Å¿"]="s";e["Ç„"]="DŽ";e["Ç…"]="Dž";e["dž"]="dž";e["LJ"]="LJ";e["Lj"]="Lj";e["lj"]="lj";e["ÇŠ"]="NJ";e["Ç‹"]="Nj";e["ÇŒ"]="nj";e["DZ"]="DZ";e["Dz"]="Dz";e["dz"]="dz";e["˘"]=" ̆";e["Ë™"]=" ̇";e["Ëš"]=" ÌŠ";e["Ë›"]=" ̨";e["Ëœ"]=" ̃";e["Ë"]=" Ì‹";e["ͺ"]=" Í…";e["΄"]=" Ì";e["Ï"]="β";e["Ï‘"]="θ";e["Ï’"]="Î¥";e["Ï•"]="φ";e["Ï–"]="Ï€";e["Ï°"]="κ";e["ϱ"]="Ï";e["ϲ"]="Ï‚";e["Ï´"]="Θ";e["ϵ"]="ε";e["Ϲ"]="Σ";e["Ö‡"]="Õ¥Ö‚";e["Ùµ"]="اٴ";e["Ù¶"]="وٴ";e["Ù·"]="Û‡Ù´";e["Ù¸"]="يٴ";e["ำ"]="à¹à¸²";e["ຳ"]="à»àº²";e["ໜ"]="ຫນ";e["à»"]="ຫມ";e["ཷ"]="ྲà¾";e["ཹ"]="ླà¾";e["ẚ"]="aʾ";e["á¾½"]=" Ì“";e["᾿"]=" Ì“";e["á¿€"]=" Í‚";e["῾"]=" Ì”";e[" "]=" ";e[" "]=" ";e[" "]=" ";e[" "]=" ";e[" "]=" ";e[" "]=" ";e[" "]=" ";e[" "]=" ";e["‗"]=" ̳";e["․"]=".";e["‥"]="..";e["…"]="...";e["″"]="′′";e["‴"]="′′′";e["‶"]="‵‵";e["‷"]="‵‵‵";e["‼"]="!!";e["‾"]=" Ì…";e["â‡"]="??";e["âˆ"]="?!";e["â‰"]="!?";e["â—"]="′′′′";e["âŸ"]=" ";e["₨"]="Rs";e["â„€"]="a/c";e["â„"]="a/s";e["℃"]="°C";e["â„…"]="c/o";e["℆"]="c/u";e["ℇ"]="Æ";e["℉"]="°F";e["â„–"]="No";e["â„¡"]="TEL";e["ℵ"]="×";e["ℶ"]="ב";e["â„·"]="×’";e["ℸ"]="ד";e["â„»"]="FAX";e["â… "]="I";e["â…¡"]="II";e["â…¢"]="III";e["â…£"]="IV";e["â…¤"]="V";e["â…¥"]="VI";e["â…¦"]="VII";e["â…§"]="VIII";e["â…¨"]="IX";e["â…©"]="X";e["â…ª"]="XI";e["â…«"]="XII";e["â…¬"]="L";e["â…­"]="C";e["â…®"]="D";e["â…¯"]="M";e["â…°"]="i";e["â…±"]="ii";e["â…²"]="iii";e["â…³"]="iv";e["â…´"]="v";e["â…µ"]="vi";e["â…¶"]="vii";e["â…·"]="viii";e["â…¸"]="ix";e["â…¹"]="x";e["â…º"]="xi";e["â…»"]="xii";e["â…¼"]="l";e["â…½"]="c";e["â…¾"]="d";e["â…¿"]="m";e["∬"]="∫∫";e["∭"]="∫∫∫";e["∯"]="∮∮";e["∰"]="∮∮∮";e["â‘´"]="(1)";e["⑵"]="(2)";e["⑶"]="(3)";e["â‘·"]="(4)";e["⑸"]="(5)";e["⑹"]="(6)";e["⑺"]="(7)";e["â‘»"]="(8)";e["⑼"]="(9)";e["⑽"]="(10)";e["⑾"]="(11)";e["â‘¿"]="(12)";e["â’€"]="(13)";e["â’"]="(14)";e["â’‚"]="(15)";e["â’ƒ"]="(16)";e["â’„"]="(17)";e["â’…"]="(18)";e["â’†"]="(19)";e["â’‡"]="(20)";e["â’ˆ"]="1.";e["â’‰"]="2.";e["â’Š"]="3.";e["â’‹"]="4.";e["â’Œ"]="5.";e["â’"]="6.";e["â’Ž"]="7.";e["â’"]="8.";e["â’"]="9.";e["â’‘"]="10.";e["â’’"]="11.";e["â’“"]="12.";e["â’”"]="13.";e["â’•"]="14.";e["â’–"]="15.";e["â’—"]="16.";e["â’˜"]="17.";e["â’™"]="18.";e["â’š"]="19.";e["â’›"]="20.";e["â’œ"]="(a)";e["â’"]="(b)";e["â’ž"]="(c)";e["â’Ÿ"]="(d)";e["â’ "]="(e)";e["â’¡"]="(f)";e["â’¢"]="(g)";e["â’£"]="(h)";e["â’¤"]="(i)";e["â’¥"]="(j)";e["â’¦"]="(k)";e["â’§"]="(l)";e["â’¨"]="(m)";e["â’©"]="(n)";e["â’ª"]="(o)";e["â’«"]="(p)";e["â’¬"]="(q)";e["â’­"]="(r)";e["â’®"]="(s)";e["â’¯"]="(t)";e["â’°"]="(u)";e["â’±"]="(v)";e["â’²"]="(w)";e["â’³"]="(x)";e["â’´"]="(y)";e["â’µ"]="(z)";e["⨌"]="∫∫∫∫";e["â©´"]="::=";e["⩵"]="==";e["⩶"]="===";e["⺟"]="æ¯";e["⻳"]="龟";e["â¼€"]="一";e["â¼"]="丨";e["⼂"]="丶";e["⼃"]="丿";e["⼄"]="ä¹™";e["â¼…"]="亅";e["⼆"]="二";e["⼇"]="亠";e["⼈"]="人";e["⼉"]="å„¿";e["⼊"]="å…¥";e["⼋"]="å…«";e["⼌"]="冂";e["â¼"]="冖";e["⼎"]="冫";e["â¼"]="几";e["â¼"]="凵";e["⼑"]="刀";e["â¼’"]="力";e["⼓"]="勹";e["â¼”"]="匕";e["⼕"]="匚";e["â¼–"]="匸";e["â¼—"]="å";e["⼘"]="åœ";e["â¼™"]="å©";e["⼚"]="厂";e["â¼›"]="厶";e["⼜"]="åˆ";e["â¼"]="å£";e["⼞"]="å›—";e["⼟"]="土";e["â¼ "]="士";e["⼡"]="夂";e["â¼¢"]="夊";e["â¼£"]="夕";e["⼤"]="大";e["â¼¥"]="女";e["⼦"]="å­";e["⼧"]="宀";e["⼨"]="寸";e["⼩"]="å°";e["⼪"]="å°¢";e["⼫"]="å°¸";e["⼬"]="å±®";e["â¼­"]="å±±";e["â¼®"]="å·›";e["⼯"]="å·¥";e["â¼°"]="å·±";e["â¼±"]="å·¾";e["â¼²"]="å¹²";e["â¼³"]="幺";e["â¼´"]="广";e["â¼µ"]="å»´";e["⼶"]="廾";e["â¼·"]="弋";e["⼸"]="弓";e["â¼¹"]="å½";e["⼺"]="彡";e["â¼»"]="å½³";e["â¼¼"]="心";e["â¼½"]="戈";e["â¼¾"]="戶";e["⼿"]="手";e["â½€"]="支";e["â½"]="æ”´";e["⽂"]="æ–‡";e["⽃"]="æ–—";e["⽄"]="æ–¤";e["â½…"]="æ–¹";e["⽆"]="æ— ";e["⽇"]="æ—¥";e["⽈"]="æ›°";e["⽉"]="月";e["⽊"]="木";e["⽋"]="欠";e["⽌"]="æ­¢";e["â½"]="æ­¹";e["⽎"]="殳";e["â½"]="毋";e["â½"]="比";e["⽑"]="毛";e["â½’"]="æ°";e["⽓"]="æ°”";e["â½”"]="æ°´";e["⽕"]="ç«";e["â½–"]="爪";e["â½—"]="父";e["⽘"]="爻";e["â½™"]="爿";e["⽚"]="片";e["â½›"]="牙";e["⽜"]="牛";e["â½"]="犬";e["⽞"]="玄";e["⽟"]="玉";e["â½ "]="ç“œ";e["⽡"]="瓦";e["â½¢"]="甘";e["â½£"]="生";e["⽤"]="用";e["â½¥"]="ç”°";e["⽦"]="ç–‹";e["⽧"]="ç–’";e["⽨"]="癶";e["⽩"]="白";e["⽪"]="çš®";e["⽫"]="çš¿";e["⽬"]="ç›®";e["â½­"]="矛";e["â½®"]="矢";e["⽯"]="石";e["â½°"]="示";e["â½±"]="禸";e["â½²"]="禾";e["â½³"]="ç©´";e["â½´"]="ç«‹";e["â½µ"]="竹";e["⽶"]="ç±³";e["â½·"]="糸";e["⽸"]="缶";e["â½¹"]="网";e["⽺"]="羊";e["â½»"]="ç¾½";e["â½¼"]="è€";e["â½½"]="而";e["â½¾"]="耒";e["⽿"]="耳";e["â¾€"]="è¿";e["â¾"]="肉";e["⾂"]="臣";e["⾃"]="自";e["⾄"]="至";e["â¾…"]="臼";e["⾆"]="舌";e["⾇"]="舛";e["⾈"]="舟";e["⾉"]="艮";e["⾊"]="色";e["⾋"]="艸";e["⾌"]="è™";e["â¾"]="虫";e["⾎"]="è¡€";e["â¾"]="è¡Œ";e["â¾"]="è¡£";e["⾑"]="襾";e["â¾’"]="見";e["⾓"]="角";e["â¾”"]="言";e["⾕"]="è°·";e["â¾–"]="豆";e["â¾—"]="豕";e["⾘"]="豸";e["â¾™"]="è²";e["⾚"]="赤";e["â¾›"]="èµ°";e["⾜"]="足";e["â¾"]="身";e["⾞"]="車";e["⾟"]="è¾›";e["â¾ "]="è¾°";e["⾡"]="è¾µ";e["â¾¢"]="é‚‘";e["â¾£"]="é…‰";e["⾤"]="釆";e["â¾¥"]="里";e["⾦"]="金";e["⾧"]="é•·";e["⾨"]="é–€";e["⾩"]="阜";e["⾪"]="隶";e["⾫"]="éš¹";e["⾬"]="雨";e["â¾­"]="é‘";e["â¾®"]="éž";e["⾯"]="é¢";e["â¾°"]="é©";e["â¾±"]="韋";e["â¾²"]="韭";e["â¾³"]="音";e["â¾´"]="é ";e["â¾µ"]="風";e["⾶"]="飛";e["â¾·"]="食";e["⾸"]="首";e["â¾¹"]="香";e["⾺"]="馬";e["â¾»"]="骨";e["â¾¼"]="高";e["â¾½"]="é«Ÿ";e["â¾¾"]="鬥";e["⾿"]="鬯";e["â¿€"]="鬲";e["â¿"]="鬼";e["â¿‚"]="é­š";e["⿃"]="é³¥";e["â¿„"]="é¹µ";e["â¿…"]="鹿";e["⿆"]="麥";e["⿇"]="麻";e["⿈"]="黃";e["⿉"]="é»";e["â¿Š"]="黑";e["â¿‹"]="黹";e["â¿Œ"]="黽";e["â¿"]="鼎";e["â¿Ž"]="鼓";e["â¿"]="é¼ ";e["â¿"]="é¼»";e["â¿‘"]="齊";e["â¿’"]="é½’";e["â¿“"]="é¾";e["â¿”"]="龜";e["â¿•"]="é¾ ";e["〶"]="〒";e["〸"]="å";e["〹"]="å„";e["〺"]="å…";e["ã‚›"]=" ã‚™";e["ã‚œ"]=" ã‚š";e["ㄱ"]="á„€";e["ㄲ"]="á„";e["ㄳ"]="ᆪ";e["ã„´"]="á„‚";e["ㄵ"]="ᆬ";e["ㄶ"]="ᆭ";e["ã„·"]="ᄃ";e["ㄸ"]="á„„";e["ㄹ"]="á„…";e["ㄺ"]="ᆰ";e["ã„»"]="ᆱ";e["ㄼ"]="ᆲ";e["ㄽ"]="ᆳ";e["ㄾ"]="ᆴ";e["ã„¿"]="ᆵ";e["ã…€"]="á„š";e["ã…"]="ᄆ";e["ã…‚"]="ᄇ";e["ã…ƒ"]="ᄈ";e["ã…„"]="á„¡";e["ã……"]="ᄉ";e["ã…†"]="á„Š";e["ã…‡"]="á„‹";e["ã…ˆ"]="á„Œ";e["ã…‰"]="á„";e["ã…Š"]="á„Ž";e["ã…‹"]="á„";e["ã…Œ"]="á„";e["ã…"]="á„‘";e["ã…Ž"]="á„’";e["ã…"]="á…¡";e["ã…"]="á…¢";e["ã…‘"]="á…£";e["ã…’"]="á…¤";e["ã…“"]="á…¥";e["ã…”"]="á…¦";e["ã…•"]="á…§";e["ã…–"]="á…¨";e["ã…—"]="á…©";e["ã…˜"]="á…ª";e["ã…™"]="á…«";e["ã…š"]="á…¬";e["ã…›"]="á…­";e["ã…œ"]="á…®";e["ã…"]="á…¯";e["ã…ž"]="á…°";e["ã…Ÿ"]="á…±";e["ã… "]="á…²";e["ã…¡"]="á…³";e["ã…¢"]="á…´";e["ã…£"]="á…µ";e["ã…¤"]="á… ";e["ã…¥"]="á„”";e["ã…¦"]="á„•";e["ã…§"]="ᇇ";e["ã…¨"]="ᇈ";e["ã…©"]="ᇌ";e["ã…ª"]="ᇎ";e["ã…«"]="ᇓ";e["ã…¬"]="ᇗ";e["ã…­"]="ᇙ";e["ã…®"]="á„œ";e["ã…¯"]="á‡";e["ã…°"]="ᇟ";e["ã…±"]="á„";e["ã…²"]="á„ž";e["ã…³"]="á„ ";e["ã…´"]="á„¢";e["ã…µ"]="á„£";e["ã…¶"]="ᄧ";e["ã…·"]="á„©";e["ã…¸"]="á„«";e["ã…¹"]="ᄬ";e["ã…º"]="á„­";e["ã…»"]="á„®";e["ã…¼"]="ᄯ";e["ã…½"]="ᄲ";e["ã…¾"]="ᄶ";e["ã…¿"]="á…€";e["ㆀ"]="á…‡";e["ã†"]="á…Œ";e["ㆂ"]="ᇱ";e["ㆃ"]="ᇲ";e["ㆄ"]="á…—";e["ㆅ"]="á…˜";e["ㆆ"]="á…™";e["ㆇ"]="ᆄ";e["ㆈ"]="ᆅ";e["ㆉ"]="ᆈ";e["ㆊ"]="ᆑ";e["ㆋ"]="ᆒ";e["ㆌ"]="ᆔ";e["ã†"]="ᆞ";e["ㆎ"]="ᆡ";e["㈀"]="(á„€)";e["ãˆ"]="(á„‚)";e["㈂"]="(ᄃ)";e["㈃"]="(á„…)";e["㈄"]="(ᄆ)";e["㈅"]="(ᄇ)";e["㈆"]="(ᄉ)";e["㈇"]="(á„‹)";e["㈈"]="(á„Œ)";e["㈉"]="(á„Ž)";e["㈊"]="(á„)";e["㈋"]="(á„)";e["㈌"]="(á„‘)";e["ãˆ"]="(á„’)";e["㈎"]="(가)";e["ãˆ"]="(á„‚á…¡)";e["ãˆ"]="(다)";e["㈑"]="(á„…á…¡)";e["㈒"]="(마)";e["㈓"]="(바)";e["㈔"]="(사)";e["㈕"]="(á„‹á…¡)";e["㈖"]="(자)";e["㈗"]="(á„Žá…¡)";e["㈘"]="(á„á…¡)"
+;e["㈙"]="(á„á…¡)";e["㈚"]="(á„‘á…¡)";e["㈛"]="(á„’á…¡)";e["㈜"]="(주)";e["ãˆ"]="(오전)";e["㈞"]="(á„‹á…©á„’á…®)";e["㈠"]="(一)";e["㈡"]="(二)";e["㈢"]="(三)";e["㈣"]="(å››)";e["㈤"]="(五)";e["㈥"]="(å…­)";e["㈦"]="(七)";e["㈧"]="(å…«)";e["㈨"]="(ä¹)";e["㈩"]="(å)";e["㈪"]="(月)";e["㈫"]="(ç«)";e["㈬"]="(æ°´)";e["㈭"]="(木)";e["㈮"]="(金)";e["㈯"]="(土)";e["㈰"]="(æ—¥)";e["㈱"]="(æ ª)";e["㈲"]="(有)";e["㈳"]="(社)";e["㈴"]="(å)";e["㈵"]="(特)";e["㈶"]="(財)";e["㈷"]="(ç¥)";e["㈸"]="(労)";e["㈹"]="(代)";e["㈺"]="(呼)";e["㈻"]="(å­¦)";e["㈼"]="(監)";e["㈽"]="(ä¼)";e["㈾"]="(資)";e["㈿"]="(å”)";e["㉀"]="(祭)";e["ã‰"]="(休)";e["㉂"]="(自)";e["㉃"]="(至)";e["ã‹€"]="1月";e["ã‹"]="2月";e["ã‹‚"]="3月";e["㋃"]="4月";e["ã‹„"]="5月";e["ã‹…"]="6月";e["㋆"]="7月";e["㋇"]="8月";e["㋈"]="9月";e["㋉"]="10月";e["ã‹Š"]="11月";e["ã‹‹"]="12月";e["ã˜"]="0点";e["ã™"]="1点";e["ãš"]="2点";e["ã›"]="3点";e["ãœ"]="4点";e["ã"]="5点";e["ãž"]="6点";e["ãŸ"]="7点";e["ã "]="8点";e["ã¡"]="9点";e["ã¢"]="10点";e["ã£"]="11点";e["ã¤"]="12点";e["ã¥"]="13点";e["ã¦"]="14点";e["ã§"]="15点";e["ã¨"]="16点";e["ã©"]="17点";e["ãª"]="18点";e["ã«"]="19点";e["ã¬"]="20点";e["ã­"]="21点";e["ã®"]="22点";e["ã¯"]="23点";e["ã°"]="24点";e["ã "]="1æ—¥";e["ã¡"]="2æ—¥";e["ã¢"]="3æ—¥";e["ã£"]="4æ—¥";e["ã¤"]="5æ—¥";e["ã¥"]="6æ—¥";e["ã¦"]="7æ—¥";e["ã§"]="8æ—¥";e["ã¨"]="9æ—¥";e["ã©"]="10æ—¥";e["ãª"]="11æ—¥";e["ã«"]="12æ—¥";e["ã¬"]="13æ—¥";e["ã­"]="14æ—¥";e["ã®"]="15æ—¥";e["ã¯"]="16æ—¥";e["ã°"]="17æ—¥";e["ã±"]="18æ—¥";e["ã²"]="19æ—¥";e["ã³"]="20æ—¥";e["ã´"]="21æ—¥";e["ãµ"]="22æ—¥";e["ã¶"]="23æ—¥";e["ã·"]="24æ—¥";e["ã¸"]="25æ—¥";e["ã¹"]="26æ—¥";e["ãº"]="27æ—¥";e["ã»"]="28æ—¥";e["ã¼"]="29æ—¥";e["ã½"]="30æ—¥";e["ã¾"]="31æ—¥";e["ff"]="ff";e["ï¬"]="fi";e["fl"]="fl";e["ffi"]="ffi";e["ffl"]="ffl";e["ſt"]="Å¿t";e["st"]="st";e["ﬓ"]="Õ´Õ¶";e["ﬔ"]="Õ´Õ¥";e["ﬕ"]="Õ´Õ«";e["ﬖ"]="Õ¾Õ¶";e["ﬗ"]="Õ´Õ­";e["ï­"]="×ל";e["ï­"]="Ù±";e["ï­‘"]="Ù±";e["ï­’"]="Ù»";e["ï­“"]="Ù»";e["ï­”"]="Ù»";e["ï­•"]="Ù»";e["ï­–"]="Ù¾";e["ï­—"]="Ù¾";e["ï­˜"]="Ù¾";e["ï­™"]="Ù¾";e["ï­š"]="Ú€";e["ï­›"]="Ú€";e["ï­œ"]="Ú€";e["ï­"]="Ú€";e["ï­ž"]="Ùº";e["ï­Ÿ"]="Ùº";e["ï­ "]="Ùº";e["ï­¡"]="Ùº";e["ï­¢"]="Ù¿";e["ï­£"]="Ù¿";e["ï­¤"]="Ù¿";e["ï­¥"]="Ù¿";e["ï­¦"]="Ù¹";e["ï­§"]="Ù¹";e["ï­¨"]="Ù¹";e["ï­©"]="Ù¹";e["ï­ª"]="Ú¤";e["ï­«"]="Ú¤";e["ï­¬"]="Ú¤";e["ï­­"]="Ú¤";e["ï­®"]="Ú¦";e["ï­¯"]="Ú¦";e["ï­°"]="Ú¦";e["ï­±"]="Ú¦";e["ï­²"]="Ú„";e["ï­³"]="Ú„";e["ï­´"]="Ú„";e["ï­µ"]="Ú„";e["ï­¶"]="Úƒ";e["ï­·"]="Úƒ";e["ï­¸"]="Úƒ";e["ï­¹"]="Úƒ";e["ï­º"]="Ú†";e["ï­»"]="Ú†";e["ï­¼"]="Ú†";e["ï­½"]="Ú†";e["ï­¾"]="Ú‡";e["ï­¿"]="Ú‡";e["ﮀ"]="Ú‡";e["ï®"]="Ú‡";e["ﮂ"]="Ú";e["ﮃ"]="Ú";e["ﮄ"]="ÚŒ";e["ï®…"]="ÚŒ";e["ﮆ"]="ÚŽ";e["ﮇ"]="ÚŽ";e["ﮈ"]="Úˆ";e["ﮉ"]="Úˆ";e["ﮊ"]="Ú˜";e["ﮋ"]="Ú˜";e["ﮌ"]="Ú‘";e["ï®"]="Ú‘";e["ﮎ"]="Ú©";e["ï®"]="Ú©";e["ï®"]="Ú©";e["ﮑ"]="Ú©";e["ï®’"]="Ú¯";e["ﮓ"]="Ú¯";e["ï®”"]="Ú¯";e["ﮕ"]="Ú¯";e["ï®–"]="Ú³";e["ï®—"]="Ú³";e["ﮘ"]="Ú³";e["ï®™"]="Ú³";e["ﮚ"]="Ú±";e["ï®›"]="Ú±";e["ﮜ"]="Ú±";e["ï®"]="Ú±";e["ﮞ"]="Úº";e["ﮟ"]="Úº";e["ï® "]="Ú»";e["ﮡ"]="Ú»";e["ﮢ"]="Ú»";e["ﮣ"]="Ú»";e["ﮤ"]="Û€";e["ﮥ"]="Û€";e["ﮦ"]="Û";e["ﮧ"]="Û";e["ﮨ"]="Û";e["ﮩ"]="Û";e["ﮪ"]="Ú¾";e["ﮫ"]="Ú¾";e["ﮬ"]="Ú¾";e["ï®­"]="Ú¾";e["ï®®"]="Û’";e["ﮯ"]="Û’";e["ï®°"]="Û“";e["ï®±"]="Û“";e["ﯓ"]="Ú­";e["ﯔ"]="Ú­";e["ﯕ"]="Ú­";e["ﯖ"]="Ú­";e["ﯗ"]="Û‡";e["ﯘ"]="Û‡";e["ﯙ"]="Û†";e["ﯚ"]="Û†";e["ﯛ"]="Ûˆ";e["ﯜ"]="Ûˆ";e["ï¯"]="Ù·";e["ﯞ"]="Û‹";e["ﯟ"]="Û‹";e["ﯠ"]="Û…";e["ﯡ"]="Û…";e["ﯢ"]="Û‰";e["ﯣ"]="Û‰";e["ﯤ"]="Û";e["ﯥ"]="Û";e["ﯦ"]="Û";e["ﯧ"]="Û";e["ﯨ"]="Ù‰";e["ﯩ"]="Ù‰";e["ﯪ"]="ئا";e["ﯫ"]="ئا";e["ﯬ"]="ئە";e["ﯭ"]="ئە";e["ﯮ"]="ئو";e["ﯯ"]="ئو";e["ﯰ"]="ئۇ";e["ﯱ"]="ئۇ";e["ﯲ"]="ئۆ";e["ﯳ"]="ئۆ";e["ﯴ"]="ئۈ";e["ﯵ"]="ئۈ";e["ﯶ"]="ئÛ";e["ﯷ"]="ئÛ";e["ﯸ"]="ئÛ";e["ﯹ"]="ئى";e["ﯺ"]="ئى";e["ﯻ"]="ئى";e["ﯼ"]="ÛŒ";e["ﯽ"]="ÛŒ";e["ﯾ"]="ÛŒ";e["ﯿ"]="ÛŒ";e["ï°€"]="ئج";e["ï°"]="ئح";e["ï°‚"]="ئم";e["ï°ƒ"]="ئى";e["ï°„"]="ئي";e["ï°…"]="بج";e["ï°†"]="بح";e["ï°‡"]="بخ";e["ï°ˆ"]="بم";e["ï°‰"]="بى";e["ï°Š"]="بي";e["ï°‹"]="تج";e["ï°Œ"]="تح";e["ï°"]="تخ";e["ï°Ž"]="تم";e["ï°"]="تى";e["ï°"]="تي";e["ï°‘"]="ثج";e["ï°’"]="ثم";e["ï°“"]="ثى";e["ï°”"]="ثي";e["ï°•"]="جح";e["ï°–"]="جم";e["ï°—"]="حج";e["ï°˜"]="حم";e["ï°™"]="خج";e["ï°š"]="خح";e["ï°›"]="خم";e["ï°œ"]="سج";e["ï°"]="سح";e["ï°ž"]="سخ";e["ï°Ÿ"]="سم";e["ï° "]="صح";e["ï°¡"]="صم";e["ï°¢"]="ضج";e["ï°£"]="ضح";e["ï°¤"]="ضخ";e["ï°¥"]="ضم";e["ï°¦"]="طح";e["ï°§"]="طم";e["ï°¨"]="ظم";e["ï°©"]="عج";e["ï°ª"]="عم";e["ï°«"]="غج";e["ï°¬"]="غم";e["ï°­"]="Ùج";e["ï°®"]="ÙØ­";e["ï°¯"]="ÙØ®";e["ï°°"]="ÙÙ…";e["ï°±"]="ÙÙ‰";e["ï°²"]="ÙÙŠ";e["ï°³"]="قح";e["ï°´"]="قم";e["ï°µ"]="قى";e["ï°¶"]="قي";e["ï°·"]="كا";e["ï°¸"]="كج";e["ï°¹"]="كح";e["ï°º"]="كخ";e["ï°»"]="كل";e["ï°¼"]="كم";e["ï°½"]="كى";e["ï°¾"]="كي";e["ï°¿"]="لج";e["ï±€"]="لح";e["ï±"]="لخ";e["ﱂ"]="لم";e["ﱃ"]="لى";e["ﱄ"]="لي";e["ï±…"]="مج";e["ﱆ"]="مح";e["ﱇ"]="مخ";e["ﱈ"]="مم";e["ﱉ"]="مى";e["ﱊ"]="مي";e["ﱋ"]="نج";e["ﱌ"]="نح";e["ï±"]="نخ";e["ﱎ"]="نم";e["ï±"]="نى";e["ï±"]="ني";e["ﱑ"]="هج";e["ï±’"]="هم";e["ﱓ"]="هى";e["ï±”"]="هي";e["ﱕ"]="يج";e["ï±–"]="يح";e["ï±—"]="يخ";e["ﱘ"]="يم";e["ï±™"]="يى";e["ﱚ"]="يي";e["ï±›"]="ذٰ";e["ﱜ"]="رٰ";e["ï±"]="ىٰ";e["ﱞ"]=" ٌّ";e["ﱟ"]=" ÙÙ‘";e["ï± "]=" ÙŽÙ‘";e["ﱡ"]=" ÙÙ‘";e["ï±¢"]=" ÙÙ‘";e["ï±£"]=" ّٰ";e["ﱤ"]="ئر";e["ï±¥"]="ئز";e["ﱦ"]="ئم";e["ﱧ"]="ئن";e["ﱨ"]="ئى";e["ﱩ"]="ئي";e["ﱪ"]="بر";e["ﱫ"]="بز";e["ﱬ"]="بم";e["ï±­"]="بن";e["ï±®"]="بى";e["ﱯ"]="بي";e["ï±°"]="تر";e["ï±±"]="تز";e["ï±²"]="تم";e["ï±³"]="تن";e["ï±´"]="تى";e["ï±µ"]="تي";e["ﱶ"]="ثر";e["ï±·"]="ثز";e["ﱸ"]="ثم";e["ï±¹"]="ثن";e["ﱺ"]="ثى";e["ï±»"]="ثي";e["ï±¼"]="ÙÙ‰";e["ï±½"]="ÙÙŠ";e["ï±¾"]="قى";e["ﱿ"]="قي";e["ï²€"]="كا";e["ï²"]="كل";e["ﲂ"]="كم";e["ﲃ"]="كى";e["ﲄ"]="كي";e["ï²…"]="لم";e["ﲆ"]="لى";e["ﲇ"]="لي";e["ﲈ"]="ما";e["ﲉ"]="مم";e["ﲊ"]="نر";e["ﲋ"]="نز";e["ﲌ"]="نم";e["ï²"]="نن";e["ﲎ"]="نى";e["ï²"]="ني";e["ï²"]="ىٰ";e["ﲑ"]="ير";e["ï²’"]="يز";e["ﲓ"]="يم";e["ï²”"]="ين";e["ﲕ"]="يى";e["ï²–"]="يي";e["ï²—"]="ئج";e["ﲘ"]="ئح";e["ï²™"]="ئخ";e["ﲚ"]="ئم";e["ï²›"]="ئه";e["ﲜ"]="بج";e["ï²"]="بح";e["ﲞ"]="بخ";e["ﲟ"]="بم";e["ï² "]="به";e["ﲡ"]="تج";e["ï²¢"]="تح";e["ï²£"]="تخ";e["ﲤ"]="تم";e["ï²¥"]="ته";e["ﲦ"]="ثم";e["ﲧ"]="جح";e["ﲨ"]="جم";e["ﲩ"]="حج";e["ﲪ"]="حم";e["ﲫ"]="خج";e["ﲬ"]="خم";e["ï²­"]="سج";e["ï²®"]="سح";e["ﲯ"]="سخ";e["ï²°"]="سم";e["ï²±"]="صح";e["ï²²"]="صخ";e["ï²³"]="صم";e["ï²´"]="ضج";e["ï²µ"]="ضح";e["ﲶ"]="ضخ";e["ï²·"]="ضم";e["ﲸ"]="طح";e["ï²¹"]="ظم";e["ﲺ"]="عج";e["ï²»"]="عم";e["ï²¼"]="غج";e["ï²½"]="غم";e["ï²¾"]="Ùج";e["ﲿ"]="ÙØ­";e["ï³€"]="ÙØ®";e["ï³"]="ÙÙ…";e["ﳂ"]="قح";e["ﳃ"]="قم";e["ﳄ"]="كج";e["ï³…"]="كح";e["ﳆ"]="كخ";e["ﳇ"]="كل";e["ﳈ"]="كم";e["ﳉ"]="لج";e["ﳊ"]="لح";e["ﳋ"]="لخ";e["ﳌ"]="لم";e["ï³"]="له";e["ﳎ"]="مج";e["ï³"]="مح";e["ï³"]="مخ";e["ﳑ"]="مم";e["ï³’"]="نج";e["ﳓ"]="نح";e["ï³”"]="نخ";e["ﳕ"]="نم";e["ï³–"]="نه";e["ï³—"]="هج";e["ﳘ"]="هم";e["ï³™"]="هٰ";e["ﳚ"]="يج";e["ï³›"]="يح";e["ﳜ"]="يخ";e["ï³"]="يم";e["ﳞ"]="يه";e["ﳟ"]="ئم";e["ï³ "]="ئه";e["ﳡ"]="بم";e["ï³¢"]="به";e["ï³£"]="تم";e["ﳤ"]="ته";e["ï³¥"]="ثم";e["ﳦ"]="ثه";e["ﳧ"]="سم";e["ﳨ"]="سه";e["ﳩ"]="شم";e["ﳪ"]="شه";e["ﳫ"]="كل";e["ﳬ"]="كم";e["ï³­"]="لم";e["ï³®"]="نم";e["ﳯ"]="نه";e["ï³°"]="يم";e["ï³±"]="يه";e["ï³²"]="Ù€ÙŽÙ‘";e["ï³³"]="Ù€ÙÙ‘";e["ï³´"]="Ù€ÙÙ‘";e["ï³µ"]="طى";e["ﳶ"]="طي";e["ï³·"]="عى";e["ﳸ"]="عي";e["ï³¹"]="غى";e["ﳺ"]="غي";e["ï³»"]="سى";e["ï³¼"]="سي";e["ï³½"]="شى";e["ï³¾"]="شي";e["ﳿ"]="حى";e["ï´€"]="حي";e["ï´"]="جى";e["ï´‚"]="جي";e["ï´ƒ"]="خى";e["ï´„"]="خي";e["ï´…"]="صى";e["ï´†"]="صي";e["ï´‡"]="ضى";e["ï´ˆ"]="ضي";e["ï´‰"]="شج";e["ï´Š"]="شح";e["ï´‹"]="شخ";e["ï´Œ"]="شم";e["ï´"]="شر";e["ï´Ž"]="سر";e["ï´"]="صر";e["ï´"]="ضر";e["ï´‘"]="طى";e["ï´’"]="طي";e["ï´“"]="عى";e["ï´”"]="عي";e["ï´•"]="غى";e["ï´–"]="غي";e["ï´—"]="سى";e["ï´˜"]="سي";e["ï´™"]="شى";e["ï´š"]="شي";e["ï´›"]="حى";e["ï´œ"]="حي";e["ï´"]="جى";e["ï´ž"]="جي";e["ï´Ÿ"]="خى";e["ï´ "]="خي";e["ï´¡"]="صى";e["ï´¢"]="صي";e["ï´£"]="ضى";e["ï´¤"]="ضي";e["ï´¥"]="شج";e["ï´¦"]="شح";e["ï´§"]="شخ";e["ï´¨"]="شم";e["ï´©"]="شر";e["ï´ª"]="سر";e["ï´«"]="صر";e["ï´¬"]="ضر";e["ï´­"]="شج";e["ï´®"]="شح";e["ï´¯"]="شخ";e["ï´°"]="شم";e["ï´±"]="سه";e["ï´²"]="شه";e["ï´³"]="طم";e["ï´´"]="سج";e["ï´µ"]="سح";e["ï´¶"]="سخ";e["ï´·"]="شج";e["ï´¸"]="شح";e["ï´¹"]="شخ";e["ï´º"]="طم";e["ï´»"]="ظم";e["ï´¼"]="اً";e["ï´½"]="اً";e["ïµ"]="تجم";e["ﵑ"]="تحج";e["ïµ’"]="تحج";e["ﵓ"]="تحم";e["ïµ”"]="تخم";e["ﵕ"]="تمج";e["ïµ–"]="تمح";e["ïµ—"]="تمخ";e["ﵘ"]="جمح";e["ïµ™"]="جمح";e["ﵚ"]="حمي";e["ïµ›"]="حمى";e["ﵜ"]="سحج";e["ïµ"]="سجح";e["ﵞ"]="سجى";e["ﵟ"]="سمح";e["ïµ "]="سمح";e["ﵡ"]="سمج";e["ïµ¢"]="سمم";e["ïµ£"]="سمم";e["ﵤ"]="صحح";e["ïµ¥"]="صحح";e["ﵦ"]="صمم";e["ﵧ"]="شحم";e["ﵨ"]="شحم";e["ﵩ"]="شجي";e["ﵪ"]="شمخ";e["ﵫ"]="شمخ";e["ﵬ"]="شمم";e["ïµ­"]="شمم";e["ïµ®"]="ضحى";e["ﵯ"]="ضخم";e["ïµ°"]="ضخم";e["ïµ±"]="طمح";e["ïµ²"]="طمح";e["ïµ³"]="طمم";e["ïµ´"]="طمي";e["ïµµ"]="عجم";e["ﵶ"]="عمم";e["ïµ·"]="عمم";e["ﵸ"]="عمى";e["ïµ¹"]="غمم";e["ﵺ"]="غمي";e["ïµ»"]="غمى";e["ïµ¼"]="Ùخم";e["ïµ½"]="Ùخم";e["ïµ¾"]="قمح";e["ﵿ"]="قمم";e["ﶀ"]="لحم";e["ï¶"]="لحي";e["ﶂ"]="لحى";e["ﶃ"]="لجج";e["ﶄ"]="لجج";e["ﶅ"]="لخم";e["ﶆ"]="لخم";e["ﶇ"]="لمح";e["ﶈ"]="لمح";e["ﶉ"]="محج";e["ﶊ"]="محم";e["ﶋ"]="محي";e["ﶌ"]="مجح";e["ï¶"]="مجم";e["ﶎ"]="مخج";e["ï¶"]="مخم";e["ﶒ"]="مجخ";e["ﶓ"]="همج";e["ﶔ"]="همم";e["ﶕ"]="نحم";e["ﶖ"]="نحى";e["ﶗ"]="نجم";e["ﶘ"]="نجم";e["ﶙ"]="نجى";e["ﶚ"]="نمي";e["ﶛ"]="نمى";e["ﶜ"]="يمم";e["ï¶"]="يمم";e["ﶞ"]="بخي";e["ﶟ"]="تجي";e["ﶠ"]="تجى";e["ﶡ"]="تخي";e["ﶢ"]="تخى";e["ﶣ"]="تمي";e["ﶤ"]="تمى";e["ﶥ"]="جمي";e["ﶦ"]="جحى";e["ﶧ"]="جمى";e["ﶨ"]="سخى";e["ﶩ"]="صحي";e["ﶪ"]="شحي";e["ﶫ"]="ضحي";e["ﶬ"]="لجي";e["ﶭ"]="لمي";e["ﶮ"]="يحي";e["ﶯ"]="يجي";e["ﶰ"]="يمي";e["ﶱ"]="ممي";e["ﶲ"]="قمي";e["ﶳ"]="نحي";e["ﶴ"]="قمح";e["ﶵ"]="لحم";e["ﶶ"]="عمي";e["ﶷ"]="كمي";e["ﶸ"]="نجح";e["ﶹ"]="مخي";e["ﶺ"]="لجم";e["ﶻ"]="كمم";e["ﶼ"]="لجم";e["ﶽ"]="نجح";e["ﶾ"]="جحي";e["ﶿ"]="حجي";e["ï·€"]="مجي";e["ï·"]="Ùمي";e["ï·‚"]="بحي";e["ï·ƒ"]="كمم";e["ï·„"]="عجم";e["ï·…"]="صمم";e["ï·†"]="سخي";e["ï·‡"]="نجي";e["﹉"]="‾";e["﹊"]="‾";e["﹋"]="‾";e["﹌"]="‾";e["ï¹"]="_";e["﹎"]="_";e["ï¹"]="_";e["ﺀ"]="Ø¡";e["ïº"]="Ø¢";e["ﺂ"]="Ø¢";e["ﺃ"]="Ø£";e["ﺄ"]="Ø£";e["ﺅ"]="ؤ";e["ﺆ"]="ؤ";e["ﺇ"]="Ø¥";e["ﺈ"]="Ø¥";e["ﺉ"]="ئ";e["ﺊ"]="ئ";e["ﺋ"]="ئ";e["ﺌ"]="ئ";e["ïº"]="ا";e["ﺎ"]="ا";e["ïº"]="ب";e["ïº"]="ب";e["ﺑ"]="ب";e["ﺒ"]="ب";e["ﺓ"]="Ø©";e["ﺔ"]="Ø©";e["ﺕ"]="ت";e["ﺖ"]="ت";e["ﺗ"]="ت";e["ﺘ"]="ت";e["ﺙ"]="Ø«";e["ﺚ"]="Ø«";e["ﺛ"]="Ø«";e["ﺜ"]="Ø«";e["ïº"]="ج";e["ﺞ"]="ج";e["ﺟ"]="ج";e["ﺠ"]="ج";e["ﺡ"]="Ø­";e["ﺢ"]="Ø­";e["ﺣ"]="Ø­";e["ﺤ"]="Ø­";e["ﺥ"]="Ø®";e["ﺦ"]="Ø®";e["ﺧ"]="Ø®";e["ﺨ"]="Ø®";e["ﺩ"]="د";e["ﺪ"]="د";e["ﺫ"]="Ø°";e["ﺬ"]="Ø°";e["ﺭ"]="ر";e["ﺮ"]="ر";e["ﺯ"]="ز";e["ﺰ"]="ز";e["ﺱ"]="س";e["ﺲ"]="س";e["ﺳ"]="س";e["ﺴ"]="س";e["ﺵ"]="Ø´";e["ﺶ"]="Ø´";e["ﺷ"]="Ø´";e["ﺸ"]="Ø´";e["ﺹ"]="ص";e["ﺺ"]="ص";e["ﺻ"]="ص";e["ﺼ"]="ص";e["ﺽ"]="ض";e["ﺾ"]="ض";e["ﺿ"]="ض";e["ﻀ"]="ض";e["ï»"]="Ø·";e["ﻂ"]="Ø·";e["ﻃ"]="Ø·";e["ﻄ"]="Ø·";e["ï»…"]="ظ";e["ﻆ"]="ظ";e["ﻇ"]="ظ";e["ﻈ"]="ظ";e["ﻉ"]="ع";e["ﻊ"]="ع";e["ﻋ"]="ع";e["ﻌ"]="ع";e["ï»"]="غ";e["ﻎ"]="غ";e["ï»"]="غ";e["ï»"]="غ";e["ﻑ"]="Ù";e["ï»’"]="Ù";e["ﻓ"]="Ù";e["ï»”"]="Ù";e["ﻕ"]="Ù‚";e["ï»–"]="Ù‚";e["ï»—"]="Ù‚";e["ﻘ"]="Ù‚";e["ï»™"]="Ùƒ";e["ﻚ"]="Ùƒ";e["ï»›"]="Ùƒ";e["ﻜ"]="Ùƒ";e["ï»"]="Ù„";e["ﻞ"]="Ù„";e["ﻟ"]="Ù„";e["ï» "]="Ù„";e["ﻡ"]="Ù…";e["ﻢ"]="Ù…";e["ﻣ"]="Ù…";e["ﻤ"]="Ù…";e["ﻥ"]="Ù†";e["ﻦ"]="Ù†";e["ﻧ"]="Ù†";e["ﻨ"]="Ù†";e["ﻩ"]="Ù‡";e["ﻪ"]="Ù‡";e["ﻫ"]="Ù‡";e["ﻬ"]="Ù‡";e["ï»­"]="Ùˆ";e["ï»®"]="Ùˆ";e["ﻯ"]="Ù‰";e["ï»°"]="Ù‰";e["ï»±"]="ÙŠ";e["ﻲ"]="ÙŠ";e["ﻳ"]="ÙŠ";e["ï»´"]="ÙŠ";e["ﻵ"]="لآ";e["ﻶ"]="لآ";e["ï»·"]="لأ";e["ﻸ"]="لأ";e["ﻹ"]="لإ";e["ﻺ"]="لإ";e["ï»»"]="لا";e["ﻼ"]="لا"});t.mapSpecialUnicodeValues=r;t.reverseIfRtl=o;t.getUnicodeRangeFor=n;t.getNormalizedUnicodes=f;t.getUnicodeForGlyph=i},function(e,t,a){"use strict";function r(e,t){this.url=e;t=t||{};this.isHttp=/^https?:/i.test(e);this.httpHeaders=this.isHttp&&t.httpHeaders||{};this.withCredentials=t.withCredentials||!1;this.getXhr=t.getXhr||function(){return new XMLHttpRequest};this.currXhrId=0;this.pendingRequests=Object.create(null);this.loadedRequests=Object.create(null)}function i(e){var t=e.response;if("string"!=typeof t)return t;for(var a=t.length,r=new Uint8Array(a),i=0;i<a;i++)r[i]=255&t.charCodeAt(i);return r.buffer}function n(e){this._options=e;var t=e.source;this._manager=new r(t.url,{httpHeaders:t.httpHeaders,withCredentials:t.withCredentials});this._rangeChunkSize=t.rangeChunkSize;this._fullRequestReader=null;this._rangeRequestReaders=[]}function s(e,t){this._manager=e;var a=t.source,r={onHeadersReceived:this._onHeadersReceived.bind(this),onProgressiveData:a.disableStream?null:this._onProgressiveData.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)};this._url=a.url;this._fullRequestId=e.requestFull(r);this._headersReceivedCapability=d();this._disableRange=t.disableRange||!1;this._contentLength=a.length;this._rangeChunkSize=a.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!1;this._isRangeSupported=!1;this._cachedChunks=[];this._requests=[];this._done=!1;this._storedError=void 0;this.onProgress=null}function o(e,t,a){this._manager=e;var r={onDone:this._onDone.bind(this),onProgress:this._onProgress.bind(this)};this._requestId=e.requestRange(t,a,r);this._requests=[];this._queuedChunk=null;this._done=!1;this.onProgress=null;this.onClosed=null}var c=a(0),l=a(8),h=c.globalScope,u=function(){try{var e=new XMLHttpRequest;e.open("GET",h.location.href);e.responseType="moz-chunked-arraybuffer";return"moz-chunked-arraybuffer"===e.responseType}catch(e){return!1}}();r.prototype={requestRange:function(e,t,a){var r={begin:e,end:t};for(var i in a)r[i]=a[i];return this.request(r)},requestFull:function(e){return this.request(e)},request:function(e){var t=this.getXhr(),a=this.currXhrId++,r=this.pendingRequests[a]={xhr:t};t.open("GET",this.url);t.withCredentials=this.withCredentials;for(var i in this.httpHeaders){var n=this.httpHeaders[i];void 0!==n&&t.setRequestHeader(i,n)}if(this.isHttp&&"begin"in e&&"end"in e){var s=e.begin+"-"+(e.end-1);t.setRequestHeader("Range","bytes="+s);r.expectedStatus=206}else r.expectedStatus=200;if(u&&!!e.onProgressiveData){t.responseType="moz-chunked-arraybuffer";r.onProgressiveData=e.onProgressiveData;r.mozChunked=!0}else t.responseType="arraybuffer";e.onError&&(t.onerror=function(a){e.onError(t.status)});t.onreadystatechange=this.onStateChange.bind(this,a);t.onprogress=this.onProgress.bind(this,a);r.onHeadersReceived=e.onHeadersReceived;r.onDone=e.onDone;r.onError=e.onError;r.onProgress=e.onProgress;t.send(null);return a},onProgress:function(e,t){var a=this.pendingRequests[e];if(a){if(a.mozChunked){var r=i(a.xhr);a.onProgressiveData(r)}var n=a.onProgress;n&&n(t)}},onStateChange:function(e,t){var a=this.pendingRequests[e];if(a){var r=a.xhr;if(r.readyState>=2&&a.onHeadersReceived){a.onHeadersReceived();delete a.onHeadersReceived}if(4===r.readyState&&e in this.pendingRequests){delete this.pendingRequests[e];if(0===r.status&&this.isHttp)a.onError&&a.onError(r.status);else{var n=r.status||200;if(200===n&&206===a.expectedStatus||n===a.expectedStatus){this.loadedRequests[e]=!0;var s=i(r);if(206===n){var o=r.getResponseHeader("Content-Range"),c=/bytes (\d+)-(\d+)\/(\d+)/.exec(o),l=parseInt(c[1],10);a.onDone({begin:l,chunk:s})}else a.onProgressiveData?a.onDone(null):s?a.onDone({begin:0,chunk:s}):a.onError&&a.onError(r.status)}else a.onError&&a.onError(r.status)}}}},hasPendingRequests:function(){for(var e in this.pendingRequests)return!0;return!1},getRequestXhr:function(e){return this.pendingRequests[e].xhr},isStreamingRequest:function(e){return!!this.pendingRequests[e].onProgressiveData},isPendingRequest:function(e){return e in this.pendingRequests},isLoadedRequest:function(e){return e in this.loadedRequests},abortAllRequests:function(){for(var e in this.pendingRequests)this.abortRequest(0|e)},abortRequest:function(e){var t=this.pendingRequests[e].xhr;delete this.pendingRequests[e];t.abort()}};var f=c.assert,d=c.createPromiseCapability,g=c.isInt,p=c.MissingPDFException,m=c.UnexpectedResponseException;n.prototype={_onRangeRequestReaderClosed:function(e){var t=this._rangeRequestReaders.indexOf(e);t>=0&&this._rangeRequestReaders.splice(t,1)},getFullReader:function(){f(!this._fullRequestReader);this._fullRequestReader=new s(this._manager,this._options);return this._fullRequestReader},getRangeReader:function(e,t){var a=new o(this._manager,e,t);a.onClosed=this._onRangeRequestReaderClosed.bind(this);this._rangeRequestReaders.push(a);return a},cancelAllRequests:function(e){this._fullRequestReader&&this._fullRequestReader.cancel(e);this._rangeRequestReaders.slice(0).forEach(function(t){t.cancel(e)})}};s.prototype={_validateRangeRequestCapabilities:function(){if(this._disableRange)return!1;var e=this._manager;if(!e.isHttp)return!1;var t=this._fullRequestId,a=e.getRequestXhr(t);if("bytes"!==a.getResponseHeader("Accept-Ranges"))return!1;if("identity"!==(a.getResponseHeader("Content-Encoding")||"identity"))return!1;var r=a.getResponseHeader("Content-Length");r=parseInt(r,10);if(!g(r))return!1;this._contentLength=r;return!(r<=2*this._rangeChunkSize)},_onHeadersReceived:function(){this._validateRangeRequestCapabilities()&&(this._isRangeSupported=!0);var e=this._manager,t=this._fullRequestId;e.isStreamingRequest(t)?this._isStreamingSupported=!0:this._isRangeSupported&&e.abortRequest(t);this._headersReceivedCapability.resolve()},_onProgressiveData:function(e){if(this._requests.length>0){this._requests.shift().resolve({value:e,done:!1})}else this._cachedChunks.push(e)},_onDone:function(e){e&&this._onProgressiveData(e.chunk);this._done=!0;if(!(this._cachedChunks.length>0)){this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[]}},_onError:function(e){var t,a=this._url;t=404===e||0===e&&/^file:/.test(a)?new p('Missing PDF "'+a+'".'):new m("Unexpected server response ("+e+') while retrieving PDF "'+a+'".',e);this._storedError=t;this._headersReceivedCapability.reject(t);this._requests.forEach(function(e){e.reject(t)});this._requests=[];this._cachedChunks=[]},_onProgress:function(e){this.onProgress&&this.onProgress({loaded:e.loaded,total:e.lengthComputable?e.total:this._contentLength})},get isRangeSupported(){return this._isRangeSupported},get isStreamingSupported(){return this._isStreamingSupported},get contentLength(){return this._contentLength},get headersReady(){return this._headersReceivedCapability.promise},read:function(){if(this._storedError)return Promise.reject(this._storedError);if(this._cachedChunks.length>0){var e=this._cachedChunks.shift();return Promise.resolve(e)}if(this._done)return Promise.resolve({value:void 0,done:!0});var t=d();this._requests.push(t);return t.promise},cancel:function(e){this._done=!0;this._headersReceivedCapability.reject(e);this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[];this._manager.isPendingRequest(this._fullRequestId)&&this._manager.abortRequest(this._fullRequestId);this._fullRequestReader=null}};o.prototype={_close:function(){this.onClosed&&this.onClosed(this)},_onDone:function(e){var t=e.chunk;if(this._requests.length>0){this._requests.shift().resolve({value:t,done:!1})}else this._queuedChunk=t;this._done=!0;this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[];this._close()},_onProgress:function(e){!this.isStreamingSupported&&this.onProgress&&this.onProgress({loaded:e.loaded})},get isStreamingSupported(){return!1},read:function(){if(null!==this._queuedChunk){var e=this._queuedChunk;this._queuedChunk=null;return Promise.resolve({value:e,done:!1})}if(this._done)return Promise.resolve({value:void 0,done:!0});var t=d();this._requests.push(t);return t.promise},cancel:function(e){this._done=!0;this._requests.forEach(function(e){e.resolve({value:void 0,done:!0})});this._requests=[];this._manager.isPendingRequest(this._requestId)&&this._manager.abortRequest(this._requestId);this._close()}};l.setPDFNetworkStreamClass(n);t.PDFNetworkStream=n;t.NetworkManager=r},function(e,t,a){"use strict";function r(){}var i=a(0),n=a(1),s=a(2),o=a(3),c=a(16),l=a(14),h=i.AnnotationBorderStyleType,u=i.AnnotationFieldFlag,f=i.AnnotationFlag,d=i.AnnotationType,g=i.OPS,p=i.Util,m=i.isArray,b=i.isInt,v=i.stringToBytes,y=i.stringToPDFString,k=i.warn,w=n.Dict,C=n.isDict,x=n.isName,S=n.isRef,A=n.isStream,I=s.Stream,B=o.ColorSpace,R=c.Catalog,T=c.ObjectLoader,O=c.FileSpec,P=l.OperatorList;r.prototype={create:function(e,t,a,r){var i=e.fetchIfRef(t);if(C(i)){var n=S(t)?t.toString():"annot_"+r.createObjId(),s=i.get("Subtype");s=x(s)?s.name:null;var o={xref:e,dict:i,ref:S(t)?t:null,subtype:s,id:n,pdfManager:a};switch(s){case"Link":return new N(o);case"Text":return new U(o);case"Widget":var c=p.getInheritableProperty(i,"FT");c=x(c)?c.name:null;switch(c){case"Tx":return new D(o);case"Btn":return new F(o);case"Ch":return new q(o)}k('Unimplemented widget field type "'+c+'", falling back to base field type.');return new L(o);case"Popup":return new j(o);case"Highlight":return new _(o);case"Underline":return new z(o);case"Squiggly":return new H(o);case"StrikeOut":return new G(o);case"FileAttachment":return new X(o);default:k(s?'Unimplemented annotation type "'+s+'", falling back to base annotation.':"Annotation is missing the required /Subtype.");return new M(o)}}}};var M=function(){function e(e,t,a){var r=p.getAxialAlignedBoundingBox(t,a),i=r[0],n=r[1],s=r[2],o=r[3];if(i===s||n===o)return[1,0,0,1,e[0],e[1]];var c=(e[2]-e[0])/(s-i),l=(e[3]-e[1])/(o-n);return[c,0,0,l,e[0]-i*c,e[1]-n*l]}function t(e){var t=e.dict;this.setFlags(t.get("F"));this.setRectangle(t.getArray("Rect"));this.setColor(t.getArray("C"));this.setBorderStyle(t);this.setAppearance(t);this.data={};this.data.id=e.id;this.data.subtype=e.subtype;this.data.annotationFlags=this.flags;this.data.rect=this.rectangle;this.data.color=this.color;this.data.borderStyle=this.borderStyle;this.data.hasAppearance=!!this.appearance}t.prototype={_hasFlag:function(e,t){return!!(e&t)},_isViewable:function(e){return!this._hasFlag(e,f.INVISIBLE)&&!this._hasFlag(e,f.HIDDEN)&&!this._hasFlag(e,f.NOVIEW)},_isPrintable:function(e){return this._hasFlag(e,f.PRINT)&&!this._hasFlag(e,f.INVISIBLE)&&!this._hasFlag(e,f.HIDDEN)},get viewable(){return 0===this.flags||this._isViewable(this.flags)},get printable(){return 0!==this.flags&&this._isPrintable(this.flags)},setFlags:function(e){this.flags=b(e)&&e>0?e:0},hasFlag:function(e){return this._hasFlag(this.flags,e)},setRectangle:function(e){m(e)&&4===e.length?this.rectangle=p.normalizeRect(e):this.rectangle=[0,0,0,0]},setColor:function(e){var t=new Uint8Array(3);if(m(e))switch(e.length){case 0:this.color=null;break;case 1:B.singletons.gray.getRgbItem(e,0,t,0);this.color=t;break;case 3:B.singletons.rgb.getRgbItem(e,0,t,0);this.color=t;break;case 4:B.singletons.cmyk.getRgbItem(e,0,t,0);this.color=t;break;default:this.color=t}else this.color=t},setBorderStyle:function(e){this.borderStyle=new E;if(C(e))if(e.has("BS")){var t=e.get("BS"),a=t.get("Type");if(!a||x(a,"Border")){this.borderStyle.setWidth(t.get("W"));this.borderStyle.setStyle(t.get("S"));this.borderStyle.setDashArray(t.getArray("D"))}}else if(e.has("Border")){var r=e.getArray("Border");if(m(r)&&r.length>=3){this.borderStyle.setHorizontalCornerRadius(r[0]);this.borderStyle.setVerticalCornerRadius(r[1]);this.borderStyle.setWidth(r[2]);4===r.length&&this.borderStyle.setDashArray(r[3])}}else this.borderStyle.setWidth(0)},setAppearance:function(e){this.appearance=null;var t=e.get("AP");if(C(t)){var a=t.get("N");if(A(a))this.appearance=a;else if(C(a)){var r=e.get("AS");x(r)&&a.has(r.name)&&(this.appearance=a.get(r.name))}}},_preparePopup:function(e){e.has("C")||(this.data.color=null);this.data.hasPopup=e.has("Popup");this.data.title=y(e.get("T")||"");this.data.contents=y(e.get("Contents")||"")},loadResources:function(e){return new Promise(function(t,a){this.appearance.dict.getAsync("Resources").then(function(r){if(r){new T(r.map,e,r.xref).load().then(function(){t(r)},a)}else t()},a)}.bind(this))},getOperatorList:function(t,a,r){if(!this.appearance)return Promise.resolve(new P);var i=this.data,n=this.appearance.dict,s=this.loadResources(["ExtGState","ColorSpace","Pattern","Shading","XObject","Font"]),o=n.getArray("BBox")||[0,0,1,1],c=n.getArray("Matrix")||[1,0,0,1,0,0],l=e(i.rect,o,c),h=this;return s.then(function(e){var r=new P;r.addOp(g.beginAnnotation,[i.rect,l,c]);return t.getOperatorList(h.appearance,a,e,r).then(function(){r.addOp(g.endAnnotation,[]);h.appearance.reset();return r})})}};return t}(),E=function(){function e(){this.width=1;this.style=h.SOLID;this.dashArray=[3];this.horizontalCornerRadius=0;this.verticalCornerRadius=0}e.prototype={setWidth:function(e){e===(0|e)&&(this.width=e)},setStyle:function(e){if(e)switch(e.name){case"S":this.style=h.SOLID;break;case"D":this.style=h.DASHED;break;case"B":this.style=h.BEVELED;break;case"I":this.style=h.INSET;break;case"U":this.style=h.UNDERLINE}},setDashArray:function(e){if(m(e)&&e.length>0){for(var t=!0,a=!0,r=0,i=e.length;r<i;r++){var n=e[r];if(!(+n>=0)){t=!1;break}n>0&&(a=!1)}t&&!a?this.dashArray=e:this.width=0}else e&&(this.width=0)},setHorizontalCornerRadius:function(e){e===(0|e)&&(this.horizontalCornerRadius=e)},setVerticalCornerRadius:function(e){e===(0|e)&&(this.verticalCornerRadius=e)}};return e}(),L=function(){function e(e){M.call(this,e);var t=e.dict,a=this.data;a.annotationType=d.WIDGET;a.fieldName=this._constructFieldName(t);a.fieldValue=p.getInheritableProperty(t,"V",!0);a.alternativeText=y(t.get("TU")||"");a.defaultAppearance=p.getInheritableProperty(t,"DA")||"";var r=p.getInheritableProperty(t,"FT");a.fieldType=x(r)?r.name:null;this.fieldResources=p.getInheritableProperty(t,"DR")||w.empty;a.fieldFlags=p.getInheritableProperty(t,"Ff");(!b(a.fieldFlags)||a.fieldFlags<0)&&(a.fieldFlags=0);a.readOnly=this.hasFieldFlag(u.READONLY);"Sig"===a.fieldType&&this.setFlags(f.HIDDEN)}p.inherit(e,M,{_constructFieldName:function(e){if(!e.has("T")&&!e.has("Parent")){k("Unknown field name, falling back to empty field name.");return""}if(!e.has("Parent"))return y(e.get("T"));var t=[];e.has("T")&&t.unshift(y(e.get("T")));for(var a=e;a.has("Parent");){a=a.get("Parent");if(!C(a))break;a.has("T")&&t.unshift(y(a.get("T")))}return t.join(".")},hasFieldFlag:function(e){return!!(this.data.fieldFlags&e)}});return e}(),D=function(){function e(e){L.call(this,e);this.data.fieldValue=y(this.data.fieldValue||"");var t=p.getInheritableProperty(e.dict,"Q");(!b(t)||t<0||t>2)&&(t=null);this.data.textAlignment=t;var a=p.getInheritableProperty(e.dict,"MaxLen");(!b(a)||a<0)&&(a=null);this.data.maxLen=a;this.data.multiLine=this.hasFieldFlag(u.MULTILINE);this.data.comb=this.hasFieldFlag(u.COMB)&&!this.hasFieldFlag(u.MULTILINE)&&!this.hasFieldFlag(u.PASSWORD)&&!this.hasFieldFlag(u.FILESELECT)&&null!==this.data.maxLen}p.inherit(e,L,{getOperatorList:function(e,t,a){var r=new P;if(a)return Promise.resolve(r);if(this.appearance)return M.prototype.getOperatorList.call(this,e,t,a);if(!this.data.defaultAppearance)return Promise.resolve(r);var i=new I(v(this.data.defaultAppearance));return e.getOperatorList(i,t,this.fieldResources,r).then(function(){return r})}});return e}(),F=function(){function e(e){L.call(this,e);this.data.checkBox=!this.hasFieldFlag(u.RADIO)&&!this.hasFieldFlag(u.PUSHBUTTON);if(this.data.checkBox){if(!x(this.data.fieldValue))return;this.data.fieldValue=this.data.fieldValue.name}this.data.radioButton=this.hasFieldFlag(u.RADIO)&&!this.hasFieldFlag(u.PUSHBUTTON);if(this.data.radioButton){this.data.fieldValue=this.data.buttonValue=null;var t=e.dict.get("Parent");if(C(t)&&t.has("V")){var a=t.get("V");x(a)&&(this.data.fieldValue=a.name)}var r=e.dict.get("AP");if(!C(r))return;var i=r.get("N");if(!C(i))return;for(var n=i.getKeys(),s=0,o=n.length;s<o;s++)if("Off"!==n[s]){this.data.buttonValue=n[s];break}}}p.inherit(e,L,{getOperatorList:function(e,t,a){var r=new P;return a?Promise.resolve(r):this.appearance?M.prototype.getOperatorList.call(this,e,t,a):Promise.resolve(r)}});return e}(),q=function(){function e(e){L.call(this,e);this.data.options=[];var t=p.getInheritableProperty(e.dict,"Opt");if(m(t))for(var a=e.xref,r=0,i=t.length;r<i;r++){var n=a.fetchIfRef(t[r]),s=m(n);this.data.options[r]={exportValue:s?a.fetchIfRef(n[0]):n,displayValue:s?a.fetchIfRef(n[1]):n}}m(this.data.fieldValue)||(this.data.fieldValue=[this.data.fieldValue]);this.data.combo=this.hasFieldFlag(u.COMBO);this.data.multiSelect=this.hasFieldFlag(u.MULTISELECT)}p.inherit(e,L,{getOperatorList:function(e,t,a){var r=new P;return a?Promise.resolve(r):M.prototype.getOperatorList.call(this,e,t,a)}});return e}(),U=function(){function e(e){M.call(this,e);this.data.annotationType=d.TEXT;if(this.data.hasAppearance)this.data.name="NoIcon";else{this.data.rect[1]=this.data.rect[3]-t;this.data.rect[2]=this.data.rect[0]+t;this.data.name=e.dict.has("Name")?e.dict.get("Name").name:"Note"}this._preparePopup(e.dict)}var t=22;p.inherit(e,M,{});return e}(),N=function(){function e(e){M.call(this,e);var t=this.data;t.annotationType=d.LINK;R.parseDestDictionary({destDict:e.dict,resultObj:t,docBaseUrl:e.pdfManager.docBaseUrl})}p.inherit(e,M,{});return e}(),j=function(){function e(e){M.call(this,e);this.data.annotationType=d.POPUP;var t=e.dict,a=t.get("Parent");if(a){this.data.parentId=t.getRaw("Parent").toString();this.data.title=y(a.get("T")||"");this.data.contents=y(a.get("Contents")||"");if(a.has("C")){this.setColor(a.getArray("C"));this.data.color=this.color}else this.data.color=null;if(!this.viewable){var r=a.get("F");this._isViewable(r)&&this.setFlags(r)}}else k("Popup annotation has a missing or invalid parent annotation.")}p.inherit(e,M,{});return e}(),_=function(){function e(e){M.call(this,e);this.data.annotationType=d.HIGHLIGHT;this._preparePopup(e.dict);this.data.borderStyle.setWidth(0)}p.inherit(e,M,{});return e}(),z=function(){function e(e){M.call(this,e);this.data.annotationType=d.UNDERLINE;this._preparePopup(e.dict);this.data.borderStyle.setWidth(0)}p.inherit(e,M,{});return e}(),H=function(){function e(e){M.call(this,e);this.data.annotationType=d.SQUIGGLY;this._preparePopup(e.dict);this.data.borderStyle.setWidth(0)}p.inherit(e,M,{});return e}(),G=function(){function e(e){M.call(this,e);this.data.annotationType=d.STRIKEOUT;this._preparePopup(e.dict);this.data.borderStyle.setWidth(0)}p.inherit(e,M,{});return e}(),X=function(){function e(e){M.call(this,e);var t=new O(e.dict.get("FS"),e.xref);this.data.annotationType=d.FILEATTACHMENT;this.data.file=t.serializable;this._preparePopup(e.dict)}p.inherit(e,M,{});return e}();t.Annotation=M;t.AnnotationBorderStyle=E;t.AnnotationFactory=r},function(e,t,a){"use strict";function r(e){return 0!=(1&e)}function i(e){return 0==(1&e)}function n(e,t,a){for(var r=t,i=e.length;r<i;++r)if(e[r]!==a)return r;return r}function s(e,t,a,r){for(var i=t;i<a;++i)e[i]=r}function o(e,t,a){for(var r=t,i=a-1;r<i;++r,--i){var n=e[r];e[r]=e[i];e[i]=n}}function c(e,t,a){return{str:e,dir:a?"ttb":t?"ltr":"rtl"}}function l(e,t,a){var l=!0,h=e.length;if(0===h||a)return c(e,l,a);g.length=h;p.length=h;var m,b,v=0;for(m=0;m<h;++m){g[m]=e.charAt(m);var y=e.charCodeAt(m),k="L";if(y<=255)k=f[y];else if(1424<=y&&y<=1524)k="R";else if(1536<=y&&y<=1791){k=d[255&y];k||u("Bidi: invalid Unicode character "+y.toString(16))}else 1792<=y&&y<=2220&&(k="AL");"R"!==k&&"AL"!==k&&"AN"!==k||v++;p[m]=k}if(0===v){l=!0;return c(e,l)}if(-1===t)if(v/h<.3){l=!0;t=0}else{l=!1;t=1}var w=[];for(m=0;m<h;++m)w[m]=t;var C=r(t)?"R":"L",x=C,S=x,A=x;for(m=0;m<h;++m)"NSM"===p[m]?p[m]=A:A=p[m];A=x;var I;for(m=0;m<h;++m){I=p[m];"EN"===I?p[m]="AL"===A?"AN":"EN":"R"!==I&&"L"!==I&&"AL"!==I||(A=I)}for(m=0;m<h;++m){I=p[m];"AL"===I&&(p[m]="R")}for(m=1;m<h-1;++m){"ES"===p[m]&&"EN"===p[m-1]&&"EN"===p[m+1]&&(p[m]="EN");"CS"!==p[m]||"EN"!==p[m-1]&&"AN"!==p[m-1]||p[m+1]!==p[m-1]||(p[m]=p[m-1])}for(m=0;m<h;++m)if("EN"===p[m]){var B;for(B=m-1;B>=0&&"ET"===p[B];--B)p[B]="EN";for(B=m+1;B<h&&"ET"===p[B];++B)p[B]="EN"}for(m=0;m<h;++m){I=p[m];"WS"!==I&&"ES"!==I&&"ET"!==I&&"CS"!==I||(p[m]="ON")}A=x;for(m=0;m<h;++m){I=p[m];"EN"===I?p[m]="L"===A?"L":"EN":"R"!==I&&"L"!==I||(A=I)}for(m=0;m<h;++m)if("ON"===p[m]){var R=n(p,m+1,"ON"),T=x;m>0&&(T=p[m-1]);var O=S;R+1<h&&(O=p[R+1]);"L"!==T&&(T="R");"L"!==O&&(O="R");T===O&&s(p,m,R,T);m=R-1}for(m=0;m<h;++m)"ON"===p[m]&&(p[m]=C);for(m=0;m<h;++m){I=p[m];i(w[m])?"R"===I?w[m]+=1:"AN"!==I&&"EN"!==I||(w[m]+=2):"L"!==I&&"AN"!==I&&"EN"!==I||(w[m]+=1)}var P,M=-1,E=99;for(m=0,b=w.length;m<b;++m){P=w[m];M<P&&(M=P);E>P&&r(P)&&(E=P)}for(P=M;P>=E;--P){var L=-1;for(m=0,b=w.length;m<b;++m)if(w[m]<P){if(L>=0){o(g,L,m);L=-1}}else L<0&&(L=m);L>=0&&o(g,L,w.length)}for(m=0,b=g.length;m<b;++m){var D=g[m];"<"!==D&&">"!==D||(g[m]="")}return c(g.join(""),l)}
+var h=a(0),u=h.warn,f=["BN","BN","BN","BN","BN","BN","BN","BN","BN","S","B","S","WS","B","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","B","B","B","S","WS","ON","ON","ET","ET","ET","ON","ON","ON","ON","ON","ES","CS","ES","CS","CS","EN","EN","EN","EN","EN","EN","EN","EN","EN","EN","CS","ON","ON","ON","ON","ON","ON","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","ON","ON","ON","ON","ON","ON","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","ON","ON","ON","ON","BN","BN","BN","BN","BN","BN","B","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","BN","CS","ON","ET","ET","ET","ET","ON","ON","ON","ON","L","ON","ON","BN","ON","ON","ET","ET","EN","EN","ON","L","ON","ON","ON","EN","L","ON","ON","ON","ON","ON","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","ON","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","L","ON","L","L","L","L","L","L","L","L"],d=["AN","AN","AN","AN","AN","AN","ON","ON","AL","ET","ET","AL","CS","AL","ON","ON","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","AL","AL","","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","NSM","AN","AN","AN","AN","AN","AN","AN","AN","AN","AN","ET","AN","AN","AL","AL","AL","NSM","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","AL","NSM","NSM","NSM","NSM","NSM","NSM","NSM","AN","ON","NSM","NSM","NSM","NSM","NSM","NSM","AL","AL","NSM","NSM","ON","NSM","NSM","NSM","NSM","AL","AL","EN","EN","EN","EN","EN","EN","EN","EN","EN","EN","AL","AL","AL","AL","AL","AL"],g=[],p=[];t.bidi=l},function(e,t,a){"use strict";var r=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron"],i=[".notdef","space","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],n=[".notdef","space","dollaroldstyle","dollarsuperior","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","hyphensuperior","colonmonetary","onefitted","rupiah","centoldstyle","figuredash","hypheninferior","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior"];t.ISOAdobeCharset=r;t.ExpertCharset=i;t.ExpertSubsetCharset=n},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(2),s=a(5),o=r.Util,c=r.assert,l=r.warn,h=r.error,u=r.isInt,f=r.isString,d=r.MissingDataException,g=r.CMapCompressionType,p=i.isEOF,m=i.isName,b=i.isCmd,v=i.isStream,y=n.Stream,k=s.Lexer,w=["Adobe-GB1-UCS2","Adobe-CNS1-UCS2","Adobe-Japan1-UCS2","Adobe-Korea1-UCS2","78-EUC-H","78-EUC-V","78-H","78-RKSJ-H","78-RKSJ-V","78-V","78ms-RKSJ-H","78ms-RKSJ-V","83pv-RKSJ-H","90ms-RKSJ-H","90ms-RKSJ-V","90msp-RKSJ-H","90msp-RKSJ-V","90pv-RKSJ-H","90pv-RKSJ-V","Add-H","Add-RKSJ-H","Add-RKSJ-V","Add-V","Adobe-CNS1-0","Adobe-CNS1-1","Adobe-CNS1-2","Adobe-CNS1-3","Adobe-CNS1-4","Adobe-CNS1-5","Adobe-CNS1-6","Adobe-GB1-0","Adobe-GB1-1","Adobe-GB1-2","Adobe-GB1-3","Adobe-GB1-4","Adobe-GB1-5","Adobe-Japan1-0","Adobe-Japan1-1","Adobe-Japan1-2","Adobe-Japan1-3","Adobe-Japan1-4","Adobe-Japan1-5","Adobe-Japan1-6","Adobe-Korea1-0","Adobe-Korea1-1","Adobe-Korea1-2","B5-H","B5-V","B5pc-H","B5pc-V","CNS-EUC-H","CNS-EUC-V","CNS1-H","CNS1-V","CNS2-H","CNS2-V","ETHK-B5-H","ETHK-B5-V","ETen-B5-H","ETen-B5-V","ETenms-B5-H","ETenms-B5-V","EUC-H","EUC-V","Ext-H","Ext-RKSJ-H","Ext-RKSJ-V","Ext-V","GB-EUC-H","GB-EUC-V","GB-H","GB-V","GBK-EUC-H","GBK-EUC-V","GBK2K-H","GBK2K-V","GBKp-EUC-H","GBKp-EUC-V","GBT-EUC-H","GBT-EUC-V","GBT-H","GBT-V","GBTpc-EUC-H","GBTpc-EUC-V","GBpc-EUC-H","GBpc-EUC-V","H","HKdla-B5-H","HKdla-B5-V","HKdlb-B5-H","HKdlb-B5-V","HKgccs-B5-H","HKgccs-B5-V","HKm314-B5-H","HKm314-B5-V","HKm471-B5-H","HKm471-B5-V","HKscs-B5-H","HKscs-B5-V","Hankaku","Hiragana","KSC-EUC-H","KSC-EUC-V","KSC-H","KSC-Johab-H","KSC-Johab-V","KSC-V","KSCms-UHC-H","KSCms-UHC-HW-H","KSCms-UHC-HW-V","KSCms-UHC-V","KSCpc-EUC-H","KSCpc-EUC-V","Katakana","NWP-H","NWP-V","RKSJ-H","RKSJ-V","Roman","UniCNS-UCS2-H","UniCNS-UCS2-V","UniCNS-UTF16-H","UniCNS-UTF16-V","UniCNS-UTF32-H","UniCNS-UTF32-V","UniCNS-UTF8-H","UniCNS-UTF8-V","UniGB-UCS2-H","UniGB-UCS2-V","UniGB-UTF16-H","UniGB-UTF16-V","UniGB-UTF32-H","UniGB-UTF32-V","UniGB-UTF8-H","UniGB-UTF8-V","UniJIS-UCS2-H","UniJIS-UCS2-HW-H","UniJIS-UCS2-HW-V","UniJIS-UCS2-V","UniJIS-UTF16-H","UniJIS-UTF16-V","UniJIS-UTF32-H","UniJIS-UTF32-V","UniJIS-UTF8-H","UniJIS-UTF8-V","UniJIS2004-UTF16-H","UniJIS2004-UTF16-V","UniJIS2004-UTF32-H","UniJIS2004-UTF32-V","UniJIS2004-UTF8-H","UniJIS2004-UTF8-V","UniJISPro-UCS2-HW-V","UniJISPro-UCS2-V","UniJISPro-UTF8-V","UniJISX0213-UTF32-H","UniJISX0213-UTF32-V","UniJISX02132004-UTF32-H","UniJISX02132004-UTF32-V","UniKS-UCS2-H","UniKS-UCS2-V","UniKS-UTF16-H","UniKS-UTF16-V","UniKS-UTF32-H","UniKS-UTF32-V","UniKS-UTF8-H","UniKS-UTF8-V","V","WP-Symbol"],C=function(){function e(e){this.codespaceRanges=[[],[],[],[]];this.numCodespaceRanges=0;this._map=[];this.name="";this.vertical=!1;this.useCMap=null;this.builtInCMap=e}e.prototype={addCodespaceRange:function(e,t,a){this.codespaceRanges[e-1].push(t,a);this.numCodespaceRanges++},mapCidRange:function(e,t,a){for(;e<=t;)this._map[e++]=a++},mapBfRange:function(e,t,a){for(var r=a.length-1;e<=t;){this._map[e++]=a;a=a.substr(0,r)+String.fromCharCode(a.charCodeAt(r)+1)}},mapBfRangeToArray:function(e,t,a){for(var r=0,i=a.length;e<=t&&r<i;){this._map[e]=a[r++];++e}},mapOne:function(e,t){this._map[e]=t},lookup:function(e){return this._map[e]},contains:function(e){return void 0!==this._map[e]},forEach:function(e){var t,a=this._map,r=a.length;if(r<=65536)for(t=0;t<r;t++)void 0!==a[t]&&e(t,a[t]);else for(t in this._map)e(t,a[t])},charCodeOf:function(e){return this._map.indexOf(e)},getMap:function(){return this._map},readCharCode:function(e,t,a){for(var r=0,i=this.codespaceRanges,n=this.codespaceRanges.length,s=0;s<n;s++){r=(r<<8|e.charCodeAt(t+s))>>>0;for(var o=i[s],c=0,l=o.length;c<l;){var h=o[c++],u=o[c++];if(r>=h&&r<=u){a.charcode=r;a.length=s+1;return}}}a.charcode=0;a.length=1},get length(){return this._map.length},get isIdentityCMap(){if("Identity-H"!==this.name&&"Identity-V"!==this.name)return!1;if(65536!==this._map.length)return!1;for(var e=0;e<65536;e++)if(this._map[e]!==e)return!1;return!0}};return e}(),x=function(){function e(e,t){C.call(this);this.vertical=e;this.addCodespaceRange(t,0,65535)}o.inherit(e,C,{});e.prototype={addCodespaceRange:C.prototype.addCodespaceRange,mapCidRange:function(e,t,a){h("should not call mapCidRange")},mapBfRange:function(e,t,a){h("should not call mapBfRange")},mapBfRangeToArray:function(e,t,a){h("should not call mapBfRangeToArray")},mapOne:function(e,t){h("should not call mapCidOne")},lookup:function(e){return u(e)&&e<=65535?e:void 0},contains:function(e){return u(e)&&e<=65535},forEach:function(e){for(var t=0;t<=65535;t++)e(t,t)},charCodeOf:function(e){return u(e)&&e<=65535?e:-1},getMap:function(){for(var e=new Array(65536),t=0;t<=65535;t++)e[t]=t;return e},readCharCode:C.prototype.readCharCode,get length(){return 65536},get isIdentityCMap(){h("should not access .isIdentityCMap")}};return e}(),S=function(){function e(e,t){for(var a=0,r=0;r<=t;r++)a=a<<8|e[r];return a>>>0}function t(e,t){return 1===t?String.fromCharCode(e[0],e[1]):3===t?String.fromCharCode(e[0],e[1],e[2],e[3]):String.fromCharCode.apply(null,e.subarray(0,t+1))}function a(e,t,a){for(var r=0,i=a;i>=0;i--){r+=e[i]+t[i];e[i]=255&r;r>>=8}}function r(e,t){for(var a=1,r=t;r>=0&&a>0;r--){a+=e[r];e[r]=255&a;a>>=8}}function i(e){this.buffer=e;this.pos=0;this.end=e.length;this.tmpBuf=new Uint8Array(l)}function n(n,s,l){return new Promise(function(h,u){var f=new i(n),d=f.readByte();s.vertical=!!(1&d);for(var g,p,m=null,b=new Uint8Array(o),v=new Uint8Array(o),y=new Uint8Array(o),k=new Uint8Array(o),w=new Uint8Array(o);(p=f.readByte())>=0;){var C=p>>5;if(7!==C){var x=!!(16&p),S=15&p;c(S+1<=o);var A,I=f.readNumber();switch(C){case 0:f.readHex(b,S);f.readHexNumber(v,S);a(v,b,S);s.addCodespaceRange(S+1,e(b,S),e(v,S));for(A=1;A<I;A++){r(v,S);f.readHexNumber(b,S);a(b,v,S);f.readHexNumber(v,S);a(v,b,S);s.addCodespaceRange(S+1,e(b,S),e(v,S))}break;case 1:f.readHex(b,S);f.readHexNumber(v,S);a(v,b,S);g=f.readNumber();for(A=1;A<I;A++){r(v,S);f.readHexNumber(b,S);a(b,v,S);f.readHexNumber(v,S);a(v,b,S);g=f.readNumber()}break;case 2:f.readHex(y,S);g=f.readNumber();s.mapOne(e(y,S),g);for(A=1;A<I;A++){r(y,S);if(!x){f.readHexNumber(w,S);a(y,w,S)}g=f.readSigned()+(g+1);s.mapOne(e(y,S),g)}break;case 3:f.readHex(b,S);f.readHexNumber(v,S);a(v,b,S);g=f.readNumber();s.mapCidRange(e(b,S),e(v,S),g);for(A=1;A<I;A++){r(v,S);if(x)b.set(v);else{f.readHexNumber(b,S);a(b,v,S)}f.readHexNumber(v,S);a(v,b,S);g=f.readNumber();s.mapCidRange(e(b,S),e(v,S),g)}break;case 4:f.readHex(y,1);f.readHex(k,S);s.mapOne(e(y,1),t(k,S));for(A=1;A<I;A++){r(y,1);if(!x){f.readHexNumber(w,1);a(y,w,1)}r(k,S);f.readHexSigned(w,S);a(k,w,S);s.mapOne(e(y,1),t(k,S))}break;case 5:f.readHex(b,1);f.readHexNumber(v,1);a(v,b,1);f.readHex(k,S);s.mapBfRange(e(b,1),e(v,1),t(k,S));for(A=1;A<I;A++){r(v,1);if(x)b.set(v);else{f.readHexNumber(b,1);a(b,v,1)}f.readHexNumber(v,1);a(v,b,1);f.readHex(k,S);s.mapBfRange(e(b,1),e(v,1),t(k,S))}break;default:u(new Error("processBinaryCMap: Unknown type: "+C));return}}else switch(31&p){case 0:f.readString();break;case 1:m=f.readString()}}h(m?l(m):s)})}function s(){}var o=16,l=19;i.prototype={readByte:function(){return this.pos>=this.end?-1:this.buffer[this.pos++]},readNumber:function(){var e,t=0;do{var a=this.readByte();a<0&&h("unexpected EOF in bcmap");e=!(128&a);t=t<<7|127&a}while(!e);return t},readSigned:function(){var e=this.readNumber();return 1&e?~(e>>>1):e>>>1},readHex:function(e,t){e.set(this.buffer.subarray(this.pos,this.pos+t+1));this.pos+=t+1},readHexNumber:function(e,t){var a,r=this.tmpBuf,i=0;do{var n=this.readByte();n<0&&h("unexpected EOF in bcmap");a=!(128&n);r[i++]=127&n}while(!a);for(var s=t,o=0,c=0;s>=0;){for(;c<8&&r.length>0;){o=r[--i]<<c|o;c+=7}e[s]=255&o;s--;o>>=8;c-=8}},readHexSigned:function(e,t){this.readHexNumber(e,t);for(var a=1&e[t]?255:0,r=0,i=0;i<=t;i++){r=(1&r)<<8|e[i];e[i]=r>>1^a}},readString:function(){for(var e=this.readNumber(),t="",a=0;a<e;a++)t+=String.fromCharCode(this.readNumber());return t}};s.prototype={process:n};return s}(),A=function(){function e(e){for(var t=0,a=0;a<e.length;a++)t=t<<8|e.charCodeAt(a);return t>>>0}function t(e){f(e)||h("Malformed CMap: expected string.")}function a(e){u(e)||h("Malformed CMap: expected int.")}function r(a,r){for(;;){var i=r.getObj();if(p(i))break;if(b(i,"endbfchar"))return;t(i);var n=e(i);i=r.getObj();t(i);var s=i;a.mapOne(n,s)}}function i(a,r){for(;;){var i=r.getObj();if(p(i))break;if(b(i,"endbfrange"))return;t(i);var n=e(i);i=r.getObj();t(i);var s=e(i);i=r.getObj();if(u(i)||f(i)){var o=u(i)?String.fromCharCode(i):i;a.mapBfRange(n,s,o)}else{if(!b(i,"["))break;i=r.getObj();for(var c=[];!b(i,"]")&&!p(i);){c.push(i);i=r.getObj()}a.mapBfRangeToArray(n,s,c)}}h("Invalid bf range.")}function n(r,i){for(;;){var n=i.getObj();if(p(n))break;if(b(n,"endcidchar"))return;t(n);var s=e(n);n=i.getObj();a(n);var o=n;r.mapOne(s,o)}}function s(r,i){for(;;){var n=i.getObj();if(p(n))break;if(b(n,"endcidrange"))return;t(n);var s=e(n);n=i.getObj();t(n);var o=e(n);n=i.getObj();a(n);var c=n;r.mapCidRange(s,o,c)}}function o(t,a){for(;;){var r=a.getObj();if(p(r))break;if(b(r,"endcodespacerange"))return;if(!f(r))break;var i=e(r);r=a.getObj();if(!f(r))break;var n=e(r);t.addCodespaceRange(r.length,i,n)}h("Invalid codespace range.")}function A(e,t){var a=t.getObj();u(a)&&(e.vertical=!!a)}function I(e,t){var a=t.getObj();m(a)&&f(a.name)&&(e.name=a.name)}function B(e,t,a,c){var h,u;e:for(;;)try{var f=t.getObj();if(p(f))break;if(m(f)){"WMode"===f.name?A(e,t):"CMapName"===f.name&&I(e,t);h=f}else if(b(f))switch(f.cmd){case"endcmap":break e;case"usecmap":m(h)&&(u=h.name);break;case"begincodespacerange":o(e,t);break;case"beginbfchar":r(e,t);break;case"begincidchar":n(e,t);break;case"beginbfrange":i(e,t);break;case"begincidrange":s(e,t)}}catch(e){if(e instanceof d)throw e;l("Invalid cMap data: "+e);continue}!c&&u&&(c=u);return c?R(e,a,c):Promise.resolve(e)}function R(e,t,a){return T(a,t).then(function(t){e.useCMap=t;if(0===e.numCodespaceRanges){for(var a=e.useCMap.codespaceRanges,r=0;r<a.length;r++)e.codespaceRanges[r]=a[r].slice();e.numCodespaceRanges=e.useCMap.numCodespaceRanges}e.useCMap.forEach(function(t,a){e.contains(t)||e.mapOne(t,e.useCMap.lookup(t))});return e})}function T(e,t){if("Identity-H"===e)return Promise.resolve(new x(!1,2));if("Identity-V"===e)return Promise.resolve(new x(!0,2));if(-1===w.indexOf(e))return Promise.reject(new Error("Unknown CMap name: "+e));c(t,"Built-in CMap parameters are not provided.");return t(e).then(function(e){var a=e.cMapData,r=e.compressionType,i=new C(!0);if(r===g.BINARY)return(new S).process(a,i,function(e){return R(i,t,e)});c(r===g.NONE,"TODO: Only BINARY/NONE CMap compression is currently supported.");var n=new k(new y(a));return B(i,n,t,null)})}return{create:function(e){var t=e.encoding,a=e.fetchBuiltInCMap,r=e.useCMap;if(m(t))return T(t.name,a);if(v(t)){return B(new C,new k(t),a,r).then(function(e){return e.isIdentityCMap?T(e.name,a):e})}return Promise.reject(new Error("Encoding required."))}}}();t.CMap=C;t.CMapFactory=A;t.IdentityCMap=x},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(2),s=a(16),o=a(5),c=a(13),l=a(14),h=a(20),u=r.OPS,f=r.MissingDataException,d=r.Util,g=r.assert,p=r.error,m=r.info,b=r.isArray,v=r.isArrayBuffer,y=r.isNum,k=r.isString,w=r.shadow,C=r.stringToBytes,x=r.stringToPDFString,S=r.warn,A=r.isSpace,I=i.Dict,B=i.isDict,R=i.isName,T=i.isStream,O=n.NullStream,P=n.Stream,M=n.StreamsSequenceStream,E=s.Catalog,L=s.ObjectLoader,D=s.XRef,F=o.Linearization,q=c.calculateMD5,U=l.OperatorList,N=l.PartialEvaluator,j=h.AnnotationFactory,_=function(){function e(e,t){return"display"===t&&e.viewable||"print"===t&&e.printable}function t(e,t,a,r,i,n,s){this.pdfManager=e;this.pageIndex=a;this.pageDict=r;this.xref=t;this.ref=i;this.fontCache=n;this.builtInCMapCache=s;this.evaluatorOptions=e.evaluatorOptions;this.resourcesPromise=null;var o="p"+this.pageIndex+"_",c={obj:0};this.idFactory={createObjId:function(){return o+ ++c.obj}}}var a=[0,0,612,792];t.prototype={getPageProp:function(e){return this.pageDict.get(e)},getInheritedPageProp:function(e,t){var a=this.pageDict,r=null,i=0;t=t||!1;for(;a;){var n=t?a.getArray(e):a.get(e);if(void 0!==n){r||(r=[]);r.push(n)}if(++i>100){S("getInheritedPageProp: maximum loop count exceeded for "+e);return r?r[0]:void 0}a=a.get("Parent")}if(r)return 1!==r.length&&B(r[0])?I.merge(this.xref,r):r[0]},get content(){return this.getPageProp("Contents")},get resources(){return w(this,"resources",this.getInheritedPageProp("Resources")||I.empty)},get mediaBox(){var e=this.getInheritedPageProp("MediaBox",!0);return b(e)&&4===e.length?w(this,"mediaBox",e):w(this,"mediaBox",a)},get cropBox(){var e=this.getInheritedPageProp("CropBox",!0);return b(e)&&4===e.length?w(this,"cropBox",e):w(this,"cropBox",this.mediaBox)},get userUnit(){var e=this.getPageProp("UserUnit");(!y(e)||e<=0)&&(e=1);return w(this,"userUnit",e)},get view(){var e=this.mediaBox,t=this.cropBox;if(e===t)return w(this,"view",e);var a=d.intersect(t,e);return w(this,"view",a||e)},get rotate(){var e=this.getInheritedPageProp("Rotate")||0;e%90!=0?e=0:e>=360?e%=360:e<0&&(e=(e%360+360)%360);return w(this,"rotate",e)},getContentStream:function(){var e,t=this.content;if(b(t)){var a,r=this.xref,i=t.length,n=[];for(a=0;a<i;++a)n.push(r.fetchIfRef(t[a]));e=new M(n)}else e=T(t)?t:new O;return e},loadResources:function(e){this.resourcesPromise||(this.resourcesPromise=this.pdfManager.ensure(this,"resources"));return this.resourcesPromise.then(function(){return new L(this.resources.map,e,this.xref).load()}.bind(this))},getOperatorList:function(t,a,r,i){var n=this,s=this.pdfManager,o=s.ensure(this,"getContentStream",[]),c=this.loadResources(["ExtGState","ColorSpace","Pattern","Shading","XObject","Font"]),l=new N(s,this.xref,t,this.pageIndex,this.idFactory,this.fontCache,this.builtInCMapCache,this.evaluatorOptions),h=Promise.all([o,c]),f=h.then(function(e){var i=e[0],s=new U(r,t,n.pageIndex);t.send("StartRenderPage",{transparency:l.hasBlendModes(n.resources),pageIndex:n.pageIndex,intent:r});return l.getOperatorList(i,a,n.resources,s).then(function(){return s})}),d=s.ensure(this,"annotations");return Promise.all([f,d]).then(function(t){var n=t[0],s=t[1];if(0===s.length){n.flush(!0);return n}var o,c,h=[];for(o=0,c=s.length;o<c;o++)e(s[o],r)&&h.push(s[o].getOperatorList(l,a,i));return Promise.all(h).then(function(e){n.addOp(u.beginAnnotations,[]);for(o=0,c=e.length;o<c;o++)n.addOpList(e[o]);n.addOp(u.endAnnotations,[]);n.flush(!0);return n})})},extractTextContent:function(e,t,a,r){var i=this,n=this.pdfManager,s=n.ensure(this,"getContentStream",[]),o=this.loadResources(["ExtGState","XObject","Font"]);return Promise.all([s,o]).then(function(s){var o=s[0];return new N(n,i.xref,e,i.pageIndex,i.idFactory,i.fontCache,i.builtInCMapCache,i.evaluatorOptions).getTextContent(o,t,i.resources,null,a,r)})},getAnnotationsData:function(t){for(var a=this.annotations,r=[],i=0,n=a.length;i<n;++i)t&&!e(a[i],t)||r.push(a[i].data);return r},get annotations(){for(var e=[],t=this.getInheritedPageProp("Annots")||[],a=new j,r=0,i=t.length;r<i;++r){var n=t[r],s=a.create(this.xref,n,this.pdfManager,this.idFactory);s&&e.push(s)}return w(this,"annotations",e)}};return t}(),z=function(){function e(e,t){var a;T(t)?a=t:v(t)?a=new P(t):p("PDFDocument: Unknown argument type");g(a.length>0,"stream must have data");this.pdfManager=e;this.stream=a;this.xref=new D(a,e)}function t(e,t,a,r){var i=e.pos,n=e.end,s=[];i+a>n&&(a=n-i);for(var o=0;o<a;++o)s.push(String.fromCharCode(e.getByte()));var c=s.join("");e.pos=i;var l=r?c.lastIndexOf(t):c.indexOf(t);if(-1===l)return!1;e.pos+=l;return!0}var a={get entries(){return w(this,"entries",{Title:k,Author:k,Subject:k,Keywords:k,Creator:k,Producer:k,CreationDate:k,ModDate:k,Trapped:R})}};e.prototype={parse:function(e){this.setup(e);var t=this.catalog.catDict.get("Version");R(t)&&(this.pdfFormatVersion=t.name);try{this.acroForm=this.catalog.catDict.get("AcroForm");if(this.acroForm){this.xfa=this.acroForm.get("XFA");var a=this.acroForm.get("Fields");a&&b(a)&&0!==a.length||this.xfa||(this.acroForm=null)}}catch(e){if(e instanceof f)throw e;m("Something wrong with AcroForm entry");this.acroForm=null}},get linearization(){var e=null;if(this.stream.length)try{e=F.create(this.stream)}catch(e){if(e instanceof f)throw e;m(e)}return w(this,"linearization",e)},get startXRef(){var e=this.stream,a=0;if(this.linearization){e.reset();t(e,"endobj",1024)&&(a=e.pos+6)}else{for(var r=!1,i=e.end;!r&&i>0;){i-=1024-"startxref".length;i<0&&(i=0);e.pos=i;r=t(e,"startxref",1024,!0)}if(r){e.skip(9);var n;do{n=e.getByte()}while(A(n));for(var s="";n>=32&&n<=57;){s+=String.fromCharCode(n);n=e.getByte()}a=parseInt(s,10);isNaN(a)&&(a=0)}}return w(this,"startXRef",a)},get mainXRefEntriesOffset(){var e=0,t=this.linearization;t&&(e=t.mainXRefEntriesOffset);return w(this,"mainXRefEntriesOffset",e)},checkHeader:function(){var e=this.stream;e.reset();if(t(e,"%PDF-",1024)){e.moveStart();for(var a,r="";(a=e.getByte())>32&&!(r.length>=12);)r+=String.fromCharCode(a);this.pdfFormatVersion||(this.pdfFormatVersion=r.substring(5))}else;},parseStartXRef:function(){var e=this.startXRef;this.xref.setStartXRef(e)},setup:function(e){this.xref.parse(e);var t=this,a={createPage:function(e,a,r,i,n){return new _(t.pdfManager,t.xref,e,a,r,i,n)}};this.catalog=new E(this.pdfManager,this.xref,a)},get numPages(){var e=this.linearization,t=e?e.numPages:this.catalog.numPages;return w(this,"numPages",t)},get documentInfo(){var e,t={PDFFormatVersion:this.pdfFormatVersion,IsAcroFormPresent:!!this.acroForm,IsXFAPresent:!!this.xfa};try{e=this.xref.trailer.get("Info")}catch(e){if(e instanceof f)throw e;m("The document information dictionary is invalid.")}if(e){var r=a.entries;for(var i in r)if(e.has(i)){var n=e.get(i);r[i](n)?t[i]="string"!=typeof n?n:x(n):m('Bad value in document info for "'+i+'"')}}return w(this,"documentInfo",t)},get fingerprint(){var e,t=this.xref,a="",r=t.trailer.get("ID");if(r&&b(r)&&r[0]&&k(r[0])&&"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"!==r[0])e=C(r[0]);else{this.stream.ensureRange&&this.stream.ensureRange(0,Math.min(1024,this.stream.end));e=q(this.stream.bytes.subarray(0,1024),0,1024)}for(var i=0,n=e.length;i<n;i++){var s=e[i].toString(16);a+=1===s.length?"0"+s:s}return w(this,"fingerprint",a)},getPage:function(e){return this.catalog.getPage(e)},cleanup:function(){return this.catalog.cleanup()}};return e}();t.Page=_;t.PDFDocument=z},function(e,t,a){"use strict";var r=a(0),i=a(2),n=a(7),s=a(4),o=a(11),c=r.Util,l=r.bytesToString,h=r.error,u=i.Stream,f=n.getGlyphsUnicode,d=s.StandardEncoding,g=o.CFFParser,p=function(){function e(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]}function t(e,t){return e[t]<<8|e[t+1]}function a(a,r,i){var n,s,o,c=1===t(a,r+2)?e(a,r+8):e(a,r+16),l=t(a,r+c);if(4===l){t(a,r+c+2);var u=t(a,r+c+6)>>1;s=r+c+14;n=[];for(o=0;o<u;o++,s+=2)n[o]={end:t(a,s)};s+=2;for(o=0;o<u;o++,s+=2)n[o].start=t(a,s);for(o=0;o<u;o++,s+=2)n[o].idDelta=t(a,s);for(o=0;o<u;o++,s+=2){var f=t(a,s);if(0!==f){n[o].ids=[];for(var d=0,g=n[o].end-n[o].start+1;d<g;d++){n[o].ids[d]=t(a,s+f);f+=2}}}return n}if(12===l){e(a,r+c+4);var p=e(a,r+c+12);s=r+c+16;n=[];for(o=0;o<p;o++){n.push({start:e(a,s),end:e(a,s+4),idDelta:e(a,s+8)-e(a,s)});s+=12}return n}h("not supported cmap: "+l)}function r(e,t,a,r){var i={},n=new g(new u(e,t,a-t),i,r),s=n.parse();return{glyphs:s.charStrings.objects,subrs:s.topDict.privateDict&&s.topDict.privateDict.subrsIndex&&s.topDict.privateDict.subrsIndex.objects,gsubrs:s.globalSubrIndex&&s.globalSubrIndex.objects}}function i(e,t,a){var r,i;if(a){r=4;i=function(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]}}else{r=2;i=function(e,t){return e[t]<<9|e[t+1]<<1}}for(var n=[],s=i(t,0),o=r;o<t.length;o+=r){var c=i(t,o);n.push(e.subarray(s,c));s=c}return n}function n(e,t){for(var a=t.charCodeAt(0),r=0,i=0,n=e.length-1;i<n;){var s=i+n+1>>1;a<e[s].start?n=s-1:i=s}e[i].start<=a&&a<=e[i].end&&(r=e[i].idDelta+(e[i].ids?e[i].ids[a-e[i].start]:a)&65535);return{charCode:a,glyphId:r}}function s(e,t,a){function r(e,a,r,i){t.push({cmd:"quadraticCurveTo",args:[e,a,r,i]})}var i,n=0,o=(e[n]<<24|e[n+1]<<16)>>16,c=0,l=0;n+=10;if(o<0)do{i=e[n]<<8|e[n+1];var h=e[n+2]<<8|e[n+3];n+=4;var u,f;if(1&i){u=(e[n]<<24|e[n+1]<<16)>>16;f=(e[n+2]<<24|e[n+3]<<16)>>16;n+=4}else{u=e[n++];f=e[n++]}if(2&i){c=u;l=f}else{c=0;l=0}var d=1,g=1,p=0,m=0;if(8&i){d=g=(e[n]<<24|e[n+1]<<16)/1073741824;n+=2}else if(64&i){d=(e[n]<<24|e[n+1]<<16)/1073741824;g=(e[n+2]<<24|e[n+3]<<16)/1073741824;n+=4}else if(128&i){d=(e[n]<<24|e[n+1]<<16)/1073741824;p=(e[n+2]<<24|e[n+3]<<16)/1073741824;m=(e[n+4]<<24|e[n+5]<<16)/1073741824;g=(e[n+6]<<24|e[n+7]<<16)/1073741824;n+=8}var b=a.glyphs[h];if(b){t.push({cmd:"save"});t.push({cmd:"transform",args:[d,p,m,g,c,l]});s(b,t,a);t.push({cmd:"restore"})}}while(32&i);else{var v,y,k=[];for(v=0;v<o;v++){k.push(e[n]<<8|e[n+1]);n+=2}n+=2+(e[n]<<8|e[n+1]);for(var w=k[k.length-1]+1,C=[];C.length<w;){i=e[n++];var x=1;8&i&&(x+=e[n++]);for(;x-- >0;)C.push({flags:i})}for(v=0;v<w;v++){switch(18&C[v].flags){case 0:c+=(e[n]<<24|e[n+1]<<16)>>16;n+=2;break;case 2:c-=e[n++];break;case 18:c+=e[n++]}C[v].x=c}for(v=0;v<w;v++){switch(36&C[v].flags){case 0:l+=(e[n]<<24|e[n+1]<<16)>>16;n+=2;break;case 4:l-=e[n++];break;case 36:l+=e[n++]}C[v].y=l}var S=0;for(n=0;n<o;n++){var A=k[n],I=C.slice(S,A+1);if(1&I[0].flags)I.push(I[0]);else if(1&I[I.length-1].flags)I.unshift(I[I.length-1]);else{var B={flags:1,x:(I[0].x+I[I.length-1].x)/2,y:(I[0].y+I[I.length-1].y)/2};I.unshift(B);I.push(B)}!function(e,a){t.push({cmd:"moveTo",args:[e,a]})}(I[0].x,I[0].y);for(v=1,y=I.length;v<y;v++)if(1&I[v].flags)!function(e,a){t.push({cmd:"lineTo",args:[e,a]})}(I[v].x,I[v].y);else if(1&I[v+1].flags){r(I[v].x,I[v].y,I[v+1].x,I[v+1].y);v++}else r(I[v].x,I[v].y,(I[v].x+I[v+1].x)/2,(I[v].y+I[v+1].y)/2);S=A+1}}}function o(e,t,a){function r(e,a){t.push({cmd:"moveTo",args:[e,a]})}function i(e,a){t.push({cmd:"lineTo",args:[e,a]})}function s(e,a,r,i,n,s){t.push({cmd:"bezierCurveTo",args:[e,a,r,i,n,s]})}function c(e){for(var p=0;p<e.length;){var m,b,v,y,k,w,C,x,S,A=!1,I=e[p++];switch(I){case 1:case 3:g+=l.length>>1;A=!0;break;case 4:f+=l.pop();r(u,f);A=!0;break;case 5:for(;l.length>0;){u+=l.shift();f+=l.shift();i(u,f)}break;case 6:for(;l.length>0;){u+=l.shift();i(u,f);if(0===l.length)break;f+=l.shift();i(u,f)}break;case 7:for(;l.length>0;){f+=l.shift();i(u,f);if(0===l.length)break;u+=l.shift();i(u,f)}break;case 8:for(;l.length>0;){m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f)}break;case 10:x=l.pop()+a.subrsBias;S=a.subrs[x];S&&c(S);break;case 11:return;case 12:I=e[p++];switch(I){case 34:m=u+l.shift();b=m+l.shift();k=f+l.shift();u=b+l.shift();s(m,f,b,k,u,k);m=u+l.shift();b=m+l.shift();u=b+l.shift();s(m,k,b,f,u,f);break;case 35:m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f);m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f);l.pop();break;case 36:m=u+l.shift();k=f+l.shift();b=m+l.shift();w=k+l.shift();u=b+l.shift();s(m,k,b,w,u,w);m=u+l.shift();b=m+l.shift();C=w+l.shift();u=b+l.shift();s(m,w,b,C,u,f);break;case 37:var B=u,R=f;m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f);m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b;f=y;Math.abs(u-B)>Math.abs(f-R)?u+=l.shift():f+=l.shift();s(m,v,b,y,u,f);break;default:h("unknown operator: 12 "+I)}break;case 14:if(l.length>=4){var T=l.pop(),O=l.pop();f=l.pop();u=l.pop();t.push({cmd:"save"});t.push({cmd:"translate",args:[u,f]});var P=n(a.cmap,String.fromCharCode(a.glyphNameMap[d[T]]));o(a.glyphs[P.glyphId],t,a);t.push({cmd:"restore"});P=n(a.cmap,String.fromCharCode(a.glyphNameMap[d[O]]));o(a.glyphs[P.glyphId],t,a)}return;case 18:g+=l.length>>1;A=!0;break;case 19:case 20:g+=l.length>>1;p+=g+7>>3;A=!0;break;case 21:f+=l.pop();u+=l.pop();r(u,f);A=!0;break;case 22:u+=l.pop()
+;r(u,f);A=!0;break;case 23:g+=l.length>>1;A=!0;break;case 24:for(;l.length>2;){m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f)}u+=l.shift();f+=l.shift();i(u,f);break;case 25:for(;l.length>6;){u+=l.shift();f+=l.shift();i(u,f)}m=u+l.shift();v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+l.shift();s(m,v,b,y,u,f);break;case 26:l.length%2&&(u+=l.shift());for(;l.length>0;){m=u;v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b;f=y+l.shift();s(m,v,b,y,u,f)}break;case 27:l.length%2&&(f+=l.shift());for(;l.length>0;){m=u+l.shift();v=f;b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y;s(m,v,b,y,u,f)}break;case 28:l.push((e[p]<<24|e[p+1]<<16)>>16);p+=2;break;case 29:x=l.pop()+a.gsubrsBias;S=a.gsubrs[x];S&&c(S);break;case 30:for(;l.length>0;){m=u;v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+(1===l.length?l.shift():0);s(m,v,b,y,u,f);if(0===l.length)break;m=u+l.shift();v=f;b=m+l.shift();y=v+l.shift();f=y+l.shift();u=b+(1===l.length?l.shift():0);s(m,v,b,y,u,f)}break;case 31:for(;l.length>0;){m=u+l.shift();v=f;b=m+l.shift();y=v+l.shift();f=y+l.shift();u=b+(1===l.length?l.shift():0);s(m,v,b,y,u,f);if(0===l.length)break;m=u;v=f+l.shift();b=m+l.shift();y=v+l.shift();u=b+l.shift();f=y+(1===l.length?l.shift():0);s(m,v,b,y,u,f)}break;default:I<32&&h("unknown operator: "+I);if(I<247)l.push(I-139);else if(I<251)l.push(256*(I-247)+e[p++]+108);else if(I<255)l.push(256*-(I-251)-e[p++]-108);else{l.push((e[p]<<24|e[p+1]<<16|e[p+2]<<8|e[p+3])/65536);p+=4}}A&&(l.length=0)}}var l=[],u=0,f=0,g=0;c(e)}function p(e){this.compiledGlyphs=Object.create(null);this.compiledCharCodeToGlyphId=Object.create(null);this.fontMatrix=e}function m(e,t,a){a=a||[488e-6,0,0,488e-6,0,0];p.call(this,a);this.glyphs=e;this.cmap=t}function b(e,t,a,r){a=a||[.001,0,0,.001,0,0];p.call(this,a);this.glyphs=e.glyphs;this.gsubrs=e.gsubrs||[];this.subrs=e.subrs||[];this.cmap=t;this.glyphNameMap=r||f();this.gsubrsBias=this.gsubrs.length<1240?107:this.gsubrs.length<33900?1131:32768;this.subrsBias=this.subrs.length<1240?107:this.subrs.length<33900?1131:32768}p.prototype={getPathJs:function(e){var t=n(this.cmap,e),a=this.compiledGlyphs[t.glyphId];if(!a){a=this.compileGlyph(this.glyphs[t.glyphId]);this.compiledGlyphs[t.glyphId]=a}void 0===this.compiledCharCodeToGlyphId[t.charCode]&&(this.compiledCharCodeToGlyphId[t.charCode]=t.glyphId);return a},compileGlyph:function(e){if(!e||0===e.length||14===e[0])return"";var t=[];t.push({cmd:"save"});t.push({cmd:"transform",args:this.fontMatrix.slice()});t.push({cmd:"scale",args:["size","-size"]});this.compileGlyphImpl(e,t);t.push({cmd:"restore"});return t},compileGlyphImpl:function(){h("Children classes should implement this.")},hasBuiltPath:function(e){var t=n(this.cmap,e);return void 0!==this.compiledGlyphs[t.glyphId]&&void 0!==this.compiledCharCodeToGlyphId[t.charCode]}};c.inherit(m,p,{compileGlyphImpl:function(e,t){s(e,t,this)}});c.inherit(b,p,{compileGlyphImpl:function(e,t){o(e,t,this)}});return{create:function(n,s){for(var o,c,h,u,f,d,g=new Uint8Array(n.data),p=t(g,4),v=0,y=12;v<p;v++,y+=16){var k=l(g.subarray(y,y+4)),w=e(g,y+8),C=e(g,y+12);switch(k){case"cmap":o=a(g,w,w+C);break;case"glyf":c=g.subarray(w,w+C);break;case"loca":h=g.subarray(w,w+C);break;case"head":d=t(g,w+18);f=t(g,w+50);break;case"CFF ":u=r(g,w,w+C,s)}}if(c){var x=d?[1/d,0,0,1/d,0,0]:n.fontMatrix;return new m(i(c,h,f),o,x)}return new b(u,o,n.fontMatrix,n.glyphNameMap)}}}();t.FontRendererFactory=p},function(e,t,a){"use strict";function r(e){if(e.fontMatrix&&e.fontMatrix[0]!==b[0]){var t=.001/e.fontMatrix[0],a=e.widths;for(var r in a)a[r]*=t;e.defaultWidth*=t}}function i(e,t){if(!e.hasIncludedToUnicodeMap&&!(e.hasEncoding||t===e.defaultEncoding||e.toUnicode instanceof ge)){var a=[],r=E();for(var i in t){var n=t[i],s=W(n,r);-1!==s&&(a[i]=String.fromCharCode(s))}e.toUnicode.amend(a)}}function n(e,t){switch(e){case"Type1":return"Type1C"===t?v.TYPE1C:v.TYPE1;case"CIDFontType0":return"CIDFontType0C"===t?v.CIDFONTTYPE0C:v.CIDFONTTYPE0;case"OpenType":return v.OPENTYPE;case"TrueType":return v.TRUETYPE;case"CIDFontType2":return v.CIDFONTTYPE2;case"MMType1":return v.MMTYPE1;case"Type0":return v.TYPE0;default:return v.UNKNOWN}}function s(e,t){if(void 0!==t[e])return e;var a=W(e,t);if(-1!==a)for(var r in t)if(t[r]===a)return r;C("Unable to recover a standard glyph name for: "+e);return e}function o(e,t,a){var r,i,n,o=Object.create(null),c=!!(e.flags&he.Symbolic);if(e.baseEncodingName){n=j(e.baseEncodingName);for(i=0;i<n.length;i++){r=a.indexOf(n[i]);o[i]=r>=0?r:0}}else if(c)for(i in t)o[i]=t[i];else{n=F;for(i=0;i<n.length;i++){r=a.indexOf(n[i]);o[i]=r>=0?r:0}}var l,h=e.differences;if(h)for(i in h){var u=h[i];r=a.indexOf(u);if(-1===r){l||(l=E());var f=s(u,l);f!==u&&(r=a.indexOf(f))}o[i]=r>=0?r:0}return o}var c=a(0),l=(a(1),a(2)),h=a(7),u=a(25),f=a(4),d=a(17),g=a(18),p=a(35),m=a(11),b=c.FONT_IDENTITY_MATRIX,v=c.FontType,y=c.assert,k=c.bytesToString,w=c.error,C=c.info,x=c.isArray,S=c.isInt,A=c.isNum,I=c.readUint32,B=c.shadow,R=c.string32,T=c.warn,O=c.MissingDataException,P=c.isSpace,M=l.Stream,E=h.getGlyphsUnicode,L=h.getDingbatsGlyphsUnicode,D=u.FontRendererFactory,F=f.StandardEncoding,q=f.MacRomanEncoding,U=f.SymbolSetEncoding,N=f.ZapfDingbatsEncoding,j=f.getEncoding,_=d.getStdFontMap,z=d.getNonStdFontMap,H=d.getGlyphMapForStandardFonts,G=d.getSupplementalGlyphMapForArialBlack,X=g.getUnicodeRangeFor,V=g.mapSpecialUnicodeValues,W=g.getUnicodeForGlyph,K=p.Type1Parser,Y=m.CFFStandardStrings,J=m.CFFParser,Z=m.CFFCompiler,Q=m.CFF,$=m.CFFHeader,ee=m.CFFTopDict,te=m.CFFPrivateDict,ae=m.CFFStrings,re=m.CFFIndex,ie=m.CFFCharset,ne=57344,se=63743,oe=!1,ce=1e3,le=!1,he={FixedPitch:1,Serif:2,Symbolic:4,Script:8,Nonsymbolic:32,Italic:64,AllCap:65536,SmallCap:131072,ForceBold:262144},ue=[".notdef",".null","nonmarkingreturn","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","nonbreakingspace","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron","Lslash","lslash","Scaron","scaron","Zcaron","zcaron","brokenbar","Eth","eth","Yacute","yacute","Thorn","thorn","minus","multiply","onesuperior","twosuperior","threesuperior","onehalf","onequarter","threequarters","franc","Gbreve","gbreve","Idotaccent","Scedilla","scedilla","Cacute","cacute","Ccaron","ccaron","dcroat"],fe=function(){function e(e,t,a,r,i,n,s,o){this.fontChar=e;this.unicode=t;this.accent=a;this.width=r;this.vmetric=i;this.operatorListId=n;this.isSpace=s;this.isInFont=o}e.prototype.matchesForCache=function(e,t,a,r,i,n,s,o){return this.fontChar===e&&this.unicode===t&&this.accent===a&&this.width===r&&this.vmetric===i&&this.operatorListId===n&&this.isSpace===s&&this.isInFont===o};return e}(),de=function(){function e(e){this._map=e}e.prototype={get length(){return this._map.length},forEach:function(e){for(var t in this._map)e(t,this._map[t].charCodeAt(0))},has:function(e){return void 0!==this._map[e]},get:function(e){return this._map[e]},charCodeOf:function(e){return this._map.indexOf(e)},amend:function(e){for(var t in e)this._map[t]=e[t]}};return e}(),ge=function(){function e(e,t){this.firstChar=e;this.lastChar=t}e.prototype={get length(){return this.lastChar+1-this.firstChar},forEach:function(e){for(var t=this.firstChar,a=this.lastChar;t<=a;t++)e(t,t)},has:function(e){return this.firstChar<=e&&e<=this.lastChar},get:function(e){if(this.firstChar<=e&&e<=this.lastChar)return String.fromCharCode(e)},charCodeOf:function(e){return S(e)&&e>=this.firstChar&&e<=this.lastChar?e:-1},amend:function(e){w("Should not call amend()")}};return e}(),pe=function(){function e(e,t,a){e[t]=a>>8&255;e[t+1]=255&a}function t(e,t,a){e[t]=a>>24&255;e[t+1]=a>>16&255;e[t+2]=a>>8&255;e[t+3]=255&a}function a(e,t,a){var r,i;if(a instanceof Uint8Array)e.set(a,t);else if("string"==typeof a)for(r=0,i=a.length;r<i;r++)e[t++]=255&a.charCodeAt(r);else for(r=0,i=a.length;r<i;r++)e[t++]=255&a[r]}function r(e){this.sfnt=e;this.tables=Object.create(null)}r.getSearchParams=function(e,t){for(var a=1,r=0;(a^e)>a;){a<<=1;r++}var i=a*t;return{range:i,entry:r,rangeShift:t*e-i}};r.prototype={toArray:function(){var i=this.sfnt,n=this.tables,s=Object.keys(n);s.sort();var o,c,l,h,u,f=s.length,d=12+16*f,g=[d];for(o=0;o<f;o++){h=n[s[o]];d+=(h.length+3&-4)>>>0;g.push(d)}var p=new Uint8Array(d);for(o=0;o<f;o++){h=n[s[o]];a(p,g[o],h)}"true"===i&&(i=R(65536));p[0]=255&i.charCodeAt(0);p[1]=255&i.charCodeAt(1);p[2]=255&i.charCodeAt(2);p[3]=255&i.charCodeAt(3);e(p,4,f);var m=r.getSearchParams(f,16);e(p,6,m.range);e(p,8,m.entry);e(p,10,m.rangeShift);d=12;for(o=0;o<f;o++){u=s[o];p[d]=255&u.charCodeAt(0);p[d+1]=255&u.charCodeAt(1);p[d+2]=255&u.charCodeAt(2);p[d+3]=255&u.charCodeAt(3);var b=0;for(c=g[o],l=g[o+1];c<l;c+=4){b=b+I(p,c)>>>0}t(p,d+4,b);t(p,d+8,g[o]);t(p,d+12,n[u].length);d+=16}return p},addTable:function(e,t){if(e in this.tables)throw new Error("Table "+e+" already exists");this.tables[e]=t}};return r}(),me=new Int32Array([0,32,127,161,173,174,1536,1920,2208,4256,6016,6144,7168,7248,8192,8208,8209,8210,8232,8240,8287,8304,9676,9677,12288,12289,43616,43648,65520,65536]),be=function(){function e(e,t,a){var i,s,o;this.name=e;this.loadedName=a.loadedName;this.isType3Font=a.isType3Font;this.sizes=[];this.missingFile=!1;this.glyphCache=Object.create(null);this.isSerifFont=!!(a.flags&he.Serif);this.isSymbolicFont=!!(a.flags&he.Symbolic);this.isMonospace=!!(a.flags&he.FixedPitch);var c=a.type,l=a.subtype;this.type=c;this.fallbackName=this.isMonospace?"monospace":this.isSerifFont?"serif":"sans-serif";this.differences=a.differences;this.widths=a.widths;this.defaultWidth=a.defaultWidth;this.composite=a.composite;this.wideChars=a.wideChars;this.cMap=a.cMap;this.ascent=a.ascent/ce;this.descent=a.descent/ce;this.fontMatrix=a.fontMatrix;this.bbox=a.bbox;this.toUnicode=a.toUnicode;this.toFontChar=[];if("Type3"!==a.type){this.cidEncoding=a.cidEncoding;this.vertical=a.vertical;if(this.vertical){this.vmetrics=a.vmetrics;this.defaultVMetrics=a.defaultVMetrics}var g;if(t&&!t.isEmpty){"Type1C"===l&&("Type1"!==c&&"MMType1"!==c?h(t)?l="TrueType":c="Type1":u(t)&&(c=l="OpenType"));"CIDFontType0C"===l&&"CIDFontType0"!==c&&(c="CIDFontType0");"OpenType"===l&&(c="OpenType");"CIDFontType0"===c&&(f(t)?l="CIDFontType0":u(t)?c=l="OpenType":l="CIDFontType0C");var p;switch(c){case"MMType1":C("MMType1 font ("+e+"), falling back to Type1.");case"Type1":case"CIDFontType0":this.mimetype="font/opentype";var m="Type1C"===l||"CIDFontType0C"===l?new ke(t,a):new ye(e,t,a);r(a);p=this.convert(e,m,a);break;case"OpenType":case"TrueType":case"CIDFontType2":this.mimetype="font/opentype";p=this.checkAndRepair(e,t,a);if(this.isOpenType){r(a);c="OpenType"}break;default:w("Font "+c+" is not supported")}this.data=p;this.fontType=n(c,l);this.fontMatrix=a.fontMatrix;this.widths=a.widths;this.defaultWidth=a.defaultWidth;this.toUnicode=a.toUnicode;this.encoding=a.baseEncoding;this.seacMap=a.seacMap;this.loading=!0}else{t&&T('Font file is empty in "'+e+'" ('+this.loadedName+")");this.missingFile=!0;var b=e.replace(/[,_]/g,"-"),y=_(),k=z(),x=!!y[b]||!(!k[b]||!y[k[b]]);b=y[b]||k[b]||b;this.bold=-1!==b.search(/bold/gi);this.italic=-1!==b.search(/oblique/gi)||-1!==b.search(/italic/gi);this.black=-1!==e.search(/Black/g);this.remeasure=Object.keys(this.widths).length>0;if(x&&"CIDFontType2"===c&&0===a.cidEncoding.indexOf("Identity-")){var S=H(),A=[];for(i in S)A[+i]=S[i];if(/Arial-?Black/i.test(e)){var I=G();for(i in I)A[+i]=I[i]}this.toUnicode instanceof ge||this.toUnicode.forEach(function(e,t){A[+e]=t});this.toFontChar=A;this.toUnicode=new de(A)}else if(/Symbol/i.test(b))this.toFontChar=d(U,E(),a.differences);else if(/Dingbats/i.test(b)){/Wingdings/i.test(e)&&T("Non-embedded Wingdings font, falling back to ZapfDingbats.");this.toFontChar=d(N,L(),a.differences)}else if(x)this.toFontChar=d(a.defaultEncoding,E(),a.differences);else{g=E();this.toUnicode.forEach(function(e,t){if(!this.composite){s=a.differences[e]||a.defaultEncoding[e];o=W(s,g);-1!==o&&(t=o)}this.toFontChar[e]=t}.bind(this))}this.loadedName=b.split("-")[0];this.loading=!1;this.fontType=n(c,l)}}else{for(i=0;i<256;i++)this.toFontChar[i]=this.differences[i]||a.defaultEncoding[i];this.fontType=v.TYPE3}}function t(e,t){return(e<<8)+t}function a(e,t){var a=(e<<8)+t;return 32768&a?a-65536:a}function o(e,t,a,r){return(e<<24)+(t<<16)+(a<<8)+r}function c(e){return String.fromCharCode(e>>8&255,255&e)}function l(e){e=e>32767?32767:e<-32768?-32768:e;return String.fromCharCode(e>>8&255,255&e)}function h(e){var t=e.peekBytes(4);return 65536===I(t,0)}function u(e){var t=e.peekBytes(4);return"OTTO"===k(t)}function f(e){var t=e.peekBytes(2);return 37===t[0]&&33===t[1]||128===t[0]&&1===t[1]}function d(e,t,a){for(var r,i=[],n=0,s=e.length;n<s;n++){r=W(e[n],t);-1!==r&&(i[n]=r)}for(var o in a){r=W(a[o],t);-1!==r&&(i[+o]=r)}return i}function g(e){for(var t=0,a=me.length-1;t<a;){var r=t+a+1>>1;e<me[r]?a=r-1:t=r}return!(1&t)}function p(e,t){var a=t.toUnicode,r=!!(t.flags&he.Symbolic),i=t.toUnicode instanceof ge,n=Object.create(null),s=[],o=[],c=ne;for(var l in e){l|=0;var h=e[l],u=l,f=!1;if(!i&&a.has(l)){f=!0;var d=a.get(u);1===d.length&&(u=d.charCodeAt(0))}if((void 0!==o[u]||g(u)||r&&!f)&&c<=se)do{u=c++;if(oe&&61440===u){u=61472;c=u+1}}while(void 0!==o[u]&&c<=se);n[u]=h;s[l]=u;o[u]=!0}return{toFontChar:s,charCodeToGlyphId:n,nextAvailableFontCharCode:c}}function m(e,t){var a=[];for(var r in e)e[r]>=t||a.push({fontCharCode:0|r,glyphId:e[r]});a.sort(function(e,t){return e.fontCharCode-t.fontCharCode});for(var i=[],n=a.length,s=0;s<n;){var o=a[s].fontCharCode,c=[a[s].glyphId];++s;for(var l=o;s<n&&l+1===a[s].fontCharCode;){c.push(a[s].glyphId);++l;++s;if(65535===l)break}i.push([o,l,c])}return i}function x(e,t){var a,r,i,n,s=m(e,t),o=s[s.length-1][1]>65535?2:1,l="\0\0"+c(o)+"\0\0"+R(4+8*o);for(a=s.length-1;a>=0&&!(s[a][0]<=65535);--a);var h=a+1;s[a][0]<65535&&65535===s[a][1]&&(s[a][1]=65534);var u,f,d,g,p=s[a][1]<65535?1:0,b=h+p,v=pe.getSearchParams(b,2),y="",k="",w="",C="",x="",S=0;for(a=0,r=h;a<r;a++){u=s[a];f=u[0];d=u[1];y+=c(f);k+=c(d);g=u[2];var A=!0;for(i=1,n=g.length;i<n;++i)if(g[i]!==g[i-1]+1){A=!1;break}if(A){w+=c(g[0]-f&65535);C+=c(0)}else{var I=2*(b-a)+2*S;S+=d-f+1;w+=c(0);C+=c(I);for(i=0,n=g.length;i<n;++i)x+=c(g[i])}}if(p>0){k+="ÿÿ";y+="ÿÿ";w+="\0";C+="\0\0"}var B="\0\0"+c(2*b)+c(v.range)+c(v.entry)+c(v.rangeShift)+k+"\0\0"+y+w+C+x,T="",O="";if(o>1){l+="\0\0\n"+R(4+8*o+4+B.length);T="";for(a=0,r=s.length;a<r;a++){u=s[a];f=u[0];g=u[2];var P=g[0];for(i=1,n=g.length;i<n;++i)if(g[i]!==g[i-1]+1){d=u[0]+i-1;T+=R(f)+R(d)+R(P);f=d+1;P=g[i]}T+=R(f)+R(u[1])+R(P)}O="\0\f\0\0"+R(T.length+16)+"\0\0\0\0"+R(T.length/12)}return l+"\0"+c(B.length+4)+B+O+T}function S(e){var t=new M(e.data),a=t.getUint16();t.getBytes(60);var r=t.getUint16();if(a<4&&768&r)return!1;if(t.getUint16()>t.getUint16())return!1;t.getBytes(6);if(0===t.getUint16())return!1;e.data[8]=e.data[9]=0;return!0}function O(e,t,a){a=a||{unitsPerEm:0,yMax:0,yMin:0,ascent:0,descent:0};var r=0,i=0,n=0,s=0,o=null,l=0;if(t)for(var h in t){h|=0;(o>h||!o)&&(o=h);l<h&&(l=h);var u=X(h);u<32?r|=1<<u:u<64?i|=1<<u-32:u<96?n|=1<<u-64:u<123?s|=1<<u-96:w("Unicode ranges Bits > 123 are reserved for internal usage")}else{o=0;l=255}var f=e.bbox||[0,0,0,0],d=a.unitsPerEm||1/(e.fontMatrix||b)[0],g=e.ascentScaled?1:d/ce,p=a.ascent||Math.round(g*(e.ascent||f[3])),m=a.descent||Math.round(g*(e.descent||f[1]));m>0&&e.descent>0&&f[1]<0&&(m=-m);var v=a.yMax||p,y=-a.yMin||-m;return"\0$ô\0\0\0Š»\0\0\0ŒŠ»\0\0ß\x001\0\0\0\0"+String.fromCharCode(e.fixedPitch?9:0)+"\0\0\0\0\0\0"+R(r)+R(i)+R(n)+R(s)+"*21*"+c(e.italicAngle?1:0)+c(o||e.firstChar)+c(l||e.lastChar)+c(p)+c(m)+"\0d"+c(v)+c(y)+"\0\0\0\0\0\0\0\0"+c(e.xHeight)+c(e.capHeight)+c(0)+c(o||e.firstChar)+"\0"}function P(e){var t=Math.floor(e.italicAngle*Math.pow(2,16));return"\0\0\0"+R(t)+"\0\0\0\0"+R(e.fixedPitch)+"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}function K(e,t){t||(t=[[],[]]);var a,r,i,n,s,o=[t[0][0]||"Original licence",t[0][1]||e,t[0][2]||"Unknown",t[0][3]||"uniqueID",t[0][4]||e,t[0][5]||"Version 0.11",t[0][6]||"",t[0][7]||"Unknown",t[0][8]||"Unknown",t[0][9]||"Unknown"],l=[];for(a=0,r=o.length;a<r;a++){s=t[1][a]||o[a];var h=[];for(i=0,n=s.length;i<n;i++)h.push(c(s.charCodeAt(i)));l.push(h.join(""))}var u=[o,l],f=["\0","\0"],d=["\0\0","\0"],g=["\0\0","\t"],p=o.length*f.length,m="\0\0"+c(p)+c(12*p+6),b=0;for(a=0,r=f.length;a<r;a++){var v=u[a];for(i=0,n=v.length;i<n;i++){s=v[i];m+=f[a]+d[a]+g[a]+c(i)+c(s.length)+c(b);b+=s.length}}m+=o.join("")+l.join("");return m}e.getFontID=function(){var e=1;return function(){return String(e++)}}();e.prototype={name:null,font:null,mimetype:null,encoding:null,get renderer(){var e=D.create(this,le);return B(this,"renderer",e)},exportData:function(){var e={};for(var t in this)this.hasOwnProperty(t)&&(e[t]=this[t]);return e},checkAndRepair:function(e,i,n){function c(e,t,a,r,i,n){if(a-t<=12)return 0;var s=e.subarray(t,a),o=s[0]<<8|s[1];if(32768&o){r.set(s,i);return s.length}var c,l=10,h=0;for(c=0;c<o;c++){h=(s[l]<<8|s[l+1])+1;l+=2}var u=l,f=s[l]<<8|s[l+1];l+=2+f;var d=l,g=0;for(c=0;c<h;c++){var p=s[l++];192&p&&(s[l-1]=63&p);var m=(2&p?1:16&p?0:2)+(4&p?1:32&p?0:2);g+=m;if(8&p){var b=s[l++];c+=b;g+=b*m}}if(0===g)return 0;var v=l+g;if(v>s.length)return 0;if(!n&&f>0){r.set(s.subarray(0,u),i);r.set([0,0],i+u);r.set(s.subarray(d,v),i+u+2);v-=f;s.length-v>3&&(v=v+3&-4);return v}if(s.length-v>3){v=v+3&-4;r.set(s.subarray(0,v),i);return v}r.set(s,i);return s.length}function l(e,t){for(var a,r,i,n,s,o=e.data,c=0,l=0,h=0,f=[],g=[],p=[],m=t.tooComplexToFollowFunctions,b=!1,v=0,y=0,k=o.length;c<k;){var w=o[c++];if(64===w){r=o[c++];if(b||y)c+=r;else for(a=0;a<r;a++)f.push(o[c++])}else if(65===w){r=o[c++];if(b||y)c+=2*r;else for(a=0;a<r;a++){i=o[c++];f.push(i<<8|o[c++])}}else if(176==(248&w)){r=w-176+1;if(b||y)c+=r;else for(a=0;a<r;a++)f.push(o[c++])}else if(184==(248&w)){r=w-184+1;if(b||y)c+=2*r;else for(a=0;a<r;a++){i=o[c++];f.push(i<<8|o[c++])}}else if(43!==w||m)if(44!==w||m){if(45===w)if(b){b=!1;l=c}else{s=g.pop();if(!s){T("TT: ENDF bad stack");t.hintsValid=!1;return}n=p.pop();o=s.data;c=s.i;t.functionsStackDeltas[n]=f.length-s.stackTop}else if(137===w){if(b||y){T("TT: nested IDEFs not allowed");m=!0}b=!0;h=c}else if(88===w)++v;else if(27===w)y=v;else if(89===w){y===v&&(y=0);--v}else if(28===w&&!b&&!y){var C=f[f.length-1];C>0&&(c+=C-1)}}else{if(b||y){T("TT: nested FDEFs not allowed");m=!0}b=!0;h=c;n=f.pop();t.functionsDefined[n]={data:o,i:c}}else if(!b&&!y){n=f[f.length-1];t.functionsUsed[n]=!0;if(n in t.functionsStackDeltas)f.length+=t.functionsStackDeltas[n];else if(n in t.functionsDefined&&p.indexOf(n)<0){g.push({data:o,i:c,stackTop:f.length-1});p.push(n);s=t.functionsDefined[n];if(!s){T("TT: CALL non-existent function");t.hintsValid=!1;return}o=s.data;c=s.i}}if(!b&&!y){var x=w<=142?d[w]:w>=192&&w<=223?-1:w>=224?-2:0;if(w>=113&&w<=117){r=f.pop();isNaN(r)||(x=2*-r)}for(;x<0&&f.length>0;){f.pop();x++}for(;x>0;){f.push(NaN);x--}}}t.tooComplexToFollowFunctions=m;var S=[o];c>o.length&&S.push(new Uint8Array(c-o.length));if(h>l){T("TT: complementing a missing function tail");S.push(new Uint8Array([34,45]))}u(e,S)}function h(e,t){if(!e.tooComplexToFollowFunctions)if(e.functionsDefined.length>t){T("TT: more functions defined than expected");e.hintsValid=!1}else for(var a=0,r=e.functionsUsed.length;a<r;a++){if(a>t){T("TT: invalid function id: "+a);e.hintsValid=!1;return}if(e.functionsUsed[a]&&!e.functionsDefined[a]){T("TT: undefined function: "+a);e.hintsValid=!1;return}}}function u(e,t){if(t.length>1){var a,r,i=0;for(a=0,r=t.length;a<r;a++)i+=t[a].length;i=i+3&-4;var n=new Uint8Array(i),s=0;for(a=0,r=t.length;a<r;a++){n.set(t[a],s);s+=t[a].length}e.data=n;e.length=i}}function f(e,t,a){return!G[e]||(!!(!ee&&t>=0&&Q.has(t))||!!($&&a>=0&&A($[a])))}var d=[0,0,0,0,0,0,0,0,-2,-2,-2,-2,0,0,-2,-5,-1,-1,-1,-1,-1,-1,-1,-1,0,0,-1,0,-1,-1,-1,-1,1,-1,-999,0,1,0,-1,-2,0,-1,-2,-1,-1,0,-1,-1,0,0,-999,-999,-1,-1,-1,-1,-2,-999,-2,-2,-999,0,-2,-2,0,0,-2,0,-2,0,0,0,-2,-1,-1,1,1,0,0,-1,-1,-1,-1,-1,-1,-1,0,0,-1,0,-1,-1,0,-999,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,-2,-999,-999,-999,-999,-999,-1,-1,-2,-2,0,0,0,0,-1,-1,-999,-2,-2,0,0,-1,-2,-2,0,0,0,-1,-1,-1,-2];i=new M(new Uint8Array(i.getBytes()));var g,m,b=["OS/2","cmap","head","hhea","hmtx","maxp","name","post","loca","glyf","fpgm","prep","cvt ","CFF "],v=function(e){return{version:k(e.getBytes(4)),numTables:e.getUint16(),searchRange:e.getUint16(),entrySelector:e.getUint16(),rangeShift:e.getUint16()}}(i),I=v.numTables,B=Object.create(null);B["OS/2"]=null;B.cmap=null;B.head=null;B.hhea=null;B.hmtx=null;B.maxp=null;B.name=null;B.post=null;for(var R,L=0;L<I;L++){R=function(e){var t=k(e.getBytes(4)),a=e.getInt32()>>>0,r=e.getInt32()>>>0,i=e.getInt32()>>>0,n=e.pos;e.pos=e.start?e.start:0;e.skip(r);var s=e.getBytes(i);e.pos=n;if("head"===t){s[8]=s[9]=s[10]=s[11]=0;s[17]|=32}return{tag:t,checksum:a,length:i,offset:r,data:s}}(i);b.indexOf(R.tag)<0||0!==R.length&&(B[R.tag]=R)}var D=!B["CFF "];if(D){B.loca||w('Required "loca" table is not found');if(!B.glyf){T('Required "glyf" table is not found -- trying to recover.');B.glyf={tag:"glyf",data:new Uint8Array(0)}}this.isOpenType=!1}else{if("OTTO"===v.version&&!n.composite||!B.head||!B.hhea||!B.maxp||!B.post){m=new M(B["CFF "].data);g=new ke(m,n);r(n);return this.convert(e,g,n)}delete B.glyf;delete B.loca;delete B.fpgm;delete B.prep;delete B["cvt "];this.isOpenType=!0}B.maxp||w('Required "maxp" table is not found');i.pos=(i.start||0)+B.maxp.offset;var U=i.getInt32(),N=i.getUint16(),_=0;if(U>=65536&&B.maxp.length>=22){i.pos+=8;if(i.getUint16()>2){B.maxp.data[14]=0;B.maxp.data[15]=2}i.pos+=4;_=i.getUint16()}var z=!1;if("CIDFontType2"===n.type&&n.toUnicode&&n.toUnicode.get(0)>"\0"){z=!0;N++;B.maxp.data[4]=N>>8;B.maxp.data[5]=255&N}var H=function(e,t,a,r){var i={functionsDefined:[],functionsUsed:[],functionsStackDeltas:[],tooComplexToFollowFunctions:!1,hintsValid:!0};e&&l(e,i);t&&l(t,i);e&&h(i,r);if(a&&1&a.length){var n=new Uint8Array(a.length+1);n.set(a.data);a.data=n}return i.hintsValid}(B.fpgm,B.prep,B["cvt "],_);if(!H){delete B.fpgm;delete B.prep;delete B["cvt "]}!function(e,t,a,r){if(t){e.pos=(e.start?e.start:0)+t.offset;e.pos+=t.length-2;var i=e.getUint16();if(i>r){C("The numOfMetrics ("+i+") should not be greater than the numGlyphs ("+r+")");i=r;t.data[34]=(65280&i)>>8;t.data[35]=255&i}var n=r-i,s=n-(a.length-4*i>>1);if(s>0){var o=new Uint8Array(a.length+2*s);o.set(a.data);a.data=o}}else a&&(a.data=null)}(i,B.hhea,B.hmtx,N);B.head||w('Required "head" table is not found');!function(e,a,r){var i=e.data,n=o(i[0],i[1],i[2],i[3]);if(n>>16!=1){C("Attempting to fix invalid version in head table: "+n);i[0]=0;i[1]=1;i[2]=0;i[3]=0}var s=t(i[50],i[51]);if(s<0||s>1){C("Attempting to fix invalid indexToLocFormat in head table: "+s);var c=a+1;if(r===c<<1){i[50]=0;i[51]=0}else if(r===c<<2){i[50]=0;i[51]=1}else T("Could not fix indexToLocFormat: "+s)}}(B.head,N,D?B.loca.length:0);var G=Object.create(null);if(D){var X=t(B.head.data[50],B.head.data[51]);G=function(e,t,a,r,i,n){var s,o,l;if(r){s=4;o=function(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]};l=function(e,t,a){e[t]=a>>>24&255;e[t+1]=a>>16&255;e[t+2]=a>>8&255;e[t+3]=255&a}}else{s=2;o=function(e,t){return e[t]<<9|e[t+1]<<1};l=function(e,t,a){e[t]=a>>9&255;e[t+1]=a>>1&255}}var h=e.data,u=s*(1+a);if(h.length!==u){h=new Uint8Array(u);h.set(e.data.subarray(0,u));e.data=h}var f=t.data,d=f.length,g=new Uint8Array(d),p=o(h,0),m=0,b=Object.create(null);l(h,0,m);var v,y;for(v=0,y=s;v<a;v++,y+=s){var k=o(h,y);k>d&&(d+3&-4)===k&&(k=d);if(k>d){l(h,y,m);p=k}else{p===k&&(b[v]=!0);m+=c(f,p,k,g,m,i);l(h,y,m);p=k}}if(0===m){var w=new Uint8Array([0,1,0,0,0,0,0,0,0,0,0,0,0,0,49,0]);for(v=0,y=s;v<a;v++,y+=s)l(h,y,w.length);t.data=w;return b}if(n){var C=o(h,s);if(g.length>C+m)t.data=g.subarray(0,C+m);else{t.data=new Uint8Array(C+m);t.data.set(g.subarray(0,m))}t.data.set(g.subarray(0,C),m);l(e.data,h.length-s,m+C)}else t.data=g.subarray(0,m);return b}(B.loca,B.glyf,N,X,H,z)}B.hhea||w('Required "hhea" table is not found');if(0===B.hhea.data[10]&&0===B.hhea.data[11]){B.hhea.data[10]=255;B.hhea.data[11]=255}var V={unitsPerEm:t(B.head.data[18],B.head.data[19]),yMax:t(B.head.data[42],B.head.data[43]),yMin:a(B.head.data[38],B.head.data[39]),ascent:t(B.hhea.data[4],B.hhea.data[5]),descent:a(B.hhea.data[6],B.hhea.data[7])};this.ascent=V.ascent/V.unitsPerEm;this.descent=V.descent/V.unitsPerEm;if(B.post){(function(e,t,a){var r=(i.start?i.start:0)+e.offset;i.pos=r;var n=e.length,s=r+n,o=i.getInt32();i.getBytes(28);var c,l,h=!0;switch(o){case 65536:c=ue;break;case 131072:var u=i.getUint16();if(u!==a){h=!1;break}var f=[];for(l=0;l<u;++l){var d=i.getUint16();if(d>=32768){h=!1;break}f.push(d)}if(!h)break;for(var g=[],p=[];i.pos<s;){var m=i.getByte();p.length=m;for(l=0;l<m;++l)p[l]=String.fromCharCode(i.getByte());g.push(p.join(""))}c=[];for(l=0;l<u;++l){var b=f[l];b<258?c.push(ue[b]):c.push(g[b-258])}break;case 196608:break;default:T("Unknown/unsupported post table version "+o);h=!1;t.defaultEncoding&&(c=t.defaultEncoding)}t.glyphNames=c;return h})(B.post,n,N)||(B.post=null)}var W,Y=[],Q=n.toUnicode,$=n.widths,ee=Q instanceof ge||65536===Q.length;if(n.composite){var te=n.cidToGidMap||[],ae=0===te.length;n.cMap.forEach(function(e,t){y(t<=65535,"Max size of CID is 65,535");var a=-1;ae?a=t:void 0!==te[t]&&(a=te[t]);a>=0&&a<N&&f(a,e,t)&&(Y[e]=a)});!z||!ae&&Y[0]||(Y[0]=N-1)}else{var re=function(e,t,a,r){if(!e){T("No cmap table available.");return{platformId:-1,encodingId:-1,mappings:[],hasShortCmap:!1}}var i,n=(t.start?t.start:0)+e.offset;t.pos=n;t.getUint16();for(var s,o=t.getUint16(),c=!1,l=0;l<o;l++){var h=t.getUint16(),u=t.getUint16(),f=t.getInt32()>>>0,d=!1;if(0===h&&0===u)d=!0;else if(1===h&&0===u)d=!0;else if(3!==h||1!==u||!r&&s){if(a&&3===h&&0===u){d=!0;c=!0}}else{d=!0;a||(c=!0)}d&&(s={platformId:h,encodingId:u,offset:f});if(c)break}s&&(t.pos=n+s.offset);if(!s||-1===t.peekByte()){T("Could not find a preferred cmap table.");return{platformId:-1,encodingId:-1,mappings:[],hasShortCmap:!1}}var g=t.getUint16();t.getUint16();t.getUint16();var p,m,b=!1,v=[];if(0===g){for(p=0;p<256;p++){var y=t.getByte();y&&v.push({charCode:p,glyphId:y})}b=!0}else if(4===g){var k=t.getUint16()>>1;t.getBytes(6);var w,C=[];for(w=0;w<k;w++)C.push({end:t.getUint16()});t.getUint16();for(w=0;w<k;w++)C[w].start=t.getUint16();for(w=0;w<k;w++)C[w].delta=t.getUint16();var x=0;for(w=0;w<k;w++){i=C[w];var S=t.getUint16();if(S){var A=(S>>1)-(k-w);i.offsetIndex=A;x=Math.max(x,A+i.end-i.start+1)}else i.offsetIndex=-1}var I=[];for(p=0;p<x;p++)I.push(t.getUint16());for(w=0;w<k;w++){i=C[w];n=i.start;var B=i.end,R=i.delta;A=i.offsetIndex;for(p=n;p<=B;p++)if(65535!==p){m=A<0?p:I[A+p-n];m=m+R&65535;v.push({charCode:p,glyphId:m})}}}else{if(6!==g){T("cmap table has unsupported format: "+g);return{platformId:-1,encodingId:-1,mappings:[],hasShortCmap:!1}}var O=t.getUint16(),P=t.getUint16();for(p=0;p<P;p++){m=t.getUint16();var M=O+p;v.push({charCode:M,glyphId:m})}}v.sort(function(e,t){return e.charCode-t.charCode});for(l=1;l<v.length;l++)if(v[l-1].charCode===v[l].charCode){v.splice(l,1);l--}return{platformId:s.platformId,encodingId:s.encodingId,mappings:v,hasShortCmap:b}}(B.cmap,i,this.isSymbolicFont,n.hasEncoding),ie=re.platformId,ne=re.encodingId,se=re.mappings,oe=se.length;if(n.hasEncoding&&(3===ie&&1===ne||1===ie&&0===ne)||-1===ie&&-1===ne&&j(n.baseEncodingName)){var ce=[];"MacRomanEncoding"!==n.baseEncodingName&&"WinAnsiEncoding"!==n.baseEncodingName||(ce=j(n.baseEncodingName));var he=E();for(W=0;W<256;W++){var fe,de;fe=this.differences&&W in this.differences?this.differences[W]:W in ce&&""!==ce[W]?ce[W]:F[W];if(fe){de=s(fe,he);var me,be=!1;if(3===ie&&1===ne){me=he[de];be=!0}else 1===ie&&0===ne&&(me=q.indexOf(de));var ve=!1;for(L=0;L<oe;++L)if(se[L].charCode===me){var ye=be?W:me;if(f(se[L].glyphId,ye,-1)){Y[W]=se[L].glyphId;ve=!0;break}}if(!ve&&n.glyphNames){var we=n.glyphNames.indexOf(fe);-1===we&&de!==fe&&(we=n.glyphNames.indexOf(de));if(we>0&&f(we,-1,-1)){Y[W]=we;ve=!0}}ve||(Y[W]=0)}}}else if(0===ie&&0===ne)for(L=0;L<oe;++L)Y[se[L].charCode]=se[L].glyphId;else for(L=0;L<oe;++L){W=255&se[L].charCode;Y[W]=se[L].glyphId}}0===Y.length&&(Y[0]=0);var Ce=p(Y,n);this.toFontChar=Ce.toFontChar;B.cmap={tag:"cmap",data:x(Ce.charCodeToGlyphId,N)};B["OS/2"]&&S(B["OS/2"])||(B["OS/2"]={tag:"OS/2",data:O(n,Ce.charCodeToGlyphId,V)});B.post||(B.post={tag:"post",data:P(n)});if(!D)try{m=new M(B["CFF "].data);g=new J(m,n,le).parse();var xe=new Z(g);B["CFF "].data=xe.compile()}catch(e){T("Failed to compile font "+n.loadedName)}if(B.name){var Se=function(e){var t=(i.start?i.start:0)+e.offset;i.pos=t;var a=[[],[]],r=e.length,n=t+r;if(0!==i.getUint16()||r<6)return a;var s,o,c=i.getUint16(),l=i.getUint16(),h=[];for(s=0;s<c&&i.pos+12<=n;s++){var u={platform:i.getUint16(),encoding:i.getUint16(),language:i.getUint16(),name:i.getUint16(),length:i.getUint16(),offset:i.getUint16()};(1===u.platform&&0===u.encoding&&0===u.language||3===u.platform&&1===u.encoding&&1033===u.language)&&h.push(u)}for(s=0,o=h.length;s<o;s++){var f=h[s];if(!(f.length<=0)){var d=t+l+f.offset;if(!(d+f.length>n)){i.pos=d;var g=f.name;if(f.encoding){for(var p="",m=0,b=f.length;m<b;m+=2)p+=String.fromCharCode(i.getUint16());a[1][g]=p}else a[0][g]=k(i.getBytes(f.length))}}}return a}(B.name);B.name.data=K(e,Se)}else B.name={tag:"name",data:K(this.name)};var Ae=new pe(v.version);for(var Ie in B)Ae.addTable(Ie,B[Ie].data);return Ae.toArray()},convert:function(e,t,a){function r(e,t){for(var a in e)if(t===e[a])return 0|a;s.charCodeToGlyphId[s.nextAvailableFontCharCode]=t;return s.nextAvailableFontCharCode++}a.fixedPitch=!1;a.builtInEncoding&&i(a,a.builtInEncoding);var n=t.getGlyphMapping(a),s=p(n,a);this.toFontChar=s.toFontChar;var o=t.numGlyphs,h=t.seacs;if(le&&h&&h.length){var u=a.fontMatrix||b,f=t.getCharset(),d=Object.create(null);for(var g in h){g|=0;var m=h[g],v=F[m[2]],y=F[m[3]],k=f.indexOf(v),w=f.indexOf(y);if(!(k<0||w<0)){var C={x:m[0]*u[0]+m[1]*u[2]+u[4],y:m[0]*u[1]+m[1]*u[3]+u[5]},S=function(e,t){var a=null;for(var r in e)if(t===e[r]){a||(a=[]);a.push(0|r)}return a}(n,g);if(S)for(var A=0,I=S.length;A<I;A++){var B=S[A],R=s.charCodeToGlyphId,T=r(R,k),M=r(R,w);d[B]={
+baseFontCharCode:T,accentFontCharCode:M,accentOffset:C}}}}a.seacMap=d}var E=1/(a.fontMatrix||b)[0],L=new pe("OTTO");L.addTable("CFF ",t.data);L.addTable("OS/2",O(a,s.charCodeToGlyphId));L.addTable("cmap",x(s.charCodeToGlyphId,o));L.addTable("head","\0\0\0\0\0\0\0\0\0\0_<õ\0\0"+l(E)+"\0\0\0\0ž\v~'\0\0\0\0ž\v~'\0\0"+l(a.descent)+"ÿ"+l(a.ascent)+c(a.italicAngle?2:0)+"\0\0\0\0\0\0\0");L.addTable("hhea","\0\0\0"+l(a.ascent)+l(a.descent)+"\0\0ÿÿ\0\0\0\0\0\0"+l(a.capHeight)+l(Math.tan(a.italicAngle)*a.xHeight)+"\0\0\0\0\0\0\0\0\0\0\0\0"+c(o));L.addTable("hmtx",function(){for(var e=t.charstrings,a=t.cff?t.cff.widths:null,r="\0\0\0\0",i=1,n=o;i<n;i++){var s=0;if(e){var l=e[i-1];s="width"in l?l.width:0}else a&&(s=Math.ceil(a[i]||0));r+=c(s)+c(0)}return r}());L.addTable("maxp","\0\0P\0"+c(o));L.addTable("name",K(e));L.addTable("post",P(a));return L.toArray()},get spaceWidth(){if("_shadowWidth"in this)return this._shadowWidth;for(var e,t=["space","minus","one","i","I"],a=0,r=t.length;a<r;a++){var i=t[a];if(i in this.widths){e=this.widths[i];break}var n=E(),s=n[i],o=0;this.composite&&this.cMap.contains(s)&&(o=this.cMap.lookup(s));!o&&this.toUnicode&&(o=this.toUnicode.charCodeOf(s));o<=0&&(o=s);e=this.widths[o];if(e)break}e=e||this.defaultWidth;this._shadowWidth=e;return e},charToGlyph:function(e,t){var a,r,i,n=e;this.cMap&&this.cMap.contains(e)&&(n=this.cMap.lookup(e));r=this.widths[n];r=A(r)?r:this.defaultWidth;var s=this.vmetrics&&this.vmetrics[n],o=this.toUnicode.get(e)||e;"number"==typeof o&&(o=String.fromCharCode(o));var c=e in this.toFontChar;a=this.toFontChar[e]||e;this.missingFile&&(a=V(a));this.isType3Font&&(i=a);var l=null;if(this.seacMap&&this.seacMap[e]){c=!0;var h=this.seacMap[e];a=h.baseFontCharCode;l={fontChar:String.fromCharCode(h.accentFontCharCode),offset:h.accentOffset}}var u=String.fromCharCode(a),f=this.glyphCache[e];if(!f||!f.matchesForCache(u,o,l,r,s,i,t,c)){f=new fe(u,o,l,r,s,i,t,c);this.glyphCache[e]=f}return f},charsToGlyphs:function(e){var t,a,r,i=this.charsCache;if(i){t=i[e];if(t)return t}i||(i=this.charsCache=Object.create(null));t=[];var n,s=e,o=0;if(this.cMap)for(var c=Object.create(null);o<e.length;){this.cMap.readCharCode(e,o,c);r=c.charcode;var l=c.length;o+=l;var h=1===l&&32===e.charCodeAt(o-1);a=this.charToGlyph(r,h);t.push(a)}else for(o=0,n=e.length;o<n;++o){r=e.charCodeAt(o);a=this.charToGlyph(r,32===r);t.push(a)}return i[s]=t}};return e}(),ve=function(){function e(e){this.error=e;this.loadedName="g_font_error";this.loading=!1}e.prototype={charsToGlyphs:function(){return[]},exportData:function(){return{error:this.error}}};return e}(),ye=function(){function e(e,t,a){for(var r,i=e.length,n=t.length,s=i-n,o=a,c=!1;o<s;){r=0;for(;r<n&&e[o+r]===t[r];)r++;if(r>=n){o+=r;for(;o<i&&P(e[o]);)o++;c=!0;break}o++}return{found:c,length:o}}function t(t,a){var r,i,n,s=[101,101,120,101,99],o=t.pos;try{r=t.getBytes(a);i=r.length}catch(e){if(e instanceof O)throw e}if(i===a){n=e(r,s,a-2*s.length);if(n.found&&n.length===a)return{stream:new M(r),length:a}}T('Invalid "Length1" property in Type1 font -- trying to recover.');t.pos=o;for(var c;;){n=e(t.peekBytes(2048),s,0);if(0===n.length)break;t.pos+=n.length;if(n.found){c=t.pos-o;break}}t.pos=o;if(c)return{stream:new M(t.getBytes(c)),length:c};T('Unable to recover "Length1" property in Type1 font -- using as is.');return{stream:new M(t.getBytes(a)),length:a}}function a(e,t){var a=e.getBytes();return{stream:new M(a),length:a.length}}function r(e,r,i){var n=i.length1,s=i.length2,o=r.peekBytes(6),c=128===o[0]&&1===o[1];if(c){r.skip(6);n=o[5]<<24|o[4]<<16|o[3]<<8|o[2]}var l=t(r,n);n=l.length;new K(l.stream,!1,le).extractFontHeader(i);if(c){o=r.getBytes(6);s=o[5]<<24|o[4]<<16|o[3]<<8|o[2]}var h=a(r,s);s=h.length;var u=new K(h.stream,!0,le),f=u.extractFontProgram();for(var d in f.properties)i[d]=f.properties[d];var g=f.charstrings,p=this.getType2Charstrings(g),m=this.getType2Subrs(f.subrs);this.charstrings=g;this.data=this.wrap(e,p,this.charstrings,m,i);this.seacs=this.getSeacs(f.charstrings)}r.prototype={get numGlyphs(){return this.charstrings.length+1},getCharset:function(){for(var e=[".notdef"],t=this.charstrings,a=0;a<t.length;a++)e.push(t[a].glyphName);return e},getGlyphMapping:function(e){var t,a=this.charstrings,r=[".notdef"];for(t=0;t<a.length;t++)r.push(a[t].glyphName);var i=e.builtInEncoding;if(i){var n=Object.create(null);for(var s in i){t=r.indexOf(i[s]);t>=0&&(n[s]=t)}}return o(e,n,r)},getSeacs:function(e){var t,a,r=[];for(t=0,a=e.length;t<a;t++){var i=e[t];i.seac&&(r[t+1]=i.seac)}return r},getType2Charstrings:function(e){for(var t=[],a=0,r=e.length;a<r;a++)t.push(e[a].charstring);return t},getType2Subrs:function(e){var t=0,a=e.length;t=a<1133?107:a<33769?1131:32768;var r,i=[];for(r=0;r<t;r++)i.push([11]);for(r=0;r<a;r++)i.push(e[r]);return i},wrap:function(e,t,a,r,i){var n=new Q;n.header=new $(1,0,4,4);n.names=[e];var s=new ee;s.setByName("version",391);s.setByName("Notice",392);s.setByName("FullName",393);s.setByName("FamilyName",394);s.setByName("Weight",395);s.setByName("Encoding",null);s.setByName("FontMatrix",i.fontMatrix);s.setByName("FontBBox",i.bbox);s.setByName("charset",null);s.setByName("CharStrings",null);s.setByName("Private",null);n.topDict=s;var o=new ae;o.add("Version 0.11");o.add("See original notice");o.add(e);o.add(e);o.add("Medium");n.strings=o;n.globalSubrIndex=new re;var c,l,h=t.length,u=[0];for(c=0;c<h;c++){var f=Y.indexOf(a[c].glyphName);-1===f&&(f=0);u.push(f>>8&255,255&f)}n.charset=new ie(!1,0,[],u);var d=new re;d.add([139,14]);for(c=0;c<h;c++){var g=t[c];0!==g.length?d.add(g):d.add([139,14])}n.charStrings=d;var p=new te;p.setByName("Subrs",null);var m=["BlueValues","OtherBlues","FamilyBlues","FamilyOtherBlues","StemSnapH","StemSnapV","BlueShift","BlueFuzz","BlueScale","LanguageGroup","ExpansionFactor","ForceBold","StdHW","StdVW"];for(c=0,l=m.length;c<l;c++){var b=m[c];if(b in i.privateData){var v=i.privateData[b];if(x(v))for(var y=v.length-1;y>0;y--)v[y]-=v[y-1];p.setByName(b,v)}}n.topDict.privateDict=p;var k=new re;for(c=0,l=r.length;c<l;c++)k.add(r[c]);p.subrsIndex=k;return new Z(n).compile()}};return r}(),ke=function(){function e(e,t){this.properties=t;var a=new J(e,t,le);this.cff=a.parse();var r=new Z(this.cff);this.seacs=this.cff.seacs;try{this.data=r.compile()}catch(a){T("Failed to compile font "+t.loadedName);this.data=e}}e.prototype={get numGlyphs(){return this.cff.charStrings.count},getCharset:function(){return this.cff.charset.charset},getGlyphMapping:function(){var e,t,a=this.cff,r=this.properties,i=a.charset.charset;if(r.composite){e=Object.create(null);if(a.isCIDFont)for(t=0;t<i.length;t++){var n=i[t],s=r.cMap.charCodeOf(n);e[s]=t}else for(t=0;t<a.charStrings.count;t++)e[t]=t;return e}e=o(r,a.encoding?a.encoding.encoding:null,i);return e}};return e}();!function(){"undefined"!=typeof navigator&&/Windows/.test(navigator.userAgent)&&(le=!0)}();!function(){"undefined"!=typeof navigator&&/Windows.*Chrome/.test(navigator.userAgent)&&(oe=!0)}();t.SEAC_ANALYSIS_ENABLED=le;t.PRIVATE_USE_OFFSET_START=ne;t.PRIVATE_USE_OFFSET_END=se;t.ErrorFont=ve;t.Font=be;t.FontFlags=he;t.IdentityToUnicodeMap=ge;t.ProblematicCharRanges=me;t.ToUnicodeMap=de;t.getFontType=n},function(e,t,a){"use strict";var r=a(0),i=a(1),n=a(3),s=a(2),o=a(15),c=r.ImageKind,l=r.assert,h=r.error,u=r.info,f=r.isArray,d=r.warn,g=i.Name,p=i.isStream,m=n.ColorSpace,b=s.DecodeStream,v=s.JpegStream,y=o.JpxImage,k=function(){function e(e,t){return t&&t.canDecode(e)?t.decode(e):Promise.resolve(e)}function t(e,t,a,r){e=t+e*a;return e<0?0:e>r?r:e}function a(e,t,a,r,i,n){var s,o,c,l,h=i*n,u=t<=8?new Uint8Array(h):t<=16?new Uint16Array(h):new Uint32Array(h),f=a/i,d=r/n,g=0,p=new Uint16Array(i),m=a;for(s=0;s<i;s++)p[s]=Math.floor(s*f);for(s=0;s<n;s++){c=Math.floor(s*d)*m;for(o=0;o<i;o++){l=c+p[o];u[g++]=e[l]}}return u}function r(e,t,a,i,n,s,o){this.image=a;var c=a.dict;if(c.has("Filter")){var l=c.get("Filter").name;if("JPXDecode"===l){var f=new y;f.parseImageProperties(a.stream);a.stream.reset();a.bitsPerComponent=f.bitsPerComponent;a.numComps=f.componentsCount}else if("JBIG2Decode"===l){a.bitsPerComponent=1;a.numComps=1}}this.width=c.get("Width","W");this.height=c.get("Height","H");(this.width<1||this.height<1)&&h("Invalid image width: "+this.width+" or height: "+this.height);this.interpolate=c.get("Interpolate","I")||!1;this.imageMask=c.get("ImageMask","IM")||!1;this.matte=c.get("Matte")||!1;var b=a.bitsPerComponent;if(!b){b=c.get("BitsPerComponent","BPC");b||(this.imageMask?b=1:h("Bits per component missing in image: "+this.imageMask))}this.bpc=b;if(!this.imageMask){var v=c.get("ColorSpace","CS");if(!v){u("JPX images (which do not require color spaces)");switch(a.numComps){case 1:v=g.get("DeviceGray");break;case 3:v=g.get("DeviceRGB");break;case 4:v=g.get("DeviceCMYK");break;default:h("JPX images with "+this.numComps+" color components not supported.")}}this.colorSpace=m.parse(v,e,t);this.numComps=this.colorSpace.numComps}this.decode=c.getArray("Decode","D");this.needsDecode=!1;if(this.decode&&(this.colorSpace&&!this.colorSpace.isDefaultDecode(this.decode)||o&&!m.isDefaultDecode(this.decode,1))){this.needsDecode=!0;var k=(1<<b)-1;this.decodeCoefficients=[];this.decodeAddends=[];for(var w=0,C=0;w<this.decode.length;w+=2,++C){var x=this.decode[w],S=this.decode[w+1];this.decodeCoefficients[C]=S-x;this.decodeAddends[C]=k*x}}if(n)this.smask=new r(e,t,n,!1);else if(s)if(p(s)){var A=s.dict,I=A.get("ImageMask","IM");I?this.mask=new r(e,t,s,!1,null,null,!0):d("Ignoring /Mask in image without /ImageMask.")}else this.mask=s}r.buildImage=function(t,a,i,n,s,o){var c,l,h=e(n,o),u=n.dict.get("SMask"),g=n.dict.get("Mask");if(u){c=e(u,o);l=Promise.resolve(null)}else{c=Promise.resolve(null);if(g)if(p(g))l=e(g,o);else if(f(g))l=Promise.resolve(g);else{d("Unsupported mask format.");l=Promise.resolve(null)}else l=Promise.resolve(null)}return Promise.all([h,c,l]).then(function(e){var t=e[0],n=e[1],o=e[2];return new r(a,i,t,s,n,o)})};r.createMask=function(e,t,a,r,i){var n,s,o=(t+7>>3)*a,c=e.byteLength,l=o===c;if(!r||i&&!l)if(i){n=new Uint8Array(o);n.set(e);for(s=c;s<o;s++)n[s]=255}else{n=new Uint8Array(c);n.set(e)}else n=e;if(i)for(s=0;s<c;s++)n[s]=~n[s];return{data:n,width:t,height:a}};r.prototype={get drawWidth(){return Math.max(this.width,this.smask&&this.smask.width||0,this.mask&&this.mask.width||0)},get drawHeight(){return Math.max(this.height,this.smask&&this.smask.height||0,this.mask&&this.mask.height||0)},decodeBuffer:function(e){var a,r,i=this.bpc,n=this.numComps,s=this.decodeAddends,o=this.decodeCoefficients,c=(1<<i)-1;if(1!==i){var l=0;for(a=0,r=this.width*this.height;a<r;a++)for(var h=0;h<n;h++){e[l]=t(e[l],s[h],o[h],c);l++}}else for(a=0,r=e.length;a<r;a++)e[a]=+!e[a]},getComponents:function(e){var t=this.bpc;if(8===t)return e;var a,r,i=this.width,n=this.height,s=this.numComps,o=i*n*s,c=0,l=t<=8?new Uint8Array(o):t<=16?new Uint16Array(o):new Uint32Array(o),h=i*s,u=(1<<t)-1,f=0;if(1===t)for(var d,g,p,m=0;m<n;m++){g=f+(-8&h);p=f+h;for(;f<g;){r=e[c++];l[f]=r>>7&1;l[f+1]=r>>6&1;l[f+2]=r>>5&1;l[f+3]=r>>4&1;l[f+4]=r>>3&1;l[f+5]=r>>2&1;l[f+6]=r>>1&1;l[f+7]=1&r;f+=8}if(f<p){r=e[c++];d=128;for(;f<p;){l[f++]=+!!(r&d);d>>=1}}}else{var b=0;r=0;for(f=0,a=o;f<a;++f){if(f%h==0){r=0;b=0}for(;b<t;){r=r<<8|e[c++];b+=8}var v=b-t,y=r>>v;l[f]=y<0?0:y>u?u:y;r&=(1<<v)-1;b=v}}return l},fillOpacity:function(e,t,i,n,s){var o,c,l,u,d,g,p=this.smask,m=this.mask;if(p){c=p.width;l=p.height;o=new Uint8Array(c*l);p.fillGrayBuffer(o);c===t&&l===i||(o=a(o,p.bpc,c,l,t,i))}else if(m)if(m instanceof r){c=m.width;l=m.height;o=new Uint8Array(c*l);m.numComps=1;m.fillGrayBuffer(o);for(u=0,d=c*l;u<d;++u)o[u]=255-o[u];c===t&&l===i||(o=a(o,m.bpc,c,l,t,i))}else if(f(m)){o=new Uint8Array(t*i);var b=this.numComps;for(u=0,d=t*i;u<d;++u){var v=0,y=u*b;for(g=0;g<b;++g){var k=s[y+g],w=2*g;if(k<m[w]||k>m[w+1]){v=255;break}}o[u]=v}}else h("Unknown mask format.");if(o)for(u=0,g=3,d=t*n;u<d;++u,g+=4)e[g]=o[u];else for(u=0,g=3,d=t*n;u<d;++u,g+=4)e[g]=255},undoPreblend:function(e,t,a){var r=this.smask&&this.smask.matte;if(r)for(var i,n,s,o=this.colorSpace.getRgb(r,0),c=o[0],l=o[1],h=o[2],u=t*a*4,f=0;f<u;f+=4){var d=e[f+3];if(0!==d){var g=255/d;i=(e[f]-c)*g+c;n=(e[f+1]-l)*g+l;s=(e[f+2]-h)*g+h;e[f]=i<=0?0:i>=255?255:0|i;e[f+1]=n<=0?0:n>=255?255:0|n;e[f+2]=s<=0?0:s>=255?255:0|s}else{e[f]=255;e[f+1]=255;e[f+2]=255}}},createImageData:function(e){var t,a=this.drawWidth,r=this.drawHeight,i={width:a,height:r},n=this.numComps,s=this.width,o=this.height,h=this.bpc,u=s*n*h+7>>3;if(!e){var f;"DeviceGray"===this.colorSpace.name&&1===h?f=c.GRAYSCALE_1BPP:"DeviceRGB"!==this.colorSpace.name||8!==h||this.needsDecode||(f=c.RGB_24BPP);if(f&&!this.smask&&!this.mask&&a===s&&r===o){i.kind=f;t=this.getImageBytes(o*u);if(this.image instanceof b)i.data=t;else{var d=new Uint8Array(t.length);d.set(t);i.data=d}if(this.needsDecode){l(f===c.GRAYSCALE_1BPP);for(var g=i.data,p=0,m=g.length;p<m;p++)g[p]^=255}return i}if(this.image instanceof v&&!this.smask&&!this.mask&&("DeviceGray"===this.colorSpace.name||"DeviceRGB"===this.colorSpace.name||"DeviceCMYK"===this.colorSpace.name)){i.kind=c.RGB_24BPP;i.data=this.getImageBytes(o*u,a,r,!0);return i}}t=this.getImageBytes(o*u);var y,k,w=0|t.length/u*r/o,C=this.getComponents(t);if(e||this.smask||this.mask){i.kind=c.RGBA_32BPP;i.data=new Uint8Array(a*r*4);y=1;k=!0;this.fillOpacity(i.data,a,r,w,C)}else{i.kind=c.RGB_24BPP;i.data=new Uint8Array(a*r*3);y=0;k=!1}this.needsDecode&&this.decodeBuffer(C);this.colorSpace.fillRgb(i.data,s,o,a,r,w,h,C,y);k&&this.undoPreblend(i.data,a,w);return i},fillGrayBuffer:function(e){var t=this.numComps;1!==t&&h("Reading gray scale from a color image: "+t);var a,r,i=this.width,n=this.height,s=this.bpc,o=i*t*s+7>>3,c=this.getImageBytes(n*o),l=this.getComponents(c);if(1!==s){this.needsDecode&&this.decodeBuffer(l);r=i*n;var u=255/((1<<s)-1);for(a=0;a<r;++a)e[a]=u*l[a]|0}else{r=i*n;if(this.needsDecode)for(a=0;a<r;++a)e[a]=l[a]-1&255;else for(a=0;a<r;++a)e[a]=255&-l[a]}},getImageBytes:function(e,t,a,r){this.image.reset();this.image.drawWidth=t||this.width;this.image.drawHeight=a||this.height;this.image.forceRGB=!!r;return this.image.getBytes(e)}};return r}();t.PDFImage=k},function(e,t,a){"use strict";var r=a(0),i=a(10),n=r.error,s=r.log2,o=r.readInt8,c=r.readUint16,l=r.readUint32,h=r.shadow,u=i.ArithmeticDecoder,f=function(){function e(){}function t(e,t,a){this.data=e;this.start=t;this.end=a}function a(e,t,a){function r(e){for(var t=0,r=0;r<e;r++){var s=a.readBit(i,n);n=n<256?n<<1|s:511&(n<<1|s)|256;t=t<<1|s}return t>>>0}var i=e.getContexts(t),n=1,s=r(1),o=r(1)?r(1)?r(1)?r(1)?r(1)?r(32)+4436:r(12)+340:r(8)+84:r(6)+20:r(4)+4:r(2);return 0===s?o:o>0?-o:null}function r(e,t,a){for(var r=e.getContexts("IAID"),i=1,n=0;n<a;n++){i=i<<1|t.readBit(r,i)}return a<31?i&(1<<a)-1:2147483647&i}function i(e,t,a){var r,i,n,s,o,c,l,h=a.decoder,u=a.contextCache.getContexts("GB"),f=[];for(i=0;i<t;i++){o=f[i]=new Uint8Array(e);c=i<1?o:f[i-1];l=i<2?o:f[i-2];r=l[0]<<13|l[1]<<12|l[2]<<11|c[0]<<7|c[1]<<6|c[2]<<5|c[3]<<4;for(n=0;n<e;n++){o[n]=s=h.readBit(u,r);r=(31735&r)<<1|(n+3<e?l[n+3]<<11:0)|(n+4<e?c[n+4]<<4:0)|s}}return f}function f(e,t,a,r,s,o,c,l){e&&n("JBIG2 error: MMR encoding is not supported");if(0===r&&!o&&!s&&4===c.length&&3===c[0].x&&-1===c[0].y&&-3===c[1].x&&-1===c[1].y&&2===c[2].x&&-2===c[2].y&&-2===c[3].x&&-2===c[3].y)return i(t,a,l);var h=!!o,u=A[r].concat(c);u.sort(function(e,t){return e.y-t.y||e.x-t.x});var f,d,g=u.length,p=new Int8Array(g),m=new Int8Array(g),b=[],v=0,y=0,k=0,w=0;for(d=0;d<g;d++){p[d]=u[d].x;m[d]=u[d].y;y=Math.min(y,u[d].x);k=Math.max(k,u[d].x);w=Math.min(w,u[d].y);d<g-1&&u[d].y===u[d+1].y&&u[d].x===u[d+1].x-1?v|=1<<g-1-d:b.push(d)}var C=b.length,x=new Int8Array(C),S=new Int8Array(C),I=new Uint16Array(C);for(f=0;f<C;f++){d=b[f];x[f]=u[d].x;S[f]=u[d].y;I[f]=1<<g-1-d}for(var R,T,O,P,M,E=-y,L=-w,D=t-k,F=B[r],q=new Uint8Array(t),U=[],N=l.decoder,j=l.contextCache.getContexts("GB"),_=0,z=0,H=0;H<a;H++){if(s){_^=N.readBit(j,F);if(_){U.push(q);continue}}q=new Uint8Array(q);U.push(q);for(R=0;R<t;R++)if(h&&o[H][R])q[R]=0;else{if(R>=E&&R<D&&H>=L){z=z<<1&v;for(d=0;d<C;d++){T=H+S[d];O=R+x[d];P=U[T][O];if(P){P=I[d];z|=P}}}else{z=0;M=g-1;for(d=0;d<g;d++,M--){O=R+p[d];if(O>=0&&O<t){T=H+m[d];if(T>=0){P=U[T][O];P&&(z|=P<<M)}}}}var G=N.readBit(j,z);q[R]=G}}return U}function d(e,t,a,r,i,s,o,c,l){var h=I[a].coding;0===a&&(h=h.concat([c[0]]));var u,f=h.length,d=new Int32Array(f),g=new Int32Array(f);for(u=0;u<f;u++){d[u]=h[u].x;g[u]=h[u].y}var p=I[a].reference;0===a&&(p=p.concat([c[1]]));var m=p.length,b=new Int32Array(m),v=new Int32Array(m);for(u=0;u<m;u++){b[u]=p[u].x;v[u]=p[u].y}for(var y=r[0].length,k=r.length,w=R[a],C=[],x=l.decoder,S=l.contextCache.getContexts("GR"),A=0,B=0;B<t;B++){if(o){A^=x.readBit(S,w);A&&n("JBIG2 error: prediction is not supported")}var T=new Uint8Array(e);C.push(T);for(var O=0;O<e;O++){var P,M,E=0;for(u=0;u<f;u++){P=B+g[u];M=O+d[u];P<0||M<0||M>=e?E<<=1:E=E<<1|C[P][M]}for(u=0;u<m;u++){P=B+v[u]+s;M=O+b[u]+i;P<0||P>=k||M<0||M>=y?E<<=1:E=E<<1|r[P][M]}var L=x.readBit(S,E);T[O]=L}}return C}function g(e,t,i,o,c,l,h,u,g,m,b){e&&n("JBIG2 error: huffman is not supported");for(var v=[],y=0,k=s(i.length+o),w=b.decoder,C=b.contextCache;v.length<o;){y+=a(C,"IADH",w);for(var x=0;;){var S=a(C,"IADW",w);if(null===S)break;x+=S;var A;if(t){var I=a(C,"IAAI",w);if(I>1)A=p(e,t,x,y,0,I,1,i.concat(v),k,0,0,1,0,l,g,m,b);else{var B=r(C,w,k),R=a(C,"IARDX",w),T=a(C,"IARDY",w);A=d(x,y,g,B<i.length?i[B]:v[B-i.length],R,T,!1,m,b)}}else A=f(!1,x,y,h,!1,null,u,b);v.push(A)}}for(var O=[],P=[],M=!1,E=i.length+o;P.length<E;){for(var L=a(C,"IAEX",w);L--;)P.push(M);M=!M}for(var D=0,F=i.length;D<F;D++)P[D]&&O.push(i[D]);for(var q=0;q<o;D++,q++)P[D]&&O.push(v[q]);return O}function p(e,t,i,s,o,c,l,h,u,f,g,p,m,b,v,y,k){e&&n("JBIG2 error: huffman is not supported");var w,C,x=[];for(w=0;w<s;w++){C=new Uint8Array(i);if(o)for(var S=0;S<i;S++)C[S]=o;x.push(C)}var A=k.decoder,I=k.contextCache,B=-a(I,"IADT",A),R=0;w=0;for(;w<c;){B+=a(I,"IADT",A);R+=a(I,"IAFS",A);for(var T=R;;){var O=1===l?0:a(I,"IAIT",A),P=l*B+O,M=r(I,A,u),E=t&&a(I,"IARI",A),L=h[M],D=L[0].length,F=L.length;if(E){var q=a(I,"IARDW",A),U=a(I,"IARDH",A),N=a(I,"IARDX",A),j=a(I,"IARDY",A);D+=q;F+=U;L=d(D,F,v,L,(q>>1)+N,(U>>1)+j,!1,y,k)}var _,z,H,G=P-(1&p?0:F),X=T-(2&p?D:0);if(f){for(_=0;_<F;_++){C=x[X+_];if(C){H=L[_];var V=Math.min(i-G,D);switch(m){case 0:for(z=0;z<V;z++)C[G+z]|=H[z];break;case 2:for(z=0;z<V;z++)C[G+z]^=H[z];break;default:n("JBIG2 error: operator "+m+" is not supported")}}}T+=F-1}else{for(z=0;z<F;z++){C=x[G+z];if(C){H=L[z];switch(m){case 0:for(_=0;_<D;_++)C[X+_]|=H[_];break;case 2:for(_=0;_<D;_++)C[X+_]^=H[_];break;default:n("JBIG2 error: operator "+m+" is not supported")}}}T+=D-1}w++;var W=a(I,"IADS",A);if(null===W)break;T+=W+g}}return x}function m(e,t){var a={};a.number=l(e,t);var r=e[t+4],i=63&r;S[i]||n("JBIG2 error: invalid segment type: "+i);a.type=i;a.typeName=S[i];a.deferredNonRetain=!!(128&r);var s=!!(64&r),o=e[t+5],h=o>>5&7,u=[31&o],f=t+6;if(7===o){h=536870911&l(e,f-1);f+=3;var d=h+7>>3;u[0]=e[f++];for(;--d>0;)u.push(e[f++])}else 5!==o&&6!==o||n("JBIG2 error: invalid referred-to flags");a.retainBits=u;var g,p,m=a.number<=256?1:a.number<=65536?2:4,b=[];for(g=0;g<h;g++){var y=1===m?e[f]:2===m?c(e,f):l(e,f);b.push(y);f+=m}a.referredTo=b;if(s){a.pageAssociation=l(e,f);f+=4}else a.pageAssociation=e[f++];a.length=l(e,f);f+=4;if(4294967295===a.length)if(38===i){var k=v(e,f),w=e[f+T],C=!!(1&w),x=new Uint8Array(6);if(!C){x[0]=255;x[1]=172}x[2]=k.height>>>24&255;x[3]=k.height>>16&255;x[4]=k.height>>8&255;x[5]=255&k.height;for(g=f,p=e.length;g<p;g++){for(var A=0;A<6&&x[A]===e[g+A];)A++;if(6===A){a.length=g+6;break}}4294967295===a.length&&n("JBIG2 error: segment end was not found")}else n("JBIG2 error: invalid unknown segment length");a.headerEnd=f;return a}function b(e,t,a,r){for(var i=[],n=a;n<r;){var s=m(t,n);n=s.headerEnd;var o={header:s,data:t};if(!e.randomAccess){o.start=n;n+=s.length;o.end=n}i.push(o);if(51===s.type)break}if(e.randomAccess)for(var c=0,l=i.length;c<l;c++){i[c].start=n;n+=i[c].header.length;i[c].end=n}return i}function v(e,t){return{width:l(e,t),height:l(e,t+4),x:l(e,t+8),y:l(e,t+12),combinationOperator:7&e[t+16]}}function y(e,t){var a,r,i,s,h=e.header,u=e.data,f=e.start,d=e.end;switch(h.type){case 0:var g={},p=c(u,f);g.huffman=!!(1&p);g.refinement=!!(2&p);g.huffmanDHSelector=p>>2&3;g.huffmanDWSelector=p>>4&3;g.bitmapSizeSelector=p>>6&1;g.aggregationInstancesSelector=p>>7&1;g.bitmapCodingContextUsed=!!(256&p);g.bitmapCodingContextRetained=!!(512&p);g.template=p>>10&3;g.refinementTemplate=p>>12&1;f+=2;if(!g.huffman){s=0===g.template?4:1;r=[];for(i=0;i<s;i++){r.push({x:o(u,f),y:o(u,f+1)});f+=2}g.at=r}if(g.refinement&&!g.refinementTemplate){r=[];for(i=0;i<2;i++){r.push({x:o(u,f),y:o(u,f+1)});f+=2}g.refinementAt=r}g.numberOfExportedSymbols=l(u,f);f+=4;g.numberOfNewSymbols=l(u,f);f+=4;a=[g,h.number,h.referredTo,u,f,d];break;case 6:case 7:var m={};m.info=v(u,f);f+=T;var b=c(u,f);f+=2;m.huffman=!!(1&b);m.refinement=!!(2&b);m.stripSize=1<<(b>>2&3);m.referenceCorner=b>>4&3;m.transposed=!!(64&b);m.combinationOperator=b>>7&3;m.defaultPixelValue=b>>9&1;m.dsOffset=b<<17>>27;m.refinementTemplate=b>>15&1;if(m.huffman){var y=c(u,f);f+=2;m.huffmanFS=3&y;m.huffmanDS=y>>2&3;m.huffmanDT=y>>4&3;m.huffmanRefinementDW=y>>6&3;m.huffmanRefinementDH=y>>8&3;m.huffmanRefinementDX=y>>10&3;m.huffmanRefinementDY=y>>12&3;m.huffmanRefinementSizeSelector=!!(14&y)}if(m.refinement&&!m.refinementTemplate){r=[];for(i=0;i<2;i++){r.push({x:o(u,f),y:o(u,f+1)});f+=2}m.refinementAt=r}m.numberOfSymbolInstances=l(u,f);f+=4;m.huffman&&n("JBIG2 error: huffman is not supported");a=[m,h.referredTo,u,f,d];break;case 38:case 39:var k={};k.info=v(u,f);f+=T;var w=u[f++];k.mmr=!!(1&w);k.template=w>>1&3;k.prediction=!!(8&w);if(!k.mmr){s=0===k.template?4:1;r=[];for(i=0;i<s;i++){r.push({x:o(u,f),y:o(u,f+1)});f+=2}k.at=r}a=[k,u,f,d];break;case 48:var C={width:l(u,f),height:l(u,f+4),resolutionX:l(u,f+8),resolutionY:l(u,f+12)};4294967295===C.height&&delete C.height;var x=u[f+16];c(u,f+17);C.lossless=!!(1&x);C.refinement=!!(2&x);C.defaultPixelValue=x>>2&1;C.combinationOperator=x>>3&3;C.requiresBuffer=!!(32&x);C.combinationOperatorOverride=!!(64&x);a=[C];break;case 49:case 50:case 51:case 62:break;default:n("JBIG2 error: segment type "+h.typeName+"("+h.type+") is not implemented")}var S="on"+h.typeName;S in t&&t[S].apply(t,a)}function k(e,t){for(var a=0,r=e.length;a<r;a++)y(e[a],t)}function w(e){for(var t=new C,a=0,r=e.length;a<r;a++){var i=e[a];k(b({},i.data,i.start,i.end),t)}return t.buffer}function C(){}function x(){}e.prototype={getContexts:function(e){return e in this?this[e]:this[e]=new Int8Array(65536)}};t.prototype={get decoder(){var e=new u(this.data,this.start,this.end);return h(this,"decoder",e)},get contextCache(){var t=new e;return h(this,"contextCache",t)}};var S=["SymbolDictionary",null,null,null,"IntermediateTextRegion",null,"ImmediateTextRegion","ImmediateLosslessTextRegion",null,null,null,null,null,null,null,null,"patternDictionary",null,null,null,"IntermediateHalftoneRegion",null,"ImmediateHalftoneRegion","ImmediateLosslessHalftoneRegion",null,null,null,null,null,null,null,null,null,null,null,null,"IntermediateGenericRegion",null,"ImmediateGenericRegion","ImmediateLosslessGenericRegion","IntermediateGenericRefinementRegion",null,"ImmediateGenericRefinementRegion","ImmediateLosslessGenericRefinementRegion",null,null,null,null,"PageInformation","EndOfPage","EndOfStripe","EndOfFile","Profiles","Tables",null,null,null,null,null,null,null,null,"Extension"],A=[[{x:-1,y:-2},{x:0,y:-2},{x:1,y:-2},{x:-2,y:-1},{x:-1,y:-1},{x:0,y:-1},{x:1,y:-1},{x:2,y:-1},{x:-4,y:0},{x:-3,y:0},{x:-2,y:0},{x:-1,y:0}],[{x:-1,y:-2},{x:0,y:-2},{x:1,y:-2},{x:2,y:-2},{x:-2,y:-1},{x:-1,y:-1},{x:0,y:-1},{x:1,y:-1},{x:2,y:-1},{x:-3,y:0},{x:-2,y:0},{x:-1,y:0}],[{x:-1,y:-2},{x:0,y:-2},{x:1,y:-2},{x:-2,y:-1},{x:-1,y:-1},{x:0,y:-1},{x:1,y:-1},{x:-2,y:0},{x:-1,y:0}],[{x:-3,y:-1},{x:-2,y:-1},{x:-1,y:-1},{x:0,y:-1},{x:1,y:-1},{x:-4,y:0},{x:-3,y:0},{x:-2,y:0},{x:-1,y:0}]],I=[{coding:[{x:0,y:-1},{x:1,y:-1},{x:-1,y:0}],reference:[{x:0,y:-1},{x:1,y:-1},{x:-1,y:0},{x:0,y:0},{x:1,y:0},{x:-1,y:1},{x:0,y:1},{x:1,y:1}]},{coding:[{x:-1,y:-1},{x:0,y:-1},{x:1,y:-1},{x:-1,y:0}],reference:[{x:0,y:-1},{x:-1,y:0},{x:0,y:0},{x:1,y:0},{x:0,y:1},{x:1,y:1}]}],B=[39717,1941,229,405],R=[32,8],T=17;C.prototype={onPageInformation:function(e){this.currentPageInfo=e;var t=e.width+7>>3,a=new Uint8Array(t*e.height);if(e.defaultPixelValue)for(var r=0,i=a.length;r<i;r++)a[r]=255;this.buffer=a},drawBitmap:function(e,t){var a,r,i,s,o=this.currentPageInfo,c=e.width,l=e.height,h=o.width+7>>3,u=o.combinationOperatorOverride?e.combinationOperator:o.combinationOperator,f=this.buffer,d=128>>(7&e.x),g=e.y*h+(e.x>>3);switch(u){case 0:for(a=0;a<l;a++){i=d;s=g;for(r=0;r<c;r++){t[a][r]&&(f[s]|=i);i>>=1;if(!i){i=128;s++}}g+=h}break;case 2:for(a=0;a<l;a++){i=d;s=g;for(r=0;r<c;r++){t[a][r]&&(f[s]^=i);i>>=1;if(!i){i=128;s++}}g+=h}break;default:n("JBIG2 error: operator "+u+" is not supported")}},onImmediateGenericRegion:function(e,a,r,i){var n=e.info,s=new t(a,r,i),o=f(e.mmr,n.width,n.height,e.template,e.prediction,null,e.at,s);this.drawBitmap(n,o)},onImmediateLosslessGenericRegion:function(){this.onImmediateGenericRegion.apply(this,arguments)},onSymbolDictionary:function(e,a,r,i,s,o){e.huffman&&n("JBIG2 error: huffman is not supported");var c=this.symbols;c||(this.symbols=c={});for(var l=[],h=0,u=r.length;h<u;h++)l=l.concat(c[r[h]]);var f=new t(i,s,o);c[a]=g(e.huffman,e.refinement,l,e.numberOfNewSymbols,e.numberOfExportedSymbols,void 0,e.template,e.at,e.refinementTemplate,e.refinementAt,f)},onImmediateTextRegion:function(e,a,r,i,n){for(var o=e.info,c=this.symbols,l=[],h=0,u=a.length;h<u;h++)l=l.concat(c[a[h]]);var f=s(l.length),d=new t(r,i,n),g=p(e.huffman,e.refinement,o.width,o.height,e.defaultPixelValue,e.numberOfSymbolInstances,e.stripSize,l,f,e.transposed,e.dsOffset,e.referenceCorner,e.combinationOperator,void 0,e.refinementTemplate,e.refinementAt,d);this.drawBitmap(o,g)},onImmediateLosslessTextRegion:function(){this.onImmediateTextRegion.apply(this,arguments)}};x.prototype={parseChunks:function(e){return w(e)}};return x}();t.Jbig2Image=f},function(e,t,a){"use strict";var r=a(0),i=r.warn,n=r.error,s=function(){function e(){this.decodeTransform=null;this.colorTransform=-1}function t(e,t){for(var a,r,i=0,n=[],s=16;s>0&&!e[s-1];)s--;n.push({children:[],index:0});var o,c=n[0];for(a=0;a<s;a++){for(r=0;r<e[a];r++){c=n.pop();c.children[c.index]=t[i];for(;c.index>0;)c=n.pop();c.index++;n.push(c);for(;n.length<=a;){n.push(o={children:[],index:0});c.children[c.index]=o.children;c=o}i++}if(a+1<s){n.push(o={children:[],index:0});c.children[c.index]=o.children;c=o}}return n[0].children}function a(e,t,a){return 64*((e.blocksPerLine+1)*t+a)}function r(e,t,r,s,o,c,u,f,d){function g(){if(L>0){L--;return E>>L&1}E=e[t++];if(255===E){var a=e[t++];a&&n("JPEG error: unexpected marker "+(E<<8|a).toString(16))}L=7;return E>>>7}function p(e){for(var t=e;;){t=t[g()];if("number"==typeof t)return t;"object"!=typeof t&&n("JPEG error: invalid huffman sequence")}}function m(e){for(var t=0;e>0;){t=t<<1|g();e--}return t}function b(e){if(1===e)return 1===g()?1:-1;var t=m(e);return t>=1<<e-1?t:t+(-1<<e)+1}function v(e,t){var a=p(e.huffmanTableDC),r=0===a?0:b(a);e.blockData[t]=e.pred+=r;for(var i=1;i<64;){var n=p(e.huffmanTableAC),s=15&n,o=n>>4;if(0!==s){i+=o;var c=h[i];e.blockData[t+c]=b(s);i++}else{if(o<15)break;i+=16}}}function y(e,t){var a=p(e.huffmanTableDC),r=0===a?0:b(a)<<d;e.blockData[t]=e.pred+=r}function k(e,t){e.blockData[t]|=g()<<d}function w(e,t){if(D>0)D--;else for(var a=c,r=u;a<=r;){var i=p(e.huffmanTableAC),n=15&i,s=i>>4;if(0!==n){a+=s;var o=h[a];e.blockData[t+o]=b(n)*(1<<d);a++}else{if(s<15){D=m(s)+(1<<s)-1;break}a+=16}}}function C(e,t){for(var a,r,i=c,s=u,o=0;i<=s;){var l=h[i];switch(F){case 0:r=p(e.huffmanTableAC);a=15&r;o=r>>4;if(0===a)if(o<15){D=m(o)+(1<<o);F=4}else{o=16;F=1}else{1!==a&&n("JPEG error: invalid ACn encoding");x=b(a);F=o?2:3}continue;case 1:case 2:if(e.blockData[t+l])e.blockData[t+l]+=g()<<d;else{o--;0===o&&(F=2===F?3:0)}break;case 3:if(e.blockData[t+l])e.blockData[t+l]+=g()<<d;else{e.blockData[t+l]=x<<d;F=0}break;case 4:e.blockData[t+l]&&(e.blockData[t+l]+=g()<<d)}i++}if(4===F){D--;0===D&&(F=0)}}var x,S,A,I,B,R,T,O=r.mcusPerLine,P=r.progressive,M=t,E=0,L=0,D=0,F=0,q=s.length;T=P?0===c?0===f?y:k:0===f?w:C:v;var U,N,j=0;N=1===q?s[0].blocksPerLine*s[0].blocksPerColumn:O*r.mcusPerColumn;for(var _,z;j<N;){var H=o?Math.min(N-j,o):N;for(A=0;A<q;A++)s[A].pred=0;D=0;if(1===q){S=s[0];for(R=0;R<H;R++){!function(e,t,r){t(e,a(e,r/e.blocksPerLine|0,r%e.blocksPerLine))}(S,T,j);j++}}else for(R=0;R<H;R++){for(A=0;A<q;A++){S=s[A];_=S.h;z=S.v;for(I=0;I<z;I++)for(B=0;B<_;B++)!function(e,t,r,i,n){var s=r/O|0,o=r%O;t(e,a(e,s*e.v+i,o*e.h+n))}(S,T,j,I,B)}j++}L=0;U=l(e,t);if(U&&U.invalid){i("decodeScan - unexpected MCU data, next marker is: "+U.invalid);t=U.offset}var G=U&&U.marker;(!G||G<=65280)&&n("JPEG error: marker was not found");if(!(G>=65488&&G<=65495))break;t+=2}U=l(e,t);if(U&&U.invalid){i("decodeScan - unexpected Scan data, next marker is: "+U.invalid);t=U.offset}return t-M}function s(e,t,a){var r,i,s,o,c,l,h,y,k,w,C,x,S,A,I,B,R,T=e.quantizationTable,O=e.blockData;T||n("JPEG error: missing required Quantization Table.");for(var P=0;P<64;P+=8){k=O[t+P];w=O[t+P+1];C=O[t+P+2];x=O[t+P+3];S=O[t+P+4];A=O[t+P+5];I=O[t+P+6];B=O[t+P+7];k*=T[P];if(0!=(w|C|x|S|A|I|B)){w*=T[P+1];C*=T[P+2];x*=T[P+3];S*=T[P+4];A*=T[P+5];I*=T[P+6];B*=T[P+7];r=b*k+128>>8;i=b*S+128>>8;s=C;o=I;c=v*(w-B)+128>>8;y=v*(w+B)+128>>8;l=x<<4;h=A<<4;r=r+i+1>>1;i=r-i;R=s*m+o*p+128>>8;s=s*p-o*m+128>>8;o=R;c=c+h+1>>1;h=c-h;y=y+l+1>>1;l=y-l;r=r+o+1>>1;o=r-o;i=i+s+1>>1;s=i-s;R=c*g+y*d+2048>>12;c=c*d-y*g+2048>>12;y=R;R=l*f+h*u+2048>>12;l=l*u-h*f+2048>>12;h=R;a[P]=r+y;a[P+7]=r-y;a[P+1]=i+h;a[P+6]=i-h;a[P+2]=s+l;a[P+5]=s-l;a[P+3]=o+c;a[P+4]=o-c}else{R=b*k+512>>10;a[P]=R;a[P+1]=R;a[P+2]=R;a[P+3]=R;a[P+4]=R;a[P+5]=R;a[P+6]=R;a[P+7]=R}}for(var M=0;M<8;++M){k=a[M];w=a[M+8];C=a[M+16];x=a[M+24];S=a[M+32];A=a[M+40];I=a[M+48];B=a[M+56];if(0!=(w|C|x|S|A|I|B)){r=b*k+2048>>12;i=b*S+2048>>12;s=C;o=I;c=v*(w-B)+2048>>12;y=v*(w+B)+2048>>12;l=x;h=A;r=4112+(r+i+1>>1);i=r-i;R=s*m+o*p+2048>>12;s=s*p-o*m+2048>>12;o=R;c=c+h+1>>1;h=c-h;y=y+l+1>>1;l=y-l;r=r+o+1>>1;o=r-o;i=i+s+1>>1;s=i-s;R=c*g+y*d+2048>>12;c=c*d-y*g+2048>>12;y=R;R=l*f+h*u+2048>>12;l=l*u-h*f+2048>>12;h=R;k=r+y;B=r-y;w=i+h;I=i-h;C=s+l;A=s-l;x=o+c;S=o-c;k=k<16?0:k>=4080?255:k>>4;w=w<16?0:w>=4080?255:w>>4;C=C<16?0:C>=4080?255:C>>4;x=x<16?0:x>=4080?255:x>>4;S=S<16?0:S>=4080?255:S>>4;A=A<16?0:A>=4080?255:A>>4;I=I<16?0:I>=4080?255:I>>4;B=B<16?0:B>=4080?255:B>>4;O[t+M]=k;O[t+M+8]=w;O[t+M+16]=C;O[t+M+24]=x;O[t+M+32]=S;O[t+M+40]=A;O[t+M+48]=I;O[t+M+56]=B}else{R=b*k+8192>>14;R=R<-2040?0:R>=2024?255:R+2056>>4;O[t+M]=R;O[t+M+8]=R;O[t+M+16]=R;O[t+M+24]=R;O[t+M+32]=R;O[t+M+40]=R;O[t+M+48]=R;O[t+M+56]=R}}}function o(e,t){for(var r=t.blocksPerLine,i=t.blocksPerColumn,n=new Int16Array(64),o=0;o<i;o++)for(var c=0;c<r;c++){var l=a(t,o,c);s(t,l,n)}return t.blockData}function c(e){return e<=0?0:e>=255?255:e}function l(e,t,a){function r(t){return e[t]<<8|e[t+1]}var i=e.length-1,n=a<t?a:t;if(t>=i)return null;var s=r(t);if(s>=65472&&s<=65534)return{invalid:null,marker:s,offset:t};for(var o=r(n);!(o>=65472&&o<=65534);){if(++n>=i)return null;o=r(n)}return{invalid:s.toString(16),marker:o,offset:n}}var h=new Uint8Array([0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5,12,19,26,33,40,48,41,34,27,20,13,6,7,14,21,28,35,42,49,56,57,50,43,36,29,22,15,23,30,37,44,51,58,59,52,45,38,31,39,46,53,60,61,54,47,55,62,63]),u=4017,f=799,d=3406,g=2276,p=1567,m=3784,b=5793,v=2896;e.prototype={parse:function(e){function a(){var t=e[u]<<8|e[u+1];u+=2;return t}var s,c,u=0,f=null,d=null,g=[],p=[],m=[],b=a();65496!==b&&n("JPEG error: SOI not found");b=a();for(;65497!==b;){var v,y,k;switch(b){case 65504:case 65505:case 65506:case 65507:case 65508:case 65509:case 65510:case 65511:case 65512:case 65513:case 65514:
+case 65515:case 65516:case 65517:case 65518:case 65519:case 65534:var w=function(){var t=a(),r=u+t-2,n=l(e,r,u);if(n&&n.invalid){i("readDataBlock - incorrect length, next marker is: "+n.invalid);r=n.offset}var s=e.subarray(u,r);u+=s.length;return s}();65504===b&&74===w[0]&&70===w[1]&&73===w[2]&&70===w[3]&&0===w[4]&&(f={version:{major:w[5],minor:w[6]},densityUnits:w[7],xDensity:w[8]<<8|w[9],yDensity:w[10]<<8|w[11],thumbWidth:w[12],thumbHeight:w[13],thumbData:w.subarray(14,14+3*w[12]*w[13])});65518===b&&65===w[0]&&100===w[1]&&111===w[2]&&98===w[3]&&101===w[4]&&(d={version:w[5]<<8|w[6],flags0:w[7]<<8|w[8],flags1:w[9]<<8|w[10],transformCode:w[11]});break;case 65499:for(var C,x=a(),S=x+u-2;u<S;){var A=e[u++],I=new Uint16Array(64);if(A>>4==0)for(y=0;y<64;y++){C=h[y];I[C]=e[u++]}else if(A>>4==1)for(y=0;y<64;y++){C=h[y];I[C]=a()}else n("JPEG error: DQT - invalid table spec");g[15&A]=I}break;case 65472:case 65473:case 65474:s&&n("JPEG error: Only single frame JPEGs supported");a();s={};s.extended=65473===b;s.progressive=65474===b;s.precision=e[u++];s.scanLines=a();s.samplesPerLine=a();s.components=[];s.componentIds={};var B,R=e[u++],T=0,O=0;for(v=0;v<R;v++){B=e[u];var P=e[u+1]>>4,M=15&e[u+1];T<P&&(T=P);O<M&&(O=M);var E=e[u+2];k=s.components.push({h:P,v:M,quantizationId:E,quantizationTable:null});s.componentIds[B]=k-1;u+=3}s.maxH=T;s.maxV=O;!function(e){for(var t=Math.ceil(e.samplesPerLine/8/e.maxH),a=Math.ceil(e.scanLines/8/e.maxV),r=0;r<e.components.length;r++){N=e.components[r];var i=Math.ceil(Math.ceil(e.samplesPerLine/8)*N.h/e.maxH),n=Math.ceil(Math.ceil(e.scanLines/8)*N.v/e.maxV),s=t*N.h,o=a*N.v,c=64*o*(s+1);N.blockData=new Int16Array(c);N.blocksPerLine=i;N.blocksPerColumn=n}e.mcusPerLine=t;e.mcusPerColumn=a}(s);break;case 65476:var L=a();for(v=2;v<L;){var D=e[u++],F=new Uint8Array(16),q=0;for(y=0;y<16;y++,u++)q+=F[y]=e[u];var U=new Uint8Array(q);for(y=0;y<q;y++,u++)U[y]=e[u];v+=17+q;(D>>4==0?m:p)[15&D]=t(F,U)}break;case 65501:a();c=a();break;case 65498:a();var N,j=e[u++],_=[];for(v=0;v<j;v++){var z=s.componentIds[e[u++]];N=s.components[z];var H=e[u++];N.huffmanTableDC=m[H>>4];N.huffmanTableAC=p[15&H];_.push(N)}var G=e[u++],X=e[u++],V=e[u++],W=r(e,u,s,_,c,G,X,V>>4,15&V);u+=W;break;case 65535:255!==e[u]&&u--;break;default:if(255===e[u-3]&&e[u-2]>=192&&e[u-2]<=254){u-=3;break}n("JPEG error: unknown marker "+b.toString(16))}b=a()}this.width=s.samplesPerLine;this.height=s.scanLines;this.jfif=f;this.adobe=d;this.components=[];for(v=0;v<s.components.length;v++){N=s.components[v];var K=g[N.quantizationId];K&&(N.quantizationTable=K);this.components.push({output:o(s,N),scaleX:N.h/s.maxH,scaleY:N.v/s.maxV,blocksPerLine:N.blocksPerLine,blocksPerColumn:N.blocksPerColumn})}this.numComponents=this.components.length},_getLinearizedBlockData:function(e,t){var a,r,i,n,s,o,c,l,h,u,f,d=this.width/e,g=this.height/t,p=0,m=this.components.length,b=e*t*m,v=new Uint8Array(b),y=new Uint32Array(e);for(c=0;c<m;c++){a=this.components[c];r=a.scaleX*d;i=a.scaleY*g;p=c;f=a.output;n=a.blocksPerLine+1<<3;for(s=0;s<e;s++){l=0|s*r;y[s]=(4294967288&l)<<3|7&l}for(o=0;o<t;o++){l=0|o*i;u=n*(4294967288&l)|(7&l)<<3;for(s=0;s<e;s++){v[p]=f[u+y[s]];p+=m}}}var k=this.decodeTransform;if(k)for(c=0;c<b;)for(l=0,h=0;l<m;l++,c++,h+=2)v[c]=(v[c]*k[h]>>8)+k[h+1];return v},_isColorConversionNeeded:function(){return!(!this.adobe||!this.adobe.transformCode)||(3===this.numComponents?!(!this.adobe&&0===this.colorTransform):!this.adobe&&1===this.colorTransform)},_convertYccToRgb:function(e){for(var t,a,r,i=0,n=e.length;i<n;i+=3){t=e[i];a=e[i+1];r=e[i+2];e[i]=c(t-179.456+1.402*r);e[i+1]=c(t+135.459-.344*a-.714*r);e[i+2]=c(t-226.816+1.772*a)}return e},_convertYcckToRgb:function(e){for(var t,a,r,i,n=0,s=0,o=e.length;s<o;s+=4){t=e[s];a=e[s+1];r=e[s+2];i=e[s+3];var l=a*(-660635669420364e-19*a+.000437130475926232*r-54080610064599e-18*t+.00048449797120281*i-.154362151871126)-122.67195406894+r*(-.000957964378445773*r+.000817076911346625*t-.00477271405408747*i+1.53380253221734)+t*(.000961250184130688*t-.00266257332283933*i+.48357088451265)+i*(-.000336197177618394*i+.484791561490776),h=107.268039397724+a*(219927104525741e-19*a-.000640992018297945*r+.000659397001245577*t+.000426105652938837*i-.176491792462875)+r*(-.000778269941513683*r+.00130872261408275*t+.000770482631801132*i-.151051492775562)+t*(.00126935368114843*t-.00265090189010898*i+.25802910206845)+i*(-.000318913117588328*i-.213742400323665),u=a*(-.000570115196973677*a-263409051004589e-19*r+.0020741088115012*t-.00288260236853442*i+.814272968359295)-20.810012546947+r*(-153496057440975e-19*r-.000132689043961446*t+.000560833691242812*i-.195152027534049)+t*(.00174418132927582*t-.00255243321439347*i+.116935020465145)+i*(-.000343531996510555*i+.24165260232407);e[n++]=c(l);e[n++]=c(h);e[n++]=c(u)}return e},_convertYcckToCmyk:function(e){for(var t,a,r,i=0,n=e.length;i<n;i+=4){t=e[i];a=e[i+1];r=e[i+2];e[i]=c(434.456-t-1.402*r);e[i+1]=c(119.541-t+.344*a+.714*r);e[i+2]=c(481.816-t-1.772*a)}return e},_convertCmykToRgb:function(e){for(var t,a,r,i,n=0,s=-16581375,o=0,c=e.length;o<c;o+=4){t=e[o];a=e[o+1];r=e[o+2];i=e[o+3];var l=t*(-4.387332384609988*t+54.48615194189176*a+18.82290502165302*r+212.25662451639585*i-72734.4411664936)+a*(1.7149763477362134*a-5.6096736904047315*r-17.873870861415444*i-1401.7366389350734)+r*(-2.5217340131683033*r-21.248923337353073*i+4465.541406466231)-i*(21.86122147463605*i+48317.86113160301),h=t*(8.841041422036149*t+60.118027045597366*a+6.871425592049007*r+31.159100130055922*i-20220.756542821975)+a*(-15.310361306967817*a+17.575251261109482*r+131.35250912493976*i-48691.05921601825)+r*(4.444339102852739*r+9.8632861493405*i-6341.191035517494)-i*(20.737325471181034*i+47890.15695978492),u=t*(.8842522430003296*t+8.078677503112928*a+30.89978309703729*r-.23883238689178934*i-3616.812083916688)+a*(10.49593273432072*a+63.02378494754052*r+50.606957656360734*i-28620.90484698408)+r*(.03296041114873217*r+115.60384449646641*i-49363.43385999684)-i*(22.33816807309886*i+45932.16563550634);e[n++]=l>=0?255:l<=s?0:255+l*(1/255/255)|0;e[n++]=h>=0?255:h<=s?0:255+h*(1/255/255)|0;e[n++]=u>=0?255:u<=s?0:255+u*(1/255/255)|0}return e},getData:function(e,t,a){this.numComponents>4&&n("JPEG error: Unsupported color mode");var r=this._getLinearizedBlockData(e,t);if(1===this.numComponents&&a){for(var i=r.length,s=new Uint8Array(3*i),o=0,c=0;c<i;c++){var l=r[c];s[o++]=l;s[o++]=l;s[o++]=l}return s}if(3===this.numComponents&&this._isColorConversionNeeded())return this._convertYccToRgb(r);if(4===this.numComponents){if(this._isColorConversionNeeded())return a?this._convertYcckToRgb(r):this._convertYcckToCmyk(r);if(a)return this._convertCmykToRgb(r)}return r}};return e}();t.JpegImage=s},function(e,t,a){"use strict";var r=a(0),i=r.getLookupTableFactory,n=i(function(e){e.Courier=600;e["Courier-Bold"]=600;e["Courier-BoldOblique"]=600;e["Courier-Oblique"]=600;e.Helvetica=i(function(e){e.space=278;e.exclam=278;e.quotedbl=355;e.numbersign=556;e.dollar=556;e.percent=889;e.ampersand=667;e.quoteright=222;e.parenleft=333;e.parenright=333;e.asterisk=389;e.plus=584;e.comma=278;e.hyphen=333;e.period=278;e.slash=278;e.zero=556;e.one=556;e.two=556;e.three=556;e.four=556;e.five=556;e.six=556;e.seven=556;e.eight=556;e.nine=556;e.colon=278;e.semicolon=278;e.less=584;e.equal=584;e.greater=584;e.question=556;e.at=1015;e.A=667;e.B=667;e.C=722;e.D=722;e.E=667;e.F=611;e.G=778;e.H=722;e.I=278;e.J=500;e.K=667;e.L=556;e.M=833;e.N=722;e.O=778;e.P=667;e.Q=778;e.R=722;e.S=667;e.T=611;e.U=722;e.V=667;e.W=944;e.X=667;e.Y=667;e.Z=611;e.bracketleft=278;e.backslash=278;e.bracketright=278;e.asciicircum=469;e.underscore=556;e.quoteleft=222;e.a=556;e.b=556;e.c=500;e.d=556;e.e=556;e.f=278;e.g=556;e.h=556;e.i=222;e.j=222;e.k=500;e.l=222;e.m=833;e.n=556;e.o=556;e.p=556;e.q=556;e.r=333;e.s=500;e.t=278;e.u=556;e.v=500;e.w=722;e.x=500;e.y=500;e.z=500;e.braceleft=334;e.bar=260;e.braceright=334;e.asciitilde=584;e.exclamdown=333;e.cent=556;e.sterling=556;e.fraction=167;e.yen=556;e.florin=556;e.section=556;e.currency=556;e.quotesingle=191;e.quotedblleft=333;e.guillemotleft=556;e.guilsinglleft=333;e.guilsinglright=333;e.fi=500;e.fl=500;e.endash=556;e.dagger=556;e.daggerdbl=556;e.periodcentered=278;e.paragraph=537;e.bullet=350;e.quotesinglbase=222;e.quotedblbase=333;e.quotedblright=333;e.guillemotright=556;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=611;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=1e3;e.ordfeminine=370;e.Lslash=556;e.Oslash=778;e.OE=1e3;e.ordmasculine=365;e.ae=889;e.dotlessi=278;e.lslash=222;e.oslash=611;e.oe=944;e.germandbls=611;e.Idieresis=278;e.eacute=556;e.abreve=556;e.uhungarumlaut=556;e.ecaron=556;e.Ydieresis=667;e.divide=584;e.Yacute=667;e.Acircumflex=667;e.aacute=556;e.Ucircumflex=722;e.yacute=500;e.scommaaccent=500;e.ecircumflex=556;e.Uring=722;e.Udieresis=722;e.aogonek=556;e.Uacute=722;e.uogonek=556;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=737;e.Emacron=667;e.ccaron=500;e.aring=556;e.Ncommaaccent=722;e.lacute=222;e.agrave=556;e.Tcommaaccent=611;e.Cacute=722;e.atilde=556;e.Edotaccent=667;e.scaron=500;e.scedilla=500;e.iacute=278;e.lozenge=471;e.Rcaron=722;e.Gcommaaccent=778;e.ucircumflex=556;e.acircumflex=556;e.Amacron=667;e.rcaron=333;e.ccedilla=500;e.Zdotaccent=611;e.Thorn=667;e.Omacron=778;e.Racute=722;e.Sacute=667;e.dcaron=643;e.Umacron=722;e.uring=556;e.threesuperior=333;e.Ograve=778;e.Agrave=667;e.Abreve=667;e.multiply=584;e.uacute=556;e.Tcaron=611;e.partialdiff=476;e.ydieresis=500;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=556;e.edieresis=556;e.cacute=500;e.nacute=556;e.umacron=556;e.Ncaron=722;e.Iacute=278;e.plusminus=584;e.brokenbar=260;e.registered=737;e.Gbreve=778;e.Idotaccent=278;e.summation=600;e.Egrave=667;e.racute=333;e.omacron=556;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=722;e.lcommaaccent=222;e.tcaron=317;e.eogonek=556;e.Uogonek=722;e.Aacute=667;e.Adieresis=667;e.egrave=556;e.zacute=500;e.iogonek=222;e.Oacute=778;e.oacute=556;e.amacron=556;e.sacute=500;e.idieresis=278;e.Ocircumflex=778;e.Ugrave=722;e.Delta=612;e.thorn=556;e.twosuperior=333;e.Odieresis=778;e.mu=556;e.igrave=278;e.ohungarumlaut=556;e.Eogonek=667;e.dcroat=556;e.threequarters=834;e.Scedilla=667;e.lcaron=299;e.Kcommaaccent=667;e.Lacute=556;e.trademark=1e3;e.edotaccent=556;e.Igrave=278;e.Imacron=278;e.Lcaron=556;e.onehalf=834;e.lessequal=549;e.ocircumflex=556;e.ntilde=556;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=556;e.gbreve=556;e.onequarter=834;e.Scaron=667;e.Scommaaccent=667;e.Ohungarumlaut=778;e.degree=400;e.ograve=556;e.Ccaron=722;e.ugrave=556;e.radical=453;e.Dcaron=722;e.rcommaaccent=333;e.Ntilde=722;e.otilde=556;e.Rcommaaccent=722;e.Lcommaaccent=556;e.Atilde=667;e.Aogonek=667;e.Aring=667;e.Otilde=778;e.zdotaccent=500;e.Ecaron=667;e.Iogonek=278;e.kcommaaccent=500;e.minus=584;e.Icircumflex=278;e.ncaron=556;e.tcommaaccent=278;e.logicalnot=584;e.odieresis=556;e.udieresis=556;e.notequal=549;e.gcommaaccent=556;e.eth=556;e.zcaron=500;e.ncommaaccent=556;e.onesuperior=333;e.imacron=278;e.Euro=556});e["Helvetica-Bold"]=i(function(e){e.space=278;e.exclam=333;e.quotedbl=474;e.numbersign=556;e.dollar=556;e.percent=889;e.ampersand=722;e.quoteright=278;e.parenleft=333;e.parenright=333;e.asterisk=389;e.plus=584;e.comma=278;e.hyphen=333;e.period=278;e.slash=278;e.zero=556;e.one=556;e.two=556;e.three=556;e.four=556;e.five=556;e.six=556;e.seven=556;e.eight=556;e.nine=556;e.colon=333;e.semicolon=333;e.less=584;e.equal=584;e.greater=584;e.question=611;e.at=975;e.A=722;e.B=722;e.C=722;e.D=722;e.E=667;e.F=611;e.G=778;e.H=722;e.I=278;e.J=556;e.K=722;e.L=611;e.M=833;e.N=722;e.O=778;e.P=667;e.Q=778;e.R=722;e.S=667;e.T=611;e.U=722;e.V=667;e.W=944;e.X=667;e.Y=667;e.Z=611;e.bracketleft=333;e.backslash=278;e.bracketright=333;e.asciicircum=584;e.underscore=556;e.quoteleft=278;e.a=556;e.b=611;e.c=556;e.d=611;e.e=556;e.f=333;e.g=611;e.h=611;e.i=278;e.j=278;e.k=556;e.l=278;e.m=889;e.n=611;e.o=611;e.p=611;e.q=611;e.r=389;e.s=556;e.t=333;e.u=611;e.v=556;e.w=778;e.x=556;e.y=556;e.z=500;e.braceleft=389;e.bar=280;e.braceright=389;e.asciitilde=584;e.exclamdown=333;e.cent=556;e.sterling=556;e.fraction=167;e.yen=556;e.florin=556;e.section=556;e.currency=556;e.quotesingle=238;e.quotedblleft=500;e.guillemotleft=556;e.guilsinglleft=333;e.guilsinglright=333;e.fi=611;e.fl=611;e.endash=556;e.dagger=556;e.daggerdbl=556;e.periodcentered=278;e.paragraph=556;e.bullet=350;e.quotesinglbase=278;e.quotedblbase=500;e.quotedblright=500;e.guillemotright=556;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=611;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=1e3;e.ordfeminine=370;e.Lslash=611;e.Oslash=778;e.OE=1e3;e.ordmasculine=365;e.ae=889;e.dotlessi=278;e.lslash=278;e.oslash=611;e.oe=944;e.germandbls=611;e.Idieresis=278;e.eacute=556;e.abreve=556;e.uhungarumlaut=611;e.ecaron=556;e.Ydieresis=667;e.divide=584;e.Yacute=667;e.Acircumflex=722;e.aacute=556;e.Ucircumflex=722;e.yacute=556;e.scommaaccent=556;e.ecircumflex=556;e.Uring=722;e.Udieresis=722;e.aogonek=556;e.Uacute=722;e.uogonek=611;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=737;e.Emacron=667;e.ccaron=556;e.aring=556;e.Ncommaaccent=722;e.lacute=278;e.agrave=556;e.Tcommaaccent=611;e.Cacute=722;e.atilde=556;e.Edotaccent=667;e.scaron=556;e.scedilla=556;e.iacute=278;e.lozenge=494;e.Rcaron=722;e.Gcommaaccent=778;e.ucircumflex=611;e.acircumflex=556;e.Amacron=722;e.rcaron=389;e.ccedilla=556;e.Zdotaccent=611;e.Thorn=667;e.Omacron=778;e.Racute=722;e.Sacute=667;e.dcaron=743;e.Umacron=722;e.uring=611;e.threesuperior=333;e.Ograve=778;e.Agrave=722;e.Abreve=722;e.multiply=584;e.uacute=611;e.Tcaron=611;e.partialdiff=494;e.ydieresis=556;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=556;e.edieresis=556;e.cacute=556;e.nacute=611;e.umacron=611;e.Ncaron=722;e.Iacute=278;e.plusminus=584;e.brokenbar=280;e.registered=737;e.Gbreve=778;e.Idotaccent=278;e.summation=600;e.Egrave=667;e.racute=389;e.omacron=611;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=722;e.lcommaaccent=278;e.tcaron=389;e.eogonek=556;e.Uogonek=722;e.Aacute=722;e.Adieresis=722;e.egrave=556;e.zacute=500;e.iogonek=278;e.Oacute=778;e.oacute=611;e.amacron=556;e.sacute=556;e.idieresis=278;e.Ocircumflex=778;e.Ugrave=722;e.Delta=612;e.thorn=611;e.twosuperior=333;e.Odieresis=778;e.mu=611;e.igrave=278;e.ohungarumlaut=611;e.Eogonek=667;e.dcroat=611;e.threequarters=834;e.Scedilla=667;e.lcaron=400;e.Kcommaaccent=722;e.Lacute=611;e.trademark=1e3;e.edotaccent=556;e.Igrave=278;e.Imacron=278;e.Lcaron=611;e.onehalf=834;e.lessequal=549;e.ocircumflex=611;e.ntilde=611;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=556;e.gbreve=611;e.onequarter=834;e.Scaron=667;e.Scommaaccent=667;e.Ohungarumlaut=778;e.degree=400;e.ograve=611;e.Ccaron=722;e.ugrave=611;e.radical=549;e.Dcaron=722;e.rcommaaccent=389;e.Ntilde=722;e.otilde=611;e.Rcommaaccent=722;e.Lcommaaccent=611;e.Atilde=722;e.Aogonek=722;e.Aring=722;e.Otilde=778;e.zdotaccent=500;e.Ecaron=667;e.Iogonek=278;e.kcommaaccent=556;e.minus=584;e.Icircumflex=278;e.ncaron=611;e.tcommaaccent=333;e.logicalnot=584;e.odieresis=611;e.udieresis=611;e.notequal=549;e.gcommaaccent=611;e.eth=611;e.zcaron=500;e.ncommaaccent=611;e.onesuperior=333;e.imacron=278;e.Euro=556});e["Helvetica-BoldOblique"]=i(function(e){e.space=278;e.exclam=333;e.quotedbl=474;e.numbersign=556;e.dollar=556;e.percent=889;e.ampersand=722;e.quoteright=278;e.parenleft=333;e.parenright=333;e.asterisk=389;e.plus=584;e.comma=278;e.hyphen=333;e.period=278;e.slash=278;e.zero=556;e.one=556;e.two=556;e.three=556;e.four=556;e.five=556;e.six=556;e.seven=556;e.eight=556;e.nine=556;e.colon=333;e.semicolon=333;e.less=584;e.equal=584;e.greater=584;e.question=611;e.at=975;e.A=722;e.B=722;e.C=722;e.D=722;e.E=667;e.F=611;e.G=778;e.H=722;e.I=278;e.J=556;e.K=722;e.L=611;e.M=833;e.N=722;e.O=778;e.P=667;e.Q=778;e.R=722;e.S=667;e.T=611;e.U=722;e.V=667;e.W=944;e.X=667;e.Y=667;e.Z=611;e.bracketleft=333;e.backslash=278;e.bracketright=333;e.asciicircum=584;e.underscore=556;e.quoteleft=278;e.a=556;e.b=611;e.c=556;e.d=611;e.e=556;e.f=333;e.g=611;e.h=611;e.i=278;e.j=278;e.k=556;e.l=278;e.m=889;e.n=611;e.o=611;e.p=611;e.q=611;e.r=389;e.s=556;e.t=333;e.u=611;e.v=556;e.w=778;e.x=556;e.y=556;e.z=500;e.braceleft=389;e.bar=280;e.braceright=389;e.asciitilde=584;e.exclamdown=333;e.cent=556;e.sterling=556;e.fraction=167;e.yen=556;e.florin=556;e.section=556;e.currency=556;e.quotesingle=238;e.quotedblleft=500;e.guillemotleft=556;e.guilsinglleft=333;e.guilsinglright=333;e.fi=611;e.fl=611;e.endash=556;e.dagger=556;e.daggerdbl=556;e.periodcentered=278;e.paragraph=556;e.bullet=350;e.quotesinglbase=278;e.quotedblbase=500;e.quotedblright=500;e.guillemotright=556;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=611;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=1e3;e.ordfeminine=370;e.Lslash=611;e.Oslash=778;e.OE=1e3;e.ordmasculine=365;e.ae=889;e.dotlessi=278;e.lslash=278;e.oslash=611;e.oe=944;e.germandbls=611;e.Idieresis=278;e.eacute=556;e.abreve=556;e.uhungarumlaut=611;e.ecaron=556;e.Ydieresis=667;e.divide=584;e.Yacute=667;e.Acircumflex=722;e.aacute=556;e.Ucircumflex=722;e.yacute=556;e.scommaaccent=556;e.ecircumflex=556;e.Uring=722;e.Udieresis=722;e.aogonek=556;e.Uacute=722;e.uogonek=611;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=737;e.Emacron=667;e.ccaron=556;e.aring=556;e.Ncommaaccent=722;e.lacute=278;e.agrave=556;e.Tcommaaccent=611;e.Cacute=722;e.atilde=556;e.Edotaccent=667;e.scaron=556;e.scedilla=556;e.iacute=278;e.lozenge=494;e.Rcaron=722;e.Gcommaaccent=778;e.ucircumflex=611;e.acircumflex=556;e.Amacron=722;e.rcaron=389;e.ccedilla=556;e.Zdotaccent=611;e.Thorn=667;e.Omacron=778;e.Racute=722;e.Sacute=667;e.dcaron=743;e.Umacron=722;e.uring=611;e.threesuperior=333;e.Ograve=778;e.Agrave=722;e.Abreve=722;e.multiply=584;e.uacute=611;e.Tcaron=611;e.partialdiff=494;e.ydieresis=556;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=556;e.edieresis=556;e.cacute=556;e.nacute=611;e.umacron=611;e.Ncaron=722;e.Iacute=278;e.plusminus=584;e.brokenbar=280;e.registered=737;e.Gbreve=778;e.Idotaccent=278;e.summation=600;e.Egrave=667;e.racute=389;e.omacron=611;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=722;e.lcommaaccent=278;e.tcaron=389;e.eogonek=556;e.Uogonek=722;e.Aacute=722;e.Adieresis=722;e.egrave=556;e.zacute=500;e.iogonek=278;e.Oacute=778;e.oacute=611;e.amacron=556;e.sacute=556;e.idieresis=278;e.Ocircumflex=778;e.Ugrave=722;e.Delta=612;e.thorn=611;e.twosuperior=333;e.Odieresis=778;e.mu=611;e.igrave=278;e.ohungarumlaut=611;e.Eogonek=667;e.dcroat=611;e.threequarters=834;e.Scedilla=667;e.lcaron=400;e.Kcommaaccent=722;e.Lacute=611;e.trademark=1e3;e.edotaccent=556;e.Igrave=278;e.Imacron=278;e.Lcaron=611;e.onehalf=834;e.lessequal=549;e.ocircumflex=611;e.ntilde=611;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=556;e.gbreve=611;e.onequarter=834;e.Scaron=667;e.Scommaaccent=667;e.Ohungarumlaut=778;e.degree=400;e.ograve=611;e.Ccaron=722;e.ugrave=611;e.radical=549;e.Dcaron=722;e.rcommaaccent=389;e.Ntilde=722;e.otilde=611;e.Rcommaaccent=722;e.Lcommaaccent=611;e.Atilde=722;e.Aogonek=722;e.Aring=722;e.Otilde=778;e.zdotaccent=500;e.Ecaron=667;e.Iogonek=278;e.kcommaaccent=556;e.minus=584;e.Icircumflex=278;e.ncaron=611;e.tcommaaccent=333;e.logicalnot=584;e.odieresis=611;e.udieresis=611;e.notequal=549;e.gcommaaccent=611;e.eth=611;e.zcaron=500;e.ncommaaccent=611;e.onesuperior=333;e.imacron=278;e.Euro=556});e["Helvetica-Oblique"]=i(function(e){e.space=278;e.exclam=278;e.quotedbl=355;e.numbersign=556;e.dollar=556;e.percent=889;e.ampersand=667;e.quoteright=222;e.parenleft=333;e.parenright=333;e.asterisk=389;e.plus=584;e.comma=278;e.hyphen=333;e.period=278;e.slash=278;e.zero=556;e.one=556;e.two=556;e.three=556;e.four=556;e.five=556;e.six=556;e.seven=556;e.eight=556;e.nine=556;e.colon=278;e.semicolon=278;e.less=584;e.equal=584;e.greater=584;e.question=556;e.at=1015;e.A=667;e.B=667;e.C=722;e.D=722;e.E=667;e.F=611;e.G=778;e.H=722;e.I=278;e.J=500;e.K=667;e.L=556;e.M=833;e.N=722;e.O=778;e.P=667;e.Q=778;e.R=722;e.S=667;e.T=611;e.U=722;e.V=667;e.W=944;e.X=667;e.Y=667;e.Z=611;e.bracketleft=278;e.backslash=278;e.bracketright=278;e.asciicircum=469;e.underscore=556;e.quoteleft=222;e.a=556;e.b=556;e.c=500;e.d=556;e.e=556;e.f=278;e.g=556;e.h=556;e.i=222;e.j=222;e.k=500;e.l=222;e.m=833;e.n=556;e.o=556;e.p=556;e.q=556;e.r=333;e.s=500;e.t=278;e.u=556;e.v=500;e.w=722;e.x=500;e.y=500;e.z=500;e.braceleft=334;e.bar=260;e.braceright=334;e.asciitilde=584;e.exclamdown=333;e.cent=556;e.sterling=556;e.fraction=167;e.yen=556;e.florin=556;e.section=556;e.currency=556;e.quotesingle=191;e.quotedblleft=333;e.guillemotleft=556;e.guilsinglleft=333;e.guilsinglright=333;e.fi=500;e.fl=500;e.endash=556;e.dagger=556;e.daggerdbl=556;e.periodcentered=278;e.paragraph=537;e.bullet=350;e.quotesinglbase=222;e.quotedblbase=333;e.quotedblright=333;e.guillemotright=556;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=611;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=1e3;e.ordfeminine=370;e.Lslash=556;e.Oslash=778;e.OE=1e3;e.ordmasculine=365;e.ae=889;e.dotlessi=278;e.lslash=222;e.oslash=611;e.oe=944;e.germandbls=611;e.Idieresis=278;e.eacute=556;e.abreve=556;e.uhungarumlaut=556;e.ecaron=556;e.Ydieresis=667;e.divide=584;e.Yacute=667;e.Acircumflex=667;e.aacute=556;e.Ucircumflex=722;e.yacute=500;e.scommaaccent=500;e.ecircumflex=556;e.Uring=722;e.Udieresis=722;e.aogonek=556;e.Uacute=722;e.uogonek=556;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=737;e.Emacron=667;e.ccaron=500;e.aring=556;e.Ncommaaccent=722;e.lacute=222;e.agrave=556;e.Tcommaaccent=611;e.Cacute=722;e.atilde=556;e.Edotaccent=667;e.scaron=500;e.scedilla=500;e.iacute=278;e.lozenge=471;e.Rcaron=722;e.Gcommaaccent=778;e.ucircumflex=556;e.acircumflex=556;e.Amacron=667;e.rcaron=333;e.ccedilla=500;e.Zdotaccent=611;e.Thorn=667;e.Omacron=778;e.Racute=722;e.Sacute=667;e.dcaron=643;e.Umacron=722;e.uring=556;e.threesuperior=333;e.Ograve=778;e.Agrave=667;e.Abreve=667;e.multiply=584;e.uacute=556;e.Tcaron=611;e.partialdiff=476;e.ydieresis=500;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=556;e.edieresis=556;e.cacute=500;e.nacute=556;e.umacron=556;e.Ncaron=722;e.Iacute=278;e.plusminus=584;e.brokenbar=260;e.registered=737;e.Gbreve=778;e.Idotaccent=278;e.summation=600;e.Egrave=667;e.racute=333;e.omacron=556;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=722;e.lcommaaccent=222;e.tcaron=317;e.eogonek=556;e.Uogonek=722;e.Aacute=667;e.Adieresis=667;e.egrave=556;e.zacute=500;e.iogonek=222;e.Oacute=778;e.oacute=556;e.amacron=556;e.sacute=500;e.idieresis=278;e.Ocircumflex=778;e.Ugrave=722;e.Delta=612;e.thorn=556;e.twosuperior=333;e.Odieresis=778;e.mu=556;e.igrave=278;e.ohungarumlaut=556;e.Eogonek=667;e.dcroat=556;e.threequarters=834;e.Scedilla=667;e.lcaron=299;e.Kcommaaccent=667;e.Lacute=556;e.trademark=1e3;e.edotaccent=556;e.Igrave=278;e.Imacron=278;e.Lcaron=556;e.onehalf=834;e.lessequal=549;e.ocircumflex=556;e.ntilde=556;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=556;e.gbreve=556;e.onequarter=834;e.Scaron=667;e.Scommaaccent=667;e.Ohungarumlaut=778;e.degree=400;e.ograve=556;e.Ccaron=722;e.ugrave=556;e.radical=453;e.Dcaron=722;e.rcommaaccent=333;e.Ntilde=722;e.otilde=556;e.Rcommaaccent=722;e.Lcommaaccent=556;e.Atilde=667;e.Aogonek=667;e.Aring=667;e.Otilde=778;e.zdotaccent=500;e.Ecaron=667;e.Iogonek=278;e.kcommaaccent=500;e.minus=584;e.Icircumflex=278;e.ncaron=556;e.tcommaaccent=278;e.logicalnot=584;e.odieresis=556;e.udieresis=556;e.notequal=549;e.gcommaaccent=556;e.eth=556;e.zcaron=500;e.ncommaaccent=556;e.onesuperior=333;e.imacron=278;e.Euro=556});e.Symbol=i(function(e){e.space=250;e.exclam=333;e.universal=713;e.numbersign=500;e.existential=549;e.percent=833;e.ampersand=778;e.suchthat=439;e.parenleft=333;e.parenright=333;e.asteriskmath=500;e.plus=549;e.comma=250;e.minus=549;e.period=250;e.slash=278;e.zero=500;e.one=500;e.two=500;e.three=500;e.four=500;e.five=500;e.six=500;e.seven=500;e.eight=500;e.nine=500;e.colon=278;e.semicolon=278;e.less=549;e.equal=549;e.greater=549;e.question=444;e.congruent=549;e.Alpha=722;e.Beta=667;e.Chi=722;e.Delta=612;e.Epsilon=611;e.Phi=763;e.Gamma=603;e.Eta=722;e.Iota=333;e.theta1=631;e.Kappa=722;e.Lambda=686;e.Mu=889;e.Nu=722;e.Omicron=722;e.Pi=768;e.Theta=741;e.Rho=556;e.Sigma=592;e.Tau=611;e.Upsilon=690;e.sigma1=439;e.Omega=768;e.Xi=645;e.Psi=795;e.Zeta=611;e.bracketleft=333;e.therefore=863;e.bracketright=333;e.perpendicular=658;e.underscore=500;e.radicalex=500;e.alpha=631;e.beta=549;e.chi=549;e.delta=494;e.epsilon=439;e.phi=521;e.gamma=411;e.eta=603;e.iota=329;e.phi1=603;e.kappa=549;e.lambda=549;e.mu=576;e.nu=521;e.omicron=549;e.pi=549;e.theta=521;e.rho=549;e.sigma=603;e.tau=439;e.upsilon=576;e.omega1=713;e.omega=686;e.xi=493;e.psi=686;e.zeta=494;e.braceleft=480;e.bar=200;e.braceright=480;e.similar=549;e.Euro=750;e.Upsilon1=620;e.minute=247;e.lessequal=549;e.fraction=167;e.infinity=713;e.florin=500;e.club=753;e.diamond=753;e.heart=753;e.spade=753;e.arrowboth=1042;e.arrowleft=987;e.arrowup=603;e.arrowright=987;e.arrowdown=603;e.degree=400;e.plusminus=549;e.second=411;e.greaterequal=549;e.multiply=549;e.proportional=713;e.partialdiff=494;e.bullet=460;e.divide=549;e.notequal=549;e.equivalence=549;e.approxequal=549;e.ellipsis=1e3;e.arrowvertex=603;e.arrowhorizex=1e3;e.carriagereturn=658;e.aleph=823;e.Ifraktur=686;e.Rfraktur=795;e.weierstrass=987;e.circlemultiply=768;e.circleplus=768;e.emptyset=823;e.intersection=768;e.union=768;e.propersuperset=713;e.reflexsuperset=713;e.notsubset=713;e.propersubset=713;e.reflexsubset=713;e.element=713;e.notelement=713;e.angle=768;e.gradient=713;e.registerserif=790;e.copyrightserif=790;e.trademarkserif=890;e.product=823;e.radical=549;e.dotmath=250;e.logicalnot=713;e.logicaland=603;e.logicalor=603;e.arrowdblboth=1042;e.arrowdblleft=987;e.arrowdblup=603;e.arrowdblright=987;e.arrowdbldown=603;e.lozenge=494;e.angleleft=329;e.registersans=790;e.copyrightsans=790;e.trademarksans=786;e.summation=713;e.parenlefttp=384;e.parenleftex=384;e.parenleftbt=384;e.bracketlefttp=384;e.bracketleftex=384;e.bracketleftbt=384;e.bracelefttp=494;e.braceleftmid=494;e.braceleftbt=494;e.braceex=494;e.angleright=329;e.integral=274;e.integraltp=686;e.integralex=686;e.integralbt=686;e.parenrighttp=384;e.parenrightex=384;e.parenrightbt=384;e.bracketrighttp=384;e.bracketrightex=384;e.bracketrightbt=384;e.bracerighttp=494;e.bracerightmid=494;e.bracerightbt=494;e.apple=790});e["Times-Roman"]=i(function(e){e.space=250;e.exclam=333;e.quotedbl=408;e.numbersign=500;e.dollar=500;e.percent=833;e.ampersand=778;e.quoteright=333;e.parenleft=333;e.parenright=333;e.asterisk=500;e.plus=564;e.comma=250;e.hyphen=333;e.period=250;e.slash=278;e.zero=500;e.one=500;e.two=500;e.three=500;e.four=500;e.five=500;e.six=500;e.seven=500;e.eight=500;e.nine=500;e.colon=278;e.semicolon=278;e.less=564;e.equal=564;e.greater=564;e.question=444;e.at=921;e.A=722;e.B=667;e.C=667;e.D=722;e.E=611;e.F=556;e.G=722;e.H=722;e.I=333;e.J=389;e.K=722;e.L=611;e.M=889;e.N=722;e.O=722;e.P=556;e.Q=722;e.R=667;e.S=556;e.T=611;e.U=722;e.V=722;e.W=944;e.X=722;e.Y=722;e.Z=611;e.bracketleft=333;e.backslash=278;e.bracketright=333;e.asciicircum=469;e.underscore=500;e.quoteleft=333;e.a=444;e.b=500;e.c=444;e.d=500;e.e=444;e.f=333;e.g=500;e.h=500;e.i=278;e.j=278;e.k=500;e.l=278;e.m=778;e.n=500;e.o=500;e.p=500;e.q=500;e.r=333;e.s=389;e.t=278;e.u=500;e.v=500;e.w=722;e.x=500;e.y=500;e.z=444;e.braceleft=480;e.bar=200;e.braceright=480;e.asciitilde=541;e.exclamdown=333;e.cent=500;e.sterling=500;e.fraction=167;e.yen=500;e.florin=500;e.section=500;e.currency=500;e.quotesingle=180;e.quotedblleft=444;e.guillemotleft=500;e.guilsinglleft=333;e.guilsinglright=333;e.fi=556;e.fl=556;e.endash=500;e.dagger=500;e.daggerdbl=500;e.periodcentered=250;e.paragraph=453;e.bullet=350;e.quotesinglbase=333;e.quotedblbase=444;e.quotedblright=444;e.guillemotright=500;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=444;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=889;e.ordfeminine=276;e.Lslash=611;e.Oslash=722;e.OE=889;e.ordmasculine=310;e.ae=667;e.dotlessi=278;e.lslash=278;e.oslash=500;e.oe=722;e.germandbls=500;e.Idieresis=333;e.eacute=444;e.abreve=444;e.uhungarumlaut=500;e.ecaron=444;e.Ydieresis=722;e.divide=564;e.Yacute=722;e.Acircumflex=722;e.aacute=444;e.Ucircumflex=722;e.yacute=500;e.scommaaccent=389;e.ecircumflex=444;e.Uring=722;e.Udieresis=722;e.aogonek=444;e.Uacute=722;e.uogonek=500;e.Edieresis=611;e.Dcroat=722;e.commaaccent=250;e.copyright=760;e.Emacron=611;e.ccaron=444;e.aring=444;e.Ncommaaccent=722;e.lacute=278;e.agrave=444;e.Tcommaaccent=611;e.Cacute=667;e.atilde=444;e.Edotaccent=611;e.scaron=389;e.scedilla=389;e.iacute=278;e.lozenge=471;e.Rcaron=667;e.Gcommaaccent=722;e.ucircumflex=500;e.acircumflex=444;e.Amacron=722;e.rcaron=333;e.ccedilla=444;e.Zdotaccent=611;e.Thorn=556;e.Omacron=722;e.Racute=667;e.Sacute=556;e.dcaron=588;e.Umacron=722;e.uring=500;e.threesuperior=300;e.Ograve=722;e.Agrave=722;e.Abreve=722;e.multiply=564;e.uacute=500;e.Tcaron=611;e.partialdiff=476;e.ydieresis=500;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=611;e.adieresis=444;e.edieresis=444;e.cacute=444;e.nacute=500;e.umacron=500;e.Ncaron=722;e.Iacute=333;e.plusminus=564;e.brokenbar=200;e.registered=760;e.Gbreve=722;e.Idotaccent=333;e.summation=600;e.Egrave=611;e.racute=333;e.omacron=500;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=667;e.lcommaaccent=278;e.tcaron=326;e.eogonek=444;e.Uogonek=722;e.Aacute=722;e.Adieresis=722;e.egrave=444;e.zacute=444;e.iogonek=278;e.Oacute=722;e.oacute=500;e.amacron=444;e.sacute=389;e.idieresis=278;e.Ocircumflex=722;e.Ugrave=722;e.Delta=612;e.thorn=500;e.twosuperior=300;e.Odieresis=722;e.mu=500;e.igrave=278;e.ohungarumlaut=500;e.Eogonek=611;e.dcroat=500;e.threequarters=750;e.Scedilla=556;e.lcaron=344;e.Kcommaaccent=722;e.Lacute=611;e.trademark=980;e.edotaccent=444;e.Igrave=333;e.Imacron=333;e.Lcaron=611;e.onehalf=750;e.lessequal=549;e.ocircumflex=500;e.ntilde=500;e.Uhungarumlaut=722;e.Eacute=611;e.emacron=444;e.gbreve=500;e.onequarter=750;e.Scaron=556;e.Scommaaccent=556;e.Ohungarumlaut=722;e.degree=400;e.ograve=500;e.Ccaron=667;e.ugrave=500;e.radical=453;e.Dcaron=722;e.rcommaaccent=333;e.Ntilde=722;e.otilde=500;e.Rcommaaccent=667;e.Lcommaaccent=611;e.Atilde=722;e.Aogonek=722;e.Aring=722;e.Otilde=722;e.zdotaccent=444;e.Ecaron=611;e.Iogonek=333;e.kcommaaccent=500;e.minus=564;e.Icircumflex=333;e.ncaron=500;e.tcommaaccent=278;e.logicalnot=564;e.odieresis=500;e.udieresis=500;e.notequal=549;e.gcommaaccent=500;e.eth=500;e.zcaron=444;e.ncommaaccent=500;e.onesuperior=300;e.imacron=278;e.Euro=500});e["Times-Bold"]=i(function(e){e.space=250;e.exclam=333;e.quotedbl=555;e.numbersign=500;e.dollar=500;e.percent=1e3;e.ampersand=833;e.quoteright=333;e.parenleft=333;e.parenright=333;e.asterisk=500;e.plus=570;e.comma=250;e.hyphen=333;e.period=250;e.slash=278;e.zero=500;e.one=500;e.two=500;e.three=500;e.four=500;e.five=500;e.six=500;e.seven=500;e.eight=500;e.nine=500;e.colon=333;e.semicolon=333;e.less=570;e.equal=570;e.greater=570;e.question=500;e.at=930;e.A=722;e.B=667;e.C=722;e.D=722;e.E=667;e.F=611;e.G=778;e.H=778;e.I=389;e.J=500;e.K=778;e.L=667;e.M=944;e.N=722;e.O=778;e.P=611;e.Q=778;e.R=722;e.S=556;e.T=667;e.U=722;e.V=722;e.W=1e3;e.X=722;e.Y=722;e.Z=667;e.bracketleft=333;e.backslash=278;e.bracketright=333;e.asciicircum=581;e.underscore=500;e.quoteleft=333;e.a=500;e.b=556;e.c=444;e.d=556;e.e=444;e.f=333;e.g=500;e.h=556;e.i=278;e.j=333
+;e.k=556;e.l=278;e.m=833;e.n=556;e.o=500;e.p=556;e.q=556;e.r=444;e.s=389;e.t=333;e.u=556;e.v=500;e.w=722;e.x=500;e.y=500;e.z=444;e.braceleft=394;e.bar=220;e.braceright=394;e.asciitilde=520;e.exclamdown=333;e.cent=500;e.sterling=500;e.fraction=167;e.yen=500;e.florin=500;e.section=500;e.currency=500;e.quotesingle=278;e.quotedblleft=500;e.guillemotleft=500;e.guilsinglleft=333;e.guilsinglright=333;e.fi=556;e.fl=556;e.endash=500;e.dagger=500;e.daggerdbl=500;e.periodcentered=250;e.paragraph=540;e.bullet=350;e.quotesinglbase=333;e.quotedblbase=500;e.quotedblright=500;e.guillemotright=500;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=500;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=1e3;e.ordfeminine=300;e.Lslash=667;e.Oslash=778;e.OE=1e3;e.ordmasculine=330;e.ae=722;e.dotlessi=278;e.lslash=278;e.oslash=500;e.oe=722;e.germandbls=556;e.Idieresis=389;e.eacute=444;e.abreve=500;e.uhungarumlaut=556;e.ecaron=444;e.Ydieresis=722;e.divide=570;e.Yacute=722;e.Acircumflex=722;e.aacute=500;e.Ucircumflex=722;e.yacute=500;e.scommaaccent=389;e.ecircumflex=444;e.Uring=722;e.Udieresis=722;e.aogonek=500;e.Uacute=722;e.uogonek=556;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=747;e.Emacron=667;e.ccaron=444;e.aring=500;e.Ncommaaccent=722;e.lacute=278;e.agrave=500;e.Tcommaaccent=667;e.Cacute=722;e.atilde=500;e.Edotaccent=667;e.scaron=389;e.scedilla=389;e.iacute=278;e.lozenge=494;e.Rcaron=722;e.Gcommaaccent=778;e.ucircumflex=556;e.acircumflex=500;e.Amacron=722;e.rcaron=444;e.ccedilla=444;e.Zdotaccent=667;e.Thorn=611;e.Omacron=778;e.Racute=722;e.Sacute=556;e.dcaron=672;e.Umacron=722;e.uring=556;e.threesuperior=300;e.Ograve=778;e.Agrave=722;e.Abreve=722;e.multiply=570;e.uacute=556;e.Tcaron=667;e.partialdiff=494;e.ydieresis=500;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=500;e.edieresis=444;e.cacute=444;e.nacute=556;e.umacron=556;e.Ncaron=722;e.Iacute=389;e.plusminus=570;e.brokenbar=220;e.registered=747;e.Gbreve=778;e.Idotaccent=389;e.summation=600;e.Egrave=667;e.racute=444;e.omacron=500;e.Zacute=667;e.Zcaron=667;e.greaterequal=549;e.Eth=722;e.Ccedilla=722;e.lcommaaccent=278;e.tcaron=416;e.eogonek=444;e.Uogonek=722;e.Aacute=722;e.Adieresis=722;e.egrave=444;e.zacute=444;e.iogonek=278;e.Oacute=778;e.oacute=500;e.amacron=500;e.sacute=389;e.idieresis=278;e.Ocircumflex=778;e.Ugrave=722;e.Delta=612;e.thorn=556;e.twosuperior=300;e.Odieresis=778;e.mu=556;e.igrave=278;e.ohungarumlaut=500;e.Eogonek=667;e.dcroat=556;e.threequarters=750;e.Scedilla=556;e.lcaron=394;e.Kcommaaccent=778;e.Lacute=667;e.trademark=1e3;e.edotaccent=444;e.Igrave=389;e.Imacron=389;e.Lcaron=667;e.onehalf=750;e.lessequal=549;e.ocircumflex=500;e.ntilde=556;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=444;e.gbreve=500;e.onequarter=750;e.Scaron=556;e.Scommaaccent=556;e.Ohungarumlaut=778;e.degree=400;e.ograve=500;e.Ccaron=722;e.ugrave=556;e.radical=549;e.Dcaron=722;e.rcommaaccent=444;e.Ntilde=722;e.otilde=500;e.Rcommaaccent=722;e.Lcommaaccent=667;e.Atilde=722;e.Aogonek=722;e.Aring=722;e.Otilde=778;e.zdotaccent=444;e.Ecaron=667;e.Iogonek=389;e.kcommaaccent=556;e.minus=570;e.Icircumflex=389;e.ncaron=556;e.tcommaaccent=333;e.logicalnot=570;e.odieresis=500;e.udieresis=556;e.notequal=549;e.gcommaaccent=500;e.eth=500;e.zcaron=444;e.ncommaaccent=556;e.onesuperior=300;e.imacron=278;e.Euro=500});e["Times-BoldItalic"]=i(function(e){e.space=250;e.exclam=389;e.quotedbl=555;e.numbersign=500;e.dollar=500;e.percent=833;e.ampersand=778;e.quoteright=333;e.parenleft=333;e.parenright=333;e.asterisk=500;e.plus=570;e.comma=250;e.hyphen=333;e.period=250;e.slash=278;e.zero=500;e.one=500;e.two=500;e.three=500;e.four=500;e.five=500;e.six=500;e.seven=500;e.eight=500;e.nine=500;e.colon=333;e.semicolon=333;e.less=570;e.equal=570;e.greater=570;e.question=500;e.at=832;e.A=667;e.B=667;e.C=667;e.D=722;e.E=667;e.F=667;e.G=722;e.H=778;e.I=389;e.J=500;e.K=667;e.L=611;e.M=889;e.N=722;e.O=722;e.P=611;e.Q=722;e.R=667;e.S=556;e.T=611;e.U=722;e.V=667;e.W=889;e.X=667;e.Y=611;e.Z=611;e.bracketleft=333;e.backslash=278;e.bracketright=333;e.asciicircum=570;e.underscore=500;e.quoteleft=333;e.a=500;e.b=500;e.c=444;e.d=500;e.e=444;e.f=333;e.g=500;e.h=556;e.i=278;e.j=278;e.k=500;e.l=278;e.m=778;e.n=556;e.o=500;e.p=500;e.q=500;e.r=389;e.s=389;e.t=278;e.u=556;e.v=444;e.w=667;e.x=500;e.y=444;e.z=389;e.braceleft=348;e.bar=220;e.braceright=348;e.asciitilde=570;e.exclamdown=389;e.cent=500;e.sterling=500;e.fraction=167;e.yen=500;e.florin=500;e.section=500;e.currency=500;e.quotesingle=278;e.quotedblleft=500;e.guillemotleft=500;e.guilsinglleft=333;e.guilsinglright=333;e.fi=556;e.fl=556;e.endash=500;e.dagger=500;e.daggerdbl=500;e.periodcentered=250;e.paragraph=500;e.bullet=350;e.quotesinglbase=333;e.quotedblbase=500;e.quotedblright=500;e.guillemotright=500;e.ellipsis=1e3;e.perthousand=1e3;e.questiondown=500;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=1e3;e.AE=944;e.ordfeminine=266;e.Lslash=611;e.Oslash=722;e.OE=944;e.ordmasculine=300;e.ae=722;e.dotlessi=278;e.lslash=278;e.oslash=500;e.oe=722;e.germandbls=500;e.Idieresis=389;e.eacute=444;e.abreve=500;e.uhungarumlaut=556;e.ecaron=444;e.Ydieresis=611;e.divide=570;e.Yacute=611;e.Acircumflex=667;e.aacute=500;e.Ucircumflex=722;e.yacute=444;e.scommaaccent=389;e.ecircumflex=444;e.Uring=722;e.Udieresis=722;e.aogonek=500;e.Uacute=722;e.uogonek=556;e.Edieresis=667;e.Dcroat=722;e.commaaccent=250;e.copyright=747;e.Emacron=667;e.ccaron=444;e.aring=500;e.Ncommaaccent=722;e.lacute=278;e.agrave=500;e.Tcommaaccent=611;e.Cacute=667;e.atilde=500;e.Edotaccent=667;e.scaron=389;e.scedilla=389;e.iacute=278;e.lozenge=494;e.Rcaron=667;e.Gcommaaccent=722;e.ucircumflex=556;e.acircumflex=500;e.Amacron=667;e.rcaron=389;e.ccedilla=444;e.Zdotaccent=611;e.Thorn=611;e.Omacron=722;e.Racute=667;e.Sacute=556;e.dcaron=608;e.Umacron=722;e.uring=556;e.threesuperior=300;e.Ograve=722;e.Agrave=667;e.Abreve=667;e.multiply=570;e.uacute=556;e.Tcaron=611;e.partialdiff=494;e.ydieresis=444;e.Nacute=722;e.icircumflex=278;e.Ecircumflex=667;e.adieresis=500;e.edieresis=444;e.cacute=444;e.nacute=556;e.umacron=556;e.Ncaron=722;e.Iacute=389;e.plusminus=570;e.brokenbar=220;e.registered=747;e.Gbreve=722;e.Idotaccent=389;e.summation=600;e.Egrave=667;e.racute=389;e.omacron=500;e.Zacute=611;e.Zcaron=611;e.greaterequal=549;e.Eth=722;e.Ccedilla=667;e.lcommaaccent=278;e.tcaron=366;e.eogonek=444;e.Uogonek=722;e.Aacute=667;e.Adieresis=667;e.egrave=444;e.zacute=389;e.iogonek=278;e.Oacute=722;e.oacute=500;e.amacron=500;e.sacute=389;e.idieresis=278;e.Ocircumflex=722;e.Ugrave=722;e.Delta=612;e.thorn=500;e.twosuperior=300;e.Odieresis=722;e.mu=576;e.igrave=278;e.ohungarumlaut=500;e.Eogonek=667;e.dcroat=500;e.threequarters=750;e.Scedilla=556;e.lcaron=382;e.Kcommaaccent=667;e.Lacute=611;e.trademark=1e3;e.edotaccent=444;e.Igrave=389;e.Imacron=389;e.Lcaron=611;e.onehalf=750;e.lessequal=549;e.ocircumflex=500;e.ntilde=556;e.Uhungarumlaut=722;e.Eacute=667;e.emacron=444;e.gbreve=500;e.onequarter=750;e.Scaron=556;e.Scommaaccent=556;e.Ohungarumlaut=722;e.degree=400;e.ograve=500;e.Ccaron=667;e.ugrave=556;e.radical=549;e.Dcaron=722;e.rcommaaccent=389;e.Ntilde=722;e.otilde=500;e.Rcommaaccent=667;e.Lcommaaccent=611;e.Atilde=667;e.Aogonek=667;e.Aring=667;e.Otilde=722;e.zdotaccent=389;e.Ecaron=667;e.Iogonek=389;e.kcommaaccent=500;e.minus=606;e.Icircumflex=389;e.ncaron=556;e.tcommaaccent=278;e.logicalnot=606;e.odieresis=500;e.udieresis=556;e.notequal=549;e.gcommaaccent=500;e.eth=500;e.zcaron=389;e.ncommaaccent=556;e.onesuperior=300;e.imacron=278;e.Euro=500});e["Times-Italic"]=i(function(e){e.space=250;e.exclam=333;e.quotedbl=420;e.numbersign=500;e.dollar=500;e.percent=833;e.ampersand=778;e.quoteright=333;e.parenleft=333;e.parenright=333;e.asterisk=500;e.plus=675;e.comma=250;e.hyphen=333;e.period=250;e.slash=278;e.zero=500;e.one=500;e.two=500;e.three=500;e.four=500;e.five=500;e.six=500;e.seven=500;e.eight=500;e.nine=500;e.colon=333;e.semicolon=333;e.less=675;e.equal=675;e.greater=675;e.question=500;e.at=920;e.A=611;e.B=611;e.C=667;e.D=722;e.E=611;e.F=611;e.G=722;e.H=722;e.I=333;e.J=444;e.K=667;e.L=556;e.M=833;e.N=667;e.O=722;e.P=611;e.Q=722;e.R=611;e.S=500;e.T=556;e.U=722;e.V=611;e.W=833;e.X=611;e.Y=556;e.Z=556;e.bracketleft=389;e.backslash=278;e.bracketright=389;e.asciicircum=422;e.underscore=500;e.quoteleft=333;e.a=500;e.b=500;e.c=444;e.d=500;e.e=444;e.f=278;e.g=500;e.h=500;e.i=278;e.j=278;e.k=444;e.l=278;e.m=722;e.n=500;e.o=500;e.p=500;e.q=500;e.r=389;e.s=389;e.t=278;e.u=500;e.v=444;e.w=667;e.x=444;e.y=444;e.z=389;e.braceleft=400;e.bar=275;e.braceright=400;e.asciitilde=541;e.exclamdown=389;e.cent=500;e.sterling=500;e.fraction=167;e.yen=500;e.florin=500;e.section=500;e.currency=500;e.quotesingle=214;e.quotedblleft=556;e.guillemotleft=500;e.guilsinglleft=333;e.guilsinglright=333;e.fi=500;e.fl=500;e.endash=500;e.dagger=500;e.daggerdbl=500;e.periodcentered=250;e.paragraph=523;e.bullet=350;e.quotesinglbase=333;e.quotedblbase=556;e.quotedblright=556;e.guillemotright=500;e.ellipsis=889;e.perthousand=1e3;e.questiondown=500;e.grave=333;e.acute=333;e.circumflex=333;e.tilde=333;e.macron=333;e.breve=333;e.dotaccent=333;e.dieresis=333;e.ring=333;e.cedilla=333;e.hungarumlaut=333;e.ogonek=333;e.caron=333;e.emdash=889;e.AE=889;e.ordfeminine=276;e.Lslash=556;e.Oslash=722;e.OE=944;e.ordmasculine=310;e.ae=667;e.dotlessi=278;e.lslash=278;e.oslash=500;e.oe=667;e.germandbls=500;e.Idieresis=333;e.eacute=444;e.abreve=500;e.uhungarumlaut=500;e.ecaron=444;e.Ydieresis=556;e.divide=675;e.Yacute=556;e.Acircumflex=611;e.aacute=500;e.Ucircumflex=722;e.yacute=444;e.scommaaccent=389;e.ecircumflex=444;e.Uring=722;e.Udieresis=722;e.aogonek=500;e.Uacute=722;e.uogonek=500;e.Edieresis=611;e.Dcroat=722;e.commaaccent=250;e.copyright=760;e.Emacron=611;e.ccaron=444;e.aring=500;e.Ncommaaccent=667;e.lacute=278;e.agrave=500;e.Tcommaaccent=556;e.Cacute=667;e.atilde=500;e.Edotaccent=611;e.scaron=389;e.scedilla=389;e.iacute=278;e.lozenge=471;e.Rcaron=611;e.Gcommaaccent=722;e.ucircumflex=500;e.acircumflex=500;e.Amacron=611;e.rcaron=389;e.ccedilla=444;e.Zdotaccent=556;e.Thorn=611;e.Omacron=722;e.Racute=611;e.Sacute=500;e.dcaron=544;e.Umacron=722;e.uring=500;e.threesuperior=300;e.Ograve=722;e.Agrave=611;e.Abreve=611;e.multiply=675;e.uacute=500;e.Tcaron=556;e.partialdiff=476;e.ydieresis=444;e.Nacute=667;e.icircumflex=278;e.Ecircumflex=611;e.adieresis=500;e.edieresis=444;e.cacute=444;e.nacute=500;e.umacron=500;e.Ncaron=667;e.Iacute=333;e.plusminus=675;e.brokenbar=275;e.registered=760;e.Gbreve=722;e.Idotaccent=333;e.summation=600;e.Egrave=611;e.racute=389;e.omacron=500;e.Zacute=556;e.Zcaron=556;e.greaterequal=549;e.Eth=722;e.Ccedilla=667;e.lcommaaccent=278;e.tcaron=300;e.eogonek=444;e.Uogonek=722;e.Aacute=611;e.Adieresis=611;e.egrave=444;e.zacute=389;e.iogonek=278;e.Oacute=722;e.oacute=500;e.amacron=500;e.sacute=389;e.idieresis=278;e.Ocircumflex=722;e.Ugrave=722;e.Delta=612;e.thorn=500;e.twosuperior=300;e.Odieresis=722;e.mu=500;e.igrave=278;e.ohungarumlaut=500;e.Eogonek=611;e.dcroat=500;e.threequarters=750;e.Scedilla=500;e.lcaron=300;e.Kcommaaccent=667;e.Lacute=556;e.trademark=980;e.edotaccent=444;e.Igrave=333;e.Imacron=333;e.Lcaron=611;e.onehalf=750;e.lessequal=549;e.ocircumflex=500;e.ntilde=500;e.Uhungarumlaut=722;e.Eacute=611;e.emacron=444;e.gbreve=500;e.onequarter=750;e.Scaron=500;e.Scommaaccent=500;e.Ohungarumlaut=722;e.degree=400;e.ograve=500;e.Ccaron=667;e.ugrave=500;e.radical=453;e.Dcaron=722;e.rcommaaccent=389;e.Ntilde=667;e.otilde=500;e.Rcommaaccent=611;e.Lcommaaccent=556;e.Atilde=611;e.Aogonek=611;e.Aring=611;e.Otilde=722;e.zdotaccent=389;e.Ecaron=611;e.Iogonek=333;e.kcommaaccent=444;e.minus=675;e.Icircumflex=333;e.ncaron=500;e.tcommaaccent=278;e.logicalnot=675;e.odieresis=500;e.udieresis=500;e.notequal=549;e.gcommaaccent=500;e.eth=500;e.zcaron=389;e.ncommaaccent=500;e.onesuperior=300;e.imacron=278;e.Euro=500});e.ZapfDingbats=i(function(e){e.space=278;e.a1=974;e.a2=961;e.a202=974;e.a3=980;e.a4=719;e.a5=789;e.a119=790;e.a118=791;e.a117=690;e.a11=960;e.a12=939;e.a13=549;e.a14=855;e.a15=911;e.a16=933;e.a105=911;e.a17=945;e.a18=974;e.a19=755;e.a20=846;e.a21=762;e.a22=761;e.a23=571;e.a24=677;e.a25=763;e.a26=760;e.a27=759;e.a28=754;e.a6=494;e.a7=552;e.a8=537;e.a9=577;e.a10=692;e.a29=786;e.a30=788;e.a31=788;e.a32=790;e.a33=793;e.a34=794;e.a35=816;e.a36=823;e.a37=789;e.a38=841;e.a39=823;e.a40=833;e.a41=816;e.a42=831;e.a43=923;e.a44=744;e.a45=723;e.a46=749;e.a47=790;e.a48=792;e.a49=695;e.a50=776;e.a51=768;e.a52=792;e.a53=759;e.a54=707;e.a55=708;e.a56=682;e.a57=701;e.a58=826;e.a59=815;e.a60=789;e.a61=789;e.a62=707;e.a63=687;e.a64=696;e.a65=689;e.a66=786;e.a67=787;e.a68=713;e.a69=791;e.a70=785;e.a71=791;e.a72=873;e.a73=761;e.a74=762;e.a203=762;e.a75=759;e.a204=759;e.a76=892;e.a77=892;e.a78=788;e.a79=784;e.a81=438;e.a82=138;e.a83=277;e.a84=415;e.a97=392;e.a98=392;e.a99=668;e.a100=668;e.a89=390;e.a90=390;e.a93=317;e.a94=317;e.a91=276;e.a92=276;e.a205=509;e.a85=509;e.a206=410;e.a86=410;e.a87=234;e.a88=234;e.a95=334;e.a96=334;e.a101=732;e.a102=544;e.a103=544;e.a104=910;e.a106=667;e.a107=760;e.a108=760;e.a112=776;e.a111=595;e.a110=694;e.a109=626;e.a120=788;e.a121=788;e.a122=788;e.a123=788;e.a124=788;e.a125=788;e.a126=788;e.a127=788;e.a128=788;e.a129=788;e.a130=788;e.a131=788;e.a132=788;e.a133=788;e.a134=788;e.a135=788;e.a136=788;e.a137=788;e.a138=788;e.a139=788;e.a140=788;e.a141=788;e.a142=788;e.a143=788;e.a144=788;e.a145=788;e.a146=788;e.a147=788;e.a148=788;e.a149=788;e.a150=788;e.a151=788;e.a152=788;e.a153=788;e.a154=788;e.a155=788;e.a156=788;e.a157=788;e.a158=788;e.a159=788;e.a160=894;e.a161=838;e.a163=1016;e.a164=458;e.a196=748;e.a165=924;e.a192=748;e.a166=918;e.a167=927;e.a168=928;e.a169=928;e.a170=834;e.a171=873;e.a172=828;e.a173=924;e.a162=924;e.a174=917;e.a175=930;e.a176=931;e.a177=463;e.a178=883;e.a179=836;e.a193=836;e.a180=867;e.a199=867;e.a181=696;e.a200=696;e.a182=874;e.a201=874;e.a183=760;e.a184=946;e.a197=771;e.a185=865;e.a194=771;e.a198=888;e.a186=967;e.a195=888;e.a187=831;e.a188=873;e.a189=927;e.a190=970;e.a191=918})});t.getMetrics=n},function(e,t,a){"use strict";var r=a(0),i=r.Uint32ArrayView,n=function(e){function t(e){this.h1=e?4294967295&e:3285377520;this.h2=e?4294967295&e:3285377520}var a=!1;try{new Uint32Array(new Uint8Array(5).buffer,0,1)}catch(e){a=!0}t.prototype={update:function(e){var t,r=a;if("string"==typeof e){var n=new Uint8Array(2*e.length),s=0;for(t=0;t<e.length;t++){var o=e.charCodeAt(t);if(o<=255)n[s++]=o;else{n[s++]=o>>>8;n[s++]=255&o}}}else if(e instanceof Uint8Array){n=e;s=n.length}else{if(!("object"==typeof e&&"length"in e))throw new Error("Wrong data format in MurmurHash3_64_update. Input must be a string or array.");n=e;s=n.length;r=!0}var c=s>>2,l=s-4*c,h=r?new i(n,c):new Uint32Array(n.buffer,0,c),u=0,f=0,d=this.h1,g=this.h2,p=3432918353,m=461845907;for(t=0;t<c;t++)if(1&t){u=h[t];u=u*p&4294901760|11601*u&65535;u=u<<15|u>>>17;u=u*m&4294901760|13715*u&65535;d^=u;d=d<<13|d>>>19;d=5*d+3864292196}else{f=h[t];f=f*p&4294901760|11601*f&65535;f=f<<15|f>>>17;f=f*m&4294901760|13715*f&65535;g^=f;g=g<<13|g>>>19;g=5*g+3864292196}u=0;switch(l){case 3:u^=n[4*c+2]<<16;case 2:u^=n[4*c+1]<<8;case 1:u^=n[4*c];u=u*p&4294901760|11601*u&65535;u=u<<15|u>>>17;u=u*m&4294901760|13715*u&65535;1&c?d^=u:g^=u}this.h1=d;this.h2=g;return this},hexdigest:function(){var e=this.h1,t=this.h2;e^=t>>>1;e=3981806797*e&4294901760|36045*e&65535;t=4283543511*t&4294901760|(2950163797*(t<<16|e>>>16)&4294901760)>>>16;e^=t>>>1;e=444984403*e&4294901760|60499*e&65535;t=3301882366*t&4294901760|(3120437893*(t<<16|e>>>16)&4294901760)>>>16;e^=t>>>1;for(var a=0,r=[e,t],i="";a<r.length;a++){for(var n=(r[a]>>>0).toString(16);n.length<8;)n="0"+n;i+=n}return i}};return t}();t.MurmurHash3_64=n},function(e,t,a){"use strict";function r(e,t,a){return["TilingPattern",a,e,t.getArray("Matrix"),t.getArray("BBox"),t.get("XStep"),t.get("YStep"),t.get("PaintType"),t.get("TilingType")]}var i=a(0),n=a(1),s=a(6),o=a(3),c=i.UNSUPPORTED_FEATURES,l=i.MissingDataException,h=i.Util,u=i.assert,f=i.error,d=i.info,g=i.warn,p=n.isStream,m=s.PDFFunction,b=o.ColorSpace,v={FUNCTION_BASED:1,AXIAL:2,RADIAL:3,FREE_FORM_MESH:4,LATTICE_FORM_MESH:5,COONS_PATCH_MESH:6,TENSOR_PATCH_MESH:7},y=function(){function e(){f("should not call Pattern constructor")}e.prototype={getPattern:function(e){f("Should not call Pattern.getStyle: "+e)}};e.parseShading=function(e,t,a,r,i){var n=p(e)?e.dict:e,s=n.get("ShadingType");try{switch(s){case v.AXIAL:case v.RADIAL:return new k.RadialAxial(n,t,a,r);case v.FREE_FORM_MESH:case v.LATTICE_FORM_MESH:case v.COONS_PATCH_MESH:case v.TENSOR_PATCH_MESH:return new k.Mesh(e,t,a,r);default:throw new Error("Unsupported ShadingType: "+s)}}catch(e){if(e instanceof l)throw e;i.send("UnsupportedFeature",{featureId:c.shadingPattern});g(e);return new k.Dummy}};return e}(),k={};k.SMALL_NUMBER=1e-6;k.RadialAxial=function(){function e(e,t,a,r){this.matrix=t;this.coordsArr=e.getArray("Coords");this.shadingType=e.get("ShadingType");this.type="Pattern";var i=e.get("ColorSpace","CS");i=b.parse(i,a,r);this.cs=i;var n=0,s=1;if(e.has("Domain")){var o=e.getArray("Domain");n=o[0];s=o[1]}var c=!1,l=!1;if(e.has("Extend")){var u=e.getArray("Extend");c=u[0];l=u[1]}if(!(this.shadingType!==v.RADIAL||c&&l)){var f=this.coordsArr[0],p=this.coordsArr[1],y=this.coordsArr[2],w=this.coordsArr[3],C=this.coordsArr[4],x=this.coordsArr[5],S=Math.sqrt((f-w)*(f-w)+(p-C)*(p-C));y<=x+S&&x<=y+S&&g("Unsupported radial gradient.")}this.extendStart=c;this.extendEnd=l;var A=e.get("Function"),I=m.parseArray(a,A),B=s-n,R=B/10,T=this.colorStops=[];if(n>=s||R<=0)d("Bad shading domain.");else{for(var O,P=new Float32Array(i.numComps),M=new Float32Array(1),E=n;E<=s;E+=R){M[0]=E;I(M,0,P,0);O=i.getRgb(P,0);var L=h.makeCssRgb(O[0],O[1],O[2]);T.push([(E-n)/B,L])}var D="transparent";if(e.has("Background")){O=i.getRgb(e.get("Background"),0);D=h.makeCssRgb(O[0],O[1],O[2])}if(!c){T.unshift([0,D]);T[1][0]+=k.SMALL_NUMBER}if(!l){T[T.length-1][0]-=k.SMALL_NUMBER;T.push([1,D])}this.colorStops=T}}e.prototype={getIR:function(){var e,t,a,r,i,n=this.coordsArr,s=this.shadingType;if(s===v.AXIAL){t=[n[0],n[1]];a=[n[2],n[3]];r=null;i=null;e="axial"}else if(s===v.RADIAL){t=[n[0],n[1]];a=[n[3],n[4]];r=n[2];i=n[5];e="radial"}else f("getPattern type unknown: "+s);var o=this.matrix;if(o){t=h.applyTransform(t,o);a=h.applyTransform(a,o);if(s===v.RADIAL){var c=h.singularValueDecompose2dScale(o);r*=c[0];i*=c[1]}}return["RadialAxial",e,this.colorStops,t,a,r,i]}};return e}();k.Mesh=function(){function e(e,t){this.stream=e;this.context=t;this.buffer=0;this.bufferLength=0;var a=t.numComps;this.tmpCompsBuf=new Float32Array(a);var r=t.colorSpace.numComps;this.tmpCsCompsBuf=t.colorFn?new Float32Array(r):this.tmpCompsBuf}function t(e,t){for(var a=e.coords,r=e.colors,i=[],n=[],s=0;t.hasData;){var o=t.readFlag(),c=t.readCoordinate(),l=t.readComponents();if(0===s){u(0<=o&&o<=2,"Unknown type4 flag");switch(o){case 0:s=3;break;case 1:n.push(n[n.length-2],n[n.length-1]);s=1;break;case 2:n.push(n[n.length-3],n[n.length-1]);s=1}i.push(o)}n.push(a.length);a.push(c);r.push(l);s--;t.align()}e.figures.push({type:"triangles",coords:new Int32Array(n),colors:new Int32Array(n)})}function a(e,t,a){for(var r=e.coords,i=e.colors,n=[];t.hasData;){var s=t.readCoordinate(),o=t.readComponents();n.push(r.length);r.push(s);i.push(o)}e.figures.push({type:"lattice",coords:new Int32Array(n),colors:new Int32Array(n),verticesPerRow:a})}function r(e,t){var a=e.figures[t];u("patch"===a.type,"Unexpected patch mesh figure");var r=e.coords,i=e.colors,n=a.coords,s=a.colors,o=Math.min(r[n[0]][0],r[n[3]][0],r[n[12]][0],r[n[15]][0]),c=Math.min(r[n[0]][1],r[n[3]][1],r[n[12]][1],r[n[15]][1]),f=Math.max(r[n[0]][0],r[n[3]][0],r[n[12]][0],r[n[15]][0]),p=Math.max(r[n[0]][1],r[n[3]][1],r[n[12]][1],r[n[15]][1]),m=Math.ceil((f-o)*d/(e.bounds[2]-e.bounds[0]));m=Math.max(l,Math.min(h,m));var b=Math.ceil((p-c)*d/(e.bounds[3]-e.bounds[1]));b=Math.max(l,Math.min(h,b));for(var v=m+1,y=new Int32Array((b+1)*v),k=new Int32Array((b+1)*v),w=0,C=new Uint8Array(3),x=new Uint8Array(3),S=i[s[0]],A=i[s[1]],I=i[s[2]],B=i[s[3]],R=g(b),T=g(m),O=0;O<=b;O++){C[0]=(S[0]*(b-O)+I[0]*O)/b|0;C[1]=(S[1]*(b-O)+I[1]*O)/b|0;C[2]=(S[2]*(b-O)+I[2]*O)/b|0;x[0]=(A[0]*(b-O)+B[0]*O)/b|0;x[1]=(A[1]*(b-O)+B[1]*O)/b|0;x[2]=(A[2]*(b-O)+B[2]*O)/b|0;for(var P=0;P<=m;P++,w++)if(0!==O&&O!==b||0!==P&&P!==m){for(var M=0,E=0,L=0,D=0;D<=3;D++)for(var F=0;F<=3;F++,L++){var q=R[O][D]*T[P][F];M+=r[n[L]][0]*q;E+=r[n[L]][1]*q}y[w]=r.length;r.push([M,E]);k[w]=i.length;var U=new Uint8Array(3);U[0]=(C[0]*(m-P)+x[0]*P)/m|0;U[1]=(C[1]*(m-P)+x[1]*P)/m|0;U[2]=(C[2]*(m-P)+x[2]*P)/m|0;i.push(U)}}y[0]=n[0];k[0]=s[0];y[m]=n[3];k[m]=s[1];y[v*b]=n[12];k[v*b]=s[2];y[v*b+m]=n[15];k[v*b+m]=s[3];e.figures[t]={type:"lattice",coords:y,colors:k,verticesPerRow:v}}function i(e,t){for(var a=e.coords,r=e.colors,i=new Int32Array(16),n=new Int32Array(4);t.hasData;){var s=t.readFlag();u(0<=s&&s<=3,"Unknown type6 flag");var o,c,l=a.length;for(o=0,c=0!==s?8:12;o<c;o++)a.push(t.readCoordinate());var h=r.length;for(o=0,c=0!==s?2:4;o<c;o++)r.push(t.readComponents());var f,d,g,p;switch(s){case 0:i[12]=l+3;i[13]=l+4;i[14]=l+5;i[15]=l+6;i[8]=l+2;i[11]=l+7;i[4]=l+1;i[7]=l+8;i[0]=l;i[1]=l+11;i[2]=l+10;i[3]=l+9;n[2]=h+1;n[3]=h+2;n[0]=h;n[1]=h+3;break;case 1:f=i[12];d=i[13];g=i[14];p=i[15];i[12]=p;i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=g;i[11]=l+3;i[4]=d;i[7]=l+4;i[0]=f;i[1]=l+7;i[2]=l+6;i[3]=l+5;f=n[2];d=n[3];n[2]=d;n[3]=h;n[0]=f;n[1]=h+1;break;case 2:f=i[15];d=i[11];i[12]=i[3];i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=i[7];i[11]=l+3;i[4]=d;i[7]=l+4;i[0]=f;i[1]=l+7;i[2]=l+6;i[3]=l+5;f=n[3];n[2]=n[1];n[3]=h;n[0]=f;n[1]=h+1;break;case 3:i[12]=i[0];i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=i[1];i[11]=l+3;i[4]=i[2];i[7]=l+4;i[0]=i[3];i[1]=l+7;i[2]=l+6;i[3]=l+5;n[2]=n[0];n[3]=h;n[0]=n[1];n[1]=h+1}i[5]=a.length;a.push([(-4*a[i[0]][0]-a[i[15]][0]+6*(a[i[4]][0]+a[i[1]][0])-2*(a[i[12]][0]+a[i[3]][0])+3*(a[i[13]][0]+a[i[7]][0]))/9,(-4*a[i[0]][1]-a[i[15]][1]+6*(a[i[4]][1]+a[i[1]][1])-2*(a[i[12]][1]+a[i[3]][1])+3*(a[i[13]][1]+a[i[7]][1]))/9]);i[6]=a.length;a.push([(-4*a[i[3]][0]-a[i[12]][0]+6*(a[i[2]][0]+a[i[7]][0])-2*(a[i[0]][0]+a[i[15]][0])+3*(a[i[4]][0]+a[i[14]][0]))/9,(-4*a[i[3]][1]-a[i[12]][1]+6*(a[i[2]][1]+a[i[7]][1])-2*(a[i[0]][1]+a[i[15]][1])+3*(a[i[4]][1]+a[i[14]][1]))/9]);i[9]=a.length;a.push([(-4*a[i[12]][0]-a[i[3]][0]+6*(a[i[8]][0]+a[i[13]][0])-2*(a[i[0]][0]+a[i[15]][0])+3*(a[i[11]][0]+a[i[1]][0]))/9,(-4*a[i[12]][1]-a[i[3]][1]+6*(a[i[8]][1]+a[i[13]][1])-2*(a[i[0]][1]+a[i[15]][1])+3*(a[i[11]][1]+a[i[1]][1]))/9]);i[10]=a.length;a.push([(-4*a[i[15]][0]-a[i[0]][0]+6*(a[i[11]][0]+a[i[14]][0])-2*(a[i[12]][0]+a[i[3]][0])+3*(a[i[2]][0]+a[i[8]][0]))/9,(-4*a[i[15]][1]-a[i[0]][1]+6*(a[i[11]][1]+a[i[14]][1])-2*(a[i[12]][1]+a[i[3]][1])+3*(a[i[2]][1]+a[i[8]][1]))/9]);e.figures.push({type:"patch",coords:new Int32Array(i),colors:new Int32Array(n)})}}function n(e,t){for(var a=e.coords,r=e.colors,i=new Int32Array(16),n=new Int32Array(4);t.hasData;){var s=t.readFlag();u(0<=s&&s<=3,"Unknown type7 flag");var o,c,l=a.length;for(o=0,c=0!==s?12:16;o<c;o++)a.push(t.readCoordinate());var h=r.length;for(o=0,c=0!==s?2:4;o<c;o++)r.push(t.readComponents());var f,d,g,p;switch(s){case 0:i[12]=l+3;i[13]=l+4;i[14]=l+5;i[15]=l+6;i[8]=l+2;i[9]=l+13;i[10]=l+14;i[11]=l+7;i[4]=l+1;i[5]=l+12;i[6]=l+15;i[7]=l+8;i[0]=l;i[1]=l+11;i[2]=l+10;i[3]=l+9;n[2]=h+1;n[3]=h+2;n[0]=h;n[1]=h+3;break;case 1:f=i[12];d=i[13];g=i[14];p=i[15];i[12]=p;i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=g;i[9]=l+9;i[10]=l+10;i[11]=l+3;i[4]=d;i[5]=l+8;i[6]=l+11;i[7]=l+4;i[0]=f;i[1]=l+7;i[2]=l+6;i[3]=l+5;f=n[2];d=n[3];n[2]=d;n[3]=h;n[0]=f;n[1]=h+1;break;case 2:f=i[15];d=i[11];i[12]=i[3];i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=i[7];i[9]=l+9;i[10]=l+10;i[11]=l+3;i[4]=d;i[5]=l+8;i[6]=l+11;i[7]=l+4;i[0]=f;i[1]=l+7;i[2]=l+6;i[3]=l+5;f=n[3];n[2]=n[1];n[3]=h;n[0]=f;n[1]=h+1;break;case 3:i[12]=i[0];i[13]=l+0;i[14]=l+1;i[15]=l+2;i[8]=i[1];i[9]=l+9;i[10]=l+10;i[11]=l+3;i[4]=i[2];i[5]=l+8;i[6]=l+11;i[7]=l+4;i[0]=i[3];i[1]=l+7;i[2]=l+6;i[3]=l+5;n[2]=n[0];n[3]=h;n[0]=n[1];n[1]=h+1}e.figures.push({type:"patch",coords:new Int32Array(i),colors:new Int32Array(n)})}}function s(e){for(var t=e.coords[0][0],a=e.coords[0][1],r=t,i=a,n=1,s=e.coords.length;n<s;n++){var o=e.coords[n][0],c=e.coords[n][1];t=t>o?o:t;a=a>c?c:a;r=r<o?o:r;i=i<c?c:i}e.bounds=[t,a,r,i]}function o(e){var t,a,r,i,n=e.coords,s=new Float32Array(2*n.length);for(t=0,r=0,a=n.length;t<a;t++){var o=n[t];s[r++]=o[0];s[r++]=o[1]}e.coords=s;var c=e.colors,l=new Uint8Array(3*c.length);for(t=0,r=0,a=c.length;t<a;t++){var h=c[t];l[r++]=h[0];l[r++]=h[1];l[r++]=h[2]}e.colors=l;var u=e.figures;for(t=0,a=u.length;t<a;t++){var f=u[t],d=f.coords,g=f.colors;for(r=0,i=d.length;r<i;r++){d[r]*=2;g[r]*=3}}}function c(c,l,h,d){u(p(c),"Mesh data is not a stream");var g=c.dict;this.matrix=l;this.shadingType=g.get("ShadingType");this.type="Pattern";this.bbox=g.getArray("BBox");var y=g.get("ColorSpace","CS");y=b.parse(y,h,d);this.cs=y;this.background=g.has("Background")?y.getRgb(g.get("Background"),0):null;var k=g.get("Function"),w=k?m.parseArray(h,k):null;this.coords=[];this.colors=[];this.figures=[];var C={bitsPerCoordinate:g.get("BitsPerCoordinate"),bitsPerComponent:g.get("BitsPerComponent"),bitsPerFlag:g.get("BitsPerFlag"),decode:g.getArray("Decode"),colorFn:w,colorSpace:y,numComps:w?1:y.numComps},x=new e(c,C),S=!1;switch(this.shadingType){case v.FREE_FORM_MESH:t(this,x);break;case v.LATTICE_FORM_MESH:var A=0|g.get("VerticesPerRow");u(A>=2,"Invalid VerticesPerRow");a(this,x,A);break;case v.COONS_PATCH_MESH:i(this,x);S=!0;break;case v.TENSOR_PATCH_MESH:n(this,x);S=!0;break;default:f("Unsupported mesh type.")}if(S){s(this);for(var I=0,B=this.figures.length;I<B;I++)r(this,I)}s(this);o(this)}e.prototype={get hasData(){if(this.stream.end)return this.stream.pos<this.stream.end;if(this.bufferLength>0)return!0;var e=this.stream.getByte();if(e<0)return!1;this.buffer=e;this.bufferLength=8;return!0},readBits:function(e){var t=this.buffer,a=this.bufferLength;if(32===e){if(0===a)return(this.stream.getByte()<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte())>>>0;t=t<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte();var r=this.stream.getByte();this.buffer=r&(1<<a)-1;return(t<<8-a|(255&r)>>a)>>>0}if(8===e&&0===a)return this.stream.getByte();for(;a<e;){t=t<<8|this.stream.getByte();a+=8}a-=e;this.bufferLength=a;this.buffer=t&(1<<a)-1;return t>>a},align:function(){this.buffer=0;this.bufferLength=0},readFlag:function(){return this.readBits(this.context.bitsPerFlag)},readCoordinate:function(){var e=this.context.bitsPerCoordinate,t=this.readBits(e),a=this.readBits(e),r=this.context.decode,i=e<32?1/((1<<e)-1):2.3283064365386963e-10;return[t*i*(r[1]-r[0])+r[0],a*i*(r[3]-r[2])+r[2]]},readComponents:function(){for(var e=this.context.numComps,t=this.context.bitsPerComponent,a=t<32?1/((1<<t)-1):2.3283064365386963e-10,r=this.context.decode,i=this.tmpCompsBuf,n=0,s=4;n<e;n++,s+=2){var o=this.readBits(t);i[n]=o*a*(r[s+1]-r[s])+r[s]}var c=this.tmpCsCompsBuf;this.context.colorFn&&this.context.colorFn(i,0,c,0);return this.context.colorSpace.getRgb(c,0)}};var l=3,h=20,d=20,g=function(){function e(e){for(var t=[],a=0;a<=e;a++){var r=a/e,i=1-r;t.push(new Float32Array([i*i*i,3*r*i*i,3*r*r*i,r*r*r]))}return t}var t=[];return function(a){t[a]||(t[a]=e(a));return t[a]}}();c.prototype={getIR:function(){return["Mesh",this.shadingType,this.coords,this.colors,this.figures,this.bounds,this.matrix,this.bbox,this.background]}};return c}();k.Dummy=function(){function e(){this.type="Pattern"}e.prototype={getIR:function(){return["Dummy"]}};return e}();t.Pattern=y;t.getTilingPatternIR=r},function(e,t,a){"use strict";var r=a(0),i=a(2),n=a(12),s=a(24),o=r.warn,c=r.createValidAbsoluteUrl,l=r.shadow,h=r.NotImplementedException,u=r.MissingDataException,f=r.createPromiseCapability,d=r.Util,g=i.Stream,p=n.ChunkedStreamManager,m=s.PDFDocument,b=function(){function e(){throw new Error("Cannot initialize BaseManagerManager")}e.prototype={get docId(){return this._docId},get password(){return this._password},get docBaseUrl(){var e=null;if(this._docBaseUrl){var t=c(this._docBaseUrl);t?e=t.href:o('Invalid absolute docBaseUrl: "'+this._docBaseUrl+'".')}return l(this,"docBaseUrl",e)},onLoadedStream:function(){throw new h},ensureDoc:function(e,t){return this.ensure(this.pdfDocument,e,t)},ensureXRef:function(e,t){return this.ensure(this.pdfDocument.xref,e,t)},ensureCatalog:function(e,t){return this.ensure(this.pdfDocument.catalog,e,t)},getPage:function(e){return this.pdfDocument.getPage(e)},cleanup:function(){return this.pdfDocument.cleanup()},ensure:function(e,t,a){return new h},requestRange:function(e,t){return new h},requestLoadedStream:function(){return new h},sendProgressiveData:function(e){return new h},updatePassword:function(e){this._password=e},terminate:function(){return new h}};return e}(),v=function(){function e(e,t,a,r,i){this._docId=e;this._password=a;this._docBaseUrl=i;this.evaluatorOptions=r;var n=new g(t);this.pdfDocument=new m(this,n);this._loadedStreamCapability=f();this._loadedStreamCapability.resolve(n)}d.inherit(e,b,{ensure:function(e,t,a){return new Promise(function(r,i){try{var n,s=e[t];n="function"==typeof s?s.apply(e,a):s;r(n)}catch(e){i(e)}})},requestRange:function(e,t){return Promise.resolve()},requestLoadedStream:function(){},onLoadedStream:function(){return this._loadedStreamCapability.promise},terminate:function(){}});return e}(),y=function(){function e(e,t,a,r,i){this._docId=e;this._password=a.password;this._docBaseUrl=i;this.msgHandler=a.msgHandler;this.evaluatorOptions=r;var n={msgHandler:a.msgHandler,url:a.url,length:a.length,disableAutoFetch:a.disableAutoFetch,rangeChunkSize:a.rangeChunkSize};this.streamManager=new p(t,n);this.pdfDocument=new m(this,this.streamManager.getStream())}d.inherit(e,b,{ensure:function(e,t,a){var r=this;return new Promise(function(i,n){function s(){try{var o,c=e[t];o="function"==typeof c?c.apply(e,a):c;i(o)}catch(e){if(!(e instanceof u)){n(e);return}r.streamManager.requestRange(e.begin,e.end).then(s,n)}}s()})},requestRange:function(e,t){return this.streamManager.requestRange(e,t)},requestLoadedStream:function(){this.streamManager.requestAllChunks()},sendProgressiveData:function(e){this.streamManager.onReceiveData({chunk:e})},onLoadedStream:function(){return this.streamManager.onLoadedStream()},terminate:function(){this.streamManager.abort()}});return e}();t.LocalPdfManager=v;t.NetworkPdfManager=y},function(e,t,a){"use strict";var r=a(0),i=a(1),n=r.error,s=r.isSpace,o=i.EOF,c=function(){function e(e){this.lexer=e;this.operators=[];this.token=null;this.prev=null}e.prototype={nextToken:function(){this.prev=this.token;this.token=this.lexer.getToken()},accept:function(e){if(this.token.type===e){this.nextToken();return!0}return!1},expect:function(e){if(this.accept(e))return!0;n("Unexpected symbol: found "+this.token.type+" expected "+e+".")},parse:function(){this.nextToken();this.expect(l.LBRACE);this.parseBlock();this.expect(l.RBRACE);return this.operators},parseBlock:function(){for(;;)if(this.accept(l.NUMBER))this.operators.push(this.prev.value);else if(this.accept(l.OPERATOR))this.operators.push(this.prev.value);else{if(!this.accept(l.LBRACE))return;this.parseCondition()}},parseCondition:function(){var e=this.operators.length;this.operators.push(null,null);this.parseBlock();this.expect(l.RBRACE);if(this.accept(l.IF)){this.operators[e]=this.operators.length;this.operators[e+1]="jz"}else if(this.accept(l.LBRACE)){var t=this.operators.length;this.operators.push(null,null);var a=this.operators.length;this.parseBlock();this.expect(l.RBRACE);this.expect(l.IFELSE);this.operators[t]=this.operators.length;this.operators[t+1]="j";this.operators[e]=a;this.operators[e+1]="jz"}else n("PS Function: error parsing conditional.")}};return e}(),l={LBRACE:0,RBRACE:1,NUMBER:2,OPERATOR:3,IF:4,
+IFELSE:5},h=function(){function e(e,t){this.type=e;this.value=t}var t=Object.create(null);e.getOperator=function(a){var r=t[a];return r||(t[a]=new e(l.OPERATOR,a))};e.LBRACE=new e(l.LBRACE,"{");e.RBRACE=new e(l.RBRACE,"}");e.IF=new e(l.IF,"IF");e.IFELSE=new e(l.IFELSE,"IFELSE");return e}(),u=function(){function e(e){this.stream=e;this.nextChar();this.strBuf=[]}e.prototype={nextChar:function(){return this.currentChar=this.stream.getByte()},getToken:function(){for(var e=!1,t=this.currentChar;;){if(t<0)return o;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!s(t))break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return new h(l.NUMBER,this.getNumber());case 123:this.nextChar();return h.LBRACE;case 125:this.nextChar();return h.RBRACE}var a=this.strBuf;a.length=0;a[0]=String.fromCharCode(t);for(;(t=this.nextChar())>=0&&(t>=65&&t<=90||t>=97&&t<=122);)a.push(String.fromCharCode(t));var r=a.join("");switch(r.toLowerCase()){case"if":return h.IF;case"ifelse":return h.IFELSE;default:return h.getOperator(r)}},getNumber:function(){var e=this.currentChar,t=this.strBuf;t.length=0;t[0]=String.fromCharCode(e);for(;(e=this.nextChar())>=0&&(e>=48&&e<=57||45===e||46===e);)t.push(String.fromCharCode(e));var a=parseFloat(t.join(""));isNaN(a)&&n("Invalid floating point number: "+a);return a}};return e}();t.PostScriptLexer=u;t.PostScriptParser=c},function(e,t,a){"use strict";var r=a(0),i=a(2),n=a(4),s=r.warn,o=r.isSpace,c=i.Stream,l=n.getEncoding,h=function(){function e(){this.width=0;this.lsb=0;this.flexing=!1;this.output=[];this.stack=[]}var t={hstem:[1],vstem:[3],vmoveto:[4],rlineto:[5],hlineto:[6],vlineto:[7],rrcurveto:[8],callsubr:[10],flex:[12,35],drop:[12,18],endchar:[14],rmoveto:[21],hmoveto:[22],vhcurveto:[30],hvcurveto:[31]};e.prototype={convert:function(e,a,r){for(var i,n,o,c=e.length,l=!1,h=0;h<c;h++){var u=e[h];if(u<32){12===u&&(u=(u<<8)+e[++h]);switch(u){case 1:case 3:this.stack=[];break;case 4:if(this.flexing){if(this.stack.length<1){l=!0;break}var f=this.stack.pop();this.stack.push(0,f);break}l=this.executeCommand(1,t.vmoveto);break;case 5:l=this.executeCommand(2,t.rlineto);break;case 6:l=this.executeCommand(1,t.hlineto);break;case 7:l=this.executeCommand(1,t.vlineto);break;case 8:l=this.executeCommand(6,t.rrcurveto);break;case 9:this.stack=[];break;case 10:if(this.stack.length<1){l=!0;break}o=this.stack.pop();l=this.convert(a[o],a,r);break;case 11:return l;case 13:if(this.stack.length<2){l=!0;break}i=this.stack.pop();n=this.stack.pop();this.lsb=n;this.width=i;this.stack.push(i,n);l=this.executeCommand(2,t.hmoveto);break;case 14:this.output.push(t.endchar[0]);break;case 21:if(this.flexing)break;l=this.executeCommand(2,t.rmoveto);break;case 22:if(this.flexing){this.stack.push(0);break}l=this.executeCommand(1,t.hmoveto);break;case 30:l=this.executeCommand(4,t.vhcurveto);break;case 31:l=this.executeCommand(4,t.hvcurveto);break;case 3072:case 3073:case 3074:this.stack=[];break;case 3078:if(r){this.seac=this.stack.splice(-4,4);l=this.executeCommand(0,t.endchar)}else l=this.executeCommand(4,t.endchar);break;case 3079:if(this.stack.length<4){l=!0;break}this.stack.pop();i=this.stack.pop();var d=this.stack.pop();n=this.stack.pop();this.lsb=n;this.width=i;this.stack.push(i,n,d);l=this.executeCommand(3,t.rmoveto);break;case 3084:if(this.stack.length<2){l=!0;break}var g=this.stack.pop(),p=this.stack.pop();this.stack.push(p/g);break;case 3088:if(this.stack.length<2){l=!0;break}o=this.stack.pop();var m=this.stack.pop();if(0===o&&3===m){var b=this.stack.splice(this.stack.length-17,17);this.stack.push(b[2]+b[0],b[3]+b[1],b[4],b[5],b[6],b[7],b[8],b[9],b[10],b[11],b[12],b[13],b[14]);l=this.executeCommand(13,t.flex,!0);this.flexing=!1;this.stack.push(b[15],b[16])}else 1===o&&0===m&&(this.flexing=!0);break;case 3089:break;case 3105:this.stack=[];break;default:s('Unknown type 1 charstring command of "'+u+'"')}if(l)break}else{u<=246?u-=139:u=u<=250?256*(u-247)+e[++h]+108:u<=254?-256*(u-251)-e[++h]-108:(255&e[++h])<<24|(255&e[++h])<<16|(255&e[++h])<<8|(255&e[++h])<<0;this.stack.push(u)}}return l},executeCommand:function(e,t,a){var r=this.stack.length;if(e>r)return!0;for(var i=r-e,n=i;n<r;n++){var s=this.stack[n];if(s===(0|s))this.output.push(28,s>>8&255,255&s);else{s=65536*s|0;this.output.push(255,s>>24&255,s>>16&255,s>>8&255,255&s)}}this.output.push.apply(this.output,t);a?this.stack.splice(i,e):this.stack.length=0;return!1}};return e}(),u=function(){function e(e){return e>=48&&e<=57||e>=65&&e<=70||e>=97&&e<=102}function t(e,t,a){if(a>=e.length)return new Uint8Array(0);var r,i,n=0|t;for(r=0;r<a;r++)n=52845*(e[r]+n)+22719&65535;var s=e.length-a,o=new Uint8Array(s);for(r=a,i=0;i<s;r++,i++){var c=e[r];o[i]=c^n>>8;n=52845*(c+n)+22719&65535}return o}function a(t,a,r){var i,n,s=0|a,o=t.length,c=o>>>1,l=new Uint8Array(c);for(i=0,n=0;i<o;i++){var h=t[i];if(e(h)){i++;for(var u;i<o&&!e(u=t[i]);)i++;if(i<o){var f=parseInt(String.fromCharCode(h,u),16);l[n++]=f^s>>8;s=52845*(f+s)+22719&65535}}}return Array.prototype.slice.call(l,r,n)}function r(e){return 47===e||91===e||93===e||123===e||125===e||40===e||41===e}function i(r,i,s){if(i){var o=r.getBytes(),l=!(e(o[0])&&e(o[1])&&e(o[2])&&e(o[3]));r=new c(l?t(o,n,4):a(o,n,4))}this.seacAnalysisEnabled=!!s;this.stream=r;this.nextChar()}var n=55665;i.prototype={readNumberArray:function(){this.getToken();for(var e=[];;){var t=this.getToken();if(null===t||"]"===t||"}"===t)break;e.push(parseFloat(t||0))}return e},readNumber:function(){var e=this.getToken();return parseFloat(e||0)},readInt:function(){var e=this.getToken();return 0|parseInt(e||0,10)},readBoolean:function(){return"true"===this.getToken()?1:0},nextChar:function(){return this.currentChar=this.stream.getByte()},getToken:function(){for(var e=!1,t=this.currentChar;;){if(-1===t)return null;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!o(t))break;t=this.nextChar()}if(r(t)){this.nextChar();return String.fromCharCode(t)}var a="";do{a+=String.fromCharCode(t);t=this.nextChar()}while(t>=0&&!o(t)&&!r(t));return a},extractFontProgram:function(){var e=this.stream,a=[],r=[],i=Object.create(null);i.lenIV=4;for(var n,s,o,c,l,u={subrs:[],charstrings:[],properties:{privateData:i}};null!==(n=this.getToken());)if("/"===n){n=this.getToken();switch(n){case"CharStrings":this.getToken();this.getToken();this.getToken();this.getToken();for(;;){n=this.getToken();if(null===n||"end"===n)break;if("/"===n){var f=this.getToken();s=this.readInt();this.getToken();o=e.makeSubStream(e.pos,s);c=u.properties.privateData.lenIV;l=t(o.getBytes(),4330,c);e.skip(s);this.nextChar();n=this.getToken();"noaccess"===n&&this.getToken();r.push({glyph:f,encoded:l})}}break;case"Subrs":this.readInt();this.getToken();for(;"dup"===(n=this.getToken());){var d=this.readInt();s=this.readInt();this.getToken();o=e.makeSubStream(e.pos,s);c=u.properties.privateData.lenIV;l=t(o.getBytes(),4330,c);e.skip(s);this.nextChar();n=this.getToken();"noaccess"===n&&this.getToken();a[d]=l}break;case"BlueValues":case"OtherBlues":case"FamilyBlues":case"FamilyOtherBlues":var g=this.readNumberArray();(g.length>0&&g.length,1)||(u.properties.privateData[n]=g);break;case"StemSnapH":case"StemSnapV":u.properties.privateData[n]=this.readNumberArray();break;case"StdHW":case"StdVW":u.properties.privateData[n]=this.readNumberArray()[0];break;case"BlueShift":case"lenIV":case"BlueFuzz":case"BlueScale":case"LanguageGroup":case"ExpansionFactor":u.properties.privateData[n]=this.readNumber();break;case"ForceBold":u.properties.privateData[n]=this.readBoolean()}}for(var p=0;p<r.length;p++){f=r[p].glyph;l=r[p].encoded;var m=new h,b=m.convert(l,a,this.seacAnalysisEnabled),v=m.output;b&&(v=[14]);u.charstrings.push({glyphName:f,charstring:v,width:m.width,lsb:m.lsb,seac:m.seac})}return u},extractFontHeader:function(e){for(var t;null!==(t=this.getToken());)if("/"===t){t=this.getToken();switch(t){case"FontMatrix":var a=this.readNumberArray();e.fontMatrix=a;break;case"Encoding":var r,i=this.getToken();if(/^\d+$/.test(i)){r=[];var n=0|parseInt(i,10);this.getToken();for(var s=0;s<n;s++){t=this.getToken();for(;"dup"!==t&&"def"!==t;){t=this.getToken();if(null===t)return}if("def"===t)break;var o=this.readInt();this.getToken();var c=this.getToken();r[o]=c;this.getToken()}}else r=l(i);e.builtInEncoding=r;break;case"FontBBox":var h=this.readNumberArray();e.ascent=Math.max(h[3],h[1]);e.descent=Math.min(h[1],h[3]);e.ascentScaled=!0}}}};return i}();t.Type1Parser=u},function(e,t,a){"use strict";var r=a(8);a(19);t.WorkerMessageHandler=r.WorkerMessageHandler},function(e,t,a){"use strict";(function(e){if("undefined"==typeof PDFJS||!PDFJS.compatibilityChecked){var t="undefined"!=typeof window?window:void 0!==e?e:"undefined"!=typeof self?self:void 0,a="undefined"!=typeof navigator&&navigator.userAgent||"",r=/Android/.test(a),i=/Android\s[0-2][^\d]/.test(a),n=/Android\s[0-4][^\d]/.test(a),s=a.indexOf("Chrom")>=0,o=/Chrome\/(39|40)\./.test(a),c=a.indexOf("CriOS")>=0,l=a.indexOf("Trident")>=0,h=/\b(iPad|iPhone|iPod)(?=;)/.test(a),u=a.indexOf("Opera")>=0,f=/Safari\//.test(a)&&!/(Chrome\/|Android\s)/.test(a),d="object"==typeof window&&"object"==typeof document;"undefined"==typeof PDFJS&&(t.PDFJS={});PDFJS.compatibilityChecked=!0;!function(){function e(e,t){return new r(this.slice(e,t))}function a(e,t){arguments.length<2&&(t=0);for(var a=0,r=e.length;a<r;++a,++t)this[t]=255&e[a]}function r(t){var r,i,n;if("number"==typeof t){r=[];for(i=0;i<t;++i)r[i]=0}else if("slice"in t)r=t.slice(0);else{r=[];for(i=0,n=t.length;i<n;++i)r[i]=t[i]}r.subarray=e;r.buffer=r;r.byteLength=r.length;r.set=a;"object"==typeof t&&t.buffer&&(r.buffer=t.buffer);return r}if("undefined"==typeof Uint8Array){t.Uint8Array=r;t.Int8Array=r;t.Uint32Array=r;t.Int32Array=r;t.Uint16Array=r;t.Float32Array=r;t.Float64Array=r}else{if(void 0===Uint8Array.prototype.subarray){Uint8Array.prototype.subarray=function(e,t){return new Uint8Array(this.slice(e,t))};Float32Array.prototype.subarray=function(e,t){return new Float32Array(this.slice(e,t))}}"undefined"==typeof Float64Array&&(t.Float64Array=Float32Array)}}();!function(){t.URL||(t.URL=t.webkitURL)}();!function(){if(void 0!==Object.defineProperty){var e=!0;try{d&&Object.defineProperty(new Image,"id",{value:"test"});var t=function(){};t.prototype={get id(){}};Object.defineProperty(new t,"id",{value:"",configurable:!0,enumerable:!0,writable:!1})}catch(t){e=!1}if(e)return}Object.defineProperty=function(e,t,a){delete e[t];"get"in a&&e.__defineGetter__(t,a.get);"set"in a&&e.__defineSetter__(t,a.set);if("value"in a){e.__defineSetter__(t,function(e){this.__defineGetter__(t,function(){return e});return e});e[t]=a.value}}}();!function(){if("undefined"!=typeof XMLHttpRequest){var e=XMLHttpRequest.prototype,t=new XMLHttpRequest;"overrideMimeType"in t||Object.defineProperty(e,"overrideMimeType",{value:function(e){}});if(!("responseType"in t)){Object.defineProperty(e,"responseType",{get:function(){return this._responseType||"text"},set:function(e){if("text"===e||"arraybuffer"===e){this._responseType=e;"arraybuffer"===e&&"function"==typeof this.overrideMimeType&&this.overrideMimeType("text/plain; charset=x-user-defined")}}});"undefined"==typeof VBArray?Object.defineProperty(e,"response",{get:function(){if("arraybuffer"!==this.responseType)return this.responseText;var e,t=this.responseText,a=t.length,r=new Uint8Array(a);for(e=0;e<a;++e)r[e]=255&t.charCodeAt(e);return r.buffer}}):Object.defineProperty(e,"response",{get:function(){return"arraybuffer"===this.responseType?new Uint8Array(new VBArray(this.responseBody).toArray()):this.responseText}})}}}();!function(){if(!("btoa"in t)){var e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";t.btoa=function(t){var a,r,i="";for(a=0,r=t.length;a<r;a+=3){var n=255&t.charCodeAt(a),s=255&t.charCodeAt(a+1),o=255&t.charCodeAt(a+2),c=n>>2,l=(3&n)<<4|s>>4,h=a+1<r?(15&s)<<2|o>>6:64,u=a+2<r?63&o:64;i+=e.charAt(c)+e.charAt(l)+e.charAt(h)+e.charAt(u)}return i}}}();!function(){if(!("atob"in t)){t.atob=function(e){e=e.replace(/=+$/,"");if(e.length%4==1)throw new Error("bad atob input");for(var t,a,r=0,i=0,n="";a=e.charAt(i++);~a&&(t=r%4?64*t+a:a,r++%4)?n+=String.fromCharCode(255&t>>(-2*r&6)):0)a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".indexOf(a);return n}}}();!function(){void 0===Function.prototype.bind&&(Function.prototype.bind=function(e){var t=this,a=Array.prototype.slice.call(arguments,1);return function(){var r=a.concat(Array.prototype.slice.call(arguments));return t.apply(e,r)}})}();!function(){if(d){"dataset"in document.createElement("div")||Object.defineProperty(HTMLElement.prototype,"dataset",{get:function(){if(this._dataset)return this._dataset;for(var e={},t=0,a=this.attributes.length;t<a;t++){var r=this.attributes[t];if("data-"===r.name.substring(0,5)){e[r.name.substring(5).replace(/\-([a-z])/g,function(e,t){return t.toUpperCase()})]=r.value}}Object.defineProperty(this,"_dataset",{value:e,writable:!1,enumerable:!1});return e},enumerable:!0})}}();!function(){function e(e,t,a,r){var i=e.className||"",n=i.split(/\s+/g);""===n[0]&&n.shift();var s=n.indexOf(t);s<0&&a&&n.push(t);s>=0&&r&&n.splice(s,1);e.className=n.join(" ");return s>=0}if(d){if(!("classList"in document.createElement("div"))){var t={add:function(t){e(this.element,t,!0,!1)},contains:function(t){return e(this.element,t,!1,!1)},remove:function(t){e(this.element,t,!1,!0)},toggle:function(t){e(this.element,t,!0,!0)}};Object.defineProperty(HTMLElement.prototype,"classList",{get:function(){if(this._classList)return this._classList;var e=Object.create(t,{element:{value:this,writable:!1,enumerable:!0}});Object.defineProperty(this,"_classList",{value:e,writable:!1,enumerable:!1});return e},enumerable:!0})}}}();!function(){if(!("undefined"==typeof importScripts||"console"in t)){var e={},a={log:function(){var e=Array.prototype.slice.call(arguments);t.postMessage({targetName:"main",action:"console_log",data:e})},error:function(){var e=Array.prototype.slice.call(arguments);t.postMessage({targetName:"main",action:"console_error",data:e})},time:function(t){e[t]=Date.now()},timeEnd:function(t){var a=e[t];if(!a)throw new Error("Unknown timer name "+t);this.log("Timer:",t,Date.now()-a)}};t.console=a}}();!function(){if(d)if("console"in window)if("bind"in console.log);else{console.log=function(e){return function(t){return e(t)}}(console.log);console.error=function(e){return function(t){return e(t)}}(console.error);console.warn=function(e){return function(t){return e(t)}}(console.warn)}else window.console={log:function(){},error:function(){},warn:function(){}}}();!function(){function e(e){t(e.target)&&e.stopPropagation()}function t(e){return e.disabled||e.parentNode&&t(e.parentNode)}u&&document.addEventListener("click",e,!0)}();!function(){(l||c)&&(PDFJS.disableCreateObjectURL=!0)}();!function(){"undefined"!=typeof navigator&&("language"in navigator||(PDFJS.locale=navigator.userLanguage||"en-US"))}();!function(){if(f||i||o||h){PDFJS.disableRange=!0;PDFJS.disableStream=!0}}();!function(){d&&(history.pushState&&!i||(PDFJS.disableHistory=!0))}();!function(){if(d)if(window.CanvasPixelArray)"function"!=typeof window.CanvasPixelArray.prototype.set&&(window.CanvasPixelArray.prototype.set=function(e){for(var t=0,a=this.length;t<a;t++)this[t]=e[t]});else{var e,t=!1;if(s){e=a.match(/Chrom(e|ium)\/([0-9]+)\./);t=e&&parseInt(e[2])<21}else if(r)t=n;else if(f){e=a.match(/Version\/([0-9]+)\.([0-9]+)\.([0-9]+) Safari\//);t=e&&parseInt(e[1])<6}if(t){var i=window.CanvasRenderingContext2D.prototype,o=i.createImageData;i.createImageData=function(e,t){var a=o.call(this,e,t);a.data.set=function(e){for(var t=0,a=this.length;t<a;t++)this[t]=e[t]};return a};i=null}}}();!function(){function e(){window.requestAnimationFrame=function(e){return window.setTimeout(e,20)};window.cancelAnimationFrame=function(e){window.clearTimeout(e)}}if(d)if(h)e();else if(!("requestAnimationFrame"in window)){window.requestAnimationFrame=window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame;"requestAnimationFrame"in window||e()}}();!function(){(h||r)&&(PDFJS.maxCanvasPixels=5242880)}();!function(){d&&l&&window.parent!==window&&(PDFJS.disableFullscreen=!0)}();!function(){d&&("currentScript"in document||Object.defineProperty(document,"currentScript",{get:function(){var e=document.getElementsByTagName("script");return e[e.length-1]},enumerable:!0,configurable:!0}))}();!function(){if(d){var e=document.createElement("input");try{e.type="number"}catch(r){var t=e.constructor.prototype,a=Object.getOwnPropertyDescriptor(t,"type");Object.defineProperty(t,"type",{get:function(){return a.get.call(this)},set:function(e){a.set.call(this,"number"===e?"text":e)},enumerable:!0,configurable:!0})}}}();!function(){if(d&&document.attachEvent){var e=document.constructor.prototype,t=Object.getOwnPropertyDescriptor(e,"readyState");Object.defineProperty(e,"readyState",{get:function(){var e=t.get.call(this);return"interactive"===e?"loading":e},set:function(e){t.set.call(this,e)},enumerable:!0,configurable:!0})}}();!function(){d&&void 0===Element.prototype.remove&&(Element.prototype.remove=function(){this.parentNode&&this.parentNode.removeChild(this)})}();!function(){if(t.Promise){"function"!=typeof t.Promise.all&&(t.Promise.all=function(e){var a,r,i=0,n=[],s=new t.Promise(function(e,t){a=e;r=t});e.forEach(function(e,t){i++;e.then(function(e){n[t]=e;i--;0===i&&a(n)},r)});0===i&&a(n);return s});"function"!=typeof t.Promise.resolve&&(t.Promise.resolve=function(e){return new t.Promise(function(t){t(e)})});"function"!=typeof t.Promise.reject&&(t.Promise.reject=function(e){return new t.Promise(function(t,a){a(e)})});"function"!=typeof t.Promise.prototype.catch&&(t.Promise.prototype.catch=function(e){return t.Promise.prototype.then(void 0,e)})}else{var e=2,a={handlers:[],running:!1,unhandledRejections:[],pendingRejectionCheck:!1,scheduleHandlers:function(e){if(0!==e._status){this.handlers=this.handlers.concat(e._handlers);e._handlers=[];if(!this.running){this.running=!0;setTimeout(this.runHandlers.bind(this),0)}}},runHandlers:function(){for(var t=Date.now()+1;this.handlers.length>0;){var a=this.handlers.shift(),r=a.thisPromise._status,i=a.thisPromise._value;try{if(1===r)"function"==typeof a.onResolve&&(i=a.onResolve(i));else if("function"==typeof a.onReject){i=a.onReject(i);r=1;a.thisPromise._unhandledRejection&&this.removeUnhandeledRejection(a.thisPromise)}}catch(t){r=e;i=t}a.nextPromise._updateStatus(r,i);if(Date.now()>=t)break}this.handlers.length>0?setTimeout(this.runHandlers.bind(this),0):this.running=!1},addUnhandledRejection:function(e){this.unhandledRejections.push({promise:e,time:Date.now()});this.scheduleRejectionCheck()},removeUnhandeledRejection:function(e){e._unhandledRejection=!1;for(var t=0;t<this.unhandledRejections.length;t++)if(this.unhandledRejections[t].promise===e){this.unhandledRejections.splice(t);t--}},scheduleRejectionCheck:function(){if(!this.pendingRejectionCheck){this.pendingRejectionCheck=!0;setTimeout(function(){this.pendingRejectionCheck=!1;for(var e=Date.now(),t=0;t<this.unhandledRejections.length;t++)if(e-this.unhandledRejections[t].time>500){var a=this.unhandledRejections[t].promise._value,r="Unhandled rejection: "+a;a.stack&&(r+="\n"+a.stack);try{throw new Error(r)}catch(e){console.warn(r)}this.unhandledRejections.splice(t);t--}this.unhandledRejections.length&&this.scheduleRejectionCheck()}.bind(this),500)}}},r=function(e){this._status=0;this._handlers=[];try{e.call(this,this._resolve.bind(this),this._reject.bind(this))}catch(e){this._reject(e)}};r.all=function(t){function a(t){if(s._status!==e){c=[];n(t)}}var i,n,s=new r(function(e,t){i=e;n=t}),o=t.length,c=[];if(0===o){i(c);return s}for(var l=0,h=t.length;l<h;++l){var u=t[l],f=function(t){return function(a){if(s._status!==e){c[t]=a;o--;0===o&&i(c)}}}(l);r.isPromise(u)?u.then(f,a):f(u)}return s};r.isPromise=function(e){return e&&"function"==typeof e.then};r.resolve=function(e){return new r(function(t){t(e)})};r.reject=function(e){return new r(function(t,a){a(e)})};r.prototype={_status:null,_value:null,_handlers:null,_unhandledRejection:null,_updateStatus:function(t,i){if(1!==this._status&&this._status!==e)if(1===t&&r.isPromise(i))i.then(this._updateStatus.bind(this,1),this._updateStatus.bind(this,e));else{this._status=t;this._value=i;if(t===e&&0===this._handlers.length){this._unhandledRejection=!0;a.addUnhandledRejection(this)}a.scheduleHandlers(this)}},_resolve:function(e){this._updateStatus(1,e)},_reject:function(t){this._updateStatus(e,t)},then:function(e,t){var i=new r(function(e,t){this.resolve=e;this.reject=t});this._handlers.push({thisPromise:this,onResolve:e,onReject:t,nextPromise:i});a.scheduleHandlers(this);return i},catch:function(e){return this.then(void 0,e)}};t.Promise=r}}();!function(){function e(){this.id="$weakmap"+a++}if(!t.WeakMap){var a=0;e.prototype={has:function(e){return!!Object.getOwnPropertyDescriptor(e,this.id)},get:function(e,t){return this.has(e)?e[this.id]:t},set:function(e,t){Object.defineProperty(e,this.id,{value:t,enumerable:!1,configurable:!0})},delete:function(e){delete e[this.id]}};t.WeakMap=e}}();!function(){function e(e){return void 0!==u[e]}function a(){o.call(this);this._isInvalid=!0}function r(e){""===e&&a.call(this);return e.toLowerCase()}function i(e){var t=e.charCodeAt(0);return t>32&&t<127&&-1===[34,35,60,62,63,96].indexOf(t)?e:encodeURIComponent(e)}function n(e){var t=e.charCodeAt(0);return t>32&&t<127&&-1===[34,35,60,62,96].indexOf(t)?e:encodeURIComponent(e)}function s(t,s,o){function c(e){y.push(e)}var l=s||"scheme start",h=0,m="",b=!1,v=!1,y=[];e:for(;(t[h-1]!==d||0===h)&&!this._isInvalid;){var k=t[h];switch(l){case"scheme start":if(!k||!g.test(k)){if(s){c("Invalid scheme.");break e}m="";l="no scheme";continue}m+=k.toLowerCase();l="scheme";break;case"scheme":if(k&&p.test(k))m+=k.toLowerCase();else{if(":"!==k){if(s){if(k===d)break e;c("Code point not allowed in scheme: "+k);break e}m="";h=0;l="no scheme";continue}this._scheme=m;m="";if(s)break e;e(this._scheme)&&(this._isRelative=!0);l="file"===this._scheme?"relative":this._isRelative&&o&&o._scheme===this._scheme?"relative or authority":this._isRelative?"authority first slash":"scheme data"}break;case"scheme data":if("?"===k){this._query="?";l="query"}else if("#"===k){this._fragment="#";l="fragment"}else k!==d&&"\t"!==k&&"\n"!==k&&"\r"!==k&&(this._schemeData+=i(k));break;case"no scheme":if(o&&e(o._scheme)){l="relative";continue}c("Missing scheme.");a.call(this);break;case"relative or authority":if("/"!==k||"/"!==t[h+1]){c("Expected /, got: "+k);l="relative";continue}l="authority ignore slashes";break;case"relative":this._isRelative=!0;"file"!==this._scheme&&(this._scheme=o._scheme);if(k===d){this._host=o._host;this._port=o._port;this._path=o._path.slice();this._query=o._query;this._username=o._username;this._password=o._password;break e}if("/"===k||"\\"===k){"\\"===k&&c("\\ is an invalid code point.");l="relative slash"}else if("?"===k){this._host=o._host;this._port=o._port;this._path=o._path.slice();this._query="?";this._username=o._username;this._password=o._password;l="query"}else{if("#"!==k){var w=t[h+1],C=t[h+2];if("file"!==this._scheme||!g.test(k)||":"!==w&&"|"!==w||C!==d&&"/"!==C&&"\\"!==C&&"?"!==C&&"#"!==C){this._host=o._host;this._port=o._port;this._username=o._username;this._password=o._password;this._path=o._path.slice();this._path.pop()}l="relative path";continue}this._host=o._host;this._port=o._port;this._path=o._path.slice();this._query=o._query;this._fragment="#";this._username=o._username;this._password=o._password;l="fragment"}break;case"relative slash":if("/"!==k&&"\\"!==k){if("file"!==this._scheme){this._host=o._host;this._port=o._port;this._username=o._username;this._password=o._password}l="relative path";continue}"\\"===k&&c("\\ is an invalid code point.");l="file"===this._scheme?"file host":"authority ignore slashes";break;case"authority first slash":if("/"!==k){c("Expected '/', got: "+k);l="authority ignore slashes";continue}l="authority second slash";break;case"authority second slash":l="authority ignore slashes";if("/"!==k){c("Expected '/', got: "+k);continue}break;case"authority ignore slashes":if("/"!==k&&"\\"!==k){l="authority";continue}c("Expected authority, got: "+k);break;case"authority":if("@"===k){if(b){c("@ already seen.");m+="%40"}b=!0;for(var x=0;x<m.length;x++){var S=m[x];if("\t"!==S&&"\n"!==S&&"\r"!==S)if(":"!==S||null!==this._password){var A=i(S);null!==this._password?this._password+=A:this._username+=A}else this._password="";else c("Invalid whitespace in authority.")}m=""}else{if(k===d||"/"===k||"\\"===k||"?"===k||"#"===k){h-=m.length;m="";l="host";continue}m+=k}break;case"file host":if(k===d||"/"===k||"\\"===k||"?"===k||"#"===k){if(2!==m.length||!g.test(m[0])||":"!==m[1]&&"|"!==m[1])if(0===m.length)l="relative path start";else{this._host=r.call(this,m);m="";l="relative path start"}else l="relative path";continue}"\t"===k||"\n"===k||"\r"===k?c("Invalid whitespace in file host."):m+=k;break;case"host":case"hostname":if(":"!==k||v){if(k===d||"/"===k||"\\"===k||"?"===k||"#"===k){this._host=r.call(this,m);m="";l="relative path start";if(s)break e;continue}if("\t"!==k&&"\n"!==k&&"\r"!==k){"["===k?v=!0:"]"===k&&(v=!1);m+=k}else c("Invalid code point in host/hostname: "+k)}else{this._host=r.call(this,m);m="";l="port";if("hostname"===s)break e}break;case"port":if(/[0-9]/.test(k))m+=k;else{if(k===d||"/"===k||"\\"===k||"?"===k||"#"===k||s){if(""!==m){var I=parseInt(m,10);I!==u[this._scheme]&&(this._port=I+"");m=""}if(s)break e;l="relative path start";continue}"\t"===k||"\n"===k||"\r"===k?c("Invalid code point in port: "+k):a.call(this)}break;case"relative path start":"\\"===k&&c("'\\' not allowed in path.");l="relative path";if("/"!==k&&"\\"!==k)continue;break;case"relative path":if(k!==d&&"/"!==k&&"\\"!==k&&(s||"?"!==k&&"#"!==k))"\t"!==k&&"\n"!==k&&"\r"!==k&&(m+=i(k));else{"\\"===k&&c("\\ not allowed in relative path.");var B;(B=f[m.toLowerCase()])&&(m=B);if(".."===m){this._path.pop();"/"!==k&&"\\"!==k&&this._path.push("")}else if("."===m&&"/"!==k&&"\\"!==k)this._path.push("");else if("."!==m){"file"===this._scheme&&0===this._path.length&&2===m.length&&g.test(m[0])&&"|"===m[1]&&(m=m[0]+":");this._path.push(m)}m="";if("?"===k){this._query="?";l="query"}else if("#"===k){this._fragment="#";l="fragment"}}break;case"query":if(s||"#"!==k)k!==d&&"\t"!==k&&"\n"!==k&&"\r"!==k&&(this._query+=n(k));else{this._fragment="#";l="fragment"}break;case"fragment":k!==d&&"\t"!==k&&"\n"!==k&&"\r"!==k&&(this._fragment+=k)}h++}}function o(){this._scheme="";this._schemeData="";this._username="";this._password=null;this._host="";this._port="";this._path=[];this._query="";this._fragment="";this._isInvalid=!1;this._isRelative=!1}function c(e,t){void 0===t||t instanceof c||(t=new c(String(t)));this._url=e;o.call(this);var a=e.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g,"");s.call(this,a,null,t)}var l=!1;try{if("function"==typeof URL&&"object"==typeof URL.prototype&&"origin"in URL.prototype){var h=new URL("b","http://a");h.pathname="c%20d";l="http://a/c%20d"===h.href}}catch(e){}if(!l){var u=Object.create(null);u.ftp=21;u.file=0;u.gopher=70;u.http=80;u.https=443;u.ws=80;u.wss=443;var f=Object.create(null);f["%2e"]=".";f[".%2e"]="..";f["%2e."]="..";f["%2e%2e"]="..";var d,g=/[a-zA-Z]/,p=/[a-zA-Z0-9\+\-\.]/;c.prototype={toString:function(){return this.href},get href(){if(this._isInvalid)return this._url;var e="";""===this._username&&null===this._password||(e=this._username+(null!==this._password?":"+this._password:"")+"@");return this.protocol+(this._isRelative?"//"+e+this.host:"")+this.pathname+this._query+this._fragment},set href(e){o.call(this);s.call(this,e)},get protocol(){return this._scheme+":"},set protocol(e){this._isInvalid||s.call(this,e+":","scheme start")},get host(){return this._isInvalid?"":this._port?this._host+":"+this._port:this._host},set host(e){!this._isInvalid&&this._isRelative&&s.call(this,e,"host")},get hostname(){return this._host},set hostname(e){!this._isInvalid&&this._isRelative&&s.call(this,e,"hostname")},get port(){return this._port},set port(e){!this._isInvalid&&this._isRelative&&s.call(this,e,"port")},get pathname(){return this._isInvalid?"":this._isRelative?"/"+this._path.join("/"):this._schemeData},set pathname(e){if(!this._isInvalid&&this._isRelative){this._path=[];s.call(this,e,"relative path start")}},get search(){return this._isInvalid||!this._query||"?"===this._query?"":this._query},set search(e){if(!this._isInvalid&&this._isRelative){this._query="?";"?"===e[0]&&(e=e.slice(1));s.call(this,e,"query")}},get hash(){return this._isInvalid||!this._fragment||"#"===this._fragment?"":this._fragment},set hash(e){if(!this._isInvalid){this._fragment="#";"#"===e[0]&&(e=e.slice(1));s.call(this,e,"fragment")}},get origin(){var e;if(this._isInvalid||!this._scheme)return"";switch(this._scheme){case"data":case"file":case"javascript":case"mailto":return"null"}e=this.host;return e?this._scheme+"://"+e:""}};var m=t.URL;if(m){c.createObjectURL=function(e){return m.createObjectURL.apply(m,arguments)};c.revokeObjectURL=function(e){m.revokeObjectURL(e)}}t.URL=c}}()}}).call(t,a(9))}])}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/pdflab.js b/vendor/assets/javascripts/pdflab.js
deleted file mode 100644
index 5d9c348ce35..00000000000
--- a/vendor/assets/javascripts/pdflab.js
+++ /dev/null
@@ -1,12484 +0,0 @@
-(function webpackUniversalModuleDefinition(root, factory) {
- if(typeof exports === 'object' && typeof module === 'object')
- module.exports = factory();
- else if(typeof define === 'function' && define.amd)
- define("PDFLab", [], factory);
- else if(typeof exports === 'object')
- exports["PDFLab"] = factory();
- else
- root["PDFLab"] = factory();
-})(this, function() {
-return /******/ (function(modules) { // webpackBootstrap
-/******/ // install a JSONP callback for chunk loading
-/******/ var parentJsonpFunction = window["webpackJsonpPDFLab"];
-/******/ window["webpackJsonpPDFLab"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
-/******/ // add "moreModules" to the modules object,
-/******/ // then flag all "chunkIds" as loaded and fire callback
-/******/ var moduleId, chunkId, i = 0, resolves = [], result;
-/******/ for(;i < chunkIds.length; i++) {
-/******/ chunkId = chunkIds[i];
-/******/ if(installedChunks[chunkId])
-/******/ resolves.push(installedChunks[chunkId][0]);
-/******/ installedChunks[chunkId] = 0;
-/******/ }
-/******/ for(moduleId in moreModules) {
-/******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
-/******/ modules[moduleId] = moreModules[moduleId];
-/******/ }
-/******/ }
-/******/ if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
-/******/ while(resolves.length)
-/******/ resolves.shift()();
-/******/
-/******/ };
-/******/
-/******/ // The module cache
-/******/ var installedModules = {};
-/******/
-/******/ // objects to store loaded and loading chunks
-/******/ var installedChunks = {
-/******/ 1: 0,
-/******/ 2: 0
-/******/ };
-/******/
-/******/ // The require function
-/******/ function __webpack_require__(moduleId) {
-/******/
-/******/ // Check if module is in cache
-/******/ if(installedModules[moduleId])
-/******/ return installedModules[moduleId].exports;
-/******/
-/******/ // Create a new module (and put it into the cache)
-/******/ var module = installedModules[moduleId] = {
-/******/ i: moduleId,
-/******/ l: false,
-/******/ exports: {}
-/******/ };
-/******/
-/******/ // Execute the module function
-/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
-/******/
-/******/ // Flag the module as loaded
-/******/ module.l = true;
-/******/
-/******/ // Return the exports of the module
-/******/ return module.exports;
-/******/ }
-/******/
-/******/ // This file contains only the entry chunk.
-/******/ // The chunk loading function for additional chunks
-/******/ __webpack_require__.e = function requireEnsure(chunkId) {
-/******/ if(installedChunks[chunkId] === 0)
-/******/ return Promise.resolve();
-/******/
-/******/ // an Promise means "currently loading".
-/******/ if(installedChunks[chunkId]) {
-/******/ return installedChunks[chunkId][2];
-/******/ }
-/******/ // start chunk loading
-/******/ var head = document.getElementsByTagName('head')[0];
-/******/ var script = document.createElement('script');
-/******/ script.type = 'text/javascript';
-/******/ script.charset = 'utf-8';
-/******/ script.async = true;
-/******/ script.timeout = 120000;
-/******/
-/******/ if (__webpack_require__.nc) {
-/******/ script.setAttribute("nonce", __webpack_require__.nc);
-/******/ }
-/******/ script.src = __webpack_require__.p + "" + chunkId + ".js";
-/******/ var timeout = setTimeout(onScriptComplete, 120000);
-/******/ script.onerror = script.onload = onScriptComplete;
-/******/ function onScriptComplete() {
-/******/ // avoid mem leaks in IE.
-/******/ script.onerror = script.onload = null;
-/******/ clearTimeout(timeout);
-/******/ var chunk = installedChunks[chunkId];
-/******/ if(chunk !== 0) {
-/******/ if(chunk) chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
-/******/ installedChunks[chunkId] = undefined;
-/******/ }
-/******/ };
-/******/
-/******/ var promise = new Promise(function(resolve, reject) {
-/******/ installedChunks[chunkId] = [resolve, reject];
-/******/ });
-/******/ installedChunks[chunkId][2] = promise;
-/******/
-/******/ head.appendChild(script);
-/******/ return promise;
-/******/ };
-/******/
-/******/ // expose the modules object (__webpack_modules__)
-/******/ __webpack_require__.m = modules;
-/******/
-/******/ // expose the module cache
-/******/ __webpack_require__.c = installedModules;
-/******/
-/******/ // identity function for calling harmony imports with the correct context
-/******/ __webpack_require__.i = function(value) { return value; };
-/******/
-/******/ // define getter function for harmony exports
-/******/ __webpack_require__.d = function(exports, name, getter) {
-/******/ if(!__webpack_require__.o(exports, name)) {
-/******/ Object.defineProperty(exports, name, {
-/******/ configurable: false,
-/******/ enumerable: true,
-/******/ get: getter
-/******/ });
-/******/ }
-/******/ };
-/******/
-/******/ // getDefaultExport function for compatibility with non-harmony modules
-/******/ __webpack_require__.n = function(module) {
-/******/ var getter = module && module.__esModule ?
-/******/ function getDefault() { return module['default']; } :
-/******/ function getModuleExports() { return module; };
-/******/ __webpack_require__.d(getter, 'a', getter);
-/******/ return getter;
-/******/ };
-/******/
-/******/ // Object.prototype.hasOwnProperty.call
-/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
-/******/
-/******/ // __webpack_public_path__
-/******/ __webpack_require__.p = "";
-/******/
-/******/ // on error function for async loading
-/******/ __webpack_require__.oe = function(err) { console.error(err); throw err; };
-/******/
-/******/ // Load entry module and return exports
-/******/ return __webpack_require__(__webpack_require__.s = 23);
-/******/ })
-/************************************************************************/
-/******/ ([
-/* 0 */
-/***/ (function(module, exports) {
-
-// shim for using process in browser
-var process = module.exports = {};
-
-// cached from whatever global is present so that test runners that stub it
-// don't break things. But we need to wrap it in a try catch in case it is
-// wrapped in strict mode code which doesn't define any globals. It's inside a
-// function because try/catches deoptimize in certain engines.
-
-var cachedSetTimeout;
-var cachedClearTimeout;
-
-function defaultSetTimout() {
- throw new Error('setTimeout has not been defined');
-}
-function defaultClearTimeout () {
- throw new Error('clearTimeout has not been defined');
-}
-(function () {
- try {
- if (typeof setTimeout === 'function') {
- cachedSetTimeout = setTimeout;
- } else {
- cachedSetTimeout = defaultSetTimout;
- }
- } catch (e) {
- cachedSetTimeout = defaultSetTimout;
- }
- try {
- if (typeof clearTimeout === 'function') {
- cachedClearTimeout = clearTimeout;
- } else {
- cachedClearTimeout = defaultClearTimeout;
- }
- } catch (e) {
- cachedClearTimeout = defaultClearTimeout;
- }
-} ())
-function runTimeout(fun) {
- if (cachedSetTimeout === setTimeout) {
- //normal enviroments in sane situations
- return setTimeout(fun, 0);
- }
- // if setTimeout wasn't available but was latter defined
- if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
- cachedSetTimeout = setTimeout;
- return setTimeout(fun, 0);
- }
- try {
- // when when somebody has screwed with setTimeout but no I.E. maddness
- return cachedSetTimeout(fun, 0);
- } catch(e){
- try {
- // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
- return cachedSetTimeout.call(null, fun, 0);
- } catch(e){
- // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error
- return cachedSetTimeout.call(this, fun, 0);
- }
- }
-
-
-}
-function runClearTimeout(marker) {
- if (cachedClearTimeout === clearTimeout) {
- //normal enviroments in sane situations
- return clearTimeout(marker);
- }
- // if clearTimeout wasn't available but was latter defined
- if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
- cachedClearTimeout = clearTimeout;
- return clearTimeout(marker);
- }
- try {
- // when when somebody has screwed with setTimeout but no I.E. maddness
- return cachedClearTimeout(marker);
- } catch (e){
- try {
- // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
- return cachedClearTimeout.call(null, marker);
- } catch (e){
- // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error.
- // Some versions of I.E. have different rules for clearTimeout vs setTimeout
- return cachedClearTimeout.call(this, marker);
- }
- }
-
-
-
-}
-var queue = [];
-var draining = false;
-var currentQueue;
-var queueIndex = -1;
-
-function cleanUpNextTick() {
- if (!draining || !currentQueue) {
- return;
- }
- draining = false;
- if (currentQueue.length) {
- queue = currentQueue.concat(queue);
- } else {
- queueIndex = -1;
- }
- if (queue.length) {
- drainQueue();
- }
-}
-
-function drainQueue() {
- if (draining) {
- return;
- }
- var timeout = runTimeout(cleanUpNextTick);
- draining = true;
-
- var len = queue.length;
- while(len) {
- currentQueue = queue;
- queue = [];
- while (++queueIndex < len) {
- if (currentQueue) {
- currentQueue[queueIndex].run();
- }
- }
- queueIndex = -1;
- len = queue.length;
- }
- currentQueue = null;
- draining = false;
- runClearTimeout(timeout);
-}
-
-process.nextTick = function (fun) {
- var args = new Array(arguments.length - 1);
- if (arguments.length > 1) {
- for (var i = 1; i < arguments.length; i++) {
- args[i - 1] = arguments[i];
- }
- }
- queue.push(new Item(fun, args));
- if (queue.length === 1 && !draining) {
- runTimeout(drainQueue);
- }
-};
-
-// v8 likes predictible objects
-function Item(fun, array) {
- this.fun = fun;
- this.array = array;
-}
-Item.prototype.run = function () {
- this.fun.apply(null, this.array);
-};
-process.title = 'browser';
-process.browser = true;
-process.env = {};
-process.argv = [];
-process.version = ''; // empty string to avoid regexp issues
-process.versions = {};
-
-function noop() {}
-
-process.on = noop;
-process.addListener = noop;
-process.once = noop;
-process.off = noop;
-process.removeListener = noop;
-process.removeAllListeners = noop;
-process.emit = noop;
-
-process.binding = function (name) {
- throw new Error('process.binding is not supported');
-};
-
-process.cwd = function () { return '/' };
-process.chdir = function (dir) {
- throw new Error('process.chdir is not supported');
-};
-process.umask = function() { return 0; };
-
-
-/***/ }),
-/* 1 */,
-/* 2 */
-/***/ (function(module, exports, __webpack_require__) {
-
-/* WEBPACK VAR INJECTION */(function(process) {/* Copyright 2017 Mozilla Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-(function webpackUniversalModuleDefinition(root, factory) {
- if(true)
- module.exports = factory();
- else if(typeof define === 'function' && define.amd)
- define("pdfjs-dist/build/pdf", [], factory);
- else if(typeof exports === 'object')
- exports["pdfjs-dist/build/pdf"] = factory();
- else
- root["pdfjs-dist/build/pdf"] = root.pdfjsDistBuildPdf = factory();
-})(this, function() {
-return /******/ (function(modules) { // webpackBootstrap
-/******/ // The module cache
-/******/ var installedModules = {};
-/******/
-/******/ // The require function
-/******/ function __w_pdfjs_require__(moduleId) {
-/******/
-/******/ // Check if module is in cache
-/******/ if(installedModules[moduleId])
-/******/ return installedModules[moduleId].exports;
-/******/
-/******/ // Create a new module (and put it into the cache)
-/******/ var module = installedModules[moduleId] = {
-/******/ i: moduleId,
-/******/ l: false,
-/******/ exports: {}
-/******/ };
-/******/
-/******/ // Execute the module function
-/******/ modules[moduleId].call(module.exports, module, module.exports, __w_pdfjs_require__);
-/******/
-/******/ // Flag the module as loaded
-/******/ module.l = true;
-/******/
-/******/ // Return the exports of the module
-/******/ return module.exports;
-/******/ }
-/******/
-/******/
-/******/ // expose the modules object (__webpack_modules__)
-/******/ __w_pdfjs_require__.m = modules;
-/******/
-/******/ // expose the module cache
-/******/ __w_pdfjs_require__.c = installedModules;
-/******/
-/******/ // identity function for calling harmony imports with the correct context
-/******/ __w_pdfjs_require__.i = function(value) { return value; };
-/******/
-/******/ // define getter function for harmony exports
-/******/ __w_pdfjs_require__.d = function(exports, name, getter) {
-/******/ if(!__w_pdfjs_require__.o(exports, name)) {
-/******/ Object.defineProperty(exports, name, {
-/******/ configurable: false,
-/******/ enumerable: true,
-/******/ get: getter
-/******/ });
-/******/ }
-/******/ };
-/******/
-/******/ // getDefaultExport function for compatibility with non-harmony modules
-/******/ __w_pdfjs_require__.n = function(module) {
-/******/ var getter = module && module.__esModule ?
-/******/ function getDefault() { return module['default']; } :
-/******/ function getModuleExports() { return module; };
-/******/ __w_pdfjs_require__.d(getter, 'a', getter);
-/******/ return getter;
-/******/ };
-/******/
-/******/ // Object.prototype.hasOwnProperty.call
-/******/ __w_pdfjs_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
-/******/
-/******/ // __webpack_public_path__
-/******/ __w_pdfjs_require__.p = "";
-/******/
-/******/ // Load entry module and return exports
-/******/ return __w_pdfjs_require__(__w_pdfjs_require__.s = 13);
-/******/ })
-/************************************************************************/
-/******/ ([
-/* 0 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-/* WEBPACK VAR INJECTION */(function(global) {
-
-var compatibility = __w_pdfjs_require__(14);
-var globalScope = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : undefined;
-var FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0];
-var TextRenderingMode = {
- FILL: 0,
- STROKE: 1,
- FILL_STROKE: 2,
- INVISIBLE: 3,
- FILL_ADD_TO_PATH: 4,
- STROKE_ADD_TO_PATH: 5,
- FILL_STROKE_ADD_TO_PATH: 6,
- ADD_TO_PATH: 7,
- FILL_STROKE_MASK: 3,
- ADD_TO_PATH_FLAG: 4
-};
-var ImageKind = {
- GRAYSCALE_1BPP: 1,
- RGB_24BPP: 2,
- RGBA_32BPP: 3
-};
-var AnnotationType = {
- TEXT: 1,
- LINK: 2,
- FREETEXT: 3,
- LINE: 4,
- SQUARE: 5,
- CIRCLE: 6,
- POLYGON: 7,
- POLYLINE: 8,
- HIGHLIGHT: 9,
- UNDERLINE: 10,
- SQUIGGLY: 11,
- STRIKEOUT: 12,
- STAMP: 13,
- CARET: 14,
- INK: 15,
- POPUP: 16,
- FILEATTACHMENT: 17,
- SOUND: 18,
- MOVIE: 19,
- WIDGET: 20,
- SCREEN: 21,
- PRINTERMARK: 22,
- TRAPNET: 23,
- WATERMARK: 24,
- THREED: 25,
- REDACT: 26
-};
-var AnnotationFlag = {
- INVISIBLE: 0x01,
- HIDDEN: 0x02,
- PRINT: 0x04,
- NOZOOM: 0x08,
- NOROTATE: 0x10,
- NOVIEW: 0x20,
- READONLY: 0x40,
- LOCKED: 0x80,
- TOGGLENOVIEW: 0x100,
- LOCKEDCONTENTS: 0x200
-};
-var AnnotationFieldFlag = {
- READONLY: 0x0000001,
- REQUIRED: 0x0000002,
- NOEXPORT: 0x0000004,
- MULTILINE: 0x0001000,
- PASSWORD: 0x0002000,
- NOTOGGLETOOFF: 0x0004000,
- RADIO: 0x0008000,
- PUSHBUTTON: 0x0010000,
- COMBO: 0x0020000,
- EDIT: 0x0040000,
- SORT: 0x0080000,
- FILESELECT: 0x0100000,
- MULTISELECT: 0x0200000,
- DONOTSPELLCHECK: 0x0400000,
- DONOTSCROLL: 0x0800000,
- COMB: 0x1000000,
- RICHTEXT: 0x2000000,
- RADIOSINUNISON: 0x2000000,
- COMMITONSELCHANGE: 0x4000000
-};
-var AnnotationBorderStyleType = {
- SOLID: 1,
- DASHED: 2,
- BEVELED: 3,
- INSET: 4,
- UNDERLINE: 5
-};
-var StreamType = {
- UNKNOWN: 0,
- FLATE: 1,
- LZW: 2,
- DCT: 3,
- JPX: 4,
- JBIG: 5,
- A85: 6,
- AHX: 7,
- CCF: 8,
- RL: 9
-};
-var FontType = {
- UNKNOWN: 0,
- TYPE1: 1,
- TYPE1C: 2,
- CIDFONTTYPE0: 3,
- CIDFONTTYPE0C: 4,
- TRUETYPE: 5,
- CIDFONTTYPE2: 6,
- TYPE3: 7,
- OPENTYPE: 8,
- TYPE0: 9,
- MMTYPE1: 10
-};
-var VERBOSITY_LEVELS = {
- errors: 0,
- warnings: 1,
- infos: 5
-};
-var CMapCompressionType = {
- NONE: 0,
- BINARY: 1,
- STREAM: 2
-};
-var OPS = {
- dependency: 1,
- setLineWidth: 2,
- setLineCap: 3,
- setLineJoin: 4,
- setMiterLimit: 5,
- setDash: 6,
- setRenderingIntent: 7,
- setFlatness: 8,
- setGState: 9,
- save: 10,
- restore: 11,
- transform: 12,
- moveTo: 13,
- lineTo: 14,
- curveTo: 15,
- curveTo2: 16,
- curveTo3: 17,
- closePath: 18,
- rectangle: 19,
- stroke: 20,
- closeStroke: 21,
- fill: 22,
- eoFill: 23,
- fillStroke: 24,
- eoFillStroke: 25,
- closeFillStroke: 26,
- closeEOFillStroke: 27,
- endPath: 28,
- clip: 29,
- eoClip: 30,
- beginText: 31,
- endText: 32,
- setCharSpacing: 33,
- setWordSpacing: 34,
- setHScale: 35,
- setLeading: 36,
- setFont: 37,
- setTextRenderingMode: 38,
- setTextRise: 39,
- moveText: 40,
- setLeadingMoveText: 41,
- setTextMatrix: 42,
- nextLine: 43,
- showText: 44,
- showSpacedText: 45,
- nextLineShowText: 46,
- nextLineSetSpacingShowText: 47,
- setCharWidth: 48,
- setCharWidthAndBounds: 49,
- setStrokeColorSpace: 50,
- setFillColorSpace: 51,
- setStrokeColor: 52,
- setStrokeColorN: 53,
- setFillColor: 54,
- setFillColorN: 55,
- setStrokeGray: 56,
- setFillGray: 57,
- setStrokeRGBColor: 58,
- setFillRGBColor: 59,
- setStrokeCMYKColor: 60,
- setFillCMYKColor: 61,
- shadingFill: 62,
- beginInlineImage: 63,
- beginImageData: 64,
- endInlineImage: 65,
- paintXObject: 66,
- markPoint: 67,
- markPointProps: 68,
- beginMarkedContent: 69,
- beginMarkedContentProps: 70,
- endMarkedContent: 71,
- beginCompat: 72,
- endCompat: 73,
- paintFormXObjectBegin: 74,
- paintFormXObjectEnd: 75,
- beginGroup: 76,
- endGroup: 77,
- beginAnnotations: 78,
- endAnnotations: 79,
- beginAnnotation: 80,
- endAnnotation: 81,
- paintJpegXObject: 82,
- paintImageMaskXObject: 83,
- paintImageMaskXObjectGroup: 84,
- paintImageXObject: 85,
- paintInlineImageXObject: 86,
- paintInlineImageXObjectGroup: 87,
- paintImageXObjectRepeat: 88,
- paintImageMaskXObjectRepeat: 89,
- paintSolidColorImageMask: 90,
- constructPath: 91
-};
-var verbosity = VERBOSITY_LEVELS.warnings;
-function setVerbosityLevel(level) {
- verbosity = level;
-}
-function getVerbosityLevel() {
- return verbosity;
-}
-function info(msg) {
- if (verbosity >= VERBOSITY_LEVELS.infos) {
- console.log('Info: ' + msg);
- }
-}
-function warn(msg) {
- if (verbosity >= VERBOSITY_LEVELS.warnings) {
- console.log('Warning: ' + msg);
- }
-}
-function deprecated(details) {
- console.log('Deprecated API usage: ' + details);
-}
-function error(msg) {
- if (verbosity >= VERBOSITY_LEVELS.errors) {
- console.log('Error: ' + msg);
- console.log(backtrace());
- }
- throw new Error(msg);
-}
-function backtrace() {
- try {
- throw new Error();
- } catch (e) {
- return e.stack ? e.stack.split('\n').slice(2).join('\n') : '';
- }
-}
-function assert(cond, msg) {
- if (!cond) {
- error(msg);
- }
-}
-var UNSUPPORTED_FEATURES = {
- unknown: 'unknown',
- forms: 'forms',
- javaScript: 'javaScript',
- smask: 'smask',
- shadingPattern: 'shadingPattern',
- font: 'font'
-};
-function isSameOrigin(baseUrl, otherUrl) {
- try {
- var base = new URL(baseUrl);
- if (!base.origin || base.origin === 'null') {
- return false;
- }
- } catch (e) {
- return false;
- }
- var other = new URL(otherUrl, base);
- return base.origin === other.origin;
-}
-function isValidProtocol(url) {
- if (!url) {
- return false;
- }
- switch (url.protocol) {
- case 'http:':
- case 'https:':
- case 'ftp:':
- case 'mailto:':
- case 'tel:':
- return true;
- default:
- return false;
- }
-}
-function createValidAbsoluteUrl(url, baseUrl) {
- if (!url) {
- return null;
- }
- try {
- var absoluteUrl = baseUrl ? new URL(url, baseUrl) : new URL(url);
- if (isValidProtocol(absoluteUrl)) {
- return absoluteUrl;
- }
- } catch (ex) {}
- return null;
-}
-function shadow(obj, prop, value) {
- Object.defineProperty(obj, prop, {
- value: value,
- enumerable: true,
- configurable: true,
- writable: false
- });
- return value;
-}
-function getLookupTableFactory(initializer) {
- var lookup;
- return function () {
- if (initializer) {
- lookup = Object.create(null);
- initializer(lookup);
- initializer = null;
- }
- return lookup;
- };
-}
-var PasswordResponses = {
- NEED_PASSWORD: 1,
- INCORRECT_PASSWORD: 2
-};
-var PasswordException = function PasswordExceptionClosure() {
- function PasswordException(msg, code) {
- this.name = 'PasswordException';
- this.message = msg;
- this.code = code;
- }
- PasswordException.prototype = new Error();
- PasswordException.constructor = PasswordException;
- return PasswordException;
-}();
-var UnknownErrorException = function UnknownErrorExceptionClosure() {
- function UnknownErrorException(msg, details) {
- this.name = 'UnknownErrorException';
- this.message = msg;
- this.details = details;
- }
- UnknownErrorException.prototype = new Error();
- UnknownErrorException.constructor = UnknownErrorException;
- return UnknownErrorException;
-}();
-var InvalidPDFException = function InvalidPDFExceptionClosure() {
- function InvalidPDFException(msg) {
- this.name = 'InvalidPDFException';
- this.message = msg;
- }
- InvalidPDFException.prototype = new Error();
- InvalidPDFException.constructor = InvalidPDFException;
- return InvalidPDFException;
-}();
-var MissingPDFException = function MissingPDFExceptionClosure() {
- function MissingPDFException(msg) {
- this.name = 'MissingPDFException';
- this.message = msg;
- }
- MissingPDFException.prototype = new Error();
- MissingPDFException.constructor = MissingPDFException;
- return MissingPDFException;
-}();
-var UnexpectedResponseException = function UnexpectedResponseExceptionClosure() {
- function UnexpectedResponseException(msg, status) {
- this.name = 'UnexpectedResponseException';
- this.message = msg;
- this.status = status;
- }
- UnexpectedResponseException.prototype = new Error();
- UnexpectedResponseException.constructor = UnexpectedResponseException;
- return UnexpectedResponseException;
-}();
-var NotImplementedException = function NotImplementedExceptionClosure() {
- function NotImplementedException(msg) {
- this.message = msg;
- }
- NotImplementedException.prototype = new Error();
- NotImplementedException.prototype.name = 'NotImplementedException';
- NotImplementedException.constructor = NotImplementedException;
- return NotImplementedException;
-}();
-var MissingDataException = function MissingDataExceptionClosure() {
- function MissingDataException(begin, end) {
- this.begin = begin;
- this.end = end;
- this.message = 'Missing data [' + begin + ', ' + end + ')';
- }
- MissingDataException.prototype = new Error();
- MissingDataException.prototype.name = 'MissingDataException';
- MissingDataException.constructor = MissingDataException;
- return MissingDataException;
-}();
-var XRefParseException = function XRefParseExceptionClosure() {
- function XRefParseException(msg) {
- this.message = msg;
- }
- XRefParseException.prototype = new Error();
- XRefParseException.prototype.name = 'XRefParseException';
- XRefParseException.constructor = XRefParseException;
- return XRefParseException;
-}();
-var NullCharactersRegExp = /\x00/g;
-function removeNullCharacters(str) {
- if (typeof str !== 'string') {
- warn('The argument for removeNullCharacters must be a string.');
- return str;
- }
- return str.replace(NullCharactersRegExp, '');
-}
-function bytesToString(bytes) {
- assert(bytes !== null && typeof bytes === 'object' && bytes.length !== undefined, 'Invalid argument for bytesToString');
- var length = bytes.length;
- var MAX_ARGUMENT_COUNT = 8192;
- if (length < MAX_ARGUMENT_COUNT) {
- return String.fromCharCode.apply(null, bytes);
- }
- var strBuf = [];
- for (var i = 0; i < length; i += MAX_ARGUMENT_COUNT) {
- var chunkEnd = Math.min(i + MAX_ARGUMENT_COUNT, length);
- var chunk = bytes.subarray(i, chunkEnd);
- strBuf.push(String.fromCharCode.apply(null, chunk));
- }
- return strBuf.join('');
-}
-function stringToBytes(str) {
- assert(typeof str === 'string', 'Invalid argument for stringToBytes');
- var length = str.length;
- var bytes = new Uint8Array(length);
- for (var i = 0; i < length; ++i) {
- bytes[i] = str.charCodeAt(i) & 0xFF;
- }
- return bytes;
-}
-function arrayByteLength(arr) {
- if (arr.length !== undefined) {
- return arr.length;
- }
- assert(arr.byteLength !== undefined);
- return arr.byteLength;
-}
-function arraysToBytes(arr) {
- if (arr.length === 1 && arr[0] instanceof Uint8Array) {
- return arr[0];
- }
- var resultLength = 0;
- var i,
- ii = arr.length;
- var item, itemLength;
- for (i = 0; i < ii; i++) {
- item = arr[i];
- itemLength = arrayByteLength(item);
- resultLength += itemLength;
- }
- var pos = 0;
- var data = new Uint8Array(resultLength);
- for (i = 0; i < ii; i++) {
- item = arr[i];
- if (!(item instanceof Uint8Array)) {
- if (typeof item === 'string') {
- item = stringToBytes(item);
- } else {
- item = new Uint8Array(item);
- }
- }
- itemLength = item.byteLength;
- data.set(item, pos);
- pos += itemLength;
- }
- return data;
-}
-function string32(value) {
- return String.fromCharCode(value >> 24 & 0xff, value >> 16 & 0xff, value >> 8 & 0xff, value & 0xff);
-}
-function log2(x) {
- var n = 1,
- i = 0;
- while (x > n) {
- n <<= 1;
- i++;
- }
- return i;
-}
-function readInt8(data, start) {
- return data[start] << 24 >> 24;
-}
-function readUint16(data, offset) {
- return data[offset] << 8 | data[offset + 1];
-}
-function readUint32(data, offset) {
- return (data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]) >>> 0;
-}
-function isLittleEndian() {
- var buffer8 = new Uint8Array(2);
- buffer8[0] = 1;
- var buffer16 = new Uint16Array(buffer8.buffer);
- return buffer16[0] === 1;
-}
-function isEvalSupported() {
- try {
- new Function('');
- return true;
- } catch (e) {
- return false;
- }
-}
-var Uint32ArrayView = function Uint32ArrayViewClosure() {
- function Uint32ArrayView(buffer, length) {
- this.buffer = buffer;
- this.byteLength = buffer.length;
- this.length = length === undefined ? this.byteLength >> 2 : length;
- ensureUint32ArrayViewProps(this.length);
- }
- Uint32ArrayView.prototype = Object.create(null);
- var uint32ArrayViewSetters = 0;
- function createUint32ArrayProp(index) {
- return {
- get: function () {
- var buffer = this.buffer,
- offset = index << 2;
- return (buffer[offset] | buffer[offset + 1] << 8 | buffer[offset + 2] << 16 | buffer[offset + 3] << 24) >>> 0;
- },
- set: function (value) {
- var buffer = this.buffer,
- offset = index << 2;
- buffer[offset] = value & 255;
- buffer[offset + 1] = value >> 8 & 255;
- buffer[offset + 2] = value >> 16 & 255;
- buffer[offset + 3] = value >>> 24 & 255;
- }
- };
- }
- function ensureUint32ArrayViewProps(length) {
- while (uint32ArrayViewSetters < length) {
- Object.defineProperty(Uint32ArrayView.prototype, uint32ArrayViewSetters, createUint32ArrayProp(uint32ArrayViewSetters));
- uint32ArrayViewSetters++;
- }
- }
- return Uint32ArrayView;
-}();
-exports.Uint32ArrayView = Uint32ArrayView;
-var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
-var Util = function UtilClosure() {
- function Util() {}
- var rgbBuf = ['rgb(', 0, ',', 0, ',', 0, ')'];
- Util.makeCssRgb = function Util_makeCssRgb(r, g, b) {
- rgbBuf[1] = r;
- rgbBuf[3] = g;
- rgbBuf[5] = b;
- return rgbBuf.join('');
- };
- Util.transform = function Util_transform(m1, m2) {
- return [m1[0] * m2[0] + m1[2] * m2[1], m1[1] * m2[0] + m1[3] * m2[1], m1[0] * m2[2] + m1[2] * m2[3], m1[1] * m2[2] + m1[3] * m2[3], m1[0] * m2[4] + m1[2] * m2[5] + m1[4], m1[1] * m2[4] + m1[3] * m2[5] + m1[5]];
- };
- Util.applyTransform = function Util_applyTransform(p, m) {
- var xt = p[0] * m[0] + p[1] * m[2] + m[4];
- var yt = p[0] * m[1] + p[1] * m[3] + m[5];
- return [xt, yt];
- };
- Util.applyInverseTransform = function Util_applyInverseTransform(p, m) {
- var d = m[0] * m[3] - m[1] * m[2];
- var xt = (p[0] * m[3] - p[1] * m[2] + m[2] * m[5] - m[4] * m[3]) / d;
- var yt = (-p[0] * m[1] + p[1] * m[0] + m[4] * m[1] - m[5] * m[0]) / d;
- return [xt, yt];
- };
- Util.getAxialAlignedBoundingBox = function Util_getAxialAlignedBoundingBox(r, m) {
- var p1 = Util.applyTransform(r, m);
- var p2 = Util.applyTransform(r.slice(2, 4), m);
- var p3 = Util.applyTransform([r[0], r[3]], m);
- var p4 = Util.applyTransform([r[2], r[1]], m);
- return [Math.min(p1[0], p2[0], p3[0], p4[0]), Math.min(p1[1], p2[1], p3[1], p4[1]), Math.max(p1[0], p2[0], p3[0], p4[0]), Math.max(p1[1], p2[1], p3[1], p4[1])];
- };
- Util.inverseTransform = function Util_inverseTransform(m) {
- var d = m[0] * m[3] - m[1] * m[2];
- return [m[3] / d, -m[1] / d, -m[2] / d, m[0] / d, (m[2] * m[5] - m[4] * m[3]) / d, (m[4] * m[1] - m[5] * m[0]) / d];
- };
- Util.apply3dTransform = function Util_apply3dTransform(m, v) {
- return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
- };
- Util.singularValueDecompose2dScale = function Util_singularValueDecompose2dScale(m) {
- var transpose = [m[0], m[2], m[1], m[3]];
- var a = m[0] * transpose[0] + m[1] * transpose[2];
- var b = m[0] * transpose[1] + m[1] * transpose[3];
- var c = m[2] * transpose[0] + m[3] * transpose[2];
- var d = m[2] * transpose[1] + m[3] * transpose[3];
- var first = (a + d) / 2;
- var second = Math.sqrt((a + d) * (a + d) - 4 * (a * d - c * b)) / 2;
- var sx = first + second || 1;
- var sy = first - second || 1;
- return [Math.sqrt(sx), Math.sqrt(sy)];
- };
- Util.normalizeRect = function Util_normalizeRect(rect) {
- var r = rect.slice(0);
- if (rect[0] > rect[2]) {
- r[0] = rect[2];
- r[2] = rect[0];
- }
- if (rect[1] > rect[3]) {
- r[1] = rect[3];
- r[3] = rect[1];
- }
- return r;
- };
- Util.intersect = function Util_intersect(rect1, rect2) {
- function compare(a, b) {
- return a - b;
- }
- var orderedX = [rect1[0], rect1[2], rect2[0], rect2[2]].sort(compare),
- orderedY = [rect1[1], rect1[3], rect2[1], rect2[3]].sort(compare),
- result = [];
- rect1 = Util.normalizeRect(rect1);
- rect2 = Util.normalizeRect(rect2);
- if (orderedX[0] === rect1[0] && orderedX[1] === rect2[0] || orderedX[0] === rect2[0] && orderedX[1] === rect1[0]) {
- result[0] = orderedX[1];
- result[2] = orderedX[2];
- } else {
- return false;
- }
- if (orderedY[0] === rect1[1] && orderedY[1] === rect2[1] || orderedY[0] === rect2[1] && orderedY[1] === rect1[1]) {
- result[1] = orderedY[1];
- result[3] = orderedY[2];
- } else {
- return false;
- }
- return result;
- };
- Util.sign = function Util_sign(num) {
- return num < 0 ? -1 : 1;
- };
- var ROMAN_NUMBER_MAP = ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM', '', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC', '', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'];
- Util.toRoman = function Util_toRoman(number, lowerCase) {
- assert(isInt(number) && number > 0, 'The number should be a positive integer.');
- var pos,
- romanBuf = [];
- while (number >= 1000) {
- number -= 1000;
- romanBuf.push('M');
- }
- pos = number / 100 | 0;
- number %= 100;
- romanBuf.push(ROMAN_NUMBER_MAP[pos]);
- pos = number / 10 | 0;
- number %= 10;
- romanBuf.push(ROMAN_NUMBER_MAP[10 + pos]);
- romanBuf.push(ROMAN_NUMBER_MAP[20 + number]);
- var romanStr = romanBuf.join('');
- return lowerCase ? romanStr.toLowerCase() : romanStr;
- };
- Util.appendToArray = function Util_appendToArray(arr1, arr2) {
- Array.prototype.push.apply(arr1, arr2);
- };
- Util.prependToArray = function Util_prependToArray(arr1, arr2) {
- Array.prototype.unshift.apply(arr1, arr2);
- };
- Util.extendObj = function extendObj(obj1, obj2) {
- for (var key in obj2) {
- obj1[key] = obj2[key];
- }
- };
- Util.getInheritableProperty = function Util_getInheritableProperty(dict, name, getArray) {
- while (dict && !dict.has(name)) {
- dict = dict.get('Parent');
- }
- if (!dict) {
- return null;
- }
- return getArray ? dict.getArray(name) : dict.get(name);
- };
- Util.inherit = function Util_inherit(sub, base, prototype) {
- sub.prototype = Object.create(base.prototype);
- sub.prototype.constructor = sub;
- for (var prop in prototype) {
- sub.prototype[prop] = prototype[prop];
- }
- };
- Util.loadScript = function Util_loadScript(src, callback) {
- var script = document.createElement('script');
- var loaded = false;
- script.setAttribute('src', src);
- if (callback) {
- script.onload = function () {
- if (!loaded) {
- callback();
- }
- loaded = true;
- };
- }
- document.getElementsByTagName('head')[0].appendChild(script);
- };
- return Util;
-}();
-var PageViewport = function PageViewportClosure() {
- function PageViewport(viewBox, scale, rotation, offsetX, offsetY, dontFlip) {
- this.viewBox = viewBox;
- this.scale = scale;
- this.rotation = rotation;
- this.offsetX = offsetX;
- this.offsetY = offsetY;
- var centerX = (viewBox[2] + viewBox[0]) / 2;
- var centerY = (viewBox[3] + viewBox[1]) / 2;
- var rotateA, rotateB, rotateC, rotateD;
- rotation = rotation % 360;
- rotation = rotation < 0 ? rotation + 360 : rotation;
- switch (rotation) {
- case 180:
- rotateA = -1;
- rotateB = 0;
- rotateC = 0;
- rotateD = 1;
- break;
- case 90:
- rotateA = 0;
- rotateB = 1;
- rotateC = 1;
- rotateD = 0;
- break;
- case 270:
- rotateA = 0;
- rotateB = -1;
- rotateC = -1;
- rotateD = 0;
- break;
- default:
- rotateA = 1;
- rotateB = 0;
- rotateC = 0;
- rotateD = -1;
- break;
- }
- if (dontFlip) {
- rotateC = -rotateC;
- rotateD = -rotateD;
- }
- var offsetCanvasX, offsetCanvasY;
- var width, height;
- if (rotateA === 0) {
- offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
- offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
- width = Math.abs(viewBox[3] - viewBox[1]) * scale;
- height = Math.abs(viewBox[2] - viewBox[0]) * scale;
- } else {
- offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
- offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
- width = Math.abs(viewBox[2] - viewBox[0]) * scale;
- height = Math.abs(viewBox[3] - viewBox[1]) * scale;
- }
- this.transform = [rotateA * scale, rotateB * scale, rotateC * scale, rotateD * scale, offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY];
- this.width = width;
- this.height = height;
- this.fontScale = scale;
- }
- PageViewport.prototype = {
- clone: function PageViewPort_clone(args) {
- args = args || {};
- var scale = 'scale' in args ? args.scale : this.scale;
- var rotation = 'rotation' in args ? args.rotation : this.rotation;
- return new PageViewport(this.viewBox.slice(), scale, rotation, this.offsetX, this.offsetY, args.dontFlip);
- },
- convertToViewportPoint: function PageViewport_convertToViewportPoint(x, y) {
- return Util.applyTransform([x, y], this.transform);
- },
- convertToViewportRectangle: function PageViewport_convertToViewportRectangle(rect) {
- var tl = Util.applyTransform([rect[0], rect[1]], this.transform);
- var br = Util.applyTransform([rect[2], rect[3]], this.transform);
- return [tl[0], tl[1], br[0], br[1]];
- },
- convertToPdfPoint: function PageViewport_convertToPdfPoint(x, y) {
- return Util.applyInverseTransform([x, y], this.transform);
- }
- };
- return PageViewport;
-}();
-var PDFStringTranslateTable = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2D8, 0x2C7, 0x2C6, 0x2D9, 0x2DD, 0x2DB, 0x2DA, 0x2DC, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x192, 0x2044, 0x2039, 0x203A, 0x2212, 0x2030, 0x201E, 0x201C, 0x201D, 0x2018, 0x2019, 0x201A, 0x2122, 0xFB01, 0xFB02, 0x141, 0x152, 0x160, 0x178, 0x17D, 0x131, 0x142, 0x153, 0x161, 0x17E, 0, 0x20AC];
-function stringToPDFString(str) {
- var i,
- n = str.length,
- strBuf = [];
- if (str[0] === '\xFE' && str[1] === '\xFF') {
- for (i = 2; i < n; i += 2) {
- strBuf.push(String.fromCharCode(str.charCodeAt(i) << 8 | str.charCodeAt(i + 1)));
- }
- } else {
- for (i = 0; i < n; ++i) {
- var code = PDFStringTranslateTable[str.charCodeAt(i)];
- strBuf.push(code ? String.fromCharCode(code) : str.charAt(i));
- }
- }
- return strBuf.join('');
-}
-function stringToUTF8String(str) {
- return decodeURIComponent(escape(str));
-}
-function utf8StringToString(str) {
- return unescape(encodeURIComponent(str));
-}
-function isEmptyObj(obj) {
- for (var key in obj) {
- return false;
- }
- return true;
-}
-function isBool(v) {
- return typeof v === 'boolean';
-}
-function isInt(v) {
- return typeof v === 'number' && (v | 0) === v;
-}
-function isNum(v) {
- return typeof v === 'number';
-}
-function isString(v) {
- return typeof v === 'string';
-}
-function isArray(v) {
- return v instanceof Array;
-}
-function isArrayBuffer(v) {
- return typeof v === 'object' && v !== null && v.byteLength !== undefined;
-}
-function isSpace(ch) {
- return ch === 0x20 || ch === 0x09 || ch === 0x0D || ch === 0x0A;
-}
-function isNodeJS() {
- if (typeof __pdfjsdev_webpack__ === 'undefined') {
- return typeof process === 'object' && process + '' === '[object process]';
- }
- return false;
-}
-function createPromiseCapability() {
- var capability = {};
- capability.promise = new Promise(function (resolve, reject) {
- capability.resolve = resolve;
- capability.reject = reject;
- });
- return capability;
-}
-var StatTimer = function StatTimerClosure() {
- function rpad(str, pad, length) {
- while (str.length < length) {
- str += pad;
- }
- return str;
- }
- function StatTimer() {
- this.started = Object.create(null);
- this.times = [];
- this.enabled = true;
- }
- StatTimer.prototype = {
- time: function StatTimer_time(name) {
- if (!this.enabled) {
- return;
- }
- if (name in this.started) {
- warn('Timer is already running for ' + name);
- }
- this.started[name] = Date.now();
- },
- timeEnd: function StatTimer_timeEnd(name) {
- if (!this.enabled) {
- return;
- }
- if (!(name in this.started)) {
- warn('Timer has not been started for ' + name);
- }
- this.times.push({
- 'name': name,
- 'start': this.started[name],
- 'end': Date.now()
- });
- delete this.started[name];
- },
- toString: function StatTimer_toString() {
- var i, ii;
- var times = this.times;
- var out = '';
- var longest = 0;
- for (i = 0, ii = times.length; i < ii; ++i) {
- var name = times[i]['name'];
- if (name.length > longest) {
- longest = name.length;
- }
- }
- for (i = 0, ii = times.length; i < ii; ++i) {
- var span = times[i];
- var duration = span.end - span.start;
- out += rpad(span['name'], ' ', longest) + ' ' + duration + 'ms\n';
- }
- return out;
- }
- };
- return StatTimer;
-}();
-var createBlob = function createBlob(data, contentType) {
- if (typeof Blob !== 'undefined') {
- return new Blob([data], { type: contentType });
- }
- warn('The "Blob" constructor is not supported.');
-};
-var createObjectURL = function createObjectURLClosure() {
- var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
- return function createObjectURL(data, contentType, forceDataSchema) {
- if (!forceDataSchema && typeof URL !== 'undefined' && URL.createObjectURL) {
- var blob = createBlob(data, contentType);
- return URL.createObjectURL(blob);
- }
- var buffer = 'data:' + contentType + ';base64,';
- for (var i = 0, ii = data.length; i < ii; i += 3) {
- var b1 = data[i] & 0xFF;
- var b2 = data[i + 1] & 0xFF;
- var b3 = data[i + 2] & 0xFF;
- var d1 = b1 >> 2,
- d2 = (b1 & 3) << 4 | b2 >> 4;
- var d3 = i + 1 < ii ? (b2 & 0xF) << 2 | b3 >> 6 : 64;
- var d4 = i + 2 < ii ? b3 & 0x3F : 64;
- buffer += digits[d1] + digits[d2] + digits[d3] + digits[d4];
- }
- return buffer;
- };
-}();
-function MessageHandler(sourceName, targetName, comObj) {
- this.sourceName = sourceName;
- this.targetName = targetName;
- this.comObj = comObj;
- this.callbackIndex = 1;
- this.postMessageTransfers = true;
- var callbacksCapabilities = this.callbacksCapabilities = Object.create(null);
- var ah = this.actionHandler = Object.create(null);
- this._onComObjOnMessage = function messageHandlerComObjOnMessage(event) {
- var data = event.data;
- if (data.targetName !== this.sourceName) {
- return;
- }
- if (data.isReply) {
- var callbackId = data.callbackId;
- if (data.callbackId in callbacksCapabilities) {
- var callback = callbacksCapabilities[callbackId];
- delete callbacksCapabilities[callbackId];
- if ('error' in data) {
- callback.reject(data.error);
- } else {
- callback.resolve(data.data);
- }
- } else {
- error('Cannot resolve callback ' + callbackId);
- }
- } else if (data.action in ah) {
- var action = ah[data.action];
- if (data.callbackId) {
- var sourceName = this.sourceName;
- var targetName = data.sourceName;
- Promise.resolve().then(function () {
- return action[0].call(action[1], data.data);
- }).then(function (result) {
- comObj.postMessage({
- sourceName: sourceName,
- targetName: targetName,
- isReply: true,
- callbackId: data.callbackId,
- data: result
- });
- }, function (reason) {
- if (reason instanceof Error) {
- reason = reason + '';
- }
- comObj.postMessage({
- sourceName: sourceName,
- targetName: targetName,
- isReply: true,
- callbackId: data.callbackId,
- error: reason
- });
- });
- } else {
- action[0].call(action[1], data.data);
- }
- } else {
- error('Unknown action from worker: ' + data.action);
- }
- }.bind(this);
- comObj.addEventListener('message', this._onComObjOnMessage);
-}
-MessageHandler.prototype = {
- on: function messageHandlerOn(actionName, handler, scope) {
- var ah = this.actionHandler;
- if (ah[actionName]) {
- error('There is already an actionName called "' + actionName + '"');
- }
- ah[actionName] = [handler, scope];
- },
- send: function messageHandlerSend(actionName, data, transfers) {
- var message = {
- sourceName: this.sourceName,
- targetName: this.targetName,
- action: actionName,
- data: data
- };
- this.postMessage(message, transfers);
- },
- sendWithPromise: function messageHandlerSendWithPromise(actionName, data, transfers) {
- var callbackId = this.callbackIndex++;
- var message = {
- sourceName: this.sourceName,
- targetName: this.targetName,
- action: actionName,
- data: data,
- callbackId: callbackId
- };
- var capability = createPromiseCapability();
- this.callbacksCapabilities[callbackId] = capability;
- try {
- this.postMessage(message, transfers);
- } catch (e) {
- capability.reject(e);
- }
- return capability.promise;
- },
- postMessage: function (message, transfers) {
- if (transfers && this.postMessageTransfers) {
- this.comObj.postMessage(message, transfers);
- } else {
- this.comObj.postMessage(message);
- }
- },
- destroy: function () {
- this.comObj.removeEventListener('message', this._onComObjOnMessage);
- }
-};
-function loadJpegStream(id, imageUrl, objs) {
- var img = new Image();
- img.onload = function loadJpegStream_onloadClosure() {
- objs.resolve(id, img);
- };
- img.onerror = function loadJpegStream_onerrorClosure() {
- objs.resolve(id, null);
- warn('Error during JPEG image loading');
- };
- img.src = imageUrl;
-}
-exports.FONT_IDENTITY_MATRIX = FONT_IDENTITY_MATRIX;
-exports.IDENTITY_MATRIX = IDENTITY_MATRIX;
-exports.OPS = OPS;
-exports.VERBOSITY_LEVELS = VERBOSITY_LEVELS;
-exports.UNSUPPORTED_FEATURES = UNSUPPORTED_FEATURES;
-exports.AnnotationBorderStyleType = AnnotationBorderStyleType;
-exports.AnnotationFieldFlag = AnnotationFieldFlag;
-exports.AnnotationFlag = AnnotationFlag;
-exports.AnnotationType = AnnotationType;
-exports.FontType = FontType;
-exports.ImageKind = ImageKind;
-exports.CMapCompressionType = CMapCompressionType;
-exports.InvalidPDFException = InvalidPDFException;
-exports.MessageHandler = MessageHandler;
-exports.MissingDataException = MissingDataException;
-exports.MissingPDFException = MissingPDFException;
-exports.NotImplementedException = NotImplementedException;
-exports.PageViewport = PageViewport;
-exports.PasswordException = PasswordException;
-exports.PasswordResponses = PasswordResponses;
-exports.StatTimer = StatTimer;
-exports.StreamType = StreamType;
-exports.TextRenderingMode = TextRenderingMode;
-exports.UnexpectedResponseException = UnexpectedResponseException;
-exports.UnknownErrorException = UnknownErrorException;
-exports.Util = Util;
-exports.XRefParseException = XRefParseException;
-exports.arrayByteLength = arrayByteLength;
-exports.arraysToBytes = arraysToBytes;
-exports.assert = assert;
-exports.bytesToString = bytesToString;
-exports.createBlob = createBlob;
-exports.createPromiseCapability = createPromiseCapability;
-exports.createObjectURL = createObjectURL;
-exports.deprecated = deprecated;
-exports.error = error;
-exports.getLookupTableFactory = getLookupTableFactory;
-exports.getVerbosityLevel = getVerbosityLevel;
-exports.globalScope = globalScope;
-exports.info = info;
-exports.isArray = isArray;
-exports.isArrayBuffer = isArrayBuffer;
-exports.isBool = isBool;
-exports.isEmptyObj = isEmptyObj;
-exports.isInt = isInt;
-exports.isNum = isNum;
-exports.isString = isString;
-exports.isSpace = isSpace;
-exports.isNodeJS = isNodeJS;
-exports.isSameOrigin = isSameOrigin;
-exports.createValidAbsoluteUrl = createValidAbsoluteUrl;
-exports.isLittleEndian = isLittleEndian;
-exports.isEvalSupported = isEvalSupported;
-exports.loadJpegStream = loadJpegStream;
-exports.log2 = log2;
-exports.readInt8 = readInt8;
-exports.readUint16 = readUint16;
-exports.readUint32 = readUint32;
-exports.removeNullCharacters = removeNullCharacters;
-exports.setVerbosityLevel = setVerbosityLevel;
-exports.shadow = shadow;
-exports.string32 = string32;
-exports.stringToBytes = stringToBytes;
-exports.stringToPDFString = stringToPDFString;
-exports.stringToUTF8String = stringToUTF8String;
-exports.utf8StringToString = utf8StringToString;
-exports.warn = warn;
-/* WEBPACK VAR INJECTION */}.call(exports, __w_pdfjs_require__(6)))
-
-/***/ }),
-/* 1 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var assert = sharedUtil.assert;
-var removeNullCharacters = sharedUtil.removeNullCharacters;
-var warn = sharedUtil.warn;
-var deprecated = sharedUtil.deprecated;
-var createValidAbsoluteUrl = sharedUtil.createValidAbsoluteUrl;
-var stringToBytes = sharedUtil.stringToBytes;
-var CMapCompressionType = sharedUtil.CMapCompressionType;
-var DEFAULT_LINK_REL = 'noopener noreferrer nofollow';
-function DOMCanvasFactory() {}
-DOMCanvasFactory.prototype = {
- create: function DOMCanvasFactory_create(width, height) {
- assert(width > 0 && height > 0, 'invalid canvas size');
- var canvas = document.createElement('canvas');
- var context = canvas.getContext('2d');
- canvas.width = width;
- canvas.height = height;
- return {
- canvas: canvas,
- context: context
- };
- },
- reset: function DOMCanvasFactory_reset(canvasAndContextPair, width, height) {
- assert(canvasAndContextPair.canvas, 'canvas is not specified');
- assert(width > 0 && height > 0, 'invalid canvas size');
- canvasAndContextPair.canvas.width = width;
- canvasAndContextPair.canvas.height = height;
- },
- destroy: function DOMCanvasFactory_destroy(canvasAndContextPair) {
- assert(canvasAndContextPair.canvas, 'canvas is not specified');
- canvasAndContextPair.canvas.width = 0;
- canvasAndContextPair.canvas.height = 0;
- canvasAndContextPair.canvas = null;
- canvasAndContextPair.context = null;
- }
-};
-var DOMCMapReaderFactory = function DOMCMapReaderFactoryClosure() {
- function DOMCMapReaderFactory(params) {
- this.baseUrl = params.baseUrl || null;
- this.isCompressed = params.isCompressed || false;
- }
- DOMCMapReaderFactory.prototype = {
- fetch: function (params) {
- var name = params.name;
- if (!name) {
- return Promise.reject(new Error('CMap name must be specified.'));
- }
- return new Promise(function (resolve, reject) {
- var url = this.baseUrl + name + (this.isCompressed ? '.bcmap' : '');
- var request = new XMLHttpRequest();
- request.open('GET', url, true);
- if (this.isCompressed) {
- request.responseType = 'arraybuffer';
- }
- request.onreadystatechange = function () {
- if (request.readyState !== XMLHttpRequest.DONE) {
- return;
- }
- if (request.status === 200 || request.status === 0) {
- var data;
- if (this.isCompressed && request.response) {
- data = new Uint8Array(request.response);
- } else if (!this.isCompressed && request.responseText) {
- data = stringToBytes(request.responseText);
- }
- if (data) {
- resolve({
- cMapData: data,
- compressionType: this.isCompressed ? CMapCompressionType.BINARY : CMapCompressionType.NONE
- });
- return;
- }
- }
- reject(new Error('Unable to load ' + (this.isCompressed ? 'binary ' : '') + 'CMap at: ' + url));
- }.bind(this);
- request.send(null);
- }.bind(this));
- }
- };
- return DOMCMapReaderFactory;
-}();
-var CustomStyle = function CustomStyleClosure() {
- var prefixes = ['ms', 'Moz', 'Webkit', 'O'];
- var _cache = Object.create(null);
- function CustomStyle() {}
- CustomStyle.getProp = function get(propName, element) {
- if (arguments.length === 1 && typeof _cache[propName] === 'string') {
- return _cache[propName];
- }
- element = element || document.documentElement;
- var style = element.style,
- prefixed,
- uPropName;
- if (typeof style[propName] === 'string') {
- return _cache[propName] = propName;
- }
- uPropName = propName.charAt(0).toUpperCase() + propName.slice(1);
- for (var i = 0, l = prefixes.length; i < l; i++) {
- prefixed = prefixes[i] + uPropName;
- if (typeof style[prefixed] === 'string') {
- return _cache[propName] = prefixed;
- }
- }
- return _cache[propName] = 'undefined';
- };
- CustomStyle.setProp = function set(propName, element, str) {
- var prop = this.getProp(propName);
- if (prop !== 'undefined') {
- element.style[prop] = str;
- }
- };
- return CustomStyle;
-}();
-var RenderingCancelledException = function RenderingCancelledException() {
- function RenderingCancelledException(msg, type) {
- this.message = msg;
- this.type = type;
- }
- RenderingCancelledException.prototype = new Error();
- RenderingCancelledException.prototype.name = 'RenderingCancelledException';
- RenderingCancelledException.constructor = RenderingCancelledException;
- return RenderingCancelledException;
-}();
-var hasCanvasTypedArrays;
-hasCanvasTypedArrays = function hasCanvasTypedArrays() {
- var canvas = document.createElement('canvas');
- canvas.width = canvas.height = 1;
- var ctx = canvas.getContext('2d');
- var imageData = ctx.createImageData(1, 1);
- return typeof imageData.data.buffer !== 'undefined';
-};
-var LinkTarget = {
- NONE: 0,
- SELF: 1,
- BLANK: 2,
- PARENT: 3,
- TOP: 4
-};
-var LinkTargetStringMap = ['', '_self', '_blank', '_parent', '_top'];
-function addLinkAttributes(link, params) {
- var url = params && params.url;
- link.href = link.title = url ? removeNullCharacters(url) : '';
- if (url) {
- var target = params.target;
- if (typeof target === 'undefined') {
- target = getDefaultSetting('externalLinkTarget');
- }
- link.target = LinkTargetStringMap[target];
- var rel = params.rel;
- if (typeof rel === 'undefined') {
- rel = getDefaultSetting('externalLinkRel');
- }
- link.rel = rel;
- }
-}
-function getFilenameFromUrl(url) {
- var anchor = url.indexOf('#');
- var query = url.indexOf('?');
- var end = Math.min(anchor > 0 ? anchor : url.length, query > 0 ? query : url.length);
- return url.substring(url.lastIndexOf('/', end) + 1, end);
-}
-function getDefaultSetting(id) {
- var globalSettings = sharedUtil.globalScope.PDFJS;
- switch (id) {
- case 'pdfBug':
- return globalSettings ? globalSettings.pdfBug : false;
- case 'disableAutoFetch':
- return globalSettings ? globalSettings.disableAutoFetch : false;
- case 'disableStream':
- return globalSettings ? globalSettings.disableStream : false;
- case 'disableRange':
- return globalSettings ? globalSettings.disableRange : false;
- case 'disableFontFace':
- return globalSettings ? globalSettings.disableFontFace : false;
- case 'disableCreateObjectURL':
- return globalSettings ? globalSettings.disableCreateObjectURL : false;
- case 'disableWebGL':
- return globalSettings ? globalSettings.disableWebGL : true;
- case 'cMapUrl':
- return globalSettings ? globalSettings.cMapUrl : null;
- case 'cMapPacked':
- return globalSettings ? globalSettings.cMapPacked : false;
- case 'postMessageTransfers':
- return globalSettings ? globalSettings.postMessageTransfers : true;
- case 'workerPort':
- return globalSettings ? globalSettings.workerPort : null;
- case 'workerSrc':
- return globalSettings ? globalSettings.workerSrc : null;
- case 'disableWorker':
- return globalSettings ? globalSettings.disableWorker : false;
- case 'maxImageSize':
- return globalSettings ? globalSettings.maxImageSize : -1;
- case 'imageResourcesPath':
- return globalSettings ? globalSettings.imageResourcesPath : '';
- case 'isEvalSupported':
- return globalSettings ? globalSettings.isEvalSupported : true;
- case 'externalLinkTarget':
- if (!globalSettings) {
- return LinkTarget.NONE;
- }
- switch (globalSettings.externalLinkTarget) {
- case LinkTarget.NONE:
- case LinkTarget.SELF:
- case LinkTarget.BLANK:
- case LinkTarget.PARENT:
- case LinkTarget.TOP:
- return globalSettings.externalLinkTarget;
- }
- warn('PDFJS.externalLinkTarget is invalid: ' + globalSettings.externalLinkTarget);
- globalSettings.externalLinkTarget = LinkTarget.NONE;
- return LinkTarget.NONE;
- case 'externalLinkRel':
- return globalSettings ? globalSettings.externalLinkRel : DEFAULT_LINK_REL;
- case 'enableStats':
- return !!(globalSettings && globalSettings.enableStats);
- case 'pdfjsNext':
- return !!(globalSettings && globalSettings.pdfjsNext);
- default:
- throw new Error('Unknown default setting: ' + id);
- }
-}
-function isExternalLinkTargetSet() {
- var externalLinkTarget = getDefaultSetting('externalLinkTarget');
- switch (externalLinkTarget) {
- case LinkTarget.NONE:
- return false;
- case LinkTarget.SELF:
- case LinkTarget.BLANK:
- case LinkTarget.PARENT:
- case LinkTarget.TOP:
- return true;
- }
-}
-function isValidUrl(url, allowRelative) {
- deprecated('isValidUrl(), please use createValidAbsoluteUrl() instead.');
- var baseUrl = allowRelative ? 'http://example.com' : null;
- return createValidAbsoluteUrl(url, baseUrl) !== null;
-}
-exports.CustomStyle = CustomStyle;
-exports.addLinkAttributes = addLinkAttributes;
-exports.isExternalLinkTargetSet = isExternalLinkTargetSet;
-exports.isValidUrl = isValidUrl;
-exports.getFilenameFromUrl = getFilenameFromUrl;
-exports.LinkTarget = LinkTarget;
-exports.RenderingCancelledException = RenderingCancelledException;
-exports.hasCanvasTypedArrays = hasCanvasTypedArrays;
-exports.getDefaultSetting = getDefaultSetting;
-exports.DEFAULT_LINK_REL = DEFAULT_LINK_REL;
-exports.DOMCanvasFactory = DOMCanvasFactory;
-exports.DOMCMapReaderFactory = DOMCMapReaderFactory;
-
-/***/ }),
-/* 2 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var AnnotationBorderStyleType = sharedUtil.AnnotationBorderStyleType;
-var AnnotationType = sharedUtil.AnnotationType;
-var stringToPDFString = sharedUtil.stringToPDFString;
-var Util = sharedUtil.Util;
-var addLinkAttributes = displayDOMUtils.addLinkAttributes;
-var LinkTarget = displayDOMUtils.LinkTarget;
-var getFilenameFromUrl = displayDOMUtils.getFilenameFromUrl;
-var warn = sharedUtil.warn;
-var CustomStyle = displayDOMUtils.CustomStyle;
-var getDefaultSetting = displayDOMUtils.getDefaultSetting;
-function AnnotationElementFactory() {}
-AnnotationElementFactory.prototype = {
- create: function AnnotationElementFactory_create(parameters) {
- var subtype = parameters.data.annotationType;
- switch (subtype) {
- case AnnotationType.LINK:
- return new LinkAnnotationElement(parameters);
- case AnnotationType.TEXT:
- return new TextAnnotationElement(parameters);
- case AnnotationType.WIDGET:
- var fieldType = parameters.data.fieldType;
- switch (fieldType) {
- case 'Tx':
- return new TextWidgetAnnotationElement(parameters);
- case 'Btn':
- if (parameters.data.radioButton) {
- return new RadioButtonWidgetAnnotationElement(parameters);
- } else if (parameters.data.checkBox) {
- return new CheckboxWidgetAnnotationElement(parameters);
- }
- warn('Unimplemented button widget annotation: pushbutton');
- break;
- case 'Ch':
- return new ChoiceWidgetAnnotationElement(parameters);
- }
- return new WidgetAnnotationElement(parameters);
- case AnnotationType.POPUP:
- return new PopupAnnotationElement(parameters);
- case AnnotationType.HIGHLIGHT:
- return new HighlightAnnotationElement(parameters);
- case AnnotationType.UNDERLINE:
- return new UnderlineAnnotationElement(parameters);
- case AnnotationType.SQUIGGLY:
- return new SquigglyAnnotationElement(parameters);
- case AnnotationType.STRIKEOUT:
- return new StrikeOutAnnotationElement(parameters);
- case AnnotationType.FILEATTACHMENT:
- return new FileAttachmentAnnotationElement(parameters);
- default:
- return new AnnotationElement(parameters);
- }
- }
-};
-var AnnotationElement = function AnnotationElementClosure() {
- function AnnotationElement(parameters, isRenderable) {
- this.isRenderable = isRenderable || false;
- this.data = parameters.data;
- this.layer = parameters.layer;
- this.page = parameters.page;
- this.viewport = parameters.viewport;
- this.linkService = parameters.linkService;
- this.downloadManager = parameters.downloadManager;
- this.imageResourcesPath = parameters.imageResourcesPath;
- this.renderInteractiveForms = parameters.renderInteractiveForms;
- if (isRenderable) {
- this.container = this._createContainer();
- }
- }
- AnnotationElement.prototype = {
- _createContainer: function AnnotationElement_createContainer() {
- var data = this.data,
- page = this.page,
- viewport = this.viewport;
- var container = document.createElement('section');
- var width = data.rect[2] - data.rect[0];
- var height = data.rect[3] - data.rect[1];
- container.setAttribute('data-annotation-id', data.id);
- var rect = Util.normalizeRect([data.rect[0], page.view[3] - data.rect[1] + page.view[1], data.rect[2], page.view[3] - data.rect[3] + page.view[1]]);
- CustomStyle.setProp('transform', container, 'matrix(' + viewport.transform.join(',') + ')');
- CustomStyle.setProp('transformOrigin', container, -rect[0] + 'px ' + -rect[1] + 'px');
- if (data.borderStyle.width > 0) {
- container.style.borderWidth = data.borderStyle.width + 'px';
- if (data.borderStyle.style !== AnnotationBorderStyleType.UNDERLINE) {
- width = width - 2 * data.borderStyle.width;
- height = height - 2 * data.borderStyle.width;
- }
- var horizontalRadius = data.borderStyle.horizontalCornerRadius;
- var verticalRadius = data.borderStyle.verticalCornerRadius;
- if (horizontalRadius > 0 || verticalRadius > 0) {
- var radius = horizontalRadius + 'px / ' + verticalRadius + 'px';
- CustomStyle.setProp('borderRadius', container, radius);
- }
- switch (data.borderStyle.style) {
- case AnnotationBorderStyleType.SOLID:
- container.style.borderStyle = 'solid';
- break;
- case AnnotationBorderStyleType.DASHED:
- container.style.borderStyle = 'dashed';
- break;
- case AnnotationBorderStyleType.BEVELED:
- warn('Unimplemented border style: beveled');
- break;
- case AnnotationBorderStyleType.INSET:
- warn('Unimplemented border style: inset');
- break;
- case AnnotationBorderStyleType.UNDERLINE:
- container.style.borderBottomStyle = 'solid';
- break;
- default:
- break;
- }
- if (data.color) {
- container.style.borderColor = Util.makeCssRgb(data.color[0] | 0, data.color[1] | 0, data.color[2] | 0);
- } else {
- container.style.borderWidth = 0;
- }
- }
- container.style.left = rect[0] + 'px';
- container.style.top = rect[1] + 'px';
- container.style.width = width + 'px';
- container.style.height = height + 'px';
- return container;
- },
- _createPopup: function AnnotationElement_createPopup(container, trigger, data) {
- if (!trigger) {
- trigger = document.createElement('div');
- trigger.style.height = container.style.height;
- trigger.style.width = container.style.width;
- container.appendChild(trigger);
- }
- var popupElement = new PopupElement({
- container: container,
- trigger: trigger,
- color: data.color,
- title: data.title,
- contents: data.contents,
- hideWrapper: true
- });
- var popup = popupElement.render();
- popup.style.left = container.style.width;
- container.appendChild(popup);
- },
- render: function AnnotationElement_render() {
- throw new Error('Abstract method AnnotationElement.render called');
- }
- };
- return AnnotationElement;
-}();
-var LinkAnnotationElement = function LinkAnnotationElementClosure() {
- function LinkAnnotationElement(parameters) {
- AnnotationElement.call(this, parameters, true);
- }
- Util.inherit(LinkAnnotationElement, AnnotationElement, {
- render: function LinkAnnotationElement_render() {
- this.container.className = 'linkAnnotation';
- var link = document.createElement('a');
- addLinkAttributes(link, {
- url: this.data.url,
- target: this.data.newWindow ? LinkTarget.BLANK : undefined
- });
- if (!this.data.url) {
- if (this.data.action) {
- this._bindNamedAction(link, this.data.action);
- } else {
- this._bindLink(link, this.data.dest);
- }
- }
- this.container.appendChild(link);
- return this.container;
- },
- _bindLink: function LinkAnnotationElement_bindLink(link, destination) {
- var self = this;
- link.href = this.linkService.getDestinationHash(destination);
- link.onclick = function () {
- if (destination) {
- self.linkService.navigateTo(destination);
- }
- return false;
- };
- if (destination) {
- link.className = 'internalLink';
- }
- },
- _bindNamedAction: function LinkAnnotationElement_bindNamedAction(link, action) {
- var self = this;
- link.href = this.linkService.getAnchorUrl('');
- link.onclick = function () {
- self.linkService.executeNamedAction(action);
- return false;
- };
- link.className = 'internalLink';
- }
- });
- return LinkAnnotationElement;
-}();
-var TextAnnotationElement = function TextAnnotationElementClosure() {
- function TextAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(TextAnnotationElement, AnnotationElement, {
- render: function TextAnnotationElement_render() {
- this.container.className = 'textAnnotation';
- var image = document.createElement('img');
- image.style.height = this.container.style.height;
- image.style.width = this.container.style.width;
- image.src = this.imageResourcesPath + 'annotation-' + this.data.name.toLowerCase() + '.svg';
- image.alt = '[{{type}} Annotation]';
- image.dataset.l10nId = 'text_annotation_type';
- image.dataset.l10nArgs = JSON.stringify({ type: this.data.name });
- if (!this.data.hasPopup) {
- this._createPopup(this.container, image, this.data);
- }
- this.container.appendChild(image);
- return this.container;
- }
- });
- return TextAnnotationElement;
-}();
-var WidgetAnnotationElement = function WidgetAnnotationElementClosure() {
- function WidgetAnnotationElement(parameters, isRenderable) {
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(WidgetAnnotationElement, AnnotationElement, {
- render: function WidgetAnnotationElement_render() {
- return this.container;
- }
- });
- return WidgetAnnotationElement;
-}();
-var TextWidgetAnnotationElement = function TextWidgetAnnotationElementClosure() {
- var TEXT_ALIGNMENT = ['left', 'center', 'right'];
- function TextWidgetAnnotationElement(parameters) {
- var isRenderable = parameters.renderInteractiveForms || !parameters.data.hasAppearance && !!parameters.data.fieldValue;
- WidgetAnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(TextWidgetAnnotationElement, WidgetAnnotationElement, {
- render: function TextWidgetAnnotationElement_render() {
- this.container.className = 'textWidgetAnnotation';
- var element = null;
- if (this.renderInteractiveForms) {
- if (this.data.multiLine) {
- element = document.createElement('textarea');
- element.textContent = this.data.fieldValue;
- } else {
- element = document.createElement('input');
- element.type = 'text';
- element.setAttribute('value', this.data.fieldValue);
- }
- element.disabled = this.data.readOnly;
- if (this.data.maxLen !== null) {
- element.maxLength = this.data.maxLen;
- }
- if (this.data.comb) {
- var fieldWidth = this.data.rect[2] - this.data.rect[0];
- var combWidth = fieldWidth / this.data.maxLen;
- element.classList.add('comb');
- element.style.letterSpacing = 'calc(' + combWidth + 'px - 1ch)';
- }
- } else {
- element = document.createElement('div');
- element.textContent = this.data.fieldValue;
- element.style.verticalAlign = 'middle';
- element.style.display = 'table-cell';
- var font = null;
- if (this.data.fontRefName) {
- font = this.page.commonObjs.getData(this.data.fontRefName);
- }
- this._setTextStyle(element, font);
- }
- if (this.data.textAlignment !== null) {
- element.style.textAlign = TEXT_ALIGNMENT[this.data.textAlignment];
- }
- this.container.appendChild(element);
- return this.container;
- },
- _setTextStyle: function TextWidgetAnnotationElement_setTextStyle(element, font) {
- var style = element.style;
- style.fontSize = this.data.fontSize + 'px';
- style.direction = this.data.fontDirection < 0 ? 'rtl' : 'ltr';
- if (!font) {
- return;
- }
- style.fontWeight = font.black ? font.bold ? '900' : 'bold' : font.bold ? 'bold' : 'normal';
- style.fontStyle = font.italic ? 'italic' : 'normal';
- var fontFamily = font.loadedName ? '"' + font.loadedName + '", ' : '';
- var fallbackName = font.fallbackName || 'Helvetica, sans-serif';
- style.fontFamily = fontFamily + fallbackName;
- }
- });
- return TextWidgetAnnotationElement;
-}();
-var CheckboxWidgetAnnotationElement = function CheckboxWidgetAnnotationElementClosure() {
- function CheckboxWidgetAnnotationElement(parameters) {
- WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
- }
- Util.inherit(CheckboxWidgetAnnotationElement, WidgetAnnotationElement, {
- render: function CheckboxWidgetAnnotationElement_render() {
- this.container.className = 'buttonWidgetAnnotation checkBox';
- var element = document.createElement('input');
- element.disabled = this.data.readOnly;
- element.type = 'checkbox';
- if (this.data.fieldValue && this.data.fieldValue !== 'Off') {
- element.setAttribute('checked', true);
- }
- this.container.appendChild(element);
- return this.container;
- }
- });
- return CheckboxWidgetAnnotationElement;
-}();
-var RadioButtonWidgetAnnotationElement = function RadioButtonWidgetAnnotationElementClosure() {
- function RadioButtonWidgetAnnotationElement(parameters) {
- WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
- }
- Util.inherit(RadioButtonWidgetAnnotationElement, WidgetAnnotationElement, {
- render: function RadioButtonWidgetAnnotationElement_render() {
- this.container.className = 'buttonWidgetAnnotation radioButton';
- var element = document.createElement('input');
- element.disabled = this.data.readOnly;
- element.type = 'radio';
- element.name = this.data.fieldName;
- if (this.data.fieldValue === this.data.buttonValue) {
- element.setAttribute('checked', true);
- }
- this.container.appendChild(element);
- return this.container;
- }
- });
- return RadioButtonWidgetAnnotationElement;
-}();
-var ChoiceWidgetAnnotationElement = function ChoiceWidgetAnnotationElementClosure() {
- function ChoiceWidgetAnnotationElement(parameters) {
- WidgetAnnotationElement.call(this, parameters, parameters.renderInteractiveForms);
- }
- Util.inherit(ChoiceWidgetAnnotationElement, WidgetAnnotationElement, {
- render: function ChoiceWidgetAnnotationElement_render() {
- this.container.className = 'choiceWidgetAnnotation';
- var selectElement = document.createElement('select');
- selectElement.disabled = this.data.readOnly;
- if (!this.data.combo) {
- selectElement.size = this.data.options.length;
- if (this.data.multiSelect) {
- selectElement.multiple = true;
- }
- }
- for (var i = 0, ii = this.data.options.length; i < ii; i++) {
- var option = this.data.options[i];
- var optionElement = document.createElement('option');
- optionElement.textContent = option.displayValue;
- optionElement.value = option.exportValue;
- if (this.data.fieldValue.indexOf(option.displayValue) >= 0) {
- optionElement.setAttribute('selected', true);
- }
- selectElement.appendChild(optionElement);
- }
- this.container.appendChild(selectElement);
- return this.container;
- }
- });
- return ChoiceWidgetAnnotationElement;
-}();
-var PopupAnnotationElement = function PopupAnnotationElementClosure() {
- function PopupAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(PopupAnnotationElement, AnnotationElement, {
- render: function PopupAnnotationElement_render() {
- this.container.className = 'popupAnnotation';
- var selector = '[data-annotation-id="' + this.data.parentId + '"]';
- var parentElement = this.layer.querySelector(selector);
- if (!parentElement) {
- return this.container;
- }
- var popup = new PopupElement({
- container: this.container,
- trigger: parentElement,
- color: this.data.color,
- title: this.data.title,
- contents: this.data.contents
- });
- var parentLeft = parseFloat(parentElement.style.left);
- var parentWidth = parseFloat(parentElement.style.width);
- CustomStyle.setProp('transformOrigin', this.container, -(parentLeft + parentWidth) + 'px -' + parentElement.style.top);
- this.container.style.left = parentLeft + parentWidth + 'px';
- this.container.appendChild(popup.render());
- return this.container;
- }
- });
- return PopupAnnotationElement;
-}();
-var PopupElement = function PopupElementClosure() {
- var BACKGROUND_ENLIGHT = 0.7;
- function PopupElement(parameters) {
- this.container = parameters.container;
- this.trigger = parameters.trigger;
- this.color = parameters.color;
- this.title = parameters.title;
- this.contents = parameters.contents;
- this.hideWrapper = parameters.hideWrapper || false;
- this.pinned = false;
- }
- PopupElement.prototype = {
- render: function PopupElement_render() {
- var wrapper = document.createElement('div');
- wrapper.className = 'popupWrapper';
- this.hideElement = this.hideWrapper ? wrapper : this.container;
- this.hideElement.setAttribute('hidden', true);
- var popup = document.createElement('div');
- popup.className = 'popup';
- var color = this.color;
- if (color) {
- var r = BACKGROUND_ENLIGHT * (255 - color[0]) + color[0];
- var g = BACKGROUND_ENLIGHT * (255 - color[1]) + color[1];
- var b = BACKGROUND_ENLIGHT * (255 - color[2]) + color[2];
- popup.style.backgroundColor = Util.makeCssRgb(r | 0, g | 0, b | 0);
- }
- var contents = this._formatContents(this.contents);
- var title = document.createElement('h1');
- title.textContent = this.title;
- this.trigger.addEventListener('click', this._toggle.bind(this));
- this.trigger.addEventListener('mouseover', this._show.bind(this, false));
- this.trigger.addEventListener('mouseout', this._hide.bind(this, false));
- popup.addEventListener('click', this._hide.bind(this, true));
- popup.appendChild(title);
- popup.appendChild(contents);
- wrapper.appendChild(popup);
- return wrapper;
- },
- _formatContents: function PopupElement_formatContents(contents) {
- var p = document.createElement('p');
- var lines = contents.split(/(?:\r\n?|\n)/);
- for (var i = 0, ii = lines.length; i < ii; ++i) {
- var line = lines[i];
- p.appendChild(document.createTextNode(line));
- if (i < ii - 1) {
- p.appendChild(document.createElement('br'));
- }
- }
- return p;
- },
- _toggle: function PopupElement_toggle() {
- if (this.pinned) {
- this._hide(true);
- } else {
- this._show(true);
- }
- },
- _show: function PopupElement_show(pin) {
- if (pin) {
- this.pinned = true;
- }
- if (this.hideElement.hasAttribute('hidden')) {
- this.hideElement.removeAttribute('hidden');
- this.container.style.zIndex += 1;
- }
- },
- _hide: function PopupElement_hide(unpin) {
- if (unpin) {
- this.pinned = false;
- }
- if (!this.hideElement.hasAttribute('hidden') && !this.pinned) {
- this.hideElement.setAttribute('hidden', true);
- this.container.style.zIndex -= 1;
- }
- }
- };
- return PopupElement;
-}();
-var HighlightAnnotationElement = function HighlightAnnotationElementClosure() {
- function HighlightAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(HighlightAnnotationElement, AnnotationElement, {
- render: function HighlightAnnotationElement_render() {
- this.container.className = 'highlightAnnotation';
- if (!this.data.hasPopup) {
- this._createPopup(this.container, null, this.data);
- }
- return this.container;
- }
- });
- return HighlightAnnotationElement;
-}();
-var UnderlineAnnotationElement = function UnderlineAnnotationElementClosure() {
- function UnderlineAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(UnderlineAnnotationElement, AnnotationElement, {
- render: function UnderlineAnnotationElement_render() {
- this.container.className = 'underlineAnnotation';
- if (!this.data.hasPopup) {
- this._createPopup(this.container, null, this.data);
- }
- return this.container;
- }
- });
- return UnderlineAnnotationElement;
-}();
-var SquigglyAnnotationElement = function SquigglyAnnotationElementClosure() {
- function SquigglyAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(SquigglyAnnotationElement, AnnotationElement, {
- render: function SquigglyAnnotationElement_render() {
- this.container.className = 'squigglyAnnotation';
- if (!this.data.hasPopup) {
- this._createPopup(this.container, null, this.data);
- }
- return this.container;
- }
- });
- return SquigglyAnnotationElement;
-}();
-var StrikeOutAnnotationElement = function StrikeOutAnnotationElementClosure() {
- function StrikeOutAnnotationElement(parameters) {
- var isRenderable = !!(parameters.data.hasPopup || parameters.data.title || parameters.data.contents);
- AnnotationElement.call(this, parameters, isRenderable);
- }
- Util.inherit(StrikeOutAnnotationElement, AnnotationElement, {
- render: function StrikeOutAnnotationElement_render() {
- this.container.className = 'strikeoutAnnotation';
- if (!this.data.hasPopup) {
- this._createPopup(this.container, null, this.data);
- }
- return this.container;
- }
- });
- return StrikeOutAnnotationElement;
-}();
-var FileAttachmentAnnotationElement = function FileAttachmentAnnotationElementClosure() {
- function FileAttachmentAnnotationElement(parameters) {
- AnnotationElement.call(this, parameters, true);
- var file = this.data.file;
- this.filename = getFilenameFromUrl(file.filename);
- this.content = file.content;
- this.linkService.onFileAttachmentAnnotation({
- id: stringToPDFString(file.filename),
- filename: file.filename,
- content: file.content
- });
- }
- Util.inherit(FileAttachmentAnnotationElement, AnnotationElement, {
- render: function FileAttachmentAnnotationElement_render() {
- this.container.className = 'fileAttachmentAnnotation';
- var trigger = document.createElement('div');
- trigger.style.height = this.container.style.height;
- trigger.style.width = this.container.style.width;
- trigger.addEventListener('dblclick', this._download.bind(this));
- if (!this.data.hasPopup && (this.data.title || this.data.contents)) {
- this._createPopup(this.container, trigger, this.data);
- }
- this.container.appendChild(trigger);
- return this.container;
- },
- _download: function FileAttachmentAnnotationElement_download() {
- if (!this.downloadManager) {
- warn('Download cannot be started due to unavailable download manager');
- return;
- }
- this.downloadManager.downloadData(this.content, this.filename, '');
- }
- });
- return FileAttachmentAnnotationElement;
-}();
-var AnnotationLayer = function AnnotationLayerClosure() {
- return {
- render: function AnnotationLayer_render(parameters) {
- var annotationElementFactory = new AnnotationElementFactory();
- for (var i = 0, ii = parameters.annotations.length; i < ii; i++) {
- var data = parameters.annotations[i];
- if (!data) {
- continue;
- }
- var element = annotationElementFactory.create({
- data: data,
- layer: parameters.div,
- page: parameters.page,
- viewport: parameters.viewport,
- linkService: parameters.linkService,
- downloadManager: parameters.downloadManager,
- imageResourcesPath: parameters.imageResourcesPath || getDefaultSetting('imageResourcesPath'),
- renderInteractiveForms: parameters.renderInteractiveForms || false
- });
- if (element.isRenderable) {
- parameters.div.appendChild(element.render());
- }
- }
- },
- update: function AnnotationLayer_update(parameters) {
- for (var i = 0, ii = parameters.annotations.length; i < ii; i++) {
- var data = parameters.annotations[i];
- var element = parameters.div.querySelector('[data-annotation-id="' + data.id + '"]');
- if (element) {
- CustomStyle.setProp('transform', element, 'matrix(' + parameters.viewport.transform.join(',') + ')');
- }
- }
- parameters.div.removeAttribute('hidden');
- }
- };
-}();
-exports.AnnotationLayer = AnnotationLayer;
-
-/***/ }),
-/* 3 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayFontLoader = __w_pdfjs_require__(11);
-var displayCanvas = __w_pdfjs_require__(10);
-var displayMetadata = __w_pdfjs_require__(7);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var amdRequire;
-var InvalidPDFException = sharedUtil.InvalidPDFException;
-var MessageHandler = sharedUtil.MessageHandler;
-var MissingPDFException = sharedUtil.MissingPDFException;
-var PageViewport = sharedUtil.PageViewport;
-var PasswordException = sharedUtil.PasswordException;
-var StatTimer = sharedUtil.StatTimer;
-var UnexpectedResponseException = sharedUtil.UnexpectedResponseException;
-var UnknownErrorException = sharedUtil.UnknownErrorException;
-var Util = sharedUtil.Util;
-var createPromiseCapability = sharedUtil.createPromiseCapability;
-var error = sharedUtil.error;
-var deprecated = sharedUtil.deprecated;
-var getVerbosityLevel = sharedUtil.getVerbosityLevel;
-var info = sharedUtil.info;
-var isInt = sharedUtil.isInt;
-var isArray = sharedUtil.isArray;
-var isArrayBuffer = sharedUtil.isArrayBuffer;
-var isSameOrigin = sharedUtil.isSameOrigin;
-var loadJpegStream = sharedUtil.loadJpegStream;
-var stringToBytes = sharedUtil.stringToBytes;
-var globalScope = sharedUtil.globalScope;
-var warn = sharedUtil.warn;
-var FontFaceObject = displayFontLoader.FontFaceObject;
-var FontLoader = displayFontLoader.FontLoader;
-var CanvasGraphics = displayCanvas.CanvasGraphics;
-var Metadata = displayMetadata.Metadata;
-var RenderingCancelledException = displayDOMUtils.RenderingCancelledException;
-var getDefaultSetting = displayDOMUtils.getDefaultSetting;
-var DOMCanvasFactory = displayDOMUtils.DOMCanvasFactory;
-var DOMCMapReaderFactory = displayDOMUtils.DOMCMapReaderFactory;
-var DEFAULT_RANGE_CHUNK_SIZE = 65536;
-var isWorkerDisabled = false;
-var workerSrc;
-var isPostMessageTransfersDisabled = false;
-var pdfjsFilePath = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : null;
-var fakeWorkerFilesLoader = null;
-var useRequireEnsure = false;
-if (typeof __pdfjsdev_webpack__ === 'undefined') {
- if (typeof window === 'undefined') {
- isWorkerDisabled = true;
- if (false) {
- require.ensure = require('node-ensure');
- }
- useRequireEnsure = true;
- } else if (true) {
- useRequireEnsure = true;
- }
- if (typeof requirejs !== 'undefined' && requirejs.toUrl) {
- workerSrc = requirejs.toUrl('pdfjs-dist/build/pdf.worker.js');
- }
- var dynamicLoaderSupported = typeof requirejs !== 'undefined' && requirejs.load;
- fakeWorkerFilesLoader = useRequireEnsure ? function (callback) {
- __webpack_require__.e/* require.ensure */(0).then((function () {
- var worker = __webpack_require__(1);
- callback(worker.WorkerMessageHandler);
- }).bind(null, __webpack_require__)).catch(__webpack_require__.oe);
- } : dynamicLoaderSupported ? function (callback) {
- requirejs(['pdfjs-dist/build/pdf.worker'], function (worker) {
- callback(worker.WorkerMessageHandler);
- });
- } : null;
-}
-function getDocument(src, pdfDataRangeTransport, passwordCallback, progressCallback) {
- var task = new PDFDocumentLoadingTask();
- if (arguments.length > 1) {
- deprecated('getDocument is called with pdfDataRangeTransport, ' + 'passwordCallback or progressCallback argument');
- }
- if (pdfDataRangeTransport) {
- if (!(pdfDataRangeTransport instanceof PDFDataRangeTransport)) {
- pdfDataRangeTransport = Object.create(pdfDataRangeTransport);
- pdfDataRangeTransport.length = src.length;
- pdfDataRangeTransport.initialData = src.initialData;
- if (!pdfDataRangeTransport.abort) {
- pdfDataRangeTransport.abort = function () {};
- }
- }
- src = Object.create(src);
- src.range = pdfDataRangeTransport;
- }
- task.onPassword = passwordCallback || null;
- task.onProgress = progressCallback || null;
- var source;
- if (typeof src === 'string') {
- source = { url: src };
- } else if (isArrayBuffer(src)) {
- source = { data: src };
- } else if (src instanceof PDFDataRangeTransport) {
- source = { range: src };
- } else {
- if (typeof src !== 'object') {
- error('Invalid parameter in getDocument, need either Uint8Array, ' + 'string or a parameter object');
- }
- if (!src.url && !src.data && !src.range) {
- error('Invalid parameter object: need either .data, .range or .url');
- }
- source = src;
- }
- var params = {};
- var rangeTransport = null;
- var worker = null;
- for (var key in source) {
- if (key === 'url' && typeof window !== 'undefined') {
- params[key] = new URL(source[key], window.location).href;
- continue;
- } else if (key === 'range') {
- rangeTransport = source[key];
- continue;
- } else if (key === 'worker') {
- worker = source[key];
- continue;
- } else if (key === 'data' && !(source[key] instanceof Uint8Array)) {
- var pdfBytes = source[key];
- if (typeof pdfBytes === 'string') {
- params[key] = stringToBytes(pdfBytes);
- } else if (typeof pdfBytes === 'object' && pdfBytes !== null && !isNaN(pdfBytes.length)) {
- params[key] = new Uint8Array(pdfBytes);
- } else if (isArrayBuffer(pdfBytes)) {
- params[key] = new Uint8Array(pdfBytes);
- } else {
- error('Invalid PDF binary data: either typed array, string or ' + 'array-like object is expected in the data property.');
- }
- continue;
- }
- params[key] = source[key];
- }
- params.rangeChunkSize = params.rangeChunkSize || DEFAULT_RANGE_CHUNK_SIZE;
- params.disableNativeImageDecoder = params.disableNativeImageDecoder === true;
- var CMapReaderFactory = params.CMapReaderFactory || DOMCMapReaderFactory;
- if (!worker) {
- var workerPort = getDefaultSetting('workerPort');
- worker = workerPort ? new PDFWorker(null, workerPort) : new PDFWorker();
- task._worker = worker;
- }
- var docId = task.docId;
- worker.promise.then(function () {
- if (task.destroyed) {
- throw new Error('Loading aborted');
- }
- return _fetchDocument(worker, params, rangeTransport, docId).then(function (workerId) {
- if (task.destroyed) {
- throw new Error('Loading aborted');
- }
- var messageHandler = new MessageHandler(docId, workerId, worker.port);
- var transport = new WorkerTransport(messageHandler, task, rangeTransport, CMapReaderFactory);
- task._transport = transport;
- messageHandler.send('Ready', null);
- });
- }).catch(task._capability.reject);
- return task;
-}
-function _fetchDocument(worker, source, pdfDataRangeTransport, docId) {
- if (worker.destroyed) {
- return Promise.reject(new Error('Worker was destroyed'));
- }
- source.disableAutoFetch = getDefaultSetting('disableAutoFetch');
- source.disableStream = getDefaultSetting('disableStream');
- source.chunkedViewerLoading = !!pdfDataRangeTransport;
- if (pdfDataRangeTransport) {
- source.length = pdfDataRangeTransport.length;
- source.initialData = pdfDataRangeTransport.initialData;
- }
- return worker.messageHandler.sendWithPromise('GetDocRequest', {
- docId: docId,
- source: source,
- disableRange: getDefaultSetting('disableRange'),
- maxImageSize: getDefaultSetting('maxImageSize'),
- disableFontFace: getDefaultSetting('disableFontFace'),
- disableCreateObjectURL: getDefaultSetting('disableCreateObjectURL'),
- postMessageTransfers: getDefaultSetting('postMessageTransfers') && !isPostMessageTransfersDisabled,
- docBaseUrl: source.docBaseUrl,
- disableNativeImageDecoder: source.disableNativeImageDecoder
- }).then(function (workerId) {
- if (worker.destroyed) {
- throw new Error('Worker was destroyed');
- }
- return workerId;
- });
-}
-var PDFDocumentLoadingTask = function PDFDocumentLoadingTaskClosure() {
- var nextDocumentId = 0;
- function PDFDocumentLoadingTask() {
- this._capability = createPromiseCapability();
- this._transport = null;
- this._worker = null;
- this.docId = 'd' + nextDocumentId++;
- this.destroyed = false;
- this.onPassword = null;
- this.onProgress = null;
- this.onUnsupportedFeature = null;
- }
- PDFDocumentLoadingTask.prototype = {
- get promise() {
- return this._capability.promise;
- },
- destroy: function () {
- this.destroyed = true;
- var transportDestroyed = !this._transport ? Promise.resolve() : this._transport.destroy();
- return transportDestroyed.then(function () {
- this._transport = null;
- if (this._worker) {
- this._worker.destroy();
- this._worker = null;
- }
- }.bind(this));
- },
- then: function PDFDocumentLoadingTask_then(onFulfilled, onRejected) {
- return this.promise.then.apply(this.promise, arguments);
- }
- };
- return PDFDocumentLoadingTask;
-}();
-var PDFDataRangeTransport = function pdfDataRangeTransportClosure() {
- function PDFDataRangeTransport(length, initialData) {
- this.length = length;
- this.initialData = initialData;
- this._rangeListeners = [];
- this._progressListeners = [];
- this._progressiveReadListeners = [];
- this._readyCapability = createPromiseCapability();
- }
- PDFDataRangeTransport.prototype = {
- addRangeListener: function PDFDataRangeTransport_addRangeListener(listener) {
- this._rangeListeners.push(listener);
- },
- addProgressListener: function PDFDataRangeTransport_addProgressListener(listener) {
- this._progressListeners.push(listener);
- },
- addProgressiveReadListener: function PDFDataRangeTransport_addProgressiveReadListener(listener) {
- this._progressiveReadListeners.push(listener);
- },
- onDataRange: function PDFDataRangeTransport_onDataRange(begin, chunk) {
- var listeners = this._rangeListeners;
- for (var i = 0, n = listeners.length; i < n; ++i) {
- listeners[i](begin, chunk);
- }
- },
- onDataProgress: function PDFDataRangeTransport_onDataProgress(loaded) {
- this._readyCapability.promise.then(function () {
- var listeners = this._progressListeners;
- for (var i = 0, n = listeners.length; i < n; ++i) {
- listeners[i](loaded);
- }
- }.bind(this));
- },
- onDataProgressiveRead: function PDFDataRangeTransport_onDataProgress(chunk) {
- this._readyCapability.promise.then(function () {
- var listeners = this._progressiveReadListeners;
- for (var i = 0, n = listeners.length; i < n; ++i) {
- listeners[i](chunk);
- }
- }.bind(this));
- },
- transportReady: function PDFDataRangeTransport_transportReady() {
- this._readyCapability.resolve();
- },
- requestDataRange: function PDFDataRangeTransport_requestDataRange(begin, end) {
- throw new Error('Abstract method PDFDataRangeTransport.requestDataRange');
- },
- abort: function PDFDataRangeTransport_abort() {}
- };
- return PDFDataRangeTransport;
-}();
-var PDFDocumentProxy = function PDFDocumentProxyClosure() {
- function PDFDocumentProxy(pdfInfo, transport, loadingTask) {
- this.pdfInfo = pdfInfo;
- this.transport = transport;
- this.loadingTask = loadingTask;
- }
- PDFDocumentProxy.prototype = {
- get numPages() {
- return this.pdfInfo.numPages;
- },
- get fingerprint() {
- return this.pdfInfo.fingerprint;
- },
- getPage: function PDFDocumentProxy_getPage(pageNumber) {
- return this.transport.getPage(pageNumber);
- },
- getPageIndex: function PDFDocumentProxy_getPageIndex(ref) {
- return this.transport.getPageIndex(ref);
- },
- getDestinations: function PDFDocumentProxy_getDestinations() {
- return this.transport.getDestinations();
- },
- getDestination: function PDFDocumentProxy_getDestination(id) {
- return this.transport.getDestination(id);
- },
- getPageLabels: function PDFDocumentProxy_getPageLabels() {
- return this.transport.getPageLabels();
- },
- getAttachments: function PDFDocumentProxy_getAttachments() {
- return this.transport.getAttachments();
- },
- getJavaScript: function PDFDocumentProxy_getJavaScript() {
- return this.transport.getJavaScript();
- },
- getOutline: function PDFDocumentProxy_getOutline() {
- return this.transport.getOutline();
- },
- getMetadata: function PDFDocumentProxy_getMetadata() {
- return this.transport.getMetadata();
- },
- getData: function PDFDocumentProxy_getData() {
- return this.transport.getData();
- },
- getDownloadInfo: function PDFDocumentProxy_getDownloadInfo() {
- return this.transport.downloadInfoCapability.promise;
- },
- getStats: function PDFDocumentProxy_getStats() {
- return this.transport.getStats();
- },
- cleanup: function PDFDocumentProxy_cleanup() {
- this.transport.startCleanup();
- },
- destroy: function PDFDocumentProxy_destroy() {
- return this.loadingTask.destroy();
- }
- };
- return PDFDocumentProxy;
-}();
-var PDFPageProxy = function PDFPageProxyClosure() {
- function PDFPageProxy(pageIndex, pageInfo, transport) {
- this.pageIndex = pageIndex;
- this.pageInfo = pageInfo;
- this.transport = transport;
- this.stats = new StatTimer();
- this.stats.enabled = getDefaultSetting('enableStats');
- this.commonObjs = transport.commonObjs;
- this.objs = new PDFObjects();
- this.cleanupAfterRender = false;
- this.pendingCleanup = false;
- this.intentStates = Object.create(null);
- this.destroyed = false;
- }
- PDFPageProxy.prototype = {
- get pageNumber() {
- return this.pageIndex + 1;
- },
- get rotate() {
- return this.pageInfo.rotate;
- },
- get ref() {
- return this.pageInfo.ref;
- },
- get userUnit() {
- return this.pageInfo.userUnit;
- },
- get view() {
- return this.pageInfo.view;
- },
- getViewport: function PDFPageProxy_getViewport(scale, rotate) {
- if (arguments.length < 2) {
- rotate = this.rotate;
- }
- return new PageViewport(this.view, scale, rotate, 0, 0);
- },
- getAnnotations: function PDFPageProxy_getAnnotations(params) {
- var intent = params && params.intent || null;
- if (!this.annotationsPromise || this.annotationsIntent !== intent) {
- this.annotationsPromise = this.transport.getAnnotations(this.pageIndex, intent);
- this.annotationsIntent = intent;
- }
- return this.annotationsPromise;
- },
- render: function PDFPageProxy_render(params) {
- var stats = this.stats;
- stats.time('Overall');
- this.pendingCleanup = false;
- var renderingIntent = params.intent === 'print' ? 'print' : 'display';
- var renderInteractiveForms = params.renderInteractiveForms === true ? true : false;
- var canvasFactory = params.canvasFactory || new DOMCanvasFactory();
- if (!this.intentStates[renderingIntent]) {
- this.intentStates[renderingIntent] = Object.create(null);
- }
- var intentState = this.intentStates[renderingIntent];
- if (!intentState.displayReadyCapability) {
- intentState.receivingOperatorList = true;
- intentState.displayReadyCapability = createPromiseCapability();
- intentState.operatorList = {
- fnArray: [],
- argsArray: [],
- lastChunk: false
- };
- this.stats.time('Page Request');
- this.transport.messageHandler.send('RenderPageRequest', {
- pageIndex: this.pageNumber - 1,
- intent: renderingIntent,
- renderInteractiveForms: renderInteractiveForms
- });
- }
- var internalRenderTask = new InternalRenderTask(complete, params, this.objs, this.commonObjs, intentState.operatorList, this.pageNumber, canvasFactory);
- internalRenderTask.useRequestAnimationFrame = renderingIntent !== 'print';
- if (!intentState.renderTasks) {
- intentState.renderTasks = [];
- }
- intentState.renderTasks.push(internalRenderTask);
- var renderTask = internalRenderTask.task;
- if (params.continueCallback) {
- deprecated('render is used with continueCallback parameter');
- renderTask.onContinue = params.continueCallback;
- }
- var self = this;
- intentState.displayReadyCapability.promise.then(function pageDisplayReadyPromise(transparency) {
- if (self.pendingCleanup) {
- complete();
- return;
- }
- stats.time('Rendering');
- internalRenderTask.initializeGraphics(transparency);
- internalRenderTask.operatorListChanged();
- }, function pageDisplayReadPromiseError(reason) {
- complete(reason);
- });
- function complete(error) {
- var i = intentState.renderTasks.indexOf(internalRenderTask);
- if (i >= 0) {
- intentState.renderTasks.splice(i, 1);
- }
- if (self.cleanupAfterRender) {
- self.pendingCleanup = true;
- }
- self._tryCleanup();
- if (error) {
- internalRenderTask.capability.reject(error);
- } else {
- internalRenderTask.capability.resolve();
- }
- stats.timeEnd('Rendering');
- stats.timeEnd('Overall');
- }
- return renderTask;
- },
- getOperatorList: function PDFPageProxy_getOperatorList() {
- function operatorListChanged() {
- if (intentState.operatorList.lastChunk) {
- intentState.opListReadCapability.resolve(intentState.operatorList);
- var i = intentState.renderTasks.indexOf(opListTask);
- if (i >= 0) {
- intentState.renderTasks.splice(i, 1);
- }
- }
- }
- var renderingIntent = 'oplist';
- if (!this.intentStates[renderingIntent]) {
- this.intentStates[renderingIntent] = Object.create(null);
- }
- var intentState = this.intentStates[renderingIntent];
- var opListTask;
- if (!intentState.opListReadCapability) {
- opListTask = {};
- opListTask.operatorListChanged = operatorListChanged;
- intentState.receivingOperatorList = true;
- intentState.opListReadCapability = createPromiseCapability();
- intentState.renderTasks = [];
- intentState.renderTasks.push(opListTask);
- intentState.operatorList = {
- fnArray: [],
- argsArray: [],
- lastChunk: false
- };
- this.transport.messageHandler.send('RenderPageRequest', {
- pageIndex: this.pageIndex,
- intent: renderingIntent
- });
- }
- return intentState.opListReadCapability.promise;
- },
- getTextContent: function PDFPageProxy_getTextContent(params) {
- return this.transport.messageHandler.sendWithPromise('GetTextContent', {
- pageIndex: this.pageNumber - 1,
- normalizeWhitespace: params && params.normalizeWhitespace === true ? true : false,
- combineTextItems: params && params.disableCombineTextItems === true ? false : true
- });
- },
- _destroy: function PDFPageProxy_destroy() {
- this.destroyed = true;
- this.transport.pageCache[this.pageIndex] = null;
- var waitOn = [];
- Object.keys(this.intentStates).forEach(function (intent) {
- if (intent === 'oplist') {
- return;
- }
- var intentState = this.intentStates[intent];
- intentState.renderTasks.forEach(function (renderTask) {
- var renderCompleted = renderTask.capability.promise.catch(function () {});
- waitOn.push(renderCompleted);
- renderTask.cancel();
- });
- }, this);
- this.objs.clear();
- this.annotationsPromise = null;
- this.pendingCleanup = false;
- return Promise.all(waitOn);
- },
- destroy: function () {
- deprecated('page destroy method, use cleanup() instead');
- this.cleanup();
- },
- cleanup: function PDFPageProxy_cleanup() {
- this.pendingCleanup = true;
- this._tryCleanup();
- },
- _tryCleanup: function PDFPageProxy_tryCleanup() {
- if (!this.pendingCleanup || Object.keys(this.intentStates).some(function (intent) {
- var intentState = this.intentStates[intent];
- return intentState.renderTasks.length !== 0 || intentState.receivingOperatorList;
- }, this)) {
- return;
- }
- Object.keys(this.intentStates).forEach(function (intent) {
- delete this.intentStates[intent];
- }, this);
- this.objs.clear();
- this.annotationsPromise = null;
- this.pendingCleanup = false;
- },
- _startRenderPage: function PDFPageProxy_startRenderPage(transparency, intent) {
- var intentState = this.intentStates[intent];
- if (intentState.displayReadyCapability) {
- intentState.displayReadyCapability.resolve(transparency);
- }
- },
- _renderPageChunk: function PDFPageProxy_renderPageChunk(operatorListChunk, intent) {
- var intentState = this.intentStates[intent];
- var i, ii;
- for (i = 0, ii = operatorListChunk.length; i < ii; i++) {
- intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
- intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
- }
- intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
- for (i = 0; i < intentState.renderTasks.length; i++) {
- intentState.renderTasks[i].operatorListChanged();
- }
- if (operatorListChunk.lastChunk) {
- intentState.receivingOperatorList = false;
- this._tryCleanup();
- }
- }
- };
- return PDFPageProxy;
-}();
-var PDFWorker = function PDFWorkerClosure() {
- var nextFakeWorkerId = 0;
- function getWorkerSrc() {
- if (typeof workerSrc !== 'undefined') {
- return workerSrc;
- }
- if (getDefaultSetting('workerSrc')) {
- return getDefaultSetting('workerSrc');
- }
- if (pdfjsFilePath) {
- return pdfjsFilePath.replace(/\.js$/i, '.worker.js');
- }
- error('No PDFJS.workerSrc specified');
- }
- var fakeWorkerFilesLoadedCapability;
- function setupFakeWorkerGlobal() {
- var WorkerMessageHandler;
- if (fakeWorkerFilesLoadedCapability) {
- return fakeWorkerFilesLoadedCapability.promise;
- }
- fakeWorkerFilesLoadedCapability = createPromiseCapability();
- var loader = fakeWorkerFilesLoader || function (callback) {
- Util.loadScript(getWorkerSrc(), function () {
- callback(window.pdfjsDistBuildPdfWorker.WorkerMessageHandler);
- });
- };
- loader(fakeWorkerFilesLoadedCapability.resolve);
- return fakeWorkerFilesLoadedCapability.promise;
- }
- function FakeWorkerPort(defer) {
- this._listeners = [];
- this._defer = defer;
- this._deferred = Promise.resolve(undefined);
- }
- FakeWorkerPort.prototype = {
- postMessage: function (obj, transfers) {
- function cloneValue(value) {
- if (typeof value !== 'object' || value === null) {
- return value;
- }
- if (cloned.has(value)) {
- return cloned.get(value);
- }
- var result;
- var buffer;
- if ((buffer = value.buffer) && isArrayBuffer(buffer)) {
- var transferable = transfers && transfers.indexOf(buffer) >= 0;
- if (value === buffer) {
- result = value;
- } else if (transferable) {
- result = new value.constructor(buffer, value.byteOffset, value.byteLength);
- } else {
- result = new value.constructor(value);
- }
- cloned.set(value, result);
- return result;
- }
- result = isArray(value) ? [] : {};
- cloned.set(value, result);
- for (var i in value) {
- var desc,
- p = value;
- while (!(desc = Object.getOwnPropertyDescriptor(p, i))) {
- p = Object.getPrototypeOf(p);
- }
- if (typeof desc.value === 'undefined' || typeof desc.value === 'function') {
- continue;
- }
- result[i] = cloneValue(desc.value);
- }
- return result;
- }
- if (!this._defer) {
- this._listeners.forEach(function (listener) {
- listener.call(this, { data: obj });
- }, this);
- return;
- }
- var cloned = new WeakMap();
- var e = { data: cloneValue(obj) };
- this._deferred.then(function () {
- this._listeners.forEach(function (listener) {
- listener.call(this, e);
- }, this);
- }.bind(this));
- },
- addEventListener: function (name, listener) {
- this._listeners.push(listener);
- },
- removeEventListener: function (name, listener) {
- var i = this._listeners.indexOf(listener);
- this._listeners.splice(i, 1);
- },
- terminate: function () {
- this._listeners = [];
- }
- };
- function createCDNWrapper(url) {
- var wrapper = 'importScripts(\'' + url + '\');';
- return URL.createObjectURL(new Blob([wrapper]));
- }
- function PDFWorker(name, port) {
- this.name = name;
- this.destroyed = false;
- this._readyCapability = createPromiseCapability();
- this._port = null;
- this._webWorker = null;
- this._messageHandler = null;
- if (port) {
- this._initializeFromPort(port);
- return;
- }
- this._initialize();
- }
- PDFWorker.prototype = {
- get promise() {
- return this._readyCapability.promise;
- },
- get port() {
- return this._port;
- },
- get messageHandler() {
- return this._messageHandler;
- },
- _initializeFromPort: function PDFWorker_initializeFromPort(port) {
- this._port = port;
- this._messageHandler = new MessageHandler('main', 'worker', port);
- this._messageHandler.on('ready', function () {});
- this._readyCapability.resolve();
- },
- _initialize: function PDFWorker_initialize() {
- if (!isWorkerDisabled && !getDefaultSetting('disableWorker') && typeof Worker !== 'undefined') {
- var workerSrc = getWorkerSrc();
- try {
- if (!isSameOrigin(window.location.href, workerSrc)) {
- workerSrc = createCDNWrapper(new URL(workerSrc, window.location).href);
- }
- var worker = new Worker(workerSrc);
- var messageHandler = new MessageHandler('main', 'worker', worker);
- var terminateEarly = function () {
- worker.removeEventListener('error', onWorkerError);
- messageHandler.destroy();
- worker.terminate();
- if (this.destroyed) {
- this._readyCapability.reject(new Error('Worker was destroyed'));
- } else {
- this._setupFakeWorker();
- }
- }.bind(this);
- var onWorkerError = function (event) {
- if (!this._webWorker) {
- terminateEarly();
- }
- }.bind(this);
- worker.addEventListener('error', onWorkerError);
- messageHandler.on('test', function PDFWorker_test(data) {
- worker.removeEventListener('error', onWorkerError);
- if (this.destroyed) {
- terminateEarly();
- return;
- }
- var supportTypedArray = data && data.supportTypedArray;
- if (supportTypedArray) {
- this._messageHandler = messageHandler;
- this._port = worker;
- this._webWorker = worker;
- if (!data.supportTransfers) {
- isPostMessageTransfersDisabled = true;
- }
- this._readyCapability.resolve();
- messageHandler.send('configure', { verbosity: getVerbosityLevel() });
- } else {
- this._setupFakeWorker();
- messageHandler.destroy();
- worker.terminate();
- }
- }.bind(this));
- messageHandler.on('console_log', function (data) {
- console.log.apply(console, data);
- });
- messageHandler.on('console_error', function (data) {
- console.error.apply(console, data);
- });
- messageHandler.on('ready', function (data) {
- worker.removeEventListener('error', onWorkerError);
- if (this.destroyed) {
- terminateEarly();
- return;
- }
- try {
- sendTest();
- } catch (e) {
- this._setupFakeWorker();
- }
- }.bind(this));
- var sendTest = function () {
- var postMessageTransfers = getDefaultSetting('postMessageTransfers') && !isPostMessageTransfersDisabled;
- var testObj = new Uint8Array([postMessageTransfers ? 255 : 0]);
- try {
- messageHandler.send('test', testObj, [testObj.buffer]);
- } catch (ex) {
- info('Cannot use postMessage transfers');
- testObj[0] = 0;
- messageHandler.send('test', testObj);
- }
- };
- sendTest();
- return;
- } catch (e) {
- info('The worker has been disabled.');
- }
- }
- this._setupFakeWorker();
- },
- _setupFakeWorker: function PDFWorker_setupFakeWorker() {
- if (!isWorkerDisabled && !getDefaultSetting('disableWorker')) {
- warn('Setting up fake worker.');
- isWorkerDisabled = true;
- }
- setupFakeWorkerGlobal().then(function (WorkerMessageHandler) {
- if (this.destroyed) {
- this._readyCapability.reject(new Error('Worker was destroyed'));
- return;
- }
- var isTypedArraysPresent = Uint8Array !== Float32Array;
- var port = new FakeWorkerPort(isTypedArraysPresent);
- this._port = port;
- var id = 'fake' + nextFakeWorkerId++;
- var workerHandler = new MessageHandler(id + '_worker', id, port);
- WorkerMessageHandler.setup(workerHandler, port);
- var messageHandler = new MessageHandler(id, id + '_worker', port);
- this._messageHandler = messageHandler;
- this._readyCapability.resolve();
- }.bind(this));
- },
- destroy: function PDFWorker_destroy() {
- this.destroyed = true;
- if (this._webWorker) {
- this._webWorker.terminate();
- this._webWorker = null;
- }
- this._port = null;
- if (this._messageHandler) {
- this._messageHandler.destroy();
- this._messageHandler = null;
- }
- }
- };
- return PDFWorker;
-}();
-var WorkerTransport = function WorkerTransportClosure() {
- function WorkerTransport(messageHandler, loadingTask, pdfDataRangeTransport, CMapReaderFactory) {
- this.messageHandler = messageHandler;
- this.loadingTask = loadingTask;
- this.pdfDataRangeTransport = pdfDataRangeTransport;
- this.commonObjs = new PDFObjects();
- this.fontLoader = new FontLoader(loadingTask.docId);
- this.CMapReaderFactory = new CMapReaderFactory({
- baseUrl: getDefaultSetting('cMapUrl'),
- isCompressed: getDefaultSetting('cMapPacked')
- });
- this.destroyed = false;
- this.destroyCapability = null;
- this._passwordCapability = null;
- this.pageCache = [];
- this.pagePromises = [];
- this.downloadInfoCapability = createPromiseCapability();
- this.setupMessageHandler();
- }
- WorkerTransport.prototype = {
- destroy: function WorkerTransport_destroy() {
- if (this.destroyCapability) {
- return this.destroyCapability.promise;
- }
- this.destroyed = true;
- this.destroyCapability = createPromiseCapability();
- if (this._passwordCapability) {
- this._passwordCapability.reject(new Error('Worker was destroyed during onPassword callback'));
- }
- var waitOn = [];
- this.pageCache.forEach(function (page) {
- if (page) {
- waitOn.push(page._destroy());
- }
- });
- this.pageCache = [];
- this.pagePromises = [];
- var self = this;
- var terminated = this.messageHandler.sendWithPromise('Terminate', null);
- waitOn.push(terminated);
- Promise.all(waitOn).then(function () {
- self.fontLoader.clear();
- if (self.pdfDataRangeTransport) {
- self.pdfDataRangeTransport.abort();
- self.pdfDataRangeTransport = null;
- }
- if (self.messageHandler) {
- self.messageHandler.destroy();
- self.messageHandler = null;
- }
- self.destroyCapability.resolve();
- }, this.destroyCapability.reject);
- return this.destroyCapability.promise;
- },
- setupMessageHandler: function WorkerTransport_setupMessageHandler() {
- var messageHandler = this.messageHandler;
- var loadingTask = this.loadingTask;
- var pdfDataRangeTransport = this.pdfDataRangeTransport;
- if (pdfDataRangeTransport) {
- pdfDataRangeTransport.addRangeListener(function (begin, chunk) {
- messageHandler.send('OnDataRange', {
- begin: begin,
- chunk: chunk
- });
- });
- pdfDataRangeTransport.addProgressListener(function (loaded) {
- messageHandler.send('OnDataProgress', { loaded: loaded });
- });
- pdfDataRangeTransport.addProgressiveReadListener(function (chunk) {
- messageHandler.send('OnDataRange', { chunk: chunk });
- });
- messageHandler.on('RequestDataRange', function transportDataRange(data) {
- pdfDataRangeTransport.requestDataRange(data.begin, data.end);
- }, this);
- }
- messageHandler.on('GetDoc', function transportDoc(data) {
- var pdfInfo = data.pdfInfo;
- this.numPages = data.pdfInfo.numPages;
- var loadingTask = this.loadingTask;
- var pdfDocument = new PDFDocumentProxy(pdfInfo, this, loadingTask);
- this.pdfDocument = pdfDocument;
- loadingTask._capability.resolve(pdfDocument);
- }, this);
- messageHandler.on('PasswordRequest', function transportPasswordRequest(exception) {
- this._passwordCapability = createPromiseCapability();
- if (loadingTask.onPassword) {
- var updatePassword = function (password) {
- this._passwordCapability.resolve({ password: password });
- }.bind(this);
- loadingTask.onPassword(updatePassword, exception.code);
- } else {
- this._passwordCapability.reject(new PasswordException(exception.message, exception.code));
- }
- return this._passwordCapability.promise;
- }, this);
- messageHandler.on('PasswordException', function transportPasswordException(exception) {
- loadingTask._capability.reject(new PasswordException(exception.message, exception.code));
- }, this);
- messageHandler.on('InvalidPDF', function transportInvalidPDF(exception) {
- this.loadingTask._capability.reject(new InvalidPDFException(exception.message));
- }, this);
- messageHandler.on('MissingPDF', function transportMissingPDF(exception) {
- this.loadingTask._capability.reject(new MissingPDFException(exception.message));
- }, this);
- messageHandler.on('UnexpectedResponse', function transportUnexpectedResponse(exception) {
- this.loadingTask._capability.reject(new UnexpectedResponseException(exception.message, exception.status));
- }, this);
- messageHandler.on('UnknownError', function transportUnknownError(exception) {
- this.loadingTask._capability.reject(new UnknownErrorException(exception.message, exception.details));
- }, this);
- messageHandler.on('DataLoaded', function transportPage(data) {
- this.downloadInfoCapability.resolve(data);
- }, this);
- messageHandler.on('PDFManagerReady', function transportPage(data) {
- if (this.pdfDataRangeTransport) {
- this.pdfDataRangeTransport.transportReady();
- }
- }, this);
- messageHandler.on('StartRenderPage', function transportRender(data) {
- if (this.destroyed) {
- return;
- }
- var page = this.pageCache[data.pageIndex];
- page.stats.timeEnd('Page Request');
- page._startRenderPage(data.transparency, data.intent);
- }, this);
- messageHandler.on('RenderPageChunk', function transportRender(data) {
- if (this.destroyed) {
- return;
- }
- var page = this.pageCache[data.pageIndex];
- page._renderPageChunk(data.operatorList, data.intent);
- }, this);
- messageHandler.on('commonobj', function transportObj(data) {
- if (this.destroyed) {
- return;
- }
- var id = data[0];
- var type = data[1];
- if (this.commonObjs.hasData(id)) {
- return;
- }
- switch (type) {
- case 'Font':
- var exportedData = data[2];
- if ('error' in exportedData) {
- var exportedError = exportedData.error;
- warn('Error during font loading: ' + exportedError);
- this.commonObjs.resolve(id, exportedError);
- break;
- }
- var fontRegistry = null;
- if (getDefaultSetting('pdfBug') && globalScope.FontInspector && globalScope['FontInspector'].enabled) {
- fontRegistry = {
- registerFont: function (font, url) {
- globalScope['FontInspector'].fontAdded(font, url);
- }
- };
- }
- var font = new FontFaceObject(exportedData, {
- isEvalSuported: getDefaultSetting('isEvalSupported'),
- disableFontFace: getDefaultSetting('disableFontFace'),
- fontRegistry: fontRegistry
- });
- this.fontLoader.bind([font], function fontReady(fontObjs) {
- this.commonObjs.resolve(id, font);
- }.bind(this));
- break;
- case 'FontPath':
- this.commonObjs.resolve(id, data[2]);
- break;
- default:
- error('Got unknown common object type ' + type);
- }
- }, this);
- messageHandler.on('obj', function transportObj(data) {
- if (this.destroyed) {
- return;
- }
- var id = data[0];
- var pageIndex = data[1];
- var type = data[2];
- var pageProxy = this.pageCache[pageIndex];
- var imageData;
- if (pageProxy.objs.hasData(id)) {
- return;
- }
- switch (type) {
- case 'JpegStream':
- imageData = data[3];
- loadJpegStream(id, imageData, pageProxy.objs);
- break;
- case 'Image':
- imageData = data[3];
- pageProxy.objs.resolve(id, imageData);
- var MAX_IMAGE_SIZE_TO_STORE = 8000000;
- if (imageData && 'data' in imageData && imageData.data.length > MAX_IMAGE_SIZE_TO_STORE) {
- pageProxy.cleanupAfterRender = true;
- }
- break;
- default:
- error('Got unknown object type ' + type);
- }
- }, this);
- messageHandler.on('DocProgress', function transportDocProgress(data) {
- if (this.destroyed) {
- return;
- }
- var loadingTask = this.loadingTask;
- if (loadingTask.onProgress) {
- loadingTask.onProgress({
- loaded: data.loaded,
- total: data.total
- });
- }
- }, this);
- messageHandler.on('PageError', function transportError(data) {
- if (this.destroyed) {
- return;
- }
- var page = this.pageCache[data.pageNum - 1];
- var intentState = page.intentStates[data.intent];
- if (intentState.displayReadyCapability) {
- intentState.displayReadyCapability.reject(data.error);
- } else {
- error(data.error);
- }
- if (intentState.operatorList) {
- intentState.operatorList.lastChunk = true;
- for (var i = 0; i < intentState.renderTasks.length; i++) {
- intentState.renderTasks[i].operatorListChanged();
- }
- }
- }, this);
- messageHandler.on('UnsupportedFeature', function transportUnsupportedFeature(data) {
- if (this.destroyed) {
- return;
- }
- var featureId = data.featureId;
- var loadingTask = this.loadingTask;
- if (loadingTask.onUnsupportedFeature) {
- loadingTask.onUnsupportedFeature(featureId);
- }
- _UnsupportedManager.notify(featureId);
- }, this);
- messageHandler.on('JpegDecode', function (data) {
- if (this.destroyed) {
- return Promise.reject(new Error('Worker was destroyed'));
- }
- if (typeof document === 'undefined') {
- return Promise.reject(new Error('"document" is not defined.'));
- }
- var imageUrl = data[0];
- var components = data[1];
- if (components !== 3 && components !== 1) {
- return Promise.reject(new Error('Only 3 components or 1 component can be returned'));
- }
- return new Promise(function (resolve, reject) {
- var img = new Image();
- img.onload = function () {
- var width = img.width;
- var height = img.height;
- var size = width * height;
- var rgbaLength = size * 4;
- var buf = new Uint8Array(size * components);
- var tmpCanvas = document.createElement('canvas');
- tmpCanvas.width = width;
- tmpCanvas.height = height;
- var tmpCtx = tmpCanvas.getContext('2d');
- tmpCtx.drawImage(img, 0, 0);
- var data = tmpCtx.getImageData(0, 0, width, height).data;
- var i, j;
- if (components === 3) {
- for (i = 0, j = 0; i < rgbaLength; i += 4, j += 3) {
- buf[j] = data[i];
- buf[j + 1] = data[i + 1];
- buf[j + 2] = data[i + 2];
- }
- } else if (components === 1) {
- for (i = 0, j = 0; i < rgbaLength; i += 4, j++) {
- buf[j] = data[i];
- }
- }
- resolve({
- data: buf,
- width: width,
- height: height
- });
- };
- img.onerror = function () {
- reject(new Error('JpegDecode failed to load image'));
- };
- img.src = imageUrl;
- });
- }, this);
- messageHandler.on('FetchBuiltInCMap', function (data) {
- if (this.destroyed) {
- return Promise.reject(new Error('Worker was destroyed'));
- }
- return this.CMapReaderFactory.fetch({ name: data.name });
- }, this);
- },
- getData: function WorkerTransport_getData() {
- return this.messageHandler.sendWithPromise('GetData', null);
- },
- getPage: function WorkerTransport_getPage(pageNumber, capability) {
- if (!isInt(pageNumber) || pageNumber <= 0 || pageNumber > this.numPages) {
- return Promise.reject(new Error('Invalid page request'));
- }
- var pageIndex = pageNumber - 1;
- if (pageIndex in this.pagePromises) {
- return this.pagePromises[pageIndex];
- }
- var promise = this.messageHandler.sendWithPromise('GetPage', { pageIndex: pageIndex }).then(function (pageInfo) {
- if (this.destroyed) {
- throw new Error('Transport destroyed');
- }
- var page = new PDFPageProxy(pageIndex, pageInfo, this);
- this.pageCache[pageIndex] = page;
- return page;
- }.bind(this));
- this.pagePromises[pageIndex] = promise;
- return promise;
- },
- getPageIndex: function WorkerTransport_getPageIndexByRef(ref) {
- return this.messageHandler.sendWithPromise('GetPageIndex', { ref: ref }).catch(function (reason) {
- return Promise.reject(new Error(reason));
- });
- },
- getAnnotations: function WorkerTransport_getAnnotations(pageIndex, intent) {
- return this.messageHandler.sendWithPromise('GetAnnotations', {
- pageIndex: pageIndex,
- intent: intent
- });
- },
- getDestinations: function WorkerTransport_getDestinations() {
- return this.messageHandler.sendWithPromise('GetDestinations', null);
- },
- getDestination: function WorkerTransport_getDestination(id) {
- return this.messageHandler.sendWithPromise('GetDestination', { id: id });
- },
- getPageLabels: function WorkerTransport_getPageLabels() {
- return this.messageHandler.sendWithPromise('GetPageLabels', null);
- },
- getAttachments: function WorkerTransport_getAttachments() {
- return this.messageHandler.sendWithPromise('GetAttachments', null);
- },
- getJavaScript: function WorkerTransport_getJavaScript() {
- return this.messageHandler.sendWithPromise('GetJavaScript', null);
- },
- getOutline: function WorkerTransport_getOutline() {
- return this.messageHandler.sendWithPromise('GetOutline', null);
- },
- getMetadata: function WorkerTransport_getMetadata() {
- return this.messageHandler.sendWithPromise('GetMetadata', null).then(function transportMetadata(results) {
- return {
- info: results[0],
- metadata: results[1] ? new Metadata(results[1]) : null
- };
- });
- },
- getStats: function WorkerTransport_getStats() {
- return this.messageHandler.sendWithPromise('GetStats', null);
- },
- startCleanup: function WorkerTransport_startCleanup() {
- this.messageHandler.sendWithPromise('Cleanup', null).then(function endCleanup() {
- for (var i = 0, ii = this.pageCache.length; i < ii; i++) {
- var page = this.pageCache[i];
- if (page) {
- page.cleanup();
- }
- }
- this.commonObjs.clear();
- this.fontLoader.clear();
- }.bind(this));
- }
- };
- return WorkerTransport;
-}();
-var PDFObjects = function PDFObjectsClosure() {
- function PDFObjects() {
- this.objs = Object.create(null);
- }
- PDFObjects.prototype = {
- ensureObj: function PDFObjects_ensureObj(objId) {
- if (this.objs[objId]) {
- return this.objs[objId];
- }
- var obj = {
- capability: createPromiseCapability(),
- data: null,
- resolved: false
- };
- this.objs[objId] = obj;
- return obj;
- },
- get: function PDFObjects_get(objId, callback) {
- if (callback) {
- this.ensureObj(objId).capability.promise.then(callback);
- return null;
- }
- var obj = this.objs[objId];
- if (!obj || !obj.resolved) {
- error('Requesting object that isn\'t resolved yet ' + objId);
- }
- return obj.data;
- },
- resolve: function PDFObjects_resolve(objId, data) {
- var obj = this.ensureObj(objId);
- obj.resolved = true;
- obj.data = data;
- obj.capability.resolve(data);
- },
- isResolved: function PDFObjects_isResolved(objId) {
- var objs = this.objs;
- if (!objs[objId]) {
- return false;
- }
- return objs[objId].resolved;
- },
- hasData: function PDFObjects_hasData(objId) {
- return this.isResolved(objId);
- },
- getData: function PDFObjects_getData(objId) {
- var objs = this.objs;
- if (!objs[objId] || !objs[objId].resolved) {
- return null;
- }
- return objs[objId].data;
- },
- clear: function PDFObjects_clear() {
- this.objs = Object.create(null);
- }
- };
- return PDFObjects;
-}();
-var RenderTask = function RenderTaskClosure() {
- function RenderTask(internalRenderTask) {
- this._internalRenderTask = internalRenderTask;
- this.onContinue = null;
- }
- RenderTask.prototype = {
- get promise() {
- return this._internalRenderTask.capability.promise;
- },
- cancel: function RenderTask_cancel() {
- this._internalRenderTask.cancel();
- },
- then: function RenderTask_then(onFulfilled, onRejected) {
- return this.promise.then.apply(this.promise, arguments);
- }
- };
- return RenderTask;
-}();
-var InternalRenderTask = function InternalRenderTaskClosure() {
- function InternalRenderTask(callback, params, objs, commonObjs, operatorList, pageNumber, canvasFactory) {
- this.callback = callback;
- this.params = params;
- this.objs = objs;
- this.commonObjs = commonObjs;
- this.operatorListIdx = null;
- this.operatorList = operatorList;
- this.pageNumber = pageNumber;
- this.canvasFactory = canvasFactory;
- this.running = false;
- this.graphicsReadyCallback = null;
- this.graphicsReady = false;
- this.useRequestAnimationFrame = false;
- this.cancelled = false;
- this.capability = createPromiseCapability();
- this.task = new RenderTask(this);
- this._continueBound = this._continue.bind(this);
- this._scheduleNextBound = this._scheduleNext.bind(this);
- this._nextBound = this._next.bind(this);
- }
- InternalRenderTask.prototype = {
- initializeGraphics: function InternalRenderTask_initializeGraphics(transparency) {
- if (this.cancelled) {
- return;
- }
- if (getDefaultSetting('pdfBug') && globalScope.StepperManager && globalScope.StepperManager.enabled) {
- this.stepper = globalScope.StepperManager.create(this.pageNumber - 1);
- this.stepper.init(this.operatorList);
- this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
- }
- var params = this.params;
- this.gfx = new CanvasGraphics(params.canvasContext, this.commonObjs, this.objs, this.canvasFactory, params.imageLayer);
- this.gfx.beginDrawing(params.transform, params.viewport, transparency);
- this.operatorListIdx = 0;
- this.graphicsReady = true;
- if (this.graphicsReadyCallback) {
- this.graphicsReadyCallback();
- }
- },
- cancel: function InternalRenderTask_cancel() {
- this.running = false;
- this.cancelled = true;
- if (getDefaultSetting('pdfjsNext')) {
- this.callback(new RenderingCancelledException('Rendering cancelled, page ' + this.pageNumber, 'canvas'));
- } else {
- this.callback('cancelled');
- }
- },
- operatorListChanged: function InternalRenderTask_operatorListChanged() {
- if (!this.graphicsReady) {
- if (!this.graphicsReadyCallback) {
- this.graphicsReadyCallback = this._continueBound;
- }
- return;
- }
- if (this.stepper) {
- this.stepper.updateOperatorList(this.operatorList);
- }
- if (this.running) {
- return;
- }
- this._continue();
- },
- _continue: function InternalRenderTask__continue() {
- this.running = true;
- if (this.cancelled) {
- return;
- }
- if (this.task.onContinue) {
- this.task.onContinue(this._scheduleNextBound);
- } else {
- this._scheduleNext();
- }
- },
- _scheduleNext: function InternalRenderTask__scheduleNext() {
- if (this.useRequestAnimationFrame && typeof window !== 'undefined') {
- window.requestAnimationFrame(this._nextBound);
- } else {
- Promise.resolve(undefined).then(this._nextBound);
- }
- },
- _next: function InternalRenderTask__next() {
- if (this.cancelled) {
- return;
- }
- this.operatorListIdx = this.gfx.executeOperatorList(this.operatorList, this.operatorListIdx, this._continueBound, this.stepper);
- if (this.operatorListIdx === this.operatorList.argsArray.length) {
- this.running = false;
- if (this.operatorList.lastChunk) {
- this.gfx.endDrawing();
- this.callback();
- }
- }
- }
- };
- return InternalRenderTask;
-}();
-var _UnsupportedManager = function UnsupportedManagerClosure() {
- var listeners = [];
- return {
- listen: function (cb) {
- deprecated('Global UnsupportedManager.listen is used: ' + ' use PDFDocumentLoadingTask.onUnsupportedFeature instead');
- listeners.push(cb);
- },
- notify: function (featureId) {
- for (var i = 0, ii = listeners.length; i < ii; i++) {
- listeners[i](featureId);
- }
- }
- };
-}();
-exports.version = '1.8.172';
-exports.build = '8ff1fbe7';
-exports.getDocument = getDocument;
-exports.PDFDataRangeTransport = PDFDataRangeTransport;
-exports.PDFWorker = PDFWorker;
-exports.PDFDocumentProxy = PDFDocumentProxy;
-exports.PDFPageProxy = PDFPageProxy;
-exports._UnsupportedManager = _UnsupportedManager;
-
-/***/ }),
-/* 4 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var FONT_IDENTITY_MATRIX = sharedUtil.FONT_IDENTITY_MATRIX;
-var IDENTITY_MATRIX = sharedUtil.IDENTITY_MATRIX;
-var ImageKind = sharedUtil.ImageKind;
-var OPS = sharedUtil.OPS;
-var Util = sharedUtil.Util;
-var isNum = sharedUtil.isNum;
-var isArray = sharedUtil.isArray;
-var warn = sharedUtil.warn;
-var createObjectURL = sharedUtil.createObjectURL;
-var SVG_DEFAULTS = {
- fontStyle: 'normal',
- fontWeight: 'normal',
- fillColor: '#000000'
-};
-var convertImgDataToPng = function convertImgDataToPngClosure() {
- var PNG_HEADER = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
- var CHUNK_WRAPPER_SIZE = 12;
- var crcTable = new Int32Array(256);
- for (var i = 0; i < 256; i++) {
- var c = i;
- for (var h = 0; h < 8; h++) {
- if (c & 1) {
- c = 0xedB88320 ^ c >> 1 & 0x7fffffff;
- } else {
- c = c >> 1 & 0x7fffffff;
- }
- }
- crcTable[i] = c;
- }
- function crc32(data, start, end) {
- var crc = -1;
- for (var i = start; i < end; i++) {
- var a = (crc ^ data[i]) & 0xff;
- var b = crcTable[a];
- crc = crc >>> 8 ^ b;
- }
- return crc ^ -1;
- }
- function writePngChunk(type, body, data, offset) {
- var p = offset;
- var len = body.length;
- data[p] = len >> 24 & 0xff;
- data[p + 1] = len >> 16 & 0xff;
- data[p + 2] = len >> 8 & 0xff;
- data[p + 3] = len & 0xff;
- p += 4;
- data[p] = type.charCodeAt(0) & 0xff;
- data[p + 1] = type.charCodeAt(1) & 0xff;
- data[p + 2] = type.charCodeAt(2) & 0xff;
- data[p + 3] = type.charCodeAt(3) & 0xff;
- p += 4;
- data.set(body, p);
- p += body.length;
- var crc = crc32(data, offset + 4, p);
- data[p] = crc >> 24 & 0xff;
- data[p + 1] = crc >> 16 & 0xff;
- data[p + 2] = crc >> 8 & 0xff;
- data[p + 3] = crc & 0xff;
- }
- function adler32(data, start, end) {
- var a = 1;
- var b = 0;
- for (var i = start; i < end; ++i) {
- a = (a + (data[i] & 0xff)) % 65521;
- b = (b + a) % 65521;
- }
- return b << 16 | a;
- }
- function encode(imgData, kind, forceDataSchema) {
- var width = imgData.width;
- var height = imgData.height;
- var bitDepth, colorType, lineSize;
- var bytes = imgData.data;
- switch (kind) {
- case ImageKind.GRAYSCALE_1BPP:
- colorType = 0;
- bitDepth = 1;
- lineSize = width + 7 >> 3;
- break;
- case ImageKind.RGB_24BPP:
- colorType = 2;
- bitDepth = 8;
- lineSize = width * 3;
- break;
- case ImageKind.RGBA_32BPP:
- colorType = 6;
- bitDepth = 8;
- lineSize = width * 4;
- break;
- default:
- throw new Error('invalid format');
- }
- var literals = new Uint8Array((1 + lineSize) * height);
- var offsetLiterals = 0,
- offsetBytes = 0;
- var y, i;
- for (y = 0; y < height; ++y) {
- literals[offsetLiterals++] = 0;
- literals.set(bytes.subarray(offsetBytes, offsetBytes + lineSize), offsetLiterals);
- offsetBytes += lineSize;
- offsetLiterals += lineSize;
- }
- if (kind === ImageKind.GRAYSCALE_1BPP) {
- offsetLiterals = 0;
- for (y = 0; y < height; y++) {
- offsetLiterals++;
- for (i = 0; i < lineSize; i++) {
- literals[offsetLiterals++] ^= 0xFF;
- }
- }
- }
- var ihdr = new Uint8Array([width >> 24 & 0xff, width >> 16 & 0xff, width >> 8 & 0xff, width & 0xff, height >> 24 & 0xff, height >> 16 & 0xff, height >> 8 & 0xff, height & 0xff, bitDepth, colorType, 0x00, 0x00, 0x00]);
- var len = literals.length;
- var maxBlockLength = 0xFFFF;
- var deflateBlocks = Math.ceil(len / maxBlockLength);
- var idat = new Uint8Array(2 + len + deflateBlocks * 5 + 4);
- var pi = 0;
- idat[pi++] = 0x78;
- idat[pi++] = 0x9c;
- var pos = 0;
- while (len > maxBlockLength) {
- idat[pi++] = 0x00;
- idat[pi++] = 0xff;
- idat[pi++] = 0xff;
- idat[pi++] = 0x00;
- idat[pi++] = 0x00;
- idat.set(literals.subarray(pos, pos + maxBlockLength), pi);
- pi += maxBlockLength;
- pos += maxBlockLength;
- len -= maxBlockLength;
- }
- idat[pi++] = 0x01;
- idat[pi++] = len & 0xff;
- idat[pi++] = len >> 8 & 0xff;
- idat[pi++] = ~len & 0xffff & 0xff;
- idat[pi++] = (~len & 0xffff) >> 8 & 0xff;
- idat.set(literals.subarray(pos), pi);
- pi += literals.length - pos;
- var adler = adler32(literals, 0, literals.length);
- idat[pi++] = adler >> 24 & 0xff;
- idat[pi++] = adler >> 16 & 0xff;
- idat[pi++] = adler >> 8 & 0xff;
- idat[pi++] = adler & 0xff;
- var pngLength = PNG_HEADER.length + CHUNK_WRAPPER_SIZE * 3 + ihdr.length + idat.length;
- var data = new Uint8Array(pngLength);
- var offset = 0;
- data.set(PNG_HEADER, offset);
- offset += PNG_HEADER.length;
- writePngChunk('IHDR', ihdr, data, offset);
- offset += CHUNK_WRAPPER_SIZE + ihdr.length;
- writePngChunk('IDATA', idat, data, offset);
- offset += CHUNK_WRAPPER_SIZE + idat.length;
- writePngChunk('IEND', new Uint8Array(0), data, offset);
- return createObjectURL(data, 'image/png', forceDataSchema);
- }
- return function convertImgDataToPng(imgData, forceDataSchema) {
- var kind = imgData.kind === undefined ? ImageKind.GRAYSCALE_1BPP : imgData.kind;
- return encode(imgData, kind, forceDataSchema);
- };
-}();
-var SVGExtraState = function SVGExtraStateClosure() {
- function SVGExtraState() {
- this.fontSizeScale = 1;
- this.fontWeight = SVG_DEFAULTS.fontWeight;
- this.fontSize = 0;
- this.textMatrix = IDENTITY_MATRIX;
- this.fontMatrix = FONT_IDENTITY_MATRIX;
- this.leading = 0;
- this.x = 0;
- this.y = 0;
- this.lineX = 0;
- this.lineY = 0;
- this.charSpacing = 0;
- this.wordSpacing = 0;
- this.textHScale = 1;
- this.textRise = 0;
- this.fillColor = SVG_DEFAULTS.fillColor;
- this.strokeColor = '#000000';
- this.fillAlpha = 1;
- this.strokeAlpha = 1;
- this.lineWidth = 1;
- this.lineJoin = '';
- this.lineCap = '';
- this.miterLimit = 0;
- this.dashArray = [];
- this.dashPhase = 0;
- this.dependencies = [];
- this.activeClipUrl = null;
- this.clipGroup = null;
- this.maskId = '';
- }
- SVGExtraState.prototype = {
- clone: function SVGExtraState_clone() {
- return Object.create(this);
- },
- setCurrentPoint: function SVGExtraState_setCurrentPoint(x, y) {
- this.x = x;
- this.y = y;
- }
- };
- return SVGExtraState;
-}();
-var SVGGraphics = function SVGGraphicsClosure() {
- function opListToTree(opList) {
- var opTree = [];
- var tmp = [];
- var opListLen = opList.length;
- for (var x = 0; x < opListLen; x++) {
- if (opList[x].fn === 'save') {
- opTree.push({
- 'fnId': 92,
- 'fn': 'group',
- 'items': []
- });
- tmp.push(opTree);
- opTree = opTree[opTree.length - 1].items;
- continue;
- }
- if (opList[x].fn === 'restore') {
- opTree = tmp.pop();
- } else {
- opTree.push(opList[x]);
- }
- }
- return opTree;
- }
- function pf(value) {
- if (value === (value | 0)) {
- return value.toString();
- }
- var s = value.toFixed(10);
- var i = s.length - 1;
- if (s[i] !== '0') {
- return s;
- }
- do {
- i--;
- } while (s[i] === '0');
- return s.substr(0, s[i] === '.' ? i : i + 1);
- }
- function pm(m) {
- if (m[4] === 0 && m[5] === 0) {
- if (m[1] === 0 && m[2] === 0) {
- if (m[0] === 1 && m[3] === 1) {
- return '';
- }
- return 'scale(' + pf(m[0]) + ' ' + pf(m[3]) + ')';
- }
- if (m[0] === m[3] && m[1] === -m[2]) {
- var a = Math.acos(m[0]) * 180 / Math.PI;
- return 'rotate(' + pf(a) + ')';
- }
- } else {
- if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
- return 'translate(' + pf(m[4]) + ' ' + pf(m[5]) + ')';
- }
- }
- return 'matrix(' + pf(m[0]) + ' ' + pf(m[1]) + ' ' + pf(m[2]) + ' ' + pf(m[3]) + ' ' + pf(m[4]) + ' ' + pf(m[5]) + ')';
- }
- function SVGGraphics(commonObjs, objs, forceDataSchema) {
- this.current = new SVGExtraState();
- this.transformMatrix = IDENTITY_MATRIX;
- this.transformStack = [];
- this.extraStack = [];
- this.commonObjs = commonObjs;
- this.objs = objs;
- this.pendingEOFill = false;
- this.embedFonts = false;
- this.embeddedFonts = Object.create(null);
- this.cssStyle = null;
- this.forceDataSchema = !!forceDataSchema;
- }
- var NS = 'http://www.w3.org/2000/svg';
- var XML_NS = 'http://www.w3.org/XML/1998/namespace';
- var XLINK_NS = 'http://www.w3.org/1999/xlink';
- var LINE_CAP_STYLES = ['butt', 'round', 'square'];
- var LINE_JOIN_STYLES = ['miter', 'round', 'bevel'];
- var clipCount = 0;
- var maskCount = 0;
- SVGGraphics.prototype = {
- save: function SVGGraphics_save() {
- this.transformStack.push(this.transformMatrix);
- var old = this.current;
- this.extraStack.push(old);
- this.current = old.clone();
- },
- restore: function SVGGraphics_restore() {
- this.transformMatrix = this.transformStack.pop();
- this.current = this.extraStack.pop();
- this.tgrp = null;
- },
- group: function SVGGraphics_group(items) {
- this.save();
- this.executeOpTree(items);
- this.restore();
- },
- loadDependencies: function SVGGraphics_loadDependencies(operatorList) {
- var fnArray = operatorList.fnArray;
- var fnArrayLen = fnArray.length;
- var argsArray = operatorList.argsArray;
- var self = this;
- for (var i = 0; i < fnArrayLen; i++) {
- if (OPS.dependency === fnArray[i]) {
- var deps = argsArray[i];
- for (var n = 0, nn = deps.length; n < nn; n++) {
- var obj = deps[n];
- var common = obj.substring(0, 2) === 'g_';
- var promise;
- if (common) {
- promise = new Promise(function (resolve) {
- self.commonObjs.get(obj, resolve);
- });
- } else {
- promise = new Promise(function (resolve) {
- self.objs.get(obj, resolve);
- });
- }
- this.current.dependencies.push(promise);
- }
- }
- }
- return Promise.all(this.current.dependencies);
- },
- transform: function SVGGraphics_transform(a, b, c, d, e, f) {
- var transformMatrix = [a, b, c, d, e, f];
- this.transformMatrix = Util.transform(this.transformMatrix, transformMatrix);
- this.tgrp = null;
- },
- getSVG: function SVGGraphics_getSVG(operatorList, viewport) {
- this.viewport = viewport;
- var svgElement = this._initialize(viewport);
- return this.loadDependencies(operatorList).then(function () {
- this.transformMatrix = IDENTITY_MATRIX;
- var opTree = this.convertOpList(operatorList);
- this.executeOpTree(opTree);
- return svgElement;
- }.bind(this));
- },
- convertOpList: function SVGGraphics_convertOpList(operatorList) {
- var argsArray = operatorList.argsArray;
- var fnArray = operatorList.fnArray;
- var fnArrayLen = fnArray.length;
- var REVOPS = [];
- var opList = [];
- for (var op in OPS) {
- REVOPS[OPS[op]] = op;
- }
- for (var x = 0; x < fnArrayLen; x++) {
- var fnId = fnArray[x];
- opList.push({
- 'fnId': fnId,
- 'fn': REVOPS[fnId],
- 'args': argsArray[x]
- });
- }
- return opListToTree(opList);
- },
- executeOpTree: function SVGGraphics_executeOpTree(opTree) {
- var opTreeLen = opTree.length;
- for (var x = 0; x < opTreeLen; x++) {
- var fn = opTree[x].fn;
- var fnId = opTree[x].fnId;
- var args = opTree[x].args;
- switch (fnId | 0) {
- case OPS.beginText:
- this.beginText();
- break;
- case OPS.setLeading:
- this.setLeading(args);
- break;
- case OPS.setLeadingMoveText:
- this.setLeadingMoveText(args[0], args[1]);
- break;
- case OPS.setFont:
- this.setFont(args);
- break;
- case OPS.showText:
- this.showText(args[0]);
- break;
- case OPS.showSpacedText:
- this.showText(args[0]);
- break;
- case OPS.endText:
- this.endText();
- break;
- case OPS.moveText:
- this.moveText(args[0], args[1]);
- break;
- case OPS.setCharSpacing:
- this.setCharSpacing(args[0]);
- break;
- case OPS.setWordSpacing:
- this.setWordSpacing(args[0]);
- break;
- case OPS.setHScale:
- this.setHScale(args[0]);
- break;
- case OPS.setTextMatrix:
- this.setTextMatrix(args[0], args[1], args[2], args[3], args[4], args[5]);
- break;
- case OPS.setLineWidth:
- this.setLineWidth(args[0]);
- break;
- case OPS.setLineJoin:
- this.setLineJoin(args[0]);
- break;
- case OPS.setLineCap:
- this.setLineCap(args[0]);
- break;
- case OPS.setMiterLimit:
- this.setMiterLimit(args[0]);
- break;
- case OPS.setFillRGBColor:
- this.setFillRGBColor(args[0], args[1], args[2]);
- break;
- case OPS.setStrokeRGBColor:
- this.setStrokeRGBColor(args[0], args[1], args[2]);
- break;
- case OPS.setDash:
- this.setDash(args[0], args[1]);
- break;
- case OPS.setGState:
- this.setGState(args[0]);
- break;
- case OPS.fill:
- this.fill();
- break;
- case OPS.eoFill:
- this.eoFill();
- break;
- case OPS.stroke:
- this.stroke();
- break;
- case OPS.fillStroke:
- this.fillStroke();
- break;
- case OPS.eoFillStroke:
- this.eoFillStroke();
- break;
- case OPS.clip:
- this.clip('nonzero');
- break;
- case OPS.eoClip:
- this.clip('evenodd');
- break;
- case OPS.paintSolidColorImageMask:
- this.paintSolidColorImageMask();
- break;
- case OPS.paintJpegXObject:
- this.paintJpegXObject(args[0], args[1], args[2]);
- break;
- case OPS.paintImageXObject:
- this.paintImageXObject(args[0]);
- break;
- case OPS.paintInlineImageXObject:
- this.paintInlineImageXObject(args[0]);
- break;
- case OPS.paintImageMaskXObject:
- this.paintImageMaskXObject(args[0]);
- break;
- case OPS.paintFormXObjectBegin:
- this.paintFormXObjectBegin(args[0], args[1]);
- break;
- case OPS.paintFormXObjectEnd:
- this.paintFormXObjectEnd();
- break;
- case OPS.closePath:
- this.closePath();
- break;
- case OPS.closeStroke:
- this.closeStroke();
- break;
- case OPS.closeFillStroke:
- this.closeFillStroke();
- break;
- case OPS.nextLine:
- this.nextLine();
- break;
- case OPS.transform:
- this.transform(args[0], args[1], args[2], args[3], args[4], args[5]);
- break;
- case OPS.constructPath:
- this.constructPath(args[0], args[1]);
- break;
- case OPS.endPath:
- this.endPath();
- break;
- case 92:
- this.group(opTree[x].items);
- break;
- default:
- warn('Unimplemented operator ' + fn);
- break;
- }
- }
- },
- setWordSpacing: function SVGGraphics_setWordSpacing(wordSpacing) {
- this.current.wordSpacing = wordSpacing;
- },
- setCharSpacing: function SVGGraphics_setCharSpacing(charSpacing) {
- this.current.charSpacing = charSpacing;
- },
- nextLine: function SVGGraphics_nextLine() {
- this.moveText(0, this.current.leading);
- },
- setTextMatrix: function SVGGraphics_setTextMatrix(a, b, c, d, e, f) {
- var current = this.current;
- this.current.textMatrix = this.current.lineMatrix = [a, b, c, d, e, f];
- this.current.x = this.current.lineX = 0;
- this.current.y = this.current.lineY = 0;
- current.xcoords = [];
- current.tspan = document.createElementNS(NS, 'svg:tspan');
- current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
- current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
- current.tspan.setAttributeNS(null, 'y', pf(-current.y));
- current.txtElement = document.createElementNS(NS, 'svg:text');
- current.txtElement.appendChild(current.tspan);
- },
- beginText: function SVGGraphics_beginText() {
- this.current.x = this.current.lineX = 0;
- this.current.y = this.current.lineY = 0;
- this.current.textMatrix = IDENTITY_MATRIX;
- this.current.lineMatrix = IDENTITY_MATRIX;
- this.current.tspan = document.createElementNS(NS, 'svg:tspan');
- this.current.txtElement = document.createElementNS(NS, 'svg:text');
- this.current.txtgrp = document.createElementNS(NS, 'svg:g');
- this.current.xcoords = [];
- },
- moveText: function SVGGraphics_moveText(x, y) {
- var current = this.current;
- this.current.x = this.current.lineX += x;
- this.current.y = this.current.lineY += y;
- current.xcoords = [];
- current.tspan = document.createElementNS(NS, 'svg:tspan');
- current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
- current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
- current.tspan.setAttributeNS(null, 'y', pf(-current.y));
- },
- showText: function SVGGraphics_showText(glyphs) {
- var current = this.current;
- var font = current.font;
- var fontSize = current.fontSize;
- if (fontSize === 0) {
- return;
- }
- var charSpacing = current.charSpacing;
- var wordSpacing = current.wordSpacing;
- var fontDirection = current.fontDirection;
- var textHScale = current.textHScale * fontDirection;
- var glyphsLength = glyphs.length;
- var vertical = font.vertical;
- var widthAdvanceScale = fontSize * current.fontMatrix[0];
- var x = 0,
- i;
- for (i = 0; i < glyphsLength; ++i) {
- var glyph = glyphs[i];
- if (glyph === null) {
- x += fontDirection * wordSpacing;
- continue;
- } else if (isNum(glyph)) {
- x += -glyph * fontSize * 0.001;
- continue;
- }
- current.xcoords.push(current.x + x * textHScale);
- var width = glyph.width;
- var character = glyph.fontChar;
- var charWidth = width * widthAdvanceScale + charSpacing * fontDirection;
- x += charWidth;
- current.tspan.textContent += character;
- }
- if (vertical) {
- current.y -= x * textHScale;
- } else {
- current.x += x * textHScale;
- }
- current.tspan.setAttributeNS(null, 'x', current.xcoords.map(pf).join(' '));
- current.tspan.setAttributeNS(null, 'y', pf(-current.y));
- current.tspan.setAttributeNS(null, 'font-family', current.fontFamily);
- current.tspan.setAttributeNS(null, 'font-size', pf(current.fontSize) + 'px');
- if (current.fontStyle !== SVG_DEFAULTS.fontStyle) {
- current.tspan.setAttributeNS(null, 'font-style', current.fontStyle);
- }
- if (current.fontWeight !== SVG_DEFAULTS.fontWeight) {
- current.tspan.setAttributeNS(null, 'font-weight', current.fontWeight);
- }
- if (current.fillColor !== SVG_DEFAULTS.fillColor) {
- current.tspan.setAttributeNS(null, 'fill', current.fillColor);
- }
- current.txtElement.setAttributeNS(null, 'transform', pm(current.textMatrix) + ' scale(1, -1)');
- current.txtElement.setAttributeNS(XML_NS, 'xml:space', 'preserve');
- current.txtElement.appendChild(current.tspan);
- current.txtgrp.appendChild(current.txtElement);
- this._ensureTransformGroup().appendChild(current.txtElement);
- },
- setLeadingMoveText: function SVGGraphics_setLeadingMoveText(x, y) {
- this.setLeading(-y);
- this.moveText(x, y);
- },
- addFontStyle: function SVGGraphics_addFontStyle(fontObj) {
- if (!this.cssStyle) {
- this.cssStyle = document.createElementNS(NS, 'svg:style');
- this.cssStyle.setAttributeNS(null, 'type', 'text/css');
- this.defs.appendChild(this.cssStyle);
- }
- var url = createObjectURL(fontObj.data, fontObj.mimetype, this.forceDataSchema);
- this.cssStyle.textContent += '@font-face { font-family: "' + fontObj.loadedName + '";' + ' src: url(' + url + '); }\n';
- },
- setFont: function SVGGraphics_setFont(details) {
- var current = this.current;
- var fontObj = this.commonObjs.get(details[0]);
- var size = details[1];
- this.current.font = fontObj;
- if (this.embedFonts && fontObj.data && !this.embeddedFonts[fontObj.loadedName]) {
- this.addFontStyle(fontObj);
- this.embeddedFonts[fontObj.loadedName] = fontObj;
- }
- current.fontMatrix = fontObj.fontMatrix ? fontObj.fontMatrix : FONT_IDENTITY_MATRIX;
- var bold = fontObj.black ? fontObj.bold ? 'bolder' : 'bold' : fontObj.bold ? 'bold' : 'normal';
- var italic = fontObj.italic ? 'italic' : 'normal';
- if (size < 0) {
- size = -size;
- current.fontDirection = -1;
- } else {
- current.fontDirection = 1;
- }
- current.fontSize = size;
- current.fontFamily = fontObj.loadedName;
- current.fontWeight = bold;
- current.fontStyle = italic;
- current.tspan = document.createElementNS(NS, 'svg:tspan');
- current.tspan.setAttributeNS(null, 'y', pf(-current.y));
- current.xcoords = [];
- },
- endText: function SVGGraphics_endText() {},
- setLineWidth: function SVGGraphics_setLineWidth(width) {
- this.current.lineWidth = width;
- },
- setLineCap: function SVGGraphics_setLineCap(style) {
- this.current.lineCap = LINE_CAP_STYLES[style];
- },
- setLineJoin: function SVGGraphics_setLineJoin(style) {
- this.current.lineJoin = LINE_JOIN_STYLES[style];
- },
- setMiterLimit: function SVGGraphics_setMiterLimit(limit) {
- this.current.miterLimit = limit;
- },
- setStrokeRGBColor: function SVGGraphics_setStrokeRGBColor(r, g, b) {
- var color = Util.makeCssRgb(r, g, b);
- this.current.strokeColor = color;
- },
- setFillRGBColor: function SVGGraphics_setFillRGBColor(r, g, b) {
- var color = Util.makeCssRgb(r, g, b);
- this.current.fillColor = color;
- this.current.tspan = document.createElementNS(NS, 'svg:tspan');
- this.current.xcoords = [];
- },
- setDash: function SVGGraphics_setDash(dashArray, dashPhase) {
- this.current.dashArray = dashArray;
- this.current.dashPhase = dashPhase;
- },
- constructPath: function SVGGraphics_constructPath(ops, args) {
- var current = this.current;
- var x = current.x,
- y = current.y;
- current.path = document.createElementNS(NS, 'svg:path');
- var d = [];
- var opLength = ops.length;
- for (var i = 0, j = 0; i < opLength; i++) {
- switch (ops[i] | 0) {
- case OPS.rectangle:
- x = args[j++];
- y = args[j++];
- var width = args[j++];
- var height = args[j++];
- var xw = x + width;
- var yh = y + height;
- d.push('M', pf(x), pf(y), 'L', pf(xw), pf(y), 'L', pf(xw), pf(yh), 'L', pf(x), pf(yh), 'Z');
- break;
- case OPS.moveTo:
- x = args[j++];
- y = args[j++];
- d.push('M', pf(x), pf(y));
- break;
- case OPS.lineTo:
- x = args[j++];
- y = args[j++];
- d.push('L', pf(x), pf(y));
- break;
- case OPS.curveTo:
- x = args[j + 4];
- y = args[j + 5];
- d.push('C', pf(args[j]), pf(args[j + 1]), pf(args[j + 2]), pf(args[j + 3]), pf(x), pf(y));
- j += 6;
- break;
- case OPS.curveTo2:
- x = args[j + 2];
- y = args[j + 3];
- d.push('C', pf(x), pf(y), pf(args[j]), pf(args[j + 1]), pf(args[j + 2]), pf(args[j + 3]));
- j += 4;
- break;
- case OPS.curveTo3:
- x = args[j + 2];
- y = args[j + 3];
- d.push('C', pf(args[j]), pf(args[j + 1]), pf(x), pf(y), pf(x), pf(y));
- j += 4;
- break;
- case OPS.closePath:
- d.push('Z');
- break;
- }
- }
- current.path.setAttributeNS(null, 'd', d.join(' '));
- current.path.setAttributeNS(null, 'stroke-miterlimit', pf(current.miterLimit));
- current.path.setAttributeNS(null, 'stroke-linecap', current.lineCap);
- current.path.setAttributeNS(null, 'stroke-linejoin', current.lineJoin);
- current.path.setAttributeNS(null, 'stroke-width', pf(current.lineWidth) + 'px');
- current.path.setAttributeNS(null, 'stroke-dasharray', current.dashArray.map(pf).join(' '));
- current.path.setAttributeNS(null, 'stroke-dashoffset', pf(current.dashPhase) + 'px');
- current.path.setAttributeNS(null, 'fill', 'none');
- this._ensureTransformGroup().appendChild(current.path);
- current.element = current.path;
- current.setCurrentPoint(x, y);
- },
- endPath: function SVGGraphics_endPath() {},
- clip: function SVGGraphics_clip(type) {
- var current = this.current;
- var clipId = 'clippath' + clipCount;
- clipCount++;
- var clipPath = document.createElementNS(NS, 'svg:clipPath');
- clipPath.setAttributeNS(null, 'id', clipId);
- clipPath.setAttributeNS(null, 'transform', pm(this.transformMatrix));
- var clipElement = current.element.cloneNode();
- if (type === 'evenodd') {
- clipElement.setAttributeNS(null, 'clip-rule', 'evenodd');
- } else {
- clipElement.setAttributeNS(null, 'clip-rule', 'nonzero');
- }
- clipPath.appendChild(clipElement);
- this.defs.appendChild(clipPath);
- if (current.activeClipUrl) {
- current.clipGroup = null;
- this.extraStack.forEach(function (prev) {
- prev.clipGroup = null;
- });
- }
- current.activeClipUrl = 'url(#' + clipId + ')';
- this.tgrp = null;
- },
- closePath: function SVGGraphics_closePath() {
- var current = this.current;
- var d = current.path.getAttributeNS(null, 'd');
- d += 'Z';
- current.path.setAttributeNS(null, 'd', d);
- },
- setLeading: function SVGGraphics_setLeading(leading) {
- this.current.leading = -leading;
- },
- setTextRise: function SVGGraphics_setTextRise(textRise) {
- this.current.textRise = textRise;
- },
- setHScale: function SVGGraphics_setHScale(scale) {
- this.current.textHScale = scale / 100;
- },
- setGState: function SVGGraphics_setGState(states) {
- for (var i = 0, ii = states.length; i < ii; i++) {
- var state = states[i];
- var key = state[0];
- var value = state[1];
- switch (key) {
- case 'LW':
- this.setLineWidth(value);
- break;
- case 'LC':
- this.setLineCap(value);
- break;
- case 'LJ':
- this.setLineJoin(value);
- break;
- case 'ML':
- this.setMiterLimit(value);
- break;
- case 'D':
- this.setDash(value[0], value[1]);
- break;
- case 'Font':
- this.setFont(value);
- break;
- default:
- warn('Unimplemented graphic state ' + key);
- break;
- }
- }
- },
- fill: function SVGGraphics_fill() {
- var current = this.current;
- current.element.setAttributeNS(null, 'fill', current.fillColor);
- },
- stroke: function SVGGraphics_stroke() {
- var current = this.current;
- current.element.setAttributeNS(null, 'stroke', current.strokeColor);
- current.element.setAttributeNS(null, 'fill', 'none');
- },
- eoFill: function SVGGraphics_eoFill() {
- var current = this.current;
- current.element.setAttributeNS(null, 'fill', current.fillColor);
- current.element.setAttributeNS(null, 'fill-rule', 'evenodd');
- },
- fillStroke: function SVGGraphics_fillStroke() {
- this.stroke();
- this.fill();
- },
- eoFillStroke: function SVGGraphics_eoFillStroke() {
- this.current.element.setAttributeNS(null, 'fill-rule', 'evenodd');
- this.fillStroke();
- },
- closeStroke: function SVGGraphics_closeStroke() {
- this.closePath();
- this.stroke();
- },
- closeFillStroke: function SVGGraphics_closeFillStroke() {
- this.closePath();
- this.fillStroke();
- },
- paintSolidColorImageMask: function SVGGraphics_paintSolidColorImageMask() {
- var current = this.current;
- var rect = document.createElementNS(NS, 'svg:rect');
- rect.setAttributeNS(null, 'x', '0');
- rect.setAttributeNS(null, 'y', '0');
- rect.setAttributeNS(null, 'width', '1px');
- rect.setAttributeNS(null, 'height', '1px');
- rect.setAttributeNS(null, 'fill', current.fillColor);
- this._ensureTransformGroup().appendChild(rect);
- },
- paintJpegXObject: function SVGGraphics_paintJpegXObject(objId, w, h) {
- var imgObj = this.objs.get(objId);
- var imgEl = document.createElementNS(NS, 'svg:image');
- imgEl.setAttributeNS(XLINK_NS, 'xlink:href', imgObj.src);
- imgEl.setAttributeNS(null, 'width', imgObj.width + 'px');
- imgEl.setAttributeNS(null, 'height', imgObj.height + 'px');
- imgEl.setAttributeNS(null, 'x', '0');
- imgEl.setAttributeNS(null, 'y', pf(-h));
- imgEl.setAttributeNS(null, 'transform', 'scale(' + pf(1 / w) + ' ' + pf(-1 / h) + ')');
- this._ensureTransformGroup().appendChild(imgEl);
- },
- paintImageXObject: function SVGGraphics_paintImageXObject(objId) {
- var imgData = this.objs.get(objId);
- if (!imgData) {
- warn('Dependent image isn\'t ready yet');
- return;
- }
- this.paintInlineImageXObject(imgData);
- },
- paintInlineImageXObject: function SVGGraphics_paintInlineImageXObject(imgData, mask) {
- var width = imgData.width;
- var height = imgData.height;
- var imgSrc = convertImgDataToPng(imgData, this.forceDataSchema);
- var cliprect = document.createElementNS(NS, 'svg:rect');
- cliprect.setAttributeNS(null, 'x', '0');
- cliprect.setAttributeNS(null, 'y', '0');
- cliprect.setAttributeNS(null, 'width', pf(width));
- cliprect.setAttributeNS(null, 'height', pf(height));
- this.current.element = cliprect;
- this.clip('nonzero');
- var imgEl = document.createElementNS(NS, 'svg:image');
- imgEl.setAttributeNS(XLINK_NS, 'xlink:href', imgSrc);
- imgEl.setAttributeNS(null, 'x', '0');
- imgEl.setAttributeNS(null, 'y', pf(-height));
- imgEl.setAttributeNS(null, 'width', pf(width) + 'px');
- imgEl.setAttributeNS(null, 'height', pf(height) + 'px');
- imgEl.setAttributeNS(null, 'transform', 'scale(' + pf(1 / width) + ' ' + pf(-1 / height) + ')');
- if (mask) {
- mask.appendChild(imgEl);
- } else {
- this._ensureTransformGroup().appendChild(imgEl);
- }
- },
- paintImageMaskXObject: function SVGGraphics_paintImageMaskXObject(imgData) {
- var current = this.current;
- var width = imgData.width;
- var height = imgData.height;
- var fillColor = current.fillColor;
- current.maskId = 'mask' + maskCount++;
- var mask = document.createElementNS(NS, 'svg:mask');
- mask.setAttributeNS(null, 'id', current.maskId);
- var rect = document.createElementNS(NS, 'svg:rect');
- rect.setAttributeNS(null, 'x', '0');
- rect.setAttributeNS(null, 'y', '0');
- rect.setAttributeNS(null, 'width', pf(width));
- rect.setAttributeNS(null, 'height', pf(height));
- rect.setAttributeNS(null, 'fill', fillColor);
- rect.setAttributeNS(null, 'mask', 'url(#' + current.maskId + ')');
- this.defs.appendChild(mask);
- this._ensureTransformGroup().appendChild(rect);
- this.paintInlineImageXObject(imgData, mask);
- },
- paintFormXObjectBegin: function SVGGraphics_paintFormXObjectBegin(matrix, bbox) {
- if (isArray(matrix) && matrix.length === 6) {
- this.transform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
- }
- if (isArray(bbox) && bbox.length === 4) {
- var width = bbox[2] - bbox[0];
- var height = bbox[3] - bbox[1];
- var cliprect = document.createElementNS(NS, 'svg:rect');
- cliprect.setAttributeNS(null, 'x', bbox[0]);
- cliprect.setAttributeNS(null, 'y', bbox[1]);
- cliprect.setAttributeNS(null, 'width', pf(width));
- cliprect.setAttributeNS(null, 'height', pf(height));
- this.current.element = cliprect;
- this.clip('nonzero');
- this.endPath();
- }
- },
- paintFormXObjectEnd: function SVGGraphics_paintFormXObjectEnd() {},
- _initialize: function SVGGraphics_initialize(viewport) {
- var svg = document.createElementNS(NS, 'svg:svg');
- svg.setAttributeNS(null, 'version', '1.1');
- svg.setAttributeNS(null, 'width', viewport.width + 'px');
- svg.setAttributeNS(null, 'height', viewport.height + 'px');
- svg.setAttributeNS(null, 'preserveAspectRatio', 'none');
- svg.setAttributeNS(null, 'viewBox', '0 0 ' + viewport.width + ' ' + viewport.height);
- var definitions = document.createElementNS(NS, 'svg:defs');
- svg.appendChild(definitions);
- this.defs = definitions;
- var rootGroup = document.createElementNS(NS, 'svg:g');
- rootGroup.setAttributeNS(null, 'transform', pm(viewport.transform));
- svg.appendChild(rootGroup);
- this.svg = rootGroup;
- return svg;
- },
- _ensureClipGroup: function SVGGraphics_ensureClipGroup() {
- if (!this.current.clipGroup) {
- var clipGroup = document.createElementNS(NS, 'svg:g');
- clipGroup.setAttributeNS(null, 'clip-path', this.current.activeClipUrl);
- this.svg.appendChild(clipGroup);
- this.current.clipGroup = clipGroup;
- }
- return this.current.clipGroup;
- },
- _ensureTransformGroup: function SVGGraphics_ensureTransformGroup() {
- if (!this.tgrp) {
- this.tgrp = document.createElementNS(NS, 'svg:g');
- this.tgrp.setAttributeNS(null, 'transform', pm(this.transformMatrix));
- if (this.current.activeClipUrl) {
- this._ensureClipGroup().appendChild(this.tgrp);
- } else {
- this.svg.appendChild(this.tgrp);
- }
- }
- return this.tgrp;
- }
- };
- return SVGGraphics;
-}();
-exports.SVGGraphics = SVGGraphics;
-
-/***/ }),
-/* 5 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var Util = sharedUtil.Util;
-var createPromiseCapability = sharedUtil.createPromiseCapability;
-var CustomStyle = displayDOMUtils.CustomStyle;
-var getDefaultSetting = displayDOMUtils.getDefaultSetting;
-var renderTextLayer = function renderTextLayerClosure() {
- var MAX_TEXT_DIVS_TO_RENDER = 100000;
- var NonWhitespaceRegexp = /\S/;
- function isAllWhitespace(str) {
- return !NonWhitespaceRegexp.test(str);
- }
- var styleBuf = ['left: ', 0, 'px; top: ', 0, 'px; font-size: ', 0, 'px; font-family: ', '', ';'];
- function appendText(task, geom, styles) {
- var textDiv = document.createElement('div');
- var textDivProperties = {
- style: null,
- angle: 0,
- canvasWidth: 0,
- isWhitespace: false,
- originalTransform: null,
- paddingBottom: 0,
- paddingLeft: 0,
- paddingRight: 0,
- paddingTop: 0,
- scale: 1
- };
- task._textDivs.push(textDiv);
- if (isAllWhitespace(geom.str)) {
- textDivProperties.isWhitespace = true;
- task._textDivProperties.set(textDiv, textDivProperties);
- return;
- }
- var tx = Util.transform(task._viewport.transform, geom.transform);
- var angle = Math.atan2(tx[1], tx[0]);
- var style = styles[geom.fontName];
- if (style.vertical) {
- angle += Math.PI / 2;
- }
- var fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
- var fontAscent = fontHeight;
- if (style.ascent) {
- fontAscent = style.ascent * fontAscent;
- } else if (style.descent) {
- fontAscent = (1 + style.descent) * fontAscent;
- }
- var left;
- var top;
- if (angle === 0) {
- left = tx[4];
- top = tx[5] - fontAscent;
- } else {
- left = tx[4] + fontAscent * Math.sin(angle);
- top = tx[5] - fontAscent * Math.cos(angle);
- }
- styleBuf[1] = left;
- styleBuf[3] = top;
- styleBuf[5] = fontHeight;
- styleBuf[7] = style.fontFamily;
- textDivProperties.style = styleBuf.join('');
- textDiv.setAttribute('style', textDivProperties.style);
- textDiv.textContent = geom.str;
- if (getDefaultSetting('pdfBug')) {
- textDiv.dataset.fontName = geom.fontName;
- }
- if (angle !== 0) {
- textDivProperties.angle = angle * (180 / Math.PI);
- }
- if (geom.str.length > 1) {
- if (style.vertical) {
- textDivProperties.canvasWidth = geom.height * task._viewport.scale;
- } else {
- textDivProperties.canvasWidth = geom.width * task._viewport.scale;
- }
- }
- task._textDivProperties.set(textDiv, textDivProperties);
- if (task._enhanceTextSelection) {
- var angleCos = 1,
- angleSin = 0;
- if (angle !== 0) {
- angleCos = Math.cos(angle);
- angleSin = Math.sin(angle);
- }
- var divWidth = (style.vertical ? geom.height : geom.width) * task._viewport.scale;
- var divHeight = fontHeight;
- var m, b;
- if (angle !== 0) {
- m = [angleCos, angleSin, -angleSin, angleCos, left, top];
- b = Util.getAxialAlignedBoundingBox([0, 0, divWidth, divHeight], m);
- } else {
- b = [left, top, left + divWidth, top + divHeight];
- }
- task._bounds.push({
- left: b[0],
- top: b[1],
- right: b[2],
- bottom: b[3],
- div: textDiv,
- size: [divWidth, divHeight],
- m: m
- });
- }
- }
- function render(task) {
- if (task._canceled) {
- return;
- }
- var textLayerFrag = task._container;
- var textDivs = task._textDivs;
- var capability = task._capability;
- var textDivsLength = textDivs.length;
- if (textDivsLength > MAX_TEXT_DIVS_TO_RENDER) {
- task._renderingDone = true;
- capability.resolve();
- return;
- }
- var canvas = document.createElement('canvas');
- canvas.mozOpaque = true;
- var ctx = canvas.getContext('2d', { alpha: false });
- var lastFontSize;
- var lastFontFamily;
- for (var i = 0; i < textDivsLength; i++) {
- var textDiv = textDivs[i];
- var textDivProperties = task._textDivProperties.get(textDiv);
- if (textDivProperties.isWhitespace) {
- continue;
- }
- var fontSize = textDiv.style.fontSize;
- var fontFamily = textDiv.style.fontFamily;
- if (fontSize !== lastFontSize || fontFamily !== lastFontFamily) {
- ctx.font = fontSize + ' ' + fontFamily;
- lastFontSize = fontSize;
- lastFontFamily = fontFamily;
- }
- var width = ctx.measureText(textDiv.textContent).width;
- textLayerFrag.appendChild(textDiv);
- var transform = '';
- if (textDivProperties.canvasWidth !== 0 && width > 0) {
- textDivProperties.scale = textDivProperties.canvasWidth / width;
- transform = 'scaleX(' + textDivProperties.scale + ')';
- }
- if (textDivProperties.angle !== 0) {
- transform = 'rotate(' + textDivProperties.angle + 'deg) ' + transform;
- }
- if (transform !== '') {
- textDivProperties.originalTransform = transform;
- CustomStyle.setProp('transform', textDiv, transform);
- }
- task._textDivProperties.set(textDiv, textDivProperties);
- }
- task._renderingDone = true;
- capability.resolve();
- }
- function expand(task) {
- var bounds = task._bounds;
- var viewport = task._viewport;
- var expanded = expandBounds(viewport.width, viewport.height, bounds);
- for (var i = 0; i < expanded.length; i++) {
- var div = bounds[i].div;
- var divProperties = task._textDivProperties.get(div);
- if (divProperties.angle === 0) {
- divProperties.paddingLeft = bounds[i].left - expanded[i].left;
- divProperties.paddingTop = bounds[i].top - expanded[i].top;
- divProperties.paddingRight = expanded[i].right - bounds[i].right;
- divProperties.paddingBottom = expanded[i].bottom - bounds[i].bottom;
- task._textDivProperties.set(div, divProperties);
- continue;
- }
- var e = expanded[i],
- b = bounds[i];
- var m = b.m,
- c = m[0],
- s = m[1];
- var points = [[0, 0], [0, b.size[1]], [b.size[0], 0], b.size];
- var ts = new Float64Array(64);
- points.forEach(function (p, i) {
- var t = Util.applyTransform(p, m);
- ts[i + 0] = c && (e.left - t[0]) / c;
- ts[i + 4] = s && (e.top - t[1]) / s;
- ts[i + 8] = c && (e.right - t[0]) / c;
- ts[i + 12] = s && (e.bottom - t[1]) / s;
- ts[i + 16] = s && (e.left - t[0]) / -s;
- ts[i + 20] = c && (e.top - t[1]) / c;
- ts[i + 24] = s && (e.right - t[0]) / -s;
- ts[i + 28] = c && (e.bottom - t[1]) / c;
- ts[i + 32] = c && (e.left - t[0]) / -c;
- ts[i + 36] = s && (e.top - t[1]) / -s;
- ts[i + 40] = c && (e.right - t[0]) / -c;
- ts[i + 44] = s && (e.bottom - t[1]) / -s;
- ts[i + 48] = s && (e.left - t[0]) / s;
- ts[i + 52] = c && (e.top - t[1]) / -c;
- ts[i + 56] = s && (e.right - t[0]) / s;
- ts[i + 60] = c && (e.bottom - t[1]) / -c;
- });
- var findPositiveMin = function (ts, offset, count) {
- var result = 0;
- for (var i = 0; i < count; i++) {
- var t = ts[offset++];
- if (t > 0) {
- result = result ? Math.min(t, result) : t;
- }
- }
- return result;
- };
- var boxScale = 1 + Math.min(Math.abs(c), Math.abs(s));
- divProperties.paddingLeft = findPositiveMin(ts, 32, 16) / boxScale;
- divProperties.paddingTop = findPositiveMin(ts, 48, 16) / boxScale;
- divProperties.paddingRight = findPositiveMin(ts, 0, 16) / boxScale;
- divProperties.paddingBottom = findPositiveMin(ts, 16, 16) / boxScale;
- task._textDivProperties.set(div, divProperties);
- }
- }
- function expandBounds(width, height, boxes) {
- var bounds = boxes.map(function (box, i) {
- return {
- x1: box.left,
- y1: box.top,
- x2: box.right,
- y2: box.bottom,
- index: i,
- x1New: undefined,
- x2New: undefined
- };
- });
- expandBoundsLTR(width, bounds);
- var expanded = new Array(boxes.length);
- bounds.forEach(function (b) {
- var i = b.index;
- expanded[i] = {
- left: b.x1New,
- top: 0,
- right: b.x2New,
- bottom: 0
- };
- });
- boxes.map(function (box, i) {
- var e = expanded[i],
- b = bounds[i];
- b.x1 = box.top;
- b.y1 = width - e.right;
- b.x2 = box.bottom;
- b.y2 = width - e.left;
- b.index = i;
- b.x1New = undefined;
- b.x2New = undefined;
- });
- expandBoundsLTR(height, bounds);
- bounds.forEach(function (b) {
- var i = b.index;
- expanded[i].top = b.x1New;
- expanded[i].bottom = b.x2New;
- });
- return expanded;
- }
- function expandBoundsLTR(width, bounds) {
- bounds.sort(function (a, b) {
- return a.x1 - b.x1 || a.index - b.index;
- });
- var fakeBoundary = {
- x1: -Infinity,
- y1: -Infinity,
- x2: 0,
- y2: Infinity,
- index: -1,
- x1New: 0,
- x2New: 0
- };
- var horizon = [{
- start: -Infinity,
- end: Infinity,
- boundary: fakeBoundary
- }];
- bounds.forEach(function (boundary) {
- var i = 0;
- while (i < horizon.length && horizon[i].end <= boundary.y1) {
- i++;
- }
- var j = horizon.length - 1;
- while (j >= 0 && horizon[j].start >= boundary.y2) {
- j--;
- }
- var horizonPart, affectedBoundary;
- var q,
- k,
- maxXNew = -Infinity;
- for (q = i; q <= j; q++) {
- horizonPart = horizon[q];
- affectedBoundary = horizonPart.boundary;
- var xNew;
- if (affectedBoundary.x2 > boundary.x1) {
- xNew = affectedBoundary.index > boundary.index ? affectedBoundary.x1New : boundary.x1;
- } else if (affectedBoundary.x2New === undefined) {
- xNew = (affectedBoundary.x2 + boundary.x1) / 2;
- } else {
- xNew = affectedBoundary.x2New;
- }
- if (xNew > maxXNew) {
- maxXNew = xNew;
- }
- }
- boundary.x1New = maxXNew;
- for (q = i; q <= j; q++) {
- horizonPart = horizon[q];
- affectedBoundary = horizonPart.boundary;
- if (affectedBoundary.x2New === undefined) {
- if (affectedBoundary.x2 > boundary.x1) {
- if (affectedBoundary.index > boundary.index) {
- affectedBoundary.x2New = affectedBoundary.x2;
- }
- } else {
- affectedBoundary.x2New = maxXNew;
- }
- } else if (affectedBoundary.x2New > maxXNew) {
- affectedBoundary.x2New = Math.max(maxXNew, affectedBoundary.x2);
- }
- }
- var changedHorizon = [],
- lastBoundary = null;
- for (q = i; q <= j; q++) {
- horizonPart = horizon[q];
- affectedBoundary = horizonPart.boundary;
- var useBoundary = affectedBoundary.x2 > boundary.x2 ? affectedBoundary : boundary;
- if (lastBoundary === useBoundary) {
- changedHorizon[changedHorizon.length - 1].end = horizonPart.end;
- } else {
- changedHorizon.push({
- start: horizonPart.start,
- end: horizonPart.end,
- boundary: useBoundary
- });
- lastBoundary = useBoundary;
- }
- }
- if (horizon[i].start < boundary.y1) {
- changedHorizon[0].start = boundary.y1;
- changedHorizon.unshift({
- start: horizon[i].start,
- end: boundary.y1,
- boundary: horizon[i].boundary
- });
- }
- if (boundary.y2 < horizon[j].end) {
- changedHorizon[changedHorizon.length - 1].end = boundary.y2;
- changedHorizon.push({
- start: boundary.y2,
- end: horizon[j].end,
- boundary: horizon[j].boundary
- });
- }
- for (q = i; q <= j; q++) {
- horizonPart = horizon[q];
- affectedBoundary = horizonPart.boundary;
- if (affectedBoundary.x2New !== undefined) {
- continue;
- }
- var used = false;
- for (k = i - 1; !used && k >= 0 && horizon[k].start >= affectedBoundary.y1; k--) {
- used = horizon[k].boundary === affectedBoundary;
- }
- for (k = j + 1; !used && k < horizon.length && horizon[k].end <= affectedBoundary.y2; k++) {
- used = horizon[k].boundary === affectedBoundary;
- }
- for (k = 0; !used && k < changedHorizon.length; k++) {
- used = changedHorizon[k].boundary === affectedBoundary;
- }
- if (!used) {
- affectedBoundary.x2New = maxXNew;
- }
- }
- Array.prototype.splice.apply(horizon, [i, j - i + 1].concat(changedHorizon));
- });
- horizon.forEach(function (horizonPart) {
- var affectedBoundary = horizonPart.boundary;
- if (affectedBoundary.x2New === undefined) {
- affectedBoundary.x2New = Math.max(width, affectedBoundary.x2);
- }
- });
- }
- function TextLayerRenderTask(textContent, container, viewport, textDivs, enhanceTextSelection) {
- this._textContent = textContent;
- this._container = container;
- this._viewport = viewport;
- this._textDivs = textDivs || [];
- this._textDivProperties = new WeakMap();
- this._renderingDone = false;
- this._canceled = false;
- this._capability = createPromiseCapability();
- this._renderTimer = null;
- this._bounds = [];
- this._enhanceTextSelection = !!enhanceTextSelection;
- }
- TextLayerRenderTask.prototype = {
- get promise() {
- return this._capability.promise;
- },
- cancel: function TextLayer_cancel() {
- this._canceled = true;
- if (this._renderTimer !== null) {
- clearTimeout(this._renderTimer);
- this._renderTimer = null;
- }
- this._capability.reject('canceled');
- },
- _render: function TextLayer_render(timeout) {
- var textItems = this._textContent.items;
- var textStyles = this._textContent.styles;
- for (var i = 0, len = textItems.length; i < len; i++) {
- appendText(this, textItems[i], textStyles);
- }
- if (!timeout) {
- render(this);
- } else {
- var self = this;
- this._renderTimer = setTimeout(function () {
- render(self);
- self._renderTimer = null;
- }, timeout);
- }
- },
- expandTextDivs: function TextLayer_expandTextDivs(expandDivs) {
- if (!this._enhanceTextSelection || !this._renderingDone) {
- return;
- }
- if (this._bounds !== null) {
- expand(this);
- this._bounds = null;
- }
- for (var i = 0, ii = this._textDivs.length; i < ii; i++) {
- var div = this._textDivs[i];
- var divProperties = this._textDivProperties.get(div);
- if (divProperties.isWhitespace) {
- continue;
- }
- if (expandDivs) {
- var transform = '',
- padding = '';
- if (divProperties.scale !== 1) {
- transform = 'scaleX(' + divProperties.scale + ')';
- }
- if (divProperties.angle !== 0) {
- transform = 'rotate(' + divProperties.angle + 'deg) ' + transform;
- }
- if (divProperties.paddingLeft !== 0) {
- padding += ' padding-left: ' + divProperties.paddingLeft / divProperties.scale + 'px;';
- transform += ' translateX(' + -divProperties.paddingLeft / divProperties.scale + 'px)';
- }
- if (divProperties.paddingTop !== 0) {
- padding += ' padding-top: ' + divProperties.paddingTop + 'px;';
- transform += ' translateY(' + -divProperties.paddingTop + 'px)';
- }
- if (divProperties.paddingRight !== 0) {
- padding += ' padding-right: ' + divProperties.paddingRight / divProperties.scale + 'px;';
- }
- if (divProperties.paddingBottom !== 0) {
- padding += ' padding-bottom: ' + divProperties.paddingBottom + 'px;';
- }
- if (padding !== '') {
- div.setAttribute('style', divProperties.style + padding);
- }
- if (transform !== '') {
- CustomStyle.setProp('transform', div, transform);
- }
- } else {
- div.style.padding = 0;
- CustomStyle.setProp('transform', div, divProperties.originalTransform || '');
- }
- }
- }
- };
- function renderTextLayer(renderParameters) {
- var task = new TextLayerRenderTask(renderParameters.textContent, renderParameters.container, renderParameters.viewport, renderParameters.textDivs, renderParameters.enhanceTextSelection);
- task._render(renderParameters.timeout);
- return task;
- }
- return renderTextLayer;
-}();
-exports.renderTextLayer = renderTextLayer;
-
-/***/ }),
-/* 6 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var g;
-g = function () {
- return this;
-}();
-try {
- g = g || Function("return this")() || (1, eval)("this");
-} catch (e) {
- if (typeof window === "object") g = window;
-}
-module.exports = g;
-
-/***/ }),
-/* 7 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var error = sharedUtil.error;
-function fixMetadata(meta) {
- return meta.replace(/>\\376\\377([^<]+)/g, function (all, codes) {
- var bytes = codes.replace(/\\([0-3])([0-7])([0-7])/g, function (code, d1, d2, d3) {
- return String.fromCharCode(d1 * 64 + d2 * 8 + d3 * 1);
- });
- var chars = '';
- for (var i = 0; i < bytes.length; i += 2) {
- var code = bytes.charCodeAt(i) * 256 + bytes.charCodeAt(i + 1);
- chars += code >= 32 && code < 127 && code !== 60 && code !== 62 && code !== 38 ? String.fromCharCode(code) : '&#x' + (0x10000 + code).toString(16).substring(1) + ';';
- }
- return '>' + chars;
- });
-}
-function Metadata(meta) {
- if (typeof meta === 'string') {
- meta = fixMetadata(meta);
- var parser = new DOMParser();
- meta = parser.parseFromString(meta, 'application/xml');
- } else if (!(meta instanceof Document)) {
- error('Metadata: Invalid metadata object');
- }
- this.metaDocument = meta;
- this.metadata = Object.create(null);
- this.parse();
-}
-Metadata.prototype = {
- parse: function Metadata_parse() {
- var doc = this.metaDocument;
- var rdf = doc.documentElement;
- if (rdf.nodeName.toLowerCase() !== 'rdf:rdf') {
- rdf = rdf.firstChild;
- while (rdf && rdf.nodeName.toLowerCase() !== 'rdf:rdf') {
- rdf = rdf.nextSibling;
- }
- }
- var nodeName = rdf ? rdf.nodeName.toLowerCase() : null;
- if (!rdf || nodeName !== 'rdf:rdf' || !rdf.hasChildNodes()) {
- return;
- }
- var children = rdf.childNodes,
- desc,
- entry,
- name,
- i,
- ii,
- length,
- iLength;
- for (i = 0, length = children.length; i < length; i++) {
- desc = children[i];
- if (desc.nodeName.toLowerCase() !== 'rdf:description') {
- continue;
- }
- for (ii = 0, iLength = desc.childNodes.length; ii < iLength; ii++) {
- if (desc.childNodes[ii].nodeName.toLowerCase() !== '#text') {
- entry = desc.childNodes[ii];
- name = entry.nodeName.toLowerCase();
- this.metadata[name] = entry.textContent.trim();
- }
- }
- }
- },
- get: function Metadata_get(name) {
- return this.metadata[name] || null;
- },
- has: function Metadata_has(name) {
- return typeof this.metadata[name] !== 'undefined';
- }
-};
-exports.Metadata = Metadata;
-
-/***/ }),
-/* 8 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var shadow = sharedUtil.shadow;
-var getDefaultSetting = displayDOMUtils.getDefaultSetting;
-var WebGLUtils = function WebGLUtilsClosure() {
- function loadShader(gl, code, shaderType) {
- var shader = gl.createShader(shaderType);
- gl.shaderSource(shader, code);
- gl.compileShader(shader);
- var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
- if (!compiled) {
- var errorMsg = gl.getShaderInfoLog(shader);
- throw new Error('Error during shader compilation: ' + errorMsg);
- }
- return shader;
- }
- function createVertexShader(gl, code) {
- return loadShader(gl, code, gl.VERTEX_SHADER);
- }
- function createFragmentShader(gl, code) {
- return loadShader(gl, code, gl.FRAGMENT_SHADER);
- }
- function createProgram(gl, shaders) {
- var program = gl.createProgram();
- for (var i = 0, ii = shaders.length; i < ii; ++i) {
- gl.attachShader(program, shaders[i]);
- }
- gl.linkProgram(program);
- var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
- if (!linked) {
- var errorMsg = gl.getProgramInfoLog(program);
- throw new Error('Error during program linking: ' + errorMsg);
- }
- return program;
- }
- function createTexture(gl, image, textureId) {
- gl.activeTexture(textureId);
- var texture = gl.createTexture();
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
- return texture;
- }
- var currentGL, currentCanvas;
- function generateGL() {
- if (currentGL) {
- return;
- }
- currentCanvas = document.createElement('canvas');
- currentGL = currentCanvas.getContext('webgl', { premultipliedalpha: false });
- }
- var smaskVertexShaderCode = '\
- attribute vec2 a_position; \
- attribute vec2 a_texCoord; \
- \
- uniform vec2 u_resolution; \
- \
- varying vec2 v_texCoord; \
- \
- void main() { \
- vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0; \
- gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); \
- \
- v_texCoord = a_texCoord; \
- } ';
- var smaskFragmentShaderCode = '\
- precision mediump float; \
- \
- uniform vec4 u_backdrop; \
- uniform int u_subtype; \
- uniform sampler2D u_image; \
- uniform sampler2D u_mask; \
- \
- varying vec2 v_texCoord; \
- \
- void main() { \
- vec4 imageColor = texture2D(u_image, v_texCoord); \
- vec4 maskColor = texture2D(u_mask, v_texCoord); \
- if (u_backdrop.a > 0.0) { \
- maskColor.rgb = maskColor.rgb * maskColor.a + \
- u_backdrop.rgb * (1.0 - maskColor.a); \
- } \
- float lum; \
- if (u_subtype == 0) { \
- lum = maskColor.a; \
- } else { \
- lum = maskColor.r * 0.3 + maskColor.g * 0.59 + \
- maskColor.b * 0.11; \
- } \
- imageColor.a *= lum; \
- imageColor.rgb *= imageColor.a; \
- gl_FragColor = imageColor; \
- } ';
- var smaskCache = null;
- function initSmaskGL() {
- var canvas, gl;
- generateGL();
- canvas = currentCanvas;
- currentCanvas = null;
- gl = currentGL;
- currentGL = null;
- var vertexShader = createVertexShader(gl, smaskVertexShaderCode);
- var fragmentShader = createFragmentShader(gl, smaskFragmentShaderCode);
- var program = createProgram(gl, [vertexShader, fragmentShader]);
- gl.useProgram(program);
- var cache = {};
- cache.gl = gl;
- cache.canvas = canvas;
- cache.resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
- cache.positionLocation = gl.getAttribLocation(program, 'a_position');
- cache.backdropLocation = gl.getUniformLocation(program, 'u_backdrop');
- cache.subtypeLocation = gl.getUniformLocation(program, 'u_subtype');
- var texCoordLocation = gl.getAttribLocation(program, 'a_texCoord');
- var texLayerLocation = gl.getUniformLocation(program, 'u_image');
- var texMaskLocation = gl.getUniformLocation(program, 'u_mask');
- var texCoordBuffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]), gl.STATIC_DRAW);
- gl.enableVertexAttribArray(texCoordLocation);
- gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
- gl.uniform1i(texLayerLocation, 0);
- gl.uniform1i(texMaskLocation, 1);
- smaskCache = cache;
- }
- function composeSMask(layer, mask, properties) {
- var width = layer.width,
- height = layer.height;
- if (!smaskCache) {
- initSmaskGL();
- }
- var cache = smaskCache,
- canvas = cache.canvas,
- gl = cache.gl;
- canvas.width = width;
- canvas.height = height;
- gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
- gl.uniform2f(cache.resolutionLocation, width, height);
- if (properties.backdrop) {
- gl.uniform4f(cache.resolutionLocation, properties.backdrop[0], properties.backdrop[1], properties.backdrop[2], 1);
- } else {
- gl.uniform4f(cache.resolutionLocation, 0, 0, 0, 0);
- }
- gl.uniform1i(cache.subtypeLocation, properties.subtype === 'Luminosity' ? 1 : 0);
- var texture = createTexture(gl, layer, gl.TEXTURE0);
- var maskTexture = createTexture(gl, mask, gl.TEXTURE1);
- var buffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, width, 0, 0, height, 0, height, width, 0, width, height]), gl.STATIC_DRAW);
- gl.enableVertexAttribArray(cache.positionLocation);
- gl.vertexAttribPointer(cache.positionLocation, 2, gl.FLOAT, false, 0, 0);
- gl.clearColor(0, 0, 0, 0);
- gl.enable(gl.BLEND);
- gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
- gl.clear(gl.COLOR_BUFFER_BIT);
- gl.drawArrays(gl.TRIANGLES, 0, 6);
- gl.flush();
- gl.deleteTexture(texture);
- gl.deleteTexture(maskTexture);
- gl.deleteBuffer(buffer);
- return canvas;
- }
- var figuresVertexShaderCode = '\
- attribute vec2 a_position; \
- attribute vec3 a_color; \
- \
- uniform vec2 u_resolution; \
- uniform vec2 u_scale; \
- uniform vec2 u_offset; \
- \
- varying vec4 v_color; \
- \
- void main() { \
- vec2 position = (a_position + u_offset) * u_scale; \
- vec2 clipSpace = (position / u_resolution) * 2.0 - 1.0; \
- gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); \
- \
- v_color = vec4(a_color / 255.0, 1.0); \
- } ';
- var figuresFragmentShaderCode = '\
- precision mediump float; \
- \
- varying vec4 v_color; \
- \
- void main() { \
- gl_FragColor = v_color; \
- } ';
- var figuresCache = null;
- function initFiguresGL() {
- var canvas, gl;
- generateGL();
- canvas = currentCanvas;
- currentCanvas = null;
- gl = currentGL;
- currentGL = null;
- var vertexShader = createVertexShader(gl, figuresVertexShaderCode);
- var fragmentShader = createFragmentShader(gl, figuresFragmentShaderCode);
- var program = createProgram(gl, [vertexShader, fragmentShader]);
- gl.useProgram(program);
- var cache = {};
- cache.gl = gl;
- cache.canvas = canvas;
- cache.resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
- cache.scaleLocation = gl.getUniformLocation(program, 'u_scale');
- cache.offsetLocation = gl.getUniformLocation(program, 'u_offset');
- cache.positionLocation = gl.getAttribLocation(program, 'a_position');
- cache.colorLocation = gl.getAttribLocation(program, 'a_color');
- figuresCache = cache;
- }
- function drawFigures(width, height, backgroundColor, figures, context) {
- if (!figuresCache) {
- initFiguresGL();
- }
- var cache = figuresCache,
- canvas = cache.canvas,
- gl = cache.gl;
- canvas.width = width;
- canvas.height = height;
- gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
- gl.uniform2f(cache.resolutionLocation, width, height);
- var count = 0;
- var i, ii, rows;
- for (i = 0, ii = figures.length; i < ii; i++) {
- switch (figures[i].type) {
- case 'lattice':
- rows = figures[i].coords.length / figures[i].verticesPerRow | 0;
- count += (rows - 1) * (figures[i].verticesPerRow - 1) * 6;
- break;
- case 'triangles':
- count += figures[i].coords.length;
- break;
- }
- }
- var coords = new Float32Array(count * 2);
- var colors = new Uint8Array(count * 3);
- var coordsMap = context.coords,
- colorsMap = context.colors;
- var pIndex = 0,
- cIndex = 0;
- for (i = 0, ii = figures.length; i < ii; i++) {
- var figure = figures[i],
- ps = figure.coords,
- cs = figure.colors;
- switch (figure.type) {
- case 'lattice':
- var cols = figure.verticesPerRow;
- rows = ps.length / cols | 0;
- for (var row = 1; row < rows; row++) {
- var offset = row * cols + 1;
- for (var col = 1; col < cols; col++, offset++) {
- coords[pIndex] = coordsMap[ps[offset - cols - 1]];
- coords[pIndex + 1] = coordsMap[ps[offset - cols - 1] + 1];
- coords[pIndex + 2] = coordsMap[ps[offset - cols]];
- coords[pIndex + 3] = coordsMap[ps[offset - cols] + 1];
- coords[pIndex + 4] = coordsMap[ps[offset - 1]];
- coords[pIndex + 5] = coordsMap[ps[offset - 1] + 1];
- colors[cIndex] = colorsMap[cs[offset - cols - 1]];
- colors[cIndex + 1] = colorsMap[cs[offset - cols - 1] + 1];
- colors[cIndex + 2] = colorsMap[cs[offset - cols - 1] + 2];
- colors[cIndex + 3] = colorsMap[cs[offset - cols]];
- colors[cIndex + 4] = colorsMap[cs[offset - cols] + 1];
- colors[cIndex + 5] = colorsMap[cs[offset - cols] + 2];
- colors[cIndex + 6] = colorsMap[cs[offset - 1]];
- colors[cIndex + 7] = colorsMap[cs[offset - 1] + 1];
- colors[cIndex + 8] = colorsMap[cs[offset - 1] + 2];
- coords[pIndex + 6] = coords[pIndex + 2];
- coords[pIndex + 7] = coords[pIndex + 3];
- coords[pIndex + 8] = coords[pIndex + 4];
- coords[pIndex + 9] = coords[pIndex + 5];
- coords[pIndex + 10] = coordsMap[ps[offset]];
- coords[pIndex + 11] = coordsMap[ps[offset] + 1];
- colors[cIndex + 9] = colors[cIndex + 3];
- colors[cIndex + 10] = colors[cIndex + 4];
- colors[cIndex + 11] = colors[cIndex + 5];
- colors[cIndex + 12] = colors[cIndex + 6];
- colors[cIndex + 13] = colors[cIndex + 7];
- colors[cIndex + 14] = colors[cIndex + 8];
- colors[cIndex + 15] = colorsMap[cs[offset]];
- colors[cIndex + 16] = colorsMap[cs[offset] + 1];
- colors[cIndex + 17] = colorsMap[cs[offset] + 2];
- pIndex += 12;
- cIndex += 18;
- }
- }
- break;
- case 'triangles':
- for (var j = 0, jj = ps.length; j < jj; j++) {
- coords[pIndex] = coordsMap[ps[j]];
- coords[pIndex + 1] = coordsMap[ps[j] + 1];
- colors[cIndex] = colorsMap[cs[j]];
- colors[cIndex + 1] = colorsMap[cs[j] + 1];
- colors[cIndex + 2] = colorsMap[cs[j] + 2];
- pIndex += 2;
- cIndex += 3;
- }
- break;
- }
- }
- if (backgroundColor) {
- gl.clearColor(backgroundColor[0] / 255, backgroundColor[1] / 255, backgroundColor[2] / 255, 1.0);
- } else {
- gl.clearColor(0, 0, 0, 0);
- }
- gl.clear(gl.COLOR_BUFFER_BIT);
- var coordsBuffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, coordsBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, coords, gl.STATIC_DRAW);
- gl.enableVertexAttribArray(cache.positionLocation);
- gl.vertexAttribPointer(cache.positionLocation, 2, gl.FLOAT, false, 0, 0);
- var colorsBuffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, colorsBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
- gl.enableVertexAttribArray(cache.colorLocation);
- gl.vertexAttribPointer(cache.colorLocation, 3, gl.UNSIGNED_BYTE, false, 0, 0);
- gl.uniform2f(cache.scaleLocation, context.scaleX, context.scaleY);
- gl.uniform2f(cache.offsetLocation, context.offsetX, context.offsetY);
- gl.drawArrays(gl.TRIANGLES, 0, count);
- gl.flush();
- gl.deleteBuffer(coordsBuffer);
- gl.deleteBuffer(colorsBuffer);
- return canvas;
- }
- function cleanup() {
- if (smaskCache && smaskCache.canvas) {
- smaskCache.canvas.width = 0;
- smaskCache.canvas.height = 0;
- }
- if (figuresCache && figuresCache.canvas) {
- figuresCache.canvas.width = 0;
- figuresCache.canvas.height = 0;
- }
- smaskCache = null;
- figuresCache = null;
- }
- return {
- get isEnabled() {
- if (getDefaultSetting('disableWebGL')) {
- return false;
- }
- var enabled = false;
- try {
- generateGL();
- enabled = !!currentGL;
- } catch (e) {}
- return shadow(this, 'isEnabled', enabled);
- },
- composeSMask: composeSMask,
- drawFigures: drawFigures,
- clear: cleanup
- };
-}();
-exports.WebGLUtils = WebGLUtils;
-
-/***/ }),
-/* 9 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var displayAPI = __w_pdfjs_require__(3);
-var displayAnnotationLayer = __w_pdfjs_require__(2);
-var displayTextLayer = __w_pdfjs_require__(5);
-var displayMetadata = __w_pdfjs_require__(7);
-var displaySVG = __w_pdfjs_require__(4);
-var globalScope = sharedUtil.globalScope;
-var deprecated = sharedUtil.deprecated;
-var warn = sharedUtil.warn;
-var LinkTarget = displayDOMUtils.LinkTarget;
-var DEFAULT_LINK_REL = displayDOMUtils.DEFAULT_LINK_REL;
-var isWorker = typeof window === 'undefined';
-if (!globalScope.PDFJS) {
- globalScope.PDFJS = {};
-}
-var PDFJS = globalScope.PDFJS;
-PDFJS.version = '1.8.172';
-PDFJS.build = '8ff1fbe7';
-PDFJS.pdfBug = false;
-if (PDFJS.verbosity !== undefined) {
- sharedUtil.setVerbosityLevel(PDFJS.verbosity);
-}
-delete PDFJS.verbosity;
-Object.defineProperty(PDFJS, 'verbosity', {
- get: function () {
- return sharedUtil.getVerbosityLevel();
- },
- set: function (level) {
- sharedUtil.setVerbosityLevel(level);
- },
- enumerable: true,
- configurable: true
-});
-PDFJS.VERBOSITY_LEVELS = sharedUtil.VERBOSITY_LEVELS;
-PDFJS.OPS = sharedUtil.OPS;
-PDFJS.UNSUPPORTED_FEATURES = sharedUtil.UNSUPPORTED_FEATURES;
-PDFJS.isValidUrl = displayDOMUtils.isValidUrl;
-PDFJS.shadow = sharedUtil.shadow;
-PDFJS.createBlob = sharedUtil.createBlob;
-PDFJS.createObjectURL = function PDFJS_createObjectURL(data, contentType) {
- return sharedUtil.createObjectURL(data, contentType, PDFJS.disableCreateObjectURL);
-};
-Object.defineProperty(PDFJS, 'isLittleEndian', {
- configurable: true,
- get: function PDFJS_isLittleEndian() {
- var value = sharedUtil.isLittleEndian();
- return sharedUtil.shadow(PDFJS, 'isLittleEndian', value);
- }
-});
-PDFJS.removeNullCharacters = sharedUtil.removeNullCharacters;
-PDFJS.PasswordResponses = sharedUtil.PasswordResponses;
-PDFJS.PasswordException = sharedUtil.PasswordException;
-PDFJS.UnknownErrorException = sharedUtil.UnknownErrorException;
-PDFJS.InvalidPDFException = sharedUtil.InvalidPDFException;
-PDFJS.MissingPDFException = sharedUtil.MissingPDFException;
-PDFJS.UnexpectedResponseException = sharedUtil.UnexpectedResponseException;
-PDFJS.Util = sharedUtil.Util;
-PDFJS.PageViewport = sharedUtil.PageViewport;
-PDFJS.createPromiseCapability = sharedUtil.createPromiseCapability;
-PDFJS.maxImageSize = PDFJS.maxImageSize === undefined ? -1 : PDFJS.maxImageSize;
-PDFJS.cMapUrl = PDFJS.cMapUrl === undefined ? null : PDFJS.cMapUrl;
-PDFJS.cMapPacked = PDFJS.cMapPacked === undefined ? false : PDFJS.cMapPacked;
-PDFJS.disableFontFace = PDFJS.disableFontFace === undefined ? false : PDFJS.disableFontFace;
-PDFJS.imageResourcesPath = PDFJS.imageResourcesPath === undefined ? '' : PDFJS.imageResourcesPath;
-PDFJS.disableWorker = PDFJS.disableWorker === undefined ? false : PDFJS.disableWorker;
-PDFJS.workerSrc = PDFJS.workerSrc === undefined ? null : PDFJS.workerSrc;
-PDFJS.workerPort = PDFJS.workerPort === undefined ? null : PDFJS.workerPort;
-PDFJS.disableRange = PDFJS.disableRange === undefined ? false : PDFJS.disableRange;
-PDFJS.disableStream = PDFJS.disableStream === undefined ? false : PDFJS.disableStream;
-PDFJS.disableAutoFetch = PDFJS.disableAutoFetch === undefined ? false : PDFJS.disableAutoFetch;
-PDFJS.pdfBug = PDFJS.pdfBug === undefined ? false : PDFJS.pdfBug;
-PDFJS.postMessageTransfers = PDFJS.postMessageTransfers === undefined ? true : PDFJS.postMessageTransfers;
-PDFJS.disableCreateObjectURL = PDFJS.disableCreateObjectURL === undefined ? false : PDFJS.disableCreateObjectURL;
-PDFJS.disableWebGL = PDFJS.disableWebGL === undefined ? true : PDFJS.disableWebGL;
-PDFJS.externalLinkTarget = PDFJS.externalLinkTarget === undefined ? LinkTarget.NONE : PDFJS.externalLinkTarget;
-PDFJS.externalLinkRel = PDFJS.externalLinkRel === undefined ? DEFAULT_LINK_REL : PDFJS.externalLinkRel;
-PDFJS.isEvalSupported = PDFJS.isEvalSupported === undefined ? true : PDFJS.isEvalSupported;
-PDFJS.pdfjsNext = PDFJS.pdfjsNext === undefined ? false : PDFJS.pdfjsNext;
-var savedOpenExternalLinksInNewWindow = PDFJS.openExternalLinksInNewWindow;
-delete PDFJS.openExternalLinksInNewWindow;
-Object.defineProperty(PDFJS, 'openExternalLinksInNewWindow', {
- get: function () {
- return PDFJS.externalLinkTarget === LinkTarget.BLANK;
- },
- set: function (value) {
- if (value) {
- deprecated('PDFJS.openExternalLinksInNewWindow, please use ' + '"PDFJS.externalLinkTarget = PDFJS.LinkTarget.BLANK" instead.');
- }
- if (PDFJS.externalLinkTarget !== LinkTarget.NONE) {
- warn('PDFJS.externalLinkTarget is already initialized');
- return;
- }
- PDFJS.externalLinkTarget = value ? LinkTarget.BLANK : LinkTarget.NONE;
- },
- enumerable: true,
- configurable: true
-});
-if (savedOpenExternalLinksInNewWindow) {
- PDFJS.openExternalLinksInNewWindow = savedOpenExternalLinksInNewWindow;
-}
-PDFJS.getDocument = displayAPI.getDocument;
-PDFJS.PDFDataRangeTransport = displayAPI.PDFDataRangeTransport;
-PDFJS.PDFWorker = displayAPI.PDFWorker;
-Object.defineProperty(PDFJS, 'hasCanvasTypedArrays', {
- configurable: true,
- get: function PDFJS_hasCanvasTypedArrays() {
- var value = displayDOMUtils.hasCanvasTypedArrays();
- return sharedUtil.shadow(PDFJS, 'hasCanvasTypedArrays', value);
- }
-});
-PDFJS.CustomStyle = displayDOMUtils.CustomStyle;
-PDFJS.LinkTarget = LinkTarget;
-PDFJS.addLinkAttributes = displayDOMUtils.addLinkAttributes;
-PDFJS.getFilenameFromUrl = displayDOMUtils.getFilenameFromUrl;
-PDFJS.isExternalLinkTargetSet = displayDOMUtils.isExternalLinkTargetSet;
-PDFJS.AnnotationLayer = displayAnnotationLayer.AnnotationLayer;
-PDFJS.renderTextLayer = displayTextLayer.renderTextLayer;
-PDFJS.Metadata = displayMetadata.Metadata;
-PDFJS.SVGGraphics = displaySVG.SVGGraphics;
-PDFJS.UnsupportedManager = displayAPI._UnsupportedManager;
-exports.globalScope = globalScope;
-exports.isWorker = isWorker;
-exports.PDFJS = globalScope.PDFJS;
-
-/***/ }),
-/* 10 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayDOMUtils = __w_pdfjs_require__(1);
-var displayPatternHelper = __w_pdfjs_require__(12);
-var displayWebGL = __w_pdfjs_require__(8);
-var FONT_IDENTITY_MATRIX = sharedUtil.FONT_IDENTITY_MATRIX;
-var IDENTITY_MATRIX = sharedUtil.IDENTITY_MATRIX;
-var ImageKind = sharedUtil.ImageKind;
-var OPS = sharedUtil.OPS;
-var TextRenderingMode = sharedUtil.TextRenderingMode;
-var Uint32ArrayView = sharedUtil.Uint32ArrayView;
-var Util = sharedUtil.Util;
-var assert = sharedUtil.assert;
-var info = sharedUtil.info;
-var isNum = sharedUtil.isNum;
-var isArray = sharedUtil.isArray;
-var isLittleEndian = sharedUtil.isLittleEndian;
-var error = sharedUtil.error;
-var shadow = sharedUtil.shadow;
-var warn = sharedUtil.warn;
-var TilingPattern = displayPatternHelper.TilingPattern;
-var getShadingPatternFromIR = displayPatternHelper.getShadingPatternFromIR;
-var WebGLUtils = displayWebGL.WebGLUtils;
-var hasCanvasTypedArrays = displayDOMUtils.hasCanvasTypedArrays;
-var MIN_FONT_SIZE = 16;
-var MAX_FONT_SIZE = 100;
-var MAX_GROUP_SIZE = 4096;
-var MIN_WIDTH_FACTOR = 0.65;
-var COMPILE_TYPE3_GLYPHS = true;
-var MAX_SIZE_TO_COMPILE = 1000;
-var FULL_CHUNK_HEIGHT = 16;
-var HasCanvasTypedArraysCached = {
- get value() {
- return shadow(HasCanvasTypedArraysCached, 'value', hasCanvasTypedArrays());
- }
-};
-var IsLittleEndianCached = {
- get value() {
- return shadow(IsLittleEndianCached, 'value', isLittleEndian());
- }
-};
-function addContextCurrentTransform(ctx) {
- if (!ctx.mozCurrentTransform) {
- ctx._originalSave = ctx.save;
- ctx._originalRestore = ctx.restore;
- ctx._originalRotate = ctx.rotate;
- ctx._originalScale = ctx.scale;
- ctx._originalTranslate = ctx.translate;
- ctx._originalTransform = ctx.transform;
- ctx._originalSetTransform = ctx.setTransform;
- ctx._transformMatrix = ctx._transformMatrix || [1, 0, 0, 1, 0, 0];
- ctx._transformStack = [];
- Object.defineProperty(ctx, 'mozCurrentTransform', {
- get: function getCurrentTransform() {
- return this._transformMatrix;
- }
- });
- Object.defineProperty(ctx, 'mozCurrentTransformInverse', {
- get: function getCurrentTransformInverse() {
- var m = this._transformMatrix;
- var a = m[0],
- b = m[1],
- c = m[2],
- d = m[3],
- e = m[4],
- f = m[5];
- var ad_bc = a * d - b * c;
- var bc_ad = b * c - a * d;
- return [d / ad_bc, b / bc_ad, c / bc_ad, a / ad_bc, (d * e - c * f) / bc_ad, (b * e - a * f) / ad_bc];
- }
- });
- ctx.save = function ctxSave() {
- var old = this._transformMatrix;
- this._transformStack.push(old);
- this._transformMatrix = old.slice(0, 6);
- this._originalSave();
- };
- ctx.restore = function ctxRestore() {
- var prev = this._transformStack.pop();
- if (prev) {
- this._transformMatrix = prev;
- this._originalRestore();
- }
- };
- ctx.translate = function ctxTranslate(x, y) {
- var m = this._transformMatrix;
- m[4] = m[0] * x + m[2] * y + m[4];
- m[5] = m[1] * x + m[3] * y + m[5];
- this._originalTranslate(x, y);
- };
- ctx.scale = function ctxScale(x, y) {
- var m = this._transformMatrix;
- m[0] = m[0] * x;
- m[1] = m[1] * x;
- m[2] = m[2] * y;
- m[3] = m[3] * y;
- this._originalScale(x, y);
- };
- ctx.transform = function ctxTransform(a, b, c, d, e, f) {
- var m = this._transformMatrix;
- this._transformMatrix = [m[0] * a + m[2] * b, m[1] * a + m[3] * b, m[0] * c + m[2] * d, m[1] * c + m[3] * d, m[0] * e + m[2] * f + m[4], m[1] * e + m[3] * f + m[5]];
- ctx._originalTransform(a, b, c, d, e, f);
- };
- ctx.setTransform = function ctxSetTransform(a, b, c, d, e, f) {
- this._transformMatrix = [a, b, c, d, e, f];
- ctx._originalSetTransform(a, b, c, d, e, f);
- };
- ctx.rotate = function ctxRotate(angle) {
- var cosValue = Math.cos(angle);
- var sinValue = Math.sin(angle);
- var m = this._transformMatrix;
- this._transformMatrix = [m[0] * cosValue + m[2] * sinValue, m[1] * cosValue + m[3] * sinValue, m[0] * -sinValue + m[2] * cosValue, m[1] * -sinValue + m[3] * cosValue, m[4], m[5]];
- this._originalRotate(angle);
- };
- }
-}
-var CachedCanvases = function CachedCanvasesClosure() {
- function CachedCanvases(canvasFactory) {
- this.canvasFactory = canvasFactory;
- this.cache = Object.create(null);
- }
- CachedCanvases.prototype = {
- getCanvas: function CachedCanvases_getCanvas(id, width, height, trackTransform) {
- var canvasEntry;
- if (this.cache[id] !== undefined) {
- canvasEntry = this.cache[id];
- this.canvasFactory.reset(canvasEntry, width, height);
- canvasEntry.context.setTransform(1, 0, 0, 1, 0, 0);
- } else {
- canvasEntry = this.canvasFactory.create(width, height);
- this.cache[id] = canvasEntry;
- }
- if (trackTransform) {
- addContextCurrentTransform(canvasEntry.context);
- }
- return canvasEntry;
- },
- clear: function () {
- for (var id in this.cache) {
- var canvasEntry = this.cache[id];
- this.canvasFactory.destroy(canvasEntry);
- delete this.cache[id];
- }
- }
- };
- return CachedCanvases;
-}();
-function compileType3Glyph(imgData) {
- var POINT_TO_PROCESS_LIMIT = 1000;
- var width = imgData.width,
- height = imgData.height;
- var i,
- j,
- j0,
- width1 = width + 1;
- var points = new Uint8Array(width1 * (height + 1));
- var POINT_TYPES = new Uint8Array([0, 2, 4, 0, 1, 0, 5, 4, 8, 10, 0, 8, 0, 2, 1, 0]);
- var lineSize = width + 7 & ~7,
- data0 = imgData.data;
- var data = new Uint8Array(lineSize * height),
- pos = 0,
- ii;
- for (i = 0, ii = data0.length; i < ii; i++) {
- var mask = 128,
- elem = data0[i];
- while (mask > 0) {
- data[pos++] = elem & mask ? 0 : 255;
- mask >>= 1;
- }
- }
- var count = 0;
- pos = 0;
- if (data[pos] !== 0) {
- points[0] = 1;
- ++count;
- }
- for (j = 1; j < width; j++) {
- if (data[pos] !== data[pos + 1]) {
- points[j] = data[pos] ? 2 : 1;
- ++count;
- }
- pos++;
- }
- if (data[pos] !== 0) {
- points[j] = 2;
- ++count;
- }
- for (i = 1; i < height; i++) {
- pos = i * lineSize;
- j0 = i * width1;
- if (data[pos - lineSize] !== data[pos]) {
- points[j0] = data[pos] ? 1 : 8;
- ++count;
- }
- var sum = (data[pos] ? 4 : 0) + (data[pos - lineSize] ? 8 : 0);
- for (j = 1; j < width; j++) {
- sum = (sum >> 2) + (data[pos + 1] ? 4 : 0) + (data[pos - lineSize + 1] ? 8 : 0);
- if (POINT_TYPES[sum]) {
- points[j0 + j] = POINT_TYPES[sum];
- ++count;
- }
- pos++;
- }
- if (data[pos - lineSize] !== data[pos]) {
- points[j0 + j] = data[pos] ? 2 : 4;
- ++count;
- }
- if (count > POINT_TO_PROCESS_LIMIT) {
- return null;
- }
- }
- pos = lineSize * (height - 1);
- j0 = i * width1;
- if (data[pos] !== 0) {
- points[j0] = 8;
- ++count;
- }
- for (j = 1; j < width; j++) {
- if (data[pos] !== data[pos + 1]) {
- points[j0 + j] = data[pos] ? 4 : 8;
- ++count;
- }
- pos++;
- }
- if (data[pos] !== 0) {
- points[j0 + j] = 4;
- ++count;
- }
- if (count > POINT_TO_PROCESS_LIMIT) {
- return null;
- }
- var steps = new Int32Array([0, width1, -1, 0, -width1, 0, 0, 0, 1]);
- var outlines = [];
- for (i = 0; count && i <= height; i++) {
- var p = i * width1;
- var end = p + width;
- while (p < end && !points[p]) {
- p++;
- }
- if (p === end) {
- continue;
- }
- var coords = [p % width1, i];
- var type = points[p],
- p0 = p,
- pp;
- do {
- var step = steps[type];
- do {
- p += step;
- } while (!points[p]);
- pp = points[p];
- if (pp !== 5 && pp !== 10) {
- type = pp;
- points[p] = 0;
- } else {
- type = pp & 0x33 * type >> 4;
- points[p] &= type >> 2 | type << 2;
- }
- coords.push(p % width1);
- coords.push(p / width1 | 0);
- --count;
- } while (p0 !== p);
- outlines.push(coords);
- --i;
- }
- var drawOutline = function (c) {
- c.save();
- c.scale(1 / width, -1 / height);
- c.translate(0, -height);
- c.beginPath();
- for (var i = 0, ii = outlines.length; i < ii; i++) {
- var o = outlines[i];
- c.moveTo(o[0], o[1]);
- for (var j = 2, jj = o.length; j < jj; j += 2) {
- c.lineTo(o[j], o[j + 1]);
- }
- }
- c.fill();
- c.beginPath();
- c.restore();
- };
- return drawOutline;
-}
-var CanvasExtraState = function CanvasExtraStateClosure() {
- function CanvasExtraState(old) {
- this.alphaIsShape = false;
- this.fontSize = 0;
- this.fontSizeScale = 1;
- this.textMatrix = IDENTITY_MATRIX;
- this.textMatrixScale = 1;
- this.fontMatrix = FONT_IDENTITY_MATRIX;
- this.leading = 0;
- this.x = 0;
- this.y = 0;
- this.lineX = 0;
- this.lineY = 0;
- this.charSpacing = 0;
- this.wordSpacing = 0;
- this.textHScale = 1;
- this.textRenderingMode = TextRenderingMode.FILL;
- this.textRise = 0;
- this.fillColor = '#000000';
- this.strokeColor = '#000000';
- this.patternFill = false;
- this.fillAlpha = 1;
- this.strokeAlpha = 1;
- this.lineWidth = 1;
- this.activeSMask = null;
- this.resumeSMaskCtx = null;
- this.old = old;
- }
- CanvasExtraState.prototype = {
- clone: function CanvasExtraState_clone() {
- return Object.create(this);
- },
- setCurrentPoint: function CanvasExtraState_setCurrentPoint(x, y) {
- this.x = x;
- this.y = y;
- }
- };
- return CanvasExtraState;
-}();
-var CanvasGraphics = function CanvasGraphicsClosure() {
- var EXECUTION_TIME = 15;
- var EXECUTION_STEPS = 10;
- function CanvasGraphics(canvasCtx, commonObjs, objs, canvasFactory, imageLayer) {
- this.ctx = canvasCtx;
- this.current = new CanvasExtraState();
- this.stateStack = [];
- this.pendingClip = null;
- this.pendingEOFill = false;
- this.res = null;
- this.xobjs = null;
- this.commonObjs = commonObjs;
- this.objs = objs;
- this.canvasFactory = canvasFactory;
- this.imageLayer = imageLayer;
- this.groupStack = [];
- this.processingType3 = null;
- this.baseTransform = null;
- this.baseTransformStack = [];
- this.groupLevel = 0;
- this.smaskStack = [];
- this.smaskCounter = 0;
- this.tempSMask = null;
- this.cachedCanvases = new CachedCanvases(this.canvasFactory);
- if (canvasCtx) {
- addContextCurrentTransform(canvasCtx);
- }
- this.cachedGetSinglePixelWidth = null;
- }
- function putBinaryImageData(ctx, imgData) {
- if (typeof ImageData !== 'undefined' && imgData instanceof ImageData) {
- ctx.putImageData(imgData, 0, 0);
- return;
- }
- var height = imgData.height,
- width = imgData.width;
- var partialChunkHeight = height % FULL_CHUNK_HEIGHT;
- var fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
- var totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;
- var chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
- var srcPos = 0,
- destPos;
- var src = imgData.data;
- var dest = chunkImgData.data;
- var i, j, thisChunkHeight, elemsInThisChunk;
- if (imgData.kind === ImageKind.GRAYSCALE_1BPP) {
- var srcLength = src.byteLength;
- var dest32 = HasCanvasTypedArraysCached.value ? new Uint32Array(dest.buffer) : new Uint32ArrayView(dest);
- var dest32DataLength = dest32.length;
- var fullSrcDiff = width + 7 >> 3;
- var white = 0xFFFFFFFF;
- var black = IsLittleEndianCached.value || !HasCanvasTypedArraysCached.value ? 0xFF000000 : 0x000000FF;
- for (i = 0; i < totalChunks; i++) {
- thisChunkHeight = i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;
- destPos = 0;
- for (j = 0; j < thisChunkHeight; j++) {
- var srcDiff = srcLength - srcPos;
- var k = 0;
- var kEnd = srcDiff > fullSrcDiff ? width : srcDiff * 8 - 7;
- var kEndUnrolled = kEnd & ~7;
- var mask = 0;
- var srcByte = 0;
- for (; k < kEndUnrolled; k += 8) {
- srcByte = src[srcPos++];
- dest32[destPos++] = srcByte & 128 ? white : black;
- dest32[destPos++] = srcByte & 64 ? white : black;
- dest32[destPos++] = srcByte & 32 ? white : black;
- dest32[destPos++] = srcByte & 16 ? white : black;
- dest32[destPos++] = srcByte & 8 ? white : black;
- dest32[destPos++] = srcByte & 4 ? white : black;
- dest32[destPos++] = srcByte & 2 ? white : black;
- dest32[destPos++] = srcByte & 1 ? white : black;
- }
- for (; k < kEnd; k++) {
- if (mask === 0) {
- srcByte = src[srcPos++];
- mask = 128;
- }
- dest32[destPos++] = srcByte & mask ? white : black;
- mask >>= 1;
- }
- }
- while (destPos < dest32DataLength) {
- dest32[destPos++] = 0;
- }
- ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
- }
- } else if (imgData.kind === ImageKind.RGBA_32BPP) {
- j = 0;
- elemsInThisChunk = width * FULL_CHUNK_HEIGHT * 4;
- for (i = 0; i < fullChunks; i++) {
- dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));
- srcPos += elemsInThisChunk;
- ctx.putImageData(chunkImgData, 0, j);
- j += FULL_CHUNK_HEIGHT;
- }
- if (i < totalChunks) {
- elemsInThisChunk = width * partialChunkHeight * 4;
- dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));
- ctx.putImageData(chunkImgData, 0, j);
- }
- } else if (imgData.kind === ImageKind.RGB_24BPP) {
- thisChunkHeight = FULL_CHUNK_HEIGHT;
- elemsInThisChunk = width * thisChunkHeight;
- for (i = 0; i < totalChunks; i++) {
- if (i >= fullChunks) {
- thisChunkHeight = partialChunkHeight;
- elemsInThisChunk = width * thisChunkHeight;
- }
- destPos = 0;
- for (j = elemsInThisChunk; j--;) {
- dest[destPos++] = src[srcPos++];
- dest[destPos++] = src[srcPos++];
- dest[destPos++] = src[srcPos++];
- dest[destPos++] = 255;
- }
- ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
- }
- } else {
- error('bad image kind: ' + imgData.kind);
- }
- }
- function putBinaryImageMask(ctx, imgData) {
- var height = imgData.height,
- width = imgData.width;
- var partialChunkHeight = height % FULL_CHUNK_HEIGHT;
- var fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
- var totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;
- var chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
- var srcPos = 0;
- var src = imgData.data;
- var dest = chunkImgData.data;
- for (var i = 0; i < totalChunks; i++) {
- var thisChunkHeight = i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;
- var destPos = 3;
- for (var j = 0; j < thisChunkHeight; j++) {
- var mask = 0;
- for (var k = 0; k < width; k++) {
- if (!mask) {
- var elem = src[srcPos++];
- mask = 128;
- }
- dest[destPos] = elem & mask ? 0 : 255;
- destPos += 4;
- mask >>= 1;
- }
- }
- ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
- }
- }
- function copyCtxState(sourceCtx, destCtx) {
- var properties = ['strokeStyle', 'fillStyle', 'fillRule', 'globalAlpha', 'lineWidth', 'lineCap', 'lineJoin', 'miterLimit', 'globalCompositeOperation', 'font'];
- for (var i = 0, ii = properties.length; i < ii; i++) {
- var property = properties[i];
- if (sourceCtx[property] !== undefined) {
- destCtx[property] = sourceCtx[property];
- }
- }
- if (sourceCtx.setLineDash !== undefined) {
- destCtx.setLineDash(sourceCtx.getLineDash());
- destCtx.lineDashOffset = sourceCtx.lineDashOffset;
- }
- }
- function composeSMaskBackdrop(bytes, r0, g0, b0) {
- var length = bytes.length;
- for (var i = 3; i < length; i += 4) {
- var alpha = bytes[i];
- if (alpha === 0) {
- bytes[i - 3] = r0;
- bytes[i - 2] = g0;
- bytes[i - 1] = b0;
- } else if (alpha < 255) {
- var alpha_ = 255 - alpha;
- bytes[i - 3] = bytes[i - 3] * alpha + r0 * alpha_ >> 8;
- bytes[i - 2] = bytes[i - 2] * alpha + g0 * alpha_ >> 8;
- bytes[i - 1] = bytes[i - 1] * alpha + b0 * alpha_ >> 8;
- }
- }
- }
- function composeSMaskAlpha(maskData, layerData, transferMap) {
- var length = maskData.length;
- var scale = 1 / 255;
- for (var i = 3; i < length; i += 4) {
- var alpha = transferMap ? transferMap[maskData[i]] : maskData[i];
- layerData[i] = layerData[i] * alpha * scale | 0;
- }
- }
- function composeSMaskLuminosity(maskData, layerData, transferMap) {
- var length = maskData.length;
- for (var i = 3; i < length; i += 4) {
- var y = maskData[i - 3] * 77 + maskData[i - 2] * 152 + maskData[i - 1] * 28;
- layerData[i] = transferMap ? layerData[i] * transferMap[y >> 8] >> 8 : layerData[i] * y >> 16;
- }
- }
- function genericComposeSMask(maskCtx, layerCtx, width, height, subtype, backdrop, transferMap) {
- var hasBackdrop = !!backdrop;
- var r0 = hasBackdrop ? backdrop[0] : 0;
- var g0 = hasBackdrop ? backdrop[1] : 0;
- var b0 = hasBackdrop ? backdrop[2] : 0;
- var composeFn;
- if (subtype === 'Luminosity') {
- composeFn = composeSMaskLuminosity;
- } else {
- composeFn = composeSMaskAlpha;
- }
- var PIXELS_TO_PROCESS = 1048576;
- var chunkSize = Math.min(height, Math.ceil(PIXELS_TO_PROCESS / width));
- for (var row = 0; row < height; row += chunkSize) {
- var chunkHeight = Math.min(chunkSize, height - row);
- var maskData = maskCtx.getImageData(0, row, width, chunkHeight);
- var layerData = layerCtx.getImageData(0, row, width, chunkHeight);
- if (hasBackdrop) {
- composeSMaskBackdrop(maskData.data, r0, g0, b0);
- }
- composeFn(maskData.data, layerData.data, transferMap);
- maskCtx.putImageData(layerData, 0, row);
- }
- }
- function composeSMask(ctx, smask, layerCtx) {
- var mask = smask.canvas;
- var maskCtx = smask.context;
- ctx.setTransform(smask.scaleX, 0, 0, smask.scaleY, smask.offsetX, smask.offsetY);
- var backdrop = smask.backdrop || null;
- if (!smask.transferMap && WebGLUtils.isEnabled) {
- var composed = WebGLUtils.composeSMask(layerCtx.canvas, mask, {
- subtype: smask.subtype,
- backdrop: backdrop
- });
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.drawImage(composed, smask.offsetX, smask.offsetY);
- return;
- }
- genericComposeSMask(maskCtx, layerCtx, mask.width, mask.height, smask.subtype, backdrop, smask.transferMap);
- ctx.drawImage(mask, 0, 0);
- }
- var LINE_CAP_STYLES = ['butt', 'round', 'square'];
- var LINE_JOIN_STYLES = ['miter', 'round', 'bevel'];
- var NORMAL_CLIP = {};
- var EO_CLIP = {};
- CanvasGraphics.prototype = {
- beginDrawing: function CanvasGraphics_beginDrawing(transform, viewport, transparency) {
- var width = this.ctx.canvas.width;
- var height = this.ctx.canvas.height;
- this.ctx.save();
- this.ctx.fillStyle = 'rgb(255, 255, 255)';
- this.ctx.fillRect(0, 0, width, height);
- this.ctx.restore();
- if (transparency) {
- var transparentCanvas = this.cachedCanvases.getCanvas('transparent', width, height, true);
- this.compositeCtx = this.ctx;
- this.transparentCanvas = transparentCanvas.canvas;
- this.ctx = transparentCanvas.context;
- this.ctx.save();
- this.ctx.transform.apply(this.ctx, this.compositeCtx.mozCurrentTransform);
- }
- this.ctx.save();
- if (transform) {
- this.ctx.transform.apply(this.ctx, transform);
- }
- this.ctx.transform.apply(this.ctx, viewport.transform);
- this.baseTransform = this.ctx.mozCurrentTransform.slice();
- if (this.imageLayer) {
- this.imageLayer.beginLayout();
- }
- },
- executeOperatorList: function CanvasGraphics_executeOperatorList(operatorList, executionStartIdx, continueCallback, stepper) {
- var argsArray = operatorList.argsArray;
- var fnArray = operatorList.fnArray;
- var i = executionStartIdx || 0;
- var argsArrayLen = argsArray.length;
- if (argsArrayLen === i) {
- return i;
- }
- var chunkOperations = argsArrayLen - i > EXECUTION_STEPS && typeof continueCallback === 'function';
- var endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
- var steps = 0;
- var commonObjs = this.commonObjs;
- var objs = this.objs;
- var fnId;
- while (true) {
- if (stepper !== undefined && i === stepper.nextBreakPoint) {
- stepper.breakIt(i, continueCallback);
- return i;
- }
- fnId = fnArray[i];
- if (fnId !== OPS.dependency) {
- this[fnId].apply(this, argsArray[i]);
- } else {
- var deps = argsArray[i];
- for (var n = 0, nn = deps.length; n < nn; n++) {
- var depObjId = deps[n];
- var common = depObjId[0] === 'g' && depObjId[1] === '_';
- var objsPool = common ? commonObjs : objs;
- if (!objsPool.isResolved(depObjId)) {
- objsPool.get(depObjId, continueCallback);
- return i;
- }
- }
- }
- i++;
- if (i === argsArrayLen) {
- return i;
- }
- if (chunkOperations && ++steps > EXECUTION_STEPS) {
- if (Date.now() > endTime) {
- continueCallback();
- return i;
- }
- steps = 0;
- }
- }
- },
- endDrawing: function CanvasGraphics_endDrawing() {
- if (this.current.activeSMask !== null) {
- this.endSMaskGroup();
- }
- this.ctx.restore();
- if (this.transparentCanvas) {
- this.ctx = this.compositeCtx;
- this.ctx.save();
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
- this.ctx.drawImage(this.transparentCanvas, 0, 0);
- this.ctx.restore();
- this.transparentCanvas = null;
- }
- this.cachedCanvases.clear();
- WebGLUtils.clear();
- if (this.imageLayer) {
- this.imageLayer.endLayout();
- }
- },
- setLineWidth: function CanvasGraphics_setLineWidth(width) {
- this.current.lineWidth = width;
- this.ctx.lineWidth = width;
- },
- setLineCap: function CanvasGraphics_setLineCap(style) {
- this.ctx.lineCap = LINE_CAP_STYLES[style];
- },
- setLineJoin: function CanvasGraphics_setLineJoin(style) {
- this.ctx.lineJoin = LINE_JOIN_STYLES[style];
- },
- setMiterLimit: function CanvasGraphics_setMiterLimit(limit) {
- this.ctx.miterLimit = limit;
- },
- setDash: function CanvasGraphics_setDash(dashArray, dashPhase) {
- var ctx = this.ctx;
- if (ctx.setLineDash !== undefined) {
- ctx.setLineDash(dashArray);
- ctx.lineDashOffset = dashPhase;
- }
- },
- setRenderingIntent: function CanvasGraphics_setRenderingIntent(intent) {},
- setFlatness: function CanvasGraphics_setFlatness(flatness) {},
- setGState: function CanvasGraphics_setGState(states) {
- for (var i = 0, ii = states.length; i < ii; i++) {
- var state = states[i];
- var key = state[0];
- var value = state[1];
- switch (key) {
- case 'LW':
- this.setLineWidth(value);
- break;
- case 'LC':
- this.setLineCap(value);
- break;
- case 'LJ':
- this.setLineJoin(value);
- break;
- case 'ML':
- this.setMiterLimit(value);
- break;
- case 'D':
- this.setDash(value[0], value[1]);
- break;
- case 'RI':
- this.setRenderingIntent(value);
- break;
- case 'FL':
- this.setFlatness(value);
- break;
- case 'Font':
- this.setFont(value[0], value[1]);
- break;
- case 'CA':
- this.current.strokeAlpha = state[1];
- break;
- case 'ca':
- this.current.fillAlpha = state[1];
- this.ctx.globalAlpha = state[1];
- break;
- case 'BM':
- if (value && value.name && value.name !== 'Normal') {
- var mode = value.name.replace(/([A-Z])/g, function (c) {
- return '-' + c.toLowerCase();
- }).substring(1);
- this.ctx.globalCompositeOperation = mode;
- if (this.ctx.globalCompositeOperation !== mode) {
- warn('globalCompositeOperation "' + mode + '" is not supported');
- }
- } else {
- this.ctx.globalCompositeOperation = 'source-over';
- }
- break;
- case 'SMask':
- if (this.current.activeSMask) {
- if (this.stateStack.length > 0 && this.stateStack[this.stateStack.length - 1].activeSMask === this.current.activeSMask) {
- this.suspendSMaskGroup();
- } else {
- this.endSMaskGroup();
- }
- }
- this.current.activeSMask = value ? this.tempSMask : null;
- if (this.current.activeSMask) {
- this.beginSMaskGroup();
- }
- this.tempSMask = null;
- break;
- }
- }
- },
- beginSMaskGroup: function CanvasGraphics_beginSMaskGroup() {
- var activeSMask = this.current.activeSMask;
- var drawnWidth = activeSMask.canvas.width;
- var drawnHeight = activeSMask.canvas.height;
- var cacheId = 'smaskGroupAt' + this.groupLevel;
- var scratchCanvas = this.cachedCanvases.getCanvas(cacheId, drawnWidth, drawnHeight, true);
- var currentCtx = this.ctx;
- var currentTransform = currentCtx.mozCurrentTransform;
- this.ctx.save();
- var groupCtx = scratchCanvas.context;
- groupCtx.scale(1 / activeSMask.scaleX, 1 / activeSMask.scaleY);
- groupCtx.translate(-activeSMask.offsetX, -activeSMask.offsetY);
- groupCtx.transform.apply(groupCtx, currentTransform);
- activeSMask.startTransformInverse = groupCtx.mozCurrentTransformInverse;
- copyCtxState(currentCtx, groupCtx);
- this.ctx = groupCtx;
- this.setGState([['BM', 'Normal'], ['ca', 1], ['CA', 1]]);
- this.groupStack.push(currentCtx);
- this.groupLevel++;
- },
- suspendSMaskGroup: function CanvasGraphics_endSMaskGroup() {
- var groupCtx = this.ctx;
- this.groupLevel--;
- this.ctx = this.groupStack.pop();
- composeSMask(this.ctx, this.current.activeSMask, groupCtx);
- this.ctx.restore();
- this.ctx.save();
- copyCtxState(groupCtx, this.ctx);
- this.current.resumeSMaskCtx = groupCtx;
- var deltaTransform = Util.transform(this.current.activeSMask.startTransformInverse, groupCtx.mozCurrentTransform);
- this.ctx.transform.apply(this.ctx, deltaTransform);
- groupCtx.save();
- groupCtx.setTransform(1, 0, 0, 1, 0, 0);
- groupCtx.clearRect(0, 0, groupCtx.canvas.width, groupCtx.canvas.height);
- groupCtx.restore();
- },
- resumeSMaskGroup: function CanvasGraphics_endSMaskGroup() {
- var groupCtx = this.current.resumeSMaskCtx;
- var currentCtx = this.ctx;
- this.ctx = groupCtx;
- this.groupStack.push(currentCtx);
- this.groupLevel++;
- },
- endSMaskGroup: function CanvasGraphics_endSMaskGroup() {
- var groupCtx = this.ctx;
- this.groupLevel--;
- this.ctx = this.groupStack.pop();
- composeSMask(this.ctx, this.current.activeSMask, groupCtx);
- this.ctx.restore();
- copyCtxState(groupCtx, this.ctx);
- var deltaTransform = Util.transform(this.current.activeSMask.startTransformInverse, groupCtx.mozCurrentTransform);
- this.ctx.transform.apply(this.ctx, deltaTransform);
- },
- save: function CanvasGraphics_save() {
- this.ctx.save();
- var old = this.current;
- this.stateStack.push(old);
- this.current = old.clone();
- this.current.resumeSMaskCtx = null;
- },
- restore: function CanvasGraphics_restore() {
- if (this.current.resumeSMaskCtx) {
- this.resumeSMaskGroup();
- }
- if (this.current.activeSMask !== null && (this.stateStack.length === 0 || this.stateStack[this.stateStack.length - 1].activeSMask !== this.current.activeSMask)) {
- this.endSMaskGroup();
- }
- if (this.stateStack.length !== 0) {
- this.current = this.stateStack.pop();
- this.ctx.restore();
- this.pendingClip = null;
- this.cachedGetSinglePixelWidth = null;
- }
- },
- transform: function CanvasGraphics_transform(a, b, c, d, e, f) {
- this.ctx.transform(a, b, c, d, e, f);
- this.cachedGetSinglePixelWidth = null;
- },
- constructPath: function CanvasGraphics_constructPath(ops, args) {
- var ctx = this.ctx;
- var current = this.current;
- var x = current.x,
- y = current.y;
- for (var i = 0, j = 0, ii = ops.length; i < ii; i++) {
- switch (ops[i] | 0) {
- case OPS.rectangle:
- x = args[j++];
- y = args[j++];
- var width = args[j++];
- var height = args[j++];
- if (width === 0) {
- width = this.getSinglePixelWidth();
- }
- if (height === 0) {
- height = this.getSinglePixelWidth();
- }
- var xw = x + width;
- var yh = y + height;
- this.ctx.moveTo(x, y);
- this.ctx.lineTo(xw, y);
- this.ctx.lineTo(xw, yh);
- this.ctx.lineTo(x, yh);
- this.ctx.lineTo(x, y);
- this.ctx.closePath();
- break;
- case OPS.moveTo:
- x = args[j++];
- y = args[j++];
- ctx.moveTo(x, y);
- break;
- case OPS.lineTo:
- x = args[j++];
- y = args[j++];
- ctx.lineTo(x, y);
- break;
- case OPS.curveTo:
- x = args[j + 4];
- y = args[j + 5];
- ctx.bezierCurveTo(args[j], args[j + 1], args[j + 2], args[j + 3], x, y);
- j += 6;
- break;
- case OPS.curveTo2:
- ctx.bezierCurveTo(x, y, args[j], args[j + 1], args[j + 2], args[j + 3]);
- x = args[j + 2];
- y = args[j + 3];
- j += 4;
- break;
- case OPS.curveTo3:
- x = args[j + 2];
- y = args[j + 3];
- ctx.bezierCurveTo(args[j], args[j + 1], x, y, x, y);
- j += 4;
- break;
- case OPS.closePath:
- ctx.closePath();
- break;
- }
- }
- current.setCurrentPoint(x, y);
- },
- closePath: function CanvasGraphics_closePath() {
- this.ctx.closePath();
- },
- stroke: function CanvasGraphics_stroke(consumePath) {
- consumePath = typeof consumePath !== 'undefined' ? consumePath : true;
- var ctx = this.ctx;
- var strokeColor = this.current.strokeColor;
- ctx.lineWidth = Math.max(this.getSinglePixelWidth() * MIN_WIDTH_FACTOR, this.current.lineWidth);
- ctx.globalAlpha = this.current.strokeAlpha;
- if (strokeColor && strokeColor.hasOwnProperty('type') && strokeColor.type === 'Pattern') {
- ctx.save();
- ctx.strokeStyle = strokeColor.getPattern(ctx, this);
- ctx.stroke();
- ctx.restore();
- } else {
- ctx.stroke();
- }
- if (consumePath) {
- this.consumePath();
- }
- ctx.globalAlpha = this.current.fillAlpha;
- },
- closeStroke: function CanvasGraphics_closeStroke() {
- this.closePath();
- this.stroke();
- },
- fill: function CanvasGraphics_fill(consumePath) {
- consumePath = typeof consumePath !== 'undefined' ? consumePath : true;
- var ctx = this.ctx;
- var fillColor = this.current.fillColor;
- var isPatternFill = this.current.patternFill;
- var needRestore = false;
- if (isPatternFill) {
- ctx.save();
- if (this.baseTransform) {
- ctx.setTransform.apply(ctx, this.baseTransform);
- }
- ctx.fillStyle = fillColor.getPattern(ctx, this);
- needRestore = true;
- }
- if (this.pendingEOFill) {
- ctx.fill('evenodd');
- this.pendingEOFill = false;
- } else {
- ctx.fill();
- }
- if (needRestore) {
- ctx.restore();
- }
- if (consumePath) {
- this.consumePath();
- }
- },
- eoFill: function CanvasGraphics_eoFill() {
- this.pendingEOFill = true;
- this.fill();
- },
- fillStroke: function CanvasGraphics_fillStroke() {
- this.fill(false);
- this.stroke(false);
- this.consumePath();
- },
- eoFillStroke: function CanvasGraphics_eoFillStroke() {
- this.pendingEOFill = true;
- this.fillStroke();
- },
- closeFillStroke: function CanvasGraphics_closeFillStroke() {
- this.closePath();
- this.fillStroke();
- },
- closeEOFillStroke: function CanvasGraphics_closeEOFillStroke() {
- this.pendingEOFill = true;
- this.closePath();
- this.fillStroke();
- },
- endPath: function CanvasGraphics_endPath() {
- this.consumePath();
- },
- clip: function CanvasGraphics_clip() {
- this.pendingClip = NORMAL_CLIP;
- },
- eoClip: function CanvasGraphics_eoClip() {
- this.pendingClip = EO_CLIP;
- },
- beginText: function CanvasGraphics_beginText() {
- this.current.textMatrix = IDENTITY_MATRIX;
- this.current.textMatrixScale = 1;
- this.current.x = this.current.lineX = 0;
- this.current.y = this.current.lineY = 0;
- },
- endText: function CanvasGraphics_endText() {
- var paths = this.pendingTextPaths;
- var ctx = this.ctx;
- if (paths === undefined) {
- ctx.beginPath();
- return;
- }
- ctx.save();
- ctx.beginPath();
- for (var i = 0; i < paths.length; i++) {
- var path = paths[i];
- ctx.setTransform.apply(ctx, path.transform);
- ctx.translate(path.x, path.y);
- path.addToPath(ctx, path.fontSize);
- }
- ctx.restore();
- ctx.clip();
- ctx.beginPath();
- delete this.pendingTextPaths;
- },
- setCharSpacing: function CanvasGraphics_setCharSpacing(spacing) {
- this.current.charSpacing = spacing;
- },
- setWordSpacing: function CanvasGraphics_setWordSpacing(spacing) {
- this.current.wordSpacing = spacing;
- },
- setHScale: function CanvasGraphics_setHScale(scale) {
- this.current.textHScale = scale / 100;
- },
- setLeading: function CanvasGraphics_setLeading(leading) {
- this.current.leading = -leading;
- },
- setFont: function CanvasGraphics_setFont(fontRefName, size) {
- var fontObj = this.commonObjs.get(fontRefName);
- var current = this.current;
- if (!fontObj) {
- error('Can\'t find font for ' + fontRefName);
- }
- current.fontMatrix = fontObj.fontMatrix ? fontObj.fontMatrix : FONT_IDENTITY_MATRIX;
- if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
- warn('Invalid font matrix for font ' + fontRefName);
- }
- if (size < 0) {
- size = -size;
- current.fontDirection = -1;
- } else {
- current.fontDirection = 1;
- }
- this.current.font = fontObj;
- this.current.fontSize = size;
- if (fontObj.isType3Font) {
- return;
- }
- var name = fontObj.loadedName || 'sans-serif';
- var bold = fontObj.black ? '900' : fontObj.bold ? 'bold' : 'normal';
- var italic = fontObj.italic ? 'italic' : 'normal';
- var typeface = '"' + name + '", ' + fontObj.fallbackName;
- var browserFontSize = size < MIN_FONT_SIZE ? MIN_FONT_SIZE : size > MAX_FONT_SIZE ? MAX_FONT_SIZE : size;
- this.current.fontSizeScale = size / browserFontSize;
- var rule = italic + ' ' + bold + ' ' + browserFontSize + 'px ' + typeface;
- this.ctx.font = rule;
- },
- setTextRenderingMode: function CanvasGraphics_setTextRenderingMode(mode) {
- this.current.textRenderingMode = mode;
- },
- setTextRise: function CanvasGraphics_setTextRise(rise) {
- this.current.textRise = rise;
- },
- moveText: function CanvasGraphics_moveText(x, y) {
- this.current.x = this.current.lineX += x;
- this.current.y = this.current.lineY += y;
- },
- setLeadingMoveText: function CanvasGraphics_setLeadingMoveText(x, y) {
- this.setLeading(-y);
- this.moveText(x, y);
- },
- setTextMatrix: function CanvasGraphics_setTextMatrix(a, b, c, d, e, f) {
- this.current.textMatrix = [a, b, c, d, e, f];
- this.current.textMatrixScale = Math.sqrt(a * a + b * b);
- this.current.x = this.current.lineX = 0;
- this.current.y = this.current.lineY = 0;
- },
- nextLine: function CanvasGraphics_nextLine() {
- this.moveText(0, this.current.leading);
- },
- paintChar: function CanvasGraphics_paintChar(character, x, y) {
- var ctx = this.ctx;
- var current = this.current;
- var font = current.font;
- var textRenderingMode = current.textRenderingMode;
- var fontSize = current.fontSize / current.fontSizeScale;
- var fillStrokeMode = textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
- var isAddToPathSet = !!(textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG);
- var addToPath;
- if (font.disableFontFace || isAddToPathSet) {
- addToPath = font.getPathGenerator(this.commonObjs, character);
- }
- if (font.disableFontFace) {
- ctx.save();
- ctx.translate(x, y);
- ctx.beginPath();
- addToPath(ctx, fontSize);
- if (fillStrokeMode === TextRenderingMode.FILL || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
- ctx.fill();
- }
- if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
- ctx.stroke();
- }
- ctx.restore();
- } else {
- if (fillStrokeMode === TextRenderingMode.FILL || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
- ctx.fillText(character, x, y);
- }
- if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
- ctx.strokeText(character, x, y);
- }
- }
- if (isAddToPathSet) {
- var paths = this.pendingTextPaths || (this.pendingTextPaths = []);
- paths.push({
- transform: ctx.mozCurrentTransform,
- x: x,
- y: y,
- fontSize: fontSize,
- addToPath: addToPath
- });
- }
- },
- get isFontSubpixelAAEnabled() {
- var ctx = this.canvasFactory.create(10, 10).context;
- ctx.scale(1.5, 1);
- ctx.fillText('I', 0, 10);
- var data = ctx.getImageData(0, 0, 10, 10).data;
- var enabled = false;
- for (var i = 3; i < data.length; i += 4) {
- if (data[i] > 0 && data[i] < 255) {
- enabled = true;
- break;
- }
- }
- return shadow(this, 'isFontSubpixelAAEnabled', enabled);
- },
- showText: function CanvasGraphics_showText(glyphs) {
- var current = this.current;
- var font = current.font;
- if (font.isType3Font) {
- return this.showType3Text(glyphs);
- }
- var fontSize = current.fontSize;
- if (fontSize === 0) {
- return;
- }
- var ctx = this.ctx;
- var fontSizeScale = current.fontSizeScale;
- var charSpacing = current.charSpacing;
- var wordSpacing = current.wordSpacing;
- var fontDirection = current.fontDirection;
- var textHScale = current.textHScale * fontDirection;
- var glyphsLength = glyphs.length;
- var vertical = font.vertical;
- var spacingDir = vertical ? 1 : -1;
- var defaultVMetrics = font.defaultVMetrics;
- var widthAdvanceScale = fontSize * current.fontMatrix[0];
- var simpleFillText = current.textRenderingMode === TextRenderingMode.FILL && !font.disableFontFace;
- ctx.save();
- ctx.transform.apply(ctx, current.textMatrix);
- ctx.translate(current.x, current.y + current.textRise);
- if (current.patternFill) {
- ctx.fillStyle = current.fillColor.getPattern(ctx, this);
- }
- if (fontDirection > 0) {
- ctx.scale(textHScale, -1);
- } else {
- ctx.scale(textHScale, 1);
- }
- var lineWidth = current.lineWidth;
- var scale = current.textMatrixScale;
- if (scale === 0 || lineWidth === 0) {
- var fillStrokeMode = current.textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
- if (fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE) {
- this.cachedGetSinglePixelWidth = null;
- lineWidth = this.getSinglePixelWidth() * MIN_WIDTH_FACTOR;
- }
- } else {
- lineWidth /= scale;
- }
- if (fontSizeScale !== 1.0) {
- ctx.scale(fontSizeScale, fontSizeScale);
- lineWidth /= fontSizeScale;
- }
- ctx.lineWidth = lineWidth;
- var x = 0,
- i;
- for (i = 0; i < glyphsLength; ++i) {
- var glyph = glyphs[i];
- if (isNum(glyph)) {
- x += spacingDir * glyph * fontSize / 1000;
- continue;
- }
- var restoreNeeded = false;
- var spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
- var character = glyph.fontChar;
- var accent = glyph.accent;
- var scaledX, scaledY, scaledAccentX, scaledAccentY;
- var width = glyph.width;
- if (vertical) {
- var vmetric, vx, vy;
- vmetric = glyph.vmetric || defaultVMetrics;
- vx = glyph.vmetric ? vmetric[1] : width * 0.5;
- vx = -vx * widthAdvanceScale;
- vy = vmetric[2] * widthAdvanceScale;
- width = vmetric ? -vmetric[0] : width;
- scaledX = vx / fontSizeScale;
- scaledY = (x + vy) / fontSizeScale;
- } else {
- scaledX = x / fontSizeScale;
- scaledY = 0;
- }
- if (font.remeasure && width > 0) {
- var measuredWidth = ctx.measureText(character).width * 1000 / fontSize * fontSizeScale;
- if (width < measuredWidth && this.isFontSubpixelAAEnabled) {
- var characterScaleX = width / measuredWidth;
- restoreNeeded = true;
- ctx.save();
- ctx.scale(characterScaleX, 1);
- scaledX /= characterScaleX;
- } else if (width !== measuredWidth) {
- scaledX += (width - measuredWidth) / 2000 * fontSize / fontSizeScale;
- }
- }
- if (glyph.isInFont || font.missingFile) {
- if (simpleFillText && !accent) {
- ctx.fillText(character, scaledX, scaledY);
- } else {
- this.paintChar(character, scaledX, scaledY);
- if (accent) {
- scaledAccentX = scaledX + accent.offset.x / fontSizeScale;
- scaledAccentY = scaledY - accent.offset.y / fontSizeScale;
- this.paintChar(accent.fontChar, scaledAccentX, scaledAccentY);
- }
- }
- }
- var charWidth = width * widthAdvanceScale + spacing * fontDirection;
- x += charWidth;
- if (restoreNeeded) {
- ctx.restore();
- }
- }
- if (vertical) {
- current.y -= x * textHScale;
- } else {
- current.x += x * textHScale;
- }
- ctx.restore();
- },
- showType3Text: function CanvasGraphics_showType3Text(glyphs) {
- var ctx = this.ctx;
- var current = this.current;
- var font = current.font;
- var fontSize = current.fontSize;
- var fontDirection = current.fontDirection;
- var spacingDir = font.vertical ? 1 : -1;
- var charSpacing = current.charSpacing;
- var wordSpacing = current.wordSpacing;
- var textHScale = current.textHScale * fontDirection;
- var fontMatrix = current.fontMatrix || FONT_IDENTITY_MATRIX;
- var glyphsLength = glyphs.length;
- var isTextInvisible = current.textRenderingMode === TextRenderingMode.INVISIBLE;
- var i, glyph, width, spacingLength;
- if (isTextInvisible || fontSize === 0) {
- return;
- }
- this.cachedGetSinglePixelWidth = null;
- ctx.save();
- ctx.transform.apply(ctx, current.textMatrix);
- ctx.translate(current.x, current.y);
- ctx.scale(textHScale, fontDirection);
- for (i = 0; i < glyphsLength; ++i) {
- glyph = glyphs[i];
- if (isNum(glyph)) {
- spacingLength = spacingDir * glyph * fontSize / 1000;
- this.ctx.translate(spacingLength, 0);
- current.x += spacingLength * textHScale;
- continue;
- }
- var spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
- var operatorList = font.charProcOperatorList[glyph.operatorListId];
- if (!operatorList) {
- warn('Type3 character \"' + glyph.operatorListId + '\" is not available');
- continue;
- }
- this.processingType3 = glyph;
- this.save();
- ctx.scale(fontSize, fontSize);
- ctx.transform.apply(ctx, fontMatrix);
- this.executeOperatorList(operatorList);
- this.restore();
- var transformed = Util.applyTransform([glyph.width, 0], fontMatrix);
- width = transformed[0] * fontSize + spacing;
- ctx.translate(width, 0);
- current.x += width * textHScale;
- }
- ctx.restore();
- this.processingType3 = null;
- },
- setCharWidth: function CanvasGraphics_setCharWidth(xWidth, yWidth) {},
- setCharWidthAndBounds: function CanvasGraphics_setCharWidthAndBounds(xWidth, yWidth, llx, lly, urx, ury) {
- this.ctx.rect(llx, lly, urx - llx, ury - lly);
- this.clip();
- this.endPath();
- },
- getColorN_Pattern: function CanvasGraphics_getColorN_Pattern(IR) {
- var pattern;
- if (IR[0] === 'TilingPattern') {
- var color = IR[1];
- var baseTransform = this.baseTransform || this.ctx.mozCurrentTransform.slice();
- var self = this;
- var canvasGraphicsFactory = {
- createCanvasGraphics: function (ctx) {
- return new CanvasGraphics(ctx, self.commonObjs, self.objs, self.canvasFactory);
- }
- };
- pattern = new TilingPattern(IR, color, this.ctx, canvasGraphicsFactory, baseTransform);
- } else {
- pattern = getShadingPatternFromIR(IR);
- }
- return pattern;
- },
- setStrokeColorN: function CanvasGraphics_setStrokeColorN() {
- this.current.strokeColor = this.getColorN_Pattern(arguments);
- },
- setFillColorN: function CanvasGraphics_setFillColorN() {
- this.current.fillColor = this.getColorN_Pattern(arguments);
- this.current.patternFill = true;
- },
- setStrokeRGBColor: function CanvasGraphics_setStrokeRGBColor(r, g, b) {
- var color = Util.makeCssRgb(r, g, b);
- this.ctx.strokeStyle = color;
- this.current.strokeColor = color;
- },
- setFillRGBColor: function CanvasGraphics_setFillRGBColor(r, g, b) {
- var color = Util.makeCssRgb(r, g, b);
- this.ctx.fillStyle = color;
- this.current.fillColor = color;
- this.current.patternFill = false;
- },
- shadingFill: function CanvasGraphics_shadingFill(patternIR) {
- var ctx = this.ctx;
- this.save();
- var pattern = getShadingPatternFromIR(patternIR);
- ctx.fillStyle = pattern.getPattern(ctx, this, true);
- var inv = ctx.mozCurrentTransformInverse;
- if (inv) {
- var canvas = ctx.canvas;
- var width = canvas.width;
- var height = canvas.height;
- var bl = Util.applyTransform([0, 0], inv);
- var br = Util.applyTransform([0, height], inv);
- var ul = Util.applyTransform([width, 0], inv);
- var ur = Util.applyTransform([width, height], inv);
- var x0 = Math.min(bl[0], br[0], ul[0], ur[0]);
- var y0 = Math.min(bl[1], br[1], ul[1], ur[1]);
- var x1 = Math.max(bl[0], br[0], ul[0], ur[0]);
- var y1 = Math.max(bl[1], br[1], ul[1], ur[1]);
- this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
- } else {
- this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10);
- }
- this.restore();
- },
- beginInlineImage: function CanvasGraphics_beginInlineImage() {
- error('Should not call beginInlineImage');
- },
- beginImageData: function CanvasGraphics_beginImageData() {
- error('Should not call beginImageData');
- },
- paintFormXObjectBegin: function CanvasGraphics_paintFormXObjectBegin(matrix, bbox) {
- this.save();
- this.baseTransformStack.push(this.baseTransform);
- if (isArray(matrix) && matrix.length === 6) {
- this.transform.apply(this, matrix);
- }
- this.baseTransform = this.ctx.mozCurrentTransform;
- if (isArray(bbox) && bbox.length === 4) {
- var width = bbox[2] - bbox[0];
- var height = bbox[3] - bbox[1];
- this.ctx.rect(bbox[0], bbox[1], width, height);
- this.clip();
- this.endPath();
- }
- },
- paintFormXObjectEnd: function CanvasGraphics_paintFormXObjectEnd() {
- this.restore();
- this.baseTransform = this.baseTransformStack.pop();
- },
- beginGroup: function CanvasGraphics_beginGroup(group) {
- this.save();
- var currentCtx = this.ctx;
- if (!group.isolated) {
- info('TODO: Support non-isolated groups.');
- }
- if (group.knockout) {
- warn('Knockout groups not supported.');
- }
- var currentTransform = currentCtx.mozCurrentTransform;
- if (group.matrix) {
- currentCtx.transform.apply(currentCtx, group.matrix);
- }
- assert(group.bbox, 'Bounding box is required.');
- var bounds = Util.getAxialAlignedBoundingBox(group.bbox, currentCtx.mozCurrentTransform);
- var canvasBounds = [0, 0, currentCtx.canvas.width, currentCtx.canvas.height];
- bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
- var offsetX = Math.floor(bounds[0]);
- var offsetY = Math.floor(bounds[1]);
- var drawnWidth = Math.max(Math.ceil(bounds[2]) - offsetX, 1);
- var drawnHeight = Math.max(Math.ceil(bounds[3]) - offsetY, 1);
- var scaleX = 1,
- scaleY = 1;
- if (drawnWidth > MAX_GROUP_SIZE) {
- scaleX = drawnWidth / MAX_GROUP_SIZE;
- drawnWidth = MAX_GROUP_SIZE;
- }
- if (drawnHeight > MAX_GROUP_SIZE) {
- scaleY = drawnHeight / MAX_GROUP_SIZE;
- drawnHeight = MAX_GROUP_SIZE;
- }
- var cacheId = 'groupAt' + this.groupLevel;
- if (group.smask) {
- cacheId += '_smask_' + this.smaskCounter++ % 2;
- }
- var scratchCanvas = this.cachedCanvases.getCanvas(cacheId, drawnWidth, drawnHeight, true);
- var groupCtx = scratchCanvas.context;
- groupCtx.scale(1 / scaleX, 1 / scaleY);
- groupCtx.translate(-offsetX, -offsetY);
- groupCtx.transform.apply(groupCtx, currentTransform);
- if (group.smask) {
- this.smaskStack.push({
- canvas: scratchCanvas.canvas,
- context: groupCtx,
- offsetX: offsetX,
- offsetY: offsetY,
- scaleX: scaleX,
- scaleY: scaleY,
- subtype: group.smask.subtype,
- backdrop: group.smask.backdrop,
- transferMap: group.smask.transferMap || null,
- startTransformInverse: null
- });
- } else {
- currentCtx.setTransform(1, 0, 0, 1, 0, 0);
- currentCtx.translate(offsetX, offsetY);
- currentCtx.scale(scaleX, scaleY);
- }
- copyCtxState(currentCtx, groupCtx);
- this.ctx = groupCtx;
- this.setGState([['BM', 'Normal'], ['ca', 1], ['CA', 1]]);
- this.groupStack.push(currentCtx);
- this.groupLevel++;
- this.current.activeSMask = null;
- },
- endGroup: function CanvasGraphics_endGroup(group) {
- this.groupLevel--;
- var groupCtx = this.ctx;
- this.ctx = this.groupStack.pop();
- if (this.ctx.imageSmoothingEnabled !== undefined) {
- this.ctx.imageSmoothingEnabled = false;
- } else {
- this.ctx.mozImageSmoothingEnabled = false;
- }
- if (group.smask) {
- this.tempSMask = this.smaskStack.pop();
- } else {
- this.ctx.drawImage(groupCtx.canvas, 0, 0);
- }
- this.restore();
- },
- beginAnnotations: function CanvasGraphics_beginAnnotations() {
- this.save();
- this.current = new CanvasExtraState();
- if (this.baseTransform) {
- this.ctx.setTransform.apply(this.ctx, this.baseTransform);
- }
- },
- endAnnotations: function CanvasGraphics_endAnnotations() {
- this.restore();
- },
- beginAnnotation: function CanvasGraphics_beginAnnotation(rect, transform, matrix) {
- this.save();
- if (isArray(rect) && rect.length === 4) {
- var width = rect[2] - rect[0];
- var height = rect[3] - rect[1];
- this.ctx.rect(rect[0], rect[1], width, height);
- this.clip();
- this.endPath();
- }
- this.transform.apply(this, transform);
- this.transform.apply(this, matrix);
- },
- endAnnotation: function CanvasGraphics_endAnnotation() {
- this.restore();
- },
- paintJpegXObject: function CanvasGraphics_paintJpegXObject(objId, w, h) {
- var domImage = this.objs.get(objId);
- if (!domImage) {
- warn('Dependent image isn\'t ready yet');
- return;
- }
- this.save();
- var ctx = this.ctx;
- ctx.scale(1 / w, -1 / h);
- ctx.drawImage(domImage, 0, 0, domImage.width, domImage.height, 0, -h, w, h);
- if (this.imageLayer) {
- var currentTransform = ctx.mozCurrentTransformInverse;
- var position = this.getCanvasPosition(0, 0);
- this.imageLayer.appendImage({
- objId: objId,
- left: position[0],
- top: position[1],
- width: w / currentTransform[0],
- height: h / currentTransform[3]
- });
- }
- this.restore();
- },
- paintImageMaskXObject: function CanvasGraphics_paintImageMaskXObject(img) {
- var ctx = this.ctx;
- var width = img.width,
- height = img.height;
- var fillColor = this.current.fillColor;
- var isPatternFill = this.current.patternFill;
- var glyph = this.processingType3;
- if (COMPILE_TYPE3_GLYPHS && glyph && glyph.compiled === undefined) {
- if (width <= MAX_SIZE_TO_COMPILE && height <= MAX_SIZE_TO_COMPILE) {
- glyph.compiled = compileType3Glyph({
- data: img.data,
- width: width,
- height: height
- });
- } else {
- glyph.compiled = null;
- }
- }
- if (glyph && glyph.compiled) {
- glyph.compiled(ctx);
- return;
- }
- var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
- var maskCtx = maskCanvas.context;
- maskCtx.save();
- putBinaryImageMask(maskCtx, img);
- maskCtx.globalCompositeOperation = 'source-in';
- maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
- maskCtx.fillRect(0, 0, width, height);
- maskCtx.restore();
- this.paintInlineImageXObject(maskCanvas.canvas);
- },
- paintImageMaskXObjectRepeat: function CanvasGraphics_paintImageMaskXObjectRepeat(imgData, scaleX, scaleY, positions) {
- var width = imgData.width;
- var height = imgData.height;
- var fillColor = this.current.fillColor;
- var isPatternFill = this.current.patternFill;
- var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
- var maskCtx = maskCanvas.context;
- maskCtx.save();
- putBinaryImageMask(maskCtx, imgData);
- maskCtx.globalCompositeOperation = 'source-in';
- maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
- maskCtx.fillRect(0, 0, width, height);
- maskCtx.restore();
- var ctx = this.ctx;
- for (var i = 0, ii = positions.length; i < ii; i += 2) {
- ctx.save();
- ctx.transform(scaleX, 0, 0, scaleY, positions[i], positions[i + 1]);
- ctx.scale(1, -1);
- ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1);
- ctx.restore();
- }
- },
- paintImageMaskXObjectGroup: function CanvasGraphics_paintImageMaskXObjectGroup(images) {
- var ctx = this.ctx;
- var fillColor = this.current.fillColor;
- var isPatternFill = this.current.patternFill;
- for (var i = 0, ii = images.length; i < ii; i++) {
- var image = images[i];
- var width = image.width,
- height = image.height;
- var maskCanvas = this.cachedCanvases.getCanvas('maskCanvas', width, height);
- var maskCtx = maskCanvas.context;
- maskCtx.save();
- putBinaryImageMask(maskCtx, image);
- maskCtx.globalCompositeOperation = 'source-in';
- maskCtx.fillStyle = isPatternFill ? fillColor.getPattern(maskCtx, this) : fillColor;
- maskCtx.fillRect(0, 0, width, height);
- maskCtx.restore();
- ctx.save();
- ctx.transform.apply(ctx, image.transform);
- ctx.scale(1, -1);
- ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1);
- ctx.restore();
- }
- },
- paintImageXObject: function CanvasGraphics_paintImageXObject(objId) {
- var imgData = this.objs.get(objId);
- if (!imgData) {
- warn('Dependent image isn\'t ready yet');
- return;
- }
- this.paintInlineImageXObject(imgData);
- },
- paintImageXObjectRepeat: function CanvasGraphics_paintImageXObjectRepeat(objId, scaleX, scaleY, positions) {
- var imgData = this.objs.get(objId);
- if (!imgData) {
- warn('Dependent image isn\'t ready yet');
- return;
- }
- var width = imgData.width;
- var height = imgData.height;
- var map = [];
- for (var i = 0, ii = positions.length; i < ii; i += 2) {
- map.push({
- transform: [scaleX, 0, 0, scaleY, positions[i], positions[i + 1]],
- x: 0,
- y: 0,
- w: width,
- h: height
- });
- }
- this.paintInlineImageXObjectGroup(imgData, map);
- },
- paintInlineImageXObject: function CanvasGraphics_paintInlineImageXObject(imgData) {
- var width = imgData.width;
- var height = imgData.height;
- var ctx = this.ctx;
- this.save();
- ctx.scale(1 / width, -1 / height);
- var currentTransform = ctx.mozCurrentTransformInverse;
- var a = currentTransform[0],
- b = currentTransform[1];
- var widthScale = Math.max(Math.sqrt(a * a + b * b), 1);
- var c = currentTransform[2],
- d = currentTransform[3];
- var heightScale = Math.max(Math.sqrt(c * c + d * d), 1);
- var imgToPaint, tmpCanvas;
- if (imgData instanceof HTMLElement || !imgData.data) {
- imgToPaint = imgData;
- } else {
- tmpCanvas = this.cachedCanvases.getCanvas('inlineImage', width, height);
- var tmpCtx = tmpCanvas.context;
- putBinaryImageData(tmpCtx, imgData);
- imgToPaint = tmpCanvas.canvas;
- }
- var paintWidth = width,
- paintHeight = height;
- var tmpCanvasId = 'prescale1';
- while (widthScale > 2 && paintWidth > 1 || heightScale > 2 && paintHeight > 1) {
- var newWidth = paintWidth,
- newHeight = paintHeight;
- if (widthScale > 2 && paintWidth > 1) {
- newWidth = Math.ceil(paintWidth / 2);
- widthScale /= paintWidth / newWidth;
- }
- if (heightScale > 2 && paintHeight > 1) {
- newHeight = Math.ceil(paintHeight / 2);
- heightScale /= paintHeight / newHeight;
- }
- tmpCanvas = this.cachedCanvases.getCanvas(tmpCanvasId, newWidth, newHeight);
- tmpCtx = tmpCanvas.context;
- tmpCtx.clearRect(0, 0, newWidth, newHeight);
- tmpCtx.drawImage(imgToPaint, 0, 0, paintWidth, paintHeight, 0, 0, newWidth, newHeight);
- imgToPaint = tmpCanvas.canvas;
- paintWidth = newWidth;
- paintHeight = newHeight;
- tmpCanvasId = tmpCanvasId === 'prescale1' ? 'prescale2' : 'prescale1';
- }
- ctx.drawImage(imgToPaint, 0, 0, paintWidth, paintHeight, 0, -height, width, height);
- if (this.imageLayer) {
- var position = this.getCanvasPosition(0, -height);
- this.imageLayer.appendImage({
- imgData: imgData,
- left: position[0],
- top: position[1],
- width: width / currentTransform[0],
- height: height / currentTransform[3]
- });
- }
- this.restore();
- },
- paintInlineImageXObjectGroup: function CanvasGraphics_paintInlineImageXObjectGroup(imgData, map) {
- var ctx = this.ctx;
- var w = imgData.width;
- var h = imgData.height;
- var tmpCanvas = this.cachedCanvases.getCanvas('inlineImage', w, h);
- var tmpCtx = tmpCanvas.context;
- putBinaryImageData(tmpCtx, imgData);
- for (var i = 0, ii = map.length; i < ii; i++) {
- var entry = map[i];
- ctx.save();
- ctx.transform.apply(ctx, entry.transform);
- ctx.scale(1, -1);
- ctx.drawImage(tmpCanvas.canvas, entry.x, entry.y, entry.w, entry.h, 0, -1, 1, 1);
- if (this.imageLayer) {
- var position = this.getCanvasPosition(entry.x, entry.y);
- this.imageLayer.appendImage({
- imgData: imgData,
- left: position[0],
- top: position[1],
- width: w,
- height: h
- });
- }
- ctx.restore();
- }
- },
- paintSolidColorImageMask: function CanvasGraphics_paintSolidColorImageMask() {
- this.ctx.fillRect(0, 0, 1, 1);
- },
- paintXObject: function CanvasGraphics_paintXObject() {
- warn('Unsupported \'paintXObject\' command.');
- },
- markPoint: function CanvasGraphics_markPoint(tag) {},
- markPointProps: function CanvasGraphics_markPointProps(tag, properties) {},
- beginMarkedContent: function CanvasGraphics_beginMarkedContent(tag) {},
- beginMarkedContentProps: function CanvasGraphics_beginMarkedContentProps(tag, properties) {},
- endMarkedContent: function CanvasGraphics_endMarkedContent() {},
- beginCompat: function CanvasGraphics_beginCompat() {},
- endCompat: function CanvasGraphics_endCompat() {},
- consumePath: function CanvasGraphics_consumePath() {
- var ctx = this.ctx;
- if (this.pendingClip) {
- if (this.pendingClip === EO_CLIP) {
- ctx.clip('evenodd');
- } else {
- ctx.clip();
- }
- this.pendingClip = null;
- }
- ctx.beginPath();
- },
- getSinglePixelWidth: function CanvasGraphics_getSinglePixelWidth(scale) {
- if (this.cachedGetSinglePixelWidth === null) {
- this.ctx.save();
- var inverse = this.ctx.mozCurrentTransformInverse;
- this.ctx.restore();
- this.cachedGetSinglePixelWidth = Math.sqrt(Math.max(inverse[0] * inverse[0] + inverse[1] * inverse[1], inverse[2] * inverse[2] + inverse[3] * inverse[3]));
- }
- return this.cachedGetSinglePixelWidth;
- },
- getCanvasPosition: function CanvasGraphics_getCanvasPosition(x, y) {
- var transform = this.ctx.mozCurrentTransform;
- return [transform[0] * x + transform[2] * y + transform[4], transform[1] * x + transform[3] * y + transform[5]];
- }
- };
- for (var op in OPS) {
- CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
- }
- return CanvasGraphics;
-}();
-exports.CanvasGraphics = CanvasGraphics;
-
-/***/ }),
-/* 11 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var assert = sharedUtil.assert;
-var bytesToString = sharedUtil.bytesToString;
-var string32 = sharedUtil.string32;
-var shadow = sharedUtil.shadow;
-var warn = sharedUtil.warn;
-function FontLoader(docId) {
- this.docId = docId;
- this.styleElement = null;
- this.nativeFontFaces = [];
- this.loadTestFontId = 0;
- this.loadingContext = {
- requests: [],
- nextRequestId: 0
- };
-}
-FontLoader.prototype = {
- insertRule: function fontLoaderInsertRule(rule) {
- var styleElement = this.styleElement;
- if (!styleElement) {
- styleElement = this.styleElement = document.createElement('style');
- styleElement.id = 'PDFJS_FONT_STYLE_TAG_' + this.docId;
- document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement);
- }
- var styleSheet = styleElement.sheet;
- styleSheet.insertRule(rule, styleSheet.cssRules.length);
- },
- clear: function fontLoaderClear() {
- if (this.styleElement) {
- this.styleElement.remove();
- this.styleElement = null;
- }
- this.nativeFontFaces.forEach(function (nativeFontFace) {
- document.fonts.delete(nativeFontFace);
- });
- this.nativeFontFaces.length = 0;
- }
-};
-var getLoadTestFont = function () {
- return atob('T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQ' + 'AABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwA' + 'AAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbm' + 'FtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAA' + 'AADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6A' + 'ABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAA' + 'MQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAA' + 'AAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAA' + 'AAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQ' + 'AAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMA' + 'AQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAA' + 'EAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAA' + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAA' + 'AAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgc' + 'A/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWF' + 'hYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQA' + 'AAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAg' + 'ABAAAAAAAAAAAD6AAAAAAAAA==');
-};
-Object.defineProperty(FontLoader.prototype, 'loadTestFont', {
- get: function () {
- return shadow(this, 'loadTestFont', getLoadTestFont());
- },
- configurable: true
-});
-FontLoader.prototype.addNativeFontFace = function fontLoader_addNativeFontFace(nativeFontFace) {
- this.nativeFontFaces.push(nativeFontFace);
- document.fonts.add(nativeFontFace);
-};
-FontLoader.prototype.bind = function fontLoaderBind(fonts, callback) {
- var rules = [];
- var fontsToLoad = [];
- var fontLoadPromises = [];
- var getNativeFontPromise = function (nativeFontFace) {
- return nativeFontFace.loaded.catch(function (e) {
- warn('Failed to load font "' + nativeFontFace.family + '": ' + e);
- });
- };
- var isFontLoadingAPISupported = FontLoader.isFontLoadingAPISupported && !FontLoader.isSyncFontLoadingSupported;
- for (var i = 0, ii = fonts.length; i < ii; i++) {
- var font = fonts[i];
- if (font.attached || font.loading === false) {
- continue;
- }
- font.attached = true;
- if (isFontLoadingAPISupported) {
- var nativeFontFace = font.createNativeFontFace();
- if (nativeFontFace) {
- this.addNativeFontFace(nativeFontFace);
- fontLoadPromises.push(getNativeFontPromise(nativeFontFace));
- }
- } else {
- var rule = font.createFontFaceRule();
- if (rule) {
- this.insertRule(rule);
- rules.push(rule);
- fontsToLoad.push(font);
- }
- }
- }
- var request = this.queueLoadingCallback(callback);
- if (isFontLoadingAPISupported) {
- Promise.all(fontLoadPromises).then(function () {
- request.complete();
- });
- } else if (rules.length > 0 && !FontLoader.isSyncFontLoadingSupported) {
- this.prepareFontLoadEvent(rules, fontsToLoad, request);
- } else {
- request.complete();
- }
-};
-FontLoader.prototype.queueLoadingCallback = function FontLoader_queueLoadingCallback(callback) {
- function LoadLoader_completeRequest() {
- assert(!request.end, 'completeRequest() cannot be called twice');
- request.end = Date.now();
- while (context.requests.length > 0 && context.requests[0].end) {
- var otherRequest = context.requests.shift();
- setTimeout(otherRequest.callback, 0);
- }
- }
- var context = this.loadingContext;
- var requestId = 'pdfjs-font-loading-' + context.nextRequestId++;
- var request = {
- id: requestId,
- complete: LoadLoader_completeRequest,
- callback: callback,
- started: Date.now()
- };
- context.requests.push(request);
- return request;
-};
-FontLoader.prototype.prepareFontLoadEvent = function fontLoaderPrepareFontLoadEvent(rules, fonts, request) {
- function int32(data, offset) {
- return data.charCodeAt(offset) << 24 | data.charCodeAt(offset + 1) << 16 | data.charCodeAt(offset + 2) << 8 | data.charCodeAt(offset + 3) & 0xff;
- }
- function spliceString(s, offset, remove, insert) {
- var chunk1 = s.substr(0, offset);
- var chunk2 = s.substr(offset + remove);
- return chunk1 + insert + chunk2;
- }
- var i, ii;
- var canvas = document.createElement('canvas');
- canvas.width = 1;
- canvas.height = 1;
- var ctx = canvas.getContext('2d');
- var called = 0;
- function isFontReady(name, callback) {
- called++;
- if (called > 30) {
- warn('Load test font never loaded.');
- callback();
- return;
- }
- ctx.font = '30px ' + name;
- ctx.fillText('.', 0, 20);
- var imageData = ctx.getImageData(0, 0, 1, 1);
- if (imageData.data[3] > 0) {
- callback();
- return;
- }
- setTimeout(isFontReady.bind(null, name, callback));
- }
- var loadTestFontId = 'lt' + Date.now() + this.loadTestFontId++;
- var data = this.loadTestFont;
- var COMMENT_OFFSET = 976;
- data = spliceString(data, COMMENT_OFFSET, loadTestFontId.length, loadTestFontId);
- var CFF_CHECKSUM_OFFSET = 16;
- var XXXX_VALUE = 0x58585858;
- var checksum = int32(data, CFF_CHECKSUM_OFFSET);
- for (i = 0, ii = loadTestFontId.length - 3; i < ii; i += 4) {
- checksum = checksum - XXXX_VALUE + int32(loadTestFontId, i) | 0;
- }
- if (i < loadTestFontId.length) {
- checksum = checksum - XXXX_VALUE + int32(loadTestFontId + 'XXX', i) | 0;
- }
- data = spliceString(data, CFF_CHECKSUM_OFFSET, 4, string32(checksum));
- var url = 'url(data:font/opentype;base64,' + btoa(data) + ');';
- var rule = '@font-face { font-family:"' + loadTestFontId + '";src:' + url + '}';
- this.insertRule(rule);
- var names = [];
- for (i = 0, ii = fonts.length; i < ii; i++) {
- names.push(fonts[i].loadedName);
- }
- names.push(loadTestFontId);
- var div = document.createElement('div');
- div.setAttribute('style', 'visibility: hidden;' + 'width: 10px; height: 10px;' + 'position: absolute; top: 0px; left: 0px;');
- for (i = 0, ii = names.length; i < ii; ++i) {
- var span = document.createElement('span');
- span.textContent = 'Hi';
- span.style.fontFamily = names[i];
- div.appendChild(span);
- }
- document.body.appendChild(div);
- isFontReady(loadTestFontId, function () {
- document.body.removeChild(div);
- request.complete();
- });
-};
-FontLoader.isFontLoadingAPISupported = typeof document !== 'undefined' && !!document.fonts;
-var isSyncFontLoadingSupported = function isSyncFontLoadingSupported() {
- if (typeof navigator === 'undefined') {
- return true;
- }
- var supported = false;
- var m = /Mozilla\/5.0.*?rv:(\d+).*? Gecko/.exec(navigator.userAgent);
- if (m && m[1] >= 14) {
- supported = true;
- }
- return supported;
-};
-Object.defineProperty(FontLoader, 'isSyncFontLoadingSupported', {
- get: function () {
- return shadow(FontLoader, 'isSyncFontLoadingSupported', isSyncFontLoadingSupported());
- },
- enumerable: true,
- configurable: true
-});
-var IsEvalSupportedCached = {
- get value() {
- return shadow(this, 'value', sharedUtil.isEvalSupported());
- }
-};
-var FontFaceObject = function FontFaceObjectClosure() {
- function FontFaceObject(translatedData, options) {
- this.compiledGlyphs = Object.create(null);
- for (var i in translatedData) {
- this[i] = translatedData[i];
- }
- this.options = options;
- }
- FontFaceObject.prototype = {
- createNativeFontFace: function FontFaceObject_createNativeFontFace() {
- if (!this.data) {
- return null;
- }
- if (this.options.disableFontFace) {
- this.disableFontFace = true;
- return null;
- }
- var nativeFontFace = new FontFace(this.loadedName, this.data, {});
- if (this.options.fontRegistry) {
- this.options.fontRegistry.registerFont(this);
- }
- return nativeFontFace;
- },
- createFontFaceRule: function FontFaceObject_createFontFaceRule() {
- if (!this.data) {
- return null;
- }
- if (this.options.disableFontFace) {
- this.disableFontFace = true;
- return null;
- }
- var data = bytesToString(new Uint8Array(this.data));
- var fontName = this.loadedName;
- var url = 'url(data:' + this.mimetype + ';base64,' + btoa(data) + ');';
- var rule = '@font-face { font-family:"' + fontName + '";src:' + url + '}';
- if (this.options.fontRegistry) {
- this.options.fontRegistry.registerFont(this, url);
- }
- return rule;
- },
- getPathGenerator: function FontFaceObject_getPathGenerator(objs, character) {
- if (!(character in this.compiledGlyphs)) {
- var cmds = objs.get(this.loadedName + '_path_' + character);
- var current, i, len;
- if (this.options.isEvalSupported && IsEvalSupportedCached.value) {
- var args,
- js = '';
- for (i = 0, len = cmds.length; i < len; i++) {
- current = cmds[i];
- if (current.args !== undefined) {
- args = current.args.join(',');
- } else {
- args = '';
- }
- js += 'c.' + current.cmd + '(' + args + ');\n';
- }
- this.compiledGlyphs[character] = new Function('c', 'size', js);
- } else {
- this.compiledGlyphs[character] = function (c, size) {
- for (i = 0, len = cmds.length; i < len; i++) {
- current = cmds[i];
- if (current.cmd === 'scale') {
- current.args = [size, -size];
- }
- c[current.cmd].apply(c, current.args);
- }
- };
- }
- }
- return this.compiledGlyphs[character];
- }
- };
- return FontFaceObject;
-}();
-exports.FontFaceObject = FontFaceObject;
-exports.FontLoader = FontLoader;
-
-/***/ }),
-/* 12 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var sharedUtil = __w_pdfjs_require__(0);
-var displayWebGL = __w_pdfjs_require__(8);
-var Util = sharedUtil.Util;
-var info = sharedUtil.info;
-var isArray = sharedUtil.isArray;
-var error = sharedUtil.error;
-var WebGLUtils = displayWebGL.WebGLUtils;
-var ShadingIRs = {};
-ShadingIRs.RadialAxial = {
- fromIR: function RadialAxial_fromIR(raw) {
- var type = raw[1];
- var colorStops = raw[2];
- var p0 = raw[3];
- var p1 = raw[4];
- var r0 = raw[5];
- var r1 = raw[6];
- return {
- type: 'Pattern',
- getPattern: function RadialAxial_getPattern(ctx) {
- var grad;
- if (type === 'axial') {
- grad = ctx.createLinearGradient(p0[0], p0[1], p1[0], p1[1]);
- } else if (type === 'radial') {
- grad = ctx.createRadialGradient(p0[0], p0[1], r0, p1[0], p1[1], r1);
- }
- for (var i = 0, ii = colorStops.length; i < ii; ++i) {
- var c = colorStops[i];
- grad.addColorStop(c[0], c[1]);
- }
- return grad;
- }
- };
- }
-};
-var createMeshCanvas = function createMeshCanvasClosure() {
- function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) {
- var coords = context.coords,
- colors = context.colors;
- var bytes = data.data,
- rowSize = data.width * 4;
- var tmp;
- if (coords[p1 + 1] > coords[p2 + 1]) {
- tmp = p1;
- p1 = p2;
- p2 = tmp;
- tmp = c1;
- c1 = c2;
- c2 = tmp;
- }
- if (coords[p2 + 1] > coords[p3 + 1]) {
- tmp = p2;
- p2 = p3;
- p3 = tmp;
- tmp = c2;
- c2 = c3;
- c3 = tmp;
- }
- if (coords[p1 + 1] > coords[p2 + 1]) {
- tmp = p1;
- p1 = p2;
- p2 = tmp;
- tmp = c1;
- c1 = c2;
- c2 = tmp;
- }
- var x1 = (coords[p1] + context.offsetX) * context.scaleX;
- var y1 = (coords[p1 + 1] + context.offsetY) * context.scaleY;
- var x2 = (coords[p2] + context.offsetX) * context.scaleX;
- var y2 = (coords[p2 + 1] + context.offsetY) * context.scaleY;
- var x3 = (coords[p3] + context.offsetX) * context.scaleX;
- var y3 = (coords[p3 + 1] + context.offsetY) * context.scaleY;
- if (y1 >= y3) {
- return;
- }
- var c1r = colors[c1],
- c1g = colors[c1 + 1],
- c1b = colors[c1 + 2];
- var c2r = colors[c2],
- c2g = colors[c2 + 1],
- c2b = colors[c2 + 2];
- var c3r = colors[c3],
- c3g = colors[c3 + 1],
- c3b = colors[c3 + 2];
- var minY = Math.round(y1),
- maxY = Math.round(y3);
- var xa, car, cag, cab;
- var xb, cbr, cbg, cbb;
- var k;
- for (var y = minY; y <= maxY; y++) {
- if (y < y2) {
- k = y < y1 ? 0 : y1 === y2 ? 1 : (y1 - y) / (y1 - y2);
- xa = x1 - (x1 - x2) * k;
- car = c1r - (c1r - c2r) * k;
- cag = c1g - (c1g - c2g) * k;
- cab = c1b - (c1b - c2b) * k;
- } else {
- k = y > y3 ? 1 : y2 === y3 ? 0 : (y2 - y) / (y2 - y3);
- xa = x2 - (x2 - x3) * k;
- car = c2r - (c2r - c3r) * k;
- cag = c2g - (c2g - c3g) * k;
- cab = c2b - (c2b - c3b) * k;
- }
- k = y < y1 ? 0 : y > y3 ? 1 : (y1 - y) / (y1 - y3);
- xb = x1 - (x1 - x3) * k;
- cbr = c1r - (c1r - c3r) * k;
- cbg = c1g - (c1g - c3g) * k;
- cbb = c1b - (c1b - c3b) * k;
- var x1_ = Math.round(Math.min(xa, xb));
- var x2_ = Math.round(Math.max(xa, xb));
- var j = rowSize * y + x1_ * 4;
- for (var x = x1_; x <= x2_; x++) {
- k = (xa - x) / (xa - xb);
- k = k < 0 ? 0 : k > 1 ? 1 : k;
- bytes[j++] = car - (car - cbr) * k | 0;
- bytes[j++] = cag - (cag - cbg) * k | 0;
- bytes[j++] = cab - (cab - cbb) * k | 0;
- bytes[j++] = 255;
- }
- }
- }
- function drawFigure(data, figure, context) {
- var ps = figure.coords;
- var cs = figure.colors;
- var i, ii;
- switch (figure.type) {
- case 'lattice':
- var verticesPerRow = figure.verticesPerRow;
- var rows = Math.floor(ps.length / verticesPerRow) - 1;
- var cols = verticesPerRow - 1;
- for (i = 0; i < rows; i++) {
- var q = i * verticesPerRow;
- for (var j = 0; j < cols; j++, q++) {
- drawTriangle(data, context, ps[q], ps[q + 1], ps[q + verticesPerRow], cs[q], cs[q + 1], cs[q + verticesPerRow]);
- drawTriangle(data, context, ps[q + verticesPerRow + 1], ps[q + 1], ps[q + verticesPerRow], cs[q + verticesPerRow + 1], cs[q + 1], cs[q + verticesPerRow]);
- }
- }
- break;
- case 'triangles':
- for (i = 0, ii = ps.length; i < ii; i += 3) {
- drawTriangle(data, context, ps[i], ps[i + 1], ps[i + 2], cs[i], cs[i + 1], cs[i + 2]);
- }
- break;
- default:
- error('illigal figure');
- break;
- }
- }
- function createMeshCanvas(bounds, combinesScale, coords, colors, figures, backgroundColor, cachedCanvases) {
- var EXPECTED_SCALE = 1.1;
- var MAX_PATTERN_SIZE = 3000;
- var BORDER_SIZE = 2;
- var offsetX = Math.floor(bounds[0]);
- var offsetY = Math.floor(bounds[1]);
- var boundsWidth = Math.ceil(bounds[2]) - offsetX;
- var boundsHeight = Math.ceil(bounds[3]) - offsetY;
- var width = Math.min(Math.ceil(Math.abs(boundsWidth * combinesScale[0] * EXPECTED_SCALE)), MAX_PATTERN_SIZE);
- var height = Math.min(Math.ceil(Math.abs(boundsHeight * combinesScale[1] * EXPECTED_SCALE)), MAX_PATTERN_SIZE);
- var scaleX = boundsWidth / width;
- var scaleY = boundsHeight / height;
- var context = {
- coords: coords,
- colors: colors,
- offsetX: -offsetX,
- offsetY: -offsetY,
- scaleX: 1 / scaleX,
- scaleY: 1 / scaleY
- };
- var paddedWidth = width + BORDER_SIZE * 2;
- var paddedHeight = height + BORDER_SIZE * 2;
- var canvas, tmpCanvas, i, ii;
- if (WebGLUtils.isEnabled) {
- canvas = WebGLUtils.drawFigures(width, height, backgroundColor, figures, context);
- tmpCanvas = cachedCanvases.getCanvas('mesh', paddedWidth, paddedHeight, false);
- tmpCanvas.context.drawImage(canvas, BORDER_SIZE, BORDER_SIZE);
- canvas = tmpCanvas.canvas;
- } else {
- tmpCanvas = cachedCanvases.getCanvas('mesh', paddedWidth, paddedHeight, false);
- var tmpCtx = tmpCanvas.context;
- var data = tmpCtx.createImageData(width, height);
- if (backgroundColor) {
- var bytes = data.data;
- for (i = 0, ii = bytes.length; i < ii; i += 4) {
- bytes[i] = backgroundColor[0];
- bytes[i + 1] = backgroundColor[1];
- bytes[i + 2] = backgroundColor[2];
- bytes[i + 3] = 255;
- }
- }
- for (i = 0; i < figures.length; i++) {
- drawFigure(data, figures[i], context);
- }
- tmpCtx.putImageData(data, BORDER_SIZE, BORDER_SIZE);
- canvas = tmpCanvas.canvas;
- }
- return {
- canvas: canvas,
- offsetX: offsetX - BORDER_SIZE * scaleX,
- offsetY: offsetY - BORDER_SIZE * scaleY,
- scaleX: scaleX,
- scaleY: scaleY
- };
- }
- return createMeshCanvas;
-}();
-ShadingIRs.Mesh = {
- fromIR: function Mesh_fromIR(raw) {
- var coords = raw[2];
- var colors = raw[3];
- var figures = raw[4];
- var bounds = raw[5];
- var matrix = raw[6];
- var background = raw[8];
- return {
- type: 'Pattern',
- getPattern: function Mesh_getPattern(ctx, owner, shadingFill) {
- var scale;
- if (shadingFill) {
- scale = Util.singularValueDecompose2dScale(ctx.mozCurrentTransform);
- } else {
- scale = Util.singularValueDecompose2dScale(owner.baseTransform);
- if (matrix) {
- var matrixScale = Util.singularValueDecompose2dScale(matrix);
- scale = [scale[0] * matrixScale[0], scale[1] * matrixScale[1]];
- }
- }
- var temporaryPatternCanvas = createMeshCanvas(bounds, scale, coords, colors, figures, shadingFill ? null : background, owner.cachedCanvases);
- if (!shadingFill) {
- ctx.setTransform.apply(ctx, owner.baseTransform);
- if (matrix) {
- ctx.transform.apply(ctx, matrix);
- }
- }
- ctx.translate(temporaryPatternCanvas.offsetX, temporaryPatternCanvas.offsetY);
- ctx.scale(temporaryPatternCanvas.scaleX, temporaryPatternCanvas.scaleY);
- return ctx.createPattern(temporaryPatternCanvas.canvas, 'no-repeat');
- }
- };
- }
-};
-ShadingIRs.Dummy = {
- fromIR: function Dummy_fromIR() {
- return {
- type: 'Pattern',
- getPattern: function Dummy_fromIR_getPattern() {
- return 'hotpink';
- }
- };
- }
-};
-function getShadingPatternFromIR(raw) {
- var shadingIR = ShadingIRs[raw[0]];
- if (!shadingIR) {
- error('Unknown IR type: ' + raw[0]);
- }
- return shadingIR.fromIR(raw);
-}
-var TilingPattern = function TilingPatternClosure() {
- var PaintType = {
- COLORED: 1,
- UNCOLORED: 2
- };
- var MAX_PATTERN_SIZE = 3000;
- function TilingPattern(IR, color, ctx, canvasGraphicsFactory, baseTransform) {
- this.operatorList = IR[2];
- this.matrix = IR[3] || [1, 0, 0, 1, 0, 0];
- this.bbox = Util.normalizeRect(IR[4]);
- this.xstep = IR[5];
- this.ystep = IR[6];
- this.paintType = IR[7];
- this.tilingType = IR[8];
- this.color = color;
- this.canvasGraphicsFactory = canvasGraphicsFactory;
- this.baseTransform = baseTransform;
- this.type = 'Pattern';
- this.ctx = ctx;
- }
- TilingPattern.prototype = {
- createPatternCanvas: function TilinPattern_createPatternCanvas(owner) {
- var operatorList = this.operatorList;
- var bbox = this.bbox;
- var xstep = this.xstep;
- var ystep = this.ystep;
- var paintType = this.paintType;
- var tilingType = this.tilingType;
- var color = this.color;
- var canvasGraphicsFactory = this.canvasGraphicsFactory;
- info('TilingType: ' + tilingType);
- var x0 = bbox[0],
- y0 = bbox[1],
- x1 = bbox[2],
- y1 = bbox[3];
- var topLeft = [x0, y0];
- var botRight = [x0 + xstep, y0 + ystep];
- var width = botRight[0] - topLeft[0];
- var height = botRight[1] - topLeft[1];
- var matrixScale = Util.singularValueDecompose2dScale(this.matrix);
- var curMatrixScale = Util.singularValueDecompose2dScale(this.baseTransform);
- var combinedScale = [matrixScale[0] * curMatrixScale[0], matrixScale[1] * curMatrixScale[1]];
- width = Math.min(Math.ceil(Math.abs(width * combinedScale[0])), MAX_PATTERN_SIZE);
- height = Math.min(Math.ceil(Math.abs(height * combinedScale[1])), MAX_PATTERN_SIZE);
- var tmpCanvas = owner.cachedCanvases.getCanvas('pattern', width, height, true);
- var tmpCtx = tmpCanvas.context;
- var graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx);
- graphics.groupLevel = owner.groupLevel;
- this.setFillAndStrokeStyleToContext(tmpCtx, paintType, color);
- this.setScale(width, height, xstep, ystep);
- this.transformToScale(graphics);
- var tmpTranslate = [1, 0, 0, 1, -topLeft[0], -topLeft[1]];
- graphics.transform.apply(graphics, tmpTranslate);
- this.clipBbox(graphics, bbox, x0, y0, x1, y1);
- graphics.executeOperatorList(operatorList);
- return tmpCanvas.canvas;
- },
- setScale: function TilingPattern_setScale(width, height, xstep, ystep) {
- this.scale = [width / xstep, height / ystep];
- },
- transformToScale: function TilingPattern_transformToScale(graphics) {
- var scale = this.scale;
- var tmpScale = [scale[0], 0, 0, scale[1], 0, 0];
- graphics.transform.apply(graphics, tmpScale);
- },
- scaleToContext: function TilingPattern_scaleToContext() {
- var scale = this.scale;
- this.ctx.scale(1 / scale[0], 1 / scale[1]);
- },
- clipBbox: function clipBbox(graphics, bbox, x0, y0, x1, y1) {
- if (isArray(bbox) && bbox.length === 4) {
- var bboxWidth = x1 - x0;
- var bboxHeight = y1 - y0;
- graphics.ctx.rect(x0, y0, bboxWidth, bboxHeight);
- graphics.clip();
- graphics.endPath();
- }
- },
- setFillAndStrokeStyleToContext: function setFillAndStrokeStyleToContext(context, paintType, color) {
- switch (paintType) {
- case PaintType.COLORED:
- var ctx = this.ctx;
- context.fillStyle = ctx.fillStyle;
- context.strokeStyle = ctx.strokeStyle;
- break;
- case PaintType.UNCOLORED:
- var cssColor = Util.makeCssRgb(color[0], color[1], color[2]);
- context.fillStyle = cssColor;
- context.strokeStyle = cssColor;
- break;
- default:
- error('Unsupported paint type: ' + paintType);
- }
- },
- getPattern: function TilingPattern_getPattern(ctx, owner) {
- var temporaryPatternCanvas = this.createPatternCanvas(owner);
- ctx = this.ctx;
- ctx.setTransform.apply(ctx, this.baseTransform);
- ctx.transform.apply(ctx, this.matrix);
- this.scaleToContext();
- return ctx.createPattern(temporaryPatternCanvas, 'repeat');
- }
- };
- return TilingPattern;
-}();
-exports.getShadingPatternFromIR = getShadingPatternFromIR;
-exports.TilingPattern = TilingPattern;
-
-/***/ }),
-/* 13 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-
-
-var pdfjsVersion = '1.8.172';
-var pdfjsBuild = '8ff1fbe7';
-var pdfjsSharedUtil = __w_pdfjs_require__(0);
-var pdfjsDisplayGlobal = __w_pdfjs_require__(9);
-var pdfjsDisplayAPI = __w_pdfjs_require__(3);
-var pdfjsDisplayTextLayer = __w_pdfjs_require__(5);
-var pdfjsDisplayAnnotationLayer = __w_pdfjs_require__(2);
-var pdfjsDisplayDOMUtils = __w_pdfjs_require__(1);
-var pdfjsDisplaySVG = __w_pdfjs_require__(4);
-exports.PDFJS = pdfjsDisplayGlobal.PDFJS;
-exports.build = pdfjsDisplayAPI.build;
-exports.version = pdfjsDisplayAPI.version;
-exports.getDocument = pdfjsDisplayAPI.getDocument;
-exports.PDFDataRangeTransport = pdfjsDisplayAPI.PDFDataRangeTransport;
-exports.PDFWorker = pdfjsDisplayAPI.PDFWorker;
-exports.renderTextLayer = pdfjsDisplayTextLayer.renderTextLayer;
-exports.AnnotationLayer = pdfjsDisplayAnnotationLayer.AnnotationLayer;
-exports.CustomStyle = pdfjsDisplayDOMUtils.CustomStyle;
-exports.createPromiseCapability = pdfjsSharedUtil.createPromiseCapability;
-exports.PasswordResponses = pdfjsSharedUtil.PasswordResponses;
-exports.InvalidPDFException = pdfjsSharedUtil.InvalidPDFException;
-exports.MissingPDFException = pdfjsSharedUtil.MissingPDFException;
-exports.SVGGraphics = pdfjsDisplaySVG.SVGGraphics;
-exports.UnexpectedResponseException = pdfjsSharedUtil.UnexpectedResponseException;
-exports.OPS = pdfjsSharedUtil.OPS;
-exports.UNSUPPORTED_FEATURES = pdfjsSharedUtil.UNSUPPORTED_FEATURES;
-exports.isValidUrl = pdfjsDisplayDOMUtils.isValidUrl;
-exports.createValidAbsoluteUrl = pdfjsSharedUtil.createValidAbsoluteUrl;
-exports.createObjectURL = pdfjsSharedUtil.createObjectURL;
-exports.removeNullCharacters = pdfjsSharedUtil.removeNullCharacters;
-exports.shadow = pdfjsSharedUtil.shadow;
-exports.createBlob = pdfjsSharedUtil.createBlob;
-exports.RenderingCancelledException = pdfjsDisplayDOMUtils.RenderingCancelledException;
-exports.getFilenameFromUrl = pdfjsDisplayDOMUtils.getFilenameFromUrl;
-exports.addLinkAttributes = pdfjsDisplayDOMUtils.addLinkAttributes;
-
-/***/ }),
-/* 14 */
-/***/ (function(module, exports, __w_pdfjs_require__) {
-
-"use strict";
-/* WEBPACK VAR INJECTION */(function(global) {
-
-if (typeof PDFJS === 'undefined' || !PDFJS.compatibilityChecked) {
- var globalScope = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : undefined;
- var userAgent = typeof navigator !== 'undefined' && navigator.userAgent || '';
- var isAndroid = /Android/.test(userAgent);
- var isAndroidPre3 = /Android\s[0-2][^\d]/.test(userAgent);
- var isAndroidPre5 = /Android\s[0-4][^\d]/.test(userAgent);
- var isChrome = userAgent.indexOf('Chrom') >= 0;
- var isChromeWithRangeBug = /Chrome\/(39|40)\./.test(userAgent);
- var isIOSChrome = userAgent.indexOf('CriOS') >= 0;
- var isIE = userAgent.indexOf('Trident') >= 0;
- var isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
- var isOpera = userAgent.indexOf('Opera') >= 0;
- var isSafari = /Safari\//.test(userAgent) && !/(Chrome\/|Android\s)/.test(userAgent);
- var hasDOM = typeof window === 'object' && typeof document === 'object';
- if (typeof PDFJS === 'undefined') {
- globalScope.PDFJS = {};
- }
- PDFJS.compatibilityChecked = true;
- (function checkTypedArrayCompatibility() {
- if (typeof Uint8Array !== 'undefined') {
- if (typeof Uint8Array.prototype.subarray === 'undefined') {
- Uint8Array.prototype.subarray = function subarray(start, end) {
- return new Uint8Array(this.slice(start, end));
- };
- Float32Array.prototype.subarray = function subarray(start, end) {
- return new Float32Array(this.slice(start, end));
- };
- }
- if (typeof Float64Array === 'undefined') {
- globalScope.Float64Array = Float32Array;
- }
- return;
- }
- function subarray(start, end) {
- return new TypedArray(this.slice(start, end));
- }
- function setArrayOffset(array, offset) {
- if (arguments.length < 2) {
- offset = 0;
- }
- for (var i = 0, n = array.length; i < n; ++i, ++offset) {
- this[offset] = array[i] & 0xFF;
- }
- }
- function TypedArray(arg1) {
- var result, i, n;
- if (typeof arg1 === 'number') {
- result = [];
- for (i = 0; i < arg1; ++i) {
- result[i] = 0;
- }
- } else if ('slice' in arg1) {
- result = arg1.slice(0);
- } else {
- result = [];
- for (i = 0, n = arg1.length; i < n; ++i) {
- result[i] = arg1[i];
- }
- }
- result.subarray = subarray;
- result.buffer = result;
- result.byteLength = result.length;
- result.set = setArrayOffset;
- if (typeof arg1 === 'object' && arg1.buffer) {
- result.buffer = arg1.buffer;
- }
- return result;
- }
- globalScope.Uint8Array = TypedArray;
- globalScope.Int8Array = TypedArray;
- globalScope.Uint32Array = TypedArray;
- globalScope.Int32Array = TypedArray;
- globalScope.Uint16Array = TypedArray;
- globalScope.Float32Array = TypedArray;
- globalScope.Float64Array = TypedArray;
- })();
- (function normalizeURLObject() {
- if (!globalScope.URL) {
- globalScope.URL = globalScope.webkitURL;
- }
- })();
- (function checkObjectDefinePropertyCompatibility() {
- if (typeof Object.defineProperty !== 'undefined') {
- var definePropertyPossible = true;
- try {
- if (hasDOM) {
- Object.defineProperty(new Image(), 'id', { value: 'test' });
- }
- var Test = function Test() {};
- Test.prototype = {
- get id() {}
- };
- Object.defineProperty(new Test(), 'id', {
- value: '',
- configurable: true,
- enumerable: true,
- writable: false
- });
- } catch (e) {
- definePropertyPossible = false;
- }
- if (definePropertyPossible) {
- return;
- }
- }
- Object.defineProperty = function objectDefineProperty(obj, name, def) {
- delete obj[name];
- if ('get' in def) {
- obj.__defineGetter__(name, def['get']);
- }
- if ('set' in def) {
- obj.__defineSetter__(name, def['set']);
- }
- if ('value' in def) {
- obj.__defineSetter__(name, function objectDefinePropertySetter(value) {
- this.__defineGetter__(name, function objectDefinePropertyGetter() {
- return value;
- });
- return value;
- });
- obj[name] = def.value;
- }
- };
- })();
- (function checkXMLHttpRequestResponseCompatibility() {
- if (typeof XMLHttpRequest === 'undefined') {
- return;
- }
- var xhrPrototype = XMLHttpRequest.prototype;
- var xhr = new XMLHttpRequest();
- if (!('overrideMimeType' in xhr)) {
- Object.defineProperty(xhrPrototype, 'overrideMimeType', {
- value: function xmlHttpRequestOverrideMimeType(mimeType) {}
- });
- }
- if ('responseType' in xhr) {
- return;
- }
- Object.defineProperty(xhrPrototype, 'responseType', {
- get: function xmlHttpRequestGetResponseType() {
- return this._responseType || 'text';
- },
- set: function xmlHttpRequestSetResponseType(value) {
- if (value === 'text' || value === 'arraybuffer') {
- this._responseType = value;
- if (value === 'arraybuffer' && typeof this.overrideMimeType === 'function') {
- this.overrideMimeType('text/plain; charset=x-user-defined');
- }
- }
- }
- });
- if (typeof VBArray !== 'undefined') {
- Object.defineProperty(xhrPrototype, 'response', {
- get: function xmlHttpRequestResponseGet() {
- if (this.responseType === 'arraybuffer') {
- return new Uint8Array(new VBArray(this.responseBody).toArray());
- }
- return this.responseText;
- }
- });
- return;
- }
- Object.defineProperty(xhrPrototype, 'response', {
- get: function xmlHttpRequestResponseGet() {
- if (this.responseType !== 'arraybuffer') {
- return this.responseText;
- }
- var text = this.responseText;
- var i,
- n = text.length;
- var result = new Uint8Array(n);
- for (i = 0; i < n; ++i) {
- result[i] = text.charCodeAt(i) & 0xFF;
- }
- return result.buffer;
- }
- });
- })();
- (function checkWindowBtoaCompatibility() {
- if ('btoa' in globalScope) {
- return;
- }
- var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
- globalScope.btoa = function (chars) {
- var buffer = '';
- var i, n;
- for (i = 0, n = chars.length; i < n; i += 3) {
- var b1 = chars.charCodeAt(i) & 0xFF;
- var b2 = chars.charCodeAt(i + 1) & 0xFF;
- var b3 = chars.charCodeAt(i + 2) & 0xFF;
- var d1 = b1 >> 2,
- d2 = (b1 & 3) << 4 | b2 >> 4;
- var d3 = i + 1 < n ? (b2 & 0xF) << 2 | b3 >> 6 : 64;
- var d4 = i + 2 < n ? b3 & 0x3F : 64;
- buffer += digits.charAt(d1) + digits.charAt(d2) + digits.charAt(d3) + digits.charAt(d4);
- }
- return buffer;
- };
- })();
- (function checkWindowAtobCompatibility() {
- if ('atob' in globalScope) {
- return;
- }
- var digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
- globalScope.atob = function (input) {
- input = input.replace(/=+$/, '');
- if (input.length % 4 === 1) {
- throw new Error('bad atob input');
- }
- for (var bc = 0, bs, buffer, idx = 0, output = ''; buffer = input.charAt(idx++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
- buffer = digits.indexOf(buffer);
- }
- return output;
- };
- })();
- (function checkFunctionPrototypeBindCompatibility() {
- if (typeof Function.prototype.bind !== 'undefined') {
- return;
- }
- Function.prototype.bind = function functionPrototypeBind(obj) {
- var fn = this,
- headArgs = Array.prototype.slice.call(arguments, 1);
- var bound = function functionPrototypeBindBound() {
- var args = headArgs.concat(Array.prototype.slice.call(arguments));
- return fn.apply(obj, args);
- };
- return bound;
- };
- })();
- (function checkDatasetProperty() {
- if (!hasDOM) {
- return;
- }
- var div = document.createElement('div');
- if ('dataset' in div) {
- return;
- }
- Object.defineProperty(HTMLElement.prototype, 'dataset', {
- get: function () {
- if (this._dataset) {
- return this._dataset;
- }
- var dataset = {};
- for (var j = 0, jj = this.attributes.length; j < jj; j++) {
- var attribute = this.attributes[j];
- if (attribute.name.substring(0, 5) !== 'data-') {
- continue;
- }
- var key = attribute.name.substring(5).replace(/\-([a-z])/g, function (all, ch) {
- return ch.toUpperCase();
- });
- dataset[key] = attribute.value;
- }
- Object.defineProperty(this, '_dataset', {
- value: dataset,
- writable: false,
- enumerable: false
- });
- return dataset;
- },
- enumerable: true
- });
- })();
- (function checkClassListProperty() {
- function changeList(element, itemName, add, remove) {
- var s = element.className || '';
- var list = s.split(/\s+/g);
- if (list[0] === '') {
- list.shift();
- }
- var index = list.indexOf(itemName);
- if (index < 0 && add) {
- list.push(itemName);
- }
- if (index >= 0 && remove) {
- list.splice(index, 1);
- }
- element.className = list.join(' ');
- return index >= 0;
- }
- if (!hasDOM) {
- return;
- }
- var div = document.createElement('div');
- if ('classList' in div) {
- return;
- }
- var classListPrototype = {
- add: function (name) {
- changeList(this.element, name, true, false);
- },
- contains: function (name) {
- return changeList(this.element, name, false, false);
- },
- remove: function (name) {
- changeList(this.element, name, false, true);
- },
- toggle: function (name) {
- changeList(this.element, name, true, true);
- }
- };
- Object.defineProperty(HTMLElement.prototype, 'classList', {
- get: function () {
- if (this._classList) {
- return this._classList;
- }
- var classList = Object.create(classListPrototype, {
- element: {
- value: this,
- writable: false,
- enumerable: true
- }
- });
- Object.defineProperty(this, '_classList', {
- value: classList,
- writable: false,
- enumerable: false
- });
- return classList;
- },
- enumerable: true
- });
- })();
- (function checkWorkerConsoleCompatibility() {
- if (typeof importScripts === 'undefined' || 'console' in globalScope) {
- return;
- }
- var consoleTimer = {};
- var workerConsole = {
- log: function log() {
- var args = Array.prototype.slice.call(arguments);
- globalScope.postMessage({
- targetName: 'main',
- action: 'console_log',
- data: args
- });
- },
- error: function error() {
- var args = Array.prototype.slice.call(arguments);
- globalScope.postMessage({
- targetName: 'main',
- action: 'console_error',
- data: args
- });
- },
- time: function time(name) {
- consoleTimer[name] = Date.now();
- },
- timeEnd: function timeEnd(name) {
- var time = consoleTimer[name];
- if (!time) {
- throw new Error('Unknown timer name ' + name);
- }
- this.log('Timer:', name, Date.now() - time);
- }
- };
- globalScope.console = workerConsole;
- })();
- (function checkConsoleCompatibility() {
- if (!hasDOM) {
- return;
- }
- if (!('console' in window)) {
- window.console = {
- log: function () {},
- error: function () {},
- warn: function () {}
- };
- return;
- }
- if (!('bind' in console.log)) {
- console.log = function (fn) {
- return function (msg) {
- return fn(msg);
- };
- }(console.log);
- console.error = function (fn) {
- return function (msg) {
- return fn(msg);
- };
- }(console.error);
- console.warn = function (fn) {
- return function (msg) {
- return fn(msg);
- };
- }(console.warn);
- return;
- }
- })();
- (function checkOnClickCompatibility() {
- function ignoreIfTargetDisabled(event) {
- if (isDisabled(event.target)) {
- event.stopPropagation();
- }
- }
- function isDisabled(node) {
- return node.disabled || node.parentNode && isDisabled(node.parentNode);
- }
- if (isOpera) {
- document.addEventListener('click', ignoreIfTargetDisabled, true);
- }
- })();
- (function checkOnBlobSupport() {
- if (isIE || isIOSChrome) {
- PDFJS.disableCreateObjectURL = true;
- }
- })();
- (function checkNavigatorLanguage() {
- if (typeof navigator === 'undefined') {
- return;
- }
- if ('language' in navigator) {
- return;
- }
- PDFJS.locale = navigator.userLanguage || 'en-US';
- })();
- (function checkRangeRequests() {
- if (isSafari || isAndroidPre3 || isChromeWithRangeBug || isIOS) {
- PDFJS.disableRange = true;
- PDFJS.disableStream = true;
- }
- })();
- (function checkHistoryManipulation() {
- if (!hasDOM) {
- return;
- }
- if (!history.pushState || isAndroidPre3) {
- PDFJS.disableHistory = true;
- }
- })();
- (function checkSetPresenceInImageData() {
- if (!hasDOM) {
- return;
- }
- if (window.CanvasPixelArray) {
- if (typeof window.CanvasPixelArray.prototype.set !== 'function') {
- window.CanvasPixelArray.prototype.set = function (arr) {
- for (var i = 0, ii = this.length; i < ii; i++) {
- this[i] = arr[i];
- }
- };
- }
- } else {
- var polyfill = false,
- versionMatch;
- if (isChrome) {
- versionMatch = userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
- polyfill = versionMatch && parseInt(versionMatch[2]) < 21;
- } else if (isAndroid) {
- polyfill = isAndroidPre5;
- } else if (isSafari) {
- versionMatch = userAgent.match(/Version\/([0-9]+)\.([0-9]+)\.([0-9]+) Safari\//);
- polyfill = versionMatch && parseInt(versionMatch[1]) < 6;
- }
- if (polyfill) {
- var contextPrototype = window.CanvasRenderingContext2D.prototype;
- var createImageData = contextPrototype.createImageData;
- contextPrototype.createImageData = function (w, h) {
- var imageData = createImageData.call(this, w, h);
- imageData.data.set = function (arr) {
- for (var i = 0, ii = this.length; i < ii; i++) {
- this[i] = arr[i];
- }
- };
- return imageData;
- };
- contextPrototype = null;
- }
- }
- })();
- (function checkRequestAnimationFrame() {
- function installFakeAnimationFrameFunctions() {
- window.requestAnimationFrame = function (callback) {
- return window.setTimeout(callback, 20);
- };
- window.cancelAnimationFrame = function (timeoutID) {
- window.clearTimeout(timeoutID);
- };
- }
- if (!hasDOM) {
- return;
- }
- if (isIOS) {
- installFakeAnimationFrameFunctions();
- return;
- }
- if ('requestAnimationFrame' in window) {
- return;
- }
- window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame;
- if (!('requestAnimationFrame' in window)) {
- installFakeAnimationFrameFunctions();
- }
- })();
- (function checkCanvasSizeLimitation() {
- if (isIOS || isAndroid) {
- PDFJS.maxCanvasPixels = 5242880;
- }
- })();
- (function checkFullscreenSupport() {
- if (!hasDOM) {
- return;
- }
- if (isIE && window.parent !== window) {
- PDFJS.disableFullscreen = true;
- }
- })();
- (function checkCurrentScript() {
- if (!hasDOM) {
- return;
- }
- if ('currentScript' in document) {
- return;
- }
- Object.defineProperty(document, 'currentScript', {
- get: function () {
- var scripts = document.getElementsByTagName('script');
- return scripts[scripts.length - 1];
- },
- enumerable: true,
- configurable: true
- });
- })();
- (function checkInputTypeNumberAssign() {
- if (!hasDOM) {
- return;
- }
- var el = document.createElement('input');
- try {
- el.type = 'number';
- } catch (ex) {
- var inputProto = el.constructor.prototype;
- var typeProperty = Object.getOwnPropertyDescriptor(inputProto, 'type');
- Object.defineProperty(inputProto, 'type', {
- get: function () {
- return typeProperty.get.call(this);
- },
- set: function (value) {
- typeProperty.set.call(this, value === 'number' ? 'text' : value);
- },
- enumerable: true,
- configurable: true
- });
- }
- })();
- (function checkDocumentReadyState() {
- if (!hasDOM) {
- return;
- }
- if (!document.attachEvent) {
- return;
- }
- var documentProto = document.constructor.prototype;
- var readyStateProto = Object.getOwnPropertyDescriptor(documentProto, 'readyState');
- Object.defineProperty(documentProto, 'readyState', {
- get: function () {
- var value = readyStateProto.get.call(this);
- return value === 'interactive' ? 'loading' : value;
- },
- set: function (value) {
- readyStateProto.set.call(this, value);
- },
- enumerable: true,
- configurable: true
- });
- })();
- (function checkChildNodeRemove() {
- if (!hasDOM) {
- return;
- }
- if (typeof Element.prototype.remove !== 'undefined') {
- return;
- }
- Element.prototype.remove = function () {
- if (this.parentNode) {
- this.parentNode.removeChild(this);
- }
- };
- })();
- (function checkPromise() {
- if (globalScope.Promise) {
- if (typeof globalScope.Promise.all !== 'function') {
- globalScope.Promise.all = function (iterable) {
- var count = 0,
- results = [],
- resolve,
- reject;
- var promise = new globalScope.Promise(function (resolve_, reject_) {
- resolve = resolve_;
- reject = reject_;
- });
- iterable.forEach(function (p, i) {
- count++;
- p.then(function (result) {
- results[i] = result;
- count--;
- if (count === 0) {
- resolve(results);
- }
- }, reject);
- });
- if (count === 0) {
- resolve(results);
- }
- return promise;
- };
- }
- if (typeof globalScope.Promise.resolve !== 'function') {
- globalScope.Promise.resolve = function (value) {
- return new globalScope.Promise(function (resolve) {
- resolve(value);
- });
- };
- }
- if (typeof globalScope.Promise.reject !== 'function') {
- globalScope.Promise.reject = function (reason) {
- return new globalScope.Promise(function (resolve, reject) {
- reject(reason);
- });
- };
- }
- if (typeof globalScope.Promise.prototype.catch !== 'function') {
- globalScope.Promise.prototype.catch = function (onReject) {
- return globalScope.Promise.prototype.then(undefined, onReject);
- };
- }
- return;
- }
- var STATUS_PENDING = 0;
- var STATUS_RESOLVED = 1;
- var STATUS_REJECTED = 2;
- var REJECTION_TIMEOUT = 500;
- var HandlerManager = {
- handlers: [],
- running: false,
- unhandledRejections: [],
- pendingRejectionCheck: false,
- scheduleHandlers: function scheduleHandlers(promise) {
- if (promise._status === STATUS_PENDING) {
- return;
- }
- this.handlers = this.handlers.concat(promise._handlers);
- promise._handlers = [];
- if (this.running) {
- return;
- }
- this.running = true;
- setTimeout(this.runHandlers.bind(this), 0);
- },
- runHandlers: function runHandlers() {
- var RUN_TIMEOUT = 1;
- var timeoutAt = Date.now() + RUN_TIMEOUT;
- while (this.handlers.length > 0) {
- var handler = this.handlers.shift();
- var nextStatus = handler.thisPromise._status;
- var nextValue = handler.thisPromise._value;
- try {
- if (nextStatus === STATUS_RESOLVED) {
- if (typeof handler.onResolve === 'function') {
- nextValue = handler.onResolve(nextValue);
- }
- } else if (typeof handler.onReject === 'function') {
- nextValue = handler.onReject(nextValue);
- nextStatus = STATUS_RESOLVED;
- if (handler.thisPromise._unhandledRejection) {
- this.removeUnhandeledRejection(handler.thisPromise);
- }
- }
- } catch (ex) {
- nextStatus = STATUS_REJECTED;
- nextValue = ex;
- }
- handler.nextPromise._updateStatus(nextStatus, nextValue);
- if (Date.now() >= timeoutAt) {
- break;
- }
- }
- if (this.handlers.length > 0) {
- setTimeout(this.runHandlers.bind(this), 0);
- return;
- }
- this.running = false;
- },
- addUnhandledRejection: function addUnhandledRejection(promise) {
- this.unhandledRejections.push({
- promise: promise,
- time: Date.now()
- });
- this.scheduleRejectionCheck();
- },
- removeUnhandeledRejection: function removeUnhandeledRejection(promise) {
- promise._unhandledRejection = false;
- for (var i = 0; i < this.unhandledRejections.length; i++) {
- if (this.unhandledRejections[i].promise === promise) {
- this.unhandledRejections.splice(i);
- i--;
- }
- }
- },
- scheduleRejectionCheck: function scheduleRejectionCheck() {
- if (this.pendingRejectionCheck) {
- return;
- }
- this.pendingRejectionCheck = true;
- setTimeout(function rejectionCheck() {
- this.pendingRejectionCheck = false;
- var now = Date.now();
- for (var i = 0; i < this.unhandledRejections.length; i++) {
- if (now - this.unhandledRejections[i].time > REJECTION_TIMEOUT) {
- var unhandled = this.unhandledRejections[i].promise._value;
- var msg = 'Unhandled rejection: ' + unhandled;
- if (unhandled.stack) {
- msg += '\n' + unhandled.stack;
- }
- try {
- throw new Error(msg);
- } catch (_) {
- console.warn(msg);
- }
- this.unhandledRejections.splice(i);
- i--;
- }
- }
- if (this.unhandledRejections.length) {
- this.scheduleRejectionCheck();
- }
- }.bind(this), REJECTION_TIMEOUT);
- }
- };
- var Promise = function Promise(resolver) {
- this._status = STATUS_PENDING;
- this._handlers = [];
- try {
- resolver.call(this, this._resolve.bind(this), this._reject.bind(this));
- } catch (e) {
- this._reject(e);
- }
- };
- Promise.all = function Promise_all(promises) {
- var resolveAll, rejectAll;
- var deferred = new Promise(function (resolve, reject) {
- resolveAll = resolve;
- rejectAll = reject;
- });
- var unresolved = promises.length;
- var results = [];
- if (unresolved === 0) {
- resolveAll(results);
- return deferred;
- }
- function reject(reason) {
- if (deferred._status === STATUS_REJECTED) {
- return;
- }
- results = [];
- rejectAll(reason);
- }
- for (var i = 0, ii = promises.length; i < ii; ++i) {
- var promise = promises[i];
- var resolve = function (i) {
- return function (value) {
- if (deferred._status === STATUS_REJECTED) {
- return;
- }
- results[i] = value;
- unresolved--;
- if (unresolved === 0) {
- resolveAll(results);
- }
- };
- }(i);
- if (Promise.isPromise(promise)) {
- promise.then(resolve, reject);
- } else {
- resolve(promise);
- }
- }
- return deferred;
- };
- Promise.isPromise = function Promise_isPromise(value) {
- return value && typeof value.then === 'function';
- };
- Promise.resolve = function Promise_resolve(value) {
- return new Promise(function (resolve) {
- resolve(value);
- });
- };
- Promise.reject = function Promise_reject(reason) {
- return new Promise(function (resolve, reject) {
- reject(reason);
- });
- };
- Promise.prototype = {
- _status: null,
- _value: null,
- _handlers: null,
- _unhandledRejection: null,
- _updateStatus: function Promise__updateStatus(status, value) {
- if (this._status === STATUS_RESOLVED || this._status === STATUS_REJECTED) {
- return;
- }
- if (status === STATUS_RESOLVED && Promise.isPromise(value)) {
- value.then(this._updateStatus.bind(this, STATUS_RESOLVED), this._updateStatus.bind(this, STATUS_REJECTED));
- return;
- }
- this._status = status;
- this._value = value;
- if (status === STATUS_REJECTED && this._handlers.length === 0) {
- this._unhandledRejection = true;
- HandlerManager.addUnhandledRejection(this);
- }
- HandlerManager.scheduleHandlers(this);
- },
- _resolve: function Promise_resolve(value) {
- this._updateStatus(STATUS_RESOLVED, value);
- },
- _reject: function Promise_reject(reason) {
- this._updateStatus(STATUS_REJECTED, reason);
- },
- then: function Promise_then(onResolve, onReject) {
- var nextPromise = new Promise(function (resolve, reject) {
- this.resolve = resolve;
- this.reject = reject;
- });
- this._handlers.push({
- thisPromise: this,
- onResolve: onResolve,
- onReject: onReject,
- nextPromise: nextPromise
- });
- HandlerManager.scheduleHandlers(this);
- return nextPromise;
- },
- catch: function Promise_catch(onReject) {
- return this.then(undefined, onReject);
- }
- };
- globalScope.Promise = Promise;
- })();
- (function checkWeakMap() {
- if (globalScope.WeakMap) {
- return;
- }
- var id = 0;
- function WeakMap() {
- this.id = '$weakmap' + id++;
- }
- WeakMap.prototype = {
- has: function (obj) {
- return !!Object.getOwnPropertyDescriptor(obj, this.id);
- },
- get: function (obj, defaultValue) {
- return this.has(obj) ? obj[this.id] : defaultValue;
- },
- set: function (obj, value) {
- Object.defineProperty(obj, this.id, {
- value: value,
- enumerable: false,
- configurable: true
- });
- },
- delete: function (obj) {
- delete obj[this.id];
- }
- };
- globalScope.WeakMap = WeakMap;
- })();
- (function checkURLConstructor() {
- var hasWorkingUrl = false;
- try {
- if (typeof URL === 'function' && typeof URL.prototype === 'object' && 'origin' in URL.prototype) {
- var u = new URL('b', 'http://a');
- u.pathname = 'c%20d';
- hasWorkingUrl = u.href === 'http://a/c%20d';
- }
- } catch (e) {}
- if (hasWorkingUrl) {
- return;
- }
- var relative = Object.create(null);
- relative['ftp'] = 21;
- relative['file'] = 0;
- relative['gopher'] = 70;
- relative['http'] = 80;
- relative['https'] = 443;
- relative['ws'] = 80;
- relative['wss'] = 443;
- var relativePathDotMapping = Object.create(null);
- relativePathDotMapping['%2e'] = '.';
- relativePathDotMapping['.%2e'] = '..';
- relativePathDotMapping['%2e.'] = '..';
- relativePathDotMapping['%2e%2e'] = '..';
- function isRelativeScheme(scheme) {
- return relative[scheme] !== undefined;
- }
- function invalid() {
- clear.call(this);
- this._isInvalid = true;
- }
- function IDNAToASCII(h) {
- if (h === '') {
- invalid.call(this);
- }
- return h.toLowerCase();
- }
- function percentEscape(c) {
- var unicode = c.charCodeAt(0);
- if (unicode > 0x20 && unicode < 0x7F && [0x22, 0x23, 0x3C, 0x3E, 0x3F, 0x60].indexOf(unicode) === -1) {
- return c;
- }
- return encodeURIComponent(c);
- }
- function percentEscapeQuery(c) {
- var unicode = c.charCodeAt(0);
- if (unicode > 0x20 && unicode < 0x7F && [0x22, 0x23, 0x3C, 0x3E, 0x60].indexOf(unicode) === -1) {
- return c;
- }
- return encodeURIComponent(c);
- }
- var EOF,
- ALPHA = /[a-zA-Z]/,
- ALPHANUMERIC = /[a-zA-Z0-9\+\-\.]/;
- function parse(input, stateOverride, base) {
- function err(message) {
- errors.push(message);
- }
- var state = stateOverride || 'scheme start',
- cursor = 0,
- buffer = '',
- seenAt = false,
- seenBracket = false,
- errors = [];
- loop: while ((input[cursor - 1] !== EOF || cursor === 0) && !this._isInvalid) {
- var c = input[cursor];
- switch (state) {
- case 'scheme start':
- if (c && ALPHA.test(c)) {
- buffer += c.toLowerCase();
- state = 'scheme';
- } else if (!stateOverride) {
- buffer = '';
- state = 'no scheme';
- continue;
- } else {
- err('Invalid scheme.');
- break loop;
- }
- break;
- case 'scheme':
- if (c && ALPHANUMERIC.test(c)) {
- buffer += c.toLowerCase();
- } else if (c === ':') {
- this._scheme = buffer;
- buffer = '';
- if (stateOverride) {
- break loop;
- }
- if (isRelativeScheme(this._scheme)) {
- this._isRelative = true;
- }
- if (this._scheme === 'file') {
- state = 'relative';
- } else if (this._isRelative && base && base._scheme === this._scheme) {
- state = 'relative or authority';
- } else if (this._isRelative) {
- state = 'authority first slash';
- } else {
- state = 'scheme data';
- }
- } else if (!stateOverride) {
- buffer = '';
- cursor = 0;
- state = 'no scheme';
- continue;
- } else if (c === EOF) {
- break loop;
- } else {
- err('Code point not allowed in scheme: ' + c);
- break loop;
- }
- break;
- case 'scheme data':
- if (c === '?') {
- this._query = '?';
- state = 'query';
- } else if (c === '#') {
- this._fragment = '#';
- state = 'fragment';
- } else {
- if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
- this._schemeData += percentEscape(c);
- }
- }
- break;
- case 'no scheme':
- if (!base || !isRelativeScheme(base._scheme)) {
- err('Missing scheme.');
- invalid.call(this);
- } else {
- state = 'relative';
- continue;
- }
- break;
- case 'relative or authority':
- if (c === '/' && input[cursor + 1] === '/') {
- state = 'authority ignore slashes';
- } else {
- err('Expected /, got: ' + c);
- state = 'relative';
- continue;
- }
- break;
- case 'relative':
- this._isRelative = true;
- if (this._scheme !== 'file') {
- this._scheme = base._scheme;
- }
- if (c === EOF) {
- this._host = base._host;
- this._port = base._port;
- this._path = base._path.slice();
- this._query = base._query;
- this._username = base._username;
- this._password = base._password;
- break loop;
- } else if (c === '/' || c === '\\') {
- if (c === '\\') {
- err('\\ is an invalid code point.');
- }
- state = 'relative slash';
- } else if (c === '?') {
- this._host = base._host;
- this._port = base._port;
- this._path = base._path.slice();
- this._query = '?';
- this._username = base._username;
- this._password = base._password;
- state = 'query';
- } else if (c === '#') {
- this._host = base._host;
- this._port = base._port;
- this._path = base._path.slice();
- this._query = base._query;
- this._fragment = '#';
- this._username = base._username;
- this._password = base._password;
- state = 'fragment';
- } else {
- var nextC = input[cursor + 1];
- var nextNextC = input[cursor + 2];
- if (this._scheme !== 'file' || !ALPHA.test(c) || nextC !== ':' && nextC !== '|' || nextNextC !== EOF && nextNextC !== '/' && nextNextC !== '\\' && nextNextC !== '?' && nextNextC !== '#') {
- this._host = base._host;
- this._port = base._port;
- this._username = base._username;
- this._password = base._password;
- this._path = base._path.slice();
- this._path.pop();
- }
- state = 'relative path';
- continue;
- }
- break;
- case 'relative slash':
- if (c === '/' || c === '\\') {
- if (c === '\\') {
- err('\\ is an invalid code point.');
- }
- if (this._scheme === 'file') {
- state = 'file host';
- } else {
- state = 'authority ignore slashes';
- }
- } else {
- if (this._scheme !== 'file') {
- this._host = base._host;
- this._port = base._port;
- this._username = base._username;
- this._password = base._password;
- }
- state = 'relative path';
- continue;
- }
- break;
- case 'authority first slash':
- if (c === '/') {
- state = 'authority second slash';
- } else {
- err('Expected \'/\', got: ' + c);
- state = 'authority ignore slashes';
- continue;
- }
- break;
- case 'authority second slash':
- state = 'authority ignore slashes';
- if (c !== '/') {
- err('Expected \'/\', got: ' + c);
- continue;
- }
- break;
- case 'authority ignore slashes':
- if (c !== '/' && c !== '\\') {
- state = 'authority';
- continue;
- } else {
- err('Expected authority, got: ' + c);
- }
- break;
- case 'authority':
- if (c === '@') {
- if (seenAt) {
- err('@ already seen.');
- buffer += '%40';
- }
- seenAt = true;
- for (var i = 0; i < buffer.length; i++) {
- var cp = buffer[i];
- if (cp === '\t' || cp === '\n' || cp === '\r') {
- err('Invalid whitespace in authority.');
- continue;
- }
- if (cp === ':' && this._password === null) {
- this._password = '';
- continue;
- }
- var tempC = percentEscape(cp);
- if (this._password !== null) {
- this._password += tempC;
- } else {
- this._username += tempC;
- }
- }
- buffer = '';
- } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
- cursor -= buffer.length;
- buffer = '';
- state = 'host';
- continue;
- } else {
- buffer += c;
- }
- break;
- case 'file host':
- if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
- if (buffer.length === 2 && ALPHA.test(buffer[0]) && (buffer[1] === ':' || buffer[1] === '|')) {
- state = 'relative path';
- } else if (buffer.length === 0) {
- state = 'relative path start';
- } else {
- this._host = IDNAToASCII.call(this, buffer);
- buffer = '';
- state = 'relative path start';
- }
- continue;
- } else if (c === '\t' || c === '\n' || c === '\r') {
- err('Invalid whitespace in file host.');
- } else {
- buffer += c;
- }
- break;
- case 'host':
- case 'hostname':
- if (c === ':' && !seenBracket) {
- this._host = IDNAToASCII.call(this, buffer);
- buffer = '';
- state = 'port';
- if (stateOverride === 'hostname') {
- break loop;
- }
- } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#') {
- this._host = IDNAToASCII.call(this, buffer);
- buffer = '';
- state = 'relative path start';
- if (stateOverride) {
- break loop;
- }
- continue;
- } else if (c !== '\t' && c !== '\n' && c !== '\r') {
- if (c === '[') {
- seenBracket = true;
- } else if (c === ']') {
- seenBracket = false;
- }
- buffer += c;
- } else {
- err('Invalid code point in host/hostname: ' + c);
- }
- break;
- case 'port':
- if (/[0-9]/.test(c)) {
- buffer += c;
- } else if (c === EOF || c === '/' || c === '\\' || c === '?' || c === '#' || stateOverride) {
- if (buffer !== '') {
- var temp = parseInt(buffer, 10);
- if (temp !== relative[this._scheme]) {
- this._port = temp + '';
- }
- buffer = '';
- }
- if (stateOverride) {
- break loop;
- }
- state = 'relative path start';
- continue;
- } else if (c === '\t' || c === '\n' || c === '\r') {
- err('Invalid code point in port: ' + c);
- } else {
- invalid.call(this);
- }
- break;
- case 'relative path start':
- if (c === '\\') {
- err('\'\\\' not allowed in path.');
- }
- state = 'relative path';
- if (c !== '/' && c !== '\\') {
- continue;
- }
- break;
- case 'relative path':
- if (c === EOF || c === '/' || c === '\\' || !stateOverride && (c === '?' || c === '#')) {
- if (c === '\\') {
- err('\\ not allowed in relative path.');
- }
- var tmp;
- if (tmp = relativePathDotMapping[buffer.toLowerCase()]) {
- buffer = tmp;
- }
- if (buffer === '..') {
- this._path.pop();
- if (c !== '/' && c !== '\\') {
- this._path.push('');
- }
- } else if (buffer === '.' && c !== '/' && c !== '\\') {
- this._path.push('');
- } else if (buffer !== '.') {
- if (this._scheme === 'file' && this._path.length === 0 && buffer.length === 2 && ALPHA.test(buffer[0]) && buffer[1] === '|') {
- buffer = buffer[0] + ':';
- }
- this._path.push(buffer);
- }
- buffer = '';
- if (c === '?') {
- this._query = '?';
- state = 'query';
- } else if (c === '#') {
- this._fragment = '#';
- state = 'fragment';
- }
- } else if (c !== '\t' && c !== '\n' && c !== '\r') {
- buffer += percentEscape(c);
- }
- break;
- case 'query':
- if (!stateOverride && c === '#') {
- this._fragment = '#';
- state = 'fragment';
- } else if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
- this._query += percentEscapeQuery(c);
- }
- break;
- case 'fragment':
- if (c !== EOF && c !== '\t' && c !== '\n' && c !== '\r') {
- this._fragment += c;
- }
- break;
- }
- cursor++;
- }
- }
- function clear() {
- this._scheme = '';
- this._schemeData = '';
- this._username = '';
- this._password = null;
- this._host = '';
- this._port = '';
- this._path = [];
- this._query = '';
- this._fragment = '';
- this._isInvalid = false;
- this._isRelative = false;
- }
- function JURL(url, base) {
- if (base !== undefined && !(base instanceof JURL)) {
- base = new JURL(String(base));
- }
- this._url = url;
- clear.call(this);
- var input = url.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '');
- parse.call(this, input, null, base);
- }
- JURL.prototype = {
- toString: function () {
- return this.href;
- },
- get href() {
- if (this._isInvalid) {
- return this._url;
- }
- var authority = '';
- if (this._username !== '' || this._password !== null) {
- authority = this._username + (this._password !== null ? ':' + this._password : '') + '@';
- }
- return this.protocol + (this._isRelative ? '//' + authority + this.host : '') + this.pathname + this._query + this._fragment;
- },
- set href(href) {
- clear.call(this);
- parse.call(this, href);
- },
- get protocol() {
- return this._scheme + ':';
- },
- set protocol(protocol) {
- if (this._isInvalid) {
- return;
- }
- parse.call(this, protocol + ':', 'scheme start');
- },
- get host() {
- return this._isInvalid ? '' : this._port ? this._host + ':' + this._port : this._host;
- },
- set host(host) {
- if (this._isInvalid || !this._isRelative) {
- return;
- }
- parse.call(this, host, 'host');
- },
- get hostname() {
- return this._host;
- },
- set hostname(hostname) {
- if (this._isInvalid || !this._isRelative) {
- return;
- }
- parse.call(this, hostname, 'hostname');
- },
- get port() {
- return this._port;
- },
- set port(port) {
- if (this._isInvalid || !this._isRelative) {
- return;
- }
- parse.call(this, port, 'port');
- },
- get pathname() {
- return this._isInvalid ? '' : this._isRelative ? '/' + this._path.join('/') : this._schemeData;
- },
- set pathname(pathname) {
- if (this._isInvalid || !this._isRelative) {
- return;
- }
- this._path = [];
- parse.call(this, pathname, 'relative path start');
- },
- get search() {
- return this._isInvalid || !this._query || this._query === '?' ? '' : this._query;
- },
- set search(search) {
- if (this._isInvalid || !this._isRelative) {
- return;
- }
- this._query = '?';
- if (search[0] === '?') {
- search = search.slice(1);
- }
- parse.call(this, search, 'query');
- },
- get hash() {
- return this._isInvalid || !this._fragment || this._fragment === '#' ? '' : this._fragment;
- },
- set hash(hash) {
- if (this._isInvalid) {
- return;
- }
- this._fragment = '#';
- if (hash[0] === '#') {
- hash = hash.slice(1);
- }
- parse.call(this, hash, 'fragment');
- },
- get origin() {
- var host;
- if (this._isInvalid || !this._scheme) {
- return '';
- }
- switch (this._scheme) {
- case 'data':
- case 'file':
- case 'javascript':
- case 'mailto':
- return 'null';
- }
- host = this.host;
- if (!host) {
- return '';
- }
- return this._scheme + '://' + host;
- }
- };
- var OriginalURL = globalScope.URL;
- if (OriginalURL) {
- JURL.createObjectURL = function (blob) {
- return OriginalURL.createObjectURL.apply(OriginalURL, arguments);
- };
- JURL.revokeObjectURL = function (url) {
- OriginalURL.revokeObjectURL(url);
- };
- }
- globalScope.URL = JURL;
- })();
-}
-/* WEBPACK VAR INJECTION */}.call(exports, __w_pdfjs_require__(6)))
-
-/***/ })
-/******/ ]);
-});
-/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(0)))
-
-/***/ }),
-/* 3 */
-/***/ (function(module, exports, __webpack_require__) {
-
-/* WEBPACK VAR INJECTION */(function(Buffer) {/*
- MIT License http://www.opensource.org/licenses/mit-license.php
- Author Tobias Koppers @sokra
-*/
-// css base code, injected by the css-loader
-module.exports = function(useSourceMap) {
- var list = [];
-
- // return the list of modules as css string
- list.toString = function toString() {
- return this.map(function (item) {
- var content = cssWithMappingToString(item, useSourceMap);
- if(item[2]) {
- return "@media " + item[2] + "{" + content + "}";
- } else {
- return content;
- }
- }).join("");
- };
-
- // import a list of modules into the list
- list.i = function(modules, mediaQuery) {
- if(typeof modules === "string")
- modules = [[null, modules, ""]];
- var alreadyImportedModules = {};
- for(var i = 0; i < this.length; i++) {
- var id = this[i][0];
- if(typeof id === "number")
- alreadyImportedModules[id] = true;
- }
- for(i = 0; i < modules.length; i++) {
- var item = modules[i];
- // skip already imported module
- // this implementation is not 100% perfect for weird media query combinations
- // when a module is imported multiple times with different media queries.
- // I hope this will never occur (Hey this way we have smaller bundles)
- if(typeof item[0] !== "number" || !alreadyImportedModules[item[0]]) {
- if(mediaQuery && !item[2]) {
- item[2] = mediaQuery;
- } else if(mediaQuery) {
- item[2] = "(" + item[2] + ") and (" + mediaQuery + ")";
- }
- list.push(item);
- }
- }
- };
- return list;
-};
-
-function cssWithMappingToString(item, useSourceMap) {
- var content = item[1] || '';
- var cssMapping = item[3];
- if (!cssMapping) {
- return content;
- }
-
- if (useSourceMap) {
- var sourceMapping = toComment(cssMapping);
- var sourceURLs = cssMapping.sources.map(function (source) {
- return '/*# sourceURL=' + cssMapping.sourceRoot + source + ' */'
- });
-
- return [content].concat(sourceURLs).concat([sourceMapping]).join('\n');
- }
-
- return [content].join('\n');
-}
-
-// Adapted from convert-source-map (MIT)
-function toComment(sourceMap) {
- var base64 = new Buffer(JSON.stringify(sourceMap)).toString('base64');
- var data = 'sourceMappingURL=data:application/json;charset=utf-8;base64,' + base64;
-
- return '/*# ' + data + ' */';
-}
-
-/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(10).Buffer))
-
-/***/ }),
-/* 4 */
-/***/ (function(module, exports) {
-
-// this module is a runtime utility for cleaner component module output and will
-// be included in the final webpack user bundle
-
-module.exports = function normalizeComponent (
- rawScriptExports,
- compiledTemplate,
- scopeId,
- cssModules
-) {
- var esModule
- var scriptExports = rawScriptExports = rawScriptExports || {}
-
- // ES6 modules interop
- var type = typeof rawScriptExports.default
- if (type === 'object' || type === 'function') {
- esModule = rawScriptExports
- scriptExports = rawScriptExports.default
- }
-
- // Vue.extend constructor export interop
- var options = typeof scriptExports === 'function'
- ? scriptExports.options
- : scriptExports
-
- // render functions
- if (compiledTemplate) {
- options.render = compiledTemplate.render
- options.staticRenderFns = compiledTemplate.staticRenderFns
- }
-
- // scopedId
- if (scopeId) {
- options._scopeId = scopeId
- }
-
- // inject cssModules
- if (cssModules) {
- var computed = Object.create(options.computed || null)
- Object.keys(cssModules).forEach(function (key) {
- var module = cssModules[key]
- computed[key] = function () { return module }
- })
- options.computed = computed
- }
-
- return {
- esModule: esModule,
- exports: scriptExports,
- options: options
- }
-}
-
-
-/***/ }),
-/* 5 */
-/***/ (function(module, exports, __webpack_require__) {
-
-/*
- MIT License http://www.opensource.org/licenses/mit-license.php
- Author Tobias Koppers @sokra
- Modified by Evan You @yyx990803
-*/
-
-var hasDocument = typeof document !== 'undefined'
-
-if (typeof DEBUG !== 'undefined' && DEBUG) {
- if (!hasDocument) {
- throw new Error(
- 'vue-style-loader cannot be used in a non-browser environment. ' +
- "Use { target: 'node' } in your Webpack config to indicate a server-rendering environment."
- ) }
-}
-
-var listToStyles = __webpack_require__(21)
-
-/*
-type StyleObject = {
- id: number;
- parts: Array<StyleObjectPart>
-}
-
-type StyleObjectPart = {
- css: string;
- media: string;
- sourceMap: ?string
-}
-*/
-
-var stylesInDom = {/*
- [id: number]: {
- id: number,
- refs: number,
- parts: Array<(obj?: StyleObjectPart) => void>
- }
-*/}
-
-var head = hasDocument && (document.head || document.getElementsByTagName('head')[0])
-var singletonElement = null
-var singletonCounter = 0
-var isProduction = false
-var noop = function () {}
-
-// Force single-tag solution on IE6-9, which has a hard limit on the # of <style>
-// tags it will allow on a page
-var isOldIE = typeof navigator !== 'undefined' && /msie [6-9]\b/.test(navigator.userAgent.toLowerCase())
-
-module.exports = function (parentId, list, _isProduction) {
- isProduction = _isProduction
-
- var styles = listToStyles(parentId, list)
- addStylesToDom(styles)
-
- return function update (newList) {
- var mayRemove = []
- for (var i = 0; i < styles.length; i++) {
- var item = styles[i]
- var domStyle = stylesInDom[item.id]
- domStyle.refs--
- mayRemove.push(domStyle)
- }
- if (newList) {
- styles = listToStyles(parentId, newList)
- addStylesToDom(styles)
- } else {
- styles = []
- }
- for (var i = 0; i < mayRemove.length; i++) {
- var domStyle = mayRemove[i]
- if (domStyle.refs === 0) {
- for (var j = 0; j < domStyle.parts.length; j++) {
- domStyle.parts[j]()
- }
- delete stylesInDom[domStyle.id]
- }
- }
- }
-}
-
-function addStylesToDom (styles /* Array<StyleObject> */) {
- for (var i = 0; i < styles.length; i++) {
- var item = styles[i]
- var domStyle = stylesInDom[item.id]
- if (domStyle) {
- domStyle.refs++
- for (var j = 0; j < domStyle.parts.length; j++) {
- domStyle.parts[j](item.parts[j])
- }
- for (; j < item.parts.length; j++) {
- domStyle.parts.push(addStyle(item.parts[j]))
- }
- if (domStyle.parts.length > item.parts.length) {
- domStyle.parts.length = item.parts.length
- }
- } else {
- var parts = []
- for (var j = 0; j < item.parts.length; j++) {
- parts.push(addStyle(item.parts[j]))
- }
- stylesInDom[item.id] = { id: item.id, refs: 1, parts: parts }
- }
- }
-}
-
-function createStyleElement () {
- var styleElement = document.createElement('style')
- styleElement.type = 'text/css'
- head.appendChild(styleElement)
- return styleElement
-}
-
-function addStyle (obj /* StyleObjectPart */) {
- var update, remove
- var styleElement = document.querySelector('style[data-vue-ssr-id~="' + obj.id + '"]')
-
- if (styleElement) {
- if (isProduction) {
- // has SSR styles and in production mode.
- // simply do nothing.
- return noop
- } else {
- // has SSR styles but in dev mode.
- // for some reason Chrome can't handle source map in server-rendered
- // style tags - source maps in <style> only works if the style tag is
- // created and inserted dynamically. So we remove the server rendered
- // styles and inject new ones.
- styleElement.parentNode.removeChild(styleElement)
- }
- }
-
- if (isOldIE) {
- // use singleton mode for IE9.
- var styleIndex = singletonCounter++
- styleElement = singletonElement || (singletonElement = createStyleElement())
- update = applyToSingletonTag.bind(null, styleElement, styleIndex, false)
- remove = applyToSingletonTag.bind(null, styleElement, styleIndex, true)
- } else {
- // use multi-style-tag mode in all other cases
- styleElement = createStyleElement()
- update = applyToTag.bind(null, styleElement)
- remove = function () {
- styleElement.parentNode.removeChild(styleElement)
- }
- }
-
- update(obj)
-
- return function updateStyle (newObj /* StyleObjectPart */) {
- if (newObj) {
- if (newObj.css === obj.css &&
- newObj.media === obj.media &&
- newObj.sourceMap === obj.sourceMap) {
- return
- }
- update(obj = newObj)
- } else {
- remove()
- }
- }
-}
-
-var replaceText = (function () {
- var textStore = []
-
- return function (index, replacement) {
- textStore[index] = replacement
- return textStore.filter(Boolean).join('\n')
- }
-})()
-
-function applyToSingletonTag (styleElement, index, remove, obj) {
- var css = remove ? '' : obj.css
-
- if (styleElement.styleSheet) {
- styleElement.styleSheet.cssText = replaceText(index, css)
- } else {
- var cssNode = document.createTextNode(css)
- var childNodes = styleElement.childNodes
- if (childNodes[index]) styleElement.removeChild(childNodes[index])
- if (childNodes.length) {
- styleElement.insertBefore(cssNode, childNodes[index])
- } else {
- styleElement.appendChild(cssNode)
- }
- }
-}
-
-function applyToTag (styleElement, obj) {
- var css = obj.css
- var media = obj.media
- var sourceMap = obj.sourceMap
-
- if (media) {
- styleElement.setAttribute('media', media)
- }
-
- if (sourceMap) {
- // https://developer.chrome.com/devtools/docs/javascript-debugging
- // this makes source maps inside style tags work properly in Chrome
- css += '\n/*# sourceURL=' + sourceMap.sources[0] + ' */'
- // http://stackoverflow.com/a/26603875
- css += '\n/*# sourceMappingURL=data:application/json;base64,' + btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))) + ' */'
- }
-
- if (styleElement.styleSheet) {
- styleElement.styleSheet.cssText = css
- } else {
- while (styleElement.firstChild) {
- styleElement.removeChild(styleElement.firstChild)
- }
- styleElement.appendChild(document.createTextNode(css))
- }
-}
-
-
-/***/ }),
-/* 6 */
-/***/ (function(module, exports, __webpack_require__) {
-
-
-/* styles */
-__webpack_require__(19)
-
-var Component = __webpack_require__(4)(
- /* script */
- __webpack_require__(7),
- /* template */
- __webpack_require__(17),
- /* scopeId */
- null,
- /* cssModules */
- null
-)
-
-module.exports = Component.exports
-
-
-/***/ }),
-/* 7 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-Object.defineProperty(exports, "__esModule", {
- value: true
-});
-
-var _pdfjsDist = __webpack_require__(2);
-
-var _pdfjsDist2 = _interopRequireDefault(_pdfjsDist);
-
-var _index = __webpack_require__(16);
-
-var _index2 = _interopRequireDefault(_index);
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
-
-//
-//
-//
-//
-//
-//
-//
-//
-//
-//
-
-exports.default = {
- props: {
- pdf: {
- type: [String, Uint8Array],
- required: true
- }
- },
- data: function data() {
- return {
- loading: false,
- pages: []
- };
- },
-
- components: { page: _index2.default },
- watch: { pdf: 'load' },
- computed: {
- document: function document() {
- return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf };
- },
- hasPDF: function hasPDF() {
- return this.pdf && this.pdf.length > 0;
- }
- },
- methods: {
- load: function load() {
- var _this = this;
-
- this.pages = [];
- return _pdfjsDist2.default.getDocument(this.document).then(this.renderPages).then(function () {
- return _this.$emit('pdflabload');
- }).catch(function (error) {
- return _this.$emit('pdflaberror', error);
- }).then(function () {
- _this.loading = false;
- });
- },
- renderPages: function renderPages(pdf) {
- var _this2 = this;
-
- var pagePromises = [];
- this.loading = true;
- for (var num = 1; num <= pdf.numPages; num += 1) {
- pagePromises.push(pdf.getPage(num).then(function (p) {
- return _this2.pages.push(p);
- }));
- }
- return Promise.all(pagePromises);
- }
- },
- mounted: function mounted() {
- if (this.hasPDF) this.load();
- }
-};
-
-/***/ }),
-/* 8 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-Object.defineProperty(exports, "__esModule", {
- value: true
-});
-//
-//
-//
-//
-//
-//
-//
-
-exports.default = {
- props: {
- page: {
- type: Object,
- required: true
- },
- number: {
- type: Number,
- required: true
- }
- },
- data: function data() {
- return {
- scale: 4,
- rendering: false
- };
- },
-
- computed: {
- viewport: function viewport() {
- return this.page.getViewport(this.scale);
- },
- context: function context() {
- return this.$refs.canvas.getContext('2d');
- },
- renderContext: function renderContext() {
- return {
- canvasContext: this.context,
- viewport: this.viewport
- };
- }
- },
- mounted: function mounted() {
- var _this = this;
-
- this.$refs.canvas.height = this.viewport.height;
- this.$refs.canvas.width = this.viewport.width;
- this.rendering = true;
- this.page.render(this.renderContext).then(function () {
- _this.rendering = false;
- });
- }
-};
-
-/***/ }),
-/* 9 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-exports.byteLength = byteLength
-exports.toByteArray = toByteArray
-exports.fromByteArray = fromByteArray
-
-var lookup = []
-var revLookup = []
-var Arr = typeof Uint8Array !== 'undefined' ? Uint8Array : Array
-
-var code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
-for (var i = 0, len = code.length; i < len; ++i) {
- lookup[i] = code[i]
- revLookup[code.charCodeAt(i)] = i
-}
-
-revLookup['-'.charCodeAt(0)] = 62
-revLookup['_'.charCodeAt(0)] = 63
-
-function placeHoldersCount (b64) {
- var len = b64.length
- if (len % 4 > 0) {
- throw new Error('Invalid string. Length must be a multiple of 4')
- }
-
- // the number of equal signs (place holders)
- // if there are two placeholders, than the two characters before it
- // represent one byte
- // if there is only one, then the three characters before it represent 2 bytes
- // this is just a cheap hack to not do indexOf twice
- return b64[len - 2] === '=' ? 2 : b64[len - 1] === '=' ? 1 : 0
-}
-
-function byteLength (b64) {
- // base64 is 4/3 + up to two characters of the original data
- return b64.length * 3 / 4 - placeHoldersCount(b64)
-}
-
-function toByteArray (b64) {
- var i, j, l, tmp, placeHolders, arr
- var len = b64.length
- placeHolders = placeHoldersCount(b64)
-
- arr = new Arr(len * 3 / 4 - placeHolders)
-
- // if there are placeholders, only get up to the last complete 4 chars
- l = placeHolders > 0 ? len - 4 : len
-
- var L = 0
-
- for (i = 0, j = 0; i < l; i += 4, j += 3) {
- tmp = (revLookup[b64.charCodeAt(i)] << 18) | (revLookup[b64.charCodeAt(i + 1)] << 12) | (revLookup[b64.charCodeAt(i + 2)] << 6) | revLookup[b64.charCodeAt(i + 3)]
- arr[L++] = (tmp >> 16) & 0xFF
- arr[L++] = (tmp >> 8) & 0xFF
- arr[L++] = tmp & 0xFF
- }
-
- if (placeHolders === 2) {
- tmp = (revLookup[b64.charCodeAt(i)] << 2) | (revLookup[b64.charCodeAt(i + 1)] >> 4)
- arr[L++] = tmp & 0xFF
- } else if (placeHolders === 1) {
- tmp = (revLookup[b64.charCodeAt(i)] << 10) | (revLookup[b64.charCodeAt(i + 1)] << 4) | (revLookup[b64.charCodeAt(i + 2)] >> 2)
- arr[L++] = (tmp >> 8) & 0xFF
- arr[L++] = tmp & 0xFF
- }
-
- return arr
-}
-
-function tripletToBase64 (num) {
- return lookup[num >> 18 & 0x3F] + lookup[num >> 12 & 0x3F] + lookup[num >> 6 & 0x3F] + lookup[num & 0x3F]
-}
-
-function encodeChunk (uint8, start, end) {
- var tmp
- var output = []
- for (var i = start; i < end; i += 3) {
- tmp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2])
- output.push(tripletToBase64(tmp))
- }
- return output.join('')
-}
-
-function fromByteArray (uint8) {
- var tmp
- var len = uint8.length
- var extraBytes = len % 3 // if we have 1 byte left, pad 2 bytes
- var output = ''
- var parts = []
- var maxChunkLength = 16383 // must be multiple of 3
-
- // go through the array every three bytes, we'll deal with trailing stuff later
- for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) {
- parts.push(encodeChunk(uint8, i, (i + maxChunkLength) > len2 ? len2 : (i + maxChunkLength)))
- }
-
- // pad the end with zeros, but make sure to not forget the extra bytes
- if (extraBytes === 1) {
- tmp = uint8[len - 1]
- output += lookup[tmp >> 2]
- output += lookup[(tmp << 4) & 0x3F]
- output += '=='
- } else if (extraBytes === 2) {
- tmp = (uint8[len - 2] << 8) + (uint8[len - 1])
- output += lookup[tmp >> 10]
- output += lookup[(tmp >> 4) & 0x3F]
- output += lookup[(tmp << 2) & 0x3F]
- output += '='
- }
-
- parts.push(output)
-
- return parts.join('')
-}
-
-
-/***/ }),
-/* 10 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-/* WEBPACK VAR INJECTION */(function(global) {/*!
- * The buffer module from node.js, for the browser.
- *
- * @author Feross Aboukhadijeh <feross@feross.org> <http://feross.org>
- * @license MIT
- */
-/* eslint-disable no-proto */
-
-
-
-var base64 = __webpack_require__(9)
-var ieee754 = __webpack_require__(13)
-var isArray = __webpack_require__(14)
-
-exports.Buffer = Buffer
-exports.SlowBuffer = SlowBuffer
-exports.INSPECT_MAX_BYTES = 50
-
-/**
- * If `Buffer.TYPED_ARRAY_SUPPORT`:
- * === true Use Uint8Array implementation (fastest)
- * === false Use Object implementation (most compatible, even IE6)
- *
- * Browsers that support typed arrays are IE 10+, Firefox 4+, Chrome 7+, Safari 5.1+,
- * Opera 11.6+, iOS 4.2+.
- *
- * Due to various browser bugs, sometimes the Object implementation will be used even
- * when the browser supports typed arrays.
- *
- * Note:
- *
- * - Firefox 4-29 lacks support for adding new properties to `Uint8Array` instances,
- * See: https://bugzilla.mozilla.org/show_bug.cgi?id=695438.
- *
- * - Chrome 9-10 is missing the `TypedArray.prototype.subarray` function.
- *
- * - IE10 has a broken `TypedArray.prototype.subarray` function which returns arrays of
- * incorrect length in some situations.
-
- * We detect these buggy browsers and set `Buffer.TYPED_ARRAY_SUPPORT` to `false` so they
- * get the Object implementation, which is slower but behaves correctly.
- */
-Buffer.TYPED_ARRAY_SUPPORT = global.TYPED_ARRAY_SUPPORT !== undefined
- ? global.TYPED_ARRAY_SUPPORT
- : typedArraySupport()
-
-/*
- * Export kMaxLength after typed array support is determined.
- */
-exports.kMaxLength = kMaxLength()
-
-function typedArraySupport () {
- try {
- var arr = new Uint8Array(1)
- arr.__proto__ = {__proto__: Uint8Array.prototype, foo: function () { return 42 }}
- return arr.foo() === 42 && // typed array instances can be augmented
- typeof arr.subarray === 'function' && // chrome 9-10 lack `subarray`
- arr.subarray(1, 1).byteLength === 0 // ie10 has broken `subarray`
- } catch (e) {
- return false
- }
-}
-
-function kMaxLength () {
- return Buffer.TYPED_ARRAY_SUPPORT
- ? 0x7fffffff
- : 0x3fffffff
-}
-
-function createBuffer (that, length) {
- if (kMaxLength() < length) {
- throw new RangeError('Invalid typed array length')
- }
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- // Return an augmented `Uint8Array` instance, for best performance
- that = new Uint8Array(length)
- that.__proto__ = Buffer.prototype
- } else {
- // Fallback: Return an object instance of the Buffer class
- if (that === null) {
- that = new Buffer(length)
- }
- that.length = length
- }
-
- return that
-}
-
-/**
- * The Buffer constructor returns instances of `Uint8Array` that have their
- * prototype changed to `Buffer.prototype`. Furthermore, `Buffer` is a subclass of
- * `Uint8Array`, so the returned instances will have all the node `Buffer` methods
- * and the `Uint8Array` methods. Square bracket notation works as expected -- it
- * returns a single octet.
- *
- * The `Uint8Array` prototype remains unmodified.
- */
-
-function Buffer (arg, encodingOrOffset, length) {
- if (!Buffer.TYPED_ARRAY_SUPPORT && !(this instanceof Buffer)) {
- return new Buffer(arg, encodingOrOffset, length)
- }
-
- // Common case.
- if (typeof arg === 'number') {
- if (typeof encodingOrOffset === 'string') {
- throw new Error(
- 'If encoding is specified then the first argument must be a string'
- )
- }
- return allocUnsafe(this, arg)
- }
- return from(this, arg, encodingOrOffset, length)
-}
-
-Buffer.poolSize = 8192 // not used by this implementation
-
-// TODO: Legacy, not needed anymore. Remove in next major version.
-Buffer._augment = function (arr) {
- arr.__proto__ = Buffer.prototype
- return arr
-}
-
-function from (that, value, encodingOrOffset, length) {
- if (typeof value === 'number') {
- throw new TypeError('"value" argument must not be a number')
- }
-
- if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) {
- return fromArrayBuffer(that, value, encodingOrOffset, length)
- }
-
- if (typeof value === 'string') {
- return fromString(that, value, encodingOrOffset)
- }
-
- return fromObject(that, value)
-}
-
-/**
- * Functionally equivalent to Buffer(arg, encoding) but throws a TypeError
- * if value is a number.
- * Buffer.from(str[, encoding])
- * Buffer.from(array)
- * Buffer.from(buffer)
- * Buffer.from(arrayBuffer[, byteOffset[, length]])
- **/
-Buffer.from = function (value, encodingOrOffset, length) {
- return from(null, value, encodingOrOffset, length)
-}
-
-if (Buffer.TYPED_ARRAY_SUPPORT) {
- Buffer.prototype.__proto__ = Uint8Array.prototype
- Buffer.__proto__ = Uint8Array
- if (typeof Symbol !== 'undefined' && Symbol.species &&
- Buffer[Symbol.species] === Buffer) {
- // Fix subarray() in ES2016. See: https://github.com/feross/buffer/pull/97
- Object.defineProperty(Buffer, Symbol.species, {
- value: null,
- configurable: true
- })
- }
-}
-
-function assertSize (size) {
- if (typeof size !== 'number') {
- throw new TypeError('"size" argument must be a number')
- } else if (size < 0) {
- throw new RangeError('"size" argument must not be negative')
- }
-}
-
-function alloc (that, size, fill, encoding) {
- assertSize(size)
- if (size <= 0) {
- return createBuffer(that, size)
- }
- if (fill !== undefined) {
- // Only pay attention to encoding if it's a string. This
- // prevents accidentally sending in a number that would
- // be interpretted as a start offset.
- return typeof encoding === 'string'
- ? createBuffer(that, size).fill(fill, encoding)
- : createBuffer(that, size).fill(fill)
- }
- return createBuffer(that, size)
-}
-
-/**
- * Creates a new filled Buffer instance.
- * alloc(size[, fill[, encoding]])
- **/
-Buffer.alloc = function (size, fill, encoding) {
- return alloc(null, size, fill, encoding)
-}
-
-function allocUnsafe (that, size) {
- assertSize(size)
- that = createBuffer(that, size < 0 ? 0 : checked(size) | 0)
- if (!Buffer.TYPED_ARRAY_SUPPORT) {
- for (var i = 0; i < size; ++i) {
- that[i] = 0
- }
- }
- return that
-}
-
-/**
- * Equivalent to Buffer(num), by default creates a non-zero-filled Buffer instance.
- * */
-Buffer.allocUnsafe = function (size) {
- return allocUnsafe(null, size)
-}
-/**
- * Equivalent to SlowBuffer(num), by default creates a non-zero-filled Buffer instance.
- */
-Buffer.allocUnsafeSlow = function (size) {
- return allocUnsafe(null, size)
-}
-
-function fromString (that, string, encoding) {
- if (typeof encoding !== 'string' || encoding === '') {
- encoding = 'utf8'
- }
-
- if (!Buffer.isEncoding(encoding)) {
- throw new TypeError('"encoding" must be a valid string encoding')
- }
-
- var length = byteLength(string, encoding) | 0
- that = createBuffer(that, length)
-
- var actual = that.write(string, encoding)
-
- if (actual !== length) {
- // Writing a hex string, for example, that contains invalid characters will
- // cause everything after the first invalid character to be ignored. (e.g.
- // 'abxxcd' will be treated as 'ab')
- that = that.slice(0, actual)
- }
-
- return that
-}
-
-function fromArrayLike (that, array) {
- var length = array.length < 0 ? 0 : checked(array.length) | 0
- that = createBuffer(that, length)
- for (var i = 0; i < length; i += 1) {
- that[i] = array[i] & 255
- }
- return that
-}
-
-function fromArrayBuffer (that, array, byteOffset, length) {
- array.byteLength // this throws if `array` is not a valid ArrayBuffer
-
- if (byteOffset < 0 || array.byteLength < byteOffset) {
- throw new RangeError('\'offset\' is out of bounds')
- }
-
- if (array.byteLength < byteOffset + (length || 0)) {
- throw new RangeError('\'length\' is out of bounds')
- }
-
- if (byteOffset === undefined && length === undefined) {
- array = new Uint8Array(array)
- } else if (length === undefined) {
- array = new Uint8Array(array, byteOffset)
- } else {
- array = new Uint8Array(array, byteOffset, length)
- }
-
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- // Return an augmented `Uint8Array` instance, for best performance
- that = array
- that.__proto__ = Buffer.prototype
- } else {
- // Fallback: Return an object instance of the Buffer class
- that = fromArrayLike(that, array)
- }
- return that
-}
-
-function fromObject (that, obj) {
- if (Buffer.isBuffer(obj)) {
- var len = checked(obj.length) | 0
- that = createBuffer(that, len)
-
- if (that.length === 0) {
- return that
- }
-
- obj.copy(that, 0, 0, len)
- return that
- }
-
- if (obj) {
- if ((typeof ArrayBuffer !== 'undefined' &&
- obj.buffer instanceof ArrayBuffer) || 'length' in obj) {
- if (typeof obj.length !== 'number' || isnan(obj.length)) {
- return createBuffer(that, 0)
- }
- return fromArrayLike(that, obj)
- }
-
- if (obj.type === 'Buffer' && isArray(obj.data)) {
- return fromArrayLike(that, obj.data)
- }
- }
-
- throw new TypeError('First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.')
-}
-
-function checked (length) {
- // Note: cannot use `length < kMaxLength()` here because that fails when
- // length is NaN (which is otherwise coerced to zero.)
- if (length >= kMaxLength()) {
- throw new RangeError('Attempt to allocate Buffer larger than maximum ' +
- 'size: 0x' + kMaxLength().toString(16) + ' bytes')
- }
- return length | 0
-}
-
-function SlowBuffer (length) {
- if (+length != length) { // eslint-disable-line eqeqeq
- length = 0
- }
- return Buffer.alloc(+length)
-}
-
-Buffer.isBuffer = function isBuffer (b) {
- return !!(b != null && b._isBuffer)
-}
-
-Buffer.compare = function compare (a, b) {
- if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) {
- throw new TypeError('Arguments must be Buffers')
- }
-
- if (a === b) return 0
-
- var x = a.length
- var y = b.length
-
- for (var i = 0, len = Math.min(x, y); i < len; ++i) {
- if (a[i] !== b[i]) {
- x = a[i]
- y = b[i]
- break
- }
- }
-
- if (x < y) return -1
- if (y < x) return 1
- return 0
-}
-
-Buffer.isEncoding = function isEncoding (encoding) {
- switch (String(encoding).toLowerCase()) {
- case 'hex':
- case 'utf8':
- case 'utf-8':
- case 'ascii':
- case 'latin1':
- case 'binary':
- case 'base64':
- case 'ucs2':
- case 'ucs-2':
- case 'utf16le':
- case 'utf-16le':
- return true
- default:
- return false
- }
-}
-
-Buffer.concat = function concat (list, length) {
- if (!isArray(list)) {
- throw new TypeError('"list" argument must be an Array of Buffers')
- }
-
- if (list.length === 0) {
- return Buffer.alloc(0)
- }
-
- var i
- if (length === undefined) {
- length = 0
- for (i = 0; i < list.length; ++i) {
- length += list[i].length
- }
- }
-
- var buffer = Buffer.allocUnsafe(length)
- var pos = 0
- for (i = 0; i < list.length; ++i) {
- var buf = list[i]
- if (!Buffer.isBuffer(buf)) {
- throw new TypeError('"list" argument must be an Array of Buffers')
- }
- buf.copy(buffer, pos)
- pos += buf.length
- }
- return buffer
-}
-
-function byteLength (string, encoding) {
- if (Buffer.isBuffer(string)) {
- return string.length
- }
- if (typeof ArrayBuffer !== 'undefined' && typeof ArrayBuffer.isView === 'function' &&
- (ArrayBuffer.isView(string) || string instanceof ArrayBuffer)) {
- return string.byteLength
- }
- if (typeof string !== 'string') {
- string = '' + string
- }
-
- var len = string.length
- if (len === 0) return 0
-
- // Use a for loop to avoid recursion
- var loweredCase = false
- for (;;) {
- switch (encoding) {
- case 'ascii':
- case 'latin1':
- case 'binary':
- return len
- case 'utf8':
- case 'utf-8':
- case undefined:
- return utf8ToBytes(string).length
- case 'ucs2':
- case 'ucs-2':
- case 'utf16le':
- case 'utf-16le':
- return len * 2
- case 'hex':
- return len >>> 1
- case 'base64':
- return base64ToBytes(string).length
- default:
- if (loweredCase) return utf8ToBytes(string).length // assume utf8
- encoding = ('' + encoding).toLowerCase()
- loweredCase = true
- }
- }
-}
-Buffer.byteLength = byteLength
-
-function slowToString (encoding, start, end) {
- var loweredCase = false
-
- // No need to verify that "this.length <= MAX_UINT32" since it's a read-only
- // property of a typed array.
-
- // This behaves neither like String nor Uint8Array in that we set start/end
- // to their upper/lower bounds if the value passed is out of range.
- // undefined is handled specially as per ECMA-262 6th Edition,
- // Section 13.3.3.7 Runtime Semantics: KeyedBindingInitialization.
- if (start === undefined || start < 0) {
- start = 0
- }
- // Return early if start > this.length. Done here to prevent potential uint32
- // coercion fail below.
- if (start > this.length) {
- return ''
- }
-
- if (end === undefined || end > this.length) {
- end = this.length
- }
-
- if (end <= 0) {
- return ''
- }
-
- // Force coersion to uint32. This will also coerce falsey/NaN values to 0.
- end >>>= 0
- start >>>= 0
-
- if (end <= start) {
- return ''
- }
-
- if (!encoding) encoding = 'utf8'
-
- while (true) {
- switch (encoding) {
- case 'hex':
- return hexSlice(this, start, end)
-
- case 'utf8':
- case 'utf-8':
- return utf8Slice(this, start, end)
-
- case 'ascii':
- return asciiSlice(this, start, end)
-
- case 'latin1':
- case 'binary':
- return latin1Slice(this, start, end)
-
- case 'base64':
- return base64Slice(this, start, end)
-
- case 'ucs2':
- case 'ucs-2':
- case 'utf16le':
- case 'utf-16le':
- return utf16leSlice(this, start, end)
-
- default:
- if (loweredCase) throw new TypeError('Unknown encoding: ' + encoding)
- encoding = (encoding + '').toLowerCase()
- loweredCase = true
- }
- }
-}
-
-// The property is used by `Buffer.isBuffer` and `is-buffer` (in Safari 5-7) to detect
-// Buffer instances.
-Buffer.prototype._isBuffer = true
-
-function swap (b, n, m) {
- var i = b[n]
- b[n] = b[m]
- b[m] = i
-}
-
-Buffer.prototype.swap16 = function swap16 () {
- var len = this.length
- if (len % 2 !== 0) {
- throw new RangeError('Buffer size must be a multiple of 16-bits')
- }
- for (var i = 0; i < len; i += 2) {
- swap(this, i, i + 1)
- }
- return this
-}
-
-Buffer.prototype.swap32 = function swap32 () {
- var len = this.length
- if (len % 4 !== 0) {
- throw new RangeError('Buffer size must be a multiple of 32-bits')
- }
- for (var i = 0; i < len; i += 4) {
- swap(this, i, i + 3)
- swap(this, i + 1, i + 2)
- }
- return this
-}
-
-Buffer.prototype.swap64 = function swap64 () {
- var len = this.length
- if (len % 8 !== 0) {
- throw new RangeError('Buffer size must be a multiple of 64-bits')
- }
- for (var i = 0; i < len; i += 8) {
- swap(this, i, i + 7)
- swap(this, i + 1, i + 6)
- swap(this, i + 2, i + 5)
- swap(this, i + 3, i + 4)
- }
- return this
-}
-
-Buffer.prototype.toString = function toString () {
- var length = this.length | 0
- if (length === 0) return ''
- if (arguments.length === 0) return utf8Slice(this, 0, length)
- return slowToString.apply(this, arguments)
-}
-
-Buffer.prototype.equals = function equals (b) {
- if (!Buffer.isBuffer(b)) throw new TypeError('Argument must be a Buffer')
- if (this === b) return true
- return Buffer.compare(this, b) === 0
-}
-
-Buffer.prototype.inspect = function inspect () {
- var str = ''
- var max = exports.INSPECT_MAX_BYTES
- if (this.length > 0) {
- str = this.toString('hex', 0, max).match(/.{2}/g).join(' ')
- if (this.length > max) str += ' ... '
- }
- return '<Buffer ' + str + '>'
-}
-
-Buffer.prototype.compare = function compare (target, start, end, thisStart, thisEnd) {
- if (!Buffer.isBuffer(target)) {
- throw new TypeError('Argument must be a Buffer')
- }
-
- if (start === undefined) {
- start = 0
- }
- if (end === undefined) {
- end = target ? target.length : 0
- }
- if (thisStart === undefined) {
- thisStart = 0
- }
- if (thisEnd === undefined) {
- thisEnd = this.length
- }
-
- if (start < 0 || end > target.length || thisStart < 0 || thisEnd > this.length) {
- throw new RangeError('out of range index')
- }
-
- if (thisStart >= thisEnd && start >= end) {
- return 0
- }
- if (thisStart >= thisEnd) {
- return -1
- }
- if (start >= end) {
- return 1
- }
-
- start >>>= 0
- end >>>= 0
- thisStart >>>= 0
- thisEnd >>>= 0
-
- if (this === target) return 0
-
- var x = thisEnd - thisStart
- var y = end - start
- var len = Math.min(x, y)
-
- var thisCopy = this.slice(thisStart, thisEnd)
- var targetCopy = target.slice(start, end)
-
- for (var i = 0; i < len; ++i) {
- if (thisCopy[i] !== targetCopy[i]) {
- x = thisCopy[i]
- y = targetCopy[i]
- break
- }
- }
-
- if (x < y) return -1
- if (y < x) return 1
- return 0
-}
-
-// Finds either the first index of `val` in `buffer` at offset >= `byteOffset`,
-// OR the last index of `val` in `buffer` at offset <= `byteOffset`.
-//
-// Arguments:
-// - buffer - a Buffer to search
-// - val - a string, Buffer, or number
-// - byteOffset - an index into `buffer`; will be clamped to an int32
-// - encoding - an optional encoding, relevant is val is a string
-// - dir - true for indexOf, false for lastIndexOf
-function bidirectionalIndexOf (buffer, val, byteOffset, encoding, dir) {
- // Empty buffer means no match
- if (buffer.length === 0) return -1
-
- // Normalize byteOffset
- if (typeof byteOffset === 'string') {
- encoding = byteOffset
- byteOffset = 0
- } else if (byteOffset > 0x7fffffff) {
- byteOffset = 0x7fffffff
- } else if (byteOffset < -0x80000000) {
- byteOffset = -0x80000000
- }
- byteOffset = +byteOffset // Coerce to Number.
- if (isNaN(byteOffset)) {
- // byteOffset: it it's undefined, null, NaN, "foo", etc, search whole buffer
- byteOffset = dir ? 0 : (buffer.length - 1)
- }
-
- // Normalize byteOffset: negative offsets start from the end of the buffer
- if (byteOffset < 0) byteOffset = buffer.length + byteOffset
- if (byteOffset >= buffer.length) {
- if (dir) return -1
- else byteOffset = buffer.length - 1
- } else if (byteOffset < 0) {
- if (dir) byteOffset = 0
- else return -1
- }
-
- // Normalize val
- if (typeof val === 'string') {
- val = Buffer.from(val, encoding)
- }
-
- // Finally, search either indexOf (if dir is true) or lastIndexOf
- if (Buffer.isBuffer(val)) {
- // Special case: looking for empty string/buffer always fails
- if (val.length === 0) {
- return -1
- }
- return arrayIndexOf(buffer, val, byteOffset, encoding, dir)
- } else if (typeof val === 'number') {
- val = val & 0xFF // Search for a byte value [0-255]
- if (Buffer.TYPED_ARRAY_SUPPORT &&
- typeof Uint8Array.prototype.indexOf === 'function') {
- if (dir) {
- return Uint8Array.prototype.indexOf.call(buffer, val, byteOffset)
- } else {
- return Uint8Array.prototype.lastIndexOf.call(buffer, val, byteOffset)
- }
- }
- return arrayIndexOf(buffer, [ val ], byteOffset, encoding, dir)
- }
-
- throw new TypeError('val must be string, number or Buffer')
-}
-
-function arrayIndexOf (arr, val, byteOffset, encoding, dir) {
- var indexSize = 1
- var arrLength = arr.length
- var valLength = val.length
-
- if (encoding !== undefined) {
- encoding = String(encoding).toLowerCase()
- if (encoding === 'ucs2' || encoding === 'ucs-2' ||
- encoding === 'utf16le' || encoding === 'utf-16le') {
- if (arr.length < 2 || val.length < 2) {
- return -1
- }
- indexSize = 2
- arrLength /= 2
- valLength /= 2
- byteOffset /= 2
- }
- }
-
- function read (buf, i) {
- if (indexSize === 1) {
- return buf[i]
- } else {
- return buf.readUInt16BE(i * indexSize)
- }
- }
-
- var i
- if (dir) {
- var foundIndex = -1
- for (i = byteOffset; i < arrLength; i++) {
- if (read(arr, i) === read(val, foundIndex === -1 ? 0 : i - foundIndex)) {
- if (foundIndex === -1) foundIndex = i
- if (i - foundIndex + 1 === valLength) return foundIndex * indexSize
- } else {
- if (foundIndex !== -1) i -= i - foundIndex
- foundIndex = -1
- }
- }
- } else {
- if (byteOffset + valLength > arrLength) byteOffset = arrLength - valLength
- for (i = byteOffset; i >= 0; i--) {
- var found = true
- for (var j = 0; j < valLength; j++) {
- if (read(arr, i + j) !== read(val, j)) {
- found = false
- break
- }
- }
- if (found) return i
- }
- }
-
- return -1
-}
-
-Buffer.prototype.includes = function includes (val, byteOffset, encoding) {
- return this.indexOf(val, byteOffset, encoding) !== -1
-}
-
-Buffer.prototype.indexOf = function indexOf (val, byteOffset, encoding) {
- return bidirectionalIndexOf(this, val, byteOffset, encoding, true)
-}
-
-Buffer.prototype.lastIndexOf = function lastIndexOf (val, byteOffset, encoding) {
- return bidirectionalIndexOf(this, val, byteOffset, encoding, false)
-}
-
-function hexWrite (buf, string, offset, length) {
- offset = Number(offset) || 0
- var remaining = buf.length - offset
- if (!length) {
- length = remaining
- } else {
- length = Number(length)
- if (length > remaining) {
- length = remaining
- }
- }
-
- // must be an even number of digits
- var strLen = string.length
- if (strLen % 2 !== 0) throw new TypeError('Invalid hex string')
-
- if (length > strLen / 2) {
- length = strLen / 2
- }
- for (var i = 0; i < length; ++i) {
- var parsed = parseInt(string.substr(i * 2, 2), 16)
- if (isNaN(parsed)) return i
- buf[offset + i] = parsed
- }
- return i
-}
-
-function utf8Write (buf, string, offset, length) {
- return blitBuffer(utf8ToBytes(string, buf.length - offset), buf, offset, length)
-}
-
-function asciiWrite (buf, string, offset, length) {
- return blitBuffer(asciiToBytes(string), buf, offset, length)
-}
-
-function latin1Write (buf, string, offset, length) {
- return asciiWrite(buf, string, offset, length)
-}
-
-function base64Write (buf, string, offset, length) {
- return blitBuffer(base64ToBytes(string), buf, offset, length)
-}
-
-function ucs2Write (buf, string, offset, length) {
- return blitBuffer(utf16leToBytes(string, buf.length - offset), buf, offset, length)
-}
-
-Buffer.prototype.write = function write (string, offset, length, encoding) {
- // Buffer#write(string)
- if (offset === undefined) {
- encoding = 'utf8'
- length = this.length
- offset = 0
- // Buffer#write(string, encoding)
- } else if (length === undefined && typeof offset === 'string') {
- encoding = offset
- length = this.length
- offset = 0
- // Buffer#write(string, offset[, length][, encoding])
- } else if (isFinite(offset)) {
- offset = offset | 0
- if (isFinite(length)) {
- length = length | 0
- if (encoding === undefined) encoding = 'utf8'
- } else {
- encoding = length
- length = undefined
- }
- // legacy write(string, encoding, offset, length) - remove in v0.13
- } else {
- throw new Error(
- 'Buffer.write(string, encoding, offset[, length]) is no longer supported'
- )
- }
-
- var remaining = this.length - offset
- if (length === undefined || length > remaining) length = remaining
-
- if ((string.length > 0 && (length < 0 || offset < 0)) || offset > this.length) {
- throw new RangeError('Attempt to write outside buffer bounds')
- }
-
- if (!encoding) encoding = 'utf8'
-
- var loweredCase = false
- for (;;) {
- switch (encoding) {
- case 'hex':
- return hexWrite(this, string, offset, length)
-
- case 'utf8':
- case 'utf-8':
- return utf8Write(this, string, offset, length)
-
- case 'ascii':
- return asciiWrite(this, string, offset, length)
-
- case 'latin1':
- case 'binary':
- return latin1Write(this, string, offset, length)
-
- case 'base64':
- // Warning: maxLength not taken into account in base64Write
- return base64Write(this, string, offset, length)
-
- case 'ucs2':
- case 'ucs-2':
- case 'utf16le':
- case 'utf-16le':
- return ucs2Write(this, string, offset, length)
-
- default:
- if (loweredCase) throw new TypeError('Unknown encoding: ' + encoding)
- encoding = ('' + encoding).toLowerCase()
- loweredCase = true
- }
- }
-}
-
-Buffer.prototype.toJSON = function toJSON () {
- return {
- type: 'Buffer',
- data: Array.prototype.slice.call(this._arr || this, 0)
- }
-}
-
-function base64Slice (buf, start, end) {
- if (start === 0 && end === buf.length) {
- return base64.fromByteArray(buf)
- } else {
- return base64.fromByteArray(buf.slice(start, end))
- }
-}
-
-function utf8Slice (buf, start, end) {
- end = Math.min(buf.length, end)
- var res = []
-
- var i = start
- while (i < end) {
- var firstByte = buf[i]
- var codePoint = null
- var bytesPerSequence = (firstByte > 0xEF) ? 4
- : (firstByte > 0xDF) ? 3
- : (firstByte > 0xBF) ? 2
- : 1
-
- if (i + bytesPerSequence <= end) {
- var secondByte, thirdByte, fourthByte, tempCodePoint
-
- switch (bytesPerSequence) {
- case 1:
- if (firstByte < 0x80) {
- codePoint = firstByte
- }
- break
- case 2:
- secondByte = buf[i + 1]
- if ((secondByte & 0xC0) === 0x80) {
- tempCodePoint = (firstByte & 0x1F) << 0x6 | (secondByte & 0x3F)
- if (tempCodePoint > 0x7F) {
- codePoint = tempCodePoint
- }
- }
- break
- case 3:
- secondByte = buf[i + 1]
- thirdByte = buf[i + 2]
- if ((secondByte & 0xC0) === 0x80 && (thirdByte & 0xC0) === 0x80) {
- tempCodePoint = (firstByte & 0xF) << 0xC | (secondByte & 0x3F) << 0x6 | (thirdByte & 0x3F)
- if (tempCodePoint > 0x7FF && (tempCodePoint < 0xD800 || tempCodePoint > 0xDFFF)) {
- codePoint = tempCodePoint
- }
- }
- break
- case 4:
- secondByte = buf[i + 1]
- thirdByte = buf[i + 2]
- fourthByte = buf[i + 3]
- if ((secondByte & 0xC0) === 0x80 && (thirdByte & 0xC0) === 0x80 && (fourthByte & 0xC0) === 0x80) {
- tempCodePoint = (firstByte & 0xF) << 0x12 | (secondByte & 0x3F) << 0xC | (thirdByte & 0x3F) << 0x6 | (fourthByte & 0x3F)
- if (tempCodePoint > 0xFFFF && tempCodePoint < 0x110000) {
- codePoint = tempCodePoint
- }
- }
- }
- }
-
- if (codePoint === null) {
- // we did not generate a valid codePoint so insert a
- // replacement char (U+FFFD) and advance only 1 byte
- codePoint = 0xFFFD
- bytesPerSequence = 1
- } else if (codePoint > 0xFFFF) {
- // encode to utf16 (surrogate pair dance)
- codePoint -= 0x10000
- res.push(codePoint >>> 10 & 0x3FF | 0xD800)
- codePoint = 0xDC00 | codePoint & 0x3FF
- }
-
- res.push(codePoint)
- i += bytesPerSequence
- }
-
- return decodeCodePointsArray(res)
-}
-
-// Based on http://stackoverflow.com/a/22747272/680742, the browser with
-// the lowest limit is Chrome, with 0x10000 args.
-// We go 1 magnitude less, for safety
-var MAX_ARGUMENTS_LENGTH = 0x1000
-
-function decodeCodePointsArray (codePoints) {
- var len = codePoints.length
- if (len <= MAX_ARGUMENTS_LENGTH) {
- return String.fromCharCode.apply(String, codePoints) // avoid extra slice()
- }
-
- // Decode in chunks to avoid "call stack size exceeded".
- var res = ''
- var i = 0
- while (i < len) {
- res += String.fromCharCode.apply(
- String,
- codePoints.slice(i, i += MAX_ARGUMENTS_LENGTH)
- )
- }
- return res
-}
-
-function asciiSlice (buf, start, end) {
- var ret = ''
- end = Math.min(buf.length, end)
-
- for (var i = start; i < end; ++i) {
- ret += String.fromCharCode(buf[i] & 0x7F)
- }
- return ret
-}
-
-function latin1Slice (buf, start, end) {
- var ret = ''
- end = Math.min(buf.length, end)
-
- for (var i = start; i < end; ++i) {
- ret += String.fromCharCode(buf[i])
- }
- return ret
-}
-
-function hexSlice (buf, start, end) {
- var len = buf.length
-
- if (!start || start < 0) start = 0
- if (!end || end < 0 || end > len) end = len
-
- var out = ''
- for (var i = start; i < end; ++i) {
- out += toHex(buf[i])
- }
- return out
-}
-
-function utf16leSlice (buf, start, end) {
- var bytes = buf.slice(start, end)
- var res = ''
- for (var i = 0; i < bytes.length; i += 2) {
- res += String.fromCharCode(bytes[i] + bytes[i + 1] * 256)
- }
- return res
-}
-
-Buffer.prototype.slice = function slice (start, end) {
- var len = this.length
- start = ~~start
- end = end === undefined ? len : ~~end
-
- if (start < 0) {
- start += len
- if (start < 0) start = 0
- } else if (start > len) {
- start = len
- }
-
- if (end < 0) {
- end += len
- if (end < 0) end = 0
- } else if (end > len) {
- end = len
- }
-
- if (end < start) end = start
-
- var newBuf
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- newBuf = this.subarray(start, end)
- newBuf.__proto__ = Buffer.prototype
- } else {
- var sliceLen = end - start
- newBuf = new Buffer(sliceLen, undefined)
- for (var i = 0; i < sliceLen; ++i) {
- newBuf[i] = this[i + start]
- }
- }
-
- return newBuf
-}
-
-/*
- * Need to make sure that buffer isn't trying to write out of bounds.
- */
-function checkOffset (offset, ext, length) {
- if ((offset % 1) !== 0 || offset < 0) throw new RangeError('offset is not uint')
- if (offset + ext > length) throw new RangeError('Trying to access beyond buffer length')
-}
-
-Buffer.prototype.readUIntLE = function readUIntLE (offset, byteLength, noAssert) {
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) checkOffset(offset, byteLength, this.length)
-
- var val = this[offset]
- var mul = 1
- var i = 0
- while (++i < byteLength && (mul *= 0x100)) {
- val += this[offset + i] * mul
- }
-
- return val
-}
-
-Buffer.prototype.readUIntBE = function readUIntBE (offset, byteLength, noAssert) {
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) {
- checkOffset(offset, byteLength, this.length)
- }
-
- var val = this[offset + --byteLength]
- var mul = 1
- while (byteLength > 0 && (mul *= 0x100)) {
- val += this[offset + --byteLength] * mul
- }
-
- return val
-}
-
-Buffer.prototype.readUInt8 = function readUInt8 (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 1, this.length)
- return this[offset]
-}
-
-Buffer.prototype.readUInt16LE = function readUInt16LE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 2, this.length)
- return this[offset] | (this[offset + 1] << 8)
-}
-
-Buffer.prototype.readUInt16BE = function readUInt16BE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 2, this.length)
- return (this[offset] << 8) | this[offset + 1]
-}
-
-Buffer.prototype.readUInt32LE = function readUInt32LE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
-
- return ((this[offset]) |
- (this[offset + 1] << 8) |
- (this[offset + 2] << 16)) +
- (this[offset + 3] * 0x1000000)
-}
-
-Buffer.prototype.readUInt32BE = function readUInt32BE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
-
- return (this[offset] * 0x1000000) +
- ((this[offset + 1] << 16) |
- (this[offset + 2] << 8) |
- this[offset + 3])
-}
-
-Buffer.prototype.readIntLE = function readIntLE (offset, byteLength, noAssert) {
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) checkOffset(offset, byteLength, this.length)
-
- var val = this[offset]
- var mul = 1
- var i = 0
- while (++i < byteLength && (mul *= 0x100)) {
- val += this[offset + i] * mul
- }
- mul *= 0x80
-
- if (val >= mul) val -= Math.pow(2, 8 * byteLength)
-
- return val
-}
-
-Buffer.prototype.readIntBE = function readIntBE (offset, byteLength, noAssert) {
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) checkOffset(offset, byteLength, this.length)
-
- var i = byteLength
- var mul = 1
- var val = this[offset + --i]
- while (i > 0 && (mul *= 0x100)) {
- val += this[offset + --i] * mul
- }
- mul *= 0x80
-
- if (val >= mul) val -= Math.pow(2, 8 * byteLength)
-
- return val
-}
-
-Buffer.prototype.readInt8 = function readInt8 (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 1, this.length)
- if (!(this[offset] & 0x80)) return (this[offset])
- return ((0xff - this[offset] + 1) * -1)
-}
-
-Buffer.prototype.readInt16LE = function readInt16LE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 2, this.length)
- var val = this[offset] | (this[offset + 1] << 8)
- return (val & 0x8000) ? val | 0xFFFF0000 : val
-}
-
-Buffer.prototype.readInt16BE = function readInt16BE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 2, this.length)
- var val = this[offset + 1] | (this[offset] << 8)
- return (val & 0x8000) ? val | 0xFFFF0000 : val
-}
-
-Buffer.prototype.readInt32LE = function readInt32LE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
-
- return (this[offset]) |
- (this[offset + 1] << 8) |
- (this[offset + 2] << 16) |
- (this[offset + 3] << 24)
-}
-
-Buffer.prototype.readInt32BE = function readInt32BE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
-
- return (this[offset] << 24) |
- (this[offset + 1] << 16) |
- (this[offset + 2] << 8) |
- (this[offset + 3])
-}
-
-Buffer.prototype.readFloatLE = function readFloatLE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
- return ieee754.read(this, offset, true, 23, 4)
-}
-
-Buffer.prototype.readFloatBE = function readFloatBE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 4, this.length)
- return ieee754.read(this, offset, false, 23, 4)
-}
-
-Buffer.prototype.readDoubleLE = function readDoubleLE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 8, this.length)
- return ieee754.read(this, offset, true, 52, 8)
-}
-
-Buffer.prototype.readDoubleBE = function readDoubleBE (offset, noAssert) {
- if (!noAssert) checkOffset(offset, 8, this.length)
- return ieee754.read(this, offset, false, 52, 8)
-}
-
-function checkInt (buf, value, offset, ext, max, min) {
- if (!Buffer.isBuffer(buf)) throw new TypeError('"buffer" argument must be a Buffer instance')
- if (value > max || value < min) throw new RangeError('"value" argument is out of bounds')
- if (offset + ext > buf.length) throw new RangeError('Index out of range')
-}
-
-Buffer.prototype.writeUIntLE = function writeUIntLE (value, offset, byteLength, noAssert) {
- value = +value
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) {
- var maxBytes = Math.pow(2, 8 * byteLength) - 1
- checkInt(this, value, offset, byteLength, maxBytes, 0)
- }
-
- var mul = 1
- var i = 0
- this[offset] = value & 0xFF
- while (++i < byteLength && (mul *= 0x100)) {
- this[offset + i] = (value / mul) & 0xFF
- }
-
- return offset + byteLength
-}
-
-Buffer.prototype.writeUIntBE = function writeUIntBE (value, offset, byteLength, noAssert) {
- value = +value
- offset = offset | 0
- byteLength = byteLength | 0
- if (!noAssert) {
- var maxBytes = Math.pow(2, 8 * byteLength) - 1
- checkInt(this, value, offset, byteLength, maxBytes, 0)
- }
-
- var i = byteLength - 1
- var mul = 1
- this[offset + i] = value & 0xFF
- while (--i >= 0 && (mul *= 0x100)) {
- this[offset + i] = (value / mul) & 0xFF
- }
-
- return offset + byteLength
-}
-
-Buffer.prototype.writeUInt8 = function writeUInt8 (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 1, 0xff, 0)
- if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value)
- this[offset] = (value & 0xff)
- return offset + 1
-}
-
-function objectWriteUInt16 (buf, value, offset, littleEndian) {
- if (value < 0) value = 0xffff + value + 1
- for (var i = 0, j = Math.min(buf.length - offset, 2); i < j; ++i) {
- buf[offset + i] = (value & (0xff << (8 * (littleEndian ? i : 1 - i)))) >>>
- (littleEndian ? i : 1 - i) * 8
- }
-}
-
-Buffer.prototype.writeUInt16LE = function writeUInt16LE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 2, 0xffff, 0)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value & 0xff)
- this[offset + 1] = (value >>> 8)
- } else {
- objectWriteUInt16(this, value, offset, true)
- }
- return offset + 2
-}
-
-Buffer.prototype.writeUInt16BE = function writeUInt16BE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 2, 0xffff, 0)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value >>> 8)
- this[offset + 1] = (value & 0xff)
- } else {
- objectWriteUInt16(this, value, offset, false)
- }
- return offset + 2
-}
-
-function objectWriteUInt32 (buf, value, offset, littleEndian) {
- if (value < 0) value = 0xffffffff + value + 1
- for (var i = 0, j = Math.min(buf.length - offset, 4); i < j; ++i) {
- buf[offset + i] = (value >>> (littleEndian ? i : 3 - i) * 8) & 0xff
- }
-}
-
-Buffer.prototype.writeUInt32LE = function writeUInt32LE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 4, 0xffffffff, 0)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset + 3] = (value >>> 24)
- this[offset + 2] = (value >>> 16)
- this[offset + 1] = (value >>> 8)
- this[offset] = (value & 0xff)
- } else {
- objectWriteUInt32(this, value, offset, true)
- }
- return offset + 4
-}
-
-Buffer.prototype.writeUInt32BE = function writeUInt32BE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 4, 0xffffffff, 0)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value >>> 24)
- this[offset + 1] = (value >>> 16)
- this[offset + 2] = (value >>> 8)
- this[offset + 3] = (value & 0xff)
- } else {
- objectWriteUInt32(this, value, offset, false)
- }
- return offset + 4
-}
-
-Buffer.prototype.writeIntLE = function writeIntLE (value, offset, byteLength, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) {
- var limit = Math.pow(2, 8 * byteLength - 1)
-
- checkInt(this, value, offset, byteLength, limit - 1, -limit)
- }
-
- var i = 0
- var mul = 1
- var sub = 0
- this[offset] = value & 0xFF
- while (++i < byteLength && (mul *= 0x100)) {
- if (value < 0 && sub === 0 && this[offset + i - 1] !== 0) {
- sub = 1
- }
- this[offset + i] = ((value / mul) >> 0) - sub & 0xFF
- }
-
- return offset + byteLength
-}
-
-Buffer.prototype.writeIntBE = function writeIntBE (value, offset, byteLength, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) {
- var limit = Math.pow(2, 8 * byteLength - 1)
-
- checkInt(this, value, offset, byteLength, limit - 1, -limit)
- }
-
- var i = byteLength - 1
- var mul = 1
- var sub = 0
- this[offset + i] = value & 0xFF
- while (--i >= 0 && (mul *= 0x100)) {
- if (value < 0 && sub === 0 && this[offset + i + 1] !== 0) {
- sub = 1
- }
- this[offset + i] = ((value / mul) >> 0) - sub & 0xFF
- }
-
- return offset + byteLength
-}
-
-Buffer.prototype.writeInt8 = function writeInt8 (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 1, 0x7f, -0x80)
- if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value)
- if (value < 0) value = 0xff + value + 1
- this[offset] = (value & 0xff)
- return offset + 1
-}
-
-Buffer.prototype.writeInt16LE = function writeInt16LE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 2, 0x7fff, -0x8000)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value & 0xff)
- this[offset + 1] = (value >>> 8)
- } else {
- objectWriteUInt16(this, value, offset, true)
- }
- return offset + 2
-}
-
-Buffer.prototype.writeInt16BE = function writeInt16BE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 2, 0x7fff, -0x8000)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value >>> 8)
- this[offset + 1] = (value & 0xff)
- } else {
- objectWriteUInt16(this, value, offset, false)
- }
- return offset + 2
-}
-
-Buffer.prototype.writeInt32LE = function writeInt32LE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000)
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value & 0xff)
- this[offset + 1] = (value >>> 8)
- this[offset + 2] = (value >>> 16)
- this[offset + 3] = (value >>> 24)
- } else {
- objectWriteUInt32(this, value, offset, true)
- }
- return offset + 4
-}
-
-Buffer.prototype.writeInt32BE = function writeInt32BE (value, offset, noAssert) {
- value = +value
- offset = offset | 0
- if (!noAssert) checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000)
- if (value < 0) value = 0xffffffff + value + 1
- if (Buffer.TYPED_ARRAY_SUPPORT) {
- this[offset] = (value >>> 24)
- this[offset + 1] = (value >>> 16)
- this[offset + 2] = (value >>> 8)
- this[offset + 3] = (value & 0xff)
- } else {
- objectWriteUInt32(this, value, offset, false)
- }
- return offset + 4
-}
-
-function checkIEEE754 (buf, value, offset, ext, max, min) {
- if (offset + ext > buf.length) throw new RangeError('Index out of range')
- if (offset < 0) throw new RangeError('Index out of range')
-}
-
-function writeFloat (buf, value, offset, littleEndian, noAssert) {
- if (!noAssert) {
- checkIEEE754(buf, value, offset, 4, 3.4028234663852886e+38, -3.4028234663852886e+38)
- }
- ieee754.write(buf, value, offset, littleEndian, 23, 4)
- return offset + 4
-}
-
-Buffer.prototype.writeFloatLE = function writeFloatLE (value, offset, noAssert) {
- return writeFloat(this, value, offset, true, noAssert)
-}
-
-Buffer.prototype.writeFloatBE = function writeFloatBE (value, offset, noAssert) {
- return writeFloat(this, value, offset, false, noAssert)
-}
-
-function writeDouble (buf, value, offset, littleEndian, noAssert) {
- if (!noAssert) {
- checkIEEE754(buf, value, offset, 8, 1.7976931348623157E+308, -1.7976931348623157E+308)
- }
- ieee754.write(buf, value, offset, littleEndian, 52, 8)
- return offset + 8
-}
-
-Buffer.prototype.writeDoubleLE = function writeDoubleLE (value, offset, noAssert) {
- return writeDouble(this, value, offset, true, noAssert)
-}
-
-Buffer.prototype.writeDoubleBE = function writeDoubleBE (value, offset, noAssert) {
- return writeDouble(this, value, offset, false, noAssert)
-}
-
-// copy(targetBuffer, targetStart=0, sourceStart=0, sourceEnd=buffer.length)
-Buffer.prototype.copy = function copy (target, targetStart, start, end) {
- if (!start) start = 0
- if (!end && end !== 0) end = this.length
- if (targetStart >= target.length) targetStart = target.length
- if (!targetStart) targetStart = 0
- if (end > 0 && end < start) end = start
-
- // Copy 0 bytes; we're done
- if (end === start) return 0
- if (target.length === 0 || this.length === 0) return 0
-
- // Fatal error conditions
- if (targetStart < 0) {
- throw new RangeError('targetStart out of bounds')
- }
- if (start < 0 || start >= this.length) throw new RangeError('sourceStart out of bounds')
- if (end < 0) throw new RangeError('sourceEnd out of bounds')
-
- // Are we oob?
- if (end > this.length) end = this.length
- if (target.length - targetStart < end - start) {
- end = target.length - targetStart + start
- }
-
- var len = end - start
- var i
-
- if (this === target && start < targetStart && targetStart < end) {
- // descending copy from end
- for (i = len - 1; i >= 0; --i) {
- target[i + targetStart] = this[i + start]
- }
- } else if (len < 1000 || !Buffer.TYPED_ARRAY_SUPPORT) {
- // ascending copy from start
- for (i = 0; i < len; ++i) {
- target[i + targetStart] = this[i + start]
- }
- } else {
- Uint8Array.prototype.set.call(
- target,
- this.subarray(start, start + len),
- targetStart
- )
- }
-
- return len
-}
-
-// Usage:
-// buffer.fill(number[, offset[, end]])
-// buffer.fill(buffer[, offset[, end]])
-// buffer.fill(string[, offset[, end]][, encoding])
-Buffer.prototype.fill = function fill (val, start, end, encoding) {
- // Handle string cases:
- if (typeof val === 'string') {
- if (typeof start === 'string') {
- encoding = start
- start = 0
- end = this.length
- } else if (typeof end === 'string') {
- encoding = end
- end = this.length
- }
- if (val.length === 1) {
- var code = val.charCodeAt(0)
- if (code < 256) {
- val = code
- }
- }
- if (encoding !== undefined && typeof encoding !== 'string') {
- throw new TypeError('encoding must be a string')
- }
- if (typeof encoding === 'string' && !Buffer.isEncoding(encoding)) {
- throw new TypeError('Unknown encoding: ' + encoding)
- }
- } else if (typeof val === 'number') {
- val = val & 255
- }
-
- // Invalid ranges are not set to a default, so can range check early.
- if (start < 0 || this.length < start || this.length < end) {
- throw new RangeError('Out of range index')
- }
-
- if (end <= start) {
- return this
- }
-
- start = start >>> 0
- end = end === undefined ? this.length : end >>> 0
-
- if (!val) val = 0
-
- var i
- if (typeof val === 'number') {
- for (i = start; i < end; ++i) {
- this[i] = val
- }
- } else {
- var bytes = Buffer.isBuffer(val)
- ? val
- : utf8ToBytes(new Buffer(val, encoding).toString())
- var len = bytes.length
- for (i = 0; i < end - start; ++i) {
- this[i + start] = bytes[i % len]
- }
- }
-
- return this
-}
-
-// HELPER FUNCTIONS
-// ================
-
-var INVALID_BASE64_RE = /[^+\/0-9A-Za-z-_]/g
-
-function base64clean (str) {
- // Node strips out invalid characters like \n and \t from the string, base64-js does not
- str = stringtrim(str).replace(INVALID_BASE64_RE, '')
- // Node converts strings with length < 2 to ''
- if (str.length < 2) return ''
- // Node allows for non-padded base64 strings (missing trailing ===), base64-js does not
- while (str.length % 4 !== 0) {
- str = str + '='
- }
- return str
-}
-
-function stringtrim (str) {
- if (str.trim) return str.trim()
- return str.replace(/^\s+|\s+$/g, '')
-}
-
-function toHex (n) {
- if (n < 16) return '0' + n.toString(16)
- return n.toString(16)
-}
-
-function utf8ToBytes (string, units) {
- units = units || Infinity
- var codePoint
- var length = string.length
- var leadSurrogate = null
- var bytes = []
-
- for (var i = 0; i < length; ++i) {
- codePoint = string.charCodeAt(i)
-
- // is surrogate component
- if (codePoint > 0xD7FF && codePoint < 0xE000) {
- // last char was a lead
- if (!leadSurrogate) {
- // no lead yet
- if (codePoint > 0xDBFF) {
- // unexpected trail
- if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD)
- continue
- } else if (i + 1 === length) {
- // unpaired lead
- if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD)
- continue
- }
-
- // valid lead
- leadSurrogate = codePoint
-
- continue
- }
-
- // 2 leads in a row
- if (codePoint < 0xDC00) {
- if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD)
- leadSurrogate = codePoint
- continue
- }
-
- // valid surrogate pair
- codePoint = (leadSurrogate - 0xD800 << 10 | codePoint - 0xDC00) + 0x10000
- } else if (leadSurrogate) {
- // valid bmp char, but last char was a lead
- if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD)
- }
-
- leadSurrogate = null
-
- // encode utf8
- if (codePoint < 0x80) {
- if ((units -= 1) < 0) break
- bytes.push(codePoint)
- } else if (codePoint < 0x800) {
- if ((units -= 2) < 0) break
- bytes.push(
- codePoint >> 0x6 | 0xC0,
- codePoint & 0x3F | 0x80
- )
- } else if (codePoint < 0x10000) {
- if ((units -= 3) < 0) break
- bytes.push(
- codePoint >> 0xC | 0xE0,
- codePoint >> 0x6 & 0x3F | 0x80,
- codePoint & 0x3F | 0x80
- )
- } else if (codePoint < 0x110000) {
- if ((units -= 4) < 0) break
- bytes.push(
- codePoint >> 0x12 | 0xF0,
- codePoint >> 0xC & 0x3F | 0x80,
- codePoint >> 0x6 & 0x3F | 0x80,
- codePoint & 0x3F | 0x80
- )
- } else {
- throw new Error('Invalid code point')
- }
- }
-
- return bytes
-}
-
-function asciiToBytes (str) {
- var byteArray = []
- for (var i = 0; i < str.length; ++i) {
- // Node's code seems to be doing this and not & 0x7F..
- byteArray.push(str.charCodeAt(i) & 0xFF)
- }
- return byteArray
-}
-
-function utf16leToBytes (str, units) {
- var c, hi, lo
- var byteArray = []
- for (var i = 0; i < str.length; ++i) {
- if ((units -= 2) < 0) break
-
- c = str.charCodeAt(i)
- hi = c >> 8
- lo = c % 256
- byteArray.push(lo)
- byteArray.push(hi)
- }
-
- return byteArray
-}
-
-function base64ToBytes (str) {
- return base64.toByteArray(base64clean(str))
-}
-
-function blitBuffer (src, dst, offset, length) {
- for (var i = 0; i < length; ++i) {
- if ((i + offset >= dst.length) || (i >= src.length)) break
- dst[i + offset] = src[i]
- }
- return i
-}
-
-function isnan (val) {
- return val !== val // eslint-disable-line no-self-compare
-}
-
-/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(22)))
-
-/***/ }),
-/* 11 */
-/***/ (function(module, exports, __webpack_require__) {
-
-exports = module.exports = __webpack_require__(3)(undefined);
-// imports
-
-
-// module
-exports.push([module.i, ".pdf-viewer{background:url(" + __webpack_require__(15) + ");display:flex;flex-flow:column nowrap}", ""]);
-
-// exports
-
-
-/***/ }),
-/* 12 */
-/***/ (function(module, exports, __webpack_require__) {
-
-exports = module.exports = __webpack_require__(3)(undefined);
-// imports
-
-
-// module
-exports.push([module.i, ".pdf-page{margin:8px auto 0;border-top:1px solid #ddd;border-bottom:1px solid #ddd;width:100%}.pdf-page:first-child{margin-top:0;border-top:0}.pdf-page:last-child{margin-bottom:0;border-bottom:0}", ""]);
-
-// exports
-
-
-/***/ }),
-/* 13 */
-/***/ (function(module, exports) {
-
-exports.read = function (buffer, offset, isLE, mLen, nBytes) {
- var e, m
- var eLen = nBytes * 8 - mLen - 1
- var eMax = (1 << eLen) - 1
- var eBias = eMax >> 1
- var nBits = -7
- var i = isLE ? (nBytes - 1) : 0
- var d = isLE ? -1 : 1
- var s = buffer[offset + i]
-
- i += d
-
- e = s & ((1 << (-nBits)) - 1)
- s >>= (-nBits)
- nBits += eLen
- for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8) {}
-
- m = e & ((1 << (-nBits)) - 1)
- e >>= (-nBits)
- nBits += mLen
- for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8) {}
-
- if (e === 0) {
- e = 1 - eBias
- } else if (e === eMax) {
- return m ? NaN : ((s ? -1 : 1) * Infinity)
- } else {
- m = m + Math.pow(2, mLen)
- e = e - eBias
- }
- return (s ? -1 : 1) * m * Math.pow(2, e - mLen)
-}
-
-exports.write = function (buffer, value, offset, isLE, mLen, nBytes) {
- var e, m, c
- var eLen = nBytes * 8 - mLen - 1
- var eMax = (1 << eLen) - 1
- var eBias = eMax >> 1
- var rt = (mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0)
- var i = isLE ? 0 : (nBytes - 1)
- var d = isLE ? 1 : -1
- var s = value < 0 || (value === 0 && 1 / value < 0) ? 1 : 0
-
- value = Math.abs(value)
-
- if (isNaN(value) || value === Infinity) {
- m = isNaN(value) ? 1 : 0
- e = eMax
- } else {
- e = Math.floor(Math.log(value) / Math.LN2)
- if (value * (c = Math.pow(2, -e)) < 1) {
- e--
- c *= 2
- }
- if (e + eBias >= 1) {
- value += rt / c
- } else {
- value += rt * Math.pow(2, 1 - eBias)
- }
- if (value * c >= 2) {
- e++
- c /= 2
- }
-
- if (e + eBias >= eMax) {
- m = 0
- e = eMax
- } else if (e + eBias >= 1) {
- m = (value * c - 1) * Math.pow(2, mLen)
- e = e + eBias
- } else {
- m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen)
- e = 0
- }
- }
-
- for (; mLen >= 8; buffer[offset + i] = m & 0xff, i += d, m /= 256, mLen -= 8) {}
-
- e = (e << mLen) | m
- eLen += mLen
- for (; eLen > 0; buffer[offset + i] = e & 0xff, i += d, e /= 256, eLen -= 8) {}
-
- buffer[offset + i - d] |= s * 128
-}
-
-
-/***/ }),
-/* 14 */
-/***/ (function(module, exports) {
-
-var toString = {}.toString;
-
-module.exports = Array.isArray || function (arr) {
- return toString.call(arr) == '[object Array]';
-};
-
-
-/***/ }),
-/* 15 */
-/***/ (function(module, exports) {
-
-module.exports = "data:image/gif;base64,R0lGODlhCgAKAIAAAOXl5f///yH5BAAAAAAALAAAAAAKAAoAAAIRhB2ZhxoM3GMSykqd1VltzxQAOw=="
-
-/***/ }),
-/* 16 */
-/***/ (function(module, exports, __webpack_require__) {
-
-
-/* styles */
-__webpack_require__(20)
-
-var Component = __webpack_require__(4)(
- /* script */
- __webpack_require__(8),
- /* template */
- __webpack_require__(18),
- /* scopeId */
- null,
- /* cssModules */
- null
-)
-
-module.exports = Component.exports
-
-
-/***/ }),
-/* 17 */
-/***/ (function(module, exports) {
-
-module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;
- return (_vm.hasPDF) ? _c('div', {
- staticClass: "pdf-viewer"
- }, _vm._l((_vm.pages), function(page, index) {
- return _c('page', {
- key: index,
- attrs: {
- "v-if": !_vm.loading,
- "page": page,
- "number": index + 1
- }
- })
- })) : _vm._e()
-},staticRenderFns: []}
-
-/***/ }),
-/* 18 */
-/***/ (function(module, exports) {
-
-module.exports={render:function (){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;
- return _c('canvas', {
- ref: "canvas",
- staticClass: "pdf-page",
- attrs: {
- "data-page": _vm.number
- }
- })
-},staticRenderFns: []}
-
-/***/ }),
-/* 19 */
-/***/ (function(module, exports, __webpack_require__) {
-
-// style-loader: Adds some css to the DOM by adding a <style> tag
-
-// load the styles
-var content = __webpack_require__(11);
-if(typeof content === 'string') content = [[module.i, content, '']];
-if(content.locals) module.exports = content.locals;
-// add the styles to the DOM
-var update = __webpack_require__(5)("59cf066f", content, true);
-// Hot Module Replacement
-if(false) {
- // When the styles change, update the <style> tags
- if(!content.locals) {
- module.hot.accept("!!../node_modules/css-loader/index.js?minimize!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() {
- var newContent = require("!!../node_modules/css-loader/index.js?minimize!../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7c7bed7e\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue");
- if(typeof newContent === 'string') newContent = [[module.id, newContent, '']];
- update(newContent);
- });
- }
- // When the module is disposed, remove the <style> tags
- module.hot.dispose(function() { update(); });
-}
-
-/***/ }),
-/* 20 */
-/***/ (function(module, exports, __webpack_require__) {
-
-// style-loader: Adds some css to the DOM by adding a <style> tag
-
-// load the styles
-var content = __webpack_require__(12);
-if(typeof content === 'string') content = [[module.i, content, '']];
-if(content.locals) module.exports = content.locals;
-// add the styles to the DOM
-var update = __webpack_require__(5)("09f1e2d8", content, true);
-// Hot Module Replacement
-if(false) {
- // When the styles change, update the <style> tags
- if(!content.locals) {
- module.hot.accept("!!../../node_modules/css-loader/index.js?minimize!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue", function() {
- var newContent = require("!!../../node_modules/css-loader/index.js?minimize!../../node_modules/vue-loader/lib/style-compiler/index.js?{\"id\":\"data-v-7e912b1a\",\"scoped\":false,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./index.vue");
- if(typeof newContent === 'string') newContent = [[module.id, newContent, '']];
- update(newContent);
- });
- }
- // When the module is disposed, remove the <style> tags
- module.hot.dispose(function() { update(); });
-}
-
-/***/ }),
-/* 21 */
-/***/ (function(module, exports) {
-
-/**
- * Translates the list format produced by css-loader into something
- * easier to manipulate.
- */
-module.exports = function listToStyles (parentId, list) {
- var styles = []
- var newStyles = {}
- for (var i = 0; i < list.length; i++) {
- var item = list[i]
- var id = item[0]
- var css = item[1]
- var media = item[2]
- var sourceMap = item[3]
- var part = {
- id: parentId + ':' + i,
- css: css,
- media: media,
- sourceMap: sourceMap
- }
- if (!newStyles[id]) {
- styles.push(newStyles[id] = { id: id, parts: [part] })
- } else {
- newStyles[id].parts.push(part)
- }
- }
- return styles
-}
-
-
-/***/ }),
-/* 22 */
-/***/ (function(module, exports) {
-
-var g;
-
-// This works in non-strict mode
-g = (function() {
- return this;
-})();
-
-try {
- // This works if eval is allowed (see CSP)
- g = g || Function("return this")() || (1,eval)("this");
-} catch(e) {
- // This works if the window reference is available
- if(typeof window === "object")
- g = window;
-}
-
-// g can still be undefined, but nothing to do about it...
-// We return undefined, instead of nothing here, so it's
-// easier to handle this case. if(!global) { ...}
-
-module.exports = g;
-
-
-/***/ }),
-/* 23 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-var PDF = __webpack_require__(6);
-var pdfjsLib = __webpack_require__(2);
-
-module.exports = {
- install: function install(_vue) {
- var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
-
- pdfjsLib.PDFJS.workerSrc = options.workerSrc || '';
- _vue.component('pdf-lab', PDF);
- }
-};
-
-/***/ })
-/******/ ]);
-}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js
new file mode 100644
index 00000000000..f7e77de34ff
--- /dev/null
+++ b/vendor/assets/javascripts/peek.js
@@ -0,0 +1,78 @@
+(function($) {
+ var fetchRequestResults, getRequestId, peekEnabled, toggleBar, updatePerformanceBar;
+ getRequestId = function() {
+ return $('#peek').data('request-id');
+ };
+ peekEnabled = function() {
+ return $('#peek').length;
+ };
+ updatePerformanceBar = function(results) {
+ var key, label, data, table, html, tr, duration_td, sql_td, strong;
+
+ Object.keys(results.data).forEach(function(key) {
+ Object.keys(results.data[key]).forEach(function(label) {
+ data = results.data[key][label];
+
+ if (label == 'queries') {
+ table = document.createElement('table');
+
+ for (var i = 0; i < data.length; i += 1) {
+ tr = document.createElement('tr');
+ duration_td = document.createElement('td');
+ sql_td = document.createElement('td');
+ strong = document.createElement('strong');
+
+ strong.append(data[i]['duration'] + 'ms');
+ duration_td.appendChild(strong);
+ tr.appendChild(duration_td);
+
+ sql_td.appendChild(document.createTextNode(data[i]['sql']));
+ tr.appendChild(sql_td);
+
+ table.appendChild(tr);
+ }
+
+ table.className = 'table';
+ $("[data-defer-to=" + key + "-" + label + "]").html(table);
+ } else {
+ $("[data-defer-to=" + key + "-" + label + "]").text(results.data[key][label]);
+ }
+ });
+ });
+ return $(document).trigger('peek:render', [getRequestId(), results]);
+ };
+ toggleBar = function(event) {
+ var wrapper;
+ if ($(event.target).is(':input')) {
+ return;
+ }
+ if (event.which === 96 && !event.metaKey) {
+ wrapper = $('#peek');
+ if (wrapper.hasClass('disabled')) {
+ wrapper.removeClass('disabled');
+ return document.cookie = "peek=true; path=/";
+ } else {
+ wrapper.addClass('disabled');
+ return document.cookie = "peek=false; path=/";
+ }
+ }
+ };
+ fetchRequestResults = function() {
+ return $.ajax('/-/peek/results', {
+ data: {
+ request_id: getRequestId()
+ },
+ success: function(data, textStatus, xhr) {
+ return updatePerformanceBar(data);
+ },
+ error: function(xhr, textStatus, error) {}
+ });
+ };
+ $(document).on('keypress', toggleBar);
+ $(document).on('peek:update', fetchRequestResults);
+ return $(function() {
+ if (peekEnabled()) {
+ return $(this).trigger('peek:update');
+ }
+ });
+})(jQuery);
diff --git a/vendor/assets/javascripts/peek.performance_bar.js b/vendor/assets/javascripts/peek.performance_bar.js
new file mode 100644
index 00000000000..6ed86dce2f2
--- /dev/null
+++ b/vendor/assets/javascripts/peek.performance_bar.js
@@ -0,0 +1,182 @@
+var PerformanceBar, ajaxStart, renderPerformanceBar, updateStatus;
+
+PerformanceBar = (function() {
+ PerformanceBar.prototype.appInfo = null;
+
+ PerformanceBar.prototype.width = null;
+
+ PerformanceBar.formatTime = function(value) {
+ if (value >= 1000) {
+ return ((value / 1000).toFixed(3)) + "s";
+ } else {
+ return (value.toFixed(0)) + "ms";
+ }
+ };
+
+ function PerformanceBar(options) {
+ var k, v;
+ if (options == null) {
+ options = {};
+ }
+ this.el = $('#peek-view-performance-bar .performance-bar');
+ for (k in options) {
+ v = options[k];
+ this[k] = v;
+ }
+ if (this.width == null) {
+ this.width = this.el.width();
+ }
+ if (this.timing == null) {
+ this.timing = window.performance.timing;
+ }
+ }
+
+ PerformanceBar.prototype.render = function(serverTime) {
+ var networkTime, perfNetworkTime;
+ if (serverTime == null) {
+ serverTime = 0;
+ }
+ this.el.empty();
+ this.addBar('frontend', '#90d35b', 'domLoading', 'domInteractive');
+ perfNetworkTime = this.timing.responseEnd - this.timing.requestStart;
+ if (serverTime && serverTime <= perfNetworkTime) {
+ networkTime = perfNetworkTime - serverTime;
+ this.addBar('latency / receiving', '#f1faff', this.timing.requestStart + serverTime, this.timing.requestStart + serverTime + networkTime);
+ this.addBar('app', '#90afcf', this.timing.requestStart, this.timing.requestStart + serverTime, this.appInfo);
+ } else {
+ this.addBar('backend', '#c1d7ee', 'requestStart', 'responseEnd');
+ }
+ this.addBar('tcp / ssl', '#45688e', 'connectStart', 'connectEnd');
+ this.addBar('redirect', '#0c365e', 'redirectStart', 'redirectEnd');
+ this.addBar('dns', '#082541', 'domainLookupStart', 'domainLookupEnd');
+ return this.el;
+ };
+
+ PerformanceBar.prototype.isLoaded = function() {
+ return this.timing.domInteractive;
+ };
+
+ PerformanceBar.prototype.start = function() {
+ return this.timing.navigationStart;
+ };
+
+ PerformanceBar.prototype.end = function() {
+ return this.timing.domInteractive;
+ };
+
+ PerformanceBar.prototype.total = function() {
+ return this.end() - this.start();
+ };
+
+ PerformanceBar.prototype.addBar = function(name, color, start, end, info) {
+ var bar, left, offset, time, title, width;
+ if (typeof start === 'string') {
+ start = this.timing[start];
+ }
+ if (typeof end === 'string') {
+ end = this.timing[end];
+ }
+ if (!((start != null) && (end != null))) {
+ return;
+ }
+ time = end - start;
+ offset = start - this.start();
+ left = this.mapH(offset);
+ width = this.mapH(time);
+ title = name + ": " + (PerformanceBar.formatTime(time));
+ bar = $('<li></li>', {
+ 'data-title': title,
+ 'data-toggle': 'tooltip',
+ 'data-container': 'body'
+ });
+ bar.css({
+ width: width + "px",
+ left: left + "px",
+ background: color
+ });
+ return this.el.append(bar);
+ };
+
+ PerformanceBar.prototype.mapH = function(offset) {
+ return offset * (this.width / this.total());
+ };
+
+ return PerformanceBar;
+
+})();
+
+renderPerformanceBar = function() {
+ var bar, resp, span, time;
+ resp = $('#peek-server_response_time');
+ time = Math.round(resp.data('time') * 1000);
+ bar = new PerformanceBar;
+ bar.render(time);
+ span = $('<span>', {
+ 'data-toggle': 'tooltip',
+ 'data-title': 'Total navigation time for this page.',
+ 'data-container': 'body'
+ }).text(PerformanceBar.formatTime(bar.total()));
+ return updateStatus(span);
+};
+
+updateStatus = function(html) {
+ return $('#serverstats').html(html);
+};
+
+ajaxStart = null;
+
+$(document).on('pjax:start page:fetch turbolinks:request-start', function(event) {
+ return ajaxStart = event.timeStamp;
+});
+
+$(document).on('pjax:end page:load turbolinks:load', function(event, xhr) {
+ var ajaxEnd, serverTime, total;
+ if (ajaxStart == null) {
+ return;
+ }
+ ajaxEnd = event.timeStamp;
+ total = ajaxEnd - ajaxStart;
+ serverTime = xhr ? parseInt(xhr.getResponseHeader('X-Runtime')) : 0;
+ return setTimeout(function() {
+ var bar, now, span, tech;
+ now = new Date().getTime();
+ bar = new PerformanceBar({
+ timing: {
+ requestStart: ajaxStart,
+ responseEnd: ajaxEnd,
+ domLoading: ajaxEnd,
+ domInteractive: now
+ },
+ isLoaded: function() {
+ return true;
+ },
+ start: function() {
+ return ajaxStart;
+ },
+ end: function() {
+ return now;
+ }
+ });
+ bar.render(serverTime);
+ if ($.fn.pjax != null) {
+ tech = 'PJAX';
+ } else {
+ tech = 'Turbolinks';
+ }
+ span = $('<span>', {
+ 'data-toggle': 'tooltip',
+ 'data-title': tech + " navigation time",
+ 'data-container': 'body'
+ }).text(PerformanceBar.formatTime(total));
+ updateStatus(span);
+ return ajaxStart = null;
+ }, 0);
+});
+
+$(function() {
+ if (window.performance) {
+ return renderPerformanceBar();
+ } else {
+ return $('#peek-view-performance-bar').remove();
+ }
+});
diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js
deleted file mode 100644
index 9fbfef03f6d..00000000000
--- a/vendor/assets/javascripts/task_list.js
+++ /dev/null
@@ -1,258 +0,0 @@
-// The MIT License (MIT)
-//
-// Copyright (c) 2014 GitHub, Inc.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-// TaskList Behavior
-//
-/*= provides tasklist:enabled */
-/*= provides tasklist:disabled */
-/*= provides tasklist:change */
-/*= provides tasklist:changed */
-//
-//
-// Enables Task List update behavior.
-//
-// ### Example Markup
-//
-// <div class="js-task-list-container">
-// <ul class="task-list">
-// <li class="task-list-item">
-// <input type="checkbox" class="js-task-list-item-checkbox" disabled />
-// text
-// </li>
-// </ul>
-// <form>
-// <textarea class="js-task-list-field">- [ ] text</textarea>
-// </form>
-// </div>
-//
-// ### Specification
-//
-// TaskLists MUST be contained in a `(div).js-task-list-container`.
-//
-// TaskList Items SHOULD be an a list (`UL`/`OL`) element.
-//
-// Task list items MUST match `(input).task-list-item-checkbox` and MUST be
-// `disabled` by default.
-//
-// TaskLists MUST have a `(textarea).js-task-list-field` form element whose
-// `value` attribute is the source (Markdown) to be udpated. The source MUST
-// follow the syntax guidelines.
-//
-// TaskList updates trigger `tasklist:change` events. If the change is
-// successful, `tasklist:changed` is fired. The change can be canceled.
-//
-// jQuery is required.
-//
-// ### Methods
-//
-// `.taskList('enable')` or `.taskList()`
-//
-// Enables TaskList updates for the container.
-//
-// `.taskList('disable')`
-//
-// Disables TaskList updates for the container.
-//
-//# ### Events
-//
-// `tasklist:enabled`
-//
-// Fired when the TaskList is enabled.
-//
-// * **Synchronicity** Sync
-// * **Bubbles** Yes
-// * **Cancelable** No
-// * **Target** `.js-task-list-container`
-//
-// `tasklist:disabled`
-//
-// Fired when the TaskList is disabled.
-//
-// * **Synchronicity** Sync
-// * **Bubbles** Yes
-// * **Cancelable** No
-// * **Target** `.js-task-list-container`
-//
-// `tasklist:change`
-//
-// Fired before the TaskList item change takes affect.
-//
-// * **Synchronicity** Sync
-// * **Bubbles** Yes
-// * **Cancelable** Yes
-// * **Target** `.js-task-list-field`
-//
-// `tasklist:changed`
-//
-// Fired once the TaskList item change has taken affect.
-//
-// * **Synchronicity** Sync
-// * **Bubbles** Yes
-// * **Cancelable** No
-// * **Target** `.js-task-list-field`
-//
-// ### NOTE
-//
-// Task list checkboxes are rendered as disabled by default because rendered
-// user content is cached without regard for the viewer.
-(function() {
- var codeFencesPattern, complete, completePattern, disableTaskList, disableTaskLists, enableTaskList, enableTaskLists, escapePattern, incomplete, incompletePattern, itemPattern, itemsInParasPattern, updateTaskList, updateTaskListItem,
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
-
- incomplete = "[ ]";
-
- complete = "[x]";
-
- // Escapes the String for regular expression matching.
- escapePattern = function(str) {
- return str.replace(/([\[\]])/g, "\\$1").replace(/\s/, "\\s").replace("x", "[xX]");
- };
-
- incompletePattern = RegExp("" + (escapePattern(incomplete))); // escape square brackets
- // match all white space
- completePattern = RegExp("" + (escapePattern(complete))); // match all cases
-
- // Pattern used to identify all task list items.
- // Useful when you need iterate over all items.
- itemPattern = RegExp("^(?:\\s*(?:>\\s*)*(?:[-+*]|(?:\\d+\\.)))\\s*(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ")\\s+(?!\\(.*?\\))(?=(?:\\[.*?\\]\\s*(?:\\[.*?\\]|\\(.*?\\))\\s*)*(?:[^\\[]|$))");
-
- // prefix, consisting of
- // optional leading whitespace
- // zero or more blockquotes
- // list item indicator
- // optional whitespace prefix
- // checkbox
- // is followed by whitespace
- // is not part of a [foo](url) link
- // and is followed by zero or more links
- // and either a non-link or the end of the string
- // Used to filter out code fences from the source for comparison only.
- // http://rubular.com/r/x5EwZVrloI
- // Modified slightly due to issues with JS
- codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg;
-
- // ```
- // followed by optional language
- // whitespace
- // code
- // whitespace
- // ```
- // Used to filter out potential mismatches (items not in lists).
- // http://rubular.com/r/OInl6CiePy
- itemsInParasPattern = RegExp("^(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ").+$", "g");
-
- // Given the source text, updates the appropriate task list item to match the
- // given checked value.
- //
- // Returns the updated String text.
- updateTaskListItem = function(source, itemIndex, checked) {
- var clean, index, line, result;
- clean = source.replace(/\r/g, '').replace(codeFencesPattern, '').replace(itemsInParasPattern, '').split("\n");
- index = 0;
- result = (function() {
- var i, len, ref, results;
- ref = source.split("\n");
- results = [];
- for (i = 0, len = ref.length; i < len; i++) {
- line = ref[i];
- if (indexOf.call(clean, line) >= 0 && line.match(itemPattern)) {
- index += 1;
- if (index === itemIndex) {
- line = checked ? line.replace(incompletePattern, complete) : line.replace(completePattern, incomplete);
- }
- }
- results.push(line);
- }
- return results;
- })();
- return result.join("\n");
- };
-
- // Updates the $field value to reflect the state of $item.
- // Triggers the `tasklist:change` event before the value has changed, and fires
- // a `tasklist:changed` event once the value has changed.
- updateTaskList = function($item) {
- var $container, $field, checked, event, index;
- $container = $item.closest('.js-task-list-container');
- $field = $container.find('.js-task-list-field');
- index = 1 + $container.find('.task-list-item-checkbox').index($item);
- checked = $item.prop('checked');
- event = $.Event('tasklist:change');
- $field.trigger(event, [index, checked]);
- if (!event.isDefaultPrevented()) {
- $field.val(updateTaskListItem($field.val(), index, checked));
- $field.trigger('change');
- return $field.trigger('tasklist:changed', [index, checked]);
- }
- };
-
- // When the task list item checkbox is updated, submit the change
- $(document).on('change', '.task-list-item-checkbox', function() {
- return updateTaskList($(this));
- });
-
- // Enables TaskList item changes.
- enableTaskList = function($container) {
- if ($container.find('.js-task-list-field').length > 0) {
- $container.find('.task-list-item').addClass('enabled').find('.task-list-item-checkbox').attr('disabled', null);
- return $container.addClass('is-task-list-enabled').trigger('tasklist:enabled');
- }
- };
-
- // Enables a collection of TaskList containers.
- enableTaskLists = function($containers) {
- var container, i, len, results;
- results = [];
- for (i = 0, len = $containers.length; i < len; i++) {
- container = $containers[i];
- results.push(enableTaskList($(container)));
- }
- return results;
- };
-
- // Disable TaskList item changes.
- disableTaskList = function($container) {
- $container.find('.task-list-item').removeClass('enabled').find('.task-list-item-checkbox').attr('disabled', 'disabled');
- return $container.removeClass('is-task-list-enabled').trigger('tasklist:disabled');
- };
-
- // Disables a collection of TaskList containers.
- disableTaskLists = function($containers) {
- var container, i, len, results;
- results = [];
- for (i = 0, len = $containers.length; i < len; i++) {
- container = $containers[i];
- results.push(disableTaskList($(container)));
- }
- return results;
- };
-
- $.fn.taskList = function(method) {
- var $container, methods;
- $container = $(this).closest('.js-task-list-container');
- methods = {
- enable: enableTaskLists,
- disable: disableTaskLists
- };
- return methods[method || 'enable']($container);
- };
-
-}).call(this);
diff --git a/vendor/gitignore/Global/Archives.gitignore b/vendor/gitignore/Global/Archives.gitignore
index e9eda68baf2..43fd5582f91 100644
--- a/vendor/gitignore/Global/Archives.gitignore
+++ b/vendor/gitignore/Global/Archives.gitignore
@@ -5,17 +5,18 @@
*.rar
*.zip
*.gz
+*.tgz
*.bzip
*.bz2
*.xz
*.lzma
*.cab
-#packing-only formats
+# Packing-only formats
*.iso
*.tar
-#package management formats
+# Package management formats
*.dmg
*.xpi
*.gem
diff --git a/vendor/gitignore/Global/JEnv.gitignore b/vendor/gitignore/Global/JEnv.gitignore
new file mode 100644
index 00000000000..d838300ad5e
--- /dev/null
+++ b/vendor/gitignore/Global/JEnv.gitignore
@@ -0,0 +1,5 @@
+# JEnv local Java version configuration file
+.java-version
+
+# Used by previous versions of JEnv
+.jenv-version
diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore
index a5d4cc86d33..ff23445e2b0 100644
--- a/vendor/gitignore/Global/JetBrains.gitignore
+++ b/vendor/gitignore/Global/JetBrains.gitignore
@@ -19,6 +19,9 @@
.idea/**/gradle.xml
.idea/**/libraries
+# CMake
+cmake-build-debug/
+
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
diff --git a/vendor/gitignore/Global/MicrosoftOffice.gitignore b/vendor/gitignore/Global/MicrosoftOffice.gitignore
index cb891745660..0c203662d39 100644
--- a/vendor/gitignore/Global/MicrosoftOffice.gitignore
+++ b/vendor/gitignore/Global/MicrosoftOffice.gitignore
@@ -13,4 +13,4 @@
~$*.ppt*
# Visio autosave temporary files
-*.~vsdx
+*.~vsd*
diff --git a/vendor/gitignore/Global/SublimeText.gitignore b/vendor/gitignore/Global/SublimeText.gitignore
index 95ff2244c99..86c3fa455aa 100644
--- a/vendor/gitignore/Global/SublimeText.gitignore
+++ b/vendor/gitignore/Global/SublimeText.gitignore
@@ -1,16 +1,16 @@
-# cache files for sublime text
+# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
-# workspace files are user-specific
+# Workspace files are user-specific
*.sublime-workspace
-# project files should be checked into the repository, unless a significant
-# proportion of contributors will probably not be using SublimeText
+# Project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
-# sftp configuration file
+# SFTP configuration file
sftp-config.json
# Package control specific files
diff --git a/vendor/gitignore/Global/Vagrant.gitignore b/vendor/gitignore/Global/Vagrant.gitignore
index a977916f658..93987ca00ec 100644
--- a/vendor/gitignore/Global/Vagrant.gitignore
+++ b/vendor/gitignore/Global/Vagrant.gitignore
@@ -1 +1,5 @@
+# General
.vagrant/
+
+# Log files (if you are creating logs in debug mode, uncomment this)
+# *.logs
diff --git a/vendor/gitignore/Global/Vim.gitignore b/vendor/gitignore/Global/Vim.gitignore
index 42e7afc1005..6d21783d471 100644
--- a/vendor/gitignore/Global/Vim.gitignore
+++ b/vendor/gitignore/Global/Vim.gitignore
@@ -1,12 +1,14 @@
-# swap
+# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
-# session
+
+# Session
Session.vim
-# temporary
+
+# Temporary
.netrwhist
*~
-# auto-generated tag files
+# Auto-generated tag files
tags
diff --git a/vendor/gitignore/Global/Windows.gitignore b/vendor/gitignore/Global/Windows.gitignore
index ba26afd9653..dff26a9ab70 100644
--- a/vendor/gitignore/Global/Windows.gitignore
+++ b/vendor/gitignore/Global/Windows.gitignore
@@ -3,6 +3,9 @@ Thumbs.db
ehthumbs.db
ehthumbs_vista.db
+# Dump file
+*.stackdump
+
# Folder config file
Desktop.ini
diff --git a/vendor/gitignore/Global/macOS.gitignore b/vendor/gitignore/Global/macOS.gitignore
index 5972fe50f66..9d1061e8bc4 100644
--- a/vendor/gitignore/Global/macOS.gitignore
+++ b/vendor/gitignore/Global/macOS.gitignore
@@ -1,3 +1,4 @@
+# General
*.DS_Store
.AppleDouble
.LSOverride
diff --git a/vendor/gitignore/Magento.gitignore b/vendor/gitignore/Magento.gitignore
index b282f5cf547..6f1fa223992 100644
--- a/vendor/gitignore/Magento.gitignore
+++ b/vendor/gitignore/Magento.gitignore
@@ -3,14 +3,41 @@
#--------------------------#
/app/etc/local.xml
+
/media/*
!/media/.htaccess
+
+!/media/customer
+/media/customer/*
!/media/customer/.htaccess
+
+!/media/dhl
+/media/dhl/*
!/media/dhl/logo.jpg
+
+!/media/downloadable
+/media/downloadable/*
!/media/downloadable/.htaccess
+
+!/media/xmlconnect
+/media/xmlconnect/*
+
+!/media/xmlconnect/custom
+/media/xmlconnect/custom/*
!/media/xmlconnect/custom/ok.gif
+
+!/media/xmlconnect/original
+/media/xmlconnect/original/*
!/media/xmlconnect/original/ok.gif
+
+!/media/xmlconnect/system
+/media/xmlconnect/system/*
!/media/xmlconnect/system/ok.gif
+
/var/*
!/var/.htaccess
+
+!/var/package
+/var/package/*
!/var/package/*.xml
+
diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore
index ff65a437185..113294a5f18 100644
--- a/vendor/gitignore/Python.gitignore
+++ b/vendor/gitignore/Python.gitignore
@@ -8,7 +8,6 @@ __pycache__/
# Distribution / packaging
.Python
-env/
build/
develop-eggs/
dist/
@@ -43,7 +42,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
-*,cover
+*.cover
.hypothesis/
# Translations
@@ -79,19 +78,22 @@ celerybeat-schedule
# SageMath parsed files
*.sage.py
-# dotenv
+# Environments
.env
-
-# virtualenv
.venv
+env/
venv/
ENV/
# Spyder project settings
.spyderproject
+.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
+
+# mypy
+.mypy_cache/
diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore
index c7659c24f38..5fa47c5a1f2 100644
--- a/vendor/gitignore/Qt.gitignore
+++ b/vendor/gitignore/Qt.gitignore
@@ -12,6 +12,9 @@
# Qt-es
+object_script.*.Release
+object_script.*.Debug
+*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
@@ -20,11 +23,17 @@
*.qbs.user.*
*.moc
moc_*.cpp
+moc_*.h
qrc_*.cpp
ui_*.h
Makefile*
*build-*
+
+# Qt unit tests
+target_wrapper.*
+
+
# QtCreator
*.autosave
diff --git a/vendor/gitignore/SugarCRM.gitignore b/vendor/gitignore/SugarCRM.gitignore
index e9270205fd5..6a183d1c748 100644
--- a/vendor/gitignore/SugarCRM.gitignore
+++ b/vendor/gitignore/SugarCRM.gitignore
@@ -6,7 +6,7 @@
# the misuse of the repository as backup replacement.
# For development the cache directory can be safely ignored and
# therefore it is ignored.
-/cache/
+/cache/*
!/cache/index.html
# Ignore some files and directories from the custom directory.
/custom/history/
@@ -22,6 +22,6 @@
# Logs files can safely be ignored.
*.log
# Ignore the new upload directories.
-/upload/
+/upload/*
!/upload/index.html
/upload_backup/
diff --git a/vendor/gitignore/UnrealEngine.gitignore b/vendor/gitignore/UnrealEngine.gitignore
index 2f096001fec..6c6e1c327fd 100644
--- a/vendor/gitignore/UnrealEngine.gitignore
+++ b/vendor/gitignore/UnrealEngine.gitignore
@@ -54,6 +54,11 @@ Binaries/*
# Builds
Build/*
+# Whitelist PakBlacklist-<BuildConfiguration>.txt files
+!Build/*/
+Build/*/**
+!Build/*/PakBlacklist*.txt
+
# Don't ignore icon files in Build
!Build/**/*.ico
diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore
index 940794e60f2..22fd88a55a3 100644
--- a/vendor/gitignore/VisualStudio.gitignore
+++ b/vendor/gitignore/VisualStudio.gitignore
@@ -42,6 +42,9 @@ TestResult.xml
[Rr]eleasePS/
dlldata.c
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
# .NET Core
project.lock.json
project.fragment.lock.json
@@ -183,6 +186,7 @@ AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
+*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
@@ -278,6 +282,9 @@ __pycache__/
# tools/**
# !tools/packages.config
+# Tabs Studio
+*.tss
+
# Telerik's JustMock configuration file
*.jmconfig
diff --git a/vendor/gitlab-ci-yml/.gitlab-ci.yml b/vendor/gitlab-ci-yml/.gitlab-ci.yml
index 18b14554887..e2a55163682 100644
--- a/vendor/gitlab-ci-yml/.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: ruby:2.3-alpine
+image: ruby:2.4-alpine
test:
- script: ruby verify_templates.rb
+ script: ./verify_templates.rb
diff --git a/vendor/gitlab-ci-yml/Crystal.gitlab-ci.yml b/vendor/gitlab-ci-yml/Crystal.gitlab-ci.yml
index 37e44735f7c..02cfab3a5b2 100644
--- a/vendor/gitlab-ci-yml/Crystal.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Crystal.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: "crystallang/crystal:latest"
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
# services:
# - mysql:latest
# - redis:latest
diff --git a/vendor/gitlab-ci-yml/Django.gitlab-ci.yml b/vendor/gitlab-ci-yml/Django.gitlab-ci.yml
index 5ded2f5ce76..57afcbbe8b5 100644
--- a/vendor/gitlab-ci-yml/Django.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Django.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: python:latest
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
services:
- mysql:latest
- postgres:latest
diff --git a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml
index 40648bcd3de..eeefadaa019 100644
--- a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml
@@ -4,10 +4,21 @@ image: docker:latest
services:
- docker:dind
+before_script:
+ - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+
+build-master:
+ stage: build
+ script:
+ - docker build --pull -t "$CI_REGISTRY_IMAGE" .
+ - docker push "$CI_REGISTRY_IMAGE"
+ only:
+ - master
+
build:
stage: build
script:
- - export IMAGE_TAG=$(echo -en $CI_COMMIT_REF_NAME | tr -c '[:alnum:]_.-' '-')
- - docker login -u "gitlab-ci-token" -p "$CI_JOB_TOKEN" $CI_REGISTRY
- - docker build --pull -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" .
- - docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
+ - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
+ - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
+ except:
+ - master
diff --git a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml
index 981a77497e2..cf9c731637c 100644
--- a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml
@@ -2,7 +2,7 @@ image: elixir:latest
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
services:
- mysql:latest
- redis:latest
diff --git a/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml b/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml
index 0d6a6eddc97..434de4f055a 100644
--- a/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: php:latest
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
services:
- mysql:latest
diff --git a/vendor/gitlab-ci-yml/Nodejs.gitlab-ci.yml b/vendor/gitlab-ci-yml/Nodejs.gitlab-ci.yml
index e5bce3503f3..41de1458582 100644
--- a/vendor/gitlab-ci-yml/Nodejs.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Nodejs.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: node:latest
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
services:
- mysql:latest
- redis:latest
diff --git a/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml b/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml
index bc36a4e6966..7abfaf53e8e 100644
--- a/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml
@@ -3,7 +3,7 @@
#
# JBake https://jbake.org/ is a Java based, open source, static site/blog generator for developers & designers
#
-# This yml works with jBake 2.4.0
+# This yml works with jBake 2.5.1
# Feel free to change JBAKE_VERSION version
#
# HowTo at: https://jorge.aguilera.gitlab.io/howtojbake/
@@ -11,12 +11,12 @@
image: java:8
variables:
- JBAKE_VERSION: 2.4.0
+ JBAKE_VERSION: 2.5.1
# We use SDKMan as tool for managing versions
before_script:
- - apt-get update -qq && apt-get install -y -qq unzip
+ - apt-get update -qq && apt-get install -y -qq unzip zip
- curl -sSL https://get.sdkman.io | bash
- echo sdkman_auto_answer=true > /root/.sdkman/etc/config
- source /root/.sdkman/bin/sdkman-init.sh
diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
index 08b57c8c0ac..4e181e85451 100644
--- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: "ruby:2.3"
# Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
services:
- mysql:latest
- redis:latest
diff --git a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
index ae3f7405ea3..7810121c350 100644
--- a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
@@ -4,7 +4,7 @@ image: "scorpil/rust:stable"
# Optional: Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
-# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
+# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
#services:
# - mysql:latest
# - redis:latest
diff --git a/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml
index 555a51d35b9..06b0c84e516 100644
--- a/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml
@@ -28,7 +28,7 @@ canary:
- command canary
environment:
name: production
- url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_DOMAIN
when: manual
only:
- master
@@ -39,7 +39,7 @@ production:
- command deploy
environment:
name: production
- url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_DOMAIN
when: manual
only:
- master
@@ -50,7 +50,7 @@ staging:
- command deploy
environment:
name: staging
- url: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_DOMAIN
only:
- master
@@ -60,7 +60,7 @@ review:
- command deploy
environment:
name: review/$CI_COMMIT_REF_NAME
- url: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
on_stop: stop_review
only:
- branches
diff --git a/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml
index ee830ec2eb0..722934b7981 100644
--- a/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml
@@ -27,7 +27,7 @@ production:
- command deploy
environment:
name: production
- url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_DOMAIN
when: manual
only:
- master
@@ -38,7 +38,7 @@ staging:
- command deploy
environment:
name: staging
- url: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_DOMAIN
only:
- master
@@ -48,7 +48,7 @@ review:
- command deploy
environment:
name: review/$CI_COMMIT_REF_NAME
- url: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
on_stop: stop_review
only:
- branches
diff --git a/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml
index 27c9107e0d7..acba718ebe4 100644
--- a/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml
@@ -23,38 +23,32 @@ build:
production:
stage: production
- variables:
- CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME.$KUBE_DOMAIN
script:
- command deploy
environment:
name: production
- url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG.$KUBE_DOMAIN
when: manual
only:
- master
staging:
stage: staging
- variables:
- CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN
script:
- command deploy
environment:
name: staging
- url: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_DOMAIN
only:
- master
review:
stage: review
- variables:
- CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
script:
- command deploy
environment:
name: review/$CI_COMMIT_REF_NAME
- url: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
+ url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN
on_stop: stop_review
only:
- branches
diff --git a/vendor/licenses.csv b/vendor/licenses.csv
index 6441df25fe1..5beb3e5e9bf 100644
--- a/vendor/licenses.csv
+++ b/vendor/licenses.csv
@@ -1,8 +1,8 @@
RedCloth,4.3.2,MIT
-abbrev,1.0.9,ISC
+abbrev,1.1.0,ISC
accepts,1.3.3,MIT
ace-rails-ap,4.1.2,MIT
-acorn,4.0.11,MIT
+acorn,5.1.1,MIT
acorn-dynamic-import,2.0.2,MIT
acorn-jsx,3.0.1,MIT
actionmailer,4.2.8,MIT
@@ -16,7 +16,7 @@ acts-as-taggable-on,4.0.0,MIT
addressable,2.3.8,Apache 2.0
after,0.8.2,MIT
after_commit_queue,1.3.0,MIT
-ajv,4.11.5,MIT
+ajv,4.11.8,MIT
ajv-keywords,1.5.1,MIT
akismet,2.0.0,MIT
align-text,0.1.4,MIT
@@ -26,16 +26,17 @@ amdefine,1.0.1,BSD-3-Clause OR MIT
ansi-escapes,1.4.0,MIT
ansi-html,0.0.5,"Apache, Version 2.0"
ansi-regex,2.1.1,MIT
-ansi-styles,2.2.1,MIT
+ansi-styles,3.1.0,MIT
anymatch,1.3.0,ISC
append-transform,0.4.0,MIT
-aproba,1.1.1,ISC
-are-we-there-yet,1.1.2,ISC
+aproba,1.1.2,ISC
+are-we-there-yet,1.1.4,ISC
arel,6.0.4,MIT
argparse,1.0.9,MIT
arr-diff,2.0.0,MIT
-arr-flatten,1.0.1,MIT
+arr-flatten,1.1.0,MIT
array-find,1.0.0,MIT
+array-find-index,1.0.2,MIT
array-flatten,1.1.1,MIT
array-slice,0.2.3,MIT
array-union,1.0.2,MIT
@@ -43,7 +44,7 @@ array-uniq,1.0.3,MIT
array-unique,0.2.1,MIT
arraybuffer.slice,0.0.6,MIT
arrify,1.0.1,MIT
-asana,0.4.0,MIT
+asana,0.6.0,MIT
asciidoctor,1.5.3,MIT
asciidoctor-plantuml,0.0.7,MIT
asn1,0.2.3,MIT
@@ -53,6 +54,7 @@ assert-plus,0.2.0,MIT
async,0.2.10,MIT
async-each,1.0.1,MIT
asynckit,0.4.0,MIT
+atomic,1.1.99,Apache 2.0
attr_encrypted,3.0.3,MIT
attr_required,1.0.0,MIT
autoparse,0.3.3,Apache 2.0
@@ -62,26 +64,27 @@ aws-sign2,0.6.0,Apache 2.0
aws4,1.6.0,MIT
axiom-types,0.1.1,MIT
babel-code-frame,6.22.0,MIT
-babel-core,6.24.0,MIT
-babel-generator,6.24.0,MIT
-babel-helper-bindify-decorators,6.22.0,MIT
-babel-helper-builder-binary-assignment-operator-visitor,6.22.0,MIT
-babel-helper-call-delegate,6.22.0,MIT
-babel-helper-define-map,6.23.0,MIT
-babel-helper-explode-assignable-expression,6.22.0,MIT
-babel-helper-explode-class,6.22.0,MIT
-babel-helper-function-name,6.23.0,MIT
-babel-helper-get-function-arity,6.22.0,MIT
-babel-helper-hoist-variables,6.22.0,MIT
-babel-helper-optimise-call-expression,6.23.0,MIT
-babel-helper-regex,6.22.0,MIT
-babel-helper-remap-async-to-generator,6.22.0,MIT
-babel-helper-replace-supers,6.23.0,MIT
-babel-helpers,6.23.0,MIT
+babel-core,6.25.0,MIT
+babel-eslint,7.2.3,MIT
+babel-generator,6.25.0,MIT
+babel-helper-bindify-decorators,6.24.1,MIT
+babel-helper-builder-binary-assignment-operator-visitor,6.24.1,MIT
+babel-helper-call-delegate,6.24.1,MIT
+babel-helper-define-map,6.24.1,MIT
+babel-helper-explode-assignable-expression,6.24.1,MIT
+babel-helper-explode-class,6.24.1,MIT
+babel-helper-function-name,6.24.1,MIT
+babel-helper-get-function-arity,6.24.1,MIT
+babel-helper-hoist-variables,6.24.1,MIT
+babel-helper-optimise-call-expression,6.24.1,MIT
+babel-helper-regex,6.24.1,MIT
+babel-helper-remap-async-to-generator,6.24.1,MIT
+babel-helper-replace-supers,6.24.1,MIT
+babel-helpers,6.24.1,MIT
babel-loader,6.4.1,MIT
babel-messages,6.23.0,MIT
babel-plugin-check-es2015-constants,6.22.0,MIT
-babel-plugin-istanbul,4.1.1,New BSD
+babel-plugin-istanbul,4.1.4,New BSD
babel-plugin-syntax-async-functions,6.13.0,MIT
babel-plugin-syntax-async-generators,6.13.0,MIT
babel-plugin-syntax-class-properties,6.13.0,MIT
@@ -90,57 +93,57 @@ babel-plugin-syntax-dynamic-import,6.18.0,MIT
babel-plugin-syntax-exponentiation-operator,6.13.0,MIT
babel-plugin-syntax-object-rest-spread,6.13.0,MIT
babel-plugin-syntax-trailing-function-commas,6.22.0,MIT
-babel-plugin-transform-async-generator-functions,6.22.0,MIT
-babel-plugin-transform-async-to-generator,6.22.0,MIT
-babel-plugin-transform-class-properties,6.23.0,MIT
-babel-plugin-transform-decorators,6.22.0,MIT
-babel-plugin-transform-define,1.2.0,MIT
+babel-plugin-transform-async-generator-functions,6.24.1,MIT
+babel-plugin-transform-async-to-generator,6.24.1,MIT
+babel-plugin-transform-class-properties,6.24.1,MIT
+babel-plugin-transform-decorators,6.24.1,MIT
+babel-plugin-transform-define,1.3.0,MIT
babel-plugin-transform-es2015-arrow-functions,6.22.0,MIT
babel-plugin-transform-es2015-block-scoped-functions,6.22.0,MIT
-babel-plugin-transform-es2015-block-scoping,6.23.0,MIT
-babel-plugin-transform-es2015-classes,6.23.0,MIT
-babel-plugin-transform-es2015-computed-properties,6.22.0,MIT
+babel-plugin-transform-es2015-block-scoping,6.24.1,MIT
+babel-plugin-transform-es2015-classes,6.24.1,MIT
+babel-plugin-transform-es2015-computed-properties,6.24.1,MIT
babel-plugin-transform-es2015-destructuring,6.23.0,MIT
-babel-plugin-transform-es2015-duplicate-keys,6.22.0,MIT
+babel-plugin-transform-es2015-duplicate-keys,6.24.1,MIT
babel-plugin-transform-es2015-for-of,6.23.0,MIT
-babel-plugin-transform-es2015-function-name,6.22.0,MIT
+babel-plugin-transform-es2015-function-name,6.24.1,MIT
babel-plugin-transform-es2015-literals,6.22.0,MIT
-babel-plugin-transform-es2015-modules-amd,6.24.0,MIT
-babel-plugin-transform-es2015-modules-commonjs,6.24.0,MIT
-babel-plugin-transform-es2015-modules-systemjs,6.23.0,MIT
-babel-plugin-transform-es2015-modules-umd,6.24.0,MIT
-babel-plugin-transform-es2015-object-super,6.22.0,MIT
-babel-plugin-transform-es2015-parameters,6.23.0,MIT
-babel-plugin-transform-es2015-shorthand-properties,6.22.0,MIT
+babel-plugin-transform-es2015-modules-amd,6.24.1,MIT
+babel-plugin-transform-es2015-modules-commonjs,6.24.1,MIT
+babel-plugin-transform-es2015-modules-systemjs,6.24.1,MIT
+babel-plugin-transform-es2015-modules-umd,6.24.1,MIT
+babel-plugin-transform-es2015-object-super,6.24.1,MIT
+babel-plugin-transform-es2015-parameters,6.24.1,MIT
+babel-plugin-transform-es2015-shorthand-properties,6.24.1,MIT
babel-plugin-transform-es2015-spread,6.22.0,MIT
-babel-plugin-transform-es2015-sticky-regex,6.22.0,MIT
+babel-plugin-transform-es2015-sticky-regex,6.24.1,MIT
babel-plugin-transform-es2015-template-literals,6.22.0,MIT
babel-plugin-transform-es2015-typeof-symbol,6.23.0,MIT
-babel-plugin-transform-es2015-unicode-regex,6.22.0,MIT
-babel-plugin-transform-exponentiation-operator,6.22.0,MIT
+babel-plugin-transform-es2015-unicode-regex,6.24.1,MIT
+babel-plugin-transform-exponentiation-operator,6.24.1,MIT
babel-plugin-transform-object-rest-spread,6.23.0,MIT
-babel-plugin-transform-regenerator,6.22.0,MIT
-babel-plugin-transform-strict-mode,6.22.0,MIT
-babel-preset-es2015,6.24.0,MIT
-babel-preset-es2016,6.22.0,MIT
-babel-preset-es2017,6.22.0,MIT
-babel-preset-latest,6.24.0,MIT
-babel-preset-stage-2,6.22.0,MIT
-babel-preset-stage-3,6.22.0,MIT
-babel-register,6.24.0,MIT
+babel-plugin-transform-regenerator,6.24.1,MIT
+babel-plugin-transform-strict-mode,6.24.1,MIT
+babel-preset-es2015,6.24.1,MIT
+babel-preset-es2016,6.24.1,MIT
+babel-preset-es2017,6.24.1,MIT
+babel-preset-latest,6.24.1,MIT
+babel-preset-stage-2,6.24.1,MIT
+babel-preset-stage-3,6.24.1,MIT
+babel-register,6.24.1,MIT
babel-runtime,6.23.0,MIT
-babel-template,6.23.0,MIT
-babel-traverse,6.23.1,MIT
-babel-types,6.23.0,MIT
+babel-template,6.25.0,MIT
+babel-traverse,6.25.0,MIT
+babel-types,6.25.0,MIT
babosa,1.0.2,MIT
-babylon,6.16.1,MIT
+babylon,6.17.4,MIT
backo2,1.0.2,MIT
-balanced-match,0.4.2,MIT
+balanced-match,1.0.0,MIT
base32,0.3.2,MIT
base64-arraybuffer,0.1.5,MIT
-base64-js,1.2.0,MIT
+base64-js,1.2.1,MIT
base64id,1.0.0,MIT
-batch,0.5.3,MIT
+batch,0.6.1,MIT
bcrypt,3.1.11,MIT
bcrypt-pbkdf,1.0.1,New BSD
better-assert,1.0.2,MIT
@@ -150,11 +153,15 @@ bindata,2.3.5,ruby
blob,0.0.4,unknown
block-stream,0.0.9,ISC
bluebird,3.5.0,MIT
-bn.js,4.11.6,MIT
-body-parser,1.17.1,MIT
+bn.js,4.11.7,MIT
+body-parser,1.17.2,MIT
+bonjour,3.5.0,MIT
boom,2.10.1,New BSD
+bootsnap,1.1.1,MIT
bootstrap-sass,3.3.6,MIT
-brace-expansion,1.1.6,MIT
+bootstrap-sass,3.3.7,MIT
+bootstrap_form,2.7.0,MIT
+brace-expansion,1.1.8,MIT
braces,1.8.5,MIT
brorand,1.1.0,MIT
browser,2.2.0,MIT
@@ -166,6 +173,7 @@ browserify-sign,4.0.4,ISC
browserify-zlib,0.1.4,MIT
browserslist,1.7.7,MIT
buffer,4.9.1,MIT
+buffer-indexof,1.1.0,MIT
buffer-shims,1.0.0,MIT
buffer-xor,1.0.3,MIT
builder,3.2.3,MIT
@@ -176,28 +184,30 @@ caller-path,0.1.0,MIT
callsite,1.0.0,unknown
callsites,0.2.0,MIT
camelcase,1.2.1,MIT
+camelcase-keys,2.1.0,MIT
caniuse-api,1.6.1,MIT
-caniuse-db,1.0.30000649,CC-BY-4.0
-carrierwave,0.11.2,MIT
+caniuse-db,1.0.30000699,CC-BY-4.0
+carrierwave,1.1.0,MIT
caseless,0.12.0,Apache 2.0
cause,0.1,MIT
center-align,0.1.3,MIT
chalk,1.1.3,MIT
charlock_holmes,0.7.3,MIT
-chokidar,1.6.1,MIT
+chokidar,1.7.0,MIT
chronic,0.10.2,MIT
chronic_duration,0.10.6,MIT
chunky_png,1.3.5,MIT
-cipher-base,1.0.3,MIT
+cipher-base,1.0.4,MIT
circular-json,0.3.1,MIT
citrus,3.0.2,MIT
-clap,1.1.3,MIT
+clap,1.2.0,MIT
cli-cursor,1.0.2,MIT
cli-width,2.1.0,ISC
+clipboard,1.7.1,MIT
cliui,2.1.0,ISC
clone,1.0.2,MIT
co,4.6.0,MIT
-coa,1.0.1,MIT
+coa,1.0.4,MIT
code-point-at,1.1.0,MIT
coercible,1.0.0,MIT
coffee-rails,4.1.1,MIT
@@ -211,19 +221,20 @@ colormin,1.1.2,MIT
colors,1.1.2,MIT
combine-lists,1.0.1,MIT
combined-stream,1.0.5,MIT
-commander,2.9.0,MIT
+commander,2.11.0,MIT
commondir,1.0.1,MIT
component-bind,1.0.0,unknown
component-emitter,1.2.1,MIT
component-inherit,0.0.3,unknown
compressible,2.0.10,MIT
-compression,1.6.2,MIT
+compression,1.7.0,MIT
compression-webpack-plugin,0.3.2,MIT
concat-map,0.0.1,MIT
concat-stream,1.6.0,MIT
+concurrent-ruby-ext,1.0.5,MIT
config-chain,1.1.11,MIT
configstore,1.4.0,Simplified BSD
-connect,3.6.0,MIT
+connect,3.6.2,MIT
connect-history-api-fallback,1.3.0,MIT
connection_pool,2.2.1,MIT
console-browserify,1.1.0,MIT
@@ -238,21 +249,22 @@ cookie,0.3.1,MIT
cookie-signature,1.0.6,MIT
core-js,2.4.1,MIT
core-util-is,1.0.2,MIT
-cosmiconfig,2.1.1,MIT
+cosmiconfig,2.1.3,MIT
crack,0.4.3,MIT
create-ecdh,4.0.0,MIT
-create-hash,1.1.2,MIT
-create-hmac,1.1.4,MIT
+create-hash,1.1.3,MIT
+create-hmac,1.1.6,MIT
creole,0.5.0,ruby
cryptiles,2.0.5,New BSD
crypto-browserify,3.11.0,MIT
css-color-names,0.0.4,MIT
-css-loader,0.28.0,MIT
-css-selector-tokenizer,0.7.0,MIT
-css_parser,1.4.1,MIT
+css-loader,0.28.4,MIT
+css-selector-tokenizer,"",unknown
+css_parser,1.5.0,MIT
cssesc,0.1.0,MIT
cssnano,3.10.0,MIT
csso,2.3.2,MIT
+currently-unhandled,0.4.1,MIT
custom-event,1.0.1,MIT
d,1.0.0,MIT
d3,3.5.17,New BSD
@@ -260,10 +272,12 @@ d3_rails,3.5.11,MIT
dashdash,1.14.1,MIT
date-now,0.1.4,MIT
de-indent,1.0.2,MIT
-debug,2.6.3,MIT
+debug,2.6.8,MIT
+debugger-ruby_core_source,1.3.8,MIT
decamelize,1.2.0,MIT
-deckar01-task_list,1.0.6,MIT
-deep-extend,0.4.1,MIT
+deckar01-task_list,2.0.0,MIT
+deep-equal,1.0.1,MIT
+deep-extend,0.4.2,MIT
deep-is,0.1.3,MIT
default-require-extensions,1.0.0,MIT
default_value_for,3.0.2,MIT
@@ -271,27 +285,32 @@ defaults,1.0.3,MIT
defined,1.0.0,MIT
del,2.2.2,MIT
delayed-stream,1.0.0,MIT
+delegate,3.1.3,MIT
delegates,1.0.0,MIT
depd,1.1.0,MIT
des.js,1.0.0,MIT
descendants_tracker,0.0.4,MIT
destroy,1.0.4,MIT
detect-indent,4.0.0,MIT
+detect-node,2.0.3,ISC
devise,4.2.0,MIT
devise-two-factor,3.0.0,MIT
di,0.0.1,MIT
diff-lcs,1.2.5,"MIT,Perl Artistic v2,GNU GPL v2"
diffie-hellman,5.0.2,MIT
diffy,3.1.0,MIT
+dns-equal,1.0.0,MIT
+dns-packet,1.1.1,MIT
+dns-txt,2.0.2,MIT
doctrine,2.0.0,Apache 2.0
-document-register-element,1.4.1,MIT
+document-register-element,1.5.0,MIT
dom-serialize,2.2.1,MIT
dom-serializer,0.1.0,MIT
domain-browser,1.1.7,MIT
domain_name,0.5.20161021,"Simplified BSD,New BSD,Mozilla Public License 2.0"
domelementtype,1.3.0,unknown
-domhandler,2.3.0,unknown
-domutils,1.5.1,unknown
+domhandler,2.4.1,Simplified BSD
+domutils,1.6.2,Simplified BSD
doorkeeper,4.2.0,MIT
doorkeeper-openid_connect,1.1.2,MIT
dropzone,4.3.0,MIT
@@ -302,7 +321,7 @@ ecc-jsbn,0.1.1,MIT
editorconfig,0.13.2,MIT
ee-first,1.1.1,MIT
ejs,2.5.6,Apache 2.0
-electron-to-chromium,1.3.3,ISC
+electron-to-chromium,1.3.15,ISC
elliptic,6.4.0,MIT
email_reply_trimmer,0.1.6,MIT
emoji-unicode-version,0.2.1,MIT
@@ -313,14 +332,14 @@ end-of-stream,1.0.0,MIT
engine.io,1.8.3,MIT
engine.io-client,1.8.3,MIT
engine.io-parser,1.3.2,MIT
-enhanced-resolve,3.1.0,MIT
+enhanced-resolve,3.3.0,MIT
ent,2.2.0,MIT
entities,1.1.1,BSD-like
equalizer,0.0.11,MIT
errno,0.1.4,MIT
error-ex,1.3.1,MIT
erubis,2.7.0,MIT
-es5-ext,0.10.15,MIT
+es5-ext,0.10.24,MIT
es6-iterator,2.0.1,MIT
es6-map,0.1.5,MIT
es6-promise,3.0.2,MIT
@@ -334,19 +353,21 @@ escodegen,1.8.1,Simplified BSD
escope,3.6.0,Simplified BSD
eslint,3.19.0,MIT
eslint-config-airbnb-base,10.0.1,MIT
-eslint-import-resolver-node,0.2.3,MIT
-eslint-import-resolver-webpack,0.8.1,MIT
-eslint-module-utils,2.0.0,MIT
-eslint-plugin-filenames,1.1.0,MIT
-eslint-plugin-html,2.0.1,ISC
-eslint-plugin-import,2.2.0,MIT
-eslint-plugin-jasmine,2.2.0,MIT
-espree,3.4.1,Simplified BSD
+eslint-import-resolver-node,0.3.1,MIT
+eslint-import-resolver-webpack,0.8.3,MIT
+eslint-module-utils,2.1.1,MIT
+eslint-plugin-filenames,1.2.0,MIT
+eslint-plugin-html,2.0.3,ISC
+eslint-plugin-import,2.7.0,MIT
+eslint-plugin-jasmine,2.7.1,MIT
+eslint-plugin-promise,3.5.0,ISC
+espree,3.4.3,Simplified BSD
esprima,2.7.3,Simplified BSD
esquery,1.0.0,BSD
-esrecurse,4.1.0,Simplified BSD
-estraverse,4.1.1,Simplified BSD
+esrecurse,4.2.0,Simplified BSD
+estraverse,4.2.0,Simplified BSD
esutils,2.0.2,BSD
+et-orbi,1.0.3,MIT
etag,1.8.0,MIT
eve-raphael,0.5.0,Apache 2.0
event-emitter,0.3.5,MIT
@@ -355,122 +376,130 @@ eventemitter3,1.2.0,MIT
events,1.1.1,MIT
eventsource,0.1.6,MIT
evp_bytestokey,1.0.0,MIT
-excon,0.52.0,MIT
+excon,0.55.0,MIT
execjs,2.6.0,MIT
exit-hook,1.1.1,MIT
expand-braces,0.1.2,MIT
expand-brackets,0.1.5,MIT
expand-range,1.8.2,MIT
-express,4.15.2,MIT
+exports-loader,0.6.4,MIT
+express,4.15.3,MIT
expression_parser,0.9.0,MIT
-extend,3.0.0,MIT
+extend,3.0.1,MIT
extglob,0.3.2,MIT
extlib,0.9.16,MIT
-extract-zip,1.5.0,Simplified BSD
extsprintf,1.0.2,MIT
-faraday,0.9.2,MIT
-faraday_middleware,0.10.0,MIT
+faraday,0.12.1,MIT
+faraday_middleware,0.11.0.1,MIT
faraday_middleware-multi_json,0.0.6,MIT
+fast-deep-equal,1.0.0,MIT
fast-levenshtein,2.0.6,MIT
+fast_gettext,1.4.0,"MIT,ruby"
fastparse,1.1.1,MIT
faye-websocket,0.7.3,MIT
-fd-slicer,1.0.1,MIT
ffi,1.9.10,BSD
figures,1.7.0,MIT
file-entry-cache,2.0.0,MIT
-file-loader,0.11.1,MIT
-filename-regex,2.0.0,MIT
+file-loader,0.11.2,MIT
+filename-regex,2.0.1,MIT
fileset,2.0.3,MIT
filesize,3.3.0,New BSD
fill-range,2.2.3,MIT
-finalhandler,1.0.1,MIT
+finalhandler,1.0.3,MIT
find-cache-dir,0.1.1,MIT
find-root,0.1.2,MIT
-find-up,2.1.0,MIT
+find-up,1.1.2,MIT
flat-cache,1.2.2,MIT
flatten,1.0.2,MIT
+flipper,0.10.2,MIT
+flipper-active_record,0.10.2,MIT
flowdock,0.7.1,MIT
-fog-aws,0.11.0,MIT
-fog-core,1.42.0,MIT
+fog-aliyun,0.1.0,MIT
+fog-aws,0.13.0,MIT
+fog-core,1.44.1,MIT
fog-google,0.5.0,MIT
fog-json,1.0.2,MIT
fog-local,0.3.0,MIT
fog-openstack,0.1.6,MIT
fog-rackspace,0.1.1,MIT
-fog-xml,0.1.2,MIT
+fog-xml,0.1.3,MIT
font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License"
for-in,1.0.2,MIT
for-own,0.1.5,MIT
forever-agent,0.6.1,Apache 2.0
-form-data,2.1.2,MIT
+form-data,2.1.4,MIT
formatador,0.2.5,MIT
forwarded,0.1.0,MIT
fresh,0.5.0,MIT
from,0.1.7,MIT
-fs-extra,1.0.0,MIT
+fs-access,1.0.1,MIT
fs.realpath,1.0.0,ISC
-fsevents,,unknown
+fsevents,1.1.2,MIT
fstream,1.0.11,ISC
fstream-ignore,1.0.5,ISC
function-bind,1.1.0,MIT
-gauge,2.7.3,ISC
+gauge,2.7.4,ISC
gemnasium-gitlab-service,0.2.6,MIT
gemojione,3.0.1,MIT
generate-function,2.0.0,MIT
generate-object-property,1.2.0,MIT
get-caller-file,1.0.2,ISC
+get-stdin,4.0.1,MIT
get_process_mem,0.2.0,MIT
-getpass,0.1.6,MIT
-gitaly,0.5.0,MIT
+getpass,0.1.7,MIT
+gettext_i18n_rails,1.8.0,MIT
+gettext_i18n_rails_js,1.2.0,MIT
+gitaly,0.14.0,MIT
github-linguist,4.7.6,MIT
github-markup,1.4.0,MIT
gitlab-flowdock-git-hook,1.0.1,MIT
gitlab-grit,2.8.1,MIT
gitlab-markup,1.5.1,MIT
gitlab_omniauth-ldap,1.2.1,MIT
-glob,7.1.1,ISC
+glob,7.1.2,ISC
glob-base,0.3.0,MIT
glob-parent,2.0.0,ISC
globalid,0.3.7,MIT
-globals,9.17.0,MIT
+globals,9.18.0,MIT
globby,5.0.0,MIT
gollum-grit_adapter,1.0.1,MIT
gollum-lib,4.2.1,MIT
gollum-rugged_adapter,0.4.4,MIT
gon,6.1.0,MIT
+good-listener,1.2.2,MIT
google-api-client,0.8.7,Apache 2.0
google-protobuf,3.2.0.2,New BSD
googleauth,0.5.1,Apache 2.0
got,3.3.1,MIT
graceful-fs,4.1.11,ISC
-graceful-readlink,1.0.1,MIT
grape,0.19.1,MIT
grape-entity,0.6.0,MIT
-grpc,1.1.2,New BSD
+grpc,1.4.0,New BSD
gzip-size,3.0.0,MIT
hamlit,2.6.1,MIT
handle-thing,1.2.5,MIT
-handlebars,4.0.6,MIT
+handlebars,4.0.10,MIT
har-schema,1.0.5,ISC
har-validator,4.2.1,ISC
has,1.0.1,MIT
has-ansi,2.0.0,MIT
has-binary,0.1.7,MIT
has-cors,1.1.0,MIT
-has-flag,1.0.0,MIT
+has-flag,2.0.0,MIT
has-unicode,2.0.1,ISC
+hash-base,2.0.2,MIT
hash-sum,1.0.2,MIT
-hash.js,1.0.3,MIT
-hasha,2.2.0,MIT
+hash.js,1.1.3,MIT
hashie,3.5.5,MIT
+hashie-forbidden_attributes,0.1.1,MIT
hawk,3.1.3,New BSD
he,1.1.1,MIT
health_check,2.6.0,MIT
hipchat,1.5.2,MIT
-hmac-drbg,1.0.0,MIT
+hmac-drbg,1.0.1,MIT
hoek,2.16.3,New BSD
home-or-tmp,2.0.0,MIT
-hosted-git-info,2.4.1,ISC
+hosted-git-info,2.5.0,ISC
hpack.js,2.1.6,MIT
html-comment-regex,1.1.1,MIT
html-entities,1.2.0,MIT
@@ -493,12 +522,14 @@ https-browserify,0.0.1,MIT
i18n,0.8.1,MIT
ice_nine,0.11.2,MIT
iconv-lite,0.4.15,MIT
-icss-replace-symbols,1.0.2,ISC
+icss-replace-symbols,1.1.0,ISC
+icss-utils,2.1.0,ISC
ieee754,1.1.8,New BSD
-ignore,3.2.6,MIT
+ignore,3.3.3,MIT
ignore-by-default,1.0.1,ISC
immediate,3.0.6,MIT
imurmurhash,0.1.4,MIT
+indent-string,2.1.0,MIT
indexes-of,1.0.1,MIT
indexof,0.0.1,unknown
infinity-agent,2.0.3,MIT
@@ -507,9 +538,11 @@ influxdb,0.2.3,MIT
inherits,2.0.3,ISC
ini,1.3.4,ISC
inquirer,0.12.0,MIT
-interpret,1.0.2,MIT
+internal-ip,1.2.0,MIT
+interpret,1.0.3,MIT
invariant,2.2.2,New BSD
invert-kv,1.0.0,MIT
+ip,1.1.5,MIT
ipaddr.js,1.3.0,MIT
ipaddress,0.8.3,MIT
is-absolute,0.2.6,MIT
@@ -518,7 +551,8 @@ is-arrayish,0.2.1,MIT
is-binary-path,1.0.1,MIT
is-buffer,1.1.5,MIT
is-builtin-module,1.0.0,MIT
-is-dotfile,1.0.2,MIT
+is-directory,0.3.1,MIT
+is-dotfile,1.0.3,MIT
is-equal-shallow,0.1.3,MIT
is-extendable,0.1.1,MIT
is-extglob,1.0.0,MIT
@@ -550,15 +584,16 @@ isexe,2.0.0,ISC
isobject,2.1.0,MIT
isstream,0.1.2,MIT
istanbul,0.4.5,New BSD
-istanbul-api,1.1.7,New BSD
-istanbul-lib-coverage,1.0.2,New BSD
-istanbul-lib-hook,1.0.5,New BSD
-istanbul-lib-instrument,1.7.0,New BSD
-istanbul-lib-report,1.0.0,New BSD
-istanbul-lib-source-maps,1.1.1,New BSD
-istanbul-reports,1.0.2,New BSD
-jasmine-core,2.5.2,MIT
+istanbul-api,1.1.10,New BSD
+istanbul-lib-coverage,1.1.1,New BSD
+istanbul-lib-hook,1.0.7,New BSD
+istanbul-lib-instrument,1.7.3,New BSD
+istanbul-lib-report,1.1.1,New BSD
+istanbul-lib-source-maps,1.2.1,New BSD
+istanbul-reports,1.1.1,New BSD
+jasmine-core,2.6.4,MIT
jasmine-jquery,2.1.1,MIT
+jed,1.1.1,MIT
jira-ruby,1.1.2,MIT
jodid25519,1.0.2,MIT
jquery,2.2.4,MIT
@@ -566,21 +601,21 @@ jquery-atwho-rails,1.3.2,MIT
jquery-rails,4.1.1,MIT
jquery-ujs,1.2.2,MIT
js-base64,2.1.9,BSD
-js-beautify,1.6.12,MIT
+js-beautify,1.6.14,MIT
js-cookie,2.1.4,MIT
-js-tokens,3.0.1,MIT
-js-yaml,3.7.0,MIT
+js-tokens,3.0.2,MIT
+js-yaml,"",unknown
jsbn,0.1.1,MIT
jsesc,1.3.0,MIT
json,1.8.6,ruby
json-jwt,1.7.1,MIT
json-loader,0.5.4,MIT
json-schema,0.2.3,"AFLv2.1,BSD"
+json-schema-traverse,0.3.1,MIT
json-stable-stringify,1.0.1,MIT
json-stringify-safe,5.0.1,ISC
json3,3.3.2,MIT
json5,0.5.1,MIT
-jsonfile,2.4.0,MIT
jsonify,0.0.0,Public Domain
jsonpointer,4.0.1,MIT
jsprim,1.4.0,MIT
@@ -588,17 +623,15 @@ jszip,3.1.3,(MIT OR GPL-3.0)
jszip-utils,0.0.2,MIT or GPLv3
jwt,1.5.6,MIT
kaminari,0.17.0,MIT
-karma,1.6.0,MIT
+karma,1.7.0,MIT
+karma-chrome-launcher,2.2.0,MIT
karma-coverage-istanbul-reporter,0.2.3,MIT
karma-jasmine,1.1.0,MIT
karma-mocha-reporter,2.2.3,MIT
-karma-phantomjs-launcher,1.0.4,MIT
karma-sourcemap-loader,0.3.7,MIT
-karma-webpack,2.0.3,MIT
-kew,0.7.0,Apache 2.0
+karma-webpack,2.0.4,MIT
kgio,2.10.0,LGPL-2.1+
-kind-of,3.1.0,MIT
-klaw,1.3.1,MIT
+kind-of,3.2.2,MIT
kubeclient,2.2.0,MIT
latest-version,1.0.1,MIT
launchy,2.4.3,ISC
@@ -610,7 +643,8 @@ lie,3.1.1,MIT
little-plugger,1.1.4,MIT
load-json-file,1.1.0,MIT
loader-runner,2.3.0,MIT
-loader-utils,0.2.17,MIT
+loader-utils,"",unknown
+locale,2.1.2,"ruby,LGPLv3+"
locate-path,2.0.0,MIT
lodash,4.17.4,MIT
lodash._baseassign,3.2.0,MIT
@@ -623,90 +657,101 @@ lodash._isiterateecall,3.0.9,MIT
lodash._topath,3.8.1,MIT
lodash.assign,3.2.0,MIT
lodash.camelcase,4.3.0,MIT
-lodash.capitalize,4.2.1,MIT
lodash.cond,4.5.2,MIT
-lodash.deburr,4.1.0,MIT
lodash.defaults,3.1.2,MIT
-lodash.get,4.4.2,MIT
+lodash.get,3.7.0,MIT
lodash.isarguments,3.1.0,MIT
lodash.isarray,3.0.4,MIT
-lodash.kebabcase,4.0.1,MIT
+lodash.kebabcase,4.1.1,MIT
lodash.keys,3.1.2,MIT
lodash.memoize,4.1.2,MIT
lodash.restparam,3.6.1,MIT
-lodash.snakecase,4.0.1,MIT
+lodash.snakecase,4.1.1,MIT
lodash.uniq,4.5.0,MIT
-lodash.words,4.2.0,MIT
+lodash.upperfirst,4.3.1,MIT
log4js,0.6.38,Apache 2.0
-logging,2.1.0,MIT
+logging,2.2.2,MIT
longest,1.0.1,MIT
loofah,2.0.3,MIT
loose-envify,1.3.1,MIT
+loud-rejection,1.6.0,MIT
lowercase-keys,1.0.0,MIT
lru-cache,3.2.0,ISC
macaddress,0.2.8,MIT
-mail,2.6.4,MIT
+mail,2.6.5,MIT
mail_room,0.9.1,MIT
+map-obj,1.0.1,MIT
map-stream,0.1.0,unknown
-math-expression-evaluator,1.2.16,MIT
+marked,0.3.6,MIT
+math-expression-evaluator,1.2.17,MIT
media-typer,0.3.0,MIT
memoist,0.15.0,MIT
memory-fs,0.4.1,MIT
+meow,3.7.0,MIT
merge-descriptors,1.0.1,MIT
method_source,0.8.2,MIT
methods,1.1.2,MIT
micromatch,2.3.11,MIT
miller-rabin,4.0.0,MIT
-mime,1.3.4,MIT
+mime,1.3.6,MIT
mime-db,1.27.0,MIT
+mime-types,2.1.15,MIT
mime-types,2.99.3,"MIT,Artistic-2.0,GPL-2.0"
mimemagic,0.3.0,MIT
mini_portile2,2.1.0,MIT
minimalistic-assert,1.0.0,ISC
minimalistic-crypto-utils,1.0.1,MIT
-minimatch,3.0.3,ISC
+minimatch,3.0.4,ISC
minimist,0.0.8,MIT
mkdirp,0.5.1,MIT
+mmap2,2.2.7,ruby
moment,2.18.1,MIT
mousetrap,1.6.1,Apache 2.0
mousetrap-rails,1.4.6,"MIT,Apache"
-ms,0.7.2,MIT
+ms,2.0.0,MIT
+msgpack,1.1.0,Apache 2.0
multi_json,1.12.1,MIT
multi_xml,0.6.0,MIT
+multicast-dns,6.1.1,MIT
+multicast-dns-service-types,1.1.0,MIT
multipart-post,2.0.0,MIT
mustermann,0.4.0,MIT
mustermann-grape,0.4.0,MIT
mute-stream,0.0.5,ISC
-nan,2.5.1,MIT
+name-all-modules-plugin,1.0.1,MIT
+nan,2.6.2,MIT
natural-compare,1.4.0,MIT
negotiator,0.6.1,MIT
nested-error-stacks,1.0.2,MIT
net-ldap,0.12.1,MIT
net-ssh,3.0.1,MIT
netrc,0.11.0,MIT
+node-ensure,0.0.0,MIT
+node-forge,0.6.33,BSD
node-libs-browser,2.0.0,MIT
-node-pre-gyp,0.6.34,New BSD
+node-pre-gyp,0.6.36,New BSD
node-zopfli,2.0.2,MIT
nodemon,1.11.0,MIT
nokogiri,1.6.8.1,MIT
nopt,4.0.1,ISC
-normalize-package-data,2.3.6,Simplified BSD
+normalize-package-data,2.4.0,Simplified BSD
normalize-path,2.1.1,MIT
normalize-range,0.1.2,MIT
normalize-url,1.9.1,MIT
-npmlog,4.0.2,ISC
+npmlog,4.1.2,ISC
+null-check,1.0.0,MIT
num2fraction,1.2.2,MIT
number-is-nan,1.0.1,MIT
numerizer,0.1.1,MIT
oauth,0.5.1,MIT
oauth-sign,0.8.2,Apache 2.0
-oauth2,1.2.0,MIT
+oauth2,1.4.0,MIT
object-assign,4.1.1,MIT
object-component,0.0.3,unknown
object.omit,2.0.1,MIT
obuf,1.1.1,MIT
octokit,4.6.2,MIT
-oj,2.17.4,MIT
+oj,2.17.5,MIT
omniauth,1.4.2,MIT
omniauth-auth0,1.4.1,MIT
omniauth-authentiq,0.3.0,MIT
@@ -745,9 +790,10 @@ os-tmpdir,1.0.2,MIT
osenv,0.1.4,ISC
p-limit,1.1.0,MIT
p-locate,2.0.0,MIT
+p-map,1.1.1,MIT
package-json,1.2.0,MIT
pako,1.0.5,(MIT AND Zlib)
-paranoia,2.2.0,MIT
+paranoia,2.3.1,MIT
parse-asn1,5.1.0,ISC
parse-glob,3.0.4,MIT
parse-json,2.2.0,MIT
@@ -756,28 +802,35 @@ parseqs,0.0.5,MIT
parseuri,0.0.5,MIT
parseurl,1.3.1,MIT
path-browserify,0.0.0,MIT
-path-exists,3.0.0,MIT
+path-exists,2.1.0,MIT
path-is-absolute,1.0.1,MIT
path-is-inside,1.0.2,(WTFPL OR MIT)
path-parse,1.0.5,MIT
path-to-regexp,0.1.7,MIT
path-type,1.1.0,MIT
-pause-stream,0.0.11,"Apache2,MIT"
-pbkdf2,3.0.9,MIT
-pend,1.2.0,MIT
+pause-stream,0.0.11,"MIT,Apache2"
+pbkdf2,3.0.12,MIT
+pdfjs-dist,1.8.527,Apache 2.0
+peek,1.0.1,MIT
+peek-gc,0.0.2,MIT
+peek-host,1.0.0,MIT
+peek-performance_bar,1.2.1,MIT
+peek-pg,1.3.0,MIT
+peek-rblineprof,0.2.0,MIT
+peek-redis,1.2.0,MIT
+peek-sidekiq,1.0.3,MIT
performance-now,0.2.0,MIT
pg,0.18.4,"BSD,ruby,GPL"
-phantomjs-prebuilt,2.1.14,Apache 2.0
pify,2.3.0,MIT
-pikaday,1.5.1,"BSD,MIT"
+pikaday,1.6.1,(0BSD OR MIT)
pinkie,2.0.4,MIT
pinkie-promise,2.0.1,MIT
pkg-dir,1.0.0,MIT
-pkg-up,1.0.0,MIT
pluralize,1.2.1,MIT
+po_to_json,1.0.1,MIT
portfinder,1.0.13,MIT
posix-spawn,0.3.11,"MIT,LGPL"
-postcss,5.2.16,MIT
+postcss,5.2.17,MIT
postcss-calc,5.3.1,MIT
postcss-colormin,2.2.2,MIT
postcss-convert-values,2.6.1,MIT
@@ -798,10 +851,10 @@ postcss-minify-font-values,1.0.5,MIT
postcss-minify-gradients,1.0.5,MIT
postcss-minify-params,1.2.2,MIT
postcss-minify-selectors,2.1.1,MIT
-postcss-modules-extract-imports,1.0.1,ISC
-postcss-modules-local-by-default,1.1.1,MIT
-postcss-modules-scope,1.0.2,ISC
-postcss-modules-values,1.2.2,ISC
+postcss-modules-extract-imports,1.1.0,ISC
+postcss-modules-local-by-default,1.2.0,MIT
+postcss-modules-scope,1.1.0,ISC
+postcss-modules-values,1.3.0,ISC
postcss-normalize-charset,1.1.1,MIT
postcss-normalize-url,3.0.8,MIT
postcss-ordered-values,2.2.3,MIT
@@ -814,14 +867,16 @@ postcss-unique-selectors,2.0.2,MIT
postcss-value-parser,3.3.0,MIT
postcss-zindex,2.2.0,MIT
prelude-ls,1.1.2,MIT
-premailer,1.8.6,New BSD
-premailer-rails,1.9.2,MIT
+premailer,1.10.4,New BSD
+premailer-rails,1.9.7,MIT
prepend-http,1.0.4,MIT
preserve,0.2.0,MIT
+prismjs,1.6.0,MIT
private,0.1.7,MIT
-process,0.11.9,MIT
+process,0.11.10,MIT
process-nextick-args,1.0.7,MIT
progress,1.1.8,MIT
+prometheus-client-mmap,0.7.0.beta8,Apache 2.0
proto-list,1.2.4,ISC
proxy-addr,1.1.4,MIT
prr,0.0.0,MIT
@@ -833,7 +888,7 @@ pyu-ruby-sasl,0.0.3.3,MIT
q,1.5.0,MIT
qjobs,1.1.5,MIT
qs,6.4.0,New BSD
-query-string,4.3.2,MIT
+query-string,4.3.4,MIT
querystring,0.2.0,MIT
querystring-es3,0.2.1,MIT
querystringify,0.0.4,MIT
@@ -849,14 +904,16 @@ rails,4.2.8,MIT
rails-deprecated_sanitizer,1.0.3,MIT
rails-dom-testing,1.0.8,MIT
rails-html-sanitizer,1.0.3,MIT
+rails-i18n,4.0.9,MIT
railties,4.2.8,MIT
-rainbow,2.1.0,MIT
-raindrops,0.17.0,LGPL-2.1+
+rainbow,2.2.2,MIT
+raindrops,0.18.0,LGPL-2.1+
rake,10.5.0,MIT
-randomatic,1.1.6,MIT
-randombytes,2.0.3,MIT
+randomatic,1.1.7,MIT
+randombytes,2.0.5,MIT
range-parser,1.2.0,MIT
raphael,2.2.7,MIT
+raven-js,3.16.1,Simplified BSD
raw-body,2.2.0,MIT
raw-loader,0.5.1,MIT
rc,1.2.1,(BSD-2-Clause OR MIT OR Apache-2.0)
@@ -865,7 +922,7 @@ react-dev-utils,0.5.2,New BSD
read-all-stream,3.1.0,MIT
read-pkg,1.1.0,MIT
read-pkg-up,1.0.1,MIT
-readable-stream,2.0.6,MIT
+readable-stream,2.3.3,MIT
readdirp,2.1.0,MIT
readline2,1.0.1,MIT
recaptcha,3.0.0,MIT
@@ -873,7 +930,8 @@ rechoir,0.6.2,MIT
recursive-open-struct,1.0.0,MIT
recursive-readdir,2.1.1,MIT
redcarpet,3.4.0,MIT
-redis,3.2.2,MIT
+redent,1.0.0,MIT
+redis,3.3.3,MIT
redis-actionpack,5.0.1,MIT
redis-activesupport,5.0.1,MIT
redis-namespace,1.5.2,MIT
@@ -883,26 +941,25 @@ redis-store,1.2.0,MIT
reduce-css-calc,1.3.0,MIT
reduce-function-call,1.0.2,MIT
regenerate,1.3.2,MIT
-regenerator-runtime,0.10.3,MIT
-regenerator-transform,0.9.8,BSD
+regenerator-runtime,0.10.5,MIT
+regenerator-transform,0.9.11,BSD
regex-cache,0.4.3,MIT
-regexpu-core,2.0.0,MIT
+regexpu-core,"",unknown
registry-url,3.1.0,MIT
regjsgen,0.2.0,MIT
regjsparser,0.1.5,BSD
-remove-trailing-separator,1.0.1,ISC
+remove-trailing-separator,1.0.2,ISC
repeat-element,1.1.2,MIT
repeat-string,1.6.1,MIT
repeating,2.0.1,MIT
request,2.81.0,Apache 2.0
-request-progress,2.0.1,MIT
request_store,1.3.1,MIT
require-directory,2.1.1,MIT
require-from-string,1.2.1,MIT
require-main-filename,1.0.1,ISC
require-uncached,1.0.3,MIT
requires-port,1.0.0,MIT
-resolve,1.3.2,MIT
+resolve,1.3.3,MIT
resolve-from,1.0.1,MIT
responders,2.3.0,MIT
rest-client,2.0.0,MIT
@@ -911,47 +968,52 @@ retriable,1.4.1,MIT
right-align,0.1.3,MIT
rimraf,2.6.1,ISC
rinku,2.0.0,ISC
-ripemd160,1.0.1,New BSD
+ripemd160,2.0.1,MIT
rotp,2.1.2,MIT
-rouge,2.0.7,MIT
+rouge,2.1.0,MIT
rqrcode,0.7.0,MIT
rqrcode-rails3,0.1.7,MIT
ruby-fogbugz,0.2.1,MIT
ruby-prof,0.16.2,Simplified BSD
ruby-saml,1.4.1,MIT
+ruby_parser,3.9.0,MIT
rubyntlm,0.5.2,MIT
rubypants,0.2.0,BSD
-rufus-scheduler,3.1.10,MIT
+rufus-scheduler,3.4.0,MIT
rugged,0.25.1.1,MIT
run-async,0.1.0,MIT
rx-lite,3.1.2,Apache 2.0
-safe-buffer,5.0.1,MIT
+safe-buffer,5.1.1,MIT
safe_yaml,1.0.4,MIT
sanitize,2.1.0,MIT
sass,3.4.22,MIT
sass-rails,5.0.6,MIT
sawyer,0.8.1,MIT
-sax,1.2.2,ISC
+sax,1.2.4,ISC
+schema-utils,0.3.0,MIT
securecompare,1.0.0,MIT
seed-fu,2.3.6,MIT
+select,1.1.2,MIT
select-hose,2.0.0,MIT
select2,3.5.2-browserify,unknown
select2-rails,3.5.9.3,MIT
+selfsigned,1.9.1,MIT
semver,5.3.0,ISC
semver-diff,2.1.0,MIT
-send,0.15.1,MIT
-sentry-raven,2.4.0,Apache 2.0
-serve-index,1.8.0,MIT
-serve-static,1.12.1,MIT
+send,0.15.3,MIT
+sentry-raven,2.5.3,Apache 2.0
+serve-index,1.9.0,MIT
+serve-static,1.12.3,MIT
set-blocking,2.0.0,ISC
set-immediate-shim,1.0.1,MIT
setimmediate,1.0.5,MIT
setprototypeof,1.0.3,ISC
settingslogic,2.0.9,MIT
+sexp_processor,4.9.0,MIT
sha.js,2.4.8,MIT
-shelljs,0.7.7,New BSD
-sidekiq,4.2.7,LGPL
-sidekiq-cron,0.4.4,MIT
+shelljs,0.7.8,New BSD
+sidekiq,5.0.0,LGPL
+sidekiq-cron,0.6.0,MIT
sidekiq-limit_fetch,3.4.0,MIT
sigmund,1.0.1,ISC
signal-exit,3.0.2,ISC
@@ -970,17 +1032,18 @@ sockjs-client,1.0.1,MIT
sort-keys,1.1.2,MIT
source-list-map,0.1.8,MIT
source-map,0.5.6,New BSD
-source-map-support,0.4.14,MIT
+source-map-support,0.4.15,MIT
spdx-correct,1.0.2,Apache 2.0
spdx-expression-parse,1.0.4,(MIT AND CC-BY-3.0)
spdx-license-ids,1.2.2,Unlicense
-spdy,3.4.4,MIT
-spdy-transport,2.0.18,MIT
+spdy,3.4.7,MIT
+spdy-transport,2.0.20,MIT
split,0.3.3,MIT
sprintf-js,1.0.3,New BSD
sprockets,3.7.1,MIT
sprockets-rails,3.2.0,MIT
-sshpk,1.11.0,MIT
+sql.js,0.4.0,MIT
+sshpk,1.13.1,MIT
state_machines,0.4.0,MIT
state_machines-activemodel,0.4.0,MIT
state_machines-activerecord,0.4.0,MIT
@@ -988,7 +1051,7 @@ stats-webpack-plugin,0.4.3,MIT
statuses,1.3.1,MIT
stream-browserify,2.0.1,MIT
stream-combiner,0.0.4,MIT
-stream-http,2.7.0,MIT
+stream-http,2.7.2,MIT
stream-shift,1.0.0,MIT
strict-uri-encode,1.1.0,MIT
string-length,1.0.1,MIT
@@ -998,8 +1061,9 @@ stringex,2.5.2,MIT
stringstream,0.0.5,MIT
strip-ansi,3.0.1,MIT
strip-bom,2.0.0,MIT
+strip-indent,1.0.1,MIT
strip-json-comments,2.0.1,MIT
-supports-color,3.2.3,MIT
+supports-color,4.2.0,MIT
svgo,0.7.2,MIT
sys-filesystem,1.1.6,Artistic 2.0
table,3.8.3,New BSD
@@ -1007,29 +1071,32 @@ tapable,0.2.6,MIT
tar,2.2.1,ISC
tar-pack,3.4.0,Simplified BSD
temple,0.7.7,MIT
-test-exclude,4.0.3,ISC
+test-exclude,4.1.1,ISC
+text,1.3.1,MIT
text-table,0.2.0,MIT
thor,0.19.4,MIT
thread_safe,0.3.6,Apache 2.0
three,0.84.0,MIT
three-orbit-controls,82.1.0,MIT
-three-stl-loader,1.0.4,MIT
-throttleit,1.0.0,MIT
+three-stl-loader,1.0.5,MIT
through,2.3.8,MIT
+thunky,0.1.0,unknown
tilt,2.0.6,MIT
timeago.js,2.0.5,MIT
timed-out,2.0.0,MIT
timers-browserify,2.0.2,MIT
timfel-krb5-auth,0.8.3,LGPL
+tiny-emitter,2.0.1,MIT
tmp,0.0.31,MIT
to-array,0.1.4,MIT
to-arraybuffer,1.0.1,MIT
-to-fast-properties,1.0.2,MIT
+to-fast-properties,1.0.3,MIT
toml-rb,0.3.15,MIT
tool,0.2.3,MIT
touch,1.0.0,ISC
tough-cookie,2.3.2,New BSD
traverse,0.6.6,MIT
+trim-newlines,1.0.0,MIT
trim-right,1.0.1,MIT
truncato,0.7.8,MIT
tryit,1.0.3,MIT
@@ -1042,10 +1109,10 @@ typedarray,0.0.6,MIT
tzinfo,1.2.2,MIT
u2f,0.2.1,MIT
uglifier,2.7.2,MIT
-uglify-js,2.8.21,Simplified BSD
+uglify-js,2.8.29,Simplified BSD
uglify-to-browserify,1.0.2,MIT
uid-number,0.0.6,ISC
-ultron,1.0.2,MIT
+ultron,1.1.0,MIT
unc-path-regex,0.1.2,MIT
undefsafe,0.0.3,MIT / http://rem.mit-license.org
underscore,1.8.3,MIT
@@ -1060,14 +1127,15 @@ uniqs,2.0.0,MIT
unpipe,1.0.0,MIT
update-notifier,0.5.0,Simplified BSD
url,0.11.0,MIT
+url-loader,0.5.9,MIT
url-parse,1.0.5,MIT
url_safe_base64,0.2.2,MIT
user-home,2.0.0,MIT
-useragent,2.1.13,MIT
+useragent,2.2.0,MIT
util,0.10.3,MIT
util-deprecate,1.0.2,MIT
utils-merge,1.0.0,MIT
-uuid,3.0.1,MIT
+uuid,3.1.0,MIT
validate-npm-package-license,3.0.1,Apache 2.0
validates_hostname,1.0.6,MIT
vary,1.1.1,MIT
@@ -1079,20 +1147,20 @@ visibilityjs,1.2.4,MIT
vm-browserify,0.0.4,MIT
vmstat,2.3.0,MIT
void-elements,2.0.1,MIT
-vue,2.2.6,MIT
-vue-hot-reload-api,2.0.11,MIT
+vue,2.3.4,MIT
+vue-hot-reload-api,2.1.0,MIT
vue-loader,11.3.4,MIT
vue-resource,0.9.3,MIT
vue-style-loader,2.0.5,MIT
-vue-template-compiler,2.2.6,MIT
-vue-template-es2015-compiler,1.5.2,MIT
+vue-template-compiler,2.3.4,MIT
+vue-template-es2015-compiler,1.5.3,MIT
warden,1.2.6,MIT
watchpack,1.3.1,MIT
wbuf,1.7.2,MIT
-webpack,2.3.3,MIT
-webpack-bundle-analyzer,2.3.1,MIT
-webpack-dev-middleware,1.10.1,MIT
-webpack-dev-server,2.4.2,MIT
+webpack,2.6.1,MIT
+webpack-bundle-analyzer,2.8.2,MIT
+webpack-dev-middleware,1.11.0,MIT
+webpack-dev-server,2.5.1,MIT
webpack-rails,0.9.10,MIT
webpack-sources,0.1.5,MIT
websocket-driver,0.6.5,MIT
@@ -1100,22 +1168,23 @@ websocket-extensions,0.1.1,MIT
whet.extend,0.9.9,MIT
which,1.2.14,ISC
which-module,1.0.0,ISC
-wide-align,1.1.0,ISC
+wide-align,1.1.2,ISC
wikicloth,0.8.1,MIT
window-size,0.1.0,MIT
-wordwrap,1.0.0,MIT
+wordwrap,0.0.2,MIT/X11
+worker-loader,0.8.1,MIT
wrap-ansi,2.1.0,MIT
wrappy,1.0.2,ISC
write,0.2.1,MIT
-write-file-atomic,1.3.1,ISC
-ws,1.1.2,MIT
+write-file-atomic,1.3.4,ISC
+ws,2.3.1,MIT
wtf-8,1.0.0,MIT
xdg-basedir,2.0.0,MIT
+xml-simple,1.1.5,ruby
xmlhttprequest-ssl,1.5.3,MIT
xtend,4.0.1,MIT
y18n,3.2.1,ISC
yallist,2.1.2,ISC
yargs,3.10.0,MIT
yargs-parser,4.2.1,ISC
-yauzl,2.4.1,MIT
yeast,0.1.2,MIT
diff --git a/vendor/project_templates/express.tar.gz b/vendor/project_templates/express.tar.gz
new file mode 100644
index 00000000000..6353f6605d5
--- /dev/null
+++ b/vendor/project_templates/express.tar.gz
Binary files differ
diff --git a/vendor/project_templates/rails.tar.gz b/vendor/project_templates/rails.tar.gz
new file mode 100644
index 00000000000..0509016f130
--- /dev/null
+++ b/vendor/project_templates/rails.tar.gz
Binary files differ
diff --git a/vendor/project_templates/spring.tar.gz b/vendor/project_templates/spring.tar.gz
new file mode 100644
index 00000000000..d7c0ab74d01
--- /dev/null
+++ b/vendor/project_templates/spring.tar.gz
Binary files differ
diff --git a/yarn.lock b/yarn.lock
index 8f38fb4a9a4..5fc28f8b5ba 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.3:
version "4.0.4"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a"
@@ -33,18 +33,22 @@ 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.4:
- version "4.0.11"
- resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0"
+acorn@^5.0.0, acorn@^5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d"
after@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-ajv-keywords@^1.0.0, ajv-keywords@^1.1.1:
+ajv-keywords@^1.0.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
+ajv-keywords@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0"
+
ajv@^4.7.0:
version "4.11.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.2.tgz#f166c3c11cbc6cb9dcc102a5bcfe5b72c95287e6"
@@ -52,6 +56,15 @@ ajv@^4.7.0:
co "^4.6.0"
json-stable-stringify "^1.0.1"
+ajv@^5.1.5:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.0.tgz#c1735024c5da2ef75cc190713073d44f098bf486"
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^0.1.0"
+ json-schema-traverse "^0.3.0"
+ json-stable-stringify "^1.0.1"
+
align-text@^0.1.1, align-text@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
@@ -128,6 +141,10 @@ arr-flatten@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b"
+array-find-index@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
+
array-find@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-find/-/array-find-1.0.0.tgz#6c8e286d11ed768327f8e62ecee87353ca3e78b8"
@@ -136,6 +153,10 @@ array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+array-flatten@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296"
+
array-slice@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5"
@@ -233,6 +254,13 @@ aws4@^1.2.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
+axios@^0.16.2:
+ version "0.16.2"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d"
+ dependencies:
+ follow-redirects "^1.2.3"
+ is-buffer "^1.1.5"
+
babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0:
version "6.22.0"
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4"
@@ -265,6 +293,15 @@ babel-core@^6.22.1, babel-core@^6.23.0:
slash "^1.0.0"
source-map "^0.5.0"
+babel-eslint@^7.2.1:
+ version "7.2.1"
+ resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-7.2.1.tgz#079422eb73ba811e3ca0865ce87af29327f8c52f"
+ dependencies:
+ babel-code-frame "^6.22.0"
+ babel-traverse "^6.23.1"
+ babel-types "^6.23.0"
+ babylon "^6.16.1"
+
babel-generator@^6.18.0, babel-generator@^6.23.0:
version "6.23.0"
resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.23.0.tgz#6b8edab956ef3116f79d8c84c5a3c05f32a74bc5"
@@ -396,14 +433,13 @@ babel-helpers@^6.23.0:
babel-runtime "^6.22.0"
babel-template "^6.23.0"
-babel-loader@^6.2.10:
- version "6.2.10"
- resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-6.2.10.tgz#adefc2b242320cd5d15e65b31cea0e8b1b02d4b0"
+babel-loader@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.1.tgz#b87134c8b12e3e4c2a94e0546085bc680a2b8488"
dependencies:
- find-cache-dir "^0.1.1"
- loader-utils "^0.2.11"
+ find-cache-dir "^1.0.0"
+ loader-utils "^1.0.2"
mkdirp "^0.5.1"
- object-assign "^4.0.1"
babel-messages@^6.23.0:
version "6.23.0"
@@ -816,9 +852,9 @@ babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.22.0, babel-types@^6.23
lodash "^4.2.0"
to-fast-properties "^1.0.1"
-babylon@^6.11.0, babylon@^6.13.0, babylon@^6.15.0:
- version "6.15.0"
- resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e"
+babylon@^6.11.0, babylon@^6.13.0, babylon@^6.15.0, babylon@^6.16.1:
+ version "6.16.1"
+ resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.16.1.tgz#30c5a22f481978a9e7f8cdfdf496b11d94b404d3"
backo2@1.0.2:
version "1.0.2"
@@ -874,6 +910,10 @@ block-stream@*:
dependencies:
inherits "~2.0.0"
+bluebird@^2.10.2:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
+
bluebird@^3.0.5, bluebird@^3.1.1, bluebird@^3.3.0:
version "3.4.7"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
@@ -882,20 +922,31 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
version "4.11.6"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.6.tgz#53344adb14617a13f6e8dd2ce28905d1c0ba3215"
-body-parser@^1.12.4:
- version "1.16.0"
- resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.16.0.tgz#924a5e472c6229fb9d69b85a20d5f2532dec788b"
+body-parser@^1.16.1:
+ version "1.17.2"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.17.2.tgz#f8892abc8f9e627d42aedafbca66bf5ab99104ee"
dependencies:
bytes "2.4.0"
content-type "~1.0.2"
- debug "2.6.0"
+ debug "2.6.7"
depd "~1.1.0"
- http-errors "~1.5.1"
+ http-errors "~1.6.1"
iconv-lite "0.4.15"
on-finished "~2.3.0"
- qs "6.2.1"
+ qs "6.4.0"
raw-body "~2.2.0"
- type-is "~1.6.14"
+ type-is "~1.6.15"
+
+bonjour@^3.5.0:
+ version "3.5.0"
+ resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5"
+ dependencies:
+ array-flatten "^2.1.0"
+ deep-equal "^1.0.1"
+ dns-equal "^1.0.0"
+ dns-txt "^2.0.2"
+ multicast-dns "^6.0.1"
+ multicast-dns-service-types "^1.1.0"
boom@2.x.x:
version "2.10.1"
@@ -990,6 +1041,10 @@ browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6:
caniuse-db "^1.0.30000639"
electron-to-chromium "^1.2.7"
+buffer-indexof@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.0.tgz#f54f647c4f4e25228baa656a2e57e43d5f270982"
+
buffer-shims@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51"
@@ -1036,14 +1091,29 @@ callsites@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca"
+camelcase-keys@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
+ dependencies:
+ camelcase "^2.0.0"
+ map-obj "^1.0.0"
+
camelcase@^1.0.2:
version "1.2.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
+camelcase@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
+
camelcase@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
+camelcase@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+
caniuse-api@^1.5.2:
version "1.6.1"
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c"
@@ -1093,6 +1163,21 @@ chokidar@^1.4.1, chokidar@^1.4.3, chokidar@^1.6.0:
optionalDependencies:
fsevents "^1.0.0"
+chokidar@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
+ dependencies:
+ anymatch "^1.3.0"
+ async-each "^1.0.0"
+ glob-parent "^2.0.0"
+ inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^2.0.0"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ optionalDependencies:
+ fsevents "^1.0.0"
+
cipher-base@^1.0.0, cipher-base@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.3.tgz#eeabf194419ce900da3018c207d212f2a6df0a07"
@@ -1265,14 +1350,6 @@ concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-concat-stream@1.5.0:
- version "1.5.0"
- resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.0.tgz#53f7d43c51c5e43f81c8fdd03321c631be68d611"
- dependencies:
- inherits "~2.0.1"
- readable-stream "~2.0.0"
- typedarray "~0.0.5"
-
concat-stream@^1.4.6:
version "1.6.0"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7"
@@ -1305,12 +1382,12 @@ connect-history-api-fallback@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.3.0.tgz#e51d17f8f0ef0db90a64fdb47de3051556e9f169"
-connect@^3.3.5:
- version "3.5.0"
- resolved "https://registry.yarnpkg.com/connect/-/connect-3.5.0.tgz#b357525a0b4c1f50599cd983e1d9efeea9677198"
+connect@^3.6.0:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.2.tgz#694e8d20681bfe490282c8ab886be98f09f42fe7"
dependencies:
- debug "~2.2.0"
- finalhandler "0.5.0"
+ debug "2.6.7"
+ finalhandler "1.0.3"
parseurl "~1.3.1"
utils-merge "1.0.0"
@@ -1358,6 +1435,19 @@ cookie@0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+copy-webpack-plugin@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.0.1.tgz#9728e383b94316050d0c7463958f2b85c0aa8200"
+ dependencies:
+ bluebird "^2.10.2"
+ fs-extra "^0.26.4"
+ glob "^6.0.4"
+ is-glob "^3.1.0"
+ loader-utils "^0.2.15"
+ lodash "^4.3.0"
+ minimatch "^3.0.0"
+ node-dir "^0.1.10"
+
core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e"
@@ -1404,6 +1494,20 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
create-hash "^1.1.0"
inherits "^2.0.1"
+cropper@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/cropper/-/cropper-2.3.0.tgz#607461d4e7aa7a7fe15a26834b14b7f0c2801562"
+ dependencies:
+ jquery ">= 1.9.1"
+
+cross-spawn@^5.0.1:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+ dependencies:
+ lru-cache "^4.0.1"
+ shebang-command "^1.2.0"
+ which "^1.2.9"
+
cryptiles@2.x.x:
version "2.0.5"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
@@ -1510,6 +1614,12 @@ csso@~2.3.1:
clap "^1.0.9"
source-map "^0.5.3"
+currently-unhandled@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
+ dependencies:
+ array-find-index "^1.0.1"
+
custom-event@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
@@ -1518,7 +1628,13 @@ d3@^3.5.11:
version "3.5.11"
resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.11.tgz#d130750eed0554db70e8432102f920a12407b69c"
-d@^0.1.1, d@~0.1.1:
+d@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
+ dependencies:
+ es5-ext "^0.10.9"
+
+d@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309"
dependencies:
@@ -1538,10 +1654,6 @@ de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
-debug@0.7.4:
- version "0.7.4"
- resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
-
debug@2.2.0, debug@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da"
@@ -1554,16 +1666,36 @@ debug@2.3.3:
dependencies:
ms "0.7.2"
-debug@2.6.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0:
- version "2.6.0"
- resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b"
+debug@2.6.7:
+ version "2.6.7"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e"
dependencies:
- ms "0.7.2"
+ ms "2.0.0"
+
+debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.4.5, debug@^2.6.6, debug@^2.6.8:
+ version "2.6.8"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
+ dependencies:
+ ms "2.0.0"
decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+deckar01-task_list@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.0.tgz#7f7a595430d21b3036ed5dfbf97d6b65de18e2c9"
+
+decompress-response@^3.2.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
+ dependencies:
+ mimic-response "^1.0.0"
+
+deep-equal@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+
deep-extend@~0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253"
@@ -1600,6 +1732,17 @@ del@^2.0.2:
pinkie-promise "^2.0.0"
rimraf "^2.2.8"
+del@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
+ dependencies:
+ globby "^6.1.0"
+ is-path-cwd "^1.0.0"
+ is-path-in-cwd "^1.0.0"
+ p-map "^1.1.1"
+ pify "^3.0.0"
+ rimraf "^2.2.8"
+
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@@ -1612,7 +1755,7 @@ delegates@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-depd@~1.1.0:
+depd@1.1.0, depd@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3"
@@ -1645,6 +1788,23 @@ diffie-hellman@^5.0.0:
miller-rabin "^4.0.0"
randombytes "^2.0.0"
+dns-equal@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
+
+dns-packet@^1.0.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.1.1.tgz#2369d45038af045f3898e6fa56862aed3f40296c"
+ dependencies:
+ ip "^1.1.0"
+ safe-buffer "^5.0.1"
+
+dns-txt@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6"
+ dependencies:
+ buffer-indexof "^1.0.0"
+
doctrine@1.5.0, doctrine@^1.2.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
@@ -1701,6 +1861,10 @@ dropzone@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/dropzone/-/dropzone-4.2.0.tgz#fbe7acbb9918e0706489072ef663effeef8a79f3"
+duplexer3@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+
duplexer@^0.1.1, duplexer@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
@@ -1733,7 +1897,7 @@ ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-ejs@^2.5.5:
+ejs@^2.5.6:
version "2.5.6"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88"
@@ -1768,9 +1932,9 @@ end-of-stream@1.0.0:
dependencies:
once "~1.3.0"
-engine.io-client@1.8.2:
- version "1.8.2"
- resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-1.8.2.tgz#c38767547f2a7d184f5752f6f0ad501006703766"
+engine.io-client@1.8.3:
+ version "1.8.3"
+ resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-1.8.3.tgz#1798ed93451246453d4c6f635d7a201fe940d5ab"
dependencies:
component-emitter "1.2.1"
component-inherit "0.0.3"
@@ -1781,7 +1945,7 @@ engine.io-client@1.8.2:
parsejson "0.0.3"
parseqs "0.0.5"
parseuri "0.0.5"
- ws "1.1.1"
+ ws "1.1.2"
xmlhttprequest-ssl "1.5.3"
yeast "0.1.2"
@@ -1796,25 +1960,25 @@ engine.io-parser@1.3.2:
has-binary "0.1.7"
wtf-8 "1.0.0"
-engine.io@1.8.2:
- version "1.8.2"
- resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.2.tgz#6b59be730b348c0125b0a4589de1c355abcf7a7e"
+engine.io@1.8.3:
+ version "1.8.3"
+ resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4"
dependencies:
accepts "1.3.3"
base64id "1.0.0"
cookie "0.3.1"
debug "2.3.3"
engine.io-parser "1.3.2"
- ws "1.1.1"
+ ws "1.1.2"
-enhanced-resolve@^3.0.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.1.0.tgz#9f4b626f577245edcf4b2ad83d86e17f4f421dec"
+enhanced-resolve@^3.4.0:
+ version "3.4.1"
+ resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
dependencies:
graceful-fs "^4.1.2"
memory-fs "^0.4.0"
object-assign "^4.0.1"
- tapable "^0.2.5"
+ tapable "^0.2.7"
enhanced-resolve@~0.9.0:
version "0.9.1"
@@ -1844,56 +2008,52 @@ error-ex@^1.2.0:
dependencies:
is-arrayish "^0.2.1"
-es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7:
- version "0.10.12"
- resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047"
+es5-ext@^0.10.14, es5-ext@^0.10.8, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2:
+ version "0.10.24"
+ resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.24.tgz#a55877c9924bc0c8d9bd3c2cbe17495ac1709b14"
dependencies:
es6-iterator "2"
es6-symbol "~3.1"
-es6-iterator@2:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac"
+es6-iterator@2, es6-iterator@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512"
dependencies:
- d "^0.1.1"
- es5-ext "^0.10.7"
- es6-symbol "3"
+ d "1"
+ es5-ext "^0.10.14"
+ es6-symbol "^3.1"
es6-map@^0.1.3:
- version "0.1.4"
- resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897"
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0"
dependencies:
- d "~0.1.1"
- es5-ext "~0.10.11"
- es6-iterator "2"
- es6-set "~0.1.3"
- es6-symbol "~3.1.0"
- event-emitter "~0.3.4"
+ d "1"
+ es5-ext "~0.10.14"
+ es6-iterator "~2.0.1"
+ es6-set "~0.1.5"
+ es6-symbol "~3.1.1"
+ event-emitter "~0.3.5"
es6-promise@^3.0.2, es6-promise@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6"
-es6-promise@~4.0.3:
- version "4.0.5"
- resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.0.5.tgz#7882f30adde5b240ccfa7f7d78c548330951ae42"
-
-es6-set@~0.1.3:
- version "0.1.4"
- resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8"
+es6-set@~0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
dependencies:
- d "~0.1.1"
- es5-ext "~0.10.11"
- es6-iterator "2"
- es6-symbol "3"
- event-emitter "~0.3.4"
+ d "1"
+ es5-ext "~0.10.14"
+ es6-iterator "~2.0.1"
+ es6-symbol "3.1.1"
+ event-emitter "~0.3.5"
-es6-symbol@3, es6-symbol@~3.1, es6-symbol@~3.1.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa"
+es6-symbol@3, es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@~3.1, es6-symbol@~3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77"
dependencies:
- d "~0.1.1"
- es5-ext "~0.10.11"
+ d "1"
+ es5-ext "~0.10.14"
es6-weak-map@^2.0.1:
version "2.0.1"
@@ -1944,12 +2104,12 @@ eslint-import-resolver-node@^0.2.0:
object-assign "^4.0.1"
resolve "^1.1.6"
-eslint-import-resolver-webpack@^0.8.1:
- version "0.8.1"
- resolved "https://registry.yarnpkg.com/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.8.1.tgz#c7f8b4d5bd3c5b489457e5728c5db1c4ffbac9aa"
+eslint-import-resolver-webpack@^0.8.3:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.8.3.tgz#ad61e28df378a474459d953f246fd43f92675385"
dependencies:
array-find "^1.0.0"
- debug "^2.2.0"
+ debug "^2.6.8"
enhanced-resolve "~0.9.0"
find-root "^0.1.1"
has "^1.0.1"
@@ -2081,20 +2241,20 @@ esutils@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
-etag@~1.7.0:
- version "1.7.0"
- resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8"
+etag@~1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051"
eve-raphael@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30"
-event-emitter@~0.3.4:
- version "0.3.4"
- resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5"
+event-emitter@~0.3.5:
+ version "0.3.5"
+ resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
dependencies:
- d "~0.1.1"
- es5-ext "~0.10.7"
+ d "1"
+ es5-ext "~0.10.14"
event-stream@~3.3.0:
version "3.3.4"
@@ -2128,6 +2288,18 @@ evp_bytestokey@^1.0.0:
dependencies:
create-hash "^1.1.1"
+execa@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+ dependencies:
+ cross-spawn "^5.0.1"
+ get-stream "^3.0.0"
+ is-stream "^1.1.0"
+ npm-run-path "^2.0.0"
+ p-finally "^1.0.0"
+ signal-exit "^3.0.0"
+ strip-eof "^1.0.0"
+
exit-hook@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -2159,9 +2331,16 @@ expand-range@^1.8.1:
dependencies:
fill-range "^2.1.0"
-express@^4.13.3, express@^4.14.1:
- version "4.14.1"
- resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33"
+exports-loader@^0.6.4:
+ version "0.6.4"
+ resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886"
+ dependencies:
+ loader-utils "^1.0.2"
+ source-map "0.5.x"
+
+express@^4.13.3, express@^4.15.2:
+ version "4.15.3"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.15.3.tgz#bab65d0f03aa80c358408972fc700f916944b662"
dependencies:
accepts "~1.3.3"
array-flatten "1.1.1"
@@ -2169,26 +2348,28 @@ express@^4.13.3, express@^4.14.1:
content-type "~1.0.2"
cookie "0.3.1"
cookie-signature "1.0.6"
- debug "~2.2.0"
+ debug "2.6.7"
depd "~1.1.0"
encodeurl "~1.0.1"
escape-html "~1.0.3"
- etag "~1.7.0"
- finalhandler "0.5.1"
- fresh "0.3.0"
+ etag "~1.8.0"
+ finalhandler "~1.0.3"
+ fresh "0.5.0"
merge-descriptors "1.0.1"
methods "~1.1.2"
on-finished "~2.3.0"
parseurl "~1.3.1"
path-to-regexp "0.1.7"
- proxy-addr "~1.1.3"
- qs "6.2.0"
+ proxy-addr "~1.1.4"
+ qs "6.4.0"
range-parser "~1.2.0"
- send "0.14.2"
- serve-static "~1.11.2"
- type-is "~1.6.14"
+ send "0.15.3"
+ serve-static "1.12.3"
+ setprototypeof "1.0.3"
+ statuses "~1.3.1"
+ type-is "~1.6.15"
utils-merge "1.0.0"
- vary "~1.1.0"
+ vary "~1.1.1"
extend@^3.0.0, extend@~3.0.0:
version "3.0.0"
@@ -2200,19 +2381,14 @@ extglob@^0.3.1:
dependencies:
is-extglob "^1.0.0"
-extract-zip@~1.5.0:
- version "1.5.0"
- resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.5.0.tgz#92ccf6d81ef70a9fa4c1747114ccef6d8688a6c4"
- dependencies:
- concat-stream "1.5.0"
- debug "0.7.4"
- mkdirp "0.5.0"
- yauzl "2.4.1"
-
extsprintf@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550"
+fast-deep-equal@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-0.1.0.tgz#5c6f4599aba6b333ee3342e2ed978672f1001f8d"
+
fast-levenshtein@~2.0.4:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
@@ -2239,12 +2415,6 @@ faye-websocket@~0.7.3:
dependencies:
websocket-driver ">=0.3.6"
-fd-slicer@~1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65"
- dependencies:
- pend "~1.2.0"
-
figures@^1.3.5:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@@ -2280,9 +2450,9 @@ filesize@3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.3.0.tgz#53149ea3460e3b2e024962a51648aa572cf98122"
-filesize@^3.5.4:
- version "3.5.4"
- resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.4.tgz#742fc7fb6aef4ee3878682600c22f840731e1fda"
+filesize@^3.5.9:
+ version "3.5.10"
+ resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.10.tgz#fc8fa23ddb4ef9e5e0ab6e1e64f679a24a56761f"
fill-range@^2.1.0:
version "2.2.3"
@@ -2294,33 +2464,25 @@ fill-range@^2.1.0:
repeat-element "^1.1.2"
repeat-string "^1.5.2"
-finalhandler@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.0.tgz#e9508abece9b6dba871a6942a1d7911b91911ac7"
- dependencies:
- debug "~2.2.0"
- escape-html "~1.0.3"
- on-finished "~2.3.0"
- statuses "~1.3.0"
- unpipe "~1.0.0"
-
-finalhandler@0.5.1:
- version "0.5.1"
- resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.1.tgz#2c400d8d4530935bc232549c5fa385ec07de6fcd"
+finalhandler@1.0.3, finalhandler@~1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.3.tgz#ef47e77950e999780e86022a560e3217e0d0cc89"
dependencies:
- debug "~2.2.0"
+ debug "2.6.7"
+ encodeurl "~1.0.1"
escape-html "~1.0.3"
on-finished "~2.3.0"
+ parseurl "~1.3.1"
statuses "~1.3.1"
unpipe "~1.0.0"
-find-cache-dir@^0.1.1:
- version "0.1.1"
- resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-0.1.1.tgz#c8defae57c8a52a8a784f9e31c57c742e993a0b9"
+find-cache-dir@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f"
dependencies:
commondir "^1.0.1"
- mkdirp "^0.5.1"
- pkg-dir "^1.0.0"
+ make-dir "^1.0.0"
+ pkg-dir "^2.0.0"
find-root@^0.1.1:
version "0.1.2"
@@ -2333,7 +2495,7 @@ find-up@^1.0.0:
path-exists "^2.0.0"
pinkie-promise "^2.0.0"
-find-up@^2.1.0:
+find-up@^2.0.0, find-up@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
dependencies:
@@ -2352,6 +2514,12 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
+follow-redirects@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.3.tgz#01abaeca85e3609837d9fcda3167a7e42fdaca21"
+ dependencies:
+ debug "^2.4.5"
+
for-in@^0.1.5:
version "0.1.6"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8"
@@ -2378,21 +2546,29 @@ forwarded@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363"
-fresh@0.3.0:
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f"
+fresh@0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e"
from@~0:
version "0.1.7"
resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
-fs-extra@~1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-1.0.0.tgz#cd3ce5f7e7cb6145883fcae3191e9877f8587950"
+fs-access@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/fs-access/-/fs-access-1.0.1.tgz#d6a87f262271cefebec30c553407fb995da8777a"
+ dependencies:
+ null-check "^1.0.0"
+
+fs-extra@^0.26.4:
+ version "0.26.7"
+ resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.26.7.tgz#9ae1fdd94897798edab76d0918cf42d0c3184fa9"
dependencies:
graceful-fs "^4.1.2"
jsonfile "^2.1.0"
klaw "^1.0.0"
+ path-is-absolute "^1.0.0"
+ rimraf "^2.2.8"
fs.realpath@^1.0.0:
version "1.0.0"
@@ -2454,6 +2630,14 @@ get-caller-file@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
+get-stdin@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
+
+get-stream@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+
getpass@^0.1.1:
version "0.1.6"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6"
@@ -2483,6 +2667,16 @@ glob@^5.0.15:
once "^1.3.0"
path-is-absolute "^1.0.0"
+glob@^6.0.4:
+ version "6.0.4"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+ dependencies:
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "2 || 3"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
@@ -2509,6 +2703,16 @@ globby@^5.0.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
+globby@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
+ dependencies:
+ array-union "^1.0.1"
+ glob "^7.0.3"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+
good-listener@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
@@ -2530,6 +2734,25 @@ got@^3.2.0:
read-all-stream "^3.0.0"
timed-out "^2.0.0"
+got@^7.0.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
+ dependencies:
+ decompress-response "^3.2.0"
+ duplexer3 "^0.1.4"
+ get-stream "^3.0.0"
+ is-plain-obj "^1.1.0"
+ is-retry-allowed "^1.0.0"
+ is-stream "^1.0.0"
+ isurl "^1.0.0-alpha5"
+ lowercase-keys "^1.0.0"
+ p-cancelable "^0.3.0"
+ p-timeout "^1.1.1"
+ safe-buffer "^5.0.1"
+ timed-out "^4.0.0"
+ url-parse-lax "^1.0.0"
+ url-to-options "^1.0.1"
+
graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
@@ -2587,6 +2810,20 @@ has-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
+has-flag@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
+
+has-symbol-support-x@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.3.0.tgz#588bd6927eaa0e296afae24160659167fc2be4f8"
+
+has-to-string-tag-x@^1.2.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.3.0.tgz#78e3d98c3c0ec9413e970eb8d766249a1e13058f"
+ dependencies:
+ has-symbol-support-x "^1.3.0"
+
has-unicode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
@@ -2607,13 +2844,6 @@ hash.js@^1.0.0:
dependencies:
inherits "^2.0.1"
-hasha@~2.2.0:
- version "2.2.0"
- resolved "https://registry.yarnpkg.com/hasha/-/hasha-2.2.0.tgz#78d7cbfc1e6d66303fe79837365984517b2f6ee1"
- dependencies:
- is-stream "^1.0.1"
- pinkie-promise "^2.0.0"
-
hawk@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4"
@@ -2674,7 +2904,7 @@ http-deceiver@^1.2.4:
version "1.2.7"
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-http-errors@~1.5.0, http-errors@~1.5.1:
+http-errors@~1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.5.1.tgz#788c0d2c1de2c81b9e6e8c01843b6b97eb920750"
dependencies:
@@ -2682,6 +2912,15 @@ http-errors@~1.5.0, http-errors@~1.5.1:
setprototypeof "1.0.2"
statuses ">= 1.3.1 < 2"
+http-errors@~1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.1.tgz#5f8b8ed98aca545656bf572997387f904a722257"
+ dependencies:
+ depd "1.1.0"
+ inherits "2.0.3"
+ setprototypeof "1.0.3"
+ statuses ">= 1.3.1 < 2"
+
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"
@@ -2734,10 +2973,23 @@ immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
+imports-loader@^0.7.1:
+ version "0.7.1"
+ resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.7.1.tgz#f204b5f34702a32c1db7d48d89d5e867a0441253"
+ dependencies:
+ loader-utils "^1.0.2"
+ source-map "^0.5.6"
+
imurmurhash@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+indent-string@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
+ dependencies:
+ repeating "^2.0.0"
+
indexes-of@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
@@ -2787,6 +3039,12 @@ inquirer@^0.12.0:
strip-ansi "^3.0.0"
through "^2.3.6"
+internal-ip@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c"
+ dependencies:
+ meow "^3.3.0"
+
interpret@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c"
@@ -2801,9 +3059,13 @@ invert-kv@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
-ipaddr.js@1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.2.0.tgz#8aba49c9192799585bdd643e0ccb50e8ae777ba4"
+ip@^1.1.0:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+
+ipaddr.js@1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.3.0.tgz#1e03a52fdad83a8bbb2b25cbf4998b4cffcd3dec"
is-absolute-url@^2.0.0:
version "2.1.0"
@@ -2826,9 +3088,9 @@ is-binary-path@^1.0.0:
dependencies:
binary-extensions "^1.0.0"
-is-buffer@^1.0.2:
- version "1.1.4"
- resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b"
+is-buffer@^1.0.2, is-buffer@^1.1.5:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc"
is-builtin-module@^1.0.0:
version "1.0.0"
@@ -2909,6 +3171,10 @@ is-number@^2.0.2, is-number@^2.1.0:
dependencies:
kind-of "^3.0.2"
+is-object@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
+
is-path-cwd@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
@@ -2925,7 +3191,7 @@ is-path-inside@^1.0.0:
dependencies:
path-is-inside "^1.0.1"
-is-plain-obj@^1.0.0:
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
@@ -2957,7 +3223,11 @@ is-resolvable@^1.0.0:
dependencies:
tryit "^1.0.1"
-is-stream@^1.0.0, is-stream@^1.0.1:
+is-retry-allowed@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34"
+
+is-stream@^1.0.0, is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
@@ -3094,14 +3364,25 @@ istanbul@^0.4.5:
which "^1.1.1"
wordwrap "^1.0.0"
-jasmine-core@^2.5.2:
- version "2.5.2"
- resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.5.2.tgz#6f61bd79061e27f43e6f9355e44b3c6cab6ff297"
+isurl@^1.0.0-alpha5:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
+ dependencies:
+ has-to-string-tag-x "^1.2.0"
+ is-object "^1.0.1"
+
+jasmine-core@^2.6.3:
+ version "2.6.3"
+ resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.6.3.tgz#45072950e4a42b1e322fe55c001100a465d77815"
jasmine-jquery@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/jasmine-jquery/-/jasmine-jquery-2.1.1.tgz#d4095e646944a26763235769ab018d9f30f0d47b"
+jed@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4"
+
jodid25519@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967"
@@ -3114,7 +3395,7 @@ jquery-ujs@^1.2.1:
dependencies:
jquery ">=1.8.0"
-jquery@>=1.8.0, jquery@^2.2.1:
+"jquery@>= 1.9.1", jquery@>=1.8.0, jquery@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-2.2.1.tgz#3c3e16854ad3d2ac44ac65021b17426d22ad803f"
@@ -3169,6 +3450,10 @@ json-loader@^0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.4.tgz#8baa1365a632f58a3c46d20175fc6002c96e37de"
+json-schema-traverse@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.0.tgz#0016c0b1ca1efe46d44d37541bcdfc19dcfae0db"
+
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
@@ -3179,7 +3464,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1:
dependencies:
jsonify "~0.0.0"
-json-stringify-safe@~5.0.1:
+json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@@ -3187,7 +3472,7 @@ json3@3.3.2, json3@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
-json5@^0.5.0:
+json5@^0.5.0, json5@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
@@ -3227,6 +3512,13 @@ jszip@^3.1.3:
pako "~1.0.2"
readable-stream "~2.0.6"
+karma-chrome-launcher@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.1.1.tgz#216879c68ac04d8d5140e99619ba04b59afd46cf"
+ dependencies:
+ fs-access "^1.0.0"
+ which "^1.2.1"
+
karma-coverage-istanbul-reporter@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-0.2.0.tgz#5766263338adeb0026f7e4ac7a89a5f056c5642c"
@@ -3243,22 +3535,15 @@ karma-mocha-reporter@^2.2.2:
dependencies:
chalk "1.1.3"
-karma-phantomjs-launcher@^1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/karma-phantomjs-launcher/-/karma-phantomjs-launcher-1.0.2.tgz#19e1041498fd75563ed86730a22c1fe579fa8fb1"
- dependencies:
- lodash "^4.0.1"
- phantomjs-prebuilt "^2.1.7"
-
karma-sourcemap-loader@^0.3.7:
version "0.3.7"
resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.7.tgz#91322c77f8f13d46fed062b042e1009d4c4505d8"
dependencies:
graceful-fs "^4.1.2"
-karma-webpack@^2.0.2:
- version "2.0.2"
- resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.2.tgz#bd38350af5645c9644090770939ebe7ce726f864"
+karma-webpack@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.4.tgz#3e2d4f48ba94a878e1c66bb8e1ae6128987a175b"
dependencies:
async "~0.9.0"
loader-utils "^0.2.5"
@@ -3266,16 +3551,16 @@ karma-webpack@^2.0.2:
source-map "^0.1.41"
webpack-dev-middleware "^1.0.11"
-karma@^1.4.1:
- version "1.4.1"
- resolved "https://registry.yarnpkg.com/karma/-/karma-1.4.1.tgz#41981a71d54237606b0a3ea8c58c90773f41650e"
+karma@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/karma/-/karma-1.7.0.tgz#6f7a1a406446fa2e187ec95398698f4cee476269"
dependencies:
bluebird "^3.3.0"
- body-parser "^1.12.4"
+ body-parser "^1.16.1"
chokidar "^1.4.1"
colors "^1.1.0"
combine-lists "^1.0.0"
- connect "^3.3.5"
+ connect "^3.6.0"
core-js "^2.2.0"
di "^0.0.1"
dom-serialize "^2.2.0"
@@ -3287,20 +3572,16 @@ karma@^1.4.1:
lodash "^3.8.0"
log4js "^0.6.31"
mime "^1.3.4"
- minimatch "^3.0.0"
+ minimatch "^3.0.2"
optimist "^0.6.1"
qjobs "^1.1.4"
range-parser "^1.2.0"
- rimraf "^2.3.3"
+ rimraf "^2.6.0"
safe-buffer "^5.0.1"
- socket.io "1.7.2"
+ socket.io "1.7.3"
source-map "^0.5.3"
- tmp "0.0.28"
- useragent "^2.1.10"
-
-kew@~0.7.0:
- version "0.7.0"
- resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b"
+ tmp "0.0.31"
+ useragent "^2.1.12"
kind-of@^3.0.2:
version "3.1.0"
@@ -3353,11 +3634,20 @@ load-json-file@^1.0.0:
pinkie-promise "^2.0.0"
strip-bom "^2.0.0"
+load-json-file@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
+ dependencies:
+ graceful-fs "^4.1.2"
+ parse-json "^2.2.0"
+ pify "^2.0.0"
+ strip-bom "^3.0.0"
+
loader-runner@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
-loader-utils@^0.2.11, loader-utils@^0.2.16, loader-utils@^0.2.5:
+loader-utils@^0.2.15, loader-utils@^0.2.5:
version "0.2.16"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.16.tgz#f08632066ed8282835dff88dfb52704765adee6d"
dependencies:
@@ -3522,7 +3812,7 @@ lodash@^3.8.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
-lodash@^4.0.0, lodash@^4.0.1, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0:
+lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
@@ -3533,6 +3823,10 @@ log4js@^0.6.31:
readable-stream "~1.0.2"
semver "~4.3.3"
+loglevel@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.4.1.tgz#95b383f91a3c2756fd4ab093667e4309161f2bcd"
+
longest@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
@@ -3543,6 +3837,13 @@ loose-envify@^1.0.0:
dependencies:
js-tokens "^3.0.0"
+loud-rejection@^1.0.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
+ dependencies:
+ currently-unhandled "^0.4.1"
+ signal-exit "^3.0.0"
+
lowercase-keys@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
@@ -3568,6 +3869,16 @@ macaddress@^0.2.8:
version "0.2.8"
resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12"
+make-dir@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978"
+ dependencies:
+ pify "^2.3.0"
+
+map-obj@^1.0.0, map-obj@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+
map-stream@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"
@@ -3584,6 +3895,12 @@ media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+mem@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
+ dependencies:
+ mimic-fn "^1.0.0"
+
memory-fs@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290"
@@ -3595,6 +3912,21 @@ memory-fs@^0.4.0, memory-fs@~0.4.1:
errno "^0.1.3"
readable-stream "^2.0.1"
+meow@^3.3.0:
+ version "3.7.0"
+ resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
+ dependencies:
+ camelcase-keys "^2.0.0"
+ decamelize "^1.1.2"
+ loud-rejection "^1.0.0"
+ map-obj "^1.0.1"
+ minimist "^1.1.3"
+ normalize-package-data "^2.3.4"
+ object-assign "^4.0.1"
+ read-pkg-up "^1.0.1"
+ redent "^1.0.0"
+ trim-newlines "^1.0.0"
+
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -3628,20 +3960,28 @@ miller-rabin@^4.0.0:
bn.js "^4.0.0"
brorand "^1.0.1"
-"mime-db@>= 1.24.0 < 2", mime-db@~1.26.0:
- version "1.26.0"
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.26.0.tgz#eaffcd0e4fc6935cf8134da246e2e6c35305adff"
+"mime-db@>= 1.24.0 < 2", mime-db@~1.27.0:
+ version "1.27.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
-mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7:
- version "2.1.14"
- resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.14.tgz#f7ef7d97583fcaf3b7d282b6f8b5679dab1e94ee"
+mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7:
+ version "2.1.15"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed"
dependencies:
- mime-db "~1.26.0"
+ mime-db "~1.27.0"
-mime@1.3.4, mime@^1.3.4:
+mime@1.3.4, mime@1.3.x, mime@^1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
+mimic-fn@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
+
+mimic-response@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.0.tgz#df3d3652a73fded6b9b0b24146e6fd052353458e"
+
minimalistic-assert@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3"
@@ -3656,16 +3996,10 @@ minimist@0.0.8, minimist@~0.0.1:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-minimist@^1.2.0:
+minimist@^1.1.3, minimist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-mkdirp@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.0.tgz#1d73076a6df986cd9344e15e71fcc05a4c9abf12"
- dependencies:
- minimist "0.0.8"
-
mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
@@ -3676,6 +4010,10 @@ moment@2.x:
version "2.17.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82"
+monaco-editor@0.8.3:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6"
+
mousetrap@^1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a"
@@ -3688,10 +4026,29 @@ ms@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+
+multicast-dns-service-types@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"
+
+multicast-dns@^6.0.1:
+ version "6.1.1"
+ resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.1.1.tgz#6e7de86a570872ab17058adea7160bbeca814dde"
+ dependencies:
+ dns-packet "^1.0.1"
+ thunky "^0.1.0"
+
mute-stream@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"
+name-all-modules-plugin@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/name-all-modules-plugin/-/name-all-modules-plugin-1.0.1.tgz#0abfb6ad835718b9fb4def0674e06657a954375c"
+
nan@^2.0.0, nan@^2.3.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2"
@@ -3710,6 +4067,16 @@ nested-error-stacks@^1.0.0:
dependencies:
inherits "~2.0.1"
+node-dir@^0.1.10:
+ version "0.1.17"
+ resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
+ dependencies:
+ minimatch "^3.0.2"
+
+node-forge@0.6.33:
+ version "0.6.33"
+ resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc"
+
node-libs-browser@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-1.1.1.tgz#2a38243abedd7dffcd07a97c9aca5668975a6fea"
@@ -3816,7 +4183,7 @@ nopt@~1.0.10:
dependencies:
abbrev "1"
-normalize-package-data@^2.3.2:
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
version "2.3.5"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.3.5.tgz#8d924f142960e1777e7ffe170543631cc7cb02df"
dependencies:
@@ -3842,6 +4209,12 @@ normalize-url@^1.4.0:
query-string "^4.1.0"
sort-keys "^1.0.0"
+npm-run-path@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+ dependencies:
+ path-key "^2.0.0"
+
npmlog@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f"
@@ -3851,6 +4224,10 @@ npmlog@^4.0.1:
gauge "~2.7.1"
set-blocking "~2.0.0"
+null-check@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/null-check/-/null-check-1.0.0.tgz#977dffd7176012b9ec30d2a39db5cf72a0439edd"
+
num2fraction@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
@@ -3916,7 +4293,7 @@ onetime@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
-opener@^1.4.2:
+opener@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8"
@@ -3969,6 +4346,14 @@ os-locale@^1.4.0:
dependencies:
lcid "^1.0.0"
+os-locale@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
+ dependencies:
+ execa "^0.7.0"
+ lcid "^1.0.0"
+ mem "^1.1.0"
+
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@@ -3980,6 +4365,14 @@ osenv@^0.1.0:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
+p-cancelable@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
+
+p-finally@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+
p-limit@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc"
@@ -3990,6 +4383,16 @@ p-locate@^2.0.0:
dependencies:
p-limit "^1.1.0"
+p-map@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.1.1.tgz#05f5e4ae97a068371bc2a5cc86bfbdbc19c4ae7a"
+
+p-timeout@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.0.tgz#9820f99434c5817868b4f34809ee5291660d5b6c"
+ dependencies:
+ p-finally "^1.0.0"
+
package-json@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/package-json/-/package-json-1.2.0.tgz#c8ecac094227cdf76a316874ed05e27cc939a0e0"
@@ -4074,6 +4477,10 @@ path-is-inside@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+path-key@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+
path-parse@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1"
@@ -4090,6 +4497,12 @@ path-type@^1.0.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
+path-type@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
+ dependencies:
+ pify "^2.0.0"
+
pause-stream@0.0.11:
version "0.0.11"
resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
@@ -4102,28 +4515,14 @@ pbkdf2@^3.0.3:
dependencies:
create-hmac "^1.1.2"
-pend@~1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
-
-phantomjs-prebuilt@^2.1.7:
- version "2.1.14"
- resolved "https://registry.yarnpkg.com/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.14.tgz#d53d311fcfb7d1d08ddb24014558f1188c516da0"
- dependencies:
- es6-promise "~4.0.3"
- extract-zip "~1.5.0"
- fs-extra "~1.0.0"
- hasha "~2.2.0"
- kew "~0.7.0"
- progress "~1.1.8"
- request "~2.79.0"
- request-progress "~2.0.1"
- which "~1.2.10"
-
-pify@^2.0.0:
+pify@^2.0.0, pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+pify@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+
pikaday@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
@@ -4146,6 +4545,12 @@ pkg-dir@^1.0.0:
dependencies:
find-up "^1.0.0"
+pkg-dir@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
+ dependencies:
+ find-up "^2.1.0"
+
pkg-up@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-1.0.0.tgz#3e08fb461525c4421624a33b9f7e6d0af5b05a26"
@@ -4429,7 +4834,7 @@ prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
-prepend-http@^1.0.0:
+prepend-http@^1.0.0, prepend-http@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
@@ -4455,7 +4860,7 @@ process@^0.11.0, process@~0.11.0:
version "0.11.9"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.9.tgz#7bd5ad21aa6253e7da8682264f1e11d11c0318c1"
-progress@^1.1.8, progress@~1.1.8:
+progress@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
@@ -4463,12 +4868,12 @@ proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
-proxy-addr@~1.1.3:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.3.tgz#dc97502f5722e888467b3fa2297a7b1ff47df074"
+proxy-addr@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.4.tgz#27e545f6960a44a627d9b44467e35c1b6b4ce2f3"
dependencies:
forwarded "~0.1.0"
- ipaddr.js "1.2.0"
+ ipaddr.js "1.3.0"
prr@~0.0.0:
version "0.0.0"
@@ -4510,13 +4915,9 @@ qjobs@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73"
-qs@6.2.0:
- version "6.2.0"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b"
-
-qs@6.2.1:
- version "6.2.1"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625"
+qs@6.4.0:
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
qs@~6.3.0:
version "6.3.0"
@@ -4541,6 +4942,10 @@ querystringify@0.0.x:
version "0.0.4"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c"
+querystringify@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb"
+
randomatic@^1.1.3:
version "1.1.6"
resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.6.tgz#110dcabff397e9dcff7c0789ccc0a49adf1ec5bb"
@@ -4562,6 +4967,12 @@ raphael@^2.2.7:
dependencies:
eve-raphael "0.5.0"
+raven-js@^3.14.0:
+ version "3.14.0"
+ resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.14.0.tgz#94dda81d975fdc4a42f193db437cf70021d654e0"
+ dependencies:
+ json-stringify-safe "^5.0.1"
+
raw-body@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96"
@@ -4612,6 +5023,13 @@ read-pkg-up@^1.0.1:
find-up "^1.0.0"
read-pkg "^1.0.0"
+read-pkg-up@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
+ dependencies:
+ find-up "^2.0.0"
+ read-pkg "^2.0.0"
+
read-pkg@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
@@ -4620,6 +5038,14 @@ read-pkg@^1.0.0:
normalize-package-data "^2.3.2"
path-type "^1.0.0"
+read-pkg@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8"
+ dependencies:
+ load-json-file "^2.0.0"
+ normalize-package-data "^2.3.2"
+ path-type "^2.0.0"
+
readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", 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"
@@ -4632,7 +5058,7 @@ readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
-readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@~2.0.0, readable-stream@~2.0.6:
+readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
dependencies:
@@ -4693,6 +5119,13 @@ recursive-readdir@2.1.1:
dependencies:
minimatch "3.0.3"
+redent@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
+ dependencies:
+ indent-string "^2.1.0"
+ strip-indent "^1.0.1"
+
reduce-css-calc@^1.2.6:
version "1.3.0"
resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716"
@@ -4786,13 +5219,7 @@ repeating@^2.0.0:
dependencies:
is-finite "^1.0.0"
-request-progress@~2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-2.0.1.tgz#5d36bb57961c673aa5b788dbc8141fdf23b44e08"
- dependencies:
- throttleit "^1.0.0"
-
-request@^2.79.0, request@~2.79.0:
+request@^2.79.0:
version "2.79.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de"
dependencies:
@@ -4865,7 +5292,13 @@ right-align@^0.1.1:
dependencies:
align-text "^0.1.1"
-rimraf@2, rimraf@^2.2.8, rimraf@^2.3.3, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@~2.5.1, rimraf@~2.5.4:
+rimraf@2, rimraf@^2.2.8, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@^2.6.0:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d"
+ dependencies:
+ glob "^7.0.5"
+
+rimraf@~2.5.1, rimraf@~2.5.4:
version "2.5.4"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.5.4.tgz#96800093cbf1a0c86bd95b4625467535c29dfa04"
dependencies:
@@ -4885,7 +5318,7 @@ rx-lite@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
-safe-buffer@^5.0.1:
+safe-buffer@^5.0.1, safe-buffer@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
@@ -4905,6 +5338,12 @@ select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
+selfsigned@^1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.9.1.tgz#cdda4492d70d486570f87c65546023558e1dfa5a"
+ dependencies:
+ node-forge "0.6.33"
+
semver-diff@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
@@ -4919,20 +5358,20 @@ semver@~4.3.3:
version "4.3.6"
resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da"
-send@0.14.2:
- version "0.14.2"
- resolved "https://registry.yarnpkg.com/send/-/send-0.14.2.tgz#39b0438b3f510be5dc6f667a11f71689368cdeef"
+send@0.15.3:
+ version "0.15.3"
+ resolved "https://registry.yarnpkg.com/send/-/send-0.15.3.tgz#5013f9f99023df50d1bd9892c19e3defd1d53309"
dependencies:
- debug "~2.2.0"
+ debug "2.6.7"
depd "~1.1.0"
destroy "~1.0.4"
encodeurl "~1.0.1"
escape-html "~1.0.3"
- etag "~1.7.0"
- fresh "0.3.0"
- http-errors "~1.5.1"
+ etag "~1.8.0"
+ fresh "0.5.0"
+ http-errors "~1.6.1"
mime "1.3.4"
- ms "0.7.2"
+ ms "2.0.0"
on-finished "~2.3.0"
range-parser "~1.2.0"
statuses "~1.3.1"
@@ -4949,14 +5388,14 @@ serve-index@^1.7.2:
mime-types "~2.1.11"
parseurl "~1.3.1"
-serve-static@~1.11.2:
- version "1.11.2"
- resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.2.tgz#2cf9889bd4435a320cc36895c9aa57bd662e6ac7"
+serve-static@1.12.3:
+ version "1.12.3"
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.3.tgz#9f4ba19e2f3030c547f8af99107838ec38d5b1e2"
dependencies:
encodeurl "~1.0.1"
escape-html "~1.0.3"
parseurl "~1.3.1"
- send "0.14.2"
+ send "0.15.3"
set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0"
@@ -4974,12 +5413,26 @@ setprototypeof@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.2.tgz#81a552141ec104b88e89ce383103ad5c66564d08"
+setprototypeof@1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
+
sha.js@^2.3.6:
version "2.4.8"
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f"
dependencies:
inherits "^2.0.1"
+shebang-command@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+ dependencies:
+ shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+
shelljs@^0.7.5:
version "0.7.6"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.6.tgz#379cccfb56b91c8601e4793356eb5382924de9ad"
@@ -5021,15 +5474,15 @@ socket.io-adapter@0.5.0:
debug "2.3.3"
socket.io-parser "2.3.1"
-socket.io-client@1.7.2:
- version "1.7.2"
- resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.2.tgz#39fdb0c3dd450e321b7e40cfd83612ec533dd644"
+socket.io-client@1.7.3:
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.3.tgz#b30e86aa10d5ef3546601c09cde4765e381da377"
dependencies:
backo2 "1.0.2"
component-bind "1.0.0"
component-emitter "1.2.1"
debug "2.3.3"
- engine.io-client "1.8.2"
+ engine.io-client "1.8.3"
has-binary "0.1.7"
indexof "0.0.1"
object-component "0.0.3"
@@ -5046,16 +5499,16 @@ socket.io-parser@2.3.1:
isarray "0.0.1"
json3 "3.3.2"
-socket.io@1.7.2:
- version "1.7.2"
- resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.2.tgz#83bbbdf2e79263b378900da403e7843e05dc3b71"
+socket.io@1.7.3:
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b"
dependencies:
debug "2.3.3"
- engine.io "1.8.2"
+ engine.io "1.8.3"
has-binary "0.1.7"
object-assign "4.1.0"
socket.io-adapter "0.5.0"
- socket.io-client "1.7.2"
+ socket.io-client "1.7.3"
socket.io-parser "2.3.1"
sockjs-client@1.0.1:
@@ -5069,16 +5522,16 @@ sockjs-client@1.0.1:
json3 "^3.3.2"
url-parse "^1.0.1"
-sockjs-client@1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.2.tgz#f0212a8550e4c9468c8cceaeefd2e3493c033ad5"
+sockjs-client@1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.4.tgz#5babe386b775e4cf14e7520911452654016c8b12"
dependencies:
- debug "^2.2.0"
+ debug "^2.6.6"
eventsource "0.1.6"
faye-websocket "~0.11.0"
inherits "^2.0.1"
json3 "^3.3.2"
- url-parse "^1.1.1"
+ url-parse "^1.1.8"
sockjs@0.3.18:
version "0.3.18"
@@ -5097,9 +5550,9 @@ source-list-map@^0.1.7, 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"
-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@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085"
source-map-support@^0.4.2:
version "0.4.11"
@@ -5107,6 +5560,10 @@ source-map-support@^0.4.2:
dependencies:
source-map "^0.5.3"
+source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
+
source-map@^0.1.41:
version "0.1.43"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346"
@@ -5119,10 +5576,6 @@ source-map@^0.4.4:
dependencies:
amdefine ">=0.0.4"
-source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3:
- version "0.5.6"
- resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
-
source-map@~0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d"
@@ -5173,6 +5626,10 @@ sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+sql.js@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445"
+
sshpk@^1.7.0:
version "1.10.2"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa"
@@ -5188,11 +5645,7 @@ sshpk@^1.7.0:
jsbn "~0.1.0"
tweetnacl "~0.14.0"
-stats-webpack-plugin@^0.4.3:
- version "0.4.3"
- resolved "https://registry.yarnpkg.com/stats-webpack-plugin/-/stats-webpack-plugin-0.4.3.tgz#b2f618202f28dd04ab47d7ecf54ab846137b7aea"
-
-"statuses@>= 1.3.1 < 2", statuses@~1.3.0, statuses@~1.3.1:
+"statuses@>= 1.3.1 < 2", statuses@~1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e"
@@ -5272,6 +5725,16 @@ strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+strip-eof@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+
+strip-indent@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
+ dependencies:
+ get-stdin "^4.0.1"
+
strip-json-comments@~1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91"
@@ -5294,6 +5757,12 @@ supports-color@^3.1.0, supports-color@^3.1.1, supports-color@^3.1.2, supports-co
dependencies:
has-flag "^1.0.0"
+supports-color@^4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.1.tgz#65a4bb2631e90e02420dba5554c375a4754bb836"
+ dependencies:
+ has-flag "^2.0.0"
+
svgo@^0.7.0:
version "0.7.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"
@@ -5321,9 +5790,9 @@ tapable@^0.1.8:
version "0.1.10"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.1.10.tgz#29c35707c2b70e50d07482b5d202e8ed446dafd4"
-tapable@^0.2.5, tapable@~0.2.5:
- version "0.2.6"
- resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.6.tgz#206be8e188860b514425375e6f1ae89bfb01fd8d"
+tapable@^0.2.7:
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.7.tgz#e46c0daacbb2b8a98b9b0cea0f4052105817ed5c"
tar-pack@~3.3.0:
version "3.3.0"
@@ -5372,14 +5841,14 @@ three@^0.84.0:
version "0.84.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918"
-throttleit@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
-
through@2, through@^2.3.6, through@~2.3, through@~2.3.1:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+thunky@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e"
+
timeago.js@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/timeago.js/-/timeago.js-2.0.5.tgz#730c74fbdb0b0917a553675a4460e3a7f80db86c"
@@ -5388,6 +5857,10 @@ timed-out@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a"
+timed-out@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+
timers-browserify@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-1.4.2.tgz#c9c58b575be8407375cb5e2462dacee74359f41d"
@@ -5404,9 +5877,9 @@ tiny-emitter@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.1.0.tgz#ab405a21ffed814a76c19739648093d70654fecb"
-tmp@0.0.28, tmp@0.0.x:
- version "0.0.28"
- resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120"
+tmp@0.0.31, tmp@0.0.x:
+ version "0.0.31"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7"
dependencies:
os-tmpdir "~1.0.1"
@@ -5438,6 +5911,10 @@ traverse@0.6.6:
version "0.6.6"
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"
+trim-newlines@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
+
trim-right@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
@@ -5464,20 +5941,20 @@ type-check@~0.3.2:
dependencies:
prelude-ls "~1.1.2"
-type-is@~1.6.14:
- version "1.6.14"
- resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.14.tgz#e219639c17ded1ca0789092dd54a03826b817cb2"
+type-is@~1.6.15:
+ version "1.6.15"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410"
dependencies:
media-typer "0.3.0"
- mime-types "~2.1.13"
+ mime-types "~2.1.15"
-typedarray@^0.0.6, typedarray@~0.0.5:
+typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-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"
+uglify-js@^2.6, uglify-js@^2.8.29:
+ version "2.8.29"
+ resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
dependencies:
source-map "~0.5.1"
yargs "~3.10.0"
@@ -5488,6 +5965,14 @@ uglify-to-browserify@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
+uglifyjs-webpack-plugin@^0.4.6:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309"
+ dependencies:
+ source-map "^0.5.6"
+ uglify-js "^2.8.29"
+ webpack-sources "^1.0.1"
+
uid-number@~0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
@@ -5496,6 +5981,10 @@ ultron@1.0.x:
version "1.0.2"
resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa"
+ultron@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864"
+
unc-path-regex@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
@@ -5538,6 +6027,19 @@ update-notifier@0.5.0:
semver-diff "^2.0.0"
string-length "^1.0.0"
+url-loader@^0.5.8:
+ version "0.5.8"
+ resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.8.tgz#b9183b1801e0f847718673673040bc9dc1c715c5"
+ dependencies:
+ loader-utils "^1.0.2"
+ mime "1.3.x"
+
+url-parse-lax@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+ dependencies:
+ prepend-http "^1.0.1"
+
url-parse@1.0.x:
version "1.0.5"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b"
@@ -5545,13 +6047,24 @@ url-parse@1.0.x:
querystringify "0.0.x"
requires-port "1.0.x"
-url-parse@^1.0.1, url-parse@^1.1.1:
+url-parse@^1.0.1:
version "1.1.7"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.7.tgz#025cff999653a459ab34232147d89514cc87d74a"
dependencies:
querystringify "0.0.x"
requires-port "1.0.x"
+url-parse@^1.1.8:
+ version "1.1.9"
+ resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.9.tgz#c67f1d775d51f0a18911dd7b3ffad27bb9e5bd19"
+ dependencies:
+ querystringify "~1.0.0"
+ requires-port "1.0.x"
+
+url-to-options@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
+
url@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@@ -5565,9 +6078,9 @@ user-home@^2.0.0:
dependencies:
os-homedir "^1.0.0"
-useragent@^2.1.10:
- version "2.1.12"
- resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.1.12.tgz#aa7da6cdc48bdc37ba86790871a7321d64edbaa2"
+useragent@^2.1.12:
+ version "2.1.13"
+ resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.1.13.tgz#bba43e8aa24d5ceb83c2937473e102e21df74c10"
dependencies:
lru-cache "2.2.x"
tmp "0.0.x"
@@ -5601,9 +6114,9 @@ validate-npm-package-license@^3.0.1:
spdx-correct "~1.0.0"
spdx-expression-parse "~1.0.0"
-vary@~1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140"
+vary@~1.1.0, vary@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37"
vendors@^1.0.0:
version "1.0.1"
@@ -5650,9 +6163,11 @@ vue-loader@^11.3.4:
vue-style-loader "^2.0.0"
vue-template-es2015-compiler "^1.2.2"
-vue-resource@^0.9.3:
- version "0.9.3"
- resolved "https://registry.yarnpkg.com/vue-resource/-/vue-resource-0.9.3.tgz#ab46e1c44ea219142dcc28ae4043b3b04c80959d"
+vue-resource@^1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/vue-resource/-/vue-resource-1.3.4.tgz#9fc0bdf6a2f5cab430129fc99d347b3deae7b099"
+ dependencies:
+ got "^7.0.0"
vue-style-loader@^2.0.0:
version "2.0.5"
@@ -5676,12 +6191,12 @@ vue@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed"
-watchpack@^1.3.1:
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.3.1.tgz#7d8693907b28ce6013e7f3610aa2a1acf07dad87"
+watchpack@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac"
dependencies:
async "^2.1.2"
- chokidar "^1.4.3"
+ chokidar "^1.7.0"
graceful-fs "^4.1.2"
wbuf@^1.1.0, wbuf@^1.4.0:
@@ -5690,50 +6205,56 @@ wbuf@^1.1.0, wbuf@^1.4.0:
dependencies:
minimalistic-assert "^1.0.0"
-webpack-bundle-analyzer@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.3.0.tgz#0d05e96a43033f7cc57f6855b725782ba61e93a4"
+webpack-bundle-analyzer@^2.8.2:
+ version "2.8.2"
+ resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.8.2.tgz#8b6240c29a9d63bc72f09d920fb050adbcce9fe8"
dependencies:
- acorn "^4.0.11"
+ acorn "^5.0.3"
chalk "^1.1.3"
commander "^2.9.0"
- ejs "^2.5.5"
- express "^4.14.1"
- filesize "^3.5.4"
+ ejs "^2.5.6"
+ express "^4.15.2"
+ filesize "^3.5.9"
gzip-size "^3.0.0"
lodash "^4.17.4"
mkdirp "^0.5.1"
- opener "^1.4.2"
+ opener "^1.4.3"
+ ws "^2.3.1"
-webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.9.0:
- version "1.10.0"
- resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.10.0.tgz#7d5be2651e692fddfafd8aaed177c16ff51f0eb8"
+webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.11.0:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.11.0.tgz#09691d0973a30ad1f82ac73a12e2087f0a4754f9"
dependencies:
memory-fs "~0.4.1"
mime "^1.3.4"
path-is-absolute "^1.0.0"
range-parser "^1.0.3"
-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"
+webpack-dev-server@^2.6.1:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.6.1.tgz#0b292a9da96daf80a65988f69f87b4166e5defe7"
dependencies:
ansi-html "0.0.7"
+ bonjour "^3.5.0"
chokidar "^1.6.0"
compression "^1.5.2"
connect-history-api-fallback "^1.3.0"
+ del "^3.0.0"
express "^4.13.3"
html-entities "^1.2.0"
http-proxy-middleware "~0.17.4"
+ internal-ip "^1.2.0"
+ loglevel "^1.4.1"
opn "4.0.2"
portfinder "^1.0.9"
+ selfsigned "^1.9.1"
serve-index "^1.7.2"
sockjs "0.3.18"
- sockjs-client "1.1.2"
+ sockjs-client "1.1.4"
spdy "^3.4.1"
strip-ansi "^3.0.0"
supports-color "^3.1.1"
- webpack-dev-middleware "^1.9.0"
+ webpack-dev-middleware "^1.11.0"
yargs "^6.0.0"
webpack-sources@^0.1.0:
@@ -5743,37 +6264,43 @@ webpack-sources@^0.1.0:
source-list-map "~0.1.7"
source-map "~0.5.3"
-webpack-sources@^0.2.3:
- version "0.2.3"
- resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.2.3.tgz#17c62bfaf13c707f9d02c479e0dcdde8380697fb"
+webpack-sources@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf"
dependencies:
- source-list-map "^1.1.1"
+ source-list-map "^2.0.0"
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"
+webpack-stats-plugin@^0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/webpack-stats-plugin/-/webpack-stats-plugin-0.1.5.tgz#29e5f12ebfd53158d31d656a113ac1f7b86179d9"
+
+webpack@^3.5.4:
+ version "3.5.4"
+ resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.5.4.tgz#5583eb263ed27b78b5bd17bfdfb0eb1b1cd1bf81"
dependencies:
- acorn "^4.0.4"
+ acorn "^5.0.0"
acorn-dynamic-import "^2.0.0"
- ajv "^4.7.0"
- ajv-keywords "^1.1.1"
+ ajv "^5.1.5"
+ ajv-keywords "^2.0.0"
async "^2.1.2"
- enhanced-resolve "^3.0.0"
+ enhanced-resolve "^3.4.0"
+ escope "^3.6.0"
interpret "^1.0.0"
json-loader "^0.5.4"
+ json5 "^0.5.1"
loader-runner "^2.3.0"
- loader-utils "^0.2.16"
+ loader-utils "^1.1.0"
memory-fs "~0.4.1"
mkdirp "~0.5.0"
node-libs-browser "^2.0.0"
source-map "^0.5.3"
- supports-color "^3.1.0"
- tapable "~0.2.5"
- uglify-js "^2.8.5"
- watchpack "^1.3.1"
- webpack-sources "^0.2.3"
- yargs "^6.0.0"
+ supports-color "^4.2.1"
+ tapable "^0.2.7"
+ uglifyjs-webpack-plugin "^0.4.6"
+ watchpack "^1.4.0"
+ webpack-sources "^1.0.1"
+ yargs "^8.0.2"
websocket-driver@>=0.3.6, websocket-driver@>=0.5.1:
version "0.6.5"
@@ -5793,7 +6320,11 @@ which-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
-which@^1.1.1, which@~1.2.10:
+which-module@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
+
+which@^1.1.1, which@^1.2.1, which@^1.2.9:
version "1.2.12"
resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192"
dependencies:
@@ -5846,13 +6377,20 @@ write@^0.2.1:
dependencies:
mkdirp "^0.5.1"
-ws@1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.1.tgz#082ddb6c641e85d4bb451f03d52f06eabdb1f018"
+ws@1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.2.tgz#8a244fa052401e08c9886cf44a85189e1fd4067f"
dependencies:
options ">=0.0.5"
ultron "1.0.x"
+ws@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-2.3.1.tgz#6b94b3e447cb6a363f785eaf94af6359e8e81c80"
+ dependencies:
+ safe-buffer "~5.0.1"
+ ultron "~1.1.0"
+
wtf-8@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a"
@@ -5885,6 +6423,12 @@ yargs-parser@^4.2.0:
dependencies:
camelcase "^3.0.0"
+yargs-parser@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9"
+ dependencies:
+ camelcase "^4.1.0"
+
yargs@^6.0.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-6.6.0.tgz#782ec21ef403345f830a808ca3d513af56065208"
@@ -5903,6 +6447,24 @@ yargs@^6.0.0:
y18n "^3.2.1"
yargs-parser "^4.2.0"
+yargs@^8.0.2:
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360"
+ dependencies:
+ camelcase "^4.1.0"
+ cliui "^3.2.0"
+ decamelize "^1.1.1"
+ get-caller-file "^1.0.1"
+ os-locale "^2.0.0"
+ read-pkg-up "^2.0.0"
+ require-directory "^2.1.1"
+ require-main-filename "^1.0.1"
+ set-blocking "^2.0.0"
+ string-width "^2.0.0"
+ which-module "^2.0.0"
+ y18n "^3.2.1"
+ yargs-parser "^7.0.0"
+
yargs@~3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
@@ -5912,12 +6474,6 @@ yargs@~3.10.0:
decamelize "^1.0.0"
window-size "0.1.0"
-yauzl@2.4.1:
- version "2.4.1"
- resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005"
- dependencies:
- fd-slicer "~1.0.1"
-
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"